اتصال روتاری انکودر به برد ESP32 با دو روش Polling و وقفه

انکودرهای چرخشی یک رابط ورودی ساده اما بسیار کاربردی برای پروژههای مبتنی بر ESP32 هستند. با یک چرخشِ کوتاه میتوان بین منوها پیمایش کرد، مقادیر را تنظیم نمود، یا سرعت و تنظیمات را به روشی طبیعیتر از فشردن مکرر دکمهها تغییر داد.
در این راهنما میآموزیم چگونه یک انکودر چرخشی را به ESP32 متصل کرده و موقعیت آن را در نرمافزار بخوانیم. همزمان، طرز کار درونی انکودر را نیز توضیح میدهیم تا سیمکشی و کد بهتر قابل درک باشد.
ابتدا با اصل کار انکودر چرخشی شروع میکنیم.
نحوه کار انکودرهای چرخشی
در داخل انکودر یک دیسک دایرهای با الگوی تماس وجود دارد که به محور چرخان (همان نوب) وصل است. این دیسک به پایهای با نام “C” متصل میشود که نقش زمین مشترک (common) را دارد. دو پایهٔ مهم دیگر به نامهای “A” و “B” وجود دارند که با بررسی وضعیت آنها میتوان جهت چرخش را مشخص کرد.

هنگامی که نوب را میچرخانید، دیسک نیز میچرخد و به طور متناوب پایههای A و B را به زمین مشترک (C) متصل میکند.
نکتهٔ کلیدی در نحوهٔ تماسها نهفته است: ترتیب تماسها طوری است که A و B همزمان با زمین وصل نمیشوند. یک پایه همیشه کمی زودتر از دیگری تماس برقرار میکند. در نتیجه دو سیگنال جداگانه تولید میشوند که از فاز یکدیگر عقب یا جلو هستند؛ در اصطلاح فنی اینها سیگنالهای کوادراتور نامیده میشوند چون 90° اختلاف فاز دارند.

به دلیل این اختلاف فاز، جهت چرخش را میتوان از روی اینکه کدام سیگنال جلوتر است تعیین کرد: اگر A نسبت به B جلوتر باشد، چرخش در یک جهت است و اگر B نسبت به A جلوتر باشد، چرخش در جهت مخالف است.
روش سادهای برای رمزگشایی در نرمافزار این است که یک کانال مثل A را پیگیری کنیم و هرگاه A تغییر کرد، وضعیت B را بررسی کنیم.
- اگر وضعیت پایهٔ B با A متفاوت باشد (B ≠ A)، نوب در یک جهت میچرخد.

- اگر وضعیت پایهٔ B با A یکسان باشد (B = A)، نوب در جهت مخالف میچرخد.

این روش برای کدهای پایه و polling مناسب و ساده است. روشهای پیشرفتهتر همهٔ تغییرات هر دو کانال A و B را دنبال میکنند تا رمزگشایی دقیقتر و قابلاطمینانتری انجام شود.
پین اوت انکودر چرخشی
پینهای یک ماژول انکودر چرخشی معمولاً به صورت زیر هستند:

GND زمین (Ground) ماژول است.
+ (VCC) تغذیهٔ ماژول را تأمین میکند. آن را به 3.3V برد ESP32 متصل کنید.
SW (Switch) به کلید فشاری داخلی انکودر متصل است. معمولاً این پایه با استفاده از مقاومت پولآپ داخلی ESP32 یا یک پولآپ خارجی در حالت “HIGH” باقی میماند و با فشار دکمه به “LOW” کشیده میشود.
CLK (Channel A) یکی از دو سیگنال خروجی کوادراتور برای تشخیص چرخش است و به یکی از پینهای GPIO برد ESP32 متصل میشود.
DT (Channel B) سیگنال دوم کوادراتور را فراهم میکند که 90 درجه از A اختلاف فاز دارد. این پایه نیز به یک GPIO دیگر متصل میشود.
سیمکشی انکودر چرخشی به ESP32
پس از اینکه نحوهٔ کار را فهمیدیم، حالا انکودر را به ESP32 وصل میکنیم. سیمکشی ساده و سرراست است.
ابتدا تغذیه را متصل کنید: پایهٔ +V ماژول را به 3.3V برد ESP32 و پایهٔ GND را به GND برد متصل نمایید.
سپس پایههای سیگنال را وصل کنید: پایهٔ CLK را به GPIO شمارهٔ 25 و پایهٔ DT را به GPIO شمارهٔ 26 متصل کنید.
پایهٔ سوئیچ (SW) را به GPIO شمارهٔ 27 متصل کنید.
جدول زیر اتصال پینها را نمایش میدهد:
| انکودر چرخشی | ESP32 | |
| + (VCC) | 3.3V | |
| GND | GND | |
| CLK | 25 | |
| DT | 26 | |
| SW | 27 |
برای راهنمای تصویری، نمودار سیمکشی زیر را مشاهده کنید:

پس از تکمیل اتصالات، انکودر آمادهٔ استفاده با ESP32 است.
مثال 1 – خواندن انکودر بدون استفاده از وقفه (روش Polling)
در این مثالِ ساده با روش polling انکودر خوانده میشود. در این روش ESP32 به طور مداوم وضعیت پایهٔ CLK را در حلقهٔ اصلی بررسی میکند. زمانی که تغییر مشاهده شود، فقط لبهٔ صعودی را در نظر میگیریم و در همان لحظه پایهٔ DT را میخوانیم تا جهت چرخش مشخص شود.
این روش فهم آسانی دارد و برای پروژههای پایه مناسب است، بنابراین نقطهٔ شروع خوبیه برای یادگیری نحوهٔ خواندن انکودر در نرمافزار.
ابتدا اسکچ زیر را آپلود و اجرا کنید، سپس مرحله به مرحله کد را توضیح میدهیم.
// Pin Configuration
#define CLK_PIN 25
#define DT_PIN 26
#define SW_PIN 27
int counter = 0;
int currentStateCLK;
int lastStateCLK;
String currentDir = "";
unsigned long lastButtonPress = 0;
void setup() {
// Use internal pull-ups for stable inputs
pinMode(CLK_PIN, INPUT_PULLUP);
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);
Serial.begin(115200);
// Read the initial state of CLK
lastStateCLK = digitalRead(CLK_PIN);
}
void loop() {
// Read the current state of CLK
currentStateCLK = digitalRead(CLK_PIN);
// If the CLK state changed and is now HIGH, a pulse occurred
if (currentStateCLK != lastStateCLK && currentStateCLK == HIGH) {
// If DT is different from CLK, direction is one way
if (digitalRead(DT_PIN) != currentStateCLK) {
counter--;
currentDir = "CCW";
} else {
counter++;
currentDir = "CW";
}
Serial.print("Direction: ");
Serial.print(currentDir);
Serial.print(" | Counter: ");
Serial.println(counter);
}
// Save last CLK state
lastStateCLK = currentStateCLK;
// Read push button
int btnState = digitalRead(SW_PIN);
//If we detect LOW signal, button is pressed
if (btnState == LOW) {
//if 200ms have passed since last LOW pulse, it means that the
//button has been pressed, released and pressed again
if (millis() - lastButtonPress > 200) {
Serial.println("Button pressed!");
// Save last button press event
lastButtonPress = millis();
}
}
// Put in a slight delay to help debounce the reading
delay(1);
} بعد از آپلود کد و باز کردن Serial Monitor، نوب را در هر دو جهت بچرخانید و تغییرات کانتر را مشاهده کنید. دکمه را نیز فشار دهید و چک کنید آیا “Button pressed!” چاپ میشود یا نه.

اگر جهتِ نمایش داده شده مخالف انتظار شما بود، به سادگی ارتباط پایههای CLK و DT را جابهجا کنید تا جهت معکوس شود.
توضیح کد (روش Polling)
در ابتدای کد، پینهای ESP32 که به CLK، DT و SW متصل میشوند تعریف شدهاند:
#define CLK_PIN 25 #define DT_PIN 26 #define SW_PIN 27
سپس چند متغیر مهم ایجاد شده است:
- متغیر
counterتعداد گامهایی که انکودر چرخیده را نگه میدارد. - متغیرهای
currentStateCLKوlastStateCLKوضعیت پایهٔ CLK را در زمانهای مختلف ذخیره میکنند تا بتوان حرکت را تشخیص داد. - متغیر
currentDirجهت فعلی چرخش را به صورت “CW” یا “CCW” نمایش میدهد. - متغیر
lastButtonPressبرای جلوگیری از خوانشهای غلطِ دکمه استفاده میشود تا از شمارش چندبارهٔ یک فشردن جلوگیری کند.
int counter = 0; int currentStateCLK; int lastStateCLK; String currentDir = ""; unsigned long lastButtonPress = 0;
در تابع setup() پینها به عنوان ورودی تعریف شده و پولآپ داخلی فعال شدهاند تا سیگنالها پایدار باشند. همچنین Serial Monitor با سرعت 115200 شروع میشود. سپس وضعیت ابتدایی CLK خوانده شده و در lastStateCLK ذخیره میگردد تا مرجعی برای تشخیص تغییرات بعدی وجود داشته باشد.
pinMode(CLK_PIN, INPUT_PULLUP); pinMode(DT_PIN, INPUT_PULLUP); pinMode(SW_PIN, INPUT_PULLUP); Serial.begin(115200); lastStateCLK = digitalRead(CLK_PIN);
در حلقهٔ اصلی وضعیت فعلی CLK خوانده میشود و با وضعیت قبلی مقایسه میگردد. اگر تغییر رخ داده و سیگنال به HIGH رسیده باشد، یک پالس تشخیص داده شده است. برای جلوگیری از شمارش دو بارِ همان گام، تنها به لبهٔ صعودی واکنش نشان میدهیم.
جهت با خواندن پایهٔ DT در همان لحظه تعیین میشود:
- اگر DT با CLK متفاوت باشد، جهت CCW است و کانتر کاهش مییابد.
- اگر DT با CLK یکسان باشد، جهت CW است و کانتر افزایش مییابد.
if (digitalRead(DT_PIN) != currentStateCLK) {
counter--;
currentDir = "CCW";
} else {
counter++;
currentDir = "CW";
} سپس جهت و مقدار کانتر در Serial Monitor چاپ میشود و وضعیت قبلی CLK آپدیت میگردد تا در دور بعدی بتوان تغییر جدید را تشخیص داد.
Serial.print("Direction: ");
Serial.print(currentDir);
Serial.print(" | Counter: ");
Serial.println(counter);
lastStateCLK = currentStateCLK; برای خواندن دکمه، اگر SW برابر LOW باشد به معنی فشرده شدن دکمه است (چون پولآپ داخلی فعال است و پایه در حالت عادی HIGH است). برای حذف اثر bounce، بررسی میکنیم که حداقل 200 میلیثانیه از آخرین فشردن معتبر گذشته باشد.
int btnState = digitalRead(SW_PIN);
if (btnState == LOW) {
if (millis() - lastButtonPress > 200) {
Serial.println("Button pressed!");
lastButtonPress = millis();
}
} حلقه با تاخیر بسیار کوچک 1 میلیثانیه به پایان میرسد تا خوانشها پیش از چرخهٔ بعدی کمی پایدار شوند.
delay(1);
مثال 2 – خواندن انکودر با استفاده از Interrupt
در مثال قبل از polling استفاده کردیم که محدودیتهایی دارد: پردازنده مکرراً پایهها را میخواند حتی وقتی نوب حرکت نمیکند، پاسخدهی فوراً انجام نمیشود اگر ESP32 درگیر کار دیگری باشد، و اگر نوب خیلی سریع چرخانده شود ممکن است برخی تغییرات بین دو خوانش از دست برود.
برای حل این موارد از وقفه استفاده میکنیم. با استفاده از interrupt، ESP32 فوراً هنگام تغییر سیگنال واکنش نشان میدهد و دیگر نیازی به خواندن مداوم در حلقه نیست. این روش کارآمدتر و قابلاطمینانتر است.
تفاوت دیگر این مثال این است که به جای روش یک-لبهای، از روش کامل حالت-گذار (full state-transition) استفاده میکنیم. در این روش تغییرات هر دو کانال A و B ردیابی شده، حالت قبلی و فعلی ذخیره میشود و از ترتیب توالی حالات برای رمزگشایی حرکت استفاده میشود.
انکودر چهار حالت ممکن تولید میکند: 00، 01، 11 و 10. ترتیب ظاهر شدن این حالات جهت چرخش را مشخص میکند. یک ترتیب نشاندهندهٔ CW و ترتیب معکوس نشاندهندهٔ CCW است.
اسکچ مبتنی بر وقفه را اجرا کنید و سپس آن را مرحله به مرحله با هم بررسی میکنیم.
// Pin Configuration
#define CLK_PIN 25
#define DT_PIN 26
#define SW_PIN 27
// Variables modified inside an interrupt MUST be declared 'volatile'
volatile int32_t encoderCount = 0; // full decoded position
volatile uint8_t encoderState = 0; // last 2-bit AB state
volatile bool buttonPressed = false;
volatile uint32_t lastButtonInterruptMs = 0;
// Quadrature Decode Table
// Index = previous_AB << 2 | current_AB // Value = movement delta // // Valid transitions: // 00->01, 01->11, 11->10, 10->00 = +1
// 00->10, 10->11, 11->01, 01->00 = -1
//
// Invalid/bounce transitions = 0
static const int8_t QUAD_TABLE[16] = {
0, -1, +1, 0,
+1, 0, 0, -1,
-1, 0, 0, +1,
0, +1, -1, 0
};
// ISR for Encoder
// IRAM_ATTR loads this function into RAM for faster execution on ESP32
void IRAM_ATTR handleEncoderISR() {
// Read both pins as quickly as possible
uint8_t a = digitalRead(CLK_PIN);
uint8_t b = digitalRead(DT_PIN);
uint8_t currentState = (a << 1) | b;
uint8_t index = (encoderState << 2) | currentState; encoderCount += QUAD_TABLE[index]; encoderState = currentState; } // ISR for Button void IRAM_ATTR handleButtonISR() { uint32_t now = millis(); // acceptable in Arduino-ESP32 ISR for simple debounce usage if (now - lastButtonInterruptMs > 200) {
buttonPressed = true;
lastButtonInterruptMs = now;
}
}
// Atomic Read Helper
int32_t readEncoderCount() {
noInterrupts();
int32_t value = encoderCount;
interrupts();
return value;
}
// If your encoder produces 4 counts per detent, divide by 4 to get "clicks".
int32_t readEncoderDetents() {
return readEncoderCount() / 4;
}
void setup() {
Serial.begin(115200);
// Set encoder pins as inputs
pinMode(CLK_PIN, INPUT_PULLUP);
pinMode(DT_PIN, INPUT_PULLUP);
pinMode(SW_PIN, INPUT_PULLUP);
// Initialize starting AB state before enabling interrupts
uint8_t a = digitalRead(CLK_PIN);
uint8_t b = digitalRead(DT_PIN);
encoderState = (a << 1) | b; // Attach interrupt on both channels attachInterrupt(digitalPinToInterrupt(CLK_PIN), handleEncoderISR, CHANGE); attachInterrupt(digitalPinToInterrupt(DT_PIN), handleEncoderISR, CHANGE); // button interrupt attachInterrupt(digitalPinToInterrupt(SW_PIN), handleButtonISR, FALLING); Serial.println("ESP32 Rotary Encoder Ready"); } void loop() { static int32_t lastReportedDetent = 0; int32_t detents = readEncoderDetents(); if (detents != lastReportedDetent) { int32_t diff = detents - lastReportedDetent; lastReportedDetent = detents; if (diff > 0) {
Serial.printf("Direction: CW | Counter: %ld\n", (long)detents);
} else {
Serial.printf("Direction: CCW | Counter: %ld\n", (long)detents);
}
}
if (buttonPressed) {
noInterrupts();
buttonPressed = false;
interrupts();
Serial.println("Button pressed");
}
delay(1);
} بعد از آپلود کد، Serial Monitor را باز کنید. نوب را در هر دو جهت بچرخانید و تغییرات کانتر را مشاهده کنید. دکمه را فشار دهید تا ببینید پیام “Button pressed” چاپ میشود یا نه.
توضیح کد (روش Interrupt)
در ابتدای کد، پینهای ESP32 که به CLK، DT و SW متصل هستند تعریف شدهاند:
#define CLK_PIN 25 #define DT_PIN 26 #define SW_PIN 27
سپس متغیرهایی برای پیگیری وضعیت انکودر و دکمه تعریف میشوند. متغیرهایی که داخل ISR تغییر میکنند با volatile علامت زده شدهاند تا کامپایلر بداند مقدار آنها میتواند در هر لحظه تغییر کند و باید همیشه از حافظه خوانده شوند:
encoderCountموقعیت کامل رمزگشدهٔ انکودر را ذخیره میکند (قابل تغییر در ISR).encoderStateآخرین حالت 2 بیتی AB را نگه میدارد (قابل تغییر در ISR).buttonPressedبه برنامهٔ اصلی اعلام میکند که دکمه فشرده شده است.lastButtonInterruptMsزمان آخرین فشردن معتبر را برای حذف bounce نگه میدارد.
volatile int32_t encoderCount = 0; volatile uint8_t encoderState = 0; volatile bool buttonPressed = false; volatile uint32_t lastButtonInterruptMs = 0;
در ادامه یک جدول decode برای کوادراتور تعریف شده است. انکودر چهار حالت 00، 01، 11 و 10 تولید میکند و هنگام چرخش این حالات در ترتیبی خاص ظاهر میشوند. جدول به سرعت مشخص میکند که گذار از حالت قبلی به حالت فعلی به معنای +1، -1 یا بدون تغییر است. گذارهای نامعتبر (ناشی از bounce یا نویز) مقدار 0 دارند.
static const int8_t QUAD_TABLE[16] = {
0, -1, +1, 0,
+1, 0, 0, -1,
-1, 0, 0, +1,
0, +1, -1, 0
}; ISR مربوط به انکودر هر بار که CLK یا DT تغییر کند صدا زده میشود. صدا زدن این تابع با IRAM_ATTR باعث میشود تابع در حافظهٔ RAM قرار گیرد تا اجرای آن در زمان وقفه سریعتر باشد.
void IRAM_ATTR handleEncoderISR() {
// Read both pins as quickly as possible
uint8_t a = digitalRead(CLK_PIN);
uint8_t b = digitalRead(DT_PIN);
uint8_t currentState = (a << 1) | b;
uint8_t index = (encoderState << 2) | currentState;
encoderCount += QUAD_TABLE[index];
encoderState = currentState;
} در این تابع ابتدا هر دو پایه با بیشترین سرعت ممکن خوانده میشوند و سپس به یک عدد 2 بیتی به نام currentState ترکیب میگردند، مثلاً:
- اگر CLK = 0 و DT = 0، حالت 00 است.
- اگر CLK = 0 و DT = 1، حالت 01 است.
- اگر CLK = 1 و DT = 0، حالت 10 است.
- اگر CLK = 1 و DT = 1، حالت 11 است.
بعد حالت قبلی و حالت فعلی را ترکیب میکنیم تا شاخصی برای جدول جستجو بسازیم و بر اساس مقدار جدول به encoderCount افزوده یا از آن کاسته میشود. سپس encoderState با حالت فعلی جایگزین میشود تا در وقفهٔ بعدی به عنوان حالت قبلی استفاده گردد.
ISR مربوط به دکمه نیز هنگام لبهٔ FALLING اجرا میشود. در آن جا از یک روش سادهٔ debounce استفاده شده: زمان فعلی گرفته میشود و اگر بیش از 200 میلیثانیه از آخرین وقفهٔ معتبر گذشته باشد، علامتِ فشردن دکمه ثبت میشود. بهجای چاپ مستقیم داخل ISR، تنها فلگ buttonPressed به true تنظیم میشود تا عملیات چاپ و پردازش کندتر در حلقهٔ اصلی انجام گیرد؛ این طرح باعث کوتاه ماندن ISR میشود.
void IRAM_ATTR handleButtonISR() {
uint32_t now = millis();
if (now - lastButtonInterruptMs > 200) {
buttonPressed = true;
lastButtonInterruptMs = now;
}
} برای خواندن ایمن مقدار encoderCount که در ISR تغییر میکند، تابع کمکی readEncoderCount() تعریف شده که با استفاده از noInterrupts() و interrupts() خوانش اتمیک انجام میدهد تا مقدار حین خواندن تغییر نکند.
int32_t readEncoderCount() {
noInterrupts();
int32_t value = encoderCount;
interrupts();
return value;
}
int32_t readEncoderDetents() {
return readEncoderCount() / 4;
} چرا تقسیم بر 4؟ بسیاری از انکودرها در هر detent (هر کلیک فیزیکی) چهار تغییر حالت تولید میکنند، بنابراین تقسیم بر 4 شمارش خام را به تعداد کلیکها تبدیل میکند.
در تابع setup() ابتدا وضعیت ابتدایی AB خوانده و در encoderState ذخیره میشود تا پیش از فعالسازی وقفهها حالت شروع صحیح تنظیم شده باشد. سپس وقفهها برای هر دو کانال با مد CHANGE متصل میشوند تا هر تغییر LOW↔HIGH ثبت گردد. وقفهٔ دکمه با مد FALLING متصل میشود چون فشردن دکمه باعث سقوط سطح از HIGH به LOW میشود.
uint8_t a = digitalRead(CLK_PIN); uint8_t b = digitalRead(DT_PIN); encoderState = (a << 1) | b; attachInterrupt(digitalPinToInterrupt(CLK_PIN), handleEncoderISR, CHANGE); attachInterrupt(digitalPinToInterrupt(DT_PIN), handleEncoderISR, CHANGE); attachInterrupt(digitalPinToInterrupt(SW_PIN), handleButtonISR, FALLING);
در حلقهٔ اصلی با فراخوانی readEncoderDetents() موقعیت فعلی خوانده میشود و اگر با آخرین موقعیت گزارش شده تفاوت داشته باشد، جهت حرکت و مقدار جدید چاپ میشود. همچنین اگر فلگ buttonPressed تنظیم شده باشد، با غیرفعال کردن موقت وقفهها فلگ پاک شده و پیام مربوط چاپ میگردد.
int32_t detents = readEncoderDetents();
if (detents != lastReportedDetent) {
int32_t diff = detents - lastReportedDetent;
lastReportedDetent = detents;
if (diff > 0) {
Serial.printf("Direction: CW | Counter: %ld\n", (long)detents);
} else {
Serial.printf("Direction: CCW | Counter: %ld\n", (long)detents);
}
}
if (buttonPressed) {
noInterrupts();
buttonPressed = false;
interrupts();
Serial.println("Button pressed");
}
delay(1); این طراحی ISRها را کوتاه نگه میدارد و وظایف کندتر مانند چاپ پیام را به حلقهٔ اصلی محول میکند. تاخیر 1 میلیثانیه در انتهای حلقه لازم نیست برای عملکرد وقفه، اما از اجرای بیش از حد سریع حلقه جلوگیری میکند و در این مثال مشکلی ایجاد نمیکند.
سوالات متداول
کدام پینهای انکودر را به کدام پینهای ESP32 وصل کنم؟
طبق اتصالهای استفاده شده در مثالها: + (VCC) به 3.3V برد ESP32، GND به GND، CLK به GPIO 25، DT به GPIO 26 و SW به GPIO 27 متصل میشود.
تفاوت روش polling و استفاده از interrupt چیست و هر کدام چه محدودیتهایی دارند؟
روش polling به صورت مداوم وضعیت پایهها را در حلقه میخواند؛ ساده است اما همواره باید خوانش انجام شود، پاسخدهی ممکن است هنگام انجام کارهای دیگر با تاخیر مواجه شود و اگر نوب خیلی سریع بچرخد، ممکن است برخی تغییرات از دست بروند. روش interrupt تنها هنگام تغییر سیگنال واکنش نشان میدهد، کاراتر و قابلاطمینانتر است و با استفاده از روش state-transition میتواند تمام گذارها را رمزگشایی کند.
اگر جهت چرخش در خروجی عکس انتظارم بود چه کنم؟
اگر جهت نمایش داده شده معکوس است، کافی است اتصال پایههای CLK و DT را با هم جابهجا کنید تا جهت خوانده شده معکوس شود.













