On a Cortex-M4, a 32-bit timer read takes three instructions: load the base address, add the offset, then dereference a volatile pointer. On an 8-bit AVR, reading a 16-bit timer in the wrong byte order creates a race because the first access latches the high byte. The peripheral view looks similar from 10,000 feet, but register-level behaviour is different.
The MMIO Contract: Why Volatile Matters
MMIO works by placing CPU and peripheral hardware in one address space. Writing to 0x40013800 on an STM32F4 targets USART1 rather than RAM, and the peripheral assigns meaning to particular bits. The C standard does not model this. To the compiler, a fixed-address write with no later read may look like a removable dead store.
The volatile qualifier tells the compiler that accesses to this address must remain observable. It must generate each read and write in source order. For MMIO, that requirement is essential. Omitting volatile from peripheral pointers leaves a driver defective, even when current flags appear to make it work. With optimization level 0 and no LTO, the problem can remain hidden.
Embedd always emits peripheral pointers with volatile. There is no valid alternative for this setting.
Cortex-M: CMSIS Struct Overlays
ARM's CMSIS standard gives Cortex-M vendors a shared register description format. A CMSIS peripheral header uses a C struct whose fields correspond to register offsets:
typedef struct {
__IO uint32_t CR1; /* offset: 0x00 */
__IO uint32_t CR2; /* offset: 0x04 */
__IO uint32_t CR3; /* offset: 0x08 */
__IO uint32_t BRR; /* offset: 0x0C */
__IO uint32_t GTPR; /* offset: 0x10 */
__IO uint32_t RTOR; /* offset: 0x14 */
__IO uint32_t RQR; /* offset: 0x18 */
__IO uint32_t ISR; /* offset: 0x1C */
__IO uint32_t ICR; /* offset: 0x20 */
__IO uint32_t RDR; /* offset: 0x24 */
__IO uint32_t TDR; /* offset: 0x28 */
} USART_TypeDef;
In ARM headers, __IO expands to volatile. A macro casts the base address for access:
#define USART1 ((USART_TypeDef *) USART1_BASE)
/* Set baud rate divisor */
USART1->BRR = 0x0683; /* 9600 baud at 16 MHz APB clock */
The approach is clear and works well with compilers. Struct layout checks offsets at compile time when padding is controlled. CMSIS headers use __attribute__((packed)) or explicit padding fields for that purpose. A hand-written overlay without the packed attribute can introduce padding errors that remain unnoticed.
For Cortex-M targets, Embedd emits CMSIS-compatible structs and adds uint8_t reserved[N] fields wherever the register map has gaps. The offsets are therefore visible and can be checked without examining the binary.
RISC-V: No Common HAL
RISC-V peripherals use fixed addresses just as ARM peripherals do. In both cases, the CPU sends a load or store to an address, and the bus routes it to a register. RISC-V lacks a CMSIS-equivalent standard.
SiFive devices use the Freedom Metal HAL. GigaDevice's GD32VF103 has a register-struct header with a CMSIS-like shape but different names. WCH's CH32V family uses another vendor format. Vendors also do not reliably publish a RISC-V equivalent to SVD.
For RISC-V, Embedd reads the datasheet directly instead of an SVD file. It emits a register-struct header following the Cortex-M output conventions, without CMSIS type aliases. We write volatile uint32_t directly because __IO belongs to ARM. The remaining struct layout is equivalent.
8-Bit MCUs: Harvard Memory and SFRs
Microchip PIC and Atmel AVR use Harvard memory, separating program and data address spaces. Peripheral registers sit in data memory, commonly in its lower region, and are known as Special Function Registers, or SFRs. On some 8-bit PIC devices, near data access uses an 8-bit pointer, so a standard C pointer cannot represent a 16-bit SFR address.
AVR exposes _SFR_IO8 and _SFR_MEM8 through avr/io.h. XC8 supplies PIC named-register access in generated, device-specific headers. Neither uses the CMSIS struct-overlay approach, because their hardware and ABI pointer models differ.
The 16-bit timer issue from the opening example shows why this matters. On AVR, the byte sequence matters: reading TCNT1L latches the high byte for the following read, so accessing TCNT1H first produces an incorrect result. A naive struct overlay can lead the compiler to generate byte-swapped reads, causing intermittent counter corruption that changes with optimization.
For 8-bit devices, Embedd emits architecture-specific accessor macros instead of struct overlays. Where multi-byte SFRs need atomic latching, these macros apply the required read order and add cli/sei, or the equivalent critical section, when specified by the datasheet.
Bitfields or Bit Masks?
C bitfield structs may seem like an easy register representation:
typedef struct {
uint32_t UE : 1; /* bit 0: USART enable */
uint32_t UESM : 1; /* bit 1: USART enable in Stop mode */
uint32_t RE : 1; /* bit 2: Receiver enable */
uint32_t TE : 1; /* bit 3: Transmitter enable */
/* ... */
} USART_CR1_bits_t;
Embedd does not use C bitfield structs for register access. The C standard leaves bitfield allocation order unspecified. A little-endian ARM compiler may place bit 0 at the LSB, matching the hardware, while a big-endian target or another compiler may reverse that arrangement. Mapping such structs onto registers is therefore undefined under C, even if one toolchain behaves as expected.
Instead, Embedd emits bit-mask macros and accessor functions:
#define EMBX_USART1_CR1_UE_MSK (1UL << 0)
#define EMBX_USART1_CR1_RE_MSK (1UL << 2)
#define EMBX_USART1_CR1_TE_MSK (1UL << 3)
static inline void embx_usart1_cr1_te_set(void) {
USART1->CR1 |= EMBX_USART1_CR1_TE_MSK;
}
static inline void embx_usart1_cr1_te_clear(void) {
USART1->CR1 &= ~EMBX_USART1_CR1_TE_MSK;
}
This approach behaves consistently across toolchains and can produce efficient code. The inline functions use read-modify-write deliberately, but that sequence is not atomic on Cortex-M without exclusive load/store instructions. Code sharing a register with interrupts should therefore use a critical section. Generated header comments identify registers needing atomic access.
One API Across Architectures
Embedd gives firmware a consistent driver API across target architectures. Function names, configuration structs, and initialization follow the same pattern on a Cortex-M0+ at 32 MHz and a RISC-V GD32 at 108 MHz.
Architecture still affects the generated internals. Cortex-M drivers call NVIC, RISC-V drivers call PLIC, and AVR drivers use sei/cli. The application-facing API hides those differences. When firmware uses the Embedd HAL and the MCU changes within a supported family, the driver internals change while calling code remains unchanged. That normalisation layer accounts for much of the generator's complexity.