- โค้ดที่ยึดตามมาตรฐาน ISO C อย่างเดียวพบได้น้อย และโค้ดเบส C ในโลกจริงมักพึ่งพาส่วนขยายที่ไม่เป็นมาตรฐาน เพื่อเพิ่มความสามารถและหลบช่องโหว่ของคอมไพเลอร์·ไลบรารีแต่ละตัว
- คอมไพเลอร์ C ที่ใช้งานได้จริงต้องจัดการได้ตั้งแต่ system header อย่าง
<stdio.h>แต่ glibc สร้างกำแพงด้วยส่วนขยายและสมมติฐานแบบ GNU เช่น__attribute__((packed)),#include_next - ลอจิก byteswapping ของ SDL สามารถเลือกใช้ inline assembly ได้ถ้ามีแมโคร ISA ทำให้คอมไพเลอร์ที่ไม่ใช่ GCC·clang ก็อาจถูกคาดหวังให้รองรับส่วนขยายสไตล์ GCC
- การจัดการ
extern inlineของ OpenBSD และ Gnulib ทำให้ความเข้ากันได้ของ semantic ของ inline ซับซ้อนขึ้น เพราะความต่างระหว่าง C99 กับ GCC, การแยกเงื่อนไขตามแพลตฟอร์ม, และเงื่อนไข_FORTIFY_SOURCE - คอมไพเลอร์ C ขนาดเล็กต้องเลือกระหว่างการส่งแพตช์ upstream, แพตช์ downstream, การทำ guard เฉพาะทาง, หรือ เลียนแบบความเข้ากันได้กับ GCC โดยการขยายการใช้ feature test macro ดูเป็นทิศทางที่ดีกว่า
กำแพงด่านแรกจาก header ของ glibc
- หากจะเป็นคอมไพเลอร์ C ที่ใช้งานได้จริง ก็ต้อง preprocess และ parse header ของ system C library ได้ และถ้าจัดการ
<stdio.h>ไม่ได้ ก็ยากแม้แต่จะให้ hello world ผ่าน - ในสภาพแวดล้อม GNU/Linux กำแพงนี้ก็คือ glibc
- glibc ใช้ sys/cdefs.h ซึ่งถูก include ทางอ้อมโดย header ของ libc เกือบทั้งหมด เพื่อตรวจแมโครที่คอมไพเลอร์ predefine ไว้ แล้วตัดสินว่าส่วนขยายใดรองรับ
- ส่วนขยายที่ไม่รองรับจะถูกจัดการด้วยการลบ definition ที่เกี่ยวข้องออก แต่ลอจิกความเข้ากันได้นี้เองก็อาจพังได้ในทางปฏิบัติ
-
struct epoll_eventและ__attribute__((packed))struct epoll_eventในsys/epoll.hของ Linux เป็น packed struct ที่ใช้ GNU__attribute__((packed))- แอททริบิวต์นี้เปลี่ยน layout ของ struct บน 64 บิต ดังนั้นถ้ามองข้ามไป ABI จะพัง
- ต่อให้คอมไพเลอร์ implement
__attribute__((packed))แล้วก็ยังไม่พอ - ใน
sys/cdefs.hมีโค้ดที่ถ้าไม่ใช่ GCC, clang, หรือ tcc จะ define__attribute__(xyz)เป็นแมโครว่าง - ผลคือคอมไพเลอร์อื่นแม้จะรองรับ packed attribute ก็อาจถูกลบแอททริบิวต์นี้ออกจาก header ของ glibc
- เพราะ header ของ
epollใช้เฉพาะ Linux จึงอาจมีข้อโต้แย้งว่าไม่ควรเอามาตรฐานการพกพาของ C มาตัดสินตรง ๆ
-
limits.hและ#include_next- header C บางตัวเช่น
stddef.h,stdint.h,limits.h,float.hจำเป็นแม้กับ implementation แบบ freestanding ดังนั้นคอมไพเลอร์ต้องเป็นผู้จัดหา - POSIX กำหนดให้
limits.hต้องนิยามค่าคงที่เฉพาะของ POSIX เพิ่มจากค่าคงที่มาตรฐาน C ด้วย จึงต้องมีlimits.hของแพลตฟอร์มซ้อนอยู่บนlimits.hของคอมไพเลอร์ <limits.h>ของ glibc จะ define ค่า ANSIlimits.hเองถ้าไม่ใช่ GNU C และในสภาพแวดล้อม GCC จะดึง header ของคอมไพเลอร์ด้วย#include_next <limits.h>- โครงสร้างนี้ตั้งอยู่บนสมมติฐานว่า builtin
limits.hเฉพาะของ GCC จะ define แมโครบ้างตัว และยังพึ่งพาส่วนขยาย#include_nextด้วย - clang เองก็ต้องแก้ทางโครงสร้างนี้
- header C บางตัวเช่น
การตรวจจับความสามารถของ SDL กับปัญหา inline assembly
- ฟังก์ชัน byteswapping ใน
SDL_endian.hจะใช้ compiler builtin หรือ inline assembly ถ้าเป็นไปได้ และสุดท้ายค่อย fallback ไปใช้ implementation แบบ bit operation ธรรมดา - ลอจิกการตรวจจับทำงานโดยคร่าว ๆ ตามลำดับนี้
- ถ้าเป็น GCC หรือ clang และมี
__has_builtin(__builtin_bswapX)ให้ใช้ builtin - ถ้าเป็น MSVC 8.0 ขึ้นไป ให้ใช้ MSVC intrinsic
#pragma - ถ้ามีการ define แมโครเฉพาะ ISA อย่าง
__x86_64__ให้ใช้ inline assembly - นอกนั้นจึงใช้ implementation แบบ bit operation ทั่วไป
- ถ้าเป็น GCC หรือ clang และมี
- ถ้าคอมไพเลอร์ที่ไม่ใช่ GCC หรือ clang มีเหตุผลสมควรที่จะ define predefined macro ตาม ISA ลำดับนี้ก็จะกลายเป็นปัญหา
- ต่อให้คอมไพเลอร์นั้นมี bswap builtin และ special operator
__has_builtinอยู่แล้ว ลอจิกนี้ก็ยังอาจพยายามใช้ inline assembly แบบ GCC - ผลก็คือโครงสร้างนี้กลายเป็นการคาดหวังว่า คอมไพเลอร์ที่ไม่รู้จัก ก็รองรับ inline assembly สไตล์ GCC เช่นกัน
ความสับสนของ OpenBSD libc กับ extern inline
- header บางตัวของ OpenBSD มีนิยามฟังก์ชัน inline ที่คอมไพเลอร์จะเลือกใช้ได้เมื่อมีการ optimize
- ฟังก์ชันเหล่านี้นิยามผ่านแมโคร
__only_inlineและถ้าคอมไพเลอร์ไม่ inline ให้จริง ก็ต้อง fallback ไปใช้ external symbol แทน - กล่าวคือจำเป็นต้องมี inline function ที่มี extern linkage
-
ความต่างของ semantic ของ inline ระหว่าง C99 กับ GCC
inlineถูกระบุไว้ใน C99 แต่พฤติกรรมตามมาตรฐานกลับขัดกับพฤติกรรมแบบ GCC ที่ไม่เป็นมาตรฐานก่อนยุค C99- นิยาม inline ใน header ต้องใช้
extern inlineพร้อมกับตัวฟังก์ชัน และในกรณีนี้จะไม่ emit exported function จริง - ใน translation unit ต้องประกาศด้วย
inlineอย่างเดียวเพื่อ export นิยามของฟังก์ชัน - ความหมายของ
inlineก็ยังต่างกันระหว่าง C++ กับ C ด้วย - ความต่างนี้อธิบายไว้อย่างละเอียดใน บทความของ Youtao Guo
-
__only_inlineของ OpenBSD- OpenBSD พึ่งพา GCC inline semantics
- เพื่อกลบความต่างของ GCC แต่ละเวอร์ชัน แมโคร
__only_inlineใน sys/cdefs.h จะระบุ gnu89 inline semantics แบบเก่าอย่างชัดเจนด้วย__attribute__บน GCC รุ่นใหม่ - บนคอมไพเลอร์ที่ไม่ใช่ GNU
__only_inlineจะถูก define ให้เป็น linkage แบบstatic - ผลคือฟังก์ชันอาจถูกประกาศและนิยามด้วย linkage ที่ขัดกันจนพังได้
-
ทางเลี่ยงด้วย
_ANSI_LIBRARY- OpenBSD ให้ความสำคัญกับแมโคร
_ANSI_LIBRARY - ถ้า define แมโครนี้ จะข้ามการใช้
__only_inlineที่ทำให้พังใน standard header อย่างsignal.hไปทั้งหมด - แม้จะไม่ได้เวอร์ชันที่ optimize แต่ก็อย่างน้อยยัง build ได้
- OpenBSD ให้ความสำคัญกับแมโคร
-
โค้ดความเข้ากันได้ของ
extern inlineใน Gnulib- โค้ดความเข้ากันได้ของ
extern inlineใน Gnulib ยังโผล่มาตอน build Guile และ nano ด้วย - extern-inline.m4 มีเงื่อนไขแยกซับซ้อนเพื่อรองรับ implementation ที่พังและประหลาดของ corner case ใน C นี้
- เงื่อนไขเหล่านี้สะท้อนความต่างของสภาพแวดล้อมอย่าง Apple, DragonFly, FreeBSD, GCC, clang, PCC, HP cc, PGI, SunPro C,
_FORTIFY_SOURCE,__GNUC_STDC_INLINE__,__GNUC_GNU_INLINE__
- โค้ดความเข้ากันได้ของ
สมมติฐานเรื่อง clang ใน Android bionic
- bionic คือ libc ของ Android และ header ของมันตั้งสมมติฐานเรื่อง clang หนักยิ่งกว่า GCC
- header ของ bionic ใช้ส่วนขยายเฉพาะของ clang อย่าง
_Nonnull,_Null_unspecifiedจำนวนมากเพื่อ ตรวจสอบ nullability - แมโครเหล่านี้ไม่ได้ยากที่จะปิดทิ้งด้วยการ
#defineจาก command line - ปัญหานี้โผล่ชัดเมื่อใช้มือถือ Android เป็นสภาพแวดล้อมพัฒนา aarch64 แบบ native ผ่าน Termux
_Null_unspecifiedยังถูกเรียกว่า__BIONIC_COMPLICATED_NULLNESSด้วย และนิยามที่เกี่ยวข้องอยู่ใน sys/cdefs.h ของ bionic
ทางเลือกที่คอมไพเลอร์ C ขนาดเล็กต้องเจอ
- โค้ดที่ยึดตามมาตรฐาน ISO C อย่างเดียวนั้นพบได้น้อยในโลกจริง และโค้ดเบส C จำนวนมากพึ่งพาพฤติกรรมที่ไม่เป็นมาตรฐานกับส่วนขยายของภาษา
- การพึ่งพาเหล่านี้ไม่ได้เกิดจากการเพิ่มความสามารถเท่านั้น แต่ยังเกิดจากการพยายามหลบ bug และช่องว่างที่ต่างกันไปของคอมไพเลอร์กับไลบรารีแต่ละชุด
- โค้ดเบสที่ต้องการรองรับหลายสภาพแวดล้อมมักพึ่งพาการตรวจของ preprocessor และ guard ต่าง ๆ แต่วิธีนี้พังได้ง่ายและดูแลยาก
- เมื่อสร้างคอมไพเลอร์ C อย่าง antcc ปัญหาความเข้ากันได้แบบนี้จะโผล่ซ้ำแล้วซ้ำอีก
- หากหลายโปรเจ็กต์โอเพนซอร์สพึ่งพาส่วนขยายและพฤติกรรมที่ไม่เป็นมาตรฐานของคอมไพเลอร์ แม้ในเรื่องที่ไม่จำเป็น ภาระในการรองรับของคอมไพเลอร์ทางเลือกก็จะสูงขึ้น
- ขณะเดียวกันก็ยากจะเรียกร้องให้นักพัฒนาทุกคนต้องทดสอบโค้ด C บนคอมไพเลอร์หลายตัว รวมถึงตัวเล็ก ๆ ที่ไม่ค่อยมีคนรู้จัก
- ความพกพาของ C นั้นยากมากอยู่แล้วในตัวมันเอง
- จากมุมมองของผู้เขียนคอมไพเลอร์ ทางเลือกที่เป็นไปได้มีสี่ข้อ
- การส่งแพตช์ upstream ดูเหมือนเป็นศึกที่ชนะได้ยาก และแพตช์ downstream คือวิธีที่ง่ายที่สุด
- หากต้องการรองรับหลายโค้ดเบสโดยให้ผู้ใช้และนักพัฒนาสับสนน้อยที่สุด การ เลียนแบบความเข้ากันได้กับ GCC เป็นทางเลือกที่สมจริง แต่ก็มีภาระในการ implement สูง
- clang define
__GNUC__=4,__GNUC_MINOR__=2,__GNUC_PATCHLEVEL__=1เพื่ออ้างความเข้ากันได้กับ GCC 4.2.1 - ทุกวันนี้ clang แทบเป็นเป้าหมายที่ต้องรองรับแยกต่างหากไปแล้ว แต่กว่าจะทำให้ Linux kernel คอมไพล์ด้วย clang ได้ ก็ต้องอาศัยความพยายามขนาดใหญ่และต้องแพตช์ทั้งสองโปรเจ็กต์
ปัญหาของแมโคร GCC และการไล่ตามให้ทัน
- วิธีแกล้งเป็น GCC เองก็มีปัญหา
- โค้ดเบสจำนวนมากตรวจแค่
#ifdef __GNUC__และอาจใช้ส่วนขยายของ GCC รุ่นใหม่โดยไม่เช็กเวอร์ชัน - ในกรณีนี้คอมไพเลอร์ทางเลือกก็ต้องคอย ไล่ตามให้ทัน อยู่เรื่อย ๆ
- นี่เป็นหนึ่งในเหตุผลที่ clang แม้จะรองรับส่วนขยาย GNU ใหม่กว่าที่ 4.2.1 มี แต่ก็ยังไม่เพิ่มค่าของแมโคร
__GNUC__ - พื้นหลังของเรื่องนี้อยู่ใน การอภิปรายของ LLVM เรื่องการเพิ่ม minor version ของ
__GNUC__ใน clang
ทิศทางที่ดีกว่าและสภาพปัจจุบัน
- ในอุดมคติแล้ว แทนที่จะใช้ guard เฉพาะคอมไพเลอร์และการตรวจเวอร์ชัน ก็ควรใช้ feature test macro ให้แพร่หลายกว่านี้
- feature test macro ที่มีประโยชน์ ได้แก่
__has_builtin,__has_feature,__has_attribute - แนวทางแบบแมโครมาตรฐานอย่าง
__STDC_NO_VLA__ก็ควรถูกใช้งานให้มากขึ้นเช่นกัน - ในโลก *NIX ปัจจุบัน ไม่ว่าชอบหรือไม่ ระบอบกึ่งสองขั้วของ GCC/clang คือสภาพพื้นฐาน
- การพัฒนาคอมไพเลอร์ C ขนาดเล็กที่เป็นอิสระก็ยังดำเนินต่อไป
1 ความคิดเห็น
ความคิดเห็นจาก Lobste.rs
(ผู้เขียนคอมไพเลอร์ kefir) จากประสบการณ์ ปัญหา
__attribute__ใน<sys/cdefs.h>เป็นหนึ่งในเรื่องที่ปวดหัวที่สุด มันทำให้ epoll, โครงสร้าง packed ทั่วไป, constructor และการมองเห็นสัญลักษณ์พัง จนต้องใส่ เฮดเดอร์ monkey patch นี้ มาพร้อมกับ kefirมันไม่ใช่วิธีที่ ideal แต่คงเป็นทางที่ใช้งานได้จริงที่สุด และในทางปฏิบัติก็ช่วยให้ลบแพตช์คัสตอมส่วนใหญ่ใน external test suite ออกได้
ปัญหาอีกรูปแบบหนึ่งคือ โค้ดทางเลือกที่มีบั๊ก บางโปรเจ็กต์พยายามตรวจจับคอมไพเลอร์แล้วปรับพฤติกรรมให้เข้ากัน แต่เพราะทดสอบกับคอมไพเลอร์ทางเลือกไม่พอ โค้ด fallback จึงเต็มไปด้วยบั๊กหรือไม่ได้รับการดูแลที่ดี จากมุมมองของผู้เขียนคอมไพเลอร์ มันน่าหงุดหงิดกว่าการ fail ทันทีว่าเป็น “คอมไพเลอร์ที่ไม่รองรับ” มาก เพราะต้องมานั่งดีบัก miscompile แปลก ๆ เอง เช่น ความกว้างของ integer typedef ระหว่างโปรแกรมกับไลบรารีที่คอมไพล์ไว้ล่วงหน้าไม่ตรงกัน
$TERMเป็นxterm-256colorเพื่อแกล้งทำเป็น xterm ทุกอย่างก็พังไปหมดไม่รู้จริง ๆ ว่าควรแก้อย่างไร สุดท้ายคงต้องให้โปรเจ็กต์ของเราแพร่หลายและดังพอเองมั้ง ง่ายเลย!
ผมก็เหมือนจะเคยเจอ miscompile แปลก ๆ ที่เกิดจาก fallback สำหรับการตรวจจับคอมไพเลอร์ไม่ได้ถูกดูแลดีพออยู่หลายครั้ง และมันน่ารำคาญมาก
ผมพัฒนา cproc บน linux-musl เป็นหลัก เลยไม่รู้มาก่อนว่า glibc ปิดการใช้งาน
__attribute__บนคอมไพเลอร์อื่น ๆ ซึ่งจริง ๆ แล้วเป็นสถานการณ์ที่แย่มาก ในคอมเมนต์บอกว่าการใช้ attribute ถูกละเลยได้ไม่มีปัญหา แต่ไม่ได้คำนึงว่ามีโค้ดแอปพลิเคชันจำนวนมากที่ includesys/cdefs.hแบบอ้อม ๆ และอาจใช้ attribute ที่ห้ามละเลยนอกจาก
packedแล้วalignedกับconstructorก็ถูกใช้บ่อยเหมือนกันสงสัยว่ามีการรายงานเรื่องนี้ไว้ใน issue tracker ที่ไหนหรือยัง ดูเหมือนการใช้ attribute ส่วนใหญ่ใน
cdefs.hจะถูกครอบด้วย__glibc_has_attributeอยู่แล้ว เลยสงสัยว่าการปิด__attribute__แบบเหมารวมจริง ๆ แล้วได้อะไร และพอจะเอาออกได้ไหมอีกปัญหาคือฟีเจอร์บางอย่างที่เฮดเดอร์ของ libc ใช้ แต่คอมไพเลอร์ไม่มีวิธีบอกได้ดีว่ารองรับหรือไม่ รองรับแบบที่เปิดเผยผ่าน
__has_attributeหรือ__has_builtinไม่ได้ ตัวอย่างที่นึกออกคือ เลเบล__asm__NetBSD ใช้มันในการเปลี่ยนชื่อสัญลักษณ์ และถ้าไม่มี__GNUC__หรือ__PCC__ก็จะ#errorแต่อีกด้านหนึ่งก็ไม่รู้ว่าจะเสนออะไรได้นอกจากปล่อยให้ลองใช้ไปเลยแล้วค่อย fail ถ้าไม่รองรับผมก็เคยเจอปัญหาเกี่ยวกับ
__builtin_va_listด้วย บางครั้ง libc จะ defineva_listเป็นvoid *ถ้าไม่มี__GNUC__หรือถึงขั้นมีนิยามที่ขัดกัน ซึ่งอันนี้ก็ทดสอบด้วย__has_builtinไม่ได้เหมือนกัน__has_builtin(__builtin_va_arg)อาจเป็นการทดสอบที่ดีพอ แต่ก็ไม่รู้ว่าจะทำให้ macOS แก้เรื่องนี้ได้อย่างไร__attribute__แบบเร็ว ๆ ใน/usr/include/sysกับ/usr/include/bitsแล้วพบว่ามีหลายจุดที่ไม่ได้ป้องกันไว้ ส่วนใหญ่เป็น__format__,__aligned__,__noreturn__ดังนั้นพวกนี้ก็ควรถูกแก้ด้วยโดยรวมแล้ว glibc ดูเหมือนจะไม่ได้ให้ความสำคัญกับ ความเข้ากันได้กับคอมไพเลอร์ที่ไม่ใช่ GCC มากนัก เลยไม่แน่ใจว่าจะยอมรับแพตช์แบบนั้นหรือเปล่า ต้นปีนี้หลังอัปเกรดระบบ glibc เพิ่มการใช้
__SIZE_TYPE__แบบไม่มีการป้องกันในเฮดเดอร์ Linux ทำให้คอมไพเลอร์ของผมคอมไพล์บางโปรเจ็กต์ไม่ได้ ผมรายงานไปแล้ว แต่ยังไม่ถูกแก้ สุดท้ายเลยต้องเพิ่มแมโคร predefined สไตล์__X_TYPE__เพื่อให้เข้ากับ GCCสำหรับปัญหาเลเบล
__asm__ผมนึกวิธีแก้ที่ดีไม่ค่อยออก แต่ถ้าการเปลี่ยนชื่อด้วย asm จำเป็นต่อการทำงาน 100% ตั้งแต่แรก บางทีการปล่อยให้ลองใช้แล้ว fail ไปเลยอาจดีกว่าการเช็กคอมไพเลอร์__builtin_va_listนี่ค่อนข้างร้ายแรง ผมเคยคาดว่า__has_builtin(__builtin_va_list)น่าจะใช้ได้ แต่ดูเหมือนจริง ๆ แล้วไม่เป็นแบบนั้น