音量旋钮一转,数字就跳;微波炉调时间、鼠标滚轮滚页面,背后干活的多半是旋转编码器。它还能反过来当传感器用——装到直流减速电机的尾巴上,就能知道电机轴转了多少圈、转得多快,配上驱动器就是一套位置反馈。这次轮到KY-040——37合1传感器套件里的老熟人,五根针、一根能按下去的轴,接上Arduino就能把"转了多少格、往哪边转、轴有没有被按下"全部读出来。这篇把引脚、接线、方向判断、消抖和计数一次讲透,代码照抄就能跑。

先说清楚它和电位器的区别。电位器转到底就停,读的是模拟电压,还会随温度漂;旋转编码器没有尽头,随便转,输出的是数字脉冲,数脉冲就知道转了多少。精度要求高的调节场合,它比电位器靠谱得多。
一、KY-040五个引脚各管什么
模块上五个引脚,从边上一路数过来:
- GND——接地。
- +(VCC)——供电,接Arduino的5V或3.3V都行。
- SW——轴上自带的自复位按键输出。平时被上拉电阻拉在高电平,把轴往下一按,这脚就被拉低到地。
- CLK——两路正交输出信号之一,接数字输入脚。
- DT——另一路正交输出,波形和CLK长得一样,就是差了90度相位。

里面是什么结构?一张开槽的圆形码盘跟着轴一起转,盘上有两个触点A和B,轮流接触公共端C(也就是地)。关键在于两个触点的位置是错开的,永远不会同时接触地——A总比B早碰到或者晚碰到那么一下,两路信号就这么错开了。

两相相位差:判断方向的核心
理解了错相位,方向判断就一句话的事:在CLK电平跳变的那个瞬间,去看DT的电平。
如果此刻DT和CLK不一样,说明DT还没跟上,轴在往一个方向转(顺时针);如果两者一样,说明DT先动过了,方向相反(逆时针)。这套玩法有个正式名字,叫正交编码(Quadrature Encoding)。两个信号谁先谁后由旋转方向决定,数脉冲的个数就知道转了多少,比一下谁先动就知道往哪边转。
两路信号长什么样?拿示波器看就是两条漂亮的方波,一模一样的频率,只是错开了一段——国外玩家把电机编码器的两路输出接到示波器上,看到的正是这个。理论上把两路波形组合起来读,还能拼出更高的分辨率,不过对KY-040这种当旋钮用的场合,单路加判向已经够用了。
顺便把编码器家族分个工:KY-040这类叫增量式(也叫相对式)编码器,只报告"转了多少格",不报告轴当前绝对角度,适合当旋钮用;另一种是绝对式,直接给出轴的精确角度。还有装在直流减速电机尾部的电机编码器,比如国外玩家实测过的那颗goBILDA 5201减速电机,一圈能出374个脉冲,专门拿来测转速、做位置闭环——数脉冲的思路一样,用处不同。
接线:五根线照着插
按下面的表接,两分钟的事:
| 编码器引脚 | Arduino引脚 |
|---|---|
| CLK | D2 |
| DT | D3 |
| SW | D4 |
| + (VCC) | 5V |
| GND | GND |

引脚不是死的,换成别的数字脚完全可以,代码里把宏定义改一致就行。想直观看到方向,还可以学国外教程的接法:两个LED各串一个150到470欧的限流电阻(220欧就行),分别接两个输出口,顺时针亮一个、逆时针亮另一个,调试的时候一眼就能看出判向对不对。
另外提醒一句电源:如果后续要在这个Arduino上挂舵机,舵机最好单独供电。舵机一动就容易把5V线上的噪声搅起来,编码器这种靠电平判读的器件跟着遭殃,读数莫名发飘的时候,先查供电再怀疑代码。
例程:轮询版和中断版各来一个
先上轮询版,在主循环里不停看CLK。它顺带把SW按键也读了:
// Rotary Encoder Inputs
#define CLK 2
#define DT 3
#define SW 4
int counter = 0;
int currentStateCLK;
int lastStateCLK;
String currentDir = "";
unsigned long lastButtonPress = 0;
void setup() {
// Set encoder pins as inputs
pinMode(CLK, INPUT);
pinMode(DT, INPUT);
pinMode(SW, INPUT_PULLUP);
// Setup Serial Monitor
Serial.begin(9600);
// Read the initial state of CLK
lastStateCLK = digitalRead(CLK);
}
void loop() {
// Read the current state of CLK
currentStateCLK = digitalRead(CLK);
// If last and current state of CLK are different, then pulse occurred
// React to only 1 state change to avoid double count
if (currentStateCLK != lastStateCLK && currentStateCLK == 1) {
// If the DT state is different than the CLK state then
// the encoder is rotating CCW so decrement
if (digitalRead(DT) != currentStateCLK) {
counter--;
currentDir = "CCW";
} else {
// Encoder is rotating CW so increment
counter++;
currentDir = "CW";
}
Serial.print("Direction: ");
Serial.print(currentDir);
Serial.print(" | Counter: ");
Serial.println(counter);
}
// Remember last CLK state
lastStateCLK = currentStateCLK;
// Read the button state
int btnState = digitalRead(SW);
//If we detect LOW signal, button is pressed
if (btnState == LOW) {
//if 50ms have passed since last LOW pulse, it means that the
//button has been pressed, released and pressed again
if (millis() - lastButtonPress > 50) {
Serial.println("Button pressed!");
}
// Remember last button press event
lastButtonPress = millis();
}
// Put in a slight delay to help debounce the reading
delay(1);
}
烧进去,打开串口监视器,正着转、反着转,看Direction和Counter怎么变;按一下轴,会蹦出Button pressed。跑起来的效果就是下面这样:

这段代码有三个要点。第一,条件里限了currentStateCLK == 1,也就是只在CLK从低变高的那个沿动作,一个档位只计一次,两个沿都响应就会双计。第二,方向判断就嵌在if里:DT不等于CLK就减计数(CCW),相等就加计数(CW)。第三,SW按键用了50毫秒的时间窗滤抖:读到低电平后记下时间,50毫秒内的重复低电平不算新按键。
轮询版能用,但有三个先天毛病:主循环得一直盯着这两个脚,干不了别的正事;忙起来的时候响应会慢半拍;转快了直接丢步。解法是中断——让硬件盯着CLK,一跳变就暂停主程序去执行读数函数,完事回来接着干。
// Rotary Encoder Inputs
#define CLK 2
#define DT 3
int counter = 0;
int currentStateCLK;
int lastStateCLK;
String currentDir = "";
void setup() {
// Set encoder pins as inputs
pinMode(CLK, INPUT);
pinMode(DT, INPUT);
// Setup Serial Monitor
Serial.begin(9600);
// Read the initial state of CLK
lastStateCLK = digitalRead(CLK);
// Call updateEncoder() when a change is seen on CLK pin
attachInterrupt(digitalPinToInterrupt(CLK), updateEncoder, CHANGE);
}
void loop() {
//Do some useful stuff here
}
void updateEncoder() {
// Read the current state of CLK
currentStateCLK = digitalRead(CLK);
// If last and current state of CLK are different, then pulse occurred
// React to only 1 state change to avoid double count
if (currentStateCLK != lastStateCLK && currentStateCLK == 1) {
// If the DT state is different than the CLK state then
// the encoder is rotating CCW so decrement
if (digitalRead(DT) != currentStateCLK) {
counter--;
currentDir = "CCW";
} else {
// Encoder is rotating CW so increment
counter++;
currentDir = "CW";
}
Serial.print("Direction: ");
Serial.print(currentDir);
Serial.print(" | Counter: ");
Serial.println(counter);
}
// Remember last CLK state
lastStateCLK = currentStateCLK;
}

和轮询版的差别就两处:把读数逻辑整体搬进updateEncoder()函数,然后在setup里挂上attachInterrupt,指定CLK脚、CHANGE触发(高低跳变都进中断)。注意主循环loop()整个是空的——检测全交给中断了,主循环爱干嘛干嘛。转起来之后串口刷新明显比轮询版跟手,快转也不丢步。
中断版还有个顺手的应用:拿counter去驱动舵机。舵机的信号线接9脚这种PWM脚,红线接5V、棕线接地;舵机只吃0到179度的位置值,所以代码里把counter往负数夹到0、往上夹到179,每转一个档位走一度,旋钮拧到哪舵机指到哪——做机械臂关节微调就是这套思路。
消抖与计数,新手最容易栽的四个坑
坑一:一个档位计两次。原因是CLK两个沿都响应了。记死一条——只在其中一个沿动作,代码里的currentStateCLK == 1就是在干这个。
坑二:按键一按蹦出好几条。机械触点闭合瞬间会抖,一次按压被读成好几次。照例程的写法用millis()卡50毫秒窗口就稳了;轮询读数发飘的话,循环末尾那个1毫秒的delay别嫌它土,留着。
坑三:转快了数字不对。轮询扛不住快速旋转,老老实实上中断版。另外例程为了演示,把Serial.print放在了中断回调里,自己项目里建议回调越短越好,打印挪到主循环去做。
坑四:想测转速却算不准。电机编码器那套玩法是:中断设成RISING、只数一路,每满一秒把计数值取走清零,脉冲数乘60再除以每圈脉冲数就是转速——一秒内的脉冲数乘60是一分钟的脉冲数,除以每圈脉冲数自然就是每分钟转多少圈。每圈脉冲数必须查你手上电机的规格页——实测那颗是374,你的未必是,这个数不查准,RPM就是错的。接线也简单:编码器的VCC和GND照接,信号出一接到中断脚就行,反正不判方向,接A相还是B相无所谓。
坑五:counter用int还是long。KY-040当旋钮慢慢拧,int绰绰有余;但换成电机编码器这种高速场合,脉冲来得又急又密,计数值一路涨,用int早晚溢出,老老实实上long,例程里电机那版用的就是volatile long,中断和主循环共用变量,volatile也别省。
最后收个尾。KY-040几块钱一颗,五根线、一个判断方向的if、一个数脉冲的变量,就构成了从"拧旋钮"到"做菜单、调舵机、测转速"的全部基础。转出来方向反了别慌,CLK和DT两根线对调一下就正过来了。下一步,把这个旋钮装到你自己的项目里去吧。











这篇把CLK和DT那个90度相位差讲得挺透。我第一次玩编码器就是栽在这,两个沿都去数,结果拧一格数字跳两下,折腾了半晚上才明白只认一个沿就行。文里那句"在CLK跳变的瞬间去看DT"算是把正交编码的精髓一句话说完了,新手照这个思路去理解波形图会顺很多。
转反了对调CLK和DT这招救过我,当时还以为是模块坏了
中断版那个空的loop()看着确实舒服,我那个菜单项目改完中断之后快转再也不丢步了。不过提醒一句,回调里放Serial.print确实别学,我打印多了同样掉数,后来只留个计数,打印全挪到loop里才稳。
KY-040几块钱一个,配合舵机做角度微调是真香
50毫秒窗口消抖这个数我记下了,之前按键一次蹦三条就是这个毛病,一直懒得查,原来millis卡个窗口就完事