Mọi biến trong một chương trình C đều nằm ở đâu đó, và nơi nó nằm quyết định hai điều bạn không thể thay đổi về sau: nó sống được bao lâu, và bạn có thể có bao nhiêu. C cho bạn ba vùng lưu trữ, và chọn sai sẽ dẫn đến hoặc crash, hoặc rò rỉ bộ nhớ. Trang này trình bày cả ba và chỉ ra bug kinh điển khi hiểu sai vòng đời.
Ba vùng lưu trữ
high addresses
+---------------------------+
| stack | locals, parameters, return addresses
| grows downward | | freed automatically on return
| v |
+---------------------------+
| (unused gap) |
+---------------------------+
| ^ |
| grows upward | |
| heap | malloc / calloc / realloc blocks
+---------------------------+ freed only by free()
| static / global data | globals and statics, whole run
+---------------------------+
| code (text) | the machine code, read-only
+---------------------------+
low addresses
- Lưu trữ tự động (stack) chứa tham số hàm và các biến cục bộ không phải static. Một phần stack được dành ra khi vào hàm và được trả lại khi hàm trả về. Kích thước được cố định lúc biên dịch.
- Lưu trữ động (heap) chứa mọi thứ từ
malloc,callocvàrealloc. Kích thước được quyết định lúc chạy; vòng đời chỉ kết thúc khifree. - Lưu trữ tĩnh chứa biến toàn cục và mọi thứ khai báo
static. Nó tồn tại suốt quá trình chạy chương trình và được khởi tạo bằng 0 trước khimainbắt đầu.
Các địa chỉ trong sơ đồ là cách bố trí thường gặp, không phải điều được đảm bảo: tiêu chuẩn C mô tả vòng đời, không mô tả cách bố trí.
Lưu trữ tự động trong thực tế
Mỗi lần gọi demo nhận một local mới và một table mới. Không có gì phải giải phóng bằng tay, không có gì có thể rò rỉ, và việc cấp phát chỉ tốn một lệnh dịch con trỏ stack. Đó là lý do biến cục bộ thông thường nên là lựa chọn mặc định: đó là kiểu lưu trữ nhanh nhất và an toàn nhất mà C có.
Vấn đề nằm ở dấu ngoặc nhọn đóng. Khi nó chạy xong, vùng nhớ đó biến mất.
Con trỏ treo
Đây là bug mà mọi lập trình viên C đều từng viết một lần:
/* BROKEN: returns the address of memory that no longer exists */
int *make_counter(void) {
int count = 0;
return &count; /* count dies at this brace */
}
int main(void) {
int *p = make_counter();
*p = 5; /* writing into a dead stack frame */
return 0;
}
&count là một địa chỉ hoàn toàn hợp lệ khi make_counter đang chạy. Khi hàm trả về, vùng stack đó được giao cho hàm nào được gọi tiếp theo, nên giờ p trỏ vào các biến cục bộ của hàm khác. Đọc thì ra rác; ghi thì làm hỏng chúng. GCC và Clang cảnh báo đúng dạng này (-Wreturn-local-addr), nên hãy biên dịch với cảnh báo được bật.
Cùng bug đó đội lốt khác khi dùng mảng, và khi ấy cảnh báo thường không xuất hiện:
Phiên bản lỗi của hàm đó sẽ dựng chuỗi trong một biến cục bộ char buf[64] rồi return buf;, tức trả về địa chỉ của một bộ đệm không còn tồn tại ngay đúng khoảnh khắc đó.
Ba cách sửa
1. Nơi gọi cung cấp bộ đệm (như ví dụ trên). Không cấp phát, không phải bận tâm ai sở hữu, và là phong cách phổ biến nhất trong các thư viện C. Hàm nhận kích thước để có thể ghi trong giới hạn đó.
2. Trả về vùng nhớ heap, và nói rõ ai giải phóng nó.
Khối heap sống lâu hơn hàm là có chủ đích: đó chính là ý nghĩa của bộ nhớ động. Cái giá là dòng chú thích về quyền sở hữu và lệnh free ở nơi gọi.
3. Dùng lưu trữ tĩnh, khi một bộ đệm dùng chung duy nhất là chấp nhận được:
static bên trong một hàm giữ phạm vi của biến là cục bộ nhưng cho nó vòng đời của cả chương trình, nên trả về địa chỉ của nó là hợp lệ. Đổi lại, chỉ có đúng một bản của nó: mọi nơi gọi đều dùng chung, khiến cách làm này không dùng được trong code đa luồng và gây bất ngờ ngay cả trong code đơn luồng khi hai nơi gọi cùng giữ con trỏ một lúc.
Kích thước: khi nào stack cạn
Không gian stack nhỏ và cố định. Luồng chính thường được 1 MB trên Windows và 8 MB trên Linux; một luồng được tạo thêm thường ít hơn nhiều. Heap bị giới hạn bởi bộ nhớ khả dụng của hệ thống.
void bad(void) {
int huge[1000000]; /* ~4 MB of stack - likely crashes on entry */
huge[0] = 1;
}
Không có thông báo chẩn đoán nào và không có NULL để kiểm tra: chương trình đơn giản là chết, thường với segmentation fault, trước cả khi dòng đầu tiên của thân hàm chạy. Phiên bản dùng heap báo lỗi đàng hoàng:
Đệ quy sâu làm cạn stack theo cùng cách, mỗi lần một frame: một hàm đệ quy mất kiểm soát là nguyên nhân phổ biến nhất gây stack overflow trong thực tế.
Chi phí và tính cục bộ
Cấp phát trên stack chỉ là một phép tính số học trên một thanh ghi. Cấp phát trên heap là một lời gọi thư viện tìm một khối phù hợp, có thể phải khóa, và đôi khi xin thêm bộ nhớ từ hệ điều hành. Trong một vòng lặp chạy liên tục, sự khác biệt đó đo được.
Dữ liệu trên stack cũng gọn và vừa được truy cập gần đây, nên thường nằm sẵn trong cache. Các khối heap có thể nằm rải rác. Không điều nào trong số này nên tự mình quyết định thiết kế, vì vòng đời đúng phải đặt lên trước, nhưng giữa hai thiết kế cùng đúng, thiết kế dùng stack thường nhanh hơn.
Nhìn thấy các vùng nhớ
In địa chỉ ra giúp cách bố trí trở nên cụ thể. Giá trị chính xác khác nhau mỗi lần chạy (các hệ thống hiện đại xáo trộn ngẫu nhiên chúng), nhưng cách chúng gom nhóm thì thấy rõ:
Biến toàn cục và biến static nằm cạnh nhau; khối heap nằm ở chỗ khác; biến cục bộ thường nằm xa cả hai. Hãy ép kiểu sang void * cho %p, vì đặc tả định dạng đó yêu cầu vậy.
Lựa chọn
Dùng stack khi:
- kích thước đã biết lúc biên dịch,
- dữ liệu chỉ cần bên trong hàm này và các hàm nó gọi,
- và dữ liệu nhỏ, cỡ vài kilobyte, không phải megabyte.
Dùng heap khi:
- kích thước phụ thuộc vào đầu vào, một tệp, hoặc một phép tính,
- dữ liệu phải sống lâu hơn hàm đã tạo ra nó,
- hoặc dữ liệu đủ lớn để đe dọa giới hạn stack.
Dùng static khi:
- chỉ nên có đúng một instance cho toàn bộ chương trình,
- và việc mọi nơi gọi dùng chung nó thật sự là đúng.
Mặc định là stack. Hãy dùng heap khi một trong ba lý do của nó áp dụng, và khi đó, hãy theo các quy tắc sở hữu ở trang rò rỉ bộ nhớ để khối nhớ bạn đã kéo dài vòng đời vẫn được giải phóng.
Hai bug đối xứng
Đáng gọi tên chúng cùng nhau, vì đó là cùng một câu hỏi về vòng đời được trả lời theo hai cách:
- Con trỏ treo: vùng nhớ chết trước con trỏ. Trả về
&local, hoặc dùng con trỏ saufree. Chương trình đọc hoặc ghi vào vùng lưu trữ giờ đã thuộc về thứ khác. - Rò rỉ bộ nhớ: con trỏ chết trước vùng nhớ. Đánh mất tham chiếu cuối cùng tới một khối
malloc. Không có gì hỏng ngay lập tức; tiến trình chỉ phình dần ra.
Cả hai đều đến từ sự lệch nhau giữa thời gian dữ liệu cần sống và vùng nhớ bạn đặt nó vào. Hãy quyết định vòng đời trước, rồi vùng nhớ sẽ theo sau.
Câu hỏi thường gặp
Stack và heap trong C khác nhau thế nào?
Stack chứa các biến cục bộ: trình biên dịch quyết định kích thước của chúng, chúng được tạo khi vào hàm và bị hủy khi hàm trả về, và việc cấp phát gần như không tốn gì. Heap chứa các khối malloc: bạn chọn kích thước lúc chạy, khối tồn tại cho đến khi bạn free nó, và việc cấp phát có chi phí thật sự.
Vì sao không thể trả về con trỏ tới biến cục bộ trong C?
Vì vùng lưu trữ của biến cục bộ được giải phóng ngay khi hàm trả về. Con trỏ vẫn giữ địa chỉ đó, nhưng vùng nhớ giờ thuộc về lời gọi hàm tiếp theo: đọc thì ra giá trị rác, ghi thì làm hỏng dữ liệu chẳng liên quan. Đó là con trỏ treo (dangling pointer). Hãy trả về một khối malloc, hoặc để nơi gọi cung cấp bộ đệm.
Stack trong C lớn bao nhiêu?
Thường từ 1 đến 8 MB cho luồng chính, và ít hơn nhiều cho các luồng phụ: đủ nhỏ để một biến cục bộ int big[1000000]; thường làm chương trình crash ngay khi vào hàm. Heap bị giới hạn bởi bộ nhớ khả dụng của hệ thống, nên dữ liệu lớn hoặc chưa biết kích thước thuộc về heap.
Khi nào nên dùng heap thay vì stack trong C?
Ba trường hợp: kích thước chưa biết cho đến lúc chạy, dữ liệu phải sống lâu hơn hàm đã tạo ra nó, hoặc khối dữ liệu quá lớn so với stack (đại khái mọi thứ trên vài trăm kilobyte). Mọi thứ khác nên là biến cục bộ thông thường: nhanh hơn và không thể bị rò rỉ.