尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

STM32编码器与步进电机闭环控制:PID位置速度环调试

STM32编码器与步进电机闭环控制:PID位置速度环调试 简介面向嵌入式初学者与电机控制方向开发者的STM32F103ZET6实战工程围绕步进电机驱动与增量式编码器反馈展开涉及定时器PWM输出、输入捕获、串口通信等关键环节可用于运动控制入门、课程设计或项目原型验证。压缩包共206个文件约4.96MB包含38个h头文件、37个c源文件及配套o、d、crf等编译中间文件另有uvprojx工程文件、sct分散加载文件、hex烧录文件和bat批处理脚本完整保留Keil工程结构与构建产物。已有7350人学习下载说明该工程在电机控制入门场景中具有一定参考热度。读者可参考定时器3驱动步进电机、定时器4捕获编码器A/B相信号、USART回传位置与速度数据的实现框架并据此扩展电机加减速、方向控制与上位机解析逻辑工程文件齐全便于直接打开对照外设初始化、中断服务与串口收发流程也方便排查编译与下载配置问题。1. 先别急着写 PID这套 STM32 编码器步进电机项目到底解决什么问题STM32 驱动编码器加步进电机最容易翻车的不是 PID 参数而是编码器计数和电机实际位移对不上。我见过一个单轴滑台开环跑得好好的一上闭环就在目标位置附近来回摆最后查出来是编码器 A/B 相接反位置环把“正转”当成了“反转”越纠越偏。这个项目要解决的就是把“发脉冲”和“读反馈”接成一条能落地的闭环链路定时器输出 PWM 控制步进驱动器编码器接口实时采集位置和速度MCU 里跑位置环或速度环串口把目标值、实际值、误差打出来。它适合正在做单轴运动控制、回零定位、速度跟随的开发者也适合想把开环步进升级成半闭环的学生项目。下面按硬件连接、编码器采集、电机驱动、闭环调试、排查、二次开发六块拆开讲。2. 硬件连接与工程骨架先把定时器资源分干净2.1 硬件清单与引脚分配这套东西的硬件不复杂但引脚一旦分错后面调代码就是纯耗时间。我一般按“编码器占一个定时器、脉冲占另一个定时器、方向使能占普通 GPIO、调试串口单独留”的原则来分。编码器用增量式 ABZ 相A/B 接定时器的 CH1/CH2Z 相接外部中断做回零步进驱动器接 PUL、DIR、ENA 三根线PUL 由定时器 PWM 输出DIR 和 ENA 用推挽 GPIO。限位开关接外部中断串口只做调试和参数下发不参与实时控制。功能引脚示例外设/模式说明编码器 A 相PA0TIM2_CH1上拉输入滤波打开编码器 B 相PA1TIM2_CH2上拉输入滤波打开编码器 Z 相PA2EXTI2回零用上升沿触发步进脉冲 PULPA8TIM1_CH1PWM 输出50% 占空比方向 DIRPB0GPIO 推挽高/低对应正反转使能 ENAPB1GPIO 推挽按驱动器有效电平接限位开关PB5EXTI5回零和硬限位调试串口PA9/PA10USART11152008N1提示编码器电源尽量和电机电源分开走线电机线不要和编码器信号线捆在同一根线槽里否则计数偶尔跳一下调 PID 时会误以为是参数问题。这里有一个容易被忽略的点步进驱动器的 PUL 输入通常要求脉冲宽度不能太窄频率越高、占空比越要留意。我一般把 PWM 占空比固定在 50%让驱动器自己按边沿计数避免窄脉冲被驱动器滤掉。方向信号要在脉冲停止后再切换至少留几个微秒的建立时间不然高速换向时容易丢第一步。2.2 工程目录与初始化顺序工程结构不用搞得太花按外设拆文件就行。我习惯把编码器、步进电机、PID 各自独立成 .c/.hmain 里只做初始化和周期调度。这样后面加第二轴时不用把 main 改成一锅粥。Project/ ├── Core/ │ ├── Inc/ │ │ ├── encoder.h │ │ ├── stepper.h │ │ ├── pid.h │ │ └── main.h │ └── Src/ │ ├── encoder.c │ ├── stepper.c │ ├── pid.c │ └── main.c ├── Drivers/ │ ├── CMSIS/ │ └── STM32F1xx_HAL_Driver/ └── MDK-ARM/ 或 STM32CubeIDE/初始化顺序也有讲究。先 GPIO再编码器定时器再 PWM 定时器最后串口和 PID 参数。编码器定时器和 PWM 定时器不要共用同一个否则一个在计数、一个在输出寄存器配置会互相打架。// main.c 里的初始化顺序按这个走少踩坑 HAL_Init(); SystemClock_Config(); // 先确认时钟树定时器频率都靠它 MX_GPIO_Init(); // 方向、使能、限位 MX_TIM2_Init(); // 编码器接口定时器 MX_TIM1_Init(); // 步进脉冲 PWM MX_USART1_UART_Init(); // 调试串口 Encoder_Init(); // 启动编码器计数 Stepper_Init(); // 启动 PWM先不使能电机 PID_Init(pos_pid, 2.0f, 0.05f, 0.1f, 1000.0f);这段代码里SystemClock_Config()必须最先跑后面Stepper_SetSpeed()里算 ARR 要用到定时器时钟。MX_TIM2_Init()和MX_TIM1_Init()的顺序可以换但两个定时器的中断优先级要提前想好编码器一般不需要中断PWM 也不需要每周期中断真正要中断的是 Z 相回零和限位。PID_Init()的四个参数分别是 Kp、Ki、Kd 和输出限幅先给一组保守值后面再整定。2.3 关键配置参数怎么定编码器线数、驱动器细分、丝杠导程这三个参数决定了“一个脉冲走多少毫米”必须先在纸上算清楚。比如编码器 1000 线四倍频后每转 4000 个计数驱动器细分 16电机每转 200 步那每转就是 3200 个脉冲丝杠导程 5 mm那一个脉冲就是 5/3200 ≈ 0.0015625 mm。编码器计数和脉冲计数要能对得上闭环才有意义。我一般把这些参数写成宏不散落在代码里#define ENCODER_PPR 1000.0f // 编码器线数 #define ENCODER_MULTI 4.0f // 四倍频 #define MOTOR_FULL_STEP 200.0f // 两相步进电机整步数 #define DRIVER_MICROSTEP 16.0f // 驱动器细分 #define SCREW_LEAD_MM 5.0f // 丝杠导程 // 编码器每转计数 #define ENCODER_CNT_PER_REV (ENCODER_PPR * ENCODER_MULTI) // 电机每转脉冲数 #define MOTOR_PULSE_PER_REV (MOTOR_FULL_STEP * DRIVER_MICROSTEP) // 一个脉冲对应的位移(mm) #define MM_PER_PULSE (SCREW_LEAD_MM / MOTOR_PULSE_PER_REV)参数说明ENCODER_PPR按编码器铭牌填不要填成“分辨率”DRIVER_MICROSTEP要和驱动器拨码一致拨码是 8 就填 8别照抄代码里的 16SCREW_LEAD_MM是丝杠转一圈走的距离不是丝杠长度。这几个宏后面在位置换算和速度换算里都会用到改一处就能全局生效。3. 编码器采集定时器编码器模式与四倍频测速3.1 编码器接口模式配置STM32 的定时器自带编码器接口模式A/B 两相接 CH1/CH2硬件自动根据边沿加减计数不需要外部中断里手动判断方向。我一般用TIM_ENCODERMODE_TI12也就是四倍频A 相和 B 相的上升沿、下降沿都计数分辨率比单相高一倍。配置的时候注意滤波参数电机一转编码器信号线上容易串进噪声滤波太小会多计数滤波太大会漏掉高速脉冲。void Encoder_Init(void) { TIM_Encoder_InitTypeDef sConfig {0}; htim2.Instance TIM2; htim2.Init.Prescaler 0; // 编码器计数不分频 htim2.Init.CounterMode TIM_COUNTERMODE_UP; htim2.Init.Period 0xFFFF; // 16 位计数器满量程回绕 htim2.Init.ClockDivision TIM_CLOCKDIVISION_DIV1; htim2.Init.AutoReloadPreload TIM_AUTORELOAD_PRELOAD_DISABLE; sConfig.EncoderMode TIM_ENCODERMODE_TI12; // A/B 相都计数四倍频 sConfig.IC1Polarity TIM_ICPOLARITY_RISING; sConfig.IC1Selection TIM_ICSELECTION_DIRECTTI; sConfig.IC1Prescaler TIM_ICPSC_DIV1; sConfig.IC1Filter 6; // 滤波系数按转速和噪声调 sConfig.IC2Polarity TIM_ICPOLARITY_RISING; sConfig.IC2Selection TIM_ICSELECTION_DIRECTTI; sConfig.IC2Prescaler TIM_ICPSC_DIV1; sConfig.IC2Filter 6; HAL_TIM_Encoder_Init(htim2, sConfig); HAL_TIM_Encoder_Start(htim2, TIM_CHANNEL_ALL); }这段代码的关键在EncoderMode和IC1Filter。TIM_ENCODERMODE_TI12表示 A/B 两相都参与计数转一圈的计数是编码器线数的四倍如果只想要两倍频可以改成TIM_ENCODERMODE_TI1或TI2。IC1Filter的取值范围是 0 到 15值越大滤波越强低速时可以大一点高速时如果发现计数偏少就往小调。Period 0xFFFF让计数器在 0 到 65535 之间循环读取时用有符号差值处理回绕后面会讲。3.2 速度计算M/T 法与滑动平均测速有两种常见做法M 法在固定时间内数脉冲低速时分辨率差T 法测两个脉冲之间的时间高速时误差大。我一般用固定控制周期读一次计数差值相当于 M 法控制周期取 10 ms低速时虽然有点粗但配合滑动平均够用。速度换算成 RPM 的公式是delta / (每转计数) / 控制周期 * 60。#define CONTROL_DT 0.01f // 10 ms 控制周期 #define SPEED_FILTER_N 5 // 滑动平均窗口 float Encoder_GetSpeedRPM(void) { static int16_t last_cnt 0; static float buf[SPEED_FILTER_N] {0}; static uint8_t idx 0; static uint8_t cnt 0; int16_t now_cnt (int16_t)__HAL_TIM_GET_COUNTER(htim2); int16_t delta now_cnt - last_cnt; // 有符号差值自动处理 16 位回绕 last_cnt now_cnt; float rpm ((float)delta / ENCODER_CNT_PER_REV) / CONTROL_DT * 60.0f; // 滑动平均抑制单次计数抖动 buf[idx] rpm; idx (idx 1) % SPEED_FILTER_N; if (cnt SPEED_FILTER_N) cnt; float sum 0; for (uint8_t i 0; i cnt; i) sum buf[i]; return sum / cnt; }这里delta用int16_t做差是血泪经验计数器从 65535 回到 0 时直接相减会得到一个很大的正数用有符号 16 位差值就能自动变成 -1 或 1。CONTROL_DT必须和实际调用周期一致如果放在 1 ms 定时器里调用却按 10 ms 算速度会差 10 倍。滑动平均窗口SPEED_FILTER_N越大越平滑但延迟也越大位置环里我一般用 3 到 5速度环里可以到 8。3.3 位置累计与溢出处理速度是差值位置是累计。编码器计数器只有 16 位转几圈就会回绕所以必须用一个 32 位变量做总位置。每次读计数器算出和上一次的差值累加到总位置里。这个方法看起来简单但有两个坑一是初始化时last要读一次当前计数器不能默认从 0 开始二是多轴时每个轴要有自己的last和total不能共用一个静态变量。int32_t Encoder_GetPosition(void) { static int16_t last 0; static int32_t total 0; static uint8_t first 1; int16_t now (int16_t)__HAL_TIM_GET_COUNTER(htim2); if (first) { last now; first 0; return 0; } total (int16_t)(now - last); last now; return total; }total的单位是四倍频后的计数要换算成毫米用total * MM_PER_PULSE要换算成角度用total / ENCODER_CNT_PER_REV * 360。first标志是为了避免上电第一次读到随机值就累加进去。这个函数不需要在中断里跑放在 10 ms 控制任务里调用就行但要注意调用频率固定否则位置累计和速度计算会对不上。4. 步进电机驱动定时器 PWM、细分与加减速曲线4.1 驱动器接口与脉冲方向控制步进驱动器一般收三根信号PUL 脉冲、DIR 方向、ENA 使能。PUL 每来一个脉冲电机走一个细分步DIR 的高低决定正反转ENA 用来释放电机方便手动调整。方向信号要在脉冲停止后切换我一般会在换向前先关 PWM延时 1 ms再改 DIR再开 PWM。使能信号的有效电平要看驱动器有的低电平使能有的高电平使能代码里留一个宏别写死。#define STEPPER_DIR_PORT GPIOB #define STEPPER_DIR_PIN GPIO_PIN_0 #define STEPPER_ENA_PORT GPIOB #define STEPPER_ENA_PIN GPIO_PIN_1 #define ENA_ACTIVE_LEVEL 0 // 0: 低电平使能1: 高电平使能 void Stepper_SetDirection(uint8_t dir) { HAL_GPIO_WritePin(STEPPER_DIR_PORT, STEPPER_DIR_PIN, dir ? GPIO_PIN_SET : GPIO_PIN_RESET); } void Stepper_Enable(uint8_t en) { GPIO_PinState state (en ENA_ACTIVE_LEVEL) ? GPIO_PIN_SET : GPIO_PIN_RESET; HAL_GPIO_WritePin(STEPPER_ENA_PORT, STEPPER_ENA_PIN, state); }Stepper_SetDirection()只改方向不保证脉冲已经停实际换向时先调用Stepper_StopPulse()再改方向再重新使能 PWM。ENA_ACTIVE_LEVEL这个宏看着不起眼但换一个驱动器就要改一次写成宏比在代码里到处找GPIO_PIN_RESET靠谱。使能信号不要频繁开关电机静止时保持使能发热会大一点但位置不会丢。4.2 定时器 PWM 频率计算步进电机的转速取决于脉冲频率不是占空比。PWM 频率的计算公式是定时器时钟 / ((Prescaler 1) * (Period 1))。反过来已知目标脉冲频率求 ARRARR 定时器时钟 / 脉冲频率 - 1。这里要注意ARR 不能太小太小了脉冲宽度变窄驱动器可能识别不到我一般把 ARR 下限设在 100 左右对应 72 MHz 时钟下最高约 720 kHz实际步进电机很少跑到这个频率。void Stepper_SetPulseFreq(float pulse_freq) { uint32_t timer_clk 72000000; // 以 72 MHz 定时器时钟为例 if (pulse_freq 1.0f) pulse_freq 1.0f; uint32_t arr (uint32_t)(timer_clk / pulse_freq) - 1; if (arr 100) arr 100; // 限制最高频率防止窄脉冲 if (arr 65535) arr 65535; // 16 位 ARR 上限 __HAL_TIM_SET_AUTORELOAD(htim1, arr); __HAL_TIM_SET_COMPARE(htim1, TIM_CHANNEL_1, arr / 2); // 50% 占空比 }这段代码里timer_clk要按实际时钟树填不是所有 STM32 的定时器时钟都是 72 MHz。arr / 2是占空比 50%有些驱动器对占空比不敏感但窄脉冲容易被滤掉所以我习惯固定 50%。arr 100的保护是后悔药曾经有一次把频率设到 1 MHz示波器上看脉冲已经变成尖刺电机原地啸叫不走步。4.3 梯形加减速与 S 曲线取舍直接给一个高频率脉冲步进电机会丢步尤其是带负载的时候。加减速曲线里梯形最简单加速段按固定斜率升频匀速段保持目标频率减速段按固定斜率降频。S 曲线更平滑但参数多调不好反而更慢。我一般先用梯形跑通确实有冲击再换 S 曲线。typedef enum { MOTION_IDLE, MOTION_ACCEL, MOTION_CRUISE, MOTION_DECEL } MotionState; static MotionState state MOTION_IDLE; static float current_freq 0; static float target_freq 0; void Stepper_UpdateRamp(void) { const float accel_step 200.0f; // 每个控制周期增加的频率 const float decel_step 300.0f; // 减速可以比加速快一点 switch (state) { case MOTION_ACCEL: current_freq accel_step; if (current_freq target_freq) { current_freq target_freq; state MOTION_CRUISE; } break; case MOTION_CRUISE: current_freq target_freq; break; case MOTION_DECEL: current_freq - decel_step; if (current_freq 0) { current_freq 0; state MOTION_IDLE; } break; default: break; } Stepper_SetPulseFreq(current_freq); }accel_step和decel_step的单位是“每个控制周期的频率增量”控制周期 10 ms 时200 表示每秒升 20 kHz。这个值要根据负载惯量调空载可以大一点带负载要小一点。状态机里没有处理“目标频率突然变小”的情况实际项目里要加一个判断如果target_freq current_freq且当前在加速段直接切到减速段不然会冲过头。5. 闭环控制与常见问题排查5.1 编码器反馈与位置闭环开环步进最大的问题是丢步了自己不知道闭环就是拿编码器计数和发出去的脉冲数做比较。位置环的输入是目标位置反馈是Encoder_GetPosition()换算出来的实际位置输出是速度指令或者直接是脉冲频率。我一般让位置环输出目标速度速度环再输出脉冲频率这样加减速曲线可以放在速度环里。如果只是简单定位位置环直接输出脉冲频率也能跑但参数要保守。typedef struct { float kp, ki, kd; float integral; float prev_error; float out_limit; } PID_t; float PID_Calc(PID_t *pid, float target, float feedback) { float error target - feedback; pid-integral error; // 积分限幅防止长时间误差导致饱和 if (pid-integral 1000.0f) pid-integral 1000.0f; if (pid-integral -1000.0f) pid-integral -1000.0f; float derivative error - pid-prev_error; pid-prev_error error; float out pid-kp * error pid-ki * pid-integral pid-kd * derivative; if (out pid-out_limit) out pid-out_limit; if (out -pid-out_limit) out -pid-out_limit; return out; }out_limit是输出限幅位置环里可以设成最大速度对应的脉冲频率积分限幅是防止电机堵转时积分越积越大松开的瞬间猛冲。derivative用的是误差差分编码器测速有噪声时微分项会放大噪声所以我一般把 Kd 设得很小或者干脆先不用 D等 P 和 I 调稳了再加。5.2 速度环与 PID 参数整定调 PID 这件事有点玄学但顺序是固定的先调速度环再调位置环先 P再 I最后 D。速度环的反馈是Encoder_GetSpeedRPM()目标来自位置环输出或者串口指令。P 从 0 开始加加到电机能跟上目标速度但不过冲I 用来消除静差加的时候观察低速是否爬行D 最后加用来抑制超调但噪声大时容易抖。参数作用调过头的现象我常用的起步值Kp响应速度振荡、啸叫速度环 2.0位置环 1.0Ki消除静差低速爬行、积分饱和速度环 0.05位置环 0.01Kd抑制超调噪声放大、高频抖动先 0需要时 0.1 以内控制周期采样一致性周期抖动导致参数失效10 ms 固定速度环的输出限幅要设成最大脉冲频率位置环的输出限幅要设成最大速度。控制周期必须固定我一般放在一个 10 ms 的定时器中断里跑中断里只做计算和标志位串口打印放到主循环不然打印一次几十毫秒PID 周期全乱了。5.3 常见问题排查从丢步、抖动到计数反跳现象一电机丢步但编码器计数正常。原因通常是脉冲频率超过了驱动器响应能力或者加减速太猛电机扭矩跟不上。解决方法是降低最高频率、减小加速步长、检查驱动器电流拨码是否匹配电机额定电流。如果编码器计数和发出去的脉冲数能对上但机械位置偏了那可能是联轴器松动不是电气问题。现象二编码器计数方向反了或者转一圈计数不对。现象是给正转指令位置反馈变小或者手动转一圈计数是 4000 而不是 1000。原因是 A/B 相接反或者EncoderMode设成了单相模式。解决方法是交换 A/B 相接线或者把TIM_ENCODERMODE_TI12改成对应模式计数倍频不对就检查ENCODER_MULTI和定时器配置是否一致。现象三PWM 频率上不去电机啸叫但不转。现象是示波器测到的频率远低于计算值或者脉冲宽度很窄。原因是定时器时钟树没配对、ARR 太小、或者中断里干了太多活导致 PWM 更新不及时。解决方法是先确认定时器时钟再把 ARR 下限抬高串口打印和浮点运算移出高频中断。现象四闭环在目标位置附近来回摆。现象是位置误差正负交替电机反复正反转。原因是 Kp 太大、积分饱和、或者测速噪声太大。解决方法是先降 Kp再加积分限幅速度反馈加滑动平均如果机械上有间隙位置环死区可以设几个脉冲别让它追太死。注意排查时先断开电机使能手动转编码器看计数方向和倍频再开环发固定数量脉冲看走位最后再上闭环。顺序反了很容易把机械问题和控制问题混在一起。6. 二次开发把单轴 demo 改成多轴联动与参数在线整定单轴跑通之后下一步通常是加轴。最省事的改法不是复制粘贴三份代码而是把编码器和步进电机抽象成结构体每个轴持有自己的定时器句柄、方向引脚、PID 参数和位置变量。这样主循环里用一个数组遍历加轴只是多填一个结构体。typedef struct { TIM_HandleTypeDef *encoder_tim; TIM_HandleTypeDef *pwm_tim; uint16_t dir_pin; GPIO_TypeDef *dir_port; int32_t position; float target_speed; PID_t speed_pid; } Axis_t; Axis_t axis[2] { { htim2, htim1, GPIO_PIN_0, GPIOB, 0, 0, {0} }, { htim4, htim3, GPIO_PIN_2, GPIOB, 0, 0, {0} }, }; void Axis_Update(Axis_t *ax) { float speed Encoder_GetSpeedRPM(ax-encoder_tim); float out PID_Calc(ax-speed_pid, ax-target_speed, speed); Stepper_SetPulseFreq(ax-pwm_tim, out); }改造时要注意两点一是每个轴的编码器定时器不能共用STM32 的定时器资源有限选型时先数清楚二是多轴联动时位置环的输出要同步更新最好放在同一个控制周期中断里按固定顺序计算避免轴间不同步。参数在线整定可以用串口协议比如SET Kp 2.5、GET POS解析放在主循环不要放在中断里。验证方法我一般分三步先开环发 10000 个脉冲看编码器计数是不是也对 10000 左右再给一个固定速度看速度反馈能不能跟上最后给位置指令看停止后误差是否在允许范围内。三步都过了再上负载。如果第二步就抖别急着调位置环先把速度环的滤波和限幅查一遍。从那以后我每次上电前都先手动转一圈编码器看计数方向再发脉冲最后才使能电机。这个习惯帮我省掉了不少“参数调了半天其实是接线反了”的返工。希望帮到你。本文还有配套的精品资源点击获取
返回列表