Part 2 · C Meets the Metal30 min read
Memory-Mapped Registers in C
On a microcontroller there is no driver API between you and the hardware. A GPIO pin, a UART, a timer: each is controlled by a handful of 32-bit registers that sit at fixed addresses in the same address space as your RAM. Writing a value to address 0x40020C18 turns on an LED. Reading 0x40004404 takes a byte out of a UART. This lesson shows how to express those accesses in C so that the compiler emits exactly the loads and stores the hardware needs, how the different kinds of registers behave when you touch them, and how professional code organizes all of this. At the end you will write stm32f407_regs.h, the register header used by every project for the rest of the course.
The Cortex-M address map
Section titled “The Cortex-M address map”Every Cortex-M core sees a flat 4 GB address space, and Arm fixes the broad layout so that tools and code can rely on it. The vendor (ST, in our case) decides what actually lives inside each region.
0xFFFFFFFF +------------------------------+ | Vendor specific |0xE0100000 +------------------------------+ | Private Peripheral Bus (PPB) | NVIC, SCB, SysTick, MPU, DWT, ITM0xE0000000 +------------------------------+ | External device (1 GB) | FSMC control registers on the F4070xA0000000 +------------------------------+ | External RAM (1 GB) | FSMC memory banks on the F4070x60000000 +------------------------------+ | Peripheral (512 MB) | APB1, APB2, AHB1, AHB2 peripherals0x40000000 +------------------------------+ | SRAM (512 MB) | SRAM1 + SRAM20x20000000 +------------------------------+ | Code (512 MB) | Flash, CCM RAM, system memory0x00000000 +------------------------------+ (flash is aliased here at boot)On the STM32F407 the interesting addresses are:
| What | Base address | Size / contents |
|---|---|---|
| Flash | 0x08000000 | 1 MB, aliased at 0x00000000 when booting from flash |
| CCM RAM | 0x10000000 | 64 KB, core-coupled, data only, not reachable by DMA |
| System memory | 0x1FFF0000 | ST’s ROM bootloader |
| SRAM1 + SRAM2 | 0x20000000 | 112 KB + 16 KB, contiguous, 128 KB total |
| APB1 peripherals | 0x40000000 | TIM2-TIM7, USART2 (0x40004400), I2C, SPI2/3, PWR, DAC |
| APB2 peripherals | 0x40010000 | TIM1/TIM8, USART1/6, ADC, SPI1, SYSCFG, EXTI |
| AHB1 peripherals | 0x40020000 | GPIOA-GPIOI, CRC, RCC (0x40023800), flash interface, DMA |
| AHB2 peripherals | 0x50000000 | USB OTG FS, camera interface, RNG |
| Core peripherals | 0xE000E000 | SysTick (0xE000E010), NVIC (0xE000E100), SCB (0xE000ED00) |
The Private Peripheral Bus is part of the Arm core itself, so its registers are at the same addresses on every Cortex-M4 from every vendor. The vendor peripherals at 0x40000000 and above are different on every chip family. The register addresses, offsets, and bit positions in this course come from ST’s reference manual RM0090 and Arm’s Cortex-M4 Devices Generic User Guide; those two documents are the ground truth whenever you add a register.
Not every address in these ranges has something behind it. Reading or writing an unimplemented address in the peripheral region causes a BusFault, which escalates to a HardFault if you have not enabled the BusFault handler. A typo in a base address usually announces itself this way.
A register is an address
Section titled “A register is an address”The GPIO output data register of port D, ODR, is at offset 0x14 from the port’s base address 0x40020C00. Its bit 12 drives pin PD12, the green LED. In C:
#include <stdint.h>
#define GPIOD_ODR (*(volatile uint32_t *)0x40020C14u)
void led_green_on(void){ GPIOD_ODR |= (1u << 12);}Read the macro from the inside out:
0x40020C14uis just an integer.(volatile uint32_t *)converts it to a pointer to a 32-bit,volatileobject. C requires the explicit cast; an integer is not a pointer.- The leading
*dereferences that pointer, soGPIOD_ODRis an lvalue you can read and assign like a variable.
The pointed-to type sets the width of the bus access. uint32_t produces ldr/str, which are 32-bit transactions. If you used uint8_t, you would get ldrb/strb, single-byte transactions. Some peripherals accept byte and halfword accesses, some do not; RM0090 states the allowed widths at the top of each peripheral’s register section. The safe default is to always access registers with their full 32-bit width.
Why volatile is mandatory
Section titled “Why volatile is mandatory”The compiler assumes that memory changes only when your code writes it. For RAM that is true. For a register, it is false in both directions: hardware changes register contents behind your back (a status flag becomes set), and reads and writes have effects beyond storing a value (a write toggles a pin). volatile tells the compiler that every access to the object is observable behavior that it must perform exactly as written, in program order.
Here is what goes wrong without it. A transmit routine waits for the UART’s “transmit data register empty” flag, TXE, bit 7 of USART2_SR at 0x40004400:
#include <stdint.h>
#define USART2_SR_PLAIN (*(uint32_t *)0x40004400u)#define USART2_SR_VOLATILE (*(volatile uint32_t *)0x40004400u)
void wait_txe_plain(void){ while ((USART2_SR_PLAIN & (1u << 7)) == 0u) { }}
void wait_txe_volatile(void){ while ((USART2_SR_VOLATILE & (1u << 7)) == 0u) { }}wait_txe_plain: ldr r3, .L6 ldr r3, [r3] @ read SR once tst r3, #128 beq .L3 bx lr.L3: b .L3 @ flag was clear: spin forever.L6: .word 1073759232
wait_txe_volatile: ldr r2, .L10.L8: ldr r3, [r2] @ read SR on every iteration lsls r3, r3, #24 @ move bit 7 into the N flag bpl .L8 @ loop while bit 7 is clear bx lr.L10: .word 1073759232Without volatile, the compiler reasons that nothing in the loop writes the location, so the value cannot change, so it reads it once. If the flag was clear at that instant, the loop becomes b .L3: a branch to itself. Your firmware hangs and the debugger shows the PC parked on a single instruction. With volatile, the load is inside the loop. (The lsls ... #24 / bpl pair is a common GCC idiom: shifting left by 24 puts bit 7 into bit 31, the sign bit, and bpl branches while it is clear.) The precise rules for volatile, including what it does not guarantee, are in Undefined behavior, volatile, and what the optimizer assumes.
What registers do when you touch them
Section titled “What registers do when you touch them”A register is not a memory cell. The reference manual tags each bit field with an access type, and those types decide how you must write your code.
| RM0090 tag | Meaning | Example |
|---|---|---|
rw | Read and write, reads return what you wrote | GPIOx_MODER, RCC_AHB1ENR |
r | Read-only, writes ignored | GPIOx_IDR (pin input levels) |
w | Write-only, reads return 0 | GPIOx_BSRR |
rc_w1 | Read, clear by writing 1, writing 0 has no effect | EXTI_PR pending bits |
rc_w0 | Read, clear by writing 0, writing 1 has no effect | TIMx_SR flags, USART_SR RXNE and TC |
| Cleared by a read sequence | Reading registers in a specific order clears the flag | USART_SR ORE: read SR, then read DR |
| Reserved | Must be kept at reset value | Gaps between fields |
Each type has a characteristic bug.
Write-1-to-clear and |=. To clear EXTI line 0’s pending bit you write a 1 to bit 0 of EXTI_PR. The tempting EXTI_PR |= (1u << 0); reads the register, which returns 1 for every line that is pending, ORs in bit 0, and writes it all back. Every pending line gets cleared, and the interrupts for lines 1 to 22 are silently lost. The correct code is a plain assignment: EXTI_PR = (1u << 0);. Zeros are harmless in a rc_w1 register, so you write exactly the bits you mean to clear.
Write-0-to-clear and &=. Timer status flags are rc_w0. TIM2_SR &= ~(1u << 0); reads SR, clears bit 0, writes it back. If the hardware sets another flag (say, a capture flag) between your read and your write, your write puts a 0 into that bit and clears it. The safe form is TIM2_SR = ~(1u << 0);: a 0 in bit 0 clears it, and 1s everywhere else have no effect.
Side effects on read. Reading USART2_DR does not just return the received byte; it removes it from the receiver and clears RXNE. This includes reads you did not intend. A debugger watch expression or memory view that displays USART2_DR performs real bus reads, and each one can swallow a received byte. When “received data goes missing only while debugging”, look for a register view open on a data register.
Reserved bits. RM0090 says reserved bits “must be kept at reset value”. Writing a full constant such as RCC_CFGR = 0x00000002u; stomps on every field in the register, including ones a previous piece of code configured and reserved bits whose meaning may change in a later silicon revision. Change only the fields you own, using the read-modify-write pattern below.
The struct overlay
Section titled “The struct overlay”Writing one macro per register does not scale: a GPIO port has ten registers and the STM32F407 has nine ports. The standard solution, used by CMSIS and every vendor header, is to describe a peripheral’s register block as a C struct and point a struct pointer at the base address.
#include <stdint.h>
typedef struct { volatile uint32_t MODER; /* 0x00 */ volatile uint32_t OTYPER; /* 0x04 */ volatile uint32_t OSPEEDR; /* 0x08 */ volatile uint32_t PUPDR; /* 0x0C */ volatile uint32_t IDR; /* 0x10 */ volatile uint32_t ODR; /* 0x14 */ volatile uint32_t BSRR; /* 0x18 */ volatile uint32_t LCKR; /* 0x1C */ volatile uint32_t AFR[2]; /* 0x20 AFRL, 0x24 AFRH */} GPIO_TypeDef;
#define GPIOA ((GPIO_TypeDef *)0x40020000u)#define GPIOD ((GPIO_TypeDef *)0x40020C00u)Because every member is a uint32_t, which has 4-byte alignment and size, the compiler inserts no padding: member n sits at offset 4n, matching the hardware layout. GPIOD->ODR means “the 32-bit volatile object at 0x40020C00 + 0x14”. The two alternate function registers AFRL and AFRH are written as an array, the CMSIS convention: AFR[0] is AFRL (pins 0 to 7) and AFR[1] is AFRH (pins 8 to 15), so pin p uses AFR[p >> 3].
The overlay produces excellent code, because the base address is loaded once and every register is a fixed offset from it. This is the struct access pattern from Reading what the compiler wrote:
void led_init(void){ GPIOD->MODER = (GPIOD->MODER & ~(3u << 24)) | (1u << 24); GPIOD->BSRR = 1u << 12;}led_init: ldr r3, .L3 @ r3 = 0x40020C00, loaded once ldr r2, [r3] @ read MODER (offset 0) bic r2, r2, #50331648 @ clear bits 25:24 (0x03000000) orr r2, r2, #16777216 @ set bit 24 (0x01000000) str r2, [r3] @ write MODER mov r2, #4096 str r2, [r3, #24] @ BSRR at offset 0x18 bx lr.L3: .word 1073875968 @ 0x40020C00Gaps and reserved space
Section titled “Gaps and reserved space”Real register blocks have holes. In the RCC block, AHB3RSTR is at 0x18, then nothing at 0x1C, then APB1RSTR at 0x20. The struct must reproduce the hole, or every member after it lands at the wrong address:
typedef struct { volatile uint32_t AHB3RSTR; /* 0x18 */ uint32_t RESERVED0; /* 0x1C: hole in the register map */ volatile uint32_t APB1RSTR; /* 0x20 */} RCC_fragment;Forget one RESERVED word and nothing complains at compile time. Your writes to AHB1ENR land one word too low, on a reserved address, and the clock never turns on. The defence is to have the compiler check the layout against the manual:
#include <stddef.h>
_Static_assert(offsetof(GPIO_TypeDef, BSRR) == 0x18u, "GPIO BSRR offset");_Static_assert(offsetof(GPIO_TypeDef, AFR[1]) == 0x24u, "GPIO AFRH offset");offsetof (from <stddef.h>) gives a member’s byte offset as a compile-time constant, and _Static_assert turns a wrong offset into a build error with your message. It costs nothing at run time. Put one on the last member of every struct, and on any member after a reserved gap.
Bit fields: _Pos, _Msk, and multi-bit writes
Section titled “Bit fields: _Pos, _Msk, and multi-bit writes”Registers pack several fields into one word. The convention in CMSIS-style headers is a pair of macros per field: _Pos is the bit position of the field’s least significant bit, _Msk is the field’s mask in place.
#define SCB_AIRCR_PRIGROUP_Pos 8u#define SCB_AIRCR_PRIGROUP_Msk (7u << 8) /* bits 10:8 */#define RCC_AHB1ENR_GPIODEN (1u << 3) /* single bit: mask only */Single-bit fields are used directly: RCC->AHB1ENR |= RCC_AHB1ENR_GPIODEN;. Multi-bit fields need the field-insert pattern from Bit manipulation: clear the field, then OR in the new value shifted into place.
reg = (reg & ~FIELD_Msk) | ((value << FIELD_Pos) & FIELD_Msk);GPIO mode is the field you will set most often. MODER has two bits per pin: pin p uses bits 2p+1:2p, with 00 input, 01 output, 10 alternate function, 11 analog. To make PA5 an alternate function pin:
GPIOA->MODER = (GPIOA->MODER & ~(3u << 10)) | (2u << 10); /* bits 11:10 = 10 */The classic bug is skipping the clear: GPIOA->MODER |= (2u << 10);. If the field already held 01 (output), OR-ing in 10 gives 11, analog mode, and the pin goes dead. On the STM32F407 this is not hypothetical, because not all ports reset to zero. GPIOA_MODER resets to 0xA8000000: pins PA13, PA14, and PA15 come up in alternate function mode because they are the debug port (SWDIO, SWCLK) and JTDI.
The read-modify-write hazard
Section titled “The read-modify-write hazard”The field-insert pattern is a read-modify-write (RMW): a load, some arithmetic, a store. That sequence is not atomic. An interrupt can arrive between any two instructions.
Suppose main turns on the green LED with GPIOD->ODR |= (1u << 12); while a timer interrupt toggles the blue LED with GPIOD->ODR ^= (1u << 15);.
main ISR ODRldr r3, [r2, #20] (reads 0x0000) 0x0000 ldr (reads 0x0000) eor -> 0x8000 str 0x8000 0x8000 blue onorr r3, r3, #0x1000 -> 0x1000str r3, [r2, #20] 0x1000 blue off!main read the old value before the ISR ran, then wrote back its stale copy with the green bit added. The ISR’s change is lost. This bug is intermittent, rare, and depends on timing, which makes it miserable to find. Disabling interrupts around the RMW fixes it (Interrupts done right covers critical sections), but GPIO has a hardware solution.
GPIOx_BSRR is a write-only bit set/reset register. Writing a 1 to bit n (0 to 15) sets output n; writing a 1 to bit n+16 clears output n; writing 0 does nothing. If both the set and the reset bit for a pin are written as 1, set wins.
GPIOD->BSRR = 1u << 12; /* PD12 high, other pins untouched */GPIOD->BSRR = 1u << (12u + 16u); /* PD12 low */That is one str instruction. There is no read, so there is nothing stale to write back, and the hardware applies the change to only the named pins. It is atomic with respect to interrupts and to DMA. Use BSRR for every GPIO output change in code that has interrupts. Other peripherals have similar set/clear registers (the NVIC’s ISER and ICER, for example), and recognising them saves you critical sections.
__I, __O, and __IO
Section titled “__I, __O, and __IO”CMSIS headers write member qualifiers with three macros:
#define __I volatile const /* read-only */#define __O volatile /* write-only */#define __IO volatile /* read/write */__I adds const, so GPIOD->IDR = 5u; becomes a compile error, which is exactly right for a read-only register. __O and __IO are both plain volatile; C has no way to forbid reads, so __O is documentation. They are worth using because they put the access type from the manual into the type system and into code review. (Identifiers starting with two underscores are reserved for the implementation in ISO C. CMSIS uses them anyway and so does every Cortex-M codebase; this course follows CMSIS so the names transfer directly.)
Vendor headers, CMSIS, SVD, and the HAL
Section titled “Vendor headers, CMSIS, SVD, and the HAL”In production you rarely write register definitions by hand. Three layers exist:
- CMSIS-Core (from Arm):
core_cm4.hdefines the core peripherals (NVIC,SCB,SysTick) and intrinsic functions such as__disable_irq(). It is identical across vendors. - The device header (from ST):
stm32f407xx.hdefines every peripheral struct, base address, and bit macro for the chip, in the same style as this lesson. ST generates it from an SVD file, a machine-readable XML description of every register and field. Debuggers load the same SVD file to show named peripheral registers, and tools likesvd2rustgenerate drivers from it. - The HAL (from ST):
HAL_GPIO_Init(),HAL_UART_Transmit(), and friends, plus the thinner LL (low-layer) drivers. They are configuration structs and functions built on top of the device header.
This course writes registers directly first, for three reasons. You cannot debug a HAL call that fails unless you know what registers it was supposed to write. Register-level code for simple peripherals is often shorter and faster than the HAL equivalent. And the skill transfers: every vendor’s chip has a reference manual with register tables, while every vendor’s HAL is different. Driver design and hardware abstraction returns to when and how to use vendor HALs.
The course header: stm32f407_regs.h
Section titled “The course header: stm32f407_regs.h”Here is the register header every project in this course uses. It covers RCC, GPIO, USART2, SysTick, NVIC, and SCB: enough for blinky, UART output, interrupts, and fault handling. Later lessons append registers for other peripherals as they need them.
/* stm32f407_regs.h: hand-written register definitions for the STM32F407. * Sources: RM0090 (ST) and the Cortex-M4 Devices Generic User Guide (Arm). * Only the subset used in this course; add registers as you need them. */#ifndef STM32F407_REGS_H#define STM32F407_REGS_H
#include <stddef.h>#include <stdint.h>
/* Access qualifiers, same spelling and meaning as CMSIS */#define __I volatile const /* read-only */#define __O volatile /* write-only */#define __IO volatile /* read/write */
/* ---- RCC: reset and clock control ---- */typedef struct { __IO uint32_t CR; /* 0x00 */ __IO uint32_t PLLCFGR; /* 0x04 */ __IO uint32_t CFGR; /* 0x08 */ __IO uint32_t CIR; /* 0x0C */ __IO uint32_t AHB1RSTR; /* 0x10 */ __IO uint32_t AHB2RSTR; /* 0x14 */ __IO uint32_t AHB3RSTR; /* 0x18 */ uint32_t RESERVED0; /* 0x1C */ __IO uint32_t APB1RSTR; /* 0x20 */ __IO uint32_t APB2RSTR; /* 0x24 */ uint32_t RESERVED1[2]; /* 0x28-0x2C */ __IO uint32_t AHB1ENR; /* 0x30 */ __IO uint32_t AHB2ENR; /* 0x34 */ __IO uint32_t AHB3ENR; /* 0x38 */ uint32_t RESERVED2; /* 0x3C */ __IO uint32_t APB1ENR; /* 0x40 */ __IO uint32_t APB2ENR; /* 0x44 */ uint32_t RESERVED3[10]; /* 0x48-0x6C: low-power enables, unused */ __IO uint32_t BDCR; /* 0x70 */ __IO uint32_t CSR; /* 0x74 */} RCC_TypeDef;
/* ---- GPIO ---- */typedef struct { __IO uint32_t MODER; /* 0x00 mode, 2 bits per pin */ __IO uint32_t OTYPER; /* 0x04 output type, 1 bit per pin */ __IO uint32_t OSPEEDR; /* 0x08 output speed, 2 bits per pin */ __IO uint32_t PUPDR; /* 0x0C pull-up/pull-down, 2 bits per pin */ __I uint32_t IDR; /* 0x10 input data */ __IO uint32_t ODR; /* 0x14 output data */ __O uint32_t BSRR; /* 0x18 bit set (0-15) / reset (16-31) */ __IO uint32_t LCKR; /* 0x1C configuration lock */ __IO uint32_t AFR[2]; /* 0x20 AFRL (pins 0-7), 0x24 AFRH (8-15) */} GPIO_TypeDef;
/* ---- USART ---- */typedef struct { __IO uint32_t SR; /* 0x00 status */ __IO uint32_t DR; /* 0x04 data (read has side effects) */ __IO uint32_t BRR; /* 0x08 baud rate */ __IO uint32_t CR1; /* 0x0C */ __IO uint32_t CR2; /* 0x10 */ __IO uint32_t CR3; /* 0x14 */ __IO uint32_t GTPR; /* 0x18 guard time and prescaler */} USART_TypeDef;
/* ---- Core peripherals (same on every Cortex-M4) ---- */typedef struct { __IO uint32_t CTRL; /* 0x00 */ __IO uint32_t LOAD; /* 0x04 24-bit reload value */ __IO uint32_t VAL; /* 0x08 current value, any write clears */ __I uint32_t CALIB; /* 0x0C */} SysTick_Type;
typedef struct { __IO uint32_t ISER[8]; /* 0x000 set-enable, write 1 to enable */ uint32_t RESERVED0[24]; __IO uint32_t ICER[8]; /* 0x080 clear-enable, write 1 to disable */ uint32_t RESERVED1[24]; __IO uint32_t ISPR[8]; /* 0x100 set-pending */ uint32_t RESERVED2[24]; __IO uint32_t ICPR[8]; /* 0x180 clear-pending */ uint32_t RESERVED3[24]; __I uint32_t IABR[8]; /* 0x200 active */ uint32_t RESERVED4[56]; __IO uint8_t IPR[240]; /* 0x300 priority, one byte per IRQ */ uint32_t RESERVED5[644]; __O uint32_t STIR; /* 0xE00 software trigger */} NVIC_Type;
typedef struct { __I uint32_t CPUID; /* 0x00 */ __IO uint32_t ICSR; /* 0x04 interrupt control and state */ __IO uint32_t VTOR; /* 0x08 vector table offset */ __IO uint32_t AIRCR; /* 0x0C needs VECTKEY on every write */ __IO uint32_t SCR; /* 0x10 system control (sleep) */ __IO uint32_t CCR; /* 0x14 configuration and control */ __IO uint32_t SHPR1; /* 0x18 MemManage, BusFault, UsageFault */ __IO uint32_t SHPR2; /* 0x1C SVCall */ __IO uint32_t SHPR3; /* 0x20 PendSV, SysTick */ __IO uint32_t SHCSR; /* 0x24 system handler control and state */ __IO uint32_t CFSR; /* 0x28 configurable fault status */ __IO uint32_t HFSR; /* 0x2C HardFault status */ __IO uint32_t DFSR; /* 0x30 debug fault status */ __IO uint32_t MMFAR; /* 0x34 MemManage fault address */ __IO uint32_t BFAR; /* 0x38 BusFault address */ __IO uint32_t AFSR; /* 0x3C auxiliary fault status */ uint32_t RESERVED0[18]; /* 0x40-0x84 feature ID registers */ __IO uint32_t CPACR; /* 0x88 coprocessor (FPU) access */} SCB_Type;
/* ---- Instances: GPIOx = 0x40020000 + 0x400 * port index (AHB1) ---- */#define GPIOA ((GPIO_TypeDef *)0x40020000u)#define GPIOB ((GPIO_TypeDef *)0x40020400u)#define GPIOC ((GPIO_TypeDef *)0x40020800u)#define GPIOD ((GPIO_TypeDef *)0x40020C00u)#define GPIOE ((GPIO_TypeDef *)0x40021000u)#define RCC ((RCC_TypeDef *)0x40023800u) /* AHB1 */#define USART2 ((USART_TypeDef *)0x40004400u) /* APB1 */#define SysTick ((SysTick_Type *)0xE000E010u) /* core peripherals */#define NVIC ((NVIC_Type *)0xE000E100u)#define SCB ((SCB_Type *)0xE000ED00u)
/* ---- Bit definitions ---- */#define RCC_AHB1ENR_GPIOAEN (1u << 0)#define RCC_AHB1ENR_GPIOBEN (1u << 1)#define RCC_AHB1ENR_GPIOCEN (1u << 2)#define RCC_AHB1ENR_GPIODEN (1u << 3)#define RCC_AHB1ENR_GPIOEEN (1u << 4)#define RCC_APB1ENR_USART2EN (1u << 17)
#define GPIO_MODER_Pos(pin) ((pin) * 2u)#define GPIO_MODER_Msk(pin) (3u << GPIO_MODER_Pos(pin))#define GPIO_MODE_INPUT 0u#define GPIO_MODE_OUTPUT 1u#define GPIO_MODE_AF 2u#define GPIO_MODE_ANALOG 3u
#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 SYSTICK_CTRL_ENABLE (1u << 0)#define SYSTICK_CTRL_TICKINT (1u << 1)#define SYSTICK_CTRL_CLKSOURCE (1u << 2)#define SYSTICK_CTRL_COUNTFLAG (1u << 16)
#define SCB_ICSR_PENDSVSET (1u << 28)#define SCB_AIRCR_VECTKEY (0x5FAu << 16)#define SCB_AIRCR_PRIGROUP_Pos 8u#define SCB_AIRCR_PRIGROUP_Msk (7u << 8)#define SCB_AIRCR_SYSRESETREQ (1u << 2)#define SCB_SCR_SLEEPDEEP (1u << 2)#define SCB_SHCSR_MEMFAULTENA (1u << 16)#define SCB_SHCSR_BUSFAULTENA (1u << 17)#define SCB_SHCSR_USGFAULTENA (1u << 18)#define SCB_CPACR_CP10_CP11_Msk (0xFu << 20) /* full access to the FPU */
/* ---- Interrupts ---- */#define EXTI0_IRQn 6u#define TIM2_IRQn 28u#define USART2_IRQn 38u#define NVIC_PRIO_BITS 4u /* STM32F4 implements the top 4 bits */
static inline void nvic_enable_irq(uint32_t irqn){ NVIC->ISER[irqn >> 5] = 1u << (irqn & 31u); /* write-1-to-set: no RMW */}
static inline void nvic_set_priority(uint32_t irqn, uint32_t prio){ NVIC->IPR[irqn] = (uint8_t)(prio << (8u - NVIC_PRIO_BITS));}
/* ---- Layout checks: a wrong offset fails the build, not the board ---- */_Static_assert(offsetof(RCC_TypeDef, AHB1ENR) == 0x30u, "RCC AHB1ENR");_Static_assert(offsetof(RCC_TypeDef, APB1ENR) == 0x40u, "RCC APB1ENR");_Static_assert(offsetof(RCC_TypeDef, CSR) == 0x74u, "RCC CSR");_Static_assert(offsetof(GPIO_TypeDef, AFR[1]) == 0x24u, "GPIO AFRH");_Static_assert(offsetof(USART_TypeDef, GTPR) == 0x18u, "USART GTPR");_Static_assert(offsetof(NVIC_Type, IPR) == 0x300u, "NVIC IPR");_Static_assert(offsetof(NVIC_Type, STIR) == 0xE00u, "NVIC STIR");_Static_assert(offsetof(SCB_Type, CFSR) == 0x28u, "SCB CFSR");_Static_assert(offsetof(SCB_Type, CPACR) == 0x88u, "SCB CPACR");
#endif /* STM32F407_REGS_H */The important decisions in this file:
- Naming follows CMSIS. Device peripherals use ST’s
_TypeDefnames (GPIO_TypeDef,RCC_TypeDef); core peripherals use Arm’s_Typenames and instance names (SysTick,NVIC,SCB). Code written against this header reads like code written against the vendor headers, so moving to them later is mostly a change of#include. - Every gap is explicit.
RESERVEDmembers reproduce holes in the register map, and the_Static_assertlines at the end prove the critical offsets. The RCC struct stops atCSR(0x74, soRCC->CSRis at0x40023874), which is as far as the course needs. - NVIC priority registers are bytes. The hardware allows byte access to the interrupt priority registers, so
IPR[n]is the priority of IRQ n. The manual names the 32-bit wordsIPR0toIPR59, each holding four IRQs;IPR[0]toIPR[3]are the four bytes of the register at0xE000E400. Only the top 4 bits of each byte are implemented on the STM32F4, which is whynvic_set_priorityshifts the priority (0 to 15) left by 4. nvic_enable_irquses a plain assignment.ISERis write-1-to-set: writing 0 bits does nothing, so no read is needed and the operation is atomic. The Cortex-M architecture covers the NVIC in full.SCB_AIRCR_VECTKEY.AIRCRignores any write whose top halfword is not0x05FA. Every write must include the key.
Using the header looks like this:
#include "stm32f407_regs.h"
void green_led_init(void){ RCC->AHB1ENR |= RCC_AHB1ENR_GPIODEN; /* clock the GPIOD block */ (void)RCC->AHB1ENR; /* read back: wait for it */
GPIOD->MODER = (GPIOD->MODER & ~GPIO_MODER_Msk(12u)) | (GPIO_MODE_OUTPUT << GPIO_MODER_Pos(12u)); GPIOD->BSRR = 1u << 12; /* LED on */}The first line matters: a peripheral whose clock is disabled ignores writes, and the GPIO ports are all disabled after reset. The read-back on the second line is explained in Clocks, reset, and timing. The next lesson turns this function into a complete, flashable project.
Common mistakes
Section titled “Common mistakes”- Forgetting
volatileon a register pointer. Polling loops become infinite loops and repeated writes collapse into one. Fix: putvolatilein the struct members or the macro, never rely on remembering it at each use. - Using
|=on a write-1-to-clear register such asEXTI_PR. It clears every pending bit. Fix: assign only the bit you want to clear. - Using
&= ~biton a write-0-to-clear status register. It can clear flags that were set during the RMW. Fix: write~bit. - Setting a multi-bit field with
|=and no clear. Leftover bits produce a different mode. Fix: always clear the field mask first. - Missing a reserved word in a register struct, shifting every later member. Fix:
_Static_asserton offsets after every gap. - Leaving a debugger view open on a data register with read side effects. Fix: close peripheral views on
DRregisters when debugging communication. - Writing a whole-register constant to
GPIOA->MODERand losing the debug connection. Fix: masked read-modify-write, and never touch PA13/PA14 configuration unless you mean to.
Exercises
Section titled “Exercises”- Using only the reference table and the struct definitions, compute the absolute addresses of
GPIOD->BSRR,RCC->APB1ENR, andUSART2->BRR. Then check them on a host: write a small program that defines the same structs, takes the struct offsets withoffsetof, adds the base addresses, and prints them withprintf("%#lx\n", ...). Expected:0x40020c18,0x40023840,0x40004408. - In
stm32f407_regs.h, delete theRESERVED2member fromRCC_TypeDefand compile any file that includes it witharm-none-eabi-gcc -c. You should getstatic assertion failed: "RCC APB1ENR". Restore it. - Write
void gpio_set_mode(GPIO_TypeDef *port, uint32_t pin, uint32_t mode)using the field-insert pattern and the header macros. Compile at-O2, disassemble, and identify the load, the masking, and the store. Then call it with constant arguments from another function in the same file and look again: you may see the compiler inline it and usebfi(bit field insert) or abic/orrpair with immediate masks. - Write two functions: one that toggles PD13 with
GPIOD->ODR ^= 1u << 13;and one that sets PD13 withGPIOD->BSRR = 1u << 13;. Disassemble both. Count the instructions between the load ofODRand the store in the first; each gap is a window where an interrupt could cause a lost update. The second should be a singlestrafter the address and value are in registers. - Extend the header with a
TIM_TypeDeffor TIM2 (base0x40000000). The registers you need areCR1 0x00,DIER 0x0C,SR 0x10,EGR 0x14,CCMR1 0x18,CCMR2 0x1C,CCER 0x20,CNT 0x24,PSC 0x28,ARR 0x2C,CCR1 0x34. Look up in RM0090 what lives at0x04,0x08, and0x30, and either name those registers or mark them reserved. Add_Static_assertchecks forSR,ARR, andCCR1. The build passing is the expected result.
Check yourself
Section titled “Check yourself”Question 1:An EXTI handler clears its pending bit with EXTI_PR |= (1u << 0);. EXTI_PR bits are rc_w1. What goes wrong?
A write of 1 clears a rc_w1 bit. The read returns 1 for all pending lines, so the OR-and-write clears all of them. Use EXTI_PR = (1u << 0);.
Question 2:A polling loop while (!(SR & TXE)) {} uses a non-volatile pointer to SR. At -O2 the board hangs. What does the listing most likely show?
Without volatile, the compiler assumes the value cannot change inside the loop, hoists the load, and turns a clear flag into an infinite loop.
Question 3:GPIOD's base is 0x40020C00 and BSRR is the seventh 32-bit register in GPIO_TypeDef. What address does GPIOD->BSRR = x; write?
The seventh member is at index 6, so offset 6 * 4 = 0x18, giving 0x40020C18.
Question 4:main does GPIOD->ODR |= LED_GREEN; and a timer ISR does GPIOD->ODR ^= LED_BLUE;. Occasionally the blue LED change is lost. What is the best fix?
The bug is a non-atomic read-modify-write. volatile is already present and does not make RMW atomic. BSRR changes only the named pins with one store.
Question 5:Which expression correctly sets PA5 to alternate function mode (MODER bits 11:10 = 10)?
The field must be cleared before inserting the new value. The plain assignment also reconfigures every other pin, including the debug pins PA13 and PA14.
Question 6:Received UART bytes disappear only while a debugger session is running. What is a likely cause?
Reads of DR have side effects: they pop the receive data and clear RXNE. A memory or peripheral view that refreshes DR steals data.
Key takeaways
Section titled “Key takeaways”- Peripherals are memory: a register is a fixed address, accessed with an ordinary load or store of the right width.
- Every register access goes through a
volatilelvalue, so the compiler performs each read and write exactly as written. - Register bits have access types (
rw,r,w,rc_w1,rc_w0, read-sequence clears) and each one dictates whether you assign, OR, or mask. - The struct overlay with explicit reserved members and
_Static_assertonoffsetofis the standard, safe way to describe a register block. - Multi-bit fields are always cleared then set; never OR a field value into an unknown register.
- Read-modify-write is not atomic. Prefer set/clear registers like
BSRRandISERthat change bits with a single write. stm32f407_regs.hfollows CMSIS naming, so everything you write against it transfers to vendor headers.
Next: Blinky from nothing, where this header, a startup file, a linker script, and a Makefile become a complete bare-metal project.