Skip to content

Capstone Project53 min read

Capstone II — Platform, Drivers, and the Sensor

This lesson takes the contracts from Capstone I and turns them into a running board. You will write the shared register header, the startup code and both linker scripts, the clock and time base, three drivers (GPIO, UART, I2C), a bus interface, and the driver for the node’s temperature and humidity sensor. At the end, a small stage-2 main.c prints a reading every second in Renode, with the simulated sensor’s value under your control from the Renode monitor, and the sensor driver passes unit tests on your PC with no hardware at all. Everything here is reused unchanged by the later lessons, so the code is complete and explained section by section.

The capstone extends the stm32f407_regs.h you wrote in Memory-mapped registers in C with the peripherals the product needs: I2C1, the flash interface, the independent watchdog, the power controller, the RTC, EXTI and SYSCFG. Only the registers and bits this project touches are defined. A vendor header defines thousands; a short one that you can audit against the reference manual is a better teaching tool and a perfectly good production choice for a small product.

common/stm32f407_regs.h
/* stm32f407_regs.h - the STM32F407 registers this project uses.
CMSIS-style names; addresses and offsets from RM0090. */
#ifndef STM32F407_REGS_H
#define STM32F407_REGS_H
#include <stddef.h>
#include <stdint.h>
#define __I volatile const /* read only */
#define __IO volatile /* read/write */
/* ------------------------------------------------------------ layouts */
typedef struct {
__IO uint32_t CR, PLLCFGR, CFGR, CIR; /* 0x00 */
__IO uint32_t AHB1RSTR, AHB2RSTR, AHB3RSTR, RES0; /* 0x10 */
__IO uint32_t APB1RSTR, APB2RSTR, RES1[2]; /* 0x20 */
__IO uint32_t AHB1ENR, AHB2ENR, AHB3ENR, RES2; /* 0x30 */
__IO uint32_t APB1ENR, APB2ENR, RES3[2]; /* 0x40 */
__IO uint32_t AHB1LPENR, AHB2LPENR, AHB3LPENR, RES4; /* 0x50 */
__IO uint32_t APB1LPENR, APB2LPENR, RES5[2]; /* 0x60 */
__IO uint32_t BDCR, CSR; /* 0x70 */
} RCC_TypeDef;
typedef struct {
__IO uint32_t MODER, OTYPER, OSPEEDR, PUPDR; /* 0x00 */
__I uint32_t IDR; /* 0x10 */
__IO uint32_t ODR, BSRR, LCKR; /* 0x14 */
__IO uint32_t AFR[2]; /* 0x20 AFRL, 0x24 AFRH */
} GPIO_TypeDef;
typedef struct { __IO uint32_t SR, DR, BRR, CR1, CR2, CR3, GTPR; } USART_TypeDef;
typedef struct { __IO uint32_t CR1, CR2, OAR1, OAR2, DR, SR1, SR2, CCR, TRISE; } I2C_TypeDef;
typedef struct { __IO uint32_t ACR, KEYR, OPTKEYR, SR, CR, OPTCR; } FLASH_TypeDef;
typedef struct { __IO uint32_t KR, PR, RLR, SR; } IWDG_TypeDef;
typedef struct { __IO uint32_t CR, CSR; } PWR_TypeDef;
typedef struct {
__IO uint32_t TR, DR, CR, ISR, PRER, WUTR, CALIBR, ALRMAR, ALRMBR, WPR, SSR;
} RTC_TypeDef;
typedef struct { __IO uint32_t IMR, EMR, RTSR, FTSR, SWIER, PR; } EXTI_TypeDef;
typedef struct { __IO uint32_t MEMRMP, PMC, EXTICR[4]; } SYSCFG_TypeDef;
typedef struct { __IO uint32_t CTRL, LOAD, VAL; __I uint32_t CALIB; } SysTick_Type;
typedef struct {
__IO uint32_t ISER[8]; uint32_t RES0[24];
__IO uint32_t ICER[8]; uint32_t RES1[24];
__IO uint32_t ISPR[8]; uint32_t RES2[24];
__IO uint32_t ICPR[8]; uint32_t RES3[24];
__IO uint32_t IABR[8]; uint32_t RES4[56];
__IO uint8_t IP[240];
} NVIC_Type;
typedef struct {
__I uint32_t CPUID;
__IO uint32_t ICSR, VTOR, AIRCR, SCR, CCR;
__IO uint32_t SHPR[3];
__IO uint32_t SHCSR, CFSR, HFSR, DFSR, MMFAR, BFAR, AFSR;
uint32_t RES0[18];
__IO uint32_t CPACR;
} SCB_Type;
/* ---------------------------------------------------------- instances */
#define RCC ((RCC_TypeDef *)0x40023800u)
#define GPIOA ((GPIO_TypeDef *)0x40020000u)
#define GPIOB ((GPIO_TypeDef *)0x40020400u)
#define GPIOD ((GPIO_TypeDef *)0x40020C00u)
#define USART2 ((USART_TypeDef *)0x40004400u)
#define I2C1 ((I2C_TypeDef *)0x40005400u)
#define FLASH ((FLASH_TypeDef *)0x40023C00u)
#define IWDG ((IWDG_TypeDef *)0x40003000u)
#define PWR ((PWR_TypeDef *)0x40007000u)
#define RTC ((RTC_TypeDef *)0x40002800u)
#define EXTI ((EXTI_TypeDef *)0x40013C00u)
#define SYSCFG ((SYSCFG_TypeDef *)0x40013800u)
#define SysTick ((SysTick_Type *)0xE000E010u)
#define NVIC ((NVIC_Type *)0xE000E100u)
#define SCB ((SCB_Type *)0xE000ED00u)
_Static_assert(offsetof(RCC_TypeDef, AHB1ENR) == 0x30, "RCC layout");
_Static_assert(offsetof(RCC_TypeDef, CSR) == 0x74, "RCC layout");
_Static_assert(offsetof(GPIO_TypeDef, AFR) == 0x20, "GPIO layout");
_Static_assert(offsetof(I2C_TypeDef, TRISE) == 0x20, "I2C layout");
_Static_assert(offsetof(RTC_TypeDef, WPR) == 0x24, "RTC layout");
_Static_assert(offsetof(NVIC_Type, IP) == 0x300, "NVIC layout");
_Static_assert(offsetof(SCB_Type, CFSR) == 0x28, "SCB layout");
_Static_assert(offsetof(SCB_Type, CPACR) == 0x88, "SCB layout");
/* --------------------------------------------------------------- bits */
#define RCC_CR_HSEON (1u << 16)
#define RCC_CR_HSERDY (1u << 17)
#define RCC_CR_PLLON (1u << 24)
#define RCC_CR_PLLRDY (1u << 25)
#define RCC_PLLCFGR_FIELDS 0x0F437FFFu /* PLLM, PLLN, PLLP, PLLSRC, PLLQ */
#define RCC_PLLCFGR_PLLSRC_HSE (1u << 22)
#define RCC_CFGR_SW_Msk (3u << 0)
#define RCC_CFGR_SW_PLL (2u << 0)
#define RCC_CFGR_SWS_Msk (3u << 2)
#define RCC_CFGR_SWS_PLL (2u << 2)
#define RCC_CFGR_PPRE_Msk (0x3Fu << 10) /* PPRE1 and PPRE2 */
#define RCC_CFGR_PPRE1_DIV4 (5u << 10)
#define RCC_CFGR_PPRE2_DIV2 (4u << 13)
#define RCC_APB1ENR_USART2EN (1u << 17)
#define RCC_APB1ENR_I2C1EN (1u << 21)
#define RCC_APB1ENR_PWREN (1u << 28)
#define RCC_APB2ENR_SYSCFGEN (1u << 14)
#define RCC_BDCR_RTCSEL_LSI (2u << 8)
#define RCC_BDCR_RTCEN (1u << 15)
#define RCC_BDCR_BDRST (1u << 16)
#define RCC_CSR_LSION (1u << 0)
#define RCC_CSR_LSIRDY (1u << 1)
#define RCC_CSR_RMVF (1u << 24)
#define RCC_CSR_BORRSTF (1u << 25)
#define RCC_CSR_PINRSTF (1u << 26)
#define RCC_CSR_PORRSTF (1u << 27)
#define RCC_CSR_SFTRSTF (1u << 28)
#define RCC_CSR_IWDGRSTF (1u << 29)
#define RCC_CSR_WWDGRSTF (1u << 30)
#define RCC_CSR_LPWRRSTF (1u << 31)
#define FLASH_ACR_LATENCY_5WS 5u
#define FLASH_ACR_PRFTEN (1u << 8)
#define FLASH_ACR_ICEN (1u << 9)
#define FLASH_ACR_DCEN (1u << 10)
#define FLASH_ACR_DCRST (1u << 12)
#define FLASH_SR_EOP (1u << 0)
#define FLASH_SR_ERRORS 0xF2u /* OPERR WRPERR PGAERR PGPERR PGSERR */
#define FLASH_SR_BSY (1u << 16)
#define FLASH_CR_PG (1u << 0)
#define FLASH_CR_SER (1u << 1)
#define FLASH_CR_SNB_Pos 3u
#define FLASH_CR_PSIZE_X32 (2u << 8)
#define FLASH_CR_STRT (1u << 16)
#define FLASH_CR_LOCK (1u << 31)
#define FLASH_KEY1 0x45670123u
#define FLASH_KEY2 0xCDEF89ABu
#define USART_SR_ORE (1u << 3)
#define USART_SR_RXNE (1u << 5)
#define USART_SR_TC (1u << 6)
#define USART_SR_TXE (1u << 7)
#define USART_CR1_RE (1u << 2)
#define USART_CR1_TE (1u << 3)
#define USART_CR1_RXNEIE (1u << 5)
#define USART_CR1_UE (1u << 13)
#define I2C_CR1_PE (1u << 0)
#define I2C_CR1_START (1u << 8)
#define I2C_CR1_STOP (1u << 9)
#define I2C_CR1_ACK (1u << 10)
#define I2C_CR1_SWRST (1u << 15)
#define I2C_SR1_SB (1u << 0)
#define I2C_SR1_ADDR (1u << 1)
#define I2C_SR1_BTF (1u << 2)
#define I2C_SR1_RXNE (1u << 6)
#define I2C_SR1_TXE (1u << 7)
#define I2C_SR1_BERR (1u << 8)
#define I2C_SR1_ARLO (1u << 9)
#define I2C_SR1_AF (1u << 10)
#define I2C_SR2_BUSY (1u << 1)
#define PWR_CR_LPDS (1u << 0)
#define PWR_CR_PDDS (1u << 1)
#define PWR_CR_CWUF (1u << 2)
#define PWR_CR_DBP (1u << 8)
#define PWR_CR_FPDS (1u << 9)
#define PWR_CR_VOS (1u << 14)
#define RTC_CR_WUCKSEL_Msk (7u << 0) /* 000 selects RTCCLK / 16 */
#define RTC_CR_WUTE (1u << 10)
#define RTC_CR_WUTIE (1u << 14)
#define RTC_ISR_WUTWF (1u << 2)
#define RTC_ISR_WUTF (1u << 10)
#define EXTI_LINE_UART_RX (1u << 3) /* PA3 through SYSCFG EXTICR1 */
#define EXTI_LINE_RTC_WKUP (1u << 22)
#define SysTick_CTRL_ENABLE (1u << 0)
#define SysTick_CTRL_TICKINT (1u << 1)
#define SysTick_CTRL_CLKSOURCE (1u << 2)
#define SCB_SCR_SLEEPDEEP (1u << 2)
#define SCB_AIRCR_RESET 0x05FA0004u /* VECTKEY | SYSRESETREQ */
/* -------------------------------------------------------- IRQ numbers */
#define RTC_WKUP_IRQn 3u
#define EXTI3_IRQn 9u
#define USART2_IRQn 38u
/* ------------------------------------------------------- core helpers */
static inline void dsb(void) { __asm volatile ("dsb" ::: "memory"); }
static inline void isb(void) { __asm volatile ("isb" ::: "memory"); }
static inline void wfi(void) { __asm volatile ("wfi" ::: "memory"); }
/* Mask interrupts and return the previous PRIMASK, for nesting. */
static inline uint32_t irq_save(void)
{
uint32_t primask;
__asm volatile ("mrs %0, primask\n\tcpsid i" : "=r"(primask) :: "memory");
return primask;
}
static inline void irq_restore(uint32_t primask)
{
__asm volatile ("msr primask, %0" :: "r"(primask) : "memory");
}
static inline void nvic_enable_irq(uint32_t irqn)
{
NVIC->ISER[irqn >> 5] = 1u << (irqn & 31u);
}
static inline void nvic_set_priority(uint32_t irqn, uint32_t prio)
{
NVIC->IP[irqn] = (uint8_t)(prio << 4); /* 4 implemented bits, top nibble */
}
__attribute__((noreturn)) static inline void system_reset(void)
{
dsb(); /* finish outstanding writes first */
SCB->AIRCR = SCB_AIRCR_RESET;
dsb();
for (;;) { }
}
#endif /* STM32F407_REGS_H */

The layouts follow the struct-overlay style. Each comment gives the offset of the first register on that line, and the _Static_assert lines check the offsets that are easy to get wrong: RCC->CSR at 0x74 sits after six groups of reserved words, NVIC->IP starts at 0x300 (address 0xE000E400) after 56 reserved words, and SCB->CPACR at 0x88 follows 18 words of feature registers we do not name. If you miscount a reserved array, the build fails here instead of the clock configuration writing the wrong register.

Three bit definitions encode facts worth knowing. RCC_PLLCFGR_FIELDS is the mask of every field we set in PLLCFGR; the register’s reset value has a reserved bit set that must be preserved, so clock code clears only these fields. FLASH_SR_ERRORS collects the five programming error flags so one test catches any of them. SCB_AIRCR_RESET includes the 0x05FA key in the upper half; a write to AIRCR without the key is ignored.

The helpers at the bottom are the project’s only inline assembly outside the fault handler. irq_save and irq_restore implement a nestable critical section: save PRIMASK, mask, and later restore the saved value rather than blindly unmasking, so a critical section inside another one does not enable interrupts early. The "memory" clobber makes each one a compiler barrier, so no load or store moves across it. nvic_set_priority writes the priority into the top four bits of the byte because the F407 implements only those four. system_reset waits for outstanding writes with DSB before requesting the reset, so a flash or UART write in flight is not cut off.

One startup.c serves both programs. The deep dive is in Startup code and the vector table; here is the version the product uses.

common/startup.c
#include <stdint.h>
#include "stm32f407_regs.h"
extern uint32_t _sidata, _sdata, _edata, _sbss, _ebss, _estack;
int main(void);
void Reset_Handler(void);
void Default_Handler(void);
#define WEAK_ALIAS __attribute__((weak, alias("Default_Handler")))
void NMI_Handler(void) WEAK_ALIAS;
void HardFault_Handler(void) WEAK_ALIAS;
void MemManage_Handler(void) WEAK_ALIAS;
void BusFault_Handler(void) WEAK_ALIAS;
void UsageFault_Handler(void) WEAK_ALIAS;
void SVC_Handler(void) WEAK_ALIAS;
void DebugMon_Handler(void) WEAK_ALIAS;
void PendSV_Handler(void) WEAK_ALIAS;
void SysTick_Handler(void) WEAK_ALIAS;
void RTC_WKUP_IRQHandler(void) WEAK_ALIAS; /* IRQ 3 */
void EXTI3_IRQHandler(void) WEAK_ALIAS; /* IRQ 9 */
void USART2_IRQHandler(void) WEAK_ALIAS; /* IRQ 38 */
typedef void (*vector_t)(void);
#define D Default_Handler
__attribute__((section(".isr_vector"), used))
const vector_t g_vectors[16 + 82] = {
(vector_t)&_estack, Reset_Handler, NMI_Handler, HardFault_Handler,
MemManage_Handler, BusFault_Handler, UsageFault_Handler, 0, 0, 0, 0,
SVC_Handler, DebugMon_Handler, 0, PendSV_Handler, SysTick_Handler,
/* IRQ 0- 7 */ D, D, D, RTC_WKUP_IRQHandler, D, D, D, D,
/* IRQ 8-15 */ D, EXTI3_IRQHandler, D, D, D, D, D, D,
/* IRQ 16-23 */ D, D, D, D, D, D, D, D,
/* IRQ 24-31 */ D, D, D, D, D, D, D, D,
/* IRQ 32-39 */ D, D, D, D, D, D, USART2_IRQHandler, D,
/* IRQ 40-47 */ D, D, D, D, D, D, D, D,
/* IRQ 48-55 */ D, D, D, D, D, D, D, D,
/* IRQ 56-63 */ D, D, D, D, D, D, D, D,
/* IRQ 64-71 */ D, D, D, D, D, D, D, D,
/* IRQ 72-79 */ D, D, D, D, D, D, D, D,
/* IRQ 80-81 */ D, D,
};
void Reset_Handler(void)
{
uint32_t *src = &_sidata;
for (uint32_t *dst = &_sdata; dst < &_edata; ) {
*dst++ = *src++; /* .data: flash (LMA) to RAM (VMA) */
}
for (uint32_t *dst = &_sbss; dst < &_ebss; ) {
*dst++ = 0u; /* .bss: zero */
}
SCB->CPACR |= (0xFu << 20); /* CP10, CP11 full access: FPU on */
SCB->VTOR = (uint32_t)&g_vectors[0]; /* this program's own table */
dsb();
isb();
(void)main();
for (;;) { } /* main must not return */
}
void Default_Handler(void)
{
for (;;) { } /* unexpected interrupt: watchdog resets us */
}

The vector table has exactly 98 entries: 16 system entries and the F407’s 82 device IRQs. Writing every IRQ slot out, eight per line, looks clumsy but has one property the alternatives lack: you can count. IRQ 38 is the seventh entry on the “32-39” line. GCC’s range initializer ([16 ... 97] = D) followed by specific overrides would be shorter, but overriding an initializer triggers -Woverride-init, which -Wextra enables. Leaving unused slots as 0 would compile, but an unexpected interrupt would then branch to address 0 and fault with a confusing INVSTATE instead of landing in Default_Handler.

Weak aliases give every handler a default. When systick.c defines a normal SysTick_Handler, the linker uses it instead of the alias, with no registration step. The handlers listed are the ones this project implements somewhere; every other slot points straight at Default_Handler.

Reset_Handler copies .data, zeroes .bss, enables the FPU, and points VTOR at its own table before calling main. At -O2, GCC may recognize the two loops and replace them with calls to memcpy and memset. That is safe: those library functions use no initialized globals. Setting VTOR here makes the code correct whether it is started by the reset hardware (bootloader, table at 0x08000000), by the bootloader’s jump (application, table at slot base plus 0x200), or by Renode pointed directly at the application. The DSB and ISB make sure the CPACR and VTOR writes take effect before the next instruction, which is the canonical barrier use from The Cortex-M architecture.

We compile with -mfloat-abi=hard even though the firmware does all its arithmetic in integers. The FPU must be enabled before any instruction that touches a floating-point register executes, or the core takes a NOCP UsageFault. Enabling it unconditionally costs one instruction and removes the question.

The bootloader and the application differ in where their code goes. Both keep .data, .bss and the stack in the same RAM (the bootloader’s RAM contents are dead once it jumps) and both place the handoff block in the same 256 bytes at the top.

boot/boot.ld
#include "flash_layout.h"
ENTRY(Reset_Handler)
MEMORY
{
ROM (rx) : ORIGIN = BOOT_BASE, LENGTH = BOOT_SIZE
RAM (rwx) : ORIGIN = RAM_BASE, LENGTH = RAM_SIZE
NOINIT (rw) : ORIGIN = HANDOFF_ADDR, LENGTH = HANDOFF_SIZE
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
_min_stack = 0x1000;
SECTIONS
{
.isr_vector : { KEEP(*(.isr_vector)) } > ROM
.text : { *(.text*) *(.rodata*) . = ALIGN(4); } > ROM
.ARM.exidx : { *(.ARM.exidx*) } > ROM
.data : { . = ALIGN(4); _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > RAM AT > ROM
_sidata = LOADADDR(.data);
.bss (NOLOAD) : { . = ALIGN(4); _sbss = .; *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM
PROVIDE(end = _ebss);
.noinit (NOLOAD) : { *(.noinit*) } > NOINIT
ASSERT(ADDR(.noinit) == HANDOFF_ADDR, "handoff block moved");
ASSERT(_ebss + _min_stack <= _estack, "RAM too small for the stack");
}
app/app.ld
#include "flash_layout.h"
ENTRY(Reset_Handler)
MEMORY
{
SLOT (rx) : ORIGIN = APP_SLOT_BASE, LENGTH = SLOT_SIZE - 16
RAM (rwx) : ORIGIN = RAM_BASE, LENGTH = RAM_SIZE
NOINIT (rw) : ORIGIN = HANDOFF_ADDR, LENGTH = HANDOFF_SIZE
}
_estack = ORIGIN(RAM) + LENGTH(RAM);
_min_stack = 0x1000;
SECTIONS
{
.image_header : { KEEP(*(.image_header)) FILL(0xFFFFFFFF); . = IMAGE_HEADER_SIZE; } > SLOT
.isr_vector : { KEEP(*(.isr_vector)) } > SLOT
.text : { *(.text*) *(.rodata*) . = ALIGN(4); } > SLOT
.ARM.exidx : { *(.ARM.exidx*) } > SLOT
.data : { . = ALIGN(4); _sdata = .; *(.data*) . = ALIGN(4); _edata = .; } > RAM AT > SLOT
_sidata = LOADADDR(.data);
.bss (NOLOAD) : { . = ALIGN(4); _sbss = .; *(.bss*) *(COMMON) . = ALIGN(4); _ebss = .; } > RAM
PROVIDE(end = _ebss);
.noinit (NOLOAD) : { *(.noinit*) } > NOINIT
ASSERT(ADDR(.isr_vector) == APP_SLOT_BASE + IMAGE_HEADER_SIZE, "vector table must follow the header");
ASSERT(ADDR(.noinit) == HANDOFF_ADDR, "handoff block moved");
ASSERT(_ebss + _min_stack <= _estack, "RAM too small for the stack");
}

The full treatment is in Linker scripts; these are the lines that are specific to the product.

  • #include "flash_layout.h" works because the Makefile runs the script through gcc -E -P -x c first. After preprocessing, APP_SLOT_BASE is 0x08008000 in build/a/app.ld and 0x08040000 in build/b/app.ld. Comments in the C header vanish in preprocessing, so ld never sees them.
  • LENGTH = SLOT_SIZE - 16 keeps the trailer out of the linker’s hands. An application that grows into the trailer fails to link with “region SLOT overflowed” instead of overwriting the confirm flag.
  • .image_header holds app_image_header from version.c, then . = IMAGE_HEADER_SIZE advances the location counter to 512 bytes from the section start, filling the gap with 0xFF (erased flash) because of FILL. The vector table lands exactly at slot base plus 0x200, and the ASSERT proves it on every link.
  • .noinit (NOLOAD) in its own region means startup never copies or zeroes it. The ASSERT catches a future edit that puts a second object in .noinit ahead of g_handoff and moves it.
  • PROVIDE(end = _ebss) defines the symbol newlib’s _sbrk stub expects. Nothing in this firmware calls malloc, but nosys.specs links the stub, and without end the link fails.
  • The stack check reserves 4 KB between the end of .bss and the top of RAM. Lesson 05 measures actual stack use against this number.

Neither program uses the 64 KB CCM RAM at 0x10000000. It would be a good home for the stack in a larger product (it is fast and DMA cannot touch it), but keeping everything in one RAM region keeps both linker scripts simple.

app/platform/clock.h
#ifndef CLOCK_H
#define CLOCK_H
#include <stdint.h>
#include "err.h"
err_t clock_init_168mhz(void); /* HSE 8 MHz -> PLL -> 168 MHz, or stay on HSI */
uint32_t clock_sysclk_hz(void);
uint32_t clock_apb1_hz(void);
#endif /* CLOCK_H */
app/platform/clock.c
#include <stdbool.h>
#include "clock.h"
#include "stm32f407_regs.h"
static uint32_t s_sysclk_hz = 16000000u; /* HSI after reset */
static uint32_t s_apb1_hz = 16000000u;
static bool wait_bits(volatile uint32_t *reg, uint32_t mask, uint32_t want)
{
for (uint32_t i = 0; i < 200000u; i++) {
if ((*reg & mask) == want) {
return true;
}
}
return false;
}
err_t clock_init_168mhz(void)
{
if ((RCC->CFGR & RCC_CFGR_SWS_Msk) == RCC_CFGR_SWS_PLL) {
return ERR_OK; /* already running from the PLL */
}
RCC->APB1ENR |= RCC_APB1ENR_PWREN;
(void)RCC->APB1ENR;
PWR->CR |= PWR_CR_VOS; /* regulator scale 1: needed above 144 MHz */
RCC->CR |= RCC_CR_HSEON;
if (!wait_bits(&RCC->CR, RCC_CR_HSERDY, RCC_CR_HSERDY)) {
RCC->CR &= ~RCC_CR_HSEON;
return ERR_TIMEOUT; /* no crystal: keep running on HSI */
}
/* 8 MHz / M=8 * N=336 / P=2 = 168 MHz; / Q=7 = 48 MHz for USB */
RCC->CR &= ~RCC_CR_PLLON;
RCC->PLLCFGR = (RCC->PLLCFGR & ~RCC_PLLCFGR_FIELDS)
| 8u | (336u << 6) | (0u << 16) | RCC_PLLCFGR_PLLSRC_HSE | (7u << 24);
RCC->CR |= RCC_CR_PLLON;
if (!wait_bits(&RCC->CR, RCC_CR_PLLRDY, RCC_CR_PLLRDY)) {
return ERR_TIMEOUT;
}
/* Raise wait states BEFORE raising the clock: 5 WS at 168 MHz, 3.3 V. */
FLASH->ACR = FLASH_ACR_LATENCY_5WS | FLASH_ACR_PRFTEN | FLASH_ACR_ICEN | FLASH_ACR_DCEN;
RCC->CFGR = (RCC->CFGR & ~RCC_CFGR_PPRE_Msk) | RCC_CFGR_PPRE1_DIV4 | RCC_CFGR_PPRE2_DIV2;
RCC->CFGR = (RCC->CFGR & ~RCC_CFGR_SW_Msk) | RCC_CFGR_SW_PLL;
if (!wait_bits(&RCC->CFGR, RCC_CFGR_SWS_Msk, RCC_CFGR_SWS_PLL)) {
return ERR_TIMEOUT;
}
s_sysclk_hz = 168000000u;
s_apb1_hz = 42000000u; /* APB1 = HCLK / 4 */
return ERR_OK;
}
uint32_t clock_sysclk_hz(void) { return s_sysclk_hz; }
uint32_t clock_apb1_hz(void) { return s_apb1_hz; }

The sequence is the one from Clocks, reset, and timing, with three product decisions added.

  • It returns early if the PLL is already the system clock. Lesson 05 calls this function after every wake from Stop mode, where the chip comes back on HSI. Calling it while already on the PLL must not turn the PLL off underneath the running CPU.
  • Every wait is bounded and the failure is survivable. If the crystal does not start, the function leaves the chip on the 16 MHz HSI and reports ERR_TIMEOUT. The accessors then report 16 MHz, and every driver computes its baud rate and timing from them, so the console still works at the right baud rate on a board with a dead crystal.
  • Frequencies come from accessors, never from constants. The UART’s BRR and the I2C timing registers are computed from clock_apb1_hz(). The classic “garbage on the terminal” bug, a baud divisor computed for 42 MHz on a peripheral clocked at 16 MHz, cannot happen.

The bounded loop counts iterations, not milliseconds, because SysTick is not running yet. At 16 MHz, 200,000 iterations of a few instructions each is tens of milliseconds, far longer than the HSE start-up time of a crystal (typically 2 ms).

app/platform/systick.h
#ifndef SYSTICK_H
#define SYSTICK_H
#include <stdbool.h>
#include <stdint.h>
void systick_init(uint32_t core_hz);
uint32_t systick_now_ms(void);
void systick_advance_ms(uint32_t ms); /* account for time spent in Stop mode */
/* Wraparound-safe: true once now has reached or passed the deadline. */
static inline bool systick_expired(uint32_t deadline_ms)
{
return (int32_t)(systick_now_ms() - deadline_ms) >= 0;
}
#endif /* SYSTICK_H */
app/platform/systick.c
#include "systick.h"
#include "stm32f407_regs.h"
static volatile uint32_t s_ms;
void SysTick_Handler(void)
{
s_ms++;
}
void systick_init(uint32_t core_hz)
{
SysTick->LOAD = core_hz / 1000u - 1u;
SysTick->VAL = 0u;
SCB->SHPR[2] = (SCB->SHPR[2] & 0x00FFFFFFu) | (0xF0u << 24); /* lowest priority */
SysTick->CTRL = SysTick_CTRL_CLKSOURCE | SysTick_CTRL_TICKINT | SysTick_CTRL_ENABLE;
}
uint32_t systick_now_ms(void)
{
return s_ms; /* aligned 32-bit read: atomic on Cortex-M */
}
void systick_advance_ms(uint32_t ms)
{
uint32_t pm = irq_save(); /* read-modify-write shared with the ISR */
s_ms += ms;
irq_restore(pm);
}

The millisecond counter wraps after 49.7 days, which a battery node will reach. Every comparison in the project therefore uses the subtraction form in systick_expired: (int32_t)(now - deadline) >= 0 is correct across the wrap as long as the two times are less than 24.8 days apart. SysTick gets the lowest priority (SHPR3 bits 31:24) because a late tick by a few microseconds costs nothing, while a late UART byte is lost. systick_advance_ms exists for lesson 05: in Stop mode the core clock stops, SysTick stops with it, and the power module adds the slept time back.

common/gpio.h
#ifndef GPIO_H
#define GPIO_H
#include <stdbool.h>
#include <stdint.h>
#include "stm32f407_regs.h"
typedef enum { GPIO_INPUT = 0, GPIO_OUTPUT = 1, GPIO_AF = 2, GPIO_ANALOG = 3 } gpio_mode_t;
typedef enum { GPIO_NOPULL = 0, GPIO_PULLUP = 1, GPIO_PULLDOWN = 2 } gpio_pull_t;
void gpio_clock_enable(GPIO_TypeDef *port);
void gpio_config(GPIO_TypeDef *port, uint32_t pin, gpio_mode_t mode,
gpio_pull_t pull, bool open_drain, uint32_t af);
static inline void gpio_set(GPIO_TypeDef *port, uint32_t pin) { port->BSRR = 1u << pin; }
static inline void gpio_clear(GPIO_TypeDef *port, uint32_t pin) { port->BSRR = 1u << (pin + 16u); }
static inline bool gpio_read(GPIO_TypeDef *port, uint32_t pin) { return ((port->IDR >> pin) & 1u) != 0u; }
#endif /* GPIO_H */
common/gpio.c
#include "gpio.h"
void gpio_clock_enable(GPIO_TypeDef *port)
{
uint32_t index = ((uint32_t)port - 0x40020000u) / 0x400u; /* GPIOA = 0 */
RCC->AHB1ENR |= 1u << index;
(void)RCC->AHB1ENR; /* read back: clock is running before first access */
}
void gpio_config(GPIO_TypeDef *port, uint32_t pin, gpio_mode_t mode,
gpio_pull_t pull, bool open_drain, uint32_t af)
{
uint32_t p2 = pin * 2u;
if (mode == GPIO_AF) { /* select the function before the pin switches to it */
uint32_t reg = pin >> 3, shift = (pin & 7u) * 4u;
port->AFR[reg] = (port->AFR[reg] & ~(0xFu << shift)) | ((af & 0xFu) << shift);
}
port->OTYPER = (port->OTYPER & ~(1u << pin)) | ((open_drain ? 1u : 0u) << pin);
port->OSPEEDR = (port->OSPEEDR & ~(3u << p2)) | (1u << p2); /* medium */
port->PUPDR = (port->PUPDR & ~(3u << p2)) | ((uint32_t)pull << p2);
port->MODER = (port->MODER & ~(3u << p2)) | ((uint32_t)mode << p2);
}

The GPIO driver is the one from GPIO in depth, cut down. Two details matter. The alternate function number is written before MODER switches the pin to alternate mode, so the pin never briefly drives the wrong peripheral’s signal. OSPEEDR is fixed at medium speed: the fastest UART edge we generate is at 115200 baud and I2C runs at 100 kHz, and slower edges radiate less. gpio_config uses read-modify-write on shared registers, so it must not race with an ISR configuring another pin of the same port; in this project all pin configuration happens in thread mode at init or around sleep.

common/uart.h
#ifndef UART_H
#define UART_H
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
typedef void (*uart_rx_hook_t)(void); /* called from the ISR per byte */
void uart_init(uint32_t pclk_hz, uint32_t baud);
void uart_putc(char c);
void uart_write(const void *data, size_t len);
void uart_puts(const char *s);
void uart_flush(void); /* wait until the last stop bit is out */
void uart_rx_enable_irq(void);
void uart_set_rx_hook(uart_rx_hook_t hook);
bool uart_getc(uint8_t *out); /* false when the ring is empty */
uint32_t uart_rx_overruns(void);
#endif /* UART_H */
common/uart.c
#include "uart.h"
#include "board.h"
#include "gpio.h"
#define RX_BUF_SIZE 256u /* power of two */
static volatile uint8_t s_rx_buf[RX_BUF_SIZE];
static volatile uint32_t s_rx_head; /* written only by the ISR */
static volatile uint32_t s_rx_tail; /* written only by main */
static volatile uint32_t s_rx_overruns;
static uart_rx_hook_t s_rx_hook;
void uart_init(uint32_t pclk_hz, uint32_t baud)
{
gpio_clock_enable(UART_PORT);
RCC->APB1ENR |= RCC_APB1ENR_USART2EN;
(void)RCC->APB1ENR;
gpio_config(UART_PORT, UART_TX_PIN, GPIO_AF, GPIO_NOPULL, false, UART_AF);
gpio_config(UART_PORT, UART_RX_PIN, GPIO_AF, GPIO_PULLUP, false, UART_AF);
USART2->CR1 = 0u;
USART2->BRR = (pclk_hz + baud / 2u) / baud; /* oversampling by 16, rounded */
USART2->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE;
}
void uart_putc(char c)
{
while ((USART2->SR & USART_SR_TXE) == 0u) { }
USART2->DR = (uint8_t)c;
}
void uart_write(const void *data, size_t len)
{
const uint8_t *p = data;
while (len--) {
uart_putc((char)*p++);
}
}
void uart_puts(const char *s)
{
while (*s != '\0') {
uart_putc(*s++);
}
}
void uart_flush(void)
{
while ((USART2->SR & USART_SR_TC) == 0u) { }
}
void uart_set_rx_hook(uart_rx_hook_t hook) { s_rx_hook = hook; }
uint32_t uart_rx_overruns(void) { return s_rx_overruns; }
void uart_rx_enable_irq(void)
{
USART2->CR1 |= USART_CR1_RXNEIE;
nvic_set_priority(USART2_IRQn, 5u);
nvic_enable_irq(USART2_IRQn);
}
void USART2_IRQHandler(void)
{
uint32_t sr = USART2->SR;
if ((sr & (USART_SR_RXNE | USART_SR_ORE)) != 0u) {
uint8_t byte = (uint8_t)USART2->DR; /* SR then DR read clears RXNE and ORE */
if ((sr & USART_SR_ORE) != 0u) {
s_rx_overruns++;
}
uint32_t head = s_rx_head;
if (head - s_rx_tail < RX_BUF_SIZE) {
s_rx_buf[head & (RX_BUF_SIZE - 1u)] = byte;
s_rx_head = head + 1u; /* publish after the data is stored */
} else {
s_rx_overruns++; /* ring full: drop and count */
}
if (s_rx_hook != NULL) {
s_rx_hook();
}
}
}
bool uart_getc(uint8_t *out)
{
uint32_t tail = s_rx_tail;
if (tail == s_rx_head) {
return false;
}
*out = s_rx_buf[tail & (RX_BUF_SIZE - 1u)];
s_rx_tail = tail + 1u;
return true;
}

The UART is in common/ because the bootloader prints through it too, at 16 MHz, using only the polled functions.

Transmit is polled. At 115200 baud a character takes 87 µs, so a 60-character report blocks the main loop for about 5 ms. For a node that wakes once every few seconds that is an acceptable trade for simplicity, and it means a log line is completely out before the code that printed it continues. An interrupt-driven TX ring is exercise 4 of lesson 03. The TXE wait has no timeout on purpose: with its clock enabled the USART always drains, and the only way it would not is a misconfigured clock that clock_init_168mhz has already ruled out.

uart_flush waits for TC, not TXE. TXE means the data register is free, while the last byte may still be shifting out. Before a reset or before Stop mode freezes the peripheral clock, the code must wait for TC (transmission complete) or the last character of “rebooting” is cut in half.

Receive is interrupt driven into a single-producer, single-consumer ring. The head and tail are free-running 32-bit counters; their difference is the fill level even across wraparound, and & (RX_BUF_SIZE - 1) turns a counter into an index because the size is a power of two. Only the ISR writes head and only uart_getc writes tail, so no lock is needed. The ISR stores the byte first and publishes the new head second; with both accesses volatile, the compiler keeps that order, and a single-core Cortex-M4 does not reorder them (the ordering argument is made precisely in Concurrency, atomics, and memory ordering). The hook lets the application learn that bytes arrived without common/ knowing anything about events.

The sensor driver must not know that it talks over I2C1 on PB6 and PB9. It needs two operations: write some bytes to a device, and write some bytes then read some back in one transaction (the register-read pattern from SPI and I2C). That is the whole interface.

app/drivers/bus.h
#ifndef BUS_H
#define BUS_H
#include <stddef.h>
#include <stdint.h>
#include "err.h"
typedef struct {
err_t (*write)(void *ctx, uint8_t addr, const uint8_t *data, size_t len);
err_t (*write_read)(void *ctx, uint8_t addr, const uint8_t *wr, size_t wlen,
uint8_t *rd, size_t rlen);
} bus_ops_t;
typedef struct {
const bus_ops_t *ops; /* const table: lives in flash */
void *ctx; /* per-instance state for the implementation */
} bus_t;
static inline err_t bus_reg_read(const bus_t *bus, uint8_t addr, uint8_t reg,
uint8_t *buf, size_t len)
{
return bus->ops->write_read(bus->ctx, addr, &reg, 1u, buf, len);
}
static inline err_t bus_reg_write(const bus_t *bus, uint8_t addr, uint8_t reg,
uint8_t value)
{
uint8_t tx[2] = { reg, value };
return bus->ops->write(bus->ctx, addr, tx, 2u);
}
#endif /* BUS_H */

A bus_t is a pair: a pointer to a const table of functions and a context pointer handed back to them. The table is const, so it costs flash, not RAM. The real implementation ignores ctx because there is only one I2C1; the fake bus in the host tests uses it to find its simulated registers. The two helpers express “read N registers starting at reg” and “write one register” once, so the sensor driver reads like the sensor’s datasheet.

app/platform/i2c.h
#ifndef I2C_H
#define I2C_H
#include <stddef.h>
#include <stdint.h>
#include "bus.h"
#include "err.h"
err_t i2c_init(uint32_t pclk_hz); /* also recovers a stuck bus */
err_t i2c_write(uint8_t addr, const uint8_t *data, size_t len);
err_t i2c_write_read(uint8_t addr, const uint8_t *wr, size_t wlen, uint8_t *rd, size_t rlen);
extern const bus_ops_t i2c_bus_ops;
#endif /* I2C_H */
app/platform/i2c.c
#include <stdbool.h>
#include "i2c.h"
#include "board.h"
#include "gpio.h"
#define I2C_WAIT_LOOPS 100000u /* about 3 ms at 168 MHz, 30 ms at 16 MHz */
static void short_delay(void)
{
for (volatile uint32_t i = 0; i < 200u; i++) { }
}
/* Clock out up to 9 pulses until a slave stuck mid-byte releases SDA, then STOP. */
static void bus_recover(void)
{
gpio_set(I2C_PORT, I2C_SCL_PIN);
gpio_set(I2C_PORT, I2C_SDA_PIN);
gpio_config(I2C_PORT, I2C_SCL_PIN, GPIO_OUTPUT, GPIO_PULLUP, true, 0u);
gpio_config(I2C_PORT, I2C_SDA_PIN, GPIO_OUTPUT, GPIO_PULLUP, true, 0u);
for (uint32_t i = 0; i < 9u && !gpio_read(I2C_PORT, I2C_SDA_PIN); i++) {
gpio_clear(I2C_PORT, I2C_SCL_PIN); short_delay();
gpio_set(I2C_PORT, I2C_SCL_PIN); short_delay();
}
gpio_clear(I2C_PORT, I2C_SDA_PIN); short_delay(); /* STOP: SDA rises while SCL high */
gpio_set(I2C_PORT, I2C_SDA_PIN); short_delay();
gpio_config(I2C_PORT, I2C_SCL_PIN, GPIO_AF, GPIO_PULLUP, true, I2C_AF);
gpio_config(I2C_PORT, I2C_SDA_PIN, GPIO_AF, GPIO_PULLUP, true, I2C_AF);
}
err_t i2c_init(uint32_t pclk_hz)
{
gpio_clock_enable(I2C_PORT);
RCC->APB1ENR |= RCC_APB1ENR_I2C1EN;
(void)RCC->APB1ENR;
I2C1->CR1 = 0u; /* PE off while pins are bit-banged */
bus_recover();
I2C1->CR1 = I2C_CR1_SWRST; /* clear any stale BUSY state */
I2C1->CR1 = 0u;
uint32_t mhz = pclk_hz / 1000000u;
I2C1->CR2 = mhz; /* FREQ: APB1 clock in MHz */
I2C1->CCR = pclk_hz / (2u * 100000u); /* 100 kHz standard mode */
I2C1->TRISE = mhz + 1u; /* 1000 ns max rise time */
I2C1->CR1 = I2C_CR1_PE;
return ((I2C1->SR2 & I2C_SR2_BUSY) != 0u) ? ERR_BUS : ERR_OK; /* still stuck */
}
static err_t wait_sr1(uint32_t mask)
{
for (uint32_t i = 0; i < I2C_WAIT_LOOPS; i++) {
uint32_t sr1 = I2C1->SR1;
if ((sr1 & I2C_SR1_AF) != 0u) {
I2C1->SR1 = 0xFFFFu & ~I2C_SR1_AF; /* rc_w0: write 0 to clear */
return ERR_NACK;
}
if ((sr1 & (I2C_SR1_BERR | I2C_SR1_ARLO)) != 0u) {
I2C1->SR1 = 0xFFFFu & ~(I2C_SR1_BERR | I2C_SR1_ARLO);
return ERR_BUS;
}
if ((sr1 & mask) != 0u) {
return ERR_OK;
}
}
return ERR_TIMEOUT;
}
static err_t wait_idle(void)
{
for (uint32_t i = 0; i < I2C_WAIT_LOOPS; i++) {
if ((I2C1->CR1 & I2C_CR1_STOP) == 0u && (I2C1->SR2 & I2C_SR2_BUSY) == 0u) {
return ERR_OK; /* previous STOP sent, bus free */
}
}
return ERR_BUSY;
}
static void clear_addr(void)
{
(void)I2C1->SR1; /* ADDR clears on SR1 then SR2 read */
(void)I2C1->SR2;
}
static err_t start_addr(uint8_t addr, bool read)
{
I2C1->CR1 |= I2C_CR1_START;
err_t e = wait_sr1(I2C_SR1_SB);
if (e != ERR_OK) {
return e;
}
I2C1->DR = ((uint32_t)addr << 1) | (read ? 1u : 0u);
return wait_sr1(I2C_SR1_ADDR);
}
static err_t write_bytes(const uint8_t *data, size_t len)
{
for (size_t i = 0; i < len; i++) {
err_t e = wait_sr1(I2C_SR1_TXE);
if (e != ERR_OK) {
return e;
}
I2C1->DR = data[i];
}
return wait_sr1(I2C_SR1_BTF); /* last byte fully on the wire */
}
/* Called with ADDR set. Issues STOP itself. */
static err_t read_bytes(uint8_t *buf, size_t len)
{
err_t e;
if (len == 1u) {
I2C1->CR1 &= ~I2C_CR1_ACK; /* NACK the only byte */
clear_addr();
I2C1->CR1 |= I2C_CR1_STOP;
e = wait_sr1(I2C_SR1_RXNE);
if (e == ERR_OK) {
buf[0] = (uint8_t)I2C1->DR;
}
return e;
}
I2C1->CR1 |= I2C_CR1_ACK;
clear_addr();
for (size_t i = 0; i < len; i++) {
if (i == len - 2u) {
/* Byte len-2 is in DR and the last byte is shifting in. ACK must be
cleared before that last byte completes, so no ISR may run here. */
uint32_t pm = irq_save();
e = wait_sr1(I2C_SR1_RXNE);
I2C1->CR1 &= ~I2C_CR1_ACK;
I2C1->CR1 |= I2C_CR1_STOP;
if (e == ERR_OK) {
buf[i] = (uint8_t)I2C1->DR;
}
irq_restore(pm);
} else {
e = wait_sr1(I2C_SR1_RXNE);
if (e == ERR_OK) {
buf[i] = (uint8_t)I2C1->DR;
}
}
if (e != ERR_OK) {
return e;
}
}
return ERR_OK;
}
err_t i2c_write(uint8_t addr, const uint8_t *data, size_t len)
{
err_t e = wait_idle();
if (e == ERR_OK) {
e = start_addr(addr, false);
}
if (e == ERR_OK) {
clear_addr();
e = write_bytes(data, len);
}
I2C1->CR1 |= I2C_CR1_STOP;
return e;
}
err_t i2c_write_read(uint8_t addr, const uint8_t *wr, size_t wlen, uint8_t *rd, size_t rlen)
{
if (rlen == 0u) {
return i2c_write(addr, wr, wlen);
}
err_t e = wait_idle();
if (e == ERR_OK) {
e = start_addr(addr, false);
}
if (e == ERR_OK) {
clear_addr();
e = write_bytes(wr, wlen);
}
if (e == ERR_OK) {
e = start_addr(addr, true); /* repeated START, no STOP between */
}
if (e == ERR_OK) {
e = read_bytes(rd, rlen);
if (e == ERR_OK) {
return ERR_OK;
}
}
I2C1->CR1 |= I2C_CR1_STOP; /* error: release the bus */
return e;
}
static err_t bus_write(void *ctx, uint8_t addr, const uint8_t *data, size_t len)
{
(void)ctx;
return i2c_write(addr, data, len);
}
static err_t bus_write_read(void *ctx, uint8_t addr, const uint8_t *wr, size_t wlen,
uint8_t *rd, size_t rlen)
{
(void)ctx;
return i2c_write_read(addr, wr, wlen, rd, rlen);
}
const bus_ops_t i2c_bus_ops = { .write = bus_write, .write_read = bus_write_read };

The F4’s I2C peripheral is event-driven and unforgiving, and SPI and I2C walks through its event sequence. What follows is what this driver adds to make it product grade.

Initialization recovers the bus first. If the MCU resets while a slave is in the middle of sending a byte, the slave may be holding SDA low waiting for clocks that will never come, and the peripheral will see a permanently busy bus. Before enabling the peripheral, bus_recover drives the pins as open-drain GPIOs, clocks SCL up to nine times until SDA is released, generates a STOP by hand, and only then hands the pins to alternate function 4. If the peripheral still reports a busy bus after that, i2c_init returns ERR_BUS. The output latches are set high before the pins become outputs so there is no glitch low. The Discovery board has external pull-ups on PB6 and PB9; the internal ones enabled here (about 40 kΩ) are too weak to be the only pull-ups at 100 kHz on a real bus. CCR and TRISE come from the actual APB1 clock: at 42 MHz, CCR = 210 gives 100 kHz and TRISE = 43.

Every wait is bounded and every failure releases the bus. wait_sr1 returns ERR_NACK when the device does not acknowledge (AF), ERR_BUS on a bus error or lost arbitration, and ERR_TIMEOUT when nothing happens. SR1’s error flags are “read, clear by writing 0”, so the driver writes zero to the flag it handles and ones elsewhere, which leaves the other flags untouched. Every exit path from a transaction sets STOP. wait_idle at the start of each transaction makes sure the previous STOP has gone out and the bus is free before generating a new START. The timeouts count loop iterations rather than milliseconds so they also work inside the critical section below, where SysTick cannot advance.

Reads use the NACK-the-last-byte rule. A master receiver must NACK the final byte so the slave lets go of SDA, then send STOP. The peripheral decides between ACK and NACK at the end of each byte using the ACK bit at that moment. For a single-byte read, ACK is cleared before ADDR is cleared, so the only byte is NACKed. For two or more, when byte len-2 arrives in DR, the last byte is already shifting in; ACK must be cleared and STOP requested before it finishes, which at 100 kHz is about 80 µs away. An interrupt arriving at that moment could delay the CPU past the deadline and the slave would get an ACK, keep driving the bus, and lock it. The driver therefore masks interrupts for those few instructions. The reference manual’s alternative uses the BTF flag to stretch the clock; the masked window is shorter to explain and costs at most one byte time of interrupt latency.

The node’s sensor is the TH20, a register-based I2C temperature and humidity sensor defined for this course so that you own its whole specification. Real parts (Sensirion SHT4x, TI HDC2080, ST HTS221) differ in detail but follow the same pattern: an identity register, a control register that starts a conversion, a status flag, and little-endian result registers.

AddressNameAccessDescription
0x00WHO_AM_IRAlways 0xA7
0x01CTRLRWBit 0 ONESHOT: write 1 to start one conversion, self-clearing. Bit 7 SWRESET: self-clearing reset
0x02STATUSRBit 0 DRDY: result ready, cleared when HUM_H is read. Bit 1 BUSY
0x03, 0x04TEMP_L, TEMP_HRTemperature, signed 16-bit, 0.01 °C per LSB, little-endian
0x05, 0x06HUM_L, HUM_HRRelative humidity, unsigned 16-bit, 0.01 %RH per LSB

The device lives at 7-bit address 0x40. The register pointer auto-increments during a multi-byte read, a conversion takes at most 20 ms, and the valid output range is -40.00 to 125.00 °C and 0 to 100.00 %RH. Because the results are already scaled integers, the driver never needs floating point: 2150 means 21.50 °C.

app/drivers/th20.h
#ifndef TH20_H
#define TH20_H
#include <stdbool.h>
#include <stdint.h>
#include "bus.h"
#define TH20_REG_WHO_AM_I 0x00u
#define TH20_REG_CTRL 0x01u
#define TH20_REG_STATUS 0x02u
#define TH20_REG_TEMP_L 0x03u
#define TH20_WHO_AM_I_VALUE 0xA7u
#define TH20_CTRL_ONESHOT 0x01u
#define TH20_CTRL_SWRESET 0x80u
#define TH20_STATUS_DRDY 0x01u
#define TH20_CONVERSION_MS 20u
typedef struct {
int16_t temp_cdeg; /* 0.01 degC */
uint16_t hum_cpct; /* 0.01 %RH */
} th20_sample_t;
typedef struct {
bus_t bus;
uint8_t addr;
bool present; /* WHO_AM_I matched at init */
} th20_t;
err_t th20_init(th20_t *dev, bus_t bus, uint8_t addr);
err_t th20_start(th20_t *dev); /* begin one conversion */
err_t th20_read(th20_t *dev, th20_sample_t *out); /* ERR_BUSY until ready */
#endif /* TH20_H */
app/drivers/th20.c
#include "th20.h"
err_t th20_init(th20_t *dev, bus_t bus, uint8_t addr)
{
if (dev == NULL || bus.ops == NULL) {
return ERR_PARAM;
}
dev->bus = bus;
dev->addr = addr;
dev->present = false;
uint8_t id = 0u;
err_t e = bus_reg_read(&dev->bus, addr, TH20_REG_WHO_AM_I, &id, 1u);
if (e != ERR_OK) {
return e;
}
if (id != TH20_WHO_AM_I_VALUE) {
return ERR_NODEV;
}
dev->present = true;
return ERR_OK;
}
err_t th20_start(th20_t *dev)
{
if (!dev->present) {
return ERR_STATE;
}
return bus_reg_write(&dev->bus, dev->addr, TH20_REG_CTRL, TH20_CTRL_ONESHOT);
}
err_t th20_read(th20_t *dev, th20_sample_t *out)
{
if (!dev->present) {
return ERR_STATE;
}
uint8_t raw[5]; /* STATUS, TEMP_L, TEMP_H, HUM_L, HUM_H in one transaction */
err_t e = bus_reg_read(&dev->bus, dev->addr, TH20_REG_STATUS, raw, sizeof raw);
if (e != ERR_OK) {
return e;
}
if ((raw[0] & TH20_STATUS_DRDY) == 0u) {
return ERR_BUSY;
}
uint16_t t = (uint16_t)(raw[1] | (raw[2] << 8));
uint16_t h = (uint16_t)(raw[3] | (raw[4] << 8));
int16_t temp = (int16_t)t; /* implementation-defined above 32767; GCC wraps */
if (temp < -4000 || temp > 12500 || h > 10000u) {
return ERR_RANGE; /* a glitch on the bus, or a broken sensor */
}
out->temp_cdeg = temp;
out->hum_cpct = h;
return ERR_OK;
}

The driver is non-blocking: th20_start triggers the conversion and returns, and the caller comes back at least 20 ms later. A driver that waited inside th20_read would freeze every other activity for 20 ms per sample and would make sleeping during the conversion impossible.

th20_read fetches status and all four data bytes in a single 5-byte transaction starting at STATUS. That matters beyond speed: reading the bytes in separate transactions would let a new conversion complete between the temperature and humidity reads, and you would report a mixed pair. The bytes are assembled with shifts, never by casting the buffer to int16_t *, so the code is independent of host endianness and alignment (the rule from Bit manipulation). The conversion of a uint16_t above 32767 to int16_t is implementation-defined in C; GCC and every compiler you will meet on Arm produce the two’s complement value, and the comment says so. The range check rejects values the sensor cannot produce, which is how you catch a corrupted transfer that still passed the bus protocol.

Lesson 03 replaces main.c with the real application. For now, a deliberately simple main proves every driver.

app/main.c (stage 2)
#include <inttypes.h>
#include <stdio.h>
#include "board.h"
#include "clock.h"
#include "i2c.h"
#include "image.h"
#include "systick.h"
#include "th20.h"
#include "uart.h"
int main(void)
{
err_t clk = clock_init_168mhz();
systick_init(clock_sysclk_hz());
uart_init(clock_apb1_hz(), CONSOLE_BAUD);
char line[96];
snprintf(line, sizeof line, "\r\nsensor-node stage 2, fw %.32s, clock %" PRIu32 " MHz%s\r\n",
app_image_header.version_str, clock_sysclk_hz() / 1000000u,
clk == ERR_OK ? "" : " (HSE failed)");
uart_puts(line);
err_t e = i2c_init(clock_apb1_hz());
th20_t sensor = { .present = false };
if (e == ERR_OK) {
e = th20_init(&sensor, (bus_t){ &i2c_bus_ops, NULL }, SENSOR_I2C_ADDR);
}
snprintf(line, sizeof line, "th20: %s\r\n", err_str(e));
uart_puts(line);
uint32_t next = systick_now_ms();
for (;;) {
while (!systick_expired(next)) { }
next += 1000u;
th20_sample_t s;
e = th20_start(&sensor);
uint32_t ready_at = systick_now_ms() + TH20_CONVERSION_MS + 2u;
while (e == ERR_OK && !systick_expired(ready_at)) { }
if (e == ERR_OK) {
e = th20_read(&sensor, &s);
}
if (e == ERR_OK) {
int32_t t = s.temp_cdeg;
uint32_t a = (uint32_t)(t < 0 ? -t : t);
snprintf(line, sizeof line, "t=%s%" PRIu32 ".%02" PRIu32 " C rh=%" PRIu32
".%02" PRIu32 " %%\r\n", t < 0 ? "-" : "", a / 100u, a % 100u,
(uint32_t)s.hum_cpct / 100u, (uint32_t)s.hum_cpct % 100u);
} else {
snprintf(line, sizeof line, "read failed: %s\r\n", err_str(e));
}
uart_puts(line);
}
}

It busy-waits, which lesson 03 removes, but it exercises the full chain: clock, SysTick, UART, I2C recovery and init, the bus interface, and the driver. Two formatting details recur throughout the project. PRIu32 is used for every uint32_t, because on arm-none-eabi uint32_t is unsigned long while on most hosts it is unsigned int, and a hard-coded %u or %lu is wrong on one of them. And the temperature sign is printed separately: printing t / 100 and t % 100 for -50 (-0.50 °C) would give “0.50” because -50 / 100 is 0.

Build it:

Terminal window
make app
text data bss dec hex filename
6872 112 1608 8592 2190 build/a/app.elf

Your numbers will differ with compiler version; what matters is the order of magnitude. Most of the 6.8 KB is newlib-nano’s snprintf.

Renode models the STM32F407 and its I2C1 peripheral but knows nothing about the TH20, so we give it a model. Renode can compile a C# peripheral when a script includes the source file, and the model needs only the three methods of Renode’s I2C peripheral interface: Write receives the bytes of a write transaction, Read returns the bytes of a read, and FinishTransmission marks the end of a transaction. For memory-mapped devices Renode also accepts peripherals written in Python, which are handy for quick register stubs; an I2C device is attached to a bus controller rather than to the system bus, so the C# route is the dependable one.

renode/th20.cs
using System;
using Antmicro.Renode.Peripherals.I2C;
namespace Antmicro.Renode.Peripherals.Sensors
{
public class TH20 : II2CPeripheral
{
public TH20() { Reset(); }
public decimal Temperature { get; set; } = 21.5m; // degC
public decimal Humidity { get; set; } = 45.0m; // %RH
public void Reset() { pointer = 0; havePointer = false; status = 0; }
public void Write(byte[] data)
{
foreach(var b in data)
{
if(!havePointer) { pointer = b; havePointer = true; continue; }
if(pointer == 0x01 && (b & 0x01) != 0) { Convert(); }
pointer++;
}
}
public byte[] Read(int count = 1)
{
var result = new byte[count];
for(var i = 0; i < count; i++) { result[i] = ReadRegister(pointer++); }
return result;
}
public void FinishTransmission() { havePointer = false; }
private void Convert()
{
var t = (short)Math.Round(Temperature * 100);
var h = (ushort)Math.Round(Humidity * 100);
regs[3] = (byte)t; regs[4] = (byte)(t >> 8);
regs[5] = (byte)h; regs[6] = (byte)(h >> 8);
status = 0x01; // DRDY
}
private byte ReadRegister(byte reg)
{
switch(reg)
{
case 0x00: return 0xA7; // WHO_AM_I
case 0x02: return status;
case 0x06: status = 0; return regs[6]; // reading HUM_H clears DRDY
default: return reg < regs.Length ? regs[reg] : (byte)0;
}
}
private readonly byte[] regs = new byte[8];
private byte pointer;
private bool havePointer;
private byte status;
}
}

The model follows the register map table: the first byte of every write transaction sets the register pointer, a 1 written to bit 0 of CTRL latches the current Temperature and Humidity properties into the result registers and sets DRDY, and reads return successive registers. The conversion completes instantly, which the real part does not do; the firmware’s 22 ms wait still works, it simply never sees ERR_BUSY. Exercise 3 makes the model honest.

renode/stage2.resc
:name: Sensor node, stage 2
:description: Runs build/a/app.elf directly from slot A, no bootloader yet
using sysbus
mach create "sensor-node"
include @renode/th20.cs
machine LoadPlatformDescription @platforms/boards/stm32f4_discovery-kit.repl
machine LoadPlatformDescriptionFromString "th20: Sensors.TH20 @ i2c1 0x40"
showAnalyzer usart2
sysbus LoadELF @build/a/app.elf
cpu VectorTableOffset 0x08008200

The script creates a machine from Renode’s STM32F4 Discovery platform, adds a TH20 at address 0x40 on i2c1, opens a terminal window on USART2, loads the ELF, and tells the simulated core to take its initial stack pointer and reset vector from slot A’s vector table. There is no bootloader yet, so this line stands in for the bootloader’s jump. Start Renode from the project root so the relative paths resolve, then type in the monitor:

Terminal window
renode renode/stage2.resc
(sensor-node) start
(sensor-node) sysbus.i2c1.th20 Temperature -3.75
(sensor-node) sysbus.i2c1.th20 Humidity 88.2

The USART2 analyzer window shows:

sensor-node stage 2, fw 1a2b3c4, clock 168 MHz
th20: ok
t=21.50 C rh=45.00 %
t=21.50 C rh=45.00 %
t=-3.75 C rh=88.20 %

The version string is whatever git describe produced for your tree. If the banner says clock 16 MHz (HSE failed), your Renode version’s clock model did not report HSERDY; the firmware fell back to HSI exactly as designed, and everything else still works. If you see th20: no device the model was not attached: check that include @renode/th20.cs ran without a compile error in the monitor.

Because th20.c depends only on bus.h, it compiles with your host gcc against a fake bus. The fake keeps a 16-byte register file, records the last write, and can be told to fail every transaction.

tests/fake_bus.h
#ifndef FAKE_BUS_H
#define FAKE_BUS_H
#include <stddef.h>
#include <stdint.h>
#include "bus.h"
typedef struct {
uint8_t regs[16];
err_t fail_with; /* if not ERR_OK, every transaction returns it */
uint8_t last_write[4];
size_t last_write_len;
uint32_t transactions;
} fake_bus_t;
extern const bus_ops_t fake_bus_ops;
static inline bus_t fake_bus_make(fake_bus_t *f) { return (bus_t){ &fake_bus_ops, f }; }
#endif /* FAKE_BUS_H */
tests/fake_bus.c
#include <string.h>
#include "fake_bus.h"
static err_t fb_write(void *ctx, uint8_t addr, const uint8_t *data, size_t len)
{
fake_bus_t *f = ctx;
(void)addr;
f->transactions++;
if (f->fail_with != ERR_OK) {
return f->fail_with;
}
if (len == 0u) {
return ERR_PARAM;
}
uint8_t ptr = data[0];
for (size_t i = 1; i < len; i++) {
f->regs[ptr++ & 0x0Fu] = data[i];
}
f->last_write_len = len < sizeof f->last_write ? len : sizeof f->last_write;
memcpy(f->last_write, data, f->last_write_len);
return ERR_OK;
}
static err_t fb_write_read(void *ctx, uint8_t addr, const uint8_t *wr, size_t wlen,
uint8_t *rd, size_t rlen)
{
fake_bus_t *f = ctx;
(void)addr;
f->transactions++;
if (f->fail_with != ERR_OK) {
return f->fail_with;
}
if (wlen != 1u) {
return ERR_PARAM;
}
uint8_t ptr = wr[0];
for (size_t i = 0; i < rlen; i++) {
rd[i] = f->regs[ptr++ & 0x0Fu];
}
return ERR_OK;
}
const bus_ops_t fake_bus_ops = { .write = fb_write, .write_read = fb_write_read };

The test framework is ten lines. Unity, from Testing and debugging C on the host, would work identically; a header with two macros keeps the capstone free of downloads.

tests/test.h
#ifndef TEST_H
#define TEST_H
#include <stdio.h>
static int g_checks, g_failures;
#define CHECK(cond) do { g_checks++; if (!(cond)) { g_failures++; \
printf("%s:%d: CHECK failed: %s\n", __FILE__, __LINE__, #cond); } } while (0)
#define RUN(test) do { printf(" %s\n", #test); test(); } while (0)
#define TEST_EXIT() (printf("%d checks, %d failures\n", g_checks, g_failures), \
g_failures != 0)
#endif /* TEST_H */
tests/test_th20.c
#include <string.h>
#include "fake_bus.h"
#include "test.h"
#include "th20.h"
static fake_bus_t fb;
static th20_t dev;
static void setup(void)
{
memset(&fb, 0, sizeof fb);
fb.regs[TH20_REG_WHO_AM_I] = TH20_WHO_AM_I_VALUE;
memset(&dev, 0, sizeof dev);
}
static void test_init_ok(void)
{
setup();
CHECK(th20_init(&dev, fake_bus_make(&fb), 0x40) == ERR_OK);
CHECK(dev.present);
}
static void test_init_wrong_id(void)
{
setup();
fb.regs[TH20_REG_WHO_AM_I] = 0x00;
CHECK(th20_init(&dev, fake_bus_make(&fb), 0x40) == ERR_NODEV);
CHECK(!dev.present);
CHECK(th20_start(&dev) == ERR_STATE); /* refuses to use a missing device */
}
static void test_init_propagates_bus_error(void)
{
setup();
fb.fail_with = ERR_NACK;
CHECK(th20_init(&dev, fake_bus_make(&fb), 0x40) == ERR_NACK);
}
static void test_start_writes_oneshot(void)
{
setup();
th20_init(&dev, fake_bus_make(&fb), 0x40);
CHECK(th20_start(&dev) == ERR_OK);
CHECK(fb.last_write_len == 2u);
CHECK(fb.last_write[0] == TH20_REG_CTRL);
CHECK(fb.last_write[1] == TH20_CTRL_ONESHOT);
}
static void test_read_not_ready(void)
{
setup();
th20_init(&dev, fake_bus_make(&fb), 0x40);
th20_sample_t s;
CHECK(th20_read(&dev, &s) == ERR_BUSY);
}
static void test_read_negative_temperature(void)
{
setup();
th20_init(&dev, fake_bus_make(&fb), 0x40);
fb.regs[2] = TH20_STATUS_DRDY;
fb.regs[3] = 0x2E; fb.regs[4] = 0xFB; /* -1234 = 0xFB2E: -12.34 C */
fb.regs[5] = 0xD7; fb.regs[6] = 0x11; /* 4567 = 0x11D7: 45.67 % */
th20_sample_t s;
CHECK(th20_read(&dev, &s) == ERR_OK);
CHECK(s.temp_cdeg == -1234);
CHECK(s.hum_cpct == 4567);
}
static void test_read_rejects_impossible_humidity(void)
{
setup();
th20_init(&dev, fake_bus_make(&fb), 0x40);
fb.regs[2] = TH20_STATUS_DRDY;
fb.regs[5] = 0x11; fb.regs[6] = 0x27; /* 10001 = 100.01 %RH */
th20_sample_t s;
CHECK(th20_read(&dev, &s) == ERR_RANGE);
}
int main(void)
{
RUN(test_init_ok);
RUN(test_init_wrong_id);
RUN(test_init_propagates_bus_error);
RUN(test_start_writes_oneshot);
RUN(test_read_not_ready);
RUN(test_read_negative_temperature);
RUN(test_read_rejects_impossible_humidity);
return TEST_EXIT();
}
tests/Makefile (stage 2)
HOSTCC ?= cc
CFLAGS := -std=c11 -Wall -Wextra -Wpedantic -Wshadow -g -O1 \
-fsanitize=address,undefined -I. -I../common -I../app/drivers
test_th20: test_th20.c fake_bus.c ../app/drivers/th20.c
$(HOSTCC) $(CFLAGS) $^ -o $@
./$@

Each test sets up a known register file, calls one driver function, and checks one behavior: identity check, error propagation, the exact bytes of the start command, the not-ready path, sign handling across the 16-bit boundary, and range rejection. -fsanitize=address,undefined turns an out-of-bounds index or a signed overflow into a crash with a line number. Run it with make test from the project root:

test_init_ok
test_init_wrong_id
test_init_propagates_bus_error
test_start_writes_oneshot
test_read_not_ready
test_read_negative_temperature
test_read_rejects_impossible_humidity
15 checks, 0 failures
  • Miscounting reserved words in a register struct. Every register after the gap is wrong and the symptom (a clock that never starts) points nowhere near the cause. Add _Static_assert(offsetof(...)) for the registers after each gap.
  • Computing a baud rate or I2C timing from an assumed clock. If the PLL fails, every peripheral is 2.6 times slower than the constant says. Compute from clock_apb1_hz().
  • Raising the clock before raising flash wait states. The CPU fetches instructions faster than flash can deliver them and executes garbage. Set FLASH->ACR latency first when speeding up.
  • Unbounded waits on I2C flags. A missing pull-up or a stuck slave turns while (!(I2C1->SR1 & I2C_SR1_SB)); into a hang that only the watchdog ends. Bound every wait and return an error.
  • Clearing ACK too late on the second-to-last byte. An interrupt in that window makes the master ACK the final byte, the slave keeps driving SDA, and the next transaction finds the bus busy. Mask interrupts for that window, or use the BTF method.
  • Reading multi-byte sensor results in separate transactions. Temperature from one conversion and humidity from the next. Read all result registers in one auto-increment transaction.
  • Casting a byte buffer to int16_t *. Endianness and alignment become your problem. Assemble with shifts.
  1. Run stage 2 in Renode and set Humidity to 100.5 from the monitor. What does the firmware print, and which line of th20.c produced that behavior? Correct result: read failed: out of range, from the h > 10000u check.
  2. Open build/a/app.map and find g_vectors, app_image_header, _sidata, and g_handoff (the last appears in lesson 03). Confirm the vector table is at 0x08008200, then run arm-none-eabi-objdump -s -j .isr_vector build/a/app.elf | head and check that the second word, the reset vector, is an odd address inside slot A. Why must it be odd?
  3. Make the TH20 model honest: set BUSY and clear DRDY when a conversion starts, and complete it only when Read is called at least 20 ms of virtual time later (Renode peripherals can obtain the machine’s virtual time through the machine object). Then confirm the firmware still works and, by temporarily shortening its wait to 5 ms, that th20_read returns ERR_BUSY.
  4. Add a host test that checks th20_read reads all five bytes in a single transaction: extend fake_bus_t to count transactions per call and assert that one call to th20_read costs exactly one.
  5. Disassemble read_bytes (arm-none-eabi-objdump -d build/a/app.elf, search for read_bytes or the inlined code in i2c_write_read). Find the cpsid i and the msr primask that bracket the second-to-last byte. Count the instructions between them, and estimate the worst-case time with interrupts masked at 168 MHz if wait_sr1 exits on its first iteration.

Question 1:The HSE crystal fails to start and clock_init_168mhz returns ERR_TIMEOUT. Why does the console still print readable text?

Question 2:In the vector table, why are unused IRQ slots filled with Default_Handler instead of 0?

Question 3:th20_read fetches STATUS, TEMP and HUM in one 5-byte transaction. What bug does this prevent?

Question 4:During an I2C read of 5 bytes, the code masks interrupts while handling byte 3 (index len-2). What would happen without the mask if a long ISR ran at that moment?

Question 5:Why does app.ld declare the slot length as SLOT_SIZE - 16?

  • A small register header with _Static_assert on offsets is auditable against the reference manual, and the asserts catch miscounted reserved words at compile time.
  • Reset_Handler should set VTOR to its own table so the same startup works from reset, from a bootloader jump, and in a simulator.
  • Clock code must survive a dead crystal, and every driver must compute timing from the clock it actually has.
  • Every hardware wait is bounded, every error path releases the bus, and I2C init recovers a stuck slave before enabling the peripheral.
  • A sensor driver that depends only on a two-function bus interface can be unit tested on the host with a fake bus and simulated in Renode with a small device model.
  • Read related result registers in one transaction and assemble multi-byte values with shifts.

Next: Capstone III — Application Core, where the event queue, the cooperative scheduler, the state machine, the shell, the binary protocol, logging, and watchdog supervision turn these drivers into a product.