ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

STM32+PS2手柄四驱遥控底盘:GPIO时序与差速解算

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说明
1DATA输入PA6上拉输入,手柄发给 MCU 的数据
2CMD输出PA5推挽输出,MCU 发给手柄的命令
37.5V电源电池正极只给震动马达供电,不接也能用
4GND地GND必须和主控共地
53.3V电源3.3V逻辑供电,别接到 5V
6ATT输出PA4片选,低电平有效,一帧的开始和结束
7CLOCK输出PA7推挽输出,时钟由 MCU 产生
8——悬空部分接收器内部保留
9ACK输入悬空或 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里只打印不写寄存器,用串口把四个轮子的目标速度打出来看波形对不对,确认解算没问题再上电。这套流程走下来,调车时间从原来的两三天压缩到半天。希朝这套拆解能帮你少走几趟弯路。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进