注:本文为 “ eBPF” 相关文章合辑。
未整理去重。
如有内容异常,请看原文。
一文读懂 eBPF 的前世今生
原创 宋赛 Linux 阅码场
说明背景
文档作为会议的记录和补充,会议主题为《eBPF 技术简介》,主讲人狄卫华
基本内容
BPF 技术来自于 1993 年发表的论文《The BSD packet filter: a new architecture for user-level packet capture》,最初是在内核中使用 BPF 技术过滤网络数据包来提升性能。通过工作于寄存器结构 CPU 之上的虚拟机设计和在内核层运行的 BPF 程序,可以将数据包过滤技术性能提升 20 倍以上。2014 年,Alexei Starovoitov 对 BPF 进行改造和功能添加,新版本被命名为 eBPF(extended Berkeley Packet Filter)而之前的 BPF 则被称为 cBPF(classic bpf),现在已基本废弃。将 eBPF 扩展到用户空间的改动,成为了该技术的转折点,使其慢慢演进为一个通用引擎,应用场景逐步扩展到内核各子模块。
eBPF 将数据与功能进行分离,数据从用户态加载,功能内置于内核中,使其具有稳定、高效、安全、热加载 / 卸载、内核内置和 VM 通用引擎简洁通用的特性。
eBPF 与 Linux 内核模块的对比如下:
维度 | Linux 内核模块 | eBPF |
---|---|---|
kprobes/tracepoints | 支持 | 支持 |
安全性 | 可能引入安全漏洞或导致内核 Panic | 通过验证器进行检查,可以保障内核安全 |
内核函数 | 可以调用内核函数 | 只能通过 BPF Helper 函数调用 |
编译性 | 需要编译内核 | 不需要编译内核,引入头文件即可 |
运行 | 基于相同内核运行 | 基于稳定 ABI 的 BPF 程序可以编译一次,各处运行 |
与应用程序交互 | 打印日志或文件 | 通过 perf_event 或 map 结构 |
数据结构丰富性 | 一般 | 丰富 |
入门门槛 | 高 | 低 |
升级 | 需要卸载和加载,可能导致处理流程中断 | 原子替换升级,不会造成处理流程中断 |
内核内置 | 视情况而定 | 内核内置支持 |
eBPF 工作原理
eBPF 基于事件驱动架构,在触发点条件满足时,会触发 eBPF 程序运行。具体流程如下:
- 编译:使用 LLVM 或者 GCC 将编写的 eBPF 代码编译称 eBPF 字节码;
- 加载:使用加载程序通过 bpf() 系统调用将字节码加载到内核中;
- 验证:内核使用验证器(verifier)组件保证执行字节码的安全性,以避免对内核造成灾难,在确认字节码安全后,通过 JIT compiler 将字节码转换为机器码,保证其高效执行。在将机器码加载到对应的模块执行;
- 触发运行:在内核对应事件触发时,运行的 eBPF 字节码程序,可通过 map 或者 perf_event 与用户态程序进行数据交互。
ebpf 程序从编写到加载进内核中运行,我们需要关注的内容包括:eBPF 辅助函数、eBPF 程序类型、eBPF maps。
eBPF 辅助函数
考虑安全性的问题,eBPF 程序不能随意调用内核函数,因此内核专门提供 eBPF 程序可以调用的辅助函数。辅助函数与程序类型有强对应关系,不同的程序类型可调用的辅助函数集合不同,这些函数由不同的 Linux 内核版本引入。
eBPF 程序类型
eBPF 字节码和 eBPF map 被加载到内核中是通过 bpf() 系统调用来实现的。Bpf() 的函数声明如下:
*int bpf(int cmd, union bpf_attr \*attr, unsigned int size);*
不同的 cmd 会执行不同的操作,每种操作需要配置不同的 attr,size 为 attr 指向的 union 的大小。当 cmd 为 BPF_PROG_LOAD 时,则是加载 ebpf 程序到内核中,返回与该程序关联的文件描述符,而 attr 指向的union bpf_attr中需要配置 ebpf 程序类型。ebpf 程序类型定义了 eBPF 程序可以挂载的事件类型以及事件的参数,也限定了可以访问的辅助函数的集合。常见的程序类型有:
BPF_PROG_TYPE_SOCKET_FILTER
BPF_PROG_TYPE_KPROBE
BPF_PROG_TYPE_SCHED_CLS
BPF_PROG_TYPE_SCHED_ACT
eBPF map
eBPF 程序最神奇的功能就是内核运行的代码与加载其的用户空间程序可以通过 map 机制实现双向通信。eBPF map 以 key/value 对保存在内核中,可以被任何 eBPF 程序访问;用户空间程序通过导出的文件描述符进行访问(在 bpf() 调用创建 map 时会产生文件描述符)。Map 类型支持 hash、array、LPM、socket 等类型。这些类型是在调用 bpf() 创建 map 时配置的。
eBPF 程序组成
后端:在内核中加载和运行的 eBPF 字节码。它将数据写入内核 map 和环形缓冲区的数据结构中
加载器:它将字节码后端加载到内核中。通常情况下,当加载器进程中止时,字节码会被内核自动卸载
前端:从数据结构中读取数据,并将其显示给用户
数据结构:后端和前端的通信手段。它们是由内核管理的 map 和环形缓冲区,可以通过文件描述符访问。需要在后端被加载之前创建,数据会持续存在,直到没有更多的后端或前端进行读写操作
*eBPF 的应用场景有哪些 *
观测和跟踪
将 eBPF 程序附加到跟踪点以及内核和用户应用探针点的能力,使得应用程序和系统本身的运行时行为具有前所未有的可见性。eBPF 不依赖于操作系统暴露的静态计数器和测量,而是实现了自定义指标的手机和内核内聚合,并基于广泛的可能来源生成可见性事件。eBPF 跟踪架构如下:
跟踪支持的事件包括:
- Kprobes/kretprobes:实现内核中动态跟踪,可跟踪 Linux 内核中的函数入口或返回点,非稳定 ABI 接口
- Uprobes/uretprobes:用户级别的动态跟踪。与 kprobes 类似,只是跟踪的函数为用户程序中的函数
- Tracepoints:内核中静态跟踪。Tracepoints 是内核开发人员维护的跟踪点,能够提供稳定的 ABI 接口,但是由于研发人员维护,数量和场景可能受限
- perf_events:定时采样和 PMC
- Socket 和其他网络 hook:网络套接字上执行操作、以及在发送或接收消息时运行 eBPF 程序。在内核的网络栈中还有称为 traffice control 或 tc 的 hook,eBPF 程序可以在初始数据包处理后运行
Linux 的不同位置的观测和跟踪,使用的工具不同,BCC(BPF Compiler Collection)提供的工具如下图:
网络
可编程性和效率的结合使得 eBPF 自然而然地满足了网络解决方案的所有数据包处理要求。eBPF 的可编程性使其能够在不离开 Linux 内核的包处理上下文的情况下,添加额外的协议解析器,并轻松编程任何转发逻辑以满足不断变化的需求。JIT 编译器提供的效率使其执行性能接近于本地编译的内核代码。
eBPF 在网络中的应用场景包括:
- 网络数据包过滤:实现实时流量丢弃和特定条件过滤后数据包
- TC 流量控制:编程网络数据路径 cls-bpf 分类器。cls-bpf 可以将 BPF 程序直接挂钩到入口和出口层,从而实现对于数据包流入和流出控制
- XDP:提供了一个仍然基于操作系统内核的安全执行环境,在设备驱动上下文中执行,可用于定制各种包处理应用。常用于三层路由转发、四层负载均衡、DDoS 防护、分布式防火墙等。
安全
虽然系统调用过滤、网络级过滤和进程上下文跟踪等方面通常由完全独立的系统处理,但 ebpf 允许将所有方面的可视性和控制结合起来,以创建更多上下文上运行的、具有更好控制水平的安全系统。应用场景如下:
- 网络安全策略:云原生场景下的 L3/L4/L7 层网络安全策略,以 Cilium 为代表
- 运行时安全:内核运行时安全检测工具,包括 Seccomp 与 LSM
软件开发生态
Bpftrace
bpftrace 是一种用于 eBPF 的高级跟踪语言,使用 LLVM 作为后端,将脚本编译为 BPF 字节码。
BCC
为了简化 BPF 程序开发,社区创建了 BCC 项目:其为编写、加载和运行 eBPF 程序提供了一个易于使用的框架,除了 “限制性 C” 之外,还可以通过编写简单的 python 和 Lua 脚本来实现。BCC 提供了大量跟踪和观测工具。
C 语言 - 原生
底层基于 libbpf 库实现用户态程序的编写和 BPF 程序的加载
C 语言 - libbpf-bootstrap
Libbpf-bootstrap 是一个脚手架工具,它为初级用户设置了尽可能多的东西,可直接进入编写 BPF 程序。Libbpf-bootstrap 依赖于 libbpf 并使用一个简单的 Makefile
Rust
存在一些 rust 开发库用于开发 eBPF。
Rust 开发库 | 使用说明 | 依赖环境 |
---|---|---|
libbpf-rs | libbpf 的 Rust 语言绑定,配合 libbpf-cargo,可以自动生成 eBPF 程序的脚手架头文件和主要代码结构 | 需要安装 libbpf |
redbpf | 提供一系列的 eBPF 工具集,支持用 Rust 同时开发用户态和内核态程序 | 需要 LLVM 和内核头文件 |
Aya | 类似于 redbpf,提供一系列的 eBPF 工具集,支持用 Rust 同时开发用户态和内核态程序 | 可选 LLVM(替换 rust-Ilvm) |
rust-bcc | BCC 库的 Rust 语言绑定 | 需要安装 libbcc 库 |
Go
用户空间程序用 Go 语言编写,程序一遍采用静态编译,分发和部署相对方便,如果将 BPF 编译后的字节码直接内嵌到 Go 程序中,分发则更加方便。
eBPF 的未来
内核面临的挑战包括:
・复杂度不断增长,性能和可扩展性新需求
・永远保持向后兼容
・特征渐进式演进
而 ebpf 内核则是完全可编程,安全地可编程。eBPF 是内核功能快速迭代和功能更加多样化的基石。
那些年读过的 eBPF 关键研究论文
原创 郑昱笙 酷玩 BPF
eBPF(扩展的伯克利数据包过滤器)是一种新兴的技术,允许在 Linux 内核中安全地执行用户提供的程序。近年来,它因加速网络处理、增强可观察性和实现可编程数据包处理而得到了广泛的应用。
本文我们将与大家一起分享那些年读过的 eBPF 相关论文,可能对 eBPF 相关研究感兴趣的人有所帮助。
一些关键亮点:
- eBPF 允许在内核中执行自定义函数,以加速分布式协议、存储引擎和网络应用,与传统的用户空间实现相比,可以提高吞吐量和降低延迟。
- eBPF 组件(如 JIT 和验证器)的正式验证确保了正确性,并揭示了实际实现中的错误。
- eBPF 的可编程性和效率使其适合在内核中完全构建入侵检测和网络监控应用。
- 从 eBPF 程序中自动生成硬件设计允许软件开发人员快速生成网卡中的优化数据包处理管道。
这些论文涵盖了 eBPF 的几个方面,包括加速分布式系统、存储和网络,eBPF 的 JIT 编译器和验证器,eBPF 入侵检测,以及从 eBPF 程序自动生成的硬件设计。eBPF 在性能、安全性、硬件集成和易用性等研究领域将会有越来越多重要的应用。
XRP: In-Kernel Storage Functions with eBPF
随着微秒级 NVMe 存储设备的出现,Linux 内核存储堆栈开销变得显著,几乎使访问时间翻倍。我们介绍了 XRP,一个框架,允许应用程序从 eBPF 在 NVMe 驱动程序中的钩子执行用户定义的存储功能,如索引查找或聚合,安全地绕过大部分内核的存储堆栈。为了保持文件系统的语义,XRP 将少量的内核状态传播到其 NVMe 驱动程序钩子,在那里调用用户注册的 eBPF 函数。我们展示了如何利用 XRP 显著提高两个键值存储,BPF-KV,一个简单的 B+ 树键值存储,和 WiredTiger,一个流行的日志结构合并树存储引擎的吞吐量和延迟。
OSDI '22 最佳论文:https://www.usenix.org/conference/osdi22/presentation/zhong
Specification and verification in the field: Applying formal methods to BPF just-in-time compilers in the Linux kernel
本文描述了我们将形式方法应用于 Linux 内核中的一个关键组件,即 Berkeley 数据包过滤器(BPF) 虚拟机的即时编译器(“JIT”) 的经验。我们使用 Jitterbug 验证这些 JIT,这是第一个提供 JIT 正确性的精确规范的框架,能够排除实际错误,并提供一个自动化的证明策略,该策略可以扩展到实际实现。使用 Jitterbug,我们设计、实施并验证了一个新的针对 32 位 RISC-V 的 BPF JIT,在五个其他部署的 JIT 中找到并修复了 16 个之前未知的错误,并开发了新的 JIT 优化;所有这些更改都已上传到 Linux 内核。结果表明,在一个大型的、未经验证的系统中,通过仔细设计规范和证明策略,可以构建一个经过验证的组件。
OSDI 20: https://www.usenix.org/conference/osdi20/presentation/nelson
λ-IO: A Unified IO Stack for Computational Storage
新兴的计算存储设备为存储内计算提供了一个机会。它减少了主机与设备之间的数据移动开销,从而加速了数据密集型应用程序。在这篇文章中,我们介绍 λ-IO,一个统一的 IO 堆栈,跨主机和设备管理计算和存储资源。我们提出了一套设计 - 接口、运行时和调度 - 来解决三个关键问题。我们在全堆栈软件和硬件环境中实施了 λ-IO,并使用合成和实际应用程序对其
进行评估,与 Linux IO 相比,显示出高达 5.12 倍的性能提升。
FAST23: https://www.usenix.org/conference/fast23/presentation/yang-zhe
Extension Framework for File Systems in User space
用户文件系统相对于其内核实现提供了许多优势,例如开发的简易性和更好的系统可靠性。然而,它们会导致重大的性能损失。我们观察到现有的用户文件系统框架非常通用;它们由一个位于内核中的最小干预层组成,该层简单地将所有低级请求转发到用户空间。虽然这种设计提供了灵活性,但由于频繁的内核 - 用户上下文切换,它也严重降低了性能。
这项工作介绍了 ExtFUSE,一个用于开发可扩展用户文件系统的框架,该框架还允许应用程序在内核中注册 “薄” 的专用请求处理程序,以满足其特定的操作需求,同时在用户空间中保留复杂的功能。我们使用两个 FUSE 文件系统对 ExtFUSE 进行评估,结果表明 ExtFUSE 可以通过平均不到几百行的改动来提高用户文件系统的性能。ExtFUSE 可在 GitHub 上找到。
ATC 19: https://www.usenix.org/conference/atc19/presentation/bijlani
Electrode: Accelerating Distributed Protocols with eBPF
在标准的 Linux 内核网络栈下实现分布式协议可以享受到负载感知的 CPU 缩放、高兼容性以及强大的安全性和隔离性。但由于过多的用户 - 内核切换和内核网络栈遍历,其性能较低。我们介绍了 Electrode,这是一套为分布式协议设计的基于 eBPF 的性能优化。这些优化在网络栈之前在内核中执行,但实现了与用户空间中实现的相似功能(例如,消息广播,收集 ack 的仲裁),从而避免了用户 - 内核切换和内核网络栈遍历所带来的开销。我们展示,当应用于经典的 Multi-Paxos 状态机复制协议时,Electrode 可以提高其吞吐量高达 128.4%,并将延迟降低高达 41.7%。
NSDI 23: https://www.usenix.org/conference/nsdi23/presentation/zhou
BMC: Accelerating Memcached using Safe In-kernel Caching and Pre-stack Processing
内存键值存储是帮助扩展大型互联网服务的关键组件,通过提供对流行数据的低延迟访问。Memcached 是最受欢迎的键值存储之一,由于 Linux 网络栈固有的性能限制,当使用高速网络接口时,其性能不高。虽然可以使用 DPDK 基础方案绕过 Linux 网络栈,但这种方法需要对软件栈进行完全重新设计,而且在客户端负载较低时也会导致高 CPU 利用率。
为了克服这些限制,我们提出了 BMC,这是一个为 Memcached 设计的内核缓存,可以在执行标准网络栈之前服务于请求。对 BMC 缓存的请求被视为 NIC 中断的一部分,这允许性能随着为 NIC 队列服务的核心数量而扩展。为确保安全,BMC 使用 eBPF 实现。尽管 eBPF 具有安全约束,但我们展示了实现复杂缓存服务是可能的。因为 BMC 在商用硬件上运行,并且不需要修改 Linux 内核或 Memcached 应用程序,所以它可以在现有系统上广泛部署。BMC 优化了 Facebook 样式的小型请求的处理时间。在这个目标工作负载上,我们的评估显示,与原始的 Memcached 应用程序相比,BMC 的吞吐量提高了高达 18 倍,与使用 SO_REUSEPORT 套接字标志的优化版 Memcached 相比,提高了高达 6 倍。此外,我们的结果还显示,对于非目标工作负载,BMC 的开销可以忽略不计,并且不会降低吞吐量。
NSDI 21: https://www.usenix.org/conference/nsdi21/presentation/ghigoff
hXDP: Efficient Software Packet Processing on FPGA NICs
FPGA 加速器在 NIC 上使得从 CPU 卸载昂贵的数据包处理任务成为可能。但是,FPGA 有限的资源可能需要在多个应用程序之间共享,而编程它们则很困难。
我们提出了一种在 FPGA 上运行 Linux 的 eXpress Data Path 程序的解决方案,这些程序使用 eBPF 编写,仅使用可用硬件资源的一部分,同时匹配高端 CPU 的性能。eBPF 的迭代执行模型不适合 FPGA 加速器。尽管如此,我们展示了,当针对一个特定的 FPGA 执行器时,一个 eBPF 程序的许多指令可以被压缩、并行化或完全删除,从而显著提高性能。我们利用这一点设计了 hXDP,它包括(i) 一个优化编译器,该编译器并行化并将 eBPF 字节码转换为我们定义的扩展 eBPF 指令集架构;(ii) 一个在 FPGA 上执行这些指令的软处理器;以及(iii) 一个基于 FPGA 的基础设施,提供 XDP 的 maps 和 Linux 内核中定义的 helper 函数。
我们在 FPGA NIC 上实现了 hXDP,并评估了其运行真实世界的未经修改的 eBPF 程序的性能。我们的实现以 156.25MHz 的速度时钟,使用约 15% 的 FPGA 资源,并可以运行动态加载的程序。尽管有这些适度的要求,但它达到了高端 CPU 核心的数据包处理吞吐量,并提供了 10 倍低的数据包转发延迟。
OSDI 20: https://www.usenix.org/conference/osdi20/presentation/brunella
Network-Centric Distributed Tracing with DeepFlow: Troubleshooting Your Microservices in Zero Code
微服务正变得越来越复杂,给传统的性能监控解决方案带来了新的挑战。一方面,微服务的快速演变给现有的分布式跟踪框架的使用和维护带来了巨大的负担。另一方面,复杂的基础设施增加了网络性能问题的概率,并在网络侧创造了更多的盲点。在这篇论文中,我们介绍了 DeepFlow,一个用于微服务故障排除的以网络为中心的分布式跟踪框架。DeepFlow 通过一个以网络为中心的跟踪平面和隐式的上下文传播提供开箱即用的跟踪。此外,它消除了网络基础设施中的盲点,以低成本方式捕获网络指标,并增强了不同组件和层之间的关联性。我们从分析和实证上证明,DeepFlow 能够准确地定位微服务性能异常,而开销几乎可以忽略不计。DeepFlow 已经为超过 26 家公司发现了 71 多个关键性能异常,并已被数百名开发人员所使用。我们的生产评估显示,DeepFlow 能够为用户节省数小时的仪表化工作,并将故障排除时间从数小时缩短到几分钟。
SIGCOMM 23: https://dl.acm.org/doi/10.1145/3603269.3604823
Fast In-kernel Traffic Sketching in eBPF
扩展的伯克利数据包过滤器(eBPF)是一个基础设施,允许在不重新编译的情况下动态加载并直接在 Linux 内核中运行微程序。
在这项工作中,我们研究如何在 eBPF 中开发高性能的网络测量。我们以绘图为案例研究,因为它们具有支持广泛任务的能力,同时提供低内存占用和准确性保证。我们实现了 NitroSketch,一个用于用户空间网络的最先进的绘图,并表明用户空间网络的最佳实践不能直接应用于 eBPF,因为它的性能特点不同。通过应用我们学到的经验教训,我们将其性能提高了 40%,与初级实现相比。
SIGCOMM 23: https://dl.acm.org/doi/abs/10.1145/3594255.3594256
SPRIGHT: extracting the server from serverless computing! high-performance eBPF-based event-driven, shared-memory processing
无服务器计算在云环境中承诺提供高效、低成本的计算能力。然而,现有的解决方案,如 Knative 这样的开源平台,包含了繁重的组件,破坏了无服务器计算的目标。此外,这种无服务器平台缺乏数据平面优化,无法实现高效的、高性能的功能链,这也是流行的微服务开发范式的设施。它们为构建功能链使用的不必要的复杂和重复的功能严重降低了性能。“冷启动” 延迟是另一个威慑因素。
我们描述了 SPRIGHT,一个轻量级、高性能、响应式的无服务器框架。SPRIGHT 利用共享内存处理显著提高了数据平面的可伸缩性,通过避免不必要的协议处理和序列化 - 反序列化开销。SPRIGHT 大量利用扩展的伯克利数据包过滤器(eBPF) 进行事件驱动处理。我们创造性地使用 eBPF 的套接字消息机制支持共享内存处理,其开销严格与负载成正比。与常驻、基于轮询的 DPDK 相比,SPRIGHT 在真实工作负载下实现了相同的数据平面性能,但 CPU 使用率降低了 10 倍。此外,eBPF 为 SPRIGHT 带来了好处,替换了繁重的无服务器组件,使我们能够以微不足道的代价保持函数处于 “暖” 状态。
我们的初步实验结果显示,与 Knative 相比,SPRIGHT 在吞吐量和延迟方面实现了一个数量级的提高,同时大大减少了 CPU 使用,并消除了 “冷启动” 的需要。
SPRIGHT | Proceedings of the ACM SIGCOMM 2022 Conference
https://dl.acm.org/doi/10.1145/3544216.3544259
Programmable System Call Security with eBPF
利用 eBPF 进行可编程的系统调用安全
系统调用过滤是一种广泛用于保护共享的 OS 内核免受不受信任的用户应用程序威胁的安全机制。但是,现有的系统调用过滤技术要么由于用户空间代理带来的上下文切换开销过于昂贵,要么缺乏足够的可编程性来表达高级策略。Seccomp 是 Linux 的系统调用过滤模块,广泛用于现代的容器技术、移动应用和系统管理服务。尽管采用了经典的 BPF 语言(cBPF),但 Seccomp 中的安全策略主要限于静态的允许列表,主要是因为 cBPF 不支持有状态的策略。因此,许多关键的安全功能无法准确地表达,和 / 或需要修改内核。
在这篇论文中,我们介绍了一个可编程的系统调用过滤机制,它通过利用扩展的 BPF 语言(eBPF)使得更高级的安全策略得以表达。更具体地说,我们创建了一个新的 Seccomp eBPF 程序类型,暴露、修改或创建新的 eBPF 助手函数来安全地管理过滤状态、访问内核和用户状态,以及利用同步原语。重要的是,我们的系统与现有的内核特权和能力机制集成,使非特权用户能够安全地安装高级过滤器。我们的评估表明,我们基于 eBPF 的过滤可以增强现有策略(例如,通过时间专化,减少早期执行阶段的攻击面积高达 55.4%)、缓解实际漏洞并加速过滤器。
[2302.10366] Programmable System Call Security with eBPF
https://arxiv.org/abs/2302.10366
Cross Container Attacks: The Bewildered eBPF on Clouds
在云上困惑的 eBPF 之间的容器攻击
扩展的伯克利数据包过滤器(eBPF)为用户空间程序提供了强大而灵活的内核接口,通过在内核空间直接运行字节码来扩展内核功能。它已被云服务广泛使用,以增强容器安全性、网络管理和系统可观察性。然而,我们发现在 Linux 主机上广泛讨论的攻击性 eBPF 可以为容器带来新的攻击面。通过 eBPF 的追踪特性,攻击者可以破坏容器的隔离并攻击主机,例如,窃取敏感数据、进行 DoS 攻击,甚至逃逸容器。在这篇论文中,我们研究基于 eBPF 的跨容器攻击,并揭示其在实际服务中的安全影响。利用 eBPF 攻击,我们成功地妨害了五个在线的 Jupyter / 交互式 Shell 服务和 Google Cloud Platform 的 Cloud Shell。此外,我们发现三家领先的云供应商提供的 Kubernetes 服务在攻击者通过 eBPF 逃逸容器后可以被利用来发起跨节点攻击。具体来说,在阿里巴巴的 Kubernetes 服务中,攻击者可以通过滥用他们过度特权的云指标或管理 Pods 来妨害整个集群。不幸的是,容器上的 eBPF 攻击鲜为人知,并且现有的入侵检测系统几乎无法发现它们。此外,现有的 eBPF 权限模型无法限制 eBPF 并确保在共享内核的容器环境中安全使用。为此,我们提出了一个新的 eBPF 权限模型,以对抗容器中的 eBPF 攻击。
Cross Container Attacks: The Bewildered eBPF on Clouds | USENIX
https://www.usenix.org/conference/usenixsecurity23/presentation/he
Comparing Security in eBPF and WebAssembly
比较 eBPF 和 WebAssembly 中的安全性
本文研究了 eBPF 和 WebAssembly(Wasm)的安全性,这两种技术近年来得到了广泛的采用,尽管它们是为非常不同的用途和环境而设计的。当 eBPF 主要用于 Linux 等操作系统内核时,Wasm 是一个为基于堆栈的虚拟机设计的二进制指令格式,其用途超出了 web。鉴于 eBPF 的增长和不断扩大的雄心,Wasm 可能提供有启发性的见解,因为它围绕在如 web 浏览器和云等复杂和敌对环境中安全执行任意不受信任的程序进行设计。我们分析了两种技术的安全目标
、社区发展、内存模型和执行模型,并进行了比较安全性评估,探讨了内存安全性、控制流完整性、API 访问和旁路通道。我们的结果表明,eBPF 有一个首先关注性能、其次关注安全的历史,而 Wasm 更强调安全,尽管要支付一些运行时开销。考虑 eBPF 的基于语言的限制和一个用于 API 访问的安全模型是未来工作的有益方向。
Comparing Security in eBPF and WebAssembly | Proceedings of the 1st Workshop on eBPF and Kernel Extensions
https://dl.acm.org/doi/abs/10.1145/3609021.3609306
更多内容可以在第一个 eBPF 研讨会中找到:https://conferences.sigcomm.org/sigcomm/2023/workshop-ebpf.html
A flow-based IDS using Machine Learning in eBPF
基于 eBPF 中的机器学习的流式入侵检测系统
eBPF 是一种新技术,允许动态加载代码片段到 Linux 内核中。它可以大大加速网络,因为它使内核能够处理某些数据包而无需用户空间程序的参与。到目前为止,eBPF 主要用于简单的数据包过滤应用,如防火墙或拒绝服务保护。我们证明在 eBPF 中完全基于机器学习开发流式网络入侵检测系统是可行的。我们的解决方案使用决策树,并为每个数据包决定它是否恶意,考虑到网络流的整个先前上下文。与作为用户空间程序实现的同一解决方案相比,我们实现了超过 20% 的性能提升。
[2102.09980] A flow-based IDS using Machine Learning in eBPF
https://arxiv.org/abs/2102.09980
Femto-containers: lightweight virtualization and fault isolation for small software functions on low-power IoT microcontrollers
针对低功耗 IoT 微控制器上的小型软件功能的轻量级虚拟化和故障隔离:Femto - 容器
低功耗的 IoT 微控制器上运行的操作系统运行时通常提供基础的 API、基本的连接性和(有时)一个(安全的)固件更新机制。相比之下,在硬件约束较少的场合,网络化软件已进入无服务器、微服务和敏捷的时代。考虑到弥合这一差距,我们在论文中设计了 Femto - 容器,这是一种新的中间件运行时,可以嵌入到各种低功耗 IoT 设备中。Femto - 容器使得可以在低功耗 IoT 设备上通过网络安全地部署、执行和隔离小型虚拟软件功能。我们实施了 Femto - 容器,并在 RIOT 中提供了集成,这是一个受欢迎的开源 IoT 操作系统。然后,我们评估了我们的实现性能,它已被正式验证用于故障隔离,确保 RIOT 受到加载并在 Femto - 容器中执行的逻辑的保护。我们在各种受欢迎的微控制器架构(Arm Cortex-M、ESP32 和 RISC-V)上的实验表明,Femto - 容器在内存占用开销、能源消耗和安全性方面提供了有吸引力的权衡。
https://dl.acm.org/doi/abs/10.1145/3528535.3565242
以上就是我们介绍的关于 eBPF 的关键研究论文,未来还会添加一些。大家也可以在这里找到更多关于 eBPF 的信息:
- 一个关于 eBPF 相关内容和信息的详细列表:https://github.com/zoidbergwill/awesome-ebpf
- eBPF 相关项目、教程:https://ebpf.io/
如果您对 eBPF 有些进一步的兴趣的话,也可以查看我们在 eunomia-bpf 的开源项目和 bpf-developer-tutorial 的 eBPF 教程。
eBPF 汇编指令介绍
Linux 内核那些事 2022 年 03 月 10 日 09:00
摘要
本文主要介绍 eBPF 的指令系统,对想深入理解 eBPF 的同学有较大的帮助。
eBPF 指令系统
BPF 是一个通用的 RISC 指令集,最初是为了用 C 的子集编写程序而设计的,这些程序可以通过编译器后端(例如 LLVM)编译成 BPF 指令,以便内核稍后可以通过将内核 JIT 编译器转换为原生操作码,以实现内核内部的最佳执行性能。
寄存器
eBPF 由 11 个 64 位寄存器、一个程序计数器和一个 512 字节的大 BPF 堆栈空间组成。寄存器被命名为 r0- r10。操作模式默认为 64 位。64 位的寄存器也可作 32 位子寄存器使用,它们只能通过特殊的 ALU(算术逻辑单元)操作访问,使用低 32 位,高 32 位使用零填充。
寄存器的使用约定如下:
寄存器 | 使用 |
---|---|
r0 | 包含 BPF 程序退出值的寄存器。退出值的语义由程序类型定义。此外,当将执行交还给内核时,退出值作为 32 位值传递。 |
r1-r5 | 保存从 BPF 程序到内核辅助函数的参数。其中 r1 寄存器指向程序的上下文(例如,网络程序可以将网络数据包( skb) 的内核表示作为输入参数)。 |
r6-r9 | 通用寄存器 |
r10 | 唯一的只读寄存器,包含用于访问 BPF 堆栈空间的帧指针地址。 |
其他:在加载和存储指令中,寄存器 R6 是一个隐式输入,必须包含指向 sk_buff 的指针。寄存器 R0 是一个隐式输出,它包含从数据包中获取的数据。
指令格式
代码实现如下:
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*/
};
指令类型
其中的 op 字段,如下:
+-----------+------+--------------+
| 5 bits | 3 bits |
| xxxxxx | instruction class |
+-----------+------+--------------+
(MSB) (LSB)
op 字段的低 3 位,决定指令类型。指令类型包含:加载与存储指令、运算指令、跳转指令。
顺便提下:ebpf 中一个字是四个字节大小,32 bits
cBPF 类 | eBPF 类 |
---|---|
BPF_LD | 0x00 |
BPF_LDX | 0x01 |
BPF_ST | 0x02 |
BPF_STX | 0x03 |
BPF_ALU | 0x04 |
BPF_JMP | 0x05 |
BPF_RET | 0x06 |
BPF_MISC | 0x07 |
-BPF_LD和BPF_LDX: 两个类都用于加载操作。BPF_LD 用于加载双字。后者是从 cBPF 继承而来的,主要是为了保持 cBPF 到 BPF 的转换效率,因为它们优化了 JIT 代码。
-BPF_ST和BPF_STX: 两个类都用于存储操作,用于将数据从寄存器到存储器中。
-BPF_ALU和BPF_ALU64: 分别是 32 位和 64 位下的 ALU 操作。
-BPF_JMP和BPF_JMP32:跳转指令。JMP32 的跳转范围是 32 位大小(一个 word)
运算和跳转指令
当 BPF_CLASS(code) == BPF_ALU 或 BPF_JMP 时,op 字段可分为三部分,如下所示:
+-----------+------+--------------+
| 4 bits | 1 bit | 3 bits |
| operation code | source | instruction class |
+-----------+------+--------------+
(MSB) (LSB)
其中的第四位,可以为 0 或者 1,在 linux 中,使用如下宏定义:
BPF_K 0x00
BPF_X 0x08
// #define BPF_CLASS(code)((code) & 0x07)
在 eBPF 中,这意味着:
BPF_SRC(code) == BPF_X - use'src_reg' register as source operand
BPF_SRC(code) == BPF_K - use 32-bit immediate as source operand
// #define BPF_SRC(code) ((code) & 0x08)
如果 BPF_CLASS(code)
等于 BPF_ALU
或 BPF_ALU64
,则 BPF_OP(code)
是以下之一:
BPF_ADD 0x00
BPF_SUB 0x10
BPF_MUL 0x20
BPF_DIV 0x30
BPF_OR 0x40
BPF_AND 0x50
BPF_LSH 0x60
BPF_RSH 0x70
BPF_NEG 0x80
BPF_MOD 0x90
BPF_XOR 0xa0
BPF_MOV 0xb0 /*eBPF only: mov reg to reg*/
BPF_ARSH 0xc0 /*eBPF only: sign extending shift right*/
BPF_END 0xd0 /*eBPF only: endianness conversion*/
如果 BPF_CLASS(code)
等于 BPF_JMP
或 BPF_JMP32
,则 BPF_OP(code)
是以下之一:
BPF_JA 0x00 /*BPF_JMP only*/
BPF_JEQ 0x10
BPF_JGT 0x20
BPF_JGE 0x30
BPF_JSET 0x40
BPF_JNE 0x50 /*eBPF only: jump !=*/
BPF_JSGT 0x60 /*eBPF only: signed '>'*/
BPF_JSGE 0x70 /*eBPF only: signed '>='*/
BPF_CALL 0x80 /*eBPF BPF_JMP only: function call*/
BPF_EXIT 0x90 /*eBPF BPF_JMP only: function return*/
BPF_JLT 0xa0 /*eBPF only: unsigned '<'*/
BPF_JLE 0xb0 /*eBPF only: unsigned '<='*/
BPF_JSLT 0xc0 /*eBPF only: signed '<'*/
BPF_JSLE 0xd0 /*eBPF only: signed '<='*/
加载和存储指令
当 BPF_CLASS(code)
等于 BPF_LD
或 BPF_ST
时,op 字段可分为三部分,如下所示:
+------+------+-------------+
| 3 bits | 2 bits | 3 bits |
| mode | size | instruction class |
+------+------+-------------+
(MSB) (LSB)
其中的 size 在 linux 中的有如下宏定义:
BPF_W 0x00 /*word=4 byte*/
BPF_H 0x08 /*half word*/
BPF_B 0x10 /*byte*/
BPF_DW 0x18 /*eBPF only, double word*/
mode 在 linux 中的有如下宏定义:
BPF_IMM 0x00 /*used for 32-bit mov in classic BPF and 64-bit in eBPF*/
BPF_ABS 0x20
BPF_IND 0x40
BPF_MEM 0x60
BPF_LEN 0x80 /*classic BPF only, reserved in eBPF*/
BPF_MSH 0xa0 /*classic BPF only, reserved in eBPF*/
BPF_ATOMIC 0xc0 /*eBPF only, atomic operations*/
ebpf 指令集编程
eBPF 编程有三种方式:BPF 指令集编程、BPF C 编程、BPF 前端(BCC、bpftrace)。
为了演示指令,我们阅读一段指令集方式编程的代码。
代码
代码来源:sample/bpf/sock_example.c
代码逻辑:
- popen() 函数通过创建管道、fork 和来打开进程调用 shell。由于管道的定义是单向的,因此类型参数只能指定读或写,不能同时指定两者;这里,使用 IPv4,ping 主机 5 次,结果可以读取。
- 创建一个 BPF_MAP_TYPE_ARRAY 类型的 map。
- 使用 eBPF 指令集进行编程,指令存放在 prog 中。关于这些指令代码的阅读,见下一节。
- 将这些指令加载到内核中,指令程序的类型为 BPF_PROG_TYPE_SOCKET_FILTER。
- open_raw_sock 返回一个原生套接字。通过该套接字,可以直接读取 lo 网络接口数据链路层的数据。
- 附加 eBPF 程序到套接字(用作过滤器)。
- 打印 map 中的一些信息。
/*eBPF example program:
*- creates arraymap in kernel with key 4 bytes and value 8 bytes
*
*- loads eBPF program:
* r0 = skb->data [ETH_HLEN + offsetof(struct iphdr, protocol)];
* *(u32*)(fp - 4) = r0;
* //assuming packet is IPv4, lookup ip->proto in a map
* value = bpf_map_lookup_elem(map_fd, fp - 4);
* if(value)
* (*(u64*) value) += 1;
*
*- attaches this program to loopback interface "lo" raw socket
*
*- every second user space reads map [tcp], map [udp], map [icmp] to see
* how many packets of given protocol were seen on "lo"
*/
#include <stdio.h>
#include <unistd.h>
#include <assert.h>
#include <linux/bpf.h>
#include <string.h>
#include <stdlib.h>
#include <errno.h>
#include <sys/socket.h>
#include <arpa/inet.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <stddef.h>
#include <bpf/bpf.h>
#include "bpf_insn.h"
#include "sock_example.h"
char bpf_log_buf [BPF_LOG_BUF_SIZE];
static int test_sock(void)
{
int sock = -1, map_fd, prog_fd, i, key;
long long value = 0, tcp_cnt, udp_cnt, icmp_cnt;
map_fd = bpf_create_map(BPF_MAP_TYPE_ARRAY, sizeof(key), sizeof(value),
256, 0);
if(map_fd < 0) {
printf("failed to create map '% s'\n", strerror(errno));
goto cleanup;
}
struct bpf_insn prog [] = {
BPF_MOV64_REG(BPF_REG_6, BPF_REG_1),
BPF_LD_ABS(BPF_B, ETH_HLEN + offsetof(struct iphdr, protocol) /*R0 = ip->proto*/),
BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_0, -4), /**(u32*)(fp - 4) = r0*/
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10),
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4), /*r2 = fp - 4*/
BPF_LD_MAP_FD(BPF_REG_1, map_fd),
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem),
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2),
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*/
BPF_MOV64_IMM(BPF_REG_0, 0), /*r0 = 0*/
BPF_EXIT_INSN(),
};
size_t insns_cnt = sizeof(prog) /sizeof(struct bpf_insn);
prog_fd = bpf_load_program(BPF_PROG_TYPE_SOCKET_FILTER, prog, insns_cnt,
"GPL", 0, bpf_log_buf, BPF_LOG_BUF_SIZE);
if(prog_fd < 0) {
printf("failed to load prog '% s'\n", strerror(errno));
goto cleanup;
}
sock = open_raw_sock("lo");
if(setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd,
sizeof(prog_fd)) < 0) {
printf("setsockopt % s\n", strerror(errno));
goto cleanup;
}
for(i = 0; i < 10; i++) {
key = IPPROTO_TCP;
assert(bpf_map_lookup_elem(map_fd, &key, &tcp_cnt) == 0);
key = IPPROTO_UDP;
assert(bpf_map_lookup_elem(map_fd, &key, &udp_cnt) == 0);
key = IPPROTO_ICMP;
assert(bpf_map_lookup_elem(map_fd, &key, &icmp_cnt) == 0);
printf("TCP % lld UDP % lld ICMP % lld packets\n",
tcp_cnt, udp_cnt, icmp_cnt);
sleep(1);
}
cleanup:
/*maps, programs, raw sockets will auto cleanup on process exit*/
return 0;
}
int main(void)
{
FILE*f;
f = popen("ping -4 -c5 localhost", "r");
(void) f; // 为什么代码中有这一行?
return test_sock();
}
eBPF 指令编程代码阅读
我们把这部分代码领出来,单独阅读下。
我在下面的连续的指令中,联系连续指令的上下文,注释了这些指令的含义。
在后续的内容中,逐个解释指令的含义,由于的单个指令,只解释其含义(不联系上下文解释其作用)。
struct bpf_insn prog [] = {
BPF_MOV64_REG(BPF_REG_6, BPF_REG_1), /*R6 = R1*//*R6 指向数据包地址*/
BPF_LD_ABS(BPF_B, ETH_HLEN + offsetof(struct iphdr, protocol) /*R0 = ip->proto*/), /*R6 作为隐式输入,R0 作为隐式输出。结果 R0 报错 IP 协议值*/
BPF_STX_MEM(BPF_W, BPF_REG_10, BPF_REG_0, -4), /**(u32*)(fp - 4) = r0*//*将协议值保存在栈中*/
BPF_MOV64_REG(BPF_REG_2, BPF_REG_10), /*R10 只读寄存器,指向栈帧。复制一份到 R2 中*/
BPF_ALU64_IMM(BPF_ADD, BPF_REG_2, -4), /*r2 = fp - 4*//*内核 bpf_map_lookup_elem 函数的第二个参数 key 的内存地址放在 R2 中*/
BPF_LD_MAP_FD(BPF_REG_1, map_fd), /*内核 bpf_map_lookup_elem 函数的第一个参数 map_fd 放在 R1 中*/
BPF_RAW_INSN(BPF_JMP | BPF_CALL, 0, 0, 0, BPF_FUNC_map_lookup_elem), /*函数的返回值为 value 所在内存的地址,放在 R0 寄存器中*/
BPF_JMP_IMM(BPF_JEQ, BPF_REG_0, 0, 2), /*如果返回的内存地址为 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*//*value 的值加一;结果 R0 存储 1,R1 存储 value 地址*/
BPF_MOV64_IMM(BPF_REG_0, 0), /*r0 = 0*/
BPF_EXIT_INSN(), /*R0 作为返回值,返回零*/
};
下面我们对上面代码逐行指令进行分享:
1.第一条指令
/*Short form of mov, dst_reg = src_reg*/
#define BPF_MOV64_REG(DST, SRC) \
((struct bpf_insn) { \
.code = BPF_ALU64 | BPF_MOV | BPF_X, \
.dst_reg = DST, \
.src_reg = SRC, \
.off = 0, \
.imm = 0 })
可以看到,这条指令是将源寄存器 R1 的值移动到 R6 寄存器中。其中,R1 指向数据包的起始地址。
1.第二条指令
/*Direct packet access, R0 =*(uint*)(skb->data + imm32)*/
#define BPF_LD_ABS(SIZE, IMM) \
((struct bpf_insn) { \
.code = BPF_LD | BPF_SIZE(SIZE) | BPF_ABS, \
.dst_reg = 0, \
.src_reg = 0, \
.off = 0, \
.imm = IMM })
在加载和存储指令中,寄存器 R6 是一个隐式输入,寄存器 R0 是一个隐式输出。(?我要这 dst_reg
和 src_reg
有何用?)
可以需要明白数据包的格式,可以参考:MAC 首部
、IP 首部
、TCP 首部
介绍。
根据偏移量,读取 IP 协议类型,例如,TCP 的协议号为 6,UDP 的协议号为 17,ICMP 的协议号为 1。其中,协议字段占 8 位。
所以,这条指令表示,将 IP 协议放入 R0 寄存器。
1.第三条指令
/*Memory store,*(uint*)(dst_reg + off16) = src_reg*/
#define BPF_STX_MEM(SIZE, DST, SRC, OFF) \
((struct bpf_insn) { \
.code = BPF_STX | BPF_SIZE(SIZE) | BPF_MEM, \
.dst_reg = DST, \
.src_reg = SRC, \
.off = OFF, \
.imm = 0 })
R10 是唯一的只读寄存器,包含用于访问 BPF 堆栈空间的帧指针地址。(关于栈帧结构可以参考:gdb 调试之栈帧信息)
所以这里,将 R0 寄存器中的内容(上一步保存了协议类型),保存到栈中。需要注意的是,这里是 BPF_W
,只保存了 R0 寄存器 中的第 32 位。
1.第四条指令
因为栈向下生长了。所以这里使用了 R2 寄存器 指向栈顶。
至于 BPF_ALU64_IMM
的宏展开,这里不列出了,自行在 samples/bpf/bpf_insn.h
中查看。
这些宏展开数字在 include/uapi/linux/bpf.h
中查看。
这样,上面的指令展开,便是一个 64 位的二进制数,是不是很神奇~
1.第五条指令
这条指令比较有意思,我们看下。
/*BPF_LD_IMM64 macro encodes single 'load 64-bit immediate' insn*/
#define BPF_LD_IMM64(DST, IMM) \
BPF_LD_IMM64_RAW(DST, 0, IMM)
#define BPF_LD_IMM64_RAW(DST, SRC, IMM) \
((struct bpf_insn) { \
.code = BPF_LD | BPF_DW | BPF_IMM,\
.dst_reg = DST, \
.src_reg = SRC, \
.off = 0, \
.imm =(__u32)(IMM) }), \
((struct bpf_insn) { \
.code = 0, \
.dst_reg = 0, \
.src_reg = 0, \
.off = 0, \
.imm =((__u64)(IMM)) >> 32 })
#ifndef BPF_PSEUDO_MAP_FD
# define BPF_PSEUDO_MAP_FD 1
#endif
/*pseudo BPF_LD_IMM64 insn used to refer to process-local map_fd*/
#define BPF_LD_MAP_FD(DST, MAP_FD) \
BPF_LD_IMM64_RAW(DST, BPF_PSEUDO_MAP_FD, MAP_FD)
可以看到,这条指令是将 map_fd
的值,保存到 R1 寄存器中。这时候,我们可能会好奇,这中间有 src_reg
什么事情?
上面我们可以看到,如果只是单纯将一个立即数保存到寄存器中,则 src_reg=0
;如果这个立即数表示是一个 map_fd
,则则 src_reg=1
;
这样我们便可以区分指令中的立即数是否表示一个 map_fd
。后面 replace_map_fd_with_map_ptr
函数会用到这个性质。
另外我试着组合了下 .code = 0
;.code = BPF_LD | BPF_W | BPF_IMM
。这确实没有含义?
1.第六条指令
/*Raw code statement block*/
#define BPF_RAW_INSN(CODE, DST, SRC, OFF, IMM) \
((struct bpf_insn) { \
.code = CODE, \
.dst_reg = DST, \
.src_reg = SRC, \
.off = OFF, \
.imm = IMM })
其中 BPF_FUNC_map_lookup_elem 的宏展开为 1。至于跳转到 1 的位置,在 verifier 后是 bpf_map_lookup_elem 这个函数,则是后续的问题了。可以参考:fixup_bpf_calls
这里,可以从宏的名称看出是是跳转到 bpf_map_lookup_elem 函数位置。
1.第七条指令
/*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 })
这条指令表示,R0 寄存器 等于 0,则向下跳过两个指令。
R0 寄存器 这里存储的是协议号,根据 IP 协议号列表可知,但 IP 数据包中的协议为 “IPv6 逐跳选项”,则向下跳过两个指令。
1.第八条指令
xadd - 交换相加。
1.第九条指令
R0 是包含 BPF 程序退出值的寄存器,设置返回值 R0=0
。
1.第十条指令
/*Program exit*/
#define BPF_EXIT_INSN() \
((struct bpf_insn) { \
.code = BPF_JMP | BPF_EXIT, \
.dst_reg = 0, \
.src_reg = 0, \
.off = 0, \
.imm = 0 })
运行这个程序
如果你想运行下这个程序,可以拉下源码,然后编译运行下。
拉取当前 linux 内核版本对应的源码,可以参考:ubuntu 获取源码方式
$ sudo apt source linux
接着编译下 sample/bpf 目录下的 bpf 程序,可以参考:运行第一个 bpf 程序
$ make M=samples/bpf
运行程序,输出如下。(PS:我的 lo 在转发浏览器数据)(ping 一次发送四个 ICMP 包?)
➜ bpf sudo ./sock_example
TCP 0 UDP 0 ICMP 0 packets
TCP 28 UDP 0 ICMP 4 packets
TCP 60 UDP 0 ICMP 4 packets
TCP 100 UDP 0 ICMP 8 packets
TCP 134 UDP 0 ICMP 12 packets
TCP 166 UDP 0 ICMP 16 packets
TCP 228 UDP 0 ICMP 16 packets
TCP 302 UDP 0 ICMP 16 packets
TCP 334 UDP 0 ICMP 16 packets
TCP 366 UDP 0 ICMP 16 packets
一文看懂 eBPF|eBPF 的简单使用
原创 songsong001 2022 年 03 月 14 日 09:00
eBPF(extended Berkeley Packet Filter)
可谓 Linux 社区的新宠,很多大公司都开始投身于 eBPF
技术,如 Goole、Facebook、Twitter 等。
eBPF 究竟有什么魅力让大家都关注它呢?
这是因为 eBPF 增加了内核的可扩展性,让内核变得更加灵活和强大。
如果大家玩过 乐高积木
的话就会深有体会,乐高积木就是通过不断向主体添加积木来组合出更庞大的模型。
而 eBPF 就像乐高积木一样,可以不断向内核添加 eBPF 模块来增强内核的功能。
本文分为 3 篇:
- eBPF 的简单使用
- eBPF 的实现原理
- kprobes 在 eBPF 中的实现原理
看完这 3 篇文章,估计对 eBPF 也有较深的理解了。
什么是 eBPF
eBPF 全称 extended Berkeley Packet Filter,中文意思是 扩展的伯克利包过滤器
。一般来说,要向内核添加新功能,需要修改内核源代码或者编写 内核模块
来实现。而 eBPF 允许程序在不修改内核源代码,或添加额外的内核模块情况下运行。
从 eBPF 的名字看,好像是专门为过滤网络包而创造的。其实,eBPF 是从 BPF(也称为 cBPF:classic Berkeley Packet Filter)发展而来的,BPF 是专门为过滤网络数据包而创造的。
但随着 eBPF 不断完善和加强,现在的 eBPF 已经不再限于过滤网络数据包了。
eBPF 架构
我们先来看看 eBPF 的架构,如下图所示:
下面用文字来描述一下:
用户态
- 用户编写 eBPF 程序,可以使用 eBPF 汇编或者 eBPF 特有的 C 语言来编写。
- 使用 LLVM/CLang 编译器,将 eBPF 程序编译成 eBPF 字节码。
- 调用
bpf()
系统调用把 eBPF 字节码加载到内核。
内核态
- 当用户调用
bpf()
系统调用把 eBPF 字节码加载到内核时,内核先会对 eBPF 字节码进行安全验证。 - 使用
JIT(Just In Time)
技术将 eBPF 字节编译成本地机器码(Native Code)。 - 然后根据 eBPF 程序的功能,将 eBPF 机器码挂载到内核的不同运行路径上(如用于跟踪内核运行状态的 eBPF 程序将会挂载在
kprobes
的运行路径上)。当内核运行到这些路径时,就会触发执行相应路径上的 eBPF 机器码。
如果大家使用过 Java 编写程序的话,会发现 eBPF 与 Java 的 AOP(Aspect Oriented Programming 面向切面编程)概念很像。
为了让有 Java 经验的同学更容易接受 eBPF 技术。我们先介绍一下 Java 中的 AOP 概念。
在 AOP 概念中,有两个很重要的角色:切点
和 拦截器
。
切点
:程序中某个具体的业务点(方法)。拦截器
:拦截器其实是一段 Java 代码,用于拦截切点在执行前(或执行后),先运行这段 Java 代码。
eBPF 程序就像 AOP 中的拦截器,而内核的某个运行路径就像 AOP 中的切点。
根据挂载点功能的不同,大概可以分为以下几个模块:
1.性能跟踪
2.网络
3.容器
4.安全
eBPF 使用
在介绍 eBPF 的实现前,我们先来介绍一下如何使用 eBPF 来跟踪 fork()
系统调用的运行情况。
编写 eBPF 程序有多种方式,比如使用原生 eBPF 汇编来编写,但使用原生 eBPF 汇编编写程序的难度较大,所以一般不建议。
也可以使用 eBPF 受限的 C 语言来编写,难度比使用原生 eBPF 汇编简单些,但对初学者来说也不是十分友好。
最简单是使用 BCC 工具来编写,BCC 工具帮我们简化了很多繁琐的工作,比如不用编写加载器。
下面我们将使用 BCC 工具来介绍怎么编写一个 eBPF 程序。
注意:由于 eBPF 对内核的版本有较高的要求,不同版本的内核对 eBPF 的支持可能有所不相同。所以使用 eBPF 时,最好使用最新版本的内核。
本文使用
Ubuntu 20.20
(内核版本为 5.8.1)作为解说。
1. BCC 工具安装
在 Ubuntu 系统中安装 BCC 工具是比较简单的,可以使用以下命令:
$ sudo apt-get install bpfcc-tools linux-headers-$(uname -r)
BCC 工具可以让你使用 Python 和 C 语言组合来编写 eBPF 程序。
安装完成后,可以使用命令 bcc -v
来测试是否安装成功。如果安装失败,可以参考官网安装文档,如下:
https://github.com/iovisor/bcc/blob/master/INSTALL.md
2. 编写 eBPF 版的 hello world
一般编程课的第一步都是编写著名的 hello world
程序,所以我们也以编写 hello world
程序作为第一步吧。
使用 BCC 编写 eBPF 程序的步骤如下:
- 使用 C 语言编写 eBPF 程序的内核态功能(也就是运行在内核态的 eBPF 程序)。
- 使用 Python 编写加载代码和用户态功能。
为什么不能全部使用 Python 编写呢?这是因为 LLVM/Clang 只支持将 C 语言编译成 eBPF 字节码,而不支持将 Python 代码编译成 eBPF 字节码。
所以,eBPF 内核态程序只能使用 C 语言编写。而 eBPF 的用户态程序可以使用 Python 进行编写,这样就能简化编写难度。
所以,第一步就是编写 eBPF 内核态程序。
使用 C 编写 eBPF 程序
新建一个 hello.c 文件,并输入下面的内容:
int hello_world(void*ctx)
{
bpf_trace_printk("Hello, World!");
return 0;
}
使用 Python 和 BCC 工具开发一个用户态程序
新建一个 hello.py 文件,并输入下面的内容:
#!/usr/bin/env python3
# 1) 加载 BCC 库
from bcc import BPF
# 2) 加载 eBPF 内核态程序
b = BPF(src_file="hello.c")
# 3) 将 eBPF 程序挂载到 kprobe
b.attach_kprobe(event="do_sys_openat2", fn_name="hello_world")
# 4) 读取并且打印 eBPF 内核态程序输出的数据
b.trace_print()
下面我们来看看每一行代码的具体含义:
- 导入了 BCC 库的 BPF 模块,以便接下来调用。
- 调用 BPF() 函数加载 eBPF 内核态程序(也就是我们编写的 hello.c)。
- 将 eBPF 程序挂载到内核探针(简称 kprobe),其中
do_sys_openat2()
是系统调用openat()
在内核中的实现。 - 读取内核调试文件
/sys/kernel/debug/tracing/trace_pipe
的内容(bpf_trace_printk()
函数会将信息写入到此文件),并打印到标准输出中。
运行 eBPF 程序
用户态程序开发完成之后,最后一步就是执行它了。需要注意的是,eBPF 程序需要以 root
用户来运行:
$ sudo python3 hello.py
运行后,可以看到如下输出:
$ sudo python3 hello.py
b' python3-31683 [001] .... 614653.225903: 0: Hello, World!'
b' python3-31683 [001] .... 614653.226093: 0: Hello, World!'
b' python3-31683 [001] .... 614653.226606: 0: Hello, World!'
b' <...>-31684 [000] .... 614654.387288: 0: Hello, World!'
b' irqbalance-669 [000] .... 614658.232433: 0: Hello, World!'
...
到了这里,我们已经成功开发并运行了第一个 eBPF 程序。当然,这个程序很简单,并且也没有实际的用途。
但通过这个程序,我们大概可以知道使用 BCC 开发一个 eBPF 程序的步骤。
一文看懂 eBPF|eBPF 实现原理
原创 songsong001 Linux 内核那些事
在介绍 eBPF 的实现原理前,先来回顾一下 eBPF 的架构图:
这幅图对理解 eBPF 实现原理有非常大的作用,在分析 eBPF 实现原理时,要经常参照这幅图来进行分析。
eBPF 虚拟机
其实我不太想介绍 eBPF 虚拟机的,因为一般来说很少会用到 eBPF 汇编来写程序。但是,不介绍 eBPF 虚拟机的话,又不能说清 eBPF 的原理。
所以,还是先简单介绍一下 eBPF 虚拟机的原理,这样对分析 eBPF 实现有很大的帮助。
eBPF 汇编
eBPF 本质上是一个虚拟机(Virtual Machine),可以执行 eBPF 字节码。
用户可以使用 eBPF 汇编或者 C 语言来编写程序,然后编译成 eBPF 字节码,再由 eBPF 虚拟机执行。
什么是虚拟机?
官方的解释是:虚拟机(VM)是一种创建于物理硬件系统(位于外部或内部)、充当虚拟计算机系统的虚拟环境,它模拟出了自己的整套硬件,包括 CPU、内存、网络接口和存储器。通过名为虚拟机监控程序的软件,用户可以将机器的资源与硬件分开并进行适当设置,以供虚拟机使用。
通俗的解释:虚拟机就是模拟计算机的运行环境,你可以把它当成是一台虚拟出来的计算机。
计算机的最本质功能就是执行代码,所以 eBPF 虚拟机也一样,可以运行 eBPF 字节码。
用户编写的 eBPF 程序最终会被编译成 eBPF 字节码,eBPF 字节码使用 bpf_insn
结构来表示,如下:
struct bpf_insn {
__u8 code; // 操作码
__u8 dst_reg:4; // 目标寄存器
__u8 src_reg:4; // 源寄存器
__s16 off; // 偏移量
__s32 imm; // 立即操作数
};
下面介绍一下 bpf_insn
结构各个字段的作用:
code
:指令操作码,如 mov、add 等。dst_reg
:目标寄存器,用于指定要操作哪个寄存器。src_reg
:源寄存器,用于指定数据来源于哪个寄存器。off
:偏移量,用于指定某个结构体的成员。imm
:立即操作数,当数据是一个常数时,直接在这里指定。
eBPF 程序会被 LLVM/Clang 编译成 bpf_insn
结构数组,当内核要执行 eBPF 字节码时,会调用 __bpf_prog_run()
函数来执行。
如果开启了 JIT(即时编译技术),内核会将 eBPF 字节码编译成本地机器码(Native Code)。这样就可以直接执行,而不需要虚拟机来执行。
关于 eBPF 汇编相关的知识点可以参考《eBPF 汇编指令介绍 .》,这里就不作深入的分析,我们只需要记住 eBPF 程序会被编译成 eBPF 字节码即可。
eBPF 虚拟机
eBPF 虚拟机的作用就是执行 eBPF 字节码,eBPF 虚拟机比较简单(只有 300 行代码左右),由 __bpf_prog_run()
函数实现。
通用虚拟机因为要模拟真实的计算机,所以通常来说实现比较复杂(如 Qemu、Virtual Box 等)。
但像 eBPF 虚拟机这种用于特定功能的虚拟机,由于只需要模拟计算机的小部分功能,所以实现通常比较简单。
eBPF 虚拟机的运行环境只有 1 个 512KB 的栈和 11 个寄存器(还有一个 PC 寄存器,用于指向当前正在执行的 eBPF 字节码)。如下图所示:
如果内核支持 JIT(Just In Time)运行模式,那么内核将会把 eBPF 字节码编译成本地机器码,这时可以直接运行这些机器码,而不需要使用虚拟机来运行。
可以通过以下命令打开 JIT 运行模式:
$ echo 1 > /proc/sys/net/core/bpf_jit_enable
将 C 程序编译成 eBPF 字节码
由于使用 eBPF 汇编编写程序比较麻烦,所以 eBPF 提供了功能受限的 C 语言来编写 eBPF 程序,并且可以使用 Clang/LLVM 将 C 程序编译成 eBPF 字节码。
使用 Clang 编译 eBPF 程序时,需要加上 -target bpf
参数才能编译成功。
下面我们用一个简单的例子来介绍怎么使用 Clang 编译 eBPF 程序,我们新建一个文件 hello.c
并且输入以下代码:
#include <linux/bpf.h>
static int(*bpf_trace_printk)(const char*fmt, int fmtsize, ...)
=(void*) BPF_FUNC_trace_printk;
int hello_world(void*ctx)
{
char msg [] = "Hello World\n";
bpf_trace_printk(msg, sizeof(msg)-1);
return 0;
}
然后我们使用以下命令编译程序:
$ clang -target bpf -Wall -O2 -c hello.c -o hello.o
编译后会得到一个名为 hello.o
的文件,我们可以通过下面命令来看到编译后的字节码:
$ readelf -x .text hello.o
Hex dump of section '.text':
0x00000000 18010000 00000000 00000000 00000000 ................
0x00000010 b7020000 0c000000 85000000 06000000 ................
0x00000020 b7000000 00000000 95000000 00000000 ................
由于编译出来的字节码是二进制的,不利于人类查阅。所以,可以通过以下命令将 eBPF 程序编译成 eBPF 汇编代码:
$ clang -target bpf -S -o hello.s hello.c
编译后会得到一个名为 hello.s
的文件,我们可以使用文本编辑器来查看其汇编代码:
...
hello_world:
*(u64*)(r10 - 8) = r1 # 把 r1 的值保存到栈
r1 = bpf_trace_printk ll #
r1 =*(u64*)(r1 + 0) # r1 赋值为 bpf_trace_printk 函数地址
r2 = .L.str ll # r2 赋值为 "Hello World\n"
r3 = 12 # r3 赋值为 12
*(u64*)(r10 - 16) = r1 # 把 r1 的值保存到栈
r1 = r2 # 调用 bpf_trace_printk 函数的参数 1
r2 = r3 # 调用 bpf_trace_printk 函数的参数 2
r3 =*(u64*)(r10 - 16) # 获取 bpf_trace_printk 函数地址
callx r3 # 调用 bpf_trace_printk 函数
r1 = 0 # r1 赋值为 0
*(u64*)(r10 - 24) = r0 # 把 r0 的值保存到栈
r0 = r1 # 返回 0
exit # 退出 eBPF 程序
...
eBPF 虚拟机的规范:
1.寄存器
r1-r5
:作为函数调用参数使用。在 eBPF 程序启动时,寄存器r1
包含 “上下文” 参数指针。
2.寄存器r0
:存储函数的返回值,包括函数调用和当前程序退出。
3.寄存器r10
:eBPF 程序的栈指针。
eBPF 加载器
eBPF 程序是由用户编写的,编译成 eBPF 字节码后,需要加载到内核才能被内核使用。
用户态可以通过调用 sys_bpf()
系统调用把 eBPF 程序加载到内核,而 sys_bpf()
系统调用会通过调用 bpf_prog_load()
内核函数加载 eBPF 程序。
我们来看看 bpf_prog_load()
函数的实现(经过精简后):
static int bpf_prog_load(union bpf_attr*attr)
{
enum bpf_prog_type type = attr->prog_type;
struct bpf_prog*prog;
int err;
...
// 创建 bpf_prog 对象,用于保存 eBPF 字节码和相关信息
prog = bpf_prog_alloc(bpf_prog_size(attr->insn_cnt), GFP_USER);
...
prog->len = attr->insn_cnt; //eBPF 字节码长度(也就是有多少条 eBPF 字节码)
err = -EFAULT;
// 把 eBPF 字节码从用户态复制到 bpf_prog 对象中
if(copy_from_user(prog->insns, u64_to_ptr(attr->insns),
prog->len*sizeof(struct bpf_insn)) != 0)
goto free_prog;
...
// 这里主要找到特定模块的相关处理函数(如修正 helper 函数)
err = find_prog_type(type, prog);
// 检查 eBPF 字节码是否合法
err = bpf_check(&prog, attr);
// 修正 helper 函数的偏移量
fixup_bpf_calls(prog);
// 尝试将 eBPF 字节码编译成本地机器码(JIT 模式)
err = bpf_prog_select_runtime(prog);
// 申请一个文件句柄用于与 bpf_prog 对象关联
err = bpf_prog_new_fd(prog);
return err;
...
}
bpf_prog_load()
函数主要完成以下几个工作:
- 创建一个
bpf_prog
对象,用于保存 eBPF 字节码和 eBPF 程序的相关信息。 - 把 eBPF 字节码从用户态复制到
bpf_prog
对象的insns
成员中,insns
成员是一个类型为bpf_insn
结构的数组。 - 根据 eBPF 程序所属的类型(如
socket
、kprobes
或xdp
等),找到其相关处理函数(如helper
函数对应的修正函数,下面会介绍)。 - 检查 eBPF 字节码是否合法。由于 eBPF 程序运行在内核态,所以要保证其安全性,否则将会导致内核崩溃。
- 修正
helper
函数的偏移量(下面会介绍)。 - 尝试将 eBPF 字节码编译成本地机器码,主要为了提高 eBPF 程序的执行效率。
- 申请一个文件句柄用于与
bpf_prog
对象关联,这个文件句柄将会返回给用户态,用户态可以通过这个文件句柄来读取内核中的 eBPF 程序。
修正 helper 函数
helper
函数是 eBPF 提供给用户使用的一些辅助函数。
由于 eBPF 程序运行在内核态,所为了安全,eBPF 程序中不能随意调用内核函数,只能调用 eBPF 提供的辅助函数(helper functions)。
调用 eBPF 的 helper
函数与调用普通的函数并不一样,调用 helper
函数时并不是直接调用的,而是通过 helper
函数的编号来进行调用。
每个 eBPF 的 helper
函数都有一个编号(通过枚举类型 bpf_func_id
来定义),定义在 include/uapi/linux/bpf.h
文件中,定义如下(只列出一部分):
enum bpf_func_id {
BPF_FUNC_unspec, // 0
BPF_FUNC_map_lookup_elem, // 1
BPF_FUNC_map_update_elem, // 2
BPF_FUNC_map_delete_elem, // 3
BPF_FUNC_probe_read, // 4
BPF_FUNC_ktime_get_ns, // 5
BPF_FUNC_trace_printk, // 6
BPF_FUNC_get_prandom_u32, // 7
BPF_FUNC_get_smp_processor_id, // 8
BPF_FUNC_skb_store_bytes, // 9
BPF_FUNC_l3_csum_replace, // 10
BPF_FUNC_l4_csum_replace, // 11
BPF_FUNC_tail_call, // 12
BPF_FUNC_clone_redirect, // 13
BPF_FUNC_get_current_pid_tgid, // 14
BPF_FUNC_get_current_uid_gid, // 15
...
__BPF_FUNC_MAX_ID,
};
下面我们来看看在 eBPF 程序中怎么调用 helper
函数:
#include <linux/bpf.h>
// 声明要调用的 helper 函数为:BPF_FUNC_trace_printk
static int(*bpf_trace_printk)(const char*fmt, int fmtsize, ...)
=(void*) BPF_FUNC_trace_printk;
int hello_world(void*ctx)
{
char msg [] = "Hello World\n";
// 调用 helper 函数
bpf_trace_printk(msg, sizeof(msg)-1);
return 0;
}
从上面的代码可以知道,当要调用 helper
函数时,需要先定义一个函数指针,并且将函数指针赋值为 helper
函数的编号,然后才能调用这个 helper
函数。
定义函数指针的原因是:指定调用函数时的参数。
所以,调用的 helper
函数其实并不是真实的函数地址。那么内核是怎么找到真实的 helper
函数地址呢?
这里就是通过上面说的修正 helper
函数来实现的。
在介绍加载 eBPF 程序时说过,加载器会通过调用 fixup_bpf_calls()
函数来修正 helper
函数的地址。我们来看看 fixup_bpf_calls()
函数的实现:
static void fixup_bpf_calls(struct bpf_prog*prog)
{
const struct bpf_func_proto*fn;
int i;
// 遍历所有的 eBPF 字节码
for(i = 0; i < prog->len; i++) {
struct bpf_insn*insn = &prog->insnsi [i];
// 如果是函数调用指令
if(insn->code ==(BPF_JMP | BPF_CALL)) {
...
// 通过 helper 函数的编号获取其真实地址
fn = prog->aux->ops->get_func_proto(insn->imm);
...
// 由于 bpf_insn 结构的 imm 字段类型为 int,
// 为了能够将 helper 函数的地址(64 位)保存到一个 int 中,
// 所以减去一个基础函数地址,调用的时候加上这个基础函数地址即可。
insn->imm = fn->func - __bpf_call_base;
}
}
}
fixup_bpf_calls()
函数主要完成修正 helper
函数的地址,其工作原理如下:
- 遍历 eBPF 程序的所有字节码。
- 如果字节码指令是一个函数调用,那么将进行函数地址修正,修正过程如下:
- 根据
helper
函数的编号获取其真实的函数地址。 - 将
helper
函数的真实地址减去__bpf_call_base
函数的地址,并且保存到字节码的imm
字段中。
从上面修正 helper
函数地址的过程可知,当调用 helper
函数时需要加上 __bpf_call_base
函数的地址。
eBPF 程序运行时机
上面介绍了 eBPF 程序的运行机制,现在来说说内核什么时候执行 eBPF 程序。
在《eBPF 的简单使用 .》一文中介绍过,eBPF 程序需要挂载到某个内核路径(挂在点)才能被执行 。
根据挂载点功能的不同,大概可以分为以下几个模块:
- 性能跟踪(kprobes/uprobes/tracepoints)
- 网络(socket/xdp)
- 容器(cgroup)
- 安全(seccomp)
比如要将 eBPF 程序挂载在 socket(套接字) 上,可以使用 setsockopt()
函数来实现,代码如下:
setsockopt(sock, SOL_SOCKET, SO_ATTACH_BPF, &prog_fd, sizeof(prog_fd));
下面说说 setsockopt()
函数各个参数的意义:
-sock:要挂载 eBPF 程序的 socket 句柄。
-SOL_SOCKET:设置的选项的级别,如果想要在套接字级别上设置选项,就必须设置为 SOL_SOCKET
。
-SO_ATTACH_BPF:表示挂载 eBPF 程序到 socket 上。
-prog_fd:通过调用 bpf()
系统调用加载 eBPF 程序到内核后返回的文件句柄。
通过上面的代码,就能将 eBPF 程序挂载到 socket 上,当 socket 接收到数据包时,将会执行这个 eBPF 程序对数据包进行过滤。
我们看看当 socket 接收到数据包时的操作:
//file: net/packet/af_packet.c
static int
packet_rcv(struct sk_buff*skb,
struct net_device*dev,
struct packet_type*pt,
struct net_device*orig_dev)
{
...
// 执行 eBPF 程序
res = run_filter(skb, sk, snaplen);
if(!res)
goto drop_n_restore;
...
}
当 socket 接收到数据包时,会调用 run_filter()
函数执行 eBPF 程序。
总结
本文主要介绍了 eBPF 的实现原理,当然本文只是按大体思路去分析,有很多细节需要读者自己阅读源码来了解。
深入探析 eBPF:从程序编写到执行的全流程解析
Linux 内核之旅 2024 年 09 月 28 日 17:42 新加坡
作者简介:杨月顺,西安邮电大学研二在读,陈莉君教授学生。操作系统和 Linux 内核爱好者,热衷于探索 Linux 内核和 eBPF 技术。
在现代 Linux 内核中,eBPF(Extended Berkeley Packet Filter)已经成为强大的工具,用于高效地在内核中执行定制代码,帮助用户监控、分析和优化系统性能。然而,eBPF 的执行机制背后蕴含着一套复杂的流程:从编写用户态程序、编译、加载到内核,验证,再到最终的执行,涉及多个关键步骤。在这篇文章中,我将从 eBPF 用户态代码开始分析,一步一步地去分析 eBPF 程序从编写代码到最终执行的整个过程。并且通过分析 libbpf、bpftrace 以及内核的源代码和一些调试信息,让大家对 eBPF 程序的编译、加载、验证、执行有一个清晰且完整的认识。
本文将重点从以下四个方面来分析 eBPF 的执行原理:
- 编译:分析 eBPF 程序的编译过程。
- 加载:分析 eBPF 程序的加载过程。
- 验证:分析 BPF 虚拟机如何进行验证。
- JIT:分析 eBPF 字节码如何转化为本地机器指令。
首先,先编写以下两个 ebpf 程序:
- 使用 libbpf 库来编写 eBPF 程序:(hello_kern.c)
#include <linux/bpf.h>
#define SEC(NAME) __attribute__((section(NAME), used))
static int (*bpf_trace_printk)(const char *fmt, int fmt_size,
...) = (void *)BPF_FUNC_trace_printk;
SEC("tracepoint/syscalls/sys_enter_execve")
int bpf_prog(void *ctx) {
char msg[] = "Hello, BPF World!";
bpf_trace_printk(msg, sizeof(msg));
return 0;
}
char _license[] SEC("license") = "GPL";
以上程序实现了一个简单的打印功能,我们后面会详细对这个程序进行分析。
- 使用 bpftrace 编写 eBPF 程序:
bpftrace -e 'kprobe:do_nanosleep { printf("PID % d sleeping...\n", pid); }'
这个 bpftrace 程序追踪了 do_nanosleep
内核函数(该函数会在进程睡眠时触发),并打印出触发该函数的进程 PID。
写好了这两个程序,接下来我会通过这两个例子来详细介绍 eBPF 程序的整个执行过程。首先,我会使用一张图来给大家宏观介绍一下 eBPF 程序的执行流程。
这张图是 eBPF 官方给出的图,接下来我会从图中标注的四个重点步骤来简要说明一下:
- 编译:
- eBPF 程序以 C 语言编写,使用
clang -target bpf
编译器将 C 源代码编译为 eBPF 字节码。这个字节码是内核可以理解并执行的代码格式。- 编译之后,生成的 eBPF 程序字节码和与之相关的 eBPF 映射(Maps)可以通过用户态程序进行管理。
- 加载:
- 编译生成的 eBPF 程序会通过
libbpf
库加载到 Linux 内核中。这个步骤涉及使用bpf()
系统调用将 eBPF 程序提交给内核进行处理。- 同时,eBPF Maps(存储 eBPF 程序运行时数据的结构)也会在用户态与内核之间进行交互。
- 验证:
- 在程序加载后,eBPF 验证器会检查字节码,确保程序的安全性和合法性,防止 eBPF 程序破坏内核的稳定性。验证通过后,eBPF 程序通过 eBPF JIT(Just-In-Time)编译器进一步优化,将字节码转换为机器码以提升执行效率。
4.JIT:
- 通过 JIT,eBPF 字节码被直接编译成底层的机器代码,程序可以直接在 CPU 上执行,无需经过解释过程。
有了上述的介绍,想必大家对 eBPF 程序的执行过程已经有了一个大致的认识了吧,接下来我们开始详细分析每个关键步骤。
一、编译阶段:
由于使用 libbpf 库编写的 ebpf 程序和使用 bpftrace 编写的 eBPF 程序在编译阶段的过程有一些不同,因此在本章节中我会说明每个工具在编译过程中特有的内容。
在编译阶段所做的事情可以总结为一句话:将用户写好的 eBPF 程序编译成 eBPF 字节码。那么什么是 eBPF 字节码?话不多是,直接看图:
//1. 使用 clang 编译器对我们编写的 hello_kern.c 源代码进行编译:
clang -O2 -target bpf -c hello_kern.c -o hello_kern.o
//2. 输出 eBPF 字节码和汇编指令
llvm-objdump -S hello_kern.o
如上图所示,每行中的左边一串 16 进制内容就是 eBPF 的指令字节码,右边是汇编指令。
接下来我们来分析 Clang/LLVM 是如何将 ebpf 的.c 程序编译成字节码文件的。
1.1 Clang/LLVM 编译 eBPF 程序过程
操作工具 | 功能 |
---|---|
clang | Clang 负责将 C 源码(如 hello_kern.c )编译成 LLVM 中间表示(LLVM IR),-target bpf 标志表明目标架构是 BPF(用于生成 eBPF 字节码)。命令中的 -Wall 启用所有警告,-O2 用于优化代码以提高性能。 |
opt(高层优化) | 这一步通过多次优化(Pass 1 到 Pass N)对生成的 LLVM IR 进行高层次优化。这些优化可以包括代码清理、消除冗余代码、循环优化等,从而提高生成代码的效率和性能。 |
llc(LLVM 静态编译器) | LLC 将优化后的 LLVM IR 转换为最终的二进制格式(即 BPF 字节码)。该字节码最终以 ELF 格式输出,适用于 eBPF 程序在内核空间中的加载和执行。 |
binary | eBPF 字节码。 |
上述内容分析了通过 Clang 编译器来进行编译的详细过程,接下来我们查看一下编译好的字节码文件中的内容(ELF 文件内容):
//llvm-readelf 用于读取和显示 ELF 文件信息的工具,-S 表示显示 ELF 文件中的节头,-s 用于显示 ELF 文件中的符号表
llvm-readelf -S -s hello_kern.o
通过上述内容我们可以看到 eBPF 字节码文件的节头和符号表信息,里面包含了我们编写的程序通过编译后的各项信息。比如:在这里我们可以看到 SEC("tracepoint/syscalls/sys_enter_execve")
代码对应的 Section 信息,通过查看符号表,可以看到 bpf_prog
函数符号,大小为 112 个字节,位于第三个节(Ndx = 3
,对应 tracepoint/syscalls/sys_enter_execve
)。
上述所讲解的 Clang/LLVM 编译 eBPF 程序的过程是 libbpf 库和 bpftrace 使用 Clang/LLVM 编译 eBPF 程序的共有过程,但是编译使用 bpftrace 编写的 eBPF 程序还需要在这个过程之前有一些操作,接下来详细解释。
1.2 bpftrace 编译过程
libbpf 编写的程序会通过 clang
编译器将 C 代码转化为 eBPF 字节码。而 bpftrace 程序的编译经过 AST,LLVM IR(1.1 分析内容),BPF bytecode 这几个阶段,而接下来我会补充 AST 这个阶段:
为了方便讲解,在这里使用图片来展示 bpftrace 的编译加载等过程:
简单来讲,在 AST 这个阶段就是将我们使用 bpftrace 编写的程序转化为 AST 格式。然后进行语义分析等工作,最终交给 Clang/LLVM 工作,最终形成 eBPF 字节码。那么通过 AST 处理后的最终结果是什么呢?请看下图:
// AST 的结构示意可以使用 -d 查看
bpftrace -d -e 'kprobe:do_nanosleep { printf("PID % d sleeping...\n", pid); }'
那么这个结果是怎么获得的呢?
上面这张图展示了从编写的 bpftrace 程序到 AST 结构的处理流程,接下来我会通过如何解析 pid 这个字段来从源码的角度说明这个过程:
- 首先,由于使用了 pid(bpftrace 内置变量),因此在 lex 查表过程中识别为builtin:(bpftrace/src/lexer.l)
ident [_a-zA-Z][_a-zA-Z0-9]*
map @{ident}|@
var ${ident}
hspace [ \t]
vspace [\n\r]
space {hspace}|{vspace}
path :(\\.|[_\-\./a-zA-Z0-9#+\*])+
builtin arg[0-9]|args|cgroup|comm|cpid|numaid|cpu|ctx|curtask|elapsed
|func|gid|pid|probe|rand|retval|sarg[0-9]|tid|uid|username|jiffies
- 进一步在 yacc 中匹配BULITIN就能分配一个关于
pid
的 AST 结点。
primary_expr:
IDENT { $$ = driver.ctx.make_node<ast::Identifier>($1, @$); }
| int { $$ = $1; }
| STRING { $$ = driver.ctx.make_node<ast::String>($1, @$); }
| STACK_MODE { $$ = driver.ctx.make_node<ast::StackMode>($1, @$); }
| BUILTIN { $$ = driver.ctx.make_node<ast::Builtin>($1, @$); }
| CALL_BUILTIN { $$ = driver.ctx.make_node<ast::Builtin>($1, @$); }
| LPAREN expr RPAREN { $$ = $2; }
| param { $$ = $1; }
| map_or_var { $$ = $1; }
| "(" vargs "," expr ")"
{
auto &args = $2;
args.push_back($4);
$$ = driver.ctx.make_node<ast::Tuple>(std::move(args), @$);
}
;
最终,我们就获取到了 AST 结构信息。再通过语义分析和 LLVM/Clang 的处理就可以得到 eBPF 字节码文件了。
二、加载阶段:
通过编译阶段得到了 eBPF 字节码文件,接下来我们来分析 eBPF 字节码是如何加载进内核的。本章节我会通过 libbpf 的源码来分析这个阶段:(libbpf/src/bpf.c)
int bpf_prog_load(enum bpf_prog_type prog_type,
const char*prog_name, const char*license,
const struct bpf_insn*insns, size_t insn_cnt,
struct bpf_prog_load_opts*opts)
{
const size_t attr_sz = offsetofend(union bpf_attr, prog_token_fd);
void*finfo = NULL,*linfo = NULL;
const char*func_info,*line_info;
__u32 log_size, log_level, attach_prog_fd, attach_btf_obj_fd;
__u32 func_info_rec_size, line_info_rec_size;
int fd, attempts;
union bpf_attr attr;
char*log_buf;
// 增加系统的 memlock 资源限制
bump_rlimit_memlock();
// 检查传入的选项是否有效
if(!OPTS_VALID(opts, bpf_prog_load_opts))
return libbpf_err(-EINVAL);
// 尝试次数选项处理,若尝试次数小于 0 返回错误,若为 0 则设置为默认尝试次数
attempts = OPTS_GET(opts, attempts, 0);
if(attempts < 0)
return libbpf_err(-EINVAL);
if(attempts == 0)
attempts = PROG_LOAD_ATTEMPTS;
// 初始化 bpf_attr 结构体
memset(&attr, 0, attr_sz);
// 设置 eBPF 程序的类型和附加选项
attr.prog_type = prog_type;
attr.expected_attach_type = OPTS_GET(opts, expected_attach_type, 0);
// 设置 BTF 和其他相关的选项
attr.prog_btf_fd = OPTS_GET(opts, prog_btf_fd, 0);
attr.prog_flags = OPTS_GET(opts, prog_flags, 0);
attr.prog_ifindex = OPTS_GET(opts, prog_ifindex, 0);
attr.kern_version = OPTS_GET(opts, kern_version, 0);
attr.prog_token_fd = OPTS_GET(opts, token_fd, 0);
// 如果程序名称存在并且支持功能特性,则复制程序名称
if(prog_name && feat_supported(NULL, FEAT_PROG_NAME))
libbpf_strlcpy(attr.prog_name, prog_name, sizeof(attr.prog_name));
attr.license = ptr_to_u64(license);
// 指令数量检查,如果指令数量超过 UINT_MAX 则返回错误
if(insn_cnt > UINT_MAX)
return libbpf_err(-E2BIG);
// 设置指令和指令数量
attr.insns = ptr_to_u64(insns);
attr.insn_cnt =(__u32) insn_cnt;
// 获取附加的程序 FD 和 BTF 对象 FD
attach_prog_fd = OPTS_GET(opts, attach_prog_fd, 0);
attach_btf_obj_fd = OPTS_GET(opts, attach_btf_obj_fd, 0);
// 检查 attach_prog_fd 和 attach_btf_obj_fd 的冲突情况
if(attach_prog_fd && attach_btf_obj_fd)
return libbpf_err(-EINVAL);
// 设置 BTF 附加 ID 和相应的 FD
attr.attach_btf_id = OPTS_GET(opts, attach_btf_id, 0);
if(attach_prog_fd)
attr.attach_prog_fd = attach_prog_fd;
else
attr.attach_btf_obj_fd = attach_btf_obj_fd;
// 获取日志缓冲区和日志选项
log_buf = OPTS_GET(opts, log_buf, NULL);
log_size = OPTS_GET(opts, log_size, 0);
log_level = OPTS_GET(opts, log_level, 0);
// 确保日志缓冲区和日志大小的一致性
if(!!log_buf != !!log_size)
return libbpf_err(-EINVAL);
// 获取函数信息和行信息的记录大小
func_info_rec_size = OPTS_GET(opts, func_info_rec_size, 0);
func_info = OPTS_GET(opts, func_info, NULL);
attr.func_info_rec_size = func_info_rec_size;
attr.func_info = ptr_to_u64(func_info);
attr.func_info_cnt = OPTS_GET(opts, func_info_cnt, 0);
line_info_rec_size = OPTS_GET(opts, line_info_rec_size, 0);
line_info = OPTS_GET(opts, line_info, NULL);
attr.line_info_rec_size = line_info_rec_size;
attr.line_info = ptr_to_u64(line_info);
attr.line_info_cnt = OPTS_GET(opts, line_info_cnt, 0);
// 设置 fd 数组
attr.fd_array = ptr_to_u64(OPTS_GET(opts, fd_array, NULL));
// 设置日志相关的选项,如果启用了日志
if(log_level) {
attr.log_buf = ptr_to_u64(log_buf);
attr.log_size = log_size;
attr.log_level = log_level;
}
// 调用系统调用加载 eBPF 程序
fd = sys_bpf_prog_load(&attr, attr_sz, attempts);
OPTS_SET(opts, log_true_size, attr.log_true_size);
if(fd >= 0)
return fd;
// 如果加载失败且 errno 为 E2BIG,尝试调整 func_info 和 line_info 并重试加载
while(errno == E2BIG &&(!finfo || !linfo)) {
if(!finfo && attr.func_info_cnt &&
attr.func_info_rec_size < func_info_rec_size) {
// 修正 func_info 并重新尝试
finfo = alloc_zero_tailing_info(func_info,
attr.func_info_cnt,
func_info_rec_size,
attr.func_info_rec_size);
if(!finfo) {
errno = E2BIG;
goto done;
}
attr.func_info = ptr_to_u64(finfo);
attr.func_info_rec_size = func_info_rec_size;
} else if(!linfo && attr.line_info_cnt &&
attr.line_info_rec_size < line_info_rec_size) {
// 修正 line_info 并重新尝试
linfo = alloc_zero_tailing_info(line_info,
attr.line_info_cnt,
line_info_rec_size,
attr.line_info_rec_size);
if(!linfo) {
errno = E2BIG;
goto done;
}
attr.line_info = ptr_to_u64(linfo);
attr.line_info_rec_size = line_info_rec_size;
} else {
break;
}
// 再次尝试加载 eBPF 程序
fd = sys_bpf_prog_load(&attr, attr_sz, attempts);
OPTS_SET(opts, log_true_size, attr.log_true_size);
if(fd >= 0)
goto done;
}
// 如果没有启用日志,但提供了日志缓冲区,重新尝试加载并启用日志
if(log_level == 0 && log_buf) {
attr.log_buf = ptr_to_u64(log_buf);
attr.log_size = log_size;
attr.log_level = 1;
fd = sys_bpf_prog_load(&attr, attr_sz, attempts);
OPTS_SET(opts, log_true_size, attr.log_true_size);
}
done:
// 释放分配的内存
free(finfo);
free(linfo);
return libbpf_err_errno(fd);
}
这段代码 bpf_prog_load
是 libbpf
库中用于加载 eBPF 程序到内核的重要函数,它执行了一系列步骤,将编译好的 eBPF 程序通过系统调用加载到内核中,并附加到特定的内核事件或子系统上。以下是程序中的一些关键部分及其解释:
1.bump_rlimit_memlock()
函数:
- 这个函数提高了
RLIMIT_MEMLOCK
限制,确保在加载 eBPF 程序时有足够的锁定内存可用。加载 eBPF 程序可能需要锁定一定量的内存,因此必须调整这个限制。
- 准备
union bpf_attr
结构体:
union bpf_attr 是一个内核接口,包含了加载 eBPF 程序所需的所有参数。函数开始时,代码将这个结构体初始化,并填充有关 eBPF 程序的详细信息,比如:
prog_type
: eBPF 程序的类型(如BPF_PROG_TYPE_XDP
或BPF_PROG_TYPE_TRACEPOINT
)。insns
: 指向编译好的 eBPF 字节码的指针。insn_cnt
: eBPF 指令的数量。license
: eBPF 程序的许可证信息。prog_flags
: 可选的程序标志。
- 设置与附加点相关的字段:
- 如果 eBPF 程序需要附加到某个特定的内核钩子点(如 tc 或 XDP),则会设置
attach_prog_fd
或attach_btf_obj_fd
,这些字段告诉内核要将 eBPF 程序附加到哪。
- 调用
sys_bpf_prog_load
:
- 核心部分:加载 eBPF 程序的关键步骤是调用系统调用
sys_bpf_prog_load
,这是直接与内核通信的接口。它将填充好的union bpf_attr
传递给内核,内核验证并尝试加载程序。 sys_bpf_prog_load
函数会返回一个文件描述符(fd
),表示成功加载的 eBPF 程序。如果失败,会返回一个负值,并设置errno
。
接下来再往下追踪 sys_bpf_prog_load
函数,最终追踪到了系统调用层面。
static inline int sys_bpf(enum bpf_cmd cmd, union bpf_attr*attr,
unsigned int size)
{
return syscall(__NR_bpf, cmd, attr, size);
}
总结一下,在加载阶段,会将编译阶段的 eBPF 字节码文件进行进一步的处理,并通过系统调用加入到内核空间中去 。
加载之后,内核就可以运行 eBPF 程序了吗?那当然不行,加载进内核的程序肯定是要安全可靠的,要是 eBPF 程序有问题,进而造成内核崩溃,那后果可太严重了。针对此问题,内核虚拟机里的验证器发挥了巨大作用。
三、验证阶段
验证阶段的工作非常繁琐,总之它的作用就是要验证你的程序对于内核来说是无害的。以下是内核中 eBPF 验证器的主要入口函数代码:(/kernel/bpf/verifier.c)
int bpf_check(struct bpf_prog**prog, union bpf_attr*attr, bpfptr_t uattr)
{
u64 start_time = ktime_get_ns(); // 获取验证器开始时间,用于计算验证时间
struct bpf_verifier_env*env; // 定义验证器环境
struct bpf_verifier_log*log; // 定义日志
int i, len, ret = -EINVAL; // 初始化一些变量
bool is_priv; // 检查用户是否拥有特权权限
// 为 bpf_verifier_env 分配内存。它包含了程序的验证状态和日志信息
env = kzalloc(sizeof(struct bpf_verifier_env), GFP_KERNEL);
if(!env)
return -ENOMEM; // 如果内存分配失败,返回错误
log = &env->log; // 初始化日志指针
len =(*prog)->len; // 获取程序的指令长度
// 分配内存给每条指令的辅助数据,保存原始索引信息
env->insn_aux_data = vzalloc(array_size(sizeof(struct bpf_insn_aux_data), len));
ret = -ENOMEM;
if(!env->insn_aux_data)
goto err_free_env; // 如果内存分配失败,跳转到清理部分
for(i = 0; i < len; i++)
env->insn_aux_data [i].orig_idx = i; // 初始化每条指令的原始索引
env->prog =*prog; // 将程序指针赋值给验证环境
env->fd_array = make_bpfptr(attr->fd_array, uattr.is_kernel); // 生成文件描述符数组指针
is_priv = bpf_capable(); // 检查是否具有执行 eBPF 的权限
// 初始化验证器状态
mark_verifier_state_clean(env);
// 处理 BPF_LD_IMM64 伪指令,这是将立即数加载到寄存器的特殊指令
ret = resolve_pseudo_ldimm64(env);
if(ret < 0)
goto skip_full_check; // 如果处理失败,跳转到后续部分
// 检查程序的控制流图,确保它是合法的
ret = check_cfg(env);
if(ret < 0)
goto skip_full_check; // 如果控制流检查失败,跳过后续的完整检查
// 验证子程序(如果有的话)模拟整个运行过程,特别是对于指针访问的每个细节进行检查
//!!!!!这里就是经常编写 ebpf 程序容易报错的地方
ret = do_check_subprogs(env);
ret = ret ?: do_check_main(env); // 验证主程序
skip_full_check:
// 释放在验证期间分配的状态表内存
kvfree(env->explored_states);
// 优化程序中的循环结构
if(ret == 0)
ret = optimize_bpf_loop(env);
// 如果用户有特权,移除程序中的死代码
if(is_priv) {
if(ret == 0)
ret = opt_remove_dead_code(env);
} else {
// 如果没有特权,只清理死代码(不移除)
if(ret == 0)
sanitize_dead_code(env);
}
// 转换上下文中的特定访问,如对网络包字段的访问
if(ret == 0)
ret = convert_ctx_accesses(env);
// 修正函数调用参数,确保其合法性
if(ret == 0)
ret = fixup_call_args(env);
// 记录验证所花费的总时间
env->verification_time = ktime_get_ns() - start_time;
err_free_env:
// 清理分配的验证器环境
kfree(env);
return ret;
}
这里列举的代码并不全面,读者要是想深入了解验证器的实现过程,请自行阅读源码。验证器所需要验证的内容和所需做的工作如下图所示:
四、JIT 阶段
要是使用 eBPF 字节码再加上 eBPF 指令解释器来运行 eBPF 程序效率是很低的,JIT(即时编译,Just-In-Time compilation)很好的解决了这个问题,并且目前大多数机器支持了这个功能。使用 JIT 的优势如下:
- 通过 JIT,eBPF 字节码被直接编译成底层的机器代码,程序可以直接在 CPU 上执行,无需经过解释过程。因此,JIT 编译可以显著提高 eBPF 程序的执行速度,尤其是在高频调用或性能关键场景下。
- 解释器在逐条执行 eBPF 字节码时,需要进行大量的上下文切换和检查操作,增加了运行的开销。而 JIT 编译后的机器代码是直接在内核态执行的,这减少了指令解释、寄存器管理、栈管理等操作的开销。
- eBPF 常用于网络包处理、性能监控等高性能场景。JIT 编译后的程序执行速度接近于内核中的原生代码,非常适合这些需要低延迟、高吞吐量的任务。特别是像 XDP(eXpress Data Path)这种需要处理每一个网络包的应用场景中,JIT 编译极大地减少了延迟。
- 不同的 CPU 体系结构(如 x86、ARM)可以通过 JIT 编译生成最适合当前架构的机器代码,充分利用硬件资源,从而进一步提升运行效率。
接下来我们来查看一下 eBPF 字节码如何翻译成机器指令(x86 体系结构),由于我们开始编写的 bpftrace 程序:bpftrace -e 'kprobe:do_nanosleep { printf("PID % d sleeping...\n", pid); }'
使用到了 pid 变量,其实是调用了 get_current_pid_tgid
这个帮助函数,以此这里以 call 指令的翻译过程来举例:(arch/x86/net/bpf_jit_comp.c)
static int do_jit(struct bpf_prog *bpf_prog, int *addrs, u8 *image, u8 *rw_image,
int oldproglen, struct jit_context *ctx, bool jmp_padding)
{
......
case BPF_JMP | BPF_CALL: {
int offs;
func = (u8 *) __bpf_call_base + imm32;
if (tail_call_reachable) {
RESTORE_TAIL_CALL_CNT(bpf_prog->aux->stack_depth);
if (!imm32)
return -EINVAL;
offs = 7 + x86_call_depth_emit_accounting(&prog, func);
} else {
if (!imm32)
return -EINVAL;
offs = x86_call_depth_emit_accounting(&prog, func);
}
if (emit_call(&prog, func, image + addrs[i - 1] + offs))
return -EINVAL;
break;
}
......
}
通过分析,我们再追踪 `emit_call` 函数:
```c
static int emit_call(u8**pprog, void*func, void*ip)
{
return emit_patch(pprog, func, ip, 0xE8);
}
最终,call 指令翻译到 x86 中的操作码是 0xE8。
通过反汇编查看指令 get_current_pid_tgid
指令:
bpftool prog dump jited id 965
第 11 行是 get_current_pid_tgid
函数的汇编指令。
接下来我们通过 bpftool 工具来查看 eBPF 程序通过 JIT 翻译后的的机器码:
# 这里的 965 是本机 ebpf 程序的编号
bpftool prog dump jited id 965 opcodes | grep -v :
可以发现,第十一行的 call 指令被翻译成了 e8,正好与 JIT 翻译后的 CALL 指令的机器码一致。
五、总结
通过本文对 eBPF 原理的分析,希望能帮助大家在 eBPF 实现原理层面获得更深入的认识。虽然本文在某些细节上尚未进行深入探讨,但我们相信它能为您提供 eBPF 程序运行全过程的初步认知,并为您后续深入学习特定方面奠定基础。若文中存在理解不准确的地方,欢迎指正,感谢各位读者的耐心阅读。
参考资料:
- 性能专家 Brendan Gregg(《性能之巅》作者)在 USENIX 上做的关于 eBPF 工作原理的演讲和相关文档
- 《Linux 内核观测技术 BPF》译者之一,狄卫华老师关于 eBPF 原理讲解视频。
- 《BPF 之巅》
via:
-
一文读懂 eBPF 的前世今生 原创 宋赛 Linux 阅码场 2022 年 05 月 24 日 08:00 陕西
https://mp.weixin.qq.com/s/ww510TUdLG8jd6VzfQnjxw -
eBPF 汇编指令介绍原创 songsong001 Linux 内核那些事 2022 年 03 月 10 日 09:00
https://mp.weixin.qq.com/s/3q3F_1nFi28hsgVTZo0mSw -
那些年读过的 eBPF 关键研究论文 原创 郑昱笙 酷玩 BPF 2023 年 10 月 16 日 18:00 浙江
https://mp.weixin.qq.com/s/pohxukbYFZycxS2hWKv3QA -
一文看懂 eBPF|eBPF 的简单使用原创 songsong001 Linux 内核那些事 2022 年 03 月 14 日 09:00
https://mp.weixin.qq.com/s/V-5k1mX5JRA0lWLXJ2AxpA -
一文看懂 eBPF|eBPF 实现原理 原创 songsong001 Linux 内核那些事 2022 年 03 月 21 日 09:00
https://mp.weixin.qq.com/s/rvXIC96iDclB0tRX2JirUg -
深入探析 eBPF:从程序编写到执行的全流程解析Linux 内核之旅 2024 年 09 月 28 日 17:42 新加坡
https://mp.weixin.qq.com/s/hl5flUD9FLiO9maapQQ0NQ