C của bạn không phải là C tiêu chuẩn: Những điểm không tương thích giữa lý thuyết và thực tế
Bài viết điểm lại một danh sách (chưa đầy đủ) những khác biệt giữa C theo tiêu chuẩn ISO và C trong thực tế khi lập trình viên dùng gcc/clang kèm glibc hoặc musl. Từ từ khóa asm, macro FLT_ROUNDS, các pragma chuẩn cho đến qualifier trong kiểu trả về của hàm và định nghĩa va_start trên musl — tất cả đều cho thấy các trình biên dịch phổ biến vẫn chưa tuân thủ đầy đủ tiêu chuẩn.
C của bạn không phải là C tiêu chuẩn
Có một khoảng cách khá lớn giữa C như được viết trong tiêu chuẩn và C như nó tồn tại trong thực tế đối với những người dùng gcc/clang kèm glibc hoặc musl. Bài viết dưới đây tổng hợp một danh sách — chưa đầy đủ — những điểm không tương thích đáng chú ý giữa hai thế giới này.
"Ủy ban đã tạo ra một ngôn ngữ không thực tế mà không ai có thể hoặc sẽ thực sự sử dụng." — Dennis Ritchie (ngoài ngữ cảnh)
Dù câu nói trên của Dennis Ritchie vốn nói về ngôn ngữ khác, nó lại khá đúng với tình trạng hiện nay của C: tiêu chuẩn và trình biên dịch thực tế không phải lúc nào cũng khớp nhau.
Từ khóa asm: tiện lợi hay bất tuân thủ?
Theo mặc định, gcc và clang đều nhận diện asm như một từ khóa mở rộng. Điều này không hoàn toàn tuân thủ tiêu chuẩn, bởi trong C tiêu chuẩn, asm là một định danh hợp lệ và không bị đặt trước. Hệ quả là code viết đúng theo tiêu chuẩn vẫn có thể không biên dịch được với gcc/clang ở cấu hình mặc định.
Tất nhiên, đây chính là lý do tồn tại tùy chọn -std, nhưng có hai điểm đáng lưu ý:
- Các trình biên dịch phổ biến không tuân thủ ngay từ cấu hình mặc định.
- Trong khi đó, C++ đã biến
asmthành từ khóa dành riêng — không rõ vì sao C lại không làm điều tương tự.
FLT_ROUNDS: macro lẽ ra phải động
Tiêu chuẩn quy định macro FLT_ROUNDS trong float.h như sau:
Chế độ làm tròn cho phép cộng số thực dấu phẩy động được đặc trưng bởi giá trị do implementation định nghĩa của
FLT_ROUNDS. Việc đánh giáFLT_ROUNDSphải phản ánh chính xác mọi thay đổi chế độ làm tròn tại thời điểm chạy thông qua hàmfesetround.
Nói cách khác, tiêu chuẩn yêu cầu giá trị này thay đổi động theo môi trường thực thi. Thế nhưng gcc lại luôn định nghĩa FLT_ROUNDS bằng 1 (làm tròn về gần nhất), bất kể chế độ làm tròn thực tế:
/* Cộng làm tròn về 0: zero, 1: nearest, 2: +inf, 3: -inf, -1: unknown. */
/* ??? Đáng lẽ phải thay đổi theo các lời gọi fesetround. */
#undef FLT_ROUNDS
#define FLT_ROUNDS 1
Clang có cung cấp builtin __builtin_flt_rounds(), còn gcc thì không — vì lý do nào đó không rõ.
Pragma chuẩn: hơn 27 năm vẫn bị bỏ quên
Tiêu chuẩn C quy định năm pragma chuẩn để điều khiển hành vi số học dấu phẩy động. Ba pragma đầu được thêm từ C99, hai pragma sau từ C23:
// thêm từ C99:
// switch có thể là ON, OFF hoặc DEFAULT
#pragma STDC FP_CONTRACT ON
#pragma STDC FENV_ACCESS ON
#pragma STDC CX_LIMITED_RANGE ON
// thêm từ C23:
// direction có thể là FE_DYNAMIC, FE_DOWNWARD, FE_TONEAREST,
// FE_TONEARESTFROMZERO, FE_TOWARDZERO, FE_UPWARD hoặc giá trị khác
#pragma STDC FENV_ROUND FE_DYNAMIC
#pragma STDC FENV_DEC_ROUND FE_DYNAMIC
Đáng chú ý là hơn 27 năm sau khi C99 được chuẩn hóa, gcc vẫn không hỗ trợ bất kỳ pragma chuẩn nào — chúng chỉ là no-op trong gcc.
Một phần chức năng có thể được thay thế bằng cờ dòng lệnh, nhưng các cờ này áp dụng cho toàn bộ đơn vị dịch, trong khi pragma có thể áp dụng cho từng hàm và từng khối riêng lẻ. Clang hỗ trợ phần lớn pragma của C99, nhưng vẫn chưa hỗ trợ pragma C23.
Qualifier trong kiểu trả về của hàm
Tiêu chuẩn quy định rằng các qualifier trong kiểu trả về của hàm phải bị loại bỏ. Cụ thể, nếu trong khai báo T D1, D1 có dạng hàm, thì kiểu được chỉ định là "hàm trả về phiên bản không có qualifier, không atomic của T".
Thế nhưng clang lại coi chúng là có ý nghĩa:
$ cat t.c
int f();
const int f();
$ clang t.c
t.c:2:11: error: conflicting types for 'f'
2 | const int f();
| ^
t.c:1:5: note: previous declaration is here
1 | int f();
| ^
1 error generated.
Hai khai báo này lẽ ra phải hoàn toàn giống nhau, nhưng clang lại cho rằng chúng xung đột.
Định nghĩa va_start lỗi thời trên musl
Vì lý do nào đó, musl cung cấp phiên bản riêng của một số header mà trình biên dịch vốn đã hỗ trợ sẵn. Một trong số đó là stdarg.h — header bắt buộc phải có trong cả môi trường freestanding, nên gần như chắc chắn trình biên dịch đã cung cấp sẵn. Nhưng vì musl cũng cung cấp, trình biên dịch sẽ dùng header của musl thay vì bản builtin.
Vấn đề nằm ở chỗ header của musl kém hơn hẳn bản builtin. Đây là cách musl định nghĩa va_start:
#define va_start(v,l) __builtin_va_start(v,l)
Định nghĩa này không chính xác, vì C23 cho phép bỏ qua tham số thứ hai. Định nghĩa đúng phải là:
#if __STDC_VERSION__ >= 202311L
#define va_start(...) __builtin_c23_va_start(__VA_ARGS__)
#else
#define va_start(v,l) __builtin_va_start(v,l)
#endif
Kết quả là khi biên dịch cho musl, không thể dùng va_start một tham số của C23, kể cả khi trình biên dịch có hỗ trợ.
Lời kết
Danh sách trên chỉ là phần nổi của tảng băng. Còn rất nhiều khác biệt khác giữa C tiêu chuẩn và C thực tế — từ cách glibc và musl xử lý lỗi toán học (math_errhandling), đến việc kiểm tra lỗi chuyển đổi chuỗi sang số thực, hay cách gcc và clang không lưu thông tin liên kết ngôn ngữ trong kiểu hàm của C++.
Đối với lập trình viên Việt Nam làm việc với hệ thống nhúng, driver hay các dự án C/C++ quy mô lớn, việc hiểu rõ những khác biệt này là rất quan trọng — bởi code "đúng chuẩn" chưa chắc đã biên dịch được, và code "chạy được" chưa chắc đã đúng chuẩn.


