ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

STM32F107实现Modbus TCP从站:LWIP配置、PHY调试与性能优化

STM32F107实现Modbus TCP从站:LWIP配置、PHY调试与性能优化 简介一份基于STM32F107的Modbus TCP通信示例工程面向工业控制、物联网等领域需要以太网远程数据交换的嵌入式开发者重点解决Modbus TCP协议在STM32平台上的移植流程与实现细节。压缩包约8.59MB共643个文件以152个.h头文件和135个.c源文件为主体另含.o、.axf、.map等编译输出便于直接在Keil工程中查阅结构。工程内集成了RS232/RS485/CAN转TCP、TCP客户端/服务器、HTTPD、SD卡及LCD显示等典型模块完整演示了从网络栈配置、Modbus TCP库调用到中断处理、调试测试的迁移思路既有底层驱动也有应用层示例。已有765人学习适合正在STM32F107上进行Modbus TCP开发、需要参考完整代码结构与移植思路的嵌入式工程师。1. 用 STM32F107 跑 Modbus TCP先想清楚这三件事接手一个 STM32F107 项目甲方要求“把设备数据送给上位机”大多数工程师的第一反应是挂一个 Modbus RTU 串口但现场布线已经走的是工业以太网交换机到设备距离超过 100 米串口方案直接出局。这时候 Modbus TCP 几乎是唯一不需要改硬件的选择F107 自带以太网 MAC外接一个 PHY 芯片典型如 LAN8720A、DP83848跑 LWIP 协议栈就能把设备变成标准 Modbus TCP 从站。但在动手前必须认清三件事。第一Modbus TCP 并不是“Modbus RTU 换了个传输层”它去掉了 CRC 校验和从站地址把 MBAP 头塞进 TCP 负载里功能码和数据段基本原样保留。第二STM32F107 的以太网外设是 RMII 接口不是 SPI 转网口那种“软实现”所以你的实时性和吞吐量取决于 DMA 描述符和 LWIP 内存池配置而不是 CPU 频率。第三例程网上多如牛毛但真正能跑满 100Mbps 甚至稳定运行 72 小时的例子很少问题往往不出在 Modbus 代码而出在 LWIP 的 TCP 超时与重传参数、内存池大小、以及 PHY 芯片的复位时序。这篇文章从 F107 的以太网外设配置讲起落到一个可复现的 Modbus TCP 从站代码结构再讲调试时用 Wireshark 怎么确认问题点最后给出多连接和功能码扩展的工程化改法。适合已经跑通串口 Modbus 但没碰过以太网的嵌入式工程师也适合把 LWIP 当黑盒想快速交付的开发者。你会看到很多例程里没有写的细节为什么tcp_accept回调里必须置位TCP_WRITE_FLAG_MORE为什么PBUF_RAM类型对 Modbus 响应是陷阱以及TCP_MSS设成 1460 反而可能让响应变慢。2. STM32F107 以太网外设与 LWIP 协议栈选型2.1 F107 的 ETH 外设和 RMII 接口先清零三张表STM32F107 的以太网模块支持 10/100MbpsMII 和 RMII 两种接口。对于大多数工业设备RMII 是主流选择因为只需要 7 根信号线TX_EN、TXD0、TXD1、RXD0、RXD1、REF_CLK、MDIO/MDCMDC 可另算。MII 需要 16 根线而且要求 PHY 的 TX_CLK/RX_CLK 分别提供F107 的 MCO 引脚一般只有一个接法更麻烦。但省线是有代价的。RMII 要求 PHY 的 REF_CLK 必须是 50MHz而 F107 的系统时钟如果跑在 72MHz不能直接从 PLL 分出 50MHz只能把 MCO 引脚配置成 50MHz 输出给 PHY或者让 PHY 自己产生 50MHz 时钟回给 F107。两种方式各有坑时钟方案做法风险MCO 输出 50MHz配置 RCC_CFGR 的 MCO 预分频通常从 PLLCLK 2 分频得到需要等 PHY 上电稳定后再启动 ETH否则 PHY 可能锁不住时钟PHY 自生时钟LAN8720A 的 CLK_OUT 引脚输出 50MHz 给 F107 ETH_CK要求 F107 的 ETH_CK 输入时钟极性匹配且启动顺序必须先时钟后复位 PHY我一般选 MCO 输出因为 LAN8720A 的 CLK_OUT 默认可能被关闭少碰一个 PHY 寄存器就少一个不确定性。对应初始化代码大致如下void ETH_GPIO_Config(void) { GPIO_InitTypeDef GPIO_InitStructure; // 使能相关 GPIO 时钟注意 PF0/PF1 是 MCO 和 ETH_CK RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_GPIOA | RCC_AHB1Periph_GPIOB | RCC_AHB1Periph_GPIOC | RCC_AHB1Periph_GPIOF, ENABLE); // RMII 引脚PA1(MDC), PA2(MDIO), PA7(CRS_DV), PC4(RXD0), PC5(RXD1), PB11(TX_EN), PB12(TXD0), PB13(TXD1) GPIO_PinAFConfig(GPIOA, GPIO_PinSource1, GPIO_AF_ETH); GPIO_PinAFConfig(GPIOA, GPIO_PinSource2, GPIO_AF_ETH); GPIO_PinAFConfig(GPIOA, GPIO_PinSource7, GPIO_AF_ETH); GPIO_PinAFConfig(GPIOC, GPIO_PinSource4, GPIO_AF_ETH); GPIO_PinAFConfig(GPIOC, GPIO_PinSource5, GPIO_AF_ETH); GPIO_PinAFConfig(GPIOB, GPIO_PinSource11, GPIO_AF_ETH); GPIO_PinAFConfig(GPIOB, GPIO_PinSource12, GPIO_AF_ETH); GPIO_PinAFConfig(GPIOB, GPIO_PinSource13, GPIO_AF_ETH); // MCO 输出 50MHzPA8 GPIO_PinAFConfig(GPIOA, GPIO_PinSource8, GPIO_AF_MCO); // 注意PA8 复用为 MCO 后不能再被其他功能占用 }这里有一个容易翻车的地方F107 的以太网引脚不能随意挪到别的端口RMII 的映射在数据手册里是固定的。如果你用的是 100 脚封装部分引脚可能被其他外设复用比如 PC4/PC5 同时也是 ADC 输入如果你的板子把 PC4 接出去了就只能忍痛换 MII 或者换 MCU。代码之外三张关键配置表必须清零ETH_DeInit()会把所有以太网寄存器恢复到复位值但对 GPIO 没有影响ETH_StructInit()只填充默认值不修改硬件寄存器LWIP_Init()会重置协议栈内部状态。例程里常见的“换了一块板子就死机”问题多半是这三张表在启动时没有按正确顺序执行。正确顺序是先给 PHY 和 ETH 的电源与复位引脚操作再 GPIO 配置再RCC_AHB1PeriphClockCmd(RCC_AHB1Periph_ETH, ENABLE)随后ETH_DeInit()然后配置ETH_InitStructure并调用ETH_Init()最后调用LWIP_Init()。2.2 LWIP 选 1.4.1 还是 2.xF107 的资源边界LWIP 目前常见版本有 1.4.1 和 2.0.3 以及更新的 2.1.x。F107 只有 64KB RAM, 256KB Flash如果你是用标准外设库 LWIP 1.4.1 的老例程RAM 占用还能压到 110KB 左右换到 2.1.x默认配置下MEM_SIZE如果是 160KB直接链接失败。选型判断标准其实很简单你需要不需要同时支持多个 TCP 连接如果只是上位机点对点连接LWIP 1.4.1 足矣而且它的tcp_accept回调接口比 2.x 简单网上资料最多。如果需要支持 4 个以上的上位机同时轮询那就上 2.0.3它对 TCP 窗口和重传的处理更完善但内存池需要重新调。我自己的建议是 F107 上用 LWIP 2.0.3理由是 2.0.3 之后tcp_write增加了自动分段功能而对 Modbus TCP 这种小包应用1.4.1 需要手动处理分段逻辑容易在PBUF_REF引用外部数据的场景下出错。下面是lwipopts.h里几个关键参数直接决定 F107 能否跑起来#define MEM_ALIGNMENT 4 #define MEM_SIZE (12 * 1024) // 堆内存LWIP 内部 malloc #define LWIP_TCP 1 #define TCP_TTL 255 #define PBUF_POOL_SIZE 8 // 每个 pbuf 池大小 #define PBUF_POOL_BUFSIZE (1500 16) // 一个完整以太网帧 #define TCP_WND (4 * TCP_MSS) // 接收窗口 #define TCP_SND_BUF (4 * TCP_MSS) // 发送缓冲 #define TCP_MSS 1460 #define LWIP_NETCONN 1 #define LWIP_SOCKET 1注意PBUF_POOL_BUFSIZE不是单纯写 1514 就行LWIP 会在前面额外构造struct pbuf头如果 4 字节对齐后放不下会 panic。F107 的以太网 DMA 描述符也要吃 RAMETH_RX_DESC_SIZE 和 ETH_TX_DESC_SIZE 各占 4 个描述符每个描述符 16 字节这部分由eth_drv内部维护不在MEM_SIZE里。如果MEM_SIZE设到 12KB 还是很紧张可以把PBUF_POOL_SIZE降到 6但连接数不能超过 2。还要注意TCP_MSS和LWIP_PLATFORM_DIAG。很多例程把TCP_MSS设成 1460认为这是标准以太网 MTU 减掉 IP 和 TCP 头但在 Modbus TCP 场景下绝大多数请求和响应都小于 300 字节。如果TCP_MSS太大LWIP 会等TCP_SND_BUF攒满再发送导致响应延迟增加。我在例程里用的是TCP_MSS 512这样不仅减少内存占用还避免了大包分片风险。如果你的上位机要求最大寄存器数量一次性读回超过 200 个即数据量超过 400 字节可把 MSS 调到 1024但必须同步增大PBUF_POOL_BUFSIZE。2.3 PHY 芯片的选择与复位坑LAN8720A 的 25MHz 晶振问题F107 的 ETH 外设需要外部 PHY最常见的低成本选择是 LAN8720A 和 DP83848。LAN8720A 便宜但它的 REF_CLK 输入支持两种模式一种是从 XI 引脚输入 50MHz另一种是从 XI 输入 25MHz 并在内部倍频到 50MHz。如果你买了某些模块板上已经放好了 25MHz 晶振然后你把 MCO 配成输出 50MHz 给它的 XIPHY 会直接锁不住表现为状态寄存器 bit2.Link 一直为 0或者软复位后 PHY ID 读出来是 0x0000。解决方式有两种一是把 MCO 输出改成 25MHz让 LAN8720A 内部倍频二是直接用板上的 25MHz 晶振F107 的 ETH_CK 引脚不接这时 RMII 的 REF_CLK 由 PHY 的 CLK_OUT 回传。注意 F107 的数据手册里 ETH 外设必须要有外部时钟才能工作所以第二种方案必须把 PHY 的 CLK_OUT 接回 F107 的 ETH_CK不能只靠 MCO。调试 PHY 的步骤很简单先读寄存器 3 和 4PHY ID确认读到正确的 ID比如 LAN8720A 是 0x0007DP83848 是 0x2000。下面的代码在初始化前做个自检uint32_t count 0; do { ETH_ReadPHYRegister(0, PHY_REG_ID1, phy_id); delay_ms(10); count; } while ((phy_id ! 0x0007) (count 100)); if (count 100) { // 上电后 1 秒内读不到 PHY ID检查复位引脚、时钟或 MDIO 上下拉 return -1; }PHY 的复位引脚是另一个重灾区。很多最小系统板把 PHY 的 NRST 直接接到 MCU 的一个 GPIO如果你在初始化 ETH 之前把 GPIO 拉低了要至少保持 1ms 再拉高然后等待 100ms。有些例程里为了省事直接拉高之后马上读 PHY 寄存器结果读到 0xFFFF。正确做法是复位后至少等待 5ms再开始 MDIO 通信。3. 最小可用的 Modbus TCP 从站实现3.1 从 LWIP 的 raw API 到 Modbus 处理不要套 netconnLWIP 有两种 APInetconn/ socket 和 raw callback。很多人喜欢用 socket因为代码像 Linux 编程但 F107 的 RAM 不够用每开一个 netconn 连接需要额外分配NETCONN结构和消息队列代码至少多占用 4KB 堆而且 netconn 在阻塞模式下容易配合不好。Modbus TCP 是一次请求一次响应完全适合 raw API 的被动回调模式。例程的结构是这样初始化阶段调用tcp_new()创建控制块用tcp_bind()绑定 502 端口Modbus TCP 标准端口再tcp_listen()进入监听然后注册accept回调。每当有客户端连接进来accept回调里新创建一个 tcp_pcb注册recv回调。数据到达时recv回调里解析数据、构造响应、用tcp_write发送。static void modbus_tcp_init(void) { struct tcp_pcb *pcb tcp_new(); if (pcb NULL) return; err_t err tcp_bind(pcb, IP_ADDR_ANY, 502); if (err ! ERR_OK) { memp_free(MEMP_TCP_PCB, pcb); return; } pcb tcp_listen(pcb); pcb-accept modbus_tcp_accept_callback; }关键参数是tcp_bind的本地端口必须显式指定为 502如果你用IP_ADDR_ANY加上端口 0LWIP 会随机分配一个高端口上位机连不上。tcp_listen成功后接受回调里需要重新设置tcp_arg和tcp_recv否则默认收不到数据。Recv 回调里不要直接处理整个数据包。Modbus TCP 的 MBAP 头有 7 个字节事务标识符2 字节、协议标识符2 字节必须为 0、长度2 字节和单元标识符1 字节。长度字段是从单元标识符开始到数据包结束的字节数。实际收到 TCP 载荷时可能要分多次到达所以要做简单的字节累加凑满 6 个字节后再解析长度。最小状态机如下#define MBAP_HEADER_LEN 6 static uint8_t modbus_tcp_buffer[512]; static uint16_t modbus_tcp_len 0; static err_t modbus_tcp_recv_callback(void *arg, struct tcp_pcb *pcb, struct pbuf *p, err_t err) { if (err ! ERR_OK || p NULL) { // 连接断开释放资源 if (p ! NULL) pbuf_free(p); tcp_close(pcb); return ERR_OK; } // 把 pbuf 中的数据拷贝到连续缓冲区等待完整帧 uint16_t copy_len p-len (sizeof(modbus_tcp_buffer) - modbus_tcp_len) ? p-len : (sizeof(modbus_tcp_buffer) - modbus_tcp_len); memcpy(modbus_tcp_buffer modbus_tcp_len, p-payload, copy_len); modbus_tcp_len copy_len; pbuf_free(p); if (modbus_tcp_len MBAP_HEADER_LEN) { uint16_t frame_len (modbus_tcp_buffer[4] 8) | modbus_tcp_buffer[5]; if (modbus_tcp_len MBAP_HEADER_LEN 1 frame_len) { // 完整的 Modbus TCP 帧进入解析 modbus_tcp_handle(pcb, modbus_tcp_buffer, MBAP_HEADER_LEN 1 frame_len); modbus_tcp_len 0; } } if (modbus_tcp_len sizeof(modbus_tcp_buffer)) { modbus_tcp_len 0; // 防止溢出直接丢弃这一帧并等待下一个包 } return ERR_OK; }注意pbuf_free(p)的位置必须放在拷贝之后否则 payload 指针失效。而modbus_tcp_len是全局变量如果多个连接同时到达这个简单例程会串数据。想支持多连接应该把 buffer 放在连接结构体里或者按连接 ID 索引这个在第 5 章再扩展。3.2 功能码解析与响应构造线圈、保持寄存器、输入寄存器Modbus 应用层的核心是一个 switch-case。最常见的功能码是 0x03读保持寄存器、0x06写单个保持寄存器、0x10写多个保持寄存器、0x04读输入寄存器。工业场景下 0x03 占八成流量因为上位机轮询时基本只读数据。我通常把寄存器区映射到一个结构体上避免用大数组散着定义。比如typedef struct { uint16_t device_status; // 寄存器地址 0x0000 uint16_t alarm_code; // 0x0001 uint16_t run_hours; // 0x0002 uint16_t temperature; // 0x0003 uint16_t voltage; // 0x0004 } modbus_regs_t; static modbus_regs_t modbus_regs;响应 0x03 功能码的代码static void modbus_response_read_holding(struct tcp_pcb *pcb, uint8_t *req, uint16_t req_len) { uint16_t start_reg (req[2] 8) | req[3]; uint16_t reg_count (req[4] 8) | req[5]; if (reg_count 125) { // 标准限制一次最多读 125 个寄存器250 字节数据 // 返回异常码 0x02非法数据地址 uint8_t resp[] {0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x01, 0x03, 0x02}; tcp_write(pcb, resp, sizeof(resp), 1); return; } uint8_t resp[9 250]; // 复制 MBAP 的事务 ID 和协议 ID resp[0] req[0]; memcpy(resp[1], req 2, 4); resp[5] (uint8_t)(3 2 * reg_count); // 长度字段 单元ID 功能码 数据字节数 resp[6] req[6]; resp[7] 0x03; resp[8] (uint8_t)(2 * reg_count); for (uint16_t i 0; i reg_count; i) { uint16_t addr start_reg i; uint16_t value *( (uint16_t*)((uint8_t*)modbus_regs addr * 2) ); resp[9 2*i] (uint8_t)(value 8); resp[9 2*i 1] (uint8_t)(value 0xFF); } tcp_write(pcb, resp, 9 2 * reg_count, TCP_WRITE_FLAG_MORE); }这里有个隐性要求modbus_regs结构体在内存里必须是紧凑排布的寄存器地址按 16 位对齐。如果你把结构体成员顺序随意调换或者加了其他变量地址映射就会错位。我一般用#pragma pack(push, 1)或者直接定义联合体而不是依赖结构体布局。tcp_write的最后一个参数是 flags。这里用TCP_WRITE_FLAG_MORE表示还有后续数据包但不适用这个场景因为我们一次响应就结束了。实际上 Modbus TCP 响应不需要置位 MORE置位 MORE 反而会让 LWIP 推迟发送等待后续数据。所以例程里tcp_write(pcb, resp, len, 0)就够了。之前网易博客上有个流传很广的例程用TCP_WRITE_FLAG_MORE导致所有响应都要等 40ms 的 ACK 定时器超时上位机轮询周期直接拉长到 60ms 以上。3.3 完整轮询循环一个线程搞定 PHY 维护和 ModbusF107 的 LWIP 例程里协议栈本身靠tcp_tick函数驱动但 Modbus 应用层不需要额外 RTOS直接在主循环里调用sys_check_timeouts()即可。PHY 的状态可以由以太网外设的 Link 状态中断驱动但简单做法是轮询 PHY 寄存器。主循环代码如下int main(void) { SystemInit(); NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4); ETH_GPIO_Config(); ETH_Setup(); LWIP_Init(); modbus_tcp_init(); while (1) { sys_check_timeouts(); // 处理 LWIP 定时器重传、ACK 等 ETH_CheckPhyLink(); // 每次读 PHY 寄存器检测网线插拔 modbus_tcp_poll(); // 如果有需要异步上报的数据可以在这里更新寄存器 // 不要在这里加 delay否则 LWIP 的 TCP 重传超时会被拖延 } }注意ETH_CheckPhyLink()的轮询周期建议 200ms 左右如果每次循环都读 MDIO会占用 ETH 总线的访问时间影响 DMA 收发。简单做法是记一个 tick每 200ms 读一次 PHY 状态寄存器。F107 没有专门的操作系统定时器用 SysTick 累计就可以。LWIP 的sys_check_timeouts()必须在主循环中频繁调用尤其当连接建立后TCP 的 ACK 超时是动态计算的如果你的主循环里有一个耗时 100ms 的 ADC 采样函数那么 ACK 可能被延迟造成上位机误以为超时。解决方式是把耗时操作放在 DMA 中断里或者拆分成多步状态机保证主循环每 10ms 至少能回来一次。4. 例程调试抓包看时序、调超时参数、查内存池溢出4.1 用 Wireshark 重建 Modbus TCP 回忆重点看 ACK 与 PSH 标志当上位机连不上或者偶发超时的时候第一件事不是改代码而是抓包。Wireshark 的显示过滤器可以写modbus.tcp它能把 Modbus TCP 协议头解析出来直接看到功能码和寄存器值。但更关键的是看 TCP 层的 PSH 和 ACK 标志。正常的一次 Modbus TCP 请求响应服务端收到请求后应该立即返回响应包并且响应包的 TCP 头里面有 PSH1、ACK1。如果 Wireshark 里看到响应包延迟了几十毫秒或者出现了 TCP ZeroWindow说明 LWIP 的发送窗口被占满多半是TCP_SND_BUF设置太小或者响应频次太高而tcp_write返回 ERR_MEM。抓包时还有一个常见现象上位机每发一个请求F107 回了一个 ACK但 Modbus 响应包迟迟不来。这时候去查看tcp_sent回调是否有数据堆积。如果tcp_write在缓冲区满的时候返回 ERR_MEM但你的代码忽略了返回值数据就丢了。正确做法是在tcp_sent回调里补发缓冲区中的数据。下面的回调片段展示了如何缓冲待发送的数据struct modbus_tcp_conn { struct tcp_pcb *pcb; uint8_t pending_data[512]; uint16_t pending_len; uint8_t sending; // 当上一次 tcp_write 返回 ERR_MEM置 1 }; static err_t modbus_tcp_sent_callback(void *arg, struct tcp_pcb *pcb, u16_t len) { struct modbus_tcp_conn *conn (struct modbus_tcp_conn*)arg; if (conn-sending conn-pending_len 0) { err_t err tcp_write(pcb, conn-pending_data, conn-pending_len, 0); if (err ERR_OK) { conn-sending 0; conn-pending_len 0; } } return ERR_OK; }这个 buffer 必须按连接维护不能是全局变量否则两个上位机同时轮询时会互相污染。4.2 TCP 超时与重传参数别让默认的 3 秒重传拖死轮询LWIP 的 TCP 重传参数由LWIP_TCP_RTO_TIME和TCP_RTO_MAX控制但 F107 例程里通常用的是标准lwipopts.h默认值初始 RTO 是 1 秒超时后指数退避到最大 10 秒。如果上位机的轮询周期是 1 秒某次网络抖动丢包后上位机至少等 1 秒 RTO 才收到重传轮询周期就会变成 2 秒。这在很多工控场景下是不可接受的。但实际上 Modbus TCP 走局域网丢包率极低真正的问题不是网络丢包而是 F107 的软件响应太慢导致上位机因为超时重发但重发的请求和上一次请求在协议栈里同时存在。比如上位机超时设为 100ms而 F107 在响应前做了一个耗时 200ms 的 Flash 擦除那么上位机重发请求F107 又收到第二次请求两笔请求都处理了产生两次响应但上位机只认其中一笔。这种现象不一定是 TCP 层问题更像是应用层超时与 MCU 任务耗时错配。调参方案是缩小 LWIP 的重传时间对局域网环境更贴合#define TCP_RTO_MIN 200 // 毫秒 #define TCP_RTO_MAX 3000 #define TCP_SYN_RTO_TIME 500TCP_RTO_MIN设成 200ms 后如果 TCP 层真的丢包最多等 200ms 重传整体轮询抖动减少到原来的五分之一。注意这个参数只影响重传不影响确认。如果你设得太小比如 50ms网络正常时也会发生虚假重传浪费带宽。4.3 RAM 不足的典型现象打印 LWIP 的 statsF107 只有 64KB RAM连接数一多最先泄漏的是PBUF_POOL。你可以用 LWIP 自带的 stats 模块把LWIP_STATS和LWIP_STATS_DISPLAY打开然后在 printf 里显示#include lwip/stats.h void modbus_tcp_status(void) { printf(pbuf: %d/%d, tcp_pcb: %d/%d, heap: %d\n, MEMP_STATS_GET(used, MEMP_PBUF_POOL), MEMP_STATS_GET(max, MEMP_PBUF_POOL), MEMP_STATS_GET(used, MEMP_TCP_PCB), MEMP_STATS_GET(max, MEMP_TCP_PCB), mem_heap_free()); }如果used长期接近max说明PBUF_POOL_SIZE不够。但有时候used不高mem_heap_free却逐渐缩小那是MEM_SIZE的堆碎片化。LWIP 的mem_malloc是简单首次适应不会做碎片整理连续 8 字节的分配会慢慢产生大量不可用碎片。解决方式是把小对象如 Modbus 缓冲区直接定义为静态数组而不是每次连接都 malloc。一个很隐蔽的问题来自PBUF_RAM。用tcp_write发送数据时新造的 pbuf 类型是PBUF_RAM如果堆内存不足发送会失败。而pbuf_alloc(PBUF_TRANSPORT, len, PBUF_RAM)失败时返回 NULL但有些例程里tcp_write返回 ERR_MEM 后代码没有释放临时 pbuf导致泄漏。我一般会强制tcp_write的入参是栈上字节数组让 LWIP 内部决定怎么分配避免自己构造 pbuf。5. 让例程更稳多连接、看门狗、功能码扩展与性能验证5.1 多连接支持set tcp_arg 里挂到带缓冲的结构体工厂里往往不止一个上位机一个中控室监控一个本地触摸屏还有一个远程采集网关。标准 Modbus TCP 允许最多 253 个并发连接但 F107 的 RAM 不支持这么多。实际例程一般限制MEMP_NUM_TCP_PCB_LISTEN和MEMP_NUM_TCP_PCB的数量我给这个例程的推荐值是 4 个并发连接。多连接的关键是每个连接必须有独立的接收缓冲区和状态而不是用全局变量。最直接的方式是在accept回调里分配一个结构体typedef struct { struct tcp_pcb *pcb; uint8_t rx_buf[512]; uint16_t rx_len; } modbus_tcp_conn_t; static modbus_tcp_conn_t conns[4]; // 预先分配避免 malloc static err_t modbus_tcp_accept_callback(void *arg, struct tcp_pcb *newpcb, err_t err) { modbus_tcp_conn_t *conn NULL; for (int i 0; i 4; i) { if (conns[i].pcb NULL) { conn conns[i]; break; } } if (conn NULL) { tcp_close(newpcb); return ERR_MEM; } conn-pcb newpcb; conn-rx_len 0; tcp_arg(newpcb, conn); tcp_recv(newpcb, modbus_tcp_recv_callback); tcp_sent(newpcb, modbus_tcp_sent_callback); tcp_err(newpcb, modbus_tcp_err_callback); return ERR_OK; }这样每个连接的数据缓冲独立不会互相覆盖。关闭连接时记得在err回调和recv回调的p NULL分支里把conn-pcb置回 NULL否则槽位浪费4 次连接后新的上位机进不来。5.2 独立看门狗与寄存器超时标志TCP 连接假死怎么拉回来给例程加上一个应用层心跳。常见做法是维护一个last_activity_tick每次收到合法的 Modbus 请求就更新它。主循环里如果看到sys_get_uptime_ms() - last_activity_tick超过 3000 毫秒可以复位一次 PHY 与重启 LWIP。为什么要这么做因为 F107 的以太网外设偶尔会发生 DMA 描述符挂死网线还在连接但 RX 通道不再更新此时 TCP 连接表现为“假死”上位机发请求过来MCU 不再回复。这几乎是所有不带 OS 的 LWIP 从站的宿命。我通常不会直接调用NVIC_SystemReset()而是先把以太网外设和 LWIP 全部卸载再重新初始化保留上电运行时间统计void modbus_tcp_restart(void) { tcp_pcb_remove(NULL, listen_pcb); ETH_DeInit(); LWIP_Deinit(); modbus_tcp_init(); }但LWIP_Deinit()在 2.0.3 里并不释放所有 PCB所以更可靠的是软复位整个芯片。其实对绝大多数应用看门狗复位是可以接受的因为从站无状态重启后上位机只需重新建立 TCP 连接。如果你不想重启可以关闭连接并重新进入监听触发条件设为连续 10 次完整 Modbus 请求但没有收到任何响应即请求解析成功但发送失败这种场景往往意味着tcp_write返回 ERR_MEM等内存回收后重试即可。5.3 功能码扩展0x2B 的设备和 0x17 的读写多寄存器除了基础的 0x03/0x06/0x10有些上位机要求支持 0x2B读设备标识和 0x17读/写多个寄存器。0x17 的应用场景是改参数后立刻读回验证比如变频器启停。这个功能码的实现比较简单因为它等于把 0x03 和 0x10 的解析串到一个帧里。0x2B 读设备标识则要对 MEI 类型做区分标准类型 0x01 返回基本设备信息比如厂商名、产品名、版本号。我写过一个只支持类型 0x01 的简略实现响应数据长度不超过 100 字节满足绝大多数 SCADA 系统的识别要求。实现的时候注意 MBAP 长度字段要包含后续所有字节而且要正确区分类别 0x01 和类别 0x04 的读序列。很多例程只实现 0x04因为可以一次读出所有自定义对象但 Modbus 规范里 0x04 要求重复读几个固定的对象实现起来比 0x01 复杂得多。除非上位机点名要 0x04否则只做 0x01 就够了。5.4 性能验证理论吞吐量 vs 实际轮询周期最后给一个验证方法。用 Python 写一个小脚本连续发 1 万次读保持寄存器请求统计平均响应时间和丢包率import socket, time, struct s socket.create_connection((192.168.1.100, 502), timeout1.0) req struct.pack(HHHBBHH, 0x0001, 0x0000, 6, 0x01, 0x03, 0x0000, 10) t0 time.time() success 0 for i in range(10000): s.send(req) resp s.recv(512) if len(resp) 0: success 1 else: print(empty resp at, i) break dt time.time() - t0 print(success, success, elapsed, dt, avg ms, dt / 10000 * 1000) s.close()如果平均响应时间超过 2ms说明你的tcp_write或回调路径里有什么地方睡着了。一个只做寄存器内存拷贝的 Modbus TCP 从站在 F107 跑 72MHz 下平均响应应该在 0.5ms 到 1ms 之间。超过 2ms 时检查是不是每次都在recv回调里调用了pbuf_alloc构造响应这个函数本身耗时可能很短但如果在中断里调用则会有问题。另外确认你的代码没有在响应路径上读 PHY 寄存器或操作 Flash那会直接卡住几十毫秒。验证完这组数据你就知道例程的余量在哪。如果上位机需要 50ms 的轮询周期而实际响应 1ms那么你还可以再加几个连接或者把 ADC 采样也塞进同一个主循环。F107 的 Modbus TCP 例程的核心优化永远只有一个原则减少协议栈回调路径上的耗时把不紧急的活挪出 TCP 回调节奏。本文还有配套的精品资源点击获取
返回列表