Embedded Basics
Introduction
Embedded systems are specialized computing systems designed to perform dedicated functions within larger mechanical or electrical systems. Unlike general-purpose computers, they are often resource-constrained and optimized for specific tasks. This chapter builds C-focused foundations you can practice on a Linux host: bit helpers, packed structs, volatile, register overlays over a buffer, a button-debounce state machine, and high-level cross-compile notes for arm-none-eabi-gcc.
Many examples are host simulations (plain gcc on Linux). They teach patterns you will use on bare metal without requiring a board.
Host compile convention:
gcc -std=c17 -Wall -Wextra -o program program.c
./programWhat Are Embedded Systems?
An embedded system is a computer with a dedicated function inside a larger product, often with real-time constraints.
Characteristics
- Dedicated function — not a general-purpose desktop OS workload
- Resource constraints — limited RAM, flash, CPU, energy
- Real-time operation — deadlines matter
- Reliability — long uptime expectations
- Cost sensitivity — BOM-driven design
- Power efficiency — batteries and thermal limits
Types (rough scale)
| Scale | Typical hardware | Examples |
|---|---|---|
| Small | 8/16-bit MCU, KB of RAM | sensors, toys, simple appliances |
| Medium | 32-bit MCU, tens–hundreds of KB | motor control, IoT nodes |
| Sophisticated | MPU + OS (Linux/RTOS), MB+ | routers, automotive domains, phones |
Microcontroller vs Microprocessor
Microcontroller (MCU): CPU + memory + peripherals on one chip; minimal external parts.
Microprocessor (MPU): CPU core; RAM, flash, and many peripherals are external; higher performance, more complex board design.
Typical Components
- Processor (CPU)
- Memory (Flash/ROM, RAM, sometimes EEPROM)
- GPIO and serial (UART, SPI, I²C)
- ADC/DAC, timers/counters
- Interrupt controller, clock tree, power management
Development Environments and Cross-Compilation
Embedded builds usually cross-compile: the compiler runs on a host (e.g. x86_64 Linux) and emits code for a different target (e.g. ARM Cortex-M).
High-Level Toolchain Notes (arm-none-eabi-gcc)
| Piece | Role |
|---|---|
arm-none-eabi-gcc |
C compiler for bare-metal ARM (no Linux userspace ABI) |
arm-none-eabi-as / ld |
Assembler / linker (often driven by gcc) |
arm-none-eabi-objcopy |
ELF → binary/ihex for flashing |
arm-none-eabi-objdump / size |
Inspect sections and code size |
arm-none-eabi-gdb |
Debug via probe (OpenOCD, J-Link, etc.) |
Linker script (.ld) |
Places .text, .data, .bss, stack at real addresses |
Startup (.s / .c) |
Reset handler, copy .data, zero .bss, call main |
Typical flags (illustrative Cortex-M3):
# Install on Debian/Ubuntu-style hosts (example package name):
# sudo apt install gcc-arm-none-eabi binutils-arm-none-eabi
arm-none-eabi-gcc -std=c17 -Wall -Wextra \
-mcpu=cortex-m3 -mthumb \
-ffunction-sections -fdata-sections \
-T linker_script.ld \
-nostartfiles \
-o firmware.elf startup.c main.c
arm-none-eabi-objcopy -O binary firmware.elf firmware.bin
arm-none-eabi-size firmware.elfWhat those flags mean (high level):
-mcpu=cortex-m3 -mthumb— generate Thumb code for that core
-ffunction-sections -fdata-sections— enable linker garbage collection of unused sections (with-Wl,--gc-sections)
-T linker_script.ld— memory map for flash/RAM
-nostartfiles— you supply startup (or use a vendor CRT carefully)
You do not need this toolchain for the host simulation programs below; use native gcc on Linux for those.
Other Tools You Will Meet
- IDEs: vendor tools, VS Code + Cortex-Debug, CLion, etc.
- Debug probes: SWD/JTAG
- Simulators: QEMU (machine-dependent), Renode
Memory Organization
| Region | Typical use |
|---|---|
| Flash / ROM | Code (.text), constants (.rodata), initializers |
| RAM | .data (copied at boot), .bss (zeroed), heap, stack |
| EEPROM / NVM | Configuration that survives power loss |
| MMIO registers | Peripheral control/status (volatile) |
/* Conceptual Cortex-M style map (numbers vary by chip) */
/* 0x00000000 code flash
* 0x20000000 SRAM
* 0x40000000 peripherals
* 0xE0000000 system (NVIC, SysTick, ...)
*/Startup Sketch (bare metal idea)
/* Conceptual only — addresses come from the linker script */
extern unsigned int _etext, _data, _edata, _bss, _ebss;
void Reset_Handler(void) {
unsigned int *src = &_etext;
unsigned int *dst = &_data;
while (dst < &_edata) {
*dst++ = *src++;
}
dst = &_bss;
while (dst < &_ebss) {
*dst++ = 0;
}
main();
for (;;) {
/* halt if main returns */
}
}Section Placement Reminders
int initialized_var = 42; /* .data */
int uninitialized_var; /* .bss */
const int constant_var = 100; /* .rodata */
void example_function(void) {
static int static_var = 10; /* .data */
static int static_uninit; /* .bss */
}Bit Manipulation Helpers
Bit ops are daily drivers for GPIO, flags, and control registers. Prefer unsigned types and 1U shifts.
/* bit_helpers.c — host-runnable */
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
static inline uint32_t bit_mask(unsigned bit) {
return 1u << bit;
}
static inline void set_bit(uint32_t *reg, unsigned bit) {
*reg |= bit_mask(bit);
}
static inline void clear_bit(uint32_t *reg, unsigned bit) {
*reg &= ~bit_mask(bit);
}
static inline void toggle_bit(uint32_t *reg, unsigned bit) {
*reg ^= bit_mask(bit);
}
static inline bool test_bit(uint32_t reg, unsigned bit) {
return (reg & bit_mask(bit)) != 0u;
}
/* Multi-bit field helpers (field in [lsb, lsb+width)) */
static inline void write_field(uint32_t *reg, unsigned lsb, unsigned width,
uint32_t value) {
uint32_t mask = ((1u << width) - 1u) << lsb;
*reg = (*reg & ~mask) | ((value << lsb) & mask);
}
static inline uint32_t read_field(uint32_t reg, unsigned lsb, unsigned width) {
return (reg >> lsb) & ((1u << width) - 1u);
}
int main(void) {
uint32_t gpio = 0;
set_bit(&gpio, 3);
set_bit(&gpio, 1);
printf("after set 3 and 1: 0x%08X\n", gpio);
clear_bit(&gpio, 1);
printf("after clear 1: 0x%08X\n", gpio);
toggle_bit(&gpio, 3);
toggle_bit(&gpio, 3);
printf("after toggle 3 x2: 0x%08X (bit3=%d)\n",
gpio, test_bit(gpio, 3));
/* e.g. mode field bits [5:4] = 0b10 */
write_field(&gpio, 4, 2, 0x2);
printf("field[5:4]=%u reg=0x%08X\n",
read_field(gpio, 4, 2), gpio);
return 0;
}gcc -std=c17 -Wall -Wextra -o bit_helpers bit_helpers.c
./bit_helpersMacro form (common in headers; use carefully with side-effect arguments):
#define SET_BIT(reg, bit) ((reg) |= (1U << (bit)))
#define CLEAR_BIT(reg, bit) ((reg) &= ~(1U << (bit)))
#define TOGGLE_BIT(reg, bit) ((reg) ^= (1U << (bit)))
#define CHECK_BIT(reg, bit) (((reg) >> (bit)) & 1U)Shift safety: shifting by ≥ width of the type is undefined. Keep bit < 32 for uint32_t.
volatile — Why Embedded Needs It
The compiler may cache memory values in registers or remove “useless” reads/writes. Memory-mapped registers and variables shared with ISRs change outside the abstract machine. Mark them volatile so each access in the source becomes a real access.
/* volatile_demo.c */
#include <stdio.h>
#include <stdint.h>
/* Simulated peripheral register (on bare metal this would be MMIO) */
static volatile uint32_t FAKE_UART_STATUS;
static volatile uint32_t FAKE_UART_DATA;
#define STATUS_RX_FULL (1u << 0)
static void hw_sim_inject(char c) {
FAKE_UART_DATA = (uint32_t)(unsigned char)c;
FAKE_UART_STATUS |= STATUS_RX_FULL;
}
static char uart_getc_blocking(void) {
/* Without volatile on STATUS, an optimizer could read once and loop forever */
while ((FAKE_UART_STATUS & STATUS_RX_FULL) == 0u) {
/* spin — in real chips an IRQ or WFI would be better */
}
char c = (char)FAKE_UART_DATA;
FAKE_UART_STATUS &= ~STATUS_RX_FULL;
return c;
}
int main(void) {
hw_sim_inject('Z');
char c = uart_getc_blocking();
printf("received: %c\n", c);
return 0;
}gcc -std=c17 -Wall -Wextra -O2 -o volatile_demo volatile_demo.c
./volatile_demoRules of thumb:
- MMIO registers:
volatile(often via pointer typedef).
- Flags set in an ISR, read in main:
volatile.
volatileis not a substitute for mutexes on multi-core or for atomic RMW correctness across threads — it only limits compiler optimizations on that object.
Packed Structs
Compilers insert padding for alignment. Hardware register maps and wire formats often need exact layouts. Use packing attributes carefully (unaligned access cost/faults on some CPUs).
/* packed_structs.c */
#include <stdio.h>
#include <stdint.h>
#include <stddef.h>
struct Loose {
uint8_t a;
uint32_t b;
uint8_t c;
};
struct Packed {
uint8_t a;
uint32_t b;
uint8_t c;
} __attribute__((packed));
/* Bit-fields: handy for docs; packing/order is implementation-defined — verify */
struct StatusBits {
uint8_t ready : 1;
uint8_t error : 1;
uint8_t mode : 2;
uint8_t : 4; /* padding bits */
};
int main(void) {
printf("sizeof(Loose) = %zu\n", sizeof(struct Loose));
printf("sizeof(Packed) = %zu\n", sizeof(struct Packed));
printf("offset b Loose = %zu\n", offsetof(struct Loose, b));
printf("offset b Packed = %zu\n", offsetof(struct Packed, b));
struct Packed p = { .a = 1, .b = 0x11223344u, .c = 0xFE };
const unsigned char *bytes = (const unsigned char *)&p;
printf("Packed bytes:");
for (size_t i = 0; i < sizeof p; i++) {
printf(" %02X", bytes[i]);
}
printf("\n");
struct StatusBits st = { .ready = 1, .error = 0, .mode = 3 };
printf("status ready=%u error=%u mode=%u\n", st.ready, st.error, st.mode);
return 0;
}gcc -std=c17 -Wall -Wextra -o packed_structs packed_structs.c
./packed_structsOn a typical 64-bit Linux host, Loose is larger than 6 bytes due to padding; Packed is 6 bytes. Always check with sizeof / offsetof for your ABI.
Memory-Mapped Register Pattern as Struct Overlay
On hardware, a peripheral’s registers sit at a fixed address. In C you often overlay a struct on that address. On the host, overlay the same struct on a byte buffer to practice without silicon.
/* mmio_overlay.c — simulate a tiny GPIO block in a buffer */
#include <stdio.h>
#include <stdint.h>
#include <string.h>
#include <stdbool.h>
typedef struct {
volatile uint32_t DIR; /* 0x00: 1 = output */
volatile uint32_t DATA; /* 0x04: pin levels */
volatile uint32_t SET; /* 0x08: write 1 to set bit in DATA */
volatile uint32_t CLEAR; /* 0x0C: write 1 to clear bit in DATA */
} GpioBlock;
/* Host “device memory” */
static uint8_t device_mem[sizeof(GpioBlock)];
static GpioBlock *gpio_at_buffer(void) {
return (GpioBlock *)(void *)device_mem;
}
/* Emulate SET/CLEAR side effects a real peripheral would do in hardware */
static void gpio_bus_write(GpioBlock *g, volatile uint32_t *reg, uint32_t value) {
if (reg == &g->SET) {
g->DATA |= value;
} else if (reg == &g->CLEAR) {
g->DATA &= ~value;
} else {
*reg = value;
}
}
static void gpio_init_outputs(GpioBlock *g, uint32_t mask) {
gpio_bus_write(g, &g->DIR, g->DIR | mask);
}
static void gpio_set_pins(GpioBlock *g, uint32_t mask) {
gpio_bus_write(g, &g->SET, mask);
}
static void gpio_clear_pins(GpioBlock *g, uint32_t mask) {
gpio_bus_write(g, &g->CLEAR, mask);
}
int main(void) {
memset(device_mem, 0, sizeof device_mem);
GpioBlock *gpio = gpio_at_buffer();
const uint32_t LED = (1u << 0);
gpio_init_outputs(gpio, LED);
gpio_set_pins(gpio, LED);
printf("DIR=0x%X DATA=0x%X (LED on)\n", gpio->DIR, gpio->DATA);
gpio_clear_pins(gpio, LED);
printf("DIR=0x%X DATA=0x%X (LED off)\n", gpio->DIR, gpio->DATA);
/* On bare metal you would instead write:
* #define GPIOA ((GpioBlock *)0x40020000u)
* GPIOA->DIR |= LED;
* with the real memory map — never do that to a random address on Linux.
*/
return 0;
}gcc -std=c17 -Wall -Wextra -o mmio_overlay mmio_overlay.c
./mmio_overlayBare-metal sketch (do not run as a normal Linux userspace poke):
#define GPIOA_BASE 0x40020000u
#define GPIOA ((volatile GpioBlock *)GPIOA_BASE)Classic Patterns (Still Useful)
LED Blink Superloop (conceptual MMIO)
#include <stdint.h>
#define GPIO_PORT_ADDR 0x40020000u
#define GPIO_DIR (*(volatile uint32_t *)(GPIO_PORT_ADDR + 0x00))
#define GPIO_DATA (*(volatile uint32_t *)(GPIO_PORT_ADDR + 0x04))
#define LED_PIN 0
/* On host, prefer the buffer overlay example — these addresses are not yours. */
void delay(volatile uint32_t count) {
while (count--) {
__asm__ volatile("nop");
}
}
void led_init(void) {
GPIO_DIR |= (1u << LED_PIN);
}
int main(void) {
led_init();
for (;;) {
GPIO_DATA |= (1u << LED_PIN);
delay(1000000);
GPIO_DATA &= ~(1u << LED_PIN);
delay(1000000);
}
}Interrupt Sketch
void UART_IRQHandler(void) {
uint32_t status = UART_STATUS_REG;
if (status & UART_RX_INTERRUPT) {
char data = (char)UART_DATA_REG;
process_received_data(data);
UART_STATUS_REG = UART_RX_INTERRUPT; /* clear */
}
}Keep ISRs short: set flags / push to a ring buffer; do heavy work in the main loop.
Timing
Busy-wait delays waste power and drift with optimization and clock changes. Prefer hardware timers or a SysTick counter for production firmware. Software nop loops are only for crude bring-up.
Practical Host Lab: Mini “Firmware” Module
Combine bits + overlay + FSM in one translation unit you can test on Linux.
/* mini_fw_sim.c */
#include <stdio.h>
#include <stdint.h>
#include <stdbool.h>
#include <string.h>
typedef struct {
volatile uint32_t DIR;
volatile uint32_t DATA;
} SimpleGpio;
static uint8_t ram[sizeof(SimpleGpio)];
static SimpleGpio *GPIO;
static inline void set_bit_u32(volatile uint32_t *r, unsigned b) {
*r |= (1u << b);
}
static inline void clear_bit_u32(volatile uint32_t *r, unsigned b) {
*r &= ~(1u << b);
}
#define LED 0u
#define BTN 1u
typedef enum { S_OFF, S_ON } LedMode;
int main(void) {
memset(ram, 0, sizeof ram);
GPIO = (SimpleGpio *)(void *)ram;
set_bit_u32(&GPIO->DIR, LED); /* LED out */
clear_bit_u32(&GPIO->DIR, BTN); /* BTN in */
LedMode mode = S_OFF;
/* Pretend samples of button (active high) every step */
const int samples[] = {0, 0, 1, 1, 1, 1, 0, 0};
int stable = 0;
int last = 0;
for (size_t i = 0; i < sizeof samples / sizeof samples[0]; i++) {
int raw = samples[i];
if (raw == last) {
stable++;
} else {
stable = 0;
last = raw;
}
if (stable == 2 && raw == 1) {
mode = (mode == S_OFF) ? S_ON : S_OFF;
printf("toggle -> %s\n", mode == S_ON ? "ON" : "OFF");
}
if (mode == S_ON) {
set_bit_u32(&GPIO->DATA, LED);
} else {
clear_bit_u32(&GPIO->DATA, LED);
}
printf("i=%zu raw=%d DATA=0x%X\n", i, raw, GPIO->DATA);
}
return 0;
}gcc -std=c17 -Wall -Wextra -o mini_fw_sim mini_fw_sim.c
./mini_fw_simExercises
Exercise 1 — Bit library
Implement set, clear, toggle, test, and write_field / read_field in a small header. Write tests that print expected masks for bits 0, 7, 31 on uint32_t. Reject bit >= 32 safely.
gcc -std=c17 -Wall -Wextra -o bit_tests bit_tests.cExercise 2 — Packed vs unpacked
Define a packet:
u8 type; u16 length; u8 flags; u32 crc;
Print sizeof and offsets with and without __attribute__((packed)). Serialize to a uint8_t buffer with explicit byte writes (portable) and compare to a packed memcpy approach.
Exercise 3 — Overlay UART
Model a UART with STATUS (RX_FULL, TX_EMPTY) and DATA registers in a buffer. Write uart_putc / uart_getc that spin on flags. Inject bytes from main by writing the buffer as the “hardware” would.
Exercise 4 — Debounce FSM extension
Add a long-press state: if pressed longer than L ticks, emit LONG_PRESS once. Draw the state diagram in comments.
Exercise 5 — Cross-compile dry run
If arm-none-eabi-gcc is installed, compile a tiny main that only returns 0:
arm-none-eabi-gcc -std=c17 -Wall -Wextra -mcpu=cortex-m3 -mthumb \
-c -o main.o main.c
arm-none-eabi-size main.oNote: without startup and a linker script you will not produce a full flashable image; the goal is to confirm the cross compiler runs and inspect section sizes.
Exercise 6 — Volatile experiment
Write a loop waiting on a non-volatile global that a second function would change (on one core with -O2, the wait may spin forever if the compiler caches the load). Then mark the flag volatile and show the wait completes when you flip the flag between calls (e.g. cooperative single-thread simulation).
Summary
Embedded C fundamentals covered here:
- Architecture — MCU vs MPU, memory map, startup and sections
- Cross-compilation — role of
arm-none-eabi-gcc, flags, linker script, objcopy
- Bit helpers — set/clear/toggle/test and field pack/unpack
volatile— MMIO and ISR-shared flags; not a lock
- Packed structs — layout control and portability caveats
- Struct overlay — register blocks on real addresses or host buffers
- Debounce FSM — software model for noisy digital inputs
- Host labs — practice patterns safely with Linux
gcc
Next chapters go deeper into low-level techniques, embedded-specific C patterns, real-time structure, and hardware interfaces.