☰
从零手写MAC与IP:以太网帧、IPv4头部与ARP实战解析
2026/10/19 7:50:19 网站建设 项目流程

我最早想写“从0开始写MAC IP”这个题目,是因为一个很朴素的疑问:平时用现成的网络库发一个UDP包,一条API就能跑通,但数据从应用程序进入网卡的那段路到底是什么样的?尤其“MAC”和“IP”这两个词,一个贴在网卡上,一个在网络层上,它们之间的绑定关系又是谁在做?与其看文档,我决定直接自己动手,把链路层的MAC地址、网络层的IP地址当成普通的字节流来构造、解析、交换。这个项目做完后,我对封包、校验和、ARP缓存的理解,比之前看多少篇文章都管用。这篇文章就是完整记录。

我理解中的“写MAC IP”,不是去路由器里配置静态地址,而是实现一个极简的数据面:能够从零构造一个以太网帧、往里头塞进IPv4头部和字段,还能用ARP完成IP到MAC的映射。适合的读者是有一定编程基础、想搞懂TCP/IP协议栈底层逻辑、或者需要自研报文构造工具的人。我不会写完整的TCP状态机,也不会涉及路由协议,但会把L2/L3这条主干线打通。

1. 项目目标拆解:这个“从0写”到底要写哪几层

1.1 先界定范围:MAC、IP分别管到哪一层

很多人一说“写MAC IP”,下意识以为是网络设备上的“MAC绑定”,其实在协议栈语境里,这句话更准确的描述是:把以太网协议和IP协议的关键过程自己实现一遍。以太网协议关心的是MAC地址和帧结构,它属于数据链路层;IP协议关心的是地址和路由,它属于网络层。两者经常被放在一起讨论,是因为一个IP包最后必须封装进一个以太网帧里才能真正被发送出去。

我在项目里把范围收敛成三块:以太网MAC帧的构造与解析、IPv4头部的构造与校验和计算、ARP请求与应答以及MAC-IP缓存表。这三块连在一起,就已经能够支撑一个最基本的网络通信场景:我的程序知道目标IP,但不知道目标MAC,于是通过ARP查到MAC,再把IP包封装进帧里发出去。

至于接收方向,我也实现了对称的解析逻辑:从网卡读入裸字节,逐层剥离MAC和IP头部,最后把负载交给上层。这个“收”的路径同样关键,只发不接,很容易写出各种自洽但实际上错的封包。

1.2 开工前必须想明白的三件事

  • 封装顺序:从应用层往下走,IP包先被“装进”以太网帧里,帧头在最外面。反过来接收时,先去掉帧头得到IP包,再去掉IP头拿到负载。这个顺序直接决定了我所有结构体的摆放方式。
  • 网络字节序:MAC、IP、长度、校验和这些字段,在网络上统一用大端序传输。也就是说,我在内存里看到的多字节整数,发送前需要转成大端,接收后要转回主机序。这个看起来很简单,实际调试时坑非常深。
  • 头部长度不是“随便定义”的:以太网帧头固定14字节,IPv4头部通常是20字节,ARP报文固定28字节。这三个数字是所有偏移量计算的基石。

1.3 验收标准:什么才算“写成功”

我为项目定了三条验收标准。第一,在本地能够用原始套接字发出一个真实以太网帧,并且用抓包工具能看到完整的MAC地址、IP地址和协议字段。第二,对端能正常收到IP包并给出回应,回应信息能回到主机。第三,ARP缓存表能够正确完成“IP查MAC”的映射,并且在缓存未命中的时候自动发送ARP请求。

这三条做完,整个链路就是通的。至于性能、并发这类问题,不是这个阶段要考虑的事。

2. 先打地基:以太网帧与MAC地址的构造/解析

2.1 MAC地址:从“冒号字符串”到6字节

MAC地址在表达层通常是00:1A:2B:3C:4D:5E这种带冒号的字符串,真正在内存里就是6个连续的字节。我写的第一个工具函数就是把字符串转成字节数组。用C实现可以借助sscanf,每个字节都按两位十六进制读进来。

int mac_str_to_bytes(const char *str, uint8_t mac[6]) { unsigned int tmp[6]; if (sscanf(str, "%x:%x:%x:%x:%x:%x", &tmp[0], &tmp[1], &tmp[2], &tmp[3], &tmp[4], &tmp[5]) != 6) { return -1; } for (int i = 0; i < 6; i++) { mac[i] = (uint8_t)tmp[i]; } return 0; }

这里有个小陷阱:%x读进来的其实是整数,直接赋给uint8_t没问题,但如果用%hhx反而在部分编译器上会有严格的指针类型警告。另外,MAC地址不区分大小写,所以在解析前统一转小写可以省掉不少后续比较的麻烦。

2.2 定义以太网帧头部结构体

以太网II帧头部只有三部分:目标MAC占6字节,源MAC占6字节,然后是2字节的以太类型字段。在C语言里定义结构体时,我习惯用紧凑排列的方式,避免编译器自动对齐产生的填充字节害得封包长度不对。

#pragma pack(push, 1) typedef struct { uint8_t dst_mac[6]; uint8_t src_mac[6]; uint16_t ether_type; // 0x0800=IPv4, 0x0806=ARP } eth_hdr_t; #pragma pack(pop)

构造一个帧其实就像填空。目标MAC是我要发给谁,源MAC是我自己,以太类型告诉接收方“帧里面装的是什么”。我本地的测试源MAC用的是某个虚拟网卡的地址,目标MAC第一次发送时还不知道,就先填广播地址或全零占位,等ARP应答后再回填。

2.3 解析帧头的时候要做什么校验

接收解析比构造更需要小心。我从套接字里读进一坨bytes,第一步就是检查长度是否至少能撑起一个14字节的头部,第二步把前6字节当作目标MAC,看它是不是发给自己的,或者是不是广播帧。第三步判断以太类型,决定下一步是解析IPv4还是ARP。

int parse_eth_frame(const uint8_t *buf, size_t len, eth_hdr_t *hdr, const uint8_t *payload, size_t *payload_len) { if (len < sizeof(eth_hdr_t)) { return -1; // 帧长度不够 } memcpy(hdr, buf, sizeof(eth_hdr_t)); hdr->ether_type = ntohs(hdr->ether_type); *payload = buf + sizeof(eth_hdr_t); *payload_len = len - sizeof(eth_hdr_t); return 0; }

不要觉得这个解析逻辑简单就跳过。等我后面把ARP和IP层接上来之后,就发现一个帧长校验能挡住很多越界读问题。真实网卡上进来的包还可能带VLAN标签,那时14字节的头部就不成立了,需要额外判断。

2.4 以太类型对照表与最小帧长问题

我在代码里维护了一个小小的类型映射表:

以太类型含义
0x0800IPv4
0x0806ARP
0x86DDIPv6(本项目暂不涉及)
0x8100802.1Q VLAN 标签

另外要留意的是以太网的最小帧长。传统以太网要求帧(不含前导码和FCS)最少60字节,加上FCS才是64字节。如果应用层负载太小,发出去之前必须在后面补padding。这个问题我是到测试阶段才发现,因为偶尔会有抓包工具提示“runt frame”或者对端直接丢包。

3. 从零写出IPv4头部:字段含义与校验和算法

3.1 20字节头部布局,逐字段说清楚

以太网帧的负载区域装的就是IP包。IPv4头部标准长度是20字节,我的实现也先不考虑可选项,所以第一字节的“IHL”字段固定为5,意思是头部占5个32位字。

整个20字节的排布顺序如下:

  • 第1字节:高4位版本号,IPv4就是4;低4位头部长度IHL,等于5
  • 第2字节:服务类型TOS,本项目里我直接填0
  • 第3~4字节:整个IP包的总长度,包含头部和负载
  • 第5~6字节:标识符,分片重装时用,我这里简单递增
  • 第7~8字节:标志位和片偏移,如果不想分片,就设置DF标志位
  • 第9字节:TTL,我填64
  • 第10字节:上层协议号,1表示ICMP,6表示TCP,17表示UDP
  • 第11~12字节:头部校验和
  • 第13~16字节:源IP地址
  • 第17~20字节:目标IP地址

构造的时候,我用一个结构体把这些字段全部装进去,并且在所有多字节字段上加上htons,保证发送到线上的字节序是正确的大端序。

3.2 校验和算法:为什么是“补码求和”

校验和是IP头最容易写错的地方,但它本身的原理不难。发送方把20字节头部按16位为一组拆成10个整数,全部相加,每出现一次16位以上的进位就把进位回卷加到低位去,最后得到的结果取反,就是校验和。

很多人第一次写都会漏掉“进位回卷”这一步,结果校验和算出来的值就差一点点。我建议把求和和回卷拆成同一个循环里做,每一步都回卷。

uint16_t checksum(const uint16_t *data, size_t words) { uint32_t sum = 0; for (size_t i = 0; i < words; i++) { sum += data[i]; // 每步一次回卷,避免32位溢出积累误差 sum = (sum & 0xffff) + (sum >> 16); } return (uint16_t)(~sum); }

这个算法妙在接收方的校验:接收端同样把所有16位字包括校验和字段通通加起来,最终得到的结果如果是0xFFFF,就说明头部在传输过程中没有被改动。用这个判据做收包校验,能有效筛掉被破坏的包。

3.3 用一个具体例子走一遍计算

假设我有这样一个IP头的前几个字段:版本/头部长度4和5拼成第一字节0x45,总长度填0x003C,标识符填0x0001,标志和片偏移填0x0000,TTL填0x40,协议填0x01,源IP是172.17.0.1,目标IP是172.17.0.2。

在头部的校验和字段暂时填0时,10个16位字分别是:

0x4500 0x003C 0x0001 0x0000 0x4001 0x0000 0xAC11 0x0001 0xAC11 0x0002

这些数依次求和并回卷后,得到的sum是0x0893,取反就是0xF76C,也就是我的头部校验和。接收方把这10个字再加上0xF76C全部求和,最终会得到0xFFFF。这个例子我在测试时反复用过,如果某天你写出校验和和我的差一个字节,十有八九是进位没回卷。

3.4 IPv4头部的快捷实现

构造头部时,我在C语言里是直接操作一个字节缓冲区,因为结构体一旦涉及对齐,反而容易出问题。当然用结构体也可以,关键是所有多字节字段都要经htons。

uint8_t ip_hdr[20] = { 0x45, 0x00, (total_len >> 8) & 0xff, total_len & 0xff, (id >> 8) & 0xff, id & 0xff, 0x40, 0x00, 0x40, proto, 0x00, 0x00, src_ip[0], src_ip[1], src_ip[2], src_ip[3], dst_ip[0], dst_ip[1], dst_ip[2], dst_ip[3] }; uint16_t csum = checksum((uint16_t *)ip_hdr, 10); ip_hdr[10] = (csum >> 8) & 0xff; ip_hdr[11] = csum & 0xff;

注意校验和计算时要先把第11、12字节当作0。否则算出来的校验和把自身也算进去了,结果就是错的。

4. 从IP地址到MAC地址:实现ARP请求与应答

4.1 为什么要ARP:IP地址没法直接上以太网

以太网帧头里的目标MAC必须是一个真实的48位地址,但你写程序时通常只知道对方的IP,并不知道它的MAC。这个“只知道IP、不知道MAC”的尴尬,就是ARP要解决的问题。

ARP报文固定28字节,它会被封装在一个以太网帧里传输。字段依次是硬件类型(以太网填1)、协议类型(IPv4填0x0800)、硬件地址长度6、协议地址长度4、操作码(请求填1,应答填2),然后是发送方MAC、发送方IP、目标MAC、目标IP。

ARP请求的特殊之处在于,目标MAC字段填的是全零,整个帧的目标MAC填的是广播地址FF:FF:FF:FF:FF:FF。这样局域网里所有机器都会收到,但只有IP匹配的那台机器会回ARP应答。

4.2 缓存表到底该存什么东西

ARP不能每次发送都广播查询,那太浪费了。我实现了一张简单的缓存表,表的每一项至少包含:IP地址、MAC地址、最后更新时间。

我用了固定数组来模拟,最多放32条,超过就随机替换掉最久未命中的项。实际操作系统里的ARP缓存会精细很多,既有老化时间,也有状态机。但我在这个项目里只关注核心映射逻辑。

typedef struct { uint8_t ip[4]; uint8_t mac[6]; uint32_t last_seen; } arp_entry_t;

查询函数按IP去匹配,匹配上了就直接返回MAC;匹配不上就触发一次ARP请求。收到ARP应答或看到别人发来的ARP请求时,会同步更新缓存表——因为ARP请求里就包含了对方的MAC和IP,顺手学习一下是合法操作,省一次广播。

4.3 完整流程:查缓存、发请求、收应答

我写了这样一个发送函数:

  1. 先把目标IP转成4字节数组。
  2. 查ARP缓存表,若命中就直接取出MAC,跳到第4步。
  3. 缓存未命中,则构建ARP请求帧,通过原始套接字广播出去。这里需要注意,广播目标MAC是FF:FF:FF:FF:FF:FF,以太类型填0x0806。
  4. 拿到目标MAC后,构建完整的以太网帧,内部封装IPv4数据报,从套接字发送。

收到ARP应答时,核心逻辑只有三步:解析ARP包的操作码,如果是应答并且目标IP是我自己的IP,把发送方IP和MAC提取出来,更新进缓存表。这里的坑是ARP报文里的IP和MAC都是按字节存储的,不要做字节序转换,直接逐字节拷贝。

5. 拼起来跑通一次发包:链路验证与实测

5.1 一个IP包在代码里走过的路径

综合前面的模块,现在可以串出完整链路。我设定一个场景:程序要把一个UDP报文从本机发到192.168.1.2的某个端口。流程是:

  1. 构造UDP头和数据。
  2. 构造IPv4头部,源IP填我的地址,目标IP填192.168.1.2,协议号填17,计算校验和。
  3. 查ARP缓存,看192.168.1.2对应什么MAC。
  4. 如果没查到,发ARP请求,等应答。
  5. 拿到MAC后,把IP包作为负载,外面包一层以太网帧头。
  6. 用AF_PACKET原始套接字把帧发到指定网卡接口。
int fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); struct sockaddr_ll sll = {0}; sll.sll_family = AF_PACKET; sll.sll_ifindex = if_nametoindex("eth0"); sll.sll_halen = 6; memcpy(sll.sll_addr, dst_mac, 6); sendto(fd, frame, frame_len, 0, (struct sockaddr *)&sll, sizeof(sll));

如果只想快速验证而不碰物理网卡,可以在本机创建一个虚拟网桥或者tap设备,让程序往这个设备发帧,另一头用抓包工具观测。这个做法干净又安全,不需要担心影响局域网里的其他机器。

5.2 抓包工具里的对照验证

我习惯在发完包之后马上用tcpdump或Wireshark的实时抓包窗口看结果。目标很简单:帧头里的目标MAC、源MAC要和代码里填的一致,以太类型0x0800要解析成IPv4,IPv4头部的源IP、目标IP、总长度、校验和都要显示正确。

如果ARP流程正确触发,Wireshark里会先看到一条ARP广播,然后收到ARP应答,紧接着才是真正的UDP包。这个顺序本身就是一次很好的“全链路自我验证”。我第一次看到这个序列时,确实比用任何工具都要有成就感。

5.3 实测日志逐行解读

我把自己测试时的一小段包头十六进制拿出来做讲解:

0000 ff ff ff ff ff ff 02 42 ac 11 00 01 08 06 ...

这段是ARP请求的帧头。目标MAC是全FF,说明这是广播;源MAC是02:42:ac:11:00:01,看起来像容器网络常见的虚拟MAC;以太类型0x0806就是ARP。紧接着的ARP体里,操作码01表示请求,发送方IP是172.17.0.1,目标IP是要查询的地址,目标MAC为零。

等收到ARP应答后,后续的IP包帧头就会变成:

0000 02 42 ac 11 00 02 02 42 ac 11 00 01 08 00 45 00 ...

这里的区别是目标MAC已经换成了真实应答方的MAC,以太类型变成了0x0800。单看前14字节,就能确认整套流程已经在数据链路层和网络层都走通了。

6. 调试阶段绕不开的坑与排查思路

6.1 大小端导致ARP里的IP地址“反着显示”

这个坑我印象最深。第一次实现完成之后,用抓包工具看ARP报文,发现发送方的IP展示成了1.0.17.172,而代码里明明填的是172.17.0.1。原因很简单:192.168.1.2这样的点分十进制本质上是4个字节,而我还用htonl去处理它,等于把四个字节的顺序整个倒过来。

IP地址在协议里就是连续4个字节,发送时不要整体做多字节序转换,只要保证它像字符串一样原样发送。排查这类问题的最快办法,是把报文的十六进制后8个字节和预期IP的十六进制做对比,一眼就能看出是不是反了。

6.2 校验和总差0x0001,多半是进位没回卷

“差一点”的校验和错误,几乎都是同一个原因。有些实现是先不分步回卷,等全部加完了再统一回卷一次,这种写法在理论上是等价的,但前提是32位累加和没有溢出堆积。真正代码里如果中间结果溢出,最后结果就和标准流程不一样。我的建议是每一步都回卷,这样每一步的sum都保持在16位范围内,逻辑最稳。

另一个低级错误是算校验和时把校验和字段自身也当成0x0000参与了累加。构造头部时先清零校验和字段,算完再填进去。

6.3 帧太短会被填充,解析时不能按固定长度硬读

以太网帧有最小长度限制。如果应用层负载小,IP包总长度只有28字节,整个帧14+20+8也就是42字节,那发送时必须在末尾补到60字节以上。但补上的padding不参与IP层的总长度计数。因此解析IP包时永远以IP头里的“总长度”为准,而不是以帧长度减去头部为准。

这个坑的典型表现是:抓包工具能看到包,但应用层收到的数据里总是多出几个零字节,或者解析负载时越界。写接收逻辑时,先读IP总长度字段,再按这个长度截取负载,别拿帧长直接算。

6.4 权限与接口名:原始套接字不是默认就能开的

使用AF_PACKET家族套接字需要较高权限,普通用户会直接遇到权限错误。调试阶段省事的方式是在受控的测试环境里运行,或者使用虚拟接口绕开物理网卡。另外,发送帧时只要填对接口索引,不要自己指定设备的MAC,网卡驱动会按实际配置处理。

还有个小经验:每条网卡接口都有mtu,如果构造出的帧超过接口MTU,发送会被拒或者被分片。这个项目里我限制了测试包大小,没踩得太深,但如果继续把TCP接进来,就绕不开分片和重组,那是下一阶段的事。

6.5 调试工具链的固定组合

排查问题时的标准动作是“三段式”:先看错误码,再看抓包结果,最后对照预期十六进制字节。错误码会告诉你系统层面为什么拒绝,比如权限不够或接口不存在;抓包结果能立刻看到帧头有没有出来;预期十六进制字节则是最终裁决,只有字段全部对上,才能认为逻辑正确。

我的常用脚本会在发送前把所有待发字节打印成十六进制,发送后在抓包里再抓一次同序号的包,两份做diff。有时候代码看起来完全正确,但窗口一对比,就能发现少了四个字节,或者广播MAC被写到了源MAC的位置上。

收尾:几位调试之后的经验沉淀

整个项目做下来,我最深的一个体会是:网络协议栈再复杂,拆到具体字段也是一门“字节摆弄”的手艺。阅读标准、抓住封装顺序、重视字节序,这三件事做好,剩下就是细心的事。

最后分享一个我还在用的调试习惯:始终保留一个“上次正确版本”的抓包文件。改代码之后拿新包和旧包做二进制对比,任何一位比特变了都能立刻定位到哪一步引入的问题。数据的链路就是这样,地址也好、校验和也好,最后都得落在实实在在的0和1上,戳破迷雾的方法永远是亲眼看到那一刻的网络字节。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询