Bỏ qua điều hướng, tới nội dung chính
Học C
Bài 33.126 phút đọc

Bốn mức hành vi trong chuẩn C

Sau bài này bạn sẽ làm được

  • Kể tên bốn mức và nêu khác biệt cốt lõi giữa chúng
  • Cho hai ví dụ cho mỗi mức
  • Giải thích vì sao chuẩn cố tình để lại hành vi không xác định
  • Tra được một hành vi cụ thể thuộc mức nào

Ba từ nghe rất giống nhau và bị dùng lẫn lộn khắp nơi: undefined, unspecified, implementation-defined. Ba thứ này khác nhau rất xa, và nhầm chúng là nguồn gốc của phần lớn những lỗi C khó nhất.

#Bốn mức trong chuẩn

Càng xuống dưới, chuẩn càng buông tay. Mức cuối là mức không còn bảo đảm nào.
MứcChuẩn nói gìTrình biên dịch phải làm gìBạn phải làm gì
Xác địnhNói rõ kết quả duy nhấtCho ra đúng kết quả đóKhông phải lo
Implementation-definedLiệt kê các lựa chọn hợp lệChọn một và ghi vào tài liệuĐọc tài liệu, hoặc viết mã không phụ thuộc
UnspecifiedLiệt kê các lựa chọn hợp lệChọn một, không phải khai, không phải nhất quánViết mã không phụ thuộc
UndefinedKhông nói gì cảLàm gì cũng hợp lệTuyệt đối tránh

#Undefined: không một yêu cầu nào

Hành vi không xác định (undefined behavior)
Chuẩn C không đặt bất kỳ yêu cầu nào lên chương trình. Không phải "kết quả không đoán trước", mà là không còn khái niệm kết quả nữa. Trình biên dịch được phép sinh ra bất cứ thứ gì, kể cả một chương trình không làm gì đúng ở mọi chỗ khác.
Bốn ví dụ, mỗi cái sập theo một kiểu khác
int a[5];
a[10] = 1;               /* 1. ngoai bien: co the khong sap gi ca */

int *p = NULL;
*p = 10;                 /* 2. NULL: gan nhu chac chan sap ngay */

int x = INT_MAX;
x = x + 1;               /* 3. tran so co dau: khong sap, nhung
                               trinh bien dich duoc phep gia dinh
                               chuyen nay khong xay ra */

char *s = "abc";
s[0] = 'A';              /* 4. ghi vao chuoi hang: sap vi trang nho
                               chi doc */
terminal
# Ví dụ 4, chạy thật trên MinGW
gcc -std=c11 -O2 -Wall -Wextra -o lit lit.c && ./lit
truoc: abc
Segmentation fault
echo $?
139

#Unspecified: chọn gì cũng được

Hành vi không nói rõ (unspecified behavior)
Chuẩn liệt kê hai hoặc nhiều khả năng hợp lệ và không nói phải chọn cái nào. Trình biên dịch không phải ghi vào tài liệu và không phải nhất quán: hai lời gọi giống hệt nhau trong cùng một chương trình vẫn có thể chọn khác nhau.
seq.c
static int lan = 0;
static int g(void) { printf("  g chay thu %d\n", ++lan); return 1; }
static int h(void) { printf("  h chay thu %d\n", ++lan); return 2; }
static int f(int a, int b) { return a * 10 + b; }

int main(void) {
    printf("f(g(), h()):\n");
    printf("  ket qua %d\n", f(g(), h()));
    return 0;
}
terminal
gcc -std=c11 -O2 -Wall -Wextra -o seq seq.c && ./seq
f(g(), h()):
  h chay thu 1
  g chay thu 2
  ket qua 12
Ví dụ unspecifiedCác khả năngCách viết an toàn
f(g(), h())g trước h, hoặc h trước gGọi ra hai biến tạm trên hai dòng riêng
a() + b()a trước b, hoặc b trước aNhư trên
Giá trị của con trỏ sau khi freeBất kỳ, và cả việc đọc nó cũng là UBGán NULL ngay sau free
Thứ tự các byte đệm trong structBất kỳKhông so sánh struct bằng memcmp
Địa chỉ hai đối tượng riêng biệtBất kỳ thứ tự nàoChỉ dùng == và != giữa chúng

#Implementation-defined: chọn và phải khai

Hành vi do cài đặt quyết định (implementation-defined)
Cũng là nhiều khả năng hợp lệ, nhưng trình biên dịch phải chọn một cách nhất quán và phải ghi lựa chọn đó vào tài liệu. Bạn tra được, và tra xong thì đoán trước được.
terminal
# Chạy trên MinGW-W64, x86-64, Windows
gcc -std=c11 -O2 -o impl impl.c && ./impl
sizeof(char)      = 1
sizeof(short)     = 2
sizeof(int)       = 4
sizeof(long)      = 4
sizeof(long long) = 8
sizeof(void *)    = 8
CHAR_BIT          = 8
CHAR_MIN          = -128  -> char CO dau
char c = 200      -> -56
-7 / 2            = -3
-7 % 2            = -1
-8 >> 1           = -4  (dich so hoc)
(int)3.9          = 3
(int)-3.9         = -3

Năm thứ hay làm hỏng mã khi chuyển máy

ThứTrên máy nàyCó thể khác ở đâuCách viết an toàn
sizeof(long)48 trên Linux và macOS 64 bitDùng int32_t, int64_t, uintptr_t
char có dấu hay khôngCó dấu, CHAR_MIN = -128Không dấu trên ARM và PowerPCViết signed char hoặc unsigned char khi cần chắc
Thứ tự byte trong số nguyênNhỏ trước (little endian)Lớn trước trên một số vi điều khiển và trên mạngDùng htonl và ntohl, hoặc tự đọc từng byte
Dịch phải số âmSố học, giữ dấuChuẩn cho phép dịch logicChuyển sang không dấu trước khi dịch
Trường bit xếp theo chiều nàoTừ bit thấp lênNgược lại trên một số trình biên dịchDùng mặt nạ và dịch bit, xem Bài 31.4

#Vì sao chuẩn cố tình để lại UB

Tự làm thử

  1. Phân loại hai mươi đoạn mã thành hợp lệ, UB, unspecified, implementation-defined.
  2. Chạy chương trình ghi ngoài biên một phần tử và xác nhận nó không sập, không cảnh báo.
  3. Thêm dần biến cục bộ vào chương trình đó cho tới khi nó bắt đầu ghi đè lên một biến khác.
  4. Viết chương trình in sizeof của mọi kiểu cơ bản và chạy nó trên hai hệ điều hành.
  5. Chứng minh memcmp trên struct có đệm cho kết quả khác nhau khi biến chưa khởi tạo hết.
  6. Tìm phụ lục J.3 của chuẩn C11 và tra ba mục bạn chưa biết.

Trình chấm điểm tự động sẽ được bổ sung ở giai đoạn sau. Hiện tại bạn tự chạy thử trên máy.

Tóm tắt

  • Bốn mức: xác định, implementation-defined, unspecified, undefined. Chỉ mức cuối làm mất mọi bảo đảm.
  • Implementation-defined thì trình biên dịch phải khai vào tài liệu, unspecified thì không.
  • UB không đồng nghĩa với sập. Ghi ngoài biên một phần tử chạy êm ru, không cảnh báo, mã thoát 0, và vẫn là UB.
  • sizeof(long) là 4 trên Windows và 8 trên Linux, nên ép con trỏ vào long là mã hỏng khi chuyển máy.
  • Chuẩn để lại UB để đổi lấy tốc độ và tính khả chuyển, và trình biên dịch ngày nay khai thác điều đó sâu hơn nhiều so với ba mươi năm trước.