This C programming guide is currently only available in Vietnamese. Please toggle the language switch (🇻🇳) in the top navigation to read the full article.

In this fifth part of the series, we present a comprehensive deep dive into C Pointers. We cover memory addresses, pointer declaration and dereferencing, double pointers (pointers-to-pointers), pointer arithmetic, const pointers, pass-by-reference swaps, and function pointers with ascii memory diagrams.

Nếu hỏi bất kỳ sinh viên công nghệ thông tin nào về "nỗi khiếp sợ" lớn nhất khi học ngôn ngữ C, câu trả lời chắc chắn 90% sẽ là Con trỏ (Pointers). Tuy nhiên, con trỏ lại chính là linh hồn, là vũ khí tối thượng làm nên sức mạnh vượt trội của C. Làm chủ được con trỏ nghĩa là bạn đã nắm được chìa khóa vận hành trực tiếp bộ nhớ RAM của máy tính. Hãy cùng bóc tách khái niệm này từ mức độ cơ bản nhất đến nâng cao.

1. Ô nhớ & Địa chỉ bộ nhớ

Hãy tưởng tượng RAM của máy tính giống như một dãy phố dài, mỗi căn nhà là một ô nhớ (memory cell) có dung lượng 1 byte.

Mỗi ô nhớ có hai thuộc tính quan trọng:

  • Địa chỉ ô nhớ (Address): Số nhà duy nhất (viết dưới dạng số thập lục phân Hexadecimal, ví dụ 0x7ffee3a9bb7c).
  • Giá trị ô nhớ (Value): Dữ liệu đang được lưu trữ bên trong căn nhà đó.

Khi bạn khai báo int x = 5;, hệ điều hành sẽ cấp phát cho biến x một vùng nhớ (thường là 4 bytes trên các hệ thống 32-bit/64-bit phổ biến ngày nay). Giá trị lưu bên trong là 5. Lưu ý rằng kích thước của con trỏ lưu địa chỉ này cũng thay đổi: nó sẽ là 4 bytes trên hệ thống 32-bit và là 8 bytes trên hệ thống 64-bit (tham khảo đặc tả chuẩn của con trỏ tại Pointer types trên cppreference.com).

Để biết địa chỉ của biến x nằm ở đâu trên RAM, C cung cấp Toán tử lấy địa chỉ: &.

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;
}

2. Con trỏ cơ bản (Con trỏ cấp 1)

Con trỏ (Pointer Variable) đơn giản chỉ là một biến bình thường, nhưng thay vì lưu trữ các giá trị thông thường (như số 5, ký tự 'A'), nó lưu trữ địa chỉ bộ nhớ của một biến khác.

Khai báo và Sử dụng:

  • Khai báo con trỏ: Kiểu_dữ_liệu *tên_biến; (Dấu * báo hiệu đây là một con trỏ).
  • Toán tử giải tham chiếu * (Dereference Operator): Khi đặt trước một biến con trỏ đã có giá trị, nó sẽ truy cập trực tiếp vào ô nhớ mà con trỏ đang trỏ tới để đọc hoặc ghi đè giá trị.
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;
}

Sơ đồ bộ nhớ trực quan:

+-------------------+             +--------------------+
| Biến p (Con trỏ)  | ----------> |    Biến x (int)    |
| Giá trị: &x        |             |  Giá trị: 100 / 200|
| Địa chỉ: 0x1111   |             |  Địa chỉ: 0x2222   |
+-------------------+             +--------------------+
              

3. Truyền con trỏ vào hàm (Tham chiếu)

Trong C, mặc định khi truyền tham số vào hàm là truyền tham trị (pass by value), tức là hàm tự tạo ra bản sao mới nên mọi thay đổi bên trong hàm sẽ biến mất khi thoát hàm.

Để thay đổi trực tiếp giá trị của biến gốc bên ngoài hàm, ta phải truyền địa chỉ của biến (thông qua con trỏ) - gọi là truyền tham chiếu (pass 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;
}

📥 Tải về mã nguồn mẫu: swap.c

4. Con trỏ cấp 2 (Pointer to Pointer)

Vì bản thân con trỏ cũng là một biến nằm trên RAM, nên nó cũng có địa chỉ bộ nhớ riêng. Từ đó, ta có thể tạo ra một con trỏ trỏ tới một con trỏ khác - gọi là con trỏ cấp 2 (Double Pointer).

Khai báo sử dụng hai dấu sao: Kiểu_dữ_liệu **tên_biến;

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;
}

5. Phép toán con trỏ & Bản chất dịch chuyển ô nhớ (Pointer Arithmetic)

Một trong những điểm gây bối rối nhất cho người mới học là: tại sao khi ta cộng 1 vào một con trỏ (ptr + 1), địa chỉ của nó không tăng lên 1 byte, mà lại tăng lên 4 hoặc 8 bytes? Bản chất nằm ở Scaling Factor (Hệ số tỉ lệ).

Trong C, các phép toán số học trên con trỏ (cộng, trừ, tăng, giảm) luôn được tự động nhân với kích thước của kiểu dữ liệu mà con trỏ đó trỏ tới (sizeof(*ptr)). Công thức tính địa chỉ mới như sau:

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

Ví dụ: Trên hệ thống 64-bit, nếu ta có một con trỏ kiểu int (kích thước 4 bytes) đang trỏ tới địa chỉ 0x1000:

  • ptr + 1 sẽ trỏ tới địa chỉ: 0x1000 + 1 * sizeof(int) = 0x1000 + 4 = 0x1004.
  • ptr + 2 sẽ trỏ tới địa chỉ: 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;
}

Lưu ý đặc biệt về void*:

Theo tiêu chuẩn C99/C11, con trỏ vô kiểu void* không xác định được kích thước dữ liệu trỏ tới (sizeof(void) là bất hợp lệ). Do đó, không được phép thực hiện phép toán con trỏ trên void*. Tuy nhiên, một số trình biên dịch như GCC có phần mở rộng (extension) coi sizeof(void) == 1 để hỗ trợ tính toán byte-by-byte. Để an toàn và tương thích chéo tốt nhất, hãy luôn ép kiểu void* sang char* trước khi tính toán địa chỉ.

6. Các từ khóa hạn chế: Con trỏ hằng, Hằng con trỏ & Từ khóa restrict

Việc sử dụng từ khóa const kết hợp con trỏ có thể gây nhầm lẫn lớn. Hãy phân biệt kỹ hai định nghĩa sau:

  • Con trỏ hằng (Pointer to Constant): const int *p hoặc int const *p.
    Bạn không thể thay đổi giá trị tại ô nhớ mà con trỏ đang trỏ tới thông qua *p = ..., nhưng bạn có thể cho con trỏ p trỏ sang địa chỉ biến khác.
  • Hằng con trỏ (Constant Pointer): int * const p.
    Địa chỉ mà con trỏ p trỏ tới là cố định, bạn không thể cho p trỏ đi đâu khác, nhưng bạn có thể thay đổi giá trị tại ô nhớ đó thông qua *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;
}

Từ khóa tối ưu hóa restrict (C99):

Một tính năng nâng cao vô cùng quan trọng liên quan đến con trỏ là Pointer Aliasing (Hiện tượng chồng chéo con trỏ). Khi hai con trỏ cùng trỏ tới một vùng nhớ, trình biên dịch phải hoạt động cực kỳ cẩn trọng. Nó buộc phải nạp lại giá trị từ RAM vào các thanh ghi CPU nhiều lần vì nó không thể chắc chắn liệu một thao tác qua con trỏ này có ghi đè giá trị của con trỏ kia hay không.

Từ khóa restrict ra đời như một lời hứa của lập trình viên với trình biên dịch: "Trong phạm vi hiệu lực của con trỏ này, vùng nhớ mà nó trỏ tới sẽ chỉ được truy cập duy nhất thông qua chính nó (hoặc các con trỏ dẫn xuất trực tiếp từ nó)."

Nhờ đó, trình biên dịch có thể tự do tối ưu hóa mã nguồn, lưu trữ các giá trị trong thanh ghi (CPU registers) thay vì liên tục đọc/ghi trực tiếp từ RAM, giúp tăng tốc độ thực thi đáng kể.

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.
}

7. Con trỏ hàm & Giả lập Bảng phương thức ảo (Vtable OOP in C)

Trong C, bản thân hàm khi biên dịch cũng được nạp vào phân vùng mã nguồn (Text Segment) và có một địa chỉ bắt đầu cụ thể. Con trỏ hàm là con trỏ lưu trữ địa chỉ của một hàm, cho phép ta gọi hàm một cách động hoặc truyền hàm này làm đối số vào một hàm khác (Callback).

Cú pháp khai báo: Kiểu_trả_về (*tên_con_trỏ)(Danh_sách_kiểu_tham_số);

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;
}

Giả lập Lập trình hướng đối tượng (OOP) và Vtable trong C:

Bạn có bao giờ thắc mắc C++ hay Java thực hiện cơ chế Đa hình (Polymorphism) bằng cách nào? Câu trả lời chính là: Virtual Table (Vtable). Bằng cách sử dụng struct kết hợp với các con trỏ hàm, chúng ta hoàn toàn có thể tự xây dựng một mô hình OOP hoàn chỉnh ngay trong C.

Dưới đây là ví dụ minh họa cách định nghĩa một Interface ảo và lớp triển khai kế thừa lớp cha thông qua cơ chế vtable:

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;
}

📥 Tải về mã nguồn mẫu giả lập OOP: c_oop_vtable.c

Lời khuyên tự học:

Cách tốt nhất để hiểu rõ con trỏ là vẽ sơ đồ bộ nhớ ra giấy nháp mỗi khi viết code. Xác định rõ vùng nhớ lưu trữ gì, biến nào đang trỏ vào đâu. Điều này giúp bạn kiểm soát hoàn toàn bộ nhớ và viết code cực kỳ tự tin.

8. Các lỗi nghiêm trọng thường gặp khi dùng Con trỏ (Troubleshooting)

  • Lỗi Segmentation Fault (Lỗi phân vùng bộ nhớ): Xảy ra khi bạn cố giải tham chiếu một con trỏ NULL hoặc một con trỏ chứa địa chỉ không hợp lệ (không thuộc phân quyền của tiến trình hiện tại). Xem thêm chi tiết về lỗi phân mảnh bộ nhớ tại Pointer types trên cppreference.com.
  • Con trỏ hoang dã (Wild Pointer): Khai báo một con trỏ nhưng không gán địa chỉ ban đầu. Lúc này nó lưu một địa chỉ rác ngẫu nhiên trên RAM. Việc giải tham chiếu con trỏ này sẽ dẫn đến những lỗi chạy runtime khôn lường. Cách phòng tránh tốt nhất: Luôn gán NULL cho con trỏ khi vừa khai báo: int *ptr = NULL;.
  • Lỗi Dangling Pointer (Con trỏ lơ lửng): Con trỏ vẫn trỏ tới địa chỉ của một biến cục bộ đã bị thu hồi sau khi thoát hàm (hoặc vùng nhớ dynamic đã giải phóng bằng lệnh free). Việc cố truy xuất ô nhớ này sẽ nhận về giá trị rác.
📝 Kiểm tra kiến thức bài 5
Cho đoạn khai báo biến trong C sau:
int x = 10;
int *p = &x;
int **pp = &p;
Để thay đổi giá trị của biến x thành 50 thông qua con trỏ cấp 2 pp, câu lệnh nào sau đây là đúng?

Related Articles

Bài viết liên quan trong series

Lesson 9: Dynamic Memory Allocation and Memory Management in C Bài 9: Cấp phát bộ nhớ động & Quản lý bộ nhớ trong C Lesson 7: Structures (Struct), Union and Typedef in C Bài 7: Cấu trúc Struct, Union & từ khóa Typedef trong C Back to C Series Overview Quay lại Lộ trình C Series