构建

开发板交出自己的画面 — 两块实物、两种传输、一个客户端

作者: makemind · 2026年7月27日

之前几篇里的硬件全是模拟器。用 C 写、真的会编译会运行的模拟器,但没有玻璃、没有泥土、没有车。每次都在文里写明了。

这次不一样。桌上真的插着两块板。 一块连着 USB 线,一块在 Wi-Fi 那头。

而这一篇的论点是:接上这两块的方法差别有多大,接上之后又有多一样。

先看结果 —— 两块板交出来的画面

用 USB 串口接上的 WeAct H723(STM32H7) 交出来的画面。

On-board LED — Turn On / Turn Off / Read Info. 运行时渲染开发板给的定义后的真实截图
On-board LED — Turn On / Turn Off / Read Info. 运行时渲染开发板给的定义后的真实截图

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

Live uptime 45745 s · Subscribe/Unsubscribe · Snapshot · Store name/Load
Live uptime 45745 s · Subscribe/Unsubscribe · Snapshot · Store name/Load

这两块画面在这个仓库里都不存在。是开发板交出来的。

全景

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 ms24 · 21 ms
sys.info 往返11 · 11 ms69 · 43 ms
连接到收到画面46 ms189 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 这一篇没有接。板子支持,但这次运行没放进来。
  • 两块板的固件不是我写的。这篇文章做的是 接上去的那一侧

此内容需要开发者或更高等级

登录并升级您的方案即可继续阅读。

查看方案
Twitter