把 STM32F407 上的 FreeRTOS 和 LwIP 移植跑通,是我近期折腾得比较久的一件事。板子本身资源不差,168MHz 主频、192KB SRAM、1MB Flash,还带以太网 MAC,可真正把 FreeRTOS 调度器、LwIP 协议栈、PHY 驱动和中断优先级这几块拼到一起时,坑远比想象中密。尤其是 ping 不通、DHCP 拿不到地址、进 HardFault、跑一会儿就死机这些现象,很多时候不是协议栈本身有问题,而是时钟、DMA 缓冲、中断优先级或者堆栈配置在作怪。
这篇内容围绕 STM32F407 移植 FreeRTOS 及 LwIP 的完整实现展开,适合已经会用 Keil 或 IAR 写 STM32 基础工程、想从裸机过渡到 RTOS 加网络协议栈的朋友。我会把方案选择、工程配置、FreeRTOSConfig 关键项、LwIP 的 sys_arch 映射、ethernetif 收发路径、PHY 初始化、TCP Echo 测试和常见排错都拆开讲。文中给出的参数和代码来自常见实践,不是唯一答案,但可以当作一套能落地的参考模板。
1. 先定方案:STM32F407、FreeRTOS、LwIP 怎么组合最稳
1.1 为什么不用裸机硬扛 LwIP
很多人第一次接触 LwIP,会想着在裸机 while(1) 里调用ethernetif_input,再定时处理sys_check_timeouts。小数据量、单一 TCP 连接时确实能跑,但一旦同时开 DHCP、TCP Server、UDP 广播和 ICMP,裸机轮询的实时性立刻吃紧。LwIP 本身有超时管理、ARP 老化、TCP 重传、分片重组,这些逻辑需要比较规律的时间基准。裸机主循环里如果还有 Flash 读写、屏幕刷新或者串口打印,网络响应就会变得很随机。
FreeRTOS 的价值在这里不是“为了用 RTOS 而用 RTOS”,而是把网络协议处理、网卡输入、应用逻辑拆成不同优先级任务。LwIP 在NO_SYS=0模式下会创建tcpip_thread,所有协议栈核心操作都放进这个线程,应用层通过 netconn 或 socket API 与它通信。这样协议栈内部的数据结构不需要应用层直接碰,竞态少很多。网卡中断只负责释放信号量,ethernetif_input任务再搬运数据,分工清楚,后期加 MQTT、HTTP Server、OTA 也更容易扩展。
对 STM32F407 来说,192KB SRAM 不算小,但也不是可以随便挥霍。FreeRTOS 堆、LwIP 内存池、以太网 DMA 描述符和收发缓冲、各个任务栈都要提前算账。我的习惯是先把资源分配列成表,再动手改配置,不然调到最后不是malloc failed,就是任务栈溢出,查起来非常费时间。
| 资源项 | 建议分配 | 说明 |
|---|---|---|
| FreeRTOS heap | 30KB~48KB | 放任务栈、队列、信号量、定时器 |
| LwIP MEM_SIZE | 12KB~20KB | 协议栈动态内存池 |
| PBUF_POOL | 16 个,每个 1524B | 网卡收包主要靠它 |
| 以太网 DMA 缓冲 | 按驱动宏定义 | 必须放 SRAM1/SRAM2,不能放 CCM |
| tcpip_thread 栈 | 1024~1536 字 | Cortex-M4 下约 4KB~6KB |
| 应用任务栈 | 512~1024 字 | 按实际打印、浮点运算调整 |
1.2 FreeRTOS 与 LwIP 的两种集成模式
LwIP 的NO_SYS决定它是否依赖操作系统。NO_SYS=1是裸机模式,所有 API 都是阻塞式直接调用;NO_SYS=0是 OS 模式,LwIP 会使用sys_sem_t、sys_mutex_t、sys_mbox_t、sys_thread_new这些抽象层。用 FreeRTOS 移植 LwIP,核心工作就是把sys_arch.c里的这些类型和函数映射到 FreeRTOS 的队列、信号量、互斥量和任务创建接口。
另一种做法是用 STM32CubeF4 中间件里自带的 FreeRTOS 和 LwIP 组合,CubeMX 勾选后自动生成ethernetif.c、lwipopts.h和sys_arch.c。自动生成省事,但很多默认参数偏保守,比如PBUF_POOL_SIZE偏小、TCP_WND偏小,跑高速 TCP 时会成为瓶颈。我的建议是:前期用 CubeMX 生成骨架,确认时钟、GPIO、ETH、FreeRTOS 基础配置没问题;后期把关键配置文件拿出来逐项改,尤其是FreeRTOSConfig.h、lwipopts.h、ethernetif.c和sys_arch.c。
注意:CubeMX 生成的中间件版本要和芯片支持包匹配。STM32F4 的 HAL 库、FreeRTOS 内核、LwIP 版本如果混用,容易出现
sys_arch接口对不上、ethernetif结构体成员不一致的问题。宁可多花十分钟核对版本,也不要一边编译报错一边猜。
1.3 以太网外设和 PHY 的选型思路
STM32F407 内部有以太网 MAC,支持 MII 和 RMII。RMII 引脚少,板上布线轻松,但必须给 MAC 提供 50MHz 参考时钟。常见方案是 PHY 芯片输出 50MHz,接到 PA1 的ETH_RMII_REF_CLK。DP83848 是一颗很常见的工业级 PHY,支持 RMII,带自协商和链路状态寄存器,调试时读 BMSR 就能判断网线有没有插好、自协商有没有完成。
选 PHY 时要关注三件事:第一,地址怎么定。DP83848 的 PHYAD 引脚决定地址,常见是 0x01,如果硬件上拉下拉不同,代码里PHYAddress也要改。第二,RMII 模式怎么进入。DP83848 有 RMII 模式选择引脚,也有 strap 配置,硬件设计不同,软件初始化顺序也会受影响。第三,复位和时钟。PHY 复位时间不够,后面读寄存器可能全是 0xFFFF;25MHz 晶振没起振,50MHz 参考时钟就没有,MAC 初始化会卡住。
如果板子用的是 Type-C 供电,PA8 经常会被拿去做 VBUS 检测或者 USB OTG 控制。这个引脚和以太网没有直接冲突,但配置 GPIO 时要确认没有误改以太网相关复用引脚。以太网 RMII 用到的 PA1、PA2、PA7、PB11、PB12、PB13、PC1、PC4、PC5 等,时钟使能和复用模式必须一次配对,尤其是 PA1 是参考时钟输入,不能配成普通输出。
2. 工程底子怎么搭:时钟、PHY、HAL 时基和目录
2.1 时钟树不是随便填:168MHz 与 RMII 50MHz 要分开看
STM32F407 跑 168MHz 的经典配置是:HSE 8MHz,PLLM=8,PLLN=336,PLLP=2,PLLQ=7。计算公式是VCO = HSE / PLLM * PLLN = 8 / 8 * 336 = 336MHz,SYSCLK = VCO / PLLP = 336 / 2 = 168MHz。AHB 不分频,APB1 四分频得到 42MHz,APB2 二分频得到 84MHz。以太网 MAC 挂在 AHB 总线上,所以 AHB 时钟要确保是 168MHz,否则 DMA 吞吐会受影响。
RMII 的 50MHz 参考时钟是另一条路径,它不由主 PLL 随便分频得到,通常从 PHY 的 50MHz 时钟输出引脚接过来。调试时用示波器或者逻辑分析仪量 PA1,看到稳定的 50MHz 方波,才能继续往下走。没有这个时钟,HAL_ETH_Init可能返回错误,也可能看似初始化成功但一包都收不到。有些人想把 MCO 输出配成 50MHz 给 PHY 用,这要看硬件是否支持,而且 MCO 输出质量、抖动和布线都会影响 RMII 稳定性,工业环境里我更倾向于用 PHY 自己输出。
注意:SYSCLK 和 RMII_REF_CLK 是两回事。系统跑 168MHz 不代表以太网参考时钟一定正确。量 PA1 是最直接的判断方法。
2.2 DP83848 硬件连接与地址确认
DP83848 的 RMII 连接大致包括:TXD0、TXD1、TX_EN 接 MAC 的发送引脚;RXD0、RXD1、CRS_DV 接接收引脚;MDC、MDIO 接管理接口;REF_CLK 输出 50MHz 给 MAC。复位引脚要由 MCU 控制或者 RC 复位,确保上电后 PHY 进入已知状态。PHY 地址由 PHYAD0 等 strap 引脚决定,常见地址是 0x01。代码里heth.Init.PHYAddress必须和硬件一致,否则HAL_ETH_Init里读 PHY ID 会失败。
我一般先写一个极简的 PHY 测试:初始化 MDC/MDIO 后,读寄存器 0x02 和 0x03,拼出 PHY ID。DP83848 的 ID 通常是0x2000A140附近,如果读到 0x0000 或 0xFFFF,先查 PHY 地址、MDIO 上拉、PHY 复位和 25MHz 晶振。再读 BMSR 寄存器 0x01,看 Link Status 位和 Auto-Negotiation Complete 位。网线插上后 Link 位会变化,这是最省事的硬件确认方式。
如果 PHY 地址不确定,可以写一个小循环,从 0 到 31 逐个读 ID,打印出来哪个地址有响应。这个方法我试过很多次,尤其适合硬件资料不全的板子。注意读 PHY 时不要开以太网中断,先用轮询方式确认物理层,再进入协议栈初始化。
2.3 HAL 时基和 FreeRTOS SysTick 的冲突处理
FreeRTOS 在 Cortex-M 上默认使用 SysTick 作为系统节拍。HAL 库也喜欢用 SysTick 做HAL_IncTick,如果两者都抢 SysTick,会出现HAL_Delay卡死、FreeRTOS 节拍异常、任务调度变慢等问题。常见解决方式是在 CubeMX 里把 HAL 的 Timebase Source 改成 TIM6 或其他基本定时器,让 SysTick 专门给 FreeRTOS 用。
TIM6 中断里只调用HAL_IncTick,不需要调用 FreeRTOS 的FromISRAPI,所以它的优先级可以设得低一些,但也不要低到影响 HAL 超时判断。我的习惯是 TIM6 中断优先级设 15,SysTick 和 PendSV 也设 15,SVC 设 15,保证内核异常优先级最低。以太网中断优先级则要高于configMAX_SYSCALL_INTERRUPT_PRIORITY对应的数值,后面会展开。
/* CubeMX 生成的 HAL 时基回调,TIM6 中断里调用 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim->Instance == TIM6) { HAL_IncTick(); } }2.4 工程目录和文件搬运的整洁习惯
Keil 和 IAR 下移植,最怕文件乱放。我的目录一般分成:BSP放板级驱动,Middlewares/FreeRTOS放内核源码,Middlewares/LwIP放协议栈源码,Network放ethernetif.c、sys_arch.c,App放应用任务。FreeRTOSConfig.h必须放在编译器能搜索到的头文件路径里,lwipopts.h也一样。IAR 用户要注意文件扩展名和包含路径,Keil 用户要检查Include Paths和Define里是否加了USE_HAL_DRIVER, STM32F407xx。
从 CubeMX 生成后,不要急着把所有文件都改一遍。先编译空工程,确认 HAL、FreeRTOS、LwIP 三部分都能通过编译。然后再逐个替换或修改配置文件。每改一处就编译一次,这样报错来源清晰。尤其 LwIP 的sys_arch.c和ethernetif.c有大量条件编译,宏定义没开对,会报一堆类型不匹配,逐个排查比一次性大改省时间。
3. FreeRTOS 移植:中断、堆和任务优先级
3.1 FreeRTOSConfig.h 关键参数逐项拆解
FreeRTOSConfig.h是 FreeRTOS 移植的核心。下面这份配置适合 STM32F407 加 LwIP 的常见场景,不是每个项目都一模一样,但可以作为起点。
#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 ((TickType_t)1000) #define configMAX_PRIORITIES (7) #define configMINIMAL_STACK_SIZE ((uint16_t)128) #define configTOTAL_HEAP_SIZE ((size_t)(40 * 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 configCHECK_FOR_STACK_OVERFLOW 2 #define configUSE_MALLOC_FAILED_HOOK 1 #define configUSE_IDLE_HOOK 0 #define configUSE_TICK_HOOK 0 #define configUSE_TIMERS 1 #define configTIMER_TASK_PRIORITY (configMAX_PRIORITIES - 1) #define configTIMER_QUEUE_LENGTH 10 #define configTIMER_TASK_STACK_DEPTH (configMINIMAL_STACK_SIZE * 2) #define configKERNEL_INTERRUPT_PRIORITY (15 << (8 - 4)) #define configMAX_SYSCALL_INTERRUPT_PRIORITY (5 << (8 - 4))configTICK_RATE_HZ设 1000,表示 1ms 一个节拍。LwIP 的超时管理、TCP 重传、DHCP 续租都依赖系统时间,1ms 比较合适。设 100 也能跑,但网络超时精度会变粗。configMAX_PRIORITIES设 7 够用,优先级数值越大越高,后面创建任务时注意别把tcpip_thread设得太低。configTOTAL_HEAP_SIZE是 FreeRTOS 自己管理的堆,任务栈、队列、信号量都从这里出。如果 LwIP 的sys_arch里用xSemaphoreCreateMutex、xQueueCreate,也会消耗这个堆。
configCHECK_FOR_STACK_OVERFLOW设 2,配合vApplicationStackOverflowHook可以在栈溢出时打印任务名。configUSE_MALLOC_FAILED_HOOK设 1,堆不够时进入vApplicationMallocFailedHook。这两个钩子函数在调试阶段非常有价值,正式版可以保留,但不要在钩子里做复杂操作。
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { (void)xTask; printf("stack overflow: %s\r\n", pcTaskName); taskDISABLE_INTERRUPTS(); for (;;) {} } void vApplicationMallocFailedHook(void) { printf("malloc failed\r\n"); taskDISABLE_INTERRUPTS(); for (;;) {} }3.2 中断优先级分组与 SVC、PendSV、SysTick
Cortex-M4 的 NVIC 优先级分组必须用NVIC_PriorityGroup_4,也就是全部 4 位用于抢占优先级,没有子优先级。FreeRTOS 的configMAX_SYSCALL_INTERRUPT_PRIORITY是 5,意思是优先级数值大于等于 5 的中断可以调用 FreeRTOS 的FromISRAPI。数值越小,硬件优先级越高。所以以太网中断如果调用xSemaphoreGiveFromISR,它的抢占优先级必须设为 5、6、7 等,不能设 1、2、3。
SVC、PendSV、SysTick 这三个内核异常优先级要设为最低,通常是 15。CubeMX 生成的启动文件里已经有SVC_Handler、PendSV_Handler、SysTick_Handler的弱定义,FreeRTOS 的port.c会重新实现。如果编译器报重复定义,检查是不是在stm32f4xx_it.c里也写了同名函数。标准做法是stm32f4xx_it.c里不再实现这三个,或者用#if (INCLUDE_xTaskGetSchedulerState == 1)这种条件编译隔离。
/* FreeRTOS 接管内核异常,启动文件或中断文件中不要再重复定义 */ void SVC_Handler(void) { vPortSVCHandler(); } void PendSV_Handler(void) { xPortPendSVHandler(); } void SysTick_Handler(void) { xPortSysTickHandler(); }3.3 堆方案和栈溢出检测
FreeRTOS 提供 heap_1 到 heap_5。带 LwIP 和动态创建任务的项目,推荐 heap_4,支持碎片合并。heap_5 适合多块不连续 RAM,比如 F407 的 CCM 和主 SRAM 分开,但以太网 DMA 不能用 CCM,所以分配时要小心。我的做法是 FreeRTOS 堆统一放在主 SRAM,CCM 留给纯 CPU 访问的数据,比如算法缓存或者栈,但不要把以太网缓冲放进去。
任务栈大小的单位是StackType_t字,Cortex-M4 下 1 字等于 4 字节。xTaskCreate里传 512,实际是 2048 字节。tcpip_thread处理协议栈,栈需求较高,建议 1024 到 1536 字。ethernetif_input任务如果只做 pbuf 搬运,512 字通常够用,但如果里面加打印,就要加大。应用任务如果用了printf浮点、JSON 解析或者 TLS,栈要单独评估。
注意:不要只看编译通过。FreeRTOS 栈溢出检测只在任务切换时检查,某些瞬时深调用可能漏掉。调试阶段可以在任务里打印
uxTaskGetStackHighWaterMark,观察剩余栈历史最小值。
3.4 先用点灯任务验证调度器
在接入 LwIP 之前,先让 FreeRTOS 点灯跑起来。创建两个任务,不同优先级,不同闪烁周期。用串口打印任务切换情况。确认vTaskDelay准确、串口不阻塞、中断能正常触发。这个步骤看似简单,但能提前暴露时钟配置、SysTick 冲突、堆不足、启动文件重复定义等问题。
void led_task(void *argument) { for (;;) { HAL_GPIO_TogglePin(GPIOF, GPIO_PIN_9); vTaskDelay(pdMS_TO_TICKS(500)); } } void print_task(void *argument) { for (;;) { printf("free heap: %u\r\n", (unsigned int)xPortGetFreeHeapSize()); vTaskDelay(pdMS_TO_TICKS(1000)); } }两个任务都能跑,xPortGetFreeHeapSize没有快速下降,说明调度器和堆基本正常。接下来再动 LwIP,心里就有底了。
4. LwIP 接入:sys_arch、lwipopts 和网卡驱动
4.1 lwipopts.h 里真正要改的参数
lwipopts.h决定 LwIP 的内存、协议开关和性能边界。下面这些参数是 STM32F407 下最值得关注的。
#define NO_SYS 0 #define LWIP_NETCONN 1 #define LWIP_SOCKET 1 #define MEM_ALIGNMENT 4 #define MEM_SIZE (16 * 1024) #define MEMP_NUM_PBUF 16 #define MEMP_NUM_UDP_PCB 6 #define MEMP_NUM_TCP_PCB 8 #define MEMP_NUM_TCP_PCB_LISTEN 4 #define MEMP_NUM_TCP_SEG 32 #define PBUF_POOL_SIZE 16 #define PBUF_POOL_BUFSIZE 1524 #define LWIP_ICMP 1 #define LWIP_DHCP 1 #define LWIP_UDP 1 #define LWIP_TCP 1 #define TCP_MSS 1460 #define TCP_WND (4 * TCP_MSS) #define TCP_SND_BUF (4 * TCP_MSS) #define LWIP_NETIF_HOSTNAME 1 #define LWIP_NETIF_STATUS_CALLBACK 1 #define LWIP_NETIF_LINK_CALLBACK 1 #define LWIP_NETIF_TX_SINGLE_PBUF 1NO_SYS=0是使用 FreeRTOS 的前提。LWIP_SOCKET=1会启用 socket API,写 TCP Echo 比较方便;如果只想省内存,可以用LWIP_NETCONN=1加 netconn API。MEM_SIZE是 LwIP 自己的堆,和 FreeRTOS 堆分开。PBUF_POOL_SIZE决定同时能缓存多少个接收包,跑 TCP 吞吐时如果这个值太小,容易丢包。TCP_WND和TCP_SND_BUF影响窗口大小,4 倍 MSS 是基础值,想提速可以加到 8 倍甚至 16 倍,但要相应增加内存池。
注意:
PBUF_POOL_BUFSIZE要能容纳最大以太网帧。常见以太网帧 1514 字节,加上一些对齐余量,1524 比较稳妥。如果开了 VLAN 或者巨帧,还要再调。
4.2 sys_arch 到 FreeRTOS 的映射
LwIP 的sys_arch.c是移植层,FreeRTOS 部分主要实现信号量、互斥量、邮箱和线程创建。下面是最小可用版本,类型定义放在sys_arch.h。
/* sys_arch.h 里通常这样定义 */ typedef SemaphoreHandle_t sys_sem_t; typedef SemaphoreHandle_t sys_mutex_t; typedef QueueHandle_t sys_mbox_t; typedef TaskHandle_t sys_thread_t; typedef int sys_prot_t; /* sys_arch.c */ sys_sem_t sys_sem_new(u8_t count) { return xSemaphoreCreateCounting(1, count); } void sys_sem_free(sys_sem_t sem) { vSemaphoreDelete(sem); } void sys_sem_signal(sys_sem_t sem) { xSemaphoreGive(sem); } u32_t sys_arch_sem_wait(sys_sem_t sem, u32_t timeout) { TickType_t ticks; if (timeout == 0) { ticks = portMAX_DELAY; } else { ticks = pdMS_TO_TICKS(timeout); } if (xSemaphoreTake(sem, ticks) == pdTRUE) { return 0; } return SYS_ARCH_TIMEOUT; } sys_mutex_t sys_mutex_new(sys_mutex_t *mutex) { *mutex = xSemaphoreCreateMutex(); return *mutex; } void sys_mutex_lock(sys_mutex_t *mutex) { xSemaphoreTake(*mutex, portMAX_DELAY); } void sys_mutex_unlock(sys_mutex_t *mutex) { xSemaphoreGive(*mutex); } sys_thread_t sys_thread_new(const char *name, lwip_thread_fn thread, void *arg, int stacksize, int prio) { TaskHandle_t h; xTaskCreate(thread, name, stacksize / sizeof(StackType_t), arg, prio, &h); return h; } sys_prot_t sys_arch_protect(void) { taskENTER_CRITICAL(); return 0; } void sys_arch_unprotect(sys_prot_t pval) { (void)pval; taskEXIT_CRITICAL(); }这里有一个容易踩的点:sys_thread_new传入的stacksize通常是字节数,而xTaskCreate需要的是字。所以要么在sys_arch里除以sizeof(StackType_t),要么在创建tcpip_thread时传入已经换算好的值。两种方式都行,但整个工程要统一。sys_arch_protect在任务上下文里用临界区没问题,如果在中断里调用,要考虑用关中断方式,别直接调用taskENTER_CRITICAL。
4.3 ethernetif.c 的收发路径
ethernetif.c是 LwIP 和 STM32 以太网驱动的桥。它负责初始化网卡、低层发送、低层接收,以及把接收到的数据交给netif->input。CubeMX 生成的版本通常已经包含low_level_init、low_level_output、low_level_input、ethernetif_input。需要检查的点包括:MAC 地址是否合法、PHY 地址是否匹配、DMA 描述符是否放在 DMA 可访问 RAM、中断回调是否释放信号量。
static err_t low_level_output(struct netif *netif, struct pbuf *p) { struct pbuf *q; uint8_t *buffer; (void)netif; buffer = (uint8_t *)ETH_GetCurrentTxBuffer(); 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 ERR_OK; } static struct pbuf *low_level_input(struct netif *netif) { struct pbuf *p = NULL; uint32_t len; uint8_t *buffer; (void)netif; if (HAL_ETH_GetReceivedFrame(&heth) != HAL_OK) { return NULL; } len = heth.RxFrameInfos.FrameLength; buffer = (uint8_t *)heth.RxFrameInfos.FrameBuffer; p = pbuf_alloc(PBUF_RAW, len, PBUF_POOL); if (p != NULL) { pbuf_take(p, buffer, len); } HAL_ETH_ReleaseFrame(&heth); return p; }接收中断里释放信号量,ethernetif_input任务等待信号量后循环调用low_level_input,再把 pbuf 交给netif->input。这样做的好处是中断时间短,协议处理在任务里完成。如果直接在中断里解析帧,很容易因为打印、malloc、锁操作导致系统不稳定。
void HAL_ETH_RxCpltCallback(ETH_HandleTypeDef *heth) { BaseType_t xHigherPriorityTaskWoken = pdFALSE; xSemaphoreGiveFromISR(eth_rx_sem, &xHigherPriorityTaskWoken); portYIELD_FROM_ISR(xHigherPriorityTaskWoken); } static void ethernetif_input(void *argument) { struct netif *netif = (struct netif *)argument; struct pbuf *p; for (;;) { if (xSemaphoreTake(eth_rx_sem, portMAX_DELAY) == pdTRUE) { while ((p = low_level_input(netif)) != NULL) { if (netif->input(p, netif) != ERR_OK) { pbuf_free(p); } } } } }4.4 PHY 初始化和链路状态处理
PHY 初始化可以交给HAL_ETH_Init,但调试阶段最好自己读一遍关键寄存器。链路状态变化时,LwIP 的netif需要知道网线插拔。LWIP_NETIF_LINK_CALLBACK打开后,可以注册netif_set_link_callback,在回调里调用netif_set_link_up或netif_set_link_down。如果不管链路状态,网线拔掉再插上,DHCP 可能不会重新获取地址。
static void netif_link_callback(struct netif *netif) { if (netif_is_link_up(netif)) { printf("link up\r\n"); dhcp_start(netif); } else { printf("link down\r\n"); dhcp_stop(netif); } }静态 IP 场景更简单:netif_set_addr设置 IP、掩码、网关,netif_set_up后就能 ping。DHCP 场景要确保LWIP_DHCP=1,tcpip_thread优先级足够高,并且网线另一端有 DHCP 服务器。如果路由器开了 AP 隔离,开发板和电脑之间也可能 ping 不通,这点在排查时容易被忽略。
4.5 TCP Echo 测试程序
网络栈跑起来后,用 TCP Echo 验证收发最直接。用 socket API 写一个监听 7 端口的任务,收到什么回什么。
void tcp_echo_task(void *argument) { int sock, client; struct sockaddr_in addr, remote; socklen_t addr_len = sizeof(remote); char buf[512]; int len; sock = socket(AF_INET, SOCK_STREAM, 0); addr.sin_family = AF_INET; addr.sin_port = htons(7); addr.sin_addr.s_addr = INADDR_ANY; bind(sock, (struct sockaddr *)&addr, sizeof(addr)); listen(sock, 2); for (;;) { client = accept(sock, (struct sockaddr *)&remote, &addr_len); if (client < 0) { continue; } while ((len = recv(client, buf, sizeof(buf), 0)) > 0) { send(client, buf, len, 0); } close(client); } }创建任务时优先级不要高于tcpip_thread,否则应用任务一直占着 CPU,协议栈线程拿不到时间。一般tcpip_thread优先级 4,ethernetif_input优先级 3,TCP Echo 应用优先级 2。具体数值按项目调整,但记住 FreeRTOS 里数值大优先级高。
5. 联调排错:ping 不通、丢包和死机的现场记录
5.1 从上电到 ping 通的检查顺序
网络调试最忌讳一上来就翻协议栈源码。我习惯按物理层、MAC 层、网络层、应用层逐级确认。下面这张表是实际排错时最常用的顺序。
| 阶段 | 检查项 | 正常现象 | 异常处理 |
|---|---|---|---|
| 上电 | PHY 复位、25MHz 晶振 | PHY ID 可读 | 查供电、复位、晶振 |
| RMII | PA1 50MHz 参考时钟 | 稳定 50MHz | 查 PHY 时钟输出配置 |
| MAC | HAL_ETH_Init 返回值 | HAL_OK | 查 PHY 地址、RMII 模式 |
| 链路 | BMSR Link 位 | 插网线后置位 | 查网线、交换机、自协商 |
| 网络 | netif IP、掩码、网关 | 与 PC 同网段 | 查 DHCP、静态 IP 配置 |
| 中断 | ETH 中断是否触发 | 收包时信号量释放 | 查 NVIC、中断优先级 |
| 协议 | ping 请求与回复 | 能 ping 通 | 查 ARP、ICMP、防火墙 |
| 应用 | TCP Echo | 收发一致 | 查 socket、任务优先级 |
如果 PA1 没有 50MHz,后面全不用看。如果有 50MHz 但 PHY ID 读不到,查 MDIO 上拉和 PHY 地址。如果 PHY 链路正常但 DHCP 拿不到地址,先试静态 IP 排除 DHCP 问题。如果能 ping 通但 TCP 连不上,查端口监听和防火墙。
5.2 HardFault、栈溢出和内存失败的典型原因
HardFault 在移植阶段很常见,原因通常集中在几类:第一,任务栈溢出,尤其是tcpip_thread和应用任务里用了printf、浮点、递归。第二,中断优先级配错,调用了FromISRAPI 的中断优先级数值小于 5,触发configASSERT或者直接异常。第三,pbuf 释放错误,pbuf_free多调一次或者少调一次,导致内存池损坏。第四,DMA 缓冲放到了 CCM RAM,DMA 访问不到,取到随机数据。
/* 在 FreeRTOSConfig.h 中打开断言,定位优先级问题 */ #define configASSERT(x) if ((x) == 0) { taskDISABLE_INTERRUPTS(); for(;;); }栈溢出可以用uxTaskGetStackHighWaterMark观察。内存失败可以看vApplicationMallocFailedHook是否触发,或者打印xPortGetFreeHeapSize、mem_get_free。如果 LwIP 的MEMP_NUM_PBUF或PBUF_POOL_SIZE太小,收包时会打印pbuf_alloc failed或者直接丢包,这种问题在低速测试时看不出,一跑吞吐就暴露。
5.3 吞吐和稳定性的优化方向
STM32F407 的以太网 MAC 支持 DMA,理论上 100Mbps 网络下 TCP 跑到几十兆比特每秒是可能的,但受限于 LwIP 配置、任务优先级和内存拷贝。优化可以从几个方向入手:增大TCP_WND和TCP_SND_BUF,增加PBUF_POOL_SIZE和MEMP_NUM_TCP_SEG,提高tcpip_thread优先级,减少low_level_output里的多次拷贝。如果应用场景允许,可以用LWIP_NETIF_TX_SINGLE_PBUF让发送尽量走单个 pbuf,减少链式拷贝开销。
另一个影响稳定性的是中断优先级。以太网接收中断如果优先级太低,收包不及时,DMA 描述符可能被覆盖。我的习惯是 ETH 中断优先级设 6,configMAX_SYSCALL_INTERRUPT_PRIORITY设 5,这样既能调用FromISR,又有较高的响应速度。TIM6 时基中断设 15,SysTick 和 PendSV 设 15,互不干扰。
注意:不要为了追求吞吐把 ETH 中断优先级设得比 5 还高,比如设 3。这样它虽然响应快,但不能调用 FreeRTOS 的
FromISRAPI,一旦在中断里释放信号量就会触发断言。硬件优先级和 FreeRTOS 可管理优先级是两个概念,别混。
6. 常见问题速查与个人经验
6.1 问题速查表
| 现象 | 可能原因 | 处理动作 |
|---|---|---|
| 编译报重复定义 SVC_Handler | 启动文件、中断文件、FreeRTOS 都实现了 | 只保留 FreeRTOS 实现 |
| HAL_Delay 卡死 | HAL 和 FreeRTOS 抢 SysTick | HAL 时基改用 TIM6 |
| ping 不通但链路灯亮 | IP 不同网段、防火墙、ARP 未通 | 静态 IP 测试,查 PC 防火墙 |
| DHCP 一直超时 | tcpip_thread 优先级低、DHCP 未开 | 提高优先级,检查宏定义 |
| 收几包后死机 | pbuf 泄漏、内存池耗尽 | 查 pbuf_free,增大 PBUF_POOL |
| 进 HardFault | 栈溢出、中断优先级错误 | 开 configASSERT,查高水位 |
| 网线插拔后不恢复 | 未处理 link callback | 注册 netif link 回调 |
| TCP 速度慢 | 窗口小、拷贝多、任务优先级低 | 调 TCP_WND、TCP_SND_BUF |
6.2 几个容易被忽略的细节
第一个细节是 MAC 地址。LwIP 的netif需要一个合法的 MAC 地址,通常从heth.Init.MACAddr传入。如果全 0 或者和网络里其他设备冲突,会出现 ARP 异常、DHCP 分配不到地址。我一般用芯片 UID 生成后三个字节,前缀用02开头,避免和真实厂商地址冲突。
第二个细节是sys_arch里的临界区。sys_arch_protect和sys_arch_unprotect必须成对出现,而且不能在中断上下文里随便调用taskENTER_CRITICAL。如果 LwIP 版本较新,某些路径可能在中断里调用保护函数,这时要改用关中断方式,并记录中断状态。调试时可以在保护函数里加计数器,观察嵌套层级是否异常。
第三个细节是 DMA 描述符的位置。STM32F407 的 CCM RAM 不能被以太网 DMA 访问。CubeMX 生成的链接脚本默认把普通变量放在 SRAM1/SRAM2,但如果你手动把.bss或者某些段分配到 CCM,就要特别检查以太网缓冲和描述符。比较稳妥的做法是显式定义一个段,把DMARxDscrTab、HEMATxDscrTab、Rx_Buff、Tx_Buff放到主 SRAM,并在链接脚本里确认地址范围。
第四个细节是 PHY 自协商。DP83848 上电后默认可能开启自协商,如果对面交换机也是自协商,一般没问题。但如果强制 100M 全双工,而 PHY 还在自协商,可能出现链路不稳定。调试时可以读 ANLPAR 和 BMSR,确认协商结果。必要时通过 BMCR 写强制模式,但量产环境建议保持自协商。
6.3 后续扩展:LVGL、OTA、4G 模块怎么接
网络栈和 RTOS 跑通后,这个工程可以继续往上加东西。如果要做本地界面,可以移植 LVGL,把显示刷新放在低优先级任务,网络任务保持较高优先级,避免刷屏阻塞 TCP。LVGL 的 tick 可以用 FreeRTOS 的xTaskGetTickCount提供,注意不要在 LVGL 任务里长时间关中断。如果要做 OTA,可以把固件下载任务和网络接收任务分开,下载到外部 Flash 或者内部空闲区,再校验、跳转。4G 模块场景下,LwIP 可以走 PPP 或者 AT Socket,如果走 PPP,需要把 PPP 网卡和以太网网卡做多 netif 管理,路由和默认网卡要设置清楚。
我个人在实际操作中的体会是:STM32F407 移植 FreeRTOS 加 LwIP,真正难的从来不是把源码加进工程,而是把时钟、中断、堆栈、DMA 缓冲和协议栈内存这几本账算清楚。先把 PHY 和 50MHz 参考时钟确认到示波器级别,再让 FreeRTOS 点灯任务稳定跑起来,最后接 LwIP,一级一级验证。每加一个功能就观察一次 heap 和 stack 高水位,比最后统一排错轻松得多。