资讯详情 Boost.Asio扩展实战:Linux下AF_PACKET二层原始套接字收发全攻略
📅 2026/10/23 7:14:52
使用 Boost.Asio 写过 TCP/UDP 的人都知道它在 IP 层以上的事情上抽象得相当顺手。但一旦需求落到直接在以太网帧这一层收发数据默认的 asio 就完全不够用了——它压根不认识AF_PACKET更不知道sockaddr_ll这种只有 Linux 二层网络才会用到的地址结构。这篇文章就把这条路完整走一遍让你能在 C 里用 Boost.Asio 的 raw socket 创建真正的第二层Ethernet套接字既能在io_context里异步收帧也能手工构造帧头发出去。适合正在做链路层协议模拟、二三层网关、流量审计、透明桥接这类工作的朋友也适合想彻底搞懂 asio 自定义 Protocol 扩展机制的人。先明确一件事标题里的 raw socket 在很多文章里指的是AF_INET SOCK_RAW它拿到的是不含以太网头的 IP 包属于三层原始套接字。本文要做的第二层 Ethernet 套接字得用 Linux 的AF_PACKET SOCK_RAW收到的数据是完整的以太网帧——目的 MAC、源 MAC、EtherType、载荷全都在发送时也要自己拼这 14 字节的头部。另外这里默认环境是 Linux因为AF_PACKET是 Linux 专属的协议族Windows 那边要另走 Npcap 之类的用户态驱动通道不在本次讨论范围。1. Boost.Asio 默认够不到以太网这一层问题出在哪儿1.1 从取快递到看快递车理解这个区别用一个生活类比就行。普通 TCP/UDP socket 相当于你告诉快递员我要那个包裹然后只关心收到的物品本身——内核帮你把 IP 层、链路层、MAC 地址这些全部处理掉了。而AF_PACKETsocket 相当于你直接站到小区门口的监控室看着每一辆快递车帧从哪个方向来、准备去哪个方向、装的是什么东西而且没有任何人替你拆箱所有字节都需要你自己理解。内核在AF_INET路径里会把以太网头悄悄丢弃只向上层交 IP 载荷但AF_PACKET会把链路层的完整信息暴露出来。为了描述这条帧是谁发的、从哪个网卡进来、发给哪个协议内核定义了一个专门的结构体struct sockaddr_ll它包含接口索引、硬件类型、包类型、发送方 MAC 地址、目标协议号等字段。这个结构在linux/if_packet.h里是 L2 套接字的核心数据结构。1.2 Boost.Asio 的抽象方式Protocol 和 EndpointBoost.Asio 能支持 TCP、UDP、ICMP 甚至串口依赖的是一套模板抽象。简单说只要一个类型能提供协议族family、套接字类型type、协议号protocol再配一个能表达地址的 Endpoint 类型asio 的basic_socket、basic_raw_socket就能为它服务。ip::tcp、ip::udp本质上就是protocol endpoint的现成实现。所以给 asio 增加一种新协议不需要改库源码只要自己写一个满足约定的 Protocol 类和 Endpoint 类即可。很多人在这一步被卡住是因为没理解 asio 在这个抽象层有多洁癖它内部虽然用的是 POSIX socket 接口但所有地址都以void* data()和size()的方式操作从不关心你传进去的是sockaddr_in还是sockaddr_ll。这恰恰给了我们操作二层套接字的窗口。1.3 三条路线怎么选实际开发中把 raw fd 纳入 asio 有几种做法我调研后总结成一张表方案能否表达 sockaddr_ll代码量适用场景generic::raw_protocol勉强要强制类型转换小快速验证抓包posix::stream_descriptor包装 fd不行只能当字节流很小只抓包、不看来源地址自定义Protocol Endpoint完全可控中正式项目收发都要我的建议很明确做正式的二层通信项目别在generic::raw_protocol上凑合花一小时把自定义 Protocol 写出来后面所有收发的代码都会顺畅很多。下面先演示一下凑合方案能跑到什么程度再进入正题。2. 先跑通最简单版本generic::raw_protocol 抓包验证与边界2.1 用 generic::raw_protocol 快速抓一个帧Boost.Asio 从 1.66 左右开始提供了比较完整的 generic 套接字支持generic::raw_protocol允许你指定任意的(family, type, protocol)三元组。所以理论上可以直接这样打开一个 AF_PACKET socket#include boost/asio.hpp #include linux/if_packet.h #include linux/if_ether.h #include net/if.h #include cstring using boost::asio::generic::raw_protocol; int main() { boost::asio::io_context io; raw_protocol proto(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); boost::asio::basic_raw_socketraw_protocol sock(io, proto); int fd sock.native_handle(); const char* ifname eth0; // 因为 generic 端点没法表达 ifindex只能用 SO_BINDTODEVICE 把 socket 锁到网卡上 ::setsockopt(fd, SOL_SOCKET, SO_BINDTODEVICE, ifname, std::strlen(ifname)); unsigned char buf[2048]; raw_protocol::endpoint from; sock.async_receive_from( boost::asio::buffer(buf, sizeof(buf)), from, [](const boost::system::error_code ec, std::size_t n) { if (!ec) { std::printf(recv %zu bytes\n, n); } if (!ec) { sock.async_receive_from(boost::asio::buffer(buf, sizeof(buf)), from, [](const boost::system::error_code, std::size_t) {}); } }); io.run(); return 0; }这段代码能收到数据但很快你会感觉到别扭SO_BINDTODEVICE是一个字符串形的网卡名不是整数 ifindexfrom这个端点返回后底层其实是sockaddr_storage你得手硬转成sockaddr_ll才能读到发方 MAC。2.2 这个能跑版本的三个限制第一个限制是源端信息读取非常不舒服。asio 的async_receive_from会把接收到的地址端点通过resize()更新长度但generic::raw_protocol::endpoint的类型是基于sockaddr_storage的长度默认 128 字节。你要读sll_addr里的 MAC必须把from.data()强转成sockaddr_ll*而且必须自己保证size()返回的地址长度至少 20 字节否则读到的字段可能越界。这对 能跑 的程序来说不是致命问题但很脏。第二个限制是发送路径没法表达发给哪个 MAC。send_to需要你传一个 Endpoint而 generic 端点里根本没有sll_halen、sll_addr这样语义明确的成员。你可以手动往sockaddr_storage里塞字节但字节序、字段偏移全靠脑补几乎等于在裸写网络协议。第三个限制是 ifindex 和网卡名的来回转换。SO_BINDTODEVICE绑定的参数是字符串而if_nametoindex()拿到的是整数你还得把整数转回字符串才能塞进 setsockopt多此一举。所以这个方案适合做一件事确认这台机器上 AF_PACKET 抓包链路是通的、权限是够的。进入正式开发前我还是倾向于把它换成自定义协议。3. 完整方案自定义 l2::protocol 让 asio 原生支持 AF_PACKET3.1 endpoint 实现把 sockaddr_ll 交给 asioasio 对 Endpoint 的要求其实非常直白data()返回底层地址结构指针size()返回当前地址长度capacity()返回缓冲区容量resize()在收包完成后更新长度family()返回协议族。这个约定在basic_socket的 socket 操作里会用到。我们定义一个l2::endpoint内部直接保存sockaddr_ll#include array #include cstring #include linux/if_packet.h #include linux/if_ether.h #include net/if.h #include boost/asio.hpp namespace l2 { class endpoint { public: endpoint() noexcept { reset(); } explicit endpoint(int ifindex) noexcept { reset(); addr_.sll_ifindex ifindex; } endpoint(int ifindex, unsigned short ethertype, const unsigned char dst_mac[6]) noexcept { reset(); addr_.sll_ifindex ifindex; addr_.sll_protocol htons(ethertype); addr_.sll_halen 6; std::memcpy(addr_.sll_addr, dst_mac, 6); } void* data() noexcept { return addr_; } const void* data() const noexcept { return addr_; } std::size_t size() const noexcept { return addr_len_; } std::size_t capacity() const noexcept { return sizeof(addr_); } void resize(std::size_t len) noexcept { addr_len_ len; } int family() const noexcept { return AF_PACKET; } private: void reset() noexcept { std::memset(addr_, 0, sizeof(addr_)); addr_.sll_family AF_PACKET; addr_.sll_protocol htons(ETH_P_ALL); addr_.sll_ifindex 0; addr_.sll_halen 6; addr_len_ sizeof(addr_); } sockaddr_ll addr_{}; std::size_t addr_len_ sizeof(addr_); }; } // namespace l2这里最关键的点是capacity()返回sizeof(sockaddr_ll)也就是 20 字节。asio 在调用recvfrom时会把capacity()作为地址缓冲区长度传给内核而 Linux 的 packet socket 在内核侧填充地址时恰好就是固定写满 20 字节的sockaddr_ll。两边长度完美对齐收包后resize()得到的地址长度也是 20后续访问sll_addr不会踩到未初始化内存。很多自定义实现把capacity()写成 128也能跑但会在很多细节上埋雷。3.2 protocol 实现AF_PACKET SOCK_RAW 协议号Protocol 类需要提供三个整数family()返回AF_PACKETtype()返回SOCK_RAWprotocol()返回你要监听的 EtherType 的网络字节序值。asio 的basic_raw_socket::open()会拿这三个值去调用系统的socket()创建原始套接字。namespace l2 { class protocol { public: using endpoint l2::endpoint; static protocol create(unsigned short ethertype) noexcept { return protocol(ethertype); } int family() const noexcept { return AF_PACKET; } int type() const noexcept { return SOCK_RAW; } int protocol() const noexcept { return htons(ethertype_); } private: explicit protocol(unsigned short ethertype) noexcept : ethertype_(ethertype) {} unsigned short ethertype_; }; } // namespace l2 using l2_socket boost::asio::basic_raw_socketl2::protocol;为什么type()必须是SOCK_RAW而不是SOCK_DGRAM因为只有SOCK_RAW才会把完整以太网帧头交给你如果选SOCK_DGRAM内核会剥掉 14 字节头你只能拿到载荷这对构造和解析任意二层帧的用途来说等于白装。SOCK_DGRAM的 AF_PACKET 适合做只想看 IP 报文的场景但既然标题是 Layer 2就用SOCK_RAW。3.3 首个能抓到帧的最小程序组合上面的两个类一个完整的抓帧程序如下#include cstdio #include cstdint void print_mac(const unsigned char* p) { std::printf(%02X:%02X:%02X:%02X:%02X:%02X, p[0], p[1], p[2], p[3], p[4], p[5]); } void handle_frame(const unsigned char* data, std::size_t len) { if (len 14) return; std::printf(len%zu , len); std::printf(dst); print_mac(data 0); std::printf( src); print_mac(data 6); std::uint16_t ethertype (data[12] 8) | data[13]; std::printf( ethertype0x%04X\n, ethertype); } int main(int argc, char** argv) { if (argc 2) return 1; boost::asio::io_context io; l2_socket sock(io, l2::protocol::create(ETH_P_ALL)); sock.bind(l2::endpoint(if_nametoindex(argv[1]))); unsigned char buf[2048]; l2::endpoint from; std::functionvoid() start_receive []() {}; start_receive []() { sock.async_receive_from( boost::asio::buffer(buf, sizeof(buf)), from, [](const boost::system::error_code ec, std::size_t n) { if (!ec) handle_frame(buf, n); start_receive(); }); }; start_receive(); io.run(); return 0; }编译命令很简单g -stdc17 -O2 -pthread l2_demo.cpp -o l2_demo sudo ./l2_demo eth0如果终端一直安静别急这说明网卡上没有流量。另开一个终端执行ping -I eth0 8.8.8.8或者在局域网里随便发个数据包就能看到程序逐帧打印目的 MAC、源 MAC 和 EtherType。这个最小程序已经具备了一个 L2 抓包器的骨架。4. 异步收发与事件模型如何在 io_context 里同时收、发 L2 帧4.1 收帧回调的经典循环写法异步收帧的套路和 UDP 很像核心是必须保证下一次 receive 在回调里继续发起否则io_context一旦把所有待处理事件跑完run()就会返回程序直接退出。上面最小程序里用的就是这种自循环结构。有一点必须注意buf和from在每次异步调用期间都是被 asio借走的绝不能在 handler 执行之前重新发起接收并复用同一个buf。实际项目中常见的错误是为了追求吞吐在 handler 里调io_context::post把数据处理丢给另一个线程主线程立刻发起下一次 receive结果buf被新帧覆盖之前的 handler 数据全乱了。正确的做法是把数据先拷贝走或者使用shared_ptr作为缓冲区持有者再交给别的线程。4.2 发送自定义以太网帧send_to 的姿势发送帧需要构造l2::endpoint(ifindex, ethertype, dst_mac)然后把自带的以太网头整个放进待发送缓冲区。下面这段示例构造了一个发往广播地址的自定义协议帧std::arrayunsigned char, 60 frame{}; std::memset(frame.data(), 0xFF, 6); // 目的 MAC广播 // 源 MAC 请替换成实际网卡 MAC frame[0] 0x00; frame[1] 0x11; frame[2] 0x22; frame[3] 0x33; frame[4] 0x44; frame[5] 0x55; frame[12] 0x88; frame[13] 0xB5; // EtherType 0x88B5 // 载荷从 frame[14] 开始自行填充 l2::endpoint target(if_nametoindex(eth0), 0x88B5, frame.data()); sock.send_to(boost::asio::buffer(frame), target);这里有一个踩坑经验sll_protocol千万不要一直用ETH_P_ALL。很多内核版本在发送路径上对ETH_P_ALL非常挑剔会出现发出去的东西对端完全不识别或者干脆返回错误。最稳的做法是让endpoint构造函数里的ethertype和你填在以太网头的frame[12..13]保持一致。换句话说发送端点的协议号要与帧头 EtherType 相同接收端才方便用ETH_P_ALL通吃。4.3 多线程 io_context::run 与线程安全接收和发送可以共用同一个io_context也可以每个 socket 一个io_context这取决于模块边界。常用的做法是std::vectorstd::thread threads; for (int i 0; i 4; i) { threads.emplace_back([io] { io.run(); }); }多线程run()的收益是明显的但有一个坑必须提前意识到async_receive_from对同一个 socket 同时只能有一个未完成的调用所以不会出现两个线程同时读到一帧的问题。不过某些 async 操作完成后 handler 会被分发到任意线程执行如果你的 handler 里修改了共享的哈希表或计数器不加重入保护一样会崩。最简单的方式是给共享状态加锁或者把涉及共享状态的 handler 都post到同一个strand上。4.4 性能方面小帧处理开销到底在哪在二层场景里最常见的是小帧风暴比如每秒几万个 ARP 广播。asio 路线每一次收包都要经历一次recvfrom系统调用和一次 handler 回调这两部分开销加起来非常可观。实测经验里普通单线程io_context.run()处理小帧的上限大概在每秒 10 万帧左右再往上就明显出现内核接收队列积压。想在这个基础上优化有几个方向第一调大 socket 接收缓冲区int rcvbuf 4 * 1024 * 1024; setsockopt(sock.native_handle(), SOL_SOCKET, SO_RCVBUF, rcvbuf, sizeof(rcvbuf));第二如果场景是大部分帧我不关心可以直接在 socket 上挂一个 BPF 过滤器让内核在投递到用户态之前就把无关帧过滤掉struct sock_filter code[] { /* 过滤规则 */ }; struct sock_fprog prog { ... }; setsockopt(sock.native_handle(), SOL_SOCKET, SO_ATTACH_FILTER, prog, sizeof(prog));第三如果每秒要处理的帧数超过几十万asio 这套模型就不太合适了直接上 AF_PACKET 的 PACKET_MMAP 环形缓冲区才是正路。那已经是另一个话题但方向你要清楚。5. 工程落地必踩的坑权限、混杂模式、协议过滤与丢包排查5.1 权限不是非得上 sudoCAP_NET_RAW 更优雅创建AF_PACKET SOCK_RAW套接字需要CAP_NET_RAW权限普通用户会得到EPERM。调试期直接sudo没问题但部署到服务里我不建议让整个进程跑 root。Linux 提供了更细粒度的能力控制sudo setcap cap_net_rawep /usr/local/bin/your_l2_tool之后普通用户就能运行不需要 sudo。如果你的程序是 systemd 服务还可以在 service 文件里加AmbientCapabilitiesCAP_NET_RAW CapabilityBoundingSetCAP_NET_RAW这两个配置组合使用既能保证功能又能把权限面压到最小。5.2 混杂模式不是默认开的很多人写完抓包程序发现只能收到自己发出去的包第一反应是代码有问题其实是网卡没有进入混杂模式硬件层直接把目的 MAC 不匹配的帧丢了。设置混杂模式有两种常见方式方式影响范围恢复方式ioctl 设置IFF_PROMISC全局网卡标志手动关闭或进程结束后仍可能残留PACKET_ADD_MEMBERSHIPPACKET_MR_PROMISC带引用计数最后一个 socket 关闭后自动恢复推荐用第二种即设置packet_mreqint fd sock.native_handle(); struct packet_mreq mr{}; mr.mr_ifindex if_nametoindex(eth0); mr.mr_type PACKET_MR_PROMISC; setsockopt(fd, SOL_PACKET, PACKET_ADD_MEMBERSHIP, mr, sizeof(mr));这个方式比IFF_PROMISC干净得多进程退出后引用计数归零网卡自动恢复非混杂状态不会污染宿主环境。5.3 ETH_P_ALL 与具体协议的差异VLAN 让帧长多 4 字节创建 socket 时第三个参数决定内核往这个 socket 上投递哪些协议。ETH_P_ALL表示所有 EtherType 都投递适合做统一监听如果写ETH_P_IP则只有 EtherType 为 0x0800 的帧到达。发送时不要用ETH_P_ALL前面已经讲过原因。另一个容易被忽略的是 VLAN 帧。当交换机端口带着 VLAN tag 转发时以太网头后面会多出 4 字节0x8100标签协议标识 2 字节 TCI。也就是说你解析收到的数据时如果看到ethertype 0x8100真实的应用协议头要从偏移 18 字节开始而不是 14。判断逻辑可以写成std::size_t hdr_len 14; if ((data[12] 0x81 data[13] 0x00) || (data[12] 0x88 data[13] 0xa8)) { hdr_len 18; // 802.1Q VLAN 或 802.1ad QinQ }如果你的目标是抓二层标签本身还需要考虑PACKET_IGNORE_OUTGOING之类的选项以及 VLAN offload 导致的内核解栈差异这些属于进阶话题但至少要有个意识收到帧的头部不一定就是 14 字节。5.4 丢包与截断先看内核计数别急着怀疑代码L2 套接字最头疼的问题是丢包无感知。应用层面recvfrom没有数据时并不代表网络上没有帧很可能是内核的 socket 接收队列满了以后直接丢弃。排查顺序我建议是这样先用ip -s link或ethtool -S eth0 | grep rx_dropped看网卡侧的丢包计数。然后调大SO_RCVBUF同时确认系统上限sysctl net.core.rmem_max如果要支持更大的接收缓冲区SO_RCVBUF不能超过这个上限。还有一类问题是帧被截断如果你的缓冲区小于物理帧长recvfrom会返回缓冲区大小的数据后面的字节被吃掉程序不会报任何错误。所以给抓帧缓冲区分配的空间至少要大于当前接口的 MTU 14 字节以太网头如果考虑 Jumbo Frame直接给 9216 甚至更大。最后共享的经验是应用层和 tcpdump 同时挂起来对比如果 tcpdump 能收到而你的程序收不到通常不是 tcpdump 更厉害而是你的 socket 创建参数、混杂模式或权限某处不对。把这三个方向查一遍大概率能省下一半的排查时间。个人实践中我还有个习惯在 handler 里统计bytes_transferred sizeof(buffer)的次数。如果频繁出现解释只有一个——你的缓冲定小了。L2 套接字不像 TCP 有流控内核投递给你的是原始帧稍有不慎就会在你根本不知道它被截断的情况下继续工作。把这个统计写进日志比事后翻代码容易得多。