简介:面向嵌入式控制学习者的PS2手柄四轮全向轮小车工程实例,围绕STM32RCT6展开,重点解决手柄输入解析、PWM电机调速与四轮全向平台运动控制的整合问题,适合具备一定单片机基础、希望理解游戏控制器接口与多电机协同的读者参考。压缩包共233个文件,约8.28MB,以C源码与H头文件为主体,配合O目标文件、CRF交叉引用文件及D依赖文件,另有Keil工程配置、批处理脚本和说明文档,便于梳理编译产物与底层驱动结构。已有747人学习,说明该案例在嵌入式小车控制方向具有一定参考价值。工程内容覆盖PS2手柄信号读取、BSP与USER模块分层、固件库调用以及全向轮独立调速思路,读者可据此对照理解输入解析到电机输出的完整链路,并借鉴其目录组织与构建清理方式,用于课程设计、毕业设计或同类小车项目的方案比对与排错参考。
1. 手柄遥控四轮小车:把 PS2 摇杆映射成四个轮子的转速
给小车加遥控,很多人第一反应是手机 App:蓝牙配对、画个摇杆控件、再处理断连重连,半天就没了。换上 PS2 手柄,一个九针接收器插上去,摇杆的模拟量直接就能读,按键还额外提供十几个,手感是现成的。这套基于 STM32 的 PS2 四电机小车方案,核心就三件事:用软件 SPI 把接收器里那 9 个字节读出来,把摇杆坐标换算成左右两侧的差速值,再用定时器输出四路 PWM 去推电机。它适合刚点完灯、想找一个能真正跑起来的项目练手的人,也适合拿它当课程设计底座——后面加编码器闭环、加 OLED、加循迹都不会打架,不用推倒重来。
2. 硬件与接线:接收器七根线怎么落到最小系统板上
2.1 物料清单与引脚分配表
先把东西配齐,别到焊的时候才发现少一根杜邦线。这套配置不需要额外的电平转换芯片,3.3V 逻辑从头走到尾,接线复杂度基本等于“点灯 + 定时器输出”。
| 器件 | 规格 | 作用 | 备注 |
|---|---|---|---|
| 主控 | STM32F103C8T6 最小系统板 | 采样手柄、算差速、出 PWM | 72MHz,TIM2 够用 |
| 手柄 | PS2 有线手柄 + 九针接收器 | 输入摇杆与按键 | 接收器要带 ACK 引脚 |
| 驱动 | L298N 双路模块 | 左右各推一组电机 | 每路并两个直流减速电机 |
| 电机 | TT 马达 ×4(带轮子) | 四轮驱动 | 减速比选 1:48 以上 |
| 电源 | 2 节 18650 串联 7.4V | 给电机和板子供电 | 加一片 5V 降压给逻辑 |
| 杂项 | 1000μF 电解、0.1μF 陶瓷、10kΩ 电阻 | 滤波与上拉 | 上拉非常关键 |
引脚分配按下面这张表走,CubeMX 里照着配,后面代码的宏定义也能直接对上。
| 信号 | STM32 引脚 | 方向 | 说明 |
|---|---|---|---|
| PS2_DAT | PA6 | 输入 | 接收器发、单片机收,需要 10k 上拉 |
| PS2_CMD | PA7 | 输出 | 单片机发、接收器收 |
| PS2_CLK | PA5 | 输出 | 主机时钟,空闲高电平 |
| PS2_SEL | PA4 | 输出 | 片选,低有效 |
| PS2_ACK | PA3 | 输入 | 低电平表示接收器在线 |
| PWMA | PA0 / TIM2_CH1 | 输出 | 左路调速 |
| PWMB | PA1 / TIM2_CH2 | 输出 | 右路调速 |
| AIN1/AIN2 | PB0 / PB1 | 输出 | 左路方向 |
| BIN1/BIN2 | PB12 / PB13 | 输出 | 右路方向 |
这里有个取舍要讲清楚:四电机不一定意味着四路独立 PWM。TT 马达功率小,左右各并联两个,用一片 L298N 的两路输出就够。好处是代码里只有左右两个速度量,差速模型简单到一页纸;代价是同侧两个轮子转速无法独立补偿,直线会轻微跑偏。如果要做原地旋转、四轮独立矢量控制,那就得上两片 L298N 或者 TB6612 双片方案,把四路 PWM 全开。新手先把两路跑通,别一上来就四路,调方向能调到你怀疑人生。
2.2 接收器接线:丝印对不上就按功能对
PS2 接收器的九针排列各家不一,有的模块把 VCC 放第 3 脚,有的放第 5 脚,不要背口诀,直接看板上丝印:DAT、CMD、SEL(有的标 ATT)、CLK、VDD、GND、ACK、VIB。VIB 是振动马达,接不接都行,接了也只能在代码里开关,实际用起来意义不大,先空着。
接的时候注意两件事:一是 VDD 一定接 3.3V,不要接 5V。接收器本身能耐受 5V,但它的数据输出会被拉到 5V 附近,而 STM32 的 PA6 不是 5V 容忍脚,长期这么接迟早出事。二是 DAT 线上必须挂一个 10k 上拉到 3.3V,有些接收器内部没有上拉,线一长读回来全是 0xFF,这个坑很玄学,示波器不抓根本看不出来。
ACK 引脚建议接上,虽然软件里可以不用它,但调试阶段拿万用表量一下——上电后 ACK 被拉低,说明接收器活着;如果一直是高电平,要么没供电,要么手柄没配对成功(手柄和接收器出厂是一对一的,别拿两个不同套的混用)。
2.3 电机侧接线:先架空,再落地
L298N 的输出端 OUT1/OUT2 接左组两个电机并联,OUT3/OUT4 接右组。并联时注意极性要一致:两个电机的红线都接 OUT1,黑线都接 OUT2,这样给正转信号时两个轮子朝同一方向转。如果并联时一正一反,通电瞬间就是两边打架,电流翻倍,模块发烫。
ENA、ENB 上的跳帽必须拔掉,那是把使能端直接短到 5V 的,拔掉之后才能用 PWM 调速。拔掉跳帽后用杜邦线把 ENA 接 PA0、ENB 接 PA1。L298N 板载的 5V 输出可以给最小系统板供电,但只建议在调试时这么干——电机启动瞬间的电流塌陷会顺着这根线灌到 MCU,复位、丢帧、串口乱码都可能是它引起的。正式跑车时逻辑电源单独走一路降压,两条地线在电源负极处单点汇合,共地但不要共路径。
电机线接反的概率非常高,因为左轮和右轮在车体上是镜像安装的,物理上转同一个方向,电气上要给的极性是反的。所以第一次上电千万别让轮子着地,架空起来,给一个 +30% 的固定占空比,看四个轮子是不是都朝前转,哪个反了就地调线,别等到装好了再拆。
2.4 上电时序与电源处理
接收器上电到能正常响应,中间有几百毫秒的初始化时间,这段时间里发什么它都不理,读回来是 0xFF。初始化函数里必须加一个 500ms 的 HAL_Delay,等它稳定下来再进配置流程。这一条我吃过亏:调试时按复位键太快,十次里有三次读不到手柄,一度以为是 SPI 时序写错了,折腾了半天。
电源部分,L298N 的电源输入端并一个 1000μF 电解电容和一个 0.1μF 陶瓷电容,前者吸收电机启停的大电流波动,后者滤高频。电机线尽量短、双绞,远离手柄的四根信号线。这些不是玄学,是四电机小车能不能稳定跑完一圈的分界线。
3. 软件架构:从摇杆原始值到四路 PWM 的数据链路
3.1 三层结构:bsp_ps2 / motor / app_control
工程不要写成一个大 main.c,四电机小车看着简单,一旦要加档位、加调试输出,全塞一起就没法维护了。按职责拆三层:ps2.c只负责通信和解析,对外吐一个状态结构体;motor.c只负责把左右两个速度量翻译成方向和占空比;app_control.c夹在中间,做摇杆映射、死区、限幅。目录大概是这个样子:
ps2_car/ ├── Core/ │ ├── Src/ │ │ ├── main.c # 初始化、主循环、20ms 调度 │ │ ├── app_control.c # 摇杆 -> 差速 -> 目标速度 │ │ ├── motor.c # 方向引脚 + PWM 输出 │ │ ├── ps2.c # 软件 SPI 收发、帧解析、配置模式 │ │ └── stm32f1xx_it.c │ └── Inc/ │ ├── app_control.h │ ├── motor.h │ └── ps2.h └── Drivers/ # HAL 库与启动文件这样分层的好处是改一处不影响别处。想换成四路独立驱动,只改motor.c里的motor_set(),上层一行不动;想换成蓝牙或 Wi-Fi 输入,只换ps2.c,映射逻辑照样复用。手柄状态结构体建议这么定义,字段和接收器返回的字节一一对应:
/* ps2.h:一帧解析后的手柄状态 */ typedef struct { uint8_t btn1; /* bit0~7: Select/L3/R3/Start/Up/Right/Down/Left */ uint8_t btn2; /* bit0~7: L2/R2/L1/R1/Triangle/Circle/Cross/Square */ uint8_t rx, ry; /* 右摇杆,0~255,中值约 128 */ uint8_t lx, ly; /* 左摇杆,0~255,中值约 128 */ uint8_t online; /* 1 = 最近一帧校验通过 */ } ps2_state_t;按键用位表示,判断一个键按没按就是st.btn1 & (1 << 3),比定义一堆 bool 变量省事得多,后面做按键边沿检测也方便。
3.2 一次控制循环的时序
主循环的节奏是 20ms 一轮,也就是 50Hz。为什么是 20ms?手柄本身刷新率大概在 60Hz 左右,读太快没有新数据,读太慢操作会滞后。20ms 刚好卡在中间,既能跟上人的反应,又不会让 PWM 更新过于频繁。
一轮循环里做的事按顺序是:读一帧手柄数据,校验失败就立即停车;校验通过就把摇杆原始值过死区、映射成前进量和转向量;再做差速合成和限幅;最后写进 TIM2 的比较寄存器。这中间任何一步失败都不能让车继续按上一帧的速度跑,丢帧必须停车,这是最基本的失效安全。
严格说,HAL_Delay(20)是阻塞延时,这段时间 MCU 什么都不干,按键响应会有延迟。进阶做法是开一个 TIM3 的 20ms 中断,中断里置标志位,主循环轮询标志;或者干脆把读手柄和算差速都放进中断,主循环只处理调试输出。新手先用阻塞版本把逻辑跑通,等发现按键响应迟钝了再升级,不必一开始就上中断。
3.3 差速模型与限幅
摇杆映射这块,核心是把二维输入压成一维差速。左摇杆上下给前进后退量(throttle),右摇杆左右给转向量(yaw),然后左轮速度 = throttle + yaw,右轮速度 = throttle - yaw。原地打转就是 throttle 为 0、yaw 拉满,左右轮一正一反;边走边转就是两个量叠加。
麻烦在于叠加会溢出。throttle 拉满 1000、yaw 再拉满 1000,左轮算出 2000,直接超出 PWM 量程。所以最后必须做等比限幅:取两个目标值里绝对值大的那个,如果超过量程,就按比例把两个值同时缩下来。这样保证左右轮的速度比例不变,转向手感才不会在满速时突然变怪。常见的错误做法是直接对每个值单独截断到 ±1000,结果就是满速转向时内侧轮被削成 0,车直接走直线,操控感全没了。
3.4 档位缓存与状态保持
手柄上的按键可以用来切档,1 到 4 档对应 25%、50%、75%、100% 的速度系数。档位这个状态不能放在app_control()的局部变量里,否则每次调用都重置。用一个静态全局或者一个car_state_t结构体存起来,主循环里只改不重建。
状态结构体里还可以塞进去上次的按键值,用来做边沿检测——只在“这帧按下、上帧没按”的那一次切档,否则按住不放会以 50Hz 的速度疯狂切档,实际表现就是档位乱跳。这个坑在按键类功能里非常典型,任何“按一下切一次”的逻辑都必须配边沿检测。
4. 核心代码拆解:软件 SPI 读帧、配置模式与电机输出
4.1 一位一位收发:PS2 的时序要点
PS2 接收器的通信是同步串行,协议类似 SPI 但又不是标准 SPI:数据低位先出(LSB first),时钟空闲为高,主机在时钟低电平期间准备 CMD 并采样 DAT。所以直接用硬件 SPI 外设反而不顺手,用 GPIO 模拟更直观,时序想调就调。
/* ps2.c:GPIO 模拟时序,一次收发一个字节 */ static uint8_t ps2_transfer(uint8_t tx) { uint8_t rx = 0; for (uint8_t i = 0; i < 8; i++) { /* 1. 先把要发的位摆到 CMD 上(低位先出) */ if (tx & 0x01) PS2_CMD_HIGH(); else PS2_CMD_LOW(); tx >>= 1; /* 2. 时钟拉低,给从机时间把数据放到 DAT 上 */ PS2_CLK_LOW(); delay_us(5); /* 3. 在低电平末尾采样 DAT,拼到接收字节里 */ if (PS2_DAT_READ()) rx |= (uint8_t)(1 << i); /* 4. 时钟拉高,完成一位 */ PS2_CLK_HIGH(); delay_us(5); } return rx; }delay_us(5)这个参数是留余量的,接收器手册里要求的时钟周期在微秒级,5μs 半周期对应 100kHz 左右,足够稳。但要注意 HAL 库的HAL_Delay最小分辨率是 1ms,微秒延时得自己用 DWT 计数器或者空循环实现,空循环在 72MHz 下要按编译优化等级实测校准,不然-O2和-O0出来的时序差一倍,调试版能跑、发布版掉线就是这么来的。
4.2 整帧读取与双帧头校验
一帧是 9 个字节。主机发01 42 00 00 00 00 00 00 00,接收器回FF 5A 5A 按键1 按键2 右X 右Y 左X 左Y。第一个字节是前导,第二、三字节是帧头,必须都是 0x5A(个别兼容手柄回 0x73 或 0x79),不匹配就说明这一帧是废的。
/* ps2.c:读一帧并做校验 */ uint8_t ps2_poll(ps2_state_t *st) { uint8_t cmd[9] = {0x01, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t buf[9]; PS2_SEL_LOW(); delay_us(10); for (uint8_t i = 0; i < 9; i++) buf[i] = ps2_transfer(cmd[i]); PS2_SEL_HIGH(); delay_us(10); /* 帧头校验:0x5A 是标准值,0x73/0x79 是兼容手柄 */ if (buf[1] != 0x5A && buf[1] != 0x73 && buf[1] != 0x79) { st->online = 0; return 0; } if (buf[2] != 0x5A && buf[2] != 0x73 && buf[2] != 0x79) { st->online = 0; return 0; } st->btn1 = buf[3]; st->btn2 = buf[4]; st->rx = buf[5]; st->ry = buf[6]; st->lx = buf[7]; st->ly = buf[8]; st->online = 1; return 1; }索引别写错,错一位就是“推前进结果车在原地转”这种诡异现象。校验只用buf[1]一个字节也能跑,但双帧头更保险,尤其在电机干扰大的时候,单字节碰巧蒙对的概率不低。
4.3 配置模式:让手柄进模拟量模式
手柄默认不一定工作在模拟模式,红灯闪烁通常就是在等你配置。上电后按顺序发三条命令:进配置、设模拟模式、退配置。设完之后红灯常亮,摇杆才有 0~255 的连续量。
/* ps2.c:上电配置流程,必须在初始化时跑一次 */ static void ps2_config(void) { uint8_t buf[9]; /* 进入配置模式 */ uint8_t enter[9] = {0x01, 0x43, 0x00, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00}; PS2_SEL_LOW(); delay_us(10); for (uint8_t i = 0; i < 9; i++) buf[i] = ps2_transfer(enter[i]); PS2_SEL_HIGH(); /* 设置模式:0x03 表示模拟模式,摇杆与按键全部使能 */ uint8_t analog[9] = {0x01, 0x44, 0x00, 0x01, 0x03, 0x00, 0x00, 0x00, 0x00}; PS2_SEL_LOW(); delay_us(10); for (uint8_t i = 0; i < 9; i++) buf[i] = ps2_transfer(analog[i]); PS2_SEL_HIGH(); /* 退出配置,后面的 0x5A 是固定填充值 */ uint8_t exit[9] = {0x01, 0x43, 0x00, 0x00, 0x5A, 0x5A, 0x5A, 0x5A, 0x5A}; PS2_SEL_LOW(); delay_us(10); for (uint8_t i = 0; i < 9; i++) buf[i] = ps2_transfer(exit[i]); PS2_SEL_HIGH(); }0x44命令里第五个字节是模式值:0x00 是数字模式(只有按键没有摇杆),0x01 是模拟模式,0x03 是全模拟。想省电或者只要按键,改成 0x01 就行。配置完成后再去读帧,摇杆字节才会有意义,否则一直是 0x00 或者 0xFF。
4.4 电机驱动层:方向加占空比
motor_set()是整个工程里最该保持简单的函数,上层给它左右两个带符号的速度量,它负责翻方向、取绝对值、写比较寄存器。所有限幅都在上层做完,这一层只做最基础的饱和保护。
/* motor.c:左右速度量 -> 方向引脚 + PWM 比较值 */ void motor_set(int16_t left, int16_t right) { /* 饱和保护,参数超范围时不至于反向输出 */ if (left > 1000) left = 1000; if (left < -1000) left = -1000; if (right > 1000) right = 1000; if (right < -1000) right = -1000; /* 左路:正转 AIN1 高,反转 AIN2 高 */ if (left >= 0) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_RESET); } else { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_0, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, GPIO_PIN_SET); left = -left; } __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, (uint16_t)left); /* 右路:电机镜像安装,方向定义与左路一致,接线时调线解决 */ if (right >= 0) { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_SET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, GPIO_PIN_RESET); } else { HAL_GPIO_WritePin(GPIOB, GPIO_PIN_12, GPIO_PIN_RESET); HAL_GPIO_WritePin(GPIOB, GPIO_PIN_13, GPIO_PIN_SET); right = -right; } __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, (uint16_t)right); }PWM 参数在 CubeMX 里配:TIM2 时钟 72MHz,预分频 0,周期寄存器 3599,得到 20kHz。这个频率高于人耳听感上限,电机不会发出啸叫;相比 1kHz 又能明显减小低速时的抖动。比较寄存器的有效范围是 0 到 3599,但上层速度量用的是 ±1000,映射关系是compare = speed * 3599 / 1000,代码里为了省除法直接用了 1000 作为满量程近似,实际对应 27.7% 到 100% 的占空比压缩,起步会更柔和一点。
4.5 差速合成与主循环
映射函数把两个摇杆轴转成四个轮子的目标速度,中间要过死区,避免摇杆回中不准导致车慢慢爬。
/* app_control.c:摇杆原始值 -> 左右目标速度 */ #define DEADZONE 12 static int16_t axis_map(uint8_t raw) { int16_t v = (int16_t)raw - 128; /* 0~255 映射到 -128~127 */ if (v > -DEADZONE && v < DEADZONE) v = 0; /* 中位死区,防止漂移 */ return v; } void app_control(const ps2_state_t *st, uint8_t level) { int32_t thr = (int32_t)axis_map(st->ly) * 1000 / 128; /* 左摇杆上下 */ int32_t yaw = (int32_t)axis_map(st->rx) * 1000 / 128; /* 右摇杆左右 */ /* 档位系数:level 1~4,对应 25%/50%/75%/100% */ thr = thr * level / 4; yaw = yaw * level / 4; /* 差速合成 */ int32_t l = thr + yaw; int32_t r = thr - yaw; /* 等比限幅:保比例,不单独截断 */ int32_t peak = (l > 0 ? l : -l); if ((r > 0 ? r : -r) > peak) peak = (r > 0 ? r : -r); if (peak > 1000) { l = l * 1000 / peak; r = r * 1000 / peak; } motor_set((int16_t)l, (int16_t)r); }主循环把这几个模块串起来,读帧失败就停车,这一条是四条电机线之外最重要的安全逻辑:
/* main.c:20ms 一轮的控制循环 */ int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM2_Init(); /* 20kHz PWM */ HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_2); HAL_Delay(500); /* 等接收器上电稳定 */ ps2_config(); /* 进配置 -> 模拟模式 -> 退配置 */ motor_set(0, 0); uint8_t level = 2; /* 默认 50% 档 */ ps2_state_t st; while (1) { if (ps2_poll(&st)) { app_control(&st, level); } else { motor_set(0, 0); /* 丢帧必须停车,别让车按旧值继续跑 */ } HAL_Delay(20); } }HAL_Delay(500)这个值不是拍脑袋定的,接收器从上电到能应答,实测在 200 到 400ms 之间,取 500 留足余量。如果发现上电后头几帧读不到,先把这个延时加大试试,比怀疑时序要快得多。
5. 避坑与排查:红灯闪、轮子反转、一动就掉线
5.1 通信类:读回 0xFF 与红灯闪烁
现象:上电后读到的 9 个字节全是 0xFF 或者全是 0x00,接收器红灯持续闪烁,摇杆怎么推都没反应。
原因:两种情况最常见。一是没跑配置流程,手柄停在数字模式,摇杆字节恒为 0;二是 DAT 线上没有上拉电阻,长杜邦线下分布电容把信号拖平,MCU 读到的是浮空电平。还有个隐蔽原因是上电延时不够,接收器还在初始化就发了配置命令,命令被丢掉。
解决:先确认红灯状态——常亮说明已进模拟模式,闪烁说明配置没生效,把ps2_config()里的三条命令逐条加打印确认;DAT 加 10k 上拉到 3.3V;上电延时从 500ms 开始试,不行加到 1000ms。另外用万用表量一下 ACK 脚,配置成功的帧里 ACK 会有低电平脉冲,量不到就说明根本没通上。
现象:能读到数据,但按键值一直在乱跳,摇杆偶尔才正常。
原因:微秒延时不准。HAL_Delay只能到毫秒,很多人拿空循环凑微秒,编译优化等级一变,循环次数就不对了,-O0能跑的程序烧进去-O2版本就废。
解决:用 DWT 周期计数器做微秒延时,或者干脆用定时器计数。把这个延时函数单独测一遍:拉高一个 GPIO,延时 100μs,用逻辑分析仪量脉宽,先保证时序函数本身是准的,再去查协议。
5.2 电源与干扰:一动就复位、一上电就跑飞
现象:静止时一切正常,一给电机加速,串口就乱码,手柄数据开始丢,严重时 MCU 直接复位。
原因:电机和逻辑共用一路电源,L298N 启停瞬间的电流冲击把 3.3V 拉塌了。或者地线走成了回路,电机的大电流从 MCU 的地线上流过,抬高了参考地电位。
解决:逻辑电源单独一片降压模块,电机电源直接走电池;两条地在电源负极处单点汇合;L298N 电源端并 1000μF 电解加 0.1μF 陶瓷;电机线双绞并远离四根信号线。做完这几条,掉线问题基本能消掉九成。
现象:上电后车自己往前冲一下才停。
原因:方向引脚在初始化前处于浮空输入状态,L298N 把它当成了高电平。
解决:在MX_GPIO_Init()里把 AIN1/AIN2/BIN1/BIN2 全部配置成推挽输出并初始化为低电平,在motor_set(0,0)之前不要启动 PWM 输出。
5.3 电机与手感:反转、啸叫、中位漂移
现象:给前进指令,左边两个轮子往前,右边两个往后,车原地打转。
原因:左右电机是镜像安装的,物理转向一致时电气极性相反。也有人是 L298N 输出端把 OUT1/OUT2 和 OUT3/OUT4 的线序搞混了。
解决:架空测试,给固定 30% 占空比,看四个轮子方向,反的那一组直接把电机两根线对调。软件里取反也能解决,但机械问题最好机械解决,不然以后换电机又得重来一遍。
现象:低速时电机不转只发烫,或者发出刺耳的啸叫。
原因:PWM 频率落在 1 到 5kHz 的听觉敏感区;或者启动占空比低于电机死区,TT 马达一般要 15% 到 25% 才起转。
解决:PWM 频率提到 16 到 20kHz;在motor_set()里加启动补偿,目标速度不为 0 时先给 200ms 的 250 占空比“踢”一下,再降到目标值。这个补偿对小车的低速平顺性提升非常明显。
现象:摇杆松手回中,车还在慢慢往前爬。
原因:摇杆机械回中不到位,中值不是精确的 128,个体差异能到 ±10。
解决:上电时采样 100 次摇杆值取平均,作为这一只手柄的中位基准存起来;死区从 ±12 起调,太大手感发木,太小又会漂。换手柄时这组基准要重新采,不能写死。
6. 二次开发:把按键变成功能键,把小车变成可调试平台
到手之后最值得先做的一件事不是加功能,是加一条调试通道。我一般会把 USART1 开出来,每 100ms 打一行 CSV:ly,rx,thr,yaw,lpwm,rpwm。摇杆推一遍,串口助手上看一眼数值范围对不对,比盯着车猜要快十倍。
按键扩展用边沿检测,别用电平判断。下面这段是切档逻辑,last_btn存在静态变量里,只有“这帧按了、上帧没按”才触发一次:
/* app_control.c:按键边沿检测,避免按住不放疯狂切档 */ static uint8_t last_btn = 0; uint8_t key_scan(uint8_t btn, uint8_t *level) { uint8_t pressed = btn & ~last_btn; /* 本帧新按下的键 */ last_btn = btn; if (pressed & (1 << 4)) { /* 十字键上:加档 */ if (*level < 4) (*level)++; } if (pressed & (1 << 6)) { /* 十字键下:减档 */ if (*level > 1) (*level)--; } if (pressed & (1 << 5)) { /* 十字键右:喇叭或灯光 */ /* 扩展功能挂这里 */ } return pressed; }pressed是位掩码,一次能同时捕获多个键,想加组合键也方便。这套结构往下延展,可做的改造大致是这几个方向:
| 改造方向 | 改动位置 | 难度 | 说明 |
|---|---|---|---|
| 四轮独立驱动 | motor.c拆成四路 | 中 | 换两片驱动,加原地旋转模式 |
| 编码器闭环 | 新增encoder.c,TIM 编码器模式 | 中高 | 需要每 20ms 读一次计数做 PI |
| 摇杆曲线 | app_control.c映射函数 | 低 | 加平方曲线,低速更细腻 |
| 参数在线调节 | 串口接收 + 全局参数表 | 低 | 不用每次改代码重新烧 |
| 掉线保护 | 主循环丢帧计数 | 低 | 连续 5 帧失败就锁死停车 |
验证方法很土但有效:把车架在纸箱上,四轮悬空,满速跑五分钟,看串口有没有丢帧、驱动模块烫不烫、电池电压掉到多少。悬空能过,落地基本没问题;悬空都掉帧,落地一定翻车。
从那次在走廊里追着一辆自己往前冲的小车跑之后,我每次上电第一件事都是把手柄平放在桌上让它自己回中,串口先把六路原始值看一遍,确认中位在 125 到 131 之间、四个方向推到极限能到 0 和 255,再挂挡起步。多花这两分钟,能省掉一下午的排查。希望帮到你。
本文还有配套的精品资源,点击获取