Pointers are extremely important in C. However, to fully understand pointers, one must not only have a proficient grasp of the C language but also basic knowledge of computer hardware and operating systems. Therefore, this article aims to explain pointers comprehensively.
Why Use Pointers?
Pointers solve some fundamental problems in programming.
First, using pointers allows different areas of code to easily share memory data. Of course, one can achieve the same effect by copying data, but this is often inefficient. Large data structures, such as structs, consume a lot of bytes, and copying can be resource-intensive. However, using pointers can effectively avoid this issue because any type of pointer occupies the same number of bytes (which can be 4 bytes, 8 bytes, or other sizes depending on the platform).
Second, pointers enable the construction of complex linked data structures, such as linked lists and binary trees.
Third, some operations must use pointers, such as heap memory allocation. Additionally, in all function calls in C, value passing is done “by value.” If we want to modify the passed object within a function, we must do so through the object’s pointer.
What Are Pointers?
We know that arrays in C refer to a class of types, specifically categorized into int arrays, double arrays, char arrays, etc. Similarly, the concept of pointers also encompasses a class of data types, such as int pointer types, double pointer types, char pointer types, etc.
Typically, we use int to store integer data, such as int num = 97, and we use char to store characters: char ch = ‘a’.
It is also important to understand that any data loaded into memory has its address, which is the pointer. To save the address of a data item in memory, we need a pointer variable.
Thus, a pointer is the address of program data in memory, while a pointer variable is used to store these addresses.
Why Do Data in Programs Have Their Own Addresses?
To clarify this question, we need to understand memory from the operating system’s perspective.
From a computer technician’s perspective, memory is physically composed of a set of DRAM chips.
As programmers, we do not need to understand the physical structure of memory; the operating system combines hardware like RAM with software to provide an abstraction of memory usage to programmers. This abstraction mechanism allows programs to use virtual memory, rather than directly manipulating the physically existing memory. The collection of all virtual addresses forms the virtual address space.
From a programmer’s perspective, memory should look like this.
This means that memory is a large, linear byte array (flat addressing). Each byte is of fixed size, consisting of 8 bits. The key point is that each byte has a unique number, starting from 0 up to the last byte. For example, in a 256MB memory, there are a total of 256x1024x1024 = 268435456 bytes, thus the address range is 0 ~268435455.
Since each byte in memory has a unique number, all variables, constants, and even function data used in programs have their unique number when loaded into memory, which is the address of that data. This is how pointers are formed.
The following code illustrates this:
#include <stdio.h>
int main(void){ char ch = 'a'; int num = 97; printf("ch's address: %p", &ch); //ch's address: 0028FF47 printf("num's address: %p", &num); //num's address: 0028FF40 return 0;}
The value of a pointer is essentially the number of the memory unit (i.e., byte), so a pointer is also an integer when viewed numerically, typically represented in hexadecimal. The value of a pointer (virtual address value) is stored using the size of a machine word. In other words, for a machine with a word size of w bits, its virtual address space ranges from 0 to 2^w – 1, allowing the program to access up to 2^w bytes. This explains why a 32-bit system like XP supports a maximum of 4GB of memory.
We can roughly illustrate the storage of variables ch and num in the memory model (assuming char occupies 1 byte and int occupies 4 bytes).
Variables and Memory
For simplicity, we will analyze the storage model of the local variable int num = 97 from the previous example.
We know that num is of type int, occupying 4 bytes of memory space, with a value of 97 and an address of 0028FF40. We will analyze it from the following aspects.
1. Data in Memory
The data in memory corresponds to the binary value of the variable. Everything is binary. The binary representation of 97 is: 00000000 00000000 00000000 01100001, but when stored in little-endian mode, the low-order data is stored at the low address, so it is illustrated in reverse.
2. Data Type in Memory
The data type in memory determines the number of bytes the data occupies and how the computer will interpret those bytes. Since num is of type int, it will be interpreted as an integer.
3. Name of Data in Memory
The name of data in memory is the variable name. In reality, all memory data is identified by its address; there is no such thing as a name for memory data. This is just an abstraction mechanism provided by high-level languages to facilitate our manipulation of memory data. Furthermore, not all memory data in C has a name; for example, heap memory allocated using malloc does not have a name.
4. Address of Data in Memory
If a type occupies more than 1 byte, the variable’s address is the address of the lowest byte. Therefore, the address of num is 0028FF40. The memory address is used to identify this memory block.
5. Lifecycle of Data in Memory
num is a local variable in the main function, so when the main function starts, it is allocated on the stack memory, and it dies when the main function finishes executing.
If a data item continuously occupies its memory, we say it is “alive”; if the memory it occupies is reclaimed, the data is considered “dead.” In C, the lifecycle characteristics of program data are determined by their defined location, type, and modifying keywords. Essentially, the memory we use in our programs is logically divided into: stack area, heap area, static data area, and method area. The data in different areas has different lifecycles.
Regardless of how computer hardware evolves in the future, memory capacity is always limited, so understanding the lifecycle of each program data item is crucial.
Pointer Variables and Pointing Relationships
A variable used to save a pointer is called a pointer variable. If the pointer variable p1 saves the address of the variable num, we say that p1 points to the variable num, or that p1 points to the memory block where num is located. This pointing relationship is generally represented by an arrow in diagrams.
In the diagram, the pointer variable p1 points to the memory block where num is located, starting from address 0028FF40, covering 4 bytes of memory.
Defining Pointer Variables
In C, when defining a variable, placing an asterisk (*) before the variable name makes it a pointer variable of the corresponding variable type. Parentheses may be added when necessary to avoid priority issues.
Extension: In C, if we write typedef before a definition, the variable name becomes a synonym for that type.
int a; // int type variable
tint *a; // int* variable
int arr[3]; // arr is an array containing 3 int elements
int (*arr)[3]; // arr is a pointer variable pointing to an array containing 3 int elements
//----------------- Various Types of Pointers ------------------------------
int *p_int; // pointer to int type variable
double *p_double; // pointer to double type variable
struct Student *p_struct; // pointer to struct type
int(*p_func)(int,int); // pointer to a function returning int with 2 int parameters
int(*p_arr)[3]; // pointer to an array containing 3 int elements
int **p_pointer; // pointer to a pointer to an int variable
Getting Addresses
Since we have pointer variables, we need to let them save the addresses of other variables, using the & operator to obtain a variable’s address.
int add(int a, int b){ return a + b;}
int main(void){ int num = 97; float score = 10.00F; int arr[3] = {1,2,3};
//-----------------------
int* p_num = # float* p_score = &score; int (*p_arr)[3] = &arr; int (*fp_add)(int ,int ) = add; // p_add is a pointer to function add return 0;}
In special cases, they do not necessarily need to use & to get the address:
-
The value of the array name is the address of the first element of that array.
-
The value of the function name is the address of that function.
-
When a string literal constant is used as a right value, it is the name of the character array corresponding to that string, which is the address of that string in memory.
int add(int a, int b){ return a + b;}
int main(void){ int arr[3] = {1,2,3}; //-----------------------
int* p_first = arr; int (*fp_add)(int ,int ) = add; const char* msg = "Hello world"; return 0;}
Dereferencing
Why do we need a pointer variable for a data item? Of course, we use it to operate (read/write) on the data it points to. Dereferencing a pointer allows us to access the memory data, and the dereferencing notation is to add an asterisk (*) before the pointer.
Dereferencing a pointer essentially means retrieving the memory data from the memory block the pointer points to.
int main(void){ int age = 19; int*p_age = &age; *p_age = 20; // modify the memory data through the pointer
printf("age = %d", *p_age); // read the memory data through the pointer printf("age = %d", age);
return 0;}
Pointer Assignment
Pointer assignment is similar to assigning int variables, which means copying the address value to another. Pointer assignment is a type of shallow copy, an efficient method for sharing memory data across multiple programming units.
int *p1 = & num;int *p3 = p1;
// By using pointers p1 and p3, both can read and write the memory data of num. If two functions use p1 and p3 respectively, they share the data num.
Null Pointer
A pointer that points to null, or does not point to anything. In C, we assign NULL to a pointer variable to indicate a null pointer, while in C, NULL is essentially ((void*)0). In C++, NULL is essentially 0.
In other words, no program data is stored in the memory block with address 0; it is reserved by the operating system.
The following code is excerpted from stdlib.h
#ifdef __cplusplus #define NULL 0#else #define NULL ((void *)0)#endif
Bad Pointer
A pointer variable that has a value of NULL, or an unknown address value, or an address value that the current application cannot access, is called a bad pointer. Dereferencing them will lead to runtime errors and cause the program to terminate unexpectedly.
Before performing dereferencing operations on any pointer variable, it must be ensured that it points to a valid, usable memory block, or an error will occur. Bad pointers are one of the most frequent causes of bugs in C.
The following code is an example of an error.
void opp(){ int *p = NULL; *p = 10; //Oops! Cannot dereference NULL}
void foo(){ int *p; *p = 10; //Oops! Cannot dereference an unknown address}
void bar(){ int *p = (int*)1000; *p =10; //Oops! Cannot dereference a pointer to an address that may not belong to this program}
Two Important Properties of Pointers
Pointers are also a type of data, and pointer variables are also a type of variable, so pointers also conform to the characteristics discussed earlier regarding variables and memory. Here, we emphasize two properties: the type of the pointer and the value of the pointer.
int main(void){ int num = 97; int *p1 = # char *p2 = (char*)(&num);
printf("%d", *p1); // outputs 97 putchar(*p2); // outputs a return 0;}
The value of a pointer is easy to understand; for the variable num, its address value is 0028FF40, so the value of p1 is 0028FF40. The data’s address is used to locate and identify that data in memory since the addresses of any two non-overlapping different data items are different.
The type of the pointer determines the number of bytes the pointer points to and how to interpret the byte information. Generally, the pointer variable’s type should match the type of the data it points to.
Since the address of num is 0028FF40, both p1 and p2 have the value 0028FF40.
*p1: This will start parsing from address 0028FF40. Since p1 is an int type pointer, it occupies 4 bytes, so it will consecutively retrieve 4 bytes of binary data and interpret this as an integer 97.
*p2: This will also start parsing from address 0028FF40. Since p2 is a char type pointer, it occupies 1 byte, so it will consecutively retrieve 1 byte of binary data and interpret this as a character, which is ‘a’.
Even though they have the same address, different pointer types interpret the memory they point to differently, resulting in different data.
void* Type Pointer
Since void is an empty type, a void* type pointer only saves the pointer’s value but loses the type information. We do not know what type of data it points to; we only specify the starting address of that data in memory. If we want to extract the data pointed to completely, the programmer must perform the correct type conversion on this pointer before dereferencing it. The compiler does not allow dereferencing a void* type pointer directly.
Structures and Pointers
Structure pointers have special syntax: the -> symbol.
If p is a structure pointer, we can access the members of the structure using p -> [member].
typedef struct{ char name[31]; int age; float score;}Student;
int main(void){ Student stu = {"Bob", 19, 98.0}; Student* ps = &stu;
ps->age = 20; ps->score = 99.0; printf("name:%s age:%d", ps->name, ps->age); return 0;}
Arrays and Pointers
1. The array name as a right value is the address of the first element.
int main(void){ int arr[3] = {1,2,3};
int *p_first = arr; printf("%d", *p_first); // 1 return 0;}
2. Pointers to array elements support increment and decrement operations (in fact, all pointers support increment and decrement operations, but they are only meaningful when used with arrays).
int main(void){ int arr[3] = {1,2,3};
int *p = arr; for(; p != arr + 3; p++){ printf("%d", *p); } return 0;}
3. p = p + 1 means to let p point to the next adjacent memory block of the same type that it originally pointed to.
Within the same array, pointer subtraction can be performed between elements, where the difference between pointers equals the difference in indices.
4. p[n] == *(p + n)
p[n][m] == *( *(p + n) + m)
5. When using sizeof on an array name, it returns the total number of bytes occupied by the entire array. When assigning an array name to a pointer and then using sizeof on that pointer, it returns the size of the pointer.
This is why when passing an array to a function, we need to pass the number of array elements as an additional parameter.
int main(void){ int arr[3] = {1,2,3};
int *p = arr; printf("sizeof(arr)=%d", sizeof(arr)); // sizeof(arr)=12 printf("sizeof(p)=%d", sizeof(p)); // sizeof(p)=4
return 0;}
Functions and Pointers
Function parameters and pointers
In C, actual parameters are passed to formal parameters by value, meaning that the formal parameters in the function are copies of the actual parameters; they are not the same memory data object. This means that this data passing is unidirectional, i.e., from the caller to the called function, and the called function cannot modify the passed parameters to achieve a return effect.
void change(int a){ a++; // Only this function's local variable a is changed, and when the function ends, a is destroyed. age remains unchanged.}
int main(void){ int age = 19; change(age); printf("age = %d", age); // age = 19 return 0;}
Sometimes we can use the return value of a function to return data, which is possible in simple cases. However, if the return value has other uses (e.g., returning a function execution status), or if there is more than one piece of data to return, the return value cannot solve the problem.
void change(int *pa){ (*pa)++; // Since we pass the address of age, pa points to the memory data of age. When dereferencing pointer pa in the function, it directly finds the data of age in memory and increments it by 1.}
int main(void){ int age = 19; change(&age); printf("age = %d", age); // age = 20 return 0;}
Here’s a classic example of using functions to swap two variable values:
#include <stdio.h>
void swap_bad(int a, int b);void swap_ok(int* pa, int* pb);
int main(){ int a = 5; int b = 3; swap_bad(a, b); // Can't swap; swap_ok(&a, &b); // OK return 0;}
// Incorrect method
def swap_bad(int a, int b){ int t; t = a; a = b; b = t;}
// Correct method: using pointers
def swap_ok(int* pa, int* pb){ int t; t = *pa; *pa = *pb; *pb = t;}
Sometimes, we pass data to a function through a pointer not to change the object it points to. On the contrary, we prevent the target data from being changed. Passing pointers is just to avoid copying large data.
Consider a structure type Student. We use the show function to output the data of the Student variable.
typedef struct{ char name[31]; int age; float score;}Student;
// Print Student variable information
void show(const Student * ps){ printf("name:%s , age:%d , score:%.2f", ps->name, ps->age, ps->score); }
In the show function, we only read the information of the Student variable without modifying it. To prevent accidental modification, we use a constant pointer. Additionally, why do we use pointers instead of directly passing the Student variable?
From the structure definition, the size of a Student variable is at least 39 bytes. Passing the variable directly to the function requires copying at least 39 bytes of data, which is highly inefficient. Passing the variable’s pointer is much faster because the size of a pointer is fixed on the same platform: X86 pointers are 4 bytes, X64 pointers are 8 bytes, which is much smaller than a Student structure variable.
Function Pointers
Every function itself is also a type of program data; a function contains multiple execution statements, and after compilation, it is essentially a collection of multiple machine instructions. When a program is loaded into memory, the machine instructions of the function are stored in a specific logical area: the code area. Since they are stored in memory, functions also have their pointers.
In C, the function name as a right value is the pointer to that function.
void echo(const char *msg){ printf("%s", msg);}
int main(void){ void(*p)(const char*) = echo; // Function pointer variable pointing to the echo function
p("Hello "); // Calling the function through the pointer p, equivalent to echo("Hello ") echo("World"); return 0;}
const and Pointers
Which one does const modify? What is immutable?
If const follows a type, it skips the nearest atomic type and modifies the data that follows (atomic types are types that cannot be further divided, such as int, short, char, and types wrapped by typedef).
If const is followed by data, it directly modifies that data.
int main(){ int a = 1;
int const *p1 = &a; // const follows *p1, which means data a, thus p1 cannot modify the value of a const int *p2 = &a; // const follows int type, thus skips int, modifies p2, same effect
int *const p3 = NULL; // const follows data p3, meaning pointer p3 itself is const.
const int *const p4 = &a; // p4 cannot change the value of a, and p4 itself is also const int const *const p5 = &a; // same effect
return 0;}
typedef int *pint_t; // Wrap int* type as pint_t, so pint_t is now a complete atomic type
int main(){ int a = 1; const pint_t p1 = &a; // Again, const skips type pint_t, modifies p1, pointer p1 itself is const pint_t const p2 = &a; // const directly modifies p, same effect
return 0;}
Deep Copy and Shallow Copy
If two programming units (for example, two functions) work by copying the pointers of the data they share, this is a shallow copy because the actual data being accessed is not copied. If the accessed data is copied, and each unit has its own copy, then operations on the target data do not affect each other, which is called a deep copy.
Additional Knowledge
The difference between pointers and references. Essentially, they are the same thing. Pointers are commonly used in C, while references are used in programming languages like Java and C#, which encapsulate direct operations on pointers at the language level.
Big-endian and little-endian
1) Little-endian means the low-order byte is placed at the low address end of memory, and the high-order byte is placed at the high address end of memory. This is commonly used in personal PCs, and Intel X86 processors use little-endian.
2) Big-endian means the high-order byte is placed at the low address end of memory, and the low-order byte is placed at the high address end of memory.
Storing data in big-endian format aligns with human thinking, while storing data in little-endian format is more beneficial for computer processing. Some machines support both big-endian and little-endian modes, which can be set through configuration.
If the short type occupies 2 bytes and is stored at address 0x30.
short a = 1;
As shown below:
// Test whether the machine uses little-endian. If so, return true; otherwise, return false
// The method of judgment is: the address of an object in C is the address of the first byte occupied by that object.
bool isSmallEndian(){ unsigned int val = 'A'; unsigned char* p = (unsigned char*)&val; // In C/C++, for multi-byte data, taking the address is taking the address of the first byte of the data object, which is the low address of the data.
return *p == 'A';}
来自信息科技人 信息科技人公众号