简介:面向嵌入式初学者与电机控制实践者的STM32RCT6+PS2手柄四轮全向轮小车工程,可用于课程设计、毕业设计及DIY移动平台参考,重点解决手柄指令解析、PWM调速与四轮协同运动控制等问题,也可作为学习嵌入式外设驱动与运动控制的实践案例。压缩包共233个文件、约8.28MB,以C源文件和头文件、Keil编译中间文件及工程配置为主,辅以说明文本、批处理脚本和可烧录hex文件,工程源码按模块组织,较完整地保留了从底层驱动到用户应用的目录结构,便于阅读与二次修改。已有747人学习下载,具备一定入门参考价值。读者可据此梳理PS2手柄输入解析、STM32F10x外设库调用、定时器PWM输出以及全向轮平移与原地旋转的实现思路,并借助工程配置和构建脚本复现调试;说明文档与源码注释也有助于理解模块划分和排错路径。
1. PS2手柄接到STM32上,四电机小车的资源到底给到了哪一层
很多人的四电机小车卡在同一个地方:电机能转、PWM 会调,但一旦要遥控,就不知道从哪下手。PS2 手柄是个便宜又好用的选择——它有 12 个按键、两组摇杆、还有震动反馈,接收器出来就是一个 SPI 接口,接 STM32 只要 4 根信号线。这套资源做的事情很具体:把 PS2 接收器的时序、9 字节数据帧的解析、以及四路电机驱动和差速映射全部打通,做成一个能直接烧进去跑的最小工程。适合已经把电机转起来、想给它加一个无线遥控手柄的人,也适合第一次接触“手柄协议解析”这种软硬结合活的同学。它不替你解决机械结构和电源设计,但手柄到轮子这一段通信链路,可以照着复现。
2. 接收器对接STM32:软件SPI时序与9字节帧逐位拆开看
2.1 硬件连线与供电:先把3.3V和共地捋顺
PS2 接收器对外只有 6 个引脚:DAT、CMD、CS、CLK、VCC、GND。名字看起来像标准 SPI,但实际速率和时序都比较“个性”,很多人第一次接上去发现读回来全是 0xFF,八成不是代码问题,是线或者电的问题。
接线按下面这个表来,STM32 侧我用的是 SPI2 对应的引脚,换成别的组也行,模拟 SPI 不挑引脚:
| 接收器引脚 | 方向 | STM32 引脚 | 说明 |
|---|---|---|---|
| DAT | 输入 | PB14 | 手柄发、主控收,对应 MISO |
| CMD | 输出 | PB15 | 主控发、手柄收,对应 MOSI |
| CS | 输出 | PB12 | 片选,低电平有效,也叫 ATT/SEL |
| CLK | 输出 | PB13 | 时钟,空闲时为高电平 |
| VCC | 电源 | 3.3V | 不建议给 5V |
| GND | 地 | GND | 必须和主控同一参考地 |
供电这里有一条血泪经验:接收器的 VCC 尽量走 3.3V,和 STM32 的 IO 电平一致。有些模块标称宽压,但内部电平转换做得很粗糙,5V 供电时 DAT 回来的高电平会超过 3.3V,短时间看不出问题,长期属于慢性损伤。另外接收器静态电流在 20~30mA 量级,别从某个已经带了一堆传感器的 LDO 上分电,电压一塌,现象就是手柄时好时坏。
提示:接收器、主控、电机驱动三者的地必须在一点汇合。电机地和信号地分开走、最后单点连接,比随便拧在一起要稳得多。
2.2 用GPIO模拟SPI:时序只要卡住下降沿和上升沿
PS2 手柄的 SPI 是模式 3:时钟空闲为高,数据在时钟下降沿之后变化,主机在上升沿采样。这个模式下,硬件 SPI 也能用,但配置起来容易和 HAL 库的默认行为打架,而且一旦速率配高了,手柄直接不认。所以常见做法是用两个普通 GPIO 手动模拟,速率自己控,代码也看得见。
/* ps2_softspi.c —— 软件SPI位操作,CS/CLK/CMD 全用普通推挽输出 */ #include "ps2_softspi.h" #define CS_LOW() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET) #define CS_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_15, GPIO_PIN_RESET) #define CMD_HIGH() HAL_GPIO_WritePin(GPIOB, GPIO_PIN_15, GPIO_PIN_SET) #define DAT_READ() HAL_GPIO_ReadPin(GPIOB, GPIO_PIN_14) /* 一次收发8位,MSB先行;时钟空闲高,下降沿换数据,上升沿采样 */ static uint8_t ps2_xfer(uint8_t tx) { uint8_t rx = 0; for (uint8_t i = 0; i < 8; i++) { CLK_LOW(); /* 下降沿:从机把数据摆到DAT上 */ if (tx & 0x80) CMD_HIGH(); /* 主机同时摆好自己的CMD位 */ else CMD_LOW(); tx <<= 1; delay_us(2); /* 半周期2us,整周期约250kHz */ CLK_HIGH(); /* 上升沿:主机采样DAT */ rx <<= 1; if (DAT_READ()) rx |= 0x01; delay_us(2); } return rx; } /* 发一整帧9字节,返回接收到的9字节 */ void ps2_transfer_frame(const uint8_t *tx, uint8_t *rx) { CS_LOW(); delay_us(10); /* CS拉低后必须给足建立时间 */ for (uint8_t i = 0; i < 9; i++) { rx[i] = ps2_xfer(tx[i]); } CS_HIGH(); /* 拉高后帧结束,手柄开始准备下一帧 */ delay_us(20); }这段代码有三个参数值得盯住。半周期delay_us(2)决定了时钟频率约 250kHz,这已经是 PS2 接收器的上限附近,如果手上这根线比较长或者杜邦线质量一般,把它改成 3~4us 会更稳,代价是每帧多花几十微秒,对 50Hz 的遥控刷新率毫无影响。CS_LOW()之后的 10us 是建立时间,省掉它,第一字节常常读成 0xFF。CS_HIGH()之后的 20us 是帧间隔,太短会让手柄认为上一帧没结束,表现为偶发丢包。
延时函数别用HAL_Delay,它的最小粒度是 1ms,一帧 9 字节就是 9ms 起步,刷新率被拖到 100Hz 以下还占着 CPU。用DWT或者一个空循环的delay_us都行,主频 72MHz 时空循环里加__NOP()就能凑出相对准的微秒。
2.3 9字节数据帧:谁是有效位、谁是摇杆
每帧固定 9 个字节,主机发什么、从机回什么,位置是一一对应的。发送侧我一般只关心前三个字节,后面补 0:
| 字节 | 主机发送 | 含义 | 手柄返回 | 含义 |
|---|---|---|---|---|
| 0 | 0x01 | 起始 | 0xFF / 0x01 | 空闲 / 就绪 |
| 1 | 0x42 | 请求按键数据 | 0x41 / 0x73 | 数字模式 / 模拟模式 |
| 2 | 0x00 / 0x01 | 震动马达开关 | 0x5A | 数据有效标志 |
| 3 | 0x00 | 保留 | 按键组 1 | 方向键、START、SELECT 等 |
| 4 | 0x00 | 保留 | 按键组 2 | 四个肩键、四个面键 |
| 5~8 | 0x00 | 保留 | 两组摇杆 | 右X、右Y、左X、左Y |
判断一帧是否可信,只要看rx[2]是不是 0x5A。不是 0x5A,后面 6 个字节全是垃圾,直接丢弃重来,不要拿去算速度——这是新手最容易翻车的地方,把无效帧的随机值当成摇杆偏移,车子会像抽风一样抖。
按键位是低电平有效,按下去为 0:
/* ps2_decode.c —— 把9字节解成结构体,按键全部反相成“按下为1” */ typedef struct { uint8_t lx, ly, rx, ry; /* 左X 左Y 右X 右Y,原始0~255 */ uint16_t keys; /* bit位含义见上表,1表示按下 */ } ps2_state_t; void ps2_decode(const uint8_t *buf, ps2_state_t *out) { if (buf[2] != 0x5A) { /* 有效标志不对,本帧作废 */ out->keys = 0; return; } out->keys = (uint16_t)(~((buf[3] << 8) | buf[4])) & 0xFFFF; out->rx = buf[5]; out->ry = buf[6]; out->lx = buf[7]; out->ly = buf[8]; }两个字节拼成一个 16 位按键字再取反,这样keys & KEY_START直接就是“START 是否按下”,不用每次写if (!(buf[3] & 0x08))这种反逻辑,读代码的人少死一半脑细胞。摇杆原始值是 0~255,中位理论上 128,实际每个手柄都有偏差,后面第 6 章会专门处理。
3. 四路电机怎么分左右:驱动选型、PWM配置与差速映射
3.1 两片TB6612还是两片L298N:四路电机选型对比
四个电机意味着至少四路独立的 H 桥。市面上常见的方案是两片双路驱动芯片并排,选型上 TB6612 和 L298N 是两条主要路线:
| 对比项 | TB6612FNG ×2 | L298N ×2 |
|---|---|---|
| 单路持续电流 | 约 1.2A | 约 2A |
| 峰值电流 | 约 3.2A(短时) | 约 3A |
| 导通压降 | 约 0.5V(MOS 输出) | 约 1.4~2V(达林顿) |
| 逻辑电平 | 3.3V 可直驱 | 5V 逻辑,3.3V 可能识别不稳 |
| 外围器件 | 基本不用,内置 | 需要续流二极管、散热片 |
| 适用场景 | 小型四驱、N20 减速电机 | 大扭矩、低速重载 |
小车轮子用的是 N20 或者 TT 马达,堵转电流在 1A 上下,TB6612 更合适:压降小意味着同样电池电压下轮上功率更多,3.3V 逻辑直连 STM32 也不用加电平转换。L298N 的问题不在能不能用,在于它的 5V 逻辑高电平门限对 3.3V 输出不够友好,有些批次能认、有些批次不认,属于玄学范畴,批量做产品要避开。
两片 TB6612 的接法是把四个轮子分成左前、左后、右前、右后,左右各占一片芯片的两个通道。这样走线短、故障隔离也好——一片烧了,至少还能判断是哪一侧出的问题。
3.2 PWM定时器与方向引脚:一路通道对应一个轮子
STM32 侧我用 TIM3 的四个通道出四路 PWM,频率 10kHz。这个频率低于人耳敏感区上限,电机不会尖叫,同时开关损耗也不至于把驱动芯片烤热。预分频和重装载值的算法是:72MHz / (PSC+1) / (ARR+1) = 10kHz,取 PSC=71、ARR=99,得到 10kHz、100 级分辨率的 PWM。
/* motor.c —— 单个轮子的方向 + 占空比,speed范围 -1000 ~ +1000 */ #include "motor.h" typedef struct { GPIO_TypeDef *port; uint16_t in1, in2; /* 方向引脚 */ TIM_HandleTypeDef *htim; uint32_t ch; /* 定时器通道 */ } motor_t; static motor_t motors[4] = { {GPIOA, GPIO_PIN_0, GPIO_PIN_1, &htim3, TIM_CHANNEL_1}, /* 左前 */ {GPIOA, GPIO_PIN_2, GPIO_PIN_3, &htim3, TIM_CHANNEL_2}, /* 左后 */ {GPIOA, GPIO_PIN_4, GPIO_PIN_5, &htim3, TIM_CHANNEL_3}, /* 右前 */ {GPIOA, GPIO_PIN_6, GPIO_PIN_7, &htim3, TIM_CHANNEL_4}, /* 右后 */ }; void motor_set(uint8_t idx, int16_t speed) { if (speed > 1000) speed = 1000; if (speed < -1000) speed = -1000; motor_t *m = &motors[idx]; if (speed > 0) { /* 正转 */ HAL_GPIO_WritePin(m->port, m->in1, GPIO_PIN_SET); HAL_GPIO_WritePin(m->port, m->in2, GPIO_PIN_RESET); } else if (speed < 0) { /* 反转 */ HAL_GPIO_WritePin(m->port, m->in1, GPIO_PIN_RESET); HAL_GPIO_WritePin(m->port, m->in2, GPIO_PIN_SET); } else { /* 滑行停,不是刹车 */ HAL_GPIO_WritePin(m->port, m->in1, GPIO_PIN_RESET); HAL_GPIO_WritePin(m->port, m->in2, GPIO_PIN_RESET); } /* 占空比按 0~1000 映射到 0~ARR,ARR=99 时要再换算 */ uint32_t compare = (uint32_t)((abs(speed) * 100) / 1000); __HAL_TIM_SET_COMPARE(m->htim, m->ch, compare); }这里有个容易忽略的细节:占空比写的是abs(speed) * 100 / 1000,因为 ARR 设的是 99,满量程对应 100 个计数。如果直接把 0~1000 塞进SET_COMPARE,超过 99 的部分会被硬件截断,表现就是“摇杆推到底和推到一半速度一样”——这类 bug 查起来特别费时间,因为现象不像 bug,像电机没劲。
另外 TB6612 的 STBY 引脚必须拉高才有输出,两片都要拉,接同一个 GPIO 就行。忘了它,代码全对、电机全不动,这是四电机小车最高频的“翻车”现场。
3.3 摇杆到四路PWM:差速映射与限幅
四驱小车的转向靠左右两侧速度差。我一般把左摇杆 Y 轴当油门,右摇杆 X 轴当转向,两个量分别映射到 -1000~1000,然后做加减:
/* control.c —— 差速混控:油门和转向合成左右两侧速度 */ #define PWM_MAX 1000 void chassis_drive(int16_t throttle, int16_t steer) { /* steer 为正表示右转:左侧加速、右侧减速 */ int16_t v_left = throttle + steer; int16_t v_right = throttle - steer; /* 限幅:任一侧超过上限时按比例缩放,保持转向比例不失真 */ int16_t peak = 0; if (abs(v_left) > peak) peak = abs(v_left); if (abs(v_right) > peak) peak = abs(v_right); if (peak > PWM_MAX) { v_left = (int16_t)((int32_t)v_left * PWM_MAX / peak); v_right = (int16_t)((int32_t)v_right * PWM_MAX / peak); } motor_set(0, v_left); /* 左前 */ motor_set(1, v_left); /* 左后 */ motor_set(2, v_right); /* 右前 */ motor_set(3, v_right); /* 右后 */ }限幅这一段很多人写成简单的if (v > 1000) v = 1000,结果是摇杆推到斜角时,超限的一侧被钉死在 1000,没超限的那一侧还在继续变,转向比例被压扁,车会突然变得“很直”。按峰值等比缩放,两个轮子的速度比保持不变,手感线性得多。这里用int32_t强转是防止乘法溢出——1000 * 1000已经超出int16_t范围了。
4. 把三段代码串成可跑工程:初始化、轮询、执行
4.1 上电初始化:先进配置模式再切模拟模式
手柄上电默认可能是数字模式,这个模式下只回按键、不回摇杆。要拿到摇杆数据,必须发一串配置命令把它切到模拟模式,同时决定要不要开震动。这段序列是整个资源里最容易抄错的部分,因为网上的版本字节顺序有出入,按下面这组来:
/* ps2_init.c —— 三条命令把接收器切到模拟模式并开启震动反馈 */ static const uint8_t cmd_enter_cfg[9] = {0x01,0x43,0x00,0x01,0x00,0x00,0x00,0x00,0x00}; static const uint8_t cmd_set_mode[9] = {0x01,0x44,0x00,0x01,0x03,0x00,0x00,0x00,0x00}; static const uint8_t cmd_exit_cfg[9] = {0x01,0x43,0x00,0x00,0x5A,0x5A,0x5A,0x5A,0x5A}; uint8_t ps2_init(void) { uint8_t buf[9]; HAL_Delay(500); /* 手柄上电自检需要时间 */ ps2_transfer_frame(cmd_enter_cfg, buf); /* 第一步:进配置模式 */ HAL_Delay(10); ps2_transfer_frame(cmd_set_mode, buf); /* 第二步:0x03 = 模拟+震动 */ HAL_Delay(10); ps2_transfer_frame(cmd_exit_cfg, buf); /* 第三步:退出配置 */ HAL_Delay(10); /* 验证:正常轮询一帧,看模式字节是不是 0x73(模拟模式) */ const uint8_t poll[9] = {0x01,0x42,0x00,0x00,0x00,0x00,0x00,0x00,0x00}; ps2_transfer_frame(poll, buf); return (buf[1] == 0x73) ? 1 : 0; }cmd_set_mode里第四个字节的0x03是模式位:低位表示模拟模式,次低位表示允许震动。如果只想省电、不要震动,把它改成0x01即可,但注意有些手柄在纯模拟模式下按键映射会有细微差异,我一般保留0x03,震动马达不主动开就行。三个HAL_Delay(10)不能省,接收器内部在处理配置命令时需要时间,连发三条会导致后一条覆盖前一条,最后停在数字模式,现象就是摇杆读数永远是 0x00。
4.2 主循环:读帧、解包、算速度、下发
主循环的节奏建议定在 50Hz,也就是 20ms 一轮。太快没意义,手柄本身的采样率就有限;太慢遥控手感发飘。用HAL_GetTick()做非阻塞定时,别用HAL_Delay(20)把整个 CPU 挂住,否则串口调试、按键扫描都没法插进来。
/* main.c —— 50Hz 主循环 */ ps2_state_t pad; uint32_t last_tick = 0; while (1) { if (HAL_GetTick() - last_tick < 20) continue; last_tick = HAL_GetTick(); uint8_t frame[9]; const uint8_t poll[9] = {0x01,0x42,0x00,0x00,0x00,0x00,0x00,0x00,0x00}; ps2_transfer_frame(poll, frame); ps2_decode(frame, &pad); if (pad.keys & KEY_START) { /* START 当急停 */ chassis_drive(0, 0); continue; } int16_t throttle = axis_to_speed(pad.ly, 128, 12); /* 左Y:油门 */ int16_t steer = axis_to_speed(pad.rx, 128, 12); /* 右X:转向 */ chassis_drive(throttle, steer); }axis_to_speed是把 0~255 的原始值转成 -1000~1000 的函数,第 6 章会展开。这里先记住两个参数:中位值 128 和死区 12。中位值不是所有手柄都是 128,有的在 125 附近,硬编码 128 会出现“松手后车还慢慢往前爬”的现象;死区 12 是为了滤掉摇杆回中时的抖动,太小会抖,太大会导致小幅度操作没反应,12 是个比较折中的起点。
把 START 键做成急停是习惯问题,但四驱小车推力不小,建议保留。真出问题时,手比代码快。
4.3 参数表与串口自检
调这类东西,串口输出比示波器还管用。把解析结果按固定格式打出来,一眼就能看出是帧无效、按键错位还是摇杆漂移:
| 参数 | 建议值 | 调整依据 |
|---|---|---|
| 半周期延时 | 2us | 线长超过 20cm 改 3~4us |
| CS 建立时间 | 10us | 首字节读成 0xFF 就加大 |
| 帧间隔 | 20us | 偶发丢帧就加到 50us |
| 轮询周期 | 20ms | 手感发飘改 10ms,抖动改 30ms |
| 摇杆中位 | 实测标定 | 松手后读 20 帧取平均 |
| 死区 | 10~15 | 静止时轮子微动就加大 |
| PWM 频率 | 10kHz | 电机啸叫改 20kHz |
/* 每 500ms 打一行,格式固定,方便肉眼比对 */ printf("[ps2] mode=%02X ack=%02X keys=%04X lx=%3d ly=%3d rx=%3d ry=%3d\r\n", frame[1], frame[2], pad.keys, pad.lx, pad.ly, pad.rx, pad.ry);正常的一行长这样:mode=73 ack=5A keys=FFFF lx=128 ly=127 rx=130 ry=125。mode=73说明模拟模式已生效,ack=5A说明本帧有效,keys=FFFF表示没有按键按下(反相后的结果)。如果mode是 41,说明还停在数字模式,回到初始化那一步查;如果ack不是 5A 而是一直是 FF,问题在时序或者接线,不在解析代码。
5. 避坑排查:手柄没反应、按键乱跳、电机不转的五种现场
5.1 手柄完全没反应,串口一直打 ack=FF
现象:初始化返回 0,轮询帧的buf[1]是 0xFF 或 0x00,buf[2]永远不是 0x5A。
原因:接收器根本没进入通信状态。常见来源有三个——CS 拉低后没有留建立时间,第一字节被吞;时钟频率超过接收器上限,波形还没稳定就被采样;接收器供电不足,内部复位。
解决:先把半周期延时从 2us 加到 5us,把 CS 建立时间从 10us 加到 50us,这两步能救回大部分“完全不响应”的情况。还不行就量 VCC,空载和插上手柄时各量一次,跌落超过 0.2V 就换供电。最后确认 GND 是真的连上了,杜邦线断在胶壳里这种事,我遇到过不止一次。
5.2 能读出数据,但按键乱跳、摇杆值满量程乱窜
现象:ack偶尔是 0x5A,但按键字频繁变化,摇杆读数在 0 和 255 之间跳。
原因:SPI 采样点错位。模式 3 要求上升沿采样,如果代码写成了下降沿采样,读回来的每一位都偏移一个时钟,数据看起来“有”但全是错的。
解决:对照 2.2 的ps2_xfer,确认顺序是“拉低时钟→摆数据→延时→拉高时钟→采样”。另外注意 CMD 引脚的输出速度配置,配成GPIO_SPEED_FREQ_LOW时上升沿会变缓,在 250kHz 下勉强能用,配成VERY_HIGH反而可能引入过冲。我一般用MEDIUM。
5.3 电机一转,手柄立刻掉线
现象:静态调试一切正常,轮子一给 PWM,串口ack变成 FF,几秒后恢复。
原因:电机换向产生的干扰通过电源和地串进接收器,或者电池电压被拉低导致接收器复位。四驱小车四个电机同时启动,瞬时电流能到 3A 以上。
解决:三件事按顺序做。电机两端各并一个 0.1uF 陶瓷电容,尽量靠近电机焊;接收器 VCC 到 GND 之间加 10uF 钽电容加 0.1uF 瓷片;电源走线改成电池先到驱动、再从驱动处单独拉一路给主控。做完这三步还有问题,就把 PWM 频率从 10kHz 降到 1kHz 试试,开关次数少,干扰也小。
5.4 摇杆松手后车还在慢慢走
现象:不碰手柄,轮子以很低的速度持续转动。
原因:摇杆中位不是理论上的 128,而是 125 或者 131,映射后得到一个很小的非零速度。这是手柄的个体差异,不是 bug。
解决:上电时做一次静态标定,连续读 20 帧,每轴取平均值当零点,存到变量里后续都用它。标定期间别碰摇杆,我一般放在上电后的第一个 1 秒内做,同时点一颗 LED 提示。
5.5 代码逻辑都对,电机就是不转
现象:串口打印的速度值正常,motor_set也被调用了,轮子一动不动。
原因:按出现频率排——TB6612 的 STBY 没拉高;定时器HAL_TIM_PWM_Start没调用或者只启动了一个通道;方向引脚和 PWM 通道对应错了(比如把左前接到了 CH2 上);电机线插在了同一片芯片的两个通道上导致共地冲突。
解决:万用表量 STBY 电平,量 PWM 引脚在速度非零时有没有电压(平均值法,占空比 50% 时约 1.65V)。都没有问题,就把四个轮子单独写一个测试函数,一个一个点,确认硬件通路,再回去查逻辑。这种时候别改代码,先证明硬件是好的。逻辑错误和硬件错误混在一起查,是最耗时间的。
6. 进阶技巧:死区标定、速度斜坡和摇杆归一化
把基础版本跑通之后,手感上的差距基本来自三个地方:中位标定、死区处理、速度斜坡。
先看归一化函数,它同时干三件事——减中位、去死区、映射到 -1000~1000:
/* 原始0~255 → -1000~1000,中位附近留死区,两端做限幅 */ int16_t axis_to_speed(uint8_t raw, int16_t center, int16_t deadzone) { int16_t delta = (int16_t)raw - center; if (delta > -deadzone && delta < deadzone) return 0; /* 死区内归零 */ int16_t span = (delta > 0) ? (255 - center) : center; /* 两侧量程不同 */ if (span <= 0) return 0; int32_t out = (int32_t)delta * 1000 / span; if (out > 1000) out = 1000; if (out < -1000) out = -1000; return (int16_t)out; }注意span是按正负两侧分别算的,因为中位不一定在 128,往上到 255 和往下到 0 的距离不相等。用统一的 127 去除,会出现“往前推到底只有 0.9,往后拉到底却是 1.0”的不对称,小车上表现为前进比后退慢一截。
第二个是速度斜坡。直接把目标速度赋给 PWM,起步瞬间电流冲击大,轮胎会打滑,齿轮也会叫:
/* 每 20ms 调用一次,限制速度变化率,斜坡时间约 200ms */ int16_t ramp_step(int16_t current, int16_t target, int16_t step) { if (target > current + step) return current + step; if (target < current - step) return current - step; return target; }step取 100 时,从 0 到满速需要 10 个周期也就是 200ms,手感上是一个“有推背感但不突兀”的起步。这个值我调过很多次,低于 60 会显得肉,高于 200 又会顿挫,100 是个不错的默认。左右两侧分别做斜坡,转向时还能顺带获得一点柔化效果。
最后是死区标定的完整流程:上电后前 1 秒,循环读 20 帧,每轴累加求平均得到center_x/center_y,同时记录这 20 帧里的最大最小值,两者之差的一半再加 3 就是死区。这样做的好处是每个手柄都能自适应,不用为不同批次改代码。震动马达如果能不开就不开,它会拉低接收器电压,间接影响摇杆读数的稳定性。
从那以后我每次接新的 PS2 接收器,都强制先跑一遍“只读不驱动”的空转测试:串口连着看 5 分钟,确认ack恒为 5A、摇杆静止时数值不跳,再上电机。这五分钟能省掉后面一堆“到底是代码问题还是接触不良”的反复怀疑。希望帮到你。
本文还有配套的精品资源,点击获取