之前几篇里的硬件全是模拟器。用 C 写、真的会编译会运行的模拟器,但没有玻璃、没有泥土、没有车。每次都在文里写明了。
这次不一样。桌上真的插着两块板。 一块连着 USB 线,一块在 Wi-Fi 那头。
而这一篇的论点是:接上这两块的方法差别有多大,接上之后又有多一样。
先看结果 —— 两块板交出来的画面
用 USB 串口接上的 WeAct H723(STM32H7) 交出来的画面。

用 mDNS 找到、走 Wi-Fi TCP 接上的 ESP32 交出来的画面。同一个客户端,内容却多得多。

这两块画面在这个仓库里都不存在。是开发板交出来的。
全景
STM32H723 ──UART 115200──▶ serial_bridge (C) ──┐
├──stdio──▶ mcp_client + 运行时
ESP32 ──Wi-Fi TCP:6270──▶ tcp_bridge (C) ──┘ (两侧完全相同)
▲
└─ 用 mDNS `_mcp._tcp` 发现(地址不用人来写)
① 传输就是一个进程
MCP 客户端已经会启动一条命令、并用它的 stdio 说话。那么 把传输做成一个进程,客户端就既不需要串口支持,也不需要套接字支持。
串口那侧桥接的核心是这个。
struct termios tio;
tcgetattr(fd, &tio);
cfmakeraw(&tio); /* 不回显、不做行编辑、不做 CR/LF 转换 */
cfsetispeed(&tio, speed);
cfsetospeed(&tio, speed);
tio.c_cflag |= (CLOCAL | CREAD);
tio.c_cflag &= (tcflag_t)~CRTSCTS;
tcsetattr(fd, TCSANOW, &tio);
没有 cfmakeraw,终端驱动就会编辑行、插进 CR,JSON 就碎了。嵌入式里「为什么有时候解析会失败」,相当一部分就出在这里。
TCP 那侧则是在另一个地方,恰好有一行很关键。
/* 请求是很短的一行,答案马上就要。Nagle 为了填满一个分段而攥着不放,
* 得不到任何好处,只会增加时延。 */
int one = 1;
setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &one, sizeof(one));
而且 两侧有一件事做得一模一样。 开发板会把给人看的日志,打在和协议同一条线上。
/* 开发板会把给人看的日志打在和 JSON-RPC 响应同一条线上。那不是协议,
* 而且会惹恼客户端的解析器,所以只放看起来像 JSON 的行过去。其余送到
* stderr —— 看得见,但不会被当成响应。 */
if (up[0] == '{') {
printf("%s\n", up);
fflush(stdout);
} else {
fprintf(stderr, "[board] %s\n", up);
}
实际上 ESP32 的配网控制台到现在还会往同一个 UART 打 W (45475251) console_prov: ... 这样的行。没有这三行,客户端就会把那一行当成响应然后死掉。
② 接上之后就一样了
两个桥接的差别,上面就是全部。它们之上的客户端长这样。
final result = await McpClient.createAndConnect(
config: McpClient.simpleConfig(name: 'Board Probe', version: '1.0.0'),
transportConfig: TransportConfig.stdio(
command: link.command, // serial_bridge 或 tcp_bridge
arguments: link.arguments, // [端口, 波特率] 或 [主机, 端口]
),
);
选传输这件事,变成了选启动哪个程序。 再往下,代码不再分叉。
③ 每块板的画面形状不一样
这里是真的需要处理一下。两块板在 ui://app 里装的东西不同。
STM32 直接给一页(656 B)。
{"type":"page","title":"WeAct H723 MCP Node","content":{ ... }}
ESP32 给的是 应用(158 B)。不是画面,而是画面的地图。
{"type":"application","title":"ESP32 MCP Node",
"routes":{"/":"ui://page/main"},"initialRoute":"/",
"lifecycle":{"onReady":[{"type":"tool","tool":"sys.info"}]}}
所以客户端带了一个分支。
// 每块板形状不同。一块直接给页面,另一块给带路由的应用,
// 第一屏在那后面。
Map<String, dynamic> screen = def;
if (def['type'] == 'application') {
final routes = (def['routes'] as Map).cast<String, dynamic>();
final initial = def['initialRoute'] as String? ?? '/';
final uri = routes[initial] as String;
final page = await client.readResource(uri);
screen = jsonDecode(page.contents.first.text!) as Map<String, dynamic>;
}
ESP32 的那一页是 1894 B,并且在自己的生命周期上挂了订阅。
"lifecycle":{
"onReady":[{"type":"resource","action":"subscribe","uri":"sensor://uptime","binding":"uptime"}],
"onDestroy":[{"type":"resource","action":"unsubscribe","uri":"sensor://uptime"}]
}
画面打开就订阅传感器,关闭就退订。这份声明是装在板子里面的。
④ 地址不用人来写
接 ESP32 的时候没有手输 IP。是板子自己在广告自己。
$ dns-sd -B _mcp._tcp
Timestamp A/R Flags if Domain Service Type Instance Name
14:04:49.297 Add 2 15 local. _mcp._tcp. ESP32 MCP Node
$ dns-sd -L "ESP32 MCP Node" _mcp._tcp
ESP32 MCP Node._mcp._tcp.local. can be reached at mcp-esp32.local.:6270
v=0.1.0 id=esp32.node proto=ndjson
TXT 记录说的是 proto=ndjson —— 按行分隔的 JSON-RPC。和之前从 UART 过来的是同样的字节。所以只要套接字一开,就没有什么要翻译的了。
校验脚本直接用这个。没有硬编码的地址。
RESOLVED=$(timeout 6 dns-sd -B _mcp._tcp 2>/dev/null | awk 'NR>4 {...}')
DETAIL=$(timeout 6 dns-sd -L "$RESOLVED" _mcp._tcp 2>/dev/null | grep "can be reached at")
HOSTPORT=$(echo "$DETAIL" | sed -n 's/.*can be reached at \([^ ]*\).*/\1/p' | sed 's/\.$//;s/\.:/:/')
⑤ 运行与校验日志
一次运行里连着跑完两条链路的原文。
[+ 2ms] links to probe: serial, tcp
[+ 33ms] [serial] connected via ../serial_bridge/serial_bridge /dev/cu.usbmodem365D395E33331 115200
[+ 53ms] [serial] tools: led.set, sys.info
[+ 65ms] [serial] resources: ui://app, ui://app/info, bundle://manifest.json
[+ 79ms] [serial] ui://app — 656 B, type "page", title "WeAct H723 MCP Node"
[+ 426ms] [serial] led.set({"on":true}) -> "LED on" (1 ms)
[+ 438ms] [serial] sys.info({}) -> "LED=on uptime=185582085ms" (11 ms)
[+ 450ms] [serial] led.set({"on":false}) -> "LED off" (10 ms)
[+ 462ms] [serial] sys.info({}) -> "LED=off uptime=185582110ms" (11 ms)
[+ 463ms] [serial] uptime advanced 185582085 -> 185582110 ms
[+ 5980ms] [tcp] connected via ../tcp_bridge/tcp_bridge mcp-esp32.local 6270
[+ 6044ms] [tcp] tools: led.set, sys.info
[+ 6110ms] [tcp] resources: ui://app, ui://page/main, ui://app/info, bundle://manifest.json, sensor://uptime
[+ 6169ms] [tcp] ui://app — 158 B, type "application", title "ESP32 MCP Node"
[+ 6169ms] [tcp] application — initialRoute "/" -> ui://page/main
[+ 6476ms] [tcp] ui://page/main — 1894 B
[+ 6512ms] [tcp] sensor://uptime read once -> {"uptime_s":47095} (bound as "uptime", not streamed)
[+ 6622ms] [tcp] led.set({"on":true}) -> "LED on" (24 ms)
[+ 6692ms] [tcp] sys.info({}) -> "LED=on uptime=47095369ms" (69 ms)
[+ 6714ms] [tcp] led.set({"on":false}) -> "LED off" (21 ms)
[+ 6757ms] [tcp] sys.info({}) -> "LED=off uptime=47095451ms" (43 ms)
[+ 6758ms] [tcp] uptime advanced 47095369 -> 47095451 ms
[+ 6759ms] done — 2 link(s) probed
构建与通过是这样的。
$ cc -O2 -o serial_bridge serial_bridge.c
$ cc -O2 -o tcp_bridge tcp_bridge.c
$ flutter analyze
No issues found!
$ bash verify.sh
/dev/cu.usbmodem365D395E33331 -> WeAct H723 MCP Node
discovered "ESP32 MCP Node" at mcp-esp32.local:6270
2 link(s) probed · screens rendered from the boards' own definitions · LED round-tripped on each
实测值
| UART (STM32H723) | Wi-Fi TCP (ESP32) | |
|---|---|---|
led.set 往返 | 1 · 10 ms | 24 · 21 ms |
sys.info 往返 | 11 · 11 ms | 69 · 43 ms |
| 连接到收到画面 | 46 ms | 189 ms |
ui://app 大小 | 656 B (page) | 158 B (application) |
| 第一屏 | — | 1,894 B (ui://page/main) |
大小和时间性质不同。 大小来自文件,量多少次都一样;时间每次运行都会变。 上面这张表取自随本文一起发布的 run.log 的那一次运行,别的运行里 Wi-Fi 那侧甚至高出两倍以上。所以这里该读的不是精确数字,而是 量级的差别 —— UART 在个位到十几毫秒,Wi-Fi 在几十毫秒。设计 UI 时,「按了就好」能指望的那一侧,和必须把等待状态画出来的那一侧,就在这条线上分开。
LED 是真的亮了又灭。 而且状态不是从本地变量看的,是 回头问板子 确认的。校验脚本要求两个方向都过 —— 只过一边可能只是回声。在此之上还要看 两次读取之间 uptime 有没有前进。固定响应过不了这一关。
没能测到的
- ESP32 画面上的
Live uptime是读了一次的值,不是流式的。 它确实是板子的真实 uptime(45,745 s),但这套脚手架不驱动生命周期动作,所以订阅没挂上。日志里也记成了read once … (bound as "uptime", not streamed)。订阅流的真实行为,这一篇没有测。 - Wi-Fi 时延是 一次运行、一台路由器、一个房间里量的。样本少、环境单一。实际每次重跑数值都在抖,尤其含 mDNS 查询的那次,连上要花好几秒 —— 时间值不可复现,这件事本身就是一项观测。
- BLE、HTTP、USB CDC 这一篇没有接。板子支持,但这次运行没放进来。
- 两块板的固件不是我写的。这篇文章做的是 接上去的那一侧。