ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

STM32F407 FreeRTOS+LwIP 以太网移植实战与排错

STM32F407 FreeRTOS+LwIP 以太网移植实战与排错 以太网加实时操作系统是嵌入式里最容易“看着简单、做着翻车”的一类活。STM32F407 这颗片子本身条件不错——168MHz 的 Cortex-M4、带 FPU、内置 10/100M MAC192KB 的 SRAM 也够折腾所以拿它来跑 FreeRTOS 加 LwIP 是一个很经典也很划算的组合。但真动手的人都知道难的不是把源码拖进工程而是把中断优先级、内存布局、时钟树、PHY 自协商、sys_arch 适配这几件事同时捋顺——任何一处没对齐表现都是同一个Ping 不通或者通了但几分钟就死。这篇是我自己在一块 F407 DP83848 的板子上从裸机轮询改到 FreeRTOS LwIP 的完整记录。不是教程的复述而是把每一步“为什么这么选”和“哪一步最容易崩”讲清楚。如果你手上正好有 F407 的板子、跑过裸机网络、想让多个任务采集、显示、通信并行跑起来这篇基本可以直接抄作业。如果你连 FreeRTOS 的任务切换流程都还没搞明白也建议先看一遍因为后面 sys_arch 那部分会直接用到信号量和邮箱的语义。1. 为什么STM32F407同时上FreeRTOS和LwIP是笔划算的账1.1 裸机轮询在以太网场景下必然撞墙先说我一开始的做法也是最常见的做法一个while(1)大循环里面轮流调用ethernetif_input()收包、处理一个 Modbus TCP 请求、刷一次 OLED、再读一遍 ADC。这套结构在 10M 网络、每秒几十个包的场景下能跑但只要对端开始连续发数据问题就来了——收包和处理是串行的处理一帧的时间里DMA 描述符环可能已经被写满后续的帧直接丢。更麻烦的是“实时性”这个需求。ADC 采样要求固定的 1ms 周期网络收包要求尽量不丢LED 状态刷新又不能卡。这三件事在单线程里是互相抢时间的你把它写成一个状态机也能做但代码会迅速变成一坨谁都不敢改的 if-else。FreeRTOS 解决的恰恰是这件事把“周期采样”“网络协议栈”“应用逻辑”“状态显示”拆成独立任务各自有独立的栈和优先级由内核按抢占式调度去分配 CPU。你不需要再手动算“这一轮循环我花了多少微秒”只需要保证高优先级任务的执行时间可控。1.2 FreeRTOS 与 LwIP 的官方默认搭配关系LwIP 本身设计上是支持两种模式的NO_SYS1的裸机模式和NO_SYS0的带操作系统模式。后者的关键点在于LwIP 内部有一个独立的 TCP/IP 线程由tcpip_init()创建所有协议栈的核心处理都在这个线程里做其他任务通过 netconn 或 socket API 往里丢消息。这个设计带来的直接好处是协议栈内部不再需要靠sys_check_timeouts()在主循环里轮询超时而是由 TCP/IP 线程自己按sys_now()的毫秒计数处理重传、ARP 老化、TCP 保活。也就是说LwIP 带 OS 模式下你的应用代码和协议栈之间是彻底解耦的这是我认为在 F407 上必须选NO_SYS0的根本原因。代价是 RAM 开销。TCP/IP 线程本身要一份栈典型 512 到 1024 字邮箱mbox要占队列内存再加上 LwIP 自己的内存池整块下来 40KB 到 60KB 是保守估计。F407 的 128KB SRAM1 加 64KB CCM 完全撑得住但分配边界必须提前划清楚。1.3 F407 这块片子的资源账要先算明白动手前我习惯先把资源账列出来因为它决定了后面所有参数怎么定。资源规格对本项目的影响内核Cortex-M4F168MHz决定 FreeRTOS 用 ARM_CM4F 端口SRAM1112KB 0x20000000DMA 可访问网卡描述符和缓冲必须放这SRAM216KB 0x2001C000同样 DMA 可访问CCM64KB 0x10000000内核零等待但DMA 访问不到以太网内置 MAC支持 MII/RMII需要外接 PHYDP83848 或 LAN8720硬件校验和支持 IP/TCP/UDP 卸载能省不少 CPU最后一行那个 CCM 的限制是后面最容易踩的坑之一。CCM 挂在 D-bus 上CPU 访问它零等待看起来是放栈和高频数据的好地方但以太网 DMA 只能访问 AHB 上的 SRAM1/SRAM2。我见过有人把ETH_DMADescTypeDef数组定义到 CCM 区编译一切正常下载运行后 PHY 链路能起来、MDIO 能读到寄存器但一个包都收不到——因为 DMA 往 CCM 地址写的数据根本到不了。这个坑排查起来非常费时间因为没有任何报错。2. 移植前的地基工程时钟、FPU与内存分区2.1 时钟树必须先在裸机状态下验证FreeRTOS 的configCPU_CLOCK_HZ和后面 LwIP 的sys_now()都依赖实际主频所以我的做法是先把 FreeRTOS 和 LwIP 全部抛开单纯用 HAL 把 168MHz 调出来用 MCO 输出或者翻转 GPIO 测一下波形。F407 的标准配置是HSE 8MHz 晶振经过 PLL 的 M 分频器除以 8 得到 1MHz 的 VCO 输入再乘 336再除以 2 得到 168MHz 的 SYSCLK。对应的 PLL 参数是PLLM8, PLLN336, PLLP2, PLLQ7PLLQ7 出 48MHz给 USB 用。这几个数字必须和你的板子晶振频率对上8MHz 和 25MHz 晶振的 PLLM 完全不同抄错一个就调不起来。这里插一句题外话。搜索引擎里经常能刷到“stm32f407 pa8 vbus typec”这类词条很多人以为是网络相关实际上那多半是 USB Type-C 接口做 VBUS 检测的用法PA8 在这类场景里被当作普通 GPIO 用跟以太网一点关系都没有。F407 上 PA8 真正和网络沾边的用法是MCO1 时钟输出——当你的板子没有给 PHY 单独配 50MHz 有源晶振时可以用 PA8 输出时钟去喂 PHY 的 REF_CLK。不过要注意MCO1 的分频器只能对 HSE、HSI、PLLCLK、PLLI2SCLK 做 1 到 5 分频168MHz 除以 4 是 42MHz凑不出 RMII 要求的 50MHz。真想从 MCO1 出 50MHz得单独配 PLLI2S 出 100MHz 再二分频。所以我的建议是能用板载 50MHz 晶振就别折腾 MCO1省下来的时间够你调十遍网卡驱动了。2.2 FPU 开启的完整链路少一步都不行Cortex-M4F 的 FPU 默认是关的需要通过CPACR寄存器打开。这一步在SystemInit()里通常不会做ST 的默认实现没开所以要在main()最开头自己加/* 放在 main 函数最开始任何浮点运算之前 */ SCB-CPACR | ((3UL 10 * 2) | (3UL 11 * 2)); __DSB(); __ISB();这只是“让 FPU 能用”。要让编译器真正生成 FPU 指令还需要在工程编译选项里打开硬浮点 ABIKeil MDKOptions for Target → Target 页 → Floating Point Hardware 选Single Precision同时确保 C/C 里的__FPU_PRESENT、__FPU_USED宏被正确定义HAL 的头文件一般会自动处理。IAR EWARMProject → Options → General Options → FPU 选VFPv4 single precision并且 Library 里要选对应的浮点库。为什么这两步都要做因为如果只开CPACR而编译选项还是软浮点代码能跑但所有float运算都走软件模拟一次除法几百个周期性能损失非常可观。反过来如果编译选了硬浮点但没开CPACR一执行浮点指令就触发UsageFault然后转成 HardFault你会看到一个莫名其妙的死机。还有一个必须提醒的点FreeRTOS 的 ARM_CM4F 端口在任务切换时PendSV 里会额外保存 FPU 的 S0-S15 和 FPSCR 寄存器。所以你用的端口文件必须是portable/RVDS/ARM_CM4F/port.c或者portable/IAR/ARM_CM4F/port.c不能拿 ARM_CM3 的端口顶上。用错端口的表现是任务里一碰浮点、一发生任务切换浮点上下文就丢失数值出现毫无规律的跳变。2.3 堆区和内存池的边界划分F407 上内存分三块用我的划分方案是这样的FreeRTOS 堆从 SRAM1 划出 48KB用heap_4管理。任务栈、队列、信号量都从这里出。LwIP 内存池独立划出 24KB通过MEM_SIZE定义PBUF_POOL再单独占 16 × 1524 ≈ 24KB。CCM 区优先给高频访问的数据和任务栈里最深的那个比如 TCP/IP 线程但不给任何 DMA 相关的缓冲区。为什么堆方案选 heap_4 而不是 heap_1 或 heap_2heap_1 只分配不释放LwIP 里动态创建关闭 socket 的场景会直接把内存吃完heap_2 不合并相邻空闲块长时间运行必然碎片化heap_4 带相邻空闲块合并还支持void* pvPortMalloc返回可用地址排序是长期运行项目的唯一合理选择。heap_5 适合你有多个不连续内存区比如想同时用 SRAM1 和 CCM的情况配置更灵活但要多写一个vPortDefineHeapRegions。3. FreeRTOS在F407上的落地从源码目录到第一个任务3.1 源码目录裁剪与文件筛选FreeRTOS 的源码包里有大量 Demo 和 port 文件全加进工程既慢又乱。我的裁剪原则是“只留本项目用得到的”FreeRTOS/Source/ ├── croutine.c 协程用不到可以不加 ├── event_groups.c 事件组建议保留 ├── list.c 必须 ├── queue.c 必须信号量和邮箱都基于它 ├── tasks.c 必须 ├── timers.c 软件定时器建议保留 └── portable/ ├── RVDS/ARM_CM4F/ Keil 用这个 │ ├── port.c │ └── portmacro.h ├── IAR/ARM_CM4F/ IAR 用这个 │ ├── port.c │ ├── portasm.s │ └── portmacro.h └── MemMang/ └── heap_4.c再往上是include/目录里面是FreeRTOS.h、task.h、queue.h、semphr.h、timers.h等头文件这些要全部加进头文件搜索路径。一个容易忽略的细节如果你的板子上还跑着 LVGL 之类的 GUI或者用了官方的 CMSIS-RTOS2 封装层注意CMSIS-RTOS2 的osKernelInitialize会在内部调用vTaskStartScheduler之后就不能再返回而 LwIP 的tcpip_init必须在vTaskStartScheduler之前调用完。两者的初始化顺序如果排错表现是调度器起来之后 TCP/IP 线程根本没创建Ping 永远不通。3.2 FreeRTOSConfig.h 逐项配置及背后的理由这份配置文件是整个移植里最需要“讲道理”的地方我把关键项和理由列出来#define configUSE_PREEMPTION 1 #define configUSE_PORT_OPTIMISED_TASK_SELECTION 1 #define configUSE_TICKLESS_IDLE 0 #define configCPU_CLOCK_HZ (168000000UL) #define configTICK_RATE_HZ (1000) #define configMAX_PRIORITIES (32) #define configMINIMAL_STACK_SIZE (128) #define configTOTAL_HEAP_SIZE ((size_t)(48 * 1024)) #define configMAX_TASK_NAME_LEN (16) #define configUSE_16_BIT_TICKS 0 #define configIDLE_SHOULD_YIELD 1 #define configUSE_MUTEXES 1 #define configUSE_RECURSIVE_MUTEXES 1 #define configUSE_COUNTING_SEMAPHORES 1 #define configQUEUE_REGISTRY_SIZE 8 #define configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_MALLOC_FAILED_HOOK 1 #define configUSE_TRACE_FACILITY 1 #define configUSE_TIMERS 1 #define configTIMER_TASK_PRIORITY (2) #define configTIMER_QUEUE_LENGTH (10) #define configTIMER_TASK_STACK_DEPTH (256)几个关键选择的理由tick 设成 1000Hz而不是默认的 100Hz主要是为了配合 LwIP 的sys_now()。LwIP 内部所有超时都以毫秒计TCP 重传的 RTO、ARP 表项老化、DHCP 重试间隔如果 tick 是 100Hzsys_now()的精度就只有 10msTCP 的重传计时会明显偏大弱网环境下表现会很难看。1000Hz 的代价是每秒多一千次中断在 168MHz 的核上这点开销完全可以接受。configMAX_PRIORITIES 给到 32而不是省内存的 7是因为 LwIP 官方推荐把网卡接收任务放在比 TCP/IP 线程高的优先级把应用任务放更低中间还要留出余量给显示、采集这类任务。层级不够的时候你会被迫把两个不相关的任务塞到同一个优先级然后靠taskYIELD手动让路维护成本立刻上升。configCHECK_FOR_STACK_OVERFLOW 设为 2这个值的意思是“既检查栈顶魔数也检查栈指针是否越界”比只设 1 更严格代价是切换开销稍大。配套必须实现vApplicationStackOverflowHook否则溢出时内核会跳到configASSERT直接挂住void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { (void)xTask; taskDISABLE_INTERRUPTS(); /* 把任务名字存下来复位后用调试器看或者直接点灯报警 */ printf(STACK OVERFLOW: %s\r\n, pcTaskName); for (;;) { } }configTICK_RATE_HZ 和 HAL 的时基冲突是另一个必踩的坑。HAL 库的HAL_Delay()和HAL_GetTick()依赖 SysTick 中断而 FreeRTOS 的调度器也要独占 SysTick。两者一起用会出现HAL_Delay(100)瞬间返回或者永久卡死。解决办法有两个一是用 CubeMX 时把 Timebase Source 改成 TIM6 之类的普通定时器让 HAL 走独立时基二是在FreeRTOSConfig.h里重映射并在stm32f4xx_it.c里把SVC_Handler、PendSV_Handler、SysTick_Handler三个函数删掉由 FreeRTOS 的端口实现接管/* FreeRTOSConfig.h */ #define vPortSVCHandler SVC_Handler #define xPortPendSVHandler PendSV_Handler #define xPortSysTickHandler SysTick_Handler注意这三个宏定义和stm32f4xx_it.c里现有的中断函数是“二选一”的关系两边同时存在会编译报重复定义。我一般直接把stm32f4xx_it.c里的这三个函数体注释掉只在文件里留个墓碑注释避免以后有人手贱又加回来。3.3 中断优先级分组一个数值差一位就随机死机Cortex-M4 的 NVIC 有 4 位优先级可以配置成不同的分组方式。FreeRTOS 要求所有调用xxxFromISR的中断其优先级数值必须大于等于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY数值越大优先级越低。配置如下#define configPRIO_BITS 4 #define configLIBRARY_LOWEST_INTERRUPT_PRIORITY 15 #define configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY 5 #define configKERNEL_INTERRUPT_PRIORITY \ (configLIBRARY_LOWEST_INTERRUPT_PRIORITY (8 - configPRIO_BITS)) #define configMAX_SYSCALL_INTERRUPT_PRIORITY \ (configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY (8 - configPRIO_BITS))意思是优先级 0 到 4 的中断绝对不能调用 FreeRTOS 的 API它们不受内核临界区保护优先级 5 到 15 的中断可以安全调用xQueueSendFromISR、xSemaphoreGiveFromISR这类函数。同时NVIC 的分组必须用NVIC_PriorityGroup_4也就是全部 4 位都当抢占优先级用没有子优先级。ST 的早期工程默认是NVIC_PriorityGroup_2如果你没改实际生效的优先级位和 FreeRTOS 的假设就对不上表现是“大部分时候正常偶尔卡死在某个中断里”。以太网中断ETH_IRQn我设在优先级 5正好卡在能调用 FromISR API 的边界上SysTick 和 PendSV 由 FreeRTOS 自己设成最低的 15。3.4 第一个任务与栈溢出检测的实测调度器起来之前先建一个最简单的闪灯任务验证内核跑通void vTaskLed(void *pvParameters) { (void)pvParameters; for (;;) { HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_9); vTaskDelay(pdMS_TO_TICKS(500)); } } int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); xTaskCreate(vTaskLed, LED, 128, NULL, 3, NULL); vTaskStartScheduler(); for (;;) { } }这一步能跑通说明时钟树对、FPU 配置对、端口文件选对、中断优先级分组对。如果灯闪了但节奏不对比如慢了 15 倍基本就是configCPU_CLOCK_HZ和实际主频不一致。堆栈溢出检测想真正起作用得配合一个反例。我习惯故意把某个任务的栈开到极小比如 64 字然后在里面调snprintf拼字符串触发溢出确认vApplicationStackOverflowHook真的被调用了。这个测试做过一次以后所有任务的栈大小你都会有概念。跑起来之后用vTaskList和xPortGetFreeHeapSize看一眼运行状态char buf[512]; vTaskList(buf); /* 需要 configUSE_TRACE_FACILITY 1 */ printf(%s\r\n, buf); printf(free heap: %u\r\n, (unsigned)xPortGetFreeHeapSize());vTaskList输出里有个Stack列是任务栈的历史最小剩余量high water mark单位是字。这个数字比什么估算都准——如果你看到某个任务只剩 8那基本就是运气好才没溢出赶紧加栈。4. LwIP协议栈接入MAC、PHY与网卡驱动4.1 LwIP 源码结构与最小接入集合LwIP 2.x 的源码目录很清晰但把整个src全加进工程会引入一堆用不到的文件。我实际接入的是这些lwip/src/ ├── core/ ip.c, ip4.c, icmp.c, udp.c, tcp.c, tcp_in.c, tcp_out.c, │ arp.c, etharp.c, dhcp.c, mem.c, memp.c, netif.c, │ pbuf.c, raw.c, stats.c, sys.c, timeout.c, inet_chksum.c ├── api/ api_lib.c, api_msg.c, err.c, netbuf.c, netdb.c, │ sockets.c, tcpip.c ├── netif/ ethernet.c ── apps/ 按需比如 httpd、mqttnetif/ethernet.c提供的是 ARP 层的通用处理真正的网卡驱动要你自己写通常叫ethernetif.c里面必须实现low_level_init、low_level_output、low_level_input三个函数再加上一个ethernetif_input用来把收到的包递交给协议栈。4.2 lwipopts.h 的关键参数与取值逻辑这份配置文件直接决定内存占用和吞吐性能我把自己调过的值列出来参数取值说明NO_SYS0带 OS 模式LWIP_NETCONN1开启 netconn APILWIP_SOCKET1开启 socket APIMEM_SIZE16 × 1024堆内存TCP 发送缓存从这里出MEMP_NUM_PBUF16pbuf 结构体数量PBUF_POOL_SIZE16接收缓冲池大小PBUF_POOL_BUFSIZE1524必须能装下一个完整以太帧MEMP_NUM_TCP_PCB8并发 TCP 连接数MEMP_NUM_TCP_SEG32发送段队列深度LWIP_TCP1TCP_MSS1460标准以太网 MSSTCP_WND6 × 1460接收窗口越大吞吐越高TCP_SND_BUF4 × 1460不小于 TCP_WND 的一半比较合理LWIP_DHCP1需要动态获取 IP 时开LWIP_ICMP1Ping 要用LWIP_NETIF_TX_SINGLE_PBUF1STM32 ETH DMA 只能处理单缓冲CHECKSUM_BY_HARDWARE1交给 MAC 做校验和LWIP_NETIF_LINK_CALLBACK1链路状态变化回调LWIP_STATS0调试时开正式版关掉省空间PBUF_POOL_BUFSIZE给 1524 而不是常见的 1518是留出对齐余量的。STM32 的 ETH DMA 描述符要求缓冲区地址 4 字节对齐如果 pbuf 的有效负载起始地址不是 4 字节对齐DMA 收发会出问题。LwIP 的PBUF_LINK_ENCAPSULATION_HLEN和对齐宏LWIP_PBUF_ALIGN相关就是干这个的。TCP_WND和TCP_SND_BUF这两个值别照着默认值抄。LwIP 默认TCP_WND 2 * TCP_MSS也就是不到 3KB。在千兆网络里这无所谓但在百兆、RTT 大约 1ms 的局域网里窗口太小会直接限制吞吐——带宽时延积大约是 100Mbps × 1ms ≈ 12.5KB窗口至少要覆盖这个量级才能把链路跑满。我实测把TCP_WND从 2×MSS 调到 6×MSS同一份代码的 iperf 结果从 22Mbps 提升到 47Mbps。4.3以太网外设初始化与 DMA 描述符环F407 的以太网 DMA 用的是描述符环结构收发各一组。描述符数组必须放在 SRAM1且 4 字节对齐#if defined ( __ICCARM__ ) #pragma location 0x20000000 ETH_DMADescTypeDef DMARxDscrTab[ETH_RXBUFNB]; ETH_DMADescTypeDef DMATxDscrTab[ETH_TXBUFNB]; #elif defined ( __CC_ARM ) __attribute__((at(0x20000000))) ETH_DMADescTypeDef DMARxDscrTab[ETH_RXBUFNB]; __attribute__((at(0x20000000 4 * ETH_RXBUFNB))) ETH_DMADescTypeDef DMATxDscrTab[ETH_TXBUFNB]; #else __attribute__((section(.RxDecripSection))) ETH_DMADescTypeDef DMARxDscrTab[ETH_RXBUFNB]; __attribute__((section(.TxDecripSection))) ETH_DMADescTypeDef DMATxDscrTab[ETH_TXBUFNB]; #endif这里用at或者section强制定位比靠链接脚本赌运气稳妥得多。缓冲区数量ETH_RXBUFNB和ETH_TXBUFNB我一般都给 4配合 LwIP 的PBUF_POOL_SIZE 16接收侧不会因为描述符耗尽丢包。RMII 模式下 F407 的引脚映射是固定的对照下面这张表确认硬件连接引脚功能说明PA1REF_CLKRMII 参考时钟 50MHzPA2MDIOPHY 管理数据PA7CRS_DV载波侦听PC1MDCPHY 管理时钟PC4RXD0接收数据 0PC5RXD1接收数据 1PB11TX_EN发送使能PB12TXD0发送数据 0PB13TXD1发送数据 1PA0IREF可选外部电阻参考HAL_ETH_Init之前有一个容易被忽略的动作复用功能配置。用 CubeMX 生成的话它会帮你配好 GPIO 的Alternate模式、GPIO_SPEED_FREQ_VERY_HIGH、以及 AF11。手工配的话漏掉GPIO_SPEED_FREQ_VERY_HIGH会导致 50MHz 的信号上升沿不够陡MDIO 读寄存器偶尔出错这种间歇性故障最难查。4.4 PHY 芯片识别与自协商DP83848 与 LAN8720 的差异DP83848 和 LAN8720 是最常见的两颗 RMII PHY两者的主要差异在 PHY 地址和寄存器细节上。DP83848默认 PHY 地址是0x01需要 50MHz 时钟接到 X1 引脚。LAN8720地址由PHYAD0引脚在上电时锁存通常配置为0x00。程序里读BSRBasic Status Register地址0x01的 bit2 可以判断链路是否 upbit5 表示自协商完成uint32_t phyReg 0; /* PHY 地址 0x01寄存器 0x01 */ HAL_ETH_ReadPHYRegister(heth, 0x01, 0x01, phyReg); if ((phyReg 0x0004) (phyReg 0x0020)) { /* 链路 up 且自协商完成 */ }判断出链路 up 之后还要读一下PHYSR地址0x10看协商结果的速度和双工模式。如果协商出来是半双工LwIP 会明显变慢并且容易丢包因为半双工下有冲突检测和退避百兆半双工的实测吞吐通常只有全双工的三分之一。我在一块板子上遇到过 PHY 默认配置成半双工的情况改了下 PHY 寄存器才恢复正常。DP83848 还有一个坑它的 RMII 模式需要通过RBR寄存器扩展寄存器地址0x17的 bit5 来切换。如果这个位没配好PHY 会按 MII 模式工作表现是 STM32 侧完全收不到数据。这个位一般通过板子的自举电阻在上电时确定但有些板子设计得不够严谨需要在驱动里手动补一刀。4.5 收发函数与中断到任务的传递链路low_level_output的核心是把 LwIP 的 pbuf 链拷进 DMA 发送缓冲static err_t low_level_output(struct netif *netif, struct pbuf *p) { err_t errval ERR_OK; struct pbuf *q; uint8_t *buffer (uint8_t *)Tx_Buff; /* 等上一次发送完成超时 100ms */ if (osSemaphoreWait(TxPktSemaphore, 100) ! osOK) { return ERR_TIMEOUT; } for (q p; q ! NULL; q q-next) { memcpy(buffer, q-payload, q-len); buffer q-len; } if (HAL_ETH_TransmitFrame(heth, p-tot_len) ! HAL_OK) { return ERR_IF; } /* 不用等发送完成中断里会释放信号量 */ return errval; }发送完成信号量必须在HAL_ETH_TxCpltCallback里释放void HAL_ETH_TxCpltCallback(ETH_HandleTypeDef *heth) { osSemaphoreRelease(TxPktSemaphore); }接收侧有两种常见写法一是在以太网中断里直接调用ethernetif_input不推荐会拉长中断时间二是中断里只释放一个信号量由独立的接收任务去轮询描述符环然后调ethernetif_input。我选后者接收任务优先级设为 5比 TCP/IP 线程的 4 高一级这样消息邮箱不会被填满。这里如果优先级反了表现是 Ping 通但延迟抖动很大跑吞吐测试时丢包率飙升因为 TCP/IP 线程处理不过来的消息只能丢。5. sys_arch移植连接FreeRTOS与LwIP的那层胶水5.1 sys_arch 必须实现的函数清单sys_arch.c是 LwIP 和 RTOS 之间的适配层需要实现的函数不多但每一个都不能漏函数作用映射到 FreeRTOSsys_mbox_new创建邮箱xQueueCreatevoid* 队列sys_mbox_post阻塞投递xQueueSend(portMAX_DELAY)sys_mbox_trypost非阻塞投递xQueueSend(0)sys_arch_mbox_fetch带超时接收xQueueReceive 超时换算sys_sem_new创建信号量xSemaphoreCreateCountingsys_arch_sem_wait带超时等待xSemaphoreTake 超时换算sys_mutex_new / lock / unlock互斥量xSemaphoreCreateMutexsys_arch_protect / unprotect短临界区taskENTER_CRITICALsys_now毫秒时间戳xTaskGetTickCount() 换算sys_thread_new创建线程xTaskCreate5.2 超时换算与 sys_now 的隐藏陷阱sys_arch_mbox_fetch和sys_arch_sem_wait的返回值是实际等待的毫秒数而这个函数的timeout参数也是毫秒。内部要转成 ticku32_t sys_arch_sem_wait(sys_sem_t *sem, u32_t timeout) { TickType_t waitTicks; TimeOut_t startTime; if (timeout 0) { waitTicks portMAX_DELAY; } else { waitTicks timeout / portTICK_PERIOD_MS; if (waitTicks 0) { waitTicks 1; /* 非零超时不能退化成永久等待 */ } } /* 记录起始 tick返回时计算实际耗时 */ ... }这里有个特别容易忽略的点timeout不为 0 但换算成 tick 后是 0 的情况。如果 tick 是 1000Hz1 到 0 毫秒不涉及但如果 tick 换成 100Hztimeout 5换算下来就是 0直接变成永久阻塞整个协议栈的超时机制全部失效。所以那个if (waitTicks 0) waitTicks 1;的保护是必须的。sys_now()的实现更关键u32_t sys_now(void) { return (u32_t)(xTaskGetTickCount() * portTICK_PERIOD_MS); }如果 tick 是 1000HzportTICK_PERIOD_MS就是 1直接返回xTaskGetTickCount()即可。但如果 tick 是 100Hz这里就是乘以 10精度 10ms。LwIP 里所有超时都依赖这个函数如果它返回的值不单调递增比如在某个时刻被 HAL 的 tick 干扰TCP 的重传定时器会疯掉表现为连接莫名其妙断开。还有一个细节sys_now()在tcpip_init()之前就可能被调用LwIP 内部初始化时会调此时调度器可能还没起来xTaskGetTickCount()返回 0这没问题但千万别在里面调用会阻塞的 API。5.3 Raw API 与 Socket API 的实测取舍LwIP 提供三套编程接口Raw API回调式、netconn API阻塞式、socket APIBSD 风格。在带 OS 模式下三者都能用。Raw API 必须跑在 TCP/IP 线程的上下文里通过tcpip_callback投递写起来最绕但最省内存没有额外的消息开销。Socket API 最好写代码跟 PC 上的网络编程几乎一样但每一层调用都会往 TCP/IP 线程的邮箱里塞一个消息开销最大。我的实测对比同一块 F407跑 TCP 回环收发接口代码量吞吐1.5KB 数据块RAM 占用Raw API中51 Mbps最低netconn API少46 Mbps中socket API最少43 Mbps最高差距不算大都在 20% 以内。所以我的建议是如果只是做几个 Modbus TCP 或 MQTT 连接直接上 socket API开发效率带来的收益远大于那 8Mbps 的性能损失。只有当你要做局域网内的高吞吐数据采集、或者 RAM 实在紧张的时候才考虑退到 netconn 或 Raw。另外提醒一句socket API的select()在 LwIP 里的实现和 PC 上不完全一样不能跨 socket 集合做混合等待多连接场景要留意。6. 联调排错Ping不通到丢包的完整排查链路6.1 Ping 不通的六层逐级排查法Ping 不通是个笼统的现象我按从物理到协议栈的顺序一层层往上排时钟层用示波器量 PA1 上的 50MHz 参考时钟。没有波形后面全白搭。注意用探头测的时候要选 10:1 衰减直接测容易因为探头电容把时钟拉偏。链路层读 PHY 的 BSR 寄存器确认 bit2 是 1。如果是 0检查网线、交换机、PHY 供电、自举电阻配置。MAC 层在HAL_ETH_Init之后读ETH-MACMIIAR和ETH-DMASR确认 MAC 已经进入工作状态DMA 的TS和RS位是 1。netif 层确认netif_is_link_up(gnetif)返回真且netif_set_up被调用过。这一步经常被漏——netif_add之后如果没调netif_set_up协议栈根本不会发 ARP 请求。IP 层确认 IP 地址没有冲突掩码和网关正确。用 Wireshark 抓包看 PC 是否收到了 ARP 请求以及 F407 是否回了 ARP 响应。应用层确认LWIP_ICMP 1且CHECKSUM_GEN_ICMP或硬件校验和配置正确。第 5 层最关键。我遇到过一次整整两天排查不出来的情况最终发现是CHECKSUM_BY_HARDWARE设成了 1但 MAC 配置里没有真正使能校验和卸载导致发出去的 ICMP 包校验和是错的。Wireshark 里每一行都是红色的Bad checksum。6.2 小包风暴下的 pbuf 耗尽跑通 Ping 之后做压力测试问题往往才浮现。我用一个 Python 脚本连续发大量 64 字节的 UDP 包跑几分钟后发现设备停止响应但没过一会儿又自己恢复了。原因在于pbuf 池被耗尽。每个发出去的包对应一个 UDP PCB每个 PCB 又要占一个 pbuf 用来做缓冲。MEMP_NUM_PBUF 16在这种小包高频率的场景下很快就被吃满。判断方法很简单在sys_now()旁边挂一个打印周期性输出memp_get_stats()看PBUF和UDP_PCB的used和max值。如果max一直贴着上限那就是池子不够或者有泄漏。修复方式有两个方向一是把MEMP_NUM_PBUF提到 32二是检查应用层有没有忘记释放 netbuf。用 netconn 或 socket 时recv拿到的数据包在netbuf_delete或freertos_recv之后才会释放如果代码里出现了提前break跳出循环又不释放泄漏就发生了。我建议在开发阶段把LWIP_STATS和MEMP_STATS打开定期打印一旦发现数值缓慢爬升就是泄漏。6.3 优先级配置错误导致的三类怪现象前面提过优先级这里把三种典型症状归一下类方便对号入座现象可能原因修正Ping 通但延迟抖动大接收任务优先级低于 TCP/IP 线程把接收任务优先级调到比 TCP/IP 线程高一级高流量下频繁丢包以太网中断优先级低于 configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITYETH_IRQn 优先级设为 5运行几小时后卡死中断里调用了非 FromISR 版本的 API检查所有 ISR 里的 API 后缀第二种情况尤其隐蔽。如果你把ETH_IRQn的优先级设成 3在中断里调用了xSemaphoreGiveFromISR内核在进入临界区时没法屏蔽这个中断队列的链表操作可能被中断打断一半结果就是链表指针错乱最后 HardFault。这种 bug 通常要跑很久才出现一次靠日志根本抓不到只能靠配置纪律。6.4 HardFault 定位从 LR 和栈帧反推现场一旦进了 HardFault 且没开任何调试信息最有效的办法是在 HardFault_Handler 里读堆栈帧void HardFault_Handler(void) { __asm volatile ( tst lr, #4 \n ite eq \n mrseq r0, msp \n mrsne r0, psp \n b hard_fault_report \n ); } void hard_fault_report(uint32_t *stack) { volatile uint32_t r0 stack[0]; volatile uint32_t r1 stack[1]; volatile uint32_t r2 stack[2]; volatile uint32_t r3 stack[3]; volatile uint32_t r12 stack[4]; volatile uint32_t lr stack[5]; volatile uint32_t pc stack[6]; volatile uint32_t psr stack[7]; printf(R0%08lx R1%08lx R2%08lx R3%08lx\r\n, r0, r1, r2, r3); printf(R12%08lx LR%08lx PC%08lx PSR%08lx\r\n, r12, lr, pc, psr); for (;;) { } }把PC的值丢进反汇编窗口里查就能定位到具体哪条指令炸了。我遇到的几次 HardFaultPC 都指向 LwIP 里的memcpy或者 pbuf 操作相关代码原因基本是 pbuf 已经被释放但指针还在被使用use-after-free。这类问题的根因往往在应用层——某个 socket 关闭之后另一个任务还在往它的 netbuf 里写数据。还有一个和 Cortex-M4 特性相关的坑-O2优化下编译器可能对 packed 结构体的成员生成LDRD指令而 Cortex-M4 的LDRD要求 8 字节对齐对非对齐地址执行会直接 HardFault。LwIP 的头文件里用PACK_STRUCT标记了很多协议头结构以太头、IP 头、TCP 头这些结构体成员访问都比较危险。规避办法是PACK_STRUCT_USE_INCLUDES 1加上-fno-strict-aliasing或者在 IAR 里关闭对应的优化选项。这个问题在 F407 上存在在 F7/H7 上因为带了数据缓存反而有别的处理方式不能照搬。7. 实测数据与可复用的工程模板建议7.1 吞吐、CPU 占用与内存水位最后把我的实测数据放出来供你对照自己的工程。测试条件F407 168MHzDP83848 RMII全双工百兆iperf 对端是 PC。指标实测值说明UDP 发送吞吐82 Mbps送 1472 字节包UDP 接收吞吐68 MbpsTCP 发送吞吐47 Mbps窗口 6×MSSTCP 接收吞吐41 MbpsPing 平均 RTT0.4 ms局域网空闲时 CPU 占用3%只有 TCP/IP 线程和接收任务在跑满载 CPU 占用约 70%UDP 满速收发FreeRTOS 堆剩余12KB建了 6 个任务之后LwIP 内存池剩余约 4KB空闲态那个 70% 的满载 CPU 占用是 UDP 场景下的TCP 因为要做确认和流控同一速率下占用会更高。如果 CPU 占用逼近 90%得考虑开硬件校验和卸载、把PBUF_POOL_BUFSIZE调大减少分片、或者把一些任务的时间片让出来。内存水位的监控建议长期挂着。做法是开一个最低优先级的任务每 10 秒打印一次xPortGetFreeHeapSize()、xPortGetMinimumEverFreeHeapSize()和memp_get_stats()的关键项。这三个数字如果在长时间运行后保持稳定说明没有泄漏如果单调下降就要查。7.2 后续可扩展的方向工程跑通之后扩展路径其实挺清晰的。加 OTA 升级F407 的 Flash 有 1MB切成 bootloader 和 app 两个区用 LwIP 的 HTTP 客户端或者 MQTT 从服务端拉固件。传输的时候用双缓冲一边收一边往 Flash 写注意 Flash 擦写会阻塞总线写 Flash 的那几毫秒里 CPU 取指会停如果此时有高优先级中断进来响应会被推迟。所以 OTA 任务要放低优先级而且写 Flash 之前要先把以太网接收暂停一下避免丢包。接 4G 模组做远程回传常见的做法是用 AT 指令加 PPP或者直接用模组的 TCP/IP 透传模式加串口。前者需要 LwIP 支持 PPP 协议配置PPP_SUPPORT和串口驱动工程复杂度会明显上升后者把模组当做一个串口设备应用层直接用串口收发简单很多但灵活性差。两条路我都试过如果你的数据量不大、不需要多连接优先选透传模式能省掉大量的调试时间。加 LVGL 显示LVGL 需要定时刷新通常lv_tick_inc1ms 一次、lv_task_handler5ms 一次可以单独起一个任务优先级放在网络任务之下。要注意的是 LVGL 的绘制会占用大量栈和显存一般要给它单独划一块 SRAM 或者外扩 SDRAM。另外lv_tick_inc可以直接在vApplicationTickHook里调避免再开一个定时器。多任务协同下的数据传递任务之间传字符串这类需求最省事的做法是传指针而不是复制整个缓冲区/* 发送方 */ char *msg pvPortMalloc(64); strcpy(msg, hello); xQueueSend(xMsgQueue, msg, portMAX_DELAY); /* 接收方 */ char *rx; xQueueReceive(xMsgQueue, rx, portMAX_DELAY); printf(%s\r\n, rx); vPortFree(rx);关键在于谁分配谁释放的约定。如果发送方分配、接收方释放队列里就必须保证不丢消息用portMAX_DELAY否则消息丢了指针也丢了内存立刻泄漏。我个人更倾向于在队列里直接传定长结构体虽然多占点内存但没有任何释放责任的问题长期运行更省心。最后分享一个小技巧关于 FreeRTOS 堆栈溢出检测的实战用法。configCHECK_FOR_STACK_OVERFLOW 2只在任务切换时检查所以如果某个任务在两次切换之间就把栈冲穿了它是抓不到的。我的做法是把关键任务的栈底部预填魔数比如0xDEADBEEF在vApplicationIdleHook里定期扫一遍一旦魔数被改写就立刻报警。这个手段配合 FreeRTOS 自带的检测基本上能做到栈溢出必被发现。另一个长期受益的习惯是从第一天就把vTaskList打印做成一个常驻的低优先级任务默认关闭通过串口输入一个字符打开。查问题时不用重新编译烧录直接敲命令看当前所有任务的状态、优先级、栈水位和运行时间占比很多“莫名其妙卡死”的问题一眼就能看出是哪两个任务在互相抢。这套东西搭起来只花了不到一个小时但在后面半年里帮我省下的时间远远不止。
返回列表