Embedded C Essentials: volatile, const and Bit Manipulation Macros

Embedded firmware is written in ordinary C, but a handful of language features carry far more weight on a microcontroller than in a desktop program. Hardware registers change on their own, flash memory is precious, and a single wrong bit can stop a peripheral. This article covers the four tools you will use in nearly every driver: fixed-width integer types, the volatile qualifier, the const qualifier, and bit manipulation with masks and macros.

Fixed-Width Integer Types

The C standard only gives minimum sizes for int and long. An int is 16 bits on an AVR and 32 bits on an ARM Cortex-M. When a variable mirrors a hardware register or a protocol field, the size must be exact, so embedded code uses the types from stdint.h.

TypeSizeTypical use
uint8_t / int8_t8 bitsBytes on a UART, I2C data, small counters
uint16_t / int16_t16 bitsADC results, 16-bit timer values
uint32_t / int32_t32 bits32-bit registers, millisecond tick counters
uintptr_tWide enough for a pointerConverting addresses to integers
bool (stdbool.h)Implementation-definedFlags and status values

Use unsigned types for anything you shift or mask. Right-shifting a negative signed value is implementation-defined, and left-shifting a 1 into the sign bit of a signed int is undefined behaviour. This is why masks are written as 1U << n or 1UL << n, not 1 << n.

volatile: Telling the Compiler Memory Can Change

An optimising compiler assumes that a variable only changes when your code writes it. If a loop reads a variable that the loop never modifies, the compiler may read it once into a CPU register and never look at memory again. That is correct for normal variables and fatal for hardware.

Declaring an object volatile tells the compiler that every read and every write in the source must actually happen, in order, because something outside the visible code can observe or change it. Use it for three things:

  1. Memory-mapped peripheral registers, which change when hardware events happen.
  2. Variables shared between an interrupt service routine and the main code, such as a flag set in a UART receive interrupt.
  3. Variables shared with a DMA controller or another core.

The classic bug looks like this: while (!data_ready) { } where data_ready is set in an ISR. Without volatile, an optimised build may turn the loop into an infinite loop because the compiler sees nothing inside it that changes the flag.

const: Read-Only Data and Safer Interfaces

const means the program will not modify the object. On most microcontroller toolchains, global const data is placed in flash (the .rodata section) instead of being copied into scarce SRAM. A 256-entry sine lookup table declared const costs flash, not RAM.

Note that on 8-bit AVR with avr-gcc this is not automatic: flash and RAM are separate address spaces, so constant tables need the PROGMEM attribute and special read functions. On ARM Cortex-M, RISC-V and most 32-bit parts, const alone is enough.

const also documents function interfaces. void uart_send(const uint8_t *buf, size_t len) promises the caller that the buffer will not be changed. Read pointer declarations from right to left:

  • const uint8_t *p - pointer to constant data; the data cannot be changed through p, but p can move.
  • uint8_t *const p - constant pointer; p always points to the same place, the data can change.
  • volatile uint32_t *const REG - a fixed address of a register whose contents change on their own. This is the exact shape of a register pointer.
  • const volatile uint32_t * - a read-only register, such as a status register the program must never write but hardware updates.

Bit Manipulation with Masks

A peripheral register packs many settings into one word: enable bits, interrupt flags and multi-bit fields such as a prescaler. Firmware must change one bit without disturbing the others. Four operations cover almost everything.

OperationExpressionEffect
Set bit nreg |= (1UL << n)Bit n becomes 1, others unchanged
Clear bit nreg &= ~(1UL << n)Bit n becomes 0, others unchanged
Toggle bit nreg ^= (1UL << n)Bit n flips
Test bit n(reg >> n) & 1ULGives 1 if bit n is set

Multi-bit fields need a clear-then-set step: clear the whole field with its mask, then OR in the new value shifted into position. Doing only the OR leaves old 1 bits behind, which is one of the most common register bugs.

Portable bit macros tested against a simulated 32-bit register; compiles with any C99 compiler
#include <stdio.h>
#include <stdint.h>

/* Single-bit helpers */
#define BIT(n)                 (1UL << (n))
#define SET_BIT(reg, n)        ((reg) |=  BIT(n))
#define CLEAR_BIT(reg, n)      ((reg) &= ~BIT(n))
#define TOGGLE_BIT(reg, n)     ((reg) ^=  BIT(n))
#define READ_BIT(reg, n)       (((reg) >> (n)) & 1UL)

/* Multi-bit field helpers: width bits starting at position pos */
#define FIELD_MASK(pos, width) (((1UL << (width)) - 1UL) << (pos))
#define FIELD_GET(reg, pos, width) (((reg) & FIELD_MASK(pos, width)) >> (pos))
#define FIELD_SET(reg, pos, width, val) \
    ((reg) = ((reg) & ~FIELD_MASK(pos, width)) | \
             (((uint32_t)(val) << (pos)) & FIELD_MASK(pos, width)))

/* On a real MCU this would be a fixed address, for example:
 * #define CTRL_REG (*(volatile uint32_t *)0x40001000UL)
 * Here it is an ordinary variable so the program runs on a PC. */
static volatile uint32_t ctrl_reg = 0x000000F0UL;

/* Pretend register layout */
#define CTRL_EN        0   /* bit 0: enable               */
#define CTRL_IE        1   /* bit 1: interrupt enable     */
#define CTRL_PSC_POS   4   /* bits 4..7: prescaler field  */
#define CTRL_PSC_W     4

int main(void)
{
    printf("start          : 0x%08lX\n", (unsigned long)ctrl_reg);

    SET_BIT(ctrl_reg, CTRL_EN);
    SET_BIT(ctrl_reg, CTRL_IE);
    printf("EN and IE set  : 0x%08lX\n", (unsigned long)ctrl_reg);

    FIELD_SET(ctrl_reg, CTRL_PSC_POS, CTRL_PSC_W, 5);
    printf("prescaler = 5  : 0x%08lX\n", (unsigned long)ctrl_reg);

    CLEAR_BIT(ctrl_reg, CTRL_IE);
    printf("IE cleared     : 0x%08lX\n", (unsigned long)ctrl_reg);

    printf("EN bit = %lu, prescaler = %lu\n",
           (unsigned long)READ_BIT(ctrl_reg, CTRL_EN),
           (unsigned long)FIELD_GET(ctrl_reg, CTRL_PSC_POS, CTRL_PSC_W));
    return 0;
}
start          : 0x000000F0
EN and IE set  : 0x000000F3
prescaler = 5  : 0x00000053
IE cleared     : 0x00000051
EN bit = 1, prescaler = 5

The prescaler field started as 0xF (all four bits set). A plain OR of 5 would have left it at 0xF; the FIELD_SET macro cleared the field first, giving the correct value 0x5 in bits 4 to 7. The (1UL << width) - 1UL expression builds a mask of width ones, so the same macro handles a 1-bit flag or a 12-bit field. Do not use it with a width of 32 on a 32-bit unsigned long, because shifting by the full width of a type is undefined.

Read-Modify-Write and Its Hazards

reg |= mask is not one operation. The CPU reads the register, ORs the mask, and writes the result back. Two problems follow. First, if an interrupt changes the same register between the read and the write, its change is lost. Second, some registers have write-1-to-clear flags: reading a pending flag as 1 and writing it back as 1 clears it by accident.

Hardware designers provide ways around this. Many STM32 parts have a GPIO bit set/reset register (BSRR) that sets or clears pins with a single write, and many interrupt flag registers are cleared by writing 1 only to the bit you want. Always check the reference manual for each register's access type: read/write, read-only, write-1-to-clear or write-only.

Common Mistakes

  • Writing 1 << 31 with a signed int, which is undefined behaviour. Use 1UL << 31.
  • Forgetting volatile on an ISR flag, so the program works at -O0 and hangs at -O2.
  • Assuming volatile makes a 32-bit counter safe to read on an 8-bit AVR. The read takes several instructions and can be torn by an interrupt.
  • Using = instead of |= and wiping every other bit in a configuration register.
  • Omitting parentheses around macro arguments, so SET_BIT(reg, n + 1) expands incorrectly. Every argument in the macros above is wrapped in parentheses for this reason.

Summary

Use stdint.h types so sizes are exact, volatile for anything hardware, an ISR or DMA can change, and const to keep tables in flash and to document interfaces. Manipulate registers with unsigned masks, clear multi-bit fields before setting them, and remember that read-modify-write is not atomic. With these habits the rest of embedded C becomes much easier to reason about.