简介:面向STM32与麦克纳姆轮底盘开发者,这份资料提供基于STM32F103ZET6的开环控制方案,通过PS2手柄遥控,驱动部分采用L298N模块,适合正在调试轮式机器人底盘、或需要快速搭建PS2遥控框架的电子爱好者与竞赛学生。压缩包共212个文件,大小约7.26MB,主体为C源码(.c/.h)及编译工程(.o/.hex/.axf),另附L298N控制原理图(.png)和完整项目文件,并包含定时器、USART等外设初始化代码,目录结构清晰,便于按模块查阅。内容含实测可用的PS2驱动代码(虽略有延迟),以及电机开环控制代码与原理图,能帮助读者理解PS2数据解析、L298N电平逻辑和底盘驱动信号链路。已有846人学习下载,对刚接触麦克纳姆轮底盘、希望避开常见坑位的开发者具有直接参考价值。
1. 开环 PS2 麦克纳姆轮底盘:先把全向移动跑起来,再谈精度
把一辆车的四个轮子换成麦克纳姆轮,你会发现推前进杆它能横着走、斜着走、原地转圈,方向和速度全由四个轮子的转速差决定。麦克纳姆轮底盘(开环,PS2控制)就是这套东西的入门玩法:手柄摇杆告诉车子你想往哪个方向动,主控按一组运动学公式把目标拆成四个轮子的速度输出。开环的意思是没有编码器反馈,转速只是“给个占空比再祈愿它差不多准”,但换来的是硬件和代码都极简。
这套方案最适合两类人:一是手里有点单片机基础、想做全向底盘但不想一上来就碰闭环调参的学生和爱好者;二是要做课程设计或快速验证全向移动方案的开发场景——先让车能稳定地动起来,之后再补编码器闭环也不迟。PS2 手柄在这里负责遥控:便宜、按键多、无线,底盘上插一个接收器就能用。接下来我直接把运动学、接线、代码和踩坑记录按落地顺序摊开讲。
2. 麦克纳姆轮运动学:摇杆一动,四个轮子怎么分速度
2.1 麦克纳姆轮的辊子朝向:装错了连直行都直不了
麦克纳姆轮轮毂四周斜着装了一排小辊子,辊子轴线与主轮轴线成 45 度角。这个斜角是“全向移动”的秘密:轮子滚动时,辊子会把一部分地面摩擦力泄到侧向,于是轮子可以一边滚动一边横向滑移。四个轮子的辊子朝向必须按 X 型布局布置:对角轮辊子角度一致,相邻轮辊子角度相反。
布局错误是新手最高频翻车点:只要有一个轮子方向装反,推前进杆时车的姿态就不是直线,而是斜着走甚至原地转圈。开环系统下更麻烦,因为四个轮的转速本来就是“估”出来的,机械装错了光看现象很像运动学代码写错,排查时容易被带偏。检查办法很简单:俯视车体,看每个轮毂上辊子的“撇捺”方向是否满足对角对称,不满足就整体拆了重装。
2.2 摇杆到四轮速度:正运动学分配公式
我先把坐标系和符号约定固定下来,后面所有代码都按这个来:
- Vx:车体前后方向速度,向前为正
- Vy:车体左右方向速度,向左为正
- Omega:车体原地旋转角速度,逆时针为正
四个轮子按左前、右前、左后、右后编号,速度分配我用这套公式:
// 输入:vx, vy, omega —— 手柄摇杆换算出来的目标速度 // 输出:wheelSpeed[0..3] —— 四个轮子的期望速度(带正负,负为反转) void kinematics(float vx, float vy, float omega, float* wheelSpeed) { float k = 1.0f; // 旋转分配系数,根据轮距轴距先给 1.0 wheelSpeed[0] = vx - vy + k * omega; // 左前 wheelSpeed[1] = vx + vy - k * omega; // 右前 wheelSpeed[2] = vx - vy + k * omega; // 左后 wheelSpeed[3] = vx + vy - k * omega; // 右后 }逻辑说明:Vx 的系数全是正,所以推前进杆四个轮子同向前转;Vy 的系数是左右两组反号,横移时左侧轮和后侧轮反向转,车体才能横着滑出去;Omega 的系数对角轮同号,原地转时前后轮同步转向,形成纯旋转。这个公式和很多教程里的符号可能差一个负号,原因在辊子定义方向相反,不影响功能——只要保证“前进杆推下去车往前”就行,反了就整体取反 k 或 vx 的符号。
参数说明里最值得调的是 k。k 太小,原地转圈慢到像“挪”;k 太大,推转向杆时车体会明显横摆。开环阶段不用把 k 精确算成轮距轴距表达式,直接从 1.0 起步,实测旋转流畅度再增减。
2.3 开环为什么先够用:它的边界在哪
开环控制就是“给定占空比 → 轮子转速约等于期望值,但没有反馈修正”。只要车在平地上、负载稳定、电池电压不掉太快,这套做法完全够跑 demo、比比赛、验证运动学算法。它的三个明显边界你要清楚:
第一,四个电机个体差异会导致同 PWM 下转速不同,直线会慢慢变成斜线。开环阶段靠“标定压缩系数”缓解,就是先把同占空比下每个轮子的空载转速测出来,做归一化修正。第二,电池电压是慢慢下降的,满电时推杆 50% 可能很快,十分钟后同样 50% 就“乌龟爬”,这是开环的固有特性。第三,地面摩擦不均匀或辊子被卡住时,轮子实际速度会偏,但控制端完全无感,车就按错误姿态滑出去。
如果后续要做精确直线、里程计或自动导航,再上编码器闭环即可。好消息是手柄映射和运动学公式可以原样复用,闭环只是在轮速输出前加一个 PID 校正,这份代码不会是白写。
3. 硬件接线与 PS2 手柄连接:从引脚到供电一次理清
3.1 PS2 接收器与主控的 SPI 接线
PS2 手柄接收器通过 SPI 通信,接口一共六个引脚:VCC、GND、DAT(数据)、CMD(命令)、CS(片选)、CLK(时钟)。主控用带 SPI 功能的单片机都行,我常用一套开源单片机平台(Arduino)做原型,STM32 做成品也自然兼容。接线表如下:
| 接收器引脚 | 功能 | 典型接法(Arduino) | 接法说明 |
|---|---|---|---|
| VCC | 电源正极 5V | 5V 引脚 | 接收器逻辑电平 5V,注意与 3.3V 主控的电平匹配 |
| GND | 电源地 | GND | 必须与主控共地 |
| DAT | 数据(SPI MISO) | 引脚 10 | 手柄数据回传 |
| CMD | 命令(SPI MOSI) | 引脚 9 | 主控发送读取命令 |
| CS | 片选(SPI CS) | 引脚 8 | 拉低使能手柄通信 |
| CLK | 时钟(SPI CLK) | 引脚 7 | 软件 SPI 模拟时用普通 IO 即可 |
如果你的主控是 3.3V 逻辑(比如某类 STM32 开发板),而接收器是 5V 逻辑,稳妥做法是给 DAT 脚加一个电阻分压或电平转换模块,否则长期使用可能烧主控引脚。PS2 手柄通信是低速 SPI,我一般把时钟频率控制在几百 kHz,软件模拟 SPI 也足够稳定,不需要走硬件 SPI 外设。
3.2 电机驱动选型与供电分配:开环底盘最容易翻车的部分
开环底盘对电机驱动的要求不高,核心是三件事:能输出足够电流、PWM 频率合适、逻辑电源隔离或共地处理干净。常见驱动芯片里,我按场景分三类:
| 驱动方案 | 输出能力 | 适用场景 | 注意项 |
|---|---|---|---|
| L298N 模块 | 2A/路,发热大 | 大轮子、扭矩需求高 | 二极管降压明显,注意散热 |
| TB6612 模块 | 3.2A 峰值,效率高 | 中小型麦克纳姆轮底盘首选 | 逻辑电源需单独供电 3.3-5V |
| DRV8825(步进驱动) | 不推荐普通直流电机 | 只有步进电机场合用 | 直流电机需要 H 桥,别混用 |
我一般选 TB6612 这类 H 桥驱动模块。它有四路独立输出,正好匹配四个轮子的正反转控制;PWM 频率给到 10kHz 左右,噪音小、线性度好。L298N 模块便宜但压降大,开环下电压掉得更明显,新手后期会怪“车越跑越慢”其实有一半是驱动板压降的锅。
供电分配方面,开环底盘的常见方案是“一组电池带驱动板,逻辑电源分开”。我习惯用两节锂电或 3S 锂电给电机驱动供电,主控板用 USB 或独立降压模块供电,但所有 GND 必须连在一起。注意电池电压不要超过驱动板耐压,同时给电机驱动留 30% 以上的电流余量,否则急加减速时电压跌落会让单片机复位,表现就是车突然失控。
3.3 接线清单与上电自检顺序
完整接线清单我列一份,照着接基本不会漏:
| 序号 | 信号 | 说明 |
|---|---|---|
| 1 | 电机 A 两线 | 接驱动板 A1/A2 输出,不分正反 |
| 2 | 电机 B 两线 | 接驱动板 B1/B2 输出 |
| 3 | 电机 C 两线 | 接驱动板 C1/C2 输出 |
| 4 | 电机 D 两线 | 接驱动板 D1/D2 输出 |
| 5 | 驱动板 PWM1-4 | 接主控四路带 PWM 的 IO |
| 6 | 驱动板方向脚 4 个 | 接主控普通 IO,控制正反转 |
| 7 | PS2 接收器 6 线 | 接主控 SPI/IO |
| 8 | 电池正负极 | 接驱动板电源输入,同时共地 |
上电自检顺序很重要,能帮你把“机械问题”和“代码问题”分开:先只给主控上电,确认板子程序能跑;再给接收器上电,装上手柄按 MODE 键,确认红灯点亮——红灯是模拟模式,摇杆数据才能正常输出;接着给驱动板上电,但先不推手柄,逐个电机给一个固定 PWM,确认四个轮子都在转且方向可控制;最后才推手柄测运动学方向。很多人一上来就满电推手柄,发现车乱跑,根本分不清是接线反了还是公式符号错了。
4. 开环控制的代码实现:最小可跑的 PS2 底盘主循环
4.1 PS2 手柄的初始化與数据读取
PS2 手柄解析用现成的开源库比自己啃 SPI 协议快得多。库的核心初始化是配置六个引脚,并把压感和震动关掉,减小通信负载:
#include <PS2X_lib.h> #define PS2_CLK 7 #define PS2_CMD 9 #define PS2_CS 8 #define PS2_DAT 10 PS2X ps2x; void setup() { Serial.begin(115200); // 参数顺序:CLK, CMD, CS, DAT, 压感, 震动 // 压感和震动这里都传 false,能省一轮 SPI 数据,读取更快 if (ps2x.config_gamepad(PS2_CLK, PS2_CMD, PS2_CS, PS2_DAT, false, false)) { Serial.println("PS2 init OK"); } else { Serial.println("PS2 init fail"); } }逻辑说明:config_gamepad 会做一次握手,失败时基本是接线接错或没按 MODE 键。压感和震动功能对底盘控制没必要,关掉后单次读取时间更短,手柄反应更跟手。初始化失败时注意检查接收器 VCC 是否有 5V、DAT/CMD 有没有接反、主控和接收器有没有共地。
参数说明:CLK 接普通数字 IO 即可,库内部是用软件模拟 SPI,不需要动硬件 SPI 外设映射;引脚编号你随意换,只要和接线一致就行。一个常见误区是以为“CS 必须接硬件片选脚”,实际它只是库控制手柄通信使能的普通 IO。
在主循环里读取摇杆数据:
void loop() { // 必须每轮先读取手柄数据,否则数据包不会更新 ps2x.read_gamepad(); int lx = ps2x.Analog(PSS_LX); // 左摇杆 X int ly = ps2x.Analog(PSS_LY); // 左摇杆 Y int rx = ps2x.Analog(PSS_RX); // 右摇杆 X // 摇杆原始范围 0~255,中位约 128 }逻辑说明:read_gamepad 会从接收器拉一次完整数据包,Analog 取的是模拟摇杆值。PS2 手柄摇杆在摇动后会自动回到中位 128,但机械回中不会完全精确,总会有 ±5 左右的抖动,后面必须加死区,否则车会自己缓慢漂移。左摇杆管前后和左右平移,右摇杆管原地旋转,这是我推荐的映射,也可以按自己习惯换。
4.2 摇杆映射:死区、归一化、限幅三步走
原始摇杆值 0~255 不能直接用到底盘速度上,中间还差三步:先剔除中位抖动,再归一化到 -1.0~1.0,最后乘以最大速度限制。代码我这样写:
float stickToSpeed(int raw) { const int center = 128; // 摇杆中位 const int deadZone = 20; // 死区范围,调大点防漂移 int offset = raw - center; if (abs(offset) < deadZone) { return 0.0f; // 中位附近直接归零,避免车体漂移 } // 出去掉死区后的偏移量,再归一化到 -1.0 ~ 1.0 float value = (float)(offset - (offset > 0 ? deadZone : -deadZone)) / (128.0f - deadZone); return constrain(value, -1.0f, 1.0f); }逻辑说明:死区不是直接把 |offset|<20 当 0,而是把死区边界“剪掉”后再缩放,这样摇杆越过死区后的输出是从 0 平滑增加的,不会出现“一推就一蹦”的台阶感。分母是 128-20=108,目的是让最大输出仍满幅到 1.0。
接着把三个摇杆值变成运动学输入:
float vx = stickToSpeed(ly); // 左摇杆前后:前推为负注意方向 float vy = stickToSpeed(lx); // 左摇杆左右:向右为正 float omg = stickToSpeed(rx); // 右摇杆左右:逆时针为正注意:PS2 手柄 Y 轴方向是“向上为负”,所以前推杆时 ly 原始值会小于 128,映射出来是负值。如果 vx 符号反了,最简单的修法是取反:vx = -stickToSpeed(ly);,不要改代码其他地方。这也是开环调试里最常遇到的“方向不一致”问题。
4.3 开环 PWM 输出:从期望速度到电机引脚
速度分配完还是带符号的浮点数,最后一步是转成“方向引脚 + PWM 占空比”输出。我的习惯做法:
// 例:驱动板方向引脚接 D5,D6,D7,D8,PWM 引脚接 D9,D10,D11,D12 #define MOTOR_DIR0 5 #define MOTOR_DIR1 6 #define MOTOR_DIR2 7 #define MOTOR_DIR3 8 #define MOTOR_PWM0 9 #define MOTOR_PWM1 10 #define MOTOR_PWM2 11 #define MOTOR_PWM3 12 void setMotor(int motorIndex, float speed) { int pwmVal = (int)(abs(speed) * 200.0f); // 200 为最大占空比,留 55 余量 int dirPin; int pwmPin; switch (motorIndex) { /* 根据索引取对应引脚 */ } digitalWrite(dirPin, speed >= 0 ? HIGH : LOW); analogWrite(pwmPin, pwmVal); }逻辑说明:speed 已归一化在 -1.0~1.0,乘以 200 而不是 255,是因为占空比接近 100% 时电机电流大且转速线性度变差,留一点余量既保护驱动板,也让标定更准。方向引脚高低电平决定正反转,PWM 引脚输出占空比决定转速。
参数说明:四个电机各自的物理方向可能和预期反,不要靠改代码里的正负号逐个硬调,正确做法是先把每个轮子单独通电测出“方向引脚为 HIGH 时轮子朝哪转”,再把代码里的方向定义与该物理方向对齐。开环下先做一次单轮标定,能省一天调试时间。
5. 开环 PS2 底盘排查:五个常见翻车现场与解决顺序
5.1 现象一:推前进杆,车斜着走甚至原地打转
现象:手柄往前推,车不直行;有时推前杆车往右前方窜,有时四个轮子各转各的,车像陀螺一样原地晃。
原因:麦克纳姆轮辊子朝向装错。X 型布局下,左前和右后轮的辊子方向要一致,右前和左后的辊子方向是他们的镜像。只要装错一个轮子,车体的速度分解就全乱。
解决:拆下所有轮子,重新按“左前与右后同向、右前与左后同向”安装。装完后静态检查:用手沿前后方向推动车辆,辊子会在斜向让车有轻微侧滑,这是正常的;然后给四个轮子分别通电点动,确认轮毂转动方向与代码期望一致。确认机械方向无误后,再推手柄测运动学方向,不要一上来就用代码规避机械问题。
5.2 现象二:PS2 摇杆没反应,偶尔一推又窜出去
现象:手柄数据时好时坏,摇杆推半天车不动,突然动一下又猛冲;或者收器红灯不亮。
原因:三个高频原因——手柄没切到红灯模拟模式;接收器供电不足导致 SPI 数据丢包;主控和接收器没共地。
解决:先按手柄背后的 MODE 键,直到红灯亮起,确保处于模拟模式。接收器供电不要从主控的 3.3V 引,最好直接接 5V 引脚。共地排查——用万用表量主控 GND 和接收器 GND 是否通路,不通就是地线没接。数据偶尔窜动还有一个隐蔽原因:手柄电池电量低,换两节新电池再试,开环控制没有数据校验,丢一帧就是一帧,现象上就是“闪光一跳”。
5.3 现象三:同样的速度指令,四个轮子转速明显不一样
现象:推前杆,车头朝一边歪,而且歪的方向每次都一样,和地面坡度无关。
原因:开环无法修正电机差异。四个电机即使同型号,空载转速也可能差 5%-15%,再叠加麦克纳姆轮辊子的转动阻力差异,四轮实际转速就不可能一致。
解决:做一次“同占空比转速标定”。给四个轮子同时输出 150/255 占空比,让车悬空,数每个轮子 10 秒转几圈,算出每轮相对最大值的修正系数,存进一个数组:
// 标定系数:先测 150 占空比下四轮每秒转速,再按比例填 // 例:四轮实测 8.2, 8.0, 7.5, 8.4 转/10s float wheelCal[4] = { 8.2f / 8.2f, 8.0f / 8.2f, 8.4f / 8.2f, 8.4f / 8.2f };把运动学输出乘上对应系数再送 PWM。这个修正对开环底盘提升巨大,能把“明显歪”改善到“基本直”。注意标定要在电池同一个电压状态下做,电压不同时系数会漂移,这也是开环的局限,只能靠缓减不能根治。
5.4 现象四:横移时车往斜前方窜,或旋转时绕某个轮子转
现象:往左横移,车头却同时朝右前方偏;原地旋转时感觉不是绕车心转,而是绕某一个轮子转。
原因:运动学公式里的符号和实际车轮布局不匹配,通常是四个轮子中某个轮子的驱动方向反了,或者轮子编号顺序和公式不一一对应。横移窜动和旋转偏心这两个现象,本质都是“某个轮的期望速度方向与物理方向相反”。
解决:按这个顺序排查——给四轮依次点动,确认每个轮子“正转”时的物理前进方向,并记录与代码中编号的对应关系;再单独测横移特性:只给 Vy 一个正速度,观察车体应整体向左平移,如果向右偏移,整体取反 Vy;最后测旋转:只给 Omega 正速度,车应逆时针自转,如果顺时针,把运动学公式里 omega 的符号取反。注意每一步只改一个符号,改完立刻验证,不要同时改多个变量。
5.5 现象五:每次推方向速度失控,一推就爆冲
现象:摇杆刚推一点点,车就像被踹了一脚,全速冲出去;或者松开摇杆后车还在继续跑。
原因:三件事缺一不可——死区没加导致摇杆中位抖动直接被当成小信号;没有斜率限制,速度阶跃输入直接打满;松杆后没有把速度指令归零。
解决:先加死区,把摇杆中位附近 ±20 以内的输出强制归零;再加斜率限制,让速度指令每次循环最多变化 0.05,车就能平缓加减速,这对开环底盘尤其重要,因为开环没有反馈,阶跃变化落在轮子上就是一次机械冲击;最后在 loop 末尾把上一轮的目标速度保存,松杆后明确置零,不要靠摇杆自然回中。爆冲是开环底盘最容易吓到人又最容易修的问题,按这三步处理后手感会立刻从“野马”变“温顺”。
6. 控制手感优化的三个技巧:斜率、曲线和开环自检
6.1 斜率限制:把阶跃指令变成斜坡
直接映射摇杆会让车一推就窜、一松就停,对麦克纳姆轮这种全向底盘来说,横移时急停还容易把车上东西甩出去。我习惯给速度指令加一个斜坡限制:
float rampFloat(float target, float current, float maxStep) { if (target - current > maxStep) return current + maxStep; if (current - target > maxStep) return current - maxStep; return target; }在主循环里对 vx、vy、omega 分别调用,maxStep 取 0.02~0.05,配合 10ms 一循环就是 0.2~0.5 秒从 0 到满速,体感非常舒服。maxStep 调太大接近无斜坡,调太小车会显得“黏”,借这个参数找车感的平衡。
6.2 摇杆曲线:让微调区更细腻
线性映射在小摇动幅度时对手指的微小偏移太敏感,做精细对位很费劲。常见做法是加一条非线性曲线:
float curve(float input) { return input * input * input * 0.5f + input * 0.5f; }这个公式让小幅度摇杆更细腻,大幅度仍能打满,适合需要横移对位和缓慢旋转的场景。灵活度低一点时直接把指数加大到 1.5~2,灵活度要求高就接近线性。曲线函数放在死区归一化之后、运动学公式之前。
6.3 开环自检清单:十分钟确认运动学没写反
上电测试按这套表格逐项过,每一项都要在松开摇杆后确认车完全停稳再做下一项:
| 序号 | 操作 | 预期结果 |
|---|---|---|
| 1 | 推前进杆 | 车头向前直行,不斜不转 |
| 2 | 拉后退杆 | 车尾向后直行 |
| 3 | 左摇杆往右推 | 车体整体向右平移,车头朝向不变 |
| 4 | 左摇杆往左推 | 车体整体向左平移 |
| 5 | 右摇杆往右推 | 车体原地顺时针旋转 |
| 6 | 右摇杆往左推 | 车体原地逆时针旋转 |
如果哪一项和预期不符,按第 5 章的顺序去查符号,不要改机械结构,不要同时改两个符号。我第一次搭这套底盘时,四个轮子全装反,推前进杆车在原地打转,我调了一下午运动学公式,后来才想起检查轮子——后来每次调试都先机械后代码,这习惯帮我省了无数时间。希望这个排查顺序也能帮到你少走弯路,先让车按你的指令 “听话” 地动起来,再想它怎么 “精” 地动。
本文还有配套的精品资源,点击获取