Embedded C
C remains the language of firmware for reasons that have little to do with inertia. It compiles to predictable machine code with no hidden allocations or runtime, it maps cleanly onto memory-mapped hardware, every microcontroller vendor ships a C toolchain and a C HAL, and decades of existing drivers and standards assume it. Even teams writing new code in Rust read C daily.
Embedded C is a dialect in practice rather than in specification. You avoid the standard library's allocating functions, treat volatile as load-bearing, use fixed-width integer types everywhere, and write code that must be correct in the presence of asynchronous interrupts. The language is the same; the constraints select a very different subset of it.
TL;DR
volatileis required for hardware registers, ISR-shared variables, and DMA buffers — omitting it breaks under optimization.- Use
<stdint.h>fixed-width types (uint8_t,int32_t). Plainintvaries by platform. - Avoid dynamic allocation after startup; fragmentation causes failures weeks into uptime.
- Bit manipulation is constant work: set
|=, clear&= ~, toggle^=, test&. - Access to multi-byte data shared with an ISR must be atomic — guard it or use
atomictypes. - Ring buffers are the standard ISR-to-main-loop handoff.
staticat file scope is your module boundary;constbelongs in flash, not RAM.- MISRA C governs safety-critical work; even outside it, its rationale is worth knowing.
Quick Example
A complete, interrupt-driven UART receiver — the pattern most embedded C is built around:
A single-producer, single-consumer ring buffer needs no locks: the ISR only advances head, the main loop only advances tail, and both are volatile so neither caches a stale value. This is the workhorse pattern of embedded C.
Core Concepts
volatile
volatile tells the compiler that a variable can change outside the visible flow of the program, so it must actually load and store it rather than caching it in a register.
What volatile is not: it is not atomicity and it is not a memory barrier for concurrency. A volatile uint32_t on an 8-bit MCU is still read in four instructions and can be torn by an interrupt. Use it for correctness of access, and add explicit protection for correctness of sequence.
Fixed-width types
int is 16 bits on AVR and MSP430, 32 bits on ARM. Code that assumes otherwise breaks silently when ported. Registers have exact widths, protocol fields have exact widths, and your variables should too.
Watch integer promotion, which surprises people regularly:
Bit manipulation
The read-modify-write pattern (|=) is not atomic — it compiles to load, modify, store. If an interrupt modifies the same register in between, one update is lost. Some hardware provides atomic set/clear registers (STM32's BSRR, for instance) precisely to avoid this; use them when they exist.
Bit fields and packed structs
Bit-field ordering, padding, and alignment vary by compiler. Manual shifting and masking is more verbose and portable, which is why every protocol implementation ends up doing it.
Static allocation
Static allocation makes memory usage visible at link time: the map file tells you exactly how much RAM you use, and running out is a build error rather than a field failure.
static and const
static at file scope is C's module system — it's how you get encapsulation without a language feature for it. const on a large table is not a style preference: it moves kilobytes from scarce RAM into plentiful flash.
Interrupt-Safe Code
Atomic access to shared data
Saving and restoring the interrupt state matters: unconditionally enabling interrupts at the end of a critical section re-enables them inside an outer critical section that expected them off.
C11 gives a portable alternative where the toolchain supports it:
Keep ISRs minimal
Coding Standards
MISRA C
MISRA C is a set of rules for safety-critical C — automotive, medical, aerospace, industrial. Compliance is often contractual in those industries, and the reasoning is useful everywhere:
- No dynamic memory allocation.
- No recursion (unbounded stack growth).
- All control statements use braces, even one-liners.
- One
returnper function (contested, but the intent is analyzable control flow). - No implicit type conversions; casts are explicit.
- Every
switchhas adefault. - No
gotoexcept for a single, forward, error-handling jump.
Tools (PC-lint, Polyspace, Cppcheck with a MISRA addon) check compliance automatically. Even on a hobby project, the subset MISRA selects tends to produce firmware that fails less mysteriously.
Defensive practices
The unbounded while waiting on a hardware flag is a classic firmware hang: a disconnected sensor or an unclocked peripheral means the flag never sets and the device stops responding with no diagnostic.
Best Practices
Use volatile deliberately, not everywhere
It's required for registers, ISR-shared data, and DMA buffers. Applying it to ordinary variables defeats optimization for no benefit, and applying it as a substitute for proper synchronization hides a real bug.
Make memory usage visible
Read the linker map file. Know your .bss, .data, and stack usage, and set the stack size deliberately rather than accepting the default. Fill unused stack with a pattern at startup and check the high-water mark — stack overflow on a device with no MMU silently corrupts adjacent memory.
Use const aggressively
const on lookup tables, strings, and configuration puts them in flash. const on pointer parameters documents intent and lets the compiler help. On an MCU with 20 KB of RAM and 256 KB of flash, this is not a micro-optimization.
Bound every loop and every wait
Any loop waiting on external state needs a timeout and an error path. Any loop over a buffer needs a bound checked against the buffer's actual size. These two rules eliminate most hangs and most overflows.
Keep hardware access behind a thin abstraction
This makes the application testable on a host, portable to a new MCU, and readable. Keep the abstraction thin — a HAL that tries to abstract everything becomes its own source of bugs.
Test logic on the host
Business logic, protocol parsing, and state machines can be compiled and unit-tested on a PC with the hardware layer stubbed. This is far faster than flashing a board for every change, and it's where most logic bugs are actually found. See Unit Testing.
Enable all warnings and treat them as errors
-Wconversion in particular catches the implicit narrowing that causes silent data loss in firmware. Warnings you've decided to ignore accumulate until they hide a real one.
Common Mistakes
Missing volatile
Returning a pointer to a local
Integer promotion surprises
Unbounded hardware wait
sizeof on a decayed array
Non-atomic read-modify-write on a register
FAQ
Should I use C or C++ for firmware?
C is the safer default: universal toolchain support, predictable code generation, and every vendor HAL. C++ is genuinely useful on larger MCUs — RAII for resource cleanup, templates for zero-cost abstraction, constexpr for compile-time work, strong typing for units — provided you avoid exceptions, RTTI, and the allocating parts of the standard library. Many teams use a constrained "embedded C++" subset. On an 8-bit part with 2 KB of RAM, C.
Is printf usable in firmware?
Sparingly. The full implementation pulls in tens of kilobytes and can allocate; floating-point support adds more. Use a lightweight alternative (tinyprintf, nanoprintf), a vendor's minimal implementation, or a binary logging scheme where the device sends an ID and arguments and the host does the formatting. Never call it from an ISR.
How do I test embedded C?
Split the code: hardware access behind a thin interface, logic above it. Compile the logic on a host and unit-test it with Unity, Ceedling, or CppUTest, stubbing the hardware layer. Test the hardware layer on real hardware with a test harness. Full on-target integration testing usually needs a hardware-in-the-loop rig. The single highest-value step is making the logic host-compilable at all.
What's the difference between volatile and atomic?
volatile guarantees the compiler performs the access; it says nothing about whether the access happens in one indivisible step. _Atomic (C11) guarantees indivisibility and provides ordering guarantees. A 32-bit volatile on an 8-bit MCU is four separate byte accesses that an interrupt can split. For ISR-shared multi-byte data you need volatile and either an atomic type or a critical section.
How do I handle errors without exceptions?
Return status codes and check every one. A common structure is an enum of status values, a goto cleanup on failure (the one goto pattern almost every standard permits), and a logging or fault-recording layer for anything the device can't recover from. Consistency matters more than the specific style — an error path that's sometimes checked is worse than one that's always checked. See Error Handling.
Should I still learn C if Rust is available?
Yes. The existing firmware you'll maintain, every vendor HAL, every datasheet code sample, and most reference implementations are C. Embedded Rust is a strong choice for new projects and interoperates with C rather than replacing it — you'll be reading and calling C either way.
Related Topics
- Microcontrollers — The hardware this code drives
- Embedded Rust — Memory safety without a runtime
- Real-Time Operating Systems — Tasks, queues, and priorities above the C layer
- C — General language fundamentals
- Memory Management — Why static allocation dominates here
- Error Handling — Status codes and failure paths
- Unit Testing — Host-side testing of embedded logic
- ESP32 — A common target for C firmware