简介:面向 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 + LAN8720A
STM32F107 片内集成 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 和 RMII,PHY 地址由硬件引脚决定,常见配成 1。无论用哪颗,软件侧的 MediaInterface 和 PhyAddress 两个字段必须和 PCB 实际接法严格一致,这也是 CubeMX 生成工程后首先要核对的地方。
2.2 CubeMX 配工程:时钟、ETH 与中断的 5 个关键选项
用 CubeMX 生成 F107 工程,最容易绕圈的是 RCC 页面和 ETH 页面的联动。我按顺序列出要过的点:RCC 里先把 HSE 打开并填上板载晶振频率,本例按 25M 常用值;时钟树把 SYSCLK 拉到 72MHz,AHB/APB 分频由生成器自动算;ETH 外设勾上 RMII 模式,如果 PCB 用的 DP83848 接 MII 则改 MII;PHY 地址填硬件实际值;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 link,bit5 协商完成 | link 为 0 先查 PHY 地址和 RMII 时钟 |
| 0x02/0x03 | PHY 标识符 | 厂商 ID | 读出全 0 或全 F,MDIO 时序或地址错 |
| 0x04 | 自动协商通告 | 速率双工能力 | 对端只通告 10M,本地却固定 100M |
如果 0x01 读出 link=1 但 ping 不通,问题基本不在 PHY,而在参考时钟、MAC 地址配置或 LwIP 的 netif 网卡状态;link=0 则优先考虑 PHY 复位引脚被拉死、地址不对。
3. Modbus TCP 报文解析与从站状态机
3.1 MBAP 头和 PDU:大小端与长度字段
Modbus TCP 帧由 7 字节 MBAP 头和 PDU 组成。MBAP 的前 4 字节是事务标识符和协议标识符,协议标识符固定为 0x0000,非 0 的请求可以直接丢弃。长度字段占 2 字节,含义是“单元标识符 + PDU”的总字节数,不含长度字段自己,也不含前面的 4 字节。也就是说,一个合法请求的整帧长度是 6 + 长度字段的值。
| 字段 | 字节数 | 说明 | 常见错误 |
|---|---|---|---|
| Transaction Identifier | 2 | 请求回显给响应 | 回包时把 0x1234 写反成 0x3412 |
| Protocol Identifier | 2 | 固定 0x0000 | 不做校验,异常报文当正常处理 |
| Length | 2 | 单元标识符 + PDU 长度 | 忘了按大端写入 |
| Unit Identifier | 1 | 从站地址 | 响应里改成了别的地址 |
| 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) | 地址+数量越界返回 02 |
| 0x06 | 写单个寄存器 | 地址(2)+值(2) | 原样回显 | 地址越界返回 02 |
| 0x10 | 写多个寄存器 | 地址(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 API | CubeMX 生成的 LwIP | 不占 RTOS 内存,响应确定性强 | 回调里不能阻塞,要理解 TCP 流模型 |
| RTOS + netconn/socket | FreeRTOS + LwIP | 编程模型接近上位机,多任务直观 | RAM 占用高,任务栈和互斥锁都要设计 |
| 移植 FreeModbus TCP | FreeModbus + 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 创建 PCB,tcp_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 要留出余量。多数现场只有一台 SCADA,4 个就够。
4.3 lwipopts.h 里值得调整的 6 个参数
裸机 LwIP 的内存来自静态数组,这些参数就是全部家底。MEMP_NUM_TCP_PCB 控制并发连接数;TCP_MSS 决定单段最大载荷,默认 536 偏保守,以太网 MTU 1500 可以直接给 1460;TCP_WND 是接收窗口,2 个 MSS 就是 2920 字节。LWIP_NETCONN和LWIP_SOCKET在裸机下要关成 0,否则编译器不报错,但内存统计会多出一大块。
| 参数 | 建议值 | 说明 |
|---|---|---|
| MEMP_NUM_TCP_PCB | 4 | 同时建立的 TCP 连接数上限 |
| TCP_MSS | 1460 | 单段能携带的数据量,与 MTU 对应 |
| TCP_WND | 2920 | 2 倍 MSS,Modbus 小包场景完全够 |
| PBUF_POOL_SIZE | 16 | 每个收包占一个 pbuf,留给突发流量 |
| LWIP_NETCONN / LWIP_SOCKET | 0 | 裸机 raw API 下必须关闭的 API 层 |
| TCP_QUEUE_OOSEQ | 0 | 乱序队列,关掉可省几千字节 RAM |
TCP_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, data=b""): 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), timeout=3) 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 02,0x83 是功能码 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。
本文还有配套的精品资源,点击获取