数据链路层访问:SOCK_PACKET、PF_PACKET协议族、抓包程序基础
说实话,很多搞C网络编程的朋友,一开始接触的都是TCP、UDP这些上层协议。写个聊天室、做个HTTP服务器,感觉挺顺手。但有一天,你突然想自己写个抓包工具,或者想直接操作以太网帧——这时候你会发现,socket(AF_INET, SOCK_STREAM, 0) 根本不够用。
为什么?因为AF_INET协议族工作在IP层之上,你根本看不到底层的MAC地址、以太网类型这些信息。要访问数据链路层,我们需要另一套东西:SOCK_PACKET 和 PF_PACKET。
从SOCK_PACKET说起
SOCK_PACKET 是Linux早期提供的一种特殊套接字类型。它允许你直接从数据链路层收发数据包。我最早接触它是在十多年前,那时候想写一个ARP欺骗工具,查了半天资料才找到这个接口。
用法很简单:
int sock = socket(AF_INET, SOCK_PACKET, htons(ETH_P_ALL));
这里的关键点:
- 协议族用AF_INET,但类型用SOCK_PACKET
- 第三个参数指定要捕获的协议类型,ETH_P_ALL表示所有协议
- 返回的套接字可以直接read/write,读写的是完整的以太网帧
不过,SOCK_PACKET 有个明显的缺陷——它不绑定具体的网络接口。你收到的数据包可能来自eth0,也可能来自wlan0,你得自己通过 sockaddr_ll 结构体来判断来源。这在多网卡机器上会让人很头疼。
PF_PACKET:更现代的方案
从Linux 2.2开始,内核引入了PF_PACKET协议族。它比SOCK_PACKET更灵活,也更规范。我个人习惯在新项目里直接用PF_PACKET,老接口能不用就不用。
创建方式:
int sock = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
你看,协议族变成了PF_PACKET,类型用SOCK_RAW。这样创建的套接字可以捕获所有经过本机的数据包。
如果你想只抓某个特定网卡的数据包,可以绑定接口:
struct sockaddr_ll sll;
memset(&sll, 0, sizeof(sll));
sll.sll_family = AF_PACKET;
sll.sll_ifindex = if_nametoindex("eth0"); // 获取接口索引
sll.sll_protocol = htons(ETH_P_ALL);
bind(sock, (struct sockaddr *)&sll, sizeof(sll));
两种模式的对比
| 特性 | SOCK_PACKET | PF_PACKET |
|---|---|---|
| 协议族 | AF_INET | PF_PACKET |
| 套接字类型 | SOCK_PACKET | SOCK_RAW 或 SOCK_DGRAM |
| 接口绑定 | 不支持 | 支持(通过bind) |
| 数据包格式 | 完整以太网帧 | 完整帧(SOCK_RAW)或去掉链路头(SOCK_DGRAM) |
| 内核版本 | 所有版本 | Linux 2.2+ |
说白了,PF_PACKET 就是 SOCK_PACKET 的升级版。如果你用的是现代Linux系统(2.6以上),直接用PF_PACKET就对了。
抓包程序的核心逻辑
写一个简单的抓包程序,其实就三步:创建套接字、循环读取、解析数据。嗯,这里要注意,读取到的数据是完整的以太网帧,你需要自己解析各个协议头。
一个最小化的抓包循环:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <net/if.h>
#include <netinet/if_ether.h>
#include <netpacket/packet.h>
int main() {
int sock = socket(PF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
if (sock < 0) {
perror("socket");
return 1;
}
unsigned char buffer[65536];
struct sockaddr_ll src_addr;
socklen_t addr_len = sizeof(src_addr);
while (1) {
int len = recvfrom(sock, buffer, sizeof(buffer), 0,
(struct sockaddr *)&src_addr, &addr_len);
if (len < 0) {
perror("recvfrom");
break;
}
// 解析以太网帧头
struct ethhdr *eth = (struct ethhdr *)buffer;
printf("收到数据包,长度: %d\n", len);
printf("源MAC: %02x:%02x:%02x:%02x:%02x:%02x\n",
eth->h_source[0], eth->h_source[1],
eth->h_source[2], eth->h_source[3],
eth->h_source[4], eth->h_source[5]);
printf("目的MAC: %02x:%02x:%02x:%02x:%02x:%02x\n",
eth->h_dest[0], eth->h_dest[1],
eth->h_dest[2], eth->h_dest[3],
eth->h_dest[4], eth->h_dest[5]);
printf("以太网类型: 0x%04x\n", ntohs(eth->h_proto));
printf("---\n");
}
close(sock);
return 0;
}
这段代码跑起来,你会看到所有经过本机的数据包。我曾经用类似的代码帮同事排查一个网络问题——交换机上两个端口一直在丢包,用这个程序抓了几分钟,发现是某个设备的MAC地址在疯狂发送广播帧,把带宽占满了。
- PF_PACKET + SOCK_RAW 可以捕获完整的以太网帧
- recvfrom 返回的数据从以太网帧头开始
- ethhdr 结构体定义在 <netinet/if_ether.h> 中
- 记得用 ntohs() 转换网络字节序
数据包流向图
为了让你更直观地理解数据包从网卡到应用层的路径,我画了一张图:
你想想看,普通AF_INET套接字只能看到传输层以上的数据,而PF_PACKET套接字在数据链路层就把数据包截下来了。这就是为什么抓包工具能看到MAC地址、ARP请求这些底层信息。
避坑指南
我曾经在写一个网络监控工具时踩过几个坑,分享给你:
- 缓冲区大小:以太网帧最大1518字节,但加上VLAN标签可能到1522。我建议缓冲区至少设65536,免得丢包。
- 性能问题:PF_PACKET套接字会把所有数据包都复制到用户空间。在高流量环境下,CPU占用会很高。可以用setsockopt设置环形缓冲区(PACKET_RX_RING)来优化。
- 协议过滤:如果只关心特定协议(比如只抓HTTP),可以在socket的第三个参数指定协议类型,比如htons(ETH_P_IP)只抓IP包。
- 多线程安全:同一个PF_PACKET套接字不要在多线程里同时读写,否则数据会乱。每个线程开一个独立的套接字更稳妥。
总结
数据链路层访问是网络编程中比较底层的一块内容。SOCK_PACKET是历史遗留接口,PF_PACKET是现代方案。写抓包程序时,记住三步走:创建套接字、绑定接口、循环读取解析。嗯,其实掌握了这些,你已经可以自己写一个mini版的tcpdump了。
如果你对BPF(Berkeley Packet Filter)感兴趣,那是另一个话题了。BPF可以在内核态做数据包过滤,避免把不相关的数据包复制到用户空间,性能会好很多。以后有机会再聊。