1 điểm bởi GN⁺ 17 giờ trước | 1 bình luận | Chia sẻ qua WhatsApp
  • Mã chỉ tuân theo tiêu chuẩn ISO C là rất hiếm; trong thực tế, các codebase C thường phụ thuộc vào những phần mở rộng phi tiêu chuẩn để bổ sung tính năng và lách các khoảng trống khác nhau giữa trình biên dịch và thư viện
  • Một trình biên dịch C hữu dụng phải xử lý được cả các system header như <stdio.h>, nhưng glibc tạo ra rào cản với các phần mở rộng GNU và những giả định như __attribute__((packed)), #include_next
  • Logic byteswapping của SDL có thể chọn inline assembly nếu có macro ISA, nên ngay cả các trình biên dịch không phải GCC·clang cũng có thể bị yêu cầu hỗ trợ các phần mở rộng kiểu GCC
  • Cách OpenBSD và Gnulib xử lý extern inline khiến tính tương thích của ngữ nghĩa inline trở nên phức tạp do khác biệt giữa C99 và GCC, các nhánh theo nền tảng và điều kiện _FORTIFY_SOURCE
  • Các trình biên dịch C nhỏ phải chọn giữa patch upstream, patch downstream, giành được các guard riêng, hoặc giả lập tương thích GCC; việc mở rộng các macro kiểm tra tính năng có vẻ là hướng đi tốt hơn

Rào cản đầu tiên do header của glibc tạo ra

  • Để trở thành một trình biên dịch C hữu dụng, nó phải có thể tiền xử lý và phân tích cú pháp các header của thư viện C hệ thống; nếu không xử lý được <stdio.h> thì ngay cả hello world cũng khó mà biên dịch qua
  • Trong môi trường GNU/Linux, rào cản đó dẫn thẳng tới glibc
  • glibc xác định các phần mở rộng được hỗ trợ bằng cách kiểm tra các macro được trình biên dịch định nghĩa sẵn trong sys/cdefs.h, tệp được hầu như mọi header libc gián tiếp include
  • Với các phần mở rộng không được hỗ trợ, nó xử lý bằng cách loại bỏ các định nghĩa liên quan, nhưng ngay cả logic tương thích này trên thực tế cũng có thể bị lỗi
  • struct epoll_event__attribute__((packed))

    • struct epoll_event trong sys/epoll.h của Linux là một packed struct dùng GNU __attribute__((packed))
    • Thuộc tính này làm thay đổi layout của struct trên 64-bit, nên nếu bỏ qua thì ABI sẽ bị hỏng
    • Chỉ triển khai __attribute__((packed)) trong trình biên dịch thôi vẫn chưa đủ
    • Trong sys/cdefs.h có đoạn mã định nghĩa __attribute__(xyz) thành macro rỗng nếu trình biên dịch không phải GCC, clang hoặc tcc
    • Kết quả là các trình biên dịch khác, dù có hỗ trợ thuộc tính packed, vẫn có thể bị header glibc loại bỏ thuộc tính đó
    • Cũng có thể phản biện rằng header epoll là dành riêng cho Linux nên khó áp nguyên xi tiêu chí tính di động theo chuẩn C
  • limits.h#include_next

    • Một số header C như stddef.h, stdint.h, limits.h, float.h là cần thiết cả với các triển khai freestanding nên trình biên dịch phải tự cung cấp
    • POSIX yêu cầu limits.h phải định nghĩa cả các hằng số dành riêng cho POSIX ngoài các hằng số chuẩn C, nên cần có limits.h theo nền tảng đặt chồng lên limits.h của trình biên dịch
    • <limits.h> của glibc sẽ tự định nghĩa trực tiếp các giá trị limits.h ANSI nếu không phải GNU C, còn trong môi trường GCC thì nó dùng #include_next <limits.h> để lấy header của trình biên dịch
    • Cấu trúc này giả định rằng limits.h builtin dành riêng cho GCC sẽ định nghĩa một số macro nhất định, đồng thời phụ thuộc vào phần mở rộng #include_next
    • clang cũng phải lách cấu trúc này

Phát hiện tính năng của SDL và vấn đề inline assembly

  • Các hàm byteswapping trong SDL_endian.h dùng compiler builtin hoặc inline assembly khi có thể, và đến bước cuối mới thay bằng triển khai phép toán bit thông thường
  • Logic phát hiện hoạt động theo thứ tự đại khái như sau
    • Nếu là GCC hoặc clang và có __has_builtin(__builtin_bswapX) thì dùng builtin
    • Nếu là MSVC 8.0 trở lên thì dùng intrinsic #pragma của MSVC
    • Nếu các macro theo ISA như __x86_64__ được định nghĩa thì dùng inline assembly
    • Nếu không thì dùng triển khai phép toán bit thông thường
  • Nếu một trình biên dịch không phải GCC hay clang định nghĩa các predefined macro theo ISA vì lý do hợp lý, thứ tự này sẽ gây vấn đề
  • Ngay cả khi trình biên dịch đó có cung cấp builtin bswap và toán tử đặc biệt __has_builtin, logic vẫn có thể cố dùng inline assembly kiểu GCC
  • Kết quả là cấu trúc này ngầm kỳ vọng cả trình biên dịch không xác định cũng hỗ trợ inline assembly theo phong cách GCC

OpenBSD libc và sự rối rắm của extern inline

  • Một số header của OpenBSD chứa các định nghĩa hàm inline để trình biên dịch tùy chọn sử dụng khi tối ưu hóa
  • Các hàm này được định nghĩa bằng macro __only_inline, và nếu trình biên dịch thực sự không inline chúng thì phải thay bằng symbol bên ngoài
  • Tức là cần các hàm inline có extern linkage
  • Khác biệt ngữ nghĩa inline giữa C99 và GCC

    • inline được quy định trong C99, nhưng hành vi chuẩn lại xung đột với hành vi GCC phi tiêu chuẩn từ trước C99
    • Với định nghĩa inline trong header, phải dùng extern inline cùng thân hàm, và trong trường hợp đó nó sẽ không phát sinh hàm exported thực sự
    • Trong translation unit, để export định nghĩa hàm thì phải khai báo chỉ với inline
    • Ý nghĩa của inline cũng khác nhau giữa C++ và C
    • Sự khác biệt này được trình bày chi tiết trong bài viết của Youtao Guo
  • __only_inline của OpenBSD

    • OpenBSD phụ thuộc vào GCC inline semantics
    • Để che đi khác biệt giữa các phiên bản GCC, macro __only_inline trong sys/cdefs.h dùng __attribute__ tường minh trên GCC mới để chỉ định ngữ nghĩa inline gnu89 cũ
    • Với các trình biên dịch không phải GNU, __only_inline được định nghĩa thành linkage static
    • Kết quả là hàm có thể bị khai báo và định nghĩa với linkage xung đột, dẫn tới hỏng biên dịch
  • Cách lách bằng _ANSI_LIBRARY

    • OpenBSD tôn trọng macro _ANSI_LIBRARY
    • Nếu định nghĩa macro này, nó sẽ bỏ qua hoàn toàn việc dùng các định nghĩa __only_inline dễ gây hỏng trong các header chuẩn như signal.h
    • Dù không có được phiên bản tối ưu, ít nhất việc build vẫn chạy được
  • Mã tương thích extern inline của Gnulib

    • Mã tương thích extern inline của Gnulib cũng xuất hiện khi build Guile và nano
    • extern-inline.m4 chứa các nhánh điều kiện phức tạp để xử lý những cách triển khai hỏng và kỳ quặc của góc cạnh này trong C
    • Các điều kiện phản ánh khác biệt giữa Apple, DragonFly, FreeBSD, GCC, clang, PCC, HP cc, PGI, SunPro C, _FORTIFY_SOURCE, __GNUC_STDC_INLINE__, __GNUC_GNU_INLINE__

Giả định về clang trong bionic của Android

  • bionic là libc của Android, và các header của nó giả định clang mạnh hơn cả GCC
  • Header của bionic dùng nhiều phần mở rộng dành riêng cho clang như _Nonnull, _Null_unspecified để phục vụ nullability checks
  • Những macro như vậy không khó để vô hiệu hóa bằng #define trên dòng lệnh
  • Vấn đề này lộ ra khi dùng Termux để biến điện thoại Android thành môi trường phát triển aarch64 native
  • _Null_unspecified còn được gọi là __BIONIC_COMPLICATED_NULLNESS, và định nghĩa liên quan nằm trong sys/cdefs.h của bionic

Những lựa chọn mà một trình biên dịch C nhỏ phải đối mặt

  • Mã chỉ tuân theo tiêu chuẩn ISO C là điều hiếm gặp trong thực tế, và nhiều codebase C phụ thuộc vào hành vi phi tiêu chuẩn cùng các phần mở rộng ngôn ngữ
  • Sự phụ thuộc này không chỉ đến từ việc thêm tính năng mà còn từ quá trình lách bug và khoảng trống khác nhau giữa từng trình biên dịch và thư viện
  • Các codebase muốn hỗ trợ nhiều môi trường thường dựa vào kiểm tra của preprocessor và các guard, nhưng cách này rất dễ gãy và khó xử lý
  • Khi viết một trình biên dịch C như antcc, các vấn đề tương thích như vậy liên tục lộ ra
  • Khi nhiều dự án mã nguồn mở phụ thuộc vào các phần mở rộng và hành vi phi tiêu chuẩn theo từng trình biên dịch ngay cả cho những việc không thật sự bắt buộc, gánh nặng thích ứng cho các trình biên dịch thay thế sẽ tăng mạnh
  • Đồng thời cũng khó có thể yêu cầu mọi lập trình viên phải kiểm thử mã C của họ trên nhiều trình biên dịch, bao gồm cả những trình biên dịch nhỏ và ít tên tuổi
  • Bản thân tính di động của C vốn đã đủ khó rồi
  • Từ góc nhìn của người viết trình biên dịch, có bốn lựa chọn khả dĩ
    • Cố gắng gửi patch lên upstream để sửa các điểm không tương thích
    • Trở nên đủ nổi tiếng để các nhà phát triển thêm kiểm tra #ifdef chuyên biệt và các bài test cơ bản
    • Xử lý ở downstream, rồi phát hành patch hoặc patch riêng
    • Giả làm một phiên bản GCC cụ thể và triển khai các phần mở rộng tương ứng
  • Patch upstream có vẻ như là một cuộc chiến khó thắng, còn patch downstream là cách dễ nhất
  • Nếu muốn hỗ trợ nhiều codebase cho người dùng và nhà phát triển với mức rối loạn tối thiểu, giả lập tương thích GCC là phương án thực tế nhưng chi phí triển khai rất lớn
  • clang định nghĩa __GNUC__=4, __GNUC_MINOR__=2, __GNUC_PATCHLEVEL__=1 để tuyên bố tương thích với GCC 4.2.1
  • Hiện nay clang gần như là một mục tiêu hỗ trợ riêng, nhưng đã từng cần tới nỗ lực lớn, bao gồm cả patch cho hai phía dự án, chỉ để làm cho Linux kernel biên dịch được bằng clang

Macro GCC và bài toán chạy theo

  • Cách giả làm GCC cũng có vấn đề riêng
  • Nhiều codebase chỉ kiểm tra #ifdef __GNUC__ mà không kiểm tra phiên bản, rồi dùng luôn các phần mở rộng GNU mới
  • Trong trường hợp đó, trình biên dịch thay thế sẽ phải liên tục chạy theo
  • Đây cũng là một trong các lý do clang hỗ trợ những phần mở rộng GNU mới hơn 4.2.1 nhưng vẫn không tăng giá trị của macro __GNUC__
  • Bối cảnh liên quan có trong thảo luận của LLVM về việc tăng minor version của __GNUC__ trong clang

Hướng đi tốt hơn và tình trạng hiện tại

  • Lý tưởng nhất là macro kiểm tra tính năng nên được dùng rộng rãi hơn thay vì guard theo từng trình biên dịch và kiểm tra phiên bản
  • Các macro kiểm tra tính năng hữu ích gồm __has_builtin, __has_feature, __has_attribute
  • Những macro chuẩn như __STDC_NO_VLA__ cũng có thể được dùng nhiều hơn
  • Trong thế giới *NIX hiện nay, dù tốt hay xấu, thế song mã gần như độc quyền của GCC/clang là trạng thái mặc định
  • Việc phát triển các trình biên dịch C nhỏ, độc lập vẫn đang tiếp diễn

1 bình luận

 
Ý kiến trên Lobste.rs
  • (tác giả trình biên dịch kefir) Vấn đề __attribute__ trong <sys/cdefs.h> theo kinh nghiệm của tôi thuộc hàng đau đầu nhất. Nó làm hỏng epoll, các cấu trúc packed thông dụng, constructor và khả năng hiển thị symbol, nên tôi đã phải kèm header monkeypatch này vào kefir
    Không lý tưởng, nhưng có lẽ là cách thực tế nhất, và trên thực tế đã cho phép loại bỏ phần lớn các bản vá tùy biến trong test suite bên ngoài
    Một kiểu thất bại khác là mã thay thế bị lỗi. Một số dự án cố phát hiện trình biên dịch để điều chỉnh hành vi, nhưng vì thiếu kiểm thử trên trình biên dịch thay thế nên mã fallback đầy lỗi hoặc không được bảo trì tử tế. Từ góc nhìn của người làm trình biên dịch, chuyện này còn khó chịu hơn nhiều so với việc thất bại ngay với thông báo “trình biên dịch không được hỗ trợ”. Ví dụ, bạn phải tự debug những lỗi biên dịch sai kỳ quặc như độ rộng typedef số nguyên không khớp giữa chương trình và thư viện biên dịch sẵn

    • Chuyện tương tự cũng xảy ra ở terminal. Nếu không đặt $TERM thành xterm-256color để giả làm xterm thì đủ thứ sẽ hỏng
      Tôi thật sự không biết phải giải quyết thế nào. Chẳng lẽ cuối cùng chỉ còn cách để dự án của chúng ta phổ biến và nổi tiếng đủ mức. Dễ quá nhỉ!
    • Cách dùng header monkeypatch có vẻ cũng là cách slimcc sử dụng, và trông như một thỏa hiệp khá ổn
      Tôi cũng có lẽ đã vài lần gặp phải những lỗi biên dịch sai kỳ quặc do fallback phát hiện trình biên dịch không được quản lý tử tế, và nó thực sự rất phiền
  • Tôi chủ yếu phát triển cproc trên linux-musl nên không biết glibc vô hiệu hóa __attribute__ trên các trình biên dịch khác, nhưng thực sự đây là tình huống khá tệ. Chú thích có nói rằng việc dùng attribute có thể bị bỏ qua mà vẫn ổn, nhưng không tính đến việc phần lớn mã ứng dụng gián tiếp include sys/cdefs.h và có thể dùng những attribute không thể bỏ qua
    Ngoài packed, alignedconstructor cũng được dùng rất phổ biến
    Tôi tự hỏi chuyện này có được báo trên issue tracker nào chưa. Có vẻ phần lớn việc dùng attribute trong cdefs.h đã được bảo vệ bằng __glibc_has_attribute, nên tôi cũng thắc mắc việc vô hiệu hóa __attribute__ trên diện rộng thực sự đạt được gì, và liệu có thể bỏ nó đi không
    Những tính năng mà header libc dùng nhưng trình biên dịch không có cách tốt để biểu thị hỗ trợ hay không cũng là vấn đề. Đây là các tính năng không lộ ra theo kiểu __has_attribute hay __has_builtin; ví dụ tôi nghĩ đến là nhãn __asm__. NetBSD dùng nó để đổi tên symbol, và nếu không có __GNUC__ hay __PCC__ thì sẽ #error. Tuy vậy, ngoài việc cứ để nó thử rồi thất bại nếu không hỗ trợ, tôi cũng không biết nên đề xuất gì hơn
    Tôi cũng đã gặp vấn đề liên quan đến __builtin_va_list. Có những libc khi không có __GNUC__ thì định nghĩa va_listvoid *, hoặc thậm chí có các định nghĩa xung đột. Chuyện này cũng không thể kiểm tra bằng __has_builtin. Có thể __has_builtin(__builtin_va_arg) là một phép kiểm đủ tốt, nhưng tôi không rõ làm sao khiến phía macOS sửa điều này

    • Tôi tìm nhanh việc dùng __attribute__ trong /usr/include/sys/usr/include/bits, và thấy có nhiều chỗ dùng không được bảo vệ. Chủ yếu là __format__, __aligned__, __noreturn__, nên các chỗ đó cũng cần được sửa
      Nhìn chung glibc có vẻ không ưu tiên khả năng tương thích với các trình biên dịch không phải GCC, nên tôi không chắc họ có chấp nhận những bản vá như vậy không. Đầu năm nay sau khi nâng cấp hệ thống, glibc đã thêm cách dùng __SIZE_TYPE__ không được bảo vệ trong header Linux, khiến trình biên dịch của tôi không thể biên dịch một số dự án. Tôi đã báo lỗi nhưng đến giờ vẫn chưa được sửa, và cuối cùng tôi phải thêm các macro định nghĩa sẵn kiểu __X_TYPE__ để khớp với GCC
      Với vấn đề nhãn __asm__, tôi cũng chưa nghĩ ra lời giải nào tốt. Tuy vậy, nếu việc đổi tên bằng asm thật sự cần thiết 100% cho hoạt động, thì có lẽ cứ để nó thử và thất bại sẽ tốt hơn là kiểm tra trình biên dịch
      __builtin_va_list thì khá nghiêm trọng. Tôi đã nghĩ __has_builtin(__builtin_va_list) sẽ hoạt động, nhưng hình như không phải vậy