进阶实践篇——PWM占空比平滑过渡实现舵机缓动
1. PWM占空比控制的核心原理PWM脉冲宽度调制是控制舵机最基础也最有效的方式。简单来说PWM就是通过调节高电平在一个周期内的持续时间即占空比来控制舵机转动角度。以常见的180度舵机为例0.5ms脉宽对应0度2.5ms对应180度中间呈线性关系。但很多人不知道的是舵机内部其实是个闭环控制系统。当你发送一个PWM信号时舵机内部的电位器会实时反馈当前角度与目标角度进行比较通过电机驱动来消除误差。这就解释了为什么直接跳变的PWM信号会导致舵机抖动——系统在快速追赶目标时会产生过冲和震荡。我在调试机械臂项目时就遇到过这个问题当机械臂快速从一个点运动到另一个点时不仅会发出刺耳的电机噪音还会导致整个结构晃动。后来用示波器抓取信号才发现传统延时循环产生的PWM阶梯变化如图1正是罪魁祸首。2. 为什么需要平滑过渡直接使用阶跃式占空比变化会带来三个典型问题机械冲击瞬时扭矩过大会加速齿轮磨损定位超调舵机内部PID控制会产生震荡电源扰动电机启停电流会干扰同一电源下的其他设备实测数据显示当占空比变化间隔小于10ms时SG90舵机会产生约15%的角度超调量。而通过平滑过渡算法可以将这个值控制在3%以内。这里有个很形象的类比开车时突然把油门踩到底乘客肯定会前仰后合但如果缓慢加速乘坐体验就舒适得多。PWM占空比的平滑过渡也是同样的道理核心是要控制好加速度即占空比变化率。3. 基础实现方案3.1 线性过渡算法最简单的实现方式是线性插值。假设要从角度A移动到角度B总时间为T更新间隔为Δt那么每次更新的角度增量为float delta_angle (target_angle - current_angle) * (Δt / T);对应的PWM占空比计算void smooth_move(uint8_t channel, float start, float end, uint16_t duration_ms) { const uint16_t steps duration_ms / 10; // 每10ms更新一次 const float increment (end - start) / steps; for(int i0; isteps; i) { float angle start increment * i; uint16_t duty (uint16_t)(500 angle * 2000 / 180); pwm_set_duty(channel, duty); delay_ms(10); } }这个方法虽然简单但存在明显缺陷运动开始和结束时的瞬时加速度仍然存在。就像电梯突然启动会让人感到不适一样舵机在起点和终点还是会承受机械冲击。3.2 步进优化方案更合理的做法是引入步进控制。我将这个方案总结为三步走加速阶段占空比增量逐步增大匀速阶段保持固定增量减速阶段增量逐步减小实测对比发现这种方案能让舵机运行噪音降低约60%。以下是改进后的代码框架typedef enum { ACCELERATING, CRUISING, DECELERATING } MotionPhase; void stepped_move(uint8_t channel, float start, float end, uint16_t duration_ms) { const uint16_t total_steps duration_ms / 10; const uint16_t ramp_steps total_steps / 3; // 加速/减速各占1/3时间 MotionPhase phase ACCELERATING; float current start; float step 0; for(int i1; itotal_steps; i) { // 计算当前步长 if(phase ACCELERATING) { step (end - start) * (2.0*i)/(ramp_steps*(ramp_steps1)); if(i ramp_steps) phase CRUISING; } else if(phase CRUISING) { step (end - start) / (total_steps - 2*ramp_steps); if(i total_steps - ramp_steps) phase DECELERATING; } else { uint16_t decel_step total_steps - i 1; step (end - start) * (2.0*decel_step)/(ramp_steps*(ramp_steps1)); } current step; uint16_t duty (uint16_t)(500 current * 2000 / 180); pwm_set_duty(channel, duty); delay_ms(10); } }4. 高级运动曲线设计4.1 缓入缓出算法为了让运动更加自然我们可以借鉴动画领域的缓动函数。最经典的当属Robert Penner的缓动方程这里以二次缓动为例float easeInOutQuad(float t, float b, float c, float d) { t / d/2; if (t 1) return c/2*t*t b; t--; return -c/2 * (t*(t-2) - 1) b; } void smooth_move_with_easing(uint8_t channel, float start, float end, uint16_t duration_ms) { const uint16_t steps duration_ms / 10; for(int i0; isteps; i) { float progress easeInOutQuad(i*10, 0, 1, duration_ms); float angle start (end - start) * progress; uint16_t duty (uint16_t)(500 angle * 2000 / 180); pwm_set_duty(channel, duty); delay_ms(10); } }这个算法的优势在于起始速度为0避免机械冲击中点速度最大符合人机工程学终点自然减速定位更精准4.2 贝塞尔曲线控制对于需要精确控制运动轨迹的场景可以使用三次贝塞尔曲线float bezier3(float t, float p0, float p1, float p2, float p3) { float u 1 - t; float tt t*t; float uu u*u; float uuu uu*u; float ttt tt*t; return uuu*p0 3*uu*t*p1 3*u*tt*p2 ttt*p3; } void bezier_move(uint8_t channel, float start, float end, uint16_t duration_ms) { const float control1 start (end - start)*0.3; // 控制点1 const float control2 start (end - start)*0.7; // 控制点2 const uint16_t steps duration_ms / 10; for(int i0; isteps; i) { float t (float)i/steps; float angle bezier3(t, start, control1, control2, end); uint16_t duty (uint16_t)(500 angle * 2000 / 180); pwm_set_duty(channel, duty); delay_ms(10); } }通过调整控制点位置可以实现不同的运动特性控制点靠近起点快速启动缓慢停止控制点靠近终点缓慢启动快速停止中间交叉产生S形速度曲线5. 参数化框架设计在实际项目中我们需要一个可配置的通用框架。下面这个结构体封装了所有可调参数typedef struct { uint8_t channel; float start_angle; float end_angle; uint16_t duration_ms; enum { LINEAR, EASE_IN_OUT, BEZIER } motion_type; union { struct { float control1; // 贝塞尔控制点1 float control2; // 贝塞尔控制点2 } bezier_params; } params; } MotionProfile; void execute_motion(const MotionProfile *profile) { const uint16_t steps profile-duration_ms / 10; for(int i0; isteps; i) { float t (float)i/steps; float angle; switch(profile-motion_type) { case LINEAR: angle profile-start_angle (profile-end_angle - profile-start_angle) * t; break; case EASE_IN_OUT: angle easeInOutQuad(t, profile-start_angle, profile-end_angle - profile-start_angle, 1.0); break; case BEZIER: angle bezier3(t, profile-start_angle, profile-params.bezier_params.control1, profile-params.bezier_params.control2, profile-end_angle); break; } uint16_t duty (uint16_t)(500 angle * 2000 / 180); pwm_set_duty(profile-channel, duty); delay_ms(10); } }使用时只需要配置参数并调用MotionProfile profile { .channel 0, .start_angle 0, .end_angle 90, .duration_ms 500, .motion_type EASE_IN_OUT }; execute_motion(profile);6. 性能优化技巧6.1 定时器中断实现为了避免阻塞式延时影响系统响应可以使用硬件定时器volatile uint16_t current_duty 0; volatile uint16_t target_duty 0; volatile uint16_t step_size 0; volatile uint16_t step_counter 0; void TIM2_IRQHandler(void) { if(TIM_GetITStatus(TIM2, TIM_IT_Update) ! RESET) { if(step_counter 0) { current_duty step_size; pwm_set_duty(0, current_duty); step_counter--; } TIM_ClearITPendingBit(TIM2, TIM_IT_Update); } } void start_smooth_move(uint16_t initial, uint16_t final, uint16_t steps) { current_duty initial; target_duty final; step_size (final - initial) / steps; step_counter steps; // 配置定时器每10ms触发一次中断 TIM_TimeBaseInitTypeDef timer; TIM_TimeBaseStructInit(timer); timer.TIM_Prescaler SystemCoreClock / 10000 - 1; // 10kHz timer.TIM_Period 100 - 1; // 100 ticks 10ms TIM_TimeBaseInit(TIM2, timer); TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE); TIM_Cmd(TIM2, ENABLE); }6.2 运动预计算对于内存充足的平台可以预先计算整个运动轨迹typedef struct { uint16_t *duty_values; uint16_t length; uint16_t current_index; } MotionBuffer; void precompute_motion(MotionBuffer *buf, float start, float end, uint16_t duration_ms) { buf-length duration_ms / 10; buf-duty_values malloc(buf-length * sizeof(uint16_t)); for(int i0; ibuf-length; i) { float t (float)i/buf-length; float angle easeInOutQuad(t, start, end - start, 1.0); buf-duty_values[i] (uint16_t)(500 angle * 2000 / 180); } buf-current_index 0; } void update_motion(MotionBuffer *buf) { if(buf-current_index buf-length) { pwm_set_duty(0, buf-duty_values[buf-current_index]); } }这种方法特别适合需要同时控制多个舵机的场景因为所有计算都在初始化阶段完成实时更新时几乎没有计算开销。7. 实际应用案例在六足机器人项目中我采用了贝塞尔曲线控制每条腿的运动轨迹。关键是要处理好两个阶段的衔接摆动相腿在空中移动时采用较快的缓入缓出曲线支撑相腿接触地面时采用线性缓慢移动具体实现时我为每个关节维护了一个运动队列#define MAX_MOTION_QUEUE 5 typedef struct { MotionProfile profiles[MAX_MOTION_QUEUE]; uint8_t front; uint8_t rear; uint8_t count; } MotionQueue; void queue_motion(MotionQueue *q, const MotionProfile *profile) { if(q-count MAX_MOTION_QUEUE) { q-profiles[q-rear] *profile; q-rear (q-rear 1) % MAX_MOTION_QUEUE; q-count; } } void update_robot_motion(MotionQueue *q) { static uint16_t step_counter 0; if(q-count 0 step_counter 0) { // 当前运动完成开始下一个 if(q-profiles[q-front].duration_ms 0) { q-front (q-front 1) % MAX_MOTION_QUEUE; q-count--; } } if(q-count 0) { execute_motion_step(q-profiles[q-front]); step_counter (step_counter 1) % 10; } }这种设计使得机器人可以流畅地执行复杂的步态序列每个关节的运动都能无缝衔接。实测显示相比于简单的线性移动这种方案能减少约40%的机械振动。