Blog03 — Struct, Union, Bitfield & Bố Cục Bộ Nhớ
Bố cục bài viết
Trang này
Ngày tạo
05/08/2026
Cập nhật
Lượt xem
16
Đọc
10m
03 — Struct, Union, Bitfield & Bố Cục Bộ Nhớ
Mục tiêu: Học cách mô tả header của một protocol bằng C. Đây là nơi lý thuyết gặp dây dẫn (wire): GTP header, Diameter header, SCCP, IP/UDP header… tất cả đều là struct. Nhưng cạm bẫy alignment/padding và endianness khiến “ánh xạ struct lên byte gói tin” trở thành nghệ thuật. Hiểu sai = parser sai = bug an ninh.
1. struct — nhóm dữ liệu liên quan
#include <stdint.h>
struct gtpv2_header {
uint8_t flags; // version(3) + P + T + spare(3)
uint8_t msg_type; // loại message (vd. 32 = Create Session Request)
uint16_t length; // độ dài phần sau (big-endian!)
uint32_t teid; // Tunnel Endpoint ID (chỉ có nếu T=1)
uint32_t seq_spare; // sequence number (24 bit) + spare (8 bit)
};
Truy cập bằng . (với biến) hoặc -> (với con trỏ):
struct gtpv2_header h;
h.msg_type = 32;
struct gtpv2_header *p = &h;
p->msg_type = 32; // (*p).msg_type viết gọn thành p->msg_type
typedef để gõ ngắn hơn (idiom phổ biến):
typedef struct {
uint8_t type;
uint16_t length;
uint8_t value[]; // flexible array member (mảng độ dài thay đổi ở cuối)
} avp_t;
2. Alignment & Padding — cạm bẫy #1 khi map struct lên packet
CPU đọc bộ nhớ hiệu quả nhất khi dữ liệu được căn (align): uint32_t ở địa chỉ chia hết cho 4, uint16_t chia hết cho 2… Để đạt điều đó, trình biên dịch chèn byte đệm (padding) vào giữa struct.
Ví dụ kinh điển:
struct example {
uint8_t a; // offset 0
uint32_t b; // offset 4 ← CHÈN 3 byte padding ở offset 1,2,3 để b căn 4!
uint8_t c; // offset 8
}; // CHÈN 3 byte padding cuối → tổng sizeof = 12, KHÔNG phải 6!
Bố cục thực tế trong RAM:
offset: 0 1 2 3 4 5 6 7 8 9 10 11
┌────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┬────┐
│ a │PAD │PAD │PAD │ b (4 byte) │ c │PAD │PAD │PAD │
└────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┴────┘
sizeof(struct example) == 12
Kiểm chứng:
printf("%zu\n", sizeof(struct example)); // 12
printf("%zu\n", offsetof(struct example, b)); // 4 (cần <stddef.h>)
Tại sao đây là vấn đề chí mạng cho telecom: Một gói GTP không có byte padding — các field nằm sát nhau đúng theo spec. Nếu bạn
memcpythẳng buffer gói tin vào một struct có padding, các field sẽ lệch và bạn đọc sai hoàn toàn.
Giải pháp 1: #pragma pack / __attribute__((packed)) — bỏ padding
struct __attribute__((packed)) gtpv2_header {
uint8_t flags;
uint8_t msg_type;
uint16_t length;
uint32_t teid;
uint32_t seq_spare;
}; // sizeof = 12, KHÔNG padding — khớp wire layout
hoặc:
#pragma pack(push, 1)
struct ip_header { /* ... */ };
#pragma pack(pop)
Cảnh báo của chuyên gia: Truy cập field unaligned qua struct packed có thể chậm hoặc crash trên một số kiến trúc (ARM cũ). Nhiều codebase lớn (Wireshark) không dùng packed struct mà parse từng byte thủ công (
tvb_get_ntohl) để vừa di động vừa an toàn. Ta sẽ học cả hai cách ở08.
Giải pháp 2 (an toàn & di động hơn): parse byte thủ công
// Không map struct — đọc từng field theo offset & endianness rõ ràng
int parse_gtpv2(const uint8_t *b, size_t len, uint16_t *out_len) {
if (len < 4) return -1;
*out_len = ((uint16_t)b[2] << 8) | b[3]; // big-endian, không phụ thuộc layout
return 0;
}
Khuyến nghị: dùng cách thủ công cho code parse “production/security”. Dùng packed struct chỉ khi bạn kiểm soát kiến trúc và muốn code gọn.
3. Endianness (thứ tự byte) — cạm bẫy #2
Số nhiều byte có hai cách lưu trong RAM:
Giá trị 0x12345678 (4 byte):
Little-endian (x86, ARM mặc định): 78 56 34 12 ← byte thấp trước
Big-endian (network byte order): 12 34 56 78 ← byte cao trước
- Network byte order = BIG-ENDIAN. Mọi field độ dài/ID nhiều byte trong IP, TCP, GTP, Diameter, SCTP… đều big-endian.
- Máy của bạn (x86-64) là little-endian. → Phải chuyển đổi khi đọc/ghi.
Hàm chuyển đổi chuẩn POSIX (<arpa/inet.h> / <endian.h>)
#include <arpa/inet.h>
uint16_t ntohs(uint16_t); // network→host, 16-bit (n-to-h-short)
uint32_t ntohl(uint32_t); // network→host, 32-bit (n-to-h-long)
uint16_t htons(uint16_t); // host→network, 16-bit
uint32_t htonl(uint32_t); // host→network, 32-bit
#include <endian.h> // Linux — đủ kích thước, rõ ràng hơn
uint64_t be64toh(uint64_t); // big-endian → host, 64-bit
uint32_t htobe32(uint32_t); // host → big-endian, 32-bit
Ví dụ đọc length từ gói:
uint16_t length_wire;
memcpy(&length_wire, &buf[2], 2); // copy 2 byte thô
uint16_t length = ntohs(length_wire); // chuyển sang host order để dùng
hoặc an toàn không phụ thuộc alignment (cách Wireshark hay dùng):
uint16_t length = ((uint16_t)buf[2] << 8) | (uint16_t)buf[3]; // tự ghép big-endian
uint32_t teid = ((uint32_t)buf[4] << 24) | ((uint32_t)buf[5] << 16) |
((uint32_t)buf[6] << 8) | (uint32_t)buf[7];
Quy tắc thép #6: Mọi field protocol nhiều byte phải qua chuyển đổi endianness. Quên
ntohs/ntohllà bug “im lặng” số một khi mới parse packet — code chạy nhưng giá trị sai (vd. length 0x3C00 thay vì 0x003C).
4. Bitfield — bóc các trường con trong một byte
Nhiều protocol nhồi nhiều field nhỏ vào một byte. Vd. byte đầu GTPv2: version (3 bit) + P flag (1) + T flag (1) + spare (3).
Cách 1: bitfield trong struct (gọn nhưng KÉM di động)
struct __attribute__((packed)) gtpv2_flags {
uint8_t spare : 3; // 3 bit
uint8_t t_flag : 1; // 1 bit
uint8_t p_flag : 1; // 1 bit
uint8_t version : 3; // 3 bit
};
Cảnh báo nặng: Thứ tự bit trong bitfield (MSB-first hay LSB-first) do trình biên dịch quyết định, không chuẩn hóa. Cùng một code cho kết quả khác nhau giữa GCC/MSVC, x86/ARM. → Không dùng bitfield cho parsing protocol thực tế.
Cách 2 (CHUẨN MỰC): dùng bitwise & mask (di động 100%)
uint8_t b = buf[0];
uint8_t version = (b >> 5) & 0x07; // 3 bit cao
uint8_t p_flag = (b >> 4) & 0x01; // bit thứ 4
uint8_t t_flag = (b >> 3) & 0x01; // bit thứ 3
Bảng tham chiếu mask/shift:
byte: bit7 bit6 bit5 | bit4 | bit3 | bit2 bit1 bit0
field: <-- version --> | P | T | <-- spare -->
version = (b >> 5) & 0b111
P = (b >> 4) & 0b1
T = (b >> 3) & 0b1
spare = b & 0b111
Ghi ngược lại (đóng gói để gửi):
uint8_t out = ((version & 0x07) << 5) |
((p_flag & 0x01) << 4) |
((t_flag & 0x01) << 3);
Quy tắc thép #7: Dùng shift + mask cho mọi sub-field bit của protocol. Bitfield struct chỉ để học khái niệm, không để parse byte thật.
5. union — nhiều cách nhìn cùng một vùng nhớ
Tất cả thành viên của union dùng chung một vùng nhớ; kích thước union = thành viên lớn nhất.
union ip_addr {
uint32_t as_u32; // nhìn như 1 số 32-bit
uint8_t octets[4]; // nhìn như 4 byte
};
union ip_addr a;
a.as_u32 = 0;
a.octets[0] = 192; a.octets[1] = 168; a.octets[2] = 1; a.octets[3] = 1;
// a.as_u32 bây giờ chứa 4 byte đó (giá trị tùy endianness)
Ứng dụng telecom:
- Phát hiện endianness của máy:
union { uint16_t s; uint8_t b[2]; } u = { .s = 0x0102 };
int little_endian = (u.b[0] == 0x02); // true trên x86
- TLV với value đa kiểu: một AVP value có thể là Integer32, OctetString, Address… dùng
unionmô tả gọn (kèm một fieldtypecho biết đang dùng nhánh nào → “tagged union”). - Reinterpret byte ↔ số (cẩn thận với strict aliasing — dùng
memcpyan toàn hơn cho việc này).
Bẫy strict-aliasing: đọc qua một thành viên union khác kiểu với cái vừa ghi là vùng xám của chuẩn C. Cách chắc chắn đúng để “đổi cách nhìn byte” là
memcpy:
uint32_t v;
memcpy(&v, buf, 4); // an toàn, trình biên dịch tối ưu thành 1 lệnh mov
6. Flexible Array Member — mô tả TLV/AVP độ dài thay đổi
typedef struct {
uint16_t type;
uint16_t length; // độ dài của value
uint8_t value[]; // FAM — không chiếm chỗ trong sizeof; cấp động kèm theo
} tlv_t;
// Cấp phát: header + length byte value, trong MỘT khối
tlv_t *t = malloc(sizeof(tlv_t) + value_len);
t->type = 1;
t->length = value_len;
memcpy(t->value, src, value_len);
sizeof(tlv_t) = 4 (chỉ type + length). FAM cho phép một struct “kéo dài” theo dữ liệu thực — đúng mô hình AVP của Diameter.
7. Struct lồng nhau & con trỏ struct (mô hình cây AVP)
Grouped AVP của Diameter chứa AVP con → cấu trúc cây. Biểu diễn bằng struct + con trỏ:
typedef struct avp_node {
uint32_t code;
uint32_t length;
uint8_t *value; // con trỏ tới dữ liệu (octet string)
struct avp_node *children; // danh sách AVP con (grouped AVP)
struct avp_node *next; // AVP kế tiếp cùng cấp
} avp_node_t;
Đây là cách dissector thực sự dựng cây protocol trong bộ nhớ. Ta sẽ hiện thực hóa ở 08.
8. Ví dụ tổng hợp: ánh xạ header IPv4 (đối chiếu được với Wireshark)
Header IPv4 (20 byte tối thiểu), big-endian:
0 1 2 3
0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
|Version| IHL |Type of Service| Total Length |
+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
Parse an toàn (cách thủ công, khuyến nghị):
#include <stdint.h>
#include <stddef.h>
typedef struct {
uint8_t version;
uint8_t ihl_bytes; // IHL × 4
uint16_t total_length;
uint8_t protocol;
uint32_t src_ip; // host order sau khi parse
uint32_t dst_ip;
} ipv4_info_t;
int parse_ipv4(const uint8_t *b, size_t len, ipv4_info_t *out) {
if (len < 20) return -1; // bound check #1
out->version = (b[0] >> 4) & 0x0F;
out->ihl_bytes = (b[0] & 0x0F) * 4;
if (out->ihl_bytes < 20 || out->ihl_bytes > len) return -2; // bound check #2
out->total_length = ((uint16_t)b[2] << 8) | b[3];
out->protocol = b[9];
out->src_ip = ((uint32_t)b[12] << 24) | ((uint32_t)b[13] << 16) |
((uint32_t)b[14] << 8) | (uint32_t)b[15];
out->dst_ip = ((uint32_t)b[16] << 24) | ((uint32_t)b[17] << 16) |
((uint32_t)b[18] << 8) | (uint32_t)b[19];
return 0;
}
Để ý hai lần
bound checkvà việc validateihl_bytes. Đây là khác biệt giữa parser “chạy được” và parser “không khai thác được”.
9. Bài tập / Lab module 03
- Đo padding: khai báo 3 struct với thứ tự field khác nhau (vd.
{uint8 uint32 uint8}vs{uint32 uint8 uint8}vs{uint8 uint8 uint32}). Insizeoftừng cái và giải thích chênh lệch. Sắp xếp lại field để giảmsizeofxuống tối thiểu (kỹ năng tối ưu struct). - Packed vs không packed: lấy
struct gtpv2_headerở mục 1, insizeofkhi có và không có__attribute__((packed)). Dùngoffsetofin offset từng field cả hai trường hợp. - Endianness: viết hàm tự phát hiện máy little/big-endian bằng union. Viết hàm
uint32_t read_be32(const uint8_t *p)đọc 4 byte big-endian → host, không dùngntohl(tự shift). So sánh kết quả vớintohl. - Bit extractor: cho byte đầu GTPv2 (
0x48= version 2, T=1), bóc version/P/T/spare bằng shift+mask. In nhị phân từng field để kiểm tra. - IPv4 parser: hoàn thiện và chạy
parse_ipv4ở mục 8 trên một gói thật: bắt 1 gói bằngtcpdump -w x.pcap -c1, trích payload IP (bỏ Ethernet 14 byte), feed vào parser, đối chiếu version/length/src/dst với Wireshark. Đây là lần đầu lý thuyết khớp gói thật — cột mốc quan trọng. - TLV với FAM: dùng
tlv_t(mục 6) cấp phát động một TLV, điền type/length/value, hexdump để xác nhận layout trong bộ nhớ.
Cột mốc module 03: Bạn map được một header protocol thật lên C, xử lý đúng padding và endianness, bóc tách bit-field bằng shift+mask, và parser của bạn khớp byte với Wireshark. Bạn đã có thể đọc/ghi cấu trúc nhị phân của bất kỳ protocol nào.
➡️ Tiếp theo: 04-C-Nang-Cao.md — con trỏ hàm, preprocessor, bitwise nâng cao, build system, gdb workflow.
Lộ trình học tập
Lập Trình C/C++
- 1 01 — C Cơ Bản
- 2 02 — Con Trỏ & Bộ Nhớ
- 3 03 — Struct, Union, Bitfield & Bố Cục Bộ Nhớ
- 4 04 — C Nâng Cao
- 5 05 — C++ Cốt Lõi
- 6 06 — C++ Hiện Đại
- 7 07 — Lập Trình Hệ Thống & Mạng
- 8 08 — Phân Tích Giao Thức Telecom Bằng C/C++
- 9 09 — An Ninh Bộ Nhớ & Khai Thác Cơ Bản
- 10 11 — Lập Trình Nhúng & Real-time (SDR-first)
- 11 Bài tập & Pet Project — Lộ trình C/C++ chuyên sâu
- 12 Tổng hợp các dạng printf để in giá trị trong C
- 13 Bitwise & Mask, Octet, Endian: Hướng dẫn parse protocol byte cho người mới
- 14 Tổng hợp: Con trỏ, kích thước kiểu dữ liệu và byte trong bộ nhớ