Library used to decode the direction and rotation of incremental mechanical rotary encoders, designed for AVR microcontrollers.
Contents
- Characteristics
- Connecting a rotary encoder
- How does it work
- Library
- Links
Characteristics
- Language: C++.
- Pin reading mode: using interrupts.
- Digital filter (de-bouncing): using a timer.
- Features: switch (button) reading, delta acceleration based on rotational speed.
- Dependencies: millis, pinInterrupt.
- Supported devices: tested on ATmega328PB. UDPI devices supported but not tested.
Connecting a rotary encoder
A typical mechanical rotary encoder has 3 pins for the encoder (A, B, C), two pins for a push button and two pins for the metallic shield connected to ground.
Pinout
SH (shield): connect to GND.
S1 and S2: the switch (if any). One pin is connected to ground and the other one to a micro-controller. S1 must be connected to VCC through a resistor (usually 10k). The internal pull-up resistors of the microcontroller can also be used. S1 idles at HIGH and goes LOW when the button is pressed.
A, B and C: pins connected to internal switches that when the encoder is rotated goes to a LOW state through pin C which is connected to GND. Both pins A and B must have a pull-up resistor (usually 10k). The internal pull-up resistors of the microcontroller can also be used. Some encoders have pins A and B reversed so when the encoder is rotated CW the sowftare will detect a CCW direction. This can easily be solved in software or by swaping the connections in hardware.
For the library to read the pins, connect pins A, B and S1 to the same port of the microcontroller.
Tip: the rotary encoder can be connected on the breadboard without an adapter, if placed in the middle where the shield pins can fit in the breadboard channel.
How does it work
A standard mechanical rotary encoder uses internal switches to generate two square waves, Channel A and Channel B, offset by a 90 degrees phase shift. This offset is called Quadrature.
Quadrature encoding
Mechanical encoders with physical "clicks" (detents) are designed to rest in a specific state when you aren't turning them. The idle state is usually HIGH due to pull-up resistors that can be external or internal inside the microcontroller. When the encoder is rotated, pins A and B are pulled LOW through a third pin C connected to ground.
A click is one full detent. Moving from one physical click to the next means cycling through all 4 distinct stages (1, 2, 3, 4) sequentially. A full detent cycle begins at HIGH-HIGH and ends right back at HIGH-HIGH.
Clockwise (CW) Rotation
- Stage 1 (Falling Edge A): Channel A drops to LOW first, while Channel B remains HIGH.
- Stage 2 (Falling Edge B): Channel B drops to LOW. Both channels are now LOW.
- Stage 3 (Rising Edge A): Channel A transitions back to HIGH, while Channel B stays LOW.
- Stage 4 (Rising Edge B): Channel B transitions back to HIGH. Both channels are back in their Idle HIGH detent position.
Counter-Clockwise (CCW) Rotation
- Stage 1 (Falling Edge B): Channel B drops to LOW first, while Channel A remains HIGH.
- Stage 2 (Falling Edge A): Channel A drops to LOW. Both channels are now LOW.
- Stage 3 (Rising Edge B): Channel B transitions back to HIGH, while Channel A stays LOW.
- Stage 4 (Rising Edge A): Channel A transitions back to HIGH. Both channels have successfully reset to the Idle HIGH detent position.
Reading the pins in software
There are two main methods of reading the encoder: by constantly polling the state of the pins or by using an interrupt.
Polling: if the time interval is short, e.g. 1ms, it has the advantage that it acts as a digital filter and it also doesn't trigger on every contact bouncing like with interrupts. A disadvantage is that it consume CPU cycles constantly all the time even if the encoder is not used.
Interrupts: all GPIO pins support PCINT interrupts that triggers when a falling or rising edge is detected. The advantage is the microcontroller processes the encoder as soon it is used by interrupting the main application flow and if the encoder is not used, the interrupt doesn't consume CPU cycles. The drawback is if the encoder has lots of electrical noise (bouncing), the interrupt will fire many times in a row. In my experience this is not an issue since I used a TFT display to show a 32-bit number incremented using a very low quality (bouncy) encoder without flickering or skipping. In case that is an issue, an RC filter can be used for pins A and B.
A common method of decoding is reading only a single edge (e.g., “When A goes LOW, check B”). While this method require less code, it fails or registers false ticks if the user hesitates, vibrates, or stops right on the physical threshold of a click. The quality and age of the encoder also matter, with unbranded or used encoders being more difficult to be read reliable without skipping or having duplicate clicks. With time the contacts oxidize causing more contact bouncing.
The present library reads the interrupt on both falling and rising edges of both pins requiring the encoder to go through all 4 stages until a turn (click) is registered.
Direction Verification via Phase Difference
When the library is triggered, it extracts the current portState and compares it to the previous state using changedBits. If it detects Stage 1, it notes that a movement has begun. It verifies direction by seeing which pin fell first. If A fell while B was HIGH direction is set to Clockwise. If B fell while A was HIGH direction is set to Counter-Clockwise.
De-bouncing
If the encoder signal were as clean as depicted in the functional diagram, reading them would be easy. Using a logic analyzer we can see that a real encoder doesn't produce nice symmetrical pulses.
It the above screen capture is a CW turn. Top channel is A and lower one is channel B. The issue is not that the low time of both channels don't have around the same length but the electrical noise due to contact bouncing on falling and rising edges.The above screen capture is the falling edge of channel A zoomed in. We can see a few high to low pulses until the signal settles to a low state. High time of some pulses are: 83ns, 500ns, 1us. This is a branded new encoder and is actually good with not that many bounces. With an unbranded or a low quality one, the electrical noise will be much worse. I have a cheap no brand abomination one that can produce bounces sometimes up to 10ms and sometimes it even manages to invert the idle state of channel B to low, not to mention pins A and B are swapped. I had to develop the algorithm around that since if you can read that the rest are not a problem.
For de-bouncing, the library uses the millis timer library with 1ms window. It
then measures both low time periods for channels A and B and it only validates
a turn if the low time periods are at least 1ms. This is to filter the short
spikes and to prevent false click detection. Since many projects require a
timing library, this method is preferred. This principle is also used to read
and de-bounce the switch.
Library
Class constructor
RotaryEncoder(char port, uint8_t pinA, uint8_t pinB, uint8_t btnPin = -1);
port: The microcontroller port letter where the encoder is physically connected (e.g., 'B', 'E'). For optimal performance, pinA, pinB, and the btnPin must reside on this same port.
pinA & pinB: The digital pin numbers corresponding to the quadrature output channels A and B.
btnPin: (Optional) The digital pin number for the integrated push button. Defaults to -1 if your encoder does not include a button or if you choose not to use it.
Public functions
Initialization
void init()
Initializes the underlying hardware. It configures the designated pins as inputs, enables the internal microcontroller pull-up resistors for channels A, B, and the button, and attaches the specialized PCINT interrupt handling vectors through the pinInterrupt library. Initializes the millis library using the F_CPU which ideally should be defined in your project configuration.
Read encoder
uint16_t read()
Returns the current accumulated position of the encoder as a signed 16-bit integer. This value increments with clockwise and counter-clockwise rotation. Each time the function is used, the internal accumulator is set to 0. The user application must use the received number of turns to, for example increment/decrement a number based on direction. If a number is incremented or a menu is scrolled and the received turns is greater than 1, the user can decide whether to increment/scroll in a single step or in discrete steps by iterating the number of turns and performing some action.
Acceleration
uint16_t calculateAcceleration()
Calculates a unified step size for this burst of pulses based on delta time from last turn. Very useful to increment a number to a high value based on rotation speed. The acceleration parameters are defined by ACCEL_THRESHOLD, ACCEL_MAX_STEP, ACCEL_WEIGHT, that can be tuned inside the header file. The returned value can be used instead of value returned by read() function to, for example, increment a number.
Get direction
RotaryEncoder::Direction getDirection()
Returns the direction defined by the Direction enum.
enum class Direction : int8_t { IDLE = 0, CW = 1, CCW = -1 };
Internal direction state remains set to last detected direction and not reset to 0. Since direction can be 1 or -1, it can be added to an integer and mathematically incremented by 1 if clockwise or decremented if counter-clockwise. E.g.: anumber += getDirection(). Received direction would need to be static type casting to an integer.
Read button
bool isButtonPressed()
Returns true if button is currently pressed or false if button is not pressed.
C++ example
Below is a C++ code example using two encoders with first one set to use acceleration and the second one not. A uart library which can be downloaded from the download section, is used to display the values to the user.
main.cpp
// Define F_CPU in project settings to be globally accessible // and easier to maintain. Needed by millis. // See: https://www.programming-electronics-diy.xyz/2024/01/defining-fcpu-in-microchip-studio.html #ifndef F_CPU #warning "F_CPU not defined. Define it in project properties." #elif F_CPU != 16000000 // replace with your actual frequency #warning "Wrong F_CPU frequency!" #endif #include <avr/io.h> #include <string.h> #include <stdio.h> #include "RotaryEncoder.h" #include "uart.h" // Instantiate the encoder statically in global memory space RotaryEncoder encoder1('E', PE0, PE1, PE2); RotaryEncoder encoder2('D', PD2, PD3, PD4); int main(void){
// Initialize encoder(s)
encoder1.init(); encoder2.init();
// Used to display the values on a serial console UART_begin(&uart0, 115200, UART_ASYNC, UART_NO_PARITY, UART_8_BIT); // Application Variables uint32_t encoder1_value = 0; uint32_t encoder2_value = 0; bool last_btn1_state = false; bool last_btn2_state = false; while(1) { // --- ENCODER 1 --- uint16_t clicks1 = encoder1.read(); if (clicks1 > 0) { // Calculate a unified step size for this burst of pulses uint16_t dynamicStep = encoder1.calculateAcceleration(); RotaryEncoder::Direction dir1 = encoder1.getDirection(); while (clicks1 > 0) { if (dir1 == RotaryEncoder::Direction::CW) { encoder1_value += dynamicStep; } else if (dir1 == RotaryEncoder::Direction::CCW) { if (encoder1_value >= dynamicStep) { encoder1_value -= dynamicStep; } else { encoder1_value = 0; // Prevent underflow } } clicks1--; } UART_sendString(&uart0, "Enc1: "); UART_sendInt(&uart0, encoder1_value); UART_sendString(&uart0, " \r\n"); } // --- ENCODER 2 --- uint16_t clicks2 = encoder2.read(); if (clicks2 > 0) { RotaryEncoder::Direction dir2 = encoder2.getDirection(); while (clicks2 > 0) { if (dir2 == RotaryEncoder::Direction::CCW) { encoder2_value++; } else if (dir2 == RotaryEncoder::Direction::CW) { if (encoder2_value > 0) encoder2_value--; // Prevent underflow wrap } clicks2--; } UART_sendString(&uart0, "Enc2: "); UART_sendInt(&uart0, encoder2_value); UART_sendString(&uart0, " \r\n"); } // --- ENCODER 1 BUTTON --- bool current_btn1_state = encoder1.isButtonPressed(); if (current_btn1_state != last_btn1_state) { last_btn1_state = current_btn1_state; if (current_btn1_state) { UART_sendString(&uart0, "Enc1: button pressed\r\n"); } else { UART_sendString(&uart0, "Enc1: button released\r\n"); } } // --- ENCODER 2 BUTTON --- bool current_btn2_state = encoder2.isButtonPressed(); if (current_btn2_state != last_btn2_state) { last_btn2_state = current_btn2_state; if (current_btn2_state) { UART_sendString(&uart0, "Enc2: button pressed\r\n"); } else { UART_sendString(&uart0, "Enc2: button released\r\n"); } } } return(0); }
Links
|
Link includes: - RotaryEncoder.c, RotaryEncoder.cpp - pinInterrupt.h, pinInterrupt.c - millis.h, millis.c - uart.h, uart.c (optional) - utils.h, utils.c (optional) |
|
| v1.0 | RotaryEncoder |
Changelog |
|
| v1.0 |
2026-05-16 Public release under GNU GPL v3 license. |





No comments:
Post a Comment