在 Windows 上跑 lwIP(四):敲电子木鱼,享赛博人生
前三篇里 lwIP 一直是"客户端":搭环境、抓包看它往外连、移植进我自己的工程发 HTTP GET。本篇让它掉个头------在 80 端口上听,等别人连进来:一个自己写的 HTTP 服务器跑在 lwIP 上,手机浏览器连进来敲木鱼。
本篇配套工程已开源 :gitee.com/buqibuli/lw...,可整仓克隆对照。
全文分四部分:效果展现 (先看手机敲槌、电脑演动画是什么样子)→ 服务器是怎么写的 (三板斧、主流程、路由、页面内嵌)→ 原理解释 (请求怎么驱动程序、显示端为什么绕开 lwIP、两个配置数值为什么决定写法)→ 验证(网络拓扑 + Wireshark 抓包佐证)。
第一部分:效果展现
1.1 玩法展示
在手机端敲下木鱼锤,电脑端的木鱼就挨一下敲:功德 +1,烦恼 -1,获得赛博佛祖的庇护 ~( ˘ω˘ )人
手机端------槌在人手,想敲就敲 ( ・ิω・ิ)っ:
电脑端------鱼在桌上,默默挨敲 (;ω;):

1.2 跑起来终端上能看到什么
构建零报错(移植篇修过的三个坑全部免疫)。运行后终端打印:

读法:第一行是显示端先就绪(display page at http://127.0.0.1:8231/,浏览器同时被自动打开);中间那串 NPF_{...} 是 Npcap 枚举的本机网卡清单,Using adapter_num: 4 选中的正是 lwipcfg.h 里指定的那块有线网卡(Killer E3100G)(我们的老朋友了) ;接着 lwIP initialized, local IP is 192.168.0.200、80 端口开始监听,服务器就绪;最后三行 muyu: tap #1~3 就是手机连点的实录。
程序启动时 PC 浏览器自动打开 127.0.0.1:8231 的木鱼页面(先点一下页面解锁音效,浏览器自动播放策略的要求),之后手机每点一下槌,终端就同步打一行 muyu: tap #N。
第二部分:服务器是怎么写的
工程搭建和上一篇移植篇完全同构(配置文件原样复制、lwIP 侧补丁在 patches/、还是那块网卡那个 IP),开源仓库里有完整构建说明,这里不重复------直接进服务器代码。
2.1 服务器的新知识只有三板斧
上一篇移植篇里,我的工程用 socket API 写了个 TCP 客户端,连 1.1.1.1:80 发 HTTP GET------那是 lwIP 作为协议栈"往外连"。这次反过来:让 lwIP 在 80 端口上听,等别人连进来。
客户端和服务器在 socket API 上,创建(socket())和收发(send()/recv())一模一样,差别只有两行:
| 步骤 | 客户端(移植篇) | 服务器(本篇) |
|---|---|---|
| 定址 | 不 bind,内核随机分个端口 |
bind() 钉死在 80 端口 |
| 建立连接 | connect() 直奔 1.1.1.1:80 |
listen(ls, 2) ,然后 accept() 守株待兔 |
这两行里服务器多出来的调用就三板斧:bind()、listen() 和 accept()。
三板斧之外,全是客户端就会的东西。所以练服务器,练的就是这三板斧,加上"请求进来之后怎么办"------对一个 HTTP 服务器来说,就是解析请求行、拼响应、然后让我的程序干活。最后这条正是本篇的重心(见 3.1 节)。
2.2 服务器主流程
应用是一个独立线程(main.c 里的 muyu_server_thread):
c
ls = socket(AF_INET, SOCK_STREAM, 0);
bind(ls, (struct sockaddr *)&addr, sizeof(addr)); /* 钉在 80 端口 */
listen(ls, 2);
for (;;) {
cs = accept(ls, NULL, NULL); /* 守株待兔 */
muyu_handle_client(cs); /* 服务这一个连接,直到它关闭 */
}
这段程序有两个重要特点:
- 单线程顺序处理 :一次只服务一个连接。这是单人单机游戏,手机就是唯一的客户端,排队?不存在的。好处是功德计数器只有这一个线程碰,
static u32_t裸变量,锁都不用 - 每个连接内部是收包循环,不是收一次就关------原因在 3.3 节,这是本篇最重要的一个决定
2.3 路由:四个路径
请求解析粗暴直接:recv 一次(HTTP GET 请求撑死几百字节,1KB 的接收缓冲足够),只看第一行,要求以 GET 开头,然后取第一个空格(或 ?、\r)之前的路径:
| 路径 | 行为 | 响应 |
|---|---|---|
GET / |
发手机页面(棒槌 + 音效开关) | 200,text/html; charset=utf-8 |
GET /tap |
计数器 +1,通知 PC 显示端 | 200,application/json,{"count":N} |
GET /count |
只查不加(其实两个页面都没调它,留着我调试用) | 同上 |
GET /sound?on=0/1 |
切 PC 端音效 | 200,{"sound":N} |
| 其他 | --- | 404,空 body |
响应头最多三行:状态行、Content-Type、Content-Length(404 连 Content-Type 都没有)。没有 Connection: close------故意保持连接打开,下一个请求复用同一条 TCP 连接(为什么,见 3.3 节)。
/tap 的处理是全程序的心脏,完整就这几行(main.c 里的 muyu_answer):
c
if (plen == 4) { /* "/tap" */
muyu_merit_count++;
printf("muyu: tap #%u\n", (unsigned int)muyu_merit_count);
muyu_display_tap(muyu_merit_count); /* PC-side animation + sound */
}
/* 然后拼 {"count":N} 回给手机 */
计数、打日志、通知显示端、回 JSON------一次网络请求驱动程序逻辑,全部发生在这四行里。其中第三行是本篇最值得说的一句,单独给它一节(见 3.1 节)。
2.4 页面怎么进固件:makefsdata 式内嵌
手机页面由 lwIP 发送,而 lwIP 这边没有文件系统概念(它模拟的是嵌入式设备),页面必须编进二进制。但把 HTML 手写进 C 字符串又丑又难改(没有语法高亮、到处是转义纪律)。
解法学 lwIP 官方 httpd 的 makefsdata:页面是真实文件,C 数组是构建产物。
web/phone.html:手机页面的真身,标准 HTML,随便写随便改cmake/gen_page.cmake:一个二十来行的 CMake 脚本,把反斜杠和双引号转义、换行变成"\n"拼接,包成 C 字符串头文件CMakeLists.txt里一个add_custom_command盯住 phone.html:改了 HTML,cmake --build自动重新生成build/phone_page.hmain.c只写一句#include "phone_page.h"
于是一个有趣的不对称出现了:改手机页面要重新编译(它编进固件),改电脑页面刷新浏览器就行(显示端运行时读盘)。这正是真实嵌入式里"固件内嵌资源"和"外部文件"的本质差别------在这个玩具项目里体验了一把真流程。
第三部分:原理解释
效果部分画面很热闹,但对 lwIP 来说这一切非常朴素:它全程只发两种东西------一个约 2KB 的 HTML 页面,和几十个字节的 JSON。动画、音效、计数显示全不在 lwIP 侧(声音是浏览器 WebAudio 现场合成的,连音频文件都没有)。
一句话总结:lwIP 越笨,游戏越稳------它只是个会数数的 HTTP 服务器!!
那 lwIP 到底管了什么、为什么这么管?这部分回答三个"为什么":一次请求怎么驱动我的程序、电脑端的画面为什么不归 lwIP 管(本篇唯一一个 lwIP 答不上来的问题)、lwipopts.h 里的两个数字为什么直接决定代码写法。
3.1 核心:一次敲击的完整链路
手机点一下槌,到我电脑屏幕上的木鱼压扁,中间走的路:
scss
手机浏览器 fetch('/tap')
│ (Wi-Fi → 路由器 → 网线 → 真实网卡 → Npcap)
▼
lwIP 协议栈:IP → TCP → 80 端口的那条连接
▼
muyu_answer():解析出 "/tap"
├── 计数器 +1,printf 打日志
├── muyu_display_tap(count) ──→ PC 屏幕动画 + 音效(怎么做到的?见 3.2 节)
└── 回 JSON {"count":N} 给手机
这里坦白一句:手机页面其实并不消费这个 JSON ------点槌的瞬间它本地就飘出"功德 +1",根本不等响应;功德总数的数字只有 PC 端在显示。/tap 照常回 {"count":N},一是应答像样,二是哪天想让手机也显示总数,接口是现成的,顺手的事。
lwIP 和我的程序之间的"连接面",就是 muyu_display.h 里的三个纯 C 函数:
c
int muyu_display_start(void); /* main 里启动时调一次 */
void muyu_display_tap(unsigned int count); /* 每敲一下调一次 */
void muyu_display_sound(unsigned int enabled); /* 切音效时调一次 */
main.c 调用它们时不需要知道对面是什么 ------浏览器?窗口?都不关 lwIP 的事。这是刻意做的黑盒:main.c 只 include lwIP 的头,muyu_display.c 只 include Windows 的头,两边靠这个纯 C 头文件隔开。为什么要隔得这么绝?因为 lwIP 的 lwip/sockets.h 和 Winsock 的 winsock2.h 定义了一堆同名不同构的东西(sockaddr、send、recv......),同一个编译单元里 include 两个就是灾难。
3.2 显示端为什么必须绕开 lwIP:hairpin 与 localhost 绕行
那显示端到底是什么?这里要坦白一个 lwIP 的硬伤:电脑本机的浏览器访问不到自己的 lwIP 。第一反应肯定是"PC 浏览器直接开 http://192.168.0.200/ 当显示屏不就行了"------不行,而且这个"不行"是硬件级的:本机发出的帧要经过交换机再到网卡,而交换机不会把帧从进来的端口再发回去(hairpin 过滤)。第一篇搭环境时就踩过同款:本机 ping 不通同网卡上的 lwIP,验证必须用另一台设备。只要 lwIP 独占这块网卡,本机任何程序都够不到它------"电脑看动画"这个需求,天然不属于 lwIP。
我的解法是------同一个进程,两套协议栈:

- lwIP 栈管手机:这是练习目的,不能动
- Winsock 栈管显示 :Windows 自己的 TCP 栈,只绑回环
127.0.0.1:8231------回环不过网卡,天然绕开了 hairpin,跟 lwIP 完全独立
PC 浏览器打开它拿到木鱼页面,同时挂一条 SSE 长连接(EventSource------一条永不结束的 HTTP 请求,服务端握着它,有事件就往里写一行);muyu_display_tap() 内部就是往这些连接里广播 data: {"count":N}(N 就是当前功德数),浏览器收到就演动画。SSE 怎么保活、静态文件怎么发,都是 muyu_display.c 那个黑盒里的事,不是本篇重点,你只需要知道------对 lwIP 侧来说,世界到三个函数为止。
3.3 应有认识 1:MEMP_NUM_TCP_PCB=5------keep-alive 不是可选项
上一篇的教训是"不要一次改一堆然后面对一堆报错"。这次学乖了:动手写代码之前,先把 lwipopts.h 里和应用直接相关的数值翻出来看了一遍。结果发现两个不理解就会直接把游戏玩死的约束。这次它们不是"坑"------因为设计阶段就绕开了------但理解它们比踩一次更值。
第一个数字:lwipopts.h 里这个宏只有 5:整个协议栈同时只能存在 5 条 TCP 连接控制块(PCB)。
想象一下如果服务器按"教科书简单写法"每回答一个请求就 close():TCP 主动关闭的一方要进 TIME_WAIT 状态,TCP_MSL 用默认值 60 秒,TIME_WAIT 等 2×MSL------每条关掉的连接要占着 PCB 两分钟 。手机点一下木鱼就是一条新连接,点四五下,5 个 PCB 全卡在 TIME_WAIT,accept 再也拿不到新连接,游戏直接死透,而且一死两分钟。
所以 muyu_handle_client 是个循环:
c
for (;;) {
ret = recv(cs, buf, sizeof(buf) - 1, 0);
if (ret <= 0) {
break; /* 对端关闭,或保活超时 */
}
buf[ret] = '\0';
muyu_answer(cs, buf); /* 解析并回答这一个请求 */
}
close(cs);
响应里不发 Connection: close,手机浏览器自然复用同一条连接反复发 /tap------整个游戏过程只占用 1~2 个 PCB 。连接也不能无限吊着(否则服务器线程被一个空闲连接占死),用 SO_RCVTIMEO 设了 30 秒超时:30 秒没动静就主动断开,回到 accept 等下一个。
一句话总结:资源 constrained 的协议栈上,"一请求一连接"是奢侈,连接复用是活命!!
4.2 节会用 Wireshark 抓包证明:连续十几次敲击,走的是同一条 TCP 连接。
3.4 应有认识 2:TCP_SND_BUF=2048------send 不保证发完
第二个数字:TCP_SND_BUF 只有 2048 字节 ,而手机页面约 2KB,已经在边缘上。更本质的问题是:send() 本来就不保证一次把你给的全部字节都收下------发送缓冲能装多少它收多少,返回值是实际收下的字节数。桌面编程里很多人忽略这点是因为缓冲够大、几乎从不触发;在 lwIP 这种抠内存的栈上,2KB 页面配 2KB 缓冲,部分发送是日常。
所以发送必须写循环:
c
static int
muyu_send_all(int s, const void *data, size_t len)
{
const char *p = (const char *)data;
int ret;
while (len > 0) {
ret = send(s, p, len, 0);
if (ret <= 0) {
return -1;
}
p += ret;
len -= (size_t)ret;
}
return 0;
}
一句话总结:send 的返回值不是错误码,是进度条------不循环就是发不全!!
第四部分:验证
4.1 网络拓扑:电脑插网线,手机连 Wi-Fi,怎么算"同一网络"?
不怕你们笑话,我一开始嘀咕过:电脑走的是有线,手机走的是无线,这俩能通吗?答案是天然能通------家用路由器的 LAN 口和 Wi-Fi 就在同一个网段里:
markdown
手机 ──Wi-Fi──┐
├─ 路由器(192.168.0.1,DHCP 发 192.168.0.x)
电脑 ──网线───┘ lwIP 挂在电脑网卡上,静态 192.168.0.200
4.2 Wireshark 佐证 keep-alive
在有线网卡上抓包,手机连点七下。过滤器用 ip.addr == 192.168.0.200 && tcp.port == 80 && (tcp.flags.syn == 1 || http)------只留握手包和 HTTP 报文,滤掉满屏的 ACK:

解读:最上面 264/267 号是全场唯一一次三次握手 (手机 192.168.0.134 从源端口 35182 发起);往下每一对 GET /tap → 200 OK 就是敲一下,七对全走在这同一条连接上------如果是"一请求一连接",每个 GET 前面都会冒出一对新的 SYN、源端口一次一换,这里完全没有。
这就是 3.3 节"整个游戏只占用 1~2 个 PCB"的线上证据。
总结
至此,lwIP 在我的 Windows 模拟环境里完成了"客户端 → 服务器"的转身。我在Windows上跑lwip的旅程也告一段落了,回顾整个探索过程,我从几乎对网络一无所知,到如今可以尝试应用简单的lwip实现基本的通信功能;从对知识学习的无从下手,到学会挑选合适的内容学习,甚至书写自己的博客维护自己的代码仓库......这些都是我这次的宝贵的收获。虽然最后的最后我的成果也只是实现了简单的赛博木鱼功能,这看起来有些南辕北辙,毕竟这个功能直接用Windows自身的协议栈实现更自然也更简单。但是我想可能我这个项目的意义本身也不在于实现一个实用的功能,我的整个试错过程都积累了宝贵的经验。这也正如我们的赛博木鱼,其意义并不只是听到木鱼的敲击声,更在于其带来的内心安宁的回响。
环境:Windows 11 (10.0.26200) / MSYS2 UCRT64 gcc 16.1.0 / lwIP 2.2.2(fix/win32-filelists-standalone 分支)/ Npcap SDK 1.16 / 手机与电脑同一路由器/一颗虔诚的心