第四章:核心源码框架
好,咱们进入正题。这一章,我打算把步进电机驱动的三个核心骨架拆开来讲。说白了,就是状态机、定时器中断、还有环形缓冲区。这三个东西,你搞懂了,写出来的代码才叫“工程级”,而不是“玩具级”。
本章核心三要素:
- 状态机设计模式 —— 让逻辑清晰可维护
- 定时器中断架构 —— 让时序精准可控
- 环形缓冲区 —— 让脉冲生成流畅不卡顿
4.1 状态机设计模式:让电机听话
我个人习惯,写电机驱动第一件事就是画状态机。为什么?因为电机运行不是线性的,它要经历启动、加速、匀速、减速、停止。每个阶段的行为完全不同。
你想想看,如果不用状态机,代码里全是 if-else 嵌套,改一个逻辑可能牵一发动全身。我在项目中遇到过,有人用全局变量加 switch 硬写,结果调试了三天,最后发现状态漏了一个分支。
我的经验:状态机设计时,每个状态只做一件事。比如 IDLE 状态只负责等待命令,不处理脉冲。ACCEL 状态只负责加速曲线计算,不处理停止逻辑。这样出了问题,定位非常快。
来看一个典型的状态机定义:
typedef enum {
MOTOR_IDLE = 0,
MOTOR_ACCEL,
MOTOR_RUN,
MOTOR_DECEL,
MOTOR_STOP
} MotorState_t;
typedef struct {
MotorState_t state;
uint32_t current_pos;
uint32_t target_pos;
uint16_t current_speed;
uint16_t max_speed;
uint16_t accel_step;
} MotorControl_t;
状态切换的逻辑,我建议放在一个单独的函数里,不要散落在各处:
void Motor_StateMachine(MotorControl_t *motor) {
switch(motor->state) {
case MOTOR_IDLE:
if(motor->target_pos != motor->current_pos) {
motor->state = MOTOR_ACCEL;
motor->current_speed = START_SPEED;
}
break;
case MOTOR_ACCEL:
motor->current_speed += ACCEL_STEP;
if(motor->current_speed >= motor->max_speed) {
motor->current_speed = motor->max_speed;
motor->state = MOTOR_RUN;
}
break;
// ... 其他状态类似
}
}
注意:状态机里千万不要做延时等待!我曾经见过有人直接在状态里加 delay_ms(),结果整个系统卡死。状态切换应该是事件驱动的,不是时间驱动的。
4.2 定时器中断架构:精准的节拍器
步进电机驱动,说白了就是精确控制脉冲的间隔时间。这个任务,必须交给硬件定时器。用软件延时?那叫“脉冲抖动”,电机跑起来会嗡嗡响。
我常用的架构是这样的:一个定时器,固定周期(比如 10us)产生中断。在中断服务函数里,我们做三件事:
- 读取当前速度值
- 计算下一个脉冲的定时器比较值
- 更新 PWM 或 GPIO 输出
代码骨架大概长这样:
// 定时器中断服务函数
void TIM_IRQHandler(void) {
if(TIM_GetITStatus(TIMx, TIM_IT_Update) != RESET) {
TIM_ClearITPendingBit(TIMx, TIM_IT_Update);
// 1. 从环形缓冲区取下一个脉冲间隔
uint16_t interval = RingBuffer_Read(&pulse_buf);
// 2. 更新定时器比较值
TIM_SetCompare1(TIMx, interval);
// 3. 切换脉冲电平
STEP_GPIO_Toggle();
}
}
嗯,这里要注意:中断服务函数一定要短。我见过有人把加减速曲线计算放在中断里,结果计算时间比中断周期还长,系统直接崩溃。正确的做法是:中断只做“取数”和“输出”,计算放在主循环里。
核心原则:中断里只做“搬运工”,不做“计算员”。计算任务交给主循环或任务调度器。
4.3 环形缓冲区:脉冲生成的“蓄水池”
为什么要用环形缓冲区?因为脉冲生成是“生产-消费”模型。主循环负责计算脉冲间隔,写入缓冲区。定时器中断负责读取缓冲区,输出脉冲。如果不用缓冲区,两者速度不匹配,要么丢脉冲,要么卡顿。
环形缓冲区的实现,我习惯用数组加两个指针:
#define BUF_SIZE 256
typedef struct {
uint16_t buffer[BUF_SIZE];
volatile uint16_t head; // 写指针
volatile uint16_t tail; // 读指针
} RingBuffer_t;
// 写操作
uint8_t RingBuffer_Write(RingBuffer_t *rb, uint16_t data) {
uint16_t next = (rb->head + 1) % BUF_SIZE;
if(next == rb->tail) {
return 0; // 缓冲区满
}
rb->buffer[rb->head] = data;
rb->head = next;
return 1;
}
// 读操作
uint16_t RingBuffer_Read(RingBuffer_t *rb) {
if(rb->head == rb->tail) {
return 0; // 缓冲区空
}
uint16_t data = rb->buffer[rb->tail];
rb->tail = (rb->tail + 1) % BUF_SIZE;
return data;
}
避坑指南:我曾经犯过一个错误——读写指针没有加 volatile 关键字。结果编译器优化后,中断里读到的 tail 指针一直是旧值,缓冲区永远显示“满”。调试了两天才发现。记住:中断和主循环共享的变量,一定要加 volatile。
缓冲区大小的选择也有讲究。太小了容易溢出,太大了浪费内存。我一般这样估算:
| 电机最高转速 | 脉冲频率 | 推荐缓冲区大小 |
|---|---|---|
| 1000 RPM | ~16.7 kHz | 64 |
| 3000 RPM | ~50 kHz | 128 |
| 6000 RPM | ~100 kHz | 256 |
这个表格是我实际测试出来的。你想想看,如果缓冲区太小,主循环稍微慢一点,中断就取不到数据,电机就会丢步。所以,宁可多分配一点内存,也别省这个空间。
重要提醒:环形缓冲区在中断和主循环之间共享,一定要保证读写操作的原子性。如果 MCU 不支持原子操作,可以考虑关中断保护,但关中断时间要尽量短。
好了,这三个核心框架讲完了。状态机管逻辑,定时器管时序,环形缓冲区管数据流。三者配合,才能写出稳定可靠的步进电机驱动。下一节,我会用实际项目代码,把这三个东西串起来跑一遍。