← Lập trình C nâng cao

Bài 16 · Nâng cao · 26 phút· Cập nhật 11/06/2026

Bẫy kinh điển & undefined behavior

Biên soạn bởi Nguyễn Anh Tuấn

Bẫy kinh điển trong C (C Traps and Pitfalls): = vs ==, số octal, dangling else, ưu tiên toán tử - và undefined behavior (UB) kèm cách bắt bằng sanitizer.

Suốt 15 bài, bạn đã gặp lác đác những cái bẫy: chuỗi hằng không được sửa (Bài 5), con trỏ sau khi free (Bài 7), các bẫy con trỏ (Bài 10). Bài này gom chúng về một mối - theo cuốn sách mỏng nhất và nổi tiếng nhất về chủ đề này: C Traps and Pitfalls (Andrew Koenig, 1989). Koenig làm việc tại Bell Labs - chính nơi sinh ra C - và viết sách từ danh sách những lỗi mà các đồng nghiệp lão luyện của ông vẫn mắc.

Sách xếp bẫy theo tầng, giống như cách trình biên dịch đọc code của bạn:

  • Bẫy TỪ VỰNG (lexical): từng ký tự bị đọc khác ý bạn - dấu = không phải ==, số 010 không phải mười.
  • Bẫy CÚ PHÁP (syntactic): từng câu lệnh bị ghép khác ý bạn - else thuộc về if nào, dấu ; thừa, toán tử nào tính trước.
  • UNDEFINED BEHAVIOR: code hợp lệ về cú pháp nhưng chuẩn C không hứa gì về kết quả - tầng nguy hiểm nhất.

Vì sao đáng học riêng một bài

Các bẫy này không phải lỗi của trình biên dịch - chúng là hệ quả của thiết kế mà Ritchie chọn để C nhỏ và nhanh. Chúng tồn tại từ 1972, và vẫn nằm nguyên trong code C bạn sẽ đọc đi làm. Học nhận diện một lần, dùng cả đời.

Bẫy nổi tiếng nhất của C chỉ là thiếu một ký tự. Trong C, phép gán x = 5 là một biểu thức CÓ GIÁ TRỊ (bằng 5), nên đặt nó vào if hoàn toàn hợp lệ:

gan-trong-if.c - biên dịch chạy bình thường; -Wall sẽ cảnh báo -Wparentheses đúng dòng này

#include <stdio.h>

int main(void) {
    int x = 0;
    if (x = 5)
        printf("vao nhanh if! x = %d\n", x);
    return 0;
}

Kết quả khi chạy

vao nhanh if! x = 5

x đang là 0, nhưng x = 5 GÁN 5 vào x rồi trả về 5 - khác 0 nghĩa là "đúng", thế là vào nhánh if. Bẫy thứ hai còn im lặng hơn - nó nằm ở cách C đọc chữ số đầu tiên:

so-bat-phan.c - không một cảnh báo nào, kể cả với -Wall -Wextra

#include <stdio.h>

int main(void) {
    int ngay = 010;   /* dinh ghi ngay 10 */
    printf("ngay = %d\n", ngay);
    return 0;
}

Kết quả khi chạy

ngay = 8
  • Hằng số nguyên bắt đầu bằng 0 là hệ BÁT PHÂN (octal): 010 = 8, 020 = 16 - di sản từ thời Unix.
  • Bẫy = vs == có cảnh báo nếu bật -Wall; bẫy octal thì KHÔNG - hợp lệ tuyệt đối, chỉ có mắt người cứu được.
  • Chỗ hay dính octal nhất: căn cột số cho thẳng hàng bằng số 0 đứng đầu.

Tầng thứ hai: từng từ đọc đúng cả, nhưng cách chúng ghép thành câu thì khác ý bạn. Thủ phạm quen nhất là một dấu ; thừa:

cham-phay-thua.c - ĐỪNG chạy: kẹt vô hạn, không bao giờ in (dấu ; sau while là thân vòng lặp rỗng)

#include <stdio.h>

int main(void) {
    int i = 1;
    while (i <= 3);   /* <-- dau ; nay la THAN vong lap */
        i++;          /* thut le chi lua mat nguoi      */
    printf("%d\n", i);
    return 0;
}

Thụt lề không có ý nghĩa với C - và đó cũng là gốc của bẫy dangling else: else luôn gắn với if gần nhất chưa có else, bất kể bạn thụt lề thế nào:

dangling-else.c - người viết muốn else thuộc if ngoài, C nghĩ khác (-Wall cảnh báo -Wdangling-else)

#include <stdio.h>

int main(void) {
    int diem = 4;
    if (diem >= 0)
        if (diem >= 5)
            printf("Dau\n");
    else
        printf("Diem am?!\n");
    return 0;
}

Kết quả khi chạy

Diem am?!

Điểm 4 không hề âm, nhưng else thuộc về if (diem >= 5) - và 4 < 5 nên nhánh else chạy. Bẫy cú pháp cuối cùng là thứ tự ưu tiên toán tử, Koenig xếp vào nhóm kinh điển nhất:

uu-tien.c - kiểm tra số chẵn bằng bit cuối… nhưng == tính trước &

#include <stdio.h>

int main(void) {
    int x = 6;            /* 6 la so chan */
    if (x & 1 == 0)
        printf("chan\n");
    else
        printf("le?!\n");
    return 0;
}

Kết quả khi chạy

le?!
  • == có ưu tiên CAO hơn &, nên x & 1 == 0 nghĩa là x & (1 == 0) = x & 0 = 0 - "sai" với mọi x.
  • Quy tắc sống còn: trộn toán tử bit (& | ^ << >>) với so sánh thì LUÔN bọc ngoặc - (x & 1) == 0.
  • Cả ba bẫy cú pháp trên đều có cảnh báo nếu bật -Wall - thêm một lý do để không bao giờ biên dịch "trần".

Đến lượt bạn. Mỗi bẫy dưới đây là một chương trình nhỏ - đoán kết quả trước, chốt rồi mới hiện đáp án và lời giải. Có cả những bẫy chưa xuất hiện ở trên:

int x = 0;
if (x = 5)
    printf("vao nhanh if! x = %d\n", x);

Chương trình in gì?

Dự đoán trước rồi mới xem đáp án - chốt là không đổi được.

Đã săn 0/8 bẫy · còn 8 bẫy chưa đoán

Trung thực

Các đáp án "in ra X" trong công cụ đều đã được biên dịch và chạy kiểm thật. Riêng các bẫy thuộc nhóm undefined behavior, đáp án đúng luôn là "không xác định" - kết quả bạn thấy khi tự chạy trên máy chỉ là MỘT khả năng, không phải chân lý. Vì sao lại thế: Bước 5.

Hai bẫy cuối trong công cụ không có "kết quả đúng" để học thuộc - vì chuẩn C cố tình không định nghĩa hành vi của chúng. Undefined behavior (UB) nghĩa là: chương trình vi phạm một quy tắc mà chuẩn không bắt trình biên dịch phải kiểm tra, nên mọi kết quả đều "hợp lệ" - sập, số rác, hay đáng sợ nhất: chạy đúng hôm nay, hỏng ngày mai. Bản đồ các UB bạn sẽ gặp nhiều nhất:

Undefined behaviorVí dụĐã gặp ở
Tràn số nguyên có dấuINT_MAX + 1bài này
Sửa & đọc cùng biến không thứ tựa[i] = i++công cụ Bước 4
Sửa chuỗi hằngchar *p = "hi"; p[0] = 'H';Bài 5 - Chuỗi & con trỏ
Dùng con trỏ sau freefree(p); *pBài 7 - Cấp phát động
Đọc biến chưa khởi tạoint x; printf("%d", x);bài này

Không ai nhớ hết danh sách UB của chuẩn (hơn 200 mục) - nên người viết C chuyên nghiệp phòng thủ bằng công cụ, theo hai lớp:

  • Lớp 1 - lúc biên dịch: LUÔN bật cc -std=c17 -Wall -Wextra (dự án nghiêm túc thêm -Werror: cảnh báo = lỗi, không cho lờ đi).
  • Lớp 2 - lúc chạy: sanitizer. Thêm -fsanitize=undefined,address là chương trình tự tố cáo UB và lỗi bộ nhớ ngay tại dòng gây ra.
  • Quy tắc vàng: không bao giờ "thử xem máy in gì" để kết luận về UB - máy chỉ cho bạn MỘT khả năng trong vô số.

Sẽ học kỹ hơn

Sanitizer (ASan, UBSan, TSan) có hẳn một bài riêng trong khoá C Thực Chiến · Toolchain - ở đó mèo con sẽ cố tình gây từng loại lỗi và xem công cụ tóm tại trận.

Bài tiếp theo

Cờ biên dịch và sanitizer bắt được lỗi ngôn ngữ - nhưng không biết hàm của bạn tính sai nghiệp vụ. Lớp phòng thủ tiếp theo phải do bạn tự viết: unit test - chính là bài kế.

Câu hỏi thường gặp

Còn - vì các bẫy trong sách nằm ở THIẾT KẾ của ngôn ngữ, không phải ở một phiên bản trình biên dịch. = vs ==, dangling else, ưu tiên toán tử… hôm nay vẫn y nguyên như 1989. Thứ đã thay đổi là công cụ: trình biên dịch hiện đại cảnh báo được phần lớn các bẫy này - nếu bạn bật cờ.

Vì ba lý do: (1) không phải bẫy nào cũng có cảnh báo - 010 là số octal hợp lệ, im lặng tuyệt đối; (2) cảnh báo chỉ hiện khi bạn bật cờ, mà rất nhiều dự án không bật; (3) đọc code của người khác (review, debug) thì không có trình biên dịch nào đứng cạnh nhắc bạn.

Khác về mức độ. "Tuỳ trình biên dịch" (implementation-defined) nghĩa là mỗi trình biên dịch chọn MỘT cách và phải ghi vào tài liệu - vd kích thước int. Còn undefined behavior là chuẩn C buông tay hoàn toàn: chương trình có thể sập, ra số rác, hoặc đáng sợ nhất - chạy "đúng" hôm nay và hỏng vào lúc quan trọng nhất.

Đánh đổi lấy tốc độ. Nếu chuẩn bắt kiểm tra tràn số ở MỌI phép cộng, mọi chương trình C đều chậm đi. C chọn tin lập trình viên: "anh nói anh không tràn, tôi khỏi kiểm" - và trình tối ưu hoá dựa vào niềm tin đó để sinh mã nhanh. Đổi lại, khi bạn phá lời hứa, hậu quả không được định nghĩa.

"Chạy đúng trên máy tôi" là dạng nguy hiểm nhất của UB: nó đúng cho tới khi đổi trình biên dịch, đổi mức tối ưu -O2, hay đổi máy. Bằng chứng phải đến từ chuẩn ngôn ngữ và công cụ (cảnh báo, sanitizer), không phải từ một lần chạy may mắn.

Có - fall-through là tính năng thật, dùng khi nhiều case chung một cách xử lý. Nhưng vì nó trông giống hệt lỗi quên break, code hiện đại đánh dấu chỗ cố ý bằng comment /* fall through */ hoặc thuộc tính [[fallthrough]] (C23) để người đọc và công cụ phân biệt được với lỗi.

Tick những điều em tự tin làm được. Càng lên cao, em càng hiểu sâu.

Tick những điều em tự tin làm được sau khi học bài này. 0/6

Trả lời vài câu để chắc rằng em đã nắm bài.

Câu 1/3 Điểm: 0

int t = 020; printf("%d", t); in ra số mấy?

  1. 1

    Săn sạch 8 bẫy

    Quay lại công cụ Bước 4: dự đoán đủ 8 bẫy. Bẫy nào đoán sai, đọc kỹ phần giải thích rồi kể lại bằng lời của mèo con.

    Hoàn thành khi: Đã săn 8/8, và giải thích lại được 2 bẫy từng đoán sai mà không nhìn đáp án.

  2. 2

    Bắt trình biên dịch khai

    Chép đoạn if (x = 5) ở Bước 2 vào máy, biên dịch 2 lần: không cờ, rồi với -Wall -Wextra. So hai kết quả.

    Hoàn thành khi: Không cờ: im lặng. Có cờ: cảnh báo -Wparentheses chỉ đúng dòng if (x = 5).

  3. 3

    Bẫy im lặng nhất

    Biên dịch int t = 020; printf("%d", t); với -Wall -Wextra. Trình biên dịch có cảnh báo gì không? In ra số mấy?

    Hoàn thành khi: Không một cảnh báo nào; in 16 - vì 020 là octal. Đây là bẫy hiếm hoi mà cờ không cứu được.

  4. 4

    Sửa dangling else

    Lấy ví dụ diem = 4 ở Bước 3, thêm ngoặc nhọn để else thuộc về if NGOÀI (ý đồ ban đầu), chạy lại.

    Hoàn thành khi: Với diem = 4 chương trình không in gì; với diem = -1 in "Diem am?!".

  5. 5

    Tự gây tràn số có chủ đích

    Viết chương trình cộng INT_MAX + 1 (cần limits.h), biên dịch với cc -std=c17 -fsanitize=undefined rồi chạy.

    Hoàn thành khi: UBSan in dòng "runtime error: signed integer overflow" - công cụ bắt được UB mà mắt thường bỏ qua.

  6. 6

    Đọc sâu thêm (Koenig)

    Nếu có cuốn C Traps and Pitfalls (giới thiệu ở bài 1 khoá C cơ bản): đọc chương 1 và 2 - bài này chỉ lấy một phần trong đó.

    Hoàn thành khi: Tìm được ít nhất 1 bẫy trong sách mà bài này chưa nhắc, và tự giải thích được nó.