Arduino编码器四倍频测速:从原理到稳定实现的完整指南
1. 项目缘起从“轮子转多快”到精准测速搞嵌入式开发的朋友尤其是玩机器人、智能小车或者自动化设备的肯定都绕不开一个基础又核心的问题怎么知道我的电机转得有多快你可能觉得不就是数脉冲吗但真上手做从选型、接线到代码调试每一步都可能让你卡上半天。特别是对于刚接触Arduino的新手面对编码器输出的两路相位差90度的方波信号怎么把它稳定、准确地转换成每分钟转数RPM或者线速度这里面门道不少。我最近在做一个平衡小车项目核心之一就是电机速度的闭环控制。速度反馈不准PID调参就是空中楼阁。市面上教程很多但要么过于理论只讲原理要么代码封装得太“黑盒”出了问题不知道怎么调试。所以我决定基于最经典的Arduino UNO和常见的霍尔编码器从头梳理一遍测速的实现把代码写得足够清晰、健壮并且完全开源。目标就一个你拿到就能用用了就能准出了问题也知道怎么查。为什么选Arduino UNO和霍尔编码器UNO几乎是所有人的入门板资源丰富社区庞大。霍尔编码器则比光栅编码器更皮实耐造不怕灰尘油污价格也便宜在智能小车、航模舵机、甚至一些工业场景里都很常见。测速的原理本质上就是在固定时间窗口内统计编码器输出的脉冲数。但如何高效、无遗漏地统计这个脉冲就是代码要解决的核心问题。2. 硬件选型与连接避开那些“理所当然”的坑在写代码之前正确的硬件连接是成功的一半。这里面的坑我几乎都踩过一遍。2.1 核心器件霍尔编码器与Arduino UNO我这次使用的电机是常见的MG310减速电机自带了一个双路霍尔编码器。你需要关注编码器的几个关键参数供电电压通常是3.3V或5V务必确认接错可能烧毁。输出类型推挽输出还是开漏输出我们常用的通常是推挽输出可以直接接单片机IO口。分辨率即电机轴旋转一圈编码器输出多少个脉冲PPR。我手头这个是13线的注意这里的“13线”指的是码盘上有13个磁极因此旋转一圈会产生13磁极 * 2A、B两相 26个脉冲。很多商家会模糊表述一定要看数据手册或实测确认。相序A、B两相信号相位差90度。这个差值是判断旋转方向的关键。Arduino UNO我们都很熟悉核心是ATmega328P。测速需要用到它的外部中断引脚和定时器。UNO的外部中断引脚是2号和3号对应INT0和INT1这是我们实现精准计数的硬件基础。2.2 接线图与防干扰细节接线听起来简单编码器VCC接5VGND接GNDA相接数字引脚2B相接数字引脚3。但如果你就这么接了大概率会遇到计数不准、数值跳变的问题。注意务必为编码器供电增加一个10uF~100uF的电解电容并联一个0.1uF的瓷片电容紧靠编码器电源引脚放置。电机启停瞬间会产生很大的电源噪声这个简单的滤波电路能吃掉绝大部分干扰是稳定计数的“定海神针”。另一个细节是上拉电阻。虽然ATmega328P的IO口可以开启内部上拉约20kΩ但在一些长导线或噪声较大的环境下内部上拉力可能不足导致信号边沿不陡峭容易误触发。最稳妥的做法是在A、B相线上各接一个4.7kΩ~10kΩ的外部上拉电阻到VCC。这样信号质量会好很多。所以完整的接线思路是Arduino UNO的5V和GND引出先接到你的滤波电容上。滤波后的稳定5V和GND再给编码器供电。编码器的A、B相信号线分别通过上拉电阻接到5V然后连接到UNO的D2和D3。UNO的GND和编码器GND必须共地。2.3 为什么不用其他引脚有朋友会问我直接用digitalRead()在loop()里轮询检测引脚状态变化不行吗理论上可以但实际不可行。loop()的执行时间受代码复杂度影响不稳定。电机高速旋转时脉冲周期很短轮询方式极易丢失脉冲导致测速值严重偏低且波动巨大。外部中断可以在脉冲边沿到来的瞬间立即响应确保不漏数。因此D2和D3这两个中断引脚是唯一的选择。3. 代码核心四倍频与方向判断的精妙实现好了硬件连稳妥了我们来看代码。完整的代码我会在最后给出这里先拆解最核心的两部分脉冲计数逻辑和速度计算逻辑。3.1 利用状态机实现四倍频与方向解码这是整个测速程序效率最高、最经典的部分。我们利用A、B两相的相位关系不仅判断方向还将分辨率提高了4倍。// 引脚定义 #define ENCODER_A 2 // 外部中断0 #define ENCODER_B 3 // 外部中断1 // 全局变量 volatile long pulseCount 0; // 必须用volatile因为它在中断中被修改 int direction 1; // 1为正转-1为反转 // 编码器状态表 (A相旧状态, B相旧状态) - (A相新状态, B相新状态) // 状态变化顺序决定了方向。这里假设正转时AB相序为01-11-10-00... // 反转时顺序相反。 const int8_t stateMachine[4][4] { // 旧状态AB: 00, 01, 10, 11 { 0, 1, -1, 0}, // 新状态A0, B0 (00) {-1, 0, 0, 1}, // 新状态A0, B1 (01) { 1, 0, 0, -1}, // 新状态A1, B0 (10) { 0, -1, 1, 0} // 新状态A1, B1 (11) }; void readEncoder() { static uint8_t oldState 0; uint8_t newState (digitalRead(ENCODER_A) 1) | digitalRead(ENCODER_B); // 将AB相状态组合成一个2位数 int8_t delta stateMachine[oldState][newState]; if (delta ! 0) { pulseCount delta; direction (delta 0) ? 1 : -1; } oldState newState; }这段代码的精髓在于那个stateMachine查找表。它编码了A、B相从旧状态切换到新状态时脉冲计数应该增加1、减少-1还是不变0。通过查表我们只用几次位运算和一次数组访问就同时完成了四倍频和方向判断效率极高。volatile关键字至关重要它告诉编译器pulseCount可能被中断随时改变禁止对其进行优化比如缓存到寄存器确保主循环读取到的值永远是最新的。3.2 定时采样与速度计算脉冲数有了怎么变成速度我们需要一个固定的时间窗口来采样。unsigned long lastSampleTime 0; const long sampleInterval 100; // 采样间隔单位毫秒 float rpm 0.0; void calculateSpeed() { unsigned long currentTime millis(); if (currentTime - lastSampleTime sampleInterval) { // 1. 禁止中断安全地读取并清零脉冲计数 noInterrupts(); long count pulseCount; pulseCount 0; interrupts(); // 2. 计算转速 (RPM) // 公式转速 (脉冲数 / 每转总脉冲数) * (60000 / 采样间隔ms) // 每转总脉冲数 编码器线数 * 4 (四倍频) float pulsesPerRevolution ENCODER_PPR * 4; // 假设ENCODER_PPR13 rpm (count / pulsesPerRevolution) * (60000.0 / sampleInterval); // 3. 记录方向和更新时间 lastSampleTime currentTime; // 4. 输出或用于控制 Serial.print(Speed: ); Serial.print(rpm); Serial.print( RPM, Direction: ); Serial.println((direction 0) ? Forward : Backward); } }关键点分析采样间隔选择sampleInterval是关键参数。太短如10ms计算频繁且脉冲数少速度波动大太长如1000ms响应迟钝。100ms是一个经验值能在响应速度和稳定性间取得较好平衡。你可以根据电机最高转速调整。原子操作在noInterrupts()和interrupts()之间读取和清零pulseCount是必须的。否则可能在你读取一半时中断发生并修改了pulseCount导致读到的数据是错的脏读。单位换算公式(count / pulsesPerRevolution)得到采样间隔内的转数* (60000 / sampleInterval)将其换算成每分钟转数RPM。60000是一分钟的毫秒数。3.3 初始化与主循环把上面的部分组合起来完整的setup()和loop()就很清晰了。void setup() { Serial.begin(115200); pinMode(ENCODER_A, INPUT_PULLUP); // 使用内部上拉如果接了外部上拉这里用INPUT也行 pinMode(ENCODER_B, INPUT_PULLUP); // 任何一边沿变化都触发中断调用readEncoder函数 attachInterrupt(digitalPinToInterrupt(ENCODER_A), readEncoder, CHANGE); attachInterrupt(digitalPinToInterrupt(ENCODER_B), readEncoder, CHANGE); lastSampleTime millis(); } void loop() { calculateSpeed(); // 计算并输出速度 // 这里可以加入你的控制逻辑比如PID计算 // delay(1); // 通常不需要delay让loop尽快循环 }这里有个重要的选择中断触发模式。我们用了CHANGE变化即A、B相任何引脚的电平发生变化上升沿或下降沿都触发中断。为什么不用RISING上升沿因为我们要做四倍频必须捕获每个边沿。一个脉冲周期有上升沿和下降沿A、B两相就有4个边沿所以是四倍频。如果只用RISING分辨率就降为一倍频了。4. 实测调试与常见问题排查代码烧录进去电机一转Serial Monitor里数字开始跳动但这只是开始。怎么判断它准不准怎么解决那些稀奇古怪的问题4.1 校准与验证你的“尺子”准吗最直接的验证方法是拖拽法。断开电机驱动用手匀速转动电机轴10圈。同时在calculateSpeed函数里不要清零pulseCount而是累加并打印出来。转动结束后查看总脉冲数。理论脉冲数 10圈 * 每圈脉冲数ENCODER_PPR * 4。以13线为例理论值10 * 52 520。实际脉冲数观察Serial Monitor的输出。如果实际值稳定且等于理论值恭喜你硬件和计数逻辑完全正确。如果实际值小于理论值且波动说明有脉冲丢失。如果实际值远大于理论值说明有噪声误触发。4.2 典型问题一计数不稳定数值乱跳症状电机静止时pulseCount自己慢慢增加或减少低速时速度显示不为零。根因信号噪声。电机驱动器的PWM信号、电源噪声都是干扰源。解决方案硬件滤波确保已按2.2节所述增加了电源滤波电容和信号上拉电阻。这是最根本的。软件消抖在中断服务函数readEncoder里加入简单的延时判断。但要极其谨慎因为中断里执行时间过长会严重影响系统实时性可能导致丢失其他中断。一个折中的办法是记录两次中断的时间间隔如果间隔小于一个阈值比如50微秒则认为可能是抖动忽略此次变化。但这需要用到micros()函数并引入静态变量会增加复杂度。void readEncoder() { static unsigned long lastInterruptTime 0; unsigned long interruptTime micros(); // 如果两次中断间隔小于100微秒认为是抖动忽略 if (interruptTime - lastInterruptTime 100) { return; } lastInterruptTime interruptTime; // ... 原有的状态机逻辑 ... }慎用此方法高速旋转时脉冲间隔可能本身就很小容易误滤除真实信号。优先解决硬件问题。4.3 典型问题二高速时计数严重偏低症状电机转速越高显示的速度比实际速度低得越多。根因中断丢失。电机转速很高时脉冲间隔很短。如果readEncoder中断服务函数执行时间过长或者系统有其他中断如串口接收频繁发生就可能错过一些脉冲边沿。解决方案优化中断服务函数确保readEncoder里的代码尽可能精简。我们使用的查表法已经非常高效。避免在中断里进行浮点运算、Serial.print等耗时操作。检查其他中断你的项目中是否用了TimerOne等库产生了高频率中断或者串口波特率太低接收大量数据暂时关闭它们进行测试。终极方案使用硬件计数器。ATmega328P的Timer1可以配置为外部事件计数器。但这需要更底层的寄存器操作且通常只能计数单相脉冲会失去方向判断和四倍频能力。对于绝大多数Arduino项目优化代码后中断方式足以应对几千RPM的转速。4.4 典型问题三方向判断偶尔错误症状电机明明只朝一个方向转但偶尔会报出反向脉冲。根因信号抖动导致状态跳变顺序异常。比如理想状态变化是00-01-11-10但一次抖动可能导致00-10跳过了中间状态查表就会得到错误的方向增量。解决方案这通常伴随问题一出现。强化硬件滤波是第一要务。其次可以增强状态机的容错性但会显著增加代码复杂度。对于多数应用在硬件滤波做好后偶发的方向错误可以忽略因为它在长时间统计中会被平均掉不影响速度计算的平均值。如果对实时方向要求极高可能需要选用带硬件方向解码功能的编码器接口芯片或者换用更高级的MCU如STM32其定时器直接支持正交编码器模式。5. 进阶优化从“能用”到“好用”基础功能稳定后我们可以考虑一些优化让代码更健壮、更易用。5.1 添加低通滤波让速度值更平滑直接计算出的RPM值可能毛刺较多直接用于PID控制会引起震荡。我们可以加入一个简单的一阶低通滤波软件滤波。float filteredRpm 0.0; const float filterFactor 0.2; // 滤波系数0~1越小越平滑但延迟越大 // 在calculateSpeed函数计算完rpm后 filteredRpm filterFactor * rpm (1 - filterFactor) * filteredRpm;这样filteredRpm就是一个平滑后的速度值。filterFactor需要根据你的采样周期和控制周期来调整在响应速度和平滑度之间权衡。5.2 封装成C类提升代码复用性如果你有多个编码器或者希望代码更模块化封装成类是个好主意。class QuadratureEncoder { private: uint8_t pinA, pinB; volatile long pulseCount; int direction; int ppr; // ... 其他私有成员和方法如状态机、上次采样时间等 public: QuadratureEncoder(uint8_t a, uint8_t b, int pulsesPerRev); void begin(); // 初始化引脚和中断 float getRPM(long sampleIntervalMs); // 获取速度内部会清零计数 int getDirection(); // 获取方向 long getPulseCount(); // 获取累计脉冲数不清零 }; // 使用示例 QuadratureEncoder encoder1(2, 3, 13); // 引脚2,313线编码器 encoder1.begin(); void loop() { float speed_rpm encoder1.getRPM(100); // ... }这样主程序会非常简洁管理和使用多个编码器也方便。5.3 应对极端情况溢出与长时间运行pulseCount我们用的是long型32位范围约±21亿。在四倍频下对于13线编码器一圈52个脉冲。要计满21亿脉冲电机需要转约4000万圈。对于长期不间断运行的设备这仍有可能溢出。解决方法有两种使用long long64位类型但Arduino对64位整数的原子操作需要特别处理。更实际的方法是我们的calculateSpeed函数每100ms就清零一次pulseCount所以它根本没有机会累加到溢出。真正需要关注的是累计行程比如小车走了多远这可以用另一个变量totalPulse来累加每次采样前的count值并且定期比如每10000圈向SD卡或EEPROM记录一次然后清零重新开始。6. 完整代码与“开箱即用”指南以下是整合了稳定性优化和滤波功能的完整代码你可以直接复制到Arduino IDE中使用。/* * Arduino UNO 霍尔编码器四倍频测速程序 * 作者[你的名字] * 描述使用外部中断和状态机实现高精度、带方向的转速测量。 * 硬件连接 * 编码器VCC - Arduino 5V (建议就近加10uF和0.1uF电容滤波) * 编码器GND - Arduino GND * 编码器 A相 - D2 (外部中断0) * 编码器 B相 - D3 (外部中断1) (建议各加4.7k上拉电阻至5V) * 参数配置 * 修改 ENCODER_PPR 为你的编码器每转脉冲数单相非四倍频 * 修改 SAMPLE_INTERVAL_MS 调整采样周期 * 修改 FILTER_FACTOR 调整软件滤波强度 */ #define ENCODER_A 2 #define ENCODER_B 3 #define ENCODER_PPR 13 // 编码器每转脉冲数单相。我用的13线编码器。 #define SAMPLE_INTERVAL_MS 100 // 速度采样间隔毫秒 #define FILTER_FACTOR 0.3 // 低通滤波系数 (0.0 ~ 1.0)越小越平滑 // 全局变量 volatile long pulseCount 0; volatile int currentDirection 1; // 1正-1反 unsigned long lastSampleTime 0; float filteredRpm 0.0; // 四倍频状态机查找表 const int8_t stateTable[4][4] { { 0, 1, -1, 0}, // 旧状态00 {-1, 0, 0, 1}, // 旧状态01 { 1, 0, 0, -1}, // 旧状态10 { 0, -1, 1, 0} // 旧状态11 }; // 中断服务函数在A或B相变化时调用 void readEncoderISR() { static uint8_t oldState 0; // 组合当前A、B相状态为一个2位数 (bit1:A, bit0:B) uint8_t newState (digitalRead(ENCODER_A) 1) | digitalRead(ENCODER_B); int8_t increment stateTable[oldState][newState]; if (increment ! 0) { pulseCount increment; currentDirection (increment 0) ? 1 : -1; } oldState newState; } void setup() { Serial.begin(115200); Serial.println(Arduino Encoder Speed Test Started.); // 配置编码器引脚启用内部上拉电阻 pinMode(ENCODER_A, INPUT_PULLUP); pinMode(ENCODER_B, INPUT_PULLUP); // 附着中断任何边沿变化都触发 attachInterrupt(digitalPinToInterrupt(ENCODER_A), readEncoderISR, CHANGE); attachInterrupt(digitalPinToInterrupt(ENCODER_B), readEncoderISR, CHANGE); lastSampleTime millis(); } void loop() { unsigned long now millis(); if (now - lastSampleTime SAMPLE_INTERVAL_MS) { // 1. 原子操作读取并清零脉冲计数 long count; noInterrupts(); count pulseCount; pulseCount 0; interrupts(); // 2. 计算原始RPM // 每转总脉冲数 单相PPR * 4 (四倍频) float pulsesPerRev ENCODER_PPR * 4.0; // RPM (采样间隔内转数) * (每分钟间隔数) // (脉冲数 / 每转脉冲数) * (60000 / 采样间隔ms) float instantRpm (count / pulsesPerRev) * (60000.0 / SAMPLE_INTERVAL_MS); // 3. 低通滤波 filteredRpm FILTER_FACTOR * instantRpm (1.0 - FILTER_FACTOR) * filteredRpm; // 4. 输出结果 Serial.print(Time: ); Serial.print(now / 1000.0, 1); Serial.print(s | Count: ); Serial.print(count); Serial.print( | RPM: ); Serial.print(instantRpm, 1); Serial.print( | Filtered RPM: ); Serial.print(filteredRpm, 1); Serial.print( | Dir: ); Serial.println((currentDirection 0) ? FWD : REV); // 5. 更新采样时间 lastSampleTime now; } // 主循环可以在这里添加其他任务如PID计算、电机驱动等 }“开箱即用”步骤修改参数根据你的编码器修改第15行的ENCODER_PPR。如果不确定可以先设为1用手转动一圈看看Count值大概是多少然后除以4再取整。连接硬件务必按照注释和第二章的说明连接好硬件特别是滤波电容和上拉电阻。上传代码用Arduino IDE上传到UNO。打开串口监视器设置波特率为115200。转动电机你应该能看到稳定的速度输出。用手捏住电机轴让其缓慢转动观察Dir方向指示是否正确快速转动观察RPM和Filtered RPM的值滤波后的值应该更平滑。如果一切正常你就可以把filteredRpm和currentDirection这两个变量用到你的PID控制、数据记录或者无线传输程序里了。这个代码框架经过实际项目验证在电源处理得当的情况下计数非常稳定可靠希望能帮你省下那些折腾硬件和调试的夜晚。

相关新闻

最新新闻

日新闻

周新闻

月新闻