
把 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_SYS0模式下会创建tcpip_thread所有协议栈核心操作都放进这个线程应用层通过 netconn 或 socket API 与它通信。这样协议栈内部的数据结构不需要应用层直接碰竞态少很多。网卡中断只负责释放信号量ethernetif_input任务再搬运数据分工清楚后期加 MQTT、HTTP Server、OTA 也更容易扩展。对 STM32F407 来说192KB SRAM 不算小但也不是可以随便挥霍。FreeRTOS 堆、LwIP 内存池、以太网 DMA 描述符和收发缓冲、各个任务栈都要提前算账。我的习惯是先把资源分配列成表再动手改配置不然调到最后不是malloc failed就是任务栈溢出查起来非常费时间。资源项建议分配说明FreeRTOS heap30KB~48KB放任务栈、队列、信号量、定时器LwIP MEM_SIZE12KB~20KB协议栈动态内存池PBUF_POOL16 个每个 1524B网卡收包主要靠它以太网 DMA 缓冲按驱动宏定义必须放 SRAM1/SRAM2不能放 CCMtcpip_thread 栈1024~1536 字Cortex-M4 下约 4KB~6KB应用任务栈512~1024 字按实际打印、浮点运算调整1.2 FreeRTOS 与 LwIP 的两种集成模式LwIP 的NO_SYS决定它是否依赖操作系统。NO_SYS1是裸机模式所有 API 都是阻塞式直接调用NO_SYS0是 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 复位时间不够后面读寄存器可能全是 0xFFFF25MHz 晶振没起振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 8MHzPLLM8PLLN336PLLP2PLLQ7。计算公式是VCO HSE / PLLM * PLLN 8 / 8 * 336 336MHzSYSCLK VCO / PLLP 336 / 2 168MHz。AHB 不分频APB1 四分频得到 42MHzAPB2 二分频得到 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 中断优先级设 15SysTick 和 PendSV 也设 15SVC 设 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.cApp放应用任务。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、SysTickCortex-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 堆统一放在主 SRAMCCM 留给纯 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_SYS0是使用 FreeRTOS 的前提。LWIP_SOCKET1会启用 socket API写 TCP Echo 比较方便如果只想省内存可以用LWIP_NETCONN1加 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_DHCP1tcpip_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优先级 4ethernetif_input优先级 3TCP Echo 应用优先级 2。具体数值按项目调整但记住 FreeRTOS 里数值大优先级高。5. 联调排错ping 不通、丢包和死机的现场记录5.1 从上电到 ping 通的检查顺序网络调试最忌讳一上来就翻协议栈源码。我习惯按物理层、MAC 层、网络层、应用层逐级确认。下面这张表是实际排错时最常用的顺序。阶段检查项正常现象异常处理上电PHY 复位、25MHz 晶振PHY ID 可读查供电、复位、晶振RMIIPA1 50MHz 参考时钟稳定 50MHz查 PHY 时钟输出配置MACHAL_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 RAMDMA 访问不到取到随机数据。/* 在 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 中断优先级设 6configMAX_SYSCALL_INTERRUPT_PRIORITY设 5这样既能调用FromISR又有较高的响应速度。TIM6 时基中断设 15SysTick 和 PendSV 设 15互不干扰。注意不要为了追求吞吐把 ETH 中断优先级设得比 5 还高比如设 3。这样它虽然响应快但不能调用 FreeRTOS 的FromISRAPI一旦在中断里释放信号量就会触发断言。硬件优先级和 FreeRTOS 可管理优先级是两个概念别混。6. 常见问题速查与个人经验6.1 问题速查表现象可能原因处理动作编译报重复定义 SVC_Handler启动文件、中断文件、FreeRTOS 都实现了只保留 FreeRTOS 实现HAL_Delay 卡死HAL 和 FreeRTOS 抢 SysTickHAL 时基改用 TIM6ping 不通但链路灯亮IP 不同网段、防火墙、ARP 未通静态 IP 测试查 PC 防火墙DHCP 一直超时tcpip_thread 优先级低、DHCP 未开提高优先级检查宏定义收几包后死机pbuf 泄漏、内存池耗尽查 pbuf_free增大 PBUF_POOL进 HardFault栈溢出、中断优先级错误开 configASSERT查高水位网线插拔后不恢复未处理 link callback注册 netif link 回调TCP 速度慢窗口小、拷贝多、任务优先级低调 TCP_WND、TCP_SND_BUF6.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 高水位比最后统一排错轻松得多。