Một file thực thi ELF không phải một khối byte đồng nhất — nó được chia thành các section, mỗi loại dữ liệu một chỗ: mã lệnh ở một section, hằng chỉ đọc ở một section, biến toàn cục ghi được ở một section, và một section đặc biệt cho dữ liệu zero. Bài này đo bốn section chính bằng size và nm, và một câu hỏi tưởng đơn giản — "một mảng toàn số 0 tốn bao nhiêu trong file?" — cho ra đáp án làm tôi bất ngờ.
Bốn section chính
Trình biên dịch xếp dữ liệu vào các section theo tính chất của chúng:
.text: mã lệnh — đọc và chạy được, không ghi..rodata: hằng chỉ đọc — chuỗi literal ("hello"), mảngconst. Ghi vào đây là SIGSEGV (đúng như bài trang chỉ đọc của sê-ri hệ điều hành đã đo)..data: biến toàn cục/static khởi tạo khác 0, ghi được. Nội dung được chép nguyên vào file..bss: biến toàn cục/static bằng 0 hoặc chưa khởi tạo.
Điểm mấu chốt phân biệt .data với .bss: .bss không tốn byte nào trong file. Vì mọi giá trị trong đó đều là 0, file chỉ cần ghi một con số "cần N byte zero", và hệ điều hành cấp phát rồi xóa 0 vùng đó lúc nạp chương trình. .data thì ngược lại — mỗi byte giá trị khác 0 phải được chép thật vào file. Đây là một tối ưu đơn giản mà hiệu quả: một chương trình khai một bảng đệm 100 MB toàn 0 vẫn cho ra file thực thi chỉ vài chục KB, vì cả 100 MB đó chỉ là một dòng ghi chú 'cấp 100 MB zero' trong phần đầu section, không phải 100 MB byte thật. Tôi đo sự khác biệt này với một mảng 1 triệu int (4 MB).
Đo: mảng zero miễn phí, mảng nonzero tốn 4MB
Dùng size (in tổng text/data/bss) và nm (cho biết ký hiệu ở section nào), với int arr[1000000] khai theo ba cách:
| Khai báo | .bss |
Kích thước file |
|---|---|---|
int arr[N]; (chưa init) |
4.000.016 | không đổi |
int arr[N] = {0}; |
4.000.016 | không đổi |
int arr[N] = {1}; |
8 | +4 MB |
Mảng chưa khởi tạo nằm trong .bss — file không lớn thêm chút nào dù mảng chiếm 4 MB bộ nhớ lúc chạy. nm xác nhận: ký hiệu arr mang mã B (.bss). Còn phiên bản = {1} cho mã D (.data) — chỉ một chữ cái khác nhau trong output của nm, nhưng là 4 megabyte khác nhau trên đĩa. Điều bất ngờ nằm ở dòng thứ hai và thứ ba.
Một lần tôi đo hớ: một số 1 biến 4MB miễn phí thành 4MB tốn đĩa
Tôi đoán rằng int arr[1000000] = {0}; — vì có dấu = {0}, tức đã khởi tạo tường minh — sẽ là "dữ liệu khởi tạo", phải chép 4 MB số 0 vào file (nằm trong .data). Đo ra hoàn toàn không: mảng = {0} vẫn nằm trong .bss (file y hệt phiên bản chưa khởi tạo, nm cho B). Trình biên dịch nhận ra "toàn số 0" và xếp nó vào .bss — vì lưu 4 triệu số 0 vào file là vô nghĩa khi .bss được zero sẵn lúc nạp.
Nhưng chỉ cần một phần tử khác 0 — int arr[1000000] = {1}; (phần tử đầu là 1, còn lại 0) — là cả mảng nhảy sang .data, và file phình thêm đúng 4 MB (nm cho D). Một con số 1 duy nhất biến một mảng 4 MB miễn phí trên đĩa thành một mảng 4 MB tốn đĩa. Ranh giới không phải "có khởi tạo hay không", mà là "có byte nào khác 0 cần chép vào file hay không". {0} không có byte khác 0 → .bss; {1} có → phải chép cả mảng (kể cả 999.999 số 0 phía sau) vào .data, vì .data là một khối liền mạch không thể "một nửa ở file, một nửa zero". Trình liên kết không có cách nào nói 'chép phần tử đầu, còn lại để zero' cho một mảng nằm trong một section duy nhất — hoặc cả mảng ở .data với đầy đủ byte, hoặc cả mảng ở .bss toàn 0, không có ở giữa.
Bài học đo lường: trực giác "đã khởi tạo thì tốn chỗ" sai — cái quyết định là có dữ liệu khác 0 để lưu hay không. Tôi đã phân loại theo cú pháp (= {...} hay không) thay vì theo nội dung (toàn 0 hay có khác 0). Chỉ khi đo size và nm mới thấy = {0} và không khởi tạo là một, còn = {1} là chuyện hoàn toàn khác. (Một chi tiết phụ đáng nhớ: chuỗi literal "..." nằm trong .rodata, còn con trỏ msg trỏ tới nó thì nằm trong .data — con trỏ ghi được, chuỗi thì không.)
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên là khai bảng lớn toàn 0 gần như miễn phí trên đĩa. Một buffer tĩnh, một bảng tra khởi tạo dần, một mảng đệm cỡ lớn — nếu để mặc định 0 (không khởi tạo, hoặc = {0}), chúng vào .bss và không làm file thực thi to lên. Nhưng nếu bạn khởi tạo chúng với dữ liệu khác 0 (một bảng hằng số tra cứu chẳng hạn), toàn bộ kích thước đó nằm trong .data và nằm trong file. Với dữ liệu hằng lớn chỉ-đọc, đặt const để nó vào .rodata (chia sẻ được giữa các tiến trình, và được bảo vệ khỏi ghi nhầm).
Hệ quả thứ hai là .bss giải thích vì sao một mảng lớn "chưa dùng" vẫn có thể tốn RAM lúc chạy dù không tốn đĩa. File nhỏ không có nghĩa chương trình nhẹ bộ nhớ: 4 MB .bss không nằm trong file nhưng vẫn được cấp phát khi nạp (dù nhờ cấp phát lười và trang zero chia sẻ, RAM vật lý chỉ tốn khi bạn thật sự chạm vào — đúng cơ chế đã đo ở sê-ri hệ điều hành). Kích thước file và dung lượng bộ nhớ là hai con số khác nhau.
Hệ quả thứ ba là đọc size và readelf -S để hiểu chương trình của mình nằm ở đâu. Khi một binary phình to bất ngờ, size cho biết là .text (nhiều mã, có thể do nội tuyến/mở vòng), .data (nhiều dữ liệu khởi tạo khác 0), hay .rodata (nhiều chuỗi/hằng). Con số mang theo: ELF chia thành .text (mã), .rodata (hằng chỉ đọc), .data (biến init khác 0, tốn file), .bss (biến zero/chưa init, KHÔNG tốn file); và một phần tử khác 0 kéo cả mảng từ .bss miễn phí sang .data tốn đúng kích thước — nên = {0} free còn = {1} tốn 4MB. Cái quyết định một mảng tốn đĩa hay không là nội dung của nó có khác 0 hay không, chứ không phải bạn có gõ dấu bằng hay không.
Thử ba mươi giây
Viết int arr[1000000]; ở phạm vi toàn cục và một main rỗng, dịch gcc a.c -o a, rồi chạy size a — để ý cột bss khoảng 4 triệu, còn ls -l a cho file nhỏ. Giờ đổi thành int arr[1000000] = {1};, dịch lại và ls -l: file to thêm 4 MB. Cuối cùng thử = {0}: file lại nhỏ như cũ. Chạy nm a | grep arr mỗi lần để thấy ký hiệu chuyển giữa B (.bss) và D (.data). Chỉ ba lần dịch là bạn thấy tận mắt vì sao một số 1 duy nhất đáng giá 4 megabyte trên đĩa.