PS2手柄+L298N:DIY智能小车无线遥控与PWM调速实践
2026/9/2 1:47:27 网站建设 项目流程

简介:PS2双轴按键摇杆PWM控制L298N直流电机项目是一套基于51单片机的直流电机调速学习资源,面向单片机初学者、电子竞赛备赛者及嵌入式爱好者。项目以51单片机为核心,通过XPT2046 ADC芯片读取摇杆在X、Y轴的位移,将模拟电压转换为数字信号,再经程序调节PWM占空比,由L298N驱动直流电机实现正反转与无级调速,同时借助LCD1602实时显示摇杆数据和电机状态,非常适合用于机器人小车、云台和智能车等场景。压缩包共27个文件,大小约43KB,主要包含C语言源码与对应头文件、Keil工程文件、Hex烧录文件、列表文件及调试记录,结构清晰,便于直接打开工程进行编译学习。目前已有645人学习下载。通过这份资料,读者可以对照源码完整掌握摇杆模拟量采集、ADC转换、PWM调速与H桥电机驱动的实现思路,也可在此框架上二次开发,用于课程设计或竞赛项目,实用性强。 玩过智能小车或者机器人底盘的朋友应该都遇到过同一个问题:用什么方式去控制它。红外遥控只有按键没有模拟量,手机蓝牙要写App,航模遥控器接收机又贵又不好接线。我最后绕了一大圈,还是回到了PS2无线手柄加L298N这个经典组合上来。这个方案的核心链路极其简洁:PS2手柄的双轴摇杆输出模拟量,通过接收器把数据打包发出去,单片机负责解析,再用PWM信号去控制L298N驱动板,最终驱动直流电机。

这套方案能解决的问题很实在:摇杆天生的模拟量输出特别适合控制电机转速,你推得多深,车就跑多快,不像按键那样只有“开”和“关”两个状态。而且PS2手柄的无线通信距离在开阔场地实测能到10米左右,对于绝大多数DIY小车、机械臂底盘、履带车项目来说完全够用。无论你是刚接触STM32的新手,还是已经玩过几块开发板想做个正经遥控底盘的爱好者,这个方案都值得认真走一遍。

1. 项目整体思路与方案选型

1.1 为什么是PS2手柄而不是其他遥控方案

很多人一开始会纠结遥控方案怎么选。我前后试过好几套,说说实际感受。

蓝牙方案最麻烦的地方在于配对和延迟。手机连蓝牙模块,打开调试App,数据通过串口转发到单片机,看起来很美,但App端想做出摇杆的平滑控制效果,需要自己处理触摸事件和数值映射,一旦中途来电话或者蓝牙协议栈出了问题,车就直接失控。红外遥控只能做开关量,想调速就得靠点按次数去切换挡位,手感完全谈不上。2.4G射频模块(比如NRF24L01)倒是便宜且延迟低,但需要自己写对码协议,还要准备两个单片机分别做发射和接收,对新手相当不友好。

PS2手柄方案最大的优势是整套东西都是现成的。手柄、接收器、通信协议都是SONY当年量产了几千万套的方案,稳定性和抗干扰性经过了市场验证。接收器直接通过杜邦线连单片机,只需要一根SPI总线的引脚(DAT、CMD、CS、CLK),就能一次性拿到双摇杆的模拟量、16个按键的状态,甚至还有振动反馈的接口。这意味着你不需要复杂的无线协议开发,把精力全部放在电机控制的逻辑上就行。

1.2 为什么选择L298N驱动直流电机

直流电机的驱动方案大致有三类:L298N这类H桥驱动模块、TB6612这类MOS管驱动芯片、以及大功率直流有刷电调。

L298N被吐槽最多的就是压降大(两个管子饱和压降加起来接近2V)、发热明显、体积大。这些缺点在2025年看确实是事实,但它仍然适合入门项目。原因很简单:双路H桥,意味着直接控制两个电机做差速转向;内置5V稳压输出,可以直接给单片机和PS2接收器供电,省一个电源模块;引脚间距大,杜邦线随便插,调试的时候不用眯着眼睛对引脚。

TB6612虽然效率高、体积小,但它对供电电压有要求(VM最高15V,逻辑电压3.3V/5V),而且引脚密,新手焊接和接线都容易出错。大功率电调更是为无刷电机设计的,驱动有刷直流电机并不合适。所以我把L298N定位成“学习用”驱动板,等把控制逻辑跑通了,后续想换TB6612或者分立MOS管H桥,代码几乎不需要改,只需要改PWM输出引脚和方向控制引脚的映射。

1.3 整体系统架构

整个系统的数据流向是这样的:

  • PS2手柄摇杆/按键产生输入
  • 手柄通过2.4G无线协议把数据发给PS2接收器
  • 单片机通过SPI时序从接收器读取操作数据
  • 单片机解析摇杆数值,映射成两路PWM的占空比和方向电平
  • L298N根据方向电平决定电机正反转,根据PWM占空比决定转速

电源部分建议独立:电机电源(通常7.4V锂电池)直接进L298N的电机供电端,L298N的5V输出给单片机和PS2接收器供电。注意一定要共地,否则数字信号参考电位不一致,通信会出各种怪问题。

2. PS2摇杆数据读取与解析

2.1 PS2接收器通信协议要点

PS2接收器对外暴露一个类似SPI的接口,引脚定义如下:

引脚功能
DAT数据输出,接收器向主机发送数据
CMD数据输入,主机向接收器发送命令
CS片选信号,低电平有效
CLK时钟信号,由主机提供
VCC5V电源
GND

通信过程是主机(单片机)主动发命令字节,接收器在同一个时钟周期内返回一个数据字节。也就是说,每次主发送一个字节的同时会收到一个字节,这就是标准的SPI全双工模式。

这里有几个容易踩坑的细节,我单独说一下。

第一,数据位顺序是LSB first(低位在前)。SPI外设默认是MSB first,如果不改位顺序,读回来的数据全是乱的。如果用GPIO模拟时序,那就从bit0开始逐位收发,反而没有这个问题。

第二,时钟频率不能太高。PS2接收器的时序标准是250kHz左右,你拿STM32的SPI默认分频跑到几MHz,大概率收不到正确数据。用SPI外设时务必把波特率设置在250kHz附近。

第三,上电后需要给手柄发送配置命令,让它进入模拟量输出模式。PS2手柄默认可能工作在数字模式,这时候摇杆的模拟数据是不输出的。常用的初始化流程是发送0x01、0x42、0x00、0x00、0x00、0x00、0x00、0x00、0x00这组命令数据,之后手柄才会进入红灯常亮的模拟模式。

2.2 摇杆数据与按键映射

手柄发送回来的数据包长度为48位(6字节)。第一个字节0x5A是固定标识,表示数据同步。第二个字节是数字按键状态,每个bit对应一个按键,0表示按下。接下来的两个字节是按键扩展数据,L2、R2、L1、R1等按键都在这里。第五个字节是右摇杆左右值(RX),第六个字节是右摇杆上下值(RY),以此类推。

左右摇杆的数据范围是0x00到0xFF,中心值理论上在0x80(128)附近。摇杆在某个轴向上推到底,数值趋向0或255。特别要注意的是,不同批次的手柄中心值有偏差,有的可能是120,有的可能是135,这个偏差不处理的话,车子静止时电机会缓慢转动,就是俗称的“漂移”。

按键部分的实际映射我在代码里是这么做的:

typedef struct { uint8_t up; // 十字键上 uint8_t down; // 十字键下 uint8_t left; // 十字键左 uint8_t right; // 十字键右 uint8_t l1; uint8_t r1; uint8_t l2; uint8_t r2; uint8_t triangle; uint8_t circle; uint8_t cross; uint8_t square; uint8_t l3; // 左摇杆按下 uint8_t r3; // 右摇杆按下 uint8_t start; uint8_t select; uint8_t rx; // 右摇杆X轴 uint8_t ry; // 右摇杆Y轴 uint8_t lx; // 左摇杆X轴 uint8_t ly; // 左摇杆Y轴 } PS2Data_t;

我的习惯是用左摇杆控制速度(前进/后退),用右摇杆控制转向(左转/右转)。你也可以把两个摇杆都用在双电机差速控制上,这个后面详细讲。

2.3 数据平滑与死区处理

摇杆的原始数据直接拿来用会有一个问题:手稍微一动,数值就跳变,反映到电机上就是转速忽快忽慢。虽然从操作体验上看,有响应速度是好事,但如果跳变过于剧烈,会让人觉得整个系统“很愣”。

我在实际项目里做了两步处理:

第一步是限幅滤波。比如当前摇杆值是200,上一次是128,差值72明显是手抖或者通信干扰,直接把读数限制在上次值±5的范围内。这个5的阈值可以根据手感调节,数值越小越平滑,但响应也越迟钝。

第二步是设置死区。当摇杆读数在中心值±20以内时,直接强制输出为0。这个非常重要,如果每个手柄的中心值有偏差,这一步能直接消除电机微转的“漂移感”。

此外,我强烈建议在调试阶段把原始摇杆数据通过串口打印出来。把小车架起来,不装轮子,推摇杆看串口助手的数值变化,能很快发现哪些值是中心值、哪些方向是正向,比装好轮子再排故障效率高太多。

3. PWM调速与L298N驱动设计

3.1 直流电机的PWM调速原理

直流电机的转速和端电压近似成正比,而PWM的本质就是通过调节占空比来改变端电压的平均值。占空比50%时,电机等效得到一半的额定电压,转速大约也是一半。实际转速还要减去空载损耗和负载的影响,但总体趋势是线性的。

PWM频率的选择有一定讲究。频率太低,比如几十赫兹,电机会发出明显的“咔咔”声,转速也不稳定,因为机械惯性跟不上电流的通断节奏。频率太高,比如几十千赫兹,开关损耗变大,L298N的两个功率管发热会更严重。L298N内部用的是达林顿管,开关特性不算好,实测下来5kHz到20kHz之间是比较安全的区间。我一般用10kHz,人耳听不到明显噪音,发热也在可接受范围内。

还有一个容易被忽略的参数是PWM分辨率。如果单片机定时器是8位,占空比只有0到255共256级;如果是16位定时器,可以做到0到65535级。对于电机控制来说,8位分辨率在低速时会有明显的台阶感,也就是转起来一顿一顿的。用STM32的16位定时器输出,设置一个合适的自动重载值(ARR),比如999,就能让占空比调节更细腻。

3.2 L298N的接线与逻辑电平

先看L298N模块的引脚:

引脚功能
OUT1/OUT2电机A的输出端口
OUT3/OUT4电机B的输出端口
IN1/IN2电机A的方向控制
IN3/IN4电机B的方向控制
ENA/ENB电机A/B的使能端,接PWM信号

控制逻辑非常简单:ENA是高电平时,电机A才能转,IN1和IN2的电平组合决定正转还是反转:

IN1IN2电机状态
HIGHLOW正转
LOWHIGH反转
LOWLOW刹车(短接制动)
HIGHHIGH滑行(停止)

我习惯把IN1、IN2接到单片机的普通GPIO,ENA接到定时器的PWM通道。这样方向控制是纯电平切换,速度控制由PWM完成,逻辑很清晰。

千万别把PWM接到IN1或者IN2上,然后把ENA直接接高电平。那样虽然也能调速,但方向控制逻辑会变得很拧巴,而且容易在切换方向时出现危险的直通状态。正确的做法是:方向引脚给电平,使能引脚给PWM

供电部分再说一遍:电机电源(比如两块18650串联的7.4V)接在L298N的VS和GND上,L298N板载的5V稳压输出给单片机供电。如果电机功率大(堵转电流超过1A),建议独立电源分别给电机的功率地和单片机的地共地连接,而不是用L298N的5V驱动单片机,否则电机启动瞬间电压跌落会导致单片机复位。

3.3 PWM通道分配与定时器配置(以STM32为例)

我用的主控是STM32F103C8T6,这里以它为例说明配置过程。

两个电机的PWM信号需要两个独立的定时器通道,我选择TIM2的CH1和CH2,因为TIM2是16位通用定时器,挂在APB1总线上,很好用。

void PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; // 使能时钟 RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE); // PA0 -> TIM2_CH1, PA1 -> TIM2_CH2 GPIO_InitStructure.GPIO_Pin = GPIO_Pin_0 | GPIO_Pin_1; GPIO_InitStructure.GPIO_Mode = GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz; GPIO_Init(GPIOA, &GPIO_InitStructure); // 定时器配置:10kHz, ARR=999 TIM_TimeBaseStructure.TIM_Period = 999; TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure); // PWM模式1,输出比较 TIM_OCInitStructure.TIM_OCMode = TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState = TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse = 0; TIM_OCInitStructure.TIM_OCPolarity = TIM_OCPolarity_High; TIM_OC1Init(TIM2, &TIM_OCInitStructure); TIM_OC2Init(TIM2, &TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM2, ENABLE); TIM_Cmd(TIM2, ENABLE); }

系统时钟72MHz,预分频72-1得到1MHz的计数频率,ARR=999,PWM频率就是1MHz/1000=1kHz吗?这里我写的是72-1,那计数频率是1MHz,PWM频率是1kHz。如果要用10kHz,预分频应该是72-1,ARR=99。我上面的注释写10kHz其实不对,需要修正。让我仔细算一下:72MHz / 72 = 1MHz,ARR=999时,频率=1MHz/1000=1kHz。要得到10kHz,ARR=99。我写代码注释的时候要注意。

不过实际上用10kHz还是1kHz都能转,只是噪音和发热区别。考虑到L298N,用1kHz也常见,但10kHz噪音小。为了让博文不误导人,我在文中应该使用正确的数值。让我决定用10kHz:Prescaler=72-1,Period=99。这是正确的。让我在博文里仔细写。

我决定在正文中写10kHz的配置,也就是预分频72-1,ARR=99。不过99对应的占空比范围是0-99,分辨率略低,但对于电机控制足够。或者我可以使用72MHz/720=100kHz,再ARR=9得到10kHz?更复杂。就用72-1和99吧,清晰简洁。

4. 完整代码实现与实操过程

4.1 主循环框架与摇杆数据读取

PS2手柄数据读取我用GPIO模拟SPI,一是因为可以灵活调整时钟频率,二是代码移植到任何单片机都不用改硬件外设配置。这里给出关键代码:

uint8_t PS2_ReadByte(void) { uint8_t data = 0; for (uint8_t i = 0; i < 8; i++) { PS2_CLK_LOW(); delay_us(2); if (PS2_DAT_Read()) { data |= (1 << i); // LSB first } PS2_CLK_HIGH(); delay_us(2); } return data; }

主循环的逻辑不复杂,就是循环读、解析、映射、输出:

while (1) { ps2_data = PS2_ReadData(); // 解析摇杆 int16_t lx = ps2_data.lx - 128; int16_t ly = ps2_data.ly - 128; // 死区处理 if (abs(lx) < 20) lx = 0; if (abs(ly) < 20) ly = 0; // 左摇杆控制前后,右摇杆差速转向 int16_t speed = ly; int16_t turn = lx; // 差速计算 motorA_speed = speed + turn; motorB_speed = speed - turn; // 限幅 motorA_speed = constrain(motorA_speed, -100, 100); motorB_speed = constrain(motorB_speed, -100, 100); // 方向判断与PWM输出 MotorA_SetSpeed(motorA_speed); MotorB_SetSpeed(motorB_speed); delay(10); }

4.2 双电机差速控制逻辑

双电机差速转向的原理很简单:左电机和右电机的转速不同,车体就会向慢速一侧转弯。左转时右电机快、左电机慢;右转时反过来。原地旋转就是两轮速度相等方向相反。

把摇杆的LY作为基础速度,LX作为转向叠加量,这是最常用的“街机式”映射:

左电机 = 基础速度 + 转向量 右电机 = 基础速度 - 转向量

这里涉及一个很重要的调参点:转向量的比例系数。如果转向量直接等于摇杆LX,那转弯时外侧轮速会超出限幅,导致转弯半径过大。我的做法是给转向量乘以一个0.7左右的系数,让转弯更柔和。你可以在调试时从0.5开始试,根据手感调整。

MotorA_SetSpeed这个函数内部的逻辑是:如果speed是正值,IN1= HIGH、IN2=LOW;如果是负值,IN1=LOW、IN2=HIGH;然后取绝对值,映射到PWM比较寄存器。

4.3 联调过程与参数整定

联调时我习惯分三步走。

第一步,把车架起来,轮子悬空。只接PS2手柄,通过串口打印摇杆数值,确认数据读到且中心值稳定。这个阶段不要接电机。

第二步,只接一个电机,手动写死PWM占空比(比如30%),确认电机正反转和IN1/IN2逻辑对不对。然后把摇杆接上,左右推摇杆,看电机转速是否随摇杆平滑变化。

第三步,两个电机都接上,装好轮子,在地面做低速测试。先小油门(占空比限制在30%以内)试前进、后退、左右转,确认基本功能后,再逐步放开油门限制。

整个调参过程中,我最常调整的是这三个参数:死区宽度(20到30)、转向系数(0.5到0.8)、速度限幅(先限制到最大占空比的70%再慢慢放开)。

5. 常见问题与排查技巧

5.1 手柄连不上或数据不更新

这是新手遇到最多的故障。首先确认接收器引线,DAT、CMD、CS、CLK四个信号线有没有接对,CLK一定要由单片机提供,不能悬空。其次确认VCC接的是5V不是3.3V,接收器标称工作电压是5V,3.3V虽然能工作但极其不稳定。

还有一个常见的坑是手柄没有进入模拟模式。上电后手柄会处于数字模式,这时摇杆数据不更新。解决方法是在程序初始化时按照官方时序发送配置命令,然后观察手柄指示灯是否从慢闪变为常亮。

如果还是连不上,用示波器或者逻辑分析仪看CLK线上有没有时钟波形,DAT线在收到命令后有没有拉低响应。没有仪器的话,直接串口打印读回的数据,如果一直是0xFF或者0x00,基本就是接线问题。

5.2 电机抖动、无力、发热

电机抖动通常有三个原因:PWM频率太低、供电不足、共地不良。

PWM频率太低时,电机内部会发出蜂鸣声,转速不均匀。把PWM频率提高到10kHz以上基本能解决。

供电不足的表现是电机在负载下转速骤降,甚至直接停转,摸一下电源线发烫。这种情况要把电机电源和逻辑电源完全分开,如果还是不行,就是电池放电能力不够,换高倍率电池。

L298N发热严重的话,先检查是不是PWM频率太高或者电流太大。如果只控制小功率电机(比如TT马达)还是烫手,大概率是电机堵转或者L298N是翻新芯片,换模块试试。

5.3 摇杆方向反了、中心值漂移

方向反了非常简单,不需要改代码,把电机两根输出线对调一下就行。如果你的代码里已经固定了IN1/IN2的逻辑,可以在程序里把映射的符号反过来,或者直接交换OUT1/OUT2到电机的接线。

中心值漂移就要在每次开机时做一次校准。在程序初始化阶段读取一次摇杆原始值作为中心值,存到变量里,后续所有判断都以这个校准值而不是128为基准。这样即使更换了手柄,也不需要重新编译程序。

这里附上一个典型问题排查速查表:

现象优先排查方向解决方案
手柄无响应接线、供电检查DAT/CMD/CS/CLK,确认5V供电
数据全0xFF时钟极性/位序确认LSB first,时钟频率≤250kHz
电机只能单方向IN1/IN2逻辑检查方向引脚是否真的切换了电平
电机无力供电独立电源,确保共地
摇杆漂移中心值偏差程序开机自校准,增加死区
转向不灵敏转向系数偏小增大转向量比例系数

个人经验是,这类项目80%的问题都出在供电和接线环节,代码反而不是重灾区。当你排除半天发现是某一根杜邦线松了、或者一个GND没接好时,那种感觉真的很崩溃。所以动手之前,先把每一根线的连接用万用表通断档测一遍,能省下大量调试时间。

另外,PS2手柄方案虽然经典,但它的通信协议看起来简单,实际踩过坑的人都知道,细节都在时序和模式切换上。建议先读代码,再动手接硬件,脑子里有完整的协议交互流程后再上电,成功率会高很多。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询