STM32H5+FreeRTOS+LwIP以太网开发实战:从配置到调优

STM32H5+FreeRTOS+LwIP以太网开发实战:从配置到调优 简介面向STM32H5嵌入式开发者这是一份将FreeRTOS与LWIP集成到STM32H563芯片上的完整示例工程可作为网络通信应用的基础开发平台也适合从ThreadX等商业实时系统迁移到开源方案的开发者参考。压缩包共含1698个文件以1068个C源文件、460个头文件、127个说明文档为主另有ICF链接脚本、启动文件、IOC图形配置等整体大小8.42MB目录结构清晰检索方便目前已有1028人学习下载。工程中实时操作系统负责多任务调度轻量级网络协议栈提供TCP、UDP、ICMP等协议支持配合STM32H5的硬件抽象层驱动与以太网控制器展示了网络接收、发送、定时器管理等任务的完整实现方式。通过分析任务调度源码、硬件抽象层驱动、协议栈配置与应用层代码可以掌握任务创建、信号量与互斥锁的用法并了解在STM32平台上搭建物联网节点所需的网络服务框架与编译流程。无论是学习RTOS基础还是快速起步网络功能这份工程都提供了可运行、可复用的起点。 最近把一套采集上报设备的主控从STM32F系列换到了STM32H5同时把原来裸机下的网络代码重构成了FreeRTOS LwIP的结构。做完之后回头看看这个组合其实非常典型H5提供算力FreeRTOS负责把多线程的任务理清楚LwIP把最麻烦的TCP/IP协议栈包起来对外就是一个能联网、能出数据的完整节点。如果你正要评估STM32H5做以太网设备或者第一次在FreeRTOS上跑LwIP这篇内容可以帮你少走几条弯路。1. 项目定位与整体方案选型1.1 为什么是STM32H5 FreeRTOS LwIP先回答一个被问过很多次的问题为什么要用STM32H5而不是继续用F4/F7或者干脆上Linux。STM32H5是Cortex-M33内核主频250MHz带单精度浮点外设资源比F4提升了不少关键是还带了硬件加密和TrustZone隔离能力。对网络设备来说H5内置了10/100M以太网MAC配合一颗外部PHY芯片就能实现标准以太网接口。这一点和F4/F7/H7是同一路线但H5的功耗控制和价格定位更贴近中端产品在“需要联网但预算和功耗都有限”的场景里很合适。接下来是RTOS选型。FreeRTOS在整个嵌入式生态里的普及率是最高的STM32CubeMX直接提供图形化配置生成代码后通过CMSIS-RTOS封装创建任务时少写很多移植代码。而且FreeRTOS的资料和问题案例非常多基本你踩过的坑网上都有人踩过。对于第一次引入RTOS的团队FreeRTOS几乎是最稳的选择。LwIP则是一个专门为嵌入式设备设计的轻量级TCP/IP协议栈TCP/UDP/ARP/ICMP/DHCP这些基础功能都有。它有两种运行模式NO_SYS1的裸机模式和NO_SYS0的带RTOS模式。在实际工程里一旦应用逻辑复杂度上来我强烈建议直接选NO_SYS0也就是让LwIP跑在FreeRTOS的线程上。理由很简单裸机模式下协议栈的tcpip_thread不存在所有协议处理都得自己手动轮询加上netconn API无法使用线程安全保护代码写起来极其痛苦。这个方案解决的问题我概括成一句话让一个MCU设备既能稳定地跑嵌入式应用逻辑又能以标准TCP/IP协议对外通信而不是为了联网就引入一个庞大的Linux系统。1.2 系统架构与板级准备整个系统分层结构大致是这样应用任务在最上面包括数据采集任务、网络通信任务、状态控制任务中间是FreeRTOS内核做任务调度和任务间通信再往下是LwIP协议栈负责TCP/UDP/ARP这些协议的处理最底层是以太网MAC控制器和PHY芯片由HAL库和ethernetif.c驱动起来。我在这个项目中使用的板卡是一块带STM32H563ZICortex-M33, 250MHz的开发板板载PHY是LAN8742A以太网接口采用RMII模式所以PHY需要一个50MHz的参考时钟。软件环境是STM32CubeMX 1.15.1H5系列支持包 STM32CubeIDE调试用串口打印日志数据抓包用Wireshark配合PC网卡。这里必须提醒一点硬件上电之前一定要确认PHY芯片的电源、地和复位引脚都正常。我第一次上电调试时PHY的复位引脚被一个跳线帽拉低导致MDIO总线始终读不到PHY ID卡了整整一天。先用官方例程或者最简单的裸机以太网例程把PHY ID读出来再来做RTOS和LwIP这是最节省时间的路线。2. CubeMX工程配置与代码生成2.1 时钟树与ETH外设配置先说时钟这步最容易出事。H5的ETH外设挂在AHB总线上MAC本身的收发逻辑需要AHB时钟而RMII接口的PHY则需要一个独立的50MHz参考时钟。RMII参考时钟有两种做法一种是用外部有源晶振直接给PHY提供50MHz另一种是用MCU的MCO引脚输出50MHz给PHY。大多数开发板采用MCO2输出方式所以CubeMX时钟树里需要把MCO2配置为50MHz。HSE晶振频率按板卡实际情况填比如25MHzSYSCLK配到250MHzAHB、APB1/APB2这些时钟按H5时钟树约束来配。在Connectivity里选中ETH后配置界面会把需要使能的中断显示出来这里把ETH中断打开并且记住它的IRQ编号后面配置FreeRTOS中断优先级时要用。PHY部分的配置项里RMII模式下需要选择PHY地址如果PHY地址寄存器是0就填0CubeMX如果认不出来可以先用Generic PHY驱动后面再换成具体型号。DMA描述符数量一般默认4或6个RX/TX Buffer Size默认1536字节对应一个标准以太网帧的最大长度。这里不建议把描述符数量改太小否则网络负载大的时候会丢包。2.2 FreeRTOS与LwIP的参数配置明细进入Middleware一栏先打开FreeRTOS选择CMSIS_V2接口这也是新代码生成工具推荐的选项。H5开发包里的FreeRTOS版本已经比较新支持Cortex-M33的MPU和TrustZone配置。如果不需要TZ安全区可以把TrustZone关掉减小调试复杂度。Heap大小建议一开始就给足而不是用到内存不够再回头调。我的配置是configTOTAL_HEAP_SIZE0x1000064KB在跑通基础TCP连接、DHCP、后来又加了cJSON打包之后剩余内存还有将近20KB。注意FreeRTOS的堆和LwIP的内存池是两套独立体系不要混为一谈。然后是LwIP的配置。在Middleware里勾选LwIP之后会弹出一堆协议栈配置项这些不是随便填的。下面这个表格是我在H5上实测过的一版参数可以直接参考配置项推荐值/示例作用与备注NO_SYS0使用带RTOS模式必须为0MEM_SIZE4096~8192协议栈堆空间cJSON等大块分配依赖它PBUF_POOL_SIZE8~16接收/发送pbuf池的数量越大抗突发能力越强PBUF_POOL_BUFSIZE1536单个pbuf容量刚好装下完整以太网帧TCP_WND1460*4TCP接收窗口决定吞吐量TCP_SND_BUF1460*4TCP发送缓冲LWIP_DHCP1如果不用DHCP可以关掉固定IP为主LWIP_NETCONN1使用netconn API的前提LWIP_SOCKET0/1用了socket就开不用就关省内存LWIP_STATS1调试期打开统计丢包和内存情况每一项都不白给。比如MEM_SIZE我最初按默认的2048配置结果cJSON创建一条稍长的JSON报文时直接失败后来把MEM_SIZE调到8192才稳定。LWIP_STATS如果不开出问题时会缺少一个很关键的诊断手段。2.3 生成代码后的工程结构CubeMX生成代码后目光先落在几个关键文件上Core/Src/main.c、Core/Src/app_freertos.c、Core/Src/lwip.c以及以太网驱动的ethernetif.c在H5包里这部分核心逻辑封装在HAL层对应的以太网驱动里。这几个文件的分工很清晰main.c负责硬件初始化里面会调用MX_LWIP_Init()和MX_FreeRTOS_Init()app_freertos.c里是FreeRTOS的任务创建代码默认会生成几个模板任务lwip.c里面是LwIP协议栈的初始化、网卡注册、DHCP启动这些逻辑ethernetif.c则是网卡驱动和协议栈之间的桥梁负责把ETH收到的数据包装成pbuf送给tcpip_input。刚拿到生成代码时不要急着改lwipopts.h里的宏。CubeMX生成的配置已经经过了一轮参数校验很多问题不是宏定义的问题而是应用层使用方式的问题。先跑通默认代码再按需求逐步调整这个顺序会流畅很多。3. LwIP在FreeRTOS下的运行机制3.1 协议栈的线程模型很多第一次接触LwIPFreeRTOS的人都会问一个问题代码不是轮询协议栈的它到底是怎么跑起来的答案在系统的线程模型。LwIP在NO_SYS0模式下会创建几个核心线程tcpip_thread是协议栈主线程负责处理所有TCP/IP协议事件ethernetif_input线程负责从网卡驱动读取收到的数据包并投递给协议栈在带DHCP时还会有dhcp线程。这些线程在MX_LWIP_Init()初始化时由sys_thread_new自动创建出来你看不到vTaskCreate的显式调用但本质上它们就是FreeRTOS任务。数据流路径大致是PHY收到网络数据产生ETH中断HAL库的ETH中断回调触发在回调里释放信号量ethernetif_input线程等到信号量后从DMA缓冲区读取数据包装成pbuf调用tcpip_input()把数据交给tcpip_threadtcpip_thread解析协议头最终把数据送给netconn层或者socket层应用任务从netconn_recv()返回的数据中拿到实际内容。用一个生活化的类比PHY是小区门口的信箱ETH中断是邮箱塞入信件时触发的铃声ethernetif_input是取信员tcpip_thread是分拣中心应用任务就是收件人。链条上任何一个环节卡住邮件就到不了收件人手里。理解这个模型后排查网络问题就有了思路先看PHY有没有收到信号再看ethernetif_input有没有被创建和执行最后看应用任务有没有阻塞在recv上。大多数“没反应”的问题是某个环节上任务没跑或者优先级太低被饿死了。3.2 netconn与socket API选型在当前工程里实际用的是netconn API而不是socket API。原因很简单netconn API是LwIP自己提供的基于连接的操作接口内部天然使用RTOS的互斥锁和信号量保护连接状态线程安全内存开销比BSD socket接口小。而BSD socket接口为了兼容标准POSIX接口底层需要更多的状态和缓冲对MCU来说是不小的负担。netconn API的使用风格比较直接。一个典型的TCP服务器流程是struct netconn *conn netconn_new(NETCONN_TCP); netconn_bind(conn, IP_ADDR_ANY, 8080); netconn_listen(conn); struct netconn *newconn; netconn_accept(conn, newconn); struct netbuf *buf; netconn_recv(newconn, buf); // 处理数据 netconn_write(newconn, resp, strlen(resp), NETCONN_COPY); netbuf_delete(buf); netconn_close(newconn); netconn_delete(conn);这段代码里netconn_accept会一直阻塞直到有客户端连接进来netconn_recv会一直阻塞直到收到数据正好和FreeRTOS任务模型契合任务切出去之后CPU可以干别的事。需要注意这些API都不能在中断服务函数里调用它们依赖RTOS的信号量和阻塞机制。3.3 内存管理与缓冲区机制LwIP的内存管理是整个协议栈最容易出问题、也最影响稳定性的部分。LwIP内部有两套内存分配体系内存池memp和内存堆mem。内存池用于分配固定大小的控制块比如TCP PCB、UDP PCB、netbuf分配快、无碎片内存堆用于需要较大连续内存的场景比如发送大数据时申请的pbuf内容区。pbuf是协议栈里最常见的数据结构分为PBUF_RAM、PBUF_POOL和PBUF_ROM三种类型分别对应堆分配、池分配和外部静态数据引用。网络收发的数据在PHY DMA缓冲区和协议栈之间传递时经过的就是一层层pbuf的封装和拷贝。如果PBUF_POOL_SIZE不够收到数据时找不到空闲pbuf数据就只能被丢弃表现就是网络时通时断ping包一会儿掉一个。排查这个问题时最容易忽略的是LWIP_STATS统计信息它里面有内存池使用的实时计数能看到是否出现过分配失败。内存问题还有一个隐蔽的表现协议栈跑一会儿后进入断言死循环。LwIP的LWIP_ASSERT宏默认在错误时开启无限循环配合调试器能看到具体崩在哪个文件和哪一行。这种情况十有八九和内存分配失败或者越界写有关。4. 核心功能实现与代码细节4.1 TCP服务器任务实战现在把这一切落到实际代码上。项目里安排了一个网络通信任务专门启动TCP服务器等待上位机或者云平台发起连接。任务创建时给它的栈大小是1024字4KB不能更小。netconn_new、netconn_accept这些API内部调用层次深、局部变量多栈太小会直接溢出。FreeRTOS的栈溢出检测configCHECK_FOR_STACK_OVERFLOW2在这种情况下会触发断言但最好还是提前算好余量。核心代码大致如下void NetworkTask(void *argument) { struct netconn *conn netconn_new(NETCONN_TCP); if (conn NULL) { vTaskDelete(NULL); } netconn_bind(conn, IP_ADDR_ANY, 8080); netconn_listen(conn); for (;;) { struct netconn *client; err_t err netconn_accept(conn, client); if (err ! ERR_OK) continue; struct netbuf *inbuf; err netconn_recv(client, inbuf); if (err ERR_OK inbuf ! NULL) { void *data; u16_t len; netbuf_data(inbuf, data, len); // 在这里对data做应用层处理 const char *resp {\status\:\ok\}; netconn_write(client, resp, strlen(resp), NETCONN_COPY); netbuf_delete(inbuf); } netconn_close(client); netconn_delete(client); } }注意netconn_recv返回之后第二个参数是netbuf用netbuf_data拿到的缓冲指针不能永久保存因为netbuf删除后数据就没了。如果你需要把数据缓存下来必须拷贝到自己的缓冲区。这一点我第一次实现时踩过坑调试时发现数据每次都不一样后来才明白是netbuf被复用导致。4.2 用cJSON组装上报数据项目里除了做TCP服务器还需要主动向上位机上报传感器实时数据。这部分用cJSON来构造JSON报文再通过LwIP发送出去这也是很多物联网采集设备的通用做法底层MCU采集数据cJSON打包LwIP发送服务器端解析JSON。代码逻辑如下cJSON *root cJSON_CreateObject(); cJSON_AddStringToObject(root, dev, h563-sensor-node); cJSON_AddNumberToObject(root, temp, 25.6); cJSON_AddNumberToObject(root, hum, 51.2); char *payload cJSON_PrintUnformatted(root); if (payload ! NULL) { netconn_write(conn, payload, strlen(payload), NETCONN_COPY); cJSON_free(payload); } cJSON_Delete(root);这里有两个很关键的坑。第一个是cJSON_PrintUnformatted会动态申请内存用完必须用cJSON_free释放否则每个上报周期都泄漏一点内存几天后系统就会因为内存耗尽而挂掉。第二个是cJSON分配内存走的是LwIP的MEM_SIZE如果MEM_SIZE设置太小会在cJSON_CreateObject或cJSON_Print的时候返回NULL代码里必须对返回值做判空否则解引用NULL指针就是硬伤。集成cJSON时我也纠结过一个问题把JSON打包放在哪个任务里做。我的结论是放在网络任务里做因为JSON字符串最终要送给netconn_write如果放在采集任务里还需要跨任务传递一块长度可变的动态内存队列和互斥锁的管理复杂度不划算。用FreeRTOS队列只传递采集到的原始数值结构体JSON打包交给网络任务职责清晰调试也容易。4.3 串口数据与网络数据打通项目中还有一个具体需求板子的串口连接外部传感器模块传感器上报的数据需要通过以太网转发给服务器。一开始我想得很简单串口收到什么网络就发什么。但实际串口数据没有固定帧头接收时机不确定如果不做缓冲和边界处理很容易把半包发给服务器。后来在串口中断里用了空闲中断DMA接收一帧完整的串口数据到达后触发回调把数据拷贝到一个环形缓冲区同时通过FreeRTOS队列通知网络任务。网络任务的处理逻辑是typedef struct { uint8_t data[256]; uint16_t len; } SerialFrame_t; SerialFrame_t frame; while (xQueueReceive(serialQueue, frame, portMAX_DELAY) pdTRUE) { // 可以把frame.data打包成JSON或直接转发 netconn_write(conn, frame.data, frame.len, NETCONN_COPY); }串口DMA接收缓冲区的长度决定了单帧的最大数据长度如果你的传感器一帧超过256字节要改成动态分块处理。需要特别注意的是串口中断回调里不能用vTaskDelay、不能用netconn_write只能调用带“FromISR”后缀的API比如xQueueSendFromISR实际的数据发送必须交给普通任务完成。5. 高频问题与排查技巧实录5.1 任务堆栈溢出检测与定位FreeRTOS堆栈溢出是个经典又难查的问题。configCHECK_FOR_STACK_OVERFLOW选2时系统会在任务切换时做两种检查一是检查任务当前的栈指针是否还落在任务栈范围内二是检查任务栈尾部的一段填充模式值是否被破坏。前者靠硬件寄存器判断后者靠软件标记。实际遇到的表现多种多样有的任务跑着跑着就触发configASSERT有的直接进HardFault还遇到过一种很隐蔽的情况——任务栈溢出把相邻任务的TCB控制块覆盖了导致另一个任务莫名其妙被删除查了很久才发现是栈不够。我的排查步骤是先在HardFault_Handler里打断点查看调用栈中压栈的LR和PC寄存器利用IDE反汇编定位到最近的函数再看FreeRTOS的任务列表确认哪个任务处于运行态最后直接把嫌疑任务的栈大小翻倍甚至翻四倍看问题是否消失用来验证。5.2 以太网中断优先级与FreeRTOS的冲突这可能是H5上最容易踩的坑。CubeMX生成ETH配置时默认把ETH中断优先级设置得比较高比如0或1在裸机下跑Ping完全没问题。但一旦接入FreeRTOSETH中断里可能调用了带FromISR后缀的API例如xSemaphoreGiveFromISR而FreeRTOS要求任何FromISR的API只能在优先级不高于configMAX_SYSCALL_INTERRUPT_PRIORITY的中断里调用。H5的Cortex-M33使用8位优先级寄存器FreeRTOS默认把最高优先级给内核管理。ETH中断优先级如果比这个门槛高FromISR调用时可能直接触发断言。解决办法是在NVIC配置里把ETH中断优先级改成5到14之间并且让所有可能调用FreeRTOS API的中断都保持在这个范围内。我的习惯是把ETH中断优先级设成5串口中断设成6定时器中断设成7这样既不会打断关键实时任务也不会违反FreeRTOS的中断安全规则。5.3 抓包与协议栈调试技巧从工程效率来看网络调试一定不要靠瞎猜。先用Wireshark在PC端抓包能看到板子发出的ARP、DHCP、TCP包就能判断链路层通没通、IP层配置对不对、TCP握手是否完成。一个高效的调试顺序是先固定静态IP例如192.168.1.100网关192.168.1.1在PC上能ping通说明链路层和IP层正常然后在PC上用网络调试助手开一个TCP服务器板子作为客户端主动连接能连上说明TCP层正常最后再测服务器往板子发数据板子能收到并返回数据应用层才算真正通了。如果ping不通优先检查PHY的链路状态寄存器。用调试器读PHY寄存器确认PHY是否处于Link Up状态、速率和双工模式是不是100M Full Duplex。RMII模式下常见问题是50MHz参考时钟没有产生可以用示波器量MCO2引脚或者确认PHY有没有外部晶振。我遇到过一次ping不通查到是网线接的是交换机百兆口但板子被配置成了千兆模式协商失败换成百兆口后一切正常。5.4 关于H5平台的额外提醒最后补一个H5特有的注意事项很多H5芯片默认开启了TrustZone隔离。如果开启了TrustZoneETH外设和对应中断必须配置在非安全区否则中断永远进不来代码表现就是网络完全不通。同时FreeRTOS在安全和非安全状态的移植也需要额外配置。如果项目里不需要TrustZone最简单的方式是在CubeMX的Project Manager里关闭TrustZone把芯片当普通M33用能减少一大块调试成本。等你在非安全模式下把网络功能跑通了再去研究安全隔离的分配路线更清晰。另外H5的内存布局和F4差异较大DMA描述符最好放在非缓存区或者做好Cache一致性维护否则收发缓存会被Cache命中导致数据不对这次我在调试时也碰到过一次最终通过MPU配置解决了。最后再分享一个小技巧备一台带串口转网络的调试工具或者直接在板子上留一个调试用TCP端口把日志通过LwIP发到PC端调试体验会比纯串口舒服很多。我在实际调这个项目时一半的晚上都在“翻代码、查寄存器、看Wireshark”这三件事之间来回切换。遇到网络问题时先别急着怀疑协议栈很多时候问题出在PHY配置、时钟或中断优先级上。先把链路调通再逐步加应用功能这是我一直推荐的做法。如果你的项目也用H5一定记得检查TrustZone和ETH中断优先级这两处能帮你省下不少调试时间。本文还有配套的精品资源点击获取