Volume 07 Beginner 7 sub-modules ~15 min read

Pointers from Zero

Pointers are the part of C people dread, and they are simpler than their reputation. A pointer is a variable that holds an address - the number of a box in memory. This volume builds that idea up slowly, with every step shown by a program, and finishes with the function pointers that drivers use to call back into your code.

You will learn
  • What a pointer holds, and what it costs in memory
  • How & takes an address and * follows one
  • Why an array turns into a pointer when you pass it, and what that loses
  • Why p + 1 moves four bytes for a uint32_t pointer, and one for a uint8_t
  • What const means in each of its three positions in a declaration
  • What void pointers and NULL are for, and how to change a pointer through a pointer
  • How function pointers build command tables and callbacks
You need

7.1 What a pointer really is

A pointer is a variable that holds an address. That is the whole idea. Everything else in this volume follows from it.

Memory is a row of numbered boxes, as Volume 02 showed. A normal variable holds a value. A pointer holds the number of a box - and that lets you reach the value in it from somewhere else.

A pointer variable holding the address of another variable int reading = 42; int *p = &reading; 42 box 100 the value 100 box 264 the address of the value p points at reading *p = 77; /* goes to box 100 and writes there */ reading is now 77, although its name was never mentioned
Figure 7.1 - Two variables. reading sits in box 100 and holds 42. p sits somewhere else and holds 100 - the address of reading. Writing *p = 77 means go to box 100 and put 77 there, which changes reading itself.
In plain words

Think of a hotel. The guest is the value; the room number is the address. A pointer is a note with a room number on it. Give someone that note and they can knock on the door - without ever knowing the guest's name.


reading  = 42
*p       = 42   (follow the pointer)
the two addresses match: yes
an int takes 4 bytes, a pointer takes 8

after *p = 77, reading = 77
  inside try_to_change, the copy is now 99
after try_to_change, reading = 77
after really_change, reading = 99

after swap(&a, &b): a = 2, b = 1
Remember

A pointer has its own size, and it is the same for every type on a given machine. That is 8 bytes on this 64-bit computer, and usually 4 bytes on a 32-bit microcontroller. A pointer to a uint8_t and a pointer to a huge struct are the same size, because both just hold an address.

Quick check

What does a pointer variable actually hold?

Show the answer

Answer: B. A pointer holds an address - the number of a box in memory. Following that address with * reaches the value stored there.

7.2 & and *

& takes an address. * follows one. Those two symbols are the whole of pointer syntax.

You write It means
int *p; p is a pointer to an int
p = &reading; put the address of reading into p
*p the value at that address (dereference)
*p = 77; write 77 into the variable p points at
p == &reading do they point at the same place?

The reason pointers exist

Volume 01 showed that a function cannot change its caller's variable, because it gets a copy. Give it the address instead, and it can:


void try_to_change(int value)     /* gets a copy      */
{
    value = 99;                   /* changes the copy */
}

void really_change(int *value)    /* gets an address  */
{
    *value = 99;                  /* changes the original */
}

int reading = 77;
try_to_change(reading);           /* reading is still 77 */
really_change(&reading);          /* reading is now 99   */

That is why scanf needs an &, and why the classic swap works only with pointers:


void swap(int *a, int *b)
{
    int temp = *a;
    *a = *b;
    *b = temp;
}

swap(&x, &y);
Common mistake

Forgetting to give the pointer something to point at:


int *p;          /* p holds whatever rubbish was in that memory */
*p = 5;          /* writes 5 to a random address */

On a computer this usually crashes. On a microcontroller there is no memory protection, so it may quietly write into a variable, a register, or the stack. Always point a pointer at something before following it.

Quick check

int value = 10; int *p = &value; *p = *p + 5; What is value now?

Show the answer

Answer: C. *p reads the value at the address, so *p + 5 is 15, and writing that back through *p stores it in value itself. Pointers let you work on the original, not a copy.

7.3 Pointers and arrays

An array name is the address of its first element. Pass an array to a function and it decays into a pointer - which is why the function needs a count as well.


in main, sizeof(data) = 16   (the whole array)
inside the function, sizeof(values) = 8   (a pointer, not the array)
so a function must be told the count: 4

data == &data[0]? yes

data[2]      = 30
*(data + 2)  = 30
p[2]         = 30
*(p + 2)     = 30

total = 100

Those four lines print the same value, because a[i] is defined as *(a + i). Indexing is pointer arithmetic with friendlier syntax.

The size is lost at the door

sizeof gave 16 in main and 8 inside the function. The array became a pointer on the way in, and a pointer knows nothing about how many elements follow it. This is why every C function that takes an array takes a length too:


uint32_t total_of(const uint32_t *values, unsigned count);
Going deeper: the array is not a pointer

Inside its own function, an array is not a pointer: sizeof knows its real size, and you cannot point it somewhere else. It *converts* to a pointer whenever it is used as a value - passed to a function, assigned, or added to. The distinction matters exactly twice: when you use sizeof, and when you write &array, which gives a pointer to the whole array rather than to its first element.

Quick check

Why does a C function that takes an array almost always take a count as well?

Show the answer

Answer: A. Passing an array hands over the address of its first element. The size is not part of a pointer, so the function cannot work out how many elements there are unless you tell it.

7.4 Pointer arithmetic

Pointer arithmetic counts elements, not bytes. Adding 1 to a uint32_t * moves four bytes; adding 1 to a uint8_t * moves one.

Adding one to a pointer moves it by the size of what it points at uint32_t words[4]; const uint32_t *p = words; 0x11111111 0x22222222 0x33333333 0x44444444 address 200 204 208 212 216 p p + 1 p + 2 p + 3 p + 4 ? + 1 moves 4 bytes p + 4 may be compared against, but never followed
Figure 7.2 - Pointer arithmetic is scaled by the type. The uint32_t pointer steps four bytes at a time, so p + 1 lands on the next element rather than the next byte. The address one past the end may be held and compared, but never followed.

a uint32_t pointer steps 4 bytes at a time
a uint8_t  pointer steps 1 byte at a time

walking the words: 0x11111111 0x22222222 0x33333333 0x44444444

stop - start = 3 elements
after cursor++, *cursor = 0x22222222

words + 4 is a legal pointer to compare against, but *end is not allowed
that is why loops are written: it != end
end - words = 4

Walking with a pointer


for (const uint32_t *it = words; it != words + 4; it++) {
    use(*it);
}

This is how driver code walks a receive buffer. The pointer one past the end is allowed to exist - that is what makes the != test legal - but following it is not.

Remember

Subtracting two pointers into the same array gives a count of elements, not bytes. That is how ptrdiff_t values and lengths are worked out when walking buffers.

Quick check

uint16_t buf[8]; uint16_t *p = buf; What address does p + 3 hold, if buf starts at 1000?

Show the answer

Answer: B. Each uint16_t is two bytes, so three elements along is six bytes on: 1006. Pointer arithmetic always counts in elements and lets the compiler do the multiplication.

7.5 const with pointers

const can lock the thing pointed at, the pointer itself, or both. Read the declaration outwards from the name and it is never ambiguous.

Declaration What is locked Reads as
const char *p the characters p is a pointer to const char
char * const p the pointer p is a const pointer to char
const char * const p both p is a const pointer to const char

const char *text     -> can move, cannot write. now "two"
char * const fixed   -> cannot move, can write. now "One"
const char * const   -> neither. still "two"

Try to break any of those promises and the compiler refuses:


error: assignment of read-only location '*text'
error: assignment of read-only variable 'fixed'
error: assignment of read-only location '*both'

Which one you will write most

const char * - a pointer to data you promise not to change - by a long way. It is the standard way to pass a buffer *into* a function:


void uart_send(const uint8_t *data, uint16_t length);    /* will not change your data */
uint16_t uart_receive(uint8_t *buffer, uint16_t size);   /* will fill your buffer     */

The difference between those two lines tells a reader exactly which way the data flows, before they read a word of the implementation.

Quick check

You want a function that reads a buffer but must never change it. How do you declare the parameter?

Show the answer

Answer: A. Putting const before the type locks what is pointed at, which is the promise you want to make. A const pointer (the second one) only stops the function moving its own local copy of the pointer, which the caller never sees.

7.6 void pointers, NULL and double pointers

A void pointer is an address with no type attached. NULL is an address that deliberately points at nothing. And a pointer to a pointer is how a function changes where your pointer points.


void copy_bytes(void *dst, const void *src, size_t count)
{
    uint8_t *d = dst;             /* a void * converts with no cast, in C */
    const uint8_t *s = src;
    while (count-- > 0u) {
        *d++ = *s++;
    }
}

A void * can hold the address of anything, which is what makes memcpy work for every type. It cannot be followed directly: you convert it to a real pointer type first, because the compiler has to know how many bytes to read.

NULL: the pointer that points nowhere


if (buffer != NULL) {
    use(buffer);
}

Following a NULL pointer is undefined behaviour. On a desktop it is a crash with a clear message. On a microcontroller address 0 is often real memory - the start of flash, or the vector table - so the read quietly succeeds and returns nonsense.

Common mistake

Returning a pointer to a local variable:


char *make_label(void)
{
    char buffer[16];        /* lives on the stack */
    ...
    return buffer;          /* gone the moment this function returns */
}

The caller gets a dangling pointer into a stack frame that the next call will reuse. Return into a buffer the caller owns, or use a static buffer, and say in the comment which one it is.

Pointer to a pointer

To change where a caller's pointer points, you need its address - a uint8_t **:


copy_bytes moved 0xDEADBEEF into a uint32_t: 0xDEADBEEF

maybe is NULL
checking for NULL first is what stops a crash

cursor points at 1
after point_at_second, cursor points at 2

It is the same rule as before, one level up. To change an int, pass int *. To change an int *, pass int **.

Quick check

Why must a void pointer be converted before it is followed?

Show the answer

Answer: C. The address alone does not say whether the thing there is one byte or four, or how to interpret it. Converting to a real pointer type supplies that missing information.

7.7 Function pointers and callbacks

A function pointer holds the address of code instead of data. It lets a program decide, while it is running, which function to call.


void led_on(void);

void (*what)(void) = led_on;      /* a pointer to a function taking nothing, returning nothing */
what();                           /* calls led_on */

typedef void (*action_fn)(void);  /* a name for that type, so the rest stays readable */
action_fn next = led_on;

A table of commands


struct command {
    const char *name;
    action_fn   run;
};

static const struct command table[] = {
    { "on",   led_on  },
    { "off",  led_off },
    { "beep", beep    },
};

for (unsigned i = 0; i < ARRAY_SIZE(table); i++) {
    if (strcmp(word, table[i].name) == 0) {
        table[i].run();
    }
}

through a function pointer:
  led on
  beep

running every command in the table:
on ->
  led on
off ->
  led off
beep ->
  beep

the callback added the bytes up to 100

sizeof(action_fn) = 8 bytes on this machine

Adding a new command means adding a row to the table - no new if, no new case. This is how command shells, protocol handlers and menu systems are written in C.

Callbacks

A callback is a function you hand to someone else, to be called when something happens:


void uart_on_byte(void (*handler)(uint8_t));      /* register it once */

...
uart_on_byte(add_to_checksum);                    /* now every byte goes there */
Common mistake

Doing heavy work inside a callback that runs from an interrupt. The handler runs with the interrupt still being serviced, so everything else waits. Set a flag or push the byte into a buffer, and do the work in the main loop. Volume 11 covers this properly.

Going deeper: state machines in C

A table of function pointers is also how a state machine is written in C: one function per state, and an array of them indexed by the current state. Volume 12 builds one that way, and the State Machines from Zero course designs the machine itself before you write a line of it.

Quick check

What does a function pointer hold?

Show the answer

Answer: B. Code lives in memory too, usually in flash. A function pointer holds the address of its first instruction, so calling through the pointer jumps there.

What you learned

Key words from this volume

Every word below has a plain-English entry in the glossary.

Practice

Practice 1

Read the declarations

Say what each of these means, in words.


uint8_t *a;
const uint8_t *b;
uint8_t * const c = &value;
uint8_t **d;
Show the solution
  • a is a pointer to uint8_t. Both the pointer and what it points at can change.
  • b is a pointer to const uint8_t. It can be moved; the data must not be written through it.
  • c is a const pointer to uint8_t. It cannot be moved; the data can be changed.
  • d is a pointer to a pointer to uint8_t - used when a function must change someone's pointer.

The trick is to start at the name and read outwards: "c is a const pointer to uint8_t."

Practice 2

Write the function

Write a function that takes a buffer and its length, and returns the largest byte in it. Then say why it cannot work out the length itself.

Show the solution

uint8_t largest(const uint8_t *data, unsigned count)
{
    uint8_t best = 0u;
    for (unsigned i = 0; i < count; i++) {
        if (data[i] > best) {
            best = data[i];
        }
    }
    return best;
}

It cannot work the length out because data is a pointer, not an array. The size was lost when the caller passed it. sizeof data inside the function gives the size of a pointer, whatever the buffer really holds - 4 bytes on a typical microcontroller.

Note the const: this function promises not to change the caller's data, and the compiler enforces it.

Practice 3

Predict the output

What does this print?


uint8_t data[4] = { 5u, 10u, 15u, 20u };
const uint8_t *p = data + 1;

printf("%u %u %u\n", *p, p[1], *(data + 3));
Show the solution

10 15 20.

p points at data[1], so *p is 10. p[1] is *(p + 1), one element further on, which is data[2] - 15. And *(data + 3) is data[3], which is 20.

The lesson in it: a pointer can start anywhere in an array, and indexing is always counted from wherever the pointer happens to be.

Practice 4

Fix the dangling pointer

What is wrong with this, and how would you write it instead?


const char *status_text(uint8_t code)
{
    char text[16];
    snprintf(text, sizeof text, "code %u", code);
    return text;
}
Show the solution

text lives on the stack, and its frame is gone the moment the function returns. The caller receives a pointer into memory that the next function call will reuse.

Three standard fixes, in order of preference:


void status_text(uint8_t code, char *out, size_t size)
{
    snprintf(out, size, "code %u", code);
}

const char *status_name(uint8_t code)
{
    static const char *names[] = { "idle", "busy", "error" };
    return (code < 3u) ? names[code] : "unknown";     /* string literals live for ever */
}

A static char buffer inside the function also works, but only for one caller at a time. Never use one from an interrupt. gcc catches the simplest version of this bug with -Werror=dangling-pointer, as Volume 05 showed.

Interview corner

Interview question 1

Explain a pointer

"Explain pointers to someone who has never programmed."

Show the solution

"Memory is a long street of numbered houses, and every variable lives in one. A pointer is a piece of paper with a house number written on it. Give someone the paper and they can go to that house and change what is inside, even though they never knew the resident's name. In C, & asks for the house number and * means go to that house. That is why a function that takes a pointer can change your variable, while one that takes a copy cannot."

Interview question 2

Array or pointer?

"Is an array the same as a pointer in C?"

Show the solution

"No, although it converts to one. Inside its own scope an array is a block of memory: sizeof gives its real size, and you cannot point it somewhere else. The moment you pass it to a function it decays into a pointer to its first element, and the size is gone. That is why sizeof inside the function gives the size of a pointer, and why every array function takes a count. The place it matters most is the ARRAY_SIZE macro: it works in the function that declared the array, and silently gives nonsense anywhere else."

Next, Volume 08 groups data together: structs, the padding the compiler inserts between members, unions that read the same bytes two ways, and enums that give numbers names.