Projects

STM32 Line Follower

Eight reflectance sensors, three clocks running at once, and one number that says where the line is.

Embedded control STM32F411 · HAL · DMA 2023
Diagram of the eight-sensor array over a black line: each sensor shaded by its reading, its weight printed beneath, and the resulting position marked on a scale from minus fifty to plus fifty
200 Hzcontrol loop
8calibrated sensors
10 kHzmotor PWM
80 MHzSTM32F411

A line follower is a deceptively good exercise, because the hard parts are not the ones you expect. The PID is textbook. What decides whether the robot is quick or twitchy is everything around it: how the sensors are calibrated, how eight readings collapse into one number, how the sampling is kept away from the control loop, and what happens in the moment the line is not under the robot at all.

This one runs bare-metal on an STM32F411 at 80 MHz with the HAL, laid out in CubeIDE. No RTOS — the structure comes from three peripherals running at three different rates and staying out of each other's way.

Calibrating the eye.

No two sensors agree, and neither does the room.

An infrared reflectance sensor returns a voltage, and that voltage means nothing on its own. Each one has a different LED brightness and a different phototransistor gain; the tape reflects differently from the paper; and the ambient light in the room shifts the whole set. A fixed threshold is a promise the hardware cannot keep.

So the robot is calibrated before every run. Hold a button down and sweep the array back and forth across the line, and the firmware records a running minimum and maximum per channel — the minima start at 4095 and the maxima at 0, so the first reading claims both. A second button throws the calibration away and starts again.

After that every reading is mapped onto its own measured span:

  • norm = 1 − (raw − min) / (max − min)

which puts every sensor on the same 0 to 1 scale — 1 meaning fully over the line — regardless of what raw numbers it happens to produce. The inversion is there because the line is dark, so the least reflected light means the most line.

Where is the line?

Eight numbers in, one number out.

The controller needs a single error signal, so the eight normalised readings are collapsed into one by weighting each sensor by its physical offset across the array — −50 at the far left through to +50 at the far right, evenly spaced by 14.28 — and summing.

The useful property is that the answer is continuous. If the line sits between two sensors, both light up partially and the weighted sum lands between their two offsets, so the robot can resolve a position far finer than the sensor spacing. A simple "which sensor is darkest" scheme would quantise the error to eight values and make the steering step rather than flow.

The sensor array with the line falling between two sensors, both partially lit, and the position landing between their two offsets
The line between two sensors. Both read partially, and the result lands between them.

One detail betrays the hardware: the weights are not applied in channel order. The ADC ranks are wired to sensors that are not in physical sequence, so the mapping from channel to offset is scrambled in the source — the kind of line that looks like a typo until you look at the board.

Three clocks.

Sample fast, decide slowly, drive faster.

The part I like most about this firmware is that the three jobs run at three rates, and none of them waits for another.

The ADC free-runs. It is set to scan all eight channels continuously and hand the results to DMA, which drops them into a buffer in memory without involving the CPU at all. At 3-cycle sampling on a 20 MHz ADC clock a full sweep of eight takes about six microseconds, so the buffer is refreshed thousands of times between control ticks and the loop simply reads the freshest values whenever it wants them. No start, no poll, no blocking.

The control loop is a timer interrupt at 200 Hz — normalise, compute position, run the PID, write two duty cycles, done. Five milliseconds is loose for an M4 at 80 MHz, which is the point: the deadline is never in question.

The PWM runs at 10 kHz, above hearing, so the motors do not whine. And the timer is set up with a quiet piece of arithmetic: prescale 80 MHz down to exactly 1 MHz and set the period to 100, and the counter now ticks in microseconds with a span of exactly one hundred. The compare register is the duty cycle in percent — the controller's output goes straight into the register with no scaling at all.

Three horizontal rate axes on a logarithmic scale showing the 200 Hz control loop, the 10 kHz PWM and the roughly 170 kHz ADC sweep rate
Three rates, three decades apart, sharing one microcontroller.

Steering, and losing the line.

The PID is the easy half.

With a position and a setpoint of zero, the rest is a standard PID — proportional 2, integral 0.01, derivative 0.32, tuned on the track rather than derived. Its output becomes a differential: a base speed plus the correction on one wheel, minus it on the other, each clamped so neither can be commanded past full or below a standstill. Each motor gets two compare channels, one per direction of its H-bridge, so reversing a wheel is a matter of which channel carries the duty.

The interesting case is the one PID cannot handle. Come into a hairpin too fast and the line leaves the array entirely — every sensor reads white, the error goes to zero, and a naive controller drives happily straight on into the wall, perfectly confident it is centred.

So the firmware keeps the previous frame's readings for the left and right thirds of the array. When the middle sensors all fall below 0.4, the robot stops following and starts hunting: it spins in place toward whichever side last saw the line, at 90 % duty, until the line comes back under the array. One frame of memory is all it takes to turn a crash into a recovery.

A two-state diagram: following the line with a PID differential, and a lost state that spins toward the side the line was last seen at 90 percent duty
Two states. The previous frame is kept for exactly one purpose.

On the track.

Where the tuning gets settled.

The serial port carries the position estimate out to a laptop while the robot drives, which is how the gains were found — watching the number oscillate, ring or lag on a real corner tells you far more than reasoning about it does. It is also the one piece of the loop that costs real time, so it is there for tuning and not for the run.

Specification.

MCU
STM32F411RE at 80 MHz, HSI with PLL, STM32CubeIDE and the HAL
Sensing
8 reflectance channels on ADC1, 12-bit, scan mode, 3-cycle sampling
Transfer
DMA in circular mode, continuous requests, no CPU involvement
Calibration
Per-channel min and max captured on a button-held sweep, reset on a second button
Normalisation
1 − (raw − min) / (max − min), giving 0 to 1 per sensor
Position
Weighted sum of the eight readings, offsets ±50 to ±7.16, clamped to ±50
Control
PID on TIM10 at 200 Hz — kp 2, ki 0.01, kd 0.32, setpoint 0
Drive
TIM3, four PWM channels at 10 kHz, compare register equal to duty in percent
Recovery
Spin toward the last-seen side at 90 % when all inner readings fall below 0.4
Telemetry
USART2 at 115200, position streamed live for gain tuning

Read the code, or get in touch.