This episode brings C to the hardware: embedded and bare-metal programming, startup code, linker scripts, and memory maps, real-time constraints with deterministic behavior, as well as embedded debugging using JTAG and simulators.

C is the main language of the embedded world: from microcontrollers in vehicles and medical devices to IoT hardware. Episode 21 takes you into bare-metal programming — writing code that runs directly on the hardware without an operating system.
Without an OS, you're responsible for everything: what happens before main is called, where code is placed in memory, and how to manage precise timing. That's the job of startup code and linker scripts.
You'll also learn to handle real-time constraints and debug using JTAG and simulators — the real working methods of firmware developers.
On bare-metal systems, hardware is controlled through registers mapped to specific memory addresses. You access them through pointers and volatile so the compiler doesn't optimize away reads:
#include <stdint.h>
#define GPIO_BASE 0x40021000UL
#define GPIO_MODER (*(volatile uint32_t *)(GPIO_BASE + 0x00))
void nyalakan_led(void) {
GPIO_MODER |= (1U << 20);
}
int main(void) {
nyalakan_led();
for (;;) {
}
return 0;
}
EOF
gcc -std=c17 -Wall -Wextra -pedantic main.c -o mainGPIO_MODER is a pointer dereference to the register address, with volatile forcing a real read and write every time. for (;;){} is the main loop that runs forever — the standard firmware pattern that never returns from main.
Microcontroller resources are severely limited: kilobytes of RAM, megabytes of flash. Avoid the heap and large libraries. Every byte and CPU cycle counts. Compile with size optimization for embedded:
arm-none-eabi-gcc -mcpu=cortex-m4 -Os -ffreestanding main.c -o main.elf-mcpu=cortex-m4 targets a specific ARM core, -Os optimizes binary size, and -ffreestanding tells the compiler there's no host runtime — no main called by an OS.
When a device powers on, the startup code works first: sets the stack pointer, copies initialized data from flash to RAM, zeroes the BSS, then calls main. All of this is orchestrated by the startup file usually provided by the vendor. Understanding this flow explains why global variables hold correct values when main begins.
A linker script determines where each section of code is placed in memory:
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
SECTIONS
{
.text : { *(.text*) } > FLASH
.data : { *(.data*) } > RAM
.bss : { *(.bss*) } > RAM
}The MEMORY script above declares two regions: FLASH for read-only code and RAM for data. The SECTIONS block places .text into FLASH and .data and .bss into RAM. These addresses match the microcontroller's memory map and must agree with the datasheet.
Tools like arm-none-eabi-objdump and arm-none-eabi-size show the layout results:
arm-none-eabi-size main.elf
arm-none-eabi-objdump -h main.elfarm-none-eabi-size main.elf shows the sizes of text, data, and bss. objdump -h prints the sections and their addresses. Comparing these numbers against the FLASH and RAM capacity is a mandatory health check before flashing a device.
Real-time systems have deadlines: results must be ready before the deadline, or the system is considered failed. Consequently, heap allocation with unpredictable execution times and locks that can freeze must not exist on critical paths.
Latency must be predictable. That means: no malloc in time-critical loops, no functions that run for an unbounded time, and interrupts managed by priority. Nested interrupts ensure the most important task always wins. Testing with hardware timers measures real latency and proves deadlines are met.
JTAG is a hardware debugging interface that lets you halt the CPU, read memory, and set breakpoints directly on the chip. OpenOCD bridges the probe to gdb:
openocd -f interface/stlink.cfg -f target/stm32f4x.cfg
gdb-multiarch main.elfThe command openocd -f interface/stlink.cfg starts the debugging server for an ST-Link probe and an STM32 target. In gdb, target remote localhost:3333 connects to the chip, and breakpoints and watchpoints work like on the host — but running on real hardware.
Simulators execute the target's instructions on your machine without hardware. QEMU in semihosting mode provides a fast development environment for logic that doesn't depend on hardware. Combining simulators for fast development and JTAG for final validation is the standard firmware team workflow.
Warning
Debugging embedded on real hardware is slow and expensive. Test as much logic as possible on the host or in a simulator first, then drop down to JTAG. Every iteration on a real chip takes far longer.
Key takeaways:
In the next episode 22 we will discuss observability and production support — logging, tracing, and monitoring for C applications, crash dump analysis with core files and post-mortem debugging, rollback strategy and release management, up to documentation, code review, and maintenance.