Wheel Encoders and Odometry: Tracking Robot Position with Arduino
Without feedback, a robot told to drive one metre simply runs its motors for a guessed time and ends up somewhere near the target. Wheel encoders fix this by counting how far each wheel actually turned. From those counts the robot can calculate its distance, speed and turn angle, and by adding up many small movements it can estimate its position on the floor. This estimate is called odometry or dead reckoning.
In this tutorial you will learn how quadrature encoders work, read two of them reliably with Arduino interrupts, convert ticks into distance, and implement the standard odometry update for a two-wheel differential drive robot. You will also see why odometry drifts over time, and what that means for real navigation.
How Quadrature Encoders Work
Gear motors such as the N20 and many 12 V metal gear motors are sold with a magnetic Hall-effect encoder on the rear motor shaft. The encoder has two outputs, A and B, that produce square waves 90 degrees out of phase. The number of pulses tells you how far the shaft turned, and which channel leads tells you the direction.
Forward: A _|--|__|--|__|-- Reverse: A _|--|__|--|__|--
B __|--|__|--|__|- B |--|__|--|__|--|_
On a rising edge of A: B LOW -> one direction
B HIGH -> the other directionBecause the encoder sits on the motor shaft, before the gearbox, its resolution at the wheel is multiplied by the gear ratio. Sellers list the pulses per motor revolution and the gear ratio, but these figures differ between products, so always measure: mark the wheel, turn it exactly ten revolutions by hand and divide the counted ticks by ten.
Ticks per wheel revolution N = encoder pulses per motor rev x gear ratio x edges counted Distance per tick d = (pi x wheel diameter) / N Example: measured N = 700 ticks/rev, wheel diameter 43 mm d = (3.1416 x 43) / 700 = 0.193 mm per tick
Wiring to an Arduino Uno
The Uno has two external interrupt pins, 2 and 3. Each encoder's channel A goes to one of them so that no pulse is missed, and channel B goes to an ordinary input to read direction.
| Encoder wire | Left motor | Right motor |
|---|---|---|
| Channel A | D2 (INT0) | D3 (INT1) |
| Channel B | D11 | D12 |
| Encoder VCC | 5 V (check your encoder rating) | 5 V |
| Encoder GND | GND | GND |
The Odometry Update
Every short time step, read how many ticks each wheel moved since the last step, convert them to distances dL and dR, and update the pose (x, y, theta). L is the wheelbase, the distance between the two wheel contact points.
d = (dR + dL) / 2 distance moved by robot centre dtheta = (dR - dL) / L change in heading (radians) x = x + d * cos(theta + dtheta/2) y = y + d * sin(theta + dtheta/2) theta = theta + dtheta
Using the heading at the middle of the step, theta plus half of dtheta, is a simple improvement over using the old heading and noticeably reduces error on curves.
Encoder and Odometry Code
// Quadrature encoder reading + differential-drive odometry (Arduino Uno)
const int L_A = 2, L_B = 11; // left encoder
const int R_A = 3, R_B = 12; // right encoder
// Robot geometry: MEASURE these on your robot
const float TICKS_PER_REV = 700.0; // ticks per wheel revolution (measured)
const float WHEEL_DIAM_M = 0.043; // wheel diameter in metres
const float WHEELBASE_M = 0.130; // distance between wheel centres
const float M_PER_TICK = PI * WHEEL_DIAM_M / TICKS_PER_REV;
volatile long leftTicks = 0, rightTicks = 0;
long prevLeft = 0, prevRight = 0;
float x = 0, y = 0, theta = 0; // pose in metres and radians
unsigned long lastUpdate = 0;
// ISRs: count on rising edge of A, use B for direction.
// If a wheel counts backwards when driving forward, swap the ++ and --.
void leftISR() { if (digitalRead(L_B)) leftTicks--; else leftTicks++; }
void rightISR() { if (digitalRead(R_B)) rightTicks++; else rightTicks--; }
void setup() {
pinMode(L_A, INPUT_PULLUP); pinMode(L_B, INPUT_PULLUP);
pinMode(R_A, INPUT_PULLUP); pinMode(R_B, INPUT_PULLUP);
attachInterrupt(digitalPinToInterrupt(L_A), leftISR, RISING);
attachInterrupt(digitalPinToInterrupt(R_A), rightISR, RISING);
Serial.begin(115200);
}
void loop() {
if (millis() - lastUpdate < 20) return; // 50 Hz update
float dt = (millis() - lastUpdate) / 1000.0;
lastUpdate = millis();
// Copy counters with interrupts off so a 4-byte read is not torn
noInterrupts();
long l = leftTicks, r = rightTicks;
interrupts();
float dL = (l - prevLeft) * M_PER_TICK;
float dR = (r - prevRight) * M_PER_TICK;
prevLeft = l; prevRight = r;
float d = (dR + dL) / 2.0;
float dTheta = (dR - dL) / WHEELBASE_M;
x += d * cos(theta + dTheta / 2.0);
y += d * sin(theta + dTheta / 2.0);
theta += dTheta;
theta = atan2(sin(theta), cos(theta)); // wrap to -pi..pi
float vL = dL / dt, vR = dR / dt; // wheel speeds in m/s
Serial.print("x="); Serial.print(x, 3);
Serial.print(" y="); Serial.print(y, 3);
Serial.print(" th="); Serial.print(theta * 180.0 / PI, 1);
Serial.print(" vL="); Serial.print(vL, 2);
Serial.print(" vR="); Serial.println(vR, 2);
}
Calibrating and Understanding Drift
- Push the robot by hand in a straight line for exactly one metre along a measuring tape. If x reads 0.95 m, scale the wheel diameter by 1 / 0.95.
- Rotate the robot on the spot through exactly ten full turns using a floor mark. Compare the summed heading with 10 x 360 degrees and correct the wheelbase in the same way.
- Repeat each test three times and use the average.
Even after calibration, odometry error grows the further the robot travels, because small errors in wheel diameter, wheel slip on turns and uneven floors are added up at every step. Heading errors are the worst, since a one degree heading error turns into a growing sideways error. Real robots correct this by fusing odometry with a gyroscope, landmarks, or a map using lidar.
Troubleshooting
- Counts jump randomly when motors run: motor noise on the encoder lines. Twist encoder wires, keep them away from motor leads, and add a small capacitor if the encoder datasheet allows.
- Count only increases: channel B is not connected or reads constantly the same level.
- x goes negative when driving forward: one or both ISRs have the wrong sign.
- Serial output slows the loop: printing at 115200 baud for every update is fine; at 9600 it may not keep up with 50 Hz.
Summary
Quadrature encoders give both distance and direction, and interrupts on pins 2 and 3 make sure no tick is missed. Measured ticks per revolution, wheel diameter and wheelbase turn tick counts into the robot's x, y and heading through a simple odometry update. Calibrate with straight and rotation tests, and expect drift that must eventually be corrected by other sensors.