eBPF 中的 XDP 程序允许进行非常高效的、自定义的数据包处理。eBPF XDP 程序在数据包到达内核网络堆栈之前运行。
翻译自 Catch Performance in eBPF with Rust: XDP Programs 。
这是五部分系列文章中的第二部分。在此阅读第一部分。
在这个系列中,我们学习了 eBPF 是什么,以及与之相关的工具,为什么 eBPF 性能很重要,以及如何使用连续基准测试来跟踪性能。在本系列的这一篇文章中,我们将讨论如何使用 Aya 在 Rust 中创建一个基本的 eBPF XDP 程序。该项目的所有源代码都是开源的,可以在 GitHub 上获取。
eBPF XDP 程序允许进行非常高效的、自定义的数据包处理。eBPF XDP 程序在数据包到达内核的网络堆栈之前运行。eBPF XDP 程序可以执行四种不同的操作:
-
XDP_PASS
:将数据包传递给正常的网络堆栈进行处理。数据包内容可以被修改。 -
XDP_DROP
:丢弃数据包并不对其进行处理。这是最快的操作。 -
XDP_TX
:将数据包转发到它所在的相同网络接口。数据包内容可以被修改。 -
XDP_ABORTED
:在处理过程中出现错误,因此丢弃数据包并不进行处理。这表示 eBPF 程序中的错误。
在我们的基本示例中,如果一切顺利,我们只会执行第一个操作 XDP_PASS
,因为我们更关注的是脚手架和进程间通信,而不是数据包处理逻辑。我们的初始版本的 eBPF XDP 应用程序只会记录接收到的每个数据包的 IPv4 源地址。
让我们首先看一下内核方面的代码:
#[xdp(name = fun_xdp)]
pub fn fun_xdp(ctx: XdpContext) -> u32 {
match try_fun_xdp(&ctx) {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}
逐行解释:
- 这是一个 Aya 宏,告诉我们我们正在创建一个名为
fun_xdp
的 eBPF XDP 程序。 - 我们的 eBPF XDP 程序的函数定义。它以上下文作为唯一参数输入。上下文告诉我们内核提供给我们的所有信息,并返回一个无符号 32 位整数。
- 有一个名为
try_fun_xdp
的辅助函数,我们将在下面讨论。根据它的返回值,如果返回Ok
,则一切正常,我们返回给定的值。否则,如果得到一个Err
,我们中止执行。
现在让我们来看一下 try_fun_xdp 函数:
fn try_fun_xdp(ctx: &XdpContext) -> Result<u32, ()> {
let eth_hdr: *const EthHdr = unsafe { ptr_at(ctx, 0)? };
unsafe {
let EtherType::Ipv4 = (*eth_hdr).ether_type else {
return Ok(xdp_action::XDP_PASS);
};
}
let ipv4_hdr: *const Ipv4Hdr = unsafe { ptr_at(ctx, EthHdr::LEN)? };
let source_addr = unsafe { (*ipv4_hdr).src_addr };
info!(ctx, "IPv4 Source Address: {}", source_addr);
Ok(xdp_action::XDP_PASS)
}
逐行解释:
-
try_fun_xdp
函数接受一个对上下文的引用,并返回一个Result
,其中包含一个Ok
的无符号 32 位整数值或一个空的Err
。 - 从上下文中获取以太网头部。注意这里的
unsafe
的ptr_at
辅助函数,我们接下来会讨论它。 - 接下来的操作在 Rust 编译器中也被认为是
unsafe
的,因此我们必须显式地选择它们。 - 对于我们的基本示例,我们只关心 IPv4 ,因此对于其他情况,我们只需要将数据包传递出去。
- --
- --
- --
- 提取 IPv4 头部。再次使用
unsafe
的ptr_at
辅助函数。 - 从 IPv4 头部获取源地址。
- 记录 IPv4 的源地址。
- --
- 返回通过!
- --
- --
最后让我们看一下 ptr_at
辅助函数:
#[inline(always)]
unsafe fn ptr_at<T>(ctx: &XdpContext, offset: usize) -> Result<*const T, ()> {
let start = ctx.data();
let end = ctx.data_end();
let len = core::mem::size_of::<T>();
if start + offset + len > end {
return Err(());
}
Ok((start + offset) as _)
}
逐行解释:
- 因为这个操作将在许多地方进行并且在关键路径中,我们使用宏要求编译器始终内联我们的辅助函数。
- 这是一个不安全函数,从上下文中以特定的字节偏移量读取泛型类型
T
的数据。对于成功读取,Result
是一个指向T
的指针的Ok
。否则,返回一个空的Err
。 - 上下文给定内存的起始地址。
- 上下文给定内存的结束地址。
- 泛型类型
T
的字节数。 - 如果起始地址、字节偏移量和T的长度之和大于结束地址,则返回一个空的 Err ,因为我们超出了上下文的界限。如果我们不进行此检查, eBPF 验证器会感到不安,并很可能使我们的构建失败。
- --
- --
- --
- --
- 从上下文给定内存的特定字节
offset
处读取 T 。 - --
还有一个最后的函数:
#[panic_handler]
fn panic(_info: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}
逐行解释:
- 定义一个自定义的 Rust panic 处理函数。
- 该函数接受 Rust panic 信息,但它从不使用。这个函数永远不应该返回。
- 给 Rust 编译器一个提示,表明这段代码应该是不可达的。也就是说,我们永远不希望发生 pani c。这是为了让 eBPF 验证器保持快乐的必要条件。
- --
现在转到用户空间的部分。让我们看看我们的 main
函数:
#[tokio::main]
async fn main() -> Result<(), anyhow::Error> {
let opt = Opt::parse();
env_logger::init();
let mut bpf = Bpf::load(include_bytes_aligned!("../path/to/ebpf-bin"))?;
BpfLogger::init(&mut bpf)?;
let program: &mut Xdp = bpf.program_mut(fun_xdp).unwrap().try_into()?;
program.load()?;
program.attach(&opt.iface, XdpFlags::default())?;
info!("Waiting for Ctrl-C...");
signal::ctrl_c().await?;
info!("Exiting...");
Ok(())
}
#[derive(clap::Parser)]
struct Opt {
#[clap(long)]
iface: String,
}
逐行解释:
- 这个宏使用
tokio
创建了一个异步运行时来运行我们的程序。 - 一个异步的
main
函数。在 Rust 二进制文件中,main
函数是事实上的入口点。该函数的结果是一个空的Ok
或使用 anyhow crate 捕获所有的Err
。 - 解析传递给二进制文件的命令行参数。
- 为用户空间初始化日志记录。
- 加载我们编译的 eBPF 字节码。Aya 使得将我们的 eBPF 源代码重新编译为字节码变得容易,所以它会在编译用户空间代码之前自动进行。
- 从我们的 eBPF 程序中初始化日志记录。
- 从我们的 eBPF 字节码中获取
fun_xdp
eBPF XDP 程序。 - 将
fun_xdp
eBPF XDP 程序加载到内核中,使用默认标志。 - 将我们的
fun_xdp
eBPF XDP 程序附加到一个由iface
命令行参数设置的网络接口上。 - --
- 记录如何退出我们的程序。
- 等待用户输入 Ctrl + C 。
- 记录我们的程序正在退出。
- 以一个空的 Ok 作为我们的结果返回。
- --
- --
- 这个宏使用 clap 来解析在
Opt
结构中定义的命令行参数。 - 命令行参数结构体名为
Opt
。 - 另一个宏,告诉
clap
这个字段应该作为长参数名进行解析,即--iface
。 - 参数的名称是
iface
,其值为字符串。
通过以上代码,我们已经创建了一个非常基本的 eBPF 程序。同样,该项目的所有源代码都是开源的,并且可在 GitHub 上获得。