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)
Bố cục bài viết
Trang này
Ngày tạo
14/09/2026
Cập nhật
Lượt xem
14
Đọc
15m
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.
inlinegợ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.staticgiới hạn hàm trong phạm vi file.chiệ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
#endiftương ứng cho mỗi#ifdef/#ifndef/#if. #define DEBUGkhông cần giá trị vẫn đủ để#ifdef DEBUGnhận ra “có tồn tại”.- Nhầm
#ifdefvới#if defined(...)—#if defined(A) && defined(B)cho phép kết hợp nhiều điều kiện,#ifdefchỉ 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 0000thành0000 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ành0000 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_PROXYABLEra1(khác 0) → đang bật;flags & F_RETRANSra0→ đang tắt, đúng như flags hiện tại0000 0011(chưa hề đụng tới bitF_RETRANS). flags &= ~F_ERROR: từ0000 0011về0000 0001— chỉ đúng bit số 1 bị tắt, bit số 0 (F_PROXYABLE) vẫn giữ nguyên bật.flags ^= F_RETRANSlần 1: từ0000 0001thành0000 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_RETRANSlần 2: quay lại0000 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ới0x0000, 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ác0x0000→ 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 1100có 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_clzluôn tính trên kiểuunsigned int32-bit, nên khi truyềnx(8-bit) vào, nó tự động được “nới rộng” thành số 32-bit00000000 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 đúng32 - 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ằngpopcount. 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ủax(bit số 2, giá trị 4), chứ không xóa nó đi như phép trên. Hai phépx&(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ùngstatic inlinefunction. - Đ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
0x0000mớ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ú ýclzluô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.