STM32F107实现Modbus TCP从站:从PHY到LwIP的完整指南

📅 发布时间:2026/9/16 2:00:34
STM32F107实现Modbus TCP从站:从PHY到LwIP的完整指南
简介面向 STM32F107 开发者的 Modbus TCP 完整移植参考工程基于 ARM Cortex-M3 内核聚焦工业以太网通信场景解决工业现场设备与上位机之间远程实时数据交换的协议对接问题适合需掌握 STM32 以太网 MAC、TCP/IP 协议栈及 Modbus 报文解析的嵌入式工程师参考。工程压缩包约 8.59MB共 643 个文件包含 152 个头文件与 135 个 C 源文件同时保留 Keil MDK 完整工程配置与编译产物.uvprojx、.o、.hex、.map 等可直接打开工程查看源码结构、链接脚本与编译输出流程。已有 765 人浏览学习内容覆盖 RS232/485/CAN 转 TCP 桥接、客户端与服务端双向通信等典型应用结合 STM32CubeMX 外设初始化思路、PHY 芯片连接方式、Modbus 功能码定义与报文解析逻辑并涉及中断处理、低功耗、网络安全等工程化细节形成了从驱动层到应用层的完整参考闭环。对计划在 STM32F107 上落地 Modbus TCP 通信、需要一套可靠起点代码的工程师尤其实用可显著减少协议移植和联调阶段的重复劳动与排错时间。1. STM32F107 上做 Modbus TCP 从站先看这三层STM32F107 是 Cortex-M3 家族里少有的片上自带以太网 MAC 的型号用它做 Modbus TCP 从站可以省掉 W5500 这类外部网口芯片BOM 成本降一大截。但网上这类“例子”大多只跑到能 ping 通离能接到组态软件、SCADA 上稳定跑还有一段路。真正能落地的从站程序要按三层拆开看PHY 决定网线插上后链路能不能起来LwIP 决定 TCP 连接在断线重连时稳不稳Modbus 报文解析决定上位机发来的 03/06/16 功能码有没有正确应答、异常码有没有按协议返回。下面按这三个层次给出可复现的最小代码、关键参数和对应的排错思路适合正在调 F107 工业以太网板或打算把 Modbus TCP 并进自己协议栈的工程师。2. STM32F107 以太网口初始化RMII PHY 与 LwIP 底层的衔接2.1 F107 的 ETH 外设特性与选型为什么推荐 RMII LAN8720ASTM32F107 片内集成 10/100M 以太网 MAC 与专用 DMA支持 MII 和 RMII 两种对外接口。MII 需要 16 根信号线RMII 只使用 7 根TXD0/TXD1、TX_EN、RXD0/RXD1、CRS_DV、REF_CLK再加上 MDIO/MDC 两根管理线对 LQFP100 这种引脚不算宽裕的封装来说优势明显。引脚省出来还能同时铺开串口、CAN 和 I2C所以新设计一般直接选 RMII。对应的 PHY 芯片LAN8720A 是最常见的搭档功耗低、外围只需一颗 25M 晶振缺点是仅支持 RMII选型时别把 MII 通道留成空位。DP83848 则同时支持 MII 和 RMIIPHY 地址由硬件引脚决定常见配成 1。无论用哪颗软件侧的 MediaInterface 和 PhyAddress 两个字段必须和 PCB 实际接法严格一致这也是 CubeMX 生成工程后首先要核对的地方。2.2 CubeMX 配工程时钟、ETH 与中断的 5 个关键选项用 CubeMX 生成 F107 工程最容易绕圈的是 RCC 页面和 ETH 页面的联动。我按顺序列出要过的点RCC 里先把 HSE 打开并填上板载晶振频率本例按 25M 常用值时钟树把 SYSCLK 拉到 72MHzAHB/APB 分频由生成器自动算ETH 外设勾上 RMII 模式如果 PCB 用的 DP83848 接 MII 则改 MIIPHY 地址填硬件实际值ETH 的两个 DMA 中断打开。最后在 middleware 里加 LwIP并关闭 DHCP 使用静态 IP避免每次上电都等请求 IP。上述 5 个选项任何一个对不上表现都是“初始化不报错但 ping 不同或者收不到包”。所以调板子前先把原理图打开把晶振频率、PHY 接线、PHY 地址三样提前写在注释里比烧录后拿示波器猜快得多。2.2.1 RMII 的 50MHz 参考时钟来源必须和 PCB 对上这是“灯亮 ping 不通”的最常见原因。RMII 需要 50MHz 参考时钟在 F107 系统里可以来自外部有源晶振、PHY 自己的时钟输出也可以由 MCU 内部 PLL 从 HSE 转换产生。CubeMX 的 ETH 配置页会依据 PHY 类型给出 RMII 参考时钟来源选项这个选项必须和 PCB 实际接法一致。LAN8720A 的典型接法是 25M 晶振接在 PHY 的 XI/XO 上由 PHY 内部倍频成 50M 输出给 MCU 的 PA1如果板子把有源 50M 直接送到 PA1就必须选外部时钟来源。方向选反时PHY 状态寄存器读出 link 位正常但 RX 时钟对不上LwIP 收不到任何包。注意RMII 参考时钟方向出错的典型现象是 PHY 协商正常、ARP 请求发不出去。用示波器量 PA1 有没有干净的 50MHz 时钟是排障第一动作。2.2.2 启用 ETH DMA 中断同时避开 I2C 的优先级坑ETH 的 DMA 工作在 AHB 主设备上帧到达后会按描述符链把数据写进内存再触发中断。CubeMX 默认分配的 RX/TX 描述符各 4 个对 Modbus 这种小帧场景足够不需要改大。中断优先级给到最高档因为这是唯一的收包入口。F107 工程里十有八九会同时用 I2C 挂一片 EEPROM 保存 IP 和参数I2C 的 DMA 通道优先级要明显低于 ETH最好只在初始化阶段读一次不要在接收请求的中断回调里做 EEPROM 写操作。一个页面写入周期能把整条网络中断卡掉好几毫秒SCADA 端表现就是请求超时。2.3 从 HAL_ETH_Init 到网卡 ready 的最小代码CubeMX 生成的 HAL_ETH_Init 调用大致如下关键在于几个字段要和硬件对齐ETH_MACInitTypeDef mac_cfg {0}; volatile uint8_t eth_link_ok 0; void Eth_Init(void) { heth.Instance ETH; heth.Init.MediaInterface HAL_ETH_RMII_MODE; /* 与PHY接口一致 */ heth.Init.AutoNegotiation ETH_AUTONEGOTIATION_ENABLE; heth.Init.Speed ETH_SPEED_100M; /* 协商失败时的兜底 */ heth.Init.DuplexMode ETH_MODE_FULLDUPLEX; heth.Init.PhyAddress 0; /* LAN8720A常见为0 */ heth.Init.RxDesc eth_rx_desc; heth.Init.TxDesc eth_tx_desc; heth.Init.RxBuffLen 1520; HAL_ETH_Init(heth); HAL_ETH_Start(heth); }MediaInterface写成 MII 而 PHY 是 RMII 接法初始化不会报错但 link 永远起不来。RxBuffLen设 1520 是为了容纳完整以太网帧别自作主张改成 256 去省 RAM。AutoNegotiation打开后 PHY 自动协商速率和双工Speed与DuplexMode只在协商失败时作为兜底值生效。HAL_ETH_Start在初始化后必须立即调用缺失时 DMA 不搬运数据recv 回调永远不触发。2.4 链接状态检测与 PHY 寄存器调试LwIP 自己不知道网线是否插着需要软件定期读 PHY 的基本状态寄存器判断。IEEE 802.3 规定寄存器 0x01 的 bit2 是 link status对任何 PHY 芯片都适用。轮询间隔放到 500ms 即可不需要更密。void Eth_Link_Poll(void) { static uint32_t last 0; uint16_t bsr 0; if (HAL_GetTick() - last 500) return; last HAL_GetTick(); if (HAL_ETH_ReadPHYRegister(heth, 0, 0x01, bsr) ! HAL_OK) { eth_link_ok 0; return; } eth_link_ok (bsr 0x0004) ? 1 : 0; }HAL_ETH_ReadPHYRegister走 MDIO 接口返回的 bsr 可以直接判断链路。调试时更常用的是下面这张表PHY 前 6 个寄存器是 IEEE 标准寄存器不同型号通用寄存器地址名称关键位常见故障0x00控制bit15 自复位bit13 速率写启动位后立即被清 0说明 MDIO 没通0x01状态bit2 linkbit5 协商完成link 为 0 先查 PHY 地址和 RMII 时钟0x02/0x03PHY 标识符厂商 ID读出全 0 或全 FMDIO 时序或地址错0x04自动协商通告速率双工能力对端只通告 10M本地却固定 100M如果 0x01 读出 link1 但 ping 不通问题基本不在 PHY而在参考时钟、MAC 地址配置或 LwIP 的 netif 网卡状态link0 则优先考虑 PHY 复位引脚被拉死、地址不对。3. Modbus TCP 报文解析与从站状态机3.1 MBAP 头和 PDU大小端与长度字段Modbus TCP 帧由 7 字节 MBAP 头和 PDU 组成。MBAP 的前 4 字节是事务标识符和协议标识符协议标识符固定为 0x0000非 0 的请求可以直接丢弃。长度字段占 2 字节含义是“单元标识符 PDU”的总字节数不含长度字段自己也不含前面的 4 字节。也就是说一个合法请求的整帧长度是 6 长度字段的值。字段字节数说明常见错误Transaction Identifier2请求回显给响应回包时把 0x1234 写反成 0x3412Protocol Identifier2固定 0x0000不做校验异常报文当正常处理Length2单元标识符 PDU 长度忘了按大端写入Unit Identifier1从站地址响应里改成了别的地址PDU功能码数据N具体操作内容把 PDU 起始位从第 7 字节数错大小端是 STM32 上最先翻车的地方。Modbus 所有多字节字段都是网络字节序而 Cortex-M3 默认小端直接强转指针去读会拿反。正确做法是显式拼接static uint16_t get_be16(const uint8_t *p) { return (uint16_t)((p[0] 8) | p[1]); }现在这个函数在第 4 章构建响应时也会用到。TCP 层收上来的是一个连续字节流先拼成帧再解析不要边拼边解析多字节字段否则很容易把上一个请求的剩余字节混进当前帧。3.2 功能码与寄存器区间设计Modbus 从站至少要覆盖读保持寄存器、写单个寄存器、写多个寄存器三个功能码。有些上位机还会用 0x04 读输入寄存器对应关系见下表功能码名称请求内容响应内容边界检查0x03读保持寄存器起始地址(2)数量(2)字节数(1)寄存器值(N)地址数量越界返回 020x06写单个寄存器地址(2)值(2)原样回显地址越界返回 020x10写多个寄存器地址(2)数量(2)字节数(1)数据地址(2)数量(2)字节数与数量不匹配返回 03寄存器区间的规划直接影响从站代码的复杂度。我一般把 0x0000–0x001F 划给保持寄存器做配置参数0x0100–0x010F 划给输入寄存器做实时采集量中间留出空地址。地址越界时返回异常码 0x02而不是装作没收到。功能码不支持时返回 0x01注意异常响应里功能码的最高位要置 1请求是 0x03异常响应就是 0x83。3.3 从字节流到完整帧粘包与拆包的正确处理TCP 是流式协议没有天然的报文边界。上位机可能一次 send 塞进两个 Modbus 请求也可能一个请求被拆成三段到达。LwIP 的 recv 回调每次拿到的 pbuf 长度和分段完全不可控所以帧边界必须靠报文里的长度字段自己还原。常见错误是直接把一个 pbuf 当一帧处理这在网络空闲时碰巧能用一旦出现粘包整个连接就废了。我维护一个接收状态机逐字节喂入凑满一帧就返回帧长度static uint8_t rx_buf[256]; static uint16_t rx_len 0; static uint16_t need_len 0; uint16_t Modbus_Frame_Feed(uint8_t byte) { if (rx_len 6) { rx_buf[rx_len] byte; if (rx_len 6) { uint16_t mb_len (rx_buf[4] 8) | rx_buf[5]; need_len 6 mb_len; if (mb_len 2 || need_len sizeof(rx_buf)) { rx_len 0; /* 非法长度字段整帧丢弃 */ need_len 0; } } return 0; } rx_buf[rx_len] byte; if (rx_len need_len) { uint16_t done_len rx_len; /* 帧数据从 rx_buf[0] 开始 */ rx_len 0; need_len 0; return done_len; } return 0; }前 6 个字节是 MBAP 前缀凑满后从第 4、5 字节读出长度字段进而推出整帧长度。length 小于 2 或超出缓冲区时直接丢弃避免被异常报文带乱状态。返回 0 表示未凑够一帧返回其他值表示一帧完整数据已经在rx_buf里。多出的字节不会丢失下一轮循环会继续把它们当作新帧的前缀收集这个就是粘包处理的正确姿势。3.4 构造响应与异常码返回拿到完整帧后先回填事务标识符、协议标识符和单元标识符再按功能码分发。下面这段只写 0x03 和默认异常0x06、0x10 照同样写法扩展uint16_t Modbus_Build_Response(uint8_t *req, uint16_t req_len, uint8_t *resp) { uint16_t tid get_be16(req); uint16_t addr, count, i; uint16_t resp_len 0; resp[0] tid 8; /* 事务ID回显 */ resp[1] tid 0xFF; resp[2] 0x00; /* 协议ID固定为0 */ resp[3] 0x00; resp[6] req[6]; /* 单元ID回显 */ resp[7] req[7]; /* 功能码 */ switch (req[7]) { case 0x03: { /* 读保持寄存器 */ if (req_len 12) return 0; addr get_be16(req[8]); count get_be16(req[10]); if (count 1 || count 16 || addr count HOLD_REG_NUM) { resp[7] 0x83; resp[8] 0x02; resp_len 9; break; } resp[8] count * 2; for (i 0; i count; i) { resp[9 2*i] hold_regs[addr i] 8; resp[10 2*i] hold_regs[addr i] 0xFF; } resp_len 9 count * 2; break; } default: resp[7] 0x81; resp[8] 0x01; resp_len 9; /* 非法功能码 */ break; } resp[4] (resp_len - 6) 8; /* 回填length字段 */ resp[5] (resp_len - 6) 0xFF; return resp_len; }响应里的事务标识符、协议标识符、单元标识符必须原样回显上位机靠这个字段把响应和请求配对。异常响应统一是 9 字节MBAP 头 6 字节加单元 ID、置位功能码、异常码。长度字段是resp_len - 6因为长度字段描述的是单元 ID 之后的字节数不含六个字节的 MBAP 前缀。count 上限 16 是为了配合单帧 256 字节缓冲区Modbus 标准允许一次读 125 个寄存器缓冲区放大到 512 就可以提上来。4. 在 STM32F107 上写出最小可用的 Modbus TCP 从站例子4.1 裸机 raw API、RTOS socket、FreeModbus 三种方案怎么选做 STM32F107 的 Modbus TCP 从站网上方案大致分三类选型差异直接决定代码量和后期的可维护性方案依赖优点缺点裸机 LwIP raw APICubeMX 生成的 LwIP不占 RTOS 内存响应确定性强回调里不能阻塞要理解 TCP 流模型RTOS netconn/socketFreeRTOS LwIP编程模型接近上位机多任务直观RAM 占用高任务栈和互斥锁都要设计移植 FreeModbus TCPFreeModbus LwIP协议层与寄存器表分离代码成熟版本旧依赖 netconn裁剪费劲Modbus TCP 是典型的请求响应协议一个从站同时要处理的主机连接一般不超过 8 路裸机 raw API 完全够用还省掉任务栈和调度器带来的不确定性。FreeModbus 的成熟度虽然高但在 F107 上往往要手动缝合 CubeMX 生成的 LwIP 版本缝出来反而不好升级。这个例子的思路就是用裸机 raw API代码都在下面。4.2 基于 raw API 的 Server 初始化与 TCP 回调接线LwIP 的 raw API 不需要操作系统靠回调函数驱动。Modbus TCP 从站在 LwIP 里就是绑定 502 端口的一个 tcp_pcb。初始化流程tcp_new 创建 PCBtcp_bind 绑定端口tcp_listen 转成监听态最后注册 accept 回调。static struct tcp_pcb *mb_server_pcb; static err_t mb_accept_cb(void *arg, struct tcp_pcb *newpcb, err_t err) { tcp_recv(newpcb, mb_recv_cb); /* 注册每个连接自己的收包回调 */ tcp_err(newpcb, mb_err_cb); /* 连接异常时清理 */ tcp_nagle_disable(newpcb); /* 关掉Nagle小帧立即发出 */ return ERR_OK; } static err_t mb_recv_cb(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { uint8_t resp[256]; uint16_t resp_len 0; struct pbuf *q; if (p NULL) { tcp_close(pcb); /* 对端发来FIN关闭连接 */ return ERR_OK; } for (q p; q ! NULL; q q-next) { for (uint16_t i 0; i q-len; i) { uint16_t frame_len Modbus_Frame_Feed(((uint8_t *)q-payload)[i]); if (frame_len 0) { resp_len Modbus_Build_Response(rx_buf, frame_len, resp); if (resp_len 0) { tcp_write(pcb, resp, resp_len, TCP_WRITE_FLAG_COPY); tcp_output(pcb); } } } tcp_recved(pcb, q-len); /* 通知协议栈已取走数据 */ } pbuf_free(p); return ERR_OK; } void Modbus_TCP_Server_Init(void) { mb_server_pcb tcp_new(); if (mb_server_pcb NULL) return; tcp_bind(mb_server_pcb, IP_ADDR_ANY, 502); mb_server_pcb tcp_listen(mb_server_pcb); tcp_accept(mb_server_pcb, mb_accept_cb); }tcp_write的TCP_WRITE_FLAG_COPY标志表示 LwIP 会复制响应数据回调返回后局部缓冲区可以安全释放。不用这个标志数据要一直存活到协议栈发送完成在回调里直接用栈数组存响应就会出问题。tcp_recved声明协议栈可以从远端继续接收数据漏掉这个调用会导致对方的发送窗口被堵死典型现象是前几帧正常、后面越收越慢。提示如果板子要同时响应多台上位机把 MEMP_NUM_TCP_PCB 加到 8每多一个连接约多占 160 字节内存F107 的 64KB RAM 要留出余量。多数现场只有一台 SCADA4 个就够。4.3 lwipopts.h 里值得调整的 6 个参数裸机 LwIP 的内存来自静态数组这些参数就是全部家底。MEMP_NUM_TCP_PCB 控制并发连接数TCP_MSS 决定单段最大载荷默认 536 偏保守以太网 MTU 1500 可以直接给 1460TCP_WND 是接收窗口2 个 MSS 就是 2920 字节。LWIP_NETCONN和LWIP_SOCKET在裸机下要关成 0否则编译器不报错但内存统计会多出一大块。参数建议值说明MEMP_NUM_TCP_PCB4同时建立的 TCP 连接数上限TCP_MSS1460单段能携带的数据量与 MTU 对应TCP_WND29202 倍 MSSModbus 小包场景完全够PBUF_POOL_SIZE16每个收包占一个 pbuf留给突发流量LWIP_NETCONN / LWIP_SOCKET0裸机 raw API 下必须关闭的 API 层TCP_QUEUE_OOSEQ0乱序队列关掉可省几千字节 RAMTCP_QUEUE_OOSEQ 关掉的代价是极端乱序场景下吞吐下降但 Modbus 寄存器读写一帧才十几字节很少触发乱序重组省下的 RAM 更值。PBUF_POOL_SIZE调大后LwIP 的自动协商和 ARP 重试都不会因为缺 pbuf 丢包这个参数也是排查“偶尔丢一帧”时的第一个检查点。4.4 主循环里做什么轮询与内存回收代码骨架完成后主循环只需要做两件事int main(void) { HAL_Init(); SystemClock_Config(); Eth_Init(); MX_LWIP_Init(); Modbus_TCP_Server_Init(); while (1) { MX_LWIP_Process(); /* 驱动LwIP内部定时器 */ Eth_Link_Poll(); /* 更新链路状态 */ } }裸机下 LwIP 的 TCP 定时器由 MX_LWIP_Process 推进重传、延时应答都在这一步被触发主循环里绝不能少了它。Eth_Link_Poll 更新 eth_link_ok链路掉线时不要立刻关闭监听连接给对端一个重连缓冲时间。到这里一套裸机 Modbus TCP 从站的代码骨架就齐了。5. 用抓包和脚本来验证例子链路、异常码与半关闭5.1 Wireshark 过滤与检查项PC 网口和板子接在同一交换机Wireshark 过滤器填tcp.port 502。用 Modbus Poll 或 Python 发一次读保持寄存器请求抓到后看三处事务标识符回显是否一致长度字段是否等于单元 ID 加 PDU 的真实字节数异常响应时功能码是否带最高位。正常解析的帧在 Wireshark 里会显示为 Modbus/TCP 协议解析不出来先看 IP 层有没有分片。5.2 Python 脚本模拟读、写、异常三组请求import socket, struct def req(tid, unit, func, datab): pdu bytes([func]) data head struct.pack(HHHB, tid, 0x0000, len(pdu) 1, unit) return head pdu s socket.create_connection((192.168.10.20, 502), timeout3) s.send(req(1, 1, 0x03, struct.pack(HH, 0, 2))) # 读2个寄存器 print(read -, s.recv(300).hex( )) s.send(req(2, 1, 0x06, struct.pack(HH, 0, 0x1234))) # 写单个 print(write -, s.recv(300).hex( )) s.send(req(3, 1, 0x03, struct.pack(HH, 0xFFFF, 1))) # 地址越界 print(error -, s.recv(300).hex( )) s.close()正常输出里error 响应应是83 020x83 是功能码 0x03 置最高位后的异常响应0x02 是异常码。把三组请求改成都连续 send 再分别 recv可以验证粘包状态机是否按长度字段切出两帧。第二个请求收不到优先回查 3.4 节 length 字段的回填逻辑。5.3 检查连接复用与半关闭Modbus TCP 连接由设备主动关闭时抓包可见 FIN由上位机断开则是收到对端的 FIN 后tcp_close。反复快速重连 20 次后若出现 RST说明 PCB 连接数耗尽或 send buffer 里还有未发完的数据就被 close。接收方向的问题也有一个快速验证让脚本连续发 50 帧请求第 40 帧以后还能正常响应说明 tcp_recved 调用位置正确如果中途卡住重点检查 recv 回调里有没有漏调 tcp_recved 或 pbuf_free。本文还有配套的精品资源点击获取