Embedded HandbookApps
All sections › IX. The C language: from basics to advanced
STM32F103, STM32F407, STM32G431 · level basic · 12 min read
Open in the interactive handbook

67. Pointers and pointer arithmetic

A pointer is a variable that holds an address. Nothing more. But on a microcontroller it is the main tool of the language: peripheral registers are addresses (section 3.3), buffers are passed to functions by address, and the interrupt vector table is an array of function addresses.

This section covers how pointers work, why p + 1 does not add one, what happens to an array when you pass it to a function, and which pointers are dangerous. Everything said here about steps and offsets is taken from real compiler output.

Terms used in the documentation

Term Meaning
pointer a variable that holds an address
address-of operator & taking the address of an object
dereference, indirection * accessing the object at an address
pointer arithmetic adding to and subtracting from addresses
array decay an array turning into a pointer
null pointer a pointer equal to zero
dangling pointer a pointer to an object that no longer exists
wild, uninitialized pointer a pointer with no initial value
pointer to const, const pointer read-only data, read-only address
function pointer, callback the address of a function, a function called back
vector table the table of handler addresses
memory-mapped register a register accessed at a memory address

67.1 A pointer is an address

Two operations:

int n = 0;
int *p = &n;        /* p хранит адрес n */
*p = 10;            /* записать 10 туда, куда указывает p: теперь n = 10 */

The asterisk in a declaration and the asterisk in an expression are different things. In int *p it says "p is a pointer to int". In *p = 10 it says "take whatever p points to". The confusion goes away if you read the declaration like this: "*p is an int".

A pointer that points nowhere is, by convention, equal to zero — NULL. You check it before use.

The size of a pointer does not depend on what it points to. On Cortex-M any pointer — to a byte, to a double, to a register structure, to a function — takes 4 bytes. On a 64-bit computer it takes 8 (section 64.3).

A pointer in the debugger memory window: p holds the address of n, b looks at word byte by byte


67.2 Why you need them


67.3 Pointer arithmetic

You can add an integer to a pointer — and here is the first surprise: p + 1 moves the address not by one byte but by the size of what p points to. This is what the compiler actually does:

Expression Machine code Address step
p + 1, p is uint8_t * adds r0, #1 1 byte
p + 1, p is uint16_t * adds r0, #2 2 bytes
p + 1, p is uint32_t * adds r0, #4 4 bytes
p + 3, p is uint32_t * adds r0, #12 12 bytes

This is not a quirk but the whole point: p + 1 is the next element, not the next byte.

The difference of pointers works the same way: it is counted in elements. The CRL and ODR registers of an F1 port are 12 bytes apart, and

&GPIOC->ODR - &GPIOC->CRL                         /* 3 — три регистра */
(uint8_t *)&GPIOC->ODR - (uint8_t *)&GPIOC->CRL   /* 12 — байт */

The compiler computed both differences in advance and put ready-made 3 and 12 into the code. To get bytes, you cast the pointers to a pointer to byte. Strictly speaking, CRL and ODR are fields of a structure, not elements of one array, and the standard leaves such a difference undefined (the rule is below): the compiler computed it correctly, but there is no guarantee. The legal way is offsetof(GPIO_TypeDef, ODR) - offsetof(GPIO_TypeDef, CRL), the same 12 bytes.

Allowed operations: add and subtract an integer, ++ and --, the difference of two pointers into the same array, comparison. Adding two pointers makes no sense, and the compiler will not allow it.

p + 1 is the next element: a step of 1, 2 or 4 bytes, depending on what p points to


67.4 Arrays and pointers

The name of an array in an expression turns into the address of its element zero. This gives the identity that all work with arrays rests on:

a[i]   ≡   *(a + i)

Square brackets are just shorthand for pointer arithmetic. The identity is so literal that 2[a] also compiles and gives the same as a[2] — addition is commutative. Do not write it that way, but understanding why it works is useful.

A few exercises to test your understanding — with int a[5] = {10, 20, 30, 40, 50};:

int *pa = a;
*(pa + 2)               /* 30 — элемент с индексом 2 */
*pa + 2                 /* 12 — сначала *pa, потом + 2: приоритет */

int *pa1 = &a[1], *qa = &a[3];
qa - pa1                /* 2 — между ними два элемента */

pa = a + 2;
*pa++ = 13;             /* записать в a[2], потом сдвинуть pa: *pa теперь 40 */
                        /* массив: 10 20 13 40 50 */
++*pa;                  /* увеличить значение, а не указатель */

*pa++ reads as *(pa++): postfix ++ binds tighter than the asterisk, but returns the old address. This is the most common form in copy loops (section 68).

There is one difference between an array and a pointer, but it matters: you cannot assign to an array — a = b will not compile. The name of an array is not a variable but an address fixed to it for good. A pointer, on the other hand, can be redirected anywhere.


67.5 Passing an array to a function: the size is lost

When you pass an array to a function, only the address of its start is passed. The function does not know how many elements are there — even if the parameter is written as int a[5]:

int count(int a[5]) { return sizeof(a) / sizeof(a[0]); }

Inside the function a is a pointer, and sizeof(a) returns the size of a pointer. On Cortex-M the function will "count" one element (4 bytes of pointer divided by 4 bytes of int), and on a computer two (8 by 4). It will never get five.

The compiler warns about this here without any flags: "sizeof on array function parameter will return size of 'int *' instead of 'int[5]'".

The right way is to pass the size as a separate parameter. Then one function handles both the whole array and any part of it:

int sum(const int *a, int size)
{
    int s = 0;
    for (int i = 0; i < size; i++)
        s += a[i];
    return s;
}

sum(a, 5);          /* 150 — весь массив */
sum(a, 2);          /* 30  — первые два */
sum(a + 2, 3);      /* 120 — с третьего до конца */

Where the array is declared, its size is computed like this: sizeof(a) / sizeof(a[0]) — this works as long as a is a real array and not a parameter.


67.6 const and pointers

const can apply to two different things:

Declaration What you cannot do What you can do
const int *p change the value through p: *p = 5 redirect p
int *const p redirect p: p = q change the value *p
const int *const p neither only read

Read right to left: int *const p is "p is a const pointer to int", const int *p is "p is a pointer to an int that is const". Breaking either rule is a compile error, not a warning.

The practical use is function parameters:

void uart_send(const uint8_t *data, uint16_t len);

const here is a promise: the function will not change the data. The compiler enforces the promise, and as a bonus you can pass a table from Flash to such a function without warnings (section 3.6). A non-const parameter will accept it too, but with a warning about discarding const, and if the function tries to write, the write to Flash will not go through or will cause a fault.


67.7 Pointer to pointer

A pointer is also a variable, so it also has an address:

int a = 77;
int *pa = &a;
int **ppa = &pa;

*pa = 88;           /* a = 88 */
**ppa = 99;         /* a = 99: через два адреса к тому же месту */

You need it when a function must change the pointer itself: move the parse position in a string, return the address of an allocated buffer. By the same logic as in section 66.3: to change a variable, you pass its address — and if the variable is itself a pointer, you get a pointer to a pointer. You will also meet it in command-line arguments (section 71).


67.8 Function pointers

A function is also stored in memory, and its address can be kept in a variable:

typedef int (*op_t)(int);           /* тип: указатель на функцию int → int */

int twice(int x)  { return 2 * x; }
int square(int x) { return x * x; }
int negate(int x) { return -x; }

op_t ops[3] = { twice, square, negate };
ops[1](7);                          /* 49 — вызов по номеру */

Without typedef the notation is hard to read — int (*ops[3])(int) — so the type is almost always given through a typedef. The parentheses around *op_t are required: without them you would get a declaration of a function that returns a pointer.

Uses on a microcontroller:

And the most important use is already in every project. The interrupt vector table is an array of function addresses. The first word of the table in the F103 startup file is the initial value of the stack pointer, the second is the address of Reset_Handler, and then come the handler addresses, one word each. For the core, an interrupt means "take the word from the table by number and jump to that address" (section 7). And if a handler is not written, the table holds the address of the default handler — that same branch-to-self (section 65.9).

All these addresses are odd: a one in the least significant bit tells the core that the code is in Thumb state, and Cortex-M has no other state. In the test bench of section 7в Reset_Handler starts at address 0x08000040, while the table holds 0x08000041; a function pointer taken in C looks the same way — for good_func in the same bench it is 0x080000D7. A jump to an even function address is an INVSTATE fault (section 7в.1).


67.9 Bytes through a pointer

A pointer to byte lets you look at any value byte by byte:

uint32_t word = 0x11223344;
uint8_t *b = (uint8_t *)&word;
/* b[0] = 44, b[1] = 33, b[2] = 22, b[3] = 11 */

The least significant byte comes first — this is the byte order of Cortex-M and of the computer, covered in section 3а.8. This is how packets are parsed and numbers are assembled from received bytes.

One caveat. The language always allows you to look at anything through a pointer to byte. But reading, say, a float through a pointer to uint32_t is formally forbidden: the optimizer may assume that pointers of different types do not look at the same place, and reorder the accesses. For such a conversion you use memcpy — the compiler folds it into a single instruction.


67.10 Dangerous pointers

Uninitialized. A local pointer without an initial value contains garbage — a random address. Writing through it corrupts random memory. The compiler notices this only with -Wall.

Dangling. A pointer to something that no longer exists:

int *make(void) { int x = 5; return &x; }   /* x исчезнет при выходе */

A local variable lives on the stack until the function returns (section 66.4), and the returned address points to a place the next call will take. The compiler warns about this without any flags.

Null. On a computer, reading from address zero crashes the program instantly — the operating system protects memory. On a microcontroller there is no such protection by default. On STM32 address zero shows a copy of the start of Flash (section 11.7), and reading through a null pointer will not crash anything: the program gets the initial stack value from the vector table and carries on with garbage. A bug that is visible at once on a computer lives quietly on the chip for months. This is not true everywhere: when booting from system memory the bootloader is at zero, and on H7 the ITCM RAM starts at address 0, so a write through NULL silently corrupts it. You can catch such accesses with an MPU region with no access at the start of the address space (section 11а).

Outside the memory map. An access to an address with neither memory nor peripherals behind it causes a processor fault (section 35.2). This is probably the most honest outcome: the fault registers show where the access came from.

Outside the array. Nobody checks the index: a[5] with five elements reads whatever follows in memory. On the chip that is usually other variables — and they get silently corrupted.


67.11 Pitfalls

The function computes the array size wrong. sizeof of a parameter is the size of a pointer. Pass the size as a separate parameter.

*p++ incremented the wrong thing. Postfix ++ applies to the pointer; to increment the value, write (*p)++ or ++*p.

p + 1 skipped several bytes. That is by design: the step is the size of an element.

The pointer difference is "four times too small". It is in elements; for bytes, cast to uint8_t *.

You cannot assign an array. The name of an array is not a variable. Copy element by element or with memcpy.

A write through a pointer corrupted a neighbouring variable. Out-of-bounds array access or an uninitialized pointer.

The function returned a pointer, and the data "drifted". A dangling pointer to a local variable.

A null pointer caused no error. On STM32, address zero reads Flash.

The program faulted when accessing a variable. The pointer points outside the memory map.

A warning about discarding const when passing a table from Flash. The parameter is declared without const: the function is allowed to write where Flash is.

The interrupt does not call the handler. The vector table holds the address of the default handler: the function name is spelled differently from the one in the startup file.


67.12 Summary

Next: section 68 "Arrays and strings".

Open in the interactive handbook