1 คะแนน โดย GN⁺ 4 시간 전 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • พฤติกรรมที่ไม่ได้กำหนด (UB) ไม่ใช่การปรับแต่งประสิทธิภาพแบบมุ่งร้ายของคอมไพเลอร์ แต่เป็นกติกาที่บอกว่าเมื่อถือว่าโค้ดถูกต้องแล้ว ก็ไม่จำเป็นต้องรองรับเส้นทางการทำงานที่เป็นไปไม่ได้
  • ใน โค้ด C/C++ ที่ไม่ใช่เรื่องเล็กน้อย มักมี UB แฝงอยู่อย่างแพร่หลาย ไม่ใช่แค่ double-free หรือการเข้าถึงนอกขอบเขตเท่านั้น แต่ยังรวมถึงเรื่องละเอียดอย่าง alignment, casting, initialization และ type mismatch
  • การเข้าถึง int* หรือ std::atomic<int>* ที่ไม่ได้จัดแนว อาจให้ผลต่างกันไปตามแพลตฟอร์ม เช่น SIGBUS, เคอร์เนลช่วยแก้ให้, หรือดูเหมือนทำงานปกติ แต่ตามมาตรฐานแล้วถือเป็น UB ไปแล้ว
  • แม้แต่โค้ดที่พบบ่อยอย่างการส่ง signed char เข้า isxdigit(), การแปลง float เป็น int, หรือการใช้ NULL กับอาร์กิวเมนต์แบบแปรผันผิดวิธีก็หลุดออกนอกมาตรฐานได้ง่าย
  • เราอาจทิ้งโค้ดเบสเดิมไม่ได้ แต่ควรแก้ไขในวงกว้างด้วยการผสาน การตรวจจับ UB ด้วย LLM เข้ากับการตรวจสอบโดยผู้เชี่ยวชาญ เพราะประเด็นนี้ละเอียดเกินกว่าจะโยนให้จูเนียร์ทำ

พฤติกรรมที่ไม่ได้กำหนดใน C/C++ ไม่ใช่ปัญหาเรื่อง optimization

  • พฤติกรรมที่ไม่ได้กำหนด (UB) ไม่ได้หมายความว่าคอมไพเลอร์ “เอาความผิดพลาดของนักพัฒนามาหากิน” แต่หมายความว่าโปรแกรมสามารถถูกถือว่าใช้งานได้ถูกต้องตามมาตรฐาน
  • แม้มนุษย์จะมองว่าเจตนาของโค้ดชัดเจน แต่ในขั้นตอนของคอมไพเลอร์หรือระหว่างโมดูล การสื่อเจตนานั้นอาจทำได้ยาก
  • คอมไพเลอร์ไม่มีหน้าที่ต้องสร้างโค้ดมารองรับกรณีพิเศษที่ “ไม่มีทางเกิดขึ้นได้” และผลลัพธ์ที่ต่างจากความตั้งใจอาจเกิดขึ้นได้ตลอดเส้นทางการทำงานรวมถึงระดับฮาร์ดแวร์
  • ต่อให้ปิด optimization ก็ไม่ได้ทำให้ UB ปลอดภัยขึ้น และไม่มีอะไรรับประกันว่าพฤติกรรมเดิมจะคงอยู่ในคอมไพเลอร์หรือสถาปัตยกรรมปัจจุบันและอนาคต

UB ไม่ได้มีอยู่แค่ในโค้ดผิดปกติ

  • double-free, use-after-free, การเข้าถึงนอกขอบเขตของออบเจ็กต์, และการเข้าถึงหน่วยความจำที่ยังไม่ได้ initialize เป็น UB ที่รู้จักกันดี แต่ก็ยังเกิดซ้ำทั่วทั้งอุตสาหกรรม
  • ยังมี UB ที่ละเอียดและสวนทางสัญชาตญาณอีกมาก ทำให้โค้ด C/C++ ที่ดูธรรมดาหลุดออกนอกมาตรฐานได้ง่าย
  • ในมาตรฐาน C23 มีคำว่า “undefined” ปรากฏอยู่ 283 ครั้ง และถ้ารวมกรณีที่ไม่ได้ระบุไว้จนทำให้ไม่ถูกนิยามด้วย ขอบเขตก็ยิ่งกว้างขึ้น
  • ในโค้ด C/C++ ที่ไม่ใช่เรื่องเล็กน้อย แทบมี UB อยู่ทุกที่ และยากจะโยนความผิดให้ความสะเพร่าของโปรแกรมเมอร์รายบุคคลอย่างเดียว

การเข้าถึงออบเจ็กต์ที่ไม่ได้จัดแนว

  • ฟังก์ชันที่ dereference int* แบบนี้จะกลายเป็น UB ทันทีหากพอยน์เตอร์ไม่ได้จัดแนวอย่างถูกต้อง
    int foo(const int* p) {
       return *p;
    }
    
  • alignment โดยทั่วไปอาจหมายถึงแอดเดรสที่เป็นพหุคูณของ sizeof(int) แต่ข้อกำหนดจริงอาจต่างกันไปตามแพลตฟอร์มและ implementation
  • บน Linux Alpha ในบางกรณีเคอร์เนลอาจรับ trap แล้วจำลองการเข้าถึงที่ตั้งใจไว้ด้วยซอฟต์แวร์ แต่บางกรณีโปรแกรมอาจตายด้วย SIGBUS
  • บน SPARC จะเกิด SIGBUS ส่วนบน x86/amd64 มักดูเหมือนทำงานได้ไม่มีปัญหา หรืออาจดูเหมือนเป็นการอ่านแบบอะตอมมิก
  • บน ARM, RISC-V หรือสถาปัตยกรรมในอนาคต เราไม่สามารถเหมารวมผลลัพธ์ได้ และสถาปัตยกรรมอนาคตอาจมีรีจิสเตอร์พิเศษที่ไม่ใช้บิตล่างของ int* เลยก็ได้
  • ถ้าคอมไพเลอร์เลือกใช้คำสั่ง load คนละแบบ การเข้าถึงที่ก่อนหน้านี้เคอร์เนลเคยช่วยแก้ให้ อาจไม่ถูกแก้อีกต่อไป
  • คอมไพเลอร์ไม่มีหน้าที่ต้องสร้าง assembly ที่รองรับพอยน์เตอร์ที่ไม่ได้จัดแนว เพราะการเข้าถึงนั้นเป็น UB ตั้งแต่แรก

ชนิดข้อมูลแบบอะตอมมิกก็เป็น UB หาก alignment ผิด

  • ต่อให้เรียก store() หรือ load() กับ std::atomic<int>* แบบนี้ ถ้าออบเจ็กต์ไม่ได้จัดแนวอย่างถูกต้อง พฤติกรรมก็ยังเป็น UB
    void set_it(std::atomic<int>* p) {
            p->store(123);
    }
    int get_it(std::atomic<int>* p) {
            return p->load();
    }
    
  • คำถามว่า “โอเปอเรชันนี้ยังเป็นอะตอมมิกไหมถ้าออบเจ็กต์ไม่ได้จัดแนว” นั้นไม่成立ในมุมของมาตรฐาน
  • บนฮาร์ดแวร์จริง อาจมีประเด็นเรื่อง atomicity แต่ในแง่มาตรฐาน มันเป็น UB ไปก่อนหน้านั้นแล้ว
  • ถ้าออบเจ็กต์ที่คิดว่าอ่านแบบอะตอมมิกพาดข้าม หน้า ปัญหาจะยิ่งซับซ้อนขึ้น แต่ข้อสรุปก็ยังไม่ใช่ “ไม่เป็นไร” แต่คือ UB

แค่สร้างพอยน์เตอร์ขึ้นมาก็อาจมีปัญหา

  • พอยน์เตอร์ที่ไม่ได้จัดแนวอาจมีปัญหาได้แม้ยังไม่ dereference เพียงแค่ cast ไปเป็นพอยน์เตอร์ของชนิดใดชนิดหนึ่งก็อาจผิดแล้ว
    bool parse_packet(const uint8_t* bytes) {
            const int* magic_intp = (const int*)bytes;   // UB!
            int magic_raw = foo(magic_intp);  // Probably crashes on SPARC.
            int magic = ntohl(magic_raw); // this is fine, at least.
            […]
    }
    
  • ปัญหาในที่นี้ไม่ใช่การเรียก foo() แต่คือการ cast (const int*)bytes
  • ตามมาตรฐานแล้ว คอมไพเลอร์สามารถให้ความหมายกับบิตล่างของ int* เช่น ใช้กับ garbage collection หรือ security tag bits ได้

ปัญหาของการส่ง char เข้า isxdigit()

  • โค้ดนี้ดูเรียบง่าย แต่บนสถาปัตยกรรมที่ char เป็น signed หากค่าที่ป้อนอยู่นอกช่วง 0–127 ก็อาจกลายเป็น UB ได้
    bool bar(char ch) {
            return isxdigit(ch);
    }
    
  • isxdigit() เป็นฟังก์ชันที่ใช้ตรวจว่าตัวอักษรเป็นเลขฐานสิบหกหรือไม่ และรับ EOF เป็นอาร์กิวเมนต์ได้ด้วย
  • ตาม C23 7.4p1 เราอนุมานได้ว่า EOF เป็น int และเป็นค่าที่ไม่สามารถแทนได้ด้วย unsigned char
  • isxdigit() รับ int ไม่ใช่ char และแม้จะแปลงจาก char เป็น int ได้ แต่ค่าติดลบจาก signed char คือปัญหา
  • ตาม C23 6.2.5 ย่อหน้า 20 การที่ char จะเป็น signed หรือไม่ เป็นสิ่งที่ implementation กำหนด
  • isxdigit() ที่เขียนแบบนี้อาจไปอ่านหน่วยความจำที่ไม่รู้จักผ่านดัชนีติดลบ
    int isxdigit(int c) {
            if (c == EOF) {
                    return false;
            }
            return some_array[c];
    }
    
  • ถ้าหน่วยความจำนั้นเป็นพื้นที่ I/O mapping ก็อาจไม่ใช่แค่ได้ค่าสุ่มหรือแครช แต่กระตุ้นพฤติกรรมของฮาร์ดแวร์ได้ด้วย
  • สิ่งนี้มีโอกาสเกิดกับระบบ embedded มากกว่าแอปบนเดสก์ท็อป แต่ก็มีกรณีอย่างไดรเวอร์เครือข่ายใน user space ที่การป้องกันใน user space อย่างเดียวอาจไม่เพียงพอ

ปัญหาของการ cast จาก float เป็น int

  • โค้ดที่แปลงค่า float หน่วยวินาทีเป็น int หน่วยมิลลิวินาทีแบบนี้พบได้บ่อย แต่มี UB อยู่ด้วย
    int milliseconds(float seconds) {
            int tmp = (int)(seconds * 1000.0); /* WRONG */
            return tmp + 1; /* WRONG separately (signed overflow is UB) */
    }
    
  • C23 6.3.1.4 ระบุว่าเมื่อแปลงค่าจำนวนจริงแบบ floating-point ที่มีค่าจำกัดไปเป็นชนิดจำนวนเต็ม หากส่วนจำนวนเต็มไม่สามารถแทนได้ด้วยชนิดจำนวนเต็มนั้น พฤติกรรมจะไม่ได้ถูกกำหนด
  • สำหรับค่าที่ไม่จำกัดก็ไม่ได้มีการระบุไว้เช่นกัน จึงเป็น UB
  • แม้แต่การเปรียบเทียบ float กับ INT_MAX ก็ไม่ใช่เรื่องง่าย
    • หาก cast float เป็น int ก็อาจไปกระตุ้น UB ที่กำลังพยายามหลีกเลี่ยง
    • หาก cast INT_MAX เป็น float ก็ไม่รู้ว่าจะถูกแทนค่าได้อย่างแม่นยำหรือไม่
    • ถ้า INT_MAX ถูกปัดเป็น float ที่กลายเป็นค่าซึ่งแทนด้วย int ไม่ได้ การเปรียบเทียบก็อาจไม่เป็นตัวแทนที่ถูกต้อง
  • ถ้าจะทำให้ปลอดภัย ต้องมีการตรวจ isfinite(), เปรียบเทียบกับระยะเผื่ออย่าง INT_MIN + 1000 และ INT_MAX - 1000, รวมถึงตรวจเพิ่มหลังการแปลงก่อนนำไปบวก
    int milliseconds(float seconds) {
            const float ftmp = seconds * 1000.0f;
            if (!isfinite(ftmp)) {
                    return 0;
            }
            if ((float)(INT_MIN + 1000) > ftmp) {
                    return 0;
            }
            if ((float)(INT_MAX - 1000) < ftmp) {
                    return 0;
            }
            const int tmp = (int)ftmp;
            if (INT_MAX == tmp) {
                    return 0;
            }
            return tmp + 1;
    }
    
  • ทั้งที่แค่ต้องการแปลง float เป็น int อย่างปลอดภัย แต่โค้ดกลับยาวขึ้นมาก

ออบเจ็กต์ที่แอดเดรส 0 และ null pointer

  • ในโค้ด OS kernel หรือ embedded อาจมีสถานการณ์ที่อยากวางออบเจ็กต์ไว้ที่แอดเดรส 0
  • แต่ถ้าจะให้ตรงตามมาตรฐาน C แทบไม่มีวิธีที่ใช้งานได้จริงในการวางออบเจ็กต์ไว้ที่แอดเดรส 0 จริง ๆ
  • ใน C 6.3.2.3 ค่าคงที่จำนวนเต็ม 0 และ nullptr ที่แปลงเป็นพอยน์เตอร์ได้เรียกว่า “null pointer constant” และที่นี่อาจเรียกรวมว่า NULL
  • C ไม่ได้กำหนดว่า NULL pointer จริงต้องชี้ไปที่แอดเดรสเครื่องหมายเลข 0
  • มาตรฐาน C พูดถึงเครื่องเชิงนามธรรมของ C ไม่ใช่ฮาร์ดแวร์จริง และรับประกันเพียงว่าเมื่อเปรียบเทียบ NULL กับ 0 แล้วจะเท่ากัน
  • ความเท่ากันนั้นอาจเกิดจากการที่จำนวนเต็ม 0 ถูกแปลงเป็นค่า NULL แบบ native ของแพลตฟอร์มนั้น ซึ่งค่านั้นอาจเป็น 0xffff ก็ได้
  • การ dereference null pointer ไม่ว่าค่าจริงจะเป็นเท่าใดก็ถือเป็น UB และเป็นตัวอย่างคลาสสิกใน C 3.4.3
  • ดังนั้นจึงไม่อาจสมมติได้ว่า memset(&ptr, 0, sizeof(ptr)); จะสร้างพอยน์เตอร์ NULL
  • การ initialize struct เป็นศูนย์ทั้งหมดแล้วสมมติว่าสมาชิกพอยน์เตอร์จะเป็น NULL เป็นสิ่งที่สร้างปัญหาจริงได้แม้กับโปรแกรมเมอร์ส่วนใหญ่
  • ในอดีตก็เคยมี เครื่องที่ใช้ NULL pointer ซึ่งไม่ใช่ 0 จริง

ปัญหาของการสมมติว่ามีฟังก์ชันอยู่ที่แอดเดรส 0

  • ต่อให้บนเครื่องสมัยใหม่ NULL จะชี้ไปที่แอดเดรส 0 และมีออบเจ็กต์หรือฟังก์ชันอยู่ที่นั่นจริง C 6.3.2.3 ก็ยังกำหนดว่า NULL ไม่เท่ากับออบเจ็กต์หรือฟังก์ชันใด ๆ
  • ดังนั้นโค้ดนี้จึงเป็น UB
    void (*func_ptr)() = NULL;
    func_ptr();
    
  • ในมุมของ C นี่หมายถึง “ไม่มีฟังก์ชันอยู่ตรงนั้น” และอาจไม่มีวิธีในภายในคอมไพเลอร์ที่จะสื่อเจตนาแบบนี้ได้
  • จึงไม่อาจสมมติได้ว่ามันจะปล่อยคำสั่ง call ไปยังแอดเดรสที่บิตทั้งหมดเป็น 0
  • บน x86 แบบ 16 บิต ก็ยังไม่ชัดเลยว่า “ศูนย์ทั้งหมด” หมายถึง 0000:0000 หรือ CS:0000

อาร์กิวเมนต์แบบแปรผันและ type mismatch

  • อาร์กิวเมนต์สุดท้ายของ execl() ต้องเป็นพอยน์เตอร์ ดังนั้นถ้าส่ง NULL macro หรือจำนวนเต็ม 0 ตรง ๆ เข้าไป ก็อาจเป็น UB
    execl("/bin/sh", "sh", "-c", "date", NULL);  /* WRONG */
    execl("/bin/sh", "sh", "-c", "date", 0);     /* WRONG */
    
  • รูปแบบที่ถูกต้องคือ cast ให้เป็นชนิดพอยน์เตอร์อย่างชัดเจน
    execl("/bin/sh", "sh", "-c", "date", (char*)NULL);
    
  • NULL macro อาจถูกตีความเป็นจำนวนเต็ม 0 และในอาร์กิวเมนต์แบบแปรผัน ข้อมูลชนิดที่จำเป็นจะไม่ถูกส่งไปด้วย
  • ใน printf() ถ้า format specifier ไม่ตรงกับชนิดของอาร์กิวเมนต์จริง ก็เป็น UB เช่นกัน
    uint64_t blah = 123;
    printf("%ld\n", blah);  /* WRONG */
    
  • หากต้องการพิมพ์ uint64_t ควรใช้ PRIu64
    uint64_t blah = 123;
    printf("%"PRIu64"\n", blah);
    
  • หากต้องการพิมพ์ uid_t อาจใช้วิธี cast เป็น uintmax_t แล้วใช้ PRIuMAX แต่ก็ยังไม่แน่ว่า uid_t เป็น unsigned หรือไม่
  • ในกรณีแย่ที่สุด อาจพิมพ์ค่าที่ไร้ความหมายออกมาแทน -1

การหารด้วย 0 และปัญหาด้านความปลอดภัย

  • การที่ หารด้วย 0 เป็น UB นั้นเป็นเรื่องที่รู้กันทั่วไป แต่ถ้าตัวหารมาจากอินพุตที่เชื่อถือไม่ได้ มันก็กลายเป็นปัญหาด้านความปลอดภัยได้
  • ประเด็นสำคัญคือมันไม่ใช่แค่ runtime error ธรรมดา แต่เป็น UB ที่อาจเกิดตรงขอบเขตของการตรวจสอบอินพุต

ไม่ใช่ UB แต่ integer promotion ก็อันตราย

  • กฎของ integer promotion นั้นยากจะประเมินได้ทันทีจากการไล่อ่านโค้ด และอาจให้ผลลัพธ์ที่สวนทางกับสัญชาตญาณ
  • ในโค้ดนี้ overflowed จะได้ค่า 0 ไม่ใช่ 1
    unsigned char a = 0xff;
    unsigned char b = 1;
    unsigned char zero = 0;
    bool overflowed = (a + b) == zero;
    // overflowed is set to zero, not one.
    
  • ในโค้ดถัดไป แม้ตัวแปรทั้งหมดจะดูเหมือน unsigned แต่ผลลัพธ์จะไม่ใช่ 2147483648 (0x80000000) แต่เป็น 18446744071562067968 (ffffffff80000000)
    unsigned char a = 0x80;
    uint64_t b = a << 24;     // Bonus UB(?)
    
  • ต่อให้ไม่ใช่ UB ก็ตาม กฎของจำนวนเต็มใน C/C++ ก็ยังไม่เป็นธรรมชาติและทำให้เกิดบั๊กได้ง่าย

การใช้ LLM เพื่อตรวจหา UB

  • LLM รุ่นใหม่ ๆ เมื่อถูกขอให้หา UB ในโค้ด C แบบสุ่ม มักจะพบปัญหาได้แทบทุกครั้ง และผลลัพธ์ส่วนใหญ่ก็ถูกต้อง
  • หลังจากหา UB ในโค้ดส่วนตัวได้แล้ว ก็มีการนำแนวทางเดียวกันไปใช้กับโค้ด OpenBSD ที่โตเต็มที่และเขียนอย่างเข้มงวด
  • เครื่องมือแรกที่เลือกดูคือ find และพบปัญหาหลายจุด
  • มีการส่งแพตช์ให้ OpenBSD ทั้งสำหรับ การเขียนออกนอกขอบเขต และ บั๊กเชิงตรรกะที่ไม่ใช่ UB
  • แต่ UB อีกจำนวนมากที่เหลืออยู่ไม่ได้ถูกส่งแพตช์
    • เพราะเคยมีประสบการณ์ว่าโครงการ OpenBSD ไม่ค่อยเปิดรับ bug report ในอดีต
    • เพราะประเมินว่าบางจุดอาจใช้งานได้จริงโดยไม่มีปัญหา
    • เพราะหาก OpenBSD จะกำจัด UB ออกจากโค้ดเบสจริง ๆ ก็คงต้องเป็นโครงการขนาดใหญ่กว่าการให้ LLM ส่งแพตช์รายจุดผ่านตัวกลาง

แนวทางที่เป็นจริงในการรับมือกับโค้ดเบส C/C++

  • เราอาจทิ้งโค้ดเบส C/C++ ที่มีอยู่ไม่ได้ แต่การปล่อยให้มันอยู่ในสภาพที่พังโดยเนื้อแท้ก็ไม่ใช่ทางเลือก
  • เราจำเป็นต้องแก้ UB ในวงกว้างโดยไม่ commit การเปลี่ยนแปลงคุณภาพต่ำจาก AI และไม่ทำให้ผู้รีวิวที่เป็นมนุษย์รับภาระเกินไป
  • ในปี 2026 การเขียน C หรือ C++ โดยไม่มีการกำกับดูแล UB ด้วย SOX อาจถูกมองว่าเหมือนการละเมิด SOX และเป็นการกระทำที่ไม่รับผิดชอบ
  • หากแม้แต่นักพัฒนา OpenBSD ก็ยังหาเรื่องเหล่านี้ไม่เจอหมดตลอดกว่า 30 ปี โอกาสที่โครงการอื่นจะทำได้ดีกว่าก็ยิ่งต่ำ
  • สำหรับโปรเจกต์ส่วนตัว อาจใช้วิธีให้ LLM ช่วยหา UB, อธิบายถ้าจำเป็น, และแก้ไข จากนั้นให้มนุษย์ตรวจผลลัพธ์
  • อย่างไรก็ตาม การตรวจยืนยันผลยังต้องใช้ผู้เชี่ยวชาญ และผู้เชี่ยวชาญก็มักยุ่งกับงานอื่นอยู่แล้ว
  • งานนี้ดูเหมือนเป็นงานเก็บกวาด แต่ก็ละเอียดอ่อนเกินกว่าจะโยนให้โปรแกรมเมอร์จูเนียร์ที่ตามธรรมเนียมมักได้รับงานลักษณะนี้

เนื้อหาที่เกี่ยวข้อง

1 ความคิดเห็น

 
GN⁺ 4 시간 전
ความคิดเห็นจาก Hacker News
  • ใน C มี พฤติกรรมที่ไม่กำหนดไว้ ที่น่าประหลาดและพิลึกอยู่มาก แต่บทความนี้ก็ยังแสดงให้เห็นได้ไม่ดีนัก แค่เกาพื้นผิวเบาๆ เท่านั้น
    ตัวอย่างที่แปลกกว่านี้คือ volatile int x = 5; printf("%d in hex is 0x%x.\n", x, x); ถ้า x เป็นแค่ int ก็ไม่มีปัญหา แต่ถ้าเป็น volatile จะกลายเป็นพฤติกรรมที่ไม่กำหนดไว้ ตามมาตรฐาน C การเข้าถึง volatile แค่การอ่านก็ถือเป็นผลข้างเคียงแล้ว และผลข้างเคียงที่ไม่มีลำดับกับ scalar object เดียวกันถือเป็นพฤติกรรมที่ไม่กำหนดไว้ อีกทั้งการประเมินอาร์กิวเมนต์ของฟังก์ชันก็ไม่มีการกำหนดลำดับระหว่างกัน
    โดยทั่วไป data race มักหมายถึงหลายเธรดเข้าถึงอ็อบเจ็กต์เดียวกันพร้อมกัน และอย่างน้อยหนึ่งครั้งเป็นการเขียน แต่ใน C อาจเกิดสถานการณ์คล้าย data race ได้แม้จะอยู่ในเธรดเดียวและไม่มีการเขียนเลยก็ตาม

    • ในฐานะผู้เขียน เห็นด้วย จุดประสงค์ของบทความนี้ไม่ใช่การไล่ครบทั้ง 283 จุดในมาตรฐานที่มีคำว่า undefined หรือแจกแจงทุกกรณีของพฤติกรรมที่ไม่กำหนดไว้ที่เกิดจากการละไว้
      ประเด็นคือ มันหลีกเลี่ยงไม่ได้ อย่างน้อยก็ตั้งแต่ C ออกมาในปี 1972 ก็ไม่เคยมีมนุษย์คนไหนหลีกเลี่ยงมันได้อย่างสมบูรณ์
      ถ้า 54 ปีแล้วยังไม่สำเร็จ คำตอบก็ไม่ใช่ “พยายามให้มากขึ้น” หรือ “อย่าพลาด” ข้อบกพร่องที่ Mythos พบใน OpenBSD ซึ่งนำไปใช้โจมตีได้หนึ่งจุดนั้นถือว่าได้คะแนนค่อนข้างดีสำหรับนักพัฒนา OpenBSD แต่พอเอาเครื่องมือไปรันกับโค้ดที่ง่ายที่สุด ก็ยังเจอพฤติกรรมที่ไม่กำหนดไว้เต็มไปหมด
      ตัวอย่างเช่น find อ่านตัวแปรอัตโนมัติ status ที่ยังไม่ถูกกำหนดค่า หลัง waitpid(&status) แต่ก่อนจะตรวจว่ามี error จาก waitpid() หรือไม่ ซึ่งก็เป็นพฤติกรรมที่ไม่กำหนดไว้ แม้จะยากจะจินตนาการถึงสถาปัตยกรรมหรือคอมไพเลอร์ที่ทำให้มันถูกใช้โจมตีได้
      อย่างที่เขียนไว้ในบทความ ไม่ได้ตั้งใจจะไล่รายการพฤติกรรมที่ไม่กำหนดไว้ทั้งหมดในโลก แต่ต้องการชี้ว่า โค้ด C/C++ ที่ไม่ใช่เรื่องเล็กน้อยทุกชิ้นล้วนมีพฤติกรรมที่ไม่กำหนดไว้
    • volatile คือ แฮ็กของระบบชนิดข้อมูล ควรมีทางแก้ที่เป็นหลักการกว่านี้ และภาษาใหม่ๆ ไม่ควรเดินตามเพียงเพราะ “C ทำแบบนั้นไว้ก็เลยน่าจะดี”
      คอมไพเลอร์ C ยุคแรกมักเขียนค่ากลับหน่วยความจำเสมอ ดังนั้นถ้าตั้ง pointer ให้ตรงกับฮาร์ดแวร์ memory-mapped I/O ทุกครั้งที่เปลี่ยน x คำสั่ง CPU ก็จะเขียนหน่วยความจำจริง ทำให้โค้ดไดรเวอร์ทำงานได้
      แต่พอมี optimization คอมไพเลอร์ก็มองว่าแค่แก้ x ไปเรื่อยๆ จึงเก็บไว้ในรีจิสเตอร์อย่างเดียว แล้วไดรเวอร์ก็พัง volatile ใน C คือแฮ็กที่บอกคอมไพเลอร์ว่า “อย่าทำ optimization แบบนั้น” ขณะที่ทางแก้ที่ถูกต้องกว่าอย่างการมี intrinsics สำหรับ memory-mapped I/O ในระดับไลบรารีน่าจะเป็นงานใหญ่กว่ามาก
      เหตุผลที่ต้องมี intrinsics ก็เพราะมันอธิบายได้แม่นยำว่าอะไรทำได้และอะไรทำไม่ได้ บนบางเป้าหมาย การเขียน 1 ไบต์ 2 ไบต์ และ 4 ไบต์ เป็นคนละการกระทำ และฮาร์ดแวร์ก็แยกความต่างนี้ด้วย บางอุปกรณ์คาดหวังการเขียน RGBA 4 ไบต์ แต่ถ้าส่งเป็นการเขียน 1 ไบต์ 4 ครั้ง ก็อาจสับสนหรือไม่ทำงานเลย บางเป้าหมายยังรองรับการเขียนระดับบิตด้วย ใช้แค่ volatile อย่างเดียวไม่มีทางรู้ได้เลยว่าเกิดอะไรขึ้นและมันมีความหมายอย่างไร
    • ต้องแยก พฤติกรรมที่ไม่กำหนดไว้ ออกจาก race ประเด็นนี้มักหายไปจากการถกเรื่องพฤติกรรมที่ไม่กำหนดไว้
      ถ้าคอมไพล์โปรแกรม C แล้วเอาไป disassemble มันจะกลายเป็นโปรแกรมแอสเซมบลีที่ไม่มีพฤติกรรมที่ไม่กำหนดไว้ เพราะในแอสเซมบลีไม่มีแนวคิดเรื่องพฤติกรรมที่ไม่กำหนดไว้
      พฤติกรรมที่ไม่กำหนดไว้เป็นคุณสมบัติของโปรแกรมต้นฉบับ ไม่ใช่ของไฟล์ปฏิบัติการ หมายความว่าสเปกของภาษาที่ใช้เขียนซอร์สนั้นไม่ได้ให้ความหมายกับโปรแกรมดังกล่าว ในขณะที่ไฟล์ปฏิบัติการซึ่งเป็นผลจากการคอมไพล์นั้นได้รับความหมายจากสเปกของเครื่อง
      ส่วน race เป็นคุณสมบัติของการทำงานของโปรแกรม ดังนั้นคุณจะพูดได้ว่าโปรแกรม C มีพฤติกรรมที่ไม่กำหนดไว้ แต่พูดไม่ได้ว่าไฟล์ปฏิบัติการมี race เกิดขึ้นจริง แน่นอนว่าคอมไพเลอร์สามารถคอมไพล์โปรแกรมที่มีพฤติกรรมที่ไม่กำหนดไว้แบบตามใจชอบ จนอาจใส่ race เข้าไปด้วยก็ได้ แต่ถ้าคอมไพล์โดยไม่สร้างเธรดใหม่ ก็จะไม่มี race
    • ความหมายของ volatile ก็คือค่าของมัน อาจถูกเปลี่ยนโดยบางสิ่งภายนอก ถ้าเป็นตัวแปร global สิ่งนั้นอาจเป็นเธรดอื่น interrupt หรือ signal handler ก็ได้ ถ้าเป็น pointer ที่อ่านจากที่อยู่เฉพาะ ก็อาจเป็นรีจิสเตอร์ของอุปกรณ์ฮาร์ดแวร์ที่ค่ากำลังเปลี่ยนอยู่
      แนวคิดของตัวแปร volatile เองไม่ใช่ปัญหา ถ้าภาษาต้องการรองรับ interrupt routine และ memory-mapped I/O ก็จำเป็นต้องมีวิธีบอกคอมไพเลอร์ว่าการอ่านฮาร์ดแวร์รีจิสเตอร์เดียวกันสองครั้งนั้นไม่เหมือนกับการอ่านตำแหน่งหน่วยความจำเดียวกันสองครั้ง
      ปัญหาจริงคือปฏิสัมพันธ์ระหว่างฟีเจอร์และข้อจำกัดของภาษายังไม่ถูกจัดวางให้ชัดพอ การระบุว่า “ค่านี้อาจเปลี่ยนได้ตลอดเวลา” แล้วกลับตัดสินว่าการใช้งานบางแบบเป็นพฤติกรรมที่ไม่กำหนดไว้เพราะเหตุผลนั้นเองถือว่าไร้เหตุผล สำหรับตัวแปร volatile ควรมีข้อยกเว้นจากนิยามเรื่อง “ผลข้างเคียงที่ไม่มีลำดับ”
    • แก่นของบทความคือคุณไม่จำเป็นต้องเขียนโค้ดประหลาดๆ เลยด้วยซ้ำถึงจะเจอพฤติกรรมที่ไม่กำหนดไว้
      หลายคนเข้าใจผิดว่า C กับ C++ “ยืดหยุ่นมากเพราะให้ทำอะไรก็ได้” ทั้งที่จริงแล้วแทบทุกเทคนิคที่ดูทรงพลังและเท่ล้วนเป็น ทุ่งกับระเบิดของพฤติกรรมที่ไม่กำหนดไว้
  • พฤติกรรมที่ไม่กำหนดไว้ของ pointer ที่ไม่จัดแนว แย่ยิ่งกว่า เพราะ pointer ที่ไม่จัดแนวนั้นไม่ใช่แค่ตอนเข้าถึงเท่านั้นที่เป็นพฤติกรรมที่ไม่กำหนดไว้ แต่เป็นตั้งแต่ตัว pointer เองแล้ว
    ดังนั้นการแคสต์ void* v เป็น int* i แบบ implicit เช่น i=v ใน C หรือ f(v) ที่รับ int* ก็เป็นพฤติกรรมที่ไม่กำหนดไว้ หาก pointer ที่ได้ไม่ตรงตามข้อกำหนดการจัดแนวของ int
    จุดสำคัญคือมันเป็นปัญหาในระดับ C ถ้าโปรแกรม C มีพฤติกรรมที่ไม่กำหนดไว้ โปรแกรม C นั้นก็ถือว่าไม่ถูกต้องและผิดรูปแบบในเชิงทางการ มันไม่ใช่ปัญหาของฮาร์ดแวร์ และไม่เกี่ยวกับการ crash หรือข้อบกพร่อง
    การแคสต์จาก void* เป็น int* ปกติแล้วในโค้ดระดับฮาร์ดแวร์มักไม่มีอะไรเกิดขึ้นเลย และชนิดข้อมูลก็มีอยู่แค่ใน C ดังนั้นฮาร์ดแวร์จะไม่ crash ตอนแคสต์นั้น คุณอาจคิดว่าถ้าเป็นค่าจำนวนเต็มในรีจิสเตอร์ก็น่าจะโอเค แต่ประเด็นคือไม่ใช่ว่า pointer ในฮาร์ดแวร์เป็นจำนวนเต็มจริงหรือไม่ แก่นคือพอแคสต์เป็น pointer ที่ไม่จัดแนว โปรแกรม C นั้นก็เสียหายในความหมายของนิยามภาษาแล้ว

    • ในฐานะผู้เขียน ถูกต้อง ประเด็นนี้อยู่ในหัวข้อ “Actually, it was UB even before that” ของบทความ
      ผมก็พยายามจะสื่อเหมือนกันว่าพฤติกรรมที่ไม่กำหนดไว้ไม่ได้อยู่ในฮาร์ดแวร์ และไม่เกี่ยวกับ crash หรือข้อบกพร่อง ขณะเดียวกันก็อยากยกตัวอย่างให้คนที่ชอบพูดว่า “แต่เห็นมันทำงานดีนี่” เห็นว่าในความเป็นจริงไม่ใช่แบบนั้น
    • ฟังดูโอเคและคาดเดาได้ดี โปรแกรมเมอร์ที่เก่งย่อมรู้ว่า การแคสต์ pointer เป็นพื้นที่อันตรายอยู่แล้ว
    • ช่วยชี้ได้ไหมว่ามาตรฐานระบุไว้ตรงไหนว่าตัว pointer ที่ไม่จัดแนวเองก็เป็นพฤติกรรมที่ไม่กำหนดไว้?
    • ถ้าสร้าง struct ด้วย #pragma pack(push, 1) แปลว่าเราใช้ member pointer ไม่ได้เลยหรือ ถ้าสมาชิกนั้นไม่ได้ถูกจัดแนวโดยบังเอิญ?
    • เดิมทีแนวคิดเรื่องพฤติกรรมที่ไม่กำหนดไว้ใน C มีไว้เพื่อให้คอมไพเลอร์มีอิสระในการแมปโค้ดลงฮาร์ดแวร์ แม้คำสั่ง machine code จะต่างกันเล็กน้อยในแต่ละสถาปัตยกรรม กล่าวคือโปรแกรม C เดียวกันสามารถสื่อพฤติกรรมที่ต่างกันได้ตามสถาปัตยกรรมที่นำไปรัน
      พฤติกรรมที่ไม่กำหนดไว้ชนิดนี้ถือว่าโอเค และแทบไม่มีใครมองว่าการเกิดบั๊กเพราะความต่างของฮาร์ดแวร์เป็นเรื่องใหญ่
      แต่เมื่อเวลาผ่านไป การตีความแบบก้าวร้าวทำให้ C กลายเป็นภาษาที่เหมือนมี design by contract แบบแฝง และข้อจำกัดต่างๆ ก็กลายเป็นสิ่งที่มองไม่เห็น นี่สร้างปัญหาคล้ายกับการที่ใน RAII การเรียก destructor แบบ implicit มองไม่เห็น
      ใน C เมื่อคุณ dereference pointer คอมไพเลอร์จะเพิ่มเงื่อนไข implicit ว่า pointer นั้นต้องไม่เป็น null ลงใน signature ของฟังก์ชัน ถ้าคุณส่ง pointer ที่อาจเป็น null เข้าฟังก์ชัน แทนที่จะเกิด error ว่าไม่มีการตรวจหรือ assert คอมไพเลอร์จะค่อยๆ กระจายเงื่อนไขต้องไม่เป็น null นี้ไปตาม pointer นั้นเงียบๆ พอพิสูจน์ได้ว่าเงื่อนไขนี้เป็นเท็จ มันก็อาจทำเครื่องหมายฟังก์ชันว่าไปไม่ถึง และการเรียกฟังก์ชันที่ไปไม่ถึงนั้นก็ทำให้ฟังก์ชันผู้เรียกกลายเป็นไปไม่ถึงตามไปด้วย
  • 5 ขั้นของการเรียนรู้พฤติกรรมที่ไม่กำหนดไว้ใน C
    ปฏิเสธ: “ฉันรู้ว่า signed overflow บนเครื่องฉันเป็นยังไง”
    โกรธ: “คอมไพเลอร์นี่ห่วย! ทำไมไม่ทำตามที่ฉันสั่ง?”
    ต่อรอง: “ฉันจะส่งข้อเสนอนี้ให้ wg14 เพื่อแก้ C…”
    ซึมเศร้า: “มีโค้ด C อะไรที่เชื่อถือได้บ้างไหม?”
    ยอมรับ: “ก็แค่ อย่าใช้ พฤติกรรมที่ไม่กำหนดไว้

    • แล้วขั้น “ทำให้คอมไพเลอร์นิยามสิ่งที่ไม่กำหนดไว้เสียเอง” ควรอยู่ตรงไหน?
      การเข้าถึงที่ไม่จัดแนวก็ใช้ packed struct ได้ คอมไพเลอร์จะสร้างโค้ดที่ถูกต้องอย่างกับเวทมนตร์ จริงๆ แล้วคอมไพเลอร์ทำได้มาตลอด เพียงแต่ไม่ทำเท่านั้น
      strict aliasing ก็ใช้การแปลงชนิดผ่าน union ได้ ถ้าเป็นคอมไพเลอร์สำคัญๆ ก็มักมีเอกสารบอกว่าทำงานได้แม้มาตรฐานจะไม่ได้ระบุ หรือจะปิดมันด้วย -fno-strict-aliasing ก็ได้ จะตีความหน่วยความจำแบบไหนก็ทำได้ อาจยังมีมุมคมอยู่บ้าง แต่อย่างน้อยจะไม่ใช่สิ่งที่มาจากคอมไพเลอร์
      overflow ก็นิยามด้วย -fwrapv ได้ เปลี่ยน +, -, * เป็น __builtin_*_overflow ก็ได้การตรวจ error แบบชัดเจนมาแบบฟรีๆ ด้วย อินเทอร์เฟซเชิงฟังก์ชันก็ดี และยังสร้างโค้ดที่มีประสิทธิภาพได้
      การยอมรับจริงๆ น่าจะใกล้กับ “คนปกติไม่สนมาตรฐาน C หรอก” มาตรฐานมันแย่ สิ่งสำคัญคือคอมไพเลอร์ต่างหาก ซึ่งมีฟีเจอร์ที่มีประโยชน์มากมายให้เลี่ยงปัญหาพวกนี้ได้เกือบทั้งหมด ที่คนไม่ใช้ก็เพราะยังอยากเขียน C แบบ “portable” “มาตรฐาน” อยู่ การหลุดออกจากกรอบคิดนั้นต่างหากคือการยอมรับจริง
      ด้วยตรรกะแบบนี้ผมทำ Lisp interpreter ใน freestanding C ได้ และยังผ่าน UBSan ด้วย ตอนแรกก็นึกว่าจะระเบิดเหมือนกัน แต่ไม่เป็นแบบนั้น ถ้าผมทำได้ คนอื่นก็ทำได้
    • ในฐานะผู้เขียน ประเด็นของบทความคือแม้แต่ “ก็แค่อย่าใช้พฤติกรรมที่ไม่กำหนดไว้” ก็เป็นสิ่งที่เป็นไปไม่ได้
      ตราบใดที่มนุษย์ยังเป็นคนเขียนโค้ด มันไม่มีทางเป็นสภาวะสุดท้ายได้ ไม่มีมนุษย์คนไหนหลีกเลี่ยงพฤติกรรมที่ไม่กำหนดไว้ใน C/C++ ได้อย่างสมบูรณ์
    • “ก็แค่อย่าใช้พฤติกรรมที่ไม่กำหนดไว้” ฟังดูเหมือนยังอยู่แค่ ขั้นต่อรอง มากกว่า
    • มาทำงานกับอุปกรณ์ embedded แบบผมสิ การเขียนซอฟต์แวร์ให้ CPU เฉพาะตัวนี่สบายมากจริงๆ
    • สำหรับ C การยอมรับน่าจะใกล้กับ “ฉันจะใช้ พฤติกรรมที่ไม่กำหนดไว้ และสักวันเรื่องแย่ๆ จะเกิดขึ้น”
  • ตัวอย่างเหล่านี้ดูจะไม่ใช่พฤติกรรมที่ไม่กำหนดไว้จริงๆ แต่เป็นกรณีที่อาจกลายเป็นพฤติกรรมที่ไม่กำหนดไว้ได้ขึ้นกับอินพุตหรือสถานการณ์มากกว่า
    ถ้าจะตีความกว้างขนาดนั้น ทุกการเรียกฟังก์ชันก็เป็นพฤติกรรมที่ไม่กำหนดไว้ได้เหมือนกัน เพราะอาจใช้ stack space เกิน ในความหมายหนึ่งจริงๆ ภาษาทุกภาษาก็อาจพูดแบบนั้นได้
    ใน C มีจุดหยาบที่น่าสนใจจริงๆ มากพออยู่แล้ว การใช้แนวหวือหวาแบบนี้อาจทำให้มือใหม่ไขว้เขวและยิ่งเป็นโทษมากกว่า

    • Ada 83 ไม่ถือว่า stack overflow ตอนเรียกใช้ call stack เป็นพฤติกรรมที่ไม่กำหนดไว้ ใน reference manual มีข้อยกเว้น STORAGE_ERROR นิยามไว้
      http://archive.adaic.com/standards/83lrm/html/lrm-11-01.html
      ระบุว่าข้อยกเว้นนี้เกิดขึ้นได้ในกรณี “พื้นที่เก็บข้อมูลไม่เพียงพอระหว่างการทำงานของการเรียก subprogram” ด้วย
    • ไม่จริงเลย
      ก่อนอื่นเลย มันเป็นไปได้ที่จะกำหนดได้ว่าจะเกิดอะไรขึ้นเมื่อ stack space หมด และก็ไม่ใช่ทุกโปรแกรมที่จะต้องการ stack ขนาดไม่จำกัด บางโปรแกรมต้องใช้แค่ขนาดคงที่ที่คำนวณล่วงหน้าได้ บาง implementation ของภาษาก็ไม่ใช้ stack เลยด้วยซ้ำ
      ภาษาอาจมีเครื่องมือให้ตรวจสอบ stack ที่เหลืออยู่และให้การรับประกันตามนั้น หรืออาจให้ติดตั้ง handler ที่จะรันเมื่อ stack space หมดก็ได้
    • พฤติกรรมที่ไม่กำหนดไว้ซึ่งเกิดตามอินพุตก็สามารถเป็น ช่องทางโจมตี ได้เช่นกัน
    • ตัวอย่างพวกนี้เป็นพฤติกรรมที่ไม่กำหนดไว้ชัดๆ จบ
      วิธีคิดที่ถูกคือ ทันทีที่เกิดพฤติกรรมที่ไม่กำหนดไว้ คุณก็ไม่ได้อยู่ใต้การคุ้มครองของมาตรฐานภาษาอีกต่อไป มันอาจดูเหมือนทำงานได้ดีไปอีกพักใหญ่ หรืออาจตลอดไปก็ได้ แต่ในความจริง คุณกำลังผูกตัวเองเข้ากับความแปรปรวนของ toolchain แบบไม่รู้ตัว การเปลี่ยนหรืออัปเกรดคอมไพเลอร์ สถาปัตยกรรม runtime หรือเวอร์ชันของ libc
      สุดท้ายแล้วคุณกำลังสร้าง ฐานบนทราย และนั่นคืออันตรายของพฤติกรรมที่ไม่กำหนดไว้
    • บทความนี้แทบจะเป็นนิยามของ FUD เลย
  • ปัญหาของพฤติกรรมที่ไม่กำหนดไว้ไม่ใช่ว่ามันอาจทำให้ crash บนบางสถาปัตยกรรม
    ปัญหาจริงคือคอมไพเลอร์คาดหวังว่าโค้ดแบบนั้นจะไม่มีวันเกิดขึ้น ถึงอย่างนั้นถ้าคุณยังเขียนโค้ดที่มีพฤติกรรมที่ไม่กำหนดไว้ คอมไพเลอร์ โดยเฉพาะ optimizer ก็มีสิทธิ์แปลมันออกมาเป็นอะไรก็ได้ที่สะดวกต่อเส้นทางปกติของโปรแกรม และ “อะไรก็ได้” นั้นบางครั้งก็อาจคาดไม่ถึงมาก เช่นการลบทิ้งทั้งก้อนโค้ดใหญ่ๆ

    • ตัวอย่างที่เกี่ยวข้องคือมีเงื่อนไขว่าทุกฟังก์ชันต้องจบลงหรือก่อให้เกิดผลข้างเคียง ผมยังไม่เคยเจอกับตัว แต่ก็พอนึกภาพได้ว่าถ้าเผลอเขียนลูปไม่รู้จบหรือ recursion แบบไม่มีที่สิ้นสุด แล้วตัวฟังก์ชันถูกลบทิ้งไป
      ถ้ามี tail recursion อีกหน่อย ก็อาจกลายเป็นว่าใน debug build ไปไม่ถึงลูปไม่รู้จบ แต่พอเพิ่มระดับ optimization แล้วบั๊กถึงค่อยโผล่มา
    • การ crash ถือเป็นหนึ่งในรูปแบบที่อ่อนโยนที่สุดของพฤติกรรมที่ไม่กำหนดไว้ อย่างน้อยมันก็มองเห็นได้ง่าย
      ที่แย่กว่าคือโปรแกรมอาจเงียบๆ รันต่อด้วยค่ามั่วๆ หรือฟอร์แมตฮาร์ดดิสก์ หรือยกกุญแจอาณาจักรให้นักโจมตีไปเลยก็ได้
    • ถูก แต่ก็เพราะแบบนี้นี่เองมันถึงเป็นฟีเจอร์ที่มีประโยชน์ที่สุดและเป็นเหตุผลของการมีอยู่ของพฤติกรรมที่ไม่กำหนดไว้
      คนที่บอกว่าแค่ทำให้มันเป็นพฤติกรรมที่กำหนดไว้หรือไม่ระบุไว้ก็พอ มักพลาดประเด็นว่าหัวใจสำคัญคือคอมไพเลอร์สามารถลบส่วนใหญ่ของโปรแกรมออกได้
      ถ้าคุณเขียนโค้ดที่กลายเป็นพฤติกรรมที่ไม่กำหนดไว้สำหรับอินพุตบางแบบ นั่นหมายความว่าสำหรับอินพุตนั้นคุณไม่ได้ตั้งใจให้โปรแกรมมีพฤติกรรมใดๆ เลย และคุณก็หวังให้คอมไพเลอร์ตัดเส้นทางนั้นทิ้งในการ optimize หรือทำสิ่งใดก็ตามที่ช่วยพฤติกรรมในกรณีที่กำหนดไว้
      การใส่สตริง log ที่เข้าถึงได้ผ่านพฤติกรรมที่ไม่กำหนดไว้เท่านั้น แล้วเห็นว่ามันไม่เหลืออยู่ในไบนารี เป็นอะไรที่น่าพอใจไม่น้อย
    • ส่วนในบทความที่บอกว่า มันไม่ใช่ปัญหาเรื่อง optimization นี่สะดุดตาผมมาก
      เมื่อก่อนผมเคยเขียน analysis pass โดยตั้งสมมติฐานว่ามันจะรันเป็นลำดับสุดท้ายของ transformation pipeline และสมมติฐานนั้นจำเป็นต่อความถูกต้อง คิดว่าเมื่อไม่มี optimization ต่อจากนั้นก็ปลอดภัยแล้ว แต่ตอนนี้ไม่แน่ใจอีกต่อไป
    • นั่นไม่ใช่ปัญหา แต่มันคือฟีเจอร์
  • ใช้ C มา 20 ปี แต่ในช่วง 6 เดือนหลังบน Hacker News ผมเห็นการพูดถึง พฤติกรรมที่ไม่กำหนดไว้ มากกว่าที่เคยเจอทั้งชีวิต
    ในบทสนทนาจริงแทบไม่เคยโผล่มาเลย เขียนโค้ด ถ้าไม่เวิร์กก็ดีบัก แก้ หรือหาทางอ้อมก็พอ ไม่เข้าใจว่าทำไมเรื่องพฤติกรรมที่ไม่กำหนดไว้ของ C ถึงขึ้นหน้าแรกได้เรื่อยๆ

    • Hacker News ยังเอนเอียงไปทางคนที่สนใจ ภาษาการเขียนโปรแกรม มากกว่าการเขียนโปรแกรมจริงอยู่พอสมควร อาจมีส่วนจากมรดก Lisp ของ Y Combinator ด้วย
      ยังมีคนกลุ่มน้อยที่เรียนวิทยาการคอมพิวเตอร์และมองว่าการพัฒนาหรือใช้ภาษาโปรแกรมใหม่ๆ เป็นเรื่องที่น่าสนใจที่สุดในโลก และบางคนก็ยังคิดแบบนั้นต่อไป
      คนแบบนั้นย่อมสนใจแง่มุมการออกแบบภาษา และพฤติกรรมที่ไม่กำหนดไว้ของ C ก็อยู่ในขอบเขตนั้น เพียงแต่เดิมทีส่วนใหญ่ก็มีไว้เพื่อรองรับสถาปัตยกรรม CPU เก่าๆ โดยไม่เสียประสิทธิภาพ จึงมีด้านที่แทบไม่ใช่ “ทางเลือกการออกแบบ” พอๆ กับที่ล้อรถต้องกลม
    • พูดอะไรน่ะ? เมื่อ 20 ปีก่อนผมก็ใช้ C และ C++ อยู่เหมือนกัน และตอนนั้นพฤติกรรมที่ไม่กำหนดไว้ก็เป็นหัวข้อใหญ่ทั้งในบทสนทนาและในหลักสูตรการสอน
      ช่วงราว GCC 3.2 คอมไพเลอร์เริ่มใช้พฤติกรรมที่ไม่กำหนดไว้ในการ optimize แบบดุดันมากขึ้น จนเกิด “เรื่องฉาว” ที่เป็นที่รู้จักกันดีหลายกรณี และเพราะแบบนั้นคนจำนวนมากจึงค้างอยู่กับ GCC 2.95 กันนาน GCC 3.2 ออกในปี 2002
    • คอมพิวเตอร์สมัยก่อนเจ๋งกว่า ส่วนคอมพิวเตอร์สมัยนี้อันตรายกว่า
      ทุกบริษัทคอยย้ำเรื่องความปลอดภัยและความเสี่ยงที่จะถูกเปิดโปงหรือติดข่าวอยู่ตลอด จนเรื่องเล่าต่อต้านความ “ไม่ปลอดภัย” ขยายใหญ่เกินเหตุ
      โลกใหม่ก็เหมือนคนเมืองที่ไม่เคยเห็นธรรมชาติแบบดิบๆ แล้วกลัวเครื่องตัดหญ้า ใบมีดมันหมุนเหรอ? เป็นไปไม่ได้!
    • เพราะสภาพแวดล้อมการรันอาจเป็นสถาปัตยกรรมที่ต่างออกไปโดยสิ้นเชิง รายละเอียดพวกนี้จึงสำคัญมาก
      ถ้าเป้าหมายจริงคือระบบ embedded เล็กๆ บนเสาสื่อสารกลางทุ่ง คำว่า “มันทำงานบนเครื่องฉัน” ก็ไม่มีประโยชน์ แน่นอนว่าคนส่วนใหญ่ไม่ได้ทำงานแบบนั้น และนักพัฒนาส่วนใหญ่ที่นี่ก็น่าจะเป็นเว็บดีเวลอปเปอร์ แต่ถึงไม่ได้เจอเอง มันก็ยังเป็นประเด็นที่น่าสนใจ และบางทีกลับยิ่งน่าสนใจเพราะไม่ใช่สิ่งที่ต้องเผชิญตรงๆ
    • ที่จริงแล้วมันคือการเขียนให้ตรงกับ เป้าหมายปลายทาง ไม่ใช่กับสเปกในจินตนาการ สเปกมีประโยชน์แค่ช่วยคาดเดาคร่าวๆ ว่าเป้าหมายปลายทางจะทำอะไร ไม่ใช่ตัวบทบังคับ
      คอมไพเลอร์อาจมีบั๊กที่ทำให้พฤติกรรมตามสเปกไม่ทำงานจริง มีส่วนขยายมากมายที่ไม่มีคู่ตรงในมาตรฐาน และแม้สิ่งที่มาตรฐานบอกว่าไม่กำหนดไว้ implementation แต่ละตัวก็อาจกำหนดความหมายที่ใช้งานได้จริงให้มัน
  • โดยรวมผมเห็นด้วยกับบทนำ แต่ตัวอย่างไม่ดี และทั้งบทความดูเหมือนห่อหุ้มมาเพื่อขาย การเขียนโค้ดด้วย LLM

    • ใช่ ตัวอย่างแต่ละชิ้นล้วนเป็นของมาตรฐานที่หลีกเลี่ยงกันอยู่แล้วเวลาจะเขียนโค้ดที่พกพาได้ หรือไม่ก็เป็นพวกไม่จำเป็นอย่างการเข้าถึงอ็อบเจ็กต์ที่ address 0
      มันให้ความรู้สึกเหมือนคนที่อยากเขียนโค้ดอะไรก็ได้ตามใจ แล้วคาดหวังให้มันทำงานเหมือนกันในทุกสภาพแวดล้อม ถ้าทำภาษาแบบนั้นจริง ข้อดีเรื่องการเขียนให้เข้ากับแพลตฟอร์มตามที่ต้องการก็จะหายไป
    • ไม่ดีอย่างไร? ถ้ามันจริงก็นับว่าร้ายแรงพอสมควรนะ
  • โค้ด C++ ในบทความนี้บางส่วนไม่ได้ถือว่าเป็นสำนวนมาตรฐานมานานเกิน 10 ปีแล้ว และทุกวันนี้น่าจะถูกมองว่าเป็น code smell
    ภาษานี้วิวัฒน์ไปมากจนต่างจากตอนแรกเริ่มอย่างชัดเจน ทันทีที่เห็น raw pointer และการเข้าถึง pointer โดยตรงเต็มไปหมด ก็ชัดเจนว่าบางส่วนของบทความควรอ่านแบบมีตัวกรอง
    อีกปัญหาที่ชัดเจนคือมุมมองที่จับ C กับ C++ มามัดรวมเหมือนเป็นภาษาเดียวกัน ทั้งที่ปัจจุบันสองภาษานี้ห่างกันไปมากแล้ว

    • ตอนแรกผมจะทักว่าโค้ดนั้นเป็น C ไม่ใช่ C++ แต่พอกลับไปดูอีกที มันเขียน std::atomic ไม่ใช่ atomic_int จริงๆ
  • ถ้าเข้าใจพฤติกรรมที่ไม่กำหนดไว้ใน C แบบนี้ จะถูกไหม?
    โปรแกรม P มีเซตอินพุต A ที่ไม่ทำให้เกิดพฤติกรรมที่ไม่กำหนดไว้ และมีเซตส่วนเติมเต็ม B ที่ทำให้เกิดมัน
    คอมไพเลอร์ที่ถูกต้องจะคอมไพล์ P เป็นไฟล์ปฏิบัติการ P' สำหรับทุกอินพุตใน A, P' ต้องทำงานเหมือน P
    แต่สำหรับอินพุตใดๆ ใน B พฤติกรรมของ P' นั้น ไม่มีข้อกำหนดใดๆ เลย

    • ในเชิงสัญชาตญาณก็ใช่ โปรแกรมจะถูกคอมไพล์ราวกับว่าไม่มีทางได้รับอินพุตใน B เลย ซึ่งรวมถึงการลบโค้ดที่พยายามตรวจจับอินพุตใน B ออกด้วย
    • สรุปได้ดี
  • ตัวอย่างรูปธรรมของพฤติกรรมที่ไม่กำหนดไว้ซึ่งเกิดจาก pointer ไม่จัดแนว: https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...

    • เป็นกรณีบน x86 ซึ่งผู้คนมักสมมติว่าจะไม่น่ามีปัญหาโดยเฉพาะ