在芯片上 — 固件用 C,画面用定义
6 分钟做芯片的公司不只卖芯片。芯片之外还会出 评估板——一块塞到你手里、告诉你「我们的芯片能干这些」的小板子。
给这块板加屏幕的那一刻,活就变大了。叠图形库、塞字体、接触摸驱动、排版面,然后把这一整套烧进 Flash。挪一个按钮,再烧一次。所以多数评估板 不带屏幕就出货,需要屏幕的客户去找别家。
这一篇换个做法。不在板上叠 GUI。 而是让开发板交出两样东西——它能做什么的清单,和展示这些事的画面定义。画,在外面画。
用桌上的 WeAct STM32H723 真板做的。
开发板交出来的东西
用 USB 串口接上去问,它这样回答。
[serial] connected via serial_bridge /dev/cu.usbmodem365D395E33331 115200
[serial] tools: led.set, sys.info
[serial] resources: ui://app, ui://app/info, bundle://manifest.json两个工具,三个资源。 这就是这块板表面的全部。
工具长这样。
{"tools":[
{"name":"led.set","description":"Turn the on-board LED on or off",
"inputSchema":{"type":"object","properties":{"on":{"type":"boolean"}},"required":["on"]}},
{"name":"sys.info","description":"Report LED state and uptime",
"inputSchema":{"type":"object","properties":{}}}]}led.set 和 sys.info。这块板还能干很多别的——擦 Flash、改时钟、跳进 Bootloader。那些能力不在清单上,不在清单上就没有名字可以叫。
画面也在板子手里
读 ui://app,画面定义就出来了。
[serial] ui://app — 656 B, type "page", title "WeAct H723 MCP Node"656 字节。 一块画面就这么点。客户端里没有一行是为这块板写的 UI 代码——接过来就画。

按一下,板子有反应
画面上的按钮调用 led.set。是真实的往返。
[serial] led.set({"on":true}) -> "LED on" (1 ms)
[serial] sys.info({}) -> "LED=on uptime=205491208ms" (10 ms)
[serial] led.set({"on":false}) -> "LED off" (10 ms)
[serial] sys.info({}) -> "LED=off uptime=205491232ms" (11 ms)
[serial] uptime advanced 205491208 -> 205491232 ms桌上那块板的 LED 亮了又灭。 再看最后一行——uptime 从 205491208 走到 205491232,前进了 24 毫秒。这不是事先填好的答案,是那一刻板子正在数的值。录好的响应,问两次会得到同一个数字。
往返是 1 到 11 毫秒,因为跑在 UART 上。
同一个客户端也能接到别的芯片
这就是这个结构的价值所在。同一份代码,隔着 Wi-Fi 接上了 ESP32。
[tcp] connected via tcp_bridge mcp-esp32.local 6270
[tcp] tools: led.set, sys.info
[tcp] ui://app — 158 B, type "application", title "ESP32 MCP Node"
[tcp] application — initialRoute "/" -> ui://page/main
[tcp] ui://page/main — 1894 B
[tcp] led.set({"on":true}) -> "LED on" (89 ms)
[tcp] uptime advanced 67004328 -> 67004391 ms
另一家的芯片、另一种传输、同一个客户端。 变的只有一个桥接程序。
而且两块板的画面结构不一样。STM32 交出的是单页(type: "page",656 B),ESP32 交出的是带路由的应用(type: "application",158 B 加一页 1894 B)。画面是什么形状,由板子自己定。
时延不同。UART 是 1–11 ms,Wi-Fi TCP 是 17–89 ms。量级不同,而这个差别会分开 UI 的设计——个位数那侧可以指望「按了就好」,几十毫秒那侧必须把等待状态画到屏幕上。
传输就是一个程序
让客户端不必懂串口的办法:把传输挪成 一个独立程序。
mcp_client ──stdio──▶ serial_bridge ──UART──▶ STM32H723
mcp_client ──stdio──▶ tcp_bridge ──TCP───▶ ESP32桥接把按行分隔的 JSON 原样转过去。串口那侧用 cfmakeraw 关掉终端加工,TCP 那侧用 TCP_NODELAY 让短请求不被攒在缓冲里。
/* 同一条规则:只有 JSON 行才是协议。板子往同一个流里打备注,
* 客户端也不能被噎住。 */
if (up[0] == '{') { printf("%s\n", up); fflush(stdout); }
else { fprintf(stderr, "[board] %s\n", up); }选传输这件事,缩成了选启动哪个程序。 客户端一个字节都不用改。
做的过程中,客户端违反了规范
一接上真板,listResources() 当场就死。
type 'Null' is not a subtype of type 'String'板子返回的资源没有 description,而 mcp_client 1.1.1 把这个字段当成非空来转换。MCP 规范里 description 是可选的,板子是对的。 2.1.0 已经修好,升上去就解决了。
只跑模拟器是不会碰到这个缺陷的。我自己写的模拟器一定会把 description 填满再发。
校验
$ bash verify.sh
[1/6] bridges (C)
[2/6] serial board? /dev/cu.usbmodem365D395E33331 -> WeAct H723 MCP Node
[3/6] network board? (mDNS _mcp._tcp) ESP32 MCP Node -> mcp-esp32.local:6270
[4/6] probe + real render
[5/6] checks
2 board(s) · 2 transports · LED round-tripped on both · uptime advanced on both这个样例没有模拟器。板子没接上,它会 说出自己找过哪里,然后失败。
[ -n "$SERIAL" ] || echo " none found on /dev/cu.usbmodem* or /dev/cu.usbserial*"因为没有硬件也能通过的硬件校验,不是校验。
这块板卖的是什么
给评估板加屏幕的代价,从 一整套图形栈加一次重编译,变成了 两个工具加 656 字节定义。
对芯片公司来说这为什么不一样?为了加屏幕不必把认证过的固件重烧一遍。客户说要改画面时不必重新发固件。画面在外面画,板子只说自己能做什么。
不管接上来的是灌溉控制器还是温室网关,板子说的话都一样。
自己跑一遍
cd serial_bridge && cc -O2 -o serial_bridge serial_bridge.c && cd ..
cd tcp_bridge && cc -O2 -o tcp_bridge tcp_bridge.c && cd ..
bash verify.sh # find board → connect → tools/resources → open it in AppPlayer可以带走的
- 两个桥接 —
serial_bridge.c与tcp_bridge.c。原样编译就能接到任何客户端 - 板子这侧的最小回应 —
initialize、tools/list、resources/read三个就够画出画面 - 硬件校验脚本 — 没有板子就不放行的
verify.sh
换成自己的板子 — 只改固件里的工具名和 ui://app 的 JSON。桥接和客户端都不动。传输不同就换个桥接。
练习任务
在示例中再接入一个设备。列出你改动的所有文件——主机不应在其中。