
Pointers are a concept that, when first encountered, may be hard to wrap your head around. As is the case with many concepts in programming, it is useful to understand the underlying mechanisms. Looking under the hood a useful way to learn how to use something, it’ll also help to reason about behaviour that may not be immediately obvious.
This article will explore what pointers are, how to use them, and what is going
on beneath the surface. If this is your first time encountering things like
&variable
, type* varname
, or even type** varname
this article will
help you make sense of that.
Memory
To understand pointers, we first need to make a detour into how memory works.
When your computer starts a program, it is loaded from disk and into memory.
Whenever you do something like initialise a variable, this will allocate some
space in the computer’s memory to hold the information. Depending on the size
of this variable, more size is needed. For example: an 8 bit integer takes up
(you guessed it) 8 bits of memory (or 1 byte). What this means is that inside
the computer memory, there are 8 memory cells set to a 1 or 0 depending on the
value of the integer. Something like: 0011 1100
which is the integer number
60.
The computer needs to have some way of tracking which pieces of memory represent what. To be able to do that, each piece of memory has its own address:
Address | Value |
---|---|
0x0001 | 0011 1100 |
0x0002 | 0000 0000 |
0x0003 | 0000 0000 |
… | … |
0xFFFF | 0000 0000 |
In this example, we can see that our 8 bit integer is stored in the block of
memory with address 0x0001
(note the 0x
at the start indicates the address
is formatted in hexadecimal, as
starting with 0b
would mean binary.).
So what happens when we have a piece of data that is too large to fit into those
8 bits of computer memory? For example, a 32 bit integer
0010 0000 0101 0100 0010 1010 0000 0100
representing the number 542386692?
It is simply put into multiple blocks of memory cells. The computer remembers
the address of the first of these blocks, and knows from the type (in this case,
a 32 bit integer) how many blocks it needs to read to get all the data.
Let’s see what it looks like when we assign our 32 bit integer to a piece of
memory following our 8 bit integer:
Address | Value |
---|---|
0x0001 | 0011 1100 |
0x0002 | 0010 0000 |
0x0003 | 0101 0100 |
0x0004 | 0010 1010 |
0x0005 | 0000 0100 |
… | … |
0xFFFF | 0000 0000 |
As we can see, the addresses 0x0002
up to 0x0005
are now used to store our
32 bit integer number, using up 4 of the 8 bit memory cells.
We only use 0x0002
to refer to the variable, the computer can figure out it
needs to read all four blocks from the type (after all, it needs to read 4 sets
of 8 bits to complete the 32 bit type.)
Pointers
So, back to pointers. We’ve really done the legwork by now. A pointer is just a memory address.
Reference
We use the &
or reference
operator (we can read this as
“address of”) to get the memory address of whatever variable we put it in front
of.
For example: we can read &someVariable
as “Address of someVariable”.
De-reference
The *
, de-reference
(we can read this as “value pointed at by”) to get
the content of the memory an address (or pointer) points at.
For example: we can read *somePointer
as “The contents of the memory at the
address somePointer points to.”
Examples
Let’s have a look at an example in C++:
#include <iostream>
int main() {
// First assign the number from our example to a 32 bit unsigned integer:
uint32_t number = 542386692;
// Then print it's address using the reference operator &:
std::cout << &number << std::endl;
return 0;
}
This piece of code prints out: 0x505278
, which is the memory address at which
the value of our number is stored. Now try and see what happens if you change
that output line:
std::cout << *&number << std::endl;
As you may have guessed, it just prints out the value of our number 542386692
.
What we’re doing is using the &
operator to get the memory address of the
number, and then immediately getting the value at that address with *
.
In plain language, *&number
reads as “The value pointed at by the address of
number”.
flowchart TD
A[0x505278]
B[542386692]
A -->|Points at| B
B -->|Is pointed at by| A
Pointer types
We’ve now seen we can get a pointer to a piece of memory with the &
operator,
and how we can then get the actual value that pointer points at by
de-referencing it with *
. The last piece in the puzzle is understanding
pointer types. One of the reasons we use pointers is so that we can pass them
around in our program. That means that if we have a big piece of data stored
somewhere in our memory, we can simply pass a pointer to that data around,
rather than all the data itself. This is much faster as it means we only have
to copy a small pointer.
To represent a pointer as a variable we use a pointer type rather than just the
address. For example, a pointer to a 32 bit unsigned integer is declared as
uint32_t* <variable name>
.
Side-note: There is some discussion about whether to write it as I did, or as
uint32_t *<variable name>
, or even asuint32_t * <variable name>
. As far as I’m concerned, it is part of the type, and should therefore be part of the type definition. You may see any of these styles, and it really comes down to personal preference or any style guides. The main thing is to be consistent.
The reason we can’t just pass the memory address around as a number is that the computer needs to know what type the pointer is pointing to. As we’ve seen in our explanation of memory, the type will tell the computer how many sections of memory it needs to read. If the type is wrong, the computer may try to read memory that stores different variables, something to be very weary of. We’ll discuss this a bit more at the end of this article.
Let’s have a look at pointer types by modifying the little program we looked at earlier:
#include <iostream>
void test(uint32_t* address_of_number) {
std::cout << address_of_number << std::endl;
std::cout << *address_of_number << std::endl;
}
int main() {
// First assign the number from our example to a 32 bit unsigned integer:
uint32_t number = 542386692;
// Get the address of number and store it.
uint32_t* address_of_number = &number;
// Then print the address:
std::cout << address_of_number << std::endl;
// Then get the value by de-referencing our pointer type:
std::cout << *address_of_number << std::endl;
// Or pass it into a function:
test(address_of_number);
return 0;
}
Which prints:
0x505278
542386692
0x505278
542386692
As you can see, we can use a pointer type to store a pointer in a variable, and then pass it around like any other variable. We can even define a function that takes a pointer type as an argument.
Modifying the value pointed at
Let’s now modify our code a little bit more, and see what happens when we write some data to the referenced memory using the pointer:
#include <iostream>
// A function that takes a pointer.
void test(uint32_t* address_of_number) {
// We can't change the pointer, but we can change what it points to.
*address_of_number = 42;
}
int main() {
// First assign the number from our example to a 32 bit unsigned integer:
uint32_t number = 542386692;
// Get the address of number and store it.
uint32_t* address_of_number = &number;
// Then get the value by de-referencing our pointer type:
std::cout << *address_of_number << std::endl;
// Pass it into the function.
test(address_of_number);
// Then print it again:
std::cout << *address_of_number << std::endl;
return 0;
}
This time the program prints out:
542386692
42
We modified the value referenced by the pointer inside the function rather than returning it from the function. Whilst this may be the right approach when you want to avoid passing around large chunks of memory, it is better to avoid doing this. This causes a side effect which will make your program harder to reason about, and harder to test.
Pointers to pointers
One other thing we can do is create a pointer to a pointer:
#include <iostream>
int main() {
// Let's assign two numbers
uint32_t number_a = 542386692;
uint32_t number_b = 123456;
// Get the addresses.
uint32_t* address_of_number_a = &number_a;
uint32_t* address_of_number_b = &number_b;
// Then assign it to another variable.
uint32_t** address_of_address_of_number = &address_of_number_a;
// Print it out.
std::cout << "&address_of_address_of_number: " << &address_of_address_of_number << std::endl;
std::cout << "address_of_address_of_number: " << address_of_address_of_number << std::endl;
std::cout << "*address_of_address_of_number: " << *address_of_address_of_number << std::endl;
std::cout << "**address_of_address_of_number: " << **address_of_address_of_number << std::endl;
// Re-assign and print out again
address_of_address_of_number = &address_of_number_b;
std::cout << "&address_of_address_of_number: " << &address_of_address_of_number << std::endl;
std::cout << "address_of_address_of_number: " << address_of_address_of_number << std::endl;
std::cout << "*address_of_address_of_number: " << *address_of_address_of_number << std::endl;
std::cout << "**address_of_address_of_number: " << **address_of_address_of_number << std::endl;
return 0;
}
Which prints out:
&address_of_address_of_number: 0x5052f8
address_of_address_of_number: 0x505300
*address_of_address_of_number: 0x505308
**address_of_address_of_number: 542386692
&address_of_address_of_number: 0x5052f8
address_of_address_of_number: 0x5052fc
*address_of_address_of_number: 0x505304
**address_of_address_of_number: 123456
As you can see, we can create a pointer to a pointer, and do the same things we can do with a normal pointer. This is useful when we want to change the address a pointer points to inside a function. You’ll rarely need this, so if you do encounter it have a good think on whether there is a better way to achieve what you’ve set out to do.
Safety
Now I’d be remiss if I didn’t touch on safety in an article about pointers. Whilst we didn’t go into pointer arithmetic, or looking at arrays and how they use pointers, we will have a brief look at pointer (or memory) safety. This is a big topic, and I’d encourage any developer to explore it further. As memory errors like buffer overflows or buffer over-reads are historically responsible for the bulk of vulnerabilities found in software.
Let’s say we go back to our memory map from earlier section, but this time we are storing some text data as well:
Address | Value |
---|---|
0x0001 | 0011 1100 |
0x0002 | ’s' |
0x0003 | ’e' |
0x0004 | ‘c’ |
0x0005 | ‘r’ |
0x0006 | ’e' |
0x0007 | ’t' |
… | … |
0xFFFF | 0000 0000 |
In address 0x0001
I have stored some 8 bit integer. And there is some other
user’s secret password that happens to be stored in the bit of memory next to
it. Let’s simulate what could happen:
#include <iostream>
int main() {
// Let's define some variables.
uint8_t number = 0b00111100;
std::cout << (int) number << std::endl;
uint8_t sometext[6] = {'s','e','c','r','e','t'};
// And then simulate a chunk of 8 bytes of memory containing both:
uint8_t memory[8] = {
number,
sometext[0],
sometext[1],
sometext[2],
sometext[3],
sometext[4],
sometext[5],
0
};
// And then create a pointer pointing at our number in that block of memory:
uint8_t* numberPointer = &memory[0];
// All good!
std::cout << (int) *numberPointer << std::endl;
// We did some stuff with pointers, and accidentally re-assigned out 8bit int pointer to 64 bits.
uint64_t* newPointer = (uint64_t*)numberPointer;
// And then print it to our user:
std::cout << *newPointer << std::endl;
}
Which outputs:
60
60
32762639266706236
Oops, what is this big number we accidentally printed to the user? If we use an online converter from integer to characters we get this:
terces<
Where the <
is the character representation of out 8bit integer. And the other
character is the 0
. In the middle we find the password of the other user that
we just leaked by accidentally accessing the wrong bit of memory… Not good.
This example seems a bit far-fetched, but there have been many cases where bugs very similar to this have caused serious issues, such as the heartbleed bug. The takeaway: be careful manually manipulating memory, or even better: avoid it altogether.
Conclusion
I hope this article helped you wrap your head around how pointers work and how they can be used. And maybe you learnt something about memory in the process. We only looked at things from a very high level, and glossed over lots of detail. If you want to dive a bit deeper, I’d recommend you google some of the following terms:
- Virtual memory
- Stack and heap
- Pointer arithmetic
- Strings in C
- Pass by reference vs pass by value vs pass by pointer
And dive a bit deeper into something that is very relevant to anyone writing software. Even if you work in high-level languages like Python or JS, it will still use these mechanisms under the hood - and it is good to understand them.