STM32CubeMX实战:编码器模式与定时器中断实现高精度电机测速

📅 发布时间:2026/7/11 15:02:16 👁️ 浏览次数:
STM32CubeMX实战:编码器模式与定时器中断实现高精度电机测速
1. 从零开始为什么我们需要高精度电机测速做电机控制的朋友都知道速度反馈是闭环控制的“眼睛”。无论是做个小车让它匀速前进还是控制一个机械臂精准定位你都得知道电机此时此刻到底转得多快。想象一下你蒙着眼睛开车只能凭感觉踩油门这得多危险电机控制也是这个道理没有准确的速度反馈什么PID调节、位置控制都是空中楼阁。而带编码器的电机就是给这个“盲人”配上了一副高精度的眼镜。编码器每转一圈会输出固定数量的脉冲。我们只要数一数固定时间内收到了多少个脉冲就能反推出电机的转速。这个方法听起来简单直接但真用STM32去实现的时候新手往往会遇到一堆坑定时器模式怎么选计数溢出怎么处理中断采样频率定多少合适数据跳动太大怎么办我自己在项目里就踩过不少坑。最早用查询法主循环里不停地读计数器结果CPU啥也别干了光顾着数数了系统一忙起来速度更新就卡顿。后来改用定时器中断又遇到了数值跳变、方向判断错误的问题。折腾了好几回才发现STM32的定时器本身就有强大的“编码器接口模式”配合另一个定时器做定时采样才是既准确又省CPU资源的王道。今天我就手把手带你用STM32CubeMX这个“可视化配置神器”把TIM3的编码器模式和TIM6的定时中断搭配起来搭建一个稳定可靠的高精度电机测速系统。你会发现原来这些复杂的硬件配置点几下鼠标就能搞定大半。2. 硬件连接与CubeMX工程创建动手之前咱们先得把硬件理清楚。我这里以最常见的增量式正交编码器为例它一般输出A、B两相脉冲信号有些还会带一个Z相零位信号。A、B两相信号在相位上相差90度电机正转和反转时它们的相位关系是相反的。STM32的定时器编码器接口就是专门用来解读这种正交信号的它能自动根据A、B相的边沿关系进行计数并自动判断方向硬件完成完全不用CPU干预。硬件接线以STM32F103C8T6和TIM3为例编码器A相 接 单片机 PA6 (TIM3_CH1)编码器B相 接 单片机 PA7 (TIM3_CH2)编码器电源和地接好注意电平匹配常见5V或3.3V。接线搞定打开STM32CubeMX开始我们的“图形化编程”。新建工程选择你的芯片型号我这里是STM32F103C8T6。第一步永远是配置时钟树为了获得更高的定时器计数精度我通常会把主频调到最大比如72MHz。在Clock Configuration标签页里选择好外部晶振HSE然后把HCLK调到72MHz系统会自动帮你配置好PLL倍频。注意时钟是单片机的心脏也是定时器计时的基准。时钟频率不准测出来的速度肯定也不准。务必确保你的外部晶振焊接可靠并且CubeMX里的配置与实际硬件一致。接下来配置我们的两个主角TIM3和TIM6。在Pinout Configuration标签页的左侧找到Timers分类。2.1 配置TIM3为编码器模式点击TIM3在操作模式Combined Channels中选择Encoder Mode。神奇的事情发生了CubeMX会自动把通道1和通道2配置为输入捕获模式并且PA6和PA7的引脚状态会变成绿色表示已被占用。这时我们需要关注右侧的Parameter Settings选项卡Counter Settings:Prescaler (PSC): 分频器这里设为0即不分频。编码器脉冲直接驱动计数器。Counter Mode: 自动变为Up在编码器模式下这个选项其实不起作用计数方向由硬件自动判断。Counter Period (ARR): 自动重装载值。这是非常关键的一个参数它决定了计数器的最大值。编码器模式下计数器会在0到ARR之间循环计数。为了避免正反转切换时计数值突变一个经典的技巧是将其设置为6553516位定时器的最大值或者像很多老手喜欢设置的0xFFFF。但这里我推荐另一种更“安全”的做法设置为0x7FFF即32767。为什么因为这样计数器的中点是0x7FFF我们可以把初始值设为中点这样无论是正转累加还是反转递减都有充足的缓冲空间不容易立即溢出。我们后面的代码也会基于这个设定。Encoder Mode:Encoder Mode: 选择Encoder Mode TI1 and TI2。这意味着在TI1通道1和TI2通道2的每个边沿都计数也就是我们常说的“4倍频”模式。编码器线数是100线的话转一圈就能产生400个计数脉冲分辨率提高了4倍Polarity: 保持默认Rising Edge即可。其他参数保持默认。这样TIM3就变成了一个智能的编码器脉冲计数器硬件会自动帮你加减计数CPU完全不用管。2.2 配置TIM6为定时中断我们需要一个“节拍器”来定期读取TIM3的计数值。基本定时器TIM6或TIM7就非常适合干这个活。它们没有复杂的输入输出通道功能纯粹就是产生定时中断。点击TIM6在模式中选择Internal Clock。Parameter Settings:Prescaler (PSC): 预分频值。我们的系统时钟是72MHz如果想让定时器每1us计数一次这里就填71因为72MHz / (711) 1MHz。Counter Mode:Up。Counter Period (ARR): 自动重装载值。这个值决定了定时中断的周期。中断时间 (PSC1) * (ARR1) / 72MHz。比如我想让TIM6每50ms产生一次中断用来采样速度。计算一下先设PSC7199这样定时器时钟为72MHz / 7200 10kHz。要让10kHz的时钟在50ms后溢出ARR应该为 (0.05s * 10000Hz) - 1 499。所以这里ARR填499。别忘了打开中断在NVIC Settings选项卡里勾选TIM6 global interrupt的Enabled复选框。2.3 配置串口用于数据输出为了能看到我们测出的速度值配置一个串口发送数据到电脑上位机是必不可少的。这里我用最常用的USART1。找到Connectivity-USART1模式选择Asynchronous异步通信。Parameter Settings:Baud Rate: 设为115200常用。Word Length: 8 Bits。Parity: None。Stop Bits: 1。其他默认。对应的引脚PA9TX和PA10RX会自动配置好。最后点击Project Manager给工程起个名字选好IDE比如MDK-ARM V5将Generated Files的HAL库设置保持默认。点击右上角的GENERATE CODECubeMX就会为你生成一个完整的、包含所有初始化代码的工程。3. 代码精讲中断采样与速度计算打开生成的工程进入我们熟悉的main.c文件。CubeMX生成的代码结构很清晰用户代码要写在/* USER CODE BEGIN */和/* USER CODE END */之间这样下次重新生成代码时你自己的代码不会被覆盖。3.1 启动定时器与中断首先在main函数的初始化部分我们需要手动启动TIM6的中断模式定时器。CubeMX只帮我们做了初始化配置MX_TIM6_Init但并没有让它开始运行。/* USER CODE BEGIN 2 */ // 清除TIM6可能存在的更新中断标志位避免一开启就误入中断 __HAL_TIM_CLEAR_FLAG(htim6, TIM_FLAG_UPDATE); // 以中断模式启动TIM6 HAL_TIM_Base_Start_IT(htim6); /* USER CODE END 2 */同时TIM3的编码器模式也需要启动但它不需要中断。// 启动TIM3的编码器接口 HAL_TIM_Encoder_Start(htim3, TIM_CHANNEL_ALL);3.2 核心函数读取脉冲差值这是整个测速逻辑的灵魂。我们不是直接读取TIM3-CNT的绝对值而是读取两次采样间隔内计数值的变化量。这个变化量才是这段时间内编码器产生的净脉冲数。这里就用到前面说的ARR设为0x7FFF的技巧了。我们把计数器的初始值设为0x7FFF中点。假设电机正转计数器会从0x7FFF向上加反转则向下减。在中断里我们读取当前计数值CNT用它减去中点的0x7FFF得到的就是相对于中点的偏移量也就是这段时间内的“净计数”。然后立刻将计数器重置回中点0x7FFF为下一个采样周期做准备。/* USER CODE BEGIN 0 */ // 定义一个函数读取指定定时器在单位时间内的脉冲变化值 int16_t Get_Encoder_Delta(TIM_HandleTypeDef *htim) { int16_t cnt_diff; // 读取当前计数值并减去初始中点值 cnt_diff (int16_t)(__HAL_TIM_GET_COUNTER(htim) - 0x7FFF); // 关键一步将计数器重置回中点为下一次采样做准备 __HAL_TIM_SET_COUNTER(htim, 0x7FFF); return cnt_diff; } /* USER CODE END 0 */使用HAL库的宏__HAL_TIM_GET_COUNTER和__HAL_TIM_SET_COUNTER来读写计数器比直接操作寄存器可读性更好。这个函数返回的cnt_diff就是过去一个采样周期比如50ms内编码器计数的变化量。正数代表正转负数代表反转。3.3 定时中断回调函数HAL库把所有的定时器更新中断都统一到了一个回调函数里我们需要在里面判断是哪个定时器触发的。/* USER CODE BEGIN 4 */ // 定时器周期到达回调函数中断服务函数 void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { // 判断是否是TIM6的中断 if (htim-Instance TIM6) { int16_t pulse_count Get_Encoder_Delta(htim3); // 获取50ms内的脉冲数 // 将脉冲数转换为实际速度例如转/分钟 RPM // 这里就是你的核心算法 current_speed (pulse_count * 60 * 1000) / (ENCODER_PPR * 4 * SAMPLE_TIME_MS); // ENCODER_PPR: 编码器线数每转脉冲数 // 乘以4是因为我们用了4倍频 // SAMPLE_TIME_MS: 采样时间这里是50 // 乘以60是把“转/毫秒”换算成“转/分钟” } } /* USER CODE END 4 */这个函数每50ms自动执行一次。在里面我们调用Get_Encoder_Delta获取脉冲差然后通过一个公式将其转换为有物理意义的速度值比如转每分钟RPM。公式的推导很简单速度 (脉冲数 / (编码器总分辨率 * 采样时间)) * 时间换算系数。3.4 主循环与数据发送速度值计算出来后我们可以在主循环里把它通过串口发送出去方便用串口助手或者上位机观察波形。/* USER CODE BEGIN WHILE */ while (1) { // 每隔100ms发送一次速度数据避免串口发送过于频繁 HAL_Delay(100); // 使用printf重定向到串口发送需在工程中开启微库MicroLIB并重写fputc printf(Speed: %d RPM\r\n, current_speed); // 或者使用HAL库函数直接发送 // char buffer[50]; // int len sprintf(buffer, Speed: %d\r\n, current_speed); // HAL_UART_Transmit(huart1, (uint8_t*)buffer, len, 100); /* USER CODE END WHILE */ /* USER CODE BEGIN 3 */ } /* USER CODE END 3 */我更喜欢用printf因为格式控制方便。记得要在工程选项Target里勾选Use MicroLIB并在usart.c里重写fputc函数将输出指向串口。4. 进阶优化与避坑指南按照上面的步骤一个基础的测速功能就有了。但要想在实际项目中稳定运行特别是对精度和实时性有要求时还有几个关键点需要优化。4.1 采样时间的选择与M/T法测速我们目前用的是M法测速即固定时间Ts测量脉冲数M。这种方法在高速时精度高但在低速时由于捕获到的脉冲数很少甚至可能少于1个误差会非常大。比如编码器分辨率是100线4倍频后是40050ms内电机转速低于30转/分钟RPM时捕获的脉冲数就少于1个速度计算就会跳变为0。怎么办呢可以引入T法测速的思想即测量固定脉冲数M所需要的时间T。T法在低速时精度高但高速时时间测量会变得很短对定时器计时精度要求极高。更高级的是M/T法测速它结合了两者的优点。思路是设定一个固定的采样时间窗口比如50ms但同时精确测量这个窗口内第一个脉冲和最后一个脉冲的精确时间。这样既能保证高速下的精度又能改善低速性能。实现起来复杂一些需要用到定时器的输入捕获功能去捕捉脉冲边沿。对于大多数中等要求的场合优化采样时间是一个更简单的办法动态调整采样周期。在低速时延长采样时间比如200ms以获得更多脉冲在高速时缩短采样时间比如20ms以提高响应速度。这可以在中断回调函数里根据当前速度值动态修改TIM6的ARR寄存器来实现。4.2 数据滤波与平滑处理直接从编码器读出的速度值尤其是低速时可能会因为机械抖动、脉冲干扰而出现毛刺和跳动。直接把这个值送给PID控制器会导致输出抖动电机发出“滋滋”的噪音。移动平均滤波是最简单有效的软件滤波方法。我们可以创建一个数组来存储最近N次的速度采样值每次计算当前速度时取这个窗口内数据的平均值。#define FILTER_WINDOW_SIZE 5 int32_t speed_buffer[FILTER_WINDOW_SIZE] {0}; uint8_t buffer_index 0; // 在中断回调函数中计算完current_speed_raw后 speed_buffer[buffer_index] current_speed_raw; buffer_index (buffer_index 1) % FILTER_WINDOW_SIZE; // 计算平均值 int32_t speed_sum 0; for(int i0; iFILTER_WINDOW_SIZE; i) { speed_sum speed_buffer[i]; } current_speed speed_sum / FILTER_WINDOW_SIZE;窗口大小FILTER_WINDOW_SIZE取5或10效果就很明显了。它的缺点是会引入一定的滞后相位延迟但对于速度环控制来说这点延迟通常是可接受的。4.3 处理计数器溢出与方向判断虽然我们设置了0x7FFF的中点理论上正反转都有足够的计数空间但在电机持续高速正转或反转时计数器仍有溢出的可能。不过由于我们每50ms就清零重置到中点一次只要50ms内的脉冲变化量不超过±0x7FFF即±32767就不会有问题。计算一下对于400线4倍频的编码器32767个脉冲对应约81.9转。在50ms内转81.9转相当于98280 RPM这远远超过普通直流电机的转速了所以在实际应用中完全不用担心。方向判断已经由硬件自动完成了。我们读取的cnt_diff值为正就是正转为负就是反转。在计算速度大小时取绝对值即可。4.4 多电机与多定时器扩展一个TIM只能接一个编码器。如果你的小车有两个轮子就需要两个定时器比如TIM3和TIM4。在CubeMX里如法炮制即可。在中断回调函数里可以共用一个TIM6做定时采样然后分别读取两个编码器的差值void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim) { if (htim-Instance TIM6) { speed_left Get_Encoder_Delta(htim3) * SPEED_FACTOR; speed_right Get_Encoder_Delta(htim4) * SPEED_FACTOR; } }代码结构非常清晰。这里的关键是Get_Encoder_Delta函数每次都会将计数器重置所以两个编码器的读取是独立的互不影响。5. 实测演示与结果分析代码写好了下载到板子里接上带编码器的电机我用的是常见的GA25-370直流减速电机带100线AB相编码器。给电机供电让它空载旋转。打开电脑上的串口助手波特率115200应该就能看到一列列的速度数据在刷屏了。为了更直观地分析我推荐使用匿名科创上位机或SerialPlot这类能绘制波形图的工具。把速度数据按照一定格式发送就能看到实时的速度曲线。你可以用手轻轻捏住电机轴让它减速观察曲线是否平滑下降突然松开观察曲线能否快速回升。一个响应迅速、曲线平滑的测速系统是做好速度闭环控制的第一步。我实测时遇到过一个问题电机静止时速度读数不为零而是在正负几个RPM之间跳动。这主要是由于编码器信号本身的微小抖动和噪声引起的。这时候可以加入一个“死区”判断当计算出的速度绝对值小于某个阈值比如5 RPM时直接将其视为0。这能有效避免电机停转时PID控制器还在微调输出。if(abs(current_speed) SPEED_DEAD_ZONE) { current_speed 0; }另一个常见问题是采样时间50ms和速度控制周期比如10ms不匹配。我的做法是速度计算仍在50ms中断中进行但将计算好的速度值存入一个全局变量。速度PID控制循环每10ms运行一次它读取的是这个最新的全局变量。虽然数据有最多50ms的“延迟”但对于大多数移动机器人平台来说这个延迟带来的相位损失是可以接受的系统依然能稳定运行。最后别忘了测试正反转。用手正向转动电机读数为正反向转动读数应为负。如果符号反了只需要在CubeMX里交换一下TIM3的通道1和通道2的引脚或者修改代码中速度计算公式的符号即可。整个流程走下来从CubeMX图形化配置到写出核心代码你会发现STM32的HAL库虽然抽象层次高但配合CubeMX开发效率提升的不是一点半点。它把最繁琐的底层寄存器配置变成了直观的图形选项让我们能更专注于算法和逻辑本身。这套编码器测速框架我已经在好几个移动机器人项目里用过效果非常稳定。