1 điểm bởi GN⁺ 4 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Hành vi không xác định (UB) không phải là kiểu tối ưu hóa ác ý của trình biên dịch, mà là quy tắc cho phép không cần xử lý các nhánh thực thi bất khả thi khi giả định mã là hợp lệ
  • Trong mã C/C++ không tầm thường, UB không chỉ ẩn ở double-free hay truy cập vượt biên mà còn phổ biến ở những điểm tinh vi như căn chỉnh, ép kiểu, khởi tạo và sai khác kiểu
  • Việc truy cập qua int* hoặc std::atomic<int>* không được căn chỉnh có thể cho kết quả khác nhau tùy nền tảng như SIGBUS, kernel sửa lỗi, hoặc trông có vẻ chạy bình thường, nhưng theo tiêu chuẩn thì đã là UB
  • Những đoạn mã phổ biến như truyền signed char vào isxdigit(), đổi float sang int, hay dùng sai NULL với đối số biến thiên cũng rất dễ vượt ra ngoài tiêu chuẩn
  • Không thể vứt bỏ các codebase hiện có, nhưng cần sửa ở quy mô lớn bằng cách kết hợp phát hiện UB dựa trên LLM với kiểm chứng của chuyên gia, vì đây là vấn đề quá tinh vi để giao cho junior

Hành vi không xác định trong C/C++ không phải là vấn đề tối ưu hóa

  • Hành vi không xác định (UB) không có nghĩa trình biên dịch “lợi dụng” sai sót của lập trình viên, mà là nó được phép giả định chương trình hợp lệ theo tiêu chuẩn
  • Với con người, ý đồ có thể rất rõ ràng, nhưng ở cấp độ biên dịch hoặc giữa các mô-đun thì việc biểu đạt ý đồ đó có thể khó khăn
  • Trình biên dịch không có nghĩa vụ sinh mã để xử lý những trường hợp đặc biệt “không thể xảy ra”, và kết quả chạy thực tế, kể cả qua phần cứng, có thể khác với ý định
  • Tắt tối ưu hóa cũng không làm UB trở nên an toàn, và cũng không có gì bảo đảm hành vi đó sẽ giữ nguyên trên trình biên dịch hay kiến trúc hiện tại hoặc tương lai

UB không chỉ có trong mã dị thường

  • double-free, use-after-free, truy cập ngoài biên đối tượng, hay truy cập bộ nhớ chưa khởi tạo là những UB đã quá quen thuộc, nhưng vẫn lặp lại khắp ngành
  • Còn có rất nhiều UB tinh vi và phản trực giác hơn, khiến những đoạn mã C/C++ trông hoàn toàn bình thường cũng dễ dàng vượt khỏi tiêu chuẩn
  • Trong tiêu chuẩn C23, từ “undefined” xuất hiện 283 lần, và nếu tính cả những trường hợp không được đặc tả nên trở thành không xác định thì phạm vi còn rộng hơn nữa
  • Trong mọi code C/C++ không tầm thường, UB hiện diện ở khắp nơi, nên khó có thể quy hoàn toàn cho sự bất cẩn của từng cá nhân lập trình viên

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

  • Hàm giải tham chiếu int* như dưới đây sẽ là UB nếu con trỏ không được căn chỉnh đúng cách
    int foo(const int* p) {
       return *p;
    }
    
  • Căn chỉnh (alignment) thường có thể được hiểu là địa chỉ là bội số của sizeof(int), nhưng yêu cầu thực tế còn tùy nền tảng và cách cài đặt
  • Trên Linux Alpha, trong một số trường hợp kernel có thể bắt trap và mô phỏng truy cập bằng phần mềm, nhưng ở những trường hợp khác chương trình có thể chết với SIGBUS
  • Trên SPARC thì có SIGBUS, còn trên x86/amd64 thì thường chạy có vẻ ổn hoặc thậm chí giống như một lần đọc nguyên tử
  • Trên ARM, RISC-V, hay các kiến trúc tương lai, không thể khái quát kết quả; thậm chí kiến trúc tương lai có thể có thanh ghi đặc biệt không dùng các bit thấp của int*
  • Nếu trình biên dịch dùng lệnh load khác đi, truy cập trước đây còn được kernel sửa có thể sẽ không còn được sửa nữa
  • Trình biên dịch không có nghĩa vụ tạo assembly vẫn chạy với con trỏ lệch căn chỉnh, vì bản thân truy cập đó đã là UB

Kiểu nguyên tử cũng là UB nếu sai căn chỉnh

  • Ngay cả khi gọi store() hoặc load() trên std::atomic<int>* như dưới đây, nếu đối tượng không được căn chỉnh đúng thì hành vi vẫn là UB
    void set_it(std::atomic<int>* p) {
            p->store(123);
    }
    int get_it(std::atomic<int>* p) {
            return p->load();
    }
    
  • Từ góc nhìn tiêu chuẩn, câu hỏi “phép toán này có còn nguyên tử trên đối tượng không được căn chỉnh không” thực ra không còn hợp lệ
  • Trên phần cứng thật, tính nguyên tử có thể là một vấn đề, nhưng theo tiêu chuẩn thì trước đó nó đã là UB rồi
  • Nếu đối tượng mà bạn nghĩ đang được đọc nguyên tử lại nằm vắt qua một trang bộ nhớ, tình hình còn phức tạp hơn, nhưng kết luận vẫn không phải là “ổn”, mà là UB

Chỉ riêng việc tạo con trỏ cũng có thể đã là vấn đề

  • Với con trỏ lệch căn chỉnh, ngay cả trước khi giải tham chiếu, chỉ cần ép kiểu sang con trỏ của một kiểu nhất định cũng có thể đã thành vấn đề
    bool parse_packet(const uint8_t* bytes) {
            const int* magic_intp = (const int*)bytes;   // UB!
            int magic_raw = foo(magic_intp);  // Probably crashes on SPARC.
            int magic = ntohl(magic_raw); // this is fine, at least.
            […]
    }
    
  • Vấn đề ở đây không phải lời gọi foo(), mà là phép ép (const int*)bytes
  • Theo tiêu chuẩn, trình biên dịch hoàn toàn có thể gán ý nghĩa cho các bit thấp của int*, chẳng hạn bit dành cho garbage collection hay thẻ bảo mật

Vấn đề khi truyền char vào isxdigit()

  • Đoạn mã sau nhìn có vẻ đơn giản, nhưng trên kiến trúc mà char là signed, nếu giá trị đầu vào nằm ngoài khoảng 0–127 thì có thể trở thành UB
    bool bar(char ch) {
            return isxdigit(ch);
    }
    
  • isxdigit() là hàm kiểm tra xem ký tự có phải chữ số hệ 16 hay không, và cũng có thể nhận EOF
  • Theo C23 7.4p1, EOFint, và có thể suy ra rằng nó là một giá trị không biểu diễn được bởi unsigned char
  • isxdigit() nhận int chứ không nhận char; việc chuyển char sang int là hợp lệ, nhưng giá trị âm của signed char mới là vấn đề
  • Theo đoạn 20 của C23 6.2.5, việc char là signed hay không là do cách cài đặt quyết định
  • Với cách cài đặt isxdigit() như sau, chỉ số âm có thể khiến nó đọc vào vùng nhớ không biết trước
    int isxdigit(int c) {
            if (c == EOF) {
                    return false;
            }
            return some_array[c];
    }
    
  • Nếu vùng nhớ đó là vùng ánh xạ I/O, nó không chỉ có thể trả về giá trị ngẫu nhiên hay làm chương trình crash mà còn có thể kích hoạt hành vi phần cứng
  • Điều này có khả năng xảy ra trên hệ nhúng cao hơn ứng dụng desktop, nhưng vẫn có những trường hợp như network driver ở user space mà chỉ riêng user space không đủ để bảo vệ hoàn toàn

Vấn đề khi ép float sang int

  • Đoạn mã đổi số giây kiểu float sang mili giây kiểu int như dưới đây rất phổ biến, nhưng có chứa UB
    int milliseconds(float seconds) {
            int tmp = (int)(seconds * 1000.0); /* WRONG */
            return tmp + 1; /* WRONG separately (signed overflow is UB) */
    }
    
  • C23 6.3.1.4 quy định rằng khi chuyển một giá trị dấu phẩy động thực hữu hạn sang kiểu số nguyên, nếu phần nguyên không biểu diễn được trong kiểu số nguyên đích thì hành vi là không xác định
  • Với các giá trị không hữu hạn, do không được nói rõ nên cũng trở thành UB
  • Ngay cả việc so sánh float với INT_MAX cũng không hề đơn giản
    • Ép float sang int có thể chính là chỗ gây ra UB mà bạn muốn tránh
    • Ép INT_MAX sang float thì không biết có được biểu diễn chính xác hay không
    • Nếu INT_MAX bị làm tròn khi chuyển sang float và thành giá trị không biểu diễn được bằng int, phép so sánh có thể mất ý nghĩa đại diện
  • Để an toàn, cần kiểm tra isfinite(), so sánh với biên có chừa khoảng như INT_MIN + 1000, INT_MAX - 1000, rồi kiểm tra thêm sau khi chuyển trước khi cộng
    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;
    }
    
  • Chỉ muốn đổi float sang int thôi, nhưng để an toàn thì mã lại dài hơn rất nhiều

Đối tượng ở địa chỉ 0 và null pointer

  • Trong kernel hệ điều hành hoặc mã nhúng, đôi khi có nhu cầu đặt đối tượng ở địa chỉ 0
  • Có thể xem như không có cách thực tiễn nào để đặt một đối tượng thật sự ở địa chỉ 0 mà vẫn tuân thủ chuẩn C
  • Trong C 6.3.2.3, hằng số nguyên 0 có thể chuyển thành con trỏ và nullptr là “null pointer constant”; ở đây có thể gọi chung là NULL
  • C không quy định con trỏ NULL thực sự trỏ tới địa chỉ máy 0
  • Chuẩn C nói về cỗ máy trừu tượng của C chứ không phải phần cứng, và chỉ bảo đảm rằng NULL so sánh bằng với 0
  • Sự bằng nhau đó có thể là vì số nguyên 0 được chuyển thành giá trị NULL gốc của nền tảng, và giá trị đó thậm chí có thể là 0xffff
  • Giải tham chiếu null pointer là UB bất kể giá trị của nó là gì, và đây là ví dụ điển hình trong C 3.4.3
  • Vì vậy không thể mặc định memset(&ptr, 0, sizeof(ptr)); sẽ tạo ra một con trỏ NULL
  • Cách khởi tạo toàn bộ struct về 0 rồi giả định các con trỏ thành viên đều là NULL cũng là điều có thể gây vấn đề thật sự cho phần lớn lập trình viên
  • Trong lịch sử cũng từng có máy dùng NULL pointer khác 0

Vấn đề khi giả định có hàm ở địa chỉ 0

  • Trên máy hiện đại, kể cả khi NULL trỏ tới địa chỉ 0 và thực sự có đối tượng hoặc hàm ở đó, C 6.3.2.3 vẫn quy định NULL không bằng bất kỳ đối tượng hay hàm nào
  • Vì vậy đoạn mã sau là UB
    void (*func_ptr)() = NULL;
    func_ptr();
    
  • Từ góc nhìn của C, điều đó có nghĩa là “không có hàm ở đó”, và có thể không có cách nào để biểu đạt ý đồ khác bên trong trình biên dịch
  • Không thể đơn giản giả định nó sẽ phát ra lệnh call tới địa chỉ có mọi bit bằng 0
  • Trên x86 16-bit, “toàn số 0” còn chưa rõ là 0000:0000 hay CS:0000

Đối số biến thiên và sai khác kiểu

  • Đối số cuối của execl() phải là con trỏ, nên nếu truyền trực tiếp macro NULL hoặc số nguyên 0 thì có thể thành UB
    execl("/bin/sh", "sh", "-c", "date", NULL);  /* WRONG */
    execl("/bin/sh", "sh", "-c", "date", 0);     /* WRONG */
    
  • Dạng đúng là ép kiểu tường minh sang kiểu con trỏ
    execl("/bin/sh", "sh", "-c", "date", (char*)NULL);
    
  • Macro NULL có thể được hiểu là số nguyên 0, còn với đối số biến thiên thì thông tin kiểu cần thiết không được truyền đi
  • Với printf() cũng vậy, nếu format specifier không khớp với kiểu đối số thực tế thì đó là UB
    uint64_t blah = 123;
    printf("%ld\n", blah);  /* WRONG */
    
  • Để in uint64_t thì nên dùng PRIu64
    uint64_t blah = 123;
    printf("%"PRIu64"\n", blah);
    
  • Muốn in uid_t, có thể có cách ép sang uintmax_t rồi dùng PRIuMAX, nhưng thậm chí uid_t có phải unsigned hay không cũng chưa chắc chắn
  • Trường hợp xấu nhất, thay vì -1 bạn có thể nhận được một giá trị vô nghĩa

Chia cho 0 và vấn đề bảo mật

  • Việc chia cho 0 là UB thì ai cũng biết, nhưng nếu mẫu số đến từ đầu vào không đáng tin cậy thì nó trở thành vấn đề bảo mật
  • Điểm quan trọng là UB có thể xuất hiện ngay tại ranh giới kiểm tra đầu vào, chứ không chỉ là một lỗi runtime đơn thuần

Không phải UB, nhưng integer promotion cũng nguy hiểm

  • Các quy tắc integer promotion rất khó áp dụng khi chỉ đọc lướt mã, và có thể cho ra kết quả trái trực giác
  • Trong đoạn mã sau, overflowed sẽ là 0 chứ không phải 1
    unsigned char a = 0xff;
    unsigned char b = 1;
    unsigned char zero = 0;
    bool overflowed = (a + b) == zero;
    // overflowed is set to zero, not one.
    
  • Trong đoạn dưới đây, dù mọi biến có vẻ đều là unsigned, kết quả lại không phải 2147483648 (0x80000000) mà là 18446744071562067968 (ffffffff80000000)
    unsigned char a = 0x80;
    uint64_t b = a << 24;     // Bonus UB(?)
    
  • Dù không phải UB, các quy tắc số nguyên trong C/C++ vẫn rất thiếu trực quan và dễ tạo lỗi

Dùng LLM để phát hiện UB

  • Khi yêu cầu các LLM hiện đại tìm UB trong mã C bất kỳ, chúng gần như luôn phát hiện được vấn đề và phần lớn là đúng
  • Sau khi tìm UB trong mã cá nhân, cách làm tương tự cũng được áp dụng cho mã OpenBSD vốn trưởng thành và được viết rất nghiêm ngặt
  • Công cụ đầu tiên được đem ra kiểm tra là find, và đã phát hiện nhiều vấn đề
  • Đã có bản vá gửi cho OpenBSD về ghi ngoài phạm vi và cả lỗi logic không phải UB
  • Tuy nhiên, nhiều UB còn lại thì không được gửi bản vá
    • Từng có trải nghiệm rằng dự án OpenBSD trước đây không mấy cởi mở với bug report
    • Cũng có nhận định rằng thực tế có thể không sao
    • Nếu OpenBSD muốn loại bỏ UB khỏi codebase, sẽ cần một dự án lớn hơn nhiều thay vì chỉ chuyển từng bản vá riêng lẻ giữa LLM và dự án

Hướng đi thực tế khi đối mặt với codebase C/C++

  • Không thể vứt bỏ các codebase C/C++ hiện có, nhưng cũng không thể để chúng tồn tại trong trạng thái về bản chất là hỏng
  • Cần sửa UB ở quy mô lớn theo cách không commit những thay đổi kém chất lượng do AI tạo ra, đồng thời không làm reviewer con người bị quá tải
  • Đến năm 2026, viết C hay C++ mà không có sự giám sát UB từ LLM có thể bị xem như vi phạm SOX, hoặc ít nhất là thiếu trách nhiệm
  • Nếu ngay cả các lập trình viên OpenBSD cũng không tìm ra hết các vấn đề này trong hơn 30 năm, thì khả năng của các dự án khác còn thấp hơn
  • Với dự án cá nhân, có thể để LLM tìm UB, giải thích khi cần, sửa lỗi, rồi con người xác nhận lại kết quả
  • Tuy vậy, để kiểm chứng kết quả vẫn cần chuyên gia, mà chuyên gia thì thường đang bận việc khác
  • Công việc này trông giống dọn dẹp, nhưng lại quá tinh vi để giao cho các lập trình viên junior vốn trước nay thường được giao những việc như vậy

Tài liệu liên quan

1 bình luận

 
Ý kiến trên Hacker News
  • C có rất nhiều hành vi không xác định kỳ lạ và đáng ngạc nhiên, nhưng bài viết này không thực sự cho thấy điều đó rõ lắm, chỉ mới cào nhẹ trên bề mặt
    Một ví dụ còn lạ hơn là volatile int x = 5; printf("%d in hex is 0x%x.\n", x, x);. Nếu x chỉ là int thì không sao, nhưng nếu là volatile thì đây lại là hành vi không xác định. Theo chuẩn C, truy cập volatile tự nó đã là một tác dụng phụ chỉ vì đọc nó, các tác dụng phụ không có thứ tự trên cùng một đối tượng vô hướng là hành vi không xác định, và việc đánh giá các đối số hàm thì thứ tự giữa chúng không được xác định
    Thông thường data race nghĩa là nhiều luồng khác nhau cùng truy cập một đối tượng và ít nhất một trong số đó là ghi, nhưng trong C có thể xuất hiện tình huống giống data race ngay cả trong một luồng đơn mà không có ghi nào

    • Với tư cách tác giả, tôi đồng ý. Mục đích của bài này không phải là liệt kê tất cả 283 chỗ từ undefined xuất hiện trong tiêu chuẩn, hay mọi trường hợp không xác định do lược bỏ
      Ý chính là không thể tránh được. Tức là ít nhất từ khi C ra đời năm 1972 đến nay, con người chưa từng tránh được hoàn toàn chuyện đó
      Nếu suốt 54 năm mà vẫn chưa thành công thì “cố hơn đi” hay “đừng phạm sai lầm” không phải là lời giải. Một lỗ hổng có thể khai thác được mà Mythos tìm thấy trong OpenBSD được đánh giá khá tốt đối với các lập trình viên OpenBSD, nhưng chỉ cần chạy công cụ trên cả đoạn mã đơn giản nhất cũng lòi ra vô số hành vi không xác định
      Ví dụ, việc find đọc biến tự động chưa khởi tạo status sau waitpid(&status) trước khi kiểm tra lỗi của waitpid() cũng là hành vi không xác định, dù khó mà tưởng tượng ra kiến trúc hay compiler nào có thể khai thác được điều đó
      Như tôi đã viết trong bài, mục tiêu không phải là liệt kê mọi hành vi không xác định trên đời, mà là nói rằng mọi mã C/C++ không tầm thường đều có hành vi không xác định
    • volatile là một kiểu hack vào hệ thống kiểu. Đáng ra nên có một lời giải bài bản hơn, và các ngôn ngữ hiện đại không nên bắt chước theo kiểu “C đã làm thế nên chắc là ý hay”
      Các compiler C đời đầu luôn đẩy giá trị ra bộ nhớ, nên nếu bạn trỏ con trỏ vào phần cứng memory-mapped I/O thì mỗi lần thay đổi x, lệnh CPU sẽ thực hiện ghi bộ nhớ thật và mã driver sẽ hoạt động
      Nhưng khi tối ưu hóa xuất hiện, compiler chỉ thấy x liên tục bị sửa nên giữ nó trong thanh ghi, và driver bị hỏng. volatile trong C là một kiểu hack để nói với compiler rằng “đừng làm tối ưu hóa đó”, trong khi lời giải đúng là cung cấp intrinsic memory-mapped I/O ở mức thư viện, điều này hẳn sẽ là công việc lớn hơn nhiều
      Lý do cần intrinsic là vì nó có thể diễn đạt chính xác đâu là hành vi khả dĩ và đâu là không thể. Trên một số đích, ghi 1 byte, 2 byte và 4 byte là các thao tác khác nhau và phần cứng phân biệt chúng. Có thiết bị mong đợi một lần ghi RGBA 4 byte, còn nếu phát ra bốn lần ghi 1 byte thì có thể gây rối hoặc không chạy. Một số đích còn hỗ trợ ghi ở mức bit. Chỉ với volatile thì không có cách nào biết chuyện gì sẽ xảy ra và ý nghĩa của nó là gì
    • Cần phân biệt hành vi không xác định với race. Sự phân biệt này thường bị bỏ sót trong các cuộc thảo luận về hành vi không xác định
      Sau khi biên dịch một chương trình C rồi dịch ngược thành assembly, bạn sẽ có một chương trình assembly không có hành vi không xác định, vì assembly không có khái niệm này
      Hành vi không xác định là thuộc tính của chương trình nguồn chứ không phải của file thực thi. Nó có nghĩa là đặc tả ngôn ngữ mà mã nguồn được viết bằng đó không gán ngữ nghĩa cho chương trình ấy. Ngược lại, file thực thi là thứ có ngữ nghĩa do đặc tả máy gán cho
      Race là thuộc tính của hành vi chương trình. Vì vậy có thể nói chương trình C có hành vi không xác định, nhưng không thể nói file thực thi thực sự có race. Dĩ nhiên compiler có thể biên dịch tùy ý một chương trình có hành vi không xác định và vì thế có thể đưa race vào, nhưng nếu nó biên dịch theo cách không tạo luồng mới thì sẽ không có race
    • Ý nghĩa của volatile chính là giá trị đó có thể bị thay đổi bởi thứ gì khác. Nếu là biến toàn cục thì thứ đó có thể là luồng khác, interrupt hay signal handler. Nếu là con trỏ đọc một địa chỉ cụ thể thì đó có thể là thanh ghi thiết bị phần cứng đang thay đổi giá trị
      Bản thân khái niệm biến volatile không phải vấn đề. Nếu một ngôn ngữ muốn hỗ trợ interrupt routine và memory-mapped I/O thì cần có cách báo cho compiler biết rằng đọc cùng một thanh ghi phần cứng hai lần không giống với đọc cùng một vị trí bộ nhớ hai lần
      Vấn đề thực sự là tương tác giữa các tính năng và ràng buộc của ngôn ngữ chưa được làm rõ đầy đủ. Nếu đã khai báo “giá trị này có thể thay đổi bất cứ lúc nào” thì vì chính lý do đó mà xem một số cách dùng của nó là hành vi không xác định là điều ngớ ngẩn. Đáng ra phải có ngoại lệ cho biến volatile trong định nghĩa về “tác dụng phụ không có thứ tự”
    • Điểm cốt lõi của bài là bạn thậm chí không cần viết mã kỳ quặc mới đụng phải hành vi không xác định
      Nhiều người nhầm rằng C và C++ “rất linh hoạt vì cho bạn làm mọi thứ mình muốn”. Thực tế thì gần như mọi kỹ thuật có vẻ mạnh mẽ và ngầu đều là bãi mìn hành vi không xác định
  • Hành vi không xác định của con trỏ không được căn chỉnh còn tệ hơn. Con trỏ không căn chỉnh là hành vi không xác định không chỉ khi truy cập mà ngay ở bản thân con trỏ
    Vì thế việc ép kiểu ngầm từ void* v sang int* i, chẳng hạn i=v trong C hoặc f(v) khi f nhận int*, cũng là hành vi không xác định nếu con trỏ kết quả không thỏa điều kiện căn chỉnh của int
    Điều quan trọng là đây là vấn đề ở cấp độ C. Nếu chương trình C có hành vi không xác định thì về mặt hình thức nó không còn là chương trình hợp lệ, mà là chương trình sai. Đây không phải vấn đề phần cứng, cũng không liên quan đến crash hay lỗi
    Việc ép kiểu từ void* sang int* ở cấp mã máy thường chẳng làm gì cả, và kiểu chỉ tồn tại trong C nên phần cứng cũng không crash vì phép ép kiểu đó. Bạn có thể nghĩ rằng nếu là giá trị số nguyên trong thanh ghi thì vẫn ổn, nhưng mấu chốt không phải là ở phần cứng con trỏ có thực sự là số nguyên hay không, mà là ngay khoảnh khắc ép kiểu thành con trỏ không căn chỉnh thì chương trình C đã sai theo định nghĩa

    • Với tư cách tác giả, đúng vậy. Đây là điều tôi bàn đến trong mục “Actually, it was UB even before that” của bài
      Tôi cũng muốn truyền đạt rằng hành vi không xác định không nằm ở phần cứng và không liên quan đến crash hay lỗi. Đồng thời tôi muốn đưa ra ví dụ cho những người nói “nhưng nhìn kìa, nó vẫn chạy mà”, trong khi thực tế không phải vậy
    • Điều đó là bình thường và dễ đoán. Lập trình viên giỏi đều biết ép kiểu con trỏ rõ ràng là vùng nguy hiểm
    • Có thể chỉ ra chỗ nào trong tiêu chuẩn nói rằng bản thân con trỏ không căn chỉnh đã là hành vi không xác định không?
    • Nếu tạo struct bằng #pragma pack(push, 1) thì điều đó có nghĩa là không thể dùng con trỏ tới thành viên trừ khi thành viên đó vô tình được căn chỉnh đúng sao?
    • Khái niệm hành vi không xác định trong C ban đầu có ý nghĩa là cho compiler tự do ánh xạ mã xuống phần cứng, dù lệnh máy có hơi khác nhau giữa các kiến trúc. Cùng một chương trình C có thể biểu diễn những hành vi khác nhau tùy kiến trúc đang chạy nó
      Loại hành vi không xác định này là ổn, và hầu như chẳng ai coi việc lỗi xuất hiện do khác biệt phần cứng là vấn đề lớn
      Nhưng theo thời gian, cách diễn giải hung hãn đã biến C thành một ngôn ngữ kiểu design by contract ngầm, còn các ràng buộc thì bị ẩn đi. Điều này tạo ra vấn đề tương tự như lời gọi destructor ngầm trong RAII là thứ không nhìn thấy được
      Trong C, khi bạn dereference một con trỏ, compiler ngầm thêm ràng buộc không-null vào chữ ký hàm. Nếu bạn truyền con trỏ có thể null vào hàm mà không có kiểm tra hay assertion, thay vì báo lỗi rằng thiếu kiểm tra, compiler sẽ lặng lẽ lan truyền ràng buộc không-null đó theo con trỏ. Nếu nó chứng minh được ràng buộc ấy là sai, nó đánh dấu hàm là không thể tới được, và lời gọi đến hàm không thể tới được sẽ lại khiến hàm gọi nó cũng bị xem là không thể tới được
  • 5 giai đoạn học về hành vi không xác định trong C
    Chối bỏ: “Tôi biết tràn số có dấu trên máy tôi sẽ ra sao”
    Giận dữ: “Compiler rác rưởi gì thế! Sao không làm theo điều tôi bảo?”
    Mặc cả: “Tôi sẽ gửi đề xuất này lên wg14 để sửa C…”
    Trầm cảm: “Có gì trong mã C mà còn tin được không?”
    Chấp nhận: “Cứ đừng dùng hành vi không xác định nữa”

    • Giai đoạn “bắt compiler định nghĩa thứ vốn không được định nghĩa” thì nằm ở đâu?
      Truy cập không căn chỉnh thì cứ dùng packed struct. Compiler sẽ tạo ra mã đúng một cách thần kỳ. Thực ra compiler luôn biết cách làm đúng, chỉ là trước giờ không làm thôi
      Quy tắc strict aliasing thì cứ dùng chuyển kiểu bằng union. Các compiler quan trọng đều có tài liệu nói nó hoạt động dù tiêu chuẩn không nói thế. Hoặc cứ tắt bằng -fno-strict-aliasing. Bạn có thể diễn giải lại bộ nhớ theo ý mình, vẫn có cạnh sắc đấy nhưng ít ra không phải từ compiler
      Tràn số thì định nghĩa bằng -fwrapv. Thay +, -, * bằng __builtin_*_overflow thì còn được kiểm tra lỗi tường minh miễn phí. Giao diện hàm cũng hay mà mã sinh ra vẫn hiệu quả
      Chấp nhận thực sự gần hơn với ý “người bình thường chẳng quan tâm chuẩn C”. Chuẩn thì dở tệ, còn compiler mới là thứ quan trọng. Compiler có rất nhiều tính năng cực kỳ hữu ích giúp lách phần lớn các vấn đề này. Người ta không dùng chỉ vì muốn viết C “chuẩn” và “di động”, và thoát khỏi lối nghĩ đó mới là chấp nhận thật sự
      Theo logic này tôi đã làm một trình thông dịch Lisp trong môi trường freestanding C và còn qua được UBSan. Ban đầu tôi tưởng nó sẽ nổ tung, nhưng không, và nếu tôi làm được thì ai cũng làm được
    • Với tư cách tác giả, ý chính của bài là “cứ đừng dùng hành vi không xác định” là điều bất khả thi
      Chừng nào con người còn viết mã thì đó không thể là trạng thái cuối cùng. Không con người nào có thể tránh hoàn toàn hành vi không xác định trong C/C++
    • “Cứ đừng dùng hành vi không xác định” nghe cùng lắm cũng mới ở giai đoạn mặc cả thôi
    • Cứ làm việc trên thiết bị nhúng như tôi đi. Viết phần mềm cho một CPU cụ thể đúng là rất nhàn
    • Trong C, chấp nhận gần hơn với “tôi sẽ dùng hành vi không xác định, rồi một ngày nào đó chuyện tệ sẽ xảy ra”
  • Các ví dụ này gần với những trường hợp có thể trở thành hành vi không xác định tùy đầu vào hay ngữ cảnh hơn là hành vi không xác định thực sự
    Nếu hiểu rộng như vậy thì mọi lời gọi hàm cũng là hành vi không xác định vì có thể làm tràn không gian stack. Thực ra ở nghĩa nào đó ngôn ngữ nào cũng vậy
    Trong C đã có quá đủ các góc cạnh thô ráp đáng nói rồi, kiểu giật gân như thế này có thể làm người mới phân tâm và còn phản tác dụng

    • Ada 83 không xem tràn call stack là hành vi không xác định. Trong reference manual có định nghĩa ngoại lệ STORAGE_ERROR
      http://archive.adaic.com/standards/83lrm/html/lrm-11-01.html
      Trong đó ghi rõ ngoại lệ này cũng xảy ra khi “không đủ không gian lưu trữ trong khi thực thi lời gọi chương trình con”
    • Hoàn toàn không đúng
      Trước hết, hoàn toàn có thể định nghĩa chuyện gì xảy ra khi vượt quá không gian stack. Ngoài ra, không phải mọi chương trình đều cần stack kích thước tùy ý; có chương trình chỉ cần kích thước hằng số có thể tính trước. Cũng có hiện thực ngôn ngữ không dùng stack chút nào
      Ngôn ngữ cũng có thể cung cấp công cụ để kiểm tra lượng stack còn lại và đưa ra đảm bảo tương ứng. Hoặc cho phép cài một handler chạy khi stack cạn
    • Hành vi không xác định xảy ra theo đầu vào cũng có thể là đường khai thác
    • Các ví dụ này rõ ràng là hành vi không xác định. Hết chuyện
      Cách nghĩ đúng là một khi hành vi không xác định xảy ra thì bạn không còn ở dưới sự bảo vệ của tiêu chuẩn ngôn ngữ nữa. Nó có thể vẫn chạy tốt một lúc, thậm chí mãi mãi. Nhưng trên thực tế, bạn vô tình phụ thuộc vào sự thất thường của toolchain, việc thay hoặc nâng cấp compiler, kiến trúc, runtime, hay khác biệt phiên bản libc
      Cuối cùng bạn đang xây nền trên cát, và đó chính là sự nguy hiểm của hành vi không xác định
    • Bài này gần như đúng định nghĩa của FUD
  • Vấn đề của hành vi không xác định không phải là nó có thể làm crash trên một số kiến trúc nào đó
    Vấn đề thực sự là compiler kỳ vọng rằng loại mã đó không bao giờ xảy ra. Nếu bạn vẫn viết mã có hành vi không xác định, compiler, đặc biệt là optimizer, có thể dịch đường đi bình thường theo bất kỳ cách nào tiện cho nó. Cái “bất kỳ” đó đôi khi rất khó lường, như xóa hẳn những mảng mã lớn

    • Một ví dụ liên quan là điều kiện mọi hàm đều phải kết thúc hoặc tạo ra tác dụng phụ. Tôi chưa từng tự dính vụ này, nhưng hoàn toàn có thể tưởng tượng ra tình huống vô tình viết vòng lặp vô hạn hay đệ quy vô hạn rồi cả hàm bị xóa mất
      Nếu lại còn có tail recursion thì lỗi có thể không lộ ra ở bản debug vì chưa vào được vòng lặp vô hạn, nhưng khi tăng mức tối ưu hóa mới phát tác
    • Crash thực ra là một trong những dạng hành vi không xác định nhẹ nhàng nhất. Ít nhất nó còn dễ thấy
      Tệ hơn là chương trình lặng lẽ tiếp tục chạy với giá trị rác, format ổ cứng, hoặc trao chìa khóa vương quốc cho kẻ tấn công
    • Đúng, nhưng đó cũng là tính năng hữu ích nhất và là lý do tồn tại của hành vi không xác định
      Những người bảo cứ định nghĩa nó hoặc biến nó thành unspecified behavior đã bỏ qua điểm cốt lõi là compiler phải có khả năng loại bỏ các phần lớn của chương trình
      Nếu bạn viết mã mà với một đầu vào nào đó nó trở thành hành vi không xác định, tức là bạn đã có ý rằng với đầu vào đó chương trình không nên có bất kỳ hành vi nào. Bạn muốn compiler tối ưu hóa bỏ đường đi ấy, hoặc làm điều gì đó có ích cho hành vi ở các trường hợp đã được định nghĩa
      Việc chèn một chuỗi log chỉ có thể tới được thông qua hành vi không xác định, rồi thấy chuỗi đó không còn trong binary, quả thật khá thỏa mãn
    • Đoạn trong bài nói rằng đây không phải vấn đề tối ưu hóa làm tôi chú ý đặc biệt
      Trước đây tôi từng viết một pass phân tích với giả định nó chạy ở cuối pipeline biến đổi, và tính đúng đắn của nó phụ thuộc vào giả định đó. Vì nghĩ rằng sau đó không còn tối ưu hóa nào nữa nên tôi cho là an toàn, nhưng giờ thì không chắc nữa
    • Đó không phải lỗi mà là tính năng
  • Tôi đã dùng C 20 năm nhưng chưa bao giờ nghe nói về hành vi không xác định nhiều như 6 tháng gần đây trên Hacker News
    Trong trao đổi thực tế thì gần như chẳng ai nhắc đến. Cứ viết mã, không chạy thì debug rồi sửa hoặc tìm cách обход. Tôi không hiểu vì sao chủ đề hành vi không xác định trong C lại đều đặn lên trang nhất như vậy

    • Hacker News vẫn nghiêng về phía quan tâm đến ngôn ngữ lập trình hơn là lập trình thực tế. Có lẽ cũng do di sản Lisp của Y Combinator
      Luôn có một nhóm nhỏ dân khoa học máy tính nghĩ rằng việc phát triển hay sử dụng ngôn ngữ lập trình mới là thứ thú vị nhất thế giới, và một số người trong họ giữ suy nghĩ đó mãi
      Việc những người như thế quan tâm đến khía cạnh thiết kế ngôn ngữ là chuyện tự nhiên, và hành vi không xác định trong C nằm trong phạm vi đó. Dù vậy, phần lớn gốc rễ của nó là nhằm dung nạp các kiến trúc CPU cũ mà không mất hiệu năng, nên cũng khó gọi đó là một “lựa chọn thiết kế” theo cách thông thường, giống như bảo bánh xe có hình tròn là một lựa chọn thiết kế vậy
    • Ý gì vậy? 20 năm trước tôi cũng dùng C và C++, và ngay thời đó hành vi không xác định đã chiếm vị trí lớn trong trao đổi lẫn giáo trình rồi
      Có những “scandal” khá nổi tiếng khi compiler bắt đầu tận dụng hành vi không xác định một cách hung hăng hơn trong tối ưu hóa, quanh thời GCC 3.2, và vì thế nhiều người bám ở GCC 2.95 rất lâu. GCC 3.2 ra năm 2002
    • Máy tính ngày xưa thì ngầu, máy tính bây giờ thì nguy hiểm
      Công ty nào cũng cứ nhấn mạnh an toàn và mức độ lộ diện, tức là chuyện lên báo, nên câu chuyện phản đối “không an toàn” bị phóng đại quá mức
      Thế giới mới này giống như dân thành phố chưa từng thấy thiên nhiên hoang dã mà nhìn máy cắt cỏ rồi hoảng hốt. Lưỡi dao đang quay á? Không thể tin nổi!
    • Vì môi trường vận hành có thể là một kiến trúc hoàn toàn khác, nên những chi tiết này rất quan trọng
      Nếu mục tiêu thật sự là một hệ thống nhúng nhỏ trên tháp truyền thông ở nơi hẻo lánh thì “chạy trên máy tôi” chẳng có ích gì. Tất nhiên đa số không làm việc đó, và phần lớn lập trình viên ở đây có lẽ là web developer, nhưng ngay cả khi chưa từng đụng đến thì đây vẫn là chủ đề thú vị. Có khi vì thế lại càng thú vị hơn
    • Nói chính xác hơn thì người ta viết theo mục tiêu đích chứ không phải theo một đặc tả trong tưởng tượng. Đặc tả hữu ích để dự đoán đại khái mục tiêu đích sẽ làm gì, chứ không phải chuẩn mực tuyệt đối
      Compiler có thể có bug khiến thứ lẽ ra phải chạy theo đặc tả lại không chạy, có nhiều extension không có đối ứng trong tiêu chuẩn, và cũng có những hành vi tiêu chuẩn coi là không xác định nhưng từng hiện thực cụ thể vẫn gán cho nó một kết quả có ý nghĩa
  • Tôi nhìn chung đồng ý với phần mở đầu, nhưng ví dụ không hay và cả bài trông như một lớp vỏ để quảng bá LLM coding

    • Đúng vậy. Từng ví dụ một đều là những thứ tiêu chuẩn mà ai viết mã di động cũng tránh, hoặc là những thứ không cần thiết như truy cập đối tượng ở địa chỉ 0
      Nó tạo cảm giác như tác giả muốn viết bất cứ mã nào tùy thích rồi đòi nó chạy như nhau ở mọi môi trường. Nếu làm ngôn ngữ như thế thì sẽ mất đi lợi thế là có thể viết phù hợp với nền tảng khi cần
    • Không hay ở chỗ nào? Nếu đúng thì khá nghiêm trọng đấy
  • Mã C++ trong bài này có phần đã không còn mang tính thành ngữ từ hơn 10 năm trước, và ngày nay có thể bị xem là mùi mã
    Ngôn ngữ đã tiến hóa thành một thứ khá khác so với lúc mới ra đời. Chỉ cần thấy đầy rẫy raw pointer và truy cập con trỏ trực tiếp là đã rõ phải đọc bài với mức độ dè dặt nhất định
    Một vấn đề rõ ràng nữa là góc nhìn gộp C và C++ lại như thể gần như cùng một ngôn ngữ. Hiện nay hai ngôn ngữ này thực sự đã cách xa nhau khá nhiều

    • Tôi định chỉ ra rằng mã đó là C chứ không phải C++, nhưng kiểm tra lại mới thấy đúng là dùng std::atomic chứ không phải atomic_int
  • Có thể hiểu hành vi không xác định trong C như thế này không?
    Chương trình P có một tập đầu vào A không gây ra hành vi không xác định, và tập bù B thì có
    Một compiler đúng sẽ biên dịch P thành file thực thi P'. Với mọi đầu vào thuộc A, P' phải hành xử giống P
    Nhưng với bất kỳ đầu vào nào thuộc B thì hành vi của P' không bị ràng buộc bởi bất kỳ yêu cầu nào

    • Về trực giác thì đúng. Chương trình được biên dịch như thể đầu vào thuộc B sẽ không bao giờ được truyền vào, và điều đó có thể bao gồm cả việc xóa mã dùng để phát hiện đầu vào thuộc B
    • Tóm tắt rất tốt
  • Một ví dụ cụ thể về hành vi không xác định do con trỏ không căn chỉnh gây ra: https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...

    • Đây là trường hợp xảy ra ngay trên x86, nền tảng mà người ta thường mặc định là sẽ không có vấn đề gì đặc biệt