Tại sao mọi thứ trong C đều là "hành vi không xác định"?

Công nghệ20 tháng 5, 2026·11 phút đọc

Bài viết này phân tích sâu về những cạm bẫy trong ngôn ngữ lập trình C và C++, nơi "hành vi không xác định" (undefined behavior) ẩn nấp khắp nơi. Từ việc căn chỉnh bộ nhớ sai lệch đến chuyển đổi kiểu dữ liệu nguy hiểm, tác giả lập luận rằng việc viết mã C/C++ hoàn hảo là gần như không thể và đề xuất sử dụng AI để giám sát mã nguồn.

Nếu Hồng y Richelieu là một lập trình viên, ông có lẽ đã nói: "Hãy cho tôi sáu dòng code do tay người lập trình C giỏi nhất thế giới viết ra, và tôi sẽ tìm đủ lý do để kích hoạt hành vi không xác định (undefined behavior - UB)".

Không ai có thể viết mã C hay C++ hoàn toàn chính xác. Tôi nói điều này với tư cách là người đã viết C và C++ gần như mỗi ngày trong khoảng 30 năm qua. Tôi nghe podcast về C++, xem các bài thuyết trình tại hội nghị C++ và thích đọc cũng như viết C++. C++ đã phục vụ chúng ta tốt, nhưng đây là năm 2026, và môi trường của năm 1985 (C++) hay 1972 (C) không còn là môi trường của ngày nay.

Tôi chắc chắn không phải người đầu tiên nói điều này. Tôi nhớ đã đọc một bài đăng của một người nổi tiếng khoảng một thập kỷ trước cho rằng có thể lập luận rằng việc sử dụng C++ là một vi phạm SOX (đạo luật Sarbanes-Oxley về trách nhiệm tài chính). Dù tôi không đồng ý với phần còn lại của bài viết đó, nhưng tôi chưa bao giờ phản đối điểm này.

Theo thời gian, tôi nhận thấy điều đó ngày càng đúng thực. Rất nhiều thứ là hành vi không xác định (UB) nhiều hơn bạn tưởng tượng.

Mọi người đều biết rằng double-free (giải phóng bộ nhớ hai lần), use after free (sử dụng sau khi giải phóng), truy cập ngoài giới hạn của đối tượng (ví dụ: mảng) và truy cập bộ nhớ chưa khởi tạo là UB. Sau cùng, C/C++ không phải là ngôn ngữ an toàn về bộ nhớ. Tuy nhiên, ngành công nghiệp của chúng ta dường như không thể ngừng mắc những lỗi này lặp đi lặp lại.

Nhưng còn nhiều hơn thế. Rất tinh tế hơn. Và phi lý hơn.

Không phải là vấn đề tối ưu hóa

Một số người dường như nghĩ rằng miễn là họ không biên dịch với các tối ưu hóa được bật, hành vi không xác định không thể gây hại cho họ. Họ tin rằng trình biên dịch đang cố tình thù địch, kiểu như "AHA! UB! Tôi có thể làm bất cứ điều gì tôi muốn ở đây!", và nếu không có tối ưu hóa thì nó sẽ không làm vậy.

Điều này là sai lầm.

UB không có nghĩa là trình biên dịch có thể tận dụng sự sơ hở của bạn. UB có nghĩa là trình biên dịch có thể giả định rằng mã của bạn hợp lệ. Nó có nghĩa là ý định của mã bạn mà con người đọc thì thấy rõ ràng đến mức nào, thậm chí không có cách nào để biểu đạt giữa các giai đoạn hoặc mô-đun của trình biên dịch.

UB có nghĩa là trình biên dịch thậm chí không cần phải triển khai một số trường hợp đặc biệt trong việc tạo mã của nó, vì chúng "không thể xảy ra".

Trình biên dịch, và thực sự là phần cứng bên dưới, đang chơi một trò chơi "điện thoại viên" với những ý định UB của bạn. Nó có thể kết thúc với những gì bạn mong muốn, nhưng không có đảm bảo cho hiện tại hay tương lai.

UB ở khắp mọi nơi

Dưới đây không phải là một nỗ lực liệt kê tất cả UB trên thế giới. Nó chỉ nhằm chứng minh rằng UB ở khắp mọi nơi, và nếu không ai làm đúng nó, thì việc đổ lỗi cho lập trình viên làm sao có thể công bằng? Điểm của tôi là TẤT CẢ mã C/C++ không tầm thường đều có UB.

Truy cập đối tượng không được căn chỉnh đúng (Alignment)

Lấy ví dụ mã này:

int foo(const int* p) {
    return *p;
}

Nếu hàm này được gọi với một con trỏ không được căn chỉnh đúng (có nghĩa là địa chỉ không phải là bội số của sizeof(int)), đây là UB. Theo tiêu chuẩn C23 6.3.2.3.

Trên Linux Alpha, trong một số trường hợp, nó chỉ sẽ bẫy vào kernel, kernel sẽ mô phỏng phần mềm những gì bạn định làm. Trong các trường hợp khác, nó có thể (có lẽ) làm sập chương trình của bạn với SIGBUS.

Trên SPARC, nó sẽ gây ra SIGBUS.

Chắc chắn, trên x86/amd64 (sau đây gọi tắt là "x86"), điều này có thể ổn. Thậm chí, nó có thể là một đọc nguyên tử. x86 nổi tiếng là cực độ bao dung với các sắc thái về nhất quán bộ nhớ.

Vì vậy, chúng ta có ba trường hợp:

  • Kernel giúp đỡ (Alpha cho một số lệnh tải)
  • Sập chương trình (các lệnh tải Alpha khác và SPARC)
  • Không có vấn đề gì (x86)

Còn ARM, RISC-V và những cái khác thì sao? Các kiến trúc trong tương lai thì sao? Một kiến trúc trong tương lai thậm chí có thể có các thanh ghi con trỏ int đặc biệt không điền các bit thấp hơn, vì các con trỏ như vậy không thể tồn tại.

Ngay cả khi nó hoạt động, có lẽ một ngày nào đó trình biên dịch thay đổi từ việc sử dụng một lệnh tải sang lệnh khác, và đột nhiên nó không còn được sửa bởi kernel. Bởi vì trình biên dịch không có nghĩa vụ phải tạo ra các hướng dẫn assembly hoạt động trên các con trỏ không căn chỉnh. Bởi vì nó là UB.

isxdigit() trên đầu vào char

bool bar(char ch) {
    return isxdigit(ch);
}

isxdigit() là một hàm đơn giản nhận một ký tự và trả về 1 nếu nó là chữ số thập lục phân (0-9 hoặc a-f). Nó cũng có thể nhận giá trị EOF. Uh, ok. Giá trị EOF là gì? Theo C23 7.4p1, chúng ta biết nó là một int, và chúng ta có thể suy luận rằng nó không thể biểu diễn bằng unsigned char.

Do đó, isxdigit() nhận một int, không phải char. Tất cả các giá trị của char vừa nằm trong int, vì vậy chúng ta nên ổn. Ép từ char sang int vừa vặn, vậy theo mục 6.3.1.3 chúng ta ổn, đúng không?

Không. Bởi vì nếu bar() được gọi với một giá trị khác 0-127, và trên kiến trúc của bạn char là signed (có dấu - được định nghĩa bởi triển khai, theo mục 6.2.5, đoạn 20 trong C23), thì giá trị nguyên sẽ kết thúc là số âm.

Và sau đây là một triển khai hợp lệ của isxdigit(), sẽ gây ra việc đọc bộ nhớ không biết là gì:

int isxdigit(int c) {
    if (c == EOF) {
        return false;
    }
    return some_array[c];
}

Ép kiểu từ float sang int

int milliseconds(float seconds) {
    int tmp = (int)(seconds * 1000.0); /* SAI */
    return tmp + 1; /* SAI riêng biệt (tràn số có dấu là UB) */
}

Khi một giá trị hữu hạn của kiểu dấu chấm động thực được chuyển đổi sang kiểu nguyên... Nếu giá trị của phần nguyên không thể được biểu diễn bởi kiểu nguyên, hành vi là không xác định.

— 6.3.1.4

Và, vì sự bỏ sót, nó cũng là UB nếu float là một giá trị vô hạn (non-finite).

Vậy làm thế nào bạn so sánh một float với INT_MAX? Bạn có ép float thành int không? Không, đó là UB bạn muốn tránh. Vậy bạn ép INT_MAX thành float? Làm sao bạn biết nó có thể được biểu diễn chính xác? Có lẽ việc ép INT_MAX thành float làm tròn thành một giá trị không thể biểu diễn trong int, và phép so sánh của bạn trở nên không đại diện?

Có lẽ đoạn sau hoạt động? Bạn sẽ bỏ lỡ việc biểu diễn một số giá trị rất cao, nhưng có lẽ điều đó ổn?

int milliseconds(float seconds) {
    const float ftmp = seconds * 1000.0f;
    if (!isfinite(ftmp)) {
        return 0;
    }
    if ((float)(INT_MIN + 1000) > ftmp) {
        return 0;
    }
    if ((float)(INT_MAX - 1000) < ftmp) {
        return 0;
    }
    const int tmp = (int)ftmp;
    if (INT_MAX == tmp) {
        return 0;
    }
    return tmp + 1;
}

Tôi chỉ muốn chuyển đổi một float thành int. :-(

Tôi cá là có rất nhiều mã ngoài kia nhận một giá trị bằng giây và chuyển đổi nó thành mili-giây nguyên, chỉ bằng cách nhân và ép kiểu.

Đối tượng tại địa chỉ zero

Hầu hết các lập trình viên sẽ không phải xử lý việc này, nhưng tôi không nghĩ có cách nào tuân thủ tiêu chuẩn C trong thực tế để đặt một đối tượng tại địa chỉ zero. Điều này có thể phát sinh trong lập trình kernel OS và nhúng.

Theo 6.3.2.3, một hằng số nguyên zero (có thể chuyển đổi thành con trỏ) và nullptr là "hằng số con trỏ null" (tôi sẽ gọi là NULL). C không quy định rằng con trỏ thực tế NULL trỏ đến địa chỉ máy zero, vì tiêu chuẩn C chỉ nói về máy trừu tượng C, không phải về phần cứng.

Tất cả những gì C đảm bảo là nếu bạn so sánh NULL với zero, bạn sẽ thấy chúng bằng nhau. Nhưng đối với tất cả những gì bạn biết, đó là vì zero được chuyển đổi thành NULL gốc của nền tảng, mà tình cờ là 0xffff.

Nó cũng nói rõ rằng việc giải tham chiếu một con trỏ null, bất kể giá trị là gì, là hành vi không xác định. Đó là ví dụ về UB trong mục 3.4.3.

Điều này cũng có nghĩa là bạn không thể giả định rằng memset(&ptr, 0, sizeof(ptr)); sẽ tạo ra một con trỏ NULL! Bạn không thể khởi tạo các cấu trúc của mình theo cách này và giả định các con trỏ thành viên là NULL! Và điều này áp dụng cho hầu hết các lập trình viên.

Vâng, một số máy lịch sử đã sử dụng các con trỏ NULL khác zero.

LLM giỏi hơn chúng ta

Hãy chỉ một LLM vào BẤT KỲ mã C nào, yêu cầu nó tìm UB, và nó sẽ tìm thấy. Và ngày nay, nó sẽ đúng hầu như mọi lúc.

Tôi cảm thấy hơi tồi tệ sau khi nó tìm thấy chính xác các lỗi trong mã của tôi, vì vậy tôi nghĩ rằng tôi sẽ chỉ nó vào OpenBSD - một dự án trưởng thành và được viết một cách cầu toàn. Tôi chỉ chọn công cụ đầu tiên tôi nghĩ ra, find, và nó phun ra một loạt.

Tôi đã gửi cho dự án một bản vá cho một lỗi ghi vượt quá giới hạn (và cũng cho một lỗi logic không phải là UB). Tôi không gửi cho họ các bản vá cho phần còn lại của UB, một phần vì dự án OpenBSD trong quá khứ không quá hoan nghênh các báo cáo lỗi, cảm giác của tôi là "có lẽ điều này ổn trong thực tế", và rằng nếu OpenBSD muốn loại bỏ UB khỏi cơ sở mã của họ, thì đó là một dự án lớn nên được thực hiện theo cách tốt hơn là tôi chỉ làm người trung gian giữa LLM và họ cho một bản vá ở đây và ở đó.

Vậy bây giờ chúng ta làm gì?

Chúng ta không thể chỉ vứt bỏ các cơ sở mã C/C++ của mình. Nhưng việc để chúng vốn dĩ bị hỏng cũng không phải là một lựa chọn.

Chúng ta cần một cách nào đó để sửa chữa UB ở quy mô lớn, mà không cần cam kết tạo ra "rác AI" hay làm quá tải các người xem xét con người.

Đây cũng không phải là một ý kiến mới, cũng không phải một sự tiết lộ vĩ đại.

Nhưng đúng là, viết C/C++ vào năm 2026 mà không có một LLM giám sát bạn về UB có lẽ nên được xem là một vi phạm đạo đức nghề nghiệp, và đơn giản là vô trách nhiệm. Nếu những người OpenBSD không thể tìm thấy những vấn đề này trong hơn 30 năm, thì chúng ta còn cơ hội nào?

Có thể nó không mở rộng quy mô cho các cơ sở mã lớn, nhưng cho các dự án cá nhân của mình, tôi đã yêu cầu LLM tìm UB, nếu cần thì giải thích nó, và sửa chữa nó. Và sau đó nhìn chằm chằm vào đầu ra cho đến khi tôi có thể xác nhận vấn đề và bản sửa lỗi.

Một vấn đề với điều này là để xác nhận các phát hiện, bạn sẽ cần một chuyên gia con người. Nhưng nói chung các chuyên gia con người đang bận làm những việc khác. Đây là công việc dọn dẹp, nhưng quá tinh tế để lại cho các lập trình viên cấp thấp, những người người ta thường giao việc dọn dẹp này.

Chia sẻ:FacebookX
Nội dung tổng hợp bằng AI, mang tính tham khảo. Xem bài gốc ↗