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
| Mức | Chuẩn nói gì | Trình biên dịch phải làm gì | Bạn phải làm gì |
|---|---|---|---|
| Xác định | Nói rõ kết quả duy nhất | Cho ra đúng kết quả đó | Không phải lo |
| Implementation-defined | Liệ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 |
| Unspecified | Liệ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án | Viết mã không phụ thuộc |
| Undefined | Khô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ụ unspecified | Các khả năng | Cách viết an toàn |
|---|---|---|
| f(g(), h()) | g trước h, hoặc h trước g | Gọ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 a | Như trên |
| Giá trị của con trỏ sau khi free | Bất kỳ, và cả việc đọc nó cũng là UB | Gán NULL ngay sau free |
| Thứ tự các byte đệm trong struct | Bất kỳ | Không so sánh struct bằng memcmp |
| Địa chỉ hai đối tượng riêng biệt | Bất kỳ thứ tự nào | Chỉ 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ày | Có thể khác ở đâu | Cách viết an toàn |
|---|---|---|---|
| sizeof(long) | 4 | 8 trên Linux và macOS 64 bit | Dùng int32_t, int64_t, uintptr_t |
| char có dấu hay không | Có dấu, CHAR_MIN = -128 | Không dấu trên ARM và PowerPC | Viết signed char hoặc unsigned char khi cần chắc |
| Thứ tự byte trong số nguyên | Nhỏ trước (little endian) | Lớn trước trên một số vi điều khiển và trên mạng | Dùng htonl và ntohl, hoặc tự đọc từng byte |
| Dịch phải số âm | Số học, giữ dấu | Chuẩn cho phép dịch logic | Chuyển sang không dấu trước khi dịch |
| Trường bit xếp theo chiều nào | Từ bit thấp lên | Ngược lại trên một số trình biên dịch | Dù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ử
- Phân loại hai mươi đoạn mã thành hợp lệ, UB, unspecified, implementation-defined.
- 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.
- 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.
- Viết chương trình in
sizeofcủa mọi kiểu cơ bản và chạy nó trên hai hệ điều hành. - Chứng minh
memcmptrên struct có đệm cho kết quả khác nhau khi biến chưa khởi tạo hết. - 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àolonglà 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.