Ask any computing student what they dread most about learning C and 90% will answer pointers. Yet pointers are the soul of the language β€” the thing that gives C its reach. Mastering them means holding the key to operating directly on the machine's RAM. Let's take the idea apart, from the simplest level up to the advanced ones.

1. Memory cells and addresses

Picture your computer's RAM as one long street, where every house is a memory cell holding 1 byte.

Each cell has two important properties:

  • Address: its unique house number (written in hexadecimal, for example 0x7ffee3a9bb7c).
  • Value: the data currently stored inside that house.

When you declare int x = 5;, the operating system allocates a region of memory for x (typically 4 bytes on today's common 32-bit/64-bit systems), and the value stored inside is 5. Note that the size of a pointer holding such an address varies too: 4 bytes on a 32-bit system and 8 bytes on a 64-bit one (see the pointer types specification on cppreference.com).

To find out where x sits in RAM, C provides the address-of operator: &.

memory_address.c
#include <stdio.h>

int main() {
    int x = 5;
    printf("Gia tri cua x: %d\n", x);
    printf("Dia chi cua x tren RAM: %p\n", &x); // %p dung de in dia chi con tro (hexadecimal)
    return 0;
}

Run it twice and you will see a different address each time β€” that is not a bug. Modern operating systems randomise where a program is loaded on every run (ASLR) to make exploits harder. What matters is not the particular number but the fact that every variable has an address, and & retrieves it. Print it with %p rather than %d, because an address is not an ordinary integer.

2. Basic pointers (single-level pointers)

A pointer variable is just an ordinary variable, except that instead of holding a normal value (the number 5, the character 'A') it holds the memory address of another variable.

Declaring and using one

  • Declaring a pointer: type *name; (the * marks this as a pointer).
  • The dereference operator *: placed in front of a pointer that holds a value, it reaches directly into the cell being pointed at, to read or overwrite the value there.
pointer_basic.c
#include <stdio.h>

int main() {
    int x = 100;
    int *p; // Khai bao con tro p kieu so nguyen int

    p = &x; // Gan dia chi cua x cho con tro p (p dang tro toi x)

    printf("Dia chi cua x: %p\n", &x);
    printf("Gia tri cua p (dia chi o nho): %p\n", p);

    printf("Gia tri cua x: %d\n", x);
    printf("Gia tri tai vung nho p tro toi (*p): %d\n", *p); // Giai tham chieu *p de lay 100

    // Thay doi gia tri cua x thong qua con tro p
    *p = 200;
    printf("Gia tri moi cua x: %d\n", x); // x gio day la 200!

    return 0;
}

The memory picture

+-------------------+              +--------------------+
| Variable p (ptr)  | -----------> | Variable x (int)   |
| Value:   &x       |              | Value:   100 / 200 |
| Address: 0x1111   |              | Address: 0x2222    |
+-------------------+              +--------------------+

3. Passing pointers into functions (by reference)

In C, arguments are passed by value by default: the function makes its own copy, so any change inside the function disappears when it returns.

To change the caller's variable directly, you must pass its address via a pointer β€” commonly called passing by reference.

swap.c
#include <stdio.h>

// Ham hoan vi hai so su dung con tro
void swap(int *a, int *b) {
    int temp = *a;
    *a = *b;
    *b = temp;
}

int main() {
    int x = 5, y = 10;
    printf("Truoc khi swap: x = %d, y = %d\n", x, y);

    // Truyen dia chi cua bien vao ham
    swap(&x, &y);

    printf("Sau khi swap: x = %d, y = %d\n", x, y); // x = 10, y = 5
    return 0;
}

πŸ“₯ Download the sample source: swap.c

4. Double pointers (pointer to pointer)

Since a pointer is itself a variable living in RAM, it also has its own address. From that we can build a pointer that points at another pointer β€” a double pointer.

It is declared with two asterisks: type **name;

double_pointer.c
#include <stdio.h>

int main() {
    int x = 42;
    int *p1 = &x;   // Con tro cap 1 p1 tro toi x
    int **p2 = &p1; // Con tro cap 2 p2 tro toi p1

    printf("Gia tri x: %d\n", x);
    printf("Gia tri thong qua p1 (*p1): %d\n", *p1);
    printf("Gia tri thong qua p2 (**p2): %d\n", **p2); // Giai tham chieu 2 lan

    printf("Dia chi p1: %p\n", &p1);
    printf("Gia tri p2: %p\n", p2); // In ra dia chi cua p1

    return 0;
}

The key is that the number of dereferences must match the number of pointer levels: *p1 travels one hop to reach x, while **p2 travels two hops β€” through p1 and only then to x. Both print 42. Double pointers sound abstract, but you will need them in Lesson 9, where a function must change the caller's own pointer β€” for instance a function that allocates memory and assigns the new address into the pointer it was given.

5. Pointer arithmetic and what moving through memory really means

One of the most confusing things for newcomers is this: why does adding 1 to a pointer (ptr + 1) not increase the address by 1 byte, but by 4 or 8? The answer is the scaling factor.

In C, arithmetic on pointers (adding, subtracting, incrementing, decrementing) is automatically multiplied by the size of the type being pointed at (sizeof(*ptr)). The new address is computed as:

Address_new = Address_old + (n * sizeof(*ptr))

For example, on a 64-bit system, if an int pointer (4 bytes per element) points at address 0x1000:

  • ptr + 1 points at 0x1000 + 1 * sizeof(int) = 0x1000 + 4 = 0x1004.
  • ptr + 2 points at 0x1000 + 2 * sizeof(int) = 0x1000 + 8 = 0x1008.
pointer_arithmetic.c
#include <stdio.h>

int main() {
    int arr[5] = {10, 20, 30, 40, 50};
    int *ptr = arr; // ptr tro toi phan tu dau tien arr[0]

    printf("Dia chi arr[0]: %p, Gia tri: %d\n", (void*)ptr, *ptr);

    // Tinh toan dia chi tu dong (Scaling Factor)
    printf("Dia chi ptr + 1: %p, Gia tri: %d\n", (void*)(ptr + 1), *(ptr + 1)); // Tang 4 bytes
    printf("Dia chi ptr + 2: %p, Gia tri: %d\n", (void*)(ptr + 2), *(ptr + 2)); // Tang 8 bytes

    // Hieu giua hai con tro (Pointer Subtraction)
    int *end_ptr = &arr[4];
    printf("So phan tu giua end_ptr va ptr: %td\n", end_ptr - ptr); // Ket qua: 4 (khong phai so bytes!)

    // Tinh tuong duong giua subscript va con tro
    // arr[i] thuc chat la viet tat cua *(arr + i)
    printf("arr[2] = %d va *(arr + 2) = %d\n", arr[2], *(arr + 2));

    // Su that thu vi: Vi phep cong co tinh chat giao hoan: *(arr + 2) == *(2 + arr)
    // Nen 2[arr] hoan toan hop le trong C!
    printf("2[arr] = %d\n", 2[arr]); // Ket qua van la 30

    return 0;
}

A special note on void*

Under the C99/C11 standard, a typeless pointer void* has no way of knowing the size of what it points at (sizeof(void) is invalid). Consequently, pointer arithmetic on void* is not permitted. Some compilers such as GCC do provide an extension treating sizeof(void) == 1 to support byte-by-byte arithmetic. For safety and portability, always cast a void* to char* before computing addresses.

6. Restricting keywords: pointer to constant, constant pointer, and restrict

Combining const with pointers is a common source of confusion. Distinguish the two carefully:

  • Pointer to constant: const int *p or int const *p. You cannot change the value in the cell being pointed at via *p = ..., but you can repoint p at a different variable.
  • Constant pointer: int * const p. The address p holds is fixed β€” you cannot repoint it β€” but you can change the value in that cell via *p = ....
const_pointer.c
#include <stdio.h>

int main() {
    int x = 10, y = 20;

    // 1. Con tro hang (Gia tri khong doi, dia chi co the doi)
    const int *ptr_to_const = &x;
    // *ptr_to_const = 15; // LOI BIEN DICH! Khong the sua gia tri
    ptr_to_const = &y;     // Hop le!

    // 2. Hang con tro (Dia chi khong doi, gia tri co the doi)
    int * const const_ptr = &x;
    *const_ptr = 15;       // Hop le!
    // const_ptr = &y;     // LOI BIEN DICH! Khong the thay doi dia chi

    return 0;
}

The restrict optimisation keyword (C99)

An important advanced feature around pointers is pointer aliasing. When two pointers refer to the same region of memory, the compiler has to be extremely cautious: it is forced to reload values from RAM into CPU registers repeatedly, because it cannot be sure whether writing through one pointer changed what the other one sees.

The restrict keyword exists as a promise from the programmer to the compiler: "within the lifetime of this pointer, the memory it refers to will be accessed only through it (or through pointers derived directly from it)."

Given that promise, the compiler is free to optimise β€” keeping values in CPU registers rather than continually reading and writing RAM, which can speed the code up considerably.

restrict_optimization.c
// Phien ban khong dung restrict:
void update_normal(int *a, int *b, int *val) {
    *a += *val;
    *b += *val;
    // Vi a, b va val co the tro vao cung mot dia chi (aliasing),
    // trinh bien dich buoc phai tai (load) lai gia tri tu *val 2 lan tu RAM
    // de phong truong hop thao tac '*a += *val' lam thay doi luon gia tri cua *val.
}

// Phien ban toi uu voi restrict:
void update_optimized(int * restrict a, int * restrict b, int * restrict val) {
    *a += *val;
    *b += *val;
    // Trinh bien dich biet chac *val khong bi thay doi boi cac phep gan tren a hoac b,
    // nen no se nap *val vao thanh ghi duy nhat 1 lan va dung cho ca hai phep tinh.
}

restrict does not change what the program computes β€” it is a promise you make to the compiler: "these three pointers never refer to the same cell." On the strength of it, the compiler may load *val once instead of twice. In exchange, if you lie β€” passing in two pointers that really do overlap β€” the result is undefined behaviour, with no warning whatsoever. This is a keyword to reach for only when you are certain.

7. Function pointers and simulating a vtable (OOP in C)

In C, a compiled function is loaded into the text segment and has a specific starting address. A function pointer stores that address, letting you call the function dynamically or pass it as an argument to another function (a callback).

Declaration syntax: return_type (*pointer_name)(parameter_types);

function_pointer.c
#include <stdio.h>

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

// Ham su dung callback con tro ham
void compute(int x, int y, int (*operation)(int, int)) {
    printf("Ket qua tinh toan: %d\n", operation(x, y));
}

int main() {
    int (*p)(int, int) = add; // Khai bao va gan dia chi ham
    printf("Tong: %d\n", p(10, 5)); // Goi ham gian tiep qua con tro p

    p = subtract;
    printf("Hieu: %d\n", p(10, 5));

    compute(20, 30, add); // Truyen callback add vao ham compute
    return 0;
}

Simulating object-oriented programming and a vtable in C

Ever wondered how C++ or Java implement polymorphism? The answer is the virtual table (vtable). By combining a struct with function pointers, we can build a complete OOP model in plain C.

The example below shows how to define a virtual interface and an implementing class that inherits from a parent, through the vtable mechanism:

c_oop_vtable.c
#include <stdio.h>

// 1. Dinh nghia vtable chua cac con tro ham (cac phuong thuc ao)
struct Shape;
struct ShapeVTable {
    void (*draw)(struct Shape *self);
    double (*area)(struct Shape *self);
};

// 2. Struct "Base class" Shape
struct Shape {
    struct ShapeVTable *vtable; // Con tro den bang vtable
};

// 3. Struct "Derived class" Circle ke thua Shape
struct Circle {
    struct Shape base; // Thuoc tinh base phai nam o dau tien de de dang ep kieu
    double radius;
};

// Trien khai cac phuong thuc thuc te cho Circle
void draw_circle(struct Shape *self) {
    // Upcast nguoc lai tu Shape* ve Circle*
    struct Circle *c = (struct Circle*)self;
    printf("Ve hinh tron voi ban kinh: %.2f\n", c->radius);
}

double area_circle(struct Shape *self) {
    struct Circle *c = (struct Circle*)self;
    return 3.14159265 * c->radius * c->radius;
}

// Khai bao bang vtable tinh cho Circle
struct ShapeVTable circle_vtable = {
    .draw = draw_circle,
    .area = area_circle
};

// Ham khoi tao doi tuong Circle
void init_circle(struct Circle *c, double r) {
    c->base.vtable = &circle_vtable; // Tro toi bang phuong thuc ao cua Circle
    c->radius = r;
}

int main() {
    struct Circle my_circle;
    init_circle(&my_circle, 5.0);

    // Dynamic Dispatch: Goi phuong thuc draw thong qua vtable
    struct Shape *shape_ptr = (struct Shape*)&my_circle;

    // Day chinh la cach compiler C++ goi phuong thuc ao tu bien con tro lop cha!
    shape_ptr->vtable->draw(shape_ptr);
    printf("Dien tich: %.2f\n", shape_ptr->vtable->area(shape_ptr));

    return 0;
}

πŸ“₯ Download the OOP simulation sample: c_oop_vtable.c

Advice for self-study

The best way to understand pointers is to draw the memory diagram on paper every time you write this kind of code. Work out what each region holds and which variable points where. It gives you full control over memory and lets you write with real confidence.

8. The serious bugs you will meet with pointers

  • Segmentation fault: happens when you dereference a NULL pointer, or one holding an invalid address outside the current process's permissions. More detail in the pointer types documentation on cppreference.com.
  • Wild pointer: a pointer declared without an initial address. It holds a random garbage address from RAM, and dereferencing it leads to unpredictable run-time failures. The best prevention: always assign NULL at declaration β€” int *ptr = NULL;.
  • Dangling pointer: a pointer still referring to a local variable whose memory was reclaimed when its function returned (or to dynamic memory already released with free). Reading through it yields garbage.
πŸ“ Check your understanding β€” Lesson 8
Given these declarations: int x = 10; int *p = &x; int **pp = &p; β€” which statement changes x to 50 through the double pointer pp?

Related lessons in this series

Lesson 9: Dynamic allocation & memory management Lesson 7: Struct, union & the typedef keyword in C Back to the C series roadmap (Vietnamese)