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 Bitwise có kèm code chạy thật và kết quả in ra thực tế (biên dịch bằng gcc) để đối chiếu.

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 (kèm code chạy thật + kết quả thực tế)

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 — demo chạy thật

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

void print_bin8(uint8_t x) {
    for (int i = 7; i >= 0; i--) {
        printf("%d", (x >> i) & 1);
        if (i == 4) printf(" ");
    }
}

enum {
    F_PROXYABLE = 1 << 0,   // 0x01
    F_ERROR     = 1 << 1,   // 0x02
    F_RETRANS   = 1 << 2,   // 0x04
};

int main(void) {
    uint8_t flags = 0;
    printf("Ban dau:              flags = "); print_bin8(flags); printf(" (0x%02X)\n", flags);

    flags |= F_ERROR;
    printf("Sau flags |= F_ERROR:  flags = "); print_bin8(flags); printf(" (0x%02X)\n", flags);

    flags |= F_PROXYABLE;
    printf("Sau flags |= F_PROXYABLE: flags = "); print_bin8(flags); printf(" (0x%02X)\n", flags);

    printf("Kiem tra F_PROXYABLE: flags & F_PROXYABLE = %d -> %s\n",
           flags & F_PROXYABLE, (flags & F_PROXYABLE) ? "dang BAT" : "dang TAT");
    printf("Kiem tra F_RETRANS:   flags & F_RETRANS   = %d -> %s\n",
           flags & F_RETRANS, (flags & F_RETRANS) ? "dang BAT" : "dang TAT");

    flags &= ~F_ERROR;
    printf("Sau flags &= ~F_ERROR: flags = "); print_bin8(flags); printf(" (0x%02X)\n", flags);

    flags ^= F_RETRANS;
    printf("Sau flags ^= F_RETRANS (lan 1): flags = "); print_bin8(flags); printf(" (0x%02X)\n", flags);

    flags ^= F_RETRANS;
    printf("Sau flags ^= F_RETRANS (lan 2): flags = "); print_bin8(flags); printf(" (0x%02X)\n", flags);

    return 0;
}

Kết quả in ra khi chạy thật (biên dịch bằng gcc):

Ban dau:              flags = 0000 0000 (0x00)
Sau flags |= F_ERROR:  flags = 0000 0010 (0x02)
Sau flags |= F_PROXYABLE: flags = 0000 0011 (0x03)
Kiem tra F_PROXYABLE: flags & F_PROXYABLE = 1 -> dang BAT
Kiem tra F_RETRANS:   flags & F_RETRANS   = 0 -> dang TAT
Sau flags &= ~F_ERROR: flags = 0000 0001 (0x01)
Sau flags ^= F_RETRANS (lan 1): flags = 0000 0101 (0x05)
Sau flags ^= F_RETRANS (lan 2): flags = 0000 0001 (0x01)

Đọc kết quả từng bước:

  • flags |= F_ERROR: từ 0000 0000 thành 0000 0010 — chỉ đúng công tắc số 1 (giá trị F_ERROR = 0x02) được bật lên, không đụng công tắc khác.
  • flags |= F_PROXYABLE: thành 0000 0011 — thêm công tắc số 0 (F_PROXYABLE = 0x01) mà công tắc số 1 vẫn giữ nguyên trạng thái bật. OR chỉ “cộng thêm”, không bao giờ tắt bit đã bật.
  • Kiểm tra flags & F_PROXYABLE ra 1 (khác 0) → đang bật; flags & F_RETRANS ra 0 → đang tắt, đúng như flags hiện tại 0000 0011 (chưa hề đụng tới bit F_RETRANS).
  • flags &= ~F_ERROR: từ 0000 0011 về 0000 0001 — chỉ đúng bit số 1 bị tắt, bit số 0 (F_PROXYABLE) vẫn giữ nguyên bật.
  • flags ^= F_RETRANS lần 1: từ 0000 0001 thành 0000 0101 — bit số 2 (F_RETRANS) đang tắt nên XOR biến nó thành bật, các bit khác giữ nguyên.
  • flags ^= F_RETRANS lần 2: quay lại 0000 0001 — XOR lần nữa vào đúng bit đó khiến nó tắt trở lại, chứng minh XOR đúng là phép “đảo qua đảo lại”.

Ứ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) — demo chạy thật

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:

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

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];   // cong tung word 16-bit
    if (len & 1) sum += (uint32_t)data[len-1] << 8;     // byte le cuoi
    while (sum >> 16) sum = (sum & 0xFFFF) + (sum >> 16); // gap carry
    return (uint16_t)~sum;                                // bu 1
}

int main(void) {
    uint8_t packet[8] = {0x45, 0x00, 0x00, 0x1C, 0x1B, 0xB0, 0x00, 0x00};

    printf("Du lieu goc (hex): ");
    for (int i = 0; i < 8; i++) printf("%02X ", packet[i]);
    printf("\n");

    uint16_t cksum = inet_checksum(packet, 8);
    printf("Checksum tinh duoc: 0x%04X\n", cksum);

    uint8_t packet_with_cksum[8];
    memcpy(packet_with_cksum, packet, 6);
    packet_with_cksum[6] = (cksum >> 8) & 0xFF;
    packet_with_cksum[7] = cksum & 0xFF;

    printf("Packet sau khi chen checksum vao 2 byte cuoi (hex): ");
    for (int i = 0; i < 8; i++) printf("%02X ", packet_with_cksum[i]);
    printf("\n");

    uint16_t verify = inet_checksum(packet_with_cksum, 8);
    printf("Ben nhan tinh lai checksum tren toan bo packet (ke ca truong checksum): 0x%04X\n", verify);

    uint8_t corrupted[8];
    memcpy(corrupted, packet_with_cksum, 8);
    corrupted[2] ^= 0xFF; // gia lap 1 byte bi loi tren duong truyen

    printf("\nGia lap loi: dao nguoc byte thu 3 -> ");
    for (int i = 0; i < 8; i++) printf("%02X ", corrupted[i]);
    printf("\n");
    uint16_t verify2 = inet_checksum(corrupted, 8);
    printf("Checksum tinh lai tren du lieu loi: 0x%04X\n", verify2);

    return 0;
}

Kết quả in ra khi chạy thật:

Du lieu goc (hex): 45 00 00 1C 1B B0 00 00
Checksum tinh duoc: 0x9F33
Packet sau khi chen checksum vao 2 byte cuoi (hex): 45 00 00 1C 1B B0 9F 33
Ben nhan tinh lai checksum tren toan bo packet (ke ca truong checksum): 0x0000

Gia lap loi: dao nguoc byte thu 3 -> 45 00 FF 1C 1B B0 9F 33
Checksum tinh lai tren du lieu loi: 0x00FF

Đọc kết quả:

  • Với 8 byte dữ liệu gốc, hàm tính ra checksum 0x9F33.
  • Khi chèn checksum đó vào 2 byte cuối rồi tính lại checksum trên toàn bộ packet (kể cả 2 byte checksum vừa chèn), kết quả ra đúng 0x0000 — đây chính là cách bên nhận xác nhận “dữ liệu không lỗi”: chỉ cần tính checksum toàn bộ gói và so với 0x0000, không cần tách riêng phần checksum ra để so sánh thủ công.
  • Sau khi giả lập lỗi (đảo ngược 1 byte giữa gói tin, mô phỏng nhiễu trên đường truyền), tính lại checksum trên dữ liệu lỗi ra 0x00FF — khác 0x0000 → phát hiện được ngay là dữ liệu đã bị hỏng.

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 — demo chạy thật

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

void print_bin8(uint8_t x) {
    for (int i = 7; i >= 0; i--) printf("%d", (x >> i) & 1);
}

int main(void) {
    uint8_t x = 0x6C; // 0110 1100

    printf("x = 0x%02X = ", x); print_bin8(x); printf("\n\n");

    printf("__builtin_popcount(x) = %d\n", __builtin_popcount(x));
    printf("__builtin_clz(x)      = %d\n", __builtin_clz((unsigned int)x));

    uint8_t step = x;
    int count = 0;
    printf("\nDung x & (x-1) de lan luot xoa bit 1 thap nhat:\n");
    while (step) {
        printf("  step = "); print_bin8(step); printf(" (0x%02X)\n", step);
        step = step & (step - 1);
        count++;
    }
    printf("  step = "); print_bin8(step); printf(" (0x%02X)  -> het bit 1, da xoa %d lan\n", step, count);

    uint8_t low_bit = x & (-x);
    printf("\nx & (-x) = "); print_bin8(low_bit); printf(" (0x%02X)\n", low_bit);

    return 0;
}

Kết quả in ra khi chạy thật:

x = 0x6C = 01101100

__builtin_popcount(x) = 4
__builtin_clz(x)      = 25

Dung x & (x-1) de lan luot xoa bit 1 thap nhat:
  step = 01101100 (0x6C)
  step = 01101000 (0x68)
  step = 01100000 (0x60)
  step = 01000000 (0x40)
  step = 00000000 (0x00)  -> het bit 1, da xoa 4 lan

x & (-x) = 00000100 (0x04)

Đọc kết quả:

  • x = 0x6C = 0110 1100 có tất cả 4 bit là 1 → popcount = 4, khớp đúng kết quả in ra.
  • __builtin_clz(x) = 25 — con số này lớn hơn nhiều so với suy nghĩ “chỉ có 8 bit”. Lý do: __builtin_clz luôn tính trên kiểu unsigned int 32-bit, nên khi truyền x (8-bit) vào, nó tự động được “nới rộng” thành số 32-bit 00000000 00000000 00000000 01101100. Đếm số bit 0 liên tiếp từ trái cho tới bit 1 đầu tiên (nằm ở vị trí thứ 7 tính từ phải) ra đúng 32 - 7 = 25. Lưu ý này rất hay bị người mới bỏ sót — luôn nhớ clz đếm trên toàn bộ độ rộng kiểu dữ liệu, không phải trên “số bit có nghĩa” mà bạn nghĩ trong đầu.
  • x & (x-1) lặp lại 4 lần thì về 0 — đúng bằng số bit 1 (4 bit) đã đếm ở trên bằng popcount. Mỗi lần lặp, đúng 1 bit 1 thấp nhất biến mất: 0110 1100 → 0110 1000 → 0110 0000 → 0100 0000 → 0000 0000. Đây cũng là cách thủ công để tự viết hàm đếm bit nếu compiler không hỗ trợ __builtin_popcount.
  • x & (-x) = 0000 0100 — tách ra đúng 1 bit duy nhất là bit 1 thấp nhất của x (bit số 2, giá trị 4), chứ không xóa nó đi như phép trên. Hai phép x&(x-1) và x&(-x) là “cặp bài trùng”: một cái loại bỏ bit thấp nhất, một cái giữ lại đúng bit đó.

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, đã kiểm chứng bằng code chạy thật ở trên.
  • Checksum internet: cộng dồn 16-bit, gập carry, đảo bit; tính lại trên toàn gói tin (kể cả trường checksum) phải ra đúng 0x0000 mới là dữ liệu hợp lệ — 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…); chú ý clz luôn tính trên độ rộng kiểu dữ liệu đầy đủ (thường 32-bit), không phải số bit “có nghĩa” trong đầu bạn.
Contact
Facebook
TOP