A Student’s C Book: 1.5. Pointers

Level 1. Introduction to C

1.5. Pointers

How to think about a computer’s memory

You may have already had a suspicious look at your personal computer at home and wondered how that thing’s memory system works. Well, here is how you should think about it: a computer’s memory is like a tape with many individual cells on it. When you declare an arbitrary variable x (that is, int x;), a random cell is allocated to hold x‘s value. When you define x by initializing it with some value (that is, x=3;), that value is written onto the corresponding cell on the tape. In the meantime, your computer refers to that variable x by the index of its tape cell — that is, it does not actually know about the human-readable variable name x when the program is run after the compilation. In fact, it is the compiler (e.g., gcc, clang, etc.) that translates the human-readable names to machine-readable memory/tape indices. The machine-readable version of your C program, which is also called an executable or a binary file, may seem somewhat more complicated at first glance but is easier to understand once you know the basics. It is outside the scope of this tutorial series, so I will not cover it in detail here. However, I still think it is necessary for you to familiarize yourself with it just a little bit to understand today’s topic well.

Figure 1. A simple illustration of a computer memory as a tape with 100 cells.

Computers can only read files in binary1. What this sentence means is that it cannot understand a text file written in a human-understandable language such as English, Italian, or Azerbaijani. Not only those languages, but it cannot understand any piece of text that uses any character other than 0 and 1. This is what reading in binary means — you can only understand 0s and 1s… and nothing else. Since a C program is a text file that has human-readable characters and keywords, such as int, main, printf, return, and so on, the compiler needs to translate all of these into an executable or a binary file with 0s and 1s. For example, the compiler needs to translate a statement like int x = 3; into something like “11000111 11110101 00000011” that is machine-readable (i.e., readable by the computer). The compiler knows how to translate C statements into binary because it knows what your computer is capable of doing when it reads certain binary words (i.e., a binary string with 8 zeros and ones). For example, there is a special binary word 11000111 that your computer understands as “writing something down onto some tape cell.” We’ll call this word the mov instruction2. The next two words that come after the mov instruction determine the value to be written and the index of the tape cell, respectively. So, the binary sentence/statement “11000111 11110101 00000011” is understood by the computer as “writing the value 3 (00000011 in binary) into the memory location 11110101 (the tape cell corresponding to the variable x)”. Although there is much more to the machine-readable instructions and executable binary files, I will stop right here because I think you now have just enough understanding of these matters to understand what the pointers are. However, before moving into the pointers, there is one last thing that I want to cover. I would like to show you how memory is managed in a computer by using instructions such as mov.

Now you know that saying something as simple as int x = 3; is read by the computer as 110001111111010100000011. Recall that in the binary instruction, the second word is the address of the cell on the tape that is translated by the compiler because, naturally, the computer must know where to put the value 3. In contrast, when you type something like int y = x;, you don’t actually want to assign x‘s address on the tape to y; instead, you want to assign x‘s value to y. Due to this, when you put x on the left-hand side (LHS) of an assignment (e.g., x=3;), the value evaluated from the right-hand side (RHS) is written (by using the mov instruction) onto the corresponding tape cell; even if some other value might have written to the same place previously, the new value replaces the old. When you put x on the RHS (e.g., int y=x; or z=x+1;), its value is used to evaluate the expression, not the location or cell number on the tape. When you define a function with parameters (e.g., void f(int a, double b){ ... }), one empty tape cell is allocated for each parameter of the function. When you pass x into a function as an argument (e.g., f(x, 3.14);), x‘s value on the tape is copied to the cell corresponding to the relevant function parameter — so, functions work with their own set of tape cells specifically allocated for their parameters. That’s why modifying a variable’s value in a function, into which it is directly passed as an argument, is not possible. All of these are illustrated in the figures below.

Memory address of my variable

You have already learned a few things about computer memory and how values are stored in memory cells indexed/pointed by certain memory addresses. In programming, we call these memory addresses pointers since they point to specific memory locations. As you might have already remembered from the previous posts, you can get the memory address of your variable by putting the ampersand sign (&) in front of it. Recall that your variable is an alias for some memory address, which can be interpreted as both the address and the value stored in it, depending on the case. A variable has two possible interpretations:

  1. If something is assigned to a variable, then the memory address of the variable is extracted and used by the computer. That is, avariables on the LHS of an assignment statement is used by the computer to identify its location/pointer to store the RHS in.
  2. In any other case (e.g., a variable appearing on the RHS of an assignment), the variable’s value is used by the computer.

Since a variable (such as int a; float f; char c;) in C always occupies some memory address, it is called an L-value in a more technical term. In contrast, values (such as 5, -13.4, ‘a’) in C have only one possible interpretation — that is, there is no memory address associated with the value alone, so it is always treated as just a value. The more technical term for it is an R-value. So, the difference between an L-value and an R-value is that an L-value is a stand-alone “object” that has its own memory location and content, whereas an R-value has just the content without a memory address. This is actually not quite right. Everything is stored somewhere in the computer’s memory, but some places are just prohibited for the programmer to access as if it was the regular memory we can access with the ampersand operator. That’s what we mean when we say an r-value doesn’t have an address, i.e., its address is inaccessible through the ampersand operator. For the record, these inaccessible places where the intermediary values are stored are called registers.

Since every variable is an L-value in C when we use a variable on the LHS of the assignment statement, its address is used, and when we use a variable on the RHS of the assignment statement or in any other circumstances (e.g., passing it to some function as an argument), its value is used. To explicitly indicate the memory address of the variable whose value is used only (e.g., because it appears on the RHS of the assignment or it is passed to a function as an argument), we use the unary ampersand operator. The ampersand followed by a variable will give us the memory address of that variable (i.e., the pointer), which is an index (i.e., a positive integer). Since this index is just a value and hence not stored anywhere accessible to us, it is an R-value. That’s why we cannot use it on the LHS of the assignment statement, as shown below:

int x, y;
&x = &y; // error, &x is r-value but LHS must l-value

We used to pass addresses of variables to the scanf() function as it required working with the raw pointers and not the values. However, we did not see what the actual memory addresses look like. Well, a pointer is actually an index (positive integer) that can be printed by using the “%p” format string as can be seen below:

int val = 1;
printf("&val = %p", &val);

If there is a variable to store a pointer value, then we should also have a data type for that variable to store the pointer itself in some other memory address. The data type to store a pointer/address of a variable of type T (e.g., int, float, char, etc.) is T* type (e.g., int*, float*, char*, etc.). For example,

int x = 1;
int* ptr = &x;
ptr = x; // wrong! ptr = 1 and not the address of x

Memory addresses of different data types usually require different pointer types as the computer needs to know what type of data the pointer points to. It needs to know this information so that you can use the pointer to manipulate the content of the pointed memory block. To read the content of the memory block pointed by a pointer, you need to put the dereferencing operator (*) in front of the pointer. The * operator tells the computer to get you the actual content stored in the address/pointer that is being dereferenced by it. This is illustrated in the code snippet below:

int val = 34;
int* addr = &val;
printf("%d is stored at address %p\n", *addr, addr);
int val2 = *addr;
printf("%d = %d\n", val, val2);

You can also modify the content of the memory block pointed by a pointer. After the modification, any variable previously used as an alias to recall the content of the same underlying memory address can be used to recall the modified content. This can be done as shown below:

int val = 1;
int* addr = &val;
*val = 2;
printf("val = %d", val);

Since &val is a pointer to an integer and has the type int*, we can also dereference it directly without storing it elsewhere. In other words, * operator followed by an ampersand cancels out (that is, *&x = x) since the dereferenced address of a variable is equal to the very same variable by loghical implication. This can be shown as follows:

int val = 8;
printf("before: %d at address %p\n", *&val, &val);
*&val = 5; // same as val = 5;
printf("after: %d at address %p\n", *&val, &val);

Let’s also take a look at the following example:

int val1 = 1;
int val2 = val1;
int* addr1 = &val1;
int* addr2 = &val2;

printf("(val1 address) %p != %p (val2 address)\n", addr1, addr2);
printf("but (val1 value) %d = %d (val2 value)\n", val1, val2);

val2 = 2;
printf("val1 = %d or *%p = %d\n", val1, addr1, *addr1);
printf("val2 = %d or *%p = %d\n", val2, addr2, *addr2);

*addr1 = -7;
printf("val1 = %d or *%p = %d\n", val1, addr1, *addr1);
printf("val2 = %d or *%p = %d\n", val2, addr2, *addr2);

Notice how the val1 and val2 addresses (i.e., &val1 and &val2, respectively) are not the same while holding the same value 1. This is because they are physically stored at different locations in your computer’s memory, but the contents of both memory locations are the same due to val2 = val1; assignment. Moreover, such an assignment does not imply anything like &val2 = &val1, and in fact, what it implies is just the action of copying &val1‘s content into the &val2 memory location. This is also why changing val2‘s value to 2 (i.e., val2 = 2;) does not affect the val1‘s value, and changing val1‘s value (i.e., *addr1 = -7;) does not change the val2‘s value from 2 to -7.

Why does scanf() require a pointer?

We can use variables to store numbers or characters in the computer’s memory. For example,

int age = 4;
printf("current age: %d\n", age);
age = age + 1;
printf("after one year: %d\n", age);

Why the hell does scanf() require a pointer, then? If you remember from one of the previous posts, computers compute the output of a function by using a substitution. Not only that, but you also learned in the first subsection of this post that the function parameters are allocated separately on the memory, and the variables/values passed as arguments are copied to those locations to be then used by the function. Let’s look at the following example:

void f(int x){
    printf("x in f() is at %p and equals %d\n", &x, x);
}
void g(int x){
    x = 6;
    printf("x in g() is at %p and equals %d\n", &x, x);
}
int main(){
    int x = 2;
    printf("x in main() is at %p and equals %d\n", &x, x);
    f(x);
    g(x);
    printf("x in main() still equals %d\n", x);
}

Although the variable name x was used in all three functions (i.e., f(), g(), and main()), each one of them was used as an alias for a different memory location, meaning that changing one x‘s value could not possibly affect the other. Don’t get confused, though. The notion of local variables lets programmers not run out of variable names too quickly, as the same variable name can be reused for a different purpose in another C function. Due to this, each local variable is actually an alias for a different memory address. A variable is considered local (for a function f()) if it is a parameter of f() or a variable declared inside the function’s body. So, notice how x‘s value in the main() function did not change from 2 to 6 even though we called the function g(x); before printing its value. This should now explain the output you would see on your screen if you ran this program; it should look like something shown below:

x in main() is at 0xa843bb9fff024eec and equals 2
x in f() is at 0xa843bb9ffd024b04 and equals 2
x in g() is at 0xa843bb9ffd034bfa and equals 6
x in main() still equals 2

So, how could we change a variable’s memory content by passing it as an argument to some function and modifying its value inside that function? Is it even possible to do something like this? The answer is yes, of course. Instead of passing the value that is stored in the variable’s memory location (say, 0xa843bb9fff024eec), we should pass the memory location of that variable as an argument. Although the memory address 0xa843bb9fff024eec is also going to be copied as a value to the function’s parameter’s location in the memory, we can now manipulate the contents of that memory address by dereferencing it. This can be done as shown below.

void h(int* addr){
    printf("addr in h() is at %p and equals %p\n", &addr, addr);
    printf("inside the addr (%p) is stored a value %d\n", addr, *addr);
    *addr = 6; // notice how we don't need to directly assign by typing `addr = 6;`
    printf("before returning from h() the address %p now holds a new value %d\n", addr, *addr);
}
int main(){
    int x = 2;
    printf("x in main() is at %p and equals %d\n", &x, x);
    h(&x);
    printf("x in main() now equals %d\n", x);
    return 0;
}

This will produce an output similar to the one shown below:

x in main() is at 0xa843bb9fff024eec and equals 2
addr in h() is at 0xa843bb9f0a0c46a1 and equals 0xa843bb9fff024eec
inside the addr (0xa843bb9fff024eec) is stored a value 2
before returning from h() the address 0xa843bb9fff024eec now holds a new value 6
x in main() now equals 6

Nice! We just managed to change the x‘s value in initialized in one function (i.e., main()) by passing it to another function (i.e., h()). We already knew that whatever we pass into functions as arguments in C is just getting copied to the function’s “private” parameter variables in memory, so inside the function’s body, we would not be able to put any new values into the address of the variable whose value we wanted to be changed. Instead, we could only change the function’s own set of variables, which are not the same as the ones passed in terms of the memory address (although the names might be the same). This was the reason we had no other way but just to pass the variable’s address directly as an argument so that modifying its content would actually make the change globally and absolutely. Since the scanf() function needs to write whatever value the user enters via the keyboard into a variable, it also needs to take its address to make the modifications on the memory.

Pointer arithmetic

Pointers are potentially big numbers/indices that refer to the memory locations of the memory cells in your computer. That is why we can apply some arithmetic operations on them and hopefully we will make sense out of the resulting numbers. For example, if we know the memory address of some variable x, then we could effectively access the memory contents of its left and right adjacent cells by dereferencing (&x-1) and (&x+1), respectively. Another example is, given two variables x, followed by y, we can also find the memory address in the middle by typing &x + (&y – &x)/2. This is all pointer arithmetic, i.e., arithmetic on memory addresses of variables and not the values stored on those addresses. Let’s see a quick example:

int x = 3;
int y = 7;
int z = -23;
printf("&x = %p\n", &x);
printf("&y = %p\n", &y);
printf("&z = %p\n", &z);
printf("[%d][%d][%d]", *(&y-1), *(&y), *(&y+1));

If you see the numbers [-23][7][3] in the output, you might think that if the computer chooses truly random addresses for each variable, then what are the odds of the numbers -23 and 3 randomly stored on those addresses or those addresses actually being consequent in the memory. Well, actually, I never told you that all of the addresses are picked in a truly random way by your computer. In fact, that’s what we observe here. The memory addresses of all three variables are adjacent to each other in memory in the order in which they appear in the code. However, there is a little caveat: such consequent addresses are allocated from higher index to lower index. That’s why &y-1 equals &z and not &x (likewise, &y+1 equals &x and not &z). With this being said, now you might naturally wonder about dereferencing (&z-1) since this address is not within the declared variables in the program given above. If you dereference that memory address by inserting *(&z-1) inside a printf statement into the code, it may result in one of the two possible outcomes:

  1. A “random” number may appear on the screen indicating that the memory address (&z-1) has been used by the computer to store that number for some reason;
  2. Your program crashes with the segmentation fault indicating that the computer does not allow you to access the memory you did not declare by using a variable for some safety reasons.

If you are coming from almost any other existing programming language, this may feel like a magic. I mean, accessing a variable by using the pointer pointing to another variable? Prery magical. Question: Do C programmers usually use pointers for this purpose? Answer: No. In fact, this is something that you should not do in your C program since it is not easily readable and it may easily lead to very shady bugs during the program execution. If you want to access the memory content of a variable, just use the same variable to do so. No need to get fancy at the cost of getting fired from your C job.

Table of Contents

  1. Preface
  2. Level 1. Introduction to C
    1. Hello, World!
    2. Basics
      1. Your computer can memorize things
      2. Your computer can “talk” and “listen”
      3. Compiling and Running programs
    3. Functions
      1. I receive Inputs, You receive Output
      2. Simple pattern matching
      3. Function calling and Recursion
    4. Control Flow
      1. Branching on a condition
      2. Branching back is called Looping
    5. Pointers ← you are here
      1. How to think about a computer’s memory
      2. Memory address of my variable
      3. Pointer arithmetic
    6. Arrays
      1. Hold my integers
      2. Size of my array
    7. Data Structures
      1. All variables in one place
      2. Example: Stack and Queue
      3. Example: Linked List
  3. Level 2. Where C normies stopped reading
    1. Data Types
      1. More types and their interpretation
      2. Union and Enumerator types
      3. Padding in Structs
    2. Bit Manipulations
      1. Big and Little Endianness
      2. Logical NOT, AND, OR, and more
      3. Arithmetic bit shifting
    3. File I/O
      1. Wait, everything is a file? Always has been!
      2. Beyond STDIN, STDOUT, and STDERR
      3. Creating, Reading, Updating, and Deleting File
    4. Memory Allocation and Deallocation
      1. Stack and Heap
      2. Static allocations on the stack
      3. Dynamic allocations on the heap
    5. Preprocessor Directives
    6. Compilation and Makefile
      1. Compilation process
      2. Header and Source files
      3. External Libraries and Linking
      4. Makefile
    7. Command-line Arguments
      1. Your C program is a function with arguments
      2. Environment variables
  4. Level 3. Becoming a C wizard
    1. Declarations and Type Definitions
      1. My pointer points to a function
      2. That function points to another function
    2. Functions with Variadic Arguments
    3. System calls versus Library calls
      1. User and Kernel modes
      2. Implementing a memory allocator
    4. Parallelism and Concurrency
      1. Multiprocessing
      2. Multithreading with POSIX
    5. Shared Memory
      1. Virtual Memory
      2. Creating, Reading, Updating, and Deleting shared memory
      3. Critical section
    6. Safety in Critical Sections
      1. Race conditions
      2. Mutual exclusion
      3. Semaphores
    7. Signaling
  5. Level 4. One does not simply become a C master
  1. Binary is a numeral system that has only two digits, 0 and 1. Unlike the decimal system (i.e., 10 available digits, from 0 to 9), it is a base-2 system. ↩︎
  2. mov stands for moving data around in the computer’s memory. ↩︎

Leave a Reply

Your email address will not be published. Required fields are marked *