部品 · 読了まで16分

ArduinoでロータリーエンコーダーKY-040を使う:配線、割り込み、メニューのコード

Arduinoでロータリーエンコーダーを使う方法。KY-040の配線、A/B相の読み取り、取りこぼさない割り込み、チャタリング対策、LCDメニュー、ESP32のPCNTとPicoまで解説します。

ロータリーエンコーダーは、Arduinoのプロジェクトで使うデジタルの操作つまみです。どこまでも回り続け、1クリックごとに1ステップを数え、軸を押すと押しボタンとしても働きます。Arduinoにつなぐと、3Dプリンターのメニューつまみ、オシロスコープの調整ダイヤル、車の音量つまみのように動作します。何クリック、どちらの方向に回したかを把握でき、端で止まることがありません。ホビー向けキットで最もよく見かけるのは、5ピンのKY-040モジュールです。

このガイドでは、KY-040とポテンショメーターの違い、A相とB相(直交信号)から回転方向を読み取る仕組み、接点のチャタリングをハードウェアとソフトウェアで取り除く方法、そして割り込みを使って1クリックも取りこぼさずに数える方法を学びます。続いて、Encoderライブラリを使った近道、LCDとOLEDでのメニュー操作、ESP32のハードウェアカウンターPCNT、Raspberry Pi Pico用のMicroPythonコードを順番に作っていきます。

ロータリーエンコーダーとは?どう動くのか

KY-040のような機械式エンコーダーの内部には、軸と一緒に回る歯付きの接点ディスクと、その上を滑る2つの小さなスイッチがあります。軸が回ると2つのスイッチが順番に開閉し、CLK (A)とDT (B)のピンに矩形波のパルスが現れます。2つの波は4分の1周期、つまり90度ずれています。これを直交(クアドラチャ)信号と呼びます。マイコンはどちらの波が先に変化したかで回転方向を、変化の回数で回転量を求めます。エンコーダーは自分の位置を覚えておらず、動きだけを知らせます。そのためKY-040はインクリメンタル型エンコーダーです。電源投入時の位置はゼロで、数える仕事はすべてコード側が担います。

項目KY-040の値
種類機械式、インクリメンタル型、A/B出力
クリック数1回転20クリック(30クリックのエンコーダーもある)
パルス数1回転でA/Bが20周期、つまりエッジ80個
角度分解能1クリックあたり18度
ピンCLK (A)、DT (B)、SW(ボタン)、+(電源)、GND
電源3.3〜5 V(プルアップ抵抗に供給するだけ)
内蔵抵抗CLKとDTに10 kΩのプルアップ抵抗。SWの抵抗はモジュールによっては未実装
ボタンノーマルオープン。軸を押すとSWがGNDにつながる(アクティブLOW)
回転無制限、端で止まらない

ロータリーエンコーダーとポテンショメーターの違いは?

一見すると、エンコーダーはポテンショメーターによく似ています。どちらも回転する軸があり、どちらも値の調整に使います。しかし仕組みはまったく違います。ポテンショメーターは分圧回路で、軸の位置に応じて0〜5 Vのアナログ電圧を出し、analogReadで読み取ります。エンコーダーは電圧ではなくパルスを出し、位置ではなく変化を知らせます。

ロータリーエンコーダーと比較するためのポテンショメーター
ポテンショメーター:回転は約270〜300度に限られ、位置をアナログ電圧で知らせます。ロータリーエンコーダーは止まらずに回り、デジタルのパルスを出します。
項目ポテンショメーターロータリーエンコーダー(KY-040)
回転約270〜300度、制限あり無制限、連続360度
出力アナログ電圧(0〜5 V)デジタルパルス(AとB)
読み取りanalogRead(ADC)digitalReadまたは割り込みで数える
位置絶対値。電源が切れても軸の位置がそのまま値になる相対値。電源投入時の位置は不明
クリック感なし、なめらかに回るあり、通常1回転20クリック
ボタンなしあり(軸を押す)
よくある問題ガリノイズ、経年で値がずれる接点のチャタリング(ソフトウェアで解決)
主な用途音量、サーボの角度、アナログ調整メニュー操作、段階的な数値設定、機器のパネル

まとめると、値が軸の物理的な位置と1対1で対応すべき場合(手でサーボのアームを動かす、シンプルな音量調整など)はポテンショメーターのほうが簡単です。メニューを移動したい、数値を正確なステップで上げ下げしたい、1つのつまみで複数の設定を変えたい、ソフトウェアからも値を変えたい、という場合はエンコーダーのほうがはるかに柔軟です。たとえば50を表示している値がスマホから80に変更されても、エンコーダーなら回すとそのまま80から続きます。ポテンショメーターでは、つまみの位置と画面の値が一致しなくなります。

A相とB相:直交信号の読み取り方

KY-040がクリックの位置で止まっているとき、2つのスイッチはどちらも開いていて、プルアップ抵抗のおかげでCLKとDTはどちらもHIGH(1)を読みます。つまみを1クリック回すと、2本の線が順番にLOWになり、そのあと順番にHIGHへ戻ります。時計回りではCLKが先に、反時計回りではDTが先に下がります。この4つの状態の並びはグレイコードで、各ステップで1ビットだけが変化します。下の表は、1クリック分の動きの間に2つのピンが読む値です。

ステップ時計回り(CLK, DT)反時計回り(CLK, DT)
クリック位置で静止1, 11, 1
1回目の遷移0, 11, 0
2回目の遷移0, 00, 0
3回目の遷移1, 00, 1
4回目の遷移(次のクリック)1, 11, 1

この表から2つの簡単なルールが導けます。1つ目:CLKが下がったときにDTがまだHIGHなら時計回り、すでにLOWなら反時計回りです。2つ目:1クリックには4回の遷移があるので、1回転20クリックなら遷移は80回です。シンプルなコードは1つ目のルールだけを使い、1クリックに1回数えます。堅牢なコードは4つの遷移をすべて追跡するので、チャタリングや途中で止まった動きを除外できます。どちらを時計回りとみなすかはモジュールや配線によって変わります。カウンターが逆に動く場合は、コード内のCLKとDTのピンを入れ替えてください。

KY-040のピン配置と配線

KY-040のピン機能Arduino UnoESP32Raspberry Pi Pico
CLKA相D2GPIO 25GP16
DTB相D3GPIO 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をこの2本につなぐのが最適です。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. KY-040のCLKピンをD2に、DTをD3に、SWをD4につなぎます。
  2. +ピンを5Vに、GNDをArduinoのGNDにつなぎます。
  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なら反時計回りです。最も理解しやすい方法ですが、弱点が2つあります。1つ目は、loopがほかの処理(画面の更新、delay、センサーの読み取り)で忙しい間に来たパルスを取りこぼし、速く回すとカウンターが遅れることです。2つ目は、機械接点のチャタリングです。CLKは1回下がる間に何度もLOW/HIGHを繰り返すことがあり、コードが1クリックを2回や3回と数えてしまいます。次の2つのセクションで両方の問題を解決します。

接点のチャタリング対策:ハードウェアとソフトウェアでの解決法

エンコーダー内部の金属ばねは、接点に触れると落ち着くまでに何度か跳ねます。メーカーのデータシートでは、この時間を数ミリ秒(たとえば最大2 ms)としていることが多いです。押しボタンでは「最後の変化から20〜50 ms待つ」ことでチャタリングを防ぎますが、エンコーダーではうまくいきません。速く回すと2つのエッジの間は数ミリ秒しかなく、長く待つと本物のステップまで消えてしまいます。代わりに次の方法を使います。

  • ハードウェアフィルター:CLKとGNDの間、DTとGNDの間にそれぞれ10〜100 nFのコンデンサを入れます。モジュールの10 kΩプルアップ抵抗と合わせてRCフィルターになり、時定数は10 kΩ × 10 nF = 0.1 msから10 kΩ × 100 nF = 1 msの間になるので、短いチャタリングを吸収します。時定数はRC時定数ツールで計算でき、103や104と書かれたコンデンサはコンデンサのコードツールで読み解けます。
  • 状態テーブル:両方の線のエッジごとに、古いAB状態と新しいAB状態を比較します。チャタリングは同じ2つの状態の間を行き来することなので、+1と−1が交互に出て打ち消し合います。2ビットが同時に変わる遷移は無効として無視します。
  • クリック位置でだけ判定:4分の1ステップを積算し、エンコーダーがクリック位置(両方の線がHIGH)に落ち着いたときだけカウンターを増減します。こうすれば、クリック位置で接点が揺れてもメニューのカーソルが飛びません。
  • ボタンにはロックアウト:SWピンは普通の押しボタンと同じように振る舞います。millis()で30 msのロックアウトをかければ十分です。

ステップを取りこぼさない割り込みでの読み取り

割り込みを使うと、ピンが変化した瞬間にプロセッサーが今の処理を中断し、短い関数(ISR)を実行します。エンコーダーの線を割り込みに割り当てれば、loopに50 msのdelayがあっても、画面の更新が遅くても、エッジは1つも失われません。下のコードは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状態と新しいAB状態を+1、−1、0に変換する。両方の線が同時に変わる無効な遷移は0として扱う
attachInterrupt(..., CHANGE)CLKとDTの立ち上がり・立ち下がりの両エッジでreadEncoder()を実行する
quarter2つのクリックの間の4分の1ステップを積算する。チャタリングによる+1と−1の組は打ち消し合う
if (ab == 3)エンコーダーがクリック位置(両方の線がHIGH)に落ち着いたときだけ判定する。積算値が+2以上なら1クリックとして数える
volatile long position(共有変数)ISRとloopが共有する変数。volatileでコンパイラーが古いコピーを使うのを防ぐ
noInterrupts() / interrupts()4バイトのlongをコピーしている途中にISRが割り込むのを防ぐ
buttonPressed()30 msのロックアウトでSWのチャタリングを除き、押した瞬間に1回だけtrueを返す
ISRはできるだけ短くしてください。中でSerial.print、delay、画面のコマンドは使わず、カウンターの更新だけにして、実際の処理はloopに任せます。ISRとloopで共有する変数はすべてvolatileで宣言します。Unoは8ビットのプロセッサーなので、2バイトや4バイトの変数を読む間だけ割り込みを止めると、更新途中の値を読むことを防げます。

近道:Encoderライブラリ

自分でISRを書きたくない場合は、Paul Stoffregen氏のEncoderライブラリが最適化されたコードで同じ仕事をしてくれます。Arduino IDEでライブラリマネージャを開き、「Encoder」を検索してPaul Stoffregen氏のライブラリをインストールします。インストール手順はArduinoにライブラリを追加する方法のガイドを見てください。このライブラリは、両方のピンが割り込みピンなら最高の性能、コンストラクターに最初に渡すピンだけが割り込みピンなら良好な性能、どちらも割り込みピンでなければ低い性能になります。すべてのエッジを数えるため、1クリックで値が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のメニューを操作する

I2C 16x2 LCDディスプレイ
I2C 16x2 LCDディスプレイ:SDAとSCLの2本だけで接続でき、メニュー例の設定がこのディスプレイに表示されます。

エンコーダーが最もよく使われるのはメニューです。つまみを回して行を移動し、押して選んだ設定を編集モードにし、回して値を変え、もう一度押してメニューに戻ります。下の例ではRGB LEDの赤・緑・青のチャンネルを0〜255の範囲で調整し、値をI2C LCDディスプレイに表示します。LCDはA4(SDA)とA5(SCL)に、カソードコモンのRGB LEDの各色の足は220 Ωの抵抗を通してD9、D10、D11につなぎます。

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がしばらく忙しくてもクリックは失われず、たまった分は次の周回でまとめて処理されます。編集モードでは1クリックで値が5変わります。もっと細かく調整したい場合はステップを1にするか、速く回したときにステップを大きくする加速機能を追加できます。

同じメニューをOLEDディスプレイに表示するには、drawMenu()関数を変えるだけです。下のバージョンでは、選択中の行を白いバーの上に反転色で描きます。LCDのライブラリの代わりにAdafruit SSD1306とAdafruit GFXのライブラリを追加し、lcdオブジェクトを削除して、setup()ではlcd.init()の代わりにdisplay.begin()を呼びます。

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(パルスカウンター)というハードウェアブロックがあります。各ユニットに2つのチャンネルがあるので、A/B信号を直接読み解けます。初代ESP32には8つ、ESP32-S2とS3には4つのPCNTユニットがあります。カウンターは16ビット幅で、短いノイズを除くグリッチフィルターを備えています。このフィルターは最大で約12.8マイクロ秒までしか設定できないため、ごく短いスパイクは除けても、チャタリング全体は除けません。残りのチャタリングは4エッジでのカウント自体が吸収します。行き来するチャタリングは+1と−1として互いに打ち消し合うからです。下のコードは、Arduino ESP32コアのバージョン3.xに付属するESP-IDFのドライバを使います。

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}

2つのチャンネルは互いに鏡像になるように設定します。チャンネルAはCLKのエッジを数え、DTのレベルに応じて方向を反転します。チャンネルBはCLKを基準にDTのエッジを数えます。その結果、1クリックでカウンターが4変わります。±10000の上限に達するとゼロに戻ります。より広い範囲が必要なら、flags.accum_countオプションとウォッチポイントでオーバーフローを積算できます。ESP32-C3にはPCNTユニットがありません。このボードでは割り込みを使うArduinoのコードを使ってください。Arduino ESP32コアのバージョン2.xを使っている場合や、もっと短いコードにしたい場合は、ESP32Encoderライブラリが同じハードウェアを使い、attachFullQuad()とgetCount()関数で同じことをしてくれます。ESP32でMicroPythonを使う場合も、最近のバージョン(たとえば1.27)のmachine.Encoderクラスが裏でPCNTを使っています。ESP32が初めてなら、先にESP32とは?を読んでください。

Raspberry Pi PicoとMicroPythonでロータリーエンコーダーを使う

PicoのすべてのGPIOピンで割り込みが使えるので、MicroPythonでPin.irq()を使えばエンコーダーを簡単に読めます。下のコードはArduinoの例とまったく同じ状態テーブルを使い、エンコーダーをLEDの調光つまみにします。1クリックで明るさが5パーセント変わり、軸を押すとLEDがオン・オフします。CLKをGP16に、DTをGP17に、SWをGP18に、LEDを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クリック)は割り込み関数の中でチェックしているので、メインループは変数を読むだけです。両側が同時に書き込む競合状態は起きません。LEDの明るさを制御する方法はPWMとは?の記事にあります。既製のものがよければ、micropython-rotaryプロジェクトのrotary_irq_rp2モジュールに上下限、ラップアラウンド、方向反転の機能があります。非常に速いモーターのエンコーダーなら、PicoのPIOブロックがプロセッサーに負荷をかけずに数えられます。MicroPythonが初めてなら、MicroPythonの基本の記事から始めるのがおすすめです。

ロータリーエンコーダーの種類:機械式、光学式、磁気式

種類仕組み一般的な分解能使われる場所
機械式(KY-040、EC11)接点ディスクと2つのスイッチ1回転12〜30クリックメニューつまみ、音量調整、機器のフロントパネル
光学式スリット付きディスク、LED、フォトトランジスタ1回転あたり数百〜数千パルスCNC工作機械、ロボットの関節、産業用モーター
磁気式(ホール効果)磁気ディスクとホールセンサーモーター軸1回転あたり数パルス(例:7や11)エンコーダー付きのN20やギア付きDCモーター
アブソリュート型位置ごとに固有のコードを出力するたとえばAS5600では12ビット(4096位置)ロボットアーム、角度測定。電源投入時に位置がわかる

この記事のコードは、A/B出力を持つほかのインクリメンタル型エンコーダーでも使えます。たとえばエンコーダー付きのギア付きDCモーターで、モーター軸1回転あたり11パルスのホールエンコーダー、4エッジでのカウント、1:30のギア比なら、出力軸1回転あたり11 × 4 × 30 = 1320カウントになります。パルスが毎秒数千に達すると、割り込みを使うコードはプロセッサーの時間をかなり消費しますが、ESP32のPCNTユニットなら楽に数えられます。産業用の光学式エンコーダーは多くが5〜24 Vで動作し、オープンコレクタ出力や差動出力です。接続する前に、必ずデータシートで出力の種類と電圧を確認してください。

ロータリーエンコーダーのプロジェクトアイデア

  • デジタル音量つまみ:DFPlayer Miniで再生中の音楽の音量をエンコーダーで調整し、軸を押して次の曲に進みます。
  • 色を選べるランプ:WS2812B LEDリングの色相をエンコーダーで回し、押すと明るさモードに切り替えます。
  • 精密な位置決め:1クリックごとにステッピングモーターを決まったステップ数だけ動かし、カメラスライダーやターンテーブルを作ります。
  • キッチンタイマー:エンコーダーで時間を設定し、押してカウントダウンを始め、時間になったらブザーを鳴らします。
  • ゲームコントローラー:エンコーダーをPongのパドルとして使います。Arduinoのゲームプロジェクトでは、ジョイスティックより細かく操作できます。
  • 時刻とアラームの設定:RTC時計モジュールの時刻とアラームを1つのつまみで設定します。
  • 計測器のパネル:PWMの周波数、信号発生器の値、はんだごてステーションの目標温度などを細かいステップで変えます。

よくある間違いと解決法

問題考えられる原因解決法
1クリックで2や4数えるエッジを1つずつ数えている、または接点のチャタリングエッジ数を4で割る、状態テーブルを使う、CLKとDTに10〜100 nFのコンデンサを追加する
2クリックに1回しか数えない30クリック・15パルスのエンコーダーで、1クリックが半周期ライブラリの値を4ではなく2で割る。テーブルのコードではif (ab == 3)をif (ab == 3 || ab == 0)に変える
方向が逆になるCLKとDTが入れ替わっている、またはモジュールが違うコード内のCLKとDTのピン番号を入れ替える
速く回すとステップを取りこぼすポーリングを使っている、loopにdelayがある割り込みかESP32のPCNTを使い、ISRを短くする
ボタンが勝手に押されるSWピンが浮いている、プルアップがないpinMode(SW, INPUT_PULLUP)を使う
カウンターがランダムに飛ぶGNDが共通でない、配線が長いか緩いGNDを共通にし、配線を短くし、ブレッドボードの接触を確認する
fatal error: driver/pulse_cnt.h: No such file or directory(ファイルが見つからない)/ undefined reference to 'pcnt_new_unit'(関数が見つからない)Arduino ESP32コア2.x(1つ目のエラー)、またはPCNTユニットのないESP32-C3(2つ目のエラー)ボードマネージャでコアを3.xに更新するか、ESP32Encoderライブラリを使う。C3では割り込みのコードを使う
LCDに四角しか表示されないコントラストの設定かI2Cアドレスが違う裏の半固定抵抗(トリマー)を回す、アドレスを0x27ではなく0x3Fにしてみる
回路を組む前にコードを試したい場合は、Wokwiシミュレーターに既製のKY-040部品があります。エンコーダーをクリックしたあと、矢印キーで1クリックずつ回し、スペースキーでボタンを押せます。準備についてはWokwiシミュレーションのガイドを参照してください。

ロータリーエンコーダーは、プロジェクトに本格的なユーザーインターフェースを持たせる最も安い方法の1つです。つまみ1つとディスプレイ1つで、何十もの設定を扱えます。まずポーリングのコードで仕組みを理解し、次に割り込み版やPCNT版に進んで、メニューの例を自分のプロジェクトに合わせてください。BlueGraysアプリには、ポテンショメーターでのサーボ制御、I2C LCD、押しボタン、7セグメントのカウンターのプロジェクトが回路図とステップごとのコード付きで揃っています。ポテンショメーターやボタンをエンコーダーに置き換えれば、簡単にグレードアップできます。

よくある質問

ロータリーエンコーダーとポテンショメーターの違いは?

ポテンショメーターは軸の位置に応じたアナログ電圧を出し、回転は約270〜300度に限られ、analogReadで読み取ります。ロータリーエンコーダーはどこまでも回り、クリックごとにデジタルのパルスを出します。知らせるのは位置ではなく、動きの方向と量です。メニュー操作や段階的な設定にはエンコーダー、位置に基づくシンプルな調整にはポテンショメーターが向いています。

ロータリーエンコーダーが1クリックで2ステップ数えたり、ステップを飛ばしたりするのはなぜ?

多く数えすぎる原因は、たいてい接点のチャタリングか、エッジを1つずつ数えていることです。1クリックあたり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は1回転で何パルス出す?

一般的なKY-040モジュールは1回転20クリックで、1クリックごとにA線とB線が1周期を完了します。1周期には4つのエッジがあるので、1回転で80エッジを数えることになり、1クリックは18度です。30クリック・15パルスのエンコーダーもあり、その場合は1クリックが半周期にあたります。

割り込みを使わずにロータリーエンコーダーを読める?

はい、loopの中でCLKピンを常にチェックするポーリングで読めます。ゆっくり回すメニューつまみならそれで十分なことも多いです。ただしloopにdelay、画面の更新、長い計算があると、速く回したクリックを取りこぼします。確実に読みたいなら割り込みを、ESP32ならPCNTハードウェアカウンターをおすすめします。

Arduinoに複数のロータリーエンコーダーをつなげる?

はい。Unoではハードウェア割り込みが使えるのはD2とD3だけなので、Encoderライブラリを使って各エンコーダーの1本目のピン(コンストラクターに最初に渡すピン)を割り込みピンに、もう1本を普通のピンにつなぐか、ピン変化割り込みを使います。Megaには割り込みピンが6本あり、初代ESP32は8つのPCNTユニットで8個のエンコーダーをハードウェアで数えられます。PicoではすべてのGPIOで割り込みが使えます。

全プロジェクトを見る
ボタンでLEDの点灯・消灯を切り替え
#10初級

ボタンでLEDの点灯・消灯を切り替え

部屋の照明スイッチのように、ボタンを1回押すと点灯し、もう1回押すと消灯します。Arduinoはボタンが押されるたびにLEDの状態を反転させます。ボタン内部の小さな接点の振動で誤動作しないよう、短い待ち時間も設けます。1つのボタンでオン・オフを切り替える、シンプルな仕組みです。

4.8(100)
167
ESP32でボタンによるLED制御(トグル)
#105初級

ESP32でボタンによるLED制御(トグル)

ボタンを押すたびにLEDの状態が切り替わり、点灯中なら消灯し、消灯中なら点灯します。基本的なトグル動作です。チャタリング対策でボタンの機械的なノイズを除去し、1回の操作で1回だけ切り替わるようにします。

4.6(39)
57
Raspberry Pi Picoのボタンのチャタリング対策:IRQとソフトウェアポーリングの比較
#159中級
プレミアム

Raspberry Pi Picoのボタンのチャタリング対策:IRQとソフトウェアポーリングの比較

ボタンを1回押しただけで、コードでは3~4回と数えられますか?原因はチャタリングです。金属接点がマイクロ秒単位で振動します!シリアルモニタで、2つのカウント方法を並べて比較します。未処理のポーリングは振動も検出して多く数え、IRQとソフトウェアによるチャタリング対策は正確に数えます。接点が振動する仕組み、ソフトウェアによる対策(ticks_diff() > 50ms)、Pin.irq()による割り込みの登録、IRQ_FALLINGトリガー、ISRハンドラー、globalとISRの安全な扱い方を学びます。IRQでLEDを切り替え、動作を目で確認できます。シリアルモニタには押すたびに両方のカウントが表示されます。

0.0(0)
12
ポテンショメーターでサーボモーターを操作
#36初級
プレミアム

ポテンショメーターでサーボモーターを操作

ポテンショメーターのつまみを回すと、サーボモーターのアームもそれに合わせて回ります。つまみの一方の端が0度、もう一方の端が半回転に対応します。Arduinoがつまみの位置を常に読み取り、サーボモーターが動きにすぐ反応します。ロボットアームや回転式カメラ台などのプロジェクトへの第一歩です。

4.6(37)
52
ESP32と可変抵抗でLEDの明るさを制御(アナログ→PWM)
#115中級
プレミアム

ESP32と可変抵抗でLEDの明るさを制御(アナログ→PWM)

可変抵抗を使ってLEDの明るさをアナログ操作で調整します。ESP32のADCピンで可変抵抗からの0-3.3Vの電圧を読み取り、PWM(パルス幅変調)信号に変換してLEDに出力します。可変抵抗を回すと、LEDが徐々に明るくなったり暗くなったりします。Arduino Core 3.0+ではanalogWrite()を直接使えます。ESP32-S2/S3ではADCの分解能を12ビットに固定します。

4.5(19)
32
7セグメントディスプレイとボタンで作るカウンター
#43上級
プレミアム

7セグメントディスプレイとボタンで作るカウンター

3つのボタンを使うシンプルなカウンターです。1つ目のボタンを押すと表示の数字が1増え、2つ目を押すと1減り、3つ目を押すと0に戻ります。1桁の7セグメントディスプレイで0から9まで数え、範囲の端に達すると隅の小さな点が0.5秒間点灯して知らせます。スコアボードや順番表示器などのプロジェクトの基礎になります。

4.6(35)
47
I2CでLCDディスプレイを使う
#32初級
プレミアム

I2CでLCDディスプレイを使う

Arduinoにディスプレイを接続し、最初のメッセージを表示します。2行のLCDディスプレイは数本の配線だけで接続できます。まず上の行にあいさつを表示し、次に下の行にもう1つのメッセージを表示します。その後、表示を消去して最初から繰り返します。温度や時刻、スコアを表示するプロジェクトは、すべてこのステップから始まります。

4.8(53)
70
パドルボールゲーム(SSD1306)
#86中級
プレミアム

パドルボールゲーム(SSD1306)

ボール1個とパドル2本で遊ぶ、ビデオゲームの原点の1つである卓球ゲームを、小さなOLEDディスプレイで楽しめます。2つのボタンで右のパドルを上下に動かします。左のパドルはコンピューターがボールを追いながら操作し、ボールを打つたびにブザーが短く鳴ります。先に9点を取ると勝ちです。その後、新しいゲームが自動で始まります。

4.5(47)
65