Arduino 旋转编码器 + OLED 滚动菜单教程

Cartoon of a hand turning the bare silver shaft of a black KY-040 rotary encoder wired to a blue 0.96-inch OLED showing an abstract four-row menu, both connected to a blue Arduino Uno with a blue sensor shield stacked on it

把 KY-040 的 CLK 接到 D2,在中断里数格数(detent),并且只有当计数真的变了才去调用 display()。最后那一条就是整篇文章的重点:一次 128×64 的 OLED 全屏重绘,会让 Arduino Uno 停住大约 25 ms,而手指一记快速拨动,好几格就埋在这段空档里。

旋转编码器为什么要两支输出脚,一支不行吗?

KY-040 那只银色金属罐里装着两组开关触点,引出来就是 CLKDT,两者刻意错开,让其中一组比另一组早四分之一个周期变化。单看 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

ItemPriceQty
Power Supply Adapter DC Universal AC to DC Converter PSU 5V2A 5V3A 9V2A 12V2A - P.S. ADAPTOR (9V2A)Power Supply Adapter DC Universal AC to DC Converter PSU 5V2A 5V3A 9V2A 12V2A - P.S. ADAPTOR (9V2A)PSA0902RM13.95

9V 2A 中心正极电源适配器 —— 如果你想让做好的菜单脱离电脑独立运行。9V 稳稳落在 Uno R3 圆孔电源座 7–12V 那个窗口的中间;12V 也能用,但 Uno 用的是线性稳压器,多出来的每一伏都只是白白变成热量,没有任何好处。

KY-040 和 OLED 怎么接到 Uno 上?

KY-0400.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 排针只是把它们落得更整齐的一个地方。

卡通特写对比:左边是 KY-040 黑色的弯针排针,银色的针横着伸出去;右边是蓝色 OLED 背面那排黑色四脚排针,银色的针朝上竖着;两边各插着一个黑色的母头杜邦胶壳
左边:KY-040 的针折了九十度横着伸出去,所以什么都插不进面包板。右边:OLED 那排四脚排针出货时就已经焊在背面。两边要的都是母头。

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

卡通图:一块黑色的 KY-040 旋转编码器模块,绿色底座上是银色金属编码器本体,轴是裸露的银色 D 形轴,五支银色排针横着折出去,旁边散放着一颗银色六角螺母和一片平垫圈
袋子里有什么:模块本身,连着裸露的银色 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。菜单标签之所以要靠 PROGMEMstrcpy_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 SSD1306Adafruit GFX Library;这段 sketch 编译出来占 15,342 字节 flash。这里刻意没有用任何编码器库 —— 上面那个处理函数一共九行。注意 loop() 里的 noInterrupts() 保护:在 AVR 上读一个字节是原子的,但先读它再把它清零是两个动作,夹在中间到来的那一格会被悄悄丢掉。

照搬之前先知道一个限制:四行、行距 15 像素,正好是一块 64 像素高的屏装得下的量,所以这里其实还没有真正滚动 —— 第五项会落在 y = 64,掉到玻璃下缘之外。超过四项就得画一个窗口:留一个 topIndex,从它开始画四行,只有当 menuIndex 走出边界时才移动它。

卡通图:两块蓝色 OLED 屏并排,显示一个抽象的四行菜单;左边那块有一个三角形标记指着其中一行,右边那块把同一行的数值框了起来表示正在编辑,中间是一只银色旋转编码器和一支青色箭头
左边是浏览模式:旋钮带着标记在行与行之间上下走。按一下轴,就进入右边的编辑模式,同一个旋钮改的变成被框起来的那个数值。

我们常见到的错误

菜单一格前进两项或四项。几乎都是数数的模型对每一个边沿、或者对某一路通道的双边沿都做了反应。只数 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 联系我们

Leave a Reply

Your email address will not be published. Required fields are marked *