Embedded HandbookApps
All sections › I. Basics
STM32F103, STM32F407 · level basic · 14 min read
Open in the interactive handbook

3. The C language for embedded systems

The language here is the same as on a big computer, but the habits must be different. A register at a fixed address changes by itself, int is not always the size you think, and char on ARM behaves differently than on x86. This section is about what is different in microcontroller C in practice.


3.1 Types: why stdint.h and not int

The main rule of embedded C: do not write int, long, short where the size matters. Include <stdint.h> and use types with an explicit width.

Instead of Write Width
unsigned char uint8_t 8 bits unsigned
char (as a number) int8_t 8 bits signed
unsigned short uint16_t 16 bits unsigned
unsigned int, unsigned long uint32_t 32 bits unsigned
— uint64_t 64 bits unsigned

The reason is not pedantry. The sizes of the basic types depend on the platform, and code that works on a computer may behave differently on a microcontroller.

What is real on ARM Cortex-M:

Type Size Comment
char 1 unsigned by default
short 2
int 4
long 4 while on 64-bit Linux it is 8
long long 8
void * 4
float 4 computed in software without an FPU
double 8 the FPU does not help on the M4 — it is single precision

The sizes of the basic types on Cortex-M in byte cells: char 1, short 2, int 4, long 4, long long 8, pointer 4, float 4, double 8; three traps are highlighted — char being unsigned, long of 4 bytes against 8 on 64-bit Linux, and double, which on the M4 is computed in software

⚠️ char on ARM is unsigned. On x86 it is signed. Code like

c char c = get_byte(); if (c < 0) { /* ... */ }

sometimes fires on a computer, but on an STM32 the condition will never be true: (char)-1 there equals 255. If you need a sign, write int8_t explicitly. The behaviour can be changed with the -fsigned-char option, but you should not rely on it: it breaks when the compiler changes.

⚠️ double is not accelerated on the Cortex-M4. The M4 FPU is single precision. Write double x = 1.5; or a constant without a suffix like x * 0.1 — and the computation goes to a software library, dozens of times slower. The compiler rescues by itself only constants that are represented exactly in a float (1.5, 3.0); 0.1 cannot be represented that way and stays a double at any optimisation level. The suffix is the most reliable: 1.5f, 0.1f; the GCC option and functions like sin are in section 69.7. Full double precision in hardware appears only on the Cortex-M7 with a double-precision FPU.

A separate note on the sizes of structure fields: for fractional quantities in embedded tasks it is often better to leave floating point altogether for fixed-point arithmetic — store, say, the temperature in hundredths of a degree as an int16_t. On a chip without an FPU this is an order of magnitude faster and more economical in memory.


3.2 Bit operations

All work with registers is setting, clearing and testing individual bits.

Task How Explanation
set bit n reg \|= (1u << n); "or" with a mask
clear bit n reg &= ~(1u << n); "and" with the inverted mask
toggle bit n reg ^= (1u << n); "exclusive or"
test bit n if (reg & (1u << n)) the result is a mask, not a one
read bit n as 0 or 1 (reg >> n) & 1u shift the bit into position zero and cut off the rest
write a field reg = (reg & ~MASK) \| VALUE; first clear, then put in

The last line is the most frequent operation when configuring peripherals, and the order in it matters: if you do not clear the field before writing, the old bits will remain and be "or"-ed with the new ones.

CMSIS packages the same thing in the macros SET_BIT, CLEAR_BIT, READ_BIT, MODIFY_REG — they are covered in section 5.

⚠️ Write 1u, not 1. The literal 1 has the type int — signed. The shift 1 << 31 lands in the sign bit, and this is undefined behaviour: formally the compiler is entitled to do anything. The correct form is 1u << 31.

A bit test returns not "true" but a mask. This is the same mine that is taken apart in section 8 using a button as an example:

if (READ_BIT(GPIOA->IDR, GPIO_IDR_IDR5) == 1)   /* НИКОГДА не сработает */
if (READ_BIT(GPIOA->IDR, GPIO_IDR_IDR5) != 0)   /* правильно */

GPIO_IDR_IDR5 equals 0x0020, not 1. Comparison with one happens to work only for bit zero.

If you need the value of the bit itself — exactly 0 or 1 — it is shifted into position zero and the rest is cut off: (GPIOA->IDR >> 5) & 1u. Such an expression can already be compared with one, added and put into an array. Testing by mask and reading a bit are two different operations: the first answers "is it there or not", the second gives a number.


3.3 Pointers and register structures

The peripherals are mapped into memory, so a register is simply an address. The most direct way to get to it:

#define GPIOC_ODR  (*(volatile uint32_t *)0x4001100C)

GPIOC_ODR |= (1u << 13);

Reading the expression from the inside out: 0x4001100C is a number; (volatile uint32_t *) — treat it as the address of a 32-bit cell that can change by itself; the outer * — access the contents.

Working like this with every register is inconvenient, so a peripheral is described by a structure, and the base address is given once:

typedef struct {
    volatile uint32_t CRL;    /* +0x00 */
    volatile uint32_t CRH;    /* +0x04 */
    volatile uint32_t IDR;    /* +0x08 */
    volatile uint32_t ODR;    /* +0x0C */
    volatile uint32_t BSRR;   /* +0x10 */
    volatile uint32_t BRR;    /* +0x14 */
    volatile uint32_t LCKR;   /* +0x18 */
} GPIO_TypeDef;

#define GPIOC ((GPIO_TypeDef *) 0x40011000)

From then on the compiler turns GPIOC->ODR into an access at address 0x40011000 + 0x0C. The order of the fields must match the register map from the reference manual — hence the RESERVED fields in the headers: they plug gaps in the map so that the offsets do not drift.

This works only because fields of uint32_t are laid out one after another, without alignment holes: sizeof(GPIO_TypeDef) is exactly 28 bytes for seven registers.

⚠️ A mixed structure gets holes. This is no longer a register map:

c typedef struct { uint8_t a; uint32_t b; } Mixed; /* sizeof == 8, not 5 */

Between a and b the compiler will insert three bytes to align b on a word boundary. Never describe registers with a structure of fields of different types and do not rely on sizeof when parsing binary protocols — that needs __packed or manual assembly from bytes.


3.4 volatile: why your code disappears

Terms used in the documentation

Term Meaning
memory-mapped register a register mapped into the address space
read-modify-write read the value, change it, write it back
atomic access an access that cannot be interrupted halfway
word / half-word / byte access an access by a whole word, a half-word or a single byte
aligned / unaligned access an access at an address that is / is not a multiple of the size
bit mask a value with ones in the bits of interest
to set / clear a bit to write a one / a zero into a bit
reserved bits bits that must not be changed

The most important keyword of embedded C. It promises one thing: every read and every write in the program text will really happen — at this address, as many times and in the same order relative to other volatile accesses. The compiler may not keep the value in a register, throw out a "redundant" write or merge two writes into one. Hence its use: the value can change outside this code — by an interrupt handler, another task or the hardware itself — and a write to a peripheral register is an action in itself and cannot be skipped. For the rest of the program volatile causes no slowdown and does not turn off optimisation; what it does not promise — indivisibility and ordering relative to ordinary variables — is covered in section 124.2.

What happens without it is better shown by the compiler output than by an explanation. Two identical flag-wait functions, differing in one word:

static uint8_t          flag_plain    = 0;
static volatile uint8_t flag_volatile = 0;

void wait_without_volatile(void) { while (flag_plain    == 0) { } }
void wait_with_volatile   (void) { while (flag_volatile == 0) { } }

void EXTI0_IRQHandler(void) { flag_plain = 1; flag_volatile = 1; }

We compile with armclang at optimisation level -O2 for Cortex-M3 and look at the assembly.

Without volatile:

wait_without_volatile:
        bx      lr

That is all. One instruction — return. The compiler reasoned like this: without volatile the flag can be read once — nobody changes it inside the loop, and the compiler does not see the handler being called, even though it lies in the same file. If it is 0, the loop is infinite and does nothing, and the C standard allows such a loop to be assumed to terminate — only the exit remains. The wait function simply disappeared. That is what armclang does. GCC (it is used in CubeIDE) does it differently on the same code: it reads the flag once and, if it is 0, goes into an endless branch to itself — the program hangs. Both outcomes are a bug, and the cure is the same.

With volatile:

wait_with_volatile:
        movw    r0, :lower16:flag_volatile
        movt    r0, :upper16:flag_volatile
        b       .LBB1_3
.LBB1_1:
        ldrb    r1, [r0]
        cmp     r1, #0
        it      ne
        bxne    lr
.LBB1_2:
        ldrb    r1, [r0]
        cbnz    r1, .LBB1_5
.LBB1_3:
        ldrb    r1, [r0]          @ честное чтение из памяти
        cbnz    r1, .LBB1_5       @ флаг не 0 — на выход
        ldrb    r1, [r0]
        cmp     r1, #0
        beq     .LBB1_1           @ назад, пока флаг 0
.LBB1_5:
        bx      lr

The loop is in place, ldrb re-reads the variable from memory each time. The compiler merely unrolled it four times: four flag reads per pass, and a check after each one.

There is more than one way to disappear. Above, the flag was a static variable, and with armclang the loop vanished entirely. If the same wait is written through a pointer to a register, the compiler output is different, but the result is no better:

#define SR (*(uint32_t *)0x40011000)          /* без volatile */

void wait_plain(void) { while ((SR & (1u << 7)) == 0) { } }

Without volatile the compiler is entitled to treat the register as an ordinary variable — throw out the read, move it or merge it with a neighbouring one. Here it reads the register once, before the loop, and folds the loop itself into a branch to itself. The program hangs forever, even if the hardware set the flag long ago. On the board this looks like "the firmware never reached the next line", and in the debugger you see an endless b to one and the same address. With volatile a read appears in the same place on every iteration — and the wait works.

The second trap from the same family is an empty-loop delay. for (uint32_t i = 0; i < 1000; i++) {} at -O2 turns into four bytes: a single bx lr. There is no loop, no delay, though in the source it seems to be there. With a volatile counter the loop stays — thirty-two bytes, a read and a write of the counter on every iteration.

Four functions in the compiler output: without volatile the flag wait folds into a branch to itself, and the empty delay disappears entirely

⚠️ An empty-loop delay is bad in itself too: its time depends on optimisation, the core frequency and even on whether the code landed in the cache. volatile here cures only the disappearance, not the arbitrariness of the time — for delays there are timers (section 13) and SysTick (section 7).

Where volatile is mandatory:

What volatile does NOT do:

⚠️ volatile does not give atomicity. It forbids caching the value in a register, but flag++ remains three operations (read, increment, write), between which an interrupt can squeeze in. Atomicity requires critical sections, registers with separate set and reset like BSRR, or the special instructions LDREX/STREX. More in section 30.

⚠️ volatile does not guarantee ordering relative to ordinary variables and does not stop the processor from reordering memory accesses. On Cortex-M this is rarely noticeable, but when working with DMA a buffer after a transfer sometimes needs a barrier (__DMB()), otherwise the core may see stale data.


3.5 Integer promotion

A trap that breaks arithmetic on small types.

In any expression uint8_t and uint16_t are promoted to int. So:

uint8_t a = 200, b = 100;

if (a + b > 255)        /* ИСТИНА: a + b считается как int и равно 300 */
uint8_t c = a + b;      /* а тут 300 обрежется до 44 */

The first behaviour often turns out to be unexpected, the second too, and they contradict each other within three lines.

Comparing signed with unsigned is even worse:

if (-1 > 1u)            /* ИСТИНА! */

-1 is converted to unsigned and becomes 0xFFFFFFFF. Hence the practical rule: do not mix signed and unsigned in one comparison, and declare loop counters that count down as signed:

for (uint8_t i = 10; i >= 0; i--)   /* ВЕЧНЫЙ ЦИКЛ: i всегда >= 0 */
for (int8_t  i = 10; i >= 0; i--)   /* правильно */

Such places are caught by the options -Wall -Wextra -Wsign-compare — turn them on from the first day of the project, later it will be too late to clean up.


3.6 const, static, inline

const for large tables is not about immutability but about memory: a constant array is placed in Flash rather than copied into RAM at startup. On a chip with 20 KB of RAM this is decisive.

const uint8_t font_table[256] = { /* ... */ };   /* во Flash */
uint8_t       buffer[256];                       /* в RAM */

Only a constant with static storage duration goes into Flash — one declared outside a function or with static. A table inside a function is written like this: static const uint8_t lut[256] = { … };. Without static it is an ordinary local variable, and the compiler is entitled to copy it onto the stack on every call. GCC 12 did exactly that at all optimisation levels — 256 bytes of stack and a memcpy on every call; armclang at -O1 and above managed without a copy as long as the address of the table did not leave for another function.

static at file level limits visibility to this file — less chance of a name clash and more freedom for the optimiser. static inside a function makes the variable live for the whole run of the program, but visible only in the function.

inline asks for the body of the function to be substituted instead of a call: faster, but the code grows. Appropriate for short wrappers over registers, not for everything. Note: inline is a request, not an order; forced inlining is done with a compiler extension (__forceinline in Keil, __attribute__((always_inline)) in GCC).


3.7 Naming style

There is no single standard, but it is useful to know the common ones in order to read other people's libraries. FreeRTOS uses Hungarian notation — the type is built into the name.

Prefix Type
c char
s short
l long
x BaseType_t, structures, handles
u added for unsigned types: uc, us, ul
p added for pointers: pc, pv
v the function returns nothing: vTaskDelay

Hence ulCounter reads as unsigned long, and pcMessage as a pointer to char. Function names also carry the module name: xQueueSend is from queues and returns BaseType_t.

ST's libraries follow a different style: module, underscore, action — HAL_GPIO_WritePin, LL_TIM_EnableCounter. Register names and bit masks are written in capitals: RCC_APB2ENR_IOPCEN.

You may choose any style of your own, but stick to it throughout the project: a mix of two notations reads worse than consistently following even an unfortunate one.


3.8 Pitfalls

A condition on a signed char never fires on an STM32. On ARM char is unsigned: (char)-1 equals 255. If you need a sign — int8_t (3.1).

A calculation on the M4 is dozens of times slower than expected. A double or a constant without f sends the computation into a software library: the M4 FPU is single precision (3.1).

1 << 31 behaves strangely. The literal 1 is signed, a shift into the sign bit is undefined behaviour; write 1u << 31 (3.2).

The sizeof of a structure is larger than the sum of its fields. The compiler aligns the fields and inserts padding bytes; protocols are parsed byte by byte or with a packed structure (3.3).

An empty delay loop disappeared or does not last as long as expected. volatile saves from disappearance, but not from an arbitrary time — delays are made with a timer or SysTick (3.4).

A volatile counter loses increments. volatile does not give atomicity: flag++ is three operations, and an interrupt gets in between them (3.4).


3.9 Summary

Next: section 3а "Bit operations".

Open in the interactive handbook