BPF之路二(e)BPF汇编

admin 2023-11-24 14:21:36 AnQuanKeInfo 来源:ZONE.CI 全球网 0 阅读模式

 

原始的BPF汇编

https://www.kernel.org/doc/html/latest/networking/filter.html

原始的BPF又称之为class BPF(cBPF), BPF与eBPF类似于i386与amd64的关系, 最初的BPF只能用于套接字的过滤,内核源码树中tools/bpf/bpf_asm可以用于编写这种原始的BPF程序,

cBPF架构的基本元素如下

元素 描述
A 32bit宽的累加器
X 32bit宽的X寄存器
M[] 16*32位宽的杂项寄存器寄存器, 又称为临时寄存器, 可寻找范围:0~15<br />类似于一个int32_t M[16];的小内存<br />

cBPF汇编的一条指令为64字节, 在头文件<linux/filter.h>中有定义 . 如下. 这种结构被组装为一个 4 元组数组,其中包含code、jt、jf 和 k 值. jt 和 jf 是用于提供代码的跳转偏移量, k为通用值

struct sock_filter {    /* Filter block */
        __u16   code;   /* 16位宽的操作码 */
        __u8    jt;     /* 如果条件为真时的8位宽的跳转偏移  */
        __u8    jf;     /* 如果条件为假时的8位宽的跳转偏移 */
        __u32   k;      /* 杂项参数 */
};

对于套接字过滤,把struct sock_filter数组的指针通过setsockopt(2) 传递给内核。例子:

#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <linux/if_ether.h>
/* ... */

/* From the example above: tcpdump -i em1 port 22 -dd */
struct sock_filter code[] = {
        { 0x28,  0,  0, 0x0000000c },
        { 0x15,  0,  8, 0x000086dd },
        { 0x30,  0,  0, 0x00000014 },
        { 0x15,  2,  0, 0x00000084 },
        { 0x15,  1,  0, 0x00000006 },
        { 0x15,  0, 17, 0x00000011 },
        { 0x28,  0,  0, 0x00000036 },
        { 0x15, 14,  0, 0x00000016 },
        { 0x28,  0,  0, 0x00000038 },
        { 0x15, 12, 13, 0x00000016 },
        { 0x15,  0, 12, 0x00000800 },
        { 0x30,  0,  0, 0x00000017 },
        { 0x15,  2,  0, 0x00000084 },
        { 0x15,  1,  0, 0x00000006 },
        { 0x15,  0,  8, 0x00000011 },
        { 0x28,  0,  0, 0x00000014 },
        { 0x45,  6,  0, 0x00001fff },
        { 0xb1,  0,  0, 0x0000000e },
        { 0x48,  0,  0, 0x0000000e },
        { 0x15,  2,  0, 0x00000016 },
        { 0x48,  0,  0, 0x00000010 },
        { 0x15,  0,  1, 0x00000016 },
        { 0x06,  0,  0, 0x0000ffff },
        { 0x06,  0,  0, 0x00000000 },
};

struct sock_fprog bpf = {
        .len = ARRAY_SIZE(code),
        .filter = code,
};

sock = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL));    //建立套接字
if (sock < 0)
        /* ... bail out ... */

ret = setsockopt(sock, SOL_SOCKET, SO_ATTACH_FILTER, &bpf, sizeof(bpf)); //把bpf程序附加到套接字上
if (ret < 0)
        /* ... bail out ... */

/* ... */
close(sock);

由于性能有限, 因此后面cBPF由发展成为eBPF, 有新的指令和架构. 原始的BPF指令会被自动翻译为新的eBPF指令

 

eBPF虚拟机

eBPF虚拟机是一个RISC指令, 带有寄存器的虚拟机, 内部有11个64位寄存器, 一个程序计数器(PC), 以及一个512字节的固定大小的栈. 9个通用寄存器可以读写, 一个是只能读的栈指针寄存器(SP), 以及一个隐含的程序计数器, 我们只能根据PC进行固定偏移的跳转. 虚拟机寄存器总是64位的(就算是32位物理机也是这样的), 并且支持32位子寄存器寻址(寄存器高32位自动设置为0)

  • r0: 保存函数调用和当前程序退出的返回值
  • r1~r5: 作为函数调用参数, 当程序开始运行时, r1包含一个指向context参数的指针
  • r6~r9: 在内核函数调用之间得到保留
  • r10: 只读的指向512字节栈的栈指针

加载BPF程序时提供的的程序类型(prog_type)决定了内核里面哪些函数子集可以调用, 也决定了程序启动时通过r1提供的context参数. r0中保存的返回值含义也由程序类型决定.

对于eBPF到eBPF, eBPF到内核, 每个函数调用最多5个参数, 保存在寄存器r1~r5中. 并且传递参数时, 寄存器r1~r5只能保存常数或者指向堆栈的指针, 不能是任意内存的指针. 所有的内存访问必须先把数据加载到eBPF堆栈中, 然后才能使用. 这样的限制简化内存模型, 帮助eBPF验证器进行正确性检查

BPF可以访问内核核心提供(除去模块扩展的部分)的内核助手函数, 类似于系统调用, 这些助手函数在内核中通过BPF_CALL_*宏进行定义. bpf.h文件提供了所有BPF能访问的内核助手函数的声明.

以bpf_trace_printk为例子, 这个函数在内核中通过BPF_CALL_5进行定义, 并且有5对类型与参数名, 定义参数的类型对于eBPF很重要, 因为每一个eBPF程序加载时eBPF验证器都要确保寄存器数据类型与被调用函数的参数类型匹配.

BPF_CALL_5(bpf_trace_printk, char *, fmt, u32, fmt_size, u64, arg1, u64, arg2, u64, arg3)
{
    ...
}

这样设计是为了让虚拟机指令与原生的指令集(x86 arm)尽可能匹配, 这样JIT编译出的指令可以更简单高效, 所有寄存器都一对一地映射到硬件寄存器。例如,x86_64 JIT 编译器可以将它们映射为

R0 - rax
R1 - rdi
R2 - rsi
R3 - rdx
R4 - rcx
R5 - r8
R6 - rbx
R7 - r13
R8 - r14
R9 - r15
R10 - rbp

 

eBPF指令编码

每个eBPF指令都是固定的8字节, 大概有100条指令, 被划分为8个类型. 虚拟机支持从通用内存(映射, 栈, contexts比如数据包, ..)中进行1-8字节的读写, 支持前后有无条件的跳转, 支持数据与逻辑操作(ALU指令), 支持函数调用.

一个eBPF程序就是64位指令的序列, 所有的eBPF指令都有同样的基础格式:

  • 8bit操作码
  • 4bit目标寄存器
  • 4bit源寄存器
  • 16bit偏移
  • 32bit立即数
msb最高bit                                                    lsb最低bit
+------------------------+----------------+----+----+--------+
|immediate               |offset          |src |dst |opcode  |
+------------------------+----------------+----+----+--------+
|       32               |    16          | 4  | 4  |    8   |

大多数指令并不会使用全部的区域, 不使用的区域应该设置为0

操作码的最低3bit表示指令类别, 这个把相关的操作码组合在一起

LD/LDX/ST/STX操作码有如下结构

msb      lsb
+---+--+---+
|mde|sz|cls|
+---+--+---+
| 3 |2 | 3 |

sz区域表示目标内存区域的大小, mde区域是内存访问模式, uBPF只支持通用MEM访问模式

ALU/ALU64/JMP操作码的结构

msb      lsb
+----+-+---+
|op  |s|cls|
+----+-+---+
| 4  |1| 3 |

如果s是0, 那么源操作数就是imm, 如果s是1, 那么源操作数就是src. op部分指明要执行哪一个ALU或者分支操作

bpf.h中使用struct bpf_insn来描述一个eBPF指令, 其定义与上文是一致的. 因此一段eBPF程序也可以用一个struct bpf_insn数组来描述

struct bpf_insn {
    __u8    code;        /* 操作码 opcode */
    __u8    dst_reg:4;    /* 目标寄存器 dest register */
    __u8    src_reg:4;    /* 源寄存器 source register */
    __s16    off;        /* 有符号的偏移 signed offset */
    __s32    imm;        /* 有符号的立即数 signed immediate constant */
};

ALU指令: 64-bit

操作对象为64位

操作码 助记符 伪代码
0x07 add dst, imm dst += imm
0x0f add dst, src dst += src
0x17 sub dst, imm dst -= imm
0x1f sub dst, src dst -= src
0x27 mul dst, imm dst *= imm
0x2f mul dst, src dst *= src
0x37 div dst, imm dst /= imm
0x3f div dst, src dst /= src
0x47 or dst, imm dst
0x4f or dst, src dst
0x57 and dst, imm dst &= imm
0x5f and dst, src dst &= src
0x67 lsh dst, imm dst <<= imm
0x6f lsh dst, src dst <<= src
0x77 rsh dst, imm dst >>= imm (logical)
0x7f rsh dst, src dst >>= src (logical)
0x87 neg dst dst = -dst
0x97 mod dst, imm dst %= imm
0x9f mod dst, src dst %= src
0xa7 xor dst, imm dst ^= imm
0xaf xor dst, src dst ^= src
0xb7 mov dst, imm dst = imm
0xbf mov dst, src dst = src
0xc7 arsh dst, imm dst >>= imm (arithmetic)
0xcf arsh dst, src dst >>= src (arithmetic)

ALU指令:32-bit

这些操作码只使用了他们操作数的低32位, 并且用0初始化目标寄存器的高32位(操作对象是32位)

操作码 助记符 伪代码
0x04 add32 dst, imm dst += imm
0x0c add32 dst, src dst += src
0x14 sub32 dst, imm dst -= imm
0x1c sub32 dst, src dst -= src
0x24 mul32 dst, imm dst *= imm
0x2c mul32 dst, src dst *= src
0x34 div32 dst, imm dst /= imm
0x3c div32 dst, src dst /= src
0x44 or32 dst, imm dst
0x4c or32 dst, src dst
0x54 and32 dst, imm dst &= imm
0x5c and32 dst, src dst &= src
0x64 lsh32 dst, imm dst <<= imm
0x6c lsh32 dst, src dst <<= src
0x74 rsh32 dst, imm dst >>= imm (logical)
0x7c rsh32 dst, src dst >>= src (logical)
0x84 neg32 dst dst = -dst
0x94 mod32 dst, imm dst %= imm
0x9c mod32 dst, src dst %= src
0xa4 xor32 dst, imm dst ^= imm
0xac xor32 dst, src dst ^= src
0xb4 mov32 dst, imm dst = imm
0xbc mov32 dst, src dst = src
0xc4 arsh32 dst, imm dst >>= imm (arithmetic)
0xcc arsh32 dst, src dst >>= src (arithmetic)

字节交换指令

操作码 助记符 伪代码
0xd4 (imm == 16) le16 dst dst = htole16(dst)
0xd4 (imm == 32) le32 dst dst = htole32(dst)
0xd4 (imm == 64) le64 dst dst = htole64(dst)
0xdc (imm == 16) be16 dst dst = htobe16(dst)
0xdc (imm == 32) be32 dst dst = htobe32(dst)
0xdc (imm == 64) be64 dst dst = htobe64(dst)

内存指令

操作码 助记符 伪代码
0x18 lddw dst, imm dst = imm
0x20 ldabsw src, dst, imm See kernel documentation
0x28 ldabsh src, dst, imm …
0x30 ldabsb src, dst, imm …
0x38 ldabsdw src, dst, imm …
0x40 ldindw src, dst, imm …
0x48 ldindh src, dst, imm …
0x50 ldindb src, dst, imm …
0x58 ldinddw src, dst, imm …
0x61 ldxw dst, [src+off] dst = (uint32_t ) (src + off)
0x69 ldxh dst, [src+off] dst = (uint16_t ) (src + off)
0x71 ldxb dst, [src+off] dst = (uint8_t ) (src + off)
0x79 ldxdw dst, [src+off] dst = (uint64_t ) (src + off)
0x62 stw [dst+off], imm (uint32_t ) (dst + off) = imm
0x6a sth [dst+off], imm (uint16_t ) (dst + off) = imm
0x72 stb [dst+off], imm (uint8_t ) (dst + off) = imm
0x7a stdw [dst+off], imm (uint64_t ) (dst + off) = imm
0x63 stxw [dst+off], src (uint32_t ) (dst + off) = src
0x6b stxh [dst+off], src (uint16_t ) (dst + off) = src
0x73 stxb [dst+off], src (uint8_t ) (dst + off) = src
0x7b stxdw [dst+off], src (uint64_t ) (dst + off) = src

分支指令

操作码 助记符 伪代码
0x05 ja +off PC += off
0x15 jeq dst, imm, +off PC += off if dst == imm
0x1d jeq dst, src, +off PC += off if dst == src
0x25 jgt dst, imm, +off PC += off if dst > imm
0x2d jgt dst, src, +off PC += off if dst > src
0x35 jge dst, imm, +off PC += off if dst >= imm
0x3d jge dst, src, +off PC += off if dst >= src
0xa5 jlt dst, imm, +off PC += off if dst < imm
0xad jlt dst, src, +off PC += off if dst < src
0xb5 jle dst, imm, +off PC += off if dst <= imm
0xbd jle dst, src, +off PC += off if dst <= src
0x45 jset dst, imm, +off PC += off if dst & imm
0x4d jset dst, src, +off PC += off if dst & src
0x55 jne dst, imm, +off PC += off if dst != imm
0x5d jne dst, src, +off PC += off if dst != src
0x65 jsgt dst, imm, +off PC += off if dst > imm (signed)
0x6d jsgt dst, src, +off PC += off if dst > src (signed)
0x75 jsge dst, imm, +off PC += off if dst >= imm (signed)
0x7d jsge dst, src, +off PC += off if dst >= src (signed)
0xc5 jslt dst, imm, +off PC += off if dst < imm (signed)
0xcd jslt dst, src, +off PC += off if dst < src (signed)
0xd5 jsle dst, imm, +off PC += off if dst <= imm (signed)
0xdd jsle dst, src, +off PC += off if dst <= src (signed)
0x85 call imm Function call
0x95 exit return r0

https://github.com/iovisor/bpf-docs/blob/master/eBPF.md

 

汇编编写eBPF程序

根据上表我们可以直接写eBPF字节码

struct bpf_insn bpf_prog[] = {
    { 0xb7, 0, 0, 0, 0x123 },   // mov r0, 0x123
    { 0xb7, 1, 0, 0, 0x456 },   // mov r1, 0x456
    { 0x0F, 0, 1, 0, 0 },       // add r0, r1
    { 0x95, 0, 0, 0, 0x0 },     // exit 
};

利用上一章说过的方法加载BPF程序, 验证器输出的日志如下, 表示已经接受了此程序

用字节码很不直观, 我们可以通过对初始化struct bpf_insn进行一个包裹, 以方便编写, 不明白的话可以对照上面的指令编码

首先进行指令类型sc的定义, 表示指令属于那个大类

#define BPF_CLASS(code) ((code) & 0x07) //指令种类为指令操作码的低3bit
#define BPF_ALU64    0x07    /* 操作64位对象的ALU指令种类 */
#define    BPF_JMP        0x05  //跳转指令类别

接着进行操作码op部分的定义, 这部分表示具体是哪个操作码, 也就是指令要干什么

#define BPF_OP(code)    ((code) & 0xf0)  //操作数为操作码的高4bit
#define BPF_MOV        0xb0    /* 把寄存器移动到寄存器 */
#define    BPF_ADD        0x00     //加法操作
#define BPF_EXIT    0x90    /* 从函数中返回 */

对于ALU与JMP指令的操作码, 还有1bit的s需要定义, 表示操作的来源

#define BPF_SRC(code)   ((code) & 0x08)    //只占用第4bit一个bit
#define        BPF_K        0x00    //源操作数是立即数, 立即数的值在imm中表示
#define        BPF_X        0x08    //源操作数是寄存器,具体是哪一个寄存器在src字段表示

下一步对于寄存器进行定义, 就是用枚举类型对r0~r10从0~10进行编码

enum {
    BPF_REG_0 = 0,
    BPF_REG_1,
    BPF_REG_2,
    BPF_REG_3,
    BPF_REG_4,
    BPF_REG_5,
    BPF_REG_6,
    BPF_REG_7,
    BPF_REG_8,
    BPF_REG_9,
    BPF_REG_10,
    __MAX_BPF_REG,
};

基本元素都有了之后就可组合为表示指令的宏

/*
    给寄存器赋值, mov DST, IMM
    操作码: BPF_ALU64 | BPF_MOV表示要进行赋值操作, BPF_K表示要源是立即数IMM
*/
#define BPF_MOV64_IMM(DST, IMM)                    \
    ((struct bpf_insn) {                    \
        .code  = BPF_ALU64 | BPF_MOV | BPF_K,        \
        .dst_reg = DST,                    \
        .src_reg = 0,                    \
        .off   = 0,                    \
        .imm   = IMM })


/*
    两个寄存器之间的ALU运算指令: OP DST, SRC; 
    OP可以是加减乘除..., DST SRC表示是那个寄存器
    操作码: BPF_ALU64|BPF_OP(OP)表示执行什么ALU64操作, BPF_X表示源操作数是寄存器
*/
#define BPF_ALU64_REG(OP, DST, SRC)                \
    ((struct bpf_insn) {                    \
        .code  = BPF_ALU64 | BPF_OP(OP) | BPF_X,    \
        .dst_reg = DST,                    \
        .src_reg = SRC,                    \
        .off   = 0,                    \
        .imm   = 0 })

/*
    退出指令: exit
    操作码: BPF_JMP|BPF_EXIT表示要进行跳转指令类比中的退出指令
*/
#define BPF_EXIT_INSN()                        \
    ((struct bpf_insn) {                    \
        .code  = BPF_JMP | BPF_EXIT,            \
        .dst_reg = 0,                    \
        .src_reg = 0,                    \
        .off   = 0,                    \
        .imm   = 0 })

借用以上宏定义, 我们可以不用令人困惑的常数重新编写这个eBPF程序, 效果与之前一样

    struct bpf_insn bpf_prog[] = {
        BPF_MOV64_IMM(BPF_REG_0, 0x123),                 //{ 0xb7, 0, 0, 0, 0x123 },  mov r0, 0x123
        BPF_MOV64_IMM(BPF_REG_1, 0x456),                 //{ 0xb7, 1, 0, 0, 0x456 },  mov r1, 0x456
        BPF_ALU64_REG(BPF_ADD, BPF_REG_0, BPF_REG_1),    //{ 0x0F, 0, 1, 0, 0 }, add r0, r1
        BPF_EXIT_INSN()                                  //{ 0x95, 0, 0, 0, 0x0 } exit 
    };

实际上, 在#include <linux/bpf.h>中含有指令操作码等常数的定义, 在内核的源码目录samples/bpf/bpf_insn.h就含有上述指令的宏定义, 而且更全面, 我们只要把此文件与源码放在同一目录, 然后#include "./bpf_insn.h"就可以直接使用这些宏来定义eBPF指令的字节码

 

C编写eBPF指令

还是一样的程序, 我们换成C写, 由于gcc不支持编译BPF程序, 因此要用clang或者llvm来编译, -target bpf表示编译为eBPF字节码, -c表示编译为目标文件即可, 因为eBPF是没有入口点的, 没法编译为可执行文件. 转换过程: C---llvm--->eBPF---JIT--->本机指令

//clang -target bpf -c ./prog.c -o ./prog.o
unsigned long prog(void){
    unsigned long a=0x123;
    unsigned long b=0x456;
    return a+b;
}

编译出来的目标文件是ELF格式, 通过readelf可以看到最终编译出的字节码

objdump不支持反汇编eBPF, 可以使用llvm-objdump对字节码进行反编译, r10是栈指针, *(u32 *)(r10-4) = r1是在向栈中写入局部变量, 整体结构与之前用汇编写的类似

如果想要执行eBPF字节码的话需要先从ELF格式的目标文件中提取.text段, 利用llvm-objcopy可以做到

如何从elf中提取指定段https://stackoverflow.com/questions/3925075/how-to-extract-only-the-raw-contents-of-an-elf-section

之后编写一个加载器负责从prog.text中读入字节码, 放入缓冲区中, 然后使用BPF_PROG_LOAD命令进行bpf系统调用, 从而把字节码注入内核, 加载器代码如下, 整体与之前类似. 不明白的可以看前一篇文章

//gcc ./loader.c -o loader
#include <stdio.h>
#include <stdlib.h>  //为了exit()函数
#include <stdint.h>    //为了uint64_t等标准类型的定义
#include <errno.h>    //为了错误处理
#include <linux/bpf.h>    //位于/usr/include/linux/bpf.h, 包含BPF系统调用的一些常量, 以及一些结构体的定义
#include <sys/syscall.h>    //为了syscall()

//类型转换, 减少warning, 也可以不要
#define ptr_to_u64(x) ((uint64_t)x)

//对于系统调用的包装, __NR_bpf就是bpf对应的系统调用号, 一切BPF相关操作都通过这个系统调用与内核交互
int bpf(enum bpf_cmd cmd, union bpf_attr *attr, unsigned int size)
{
    return syscall(__NR_bpf, cmd, attr, size);
}

//用于保存BPF验证器的输出日志
#define LOG_BUF_SIZE 0x1000
char bpf_log_buf[LOG_BUF_SIZE];

//通过系统调用, 向内核加载一段BPF指令
int bpf_prog_load(enum bpf_prog_type type, const struct bpf_insn* insns, int insn_cnt, const char* license)
{
    union bpf_attr attr = {
        .prog_type = type,        //程序类型
        .insns = ptr_to_u64(insns),    //指向指令数组的指针
        .insn_cnt = insn_cnt,    //有多少条指令
        .license = ptr_to_u64(license),    //指向整数字符串的指针
        .log_buf = ptr_to_u64(bpf_log_buf),    //log输出缓冲区
        .log_size = LOG_BUF_SIZE,    //log缓冲区大小
        .log_level = 2,    //log等级
    };

    return bpf(BPF_PROG_LOAD, &attr, sizeof(attr));
}

//BPF程序就是一个bpf_insn数组, 一个struct bpf_insn代表一条bpf指令
struct bpf_insn bpf_prog[0x100];

int main(int argc, char **argv){
    //用法 loader <保存字节码的文件> <字节码长度, 字节为单位>

    //读入文件中的内容到bpf_prog数组中
    int text_len = atoi(argv[2]);
    int file = open(argv[1], O_RDONLY);
    if(read(file, (void *)bpf_prog, text_len)<0){  
        perror("read prog fail");
        exit(-1);
    }
    close(file);

    //加载执行
    int prog_fd = bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, bpf_prog, text_len/sizeof(bpf_prog[0]), "GPL");
    if(prog_fd<0){
        perror("BPF load prog");
        exit(-1);
    }
    printf("prog_fd: %d\n", prog_fd);
    printf("%s\n", bpf_log_buf);    //输出程序日志
}

clang编译出9条指令, 一个72字节, 使用命令./loader ./prog.text 72执行的结果如下

weinxin
版权声明
本站原创文章转载请注明文章出处及链接,谢谢合作!
评论:0   参与:  3