☰
STM32+PS2手柄机械臂控制:兼容PWM与总线舵机的实现
2026/10/25 10:28:06 网站建设 项目流程

简介:本资源是一套面向嵌入式初学者与机器人开发者的STM32实战项目资料,聚焦PS2无线手柄对多类型机械臂的实时控制,解决舵机混控(PWM型+总线型)这一典型工程难点,适用于智能小车、教育机械臂、人机交互实验等场景。压缩包共209个文件,含36个C源码(如ps2.c、servor.c、timer.c)、37个头文件(如ps2.h、servor.h)、36个编译中间文件(.o/.d/.crf),以及Keil uVision工程核心文件(.uvproj、.uvopt)、启动配置(.sct)、调试输出(.axf/.map/.lst)和关键文档“ps2解码通讯手册V1.3.doc”,完整覆盖驱动开发、协议解析、PWM生成、I2C/SPI总线通信及中断响应全流程。已有1047人学习下载,资源结构清晰,模块化程度高,main.__i、ps2.__i、servor.__i等预编译符号文件便于快速定位调试入口,配套工程可直接编译烧录,显著降低PS2手柄与双协议舵机协同控制的学习门槛。

1. 项目概述

1.1 核心需求解析

STM32系列单片机在机器人控制领域一直扮演着极其重要的角色。而对于刚接触机械臂控制、或者正在为手头机器人项目寻找解决方案的开发者来说,PS2无线手柄是一个非常理想的人机交互方案。原因很简单:PS2手柄模块本身技术成熟、成本低廉、通信协议清晰且资料丰富,几乎不需要自己再设计复杂的遥控板;与之配套的接收器价格也相当亲民,一个模块就能提供两路模拟摇杆和十余个数字按键,这对大多数机械臂的控制需求来说已经是绝对够用的硬件资源了。

那这个项目具体要解决什么问题?我把它拆成三个层面来看:

  • 第一层,是实现PS2无线手柄与STM32之间的数据通信。这一层是整个项目的基石,因为只有先把按键和摇杆的数据准确读回来,后面谈任何控制逻辑才有意义。
  • 第二层,是把读回来的手柄数据转化为对舵机的控制信号。这里紧接着就会遇到一个比较现实的问题——市面上常见的舵机分为PWM舵机和总线舵机两大阵营,它们的控制方式完全不同,通信接口也截然不同。如果只做一种舵机的支持,终端用户往往会被限制在某一个特定舵机生态里。
  • 第三层,是在前两层的基础上,完成机械臂的运动逻辑与安全保护。机械臂毕竟是一个多自由度联动系统,最少都是2轴,多则5轴、6轴。如何处理摇杆与各关节之间的映射关系、如何保证舵机不会运动到物理极限位置把齿轮打坏,这些都是必须考虑的实际问题。

这个项目最核心的亮点,就是把PWM舵机和总线舵机的支持同时做到了一个工程里。对开发者来说,通过切换配置就能适配两种舵机系统,也就意味着这个工程既可以直接用于入门学习,也可以稍加改动就用于实际制作。对学习嵌入式或者机器人方向的学生来说,这个作品的可玩性、可扩展性也非常高,可以方便地对接后续视觉抓取、路径规划等更复杂的功能。

1.2 适用人群与学习价值

写这篇博文,面向的读者大概是这三类:

  • 正在以STM32为平台学习嵌入式编程的在校生。这类同学通常已经接触过GPIO、定时器、串口等基础外设,但缺乏一个综合性的实践项目把这些模块串起来。这个项目用到的知识恰好覆盖了SPI通信、定时器PWM、串口DMA等主流外设,是一个很好的综合练兵项目。
  • 做毕业设计或者竞赛作品,需要快速完成机械臂遥控功能的同学。很多毕业设计题目都含有“机械臂控制”这个关键词,直接全部自己从零写需要花费大量时间,而站在一个可靠的工程肩膀上做二次开发,可以整体加快进度,把主要精力用到核心创新点上。
  • 已经在做机器人DIY、想给自制机械臂加装无线控制能力的创客朋友。这类用户动手能力强,但可能没有那么多的调试时间,拿到一个硬件基础已经验证过的方案,直接按步骤接线、烧录、运行,更容易快速跑通属于自己的机械臂控制功能。

从我个人的经验来看,这个项目还具备一个比较独特的学习价值:它迫使你把整个控制链路从头到尾梳理清楚。数据是怎么从手柄按键走向舵机齿轮的,中途每一层做了什么处理、为什么要做这个处理,这些不是靠背代码能学会的,而是需要在实操中反复琢磨。而一旦你把这个链路完全打通,再去看市面上任何一种遥控方案(2.4G、蓝牙、WiFi),底层逻辑都是大同小异的,迁移成本很低。

2. 整体设计方案与技术选型

2.1 PS2手柄通信协议概述

PS2无线手柄的接收器与STM32之间使用的是SPI协议。很多第一次接触这个模块的人会被通信时序搞糊涂,实际上PS2接收器的通信机制比标准SPI要简单直接一些,它没有像SD卡、Flash那样复杂的命令集,而是采用了一种“按字节收发”的帧格式。

一次完整的数据读取过程是:主机(也就是STM32)拉低CS片选,然后按位发送一个命令字节0x01,与此同时主机持续读入接收器从MISO引脚返回的数据。PS2手柄默认会连续返回9个字节的数据帧,其中前两个字节固定是0x5A 0x5A,这可以作为数据是否有效的校验标准。第三个字节通常是0xFF或者0x5A,后六个字节里面就藏着各个按键的状态和两路模拟摇杆的数值。

这里有一个关键点需要理解——按键数据是“低电平有效”的。也就是说,数据位是0代表按键按下,数据位是1代表按键松开。很多人在写程序时习惯性地以为读到1就是按下,结果调试半天没反应,最后才发现是逻辑搞反了。摇杆数据则是8位的ADC值,取值范围0x00到0xFF,推杆在中间位置时读到的值大约在0x80附近。

通信速率方面,PS2接收器的SPI时钟一般建议不要超过500kHz。实测下来,如果时钟设得太高,数据读取偶尔会出现丢位的问题,这主要是因为接收器模块本身的开漏输出能力和上拉电阻的配合有限制。稳妥起见,把SPI预分频配得大一点,CPU占用率完全不是问题,毕竟一次读取也就几十微秒的时间。

关于工作模式,PS2手柄有红灯(模拟模式ANALOG)和绿灯(数字模式DIGITAL)之分。做机械臂控制时一定要确保手柄背部的模式指示灯是红灯亮起(也就是ANALOG模式),否则摇杆数据是读不出来的。这一条我当年第一次实验时就踩过坑,在绿灯模式下按键都正常,摇杆数据却全是0x7F(中间值),排查了很长时间才反应过来是模式开关的问题。

2.2 PWM舵机与总线舵机的选型对比

PWM舵机就是我们常说的模拟舵机/标准舵机,它接收周期20ms、高电平脉宽0.5ms到2.5ms的PWM信号,脉宽大小对应舵机输出轴的角度。它的优点是技术成熟、结构简单、价格较低,缺点是精度有限、无法回传角度信息,而且多路舵机运动时容易互相干扰。

总线舵机则完全不是一回事。它本质上是一个带MCU的智能伺服系统,内部集成了驱动、减速器、角度传感器和通信接口。常见的总线舵机使用UART半双工通信,通过一根信号线把所有舵机串起来,每个舵机分配唯一的ID地址。主机发送包含ID和位置信息的指令数据帧,舵机解析后执行动作,同时还可以把当前角度、温度、电压等状态回传给主机。

两种舵机的控制差异对项目架构的影响非常大。如果只支持PWM舵机,代码里只需要定时器配置好PWM输出,然后用一个简单的数组管理所有舵机的角度目标值即可;如果只支持总线舵机,那代码的核心就是串口数据帧的封装与解析。而本项目要同时支持这两种方案,就必须在代码架构上把“控制策略”和“底层驱动”做两层抽象,统一对外提供“设置舵机角度”的接口,内部再根据配置决定走PWM通道还是总线通道。

下面这个表格是我在做硬件选型对比时整理的,贴出来供大家参考。

对比维度PWM舵机总线舵机
控制信号类型20ms周期PWM脉宽UART半双工数据帧
角度反馈能力无支持,可回传角度/温度/电压
级联方式每路一个定时器通道,独立接线单总线串联,舵机并联挂载
接线复杂度低,但线束多高,但线束少
控制精度受限于PWM分辨率数字控制,精度更高
典型成本低高
故障排查直接看波形,逻辑简单需要协议分析工具

如果你做的机械臂自由度不多(2到4轴),预算又敏感,PWM舵机是完全够用的方案;如果项目有较高精度要求,或者需要机械臂自动回零、巡检等复杂功能,总线舵机的角度反馈优势就很明显了。这个项目把两种方案都做好了,本质上是你拿到工程后,改一个宏定义就能在两套体系间切换,自由度非常高。

2.3 为什么选择STM32作为主控

ST公司的STM32系列,特别是F103和F407两代主力型号,几乎成了国内机器人DIY圈子的默认选择。选择STM32做这个项目的主控,理由其实很务实:

  • 外设资源完整。PS2需要SPI,PWM舵机需要高级定时器,总线舵机需要UART,这些标准外设在STM32上都是现成的,用HAL库配置起来也很快。
  • 片上Flash和RAM对这类控制任务绰绰有余。一条SPI读手柄的代码加几条PWM配置,再嵌入一个简单的状态机,整个工程的空间占用其实非常小。
  • 生态资料极其丰富。无论是野火、正点原子这些开发板厂商的资料,还是ST官方提供的HAL库例程,都能让开发者迅速上手。遇到问题网上搜一下基本都有对应的讨论。
  • 价格透明、购买方便。国产替代型号(如GD32、APM32)也完全兼容,进一步拉低了硬件门槛。

有一点要强调的是,这个项目的逻辑核心其实并不依赖某个特定型号的强算力,更考验的是你对单片机外设的综合调度能力。用STM32F103C8T6这种“小蓝板”就足以跑得很流畅了。当然,如果你手上正好是F407系列,那更是杀鸡用牛刀,性能上毫无压力。

3. 硬件准备与接口接线

3.1 硬件清单

动手实验之前,先把必备的硬件列一个清单,避免做的时候来回等快递:

硬件名称规格/型号数量备注
STM32开发板STM32F103C8T6 或兼容板1核心板即可,不需要带屏
PS2无线手柄套装含接收器1注意买带接收器的完整套装
PWM舵机SG90/MG996R等2-4模拟舵机,注意扭矩匹配
总线舵机LX-16A / SCS15 / STS3215 等2-4需确认通信协议为串口半双工
舵机电源5V/6V开关电源或锂电池+降压模块1电流能力按舵机数量预留余量
机械臂支架亚克力或3D打印件1套方便把舵机固定起来
杜邦线/电源线若干若干建议备不同颜色方便区分
逻辑电平转换模块3.3V-5V双向模块1如果PS2接收器是5V供电但IO电平不匹配时使用

这里要专门提醒一句:舵机的供电是很容易翻车的环节,而且翻车后果比较严重——轻则舵机无力,重则烧毁开发板电源芯片。PWM舵机尤其是MG996R这类大扭矩舵机,启动瞬间电流可以冲到1A甚至更高,而STM32开发板上的AMS1117稳压芯片能扛住的总电流也就几百毫安量级。所以正确的做法一定是:开发板用USB或独立5V供电,舵机单独用电源供电,两套电源的GND必须可靠共地。GND共地这一条特别容易被新手忽略,不共地的话PWM信号是完全没有参考基准的,舵机根本不会正常工作。

3.2 PS2接收器接口定义与接线

PS2接收器通常引出6个引脚:GND、VCC、DAT(数据)、CMD(命令)、CS(片选)、CLK(时钟)。不同厂家模块的丝印可能略有差异,但功能是一致的。

引脚名称功能说明接STM32引脚
GND电源地GND
VCC电源正极(3.3V-5V)3.3V或5V
DATSPI MISO(数据输出)PA6(SPI1_MISO)
CMDSPI MOSI(数据输入)PA7(SPI1_MOSI)
CS片选信号PA4(SPI1_NSS,软件控制)
CLK时钟信号PA5(SPI1_SCK)

关于供电电压,如果接收器用的是5V供电,DAT引脚输出的高电平也接近5V。对于STM32F103的大部分IO来说,直接在数据引脚上读5V电平在实践中有一定容忍度,但终归不太稳妥。更安全的做法是把VCC接3.3V,实测绝大多数PS2接收器在3.3V供电下都能正常工作。如果手里的模块对电压要求比较严格,那就加一个3.3V-5V的双向电平转换模块,把DAT引脚的5V电平降到3.3V再接给PA6。

CS引脚的片选控制建议直接用软件拉高拉低,不要依赖SPI硬件自动管理的NSS,这样时序上更可控。因为PS2通信的字节时序中间还要求CS的低电平持续时间覆盖完全部9个字节,用硬件NSS反而容易出奇怪的问题。

3.3 PWM舵机接口接线

PWM舵机的三根线颜色基本统一:棕色是GND、红色是VCC(5V/6V)、橙色或黄色是信号线。

接线方法很简单:每一路舵机的信号线分别接一个能输出PWM的定时器通道引脚,GND和VCC统一接到舵机电源的正负极。拿STM32F103C8T6来说,定时器1的四个通道(PA8、PA9、PA10、PA11)可以输出四路独立PWM,刚好覆盖一个四自由度机械臂的需求。如果舵机数量更多,可以再把定时器2的通道(PA0、PA1、PA2、PA3)也用上。

这里有一个比较关键的细节,就是PWM舵机的信号线并不需要额外串联电阻,直接把GPIO配置成推挽输出即可。但要注意在程序初始化的瞬间,务必将所有PWM输出引脚设置为一个安全脉宽(比如1.5ms对应的中位值),避免开发板上电瞬间舵机疯狂转动到极限位置,撞到机械限位。这个问题很多人都是在机械臂做出来之后才发现的,但最好的习惯是从第一步写代码就建立起来。

3.4 总线舵机接口接线

总线舵机的接线要比PWM舵机简单很多,因为它采用的是一根半双工信号总线。以LX-16A为例,舵机引出三根线:GND、VCC和SIG(信号线)。把所有总线舵机的三根线并联,SIG线统一接到STM32的一个UART TX引脚即可。

关键问题在于,总线舵机通常工作在5V甚至更高电压下,而它的SIG信号线是双向的——不但要接收主机发来的指令,还要把舵机状态数据反馈给主机。这就意味着不能简单地用STM32的普通UART TX引脚去驱动它,因为普通推挽输出无法实现“同一根线既发又收”的半双工通信。

解决这个问题的常用方案有两种:

  • 用带方向控制引脚的电平转换芯片,比如MAX485或者SN65HVD72这类RS485收发器,把UART的单端信号转换为半双工总线信号。这也是工业总线舵机最常见的接法。
  • 用舵机厂家配套的“总线舵机转接板”或“调试板”。这些小板子内部已经集成了电平转换和方向切换逻辑,接上就能用,最省事。

在STM32侧的代码实现上,需要配置UART的TX引脚为复用推挽,再配合一个GPIO控制收发方向。发送数据前把方向引脚置为发送模式,发送完一帧后立即切回接收模式。这里节奏控制很关键,需要在发送完成的最后一个字节中断里切换方向,否则会漏掉舵机回传数据的前几个字节。具体代码后面会详细展开。

4. 核心代码实现与关键参数

4.1 PS2手柄数据读取的完整实现

PS2手柄数据读取使用SPI1外设,配置为主模式。下面给出基于HAL库的核心代码框架,这段代码经过了实际测试,可以直接用在F103上。

#include "stm32f1xx_hal.h" // 定义SPI句柄 SPI_HandleTypeDef hspi1; // CS引脚控制宏 #define PS2_CS_LOW() HAL_GPIO_WritePin(PS2_CS_GPIO_Port, PS2_CS_Pin, GPIO_PIN_RESET) #define PS2_CS_HIGH() HAL_GPIO_WritePin(PS2_CS_GPIO_Port, PS2_CS_Pin, GPIO_PIN_SET) // 存储手柄数据的结构体 typedef struct { uint8_t data[9]; // 原始9字节数据 uint8_t Lx; // 左摇杆X轴 0~255 uint8_t Ly; // 左摇杆Y轴 0~255 uint8_t Rx; // 右摇杆X轴 0~255 uint8_t Ry; // 右摇杆Y轴 0~255 uint8_t btnSelect; // SELECT键,1按下 uint8_t btnStart; // START键 uint8_t btnUp; // 方向键上 uint8_t btnDown; // 方向键下 uint8_t btnLeft; // 方向键左 uint8_t btnRight; // 方向键右 uint8_t btnTriangle; // 三角键 uint8_t btnCross; // 叉键 uint8_t btnSquare; // 方块键 uint8_t btnCircle; // 圆圈键 uint8_t btnL1; uint8_t btnL2; uint8_t btnR1; uint8_t btnR2; } PS2_Data_t; PS2_Data_t ps2_data; // 发送一个字节,并同时读取一个字节 uint8_t PS2_SPI_TransferByte(uint8_t byte) { uint8_t rxbuf = 0; HAL_SPI_TransmitReceive(&hspi1, &byte, &rxbuf, 1, 100); return rxbuf; } // 读取一次完整的手柄状态 void PS2_ReadData(void) { uint8_t cmd[9] = {0x01, 0x42, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}; uint8_t temp[9] = {0}; PS2_CS_LOW(); // 发送很短延时,确保CS稳定 for (int i = 0; i < 10; i++) { __NOP(); } for (int i = 0; i < 9; i++) { temp[i] = PS2_SPI_TransferByte(cmd[i]); } PS2_CS_HIGH(); // 校验同步码 if (temp[0] != 0x5A || temp[1] != 0x5A) { return; // 通信异常,直接丢弃本次数据 } // 保存原始数据 memcpy(ps2_data.data, temp, 9); // 解析按键(低电平有效,取反) ps2_data.btnSelect = (temp[3] & 0x01) ? 0 : 1; ps2_data.btnL1 = (temp[3] & 0x02) ? 0 : 1; ps2_data.btnR1 = (temp[3] & 0x04) ? 0 : 1; ps2_data.btnStart = (temp[3] & 0x08) ? 0 : 1; ps2_data.btnUp = (temp[3] & 0x10) ? 0 : 1; ps2_data.btnRight = (temp[3] & 0x20) ? 0 : 1; ps2_data.btnDown = (temp[3] & 0x40) ? 0 : 1; ps2_data.btnLeft = (temp[3] & 0x80) ? 0 : 1; ps2_data.btnTriangle = (temp[4] & 0x10) ? 0 : 1; ps2_data.btnCircle = (temp[4] & 0x20) ? 0 : 1; ps2_data.btnCross = (temp[4] & 0x40) ? 0 : 1; ps2_data.btnSquare = (temp[4] & 0x80) ? 0 : 1; ps2_data.btnL2 = (temp[5] & 0x01) ? 0 : 1; ps2_data.btnR2 = (temp[5] & 0x02) ? 0 : 1; // 解析摇杆 ps2_data.Lx = temp[6]; ps2_data.Ly = temp[7]; ps2_data.Rx = temp[8]; // 注意:有些手柄布局返回的数据略有差异,需要根据实际情况调整 }

这里补充两点心得。第一,校验码的判断必须严谨,不能偷懒省掉。无线通信过程中偶尔出现一两个字节的误码是完全正常的,如果不加校验直接把错误数据送进控制逻辑,机械臂可能会猛地抖一下。虽然不至于造成大事故,但体验会非常糟糕。第二,摇杆的数值并不一定是稳定平滑的,直接读取的值会有些抖动,表现为机械臂末端轻微颤动。要解决这个问题,就需要在读取摇杆数据后加一个低通滤波或者死区处理。

4.2 PWM舵机驱动的配置与调参

PWM舵机的驱动核心是把定时器配置为PWM输出模式,并设置合适的周期和脉宽。以STM32F103的TIM1为例,假设系统时钟72MHz,预分频设置为71,那么定时器计数频率是1MHz,即每个计数单位对应1微秒。要实现20ms周期,只需要把自动重载值ARR设置为20000。此时CCR寄存器里的值乘以1微秒就是高电平脉宽。

0.5ms脉宽对应0度,2.5ms脉宽对应180度,因此角度到CCR值的换算公式为:

CCR = 500 + (angle / 180.0f) * 2000

也就是角度0时CCR=500,角度180时CCR=2500。中间值90度对应1500,也就是1.5ms,这是大多数舵机的机械中位。

这里贴一下HAL库下的PWM初始化代码:

void PWM_Servo_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; TIM_OC_InitTypeDef sConfigOC = {0}; // 启用TIM1和对应GPIO的时钟 __HAL_RCC_TIM1_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); // PA8 复用为TIM1_CH1 GPIO_InitStruct.Pin = GPIO_PIN_8; GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // TIM1 时钟源 = 72MHz,预分频71 -> 1MHz计数频率 htim1.Instance = TIM1; htim1.Init.Prescaler = 71; htim1.Init.CounterMode = TIM_COUNTERMODE_UP; htim1.Init.Period = 20000; htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter = 0; htim1.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(&htim1); // PWM模式1,初始脉宽调到中位1500us sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 1500; sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCNPolarity = TIM_OCNPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; sConfigOC.OCIdleState = TIM_OCIDLESTATE_RESET; sConfigOC.OCNIdleState = TIM_OCNIDLESTATE_RESET; HAL_TIM_PWM_ConfigChannel(&htim1, &sConfigOC, TIM_CHANNEL_1); // 启动PWM输出 HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); } void PWM_Servo_SetAngle(uint8_t channel, float angle) { if (angle < 0) angle = 0; if (angle > 180) angle = 180; uint32_t ccr = (uint32_t)(500 + (angle / 180.0f) * 2000); switch (channel) { case 0: __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, ccr); break; case 1: __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, ccr); break; case 2: __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_3, ccr); break; case 3: __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_4, ccr); break; } }

这一套代码跑起来之后,配合逻辑分析仪或者示波器,可以直接观察到20ms周期、脉宽随角度变化的标准PWM波形。在调试时我习惯在舵机信号线上挂一台示波器,转动摇杆时观察脉宽是否平滑变化,这比反复看程序日志更直观。

4.3 总线舵机的控制协议与实现

总线舵机的协议各家具体帧格式略有不同,但是整体框架都遵循“帧头+ID+命令+参数+校验”的结构。以常见的LX-16A为例,发送一帧位置控制指令的格式如下:

帧头帧头ID长度命令参数1参数2参数3参数4校验和
0x550x550x010x090x03角度低8位角度高8位时间低8位时间高8位校验

角度值的范围通常是0到1000,对应机械范围0到240度左右(不同型号会不一样,需要以自己舵机的数据手册为准)。时间参数的单位是ms,表示舵机从当前位置运动到目标位置所用的时间。

校验和的计算方法一般是:从帧头第二个字节开始到最后一个参数字节,所有数据求和后取低8位,再用255减去这个和。具体写法如下:

uint8_t bus_servo_checksum(uint8_t* data, int len) { uint8_t sum = 0; for (int i = 1; i < len; i++) { // 从帧头第二个字节开始 sum += data[i]; } return 255 - sum; } void BusServo_SetAngle(uint8_t id, uint16_t angle, uint16_t time_ms) { uint8_t frame[10]; frame[0] = 0x55; frame[1] = 0x55; frame[2] = id; frame[3] = 0x09; // 帧长度 frame[4] = 0x03; // 位置控制命令 frame[5] = angle & 0xFF; frame[6] = (angle >> 8) & 0xFF; frame[7] = time_ms & 0xFF; frame[8] = (time_ms >> 8) & 0xFF; frame[9] = bus_servo_checksum(frame, 9); // 切换为发送模式 BUS_DIR_TX(); HAL_UART_Transmit(&huart2, frame, 10, 100); // 等待最后一个字节发送完毕 while (__HAL_UART_GET_FLAG(&huart2, UART_FLAG_TC) == RESET); // 切换回接收模式,准备接收舵机回传状态 BUS_DIR_RX(); }

这段代码里最值得注意的就是方向切换的时机。如果发送完直接切换方向,UART硬件可能还在移位寄存器里没把最后一个字节完全送出,导致帧尾被截断。所以一定要等待TC(传输完成)标志位置位后再切换方向。

总线舵机在收到指令后,默认情况下会回传一帧状态数据(包含当前角度、电压、温度等)。这些数据可以用来做闭环报警,比如检测到电压低于6V时限制机械臂运动速度,或者检测到温度超过70度时停止动作。对于机械臂这类需要长时间运行的设备来说,这个状态监控能力非常实用。

4.4 摇杆映射与运动控制逻辑

有了手柄数据读取和舵机驱动这两块基础之后,剩余的核心工作就是把摇杆的数值映射成舵机的目标角度。这一节的逻辑直接决定了机械臂操作手感的好坏。

先明确一下控制方案。对于机械臂的每个关节,我建议把左右摇杆分别负责两组关节:左摇杆上下推控制基座旋转和肩关节,左右推控制肘关节和腕关节;右摇杆越简化越好,直接把上下方向映射到夹爪开合。这样布置符合大多数人的操作直觉,上手很快。

摇杆原始值范围是0到255,中位值是128。我们需要把它映射到舵机的角度范围。最简单的写法是:

float map_joystick_to_angle(uint8_t joy_value, float min_angle, float max_angle) { return (float)(joy_value - 128) / 128.0f * (max_angle - min_angle) / 2.0f + (max_angle + min_angle) / 2.0f; }

这个映射公式会把摇杆中位(128)对应到角度区间的中点。实测中,摇杆在复位时并不一定会精确回到128,往往会有几格的偏差。如果直接把原始值用于控制,机械臂会有轻微漂移。解决方案是在摇杆中位附近引入死区:

int joystick_deadband(int value, int center, int band) { if (value > center - band && value < center + band) { return center; } return value; }

死区宽度建议取8到16之间。太大会让操作变得迟钝,太小则无法完全消除抖动。在实际调参时我会结合机械臂末端的微小动作来判断:如果机械臂在中位附近静止时出现肉眼可见的颤动,就把死区加大2到4;如果操作时感觉有明显的空行程,就适当减小。

另外还有一个很实用的保护逻辑。机械臂的每个关节都有物理限位,舵机角度超出限位就会撞到机械结构,轻则舵机堵转发烫,重则打齿报废。在代码中为每个关节定义角度上下限,并在每次设置舵机前做一次限幅:

typedef struct { float min_angle; float max_angle; float current_angle; float target_angle; } Joint_t; Joint_t joints[4]; void Joint_SetTarget(uint8_t idx, float target) { if (idx >= 4) return; if (target < joints[idx].min_angle) target = joints[idx].min_angle; if (target > joints[idx].max_angle) target = joints[idx].max_angle; joints[idx].target_angle = target; }

限幅的同时还需要考虑速度控制。如果摇杆在短时间内被猛推到底,目标角度会瞬间变化很大,舵机会以最大速度冲过去,机械臂动作非常突兀,也容易造成晃动。解决办法是加一个简单的斜坡限速——每次更新时,目标角度最多朝最终目标靠近一个固定的步长:

float speed_limit(float current, float target, float max_step) { if (target > current + max_step) return current + max_step; if (target < current - max_step) return current - max_step; return target; }

max_step的值直接决定了机械臂的最大运动速度。对于常见的MG996R舵机,在5V供电下运行速度大约是0.17秒/60度,也就是每秒约350度。如果控制周期是10ms,那么max_step取0.35度左右时舵机处于全速状态。实战中我一般取0.2到0.3,让机械臂移动的时候看起来比较温和可控。

最后是状态机的实现。主循环通常长这样:

while (1) { PS2_ReadData(); // 更新目标角度(基于摇杆) uint8_t lx = joystick_deadband(ps2_data.Lx, 128, 10); uint8_t ly = joystick_deadband(ps2_data.Ly, 128, 10); Joint_SetTarget(0, map_joystick_to_angle(ly, 0, 180)); Joint_SetTarget(1, map_joystick_to_angle(lx, 0, 180)); // 按键控制夹爪 if (ps2_data.btnR1) { Joint_SetTarget(3, 0); // 夹爪闭合 } else if (ps2_data.btnR2) { Joint_SetTarget(3, 60); // 夹爪张开 } // 对每个关节做限速干预并输出 for (int i = 0; i < 4; i++) { joints[i].current_angle = speed_limit(joints[i].current_angle, joints[i].target_angle, 0.3f); PWM_Servo_SetAngle(i, joints[i].current_angle); // 如果是总线舵机模式,就调用 BusServo_SetAngle(i, angle_to_bus_value(...), 0) } HAL_Delay(10); }

看到这里你应该能发现,PWM舵机和总线舵机在这套控制逻辑之下的区别,仅仅在于最后一层“把角度发给舵机”的函数不同。只要把这一层抽象出来,上层逻辑完全可以复用。这也是这个工程能同时支持两种舵机体系的核心设计思路。

5. 常见问题与排查技巧实录

5.1 手柄数据读取失败的排查思路

在做PS2通信时遇到的最典型的问题,就是读回来的数据校验不对,temp[0]和temp[1]不是0x5A 0x5A。这个问题我从实践角度列一个由简到繁的排查顺序:

第一步,检查接线。这是最基础也是最容易出错的一步。DAT、CMD、CS、CLK四条线的位置是否和代码中的引脚定义一致?我遇到过不少因为杜邦线插错位置导致数据全乱的案例。建议先拿万用表测一下各引脚之间的通断,排除接触不良。

第二步,检查供电电压。接收器的VCC如果接到了5V,而STM32的IO容忍度不够或者模块的DAT输出能力不足,都可能导致数据异常。先尝试把VCC改成3.3V供电,看看能否恢复正常。

第三步,检查SPI时钟频率。前面提过,PS2接收器的通信速率不能太高。HAL库中通过修改SPI的预分频值,把时钟降到500kHz以下。很多从标准SPI设备移植过来的代码,直接把时钟设成了18MHz甚至36MHz,这在PS2上是跑不通的。

第四步,检查手柄模式。确认手柄背面的ANALOG灯是亮的。如果手柄处于数字模式,摇杆数据读取不到或者读数恒为中间值,按键数据倒是正常的。这一点在调试中最容易忽视,因为表面上看通信是通的,但就是数据不对。

第五步,用逻辑分析仪抓波形。如果不确定是哪里的时序出了问题,直接用一个便宜的8通道逻辑分析仪挂到CLK、CMD、DAT、CS四个引脚上,对比实际波形和数据手册要求。这一步能快速定位是主机发送命令的问题,还是从机返回数据的问题。能用示波器/逻辑分析仪精准定位问题,比盲猜代码高效十倍。

5.2 舵机抖动、无力与发热的排查

舵机表现异常时,不能只盯着程序,硬件供电往往才是根源。下面是几个常见的故障现象和对应的排查方向:

故障现象可能原因排查方法
舵机静止时抖动PWM信号不稳/电源纹波大用示波器看电源电压,加装大电容(1000uF以上)
舵机无力、转速慢供电电压偏低或电流不足万用表实测舵机电源电压,确认电源额定电流足够
舵机空载正常、带载发热机械结构卡涩或负载过大拆下舵机手动转动各关节,检查机械装配是否顺畅
多路舵机同时运动时重启瞬时电流过大拉低电压增加电源功率,或在电源端并联一个大容量电解电容
总线舵机偶尔不动作总线信号反射/方向切换时序不对检查总线终端电阻,确认方向切换在TC之后执行

这里想单独聊一下“抖动”这个问题。PWM舵机的抖动大多数时候并不是程序逻辑错误,而是电源质量不佳。因为PWM舵机内部控制电路是根据脉宽来判断角度的,如果供电电压在脉宽的高电平期间发生明显跌落,控制信号的有效电平就会被干扰,舵机内部比较器就会产生误判,表现为静止时不断发出吱吱声或来回小幅摆动。解决办法是在舵机电源两端并联一个大容量电解电容(470uF到2200uF,耐压10V以上),利用电容的储能效应吸收瞬态电流尖峰。这个做法在电源质量一般的场景下往往立竿见影。

总线舵机不动作的问题则多是方向切换时序不对造成的。很多人用的是HAL_UART_Transmit函数,跟着在函数返回后立刻切换方向,这其实是错误的。因为Transmit函数返回时,数据可能还在发送移位寄存器里,只是已经填入了发送缓冲。一定要等到UART_FLAG_TC置位后再切换方向。另外,总线舵机之间的连接线如果距离较长,建议在总线末端并联一个120欧姆的终端电阻,减少信号反射。

5.3 机械臂上电瞬间的“抽搐”问题

这个问题我当年第一次做机械臂时就撞上了:一上电,机械臂所有关节猛地一下全部转到极限位置,然后又慢慢回来。造成这个现象的原因非常典型——STM32的GPIO在复位期间,所有引脚都是高阻态(浮空输入),而舵机信号线在没有被主控芯片驱动前,外部干扰或上拉到高电平,会让舵机误认为收到一个极限位置的PWM信号。

解决思路主要靠硬件和软件两个层面的配合。

硬件上,可以在每个PWM信号线对地并联一个10k到47k的下拉电阻。这样在MCU复位期间,信号线被电阻拉低,舵机不会收到有效脉宽。这个电阻在MCU正常工作后不会对PWM信号产生影响,因为GPIO的推挽输出能力远大于下拉电阻的泄放作用。

软件上,在main函数最开始、初始化定时器之前,先把所有PWM引脚配置为推挽输出低电平,然后再初始化外设。这样即使代码在后续某个环节卡死,舵机也不会乱动。我在工程模板中专门为这个初始化顺序写过注释,提醒后来者不要随意调整。

还有一个容易被忽略的细节是:如果使用总线舵机,在上电期间主控如果不在总线上发送有效数据帧,舵机一般不会动作。但有一个例外——部分总线舵机在刚上电时会进入一个“松开”状态,机械臂会因重力自然下垂。这属于舵机的正常行为,但对于机械臂来说,运动到物理极限位置总会让人心里一紧。如果项目对这个很敏感,可以考虑给舵机加一个“使能”控制,让程序在初始化完成后再释放舵机刹车。不过这部分逻辑与具体舵机型号强相关,需要根据你手里的舵机手册来设计。

5.4 代码架构的一点建议

最后从工程维护角度再唠叨几句。STM32的HAL库工程结构本来就松散,如果直接在main函数里堆代码,刚开始写起来挺爽,等代码量上来之后改起来会非常痛苦。建议在工程里按照功能拆分模块:

模块文件职责
ps2.c / ps2.h手柄数据读取与解析
servo_pwm.c / servo_pwm.hPWM舵机驱动
bus_servo.c / bus_servo.h总线舵机驱动
joint.c / joint.h关节管理(限位、限速、映射)
main.c初始化与主循环控制逻辑

这样做的好处很直接:将来想换一个手柄协议(比如换成2.4G遥控器),只需要改ps2模块;想换一个总线舵机品牌,只需要改bus_servo模块的帧格式解析。各个模块之间通过接口函数耦合,而不是直接在main里操作寄存器,整体可读性和可维护性都会好很多。

6. 实测体验与经验小结

这个项目从最初只有PWM舵机支持,到后来把总线舵机也塞进同一个工程,中间经历了不少反复调试的过程。把这段经历写出来,希望能帮后来者少走一些弯路。

先说PS2手柄的整体体验。在ANALOG模式下,两路摇杆的手感足够细腻,128级的分辨率对机械臂控制来说已经绰绰有余。按键方面,L1/R1、L2/R2四个肩键最适合用来控制夹爪开合和切换机械臂的“速度档位”。我个人习惯把L1设为慢速模式、R1设为快速模式,通过切换speed_limit的max_step参数来改变机械臂运动速度。在精细对位时用慢速档,在空程移动时用快速档,操作体验会好很多。

再说总线舵机相对于PWM舵机的优势,在实际操作中确实能明显感觉到。总线舵机的角度控制是数字式的,回中精度高,且没有PWM舵机那种“吱吱”的静态抖动声。另外,总线舵机支持直接读取当前角度,这让机械臂具备了位置闭环的基础——即使外力把某个关节碰歪了,系统也能感知到偏差并自动纠正回来。当然,代价是总线舵机价格更高、协议更复杂,对新手来说调试门槛也确实高一些。

有一点我发现很值得注意:两种舵机混用并不一定非要做二选一。在实际项目中,完全可以基座和肩关节用总线舵机(承载力强、能回传状态),腕部和夹爪用PWM舵机(成本低、体积小),只要在代码里同时保留两套驱动模块,并根据关节ID分别调用不同的驱动函数即可。这个工程虽然默认是全PWM或全总线模式,但稍微改造一下就能支持混合模式,灵活性很强。

最后补充一个小技巧,是关于调试效率的。不论做PWM舵机还是总线舵机控制,调试时都尽量不要用printf串口输出大段调试信息,因为串口打印本身会占用CPU时间,影响PWM信号的实时性。更科学的做法是:用串口输出关键变量的“变化值”,比如只在摇杆进入或离开死区时打印一次当前角度和目标角度,再配合OLED屏或者串口屏显示当前状态。这样既能保证实时性,又能方便观察系统行为。

说实话,做机械臂控制这个方向,难度并不在于某个单一技术点,而在于把所有模块整合在一起后保持系统稳定可靠。PS2手柄通信、PWM输出、总线舵机协议,每个部分拆开来看都不复杂,但当它们组合成一个实时系统后,电源、时序、初始化顺序这些工程细节就会逐一浮现出来。这个项目恰好覆盖了这些坑,你要是能把它全部走通,再去做更复杂的机械臂视觉抓取、路径规划项目,底子会打得非常扎实。

如果你正在做类似的机械臂项目,或者在这个工程上二次开发时遇到了新的问题,欢迎在评论区留言讨论。我踩过的坑,未必就比你少,但多一个人一起排查,总能更快找到出路。

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

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

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

立即咨询