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-defined networking). Распрацаваны сеткавымі інжынерамі ядра як удасканаленая замена класічнага BPF, новы BPF літаральна праз паўгода знайшоў ужыванні ў нялёгкай справе трасіроўкі Linux сістэм, а цяпер, праз шэсць гадоў пасля з'яўлення, нам запатрабуецца цэлы, наступны, артыкул толькі для таго, каб пералічыць розныя тыпы праграм.

ВЯСЁЛЫЯ КАРТЫНКІ

У сваёй аснове BPF - гэта віртуальная машына-пясочніца, якая дазваляе запускаць "адвольны" код у прасторы ядра без шкоды для бяспекі. Праграмы BPF ствараюцца ў прасторы карыстальніка, загружаюцца ў ядро ​​і падлучаюцца да якой-небудзь крыніцы падзей. Падзеяй можа быць, напрыклад, дастаўка пакета на сеткавы інтэрфейс, запуск якой-небудзь функцыі ядра, і да т.п. У выпадку пакета праграме BPF будуць даступныя дадзеныя і метададзеныя пакета (на чытанне і, можа, на запіс, у залежнасці ад тыпу праграмы), у выпадку запуску функцыі ядра - аргументы функцыі, уключаючы паказальнікі на памяць ядра, і да т.п.

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

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

Архітэктура BPF распрацоўвалася, у прыватнасці, для таго, каб эфектыўна выконвацца на сучасных машынах. Для таго, каб гэта працавала на практыку, байт-код BPF, пасля загрузкі ў ядро ​​транслюецца ў натыўны код пры дапамозе кампанента пад назовам JIT compiler (Jусть 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 праграма або 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/XNUMX сістэмны выклік 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 sockets, гл. таксама 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 into BPF: апісанне матэрыялу. Гіганцкі (і да гэтага часу які падтрымліваецца) спіс спасылак на дакументацыю па BPF ад Quentin Monnet.

Крыніца: habr.com

Дадаць каментар