简介:面向自动控制初学者与嵌入式开发者的平衡小车实践资料包,围绕陀螺仪与加速度计姿态采集、PID 控制、卡尔曼滤波及状态观测等核心环节,提供可对照学习的工程实现,适合课程设计、竞赛备赛与自平衡机器人入门。压缩包共 1420 个文件,约 36.17MB,以 439 个 C 源文件、379 个头文件为主,辅以汇编启动文件、Keil/IAR 等工程配置、hex/axf 编译产物及 txt、doc、pdf 说明文档,便于从代码结构、工程搭建到调试记录逐层查阅。已有 1748 人学习下载。读者可据此梳理平衡小车的控制流程与参数调节思路,结合源码理解传感器数据处理、电机控制与工程组织方式,并通过说明文档和编译输出辅助复现实验、排查问题,形成从理论到落地的完整参考。
1. 第一次上电那三秒,决定了这套平衡小车值不值得拆
拆开压缩包、接好线、烧录固件、上电——小车往前一窜,轮子空转两圈,"啪"一下趴在地板上。几乎每个做平衡小车的人都经历过这三秒。这套工程的价值不在"能站",而在于它把一条完整链路摆在你面前:姿态采集、滤波解算、串级 PID、PWM 驱动、编码器测速、串口波形回传,一层一层都能单独拎出来改。适合三类人:想真正弄懂串级 PID 为什么这么叠的人、拿平衡小车当课设或毕设原型的人、以及手上一堆散件不知道从哪下手的人。下面按"先看清结构、再跑通、最后改"的顺序拆。
2. 技术栈与目录结构:主控、传感器、驱动是怎么串成一条链的
2.1 硬件拓扑:五个模块咬合出的最小闭环
平衡小车的硬件不复杂,但每一环都有坑。常见配置是:一颗 STM32F103 系列主控跑控制环,MPU6050 挂在 I2C 上采三轴加速度和三轴角速度,两路带减速箱的直流电机由 TB6612 这类双 H 桥驱动,电机尾部的霍尔编码器接回主控的定时器编码器接口,OLED 或串口负责把角度、速度、PID 输出吐出来看。
关键在于"谁给谁提供反馈"。姿态环的反馈来自陀螺仪,速度环的反馈来自编码器,转向环的反馈可以是陀螺仪 Z 轴也可以是左右轮速差。三者叠加后才变成左右两路 PWM 占空比。你要是把这条反馈链想错了,后面调参就是在跟玄学搏斗。
| 模块 | 常见型号 | 接口方式 | 在控制环里的角色 |
|---|---|---|---|
| 主控 | STM32F103C8T6 / RCT6 | — | 跑 5ms 定时中断,算 PID,出 PWM |
| 姿态传感器 | MPU6050 | I2C(软件或硬件) | 提供俯仰角与俯仰角速度 |
| 电机驱动 | TB6612FNG | PWM + 2 路方向 IO | 把 PID 输出放大成电机电流 |
| 编码器 | 霍尔 / 光电,13 线常见 | TIM 编码器模式 | 提供左右轮实际线速度 |
| 显示 / 调试 | 0.96 寸 OLED、HC-05 蓝牙 | I2C / UART | 看参数、改参数、回传波形 |
上表里最容易被人忽略的是"编码器"这一行。很多人第一次做平衡小车只做直立环,车确实能站住,但你用手轻轻一推它就慢慢走远再也回不来——因为没有速度环把它拽回原点。这不是参数问题,是结构缺了一层。
2.2 压缩包解压后,每个文件夹该去哪儿找
这类工程压缩包解开后,通常不是单一 Keil 工程,而是固件 + 上位机 + 硬件资料 + 文档的组合。目录大概长这样,你对照着自己那份看,名字可能不同但分层逻辑基本一致:
balance_car/ ├── firmware/ # 单片机固件,重点在这 │ ├── Core/ # 启动文件、时钟配置、中断入口 │ ├── Drivers/ # 芯片外设库(标准库或 HAL) │ ├── Hardware/ # 板级驱动:mpu6050.c / motor.c / encoder.c / oled.c │ ├── App/ # 控制算法:pid.c / attitude.c / control.c │ ├── User/ # main.c、中断服务函数 │ └── project.uvprojx # Keil 工程文件,双击直接开 ├── host/ # 上位机,串口波形 + 参数下发 ├── hardware/ # 原理图 PDF、PCB 源文件、BOM ├── docs/ # 接线表、调试记录、参数说明 └── tools/ # 烧录工具、串口助手配置Hardware/和App/的分离是这个工程比较舒服的地方:前者只跟寄存器打交道,后者只跟物理量打交道。你想换一颗姿态传感器,只改Hardware/里的读数据函数,App/里的滤波和 PID 一行都不用动。二次开发时这条边界要守住,否则改着改着就变成一锅粥。
docs/里如果有接线表,先看它,别急着照着自己想法接线。平衡小车的电机方向、编码器 A/B 相、陀螺仪轴向,任何一个反了都会让 PID 从负反馈变成正反馈,表现就是上电瞬间满速冲出去。这种翻车我见过太多次。
2.3 把工程编译出来:环境、依赖和一个能跑的最小验证
固件工程大概率是 Keil MDK 的.uvprojx。需要的环境是 Keil MDK5、对应芯片的 Device Pack、以及一个 ST-Link 或 DAP-Link 下载器。如果你不想装 Keil,也可以用arm-none-eabi-gcc+ Makefile 自己搭一套,但工程里的启动文件和分散加载文件得手动对上,新手不建议一上来就这么干。
先在命令行里确认工具链在位,再决定用哪条路:
# 检查 Keil 命令行编译工具(装完 MDK 后一般在安装目录下) which UV4 || ls "/c/Keil_v5/UV4/UV4.exe" # 如果走 gcc 路线,确认交叉编译器版本 arm-none-eabi-gcc --version # 编译完成后确认产物,主要是 axf/hex/bin ls -lh firmware/Objects/*.axf firmware/Objects/*.hex第一条命令是在找 Keil 的批处理编译入口,找到之后可以直接UV4 -b project.uvprojx -o build.log做无界面编译,适合接 CI 或者批量出固件。第二条是给不用 Keil 的人准备的,arm-none-eabi-gcc版本建议 10 以上,老版本对 C99 的某些写法支持不全,容易在编译attitude.c时报莫名其妙的错。第三条只是确认产物,axf是带调试信息的可执行文件,hex/bin是拿去烧录的。
烧录这一步,命令行用 OpenOCD 或者 ST-Link 的 CLI 都行:
# 用 OpenOCD 把固件写进去并复位运行 openocd -f interface/stlink.cfg -f target/stm32f1x.cfg \ -c "program firmware/Objects/balance_car.hex verify reset exit"-f interface/stlink.cfg指定下载器,target/stm32f1x.cfg指定目标芯片系列,program ... verify reset exit四个动作连起来是:写 flash、校验、复位、退出。如果你的板子是 F4 系列,把 target 那一段换成对应的 cfg 就行。这一步失败最常见的原因是芯片被读保护了或者接线只接了 SWDIO 没接 SWCLK 的地线,先量一下 GND 通不通。
编译通过、烧录成功,不代表车能站。真正的验证是:上电后串口或 OLED 上能看到角度在 ±1 度内轻微波动,用手把车往前倾,角度数值单调变化、方向正确,松手能回零。这一步过了,才轮到调 PID。
3. 姿态解算与控制环:从陀螺仪原始值到左右轮 PWM 的完整链路
3.1 MPU6050 初始化:量程、滤波和那个必须做的零漂校准
MPU6050 上手第一件事不是读数据,是配量程。量程配小了,车一抖数据就饱和削顶,PID 直接发疯;配大了,分辨率不够,角度看着像台阶。经验值是陀螺仪至少 ±1000 dps、加速度计 ±8g,除非你的车特别温柔。
/* mpu6050.c —— 只保留和姿态相关的寄存器配置 */ #include "mpu6050.h" #include "i2c_soft.h" #define MPU_ADDR 0xD0 /* AD0 接地:7 位地址 0x68 左移一位 */ #define SMPLRT_DIV 0x19 #define CONFIG 0x1A #define GYRO_CONFIG 0x1B #define ACCEL_CONFIG 0x1C #define PWR_MGMT_1 0x6B #define ACCEL_XOUT_H 0x3B void MPU_Init(void) { I2C_WriteByte(MPU_ADDR, PWR_MGMT_1, 0x00); /* 解除休眠,用内部 8M 时钟 */ I2C_WriteByte(MPU_ADDR, SMPLRT_DIV, 0x04); /* 采样率 = 1k/(1+4) = 200Hz */ I2C_WriteByte(MPU_ADDR, CONFIG, 0x03); /* DLPF 约 44Hz,滤掉电机高频振动 */ I2C_WriteByte(MPU_ADDR, GYRO_CONFIG, 0x18); /* ±2000dps 满量程 */ I2C_WriteByte(MPU_ADDR, ACCEL_CONFIG, 0x10); /* ±8g */ }SMPLRT_DIV=0x04把内部采样率降到 200Hz,比控制周期 5ms(200Hz)刚好对齐,再快也没意义,反而让 I2C 总线更忙。CONFIG=0x03打开数字低通滤波,这个寄存器是血泪经验:不配它,电机一转,加速度计读数就跟着抖,滤波后的角度像心电图。0x03对应约 44Hz 截止,既能压住电机振动,又不会把真实倾倒的高频分量一起砍掉。
接着是零漂校准。陀螺仪静止时输出不是 0,是几百个 LSB 的偏置,不校准的话积分几秒钟角度就漂到天上去:
static float gyro_bias = 0.0f; /* 上电后、车必须完全静止,调用一次即可 */ void MPU_Calibrate(uint16_t times) { int32_t sum = 0; for (uint16_t i = 0; i < times; i++) { sum += MPU_ReadRawGyroY(); /* 只取俯仰轴角速度原始值 */ Delay_ms(5); } gyro_bias = (float)sum / (float)times; } float MPU_GetGyroY(void) { /* 减掉偏置,再按 16.4 LSB/(°/s) 换算成度每秒 */ return ((float)MPU_ReadRawGyroY() - gyro_bias) / 16.4f; }times建议 200 次以上,也就是大概 1 秒的采样,次数太少偏置估计不准。这里有个前提:校准期间车必须真的静止。很多人把校准放在main里,上电就调,结果手还扶着车,校出来的偏置带着一个恒定误差,后面怎么调参数都站不稳——这种问题查起来极其痛苦,因为现象和"参数没调好"完全一样。
3.2 互补滤波:加速度计和陀螺仪各信一半,但比例不能拍脑袋
加速度计算角度靠atan2,长期准但短时噪声大;陀螺仪积分短期准但会漂。互补滤波就是拿一个系数把两者按频率分工:高频信陀螺仪,低频信加速度计。
#define ALPHA 0.98f /* 滤波系数,越大越信陀螺仪 */ static float pitch = 0.0f; /* acc_angle:由加速度计算出的倾角(度) gyro_rate:去偏置后的俯仰角速度(度/秒) dt :两次调用的真实时间间隔(秒) */ void Attitude_Update(float acc_angle, float gyro_rate, float dt) { pitch = ALPHA * (pitch + gyro_rate * dt) + (1.0f - ALPHA) * acc_angle; } float Attitude_GetAngle(void) { return pitch; }ALPHA=0.98是常见起手值,但它不是万能常数。车重、电机振动大、传感器装得高,都会让最优值偏移。ALPHA调大,角度跟得快但漂移抑制弱;调小,静态稳但车倾倒的瞬间响应会迟滞,表现是"明明已经倒了,PID 还没反应过来"。判断方法很简单:车静止时看角度是否在几分钟内缓慢单向漂移,漂了就说明ALPHA太大或者零漂没校好。
dt这一项是最容易翻车的地方。它必须是真实控制周期,不是你想当然的 0.005。如果你在定时器中断里调用,dt用固定值没问题;如果在主循环里调用,就得用定时器读数算真实间隔,否则积分项会随主循环负载飘来飘去。我一般把姿态更新放在 5ms 定时中断里,dt直接写 0.005f,简单可靠。
想要更平滑的曲线就换卡尔曼或者 Mahony,但那更多是"看起来专业",对这种两轮小车,调好系数的互补滤波完全够用,而且计算量小到你不用关心它占了多少 CPU。
3.3 串级 PID:直立环给角度、速度环改目标、转向环加差速
平衡小车的控制结构是串级:直立环在内,速度环在外,转向环并联在输出端。理解顺序比记住参数重要得多。
typedef struct { float kp, ki, kd; float err_last; /* 上一次误差,用于微分 */ float integral; /* 积分累加 */ float out_max; /* 输出限幅 */ float integral_max; /* 积分限幅,防饱和 */ } PID_t; float PID_Calc(PID_t *p, float target, float measure) { float err = target - measure; p->integral += err; if (p->integral > p->integral_max) p->integral = p->integral_max; if (p->integral < -p->integral_max) p->integral = -p->integral_max; float out = p->kp * err + p->ki * p->integral + p->kd * (err - p->err_last); p->err_last = err; if (out > p->out_max) out = p->out_max; if (out < -p->out_max) out = -p->out_max; return out; }out_max一定要设,而且要和 PWM 定时器的自动重装值对上。比如 TIM3 配的是 7200,那out_max就该是 7200 左右,超了也没用,只是让积分项白白攒到饱和,车倒了一次之后要好几秒才能缓过来——这就是典型的积分饱和,加个integral_max能缓解大半。
直立环不用积分,用 PD 就够。原因是角度误差本身没有稳态,加上积分反而让车在原地缓慢前后晃。D 项可以直接用陀螺仪角速度,比用误差差分更干净,省掉一次减法带来的噪声放大:
/* control.c —— 5ms 定时中断里调用 */ void Control_Loop(void) { float angle = Attitude_GetAngle(); /* 当前倾角,度 */ float gyro = MPU_GetGyroY(); /* 俯仰角速度,度/秒 */ float speed = Encoder_GetSpeed(); /* 左右轮平均线速度,cm/s */ /* 直立环:目标机械中值 - 当前角度,D 项直接用角速度 */ float balance = pid_angle.kp * (MECH_ZERO - angle) - pid_angle.kd * gyro; /* 速度环:目标速度 0,输出叠加到直立环上 */ float speed_out = PID_Calc(&pid_speed, 0.0f, speed); /* 转向环:蓝牙给的转向量,作用为左右轮反向差速 */ float turn_out = pid_turn.kp * (turn_target - gyro_z); float out_l = balance + speed_out + turn_out; float out_r = balance + speed_out - turn_out; Motor_Set(out_l, out_r); }MECH_ZERO是机械中值,也就是小车真正能静止的那个角度。它不一定是 0 度——电池装在后面、电机前后重量不均,中值可能偏 2 到 5 度。这个值必须实测:把车扶到能自己站住的位置,读此刻的角度,写进去。写错了的表现是车能站但一直往一个方向偏,或者站着站着慢慢加速跑掉。
speed_out怎么叠加很讲究。它加的是"角度偏移量"而不是直接加在 PWM 上,物理含义是:车往前跑太快了,速度环输出让目标角度往前压一点,车就会主动前倾刹车。位置搞反了就是正反馈,车会越跑越快。判断方法是——用手推车往前,看它是不是先顺着走一小段再往回退,是就对了。
turn_out直接做左右差速,P 控制足够,不需要积分。差速量要给限幅,否则原地转向时一侧轮子反转,另一侧满速,电流一下拉满。
常用起手参数(依赖你的 PWM 量程和单位换算,只能当参考,不能照抄):
| 参数 | 含义 | 起手值范围 | 调过头的现象 |
|---|---|---|---|
pid_angle.kp | 直立环比例 | 200 ~ 400 | 低频前后大幅摆动 |
pid_angle.kd | 直立环微分 | kp 的 1/100 量级 | 高频抖动、电机发烫、嗡嗡响 |
pid_speed.kp | 速度环比例 | 20 ~ 60 | 车追着自己跑、来回窜 |
pid_speed.ki | 速度环积分 | kp 的 1/200 量级 | 低速蠕动、松手后停不下来 |
pid_turn.kp | 转向环比例 | 50 ~ 150 | 转向过冲、原地画圈 |
MECH_ZERO | 机械中值 | 实测 0 ~ 5 度 | 单向跑偏、站不住 |
3.4 时序安排:谁在中断里跑,谁在主循环里跑
控制环必须放在定时器中断里,周期固定 5ms。主循环只做三件不实时的事:刷 OLED、解析串口命令、处理按键。姿态读取、滤波、PID、电机输出这四步在中断里一气呵成,中间不能有Delay、不能有阻塞式 I2C 读 OLED,否则周期一抖,积分项就跟着抖。
编码器读数用定时器的编码器模式,每 5ms 读一次计数增量然后清零:
/* encoder.c —— 每个控制周期读一次,得到的是"这 5ms 内转了多少格" */ int16_t Encoder_ReadDelta(TIM_HandleTypeDef *htim) { int16_t cnt = (int16_t)__HAL_TIM_GET_COUNTER(htim); __HAL_TIM_SET_COUNTER(htim, 0); return cnt; }读到的增量乘一个系数就是 cm/s,系数靠"轮子转一圈多少格 × 轮周长 ÷ 0.005"算出来。这一步要是算错,速度环的目标 0 就变成了一个非零值,车会一直缓慢移动——很多人以为是ki的问题,其实是单位换算错了。
4. 从零跑通:编译、烧录、分阶段调试和第一次成功站立
4.1 上电前的静态检查:那些烧了板子才发现的问题
接线检查要在通电之前做完,顺序是:电源、地、电机、编码器、传感器、下载线。电源部分尤其要注意,电机和主控最好分开供电或者至少加个大电容,电机启动瞬间的电流跌落会把主控拉到复位,现象是"车一动就重启",OLED 闪一下黑屏。
几条必须逐项确认的:电机驱动板的 VM 接了电池正极、VCC 接了 5V 或 3.3V、GND 和主控共地、STBY 引脚拉高(不拉高芯片一直待机,PWM 给了也不转,这个坑几乎人人踩一次)。编码器的 A/B 相接反不会烧东西,但速度值会是反的,速度环变正反馈,车一上电就往后猛冲。
传感器安装位置也有讲究。MPU6050 要尽可能装在车的旋转中心附近、装平、装牢,用双面胶粘在壳子上大概率会因为车身振动而引入噪声。宁可多打两个螺丝孔。
4.2 分阶段调试:先让它站住,再让它不走,最后才让它听话
调试顺序不能乱,一次只加一层:
第一阶段只开直立环,速度环和转向环的输出强制为 0。目标是把车扶到机械中值附近,松手后它能前后小幅晃动但不倒。这一阶段kp从 200 开始往上加,加到车开始明显低频摆动就回退 20%,然后加kd压抖动。kd一加,电机就会开始有"嗡嗡"的高频声,这个声音存在是正常的,但如果声音大到刺耳而且车在原地高频颤,就是kd过头了。
第二阶段加速度环。先把ki设 0,只加kp,用手推车往前,观察它是否有"追回来"的趋势。有趋势之后再加一点点ki消除静差。这一阶段最容易犯的错是kp给太大,车会在原地疯狂前后冲,看起来像在抖腿。
第三阶段接蓝牙,测转向。转向环的kp从小往大调,调到给定一个转向量时车能平稳转过去、松手能停。
每个阶段结束都要用手机拍一段慢动作回放。肉眼看不出来的 5Hz 抖动,慢放一遍清清楚楚。
4.3 参数整定的手感:什么现象对应哪个参数
调参不是玄学,但确实有手感。几个可以对照的判断:
车静止时高频颤、电机烫、声音尖——kd太大或者kp太大,先砍kd。 车低频大幅前后摆,像在鞠躬——kp不够或者kd相对太小。 车能站但一推就走远、不回来——速度环没加、ki太小或者速度反馈符号反了。 车缓慢单向漂移然后加速跑掉——MECH_ZERO不准,或者陀螺仪零漂没校。 车一给转向就原地打转停不下来——转向环kp太大或者没有限幅。
改参数时一次只改一个,改完复位重来。同时改三个参数,你就再也说不清是哪个起的作用了。
4.4 用上位机看波形:把"感觉"换成曲线
串口回传是这套工程里最值得用的功能。把角度、角速度、速度环输出、左右 PWM 打包成一帧发出去,用串口示波器(VOFA+ 这类工具都行,或者自己写个 Python 脚本画图)实时看。
# host/plot.py —— 最简波形工具,串口读 CSV 帧直接画 import serial, matplotlib.pyplot as plt from collections import deque ser = serial.Serial("COM3", 115200, timeout=0.05) buf = {k: deque(maxlen=400) for k in ("angle", "gyro", "pwm_l", "pwm_r")} plt.ion() while True: line = ser.readline().decode(errors="ignore").strip() if not line.startswith("$"): # 帧头不对直接丢,防错位 continue parts = line[1:].split(",") if len(parts) != 4: continue for k, v in zip(buf, parts): buf[k].append(float(v)) plt.clf() for k in buf: plt.plot(buf[k], label=k) plt.legend(loc="upper right") plt.pause(0.01)startswith("$")这个帧头判断别省,串口上电瞬间会吐乱码,不判帧头的话第一帧解析就会抛异常退出。maxlen=400控制窗口长度,200Hz 采样下大约 2 秒,正好够看清一次完整的摆动周期。
看波形时重点看两件事:角度曲线是不是平滑的正弦,有没有叠加高频毛刺;PWM 输出在车静止时是不是在一个小范围内来回对称。如果 PWM 一直偏向一侧不回头,问题一定在机械中值或者速度环符号上,别再调kp了。
5. 避坑与排查:调平衡车最常翻车的五个现场
5.1 上电就满速冲出去,根本来不及反应
现象:一按电源,两个轮子瞬间满速,车直接飞出去撞墙,OLED 上角度数值看起来还算正常。
原因:PID 极性反了。三种可能——电机接线左右反、编码器 A/B 相反、陀螺仪轴向取反。任何一个反了,负反馈就变正反馈,误差越大输出越大,输出越大误差更大,几毫秒就饱和。
解决:先断开电机,只上电看角度。用手把车往前倾,角度数值应该往一个方向单调变化;再单独测电机,给一个正的 PWM,轮子应该往前转。两个方向都对上了再接起来。这一步花五分钟,能省一块驱动板。
5.2 车能站住,但一直在原地高频颤动、电机发烫
现象:松手后车确实不倒,但整个车身在 ±1 度内高频抖,手摸电机外壳烫得放不住,电池掉电特别快。
原因:kd给太大了。微分项对噪声极其敏感,陀螺仪那点本底噪声被放大后直接进 PWM,电机就在两个方向之间反复横跳。也可能是CONFIG寄存器没配低通滤波,电机振动原封不动进了加速度计。
解决:先把kd砍一半看效果,如果抖动明显减轻就是它。如果砍了还抖,去检查 MPU6050 的 DLPF 配置,把截止频率压到 20Hz 左右试试。另外确认传感器是不是用双面胶粘的,换成螺丝固定往往立竿见影。
5.3 能站能走,但推一下就走远,再也回不来
现象:车静止时站得很好,用手轻推一下,它顺着方向走一段然后越走越快,最后倒下。速度环参数怎么调都没用。
原因:速度环根本没生效。常见有三种——编码器读数一直是 0(接线错或者定时器编码器模式没使能)、速度环输出符号反了(加了反而加速)、速度环输出没有叠加到直立环的目标角度上而是直接加到了 PWM。
解决:先把速度环输出打到串口上看,用手转轮子,数值应该正负变化。是 0 就查编码器接线和定时器配置;有数值但推车不回来,就把speed_out前面加个负号试一次,一次就能判断符号对不对。
5.4 PWM 给了,轮子就是不转,但驱动板有电
现象:示波器或者串口上能看到 PWM 占空比在变,电机纹丝不动,用手拨一下轮子能自由转,说明没堵转。
原因:TB6612 的 STBY 引脚悬空或者被拉低。这个引脚是芯片的使能端,低电平时内部 H 桥全部关断,PWM 进去也没用。另一种可能是方向控制的两个 IO 同时给了高电平,那是刹车状态。
解决:万用表量一下 STBY 是不是高电平,不是就接到 3.3V 或者用一个 IO 拉高。方向 IO 保证同一时刻一个高一个低,切换方向时最好留一两个毫秒的死区,避免上下桥直通。
5.5 车一动就重启,OLED 黑一下又亮
现象:静止时一切正常,电机一转主控就复位,串口打印从头开始,参数全丢。
原因:电源被电机拉垮了。电机启动瞬间电流能到好几安,电池内阻或者细导线上的压降让主控电压跌到复位阈值以下。也可能是电机和主控共地但走线太长,地弹把复位脚拉低。
解决:电机电源和主控电源分开走线,在主控供电入口并一个 470uF 以上的电解电容加 0.1uF 瓷片。电池到驱动板的线至少用 20AWG。如果还不行,给主控单独上一路稳压模块,别跟电机抢。
6. 二次开发:把平衡车从"能站"改成"能干活"的平台
调通之后,这套工程真正的价值才开始显现——它是个很干净的验证平台。我一般会按三条线往下改。
第一条是加感知。车头装两到三路灰度或红外循迹模块,接到任意 GPIO,在 5ms 中断里读状态,输出一个左右差速量叠加到turn_out上:
/* track.c —— 三路循迹,直接叠加到转向量上 */ float Track_GetTurn(void) { uint8_t l = GPIO_Read(L), m = GPIO_Read(M), r = GPIO_Read(R); if (m && !l && !r) return 0.0f; /* 正中,不修 */ if (l && !m && !r) return -TURN_STEP; /* 偏右,往左打 */ if (r && !m && !l) return TURN_STEP; /* 偏左,往右打 */ return 0.0f; /* 丢线,交给速度环自己找 */ }TURN_STEP从 200 开始试,这个量太大车会在线上左右画龙,太小拐不过弯。丢线时返回 0 是一种保守策略,也可以改成记住上一次的方向继续找。
第二条是改通信。把串口协议从"人看的文本"改成"机器读的帧",加帧头、长度、校验,这样上位机就能稳定地在线改参数而不需要重新烧录:
| 字段 | 长度 | 说明 |
|---|---|---|
| 帧头 | 2 字节 | 固定0xAA 0x55 |
| 命令字 | 1 字节 | 0x01 读参数 / 0x02 写参数 / 0x03 波形 |
| 数据长度 | 1 字节 | 后续数据字节数 |
| 数据 | N 字节 | float 按小端打包 |
| 校验 | 1 字节 | 前面所有字节异或 |
第三条是换执行器。有刷电机加减速箱的响应带宽有限,想跑更快就得换无刷加 FOC 驱动,但那样整个电流环都要重写,Motor_Set那层抽象得先抽干净。改之前先把Hardware/motor.c里的接口收敛成Motor_Set(left, right)一个函数,后面换驱动只改这一个文件,上层控制逻辑一行不动。
验证改造有没有破坏原有平衡,我的做法是保留一个"回归测试":车上电后先空跑 10 秒只做直立,用串口记录角度方差,改完代码再跑一次对比,方差明显变大就说明动到了不该动的地方。
最后说个教训。早期我改参数从来不留档,调到一个能用的状态就往下加功能,结果某次改完循迹之后车怎么都站不稳,翻了半天代码也找不出问题,最后发现是顺手把MECH_ZERO改了一个小数。从那以后我每次动代码前都先把整个App/目录复制一份带日期后缀,参数改动全写在一个tuning_log.md里,包括改之前的现象和改之后的曲线截图。这套习惯救过我至少三次。希望帮到你。
本文还有配套的精品资源,点击获取