Pointers — you either love them, or you haven’t fully understood them yet. But before you storm off to the comment section now, pointers are indeed a polarizing subject and are both C’s biggest strength, and its major source of problems. With great power comes great responsibility. The internet and libraries are full of tutorials and books telling about pointers, and you can randomly pick pretty much any one of them and you’ll be good to go. However, while the basic principles of pointers are rather simple in theory, it can be challenging to fully wrap your head around their purpose and exploit their true potential.
So if you’ve always been a little fuzzy on pointers, read on for some real-world scenarios of where and how pointers are used. The first part starts with regular pointers, their basics and common pitfalls, and some general and microcontroller specific examples.
The Basics
Computer RAM, where your variable data gets stored, is a bit like the cubbyholes in a kindergarten, only each box has an address instead of a nametag. When you use variables in C, the compiler associates a particular location in memory (by address) with the variable name you give it. Pointers let you get in the middle of this association — and give you access to the memory address itself, rather than what’s stored there.
To make sure we’re on the same page, we start with a recap of pointer syntax, how to assign and use them, and how not to use them.
// regular int variable with initial value int value = 0x1000; // int pointer variable, pointing to the location of value int *pointer = &value; printf("value: 0x%x at address %p\n", value, &value); printf("pointer: %p at address %p\n", pointer, &pointer); printf("*pointer: 0x%x at address %p\n", *pointer, pointer);
For simplicity, we’ll assume some made-up addresses, and our first example will give us the following output:
value: 0x1000 at address 0x2468 pointer: 0x2468 at address 0x246c *pointer: 0x1000 at address 0x2468
&
gets the address of the variable that it’s prepended to, and *
“dereferences” a pointer — you can think of it requesting the value at the address that the pointer points to. We can see that value
resides at address 0x2468
and pointer
at address 0x246c
, with pointer
‘s content being the address of value
. That’s the whirlwind tour.
Storing numbers in memory isn’t very useful without knowing what type of number they are — how many bytes long the number is, whether it’s signed, and so on. Based on the values alone, we couldn’t tell which one would represent a memory address, an integer, or any other data, they are all just numbers and it depends how we use and reference them. This is why pointers themselves have their own types — the type of a pointer to the type data that is stored at the address. This is especially the case for pointer
as a regular variable, and *pointer
as a reference to another memory location. The difference becomes even more evident when we assign new values to either one of them.
// simple variable assignment value = 0x2000; // regular pointer dereference and assignment, same result as the statement before *pointer = 0x2000; // assigning to the pointer itself without dereferencing // technically okay, but compiler warns about assigning a regular integer to a pointer variable pointer = 0x2000; // explicit cast keeps the compiler happy, but.. pointer = (int *) 0x2000; // ..dereferencing is where things get tricky now *pointer = 0x2000;
On a system with a memory management unit, such as your average desktop computer, you will most certainly end up with a segmentation fault in the last line. What happened? In the third line, we wrote a new address (0x2000
) into the pointer. The compiler complained because we wrote an integer into a variable that should be a pointer to an integer, and we should have listened.
Instead, we just got rid of the error warning by explicitly telling the compiler to store 0x2000
as a pointer to an integer in the fourth line. When we tried to write a value into the address associated with this pointer, it was out of bounds. Who knows what lives in memory location 0x2000
? On a big computer, the MMU prevents you from writing to addresses outside your program’s territory, which could otherwise have fatal consequences. Naturally, this gets a lot more interesting on systems without such protection, like the vast majority of 8-bit microcontrollers. Assigning a hard-coded memory address to a pointer will be perfectly fine in that case, as long as you know what you are doing. On the other hand, if you have programmed for a microcontroller before, you have most likely used hard-coded pointer locations already without even noticing.
Microcontroller Registers
Let’s have a look at this simple piece of code that could be used to turn on a LED attached to an AVR ATmega328:
// file led.c #include <avr/io.h> DDRB = (1 << DDB1); // set up PB1 as output PORTB = (1 << PB1); // set output on PB1 high
DDRB
and PORTB
are two of the ATmega’s GPIO registers and defined as preprocessor macro by avr-gcc
. If we take a look at the preprocessor output, we can see what’s behind those two macros:
$ avr-gcc -E -mmcu=atmega328 led.c ... (*(volatile uint8_t *)((0x04) + 0x20)) = (1 << 1); (*(volatile uint8_t *)((0x05) + 0x20)) = (1 << 1); $
Yes, your microcontroller registers are in fact pointers to a hard-coded memory address. If you take a look at the Register summary section in the ATmega’s data sheet, you will find those two registers are mapped indeed to addresses 0x24
and 0x25
respectively. And being pointers, we can pretty much treat them like any other pointer.
Passing Registers to Functions
To a pointer, it doesn’t matter what kind of memory it is pointing to. Whether it references a local variable on the stack, or a register mapped into RAM, in the end, it’s all just data behind an address. And by that logic, if we can do something like some_function(®ular_variable)
in C, i.e. pass a pointer as parameter to a function, we should be able to do the same with registers.
We all love LEDs, and toggling LEDs is always a good example, but let’s assume that we cannot commit to one specific I/O pin that should control our LED, and we rather keep our options open to easily change that later on, maybe even during runtime.
// a simple struct that stores a GPIO port and pin number struct gpio { volatile uint8_t *port; uint8_t pin; }; // a variable for our LED GPIO pin struct gpio led; // assign the given port and pin to our led struct void led_setup(volatile uint8_t *port, uint8_t pin) { led.port = port; led.pin = pin; } // turn the LED on by setting its GPIO pin to 1 void led_on(void) { // PORTB |= (1 << PB1) *led.port |= (1 << led.pin); } // turn the LED off by clearing its GPIO pin void led_off(void) { // PORTB &= ~(1 << PB1) *led.port &= ~(1 << led.pin); } int main(void) { DDRB = (1 << DDB1); // set PB1 as output led_setup(&PORTB, PB1); // note the ampersand & led_on(); }
Instead of accessing the GPIO register directly, we now store a reference to that register in a global variable, and later dereference that variable, which will let us access the actual register again. By the time led_on()
is executed, the function doesn’t care anymore which I/O pin the LED is actually connected to. Admittedly, this particular example won’t quite justify the added complexity, we can achieve more or less the same with preprocessor macros. However, say we wanted to control multiple LEDs connected arbitrarily, or have a generic handler for polling multiple inputs, we could now store the references in an array and loop through it.
If we take another look at the preprocessor output, you may notice that there are now some redundant pointer operations going on, since &PORTB
is translated to &(*(volatile uint8_t *)((0x05) + 0x20))
, which is essentially the same as (volatile uint8_t *)((0x05) + 0x20)
. On the other hand, so is &*&*&*&(*(volatile uint8_t *)((0x05) + 0x20))
, and they will all result in the exact same binary file. In other words, it makes no difference, but &PORTB
seems a lot clearer than (volatile uint8_t *) 0x25
.
You probably noticed the volatile
keyword by now. If you want to read more about it, we dedicated a full article to it in the past.
Pointers Need a Home
The most important requirement to succeed with pointers and avoid segmentation faults is that they always have a large enough place inside memory that they can actually access. Again, on a system without MMU, any location in the memory is technically such a place, but it’s a lot stricter with memory protection, and we also need to avoid pointers that don’t point to anywhere specific.
// uninitialized pointer variable int *pointer; // dereferencing might access any arbitrary location in memory *pointer = 123;
Depending on some other factors, this may not necessarily cause a segmentation fault, but that doesn’t mean it’s without problems. To make your life easier, always ensure that a pointer has an actual location it is pointing to. However, once a pointer has a valid memory location associated to it, we can do pretty much anything we want with that space.
// have one type of struct struct something foo = { ... }; // make a different type of struct believe the first one is one of them struct something_else bar = *((struct something_else *) &foo);
This is a perfectly valid cast, and as long as accessing members of bar
won’t go beyond the size of foo
, we will be on the safe side. Whether such a type cast makes sense or not is of course a different story, and depends on the context, but pointers give us the freedom to cast as we please. Another way to use this freedom is assembling sequential data to one common buffer.
Assembling Data with Pointers
Say we need both of those struct
s concatenated into a single buffer. We could use a char[]
buffer and memcpy()
both struct
s into it, but then we use twice the memory. Instead, we can simply make that buffer believe that it’s actually two types of struct
s:
char buf[BIG_ENOUG_SIZE]; struct something *foo; struct something_else *bar; // point foo to the beginning of buf foo = (struct something *) buf; // point bar to the location after foo inside buf bar = (struct something_else *) (buf + sizeof(struct something));
Once again, pointers are simply memory addresses. The compiler will guide us to avoid the most obvious mistakes, but in the end, it’s up to us how we interpret what’s located at those addresses. And in case we don’t want to interpret that data at all, we can make use of C’s generic pointer type void *
.
The Void Pointer
In some cases, we are only interested in the memory address itself, and we might just want to pass that address around, for example to a function that doesn’t care how the data itself is arranged. If we use the void
pointer, we can simply assign to and from it without any explicit type cast necessary, which can help us keep the code a bit cleaner.
// actual storage for the struct struct something s; // assign it to void pointer, no cast needed here void *foo = &s; // assign it back to a specific pointer, no cast needed here either struct something *bar = foo;
void
pointers are commonly found in dynamic memory allocation functions, for example as return type for malloc()
and as parameter type for free()
, and any other generic memory accessing functions such as memcpy()
or fwrite()
. Keep in mind though, since it removes details of the data itself, dereferencing a void
pointer will result in a compiler error, unless we first cast to an explicit pointer type.
// regular struct member assignment s.some_member = value; // dereferencing void pointer, will result in compiler error foo->some_member = value; // cast before dereferencing void pointer, this is okay ((struct something *) foo)->some_member = value; // dereferencing explicit pointer type, no problems here bar->some_member = value;
Note the arrow operator ->
when dereferencing a struct
(or union
) to access its members. This is a shortcut C offers and is identical to (*variable).member
. Beware though that (*variable).member
is not the same as *variable.member
. The first, enforced by the parentheses, dereferences the pointer before accessing member
, while the second dereferences a pointer-type member
inside the struct
. This is naturally an easy source for errors, which the arrow operator helps us to prevent.
To Be Continued
This concludes our first part, and we merely scratched the surface of possibilities we have with pointers, which just shows how complex the seemingly simple concept of “it’s just a memory address” can really be. Next time, we continue with pointer arithmetic and some more complex pointer arrangements.
No comments:
Post a Comment