just foo จะประมวลผล "\n" ใน justfile และเขียน ไบต์เดียว 0x0A ลงในไฟล์ bar โดยบทความนี้ไล่ตามทีละขั้นว่าค่านี้มาจากไหน
- parser ของ
just ที่เขียนด้วย Rust ถูกทำไว้ให้เมื่อเจอ escape \n จะใส่ค่าของ Rust character escape '\n' ลงในสตริง
- ปัจจุบัน
rustc ก็เขียนด้วย Rust เช่นกัน ทำให้การไล่ตามต้องย้อนต่อไปยัง lexer ของ rustc เอง แต่ใน implementation ภาษา OCaml ก่อนยุค self-hosted มีเบาะแสที่ตรงกว่าซ่อนอยู่
rustc เวอร์ชันแรก ๆ ที่เขียนด้วย OCaml จัดการ character escape n ด้วย Char.code '\n' และ OCaml lexer ก็นิยามสิ่งนี้เป็น '\010'
- เนื่องจาก
0x0A คือ 10 ดังนั้น \n ใน justfile จึงเป็นค่าที่ถูกส่งต่อมาผ่านหลายรุ่นของคอมไพเลอร์ Rust และจุดเริ่มต้นย้อนกลับไปถึงไบต์ที่ OCaml compiler ประเมินจาก '\010' แล้วใส่ไว้ในไบนารี rustc รุ่นแรก
\n ใน justfile กลายเป็น 0x0A ได้อย่างไร
- เมื่อรัน
just foo justfile ต่อไปนี้จะเขียน ไบต์เดียว 0x0A ลงในไฟล์ bar
x := "\n"
foo:
printf '{{x}}' > bar
just เขียนด้วย Rust และฟังก์ชัน cook_string ใน parser จะเปลี่ยน string token ของ just ที่มี escape sequence ให้เป็นสตริง UTF-8
- ถ้าหลัง backslash เป็น
n ฟังก์ชันนี้จะรัน cooked.push('\n')
State::Backslash => {
match c {
'n' => cooked.push('\n'),
…
}
}
- ในขั้นนี้
just ปล่อยให้ rustc เป็นผู้ประเมินผลของ Rust character escape '\n' แล้วใส่ค่านั้นลงในสตริง
เส้นทางที่ย้อนกลับไปถึง rustc และ OCaml
- การจัดการ escape ของ
rustc อยู่ในฟังก์ชัน scan_escape ของ lexer และเมื่อเจอ n ก็จะจัดการเป็น Rust character escape '\n' อีกครั้ง
let res: char = match chars.next().ok_or(EscapeError::LoneSlash)? {
…
'n' => '\n',
…
};
- ปัจจุบัน
rustc เขียนด้วย Rust และคอมไพล์ตัวเอง ดังนั้นกระบวนการตามหาความหมายของ '\n' จึงวนกลับจาก rustc ไป rustc อีกที
- อย่างไรก็ตาม
rustc ไม่ได้เขียนด้วย Rust มาตั้งแต่แรก และเวอร์ชันเริ่มต้นก่อนยุค self-hosted นั้นเขียนด้วย OCaml
- lexer ของ
rustc เวอร์ชัน OCaml จัดการ character escape n แบบนี้
| 'n' { end_char (Char.code '\n') lexbuf }
- ตรงนี้ก็ยังใช้ OCaml character escape
'\n' แต่ใน OCaml lexer มีคำนิยามที่ตรงกว่าอยู่
let char_for_backslash = function
'n' -> '\010'
- เมื่อ OCaml compiler เจอ
\n มันจะใส่ผลลัพธ์ที่ประเมินได้จาก decimal character escape '\010' และเพราะ 0x0A คือ 10 จึงตรงกับค่าไบต์ที่ตามหา
- ดังนั้น
\n ใน justfile จึงเชื่อมโยงไปถึงไบต์ 0x0A ในรูปแบบใดรูปแบบหนึ่งภายในไบนารี just และไบต์นั้นถูกใส่มาโดย rustc ซึ่ง rustc รุ่นก่อนหน้าก็ส่งต่อค่าเดียวกันนี้ข้ามรุ่นมาเรื่อย ๆ
- ปัจจุบัน
rustc อยู่ที่ 1.81.0 และถ้านับเฉพาะหลัง rustc 1.0 กระบวนการนี้ก็เกิดขึ้นอย่างน้อย 81 ครั้งแล้ว และถ้านับก่อน 1.0 ด้วยก็อาจมากกว่านั้น
- จุดเริ่มต้นของการไล่ตามนี้คือจุดที่ OCaml compiler ประเมิน decimal character escape
'\010' แล้วใส่ไบต์ 0x0A ลงในไบนารี rustc รุ่นแรก
1 ความคิดเห็น
ความคิดเห็นจาก Hacker News
ครั้งแรกที่ผมอ่านแนวคิดนี้ ไม่ใช่ในบริบทของ trusting trust แบบทั่วไป แต่เป็นเรื่องของ อักขระขึ้นบรรทัดใหม่ โดยอยู่ในวันที่ 42 ของ https://www.sigbus.info/how-i-wrote-a-self-hosting-c-compile...
มันน่าสนใจตรงที่ หากจะตีความ
"\n"ภายใน string literal ให้เป็นอักขระขึ้นบรรทัดใหม่จริง ๆ ในซอร์สโค้ดเองกลับไม่มีข้อมูลรหัส ASCII นั้นอยู่ แต่ถูกส่งต่อมาจากคอมไพเลอร์ก่อนหน้าที่ใช้คอมไพล์คอมไพเลอร์ตัวนั้นท้ายที่สุด อักขระขึ้นบรรทัดใหม่ของคอมไพเลอร์นั้นอาจสาวย้อนกลับไปได้ถึง GCC ที่ใช้คอมไพล์มัน
'\n'ขึ้นกับคอมไพเลอร์ที่ใช้คอมไพล์มันเหมือนกัน แต่ความจริงคือมันฮาร์ดโค้ดค่าตัวเลขของ escape ไว้[1] และดูเหมือนจะมีแค่ตัวเลือกสำหรับระบบ ASCII กับ EBCDIC[1] https://github.com/gcc-mirror/gcc/blob/8a4a967a77cb937a2df45...
ต้นฉบับที่ผู้เขียนนึกถึงน่าจะเป็นปาฐกถารางวัลทัวริงของ Ken Thompson เรื่อง Reflections on Trusting Trust
มีงานศึกษา บทความวิชาการ และคำอธิบายเกี่ยวกับ quine อยู่ค่อนข้างมาก ผู้เขียนอาจเคยอ่านอะไรในสายนี้มาก็ได้
https://en.wikipedia.org/wiki/Quine_(computing)
https://www.teamten.com/lawrence/writings/coding-machines/
ผมเองก็จำได้ลาง ๆ ว่าเมื่อหลายปีก่อนเคยเห็นเกร็ดความรู้แนวเดียวกันนี้ที่พูดถึง
'\n'ใน Rust โดยตรง แต่ตอนนี้หาแหล่งที่มาไม่เจอแล้วน่าสนใจที่ผ่านไป 10 ชั่วโมงแล้วยังไม่มีเธรดไหนพูดถึง EBCDIC
คอมไพเลอร์ C ยุคแรก ๆ ก็มีอยู่บนระบบที่ไม่ใช่ ASCII ซึ่งไม่ได้แมป
\nไปเป็น “line feed” ค่าเลขฐานสิบ 10 ดังนั้นทุกทฤษฎีที่คุยกันอยู่ตรงนี้ควรต้องอธิบายข้อเท็จจริงนั้นได้https://en.wikipedia.org/wiki/EBCDIC
ยิ่งไปกว่านั้น EBCDIC ยังมีทั้งอักขระ NextLine และ LineFeed อย่างชัดเจน
ใน ASCII,
for (c = 'A'; c <= 'Z'; ++c) putchar(c);จะพิมพ์ A ถึง Z แต่ใน EBCDIC มีช่องว่างระหว่างตัวอักษร จึงจะพิมพ์ออกมาทั้งหมด 41 ตัว รวมอักขระที่ไม่ได้กำหนดไว้ด้วยลำดับเรียงของ EBCDIC ก็ตรงข้ามกับ ASCII โดยตัวพิมพ์เล็กมาก่อนตัวพิมพ์ใหญ่ และตัวอักษรมาก่อนตัวเลข
มาตรฐาน C รับประกันเกี่ยวกับการเข้ารหัสอักขระเพียงอย่างเดียวว่า
'0'ถึง'9'จะถูกแมปแบบต่อเนื่องในลำดับเพิ่มขึ้นในทางทฤษฎี โปรแกรม C ง่าย ๆ ควรถูกคอมไพล์จากซอร์สเดียวกันได้ทั้งบน ASCII และ EBCDIC แล้วให้ผลลัพธ์เหมือนกัน แต่ในทางปฏิบัติมีกับดักอยู่มาก
ระบบ EBCDIC ยุคแรก ๆ (MVS, VM/CMS, OS/400, DOS/VSE ฯลฯ) เก็บข้อความเป็น ไฟล์แบบระเบียน มากกว่าจะเป็นไฟล์แบบ byte stream โดยแต่ละบรรทัดคือระเบียนความยาวคงที่หรือแปรผัน
ในระเบียนความยาวคงที่ จะประกาศความยาวระเบียนอย่าง 80 หรือ 132 ตอนสร้างไฟล์ และบรรทัดที่สั้นกว่าก็มักถูกเติมด้วยอักขระช่องว่าง EBCDIC 0x40 ส่วนบรรทัดที่ยาวเกินจะถูกตัดหรือใช้เครื่องหมายต่อบรรทัด
ระเบียนความยาวแปรผันจะมี record descriptor word (RDW) นำหน้าเพื่อเก็บความยาว แต่พบไม่บ่อยในไฟล์ข้อความและซอร์สโค้ด โดยทั่วไปมักใช้ระเบียนความยาวคงที่
เพราะแบบนี้ ต่อให้มี NEL อยู่ ก็มักไม่ถูกใช้ในไฟล์บนดิสก์
อักขระขึ้นบรรทัดใหม่อย่าง NEL เป็น สัญญาณในแบนด์ สำหรับบอกขอบเขตบรรทัด/ระเบียน แต่ระบบไฟล์แบบระเบียนบอกขอบเขตนั้น นอกแบนด์ อยู่แล้ว
ผมไม่แน่ใจว่า stdio ถูกอิมพลีเมนต์อย่างไรแน่ในไลบรารีรันไทม์ของคอมไพเลอร์ C บน EBCDIC แต่เดาว่าภายในมันคงแมป
\nไปเป็น NEL แล้วให้ชั้น stdio ปฏิบัติต่อมันเป็นตัวคั่นระเบียน เขียนแต่ละระเบียนด้วย system call แยกกัน และเติม padding หากจำเป็นต่อมาระบบปฏิบัติการเหล่านี้ส่วนใหญ่ก็มีซับซิสเต็มที่เข้ากันได้กับ POSIX ทำให้มีไฟล์แบบ byte stream แบบเดียวกับระบบกระแสหลักด้วย
ระบบของ IBM โดยทั่วไปยังรองรับการติดแท็ก code page ให้ไฟล์ ทำให้ไฟล์หนึ่งสามารถปะปน EBCDIC และ ASCII ได้ และระบบปฏิบัติการจะช่วยแปลงให้ที่ชั้น I/O
ด้วยเหตุนี้ แอปพลิเคชันที่รันไทม์ใช้ EBCDIC ก็สามารถอ่านไฟล์ ASCII ได้เหมือนเป็น EBCDIC โดยไม่ต้องเรียก API แปลงแยกต่างหากหรือระบุอะไรอย่างชัดเจน
แอปพลิเคชันใหม่ ๆ หันไปใช้ระบบไฟล์แบบ POSIX มากขึ้นเรื่อย ๆ แต่แอปพลิเคชันเก่ายังคงเก็บข้อมูล ไฟล์ข้อความ และแม้แต่ซอร์สโค้ดไว้ในระบบไฟล์แบบระเบียนดั้งเดิมอยู่บ่อยครั้ง
จากที่ผมเข้าใจ สภาพแวดล้อมจริงที่พบ EBCDIC NEL บ่อยที่สุดน่าจะเป็น การเชื่อมต่อเทอร์มินัลแบบ line mode ของฮาร์ดคอปปี้เทอร์มินัลอย่าง IBM 2741 และ IBM 3767
เป็นบทความที่น่าสนใจมาก
สำหรับผม มันเหมือนส่วนผสมระหว่าง literate programming กับบทกวี
มันคือบทความที่พยายามอธิบายว่าตอนไปรัน
just fooแล้วได้ไบต์ 0x0A ออกมานั้น ไบต์ตัวเดียวกันนี้อาจเดินทางผ่านวัฏจักรของการสร้างโค้ดมาหลายร้อยครั้งแล้วเมื่อนานมาแล้ว มีใครบางคนเข้ารหัสข้อมูลนี้ไว้ในคอมไพเลอร์ OCaml ไม่ทางใดก็ทางหนึ่ง และหลายปีต่อมา ข้อมูล 0x0A บนคอมพิวเตอร์ของผมก็ยังถูกเก็บรักษาไว้เพราะประวัติศาสตร์นั้น
แต่ปรากฏการณ์นี้ถูกอธิบายด้วยโค้ดจริง
แน่นอนว่าโค้ดนั้นไม่ใช่ประเด็นหลักในตัวมันเอง และก็คงไม่มีใครจะไปรันหรือคอมไพล์โค้ดชิ้นนี้จริง ๆ แต่เป็นโค้ดที่วางไว้เพื่อช่วยให้คนตามการอภิปรายได้
สงสัยว่า clang มีคุณสมบัติแบบเดียวกันไหม ปรากฏว่าใน
lib/Lex/LiteralSupport.cppมีการ ฮาร์ดโค้ดเป็น 10 ไว้อย่างชัดเจนProcessCharEscapeจะพาร์สลำดับ escape มาตรฐานของ C และจัดการในลักษณะอย่างcase 'n': ResultChar = 10; break;gcc/libcpp/charset.ccและเลือกอย่างใดอย่างหนึ่งระหว่าง ASCII หรือ EBCDICใส่ค่าของ
\a \b \e \f \n \r \t \vลงในอาร์เรย์charconstsโดยถ้าเป็น ASCII จะใช้{ 7, 8, 27, 12, 10, 13, 9, 11 }และถ้าเป็น EBCDIC จะใช้{ 47, 22, 39, 12, 21, 13, 5, 11 }จากนั้นจัดการด้วยcase 'n': c = charconsts[4]; break;จำได้ว่าเคยอ่านบทความคล้ายกันเกี่ยวกับ C compiler ตัวหนึ่ง
สุดท้ายพบว่าที่เดียวที่ค่า 0x10 ปรากฏขึ้นคือในไบนารีของคอมไพเลอร์ และในซอร์สโค้ดมีเพียงรูปแบบอย่าง
"\\n" -> "\n"เท่านั้นเป็นเรื่องที่เกินระดับความเข้าใจของฉัน
ไม่เข้าใจว่าทำไมการจะหาว่าทำไม
\nถึงถูกเข้ารหัสเป็นไบต์ที่มีค่า 10 ถึงต้องเดินทางยาวขนาดนี้มันดูเหมือนเป็นเรื่องที่ควรจะชัดเจนอยู่แล้ว และทั้งผู้เขียนกับคอมเมนต์ก็ไม่ได้อธิบาย เลยทำให้ฉันรู้สึกเหมือนตัวเองโง่
ถ้าคุณเขียน parser แล้วพาร์สบรรทัดใหม่เป็นลำดับ escape
\nค่า 10 มาจากไหนถ้าคุณพาร์สบรรทัดใหม่เป็น integer literal
10ค่าไบนารีจริง1010มาจากไหนเป้าหมายสูงสุดของ thought experiment นี้คือการเปลี่ยนมุมมองที่มีต่อคอมไพเลอร์ แบบเดียวกับงานบรรยายชื่อดัง Reflections On Trusting Trust
กล่าวคือ คอมไพเลอร์ไม่ได้เป็นแค่สิ่งที่สร้างโปรแกรมออกมาเท่านั้น แต่ยังเป็นอินพุตของโปรแกรมด้วย
ตัวคอมไพเลอร์เองก็เป็นโปรแกรม ดังนั้นคอมไพเลอร์ที่ใช้สร้างมันจึงเป็นอินพุตของคอมไพเลอร์ปัจจุบัน และโดยการถ่ายทอดต่อก็กลายเป็นอินพุตของโปรแกรมของฉันด้วย
และสิ่งนี้ก็ย้อนต่อไปได้เรื่อย ๆ ถึงคอมไพเลอร์ของคอมไพเลอร์ของคอมไพเลอร์ และชั้นที่อยู่เหนือขึ้นไป
'\n'ถูกแมปกับอะไรจริง ๆเป็นตัวอย่างในโลกจริงที่น่าสนใจของการแฮ็กแบบ Ken Thompson
ทำไมไม่ใช่ 9 หรือ 11
โค้ดพูดเพียงว่า “ถ้าเห็นสตริงอักขระขึ้นบรรทัดใหม่ ก็ให้แสดงอักขระขึ้นบรรทัดใหม่”
แล้วคอมไพเลอร์รู้ได้อย่างไรว่าอักขระขึ้นบรรทัดใหม่คืออะไร
โค้ดของคอมไพเลอร์ตัวนั้นเองก็พูดซ้ำแค่ว่า “ถ้าเห็นสตริงอักขระขึ้นบรรทัดใหม่ ก็ให้จัดการเป็นอักขระขึ้นบรรทัดใหม่”
มนุษย์ค้นหา “C string escape codes” ก็ได้ แต่ตารางนั้นไม่มีอยู่ตรงไหนในคอมไพเลอร์เลย
ถ้า C 2025 นิยาม Start of Heading เป็น
\hแล้ว'h' => cooked.push('\h')จะเริ่มทำงานได้อย่างอัศจรรย์เองหรือแล้วจะรู้ได้อย่างไร
ชัดเจนว่าในบางจุดต้องมีใครสักคนเขียนแมปปิง
'n' => 10ลงไปด้วยมือ แต่คำถามคือมันอยู่ตรงไหนอาจเพราะ C เลยทำให้ฉันคิดมาตลอดว่า
\0???คือ octal escapeดังนั้นในหัวฉัน
\012คือ\x0aหรือ0x0aและ\010คือ0x08เลยรู้สึกว่าบทความนี้ค่อนข้างทำให้งง
บางที OCaml อาจไม่ได้ใช้ octal escape แต่ใช้ decimal escape และ
\09อาจเป็นอักขระแท็บก็ได้แต่ฉันยังไม่ได้ไปตรวจดู
backslash escape เป็นเชิงสัญลักษณ์/ช่วยจำ ดังนั้น
\nคือ "[Ne]wline",\rคือ "carriage [R]eturn",\tคือ "[T]ab" อะไรทำนองนั้นสิ่งที่ควรมองแทนคือธรรมเนียมของ control characters อย่าง
^C(interrupt),^G(bell),^M(carriage return)พวกนี้อยู่ในชุดอักขระควบคุม C0 โดย
^Cคือ\0x3,^Gคือ\0x7,^Mคือ\0xDเป็นวิธีชาญฉลาดที่ย้อนกลับไปได้ถึงก่อนยุคยูนิกซ์ โดยเทอร์มินัลจะแสดงอักขระ C0 ของ ASCII ที่มองไม่เห็นด้วยการเติม
^ข้างหน้า แล้วนำตัวอักษรนั้นไป AND กับ 0x40 เพื่อย้ายให้มาอยู่ในช่วงที่มองเห็นได้ก่อนแสดงผลถ้าอยากไล่ดูให้เข้าใจ ลองเปิดตาราง ASCII อย่าง https://www.asciitable.com
อักขระควบคุมแต่ละตัวจะถูกแมปกับ
^ตัวอักษรที่อยู่ห่างออกไปสองช่องในตารางนั้นจึงเกิดรูปแบบเทียบกันที่จำยาก เช่น
\0ถูกแสดงเป็น^@อย่างประหลาด และปุ่ม Esc กลายเป็น^[นี่ไม่ใช่การตัดสินใจของผู้เขียนยูนิกซ์ แต่เป็น ผลลัพธ์ของระบบเลขของ ASCII
ในไวยากรณ์สตริงที่ฉันรู้จักมีประมาณ OCaml, Lua, DNS เท่านั้น
เพราะการใช้ตัวพิมพ์ใหญ่ที่ผิด ฉันเลยนึกว่ามีลำดับ escape ที่ไม่ค่อยมีใครรู้จักอีกตัวคือ
\Nซึ่งต่างจาก\nตอนแรกคิดว่ามันอาจใช้จับคู่กับอักขระอะไรก็ได้ที่ไม่ใช่ newline แต่ไม่ใช่ ที่เห็นแบบนั้นเป็นเพราะการแสดง small caps ในบทความต้นฉบับ
\nจริง ๆ แต่กฎ CSS นี้ทำให้มันแสดงออกมาแบบนั้น.title { font-variant: small-caps; }\Nหลายระบบใช้
\Nเป็น NULL ใน CSV หรือฟอร์แมตคล้ายกัน เพื่อแยกจากสตริงว่างเพราะอย่างนั้นฉันเลยคิดว่าบทความนี้น่าจะพูดถึงเรื่องนั้น
\Nใช้แทรกอักขระยูนิโค้ดด้วยชื่อ
ตัวอย่างเช่น
'\N{PILE OF POO}'คือสตริงยูนิโค้ดที่เป็นอีโมจิกองอึหนึ่งตัวมันอธิบายตัวเองได้มากกว่าการใช้ลำดับเลขฐานสิบหกอย่าง
\uหรือ\Uถึงอย่างนั้นก็ยังอ่านสนุกดี
“บทความอื่น” ที่เป็นแรงบันดาลใจให้บทความนี้ น่าจะเป็นอันนี้
https://research.swtch.com/nih
Running the "Reflections on Trusting Trust" Compiler - https://news.ycombinator.com/item?id=38020792 - ตุลาคม 2023, ความคิดเห็น 67 รายการ