ทุกอย่างใน C คือพฤติกรรมที่ไม่ได้กำหนด
(blog.habets.se)- พฤติกรรมที่ไม่ได้กำหนด (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>*แบบนี้ ถ้าออบเจ็กต์ไม่ได้จัดแนวอย่างถูกต้อง พฤติกรรมก็ยังเป็น UBvoid 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ได้ แต่ค่าติดลบจาก signedcharคือปัญหา- ตาม 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ไม่ได้ การเปรียบเทียบก็อาจไม่เป็นตัวแทนที่ถูกต้อง
- หาก cast
- ถ้าจะทำให้ปลอดภัย ต้องมีการตรวจ
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 ไม่ได้กำหนดว่า
NULLpointer จริงต้องชี้ไปที่แอดเดรสเครื่องหมายเลข 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()ต้องเป็นพอยน์เตอร์ ดังนั้นถ้าส่งNULLmacro หรือจำนวนเต็ม 0 ตรง ๆ เข้าไป ก็อาจเป็น UBexecl("/bin/sh", "sh", "-c", "date", NULL); /* WRONG */ execl("/bin/sh", "sh", "-c", "date", 0); /* WRONG */ - รูปแบบที่ถูกต้องคือ cast ให้เป็นชนิดพอยน์เตอร์อย่างชัดเจน
execl("/bin/sh", "sh", "-c", "date", (char*)NULL); NULLmacro อาจถูกตีความเป็นจำนวนเต็ม 0 และในอาร์กิวเมนต์แบบแปรผัน ข้อมูลชนิดที่จำเป็นจะไม่ถูกส่งไปด้วย- ใน
printf()ถ้า format specifier ไม่ตรงกับชนิดของอาร์กิวเมนต์จริง ก็เป็น UB เช่นกันuint64_t blah = 123; printf("%ld\n", blah); /* WRONG */ - หากต้องการพิมพ์
uint64_tควรใช้PRIu64uint64_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 ไม่ใช่ 1unsigned 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 ความคิดเห็น
ความคิดเห็นจาก 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 ได้แม้จะอยู่ในเธรดเดียวและไม่มีการเขียนเลยก็ตาม
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อย่างเดียวไม่มีทางรู้ได้เลยว่าเกิดอะไรขึ้นและมันมีความหมายอย่างไรถ้าคอมไพล์โปรแกรม 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 นั้นก็เสียหายในความหมายของนิยามภาษาแล้วผมก็พยายามจะสื่อเหมือนกันว่าพฤติกรรมที่ไม่กำหนดไว้ไม่ได้อยู่ในฮาร์ดแวร์ และไม่เกี่ยวกับ crash หรือข้อบกพร่อง ขณะเดียวกันก็อยากยกตัวอย่างให้คนที่ชอบพูดว่า “แต่เห็นมันทำงานดีนี่” เห็นว่าในความเป็นจริงไม่ใช่แบบนั้น
#pragma pack(push, 1)แปลว่าเราใช้ member pointer ไม่ได้เลยหรือ ถ้าสมาชิกนั้นไม่ได้ถูกจัดแนวโดยบังเอิญ?พฤติกรรมที่ไม่กำหนดไว้ชนิดนี้ถือว่าโอเค และแทบไม่มีใครมองว่าการเกิดบั๊กเพราะความต่างของฮาร์ดแวร์เป็นเรื่องใหญ่
แต่เมื่อเวลาผ่านไป การตีความแบบก้าวร้าวทำให้ 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++ ได้อย่างสมบูรณ์
ตัวอย่างเหล่านี้ดูจะไม่ใช่พฤติกรรมที่ไม่กำหนดไว้จริงๆ แต่เป็นกรณีที่อาจกลายเป็นพฤติกรรมที่ไม่กำหนดไว้ได้ขึ้นกับอินพุตหรือสถานการณ์มากกว่า
ถ้าจะตีความกว้างขนาดนั้น ทุกการเรียกฟังก์ชันก็เป็นพฤติกรรมที่ไม่กำหนดไว้ได้เหมือนกัน เพราะอาจใช้ stack space เกิน ในความหมายหนึ่งจริงๆ ภาษาทุกภาษาก็อาจพูดแบบนั้นได้
ใน C มีจุดหยาบที่น่าสนใจจริงๆ มากพออยู่แล้ว การใช้แนวหวือหวาแบบนี้อาจทำให้มือใหม่ไขว้เขวและยิ่งเป็นโทษมากกว่า
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
สุดท้ายแล้วคุณกำลังสร้าง ฐานบนทราย และนั่นคืออันตรายของพฤติกรรมที่ไม่กำหนดไว้
ปัญหาของพฤติกรรมที่ไม่กำหนดไว้ไม่ใช่ว่ามันอาจทำให้ crash บนบางสถาปัตยกรรม
ปัญหาจริงคือคอมไพเลอร์คาดหวังว่าโค้ดแบบนั้นจะไม่มีวันเกิดขึ้น ถึงอย่างนั้นถ้าคุณยังเขียนโค้ดที่มีพฤติกรรมที่ไม่กำหนดไว้ คอมไพเลอร์ โดยเฉพาะ optimizer ก็มีสิทธิ์แปลมันออกมาเป็นอะไรก็ได้ที่สะดวกต่อเส้นทางปกติของโปรแกรม และ “อะไรก็ได้” นั้นบางครั้งก็อาจคาดไม่ถึงมาก เช่นการลบทิ้งทั้งก้อนโค้ดใหญ่ๆ
ถ้ามี tail recursion อีกหน่อย ก็อาจกลายเป็นว่าใน debug build ไปไม่ถึงลูปไม่รู้จบ แต่พอเพิ่มระดับ optimization แล้วบั๊กถึงค่อยโผล่มา
ที่แย่กว่าคือโปรแกรมอาจเงียบๆ รันต่อด้วยค่ามั่วๆ หรือฟอร์แมตฮาร์ดดิสก์ หรือยกกุญแจอาณาจักรให้นักโจมตีไปเลยก็ได้
คนที่บอกว่าแค่ทำให้มันเป็นพฤติกรรมที่กำหนดไว้หรือไม่ระบุไว้ก็พอ มักพลาดประเด็นว่าหัวใจสำคัญคือคอมไพเลอร์สามารถลบส่วนใหญ่ของโปรแกรมออกได้
ถ้าคุณเขียนโค้ดที่กลายเป็นพฤติกรรมที่ไม่กำหนดไว้สำหรับอินพุตบางแบบ นั่นหมายความว่าสำหรับอินพุตนั้นคุณไม่ได้ตั้งใจให้โปรแกรมมีพฤติกรรมใดๆ เลย และคุณก็หวังให้คอมไพเลอร์ตัดเส้นทางนั้นทิ้งในการ optimize หรือทำสิ่งใดก็ตามที่ช่วยพฤติกรรมในกรณีที่กำหนดไว้
การใส่สตริง log ที่เข้าถึงได้ผ่านพฤติกรรมที่ไม่กำหนดไว้เท่านั้น แล้วเห็นว่ามันไม่เหลืออยู่ในไบนารี เป็นอะไรที่น่าพอใจไม่น้อย
เมื่อก่อนผมเคยเขียน analysis pass โดยตั้งสมมติฐานว่ามันจะรันเป็นลำดับสุดท้ายของ transformation pipeline และสมมติฐานนั้นจำเป็นต่อความถูกต้อง คิดว่าเมื่อไม่มี optimization ต่อจากนั้นก็ปลอดภัยแล้ว แต่ตอนนี้ไม่แน่ใจอีกต่อไป
ใช้ C มา 20 ปี แต่ในช่วง 6 เดือนหลังบน Hacker News ผมเห็นการพูดถึง พฤติกรรมที่ไม่กำหนดไว้ มากกว่าที่เคยเจอทั้งชีวิต
ในบทสนทนาจริงแทบไม่เคยโผล่มาเลย เขียนโค้ด ถ้าไม่เวิร์กก็ดีบัก แก้ หรือหาทางอ้อมก็พอ ไม่เข้าใจว่าทำไมเรื่องพฤติกรรมที่ไม่กำหนดไว้ของ C ถึงขึ้นหน้าแรกได้เรื่อยๆ
ยังมีคนกลุ่มน้อยที่เรียนวิทยาการคอมพิวเตอร์และมองว่าการพัฒนาหรือใช้ภาษาโปรแกรมใหม่ๆ เป็นเรื่องที่น่าสนใจที่สุดในโลก และบางคนก็ยังคิดแบบนั้นต่อไป
คนแบบนั้นย่อมสนใจแง่มุมการออกแบบภาษา และพฤติกรรมที่ไม่กำหนดไว้ของ C ก็อยู่ในขอบเขตนั้น เพียงแต่เดิมทีส่วนใหญ่ก็มีไว้เพื่อรองรับสถาปัตยกรรม CPU เก่าๆ โดยไม่เสียประสิทธิภาพ จึงมีด้านที่แทบไม่ใช่ “ทางเลือกการออกแบบ” พอๆ กับที่ล้อรถต้องกลม
ช่วงราว GCC 3.2 คอมไพเลอร์เริ่มใช้พฤติกรรมที่ไม่กำหนดไว้ในการ optimize แบบดุดันมากขึ้น จนเกิด “เรื่องฉาว” ที่เป็นที่รู้จักกันดีหลายกรณี และเพราะแบบนั้นคนจำนวนมากจึงค้างอยู่กับ GCC 2.95 กันนาน GCC 3.2 ออกในปี 2002
ทุกบริษัทคอยย้ำเรื่องความปลอดภัยและความเสี่ยงที่จะถูกเปิดโปงหรือติดข่าวอยู่ตลอด จนเรื่องเล่าต่อต้านความ “ไม่ปลอดภัย” ขยายใหญ่เกินเหตุ
โลกใหม่ก็เหมือนคนเมืองที่ไม่เคยเห็นธรรมชาติแบบดิบๆ แล้วกลัวเครื่องตัดหญ้า ใบมีดมันหมุนเหรอ? เป็นไปไม่ได้!
ถ้าเป้าหมายจริงคือระบบ embedded เล็กๆ บนเสาสื่อสารกลางทุ่ง คำว่า “มันทำงานบนเครื่องฉัน” ก็ไม่มีประโยชน์ แน่นอนว่าคนส่วนใหญ่ไม่ได้ทำงานแบบนั้น และนักพัฒนาส่วนใหญ่ที่นี่ก็น่าจะเป็นเว็บดีเวลอปเปอร์ แต่ถึงไม่ได้เจอเอง มันก็ยังเป็นประเด็นที่น่าสนใจ และบางทีกลับยิ่งน่าสนใจเพราะไม่ใช่สิ่งที่ต้องเผชิญตรงๆ
คอมไพเลอร์อาจมีบั๊กที่ทำให้พฤติกรรมตามสเปกไม่ทำงานจริง มีส่วนขยายมากมายที่ไม่มีคู่ตรงในมาตรฐาน และแม้สิ่งที่มาตรฐานบอกว่าไม่กำหนดไว้ implementation แต่ละตัวก็อาจกำหนดความหมายที่ใช้งานได้จริงให้มัน
โดยรวมผมเห็นด้วยกับบทนำ แต่ตัวอย่างไม่ดี และทั้งบทความดูเหมือนห่อหุ้มมาเพื่อขาย การเขียนโค้ดด้วย LLM
มันให้ความรู้สึกเหมือนคนที่อยากเขียนโค้ดอะไรก็ได้ตามใจ แล้วคาดหวังให้มันทำงานเหมือนกันในทุกสภาพแวดล้อม ถ้าทำภาษาแบบนั้นจริง ข้อดีเรื่องการเขียนให้เข้ากับแพลตฟอร์มตามที่ต้องการก็จะหายไป
โค้ด C++ ในบทความนี้บางส่วนไม่ได้ถือว่าเป็นสำนวนมาตรฐานมานานเกิน 10 ปีแล้ว และทุกวันนี้น่าจะถูกมองว่าเป็น code smell
ภาษานี้วิวัฒน์ไปมากจนต่างจากตอนแรกเริ่มอย่างชัดเจน ทันทีที่เห็น raw pointer และการเข้าถึง pointer โดยตรงเต็มไปหมด ก็ชัดเจนว่าบางส่วนของบทความควรอ่านแบบมีตัวกรอง
อีกปัญหาที่ชัดเจนคือมุมมองที่จับ C กับ C++ มามัดรวมเหมือนเป็นภาษาเดียวกัน ทั้งที่ปัจจุบันสองภาษานี้ห่างกันไปมากแล้ว
std::atomicไม่ใช่atomic_intจริงๆถ้าเข้าใจพฤติกรรมที่ไม่กำหนดไว้ใน C แบบนี้ จะถูกไหม?
โปรแกรม P มีเซตอินพุต A ที่ไม่ทำให้เกิดพฤติกรรมที่ไม่กำหนดไว้ และมีเซตส่วนเติมเต็ม B ที่ทำให้เกิดมัน
คอมไพเลอร์ที่ถูกต้องจะคอมไพล์ P เป็นไฟล์ปฏิบัติการ P' สำหรับทุกอินพุตใน A, P' ต้องทำงานเหมือน P
แต่สำหรับอินพุตใดๆ ใน B พฤติกรรมของ P' นั้น ไม่มีข้อกำหนดใดๆ เลย
ตัวอย่างรูปธรรมของพฤติกรรมที่ไม่กำหนดไว้ซึ่งเกิดจาก pointer ไม่จัดแนว: https://pzemtsov.github.io/2016/11/06/bug-story-alignment-on...