← Lập trình C nâng cao

Bài 10 · Nâng cao · 24 phút· Cập nhật 11/06/2026

Bẫy & quản lý bộ nhớ

Biên soạn bởi Nguyễn Anh Tuấn

Bẫy con trỏ và quản lý bộ nhớ trong C: void *, const với con trỏ, con trỏ hàm (function pointer) và callback - checklist bẫy cùng kỷ luật sở hữu (ownership).

void* là con trỏ “không kiểu”, trỏ được tới bất kỳ loại dữ liệu nào - đó là lý do malloc trả về void*. Nhưng vì không biết kiểu, bạn phải ép kiểu trước khi dereference:

voidptr.c

#include <stdio.h>

int main() {
    int x = 65;
    void *v = &x;          // tro toi bat ky kieu nao
    // printf("%d", *v);   // LOI: khong deref void* truc tiep
    int *pi = (int*)v;     // ep ve int* truoc
    printf("%d\n", *pi);   // 65
    return 0;
}

Kết quả khi chạy

65
  • void* trỏ tới mọi kiểu - dùng cho hàm tổng quát (malloc, memcpy, qsort).
  • KHÔNG dereference void* trực tiếp: phải ép về con trỏ cụ thể trước.
  • malloc trả về void*, tự chuyển sang con trỏ đích (không cần ép trong C).

const giúp trình biên dịch chặn sửa nhầm. Vị trí của const đổi cả ý nghĩa - đọc khai báo từ phải sang trái:

const-ptr.c

#include <stdio.h>

int main() {
    int a = 1, b = 2;
    const int *p = &a;   // khong sua duoc *p; doi duoc p
    // *p = 9;           // LOI bien dich
    p = &b;              // OK: tro sang b
    printf("%d\n", *p);  // 2
    return 0;
}

Kết quả khi chạy

2
  • const int *p - không sửa được DỮ LIỆU (*p), nhưng đổi được con trỏ p.
  • int *const p - sửa được *p, nhưng KHÔNG đổi được p (trỏ cố định).
  • const char * cho tham số chuỗi chỉ-đọc - quen thuộc trong thư viện C.

Hàm cũng có địa chỉ. Con trỏ hàm lưu địa chỉ đó để gọi gián tiếp hoặc chọn việc lúc chạy. Chọn hàm cho op trỏ tới và thử:

Cho con trỏ hàm op trỏ tới hàm nào?
int tang(int x)       { return x + 1; }
int giam(int x)       { return x - 1; }
int gapDoi(int x)     { return x * 2; }
int binhPhuong(int x) { return x * x; }

int (*op)(int) = tang;  // op trỏ tới tang
op(5) ≡ (*op)(5) ≡ tang(5) = 6
Callback - áp dụng op cho mỗi phần tử mảng:
[1, 2, 3, 4] - op → [2, 3, 4, 5]

Một con trỏ hàm lưu ĐỊA CHỈ của một hàm, cho phép chọn việc cần làm lúc chạy. Đây là nền tảng của callback (vd qsort), bảng điều phối, và plugin.

callback.c

#include <stdio.h>

int gapDoi(int x) { return x * 2; }

// nhan MOT HAM lam doi so (callback)
void apDung(int *a, int n, int (*f)(int)) {
    for (int i = 0; i < n; i++) a[i] = f(a[i]);
}

int main() {
    int arr[4] = {1, 2, 3, 4};
    apDung(arr, 4, gapDoi);                 // truyen ham gapDoi
    for (int i = 0; i < 4; i++) printf("%d ", arr[i]);  // 2 4 6 8
    return 0;
}

Kết quả khi chạy

2 4 6 8

Gom lại mọi cạm bẫy con trỏ của cả khoá - đây là checklist để rà soát code:

  • NULL / con trỏ hoang: luôn khởi tạo; kiểm tra trước khi dereference.
  • Con trỏ treo (dangling): trả &biến_cục_bộ, hoặc dùng sau free → use-after-free.
  • Double free: free cùng khối hai lần → đặt p = NULL sau free (free(NULL) vô hại).
  • Rò rỉ (leak): mỗi malloc một free; một chủ sở hữu rõ ràng.
  • Tràn bộ đệm & lệch một đơn vị: nhớ chỗ cho \0; chỉ số 0…n−1.
  • Quên ép kiểu void*; nhầm . với ->; quên * khi cần địa chỉ.

C trao cho bạn toàn quyền với bộ nhớ - kèm trách nhiệm. Vài nguyên tắc giúp code an toàn:

  • Sở hữu rõ ràng: mỗi khối heap có ĐÚNG MỘT chủ chịu trách nhiệm free.
  • Ghép cặp alloc/free ngay khi viết malloc - đừng để “tính sau”.
  • Đặt con trỏ = NULL sau khi free; kiểm tra NULL sau khi malloc.
  • Tài liệu hoá: hàm trả con trỏ malloc phải nói rõ “ai free”.
  • Dùng công cụ: valgrind / AddressSanitizer, và -Wall -Wextra.

Làm chủ con trỏ - tiếp theo: tổ chức mã

Từ địa chỉ & con trỏ, qua hàm, mảng, chuỗi, bố cục bộ nhớ, cấp phát động, struct & danh sách liên kết, tới con trỏ hàm - bạn đã nắm phần khó nhất và mạnh nhất của C. Hai bài tiếp theo sẽ dạy cách TỔ CHỨC tất cả thành chương trình gọn gàng như một lập trình viên senior: header & module, rồi đóng gói & thư viện.

Câu hỏi thường gặp

Con trỏ “không kiểu” - trỏ tới bất kỳ loại dữ liệu nào. malloc, memcpy, qsort dùng void* để làm việc tổng quát. Bạn KHÔNG dereference void* trực tiếp (nó không biết kích thước/kiểu); phải ép về con trỏ cụ thể trước (int*, struct X*…).

Đọc từ phải qua trái. const int *p: “p là con trỏ tới int hằng” → không sửa được *p, nhưng đổi được p (trỏ chỗ khác). int *const p: “p là con trỏ HẰNG tới int” → sửa được *p, nhưng không đổi được p. const int *const p khoá cả hai.

Lưu ĐỊA CHỈ của một hàm để gọi gián tiếp, chọn việc cần làm lúc chạy. Nền tảng của callback (qsort nhận hàm so sánh), bảng điều phối (dispatch table), máy trạng thái, plugin. Tên hàm “suy biến” thành địa chỉ giống tên mảng.

op là con trỏ tới hàm nhận một int và trả về int”. Dấu ngoặc quanh (*op) là BẮT BUỘC: int *op(int) lại có nghĩa khác hẳn (hàm trả về int*). Khi bí, dùng typedef cho gọn: typedef int (*Op)(int);

Một quy ước: mỗi khối heap có ĐÚNG MỘT phần code chịu trách nhiệm free nó (chủ sở hữu). Ghi rõ trong tài liệu hàm “ai cấp, ai phải free”. Kỷ luật này loại bỏ phần lớn double-free (hai nơi cùng free) và rò rỉ (không ai free).

valgrind và AddressSanitizer (biên dịch với -fsanitize=address) phát hiện rò rỉ, use-after-free, tràn bộ đệm lúc chạy. Bật cảnh báo trình biên dịch (-Wall -Wextra) để bắt lỗi sớm. Đừng chỉ “chạy thấy đúng” - bộ nhớ hỏng có thể im lặng rất lâu.

Bài Sanitizers - khoá C Thực Chiến · Toolchain →

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

Vì sao không được dereference một void* trực tiếp?

  1. 1

    Gọi qua con trỏ hàm

    Dùng công cụ ở Bước 3: cho op trỏ tới binhPhuong, x = 6. op(6) bằng bao nhiêu?

    Hoàn thành khi: 36 - op(6) = binhPhuong(6) = 6×6.

  2. 2

    void* cần ép kiểu

    void *v = &x; (xint). Vì sao phải viết *(int*)v chứ không *v?

    Hoàn thành khi: void* không biết kiểu/kích thước để dereference; phải ép về int* trước rồi mới đọc.

  3. 3

    const con trỏ

    Cho const int *p = &a; thì dòng nào lỗi: *p = 9; hay p = &b;? Vì sao?

    Hoàn thành khi: *p = 9; LỖI (không sửa được dữ liệu được trỏ). p = &b; OK (đổi được con trỏ).

  4. 4

    Đọc khai báo hàm

    int (*op)(int) = tang; rồi op(9) gọi hàm nào, kết quả gì?

    Hoàn thành khi: Gọi tang(9) = 10. op là con trỏ hàm đang trỏ tới tang.

  5. 5

    Callback

    apDung(arr, n, gapDoi) với arr = {1, 2, 3} thì arr trở thành gì?

    Hoàn thành khi: {2, 4, 6} - apDung gọi gapDoi cho từng phần tử.

  6. 6

    Ai sở hữu?

    Hàm Node* taoNode() trả về một con trỏ malloc. AI nên gọi free, và làm sao người dùng biết?

    Hoàn thành khi: Bên GỌI (caller) sở hữu khối và phải free. Phải ghi rõ trong tài liệu/tên hàm rằng “người gọi chịu trách nhiệm free”.