Day 1 of 5
⏱ ~60 minutes
Firmware Development in 5 Days — Day 1

Bare-Metal C Basics

Firmware runs on metal — no OS, no malloc, no printf. Today you'll write C that directly controls hardware with nothing in between.

What Bare Metal Means

Bare-metal firmware runs directly on the CPU with no operating system. You control the startup sequence, memory layout, interrupt vectors, and every peripheral register. Advantages: deterministic timing, minimal overhead, smallest footprint. The startup sequence: CPU resets → loads PC from reset vector → runs startup code → initializes .data section from flash, zeros .bss section → calls main(). If main() returns, the CPU executes whatever is next in memory — always add an infinite loop.

The Memory Map

A microcontroller's address space is divided into regions defined by the manufacturer. Typical ARM Cortex-M: 0x00000000–0x1FFFFFFF = flash (code), 0x20000000–0x3FFFFFFF = SRAM (data/stack/heap), 0x40000000–0x5FFFFFFF = peripheral registers, 0xE0000000+ = core peripherals (NVIC, SysTick, etc.). The linker script (.ld file) maps your code sections (.text, .data, .bss) to these physical addresses.

CMSIS and Peripheral Headers

CMSIS (Cortex Microcontroller Software Interface Standard) provides a common C header for ARM Cortex-M peripherals. MCU vendors provide device-specific headers that define all peripheral register addresses as C structures: GPIOA->ODR = 0x01 sets PA0 high directly via memory-mapped I/O. No HAL needed — write to the register address and the hardware responds immediately.

c
// Bare-metal GPIO blink on STM32F1 (no HAL)
// Directly access peripheral registers via CMSIS headers

#include "stm32f1xx.h"

// The startup file provides this symbol
extern uint32_t SystemCoreClock;

void delay_ms(uint32_t ms) {
    // Simple busy-wait using SysTick
    uint32_t ticks = (SystemCoreClock / 1000) * ms;
    volatile uint32_t i;
    for (i = 0; i < ticks; i++) __NOP();
}

int main(void) {
    // 1. Enable clock to GPIOC (bit 4 of APB2ENR)
    RCC->APB2ENR |= RCC_APB2ENR_IOPCEN;

    // 2. Configure PC13 as general-purpose push-pull output, 2MHz
    // CRH controls pins 8-15; PC13 is bits 23:20
    GPIOC->CRH &= ~(0xF << 20);   // clear bits
    GPIOC->CRH |=  (0x2 << 20);   // output, 2MHz (CNF=00, MODE=10)

    while (1) {
        // Set PC13 high (LED OFF on most Blue Pill boards — inverted)
        GPIOC->BSRR = (1 << 13);     // Bit Set Register
        delay_ms(500);

        // Set PC13 low (LED ON)
        GPIOC->BRR  = (1 << 13);     // Bit Reset Register
        delay_ms(500);
    }
    // Never return — would be undefined behavior
}
💡
Never use = 0b00000010 to set a register field — you'll accidentally clear other bits. Always use read-modify-write: REG &= ~MASK; REG |= VALUE; or the atomic BSRR/BRR registers for GPIO. Clearing other bits in a control register can disable unrelated peripherals.
📝 Day 1 Exercise
Register-Level GPIO Control
  1. Set up a development environment: install arm-none-eabi-gcc, OpenOCD, and VS Code with Cortex-Debug extension.
  2. Clone or create a bare-metal project for STM32F103 (Blue Pill, ~$2). Compile the blink sketch with: arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -o blink.elf blink.c startup.c -T stm32f1.ld
  3. Flash with OpenOCD: openocd -f interface/stlink.cfg -f target/stm32f1x.cfg -c 'program blink.elf verify reset exit'
  4. Read the STM32F1 reference manual section on GPIO. Find the register that reads the input state of a pin. Implement a software button read without any library.
  5. Compare register-level code size (arm-none-eabi-size blink.elf) to the same sketch using Arduino HAL. What's the size difference?

Day 1 Summary

  • Bare-metal runs with no OS — you control startup, memory layout, and every register
  • The memory map is fixed by the hardware: flash at 0x0, RAM at 0x20000000, peripherals at 0x40000000
  • CMSIS headers map peripheral registers to C structs — write to them directly
  • Use BSRR/BRR for atomic GPIO — never read-modify-write GPIO ODR in interrupt handlers
Challenge

Implement SysTick-based millisecond timer without any library. Configure SysTick to interrupt every 1ms (reload value = SystemCoreClock/1000 - 1). In the ISR, increment a volatile uint32_t counter. Implement uint32_t millis() that returns the counter. Implement void delay_ms(uint32_t) using it. Verify accuracy with a scope or LED toggle timing.

Finished this lesson?