Skip to content

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 移植」是同一件事,区别只在文件组织方式。

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.csys_now() 时基 + 临界区保护函数必需
ethernetif.clwIP 的网卡接口层,把 DM9000 对接到 netif必需
lwip_comm.c协议栈初始化与主循环轮询封装本项目封装
iap_tcp.cTCP 服务器,供 IAP 网络升级使用应用层,见 LV020-网络通信实验

lwip_comm.c 和 iap_tcp.c 严格说不属于"移植",是把 lwIP 用起来的封装;放在同一个目录只是因为它们都只服务于这一颗网卡。其中 lwipopts.h 与 lwip_comm.c 的内容见 LV015-lwIP配置与初始化。

三、 编译器适配:cc.h ​

1. 结构体紧凑宏 ​

ARMCC 与 GCC 对"结构体紧凑"的表达方式不同,lwIP 用 PACK_STRUCT_* 一组宏把差异隔离掉:

c
// 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. 时基 ​

c
// 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. 临界区 ​

c
// 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 循环写法:

c
// 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/lwIP34lwIP 内核 30 个 .c + sys_arch.c、ethernetif.c、lwip_comm.c、iap_tcp.c
Drivers/BSP8含 dm9000.c
Drivers/BSP/Ota2ota_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. 参考资料 ​


本文档由 markdowncli 技能辅助生成