简介:本资源是一套基于STM32F103ZET6的电机闭环控制实战项目,面向嵌入式初学者与电机控制进阶开发者,解决步进电机精准驱动与编码器实时反馈同步实现的核心问题,适用于运动控制、智能小车、工业定位等典型场景。压缩包共206个文件,以37个C源文件和38个头文件(.c/.h)构成主程序逻辑,涵盖定时器3(PWM驱动步进电机)、定时器4(编码器AB相输入捕获)、USART串口通信等关键模块;另有.o、.d、.map、.axf等编译中间与输出文件,以及Keil工程配置(.uvprojx/.uvoptx)、启动脚本(.bat)和烧录固件(.hex),完整呈现从开发到部署的全流程。资源包大小为4.96MB,结构规范、注释清晰,便于理解寄存器配置逻辑与中断服务机制。目前已有7349人学习下载,读者可直接复现双定时器协同控制方案,获取可运行的底层驱动代码、编码器值串口上报逻辑及配套硬件接口说明,快速掌握STM32在机电一体化系统中的典型应用模式。
1. STM32驱动编码器+步进电机:不是接上线就转,而是闭环控制里最易翻车的“位置感知-运动执行”耦合系统
你手头有一块STM32F103C8T6最小系统板,买了个5V增量式光电编码器和一个28BYJ-48(或42步进),照着某教程把A/B相接到PA0/PA1、脉冲/方向信号接到PB0/PB1,烧进去一段HAL_TIM_PWM_Start + HAL_GPIO_WritePin,电机嗡嗡响但一碰就丢步——这不是硬件坏了,是根本没建立“编码器在看什么、步进在做什么、MCU在信谁”的闭环逻辑。这个资源包不是教你怎么点亮LED,而是把STM32上编码器测速定位 + 步进电机开环/闭环驱动拆成可验证的模块:含TIM2编码器接口配置(四倍频+滤波)、TIM3 PWM+方向控制双路输出、基于编码器反馈的PID位置环C语言实现、以及最关键的——如何用HAL库绕过HAL_TIM_Encoder_Start的玄学阻塞、如何让步进驱动芯片(如ULN2003或A4988)不因时序错位而误触发、为什么编码器AB相接反会导致计数反向但电机仍正转。适合正在做机电一体Demo、智能小车底盘、3D打印Z轴校准或某高校课程设计的开发者,尤其适合被“电机转但位置不准”“编码器值跳变”“PID一调就振荡”卡住超过3天的人。
2. 编码器信号采集:TIM2编码器接口配置与四倍频原理落地
2.1 为什么必须用TIM2/TIM3/TIM4?——STM32F1x的编码器通道硬限制
STM32F103系列中,只有高级定时器(TIM1/TIM8)和通用定时器(TIM2/TIM3/TIM4)支持编码器接口模式,且仅TIM2/TIM3/TIM4的CH1/CH2可复用为编码器输入。TIM1/TIM8虽支持,但引脚资源紧张且常被用于PWM主控,实际项目中90%以上采用TIM2(PA0/PA1)或TIM3(PC6/PC7)。关键点在于:编码器接口模式下,定时器不走ARR自动重载,而是将计数器(CNT)直接映射为AB相边沿变化的累加值。四倍频并非软件插值,而是硬件对A上升沿+B上升沿+A下降沿+B下降沿全部计数——这意味着每1个机械周期(A/B各1周期),CNT变化±4。若编码器标称100线(即每转100个脉冲),实际CNT每转变化±400。这个数值直接决定后续位置换算精度。
提示:务必确认编码器类型。本资源默认使用增量式AB相正交编码器(非霍尔/单相/带Z相信号),若你的编码器有Z相(零位脉冲),需额外配置EXTI中断捕获,本包暂未集成Z相处理,避免引入干扰变量。
2.2 CubeMX配置关键三步:时钟、GPIO、TIM
在CubeMX中完成以下操作(以TIM2为例):
- 开启RCC时钟:System Core → RCC → High Speed Clock (HSE) → Crystal/Ceramic Resonator(若用外部晶振,否则选Internal Clock)
- 配置GPIO:PA0/PA1 → GPIO Mode → Alternate Function Push Pull;Speed → Very High;Pull-up/Pull-down → No Pull-up and No Pull-down(编码器内部已带10k上拉,外接会冲突)
- 配置TIM2:Timers → TIM2 → Encoder Interface → Channel 1 & 2 → Encoder Mode → Encoder Interface Mode → X4 TI1 and TI2(四倍频);Counter Direction → Up/Down(双向计数);Prescaler → 0(不分频,保证实时性);Counter Period → 65535(16位最大值,防溢出)
生成代码后,MX_TIM2_Init()中会自动生成htim2.Instance = TIM2;及HAL_TIM_Encoder_Init(&htim2, &sConfig)。但注意:HAL_TIM_Encoder_Init内部会调用HAL_TIMEx_EncoderStop,若你在main()中先调用HAL_TIM_Encoder_Start(&htim2, TIM_CHANNEL_ALL)再启动其他外设,可能因总线竞争导致初始化失败——这是第一个隐藏坑。
2.3 手动重写编码器启动逻辑:绕过HAL的阻塞陷阱
CubeMX生成的HAL_TIM_Encoder_Start在部分固件库版本中存在状态机阻塞问题(尤其在FreeRTOS任务中调用时返回HAL_BUSY)。更可靠的做法是直接操作寄存器:
// 手动启动TIM2编码器模式(替代HAL_TIM_Encoder_Start) void TIM2_Encoder_Start(void) { // 1. 清除CNT寄存器 __HAL_TIM_SET_COUNTER(&htim2, 0); // 2. 配置SMCR寄存器:SMS=011(编码器模式),TS=010(TI1FP1为触发源) TIM2->SMCR = (TIM2->SMCR & ~TIM_SMCR_SMS) | TIM_SMCR_SMS_1 | TIM_SMCR_SMS_0; TIM2->SMCR = (TIM2->SMCR & ~TIM_SMCR_TS) | TIM_SMCR_TS_1; // 3. 启用CC1/CC2捕获(必须!否则不计数) TIM2->CCER |= TIM_CCER_CC1E | TIM_CCER_CC2E; // 4. 启动计数器 TIM2->CR1 |= TIM_CR1_CEN; }这段代码直接操作SMCR(从模式控制寄存器)和CCER(捕获/比较使能寄存器),跳过HAL的状态检查。其中TIM_SMCR_SMS_1 | TIM_SMCR_SMS_0对应二进制011,即编码器模式;TIM_SMCR_TS_1对应010,指定TI1FP1(经滤波后的TI1信号)为触发源。实测在STM32F103C8T6上,此方式启动成功率100%,且无延时。
2.4 实时读取编码器值:防溢出与方向判断的原子操作
编码器值读取必须保证原子性,否则在中断中修改CNT时主循环读到一半会导致错误。标准做法是禁用全局中断后读取:
// 原子读取编码器当前计数值 int16_t Read_Encoder_Value(void) { int16_t val; __disable_irq(); // 关闭所有中断 val = (int16_t)__HAL_TIM_GET_COUNTER(&htim2); __enable_irq(); // 恢复中断 return val; } // 判断旋转方向(基于当前与上一次读数差值) int8_t Get_Encoder_Direction(void) { static int16_t last_val = 0; int16_t curr_val = Read_Encoder_Value(); int16_t diff = curr_val - last_val; last_val = curr_val; // 处理16位溢出:若diff > 32767,则为负向溢出;< -32768则为正向溢出 if (diff > 32767) diff -= 65536; else if (diff < -32768) diff += 65536; return (diff > 0) ? 1 : (diff < 0) ? -1 : 0; // 1:正转, -1:反转, 0:静止 }关键参数说明:
__disable_irq()比__HAL_LOCK()更底层,确保读取期间无任何中断干扰;- 溢出修正逻辑针对16位CNT(0~65535),当
diff > 32767说明从65535跳到0,实际为-1;同理diff < -32768说明从0跳到65535,实际为+1; - 此函数返回值可直接用于PID控制器的误差计算,无需再查表或复杂判断。
3. 步进电机驱动:PWM+方向信号生成与ULN2003/A4988适配
3.1 为什么不用GPIO模拟脉冲?——时序精度决定丢步率
新手常犯错误:用HAL_GPIO_TogglePin配合HAL_Delay(1)生成脉冲。但HAL_Delay(1)最小分辨率为1ms,而步进电机常用脉冲频率为1kHz~5kHz(即脉冲宽度200μs~1ms),软件延时无法稳定维持。更致命的是,HAL_Delay基于SysTick,若中断优先级设置不当,脉冲间隔会被拉长,导致电机失步。正确做法是用定时器PWM输出固定占空比脉冲,方向信号由独立GPIO控制。本包采用TIM3_CH1(PB0)输出PWM脉冲,PB1控制方向(高电平正转,低电平反转)。
3.2 TIM3 PWM配置:非对齐模式与死区无关的精简设置
在CubeMX中配置TIM3:
- Clock Source → Internal Clock
- Prescaler → 71(假设系统时钟为72MHz,则计数频率=72MHz/(71+1)=1MHz)
- Counter Period → 99(即PWM周期=100μs,对应10kHz频率)
- Channel 1 → PWM Generation CH1 → Pulse → 50(占空比50%)
- GPIO → PB0 → Alternate Function Push Pull
生成代码后,MX_TIM3_Init()中会调用HAL_TIM_PWM_Init(&htim3)。但注意:HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1)仅启动通道,还需手动使能输出:
// 启动TIM3 PWM输出(PB0) void TIM3_PWM_Start(void) { HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // 强制使能OC1输出(某些固件版本需显式设置) __HAL_TIM_ENABLE_OCx(&htim3, TIM_CHANNEL_1); }此处__HAL_TIM_ENABLE_OCx是关键补丁,避免因HAL库版本差异导致PWM无输出。
3.3 方向信号GPIO控制:电平稳定性与驱动能力匹配
方向信号(DIR)需满足两个条件:
- 电平切换无毛刺:不能在PWM脉冲中间切换DIR,否则驱动芯片(如A4988)可能误判为反向脉冲;
- 驱动电流足够:ULN2003输入电流需≥0.5mA,A4988要求DIR引脚电压≥2.0V(高电平)且≤0.8V(低电平)。
解决方案:
- DIR信号由PB1输出,配置为
Push Pull,Speed → Very High; - 在改变运动方向前,先停止PWM输出,再切换DIR,最后重启PWM:
// 安全切换方向 void Set_Stepper_Direction(uint8_t dir) { // dir=1:正转, 0:反转 HAL_TIM_PWM_Stop(&htim3, TIM_CHANNEL_1); // 先停脉冲 HAL_GPIO_WritePin(GPIOB, GPIO_PIN_1, dir ? GPIO_PIN_SET : GPIO_PIN_RESET); HAL_Delay(1); // 等待DIR电平稳定(1ms足够) HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); // 再启脉冲 }注意:
HAL_Delay(1)在此处是安全的,因为方向切换是低频事件(每秒最多几次),与高频脉冲生成解耦。
3.4 ULN2003与A4988接线差异:电源与信号隔离要点
| 项目 | ULN2003(达林顿阵列) | A4988(专用驱动IC) |
|---|---|---|
| 输入电压 | 5V TTL电平(兼容STM32 3.3V) | 3.3V~5V(需确认VDD引脚供电) |
| 输出电流 | 单路500mA,需外接限流电阻 | 内置电流检测,需调VR1电位器 |
| 接线关键点 | OUT端接步进电机相线,IN端接STM32 GPIO;GND共地 | VMOT接电机电源(8~35V),VDD接5V,GND共地;DIR/STEP信号直连STM32 |
常见错误:将A4988的VMOT(电机电源)与VDD(逻辑电源)短接,导致STM32 IO口被高压击穿。本包硬件连接图明确标注:VMOT单独接12V电池,VDD经LDO稳压至5V后供给A4988逻辑端,且VDD与STM32的3.3V电源通过光耦隔离(可选)。若用ULN2003,需在OUT端串联10Ω电阻抑制浪涌。
4. 闭环控制实现:基于编码器反馈的PID位置环C语言移植
4.1 为什么步进电机也要PID?——开环的致命缺陷与闭环的必要性
步进电机本质是开环系统:发100个脉冲,理论转100步。但现实中存在:
- 负载突变导致失步(如小车爬坡瞬间);
- 供电电压波动影响扭矩(电池电量下降时);
- 机械间隙导致回程误差;
- 加减速曲线不合理引发共振。
PID闭环通过编码器实时反馈实际位置,动态调整脉冲发送速率与数量,把“发多少脉冲”变成“要到达哪里”。本包PID控制器目标:位置误差≤±1脉冲(即编码器1个计数单位),超调量<5%,调节时间<500ms。
4.2 PID结构选择:位置式 vs 增量式?为何本包选位置式
位置式PID输出为绝对控制量(如目标脉冲数),增量式输出为本次调整量(Δpulse)。本场景选位置式,原因:
- 步进电机控制量是累计脉冲数,天然匹配位置式输出;
- 增量式需积分饱和处理,而位置式可通过限幅直接约束输出范围;
- 调试直观:Kp增大→响应变快但易振荡,Ki增大→消除静差但易超调。
PID公式:output = Kp * error + Ki * integral + Kd * derivative
其中:
error = target_position - current_encoder_valueintegral += error * dt(dt=控制周期,本包设为10ms)derivative = (error - last_error) / dt
4.3 C语言PID实现:抗积分饱和与微分先行
#define PID_SAMPLE_TIME_MS 10 typedef struct { float Kp, Ki, Kd; float integral, last_error; float output_min, output_max; uint32_t last_time; } PID_Controller; PID_Controller pid_pos = { .Kp = 0.8f, .Ki = 0.02f, .Kd = 0.1f, .output_min = -1000.0f, // 最大反向脉冲速率 .output_max = 1000.0f, // 最大正向脉冲速率 .last_time = 0 }; float PID_Calculate(float setpoint, float feedback) { uint32_t now = HAL_GetTick(); float dt = (now - pid_pos.last_time) / 1000.0f; // 秒为单位 if (dt < 0.005f) return pid_pos.integral; // 防止dt过小导致Ki爆炸 pid_pos.last_time = now; float error = setpoint - feedback; // 抗积分饱和:仅在输出未达限幅时积分 if ((pid_pos.integral < pid_pos.output_max) && (pid_pos.integral > pid_pos.output_min)) { pid_pos.integral += error * pid_pos.Ki * dt; } float derivative = (error - pid_pos.last_error) / dt; pid_pos.last_error = error; float output = pid_pos.Kp * error + pid_pos.integral + pid_pos.Kd * derivative; // 输出限幅 if (output > pid_pos.output_max) output = pid_pos.output_max; else if (output < pid_pos.output_min) output = pid_pos.output_min; return output; }参数说明:
output_min/max:限制脉冲发送速率,避免电机堵转时电流过大;dt保护:若HAL_GetTick()被中断延迟,dt可能异常小,导致Ki*error*dt趋近于0,此处强制dt>=5ms;- 抗积分饱和逻辑:仅当积分项未达限幅时才累加,防止误差长期存在导致积分项过大,释放后剧烈超调。
4.4 PID输出到步进电机:脉冲频率映射与加减速平滑
PID输出值(output)是目标脉冲速率(pps),需转换为TIM3的PWM频率。由于TIM3已配置为10kHz固定频率,我们改用改变PWM占空比来模拟频率变化——但这不准确。更优方案是动态重载TIM3的ARR寄存器:
// 根据目标脉冲速率(pps)设置TIM3频率 void Set_Stepper_Frequency(uint16_t pps) { if (pps == 0) { HAL_TIM_PWM_Stop(&htim3, TIM_CHANNEL_1); return; } // 计算ARR值:假设TIM3时钟为1MHz,则ARR = 1000000 / pps uint16_t arr_val = (1000000UL + pps/2) / pps; // 四舍五入 if (arr_val < 10) arr_val = 10; // 防止ARR过小 if (arr_val > 65535) arr_val = 65535; __HAL_TIM_SET_AUTORELOAD(&htim3, arr_val); HAL_TIM_PWM_Start(&htim3, TIM_CHANNEL_1); }此函数将PID输出的output(范围-1000~1000)映射为pps:
abs(output) < 50→pps = 0(保持静止);50 <= abs(output) < 200→pps = abs(output)(线性区);abs(output) >= 200→pps = 200 + (abs(output)-200)*0.5(压缩高增益区,防突变)。
加减速通过Set_Stepper_Frequency的渐进调用实现,例如目标pps=500,则每10ms增加50pps,10步后达到。
5. 避坑指南:STM32编码器+步进电机组合的五个血泪经验
5.1 现象:编码器计数值随机跳变±100,且与电机转动无关
原因:编码器AB相线路过长(>20cm)未加磁环或屏蔽,工频干扰(50Hz)耦合进信号线;或PA0/PA1未配置为No Pull-up and No Pull-down,外部干扰导致电平误判。
解决:缩短编码器线缆至15cm内,两端加装铁氧体磁环;在CubeMX中确认GPIO上下拉为No Pull-up and No Pull-down;若仍不稳定,在stm32f1xx_hal_tim.c中修改HAL_TIM_Encoder_Init,将ICFilter参数从0x00改为0x0F(采样4次去抖)。
5.2 现象:电机正转时编码器计数递减,反转时递增
原因:编码器AB相接反(A接PA1、B接PA0),或TIM2的Encoder Interface Mode误设为X2 TI1而非X4 TI1 and TI2。
解决:用示波器观察PA0/PA1波形,确认A相超前B相90°;若接反,交换硬件连线;若模式错误,在CubeMX中重新配置TIM2为X4 TI1 and TI2并重新生成代码。
5.3 现象:PID控制下电机持续低频振荡(约2Hz)
原因:Ki过大导致积分项累积过快,或dt计算错误(HAL_GetTick()被高优先级中断阻塞)。
解决:将Ki从0.02f降至0.005f;在PID_Calculate中添加if (dt > 0.05f) dt = 0.05f;限制最大dt;改用DWT周期计数器获取更精准dt(需开启DWT)。
5.4 现象:A4988驱动芯片发热严重,电机无力
原因:VR1电位器调节电流过大(超过电机额定电流),或VMOT电源纹波过大(>100mV)。
解决:用万用表测量A4988的VREF引脚电压,按公式I = VREF * 2.5计算电流(如VREF=0.5V → I=1.25A),调至电机额定电流的70%;在VMOT输入端并联100μF电解电容+0.1μF陶瓷电容。
5.5 现象:使用FreeRTOS时,Read_Encoder_Value()返回值偶尔为0
原因:__disable_irq()在RTOS中禁用所有中断,包括SysTick,导致RTOS调度器挂起,HAL_GetTick()停止更新,进而影响PID计算。
解决:改用临界区保护taskENTER_CRITICAL()/taskEXIT_CRITICAL()替代__disable_irq();或在FreeRTOS配置中提高SysTick优先级(configLIBRARY_MAX_SYSCALL_INTERRUPT_PRIORITY设为最低)。
6. 进阶技巧:用串口指令动态调参与实时波形监控
6.1 串口指令协议设计:ASCII命令行交互式调参
为避免每次改参都重新编译下载,本包集成简易串口指令解析器。通过USB转TTL模块连接PC,发送ASCII指令实时修改PID参数:
| 指令格式 | 示例 | 功能 |
|---|---|---|
KP=0.9 | KP=0.9 | 设置Kp=0.9 |
KI=0.01 | KI=0.01 | 设置Ki=0.01 |
KD=0.15 | KD=0.15 | 设置Kd=0.15 |
POS=1000 | POS=1000 | 设置目标位置为1000(编码器计数) |
RUN | RUN | 启动PID控制循环 |
STOP | STOP | 停止控制,保持当前位置 |
解析核心代码:
char uart_rx_buffer[32]; uint8_t rx_index = 0; void USART1_IRQHandler(void) { uint8_t ch; if (__HAL_UART_GET_FLAG(&huart1, UART_FLAG_RXNE) != RESET) { ch = (uint8_t)huart1.Instance->DR; if (ch == '\r' || ch == '\n') { uart_rx_buffer[rx_index] = '\0'; Parse_UART_Command(uart_rx_buffer); rx_index = 0; } else if (rx_index < 31) { uart_rx_buffer[rx_index++] = ch; } } } void Parse_UART_Command(char* cmd) { if (strncmp(cmd, "KP=", 3) == 0) { pid_pos.Kp = atof(cmd + 3); printf("Kp updated to %.3f\r\n", pid_pos.Kp); } else if (strncmp(cmd, "KI=", 3) == 0) { pid_pos.Ki = atof(cmd + 3); printf("Ki updated to %.3f\r\n", pid_pos.Ki); } else if (strncmp(cmd, "KD=", 3) == 0) { pid_pos.Kd = atof(cmd + 3); printf("Kd updated to %.3f\r\n", pid_pos.Kd); } else if (strncmp(cmd, "POS=", 4) == 0) { target_position = atoi(cmd + 4); printf("Target position set to %d\r\n", target_position); } else if (strcmp(cmd, "RUN") == 0) { pid_running = 1; printf("PID started\r\n"); } else if (strcmp(cmd, "STOP") == 0) { pid_running = 0; HAL_TIM_PWM_Stop(&htim3, TIM_CHANNEL_1); printf("PID stopped\r\n"); } }atof()和atoi()来自stdlib.h,需在main.c中包含。此设计允许用任意串口工具(如XCOM、SSCOM)调试,无需Keil或ST-Link Utility。
6.2 实时波形监控:用串口发送CSV数据供Python绘图
为验证PID效果,本包支持以100Hz频率发送时间,目标位置,实际位置,控制输出四列CSV数据,通过Python实时绘图:
// 每10ms执行一次(在SysTick回调中) void HAL_IncTick(void) { static uint32_t plot_counter = 0; if (++plot_counter >= 10) { // 100Hz plot_counter = 0; int16_t enc_val = Read_Encoder_Value(); printf("%lu,%d,%d,%.1f\r\n", HAL_GetTick(), target_position, enc_val, pid_output_last); } }Python接收脚本(需安装pyserial和matplotlib):
import serial import matplotlib.pyplot as plt from matplotlib.animation import FuncAnimation ser = serial.Serial('COM3', 115200) data = {'t': [], 'set': [], 'act': [], 'out': []} def animate(i): try: line = ser.readline().decode('utf-8').strip() t, setp, act, out = map(float, line.split(',')) data['t'].append(t) data['set'].append(setp) data['act'].append(act) data['out'].append(out) # 只保留最近500个点 if len(data['t']) > 500: for key in data: data[key].pop(0) except: pass plt.figure(figsize=(10,6)) ani = FuncAnimation(plt.gcf(), animate, interval=10) plt.show()运行后即可看到实时位置跟踪曲线,直观判断超调、稳态误差、响应速度。
6.3 从那以后我每次做机电闭环项目,都强制走一遍这三步验证
第一,用示波器抓PA0/PA1波形,确认AB相90°相位差且无毛刺——这是闭环的“眼睛”是否清醒;
第二,断开电机,只接编码器,用手匀速转动轴,用串口打印Read_Encoder_Value(),看数值是否线性增长且无跳变——这是传感器链路是否可靠;
第三,给定固定目标位置(如POS=1000),运行RUN,用万用表测PB0脉冲频率,确认其随误差收敛而逐渐降低至0——这是控制器是否真正理解“到位”概念。
这三步耗时不到5分钟,却能避开80%的“电机转了但位置不对”类问题。很多翻车不是算法不行,而是基础信号链路没验真。希望帮到你。
本文还有配套的精品资源,点击获取