把 KY-040 的 CLK 接到 D2,在中断里数格数(detent),并且只有当计数真的变了才去调用 display()。最后那一条就是整篇文章的重点:一次 128×64 的 OLED 全屏重绘,会让 Arduino Uno 停住大约 25 ms,而手指一记快速拨动,好几格就埋在这段空档里。
旋转编码器为什么要两支输出脚,一支不行吗?
KY-040 那只银色金属罐里装着两组开关触点,引出来就是 CLK 和 DT,两者刻意错开,让其中一组比另一组早四分之一个周期变化。单看 CLK 上的一个跳变毫无意义 —— 无论你把轴顺时针还是逆时针转,那组触点断开的样子一模一样。方向不在任何一路信号里,它藏在两路之间的相位上。
所以要在其中一路动的那一瞬间去读另一路:CLK 下降的时候采样 DT,往一个方向转它还是 HIGH,往另一个方向转它已经是 LOW 了。一次 digitalRead 就够 —— 这就是这个模块要两支信号脚的原因。
| 一格之内 | 顺时针转 | 逆时针转 |
|---|---|---|
| 停在格位上 | CLK HIGH,DT HIGH | CLK HIGH,DT HIGH |
| 第 1 次跳变 | CLK 下降 —— DT 仍为 HIGH | DT 下降 —— CLK 仍为 HIGH |
| 第 2 次跳变 | DT 下降 —— 两路都为 LOW | CLK 下降 —— DT 已经是 LOW |
| 第 3 次跳变 | CLK 上升 —— DT 仍为 LOW | DT 上升 —— CLK 仍为 LOW |
| 第 4 次跳变 | DT 上升 —— 回到格位 | CLK 上升 —— 回到格位 |
加粗的那两行就是整个解码器。换个方向、顺着表往下读,你会看到另一半答案:一格是四次电平跳变,不是一次 —— 这正是这个模块最常见的那句抱怨的源头。下面这段讲解走的是同一套双脉冲原理,还把一对正交信号放上示波器,让你看到真实的方波:
材料清单 —— 旋钮、屏幕,以及真的够得着它们的接线
Parts list
| Item | Price | Qty | |
|---|---|---|---|
Power Supply Adapter DC Universal AC to DC Converter PSU 5V2A 5V3A 9V2A 12V2A - P.S. ADAPTOR (9V2A)PSA0902 | RM13.95 |
9V 2A 中心正极电源适配器 —— 如果你想让做好的菜单脱离电脑独立运行。9V 稳稳落在 Uno R3 圆孔电源座 7–12V 那个窗口的中间;12V 也能用,但 Uno 用的是线性稳压器,多出来的每一伏都只是白白变成热量,没有任何好处。
KY-040 和 OLED 怎么接到 Uno 上?
KY-040 和 0.96 寸 OLED 引出来的都是公针,而 Uno 板上的排座是母的,两者没办法直接对插。Sensor Shield V5.0 传感器扩展板解决的就是这件事:它压在 Uno 上,把每一支引脚重新引成一组三针公排针,丝印标着 G、V、S —— 地、五伏、信号。再用九条母对母杜邦线接到两个模块上,全程不用焊接。
平时那个替代方案 —— 面包板 —— 在这里行不通。KY-040 那排五脚排针,黑色塑料座立在板子的右侧边缘,银色的针折了九十度,横着伸出去,与 PCB 平行 —— 没有一支朝下,也就没有一支能插进面包板的孔里。给这些横着的针套上母头,是唯一的走法。
OLED 同样不用做任何准备:它那排四脚公针,出货时就已经焊在背面了。正面看上去像四个空焊孔的地方,其实是从端面看过去的焊点和针尾。母头杜邦线插上去就能接线,不用焊。
第一条线接上去之前,先确认两件事。第一,OLED 的引脚顺序是 GND、VCC、SCL、SDA —— 地在最前,跟常见的 VCC 在前正好相反 —— 而扩展板 IIC 排针的顺序是 SCL、SDA、−、+。两者是镜像的,所以一条平行排过去的排线,四根全错。一根一根地接:GND 和 VCC 接反,等于把电源反着加在屏上,可能直接把它烧掉。
第二,扩展板上那个黄色的 SEL 跳线帽,就在蓝色接线端子旁边。它出货就是插着的,而这个作品要的正是插着:它让 D0–D13 那些排针的 V 脚由 Uno 自己的 5V 供电。把它拔掉,那些 V 脚就一直没电,除非你自己从接线端子喂电进去。A0–A5 那几组和 IIC 排针无论如何都吃 Uno 的 5V,所以 SEL 少了会制造出一个很有迷惑性的故障:菜单画得好好的,旋钮却毫无反应 —— 因为屏幕有电,编码器从头到尾没电。还有,SEL 插着的时候绝对不要往接线端子送电:那等于把外部电源直接并到 Uno 的 5V 电源轨上。
| 模块引脚 | 接到扩展板的哪里 | 背后对应的 Uno 引脚 |
|---|---|---|
| KY-040 CLK | D2 那一组的 S 针 | D2(INT0) |
| KY-040 DT | D3 那一组的 S 针 | D3(INT1) |
| KY-040 SW | D4 那一组的 S 针 | D4 |
| KY-040 + | 任意一组的 V 针 | 5V |
| KY-040 GND | 任意一组的 G 针 | GND |
| OLED GND | IIC 排针的 − | GND |
| OLED VCC | IIC 排针的 + | 5V |
| OLED SCL | IIC 排针的 SCL | A5 |
| OLED SDA | IIC 排针的 SDA | A4 |
在 Uno 上,A4 和 A5 就是 I2C 总线;扩展板的 IIC 排针只是把它们落得更整齐的一个地方。

编码器的轴是裸露的 6 mm D 形轴,所以任何 6 mm D 轴旋钮都能直接压上去。

菜单为什么一格跳两项或四项?
KY-040 的一格不等于一个脉冲。这个旋钮转一圈有 20 格,而一格正好走完一个完整的正交周期 —— 也就是上面那张表里的四次跳变。这类编码器就是按这个配对关系来选型的:Bourns 的 PEC11R 数据手册把定位选项列成 “1 = 18 Detents (18 pulses)” 和 “2 = 24 Detents (12, 24 pulses)” —— 要么一格一个完整的正交周期,要么在减半的型号上两格才走完一个周期。所谓“每转 360° 多少脉冲”,数的是单独一路通道,所以 KY-040 的 20 格,在 CLK 上是 20 个脉冲,在 DT 上也是 20 个。
两路通道上的每一个边沿都数,一格就是四;只数一路通道的双边沿,是二。只数 CLK 的下降沿 —— 也就是下面这段 sketch 的做法 —— 一格正好是一。编码器从来没坏过,坏的是数数的模型。
另一个把计数撑大的东西是触点抖动。这是机械触片,同一份 Bourns 数据手册把抖动规定为最大 2.0 ms,所以一格可以往引脚上甩出好几个多余的边沿。解法是在中断处理函数里放一个 micros() 时间戳,把距离上一个被接受的边沿不到 2 ms 的边沿一律拒掉。必须用 micros():millis() 是靠它自己那个定时器中断往前走的,而你的处理函数占着 CPU 的时候,那个中断根本跑不起来。里面也绝对不要 delay() —— 中断是关着的,它会把 I2C 传输、连同下一个边沿一起卡死。
CLK、DT、SW 三支脚都要声明成 INPUT_PULLUP。KY-040 的触点是往地上切的,所以没有上拉的话,触点一断开引脚就悬空,读到的全是噪声。ATmega328P 数据手册给出的内部上拉是 20–50 kΩ;在模块自己也带了 10 kΩ 上拉的版本上,两者并联,仍然把引脚稳稳按在 HIGH 上,而在没带上拉的版本上,内部上拉就是唯一干这件事的。两种情况都成立。
为什么一刷新屏幕,旋钮就漏格?
Adafruit_SSD1306 把整屏画面存在 RAM 里,然后一口气推出去。它申请的是 WIDTH × ((HEIGHT + 7) / 8) 字节 —— 对一块 128×64 的屏来说就是 128 × 8 = 1024 字节 —— 而 display() 会把这 1024 字节全部经 I2C 送出去。库在自己传输时会把总线拉到 400 kHz,而 I2C 上每一个字节要花九个位周期:八个数据位,加上屏幕回过来的那一个应答位。于是 1024 × 9 = 9216 个位周期,在 400 kHz 下就是 23 ms。再加上库每传 32 字节要花掉的一次重启和地址字节,一次全屏重绘会让 Uno 停住大约 25 ms。这段时间里,loop() 根本没在跑。只有中断进得来。
在你自己的板子上量一量:
unsigned long t = micros();
display.display();
Serial.println(micros() - t); // 一次全屏重绘花掉的微秒数
这块缓冲吃掉的不只是时间,还有内存,而且它藏得很深:1024 字节正好是 ATmega328P 那 2 KB SRAM 的一半,但它是在 display.begin() 里运行时才申请的,所以它从来不会出现在 IDE 编译完打印的那个数字里。下面这段 sketch 报出来的全局变量是 543 字节 —— 直到 begin() 又拿走 1024 字节,真实数字变成 2048 里的大约 1567。菜单标签之所以要靠 PROGMEM 和 strcpy_P 住在 flash 里,原因就在这:在这颗芯片上,你不能为了打印四个词就掏 RAM 出来存它们。
所以数格数这件事要搬进中断。D2 和 D3 是 Uno 仅有的两支外部中断脚(INT0 和 INT1),所以把 CLK 放在 D2 上不是随手安排的 —— 上面那张引脚表长成那样,原因就在这里。挂在 D2 上的中断服务程序会在传输进行当中触发,所以落进盲区的那一格照样被记下来。之后 loop() 再拿计数跟屏幕上显示的比,只有对不上时才重绘:快速转十格,你得到的重绘次数远少于十次,但最后停的位置是对的。
这段菜单 sketch 到底长什么样?
一个菜单就是一台很小的状态机,状态只有三样:menuIndex 记光标停在哪一行,一个 bool editing 记旋钮当前接在什么上,还有一个 values[] 数组放那些数值。浏览时旋钮改的是 menuIndex,编辑时改的是 values[menuIndex];按下轴上的开关就在两者之间来回切。这套模式可以直接移植到任何带设置项的东西上 —— 比如这个菜单一开始就是为了设它而生的 DS3231 时钟,或者键盘密码锁的那几个超时时间。
// 旋转编码器 + OLED 可滚动菜单,Arduino Uno。
// 接线:KY-040 CLK -> D2(INT0),DT -> D3,SW -> D4,+ -> 5V,GND -> GND。
// OLED SCL -> A5,SDA -> A4,VCC -> 5V,GND -> GND。
// 关键就一句:在中断里数,只有计数变了才重绘。
#include <Wire.h>
#include <Adafruit_GFX.h>
#include <Adafruit_SSD1306.h>
#define SCREEN_WIDTH 128
#define SCREEN_HEIGHT 64
#define OLED_RESET -1
#define OLED_ADDR 0x3C // 丝印写的是 0x78,那是 8 位形式,0x78 >> 1 = 0x3C
Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET);
const uint8_t PIN_CLK = 2; // INT0 - Uno 仅有的两支外部中断脚之一
const uint8_t PIN_DT = 3;
const uint8_t PIN_SW = 4;
const uint8_t ITEMS = 4;
// 标签住在 flash 里,不占 SRAM - 帧缓冲已经吃掉一半 RAM 了。
const char item0[] PROGMEM = "Hour";
const char item1[] PROGMEM = "Minute";
const char item2[] PROGMEM = "Brightness";
const char item3[] PROGMEM = "Beep";
const char *const MENU[ITEMS] PROGMEM = {item0, item1, item2, item3};
const int8_t VAL_MIN[ITEMS] PROGMEM = {0, 0, 0, 0};
const int8_t VAL_MAX[ITEMS] PROGMEM = {23, 59, 15, 1};
int8_t values[ITEMS] = {7, 30, 8, 1};
uint8_t menuIndex = 0;
bool editing = false;
volatile int8_t encDelta = 0; // loop() 上次来看过之后又数到的格数
volatile uint32_t lastEdgeUs = 0;
// CLK 每来一个下降沿就跑一次。一格 = 一个完整的正交周期,
// 而一个周期里 CLK 的下降沿正好只有一个,所以这里一格触发一次。
void onClkFalling() {
uint32_t now = micros();
if (now - lastEdgeUs < 2000UL) return; // 触点抖动:2 ms 内的杂讯一律忽略
lastEdgeUs = now;
if (digitalRead(PIN_DT)) encDelta++; // CLK 下降时 DT 还是 HIGH
else encDelta--; // DT 早就已经变成 LOW 了
}
// 按下时触发一次,松开不触发,抖动也不触发。
bool buttonPressed() {
static bool wasDown = false;
static uint32_t lastChangeMs = 0;
bool down = (digitalRead(PIN_SW) == LOW); // INPUT_PULLUP:按下读到的是 LOW
if (down != wasDown && millis() - lastChangeMs > 25UL) {
lastChangeMs = millis();
wasDown = down;
return down;
}
return false;
}
// 最昂贵的那个调用。上面所有东西存在的意义,就是让它跑得尽量少。
void drawMenu() {
char label[16]; // 必须放得下最长的标签,外加结束符
display.clearDisplay();
display.setTextSize(1);
for (uint8_t i = 0; i < ITEMS; i++) {
uint8_t y = 4 + i * 15;
strcpy_P(label, (char *)pgm_read_ptr(&MENU[i]));
display.setCursor(0, y);
if (i != menuIndex) display.print(F(" ")); // 没被选中
else if (editing) display.print(F("* ")); // 旋钮正在改这个值
else display.print(F("> ")); // 旋钮在移动光标
display.print(label);
display.setCursor(98, y);
display.print(values[i]);
}
display.display(); // 约 25 ms 的 CPU 停顿 - 中断存在的理由
}
void setup() {
pinMode(PIN_CLK, INPUT_PULLUP);
pinMode(PIN_DT, INPUT_PULLUP);
pinMode(PIN_SW, INPUT_PULLUP);
Serial.begin(9600);
if (!display.begin(SSD1306_SWITCHCAPVCC, OLED_ADDR)) {
Serial.println(F("No SSD1306 at 0x3C - check SDA/SCL and 5V"));
for (;;) { }
}
display.setTextColor(SSD1306_WHITE);
attachInterrupt(digitalPinToInterrupt(PIN_CLK), onClkFalling, FALLING);
drawMenu();
}
void loop() {
bool changed = false;
// encDelta 只有一个字节,但清零它是一次读-改-写,所以要保护起来。
noInterrupts();
int8_t steps = encDelta;
encDelta = 0;
interrupts();
if (steps != 0) {
if (editing) {
int8_t lo = (int8_t)pgm_read_byte(&VAL_MIN[menuIndex]);
int8_t hi = (int8_t)pgm_read_byte(&VAL_MAX[menuIndex]);
int16_t v = (int16_t)values[menuIndex] + steps;
if (v < lo) v = lo; // 数值要夹紧,不要绕回
if (v > hi) v = hi;
values[menuIndex] = (int8_t)v;
} else {
int16_t i = (int16_t)menuIndex + steps;
while (i < 0) i += ITEMS; // 光标两个方向都绕回
menuIndex = (uint8_t)(i % ITEMS);
}
changed = true;
}
if (buttonPressed()) {
editing = !editing;
changed = true;
}
if (changed) drawMenu(); // 而且只有这时候
}
从库管理器装上 Adafruit SSD1306 和 Adafruit GFX Library;这段 sketch 编译出来占 15,342 字节 flash。这里刻意没有用任何编码器库 —— 上面那个处理函数一共九行。注意 loop() 里的 noInterrupts() 保护:在 AVR 上读一个字节是原子的,但先读它再把它清零是两个动作,夹在中间到来的那一格会被悄悄丢掉。
照搬之前先知道一个限制:四行、行距 15 像素,正好是一块 64 像素高的屏装得下的量,所以这里其实还没有真正滚动 —— 第五项会落在 y = 64,掉到玻璃下缘之外。超过四项就得画一个窗口:留一个 topIndex,从它开始画四行,只有当 menuIndex 走出边界时才移动它。

我们常见到的错误
菜单一格前进两项或四项。几乎都是数数的模型对每一个边沿、或者对某一路通道的双边沿都做了反应。只数 CLK 的下降沿。
按键读起来像是一直按着。KY-040 的开关只是把 SW 短到地,其他时候什么都不做,所以用普通的 INPUT 会让引脚悬空,sketch 就看到一堆幽灵按键。改用 INPUT_PULLUP,并把 LOW 当作按下。
旋钮只在屏幕刷新的那一下“漏格”。特征很明显:慢慢转完全正常,转快了就不行。编码器没有任何问题 —— 是那 25 ms 的重绘把轮询的循环堵住了。把数格数搬进中断里。
屏幕一片空白。先用 9600 打开串口监视器 —— 如果显示屏从来没应答过,sketch 会把这件事说出来,然后停住,这也是板子看起来像死了的原因。通常不是扩展板 IIC 排针上的 SCL 和 SDA 接反了,就是 sketch 里写成了 0x78 而不是 0x3C —— 显示屏那一侧,我们的 OLED 监测教程讲得更细。
常见问题
KY-040 旋转编码器该接 Arduino 的哪几支引脚?
CLK 接 D2,DT 接 D3,SW 接 D4,+ 接 5V,GND 接 GND。D2 和 D3 是 Uno 仅有的两支外部中断脚,所以想让计数撑过一次屏幕重绘,CLK 就该落在其中一支上。
旋转编码器为什么一格跳两项或四项菜单?
一格是一个完整的正交周期,也就是 CLK 和 DT 上一共四次电平跳变。四次全都反应,一格就数到四;只对一路通道的双边沿反应,是二。只对 CLK 的下降沿反应,一格正好是一。
KY-040 需要装库吗?
不需要。两支 INPUT_PULLUP 引脚、一个挂在 CLK 下降沿上的 attachInterrupt,再加上处理函数里对 DT 的一次 digitalRead,就是一个完整的解码器。倒是显示屏那边要装 Adafruit SSD1306 和 Adafruit GFX。
0.96 寸 OLED 的 I2C 地址是 0x78 还是 0x3C?
都是,只是写法不同。板上丝印印的是 0x78,那是包含读写位的 8 位形式;Arduino 的 Wire 库要的是 7 位形式,所以右移一位:0x78 >> 1 = 0x3C。跳线换到另一个位置是 0x7A,写进 sketch 就是 0x3D。
做好的菜单能不接电脑独立运行吗?
可以 —— 往 Uno 的圆孔电源座插一个 9V 中心正极适配器就行。Uno R3 在那里接受 7–12V,而编码器和 OLED 加起来,也只从板子自己的 5V 电源轨上取几十毫安。
最后更新:2026年8月。有问题?WhatsApp 联系我们。



Rotary Encoder KY-040 360 Degree Encoder Sensor Module High Precision Knob
0.96‘ OLED Display Module Blue White Screen I2C IIC Serial 128X64 LCD Display Board - 0.96' OLED WHITE
Arduino Uno Compatible SMD UNO R3 with Type B Cable - ATMEGA328P with CH340G-Microcontroller Project
Sensor Shield V5.0 Arduino Uno R3 Shield For Arduino Protoype Application
40pcs Dupont Wire 10cm 20cm 30cm for Breadboard DIY Experiment Jumper Wire Breadboard wire - DUPONT WIRE F-F 20CM
Power Supply Adapter DC Universal AC to DC Converter PSU 5V2A 5V3A 9V2A 12V2A - P.S. ADAPTOR (9V2A)