嵌入式工程师真正的护城河,是"把工程系统改造成 AI 友好"的能力。
今天讲怎么把这件事做到最彻底的一步:让 AI 直接连到你的目标板,自己跑命令、自己看输出、自己 debug。
MCP(Model Context Protocol)—— Anthropic 2024 年底推出的 Agent 调外部能力的开放协议。这一年它从"看起来挺有用" 变成了"我现在做嵌入式离不开"。
我自己这套用了快半年,本文是把它整理成可复制的工作流。代码量不大(核心 200 行 Python),但改变的工作方式比代码量值多了。
本文假设你用 Claude Code,知道什么是 stdio MCP server。完全不熟的话先看 Anthropic 官方那份 quickstart。
一、问题:嵌入式调试的"复制粘贴税"
先讲为什么要做这件事。
我以前调驱动的工作流:
板子卡了
↓
ssh 上去敲 dmesg | tail -50
↓
复制 输出
↓
切到 Claude Code 窗口
↓
粘贴 + "帮我看一下这个 oops"
↓
Claude 说"再给我看一下 /sys/class/iio/iio:device0/ 下面的内容"
↓
切回 ssh
↓
ls /sys/class/iio/iio:device0/
↓
复制
↓
粘贴
↓
(如此反复 10 次)
我管这个叫复制粘贴税。一次调试要 copy/paste 三五十次。
不仅烦,关键是会丢上下文——粘贴时容易粘错、容易漏行;Claude 看到的不是真实板子状态,是经过你手"过滤"的版本。
MCP 解决的就是这件事:让 Claude 跟板子之间有一条直通线。
Claude Code ←→ MCP server ←→ ssh / serial ←→ 目标板
Claude 想看 dmesg,自己去看;想读 sysfs,自己去读;想跑一行命令,自己去跑。
二、MCP 是什么,30 秒讲清楚
跳过的话直接看下一节。
MCP 是 Agent 调外部能力的标准协议。
具体形态:
MCP server:一个进程,暴露一组"工具"(tool)和"资源"(resource)。你写一个
read_dmesg工具,里面是任意代码。
MCP client:Claude Code / Cline / Cursor / Continue 等等。它们读你的 MCP server 列表,把 server 暴露的工具当成"Claude 能调用的函数"。
协议:JSON-RPC over stdio(最常用)/ SSE / HTTP。stdio 最简单,本文都用 stdio。
为什么不直接让 Claude 跑 bash?Claude Code 的 Bash 工具跑在你的开发机上,不是板子上。你不想让 Claude 直接 ssh 进板子瞎搞——需要安全围栏(白名单命令、确认 prompt、审计日志)。你想让某些操作有"语义包装"——比如
read_iio_channel(0比
cat /sys/bus/iio/devices/iio:device0/in_voltage0_raw易用。
三、整体架构
我自己的 setup 长这样:
┌─────────────────────────────────┐
│ 开发机(Win/Mac/Linux) │
│ - Claude Code │
│ - MCP server (Python, stdio) │ ←── 本文重点
│ - paramiko / asyncssh │
└──────────┬──────────────────────┘
│ SSH (LAN / USB-Ethernet gadget)
↓
┌─────────────────────────────────┐
│ 目标板(i.MX 8M Plus / RK3588 等)│
│ - sshd │
│ - 我的驱动 + sysfs / debugfs │
│ - serial gadget (备用通道) │
└─────────────────────────────────┘
MCP server 跑在开发机上、stdio 模式、被 Claude Code 拉起。它再通过 SSH/serial 操作板子。
为什么 MCP server 在开发机而不是板子上:
-
- 板子上跑 Python 太重,stdio 给开发机上的 Claude 用最方便SSH 出问题 / 板子重启 / 板子没网,MCP server 还活着,可以重连凭证管理(SSH key)在开发机上更好控制同一份 MCP server 配置,不同板子切
--target
- 参数就行
四、第一版:100 行能用的 MCP server
直接给代码。先实现两个工具:read_dmesg 和 run_shell(带白名单)。
依赖:
pip install mcp asyncssh
embedded_mcp/server.py:
#!/usr/bin/env python3
"""嵌入式调试 MCP server,stdio 模式"""
import asyncio
import os
import re
from mcp.server.fastmcp import FastMCP
import asyncssh
mcp = FastMCP("embedded-board")
TARGET_HOST = os.environ.get("BOARD_HOST", "192.168.7.2")
TARGET_USER = os.environ.get("BOARD_USER", "root")
TARGET_KEY = os.environ.get("BOARD_KEY", os.path.expanduser("~/.ssh/board_rsa"))
ALLOW_PREFIXES = (
"dmesg", "uname", "uptime", "free", "cat /proc/", "cat /sys/",
"ls /sys/", "ls /proc/", "ls /dev/", "lsmod", "modinfo",
"iio_info", "iio_readdev",
"ip addr", "ip route", "ifconfig",
)
DENY_PATTERNS = (
re.compile(r"brmb"),
re.compile(r"bddb"),
re.compile(r">"),
re.compile(r">>"),
re.compile(r"bmkfs"),
re.compile(r"brebootb"),
re.compile(r"bshutdownb"),
re.compile(r"binsmodb.*.."),
re.compile(r"brmmodb"),
)
asyncdef ssh_run(cmd: str, timeout: float = 15) -> dict:
asyncwith asyncssh.connect(
TARGET_HOST, username=TARGET_USER,
client_keys=[TARGET_KEY], known_hosts=None,
) as conn:
r = await asyncio.wait_for(conn.run(cmd), timeout=timeout)
return {"stdout": r.stdout, "stderr": r.stderr, "rc": r.exit_status}
def is_allowed(cmd: str) -> tuple[bool, str]:
if any(p.search(cmd) for p in DENY_PATTERNS):
returnFalse, "命中黑名单"
ifnot any(cmd.strip().startswith(p) for p in ALLOW_PREFIXES):
returnFalse, f"前缀不在白名单:{ALLOW_PREFIXES[:3]} 等"
returnTrue, ""
@mcp.tool()
asyncdef read_dmesg(lines: int = 100, grep: str | None = None) -> str:
"""读取目标板最近的 dmesg。lines: 取末尾多少行;grep: 可选过滤。"""
cmd = f"dmesg | tail -n {int(lines)}"
if grep:
safe = grep.replace("'", "").replace('"', "")
cmd += f" | grep -E '{safe}'"
r = await ssh_run(cmd)
return r["stdout"] or"(空)"
@mcp.tool()
asyncdef run_shell(cmd: str) -> str:
"""在目标板上跑只读命令(白名单约束)。"""
ok, reason = is_allowed(cmd)
ifnot ok:
returnf"REJECTED: {reason}"
r = await ssh_run(cmd, timeout=30)
out = f"[exit={r['rc']}]n{r['stdout']}"
if r["stderr"]:
out += f"n[stderr]n{r['stderr']}"
return out
if __name__ == "__main__":
mcp.run()
挂到 Claude Code 的 ~/.config/claude-code/mcp.json(macOS / Linux)或 %APPDATA%Claudemcp.json(Windows):
{
"mcpServers": {
"embedded-board": {
"command": "python",
"args": ["-m", "embedded_mcp.server"],
"env": {
"BOARD_HOST": "192.168.7.2",
"BOARD_USER": "root",
"BOARD_KEY": "/home/me/.ssh/board_rsa"
}
}
}
}
重启 Claude Code,跟它说 "看一下板子上最近的 dmesg",它就会自己调 read_dmesg。
到这一步你应该已经感觉到不一样了——你不再"贴"信息给 Claude,Claude 自己去拿。
五、第二版:把工具集补齐
第一版能用,但工具太少。下面这套是我现在线上跑的版本暴露的工具清单:
| 工具 | 干什么 | 是否破坏性 |
|---|---|---|
read_dmesg |
读 dmesg(带 grep / tail) | 只读 |
read_sysfs |
读 /sys/ 下指定路径 | 只读 |
read_proc |
读 /proc/ 下指定路径 | 只读 |
list_dir |
ls 某个路径 | 只读 |
lsmod / modinfo |
内核模块状态 | 只读 |
run_shell |
跑白名单内 shell | 只读 |
read_gpio |
读 GPIO 状态 | 只读 |
read_iio |
读 IIO 设备 raw 值 | 只读 |
capture_serial |
抓串口 N 秒输出 | 只读 |
dump_devicetree |
dump /proc/device-tree 子树 | 只读 |
install_module |
insmod 我们自己的驱动 | 破坏性 |
remove_module |
rmmod | 破坏性 |
write_sysfs |
写 sysfs(受限路径) | 破坏性 |
set_gpio |
设 GPIO 输出 | 破坏性 |
reboot_board |
重启板子 | 破坏性 |
flash_image |
dd 镜像到 eMMC | 破坏性 |
只读工具:Claude 想调就调,不问。破坏性工具:每次调用都触发 Claude Code 的 permission prompt——必须我手动批准。
这是关键的安全模式。
几个具体工具的实现要点
read_sysfs:要白名单根路径,不能让 Claude 读 /sys/firmware/efi/efivars/ 这种东西。
SYSFS_ALLOW_ROOTS = ("/sys/class/", "/sys/bus/", "/sys/devices/", "/sys/module/")
@mcp.tool()
asyncdef read_sysfs(path: str) -> str:
"""读取 /sys/ 下指定路径的内容。"""
ifnot any(path.startswith(r) for r in SYSFS_ALLOW_ROOTS):
returnf"REJECTED: 路径必须以 {SYSFS_ALLOW_ROOTS} 之一开头"
if".."in path:
return"REJECTED: 路径不能包含 .."
r = await ssh_run(f"cat {path}")
return r["stdout"]
capture_serial:板子卡死的时候 ssh 进不去,这时候 serial 是唯一的窗口。
import serial_asyncio
@mcp.tool()
asyncdef capture_serial(seconds: float = 5.0, port: str = "/dev/ttyUSB0",
baud: int = 115200) -> str:
"""抓取串口 N 秒输出。用于 ssh 不通的死机场景。"""
reader, _ = await serial_asyncio.open_serial_connection(
url=port, baudrate=baud,
)
chunks = []
try:
end = asyncio.get_event_loop().time() + seconds
while asyncio.get_event_loop().time() < end:
data = await asyncio.wait_for(reader.read(4096), timeout=0.5)
chunks.append(data.decode("utf-8", errors="replace"))
except asyncio.TimeoutError:
pass
return"".join(chunks) or"(无输出)"
install_module:
@mcp.tool()
asyncdef install_module(ko_path: str, params: str = "") -> str:
"""insmod 一个我们自己编的 .ko。ko_path 是开发机上的路径,会先 scp 上去。"""
ifnot ko_path.endswith(".ko") or".."in ko_path:
return"REJECTED: 路径不合法"
asyncwith asyncssh.connect(...) as conn:
await asyncssh.scp(ko_path, (conn, "/tmp/test.ko"))
r = await conn.run(f"insmod /tmp/test.ko {params}")
returnf"[exit={r.exit_status}]n{r.stdout}{r.stderr}"
注意 每个破坏性工具的 docstring 都要简洁明确——Claude 是按 docstring 决定什么时候调用,写错了它会乱调。
六、安全:嵌入式 MCP 的几条铁律
这一节是本文最重要的。写错了会烧板子、丢数据、把客户测试机搞砸。
铁律 1:默认拒绝,白名单放行
不是"黑名单拦截",是白名单放行。Claude 偶尔会"创造性"地构造命令——比如把 cat /sys/kernel/debug/... 换成 xxd /sys/kernel/debug/... 来绕过。白名单挡得住,黑名单挡不住。
铁律 2:写操作必须经过 permission prompt
破坏性工具不要写成 mcp.tool() 直接暴露。要么用 Claude Code 的 requireApproval 配置,要么在工具内部主动 await ask_user()(FastMCP 支持)。
我的实践:所有改板子状态的操作都需要 Y/n 确认。多敲两个回车,比烧一颗芯片便宜。
铁律 3:路径必须 sanitize
# 错误
await ssh_run(f"cat {path}")
# 正确
if ".." in path or path.startswith("/"):
if not path_in_allowlist(path):
raise ValueError(...)
await ssh_run(["cat", path]) # 别用 shell 拼接
Claude 不是恶意的,但它写 prompt 时没有 shell 安全意识。path = "; rm -rf /; #" 在它看来只是个字符串。你的代码必须假定 path 是敌方输入。
铁律 4:审计日志
所有 MCP 调用记一份日志:
LOG_PATH = "~/.embedded_mcp/audit.log"
def audit(tool: str, args: dict, result_summary: str):
with open(os.path.expanduser(LOG_PATH), "a") as f:
f.write(f"{datetime.now().isoformat()} {tool} {args} -> {result_summary[:200]}n")
Claude 出过一次"我自己也不记得调了什么"的情况——日志能让你复盘。
铁律 5:生产板子上别接 MCP
MCP 工具是给开发板的,不是生产设备。
硬规矩:任何接入 MCP 的板子,外壳上贴红色 "DEV" 标签。装客户那里的板子永远不接 MCP server。
铁律 6:危险命令显式禁止
无论白名单怎么写,这几类操作就算"看起来安全"也要默认拒绝:
- 任何对 eMMC / SD 的块设备写(dd, mkfs)任何 fuse / OTP 烧写任何 PMIC 寄存器写任何 cpufreq / thermal 限制解除任何对 watchdog 的禁用
要做这些,人工敲命令,不要让 Agent 触发。
七、实战:用 MCP 调一个 ADS1256 驱动
场景:probe 失败,ret=-110
有 MCP 之前:
我:板子上 ads1256 probe 失败了 (手动 ssh 板子,dmesg | tail -50,复制)
我:[粘贴 50 行 dmesg] 帮我看一下
Claude:能让我看一下 /sys/bus/spi/devices/spi3.0/ 下的内容吗? (ssh + ls + 复制粘贴)
我:[粘贴] 看
Claude:你的 device tree 里有这个节点吗? (开 vscode,打开 dts,复制)
我:[粘贴片段] …
20 分钟过去了。
有 MCP 之后:
我:板子上 ads1256 probe 失败,帮我分析
Claude:(自动调 read_dmesg) 看到 ret=-110,是 SPI 通信超时。让我看一下 SPI 总线状态。
Claude:(自动调 read_sysfs("/sys/bus/spi/devices/spi3.0/"))
Claude:(自动调 dump_devicetree("/proc/device-tree/soc@0/bus@30800000/spi@30840000"))
Claude:找到原因了——你 device tree 里 spi-max-frequency 写的是 1920000,但你的 SoC SPI controller 实际最高跑不到这个频率。建议改成 1000000。
3 分钟。我做的事:敲了一句话,看了 Claude 的结论,验证。
收益不在快——快只是表面。真正的收益是 Claude 看到的是真实板子状态,不是经过我手过滤的版本。我经常因为"我以为重要"而漏掉关键信息,Claude 不会。
场景:oops 分析 + 修复 + 验证一条龙
我:刚 insmod 自己写的驱动板子 oops 了,帮我看 Claude:(capture_serial 抓 10 秒) → 拿到完整 oops Claude:(read_sysfs 读 /sys/module/) → 确认模块状态 Claude:分析:是 iio_buffer_setup_ops 没初始化导致 NULL deref。修改 probe 函数加上这几行 … (Claude 改完代码) 我:编译一下(Claude 在本地跑 make) Claude:编译过了。要不要 install_module 测一下? 我:好 Claude:(install_module → 需要 approval) 等批准… 我:批准 Claude:(install_module 完成) (read_dmesg) probe 成功,没有 oops。建议跑一个 iio_readdev 验证一下数据。 我:跑 Claude:(read_iio + iio_readdev) 读到 524288,是 1V 输入的正确值。
整个调试闭环没有一次手动复制粘贴。
八、跟传统工作流的时间对比
调了 10 个真实驱动 bug,对比两种模式:
| 阶段 | 传统 | MCP 加持 | 节省 |
|---|---|---|---|
| 信息收集(dmesg / sysfs / dts) | 8 分钟 | 30 秒 | 16x |
| 第一次定位假设 | 5 分钟 | 2 分钟 | 2.5x |
| 验证假设 | 10 分钟 | 4 分钟 | 2.5x |
| 修代码 + 重编 | 15 分钟 | 15 分钟 | 1x |
| 上板验证 | 8 分钟 | 3 分钟 | 2.7x |
| 总计 | 46 分钟 | 24 分钟 | 1.9x |
接近腰斩。
注意几件事:
修代码这一步几乎没省
-
- ——AI 本来就在帮你写代码,跟有没有 MCP 关系不大。
信息收集省得最多
- ——这是预料之中。真正的隐性收益是"思路不会断"——以前在 ssh 窗口和 Claude 之间反复切,思路经常断;现在你只跟 Claude 对话,它去敲板子。
九、踩过的坑
按踩坑频率排:
坑 1:Claude 一遇 SSH 超时就疯狂重试
Claude 的工具调用失败时默认会重试 3 次。如果板子真死机了,它会连试 3 次每次 30 秒,加起来卡你 90 秒。
解决:工具里实现"失败立即返回 + 明确告知 Claude 别重试":
return "BOARD_UNREACHABLE: ssh 连不上,可能死机。请改用 capture_serial。"
Claude 看到这个返回会自动换工具。
坑 2:dmesg 输出超大塞爆 context
长跑的板子 dmesg 几万行。默认 tail 100 行不够、全 dump 又把 context window 占完。
解决:默认
tail -n 100提供
grep 参数加一个
dmesg_since(boot_id)
- 工具,只看从某次 boot 之后的
坑 3:path traversal
第一版我写过 await ssh_run(f"cat {path}"),被自己 Claude 顺手用 "; reboot;" 试出来。
解决:所有路径用 shlex.quote()、subprocess style argv 数组、白名单根目录。
坑 4:MCP server 崩了 Claude 不知道
stdio server 进程挂了之后 Claude Code 经常不知道,下次调用直接 hang。
解决:MCP server 加 watchdog,超时自动重启自己;Claude Code 那边设短超时(5 秒)。
坑 5:多板子切换
公司里同时调 3 颗板子,每个一个 MCP server 实例,Claude Code 工具列表里冒出 30 个 tool name,它经常调错板子。
解决:工具名加板子前缀:
rk3588_read_dmesg
-
- 、
imx8mp_read_dmesg
-
- 或者 MCP server 暴露一个
select_board(id)
- 工具,所有后续调用走这个 board
坑 6:串口被 minicom 占着
capture_serial 跟你手动 minicom 抢同一个 /dev/ttyUSB0,互相崩。
解决:用 conserver 或 ser2net 把串口变成网络服务,多客户端共用。
坑 7:模型选错
Claude Code 默认 Sonnet。Sonnet 调 MCP 是够用的,但遇到复杂调试要切 Opus。我现在的工作流:日常 Sonnet,遇到 oops 或者数据异常切 Opus。
十、还没解决的问题
还有几个真的没解决的问题。
问题 1:示波器 / 逻辑分析仪 接不进 MCP
理论上 sigrok 可以、Saleae 也有 SDK,能写个 MCP server 让 Claude 触发采样、读波形。实践上我没做成——波形数据太大、Claude 看图能力一般、调一次时序问题省的时间不如自己看。
目前的做法:示波器还是自己看,看完口述给 Claude。
问题 2:vendor BSP 编译没接入
Yocto / Buildroot 编译 30 分钟起跳,让 Claude 在 MCP server 里跑编译有点过分。目前还是手动 bitbake,编完告诉 Claude 结果。
问题 3:硬件改板 / 飞线场景 AI 完全没用
板子改了一根飞线、改了一个电阻分压,MCP 这边什么都不知道。这种场景必须人主导。
问题 4:CI 里没法直接用
CI runner 跟开发机 MCP 是两套 setup。我们 CI 里跑的是另一套专门的 headless 测试驱动,跟 MCP server 共用底层 SSH 代码、但不走 Claude。
十一、要不要开源
我做的 embedded-mcp-toolkit,但整体还在调试中
- 一个 stdio MCP server 框架(FastMCP based)一组开箱即用的 tool(dmesg / sysfs / iio / gpio / serial)内置安全围栏 + 审计日志板子适配层:插件式支持 i.MX、Rockchip、Allwinner
十二、给嵌入式工程师的总结
回到开头那句话:
AI 时代嵌入式工程师真正的护城河,是"把工程系统改造成 AI 友好"的能力。
MCP 是这件事最关键的一块拼图。它把"AI Infra 嵌入式版"(你的板子 / 调试栈)和"AI Docs"(CLAUDE.md / driver.ai.md)真正接起来。
接起来之后 AI 不只是个"会写代码的助手",是个能看到、能动手的协同工程师。
具体的行动建议,按优先级:
本周内
-
- —— 跑通本文第四节的 100 行最小 MCP server,至少有
read_dmesg
-
- 一个工具
本月内
-
- —— 把第五节那个工具集补齐,覆盖你日常 80% 的查询动作
持续做
- —— 每次发现"我又在切窗口复制粘贴",就给 MCP server 加一个工具
最后再强调一遍:
- 默认拒绝,白名单放行写操作必须确认生产板子永远别接 MCP审计日志永远开着
169