
简介这是一份基于FreeRTOS与STM32的超声波智能避障平衡小车完整工程面向单片机、嵌入式方向的开发者和学生适用于毕业设计、课程设计、工程实训与学科竞赛。工程在STM32平台移植FreeRTOS实现了小车自平衡、超声波避障和手机远程控制三大功能源码已经过验证可直接编译烧录复现门槛低。资源包共有993个文件大小约125兆以C语言与头文件源码为主体同时含有Keil工程配置、CubeMX初始化文件、启动文件、链接脚本及PDF说明文档覆盖从工程配置到程序烧录的完整链路。目前已有572人学习下载适合作为项目练手或二次开发的基础。除源码工程和说明文档外项目还给出了引脚定义与接线思路即使不会绘制PCB也可用面包板加杜邦线外接模块的方式搭建硬件便于快速复刻并扩展更多智能功能。1. 引言这个平衡车里藏着三块值得拆解的硬骨头把一辆两轮小车立在原地不摔再让它自动躲开障碍物同时还接受手机遥控这三件事单拎出来都不算简单但有意思的恰恰是它们被装进同一颗STM32后产生的冲突姿态解算要高频且确定超声波测距要避开电机噪声蓝牙数据又可能随时打断控制时序。用裸机轮询做控制周期一抖车就倒给你看用FreeRTOS把这些任务按优先级切开反而把问题变成了纯粹的资源调度和时序设计。这个工程的参考价值不在平衡本身而在它演示了一组典型嵌入式系统设计的完整拆法——从任务划分、传感器融合到协议帧设计每一步都有能直接抄的代码和值得记的坑。2. FreeRTOS任务划分让姿态环、避障、蓝牙互不打架2.1 为什么选FreeRTOS而不是裸机轮询很多从51单片机过渡过来的开发者习惯超级循环主循环里读MPU6050、算PID、扫超声波、查串口。这套逻辑在只有一两个外设时没问题但平衡车恰恰是嵌入式系统设计里典型的多种周期任务并存场景——姿态解算想要1kHz超声波测一个来回就要几十毫秒蓝牙串口事件完全是异步的。硬塞进一个循环要么姿态被超声波阻塞要么蓝牙命令得不到及时响应。FreeRTOS解决的不是多任务本身而是给每个任务明确的时间边界。控制任务以最高优先级跑姿态解算和控制输出始终被优先执行避障任务在测距时哪怕被中断也只是让出CPU而不是阻塞系统。关键点在于任务优先级的选择优先级越高抢占越强但也要承担阻塞低优先级任务的风险。比如把蓝牙任务提到和姿态控制一样高一旦串口中断频繁触发控制任务就可能被延迟。2.2 工程里的任务表与优先级设计拿到工程后先不要急着看PID参数打开FreeRTOS的创建任务代码把每个任务的名字、优先级、栈大小列出来。这个工程的实际划分值得抄作业四个任务配合得很干净任务名优先级任务周期栈大小(字)职责ControlTask4最高1ms 软件定时器触发256读倾角计算直立PD输出PWMSensorTask35ms 轮询256读取MPU6050执行互补滤波AvoidTask250ms 轮询128触发HC-SR04计算距离决策转向BluetoothTask2事件驱动队列128解析串口指令切换控制模式注意两个细节ControlTask不直接读传感器而是从SensorTask刚更新完的全局变量里取数据避免在1ms周期里等待I2C——这个设计思路在嵌入式系统设计里很关键。另外Ultrasound和蓝牙任务同级用时间片轮转也没问题因为蓝牙数据量极小执行完解析就挂起。任务创建代码大致是这个结构TaskHandle_t hControlTask, hSensorTask, hAvoidTask, hBtTask; void system_tasks_init(void) { xTaskCreate(vControlTask, control, 256, NULL, 4, hControlTask); xTaskCreate(vSensorTask, sensor, 256, NULL, 3, hSensorTask); xTaskCreate(vAvoidTask, avoid, 128, NULL, 2, hAvoidTask); xTaskCreate(vBtTask, bt, 128, NULL, 2, hBtTask); vTaskStartScheduler(); }优先级的数字越大越优先。ControlTask必须独占最高优先级否则任何一次任务切换都可能让控制周期漂移车就会高频抖动。SensorTask比避障高是因为互补滤波的结果直接喂给控制环数据越新鲜越好。避障和蓝牙的优先级最低且相同因为它们都有容忍延迟的特性——测距慢几十毫秒大不了多走一步蓝牙慢几十毫秒用户也感受不到。栈大小的估值有一个经验公式任务里最大函数调用链所需的局部变量总和加上中断嵌套的余量再乘以1.5。256字足够跑PID运算和浮点操作但如果你在蓝牙任务里调用vsnprintf这类格式化输出函数栈至少要翻倍。2.3 数据传递用全局变量还是队列按场景选任务间通信是FreeRTOS使用中最容易走极端的点。很多教程会强调队列但平衡车这种低频、短延迟的传感器数据用队列反而画蛇添足——入队出队有拷贝开销且队列满时会阻塞发送者。我这边的做法是MPU6050解算出的倾角和角速度用volatile全局变量直接暴露给控制任务超声波距离和蓝牙命令用队列传递因为它们是事件型数据不希望丢失或覆盖。// sensor_task.c volatile float g_angle 0.0f; volatile float g_gyro 0.0f; // 在SensorTask中被定时更新 void vSensorTask(void *arg) { for (;;) { read_mpu6050(g_angle, g_gyro, g_temperature); vTaskDelay(pdMS_TO_TICKS(5)); } }为什么要加volatile因为ControlTask运行在更高优先级编译器无法判断它什么时候读这个变量不加volatile启用-O2优化后read端可能把值缓存到寄存器里导致控制始终用旧的倾角。另外一个细节是如果两个任务可能同时写一个全局变量比如信号量、状态机标志必须用taskENTER_CRITICAL()保护或者在中断函数里用portYIELD_FROM_ISR触发调度。2.4 栈溢出检测一定要开嵌入式系统设计里最隐蔽的bug就是栈溢出——车跑着跑着突然HARD FAULT或者某个变量莫名其妙被改写。FreeRTOS提供了两套检测机制第一套是内存上填金丝雀值任务切换时检查是否被踩穿第二套是MPU保护需要硬件支持。最轻量的做法是用vApplicationStackOverflowHook钩子void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName) { // 打印任务名后锁死方便定位是哪个任务出了问题 printf(Stack Overflow: %s\r\n, pcTaskName); taskDISABLE_INTERRUPTS(); for (;;); }这套机制默认只在使用heap_4或heap_5内存管理方案时才完整生效。如果工程编译选项里没开INCLUDE_vTaskDelete这个钩子不会被调用。另外还有更温情的排查方式——调用uxTaskGetStackHighWaterMark(hControlTask)查看栈的水位线小于20字就得加栈了这在我后面第5章会讲具体做法。3. 自平衡核心MPU6050姿态融合与直立PD控制3.1 从加速度计和陀螺仪得到可靠倾角自平衡的基础是一个足够平滑、延迟够低的倾角信号。MPU6050能同时给出加速度计和陀螺仪数据但直接测倾角都不行加速度计在静止时很准但小车一加速或振动线性加速度会和重力混在一起角度瞬间失真陀螺仪的积分漂移又会让角度慢慢飞掉。互补滤波就是平衡这两者的经典方案在嵌入式系统设计里属于性价比最高的滤波算法。它对陀螺仪积分角度做高通对加速度计解算角度做低通然后用一个权重系数融合在一起#define FILTER_ALPHA 0.98f // 典型互补滤波dt取任务周期0.005s float complementary_filter(float acc_angle, float gyro_rate) { static float fused_angle 0.0f; float dt 0.005f; fused_angle FILTER_ALPHA * (fused_angle gyro_rate * dt) (1.0f - FILTER_ALPHA) * acc_angle; return fused_angle; }FILTER_ALPHA取0.98的意思是98%相信陀螺仪的短时积分2%相信加速度计的长期修正。系数越大滤波越平滑但对加速度突变响应越慢系数越小抗振性越差。平衡车场景我一般先从0.98开始车抖就往下调车倒得慢就往上调。这里没有绝对正确值调的是车在水泥地和瓷砖地都不倒的折中。加速度计解算倾角的公式需要从寄存器原始数据换算成物理值。MPU6050默认量程是±2g灵敏度是16384 LSB/g所以读取ACCEL_XOUT和ACCEL_ZOUT后float acc_angle atan2f(accel_y, accel_z) * 180.0f / 3.14159f;这里取y轴和z轴而不是x轴和y轴取决于小车安装方向。工程里MPU6050是横放的y轴指向车前进方向z轴垂直向上atan2(y, z)算出来的就是前倾后仰角。注意atan2的参数顺序——写反了角度会偏移90度车一上电就往前冲这是初学最容易踩的坑。3.2 直立PD环与速度PI环的配合得到倾角后平衡控制用的是典型的直立PD速度PI串级结构。直立PD的直接输出是PWM占空比公式很简洁int16_t balance_pid_control(float angle, float gyro) { float p_term BALANCE_KP * angle; // 比例项角度偏差 float d_term BALANCE_KD * gyro; // 微分项角速度来自陀螺仪 float output p_term d_term; if (output PWM_MAX) output PWM_MAX; if (output -PWM_MAX) output -PWM_MAX; return (int16_t)output; }注意D项用的是陀螺仪的原始角速度而不是PID里常用的(当前角度減上次角度)/dt——这样更平滑因为陀螺仪本身已经是角速度传感器。只做直立PD车能站着但会慢慢朝一个方向走掉所以需要速度环。速度环的做法是把编码器测得的实际轮速做PI控制输出值作为目标倾角偏移叠加到直立环的输入上// speed_task周期10ms static float speed_integral 0.0f; float speed_pi_control(float target_speed_cm_s, float current_speed_cm_s) { float error target_speed_cm_s - current_speed_cm_s; speed_integral error * 0.01f; speed_integral constrain_f(speed_integral, -50.0f, 50.0f); // 限幅防积分饱和 float output SPEED_KP * error SPEED_KI * speed_integral; return constrain_f(output, -3.0f, 3.0f); // 单位度作为直立环的目标倾斜角 }速度环的输出加到目标倾角上比如车往前走了编码器反馈速度为正速度环输出一个负的角度偏移让车身微微后仰利用重力减速。这就是平衡车速度闭环的本质——不是直接制动而是改变重心。限幅值±3度很关键超过这个值车身倾角控制就会不稳定车会开始震荡。3.3 PID参数怎么定从振荡到收敛的三步走PID参数在这个工程里是硬调出来的但调参顺序比参数本身更值得记录。我的固定套路是三步第一步只调直立环的Kp。从10开始往上涨涨到车能快速回正但开始小幅高频抖动记下这个值往回退20%作为初值。这里有个经验Kp太小车无力回正直接倒Kp太大车的抖动手感明显且电机发烫。第二步固定Kp调Kd。Kd的作用是阻尼从0开始每0.05一档往上加加到车不再抖手推一下能干脆利落地回正为止。Kd过大会让车发僵推起来像撞墙电机还会嗡嗡响。第三步加入速度环。Kp先给0.1观察车往哪个方向漂——往前漂说明目标前倾角度偏正把速度环的Kp增加让它更积极回拉。Ki的作用是消除静态误差但给太大会让车像呼吸一样周期性地前倾后仰这是因为速度积分饱和了。下表是我调出来的一组可跑参数抄的时候先按这个来参数数值范围单位说明BALANCE_KP30~50千分比PWM/度倾角到占空比的增益太大抖动BALANCE_KD0.6~1.2千分比PWM/(度/秒)阻尼系数太大发僵SPEED_KP0.1~0.3目标倾角/(cm/s)速度误差到倾角的增益SPEED_KI0.01~0.05目标倾角/(cm·s)需配合积分限幅使用调参时一定要用串口把角度和PWM值打印出来不要凭手感瞎试。看波形比看车准得多角度曲线毛刺多检查滤波系数PWM到顶频繁说明Kp给大了或者机械重心偏了。4. 超声波避障与蓝牙远控从时序到协议4.1 HC-SR04测距的DWT计时方案HC-SR04的时序在嵌入式系统设计里是经典基础给Trig引脚一个大于10μs的高电平触发模块自动发8个40kHz超声波脉冲并拉高Echo引脚Echo高电平持续的时间就是声波往返的时间。距离按340m/s算time除以2再乘0.034单位厘米。但Degisn里有个关键细节常被忽略Echo高电平时间最长60ms对应大约10米量程这个时间在40MHz的STM32F4上用HAL_Delay微秒级阻塞等待会严重拖累其他任务。我见过很多人直接在SensorTask里轮询等Echo拉低结果FreeRTOS的调度器被堵死了——因为阻塞等待不是挂起任务而是占着CPU空转。正确做法是配合DWT内核时钟计数器做非阻塞计时。Cortex-M4内核自带一个自由运行的周期计数器CYCCNT精度远高于HAL_GetTick()的1ms。启用后在测距函数里直接读它避免中断和系统Tick干扰// 启用DWT周期计数器需要在初始化时调用一次 void dwt_init(void) { CoreDebug-DEMCR | CoreDebug_DEMCR_TRCENA_Msk; DWT-CYCCNT 0; DWT-CTRL | DWT_CTRL_CYCCNTENA_Msk; } uint32_t measure_distance_cm(void) { HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_SET); delay_us(20); HAL_GPIO_WritePin(TRIG_PORT, TRIG_PIN, GPIO_PIN_RESET); uint32_t timeout 0; while (HAL_GPIO_ReadPin(ECHO_PORT, ECHO_PIN) GPIO_PIN_RESET) { if (timeout 2000) return 999; // 无遮挡或异常返回大值 } uint32_t start DWT-CYCCNT; timeout 0; while (HAL_GPIO_ReadPin(ECHO_PORT, ECHO_PIN) GPIO_PIN_SET) { if (timeout 30000) return 999; } uint32_t elapsed DWT-CYCCNT - start; // 系统主频168MHz声速340m/s往返除以2 return (uint32_t)((float)elapsed / 168000000.0f * 34000.0f / 2.0f); }注意两点第一DWT-CYCCNT在FreeRTOS调度器切换任务时依然会跑不会受影响第二两次while循环的timeout计数值不是时间单位只是防止Echo引脚异常悬空导致死循环的保险丝。返回999厘米表示无障碍这个值在避障逻辑里作为安全距离处理比返回0更合理——0会被误判成贴着障碍物车会原地乱转。4.2 避障策略可复用的S型绕行逻辑测距拿到了剩下的是决策逻辑。我一开始用最简单的近距离停车转弯策略距离小于25cm原地右转90度结果小车在墙角会陷入左右反复横跳。原因很简单每次转完弯再直行车头还是对着墙角超声波测距再次触发避障形成死循环。改进后用的是带记忆的S型绕行策略。核心思路是给避障状态机加一个上次转向方向的变量下次遇障就朝相反方向转形成S型前进路径typedef enum { AVOID_FORWARD, AVOID_TURN_LEFT, AVOID_TURN_RIGHT, AVOID_BACKWARD } AvoidState; static AvoidState avoid_state AVOID_FORWARD; static uint8_t last_turn_dir 0; // 0左, 1右 void avoid_update(uint32_t dist_cm) { if (dist_cm 35) { avoid_state AVOID_FORWARD; // 前方安全恢复直行 return; } if (dist_cm 15) { avoid_state AVOID_BACKWARD; // 太近了先退防止撞上 set_motor_command(CMD_BACK, 180); return; } // 交替转向避免卡墙角 if (last_turn_dir 0) { avoid_state AVOID_TURN_RIGHT; set_motor_command(CMD_RIGHT, 200); last_turn_dir 1; } else { avoid_state AVOID_TURN_LEFT; set_motor_command(CMD_LEFT, 200); last_turn_dir 0; } }最后一个细节超声波传感器装在车头且有一定仰角时远距离测量会打到地面返回错误读数。所以在预处理里加个范围过滤只信任8cm到60cm之间的数据超过这个区间一律按无障碍处理。别小看这个过滤它直接决定了小车过门框时会不会乱转向。4.3 蓝牙遥控协议设计与串口中断处理手机远程控制这块市面上的模块大多是HC-05或HC-06转串口透传模式所以嵌入式端要处理的不是蓝牙协议而是自己定一个应用层帧格式。工程里我用的是一个非常紧凑的4字节帧帧头0xAA 命令码 校验字节 帧尾0x55。命令码语义如下命令码含义动作0x01前进目标速度2000x02后退目标速度-2000x03左转转向环输出偏移0x04右转转向环输出偏移0x05停止目标速度为00x06自动模式切换到超声波避障0x07手动模式切换到蓝牙手动控制协议里的校验字节用最简单的异或和比CRC16省算力且透传场景的错误率极低够用。串口中断里把收到的字节喂给一个状态机拼完整帧后通过FreeRTOS队列发给蓝牙任务#define BUF_LEN 4 static uint8_t rx_buf[BUF_LEN]; static uint8_t rx_index 0; static uint8_t rx_state 0; void HAL_UART_RxCpltCallback(UART_HandleTypeDef *huart) { if (huart-Instance USART1) { uint8_t byte rx_buf[rx_index]; if (rx_state 0) { if (byte 0xAA) rx_state 1; // 帧头 } else if (rx_state 1) { rx_buf[0] 0xAA; rx_buf[1] byte; rx_state 2; } else if (rx_state 2) { // 异或校验命令码与帧头异或不等于则丢弃 if (byte (0xAA ^ rx_buf[1])) rx_state 3; else rx_state 0; } else { if (byte 0x55) { // 完整帧投递到蓝牙任务队列 uint8_t cmd rx_buf[1]; xQueueSendFromISR(bt_cmd_queue, cmd, NULL); } rx_state 0; } HAL_UART_Receive_IT(huart, rx_buf[rx_index], 1); } }因为用了HAL_UART_Receive_IT单字节接收每次中断只收一个字节。这种方式的优点是代码简单、状态清晰缺点是每个字节都进一次中断波特率115200时CPU占用不高可以接受。队列在创建时深度设4就够防止用户快速连点屏幕时溢出。蓝牙任务只做一件事阻塞在xQueueReceive上拿到命令后修改全局的目标速度或控制模式标志。最后在ControlTask里加一段模式切换if (g_control_mode MODE_MANUAL) { // 蓝牙手动模式直立环依然工作速度环目标是蓝牙设置值 speed_pi_control(bt_target_speed, current_speed); } else { // 自动模式调用避障逻辑超声波结果决定目标速度 avoid_update(last_dist_cm); }手动模式下平衡依然存在蓝牙只是控制往哪走而不是要不要站住——这个设计让小车在遥控时依然安全松手不会倒。5. 接线、三个高频坑与验证技巧5.1 完整的引脚接线参考拿到工程后先对照引脚定义连接硬件。我用的是STM32F407VET6最小系统板配TB6612电机驱动和HC-05蓝牙模块整套系统的接线参考如下外设引脚对面引脚说明MPU6050PB6(SCL), PB7(SDA)SCL, SDAI2C13.3V供电超声波TrigPB10Trig普通GPIO输出超声波EchoPB11Echo必须经电阻分压或二极管保护蓝牙TXPA10RXUSART1蓝牙RXPA9TXUSART1注意3.3V电平电机PWMPA8/PA11AIN1/AIN2TIM1_CH1/CH410kHz电机方向PB12/PB13BIN1/BIN2普通GPIO编码器PB4/PB5左电机A/B相TIM3编码器模式Echo引脚分压是很多人忽略的一步。HC-SR04是5V逻辑STM32的GPIO耐压虽然标称5V但把5V直接怼到ADC或I2C引脚上内部保护二极管会导通长期使用轻则Echo读数漂移重则GPIO烧毁。稳妥做法是串一个1kΩ电阻再并联一个2kΩ电阻分压到3.3V。5.2 三个高频坑的处理方案第一个坑是电机供电和单片机供电共地但不同源。两个18650电池串联约7.4V给TB6612供电同时用降压模块稳到5V供给单片机。如果电机地和单片机的GND不连在一起PWM信号就会因参考地不一致而乱码电机转动时单片机甚至能直接复位。务必把电机驱动板和单片机的GND用粗线连起来。第二个坑是PWM频率和电机噪声。工程里TIM1配置的是10kHz。低于5kHz电机定子会发出可听见的啸叫同时电流纹波大高于20kHz驱动管的开关损耗上升TB6612发热明显。10kHz是电机驱动场景下兼顾噪声、功耗和响应速度的折中值。第三个坑是FreeRTOS任务栈溢出导致硬件错误。前面第2章提过钩子函数但printf在钩子里不一定能执行——因为栈已经踩了打印本身又会压栈。更可靠的定位手段是看任务的水位// 在串口调试任务中周期性执行 UBaseType_t wm_ctrl uxTaskGetStackHighWaterMark(hControlTask); UBaseType_t wm_avoid uxTaskGetStackHighWaterMark(hAvoidTask); printf(ctrl%u avoid%u\r\n, wm_ctrl, wm_avoid);跑10分钟水位低于20字的任务栈顶多再加128字。不要给所有任务统一256字太浪费RAM。5.3 验证是否真正复刻成功的三个标准项目拿到手烧录前先做静态验证打开MPU6050的初始化函数确认量程配置是ACCEL_CONFIG的±2g且DLPF滤波开启然后检查FreeRTOSConfig.h里的configUSE_IDLE_HOOK是否打开——工程有低功耗或喂狗操作依赖这个钩子。动态验证分三步走。第一步只开调试串口输出手拿起小车保持水平看角度数据是否稳定在±1度以内用手快速转动车体看角度是否平滑跟随。第二步把直立环使能手扶着车放在地上慢慢松手车应该会前后快速微调而不是直接倒如果向前倒说明Kp符号反了把Kp取负号即可——这是新手上电最容易看到的现象。第三步蓝牙连接手机发送前进命令车应该缓慢匀速直行发送停止后车平稳停住不点头。最后一个验证技巧是用逻辑分析仪抓PWM波形正常直立时占空比应该在50%附近抖动如果看到占空比满幅到100%或0%频繁切换说明PID参数已经工作在饱和区了需要降增益。没有逻辑分析仪的话用串口打印占空比数据也一样。这套验证流程跑完一遍基本就能确认复刻成功接下来调参就是按第3章的顺序做微调。本文还有配套的精品资源点击获取