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で読み取ります。エンコーダーは電圧ではなくパルスを出し、位置ではなく変化を知らせます。

| 項目 | ポテンショメーター | ロータリーエンコーダー(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, 1 | 1, 1 |
| 1回目の遷移 | 0, 1 | 1, 0 |
| 2回目の遷移 | 0, 0 | 0, 0 |
| 3回目の遷移 | 1, 0 | 0, 1 |
| 4回目の遷移(次のクリック) | 1, 1 | 1, 1 |
この表から2つの簡単なルールが導けます。1つ目:CLKが下がったときにDTがまだHIGHなら時計回り、すでにLOWなら反時計回りです。2つ目:1クリックには4回の遷移があるので、1回転20クリックなら遷移は80回です。シンプルなコードは1つ目のルールだけを使い、1クリックに1回数えます。堅牢なコードは4つの遷移をすべて追跡するので、チャタリングや途中で止まった動きを除外できます。どちらを時計回りとみなすかはモジュールや配線によって変わります。カウンターが逆に動く場合は、コード内のCLKとDTのピンを入れ替えてください。
KY-040のピン配置と配線
| KY-040のピン | 機能 | Arduino Uno | ESP32 | Raspberry Pi Pico |
|---|---|---|---|---|
| CLK | A相 | D2 | GPIO 25 | GP16 |
| DT | B相 | D3 | GPIO 26 | GP17 |
| SW | ボタン(押すとLOW) | D4 | GPIO 27 | GP18 |
| + | 電源 | 5V | 3V3 | 3V3(OUT) |
| GND | グランド | GND | GND | GND |
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には使わないでください。
ステップごとの最初のテスト:ポーリングで読む
- KY-040のCLKピンをD2に、DTをD3に、SWをD4につなぎます。
- +ピンを5Vに、GNDをArduinoのGNDにつなぎます。
- 下のコードをArduino IDEに貼り付けて、ボードに書き込みます。
- シリアルモニタを開き、速度を115200ボーに設定します。
- つまみを両方向に数クリック回したあと、軸を押してカウンターをリセットします。
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の割り込みの記事も参照してください。
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()を実行する |
| quarter | 2つのクリックの間の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を返す |
近道:Encoderライブラリ
自分でISRを書きたくない場合は、Paul Stoffregen氏のEncoderライブラリが最適化されたコードで同じ仕事をしてくれます。Arduino IDEでライブラリマネージャを開き、「Encoder」を検索してPaul Stoffregen氏のライブラリをインストールします。インストール手順はArduinoにライブラリを追加する方法のガイドを見てください。このライブラリは、両方のピンが割り込みピンなら最高の性能、コンストラクターに最初に渡すピンだけが割り込みピンなら良好な性能、どちらも割り込みピンでなければ低い性能になります。すべてのエッジを数えるため、1クリックで値が4変わります。クリック数を得るには4で割ってください。
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のメニューを操作する

エンコーダーが最もよく使われるのはメニューです。つまみを回して行を移動し、押して選んだ設定を編集モードにし、回して値を変え、もう一度押してメニューに戻ります。下の例ではRGB LEDの赤・緑・青のチャンネルを0〜255の範囲で調整し、値をI2C LCDディスプレイに表示します。LCDはA4(SDA)とA5(SCL)に、カソードコモンのRGB LEDの各色の足は220 Ωの抵抗を通してD9、D10、D11につなぎます。
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()を呼びます。
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}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のドライバを使います。
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とは?の記事を参照してください。
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にしてみる |
ロータリーエンコーダーは、プロジェクトに本格的なユーザーインターフェースを持たせる最も安い方法の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の点灯・消灯を切り替え
部屋の照明スイッチのように、ボタンを1回押すと点灯し、もう1回押すと消灯します。Arduinoはボタンが押されるたびにLEDの状態を反転させます。ボタン内部の小さな接点の振動で誤動作しないよう、短い待ち時間も設けます。1つのボタンでオン・オフを切り替える、シンプルな仕組みです。

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

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

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

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

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

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

パドルボールゲーム(SSD1306)
ボール1個とパドル2本で遊ぶ、ビデオゲームの原点の1つである卓球ゲームを、小さなOLEDディスプレイで楽しめます。2つのボタンで右のパドルを上下に動かします。左のパドルはコンピューターがボールを追いながら操作し、ボールを打つたびにブザーが短く鳴ります。先に9点を取ると勝ちです。その後、新しいゲームが自動で始まります。
ほかの記事
BlueGraysアプリ
この回路をスマホでステップごとに作る
回路図、コード、部品リストを1つの画面で確認できます。Arduino、ESP32、Picoのプロジェクトを200件以上用意しています。

