☰
基于STM32的六自由度机械臂拖拽示教与轨迹复现详解
2026/9/27 12:13:52 网站建设 项目流程

上一期我们把六自由度机械臂的结构和基础舵机控制跑通之后,很多朋友留言问:“能不能像工业机械臂那样,先用手拖着机械臂摆几个姿势,让它记住,然后自己重复执行?” 这个功能叫直接示教,也叫拖拽示教,是机械臂入门里最直观、也最容易做出成就感的玩法。

这一期教程围绕 STM32F103 单片机展开,重点解决三件事:

  1. 如何把关节角度实时读回单片机;
  2. 如何进入“示教记录”状态,拖动机器人时按固定周期保存关节角度;
  3. 如何把记录下来的轨迹平滑回放,让机械臂自行复现刚才的动作。

如果你手里有第一期的六自由度机械臂结构件,这一期不需要大改硬件,主要是把传感器回馈和控制状态机补上。需要完整材料包清单的,可以在评论区留言或私信交流,我会把装配图和接线表一起发给你。

下面我们直接从原理开始,再进入 STM32F103 的代码实现。

1. 项目背景:让机械臂从“会动”到“能记住动作”

1.1 这一期要解决什么问题

传统 DIY 机械臂控制,通常是在代码里写死一组角度:

// 写死一组动作 Servo_SetJointAngle(0, 90); Servo_SetJointAngle(1, 45); Servo_SetJointAngle(2, 30);

这种方法的问题是:每改变一次动作,就要重新改代码、重新编译、重新烧录。如果只是让机械臂做固定演示问题不大,但如果想让机械臂学会一段连续动作,比如“从左边抓起来,转到右边放下”,写死角度的工作量会非常大,而且很难调出流畅轨迹。

所以这一期的目标就是做一个“示教器”功能:

用手拖动机器臂,机械臂实时记录各个关节的角度变化; 拖完松开,按一下回放键,机械臂自己把刚才的动作重复出来。

这个功能在工业上称为示教再现,在小型六自由度机械臂上同样可以实现。

1.2 直接示教和关节空间轨迹

工业机械臂的示教通常有两类:

示教方式说明难度
离线示教在软件里通过运动学计算轨迹,再上传给机器人需要 DH 参数、正逆解
直接示教人工拖动机器臂,系统记录关节角度不需要运动学计算,实现简单

本文采用直接示教。它的核心思路是:机械臂末端走什么路,最终都体现在六个关节角度的变化上。如果我们不关心末端在世界坐标系里的绝对位置,只记录每个关节在每一时刻的角度值,那么回放时只需要按照同样的时间线去驱动六个舵机就可以了。

这就是“关节空间轨迹复现”。

它和“笛卡尔空间轨迹规划”有本质区别:

  • 关节空间:只记录关节角度,适合复现一段动作;
  • 笛卡尔空间:需要解算末端位置,适合抓取、写字、画圆等任务。

对于本项目的直接示教需求,关节空间记录已经足够,不需要引入逆运动学,这也是它能跑在 STM32F103 上的原因。

2. 整体架构与信号流程

2.1 系统连接图

先看机械臂控制系统的信号链路,建议在脑子里建立一份数据流图:

复位/串口指令 -> STM32F103 状态机 -> PWM 信号 -> 六路舵机 ^ | | | 关节角度采集 <------ 电位器/编码器传感器

简单来说:

  1. STM32F103 用定时器输出 6 路 PWM 控制舵机角度;
  2. 每个关节旁边装有角度传感器,通过 ADC 把电位器电压读回单片机;
  3. 示教时,舵机允许被外力拖动,同时角度传感器持续输出角度值;
  4. 回放时,单片机根据已经记录的角度数组,反推出舵机 PWM,让机械臂复现动作。

这里需要特别注意:示教时记录的是“传感器测到的实际关节角度”,而不是“代码里发送给舵机的目标角度”。否则一旦舵机堵转或负载导致实际角度没到位,记录和回放就会产生误差。

2.2 工作状态机设计

为了让代码清晰,机械臂整体运行状态用一个状态机来管理:

状态说明进入条件
IDLE 空闲机械臂保持当前位置上电复位、任意功能结束
RECORD 示教记录舵机释放扭矩,允许拖动,同时保存轨迹按下“记录键”或收到 R 指令
PLAY 轨迹回放按记录数据驱动舵机运动按下“回放键”或收到 P 指令
STOP 停止停止当前动作,回到空闲按下“停止键”或收到 S 指令

如果还需要掉电保存,可以在回放前增加一个“读取存储”步骤;如果不需要,直接放在 RAM 中就行。

2.3 为什么需要定时器中断来管理采样

机械臂轨迹回放最怕的就是“时间不均匀”。

例如我们计划每 20ms 采样一个点,如果不用定时器中断,而只是在while循环里用HAL_Delay采样,那么串口接收、按键判断、舵机刷新都会干扰采样周期,导致记录下来的轨迹时间轴忽快忽慢,回放时动作就会卡顿。

因此,本项目必须把“固定周期采样”和“固定周期回放”放到定时器中断里处理,主循环只负责接收指令和刷新状态。

3. 核心原理拆解

3.1 如何拖动机器臂并读取关节角度

要让机械臂可以“用手拖着动”,首先得让舵机不再锁死关节。

这里有一个常见的误区和坑点:普通 PWM 舵机并没有标准的“脱离扭矩”指令。很多舵机只要不断电、不撤 PWM,就算我们发送中位信号,它也会持续抵抗外力;而如果线断了,有些舵机反而会因为内部减速齿轮自锁,依然很难手动转动。

所以在直接示教方案中,推荐两种硬件做法:

方案 A:使用带扭矩关闭功能的总线舵机

总线舵机(如 ST3215、LX-16A 等)通常支持:

  • 读取当前角度;
  • 设置舵机扭矩关闭;
  • 扭矩关闭后可以用手转动关节;
  • 转动过程中持续读回当前角度。

这种方式最省事,硬件接线也简单。缺点是总线舵机价格更高,需要额外的串口协议驱动。

方案 B:普通 PWM 舵机 + 关节角度传感器

在示教阶段,给全部或单个舵机断电,让关节可以自由活动;此时关节处的电位器或编码器通过 ADC 读取角度。回放阶段再给舵机通电,由 PWM 指令复现角度。

这种方案更适合低成本材料包。本文后面的代码围绕“PWM 舵机 + ADC 角度传感器”的思路展开,同时把舵机断电和角度读取都封装成独立接口。如果你使用总线舵机,只需要替换这两个接口函数即可。

很多朋友在第一次做拖拽示教时,直接把舵机线从舵机端拔掉,然后发现还是转不动,这是因为舵机减速齿轮箱的自锁阻力太大。此时建议在关节轴的另一侧并联一个角度电位器,并确保机械结构上可以手动带动关节转动,而不要强行去滚舵机输出轴。

3.2 PWM 舵机控制原理

普通模拟/数字舵机的控制信号是 50Hz 的周期脉冲,周期 20ms,脉宽对应角度。

脉宽典型角度
0.5ms0°
1.0ms45°
1.5ms90°
2.0ms135°
2.5ms180°

因此我们只需要让 STM32F103 的定时器产生 20ms 周期、脉宽可变的 PWM,就能控制舵机角度。

假设定时器时钟为 72MHz,预分频为 72,得到 1MHz 的计数频率,也就是每个计数单位为 1us。要让 PWM 周期为 20ms,重装载值设为:

20000us - 1 = 19999

脉宽 PWM 占空比对应比较寄存器的值:

CCR = 500 + angle / 180.0f * 2000;

其中:

  • 500 对应 0.5ms 脉宽;
  • 2000 对应 2.0ms 脉宽范围;
  • 500 + 2000 = 2500,对应 2.5ms,即 180°。

3.3 轨迹采样与插值回放

机械臂示教记录,实际上是记录一条“角度-时间”曲线。

例如:

时间 0ms :关节角度 [ 10°, 20°, 30°, 40°, 50°, 60°] 时间 20ms :关节角度 [ 15°, 22°, 29°, 43°, 52°, 61°] 时间 40ms :关节角度 [ 23°, 25°, 28°, 47°, 55°, 63°] ...

回放时,最简单的思路是每隔 20ms 把记录的采样点依次发给舵机。

但是,如果采样间隔较长,比如 50ms,回放时舵机会一顿一顿地走。为了保证流畅,通常需要对相邻两点做线性插值。

线性插值公式为:

当前角度 = 前一点角度 + (后一点角度 - 前一点角度) * (当前时间 - 前一点时间) / (后一点时间 - 前一点时间)

当采样周期固定为 20ms 时,插值计算可以进一步简化为表格索引和比例运算。

4. 硬件与环境准备

4.1 材料清单

下面以低成本材料包为例:

模块数量说明
STM32F103C8T6 最小系统板1主控
六自由度机械臂结构件1 套铝合金或 3D 打印
PWM 舵机6关节执行器
旋转电位器/角度传感器6并联在关节轴上
舵机电源 5V~7.4V1电流建议 3A 以上
单独 3.3V 稳压1给 MCU 和传感器供电
USB 转串口模块1调试可选
按键若干2~3记录、回放、停止

很多材料包里六个舵机并不是完全同型号,但只要 PPM/PWM 信号标准一致,代码层面可以不区分。真正的区别在于扭矩和耗电,所以电源需要按最大堵转电流预留。

4.2 软件环境与版本建议

本文代码基于 HAL 库:

  • STM32CubeMX 6.x;
  • Keil MDK 5.x 或 STM32CubeIDE;
  • STM32F1 HAL 库。

不同版本生成的代码结构略有差异,但下面的核心逻辑都可以直接移植。版本不建议刻意追求最新,能正常生成工程即可。

4.3 接线与电源注意事项

六个舵机同时运动时,瞬时电流可能达到 2A 以上。如果使用 AMS1117 这类小电流 LDO 给 MCU 供电,舵机会把电源电压拉低,导致 STM32 复位。

建议采用以下供电结构:

电池/电源适配器 -> 舵机电源模块 -> 舵机 -> 5V/3.3V LDO -> STM32F103

并且注意:

  • MCU 的 GND 和舵机的 GND 必须共地;
  • 舵机 PWM 信号线不建议直接从舵机驱动板绕太长;如果信号线超过 20cm,可以加 1kΩ 串联电阻;
  • 电位器中间抽头接 ADC,另外两端分别接 3.3V 和 GND,不要接 5V,否则会超过 STM32F103 ADC 输入范围。

5. STM32F103 工程初始化

5.1 用 CubeMX 创建基础工程

在 STM32CubeMX 中需要开启以下资源:

  • RCC:外部晶振或内部晶振;
  • SYS:Debug Serial Wire;
  • 定时器:一个用于产生 6 路 PWM,一个用于产生 10ms 系统节拍;
  • ADC:至少 6 个通道,用于角度传感器;
  • USART1:用于调试和串口指令;
  • GPIO:三个按键,分别是记录、回放、停止。

如果 MCU 的定时器通道不够,可以使用两组定时器,例如:

舵机定时器/通道说明
舵机 0TIM2_CH1示例映射,按实际接线调整
舵机 1TIM2_CH2示例映射
舵机 2TIM2_CH3示例映射
舵机 3TIM2_CH4示例映射
舵机 4TIM3_CH1示例映射
舵机 5TIM3_CH2示例映射

角度传感器供电为 3.3V 时,ADC 精度按 12 位处理,分辨率约 0.08° 左右,已经足够用于舵机示教回放。

5.2 定时器参数建议

PWM 定时器配置如下:

时钟源:内部时钟 72MHz 预分频 PSC:71 计数周期 ARR:19999 输出比较模式:PWM Mode 1 脉冲初始值:1500(对应 1.5ms 中位)

系统节拍定时器可以配置为:

节拍周期:10ms 中断优先级:高于主循环

10ms 节拍用于轨迹记录和回放刷新。如果希望轨迹更细腻,也可以改成 5ms,但要注意记录点数会增加,RAM 消耗也随之翻倍。

6. 核心代码编写

下面开始编写机械臂示教代码。为了便于阅读,代码按功能拆成几个文件,你可以直接新建到自己的 Keil/CubeIDE 工程中。

6.1 舵机 PWM 模块

新建servo_ctrl.h:

#ifndef __SERVO_CTRL_H #define __SERVO_CTRL_H #include "main.h" #include <stdint.h> #define SERVO_NUM 6U void Servo_InitAll(void); void Servo_SetJointAngle(uint8_t index, float angleDeg); void Servo_TorqueOffAll(void); uint32_t Servo_AngleToPulse(float angleDeg); #endif

新建servo_ctrl.c:

#include "servo_ctrl.h" // 如果使用 CubeMX 生成,htim2、htim3 是全局变量 extern TIM_HandleTypeDef htim2; extern TIM_HandleTypeDef htim3; // 每个舵机使用的定时器句柄,按实际接线修改 static TIM_HandleTypeDef* servoTimer[SERVO_NUM] = { &htim2, &htim2, &htim2, &htim2, &htim3, &htim3 }; // 每个舵机使用的定时器通道 static uint32_t servoChannel[SERVO_NUM] = { TIM_CHANNEL_1, TIM_CHANNEL_2, TIM_CHANNEL_3, TIM_CHANNEL_4, TIM_CHANNEL_1, TIM_CHANNEL_2 }; void Servo_InitAll(void) { // PWM 定时器已经在 MX_TIM2_Init / MX_TIM3_Init 中初始化 // 这里开始输出中位脉冲 for (uint8_t i = 0; i < SERVO_NUM; i++) { __HAL_TIM_SET_COMPARE(servoTimer[i], servoChannel[i], 1500); HAL_TIM_PWM_Start(servoTimer[i], servoChannel[i]); } } uint32_t Servo_AngleToPulse(float angleDeg) { // 控制角度限制在 0~180 if (angleDeg < 0.0f) { angleDeg = 0.0f; } if (angleDeg > 180.0f) { angleDeg = 180.0f; } // 0.5ms=500,2.0ms范围=2000,定时器计数频率1MHz return (uint32_t)(500.0f + (angleDeg / 180.0f) * 2000.0f); } void Servo_SetJointAngle(uint8_t index, float angleDeg) { if (index >= SERVO_NUM) { return; } uint32_t pulse = Servo_AngleToPulse(angleDeg); __HAL_TIM_SET_COMPARE(servoTimer[index], servoChannel[index], pulse); } void Servo_TorqueOffAll(void) { // 注意:普通 PWM 舵机停止 PWM 不一定等于“脱力” // 如果实测无法拖动,需要在舵机电源回路加 MOSFET 开关 for (uint8_t i = 0; i < SERVO_NUM; i++) { HAL_TIM_PWM_Stop(servoTimer[i], servoChannel[i]); } }

这里把Servo_TorqueOffAll()留作对外接口,具体如何断电需要根据硬件调整。若你的舵机在PWM_Stop后依然锁死,请在示教前断开舵机电源,这也是很多低成本机械臂最常采用的硬件实现。

6.2 关节角度采集模块

假设使用 ADC 连续采样,DMA 把六个通道的结果放到数组adcRaw[6]中。

新建joint_sensor.h:

#ifndef __JOINT_SENSOR_H #define __JOINT_SENSOR_H #include "main.h" #include <stdint.h> #define JOINT_NUM 6U void JointSensor_Init(void); float JointSensor_GetAngleDeg(uint8_t index); #endif

新建joint_sensor.c:

#include "joint_sensor.h" extern ADC_HandleTypeDef hadc1; static volatile uint16_t adcRaw[JOINT_NUM]; void JointSensor_Init(void) { // 启动 ADC DMA,连续转换 6 个通道 HAL_ADC_Start_DMA(&hadc1, (uint32_t *)adcRaw, JOINT_NUM); } float JointSensor_GetAngleDeg(uint8_t index) { if (index >= JOINT_NUM) { return 0.0f; } // 传感器输出电压 0~3.3V,对应 0~4095 // 机械量程按 0~180° 近似,实际装配后需要校准 float angle = ((float)adcRaw[index]) / 4095.0f * 180.0f; return angle; }

以上代码只适用于“电位器整圈对应 0~180°”的理想情况。真实安装中,电位器起始角度不一定和机械零点重合,而且每个关节机械限位不同。因此实际工程中还要加一个校准偏移量和缩放系数:

typedef struct { float offsetDeg; // 零点偏移 float scale; // 单位 ADC 值对应角度 } JointCalib_t; static const JointCalib_t jointCalib[JOINT_NUM] = { {0.0f, 180.0f / 4095.0f}, {0.0f, 180.0f / 4095.0f}, {0.0f, 180.0f / 4095.0f}, {0.0f, 180.0f / 4095.0f}, {0.0f, 180.0f / 4095.0f}, {0.0f, 180.0f / 4095.0f}, }; float JointSensor_GetAngleDeg(uint8_t index) { if (index >= JOINT_NUM) { return 0.0f; } float raw = (float)adcRaw[index]; return raw * jointCalib[index].scale + jointCalib[index].offsetDeg; }

每个材料包机械结构不同,强烈建议在正式示教前做一次“绝对零点对齐”:

  1. 把机械臂摆到一个容易测量的基准姿态;
  2. 用上位机或串口打印此刻六个 ADC 读数;
  3. 将读数换算成角度,记录到上面的校准结构体里。

6.3 示教记录与回放模块

这是整个功能的核心,包含状态机、记录缓冲区和插值回放算法。

新建drag_teach.h:

#ifndef __DRAG_TEACH_H #define __DRAG_TEACH_H #include <stdint.h> #define JOINT_NUM 6U #define MAX_POINTS 600U // 最大采样点数 #define SAMPLE_DT_MS 20U // 采样周期 20ms typedef enum { ARM_STATE_IDLE = 0, ARM_STATE_RECORD, ARM_STATE_PLAY } ArmState_t; typedef struct { uint16_t pointCount; int16_t jointAngle[MAX_POINTS][JOINT_NUM]; // 单位:0.1° } Trajectory_t; void DragTeach_StartRecord(void); void DragTeach_StartPlay(float speed); void DragTeach_Stop(void); void DragTeach_10msTick(void); ArmState_t DragTeach_GetState(void); #endif

新建drag_teach.c:

#include "drag_teach.h" #include "servo_ctrl.h" #include "joint_sensor.h" static Trajectory_t gTraj; static ArmState_t gState = ARM_STATE_IDLE; static float gPlaySpeed = 1.0f; static uint32_t gPlayTimeMs = 0; static uint16_t gRecordTick = 0; void DragTeach_StartRecord(void) { if (gState != ARM_STATE_IDLE) { return; } gTraj.pointCount = 0; gRecordTick = 0; // 示教前舵机释放扭矩 Servo_TorqueOffAll(); gState = ARM_STATE_RECORD; } void DragTeach_StartPlay(float speed) { if (gState != ARM_STATE_IDLE) { return; } if (gTraj.pointCount < 2) { // 没有有效轨迹 gState = ARM_STATE_IDLE; return; } gPlaySpeed = speed; gPlayTimeMs = 0; // 重新使能舵机 Servo_InitAll(); gState = ARM_STATE_PLAY; } void DragTeach_Stop(void) { gState = ARM_STATE_IDLE; } ArmState_t DragTeach_GetState(void) { return gState; } static void Record_Tick(void) { if (gTraj.pointCount >= MAX_POINTS) { // 记录缓冲区已满,自动结束 gState = ARM_STATE_IDLE; return; } uint16_t idx = gTraj.pointCount; for (uint8_t j = 0; j < JOINT_NUM; j++) { float deg = JointSensor_GetAngleDeg(j); gTraj.jointAngle[idx][j] = (int16_t)(deg * 10.0f); } gTraj.pointCount++; } // 根据当前时间,对某关节做线性插值 static float InterpAngle(uint8_t joint, uint32_t timeMs) { if (gTraj.pointCount < 2) { return JointSensor_GetAngleDeg(joint); } float t = (float)timeMs / (float)SAMPLE_DT_MS; uint16_t i1 = (uint

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

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

立即咨询