Embedded C

C is the language of firmware. Learn fixed-width types, bit manipulation, register access, volatile, const and memory placement, interrupts and shared data, state machines, fixed-point maths and defensive practices like watchdogs.

Embedded C is ordinary C used close to the hardware: reading and writing peripheral registers, handling interrupts, and fitting into kilobytes of memory with deterministic timing. Most microcontroller vendors, RTOSes and drivers are written in it.

A few language features matter far more here than in desktop programming — bit operations, volatile, fixed-width integers and careful use of memory — and a few desktop habits (malloc, printf, floating point everywhere) are best avoided.

An analogy

Writing embedded C is like building a ship in a bottle: you have precise tools and very little room, so every piece must be placed deliberately.

Fixed-width types

int can be 16 or 32 bits depending on the compiler. Firmware uses <stdint.h> types — uint8_t, int16_t, uint32_t — so register widths and data layouts are exact and portable.

Bit manipulation

Hardware registers pack many settings into individual bits and fields. Masks and shifts set, clear, toggle and test them without disturbing neighbouring bits (read–modify–write). Always use unsigned constants (1u) to avoid undefined behaviour when shifting into the sign bit.

volatile and const

volatile tells the compiler that a value can change outside the visible code (hardware registers, variables shared with ISRs), so every access really reads or writes memory. const places lookup tables in flash rather than scarce RAM. Neither makes code thread-safe on its own.

Interrupt-safe sharing and state machines

A multi-byte variable updated by an ISR can be read half-old, half-new by the main loop. Protect such accesses by briefly disabling interrupts or using atomic operations. Structure firmware as non-blocking state machines (switch on a state variable) instead of long delays, so the system stays responsive.

Robust firmware practices

Avoid dynamic allocation (fragmentation), recursion (unbounded stack) and unchecked pointer arithmetic. Enable a watchdog that resets the MCU if the main loop stalls. Follow MISRA C or CERT C guidelines, run static analysis, and unit-test logic off-target. Measure stack usage and worst-case execution time.

Formulas

Worked example: Bit operations on a register value

Given: reg = 0x41 (0100 0001); Set bits 3 and 5, then clear bit 0

Find: Final value.

Answer: 0x68 (0110 1000)

Worked example: Writing a 3-bit field

Given: Field at bits 6..4 (MASK = 0x7, POS = 4); reg = 0xFF, new value = 2

Find: New register value.

Answer: 0xAF

Registers, volatile and ISR-shared data

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

#define GPIOA_ODR   (*(volatile uint32_t *)0x4001080Cu)   // memory-mapped register
#define LED_PIN     5u

static volatile bool     tick_flag = false;   // shared with ISR -> volatile
static volatile uint32_t ms_ticks  = 0;

void SysTick_Handler(void) {       // runs every 1 ms
    ms_ticks++;
    if ((ms_ticks % 500u) == 0u) {
        tick_flag = true;          // just signal; do the work in main
    }
}

int main(void) {
    for (;;) {
        if (tick_flag) {
            tick_flag = false;
            GPIOA_ODR ^= (1u << LED_PIN);   // toggle LED every 500 ms
        }
    }
}

Without volatile, the compiler may keep tick_flag in a register and never see the ISR change it. Keep ISRs short and communicate through flags or queues.

Fixed-point instead of float

// Convert a 12-bit ADC reading (3.3 V ref) from an LM35 (10 mV/°C)
// into tenths of a degree, using only integer maths.
int32_t adc_to_temp_x10(uint16_t adc) {
    int32_t mv = ((int32_t)adc * 3300 + 2048) / 4096;   // rounded millivolts
    return mv;               // 10 mV per °C  ->  1 mV = 0.1 °C
}
// adc = 1241 -> 1000 mV -> returns 1000 -> 100.0 °C

On MCUs without an FPU, integer and fixed-point arithmetic are far faster and fully deterministic.

Common mistakes

Applications

Key takeaways

Going further