在 Windows 上跑 lwIP(三):把 lwIP 移植进自己的工程
前两篇里我一直跑的是 lwIP 自带的 example_app:改它的
lwipcfg.h、开它的应用开关。这对搭环境没问题,But------那是别人的应用,不是我的 。真实嵌入式项目里没人会把产品代码写进示例程序里。本篇我们将按 lwIP 官方BUILDING文档的标准用法,把 lwIP 当库链接进来,写一个最小 TCP 客户端。
本篇配套工程已开源 :gitee.com/buqibuli/lw...,可整仓克隆对照。
使用须知
首先我们需要明确独立构建自己的应用,好处是实打实的:
- 构建里只有自己要的东西:不编示例、不编用不到的官方应用,工程干净;报错时面对的都是和自己代码相关的东西,调试成本低
- 亲身体会 lwIP 的"轻量化"不是口号 :用不到的组成部分真的可以完全不进构建------不过这篇做到的只是"选库"这半;坑 2 会告诉你 PPP 源文件其实还在编译清单里,更进一步的功能裁剪(拨
lwipopts.h的开关)是拿到基准之后的下一步 - 主动权在自己手里:lwIP 的 CMake 文件是 include 进来用的,库怎么选、宏怎么定,都是自己的 CMakeLists 说了算,不用反过来迁就示例的构建逻辑
同样要明确的是:在 Windows 上构建和在嵌入式上构建,本质不是一回事------
| Windows 上(本篇的做法) | 嵌入式上(真实产品的做法) | |
|---|---|---|
| 集成方式 | lwIP 当库链接,用官方 Filelists(全量编译清单) | 源码拖进自己的工程,文件级裁剪手工做(PPP 目录直接不加进去) |
| sys_arch 移植层 | 用现成的 win32 端口,Windows 线程模拟 | 自己写,或按所用 RTOS 适配------移植最硬核的部分之一 |
| 网卡驱动 | pcapif 经 Npcap 借用真实网卡 | 自己写 MAC/DMA 驱动 |
| 资源约束 | 内存 Flash 几乎无限,IPv6/PPP 全开也跑得欢 | 每 KB Flash/RAM 都是预算,需要尽可能地压榨所需资源 |
所以在 Windows 上体验不到的部分:
- 真实资源约束下"不得不裁剪"的压力:PC 上没有这个动力,裁掉一个功能省了多少 Flash/RAM,这个绝对数字在 PC 上也无意义
- sys_arch 移植和网卡驱动开发:移植 lwIP 最硬的两块骨头,Windows 上全是现成的,一行都不用写
- 真实硬件行为:中断、DMA、链路 up/down,pcapif 的模拟路径上根本不存在
但总的来说,能体验的 也很值钱:协议栈行为本身(三次握手、重传、状态机,第二篇逐包抓的就是这些)、和嵌入式完全一致的 socket API、以及 lwipopts.h 裁剪的方向 ------开关关掉后代码段确实变小,size 一看便知,只是绝对值不代表你的目标机。
第一部分:拿到工程,跑起来
1.1 前提
- 环境就是第一篇搭好的那套(MSYS2 UCRT64 的 gcc、CMake、Ninja、Npcap + Npcap SDK),零新增
- lwIP 仓库侧需要三个本地补丁:前两个第一篇已经打过(
errno.h的#undef、win32 Filelists 的 SYSTEM 包含),第三个是这篇新增------win32 Filelists 引用了一个独立工程里不存在的目标,不打的话第一步 cmake 配置就报错(为什么 → 3.1 节)。三处改动整体打包在配套仓库里,从干净的 lwIP 仓库出发的话,在仓库根目录执行一条:
bash
git apply ../lwip-myapp/patches/lwip-win32-standalone-fixes.patch
(补丁 52 行、只动两个文件,可先审查再应用。第一篇跟到底的读者只缺第三个补丁,照 3.1 节的改前改后对照手改那几行更快;从克隆开始的完整目录布局见仓库 README。)
1.2 工程结构
务必确认你已经有 lwIP 的官方仓库
新工程放在 lwIP 仓库外面,两者同级(这是关键------仓库保持原样,我的工程只是引用它)。目录布局长这样:
javascript
D:/Projects/ ← 父目录随意,关键是两个仓库同级
├── lwip/ ← lwIP 官方仓库(打完 1.1 的补丁后不再动它)
│ ├── src/ ← 协议栈本体,编译出 lwipcore
│ ├── contrib/ports/win32/ ← win32 端口:pcapif 网卡驱动 + sys_arch 移植层
│ └── ... ← 仓库里还有很多别的目录,与本文无关,省略
└── lwip-myapp/ ← 我的工程(从配套仓库克隆)
├── CMakeLists.txt ← 集成入口:设 LWIP_DIR/WPDPACK_DIR,include 两个 Filelists,链接 lwipcore + win32 端口库
├── main.c ← 全部应用代码(159 行):初始化四步 + 一个 socket TCP 客户端线程
├── lwipopts.h ← lwIP 编译时配置,从 example_app 原样复制的那份(仓库里已带)
├── lwipcfg.h ← 网卡端口配置,自己写的精简版(1.3 节;不入库,仓库里是 docs/ 下的模板)
├── ppp_settings.h ← 从 example_app 复制的那份(仓库里已带),一个字符都不能少(为什么 → 3.2 节)
├── patches/ ← 1.1 节说的三个 lwIP 补丁
└── docs/ ← 从 lwIP 仓库复制的参考文档:BUILDING、win32 readme、lwipcfg.h.example
(这棵目录树只是参考示例,实际要求只有一条:lwip 和 lwip-myapp 放在同级目录下------CMakeLists 默认就按这个布局找 lwIP,LWIP_DIR 缺省值是 ../lwip。)
1.3 lwipcfg.h:只有三处要换成你的
完整文件看仓库(docs/lwipcfg.h.example 里有全部配置项的说明),真正要换成你自己机器的值的就三处:
PACKET_LIB_ADAPTER_GUID:网卡 GUID。程序一启动就会枚举本机网卡列表(序号 + GUID + 描述,输出样例见 4.1 节),照着抄一个物理网卡的即可。定义了 GUID 就按 GUID 选卡,PACKET_LIB_ADAPTER_NR的序号只是兜底LWIP_PORT_INIT_*:静态 IP/网关/掩码,按你的局域网网段填LWIP_MAC_ADDR_BASE:你网卡的真实 MAC,末字节减 1 填进去(pcapif 会 +1 加回来,为什么 → 2.2 节)
详细配置说明见《在 Windows 上用 MinGW 搭建 lwIP 调试环境:方法与踩坑实录》
1.4 构建
bash
cd /d/Projects/lwip-myapp
cmake -S . -B build -G Ninja -DWPDPACK_DIR=D:/npcap-sdk-1.16 # 首次配置,路径换你自己的
cmake --build build
./build/myapp.exe
预期输出:lwIP initialized, local IP is 192.168.0.200 → connected, sending HTTP request → 打印收到的 HTTP 响应。完整的验证清单见第四部分。
如果你只想要结果,到这里就够了。下面讲每个文件为什么这么写,以及我首次构建时踩的三个坑。
第二部分:为什么这么写
2.1 两个"应有认识"
应有认识 1:"移植部分功能"到底是在裁什么。
一开始我以为"移植部分功能"是从 lwIP 里挑一些 .c 文件出来编译。看了 BUILDING 和 Filelists 之后发现不是,手段有两层,一层比一层细:
| 层 | 手段 | 控制什么 |
|---|---|---|
| 库级 | 链接哪些库目标 | lwipcore(协议栈本体,必需)、lwipallapps(http/mqtt 等官方应用,可选)、lwipcontribexamples(示例,不要) |
| 功能级 | lwipopts.h 编译开关 |
LWIP_IPV6、LWIP_UDP、LWIP_SOCKET...... 关掉的功能根本不参与编译 |
一句话总结:不是挑源文件,是"选库 + 拨开关"!!
应有认识 2:一个能跑的 lwIP 应用,最少需要哪几块。
对着 example_app 的依赖关系捋一遍,缺一不可的是四块:
lwipcore------ 协议栈本体(src/Filelists.cmake提供)- sys_arch 移植层 ------ 线程/信号量/邮箱抽象。Windows 上直接用 win32 端口的
sys_arch.c,不用自己写 - netif 网卡驱动 ------ win32 端口的
pcapif.c(经 Npcap 收发真实以太网帧,原理见本系列第一篇) - 两个配置头 ------
lwipopts.h(协议栈配置)和lwipcfg.h(网卡等端口配置,pcapif.c会直接#include它)
2 和 3 合起来就是 win32 端口库 lwipcontribportwindows(contrib/ports/win32/Filelists.cmake 提供)。所以我们要写的只有:一个 CMakeLists、一个 main.c、两个配置头。
2.2 三个文件的写法要点
CMakeLists:骨架只有四行,其余都是路径和变量。
cmake
include(${LWIP_DIR}/src/Filelists.cmake) # 拿到 lwipcore(协议栈本体)
include(${LWIP_CONTRIB_DIR}/ports/win32/Filelists.cmake) # 拿到 lwipcontribportwindows(sys_arch + pcapif)
add_executable(myapp main.c)
target_link_libraries(myapp lwipcontribportwindows lwipcore)
这套写法是 BUILDING 文档规定好的
main.c:初始化四步 + 一个客户端线程。 对照 example_app 的 test.c(773 行,什么 PPP/SLIP/十几种应用都揉在里面),提炼出来初始化其实只有四步------先在 main 里启动协议栈,再在 tcpip 线程上下文里把网卡拉起来:
c
tcpip_init(myapp_on_tcpip_init, &init_sem); // main 里:启动协议栈,回调在 tcpip 线程里执行
// myapp_on_tcpip_init 里:网卡三件套
netif_add(&myapp_netif, &ipaddr, &netmask, &gw, NULL, pcapif_init, tcpip_input);
netif_set_default(&myapp_netif);
netif_set_up(&myapp_netif);
应用逻辑是一个独立线程,用 socket API 连 1.1.1.1:80 发一个 HTTP GET。
lwipcfg.h:为什么只剩这几行。 example_app 那份有 81 行(PPP 账号、SLIP 参数、十几个 LWIP_XXX_APP 应用开关),我的应用用不到。哪些宏是 pcapif 真正读的?对着 pcapif.c 的源码数的:PACKET_LIB_ADAPTER_NR/GUID(选网卡)、LWIP_MAC_ADDR_BASE(MAC),加上我自己 main.c 用的 LWIP_PORT_INIT_*。其余的在这个工程里都是死宏,删掉。(如果你想确认自己环境下的答案,grep 一下 contrib/ports/win32/ 目录就有,源码是最终事实。)
2.3 两个容易翻车的小细节
细节 1:-Werror 下死循环后面不能写 return 0;。 工程继承了 lwIP 的严格告警集(-Werror -Wunreachable-code 等,来自 CMakeCommon.cmake)。for (;;) 是编译器可证明的无限循环,后面跟的任何语句都是 unreachable code,直接报错。所以 main 函数让循环自然结束,不落 return(C99 起 main 跌出函数体等价于 return 0)。
细节 2:断言钩子要跟着 lwipopts.h 走。 复制来的 lwipopts.h 末尾把 LWIP_PLATFORM_ASSERT 指向一个外部函数 lwip_example_app_platform_assert(example_app 里定义的)。我的 main.c 里必须实现一个同名的,否则链接报错。名字不要改,除非同步改 lwipopts.h。
第三部分:踩坑实录(首次构建的 debug 过程)
理想很丰满:1.4 节那两条命令,首次执行实际连踩三坑------配置期一个(坑 1)、编译 lwIP 库一个(坑 2)、编译我自己的 main.c 一个(坑 3)。三个坑有个共同点:example_app 碰巧都绕过去了,而我的最小工程没有------这正是"从跑示例到用库"要付的学费。逐个交代。
3.1 坑 1:CMake 配置就报错------lwipcontribaddons 是个不存在的目标
现象(第一步 cmake -S . -B build -G Ninja 直接炸了):
vbnet
CMake Error at D:/Projects/lwip/contrib/ports/win32/Filelists.cmake:51 (target_compile_definitions):
Cannot specify compile definitions for target "lwipcontribaddons" which is
not built by this project.
Call Stack (most recent call first):
CMakeLists.txt:38 (include)
排查:对着 win32 端口的 Filelists 看,第 51 行在给一个叫 lwipcontribaddons 的目标设编译宏。但这个目标定义在 contrib/Filelists.cmake 里------而我的工程故意不 include 那个文件(见 2.2 节,这是"不要示例"的落点)。对一个不存在的目标设属性,CMake 直接报错。example_app 是全量构建、那个目标永远存在,所以上游自己踩不到:这行代码隐含假设了"你一定会把 contrib 的 Filelists 也 include 进来",对独立工程场景是个疏漏。
正解:给这行加个"目标存在才执行"的保护。原来是:
cmake
target_compile_definitions(lwipcontribaddons PRIVATE ${LWIP_DEFINITIONS} ${LWIP_MBEDTLS_DEFINITIONS})
改为:
cmake
# lwipcontribaddons 这个目标只有 include 了 contrib/Filelists.cmake 才存在;
# 只 include 本端口文件的独立工程不能碰它,所以加个"目标存在才执行"的保护
if(TARGET lwipcontribaddons)
target_compile_definitions(lwipcontribaddons PRIVATE ${LWIP_DEFINITIONS} ${LWIP_MBEDTLS_DEFINITIONS})
endif()
对全量构建零影响(目标存在时行为不变)。它是 lwIP 仓库里继 errno.h、SYSTEM include 之后的第三个本地补丁(提交在 fix/win32-filelists-standalone 分支)------1.1 节那条 git apply 打的就是它。
一句话总结:只 include 部分 Filelists 时,被 include 的脚本不能假设别的脚本定义的目标存在!!
3.2 坑 2:ppp_settings.h------我一个以太网应用,为什么要 PPP 的头?
现象(配置过了,编译 lwipcore 时 PPP 那串源文件全炸):
less
D:/Projects/lwip/src/include/netif/ppp/ppp_impl.h:41:10: fatal error: ppp_settings.h: No such file or directory
41 | #include "ppp_settings.h"
| ^~~~~~~~~~~~~~~~
compilation terminated.
排查:src/Filelists.cmake 把全部 PPP 源文件编进 lwipcore 的编译清单------注意这是文件清单层面 的全量,PPP_SUPPORT=0 控制的是功能逻辑,挡不住编译器打开这个 .c 文件、执行它的 #include。而 ppp_impl.h 无条件包含 ppp_settings.h:这是 lwIP 约定里由应用侧提供 的配置头(和 lwipopts.h 同类),example_app 的目录里正好有这个文件、又在它的 include 路径上,所以官方构建从来不缺。
正解:把 example_app 那份 ppp_settings.h 复制到我工程根目录(那里本来就在 LWIP_INCLUDE_DIRS 里)------仓库里带的就是这份。
一句话总结:XXX_SUPPORT=0 关的是功能,不是编译清单;清单里的文件要包含的头,一个都不能少!!
3.3 坑 3:40 个 errno 宏重定义------include 顺序也是 bug
现象(编译我自己的 main.c 时一口气约 40 个,摘一头一尾):
javascript
D:/Projects/lwip/src/include/lwip/errno.h:73:10: error: 'ETXTBSY' redefined [-Werror]
73 | #define ETXTBSY 26 /* Text file busy */
C:/msys64/ucrt64/include/errno.h:218:9: note: this is the location of the previous definition
218 | #define ETXTBSY 139
...(ETXTBSY / EDEADLK / ENOSYS / ECONNRESET / EINPROGRESS 等约 40 个,全是同一模式)...
cc1.exe: all warnings being treated as errors
是不是相当熟悉?这和我们在第一篇遇到的报错极为相似。当时我们采用 #undef 解决,其实是一种偷懒------只按住了触发的那两个宏;现在问题全面爆发了,约 40 个一起撞车,#undef 按不住了。
正解是换个顺序:把 lwIP 头放在系统头之前 include 。顺序倒过来后,重定义发生在 UCRT64 的系统头里,gcc 默认不报告系统头中的警告,冲突消解(这本来就是 lwIP 的惯例:lwip/opt.h 必须第一个包含)。原来是:
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "lwip/opt.h"
#include "lwip/init.h"
/* ... 其余 lwIP 头 ... */
改为:
c
/* lwIP 的头必须放在系统头之前:lwip/errno.h(经 lwip/sockets.h 拉入)定义了
* 自己的一套 errno 值。如果 C 库的 <errno.h> 先进来(UCRT64 上 stdlib.h ->
* malloc.h -> mm_malloc.h 会把它拉进来),lwIP 的重定义在 -Werror 下全是错误。
* 反过来让 lwIP 先来,重定义就发生在 C 库的系统头里,gcc 默认不报告 */
#include "lwip/opt.h"
#include "lwip/init.h"
/* ... 其余 lwIP 头 ... */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
副作用要交代:这样 main.c 里看到的 errno 宏值最终是系统的(ECONNRESET=108)而不是 lwIP 的(104),所以应用代码里别拿 errno 宏做符号比较,只做数字打印就没事。
一句话总结:两套 errno 定义撞车时,让 lwIP 的先来------系统头里的重定义,gcc 会闭嘴!!
第四部分:验证与后续
4.1 验证清单
和 example_app 时代同一个标准:
- 终端打印
lwIP initialized, local IP is 192.168.0.200,然后connected, sending HTTP request,最后打印收到的响应字节 - 局域网另一台设备 ping
192.168.0.200通(本机 ping 不通是原理性限制,hairpin 过滤,见第一篇 3.5 节,别又拿本机试) - 可选:Wireshark 抓包看三次握手,和第二篇分析的序列对照
实测(2026-08-03,修复三坑后一次通过):编译链接干净收尾([92/92] Linking C executable myapp.exe),运行 ./build/myapp.exe 输出:
javascript
0: NPF_{9198ECB8-5323-4B4B-8C80-102D46E60528}
Desc: "WAN Miniport (Network Monitor)"
...(中间是 pcapif_helper 枚举的本机网卡列表,略)...
4: NPF_{D83D00A3-B69D-4368-ABC0-9B1AAB9F4E61}
Desc: "Killer E3100G 2.5 Gigabit Ethernet Controller"
...
Using adapter_num: 4
Using adapter: "Killer E3100G 2.5 Gigabit Ethernet Controller"
lwIP initialized, local IP is 192.168.0.200
connecting to 1.1.1.1:80 ...
connected, sending HTTP request
received 381 bytes:
HTTP/1.1 301 Moved Permanently
Server: cloudflare
Date: Mon, 03 Aug 2026 08:30:46 GMT
Content-Type: text/html
Content-Length: 167
Connection: close
Location: https://1.1.1.1/
CF-RAY: a253f6553d64da3a-LAX
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>cloudflare</center>
</body>
</html>
myapp client finished.
开头那串网卡列表是 pcapif_helper.c 枚举出来给你对 GUID 用的------1.3 节说的"照着抄"就是抄这里,lwipcfg.h 里填的 GUID 命中了序号 4 的 Killer E3100G,选卡正确。之后几行就是全链路验证:协议栈初始化拿到 IP → TCP 三次握手连上 1.1.1.1:80 → 发出 HTTP GET → 收到 301 响应。
4.2 跑通之后:裁剪路线
拿到基准后,lwipopts.h 的裁剪才正式开始,顺序建议从大到小:LWIP_IPV6 0 → PPP_SUPPORT 0 → 不用的统计/调试选项。每改一项重新编译------能过编译就说明裁剪自洽,跑一遍客户端说明行为没破。不过在 Windows 上这步纯属锦上添花,毕竟能跑就行;真正的裁剪压力要到嵌入式目标上才有
总结与预告
至此,lwIP 从"我跑的示例"变成了"我工程里链接的库"。回顾最有价值的两个认知:
BUILDING文档要认真读:lwIP 的集成方式(include Filelists、选库链接)它就差手把手写了,比网上二手教程准确得多- "移植"的裁剪是选库 + 拨开关 ,不是挑源文件;
lwipopts.h才是功能裁剪的主战场
下一篇让 lwIP 掉个头当服务器,实现一个简单的敲木鱼小程序。
致谢
感谢 lwIP # 在 Windows 上跑 lwIP(三):把 lwIP 移植进自己的工程
前两篇里我一直跑的是 lwIP 自带的 example_app:改它的
lwipcfg.h、开它的应用开关。这对搭环境没问题,But------那是别人的应用,不是我的 。真实嵌入式项目里没人会把产品代码写进示例程序里。本篇我们将按 lwIP 官方BUILDING文档的标准用法,把 lwIP 当库链接进来,写一个最小 TCP 客户端。
本篇配套工程已开源 :gitee.com/buqibuli/lw...,可整仓克隆对照。
使用须知
首先我们需要明确独立构建自己的应用,好处是实打实的:
- 构建里只有自己要的东西:不编示例、不编用不到的官方应用,工程干净;报错时面对的都是和自己代码相关的东西,调试成本低
- 亲身体会 lwIP 的"轻量化"不是口号 :用不到的组成部分真的可以完全不进构建------不过这篇做到的只是"选库"这半;坑 2 会告诉你 PPP 源文件其实还在编译清单里,更进一步的功能裁剪(拨
lwipopts.h的开关)是拿到基准之后的下一步 - 主动权在自己手里:lwIP 的 CMake 文件是 include 进来用的,库怎么选、宏怎么定,都是自己的 CMakeLists 说了算,不用反过来迁就示例的构建逻辑
同样要明确的是:在 Windows 上构建和在嵌入式上构建,本质不是一回事------
| Windows 上(本篇的做法) | 嵌入式上(真实产品的做法) | |
|---|---|---|
| 集成方式 | lwIP 当库链接,用官方 Filelists(全量编译清单) | 源码拖进自己的工程,文件级裁剪手工做(PPP 目录直接不加进去) |
| sys_arch 移植层 | 用现成的 win32 端口,Windows 线程模拟 | 自己写,或按所用 RTOS 适配------移植最硬核的部分之一 |
| 网卡驱动 | pcapif 经 Npcap 借用真实网卡 | 自己写 MAC/DMA 驱动 |
| 资源约束 | 内存 Flash 几乎无限,IPv6/PPP 全开也跑得欢 | 每 KB Flash/RAM 都是预算,需要尽可能地压榨所需资源 |
所以在 Windows 上体验不到的部分:
- 真实资源约束下"不得不裁剪"的压力:PC 上没有这个动力,裁掉一个功能省了多少 Flash/RAM,这个绝对数字在 PC 上也无意义
- sys_arch 移植和网卡驱动开发:移植 lwIP 最硬的两块骨头,Windows 上全是现成的,一行都不用写
- 真实硬件行为:中断、DMA、链路 up/down,pcapif 的模拟路径上根本不存在
但总的来说,能体验的 也很值钱:协议栈行为本身(三次握手、重传、状态机,第二篇逐包抓的就是这些)、和嵌入式完全一致的 socket API、以及 lwipopts.h 裁剪的方向 ------开关关掉后代码段确实变小,size 一看便知,只是绝对值不代表你的目标机。
第一部分:拿到工程,跑起来
1.1 前提
- 环境就是第一篇搭好的那套(MSYS2 UCRT64 的 gcc、CMake、Ninja、Npcap + Npcap SDK),零新增
- lwIP 仓库侧需要三个本地补丁:前两个第一篇已经打过(
errno.h的#undef、win32 Filelists 的 SYSTEM 包含),第三个是这篇新增------win32 Filelists 引用了一个独立工程里不存在的目标,不打的话第一步 cmake 配置就报错(为什么 → 3.1 节)。三处改动整体打包在配套仓库里,从干净的 lwIP 仓库出发的话,在仓库根目录执行一条:
bash
git apply ../lwip-myapp/patches/lwip-win32-standalone-fixes.patch
(补丁 52 行、只动两个文件,可先审查再应用。第一篇跟到底的读者只缺第三个补丁,照 3.1 节的改前改后对照手改那几行更快;从克隆开始的完整目录布局见仓库 README。)
1.2 工程结构
务必确认你已经有 lwIP 的官方仓库
新工程放在 lwIP 仓库外面,两者同级(这是关键------仓库保持原样,我的工程只是引用它)。目录布局长这样:
javascript
D:/Projects/ ← 父目录随意,关键是两个仓库同级
├── lwip/ ← lwIP 官方仓库(打完 1.1 的补丁后不再动它)
│ ├── src/ ← 协议栈本体,编译出 lwipcore
│ ├── contrib/ports/win32/ ← win32 端口:pcapif 网卡驱动 + sys_arch 移植层
│ └── ... ← 仓库里还有很多别的目录,与本文无关,省略
└── lwip-myapp/ ← 我的工程(从配套仓库克隆)
├── CMakeLists.txt ← 集成入口:设 LWIP_DIR/WPDPACK_DIR,include 两个 Filelists,链接 lwipcore + win32 端口库
├── main.c ← 全部应用代码(159 行):初始化四步 + 一个 socket TCP 客户端线程
├── lwipopts.h ← lwIP 编译时配置,从 example_app 原样复制的那份(仓库里已带)
├── lwipcfg.h ← 网卡端口配置,自己写的精简版(1.3 节;不入库,仓库里是 docs/ 下的模板)
├── ppp_settings.h ← 从 example_app 复制的那份(仓库里已带),一个字符都不能少(为什么 → 3.2 节)
├── patches/ ← 1.1 节说的三个 lwIP 补丁
└── docs/ ← 从 lwIP 仓库复制的参考文档:BUILDING、win32 readme、lwipcfg.h.example
(这棵目录树只是参考示例,实际要求只有一条:lwip 和 lwip-myapp 放在同级目录下------CMakeLists 默认就按这个布局找 lwIP,LWIP_DIR 缺省值是 ../lwip。)
1.3 lwipcfg.h:只有三处要换成你的
完整文件看仓库(docs/lwipcfg.h.example 里有全部配置项的说明),真正要换成你自己机器的值的就三处:
PACKET_LIB_ADAPTER_GUID:网卡 GUID。程序一启动就会枚举本机网卡列表(序号 + GUID + 描述,输出样例见 4.1 节),照着抄一个物理网卡的即可。定义了 GUID 就按 GUID 选卡,PACKET_LIB_ADAPTER_NR的序号只是兜底LWIP_PORT_INIT_*:静态 IP/网关/掩码,按你的局域网网段填LWIP_MAC_ADDR_BASE:你网卡的真实 MAC,末字节减 1 填进去(pcapif 会 +1 加回来,为什么 → 2.2 节)
详细配置说明见《在 Windows 上用 MinGW 搭建 lwIP 调试环境:方法与踩坑实录》
1.4 构建
bash
cd /d/Projects/lwip-myapp
cmake -S . -B build -G Ninja -DWPDPACK_DIR=D:/npcap-sdk-1.16 # 首次配置,路径换你自己的
cmake --build build
./build/myapp.exe
预期输出:lwIP initialized, local IP is 192.168.0.200 → connected, sending HTTP request → 打印收到的 HTTP 响应。完整的验证清单见第四部分。
如果你只想要结果,到这里就够了。下面讲每个文件为什么这么写,以及我首次构建时踩的三个坑。
第二部分:为什么这么写
2.1 两个"应有认识"
应有认识 1:"移植部分功能"到底是在裁什么。
一开始我以为"移植部分功能"是从 lwIP 里挑一些 .c 文件出来编译。看了 BUILDING 和 Filelists 之后发现不是,手段有两层,一层比一层细:
| 层 | 手段 | 控制什么 |
|---|---|---|
| 库级 | 链接哪些库目标 | lwipcore(协议栈本体,必需)、lwipallapps(http/mqtt 等官方应用,可选)、lwipcontribexamples(示例,不要) |
| 功能级 | lwipopts.h 编译开关 |
LWIP_IPV6、LWIP_UDP、LWIP_SOCKET...... 关掉的功能根本不参与编译 |
一句话总结:不是挑源文件,是"选库 + 拨开关"!!
应有认识 2:一个能跑的 lwIP 应用,最少需要哪几块。
对着 example_app 的依赖关系捋一遍,缺一不可的是四块:
lwipcore------ 协议栈本体(src/Filelists.cmake提供)- sys_arch 移植层 ------ 线程/信号量/邮箱抽象。Windows 上直接用 win32 端口的
sys_arch.c,不用自己写 - netif 网卡驱动 ------ win32 端口的
pcapif.c(经 Npcap 收发真实以太网帧,原理见本系列第一篇) - 两个配置头 ------
lwipopts.h(协议栈配置)和lwipcfg.h(网卡等端口配置,pcapif.c会直接#include它)
2 和 3 合起来就是 win32 端口库 lwipcontribportwindows(contrib/ports/win32/Filelists.cmake 提供)。所以我们要写的只有:一个 CMakeLists、一个 main.c、两个配置头。
2.2 三个文件的写法要点
CMakeLists:骨架只有四行,其余都是路径和变量。
cmake
include(${LWIP_DIR}/src/Filelists.cmake) # 拿到 lwipcore(协议栈本体)
include(${LWIP_CONTRIB_DIR}/ports/win32/Filelists.cmake) # 拿到 lwipcontribportwindows(sys_arch + pcapif)
add_executable(myapp main.c)
target_link_libraries(myapp lwipcontribportwindows lwipcore)
这套写法是 BUILDING 文档规定好的
main.c:初始化四步 + 一个客户端线程。 对照 example_app 的 test.c(773 行,什么 PPP/SLIP/十几种应用都揉在里面),提炼出来初始化其实只有四步------先在 main 里启动协议栈,再在 tcpip 线程上下文里把网卡拉起来:
c
tcpip_init(myapp_on_tcpip_init, &init_sem); // main 里:启动协议栈,回调在 tcpip 线程里执行
// myapp_on_tcpip_init 里:网卡三件套
netif_add(&myapp_netif, &ipaddr, &netmask, &gw, NULL, pcapif_init, tcpip_input);
netif_set_default(&myapp_netif);
netif_set_up(&myapp_netif);
应用逻辑是一个独立线程,用 socket API 连 1.1.1.1:80 发一个 HTTP GET。
lwipcfg.h:为什么只剩这几行。 example_app 那份有 81 行(PPP 账号、SLIP 参数、十几个 LWIP_XXX_APP 应用开关),我的应用用不到。哪些宏是 pcapif 真正读的?对着 pcapif.c 的源码数的:PACKET_LIB_ADAPTER_NR/GUID(选网卡)、LWIP_MAC_ADDR_BASE(MAC),加上我自己 main.c 用的 LWIP_PORT_INIT_*。其余的在这个工程里都是死宏,删掉。(如果你想确认自己环境下的答案,grep 一下 contrib/ports/win32/ 目录就有,源码是最终事实。)
2.3 两个容易翻车的小细节
细节 1:-Werror 下死循环后面不能写 return 0;。 工程继承了 lwIP 的严格告警集(-Werror -Wunreachable-code 等,来自 CMakeCommon.cmake)。for (;;) 是编译器可证明的无限循环,后面跟的任何语句都是 unreachable code,直接报错。所以 main 函数让循环自然结束,不落 return(C99 起 main 跌出函数体等价于 return 0)。
细节 2:断言钩子要跟着 lwipopts.h 走。 复制来的 lwipopts.h 末尾把 LWIP_PLATFORM_ASSERT 指向一个外部函数 lwip_example_app_platform_assert(example_app 里定义的)。我的 main.c 里必须实现一个同名的,否则链接报错。名字不要改,除非同步改 lwipopts.h。
第三部分:踩坑实录(首次构建的 debug 过程)
理想很丰满:1.4 节那两条命令,首次执行实际连踩三坑------配置期一个(坑 1)、编译 lwIP 库一个(坑 2)、编译我自己的 main.c 一个(坑 3)。三个坑有个共同点:example_app 碰巧都绕过去了,而我的最小工程没有------这正是"从跑示例到用库"要付的学费。逐个交代。
3.1 坑 1:CMake 配置就报错------lwipcontribaddons 是个不存在的目标
现象(第一步 cmake -S . -B build -G Ninja 直接炸了):
vbnet
CMake Error at D:/Projects/lwip/contrib/ports/win32/Filelists.cmake:51 (target_compile_definitions):
Cannot specify compile definitions for target "lwipcontribaddons" which is
not built by this project.
Call Stack (most recent call first):
CMakeLists.txt:38 (include)
排查:对着 win32 端口的 Filelists 看,第 51 行在给一个叫 lwipcontribaddons 的目标设编译宏。但这个目标定义在 contrib/Filelists.cmake 里------而我的工程故意不 include 那个文件(见 2.2 节,这是"不要示例"的落点)。对一个不存在的目标设属性,CMake 直接报错。example_app 是全量构建、那个目标永远存在,所以上游自己踩不到:这行代码隐含假设了"你一定会把 contrib 的 Filelists 也 include 进来",对独立工程场景是个疏漏。
正解:给这行加个"目标存在才执行"的保护。原来是:
cmake
target_compile_definitions(lwipcontribaddons PRIVATE ${LWIP_DEFINITIONS} ${LWIP_MBEDTLS_DEFINITIONS})
改为:
cmake
# lwipcontribaddons 这个目标只有 include 了 contrib/Filelists.cmake 才存在;
# 只 include 本端口文件的独立工程不能碰它,所以加个"目标存在才执行"的保护
if(TARGET lwipcontribaddons)
target_compile_definitions(lwipcontribaddons PRIVATE ${LWIP_DEFINITIONS} ${LWIP_MBEDTLS_DEFINITIONS})
endif()
对全量构建零影响(目标存在时行为不变)。它是 lwIP 仓库里继 errno.h、SYSTEM include 之后的第三个本地补丁(提交在 fix/win32-filelists-standalone 分支)------1.1 节那条 git apply 打的就是它。
一句话总结:只 include 部分 Filelists 时,被 include 的脚本不能假设别的脚本定义的目标存在!!
3.2 坑 2:ppp_settings.h------我一个以太网应用,为什么要 PPP 的头?
现象(配置过了,编译 lwipcore 时 PPP 那串源文件全炸):
less
D:/Projects/lwip/src/include/netif/ppp/ppp_impl.h:41:10: fatal error: ppp_settings.h: No such file or directory
41 | #include "ppp_settings.h"
| ^~~~~~~~~~~~~~~~
compilation terminated.
排查:src/Filelists.cmake 把全部 PPP 源文件编进 lwipcore 的编译清单------注意这是文件清单层面 的全量,PPP_SUPPORT=0 控制的是功能逻辑,挡不住编译器打开这个 .c 文件、执行它的 #include。而 ppp_impl.h 无条件包含 ppp_settings.h:这是 lwIP 约定里由应用侧提供 的配置头(和 lwipopts.h 同类),example_app 的目录里正好有这个文件、又在它的 include 路径上,所以官方构建从来不缺。
正解:把 example_app 那份 ppp_settings.h 复制到我工程根目录(那里本来就在 LWIP_INCLUDE_DIRS 里)------仓库里带的就是这份。
一句话总结:XXX_SUPPORT=0 关的是功能,不是编译清单;清单里的文件要包含的头,一个都不能少!!
3.3 坑 3:40 个 errno 宏重定义------include 顺序也是 bug
现象(编译我自己的 main.c 时一口气约 40 个,摘一头一尾):
javascript
D:/Projects/lwip/src/include/lwip/errno.h:73:10: error: 'ETXTBSY' redefined [-Werror]
73 | #define ETXTBSY 26 /* Text file busy */
C:/msys64/ucrt64/include/errno.h:218:9: note: this is the location of the previous definition
218 | #define ETXTBSY 139
...(ETXTBSY / EDEADLK / ENOSYS / ECONNRESET / EINPROGRESS 等约 40 个,全是同一模式)...
cc1.exe: all warnings being treated as errors
是不是相当熟悉?这和我们在第一篇遇到的报错极为相似。当时我们采用 #undef 解决,其实是一种偷懒------只按住了触发的那两个宏;现在问题全面爆发了,约 40 个一起撞车,#undef 按不住了。
正解是换个顺序:把 lwIP 头放在系统头之前 include 。顺序倒过来后,重定义发生在 UCRT64 的系统头里,gcc 默认不报告系统头中的警告,冲突消解(这本来就是 lwIP 的惯例:lwip/opt.h 必须第一个包含)。原来是:
c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include "lwip/opt.h"
#include "lwip/init.h"
/* ... 其余 lwIP 头 ... */
改为:
c
/* lwIP 的头必须放在系统头之前:lwip/errno.h(经 lwip/sockets.h 拉入)定义了
* 自己的一套 errno 值。如果 C 库的 <errno.h> 先进来(UCRT64 上 stdlib.h ->
* malloc.h -> mm_malloc.h 会把它拉进来),lwIP 的重定义在 -Werror 下全是错误。
* 反过来让 lwIP 先来,重定义就发生在 C 库的系统头里,gcc 默认不报告 */
#include "lwip/opt.h"
#include "lwip/init.h"
/* ... 其余 lwIP 头 ... */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
副作用要交代:这样 main.c 里看到的 errno 宏值最终是系统的(ECONNRESET=108)而不是 lwIP 的(104),所以应用代码里别拿 errno 宏做符号比较,只做数字打印就没事。
一句话总结:两套 errno 定义撞车时,让 lwIP 的先来------系统头里的重定义,gcc 会闭嘴!!
第四部分:验证与后续
4.1 验证清单
和 example_app 时代同一个标准:
- 终端打印
lwIP initialized, local IP is 192.168.0.200,然后connected, sending HTTP request,最后打印收到的响应字节 - 局域网另一台设备 ping
192.168.0.200通(本机 ping 不通是原理性限制,hairpin 过滤,见第一篇 3.5 节,别又拿本机试) - 可选:Wireshark 抓包看三次握手,和第二篇分析的序列对照
实测(2026-08-03,修复三坑后一次通过):编译链接干净收尾([92/92] Linking C executable myapp.exe),运行 ./build/myapp.exe 输出:
javascript
0: NPF_{9198ECB8-5323-4B4B-8C80-102D46E60528}
Desc: "WAN Miniport (Network Monitor)"
...(中间是 pcapif_helper 枚举的本机网卡列表,略)...
4: NPF_{D83D00A3-B69D-4368-ABC0-9B1AAB9F4E61}
Desc: "Killer E3100G 2.5 Gigabit Ethernet Controller"
...
Using adapter_num: 4
Using adapter: "Killer E3100G 2.5 Gigabit Ethernet Controller"
lwIP initialized, local IP is 192.168.0.200
connecting to 1.1.1.1:80 ...
connected, sending HTTP request
received 381 bytes:
HTTP/1.1 301 Moved Permanently
Server: cloudflare
Date: Mon, 03 Aug 2026 08:30:46 GMT
Content-Type: text/html
Content-Length: 167
Connection: close
Location: https://1.1.1.1/
CF-RAY: a253f6553d64da3a-LAX
<html>
<head><title>301 Moved Permanently</title></head>
<body>
<center><h1>301 Moved Permanently</h1></center>
<hr><center>cloudflare</center>
</body>
</html>
myapp client finished.
开头那串网卡列表是 pcapif_helper.c 枚举出来给你对 GUID 用的------1.3 节说的"照着抄"就是抄这里,lwipcfg.h 里填的 GUID 命中了序号 4 的 Killer E3100G,选卡正确。之后几行就是全链路验证:协议栈初始化拿到 IP → TCP 三次握手连上 1.1.1.1:80 → 发出 HTTP GET → 收到 301 响应。
4.2 跑通之后:裁剪路线
拿到基准后,lwipopts.h 的裁剪才正式开始,顺序建议从大到小:LWIP_IPV6 0 → PPP_SUPPORT 0 → 不用的统计/调试选项。每改一项重新编译------能过编译就说明裁剪自洽,跑一遍客户端说明行为没破。不过在 Windows 上这步纯属锦上添花,毕竟能跑就行;真正的裁剪压力要到嵌入式目标上才有
总结与预告
至此,lwIP 从"我跑的示例"变成了"我工程里链接的库"。回顾最有价值的两个认知:
BUILDING文档要认真读:lwIP 的集成方式(include Filelists、选库链接)都有包含,简洁且准确- "移植"的裁剪是选库 + 拨开关 ,不是挑源文件;
lwipopts.h才是功能裁剪的主战场
下一篇让 lwIP 掉个头当服务器,实现一个简单的敲木鱼小程序。
致谢
感谢 lwIP 作者的 BUILDING文件,本文的一切灵感皆源于此。
环境:Windows 11 / MSYS2 UCRT64 gcc 16.1.0 / lwIP 2.2.2 / Npcap SDK 1.16 作者的 BUILDING文件,本文的一切灵感皆源于此。
环境:Windows 11 / MSYS2 UCRT64 gcc 16.1.0 / lwIP 2.2.2 / Npcap SDK 1.16