简介:面向嵌入式网络开发者的实战资源,基于GD32F450微控制器、FreeRTOS实时系统和lwIP协议栈,聚焦DP83848以太网PHY芯片的适配。压缩包完整呈现驱动开发、lwIP参数配置、FreeRTOS任务划分、中断服务例程及错误处理等关键环节,适合物联网设备、智能传感器节点等场景的二次开发。包内共501个文件,以头文件(.h)、C源码(.c)、编译中间文件(.o/.d)以及工程配置文件(.uvprojx/.hex/.map)为主,另含readme和txt说明,整体约7.19MB,目录结构便于按模块查阅。该项目在CSDN已有729人学习,适合具备C语言和嵌入式基础、希望快速搭建GD32F450+FreeRTOS+lwIP网络框架的开发者参考。 前阵子在网盘里翻出一个老工程包,名字就叫freertos_gd32_lwip.zip。解压一看,是当时给某款数据采集网关做的底层工程:主控用的 GD32F470,系统跑的 FreeRTOS,网络协议栈用的 lwIP。这类项目在工业控制、智能楼宇、环境监测这些场景里太常见了——前端采集传感器数据,通过串口或者 IO 读进来,再走以太网上报到服务器。问题在于,网上聊 FreeRTOS 移植的不少,聊 lwIP 的也不少,但真正把 GD32、FreeRTOS、lwIP 这三样揉到一起、能直接跑起来的完整工程却少得可怜。这篇文章不打算重新打包发工程,而是把这个工程里最该讲透的几个地方拆开说清楚,包括架构选型、工程搭建、RTOS 移植细节、lwIP 协作机制,以及联调阶段最容易踩的坑。
1. 为什么偏偏是这组搭档:GD32+FreeRTOS+lwIP 的架构动机
1.1 网络协议栈是天然的“异步怪物”
很多人第一次在单片机上做以太网,会下意识地想在裸机大循环里处理 TCP/IP。刚动手就发现不对劲:lwIP 要处理超时重传、ARP 请求、TCP 状态机、DHCP 租约续期……这些事件都不是按你 main 循环的顺序来的,而是随机插入的。裸机下要么写一堆标志位轮询,要么干脆把协议栈丢到定时器中断里跑,结果就是主循环被拖垮,中断延迟也跟着变大。
FreeRTOS 的引入不是在秀 RTOS 优越性,而是它把“什么时候处理网络事件”这个问题从你手里接走了。lwIP 的 tcpip_thread 作为独立任务存在,以太网中断只负责收包和发信号量,真正协议栈处理在这个线程里完成。你只需要把应用逻辑拆成几个任务,用队列和信号量交换数据,代码结构立刻清爽很多。这种架构在处理 TCP 重传、掉线重连、多客户端并发时,优势非常明显。
1.2 GD32 这颗芯片到底够不够用
GD32F470 系列的定位是带以太网 MAC 的 Cortex-M4 芯片,主频可以跑到 240MHz,配合浮点单元用起来不觉得吃力。和同类型 STM32F4 相比,外设布局上确实有相似之处,但时钟树、外设寄存器细节都不一样,不能直接烧 STM32 的工程。不过它的优势也很直白:集成 MAC 后只需要外挂一颗 PHY 芯片,比如 LAN8720A 或者 DP83848,硬件成本压得很低;RAM 容量对于 lwIP 加 FreeRTOS 的组合也够宽松,不像早期 M3 芯片那样抠内存。
选型时有一个容易忽略的点:GD32F470 的以太网 MAC 是 10/100M 以太网,不支持千兆。如果你的项目规划里以后可能要千兆上行,那这颗芯片就明显不合适。但做设备数据上报、Modbus TCP 网关、本地 Web 配置页面这类场景,100M 完全够用,还能省掉外接 MAC 芯片的成本。
1.3 lwIP 的轻量与不轻量
lwIP 官方定位是轻量级 TCP/IP 协议栈,但“轻量”是相对完整 Linux TCP/IP 栈而言的。真要把它用好,还是得花不少心思。它支持三种运行模式:裸机模式的 NO_SYS=1、带 RTOS 的 NO_SYS=0、以及基于 netconn 或 socket API 的应用层接口。我们这里选的是 NO_SYS=0,让 lwIP 跑在自己的 tcpip_thread 里,任务调度交给 FreeRTOS。
lwIP 的轻量主要体现在裁剪灵活:不需要的协议可以在 lwipopts.h 里直接关掉,比如不用 DHCP 就LWIP_DHCP 0,不用 IPv6 就LWIP_IPV6 0。但代价是配置项太多,一个宏设错,表现很诡异。比如说TCP_WND调太小,TCP 传大文件会慢到怀疑人生;MEMP_NUM_TCP_PCB不够,连接数一多就建立不了新连接。所以 lwIP 移植不叫“跑起来”,叫“调到符合你的应用场景”。
2. 从压缩包到能跑的任务:工程骨架搭建
2.1 工具链与版本搭配
GD32 的开发工具目前支持几个路径:官方 IDE 叫 GD32 Embedded Builder,基于 Eclipse 定制,对新手最友好,新建工程时能直接选芯片型号和固件库;另外就是 Keil MDK、IAR 以及 VSCode + CMake + GCC。我自己在做这个工程时用的是 GD32 Embedded Builder,理由很简单:GD32 固件库里的例程基本都配好了启动文件和链接脚本,省掉了很多手工设置的功夫。
版本搭配上建议这样:FreeRTOS 用 V10.x 系列源码,lwIP 用 2.1.x(2.0 也常见,但 2.1 的 TCP 性能和新 API 更好),GD32 固件库用官方最新的 GD32F4xx_Firmware_Library。这三个版本相互之间没有强绑定,只要编译环境支持 CMSIS,基本都能合到一起。老工程最容易出的问题是 lwIP 源码里带着 STM32 的以太网驱动,直接拿过来改会很痛苦,建议网上找专门为 GD32 适配过的驱动,或者自己基于官方固件库的 enet 例程改。
2.2 目录结构照这个摆
工程目录建议按模块划分,别把所有源文件堆在同一个文件夹里。我这个工程的结构是这样:
project_root/ ├── CMSIS/ │ ├── core_cm4.h │ └── system_gd32f4xx.c ├── GD32F4xx_standard_peripheral/ # 标准外设库 ├── FreeRTOS/ │ ├── include/ │ ├── portable/GCC/ARM_CM4F/ │ └── src/ ├── lwIP/ │ ├── src/api/ │ ├── src/core/ │ ├── src/netif/ │ ├── src/port/ # sys_arch.c 和编译器相关 │ └── lwipopts.h ├── enet_driver/ # GD32 以太网 MAC 驱动 ├── app/ │ ├── main.c │ ├── app_task.c │ ├── cJSON.c │ └── board_init.c └── linker_script.ld这样分的好处是,固件库升级、FreeRTOS 换版本或者 lwIP 换版本时,各自改动都被隔离了。尤其 lwIP 的port目录,负责存放 sys_arch.h 和 sys_arch.c,这两文件是连接 lwIP 和 FreeRTOS 的桥梁,单独放出来以后维护不会翻车。
2.3 时钟和链接脚本别等最后才想起来
GD32F470 的时钟配置核心是用 PLL 把外部高速晶振倍频到系统主频。很多人喜欢直接复制官方例程里的 SystemInit,但这块特别容易忽略一件事:FreeRTOS 的configCPU_CLOCK_HZ必须和实际跑的 HCLK 一致。如果你用的是 240MHz 主频,但宏定义里写 200MHz,那么所有vTaskDelay、超时计算都会按错误时间基准跑,调试时定位问题会非常痛苦。
链接脚本也建议提前检查内存布局。以太网 DMA 描述符和接收缓冲在 GD32 工程里经常被定义成大数组,如果编译器把它们分配到了地址不对齐的内存位置,DMA 传输会直接失败甚至触发硬件错误。稳妥做法是给这些数组加上对齐属性,比如:
__attribute__((aligned(32))) uint8_t rx_buffer[ETH_RX_BUF_SIZE];同时确认链接脚本的堆大小足够,因为 FreeRTOS 的动态内存和 lwIP 的mem_malloc都是基于堆或者自定义内存池的,堆太小会出现运行几小时才暴露的诡异崩溃。
3. FreeRTOS 在 GD32 上容易翻车的四个细节
3.1 SysTick 到底归谁
Cortex-M 内核自带的 SysTick 是 FreeRTOS 最常见的时基来源,GD32 也不例外。问题在于,很多 GD32 工程原本是裸机代码,可能已经用 SysTick 写了delay_ms之类的延时函数。FreeRTOS 接管 SysTick 之后,裸机的延时函数就会失效或者导致系统时间错乱。
我踩过这个坑后总结了一条原则:凡是跑 FreeRTOS 的工程,业务代码里别再直接操作 SysTick。裸机例程里的delay_ms全部改成vTaskDelay,或者改用portNVIC_SYSTICK_CURRENT_VALUE_REG做短延时。如果某个外设初始化时必须要毫秒级延时且此时任务还没启动,可以使用临时让 FreeRTOS 进入临界区调portDISABLE_INTERRUPTS再跑延时,但这属于特殊情况,正常 Init 阶段不建议这么干。
3.2 中断优先级分组必须统一
Cortex-M4 的中断优先级分组可以在 3 位抢占/1 位子优先级 到 0 位抢占/4 位子优先级之间配置。FreeRTOS 官方要求使用 4 位全部作为抢占优先级(即分组 4),否则configPRIO_BITS和configMAX_SYSCALL_INTERRUPT_PRIORITY很难和安全阈值对得上。
GD32 固件库设置优先级分组的函数是nvic_priority_group_set,需要在创建任务之前就调用。FreeRTOSConfig.h 里的configPRIO_BITS固定为 4,configMAX_SYSCALL_INTERRUPT_PRIORITY按 FreeRTOS 官方的configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY << (8 - configPRIO_BITS)计算。这里要特别提醒:你在中断服务函数里凡是调用了FromISR结尾的 API,这个中断的优先级数值必须大于(也就是逻辑优先级低于)configMAX_SYSCALL_INTERRUPT_PRIORITY,否则会触发断言挂死。这在调试串口中断、收发中断时最容易碰到。
3.3 堆大小和分配策略
FreeRTOS 的 heap 实现有 5 种,从 heap_1 到 heap_5。如果工程里既有任务创建、又有 lwIP 的动态内存分配、还有 cJSON 的反复 malloc/free,heap_1(不支持释放)显然不够用。heap_4 是实用性最好的选择,它支持释放,并且会把相邻的空闲块合并,能有效减少碎片。
configTOTAL_HEAP_SIZE给多少,取决于你的整体 RAM 容量。GD32F470 的 SRAM 有几十到几百 KB,具体看型号。我们当时给了 300KB 左右给 FreeRTOS 堆,再把 lwIP 的内存池独立出去,这样两者互不干扰。如果 RAM 偏小,优先压缩 lwIP 的内存池,不要压 FreeRTOS 的任务栈,任务栈太小会导致随机 HardFault,而且特别难复现。
3.4 任务栈别靠猜,给它装上“仪表盘”
网络任务和协议栈相关任务一般栈要开大一些,我个人习惯是初始化给 2048 字(Word,不是字节),跑完长时间压力测试后再用uxTaskGetStackHighWaterMark看实际低水位。这个方法可以打印出每个任务距离栈溢出还剩多少空间,并根据结果慢慢把栈调小。
同时把configCHECK_FOR_STACK_OVERFLOW打开并实现vApplicationStackOverflowHook。这个钩子函数不一定能百分之百抓到所有溢出场景,但即使只能抓到一部分,也能帮你从随机死机里定位到“谁”在栈上越界了。有一次我排查一个周期性重启的 bug,最后就是靠溢出钩子锁定了是某个 Modbus 解析函数在数组越界时把局部缓冲区写爆了。
4. lwIP 和 FreeRTOS 的协作方式
4.1 以太网中断与驱动任务之间靠信号量握手
GD32 的以太网 MAC 收到一帧数据后,会产生接收中断。在这个中断里要做的事情越少越好——最合理的方式是在中断里读取 DMA 描述符状态,把缓冲交给驱动层,然后给一个专用的接收处理任务发信号量,唤醒它去调用 lwIP 的netif->input。发信号量必须用xSemaphoreGiveFromISR,并且检查返回值pxHigherPriorityTaskWoken后执行portYIELD_FROM_ISR。
发送路径反过来:应用任务调用 lwIP 的发送接口后,驱动层把帧数据写入 DMA 发送描述符,使能发送,发送完成中断里再回收缓冲。这个设计看起来不复杂,但很多人会在中断里直接调用 lwIP 的协议处理函数,这在 NO_SYS=0 模式下是不允许的,会造成上下文竞争,甚至服务端没收到数据,表现非常诡异。
4.2 sys_arch 层:把 lwIP 的内核线程翻译成 FreeRTOS
当NO_SYS=0时,lwIP 内部会假设存在一个“OS 模拟层”,即 sys_arch 实现。lwIP 的线程、信号量、邮箱这些抽象在 FreeRTOS 上都需要一一映射。通常要实现的接口包括:
| sys_arch 接口 | FreeRTOS 实现方式 |
|---|---|
sys_thread_new | 调用xTaskCreate,线程函数传入sys_thread_func |
sys_sem_new/sys_sem_signal | 用 FreeRTOS 二值/计数信号量或者直接映射 |
sys_arch_sem_wait | 调用xSemaphoreTake,注意处理超时返回值 |
sys_mbox_new/sys_mbox_post | 用 FreeRTOS 队列,队列中存放指针 |
sys_mutex_trylock | 用 FreeRTOS 互斥量,并注意优先级继承选项 |
这个文件是移植 lwIP 的核心难点,不建议自己从零写,最好直接参考一个已经被验证过的 FreeRTOS 移植版本,然后重点检查邮箱实现里队列长度是否够用。队列长度不够会导致 lwIP 在处理高强度收发时丢包,典型表现就是 PING 偶尔通、偶尔请求超时。
4.3 lwipopts.h 里真正影响稳定性的几项
lwipopts.h 的配置项有上百个,但真正决定生死的主要是下面这几个:
| 配置项 | 推荐做法 | 影响 |
|---|---|---|
MEM_SIZE | 8KB 以上 | lwIP 内部堆大小,PBUF 之外的内存分配都从这里来 |
PBUF_POOL_SIZE | 16~32 | 接收缓冲池数量,太小会丢包 |
TCP_WND | 设为(8 * TCP_MSS) | 接收窗口大小,太小传输速率上不去 |
TCP_SND_BUF | 至少 4 * TCP_MSS | 发送缓冲,影响单连接发送吞吐 |
MEMP_NUM_TCP_SEG | 32 左右 | TCP 分段管理个数,不够会影响多连接并发 |
LWIP_SOCKET | 1 | 开启 socket API,应用层写起来更简单 |
LWIP_DHCP | 按需开启 | 网关场景一般用静态 IP,设备上报场景常要 DHCP |
这些值并不存在万能答案,必须结合你的 RAM 大小和业务流量调整。我当时先按 lwIP 默认值编译,结果用 TCP 传 100KB 数据一直卡,后来把TCP_SND_BUF从默认的 2 倍 MSS 改成 8 倍 MSS,问题立刻消失。所以性能问题排查顺序一定是从协议栈缓冲配置开始,而不是先怀疑驱动。
5. 把串口数据变成网络包:应用层设计与排错
5.1 任务拆分和消息流
应用层我建议拆成三个任务:串口采集任务、业务处理任务、网络上报任务。串口任务负责接收终端设备的数据帧,按“帧头+长度+数据+CRC”格式解析,校验完成后把数据通过 FreeRTOS 队列发给业务任务;业务任务负责对数据做单位换算、告警判断,或者调用 cJSON 拼装成结构化的 JSON 字符串;网络任务负责建立 TCP 连接、维持心跳、把 JSON 数据通过 lwIP 的 socket 接口发送出去。
消息流设计里最容易忽略的是背压问题:当网络断线或者服务器处理不过来时,串口还在持续采集数据,队列会越积越多。解决办法是给队列设置一个最大深度,满了之后直接丢弃最旧的数据并做本地缓存记录。对数据采集类设备来说,丢掉过期数据比内存耗尽导致系统重启要合理得多。
5.2 cJSON 的线程安全与内存管理
cJSON 本来是纯 C 的 JSON 解析库,默认调用malloc和free。在 FreeRTOS 环境下,如果你的工程开启了configSUPPORT_DYNAMIC_ALLOCATION,FreeRTOS 的堆管理函数是可以覆盖标准库的malloc/free的,做法是调用cJSON_InitHooks把这些回调替换成pvPortMalloc和vPortFree。
但更关键的问题是线程安全。cJSON 本身并不是线程安全的,如果业务任务和网络任务同时在调用 cJSON 的解析和序列化,就可能出现内存错乱。我的做法是规定所有 cJSON 操作只发生在业务任务里,网络任务只负责发送已经序列化好的字符串和接收数据内容。如果项目里确实有多个任务要拼 JSON,建议在 cJSON 外层包一层互斥锁,或者直接用一个专门的任务顺序处理所有 JSON 序列化请求。
5.3 三类典型故障的排查链路
这个工程联调阶段遇到的故障,基本上可以归类成三类,排查链路我整理一下:
第一类是网口灯亮但 PING 不通。先查 PHY 地址是否匹配,LAN8720A 常用地址是 0x00,要检查你的phy_addr宏;再查 RMII 模式下 PHY 的 50MHz 参考时钟是否正常,很多硬件设计为了省成本把时钟源接错,导致 PHY 一直处于未 link 状态。之后用调试器读 PHY 基本状态寄存器(寄存器 1),看 link 状态位是否置起来,这一步能过滤掉一大半问题。
第二类是 PING 通但 TCP 连接建立不了。优先看MEMP_NUM_TCP_PCB_LISTEN够不够,再看端口号是否被占用,还有防火墙/服务端是否真的监听在这个端口。我遇到过一次很折腾的情况:TCP 客户端能连上服务端,但一收到服务端主动断开就再也连不上了,最后发现是TCP_MSS设置和服务端不一致,导致握手后协商出的分片大小异常。
第三类是设备偶发死机或者重启。这种问题说实话是最难排查的,先把触发条件找到,比如是否在连续上报大数据时发生;再检查 DMA 描述符缓冲的对齐属性和内存占用;最后把 FreeRTOS 的任务栈溢出钩子、configUSE_MALLOC_FAILED_HOOK全部打开,配合调试器定位。很多所谓的内存 bug,能被这两个钩子解决掉一大半。
5.4 按我的经验,这几点能省很多事
最后补充几个实操习惯。串口数据帧里长度字段一定要做上限校验,否则恶意数据或者干扰会把缓冲区写穿;网络任务里不要做阻塞式发送,lwIP 的send超时时间要设置合理值,避免断线时任务长期卡死;所有任务函数里设置一个软件看门狗,周期喂狗,一旦某个任务跑飞可以快速定位是哪一个;还有,工程里留一个printf级别的日志模块,在 RTOS 下注意加互斥保护,不然多个任务同时打印会把日志搅成一团,调试效率直线下降。
如果你的应用也是“传感器数据汇总后走以太网上传”这种模式,按照这套思路搭起来的 GD32+FreeRTOS+lwIP 工程会稳定很多。
本文还有配套的精品资源,点击获取