概述
MCP 工具(Tool)就是 AI 可以调用的一组函数。AI 通过 JSON 指令调用工具,MCU 执行硬件动作后回执结果。官方基础工程默认注册 0 个工具(只完成了 emMCP 初始化与心跳)。
本章一次注册 4 个工具(继电器 / 温湿度 / 电源输出 / 彩灯),完整走一遍"回调函数 → 注册 → 工具清单上报(自动/手动二选一)→ 串口测试"流程。第 6~9 章案例将分别实战这 4 个工具(驱动、AI 控制)。
🧩 九章开发板的外设(OLED、温湿度、继电器、PD 诱骗、AI 模组)全部板载并已接好线,案例中的"接线"章节是对照认识引脚用的(或扩展外部模块时参考),不需要自己动手接线。板载资源总览见产品简介与硬件说明。
协议参考:了解 MCP 协议
这一步做什么:搞清楚 MCP 工具由哪些字段组成——AI 模组正是靠这些字段"认识"你的工具、理解它的用途,并决定怎么调用它。
一个工具就是一个 emMCP_tool_t 结构体(定义于 uart-mcp/emMCP.h):
typedef struct emMCP_tool {
char *name; // 工具名(AI 靠它找到工具)
char *description; // 工具描述(AI 靠它理解怎么用)
void (*setRequestHandler)(void *); // ★ 执行回调:AI 请求工具执行时调用
void (*checkRequestHandler)(void *); // ★ 查询回调:AI 问工具状态时调用
inputSchema_t inputSchema; // 参数说明(属性名/类型/描述)
struct emMCP_tool *next;
} emMCP_tool_t;
参数属性(每个工具最多 MCP_SERVER_TOOL_PROPERTIES_NUM = 6 个):
t.inputSchema.properties[0].name; // 参数名,如 "state"
t.inputSchema.properties[0].description; // 参数描述,如 "on打开 / off关闭"
t.inputSchema.properties[0].type; // 类型:MCP_SERVER_TOOL_TYPE_STRING/NUMBER/BOOLEAN
做完有什么用:理解了结构,下一步写回调函数时,就知道每个字段该填什么——
name/description是给 AI 看的"名片",properties是参数说明,两个回调是"干活"的代码。
这一步做什么:为 4 个工具各写一对回调函数——set 回调是"AI 让工具动作时执行的代码"(解析参数、操作硬件、回执结果),check 回调是"AI 查询工具状态时执行的代码"(回执当前状态)。这是工具的核心逻辑,AI 调用的就是它。
文件路径:C:\Users\你的名字\DOCS_TEST1/Core/Src/freertos.c,共 3 处插入
插入位置一:8 个回调函数的原型声明
位置:/* USER CODE BEGIN FunctionPrototypes */ 之后添加(回调定义在注册代码之后,必须先声明):
/* USER CODE BEGIN FunctionPrototypes */
void ui_show_status(void); ← 现有代码
static void relay_set_handler(void *arg);
static void relay_check_handler(void *arg);
static void sht3x_query_set_handler(void *arg);
static void sht3x_query_check_handler(void *arg);
static void ch224_voltage_set_handler(void *arg);
static void ch224_voltage_check_handler(void *arg);
static void ledstrip_set_handler(void *arg);
static void ledstrip_check_handler(void *arg);
/* USER CODE END FunctionPrototypes */
插入位置二:全局状态变量
位置:USER CODE BEGIN Variables 与 USER CODE END Variables 之间添加(灯带 AI 控制标志,动画任务与工具回调共用):
/* USER CODE BEGIN Variables */
double g_temp = 0.0; ← 现有代码
double g_hum = 0.0; ← 现有代码
static volatile bool led_on = false; // 灯带:AI 控制标志
/* USER CODE END Variables */
其余状态变量(
relay_on、current_vout、led_r/g/b、led_brightness)由各工具回调就近声明(见插入位置三),不要在这里重复声明。
插入位置三:4 个工具的完整回调函数
位置:/* USER CODE BEGIN Application */ 之后添加(官方已在此区实现接收回调,加在它后面即可)。以下为已在 DOCS_TEST1 编译验证过的完整代码:
/* ===== 4 个 MCP 工具回调 ===== */
static bool relay_on = false;
/* 1. 继电器 relay */
static void relay_set_handler(void *arg) {
cJSON *root = (cJSON *)arg;
cJSON *state_item = cJSON_GetObjectItem(root, "state");
if (state_item != NULL && cJSON_IsString(state_item)) {
const char *s = state_item->valuestring;
if (strcmp(s, "on") == 0) { relay_on = true; axk_relay_set(ON); log_info("Relay -> ON"); }
else if (strcmp(s, "off") == 0) { relay_on = false; axk_relay_set(OFF); log_info("Relay -> OFF"); }
else { emMCP_ResponseValue(emMCP_CTRL_ERROR); return; }
}
log_info("[Relay] sending response...");
emMCP_ResponseValue(emMCP_CTRL_OK);
log_info("[Relay] response sent");
}
static void relay_check_handler(void *arg) { (void)arg; emMCP_ResponseValue(relay_on ? "on" : "off"); }
/* 2. 温湿度 sht3x */
static void sht3x_query_set_handler(void *arg) {
(void)arg;
log_info("[sht3x] query called");
double temp = 0.0, hum = 0.0;
if (axk_sht3x_read(0x2c06, &temp, &hum) == 0) {
char rsp[64];
snprintf(rsp, sizeof(rsp), "{\"temperature\":%.1f,\"humidity\":%.1f}", temp, hum);
log_info("[sht3x] read ok: %s", rsp);
emMCP_ResponseValue(rsp);
} else {
log_error("[sht3x] read failed");
emMCP_ResponseValue(emMCP_CTRL_ERROR);
}
}
static void sht3x_query_check_handler(void *arg) { sht3x_query_set_handler(arg); }
/* 3. 电源输出 ch224_voltage */
static float current_vout = 12.2f;
static void ch224_voltage_set_handler(void *arg) {
cJSON *root = (cJSON *)arg;
cJSON *v = cJSON_GetObjectItem(root, "voltage");
if (v != NULL && cJSON_IsNumber(v)) {
float target = (float)v->valuedouble;
if (target < 5.0f || target > 20.0f) {
log_error("[ch224] voltage out of range: %.1f", target);
emMCP_ResponseValue(emMCP_CTRL_ERROR);
return;
}
/* 动态调压:先写 PPS 电压寄存器(0x53 = target*10),再确保 PPS 模式(VOUT=0x06) */
int r1 = axk_ch224_set_pps_vout(target);
log_info("[ch224] set_pps_vout(%.1f) = %d", target, r1);
if (r1 == 0) {
int r2 = axk_ch224_set_mode(AXK_CH224_VOUT_PPS);
log_info("[ch224] set_mode(PPS) = %d", r2);
if (r2 == 0) {
current_vout = target;
emMCP_ResponseValue(emMCP_CTRL_OK);
} else emMCP_ResponseValue(emMCP_CTRL_ERROR);
} else emMCP_ResponseValue(emMCP_CTRL_ERROR);
} else {
/* 无 voltage 参数:查询当前电压 */
char rsp[32];
snprintf(rsp, sizeof(rsp), "{\"voltage\":%.1f}", current_vout);
emMCP_ResponseValue(rsp);
}
}
static void ch224_voltage_check_handler(void *arg) { ch224_voltage_set_handler(arg); }
/* 4. 彩灯 ledstrip */
static uint8_t led_r = 255, led_g = 0, led_b = 0;
static uint8_t led_brightness = 50;
static void ledstrip_set_handler(void *arg) {
cJSON *root = (cJSON *)arg;
cJSON *mode_item = cJSON_GetObjectItem(root, "mode");
log_info("[ledstrip] called, mode=%s", mode_item && cJSON_IsString(mode_item) ? mode_item->valuestring : "(null)");
if (mode_item && cJSON_IsString(mode_item)) {
const char *m = mode_item->valuestring;
if (strcmp(m, "on") == 0) {
led_on = true;
axk_ws2812_set_all_pixels_color(led_r, led_g, led_b, led_brightness / 100.0f);
} else if (strcmp(m, "off") == 0) {
led_on = false;
axk_ws2812_set_all_pixels_color(0, 0, 0, 0.0f);
} else if (strcmp(m, "set") == 0) {
cJSON *r = cJSON_GetObjectItem(root, "r");
cJSON *g = cJSON_GetObjectItem(root, "g");
cJSON *b = cJSON_GetObjectItem(root, "b");
if (r && cJSON_IsNumber(r)) led_r = (uint8_t)r->valueint;
if (g && cJSON_IsNumber(g)) led_g = (uint8_t)g->valueint;
if (b && cJSON_IsNumber(b)) led_b = (uint8_t)b->valueint;
cJSON *br = cJSON_GetObjectItem(root, "brightness");
if (br && cJSON_IsNumber(br)) led_brightness = (uint8_t)br->valueint;
log_info("[ledstrip] set: r=%d g=%d b=%d br=%d", led_r, led_g, led_b, led_brightness);
led_on = true;
axk_ws2812_set_all_pixels_color(led_r, led_g, led_b, led_brightness / 100.0f);
} else if (strcmp(m, "query") == 0) {
char rsp[128];
snprintf(rsp, sizeof(rsp), "{\"mode\":\"%s\",\"on\":%s,\"r\":%d,\"g\":%d,\"b\":%d,\"brightness\":%d}", "set", led_on ? "true" : "false", led_r, led_g, led_b, led_brightness);
emMCP_ResponseValue(rsp);
return;
} else { emMCP_ResponseValue(emMCP_CTRL_ERROR); return; }
emMCP_ResponseValue(emMCP_CTRL_OK);
return;
}
emMCP_ResponseValue(emMCP_CTRL_ERROR);
}
static void ledstrip_check_handler(void *arg) { ledstrip_set_handler(arg); }
四个工具回调说明:
- relay:
state参数(“on”/“off”)控制 PB5 继电器;- sht3x:无参数,读取温湿度并以 JSON 回执;
- ch224_voltage:
voltage参数(5~20)调节电源输出,不带参数则查询当前电压;- ledstrip:
mode(on/off/set/query)+r/g/b/brightness控制彩灯。
这一步做什么:把 4 个工具的回调函数打包进 emMCP_tool_t 结构体,注册进 emMCP 的工具列表——相当于把"继电器、温湿度、电源、彩灯"四个功能正式登记给 emMCP 管理。不注册的工具 AI 永远调不到。
文件路径:C:\Users\你的名字\DOCS_TEST1/Core/Src/freertos.c,StartDefaultTask 函数
在 emMCP_Init(&emMCP); 之后(for 循环之前)插入以下注册代码(已编译验证):
/* 注册 4 个 MCP 工具 */
emMCP_tool_t t;
/* relay */
memset(&t, 0, sizeof(t));
t.name = "relay";
t.description = "继电器控制工具,用于打开/关闭继电器";
t.inputSchema.properties[0].name = "state";
t.inputSchema.properties[0].description = "on打开继电器 / off关闭继电器";
t.inputSchema.properties[0].type = MCP_SERVER_TOOL_TYPE_STRING;
t.setRequestHandler = relay_set_handler;
t.checkRequestHandler = relay_check_handler;
emMCP_AddToolToToolList(&t);
/* sht3x */
memset(&t, 0, sizeof(t));
t.name = "sht3x";
t.description = "温湿度查询工具,返回当前温度和湿度";
t.setRequestHandler = sht3x_query_set_handler;
t.checkRequestHandler = sht3x_query_check_handler;
emMCP_AddToolToToolList(&t);
/* ch224_voltage */
memset(&t, 0, sizeof(t));
t.name = "ch224_voltage";
t.description = "电源输出电压调节工具,voltage参数范围5到20伏,不传voltage参数时查询当前电压";
t.inputSchema.properties[0].name = "voltage";
t.inputSchema.properties[0].description = "目标电压值5-20伏,查询当前电压时不要传此参数";
t.inputSchema.properties[0].type = MCP_SERVER_TOOL_TYPE_NUMBER;
t.setRequestHandler = ch224_voltage_set_handler;
t.checkRequestHandler = ch224_voltage_check_handler;
emMCP_AddToolToToolList(&t);
/* ledstrip */
memset(&t, 0, sizeof(t));
t.name = "ledstrip";
t.description = "WS2812灯带控制工具,RGB颜色(r/g/b:0-255)、可选mode(on/off)、brightness(0-100)";
t.inputSchema.properties[0].name = "r";
t.inputSchema.properties[0].description = "红色分量 0-255";
t.inputSchema.properties[0].type = MCP_SERVER_TOOL_TYPE_NUMBER;
t.inputSchema.properties[1].name = "g";
t.inputSchema.properties[1].description = "绿色分量 0-255";
t.inputSchema.properties[1].type = MCP_SERVER_TOOL_TYPE_NUMBER;
t.inputSchema.properties[2].name = "b";
t.inputSchema.properties[2].description = "蓝色分量 0-255";
t.inputSchema.properties[2].type = MCP_SERVER_TOOL_TYPE_NUMBER;
t.inputSchema.properties[3].name = "brightness";
t.inputSchema.properties[3].description = "亮度 0-100";
t.inputSchema.properties[3].type = MCP_SERVER_TOOL_TYPE_NUMBER;
t.setRequestHandler = ledstrip_set_handler;
t.checkRequestHandler = ledstrip_check_handler;
emMCP_AddToolToToolList(&t);
注册要点:每个工具注册前
memset(&t, 0, sizeof(t))清零;name/description用字符串常量(AI 后续调用还会用到);properties是参数说明(最多 6 个)。
这一步做什么:第③步的 emMCP_AddToolToToolList 只让 MCU 本地认识工具;要让 AI 模组知道"MCU 这边有哪些工具可用、每个工具怎么调用",还需把工具清单发给 AI 模组。
推荐方式:自动注册
调用 emMCP_RegistrationTools(),框架自动把已添加的工具封装成 mcp-tool JSON 发给 AI 模组:
/* 4 个工具 AddToolToToolList 注册完之后,加一行: */
emMCP_RegistrationTools();
前提:emMCP_config.h 里不要定义 EMMCP_MANUAL_TOOLS_ONLY(默认不定义)。
💡 本教程配套示例工程因 RAM 紧张(20KB 已用 92%)采用了手动 JSON 注册(
EMMCP_MANUAL_TOOLS_ONLY),想了解手动方式、以及不依赖 AI 模组的串口调试方法,见第⑤步"手动注册/调试(备用方案)"。
做完有什么用:AI 模组收到工具清单后,就把
relay加入自己的可调用工具库——之后你说"打开继电器",AI 就能找到并调用它。没有这一步,AI 永远不知道 MCU 有继电器。
什么时候用:① 想了解手动 mcp-tool 广播(配套示例工程的手动注册模式);② 用串口直接调试 AI 模组 / 验证工具注册——开发中最快的验证手段。
🔌 串口怎么接(调试 AI 模组):
模组的 Type-C 口自带串口调试功能——USB 线插上模组 Type-C,串口助手连对应 COM 口(波特率 115200,勾选"发送新行")即可直接和模组对话,这条串口上你扮演 MCU 角色。
先把板上的串口开关拨动到上面(切换到模组调试档位):

📋 手动注册指令(串口助手直接复制发送,⚠️ 必须是单行,不要换行/缩进):
mcp-tool {"role":"MCU","msgType":"MCP","data":{"tools":[{"name":"relay","description":"继电器控制,打开/关闭继电器","inputSchema":{"properties":{"state":{"description":"on打开/off关闭","type":"string"}}}},{"name":"sht3x","description":"温湿度查询","inputSchema":{"properties":{"query":{"description":"查询温湿度","type":"string"}}}},{"name":"ch224_voltage","description":"电源输出电压调节,voltage参数范围5到20伏,不传voltage参数时查询当前电压","inputSchema":{"properties":{"voltage":{"description":"目标电压值5-20伏,查询当前电压时不要传此参数","type":"number"}}}},{"name":"ledstrip","description":"WS2812灯带开关、颜色和亮度控制","inputSchema":{"properties":{"mode":{"description":"on打开/off关闭/set设置/query查询状态","type":"string"},"r":{"description":"红色0-255(mode=set)","type":"number"},"g":{"description":"绿色0-255","type":"number"},"b":{"description":"蓝色0-255","type":"number"},"brightness":{"description":"亮度0-100","type":"number"}}}}]}}

⚠️ 自动注册(第④步)与手动注册二选一:自动模式不要定义
EMMCP_MANUAL_TOOLS_ONLY且不要手动发 JSON;手动模式不要调用emMCP_RegistrationTools()。混用会导致工具重复注册/AI 识别异常。
串口手动测试(控制模组自带设备,验证串口链路):
因为模组现在连接的是你的串口(开关拨到上面 = 模组调试档),没有连接 STM32 的串口(开关拨下去才是连接 STM32)——所以此时不能控制注册到 MCU 的设备(继电器、灯带等工具的执行端在 STM32,不在线),只能控制模组自带的设备,例如设置音量:
volume-set {"role":"MCU","msgType":"volume","data":70}
模组回执(音量设置成功):
{"role":"AI board","msgType":"volume","data":70,"status":"OK"}

还可以试 volume-check(查询音量)、wake-up(唤醒模组)等模组自带功能。
想真实控制注册到 MCU 的设备(继电器等):把开关拨下去(模组 ↔ STM32 连接),对模组说话"打开继电器",STM32 执行工具并由模组播报;此时串口调试口看到的是模组发来的
mcp_set工具调用。
常见问题与踩坑提示
🔧 AI 能找到工具但调用后回执 data:"false"
原因:回调里参数解析失败或执行失败
解决:看 USART1 日志(1500000)里的 log_error 具体原因;检查参数名是否与注册时一致(如 state 不是 Status)
🔧 工具执行成功但 AI 播报"失败",指令延迟执行原因:⚠️ USART2 中断未勾选(.ioc 里 USART2 global interrupt 没打勾) 解决:打开 .ioc → USART2 → NVIC Settings → 勾选 "USART2 global interrupt" → 重新生成。详见STM32 工程创建的"USART2 中断配置"章节
🔧 查询类工具回执数值是空的({"temperature":})原因:⚠️ 浮点 printf 未链接(newlib-nano 不支持 %f) 解决:两个 cmake 工具链文件的链接选项加 -u _printf_float(见温湿度案例的"浮点 printf 链接选项"章节)
🔧 串口日志乱码/丢字节
原因:多任务日志竞争
解决:log_printf 加静态互斥锁(见温湿度案例的"日志互斥锁"章节)
🔧 工具回调里 cJSON_GetObjectItem 返回 NULL
原因:AI 传的参数名/类型与注册的 Schema 不一致
解决:核对注册的 properties 名与回调里取的字段名;用 cJSON_PrintUnformatted 打印收到的原始参数(项目里已有此日志)
🔧 注册了第 4 个工具后 AI 找不到它
原因:工具数量上限不够
解决:emMCP_config.h 里 MCP_SERVER_TOOL_NUMBLE_MAX 默认只有 3,本项目设为 7;加工具记得 +1 并重新编译
🔧 t.name 用局部变量数组导致 AI 调用时崩溃/找不到
原因:注册后函数返回,局部变量失效
解决:name/description 必须用字符串常量("relay")或 static/全局数组
🔧 手动 JSON 注册(mcp-tool)后 AI 不识别任何工具
原因:JSON 格式错误(缺逗号/引号/括号)
解决:格式必须是 mcp-tool {"role":"MCU","msgType":"MCP","data":{"tools":[...]}},最后一个工具后不能有多余逗号
🔧 自动注册和手动注册混用
原因:两个都开导致工具重复/冲突
解决:二选一:自动模式注释掉 EMMCP_MANUAL_TOOLS_ONLY;手动模式不要调用 emMCP_RegistrationTools()
🔧 手动发指令继电器动了,但 AI 语音说不行
原因:AI 理解工具描述有偏差
解决:description 写得更口语化明确(如"继电器控制工具,用于打开/关闭继电器");AI 靠 description 决定什么时候调、传什么参
🔧 查询工具(check)回执内容 AI 听不懂
原因:回执格式不规范
解决:简单状态回执字符串("on"/"off");多值回执 JSON 对象({"temperature":26.5});布尔用 emMCP_CTRL_OK/ERROR
🔧 改了工具代码但行为没变
原因:没重新编译/烧录
解决:cmake --build build/Debug + 重新烧录,串口日志确认新固件版本
🔧 串口助手发指令没反应
原因:① 接线 TX/RX 接反 ② 没勾发送新行 ③ 波特率错
解决:TX→PA3、RX→PA2、GND 共地;勾"发送新行";波特率 115200
调试工具的最快路径
所有 MCP 工具都可以用一条串口指令验证,不用等 AI:{"role":"AI","msgType":"MCP","data":{"name":"你的工具名","args":{...}}}。先用它确认工具逻辑,再上 AI 语音,出问题就好定位了。

