数据链路层访问: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 需要 root 权限才能创建。我在项目中遇到过有人用普通用户跑抓包程序,结果socket返回-1,查了半天才发现是权限问题。

不过,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));
小技巧:if_nametoindex() 函数可以把接口名(如"eth0")转换成索引号。反过来,if_indextoname() 可以把索引转成名。这两个函数在 <net/if.h> 里声明。

两种模式的对比

特性 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() 转换网络字节序

数据包流向图

为了让你更直观地理解数据包从网卡到应用层的路径,我画了一张图:

数据包从网卡到应用层的路径 物理网卡(eth0) 原始以太网帧 数据链路层(PF_PACKET套接字) 根据协议类型分发 网络层(IP协议处理) 传输层分发 传输层(TCP/UDP处理) 注:PF_PACKET套接字在数据链路层截获数据包,此时IP/TCP头尚未被内核解析

你想想看,普通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套接字不要在多线程里同时读写,否则数据会乱。每个线程开一个独立的套接字更稳妥。
重要提醒:抓包程序在生产环境运行时,一定要考虑性能影响。我曾经在一个千兆链路上跑抓包程序,没做任何优化,结果CPU直接飙到100%,业务受到了影响。后来加了BPF过滤和环形缓冲区,CPU降到5%以下。

总结

数据链路层访问是网络编程中比较底层的一块内容。SOCK_PACKET是历史遗留接口,PF_PACKET是现代方案。写抓包程序时,记住三步走:创建套接字、绑定接口、循环读取解析。嗯,其实掌握了这些,你已经可以自己写一个mini版的tcpdump了。

如果你对BPF(Berkeley Packet Filter)感兴趣,那是另一个话题了。BPF可以在内核态做数据包过滤,避免把不相关的数据包复制到用户空间,性能会好很多。以后有机会再聊。


公众号:蓝海资料掘金营,微信deep3321