STM32F407 上 FreeRTOS 与 LwIP 以太网稳定联调与排错
2026/9/18 5:16:01 网站建设 项目流程

板子刚焊好、网口灯也亮着,裸机程序里ping一下就通,结果一把 FreeRTOS 挂上去,网口直接哑火,甚至开机就跳 HardFault——这个场景我在 F407 上遇到过不止一次。STM32F407 这颗 Cortex-M4F 芯片本身条件不错:168MHz 主频、带硬件浮点、192KB SRAM、1MB Flash,片内还集成了以太网 MAC 和专用 DMA,理论上跑实时操作系统加 LwIP 协议栈完全够用。但"够用"和"跑得稳"之间隔着一整套中断优先级、堆内存、DMA 描述符对齐和 PHY 时序的坑,任何一个没对齐,现象都可能是"灯亮但 ping 不通"这种最磨人的状态。

这篇内容就是把这套组合拳从头到尾捋一遍:FreeRTOS 的 port 层到底改哪几个文件、FreeRTOSConfig.h里哪些宏是生死线、LwIP 的lwipopts.h怎么配才不至于收发几个包就卡死、PHY 芯片地址和 RMII 时钟怎么确认、以及联调阶段用什么样的排查顺序能最快定位问题。适合已经能点亮 LED、写过串口收发,准备把网络和 RTOS 一起用起来的人;也适合已经在跑但稳定性不理想、想搞清楚底层到底发生了什么的人。下面所有配置我都以 STM32F407 + RMII 接口 + PHY(DP83848 或 LAN8720 这类常见型号)+ Keil/IAR 为主线来讲,其他 PHY 只需替换时序细节。

1. F407 上加 FreeRTOS 与 LwIP,真正难的地方在哪

1.1 先看清 F407 的硬件底子与资源天花板

很多人移植失败,根源不在代码,而在于一开始对芯片资源的估算就是错的。STM32F407 的 192KB SRAM 并不是一整块连续空间:它由 112KB + 16KB 的 SRAM1、64KB 的 SRAM2 组成,地址上 SRAM1 从 0x20000000 开始,SRAM2 在 0x2001C000 附近,中间还夹着一段保留区。这意味着如果你指望把一个大数组或者一块巨型堆直接放在"SRAM 里",编译器很可能会因为跨越不连续区域而报错或者悄悄截断。

以太网部分,F407 的 MAC 控制器支持 MII 和 RMII 两种接口。引脚数量上 RMII 只需要 7 根信号线加一根参考时钟,比 MII 省一半引脚,所以实际项目里绝大多数选 RMII。RMII 的参考时钟必须是 50MHz,这个时钟有两个来源:一是外部 PHY 提供、从 PA1 引脚输入;二是由 MCU 的 MCO1 引脚输出 50MHz 送给 PHY。两种方案各有代价,前者要求 PHY 侧有 50MHz 晶振或者能分频输出,后者占用一个引脚并且对 MCO 的时钟源配置有要求。

资源分配这块,我一般按这个比例切:FreeRTOS 堆configTOTAL_HEAP_SIZE给 32KB,LwIP 自己的MEM_SIZE给 16KB,pbuf 池给 16KB 左右,其余留给各个任务栈和全局缓冲。注意 FreeRTOS 堆和 LwIP 内存池是两套完全独立的分配器,一个用pvPortMalloc,一个用mem_malloc,两者不能互相释放,这一点在排查内存相关故障时非常关键。

1.2 双栈协同的第一次冲突:中断优先级分组

FreeRTOS 在 Cortex-M 上实现临界区的方式,是通过写BASEPRI寄存器屏蔽掉低于某个优先级的中断。具体来说,configMAX_SYSCALL_INTERRUPT_PRIORITY决定了一个阈值,所有优先级数值大于等于这个阈值的中断都会被BASEPRI挡住,而优先级数值小于它的中断可以照常打断内核。这套机制要求所有会调用 FreeRTOS API 的中断,其优先级数值都必须在阈值之内。

问题就出在这里:STM32 的 NVIC 支持优先级分组,默认复位后是分组 0(也就是高 4 位全做抢占优先级,低 4 位做子优先级,实际 STM32 只实现了 4 位优先级)。如果你在别处调用了HAL_NVIC_SetPriorityGrouping设成别的分组,那么"优先级数值"的含义就变了,FreeRTOS 的屏蔽逻辑会直接失效,典型现象是内核调度时随机死机或者进 HardFault。

我个人的做法是:工程初始化最开头就固定调用NVIC_PriorityGroupConfig(NVIC_PriorityGroup_4),让 4 位全部作为抢占优先级,不做子优先级。这样优先级数值和 FreeRTOS 的判断逻辑就是一一对应的,不会出现歧义。

1.3 以太网中断优先级为什么必须低于内核阈值

以太网接收中断会调用xSemaphoreGiveFromISR或者 LwIP 的收发函数,这就属于"会调用内核 API 的中断",它的优先级数值必须大于等于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY。假设阈值设为 5,那么 ETH 中断的抢占优先级只能设成 5 到 15 之间的值。

反过来,如果你把 ETH 中断优先级设成 2,看起来响应更快,实际上它不受BASEPRI屏蔽,一旦在 FreeRTOS 操作链表的过程中打断内核,链表指针就可能被改坏,表现就是偶发的、极难复现的死机。这类问题用调试器很难抓,因为断点一停下来时序就变了,只能靠日志和逻辑推理。

2. 工程目录与文件分组:把地基铺平再动手

2.1 FreeRTOS、LwIP、BSP 三个目录的边界怎么划

我见过太多工程把 FreeRTOS 官方源码、LwIP 官方源码和 HAL 库全部塞在一个Src文件夹里,编译一次几百个源文件,出问题根本不知道是谁的问题。比较合理的做法是分成三类目录,各自职责清楚:

  • 第三方源码目录FreeRTOS/Source(含portable)和lwip/src,这部分尽量保持官方原样,只动portableinclude/lwipopts.h,方便以后升级版本时对比差异。
  • 适配层目录:放ethernetif.csys_arch.carch/下的cc.hsys_arch.h。这是移植时改动最多的地方,也是唯一需要你深度定制的地方。
  • 业务与驱动目录:BSP 层的 PHY 驱动、LED、串口,以及你的应用任务。

这样分的好处是,当你怀疑是协议栈自身问题时,可以快速把适配层单独拉出来做裸机验证,而不至于在一片文件里翻找。另外,lwipopts.h建议放在工程自己的 include 路径里,而不是直接改官方lwip/src/include/lwip/lwipopts.h,避免升级时覆盖。

2.2 Keil 和 IAR 下添加文件的方式差异

Keil 用 Manage Project Items 建 Group,把文件按目录加进去,注意portable目录下只需要选RVDS(对应 ARM Compiler)或者GCC里的一个,不要全加,否则会出现重复定义的vPortSVCHandler之类的符号冲突。头文件路径要在 C/C++ 的 Include Paths 里逐个加,FreeRTOS/includeFreeRTOS/portable/RVDS/ARM_CM4Flwip/src/includelwip/arch这几个是必须的。

IAR 是在 Project 的 Add Group 里操作,原理类似,但要注意 IAR 对头文件路径大小写敏感度在某些版本上不一样,路径写错会报找不到FreeRTOS.h。另外 IAR 的 ARM 编译器对某些 GCC 扩展语法支持不同,比如__attribute__((packed))在 IAR 里要换成#pragma pack或者 IAR 自己的__packed关键字,ethernetif.c里定义描述符结构时要留意。

有个细节我踩过:Keil 默认的 ARM Compiler 5 和 ARM Compiler 6 对 FreeRTOS port 的选择不一样,AC5 用RVDS/ARM_CM4F,AC6 用GCC/ARM_CM4F(因为 AC6 基于 Clang)。选错了会在链接阶段报一堆__use_no_semihosting或者内联汇编语法错误。

3. FreeRTOS 这层,port 文件到底动了什么

3.1 port.c 与 portmacro.h 里真正需要改的只有几处

Cortex-M4F 的移植文件 FreeRTOS 官方已经写好,正常情况下你一行都不用改。真正需要你处理的只有三件事:

第一是中断向量表的归属。FreeRTOS 的port.c里定义了vPortSVCHandlerxPortPendSVHandlerxPortSysTickHandler三个函数,而 STM32 的启动文件里默认有SVC_HandlerPendSV_HandlerSysTick_Handler。你需要在stm32f4xx_it.c里把这三个 ISR 改成调用 FreeRTOS 的对应函数,比如:

void SVC_Handler(void) { vPortSVCHandler(); } void PendSV_Handler(void) { xPortPendSVHandler(); } void SysTick_Handler(void) { xPortSysTickHandler(); }

第二是FreeRTOSConfig.h里的宏,下一节细说。第三是 FPU 相关,写在 3.3 节。

portmacro.h里一般不需要改,但如果你用了不常见的内核版本,要确认portSTACK_TYPEportBASE_TYPE的定义和编译器宽度一致。在 32 位 ARM 上都是 32 位,通常没问题。

3.2 FreeRTOSConfig.h 里最要命的几个宏

这个文件是整个移植的中枢,配错一个就可能是"看起来能跑,跑一会儿就崩"。下面这张表是我实际项目里反复验证过的取值,仅供参考,具体数值要按你的时钟和需求调:

宏名建议取值作用与坑点
configCPU_CLOCK_HZ168000000必须是内核实际跑的主频,不是晶振频率
configTICK_RATE_HZ10001ms 一个 tick,配合 SysTick 中断
configMAX_PRIORITIES8 或以上数值太小会导致 LwIP 任务优先级不够用
configMINIMAL_STACK_SIZE128单位是字,不是字节,换算是 512 字节
configTOTAL_HEAP_SIZE32*1024单位字节,需与 SRAM 分配一起规划
configMAX_SYSCALL_INTERRUPT_PRIORITY5 << (8-4)即 0x50,配合 4 位优先级实现
configKERNEL_INTERRUPT_PRIORITY15 << (8-4)即 0xF0,内核最低优先级
configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY5逻辑优先级,给用户代码用
configPRIO_BITS4STM32 只实现了 4 位优先级
configUSE_PREEMPTION1抢占式调度,LwIP 场景建议开
configUSE_TIME_SLICING1同优先级时间片轮转

这里要特别说configPRIO_BITS。很多人写 8,因为看到configMAX_SYSCALL_INTERRUPT_PRIORITY是 8 位寄存器。但 STM32 的 NVIC 实际只实现了高 4 位,低 4 位读回来是 0,如果写成 8,左移计算出来的值就会错位,屏蔽阈值直接失效。判断方法很简单:写一个值进去再读出来,看低 4 位是不是被硬件忽略了。

3.3 开启 FPU 之后的 lazy stacking 与寄存器保存

F407 带单精度硬件浮点,只要在工程里定义了__FPU_PRESENT__FPU_USED,并且在FreeRTOSConfig.h里配置好,port.c 的vPortEnableVFP就会把 CPACR 寄存器的 CP10/CP11 位打开。这一步不做的话,任务里用float运算就会触发 UsageFault。

真正容易被忽略的是懒加载(lazy stacking)。Cortex-M4 在异常进入时并不是立刻把 FPU 寄存器组压栈,而是先标记状态,等到任务真的用到浮点指令时才补压栈。这个机制本身没问题,但如果你在中断里也用了浮点运算,而中断优先级设置又和系统不匹配,就可能出现 FPU 上下文丢失,表现为浮点结果莫名奇妙变成随机值。

我的经验是:中断服务函数里尽量不要做浮点运算,需要的话把原始数据丢给任务去算。如果非要在 ISR 里算,请确认该中断的优先级在 FreeRTOS 屏蔽阈值之内,否则异常嵌套时 FPU 状态会乱。

另外configUSE_TASK_FPU_SUPPORT这个宏只在部分版本存在,如果你的内核版本没有,不用管,port.c 的默认行为就够用。

3.4 heap_4 与那 192KB SRAM 的分配账

FreeRTOS 提供了五种堆方案,从纯静态到可合并空闲块。网络场景我强烈建议用heap_4,因为它支持相邻空闲内存块合并,能显著降低长时间运行后的碎片化。heap_1不能释放,LwIP 场景下任务创建销毁肯定会用到释放;heap_2支持释放但不合并,跑几小时后碎片化严重;heap_5适合多块不连续 RAM,F407 如果要把 SRAM1 和 SRAM2 合并管理才需要它。

分配账这样算:configTOTAL_HEAP_SIZE设成 32KB,意味着链接器必须在 SRAM 里预留这 32KB 的ucHeap数组。加上 LwIP 的MEM_SIZE16KB、pbuf 池 16KB、以太网 DMA 收发缓冲各 4KB 左右,再算上主任务栈、TCPIP 任务栈(2KB)、空闲任务栈(512B),基本就把 112KB 的 SRAM1 用掉大半。剩下的 SRAM2 可以留给大数据缓冲。

还有个坑:以太网 DMA 描述符和收发缓冲对地址对齐有要求,描述符需要 4 字节对齐,缓冲推荐 32 字节对齐。如果这些数组分散在堆里,编译器可能会把它们放到不对齐的位置,导致 DMA 数据错位。稳妥的做法是显式指定段或者用__attribute__((aligned(32)))

4. LwIP 这块,从 lwipopts.h 到 PHY 链路

4.1 lwipopts.h 里决定收发成败的参数

LwIP 有几百个配置项,大部分用默认值就行,但有几个必须按你的场景改,改错就是收发几个包之后就卡住:

  • NO_SYS:要配合 FreeRTOS 用,必须设成 0。
  • LWIP_NETCONNLWIP_SOCKET:想用 socket 风格 API 就都开成 1,只想用 raw API 可以关掉省内存。
  • MEM_SIZE:LwIP 自己堆的大小,网络吞吐大时给 16KB 起步,数据量大的应用要往上加。
  • PBUF_POOL_SIZEPBUF_POOL_BUFSIZE:每个 pbuf 一个条目,缓冲大小要能装下最大帧长。以太网 MTU 1500 加各种头部,设成 1524 比较稳。
  • PBUF_LINK_ENCAPSULATION_HLEN:如果用了带 VLAN 或特殊封装的链路,要留出头部空间,否则pbuf_alloc时空间不够会失败。
  • TCP_MSSTCP_SND_BUFTCP_WND:这三个决定 TCP 吞吐。MSS 一般 1460,SND_BUF 和 WND 给4 * TCP_MSS是比较通用的起点。
  • TCPIP_THREAD_STACKSIZE:tcpip 线程栈,1024 字(4KB)是常用值,开了 socket 后建议 2048。
  • CHECKSUM_BY_HARDWARE:F407 的 MAC 支持硬件校验和,开了能省 CPU,但要确认收包侧也正确处理。

我之前遇到过一种情况:PBUF_POOL_SIZE设成 8,短连接小数据量完全没问题,一旦做连续大包传输就断流。原因就是 pbuf 池被耗尽,收包时分配不到缓冲,包直接被丢,TCP 层触发重传,看起来就像"网络时通时断"。把池子加到 16 再测就正常了。

4.2 ethernetif.c 的中断收包链路怎么搭

ethernetif.c是 LwIP 和网卡驱动之间的桥。它要实现四个函数:low_level_init(初始化 MAC 和 PHY,启动 DMA 接收)、low_level_output(把 pbuf 链拷进发送缓冲并触发发送)、low_level_input(从接收缓冲拼出 pbuf 链)、ethernetif_input(把 pbuf 交给netif->input处理)。

中断收包的具体流程是:ETH 接收中断进来,调用HAL_ETH_RxCpltCallback或者你自己的回调,在回调里xSemaphoreGiveFromISR释放一个信号量;同时有一个专门的ethernetif_input任务阻塞在这个信号量上,被唤醒后调用low_level_input把数据搬出来,再通过netif->input送进 LwIP 的tcpip_thread

这里有个细节特别容易出错:low_level_input里分配 pbuf 用的是pbuf_alloc,如果返回 NULL,不能直接返回,否则信号量已经消耗掉但数据没取走,会导致后续包全部堵住。正确做法是循环取包直到取不到为止,或者在失败时把中断重新挂上,让下次中断再取。

另一个坑是发送侧。low_level_output在 pbuf 释放和 DMA 发送完成之间有个时间差,如果你在 pbuf 里直接让 DMA 去读数据,然后立即释放 pbuf,DMA 读到的可能是被复用的内存。稳妥的做法是先拷到自己管理的发送缓冲,等 DMA 发送完成中断后再标记缓冲可用。

4.3 PHY 地址、自协商和 RMII 时钟的排查顺序

PHY 层是最容易出现"灯亮但不通"的地方。排查顺序我一般按这个走:

  1. 确认 RMII 参考时钟。用示波器量 PA1 上是否有稳定的 50MHz。没有的话,要么 PHY 侧没供时钟,要么 MCO1 配置错了。这个不对,后面全部免谈。
  2. 确认 MDIO/MDC 通信。读 PHY 的 ID 寄存器(寄存器 2 和 3),读到的值应该和 PHY 型号对应。读不到就是硬件连接或者时钟问题。
  3. 确认 PHY 地址。DP83848 的地址由 PHYAD 引脚决定,常见是 0x01;LAN8720 常见是 0x00 或 0x01。地址写错,读写全是 0 或者超时。
  4. 确认自协商结果。读基本状态寄存器(寄存器 1),看链路是否 up、速率是 100M 还是 10M、双工模式是什么。如果协商成 10M 半双工,而 MAC 侧配的是 100M 全双工,就会大量丢包。
  5. 检查复位时序。PHY 复位后需要等一段时间才能访问寄存器,太早读写会失败。

这几步都过了,MAC 和 PHY 的链路基本就通了,接下来才是协议栈层面的问题。

5. 中断优先级分组与 sys_arch 的对接

5.1 从逻辑优先级到寄存器数值的换算

FreeRTOS 里配置的configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY是一个逻辑上的优先级数值,范围是 0 到 15(因为 4 位优先级)。而写进BASEPRI寄存器的值需要左移到高 4 位,也就是值 << (8 - 4)。这就是为什么宏定义要写成5 << (8-4)

在代码里给中断设优先级,用的还是逻辑值:

HAL_NVIC_SetPriority(ETH_IRQn, 5, 0);

这里的 5 必须大于等于configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY。如果设成 3,那么这个中断不受 FreeRTOS 屏蔽保护,但它又会调用xSemaphoreGiveFromISR,结果就是内核数据被破坏。

一个实用的自检方法:在初始化结束后,打印所有会调用内核 API 的中断的优先级,逐个对照阈值检查一遍。我在一个项目里就是因为漏了一个串口 DMA 中断,设成了默认的 0,跑了几小时才崩一次,排查花了整整两天。

5.2 sys_arch.c 里信号量、邮箱和超时的实现

LwIP 在NO_SYS=0模式下,通过sys_arch.c把自身的信号量、邮箱、线程抽象映射到 FreeRTOS 上。核心映射关系是:

  • sys_sem_t映射成 FreeRTOS 的SemaphoreHandle_t,用计数信号量实现。
  • sys_mbox_t映射成QueueHandle_t,队列元素存的是void*指针。
  • sys_mutex_t映射成互斥量,注意优先级继承要开,否则高优先级任务等低优先级任务持有的锁会出现优先级反转。
  • sys_thread_newxTaskCreate创建任务,栈大小参数单位要换算。

超时处理上要特别注意单位。LwIP 的sys_arch_mbox_fetch参数是毫秒,而xQueueReceive的参数是 tick 数,需要除以portTICK_PERIOD_MS。当portTICK_RATE_HZ是 1000 时两者相等,但如果 tick 频率改成 100,直接传毫秒值就会导致超时时间放大 10 倍,进而拖慢整体收发。

u32_t sys_arch_mbox_fetch(sys_mbox_t *mbox, void **msg, u32_t timeout) { TickType_t ticks; if (timeout == 0) { ticks = portMAX_DELAY; } else { ticks = timeout / portTICK_PERIOD_MS; if (ticks == 0) ticks = 1; } if (xQueueReceive(*mbox, msg, ticks) != pdTRUE) { return SYS_ARCH_TIMEOUT; } return 0; }

另外sys_arch_protectsys_arch_unprotect的实现要根据是否启用中断嵌套来定,简单场景用taskENTER_CRITICALtaskEXIT_CRITICAL就够,但要注意不能在中断里调用。

6. 联调排查:从 PHY 灯不亮到稳定 ping 的完整链路

6.1 分层验证法:别想一步到位

我的习惯是把验证拆成三层,每层单独跑通,哪层出问题一目了然。

第一层是裸机验证。不挂 FreeRTOS,直接用 HAL 库初始化 ETH,写一个最简单的收发测试,能 ping 通或者能收发 UDP 就行。这一层排除的是硬件、PHY 时序、DMA 描述符、引脚复用这些纯底层问题。

第二层是挂上 FreeRTOS 但不跑 LwIP。创建几个任务互相发信号量,确认调度正常、SysTick 正常、串口打印正常。这一层排除的是 port 文件、中断优先级、堆配置这些问题。

第三层才把 LwIP 加进来。先只跑一个简单的 TCP echo 服务,跑通之后再上 DHCP、socket、应用层协议。这样即使出问题,也能快速判断是新加的哪一层引起的。

这个顺序看起来慢,实际上比一上来就全堆上去然后对着黑盒猜要快得多。我见过最惨的一个情况是:所有东西一次性合并,结果 PHY 地址错了、中断优先级也错了、pbuf 池又太小,三个问题叠在一起,现象是"偶发死机加偶发断网",根本没法定位。

6.2 常见故障现象与根因的对照表

现象最可能的根因验证方法
PHY 灯不亮RMII 参考时钟缺失或 PHY 未复位示波器量 50MHz 时钟
读 PHY ID 全 0PHY 地址错或 MDIO 引脚配置错换几个常见地址试读
裸机 ping 通,挂 OS 后不通ETH 中断优先级越界检查中断优先级数值
开机即 HardFaultFPU 未使能或中断向量表未映射查 CPACR 和 it.c 中的 ISR
传输一段时间后断流pbuf 池耗尽或内存碎片增大 PBUF_POOL_SIZE,切 heap_4
大数据量时丢包TCP_WND/SND_BUF 太小提高到 4 倍 MSS 以上
浮点结果异常中断里做了浮点运算把浮点运算移到任务中
串口无输出栈太小导致溢出configCHECK_FOR_STACK_OVERFLOW

6.3 吞吐与稳定性压测怎么做

功能跑通只是及格线,真正上线前要做压测。我一般会做三件事:

一是长时 ping。连续跑 24 小时,统计丢包率。正常应该接近 0,如果有少量丢包,检查 pbuf 池和 DMA 描述符数量。

二是大文件传输。PC 端用iperf或者自己写个 TCP 客户端发 100MB 数据,观察是否有卡顿、重传、内存增长。重点关注MEM_SIZE是否吃紧、堆是否被慢慢耗光。用xPortGetFreeHeapSize定时打印剩余堆,如果数值持续下降不回升,说明有泄漏或者碎片化。

三是多任务并发。让网络任务和串口任务、ADC 任务同时跑,把各任务优先级拉开,观察网络延迟是否受影响。如果 CPU 占用过高,可以考虑开硬件校验和,减少软件计算量。

关于 LwIP 的内存占用监控,mem_used()mem_peak()这两个接口很有用,可以定期打印出来。但要注意它们依赖LWIP_STATSMEMP_STATS,不开统计功能是拿不到数据的。

我个人的体会是,F407 这套组合的稳定性,八成取决于配置而不是代码本身。真正需要写的代码量并不大,port 层几乎不用动,ethernetif 和 sys_arch 也就几百行,剩下的都是配置项的取舍。配置对了,跑起来就是稳的;配置错了,写再多补丁也是按下葫芦浮起瓢。所以每次新项目开始,我都会把FreeRTOSConfig.hlwipopts.h这两个文件从头到尾读一遍,把每个非默认值的项都问自己一句"为什么设成这个值",很多隐藏问题在写代码之前就被发现了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询