Skip to content

Latest commit

Β 

History

60 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Embedded Systems - Deeper Dive

This repo will be used to track my deep-dive learning progress with embedded systems. Although I have some experience with it, I never quite formalized my learning through first principles. Hopefully I can cover stuff I was always interested in, like linkers, bootloaders, baremetal code, RTOS, etc. and track my journey throughout.

πŸ§‡ :::: STM32 ::::

As a starter, I will be using an STM32 board, or more precisely, this one, as it was the one I had in stock when I was starting. The board is assumed to be deprecated, as looking up its datasheet returns a 404 on the manufacturer's site.

The documentation and datasheets will be included under the docs folder.


βš™οΈ :::: Baremetal ::::

This folder contains projects done without using the hardware abstraction layer (HAL). I wanted to learn how the baremetal code works by manipulating registers and values in memory addresses directly.

The resulting code may not be as versatile as one would find in the HAL libraries. For example, the ADXL345 library I wrote can access the data registers (raw IMU values) via SPI, but I did not write any function that can access specific data registers via SPI, like I did when writing the functions responsible for I2C access in the same library.

Project Detals (πŸ‘ˆ toggle expand)
Project Summary
0 - LED Blink Simple Blinks LED1 (PA5) by directly computing and dereferencing peripheral memory addresses. No CMSIS headers used.
1 - LED Blink Struct Same blink on LED2 (PC9), but register access is done through manually defined C structs mirroring the peripheral layout.
2 - GPIO Output Blinks LED1 using CMSIS headers (stm32f4xx.h) instead of raw address defines. Intro to using the CMSIS device layer.
3 - GPIO BSRR Blinks LED1 using the GPIO Bit Set/Reset Register (BSRR) for atomic pin control, instead of read-modify-write on ODR.
4 - GPIO Input Reads the onboard button (PC13) and drives LED1 accordingly. Intro to configuring a GPIO pin as input.
5 - UART TX Transmits a single character over USART2 by manually configuring baud rate, word length, and the TX enable bits.
6 - UART Printf Retargets printf to USART2 by implementing __io_putchar, enabling formatted output over serial.
7 - UART Modular Refactors UART init and transmit into a reusable uart.c/uart.h module used across all subsequent projects.
8 - UART TX/RX Adds receive support to the UART module. Reads a character from USART2 and echoes it back.
9 - ADC Single Conversion Configures ADC1 on PA1 for single software-triggered conversions, polling the EOC flag and printing the result.
10 - ADC Continuous Conversion Runs ADC1 in continuous mode, reading and printing values in a tight loop without re-triggering each conversion.
11 - SysTick Timer Implements a blocking systickDelayMs function using the Cortex-M4 SysTick counter to blink the LED at a precise rate.
12 - Timer Basics Configures TIM2 to overflow at 1 Hz by setting prescaler and ARR, polling the UIF flag in the main loop.
13 - Timer Output Compare Uses TIM2 in output compare mode to automatically toggle the LED pin on match, with no polling in the main loop.
14 - Timer Input Capture TIM2 drives PA5 via output compare; TIM3 captures the rising edge on PA6 (wired to PA5) and prints the timestamp.
15 - Input Interrupt Configures an EXTI line on PC13 (onboard button) to trigger an interrupt, printing a message in the ISR callback.
16 - UART Interrupt Enables the USART2 RXNE interrupt. Received characters are handled in the IRQ handler, toggling the LED on '1'.
17 - ADC Interrupt Runs ADC1 in continuous mode with EOC interrupt enabled, reading and printing the conversion result from the ISR.
18 - SysTick Interrupt Configures SysTick to fire at 1 Hz. The SysTick_Handler toggles the LED and prints a message, freeing the main loop.
19 - Timer Interrupt Moves TIM2 overflow handling into TIM2_IRQHandler, toggling the LED and printing from the ISR instead of polling.
20 - DMA UART TX Transfers a string to USART2's data register via DMA1 Stream 6, with a transfer-complete IRQ that lights the LED.
21 - I2C ADXL345 Communicates with the ADXL345 accelerometer over I2C. Reads raw X/Y/Z registers and converts them to g values.
22 - SPI ADXL345 Re-implements ADXL345 communication over SPI. The same adxl345 module is extended to support both interfaces.
23 - HardFault: Div by Zero Enables the DIV_0_TRP bit in SCB->CCR, then deliberately divides by zero. A custom fault handler catches and reports the UFSR.
24 - HardFault: Unaligned Access Enables UNALIGNED_TRP and accesses a buffer at non-word-aligned offsets to trigger a HardFault, caught by the same fault handler.

πŸ‘“ Parting thoughts:

All in all, it turned out to be quite an educational and an enjoyable experience overall to sift through the datasheets and operate on baremetal hardware. Without relying on the abstractions, it gave me a better grasp on how the HAL works to some extent.


⏳ :::: RTOS ::::

This folder contains 11 projects built using FreeRTOS (via CMSIS-RTOS2 and STM32 HAL), progressively covering the core concepts of real-time operating systems, from basic task scheduling and inter-task communication, through synchronization primitives and interrupt-driven designs, to classic concurrency problems like deadlock and priority inversion.

Project Detals (πŸ‘ˆ toggle expand)
Project Summary
0 - UART Blink Two concurrent tasks: one blinks an LED, the other reads a delay value over UART with scanf. Intro to the FreeRTOS scheduler.
1 - Memory Management Heap allocation with pvPortMalloc/vPortFree. Two tasks share a heap-allocated buffer, synchronized via a volatile flag.
2 - Queues Bidirectional inter-task messaging using two FreeRTOS queues, one carrying integer delay values, the other carrying status strings.
3 - Mutex Two tasks race to increment a shared variable. A mutex guards the critical section, demonstrating how it prevents the resulting race condition.
4 - Semaphore Producer-consumer with counting semaphores tracking buffer fullness/emptiness. Multiple dynamically-created producer and consumer tasks.
5 - Software Timers Creates a one-shot and a recurring FreeRTOS software timer, both sharing a single callback and distinguished by their timer ID.
6 - Timer Example Practical use of a one-shot timer: an inactivity timer that turns off the LED 2 seconds after the last UART keystroke.
7 - Interrupts and Timers A hardware timer ISR increments a counter. A task reads it safely using taskENTER_CRITICAL and its ISR-safe counterpart.
8 - Interrupts and Semaphores ADC triggered by hardware timer, ISR fills a double buffer. A semaphore gates buffer swaps; a calc task is woken via task notification; a CLI task exposes an avg command over UART.
9 - Deadlocks and Starvation Dining philosophers problem with 5 tasks. Deadlock is avoided by enforcing a resource ordering rule, always acquire the lower-numbered chopstick mutex first.
10 - Priority Inversion Three tasks at different priority levels share a mutex. Task startup is sequenced to deliberately trigger priority inversion and observe its effects.

πŸ‘“ Parting thoughts:

It was great to finally explore an RTOS, and I can now visualize how in past cases, using this would have helped me better organize projects. Although I have worked with concurrency before, I have not used it on microcontrollers or machines with similar constraints. Good stuff!


πŸ› οΈ :::: Tooling ::::

This folder contains 5 projects exploring the build toolchain and bootloader development for the STM32F411RE from first principles, using hand-written Makefiles, custom linker scripts, and no IDE-generated glue code. The projects build progressively toward a fully functional multi-stage bootloader with shared memory, execution relocation, and standard library support. I did this part after going through the excellent blog posts in - Zero to main() by FranΓ§ois Baldassari.

I decided to use the knowledge I gained there and learned a bit more stuff from various other sources to make similar implementations on the STM32 board I had. Quite a few things differ in this implementation, such as the memory maps, Makefile structure, project organization, etc.

Project Detals (πŸ‘ˆ toggle expand)
Project Summary
0 - Minimal Blink with CMSIS Headers Hand-written Makefile that compiles a baremetal LED blink using the ARM GCC toolchain and CMSIS headers directly, without any IDE or HAL. Establishes the baseline build setup.
1 - Custom Bootloader Flash is partitioned into a 16 kB bootrom and a 240 kB approm via a custom linker script. The bootloader prints a greeting over UART, reads the app's vector table from flash, loads the initial stack pointer into MSP, and jumps to the app's Reset Handler. A top-level Makefile builds both binaries and concatenates them into a single flashable image.
2 - Bootloader Shared Data Carves out a dedicated 4 kB shared region in SRAM that is not cleared by the startup code, allowing data to persist across resets. A SharedData struct tracks a boot counter that the bootloader increments on each startup, warns over UART if it exceeds 3, and resets it. The app binary can read the same shared region.
3 - Bootloader Exec Relocation The bootloader copies the app binary from flash into a dedicated execram region in SRAM before jumping to it, so the application executes entirely from RAM rather than flash. Demonstrates how to structure linker regions and perform the copy loop using linker-exported symbols.
4 - Stdlib Usage Integrates newlib into the baremetal build by implementing the required syscalls.c stubs (_write, _sbrk, etc.) and retargeting __io_putchar to USART2. This enables printf for formatted output in the bootloader and replaces the manual copy loop with memcpy from <string.h>.

πŸ‘“ Parting thoughts:

I was able to gain much deeper understanding of the core C build system, how the linker scripts and toolchain work, how embedded systems make use of the standard library, how memory regions can be manually mapped and used, and much more. In fact, I can now better appreciate how each byte of memory is laid out in memory, whenever I write code, and will probably always be thinking of efficient usage of the hardware.


✨ :::: Capstone ::::

This is the culmination project, pulling together concepts from both the baremetal and tooling sections into a complete, from-scratch bootloader for the STM32F411RE.

πŸ—ΊοΈ Overview

The flash is partitioned into a bootrom, two application regions (appromA and appromB), and a small metarom sector for storing firmware metadata. A dedicated block at the start of SRAM is carved out as a shared region that persists across resets.

On every power-on, the bootloader waits up to 5 seconds on UART for an update notice from the host. If one arrives, it kicks off a firmware update. Otherwise, it checks the metarom for valid firmware and jumps to it.

Updates always target the inactive partition, so a valid image is never touched mid-transfer. Once a transfer completes, the bootloader validates the full image and writes the new metadata to the metarom before handing off execution to the app.

πŸ”© Implementation Details

  • πŸ—ƒοΈ Memory map: The flash is split into a 16 kB bootrom (sector 0), appromA at 112 kB (sectors 1-4), appromB at 128 kB (sector 5), and a 512-byte metarom in sector 7. The shared SRAM region is 4 kB and tracks a boot counter across resets. All region starts and sizes are exported as linker symbols and accessible from C via memory_map.h.

  • πŸ“‘ Firmware update protocol: The bootloader and host do a short handshake, during which the target partition is erased. The host then sends the image metadata word-by-word (version, size, CRC), with each field individually CRC32-protected. The image follows in 512-word chunks, each with a trailing CRC word. The bootloader can request retransmission of any individual chunk on a CRC mismatch, and validates the full image CRC before committing the firmware metadata to the metarom.

  • πŸ”„ A/B partition scheme: The active region is tracked in the metarom. If no firmware exists, updates go to appromA. If appromA is active, updates go to appromB, and vice versa.

  • πŸ–₯️ Host script: scripts/uart_firmware_update.py implements the host side of the protocol. It loads the firmware binary, pads it to a chunk boundary with 0xFFs, computes matching CRC32s, and drops into a serial monitor after a successful transfer.


Module Details (πŸ‘ˆ toggle expand)
Module Description
boot/src/bootloader.c Entry point. Listens for an update notice from the host on startup, with a 5-second timeout. On a valid notice, triggers the UART update flow. Otherwise, reads the metarom for valid firmware and jumps to it.
boot/src/firmware.c Core of the bootloader. Implements the full UART firmware update protocol, approm erase and write routines, metarom read/write, and the A/B region selection logic.
boot/inc/firmware.h Defines the FirmwareInfo_t struct, AppromRegion enum, protocol byte constants (ACK, SYNC, REQ, RSND, FIN, ERR), and the flash sector layout for both app regions.
common/ Shared driver library. Covers UART (TX, RX, echo control), flash (sector erase, word write, lock/unlock), CRC32, SysTick, and the newlib syscalls.c stubs.
shared/ Defines the SharedData_t struct placed in the 4 kB SRAM region that survives resets, used for tracking a boot counter.
memory_map.ld Master linker script. Lays out all memory regions (bootrom, appromA, appromB, metarom, shared, ram) and exports region starts and sizes as linker symbols accessible from C.
scripts/uart_firmware_update.py Host-side update tool. Implements the full protocol, loads and pads the firmware binary to chunk boundaries, computes matching CRC32s, and switches to a serial monitor on completion.

πŸ“ Notes

πŸ” Using OpenOCD to debug

On a Fedora system which I used for this project, here are the steps I took for debugging with OpenOCD:

# Install OpenOCD
sudo dnf install openocd

# Ensure gdb is installed
which gdb

# Start the openocd instance
openocd -f /usr/share/openocd/scripts/interface/stlink-v2.cfg -f /usr/share/openocd/scripts/target/stm32f4x.cfg

# The above will open some ports (e.g. 3333)
# Start a gdb instance
gdb ./path/to/the/debug/elf/file/example-blink-led.elf

# Once started, we need to tell gdb to debug a remote target
(gdb) target extended-remote localhost:3333

# We can then debug the program using gdb :)

Example set commands in GDB to toggle PC9 (LED2):

# (only once) Enable clock access to GPIOC in RCC_AHB1ENR
set *((uint32_t *)0x40023830) |= 0x00000005

# (only once) Set PC9 to output mode in GPIOC_MODER (bit 19 to 0 and bit 18 to 1)
set *((uint32_t *)0x40020800) &= ~0x00080000
set *((uint32_t *)0x40020800) |= 0x00040000

# (as many times as we want) Toggle bit 9 of GPIOC_ODR
set *((uint32_t *)0x40020814) ^= 0x00000200

To toggle the LED2, we can just run the last set command above. We don't need to run the first 3 set commands, as they are just for initializing PC9.

About

Structured deep-dive into embedded systems from first principles, progressing through bare-metal peripheral programming, FreeRTOS concurrency, and low-level tooling including linker scripts and bootloaders, on an ARM Cortex MCU

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages