Skip to content

Instantly share code, notes, and snippets.

@nicknapoli82
Last active February 18, 2024 22:52
Show Gist options
  • Star 20 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save nicknapoli82/30f1c7b30e7b1561026d88655e5278f7 to your computer and use it in GitHub Desktop.
Save nicknapoli82/30f1c7b30e7b1561026d88655e5278f7 to your computer and use it in GitHub Desktop.
Everything in c is a pointer!

Everything in C is a pointer!

That’s right. I’m making this statement. This little write-up is an attempt at explaining how any why everything in the c programming language is actually just a pointer. This is simply an attempt at explaining how memory in the computer is arranged in a narrowed view and my real goal is to consider the most simple examples possible.

I will be focusing on x86 specifically.

Types in c (bits and bytes) - and pointers

I would feel pretty confident that you, by now, understand that the types in c are simply identifiers in how much space is used to represent a number. I have no interest in explaining the difference between signed vs unsigned integers. Nor am I going to explain how floating point numbers are represented. If you are interested in those things take a look at these two links. C data types and Floating-point numbers.

Since all types in the computer take some number of bits. We can logically say that each variable is going to use a defined number of bits. Much like the CS industry does though, we are going to refer to the bits needed as bytes though. So for every 8 bits used that is 1 byte.

I only intend to use simple types in this write-up. So we can qualify the size of each type used

typebytesbits
char18
int432
long864

Pretty simple. Lets try and keep it that way.

The only thing left to examine in this section though, is the pointer type. Pointers ‘point to’ a location in the computers memory. The literal number stored in a pointer is the memory location in the computer that the pointer looks at. So, when I create pointers that point to different types the size of the pointer itself is always the same.

typebytesbits
char *864
int *864
long *864

So why is the size of all pointers 8 bytes? Well, in this case I’m assuming that the computer we are using is a 64 bit machine. What that means is that the memory space in the computer is organized in a way where we need 64 bits to identify all the memory locations that the computer would need to access. If your computer were a 32 bit architecture, then you would only have ((2 ^ 32) - 1) = 4,294,967,295 addresses available. Yep, thats 4 gigabytes. Twenty years ago 32 bit computers were all we needed for the average computer. Now though, the laptop I’m typing this out on has 16 gigabytes available in RAM. So to accomodate our computers need 64 bits to identify every memory location, ((2 ^ 64) - 1) = 18,446,744,073,709,551,615. So that may take a little while for us to exceed that amount of memory.

This is a gross oversimplification of how manufacturers would design the memory architecture of a computer. So don’t beat me up in my above theoretical limitations of memory addressing.

What does this mean for our little pointer types? Well we need to store 64 bits worth of information for the computer to know what it is we are pointing to. So lets consider the most simple example I can think of.

int test = 5;
int *ptr = &test;

If we lay out our computers memory as an array of bytes. This is what it would look like in the computers memory.

[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

First we create our test variable, and add it to the stack. We are just adding the name test where the bytes in memory used are the test variable
[test, test, test, test, 0, 0, 0, 0, 0, 0, 0, 0]\

Then we assign the number 5 to our test variable, so now the 5s are where the test variable exists

[5, 5, 5, 5, 0, 0, 0, 0, 0, 0, 0, 0]

Next we create our ptr variable in memory.

[5, 5, 5, 5, ptr, ptr, ptr, ptr, ptr, ptr, ptr, ptr]

Then we assign the address of our variable test to pointer. Since test happens to be at the very first index in our array of computer memory. The address is actually 0. So I’m going to leave everything the same in that representation of the computers memory, but fill in the middle of the ptr locations with the value it stored. So the boundaries of our ptr variable are still shown, and what it is set to is shown in the middle.

[5, 5, 5, 5, ptr, 0, 0, 0, 0, 0, 0, ptr]

So although this is a really trivial example. All I’m attempting to identify here is when you create variables in the c language, that variable takes space somewhere in the computers memory. That variable has a defined size in bytes, and a pointer is simply a variable that looks at the address of another location in the computers memory. Also note that I can ask the computer for the address of a variable. That will come into play soon.

Memory Layout of an executable

So for some nitty gritty details I recommend looking here Memory Layout of C Program. I’m not going to consider all the pieces of an actual executable program. All I want to consider in this write-up is the stack. I may throw in some extra information with regard to the other parts of memory in the computer, like heap addresses. For simplicity though. Lets just work with the stack.

A tiny peek at where data goes

If you didn’t look at the Memory Layout of C Program link. Then I feel I should explain something briefly.

When you create variables. There are certain places those variables may be stored that are not on the stack. These are constant variables specifically. When you define a constant thing, it may need to go somewhere that can be recalled. For example

char *my_string = "This is a string";
char can_change[20] = {'\0'};

Because my_string is a pointer that points to the beginning of a string. You may wonder “where does that string get stored?” That string happens to be considered a constant value. As in it can not be changed. The c language defines that as a string literal. Why? This is because the string “This is a string” gets stored a region of memory of the program that is “read only”. You can’t change that string. It is exactly what you said it is in the code.

Compare that to the can_change array. That variable gets created on the stack as the program gets executed. The stack happens to be a memory regon that is marked “read/write”. You can manipulate that array any way you want, and there are no problems. Of course you can read both the in the same manner. It just happens that you can only write, or change, one where you can’t change the other.

The point of this small section is simply to identify that the location of where things gets stored does dictate what you can do with that memory.

A quick look at certain computer registers

When I say computer here. I’m specifically saying CPU. The things that is actually executing your code and doing something meaningful. The computer itself has tons of registers that individually do specific things. These registers may accumulate the math operations you would do on variables, and then store that back to a place in memory. There is a specific register that stores the memory location of where your program is in its execution. All of these registers combined allow the computer to do the things you asked, but each has a specific purpose.

If you want a view into how that works take a look at this video. See How a Computer Works

The specific computer register that I am interested in right now is called RBP. This computer register is called the Base Pointer. Yes it is a pointer. That RBP computer register sets the “root” or base of the current stack frame that your program is executing. This computer register is what tracks the stack as it grows and shrinks, and is ultimately the thing that acts as the base for all your local function variables to offset from and be found.

When your code calls a function. There are things that happen on the computer that create the new stack frame, or function call. The thing that we are concerned with in this write-up is the fact that the computer register RBP gets updated to move to the base of our new stack frame. When our current function is finished, or returns, the RBP register gets set back to the base of the calling function. So it is RBP that literally is the stack. In a sense. Every local variable that your functions create and use, are an offset from the RBP register. More on that soon.

Note: The RBP register is the base pointer for 64 bit addressing. There is also a computer register called EBP, but that is for 32 bit addressing. There are also the registers RSP and ESP, but for simplicity in this write-up I’m only considering RBP for a reference point. Just as an aside.

For more depth on computer registers take a look here Understanding C by learning assembly

Local Variables in a function

So. With all the little things above explained hopefully enough we can actually start looking at something useful. In order to look at how the computer is arranging the memory. Lets consider this really simple peice of code

int main(void) {
    int i = 0;
    int j = 1;
    i = i + j;
    return 0;
}

If I compile that code with the following command:

clang -masm=intel -O0 -S esb_test.c

The first line in the code is just our main function. We create two variables i and j, and add them… Thats it. So when I compile the code, and look at what the computer is actually doing. This is what that becomes in assembly.

main:
	push	rbp
	mov	rbp, rsp
	xor	eax, eax
	mov	dword ptr [rbp - 4], 0
	mov	dword ptr [rbp - 8], 1
	mov	ecx, dword ptr [rbp - 4]
	add	ecx, dword ptr [rbp - 8]
	mov	dword ptr [rbp - 4], ecx
	pop	rbp
	ret

The code really is useless, but it provides a window into how the compiler is setting up our local variables in the function. Notice that the variable names are completely gone. There is no i, and there is no j. So how does the computer know what those are?

First, as I mentioned above about the computer register RBP. We set that register to be the beginning of our stack frame, the main function. (push rbp) All of our local variables are then accessed as an offset from our RBP pointer. So: [rbp - 4] is our variable i, the type int is 4 bytes wide [rbp - 8] is our variable j, the type int is 4 bytes wide

Our i and j variables got replaced by the compiler as literal offsets, and those locations being on the stack in memory are then located using the RBP pointer.

The addition is done using the ecx register in the computer. So we move i[rbp - 4] into the ecx register, then add j[rbp - 8] in the ecx register, and move the result back into the memory location where i[rbp - 4] is.

The final thing we are doing is pop rbp. If we have multiple functions called, and placed on the stack. pop rbp moves the base stack pointer back to the calling function.

When the function starts and runs this is what it looks like in the computers memory.

...[j, j, j, j, i, i, i, i]...
rbp points here ----------↑

The rbp register is actually set to 8

I think with this simple example. We can see that our variables actually aren’t even really anything really in the program. We are using the rbp pointer to access the memory locations on the stack, and the compiler is setting things up right for us to ensure that our variables have the space they need.

Note: I’ve preterbed the actual assembly just a little bit to make it as easy as possible to understand. The real output has a few extra things in the assembly, but those things are outside of what I’m attempting to express right now.

Wait… You may be wondering. Why is the rbp pointer set to the end of the memory space? That is indeed a good question. So lets explore that just a little bit then.

How the stack shrinks and grows

Everytime your program calls a function, including the entry point main, we add that function on the call stack. As our program runs functions get called and then return. Every time a function gets called, a frame is added onto the call stack. When the function returns, that frame gets removed from the call stack.

Usually a stack is explained like a stack of plates. So to continue with that analogy lets just consider a simple program.

#include <stdio.h>

register void *rbp asm("rbp");

void fun(int number) {
    printf("In fun %i. rbp = %p\n", number, rbp);
    if(number == 5) {
	printf("Leaving fun %i. rbp = %p\n", number, rbp);
        return;
    }
    else 
        fun(number + 1);
    printf("Leaving fun %i. rbp = %p\n", number, rbp);
}

int main(void) {
    printf("In main. rbp = %p\n", rbp);
    fun(1);
    printf("Leaving main. rbp = %p\n", rbp);
    return 0;
}

That program produces this output when I run it on my computer

In main. rbp = 0x7fffb8190e70 
In fun 1. rbp = 0x7fffb8190e50
In fun 2. rbp = 0x7fffb8190e30
In fun 3. rbp = 0x7fffb8190e10
In fun 4. rbp = 0x7fffb8190df0
In fun 5. rbp = 0x7fffb8190dd0
Leaving fun 5. rbp = 0x7fffb8190dd0
Leaving fun 4. rbp = 0x7fffb8190df0
Leaving fun 3. rbp = 0x7fffb8190e10
Leaving fun 2. rbp = 0x7fffb8190e30
Leaving fun 1. rbp = 0x7fffb8190e50
Leaving main. rbp = 0x7fffb8190e70

Imagining the call stack, we would need to flip the output over. So it would look like this using only the first 5 lines of output.

fun 5
fun 4
fun 3
fun 2
fun 1
main

So main calls fun with 1, fun1 calls fun with 2, and so forth. Each function call is symbolically adding a plate onto the call stack. When each function is done, it is removed from the call stack. One at a time.

You are probably wondering about the line

register void *rbp asm(“rbp”);

That is simply creating a global variable so we can track where our rbp register is as our program runs. The gloabal variable rbp is the actual computer register, and gets updated as our program runs. So in each function call, I’m printing out the literal memory address that our base stack pointer is set to.

Notice that in each function call, rbp gets changed. We can say with certainty, that every time we call the fun function. Our rbp pointer is getting moved by 0x20, or 32 bytes in decimal. I’m not going to cover why every function call to our fun function is 32 bytes as that begins drilling into things that are not relevant to what I’m trying to convey.

The key thing to take away is. Every time you call a function in your code a new plate gets placed on the stack. Space is made for your local variables using the stack pointer registers in the CPU. Every frame on the call stack has its local memory preseved because only the active frame is being used. rbp gives some guarantee for that.

As your functions return though. We imagine a plate being removed from the stack. This is true in the sense that our base pointer gets popped back to the last location it was. The analogy falls a little short in that I think we envision the plate getting totally removed. This is not true. Memory is not getting removed. None of the memory gets reset. If you don’t take care to initialize your variables. Then you may end up with the memory that was used on a prior function call, or just trashed memory. I can illustrate how that may be a problem with this simple bit of code.

#include <stdio.h>

void fun1() {
    int i = 5;
    printf("In fun1 i is %i\n", i);
}

void fun2() {
    int i; // We never initialized our variable i
    printf("In fun2 i is %i\n", i);
}

void fun3() {
    long i; // We never initialized our variable;
    printf("In fun2 i is %li\n", i);
}

int main(void) {
    fun1();
    fun2();
    fun3();
}

Running this code produces the following

In fun1 i is 5
In fun2 i is 5
In fun3 i is 21474836495

As you can see. In fun2 and fun3. Because our base pointer is getting moved back and forth we end up using the same memory that the previous function call already used. fun2 proves that with the exact same local variable types that the memory is exactly the same. fun3 proves that if the size of the type is different, we are reading into the memory different and therefore a totally different number is printed.

Why is the base stack pointer decreasing in value?

You may have noticed in the last section that when I recursively called the fun function, that our rbp pointer was getting moved subtractively in memory. I just wanted to hit on this really quick. The reason why that is happening is based on an architecture decision for my computer. Intel in this case. You will find this on many different computer designs, and the idea behind is provides a little memory safety as your program executes. I’m not going to go into depth on this topic as it is to far removed from the point of this little write-up.

As our stack continues to grow. In my computers case. The stack pointer is actually moving closer to memory address 0.

Arrays are just a pointer

Its true. When you declair an array, the array itself is just a pointer. Much like was observed in how the base stack pointer works with local variables. An array works the same. In this case, though, we can consider the base of the array as the base pointer. When you use array notation in c you are simply saying start at the base of the array and jump forward in memory this far. So if you have.

char my_array[4] = {1, 2, 3, 4};

Then you can say my_array[2]. All that says is; Start at the beginning (base) of the array and jump forward in memory 2 bytes. (sizeof(char))

We can use pointer notation to get the same effect. So

my_array[2] == *(my_array + 2) // true

Since we know that an array is a series of bytes lined up in memory back to back. We can look at the memory in a particular way.

char my_array[4] = {1, 2, 3, 4};
[1, 2, 3, 4]

Notice the order of how the array is created. It is in the exact opposite way that our call stack is growing. That means the base pointer for my_array is actually closer to 0, and as we index further into the array we are going toward the beginning of our stack memory.

~0 <- [1, 2, 3, 4] -> beginning of stack~\\
~      ↑-- my_array points here~

This would make sense though. As we are starting at the beginning of the array and working in an additive manner.

This means, to the compiler my_array is just the address that the array starts at. Thats it. I actually don’t feel I need to explain more here. Just remember that once compiled everything is still reference from the rbp register.

When does a segmentation fault occure

Before we runaway on purpose and for exploratory sake. Let consider something restrained that doesn’t fail first. Consider this code

#include <stdio.h>

int main(void) {
    char stop = 127;
    long trashed = 0x090a0b0c0d0e0f10;
    char arr[4] = {1, 2, 3, 4};

    printf("Our trashed variable is %lx\n\n", trashed);
    for(int i = 0; arr[i] != 127; i++) {
	printf("We have looped %i times, arr[%i] is %i\n", i, i, arr[i]);
	arr[i] = i;
    }
    printf("\nOur trashed variable was 90a0b0c0d0e0f10, but now is %lx\n", trashed);
}

Consider the order that our variables are declaired. Since the arr variable is declared last, that means as we walk through the array we are increasing in the computers memory addresses. I am intentionally running past the end of the array, overwriting all the values that exist in our trashed variable, and finally stopping at the stop variable. So what does the output look like?

./array_overrun
Our trashed variable is 90a0b0c0d0e0f10

We have looped 0 times, arr[0] is 1
We have looped 1 times, arr[1] is 2
We have looped 2 times, arr[2] is 3
We have looped 3 times, arr[3] is 4
We have looped 4 times, arr[4] is 16
We have looped 5 times, arr[5] is 15
We have looped 6 times, arr[6] is 14
We have looped 7 times, arr[7] is 13
We have looped 8 times, arr[8] is 12
We have looped 9 times, arr[9] is 11
We have looped 10 times, arr[10] is 10
We have looped 11 times, arr[11] is 9
We have looped 12 times, arr[12] is 0
We have looped 13 times, arr[13] is 0
We have looped 14 times, arr[14] is 0

Our trashed variable was 90a0b0c0d0e0f10, but now is b0a090807060504

The way our memory lays out on the stack would look like this, where t is the trashed variable, and then stop

[arr[0], arr[1], arr[2], arr[3], t, t, t, t, t, t, t, t, 0, 0, 0, stop]

The whole point of this little example is. Your program will not fail due to segmentation fault simply by writing past the end of an array. Because the array is litterally just a pointer, we can index into the array as far as we want. So long as we own the memory, we can write over it. On accident, or on purpose.

There is a reason that there is a gap in memory between our stop variable, and the end of our trashed variable. This is due to memory alignment, and decisions that the compiler is making for us. I’m saying so explicitly because this gap is oberservable in the output of the program. So I may as well explain there is a gap, there is a reason for that gap, but the reason why is a little beyond the scope of what I want to explain here.

What happens when we have a runaway loop that walks through an array then? When does a segmentation fault occure? Well, as long as the computer decides we own the memory, it doesn’t care what we do with it. So consider this.

#include <stdio.h>

#define TRUE 1

int main(void) {
    char gonna_fail[1];
    int count = 0;
    while(TRUE) {
	printf("Count = %i\n", count);
	gonna_fail[count++] = 1;
    }
}

What is the final count before the program fails?

...
Count = 8164
Count = 8165
Segmentation fault (core dumped)

Hmmm. So thats a lot. I mean, we managed to go through 8165 bytes before our code failed. That is increasing the memory address though.

What happens if I run the exact same program, but change count++ to count–? Here is the output.

...
Count = -8377157
Count = -8377158
Count = -8377159
Count = -8377160
Segmentation fault (core dumped)

OK. So pretty much 8 megabytes. Since I mentioned this little section is exploratory. I’ll admit. I wasn’t certain what the result was going to be. There are details for our program execution that I’m not 100% aware of because I’m not asking the operating system. Such as where does the stack memory start with relation to the program. I can set very specific things with clang to tell it and the linker more specifically what I want, but I was just rolling with the punches here and seeing how far we can go when we just run to fail byte by byte.

What I’m observing is there is space in memory in how the operating system is laying out our memory at run time. So when we are running additive with our array its not suprising to see that we have some leeway. What is not suprising to me though is when we run through memory in a subtractive manner, we are running through the actual stack memory. Which is much, much more. We have found that apparently clang, and the linker are giving us roughly 8 megabytes of stack memory at runtime to use.

Whats the point of that you may wonder. Well, the point is to provide something meaningful to observe in what creates a segmentation fault. Which is literally touching computer memory that is not yours. A segmentation fault is not simply that you have run outside the boundary of your array. The computer doesn’t care that your array is only a length of 4. The computer only cares that you play in your own playground.

The struct type is just a pointer

Really. All a struct is; Is a layout of data for you the programmer to more easily conceptualize a grouping of data packed into a single object. In fact when compiled the memory is arranged in a similar grouping. Its just grouped as a chunk of memory. Each field in the struc is just a piece of memory and that piece of memory is its own local variable. So there is no difference to the computer between a local variable, and a struct. So what do I mean?

Consider this simple struct

struct Thing {
    int one;
    int two;
    long long three;
}

Now there isn’t much to that struct, but when I create that struct as a local variable in a program the compiler take that Thing and understands where each field is based on the offset from the origin (root) of that Thing. So if I were to create a Thing simply as

int main(void) {
    struct Thing test;
    test.one = 100;
    test.two = 200;
    test.three = 300;
}

Then this is what it looks like to the computer in assembly.

	push	rbp
	.cfi_def_cfa_offset 16
	.cfi_offset rbp, -16
	mov	rbp, rsp
	.cfi_def_cfa_register rbp
	xor	eax, eax
	mov	dword ptr [rbp - 16], 100
	mov	dword ptr [rbp - 12], 200
	mov	qword ptr [rbp - 8], 300
	pop	rbp
	.cfi_def_cfa rsp, 8
	ret

What we see is the compiler is resolving each field in the struct as separate local variables. There is nothing more to it. Our same rbp stack pointer is being used as an offset to the actual memory address where each Thing is, and we have full control of those variables in the same manner of the struct just as we would with any local variable.

No matter the complexity of the struct itself the compiler is breaking it down into simpler forms of memory access. We are still just using our stack pointers to access and manipulate this memory. Arrays as discussed above still lay out in memory in the same way, and everything else contained in the struct.

Functions are just a pointer

I knew it was going to happen at some point in the write-up. We were going to have to deviate… Just slightly from our observing the stack memory. I feel very confident that you have probably written many functions by now. Some call other functions, some use recursion as my simple example did above in discussing the call stack. Which may leave us to wonder. How is it that the computer knows where these functions exist?

We are still going to observe the stack, but the full truth is our function code does not exist on the stack itself. So we are going to need to look at a different region in computer memory. Hopefully along with this we can tie everything together in the next section as a final hurrah.

So lets consider this very simple program.

int add(int a, int b) {
    return a + b;
}

int main(void) {
    int result = add(5, 6);
}

So in assembly, the compiled version looks something like this. Note. I’m leaving in more of the full program because we need to see just a bit more now.

	.text
	.intel_syntax noprefix
	.file	"add_function.c"
	.globl	add                     # -- Begin function add
	.p2align	4, 0x90
	.type	add,@function
add:                                    # @add
	push	rbp
	mov	rbp, rsp
	mov	dword ptr [rbp - 4], edi
	mov	dword ptr [rbp - 8], esi
	mov	eax, dword ptr [rbp - 4]
	add	eax, dword ptr [rbp - 8]
	pop	rbp
	ret

main:                                   # @main
	push	rbp
	mov	rbp, rsp
	sub	rsp, 16
	mov	edi, 5
	mov	esi, 6
	call	add
	xor	ecx, ecx
	mov	dword ptr [rbp - 4], eax
	mov	eax, ecx
	add	rsp, 16
	pop	rbp
	ret

So now we are looking at possibly a few new things so I think I owe a little bit of an explanation as to what is going on here. When our program first gets executed the operating system is doing a little bit of work to set things up for us. Its setting up the Instruction, Stack, and Data memory regions and putting the correct things in their place. Its also wiping out then entire memory allocated for your stack memory, and setting it all to 0 for you.

If you note at the top there is a text marker. Though its not noted in this output, that text is the actual instructions for your code to run. When your program starts, there is an address fixup that the operating system does. Which means that the memory addresses of each function get set in the globl section of the program, also noted in the output. The operating system is picking a place for your executable instructions to be put, and marking up the beginning address for each of the functions. So in the global data the actual memory address is written marking where our add function starts in the computers memory.

Now with all things in their right place we can start the main function. We set our rbp pointer first. Then notice we are setting the rsp register -16. The rsp register is the actual stack pointer for our program, where the rbp register is the base pointer. So rsp -16 is setting up our function call in main.

The edi, and esi registers get set to the values 5 and 6, which are are our arguments that get passed to the add function. (Note) this is not always the case, but the compiler is doing a simple thing because the computer is capable of storing all the arguments to the function in the cpu registers.

Then we say call add. What the call instruction does is jump our instruction register in the computer to the first line of the add function. All of our memory is set up before hand by moving the rsp pointer to the correct memory location to add a new function onto the call stack.

I’m not going to explain the actual add function, because my goal for the moment was simply to express how the computer is setting things up to call a new function. Notice specifically that still yet, the function itself is simply just another location in memory and we are setting the instruction register in the cpu to start executing that function with the call instruction.

Hidden from you are a few additional pieces of data in this call stack though. These additional pieces of data are what tell the computer to to return things back to the calling function. This is done with the ret instruction, but I feel that is a bit out of scope of what I’m trying to explain in this write-up. If you want to dig a bit more into calling conventions and how they work take a look here C Calling Convention.

Pointers are just a pointer

Yep. I think you probably saw this one coming. The add function in the last section hints at that, and of course I neglected to discuss the add function in any detail. Before we start diving in though. Lets look at a simplified explanation. I just want to make sure you understand what a pointer is to begin with.

Consider this simple snippet of code (Note in this small section we are not considering the stack. We are just arranging memory in byte order as an array would appear)

int a = 5; 
int* b;
int c;
b = &c

Crudely drawn. If you think about memory like a char array. Then

[0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

are all the bytes lined up in memory So when you say int a. That variable occupies 4 bytes of space on the stack

[a, a, a, a, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]

Then you create int *b on the stack. Because b is a pointer it occupies 8 bytes of memory (assuming a 64 bit computer)

[a, a, a, a, b, b, b, b, b, b, b, b, 0, 0, 0, 0]

Then you create your variable c on the stack which also occupies 4 bytes.

[a, a, a, a, b, b, b, b, b, b, b, b, c, c, c, c]

So that is a representation of your variables in memory. It would be right to say &a address of a is at memory location 0, &b is at memory location 4 &c is at memory location 12. So assigning the address of c to the pointer b would say b = 12. But that is where b points to. b holds the value 12 as a pointer that points to the variable c in memory.

The key here is, the address of your variable b is at memory location 4, but as a pointer it looks at memory address 12. b needs 8 bytes to store the memory address of what it is looking at (pointing to).

We can then ask b “What is the value of the thing you point to” by dereferencing b, and we can assign something to the variable it points to.

*b = 10;
c == 10 // true

We have changed the value where c is, so the value of *b == c, which is the number 10.

Finally. A quick observation just to compare the address of b and c.

&b == &c // false
b == &c // true

So although trivial. The point of this example is an attempt at simplifying the computers memory in a way where we can see what pointers are, and what they are doing.

So now that we have discussed that. Lets take a look at a simple code example as the computer would view it.

int main(void) {
    int test = 5;
    int *c = &test;
    int result = *c;
}

What that translates to in assembly is simply

main:                                   # @main
	push	rbp
	mov	rbp, rsp
	xor	eax, eax
	mov	dword ptr [rbp - 4], 5
	lea	rcx, [rbp - 4]
	mov	qword ptr [rbp - 16], rcx
	mov	rcx, qword ptr [rbp - 16]
	mov	edx, dword ptr [rcx]
	mov	dword ptr [rbp - 20], edx
	pop	rbp
	ret

The key in this assembly is the instruction lea. What that does is take the address specified and move it into a register. In our case the rcx register. We can then store that address in our variable c, which is at address [rbp - 16]. I’m uncertain if you have noticed, but the assembly is explicitly using the terms above dword ptr and qword ptr. What these are doing is telling the computer how many bytes (the size) at a particular address should be loaded into a register. So our dword in this case is telling the computer to load 4 bytes into a register. qword on the other hand means 8 bytes respectively.

The final assignment for result takes our address [rbp - 16] as a qword and moves it into the rcx register. Notice though that when we do the final assignment we are not using the rbp register. We are solely using rcx as the address of memory.

Seeing this simple thing, is exactly what pointers are in the c program. You are storing a memory address that “points” somewhere else into a location in memory. We can then use that location in place of rbp, or any stack pointer directly.

Conclusion???

What I’m hoping you see having made it this far is simply all of the types and semantics we are using in the C language breaks down to very simple constructs in assembly and to the computer. That the fundamental pieces of how the computer is operating is based on the registers, and that everything that you create in C programs are one thing only. That one thing is a location in memory, and everything is referenced from some known point (pointer) to access that location as an offset.

In some way this is exactly what you are doing when you are using pointer arithmetic in the C language. Look here, and offset so far in memory this way.

What I’m really hoping though. Is that this creates more questions and curiosity rather than provide explicit answers. Its simply a matter of digging. How deep? Thats up to you.

@josefrichter
Copy link

Thank you for this write-up and the links! Made me read a bit about the assembler and actually makes C easier to understand.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment