Embedded C: Writing Firmware That Lasts
Why C still rules embedded
C gives you direct access to memory, near-zero runtime overhead and predictable timing. There is no garbage collector that can pause you mid-ISR, no hidden allocations, and the generated assembly looks roughly like the source. That is exactly what you want when a watchdog is breathing down your neck.
Embedded C ≠ standard C. Same syntax, very different mindset: every byte matters and every cycle is visible.
Types & stdint.h
Never use plain int or long in firmware — their sizes change between platforms. Always use the fixed-width types from <stdint.h>:
#include <stdint.h>
#include <stdbool.h>
uint8_t counter = 0; // 0..255
int16_t temp_x10 = -125; // -32k..32k, °C * 10
uint32_t ticks_ms = 0;
bool ready = false;
Bit manipulation
You will spend more time twiddling bits than anything else. Memorize these four patterns:
/* Set bit n */
reg |= (1U << n);
/* Clear bit n */
reg &= ~(1U << n);
/* Toggle bit n */
reg ^= (1U << n);
/* Check bit n */
if (reg & (1U << n)) { /* it is set */ }
volatile & memory-mapped I/O
Hardware registers and variables shared with ISRs must be declared volatile, otherwise the compiler will happily optimize your code into something that does not work.
/* Memory-mapped GPIO register */
#define GPIOA_ODR (*(volatile uint32_t *)0x40020014U)
/* Shared with ISR */
volatile uint32_t systick_ms = 0;
void SysTick_Handler(void) {
systick_ms++; // updated in interrupt
}
void delay_ms(uint32_t ms) {
uint32_t start = systick_ms;
while ((systick_ms - start) < ms) { }
}
Forget volatile on a shared variable and your program may work in debug, then mysteriously freeze in release. This is the #1 firmware bug.
The embedded memory model
An MCU binary lives in a few sections defined by the linker script:
- .text — code, in Flash.
- .rodata — constants (
const), in Flash. - .data — initialized globals, copied from Flash → RAM at boot.
- .bss — zero-initialized globals, in RAM.
- Stack — function calls, local variables.
- Heap — dynamic allocation. Usually avoided.
Most production firmware avoids malloc() entirely — fragmentation in a long-running embedded system is a one-way ticket to a hard fault.
Patterns every firmware engineer needs
1. Finite State Machine
typedef enum { ST_IDLE, ST_HEATING, ST_HOLD, ST_FAULT } state_t;
static state_t state = ST_IDLE;
void controller_step(void) {
switch (state) {
case ST_IDLE: if (start_pressed()) state = ST_HEATING; break;
case ST_HEATING: if (temp_reached()) state = ST_HOLD; break;
case ST_HOLD: if (stop_pressed()) state = ST_IDLE; break;
case ST_FAULT: /* require reset */ break;
}
}
2. Lock-free ring buffer
#define RB_SIZE 64
static volatile uint8_t buf[RB_SIZE];
static volatile uint16_t head = 0, tail = 0;
bool rb_push(uint8_t b) {
uint16_t next = (head + 1) % RB_SIZE;
if (next == tail) return false; // full
buf[head] = b;
head = next;
return true;
}
bool rb_pop(uint8_t *b) {
if (head == tail) return false; // empty
*b = buf[tail];
tail = (tail + 1) % RB_SIZE;
return true;
}
3. Driver = function table
typedef struct {
int (*init)(void);
int (*write)(const uint8_t *data, size_t len);
int (*read) (uint8_t *data, size_t len);
} comm_driver_t;
extern const comm_driver_t uart_driver;
extern const comm_driver_t usb_driver;
Common pitfalls
- Forgetting
volatileon ISR-shared variables. - Using
float/doubleon an MCU without an FPU. - Calling
printf()from an ISR — never do that. - Stack overflow because of large local arrays — use
static. - Implicit integer promotion to
inthiding overflow bugs. - Trusting
malloc()in a long-running system.
Master these and you are 80% of the way to writing professional firmware.