Chapter 1: The Embedded C Mindset
Most developers coming from Python, JavaScript, or game engines are used to an invisible safety net: an operating system that manages memory, schedules threads, and cleans up after crashes. Embedded C removes that net. Your code runs directly on hardware, often without an OS, a heap allocator, or even a filesystem. This chapter resets your mental model before we write a single register access.
The Hosted vs. Bare-Metal Divide
In a hosted environment, the operating system loads your program, provides a stack, initializes the heap, and calls main(). If your program dereferences a null pointer, the OS catches it and terminates the process. If you allocate memory and forget to free it, the OS reclaims it when the process exits.
In a bare-metal embedded system, none of this exists. Your code is the only code running. When the microcontroller resets, the hardware jumps to a reset vector defined in your startup file, which eventually calls your main(). There is no OS to catch a bad pointer access. There is no process boundary. A write to an invalid address silently corrupts memory, potentially altering register values or overwriting your own code.
Misconception: "A crash is just a crash. I'll see an error message, fix the bug, and move on."
Reality: On embedded hardware, a crash may manifest as a watchdog reset, a frozen display, or corrupted sensor data minutes after the actual bug occurred. The hardware does not know your code is wrong; it simply executes whatever instructions or memory writes you tell it to.
The Framework Is the Hardware
If you come from React, Unity, or Godot, you are used to a framework calling your functions. The engine owns the main loop and invokes your callbacks. In embedded C, the hardware is the framework. The CPU executes your code from the reset vector onward. Peripherals signal events through interrupts that preempt your main loop. Timers tick independently of your program counter.
This means your mental model of program flow must change. Instead of "my code runs and the engine updates the scene," it becomes "the hardware runs and occasionally jumps to my ISR." We will explore this in detail in Chapter 5, but the mindset shift begins here.
Static by Default, Dynamic by Exception
Desktop and web developers are accustomed to creating objects freely. You allocate a list, push items onto it, and let the garbage collector worry about cleanup. Embedded systems are different: RAM is measured in kilobytes, not gigabytes. A typical ARM Cortex-M0 microcontroller might have 16 KB of RAM. An STM32F4 has 192 KB. Your entire program's variables must fit in that space.
The default pattern in embedded C is static allocation: every buffer, every struct, every array is declared with a fixed size known at compile time. The linker places them in .bss (zero-initialized) or .data (initialized) sections. There is no growth, no resizing, no reallocation. This is not a limitation you fight against; it is a constraint you design around.
Dynamic allocation (malloc) is sometimes available, but many firmware projects ban it outright. The reasons—memory fragmentation, non-deterministic timing, and the difficulty of proving worst-case memory usage—are explored in Chapter 6. For now, internalize this: if you need a buffer for a UART packet, you do not ask the OS for memory at runtime. You declare a uint8_t rx_buffer[256]; at file scope and know exactly where it lives.
Undefined Behavior Is a Hardware Problem
In high-level languages, undefined behavior is rare. In C, it is pervasive, and in embedded C, it is dangerous. The C standard defines UB as behavior for which the standard imposes no requirements. The compiler is free to do anything: optimize the code away, emit no instructions, or generate code that appears to work in testing but fails on hardware.
Consider this example:
uint32_t read_register(uint32_t address) {
return *((volatile uint32_t *)address);
}
uint32_t shift_example(uint32_t value) {
return value << 32; // UB: shift by width of type
}
On x86, shifting a 32-bit value by 32 might produce zero or the original value, depending on the CPU's shift instruction implementation. On ARM Cortex-M, the result is architecturally defined as zero for LSL with shift amount 32. But the C standard says this is UB, so the compiler may assume it never happens and optimize surrounding code accordingly. The result: your code behaves differently with -O0 and -O2, or differently across compiler versions.
Embedded C programmers treat UB as a first-class bug category because the consequences are not "the program crashes." They are "the motor controller glitches, the ADC reads garbage, or the flash write corrupts firmware." When hardware is involved, UB is not theoretical.
What "Runs on Hardware" Actually Means
When you write int x = 5; in a desktop program, the OS and compiler together handle the details: where x is stored, how it is initialized, and what happens when it goes out of scope. In embedded C, you must eventually understand these details yourself.
The compiler assigns x to a stack location or a register. The linker assigns all global variables to specific addresses in the memory map. The startup code copies initialized data from flash to RAM before main() is called. The reset handler configures the stack pointer before any of this happens.
You do not need to memorize the exact addresses on every microcontroller. But you must understand the chain: source code → object file → linker script → binary image → flash. Each step imposes constraints on what your code can do.
A Concrete Comparison
Here is the same logical operation expressed in a hosted language and in embedded C.
Python (hosted):
def read_temperature(sensor_id):
# OS handles file I/O, device drivers, and error handling
with open(f"/dev/i2c-{sensor_id}", "rb") as f:
raw = f.read(2)
# Python handles endianness, allocation, and conversion
return int.from_bytes(raw, byteorder="big") / 16.0
Embedded C (bare-metal):
// You must know the register address from the datasheet
#define SENSOR_DATA_REGISTER ((volatile uint16_t *)0x40012000)
uint16_t read_temperature_raw(void) {
// You trigger the conversion manually
*SENSOR_CTRL_REGISTER |= SENSOR_START_CONVERSION;
// You poll the status bit until the hardware is ready
while (!(*SENSOR_STATUS_REGISTER & SENSOR_DATA_READY)) {
// Busy-wait: nothing else runs unless interrupts are enabled
}
// You read the data directly from the hardware register
return *SENSOR_DATA_REGISTER;
}
The Python version delegates everything to the OS and libraries. The C version is the OS and library. This is the core of embedded development: you are writing the lowest layer of the stack.
What This Course Assumes
This course assumes you know C syntax: pointers, structs, function pointers, and basic preprocessor directives. It does not assume you have written firmware, read a datasheet, or configured a linker script.
For examples, we will default to ARM Cortex-M microcontrollers (specifically the STM32 family) using the arm-none-eabi-gcc toolchain. These assumptions are made because Cortex-M is the most common embedded architecture and has clean, well-documented behavior. The concepts transfer to other platforms (AVR, PIC, RISC-V, ESP32) with minor syntax differences.
If you are using a different MCU family, substitute the register names and toolchain specifics as needed. The principles—memory-mapped I/O, interrupt handling, static allocation, and the absence of an OS—remain identical.
Key Takeaways
Before moving to Chapter 2, internalize these points:
- No OS means no safety net. Your code is solely responsible for memory correctness, timing, and system state.
- The hardware is the framework. Program flow is driven by reset vectors, interrupts, and timers, not by an engine loop or event system.
- Static allocation is the default. Know where your variables live and how much RAM they consume at compile time.
- Undefined behavior has hardware consequences. A mis-optimized expression can corrupt registers or timing, not just crash a process.
- You are writing the lowest layer. The abstractions you took for granted in Python or JavaScript are your responsibility now.
Chapter 2 will ground these abstract concepts in concrete tooling: how the compiler, linker, and startup code actually place your code and data in memory.