BPF для самых маленьких, часть первая: extended BPF

В начале была технология и называлась она BPF. Мы посмотрели на нее в предыдущей, ветхозаветной, статье этого цикла. В 2013 году усилиями Алексея Старовойтова (Alexei Starovoitov) и Даниэля Боркмана (Daniel Borkman) была разработана и включена в ядро Linux ее усовершенствованная версия, оптимизированная под современные 64-битные машины. Эта новая технология недолгое время носила название Internal BPF, затем была переименована в Extended BPF, а теперь, по прошествии нескольких лет, все ее называют просто BPF.

Грубо говоря, BPF позволяет запускать произвольный код, предоставляемый пользователем, в пространстве ядра Linux и новая архитектура оказалась настолько удачной, что нам потребуется еще с десяток статей, чтобы описать все ее применения. (Единственное с чем не справились разработчики, как вы можете видеть на кпдв ниже, это с созданием приличного логотипа.)

В этой статье описывается строение виртуальной машины BPF, интерфейсы ядра для работы с BPF, средства разработки, а также краткий, очень краткий, обзор существующих возможностей, т.е. всё то, что нам потребуется в дальнейшем для более глубокого изучения практических применений BPF.
ebcxcqjpcedlop51jeh-bvbhyfy.png


Краткое содержание статьи

Введение в архитектуру BPF. Вначале мы посмотрим на архитектуру BPF с высоты птичьего полета и обозначим основные компоненты.

Регистры и система команд виртуальной машины BPF. Уже имея представление об архитектуре в целом, мы опишем строение виртуальной машины BPF.

Жизненный цикл объектов BPF, файловая система bpffs. В этом разделе мы более пристально посмотрим на жизненный цикл объектов BPF — программ и мапов.

Управление объектами при помощи системного вызова bpf. Имея уже некоторое представление о системе мы, наконец, посмотрим на то, как создавать и управлять объектами из пространства пользователя при помощи специального системного вызова — bpf(2).

Пишем программы BPF с помощью libbpf. Писать программы при помощи системного вызова, конечно, можно. Но сложно. Для более реалистичного сценария ядерными программистами была разработана библиотека libbpf. Мы создадим простейший скелет приложения BPF, который мы будем использовать в последующих примерах.

Kernel Helpers. Здесь мы узнаем как программы BPF могут обращаться к функциям-помощникам ядра — инструменту, который, наряду с мапами, принципиально расширяет возможности нового BPF по сравнению с классическим.

Доступ к maps из программ BPF. К этому моменту мы будем знать достаточно, чтобы понять как именно можно создавать программы, использующие мапы. И даже одним глазком заглянем в великий и могучий verifier.

Средства разработки. Справочный раздел о том, как собрать требуемые утилиты и ядро для экспериментов.

Заключение. В конце статьи те, кто дотуда дочитает, найдут мотивирующие слова и краткое описание того, что будет в следующих статьях. Мы также перечислим некоторое количество ссылок для самостоятельного изучения для тех, у кого нет желания или возможности ждать продолжения.


Введение в архитектуру BPF

Перед тем как начать рассматривать архитектуру BPF мы в последний раз (ой ли) сошлемся на классический BPF, который был разработан как ответ на появление RISC машин и решал проблему эффективной фильтрации пакетов. Архитектура получилась настолько удачной, что, родившись в лихие девяностые в Berkeley UNIX, она была портирована на большинство существующих операционных систем, дожила до безумных двадцатых и до сих пор находит новые применения.

Новый BPF был разработан как ответ на повсеместное распространение 64-битных машин, облачных сервисов и возросших потребностей в инструментах для создания SDN (Software-defined networking). Разработанный сетевыми инженерами ядра как усовершенствованная замена классического BPF, новый BPF буквально через полгода нашел применения в нелегком деле трассировки Linux систем, а сейчас, через шесть лет после появления, нам потребуется целая, следующая, статья только для того, чтобы перечислить разные типы программ.


ВеСёЛыЕ КаРтИнКи

В своей основе BPF — это виртуальная машина-песочница, позволяющая запускать «произвольный» код в пространстве ядра без ущерба для безопасности. Программы BPF создаются в пространстве пользователя, загружаются в ядро и подсоединяются к какому-нибудь источнику событий. Событием может быть, например, доставка пакета на сетевой интерфейс, запуск какой-нибудь функции ядра, и т.п. В случае пакета программе BPF будут доступны данные и метаданные пакета (на чтение и, может, на запись, в зависимости от типа программы), в случае запуска функции ядра — аргументы функции, включая указатели на память ядра, и т.п.

Давайте посмотрим на этот процесс подробнее. Для начала расскажем про первое отличие от классического BPF, программы для которого писались на ассемблере. В новой версии архитектура была дополнена так, что программы стало можно писать на языках высокого уровня, в первую очередь, конечно, на C. Для этого был разработан бакенд для llvm, позволяющий генерировать байт-код для архитектуры BPF.

0px295mnsbzwd9pmkqbtswwk-nc.png

Архитектура BPF разрабатывалась, в частности, для того, чтобы эффективно выполняться на современных машинах. Для того, чтобы это работало на практике, байт-код BPF, после загрузки в ядро транслируется в нативный код при помощи компонента под названием JIT compiler (Just In Time). Далее, если вы помните, в классическом BPF программа загружалась в ядро и присоединялась к источнику событий атомарно — в контексте одного системного вызова. В новой архитектуре это происходит в два этапа — сначала код загружается в ядро при помощи системного вызова bpf(2), а затем, позднее, при помощи других механизмов, разных в зависимости от типа программы, программа подсоединяется (attaches) к источнику событий.

Тут у читателя может возникнуть вопрос:, а что, так можно было? Каким образом гарантируется безопасность выполнения такого кода? Безопасность выполнения гарантируется нам этапом загрузки программ BPF под названием верификатор (по-английски этот этап называется verifier и я дальше буду использовать английское слово):

l8ilpkcnogyoaf73qwsljfvevjo.png

Verifier — это статический анализатор, который гарантирует, что программа не нарушит нормальный ход работы ядра. Это, кстати, не означает, что программа не может вмешаться в работу системы — программы BPF, в зависимости от типа, могут читать и переписывать участки памяти ядра, возвращаемые значения функций, обрезать, дополнять, переписывать и даже пересылать сетевые пакеты. Verifier гарантирует, что от запуска программы BPF ядро не свалится и что программа, которой по правилам доступны на запись, например, данные исходящего пакета, не сможет переписать память ядра вне пакета. Чуть более подробно мы посмотрим на verifier в соответствующем разделе, после того, как познакомимся со всеми остальными компонентами BPF.

Итак, что мы узнали к этому моменту? Пользователь пишет программу на языке C, загружает ее в ядро при помощи системного вызова bpf(2), где она проходит проверку на verifier и транслируется в нативный байткод. Затем тот же или другой пользователь подсоединяет программу к источнику событий и она начинает выполняться. Разделение загрузки и подсоединения нужно по нескольким причинам. Во-первых, запуск verifier — это относительно дорого и, загружая одну и ту же программу несколько раз, мы тратим компьютерное время впустую. Во-вторых, то, как именно подсоединяется программа, зависит от ее типа и один «универсальный» интерфейс, разработанный год назад может не подойти для новых типов программ. (Хотя сейчас, когда архитектура становится более зрелой, есть идея унифицировать этот интерфейс на уровне libbpf.)

Внимательный читатель может заметить, что мы еще не закончили с картинками. И правда, все сказанное выше не объясняет, чем BPF принципиально меняет картину по сравнению с классическим BPF. Два новшества, которые существенно расширяют границы применимости — это наличие возможности использовать разделяемую память и функции-помощники ядра (kernel helpers). В BPF разделяемая память реализована при помощи так называемых maps — разделяемых структур данных с определенным API. Такое название они получили, наверное, потому, что первым появившимся типом map была хэш-таблица. Дальше появились массивы, локальные (per-CPU) хэш-таблицы и локальные массивы, деревья поиска, мапы, содержащие указатели на программы BPF и многое другое. Нам сейчас интересен тот факт, что программы BPF получили возможность сохранять состояние между вызовами и разделять его с другими программами и с пространством пользователя.

Доступ к maps осуществляется из пользовательских процессов при помощи системного вызова bpf(2), а из программ BPF, работающих в ядре — при помощи функций-помощников. Более того, helpers существуют не только для работы с мапами, но и для доступа к другим возможностям ядра. Например, программы BPF могут использовать функции-помощники для перенаправления пакетов на другие интерфейсы, для генерации событий подсистемы perf, доступа к структурам ядра и т.п.

1wjosqevjc0ghneknvv56jdc4fo.png

Итого, BPF предоставляет возможность загружать произвольный, т.е., прошедший проверку на verifier, код пользователя в пространство ядра. Этот код может сохранять состояние между вызовами и обмениваться данными с пространством пользователя, а также имеет доступ к разрешенным данному типу программ подсистемам ядра.

Это уже похоже на возможности, предоставляемые модулями ядра, по сравнению с которыми у BPF есть некоторые преимущества (конечно, сравнивать можно только похожие приложения, например, трассировку системы — на BPF нельзя написать произвольный драйвер). Можно отметить более низкий порог входа (некоторые утилиты, использующие BPF не предполагают у пользователя наличие навыков программирования ядра, да и вообще навыков программирования), безопасность времени выполнения (поднимите руку в комментариях те, кто не ломал систему при написании или тестировании модулей), атомарность — при перезагрузке модулей есть время простоя, а подсистема BPF гарантирует, что ни одно событие не будет пропущено (справедливости ради, это верно не для всех типов программ BPF).

Наличие таких возможностей и делает BPF универсальным инструментом для расширения ядра, что подтверждается на практике: все новые и новые типы программ добавляются в BPF, все больше крупных компаний используют BPF на боевых серверах 24×7, все больше стартапов строят свой бизнес на решениях, в основе которых лежит BPF. BPF используется везде: в защите от DDoS атак, создании SDN (например, реализации сетей для kubernetes), в качестве основного инструмента трассировки систем и сборщика статистики, в системах обнаружения вторжения и в системах-песочницах и т.п.

Давайте на этом закончим обзорную часть статьи и посмотрим на виртуальную машину и на экосистему BPF более подробно.


Отступление: утилиты

Для того, чтобы иметь возможность запускать примеры из последующих разделов, вам может потребоваться некоторое количество утилит, минимум llvm/clang с поддержкой bpf и bpftool. В разделе Средства разработки можно прочитать инструкции по сборке утилит, а также своего ядра. Этот раздел помещен ниже, чтобы не нарушать стройность нашего изложения.


Регистры и система команд виртуальной машины BPF

Архитектура и система команд BPF разрабатывалась с учетом того, что программы будут писаться на языке C и после загрузки в ядро транслироваться в нативный код. Поэтому количество регистров и множество команд выбиралось с оглядкой на пересечение, в математическом смысле, возможностей современных машин. Кроме этого, на программы налагались разного рода ограничения, например, до недавнего времени не было возможности писать циклы и подпрограммы, а количество инструкций было ограничено 4096 (сейчас привилегированным программам можно загружать до миллиона инструкций).

В BPF имеется одиннадцать доступных пользователю 64-битных регистров r0r10 и счётчик команд (program counter). Регистр r10 содержит указатель на стек (frame pointer) и доступен только для чтения. Программам во время выполнения доступен стек в 512 байт и неограниченное количество разделяемой памяти в виде maps.

Программам BPF разрешается запускать определенный в зависимости от типа программы набор функций-помощников (kernel helpers) и, с недавних пор, и обычные функции. Каждая вызываемая функция может принимать до пяти аргументов, передаваемых в регистрах r1r5, а возвращаемое значение передается в r0. Гарантируется, что после возврата из функции содержание регистров r6r9 не изменится.

Для эффективной трансляции программ регистры r0r11 для всех поддерживаемых архитектур однозначно отображаются на настоящие регистры с учетом особенностей ABI текущей архитектуры. Например, для x86_64 регистры r1r5, использующиеся для передачи параметров функций, отображаются на rdi, rsi, rdx, rcx, r8, которые используются для передачи параметров в функции на x86_64. Например, код слева транслируется в код справа вот так:

1:  (b7) r1 = 1                    mov    $0x1,%rdi
2:  (b7) r2 = 2                    mov    $0x2,%rsi
3:  (b7) r3 = 3                    mov    $0x3,%rdx
4:  (b7) r4 = 4                    mov    $0x4,%rcx
5:  (b7) r5 = 5                    mov    $0x5,%r8
6:  (85) call pc+1                 callq  0x0000000000001ee8

Регистр r0 также используется для возврата результата выполнения программы, а в регистре r1 программе передается указатель на контекст — в зависимости от типа программы это может быть, например, структура struct xdp_md (для XDP) или структура struct __sk_buff (для разных сетевых программ) или структура struct pt_regs (для разных типов tracing программ) и т.п.

Итак, у нас был набор регистров, kernel helpers, стек, указатель на контекст и разделяемая память в виде maps. Не то, чтобы всё это было категорически необходимо в поездке, но…

Давайте продолжим описание и расскажем про систему команд для работы с этими объектами. Все (почти все) инструкции BPF имеют фиксированный 64-битный размер. Если вы посмотрите на одну инструкцию на 64-битной Big Endian машине, то вы увидите

eawjpniwfui7mpowjoa6nd6uths.png

Здесь Code — это кодировка инструкции, Dst/Src — это кодировки приемника и источника, соответственно, Off — 16-битный знаковый отступ, а Imm — это 32-битное знаковое целое число, используемое в некоторых командах (аналог константы K из cBPF). Кодировка Code имеет один из двух видов:

_3fccl4c0qb2v90wodmua08s2ps.png

Классы инструкций 0, 1, 2, 3 определяют команды для работы с памятью. Они называются, BPF_LD, BPF_LDX, BPF_ST, BPF_STX, соответственно. Классы 4, 7 (BPF_ALU, BPF_ALU64) составляют набор ALU инструкций. Классы 5, 6 (BPF_JMP, BPF_JMP32) заключают в себе инструкции перехода.

Дальнейший план изучения системы команд BPF такой: вместо того, чтобы дотошно перечислять все инструкции и их параметры, мы разберем пару примеров в этом разделе и из них станет ясно, как устроены инструкции на самом деле и как вручную дизассемблировать любой бинарный файл для BPF. Для закрепления материала дальше в статье мы еще встретимся с индивидуальными инструкциями в разделах про Verifier, JIT компилятор, трансляцию классического BPF, а также при изучении maps, вызове функций и т.п.

Когда мы будем говорить об индивидуальных инструкциях, мы будем ссылаться на файлы ядра bpf.h и bpf_common.h, в которых определяются численные коды инструкций BPF. При самостоятельном изучении архитектуры и/или разборе бинарников, семантику вы можете найти в следующих, отсортированных в порядке сложности, источниках: Unofficial eBPF spec, BPF and XDP Reference Guide, Instruction Set, Documentation/networking/filter.txt и, конечно, в исходных кодах Linux — verifier, JIT, интерпретатор BPF.


Пример: дизассемблируем BPF в уме

Давайте разберем пример, в котором мы скомпилируем программу readelf-example.c и посмотрим на получившийся бинарник. Мы раскроем оригинальное содержимое readelf-example.c ниже, после того как восстановим его логику из бинарных кодов:

$ clang -target bpf -c readelf-example.c -o readelf-example.o -O2
$ llvm-readelf -x .text readelf-example.o
Hex dump of section '.text':
0x00000000 b7000000 01000000 15010100 00000000 ................
0x00000010 b7000000 02000000 95000000 00000000 ................

Первый столбец в выводе readelf — это отступ и наша программа, таким образом, состоит из четырех команд:

Code Dst Src Off  Imm
b7   0   0   0000 01000000
15   0   1   0100 00000000
b7   0   0   0000 02000000
95   0   0   0000 00000000

Коды команд равны b7, 15, b7 и 95. Вспомним, что три младшие бита — это класс инструкции. В нашем случае четвертый бит у всех инструкций пустой, поэтому классы инструкций равны, соответственно, 7, 5, 7, 5. Класс 7 — это BPF_ALU64, а 5 — это BPF_JMP. Для обоих классов формат инструкции одинаковый (см. выше) и мы можем переписать нашу программу так (заодно перепишем остальные столбцы в человеческом виде):

Op S  Class   Dst Src Off  Imm
b  0  ALU64   0   0   0    1
1  0  JMP     0   1   1    0
b  0  ALU64   0   0   0    2
9  0  JMP     0   0   0    0

Операция b класса ALU64 — это BPF_MOV. Она присваивает значение регистру-приемнику. Если установлен бит s (source), то значение берется из регистра-источника, а если, как в нашем случае, он не установлен, то значение берется из поля Imm. Таким образом, в первой и третьей инструкциях мы выполняем операцию r0 = Imm. Далее, операция 1 класса JMP — это BPF_JEQ (jump if equal). В нашем случае, так как бит S равен нулю, она сравнивает значение регистра-источника с полем Imm. Если значения совпадают, то переход происходит на PC + Off, где PC, как водится, содержит адрес следующей инструкции. Наконец, операция 9 класса JMP — это BPF_EXIT. Эта инструкция завершает работу программы, возвращая ядру r0. Добавим новый столбец к нашей таблице:

Op    S  Class   Dst Src Off  Imm    Disassm
MOV   0  ALU64   0   0   0    1      r0 = 1
JEQ   0  JMP     0   1   1    0      if (r1 == 0) goto pc+1
MOV   0  ALU64   0   0   0    2      r0 = 2
EXIT  0  JMP     0   0   0    0      exit

Мы можем переписать это в более удобном виде:

     r0 = 1
     if (r1 == 0) goto END
     r0 = 2
END:
     exit

Если мы вспомним, что в регистре r1 программе передается указатель на контекст от ядра, а в регистре r0 в ядро возвращается значение, то мы можем увидеть, что если указатель на контекст равен нулю, то мы возвращаем 1, а в противном случае — 2. Проверим, что мы правы, посмотрев на исходник:

$ cat readelf-example.c
int foo(void *ctx)
{
        return ctx ? 2 : 1;
}

Да, это бессмысленная программа, но зато она транслируется всего в четыре простых инструкции.


Пример-исключение: 16-байтная инструкция

Ранее мы упомянули, что некоторые инструкции занимают больше, чем 64 бита. Это относится, например, к инструкции lddw (Code = 0x18 = BPF_LD | BPF_DW | BPF_IMM) — загрузить в регистр двойное слово из полей Imm. Дело в том, что Imm имеет размер 32, а двойное слово — 64 бита, поэтому загрузить в регистр 64-битное непосредственное значение в одной 64-битной инструкции не получится. Для этого две соседние инструкции используются для хранения второй части 64-битного значения в поле Imm. Пример:

$ cat x64.c
long foo(void *ctx)
{
        return 0x11223344aabbccdd;
}
$ clang -target bpf -c x64.c -o x64.o -O2
$ llvm-readelf -x .text x64.o
Hex dump of section '.text':
0x00000000 18000000 ddccbbaa 00000000 44332211 ............D3".
0x00000010 95000000 00000000                   ........

В бинарной программе всего две инструкции:

Binary                                 Disassm
18000000 ddccbbaa 00000000 44332211    r0 = Imm[0]|Imm[1]
95000000 00000000                      exit

Мы еще встретимся с инструкцией lddw, когда поговорим о релокациях и работе с maps.


Пример: дизассемблируем BPF стандартными средствами

Итак, мы научились читать бинарные коды BPF и готовы разобрать любую инструкцию, если потребуется. Однако, стоит сказать, что на практике удобнее и быстрее дизассемблировать программы при помощи стандартных средств, например:

$ llvm-objdump -d x64.o

Disassembly of section .text:

0000000000000000 :
 0: 18 00 00 00 dd cc bb aa 00 00 00 00 44 33 22 11 r0 = 1234605617868164317 ll
 2: 95 00 00 00 00 00 00 00 exit


Жизненный цикл объектов BPF, файловая система bpffs

(Некоторые подробности, описываемые в этом подразделе, я впервые узнал из поста Alexei Starovoitov в BPF Blog.)

Объекты BPF — программы и мапы — создаются из пространства пользователя при помощи команд BPF_PROG_LOAD и BPF_MAP_CREATE системного вызова bpf(2), мы поговорим про то как именно это происходит в следующем разделе. При этом создаются структуры данных ядра и для каждой из них refcount (счетчик ссылок) устанавливается равным единице, а пользователю возвращается файловый дескриптор, указывающий на объект. После закрытия дескриптора refcount объекта уменьшается на единицу, и при достижении им нуля объект уничтожается.

Если программа использует мапы, то refcount этих мапов увеличивается на единицу после загрузки программы, т.е. их файловые дескрипторы можно закрыть из пользовательского процесса и при этом refcount не станет нулем:

2d6tivzhrp7bb9-z0ttjm4i4cn4.png

После успешной загрузки программы мы обычно присоединяем ее к какому-нибудь генератору событий. Например, мы можем посадить ее на сетевой интерфейс для обработки входящих пакетов или подключить ее к какой-нибудь tracepoint в ядре. В этот момент счетчик ссылок тоже увеличится на единицу и мы сможем закрыть файловый дескриптор в программе-загрузчике.

Что случится если мы теперь завершим работу загрузчика? Это зависит от типа генератора событий (hook). Все сетевые хуки будут существовать после завершения загрузчика, это, так называемые, глобальные хуки. А, например, программы трассировки будут освобождены после завершения процесса, создавшего их (и поэтому называются локальными, от «local to the process»). Технически, локальные хуки всегда имеют соответствующий файловый дескриптор в пространстве пользователя и поэтому закрываются с закрытием процесса, а глобальные — нет. На следующем рисунке я при помощи красных крестиков стараюсь показать как завершение программы-загрузчика влияет на время жизни объектов в случае локальных и глобальных хуков.

vkhdoalgmwvdj0kewke11pov_xu.png

Зачем существует разделение на локальные и глобальные хуки? Запуск некоторых типов сетевых программ имеет смысл и без userspace, например, представьте защиту от DDoS — загрузчик прописывает правила и подключает BPF программу к сетевому интерфейсу, после чего загрузчик может пойти и убиться. С другой стороны, представьте себе отладочную программу трассировки, которую вы написали на коленке за десять минут — после ее завершения вам бы хотелось, чтобы в системе не оставалось мусора, и локальные хуки это гарантируют.

С другой стороны, представьте, что вы хотите подсоединиться к tracepoint в ядре и собирать статистику в течение многих лет. В этом случае вам бы хотелось завершить пользовательскую часть и возвращаться к статистике время от времени. Такую возможность предоставляет файловая система bpf. Это псевдо-файловая система, существующая только в памяти, которая позволяет создавать файлы, ссылающиеся на объекты BPF и, тем самым, увеличивающие refcount объектов. После этого загрузчик может завершить работу, а созданные им объекты останутся живы.

qc5oh0vxhkoah5-bzlr-chgz_qk.png

Создание файлов в bpffs, ссылающихся на объекты BPF называется «закрепление» («pin», как в следующей фразе: «process can pin a BPF program or map»). Создание файловых объектов для объектов BPF имеет смысл не только для продления жизни локальных объектов, но и для удобства использования глобальных объектов — возвращаясь к примеру с глобальной программой для защиты от DDoS, мы хотим иметь возможность время от времени приходить и смотреть на статистику.

Файловая система BPF обычно монтируется в /sys/fs/bpf, но ее можно смонтировать и локально, например, так:

$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpoint

Имена в файловой системе создаются при помощи команды BPF_OBJ_PIN системного вызова BPF. В качестве иллюстрации давайте возьмем какую-нибудь программу, скомпилируем, загрузим и закрепим ее в bpffs. Наша программа не делает ничего полезного, мы приводим ее код только для того, чтобы вы могли воспроизвести пример:

$ cat test.c
__attribute__((section("xdp"), used))
int test(void *ctx)
{
        return 0;
}

char _license[] __attribute__((section("license"), used)) = "GPL";

Скомпилируем эту программу и создадим локальную копию файловой системы bpffs:

$ clang -target bpf -c test.c -o test.o
$ mkdir bpf-mountpoint
$ sudo mount -t bpf none bpf-mountpoint

Теперь загрузим нашу программу при помощи утилиты bpftool и посмотрим на сопутствующие системные вызовы bpf(2) (из вывода strace удалены некоторые не относящиеся к делу строки):

$ sudo strace -e bpf bpftool prog load ./test.o bpf-mountpoint/test
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, prog_name="test", ...}, 120) = 3
bpf(BPF_OBJ_PIN, {pathname="bpf-mountpoint/test", bpf_fd=3}, 120) = 0

Здесь мы загрузили программу при помощи BPF_PROG_LOAD, получили от ядра файловый дескриптор 3 и при помощи команды BPF_OBJ_PIN закрепили этот файловый дескриптор в виде файла "bpf-mountpoint/test". После этого программа-загрузчик bpftool закончила работу, но наша программа осталась в ядре, хотя мы и не прикрепляли ее ни к какому сетевому интерфейсу:

$ sudo bpftool prog | tail -3
783: xdp  name test  tag 5c8ba0cf164cb46c  gpl
        loaded_at 2020-05-05T13:27:08+0000  uid 0
        xlated 24B  jited 41B  memlock 4096B

Мы можем удалить файловый объект обычным unlink(2) и после этого соответствующая программа будет удалена:

$ sudo rm ./bpf-mountpoint/test
$ sudo bpftool prog show id 783
Error: get by id (783): No such file or directory


Удаление объектов

Говоря об удалении объектов, необходимо уточнить, что после того, как мы отключили программу от хука (генератора событий), ни одно новое событие не повлечет ее запуск, однако, все текущие экземпляры программы будут завершены в нормальном порядке.

Некоторые виды BPF программ позволяют подменять программу на лету, т.е. предоставляют атомарность последовательности replace = detach old program, attach new program. При этом все активные экземпляры старой версии программы закончат свою работу, а новые обработчики событий будут создаваться уже из новой программы, и «атомарность» означает здесь, что ни одно событие не будет пропущено.


Присоединение программ к источникам событий

В этой статье мы не будем отдельно описывать подсоединение программ к источникам событий, так как это имеет смысл изучать в контексте конкретного типа программы. См. пример ниже, в котором мы показываем как подсоединяются программы типа XDP.


Управление объектами при помощи системного вызова bpf


Программы BPF

Все объекты BPF создаются и управляются из пространства пользователя при помощи системного вызова bpf, имеющего следующий прототип:

#include 

int bpf(int cmd, union bpf_attr *attr, unsigned int size);

Здесь команда cmd — это одно из значений типа enum bpf_cmd, attr — указатель на параметры для конкретной программы и size — размер объекта по указателю, т.е. обычно это sizeof(*attr). В ядре 5.8 системный вызов bpf поддерживает 34 различных команды, а определение union bpf_attr занимает 200 строчек. Но нас не должно это пугать, так как мы будем знакомиться с командами и параметрами на протяжении нескольких статей.

Начнем мы с команды BPF_PROG_LOAD, которая создает программы BPF — берет набор инструкций BPF и загружает его в ядро. В момент загрузки запускается verifier, а потом JIT compiler и, после успешного выполнения, пользователю возвращается файловый дескриптор программы. Мы видели что с ним происходит дальше в предыдущем разделе про жизненный цикл объектов BPF.

Сейчас мы напишем пользовательскую программу, которая будет загружать простую программу BPF, но сначала нам нужно решить, что именно за программу мы хотим загрузить — нам придется выбрать тип и в рамках этого типа написать программу, которая пройдет проверку на verifier. Однако, чтобы не усложнять процесс, вот готовое решение: мы возьмем программу типа BPF_PROG_TYPE_XDP, которая будет возвращать значение XDP_PASS (пропустить все пакеты). На ассемблере BPF это выглядит очень просто:

r0 = 2
exit

После того, как мы определились с тем, что мы будем загружать, мы можем рассказать как мы это сделаем:

#define _GNU_SOURCE
#include 
#include 
#include 
#include 

static inline __u64 ptr_to_u64(const void *ptr)
{
        return (__u64) (unsigned long) ptr;
}

int main(void)
{
    struct bpf_insn insns[] = {
        {
            .code = BPF_ALU64 | BPF_MOV | BPF_K,
            .dst_reg = BPF_REG_0,
            .imm = XDP_PASS
        },
        {
            .code = BPF_JMP | BPF_EXIT
        },
    };

    union bpf_attr attr = {
        .prog_type = BPF_PROG_TYPE_XDP,
        .insns     = ptr_to_u64(insns),
        .insn_cnt  = sizeof(insns)/sizeof(insns[0]),
        .license   = ptr_to_u64("GPL"),
    };

    strncpy(attr.prog_name, "woo", sizeof(attr.prog_name));
    syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr));

    for ( ;; )
        pause();
}

Интересные события в программе начинаются с определения массива insns — нашей программы BPF в машинных кодах. При этом каждая инструкция программы BPF упаковывается в структуру bpf_insn. Первый элемент insns соответствует инструкции r0 = 2, второй — exit.

Отступление. В ядре определены более удобные макросы для написания машинных кодов, и, используя ядерный заголовочный файл tools/include/linux/filter.h мы могли бы написать

struct bpf_insn insns[] = {
    BPF_MOV64_IMM(BPF_REG_0, XDP_PASS),
    BPF_EXIT_INSN()
};

Но так как написание программ BPF в машинных кодах нужно только для написания тестов в ядре и статей про BPF, отсутствие этих макросов на самом деле не усложняет жизнь разработчика.

После определения программы BPF мы переходим к ее загрузке в ядро. Наш минималистский набор параметров attr включает в себя тип программы, набор и количество инструкций, обязательную лицензию, а также имя "woo", которое мы используем, чтобы найти нашу программу в системе после загрузки. Программа, как и было обещано, загружается в систему при помощи системного вызова bpf.

В конце программы мы попадаем в бесконечный цикл, который имитирует полезную нагрузку. Без него программа будет уничтожена ядром при закрытии файлового дескриптора, который возвратил нам системный вызов bpf, и мы не увидим ее в системе.

Ну что же, мы готовы к тестированию. Соберем и запустим программу под strace, чтобы проверить, что все работает как надо:

$ clang -g -O2 simple-prog.c -o simple-prog

$ sudo strace ./simple-prog
execve("./simple-prog", ["./simple-prog"], 0x7ffc7b553480 /* 13 vars */) = 0
...
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=2, insns=0x7ffe03c4ed50, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_V
ERSION(0, 0, 0), prog_flags=0, prog_name="woo", prog_ifindex=0, expected_attach_type=BPF_CGROUP_INET_INGRESS}, 72) = 3
pause(

Все в порядке, bpf(2) вернул нам дескриптор 3 и мы отправились в бесконечный цикл с pause(). Давайте попробуем найти нашу программу в системе. Для этого мы пойдем в другой терминал и используем утилиту bpftool:

# bpftool prog | grep -A3 woo
390: xdp  name woo  tag 3b185187f1855c4c  gpl
        loaded_at 2020-08-31T24:66:44+0000  uid 0
        xlated 16B  jited 40B  memlock 4096B
        pids simple-prog(10381)

Мы видим, что в системе имеется загруженная программа woo чей глобальный ID равен 390, и что в данный момент в процессе simple-prog имеется открытый файловый дескриптор, указывающий на программу (и если simple-prog завершит работу, то woo исчезнет). Как и ожидалось, программа woo занимает 16 байт — две инструкции — бинарных кодов в архитектуре BPF, но в нативном виде (x86_64) — это уже 40 байт. Давайте посмотрим на нашу программу в оригинальном виде:

# bpftool prog dump xlated id 390
   0: (b7) r0 = 2
   1: (95) exit

без сюрпризов. Теперь посмотрим на код, созданный JIT компилятором:

# bpftool prog dump jited id 390
bpf_prog_3b185187f1855c4c_woo:
   0:   nopl   0x0(%rax,%rax,1)
   5:   push   %rbp
   6:   mov    %rsp,%rbp
   9:   sub    $0x0,%rsp
  10:   push   %rbx
  11:   push   %r13
  13:   push   %r14
  15:   push   %r15
  17:   pushq  $0x0
  19:   mov    $0x2,%eax
  1e:   pop    %rbx
  1f:   pop    %r15
  21:   pop    %r14
  23:   pop    %r13
  25:   pop    %rbx
  26:   leaveq
  27:   retq

не очень-то эффективно для exit(2), но справедливости ради, наша программа слишком уж проста, а для нетривиальных программ пролог и эпилог, добавленные JIT компилятором, конечно, нужны.


Maps

Программы BPF могут использовать структурированные области памяти, доступные как другим программам BPF, так и программам из пространства пользователя. Эти объекты называются maps и в этом разделе мы покажем как управлять ими при помощи системного вызова bpf.

Сразу скажем, что возможности maps не ограничиваются только доступом к общей памяти. Существуют мапы специального назначения, содержащие, например, указатели на программы BPF или указатели на сетевые интерфейсы, мапы для работы с perf events и т.п. Здесь мы о них говорить не будем, чтобы не путать читателя. Кроме этого, мы игнорируем проблемы синхронизации, так как это не важно для наших примеров. Полный список доступных типов мапов можно найти в , а в этом разделе мы в качестве примера возьмем исторически первый тип, хэш-таблицу BPF_MAP_TYPE_HASH.

Если вы создаете хэш-таблицу, скажем, в C++, вы скажете unordered_map woo, что по-русски означает «мне нужна таблица woo неограниченного размера, у которой ключи имеют тип int, а значения — тип long». Для того, чтобы создать хэш-таблицу BPF мы должны сделать примерно то же самое, с поправкой на то, что нам придется указать максимальный размер таблицы, а вместо типов ключей и значений нам нужно указать их размеры в байтах. Для создания мапов используется команда BPF_MAP_CREATE системного вызова bpf. Давайте посмотрим на более-менее минимальную программу, которая создает map. После предыдущей программы, загружающей программы BPF, эта должна вам показаться простой:

$ cat simple-map.c
#define _GNU_SOURCE
#include 
#include 
#include 
#include 

int main(void)
{
    union bpf_attr attr = {
        .map_type = BPF_MAP_TYPE_HASH,
        .key_size = sizeof(int),
        .value_size = sizeof(int),
        .max_entries = 4,
    };
    strncpy(attr.map_name, "woo", sizeof(attr.map_name));
    syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr));

    for ( ;; )
        pause();
}

Зд

© Habrahabr.ru