前言
BPF是内核中的顶级模块, 十分精妙, 相关书籍有限, 而且还都是从应用的视角看待BPF的, 我想写一系列文章, 从一个安全研究员的视角观察BPF, 以帮助更多的人学习和研究
linux内核观测技术一书中, 利用源码树中已有的包裹函数作为入门的例子, 层层包装导致编译时依赖繁多, 代码复杂无法一眼看到底层, 不是很友好
我们先明确: 用户空间所有的BPF相关函数, 归根结底都是对于bpf系统调用的包装, 我们完全可以跳过这些包裹函数, 手写bpf相关系统调用
最好的学习资料永远是man, 我翻译了manual中关于bpf系统调用的部分, 如下
系统调用声明
- bpf – 在扩展BPF映射或者程序上执行命令
#include <linux/bpf.h>
int bpf(int cmd, union bpf_attr *attr, unsigned int size);
- 此函数其实在
linux/bpf.h
中没有定义, 需要手动定义, 其实就是对于系统调用的包裹函数
int bpf(enum bpf_cmd cmd, union bpf_attr *attr, unsigned int size)
{
return syscall(__NR_bpf, cmd, attr, size);
}
描述
bpf()
系统调用会执行一系列exBPF相关的操作, eBPF类似于classic BPF(cBPF), 也用于进行网络包的过滤. 对于cBPF与eBPF内核都会在加载前进行静态分析, 以确保安全性
eBPF是cBPF的扩展, 包括调用一些固定的内核帮助函数(通过eBPF提供的BPF_CALL 操作码扩展), 并能访问一些共享数据结构, 如eBPF maps
eBPF设计架构
eBPF映射是为了保存多类型数据的通用数据结构. 数据类型都被是为二进制的, 所以用户在创建映射时只需要指明key和value的大小, 换而言之, 一个映射的key或者value可以是任意类型的数据
用户进程可以创建多个映射(用键值对是数据不透明的字节) 并且通过文件描述符fd访问. 不同的eBPF程序可以并行访问相同的映射. 映射里面保存什么取决于用户进程和eBPF程序
有一个特殊的映射类型, 称之为程序数组(program-array). 这个类型的映射保存引用其他eBPF进程的文件描述符. 在这个映射中进行查找时, 程序执行流会被就地重定位到另一个eBPF程序的开头, 并且不会返回到调用程序.嵌套最多32层 因此不会出现无限的套娃. 在运行时, 程序的文件描述符保存在一个可以修改的映射中, 因此程序可以进入某种要求有目的的改变. 程序数组映射中引用的程序都必须事先通过bpf()
加载到内核中. 如果映射查找失败, 当前程序会继续执行
大体上, eBPF程序都是被用户进程加载, 并在进程退出时自动卸载的. 有些特殊的情况, 如tc-bpf()
, 就算加载BPF程序的进程退出了, BPF程序还会驻留在内核中. 在这个例子中, BPF程序的文件描述符被进程关闭后, 由tc子系统保持对BPF程序的引用. 因此一个BPF程序是否在内核中存活取决于 通过bpf()载入内核后如何进一步附加在别的子系统上
每一个eBPF程序都是结束前可以安全执行的指令集合. 内核中一个验证器会静态的检查一个BPF程序是否会终止, 是否安全. 在验证期间, 内核会增加这个eBPF程序使用的所有映射的引用计数, 因此附加的映射不能被移除, 直到程序被卸载
eBPF程序可以附加在各种事件上. 这些事件可以是网络数据包的到达, 追踪时间, 根据网络队列规则的分类事件, 以及未来会被加上的其他事件. 一个新事件会触发eBPF程序的执行, 也可能在eBPF映射中保存事件相关的信息. 除了保存数据, eBPF程序还可能调用一些固定的内核帮助函数集合
同一个eBPF程序可以附加到多个事件, 并且不同的eBPF程序可以访问同一个映射, 示意图如下
tracing tracing tracing packet packet packet
event A event B event C on eth0 on eth1 on eth2
| | | | | ^
| | | | v |
--> tracing <-- tracing socket tc ingress tc egress
prog_1 prog_2 prog_3 classifier action
| | | | prog_4 prog_5
|--- -----| |------| map_3 | |
map_1 map_2 --| map_4 |--
系统调用参数
bpf()
系统调用的执行的操作是由cmd参数决定的. 每一个操作都有通过attr
传递的对应参数, 这个参数是指向公用体类型bpf_attr
的指针, size
参数代表attr
指针指向的数据长度
cmd
可以是下面的值
BPF_MAP_CREATE
: 创建一个映射, 返回一个引用此此映射的文件描述符. close-on-exec标志会自动设置BPF_MAP_LOOKUP_ELEM
在指定的映射中根据key查找一个元素, 并返回他的值BPF_MAP_UPDATE_ELEM
在指定映射中创建或者更新一个元素BPF_MAP_DELETE_ELEM
在指定映射中根据key查找并删除一个元素BFP_MAP_GET_NEXT_KEY
在指定映射中根据key查找一个元素, 并返回下一个元素的keyBPF_PROG_LOAD
: 验证并加载一个eBPF程序, 返回一个与此程序关联的新文件描述符. close-on-exec标志也会自动加上
公用体bfp_attr
由多种用于不同bfp命令的匿名结构体组成:
union bpf_attr {
struct { /* 被BPF_MAP_CREATE使用 */
__u32 map_type; /* 映射的类型 */
__u32 key_size; /* key有多少字节 size of key in bytes */
__u32 value_size; /* value有多少字节 size of value in bytes */
__u32 max_entries; /* 一个map中最多多少条映射maximum number of entries in a map */
};
struct { /* 被BPF_MAP_*_ELEM和BPF_MAP_GET_NEXT_KEY使用 */
__u32 map_fd;
__aligned_u64 key;
union {
__aligned_u64 value;
__aligned_u64 next_key;
};
__u64 flags;
};
struct { /* 被BPF_PROG_LOAD使用 */
__u32 prog_type;
__u32 insn_cnt;
__aligned_u64 insns; /* 'const struct bpf_insn *' */
__aligned_u64 license; /* 'const char *' */
__u32 log_level; /* 验证器的详细级别 */
__u32 log_size; /* 用户缓冲区的大小 size of user buffer */
__aligned_u64 log_buf; /* 用户提供的char*缓冲区 user supplied 'char *' buffer */
__u32 kern_version;
/* checked when prog_type=kprobe (since Linux 4.1) */
};
} __attribute__((aligned(8)));
eBPF映射
映射是一种保存不同类型数据的通用数据结构. 映射可以在不同eBPF内核程序中共享数据, 也可以在用户进程和内核之间共享数据.
每一个映射都有如下属性
- 类型type
- 做多多少个元素
- key有多少字节
- value有多少字节
下列包裹函数展示了如何使用多种bpf系统调用访问映射, 这些函数通过cmd参数代表不同的操作
BPF_MAP_CREATE
BPF_MAP_CREATE
命令可用于创建新映射, 返回一个引用此映射的文件描述符
int bpf_create_map(enum bpf_map_type map_type,
unsigned int key_size,
unsigned int value_size,
unsigned int max_entries)
{
union bpf_attr attr = { //设置attr指向的对象
.map_type = map_type,
.key_size = key_size,
.value_size = value_size,
.max_entries = max_entries
};
return bpf(BPF_MAP_CREATE, &attr, sizeof(attr)); //进行系统调用
}
新映射的种类由map_type
指定, 属性由key_size, value_size, max_entries
指定, 如果成功的话返回文件描述符, 失败的话返回-1
key_size, value_size
属性会在加载时被验证器使用, 来检查程序是否用正确初始化的key来调用bfp_map_*_elem()
, 检查映射元素value是否超过指定的value_size.
例如一个映射创建时key_size
为8, eBPF程序调用bpf_map_lookup_elem(map_fd, fp - 4)
, 程序会被拒绝, 因为kernel内的助手函数bpf_map_lookup_elem(map_fd, void *key)
期望从key指向的位置读入8字节, 但是fp-4
(fp是栈顶)起始地址会导致访问栈时越界
类似的, 如果一个映射用value_size=1
创建, eBPF程序包含
value = bpf_map_lookup_elem(...);
*(u32 *) value = 1;
这个程序会被拒绝执行, 因为他访问的value指针超过了value_size指定的1字节限制
目前下列值可用于map_type
enum bpf_map_type {
BPF_MAP_TYPE_UNSPEC, /* Reserve 0 as invalid map type */
BPF_MAP_TYPE_HASH,
BPF_MAP_TYPE_ARRAY,
BPF_MAP_TYPE_PROG_ARRAY,
BPF_MAP_TYPE_PERF_EVENT_ARRAY,
BPF_MAP_TYPE_PERCPU_HASH,
BPF_MAP_TYPE_PERCPU_ARRAY,
BPF_MAP_TYPE_STACK_TRACE,
BPF_MAP_TYPE_CGROUP_ARRAY,
BPF_MAP_TYPE_LRU_HASH,
BPF_MAP_TYPE_LRU_PERCPU_HASH,
BPF_MAP_TYPE_LPM_TRIE,
BPF_MAP_TYPE_ARRAY_OF_MAPS,
BPF_MAP_TYPE_HASH_OF_MAPS,
BPF_MAP_TYPE_DEVMAP,
BPF_MAP_TYPE_SOCKMAP,
BPF_MAP_TYPE_CPUMAP,
BPF_MAP_TYPE_XSKMAP,
BPF_MAP_TYPE_SOCKHASH,
BPF_MAP_TYPE_CGROUP_STORAGE,
BPF_MAP_TYPE_REUSEPORT_SOCKARRAY,
BPF_MAP_TYPE_PERCPU_CGROUP_STORAGE,
BPF_MAP_TYPE_QUEUE,
BPF_MAP_TYPE_STACK,
/* See /usr/include/linux/bpf.h for the full list. */
};
map_type
选择内核中一个可用的map实现. 对于所有的map类型, eBPF程序都使用相同的bpf_map_look_elem()
和bpf_map_update_elem()
助手函数访问.
BPF_MAP_LOOK_ELEM
BPF_MAP_LOOKUP_ELEM
命令用于在fd指向的映射中根据key查找对应元素
int bpf_lookup_elem(int fd, const void* key, void* value)
{
union bpf_attr attr = {
.map_fd = fd,
.key = ptr_to_u64(key),
.value = ptr_to_u64(value),
};
return bpf(BPF_MAP_LOOKUP_ELEM, &attr, sizeof(attr));
}
如果找到一个元素那么会返回0并把元素的值保存在value中, value必须是指向value_size
字节的缓冲区
如果没找到, 会返回-1, 并把errno
设置为ENOENT
BPF_MAP_UPDATE_ELEM
BPF_MAP_UPDATE_ELEM
命令在fd引用的映射中用给定的key/value
去创建或者更新一个元素
int bpf_update_elem(int fd, const void* key, const void* value, uint64_t flags)
{
union bpf_attr attr = {
.map_fd = fd,
.key = ptr_to_u64(key),
.value = ptr_to_u64(value),
.flags = flags,
};
return bpf(BPF_MAP_UPDATE_ELEM, &attr, sizeof(attr));
}
flags
参数应该被指定为下面中的一个
BPF_ANY
创建一个新元素或者更新一个已有的BPF_NOEXIST
只在元素不存在的情况下创建一个新的元素BPF_EXIST
更新一个已经存在的元素
如果成功的话返回0, 出错返回-1, 并且errno
会被设置为EINVAL, EPERM, ENOMEM, E2BIG
E2BIG
表示映射中的元素数量已经到达了创建时max_entries
指定的上限EEXIST
表示flag设置了BPF_NOEXIST
但是key
已有对应元素ENOENT
表示flag设置了BPF_EXIST
但是key
没有对应元素
BPF_MAP_DELETE_ELEM
BPF_MAP_DELETE_ELEM
命令用于在fd指向的映射汇总删除键为key
的元素
int bpf_delete_elem(int fd, const void* key)
{
union bpf_attr attr = {
.map_fd = fd,
.key = ptr_to_u64(key),
};
return bpf(BPF_MAP_DELETE_ELEM, &attr, sizeof(attr));
}
成功的话返回0, 如果对应元素不存在那么会返回-1, 并且errno
会被设置为ENOENT
BPF_MAP_GET_NEXT_KEY
BPF_MAP_GET_NEXT_KEY
命令用于在fd引用的映射中根据key
查找对应元素, 并设置next_key
指向下一个元素的键
int bpf_get_next_key(int fd, const void* key, void* next_key)
{
union bpf_attr attr = {
.map_fd = fd,
.key = ptr_to_u64(key),
.next_key = ptr_to_u64(next_key),
};
return bpf(BPF_MAP_GET_NEXT_KEY, &attr, sizeof(attr));
}
如果key
被找到了, 那么会返回0并设置指针netx_pointer
指向下一个元素的键. 如果key
没找到, 会返回0并设置next_pointer
指向映射中第一个元素的键. 如果key
就是最后一个元素呀了, 那么会返回-1, 并设置errno
为ENOENT
. errno
其他可能的值为ENOMEM, EFAULT, EPERM, EINVAL
. 这个方法可用于迭代map中所有的元素
close(map_fd)
删除map_fd
引用的映射. 当创建映射的用户进程退出时, 所有的映射都会被自动删除
eBPF映射的种类
支持下列映射种类
BPF_MAP_TYPE_HASH
Hash-table映射有如下特征
- 映射由用户空间的程序创建和删除. 用户空间的程序和eBPF程序都可以进行查找, 更新, 删除操作
- 内核负责键值对的分配和释放工作
- 当到达
max_entries
数量极限时, 助手函数map_update_elem()
无法插入新的元素, (这保证了eBPF不会耗尽内存) map_update_elem()
会自动替换已经存在的元素
Hash-table映射对于查找的速度优化过
BPF_MAP_TYPE_ARRAY
数组映射有如下特征
- 为了最快的超找速度优化过. 在未来, 验证器或者JIT编译器可能会识别使用常量键的
lookup()
操作并将其有优化为常量指针. 既然指针和value_size
在eBPF生存期间都是常数, 也有可能把一个非常量键优化为直接的指针运算(类似于C数组中的基址寻址). 换而言之,array_map_lookup_elem()
可能会被验证器或者JIT编译器内联, 同时保留从用户空间的并发访问能力 - 在初始化时, 所有的数组元素都被预先分配并0初始化
- 映射的键就是数组的下标, 必须是4字节的
map_delete_elem()
以EINVAL
错误失败,因为数组中的元素不能被删除map_update_elem()
会以非原子的方式替换一个元素. 想要原子更新的话应该使用hash-table映射. 但是有一个可用于数组的特殊情况: 内建的原子函数__sync_fetch_and_add()
可用于32或者64位的原子计数器上. 例如: 如果值代表一个单独的计数器, 可以被用在整个值自身, 如果一个结构体包含多个计数器,此函数可以被用在单独的计数器上. 这对于事件的聚合和统计来说十分有用
数组映射有如下用途
- 作为全局的eBPF变量: 只有一个元素, 键为0的数组. value是全局变量的集合, eBPF程序可使用这些变量保存时间的状态
- 聚合追踪事件到一组固定的桶中
- 统计网络事件, 例如数据包的数量和大小
BPF_MAP_TYPE_PROG_ARRAY
一个程序数组映射是一种特殊的数组映射, 其映射的值只包含引用其他eBPF程序的文件描述符. 因此key_size
和value_size
都必须被指定为四字节(数组映射的index为4字节, 文件描述符为4字节). 此映射助手函数bpf_tail_call()
结合使用
这意味着一个带有程序数组映射的eBPF程序可以从kernel一侧调用void bpf_tail_call(void *context, void *prog_map, unsigned int index);
因而用程序数组中一个给定程序替换自己的程序执行流. 程序数组可以被当做一种切换到其他eBPF程序的跳表(jump-table), 被调用的程序会继续使用同一个栈. 当跳转到一个新程序时, 他再也不会返回到原来的老程序
如果用给的index在程序数组中没有发现eBPF程序(因为对应槽中没有一个有效的文件描述符, 或者index越界, 或者达到32层嵌套的限制), 会继续执行当前eBPF程序. 这部分(跳转指令后面)可用于默认情况的错误处理
程序数组映射在追踪或者网络中很有用, 可用于在自己的子程序中处理单个系统调用或者协议(原eBPF作为任务分配器, 根据每种情况调用对应的eBPF子程序). 此方法有助于性能改善, 并有可能突破单个eBPF程序的指令数量限制. 在动态环境下, 一个用户空间的守护进程可能在运行时间用更新版本的程序自动替换单个子程序, 以改变整个程序的行为. 比如在全局策略改版的情况下
加载eBPF程序
BPF_PROG_LOAD
命令用于在内核中装载eBPF程序, 返回一个与eBPF程序关联的文件描述符
char bpf_log_buf[LOG_BUF_SIZE];
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_size = LOG_BUF_SIZE,
.log_level = 1,
};
return bpf(BPF_PROG_LOAD, &attr, sizeof(attr));
}
prog_type
是下列可用程序类型之一
enum bpf_prog_type {
BPF_PROG_TYPE_UNSPEC, /* Reserve 0 as invalid program type */
BPF_PROG_TYPE_SOCKET_FILTER,
BPF_PROG_TYPE_KPROBE,
BPF_PROG_TYPE_SCHED_CLS,
BPF_PROG_TYPE_SCHED_ACT,
BPF_PROG_TYPE_TRACEPOINT,
BPF_PROG_TYPE_XDP,
BPF_PROG_TYPE_PERF_EVENT,
BPF_PROG_TYPE_CGROUP_SKB,
BPF_PROG_TYPE_CGROUP_SOCK,
BPF_PROG_TYPE_LWT_IN,
BPF_PROG_TYPE_LWT_OUT,
BPF_PROG_TYPE_LWT_XMIT,
BPF_PROG_TYPE_SOCK_OPS,
BPF_PROG_TYPE_SK_SKB,
BPF_PROG_TYPE_CGROUP_DEVICE,
BPF_PROG_TYPE_SK_MSG,
BPF_PROG_TYPE_RAW_TRACEPOINT,
BPF_PROG_TYPE_CGROUP_SOCK_ADDR,
BPF_PROG_TYPE_LWT_SEG6LOCAL,
BPF_PROG_TYPE_LIRC_MODE2,
BPF_PROG_TYPE_SK_REUSEPORT,
BPF_PROG_TYPE_FLOW_DISSECTOR,
/* See /usr/include/linux/bpf.h for the full list. */
};
eBPF程序类型的细节在后面, bpf_attr
剩余区域按照如下设置
insns
是struct bpf_insn
指令组成的数组insn_cnt
是insns
中指令的个数license
是许可字符串, 为了与标志为gpl_only
的助手函数匹配必须设置GPLlog_buf
是一个调用者分配的缓冲区, 内核中的验证器可以在里面保存验证的log信息. 这个log信息由多行字符串组成, 目的是让程序作者明白为什么验证器认为这个程序是不安全的(相当于编译器的日志), 随着验证器的发展, 输出格式可能会改变log_size
是log_buf
的缓冲区大小, 要是缓冲区不足以保存全部的验证器日志, 那么会返回-1, 并把errno
设置为ENOSPC
log_level
是验证器日志的详细级别, 00表示验证器不会提供日志, 在这种情况下log_buf
必须是空指针,log_size
必须是0
对返回的文件描述符调用close()
会卸载eBPF程序
映射可以被eBPF程序访问, 并被用于在eBPF程序之间, 在eBPF与用户程序之间交换数据. 例如, eBPF程序可以处理各种时间(kprobe, packet)并保存他们的数据到映射中, 并且用户空间的程序可以从映射中获取数据. 反过来用户空间的程序可以把映射当做一种配置机制, 用eBPF程序检查过的值填充映射, 可以根据值动态的改变程序的行为
eBPF程序种类
eBPF程序的种类决定了能调用哪些内核助手函数. 程序的种类也决定了程序的输入-struct bpf_context
的格式(也就是首次运行时传递给eBPF程序的一些数据)
例如, 作为socket过滤程序一个追踪程序不一定有一组相同的助手函数(可能都有的通用助手函数). 类似的, 一个追踪程序的输入(context)是一些寄存器值的集合, 对于socket过滤器来说是一个网络数据包
对于特定类型的eBPF程序可用函数集合未来可能会增加
下列程序类型是支持的
BPF_PROG_TYPE_SOCKET_FILTER
, 目前,BPF_PROG_TYPE_SOCKET_FILTER
其可用函数集合如下bpf_map_lookup_elem(map_fd, void *key)
: 在map_fd中寻找keybpf_map_update_elem(map_fd, void *key, void *value)
: 更新key或者valuebpf_map_delete_elem(map_fd, void *key)
在map_fd中删除一个键bpf_context
参数是一个指向struct __sk_buff
(网络数据包缓冲区)的指针
BPF_PROG_TYPE_KPROBE
BPF_PROG_TYPE_SCHED_CLS
BPF_PROG_TYPE_SCHED_ACT
事件
一但一个程序被加载, 他就可以附加到一个事件上. 各种内核子系统都有不同的方式去做到这一点
从linux3.19开始, 如下调用会把程序prog_fd
附加到先前通过socket()创建的套接字sockfd
上
setsockopt(sockfd, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd));
自从linux4.1开始, 如下调用会把prog_fd
指向的eBPF程序附加到一个perf事件描述符event_fd
, 这个描述符先前由perf_event_open()
创建
ioctl(event_fd, PERF_EVENT_IOC_SET_BPF, prog_fd);
返回值
对于一次成功的调用, 返回值取决于操作
BPF_MAP_CREATE
: 返回与eBPF映射关联的文件描述符BPF_PROG_LOAD
: 返回与eBPF程序关联的文件描述符- 其他命令: 0
如果发生错误就返回-1, errno设置为错误原因
其他相关笔记
在linux4.4之前, 所有的bpf()命令都要求调用者有CAP_SYS_ADMIN
的能力, 从linux4.4开始, 非特权用户可以创建受限的BPF_PROG_TYPE_SOCKET_FILTER
类型的程序和相关的映射. 然后他们不能在映射里面保存内核指针, 现在只能使用如下助手函数
- get_random()
- get_smp_processer_id()
- tail_call())
- ktime_get_ns()
非特权访问可以通过向/proc/sys/kernel/unprivileged_bpf_disabled
写入1来阻止
eBPF对象(映射和程序)可以在进程之间共享, 例如, 在fork()之后, 子进程会继承同一个指向eBPF对象的文件描述符.另外, 引用eBPF对象的文件描述符也可以通过UNIX domin socket传递. 引用eBPF对象的文件描述符也通过普通的方式使用dup(2)
和类似的调用进行复制. 一个eBPF对象只在所有的文件描述符引用都关闭之后才会被析构
eBPF程序可以使用受限的C语音写, 然后被编译成eBPF字节码. 在受限的C语音中, 很多特性都被删去了, 例如: 循环, 全局变量, 可变参函数, 浮点数, 传递一个结构体作为函数参数. 内核源码的samples/bpf/*_kern.c
文件中有些eBPF程序的样例
为了更好的性能, 内核包含一个能翻译eBPF字节码为本地机器指令的及时编译器(JIT, just-in-time compiler). 在linux4.15之前的内核, 这个JIT编译器是被默认禁止的, 但可以通过向/proc/sys/net/core/bpf_jit_enable
写入一个整数字符串来控制其行为
- 0: 禁用JIT编译器(默认的)
- 1: 正常的编译
- 2: 调试模式. 生成的指令会以十六进制的性质被复制到内核log中, 这个字节码可以通过内核源码树中
tools/net/bpf_jit_disasm.c
来进行反编译
从4.15开始, 内核可以用CONFIG_BPF_JIT_ALWAYS_ON
选项进行配置, 在这种情况下, JIT编译器总是会开启, 并且bpf_jit_enable
也初始化为1并且不可改变. (内核配置选项可以缓解潜在的针对BPF解释器的攻击)
eBPF的JIT编译器目前对下列架构可用
* x86-64 (since Linux 3.18; cBPF since Linux 3.0);
* ARM32 (since Linux 3.18; cBPF since Linux 3.4);
* SPARC 32 (since Linux 3.18; cBPF since Linux 3.5);
* ARM-64 (since Linux 3.18);
* s390 (since Linux 4.1; cBPF since Linux 3.7);
* PowerPC 64 (since Linux 4.8; cBPF since Linux 3.1);
* SPARC 64 (since Linux 4.12);
* x86-32 (since Linux 4.18);
* MIPS 64 (since Linux 4.18; cBPF since Linux 3.16);
* riscv (since Linux 5.1).
代码样例
为了更加直观, 我不引入内核源码树中的bpf_help.h
, 也不使用loader, 以更加直观的展示BPF的用法
数组映射的使用
//gcc ./bpf.c -o bpf
#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);
}
//创建一个映射, 参数含义: 映射类型, key所占自己, value所占字节, 最多多少个映射
int bpf_create_map(enum bpf_map_type map_type, unsigned int key_size, unsigned int value_size, unsigned int max_entries)
{
union bpf_attr attr = { //设置attr指向的对象
.map_type = map_type,
.key_size = key_size,
.value_size = value_size,
.max_entries = max_entries
};
return bpf(BPF_MAP_CREATE, &attr, sizeof(attr)); //进行系统调用
}
//在映射中更新一个键值对
int bpf_update_elem(int fd, const void* key, const void* value, uint64_t flags)
{
union bpf_attr attr = {
.map_fd = fd,
.key = ptr_to_u64(key),
.value = ptr_to_u64(value),
.flags = flags,
};
return bpf(BPF_MAP_UPDATE_ELEM, &attr, sizeof(attr));
}
//在映射中根据指针key指向的值搜索对应的值, 把值写入到value指向的内存中
int bpf_lookup_elem(int fd, const void* key, void* value)
{
union bpf_attr attr = {
.map_fd = fd,
.key = ptr_to_u64(key),
.value = ptr_to_u64(value),
};
return bpf(BPF_MAP_LOOKUP_ELEM, &attr, sizeof(attr));
}
int main(void){
//首先创建一个数组映射, 键和值都是4字节类型, 最多0x100个映射
int map_fd = bpf_create_map(BPF_MAP_TYPE_ARRAY, 4, 4, 0x100);
printf("BPF_map_fd: %d\n", map_fd);
//按照key->key+1的规律填充这个数组映射
for(int idx=0; idx<0x20; idx+=1){
int value = idx+1;
//记住, 数组映射中的元素预先分配, 已经存在, 不可删除, 因此flag要么是BPF_ANY, 要么是BPF_EXISTS, 表示更新一个已有的值
if(bpf_update_elem(map_fd, &idx, &value, BPF_EXIST)<0){
perror("BPF update error");
exit(-1);
}
}
//读入key
int key;
scanf("%d", &key);
//尝试在数组映射中查找对应的值
int value;
if(bpf_lookup_elem(map_fd, &key, &value)<0){
perror("BPF lookup error");
exit(-1);
}
printf("key: %d => value: %d\n", key, value);
}
运行结果
hash映射的使用
//gcc ./bpf.c -o bpf
#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);
}
//创建一个映射, 参数含义: 映射类型, key所占自己, value所占字节, 最多多少个映射
int bpf_create_map(enum bpf_map_type map_type, unsigned int key_size, unsigned int value_size, unsigned int max_entries)
{
union bpf_attr attr = { //设置attr指向的对象
.map_type = map_type,
.key_size = key_size,
.value_size = value_size,
.max_entries = max_entries
};
return bpf(BPF_MAP_CREATE, &attr, sizeof(attr)); //进行系统调用
}
//在映射中更新一个键值对
int bpf_update_elem(int fd, const void* key, const void* value, uint64_t flags)
{
union bpf_attr attr = {
.map_fd = fd,
.key = ptr_to_u64(key),
.value = ptr_to_u64(value),
.flags = flags,
};
return bpf(BPF_MAP_UPDATE_ELEM, &attr, sizeof(attr));
}
//在映射中根据指针key指向的值搜索对应的值, 把值写入到value指向的内存中
int bpf_lookup_elem(int fd, const void* key, void* value)
{
union bpf_attr attr = {
.map_fd = fd,
.key = ptr_to_u64(key),
.value = ptr_to_u64(value),
};
return bpf(BPF_MAP_LOOKUP_ELEM, &attr, sizeof(attr));
}
//字符串表
char *strtab[] = {
"The",
"Dog",
"DDDDog"
};
int main(void){
//创建一个hash映射, 键为4字节的int, 值为一个char*指针, 因此大小分别是sizeof(int)与sizeof(char*), 最多0x100个
int map_fd = bpf_create_map(BPF_MAP_TYPE_HASH, sizeof(int), sizeof(char*), 0x100);
printf("BPF_map_fd: %d\n", map_fd);
//用strtable初始化hash映射
for(int idx=0; idx<3; idx+=1){
char *value = strtab[idx];
//hash映射中元素预先是不存在的, 因此可以设置BPF_NOEXIST或者BPF_ANY标志
if(bpf_update_elem(map_fd, &idx, &value, BPF_NOEXIST)<0){
perror("BPF update error");
exit(-1);
}
}
//读入键
int key;
scanf("%d", &key);
//查找对应值, 把值作为char*类型
char *value;
if(bpf_lookup_elem(map_fd, &key, &value)<0){
perror("BPF lookup error");
exit(-1);
}
printf("key: %d => value: %s\n", key, value);
}
运行例子
加载BPF程序
加载BPF程序涉及到如何用BPF汇编, 我们先不管BPF汇编, 直接使用固定的汇编代码, 然后加载后运行
//gcc ./bpf.c -o bpf
#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[] = {
{ 0xb7, 0, 0, 0, 0x2 }, //初始化一个struct bpf_insn, 指令含义: mov r0, 0x2;
{ 0x95, 0, 0, 0, 0x0 }, //初始化一个struct bpf_insn, 指令含义: exit;
};
int main(void){
//加载一个bpf程序
int prog_fd = bpf_prog_load(BPF_PROG_TYPE_SOCKET_FILTER, bpf_prog, sizeof(bpf_prog)/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); //输出程序日志
}
运行情况
原始的BPF汇编
Linux Socket Filtering aka Berkeley Packet Filter (BPF) — The Linux Kernel documentation
原始的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中提取指定段linux - How to extract only the raw contents of an ELF section? - Stack Overflow
之后编写一个加载器负责从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
执行的结果如下