LV010-lwIP协议栈移植
一、 lwIP 简介
1. lwIP 是什么
lwIP(lightweight IP)是一个开源的轻量级 TCP/IP 协议栈,由 Adam Dunkels 在瑞典计算机科学研究所(SISC)发起,目前托管在 GNU Savannah 上,采用 BSD 许可证,可以闭源商用。它专为资源受限的嵌入式系统设计,只要几十 KB 的 RAM/ROM 就能跑起来,同时实现了完整的一套协议:ARP / IPv4 / IPv6 / ICMP / TCP / UDP,以及 DHCP、DNS、AutoIP、IGMP 等周边,应用层还附带 HTTP、MQTT、TFTP、SNTP 等示例。
对 MCU 项目的两个关键特性:
(1)可裁剪。几乎每个协议、每块缓冲都有一个 LWIP_XXX / MEM_XXX 宏开关,用一个 lwipopts.h 就能把协议栈裁到只剩 TCP + UDP。
(2)支持无操作系统模式(NO_SYS)。不带 RTOS 时,协议栈不建线程,靠应用循环周期性调用 sys_check_timeouts() 和网卡收发函数推着走——本项目用的正是这条路线,详见下文。
2. 官网与源码下载
| 入口 | 地址 |
|---|---|
| 项目主页 | https://www.nongnu.org/lwip/ |
| Savannah 项目页(源码仓库、任务、补丁) | https://savannah.nongnu.org/projects/lwip/ |
| 发布版下载 | https://download.savannah.gnu.org/releases/lwip/ |
| GitHub 镜像(跟踪开发版) | https://github.com/lwip-tcpip/lwip |
| 官方 Wiki(FAQ、示例) | https://lwip.fandom.com/wiki/LwIP_Wiki |
下载建议取 Savannah 发布区的稳定版 zip 包(截至写作时最新稳定版为 2.1.3,2022 年 12 月发布);GitHub 仓库适合看最新提交,不宜直接当发布版用。另外 ST 的 Cube 固件包也在 Middlewares/Third_Party/LwIP 里内置了一份 lwIP,但版本偏旧且混在 ST 的中间件结构里,本项目选择从官网下载干净的 2.1.3 原版源码自行维护——这也是下文目录布局的起点。
二、 移植思路与源码布局
1. 移植的三步
lwIP 官方提供了 "port-template"(Components/lwip-2.1.3/port-template/),里面是需要逐个填的平台相关文件。所以移植这件事本质上就是三步:
(1)把 lwIP 源码原样搬进工程。
(2)照模板补 3 个平台适配文件(编译器适配、系统适配、网卡接口)。
(3)用 lwipopts.h 把协议栈裁剪到能塞进这颗 MCU。
本项目跑的是无操作系统(NO_SYS)模式:不带 RTOS,协议栈由主循环每 1 ms 调一次推着走。这条路线与《STM32F1 LWIP 开发手册(DM9000 版)》第一章「无操作系统 LWIP 移植」是同一件事,区别只在文件组织方式。
2. 源码放哪:为什么要避开 CubeMX 托管路径
CubeMX 生成的代码集中在 Core/、Drivers/ 下,重新生成工程时会按自己的记录增删文件。第三方协议栈放在这两个目录里,迟早会被 CubeMX 冲掉,所以 lwIP 单独放在 Components/:提交记录 refactor(net): lwIP 目录迁移至 Components, 避开 CubeMX 托管路径 做的就是这件事。
atk-f103/Components/lwip-2.1.3/
├── src/ 协议栈内核(IPv4 / TCP / UDP / ICMP / ARP …)
├── port/ 本项目的适配层(自己写的,只有 6 个文件)
│ ├── arch/ 编译器与系统架构适配
│ ├── ethernetif.c
│ ├── lwip_comm.c
│ ├── lwipopts.h
│ ├── sys_arch.c
│ └── iap_tcp.c
├── port-template/ 官方模板,对照用
└── doc/ 官方 Doxygen 文档与 httpd 示例3. port 目录的六个文件
| 文件 | 职责 | 是否必需 |
|---|---|---|
lwipopts.h | 协议栈配置总开关与资源上限 | 必需 |
arch/cc.h | 编译器/平台适配(结构体压缩、断言、调试输出) | 必需 |
arch/sys_arch.h | 只留一个 sys_prot_t 类型 | 必需 |
sys_arch.c | sys_now() 时基 + 临界区保护函数 | 必需 |
ethernetif.c | lwIP 的网卡接口层,把 DM9000 对接到 netif | 必需 |
lwip_comm.c | 协议栈初始化与主循环轮询封装 | 本项目封装 |
iap_tcp.c | TCP 服务器,供 IAP 网络升级使用 | 应用层,见 LV020-网络通信实验 |
lwip_comm.c 和 iap_tcp.c 严格说不属于"移植",是把 lwIP 用起来的封装;放在同一个目录只是因为它们都只服务于这一颗网卡。其中 lwipopts.h 与 lwip_comm.c 的内容见 LV015-lwIP配置与初始化。
三、 编译器适配:cc.h
1. 结构体紧凑宏
ARMCC 与 GCC 对"结构体紧凑"的表达方式不同,lwIP 用 PACK_STRUCT_* 一组宏把差异隔离掉:
// Components/lwip-2.1.3/port/arch/cc.h
#if defined(__CC_ARM)
#define PACK_STRUCT_BEGIN __packed
#define PACK_STRUCT_FIELD(x) x
#elif defined(__GNUC__)
#define PACK_STRUCT_STRUCT __attribute__((packed))
#endif这一组宏不能省。lwIP 的报文头部(eth_hdr、ip_hdr、tcp_hdr)必须按协议规定的字节数紧密排布,一旦编译器插入对齐填充,抓包看到的就是错位的报文。原因在 LV005-数据封装与以太网帧 第五节讲过:报文字段的位置是协议写死的,解析全靠偏移量。
2. 调试与断言输出
另外两个宏要走 printf(重定向到 USART1),是排查协议栈内部问题的两条通路:
| 宏 | 用途 |
|---|---|
LWIP_PLATFORM_DIAG(x) | lwIP 内部的调试输出 |
LWIP_PLATFORM_ASSERT(x) | 断言失败时打印文件名、行号与断言表达式 |
两者都通过 do { printf x; } while (0) 实现,也就是不占用独立的串口通道:板子上只有一路 USART1,协议栈日志、IAP 日志、命令行回显全部复用它。
3. 一个反直觉的点:cc.h 里为什么要包含 sys_arch.h
NO_SYS 模式下,lwIP 2.1.3 的 sys.h 不会自动包含 arch/sys_arch.h,但它内部又用到了 sys_prot_t 类型。这个类型定义在 arch/sys_arch.h 里,如果不提前引入,编译会直接报 sys_prot_t 未定义。
解决方案就是在 cc.h(它总比 sys.h 先被包含)末尾把它拉进来。这是 NO_SYS 下少数几个必须"手接"的地方,写在 arch/sys_arch.h 的注释里,免得以后照着官方模板删掉。
四、 系统适配:sys_arch.c
NO_SYS 模式下不需要线程、信号量那一套,只剩两件事。
1. 时基
// Components/lwip-2.1.3/port/sys_arch.c
u32_t sys_now(void)
{
return (u32_t)HAL_GetTick();
}HAL_Init() 已经起了 1 ms 的 SysTick,直接复用即可。lwIP 的 TCP 重传、ARP 表老化、TFTP 超时全靠这个时基,所以一定要保证 1 ms 节拍真的在跑。
2. 临界区
// Components/lwip-2.1.3/port/sys_arch.c
sys_prot_t sys_arch_protect(void)
{
sys_prot_t lev = (sys_prot_t)__get_PRIMASK();
__disable_irq();
return lev;
}
void sys_arch_unprotect(sys_prot_t lev)
{
__set_PRIMASK((uint32_t)lev);
}进出临界区保存/恢复 PRIMASK,而不是简单地开中断。理由有两条:
(1)PRIMASK 只关中断,不受中断优先级分组影响。
(2)恢复时能把"进入前的中断状态"原样写回。IAP 跳转 APP 的那段代码里本来就关着中断,如果临界区退出时无条件 __enable_irq(),会把外层的中断开关状态破坏掉。
这两个函数必须是函数而不是宏:lwIP 2.1.3 的 sys.h 里无条件定义了 SYS_ARCH_PROTECT,官方模板那种 #define 覆盖法在新版本上不管用。
五、 网卡接口:ethernetif.c
1. 五个函数的职责
这是"协议栈"与"网卡驱动"的交界处,官方模板框架不动,只把函数体换成 DM9000 的调用:
| 函数 | 作用 | 本项目实现 |
|---|---|---|
low_level_init | 填 MAC、MTU、接口标志 | 从 dm9000cfg 取 MAC,MTU 1500,标志置广播/ARP/以太网/链路已连接 |
low_level_output | 发送一包 | 直接交给 DM9000_SendPacket(p) |
low_level_input | 收一包 | 直接返回 DM9000_Receive_Packet() |
ethernetif_input | 把包喂给协议栈 | 循环调 low_level_input 直到 FIFO 取空 |
ethernetif_init | 绑定接口 | 设名字 en0,挂 netif->output 与 netif->linkoutput |
ethernetif_init 里那一行挂接是关键:netif->output = etharp_output(IP 包先走 ARP 解析),netif->linkoutput = low_level_output(真正落到网卡上)。
2. 收发缓冲与"一次取空"
需要留意 ethernetif_input 的 while 循环写法:
// Components/lwip-2.1.3/port/ethernetif.c
do
{
p = low_level_input(netif);
if (p != NULL)
{
if (netif->input(p, netif) != ERR_OK) { pbuf_free(p); }
}
} while (p != NULL);一次调用要把 DM9000 的 RX FIFO 取空。如果每次只取一包,网络吞吐会被主循环的调用频率卡死——每 1 ms 只处理一个包,理论吞吐上限就只有 1 包/ms。
反过来,这个循环也意味着单次调用可能连续处理很多个包,所以在 ethernetif_input 的调用前后做的事情要尽量轻,否则会拖慢整个取包循环。
六、 挂进主循环与工程配置
1. 调用链与顺序
调用链落在 IAP 业务层里(atk-f103/Drivers/BSP/Iap/iap_service.c):
| 阶段 | 动作 |
|---|---|
| 初始化 | ota_update_init() → lwip_comm_init() → iap_tcp_init() |
| 1 ms 任务 | iap_rx_poll() → usart1_cmd_poll() → lwip_comm_poll() → ota_update_task() |
两个顺序上的讲究:
(1)ota_update_init() 必须排在 lwip_comm_init() 之前,更要在 usart1_rx_start() 之前。串口命令表只在启动前接受注册,注册晚了 update 命令就不存在。
(2)ota_update_task() 排在 lwip_comm_poll() 之后。先把这一拍到达的数据块收进来,再判超时;反过来会让"慢一点但还活着"的链路被误判成超时。
主循环整体的节奏是:外层每 500 ms 翻转一次 LED,内层每 1 ms 跑一拍上面这串任务。
2. Keil 分组与头文件路径
Keil 侧需要在 Options for Target → C/C++ → Include Paths 里补四条(Components/lwip-2.1.3/src/include、.../port、.../port/arch、Drivers/BSP/Ethernet),并把源文件按下面的分组加进工程:
| 分组 | 数量 | 内容 |
|---|---|---|
Components/lwIP | 34 | lwIP 内核 30 个 .c + sys_arch.c、ethernetif.c、lwip_comm.c、iap_tcp.c |
Drivers/BSP | 8 | 含 dm9000.c |
Drivers/BSP/Ota | 2 | ota_tftp.c、ota_update.c |
两个观察:
(1)内核那 30 个文件里包含了 dhcp.c、dns.c、autoip.c、igmp.c——它们被 lwipopts.h 里的宏裁成了空实现,编进工程只是留个口子,以后要用开宏即可。
(2)iap_tcp.c 放在 lwIP 分组而不是 BSP 分组,因为它直接依赖 lwip/tcp.h。
七、 几条硬约束
NO_SYS + 中断收包这套组合下,有几条规矩不能破:
(1)不要在中断里调用 lwIP API。HAL_GPIO_EXTI_Callback() 里只允许读寄存器、清中断标志、置一个软件标志;真正的 ethernetif_input() 必须回到主循环里做。
(2)回调里只做 O(1) 搬运。tcp_recv 回调运行在主循环上下文,一旦在里面打印、算 CRC 或写 Flash,就会把接收窗口卡住。
(3)sys_check_timeouts() 的调用间隔要稳定。1 ms 一拍是因为时基就是 1 ms;如果某处堵上几百毫秒,lwIP 的重传节奏会跟着抖。
(4)临界区一律保存/恢复 PRIMASK,不要用"关中断 / 无条件开中断"的写法,否则嵌套调用时会破坏外层的中断状态。
八、 小结与参考资料
1. 本篇小结
- 移植 = 搬源码 + 补适配文件 + 裁剪配置;本项目跑 NO_SYS 模式,协议栈靠主循环推着走。
- 第三方协议栈放在
Components/,避开 CubeMX 托管路径,防止重新生成工程时被删。 cc.h负责结构体紧凑与调试输出,并且要"手接"一次sys_arch.h;sys_arch.c只提供sys_now()与一对PRIMASK临界区函数。ethernetif.c是协议栈与网卡的交界:填 MAC/MTU、挂output与linkoutput、循环取包直到 FIFO 取空。- 初始化顺序与每拍任务顺序都有讲究:命令要先注册、网络先收包再判超时。
- 四条硬约束的本质都是同一件事:不要让长耗时的操作挡住 1 ms 的主循环节奏。
2. 参考资料
- lwIP 官网:www.nongnu.org/lwip、Savannah 项目页(源码下载见第一节)
- 手册:
30-STM32资料/10-lwIP/STM32F1 LWIP开发手册(DM9000版)_V1.1.pdf第 1.4 节「无操作系统 LWIP 移植」 - 适配源码:
atk-f103/Components/lwip-2.1.3/port/全部 6 个文件 - 网卡驱动:
dm9000.c - 业务层调用链:
iap_service.c - 工程配置:
atk-f103/MDK-ARM/atk-f103.uvprojx - 移植相关提交:引入 lwIP 2.1.3 源码与网卡接口模板、lwIP 目录迁移至 Components、Keil 工程分组精简重组
本文档由 markdowncli 技能辅助生成