从零手写MAC与IP:以太网帧、IPv4头部与ARP实战解析 我最早想写“从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; // 0x0800IPv4, 0x0806ARP } 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 以太类型对照表与最小帧长问题我在代码里维护了一个小小的类型映射表以太类型含义0x0800IPv40x0806ARP0x86DDIPv6本项目暂不涉及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表示ICMP6表示TCP17表示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标志和片偏移填0x0000TTL填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 为什么要ARPIP地址没法直接上以太网以太网帧头里的目标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 完整流程查缓存、发请求、收应答我写了这样一个发送函数先把目标IP转成4字节数组。查ARP缓存表若命中就直接取出MAC跳到第4步。缓存未命中则构建ARP请求帧通过原始套接字广播出去。这里需要注意广播目标MAC是FF:FF:FF:FF:FF:FF以太类型填0x0806。拿到目标MAC后构建完整的以太网帧内部封装IPv4数据报从套接字发送。收到ARP应答时核心逻辑只有三步解析ARP包的操作码如果是应答并且目标IP是我自己的IP把发送方IP和MAC提取出来更新进缓存表。这里的坑是ARP报文里的IP和MAC都是按字节存储的不要做字节序转换直接逐字节拷贝。5. 拼起来跑通一次发包链路验证与实测5.1 一个IP包在代码里走过的路径综合前面的模块现在可以串出完整链路。我设定一个场景程序要把一个UDP报文从本机发到192.168.1.2的某个端口。流程是构造UDP头和数据。构造IPv4头部源IP填我的地址目标IP填192.168.1.2协议号填17计算校验和。查ARP缓存看192.168.1.2对应什么MAC。如果没查到发ARP请求等应答。拿到MAC后把IP包作为负载外面包一层以太网帧头。用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要解析成IPv4IPv4头部的源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字节整个帧14208也就是42字节那发送时必须在末尾补到60字节以上。但补上的padding不参与IP层的总长度计数。因此解析IP包时永远以IP头里的“总长度”为准而不是以帧长度减去头部为准。这个坑的典型表现是抓包工具能看到包但应用层收到的数据里总是多出几个零字节或者解析负载时越界。写接收逻辑时先读IP总长度字段再按这个长度截取负载别拿帧长直接算。6.4 权限与接口名原始套接字不是默认就能开的使用AF_PACKET家族套接字需要较高权限普通用户会直接遇到权限错误。调试阶段省事的方式是在受控的测试环境里运行或者使用虚拟接口绕开物理网卡。另外发送帧时只要填对接口索引不要自己指定设备的MAC网卡驱动会按实际配置处理。还有个小经验每条网卡接口都有mtu如果构造出的帧超过接口MTU发送会被拒或者被分片。这个项目里我限制了测试包大小没踩得太深但如果继续把TCP接进来就绕不开分片和重组那是下一阶段的事。6.5 调试工具链的固定组合排查问题时的标准动作是“三段式”先看错误码再看抓包结果最后对照预期十六进制字节。错误码会告诉你系统层面为什么拒绝比如权限不够或接口不存在抓包结果能立刻看到帧头有没有出来预期十六进制字节则是最终裁决只有字段全部对上才能认为逻辑正确。我的常用脚本会在发送前把所有待发字节打印成十六进制发送后在抓包里再抓一次同序号的包两份做diff。有时候代码看起来完全正确但窗口一对比就能发现少了四个字节或者广播MAC被写到了源MAC的位置上。收尾几位调试之后的经验沉淀整个项目做下来我最深的一个体会是网络协议栈再复杂拆到具体字段也是一门“字节摆弄”的手艺。阅读标准、抓住封装顺序、重视字节序这三件事做好剩下就是细心的事。最后分享一个我还在用的调试习惯始终保留一个“上次正确版本”的抓包文件。改代码之后拿新包和旧包做二进制对比任何一位比特变了都能立刻定位到哪一步引入的问题。数据的链路就是这样地址也好、校验和也好最后都得落在实实在在的0和1上戳破迷雾的方法永远是亲眼看到那一刻的网络字节。