简介:STM32多舵机控制是嵌入式硬件开发中的典型场景,基于ARM Cortex-M内核的微控制器配合丰富定时器资源,这份资料面向学习PWM生成与多路舵机驱动的开发者,也适合机器人、遥控模型等项目参考。压缩包共324个文件,约8.28MB,以Keil工程源码为主,涵盖.c/.h源文件、编译生成的.axf/.hex/.map、列表.lst及说明文档,并包含uvproj工程配置,能直接看到从定时器配置到输出比较更新的完整代码结构。已有1145人学习下载。舵机控制依赖20ms周期PWM信号,1~2ms高电平对应0°~180°,资料围绕定时器PWM生成、GPIO推挽输出、捕获比较寄存器更新、串口指令扩展等关键点展开,说明了通过结构体管理多舵机目标角度、平滑调速的实现思路。研读后可掌握多路舵机精确角度控制与项目调试方法,对进阶STM32嵌入式开发有明显帮助。
1. 多个舵机一上电就抖、复位、甚至冒烟,问题多半不在代码
把多个舵机接到 STM32 单片机上,第一反应就是用定时器的 PWM 输出。SG90、MG996R 这类舵机只需要一路 50Hz、脉宽 0.5ms 到 2.5ms 的方波,STM32 的定时器通道刚好能干这个活。但真把 6 路、8 路舵机接上去,很多人会碰到同一个现象:程序一跑,舵机发出高频吱吱声,或者单片机直接复位,严重的时候 USB 转串口模块也跟着掉线。
这个标题真正要解决的不是“怎么让一个舵机转”,而是“多个舵机同时工作时,PWM 通道分配、电源供给、信号干扰和程序架构怎么一起设计”。常见做法是用定时器的多个通道各带一路舵机,比如 STM32F103C8T6 的 TIM2、TIM3、TIM4 加起来理论上有 12 路 PWM,听着够用,但实际布线和供电一乱,波形就完蛋。这篇文章把从定时器配置到舵机供电、再到串口控制多路舵机的完整套路拆开讲,适合做机械臂、云台、仿生机器人或者毕业设计的开发者,新手能照着抄,老手可以重点看后面 DMA 扩展和电源处理的细节。
2. STM32 定时器 PWM 通道与多舵机最小实现:从 STM32F103C8T6 说起
2.1 舵机 PWM 协议:频率 50Hz,脉宽 0.5ms 到 2.5ms,中间有死区
舵机的控制信号本质上是一个周期 20ms、高电平脉宽在 0.5ms 到 2.5ms 之间的方波。0.5ms 对应 0 度,2.5ms 对应 180 度,中间近似线性。有些数字舵机支持更高频率,比如 333Hz,但绝大多数模拟舵机工作在 50Hz,超出频率范围就会发热、抖动甚至烧毁。所以 STM32 定时器的 ARR(自动重装载值)要算成 20ms 周期,而不是随便填一个值。
脉宽和角度的对应关系不是绝对的 0.5ms 到 2.5ms,实际舵机会有约 10us 到 20us 的死区。死区意味着舵机在一个小范围内输出的扭矩是零,电机不转也不响。利用死区可以设置“锁死”位置,避免舵机在目标角度附近来回震荡。控制多个舵机时,每个舵机的死区大小不完全一样,所以批量调参时要给每个通道留出独立的微调偏移量。
2.2 定时器通道、重映射与预分配:F103C8T6 到底能带几路
在算“能带几路”之前,先看 STM32F103C8T6 的定时器资源:TIM1 是高级定时器,有 4 个通道;TIM2、TIM3、TIM4 是通用定时器,各有 4 个通道。如果全部用来输出 PWM,硬件上最多 16 路,但引脚不是全都引出来了。C8T6 是 LQFP48 封装,PA0-PA7、PB0-PB1、PB6-PB9、PA8-PA11 这些引脚上都有定时器通道的重映射,具体要看数据手册的 AFIO 重映射表。
通道和引脚的映射关系决定了布线难度。默认状态下,TIM2 的四个通道在 PA0 到 PA3,TIM3 在 PA6、PA7、PB0、PB1,TIM4 在 PB6 到 PB9。如果想把 8 路舵机排成一排,直接接在同一个排针上,就需要用到部分重映射,比如把 TIM3 的重映射到 PC 口或者把 TIM2 的通道映射到 PA15、PB3 这些位置。注意 PB3、PB4 和 PA15 默认是 JTAG 引脚,要先关闭 JTAG 才能当普通 IO 用,这是新手最容易踩的坑。
下面的表格汇总了 F103C8T6 常见可用于舵机 PWM 的引脚组合,按“不改跳线直接能用”的优先级排列:
| 定时器 | 通道 | 默认引脚 | 部分重映射 | 完整重映射 |
|---|---|---|---|---|
| TIM2 | CH1 | PA0 | PA15 | PA0 |
| TIM2 | CH2 | PA1 | PB3 | PA1 |
| TIM2 | CH3 | PA2 | PB10 | PA2 |
| TIM2 | CH4 | PA3 | PB11 | PA3 |
| TIM3 | CH1 | PA6 | PB4 | PC6 |
| TIM3 | CH2 | PA7 | PB5 | PC7 |
| TIM3 | CH3 | PB0 | PB0 | PC8 |
| TIM3 | CH4 | PB1 | PB1 | PC9 |
| TIM4 | CH1 | PB6 | PB6 | PD12 |
| TIM4 | CH2 | PB7 | PB7 | PD13 |
| TIM4 | CH3 | PB8 | PB8 | PD14 |
| TIM4 | CH4 | PB9 | PB9 | PD15 |
通道分配的原则是先给高级定时器让路,因为 TIM1 同时又用作互补输出和刹车功能,一旦复用难免冲突。多个舵机控制里,TIM2 和 TIM3 是最顺手的两个定时器,把这两组凑齐就够 8 路了,剩下的 TIM4 可以留作其他用途,比如给编码器或者超声波模块。
顺便提一句,有人用“一个定时器一个通道 + 软件延时分时输出”的方式强行带十几个舵机,这在低占空比场景能跑,但 CPU 会被占得死死的,主循环跑不动。后面第 3 章会讲更优雅的 DMA 方案。
2.3 标准库与 HAL 库初始化代码:8 路舵机直接接 TIM2 + TIM3
舵机控制看起来就是设置定时器的 CCR 寄存器,但初始化参数选错,波形出不来。这里给一套经过验证的最小配置:系统时钟 72MHz,PSC 设为 71,定时器时钟变成 1MHz,也就是 1us 计数一次;ARR 设为 19999,计数周期 20000us,正好是 20ms,50Hz。CCR 的数值范围是 500 到 2500,对应 0.5ms 到 2.5ms,这样角度和脉宽的换算非常直观。
HAL 库的代码里,需要注意定时器输出比较通道的极性选择。TIM_OCPOLARITY_HIGH表示高电平是有效电平,舵机信号要求高电平脉宽,所以极性必须设成 HIGH。还有TIM_OCMODE_PWM1表示计数值小于 CCR 时输出高电平,大于 CCR 时输出低电平,这是 PWM1 模式;反过来 PWM2 模式是计数值小于 CCR 时输出低电平,初学的同学容易搞混,波形出来是反的。
下面是基于 HAL 库的初始化代码,使用定时器句柄和通道结构体,两个定时器 8 个通道一次性全部初始化。
// 舵机PWM初始化,TIM2 + TIM3 一共8个通道 // 输入参数无,输出无,直接操作全局定时器句柄 void Servo_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; TIM_OC_InitTypeDef sConfigOC = {0}; // 开时钟 __HAL_RCC_TIM2_CLK_ENABLE(); __HAL_RCC_TIM3_CLK_ENABLE(); __HAL_RCC_GPIOA_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); // GPIO复用配置:推挽输出,50MHz GPIO_InitStruct.Pin = GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3; // TIM2 CH1-CH4 GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_6 | GPIO_PIN_7; // TIM3 CH1-CH2 HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); GPIO_InitStruct.Pin = GPIO_PIN_0 | GPIO_PIN_1; // TIM3 CH3-CH4 HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); // 定时器基础配置:72MHz / (71+1) = 1MHz,ARR=19999 → 20ms TIM2.Instance = TIM2; TIM2.Init.Prescaler = 71; TIM2.Init.CounterMode = TIM_COUNTERMODE_UP; TIM2.Init.Period = 19999; TIM2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; TIM2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(&TIM2); TIM3.Instance = TIM3; TIM3.Init.Prescaler = 71; TIM3.Init.CounterMode = TIM_COUNTERMODE_UP; TIM3.Init.Period = 19999; TIM3.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; TIM3.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_ENABLE; HAL_TIM_PWM_Init(&TIM3); // PWM1模式,高电平有效,初始脉宽1.5ms(90度,舵机中点) sConfigOC.OCMode = TIM_OCMODE_PWM1; sConfigOC.Pulse = 1500; sConfigOC.OCPolarity = TIM_OCPOLARITY_HIGH; sConfigOC.OCFastMode = TIM_OCFAST_DISABLE; HAL_TIM_PWM_ConfigChannel(&TIM2, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_ConfigChannel(&TIM2, &sConfigOC, TIM_CHANNEL_2); HAL_TIM_PWM_ConfigChannel(&TIM2, &sConfigOC, TIM_CHANNEL_3); HAL_TIM_PWM_ConfigChannel(&TIM2, &sConfigOC, TIM_CHANNEL_4); HAL_TIM_PWM_ConfigChannel(&TIM3, &sConfigOC, TIM_CHANNEL_1); HAL_TIM_PWM_ConfigChannel(&TIM3, &sConfigOC, TIM_CHANNEL_2); HAL_TIM_PWM_ConfigChannel(&TIM3, &sConfigOC, TIM_CHANNEL_3); HAL_TIM_PWM_ConfigChannel(&TIM3, &sConfigOC, TIM_CHANNEL_4); // 启动PWM输出 HAL_TIM_PWM_Start(&TIM2, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&TIM2, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&TIM2, TIM_CHANNEL_3); HAL_TIM_PWM_Start(&TIM2, TIM_CHANNEL_4); HAL_TIM_PWM_Start(&TIM3, TIM_CHANNEL_1); HAL_TIM_PWM_Start(&TIM3, TIM_CHANNEL_2); HAL_TIM_PWM_Start(&TIM3, TIM_CHANNEL_3); HAL_TIM_PWM_Start(&TIM3, TIM_CHANNEL_4); } // 角度控制函数:把0~180度映射到500~2500的CCR值 // ch取值1~8,对应TIM2_CH1~TIM3_CH4;angle范围0~180 void Servo_SetAngle(uint8_t ch, uint16_t angle) { uint32_t ccr = 500 + (uint32_t)angle * 2000 / 180; // 0度=500, 180度=2500 if (ccr > 2500) ccr = 2500; if (ccr < 500) ccr = 500; if (ch == 1) TIM2->CCR1 = ccr; else if (ch == 2) TIM2->CCR2 = ccr; else if (ch == 3) TIM2->CCR3 = ccr; else if (ch == 4) TIM2->CCR4 = ccr; else if (ch == 5) TIM3->CCR1 = ccr; else if (ch == 6) TIM3->CCR2 = ccr; else if (ch == 7) TIM3->CCR3 = ccr; else if (ch == 8) TIM3->CCR4 = ccr; }代码注释已经说明了频率推导过程,这里再强调两个容易忽略的点。HAL_TIM_PWM_Init在 HAL 库内部会调用HAL_TIM_PWM_MspInit,如果你的工程没有实现这个回调函数,定时器的 GPIO 时钟和复用配置不会生效,波形输出不出来,所以要么在stm32f1xx_hal_msp.c里补上,要么像我上面这样直接在初始化函数里手动配置 GPIO。另一个点是TIM_AUTORELOAD_PRELOAD_ENABLE,开启预装载后 ARR 的变化会在下一个更新事件才生效,避免了运行中改频率导致波形撕裂,但这个参数也意味着初始化后立刻修改 ARR 不会马上起作用,调试时容易误判。
让舵机动起来的调用方式很简单,Servo_SetAngle(1, 45)就能让 1 号通道转到 45 度。真正调试多路舵机时,不建议在 for 循环里连续调用这个函数,因为每路舵机的负载不同,到达目标位置的时间也不同,连续发指令会让舵机一直处在追目标的状态,电流波动很大。正确做法是设定目标角度后,等 300ms 到 500ms 再发下一组指令,给舵机留出运动时间。
3. 定时器通道不够用时的三种扩展方案:DMA、软件 PWM 与总线舵机
3.1 通道不够的本质:硬件 PWM 是自动翻转,软件 PWM 是打断主循环
STM32 的定时器通道数决定了能同时输出几路独立的硬件 PWM。如果你要控制 12 个以上的舵机,或者引脚被其他功能占用了,就需要扩展方案。扩展不只是“凑够路数”,还要考虑实时性。硬件 PWM 由定时器计数器自动比较输出,CPU 完全不参与;软件 PWM 靠定时器中断翻转 IO,中断里做计数和比较,一旦中断响应不及时,波形就会抖动。
解决通道不够有三个常见方向。第一种是用 DMA 搬运数据,用一个定时器触发 DMA 去切换多个引脚的电平状态;第二种是干脆不用硬件 PWM,直接用 IO 翻转加延时做软件 PWM,适合舵机数量少且运动频率要求不高的场景;第三种是换硬件,加 PCA9685 这类 I2C 舵机驱动板,或者用串行总线舵机(比如飞特串行舵机),后者把电源和信号分开,一根线串联所有舵机。下面分别展开。
3.2 DMA + 通用引脚做脉冲分配:用 TIM1 更新事件触发 DMA 搬数组
硬件 PWM 通道不够,但定时器还有一个可用的资源:更新事件。每次计数器溢出时产生一次更新事件,这个事件可以触发 DMA 搬运数据。配合 GPIO 的 BSRR 寄存器,每次 DMA 搬运把一组引脚电平状态写到 BSRR,就能在固定的时间间隔内精确控制引脚翻转,从而在任意引脚上“伪造”出 PWM 波形。
以 16 路舵机为例,思路是开一个 1MHz 的定时器,溢出周期还是 20ms,把 20ms 拆成 20000 个 1us 的时隙。每个时隙里 16 路舵机的期望电平(高还是低)打包成一个 16bit 数组,放在 DMA 的缓冲区里。定时器每 1us 触发一次 DMA,把数组当前值写到 GPIOB 的 BSRR,这样 GPIOB 的 16 个引脚就在每个时刻按预定义的波形翻转。脉宽数据变成一张 20000 x 2 字节的表,定期更新这张表就能改变所有舵机的角度。
这个方案的实现代码如下,注意代码里展示的是 DMA 传输的初始化骨架,实际使用时缓冲区要开成 20000 个 16bit 数组,对 RAM 的占用约 40KB,STM32F103C8T6 只有 20KB RAM,装不下,所以要么减少时隙粒度到 10us(2000 个数组,4KB),要么用 F103ZET6 这类大 RAM 型号。实际项目里我一般用 10us 粒度,对舵机来说完全够。
// 使用TIM1更新事件触发DMA,搬运PWM状态表到GPIOB的BSRR // 时隙粒度10us,周期20ms,共2000个时隙 #define PWM_SLOT_CNT 2000 #define PWM_PERIOD_US 20000 uint16_t pwm_slot_table[PWM_SLOT_CNT] = {0}; // 每一路电平状态,bit0~bit15对应PB0~PB15 uint16_t dma_buf[PWM_SLOT_CNT]; // DMA搬运的源数据,等于pwm_slot_table void Servo_DMA_PWM_Init(void) { GPIO_InitTypeDef GPIO_InitStruct = {0}; DMA_InitTypeDef DMA_InitStruct = {0}; TIM_HandleTypeDef htim1; __HAL_RCC_TIM1_CLK_ENABLE(); __HAL_RCC_DMA1_CLK_ENABLE(); __HAL_RCC_GPIOB_CLK_ENABLE(); // PB0~PB15全部配置为推挽输出 GPIO_InitStruct.Pin = GPIO_PIN_All; GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP; GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH; HAL_GPIO_Init(GPIOB, &GPIO_InitStruct); // DMA1通道5,外设地址是GPIOB的BSRR寄存器 DMA_InitStruct.PeriphInc = DMA_PINC_DISABLE; DMA_InitStruct.MemInc = DMA_MINC_ENABLE; DMA_InitStruct.PeriphDataAlignment = DMA_PDATAALIGN_HALFWORD; DMA_InitStruct.MemDataAlignment = DMA_MDATAALIGN_HALFWORD; DMA_InitStruct.Mode = DMA_CIRCULAR; // 循环模式,持续输出波形 DMA_InitStruct.Priority = DMA_PRIORITY_HIGH; DMA_InitStruct.PeriphBaseAddr = (uint32_t)&GPIOB->BSRR; DMA_InitStruct.MemoryBaseAddr = (uint32_t)pwm_slot_table; DMA_InitStruct.Direction = DMA_MEMORY_TO_PERIPH; DMA_InitStruct.BufferSize = PWM_SLOT_CNT; HAL_DMA_Init(&hdma_tim1_up); // 把DMA和TIM1的更新事件关联起来 __HAL_LINKDMA(&htim1, hdma, hdma_tim1_up); // TIM1配置为10us溢出一次:72MHz / (72-1) = 1MHz,ARR=9 htim1.Instance = TIM1; htim1.Init.Prescaler = 71; htim1.Init.CounterMode = TIM_COUNTERMODE_UP; htim1.Init.Period = 9; htim1.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; htim1.Init.RepetitionCounter = 0; HAL_TIM_Base_Init(&htim1); // 开启更新事件DMA请求 __HAL_TIM_ENABLE_DMA(&htim1, TIM_DMA_UPDATE); HAL_TIM_Base_Start(&htim1); } // 更新某一通道的脉宽,ch范围0~15,pulse_us范围500~2500 // 调用后DMA会在下一个周期自动按新表输出 void Servo_DMA_SetPulse(uint8_t ch, uint16_t pulse_us) { // 先把该通道在所有时隙里的电平全部清零 for (uint16_t i = 0; i < PWM_SLOT_CNT; i++) { pwm_slot_table[i] &= ~(1 << ch); } // 前pulse_us/10个时隙里,该通道电平置高 uint16_t high_slots = pulse_us / 10; if (high_slots > PWM_SLOT_CNT) high_slots = PWM_SLOT_CNT; for (uint16_t i = 0; i < high_slots; i++) { pwm_slot_table[i] |= (1 << ch); } }这段代码的关键逻辑是pwm_slot_table数组的生成:每一行代表一个时隙内 16 个引脚的电平状态,DMA 每 10us 把一行搬到 BSRR。BSRR 的低 16 位是置位寄存器,写 1 对应的引脚输出高电平;高 16 位是复位寄存器。如果直接搬运整个数组到 BSRR,要注意 BSRR 高 16 位写 1 会清掉对应引脚,所以干净的做法是在pwm_slot_table中预留好位域,比如 bit16 到 bit31 放复位位,但这会占双倍内存,所以上述代码只用了低 16 位置位,没管复位——没关系,因为每个时隙都会把整行数据重新写入,引脚状态完全由 DMA 的数组决定。
用这套方案,CPU 只在更新角度时修改数组,日常运行完全不参与波形生成,16 路舵机只占用一个 DMA 通道,主循环还能跑传感器读取和串口通信。内存占用是最大限制。另一个缺点是引脚必须集中在同一个 GPIO 口上,如果舵机分散在 PA、PB、PC 多个端口,DMA 表就得扩展成多个 BSRR 地址,复杂度直线上升。对于这种分散需求,我一般直接选方案三的总线舵机。
3.3 软件 PWM 的代价:CPU 占用、抖动和定时器中断嵌套
有些开发者习惯用HAL_GPIO_TogglePin加延时函数做软件 PWM,代码写起来最快,但多路舵机同时控制时会暴露出严重问题。一个定时器中断服务函数里,如果做 8 路舵机的电平判断,中断执行时间可能超过 20us,而 50Hz 的周期是 20ms,占空比最小的 0.5ms 意味着中断要在 0.5ms 内完成高电平维持和翻转,任何一个额外操作都会导致脉宽偏移。
软件 PWM 适合什么场景呢?舵机数量在 2 到 4 路,且不需要精确定位的场合,比如玩具车转向和油门,两个舵机用两个 Timer 中断就够了。一旦超过 4 路,中断嵌套、任务调度和看门狗喂狗都会变得不可控。用软件 PWM 要坚持两个原则:中断服务函数里只做比较和翻转,不做角度计算;角度到延迟时间的换算放在主循环里提前算好,中断里查表。
3.4 换个思路:PCA9685 I2C 舵机驱动板,以及串行总线舵机
PCA9685 是 NXP 的 16 路 12bit PWM 驱动器,通过 I2C 接口控制,价格便宜,一块板子就能带 16 路舵机,而且是 5V 电平输出,和大部分舵机直接兼容。STM32 只要引出 SCL 和 SDA 两根线,写一个 I2C 驱动,就能控制 16 路舵机。这种方式最适合舵机数量多但位置分散的场景,缺点是刷新率固定 50Hz,且 I2C 速率只有 400kHz,如果每路舵机都需要平滑插值运动,实时性会差点。
还有一种进阶玩法是串行总线舵机。总线舵机内部有 MCU,接收串口指令控制电机,多路舵机共用一根信号线串联,每个舵机有独立 ID。STM32 通过 UART 发送指令帧,比如飞特舵机的协议帧格式是帧头 + ID + 指令 + 参数 + 校验和。这种方案的优势是控制指令里直接带角度、速度和启动时间,MCU 不需要生成 PWM 波形;劣势是舵机单价高,而且需要适配每个厂商的通信协议。用在机械臂和仿生机器人上,总线舵机让线束从几十根减少到三根(电源、地、信号),这是多自由度项目里最省心的选择。不过起步阶段还是先把 STM32 定时器 PWM 玩透,总线舵机再上不迟。
4. 供电和波形:多个舵机动起来后,复位、抖动、猛跳的排查顺序
4.1 拉电流计算:标称 500mA 的舵机堵转时能吃掉 1A 以上
舵机控制里最常见的错误是用 STM32 开发板的 3.3V LDO 给舵机供电。SG90 微型舵机标称工作电流 100mA 到 200mA,堵转电流约 650mA;MG996R 大舵机标称电流 500mA 到 900mA,堵转电流能到 2.5A。你以为 8 路舵机同时动作,按标称电流算只需要 4A,实际上多个舵机同时启动时,瞬时电流会叠加,峰值轻松超过 8A,这时候任何线性稳压器都会过热保护。
供电设计必须遵守一条铁律:STM32 和舵机电源完全分开,但地线要共地。信号参考地必须连接,否则 PWM 信号的电平没有参考点,波形会乱跳。信号线串联一个 1kΩ 到 2.2kΩ 的电阻可以降低舵机电源噪声对 MCU 的干扰,高频场景还可以再加一个 10nF 电容到地。电源端要并联一个大容量电解电容,470uF 起步,舵机越多容值越大,用于吸收电机启动时的浪涌电流。另外,如果舵机电源是开关电源,输出端加一个 TVS 管或者稳压二极管钳制浪涌尖峰。
4.2 电源方案:独立 5V 电源、共地、大电容、TVS
实操层面,推荐一个通用方案,用一个 5V 3A 的电源适配器给舵机供电,STM32 用 USB 或独立 3.3V 供电,两块电源的地在舵机排针附近单点连接。舵机电源的 5V 不要直接接到 STM32 的 5V 引脚上,因为舵机电源噪声大,可能导致 ADC 采样异常或直接损坏 MCU 的电源引脚。如果一定要共用电源,中间加一个 LC 滤波,电感和电容的取值参照舵机的工作频率计算。
// 供电检测的思路:用ADC采样舵机电源电压,低于阈值时停止输出PWM并报警 // 这是一个简化示例,实际项目里ADC会配DMA循环采样 uint16_t get_vbus_mv(void) { // ADC1_IN1, PA1引脚 ADC_ChannelConfTypeDef sConfig = {0}; sConfig.Channel = ADC_CHANNEL_1; sConfig.Rank = ADC_REGULAR_RANK_1; sConfig.SamplingTime = ADC_SAMPLETIME_239CYCLES_5; HAL_ADC_ConfigChannel(&hadc1, &sConfig); HAL_ADC_Start(&hadc1); HAL_ADC_PollForConversion(&hadc1, 10); uint16_t adc_val = HAL_ADC_GetValue(&hadc1); HAL_ADC_Stop(&hadc1); // 假设分压电阻 10K:10K,满量程 3.3V,计算实际电压 uint32_t mv = (uint32_t)adc_val * 3300 / 4095; return (mv * 2); } // 主循环里周期性检查电压 void servo_power_check(void) { uint16_t v = get_vbus_mv(); if (v < 4500) { // 电压跌落明显,关PWM,防止舵机半转卡死 __HAL_TIM_SET_COMPARE(&TIM2, TIM_CHANNEL_1, 0); __HAL_TIM_SET_COMPARE(&TIM2, TIM_CHANNEL_2, 0); // 其他通道同样处理... } }4.3 抖动与波形毛刺:示波器/逻辑分析仪抓 PWM,检查上升沿和地弹
舵机抖动分两种:一种是指令抖动,就是角度值本身在来回变,这种查代码就行;另一种是波形抖动,PWM 波形上叠加了毛刺,导致舵机误判脉宽。波形毛刺最常见的原因是地线阻抗。多个舵机回流的电流在地线上产生压降,这个压降叠加到 MCU 的 GND 上,相当于信号的电平基准在跳,PWM 波形看起来就有很多尖峰。
排查波形问题用逻辑分析仪(比如 Saleae 兼容的 8 通道 24MHz 采样)比示波器更方便,因为可以同时抓 8 路信号做时序对比。接好探头后,把时基调到 1ms/div,观察每一路的脉宽是否稳定。正常波形的高电平应该平整,没有明显的振铃;如果看到高电平中间有下凹,说明电源在内阻上产生了压降。这时候加大电容、缩短舵机电源线,或者换成粗线(AWG 18 以上),波形很快就能改善。
4.4 烧录后第一个现象对照:舵机不动、有声音、乱转、复位
给一个快速排查表,按现象找原因比从头翻原理快得多:
| 现象 | 最常见原因 | 验证方法 | 解决办法 |
|---|---|---|---|
| 舵机完全不动 | 供电没通或信号线接反 | 万用表量舵机电源端电压 | 确认 5V 供电,信号线接对引脚 |
| 舵机抖动并发烫 | PWM 频率不对,超出 50Hz | 用逻辑分析仪测信号频率 | 检查 ARR 和 PSC,确保 20ms 周期 |
| 舵机吱吱响但不动 | 脉宽超出有效范围 | 依次把 CCR 设为 500、1500、2500 | 确认映射公式,限制脉宽 500~2500us |
| 舵机到达位置后反弹 | 死区设置太小或电源带载能力不足 | 观察电源电压是否跌落 | 调低舵机扭矩,增大电源电流 |
| STM32 反复复位 | 舵机启动电流拉低电源,MCU 供电不足 | 用示波器抓 3.3V 波形 | 独立电源供电,串联二极管隔离 |
记住一个判断原则:单片机和舵机分电源供电是底线。很多“代码没问题但一接上就复位”的案例,最后查出来都是共用电源导致的。
5. 8 路舵机角度调试的最小框架:串口指令 + 角度映射 + 限幅
多路舵机系统联调时,反复改代码然后重新烧录非常浪费时间。常见做法是通过串口发送指令直接控制舵机角度,这样不用重新编译,甚至可以在上位机上做滑块实时调节。串口接收本身是个状态机,和 modbus 单片机帧接收数据程序的思路类似,用一帧固定格式的数据包,解析出舵机 ID 和角度值。
我一般用的帧格式很简单:帧头 0xAA 0x55,接着是舵机 ID(1 到 8),然后是角度高字节和低字节,最后是校验字节(前面所有数据之和的低 8 位)。串口中断只负责把数据收进环形缓冲区,主循环里做帧解析。这样即使一帧数据被拆成几次中断收到,也不会丢数据。中断里不要做任何角度计算,只做字节接收,这是稳定性的关键。
// 串口接收状态机:接收一帧6字节数据,格式 AA 55 ID ANGLE_H ANGLE_L SUM // 返回1表示完整帧,返回值通过指针带回舵机ID和角度 uint8_t servo_uart_frame_parse(uint8_t byte, uint8_t *servo_id, uint16_t *angle) { static uint8_t rx_buf[6]; static uint8_t rx_index = 0; if (rx_index == 0 && byte != 0xAA) return 0; // 等待帧头 if (rx_index == 1 && byte != 0x55) { rx_index = 0; return 0; } rx_buf[rx_index++] = byte; if (rx_index >= 6) { rx_index = 0; uint8_t sum = 0; for (uint8_t i = 0; i < 5; i++) sum += rx_buf[i]; if (sum != rx_buf[5]) return 0; // 校验失败 *servo_id = rx_buf[2]; *angle = ((uint16_t)rx_buf[3] << 8) | rx_buf[4]; if (*angle > 180) *angle = 180; // 限幅 return 1; } return 0; } // 主循环调用示例 void server_loop(void) { uint8_t id; uint16_t angle; while (1) { // 假设从串口缓冲区取一个字节,伪代码表示 // uint8_t byte = uart_receive_byte(); // if (servo_uart_frame_parse(byte, &id, &angle)) { // Servo_SetAngle(id, angle); // } // 其他任务 } }上面代码里的状态机有 6 字节缓冲,因为帧长度固定,所以不需要处理粘包和半包问题。注意校验和的算法是所有字节求和取低 8 位,这个逻辑可以在上位机里用同样的方式计算,避免出现 CRC 库依赖。
角度到 CCR 的映射在第二章的Servo_SetAngle里已经写了。这里补充一个实际使用中的细节:舵机不是在所有角度范围内都线性,0 度附近和 180 度附近会出现明显的非线性,因为舵机内部电位器的机械结构有误差。对于定位要求高的场景,可以做一个两点的校准:发送 0 度指令,实测舵机停在哪,得到一个偏移;发送 180 度指令,再实测一次,算出斜率修正值。把这个修正值存在一个数组里,每次设置角度时先查表,比硬编码一个公式可靠得多。校准的过程最好也是通过串口指令来做,在代码里留出“校准模式”的状态,这样就不需要反复烧录固件。
上电瞬间的位置管理和校准本身也很重要。舵机上电默认会停在接收到第一个 PWM 信号之前的位置,这个位置可能是上次断电时的位置,也可能是随机位置。如果机械臂上电后舵机猛跳一下,有可能是初始化代码里第一条 PWM 就给了 0 度或 180 度。稳妥的做法是把初始化角度全部设成 90 度(对应 1.5ms),等系统启动完成、串口收到第一批指令后再运动到目标位置。这个“先回中点再动”的策略在仿生机器人和六轴机械臂的多舵机系统里几乎是标配。在舵机阵列上电后,先用逻辑分析仪抓一下第一帧波形,确认每一路的脉宽都是 1500us 附近,再开始角度规划,这一步排查能省下大量雕塑动作的调试时间。
本文还有配套的精品资源,点击获取