BPF для найменших, частина перша: extended BPF

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

Грубо кажучи, BPF дозволяє запускати довільний код, що надається користувачем, у просторі ядра Linux і нова архітектура виявилася настільки вдалою, що нам потрібно ще з десяток статей, щоб описати всі її застосування. (Єдине з чим не впоралися розробники, як ви можете бачити на ккдв нижче, це зі створенням пристойного логотипу.)

У статті описується будова віртуальної машини BPF, інтерфейси ядра до роботи з BPF, кошти розробки, і навіть короткий, дуже короткий, огляд існуючих можливостей, тобто. все те, що нам буде потрібно надалі для більш глибокого вивчення практичних застосувань BPF.
BPF для найменших, частина перша: extended BPF

Короткий зміст статті

Введення до архітектури 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-dуточнений networking). Розроблений мережевими інженерами ядра як удосконалена заміна класичного BPF, новий BPF буквально через півроку знайшов застосування у нелегкій справі трасування Linux систем, а зараз, через шість років після появи, нам буде потрібна ціла, наступна, стаття лише для того, щоб перерахувати різні типи програм.

Веселі картинки

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

Погляньмо на цей процес докладніше. Спочатку розповімо про першу відмінність від класичного BPF, програми для якого писалися на асемблері. У новій версії архітектура була доповнена так, що програми можна писати мовами високого рівня, в першу чергу, звичайно, на C. Для цього був розроблений бакенд для llvm, що дозволяє генерувати байт-код для архітектури BPF.

BPF для найменших, частина перша: extended BPF

Архітектура BPF розроблялася зокрема для того, щоб ефективно виконуватися на сучасних машинах. Для того, щоб це працювало на практиці, байт-код BPF після завантаження в ядро ​​транслюється в нативний код за допомогою компонента під назвою JIT compiler (Just In Time). Далі, якщо ви пам'ятаєте, у класичному BPF програма завантажувалася в ядро ​​та приєднувалася до джерела подій атомарно – у контексті одного системного виклику. У новій архітектурі це відбувається у два етапи – спочатку код завантажується в ядро ​​за допомогою системного виклику bpf(2)а потім, пізніше, за допомогою інших механізмів, різних залежно від типу програми, програма приєднується (attaches) до джерела подій.

Тут у читача може виникнути питання: а що так можна було? Як гарантується безпека виконання такого коду? Безпека виконання гарантується нам етапом завантаження програм BPF під назвою верифікатор (англійською цей етап називається verifier і я далі використовуватиму англійське слово):

BPF для найменших, частина перша: extended BPF

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, доступу до структур ядра і т.п.

BPF для найменших, частина перша: extended BPF

Разом, 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-бітних регістрів r0-r10 та лічильник команд (program counter). Реєстр r10 містить покажчик на стек (frame pointer) і доступний лише читання. Програмам під час виконання доступний стек 512 байт і необмежену кількість пам'яті, що розділяється, у вигляді maps.

Програмам BPF дозволяється запускати певний залежно від типу програми набір функцій-помічників (kernel helpers) і з недавніх пір і звичайні функції. Кожна функція, що викликається, може приймати до п'яти аргументів, що передаються в регістрах r1-r5, а значення, що повертається, передається в r0. Гарантується, що після повернення з функції утримання регістрів r6-r9 не зміниться.

Для ефективної трансляції програм регістри r0-r11 для всіх архітектур, що підтримуються, однозначно відображаються на справжні регістри з урахуванням особливостей ABI поточної архітектури. Наприклад, для x86_64 регістри r1-r5, що використовуються для передачі параметрів функцій, відображаються на 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 машині, то ви побачите

BPF для найменших, частина перша: extended BPF

Тут Code - це кодування інструкції, Dst/Src — це кодування приймача та джерела, відповідно, Off - 16-бітовий знаковий відступ, а Imm - Це 32-бітове знакове ціле число, що використовується в деяких командах (аналог константи K з cBPF). Кодування Code має один із двох видів:

BPF для найменших, частина перша: extended BPF

Класи інструкцій 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 <foo>:
 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 не стане банкрутом:

BPF для найменших, частина перша: extended BPF

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

Що станеться, якщо ми тепер завершимо роботу завантажувача? Це від типу генератора подій (hook). Всі мережеві хуки будуть існувати після завершення завантажувача, це так звані глобальні хуки. А, наприклад, програми трасування будуть звільнені після завершення процесу, що їх створив (і тому називаються локальними, від «local to the process»). Технічно, локальні хуки завжди мають відповідний файловий дескриптор у просторі користувача і тому закриваються із закриттям процесу, а глобальні – ні. На наступному малюнку я за допомогою червоних хрестиків намагаюся показати, як завершення програми-завантажувача впливає на час життя об'єктів у разі локальних та глобальних хуків.

BPF для найменших, частина перша: extended BPF

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

З іншого боку, уявіть, що ви хочете приєднатися до tracepoint в ядрі і збирати статистику протягом багатьох років. У цьому випадку вам хотілося б завершити користувальницьку частину і повертатися до статистики час від часу. Таку можливість надає файлова система BPF. Це псевдо-файлова система, що існує тільки в пам'яті, яка дозволяє створювати файли, що посилаються на об'єкти BPF і тим самим збільшують refcount об'єктів. Після цього завантажувач може завершити роботу, а створені ним об'єкти залишаться живими.

BPF для найменших, частина перша: extended BPF

Створення файлів у bpffs, що посилаються на об'єкти BPF називається "закріплення" ("pin", як у наступній фразі: "процес 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 <linux/bpf.h>

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 <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/bpf.h>

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 компілятором, звичайно, потрібні.

карти

Програми BPF можуть використовувати структуровані області пам'яті, доступні як іншим BPF, так і програмам з простору користувача. Ці об'єкти називаються maps і в цьому розділі ми покажемо, як керувати ними за допомогою системного виклику bpf.

Відразу скажемо, що можливості maps не обмежуються лише доступом до спільної пам'яті. Існують карти спеціального призначення, які містять, наприклад, покажчики на програми BPF або покажчики на мережеві інтерфейси, карти для роботи з perf events і т.п. Тут ми про них не говоритимемо, щоб не плутати читача. Крім цього, ми ігноруємо проблеми синхронізації, оскільки це не має значення для наших прикладів. Повний список доступних типів карт можна знайти в <linux/bpf.h>, а в цьому розділі ми як приклад візьмемо історично перший тип, хеш-таблицю BPF_MAP_TYPE_HASH.

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

$ cat simple-map.c
#define _GNU_SOURCE
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <linux/bpf.h>

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();
}

Тут ми визначаємо набір параметрів attr, в якому говоримо «Мені потрібна хеш-таблиця з ключами та значеннями розміру sizeof(int), В яку я можу покласти максимум чотири елементи ». При створенні BPF карт можна вказувати й інші параметри, наприклад, так само, як і в прикладі з програмою, ми вказали назву об'єкта як "woo".

Скомпілюємо та запустимо програму:

$ clang -g -O2 simple-map.c -o simple-map
$ sudo strace ./simple-map
execve("./simple-map", ["./simple-map"], 0x7ffd40a27070 /* 14 vars */) = 0
...
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_HASH, key_size=4, value_size=4, max_entries=4, map_name="woo", ...}, 72) = 3
pause(

Тут системний виклик bpf(2) повернув нам дескриптор. 3 і далі програма, як і очікувалося, чекає подальших вказівок у системному виклику pause(2).

Тепер відправимо нашу програму в background або відкриємо інший термінал та подивимося на наш об'єкт за допомогою утиліти bpftool (ми можемо відрізнити наш map від інших на його ім'я):

$ sudo bpftool map
...
114: hash  name woo  flags 0x0
        key 4B  value 4B  max_entries 4  memlock 4096B
...

Число 114 – це глобальний ID нашого об'єкта. Будь-яка програма в системі може використовувати цей ID, щоб відкрити існуючий map за допомогою команди BPF_MAP_GET_FD_BY_ID системного виклику bpf.

Тепер ми можемо грати з нашою хеш-таблицею. Давайте подивимося на її вміст:

$ sudo bpftool map dump id 114
Found 0 elements

Порожньо. Давайте покладемо на неї значення hash[1] = 1:

$ sudo bpftool map update id 114 key 1 0 0 0 value 1 0 0 0

Подивимося на таблицю ще раз:

$ sudo bpftool map dump id 114
key: 01 00 00 00  value: 01 00 00 00
Found 1 element

Ура! У нас вдалося додати один елемент. Зауважте, що для цього нам доводиться працювати на рівні байтів, оскільки bptftool не знає який тип мають значення у хеш-таблиці. (Їй можна передати це знання, використовуючи BTF, але про це не зараз.)

Як саме bpftool читає та додає елементи? Давайте заглянемо під капот:

$ sudo strace -e bpf bpftool map dump id 114
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=NULL, next_key=0x55856ab65280}, 120) = 0
bpf(BPF_MAP_LOOKUP_ELEM, {map_fd=3, key=0x55856ab65280, value=0x55856ab652a0}, 120) = 0
key: 01 00 00 00  value: 01 00 00 00
bpf(BPF_MAP_GET_NEXT_KEY, {map_fd=3, key=0x55856ab65280, next_key=0x55856ab65280}, 120) = -1 ENOENT

Спочатку ми відкрили картку по його глобальному ID за допомогою команди BPF_MAP_GET_FD_BY_ID и bpf(2) повернув нам дескриптор 3. Далі за допомогою команди BPF_MAP_GET_NEXT_KEY ми знайшли перший ключ у таблиці, передавши NULL як покажчик на «попередній» ключ. За наявності ключа ми можемо зробити BPF_MAP_LOOKUP_ELEM, який повертає значення в покажчик value. Наступний крок – ми намагаємося знайти наступний елемент, передаючи покажчик на поточний ключ, але наша таблиця містить лише один елемент та команда BPF_MAP_GET_NEXT_KEY повертає ENOENT.

Добре, давайте змінимо значення за ключом 1, скажімо, наша бізнес-логіка вимагає прописати hash[1] = 2:

$ sudo strace -e bpf bpftool map update id 114 key 1 0 0 0 value 2 0 0 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=3, key=0x55dcd72be260, value=0x55dcd72be280, flags=BPF_ANY}, 120) = 0

Як і очікувалося, це дуже просто: команда BPF_MAP_GET_FD_BY_ID відкриває наш мап по ID, а команда BPF_MAP_UPDATE_ELEM перезаписує елемент.

Отже, після створення хеш-таблиці з однієї програми ми можемо читати та писати її вміст з іншої. Зверніть увагу, що якщо ми змогли це зробити з командного рядка, то це може і будь-яка інша програма в системі. Крім команд, описаних вище, для роботи з картами з простору користувача доступні наступні:

  • BPF_MAP_LOOKUP_ELEM: знайти значення по ключу
  • BPF_MAP_UPDATE_ELEM: оновити/створити значення
  • BPF_MAP_DELETE_ELEM: видалити ключ
  • BPF_MAP_GET_NEXT_KEY: знайти наступний (або перший) ключ
  • BPF_MAP_GET_NEXT_ID: дозволяє пройтися по всіх існуючих картах, так працює bpftool map
  • BPF_MAP_GET_FD_BY_ID: відкрити існуючий карток за його глобальним ID
  • BPF_MAP_LOOKUP_AND_DELETE_ELEM: атомарно оновити значення об'єкта та повернути старе
  • BPF_MAP_FREEZE: зробити мап незмінним з userspace (цю операцію не можна скасувати)
  • BPF_MAP_LOOKUP_BATCH, BPF_MAP_LOOKUP_AND_DELETE_BATCH, BPF_MAP_UPDATE_BATCH, BPF_MAP_DELETE_BATCH: масові операції. Наприклад, BPF_MAP_LOOKUP_AND_DELETE_BATCH — це єдиний надійний спосіб прочитати та обнулити всі значення з карти

Не всі з цих команд працюють для всіх типів карт, але взагалі робота з іншими типами maps з простору користувача виглядає так само, як і робота з хеш-таблицями.

Для порядку, давайте завершимо наші експерименти з хеш-таблицею. Пам'ятаєте, що ми створили таблицю, у якій може бути до чотирьох ключів? Додамо ще кілька елементів:

$ sudo bpftool map update id 114 key 2 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 3 0 0 0 value 1 0 0 0
$ sudo bpftool map update id 114 key 4 0 0 0 value 1 0 0 0

Поки все добре:

$ sudo bpftool map dump id 114
key: 01 00 00 00  value: 01 00 00 00
key: 02 00 00 00  value: 01 00 00 00
key: 04 00 00 00  value: 01 00 00 00
key: 03 00 00 00  value: 01 00 00 00
Found 4 elements

Спробуємо додати ще один:

$ sudo bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
Error: update failed: Argument list too long

Як і очікувалося, у нас не вийшло. Подивимося на помилку докладніше:

$ sudo strace -e bpf bpftool map update id 114 key 5 0 0 0 value 1 0 0 0
bpf(BPF_MAP_GET_FD_BY_ID, {map_id=114, next_id=0, open_flags=0}, 120) = 3
bpf(BPF_OBJ_GET_INFO_BY_FD, {info={bpf_fd=3, info_len=80, info=0x7ffe6c626da0}}, 120) = 0
bpf(BPF_MAP_UPDATE_ELEM, {map_fd=3, key=0x56049ded5260, value=0x56049ded5280, flags=BPF_ANY}, 120) = -1 E2BIG (Argument list too long)
Error: update failed: Argument list too long
+++ exited with 255 +++

Все гаразд: як і очікувалося, команда BPF_MAP_UPDATE_ELEM намагається створити новий, п'ятий, ключ, але падає з E2BIG.

Отже, ми вміємо створювати та завантажувати програми BPF, а також створювати та керувати мапами з простору користувача. Тепер логічно подивитися на те, як ми можемо використовувати карти з самих програм BPF. Ми могли б розповісти про це, мовою програм, що важко читаються, в машинних кодах-макросах, але насправді настав час показати, як пишуться і обслуговуються програми BPF насправді — за допомогою libbpf.

(Для читачів, невдоволених відсутністю низькорівневого прикладу: ми докладно розберемо програми, які використовують карти та функції-помічники, створені за допомогою libbpf та розповімо, що відбувається на рівні інструкцій. Для читачів, незадоволених дуже сильно, ми додали приклад у відповідному місці статті.)

Пишемо програми BPF за допомогою libbpf

Писати програми BPF за допомогою машинних кодів може бути цікаво лише спочатку, а потім настає пересичення. У цей момент потрібно звернути свій погляд на llvm, в якому є бакенд для створення коду для архітектури BPF, а також на бібліотеку libbpf, яка дозволяє писати частину додатків BPF і завантажувати код програм BPF, згенерованих за допомогою llvm/clang.

Насправді, як ми побачимо у цій та наступних статтях, libbpf робить досить багато роботи і без неї (або аналогічних інструментів. iproute2, libbcc, libbpf-go, і т.п.) жити неможливо. Однією з killer-фіч проекту libbpf є BPF CO-RE (Compile Once, Run Everywhere) - проект, який дозволяє писати програми BPF, що переносяться з одного ядра на інше, з можливістю запуску на різних API (наприклад, коли структура ядра змінюється від версії до версії). Для того, щоб мати можливість працювати з CO-RE, ваше ядро ​​має бути скомпільоване за допомогою BTF (як це зробити ми розповідаємо в розділі Засоби розробки. Перевірити, чи зібрано ваше ядро ​​з BTF чи ні, можна дуже просто – за наявності наступного файлу:

$ ls -lh /sys/kernel/btf/vmlinux
-r--r--r-- 1 root root 2.6M Jul 29 15:30 /sys/kernel/btf/vmlinux

Цей файл зберігає в собі інформацію про всі типи даних, що використовуються в ядрі і використовується у всіх прикладах, що використовують libbpf. Ми будемо докладно говорити про CO-RE у наступній статті, а в цій — просто побудуйте собі ядро ​​з CONFIG_DEBUG_INFO_BTF.

Бібліотека libbpf живе прямо в директорії tools/lib/bpf ядра та її розробка ведеться через список розсилки [email protected]. Однак для потреб додатків, які живуть за межами ядра, підтримується окремий репозиторій https://github.com/libbpf/libbpf в якому ядерна бібліотека дзеркається для доступу на читання більш-менш як є.

У цьому розділі ми подивимося на те, як можна створити проект, який використовує libbpf, напишемо кілька (менш безглуздих) тестових програм і докладно розберемо як все це працює. Це дозволить нам у наступних розділах простіше пояснити, як саме програми BPF взаємодіють із maps, kernel helpers, BTF тощо.

Зазвичай проекти, які використовують libbpf додають гітхабовський репозиторій як git submodule, зробимо це і ми:

$ mkdir /tmp/libbpf-example
$ cd /tmp/libbpf-example/
$ git init-db
Initialized empty Git repository in /tmp/libbpf-example/.git/
$ git submodule add https://github.com/libbpf/libbpf.git
Cloning into '/tmp/libbpf-example/libbpf'...
remote: Enumerating objects: 200, done.
remote: Counting objects: 100% (200/200), done.
remote: Compressing objects: 100% (103/103), done.
remote: Total 3354 (delta 101), reused 118 (delta 79), pack-reused 3154
Receiving objects: 100% (3354/3354), 2.05 MiB | 10.22 MiB/s, done.
Resolving deltas: 100% (2176/2176), done.

збирається libbpf дуже просто:

$ cd libbpf/src
$ mkdir build
$ OBJDIR=build DESTDIR=root make -s install
$ find root
root
root/usr
root/usr/include
root/usr/include/bpf
root/usr/include/bpf/bpf_tracing.h
root/usr/include/bpf/xsk.h
root/usr/include/bpf/libbpf_common.h
root/usr/include/bpf/bpf_endian.h
root/usr/include/bpf/bpf_helpers.h
root/usr/include/bpf/btf.h
root/usr/include/bpf/bpf_helper_defs.h
root/usr/include/bpf/bpf.h
root/usr/include/bpf/libbpf_util.h
root/usr/include/bpf/libbpf.h
root/usr/include/bpf/bpf_core_read.h
root/usr/lib64
root/usr/lib64/libbpf.so.0.1.0
root/usr/lib64/libbpf.so.0
root/usr/lib64/libbpf.a
root/usr/lib64/libbpf.so
root/usr/lib64/pkgconfig
root/usr/lib64/pkgconfig/libbpf.pc

Наш подальший план у цьому розділі полягає в наступному: ми напишемо програму BPF типу BPF_PROG_TYPE_XDP, ту саму, що і в попередньому прикладі, але на C, скомпілюємо її за допомогою clang, і напишемо програму-помічник, яка завантажуватиме її в ядро. У наступних розділах ми розширимо можливості програми BPF і програми-помічника.

Приклад: створюємо повноцінний додаток за допомогою libbpf

Для початку ми використовуємо файл /sys/kernel/btf/vmlinux, Про яке говорилося вище, і створимо його еквівалент у вигляді заголовного файлу:

$ bpftool btf dump file /sys/kernel/btf/vmlinux format c > vmlinux.h

У цьому файлі будуть зберігатися всі структури даних, які є в нашому ядрі, наприклад, ось так в ядрі визначається заголовок IPv4:

$ grep -A 12 'struct iphdr {' vmlinux.h
struct iphdr {
    __u8 ihl: 4;
    __u8 version: 4;
    __u8 tos;
    __be16 tot_len;
    __be16 id;
    __be16 frag_off;
    __u8 ttl;
    __u8 protocol;
    __sum16 check;
    __be32 saddr;
    __be32 daddr;
};

Тепер ми напишемо нашу програму BPF мовою C:

$ cat xdp-simple.bpf.c
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("xdp/simple")
int simple(void *ctx)
{
        return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

Хоча програма у нас вийшла дуже проста, але все ж таки ми повинні звернути увагу на безліч деталей. По-перше, першим заголовним файлом, який ми включаємо є vmlinux.h, який ми щойно згенерували за допомогою bpftool btf dump - Тепер нам не потрібно встановлювати пакет kernel-headers, щоб дізнатися, як виглядають структури ядра. Наступний заголовний файл надходить до нас із бібліотеки libbpf. Зараз він нам потрібний лише для того, щоб визначився макрос SEC, який надсилає символ до відповідної секції об'єктного файлу ELF. Наша програма міститься у секції xdp/simple, де перед слешем ми визначаємо тип програми BPF - це угода, яка використовується в libbpf, на основі назви секції вона підставить правильний тип під час запуску bpf(2). Сама програма BPF на C - дуже проста і складається з одного рядка return XDP_PASS. Зрештою, окрема секція "license" містить назву ліцензії.

Ми можемо скомпілювати нашу програму за допомогою llvm/clang, версії >= 10.0.0, а краще більше (див. розділ Засоби розробки):

$ clang --version
clang version 11.0.0 (https://github.com/llvm/llvm-project.git afc287e0abec710398465ee1f86237513f2b5091)
...

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o

З цікавих особливостей: ми вказуємо цільову архітектуру -target bpf і шлях до заголовків libbpf, які ми нещодавно встановили. Також, не забувайте про -O2без цієї опції вас можуть очікувати сюрпризи надалі. Подивимося на наш код, чи вдалося нам написати програму, яку ми хотіли?

$ llvm-objdump --section=xdp/simple --no-show-raw-insn -D xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       r0 = 2
       1:       exit

Так, вийшло! Тепер, у нас є бінарний файл із програмою, і ми хочемо створити програму, яка буде його завантажувати в ядро. Для цього бібліотека libbpf пропонує нам два варіанти - використовувати більш низькорівневий API або більш високорівневий API. Ми підемо другим шляхом, тому що нам хочеться навчитися писати, завантажувати та приєднувати програми BPF мінімальними зусиллями для їх подальшого вивчення.

Для початку, нам потрібно згенерувати «скелет» нашої програми з її бінарника за допомогою тієї ж утиліти bpftool - швейцарського ножа світу BPF (що можна розуміти і буквально, тому що Daniel Borkman - один із творців та мантейнерів BPF - швейцарець):

$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h

У файлі xdp-simple.skel.h міститься бінарний код нашої програми та функції для керування - завантаження, приєднання, видалення нашого об'єкта. У нашому простому випадку це виглядає як overkill, але це працює і у випадку, коли об'єктний файл містить безліч програм BPF і карт і для завантаження цього гігантського ELF нам достатньо лише згенерувати скелет і викликати одну-дві функції з додатка користувача, до написання якого ми зараз і перейдемо.

Власне, наша програма-завантажувач — тривіальна:

#include <err.h>
#include <unistd.h>
#include "xdp-simple.skel.h"

int main(int argc, char **argv)
{
    struct xdp_simple_bpf *obj;

    obj = xdp_simple_bpf__open_and_load();
    if (!obj)
        err(1, "failed to open and/or load BPF objectn");

    pause();

    xdp_simple_bpf__destroy(obj);
}

Тут struct xdp_simple_bpf визначається у файлі xdp-simple.skel.h та описує наш об'єктний файл:

struct xdp_simple_bpf {
    struct bpf_object_skeleton *skeleton;
    struct bpf_object *obj;
    struct {
        struct bpf_program *simple;
    } progs;
    struct {
        struct bpf_link *simple;
    } links;
};

Ми можемо помітити сліди низькорівневого API: структуру struct bpf_program *simple и struct bpf_link *simple. Перша структура визначає саме нашу програму, записану в секції xdp/simple, а друга - описує те, як програма приєднується до джерела подій.

Функція xdp_simple_bpf__open_and_load, відкриває об'єкт ELF, парсить його, створює всі структури та підструктури (крім програми в ELF знаходяться й інші секції - data, readonly data, налагоджувальна інформація, ліцензія тощо), а потім завантажує в ядро ​​за допомогою системного виклику bpf, що ми можемо перевірити, скомпілювавши та запустивши програму:

$ clang -O2 -I ./libbpf/src/root/usr/include/ xdp-simple.c -o xdp-simple ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz

$ sudo strace -e bpf ./xdp-simple
...
bpf(BPF_BTF_LOAD, 0x7ffdb8fd9670, 120)  = 3
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=2, insns=0xdfd580, license="GPL", log_level=0, log_size=0, log_buf=NULL, kern_version=KERNEL_VERSION(5, 8, 0), prog_flags=0, prog_name="simple", prog_ifindex=0, expected_attach_type=0x25 /* BPF_??? */, ...}, 120) = 4

Давайте тепер подивимося на нашу програму за допомогою bpftool. Знайдемо її ID:

# bpftool p | grep -A4 simple
463: xdp  name simple  tag 3b185187f1855c4c  gpl
        loaded_at 2020-08-01T01:59:49+0000  uid 0
        xlated 16B  jited 40B  memlock 4096B
        btf_id 185
        pids xdp-simple(16498)

та здампім (ми використовуємо скорочений вид команди bpftool prog dump xlated):

# bpftool p d x id 463
int simple(void *ctx):
; return XDP_PASS;
   0: (b7) r0 = 2
   1: (95) exit

Щось нове! Програма надрукувала шматки нашого вихідного файлу мовою C. Це було зроблено бібліотекою libbpf, яка знайшла секцію налагодження в бінарнику, скомпілювала її в об'єкт BTF, завантажила його в ядро ​​за допомогою BPF_BTF_LOAD, а потім вказала отриманий файловий дескриптор під час завантаження програми командою BPG_PROG_LOAD.

Kernel Helpers

Програми BPF можуть запускати «зовнішні» функції – kernel helpers. Ці функції-помічники дозволяють програмам BPF отримувати доступ до структур ядра, керувати maps, а також спілкуватися з «реальним світом» — створювати perf events, керувати обладнанням (наприклад, перенаправляти пакети) тощо.

Приклад: bpf_get_smp_processor_id

В рамках парадигми «вчимося на прикладах», розглянемо одну з функцій-помічників, bpf_get_smp_processor_id(), певну у файлі kernel/bpf/helpers.c. Вона повертає номер процесора, на якому запускається програма BPF, що викликала її. Але нас не так цікавить її семантика, як те, що її реалізація займає один рядок:

BPF_CALL_0(bpf_get_smp_processor_id)
{
    return smp_processor_id();
}

Визначення функцій-помічників BPF схожі на визначення системних викликів Linux. Тут, наприклад, визначається функція, яка не має аргументів. (Функція, яка приймає, скажімо, три аргументи, визначається за допомогою макросу BPF_CALL_3. Максимальна кількість аргументів дорівнює п'яти.) Однак, це лише перша частина визначення. Друга частина полягає у визначенні структури типу struct bpf_func_protoяка містить опис функції-помічника, зрозуміле verifier:

const struct bpf_func_proto bpf_get_smp_processor_id_proto = {
    .func     = bpf_get_smp_processor_id,
    .gpl_only = false,
    .ret_type = RET_INTEGER,
};

Реєстрація функцій-помічників

Щоб програми BPF певного типу могли використовувати цю функцію, вони повинні зареєструвати її, наприклад, для типу BPF_PROG_TYPE_XDP у ядрі визначається функція xdp_func_proto, яка за ID функції-помічника визначає, чи підтримує XDP цю функцію чи ні. Нашу функцію вона підтримує:

static const struct bpf_func_proto *
xdp_func_proto(enum bpf_func_id func_id, const struct bpf_prog *prog)
{
    switch (func_id) {
    ...
    case BPF_FUNC_get_smp_processor_id:
        return &bpf_get_smp_processor_id_proto;
    ...
    }
}

Нові типи програм BPF «визначаються» у файлі include/linux/bpf_types.h за допомогою макросу BPF_PROG_TYPE. Визначаються взято в лапки, оскільки це логічне визначення, а термінах мови C визначення цілого набору конкретних структур відбувається у інших місцях. Зокрема, у файлі kernel/bpf/verifier.c всі визначення з файлу bpf_types.h використовуються, щоб створити масив структур bpf_verifier_ops[]:

static const struct bpf_verifier_ops *const bpf_verifier_ops[] = {
#define BPF_PROG_TYPE(_id, _name, prog_ctx_type, kern_ctx_type) 
    [_id] = & _name ## _verifier_ops,
#include <linux/bpf_types.h>
#undef BPF_PROG_TYPE
};

Тобто для кожного типу програм BPF визначається покажчик на структуру даних типу struct bpf_verifier_ops, який ініціалізується значенням _name ## _verifier_ops, тобто. xdp_verifier_ops для xdp. Структура xdp_verifier_ops визначається у файлі net/core/filter.c наступним чином:

const struct bpf_verifier_ops xdp_verifier_ops = {
    .get_func_proto     = xdp_func_proto,
    .is_valid_access    = xdp_is_valid_access,
    .convert_ctx_access = xdp_convert_ctx_access,
    .gen_prologue       = bpf_noop_prologue,
};

Тут ми бачимо нашу знайому функцію xdp_func_proto, яка буде запускатися verifier щоразу, як він зустріне виклик якийсь функції усередині програми BPF, див. verifier.c.

Подивимося, як гіпотетична програма BPF використовує функцію bpf_get_smp_processor_id. Для цього перепишемо програму з нашого попереднього розділу так:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

SEC("xdp/simple")
int simple(void *ctx)
{
    if (bpf_get_smp_processor_id() != 0)
        return XDP_DROP;
    return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

Символ bpf_get_smp_processor_id визначається в <bpf/bpf_helper_defs.h> бібліотеки libbpf як

static u32 (*bpf_get_smp_processor_id)(void) = (void *) 8;

тобто, bpf_get_smp_processor_id - це покажчик на функцію, значення якого дорівнює 8, де 8 - це значення BPF_FUNC_get_smp_processor_id типу enum bpf_fun_id, що визначається для нас у файлі vmlinux.h (файл bpf_helper_defs.h в ядрі генерується скриптом, тому магічні числа - це ok). Ця функція не приймає аргументів та повертає значення типу __u32. Коли ми запускаємо її у нашій програмі, clang генерує інструкцію BPF_CALL "правильного вигляду". Давайте скомпілюємо програму і подивимося на секцію xdp/simple:

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o
$ llvm-objdump -D --section=xdp/simple xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       bf 01 00 00 00 00 00 00 r1 = r0
       2:       67 01 00 00 20 00 00 00 r1 <<= 32
       3:       77 01 00 00 20 00 00 00 r1 >>= 32
       4:       b7 00 00 00 02 00 00 00 r0 = 2
       5:       15 01 01 00 00 00 00 00 if r1 == 0 goto +1 <LBB0_2>
       6:       b7 00 00 00 01 00 00 00 r0 = 1

0000000000000038 <LBB0_2>:
       7:       95 00 00 00 00 00 00 00 exit

У першому ж рядку ми бачимо інструкцію call, параметр IMM якої дорівнює 8, а SRC_REG - Нулю. За ABI-угодою, яка використовується verifier, це і є виклик функції-помічника під номером вісім. Після її запуску логіка проста. Повернене значення з регістру r0 копіюється в r1 і на рядках 2,3 наводиться до типу u32 - Верхні 32 біти обнулюються. На рядках 4,5,6,7 ми повертаємо 2 (XDP_PASS) або 1 (XDP_DROP) залежно від того, чи повернула функція-помічник зі рядка 0 нульове чи ненульове значення.

Перевіримо себе: завантажимо програму та подивимося на висновок bpftool prog dump xlated:

$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simple &
[2] 10914

$ sudo bpftool p | grep simple
523: xdp  name simple  tag 44c38a10c657e1b0  gpl
        pids xdp-simple(10915)

$ sudo bpftool p d x id 523
int simple(void *ctx):
; if (bpf_get_smp_processor_id() != 0)
   0: (85) call bpf_get_smp_processor_id#114128
   1: (bf) r1 = r0
   2: (67) r1 <<= 32
   3: (77) r1 >>= 32
   4: (b7) r0 = 2
; }
   5: (15) if r1 == 0x0 goto pc+1
   6: (b7) r0 = 1
   7: (95) exit

Добре, verifier знайшов правильний kernel-helper.

Приклад: передаємо аргументи і нарешті запускаємо програму!

Усі функції-помічники на рівні виконання мають прототип

u64 fn(u64 r1, u64 r2, u64 r3, u64 r4, u64 r5)

Параметри функцій-помічників передаються в регістрах r1-r5, а значення повертається в регістрі r0. Функцій, які приймають більше п'яти аргументів, немає і додавати їхню підтримку в майбутньому не передбачається.

Давайте подивимося на новий kernel helper і те, як BPF передає параметри. Перепишемо xdp-simple.bpf.c наступним чином (інші рядки не змінилися):

SEC("xdp/simple")
int simple(void *ctx)
{
    bpf_printk("running on CPU%un", bpf_get_smp_processor_id());
    return XDP_PASS;
}

Наша програма друкує номер CPU, на якому вона запущена. Скомпілюємо її і подивимося на код:

$ llvm-objdump -D --section=xdp/simple --no-show-raw-insn xdp-simple.bpf.o

0000000000000000 <simple>:
       0:       r1 = 10
       1:       *(u16 *)(r10 - 8) = r1
       2:       r1 = 8441246879787806319 ll
       4:       *(u64 *)(r10 - 16) = r1
       5:       r1 = 2334956330918245746 ll
       7:       *(u64 *)(r10 - 24) = r1
       8:       call 8
       9:       r1 = r10
      10:       r1 += -24
      11:       r2 = 18
      12:       r3 = r0
      13:       call 6
      14:       r0 = 2
      15:       exit

У рядках 0-7 ми записуємо на стек рядок running on CPU%un, а потім на рядку 8 запускаємо знайомий нам bpf_get_smp_processor_id. У рядках 9-12 ми готуємо аргументи хелпера bpf_printk - Регістри r1, r2, r3. Чому їх три, а чи не два? Тому що bpf_printkце макрос-обгортка навколо справжнього хелпера bpf_trace_printk, якому потрібно передати розмір форматного рядка.

Давайте тепер додамо пару рядків до xdp-simple.cщоб наша програма приєднувалася до інтерфейсу lo і по-справжньому запускалася!

$ cat xdp-simple.c
#include <linux/if_link.h>
#include <err.h>
#include <unistd.h>
#include "xdp-simple.skel.h"

int main(int argc, char **argv)
{
    __u32 flags = XDP_FLAGS_SKB_MODE;
    struct xdp_simple_bpf *obj;

    obj = xdp_simple_bpf__open_and_load();
    if (!obj)
        err(1, "failed to open and/or load BPF objectn");

    bpf_set_link_xdp_fd(1, -1, flags);
    bpf_set_link_xdp_fd(1, bpf_program__fd(obj->progs.simple), flags);

cleanup:
    xdp_simple_bpf__destroy(obj);
}

Тут ми використовуємо функцію bpf_set_link_xdp_fd, яка приєднує програми BPF типу XDP до мережних інтерфейсів. Ми захардкодили номер інтерфейсу lo, який завжди дорівнює 1. Ми запускаємо функцію двічі, щоб спочатку від'єднати стару програму, якщо вона була приєднана. Зауважте, що тепер нам не потрібний виклик pause або нескінченний цикл: наша програма-завантажувач завершить роботу, але BPF не буде знищена, оскільки вона приєднана до джерела подій. Після успішного завантаження та під'єднання, програма буде запускатися для кожного мережного пакета, що надходить на lo.

Завантажимо програму та подивимося на інтерфейс lo:

$ sudo ./xdp-simple
$ sudo bpftool p | grep simple
669: xdp  name simple  tag 4fca62e77ccb43d6  gpl
$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 669

Програма, яку ми завантажили, має ID 669 і той же ID ми бачимо на інтерфейсі lo. Пошлемо пару пакетів на 127.0.0.1 (request + reply):

$ ping -c1 localhost

і тепер подивимося на вміст віртуального файлу налагодження /sys/kernel/debug/tracing/trace_pipe, в який bpf_printk пише свої повідомлення:

# cat /sys/kernel/debug/tracing/trace_pipe
ping-13937 [000] d.s1 442015.377014: bpf_trace_printk: running on CPU0
ping-13937 [000] d.s1 442015.377027: bpf_trace_printk: running on CPU0

Два пакети були помічені на lo та оброблені на CPU0 – наша перша повноцінна безглузда програма BPF відпрацювала!

Варто зауважити, що bpf_printk недаремно пише в налагоджувальний файл: це не найвдаліший хелпер для використання в production, але наша мета була показати щось просте.

Доступ до maps із програм BPF

Приклад: використовуємо картку з програми BPF

У попередніх розділах ми навчилися створювати та використовувати карти з простору користувача, а тепер подивимося на ядерну частину. Почнемо, як водиться, з прикладу. Перепишемо нашу програму xdp-simple.bpf.c наступним чином:

#include "vmlinux.h"
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 8);
    __type(key, u32);
    __type(value, u64);
} woo SEC(".maps");

SEC("xdp/simple")
int simple(void *ctx)
{
    u32 key = bpf_get_smp_processor_id();
    u32 *val;

    val = bpf_map_lookup_elem(&woo, &key);
    if (!val)
        return XDP_ABORTED;

    *val += 1;

    return XDP_PASS;
}

char LICENSE[] SEC("license") = "GPL";

На початок програми ми додали визначення карту woo: це масив із 8 елементів, в якому зберігаються значення типу u64 (на C ми визначили б такий масив як u64 woo[8]). В програмі "xdp/simple" ми отримуємо номер поточного процесора у змінну key і потім за допомогою функції-помічника bpf_map_lookup_element отримуємо покажчик на відповідний запис у масиві, який збільшуємо на одиницю. У перекладі російською: ми підраховуємо статистику того, на якому CPU були оброблені вхідні пакети. Спробуємо запустити програму:

$ clang -O2 -g -c -target bpf -I libbpf/src/root/usr/include xdp-simple.bpf.c -o xdp-simple.bpf.o
$ bpftool gen skeleton xdp-simple.bpf.o > xdp-simple.skel.h
$ clang -O2 -g -I ./libbpf/src/root/usr/include/ -o xdp-simple xdp-simple.c ./libbpf/src/root/usr/lib64/libbpf.a -lelf -lz
$ sudo ./xdp-simple

Перевіримо, що вона підчепилася до lo і пошлемо трохи пакетів:

$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 108

$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; done

Тепер подивимося на вміст масиву:

$ sudo bpftool map dump name woo
[
    { "key": 0, "value": 0 },
    { "key": 1, "value": 400 },
    { "key": 2, "value": 0 },
    { "key": 3, "value": 0 },
    { "key": 4, "value": 0 },
    { "key": 5, "value": 0 },
    { "key": 6, "value": 0 },
    { "key": 7, "value": 46400 }
]

Майже всі процеси були опрацьовані на CPU7. Нам це не важливо, головне, що програма працює і ми зрозуміли, як отримати доступ до карт з програм BPF — за допомогою хелперов bpf_mp_*.

Містичний покажчик

Отже, ми можемо отримувати доступ із програми BPF до карти за допомогою викликів виду

val = bpf_map_lookup_elem(&woo, &key);

де функція-помічник виглядає як

void *bpf_map_lookup_elem(struct bpf_map *map, const void *key)

але ми ж передаємо покажчик &woo на безіменну структуру struct { ... }...

Якщо ми подивимося на асемблер програми, то побачимо, що значення &woo насправді не визначено (рядок 4):

llvm-objdump -D --section xdp/simple xdp-simple.bpf.o

xdp-simple.bpf.o:       file format elf64-bpf

Disassembly of section xdp/simple:

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
       2:       bf a2 00 00 00 00 00 00 r2 = r10
       3:       07 02 00 00 fc ff ff ff r2 += -4
       4:       18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
       6:       85 00 00 00 01 00 00 00 call 1
...

і міститься в релокаціях:

$ llvm-readelf -r xdp-simple.bpf.o | head -4

Relocation section '.relxdp/simple' at offset 0xe18 contains 1 entries:
    Offset             Info             Type               Symbol's Value  Symbol's Name
0000000000000020  0000002700000001 R_BPF_64_64            0000000000000000 woo

Але якщо ми подивимося вже завантажену програму, то побачимо покажчик на правильний map (рядок 4):

$ sudo bpftool prog dump x name simple
int simple(void *ctx):
   0: (85) call bpf_get_smp_processor_id#114128
   1: (63) *(u32 *)(r10 -4) = r0
   2: (bf) r2 = r10
   3: (07) r2 += -4
   4: (18) r1 = map[id:64]
...

Таким чином, ми можемо зробити висновок, що в момент запуску нашої програми-завантажувача посилання на &woo була на щось замінена бібліотекою libbpf. Спочатку ми подивимося на висновок strace:

$ sudo strace -e bpf ./xdp-simple
...
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_ARRAY, key_size=4, value_size=8, max_entries=8, map_name="woo", ...}, 120) = 4
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, prog_name="simple", ...}, 120) = 5

Ми бачимо, що libbpf створила картку woo і потім завантажила нашу програму simple. Подивимося уважніше те що, як ми завантажуємо програму:

  • викликаємо xdp_simple_bpf__open_and_load з файлу xdp-simple.skel.h
  • яка викликає xdp_simple_bpf__load з файлу xdp-simple.skel.h
  • яка викликає bpf_object__load_skeleton з файлу libbpf/src/libbpf.c
  • яка викликає bpf_object__load_xattr з libbpf/src/libbpf.c

Остання функція, крім іншого, викличе bpf_object__create_mapsяка створює або відкриває існуючі maps, перетворюючи їх на файлові дескриптори. (Це те місце, де ми бачимо BPF_MAP_CREATE у висновку strace.) Далі викликається функція bpf_object__relocate і саме вона нас і цікавить, тому що ми пам'ятаємо, що ми бачили woo у таблиці релокацій. Досліджуючи її, ми, зрештою, потрапляємо в функцію bpf_program__relocate, яка і займається релокаціями карт:

case RELO_LD64:
    insn[0].src_reg = BPF_PSEUDO_MAP_FD;
    insn[0].imm = obj->maps[relo->map_idx].fd;
    break;

Отже, ми беремо нашу інструкцію

18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll

і замінюємо в ній регістр-джерело на BPF_PSEUDO_MAP_FD, а перший IMM на файловий дескриптор нашого карту і, якщо він дорівнює, наприклад, 0xdeadbeef, то в результаті ми отримаємо інструкцію

18 11 00 00 ef eb ad de 00 00 00 00 00 00 00 00 r1 = 0 ll

Саме так інформація про карти передається до конкретної завантаженої програми BPF. При цьому мап може бути створений за допомогою BPF_MAP_CREATE, так і відкритий за ID за допомогою BPF_MAP_GET_FD_BY_ID.

Разом, при використанні libbpf алгоритм наступний:

  • під час компіляції для посилань на карти створюються записи у таблиці релокації
  • libbpf відкриває об'єктник ELF, знаходить всі використовуються карти і створює для них файлові дескриптори.
  • файлові дескриптори завантажуються в ядро ​​як частину інструкції LD64

Як ви розумієте, це ще не все, і нам доведеться заглянути у ядро. На щастя, ми маємо зачіпку — ми прописали значення BPF_PSEUDO_MAP_FD в регістр-джерело і можемо погріпати його, що приведе нас у свята всіх святих. kernel/bpf/verifier.cде функція з характерною назвою замінює файловий дескриптор на адресу структури типу struct bpf_map:

static int replace_map_fd_with_map_ptr(struct bpf_verifier_env *env) {
    ...

    f = fdget(insn[0].imm);
    map = __bpf_map_get(f);
    if (insn->src_reg == BPF_PSEUDO_MAP_FD) {
        addr = (unsigned long)map;
    }
    insn[0].imm = (u32)addr;
    insn[1].imm = addr >> 32;

(Повний код можна знайти за посиланням). Отже, ми можемо доповнити наш алгоритм:

  • під час завантаження програми verifier перевіряє коректність використання картки та прописує адресу відповідної структури struct bpf_map

При завантаженні бінарника ELF за допомогою libbpf відбувається ще багато подій, але ми обговоримо це у рамках інших статей.

Завантажуємо програми та карти без libbpf

Як і обіцялося, ось приклад для читачів, які хочуть знати, як створити та завантажити програму, яка використовує карти, без допомоги libbpf. Це може бути корисно, коли ви працюєте в оточенні, для якого не можете зібрати залежності, або заощаджуєте кожен біт, або пишете програму типу plyяка генерує бінарний код BPF нальоту.

Для того, щоб було простіше слідувати за логікою, ми для цього перепишемо наш приклад xdp-simple. Повний і трохи розширений код програми, що розглядається в цьому прикладі, ви можете знайти в цьому суть.

Логіка нашого додатка така:

  • створити карт типу BPF_MAP_TYPE_ARRAY за допомогою команди BPF_MAP_CREATE,
  • створити програму, яка використовує цей карт,
  • приєднати програму до інтерфейсу lo,

що перекладається на людську як

int main(void)
{
    int map_fd, prog_fd;

    map_fd = map_create();
    if (map_fd < 0)
        err(1, "bpf: BPF_MAP_CREATE");

    prog_fd = prog_load(map_fd);
    if (prog_fd < 0)
        err(1, "bpf: BPF_PROG_LOAD");

    xdp_attach(1, prog_fd);
}

Тут map_create створює мап точно так, як ми робили це в першому прикладі про системний виклик bpf — «ядро, будь ласка, зроби мені новий карт у вигляді масиву з 8 елементів типу __u64 і поверни мені файловий дескриптор»:

static int map_create()
{
    union bpf_attr attr;

    memset(&attr, 0, sizeof(attr));
    attr.map_type = BPF_MAP_TYPE_ARRAY,
    attr.key_size = sizeof(__u32),
    attr.value_size = sizeof(__u64),
    attr.max_entries = 8,
    strncpy(attr.map_name, "woo", sizeof(attr.map_name));
    return syscall(__NR_bpf, BPF_MAP_CREATE, &attr, sizeof(attr));
}

Програма також завантажується просто:

static int prog_load(int map_fd)
{
    union bpf_attr attr;
    struct bpf_insn insns[] = {
        ...
    };

    memset(&attr, 0, sizeof(attr));
    attr.prog_type = BPF_PROG_TYPE_XDP;
    attr.insns     = ptr_to_u64(insns);
    attr.insn_cnt  = sizeof(insns)/sizeof(insns[0]);
    attr.license   = ptr_to_u64("GPL");
    strncpy(attr.prog_name, "woo", sizeof(attr.prog_name));
    return syscall(__NR_bpf, BPF_PROG_LOAD, &attr, sizeof(attr));
}

Складна частина prog_load - Це визначення нашої програми BPF у вигляді масиву структур struct bpf_insn insns[]. Але так як ми використовуємо програму, яка у нас є на C, ми можемо трохи схитрувати:

$ llvm-objdump -D --section xdp/simple xdp-simple.bpf.o

0000000000000000 <simple>:
       0:       85 00 00 00 08 00 00 00 call 8
       1:       63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0
       2:       bf a2 00 00 00 00 00 00 r2 = r10
       3:       07 02 00 00 fc ff ff ff r2 += -4
       4:       18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll
       6:       85 00 00 00 01 00 00 00 call 1
       7:       b7 01 00 00 00 00 00 00 r1 = 0
       8:       15 00 04 00 00 00 00 00 if r0 == 0 goto +4 <LBB0_2>
       9:       61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0)
      10:       07 01 00 00 01 00 00 00 r1 += 1
      11:       63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1
      12:       b7 01 00 00 02 00 00 00 r1 = 2

0000000000000068 <LBB0_2>:
      13:       bf 10 00 00 00 00 00 00 r0 = r1
      14:       95 00 00 00 00 00 00 00 exit

Отже, нам потрібно написати 14 інструкцій у вигляді структур типу struct bpf_insn (порада: Візьміть дамп зверху, перечитайте розділ про інструкції, відкрийте linux/bpf.h и linux/bpf_common.h і спробуйте визначити struct bpf_insn insns[] самостійно):

struct bpf_insn insns[] = {
    /* 85 00 00 00 08 00 00 00 call 8 */
    {
        .code = BPF_JMP | BPF_CALL,
        .imm = 8,
    },

    /* 63 0a fc ff 00 00 00 00 *(u32 *)(r10 - 4) = r0 */
    {
        .code = BPF_MEM | BPF_STX,
        .off = -4,
        .src_reg = BPF_REG_0,
        .dst_reg = BPF_REG_10,
    },

    /* bf a2 00 00 00 00 00 00 r2 = r10 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_X,
        .src_reg = BPF_REG_10,
        .dst_reg = BPF_REG_2,
    },

    /* 07 02 00 00 fc ff ff ff r2 += -4 */
    {
        .code = BPF_ALU64 | BPF_ADD | BPF_K,
        .dst_reg = BPF_REG_2,
        .imm = -4,
    },

    /* 18 01 00 00 00 00 00 00 00 00 00 00 00 00 00 00 r1 = 0 ll */
    {
        .code = BPF_LD | BPF_DW | BPF_IMM,
        .src_reg = BPF_PSEUDO_MAP_FD,
        .dst_reg = BPF_REG_1,
        .imm = map_fd,
    },
    { }, /* placeholder */

    /* 85 00 00 00 01 00 00 00 call 1 */
    {
        .code = BPF_JMP | BPF_CALL,
        .imm = 1,
    },

    /* b7 01 00 00 00 00 00 00 r1 = 0 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 0,
    },

    /* 15 00 04 00 00 00 00 00 if r0 == 0 goto +4 <LBB0_2> */
    {
        .code = BPF_JMP | BPF_JEQ | BPF_K,
        .off = 4,
        .src_reg = BPF_REG_0,
        .imm = 0,
    },

    /* 61 01 00 00 00 00 00 00 r1 = *(u32 *)(r0 + 0) */
    {
        .code = BPF_MEM | BPF_LDX,
        .off = 0,
        .src_reg = BPF_REG_0,
        .dst_reg = BPF_REG_1,
    },

    /* 07 01 00 00 01 00 00 00 r1 += 1 */
    {
        .code = BPF_ALU64 | BPF_ADD | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 1,
    },

    /* 63 10 00 00 00 00 00 00 *(u32 *)(r0 + 0) = r1 */
    {
        .code = BPF_MEM | BPF_STX,
        .src_reg = BPF_REG_1,
        .dst_reg = BPF_REG_0,
    },

    /* b7 01 00 00 02 00 00 00 r1 = 2 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_K,
        .dst_reg = BPF_REG_1,
        .imm = 2,
    },

    /* <LBB0_2>: bf 10 00 00 00 00 00 00 r0 = r1 */
    {
        .code = BPF_ALU64 | BPF_MOV | BPF_X,
        .src_reg = BPF_REG_1,
        .dst_reg = BPF_REG_0,
    },

    /* 95 00 00 00 00 00 00 00 exit */
    {
        .code = BPF_JMP | BPF_EXIT
    },
};

Вправа для тих, хто не став писати це сам - знайдіть map_fd.

У нашій програмі залишилася ще одна нерозкрита частина xdp_attach. На жаль, програми типу XDP не можна підключити за допомогою системного виклику bpf. Люди, що створювали BPF і XDP були з мережевого співтовариства Linux, а значить, вони використовували найзвичніший для них (але не для нормальних людей) інтерфейс взаємодії з ядром: розетки netlink, Див. також RFC3549. Найпростіший спосіб реалізації xdp_attach — це копіювання коду з libbpf, А саме, з файлу netlink.c, Що ми і зробили, трохи вкоротив його:

Ласкаво просимо у світ netlink сокетів

Відкриваємо netlink сокет типу NETLINK_ROUTE:

int netlink_open(__u32 *nl_pid)
{
    struct sockaddr_nl sa;
    socklen_t addrlen;
    int one = 1, ret;
    int sock;

    memset(&sa, 0, sizeof(sa));
    sa.nl_family = AF_NETLINK;

    sock = socket(AF_NETLINK, SOCK_RAW, NETLINK_ROUTE);
    if (sock < 0)
        err(1, "socket");

    if (setsockopt(sock, SOL_NETLINK, NETLINK_EXT_ACK, &one, sizeof(one)) < 0)
        warnx("netlink error reporting not supported");

    if (bind(sock, (struct sockaddr *)&sa, sizeof(sa)) < 0)
        err(1, "bind");

    addrlen = sizeof(sa);
    if (getsockname(sock, (struct sockaddr *)&sa, &addrlen) < 0)
        err(1, "getsockname");

    *nl_pid = sa.nl_pid;
    return sock;
}

Читаємо з такого сокету:

static int bpf_netlink_recv(int sock, __u32 nl_pid, int seq)
{
    bool multipart = true;
    struct nlmsgerr *errm;
    struct nlmsghdr *nh;
    char buf[4096];
    int len, ret;

    while (multipart) {
        multipart = false;
        len = recv(sock, buf, sizeof(buf), 0);
        if (len < 0)
            err(1, "recv");

        if (len == 0)
            break;

        for (nh = (struct nlmsghdr *)buf; NLMSG_OK(nh, len);
                nh = NLMSG_NEXT(nh, len)) {
            if (nh->nlmsg_pid != nl_pid)
                errx(1, "wrong pid");
            if (nh->nlmsg_seq != seq)
                errx(1, "INVSEQ");
            if (nh->nlmsg_flags & NLM_F_MULTI)
                multipart = true;
            switch (nh->nlmsg_type) {
                case NLMSG_ERROR:
                    errm = (struct nlmsgerr *)NLMSG_DATA(nh);
                    if (!errm->error)
                        continue;
                    ret = errm->error;
                    // libbpf_nla_dump_errormsg(nh); too many code to copy...
                    goto done;
                case NLMSG_DONE:
                    return 0;
                default:
                    break;
            }
        }
    }
    ret = 0;
done:
    return ret;
}

Нарешті, ось наша функція, яка відкриває сокет і надсилає до нього спеціальне повідомлення, що містить файловий дескриптор:

static int xdp_attach(int ifindex, int prog_fd)
{
    int sock, seq = 0, ret;
    struct nlattr *nla, *nla_xdp;
    struct {
        struct nlmsghdr  nh;
        struct ifinfomsg ifinfo;
        char             attrbuf[64];
    } req;
    __u32 nl_pid = 0;

    sock = netlink_open(&nl_pid);
    if (sock < 0)
        return sock;

    memset(&req, 0, sizeof(req));
    req.nh.nlmsg_len = NLMSG_LENGTH(sizeof(struct ifinfomsg));
    req.nh.nlmsg_flags = NLM_F_REQUEST | NLM_F_ACK;
    req.nh.nlmsg_type = RTM_SETLINK;
    req.nh.nlmsg_pid = 0;
    req.nh.nlmsg_seq = ++seq;
    req.ifinfo.ifi_family = AF_UNSPEC;
    req.ifinfo.ifi_index = ifindex;

    /* started nested attribute for XDP */
    nla = (struct nlattr *)(((char *)&req)
            + NLMSG_ALIGN(req.nh.nlmsg_len));
    nla->nla_type = NLA_F_NESTED | IFLA_XDP;
    nla->nla_len = NLA_HDRLEN;

    /* add XDP fd */
    nla_xdp = (struct nlattr *)((char *)nla + nla->nla_len);
    nla_xdp->nla_type = IFLA_XDP_FD;
    nla_xdp->nla_len = NLA_HDRLEN + sizeof(int);
    memcpy((char *)nla_xdp + NLA_HDRLEN, &prog_fd, sizeof(prog_fd));
    nla->nla_len += nla_xdp->nla_len;

    /* if user passed in any flags, add those too */
    __u32 flags = XDP_FLAGS_SKB_MODE;
    nla_xdp = (struct nlattr *)((char *)nla + nla->nla_len);
    nla_xdp->nla_type = IFLA_XDP_FLAGS;
    nla_xdp->nla_len = NLA_HDRLEN + sizeof(flags);
    memcpy((char *)nla_xdp + NLA_HDRLEN, &flags, sizeof(flags));
    nla->nla_len += nla_xdp->nla_len;

    req.nh.nlmsg_len += NLA_ALIGN(nla->nla_len);

    if (send(sock, &req, req.nh.nlmsg_len, 0) < 0)
        err(1, "send");
    ret = bpf_netlink_recv(sock, nl_pid, seq);

cleanup:
    close(sock);
    return ret;
}

Отже, все готове до тестування:

$ cc nolibbpf.c -o nolibbpf
$ sudo strace -e bpf ./nolibbpf
bpf(BPF_MAP_CREATE, {map_type=BPF_MAP_TYPE_ARRAY, map_name="woo", ...}, 72) = 3
bpf(BPF_PROG_LOAD, {prog_type=BPF_PROG_TYPE_XDP, insn_cnt=15, prog_name="woo", ...}, 72) = 4
+++ exited with 0 +++

Подивимося, чи приєдналася наша програма до lo:

$ ip l show dev lo
1: lo: <LOOPBACK,UP,LOWER_UP> mtu 65536 xdpgeneric qdisc noqueue state UNKNOWN mode DEFAULT group default qlen 1000
    link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00
    prog/xdp id 160

Пошлемо пінги і помостимо на map:

$ for s in `seq 234`; do sudo ping -f -c 100 127.0.0.1 >/dev/null 2>&1; done
$ sudo bpftool m dump name woo
key: 00 00 00 00  value: 90 01 00 00 00 00 00 00
key: 01 00 00 00  value: 00 00 00 00 00 00 00 00
key: 02 00 00 00  value: 00 00 00 00 00 00 00 00
key: 03 00 00 00  value: 00 00 00 00 00 00 00 00
key: 04 00 00 00  value: 00 00 00 00 00 00 00 00
key: 05 00 00 00  value: 00 00 00 00 00 00 00 00
key: 06 00 00 00  value: 40 b5 00 00 00 00 00 00
key: 07 00 00 00  value: 00 00 00 00 00 00 00 00
Found 8 elements

Ура, все працює. Зверніть увагу, до речі, що наш map знову відображається у вигляді байтиків. Це відбувається через те, що, на відміну від libbpf ми не завантажували інформацію про типи (BTF). Але докладніше про це ми поговоримо наступного разу.

Засоби розробки

У цьому розділі ми подивимося на мінімальний інструментальний набір BPF.

Взагалі кажучи, для розробки програм BPF не потрібно нічого особливого - BPF працює на будь-якому пристойному дистрибутивному ядрі, а програми збираються за допомогою clang, який можна поставити із пакета. Однак, через те, що BPF знаходиться в процесі розробки, ядро ​​та інструменти постійно змінюються, якщо ви не хочете писати програми BPF дідівськими методами з 2019-х, то вам доведеться зібрати

  • llvm/clang
  • pahole
  • своє ядро
  • bpftool

(Для довідки: цей розділ і всі приклади статті запускалися на Debian 10.)

llvm/clang

BPF товаришує з LLVM і, хоча з недавніх пір програми для BPF можна компілювати і за допомогою gcc, вся поточна технологія ведеться для LLVM. Тому насамперед ми зберемо поточну версію clang з git:

$ sudo apt install ninja-build
$ git clone --depth 1 https://github.com/llvm/llvm-project.git
$ mkdir -p llvm-project/llvm/build/install
$ cd llvm-project/llvm/build
$ cmake .. -G "Ninja" -DLLVM_TARGETS_TO_BUILD="BPF;X86" 
                      -DLLVM_ENABLE_PROJECTS="clang" 
                      -DBUILD_SHARED_LIBS=OFF 
                      -DCMAKE_BUILD_TYPE=Release 
                      -DLLVM_BUILD_RUNTIME=OFF
$ time ninja
... много времени спустя
$

Тепер ми можемо перевірити, чи все правильно зібралося:

$ ./bin/llc --version
LLVM (http://llvm.org/):
  LLVM version 11.0.0git
  Optimized build.
  Default target: x86_64-unknown-linux-gnu
  Host CPU: znver1

  Registered Targets:
    bpf    - BPF (host endian)
    bpfeb  - BPF (big endian)
    bpfel  - BPF (little endian)
    x86    - 32-bit X86: Pentium-Pro and above
    x86-64 - 64-bit X86: EM64T and AMD64

(Інструкція по збірці clang взята мною з bpf_devel_QA.)

Ми не будемо встановлювати щойно зібрані програми, а натомість просто додамо їх у PATH, Наприклад:

export PATH="`pwd`/bin:$PATH"

(Це можна додати до .bashrc або окремий файл. Особисто я додаю такі речі в ~/bin/activate-llvm.sh і коли треба роблю . activate-llvm.sh.)

Pahole та BTF

Утиліта pahole використовується при складанні ядра для створення налагоджувальної інформації у форматі BTF. Ми не будемо в цій статті докладно зупинятися на деталях технології BTF, крім того, що це зручно і ми хочемо його використовувати. Тому, якщо ви збираєтесь збирати своє ядро, зберіть спочатку pahole (без pahole ви не зможете зібрати ядро ​​з опцією CONFIG_DEBUG_INFO_BTF:

$ git clone https://git.kernel.org/pub/scm/devel/pahole/pahole.git
$ cd pahole/
$ sudo apt install cmake
$ mkdir build
$ cd build/
$ cmake -D__LIB=lib ..
$ make
$ sudo make install
$ which pahole
/usr/local/bin/pahole

Ядра для експериментів з BPF

Під час вивчення можливостей BPF хочеться зібрати своє ядро. Це, взагалі кажучи, не обов'язково, так як ви зможете збирати і завантажувати програми BPF і на дистрибутивному ядрі, проте наявність свого ядра дозволяє використовувати найновіші можливості BPF, які виявляться у вашому дистрибутиві в кращому випадку через місяці або, як у випадку з деякими налагоджувальними інструментами, взагалі не будуть заклопотані в найближчому майбутньому. Також своє ядро ​​дозволяє відчути себе важливим експериментувати з кодом.

Щоб побудувати ядро ​​вам потрібно, по-перше, саме ядро, а по-друге, файл конфігурації ядра. Для експериментів з BPF ми можемо використовувати звичайне ванільне ядро чи одне з девелоперських ядер. Історично розробка BPF відбувається в рамках мережевого співтовариства Linux і тому всі зміни рано чи пізно відбуваються через Девіда Міллера (David Miller) – мантейнера мережевої частини Linux. Залежно від своєї природи — редагування або нові фічі — мережеві зміни потрапляють в одну з двох ядер. net або net-next. Зміни для BPF таким же чином розподіляються між bpf и bpf-next, які потім куляться в net і net-next, відповідно. Детальніше див. bpf_devel_QA и netdev-FAQ. Так що вибирайте ядро ​​виходячи з вашого смаку та потреб у стабільності системи, на якій ви тестуєте (*-next ядра найнестабільніші з перерахованих).

У рамки цієї статті не входить розповідь про те, як керуватися файлами конфігурації ядра - передбачається, що ви або вже вмієте це робити, або готові навчитися самостійно. Однак, наступних інструкцій має бути більш-менш достатньо, щоб у вас вийшла працююча система з підтримкою BPF.

Завантажити одне з вищезгаданих ядер:

$ git clone git://git.kernel.org/pub/scm/linux/kernel/git/bpf/bpf-next.git
$ cd bpf-next

Зібрати мінімальний працюючий конфіг ядра:

$ cp /boot/config-`uname -r` .config
$ make localmodconfig

Увімкнути опції BPF у файлі .config на свій вибір (швидше за все, сам CONFIG_BPF вже буде включено, оскільки його використовує systemd). Ось список опцій з ядра, яке використовувалося для цієї статті:

CONFIG_CGROUP_BPF=y
CONFIG_BPF=y
CONFIG_BPF_LSM=y
CONFIG_BPF_SYSCALL=y
CONFIG_ARCH_WANT_DEFAULT_BPF_JIT=y
CONFIG_BPF_JIT_ALWAYS_ON=y
CONFIG_BPF_JIT_DEFAULT_ON=y
CONFIG_IPV6_SEG6_BPF=y
# CONFIG_NETFILTER_XT_MATCH_BPF is not set
# CONFIG_BPFILTER is not set
CONFIG_NET_CLS_BPF=y
CONFIG_NET_ACT_BPF=y
CONFIG_BPF_JIT=y
CONFIG_BPF_STREAM_PARSER=y
CONFIG_LWTUNNEL_BPF=y
CONFIG_HAVE_EBPF_JIT=y
CONFIG_BPF_EVENTS=y
CONFIG_BPF_KPROBE_OVERRIDE=y
CONFIG_DEBUG_INFO_BTF=y

Далі ми можемо легко зібрати та встановити модулі та ядро ​​(до речі, можна зібрати ядро ​​за допомогою щойно зібраного clang, додавши CC=clang):

$ make -s -j $(getconf _NPROCESSORS_ONLN)
$ sudo make modules_install
$ sudo make install

і перезавантажити з новим ядром (я використовую для цього kexec з пакета kexec-tools):

v=5.8.0-rc6+ # если вы пересобираете текущее ядро, то можно делать v=`uname -r`
sudo kexec -l -t bzImage /boot/vmlinuz-$v --initrd=/boot/initrd.img-$v --reuse-cmdline &&
sudo kexec -e

bpftool

Найуживанішою утилітою у статті буде утиліта bpftool, що постачається у складі ядра Linux. Вона написана та підтримується розробниками BPF для розробників BPF і за її допомогою можна керуватися з усіма типами об'єктів BPF – завантажувати програми, створювати та змінювати maps, досліджувати життєдіяльність екосистеми BPF тощо. Документацію як вихідників до man pages можна знайти в ядрі або, вже скомпільовану, в мережі.

На момент написання статті bpftool поставляється у готовому вигляді тільки для RHEL, Fedora та Ubuntu (див., наприклад, ця тред, в якому розповідається незакінчена історія опікування bpftool в Debian). Але якщо ви вже зібрали своє ядро, то зібрати bpftool простіше простого:

$ cd ${linux}/tools/bpf/bpftool
# ... пропишите пути к последнему clang, как рассказано выше
$ make -s

Auto-detecting system features:
...                        libbfd: [ on  ]
...        disassembler-four-args: [ on  ]
...                          zlib: [ on  ]
...                        libcap: [ on  ]
...               clang-bpf-co-re: [ on  ]

Auto-detecting system features:
...                        libelf: [ on  ]
...                          zlib: [ on  ]
...                           bpf: [ on  ]

$

(тут ${linux} - це ваша директорія з ядром.) Після виконання цих команд bpftool буде зібрана в директорії ${linux}/tools/bpf/bpftool і її можна буде прописати в дорогу (насамперед користувачеві root) або просто скопіювати в /usr/local/sbin.

збирати bpftool найкраще за допомогою останнього clang, зібраного, як розказано вище, а перевірити, чи правильно вона зібралася - за допомогою, наприклад, команди

$ sudo bpftool feature probe kernel
Scanning system configuration...
bpf() syscall for unprivileged users is enabled
JIT compiler is enabled
JIT compiler hardening is disabled
JIT compiler kallsyms exports are enabled for root
...

яка покаже, які фічі BPF включені у ядрі.

До речі, попередню команду можна запустити як

# bpftool f p k

Це зроблено за аналогією з утилітами з пакета iproute2, де ми можемо, наприклад, сказати ip a s eth0 замість ip addr show dev eth0.

Висновок

BPF дозволяє підкувати блоху ефективно вимірювати та нальоту змінювати функціональність ядра. Система вийшла дуже вдалою в кращих традиціях UNIX: простий механізм, що дозволяє (пере)програмувати ядро, дозволив величезній кількості людей та організацій експериментувати. І хоча експерименти, так само як і розвиток самої інфраструктури BPF, ще далеко не закінчені, система вже має стабільне ABI, що дозволяє вибудовувати надійну, а головне, ефективну бізнес-логіку.

Хочеться відзначити, що на мою думку технологія вийшла настільки популярною тому, що з одного боку, в неї можна грати (архітектуру машини можна зрозуміти більш-менш за один вечір), а з іншого - вирішувати завдання, які не вирішуються (красиво) до її появи. Дві ці компоненти разом змушують людей експериментувати та мріяти, що і призводить до появи нових і нових інноваційних рішень.

Ця стаття, хоч і вийшла не дуже короткою, є лише введенням у світ BPF і не описує «просунуті» можливості та важливі частини архітектури. Подальший план приблизно такий: наступна стаття буде оглядом типів програм BPF (в ядрі 5.8 підтримується 30 типів програм), потім ми, нарешті, подивимося на те, як писати справжні додатки на BPF на прикладі програм для трасування ядра, потім прийде час для більш поглибленого курсу з архітектури BPF, а потім – для прикладів мережевих та security додатків BPF.

Попередні статті цього циклу

  1. BPF для найменших, частина нульова: classic BPF

Посилання

  1. BPF and XDP Reference Guide — документація з BPF від cilium, а точніше від Daniel Borkman, одного із творців та мантейнерів BPF. Це один з перших серйозних описів, який відрізняється від інших тим, що Daniel точно знає про що пише і ляпів там не спостерігається. Зокрема, у цьому документі розповідається як працювати з програмами BPF типів XDP та TC за допомогою відомої утиліти. ip з пакета iproute2.

  2. Documentation/networking/filter.txt — оригінальний файл із документацією за класичним, а потім і за extended BPF. Корисно читати, якщо ви хочете покопатися в асемблері та технічних деталях архітектури.

  3. Блог про BPF від facebook. Оновлюється рідко, але влучно, тому що пишуть туди Alexei Starovoitov (автор eBPF) та Andrii Nakryiko - (мантейнер libbpf).

  4. Секрети bpftool. Цікавий twitter-тред від Quentin Monnet з прикладами та секретами використання bpftool.

  5. Dive in BPF: List of reading material. Гігантський (і досі підтримуваний) список посилань на документацію BPF від Quentin Monnet.

Джерело: habr.com

Додати коментар або відгук