☰
基于小智ESP32S3的智能控制小车:用MCP打通GPIO与L298N的TaoToken实践
2026/10/2 20:20:02 网站建设 项目流程

1. 小智 ESP32S3 智能小车为什么需要 L298N 与 MCP 协议

小智 ESP32S3 智能控制小车,本质是把「语音/文本指令」翻译成「GPIO 电平」,再通过 L298N 驱动四个直流电机,完成前进、后退、转向、调速的闭环。很多刚上手的朋友会问:ESP32S3 的 GPIO 不是能输出高低电平吗,为什么不直接把电机接上去?原因很直接——ESP32S3 的 GPIO 输出电流只有几十毫安级别,而一个 130 或 TT 马达堵转电流轻松上到几百毫安,直接接上去要么电机不转,要么把主控拉挂。L298N 的作用就是「低压控制高压」:它用独立电池(两节 3.7V 串联约 7.4V)给电机供电,同时接收 ESP32S3 的 3.3V 逻辑信号,两边互不干扰。

那 MCP 又是什么角色?MCP(Model Context Protocol)在小智项目里是「工具调用协议」。简单说,你对着小智说「往前走两秒」,语音识别把这句话交给大模型,大模型判断需要调用一个叫self.car.control的工具,参数是action=forward, duration=2000,这个调用请求通过 MCP 协议下发到 ESP32S3,固件里的回调函数再去操作 GPIO。整条链路是:语音 → ASR → LLM → MCP tools/call → GPIO → L298N → 电机。理解了这条链路,后面接线和改代码就不会迷路。

这套方案适合谁?适合已经跑通小智语音助手、想加一个「能动的身体」的玩家;也适合想学 MCP 协议在嵌入式端怎么落地的开发者。成本上,小智开发套件加亚克力底板、四驱电机、L298N、杜邦线和电池,整体可控。我试过用最朴素的四驱底板搭,接线半小时、改代码十分钟就能动起来,难点主要在 GPIO 上电自转和引脚映射这两块。

需要提前说明的是,本文聚焦「GPIO 指令下发到 L298N」这一段闭环,语音唤醒、麦克风、功放这些属于小智基础搭建,按官方百科走通即可。下面从硬件映射、MCP 工具注册、编译烧录到串口验证,一步步给可复制的配置。

2. TaoToken 前置准备:拿到 Base URL、API Key 与 Model ID

在动手接线之前,先把「大脑」这一侧准备好。小智的语音对话和工具调用需要一个大模型服务来解析意图,这里用 TaoToken 提供的统一接入。它的价值在于:一个 Base URL 加一个 Key,就能在模型对话、Coding Plan、API 调用之间切换,不用为每个模型单独配环境。对小智这种需要频繁调试 prompt 和工具描述的场景,省事很多。

第一步,打开官网 https://taotoken.net/?utm_source=taotoken_aicg_blog_end&utm_medium=csdn&utm_campaign=rewrite&utm_content= 注册并登录。登录后进入控制台 https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite ,在「API Keys」页面创建一个新 Key。创建时给它起个能认出来的名字,比如xiaozhi-car,方便后面区分。Key 只在创建时完整显示一次,复制下来存好,别截图发群里。

第二步,确认你要用的 Model ID。小智的工具调用对模型的 function calling 能力有要求,选一个支持工具调用的模型即可。在模型对话页面 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 可以先手动测一下模型能不能正确返回工具调用结构,确认没问题再写进固件配置。

第三步,记下三个关键值,后面配置里会反复用到:

配置项值说明
Base URLhttps://taotoken.net/api统一接入地址,不加 UTM
API Keysk-xxxxxxxx控制台创建,仅显示一次
Model ID按控制台可选模型填写需支持 function calling

如果你打算长期做编码和 Agent 类调试,可以了解下 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite ,它更适合高频调用场景。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,遇到参数格式问题先查这里。

这里要提醒一句:小智固件里配置的是「服务端地址 + Key」,不是让你在 ESP32 上跑模型。ESP32S3 只负责采集语音、发请求、执行工具回调,推理在云端完成。所以 Key 的权限和额度要留够,调试阶段工具调用会比较频繁。

3. 可复制配置:GPIO 映射表与 MCP 工具注册代码

这一节是全文的核心,给你可以直接抄的接线映射和mcp_server.cc修改片段。先说接线。我用的是四驱亚克力底板,四个直流电机,L298N 四路驱动模块。ESP32S3 的 GPIO 到 L298N 的 IN 引脚映射如下,这张表建议打印出来贴在桌边:

电机编号电机位置L298N IN 引脚ESP32 GPIO
电机1左前轮IN1GPIO19
电机1左前轮IN2GPIO20
电机2右前轮IN3GPIO3
电机2右前轮IN4GPIO46
电机3左后轮IN5GPIO9
电机3左后轮IN6GPIO10
电机4右后轮IN7GPIO11
电机4右后轮IN8GPIO12

接线注意三点:L298N 的 GND 必须和 ESP32S3 的 GND 共地,否则逻辑电平没有参考;电机电源用两节 3.7V 串联约 7.4V,接 L298N 的 12V 输入和 GND;ESP32S3 的 3.3V 只给 L298N 的逻辑供电参考,不要用它去驱动电机。左侧的 ESP32S3 开发板除了主控,还要有数字麦克风和功放喇叭,否则语音链路不完整。

接下来是代码。小智源码在 v1.7.0 起把 MCP 作为默认控制协议,之前的 IoT framework 已删除,所以务必用支持 MCP 的版本,我用的是 v2.0.3。修改文件是main/mcp_server.cc,在AddCommonTools()里注册一个控制小车的工具。核心片段如下,可直接粘贴:

// 小车整体控制工具 - 基于L298N四电机模块 AddTool("self.car.control", "控制小车的整体运动。支持前进、后退、左转、右转、停止等基本动作。\n" "Use this tool to control the car movement when user asks to move the car.\n" "Args:\n" " `action`: 运动动作,可选值: 'forward'(前进), 'backward'(后退), 'left'(左转), 'right'(右转), 'stop'(停止)\n" " `duration`: 持续时间(毫秒),0表示持续运动,默认0\n" "Return:\n" " 返回控制结果和状态信息", PropertyList({ Property("action", kPropertyTypeString), Property("duration", kPropertyTypeInteger, 0, 0, 100000) }), [](const PropertyList& properties) -> ReturnValue { auto action = properties["action"].value<std::string>(); int duration = properties["duration"].value<int>(); static bool gpio_initialized = false; static const gpio_num_t motor_pins[4][2] = { {GPIO_NUM_19, GPIO_NUM_20}, // 电机1: 左前轮 {GPIO_NUM_3, GPIO_NUM_46}, // 电机2: 右前轮 {GPIO_NUM_9, GPIO_NUM_10}, // 电机3: 左后轮 {GPIO_NUM_11, GPIO_NUM_12} // 电机4: 右后轮 }; if (!gpio_initialized) { for (int i = 0; i < 4; i++) { gpio_set_direction(motor_pins[i][0], GPIO_MODE_OUTPUT); gpio_set_direction(motor_pins[i][1], GPIO_MODE_OUTPUT); gpio_set_level(motor_pins[i][0], 0); gpio_set_level(motor_pins[i][1], 0); } vTaskDelay(pdMS_TO_TICKS(50)); for (int i = 0; i < 4; i++) { gpio_config_t config = { .pin_bit_mask = (1ULL << motor_pins[i][0]) | (1ULL << motor_pins[i][1]), .mode = GPIO_MODE_OUTPUT, .pull_up_en = GPIO_PULLUP_DISABLE, .pull_down_en = GPIO_PULLDOWN_DISABLE, .intr_type = GPIO_INTR_DISABLE, }; ESP_ERROR_CHECK(gpio_config(&config)); gpio_set_level(motor_pins[i][0], 0); gpio_set_level(motor_pins[i][1], 0); } gpio_initialized = true; ESP_LOGI(TAG, "Car GPIO initialized, all motors stopped"); } auto control_motor = [&](int idx, int dir) { if (dir > 0) { gpio_set_level(motor_pins[idx][0], 0); gpio_set_level(motor_pins[idx][1], 1); } else if (dir < 0) { gpio_set_level(motor_pins[idx][0], 1); gpio_set_level(motor_pins[idx][1], 0); } else { gpio_set_level(motor_pins[idx][0], 0); gpio_set_level(motor_pins[idx][1], 0); } }; if (action == "forward") { control_motor(0, -1); control_motor(1, 1); control_motor(2, -1); control_motor(3, 1); } else if (action == "backward") { control_motor(0, 1); control_motor(1, -1); control_motor(2, 1); control_motor(3, -1); } else if (action == "left") { control_motor(0, -1); control_motor(1, 1); control_motor(2, 1); control_motor(3, -1); } else if (action == "right") { control_motor(0, 1); control_motor(1, -1); control_motor(2, -1); control_motor(3, 1); } else if (action == "stop") { control_motor(0, 0); control_motor(1, 0); control_motor(2, 0); control_motor(3, 0); } else { throw std::runtime_error("Invalid action: " + action); } if (duration > 0) { auto& app = Application::GetInstance(); app.Schedule([duration]() { static const gpio_num_t pins[4][2] = { {GPIO_NUM_19, GPIO_NUM_20}, {GPIO_NUM_3, GPIO_NUM_46}, {GPIO_NUM_9, GPIO_NUM_10}, {GPIO_NUM_11, GPIO_NUM_12} }; vTaskDelay(pdMS_TO_TICKS(duration)); for (int i = 0; i < 4; i++) { gpio_set_level(pins[i][0], 0); gpio_set_level(pins[i][1], 0); } ESP_LOGI(TAG, "Car auto-stopped after %d ms", duration); }); } cJSON* result = cJSON_CreateObject(); cJSON_AddStringToObject(result, "action", action.c_str()); cJSON_AddNumberToObject(result, "duration", duration); cJSON_AddBoolToObject(result, "success", true); return result; });

这段代码有两个关键设计。一是构造函数里先把所有电机引脚拉低,防止上电瞬间 GPIO19、GPIO20、GPIO3、GPIO46 这些可能默认高电平的引脚让电机自转。二是duration参数配合Application::Schedule做定时停止,这样「往前走两秒」这种指令能自动收尾,不用再喊一句「停」。

如果你用的是 Cline MCP 或 Codex 这类外部工具来调试工具描述,记得三件套要写全:Base URL 填https://taotoken.net/api,Key 填控制台创建的,Model ID 填支持 function calling 的模型。三者缺一,工具调用就会失败。

4. 编译烧录与串口验证:确认 GPIO 真的动了

代码改完,接下来编译烧录。我用的是 ESP-IDF + VS Code(或 Cursor)的组合,环境搭建按乐鑫官方教程走一遍即可。烧录前先确认idf.py set-target esp32s3选对芯片,然后idf.py build编译,idf.py -p /dev/ttyUSB0 flash monitor烧录并打开串口监视器。Windows 下端口可能是COM3之类,按设备管理器实际显示填。

烧录成功后,串口会打印初始化日志。重点看这几行:

I (xxx) MCP: Car GPIO initialized, all motors stopped I (xxx) MCP: Motor1(左前): IN1=GPIO19, IN2=GPIO20 I (xxx) MCP: Motor2(右前): IN3=GPIO3, IN4=GPIO46 I (xxx) MCP: Motor3(左后): IN5=GPIO9, IN6=GPIO10 I (xxx) MCP: Motor4(右后): IN7=GPIO11, IN8=GPIO12

看到这些说明 GPIO 配置生效、电机已处于停止状态。如果上电瞬间轮子转了一下才停,说明构造函数里的拉低逻辑起作用了,属于正常现象;如果一直转不停,检查是不是漏了gpio_config那一段。

接下来做动作验证。唤醒小智,说「往前走两秒」,串口应该打印类似:

I (xxx) MCP: Car moving forward - all motors rotating forward I (xxx) MCP: Car auto-stopped after 2000 ms

同时观察四个轮子是否同向转动。如果某个轮子反向,把对应电机的两根 IN 线对调,或者改代码里control_motor的正负号。转向验证说「左转」,应该是左侧两轮反转、右侧两轮正转,形成原地左旋。调速这块,L298N 的 ENA/ENB 使能脚如果接了 PWM,可以在代码里用ledc输出占空比来调速;如果没接,就是全速运行,先用动作验证跑通再考虑调速。

轮速验证有个土办法:在轮子上贴一小段胶带,用手机慢动作拍一秒,数胶带转了几圈,粗略估算转速。四个轮子转速差异大,说明电机个体差异或供电不足,检查电池电压是否掉到 6V 以下。

验证模型侧是否正常,可以打开模型对话 https://taotoken.net/chat?utm_source=taotoken_aicg_blog_end&utm_content=model_chat&utm_campaign=rewrite 手动发一条工具调用请求,看返回结构里tool_calls字段是否正确。这一步能把「模型不返回工具调用」和「固件不执行」两类问题分开定位。

5. 本篇常见错误排查:401、local proxy failed 与 choices 解析失败

调试过程中最容易卡在几个报错上,这里逐个对照。

401 Unauthorized。这个基本是 Key 的问题。检查三处:Key 是否复制完整(有没有漏字符)、Base URL 是否写成https://taotoken.net/api(不要多加斜杠或路径)、请求头里Authorization: Bearer sk-xxx格式对不对。如果 Key 在控制台被删过,旧 Key 会立即失效,重新创建一个换上。

local proxy failed / connection refused。这类报错通常出现在本地调试工具链里,意思是请求没发出去。先确认网络能正常访问taotoken.net,再检查是不是把 Base URL 填成了带 UTM 的官网地址——API 调用要用https://taotoken.net/api,不带任何查询参数。如果用了 Cline MCP 或类似工具,检查它的代理配置有没有指向一个不存在的本地端口。

reading choices / choices 字段为空。这是响应解析阶段的报错,说明请求发出去了但返回结构不符合预期。常见原因有两个:一是 Model ID 填错,用了一个不支持 function calling 的模型,返回里没有choices或tool_calls;二是请求体里tools字段格式不对,模型没识别出工具定义。解决办法是先用模型对话页面手动发一次带工具的请求,确认返回结构,再对照固件里的请求格式改。

OAuth 相关报错。如果你在 Claude Code 或类似工具里接入,遇到 OAuth 报错,说明认证方式选错了。这类工具应该用 API Key 认证,不是 OAuth 流程。把认证方式切到 Key,Base URL 填https://taotoken.net/api,Model ID 填对,三件套齐了再试。

电机不转但串口有日志。说明 MCP 调用成功、GPIO 也设置了,问题在硬件侧。检查 L298N 的电机电源是否接上、共地是否做了、使能跳线帽是否插好。L298N 板子上通常有 5V-EN 跳线帽,拔掉才能用外部逻辑电平控制,插着的话使能脚被拉高,可能一直转或一直不转。

电机自转停不下来。回到构造函数那段,确认gpio_set_level拉低在所有gpio_config之前和之后都执行了。GPIO19、GPIO20、GPIO3、GPIO46 这几个引脚在 ESP32S3 上电时有默认状态,必须显式拉低。

排障时建议开两个窗口:一个看串口日志,一个看模型对话页面的请求记录。哪一侧断了,一眼能看出来。接入文档 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 里有完整的请求示例,对照着改比自己猜快。

6. 从能跑到好用:把小车接入你的日常调试流

跑通「前进两秒」只是起点。真正让这台小车好用,是把 MCP 工具描述写得更贴合你的说话习惯。比如你习惯说「冲一下」,可以在工具描述里加同义词提示,让模型更容易命中forward。工具描述写得越具体,模型误判越少。

另一个实用技巧是给动作加组合。现在只有单步动作,你可以在self.car.control之外再注册一个self.car.patrol,内部循环调用前进、转向、停止,实现简单的巡逻路径。这样一句「巡逻一圈」就能触发一串动作,比一步步喊高效。

调速方面,如果 L298N 的 ENA/ENB 接了 PWM 引脚,可以用ledc_timer_config和ledc_channel_config输出不同占空比,把速度也做成工具参数。比如self.car.control加一个speed参数,0 到 100,映射到占空比。这样「慢慢往前」和「快速后退」就能区分开。

长期做这类嵌入式 Agent 调试,调用频率会上去,可以关注 Coding Plan https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite ,它更适合高频工具调用场景。API Key 管理在控制台 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite ,建议给不同项目建不同的 Key,方便排查和限额。

最后说个我踩过的坑:一开始我把四个电机的正负号写反了两个,结果「前进」变成了原地打转。后来在代码里加了一行日志,把每个电机实际设置的电平打出来,对照接线表一查就找到了。所以别嫌日志多,调试阶段串口打印是你最好的朋友。把这套跑顺之后,你可以试着让小智控制小车去撞倒一个纸杯,或者沿着胶带线走一圈,这些具体任务比单纯「前进后退」更能暴露工具描述和参数设计的问题。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询