第四章:核心源码框架

好,咱们进入正题。这一章,我打算把步进电机驱动的三个核心骨架拆开来讲。说白了,就是状态机、定时器中断、还有环形缓冲区。这三个东西,你搞懂了,写出来的代码才叫“工程级”,而不是“玩具级”。

本章核心三要素:

  • 状态机设计模式 —— 让逻辑清晰可维护
  • 定时器中断架构 —— 让时序精准可控
  • 环形缓冲区 —— 让脉冲生成流畅不卡顿
步进电机驱动核心源码框架 状态机设计模式 IDLE / ACCEL / RUN DECEL / STOP 状态切换 + 动作执行 定时器中断架构 固定周期中断 速度曲线计算 脉冲输出控制 环形缓冲区 脉冲序列缓存 读写指针分离 无锁数据交换 驱动 填充

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)产生中断。在中断服务函数里,我们做三件事:

  1. 读取当前速度值
  2. 计算下一个脉冲的定时器比较值
  3. 更新 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 不支持原子操作,可以考虑关中断保护,但关中断时间要尽量短。

好了,这三个核心框架讲完了。状态机管逻辑,定时器管时序,环形缓冲区管数据流。三者配合,才能写出稳定可靠的步进电机驱动。下一节,我会用实际项目代码,把这三个东西串起来跑一遍。