【译】eBPF 概述:第 2 部分:机器和字节码

简介: 【译】eBPF 概述:第 2 部分:机器和字节码

1. 前言


我们在第 1 篇文章中 介绍了 eBPF 虚拟机,包括其有意的设计限制以及如何从用户空间进程中进行交互。如果你还没有读过这篇文章,建议你在继续之前读一下,因为没有适当的介绍,直接开始接触机器和字节码的细节是比较困难的。如果有疑问,请看第 1 部分 开头的流程图。


本系列的第 2 部分对第 1 部分中研究的 eBPF 虚拟机和程序进行了更深入的探讨。掌握这些低层次的知识并不是强制性的,但可以为本系列的其他部分打下非常有用的基础,我们将在这些机制的基础上研究更高层次的工具。


2. 虚拟机


eBPF 是一个 RISC 寄存器机,共有11 个 64 位寄存器,一个程序计数器和 512 字节的固定大小的栈。9 个寄存器是通用读写的,1 个是只读栈指针,程序计数器是隐式的,也就是说,我们只能跳转到它的某个偏移量。VM 寄存器总是 64 位宽(即使在 32 位 ARM 处理器内核中运行!),如果最重要的 32 位被清零,则支持 32 位子寄存器寻址 - 这在第 4 部分交叉编译和在嵌入式设备上运行 eBPF 程序时非常有用。

这些寄存器是:


r0: 存储返回值,包括函数调用和当前程序退出代码
r1-r5: 作为函数调用参数使用,在程序启动时,r1 包含 "上下文" 参数指针
r6-r9: 这些在内核函数调用之间被保留下来
r10: 每个 eBPF 程序 512 字节栈的只读指针


在加载时提供的 eBPF程序类型决定了哪些内核函数的子集可以被调用,以及在程序启动时通过 r1 提供的 "上下文" 参数。存储在 r0 中的程序退出值的含义也由程序类型决定。

每个函数调用在寄存器 r1-r5 中最多可以有 5 个参数;这适用于 ebpf 到 ebpf 的调用和内核函数调用。寄存器 r1-r5 只能存储数字或指向栈的指针(作为函数的参数),不能直接指向任意的内存。所有的内存访问必须在 eBPF 程序中使用之前首先将数据加载到 eBPF 栈。这一限制有助于 eBPF 验证器,它简化了内存模型,使其更容易进行内核检查。


BPF 可访问的内核 "帮助(helper)" 函数是由内核通过类似于定义 syscalls 的 API 定义的(不能通过模块扩展),定义使用 BPF_CALL_* 宏。bpf.h试图为所有 BPF 可访问的内核辅助函数提供参考。例如,bpf_trace_printk的定义使用了 BPF_CALL_5 和 5 对类型 / 参数名称。定义 参数数据类型 是非常重要的,因为在每次 eBPF 程序加载时,eBPF 验证器会确保寄存器的数据类型与被调用者的参数类型相符。


eBPF 指令也是固定大小的 64 位编码,目前大约有 100 条指令,被分组为8 类。该虚拟机支持从通用内存(map、栈、如数据包缓冲区等的 "上下文",)进行 1-8 字节的加载 / 存储,前 / 后(非)条件跳转、算术 / 逻辑操作和函数调用。操作码格式格式深入研究的文档,请参考 Cilium 项目指令集文档。IOVisor 项目也维护了一个有用的指令规格

在本系列第 1 部分研究的例子中,我们使用了部分有用的 内核宏,使用以下结构 创建了一个 eBPF 字节码指令数组(所有指令都是这样编码的):


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 */
};
msb                                                        lsb
+------------------------+----------------+----+----+--------+
|immediate               |offset          |src |dst |opcode  |
+------------------------+----------------+----+----+--------+


让我们看看 BPF_JMP_IMM 指令,它编码了一个针对立即值的条件跳转。下面的宏注释对指令的逻辑应该是不言自明的。操作码编码了指令类别 BPF_JMP,操作(通过 BPF_OP 位域以确保核心性)和一个标志,表示它是对即期 / 常量值的操作,BPF_K。


#define BPF_OP(code)    ((code) & 0xf0)
#define BPF_K   0x00
/* Conditional jumps against immediates, if (dst_reg 'op' imm32) goto pc + off16 */
#define BPF_JMP_IMM(OP, DST, IMM, OFF)        \
  ((struct bpf_insn) {          \
    .code  = BPF_JMP | BPF_OP(OP) | BPF_K,    \
    .dst_reg = DST,         \
    .src_reg = 0,         \
    .off   = OFF,         \
    .imm   = IMM })


如果我们去计算该指令的值,或者拆解一个包含 BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2)的 eBPF 字节码,我们会发现它是 0x020015。这个特定的字节码非常频繁地被用来测试存储在 r0 中的函数调用的返回值;如果 r0 == 0,它就会跳过接下来的 2 条指令。


3. 重新认识字节码


现在我们已经有了必要的知识来完全理解本系列第 1 部分中 eBPF 例子中使用的字节码,现在我们将一步一步地进行详解。记住,sock_example.c是一个简单的用户空间程序,使用 eBPF 来统计回环接口上收到多少个 TCP、UDP 和 ICMP 协议包。


在更高层次上,代码所做的是从接收到的数据包中读取协议号,然后把它推到 eBPF 栈中,作为 map_lookup_elem 调用的索引,从而得到各自协议的数据包计数。


map_lookup_elem 函数在 r0 接收一个索引(或键)指针,在 r1 接收一个 map 文件描述符。如果查找调用成功,r0 将包含一个指向存储在协议索引的 map 值的指针。然后我们原子式地增加 map 值并退出。


BPF_MOV64_REG(BPF_REG_6, BPF_REG_1),


当一个 eBPF 程序启动时,r1 中的地址指向 context 上下文(当前情况下为数据包缓冲区)。r1 将在函数调用时用于参数,所以我们也将其存储在 r6 中作为备份。


BPF_LD_ABS(BPF_B, ETH_HLEN + offsetof(struct iphdr, protocol) /* R0 = ip->proto */),


这条指令从 context 上下文缓冲区的偏移量向 r0 加载一个字节(BPF_B),当前情况下是网络数据包缓冲区,所以我们从一个 iphdr 结构 中提供协议字节的偏移量,以加载到 r0。


BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_0, -4), /* *(u32 *)(fp - 4) = r0 */


将包含先前读取的协议的字(BPF_W)加载到栈上(由 r10 指出,从偏移量 -4 字节开始)。


BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4), /* r2 = fp - 4 */


将栈地址指针移至 r2 并减去 4,所以现在 r2 指向协议值,作为下一个 map 键查找的参数。


BPF_LD_MAP_FD(BPF_REG_1, map_fd),


将本地进程中的文件描述符引用包含协议包计数的 map 加载到 r1。


BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),


执行 map 查找调用,将栈中由 r2 指向的协议值作为 key。结果存储在 r0 中:一个指向由 key 索引的值的指针地址。


BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2),


还记得 0x020015 吗?这和第一节的字节码是一样的。如果 map 查找没有成功,r0 == 0,所以我们跳过下面两条指令。


BPF_MOV64_IMM(BPF_REG_1, 1), /* r1 = 1 */
BPF_RAW_INSN(BPF_STX | BPF_XADD | BPF_DW, BPF_REG_0, BPF_REG_1, 0, 0), /* xadd r0 += r1 */


递增 r0 所指向的地址的 map 值。


BPF_MOV64_IMM(BPF_REG_0, 0), /* r0 = 0 */
BPF_EXIT_INSN(),


将 eBPF 的 retcode 设置为 0 并退出。


尽管这个 sock_example 逻辑是非常简单(它只是在一个映射中增加一些数字),但在原始字节码中实现或理解它也是很难做到的。更加复杂的任务在像这样的汇编程序中完成会变得非常困难。展望未来,我们将准备使用更高级别的语言和工具来实现更强大的 eBPF 用例,而不费吹灰之力。


4. 总结


在这一部分中,我们仔细观察了 eBPF 虚拟机的寄存器和指令集,了解了 eBPF 可访问的内核函数是如何从字节码中调用的,以及它们是如何被核心内核通过类似 syscall 的特殊目的 API 定义的。我们也完全理解了第 1 部分例子中使用的字节码。还有一些未探索的领域,如创建多个 eBPF 程序函数或链式 eBPF 程序以绕过 Linux 发行版的 4096 条指令限制。也许我们会在以后的文章中探讨这些。


现在,主要的问题是编写原始字节码是很困难的,这非常像编写汇编代码,而且编写效果不高。在第 3 部分中,我们将开始研究使用高级语言编译成 eBPF 字节码,到此为止我们已经了解了虚拟机工作的底层基础知识。

目录
相关文章
Minecraft Forge部署以及部署时可能出现的问题以及解决方案
Minecraft Forge部署以及部署时可能出现的问题以及解决方案
1136 0
|
数据可视化 Python
神操作!用 Python 操作 xmind 绘制思维导图!
在平时的工作中当我们要总结一些知识的时候就需要一款工具来画画流程图,这里推荐 XMind 软件,用 Xmind 绘制的思维导图看起来思路清晰,那么今天的文章介绍关于思维导图的相关知识以及用 Python 如何操作 Xmind 绘制思维导图。
2257 0
神操作!用 Python 操作 xmind 绘制思维导图!
|
数据采集 测试技术 API
python爬虫之app爬取-微信朋友圈
搭建appium环境,appium基本使用,API操作等等
529 0
|
12月前
|
存储 安全 数据安全/隐私保护
数据传输中遇到问题要怎么解决
在数据传输中遇到问题时,可采取多种解决方案:使用可靠协议(如HTTPS、SFTP)、创建冗余备份、数据压缩与加密、错误检测与纠错、优化网络性能、解决数据丢失、降低延迟、提高安全性及解决带宽瓶颈。这些措施有助于确保数据传输的稳定、安全与高效。
|
存储 人工智能 弹性计算
通义万相AI绘画创作评测及图文搭建教程
【7月更文挑战第4天】阿里云的通义万相是AI绘画模型,结合ECS、OSS和API服务,提供无缝创作环境。用户上传图片至OSS,模型通过签名URL下载图片,然后生成AI艺术作品。模型服务具有高性能、易集成的特点,适用于多种场景如设计、广告等。用户可按指示在阿里云官网注册、充值、开通服务并部署。项目评测显示,其集成便捷、响应快、泛化能力强,但仍有改进空间,如增加图像控制选项和批量处理能力。相对于竞品,通义万相在成本、易用性和应用场景上有竞争力,值得推荐。
11578 9
|
开发工具 git
Jupyter Lab操作文档
**Jupyter Lab 概览:**集成编辑器、终端和自定义组件的环境。可定制主题、显示行号、切换语言。使用时,了解界面布局,通过`Ctrl+Enter`运行代码,`Shift+Enter`前进,`Alt+Enter`新建行。利用Markdown写作,通过Terminal执行命令,用快捷键提升效率,如`a/b`增删单元格,`m/y`切换模式。文件上传下载可使用OBS或终端工具。
Jupyter Lab操作文档
|
机器学习/深度学习 NoSQL Redis
Redis -- zset有序集合
Redis -- zset有序集合
310 0
|
移动开发 前端开发
改造ruoyi-nbcio-plus亿事达多租户前端系统的一些记录
改造ruoyi-nbcio-plus亿事达多租户前端系统的一些记录
186 0
|
人工智能 安全 数据安全/隐私保护
叫好不叫座?Arm、英特尔、AMD 等 5 位技术大咖畅聊机密计算技术
机密计算作为一项新兴技术,为我们如何安全处理和计算敏感数据提供了新的视角和解决方案。
|
开发者 iOS开发
appstore开发者名称修改
在App Store中,开发者名称是一个非常关键的信息,它决定了用户对应用的信任度和对开发者的认知程度。开发者名称也是一个非常重要的推广工具,一个好的开发者名称可以帮助开发者吸引更多的用户关注。因此,如果你想要修改你在App Store中的开发者名称,那么这篇文章就是为你准备的。