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

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

Union, biểu diễn byte & alignment

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

Union trong C: type punning đọc một vùng nhớ theo nhiều kiểu; biểu diễn byte, alignment & padding trong struct và luật effective type của C17.

Bạn đã quen nhìn biến qua KIỂU của nó: int, float, struct (Bài 8)… Chương The C memory model của Modern C mời bạn nhìn xuống một tầng: mọi đối tượng đều là một dãy byte, và C cho phép soi tận nơi bằng con trỏ unsigned char:

soi-byte.c - xem từng byte của một int

#include <stdio.h>

int main(void) {
    unsigned int x = 0x01020304;
    unsigned char *p = (unsigned char *)&x;
    for (int i = 0; i < 4; i++) printf("%02x ", p[i]);
    printf("\n");
    return 0;
}

Kết quả khi chạy

04 03 02 01 

Trung thực: kết quả tuỳ máy

Output trên là của máy little-endian (Intel, Apple Silicon): byte THẤP đứng ở địa chỉ thấp, nên dãy in ra "ngược" với cách ta viết số. Máy big-endian in 01 02 03 04. Thứ tự byte (endianness) có hẳn một bài trong khoá C Thực Chiến · Nhúng - hôm nay chỉ cần biết: nó tồn tại, và đừng giả định.

struct đặt các trường nối tiếp; union đặt mọi trường chồng lên cùng một chỗ - cùng một vùng nhớ nhưng được đọc theo nhiều kiểu khác nhau (type punning). Ứng dụng đẹp nhất cho người học: soi bit của số thực:

soi-float.c - cùng 4 byte, đọc theo float hoặc theo uint32_t

#include <stdio.h>
#include <stdint.h>

union SoiFloat {
    float    f;
    uint32_t bits;
};

int main(void) {
    union SoiFloat u = { .f = 1.0f };
    printf("sizeof(union SoiFloat) = %zu\n", sizeof u);
    printf("1.0f nhin theo bit: 0x%08x\n", u.bits);
    u.f = -1.0f;
    printf("-1.0f nhin theo bit: 0x%08x\n", u.bits);
    return 0;
}

Kết quả khi chạy

sizeof(union SoiFloat) = 4
1.0f nhin theo bit: 0x3f800000
-1.0f nhin theo bit: 0xbf800000
  • sizeof union = trường LỚN NHẤT (các trường chồng nhau, không cộng dồn).
  • Ghi u.f rồi đọc u.bits: C17 cho phép - giá trị được diễn giải lại theo biểu diễn byte (type punning). C++ thì cấm kiểu này.
  • Đổi 1.0f thành -1.0f chỉ lật đúng bit cao nhất (0x3f8 → 0xbf8) - bit dấu của IEEE 754 hiện nguyên hình.

Bài 14 khoá cơ bản đã cảnh báo "đừng tự cộng tay sizeof của struct" - giờ là lời giải. CPU đọc bộ nhớ theo khối: int nằm tại địa chỉ chia hết cho 4 thì đọc một nhịp. Nên trình biên dịch chèn byte đệm (padding) để mọi trường nằm đúng mốc alignment:

dem.c - ba struct, cùng dữ liệu, khác kích thước

#include <stdio.h>

struct A { char c; int n; };
struct B { char c; int n; char d; };
struct C { char c; char d; int n; };

int main(void) {
    printf("A (char,int)      : %zu\n", sizeof(struct A));
    printf("B (char,int,char) : %zu\n", sizeof(struct B));
    printf("C (char,char,int) : %zu\n", sizeof(struct C));
    return 0;
}

Kết quả khi chạy

A (char,int)      : 8
B (char,int,char) : 12
C (char,char,int) : 8
  • B và C chứa Y HỆT dữ liệu (2 char + 1 int) nhưng lệch nhau 4 byte - chỉ vì thứ tự khai báo.
  • Luật: mỗi trường đặt tại offset chia hết cho alignment của nó; cuối struct đệm tới bội của alignment lớn nhất.
  • Hệ quả thực dụng: khai trường TO trước, NHỎ sau thì ít đệm nhất.

Chọn một struct mẫu, nhìn bản đồ byte (ô xám là đệm), rồi bấm ↑↓ xếp lại trường xem sizeof co giãn:

Các trường (bấm ↑↓ để xếp lại)
  • char c; offset 0
  • int n; offset 4 (đệm 3)
  • char d; offset 8
Bản đồ byte (mỗi ô = 1 byte, hàng 8 byte)
c
·
·
·
n
n
n
n
d
·
·
·
sizeof = 12 · dữ liệu thật 6 byte · đệm 6 byte

Trung thực

Kích thước/alignment trong công cụ theo mô hình LP64 (macOS, Linux 64-bit) - đúng với máy bạn đang học. Windows 64-bit khác một chi tiết: long chỉ 4 byte. Mọi con số của 4 preset đã đối chiếu sizeof thật.

Đã thấy hai cách đọc-theo-kiểu-khác: unsigned char * (Bước 1) và union (Bước 2). Còn cách mà rất nhiều code trên mạng dùng thì lại phạm luật: ép thẳng con trỏ.

ba cách nhìn bit của float - chỉ hai cách đầu là hợp lệ

float f = 1.0f;
uint32_t b;

/* 1. union - hop le trong C (Buoc 2) */
union SoiFloat u = { .f = f };
b = u.bits;

/* 2. memcpy - hop le, trinh bien dich toi uu thanh 0 lenh copy */
memcpy(&b, &f, sizeof b);

/* 3. ep con tro - UNDEFINED BEHAVIOR (vi pham effective type) */
b = *(uint32_t *)&f;   /* DUNG LAM: "chay duoc tren may toi" khong phai bang chung */
  • Mỗi vùng nhớ có một "kiểu hiệu lực" (effective type); đọc nó bằng con trỏ kiểu khác là UB - trình tối ưu hoá được phép giả định điều đó không xảy ra (strict aliasing).
  • Ba lối đi hợp pháp: unsigned char * (soi byte), union (C17), và memcpy (an toàn nhất, vẫn nhanh).
  • Bài Bẫy kinh điển & undefined behavior (cuối khoá) sẽ đặt luật này cạnh các UB khác - và cách bắt bằng sanitizer.

Bài tiếp theo

Bạn vừa có mô hình bộ nhớ hoàn chỉnh: byte, kiểu, alignment. Bài kế dùng nó để tổng kết các bẫy con trỏ & kỷ luật quản lý bộ nhớ - void *, const và con trỏ hàm.

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

Chuẩn C cho char/unsigned char một đặc quyền: con trỏ kiểu này được phép đọc biểu diễn byte của MỌI đối tượng. Các kiểu khác không có đặc quyền đó - ép con trỏ float thành int * rồi đọc là vi phạm luật effective type (xem Bước 5). Một ngoại lệ được thiết kế có chủ đích, không phải lỗ hổng.

struct đặt các trường NỐI TIẾP nhau - sizeof xấp xỉ tổng. union đặt mọi trường CHỒNG lên cùng một chỗ - sizeof bằng trường lớn nhất, và ghi trường này sẽ đè trường kia. struct là "và", union là "hoặc".

Trong C: CÓ. C17 nói rõ giá trị được diễn giải lại theo biểu diễn byte (type punning qua union là hợp lệ). Nhưng C++ thì CẤM kiểu đọc này - nếu sau này mèo con viết C++, đừng mang thói quen này theo; ở đó dùng memcpy hoặc std::bit_cast.

Phần cứng đọc bộ nhớ theo khối: int 4 byte đọc một nhịp nếu nằm tại địa chỉ chia hết cho 4. Lệch lề thì CPU phải đọc hai khối rồi ghép (chậm), thậm chí một số CPU từ chối thẳng. Nên trình biên dịch chèn đệm để mọi trường nằm đúng mốc alignment - đổi vài byte lấy tốc độ.

Có - nhiều trình biên dịch hỗ trợ #pragma pack hay __attribute__((packed)) để nén sát. Nhưng đó là dao hai lưỡi: truy cập trường lệch alignment chậm hơn, vài kiến trúc còn sập. Cách lành mạnh nhất vẫn là XẾP LẠI trường như công cụ Bước 4; packed chỉ dành cho định dạng file/giao thức cố định (khoá Nhúng sẽ gặp).

Không! Thứ tự byte (endianness) tuỳ kiến trúc: máy Intel/Apple Silicon là little-endian (byte thấp đứng trước) nên ra 04 03 02 01; máy big-endian ra 01 02 03 04. Code đọc/ghi dữ liệu nhị phân qua mạng hay file phải xử lý chuyện này - khoá C Thực Chiến · Nhúng có hẳn một bài (Endianness & struct packing).

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

union { float f; uint32_t bits; } - sizeof của nó bằng bao nhiêu và vì sao?

  1. 1

    Soi máy của mèo con

    Chạy chương trình Bước 1 trên máy mình. Máy mèo con là little-endian hay big-endian?

    Hoàn thành khi: Trả lời được kèm bằng chứng: 04 03 02 01 → little-endian (byte thấp ở địa chỉ thấp).

  2. 2

    Soi double

    Sửa union SoiFloat thành double + uint64_t (in %016llx). Bit của 1.0 là gì?

    Hoàn thành khi: 0x3ff0000000000000 - đối chiếu được với cấu trúc IEEE 754 64-bit (1023 << 52).

  3. 3

    Đoán trước sizeof

    KHÔNG chạy máy: đoán sizeof của struct { short a; long b; char c; }. Rồi kiểm bằng công cụ Bước 4 và bằng cc thật.

    Hoàn thành khi: Đoán 24 (đệm 6 sau short, đệm 7 sau char) - cả công cụ lẫn sizeof xác nhận.

  4. 4

    Tiết kiệm cho preset D

    Trong công cụ Bước 4: xếp lại {char, double, short} để sizeof nhỏ nhất có thể.

    Hoàn thành khi: Đạt 16 byte (double trước, short rồi char sau) - bớt 8 byte so với 24 ban đầu.

  5. 5

    offsetof làm chứng

    Dùng offsetof (stddef.h) in vị trí từng trường của struct B {char; int; char;} và đối chiếu với bản đồ byte của công cụ.

    Hoàn thành khi: offset 0, 4, 8 - khớp từng ô với công cụ; sizeof 12 vì đệm cuối.

  6. 6

    Tự gây UB rồi nhờ máy bắt

    Viết float f = 1.0f; rồi đọc *(uint32_t *)&f. Biên dịch với -O2 -Wstrict-aliasing và thử thêm -fsanitize=undefined.

    Hoàn thành khi: Hiểu vì sao cách này KHÔNG đáng tin dù "có vẻ chạy" - và đổi sang union/memcpy thì sạch.