做六轴机械臂项目做到中期以后,大家问得最多的往往不是“怎么让舵机转起来”,而是“怎样让它按照我想要的轨迹动起来”。如果要靠运动学逆解去编写一段手腕翻转、抓取、回位的组合动作,对数学和代码能力的要求都不低;但如果采用示教+轨迹复现的方式,整个过程就变成“人拖动小机械臂做一遍,单片机记下来,再让执行臂重放”,工程门槛会一下子低很多。
这一期教程围绕六自由度机械臂的示教操作与轨迹复现展开,控制核心使用 STM32F103 单片机。文章会先讲清楚整体架构和硬件接线,再给出可落地的 PWM 舵机驱动、ADC 角度采集、轨迹记录和回放状态机设计思路,最后补充常见故障排查和工程注意事项。无论你是在做课程设计、毕业设计,还是自己组一套六轴机械臂玩,这套思路都可以直接迁移。
1. 为什么六轴机械臂要做“示教操作 + 轨迹复现”
很多刚开始接触六轴机械臂的同学,第一反应是让机械臂完成“从 A 点抓取,放到 B 点”这样的任务。
要做到这一点,常规思路是:
- 给机械臂建立 DH 参数模型。
- 标定六个关节的零位和运动范围。
- 写出正运动学,把关节角度换算成末端位姿。
- 再写逆运动学,把目标位姿换算成六个关节角。
- 规划轨迹,生成每个时刻的关节位置。
- 最后让控制器逐点执行。
这套流程当然是最接近工业机器人开发的,但放在 DIY 六轴机械臂上,问题也很明显:结构件加工误差、舵机安装偏差、减速箱回差都会让理论模型和实际运动对不上。如果每一段动作都想靠代码去“算”出来,调试周期会非常长。
示教操作则绕开了复杂的运动学推导。它的核心思想是:
由人直接拖动一套与执行机械臂结构相似的小机械臂,各关节的角度传感器实时检测关节位置;主控把传感器数据转换为舵机 PWM 信号,驱动另一条机械臂同步跟随。
“拖动小机械臂”是人工示教比较常用的一种做法。好处有三个:
- 不需要逆解:操作人员无需关心关节角度,只要用手把教臂摆成目标姿态即可。
- 动作直观:教臂转多少,执行臂就按预设比例转多少,效果所见即所得。
- 可以快速产生轨迹:把示教过程中每个时刻的六个关节角度按固定时间间隔保存下来,就能形成一条完整轨迹;需要复现时,单片机按同样的时间间隔把角度点回放出来即可。
因此,本文要做的事情并不复杂:读取教臂角度、实时驱动执行臂、定时保存轨迹点、按记录顺序回放。这正好是 STM32F103 这类资源不多、但控制能力足够的 MCU 所擅长的任务。
2. 整体方案与工作流程
2.1 系统由哪几部分组成
整套系统可以划分为四个部分:
| 模块 | 作用 | 典型器件 |
|---|---|---|
| 示教小机械臂 | 人手拖动,提供六个关节的目标角度 | 六轴串联结构,各关节安装角度传感器 |
| 角度采集电路 | 把机械角度变成 MCU 可读的电信号 | 旋转电位器,或 AS5600 等磁编码器 |
| 主控板 | 读取角度、处理按键、记录和回放轨迹 | STM32F103C8T6 最小系统板 |
| 执行机械臂 | 按主控给出的 PWM 信号运动 | 六个 PWM 数字舵机 + 结构件 |
这里要说明一下:很多同学会问,为什么不让执行臂直接被手拖动?
对于普通舵机驱动的机械臂,舵机内部有减速齿轮组,通电后反向拖动很容易导致舵机堵转、发热甚至烧毁。因此更安全的 DIY 示教方案是采用“主从式”结构:人拖动的是无动力或轻负载的教臂,执行臂只在复现阶段承担运动任务。如果想让执行臂本身支持直接拖动示教,需要特殊设计的电机抱闸释放机构和绝对值角度反馈,复杂度会高很多。
2.2 数据流向
用一句话概括系统流程:
人手拖动教臂 -> 六个关节电位器产生模拟电压 -> STM32F103 的 ADC 采样 -> 转换为舵机脉宽 -> 输出 PWM 驱动执行臂 -> 同时按时间间隔保存当前角度 -> 按键切换后按保存顺序回放在实时跟随模式下,STM32F103 每 20ms 左右读取一次六路 ADC 值,并把这组值映射成 PWM 脉宽输出。这个循环不断重复,看起来就是执行臂跟随教臂同步运动。
在记录模式下,单片机除了实时跟随,还会把每一次的 PWM 脉宽保存到轨迹数组中。停止记录后,数组里保存的就是这段时间的完整动作序列。
在回放模式下,单片机从轨迹数组第一个点开始,每隔固定时间间隔输出一组 PWM 脉宽,直到全部回放结束。
3. 本期硬件组装与接线要点
3.1 材料清单
第二期既然要接入示教与复现功能,硬件上需要增加“教臂”部分。由于材料包的螺丝、铝件尺寸不一样,下面只给出通用清单:
| 类别 | 说明 |
|---|---|
| 执行机械臂结构 | 底座、腰部、大臂、小臂、腕部等六轴结构件 |
| 教臂结构件 | 与执行臂关节数量一致的小型串联臂,可以用 3D 打印,也可以用型材搭建 |
| 角度传感器 | 每个教臂关节一个旋转电位器,共 6 个 |
| 舵机 | 每个执行关节一个 PWM 舵机,共 6 个 |
| 主控板 | STM32F103C8T6 最小系统板,或类似兼容板 |
| 电源 | 舵机独立电源,例如 2S 锂电池加稳压模块;5V 主控供电 |
| 按键与指示灯 | 用于切换“记录 / 回放 / 停止” |
| 接线材料 | 杜邦线或者排线、铜柱、扎带等 |
如果你手里的执行臂是“总线舵机”版本,则舵机接口不再是普通 PWM,通信协议不同,本文示例代码需要对应替换;以下内容以最适合入门学习的 PWM 舵机为例。
3.2 示教臂角度传感器装配
对于 DIY 主从示教,旋转电位器是最容易获取的角度传感器。装配时有几点需要注意:
- 电位器要和关节旋转轴同心,否则转动时会憋住。
- 每个关节最好有机械限位,避免电位器超过有效电气转角。
- 电位器两端分别接 3.3V 和 GND,中间抽头接 STM32 的 ADC 引脚。
- 如果某个关节方向反了,可以在软件里做反向映射,不一定要重新改结构。
在功能上,教臂并不需要很大的力矩,关节转动越顺滑越好。如果结构件转动不顺畅,首先检查轴承或轴套的安装同轴度。
3.3 执行臂舵机与供电
六自由度机械臂的六个舵机在启动和换向瞬间电流较大,最忌讳的是用 STM32 最小系统板的 USB 口直接给舵机供电。更合理的供电方式是:
电池或电源适配器 -> 稳压/降压模块 -> 舵机电源正负极 -> 舵机信号线接 STM32 PWM 引脚还需要特别注意:舵机电源地、STM32 系统板地必须共地。很多“舵机不转、舵机乱抖、单片机复位”的问题,本质都是没有共地。
舵机的信号线是高电平脉冲信号,STM32 的 PWM 引脚输出就可以驱动。接线时不需要把所有舵机电源并到主控板上,正确的电源走线是直接从电源模块分出来,再让主控板单独取电。
3.4 引脚规划与避免 IO 冲突
STM32F103C8T6 的引脚资源有限。如果六个舵机 PWM 和六路 ADC 都要占用引脚,直接随便接很容易冲突。
一个比较直观的分配方案如下:
| 功能 | 引脚 | 对应外设 |
|---|---|---|
| 舵机 0~3 PWM | PA0、PA1、PA2、PA3 | TIM2_CH1~CH4 |
| 舵机 4、5 PWM | PA8、PA9 | TIM1_CH1、TIM1_CH2 |
| 教臂 ADC 通道 0~5 | PA4、PA5、PA6、PA7、PB0、PB1 | ADC1_IN4~IN9 |
这样安排后,PWM 引脚和 ADC 引脚不重叠,六个舵机和六路电位器都能接入。如果你手里的 PCB 已经定义好其他引脚,以你手上的原理图为准,重点是把引脚配置集中到宏定义或配置文件中,方便后续调整。
4. STM32F103 核心驱动代码解析
4.1 舵机 PWM 控制原理
常见的模拟或数字舵机,控制信号是周期约 20ms 的脉冲。脉冲宽度不同,舵机输出的角度不同:
| 脉宽 | 典型角度 |
|---|---|
| 0.5ms | 0° 附近 |
| 1.5ms | 中间位置 |
| 2.5ms | 最大角度附近 |
不同厂家舵机的脉宽范围有差异,有的实际范围是 0.5ms~2.5ms,有的只有 1ms~2ms。建议先查舵机数据手册,然后在代码里把范围定义成宏,方便标定。
在 STM32CubeMX 中,将定时器时钟配置成 72MHz,预分频器设为 71,则计数器频率为:
72MHz / 72 = 1MHz也就是计数器每计数一次为 1 微秒。如果把自动重载值 ARR 设为 19999,则 PWM 周期为:
(19999 + 1) * 1us = 20000us = 20ms这正好符合舵机的 20ms 周期要求。此时设置 CCR 比较值:
| CCR 值 | 脉宽 |
|---|---|
| 500 | 500us |
| 1500 | 1500us |
| 2500 | 2500us |
因此,代码里不需要写很复杂的数学公式,直接“往 CCR 寄存器写入微秒值”即可。
4.2 六路舵机 PWM 输出代码
假设你在 STM32CubeMX 中已经生成了工程,定时器句柄分别是htim1和htim2。可以写一个统一的舵机设置接口:
// servo.c #include "main.h" #include "servo.h" #define PULSE_MIN_US 500 #define PULSE_MAX_US 2500 void Servo_SetPulse(uint8_t idx, uint16_t pulse_us) { if (pulse_us < PULSE_MIN_US) pulse_us = PULSE_MIN_US; if (pulse_us > PULSE_MAX_US) pulse_us = PULSE_MAX_US; switch (idx) { case 0: __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, pulse_us); break; case 1: __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_2, pulse_us); break; case 2: __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_3, pulse_us); break; case 3: __HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_4, pulse_us); break; case 4: __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_1, pulse_us); break; case 5: __HAL_TIM_SET_COMPARE(&htim1, TIM_CHANNEL_2, pulse_us); break; default: break; } } void Servo_InitAll(void) { HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_3); HAL_TIM_PWM_Start(&htim2, TIM_CHANNEL_4); /* TIM1 是高级定时器,需要使能主输出 */ __HAL_TIM_MOE_ENABLE(&htim1); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&htim1, TIM_CHANNEL_2); for (uint8_t i = 0; i < 6; i++) { Servo_SetPulse(i, 1500); } }代码里的“1500”表示舵机先回到 1.5ms 脉宽中位。实际项目中,如果机械臂结构安装时零位并不在舵机中位,这里需要按你的机械零位修改。
4.3 六路 ADC 角度读取
如果教臂使用电位器,ST 的 ADC1 可以依次扫描多个通道。为了减少抖动,我习惯对每个通道连续读几次然后取平均。下面是一个轻量实现:
// teach_adc.c #include "main.h" #include "teach_adc.h" static uint16_t adc_channel_map[6] = { ADC_CHANNEL_4, // PA4 ADC_CHANNEL_5, // PA5 ADC_CHANNEL_6, // PA6 ADC_CHANNEL_7, // PA7 ADC_CHANNEL_8, // PB0 ADC_CHANNEL_9 // PB1 }; uint16_t ReadOneChannel(uint16_t channel) { ADC_ChannelConfTypeDef sConfig = {0}; uint16_t value = 0; sConfig.Channel = channel; sConfig.Rank = 1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); value = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); return value; } void TeachArm_ReadAll(uint16_t *raw) { uint32_t sum = 0; for (uint8_t ch = 0; ch < 6; ch++) { sum = 0; for (uint8_t sample = 0; sample < 5; sample++) { sum += ReadOneChannel(adc_channel_map[ch]); } raw[ch] = sum / 5; } }这里之所以每读一个通道都执行HAL_ADC_Stop(),是为了让下一次切换通道时不要沿用上一次的配置。在 6 个电位器、采样率要求不高的场景下,这种写法简单可靠。
5. 示教、记录与回放软件设计
5.1 状态机设计
机械臂示教不只是一个“循环读角度”的过程,还需要考虑“什么时候开始记录”“什么时候停止”“什么时候回到轨迹首点”“什么时候开始回放”。使用状态机会清晰很多。
可以定义这样几种模式:
| 模式 | 含义 |
|---|---|
| MODE_FOLLOW | 实时跟随教臂运动,但不记录 |
| MODE_RECORD | 实时跟随,同时按周期保存轨迹点 |
| MODE_BACK_TO_START | 从当前姿态平滑回到轨迹首点 |
| MODE_PLAYBACK | 逐点回放轨迹 |
按钮的功能可以这样设计:
- 按键 1:进入记录。如果已经在记录,则停止记录,并进入“待机”或“跟随”模式。
- 按键 2:开始回放。如果采集到的轨迹点数为 0,则按下无效。
- 按键 3:急停或复位,所有舵机输出中位脉宽。
采用这种划分后,主循环的逻辑会很清楚。
5.2 轨迹数据结构
使用 STM32F103C8T6 时,RAM 只有约 20KB。如果六个关节都保存为uint16_t,每个轨迹点需要:
6 * 2 = 12 字节如果保存 800 个点,总容量为 9600 字节,约占 RAM 的一半。示例中先按这个规模设计:
// trajectory.h #define SERVO_NUM 6 #define MAX_TRAJ_POINT 800 typedef struct { uint16_t pulse[SERVO_NUM]; } TrajPoint; extern TrajPoint g_traj[MAX_TRAJ_POINT]; extern volatile uint16_t g_traj_len;当然,800 个点通常不够保存几分钟的长轨迹。如果希望断电保存或保存更长时间,可以外接 SPI Flash、SD 卡,或者用串口把数据发送到上位机保存。这里先把 RAM 方案跑通,再考虑扩展存储。
5.3 实时跟随与轨迹记录
下面这段代码是核心循环逻辑:
// main.c 中的循环片段 void MainLoop_Tick(void) { static uint32_t last_record_tick = 0; static uint32_t last_follow_tick = 0; uint32_t now = HAL_GetTick(); if (g_mode == MODE_FOLLOW || g_mode == MODE_RECORD) { if (now - last_follow_tick >= 20) { uint16_t raw[6]; TeachArm_ReadAll(raw); for (uint8_t i = 0; i < 6; i++) { g_current_pulse[i] = MapRawToPulse(i, raw[i]); Servo_SetPulse(i, g_current_pulse[i]); } last_follow_tick = now; } } if (g_mode == MODE_RECORD) { if (now - last_record_tick >=