Skip to content
Informatics

C for microcontrollers: registers, bit operations and interrupts

Peripheral registers, masks and bit operations, access with volatile, interrupts and service routines, non-blocking timing, the rules of robust firmware.

For an electronics engineer C is not just for calculations: it is for making a microcontroller work, that is switching on an LED, reading a button, generating a PWM signal, talking to a sensor. This kind of programming, in direct contact with the hardware, is called embedded. In this lesson we put together what we have seen (fixed-width types, bitwise operators, pointers, loops) to understand how C controls peripherals through registers, and we look at interrupts and the rules of robust firmware.

Peripheral registers

In a microcontroller each peripheral (input/output ports, timers, analogue-to-digital converter, serial interfaces) is controlled by writing and reading its registers: memory cells at fixed addresses, described in the datasheet, in which each bit or group of bits has a meaning. For example, in an 8-bit output port, writing 1 to bit 3 drives pin 3 high.

An 8-bit register with bit 3 highlighted and the operations to set, clear, toggle and read it
An 8-bit register (0100 1011). The four basic operations on bit 3: set it to 1 with OR, clear it with AND and an inverted mask, toggle it with XOR, read it with a shift and an AND.

Bit operations

To change one bit without touching the others you use masks: (1u << n) is a number with only bit n set.

#include <stdint.h>

#define LED_PIN 3

PORTB |=  (1u << LED_PIN);     /* set bit 3: LED on */
PORTB &= ~(1u << LED_PIN);     /* clear bit 3: LED off */
PORTB ^=  (1u << LED_PIN);     /* toggle bit 3: the LED changes state */

if (PINB & (1u << 2)) {        /* read bit 2: button pressed? */
    /* ... */
}

For multi-bit fields, mask and shift are used together. If bits 4 to 6 of a configuration register select an interface’s speed (8 possible values):

reg = (reg & ~(0x7u << 4)) | ((speed & 0x7u) << 4);     /* writes 3 bits leaving the others alone */
uint8_t s = (reg >> 4) & 0x7u;                          /* reads them back */

This is exactly what manufacturers’ library functions (HAL) do, and it pays to be able to read them anyway: when something does not work, you go and look at the register.

How a register is accessed: volatile

Registers sit at fixed addresses. In manufacturers’ header files they are defined more or less like this:

#define GPIOA_ODR  (*(volatile uint32_t *)0x40020014u)

GPIOA_ODR |= (1u << 5);        /* writes directly to address 0x40020014 */

The address is converted to a pointer and dereferenced. The volatile keyword tells the compiler that the value can change at any moment for reasons outside the program (the hardware, or an interrupt) and that every read and write must really be performed, in the order written. Without volatile an optimising compiler might read a register only once and reuse the value, or remove a write that “looks” useless: the code works without optimisation and breaks with -O2, one of the classic mysteries for beginners.

Interrupts

Checking a button continuously in a loop (polling) wastes time and can miss short events. With interrupts the hardware tells the processor: when an event arrives (an edge on a pin, a byte on the serial port, a timer expiring) the processor suspends what it is doing, runs a dedicated function, the interrupt service routine (ISR), and then resumes.

#include <stdint.h>
#include <stdbool.h>

volatile bool data_ready = false;      /* shared between ISR and main: volatile */
volatile uint16_t last_reading;

void ADC_IRQHandler(void)              /* called by the hardware at end of conversion */
{
    last_reading = ADC_DATA;           /* the converter's register */
    data_ready = true;
}

int main(void)
{
    init_adc();
    for (;;) {
        if (data_ready) {
            data_ready = false;
            process(last_reading);
        }
        /* here the processor can also be put in low-power mode until the next interrupt */
    }
}

The rules for ISRs: they must be short (no printf, no waiting), variables shared with the main program must be declared volatile, and if they are wider than the processor reads in one go (a uint32_t on an 8-bit processor) access from the main program must be protected by briefly disabling interrupts, otherwise you can read a value that is half old and half new.

Timing without blocking

A delay(500) function blocks the processor for half a second: during that time it does not read buttons or answer the serial port. Well-written firmware uses a millisecond counter updated by a timer and compares times:

static uint32_t last = 0;
if ((uint32_t)(millis() - last) >= 500u) {      /* unsigned subtraction survives the wrap-around */
    last += 500u;
    blink_led();
}

Thanks to unsigned integers’ modulo 2^{32} arithmetic (lesson on data representation), the difference is correct even when the counter restarts from zero after 49.7 days.

The rules of robust firmware

  • Use fixed-width types (uint8_t, int32_t) and check overflow edge cases.
  • Avoid malloc after initialisation and deep recursion: memory is scarce and an error does not show up at once.
  • Enable the watchdog, a hardware timer that resets the microcontroller if the program hangs and stops “feeding” it.
  • Always read the component’s datasheet and errata: peripherals have precise initialisation sequences and, sometimes, documented defects.
  • Compile with all warnings, and in professional projects follow a standard such as MISRA C.

This is where the course ends: from computer architecture to a microcontroller’s registers, C remains the language through which software talks directly to electronics.

Subscribe
Notify of
guest
0 Comments
Oldest
Newest Most Voted
0
Would love your thoughts, please comment.x
()
x