简介:这份基于uCOSIII与LwIP的STM32F407应用代码,面向需要同时掌握实时操作系统和网络协议栈的嵌入式开发者,尤其适合工业控制、物联网网关及人机交互等场景;代码包共483个文件,以192个C源文件、256个H头文件为主体,并包含启动汇编代码、Keil工程配置、CubeMX的IOC文件及说明文档,整体仅2.3MB,目录按模块组织,便于定位和裁剪,覆盖从底层启动到上层应用的完整链路。目前已有298人学习下载。工程依托uCOSIII实现任务划分、优先级调度、中断管理和内存管理,同时集成LwIP协议栈,可完成TCP、UDP等网络通信,也支持HTTP与FTP等高级协议;TFTLCD驱动与示例能快速搭建彩色显示界面。全工程注释风格统一、模块化设计突出,附有演示与文档,既能帮助初学者理解RTOS与TCP/IP的实际工程结构,也可作为工程师二次开发的稳定起点。
1. uCOSIII 与 LwIP 在 STM32F407 应用代码里真正难接的部分
ExploreF407-SW 这套工程名拆开看是三块:uCOSIII 3.08.00 内核、LwIP 协议栈、STM32F407 应用代码。调过的人都清楚,难的不是把两个库编译通过,而是让它们在运行时各司其职:uCOSIII 给 TCP/IP 协议栈提供线程、信号量和邮箱,ETH 中断把网络帧从 DMA 描述符搬到内存后通知 tcpip_thread,应用任务再用 netconn 接口收发数据。这三个环节只要有一个衔接错位,现象就是 ping 不通、TCP 连接被复位,或者进一次中断任务就 HardFault。下面按移植路径、任务设计、中断与 DMA、内存与验证四条主线展开,把这类工程常见的配置和坑位讲透。
2. 用 CubeMX 生成 ETH + LwIP 再挂 uCOSIII 的最小路径
2.1 裸机移植模型与 RTOS 模型的分界点
LwIP 的lwip 裸机移植模型特征是NO_SYS=1,协议栈不需要操作系统提供线程,所有协议处理都在主循环里靠周期性调用完成。CubeMX 生成 STM32F407 的 ETH + LwIP 工程时,默认代码基本就是裸机节奏:main循环里轮询ethernetif_recv,再调用tcpip_thread里本应由 RTOS 调度的那些逻辑。
uCOSIII 工程要反过来设NO_SYS=0,让 LwIP 走tcpip_thread线程模型。此时协议栈的 TCP 定时器、超时重传、ARP 老化都在独立线程里运转,应用侧用 netconn API 发收包时也会挂起等待。标题里的 SW 包本质就是把这套改动做完整:HAL 驱动、PHY 初始化、DMA 描述符、中断回调、RTOS 封装层全部串联。
用 CubeMX 生成工程时,只勾选 ETH 和 LwIP,在 Middleware 里打开 LwIP,确认 PHY 地址和型号匹配。F407 板载 PHY 常见有 LAN8720A 和 DP83848,两者的 PHY 地址不同,例如 LAN8720A 是 0x00,DP83848 是 0x01,读不到 PHY 芯片 ID 时首先检查这个地址。
2.2 lwipopts.h 里与 RTOS 强相关的参数
lwipopts.h是整个移植的配置中心。下面这组配置是 STM32F407 上跑 uCOSIII 时比较常见的一组起点,注意每项都和内存占用挂钩。
/* lwipopts.h 中与 uCOSIII 集成强相关的一组配置 */ #define NO_SYS 0 /* 使用操作系统线程模型 */ #define MEM_ALIGNMENT 4 /* 内存对齐,F407 按 4 字节 */ #define MEM_SIZE (10 * 1024) /* 协议栈堆大小 */ #define MEMP_NUM_PBUF 16 /* 控制块池数量 */ #define PBUF_POOL_SIZE 16 /* PBUF 池数量 */ #define PBUF_POOL_BUFSIZE 1512 /* 单个 PBUF 缓冲区大小 */ #define TCP_MSS 1460 /* 最大报文段长度 */ #define TCP_WND (4 * TCP_MSS) /* 接收窗口 */ #define TCP_SND_BUF (4 * TCP_MSS) /* 发送缓冲 */ #define TCPIP_THREAD_STACKSIZE 1024 /* tcpip_thread 堆栈深度 */ #define TCPIP_MBOX_SIZE 16 /* 协议栈邮箱容量 */ #define SYS_LIGHTWEIGHT_PROT 1 /* 让 LwIP 使用系统保护接口 */ #define LWIP_TIMEVAL_PRIVATE 0 /* 使用系统时间结构 */NO_SYS是第一个分叉口,裸机是 1,uCOSIII 下必须为 0。MEM_SIZE决定协议栈内部堆,太小会出现err_t返回ERR_MEM,在 TCP 吞吐测试时尤其明显。TCPIP_THREAD_STACKSIZE是线程栈,单位取决于sys_arch.c里的实现,如果按字节传就把 1024 当作字节数,按 4 字节字长传则是 4KB 栈空间,和堆栈溢出位置直接相关。
TCP_WND和TCP_SND_BUF不是越大越好。F407 总 RAM 128KB,uCOSIII 任务栈和内核对象都要占空间,这两项每放大一倍就多占用 5.8KB 左右内存。先按4 * TCP_MSS起步,跑通后再根据实际吞吐需求调整。
2.3 在 uCOSIII 上实现 sys_arch 封装层
LwIP 在NO_SYS=0模式下需要外部提供sys_thread_new、sys_mbox_new、sys_sem_new、sys_timeout等接口。uCOSIII 移植时,常见做法是把这些 API 映射到OSSemCreate、OSMboxCreate、OSTaskCreate。下面是一个可用的sys_thread_new简化实现。
/* sys_arch.c —— uCOSIII 创建线程的适配层 */ #define SYS_THREAD_MAX_NUM 8 static OS_TCB sys_tcb[SYS_THREAD_MAX_NUM]; static CPU_STK sys_stk[SYS_THREAD_MAX_NUM][1024]; err_t sys_thread_new(const char *name, void (*thread)(void *arg), void *arg, int stacksize, int prio) { OS_ERR err; CPU_SR_ALLOC(); OS_CRITICAL_ENTER(); for (int i = 0; i < SYS_THREAD_MAX_NUM; i++) { if (sys_tcb[i].CPU_StkBase == 0) { /* 找一个空闲槽位 */ OSTaskCreate(&sys_tcb[i], name, thread, arg, prio, sys_stk[i], stacksize / 10, stacksize, 0, 0, 0, OS_OPT_TASK_STK_CHK, &err); OS_CRITICAL_EXIT(); return (err == OS_ERR_NONE) ? ERR_OK : ERR_MEM; } } OS_CRITICAL_EXIT(); return ERR_MEM; }代码里的stacksize参数直接控制 LwIP 内部线程的可用栈。uCOSIII 的OSTaskCreate要求给出任务栈底、栈限位和栈深,这里把stacksize同时传给三个参数,注意单位一致性:LwIP 的TCPIP_THREAD_STACKSIZE如果定义为 1024,适配层就应该按 1024 个CPU_STK(即 4KB)来分配。
OS_CRITICAL_ENTER/EXIT防止两个线程同时创建任务时下标冲突。很多 arry 溢出问题出在并发调用sys_thread_new,所以遍历槽位时对临界区保护。SYS_THREAD_MAX_NUM按工程实际线程数设定,LwIP 默认创建tcpip_thread、eth_rx_thread,加上应用层 TCP/UDP 任务,预留 8 个槽位足够。
提示:
sys_mbox和sys_sem的适配方式类似,注意区分 LwIP 的sys_mbox是计数型邮箱,而 uCOSIII 的OSMboxCreate同一时刻只能存一条消息。常见做法是sys_mbox用OSQCreate消息队列模拟,sys_sem用OSSemCreate信号量模拟,两者内核对象类型不能混。
3. 用 netconn API 在 uCOSIII 任务里搭 TCP/UDP 服务
3.1 任务优先级和堆栈的分配表
uCOSIII 优先级数值越小优先级越高,LwIP 的tcpip_thread要尽量放在高优先级段,ethernetif_rx收包线程紧随其后,应用任务再往低位放。下面是一张 STM32F407 上可复制的任务规划表。
| 任务名 | 优先级 | 堆栈深度(字) | 职责 |
|---|---|---|---|
tcpip_thread | 3 | 1024 | LwIP 协议栈主循环 |
eth_rx_thread | 4 | 512 | 等待信号量并调用ethernetif_input |
tcp_echo_task | 8 | 512 | netconn 层 TCP 收发 |
udp_report_task | 9 | 512 | 周期上报数据到 UDP 端口 |
main_task | 12 | 512 | 测试用任务,控制 LED 或打印 |
优先级 3 给协议栈是保险做法,因为 TCP 定时器超时处理不能太晚执行。应用任务设置成 8 和 9,让网络协议栈的处理始终先于业务代码。如果业务里还有硬实时控制任务,它的优先级应该比tcpip_thread更高,或者用OS_CFG_PRIO_MAX对应的空闲任务做兜底。
3.2 一个可直接下载的 TCP echo 任务
用 netconn API 写 TCP 服务端比 raw API 直观太多。下面的任务在 uCOSIII 下创建后,监听 8080 端口,收到什么就原样写回。这段代码可以直接放进OSTaskCreate的入口函数。
/* tcp_echo_task —— uCOSIII 下的 netconn TCP echo 服务 */ static void tcp_echo_task(void *p_arg) { OS_ERR err; err_t rc; struct netconn *server, *client; struct netbuf *buf; void *data; u16_t len; server = netconn_new(NETCONN_TCP); /* 创建 TCP 连接控制块 */ netconn_bind(server, IP_ADDR_ANY, 8080); /* 绑定 8080 端口 */ netconn_listen(server); /* 进入监听 */ while (1) { rc = netconn_accept(server, &client); /* 阻塞等待客户端连接 */ if (rc != ERR_OK) { continue; } while (1) { rc = netconn_recv(client, &buf); /* 等待并接收数据 */ if (rc != ERR_OK) { break; /* 连接关闭或超时 */ } netbuf_data(buf, &data, &len); /* 取出数据指针 */ netconn_write(client, data, len, NETCONN_COPY); /* 回写 */ netbuf_delete(buf); /* 释放缓冲区 */ } netconn_close(client); netconn_delete(client); } }netconn_accept在没有客户端连接时会让出 CPU,uCOSIII 把该任务挂起到信号量/邮箱上,其他任务正常调度,不会忙等。netbuf_data拿到的data指针指向 LwIP 内部缓冲区,netconn_write如果用NETCONN_COPY会先拷贝一份到发送缓冲,再归还缓冲区,这对数据传输安全更友好;如果追求吞吐可以把第三个参数改成NETCONN_NOCOPY,但要求上层保证数据在写回完成前不被释放。
这段代码里要注意netbuf_delete的位置。netconn_recv返回的netbuf有引用计数,在netconn_write之后再删除是对的,提前删除会导致写回时读野指针。netconn_close和netconn_delete要成对出现,只 close 不 delete 会泄漏协议控制块。
3.3 阻塞语义与超时配置
netconn API 的阻塞调用依赖 LwIP 内部邮箱:netconn_accept阻塞在连接队列上,netconn_recv阻塞在接收邮箱上。uCOSIII 提供的OSTimeDlyHMSM和 netconn 的阻塞不是一回事,后者由sys_mbox_fetch_timeout驱动,超时由struct netconn的recv_timeout决定。
client = netconn_accept(server, &client); /* 永久等待 */ netconn_set_recvtimeout(client, 2000); /* 2 秒收包超时 */设置recv_timeout后,netconn_recv超过 2 秒没有数据会返回ERR_TIMEOUT,这对需要周期性上报状态的业务场景很实用。注意netconn_accept本身没有独立的超时接口,如果想让 accept 也超时,只能再包一层OStaskSemPend或者用定时器定期关闭旧连接。折腾过的工程里,很多人为了处理半开连接,把 accept 循环放在低优先级任务,配合断线检测线程定期探活。
4. ETH 中断、DMA 描述符与临界区保护
4.1 中断到协议栈的唤醒链路
STM32F407 的 ETH 外设收到帧后,DMA 把数据写入描述符指向的缓冲区,然后触发 ETH 中断。裸机模型在中断里直接调用ethernetif_input,但在 uCOSIII 下如果中断里做协议栈处理,会和tcpip_thread产生优先级反转和重入问题。典型做法是中断里只发信号量,真正的协议栈收包在eth_rx_thread里完成。
/* stm32f4xx_it.c —— ETH 中断服务函数 */ void ETH_IRQHandler(void) { OS_ERR err; OSIntEnter(); /* uCOSIII 中断进入标记 */ if (__HAL_ETH_GET_FLAG(&g_eth_handle, ETH_FLAG_RX)) { OSSemPost(&g_sem_eth_rx, /* 唤醒收包线程 */ OS_OPT_POST_FIFO, &err); } HAL_ETH_IRQHandler(&g_eth_handle); /* 清中断标志 */ OSIntExit(); /* uCOSIII 中断退出标记 */ }收包线程的主循环如下,它等待信号量后,把描述符链表上的帧逐个交给 LwIP 输入函数。
static void eth_rx_thread(void *p_arg) { OS_ERR err; while (1) { OSSemPend(&g_sem_eth_rx, 0, OS_OPT_PEND_BLOCKING, 0, &err); ethernetif_input(&g_netif); } }OSIntEnter/OSIntExit这对调用不能省。它们让 uCOSIII 在中断嵌套结束时不要立即切换任务,而是等中断环境完全退出后再调度。如果忘了调OSIntExit,可能导致中断里发送的信号量唤醒的任务无法立刻执行,网络收包延迟被拉长到毫秒级。
ETH 中断的标志位清除放在HAL_ETH_IRQHandler里更安全。如果自己手动清标志,要确认ETH_DMACSR_RBUS和ETH_DMACSR_RXU这类错误状态也被清除,否则 DMA 停在接收状态,后续帧全部丢弃。
4.2 DMA 描述符和缓冲区不能放的区域
STM32F407 有 64KB CCM RAM,地址从 0x10000000 开始,这一块内存不经过 AHB 总线矩阵,所以 ETH DMA 完全访问不到。很多工程把描述符或接收缓冲定义成普通全局数组,链接脚本默认顺序可能把它们放进 CCM,结果表现为调试模式下能看到ETH初始化完成,但收包计数永远是 0。这就是stm32f407 ccram最常见的坑位。
/* 显式放到 0x20000000 开始的 SRAM,避开 CCM */ ALIGN_32BYTES(ETH_DMADescTypeDef g_eth_rx_desc[ETH_RX_DESC_CNT]); ALIGN_32BYTES(ETH_DMADescTypeDef g_eth_tx_desc[ETH_TX_DESC_CNT]); uint8_t g_eth_rx_buf[ETH_RX_DESC_CNT][ETH_RX_BUF_SIZE] __attribute__((section(".ARM.__at_0x20000000")));sct链接文件里最稳妥的办法是给 DMA 描述符单独开一个 execution region,放在RW_IRAM1也就是 0x20000000 区域。观察工程里g_eth_rx_desc的地址,如果落在 0x10000000 区间,先改链接文件而不是改代码。描述符的ETH_RX_DESC_CNT默认 4 或 8,每描述符占 16 字节,缓冲区ETH_RX_BUF_SIZE一般是 1524 或 1536,按ETH_RX_DESC_CNT * ETH_RX_BUF_SIZE估算 SRAM 占用,预留足够再调大。
4.3 临界区保护与 LwIP 的光开关
LwIP 内部有SYS_ARCH_PROTECT和SYS_ARCH_UNPROTECT两个宏,用来保护临界资源。uCOSIII 移植时,这两个宏映射到关中断函数或调度器锁,但要注意不能和 uCOSIII 的OS_CRITICAL_ENTER混用。
#define SYS_ARCH_PROTECT() CPU_CRITICAL_ENTER() #define SYS_ARCH_UNPROTECT() CPU_CRITICAL_EXIT()CPU_CRITICAL_ENTER会保存中断状态并关中断,这段期间 uCOSIII 的时钟节拍中断也被屏蔽,所以临界区里绝不能调用任何内核 API。LwIP 底层对接收链表的操作很短,放几行代码没事;一旦把OSSemPost之类写进去,系统会直接死在临界区里。遇到死锁时,先在中断里查OSIntNestingCtr和PRIMASK的值,能快速定位是不是关中断时间过长。
5. 验证 LwIP 收包能力的内存配置和三条实测技巧
5.1 PBUF 池与 TCP 窗口的匹配关系
LwIP 收包依赖 PBUF 池,F407 的 ETH 帧最大 1518 字节,PBUF_POOL_BUFSIZE超过 1512 是为了容纳pbuf头部和对齐填充。PBUF 池太小会直接丢包,表现为 ping 小包正常,大包连续 ping 有超时。
| 检查项 | 阈值参考 | 异常现象 |
|---|---|---|
PBUF_POOL_SIZE | 16 起步,调 32 | 高负载下丢包,Errors计数增长 |
MEM_SIZE | 10KB 起步 | ERR_MEM频繁出现 |
TCP_SND_BUF | 4~8 个 MSS | 大文件发送停滞 |
TCP_WND | 4~8 个 MSS | 接收吞吐上不去 |
TCPIP_THREAD_STACKSIZE | 1024 字起步 | 协议栈任务栈溢出 |
验证吞吐用iperf最直接。Windows 上跑iperf -s,板子端用 LwIP 的 example 或自写 TCP 客户端连上后发送数据流。F407 在 168MHz 下跑 LwIP 的实测区间通常在 20Mbps 到 50Mbps,这个差距主要是TCP_WND、PBUF 池大小和 PHY 中断频率共同决定的。
5.2 用 ping 和 iperf 区分问题层级
先把 PHY 链路确认清楚,netif_is_link_up返回真后再谈协议栈。ping 测试用 1472 字节载荷逼近单包上限。
ping -l 1472 -f 192.168.109.12-l 1472加-f表示不拆分,强迫协议栈按最大帧发送。如果 1472 通但 2000 字节不通,先检查 MTU 和 TCP_MSS 是否一致。iperf 测 UDP 比测 TCP 更容易暴露底层问题,因为 TCP 有滑动窗口重传,吞吐低不容易定位是发送侧还是接收侧瓶颈。UDP 测试丢包时,加大PBUF_POOL_SIZE通常会立刻改善。
5.3 减少拷贝:从 NETCONN_COPY 换成零拷贝
netconn_write用NETCONN_COPY时,LwIP 把应用缓冲区先拷贝到 PBUF,再交给 TCP 写出。这个拷贝在高速收发场景下会占大量 CPU。如果确认应用发送缓冲区的生命周期足够长,可以改用NETCONN_NOCOPY,让协议栈直接引用应用缓冲区。
static uint8_t app_tx_buf[2048]; netconn_write(client, app_tx_buf, len, NETCONN_NOCOPY);使用NETCONN_NOCOPY后,netconn_write返回并不代表数据已经发出,只是交付给协议栈。该缓冲区在下次写入前必须保持有效,否则突发一个 TCP 重传就会读到被覆盖的数据。调试时用tcpip_thread的栈深度和 uCOSIII 自带的任务栈检测同时盯住两边,能减少这类隐蔽的内存覆盖问题。对大多数 20Mbps 以内的业务,NETCONN_COPY仍然是最省心的选择,零拷贝适合数据量已经明显吃满 CPU 的场合再用。
本文还有配套的精品资源,点击获取