STM32 + ENC28J60 实现 ModbusTCP:一个工程师手把手踩坑复盘的实战笔记
你有没有遇到过这样的场景?
客户指着PLC柜里那台老式温控仪说:“能不能把它连到我们的SCADA系统里,不用加网关?”
或者产线工程师拍着桌子问:“为什么每次换RS-485线就要重新调终端电阻?能不能直接插网线就通?”
又或者你刚画完一块基于STM32F103的传感器采集板,老板突然甩来一句:“下周要能用网页看数据,IP地址固定,ModbusTCP协议——别告诉我做不到。”
这时候,ENC28J60 + STM32 + 轻量TCP/IP栈就不是教科书里的选型方案,而是你焊台上正在冒烟的那块PCB的真实救星。它不炫技、不堆料、不依赖HAL库魔改,靠的是对寄存器时序的敬畏、对帧结构的抠字眼理解、以及一次又一次拔掉网线重试的耐心。
下面这些内容,不是从数据手册里复制粘贴的“标准答案”,而是一个人在实验室里熬了三个通宵、换了四块ENC28J60模块、重写五版SPI驱动后,把关键逻辑、致命陷阱和可复用代码揉碎了讲给你听的实战笔记。
为什么是 ENC28J60?而不是 W5500 或 DP83848?
先泼一盆冷水:ENC28J60 是个“难搞”的芯片——它没有内置TCP/IP协议栈,不支持DMA Ready信号,没有自动重传,甚至没有独立的接收中断引脚。但它有一个不可替代的优势:全寄存器可编程,无黑盒固件,每一个bit都由你掌控。
这意味着什么?
- 当你的ModbusTCP响应慢了2ms,你可以直接查EIR寄存器确认是不是PKTIF没清;
- 当收包总是错位,你能翻出ERXND和ERXRDPT的差值公式,亲手算出该减几个字节;
- 当链路莫名其妙断开,你可以每5秒发一次ARP请求,用ESTAT.LNKSTAT位实时观察PHY状态,而不是等W5500内部状态机自己“想通”。
它的8KB SRAM不是拿来炫参数的,而是让你亲手切分RX/TX缓冲区、手动维护读写指针、在内存紧张的STM32F103上榨出最后一点吞吐余量的战场。
✅核心参数速览(只列真正影响设计的)
| 参数 | 值 | 工程意义 |
|------|----|-----------|
| SPI最大速率 | 20MHz | 实际建议≤9MHz(STM32F103@72MHz下用8分频最稳) |
| 内置RAM | 8KB | 必须手动划分:典型6KB RX / 2KB TX,地址需16-bit对齐 |
| 中断引脚 | 单INT | 所有事件共用一个引脚,必须读EIR判源+及时清标 |
| PHY类型 | 10BASE-T | 不支持百兆,但省去外部变压器匹配难题(HR911105A即可) |
| 时钟容限 | ±50ppm | STM32必须用外部8MHz晶振,禁用HSI! |
STM32 驱动 ENC28J60:SPI不是接上线就能跑
很多初学者栽在第一步:SPI配置看似简单,实则暗藏三处“静默杀手”。
杀手一:SPI模式必须是 Mode 0(CPOL=0, CPHA=0)
ENC28J60 的数据在SCK上升沿采样,空闲时SCK为低电平。如果你在CubeMX里勾选了“Mode 3”或让HAL自动推导,通信会间歇性丢包——现象是:ping通但Modbus请求无响应,Wireshark里能看到SYN包发出,但ACK永远不来。
✅ 正确配置(标准库示例):
SPI_InitTypeDef SPI_InitStruct; SPI_InitStruct.SPI_Direction = SPI_Direction_2Lines_FullDuplex; SPI_InitStruct.SPI_Mode = SPI_Mode_Master; SPI_InitStruct.SPI_DataSize = SPI_DataSize_8b; SPI_InitStruct.SPI_CPOL = SPI_CPOL_Low; // 关键! SPI_InitStruct.SPI_CPHA = SPI_CPHA_1Edge; // 关键! SPI_InitStruct.SPI_NSS = SPI_NSS_Soft; SPI_InitStruct.SPI_BaudRatePrescaler = SPI_BaudRatePrescaler_8; // 72MHz/8 = 9MHz SPI_InitStruct.SPI_FirstBit = SPI_FirstBit_MSB; SPI_Init(SPI1, &SPI_InitStruct);杀手二:NSS(片选)必须软件控制,且拉低时间要够长
ENC28J60要求NSS在命令周期内保持稳定低电平。如果用硬件NSS(SPI_NSS_Hard),STM32的SPI外设可能在字节间短暂释放NSS,导致ENC28J60误判为多条指令。
✅ 推荐做法:GPIO模拟NSS,每次SPI传输前手动拉低,传输结束后延时1μs再拉高:
#define ENC28J60_CS_LOW() GPIO_ResetBits(GPIOA, GPIO_Pin_9) #define ENC28J60_CS_HIGH() GPIO_SetBits(GPIOA, GPIO_Pin_9) void enc28j60_spi_write(uint8_t data) { ENC28J60_CS_LOW(); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_TXE) == RESET); SPI_I2S_SendData(SPI1, data); while (SPI_I2S_GetFlagStatus(SPI1, SPI_I2S_FLAG_RXNE) == RESET); (void)SPI_I2S_ReceiveData(SPI1); // dummy read ENC28J60_CS_HIGH(); __NOP(); __NOP(); // 确保CS高电平维持≥100ns }杀手三:Bank切换不是可选项,而是生命线
ENC28J60有4个寄存器Bank(0~3),每个Bank包含不同功能寄存器(如Bank0有MAC寄存器,Bank3有RX/TX指针)。你读ERDPTL前,必须先调用enc28j60_bank_sel(BANK3),否则读到的是Bank0的某个无关寄存器。
✅ 封装成原子操作,杜绝遗漏:
void enc28j60_bank_sel(uint8_t bank) { uint8_t econ1 = enc28j60_read_reg(ECON1); econ1 &= ~ECON1_BSEL_MASK; econ1 |= (bank << 5); enc28j60_write_reg(ECON1, econ1); }接收中断里,到底该减几个字节?
这是全网教程最含糊、最容易出错的一环。我们来亲手拆解一帧以太网包:
[8字节前导码] [6字节DA] [6字节SA] [2字节Type] [IP包...] [TCP段...] [ModbusTCP PDU] [4字节FCS]ENC28J60的ERXND指向接收缓冲区最后一个字节之后的位置,ERXRDPT指向当前读取起始位置。
所以有效数据长度 =ERXND - ERXRDPT - 8
为什么减8?因为ENC28J60在接收时自动剥离了前导码(8字节)和FCS(4字节),但保留了DA/SA/Type这14字节——等等,14 ≠ 8?
真相是:ENC28J60的“自动剥离”仅针对物理层头尾,链路层帧头(14字节)仍完整存于RX缓冲区中。所以实际计算应为:
✅ 正确公式:len = (ERXND - ERXRDPT) - 14
(减去14字节以太网帧头:6DA+6SA+2Type)
但注意:ERXND和ERXRDPT是16位地址,且ENC28J60的RAM是循环缓冲区。当ERXND < ERXRDPT时,说明发生了跨页(wrap-around),此时真实长度 =(0x2000 - ERXRDPT) + ERXND(0x2000是8KB上限)。
✅ 安全实现:
uint16_t enc28j60_get_rx_len(void) { uint16_t rdpt = enc28j60_read_reg(ERXRDPTL) | ((uint16_t)enc28j60_read_reg(ERXRDPTH) << 8); uint16_t nd = enc28j60_read_reg(ERXNDL) | ((uint16_t)enc28j60_read_reg(ERXNDH) << 8); uint16_t len; if (nd >= rdpt) { len = nd - rdpt; } else { len = (0x2000 - rdpt) + nd; } return (len >= 14) ? (len - 14) : 0; // 减去14字节以太网帧头 }这个函数必须在中断里第一时间调用——晚一步,下一帧就可能覆盖上一帧未读取的数据。
ModbusTCP 报文构造:Length字段不是“整个包长度”
RFC 1006明确定义:MBAP头中的Length字段,表示后续PDU(Protocol Data Unit)的字节数,不包括MBAP头本身。
常见错误写法:
// ❌ 错误:把整个响应帧长度塞进去 tx_buf[4] = (7 + 1 + 1 + 2*reg_count) >> 8; // 7(MBAP)+1(FC)+1(ByteCnt)+2*N✅ 正确逻辑(以Read Holding Registers为例):
- PDU =[Function Code: 1 byte] + [Byte Count: 1 byte] + [Register Values: 2×N bytes]
- 所以 Length =1 + 1 + 2×N = 2 + 2×N
uint16_t pdu_len = 2 + (2 * reg_count); // PDU length only tx_buf[4] = pdu_len >> 8; tx_buf[5] = pdu_len & 0xFF;更隐蔽的坑:Unit ID 字段。很多教程直接写死tx_buf[6] = 0x01,但如果你的设备要接入大型SCADA系统,Unit ID必须与现场总线地址一致(例如PLC槽号),否则主站会忽略响应。这个值应该来自EEPROM配置或启动时拨码开关读取,而非硬编码。
真正让设备“活下来”的三件事
工业现场不关心你用了多酷的算法,只关心:断电重启后能否自动上线?网线被老鼠咬断后能否自愈?连续运行三个月会不会内存泄漏?
1. ENC28J60 链路状态必须主动探测
不要等LINKIF中断——它只在物理连接变化时触发,而网线松动、交换机端口震荡时可能毫无反应。
✅ 每5秒发一次ARP请求,检查ESTAT.LNKSTAT:
if (++arp_timer >= 50) { // 5秒(假设100ms tick) arp_timer = 0; if (!(enc28j60_read_reg(ESTAT) & ESTAT_LNKUP)) { // 物理链路已断,执行软复位 enc28j60_soft_reset(); enc28j60_init(); } else { arp_send_request(); // 主动探测网关连通性 } }2. TCP连接异常必须有限次重连
uIP栈的tcp_connect()失败后,不能无限重试阻塞主循环。
✅ 设置最大重试3次,每次间隔1秒,失败后进入低功耗等待:
if (tcp_conn_state == TCP_CONN_DISCONNECTED && retry_count < 3) { if (tcp_connect(&pcb, &ip_addr, 502, tcp_connected_callback) == ERR_OK) { retry_count = 0; } else { retry_count++; osTimerStart(reconnect_timer, 1000); // 1秒后重试 } }3. 寄存器数组必须做边界校验
Modbus主站可能发送非法地址(如0xFFFF),若不做检查直接访问holding_regs[0xFFFF],将触发HardFault。
✅ 在PDU解析阶段立即拦截:
if (start_addr + reg_count > HOLDING_REGS_COUNT) { // 返回异常响应:0x03 + 0x02 (Illegal Data Address) modbus_tcp_build_exception(tx_buf, trans_id, 0x03, 0x02); tcp_send(pcb, tx_buf, 12, 0); return; }最后一句大实话
这套方案不会让你登上IEEE期刊,也不会成为发布会PPT里的“行业首创”。但它能在一个闷热的配电房里,让一台没有屏幕、没有键盘的STM32小板子,稳稳地把温度、湿度、电流值,通过一根网线,送到千里之外的调度中心大屏上。
当你调试到凌晨两点,Wireshark里终于看到那个绿色的Modbus Read Response包,TCP标志位显示[ACK][PSH],而SCADA界面上的数字开始跳动——那一刻你会明白:所谓嵌入式开发的浪漫,就是用最朴素的寄存器、最老实的SPI时序、最较真的字节计算,把比特流变成看得见的生产力。
如果你也在用ENC28J60啃ModbusTCP这块硬骨头,欢迎在评论区留下你的“踩坑时刻”——比如SPI波形怎么调才不抖动,或者uIP的tcp_accept()回调为什么总进不去…… 我们一起把那些藏在示波器底下的真相,一帧一帧挖出来。