STM32+PS2手柄四驱遥控底盘:GPIO时序与差速解算
简介:面向嵌入式初学者、课程设计学生与机电控制实践者的PS2手柄四电机全向轮小车项目源码包,基于STM32微控制器实现,重点解决游戏手柄输入解析、PWM调速以及四轮全向底盘协同驱动等问题。压缩包共233个文件、约8.28MB,包含C源文件与H头文件、O和CRF编译中间文件,以及HEX、AXF可执行产物和工程配置,能够呈现从程序源码、编译链接到烧录验证的完整组织。已有747人学习。资源中既有底层外设驱动,也有用户应用层代码,便于定位手柄解析、电机控制和系统初始化之间的关系;读者可据此理解PS2通信时序、按键与摇杆数据处理、电机正反转及占空比调节、全向轮平移与旋转运动解算,并参考BSP、USER分层和构建脚本安排自己的项目结构。内容适合嵌入式入门、课程设计、竞赛调试及小车平台二次开发时对照学习。
1. 一个手柄、一块 STM32、四个轮子:这套遥控底盘能干什么
桌上放着一台四个直流电机的小车,一个 PS2 手柄,一块 STM32 最小系统板,没有手机 App、没有配网、没有上位机,上电拨一下摇杆,车就走了。这就是整个项目的全部形态:用手柄的左摇杆做前后和转向,右摇杆做原地旋转或者云台,L1/L2 做速度档位。它适合两类人——刚学完 GPIO 和定时器、想找一个“能动起来”的完整项目练手的;以及手上已经有底盘、想找一个稳定遥控方案做二次开发底座的。代码量不大,但串行时序、PWM、差速解算、电源干扰这几块能一次性踩全,含金量比点灯高得多。
2. 硬件链路拆解:从 9 针接收器到 STM32 引脚的完整对接
这套方案的链路很短:手柄(2.4G)→ 接收器(9 针排针)→ STM32(GPIO 模拟时序)→ 驱动芯片 → 四个电机。链路越短,出问题时定位越快,但也意味着任何一环接错都直接表现为“车不动”,所以先把硬件定死。
2.1 为什么是 PS2 手柄加 STM32 这套组合
做遥控底盘常见的三条路:手机蓝牙、WiFi + 路由器、专用遥控器。蓝牙要跑协议栈,手机端还得写个 App,调试的时候一手拿手机一手拿车,很别扭;WiFi 依赖路由,场地一变 IP 就变,血泪经验是户外根本连不上。PS2 接收器走的是 2.4G 私有协议,接收器直接输出数字时序,STM32 只要按位把 9 个字节读上来就行,不需要协议栈、不需要配对、不需要知道射频细节,上电就能用。
代价是这套协议本身很“裸”:没有 CRC、没有重传、没有断连通知。接收器被遮挡或者电池电压掉下去,STM32 收到的就是一串无效数据或者上一次的旧数据,它不会告诉你“我掉线了”。所以软件层面必须自己补一个超时机制,这个后面会讲。整个协议占用的 MCU 资源非常小:两根推挽输出(CMD、CLOCK)、一根推挽输出(ATT 片选)、一根上拉输入(DATA),再加一个 10ms 的轮询任务。72MHz 的 F103 跑四路 PWM 加 100Hz 手柄轮询,CPU 占用连一成都不到,这就是它至今还在被大量使用的原因——够用、便宜、不挑板子。
2.2 接收器 9 针怎么落到 STM32 引脚上
接收器是标准 2.54 排针,9 个引脚,正反面容易看反,接线前先用万用表确认一遍。下面是这套项目里我用得最顺手的一组映射,避开了 JTAG 的 PA13/PA14/PA15 和串口,方便后期挂调试口。
| 接收器引脚 | 信号名 | 方向 | 接到 STM32 | 说明 |
|---|---|---|---|---|
| 1 | DATA | 输入 | PA6 | 上拉输入,手柄发给 MCU 的数据 |
| 2 | CMD | 输出 | PA5 | 推挽输出,MCU 发给手柄的命令 |
| 3 | 7.5V | 电源 | 电池正极 | 只给震动马达供电,不接也能用 |
| 4 | GND | 地 | GND | 必须和主控共地 |
| 5 | 3.3V | 电源 | 3.3V | 逻辑供电,别接到 5V |
| 6 | ATT | 输出 | PA4 | 片选,低电平有效,一帧的开始和结束 |
| 7 | CLOCK | 输出 | PA7 | 推挽输出,时钟由 MCU 产生 |
| 8 | — | — | 悬空 | 部分接收器内部保留 |
| 9 | ACK | 输入 | 悬空或 PA3 | 震动反馈用,普通遥控可以不接 |
供电这块有个容易被忽略的点:接收器有两种供电,3.3V 是逻辑电平,7.5V 是震动马达。只接 3.3V 时手柄能正常通信,但震动功能基本没劲;接了 7.5V 之后 DATA/CMD 仍然是 3.3V 电平,可以直连 MCU,不用电平转换。如果不确定手上模块的内部结构,中间串一个 1k 电阻最稳妥,即使电平不匹配也不会烧 IO。
2.3 软件分层:别把时序和业务写在一个文件里
新手最容易把所有东西塞进 main.c,结果调电机的时候手柄读不出来,查半天不知道是谁阻塞了谁。这套项目我按四层拆,每层只干一件事,出问题时能直接定位到层。
Project/ ├── Core/ │ ├── Src/ │ │ ├── main.c # 初始化 + 主循环调度 │ │ ├── tim.c # TIM1 四路 PWM、TIM3 1ms 时基 │ │ └── gpio.c │ └── Inc/ ├── BSP/ │ ├── bsp_ps2.c # 位操作时序,只负责收发 9 字节 │ ├── bsp_ps2.h │ ├── bsp_motor.c # 方向 GPIO + CCR 写入 │ └── bsp_motor.h └── APP/ ├── app_remote.c # 报文字节 → 摇杆值 + 按键位域 ├── app_chassis.c # 差速解算 → 四路目标速度 └── app_config.h # 死区、限幅、最大速度等参数分层的边界很清楚:bsp_ps2 完全不知道“摇杆”是什么,它只知道“收 9 个字节”;app_remote 不知道电机存在,它只输出 -1000 到 1000 的摇杆量;app_chassis 不知道 PWM 寄存器在哪,它只输出四个目标速度。这样换驱动芯片只改 bsp_motor,换控制方式只改 app_chassis,上层业务一行都不用动。
调度上,1ms 的定时器中断里跑电机斜坡和超时计数,主循环里每 10ms 发一次手柄请求(100Hz)。注意这里千万别在主循环里加 HAL_Delay,一旦哪次延时超过 50ms,手柄这边就会丢包,表现出来就是“偶尔一顿一顿的”,这是最常见的翻车点之一。
3. PS2 协议驱动:9 字节报文怎么一位一位收上来
协议本身不复杂,但时序敏感,用硬件 SPI 反而不好控制(因为 ATT 的时序和 ACK 的处理硬件 SPI 处理不了),所以老老实实拿 GPIO 位操作是最稳的做法。
3.1 一次完整的 poll 长什么样
每次通信固定 9 个字节,MCU 先发命令,同时把接收器返回的字节读回来。最常用的三条命令是:轮询(读按键和摇杆)、进入配置模式、设置模拟模式。命令字是固定死的,抄下来就行:
| 用途 | 9 字节命令 | 说明 |
|---|---|---|
| 轮询 | 0x01 0x42 0x00 0x00 0x00 0x00 0x00 0x00 0x00 | 回复第 2 字节为 0x41 表示手柄在线 |
| 进入配置 | 0x01 0x43 0x00 0x01 0x00 0x00 0x00 0x00 0x00 | 必须在设置模式前发 |
| 设置模拟模式 | 0x01 0x44 0x00 0x01 0x03 0x00 0x00 0x00 0x00 | 开启双摇杆模拟量 |
| 开启震动 | 0x01 0x4D 0x00 0x00 0x01 0xFF 0xFF 0xFF 0xFF | 可选的马达映射 |
| 退出配置 | 0x01 0x43 0x00 0x00 0x5A 0x5A 0x5A 0x5A 0x5A | 固定 0x5A 结尾 |
回复报文的字节含义也固定:第 3 字节是按键低字节(Select、L3、R3、Start、上、右、下、左),第 4 字节是按键高字节(L2、R2、L1、R1、三角、圆圈、叉、方块),第 5 到第 8 字节依次是右摇杆 X、右摇杆 Y、左摇杆 X、左摇杆 Y,取值范围 0 到 255,中值约 128。
注意一个细节:只有成功进入模拟模式之后,第 5 到第 8 字节才有意义。如果初始化序列漏了“进入配置”直接发“设置模拟模式”,多数接收器会静默忽略,摇杆值一直是 0x00 或者 0xFF,表现出来的现象就是“按键能用但摇杆不动”,很多人会误以为是手柄坏了。
3.2 位操作收发函数逐段拆解
核心就三个宏加一个字节收发函数,理解了这一段,整个驱动就没有黑匣子了。
/* bsp_ps2.c —— 引脚操作,按实际接线改宏即可 */ #define PS2_ATT_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_RESET) #define PS2_ATT_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_4, GPIO_PIN_SET) #define PS2_CLK_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_RESET) #define PS2_CLK_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_7, GPIO_PIN_SET) #define PS2_CMD_L() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_RESET) #define PS2_CMD_H() HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET) #define PS2_DAT_RD() HAL_GPIO_ReadPin(GPIOA, GPIO_PIN_6) /* 收发一个字节:低位先出,下降沿后采样 * 协议是 LSB First,和标准 SPI 的 MSB First 正好相反, * 这一点写错就会得到完全对不上的数据,且不会报错 */ static uint8_t ps2_xfer_byte(uint8_t out) { uint8_t in = 0; for (uint8_t i = 0; i < 8; i++) { if (out & 0x01) PS2_CMD_H(); else PS2_CMD_L(); /* 准备这一位 */ out >>= 1; PS2_CLK_L(); /* 时钟拉低 */ __NOP(); __NOP(); __NOP(); /* 约 1us 建立时间 */ if (PS2_DAT_RD()) in |= (uint8_t)(1U << i); /* 低电平期间采样 */ PS2_CLK_H(); /* 时钟拉高 */ __NOP(); __NOP(); __NOP(); } return in; } /* 一整帧:9 字节,ATT 包住 */ void PS2_Transfer(const uint8_t *tx, uint8_t *rx) { PS2_ATT_L(); for (volatile uint8_t d = 0; d < 30; d++) __NOP(); /* 拉低后至少等 10us */ for (uint8_t i = 0; i < 9; i++) rx[i] = ps2_xfer_byte(tx[i]); for (volatile uint8_t d = 0; d < 30; d++) __NOP(); PS2_ATT_H(); }这段代码里有三个参数决定了能不能跑通。第一个是时钟频率,__NOP()的个数决定了半周期长度,F103 上 3 个 NOP 大约是 1us 出头,对应 250kHz 左右,正好在接收器能接受的范围内;NOP 太少(比如一个都不加)会超过 500kHz,接收器直接不响应。第二个是采样边沿,必须放在时钟低电平期间读 DATA,写在高电平期间读到的会是下一位的数据,表现是数据整体错位一位,摇杆值永远差一截。第三个是 ATT 拉低后的等待,这个 10us 不是可选的,接收器内部要在这段时间里准备应答,省掉它的话第一次读到的经常是 0xFF。
帧与帧之间还要留间隔。PS2_Transfer返回之后到下一次调用,中间至少隔 500us,太密集接收器会丢帧。主循环 10ms 一次天然满足,但如果你在初始化里连发五条命令,中间一定要加HAL_Delay(1)。
3.3 初始化序列与摇杆值归一化
初始化不是发一次就完事,要按顺序来,而且每一步都要检查返回。
/* app_remote.c —— 上电初始化 */ static const uint8_t cmd_poll[9] = {0x01,0x42,0,0,0,0,0,0,0}; static const uint8_t cmd_cfg[9] = {0x01,0x43,0x00,0x01,0,0,0,0,0}; static const uint8_t cmd_analog[9]= {0x01,0x44,0x00,0x01,0x03,0,0,0,0}; static const uint8_t cmd_exit[9] = {0x01,0x43,0x00,0x00,0x5A,0x5A,0x5A,0x5A,0x5A}; uint8_t PS2_Init(void) { uint8_t rx[9] = {0}; HAL_Delay(200); /* 等接收器上电稳定 */ PS2_Transfer(cmd_poll, rx); if (rx[1] != 0x41) return 0; /* 第 2 字节不是 0x41,说明没找到手柄 */ PS2_Transfer(cmd_cfg, rx); HAL_Delay(2); PS2_Transfer(cmd_analog, rx); HAL_Delay(2); PS2_Transfer(cmd_exit, rx); HAL_Delay(2); PS2_Transfer(cmd_poll, rx); /* 再轮询一次确认模式切换成功 */ return (rx[1] == 0x41) ? 1 : 2; }返回值设计成三态是有用的:0 表示完全没通信(查线),2 表示通信正常但模式没切过去(查初始化顺序或者手柄是不是原装的),1 才是正常。实际调试时这两个失败码能省下大量时间。
摇杆归一化要在初始化之后做一次校准。理论中值是 128,但手柄用久了会有漂移,实测经常在 118 到 138 之间。上电后让手柄静置,连续读 20 帧取平均,把结果当中值存起来,后面所有解算都用这个实测值。
/* 读取一帧并归一化,输出范围 -1000 ~ 1000 */ int16_t PS2_ReadAxis(uint8_t raw, uint8_t center) { int16_t v = (int16_t)raw - (int16_t)center; if (v > -12 && v < 12) return 0; /* 死区,防止车自己爬行 */ if (v > 0) v = (v * 1000) / (255 - center); /* 正向按剩余行程缩放 */ else v = (v * 1000) / center; /* 反向按中值行程缩放 */ if (v > 1000) v = 1000; if (v < -1000) v = -1000; return v; }这里的缩放分母必须分开算。如果统一除以 127,正向只能到 1000 左右,反向却会溢出,摇杆往一边推到底车快,往另一边推到底车慢,手感非常怪。死区取 ±12 是实测出来的经验值,太小会在中位附近抖动,太大转向会有明显滞后。
4. 电机与运动学:四路 PWM 怎么把摇杆值变成轮速
硬件链路走通了,接下来是让四个轮子听话。这一块最容易出的问题不是代码,而是电源和共地。
4.1 驱动芯片选型与四路 PWM 配置
四个直流电机,常见的两种方案:一片 L298N 双路模块需要两片,压降大、发热明显;TB6612FNG 一片两路,同样需要两片,但效率高、体积小。这套项目用的是后者,每片负责一侧的两个轮子。
每片 TB6612 需要五个控制信号:PWMA、AIN1、AIN2、PWMB、BIN1、BIN2,外加一个 STBY。STBY 是使能脚,拉低时芯片完全关断,很多新手把这个脚悬空,结果 PWM 打得好好的电机就是不转,查了半天以为是 PWM 没配好。
PWM 用 TIM1 的四路通道(CH1 到 CH4),两片驱动各占两路。配置上,72MHz 主频,预分频 PSC = 0,自动重装 ARR = 7199,得到 10kHz 的 PWM 频率。这个频率是折中:低于 5kHz 电机会有可闻的啸叫,高于 20kHz 又会让驱动芯片开关损耗上升、发热加剧。
/* bsp_motor.c —— 电机抽象,四个实例对应四个轮子 */ typedef struct { GPIO_TypeDef *port; uint16_t pin_a; /* 方向脚 1 */ uint16_t pin_b; /* 方向脚 2 */ TIM_HandleTypeDef *htim; uint32_t ch; /* TIM_CHANNEL_x */ int8_t invert; /* 电机装反了,置 -1 省得改线 */ } Motor_t; /* speed 范围 -1000 ~ 1000,正为前进 */ void Motor_Set(const Motor_t *m, int16_t speed) { if (speed > 1000) speed = 1000; if (speed < -1000) speed = -1000; speed *= m->invert; if (speed > 0) { HAL_GPIO_WritePin(m->port, m->pin_a, GPIO_PIN_SET); HAL_GPIO_WritePin(m->port, m->pin_b, GPIO_PIN_RESET); } else if (speed < 0) { HAL_GPIO_WritePin(m->port, m->pin_a, GPIO_PIN_RESET); HAL_GPIO_WritePin(m->port, m->pin_b, GPIO_PIN_SET); } else { /* 刹车:两脚同时拉高,比滑行更稳,适合坡道停车 */ HAL_GPIO_WritePin(m->port, m->pin_a, GPIO_PIN_SET); HAL_GPIO_WritePin(m->port, m->pin_b, GPIO_PIN_SET); } /* 把 -1000~1000 映射到 0~ARR */ uint32_t ccr = (uint32_t)((int32_t)abs(speed) * 7199 / 1000); __HAL_TIM_SET_COMPARE(m->htim, m->ch, ccr); }invert这个字段看着不起眼,实际能救命。四个电机装到车架上,左右两侧是对称的,左边正转对应前进,右边正转可能是后退,如果靠改接线来纠正,拆装几次端子就松了。用软件取反,一行搞定。零速时让两个方向脚同时拉高是刹车模式,比同时拉低(滑行)更稳,尤其是在有坡的地方松手不会溜车。
4.2 差速解算:一个摇杆值拆成四个轮子
四驱底盘本质上是个滑移转向(skid steer)结构,左右两侧速度差产生转向。解算就两行:左轮速度 = 前进量 + 转向量,右轮速度 = 前进量 - 转向量。复杂的地方在于两个量各自的范围和限幅。
/* app_chassis.c —— 输入归一化后的摇杆值,输出四轮目标速度 */ typedef struct { int16_t lf, lr, rf, rr; } Wheel_Target_t; void Chassis_Mix(int16_t fwd, int16_t turn, uint16_t max_speed, Wheel_Target_t *out) { int32_t left = (int32_t)fwd + turn; int32_t right = (int32_t)fwd - turn; /* 等比压缩:保证最快的那个轮子不超上限,同时保留比例关系 * 直接单边限幅会导致转向时内轮先饱和,车会走歪 */ int32_t peak = 1000; int32_t m = (abs(left) > abs(right)) ? abs(left) : abs(right); if (m > peak) { left = left * peak / m; right = right * peak / m; } /* 映射到实际最大速度档位 */ left = left * max_speed / 1000; right = right * max_speed / 1000; out->lf = out->lr = (int16_t)left; out->rf = out->rr = (int16_t)right; }max_speed是速度档位,由 L1/L2 按键在 300 到 1000 之间切换,这样低速时操控精细,高速时能跑起来。等比压缩那一段是整个解算里最关键的三行——如果简单地写成“left 超过 1000 就截断到 1000”,前进和转向同时打满的时候,内轮先饱和、外轮还在涨,车会突然往一边偏,手感极差。等比压缩牺牲一点绝对速度,换来了转向过程中左右轮速比始终正确。
如果底盘是麦克纳姆轮,这一段要换成四轮独立解算,X 轴、Y 轴、旋转三个量分别投影到四个轮子上,公式变成 (fwd ± strafe ± rotate) 的组合。普通四驱不需要,别为了看起来高级硬上麦轮,麦轮对地面平整度要求高,稍微有点坡就打滑。
4.3 限幅、死区和斜坡起步
三层保护叠在一起,才能让车开起来不像是“开关控制”。
第一层是死区,前面PS2_ReadAxis里已经做了,摇杆中位附近输出 0。第二层是电机死区补偿:PWM 占空比低于 15% 左右时电机根本不转,只会嗡嗡响发热,所以非零速度要映射到 150 到 1000 这个区间,而不是 0 到 1000。第三层是斜坡,目标是让速度指令按固定斜率逼近,避免瞬间满占空比导致电流冲击,轻则驱动芯片过流保护,重则电池电压瞬间跌落把 MCU 拉复位。
/* 1ms 定时器中断里调用,把当前速度平滑推到目标速度 */ void Motor_Slew(Motor_t *m, int16_t target, int16_t step) { static int16_t cur[4] = {0}; /* cur 按索引区分四个电机,这里省略索引,只写逻辑 */ int16_t c = cur[0]; if (c < target) { c += step; if (c > target) c = target; } else if (c > target) { c -= step; if (c < target) c = target; } cur[0] = c; Motor_Set(m, c); }step取 20 意味着 1ms 变化 20,从 0 到满速需要 50ms,起步有明显缓冲但不拖沓。这个值调太小(比如 5)会感觉车“肉”,调太大(比如 100)就退化成阶跃,等于没做。另外要注意cur数组必须用静态变量或者全局变量,放在函数局部非 static 里每帧都会清零,斜坡就永远爬不上去,这个坑我在别的项目里踩过一次。
5. 常见问题排查:调了三天小车还在原地打转的 5 个坑
这一节是按“现象 → 原因 → 解决”整理的实战记录,基本上新手能遇到的都在里面了。
5.1 接收器灯亮着,但代码读回来全是 0xFF
现象:接收器上电后指示灯常亮或者闪烁,说明供电没问题,但PS2_Init一直返回 0,rx[1]永远是 0xFF 或者 0x00。
原因通常有三个。一是 DATA 引脚没配上拉,接收器输出是开漏结构,MCU 端不加上拉读到的永远是高电平。二是 CMD 和 DATA 接反了,这两根线在排针上相邻,插错很方便。三是时钟太快,NOP 个数不够,接收器跟不上直接不响应。
解决:先示波器或者逻辑分析仪看 CLOCK 和 ATT,确认有波形;再把 PA6 配成GPIO_PULLUP;最后把ps2_xfer_byte里的 NOP 加到 5 个以上降速验证。如果降到 100kHz 能通,说明是时序太快,慢慢往上加找到临界点,再留一倍余量。
5.2 按键能读,摇杆一直是 0 或者一直是 255
现象:按叉、圆圈都有反应,但摇杆四个字节纹丝不动。
原因:初始化序列里漏了“设置模拟模式”,或者顺序错了。手柄默认是数字模式,只上传按键状态,模拟量字段填的是固定值。另外还有一种可能是买到了只支持数字模式的兼容手柄。
解决:严格按“轮询 → 进入配置 → 设置模拟模式 → 退出配置 → 再轮询”的顺序走,每一步之间加 2ms 延时。如果还是不行的,把rx[1]和rx[2]打出来看,正常情况下rx[1]应该是 0x41、rx[2]应该是 0x5A,不是的话说明命令根本没被接受。
5.3 PWM 有输出,电机就是不转或者一抖一抖
现象:用示波器量 PWMA 有正常方波,占空比也在变,但电机静止,或者发出“哒哒”声轻微抖动。
原因:最常见的是 TB6612 的 STBY 脚没拉高,芯片处于待机状态,PWM 进得去出不来。其次是电机和主控没共地,或者驱动芯片的逻辑电源和电机电源接反。第三种是电池功率不够,四个电机同时启动瞬间电流能到 5A 以上,用 9V 层叠电池或者小容量锂电池会直接跌压。
解决:先量 STBY 是不是 3.3V;再把万用表打到通断档确认主控 GND 和驱动 GND 是通的;最后换一个能持续输出 2A 以上的电源,同时在电机两端各并一个 0.1uF 陶瓷电容抑制火花干扰。这个电容很关键,不加的话电机一转 MCU 就复位,看起来像程序跑飞。
5.4 车能走但走不直,松手还会缓慢爬行
现象:摇杆回中,车还在动;全速前进时车往一边偏。
原因:摇杆中值不是理论上的 128,实测偏差能到 ±10;四个电机的内阻和减速箱阻力也不一致,同样占空比转速不同。
解决:上电时做一次静置校准,把 20 帧的平均值存成 center,这个方法前面代码里已经写了。电机差异靠软件补偿,在app_config.h里给每个轮子加一个 0.9 到 1.1 的系数,实测哪个轮子快就往下调。另外确认死区没被注释掉,很多人调试的时候为了看效果把死区去了,忘了加回来。
5.5 跑着跑着突然一顿,或者干脆失控
现象:正常行驶中偶尔卡顿,严重时车会保持上一次的动作一直冲。
原因:手柄数据没有超时保护。接收器被遮挡、电池电量低、或者主循环里被某个阻塞操作拖住,都会导致读上来的数据和上一次一样,程序以为你还在推摇杆。另外如果主循环里有HAL_Delay(100)之类的操作,100Hz 的轮询就变成了 10Hz,手感立刻变差。
解决:加一个看门狗计数器,每成功收到一帧就清零,主循环里超过 200ms 没清就强制把四路速度设成 0。这个后悔药必须提前吃,不能等撞了墙再加。同时把主循环里所有超过 5ms 的延时都去掉,需要延时的活挪到定时器里去。
6. 二次开发:把遥控玩具改成能跑闭环的底盘平台
遥控跑通只是起点,这套代码真正的价值在于分层之后每一层都能单独替换。想把遥控车升级成能跑算法的底盘,按下面的顺序改最省事。
第一步是给四个轮子加编码器,把Motor_Set换成Motor_SetWithFeedback,用定时器的编码器模式读脉冲,算出实际转速。这一步做完,app_chassis输出的就不再是占空比而是目标转速,中间插一个 PI 控制器。参数上,先只加 P,从 0.5 开始往上试,看到速度有轻微振荡就回退一半;I 用来消稳态误差,取 P 的十分之一左右,太大一定会积分饱和。
第二步是把遥控输入的来源抽象掉。现在app_chassis的输入是摇杆值,改成从一个Control_Input_t结构读,遥控、串口上位机、自动巡航任务都往这个结构里写,底盘层完全不用改。这个抽象做早了没意义,做晚了到处是 if-else,我一般会在第二次接新输入源的时候就抽出来。
第三步是加状态机。手动遥控、原地旋转、定速巡航、低速跟线这几种模式用一个enum管起来,切换时统一走一个Chassis_Stop()清速度,避免模式切换瞬间速度突变。
| 改造项 | 改动位置 | 工作量 | 注意点 |
|---|---|---|---|
| 编码器闭环 | bsp_motor 新增,app_chassis 改输出 | 半天 | 编码器线要远离电机线走 |
| 上位机调试 | 新增 app_uart,10Hz 上报 | 2 小时 | 别在中断里 printf |
| 电池电压监测 | ADC + 低压降速 | 1 小时 | 分压电阻别用太大阻值 |
| 手柄断连保护 | app_remote 加超时计数 | 20 分钟 | 超时后必须清速度 |
验证方法很简单:把车架起来,让四个轮子悬空,全速跑十分钟,摸驱动芯片温度、看电流表读数、观察有没有偶发复位。落地测试之前先做这一步,能提前发现九成的电源问题。
我现在的习惯是,每加一个新模块,先不接电机,让Motor_Set里只打印不写寄存器,用串口把四个轮子的目标速度打出来看波形对不对,确认解算没问题再上电。这套流程走下来,调车时间从原来的两三天压缩到半天。希朝这套拆解能帮你少走几趟弯路。
本文还有配套的精品资源,点击获取