STM32+ESP32+UCOSII智能小车:双MCU架构与微信小程序远程控制实践
2026/9/9 10:45:06 网站建设 项目流程

简介:这是一套面向嵌入式初学者与课程设计者的完整智能小车开发实战资源,聚焦STM32+ESP32双MCU协同控制、UCOSII实时任务调度及微信小程序蓝牙交互等核心能力训练,适用于毕业设计、电子竞赛、工程实训及嵌入式系统进阶学习。资源包共1350个文件,涵盖503个编译中间文件(.o/.d)、138个头文件(.h)、52个C源码(含STM32裸机驱动、UCOSII任务管理、ESP32蓝牙协议栈适配及小程序通信逻辑)、37个Makefile构建脚本,以及微信小程序的wxml/wxss/js/json等前端文件,整体压缩后仅17.28MB,结构清晰、模块解耦。已有964人学习下载,所有源码均经实测可直接编译烧录运行,包含freertos、lwip、mbedtls、nvs_flash等ESP32官方SDK组件库,配套红外避障、电机驱动、舵机转向、LED状态指示等完整外设控制例程,无需PCB即可用面包板快速复现系统功能。

1. 项目概述:一个嵌入式工程师的“全栈”小车实践

搞嵌入式开发的朋友,尤其是玩STM32的,估计都做过或者想做过一辆智能小车。这玩意儿就像电子工程师的“Hello World”,但真要把它从“能动就行”做到“智能可控”,里面门道可就多了。我这次做的这个项目,就是想挑战一下自己,把几个主流的技术栈串起来,搞一个功能相对完整、架构清晰、还能通过手机远程控制的智能小车。核心就是标题里提到的:STM32做主控,ESP32做网络和蓝牙桥接,微信小程序做手机端遥控器,最后再用上UCOSII实时操作系统来管理多任务。这听起来像是个“缝合怪”,但实际做下来,你会发现,这正是当前很多智能硬件产品(比如一些教育机器人、巡检小车)的简化版技术原型。它考验的不仅仅是你对单片机的编程能力,更是对系统架构设计、通信协议、前后端联调的综合把控。如果你正想从裸机编程迈向RTOS,或者想打通单片机与手机App的链路,这个项目会是一个非常好的练手素材。

2. 整体系统架构与设计思路拆解

2.1 为什么选择这样的“双MCU”架构?

很多新手可能会问,为什么不用一个ESP32搞定所有事?它既有Wi-Fi/蓝牙,性能也足够驱动电机。这里就涉及到嵌入式系统设计中的一个核心思想:职责分离与资源优化

STM32(我选用的是STM32F103C8T6,也就是常说的“蓝桥杯”或“最小系统板”款)的优势在于其丰富、稳定的外设接口(如定时器、PWM、ADC)和极强的实时性。驱动电机(特别是用带编码器的直流电机)、读取巡线传感器、超声波避障,这些需要精确时序控制的任务,是STM32的强项。它的开发环境(Keil、STM32CubeMX)和生态(HAL库、标准库)也非常成熟,调试起来心里有底。

而ESP32(我用的是ESP32-WROOM-32)的核心价值在于其强大的无线通信能力和相对丰富的内存。让它去处理Wi-Fi连接、蓝牙串口协议(SPP)、甚至未来扩展的MQTT物联网协议,是物尽其用。如果让STM32去直接管理复杂的无线协议栈,会占用大量CPU资源,且开发复杂度陡增。

因此,这个架构的本质是:STM32作为“执行与感知单元”,负责底层硬件的精确控制与数据采集;ESP32作为“通信与连接单元”,负责与外部世界(手机小程序)进行数据交换。两者之间通过最经典、最可靠的**串口(UART)**进行通信,约定好简单的自定义协议即可。这样,任何一方的改动都不会过分影响另一方,降低了耦合度,也方便后期单独升级功能。

2.2 UCOSII实时操作系统的引入价值

在裸机(while(1)超级循环)环境下,我们要同时处理电机PID控制、传感器数据读取、串口数据解析、状态指示灯闪烁等多个任务。通常会使用前后台系统或基于状态机的调度,代码容易变得冗长且难以维护,特别是当任务间有同步或资源共享需求时(比如电机控制任务需要最新的传感器数据)。

引入UCOSII,就是为了解决这个问题。它是一个优先级占先式的实时内核,能让我们以“任务”为单位来组织代码。在这个小车上,我可以创建几个独立的任务:

  • 电机控制任务:优先级最高,确保电机控制的实时性,实现精准的速度闭环。
  • 传感器融合任务:周期性读取各类传感器,并做初步滤波处理。
  • 串口通信任务:负责与ESP32通信,解析指令和发送数据。
  • 系统监控任务:优先级最低,比如用LED做个“心跳灯”,指示系统运行状态。

使用RTOS后,每个任务的代码逻辑变得非常清晰。通过信号量、消息队列等机制进行任务间通信,比裸机下用全局变量和标志位要安全、优雅得多。虽然为STM32F103移植UCOSII需要额外的工作(主要是修改os_cpu_a.asmos_cpu_c.c这几个移植层文件),但一旦跑通,整个系统的可扩展性和健壮性会提升一个档次。例如,未来你想加入一个语音识别模块,只需要新建一个语音处理任务,而不必去大刀阔斧地修改原有循环结构。

2.3 微信小程序作为控制端的优势

相比于开发一个原生Android/iOS App,微信小程序的优势太明显了:开发快、免安装、跨平台。对于这种个人项目或产品原型来说,它是最佳选择。小程序通过wx提供的API可以轻松调用手机的蓝牙功能,与ESP32建立连接。界面用WXML和WXML就能快速搭出来,实时显示小车传感器数据(比如超声波距离)、发送控制指令(前后左右、速度调节)都非常方便。它实际上扮演了一个**兼具UI界面和蓝牙协议栈的“遥控器”**角色。

3. 硬件选型与核心电路设计要点

3.1 主控与通信模块选型解析

STM32主控:如前所述,STM32F103C8T6是性价比之王。72MHz主频,64KB Flash,20KB RAM,对于运行UCOSII和基本控制算法绰绰有余。要注意的是,它的串口资源(USART)至少需要两个:一个(USART1)用于与ESP32通信,另一个(USART2)可以预留用于调试输出(接USB转TTL模块,方便打印日志)。

ESP32模块:ESP32-WROOM-32是最通用的型号。注意其工作电压是3.3V,与STM32电平一致,可以直接连接。我们主要使用其经典蓝牙(Bluetooth Classic)模式下的**串口透传(SPP)**功能。这意味着在手机和小程序端,你会像操作一个虚拟的串口一样与ESP32通信,非常直观。不需要去啃复杂的BLE(低功耗蓝牙)协议,对于控制类应用,经典蓝牙的吞吐量和延迟更合适。

电机驱动:这是关键!建议直接使用双路H桥电机驱动模块,比如TB6612FNG或DRV8833。它们比古老的L298N效率高、发热小、体积也小。一个模块可以驱动两个直流电机,正好对应小车左右轮。STM32通过产生两路PWM波给驱动芯片的AIN/BIN引脚来控制电机的转速和方向。

电源管理:整个系统的“心脏”。小车电机启动瞬间电流很大(可能超过2A),而单片机和控制电路需要干净的5V或3.3V。强烈建议使用两路独立供电:一路大容量锂电池(如7.4V航模电池)通过大电流开关直接给电机驱动模块供电;另一路通过一个DC-DC降压模块(如LM2596)将电池电压稳定到5V,再给STM32、ESP32和传感器供电。5V到3.3V的转换,可以使用AMS1117-3.3这类LDO。务必在电机驱动模块的电源输入端并联一个大电容(如470uF~1000uF的电解电容)来缓冲瞬间电流冲击,避免造成整个系统电压骤降而复位。

3.2 传感器扩展与接口设计

基础传感器可以包括:

  • 超声波模块(HC-SR04):用于前方障碍物距离检测,实现自动避障或跟随。注意其需要5V触发,但回响(Echo)引脚输出是5V电平,需要用一个1kΩ和2kΩ电阻分压到3.3V后再接入STM32的IO口,或者使用电平转换芯片。
  • 红外巡线模块(TCRT5000):用于实现巡线功能。通常输出数字信号(遇到黑线输出高/低电平),直接接STM32的GPIO即可。
  • 编码器电机:如果想做精确的速度或位置控制,强烈建议使用带AB相编码器的直流电机。编码器输出直接接到STM32的定时器编码器接口(如TIM2、TIM3),利用硬件计数,非常精准。

所有传感器与STM32的连接,最好遵循统一的电源(3.3V或5V)、地(GND)、信号线(Signal)的接线规范,并做好线缆固定,避免在运动过程中脱落。

4. 软件框架搭建与UCOSII移植实战

4.1 UCOSII在STM32F103上的移植步骤

移植是第一步,也是稍有门槛的一步。现在网上有大量成熟的移植工程,但理解过程很重要。

  1. 获取源码:从Micrium官网或可靠的GitHub仓库获取UCOSII最新稳定版源码。核心是Source文件夹下的内核文件,以及Ports文件夹下针对ARM Cortex-M3的移植文件。
  2. 工程准备:在Keil MDK或STM32CubeIDE中创建一个标准的STM32F103工程,确保HAL库或标准库能正常编译运行。
  3. 文件添加
    • Source下所有.c文件添加到工程的uC/OS-II/Source组。
    • Ports/ARM/Cortex-M3/Generic/RealView下的os_cpu_a.asm(汇编文件)、os_cpu_c.cos_cpu.h添加到工程的uC/OS-II/Port组。
    • Source下的os_cfg.hincludes.h(如果需要)复制到项目目录,并根据需要修改配置。os_cfg.h是UCOSII的“功能裁剪板”,你可以在这里关闭不需要的功能(如信号量、消息队列、事件标志组等)来节省资源。
  4. 关键修改
    • os_cpu_a.asm:重点检查OS_CPU_PendSVHandlerOS_CPU_SysTickHandler这两个汇编函数。它们需要替换掉STM32原生的PendSV_HandlerSysTick_Handler中断服务函数。在STM32的启动文件(如startup_stm32f103xb.s)中,将这两个中断向量的名字修改为OS_CPU_PendSVHandlerOS_CPU_SysTickHandler,或者反过来,在UCOSII的移植文件里修改函数名去匹配启动文件。这是移植成败的关键,必须保证一致!
    • os_cpu_c.c:需要实现OS_CPU_SysTickInit()函数,用于初始化系统滴答定时器(SysTick)。在STM32上,通常就是调用HAL库的HAL_SYSTICK_Config()函数。
    • 堆栈方向:Cortex-M3内核是满减栈,这在移植文件中已经定义好,一般无需改动。
  5. 编译与调试:解决所有编译错误后,编写一个简单的创建两个任务让LED闪烁的测试程序。使用调试器单步跟踪,确保任务能正常切换。可以使用UCOSII提供的统计任务来观察CPU使用率和任务堆栈使用情况。

注意:在UCOSII中,每个任务都需要分配独立的堆栈空间。对于STM32F103这种RAM较小的芯片,务必仔细估算每个任务所需的堆栈大小,避免溢出。一个简单的调试技巧是,在任务函数开始时用特定值(如0xAA)填充堆栈,运行一段时间后检查被改写的位置,来估算最大使用深度。

4.2 多任务划分与优先级设计

基于我们的系统功能,一个合理的任务划分如下:

  1. Task_MotorCtrl(优先级: 5):最高优先级。负责接收目标速度指令,结合编码器反馈,计算并输出PWM,实现电机闭环控制(如PID控制)。该任务需要高频率运行(例如1ms或2ms一次)。
  2. Task_Sensor(优先级: 6):周期性读取超声波距离、巡线传感器状态等。读取后,可以将处理好的数据放入一个全局结构体或通过消息队列发送给其他任务。
  3. Task_Comm(优先级: 7):负责与ESP32的串口通信。它需要监听串口接收中断(或使用DMA+空闲中断提高效率),将接收到的原始数据包放入缓冲区,然后在本任务中解析协议。同时,它也负责将需要上报给手机的数据(如传感器数据)打包发送出去。
  4. Task_SysMonitor(优先级: 10):最低优先级。实现一个简单的“看门狗”功能,比如让一个LED以1Hz频率闪烁,表明系统还在运行。也可以在这里监控电池电压。

优先级数字在UCOSII中通常数字越小优先级越高。电机控制任务优先级最高,确保控制的实时性;通信任务次之,保证指令响应及时;传感器任务再次;监控任务最低。

4.3 STM32与ESP32的串口通信协议设计

通信的稳定性是整个系统的“生命线”。必须设计一个简单、可靠、可扩展的协议。

我采用了一种**“帧头+命令字+数据长度+数据内容+校验和”**的格式。例如:

字节序号字段说明示例值 (Hex)
0帧头1固定值,如 0xAAAA
1帧头2固定值,如 0x5555
2命令字区分指令类型0x01 (控制指令)
3数据长度 N后续数据域的字节数0x04
4 ~ 4+N-1数据域具体的指令参数[Speed_L, Speed_R, Dir_L, Dir_R]
4+N校验和前面所有字节的累加和(取低8位)SUM

示例:控制小车前进,左轮速度100,右轮速度100。发送帧:AA 55 01 04 64 64 01 01 D3(校验和 0xAA+0x55+0x01+0x04+0x64+0x64+0x01+0x01 = 0x2D3,取低8位0xD3)

在STM32端,Task_Comm任务中解析此协议。使用一个状态机来解析:

  1. 状态0:寻找帧头1。收到0xAA则进入状态1。
  2. 状态1:寻找帧头2。收到0x55则进入状态2,否则回状态0。
  3. 状态2:读取命令字和数据长度
  4. 状态3:读取指定长度的数据
  5. 状态4:读取并验证校验和。如果校验通过,则根据命令字执行相应操作(如将速度值通过消息队列发送给Task_MotorCtrl),然后状态机复位。

实操心得:串口通信一定要做好超时和容错处理。如果在一个状态等待超时(比如500ms),状态机必须复位到初始状态,否则一次错误数据就会导致整个通信挂死。另外,STM32端最好使用DMA+串口空闲中断的方式来接收数据,这样可以一次性接收一帧数据,避免在任务中频繁切换上下文去处理单个字节。

5. ESP32蓝牙串口透传与小程序连接

5.1 ESP32的蓝牙SPP服务端配置

在Arduino IDE环境下开发ESP32的蓝牙部分非常方便。核心是使用BluetoothSerial库。

#include “BluetoothSerial.h” BluetoothSerial SerialBT; String device_name = “SmartCar_ESP32”; // 蓝牙设备名称 void setup() { Serial.begin(115200); // 用于与STM32通信的硬件串口 SerialBT.begin(device_name); // 启动蓝牙,设备可见 Serial.printf(“蓝牙设备已启动,名称:%s\n”, device_name.c_str()); } void loop() { // 1. 检查蓝牙是否有数据传来,有则转发给STM32 if (SerialBT.available()) { char cmd_from_phone = SerialBT.read(); Serial.write(cmd_from_phone); // 通过硬件串口TX发送给STM32 } // 2. 检查STM32是否有数据传来(通过串口RX),有则转发给手机 if (Serial.available()) { char data_from_stm32 = Serial.read(); SerialBT.write(data_from_stm32); } }

这段代码实现了一个最简单的双向透传桥接。ESP32就像一个“接线员”,把手机蓝牙发来的数据原封不动通过串口发给STM32,也把STM32发来的数据通过蓝牙传给手机。

注意事项:务必确保ESP32与STM32之间的串口波特率、数据位、停止位、校验位设置完全一致(例如115200, 8N1)。连接时,ESP32的TX接STM32的RX,ESP32的RX接STM32的TX。

5.2 微信小程序蓝牙API连接与控制

小程序端的逻辑主要分为几个阶段:初始化蓝牙、搜索设备、连接、获取服务/特征值、读写数据。

  1. 初始化蓝牙适配器

    wx.openBluetoothAdapter({ success: (res) => { console.log(‘蓝牙适配器初始化成功’); this.startBluetoothDevicesDiscovery(); }, fail: (err) => { console.error(‘初始化失败:’, err); } });
  2. 搜索设备并过滤:在onBluetoothDeviceFound回调中,根据设备名称(device.name)过滤出我们的SmartCar_ESP32

  3. 连接设备并发现服务:连接后,需要调用wx.getBLEDeviceServiceswx.getBLEDeviceCharacteristics来获取服务的UUID和特征值UUID。这里是经典蓝牙(SPP)模拟BLE的一个关键点!ESP32在SPP模式下,会提供一个特定的服务UUID(通常是00001101-0000-1000-8000-00805F9B34FB),其特征值用于读写。你需要将这个UUID硬编码在小程序里。

  4. 读写数据:找到正确的特征值UUID后,就可以通过wx.writeBLECharacteristicValue向ESP32发送指令(即前面协议封装好的字节数组),并通过wx.onBLECharacteristicValueChange监听ESP32发来的数据(如传感器数据),进行解析和显示。

避坑指南:安卓和iOS在蓝牙权限、API行为上可能有细微差别。务必在真机上进行充分测试。特别是写入数据时,需要将字符串或数字转换成ArrayBuffer。小程序蓝牙API的调用是异步的,要做好回调函数里的错误处理,避免连接断开后还在尝试发送数据。

6. 电机控制与PID调速算法实现

6.1 带编码器的直流电机速度获取

使用STM32的定时器编码器接口模式,可以轻松读取编码器脉冲数。以TIM2为例,在CubeMX中配置为编码器模式,选择TI1和TI2两个通道。初始化后,编码器的计数值会自动存储在htim2.Instance->CNT寄存器中。

速度计算的核心是在固定周期内(如10ms),读取编码器计数值的增量。这个增量就代表了电机在这段时间内转过的角度或脉冲数。

int32_t get_motor_speed(TIM_HandleTypeDef *htim) { static int32_t last_count = 0; int32_t current_count = (int32_t)(htim->Instance->CNT); int32_t diff = current_count - last_count; // 计算增量 last_count = current_count; // 处理计数器溢出(16位或32位) if(diff > 32768) diff -= 65536; // 假设是16位计数器,向上溢出 else if(diff < -32768) diff += 65536; // 向下溢出 // 将脉冲增量转换为实际转速(RPM或Pulse/s) // 例如:每转产生N个脉冲,采样周期为T秒 // speed_rps = diff / (N * T); return diff; }

6.2 增量式PID控制器设计与参数整定

对于直流电机的速度控制,增量式PID比位置式PID更常用,因为它输出的是控制量的增量,误动作影响小,且更易于实现无扰切换。

typedef struct { float Kp, Ki, Kd; // PID参数 float error_prev; // 上一次误差 float error_prev2; // 上上次误差(用于增量式) float integral; // 积分项(位置式用) float output_max, output_min; // 输出限幅 } PID_Controller; float pid_incremental(PID_Controller *pid, float setpoint, float measurement) { float error = setpoint - measurement; // 增量式PID公式: Δu = Kp*(e(k)-e(k-1)) + Ki*e(k) + Kd*(e(k)-2e(k-1)+e(k-2)) float delta_output = pid->Kp * (error - pid->error_prev) + pid->Ki * error + pid->Kd * (error - 2*pid->error_prev + pid->error_prev2); // 更新历史误差 pid->error_prev2 = pid->error_prev; pid->error_prev = error; // 返回的是输出增量,需要在外部累加得到最终输出 return delta_output; }

在电机控制任务中,流程如下:

  1. 读取编码器值,计算当前实际速度。
  2. 调用PID函数,计算PWM占空比的增量。
  3. 将增量累加到当前的PWM占空比上。
  4. 对最终的PWM占空比进行限幅(比如0-1000对应0-100%)。
  5. 更新定时器的CCR寄存器,改变PWM输出。
  6. 任务通过OSTimeDly()延时,等待下一个控制周期。

参数整定“三步法”

  1. 比例(P)先行:将Ki和Kd设为0,逐渐增大Kp,直到电机出现明显的、等幅振荡。此时记下Kp值为Ku(临界增益),振荡周期为Tu。
  2. 加入积分(I):引入Ki,按照Ziegler-Nichols等经验公式(如Ki = 0.45 * Ku / Tu)设置,或者手动微调,以消除静差(即目标速度与实际速度的稳态误差)。
  3. 加入微分(D):最后加入Kd(如Kd = 0.125 * Ku * Tu),用于抑制超调和振荡,提高系统响应速度。微分项对噪声敏感,实际中往往用得较小,甚至为0。

核心技巧:在软件中实现“抗积分饱和”和“输出限幅”至关重要。当输出已经达到最大或最小值时,应停止积分项的累加,防止积分项过大导致系统恢复时产生巨大超调。

7. 系统联调与常见问题排查实录

7.1 上电顺序与电源噪声干扰

现象:小车偶尔无故复位,ESP32连接不稳定,传感器读数跳动大。排查

  1. 检查电源。用万用表测量电机动作时,STM32和ESP32的3.3V/5V电源引脚电压是否被拉低(如低于3.0V)。如果是,说明电机电流影响了控制电路供电。
  2. 解决:确保电机电源与控制电源分离。在电机驱动模块的电源输入端并联更大的电解电容(如1000uF 25V)。在AMS1117等LDO的输入输出端,靠近芯片引脚处并联一个10uF和一个0.1uF的电容进行滤波。
  3. 注意上电顺序。最好先给控制部分上电,稳定后再给电机驱动部分上电。

7.2 蓝牙连接不稳定或距离短

现象:手机小程序经常断开连接,或者距离一两米就断连。排查

  1. 检查ESP32天线区域(PCB板上的蛇形走线部分)是否被金属车体或电池大面积覆盖。金属会严重屏蔽蓝牙信号。
  2. 解决:尽量将ESP32模块置于小车塑料外壳的顶部或侧面,天线部分朝向空旷处。可以尝试外接一根小型的2.4G胶棒天线(如果模块支持)。
  3. 检查电源。供电不足也会导致射频性能下降。确保ESP32的3.3V电源纹波小,电流充足(峰值可能超过500mA)。

7.3 电机启动时单片机死机

现象:一发送前进指令,小车抽搐一下然后就全停了,需要重新上电。排查

  1. 这是最典型的电源问题。电机启动瞬间的浪涌电流导致整个系统电压跌落,触发STM32的欠压复位(BOR)。
  2. 解决:除了加强电源滤波电容,还可以在软件上做“软启动”。即不要一下子将PWM占空比从0调到很大,而是以较小的步长逐渐增加到目标值,给电源一个缓冲时间。

7.4 串口通信数据错乱或丢失

现象:手机控制指令时灵时不灵,传感器数据上传不全。排查

  1. 首先用USB转TTL工具,分别监听STM32发给ESP32的串口数据,以及ESP32发给手机的蓝牙数据,定位问题发生在哪一段。
  2. 检查波特率是否准确。115200的波特率,对于8M晶振的STM32F103,通常使用USART_BRR = 0x1D4C这个值,计算要精确。
  3. 检查双方的中断优先级。如果STM32处理电机控制的中断优先级过高,且执行时间过长,可能会打断串口接收中断,导致数据丢失。适当调整中断优先级(NVIC),确保串口接收中断能及时响应。
  4. 解决:在STM32端使用DMA+串口空闲中断接收。发送端(无论是STM32还是ESP32)在发送一帧数据后,最好延时几毫秒,避免连续发送导致缓冲区溢出。

7.5 UCOSII任务堆栈溢出

现象:程序运行一段时间后,出现HardFault等异常。排查

  1. UCOSII提供了堆栈检查功能。在os_cfg.h中启用OS_TASK_STAT_ENOS_TASK_STAT_STK_CHK_EN,然后在统计任务中观察各任务的堆栈使用率。
  2. 解决:在任务创建时,给可能使用较多局部变量或调用深层次函数的任务(如Task_Comm解析协议时)分配更大的堆栈空间。也可以优化代码,减少函数调用层级和大型局部数组。

这个项目从硬件焊接、驱动编写、RTOS移植到前后端联调,几乎涵盖了嵌入式开发中80%的常见技能点。它不像做一个单纯的LED闪烁那样立竿见影,过程中你会遇到电源、噪声、时序、通信、调试等各种问题,每一个问题的解决都是实实在在的经验积累。当最后用手机小程序流畅地遥控着小车前进后退,屏幕上实时显示着前方障碍物的距离时,那种成就感是无可替代的。它不仅仅是一辆小车,更是一个可扩展的移动机器人平台,后续你可以轻松地加入摄像头、机械臂、室内定位等模块,继续探索更广阔的世界。

本文还有配套的精品资源,点击获取

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

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

立即咨询