简介:这是一份蜘蛛机器人STM32F103控制程序源码示例项目,主要面向正在学习嵌入式系统、STM32开发或机器人控制的开发者。源码围绕GPIO、ADC、SPI、I2C、UART等硬件接口展开,涵盖电机驱动、传感器数据采集、FreeRTOS等实时操作系统任务调度、PID与步进电机控制算法,并涉及蓝牙/Wi-Fi通信、传感器融合、电源管理及故障诊断机制,可帮助使用者系统理解一只完整蜘蛛机器人从底层驱动到上层控制的实现路径。资源包为1.6MB的RAR压缩包,共188个文件,以C源码、编译工程文件、中间输出及调试文件为主,目录结构清晰,便于对照代码和工程配置进行学习;目前已有1378人学习/下载。这份源码的看点在于它并非零散例程,而是一套可运行的工程骨架。读者既能从中提取外设初始化、中断处理和RTOS任务管理的通用模板,也能结合蜘蛛机器人特有的步态计算与传感器融合逻辑,加深对嵌入式机器人控制架构的整体认识,适合具备一定C语言与STM32基础、希望进阶到实际项目层级的开发者参考。 做了两年多足式机器人,我的感受是:真正能跑起来的蜘蛛机器人,难点压根不在舵机驱动,而在“18路PWM怎么管、每条腿三个关节怎么解算、六条腿怎么排步态周期”这一整条链路。STM32F103恰好是这条链路里性价比和资料丰富度都很均衡的控制器,72MHz主频没有浮点单元,但算三连杆逆解绰绰有余。这篇我会把蜘蛛机器人控制程序的核心思路、模块拆解和代码骨架摊开讲,重点是让你拿到源码后能看懂、能改、能自己加功能,而不是只会烧录进去看它爬两下。
1. 为什么是F103:18个舵机背后的引脚与选型逻辑
1.1 先算一笔账:STM32F103的PWM资源到底够不够
蜘蛛机器人常规构型是六条腿,每条腿三个舵机:根部水平旋转一个(控制整条腿的朝向)、大腿一个(控制抬腿)、小腿一个(控制蹬地)。算下来是18路PWM输出,这个数字直接决定了控制器选型。
STM32F103的定时器资源大概是这样的:TIM1是高级定时器,4路PWM通道;TIM2、TIM3、TIM4都是通用定时器,每个也是4路;TIM6、TIM7是基本定时器,没有输出比较通道,只能做定时和内部触发。所以满打满算,F103的PWM输出能力是12到16路——注意这里还不能全算,因为TIM1的部分通道在某些封装脚位上会跟SWD调试口或晶振引脚冲突。
我之前做过一版只依赖MCU原生PWM的设计,全部堆在TIM1、TIM2、TIM3、TIM4上,实际可用的稳定通道数是14路,离18路还差4路。如果你追求“MCU直驱全部舵机”,F103直接驱动是不现实的。
1.2 外扩方案:PCA9685和“16+2”的实战组合
既然原生PWM不够,常规解法是外扩PCA9685,这是一颗I2C接口的16路PWM驱动芯片,舵机控制频率可以通过寄存器配置为50Hz,脉宽精度到12位,对蜘蛛机器人完全够用。但我不建议把18路舵机全部接到PCA9685上,原因有两个:一是PCA9685和MCU之间靠I2C通信,虽然400kHz速率下刷新20ms周期的18路信号没问题,但如果你同时还要跑运动学解算和步态规划,I2C总线上的连续写入会占用不少CPU时间;二是SPI/I2C引脚的电气噪声在舵机频繁动作时容易被干扰,处理起来比原生PWM麻烦。
我实测下来最好用的组合是“MCU原生PWM出14到16路,PCA9685补剩余2到4路”。原生PWM给大腿和小腿这种需要频繁高精度控制的关节,PCA9685给根部水平旋转这种动作幅度大、对时序并不敏感的关节。这样即使PCA9685偶尔被干扰导致某一路抖动,也不会影响整机稳定性。
1.3 引脚分配的具体建议
分配引脚时记住三条铁律:避开SWD(PA13/PA14)和晶振脚(PD0/PD1),避开串口下载用的BOOT引脚,给调试串口预留至少一组USART。我的推荐分配是这样的:
| 定时器 | 通道 | 映射引脚 | 用途 |
|---|---|---|---|
| TIM1 | CH1-CH4 | PA8/PA9/PA10/PA11 | 左前腿大腿/小腿、左中腿大腿/小腿 |
| TIM2 | CH1-CH4 | PA0-PA3 | 左后腿大腿/小腿、右前腿大腿/小腿 |
| TIM3 | CH1-CH4 | PB0-PB3 | 右中腿大腿/小腿、右后腿大腿/小腿 |
| TIM4 | CH1-CH2 | PB6/PB7 | 预留备用PWM或调试信号 |
| PCA9685 | 通道0-5 | SCL=PB8, SDA=PB9 | 六条腿根部旋转舵机 |
注意TIM1的CH1在PA8,PA8和PA9刚好又是USART1的TX引脚,所以USART1没法同时用,我一般把调试串口放到USART2(PA2/PA3)或者把USART1复用重映射到PB6/PB7——但PB6/PB7又被TIM4占用了,所以干脆用USART2最省事。
2. 20ms控制周期:PWM解算与定时器分频的参数体系
2.1 舵机信号的本质是脉宽,不是频率
几乎所有模拟舵机和数字舵机,标准控制信号都是50Hz(20ms一个周期),而角度信息藏在脉宽里:通常0.5ms脉宽对应0度,2.5ms对应180度,1.5ms对应中点。也就是说,你不需要额外控制频率,只需要精确地输出一个宽度在0.5ms到2.5ms之间的高电平,并且每20ms重复一次。
这意味着PWM配置的核心不是“多高频率”,而是“一个周期内的高电平时间”。我在第一次调这块时犯过一个错,把ARR设得特别大,想提高脉宽分辨率,结果周期变成了50ms,舵机开始发抖——因为舵机内部的比较器在等待下一个脉冲时,发现周期不对,以为信号丢失,回中力就开始振荡。
2.2 F103定时器的分频计算:从72MHz推到20ms
F103的系统时钟是72MHz。以挂在APB2上的TIM1为例,它的时钟源就是72MHz。我们要做到20ms周期、1µs级别的脉宽分辨率,计算如下:
- 预分频器PSC = 71,这样计数脉冲频率 = 72MHz / (71+1) = 1MHz,即每个计数单位1微秒。
- 自动重装载值ARR = 19999,因为周期 = (19999+1) * 1µs = 20ms。
- 脉宽 = CCR寄存器的值,单位是微秒。0.5ms对应CCR=500,2.5ms对应CCR=2500。
如果要把角度映射到CCR,做一个线性转换:
uint16_t angle_to_ccr(float angle) { // angle: 0.0f ~ 180.0f return (uint16_t)(500.0f + angle * (2000.0f / 180.0f)); }上面这个公式的前提是舵机严格按0.5ms到2.5ms对应0到180度。实际会有偏差,我在后面调试章节专门讲标定问题。
2.3 控制周期的分层设计
20ms只是一个PWM刷新周期。整个机器人的控制逻辑还需要更细致的时基。我的代码里分了三个时间层:
- 1ms时基:用SysTick或TIM6中断,维护一个tick计数器,用来做非阻塞延时和状态轮询。
- 5ms时基:做运动学解算,生成每条腿足端的目标坐标。
- 20ms时基:把解算结果写入舵机PWM寄存器,并且执行步态相位更新。
为什么要分开?因为运动学解算如果放到20ms周期里做,一个步态周期只有5次更新,走出来的动作明显卡顿;而如果放到1ms里做,CPU占用又偏高(F103没有FPU,atan2f和cosf加起来单次近20微秒,18条腿算一轮要几百微秒,但1ms周期内中仍有大量富余——其实也没有那么吃紧,只是没必要)。5ms的解算间隔配合20ms的输出刷新,既能保证动作平滑,又留足余量给串口指令处理和传感器读取。
3. 三连杆逆解:把“足端坐标”变成“三个舵机角度”
3.1 把一条腿拆成“根部旋转 + 平面两连杆”
蜘蛛机器人的单腿运动学可以拆成两步理解。第一步是根部旋转舵机,它决定了整条腿在水平面上的朝向;第二步是大腿和小腿在竖直平面内组成的两连杆结构。两步解耦之后,逆解就变得非常清晰。
我用的坐标系定义如下:以身体几何中心为原点,x轴指向机器人前方,y轴指向左侧,z轴竖直向上。腿根部旋转舵机的输出轴垂直于身体平面,所以它控制的其实是足端在以根部为圆心的水平投影方向。
先解根部角:
theta0 = atan2f(target_y, target_x);这里target_x和target_y是足端在身体坐标系中的坐标。注意腿部安装方向不同,atan2f可能需要加一个偏移角度,我用结构体里存了每只脚的安装偏角,初始化时根据机械装配统一写入。
3.2 大腿和小腿的解算:余弦定理是主角
根部旋转解出来之后,把坐标系绕z轴旋转-theta0,问题就变成了:在一个竖直平面内,从髋关节点出发,用两根分别长L1(大腿)和L2(小腿)的连杆,去抵达平面内的目标点 (px, pz)。
这个平面内的距离:
float r = sqrtf(px * px + pz * pz); // 髋关节到足端的直线距离如果 r 大于 L1 + L2,说明目标点超出机械臂可达范围,需要做钳位或者返回错误标志。我是在代码里先做可达性检查,不可达就直接保留上一周期的角度,避免舵机瞬间跳变。
接下来用余弦定理求小腿角度:
float cos_theta2 = (L1 * L1 + L2 * L2 - r * r) / (2.0f * L1 * L2); float theta2 = acosf(cos_theta2); // 小腿相对大腿的夹角然后求大腿角度。大腿相对竖直轴的夹角,等于髋关节到目标点连线与竖直轴的夹角,减去大腿与这条连线之间的夹角:
float alpha = atan2f(px, pz); // 注意这里atan2f(px, pz)是先传x再传z float beta = acosf((L1 * L1 + r * r - L2 * L2) / (2.0f * L1 * r)); float theta1 = alpha - beta;最后把theta0、theta1、theta2分别乘上舵机安装方向系数,再映射到CCR值写入PWM寄存器。这里有个很容易搞错的地方:每条腿因为装配镜像,大腿和小腿的旋转方向可能相反,所以需要预留一个direction参数,取值+1或-1,乘到最终输出上。
3.3 完整函数实现参考
typedef struct { float x; float y; float z; } Vec3f; typedef struct { float l1; // 大腿长度 float l2; // 小腿长度 float mount_angle; // 根部安装偏角 float dir[3]; // 三个舵机的方向系数 } LegParam; void leg_inverse_kinematics(LegParam *leg, Vec3f target, float *angles) { // 1. 根部角 float theta0 = atan2f(target.y, target.x) - leg->mount_angle; // 2. 绕z轴旋转后的平面坐标 float c = cosf(theta0 + leg->mount_angle); float s = sinf(theta0 + leg->mount_angle); float px = target.x * c + target.y * s; // 旋转到腿部平面 float pz = target.z; // z方向不变 float r = sqrtf(px * px + pz * pz); // 3. 可达性检查 float d_max = leg->l1 + leg->l2 - 0.01f; if (r > d_max) { return; // 上一周期角度保持不变 } // 4. 余弦定理求解 float cos_theta2 = (leg->l1 * leg->l1 + leg->l2 * leg->l2 - r * r) / (2.0f * leg->l1 * leg->l2); float theta2 = acosf(fmaxf(-1.0f, fminf(1.0f, cos_theta2))); float alpha = atan2f(px, pz); float beta = acosf((leg->l1 * leg->l1 + r * r - leg->l2 * leg->l2) / (2.0f * leg->l1 * r)); float theta1 = alpha - beta; // 5. 方向修正 angles[0] = theta0 * leg->dir[0]; angles[1] = theta1 * leg->dir[1]; angles[2] = -theta2 * leg->dir[2]; // 小腿角度的符号取决于安装方式 }F103没有硬件浮点单元,但atan2f、cosf、acosf这些函数在20ms周期内算18个点是没问题的,实测大约每只脚0.3ms左右,全腿一轮不到2ms,远低于20ms刷新周期。
4. 三角步态的实现方式:分组、抬腿与重心
4.1 为什么是“三脚着地”而不是“四脚着地”
蜘蛛机器人做静态行走,最关键的是重心投影要落在支撑多边形内部。六条腿最稳定的支撑状态有两个选择:三腿支撑和四腿支撑。四腿支撑虽然更稳,但四腿着地时另外两条腿抬起来,步态周期会变得不连续,而且四腿支撑容易和着地的另外两条腿打架。
三角步态是最经典的方案:六条腿分成两组,每组三条腿,两组交替支撑和摆动。因为任意时刻必有一组的三条腿着地,而且这三条腿形成了一个三角形稳定区域,重心只要落在这个区域内,机器人就不会倾倒。
分组方式我用了最常见的“对角三角”结构:
| 分组 | 腿号 |
|---|---|
| A组 | 左前(LF)、右中(RM)、左后(LH) |
| B组 | 右前(RF)、左中(LM)、右后(RH) |
A组摆动时B组着地,B组摆动时A组着地。注意每组里的三条腿不能都在同一侧或同一前后位置,否则支撑多边形会很窄,容易侧翻。
4.2 相位管理:用一对方波生成“抬腿-落地”时序
我实现步态的方式很简单,维护一个gait_phase变量,从0到1循环:0到0.5是A组摆动、B组支撑,0.5到1是B组摆动、A组支撑。这样每组腿的摆动和支撑正好各占半个步态周期。
对每一条腿,在摆动相内,足端轨迹是“抬起来-往前伸-落下去”。我用半正弦曲线生成抬腿高度,这样摆动腿起点和终点的垂直速度为零,落地冲击小:
float t_swing = phase_in_gait; // 0~1,摆动相内部归一化 // 摆动相抬腿高度,起点和终点都是0 float z_lift = swing_height * sinf(M_PI * t_swing); // 水平方向位移:从起点线性插值到终点,也可以加S形 float x_swing = start_x + (end_x - start_x) * t_swing;支撑相的腿则相对身体向后滑动,提供前进推力。支撑相的目标点也要随身体移动不断更新,否则会出现“腿追不上身体”的拖拽感。
4.3 转向和横移:在足端轨迹上叠加偏移
蜘蛛机器人转向和横移不需要改运动学模型,只需要在生成足端目标坐标时,对每组腿的摆动轨迹叠加一个旋转偏移或侧向偏移。我是在gait_generate_target()函数里先计算基础步态轨迹,然后根据外部遥控指令(前进速度、转向速度)叠加偏移量:
Vec3f gait_generate_target(int leg_index, float phase) { // 基础步态目标点(以腿根部为原点) Vec3f pos = base_trajectory(leg_index, phase); // 转向:每只腿绕身体中心旋转 if (turn_rate != 0.0f) { float theta = turn_rate * step_distance; rotate_around_body_center(&pos, leg_index, theta); } // 横移 pos.x += strafe_offset * phase_amp; return pos; }这里的核心经验是:转向量和横移量都要归一化到单步步距,不要直接用速度值去叠加,否则不同步态周期会越走越偏。
5. 代码架构:状态机调度与模块划分
5.1 五个核心模块,各管一摊事
拿到一份蜘蛛机器人源码,不要急着通读全部,先按模块归类。我做这套程序时模块划分是这样的:
main.c:初始化硬件、注册中断回调、进入主循环调度。servo.c/servo.h:PWM输出层,负责把“角度”写入对应定时器的CCR寄存器,同时管理PCA9685的外部PWM输出。kinematics.c/kinematics.h:正逆解,输入足端坐标,输出关节角。gait.c/gait.h:步态规划,输入控制指令(前进、后退、转向、横移),输出每条腿的足端目标点。protocol.c/protocol.h:串口指令解析,接收上位机或遥控模块发来的命令帧,翻译成控制指令。
这种分层的好处很明显:你想调步态参数,根本不用关心舵机PWM细节;你想换舵机型号,改servo层就行,kinematics和gait逻辑都复用。
5.2 主循环:别用阻塞延时,用状态标志
很多初学者写的STM32控制程序是while(1)里跑一大串延时,舵机一抖动就发现是延时阻塞导致刷新周期不稳定。正确做法是主循环做非阻塞调度,由定时器中断置标志位,主循环查标志位再干活。
我搭的骨架大概是这样的:
int main(void) { HAL_Init(); SystemClock_Config(); MX_GPIO_Init(); MX_TIM1_Init(); MX_USART2_UART_Init(); pca9685_init(); servo_center_all(); gait_init(); while (1) { protocol_process(); // 收到遥控指令则更新 gait 参数 if (flag_5ms) { // 由TIM6中断置位 flag_5ms = 0; gait_update(); for (int i = 0; i < 6; i++) { leg_inverse_kinematics(&legs[i], target[i], angles[i]); } } if (flag_20ms) { // 由TIM6或SysTick置位 flag_20ms = 0; for (int i = 0; i < 6; i++) { servo_set_angle(i * 3 + 0, angles[i][0]); servo_set_angle(i * 3 + 1, angles[i][1]); servo_set_angle(i * 3 + 2, angles[i][2]); } } } }5.3 状态机:待机、行走、转向、停止
整个控制主状态我设计成一个简单的枚举状态机:
typedef enum { ROBOT_STANDBY, ROBOT_WALK, ROBOT_TURN, ROBOT_STRAFE, ROBOT_STOP } RobotState;状态切换发生在一个动作周期结束后,不要在动作执行中途切换——否则舵机目标点会跳变,轻则卡顿,重则撞机。我在gait.c里维护一个“当前步态是否执行完毕”的标志位,只有在这个标志位置位时才允许切换状态。
6. 调试中绕不开的坑位:抖动、电流、掉电
6.1 上电瞬间的“舵机乱舞”
这个问题几乎每个做多舵机机器人的都会遇到。现象是:单片机一上电,所有舵机猛转一下,然后停在某个随机位置,过一会儿才恢复正常。根因是单片机在GPIO初始化和定时器启动之间有一段“无信号”窗口,舵机检测不到有效PWM波形时就跑到一个不安全的默认位置。
我的处理方式是在servo_init()里先做三件事:
void servo_init(void) { // 1. 先把所有PWM脚配置为推挽输出低电平 // 2. 再初始化定时器,但先不输出比较 // 3. 等主函数走到这里时,再把PWM使能并输出中点脉宽 }这样上电后舵机会先收到一个稳定的低电平,不会误转。另外控制板给舵机的电源如果比逻辑信号晚到,也会出现乱舞,我加了硬件上的“信号保持电路”——串一个10k电阻到GND,让GPIO未初始化时信号线默认拉低。
6.2 抖动与供电:万恶的电不够
蜘蛛机器人18个舵机同时动的时候,瞬间电流是惊人的。MG996R这种金属齿轮舵机堵转电流能到1A以上,18个就是18A,哪怕不是同时堵转,起步瞬间的浪涌也够普通5V电源喝一壶的。我一开始用2A的USB电源供电,爬了两步就开始抖动,测了一下电压纹波已经到400mV了。
解决办法是给舵机独立供电,用6V/5A以上的BEC或是锂电池降压模块,并且在舵机电源端并联一颗470µF~1000µF的电解电容加若干104陶瓷电容。MCU逻辑电路和舵机电源之间要共地,但供电走线要分开,避免舵机的电流脉冲在地线上压出噪声。
有一个不太直观但很重要的细节:舵机的PWM信号电平一般是5V,而F103是3.3V逻辑,直接连问题也不大,因为大多数舵机对高电平的识别阈值比较宽松。但如果你用的是那种对电平很挑剔的舵机,最好加一个电平转换模块。
6.3 掉电保存:F103内部Flash写数据的两个坑
如果需要存“舵机中点标定值”这类掉电不能丢的参数,F103内部Flash可以省掉外接EEPROM。但有两个坑必须知道:
第一,Flash写入前必须擦除整个扇区,不能只擦一个字节。F103小容量每扇区1KB,中容量是2KB,擦除操作是按扇区为单位整块擦的。所以你不能频繁修改同一个字节,否则Flash会快速磨损。
第二,写入Flash时程序会被挂起,如果此时发生中断,会导致写入异常。正确写法是在写入前关闭总中断,写完后恢复。
我用的方案是:在Flash里划出两个扇区,写新数据前先检查当前扇区是否快写满,快满了就擦除备份扇区再切换,这样即使写入中途掉电,至少有一份旧数据是完整的。
7. 还能往哪里扩展
蜘蛛机器人的基础平台搭好之后,扩展方向非常多。只说几个我试过并且觉得性价比高的:
手机遥控:在protocol.c的指令解析里加一条蓝牙串口协议,将手机陀螺仪角度映射成机器人的速度方向和转向量,改动量很小,但体验提升非常大。
姿态反馈:加MPU6050惯性传感器,把姿态角作为闭环反馈,实时纠正四条支撑腿的伸缩量,在斜坡和软地面上稳定性明显提高。这类扩展需要把运动学输出从“纯几何解算”升级成“闭环控制”,但代码结构不用大改,只要在5ms周期里加入姿态修正量即可。
上位机可视化:用Python写一个简单的3D可视化脚本,通过串口实时读取每条腿的关节角度和足端轨迹,这对调参太有用了——你可以直接看到足端轨迹有没有严重抖动、有没有穿过身体平面,省得蹲在地上盯半天。
我个人的经验是,千万不要一上来就想着给蜘蛛机器人加视觉、加导航,先把步态调顺,把舵机标定做好,这台机器人才算真正“活”了。再往后加任何传感器,都是在这个稳定骨架上锦上添花。
本文还有配套的精品资源,点击获取