【机创_①单个步进电机PWM控制】STM32F407 + HAL库

在机械创新比赛或嵌入式项目中,步进电机是最常用的执行器。相比于简单的翻转GPIO口,使用 定时器PWM + 中断计数 的方式控制步进电机,能够极大释放CPU资源,实现更精准、平滑的控制。

本文将详细介绍如何基于 STM32F407ZET6,利用 HAL 库实现对单个步进电机的精准位置(步数)和速度(RPM)控制。


1. 能够实现的功能

本驱动程序旨在实现一种“指哪打哪”的基础控制逻辑,具体功能如下:

  1. 精准步数控制:指定电机走多少步(对应多少角度),走完自动停止。
  2. 速度可调:可以直接设定电机的 RPM(转/分钟),程序自动计算定时器参数。
  3. 方向控制:通过 API 参数直接控制正反转。
  4. 硬件资源利用:使用 TIM 定时器产生脉冲,仅在脉冲结束时触发中断计数,CPU 占用率极低。
  5. 解耦设计:驱动代码与主逻辑分离,方便移植和扩展。

2. STM32CubeMX 配置

硬件环境:

  • MCU: STM32F407ZET6
  • 电机驱动器: 张大头驱动板
  • 引脚分配:
  • PUL (脉冲): PA2 (TIM5_CH3)
  • DIR (方向): PE3
  • EN (使能): PE4

配置步骤:

  1. 时钟树 (RCC):
  • 配置外部晶振 (HSE),系统主频设为 168MHz
  • 注意:TIM5 挂载在 APB1 总线上,APB1 Timer Clock 通常为 84MHz
    在这里插入图片描述
  1. 定时器配置 (TIM5):
  • 找到 TIM5,勾选 Internal Clock

  • Channel 3 选择 PWM Generation CH3

  • Parameter Settings (关键):

  • Prescaler (PSC): 83

  • 原理: 这意味着定时器每 1us计数一次,方便后续计算。

  • Counter Period (ARR): 1000 (初始值随便填,代码中会动态修改)。

  • Pulse: 500 (初始占空比 50%)。

  • Auto-reload preload: Enable。

  • NVIC Settings (重中之重):

  • 勾选 TIM5 global interrupt -> Enabled使用计数器更新中断 如果不开启中断,无法计算步数,电机将停不下来。

为什么

1. 真实机制:定时器的“内循环”

STM32 的定时器(TIM5)内部有两个关键寄存器:

  1. CNT (Counter, 计数器):当前的计数值(比如 0, 1, 2, …)。
  2. ARR (Auto-Reload Register, 自动重装载值):设定的终点(比如 999)。
过程演示(假设 ARR = 999):
  1. 开始CNT 从 0 开始,每过 1us 加 1。
  2. 过程
  • CNT = 100 … 500 … 900 …
  • 同时,硬件电路根据 CNT 的值控制 PWM 引脚输出高电平或低电平(这是纯硬件行为,不干扰 CPU)。
  1. 终点(关键时刻)
  • CNT 增加到 999 (ARR) 时,周期结束。
  • 下一刻,CNT 瞬间清零变成 0
  • 就在这一瞬间(溢出/更新事件),定时器硬件内部自动产生一个中断信号 (Update Interrupt)。

结论:

  • 一次溢出 = 一个 PWM 周期 = 一次中断。
  • 中断是因为“计数器数满了一圈”而触发的,而不是因为“引脚电平变了”而触发的。哪怕你把 PWM 引脚断开,甚至不配置 GPIO 输出,只要定时器在跑,这个中断依然会准时触发。

2. 为什么中断里减 1 就是准确的?

因为严格的同步关系

  • 硬件承诺:我每数完一圈(产生一个完整的 PWM 波形),我就触发一次中断。
  • 软件逻辑:既然你触发了中断,就说明你刚刚发完了一个波。那我就把 g_steps_remaining 减 1。

图解说明:

  • 上图(CNT):锯齿波,代表计数器从 0 涨到 ARR,再掉回 0。

  • 中图(PWM Output):根据 CNT 的值,引脚输出高低电平。

  • 下图(Interrupt):每次 CNT 掉回 0 的瞬间(也是 PWM 周期结束的瞬间),产生一个尖峰,这就是中断请求。

  • 这里用的是内部计数器溢出触发中断(TIM Update Interrupt)。

中断根本不知道发出了多少波,它只知道:“老板(定时器),我又跑完一圈了!
然后 CPU(主程序)在中断里拿出小本本(g_steps_remaining)记下来:“好,又跑了一圈,还剩 99 圈。”

  1. GPIO 配置:
  • PE3 (DIR): Output Push Pull, 默认 Low, 命名 MOTOR_DIR
  • PE4 (EN): Output Push Pull, 默认 High (假设高电平脱机/省电), 命名 MOTOR_EN

在这里插入图片描述


3. 完整代码

为了保持工程整洁,我们将电机驱动封装为 motor_driver.cmotor_driver.h

3.1 头文件 (motor_driver.h)

这里使用宏定义管理引脚,方便后续修改硬件连接。

#ifndef __MOTOR_DRIVER_H
#define __MOTOR_DRIVER_H

#include "main.h"

// --- 硬件引脚定义 (修改这里即可换引脚) ---
// 1. 方向引脚 (DIR) -> 现在改为 PE3
#define MOTOR_DIR_PORT   GPIOE
#define MOTOR_DIR_PIN    GPIO_PIN_3 

// 2. 使能引脚 (EN) -> 保持 PE4
#define MOTOR_EN_PORT    GPIOE
#define MOTOR_EN_PIN     GPIO_PIN_4

// --- 电机参数定义 ---
#define STEPS_PER_REV    3200     // 16细分: 200 * 16
#define TIMER_FREQ       1000000  // 定时器计数频率 1MHz (需CubeMX PSC=83)

// --- 全局变量声明 ---
extern volatile uint32_t g_steps_remaining;
extern volatile uint8_t  g_motor_running;

// --- 函数声明 ---
void Motor_Init(void);
void Motor_Move(uint8_t dir, uint32_t steps, float rpm);
void Motor_Stop(void);

#endif

3.2 源文件 (motor_driver.c)

#include "motor_driver.h"
#include "tim.h"  // 必须包含以获取 htim5

// --- 全局变量定义 ---
volatile uint32_t g_steps_remaining = 0; // 剩余脉冲数 Global g_ (全局) + steps (步数) + remaining (剩余)
volatile uint8_t  g_motor_running = 0;   // 运行标志位

// --- 初始化函数 ---
void Motor_Init(void)
{
    // 1. 确保PWM停止
    HAL_TIM_PWM_Stop(&htim5, TIM_CHANNEL_3);
    HAL_TIM_Base_Stop_IT(&htim5);
    
    // 2. 初始化 GPIO 状态
    // DIR (PE3): 默认拉低
    HAL_GPIO_WritePin(MOTOR_DIR_PORT, MOTOR_DIR_PIN, GPIO_PIN_RESET);
    
    // EN (PE4): 默认拉高 (假设 High = 松开/脱机, Low = 锁死/使能)
    // 根据你的驱动器实际逻辑,如果不希望电机发热,初始化时拉高
    HAL_GPIO_WritePin(MOTOR_EN_PORT, MOTOR_EN_PIN, GPIO_PIN_SET); 
}

// --- 设置速度 (内部函数) ---
static void Motor_SetSpeed(float rpm)
{
    if (rpm <= 0) return;

    // 1. 计算脉冲频率 (Hz)
    // 频率 = (RPM * 3200) / 60
    float freq_hz = (rpm * STEPS_PER_REV) / 60.0f;

    // 2. 计算 ARR (自动重装载值)
    // ARR = (1MHz / 频率) - 1
    uint32_t arr = (uint32_t)(TIMER_FREQ / freq_hz) - 1;

    // 3. 限制范围 (防止 ARR 太小导致中断溢出卡死)
    if (arr < 100) arr = 100;      
    if (arr > 65535) arr = 65535;

    // 4. 更新寄存器 (改变频率)
    __HAL_TIM_SET_AUTORELOAD(&htim5, arr);
    __HAL_TIM_SET_COMPARE(&htim5, TIM_CHANNEL_3, arr / 2); // 占空比保持 50%
}

// --- 电机移动核心函数 ---
// dir: 0 或 1
// steps: 脉冲数
// rpm: 转速
void Motor_Move(uint8_t dir, uint32_t steps, float rpm)
{
    if (g_motor_running) return; // 防止重复调用

    g_motor_running = 1;
    g_steps_remaining = steps;

    // 1. 设置方向 (使用 PE3)
    if (dir == 1)
        HAL_GPIO_WritePin(MOTOR_DIR_PORT, MOTOR_DIR_PIN, GPIO_PIN_SET);
    else
        HAL_GPIO_WritePin(MOTOR_DIR_PORT, MOTOR_DIR_PIN, GPIO_PIN_RESET);

    // 2. 使能驱动器 (拉低 PE4,电机上电锁死)
    HAL_GPIO_WritePin(MOTOR_EN_PORT, MOTOR_EN_PIN, GPIO_PIN_RESET);

    // 3. 设置速度
    Motor_SetSpeed(rpm);

    // 4. 清除计数器和中断标志 (关键步骤!)
    __HAL_TIM_SET_COUNTER(&htim5, 0);
    __HAL_TIM_CLEAR_IT(&htim5, TIM_IT_UPDATE);

    // 5. 开启中断和PWM
    HAL_TIM_Base_Start_IT(&htim5);            // 开启计数中断
    HAL_TIM_PWM_Start(&htim5, TIM_CHANNEL_3); // 开启PWM发波
}

// --- 停止电机 ---
void Motor_Stop(void)
{
    HAL_TIM_PWM_Stop(&htim5, TIM_CHANNEL_3);
    HAL_TIM_Base_Stop_IT(&htim5);
    g_motor_running = 0;
    
    // 停止后保持锁定 (保持 EN 为低电平)
    // 如果你想停止后电机省电且无力,改为 GPIO_PIN_SET
    HAL_GPIO_WritePin(MOTOR_EN_PORT, MOTOR_EN_PIN, GPIO_PIN_RESET); 
}

// --- 中断回调函数 ---
// 放在这里,HAL库会自动调用
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
    if (htim->Instance == TIM5) // 确认是 TIM5
    {
        if (g_motor_running)
        {
            if (g_steps_remaining > 0)
            {
                g_steps_remaining--; // 步数减 1
            }
            else
            {
                Motor_Stop(); // 步数走完,停止
            }
        }
    }
}

【CPU 软件执行流】                             【定时器 硬件时间轴】
             |                                         |
             |                                         |
   1. 进入 Motor_Move 函数                             | (定时器处于停止状态)
             |                                        |
             | (配置方向、使能...)                     |
             |                                        |
             |                                        |
   2. 调用 HAL_TIM_Base_Start_IT -------------------> | 动作:开启“报警开关”(中断使能)
             |                                        | (注意:此时 CNT 还是 0,没开始跑)
             |                                        |
             |                                        |
   3. 调用 HAL_TIM_PWM_Start -----------------------> | 动作:开启计数器
             |                                        | 状态:CNT 开始狂奔 (0 -> 1 -> 2...)
             |                                        |
   4. Motor_Move 函数结束,return                      |
             |                                        |
   5. CPU 回到 main 函数                               |
      (执行 while 循环或处理串口)                      |
             |                                        |
             |                                        | 状态:CNT 继续增加 (... -> 500 -> ...)
             |                                        |
             |                                        |
             |                                        | 状态:CNT 继续增加 (... -> 998 -> 999)
             |                                        |
             |                                        | 动作:CNT 溢出 (到达 ARR)| <----------【中断触发!】-------------- | 信号:硬件产生 Update Event
             |                                        | 状态:CNT 瞬间归零,开始下一圈 (0 -> 1...)
             |                                        |
   6. CPU 暂停手头工作,瞬移进中断                      |
             |                                        |
   7. 执行 HAL_TIM_PeriodElapsedCallback              |
      (g_steps_remaining 减 1)                        |
             |                                        |
   8. 中断结束,CPU 跳回 main                          |
             |                                        |
             v                                        v
         (继续往下走)                             (继续跑下一圈)

3.3 主程序调用 (main.c)

/* USER CODE BEGIN Header */
/**
  ******************************************************************************
  * @file           : main.c
  * @brief          : Main program body
  ******************************************************************************
  * @attention
  *
  * Copyright (c) 2026 STMicroelectronics.
  * All rights reserved.
  *
  * This software is licensed under terms that can be found in the LICENSE file
  * in the root directory of this software component.
  * If no LICENSE file comes with this software, it is provided AS-IS.
  *
  ******************************************************************************
  */
/* USER CODE END Header */
/* Includes ------------------------------------------------------------------*/
#include "main.h"
#include "tim.h"
#include "gpio.h"

/* Private includes ----------------------------------------------------------*/
/* USER CODE BEGIN Includes */
#include "motor_driver.h"
/* USER CODE END Includes */

/* Private typedef -----------------------------------------------------------*/
/* USER CODE BEGIN PTD */

/* USER CODE END PTD */

/* Private define ------------------------------------------------------------*/
/* USER CODE BEGIN PD */

/* USER CODE END PD */

/* Private macro -------------------------------------------------------------*/
/* USER CODE BEGIN PM */

/* USER CODE END PM */

/* Private variables ---------------------------------------------------------*/

/* USER CODE BEGIN PV */

/* USER CODE END PV */

/* Private function prototypes -----------------------------------------------*/
void SystemClock_Config(void);
/* USER CODE BEGIN PFP */

/* USER CODE END PFP */

/* Private user code ---------------------------------------------------------*/
/* USER CODE BEGIN 0 */

/* USER CODE END 0 */

/**
  * @brief  The application entry point.
  * @retval int
  */
int main(void)
{
  /* USER CODE BEGIN 1 */

  /* USER CODE END 1 */

  /* MCU Configuration--------------------------------------------------------*/

  /* Reset of all peripherals, Initializes the Flash interface and the Systick. */
  HAL_Init();

  /* USER CODE BEGIN Init */

  /* USER CODE END Init */

  /* Configure the system clock */
  SystemClock_Config();

  /* USER CODE BEGIN SysInit */

  /* USER CODE END SysInit */

  /* Initialize all configured peripherals */
  MX_GPIO_Init();
  MX_TIM5_Init();
  /* USER CODE BEGIN 2 */
  
  // 1. 初始化电机 (拉高 EN,停止 PWM)
  Motor_Init();
  
  /* USER CODE END 2 */

  /* Infinite loop */
  /* USER CODE BEGIN WHILE */
  while (1)
  {
	  // --- 动作 1: 正转 ---
      // 方向: 1, 步数: 3200 (1圈), 速度: 300 RPM
      Motor_Move(1, 3200, 300.0f);
      
      // 等待直到电机停止
      while(g_motor_running)
      {
          // 这里可以干别的事,比如检测限位开关
      }
      HAL_Delay(500); // 停 0.5 秒


      // --- 动作 2: 反转 ---
      // 方向: 0, 步数: 6400 (2圈), 速度: 600 RPM
      Motor_Move(0, 6400, 600.0f);
      
      while(g_motor_running);
      HAL_Delay(500);
    /* USER CODE END WHILE */

    /* USER CODE BEGIN 3 */
  }
  /* USER CODE END 3 */
}

/**
  * @brief System Clock Configuration
  * @retval None
  */
void SystemClock_Config(void)
{
  RCC_OscInitTypeDef RCC_OscInitStruct = {0};
  RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};

  /** Configure the main internal regulator output voltage
  */
  __HAL_RCC_PWR_CLK_ENABLE();
  __HAL_PWR_VOLTAGESCALING_CONFIG(PWR_REGULATOR_VOLTAGE_SCALE1);

  /** Initializes the RCC Oscillators according to the specified parameters
  * in the RCC_OscInitTypeDef structure.
  */
  RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSI;
  RCC_OscInitStruct.HSIState = RCC_HSI_ON;
  RCC_OscInitStruct.HSICalibrationValue = RCC_HSICALIBRATION_DEFAULT;
  RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
  RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSI;
  RCC_OscInitStruct.PLL.PLLM = 8;
  RCC_OscInitStruct.PLL.PLLN = 168;
  RCC_OscInitStruct.PLL.PLLP = RCC_PLLP_DIV2;
  RCC_OscInitStruct.PLL.PLLQ = 4;
  if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK)
  {
    Error_Handler();
  }

  /** Initializes the CPU, AHB and APB buses clocks
  */
  RCC_ClkInitStruct.ClockType = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK
                              |RCC_CLOCKTYPE_PCLK1|RCC_CLOCKTYPE_PCLK2;
  RCC_ClkInitStruct.SYSCLKSource = RCC_SYSCLKSOURCE_PLLCLK;
  RCC_ClkInitStruct.AHBCLKDivider = RCC_SYSCLK_DIV1;
  RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV4;
  RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV2;

  if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_5) != HAL_OK)
  {
    Error_Handler();
  }
}

/* USER CODE BEGIN 4 */

/* USER CODE END 4 */

/**
  * @brief  This function is executed in case of error occurrence.
  * @retval None
  */
void Error_Handler(void)
{
  /* USER CODE BEGIN Error_Handler_Debug */
  /* User can add his own implementation to report the HAL error return state */
  __disable_irq();
  while (1)
  {
  }
  /* USER CODE END Error_Handler_Debug */
}

#ifdef  USE_FULL_ASSERT
/**
  * @brief  Reports the name of the source file and the source line number
  *         where the assert_param error has occurred.
  * @param  file: pointer to the source file name
  * @param  line: assert_param error line source number
  * @retval None
  */
void assert_failed(uint8_t *file, uint32_t line)
{
  /* USER CODE BEGIN 6 */
  /* User can add his own implementation to report the file name and line number,
     ex: printf("Wrong parameters value: file %s on line %d\r\n", file, line) */
  /* USER CODE END 6 */
}
#endif /* USE_FULL_ASSERT */



4. 代码思路和核心讲解

这是一个非常好的问题!理解代码背后的逻辑比单纯复制粘贴重要得多。我们正在做的是**“裸机驱动开发”(Bare-metal Driver Development),核心思想是用软件逻辑去驾驭硬件资源**。

本节将从整体架构设计、关键变量设计、函数功能拆解、以及“为什么这么写”(避坑指南)这四个维度为你详细讲解。

4.1 整体架构设计思路

我们面对的任务是:让单片机精准地控制电机走指定的步数,并且速度可调。

为了实现这个目标,我选择了 “PWM + 中断计数” 的模式:

  • PWM (脉冲宽度调制): 负责“发波”。通过调整 PWM 的频率,就能控制电机的速度。这部分由硬件定时器(TIM5)全自动完成,不占 CPU。
  • 中断 (Interrupt): 负责“数数”。每发一个波,硬件就会通知一次 CPU(产生中断),我们在中断里做一个减法,减到 0 就关掉 PWM。

为什么不直接用 HAL_Delay 翻转 GPIO?

  • 低效delay 会让 CPU 死等,做不了别的事(比如处理串口、读传感器)。
  • 不准:CPU 被其他中断打断时,脉冲宽度会变,电机声音会很难听,甚至丢步。

4.2 关键变量设计 (全局变量)

代码里定义了两个带有 volatile 关键字的全局变量,这是驱动的灵魂所在。

volatile uint32_t g_steps_remaining = 0; // 剩余脉冲数
volatile uint8_t  g_motor_running = 0;   // 运行标志位
1. 为什么要加 volatile

这是一个嵌入式面试必考题,也是新手最容易出 Bug 的地方。

  • 场景g_steps_remaining 这个变量,主程序(Motor_Move)在它,中断程序(HAL_TIM_PeriodElapsedCallback)在读和改它。
  • 问题:编译器(Keil/GCC)很聪明,它看主程序里写了 steps = 1000,然后后面只是在空转等待,它可能觉得“哎?没人动这个变量啊”,就直接把它优化成常数了,根本不去检查内存里它是不是变了。
  • 解决volatile 告诉编译器:“老实点!这个变量随时可能被(中断)偷偷修改,每次用它都必须去内存里重新读取,不要擅自优化!
2. g_motor_running 的作用是什么?

它起到了 “互斥锁”“状态指示灯” 的作用。

  • 互斥:当电机正在转的时候(g_motor_running == 1),如果主程序又调用了一次 Motor_Move,函数开头直接 return,防止新的指令打断正在进行的动作,避免逻辑混乱。
  • 指示:主程序可以通过 while(g_motor_running); 来阻塞等待,直到电机停下再执行下一步。

4.3 函数功能深度拆解

1. Motor_Init (初始化)
  • 思路:上电后的第一件事是确立初始状态。
  • 关键点
  • HAL_TIM_PWM_Stop: 先把发波关掉,防止一上电电机就乱转。
  • HAL_GPIO_WritePin(..., GPIO_PIN_SET): 把 EN 拉高。大多数驱动器(如 TB6600)EN 悬空或高电平时是脱机状态(电机没劲,手能转动)。这样可以防止电机在没工作时发热,也防止上电瞬间电机“咯噔”一下抖动。
2. Motor_SetSpeed (计算 ARR)
static void Motor_SetSpeed(float rpm) { ... }
  • static 的含义:这个函数只在 .c 文件内部使用,不给外部(main.c)调用,这叫封装
  • 数学逻辑
  1. 你给了我 RPM(每分钟转多少圈)。
  2. 我知道电机一圈要 3200 步
  3. 所以我算出每秒要多少个脉冲(频率 freq_hz)。
  4. 我知道定时器 1 秒钟数 1,000,000 次(因为我们在 CubeMX 里设了 PSC 让它变成 1MHz)。
  • 核心公式ARR = (1MHz / 频率) - 1

  • 比如你要 1kHz 的频率,即 1ms 一个波。定时器数 1000 次就是 1ms。所以 ARR = 999。

  • 防呆设计if (arr < 100) arr = 100;。如果用户设了个 100000 RPM 的天价速度,ARR 会变得极小(比如 5),定时器中断会疯狂触发(每几微秒一次),直接把 CPU 累死,主程序就跑不动了。

3. Motor_Move (核心发令官)

这是你调用的主要函数,它的执行顺序非常讲究:

  1. 设置方向 & 使能:先把路指好(DIR),再给油门(EN 拉低上电)。
  2. 设置速度:算好 ARR。
  3. 【最关键的一步】清除计数器
__HAL_TIM_SET_COUNTER(&htim5, 0);
__HAL_TIM_CLEAR_IT(&htim5, TIM_IT_UPDATE);
  • 为什么要清零? 假设上次电机跑完,定时器可能停在了 CNT = 500 的位置。你这次启动,ARR 设为 800。如果不清零,它从 500 数到 800 只需要一点点时间,导致第一个脉冲特别短,电机可能会抖一下。
  • 为什么要清标志位? 开启中断的瞬间,如果寄存器里残留了上次的中断标志,CPU 会立刻进一次中断,导致步数多算了一步(比如让你走 100 步,实际只走了 99 步脉冲就停了,因为第 1 次是误触发)。
  1. 启动:先开中断(开始计数),再开 PWM(开始发波)。
4. HAL_TIM_PeriodElapsedCallback (幕后记账员)

这是中断回调函数,它是被动触发的。

  • 逻辑
    每当 PWM 发出一个波(定时器溢出),CPU 就会暂停手头的工作,跳到这里。
    我们看看 g_steps_remaining 还有没有?
  • :减 1,然后立马退出中断,回去干活。
  • 没有(减到0了):说明步数走够了。调用 Motor_Stop 关掉 PWM 和中断。

4.4 为什么要这样写?(总结)

  1. 解耦 (Decoupling)
    我们将电机驱动逻辑(motor_driver)和业务逻辑(main)分开了。motor_driver.c 只负责“怎么转”,main.c 只负责“什么时候转、转多少”。如果不分开,代码全堆在 main 里会乱成一锅粥。
  2. 效率 (Efficiency)
    利用 STM32 的硬件资源(TIM5)去生成脉冲,CPU 只需要在脉冲结束的瞬间花几十个时钟周期去减个数。即使电机转速很快,CPU 依然有 99% 的空闲时间去处理你的串口通信、传感器数据等。
  3. 可移植性 (Portability)
    如果你下次换了 F103 或者 H7 芯片,或者换了引脚,你只需要修改 motor_driver.h 里的宏定义,逻辑代码几乎一行都不用动。

一句话总结:
这段代码是用最少的 CPU 资源,通过硬件定时器实现精准脉冲控制的标准工业级写法。理解了它,你就理解了单片机驱动开发的精髓。


5. 补充知识点:深入理解 volatile 关键字

motor_driver.c 中,你可能注意到了这行代码:

volatile uint32_t g_steps_remaining = 0;

很多初学者会忽略这个 volatile(易变的),觉得“不加好像也能编译通过”。但实际上,如果不加它,你的程序可能会陷入死循环。

为了彻底理解它,我们需要从 生活类比底层原理 两个维度来看。

5.1 生活类比:“黑板”与“笔记本”

想象一下,单片机的主程序(CPU)正在考试,它的任务是盯着教室前面的 黑板(内存/SRAM)

  • 规则:如果黑板上写着“跑”,你就一直跑;如果老师(中断)把字改成“停”,你就停下来。

情况一:不加 volatile(编译器自作聪明)
编译器(你的大脑)非常聪明但也非常懒。它发现:“一直在跑步,黑板离我太远了,抬头看好累。”
于是,它把黑板上的“跑”字抄到了手边的 笔记本(CPU寄存器/缓存) 上。接下来的时间里,它只看笔记本,不再抬头看黑板

  • 突发:老师(中断)突然走上讲台,把黑板上的字改成了“停”。
  • 后果:因为你一直死盯着笔记本(上面还是写的“跑”),你根本不知道黑板变了。你会一直跑下去,程序卡死。

情况二:加上 volatile(强制查岗)
volatile 的意思就是告诉编译器:

“注意!黑板上的字随时可能被那个‘疯子老师’(中断)改掉!你千万别偷懒抄在笔记本上!每次要确认状态,必须亲自抬头去看黑板!

这样,无论中断什么时候修改了内存,主程序下一次循环时,一定会从内存读取最新值,程序就能正常退出了。

5.2 硬核原理:寄存器是“复印件”不是“映射”

从计算机体系结构的角度来看,CPU 计算时,数据必须从 内存(RAM) 搬运到 寄存器(Register) 才能处理。

这里有两个核心机制导致了“不加 volatile 就会出错”:

1. 寄存器只是“复印件”

当你执行判断 while(flag == 1) 时,如果没有 volatile,编译器会进行优化:

  1. 加载:先把 RAM 里的 flag (值=1) 读到寄存器 R0 中。
  2. 死循环:以后只检查 R0 的值。
  3. 脱节:此时,RAM 里的 flag 和寄存器 R0 在物理上是断开的。就算中断把 RAM 里的 flag 改成了 0,R0 依然是 1。
2. 中断的“现场保护”机制

你可能会问:“中断发生时,为什么不顺便把寄存器也改了?” 答案是:绝对不行,CPU 必须保护现场。

  • 中断前:主程序正在用寄存器 R0(值为1)。
  • 中断发生:CPU 做的第一件事是 压栈(Push)。它把 R0 的当前值(1)保存到堆栈里“存档”。
  • 执行中断:中断代码修改了 内存地址 里的 flag 为 0。注意,它改的是内存,不是刚才存档的 R0
  • 中断结束:CPU 执行 出栈(Pop)。它把刚才存档的旧值(1)强制恢复R0 寄存器。

结果:主程序醒来后,看了一眼 R0,发现还是 1。它根本不知道内存已经被改了。

5.3 结论

在单片机开发中,只要满足以下条件,变量必须加 volatile

  1. 变量在中断(ISR)中被修改,在主程序中被读取。
  2. 变量指向硬件寄存器(如状态寄存器),数值由硬件自动改变。
  3. 多线程(RTOS)共享的全局变量。

所以在本驱动中,g_steps_remainingg_motor_running 都必须加上 volatile,这是保证代码逻辑正确的基石。

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐