简介:这份资源是面向STM32初学者与嵌入式控制入门者的自平衡小车完整工程包,聚焦纯平衡控制这一核心目标,可用于学习姿态解算与闭环控制的实际落地。压缩包共234个文件、约8.57MB,以41个c源文件与47个h头文件为主体,涵盖STM32外设驱动、MPU6050数据读取与DMP解算、定时器与串口配置;配套的uvprojx、uvoptx工程文件与axf、hex、map等编译产物可直接用Keil打开烧录验证,另附sct分散加载文件与批处理脚本便于清理与构建。已有2215人学习下载,说明该工程在同类入门项目中具有一定参考基础。读者可从中获取传感器数据解析、PID参数整定、电机驱动与调试排错的完整思路,并借助现成工程结构快速搭建实验环境,对照源码理解比例、积分、微分环节对平衡效果的实际影响,适合作为课程设计或自学练手的参考工程。
1. 纯平衡小车到底在跑什么:先看懂这条 5ms 控制链路
平衡小车程序(纯平衡)这类压缩包,真正值钱的地方不是那几个 PID 系数,而是它把 STM32 上一整套控制链路切得很干净:姿态采样、角度融合、串级 PID、电机输出,全部挂在同一个定时中断里,周期固定。很多新手拿到手先改 kp,车一倒就开始怀疑代码,其实问题往往出在链路没跑通,比如角度方向反了、编码器读数没清零、控制周期抖动。纯平衡版本的好处是它砍掉了循迹、避障、遥控这些干扰项,只剩“站住”这一件事,对想搞懂嵌入式闭环控制的人来说,是块很干净的地板。这份代码适合手上已经有 F103 开发板、电机驱动和一块六轴模块,想从零把车调站起来的人;也适合已经能跑 Demo、但说不清串级 PID 到底谁套谁的人。
2. 工程骨架拆解:目录、外设与中断的时间片怎么分
拿到源码包先别急着打开 main.c,先看目录。这类工程一般由 CubeMX 生成 HAL 库骨架,后面的业务代码被拆进几个独立模块,彼此只通过头文件里的函数原型打交道。把目录读一遍,你大概就知道作者把复杂度放在了哪。
2.1 目录结构与模块职责
典型的目录长这样,不同人做法略有出入,但分层思路基本一致:
BalanceCar/ ├── Core/ │ ├── Inc/ │ │ ├── main.h │ │ ├── pid.h # PID 结构体与通用计算 │ │ ├── attitude.h # 姿态:MPU6050 读写 + 互补滤波 │ │ ├── motor.h # 电机 PWM 与方向控制 │ │ ├── encoder.h # 编码器测速 │ │ ├── oled.h # 本地显示(可选) │ │ └── debug.h # 串口调试输出 │ └── Src/ │ ├── main.c # 初始化 + 主循环,不放控制逻辑 │ ├── pid.c │ ├── attitude.c │ ├── motor.c │ ├── encoder.c │ ├── oled.c │ ├── debug.c │ └── stm32f1xx_it.c # 中断入口,只做转发 ├── Drivers/ # HAL 库 + CMSIS └── MDK-ARM/ # Keil 工程文件如果包里的 Drivers 目录下出现的是STM32F10x_FWLib,说明这是标准库版本,模块划分不变,只是初始化 API 从HAL_xxx_Init换成了xxx_Init。两种版本的业务代码几乎可以直接对拷,真正要改的只有外设初始化和中断回调的写法。
main.c 在这套结构里应该很薄:时钟、GPIO、定时器、串口、I2C 初始化完,启动定时中断,然后进 while(1) 做慢任务(显示刷新、串口打印、按键扫描)。所有跟“车会不会倒”相关的计算,一律不放在主循环。这个边界一旦破了,后面调参基本没法调——主循环里加一句 printf,控制周期就从 5ms 变成 6ms,PID 参数全部要重来。我带过的新手最常见的翻车点,就是在主循环里塞了一堆东西,然后奇怪为什么加个屏幕车就抖。
2.2 外设分配:谁在什么时候干活
一张表把外设职责列清楚,后面查问题会快很多。引脚按工程实际为准,下面是一组常见映射:
| 功能 | 外设 | 常见引脚 | 配置要点 |
|---|---|---|---|
| 姿态传感器 | I2C1 | PB6 / PB7 | 400kHz,上拉 4.7k |
| 左电机 PWM | TIM1_CH1 | PA8 | 10~20kHz,避免啸叫 |
| 右电机 PWM | TIM1_CH2 | PA9 | 同上 |
| 电机方向 | GPIO | PB12~PB15 | 两个一组控制正反转 |
| 左编码器 | TIM3 | PA6 / PA7 | 编码器模式,四倍频 |
| 右编码器 | TIM4 | PB6 / PB7 | 若与 I2C 冲突就换脚或改软件 I2C |
| 控制周期 | TIM6 | 无引脚 | 5ms 更新中断 |
| 调试串口 | USART1 | PA9 / PA10 | 115200,注意别和 PWM 抢脚 |
PWM 频率这一项经常被忽略。用 1kHz 驱动电机,人耳能听到明显啸叫,电流纹波也大;拉到 15~20kHz 就听不见了,但驱动芯片的开关损耗会上去,TB6612 这类模块一般扛得住。频率改完记得同步改预分频和自动重装值,否则占空比分辨率会掉到没法做细调。
2.3 控制周期:那条 5ms 中断
整套代码的心脏是这个回调,所有实时计算都在里面:
/* TIM6 更新中断,5ms 触发一次,对应 200Hz 控制频率 */ void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { static uint8_t slow_tick = 0; if (htim->Instance != TIM6) return; /* 1. 采姿态:滤波系数与 dt 强相关,dt 变了这里必须跟着改 */ attitude_update(&g_angle, CTRL_DT); /* 2. 读编码器增量,读到就清零,供下个周期重新累计 */ g_enc_l = encoder_read_delta(&htim3); g_enc_r = encoder_read_delta(&htim4); /* 3. 串级 PID:先算速度环,再算直立环和转向环 */ balance_control(g_angle.pitch, g_angle.gyro_y, g_enc_l + g_enc_r, CTRL_DT); /* 4. 输出到电机 */ motor_set(g_pwm_l, g_pwm_r); /* 5. 慢任务分频:10ms 打印一次,绝不在 5ms 里做阻塞 IO */ if (++slow_tick >= 2) { slow_tick = 0; debug_output(); } }CTRL_DT就是 0.005f,定义成宏而不是到处写 0.005,是为了以后想改周期时只改一处。中断里只做计算和寄存器写入,不做任何等待,printf 靠分频挪到 10ms 一次,而且用的串口发送要配 DMA 或至少是非阻塞的写法——半双工阻塞发送一次 40 字节在 115200 下大约 3.5ms,5ms 周期里塞这个,控制就废了。
编码器读数这里有个小技巧值得单独说,因为很多人第一次写会踩:
/* 读增量并清零:5ms 内计数不会超过 int16 范围,直接强转即可 */ static int16_t encoder_read_delta(TIM_HandleTypeDef *htim) { int16_t cnt = (int16_t)__HAL_TIM_GET_COUNTER(htim); __HAL_TIM_SET_COUNTER(htim, 0); return cnt; }计数器是 16 位的,转成有符号数就能同时表达正反转。清零的好处是每个周期拿到的是纯增量,不用自己维护上一次的值做减法,也不会因为累计溢出算错。前提是周期足够短,5ms 内电机转速对应的脉冲数远小于 32767。如果控制周期拉到 50ms,高速时就会溢出翻负,表现为速度环突然反向修正,车猛地一顿——这种偶发问题最难查。
3. 姿态解算链路:MPU6050 读数、互补滤波和开机零漂校准
车能不能站住,七成看角度准不准。角度不准,PID 参数调到天上去也是白搭。这条链路分三段:把原始寄存器读成物理量、把加速度和陀螺仪融合成一个角度、开机时把零漂扣掉。
3.1 初始化与原始数据读取
初始化要关注三个寄存器:电源管理、采样率分频、低通滤波。采样率和滤波带宽必须跟 200Hz 的控制周期匹配,配太高会把噪声全引进来,配太低相位滞后严重,直立环会明显发软。
#define MPU_ADDR 0xD0 #define REG_SMPLRT_DIV 0x19 #define REG_CONFIG 0x1A #define REG_GYRO_CFG 0x1B #define REG_ACCEL_CFG 0x1C #define REG_ACCEL_XOUT 0x3B #define REG_PWR_MGMT_1 0x6B void mpu_init(void) { mpu_write(REG_PWR_MGMT_1, 0x00); /* 解除休眠 */ HAL_Delay(50); /* 上电稳定,别省这一步 */ mpu_write(REG_SMPLRT_DIV, 0x04); /* 1kHz / (1+4) = 200Hz 输出率 */ mpu_write(REG_CONFIG, 0x03); /* DLPF 约 42Hz,压住电机振动噪声 */ mpu_write(REG_GYRO_CFG, 0x08); /* 陀螺 ±500 dps,灵敏度 65.5 LSB/dps */ mpu_write(REG_ACCEL_CFG, 0x00); /* 加速度 ±2g,灵敏度 16384 LSB/g */ }量程选择是个权衡。陀螺选 ±500dps 而不是 ±2000dps,是因为小车倒下瞬间角速度也就两三百度每秒,量程小一位,分辨率就高一位,静态噪声明显小。但如果你的车特别高、倒得特别快,±500 会削顶,表现为倒到某个角度后数据不动了。
读原始值一次连续读 14 个字节,比逐个寄存器读快得多,也避免两次读取之间数据不同步:
typedef struct { int16_t ax, ay, az; int16_t gx, gy, gz; } mpu_raw_t; void mpu_read_raw(mpu_raw_t *r) { uint8_t buf[14]; mpu_read(REG_ACCEL_XOUT, buf, 14); /* 一次读满,保证同一采样点 */ r->ax = (int16_t)((buf[0] << 8) | buf[1]); r->ay = (int16_t)((buf[2] << 8) | buf[3]); r->az = (int16_t)((buf[4] << 8) | buf[5]); r->gx = (int16_t)((buf[8] << 8) | buf[9]); /* 6、7 是温度,跳过 */ r->gy = (int16_t)((buf[10] << 8) | buf[11]); r->gz = (int16_t)((buf[12] << 8) | buf[13]); }换成物理量时除以对应灵敏度就行,浮点运算在 F103 上是软件模拟的,能少算就少算。常见做法是加速度只在算角度时用一次,陀螺仪在滤波里用一次,别在每个环节反复换算。
3.2 互补滤波:为什么小车很少上卡尔曼
加速度计算角度用反正切,长期稳定但噪声大;陀螺仪积分短期准但会漂。互补滤波干的事就是给两者分配权重,高通给陀螺、低通给加速度,一行代码搞定:
#define ALPHA 0.98f #define RAD2DEG 57.29578f void attitude_update(attitude_t *a, float dt) { mpu_raw_t raw; mpu_read_raw(&raw); float ax = raw.ax / 16384.0f; float ay = raw.ay / 16384.0f; float az = raw.az / 16384.0f; float gyro_y = (raw.gy - a->gyro_bias_y) / 65.5f; /* 扣零漂后转 dps */ /* 用 atan2 而不是 atan(ax/az):不会除零,也不用管象限 */ float acc_pitch = atan2f(ay, az) * RAD2DEG; /* 核心一行:陀螺积分占 98%,加速度校正占 2% */ a->pitch = ALPHA * (a->pitch + gyro_y * dt) + (1.0f - ALPHA) * acc_pitch; a->gyro_y = gyro_y; }ALPHA是这套代码里最玄学的一个参数。取 0.98,时间常数大约 0.25 秒,跟得上车的动作又能压住振动;取 0.999,角度很平滑但会明显滞后,直立环表现为“反应慢半拍”,车总是往倒的方向多走一步才修正;取 0.9,加速度噪声直接灌进来,电机发出沙沙声。我一般从 0.98 起调,如果车在原地高频小幅抖动,先往 0.99 调一点看看,如果反而更晃,说明是机械共振不是滤波问题。
至于卡尔曼,网上教程一片一片,但在这类小车上收益有限。F103 没有硬件浮点,五阶矩阵运算一次几百微秒,200Hz 下 CPU 占用不低;而且卡尔曼的过程噪声、观测噪声两个协方差矩阵,实际调起来比 ALPHA 一个参数难得多。除非你的车要上坡、要扛冲击,否则互补滤波够用。这是个典型的“教程最优”和“工程够用”之间的差别。
3.3 开机校准:把零漂扣干净
陀螺仪静止时输出不为零,这是物理特性,不是坏块。零漂不扣,积分出来的角度会匀速漂移,车会一直以为自己在往一个方向倒,于是持续给反向修正,表现为上电后慢慢往一边跑。
/* 开机静置 1 秒,采样 500 次求均值作为零漂 */ void mpu_calibrate(attitude_t *a) { int32_t sx = 0, sy = 0, sz = 0; mpu_raw_t raw; for (uint16_t i = 0; i < 500; i++) { mpu_read_raw(&raw); sx += raw.gx; sy += raw.gy; sz += raw.gz; HAL_Delay(2); } a->gyro_bias_x = (float)sx / 500.0f; a->gyro_bias_y = (float)sy / 500.0f; a->gyro_bias_z = (float)sz / 500.0f; }调用这个函数期间小车必须完全静止放在桌上,电机不能通电。校准完再启动控制中断,顺序反了会把电机振动一起算进零漂里,那这个校准就白做了。另外零漂随温度变化,冷机开机和跑十分钟后能差出零点几度每秒,长跑场景下有人会做慢速跟踪补偿:车静止判断成立时,缓慢把零漂往当前陀螺读数上拉,用时间换精度。这套纯平衡代码里不一定带,属于可以自己加的改造点。
4. 串级 PID 实现:直立环、速度环、转向环的代码与参数
这是整个项目最核心的一段。很多人卡在“三个环怎么套”,其实理顺一句话就通了:直立环负责让车不倒,速度环不直接驱动电机,而是去改直立环的目标角度,转向环最后叠加到左右轮差速上。
4.1 通用 PID 结构体与计算函数
先把三个环共用的部分抽出来,避免复制三份几乎一样的代码:
typedef struct { float kp, ki, kd; float target; float integral; /* 积分累计项 */ float last_err; float out_max; /* 输出限幅 */ float integral_max; /* 积分限幅,防止饱和 */ } pid_t; float pid_calc(pid_t *p, float measure, float dt) { float err = p->target - measure; p->integral += err * dt; 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->last_err) / dt; p->last_err = err; if (out > p->out_max) out = p->out_max; if (out < -p->out_max) out = -p->out_max; return out; }积分限幅和输出限幅这两行看着不起眼,实际上是血泪经验。速度环积分不限,车被手挡住两秒,积分项能攒到几千,一松手车直接蹿出去,这就是典型的积分饱和。integral_max一般取out_max / ki的量级再留点余量,具体数值配合实际调参定。
4.2 直立环:PD 控制与机械中值
直立环只用 PD,不用 I。因为它要的是快速响应,积分项在这里只会拖慢节奏、引入过冲。
float balance_pid(float angle, float gyro_y) { /* 目标是机械中值,不是 0 度。车重心偏前偏后都要靠这个补偿 */ float err = angle - g_mech_zero; /* P 拉回角度,D 用陀螺仪而不是对角度差分 */ float out = g_balance.kp * err - g_balance.kd * gyro_y; return out; }D 项直接拿陀螺仪读数,这一点值得展开。如果用(err - last_err) / dt对角度做数值微分,角度本身的噪声会被放大十几倍,电机听着像在打嗝。陀螺仪输出的本来就是角速度,物理意义上正好对应微分项,还自带低通,噪声小得多。代价是符号要自己确认:车往前倾时角度增大、陀螺读数方向可能相反,第一次调的时候把 D 的符号搞反,车会瞬间变成正反馈,一上电就全速冲出去,非常危险。
g_mech_zero是机械中值,也叫平衡点。它不是 0 度,因为电池、驱动板、传感器的重量分布决定了重心不在几何中心。判断方法:手扶车让它静止在刚好不往任何方向倒的姿态,读此时的角度值,写进这个变量。这个值调不准,车会一直有个恒定的前后偏移,速度环再怎么调也只能把它压成缓慢蠕动。
4.3 速度环:PI 串进直立环的中值
速度环的输出不去驱动电机,而是叠加到机械中值上。这是串级的精髓所在:
void balance_control(float angle, float gyro_y, int32_t enc_sum, float dt) { /* 编码器原始计数转换成 cm/s,系数靠实测标定 */ float speed = enc_sum * ENC_TO_SPEED; /* 一阶低通,滤掉编码器量化噪声,系数 0.3 左右 */ static float speed_f = 0; speed_f = speed_f * 0.7f + speed * 0.3f; /* 第一级:速度环 PI,目标是零速 */ g_speed.target = 0.0f; float speed_out = pid_calc(&g_speed, speed_f, dt); /* 速度环输出改变直立环的目标角度,而不是直接给 PWM */ float target_angle = g_mech_zero + speed_out; /* 第二级:直立环 PD */ float err = angle - target_angle; float balance_out = g_balance.kp * err - g_balance.kd * gyro_y; /* 转向环单独算,纯平衡版本一般只给 0,留接口 */ float turn_out = turn_pid(); /* 合成到左右轮,转向是差速叠加 */ int16_t pwm_l = (int16_t)(balance_out + turn_out); int16_t pwm_r = (int16_t)(balance_out - turn_out); g_pwm_l = pwm_limit(pwm_l); g_pwm_r = pwm_limit(pwm_r); }为什么不能速度环直接给 PWM?因为直立车是欠驱动系统,唯一的控制量是轮子加速度,而轮子加速度同时影响倾角和位移。速度环直接输出会跟直立环抢控制权,两个环互相打架,表现为车在原地前后低频摆动,怎么调参数都压不住。串起来之后,速度环只管“我要往哪边倾一点才能回到原点”,直立环只管“怎么快速倾到那个角度”,职责清晰,参数也好调。
速度环一般只给 PI,不给 D。编码器信号本来就有量化噪声,再微分会炸。P 决定回到原点的力度,I 消除稳态误差——只有 P 的话,车会停在一个偏离原点的位置不动,因为速度为零时 P 项输出为零,没有力量把它拉回去。
4.4 输出限幅与死区
最后一道关卡是限幅和死区,这两个参数直接决定车是“稳”还是“抖”:
static int16_t pwm_limit(int16_t v) { /* 死区:占空比太小电机转不动,反而发热,直接归零 */ if (v > 0 && v < MOTOR_DEADZONE) return 0; if (v < 0 && v > -MOTOR_DEADZONE) return 0; if (v > PWM_MAX) return PWM_MAX; if (v < -PWM_MAX) return -PWM_MAX; return v; }MOTOR_DEADZONE是电机启动的最小占空比,减速电机带齿轮箱,一般要 5%~10% 才转得动。不加死区,PID 在零点附近输出的几十个 LSB 占空比推不动车,只在电机线圈里发热,还会让积分项持续累积。加了死区,零点附近变成开关式的输出,反而更干净。
PWM_MAX也别设成满量程。留 20% 余量,一是防止瞬间大电流拉垮电池电压导致单片机复位,二是给转向差速留空间——如果balance_out已经顶到最大值,再加减turn_out就会削顶,转向直接失效。
5. 避坑排查:上电就倒、高频抖动、慢漂的 5 条现场经验
代码看懂了不等于车能站起来。下面这几条是调车过程中几乎必然会撞上的,按“现象、原因、解决”的顺序排好,对号入座能省不少时间。
5.1 现象:一上电就朝一个方向全速冲,扶都扶不住
原因基本只有一个——某个环节的符号反了。可能是电机相线接反、编码器 A/B 相接反、陀螺仪方向与安装方向不一致,也可能是直立环 D 项符号写反。这几个错误的表现都是正反馈:车越倒,输出越往倒的方向推。
解决方法是上电前逐项验证,不要靠猜。第一步架空车轮,用手把车头往下压,看轮子是不是往车头方向转(追着倒的方向跑才对);第二步转动车轮,看串口打印的速度符号跟实际方向是否一致;第三步把车倾斜一个固定角度,看打印的角度符号。三项全部对上再落地。从那以后我每次重新接线都会走一遍这个流程,比事后排查快十倍。
5.2 现象:车能站住但原地高频抖动,电机发出“嗡嗡”或“哒哒”声
这个要分两种。嗡嗡声一般是滤波或 D 参数问题,哒哒声更像机械间隙或者控制周期不稳。常见诱因有三个:D 参数给太大,把传感器噪声放大成了抖动;ALPHA 取值偏小,加速度噪声漏进角度里;MPU6050 没有做减振,电机振动直接传上来。
处理顺序是先减振再调参。在传感器和车架之间垫一层厚双面胶或者减振棉,效果立竿见影,很多“调不出来的抖动”其实换个安装方式就没了。然后再把 kd 往小调 20%,看抖动频率有没有下降。如果调小 kd 后车变得软塌塌站不稳,说明抖动不是 D 引起的,回头查 ALPHA 和 PWM 频率。
5.3 现象:车静止放着,角度值以每分钟几度的速度缓慢漂移
零漂没扣干净,或者校准的时候车没放稳。还有一种容易被忽略:校准期间电机驱动虽然没输出,但使能脚是开的,线圈有微弱电流,磁场干扰传感器。
解决方法是把校准函数挪到电机初始化之前调用,并且在校准前加 300~500ms 的延时等芯片内部稳定。校准完把零漂值通过串口打出来,正常情况下三轴都应该在几十 LSB 以内,如果某一轴到了几百,说明校准环境有问题(桌子在晃、旁边有音箱或磁铁)。跑久了才漂的属于温漂,可以加一个静止检测:连续若干周期角度变化小于阈值且编码器为零,就慢速更新零漂,时间常数给到几十秒,不影响动态响应。
5.4 现象:车能站,但总是匀速往一个方向慢慢跑,越跑越快
先看机械中值。手扶车找到真正静止平衡的角度,跟代码里的g_mech_zero对一下,差个一两度很正常,差五度以上就要重新标定。标定不准,速度环会持续输出一个偏置去补偿,补偿量一大就容易触发积分饱和。
如果中值没问题,检查速度环的积分限幅是不是设得太大,或者编码器方向接反导致速度环正反馈——这个隐蔽性很强,车会表现为慢慢加速然后突然失控。用串口把速度值打出来,手推车往前,看数值是不是跟预期方向一致,一眼就能看出来。
5.5 现象:一开串口打印,车立刻开始抖
控制周期被破坏了。115200 波特率下发 40 字节大约 3.5ms,如果放在 5ms 中断里做阻塞发送,实际周期就变成 8ms 以上,而且抖动不定。PID 对周期抖动极其敏感,dt在公式里是除数,周期一变,等效的 D 项增益就跟着变。
解决办法有三层:把打印挪出中断,用环形缓冲区加 DMA 发送;降低打印频率到 50ms 甚至 100ms 一次;只打关键变量,别一次打十几个浮点。我一般调试期只打角度、速度、PWM 三个量,够看趋势了。真要精细分析,把数据按二进制帧发出去,上位机解析,比文本协议省一半带宽。
6. 二次开发:调参顺序、串口协议与验证手法
这套纯平衡代码的扩展性其实很好,因为它把控制律和硬件访问分开了。想加功能,优先在balance_control外面套,而不是改里面的公式。我自己改这类工程有个固定顺序:先保证原始版本能站住,再加新东西,每加一样就回归测试一次。
调参顺序千万别乱。正确顺序是:机械中值 → 直立环 P → 直立环 D → 速度环 P → 速度环 I → 转向环。跳过中值直接调 P,你会发现无论怎么调车都有个固定偏移;先调速度环再调直立环,车根本站不住,没有调参的物理基础。
| 参数 | 调大后的表现 | 调小后的表现 | 建议步长 |
|---|---|---|---|
| 直立环 kp | 回正快,过大则低频摆动 | 站不住,软塌塌 | 每次 10% |
| 直立环 kd | 抑制摆动,过大则高频抖动 | 有过冲,来回晃 | 每次 15% |
| 速度环 kp | 回原点快,过大则前后振荡 | 漂移纠正慢 | 每次 20% |
| 速度环 ki | 消除稳态误差,过大则蹿车 | 停不到原点 | 每次 20% |
| ALPHA | 更平滑但滞后 | 噪声大、抖 | 0.005 一档 |
加串口调参是最值得先做的一件事。不用改代码重新烧录,调参效率完全不同:
/* 帧格式:0xAA 0x55 | 参数ID | float 四字节 | 校验和 */ void param_parse(uint8_t *buf, uint8_t len) { if (len != 8 || buf[0] != 0xAA || buf[1] != 0x55) return; uint8_t sum = 0; for (uint8_t i = 0; i < 7; i++) sum += buf[i]; if (sum != buf[7]) return; /* 校验不过直接丢,别信坏帧 */ float val; memcpy(&val, &buf[3], 4); /* 用 memcpy 避免对齐问题 */ switch (buf[2]) { case 1: g_balance.kp = val; break; case 2: g_balance.kd = val; break; case 3: g_speed.kp = val; break; case 4: g_mech_zero = val; break; default: break; } }再加一条命令让参数能存进 Flash,下次上电自动加载,就不用来回调。注意写 Flash 会阻塞几十毫秒,一定要在车停稳、电机断使能的状态下执行,运行中写 Flash 会导致控制中断丢失,车直接趴下。
验证改动是否有效,别只看“感觉稳了”。我的做法是给一个固定扰动:用手在同一位置、同一力度推车头,看它回到平衡位置需要几次摆动。好的参数一般一到两次就收住,三次以上说明阻尼不够,零次但回得很慢说明 P 偏小。把这个过程录下来对比,比凭记忆靠谱得多。
最后说个习惯。从那以后我每次改完参数、重新烧录之前,都会先把车轮架空再上电,确认三个方向的动作都符合预期才落地。这个动作看着多余,但它拦下过好几次符号写反导致的全速冲车。希望帮到你。
本文还有配套的精品资源,点击获取