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ử:
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 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ã
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.
Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.
Trả lời vài câu để chắc rằng em đã nắm bài.
Vì sao không được dereference một void* trực tiếp?
- 1
Gọi qua con trỏ hàm
Dùng công cụ ở Bước 3: cho
optrỏ tớibinhPhuong,x = 6.op(6)bằng bao nhiêu?Hoàn thành khi: 36 -
op(6)=binhPhuong(6)= 6×6. - 2
void* cần ép kiểu
Có
void *v = &x;(xlàint). Vì sao phải viết*(int*)vchứ 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
const con trỏ
Cho
const int *p = &a;thì dòng nào lỗi:*p = 9;hayp = &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
Đọc khai báo hàm
int (*op)(int) = tang;rồiop(9)gọi hàm nào, kết quả gì?Hoàn thành khi: Gọi
tang(9)= 10.oplà con trỏ hàm đang trỏ tớitang. - 5
Callback
apDung(arr, n, gapDoi)vớiarr = {1, 2, 3}thìarrtrở thành gì?Hoàn thành khi:
{2, 4, 6}-apDunggọigapDoicho từng phần tử. - 6
Ai sở hữu?
Hàm
Node* taoNode()trả về một con trỏmalloc. AI nên gọifree, 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”.