简介:面向嵌入式初学者与STM32实践者的PS2手柄控制四轮全向轮小车项目源码包,聚焦手柄信号解析、PWM调速与全向轮运动控制的完整实现。资源共233个文件,以41个c源文件、42个h头文件及配套o、crf编译中间文件为主,另有Keil工程文件、hex可执行文件、依赖文件和说明文本,压缩包约8.28MB,适合导入Keil对照学习。已有747人学习。包内按BSP、USER、CORE、SYSTEM等模块组织,覆盖PS2手柄时序读取、电机正反转与占空比调节、四轮协同平移旋转等关键逻辑;全向轮可实现横移、斜行和原地旋转,调试时可分别验证单轮驱动与四轮协同。项目保留固件库、工程配置、批处理清理脚本与README说明,便于读者梳理嵌入式项目目录结构、理解从输入解析到电机输出的控制链路,也可作为课程设计或小车调试的参考底稿。
1. 从一只旧手柄开始的四驱底盘改造需求
实验室角落躺着一台四驱底盘,四个直流减速电机、两块双 H 桥驱动,唯一缺的是一个能让人站着就把车开走的上位控制器。用手机 App 要配网、要写安卓端,成本全花在通信上;自己画个遥控器又要开模、要打板。翻出来一只 PS2 手柄和它的接收器,四根信号线接上就能读摇杆和十几个按键——这就是「PS2 控制 4 电机小车」这套方案最朴素的起点,也是很多机电、自动化、物联网方向毕设和课设里最划算的一笔投入。
这套方案要解决的问题有三个,边界很清楚:STM32 用普通 GPIO 模拟时序把 PS2 报文稳定读回来,不能靠“差不多能读”;把摇杆的模拟量映射成四路电机的差速量,让车能走直线、能原地转;在丢包、断电、误触的情况下车必须能停下来,而不是满速冲出去。适合正在做智能小车、移动机器人底盘的在校学生,也适合手里有现成底盘、只想补一个低成本遥控入口的从业者。
2. 总体架构设计:从手柄到四个轮子的三层职责划分
先把架构定下来再动焊枪,是我吃过亏之后养成的习惯。四电机小车看起来简单,实际上有输入时序、实时控制、功率驱动三件性质完全不同的事混在一起,如果全部塞进一个while(1)里,调试阶段会非常难受——车跑偏了你不知道是手柄读错了、混控算错了,还是某个电机线接反了。
2.1 三层结构:输入层、控制层、执行层各管什么
我一般把它切成三层,层与层之间只用结构体传数据,任何一层都不直接碰另一层的寄存器。
输入层只有一个职责:每隔固定周期把 PS2 接收器的 9 个字节读回来,解码成一个统一的遥控帧结构体rc_frame_t,里面是归一化后的v(前进分量)、w(自转分量)和按键位图。这一层不关心车怎么走,它只负责“手柄现在说了什么”。
控制层拿到遥控帧,做三件事:档位缩放(低速档、高速档)、加减速斜坡限幅、四轮混控。输出是一个四个元素的占空比数组加四个方向标志。这一层是纯计算,不碰硬件,所以可以在 PC 上单独跑单元测试——把一堆摇杆值喂进去,看输出的四路占空比对不对,比在车上试快十倍。
执行层最薄:把占空比写进定时器的比较寄存器,把方向标志写进 GPIO,仅此而已。它不做任何判断,因为一旦执行层里藏了逻辑,出问题时就很难定位到底是算错了还是写错了。
这样分层之后,调试路径就变成一条直线:车不动 → 先看输入层的帧是否刷新 → 再看控制层输出是否非零 → 最后量执行层引脚有没有波形。三步之内必定能定位到具体是哪一层。
2.2 控制周期与数据流:为什么把 20ms 定成一个节拍
PS2 接收器本身并不推送数据,它是个被动器件,主机不发时钟它就不吐字节。所以整条链路的节奏完全由我们决定。
我通常把主控制节拍定在 20ms,也就是 50Hz。理由有三条。第一,人手的操作带宽大概在 10Hz 上下,50Hz 的采样已经远超需求,再快只是浪费 CPU。第二,一次完整的 PS2 九字节交互在 250kHz 时钟下大约 300μs,20ms 周期里占比不到 2%,主循环有充足余量做混控和看门狗。第三,20ms 也是失联判定的天然计量单位——连续 10 个周期没读到有效帧,就是 200ms,正好是人能感知到的“延迟上限”。
电机 PWM 则另走一路,频率设在 20kHz。这个数字不是随便挑的:低于 16kHz 会落进人耳可听范围,车一动就有尖锐的啸叫声,答辩现场非常掉分;太高则开关损耗上去,L298N 这类老驱动芯片会明显发烫。20kHz 刚好在听觉上限之上,又是 72MHz 主频能被整数分频得到的值。
下面这张表是我在方案文档里必写的时序预算,答辩时被问到“实时性怎么保证”直接翻这一页:
| 任务 | 周期 | 单次耗时(估算) | 执行位置 |
|---|---|---|---|
| PS2 报文读取与解码 | 20ms | 约 300μs | 主循环 |
| 摇杆归一化与斜坡限幅 | 20ms | 小于 20μs | 主循环 |
| 四轮混控与占空比输出 | 20ms | 小于 30μs | 主循环 |
| 失联与急停检测 | 20ms | 小于 5μs | 主循环 |
| PWM 波形维持 | 硬件自动 | 不占 CPU | 定时器 |
可以看到 CPU 占用率极低,剩下的时间全部留给串口调试打印和状态灯。这也是我建议主循环轮询、不要用中断去读 PS2 的原因:PS2 的时序对抖动并不敏感,但对手动拉低拉高的顺序极度敏感,放进中断里反而容易被别的中断打断,产生半帧数据。
2.3 安全策略:失联、急停与上电自检写进架构
安全策略不能等到最后再补,必须在架构阶段就占好位置。这套方案里我固定放三条。
第一条是失联保护。遥控帧结构体里带一个tick字段,主循环每次成功读到有效帧就刷新它。混控之前先判断当前节拍减去tick是否超过 200ms,超过就强制把目标速度清零,同时点亮一个红色状态灯。这条逻辑放在控制层入口,任何后续计算都绕不过去。
第二条是解锁条件。上电后车不能直接响应摇杆,必须先满足“摇杆回中且按下 START 键”才进入遥控态。这听起来多余,但真实现场太多人上电瞬间摇杆是歪的,车直接窜出去撞墙。做成状态机之后,上电默认停在STATE_IDLE,只有解锁事件才能跳到STATE_RUN。
第三条是上电自检。初始化阶段依次检查:PS2 接收器是否能正常握手(返回帧头是否为0x5A或0x73)、四个电机的方向引脚是否能拉高拉低(用万用表或直接看驱动板指示灯)、电池电压是否高于欠压阈值。任何一项失败就停在故障态并让蜂鸣器间歇响,避免带着隐患上场。
状态机我一般就写四个状态:IDLE(待机,输出全零)、RUN(正常遥控)、FAULT(自检失败或欠压)、ESTOP(急停,需重新解锁)。状态迁移条件写在一张表里,代码按表实现,答辩讲起来也清楚。
3. 硬件选型与接线设计:STM32、PS2 接收器与四路驱动怎么配
硬件这一章最容易被跳过,但四电机小车翻车的地方八成在硬件上:不是代码写错了,是电源被电机拉塌了,或者接收器被 5V 烧了。所以这部分我写方案时会写得比代码还细。
3.1 主控与外设资源分配表
主控选 STM32F103C8T6 这类 72MHz、48 脚的最小系统板就够用。算一下引脚需求:PS2 要 4 根(ATT、CLK、CMD、DAT),四路电机要 4 路 PWM 加 8 根方向线,再加两三个状态灯和一个调试串口,总共 20 根左右,48 脚完全放得下,还留了余量给后续加编码器或者超声波。
PWM 我优先用 TIM1 的四个通道,因为它们挨在一起,初始化代码简洁,而且四个通道共用同一个自动重装值,四路频率严格一致,不会出现某个轮子 PWM 周期和别人不一样导致的速度偏差。
| 功能 | 引脚 | 模式 | 说明 |
|---|---|---|---|
| PS2 ATT/CS | PB12 | 推挽输出 | 低电平选中,空闲高 |
| PS2 CLK | PB13 | 推挽输出 | 时钟,空闲高电平 |
| PS2 CMD | PB14 | 推挽输出 | 主机发往手柄 |
| PS2 DAT | PB15 | 上拉输入 | 手柄发往主机 |
| M1 PWM(左前) | PA8 | TIM1_CH1 | 复用推挽 |
| M2 PWM(左后) | PA9 | TIM1_CH2 | 复用推挽 |
| M3 PWM(右前) | PA10 | TIM1_CH3 | 复用推挽 |
| M4 PWM(右后) | PA11 | TIM1_CH4 | 复用推挽 |
| M1~M4 方向 | PB0~PB7 | 推挽输出 | 每电机两路 IN1/IN2 |
定时器参数这样配:系统时钟 72MHz,预分频PSC = 0,自动重装ARR = 3599,得到 72M / 3600 = 20kHz 的 PWM 频率,占空比分辨率 3600 级,足够细腻。后面代码里的PWM_MAX就取 3600。
3.2 PS2 接收器接线与电平注意点
接收器是一个 9 脚的小板,实际用到的只有 6 根:VDD、GND、ATT、CLK、CMD、DAT,另外三根是震动电机相关,这套方案里不接。
最容易翻车的一点:接收器是 3.3V 器件,VDD 必须接 3.3V,接 5V 会直接烧。我见过至少两次“手柄死活读不到”,最后拆开发现是电源接错,芯片已经报废。稳妥的做法是在接收器 VDD 和 GND 之间并一颗 0.1μF 瓷片电容,线长控制在 30cm 以内,并且让这四根信号线远离电机线束走线。
还有一个隐性坑:PS2 是开漏/开集结构,DAT 线在接收器内部没有强上拉,STM32 端必须配置成上拉输入,否则读回来的数据会飘。如果通信距离确实需要拉长,可以在 DAT 上并一个 4.7kΩ 上拉到 3.3V。
供电时序也要注意:先给主控上电、再给电机驱动上电,或者干脆用同一个开关同时控制,避免出现主控已经跑起来、电机驱动还没就绪的中间状态。
3.3 四路电机驱动与供电分区
四个电机有两种常见方案。一种是用两块 L298N,每块带两个电机,接线直观、成本低,缺点是内部压降接近 2V,效率低、发热大,7.4V 电池实际到电机端只剩 5V 多一点。另一种是用两块 TB6612 或者四块单路驱动,压降小、效率高,但对焊接和走线要求高一些。毕设和课设场景我一般推荐前者,因为它的失效模式最“温和”——大不了跑得慢,不会炸。
选驱动板时按堵转电流选,不是按空载电流。一个 130 减速电机空载 200mA,堵转轻松到 1.5A 以上,四路同时堵转就是 6A。L298N 标称单路 2A,实际长时间跑 1.5A 就得加散热片。这一点在方案文档的“器件选型依据”一栏里必须写清楚,答辩老师很爱问。
供电是整个硬件部分最需要认真对待的地方,我的分区原则是:
- 电池正极先经过一个总开关,再分两路:一路直接进电机驱动板的 VM,另一路经降压模块出 5V 给主控板。
- 电机驱动板电源入口并一个 470μF 电解电容加一个 0.1μF 瓷片电容,用来吸收换向瞬间的电流冲击。这颗电容不是可选项,省掉它,STM32 就会莫名其妙复位。
- 主控和接收器共用同一个地,电机地用粗线单独回到电池负极,两者在电池负极处单点汇合。这叫单点接地,能显著降低地弹。
- 电池电压经分压后接一路 ADC,用来做欠压保护。2S 锂电低于 6.8V 就进
FAULT态,别让车跑到电池鼓包。
4. 软件实现:PS2 协议解析、摇杆映射与四轮混控
代码部分我会把四件事拆开写:怎么读、怎么算、怎么分、怎么保命。每一块都能单独验证,不用整车联调。
4.1 PS2 轮询时序与按键解码
PS2 的物理层就是一根时钟、两根数据线组成的同步串行接口,主机拉低 ATT 选中接收器,然后在 CLK 上打 8 个脉冲,每一拍在下降沿采样一位,低位在前。九字节一帧,第一个字节是握手标识,正常应该是0x5A(数字模式)或0x73(模拟模式),后面依次是按键位图和四路摇杆值。
/* PS2 引脚操作宏,PB12~PB15 已在 CubeMX 里配好 */ #define ATT_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET) #define ATT_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET) #define CLK_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, GPIO_PIN_RESET) #define CLK_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, GPIO_PIN_SET) #define CMD_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_14, GPIO_PIN_RESET) #define CMD_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_14, GPIO_PIN_SET) #define DAT_READ() HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_15) /* 收发一个字节,CLK 空闲为高,下降沿后采样,低位在前 */ static uint8_t ps2_xfer(uint8_t cmd) { uint8_t data = 0; for (uint8_t i = 0; i < 8; i++) { if (cmd & (1u << i)) CMD_HIGH(); else CMD_LOW(); delay_us(2); /* CMD 建立时间 */ CLK_LOW(); /* 时钟下降沿,手柄开始吐数据 */ delay_us(2); if (DAT_READ()) data |= (uint8_t)(1u << i); CLK_HIGH(); /* 时钟上升沿,准备下一位 */ delay_us(2); } return data; } /* 读一帧,返回 0 表示成功,buf 存 9 个字节 */ uint8_t ps2_read_frame(uint8_t *buf) { ATT_LOW(); delay_us(10); ps2_xfer(0x01); /* 起始字节 */ ps2_xfer(0x42); /* 请求数据 */ ps2_xfer(0x00); for (uint8_t i = 0; i < 6; i++) { buf[i] = ps2_xfer(0x00); /* 依次取回按键与摇杆 */ } ATT_HIGH(); delay_us(20); /* 帧间隔,不宜小于 10us */ return (buf[0] == 0x5A || buf[0] == 0x73) ? 0 : 1; }几个参数必须说清楚。delay_us(2)对应约 250kHz 的时钟频率,接收器手册里的上限是 500kHz,取一半留足余量;实际用示波器看,如果把延时压到 1μs 以下,读回来的数据就开始随机出错。ATT_HIGH()之后的 20μs 间隔是给接收器内部状态机的复位时间,我试过往下砍到 5μs,短时间能跑,长时间会偶发丢帧。
验证这一段的最快办法不是接车,而是打开串口,把九字节原样打印出来:不按键时按键字节应该是0xFF,按住某个键对应位变 0(PS2 是低电平有效),摇杆不动时四路值都在0x80附近小幅抖动。这三条对上,物理层就通了。
按键位映射我习惯写成一张宏表,比如buf[2]的 bit3 是 START、bit0 是 SELECT、buf[3]的 bit6 是 R1、bit5 是 L1。不同批次接收器的位序是一致的,但第一次用还是建议逐个按下去看串口打印,五分钟就能把表对死。
4.2 摇杆归一化、死区与加减速斜坡
模拟模式下摇杆输出是 0~255,中间值 128。直接拿这个值去算 PWM 会遇到两个问题:一是松手后摇杆回不到精确的 128,通常落在 124~132 之间,车会一直“慢慢爬”;二是量程不对称,有些手柄往左能到 0,往右只到 250,导致左右转向力度不一样。
标准做法是先减去中点、再做死区、最后按有效量程归一化:
#define JOY_CENTER 128 #define JOY_DEADZONE 10 /* 死区,手柄抖动通常不超过 ±8 */ /* 把 0~255 的原始值映射成 -1.0 ~ +1.0,死区内输出 0 */ static float axis_norm(uint8_t raw) { int16_t d = (int16_t)raw - JOY_CENTER; if (d > JOY_DEADZONE) d -= JOY_DEADZONE; else if (d < -JOY_DEADZONE) d += JOY_DEADZONE; else return 0.0f; float span = (d > 0) ? (127.0f - JOY_DEADZONE) : (128.0f - JOY_DEADZONE); float v = (float)d / span; if (v > 1.0f) v = 1.0f; if (v < -1.0f) v = -1.0f; return v; }这样处理后,死区内是严格的 0,越过死区的瞬间也不会跳变——因为减掉的死区量被补回来了,输出是从 0 连续增长的。这一步如果图省事写成“超过死区就直接输出原值”,车一动就会顿一下,手感极差。
更彻底的办法是在上电自检时采样 100 次摇杆中点,取平均作为该手柄的实际零点存进全局变量。我一般默认用 128,同时留一个“按住 L1 上电即校准”的入口,遇到漂得厉害的手柄就用校准。
斜坡限幅是另一个必做项。摇杆从 0 推到满只需要几十毫秒,但电机带着小车从静止到 1m/s 需要几百毫秒,如果占空比一步到位,轮胎会打滑、驱动板会过流,长时间还会烧电机。我一般按每 20ms 周期最多变化 0.05(归一化值)来限幅,也就是大约 400ms 从零到满,这个加速度体感上轻快又不突兀。
/* 每周期调用一次,step 是单周期允许的最大变化量 */ static float slew_limit(float cur, float target, float step) { if (target > cur + step) return cur + step; if (target < cur - step) return cur - step; return target; }4.3 四轮差速混控与可选的麦轮矢量合成
四电机底盘默认按滑移转向(skid steer)处理:左侧两个电机同速、右侧两个电机同速,靠左右速度差产生转差。遥控帧里的v和w转成左右两侧的目标速度,只需要一次加减:
#define PWM_MAX 3600.0f /* 单轮标定系数,用来修正电机个体差异和轮径误差 */ static const float MOTOR_TRIM[4] = {1.00f, 0.97f, 1.02f, 1.00f}; typedef struct { float v; float w; } chassis_cmd_t; /* c.v 前进为正,c.w 逆时针为正;输出四路占空比与方向 */ static void mix_4wd(chassis_cmd_t c, int16_t duty[4], int8_t dir[4]) { float left = c.v - c.w; /* 左前、左后 */ float right = c.v + c.w; /* 右前、右后 */ /* 归一化:v 和 w 同时取满时不能超过 1 */ float peak = 1.0f; if (fabsf(left) > peak) peak = fabsf(left); if (fabsf(right) > peak) peak = fabsf(right); left /= peak; right /= peak; float raw[4] = { left, left, right, right }; /* M1 M2 M3 M4 */ for (uint8_t i = 0; i < 4; i++) { float s = raw[i] * MOTOR_TRIM[i]; dir[i] = (s >= 0.0f) ? 1 : -1; float d = fabsf(s) * PWM_MAX; duty[i] = (int16_t)(d > PWM_MAX ? PWM_MAX : d); } }这段代码里有三处值得在文档里解释。left = v - w里w的符号决定了转向方向,如果实车发现左右摇杆和车头转向相反,改这里的正负号,不要去改接线,改代码只需要一行,改接线要拆四个端子。归一化那几行是为了防止“前进加满怀疑同时还想转”时溢出——如果不归一化,left可能到 -2,除以PWM_MAX之后直接饱和,车会失控地一边轮子停转。MOTOR_TRIM是标定用的,四个电机同型号也会有 5% 左右的速度差,跑三米能偏出去半米,用系数拉回来是最省事的办法。
如果底盘是麦克纳姆轮,混控就换成四轮独立矢量合成:vx前后、vy左右、w自转,四个轮子的目标速度分别是vx - vy - w、vx + vy - w、vx + vy + w、vx - vy + w(顺序取决于轮子安装方向)。结构上仍然是“输入层给 v/w,控制层算四个轮速”,只是中间多一个vy,架构不用动。这也是分层设计的好处——换底盘形式只改控制层的一个函数。
4.4 主循环与中断的配合
主循环的结构非常固定,我基本每次都是同一个骨架:
int main(void) { HAL_Init(); SystemClock_Config(); /* 72MHz */ MX_GPIO_Init(); MX_TIM1_Init(); /* 20kHz PWM,ARR = 3599 */ MX_USART1_UART_Init(); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); /* 其余三个通道同上 */ ps2_init(); /* 进入模拟模式并做一次握手 */ voltage_check(); uint32_t last_tick = HAL_GetTick(); float v_cur = 0.0f, w_cur = 0.0f; while (1) { if (HAL_GetTick() - last_tick < 20) continue; /* 20ms 节拍 */ last_tick = HAL_GetTick(); uint8_t frame[9]; if (ps2_read_frame(frame) == 0) { rc_decode(frame); /* 更新 g_rc 与 g_rc.tick */ } /* 失联判定:200ms 内没有有效帧就强制归零 */ if (HAL_GetTick() - g_rc.tick > 200) { g_rc.v = 0.0f; g_rc.w = 0.0f; state_to_estop(); } v_cur = slew_limit(v_cur, g_rc.v, 0.05f); w_cur = slew_limit(w_cur, g_rc.w, 0.05f); int16_t duty[4]; int8_t dir[4]; mix_4wd((chassis_cmd_t){ v_cur, w_cur }, duty, dir); motor_apply(duty, dir); /* 写 CCR + 写方向 GPIO */ } }motor_apply里要注意一点:改变方向之前必须先把占空比清零,再切方向引脚,最后重新给占空比。直接在带载状态下翻转方向,相当于让电机反接制动,瞬间电流能达到堵转电流的两倍以上,L298N 会当场保护甚至炸管。这个细节如果不写进代码,实车倒车几次就会出问题。
5. 调试避坑:五类最容易翻车的问题与排查路径
这一章是我把过去几年踩过的坑整理成的排查表。方案文档里放这么一章,评审时反而加分——它证明你真的动过手。
5.1 现象:接收器灯不亮,串口一帧都读不到
原因通常有两个。一是供电接错,把 VDD 接到了 5V,3.3V 器件当场击穿;二是 ATT 和 CLK 接反了,主机永远选不中器件。还有一种少见情况是接收器需要主机先发一次唤醒序列才能进入模拟模式,冷启动时直接发0x42会被忽略。
解决顺序是:先用万用表量 VDD 是不是稳定的 3.3V,再确认四根线的顺序,最后在ps2_init()里补一段初始化序列——发0x01 0x43 0x00 0x01 0x00让手柄切到模拟模式,发0x01 0x44 0x00 0x01 0x03 0x00 0x00 0x00 0x00打开震动和压力反馈,再发0x01 0x4D 0x00 0x00 0x5A 0x5A 0x5A 0x5A 0x5A退出原生模式,最后发0x01 0x43 0x00 0x00 0x5A 0x5A 0x5A 0x5A 0x5A把模拟模式锁死。发完之后如果读回的帧头变成0x73,说明已经在模拟模式了。
5.2 现象:摇杆往某个方向推,车突然满速乱窜
原因是数据位错位。PS2 每帧九字节,如果你只读了 8 个字节就拉高 ATT,后面的数据会整体错开一位,摇杆值直接被当成按键位图解析,输出的速度自然是乱的。另一个高频原因是采样边沿搞反——在上升沿采样而不是下降沿,读回来的值在0x00和0xFF之间跳。
排查办法是把九字节原样打印出来,手动推摇杆看哪一字节在变化,如果变化的位置对不上手册的定义,就是错位了;如果某一字节在0x00/0xFF之间剧烈跳变,就是边沿问题。修完之后一定要加一道校验:帧头不是0x5A或0x73就整帧丢弃,宁可这一拍不动,也不要用脏数据去驱动电机。
5.3 现象:电机一转,STM32 就复位或者 PS2 断连
这是电源问题,不是代码问题。电机换向瞬间会在电源线上产生几百毫伏的尖峰,如果电池和主控共用一个薄弱的降压模块,主控的 3.3V 会被拉塌,表现就是复位、死机、串口乱码。地线走线不合理也会造成同样的效果——电机的大电流从信号地回流,地电位被抬起,接收器读到的电平就全乱了。
解决是三条一起上:电机驱动板电源入口并 470μF 电解加 0.1μF 瓷片;电机地和信号地分开走,只在电池负极单点汇合;主控端的 3.3V 上再并一个 10μF 钽电容。做完这三条,九成的复位问题会消失。剩下的一成通常是电机碳刷火花干扰,在电机外壳和引脚之间并一颗 0.1μF 电容即可。
5.4 现象:直线跑偏,转向时一侧明显比另一侧快
根本原因是四个电机的个体差异——同型号同批次的减速电机,空载转速差 5% 很常见,再叠加轮径误差和装配误差,跑三米偏半米是正常现象。这不是 bug,是物理事实。
处理办法分两档。低成本的方案是软件标定:让车在平地上空载跑三米直线,量偏移量,反复调MOTOR_TRIM里的四个系数,直到三米内偏移小于 10cm。这个精度对课设完全够用。要求更高的方案是加编码器做闭环,每个轮子加一个霍尔或光电编码器,用定时器的编码器模式测速,PID 把四轮实际速度拉到一致,跑偏量能压到 2cm 以内。代价是多四个引脚和一套 PID 参数调试,工作量翻倍。方案文档里我一般把两档都写出来,说明“基础版用标定、进阶版用闭环”,答辩时选择空间更大。
5.5 现象:松手后车还在慢慢爬,或者上电就往前冲
前者是死区设小了。摇杆的机械回中精度有限,松手后原始值通常在 124~132 之间漂,死区只给 5 的话,漂到 132 就被当成有效输入。把死区提到 10~12 就能压住,代价是操作手感稍微“钝”一点,但比车自己爬要好得多。如果换了好几个手柄都压不住,就上电采样零点做校准。
后者是没有做解锁条件。上电时如果摇杆正好在中点偏一点的位置,混控立刻输出一个非零占空比,车就窜出去了。回到 2.3 节的状态机,把STATE_IDLE到STATE_RUN的迁移条件写成“摇杆归零且按下 START”,这个问题从架构上就不存在了。
6. 验收与答辩:一张测试表和一条演示路线
方案做完了,最后一步是证明它真的能用。我带的项目里,验收环节翻车的往往不是功能没做出来,而是做出来了但说不清楚、演示时手忙脚乱。所以验收表和演示路线要在动手写代码之前就定好,写完代码按表逐项打勾。
下面这张表是我常用的验收清单,每一项都必须是可量化、可复现的:
| 验收项 | 测试方法 | 合格标准 |
|---|---|---|
| 遥控帧完整率 | 串口统计 1000 帧中的有效帧 | 不低于 99% |
| 摇杆死区 | 松手静置 10 秒,观察占空比输出 | 四轮输出恒为 0 |
| 直线性能 | 平地全速前进 3m,量横向偏移 | 偏移不超过 15cm |
| 原地转向 | 全舵转向 360°,量回转直径 | 直径不超过车长 1.5 倍 |
| 失联保护 | 运行中拔掉接收器电源 | 200ms 内四轮停转 |
| 急停响应 | 运行中按 START | 300ms 内完全停止 |
| 遥控距离 | 空旷室内逐步拉远 | 稳定通信不小于 10m |
| 连续运行 | 满速连续跑 30 分钟 | 驱动板温升不超过 40℃,无复位 |
| 欠压保护 | 用可调电源模拟降压 | 低于阈值进入 FAULT 态 |
测试方法这一列要写死,不能含糊。比如“直线性能”必须写清楚是哪种地面、什么速度、用什么量——我一般用卷尺加粉笔线,起跑线画一条,车头过终点线时量轮子中心到线的垂距,重复三次取最大偏差。这种细节写在文档里,评审一看就知道你是真测过。
答辩演示我固定走一条三分钟的路线,顺序是这样的:先静态展示——把车放在桌上,展示手柄、主控、驱动板和电源分区的接线,用手转一下轮子说明四轮独立驱动;然后做上电自检,让蜂鸣器响一声、状态灯变绿,说明解锁条件;接着低速档走一段 S 形,展示摇杆的线性响应和死区;再切高速档做一次原地转 360°,展示差速效果;最后当着评审的面拔掉接收器电源,让车在 200ms 内停下,再按 START 重新解锁。这条路线把“能开、能转、能保命”三件事全演了一遍,比放一段录像有说服力得多。
讲解的时候重点讲三件事,不要流水账式报菜名。第一件讲分层,输入层、控制层、执行层各自的边界,说明为什么这样切——这样出问题时能三步定位到具体哪一层,这是工程能力的体现。第二件讲取舍,比如为什么选 L298N 而不是更高效的驱动、为什么主循环轮询而不用中断读 PS2、为什么用软件标定而不是上编码器,每个取舍都要给出理由和代价。第三件讲数据,把验收表里的实测值报出来,偏移多少、延迟多少、温升多少,有数字就有底气。
我第一次带这种四电机底盘的时候,代码写得飞快,验收前一天才发现车一开电机主控就复位,连夜拆了重焊电源,最后答辩演示时手都是抖的。从那以后我每次做带电机的板子,都强制先把电源分区和滤波电容画进原理图,再写第一行代码。希望你不用经历那一晚,也希望这套方案能帮你把四电机小车稳稳地开起来。
本文还有配套的精品资源,点击获取