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
- Set bit n:
reg |= (1u << n); - Clear bit n:
reg &= ~(1u << n); - Toggle / test bit n:
reg ^= (1u << n); if (reg & (1u << n)) { … } - Write a field:
reg = (reg & ~(MASK << POS)) | ((val & MASK) << POS);
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.
- Set: 0x41 | (1<<3) | (1<<5) = 0x41 | 0x08 | 0x20 = 0x69
- Clear bit 0: 0x69 & ~0x01 = 0x68
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.
- Clear field: 0xFF & ~(0x7 << 4) = 0xFF & ~0x70 = 0x8F
- Insert: 0x8F | (2 << 4) = 0x8F | 0x20 = 0xAF
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 °COn MCUs without an FPU, integer and fixed-point arithmetic are far faster and fully deterministic.
Common mistakes
- Wrong: Forgetting volatile on a flag set by an interrupt. — Right: Declare ISR-shared variables volatile (and protect multi-byte reads).
- Wrong: Using delay loops to wait for events. — Right: Use timers, interrupts and state machines so the CPU can sleep or do other work.
Applications
- Device drivers: Peripheral drivers configure registers bit by bit and move data with interrupts and DMA.
- Control firmware: Motor, power-supply and battery controllers run fixed-point control loops at tens of kHz.
- Safety-critical code: Automotive and medical firmware follows MISRA C with static analysis and coverage testing.
Key takeaways
- Use <stdint.h> types and unsigned masks for bit manipulation.
- volatile for hardware and ISR-shared data; const tables in flash.
- Short ISRs + flags/queues + non-blocking state machines.
- Avoid heap, recursion and floats where possible; use a watchdog.
Going further
- Memory barriers and ordering: On multi-core MCUs or with DMA, compiler and CPU reordering can break assumptions; __DMB/__DSB barriers and C11 atomics enforce ordering.
- Linker scripts: Linker scripts place code and data into flash and RAM regions, define the stack and heap, and let you put fast code in RAM or tables at fixed addresses.
- Hardware abstraction layers: HALs and CMSIS drivers make code portable across MCUs, at some cost in size and speed; performance-critical paths often use direct register access.