Ở bài trước về căn lề và đệm struct ta thấy trình biên dịch thêm byte đệm để mỗi trường nằm đúng chỗ. Trường bit (bit field) là hướng ngược lại: thay vì cho mỗi giá trị cả một int, ta bảo trình biên dịch nhồi nhiều giá trị nhỏ vào chung vài bit — unsigned a:3 nghĩa là a chỉ chiếm 3 bit. Nghe như một cách tiết kiệm bộ nhớ gọn gàng cho các lá cờ và trường header. Bài này biên dịch thật trong container, đo sizeof, đọc byte thô, và đọc cả assembly — rồi phát hiện một struct tôi đinh ninh là 1 byte hóa ra 4, và một điều còn nguy hiểm hơn kích thước.
Trường bit gói dữ liệu thế nào
Một trường bit khai báo số bit mà một thành viên struct được phép chiếm, viết sau dấu hai chấm: unsigned a:3 cho a ba bit (giá trị 0–7), unsigned b:5 cho b năm bit. Trình biên dịch xếp chúng vào chung một đơn vị lưu trữ (storage unit) và cho bạn truy cập theo tên như trường thường: s.a = 5. Công dụng kinh điển là gói một mớ cờ boolean — thay vì tám biến unsigned char tốn tám byte, tám cờ 1-bit vừa trong một byte; hoặc để mô tả các trường chật chội trong một header giao thức hay một thanh ghi phần cứng.
Điều dễ bỏ qua — và là cái làm tôi đo hớ — là kiểu bạn khai trước dấu hai chấm quyết định đơn vị lưu trữ. unsigned a:1 dùng unsigned int làm đơn vị, tức 4 byte; unsigned char a:1 dùng char làm đơn vị, 1 byte. Số bit bạn dùng thì như nhau, nhưng cái hộp chứa chúng thì to nhỏ tùy kiểu cơ sở.
Đo: struct tám cờ không phải một byte
Tôi khai một struct tám cờ, mỗi cờ một bit, và một struct tám unsigned char thường để so:
struct flags { unsigned a:1, b:1, c:1, d:1, e:1, f:1, g:1, h:1; };
struct bools { unsigned char a,b,c,d,e,f,g,h; };
Biên dịch với gcc -O2 trên aarch64 rồi in sizeof:
sizeof(flags 8x1bit) = 4 | sizeof(bools 8xchar) = 8
sizeof(fields 3+5bit)= 4
sizeof(straddle 20+20bit) = 8
flags chiếm 4 byte, không phải 1. Tám cờ một bit gộp lại đúng 8 bit — vừa một byte — nhưng vì tôi khai kiểu cơ sở là unsigned (tức int), đơn vị lưu trữ là 4 byte, và cả struct chiếm trọn một đơn vị đó. So với tám unsigned char (8 byte) thì vẫn tiết kiệm một nửa, nhưng không phải cái mức "1 byte" tôi tưởng.
straddle gồm hai trường 20 bit, tổng 40 bit — vượt quá một int 32 bit — nên nó chiếm hai đơn vị int = 8 byte. Còn việc trường thứ hai có được cắt đôi vắt qua ranh giới int hay bắt đầu hẳn ở đơn vị mới thì tùy trình biên dịch, nhưng sizeof thì chắc chắn là 8.
Để xác nhận thủ phạm đúng là kiểu cơ sở, tôi đổi unsigned thành unsigned char:
base unsigned int : sizeof = 4
base unsigned char: sizeof = 1
Cùng tám cờ một bit, chỉ đổi kiểu khai từ unsigned sang unsigned char, kích thước từ 4 byte tụt xuống 1 byte. Vậy là kiểu cơ sở, không phải số bit, mới định đoạt kích thước struct.
Đọc assembly: mỗi lần chạm là đọc-sửa-ghi
Trường bit gọn trong bộ nhớ, nhưng truy cập nó không miễn phí. Tôi biên dịch một hàm đọc và một hàm ghi trường 5 bit rồi đọc assembly gcc -O2 -S:
get_b: ldr w0, [x0]
ubfx x0, x0, 3, 5 ; cắt 5 bit bắt đầu từ bit 3
ret
set_b: ldrb w2, [x0]
bfi w2, w1, 3, 5 ; chèn 5 bit vào vị trí bit 3
strb w2, [x0]
ret
Đọc một trường bit là nạp cả đơn vị rồi ubfx (unsigned bitfield extract) để cắt đúng dải bit ra — một lệnh thêm so với đọc một trường thường. Ghi thì tốn hơn: ldrb nạp byte hiện tại, bfi (bitfield insert) chèn các bit mới vào đúng chỗ mà không đụng các bit hàng xóm, rồi strb ghi cả byte trở lại. Đó là một chu trình đọc-sửa-ghi (read-modify-write): để đổi vài bit, CPU phải đọc trọn đơn vị, sửa, rồi ghi lại. Hệ quả quan trọng: ghi một trường bit không nguyên tử — hai luồng cùng ghi hai trường bit khác nhau trong cùng một đơn vị vẫn có thể giẫm lên nhau, vì mỗi bên đọc-sửa-ghi cả đơn vị và có thể ghi đè phần bên kia vừa sửa.
Một lần tôi đo hớ: tưởng đếm bit là ra kích thước
Tôi vào bài với một phép tính tưởng chừng hiển nhiên: tám cờ, mỗi cờ một bit, tổng tám bit, tức đúng một byte — nên struct flags phải là 1 byte. Đo ra 4 byte, gấp bốn dự đoán.
Sai lầm nằm ở chỗ tôi quên rằng số bit không tự nó quyết định kích thước; kiểu cơ sở mới quyết định. Khai unsigned a:1 là bảo trình biên dịch dùng một unsigned int (4 byte) làm cái hộp chứa các bit, và dù chỉ dùng 8 trong 32 bit của hộp đó, cả struct vẫn to bằng cái hộp. Đổi kiểu khai sang unsigned char a:1 thì hộp co lại còn 1 byte và struct đúng 1 byte như tôi mong. Bài học đo lường: một con số tưởng suy ra được bằng số học đơn giản (đếm bit) lại phụ thuộc một biến ẩn tôi không khai vào phép tính (kiểu cơ sở) — y hệt lần đo trước, khi sizeof một struct không phải tổng kích thước các trường vì còn có đệm.
Nhưng khi đọc byte thô, tôi vấp một cái bẫy còn đáng sợ hơn kích thước. Tôi đặt trường đầu tiên bằng 1 rồi in byte thô của struct:
đặt a=1 (trường đầu), byte thô = 0x01 (bit 0 được bật)
Trên gcc/aarch64 little-endian, trường đầu tiên nằm ở bit thấp nhất. Nghe như một quy luật để dựa vào — nhưng nó không phải. Chuẩn C nói rõ thứ tự các trường bit trong một đơn vị lưu trữ là do trình biên dịch định đoạt (implementation-defined). Một trình biên dịch khác, hoặc một kiến trúc big-endian, hoàn toàn có thể đặt trường đầu vào bit cao nhất, cho ra 0x80 thay vì 0x01. Nghĩa là nếu tôi dùng một struct trường bit để mô tả bố cục bit của một thanh ghi phần cứng hay một trường trong header giao thức mạng — rồi đọc/ghi nó như một số nguyên thô — thì cùng đoạn mã đó cho ra bit pattern khác nhau trên các nền khác nhau. Code chạy đúng trên máy tôi sẽ lặng lẽ sai trên máy khác, không một lời cảnh báo.
Đó là điểm mấu chốt tôi suýt bỏ lỡ: trường bit an toàn khi bạn coi nó như một cách gói dữ liệu nội bộ và luôn truy cập theo tên trường — trình biên dịch lo mọi việc cắt/chèn bit, và bố cục cụ thể không quan trọng vì bạn không bao giờ nhìn vào byte thô. Nó trở thành cái bẫy khi bạn tin vào bố cục bit cụ thể để khớp một định dạng bên ngoài.
Vì sao điều này quan trọng khi lập trình
Hệ quả đầu tiên: dùng trường bit để tiết kiệm bộ nhớ, đừng dùng để khớp định dạng nhị phân ngoài. Muốn gói vài chục cờ boolean trong một struct cho gọn RAM? Tuyệt — cứ khai trường bit, truy cập theo tên, và chọn kiểu cơ sở nhỏ nhất đủ dùng (unsigned char cho một byte cờ) để không phí cả một int. Nhưng muốn phân tích một header TCP, một thanh ghi thiết bị, một định dạng file nhị phân? Đừng ánh xạ struct trường bit lên byte thô rồi tin nó khớp — thứ tự bit, cách vượt biên đơn vị, và cả endian đều tùy trình biên dịch. Cách bền vững là dùng mặt nạ và dịch bit tường minh trên một kiểu nguyên có độ rộng cố định: uint16_t v; unsigned flag = (v >> 3) & 0x1f;. Nó dài dòng hơn, nhưng bố cục bit nằm hẳn trong mã bạn viết, không phó mặc cho trình biên dịch.
Hệ quả thứ hai: trường bit đánh đổi kích thước lấy tốc độ truy cập, và mất tính nguyên tử. Mỗi lần đọc là một lệnh cắt bit thêm, mỗi lần ghi là một chu trình đọc-sửa-ghi. Với dữ liệu ít chạm mà nhiều bản (một mảng triệu struct cờ), tiết kiệm bộ nhớ thường đáng cái giá vài lệnh. Nhưng với dữ liệu nóng trong vòng lặp chặt, hoặc dữ liệu bị nhiều luồng ghi, hãy cân nhắc: một trường bit chia sẻ giữa các luồng không an toàn kể cả khi các luồng động tới các trường khác nhau, vì chúng dùng chung một đơn vị đọc-sửa-ghi. Cần nguyên tử thì dùng một kiểu nguyên riêng và thao tác nguyên tử, đừng dùng trường bit.
Hệ quả thứ ba, và là sợi chỉ xuyên suốt sê-ri này: kích thước và bố cục của kiểu dữ liệu là do trình biên dịch định đoạt, không phải do trực giác số học của bạn. sizeof một struct trường bit phụ thuộc kiểu cơ sở; thứ tự bit phụ thuộc trình biên dịch và endian; đệm phụ thuộc quy tắc căn lề. Con số mang theo: struct tám cờ một bit chiếm 4 byte với kiểu cơ sở int, 1 byte với unsigned char — và thứ tự bit trong đơn vị là tùy trình dịch, nên trường bit hợp để gói dữ liệu nội bộ truy cập theo tên, tuyệt đối không hợp để khớp bố cục bit của một định dạng ngoài. Khi cần một bố cục nhị phân chính xác, hãy viết nó ra bằng mặt nạ và dịch bit — đừng đoán, và đừng tin cái byte thô mà một struct trường bit tình cờ cho ra trên máy của bạn.
Thử ba mươi giây
Khai một struct trường bit đơn giản và in sizeof của nó với hai kiểu cơ sở khác nhau: struct { unsigned a:1, b:1; } so với struct { unsigned char a:1, b:1; }. Bạn sẽ thấy cái đầu 4 byte, cái sau 1 byte — cùng hai bit, khác cái hộp. Rồi đặt trường đầu tiên bằng 1, memcpy struct sang một unsigned char và in ra dạng hex: trên máy little-endian thường bạn sẽ thấy 0x01 (bit thấp). Con số đó đúng trên máy bạn nhưng không phải một lời hứa — đó chính là lý do đừng bao giờ dùng bố cục ấy để đọc một byte đến từ bên ngoài chương trình.