빌드

보드가 자기 화면을 건넨다 — 실물 두 대, 전송 두 가지, 클라이언트 하나

작성: 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