简介:面向STM32嵌入式网络开发者的ARP协议示例工程,基于STM32与ENC28J60以太网控制器,解决嵌入式设备中IP地址与MAC地址映射的实际问题。压缩包共241个文件、约7.65MB,以C源码、头文件、Keil工程文件为主,同时包含编译生成的o/axf/lst及备份脚本,既能查阅源码,也可直接用于工程编译与烧录。目前已有238人学习。工程完整覆盖ENC28J60的SPI接口初始化、ARP缓存表管理、ARP请求构造与发送、应答解析及缓存老化更新等关键环节,并附带bootloader相关代码,适合嵌入式网络入门者理解底层协议实现,也便于开发者在此基础上扩展TCP/IP协议栈或迁移到其他STM32型号。
STM32 + ENC28J60 手动实现ARP协议:从寄存器到抓包全流程记录
做嵌入式网络项目,最常碰到的尴尬就是:程序烧进去了,PC端ping却一直超时,Wireshark抓包一看,ICMP请求发出去了,对面却一点应答都没有。这种问题十有八九出在最底层的ARP上。这次这个项目就是正儿八经和ARP干了一架:STM32通过SPI驱动ENC28J60以太网控制器,不依赖任何现成协议栈,从寄存器层面把ARP协议完整实现了一遍。最终的效果是,PC和STM32在同一个局域网内,PC ping STM32的IP能稳定通,Wireshark上能看到完整的ARP请求与应答交互过程。
这个项目看起来不大,但信息量很足。它适合三类人:一是刚接触嵌入式网络、想搞明白TCP/IP协议栈底层原理的;二是做单片机接入以太网但不想直接套lwIP这类重量级协议栈的;三是被“ping不通”折腾到头大、想彻底搞懂ARP机制来排查网络问题的。整个过程走下来,你对网络分层、MAC地址、IP地址、以太网帧格式这些概念,会从“好像知道”变成“真懂”。
1. 整体设计:为什么选ENC28J60并徒手实现ARP
1.1 为什么不用W5500和现成协议栈
先回答一个最直接的问题:市面上有的是W5500这种自带硬件TCP/IP协议栈的以太网控制器,STM32用SPI发几条命令就能完成TCP通信,干嘛还要碰ENC28J60这种“裸芯片”?
答案是,两者的定位完全不同。W5500把TCP/IP协议栈烧进了硬件,MCU只需要处理应用层逻辑,效率高、开发快,但代价是你永远看不到协议栈内部发生了什么。而ENC28J60本质上只集成了MAC和PHY,也就是链路层和物理层的硬件,网络层之上的所有协议,包括ARP、IP、ICMP、UDP、TCP,都需要你在MCU里用软件自己实现。正是这种“什么都没有”的状态,反而给了你把底层原理彻底搞明白的空间。
另一个考虑是通用性。ENC28J60用SPI接口通信,几乎所有单片机都能跑,成本也比W5500低一截,在中低速率场景下完全够用。实际项目中如果你只是采集传感器数据、控制继电器这类小流量应用,ENC28J60加一个简单的ARP/UDP实现,比挂一个完整协议栈轻量太多。用不用lwIP呢?lwIP确实功能全,但移植起来动辄几千行代码,对资源和精力都是考验。先徒手实现ARP,理解以太网帧的收发机制,之后再移植lwIP也会轻松得多——你会发现lwIP的以太网接口层,本质上就是你在裸机上写的那些东西。
最终决定方案是:STM32F103C8T6作为主控,ENC28J60完成以太网收发,SPI配置在10MHz,中断引脚接入EXTI,再通过串口输出调试日志。软件上按驱动层、协议层、应用层三层来写,其中ARP是整个协议栈的第一块基石。
1.2 硬件连接与SPI配置要点
硬件连接相当简单,ENC28J60模块一般直接买现成的,接7根线就能跑。我用的STM32F103C8T6蓝色板,引脚分配如下:
| STM32引脚 | ENC28J60引脚 | 说明 |
|---|---|---|
| PA5 | SCK | SPI时钟 |
| PA6 | MISO | 主机输入 |
| PA7 | MOSI | 主机输出 |
| PA4 | CS | 片选,低有效 |
| PB0 | INT | 中断输出,低电平有效 |
| NRST | RST | 复位,低有效 |
| 3.3V / GND | VCC / GND | 电源 |
特别注意,ENC28J60是纯3.3V器件,别图省事直接接5V,芯片会冒烟。如果主控是5V逻辑电平,SPI线上必须加电平转换或用电阻分压,STM32F103本身是3.3V,所以不存在这个问题。复位引脚我直接接到了STM32的普通GPIO上,这样既能上电自动复位,也能软件随时硬复位,排查问题时会很顺手。
SPI配置有几个容易被忽略的点。一是时钟极性CPOL和相位CPHA,ENC28J60要求CPOL=1、CPHA=1,也就是空闲时时钟为高、第二个边沿采样,搞反了读出来全是0xFF或者随机数。二是SPI速度,芯片标称支持20MHz,但实测10MHz最稳妥,尤其是你用了杜邦线飞线连接时,线间电容和串扰会让高速SPI的误码率直线上升。三是片选的时机,每次操作寄存器的整个读或写过程,CS都必须保持低电平,中途抬起来这次操作就无效了。
1.3 软件分层思想
如果你一上来就在main函数里堆寄存器操作,那代码写不到200行就会乱成一锅粥。我的做法是严格分三层:
- 驱动层(enc28j60.c/h):只负责SPI读写、寄存器操作、缓冲区读写、帧收发,不关心数据是什么协议。
- 协议层(arp.c/h):基于驱动层提供的帧收发接口,实现ARP报文的解析、构造、应答、缓存管理。
- 应用层(main.c):负责初始化、主循环调度、串口日志输出,以及后续扩展用的测试钩子。
这种分层的好处是,上层协议永远不需要关心“这个寄存器的第几位是什么意思”,驱动层也不关心“这包数据是个ARP还是IP包”。后续真要在这个基础上扩展UDP、ICMP,协议层只需要新增一个文件,完全不需要动驱动。值得强调的是,ARP虽然简单,但它极其依赖“能正常收发以太网帧”这个前提。所以我的开发顺序是:先写驱动,用一个简单的回环测试确认SPI和帧收发没问题,再动ARP逻辑,不要一上来就整个大流程,否则出了Bug根本定位不了。
2. ENC28J60驱动:寄存器和收发缓冲区的底层操作
2.1 初始化流程与关键寄存器
ENC28J60的上手步骤本身就是一个很好的学习材料。它的寄存器分三类:控制寄存器(CTRL)、以太网MAC寄存器(MAC)、PHY寄存器(PHY)。前三类直接通过SPI读写,PHY寄存器需要借助MII接口间接访问。初始化时我按这个顺序走:
- 硬件复位:拉低RST引脚至少5微秒,然后释放,等10毫秒。
- 软复位:置位ECON1寄存器的TXRST和RXRST,再清除。这一步能把芯片内部状态清干净,实测比单纯硬件复位更可靠。
- 等待时钟稳定:反复读ESTAT寄存器,等CLKRDY位置1。这一步很多人忽略,上电后晶体起振需要时间,太早写寄存器会失败。
- 配置收发缓冲区:ENC28J60内置8KB SRAM,我分配为6KB接收缓冲区(0x0000~0x17FF)、2KB发送缓冲区(0x1800~0x1FFF),通过ERXST、ERXND、ETXST、ETXND四个寄存器划定。
- 配置MAC层:MACON1打开接收使能(MARXEN=1),MACON3设置全双工模式、开启自动填充(PADCFG=0b01)和CRC自动追加(TXCRCEN=1)。自动填充这点很重要,后面讲ARP报文时细说。
- 配置PHY:通过PHYWRITE命令把PHCON2设为0x0000禁用心跳,PHLCON设为0x0476让两个LED分别指示链接状态和活动状态,方便调试时直接看灯。
- 写入MAC地址:通过MAADR1~MAADR5寄存器,把芯片自己的MAC地址设好。注意字节顺序,先写高字节。
- 清除所有中断标志,使能接收中断(PKTIE=1),打开接收使能(RXEN=1)。
我整理了一个常用寄存器的速查表,刚接触ENC28J60的同学可以存一下:
| 寄存器 | 地址 | 用途 | 最关键的位 |
|---|---|---|---|
| ESTAT | 0x1D | 芯片状态 | CLKRDY |
| ECON1 | 0x1F | 核心控制 | RXEN、TXRTS、TXRST、RXRST |
| EIR | 0x1C | 中断标志 | PKTIF、TXIF、TXERIF |
| ERXST/ERXND | 0x04/0x06 | 接收缓冲区起止 | 长度为偶数的地址 |
| ETXST/ETXND | 0x08/0x0A | 发送缓冲区起止 | 同上 |
| MACON3 | 0x12 | MAC模式 | FULDPX、PADCFG、TXCRCEN |
| MAADR1~5 | 0x00~0x04 | MAC地址 | 先写高字节 |
| ERXFCON | 0x18 | 接收过滤 | UCEN、CRCEN |
2.2 发送一帧数据的完整动作
发送以太网帧到ENC28J60,流程并不复杂,但细节决定成败。先看代码:
void enc28j60_send_packet(uint8_t *packet, uint16_t len) { uint8_t header[1] = {0x00}; // 发送描述符头的保留字节,必须为0 enc28j60_write_control_reg(ETXST, 0x1800); // 发送起始地址 enc28j60_write_control_reg(ETXND, 0x1800); // 先设起始,后面再更新结束 enc28j60_set_bank(0); // 切换到寄存器组0 enc28j60_spi_write_buffer(EVREGS + ETXSTL, ...); // 写入数据前先准备好缓冲区 // 把描述符头写入发送缓冲区 enc28j60_spi_write(0x1800, header, 1); // 把完整以太网帧写入发送缓冲区 enc28j60_spi_write(0x1801, packet, len); // 计算帧结束地址 uint16_t end_addr = 0x1800 + len; enc28j60_write_control_reg(ETXND, end_addr); // 发起发送 enc28j60_set_bit(ECON1, 0x08); // TXRTS置1 // 等待发送完成:轮询EIR的TXIF,或者等中断 while (!(enc28j60_read_control_reg(EIR) & 0x08)); enc28j60_clear_bit(EIR, 0x08); // 清中断标志 enc28j60_clear_bit(ECON1, 0x08); // 清TXRTS }这里有两个坑。第一,发送缓冲区的最起始地址处要写一个字节的保留描述符头,值必须是0x00,这个字节不计入帧长度,但写入数据时要跳过去。第二,帧结束地址一定算好,如果填少了,芯片只会发出去一半的帧;填多了,会把后面缓冲区里的垃圾数据也带上。很多“发出去的包PC端解析不了”的问题,都是这两个地方没搞对。
2.3 接收一帧数据的完整动作
接收比发送略复杂,因为ENC28J60的接收缓冲区是一个环形结构。芯片收到以太网帧后,先把数据写入缓冲区,同时更新一个接收状态向量(RSV),每个RSV占4字节:两字节的下一包指针、两字节的状态和长度信息。处理接收中断时,整个流程是:
void enc28j60_poll(void) { uint8_t irq = enc28j60_read_control_reg(EIR); if (irq & 0x40) { // PKTIF while (enc28j60_read_control_reg(EPKTCNT) > 0) { uint16_t rxrdpt = enc28j60_read_control_reg(ERXWRPT); uint8_t rsv[4]; enc28j60_spi_read(rxrdpt, rsv, 4); // 读接收状态向量 uint16_t len = rsv[2] | (rsv[3] << 8); // 帧长度(含以太网头) uint8_t *frame = (uint8_t *)malloc(len); enc28j60_spi_read(rxrdpt + 4, frame, len); enc28j60_write_control_reg(ERXWRPT, rsv[0] | (rsv[1] << 8)); // 移动到下一包 enc28j60_write_control_byte(EIR, 0x40); // 清PKTIF enc28j60_write_control_byte(ECON2, 0x40); // PKTDEC,包计数减1 // 把frame交给协议层处理 process_frame(frame, len); } } }中断服务函数里不要做太多事,我通常只设置一个标志位,然后在主循环里轮询处理。中断标志必须在整包处理完之后再清,提前清会导致芯片认为你处理完了、覆盖缓冲区,然后后面几包就乱了。EPKTCNT寄存器在接收中断时会自动加1,处理完一包后必须写PKTDEC位把它减掉,否则会重复处理同一包。
3. ARP协议实现:报文格式与处理逻辑
3.1 ARP报文逐字段拆解
以太网帧的EtherType字段为0x0806时,表示负载是ARP报文。ARP本身的结构非常规整,固定28字节:
| 偏移 | 字段 | 长度 | 说明 |
|---|---|---|---|
| 0 | 硬件类型 | 2字节 | 以太网为0x0001 |
| 2 | 协议类型 | 2字节 | IPv4为0x0800 |
| 4 | 硬件地址长度 | 1字节 | 以太网MAC为6 |
| 5 | 协议地址长度 | 1字节 | IPv4为4 |
| 6 | 操作码 | 2字节 | 1=请求,2=应答 |
| 8 | 发送方MAC | 6字节 | 发起方MAC |
| 14 | 发送方IP | 4字节 | 发起方IP |
| 18 | 目标MAC | 6字节 | 请求时为全0 |
| 24 | 目标IP | 4字节 | 要解析的IP |
这里有个新手很容易忽略的细节:整个以太网帧的最小长度是60字节(不含FCS)。ARP报文28字节加上以太网头14字节,一共42字节,不满足最小帧长。如果芯片没有开启自动填充,你发出去的帧会被交换机或PC当作残帧丢弃。所以前面初始化时MACON3的PADCFG=0b01就是干这个的,它让ENC28J60在发送时自动把帧填充到60字节。如果你不用这个功能,就必须自己在软件里填充18字节的0x00,否则ARP永远无法被对端识别。
3.2 ARP请求接收与应答构造
收到一帧数据,先判断EtherType是否为0x0806。是,就交给arp_process处理。处理逻辑其实非常简单——判断是不是请求,判断目标IP是不是本机IP,是的话构造应答报文发回去。
void arp_process(uint8_t *frame, uint16_t len) { arp_header_t *arp = (arp_header_t *)(frame + 14); // 跳过以太网头 if (arp->htype != htons(0x0001) || arp->ptype != htons(0x0800)) { return; // 只处理以太网+IPv4的ARP } if (ntohs(arp->opcode) != 1) { return; // 只处理请求 } if (memcmp(arp->tpa, my_ip, 4) != 0) { return; // 目标IP不是自己,忽略 } // 构造应答:操作码改为2,交换发送方和目标字段 arp->opcode = htons(2); memcpy(arp->tha, arp->sha, 6); // 目标MAC = 请求方MAC memcpy(arp->tpa, arp->spa, 4); // 目标IP = 请求方IP memcpy(arp->sha, my_mac, 6); // 发送方MAC = 本机MAC memcpy(arp->spa, my_ip, 4); // 发送方IP = 本机IP // 同时更新以太网头的目标MAC为请求方MAC memcpy(frame, arp->tha, 6); memcpy(frame + 6, my_mac, 6); // 发送应答帧,注意是帧的完整长度42字节 enc28j60_send_packet(frame, 42); }很多人在这一步卡住。最典型的问题有两个:一是忘了改以太网头的目标MAC,应答帧还是发到广播地址去了,PC收到后发现不是发给自己的,直接丢弃;二是用了结构体打包后,在STM32上出现了字节对齐问题,报文错位导致对端解析失败。我的建议是:结构体定义后面加__attribute__((packed)),或者干脆用一个固定的uint8_t数组手动拼报文。
3.3 ARP缓存与主动解析
严格来说,一个设备可以被动响应ARP请求就够用了,PC能ping通就说明ARP应答没问题。但作为一个完整的协议实现,最好还是加上主动解析能力。所谓主动解析,就是当STM32要往某个IP发包、但不知道对方MAC地址时,自己先广播一个ARP请求,然后等待应答。这需要维护一张ARP缓存表:
typedef struct { uint8_t ip[4]; uint8_t mac[6]; uint32_t timestamp; // 记录插入时间 uint8_t valid; } arp_cache_entry_t; arp_cache_entry_t arp_cache[8];缓存表最多放8条,满了就覆盖最旧的那条;每次解析到IP和MAC的对应关系就更新;每次使用缓存前检查时间戳,超过120秒视为失效,需要重新发起ARP请求。这个机制很轻量,但它会让你真正理解,PC在ping一个陌生设备之前,为什么要先来一发ARP广播。
主动发送ARP请求的流程也不复杂:构造一个操作码为1的ARP报文,目标MAC填全0,以太网头的目标MAC填广播地址FF:FF:FF:FF:FF:FF,然后发给驱动层发出去。发出后轮询等待应答,超时时间设在200~500毫秒,超时重发。项目里我在串口调试菜单里加了一条测试指令,手动触发对某IP的ARP解析,方便观察整个交互过程。
4. 联调验证:Wireshark抓包确认协议正确性
4.1 测试环境的搭建
到了联调阶段,我建议先把环境搭干净,别一上来就接交换机、路由器,这样报文会复杂很多。最简单的拓扑是:PC的网口直接连STM32开发板,两边都配置静态IP。我的环境是:
- PC以太网卡IP:192.168.1.10,子网掩码255.255.255.0
- ENC28J60的MAC:02:00:00:12:34:56(注意第一个字节的低位为0,是单播地址)
- STM32的IP:192.168.1.20
PC端打开Wireshark抓包,过滤条件直接写arp or icmp。STM32端通过串口打印调试日志,每收到一个ARP请求、每发送一个ARP应答,都打印一条记录。两边对着看,问题会暴露得更快。
4.2 抓包过程与分析
在PC的命令行里执行ping 192.168.1.20 -t,正常情况下,Wireshark里首先会看到一条ARP请求:谁是192.168.1.20?请告诉192.168.1.10。紧接着就是一条ARP应答:192.168.1.20在02:00:00:12:34:56。之后再出现的才是ICMP的echo request和echo reply。
展开ARP请求报文,逐字段看:硬件类型0x0001、协议类型0x0800、硬件地址长度6、协议地址长度4、操作码1、发送方MAC为PC的MAC、发送方IP为192.168.1.10、目标MAC为全0、目标IP为192.168.1.20。这跟你代码里构造报文的逻辑一模一样。
再看ARP应答:操作码变为2,发送方MAC变成02:00:00:12:34:56,发送方IP变成192.168.1.20,目标MAC变成PC的MAC。关键点在于,应答帧的以太网头目标地址是PC的单播MAC,而不是广播地址。这一点抓包软件里一眼就能看出来——广播帧在Wireshark里目标MAC显示为Broadcast,单播帧显示为具体的MAC。
我这里踩过一个印象深刻的坑:第一次写完程序,ping死活不通,Wireshark显示ARP请求发出了,但始终没有应答。排查到最后发现是MAC地址初始化顺序错了,MAADR寄存器要求按高字节到低字节的顺序写入,我用结构体memcpy一次性写进去,高低字节搞反,导致芯片认为自己MAC是全0,ARP应答包构造出来源MAC全0。PC收到一个源MAC全0的ARP应答,直接静默丢弃。后来我在应答处理函数里加了校验,凡是本机MAC全0就直接报错,这种低级错误就再没犯过。
4.3 从ARP到ICMP的完整链路
当ARP应答成功之后,PC的ARP缓存里就有了192.168.1.20对应的MAC,ICMP echo request会直接发到STM32的MAC地址上。这个阶段如果你还没实现ICMP协议,STM32收到0x0800类型的IP帧,应该打印一条“Unknown EtherType”这样的日志,但你至少能在抓包里看到ICMP请求确实到了。这就是下一步做IP协议解析和ICMP应答的起点。
验证ARP本身的效果,最好的指标是:PC第一次ping时,会先出现ARP交互,然后立刻出现ICMP交互。第二次ping时,由于ARP缓存还在有效期内,不会再出现ARP请求,直接就是ICMP。如果你能看到这个规律,说明ARP功能是完整且正确的。
5. 踩坑记录与排查技巧实录
5.1 典型问题速查表
把这段时间遇到的所有问题整理成一个速查表,按现象、可能原因、解决办法三列展示:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 读寄存器全是0xFF | SPI模式配置错误 | 检查CPOL=1、CPHA=1,时钟极性错误 |
| 读寄存器全是0x00 | CS片选时序不对 | 检查CS拉低时机,确保整个操作期间保持低 |
| 初始化卡在CLKRDY | 供电不足或晶振未起振 | 检查3.3V电源,确认晶振焊接,复位后再试 |
| 能广播但PC收不到应答 | 应答帧目标MAC没改 | 确认应答帧以太网头的目标MAC是请求方MAC |
| ARP应答发出但PC不认 | MAC地址字节序错误 | 确认MAADR寄存器高低字节顺序 |
| 偶发性ping不通 | SPI速率过高或杜邦线太长 | 降到5~10MHz,缩短飞线距离 |
| PC报“请求超时”但抓包有响应 | 帧长不足60字节且未自动填充 | 开启MACON3的PADCFG自动填充 |
5.2 我遇到过的三个顽固Bug
第一个是SPI读取数据偶发错位。一开始以为芯片坏了,换了块板子还是一样。后来用示波器看MISO线上的波形,发现数据在时钟下降沿附近跳变,说明时序裕量不足。解决办法很简单,把SPI时钟从20MHz降到10MHz,问题消失。后来接线改短一点,20MHz也能跑,但为了稳定我保留了10MHz。
第二个是接收中断的嵌套问题。第一版代码在中断里直接处理ARP逻辑,结果PC一ping,程序跑着跑着就进HardFault。排查发现,因为中断里做了SPI操作,而主循环里也在做SPI操作,两者抢总线导致状态错乱。修订方案是中断只置标志位,主循环统一调度,几十行代码的事,但稳定性和之前完全是两个级别。
第三个坑最有意思。程序写好了,用USB转串口连PC观察日志,一切正常,但拔掉串口线后再上电,ping就不通了。查了一圈发现是芯片上电时序的问题:复位释放后芯片需要等待时钟稳定(CLKRDY)后才能正常初始化,但我的初始化代码在CLKRDY还没置位时就开始写寄存器,这些写入全部无效。因为之前连着串口线调试,串口初始化消耗了时间,碰巧掩盖了问题。后来在初始化最前面加了一个等待CLKRDY的循环,配合硬件复位引脚延时释放,问题彻底解决。这个排查过程让我深刻体会到,时序问题是最阴险的Bug——它在你的调试条件下不出现,换一个环境就冒出来。
6. 后续还能怎么扩展
写完ARP之后,这个项目自然而然就有了一条清晰的扩展路径。建议按这个顺序往下走:
- 实现ICMP echo应答。这样PC就能ping通完整的IP层了,而且ICMP的报文格式比IP简单,正好作为IP协议处理的练手。
- 实现UDP收发。UDP不需要连接管理,在ARP和IP之上加一个端口号的概念就能跑起来,适合做简单的数据上报。
- 移植lwIP。等你徒手实现了ARP和UDP之后,再去看lwIP的源码,很多抽象概念都能落到具体代码上,移植效率会高很多。
从我个人经验来说,这个项目最大的收获不在于“我实现了ARP”,而在于彻底打通了从物理层到网络层的整个认知链路。以前看网络教材里的封包、解包、寻址,总觉得像隔着一层雾,现在每次看到Wireshark里的那一条条报文,脑子里能清晰浮现出代码在哪个寄存器、哪个函数里做了哪些操作。如果你也想真正理解网络协议,强烈建议找一个不上量的接口,从ARP开始徒手实现一遍。
本文还有配套的精品资源,点击获取