Компоненты · 12 мин чтения

Энкодер и Arduino: подключение KY-040, прерывания и код меню

Как подключить поворотный энкодер к Arduino: схема KY-040, квадратурное декодирование A/B, прерывания без пропуска шагов, антидребезг, меню на LCD, PCNT в ESP32 и Pico.

Поворотный энкодер — это цифровая ручка управления для проектов на Arduino: она вращается бесконечно, отсчитывает один шаг на каждый щелчок и работает как кнопка, если нажать на вал. Подключённый к Arduino энкодер ведёт себя как ручка меню 3D-принтера, регулятор осциллографа или ручка громкости в машине: он знает, на сколько щелчков и в какую сторону повёрнута ручка, и никогда не упирается в ограничитель. В большинстве наборов для хобби встречается пятипиновый модуль KY-040.

В этом руководстве ты узнаешь, чем KY-040 отличается от потенциометра, как направление декодируется по фазам A и B (квадратура), как подавить дребезг контактов аппаратно и программно и как с помощью прерываний посчитать каждый щелчок. Затем пошагово разберём быстрый путь с библиотекой Encoder, навигацию по меню на LCD и OLED, аппаратный счётчик PCNT в ESP32 и код на MicroPython для Raspberry Pi Pico.

Что такое поворотный энкодер и как он работает?

Внутри механического энкодера вроде KY-040 есть зубчатый контактный диск, который вращается вместе с валом, и два маленьких переключателя, скользящих по нему. При вращении вала оба переключателя по очереди замыкаются и размыкаются и формируют прямоугольные импульсы на пинах CLK (A) и DT (B). Две волны сдвинуты на четверть периода, то есть на 90 градусов; такой сигнал называется квадратурным. Микроконтроллер определяет направление по тому, какая волна меняется первой, а величину поворота — по количеству изменений. Энкодер не помнит своё положение, он сообщает только о движении. Поэтому KY-040 — это инкрементальный энкодер: при включении питания положение равно нулю, а весь подсчёт целиком ложится на твой код.

ПараметрЗначение для KY-040
ТипМеханический, инкрементальный, выходы A/B
Фиксации (щелчки)20 щелчков на оборот (у некоторых энкодеров 30)
Импульсы20 полных циклов A/B на оборот, то есть 80 фронтов
Угловое разрешение18 градусов на щелчок
ПиныCLK (A), DT (B), SW (кнопка), + (питание), GND
Питание3,3–5 V (питает только подтягивающие резисторы)
Встроенные резисторыПодтяжка 10 kΩ на CLK и DT; резистор для SW распаян не на каждом модуле
КнопкаНормально разомкнутая; нажатие на вал соединяет SW с GND (активный LOW)
ВращениеБез ограничений, нет упора

Энкодер или потенциометр: в чём разница?

На первый взгляд энкодер очень похож на потенциометр: у обоих есть вращающийся вал, и оба служат для настройки значения. Но работают они совершенно по-разному. Потенциометр — это делитель напряжения; в зависимости от положения вала он выдаёт аналоговое напряжение от 0 до 5 V, которое читается через analogRead. Энкодер вместо напряжения выдаёт импульсы; он сообщает об изменении, а не о положении.

Потенциометр для сравнения с поворотным энкодером
Потенциометр: его поворот ограничен примерно 270–300 градусами, а положение он передаёт аналоговым напряжением. Поворотный энкодер вращается без упора и выдаёт цифровые импульсы.
ПараметрПотенциометрПоворотный энкодер (KY-040)
ВращениеПримерно 270–300 градусов, ограниченоБез ограничений, непрерывно 360 градусов
ВыходАналоговое напряжение (0–5 V)Цифровые импульсы (A и B)
ЧтениеanalogRead (АЦП)digitalRead или подсчёт с прерываниями
ПоложениеАбсолютное; положение вала даёт значение даже после отключения питанияОтносительное; при включении положение неизвестно
Ощущение щелчковНет, вращается плавноЕсть, обычно 20 щелчков на оборот
КнопкаНетЕсть (нажатие на вал)
Типичная проблемаТреск и уплывающие со временем значенияДребезг контактов (решается программно)
Типичное применениеГромкость, угол сервопривода, аналоговая регулировкаНавигация по меню, пошаговая настройка чисел, панели приборов

Короче говоря, если значение должно один к одному соответствовать физическому положению вала (поворот рычага сервопривода вручную или простая регулировка громкости), потенциометр проще. Если нужно перемещаться по меню, точно по шагам увеличивать и уменьшать число, менять несколько настроек одной ручкой или ещё и менять значение из программы, энкодер гораздо гибче. Например, если значение 50 с телефона изменить на 80, то при вращении энкодера отсчёт просто продолжится с 80; у потенциометра же положение ручки и значение на экране перестанут совпадать.

Фазы A и B: как работает квадратурное декодирование

Когда KY-040 стоит в положении фиксации, оба переключателя разомкнуты, и благодаря подтягивающим резисторам на CLK и DT читается HIGH (1). Когда поворачиваешь ручку на один щелчок, обе линии по очереди падают в LOW и затем по очереди возвращаются в HIGH. По часовой стрелке первым падает CLK, против часовой — DT. Эта последовательность из четырёх состояний — код Грея: на каждом шаге меняется только один бит. В таблице ниже показано, что читают два пина за один щелчок:

ШагПо часовой стрелке (CLK, DT)Против часовой стрелки (CLK, DT)
Покой в положении фиксации1, 11, 1
1-й переход0, 11, 0
2-й переход0, 00, 0
3-й переход1, 00, 1
4-й переход (следующий щелчок)1, 11, 1

Из таблицы следуют два простых правила. Первое: если в момент спада CLK линия DT ещё в HIGH, ручка вращается по часовой стрелке; если DT уже в LOW — против часовой. Второе: в одном щелчке четыре перехода, значит, 20 щелчков на оборот дают 80 переходов. Простой код использует только первое правило и считает один раз за щелчок. Надёжный код отслеживает все четыре перехода и поэтому может отсеивать дребезг или незавершённые движения. Какое направление считается «по часовой», зависит от модуля и подключения; если счётчик идёт в обратную сторону, достаточно поменять местами пины CLK и DT в коде.

Распиновка и подключение KY-040

Пин KY-040НазначениеArduino UnoESP32Raspberry Pi Pico
CLKФаза AD2GPIO 25GP16
DTФаза BD3GPIO 26GP17
SWКнопка (LOW при нажатии)D4GPIO 27GP18
+Питание5V3V33V3(OUT)
GNDЗемляGNDGNDGND
На ESP32 и Raspberry Pi Pico всегда подключай пин + модуля к 3,3 V. Подтягивающие резисторы KY-040 идут к пину +, поэтому при питании модуля от 5 V линии CLK и DT подтягиваются к 5 V, и предел входов на 3,3 V превышается. Энкодер — это всего лишь переключатели, так что на 3,3 V он работает отлично.

На Arduino Uno и Nano аппаратные прерывания есть только на пинах D2 и D3, поэтому CLK и DT лучше всего подключать именно к ним. На Mega пины прерываний — 2, 3, 18, 19, 20 и 21; на Leonardo — 0, 1, 2, 3 и 7. На ESP32 и Pico прерывание может вызвать почти любой GPIO. Карту пинов своей платы можно посмотреть в утилите распиновки. Для пина SW в коде используй INPUT_PULLUP: на некоторых модулях на этой линии нет резистора, а «висящий» пин читается так, будто кнопку нажимают случайным образом. У ESP32 пины GPIO 34–39 работают только на вход и не имеют встроенной подтяжки, поэтому не используй их для SW.

Первая проверка шаг за шагом: чтение опросом

  1. Подключи пин CLK модуля KY-040 к D2, DT — к D3, а SW — к D4.
  2. Подключи пин + к 5V, а GND — к GND Arduino.
  3. Вставь код ниже в Arduino IDE и загрузи его в плату.
  4. Открой монитор порта и выставь скорость 115200 бод.
  5. Поверни ручку на несколько щелчков в обе стороны, затем нажми на вал, чтобы сбросить счётчик.
C++ (Arduino IDE)
1// KY-040: CLK -> D2, DT -> D3, SW -> D4, + -> 5V, GND -> GND
2const byte PIN_CLK = 2;
3const byte PIN_DT  = 3;
4const byte PIN_SW  = 4;
5
6int counter = 0;
7int lastClk;
8
9void setup() {
10  pinMode(PIN_CLK, INPUT_PULLUP);   // modülde 10k pull-up zaten var / the module already has 10k pull-ups
11  pinMode(PIN_DT, INPUT_PULLUP);
12  pinMode(PIN_SW, INPUT_PULLUP);    // basınca LOW okunur / reads LOW when pressed
13  Serial.begin(115200);
14  lastClk = digitalRead(PIN_CLK);
15}
16
17void loop() {
18  int clk = digitalRead(PIN_CLK);
19  if (clk != lastClk && clk == LOW) {      // CLK düşen kenar / CLK falling edge
20    if (digitalRead(PIN_DT) == HIGH) {     // DT hâlâ HIGH: saat yönü / DT still HIGH: clockwise
21      counter++;
22      Serial.print("CW  ");
23    } else {                               // DT zaten LOW: ters yön / DT already LOW: counter-clockwise
24      counter--;
25      Serial.print("CCW ");
26    }
27    Serial.println(counter);
28  }
29  lastClk = clk;
30
31  if (digitalRead(PIN_SW) == LOW) {        // mile basıldı / shaft pressed
32    delay(20);                             // buton sıçramasını bekle / wait out the button bounce
33    if (digitalRead(PIN_SW) == LOW) {
34      counter = 0;
35      Serial.println("Sifirlandi / reset");
36      while (digitalRead(PIN_SW) == LOW) {}   // bırakılmasını bekle / wait for release
37    }
38  }
39}

При каждом проходе loop код читает CLK и ловит только момент, когда CLK падает из HIGH в LOW. Если в этот момент DT ещё в HIGH, ручка повернулась по часовой стрелке; если в LOW — против часовой. Это самый понятный способ, но у него два слабых места. Во-первых, импульсы, пришедшие, пока loop занят другой работой (обновлением дисплея, delay, чтением датчиков), теряются, и при быстром вращении счётчик отстаёт. Во-вторых, механические контакты дребезжат: за одно падение CLK может несколько раз переключиться LOW-HIGH, и код может засчитать один щелчок как два или три. Следующие два раздела решают обе проблемы.

Дребезг контактов (антидребезг): аппаратные и программные решения

Металлические пружинки внутри энкодера, коснувшись контакта, несколько раз отскакивают, прежде чем успокоиться. В даташитах производителей это время обычно указано в несколько миллисекунд (например, максимум 2 мс). У кнопок дребезг устраняют правилом «подождать 20–50 мс после последнего изменения», но для энкодера это не годится: при быстром вращении между двумя фронтами проходит всего несколько миллисекунд, и долгое ожидание проглатывает и настоящие шаги. Вместо этого используют такие методы:

  • Аппаратный фильтр: поставь конденсатор 10–100 nF между CLK и GND и ещё один между DT и GND. Вместе с подтяжками модуля на 10 kΩ они образуют RC-фильтр; постоянная времени лежит между 10 kΩ × 10 nF = 0,1 мс и 10 kΩ × 100 nF = 1 мс, и этого хватает, чтобы проглотить короткий дребезг. Рассчитать её можно в утилите постоянной времени RC, а расшифровать конденсатор с маркировкой 103 или 104 — в утилите кодов конденсаторов.
  • Таблица состояний: на каждом фронте обеих линий старое и новое состояние AB сравниваются. Дребезг — это прыжки туда-обратно между одними и теми же двумя состояниями, которые по очереди дают +1 и −1 и взаимно гасятся. Переходы, при которых оба бита меняются одновременно, считаются недопустимыми и пропускаются.
  • Решение только в положении фиксации: четвертьшаги накапливаются, а счётчик увеличивается или уменьшается, только когда энкодер встаёт в фиксацию (обе линии в HIGH). Так дрожащий на щелчке контакт не заставляет курсор меню прыгать.
  • Блокировка для кнопки: пин SW ведёт себя как обычная кнопка; хватит блокировки на 30 мс с помощью millis().

Чтение с прерываниями, чтобы не терять ни одного шага

Прерывание заставляет процессор в момент изменения пина бросить текущую работу и выполнить короткую функцию (ISR). Если линии энкодера привязаны к прерываниям, ни один фронт не теряется, даже если в loop есть delay на 50 мс или дисплей обновляется медленно. Код ниже срабатывает на обоих фронтах CLK и DT, использует описанную выше таблицу состояний и считает, только когда ручка встаёт в фиксацию. Основы описаны также в статье Прерывания в Arduino.

C++ (Arduino IDE)
1// KY-040: CLK -> D2 (INT0), DT -> D3 (INT1), SW -> D4, + -> 5V
2const byte PIN_CLK = 2;
3const byte PIN_DT  = 3;
4const byte PIN_SW  = 4;
5
6// İndeks: (eski AB << 2) | yeni AB / Index: (old AB << 2) | new AB
7const int8_t QEM[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
8
9volatile long position = 0;   // tık sayısı, ISR ile loop paylaşır / detent count shared by ISR and loop
10uint8_t lastAB = 3;           // son durum, 3 = ikisi de HIGH / last state, 3 = both HIGH
11int8_t quarter = 0;           // iki tık arası çeyrek adımlar / quarter steps between detents
12
13void readEncoder() {
14  uint8_t ab = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
15  quarter += QEM[(lastAB << 2) | ab];
16  lastAB = ab;
17  if (ab == 3) {                          // tıka oturdu / settled in a detent
18    if (quarter > 1) position++;          // saat yönü / clockwise
19    else if (quarter < -1) position--;    // ters yön / counter-clockwise
20    quarter = 0;
21  }
22}
23
24bool buttonPressed() {
25  static bool lastState = HIGH;
26  static unsigned long lastChange = 0;
27  bool state = digitalRead(PIN_SW);
28  if (state != lastState && millis() - lastChange > 30) {   // 30 ms kilit / 30 ms lockout
29    lastChange = millis();
30    lastState = state;
31    return state == LOW;                  // yalnızca basış anı / only the moment of pressing
32  }
33  return false;
34}
35
36void setup() {
37  pinMode(PIN_CLK, INPUT_PULLUP);
38  pinMode(PIN_DT, INPUT_PULLUP);
39  pinMode(PIN_SW, INPUT_PULLUP);
40  Serial.begin(115200);
41  lastAB = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
42  attachInterrupt(digitalPinToInterrupt(PIN_CLK), readEncoder, CHANGE);   // her iki kenar / both edges
43  attachInterrupt(digitalPinToInterrupt(PIN_DT), readEncoder, CHANGE);
44}
45
46long lastPosition = 0;
47
48void loop() {
49  noInterrupts();                         // 4 baytı güvenle kopyala / copy the 4 bytes safely
50  long pos = position;
51  interrupts();
52
53  if (pos != lastPosition) {
54    Serial.print(pos > lastPosition ? "CW  " : "CCW ");
55    Serial.println(pos);
56    lastPosition = pos;
57  }
58
59  if (buttonPressed()) {
60    noInterrupts();
61    position = 0;
62    interrupts();
63    Serial.println("Sifirlandi / reset");
64  }
65
66  delay(50);   // loop yavaş olsa da tık kaçmaz / no detent is lost even with a slow loop
67}
Часть кодаЧто делает?
Таблица QEM[16]Превращает старое и новое состояние AB в +1, −1 или 0; недопустимые переходы, когда обе линии меняются одновременно, считаются как 0
attachInterrupt(..., CHANGE) (на оба фронта)Запускает readEncoder() и на нарастающем, и на спадающем фронте CLK и DT
quarter (четвертьшаги)Накапливает четвертьшаги между двумя щелчками; пары +1 и −1 от дребезга взаимно гасятся
if (ab == 3) (обе линии HIGH)Решение принимается, только когда энкодер встаёт в фиксацию (обе линии в HIGH); накопленное значение +2 и больше засчитывается как один щелчок
volatile long position (общая переменная)Переменная, общая для ISR и loop; volatile не даёт компилятору использовать устаревшую копию
noInterrupts() / interrupts() (защита при копировании)Не даёт ISR вклиниться, пока копируется 4-байтовый long
buttonPressed() (кнопка)Подавляет дребезг SW блокировкой на 30 мс и возвращает true только один раз, в момент нажатия
Делай ISR как можно короче: не используй внутри неё Serial.print, delay или команды дисплея; просто обнови счётчик, а настоящую работу оставь loop. Каждую переменную, общую для ISR и loop, объявляй как volatile. У Uno 8-битный процессор, поэтому, ненадолго отключив прерывания при чтении 2- или 4-байтовой переменной, ты не прочитаешь наполовину обновлённое значение.

Быстрый путь: библиотека Encoder

Если не хочется писать свою ISR, библиотека Encoder от Paul Stoffregen делает то же самое оптимизированным кодом. Открой «Менеджер библиотек» (Library Manager) в Arduino IDE, найди «Encoder» и установи библиотеку Paul Stoffregen; шаги установки описаны в руководстве Как добавить библиотеку в Arduino. Библиотека работает лучше всего, когда оба пина — пины прерываний, хорошо, когда прерывание есть только у первого пина, переданного в конструктор, и медленно, когда его нет ни у одного. Она считает каждый фронт, поэтому значение меняется на 4 за щелчок; чтобы получить число щелчков, раздели на 4.

C++ (Arduino IDE)
1#include <Encoder.h>
2
3// Önce DT (3), sonra CLK (2): saat yönü artı sayılır / DT (3) first, then CLK (2): clockwise counts up
4Encoder knob(3, 2);
5const byte PIN_SW = 4;
6long lastDetent = 0;
7
8void setup() {
9  pinMode(PIN_SW, INPUT_PULLUP);
10  Serial.begin(115200);
11}
12
13void loop() {
14  long detent = knob.read() / 4;          // 4 kenar = 1 tık / 4 edges = 1 detent
15  if (detent != lastDetent) {
16    lastDetent = detent;
17    Serial.println(detent);
18  }
19  if (digitalRead(PIN_SW) == LOW) {       // basınca sıfırla / reset on press
20    knob.write(0);
21  }
22}

read() возвращает общее число фронтов на данный момент, write(0) сбрасывает счётчик, а readAndReset() читает и сбрасывает за один вызов. В конструктор мы передаём сначала DT, потом CLK из-за знака направления: в таком порядке вращение по часовой стрелке считается положительным. Если у тебя получается наоборот, поменяй местами 2 и 3. Встроенные подтягивающие резисторы пинов библиотека тоже включает сама.

Навигация по меню на LCD с помощью поворотного энкодера

LCD-дисплей 16x2 с I2C
LCD-дисплей 16x2 с I2C: подключается всего двумя проводами SDA и SCL; на нём отображаются настройки из примера с меню.

Меню — самое частое применение энкодеров: вращаешь ручку, чтобы переходить между строками, нажимаешь, чтобы перевести выбранную настройку в режим редактирования, вращаешь, чтобы изменить значение, и нажимаешь ещё раз, чтобы вернуться в меню. Пример ниже регулирует красный, зелёный и синий каналы RGB-светодиода от 0 до 255 и показывает значения на LCD-дисплее с I2C. Подключи LCD к A4 (SDA) и A5 (SCL), а цветовые ножки RGB-светодиода с общим катодом — к D9, D10 и D11 через резисторы 220 Ω.

C++ (Arduino IDE)
1#include <Wire.h>
2#include <LiquidCrystal_I2C.h>
3
4LiquidCrystal_I2C lcd(0x27, 16, 2);           // adres 0x27 ya da 0x3F / address 0x27 or 0x3F
5
6const byte PIN_CLK = 2, PIN_DT = 3, PIN_SW = 4;
7const byte COUNT = 3;
8const byte LED_PINS[COUNT] = {9, 10, 11};     // RGB LED, PWM pinleri / PWM pins
9const char* NAMES[COUNT] = {"Kirmizi/Red", "Yesil/Green", "Mavi/Blue"};
10int values[COUNT] = {0, 0, 0};                // 0-255 parlaklık / brightness
11
12const int8_t QEM[16] = {0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0};
13volatile int steps = 0;                       // loop'un işlemediği tıklar / detents not yet handled by loop
14uint8_t lastAB = 3;
15int8_t quarter = 0;
16
17int selected = 0;                             // seçili satır / selected item
18bool editing = false;                         // değer düzenleme modu / value edit mode
19
20void readEncoder() {
21  uint8_t ab = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
22  quarter += QEM[(lastAB << 2) | ab];
23  lastAB = ab;
24  if (ab == 3) {
25    if (quarter > 1) steps++;
26    else if (quarter < -1) steps--;
27    quarter = 0;
28  }
29}
30
31bool buttonPressed() {
32  static bool lastState = HIGH;
33  static unsigned long lastChange = 0;
34  bool state = digitalRead(PIN_SW);
35  if (state != lastState && millis() - lastChange > 30) {
36    lastChange = millis();
37    lastState = state;
38    return state == LOW;
39  }
40  return false;
41}
42
43void drawMenu() {
44  int top = selected > COUNT - 2 ? COUNT - 2 : selected;   // görünen ilk satır / first visible row
45  for (byte row = 0; row < 2; row++) {
46    int i = top + row;
47    char mark = ' ';
48    if (i == selected) mark = editing ? '*' : '>';
49    char line[17];
50    snprintf(line, sizeof(line), "%c%-11s%4d", mark, NAMES[i], values[i]);
51    lcd.setCursor(0, row);
52    lcd.print(line);
53  }
54}
55
56void setup() {
57  pinMode(PIN_CLK, INPUT_PULLUP);
58  pinMode(PIN_DT, INPUT_PULLUP);
59  pinMode(PIN_SW, INPUT_PULLUP);
60  for (byte i = 0; i < COUNT; i++) pinMode(LED_PINS[i], OUTPUT);
61  lcd.init();
62  lcd.backlight();
63  lastAB = (digitalRead(PIN_CLK) << 1) | digitalRead(PIN_DT);
64  attachInterrupt(digitalPinToInterrupt(PIN_CLK), readEncoder, CHANGE);
65  attachInterrupt(digitalPinToInterrupt(PIN_DT), readEncoder, CHANGE);
66  drawMenu();
67}
68
69void loop() {
70  noInterrupts();
71  int s = steps;                              // biriken tıkları al / take the pending detents
72  steps = 0;
73  interrupts();
74
75  if (s != 0) {
76    if (editing) {
77      values[selected] = constrain(values[selected] + s * 5, 0, 255);   // tık başına 5 / 5 per detent
78      analogWrite(LED_PINS[selected], values[selected]);
79    } else {
80      selected = constrain(selected + s, 0, COUNT - 1);
81    }
82    drawMenu();
83  }
84
85  if (buttonPressed()) {                      // bas: düzenle ya da bitir / press: edit or finish
86    editing = !editing;
87    drawMenu();
88  }
89}

В начале выбранной строки появляется знак >, а в режиме редактирования — знак *. Код энкодера такой же, как в предыдущем примере; единственное отличие в том, что ISR накапливает не положение, а число необработанных щелчков (steps), а loop читает его и обнуляет. Так что даже если loop какое-то время занят, ни один щелчок не теряется: накопленные обрабатываются все вместе на следующем проходе. В режиме редактирования каждый щелчок меняет значение на 5; для более тонкой настройки можно уменьшить шаг до 1 или добавить ускорение, которое увеличивает шаг при быстром вращении ручки.

Чтобы показать то же меню на OLED-дисплее, достаточно изменить функцию drawMenu(). Версия ниже рисует выбранную строку инверсными цветами на белой полосе. Вместо библиотеки LCD подключи библиотеки Adafruit SSD1306 и Adafruit GFX, удали объект lcd и в setup() вызови display.begin() вместо lcd.init().

C++ (Arduino IDE)
1// LCD menü kodunda değişecek kısımlar / parts to change in the LCD menu sketch
2#include <Adafruit_GFX.h>
3#include <Adafruit_SSD1306.h>
4
5Adafruit_SSD1306 display(128, 64, &Wire, -1);
6// setup() içinde lcd.init() ve lcd.backlight() yerine / in setup() instead of lcd.init() and lcd.backlight():
7//   display.begin(SSD1306_SWITCHCAPVCC, 0x3C);
8
9void drawMenu() {
10  display.clearDisplay();
11  display.setTextSize(1);
12  for (byte i = 0; i < COUNT; i++) {
13    int y = i * 16;
14    if (i == selected) {
15      display.fillRect(0, y, 128, 14, SSD1306_WHITE);   // seçili satır çubuğu / highlight bar
16      display.setTextColor(SSD1306_BLACK);
17    } else {
18      display.setTextColor(SSD1306_WHITE);
19    }
20    display.setCursor(4, y + 3);
21    display.print(NAMES[i]);
22    display.setCursor(98, y + 3);
23    display.print(values[i]);
24    if (i == selected && editing) display.print('*');   // düzenleme işareti / edit marker
25  }
26  display.display();
27}
Если не хочется, чтобы настройки терялись при отключении питания, записывай значение в EEPROM при выходе из режима редактирования и читай его обратно при запуске. У EEPROM ограниченное число циклов записи, поэтому сохраняй только по нажатию кнопки, а не на каждом щелчке. Долгое нажатие в роли команды «назад» — тоже простой способ строить более глубокие меню.

Поворотный энкодер и ESP32: аппаратный счётчик PCNT

В ESP32 есть аппаратный блок PCNT (Pulse Counter, счётчик импульсов), который считает импульсы, совсем не нагружая процессор. У каждого блока два канала, поэтому он может напрямую декодировать сигнал A/B; в классическом ESP32 8 блоков PCNT, в ESP32-S2 и S3 — 4. Счётчик 16-битный и включает фильтр помех (glitch filter), убирающий короткие помехи. Этот фильтр можно настроить максимум примерно на 12,8 микросекунды, поэтому он проглатывает только очень короткие всплески, а не весь дребезг контактов. Подсчёт по четырём фронтам сам уравновешивает остаток дребезга: колебания туда-обратно гасят друг друга как +1 и −1. Код ниже использует драйвер ESP-IDF, который входит в ядро Arduino ESP32 версии 3.x.

C++ (Arduino IDE)
1#include "driver/pulse_cnt.h"
2#include "driver/gpio.h"
3
4// KY-040: CLK -> GPIO 25, DT -> GPIO 26, SW -> GPIO 27, + -> 3V3
5const int PIN_CLK = 25;
6const int PIN_DT  = 26;
7const int PIN_SW  = 27;
8
9pcnt_unit_handle_t unit = NULL;
10
11void setup() {
12  Serial.begin(115200);
13  pinMode(PIN_SW, INPUT_PULLUP);
14
15  pcnt_unit_config_t unitCfg = {};
16  unitCfg.low_limit = -10000;                 // sınıra varınca sayaç 0'a döner / the counter returns to 0 at a limit
17  unitCfg.high_limit = 10000;
18  ESP_ERROR_CHECK(pcnt_new_unit(&unitCfg, &unit));
19
20  pcnt_glitch_filter_config_t filterCfg = {};
21  filterCfg.max_glitch_ns = 1000;             // 1 µs'den kısa iğneleri yok say / ignore spikes shorter than 1 µs
22  ESP_ERROR_CHECK(pcnt_unit_set_glitch_filter(unit, &filterCfg));
23
24  pcnt_chan_config_t aCfg = {};
25  aCfg.edge_gpio_num = PIN_CLK;               // A kanalı: CLK kenarları, DT seviyesi / channel A: CLK edges, DT level
26  aCfg.level_gpio_num = PIN_DT;
27  pcnt_channel_handle_t chA = NULL;
28  ESP_ERROR_CHECK(pcnt_new_channel(unit, &aCfg, &chA));
29
30  pcnt_chan_config_t bCfg = {};
31  bCfg.edge_gpio_num = PIN_DT;                // B kanalı: DT kenarları, CLK seviyesi / channel B: DT edges, CLK level
32  bCfg.level_gpio_num = PIN_CLK;
33  pcnt_channel_handle_t chB = NULL;
34  ESP_ERROR_CHECK(pcnt_new_channel(unit, &bCfg, &chB));
35
36  // 4x çözme: iki hattın tüm kenarları sayılır / 4x decoding: every edge of both lines is counted
37  ESP_ERROR_CHECK(pcnt_channel_set_edge_action(chA, PCNT_CHANNEL_EDGE_ACTION_DECREASE, PCNT_CHANNEL_EDGE_ACTION_INCREASE));
38  ESP_ERROR_CHECK(pcnt_channel_set_level_action(chA, PCNT_CHANNEL_LEVEL_ACTION_KEEP, PCNT_CHANNEL_LEVEL_ACTION_INVERSE));
39  ESP_ERROR_CHECK(pcnt_channel_set_edge_action(chB, PCNT_CHANNEL_EDGE_ACTION_INCREASE, PCNT_CHANNEL_EDGE_ACTION_DECREASE));
40  ESP_ERROR_CHECK(pcnt_channel_set_level_action(chB, PCNT_CHANNEL_LEVEL_ACTION_KEEP, PCNT_CHANNEL_LEVEL_ACTION_INVERSE));
41
42  gpio_pullup_en((gpio_num_t)PIN_CLK);        // çıplak encoder için iç pull-up / internal pull-up for a bare encoder
43  gpio_pullup_en((gpio_num_t)PIN_DT);
44
45  ESP_ERROR_CHECK(pcnt_unit_enable(unit));
46  ESP_ERROR_CHECK(pcnt_unit_clear_count(unit));
47  ESP_ERROR_CHECK(pcnt_unit_start(unit));
48}
49
50int lastDetent = 0;
51
52void loop() {
53  int count = 0;
54  pcnt_unit_get_count(unit, &count);
55  int detent = count / 4;                     // 4 kenar = 1 tık / 4 edges = 1 detent
56  if (detent != lastDetent) {
57    lastDetent = detent;
58    Serial.printf("Tik / detent: %d  (ham / raw: %d)\n", detent, count);
59  }
60  if (digitalRead(PIN_SW) == LOW) {           // basınca sıfırla / reset on press
61    pcnt_unit_clear_count(unit);
62  }
63  delay(20);
64}

Два канала настроены зеркально: канал A считает фронты CLK и меняет направление в зависимости от уровня DT, а канал B считает фронты DT относительно CLK. В результате счётчик меняется на 4 за щелчок. Дойдя до предела ±10000, он возвращается к нулю; если нужен более широкий диапазон, переполнения можно накапливать с помощью опции flags.accum_count и точек наблюдения (watch points). В ESP32-C3 нет блока PCNT; на этой плате используй Arduino-код на прерываниях. Если у тебя ядро Arduino ESP32 версии 2.x или хочется кода покороче, библиотека ESP32Encoder использует то же оборудование и справляется с задачей функциями attachFullQuad() и getCount(). Если на ESP32 используется MicroPython, класс machine.Encoder в свежих версиях (например, 1.27) тоже работает через PCNT. Если ESP32 для тебя в новинку, сначала прочитай статью Что такое ESP32.

Поворотный энкодер с Raspberry Pi Pico и MicroPython

На Pico прерывание может вызвать любой пин GPIO, поэтому читать энкодер в MicroPython через Pin.irq() просто. Код ниже использует ровно ту же таблицу состояний, что и пример для Arduino, и превращает энкодер в диммер для светодиода: каждый щелчок меняет яркость на 5 процентов, а нажатие на вал включает и выключает светодиод. Подключи CLK к GP16, DT к GP17, SW к GP18, а светодиод через резистор 220 Ω — к GP15. Как установить MicroPython, описано в статье Что такое Raspberry Pi Pico.

MicroPython
1from machine import Pin, PWM
2import time
3
4clk = Pin(16, Pin.IN, Pin.PULL_UP)     # A fazı / phase A
5dt = Pin(17, Pin.IN, Pin.PULL_UP)      # B fazı / phase B
6sw = Pin(18, Pin.IN, Pin.PULL_UP)      # buton, basınca 0 / button, 0 when pressed
7led = PWM(Pin(15))                     # LED + 220 ohm
8led.freq(1000)
9
10# İndeks: (eski AB << 2) | yeni AB / Index: (old AB << 2) | new AB
11QEM = (0, -1, 1, 0, 1, 0, 0, -1, -1, 0, 0, 1, 0, 1, -1, 0)
12last_ab = (clk.value() << 1) | dt.value()
13quarter = 0
14position = 10                          # 0-20 tık, başlangıç %50 / 0-20 detents, start at 50 %
15
16def on_edge(pin):
17    global last_ab, quarter, position
18    ab = (clk.value() << 1) | dt.value()
19    quarter += QEM[(last_ab << 2) | ab]
20    last_ab = ab
21    if ab == 3:                        # tıka oturdu / settled in a detent
22        if quarter > 1 and position < 20:
23            position += 1
24        elif quarter < -1 and position > 0:
25            position -= 1
26        quarter = 0
27
28clk.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=on_edge)
29dt.irq(trigger=Pin.IRQ_RISING | Pin.IRQ_FALLING, handler=on_edge)
30
31on = True
32shown = -1
33while True:
34    if sw.value() == 0:                # mile basıldı / shaft pressed
35        time.sleep_ms(20)              # sıçramayı bekle / wait out the bounce
36        if sw.value() == 0:
37            on = not on
38            shown = -1                 # yeniden yaz / force an update
39            while sw.value() == 0:     # bırakılmasını bekle / wait for release
40                time.sleep_ms(10)
41    if position != shown:
42        shown = position
43        led.duty_u16(shown * 65535 // 20 if on else 0)
44        print("Parlaklik / brightness: %d%%" % (shown * 5), "acik / on" if on else "kapali / off")
45    time.sleep_ms(10)

Границы значения (0–20 щелчков) мы проверяем внутри функции прерывания, поэтому основной цикл только читает переменную, и не возникает состояния гонки, когда обе стороны пишут одновременно. Как управлять яркостью светодиода, рассказано в статье Что такое ШИМ (PWM). Если нужно готовое решение, модуль rotary_irq_rp2 из проекта micropython-rotary умеет работать с границами, зацикливанием (wrap-around) и сменой направления. Для очень быстрых энкодеров моторов блок PIO в Pico может считать, совсем не нагружая процессор. Если MicroPython для тебя в новинку, хорошая отправная точка — статья Основы MicroPython.

Виды поворотных энкодеров: механические, оптические и магнитные

ТипКак работает?Типичное разрешениеГде применяется?
Механический (KY-040, EC11)Контактный диск и два переключателя12–30 щелчков на оборотРучки меню, регуляторы громкости, передние панели приборов
ОптическийДиск с прорезями, светодиод и фототранзисторОт сотен до тысяч импульсов на оборотСтанки с ЧПУ, суставы роботов, промышленные двигатели
Магнитный (на эффекте Холла)Магнитный диск и датчики ХоллаНесколько импульсов на оборот вала мотора (например, 7 или 11)N20 и DC-моторы с редуктором и энкодером
АбсолютныйВыдаёт уникальный код для каждого положенияНапример, 12 бит (4096 положений) у AS5600Руки роботов, измерение угла; знает своё положение при включении

Код из этой статьи работает и с другими инкрементальными энкодерами с выходами A/B. Например, у DC-мотора с редуктором и энкодером датчик Холла, дающий 11 импульсов на оборот вала мотора, при подсчёте по четырём фронтам и передаточном отношении 1:30 даёт 11 × 4 × 30 = 1320 отсчётов на каждый оборот выходного вала. Когда частота импульсов доходит до тысяч в секунду, код на прерываниях забирает заметную долю времени процессора, а блок PCNT в ESP32 справляется с таким подсчётом без усилий. Промышленные оптические энкодеры чаще всего работают от 5–24 V и имеют выходы с открытым коллектором или дифференциальные; перед подключением обязательно проверь в даташите тип выхода и напряжение.

Идеи проектов с поворотным энкодером

  • Цифровая ручка громкости: регулируй энкодером громкость музыки на DFPlayer Mini, а нажатием на вал переключай на следующий трек.
  • Лампа с выбором цвета: меняй энкодером цветовой тон кольца из светодиодов WS2812B, а нажатием переходи в режим яркости.
  • Точное позиционирование: пусть каждый щелчок поворачивает шаговый двигатель на заданное число шагов; так можно собрать слайдер для камеры или поворотный столик.
  • Кухонный таймер: выставляй время энкодером, нажатием запускай обратный отсчёт, а когда время выйдет, включай зуммер.
  • Игровой контроллер: используй энкодер как ракетку в Pong; в игровых проектах на Arduino он даёт более точное управление, чем джойстик.
  • Настройка часов и будильника: выставляй время и будильник модуля часов реального времени (RTC) одной ручкой.
  • Панель измерительного прибора: меняй мелкими шагами частоту ШИМ, значение генератора сигналов или целевую температуру паяльной станции.

Частые ошибки и как их исправить

ПроблемаВероятная причинаРешение
Считает 2 или 4 за щелчокКаждый фронт считается отдельно или дребезжат контактыРаздели число фронтов на 4, используй таблицу состояний, добавь конденсаторы 10–100 nF на CLK и DT
Считает только каждый второй щелчокЭнкодер на 30 щелчков / 15 импульсов; каждый щелчок — это половина циклаДели значение библиотеки на 2, а не на 4; в коде с таблицей замени if (ab == 3) на if (ab == 3 || ab == 0)
Направление перепутаноCLK и DT перепутаны местами или модуль другойПоменяй местами номера пинов CLK и DT в коде
При быстром вращении шаги пропускаютсяИспользуется опрос, в loop есть delayИспользуй прерывания или PCNT в ESP32, делай ISR короткой
Кнопка «нажимается» самаПин SW «висит» в воздухе, нет подтяжкиИспользуй pinMode(SW, INPUT_PULLUP)
Счётчик скачет случайным образомНет общей земли, длинные или плохо закреплённые проводаОбъедини GND, укороти провода, проверь контакты макетной платы
fatal error: driver/pulse_cnt.h: No such file or directory (заголовочный файл не найден) / undefined reference to 'pcnt_new_unit' (функция не найдена при сборке)Ядро Arduino ESP32 2.x (первая ошибка) или ESP32-C3, у которого нет блока PCNT (вторая ошибка)Обнови ядро до 3.x в «Менеджере плат» (Boards Manager) или используй библиотеку ESP32Encoder; на C3 используй код с прерываниями
На LCD видны только прямоугольникиНеверная настройка контраста или I2C-адресПокрути подстроечный резистор на обратной стороне, попробуй адрес 0x3F вместо 0x27
Если хочется проверить код до сборки схемы, в симуляторе Wokwi есть готовая деталь KY-040: после клика по энкодеру его можно вращать по щелчкам стрелками на клавиатуре, а кнопку нажимать пробелом. Как всё настроить, описано в руководстве Симуляция в Wokwi.

Поворотный энкодер — один из самых дешёвых способов дать проекту профессиональный интерфейс: одна ручка и дисплей справятся с десятками настроек. Начни с кода на опросе, чтобы понять логику, затем переходи на версию с прерываниями или PCNT и адаптируй пример с меню под свой проект. В приложении BlueGrays есть проекты с управлением сервоприводом от потенциометра, I2C LCD, кнопкой и счётчиком на 7-сегментном индикаторе — со схемами подключения и пошаговым кодом; их легко улучшить, заменив потенциометр или кнопку энкодером.

Частые вопросы

Чем поворотный энкодер отличается от потенциометра?

Потенциометр выдаёт аналоговое напряжение, зависящее от положения вала, его поворот ограничен примерно 270–300 градусами, а читается он через analogRead. Поворотный энкодер вращается бесконечно и на каждом щелчке выдаёт цифровые импульсы; он сообщает направление и величину движения, а не положение. Энкодер лучше подходит для навигации по меню и пошаговых настроек, потенциометр — для простых регулировок по положению.

Почему энкодер считает два шага за щелчок или пропускает шаги?

Лишний счёт обычно вызван дребезгом контактов или тем, что каждый фронт считается отдельно; если разделить 4 фронта на щелчок на 4 и использовать таблицу состояний, проблема решается. Пропуски шагов бывают, когда импульсы приходят, пока loop занят. Подключи CLK и DT к пинам прерываний, читай их через прерывания и при необходимости добавь конденсаторы 10–100 nF — и проблема исчезнет.

Работает ли энкодер KY-040 от 3,3 V?

Да. KY-040 состоит только из механических переключателей и подтягивающих резисторов, поэтому отлично работает и от 3,3 V. На ESP32 и Raspberry Pi Pico всегда подключай пин + к 3V3; если питать модуль от 5 V, подтягивающие резисторы поднимут CLK и DT до 5 V, и предел входов на 3,3 V будет превышен.

Сколько импульсов на оборот даёт KY-040?

У распространённых модулей KY-040 20 щелчков на оборот, и линии A и B за один щелчок проходят один полный цикл. В каждом цикле четыре фронта, так что за оборот насчитывается 80 фронтов, а каждый щелчок равен 18 градусам. У некоторых энкодеров 30 щелчков и 15 импульсов; у них каждый щелчок соответствует половине цикла.

Можно ли читать поворотный энкодер без прерываний?

Да, его можно читать опросом, постоянно проверяя пин CLK внутри loop, и для медленно вращаемой ручки меню этого часто хватает. Но если в loop есть delay, обновление дисплея или долгие вычисления, быстрые щелчки будут теряться. Для надёжного результата рекомендуются прерывания, а на ESP32 — аппаратный счётчик PCNT.

Можно ли подключить к Arduino несколько поворотных энкодеров?

Да. На Uno аппаратные прерывания есть только на D2 и D3, поэтому с библиотекой Encoder можно подключить первый пин каждого энкодера (тот, что передаётся в конструктор первым) к пину прерывания, а второй — к обычному пину, или использовать прерывания по изменению состояния пина (pin change interrupts). У Mega шесть пинов прерываний, классический ESP32 может аппаратно считать 8 энкодеров своими 8 блоками PCNT, а на Pico прерывание может вызвать любой GPIO.

Все проекты
Включение и выключение светодиода кнопкой (вкл/выкл)
#10Новичок

Включение и выключение светодиода кнопкой (вкл/выкл)

Нажми кнопку один раз — свет загорится, нажми ещё раз — погаснет, совсем как выключатель в твоей комнате. Arduino замечает каждое нажатие и переводит светодиод в противоположное состояние, выжидая кор

4.8(100)
167
Управление светодиодом кнопкой на ESP32 (переключение)
#105Новичок

Управление светодиодом кнопкой на ESP32 (переключение)

При каждом нажатии кнопки состояние светодиода меняется: если горел — гаснет, если не горел — загорается. Классический шаблон переключения. Антидребезг убирает механический шум кнопки, чтобы одно нажатие давало одно переключение.

4.6(39)
57
Антидребезг кнопки на Pico: IRQ против программного опроса
#159Средний
Premium

Антидребезг кнопки на Pico: IRQ против программного опроса

Нажимаешь кнопку один раз, а код насчитывает 3–4? Это дребезг контактов — металлические контакты колеблются доли миллисекунды! Сравни в мониторе два способа подсчёта рядом: сырой опрос (ловит дребезг и считает лишнее) и IRQ + программный антидребезг (точно). Изучи физику дребезга, программный антидребезг (ticks_diff() > 50ms), регистрацию прерывания Pin.irq(), триггер IRQ_FALLING, обработчики ISR и безопасность global в ISR.

0.0(0)
12
Управление сервоприводом потенциометром
#36Новичок
Premium

Управление сервоприводом потенциометром

Когда ты поворачиваешь ручку потенциометра, рычаг сервопривода поворачивается вместе с ней: одно крайнее положение ручки — ноль градусов, другое — пол-оборота. Arduino всё время читает положение ручки

4.6(37)
52
Регулировка яркости светодиода потенциометром на ESP32 (аналог → PWM)
#115Средний
Premium

Регулировка яркости светодиода потенциометром на ESP32 (аналог → PWM)

В этом проекте яркость светодиода плавно регулируется потенциометром. Пин АЦП ESP32 читает напряжение 0–3,3V с потенциометра, затем это значение превращается в сигнал PWM (широтно-импульсная модуляция) и подаётся на светодиод. В результате при повороте потенциометра светодиод постепенно становится ярче или тусклее. В Arduino Core 3.0+ можно сразу использовать analogWrite(); на ESP32-S2/S3 разрешение АЦП фиксировано — 12 бит.

4.5(19)
32
Счётчик на семисегментном индикаторе с кнопками
#43Продвинутый
Premium

Счётчик на семисегментном индикаторе с кнопками

Простой счётчик с тремя кнопками: нажми одну — цифра на индикаторе увеличится на единицу, нажми другую — уменьшится, а третья возвращает её к нулю. Одноразрядный семисегментный индикатор считает от 0

4.6(35)
47
LCD-экран с I2C
#32Новичок
Premium

LCD-экран с I2C

Дай своему Arduino собственный экран и заставь его написать первое сообщение. Двухстрочный LCD-экран подключается всего несколькими проводами; в верхней строке появляется приветствие, затем второе соо

4.8(53)
70
Игра в пинг-понг — SSD1306
#86Средний
Premium

Игра в пинг-понг — SSD1306

Мяч, две ракетки и ты: на маленьком OLED-экране ты играешь в настольный теннис — одну из самых первых видеоигр. Две кнопки двигают твою ракетку справа вверх и вниз, а левой ракеткой управляет компьюте

4.5(47)
65