Skip to content
LIVE FEED

BlogC cho người mới bắt đầu: Preprocessor (Macro, Điều kiện biên dịch) & Bitwise nâng cao (Flags, Checksum, đếm bit)

C cho người mới bắt đầu: Preprocessor & Bitwise nâng cao

Bài viết tổng hợp lại một buổi học C từ đầu đến cuối, gồm 2 chủ đề lớn: Preprocessor (macro, điều kiện biên dịch) và Bitwise nâng cao (flags, checksum, đếm bit). Các ví dụ có liên hệ tới lập trình mạng/viễn thông (Diameter, IP/UDP checksum) nhưng phần giải thích nhắm tới người mới học C.

Phần 1: Preprocessor là gì?

Trước khi trình biên dịch (compiler) thực sự “hiểu” code C, có một bước chạy trước gọi là preprocessor. Việc nó làm đơn giản là tìm và thay thế văn bản — giống hệt bấm Ctrl+H trong Word, chỉ khác là máy tự làm. Nó không hiểu kiểu dữ liệu, không biết int hay float là gì — nó chỉ thấy chữ và thay chữ.

1.1. Macro hằng — đặt tên cho một con số

#define DIAMETER_PORT 3868

Nghĩa là: mỗi chỗ nào trong code có chữ DIAMETER_PORT, preprocessor sẽ thay bằng 3868 trước khi biên dịch. Đây không phải biến — không tốn bộ nhớ, không có địa chỉ. Lợi ích: nếu sau này port đổi, chỉ cần sửa 1 dòng #define, khỏi tìm sửa 3868 khắp nơi.

1.2. Macro hàm — “công thức thay thế” có tham số

#define MIN(a, b) ((a) < (b) ? (a) : (b))   // LUÔN đóng ngoặc từng tham số!

Khi gọi MIN(x, y), preprocessor dán nguyên văn x vào chỗ a, y vào chỗ b — nó không “gọi hàm” như hàm thường, chỉ chép/dán chữ. Vì vậy rất dễ sai do độ ưu tiên phép toán, nên phải bọc ngoặc từng tham số và cả biểu thức kết quả.

Bẫy 1: thiếu ngoặc

#define SQ(x) x*x

Gọi SQ(a+1), preprocessor dán thẳng thành:

a+1*a+1

Theo quy tắc toán học, * làm trước +, nên biểu thức thực chất được tính là a + (1*a) + 1 — hoàn toàn sai so với ý muốn (a+1)×(a+1). Bản sửa đúng:

#define SQ(x) ((x)*(x))

Lúc này SQ(a+1) dán ra ((a+1)*(a+1)) — đúng ý. Ghi nhớ: mỗi tham số, và cả biểu thức kết quả, đều phải có ngoặc riêng bọc quanh.

Bẫy 2: tham số bị lặp lại (tác dụng phụ)

MIN(i++, j)

dán ra:

((i++) < (j) ? (i++) : (j))

i++ xuất hiện 2 lần sau khi dán! Vì macro chỉ chép/dán chữ chứ không “tính một lần rồi dùng nhiều lần” như hàm thật. Kết quả i có thể tăng 1 hoặc 2 lần tùy nhánh được chọn — hành vi không đoán trước được, dễ ra bug âm thầm.

Giải pháp an toàn hơn: static inline function

static inline int imin(int a, int b) { return a < b ? a : b; }

Đây là hàm thật:

  • Tham số chỉ được tính đúng 1 lần, dù gọi imin(i++, j) — không lặp tác dụng phụ.
  • Compiler kiểm tra kiểu dữ liệu giúp bạn — macro thì không.
  • inline gợi ý compiler chèn thẳng code vào chỗ gọi, tốc độ gần như macro nhưng an toàn hơn.
  • static giới hạn hàm trong phạm vi file .c hiện tại, tránh xung đột tên với file khác.

Tóm gọn: macro hằng khá an toàn (chỉ đặt tên số). Macro hàm là “dán chữ mù quáng” — dễ dính bẫy độ ưu tiên phép toán và tác dụng phụ khi tham số có ++/--. Khi phân vân, ưu tiên static inline function.

2. Điều kiện biên dịch

Ngoài tìm-thay-thế, preprocessor còn quyết định đoạn code nào được giữ lại, đoạn nào bị xóa hoàn toàn trước khi compiler kịp nhìn thấy — giống như tờ hướng dẫn “nếu dùng Windows đọc mục A, nếu dùng Mac đọc mục B” đã bị cắt sẵn, bạn chỉ nhận đúng phần cần, phần kia không tồn tại.

#ifdef / #ifndef — kiểm tra “có định nghĩa macro này không”

#ifdef DEBUG
    printf("Gia tri x = %d\n", x);
#endif

Nếu DEBUG chưa được #define, dòng printf bị xóa hoàn toàn khỏi chương trình đã biên dịch — không phải “bỏ qua lúc chạy”.

#ifndef là “if NOT defined”, hay dùng để đặt giá trị mặc định:

#ifndef MAX_RETRY
#define MAX_RETRY 5
#endif

#if / #elif / #else — so sánh giá trị cụ thể

#if PROTOCOL_VERSION == 2
    #define HEADER_SIZE 20
#elif PROTOCOL_VERSION == 3
    #define HEADER_SIZE 28
#else
    #error "Phien ban giao thuc khong duoc ho tro"
#endif

Khác #ifdef (chỉ hỏi có tồn tại hay không), #if cho phép so sánh giá trị với ==, >, <, &&, ||… #error khiến preprocessor dừng biên dịch ngay và in thông báo lỗi tự viết — hữu ích để bắt lỗi cấu hình sai sớm.

Ứng dụng 1: Header guard (chặn include 2 lần)

#ifndef DIAMETER_H
#define DIAMETER_H

// ... toàn bộ nội dung file diameter.h ...

#endif

Nếu 2 file .c đều include cùng 1 header vào chung main.c, nội dung header có nguy cơ bị dán 2 lần → trùng khai báo → lỗi biên dịch. Lần include đầu, DIAMETER_H chưa tồn tại → giữ nội dung và define nó; lần include sau, DIAMETER_H đã tồn tại → nội dung bị bỏ qua, tránh trùng lặp.

Ứng dụng 2: Code riêng theo nền tảng / bật tắt debug

#ifdef _WIN32
    #include <windows.h>
#else
    #include <unistd.h>
#endif
#ifdef DEBUG
    #define LOG(fmt, ...) printf("[DEBUG] " fmt "\n", ##__VA_ARGS__)
#else
    #define LOG(fmt, ...)   // khi không debug, LOG(...) bị thay bằng "không gì cả"
#endif

Khi build bản release (không define DEBUG), mọi lời gọi LOG(...) biến mất hoàn toàn — không tốn chi phí runtime, vì đã bị xóa từ bước preprocessor.

Lỗi hay gặp

  • Quên #endif tương ứng cho mỗi #ifdef/#ifndef/#if.
  • #define DEBUG không cần giá trị vẫn đủ để #ifdef DEBUG nhận ra “có tồn tại”.
  • Nhầm #ifdef với #if defined(...) — #if defined(A) && defined(B) cho phép kết hợp nhiều điều kiện, #ifdef chỉ kiểm tra được một macro.

3. Bitwise nâng cao

Hình dung bit như dãy công tắc đèn

Một byte (uint8_t) là trạng thái của 8 công tắc đèn, đánh số từ phải sang trái là 0, 1, 2… Mỗi công tắc chỉ bật (1) hoặc tắt (0).

4 phép toán bit, hiểu như 4 câu hỏi/lệnh khi so hai công tắc:

  • AND (&) → “Cả hai cùng bật à?” — đúng chỉ khi cả hai đều bật.
  • OR (|) → “Có cái nào bật không?” — đúng khi ít nhất 1 cái bật.
  • XOR (^) → “Hai cái có khác nhau không?” — đúng khi một bật một tắt.
  • NOT (~) → lệnh đảo hết: bật thành tắt, tắt thành bật.

3.1. Tập cờ (flags) bằng bit

enum {
    F_PROXYABLE = 1 << 0,   // 0x01 — công tắc số 0
    F_ERROR     = 1 << 1,   // 0x02 — công tắc số 1
    F_RETRANS   = 1 << 2,   // 0x04 — công tắc số 2
};
uint8_t flags = 0;
flags |=  F_ERROR;                 // bật cờ
flags &= ~F_ERROR;                 // tắt cờ
if (flags & F_PROXYABLE) { ... }   // kiểm tra cờ
flags ^=  F_RETRANS;               // đảo cờ
  • Bật (|=): OR chỉ cần 1 trong 2 bật là bật, các công tắc khác giữ nguyên vì phía macro cờ ở vị trí đó luôn tắt, không ép chúng đổi.
  • Tắt (&= ~): ~F_ERROR đảo ngược — công tắc muốn tắt thành 0, mọi công tắc còn lại thành 1. AND với “mặt nạ” này giữ nguyên mọi bit khác, chỉ ép đúng bit cần tắt về 0.
  • Kiểm tra (&): AND với cờ chỉ có đúng 1 công tắc bật — công tắc khác của flags bị “lọc” mất vì phía kia luôn tắt, kết quả chỉ còn phụ thuộc vào đúng công tắc cần hỏi.
  • Đảo (^=): XOR với 0 thì không đổi, XOR với 1 thì lật ngược — vì cờ chỉ có đúng 1 bit là 1, nên chỉ đúng bit đó bị lật, còn lại giữ nguyên.

Ứng dụng Diameter Command Flags: byte cờ này nằm ngay sau Command Code trong header Diameter, gồm 4 bit đầu R(equest)/P(roxyable)/E(rror)/T (retransmit). Ví dụ bóc bit R (thường là bit cao nhất, 0x80):

int is_request = (flags_byte & 0x80) != 0;

3.2. Internet checksum (16-bit one’s complement)

Hình dung: gửi một xấp tiền qua bưu điện, trước khi gửi cộng tổng mệnh giá ghi ra giấy kèm theo; người nhận tự cộng lại, so với con số đó — khớp thì yên tâm, không khớp thì biết có gì bị rơi/thêm dọc đường. Checksum mạng (dùng trong IP/UDP/ICMP) làm y hệt, chỉ khác là cộng các cụm 2-byte trong gói tin:

uint16_t inet_checksum(const uint8_t *data, size_t len) {
    uint32_t sum = 0;
    for (size_t i = 0; i + 1 < len; i += 2)
        sum += ((uint32_t)data[i] << 8) | data[i+1];   // cộng từng word 16-bit
    if (len & 1) sum += (uint32_t)data[len-1] << 8;     // byte lẻ cuối
    while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16);// gập carry
    return (uint16_t)~sum;                               // bù 1
}
  • Vòng for: gộp từng cặp byte liền nhau thành 1 số 16-bit (byte đầu dịch trái 8 bit làm “nửa cao”, byte sau OR vào làm “nửa thấp”), cộng dồn vào sum. sum khai báo uint32_t (32-bit) để có chỗ chứa phần tràn khi cộng nhiều khối 16-bit.
  • if (len & 1): len & 1 kiểm tra độ dài lẻ (bit thấp nhất là 1 nghĩa là số lẻ). Byte lẻ cuối cùng được coi là “nửa cao” của 1 khối, nửa thấp coi như đệm 0.
  • while (sum >> 16): đây là bước gập carry — sum >> 16 lấy phần tràn vượt quá 16-bit, sum & 0xFFFF giữ 16-bit thấp, cộng lại với nhau vì one’s-complement checksum quy định carry tràn phải được cộng vòng lại chứ không vứt bỏ.
  • return (uint16_t)~sum: đảo hết bit (bù 1). Bên nhận tính lại checksum trên toàn gói tin (kể cả trường checksum) — nếu không lỗi, kết quả sẽ luôn ra toàn bit 1 (0xFFFF).

Vì sao cần quan tâm: khi tự craft gói tin thô qua raw socket, hệ điều hành không tự tính checksum giúp bạn — nếu ghi sai giá trị, kernel/máy đích sẽ âm thầm vứt gói, không báo lỗi rõ ràng, khiến debug rất khó hiểu. Đây là kỹ năng nền cho fuzzing tầng mạng.

3.3. Đếm bit, thao tác bit thông dụng

Ví dụ minh họa với số 4-bit x = 0110 (công tắc 3=0, 2=1, 1=1, 0=0):

__builtin_popcount(x);   // đếm số bit 1 (GCC/Clang)
__builtin_clz(x);        // số bit 0 dẫn đầu
x & (x - 1);             // xóa bit 1 thấp nhất (mẹo kinh điển)
x & -x;                  // tách bit 1 thấp nhất
  • __builtin_popcount(x): đếm số công tắc đang bật. Với x = 0110 có 2 công tắc bật → trả về 2.
  • __builtin_clz(x): đếm số công tắc tắt liên tiếp từ bên trái cho tới khi gặp công tắc bật đầu tiên. Với x = 0110, công tắc ngoài cùng trái (vị trí 3) tắt — đếm 1 — rồi gặp ngay công tắc vị trí 2 đang bật, dừng lại → clz = 1.
  • x & (x - 1): xóa đúng bit 1 thấp nhất, giữ nguyên còn lại. Với x = 0110: x - 1 = 0101 (trừ 1 làm bit thấp nhất đang bật tắt đi, bit thấp hơn nó bật lên do “mượn”). AND 0110 & 0101 chỉ giữ công tắc nào cả hai cùng bật → 0100 — bit bật thấp nhất ban đầu đã biến mất. Dùng để lần lượt tắt từng công tắc trong vòng lặp (đếm bit thủ công), hoặc kiểm tra “có phải lũy thừa của 2” ((x != 0) && ((x & (x-1)) == 0), vì lũy thừa của 2 chỉ có đúng 1 bit bật).
  • x & -x: ngược lại, tách riêng đúng bit 1 thấp nhất, tắt hết còn lại — nhờ cách máy tính biểu diễn số âm (bù 2, -x = ~x + 1).

Tổng kết

  • Macro hằng an toàn (chỉ đặt tên số); macro hàm là dán chữ mù quáng — luôn bọc ngoặc từng tham số và cả biểu thức, cẩn thận tác dụng phụ khi tham số có ++/--; khi phân vân, dùng static inline function.
  • Điều kiện biên dịch (#ifdef/#ifndef/#if) quyết định code có được giữ để biên dịch hay bị xóa hoàn toàn — dùng cho header guard, code theo nền tảng, bật/tắt debug log không tốn chi phí runtime.
  • Bitwise flags: OR để bật, AND-với-đảo để tắt, AND để hỏi, XOR để đảo — mỗi phép nhắm đúng 1 bit mà không đụng bit khác.
  • Checksum internet: cộng dồn 16-bit, gập carry, đảo bit — quan trọng khi tự ghép gói tin thô qua raw socket.
  • popcount, clz, x&(x-1), x&-x: các mẹo bit tiêu chuẩn cho bitmap hiệu năng cao (quản lý session, trạng thái subchannel…).
Contact
Facebook
TOP