Firmware runs on metal — no OS, no malloc, no printf. Today you'll write C that directly controls hardware with nothing in between.
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.
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 (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.
// 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
}
= 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.arm-none-eabi-gcc -mcpu=cortex-m3 -mthumb -o blink.elf blink.c startup.c -T stm32f1.ldopenocd -f interface/stlink.cfg -f target/stm32f1x.cfg -c 'program blink.elf verify reset exit'arm-none-eabi-size blink.elf) to the same sketch using Arduino HAL. What's the size difference?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.