ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

STM32三轴机械臂实战:舵机PWM控制、逆解与直线插补

STM32三轴机械臂实战:舵机PWM控制、逆解与直线插补 简介基于STM32的三轴机械臂控制源码工程面向嵌入式爱好者、机械臂控制初学者以及正在做课设或毕设的STM32开发者可用于快速搭建三轴机械臂的控制原型。压缩包共169个文件、约3.39MB内容以H头文件和C源文件为主体配合Keil MDK的工程配置文件、编译生成的hex/axf烧录文件以及map/lst等调试输出整体结构完整便于在目标板卡上重新编译、烧录与二次开发。源码中涉及定时器、ADC、I2C等STM32标准外设驱动核心功能围绕三轴机械臂的PWM舵机控制、位置采集和运动逻辑展开能够帮助读者掌握从外设初始化到上层控制算法的工程组织方式由于代码采用可读性较好的标准库写法也适合作为移植、裁剪和功能扩展的参考蓝本。目前已有1939人学习浏览适合需要快速上手机械臂控制或借鉴完整工程架构的嵌入式学习者。1. 拆包三轴机械臂工程uvguix 缓存之外的三个外设文件才是骨架拿到这个压缩包第一眼看到的是STM32-DEMO.uvguix.20155、.axf、_uvproj.bak这类文件它们分别是 Keil MDK 的窗口布局缓存、编译产物和工程备份。真正有价值的是stm32f10x_tim.c、stm32f10x_adc.c、stm32f10x_i2c.c这三个标准外设库源码——它们的出现基本锁定了这套三轴机械臂的技术路线用高级定时器输出 PWM 驱动舵机用 ADC 读电位器反馈关节角度用 I2C 挂载传感器或 EEPROM 存标定参数。这个组合在基于 STM32 的机械臂项目里非常典型既绕开了步进电机那套驱动器选型又能把舵机控制在 1° 量级。适合正在做基于 STM32 的毕业设计、或者想把三轴机械臂从手拧电位器升级成角度闭环 轨迹控制的人阅读。下面按外设骨架、逆解实现、联动控制、工程排错的顺序把这条链路拆开。2. 硬件骨架与外设选型TIM 输出比较、ADC 电位器反馈与 I2C 传感器接入2.1 三轴机械臂的驱动链路为什么是 PWM 舵机而不是步进电机三轴机械臂的关节驱动有两种主流做法步进电机加驱动器或者舵机加 PWM。这套工程选的是后者原因很直接舵机内部已经集成了减速齿轮箱、直流电机和控制电路MCU 只需要输出一个 50Hz、脉宽 0.5ms 到 2.5ms 的方波舵机就会自动转到对应角度。相比步进电机它省掉了 DIR/PUL 两根信号线和驱动器选型而且在 0° 到 180° 范围内是位置闭环不会因为堵转丢步。STM32 这边输出 PWM 最顺手的资源是通用定时器 TIM2/TIM3/TIM4每个定时器有 4 个捕获比较通道可以同时驱动 3 个舵机再加 1 个备用通道。如果未来要上六轴TIM2 和 TIM3 各出 3 路就够了无需换 MCU。值得注意的是stm32f10x 系列的 APB1 定时器时钟有个翻倍规则当 APB1 预分频系数大于 1 时定时器时钟是 APB1 的两倍。在标准库工程里如果系统时钟是 72MHz、APB1 分频为 2那么 TIM2 的输入时钟实际是 72MHz这一点在下面计算预分频系数时非常关键很多人在 50Hz 舵机频率上卡住都是因为把定时器时钟当成了 36MHz。2.2 引脚分配与外设初始化顺序三轴机械臂一般包含基座旋转舵机、大臂俯仰舵机、小臂俯仰舵机三路 PWM外加两到三路电位器做角度反馈用于手动示教或限位检测以及一路 I2C 总线挂载 MPU6050 或 AT24C02。这套工程涉及的引脚分配建议如下表外设功能引脚复用配置说明舵机 1 PWMPA0TIM2_CH1 复用推挽基座旋转舵机 2 PWMPA1TIM2_CH2 复用推挽大臂俯仰舵机 3 PWMPA2TIM2_CH3 复用推挽小臂俯仰电位器 1 反馈PA3ADC1_IN3 模拟输入大臂角度采样电位器 2 反馈PB0ADC1_IN8 模拟输入小臂角度采样电位器 3 反馈PB1ADC1_IN9 模拟输入基座角度采样I2C1 SCL/SDAPB6/PB7开漏输出上拉外接传感器初始化顺序有个容易被忽略的坑先开 GPIO 时钟再开 TIM2 时钟然后配置 GPIO 复用模式最后才初始化定时器时基和输出比较。如果先配置 GPIO 再开时钟标准库的 GPIO_Init 在访问 CRL/CRH 寄存器时会读到无效值虽然不一定立刻 HardFault但会出现某些引脚复用不上。ADC 电位器输入也一样GPIO_Mode 必须设为 GPIO_Mode_AIN否则模拟输入会被数字输入缓冲器干扰采样值在低三位跳动。2.3 定时器 PWM 初始化代码与参数调整舵机要求的控制信号是 50Hz、20ms 周期。假设系统时钟 72MHzAPB1 分频后定时器时钟仍为 72MHz那么预分频 PSC 取 72-1计数频率为 1MHz自动重装载值 ARR 取 20000-1这样定时器溢出周期正好是 20ms。初始化代码用标准外设库写出来是这样void TIM2_PWM_Init(u16 arr, u16 psc) { GPIO_InitTypeDef GPIO_InitStructure; TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure; TIM_OCInitTypeDef TIM_OCInitStructure; RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA | RCC_APB2Periph_AFIO, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1 | GPIO_Pin_2; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AF_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOA, GPIO_InitStructure); TIM_TimeBaseStructure.TIM_Period arr - 1; TIM_TimeBaseStructure.TIM_Prescaler psc - 1; TIM_TimeBaseStructure.TIM_ClockDivision TIM_CKD_DIV1; TIM_TimeBaseStructure.TIM_CounterMode TIM_CounterMode_Up; TIM_TimeBaseInit(TIM2, TIM_TimeBaseStructure); TIM_OCInitStructure.TIM_OCMode TIM_OCMode_PWM1; TIM_OCInitStructure.TIM_OutputState TIM_OutputState_Enable; TIM_OCInitStructure.TIM_Pulse 1500; // 初始 1.5ms舵机回中 TIM_OCInitStructure.TIM_OCPolarity TIM_OCPolarity_High; TIM_OC1Init(TIM2, TIM_OCInitStructure); TIM_OC2Init(TIM2, TIM_OCInitStructure); TIM_OC3Init(TIM2, TIM_OCInitStructure); TIM_OC1PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_OC2PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_OC3PreloadConfig(TIM2, TIM_OCPreload_Enable); TIM_ARRPreloadConfig(TIM2, ENABLE); TIM_Cmd(TIM2, ENABLE); }这里TIM_Pulse 1500对应比较寄存器值 1500因为计数频率是 1MHz所以 1500 就是 1.5ms 脉宽舵机会转到中位。PSC 和 ARR 的计算逻辑是定时器时钟 72MHz 先经过 PSC 分频到 1MHz再计数到 ARR 溢出因此周期 (PSC1) × (ARR1) / 72MHz。如果换到 8MHz 的内部时钟且未倍频PSC 要改成 8-1、ARR 保持 20000-1 才有 50Hz这一点在不同开发板上差异很大。提示如果你在调试时舵机不回中先查 SystemCoreClock 的值。很多旧版 STM32-DEMO 工程没开 PLL实际主频是 8MHz 而不是 72MHz这时按 72MHz 算出来的预分频值会让 PWM 频率变成 400Hz舵机直接啸叫。3. 逆解与角度映射从目标坐标到比较寄存器值的完整链路3.1 三轴机械臂的运动学模型与 D-H 简化三轴机械臂的逆解不需要完整的 D-H 参数矩阵。常见的结构是基座一个旋转自由度偏航角大臂和小臂各一个俯仰自由度末端在半径方向形成一个二连杆平面机构。将目标点坐标 (x, y, z) 先投影到水平面基座转角直接由方位角决定再把剩余的两连杆问题放到半径-高度平面内求解。这种降维做法比直接套 D-H 矩阵直观得多也容易在单片机上用 float 运算实时跑。设大臂长度 L1、小臂长度 L2目标点的水平投影距基座轴线距离为 r sqrt(x² y²)高度为 z那么末端到基座原点的空间距离 d sqrt(r² z²)。二连杆平面内的两个关节角由余弦定理求出关节 2小臂相对大臂的夹角余弦值为 (L1² L2² - d²) / (2 × L1 × L2)关节 1大臂相对水平面的角度等于末端向量方向角减去小臂引起的偏转。这套模型的适用前提是小臂末端没有额外偏置如果机械臂末端有夹爪和连杆偏心还需要在 z 方向上减去固定偏置量。3.2 SCARA 结构逆解代码实现把上面公式落成 C 代码注意使用atan2f而不是atanf因为atan2f能根据 x、y 的符号自动判断象限基座转角可以覆盖 -180° 到 180° 的完整范围。实现如下typedef struct { float theta0; // 基座旋转角单位度 float theta1; // 大臂俯仰角单位度 float theta2; // 小臂俯仰角单位度 } ArmAngle; ArmAngle arm_inverse_kinematics(float x, float y, float z, float l1, float l2) { ArmAngle angle; float r sqrtf(x * x y * y); float d sqrtf(r * r z * z); float cos_theta2 (l1 * l1 l2 * l2 - d * d) / (2.0f * l1 * l2); // 防浮点误差越界acosf 要求入参在 [-1, 1] if (cos_theta2 1.0f) cos_theta2 1.0f; if (cos_theta2 -1.0f) cos_theta2 -1.0f; angle.theta0 atan2f(y, x) * 180.0f / PI; angle.theta2 acosf(cos_theta2) * 180.0f / PI; float alpha atan2f(z, r); float beta atan2f(l2 * sinf(angle.theta2 * PI / 180.0f), l1 l2 * cosf(angle.theta2 * PI / 180.0f)); angle.theta1 (alpha - beta) * 180.0f / PI; return angle; }这段代码里atan2f(z, r)求出的是末端点在 r-z 平面内的方位角beta是小臂重力方向偏移造成的额外转角两者相减才是大臂相对水平面的真实角度。acosf入参的 clamp 处理是必须的因为浮点运算中 d 可能因为坐标误差略大于 L1L2 或者略小于 |L1-L2|不 clamp 会直接得到 NaN后续所有关节角度全部失效。建议在调用逆解前先用d l1 l2做一次可达性判断返回错误码而不是强行解算出一个怪异角度。下面给一组可用数据做验证设 L1 120mm、L2 100mm目标坐标 (x, y, z) 单位 mmtheta0度theta1度theta2度(100, 0, 80)0.0约 2.3约 81.8(80, 40, 60)约 26.6约 7.9约 57.7如果解出来的角度和表里差很多优先检查角度制/弧度制的混用——atan2f返回弧度乘180/PI转角度后面sinf、cosf里又要把角度转回弧度这个来回转换最容易出错。舵机角度映射到定时器比较寄存器也有固定公式脉宽 0.5ms 角度 / 180 × 2.0msCCR 值 脉宽(ms) × 1000计数频率 1MHz 时每 ms 对应 1000 个计数。3.3 角度到定时器比较值的映射与死区处理得到三个关节角度后不能直接拿去写 TIM_CCR。舵机有机械限位和转角范围差异便宜的 MG996R 实际能转 170° 左右且 0° 位置对应的脉宽会有 ±20μs 的个体偏差。所以角度映射必须带限位和标定修正#define SERVO0_MIN 0.0f #define SERVO0_MAX 180.0f #define SERVO0_CAL 0.0f // 舵机零点偏差实测后修正 float angle_to_pulse(float angle, float min, float max, float cal) { if (angle min) angle min; if (angle max) angle max; return 0.5f (angle cal) / 180.0f * 2.0f; // 返回脉宽单位 ms } void servo_write_pulse(TIM_TypeDef* TIMx, u8 channel, float pulse_ms) { u16 ccr (u16)(pulse_ms * 1000.0f); switch (channel) { case 1: TIM_SetCompare1(TIMx, ccr); break; case 2: TIM_SetCompare2(TIMx, ccr); break; case 3: TIM_SetCompare3(TIMx, ccr); break; default: break; } }这里SERVO0_CAL的作用是吸收舵机的个体误差。实操中先把机械臂摆到 90° 中位量一下实际角度把差值填进去。限位值必须小于机械极限否则舵机堵转电流会拉低 5V 供电轨导致 MCU 复位——这也是 ADC 采样值突然跳变的常见原因。另一个容易忽略的点是舵机信号线在 MCU 复位瞬间会变成高阻态如果舵机供电先于 MCU 建立信号线悬空可能让舵机猛甩到最大角度。解决方法是把三路信号线上各加一个 10k 下拉电阻到 GND复位期间舵机保持在 0° 位置。4. 三轴联动控制DMA 采样、定时器中断与直线插补4.1 为什么单独控制三个舵机会抖动很多人做的三轴机械臂是按顺序转的先转基座再转大臂再转小臂每个动作之间 delay 几百毫秒。这样末端轨迹完全不可控而且三个舵机同时到达目标角度的瞬间机械臂会因为惯性产生明显晃动。要让末端走出平滑直线必须在每个控制周期里同时更新三个舵机的目标角度而不是各转各的。这里的核心是把三个角度统一由同一组插补坐标驱动而不是分别做三次独立的插值。控制周期选择也很关键。舵机内部的电位器反馈回路响应时间大概在 50ms 到 200ms控制周期小于 10ms 时舵机根本跟不上而且每个周期只挪 0.1° 会让误差累积控制周期大于 50ms 时轨迹阶梯感明显。20ms 是多数做法里比较平衡的选择刚好对应 50Hz PWM 的一个周期便于在定时器中断里同步更新比较寄存器。4.2 定时器中断作为时基与状态机切换使用 TIM3 产生 1ms 的中断时基软件计数到 20 再执行一次插补更新这样插补周期保持在 20ms。中断服务函数里不直接做逆解而是先更新插补位置坐标再调用逆解最后写三个比较寄存器一气呵成。直线插补的状态机如下typedef struct { float start[3]; // 起点坐标 float target[3]; // 终点坐标 float curr[3]; // 当前坐标 float step[3]; // 每周期增量 u8 running; } Trajectory; Trajectory traj; void trajectory_start(float sx, float sy, float sz, float tx, float ty, float tz, float speed_mm_s) { traj.start[0] sx; traj.start[1] sy; traj.start[2] sz; traj.target[0] tx; traj.target[1] ty; traj.target[2] tz; traj.curr[0] sx; traj.curr[1] sy; traj.curr[2] sz; float dist sqrtf((tx-sx)*(tx-sx) (ty-sy)*(ty-sy) (tz-sz)*(tz-sz)); float step_dist speed_mm_s * 0.02f; // 每个控制周期移动的距离 u16 n (u16)(dist / step_dist) 1; // 总步数 for (int i 0; i 3; i) { traj.step[i] (traj.target[i] - traj.start[i]) / n; } traj.running 1; }每 20ms 的中断处理里把curr累加一个step然后判断是否到达终点。到达判据用剩余距离小于一个 step而不是精确相等否则浮点误差会导致最后一步永远加不完。4.3 DMA ADC 高速采样电位器反馈三轴机械臂如果要做角度闭环ADC 采样电位器是成本最低的方案。但注意不能把电位器接到 PA0-PA2这三个引脚已被 TIM2 复用成 PWM 输出同一引脚同时做模拟输入和复用推挽会互相干扰。正确做法是接到 PA3、PB0、PB1对应 ADC1 的通道 3、8、9。用规则组扫描模式加 DMA 循环传输三个通道的采样值会自动写入缓冲区CPU 全程不参与#define ADC_CH_NUM 3 u16 adc_buf[ADC_CH_NUM]; void ADC_DMA_Init(void) { GPIO_InitTypeDef GPIO_InitStructure; DMA_InitTypeDef DMA_InitStructure; ADC_InitTypeDef ADC_InitStructure; RCC_AHBPeriphClockCmd(RCC_AHBPeriph_DMA1, ENABLE); RCC_APB2PeriphClockCmd(RCC_APB2Periph_ADC1 | RCC_APB2Periph_GPIOA | RCC_APB2Periph_GPIOB, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_3; GPIO_InitStructure.GPIO_Mode GPIO_Mode_AIN; GPIO_Init(GPIOA, GPIO_InitStructure); GPIO_InitStructure.GPIO_Pin GPIO_Pin_0 | GPIO_Pin_1; GPIO_Init(GPIOB, GPIO_InitStructure); DMA_InitStructure.DMA_PeripheralBaseAddr (u32)ADC1-DR; DMA_InitStructure.DMA_MemoryBaseAddr (u32)adc_buf; DMA_InitStructure.DMA_DIR DMA_DIR_PeripheralSRC; DMA_InitStructure.DMA_BufferSize ADC_CH_NUM; DMA_InitStructure.DMA_PeripheralInc DMA_PeripheralInc_Disable; DMA_InitStructure.DMA_MemoryInc DMA_MemoryInc_Enable; DMA_InitStructure.DMA_PeripheralDataSize DMA_PeripheralDataSize_HalfWord; DMA_InitStructure.DMA_MemoryDataSize DMA_MemoryDataSize_HalfWord; DMA_InitStructure.DMA_Mode DMA_Mode_Circular; DMA_InitStructure.DMA_Priority DMA_Priority_High; DMA_Init(DMA1_Channel1, DMA_InitStructure); ADC_InitStructure.ADC_Mode ADC_Mode_Independent; ADC_InitStructure.ADC_ScanConvMode ENABLE; ADC_InitStructure.ADC_ContinuousConvMode ENABLE; ADC_InitStructure.ADC_ExternalTrigConv ADC_ExternalTrigConv_None; ADC_InitStructure.ADC_DataAlign ADC_DataAlign_Right; ADC_InitStructure.ADC_NbrOfChannel ADC_CH_NUM; ADC_Init(ADC1, ADC_InitStructure); ADC_RegularChannelConfig(ADC1, ADC_Channel_3, 1, ADC_SampleTime_28Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_8, 2, ADC_SampleTime_28Cycles5); ADC_RegularChannelConfig(ADC1, ADC_Channel_9, 3, ADC_SampleTime_28Cycles5); ADC_DMACmd(ADC1, ENABLE); DMA_Cmd(DMA1_Channel1, ENABLE); ADC_Cmd(ADC1, ENABLE); }这里的ADC_NbrOfChannel 3决定了 DMA 一次传输的字节长度是 3 个半字6 字节规则组按顺序转换通道 3、8、9结果依次落进adc_buf[0..2]。采样时间我选了 28.5 周期而不是最快 1.5 周期原因是机械臂电机转动时会产生振动和噪声电位器是滑动变阻器结构接触电阻会随振动波动采样时间太短会把接触噪声采进去。如果后续要加数字滤波对连续 8 次采样取中值即可注意不要用平均值单点毛刺会把平均值拉偏。提示DMA 循环模式下 adc_buf 中的数据会持续刷新CPU 读 buf 时可能赶上 DMA 正在写同一地址导致读到半个新值半个旧值。对电压反馈这种低速信号直接读问题不大如果需要严格一致可以在 DMA 传输完成中断里做乒乓缓冲或者读两次比较。4.4 插补周期与伺服写入的配合把逆解和插补串进中断完整逻辑如下void TIM3_IRQHandler(void) { static u16 tick 0; if (TIM_GetITStatus(TIM3, TIM_IT_Update) RESET) return; TIM_ClearITPendingBit(TIM3, TIM_IT_Update); if (tick 20) return; // 20ms 控制周期 tick 0; if (!traj.running) return; for (int i 0; i 3; i) traj.curr[i] traj.step[i]; // 判断是否到达终点 float dx traj.target[0] - traj.curr[0]; float dy traj.target[1] - traj.curr[1]; float dz traj.target[2] - traj.curr[2]; float remaining sqrtf(dx*dx dy*dy dz*dz); if (remaining 0.5f) { traj.curr[0] traj.target[0]; traj.curr[1] traj.target[1]; traj.curr[2] traj.target[2]; traj.running 0; } ArmAngle a arm_inverse_kinematics(traj.curr[0], traj.curr[1], traj.curr[2], 120.0f, 100.0f); float p0 angle_to_pulse(a.theta0, SERVO0_MIN, SERVO0_MAX, SERVO0_CAL); float p1 angle_to_pulse(a.theta1, SERVO1_MIN, SERVO1_MAX, SERVO1_CAL); float p2 angle_to_pulse(a.theta2, SERVO2_MIN, SERVO2_MAX, SERVO2_CAL); servo_write_pulse(TIM2, 1, p0); servo_write_pulse(TIM2, 2, p1); servo_write_pulse(TIM2, 3, p2); }参数推荐值说明插补周期20ms与 PWM 周期同步舵机响应时间匹配末端速度25-50 mm/s太快会丢步太慢轨迹无意义插补步长0.5-1.0 mm由速度除以周期得到到达阈值0.5mm小于一个步长即判定完成中断里做浮点逆解在 STM32F103 上是可行的一次arm_inverse_kinematics调用大约耗时 20-40μs无 FPU 时占 20ms 周期的 0.2%不会影响实时性。但注意不要在中断里调用printf或 HAL_Delay串口阻塞发送会直接拖垮插补节奏需要调试输出时用 DMA 发送或标志位延迟打印。5. 编译与烧录时的几个坑从 uvproj.bak 到实际下板压缩包里的STM32-DEMO_uvproj.bak是 Keil 工程的上一次保存备份。如果你打开.uvproj后工程文件损坏直接把.bak后缀改成.uvproj替换即可Keil 会正常加载。而STM32-DEMO.axf是编译生成的 ELF 格式文件包含调试信息和地址映射用 J-Link 或 ST-Link 调试时可以直接在 Keil 里 Load不需要手动转 hex。用 ST-Link Utility 单独烧录时也可以选 axf 文件它会自动解析出 Flash 加载段。换到自己的板子时容易出三个问题第一标准外设库版本不一致。工程里用到stm32f10x_tim.c、stm32f10x_adc.c、stm32f10x_i2c.c这些属于标准外设库 3.5如果从别的工程复制了不同版本的外设库文件TIM_OCInitTypeDef这类结构体的成员有细微差异编译报错会指向不存在的成员变量需要统一换成同一套库文件。第二启动文件选型。STM32F103C8T6 选startup_stm32f10x_md.s大容量如 STM32F103ZET6 要换hd.s选错会导致程序上电直接进 HardFault。第三下载时 BOOT0 引脚必须拉低否则芯片复位后进入内置 Bootloader 而不是用户 FlashKeil 下载完成但程序不运行。最后给出一个下板前的排查顺序先量 SystemCoreClock 是否为 72000000再看 TIM2 的 PSC 和 ARR 写进去有没有被总线时钟分频影响然后用逻辑分析仪抓 PA0 引脚确认 PWM 在 50Hz 且脉宽可调最后才接舵机。每次改角度先改 90° 中位确认三路舵机都指向机械臂侧向后再跑逆解代码。本文还有配套的精品资源点击获取
返回列表