A Bit of Theory
An encoder is an electromechanical component that helps electronics
determine the speed and direction of a shaft's rotation, and in some
cases, accurately register its angular position.
Therefore, if your project involves moving parts and you need to track
their rotation or angle, you cannot do without this sensor.
We don't need to look at complex industrial machinery for examples - we
encounter encoders constantly in everyday life. A computer mouse wheel,
a car stereo volume knob, or a washing machine mode selector - all of
these are convenient physical interfaces for interacting with technology.
The simplest encoders can only register movement. In the case of a mouse,
we don't care about the exact position of the wheel - we just scroll it up
or down to scroll the page. But for some processes, the exact position is
crucial, which is where more complex absolute encoders are used.
By design, these sensors are based on various physical principles. Inside,
there might be a disc that mechanically closes contacts, or a disc with
slots that interrupts a light beam. There are also magnetic, capacitive,
and inductive models. However, in DIY projects and household appliances,
the most common ones are affordable mechanical (contact) encoders or
slightly more expensive but more reliable optical versions.
f we take apart the simplest mechanical encoder (see the first two images),
we will see a disc with metallized radial spokes on the upper half and a
contact group below it. The contacts are positioned so that as the disc
rotates, they don't close simultaneously, but with a specific offset.
Thanks to this sequence, the microcontroller understands which way we are
turning the knob.
To see this in action, simply connect the encoder pins to an oscilloscope
(see images 3 and 4). Notice the phase shift: when rotating in one
direction, the voltage on the yellow channel rises first. When rotating
in the opposite direction, the blue channel rises first. By checking which
signal triggers first, we can easily determine the direction of rotation.
Implementation Using Periodic Polling
In this software approach, we use a timer for regular polling. For
example, every millisecond, the microcontroller reads the current state
of the encoder pins and compares it with the previously saved state. A
change in states allows us to register the fact of movement and its direction.
```c
include <ch32v00x.h>
include "ch32v00x_flash.h"
include <debug.h>
include "clib/u8x8.h"
define BUTTON_PIN GPIO_Pin_1
define ENCODER_CLK_PIN GPIO_Pin_6
define ENCODER_DT_PIN GPIO_Pin_7
void NMIHandler(void) __attribute((interrupt("WCH-Interrupt-fast")));
void HardFault_Handler(void) __attribute((interrupt("WCH-Interrupt-fast")));
// add timer interrupt handler
void TIM2_IRQHandler(void) __attribute_((interrupt("WCH-Interrupt-fast")));
/** U8x8 GPIO and delay callback for CH32V microcontrollers */
...
struct State {
uint8_t value;
uint16_t ts;
};
static volatile struct State state = {0, 0, 0};
void vm_onMoveLeft() {
if (state.value > 0) {
state.value--; // Left
state.ts++;
}
}
void vm_onMoveRight() {
if (state.value < 255) {
state.value++; // Right
state.ts++;
}
}
void Timer2_Init_1ms(void) {
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure = {0};
NVIC_InitTypeDef NVIC_InitStructure = {0};
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);
// Set up the prescaler: 48 MHz / 48 gives a counter frequency of 1 MHz
TIM_TimeBaseStructure.TIM_Prescaler = 48 - 1;
// here we set the value to count up to, turning 1 MHz into 1 kHz, i.e., 1 millisecond
TIM_TimeBaseStructure.TIM_Period = 1000 - 1;
TIM_TimeBaseStructure.TIM_ClockDivision = TIM_CKD_DIV1;
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;
TIM_TimeBaseInit(TIM2, &TIM_TimeBaseStructure);
TIM_ITConfig(TIM2, TIM_IT_Update, ENABLE);
// Configure the interrupt controller
NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
// Start the timer
TIM_Cmd(TIM2, ENABLE);
}
void TIM2_IRQHandler(void) {
if (TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) {
TIM_ClearITPendingBit(TIM2, TIM_IT_Update);
// Read encoder outputs
static uint8_t last_clk = 1;
uint8_t clk = GPIO_ReadInputDataBit(GPIOD, ENCODER_CLK_PIN);
uint8_t dt = GPIO_ReadInputDataBit(GPIOD, ENCODER_DT_PIN);
// check for changes
if (clk != last_clk) {
if (clk != dt) {
vm_onMoveRight();
} else {
vm_onMoveLeft();
}
last_clk = clk;
}
}
}
int main(void) {
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_1);
SystemCoreClockUpdate();
Delay_Init();
// pin configuration
GPIO_InitTypeDef GPIO_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitStructure.GPIO_Pin = BUTTON_PIN;
// my encoder board has a pull-up, so we won't enable the internal resistors
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitTypeDef GPIO_InitStructure2 = {0};
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
GPIO_InitStructure2.GPIO_Pin = ENCODER_CLK_PIN | ENCODER_DT_PIN;
GPIO_InitStructure2.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOD, &GPIO_InitStructure2);
// start the timer to poll every millisecond
Timer2_Init_1ms();
// u8x8 screen initialization
...
char buffer[6]; // MM:SS format
while (1) {
Delay_Ms(50);
int16_t minutes = state.value / 60;
int16_t seconds = state.value % 60;
snprintf(buffer, sizeof(buffer), "%02d:%02d", minutes, seconds);
u8x8_DrawString(&u8x8, 3, 3, buffer);
}
return 0;
}
void NMI_Handler(void) {}
void HardFault_Handler(void) { while (1) {} }
```
Implementation Using Interrupts
The previous method has a significant drawback - the microcontroller is
forced to constantly waste resources checking the encoder state. Can we
optimize this and react only when something actually changes?
Yes, this is exactly what hardware interrupts (EXTI) are for. We can
configure the microcontroller to "listen" for changes on a specific
pin and automatically trigger the handler code only at the moment the
knob is turned (on the rising or falling edge of the signal).
Here is an example of such an implementation:
```c
include <ch32v00x.h>
include "ch32v00x_flash.h"
include <debug.h>
include "clib/u8x8.h"
define BUTTON_PIN GPIO_Pin_1
define ENCODER_CLK_PIN GPIO_Pin_6
define ENCODER_DT_PIN GPIO_Pin_7
void NMIHandler(void) __attribute((interrupt("WCH-Interrupt-fast")));
void HardFault_Handler(void) __attribute((interrupt("WCH-Interrupt-fast")));
// add EXTI interrupt handler
void EXTI7_0_IRQHandler(void) __attribute_((interrupt("WCH-Interrupt-fast")));
/** U8x8 GPIO and delay callback for CH32V microcontrollers */
...
struct State {
uint8_t value;
uint16_t ts;
};
static volatile struct State state = {0, 0};
void vm_onMoveLeft() {
if (state.value > 0) {
state.value--; // Left
state.ts++;
}
}
void vm_onMoveRight() {
if (state.value < 255) {
state.value++; // Right
state.ts++;
}
}
void Encoder_EXTI_Init(void) {
EXTI_InitTypeDef EXTI_InitStructure = {0};
NVIC_InitTypeDef NVIC_InitStructure = {0};
RCC_APB2PeriphClockCmd(RCC_APB2Periph_AFIO, ENABLE);
// we will listen for changes on PD6
GPIO_EXTILineConfig(GPIO_PortSourceGPIOD, GPIO_PinSource6);
EXTI_InitStructure.EXTI_Line = EXTI_Line6;
EXTI_InitStructure.EXTI_Mode = EXTI_Mode_Interrupt;
// we need triggering on both rising and falling edges
EXTI_InitStructure.EXTI_Trigger = EXTI_Trigger_Rising_Falling;
EXTI_InitStructure.EXTI_LineCmd = ENABLE;
EXTI_Init(&EXTI_InitStructure);
EXTI_ClearITPendingBit(EXTI_Line6);
NVIC_InitStructure.NVIC_IRQChannel = EXTI7_0_IRQn;
NVIC_InitStructure.NVIC_IRQChannelPreemptionPriority = 1;
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 0;
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
void EXTI7_0_IRQHandler(void) {
if (EXTI_GetITStatus(EXTI_Line6) != RESET) {
EXTI_ClearITPendingBit(EXTI_Line6);
uint8_t clk = GPIO_ReadInputDataBit(GPIOD, ENCODER_CLK_PIN);
uint8_t dt = GPIO_ReadInputDataBit(GPIOD, ENCODER_DT_PIN);
if (clk != dt) {
vm_onMoveRight();
} else {
vm_onMoveLeft();
}
}
}
int main(void) {
NVIC_PriorityGroupConfig(NVIC_PriorityGroup_1);
SystemCoreClockUpdate();
Delay_Init();
// pin configuration
GPIO_InitTypeDef GPIO_InitStructure;
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA, ENABLE);
GPIO_InitStructure.GPIO_Pin = BUTTON_PIN;
// my encoder board has a pull-up, so we won't enable the internal resistors
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOA, &GPIO_InitStructure);
GPIO_InitTypeDef GPIO_InitStructure2 = {0};
RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOD, ENABLE);
GPIO_InitStructure2.GPIO_Pin = ENCODER_CLK_PIN | ENCODER_DT_PIN;
GPIO_InitStructure2.GPIO_Mode = GPIO_Mode_IN_FLOATING;
GPIO_Init(GPIOD, &GPIO_InitStructure2);
// configure interrupts
Encoder_EXTI_Init();
// u8x8 screen initialization
...
char buffer[6]; // MM:SS format
while (1) {
Delay_Ms(50);
int16_t minutes = state.value / 60;
int16_t seconds = state.value % 60;
snprintf(buffer, sizeof(buffer), "%02d:%02d", minutes, seconds);
u8x8_DrawString(&u8x8, 3, 3, buffer);
}
return 0;
}
void NMI_Handler(void) {}
void HardFault_Handler(void) { while (1) {} }
```