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 |
⚠️
charon 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)-1there equals 255. If you need a sign, writeint8_texplicitly. The behaviour can be changed with the-fsigned-charoption, but you should not rely on it: it breaks when the compiler changes.⚠️
doubleis not accelerated on the Cortex-M4. The M4 FPU is single precision. Writedouble x = 1.5;or a constant without a suffix likex * 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 afloat(1.5,3.0);0.1cannot be represented that way and stays adoubleat any optimisation level. The suffix is the most reliable:1.5f,0.1f; the GCC option and functions likesinare 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, not1. The literal1has the typeint— signed. The shift1 << 31lands in the sign bit, and this is undefined behaviour: formally the compiler is entitled to do anything. The correct form is1u << 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
aandbthe compiler will insert three bytes to alignbon a word boundary. Never describe registers with a structure of fields of different types and do not rely onsizeofwhen parsing binary protocols — that needs__packedor 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.
⚠️ 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.
volatilehere cures only the disappearance, not the arbitrariness of the time — for delays there are timers (section 13) andSysTick(section 7).
Where volatile is mandatory:
- pointers to peripheral registers (already set in the CMSIS headers);
- variables changed by an interrupt handler;
- variables shared between RTOS tasks;
- software empty-loop delays — otherwise the loop is thrown out.
What volatile does NOT do:
⚠️
volatiledoes not give atomicity. It forbids caching the value in a register, butflag++remains three operations (read, increment, write), between which an interrupt can squeeze in. Atomicity requires critical sections, registers with separate set and reset likeBSRR, or the special instructionsLDREX/STREX. More in section 30.⚠️
volatiledoes 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
- Use the types from
stdint.h.intis 4 bytes,longis also 4, andcharon ARM is unsigned. - On the Cortex-M4 write
floatand constants with the suffixf: the FPU is single precision,doubleis computed in software. - A bit test returns a mask, not a one. Compare with zero, not with one.
- Shift an unsigned one:
1u << 31, otherwise it is undefined behaviour. - Registers are described by a structure of
volatile uint32_t— it lies on the memory map without holes. Mixed types give alignment and break the correspondence. - Without
volatilea wait loop at-O2disappears (armclangleaves a singlebx lr) or hangs forever (GCC reads the flag once and goes into a branch to itself). volatilegives neither atomicity nor memory barriers.- A
uint8_tin an expression becomes anint:200 + 100equals 300, but when written back into auint8_tit turns into 44. -1 > 1uis true. Do not mix signed with unsigned, turn on-Wsign-compare.constputs tables into Flash and saves RAM.
Next: section 3а "Bit operations".