Dữ liệu & CPU/Byte order và thao tác bit — endianness, mask, shift
6/23
Bài 6 / 23~18 phútBiểu diễn dữ liệuMiễn phí lượt xem

Byte order và thao tác bit — endianness, mask, shift

Big-endian và little-endian là gì và vì sao quan trọng khi đọc file nhị phân hay packet mạng. Cùng bộ thao tác bit thực dụng: mask, shift, cờ bitmask.

TL;DR: Khi lưu số nguyên nhiều byte xuống bộ nhớ, máy phải chọn thứ tự: byte thấp trước (little-endian, x86/ARM) hay byte cao trước (big-endian, mạng/file chuẩn). Nhầm thứ tự → đọc sai giá trị hoàn toàn. Bên cạnh đó, thao tác bit (AND, OR, XOR, NOT, shift) cho phép gói nhiều cờ boolean vào một số nguyên duy nhất, kiểm tra chẵn/lẻ tức thì, và nhân/chia luỹ thừa 2 không cần phép nhân thật. Hai kỹ năng này là nền tảng khi đọc file binary, parse packet mạng, hay viết permission system.

Bạn ghi số 305419896 (tức 0x12345678) ra file trên máy x86, rồi mở file đó trên một hệ thống khác dùng quy ước ngược — bỗng nhiên đọc về 0x78563412, một con số hoàn toàn khác. Không có bit nào thay đổi, nhưng giá trị đọc được sai hoàn toàn chỉ vì hai bên dùng thứ tự byte khác nhau.

Bài này giải thích vì sao thứ tự byte tồn tại, khi nào nó gây rắc rối, và bộ thao tác bit giúp bạn giải quyết những bài toán thường gặp gọn hơn — cốt lõi để bạn đọc file binary, parse packet mạng, và implement permission flags đúng cách.

1. Analogy — cách viết ngày tháng

Người Mỹ viết ngày 06/16 (tháng trước, ngày sau); người Việt và châu Âu viết 16/06 (ngày trước, tháng sau). Cùng một sự kiện — 16 tháng 6 — nhưng hai quy ước thứ tự khác nhau. Ai không biết quy ước của bên kia sẽ đọc nhầm: "tháng 16" không tồn tại.

Máy tính gặp vấn đề y hệt khi lưu số nguyên 4 byte: phải chọn byte nào đứng trước trong bộ nhớ.

Đời thườngKhái niệm máy tính
Tháng/Ngày (Mỹ)Little-endian (byte thấp trước)
Ngày/Tháng (Việt/EU)Big-endian (byte cao trước)
Ai không biết quy ước → đọc saiChương trình không xử lý endianness → parse sai giá trị
Quy ước phổ biến toàn cầu = ISO 8601Network byte order = big-endian (RFC 791)
💡 Cách nhớ

Little-endian = byte nhỏ (thấp) đứng trước — "little first". Big-endian = byte to (cao) đứng trước — "big first". Tên đặt theo byte đầu tiên được ghi vào địa chỉ thấp nhất.

2. Big-endian và little-endian khác nhau thế nào?

2.1 Ví dụ cụ thể với 0x12345678

Số 0x12345678 (305.419.896 thập phân) chiếm 4 byte:

  • Byte cao nhất (most significant byte): 0x12
  • Byte tiếp theo: 0x34
  • Byte tiếp theo: 0x56
  • Byte thấp nhất (least significant byte): 0x78

Giả sử địa chỉ bắt đầu là 0x1000:

Địa chỉLittle-endian (x86/ARM)Big-endian (mạng)
0x10000x78 (byte thấp trước)0x12 (byte cao trước)
0x10010x560x34
0x10020x340x56
0x10030x12 (byte cao sau)0x78 (byte thấp sau)
block-beta
  columns 4
  block:LE["Little-endian (x86)"]:4
    LE0["0x1000\n0x78\n(thap nhat)"]
    LE1["0x1001\n0x56"]
    LE2["0x1002\n0x34"]
    LE3["0x1003\n0x12\n(cao nhat)"]
  end
  block:BE["Big-endian (mang)"]:4
    BE0["0x1000\n0x12\n(cao nhat)"]
    BE1["0x1001\n0x34"]
    BE2["0x1002\n0x56"]
    BE3["0x1003\n0x78\n(thap nhat)"]
  end
// Doc 4 byte tu dia chi 0x1000:
// Little-endian: doc 78 56 34 12 -> ghep nguoc lai -> 0x12345678 (dung)
// Big-endian:    doc 12 34 56 78 -> ghep thuan   -> 0x12345678 (dung)
// Nhung neu chuong trinh little-endian doc file big-endian:
// Doc 12 34 56 78 -> ghep nguoc lai -> 0x78563412 (SAI!)
import struct

# Python minh hoa: ghi va doc voi endianness khac nhau
value = 0x12345678

# Ghi theo little-endian (< = little-endian trong struct format)
le_bytes = struct.pack('<I', value)
print("Little-endian bytes:", [hex(b) for b in le_bytes])
# Output: ['0x78', '0x56', '0x34', '0x12']

# Ghi theo big-endian (> = big-endian)
be_bytes = struct.pack('>I', value)
print("Big-endian bytes:   ", [hex(b) for b in be_bytes])
# Output: ['0x12', '0x34', '0x56', '0x78']

# Doc nham: doc le_bytes nhung interpret theo big-endian
wrong = struct.unpack('>I', le_bytes)[0]
print("Doc nham endianness:", hex(wrong))
# Output: 0x78563412 -- SAI!

2.2 Khi nào endianness quan trọng

Quan trọng — cần xử lý tường minh:

  • Đọc/ghi file binary (PNG, BMP, WAV, ZIP…): mỗi format quy định rõ endianness trong spec. PNG dùng big-endian cho mọi trường số nguyên.
  • Giao tiếp mạng: TCP/IP (RFC 791) quy định network byte order = big-endian. Mọi header packet (port, địa chỉ IP dạng số) phải convert sang big-endian trước khi gửi.
  • Ép kiểu con trỏ (reinterpret_cast trong C++, unsafe trong Rust): đọc bytes thô trực tiếp theo layout bộ nhớ.

Không quan trọng — CPU tự xử lý:

  • Tính toán bình thường trong RAM (a + b, a * b): CPU đọc và ghi theo endianness của chính nó — nhất quán tuyệt đối, kết quả luôn đúng.
  • Truy cập biến trong ngôn ngữ bậc cao: compiler và runtime lo hết, bạn chỉ thấy giá trị logic.
💡 Quy tắc đơn giản

Endianness chỉ quan trọng khi dữ liệu vượt ranh giới — ra file, ra mạng, hoặc đọc bytes thô. Trong phạm vi một chương trình đang chạy, endianness trong suốt.

3. Thao tác bit — bộ công cụ thực dụng

Bên cạnh endianness, thao tác trực tiếp trên từng bit của một số nguyên là kỹ năng thường gặp trong systems programming, embedded, và permission system.

3.1 Các toán tử bit cơ bản

Toán tửTênÝ nghĩa
&ANDBit kết quả = 1 chỉ khi CẢ HAI bit đều 1
|ORBit kết quả = 1 khi ÍT NHẤT MỘT bit = 1
^XORBit kết quả = 1 khi HAI bit KHÁC NHAU
~NOTĐảo ngược mọi bit (bitwise complement)
<<Shift tráiDịch tất cả bit sang trái N vị trí, điền 0 bên phải
>>Shift phảiDịch tất cả bit sang phải N vị trí
a = 0b1010  # 10 decimal
b = 0b1100  # 12 decimal

print(bin(a & b))   # 0b1000  (8)  -- AND: chi bit ca hai deu 1
print(bin(a | b))   # 0b1110  (14) -- OR:  bit nao co it nhat 1 con 1
print(bin(a ^ b))   # 0b0110  (6)  -- XOR: bit khac nhau thi = 1
print(bin(~a))      # -0b1011 (-11)-- NOT: dao nguoc (two's complement)
print(bin(a << 1))  # 0b10100 (20) -- shift trai 1: nhan 2
print(bin(a >> 1))  # 0b101   (5)  -- shift phai 1: chia 2 (bo phan le)

3.2 Pattern thực dụng: bitmask và cờ boolean

Bitmask là dùng từng bit của một số nguyên để đại diện cho một boolean flag độc lập. Thay vì dùng 8 biến bool riêng lẻ, dùng 1 số int với 8 bit.

Ví dụ: permission Unix-style (3 bit)

# Permission flags: 3 bit cuoi cua mot so nguyen
READ    = 0b100  # bit 2 = quyen doc   (4)
WRITE   = 0b010  # bit 1 = quyen ghi   (2)
EXECUTE = 0b001  # bit 0 = quyen chay  (1)

# --- Set bit: dung OR de bat co ---
perms = 0
perms = perms | READ    # bat quyen doc
perms = perms | WRITE   # bat quyen ghi
print(bin(perms))       # 0b110 (6) -- doc + ghi

# --- Clear bit: dung AND voi NOT cua mask ---
perms = perms & ~WRITE  # tat quyen ghi
print(bin(perms))       # 0b100 (4) -- chi doc

# --- Test bit: dung AND, ket qua != 0 thi bit dang bat ---
can_read    = (perms & READ)    != 0  # True
can_write   = (perms & WRITE)   != 0  # False
can_execute = (perms & EXECUTE) != 0  # False

# --- Toggle bit: dung XOR de dao trang thai ---
perms = perms ^ EXECUTE  # bat quyen chay
print(bin(perms))        # 0b101 (5) -- doc + chay

Tương tự trong Java:

static final int READ    = 0b100;
static final int WRITE   = 0b010;
static final int EXECUTE = 0b001;

int perms = 0;
perms |= READ;              // set bit READ
perms &= ~WRITE;            // clear bit WRITE
boolean canRead = (perms & READ) != 0;  // test bit READ
perms ^= EXECUTE;           // toggle bit EXECUTE

3.3 Pattern khác hay dùng

Kiểm tra số chẵn/lẻ — dùng & 1 (AND với bit thấp nhất):

def is_odd(n):
    return (n & 1) == 1

print(is_odd(7))   # True  -- 0b0111 & 0b0001 = 1
print(is_odd(8))   # False -- 0b1000 & 0b0001 = 0

Nhanh hơn phép chia % 2 vì CPU không cần thực hiện phép chia. Compiler hiện đại thường tự tối ưu % 2 thành & 1; viết & 1 khi muốn làm rõ ý định bit-level.

Nhân/chia luỹ thừa 2 bằng shift:

x = 5
print(x << 1)  # 10  -- x * 2
print(x << 2)  # 20  -- x * 4
print(x << 3)  # 40  -- x * 8

print(x >> 1)  # 2   -- x // 2 (bo phan le)
print(x >> 2)  # 1   -- x // 4

x << n tương đương x * (2^n); x >> n tương đương x // (2^n). Compiler hiện đại thường tự tối ưu phép nhân luỹ thừa 2 thành shift — bạn viết x * 4 cũng được; shift explicit hữu ích khi cần rõ ý định bit-level.

📚 Đào sâu (tuỳ chọn) — Network byte order và hàm htonl/ntohl

C cung cấp 4 hàm chuẩn để convert endianness khi viết network code:

  • htons(x) — host to network short (16-bit): đổi từ endianness máy chủ sang big-endian
  • htonl(x) — host to network long (32-bit): tương tự cho 32-bit
  • ntohs(x) — network to host short (16-bit): ngược lại
  • ntohl(x) — network to host long (32-bit): ngược lại

Trên máy big-endian, các hàm này là no-op. Trên x86 (little-endian), chúng swap byte order. Code dùng đúng htonl/ntohl chạy đúng trên cả hai kiến trúc.

Vì sao x86 chọn little-endian? Lý do thực dụng: khi cộng hai số nhiều byte, CPU bắt đầu từ byte thấp nhất (carry lan dần lên byte cao). Với little-endian, byte thấp nằm ở địa chỉ thấp nhất — CPU không cần biết trước độ dài số để bắt đầu tính carry.

Arithmetic vs Logical shift phải cho số âm: >> trên số có dấu thường là arithmetic shift (giữ nguyên bit dấu, điền 1 bên trái nếu số âm) — (-8) >> 1 ra -4. Trên số không dấu là logical shift (điền 0). Chi tiết phụ thuộc ngôn ngữ: Java có >>> cho logical shift phải.

4. Áp dụng vào code của bạn

4.1 Dùng bitmask thay nhiều biến boolean

Giả sử bạn cần biểu diễn trạng thái của một task trong pipeline (running, paused, failed, cancelled, retrying).

Sai — 5 biến bool rời rạc:

# Code sai: kho kiem tra to hop trang thai, de inconsistent
is_running   = True
is_paused    = False
is_failed    = False
is_cancelled = False
is_retrying  = False

# Lam sao biet trang thai chinh xac? Va sao dam bao chi co 1 trang thai active?

Đúng — 1 int bitmask:

# Code dung: bitmask ro rang, kiem tra to hop de
RUNNING   = 1 << 0  # 0b00001
PAUSED    = 1 << 1  # 0b00010
FAILED    = 1 << 2  # 0b00100
CANCELLED = 1 << 3  # 0b01000
RETRYING  = 1 << 4  # 0b10000

status = RUNNING
# Bat dau retry: dang chay va dang retry (to hop)
status |= RETRYING        # status = RUNNING | RETRYING

# Kiem tra
is_active   = bool(status & RUNNING)   # True
is_retrying = bool(status & RETRYING)  # True
is_done     = bool(status & (FAILED | CANCELLED))  # False

# Reset ve trang thai sach
status = FAILED   # chi mot trang thai

Bitmask đặc biệt hữu ích khi cần biểu diễn tập con (một task có thể vừa RUNNING vừa RETRYING), truyền nhiều flag qua một tham số hàm, hoặc lưu nhiều boolean vào một cột int trong database.

4.2 Đọc dữ liệu binary — luôn xét endianness

Sai — đọc file binary bỏ qua endianness:

# Doc sai: khong xet endianness, ket qua phu thuoc machine
with open("data.bin", "rb") as f:
    raw = f.read(4)
    value = int.from_bytes(raw, byteorder='little')  # gia su little?
    # Neu file theo big-endian -> gia tri sai hoan toan

Đúng — doc spec, convert tường minh:

import struct

# Tra spec: PNG dung big-endian cho moi truong so nguyen
# https://www.w3.org/TR/PNG/#7Integers-and-byte-order

def read_png_chunk_length(f):
    raw = f.read(4)
    # Dung '>' (big-endian) theo PNG spec
    length = struct.unpack('>I', raw)[0]
    return length

# Doi voi Java (doc tu InputStream):
# DataInputStream tu dong xu ly big-endian: dis.readInt() la big-endian
# ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN).getInt() cho little-endian

Nguyên tắc: trước khi đọc bất kỳ file binary hay packet nào, tra spec để biết endianness của format đó, rồi dùng API đúng (struct.pack/unpack Python, ByteBuffer.order() Java, BinaryReader C#...).

5. Liên hệ các bài khác

6. Tóm tắt

  • Little-endian (x86, ARM phổ biến): byte thấp nhất lưu ở địa chỉ thấp nhất. Big-endian (network, một số format file): byte cao nhất lưu trước.
  • Endianness chỉ quan trọng khi dữ liệu vượt ranh giới — ghi file binary, gửi packet mạng, hoặc ép kiểu con trỏ. Trong tính toán nội bộ, CPU xử lý tự động.
  • Network byte order = big-endian (RFC 791). Hàm htonl/ntohl convert giữa endianness máy chủ và network.
  • & để test/clear bit, | để set bit, ^ để toggle bit, ~ để đảo tất cả bit.
  • Bitmask: gói nhiều cờ boolean vào 1 số nguyên — tiết kiệm bộ nhớ, truyền qua 1 tham số, dễ kiểm tra tổ hợp.
  • & 1 kiểm tra chẵn/lẻ; << n nhân 2^n; >> n chia 2^n (bỏ phần lẻ).
  • Trước khi đọc file binary hay packet: tra spec để biết endianness, rồi dùng API convert tường minh.

7. Tự kiểm tra

Tự kiểm tra
Q1
Số 0x0A0B0C0D được lưu theo little-endian vào 4 byte. Thứ tự 4 byte đó là gì (từ địa chỉ thấp đến cao)?
Byte thấp nhất (least significant byte) là 0x0D lưu ở địa chỉ thấp nhất, tiếp theo 0x0C, 0x0B, và byte cao nhất 0x0A ở địa chỉ cao nhất. Thứ tự: 0D 0C 0B 0A. Little-endian nghĩa là "byte nhỏ (thấp) đứng trước" — đây là quy ước x86 phổ biến.
Q2
Vì sao khi viết code tính toán bình thường (cộng, nhân hai số int) bạn không cần lo về endianness, nhưng khi ghi số đó ra file thì phải lo?
Khi tính toán trong RAM, CPU đọc và ghi theo endianness của chính nó — nhất quán tuyệt đối, kết quả luôn đúng. Nhưng khi ghi ra file, dãy byte được cố định theo thứ tự vật lý. Nếu chương trình khác (hoặc hệ thống khác) đọc file đó mà dùng quy ước ngược, nó ghép byte theo thứ tự sai → giá trị sai hoàn toàn. Ranh giới file là lúc "quy ước thứ tự" trở thành hợp đồng giữa người ghi và người đọc.
Q3
Cho perms = 0b101 (READ=4, WRITE=2, EXECUTE=1). Viết biểu thức để: (a) thêm quyền WRITE; (b) kiểm tra có quyền READ không; (c) xoá quyền EXECUTE.

(a) Thêm WRITE dùng OR: perms |= WRITE — OR bật bit mà không ảnh hưởng các bit khác. Kết quả: 0b111.

(b) Kiểm tra READ dùng AND: (perms & READ) != 0 — AND giữ lại đúng bit cần test; kết quả khác 0 thì bit đang bật.

(c) Xoá EXECUTE dùng AND với NOT: perms &= ~EXECUTE~EXECUTE là mask có tất cả bit = 1 trừ bit EXECUTE; AND với nó xoá chính xác bit đó.

Q4
Vì sao dùng bitmask để lưu nhiều boolean flag lại tiện hơn dùng nhiều biến bool riêng lẻ?
Bitmask gom nhiều flag vào một số nguyên duy nhất, giúp: (1) truyền toàn bộ trạng thái qua một tham số hàm thay vì 5-8 tham số; (2) kiểm tra tổ hợp flag bằng một biểu thức AND đơn giản; (3) lưu vào một cột int trong database thay vì nhiều cột boolean; (4) dễ thêm flag mới mà không thay đổi signature. Trade-off: giảm tính đọc hiểu nếu không đặt tên hằng số rõ ràng — luôn dùng hằng tên (READ, WRITE, EXECUTE) thay vì magic number (4, 2, 1).
Q5
Biểu thức n & (n - 1) cho kết quả gì khi n là luỹ thừa của 2? Vì sao pattern này hay được dùng?
Khi n là luỹ thừa của 2, n & (n - 1) luôn bằng 0. Lý do: luỹ thừa 2 dưới dạng nhị phân có đúng 1 bit = 1 (ví dụ 8 = 0b1000); còn n - 1 có tất cả bit thấp hơn = 1 và bit đó = 0 (7 = 0b0111); AND của hai dạng này luôn ra 0. Pattern này dùng để kiểm tra nhanh "n có phải luỹ thừa 2 không" mà không cần vòng lặp hay phép chia, hay để "làm tròn xuống luỹ thừa 2 gần nhất".
Q6
Network byte order là gì và vì sao giao thức mạng TCP/IP chọn quy ước đó?
Network byte order là big-endian — byte cao nhất truyền trước. RFC 791 (IP) và RFC 793 (TCP) quy định tường minh điều này. Lý do chọn big-endian: khi thiết kế TCP/IP (thập niên 1970), big-endian là quy ước phổ biến hơn trong các hệ thống mainframe và minicomputer của thời đó. Quan trọng hơn, cần có một chuẩn duy nhất để các máy khác nhau giao tiếp được — chọn big-endian hay little-endian đều hoạt động, miễn tất cả đồng thuận. Hàm htonl/ntohl giúp code C portable: trên x86 (little-endian) chúng swap byte, trên máy big-endian chúng là no-op.
Q7
Vì sao x << 3 tương đương nhân 8, và khi nào shift phải >> có thể cho kết quả bất ngờ?
Shift trái 1 vị trí nhân đôi giá trị (mỗi bit dịch lên vị trí có trọng số gấp đôi); shift 3 vị trí nhân 2^3 = 8. Shift phải bất ngờ với số âm: Java và C/C++ dùng arithmetic shift cho kiểu có dấu — bit dấu được nhân đôi (điền 1 bên trái khi số âm), nên (-8) >> 1 ra -4 thay vì giá trị không âm. Java cung cấp >>> (unsigned right shift) để luôn điền 0, hữu ích khi làm việc với bit pattern không phải số học.

Bài tiếp theo: Tổng kết & cheat sheet

Bài này có giúp bạn hiểu bản chất không?

Hỏi đáp về bài này

Chưa có câu hỏi

Đặt câu hỏi

Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).

Đặt câu hỏi đầu tiên