Інструкції
Тепер, коли ви розумієте регістри та області пам'яті sBPF, давайте розглянемо інструкції, які ними маніпулюють.
Інструкції — це фундаментальні операції, які виконує ваша програма: додавання чисел, завантаження з пам'яті або перехід до різних місць.
Що таке інструкції?
Інструкції — це основні будівельні блоки вашої програми. Уявіть їх як команди, які точно вказують процесору, що робити:
add64 r1, r2: "Додати значення в регістрахr1таr2, зберегти результат вr1"ldxdw r0, [r10 - 8]: "Завантажити 8 байтів зі стекової пам'яті в регістрr0"jeq r1, 42, +3: "Якщоr1дорівнює 42, перейти вперед на 3 інструкції"
Кожна інструкція виконує рівно одну операцію і кодується як рівно 8 байтів даних для миттєвого декодування віртуальною машиною.
Інструкції sBPF працюють з різними розмірами даних:
байт = 8 бітів (1 байт)
півслово = 16 бітів (2 байти)
слово = 32 біти (4 байти)
подвійне слово = 64 біти (8 байтів)
Більшість операцій sBPF використовують 64-бітні значення (подвійні слова), оскільки регістри мають розмір 64 біти, але за потреби ви можете завантажувати та зберігати менші розміри для підвищення ефективності.
Категорії інструкцій та формат
Коли ви компілюєте код на Rust, C або асемблері, інструментарій видає потік інструкцій фіксованої ширини по 8 байтів, упакованих у секцію .text вашого ELF-файлу.
Кожна інструкція має послідовну структуру, яку віртуальна машина може декодувати за один прохід:
1 byte 4 bits 4 bits 2 bytes 4 bytes
┌──────────┬────────┬────────┬──────────────┬──────────────────┐
│ opcode │ dst │ src │ offset │ imm │
└──────────┴────────┴────────┴──────────────┴──────────────────┘opcode: Визначає тип операції. Верхні 3 біти вибирають клас інструкції (арифметична, пам'ять, перехід, виклик, вихід), тоді як нижні 5 бітів визначають конкретний варіант (додавання, множення, завантаження, перехід-якщо-дорівнює).dst: Номер регістра призначення (r0–r10), де зберігаються результати — результати арифметичних операцій, завантажені значення або повернені значення допоміжних функцій.src: Вихідний регістр, що надає вхідні дані. Для двооперандної арифметики (add r1, r2) він надає друге значення. Для операцій з пам'яттю він може надавати базову адресу. Для варіантів з безпосереднім значенням (add r1, 10) ці 4 біти включаються в код операції.offset: Невелике ціле число, яке змінює поведінку інструкції. Для завантаження/збереження воно додається до вихідної адреси, щоб досягти[src + offset]. Для переходів це відносна ціль переходу, виміряна в інструкціях.imm: Поле безпосереднього значення. Арифметичні операції використовують його для констант (add r1, 42),CALLвикористовує його для номерів системних викликів (sol_log = 16), а операції з пам'яттю можуть розглядати його як абсолютний вказівник.
Категорії інструкцій
Різні типи інструкцій використовують ці поля особливим чином:
Переміщення даних: Переміщення значень між регістрами та пам'яттю:
mov64 r1, 42 // Put immediate value 42 into r1
// opcode=move_imm, dst=1, src=unused, imm=42
ldxdw r0, [r10 - 8] // Load 8 bytes from stack into r0
// opcode=load64, dst=0, src=10, offset=-8, imm=unused
stxdw [r1 + 16], r0 // Store r0 to memory at [r1 + 16]
// opcode=store64, dst=1, src=0, offset=16, imm=unusedАрифметика: Виконання математичних операцій:
add64 r1, r2 // r1 = r1 + r2
// opcode=add_reg, dst=1, src=2, offset=unused, imm=unused
add64 r1, 100 // r1 = r1 + 100
// opcode=add_imm, dst=1, src=unused, offset=unused, imm=100Керування потоком: Зміна послідовності виконання:
ja +5 // Jump forward 5 instructions unconditionally
// opcode=jump, dst=unused, src=unused, offset=5, imm=unused
jeq r1, r2, +3 // If r1 == r2, jump forward 3 instructions
// opcode=jump_eq_reg, dst=1, src=2, offset=3, imm=unused
jeq r1, 42, +3 // If r1 == 42, jump forward 3 instructions
// opcode=jump_eq_imm, dst=1, src=unused, offset=3, imm=42Кодування опкодів
Кодування опкодів містить багато інформації, окрім типу операції:
Клас інструкції: арифметична, пам'ять, перехід, виклик тощо.
Розмір операції: 32-бітні та 64-бітні операції
Тип джерела: регістр чи безпосереднє значення
Конкретна операція: додавання чи віднімання, завантаження чи збереження тощо.
Це створює окремі опкоди для варіантів інструкцій. Наприклад, add64 r1, r2 (джерело - регістр) використовує інший опкод, ніж add64 r1, 42 (безпосереднє джерело). Аналогічно, add64 та add32 мають різні опкоди для різних розмірів операцій.
Арифметичні операції додатково розрізняють знакові та беззнакові варіанти. udiv64 обробляє значення як беззнакові (від 0 до 18 квінтильйонів), тоді як sdiv64 обробляє знакові значення (від -9 квінтильйонів до +9 квінтильйонів).
Instruction Execution
Опкод визначає, як віртуальна машина інтерпретує решту полів.
Коли віртуальна машина зустрічає add64 r1, r2, вона зчитує опкод і розпізнає це як 64-бітну арифметичну операцію з використанням двох регістрів:
Поле dst вказує, що результат записується в r1, поле src визначає r2 як другий операнд, а поля offset та immediate ігноруються.
Для add64 r1, 42, опкод змінюється, щоб вказати на операцію з безпосереднім значенням. Тепер dst все ще вказує на r1, але src стає беззмістовним, а поле immediate надає другий операнд (42).
Операції з пам'яттю змістовно поєднують кілька полів:
Для ldxdw r1, [r2+8], опкод вказує на 64-бітне завантаження з пам'яті, dst отримує завантажене значення, src надає базову адресу, а offset (8) додається для створення кінцевої адреси r2 + 8.
Інструкції потоку керування слідують тому ж шаблону:
Коли ви пишете jeq r1, r2, +5, опкод кодує умовний перехід, порівнюючи два регістри. Якщо r1 дорівнює r2, віртуальна машина додає offset (5) до лічильника програми, переходячи вперед на 5 інструкцій.
Виклики функцій та системні виклики
Механізм виклику sBPF еволюціонував у різних версіях для кращої ясності та безпеки. До sBPF v3, call imm служив подвійним цілям: безпосереднє значення визначало, чи викликаєте ви внутрішню функцію, чи системний виклик.
Середовище виконання розрізняло їх на основі діапазону безпосередніх значень, при цьому номери системних викликів зазвичай були малими додатними цілими числами, як-от 16 для sol_log.
Починаючи з sBPF v3, інструкції розділилися для явної поведінки. call тепер обробляє внутрішні виклики функцій, використовуючи відносні зміщення, тоді як syscall imm явно викликає функції середовища виконання. Це розділення робить наміри байткоду зрозумілими та забезпечує кращу перевірку.
Непрямі виклики через callx також еволюціонували. У ранніх версіях цільовий регістр кодувався в полі безпосереднього значення, але починаючи з v2, він кодується в полі регістра джерела для узгодженості із загальним форматом інструкції.