2 คะแนน โดย GN⁺ 2024-04-04 | 1 ความคิดเห็น | แชร์ทาง WhatsApp
  • คอมพิวเตอร์ยุคแรกต้องทำให้การเรียกฟังก์ชันทำงานได้ แม้ไม่มีสแต็กและฮีป และคอมไพเลอร์จัดการสถานะการเรียกด้วยตัวแปรโกลบอลแฝงที่สอดคล้องกับพารามิเตอร์ ที่อยู่สำหรับคืนกลับ และตัวแปรโลคัล
  • ผู้เรียกจะเก็บอาร์กิวเมนต์ ใส่ตำแหน่งที่จะกลับไปไว้ใน ตัวแปรที่อยู่สำหรับคืนกลับ แล้วกระโดดไปยังจุดเริ่มต้นของฟังก์ชัน จากนั้นฟังก์ชันคำนวณเสร็จแล้วกระโดดกลับไปยังที่อยู่ที่เก็บไว้
  • แม้ตัวแปรโลคัลในเชิงตรรกะจะดูเหมือนเป็นของฟังก์ชัน แต่จริง ๆ แล้วใช้พื้นที่เก็บข้อมูลแบบโกลบอล ดังนั้นภายนอกดูเหมือนฟังก์ชัน แต่การทำงานภายในใกล้เคียงกับหน่วยความจำแบบคงที่และ goto
  • ABI และโปรเซสเซอร์บางส่วนปรับให้การส่งอาร์กิวเมนต์และการจัดการที่อยู่สำหรับคืนกลับเร็วขึ้นด้วย รีจิสเตอร์ หรือ branch with link แต่ข้อจำกัดพื้นฐานยังคงอยู่
  • เนื่องจากที่อยู่สำหรับคืนกลับของฟังก์ชันเดียวกันจะถูกการเรียกใหม่เขียนทับ จึงไม่สามารถเรียกแบบรีเคอร์ซีฟได้ และภาษาสมัยนั้นรับมือด้วยการห้ามรีเคอร์ชันหรืออนุญาตเฉพาะเมื่อระบุอย่างชัดเจน

วิธีประกอบการเรียกฟังก์ชันโดยไม่มีสแต็ก

  • ในสภาพแวดล้อมคอมพิวเตอร์ยุคแรก ไม่มี สแต็ก หรือ ฮีป อย่างที่เราถือว่าเป็นเรื่องปกติในปัจจุบัน
  • การจัดสรรหน่วยความจำแบบไดนามิกที่ไม่มีฮีปสามารถแทนด้วยบัฟเฟอร์ขนาดคงที่ได้
    • แม้เมื่อต้องจัดการข้อมูลขนาดแปรผัน ก็จองบัฟเฟอร์คงที่ที่ใหญ่พอไว้ล่วงหน้า
    • หากข้อมูลที่ร้องขอเกินความจุของบัฟเฟอร์ โปรแกรมจะจบการทำงานด้วยข้อผิดพลาดร้ายแรง
    • การใช้งานที่เป็นมิตรกว่านั้นเปิดให้ตั้งค่าความจุสูงสุดตอนคอมไพล์ได้
    • การใช้งานที่ซับซ้อนกว่านั้นวางตัวจัดสรรแบบกำหนดเองบนบัฟเฟอร์คงที่ และทำให้ใช้งานได้คล้าย allocate และ free

ข้อตกลงการเรียกที่อิงตัวแปรโกลบอลแฝง

  • เพื่อทำให้การเรียกฟังก์ชันทำงานได้โดยไม่มีสแต็ก คอมไพเลอร์จะนิยาม ตัวแปรโกลบอลแฝง หลายตัวสำหรับแต่ละฟังก์ชัน
    • ตัวแปรโกลบอลสำหรับพารามิเตอร์อินพุตแต่ละตัว
    • ตัวแปรโกลบอลที่เก็บที่อยู่สำหรับคืนกลับของฟังก์ชัน
    • ตัวแปรโกลบอลที่สอดคล้องกับตัวแปรโลคัล
  • โค้ดฝั่งผู้เรียกจะทำงานตามลำดับต่อไปนี้
    • เก็บค่าพารามิเตอร์ลงในตัวแปรโกลบอลแฝงที่เกี่ยวข้อง
    • บันทึกตำแหน่งที่จะกลับไปไว้ในตัวแปรที่อยู่สำหรับคืนกลับของฟังก์ชัน
    • กระโดดด้วย goto ไปยังตำแหน่งเริ่มต้นของฟังก์ชัน
  • ฟังก์ชันจะอ่านและเขียนทั้งพารามิเตอร์และตัวแปรโลคัลจากตัวแปรโกลบอลแฝง
  • เมื่อทำงานเสร็จ จะใส่ค่าที่คืนกลับลงในรีจิสเตอร์ค่าคืนกลับ แล้วกระโดดไปยังที่อยู่ที่เก็บไว้ในตัวแปรที่อยู่สำหรับคืนกลับของฟังก์ชัน

ตัวอย่างโค้ดคล้าย C ที่ถูกแปลงเป็นโค้ดแบบ goto

  • ฟังก์ชันตัวอย่าง add_two_values(int a, int b) อาจถูกแปลงเป็นพื้นที่เก็บข้อมูลต่อไปนี้เมื่อไม่มีสแต็ก
    • a2v_a, a2v_b เป็นตัวแปรโกลบอลสำหรับเก็บอาร์กิวเมนต์
    • a2v_c เป็นตัวแปรโกลบอลที่สอดคล้องกับตัวแปรโลคัล c
    • a2v_retaddr เป็นตัวแปรโกลบอลสำหรับเก็บที่อยู่ที่จะกลับไป
  • ผู้เรียก sample() จะเก็บ 31415 และ 2718 ลงในตัวแปรโกลบอลของอาร์กิวเมนต์แต่ละตัว
  • จากนั้นใส่ตำแหน่ง resume ลงใน a2v_retaddr แล้วกระโดดไปยัง add_two_values
  • add_two_values เก็บผลลัพธ์การคำนวณไว้ใน return_value_register แล้วกลับไปยัง a2v_retaddr
  • เมื่อกลับมาที่ตำแหน่ง resume ผู้เรียกจะเก็บค่าจากรีจิสเตอร์ค่าคืนกลับลงใน sample_x

การปรับให้เหมาะสมด้วยรีจิสเตอร์และ branch with link

  • โครงสร้างเดียวกันสามารถทำให้เร็วขึ้นในระดับ ABI ด้วย การส่งผ่านรีจิสเตอร์
  • โปรเซสเซอร์จำนวนมากมี link register พิเศษและคำสั่ง branch with link
    • branch with link จะบันทึกที่อยู่ของคำสั่งถัดจากคำสั่ง branch ลงใน link register โดยอัตโนมัติ
    • ผู้เรียกสามารถใส่อาร์กิวเมนต์สองตัวแรกลงใน argument_register_1, argument_register_2
    • ฟังก์ชันที่ถูกเรียกสามารถย้ายค่าจากรีจิสเตอร์เหล่านี้ไปยังตัวแปรโกลบอลแฝงของตัวเองเพื่อใช้งานได้
  • ที่อยู่สำหรับคืนกลับก็สามารถบันทึกจาก link_register ไปยังตัวแปรที่อยู่สำหรับคืนกลับของฟังก์ชันได้
  • การปรับให้เหมาะสมนี้ยังคงโครงสร้างพื้นฐานที่สามารถเรียกและคืนกลับได้โดยไม่มีสแต็กไว้

เหตุผลที่รีเคอร์ชันถูกปิดกั้น

  • ข้อจำกัดหลักของวิธีเรียกนี้คือ ไม่สามารถเรียกแบบรีเคอร์ซีฟได้
  • เมื่อเกิดการเรียกแบบรีเคอร์ซีฟ ตัวแปรที่อยู่สำหรับคืนกลับของฟังก์ชันเดียวกันจะถูกเขียนทับด้วยที่อยู่สำหรับคืนกลับของการเรียกใหม่
  • เมื่อการเรียกชั้นนอกจบลง ตำแหน่งเดิมที่ควรกลับไปจะหายไป ทำให้กระโดดไปยังตำแหน่งที่ผิด
  • ภาษาการเขียนโปรแกรมในเวลานั้นหลีกเลี่ยงปัญหานี้ด้วยการไม่รองรับรีเคอร์ชัน
  • FORTRAN ในตอนแรกยังไม่รองรับแม้แต่ซับรูทีน และซับรูทีนถูกเพิ่มเข้ามาในปี 1958
  • การรองรับรีเคอร์ชันใน FORTRAN กลายเป็นมาตรฐานในปี 1991 และแม้ในตอนนั้นก็ยังต้องระบุซับรูทีนเป็น RECURSIVE อย่างชัดเจน

โค้ดที่แก้ไขตัวเองและคำสั่งซับรูทีนของโปรเซสเซอร์ยุคแรก

  • คอมไพเลอร์บางตัวใช้ โค้ดที่แก้ไขตัวเอง อย่างแยบยลกว่า
    • ฟิลด์ที่อยู่ภายในคำสั่งกระโดดท้ายฟังก์ชันทำหน้าที่เสมือนตัวแปรที่อยู่สำหรับคืนกลับ
  • วิธีนี้ไม่ใช่แค่ทริกง่าย ๆ แต่อาจเป็นความจำเป็นในทางปฏิบัติ
    • โปรเซสเซอร์บางรุ่นอาจไม่รองรับ indirect jump
  • หลังจากความเป็นประโยชน์ของซับรูทีนได้รับการยอมรับ โปรเซสเซอร์หลายรุ่นก็เพิ่มคำสั่งเรียกเฉพาะทาง
    • บันทึกที่อยู่สำหรับคืนกลับไว้ใน word แรกของซับรูทีน
    • การทำงานจริงเริ่มจาก word ที่สอง
    • เมื่อคืนกลับ จะทำ indirect jump ผ่านลาเบลเริ่มต้นของซับรูทีน
  • ในแอสเซมบลีตัวอย่าง bsr add_two_values จะบันทึกที่อยู่สำหรับคืนกลับไว้ใน word แรกของ add_two_values แล้วเริ่มรันจากคำสั่งจริงถัดจาก nop ที่ใช้เป็นตัวเสียสละ

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

 
GN⁺ 2024-04-04
ความคิดเห็นจาก Hacker News
  • ในหัวข้อนี้ The Art of Computer Programming ดีมากจริง ๆ
    มองเผิน ๆ อาจดูเก่า แต่มีอัลกอริทึมจำนวนมหาศาลสำหรับจัดการอาร์เรย์หรือโครงสร้างข้อมูลที่เปลี่ยนแปลงแบบไดนามิกในยุคก่อนมี heap หรือ stack
    หนังสือค่อย ๆ ไล่ไปถึง garbage collection และการ implement รายการของ Lisp และอัดแน่นด้วยความรู้แบบสารานุกรมอย่างที่คาดหวังจาก Knuth
    ตัวอย่างที่ชอบเป็นพิเศษคือวิธีให้อาร์เรย์สองชุดแชร์พื้นที่เดียวกันแบบไดนามิก ให้อาร์เรย์หนึ่งโตไปข้างหน้าจาก location#0 และให้อาร์เรย์ที่สองโตย้อนกลับจาก location#End ก็จะแบ่งใช้พื้นที่ที่จัดสรรแบบ static ได้อย่างมีประสิทธิภาพ
    สามารถขยายไปเป็นอาร์เรย์จำนวนเท่าใดก็ได้ แต่ถึงจุดนั้นแค่ใช้ Malloc กับ Realloc น่าจะดีกว่า และเทคนิคนั้นเองก็ใกล้เคียงกับรูทีนที่คล้าย malloc อยู่มาก

    • โปรแกรมประมวลผลคำบางตัวบนคอมพิวเตอร์ 8 บิตทำงานแบบนี้ เอกสารจะกิน RAM ทั้งหมดที่มี ข้อความก่อนเคอร์เซอร์อยู่ที่ช่วงต้นของ RAM ส่วนข้อความหลังเคอร์เซอร์อยู่ที่ช่วงท้ายของ RAM
      การแทรกและการวางไม่ต้องเลื่อนข้อมูลออกไป แต่การนำทางจำเป็นต้องทำ ถึงอย่างนั้นก็ทำงานได้ดี
    • ในสถาปัตยกรรมชุดคำสั่งและ ABI ส่วนใหญ่ stack จะเติบโตลงจากที่อยู่สูง ดังนั้นในระบบหน่วยความจำขนาดเล็กแบบเธรดเดียว เทคนิคนี้จึงช่วยแบ่งหน่วยความจำระหว่าง heap กับ stack ได้อย่างยืดหยุ่น
    • การจัดสรรทรัพยากรแยกตามแอปของ MacOS รุ่นเก่า อธิบายได้ตรงกับวิธีนี้พอดี แต่ละแอปมีค่าความต้องการ RAM ขั้นต่ำและค่าความต้องการ RAM ที่ต้องการแนบมาด้วย และเมื่อรันก็จะกินสล็อตตามขนาดที่ต้องการ
      ถ้าไม่มีมากพอก็จะจัดให้น้อยกว่าค่าที่ต้องการ และถ้าได้ไม่ถึงค่าขั้นต่ำก็จะรันไม่สำเร็จ
      เท่าที่จำได้ ระบบจะวาง heap และไลบรารีไว้ด้านล่างของชิ้น RAM จริงนั้น และวาง stack ไว้ด้านบน
      พอราว ๆ System 8 มีการเพิ่มเลเยอร์ virtualization เข้ามา วิธีนี้ก็จำเป็นน้อยลง และในยุค MacOS X ก็ใช้หน่วยความจำแบบ paging เหมือนระบบอื่น ๆ ทำให้ไม่ต้องใช้ท่าพลิกแพลงแบบนี้อีก
      แต่ก็ยังน่าสนุกเมื่อคิดว่า “ทริกแปลก ๆ เพียงหนึ่งเดียว” แบบนี้จาก Art of Computer Programming เคยเป็นวิธีจัดสรร RAM ให้หลายแอปที่รันพร้อมกันในยุคหนึ่ง
    • เกร็ดสนุก ๆ คือ Itanium มี stack สองชุด ชุดหนึ่งสำหรับ push/pop แบบ manual และอีกชุดเป็น stack ที่วนผ่าน register file
      ชุดหนึ่งโตขึ้น อีกชุดหนึ่งโตลง เป็นโครงสร้างที่น่าหลงใหล แต่สุดท้ายก็ไม่สามารถให้ประสิทธิภาพตามที่สัญญาไว้ได้
    • รูปแบบบนดิสก์ของ SQLite ก็ใช้เทคนิคอาร์เรย์คล้ายกันเมื่อเก็บเนื้อหาของหน้า leaf node ใน B-tree ของตาราง
      ภายในหน้าขนาดคงที่ อาร์เรย์ของ offset จะโตไปข้างหน้า ส่วนอาร์เรย์ของค่าบรรทัดที่มีความยาวแปรผันจะโตย้อนกลับจากท้ายหน้า เข้าใจว่าเมื่อ delete row แล้ว อาร์เรย์ด้านหลังอาจเกิดช่องว่างได้
      เอกสารอ้างถึง TAOCP เกี่ยวกับโครงสร้าง B-tree เอง ดังนั้นถ้าจะเป็นแรงบันดาลใจโดยตรงก็ไม่น่าแปลกใจ
  • การใส่ฟังก์ชัน recursive เข้าไปใน ALGOL เป็นเรื่องที่ถกเถียงกันพอสมควร และยังคงเป็นเรื่องเล่าน่าสนุก: https://vanemden.wordpress.com/2014/06/18/how-recursion-got-...

  • อินเทอร์พรีเตอร์ Forth สำหรับเครื่อง SUBLEQ(https://github.com/howerj/subleq) และอินเทอร์พรีเตอร์สำหรับเครื่องแบบบิตอนุกรม(https://github.com/howerj/bit-serial) ถูกเขียนขึ้น โดยทั้งสองไม่มีสแตกสำหรับเรียกฟังก์ชันที่ Forth ต้องใช้
    SUBLEQ ไม่อนุญาตแม้แต่การโหลด/บันทึกแบบอ้อม ดังนั้นถ้าจะทำอะไรที่ซับซ้อนแม้เพียงเล็กน้อยก็ต้องใช้โค้ดที่แก้ไขตัวเอง
    แนวทางคือสร้าง เครื่องเสมือน ที่สามารถทำหน้าที่เหล่านั้นได้บนทั้งสองเครื่อง และใส่ cooperative multithreading เข้าไปด้วย
    ถ้าต้องการฮีปก็เขียนด้วย Forth และชุดคำของ floating-point ก็เขียนด้วย Forth เช่นกัน MCU หลายตัวยังคงไม่มีคำสั่ง floating-point และสามารถจัดการได้ด้วยการเรียกฟังก์ชันซอฟต์แวร์ที่ implement สิ่งนี้
    คอมไพเลอร์อื่น ๆ แม้ไม่ได้ถูกกล่าวถึงก็น่าจะใช้แนวทางคล้ายกัน อินเทอร์พรีเตอร์ BASIC บางตัวก็ implement VM แล้วตั้งเป้าไปที่ VM นั้น และ P-Code ก็คล้ายกัน

    • TI-99/4A มี RAM หลักที่ CPU เข้าถึงโดยตรงได้เพียง 256 ไบต์ หรือ 128 เวิร์ดเท่านั้น
      หน่วยความจำระบบส่วนใหญ่โดยพื้นฐานเป็นวิดีโอ RAM และต้องเข้าถึงผ่านขั้นตอนที่ค่อนข้างยุ่งยากด้วยการ poke/peek รีจิสเตอร์ของชิปวิดีโอ
      ชิปวิดีโอจะรักษาพอยน์เตอร์หน่วยความจำปัจจุบันที่เพิ่มค่าอัตโนมัติไว้ ทำให้เมื่ออ่านหรือเขียนต่อเนื่อง พอยน์เตอร์จะเพิ่มขึ้นทีละ 1 แต่ข้อเท็จจริงที่ว่าหน่วยความจำระบบส่วนใหญ่เข้าถึงได้ด้วยวิธีนี้เท่านั้นเองก็ทำให้การเขียนโปรแกรมขนาดใหญ่ยากขึ้นมาก
      ดังนั้น TI จึงสร้างเครื่องนามธรรมชื่อ GPL เพื่อทำให้การเข้าถึงวิดีโอ RAM นี้เป็นธรรมชาติมากขึ้น อย่างไรก็ตามมันถูกตีความและรันบน TMS9900 จึงช้ากว่าโค้ดเนทีฟ และยังช้าลงอีกเพราะ CPU เข้าถึง RAM ของชิปวิดีโอได้เฉพาะช่วงที่ชิปไม่ได้สแกนภาพออกหน้าจอ เช่น ช่วง horizontal/vertical retrace เท่านั้น
      โค้ด BASIC และตัวแปรทั้งหมดก็อยู่ในวิดีโอเมโมรีนี้ ดังนั้นจึงเดาได้ไม่ยากว่าอินเทอร์พรีเตอร์ BASIC ของ TI-99/4A เขียนด้วยอะไร มันไม่ได้เร็วเลย
      จุดที่น่าสนใจคือ TMS9900 ไม่มีรีจิสเตอร์ใช้งานทั่วไปจริง ๆ รีจิสเตอร์ workspace WR0~WR15 อยู่ที่ใดที่หนึ่งในหน่วยความจำ และรีจิสเตอร์ workspace pointer WP จะชี้ไปยังชุดนั้น
      รีจิสเตอร์จริงทางกายภาพของ CPU มีเพียงสามตัวคือ PC, WP และ status register ผลก็คือสามารถทำ register windowing แบบดิบมาก ๆ ได้ และเมื่อใช้คำสั่ง BLWP เพื่อกระโดด ชุด “รีจิสเตอร์” ใหม่ที่อยู่คนละตำแหน่งในหน่วยความจำจะถูกเปิดใช้งาน และ return address จะถูกบันทึกไว้ใน workspace ใหม่
      ที่ช่วงนี้พูดถึง TI-99/4A บ่อยก็เพราะกำลังทำ assembler สำหรับเครื่องรุ่นนี้เป็นโปรเจกต์ส่วนตัว
    • ระหว่างเรียนรู้และขุดลึก Forth กับ Subleq ก็ได้เห็นงานเหล่านั้น ชอบที่ได้อ่านแนวทาง และอยากซื้อหนังสือ แต่ Amazon บอกว่าไม่ได้ เลยสงสัยว่าจะมีการพิมพ์ซ้ำไหม
    • ตั้งใจจะพูดเรื่อง subleq อยู่เหมือนกัน แต่ที่นั่นแค่เขียน “Hello world” อย่างเดียวก็ยากจริง ๆ
  • คำพูดที่ว่าโปรเซสเซอร์บางตัวบันทึก return address ไว้ในเวิร์ดก่อนหน้าคำสั่งแรกของซับรูทีนนั้นถูกต้อง และ PDP-8 ก็ทำแบบนั้น
    วิวัฒนาการของ PDP-8 อาจมองได้ว่าเป็นการเดินทางของการรองรับ recursion ในฮาร์ดแวร์
    ตอนแรกคำสั่ง JMS จะฝัง return address ไว้ในเวิร์ดแรกของฟังก์ชัน มักมีกรณีที่ผู้เรียกวางอาร์กิวเมนต์ไว้หลังคำสั่ง JMS และผู้ถูกเรียกอ่านอาร์กิวเมนต์ด้วยออฟเซ็ตจากคำสั่ง return พร้อมกับเพิ่มค่าไปเรื่อย ๆ เพื่อให้ return address กลับไปชี้ตำแหน่งโค้ดอีกครั้ง
    ต่อมาวิธีที่ค่อนข้างแพร่หลายคือใช้ตำแหน่ง auto-increment ตำแหน่งหนึ่งเพื่อสร้างสแตกอย่างง่าย PDP-8 มีตำแหน่งหน่วยความจำ 8 ตำแหน่งที่เพิ่มค่าทุกครั้งเมื่อใช้เป็นพอยน์เตอร์ และ prologue/epilogue ของฟังก์ชันจะจัดการสแตกนี้เอง ทำให้ทำ recursion ได้เต็มรูปแบบ
    ภายหลังยิ่งกว่านั้น ใน implementation แบบไมโครโปรเซสเซอร์อย่าง Harris 6120 มีการเพิ่มฮาร์ดแวร์สแตกเข้ามา ทำให้ประสิทธิภาพดีขึ้น

    • Librascope LGP-30 ปี 1956 มีคำสั่ง R หรือคำสั่งบันทึก return address
      คำสั่งนี้จะบันทึก PC+1 ที่ถูกเพิ่มค่าแล้วลงในส่วนที่อยู่ของคำสั่งที่ตำแหน่งเป้าหมาย และตามธรรมเนียม เป้าหมายนั้นคือคำสั่งกระโดดแบบไม่มีเงื่อนไขที่อยู่ก่อนจุดเริ่มซับรูทีนทันที
      หลังคำสั่ง R จะวางคำสั่ง U ซึ่งเป็นคำสั่งกระโดดแบบไม่มีเงื่อนไขไปยังซับรูทีนนั้น
      ซับรูทีนจะ return ด้วยการกระโดดไปยังที่อยู่ก่อนหน้าตัวเอง และตรงนั้นมีคำสั่งกระโดดแบบไม่มีเงื่อนไขกลับไปยังตำแหน่งถัดจากจุดเรียกพอดี
      หากไม่ใช้ calling convention ที่พัฒนากว่านี้ recursion ก็เป็นไปไม่ได้ และ opcode ของทุกคำสั่งในภาษา assembly เป็นตัวอักษรหนึ่งตัว
    • IBM 1800, IBM 1130 และเครื่องจักรหลายรุ่นในยุคนั้นก็เป็นแบบนั้นเช่นกัน เครื่องที่มีรีจิสเตอร์มากพออย่างตระกูล Xerox Sigma สามารถหลีกเลี่ยงธรรมเนียมแบบนี้ได้
  • ในโปรแกรมที่เขียนสำหรับ AVR-8 บางครั้งการใช้ C calling convention ให้ความรู้สึกเหมือนบ้าไปแล้ว
    ถ้าใช้ assembly ก็สามารถเก็บตัวแปรลูปภายในไว้ใน register file ขนาดใหญ่ได้ตลอด หรือไม่ก็ใช้วิธีที่อธิบายในบทความได้
    วิธี “ระบายสี” ฟังก์ชันในแอปแบบนี้ก็ดีเช่นกัน ถ้ารู้ว่าฟังก์ชันสีแดงกับฟังก์ชันสีเขียวจะไม่ active พร้อมกัน ก็สามารถนำตัวแปร local หรือพารามิเตอร์ของทั้งสองมาใช้พื้นที่ซ้ำกันได้

    • เมื่อทำงานในสภาพแวดล้อมที่มีข้อจำกัด โดยเฉพาะถ้าคุ้นกับความสะดวกของระบบปฏิบัติการเดสก์ท็อป ปริมาณการใช้สแตกของ C อาจไม่เป็นไปตามสัญชาตญาณ
      ครั้งหนึ่งในโปรเจกต์ codebase สำหรับไมโครคอนโทรลเลอร์ที่ผมเข้าไปร่วม นักพัฒนาหลายคนใช้เวลาหลายสัปดาห์ตามหาบั๊กที่จับยากในหลาย subsystem
      พอย้ายโค้ด บั๊กก็ย้ายตามไปด้วย หลังจากไล่ดูเล็กน้อยและวาง trap ไว้ ก็พบตำแหน่งโค้ดที่ call stack ลึกเกินไปจนเขียนทับโครงสร้างข้อมูลอื่นได้
  • ตอนเริ่มเรียนเขียนโปรแกรม ผมถูกบังคับให้เขียนแบบนี้เป๊ะ ๆ เลย ไม่ใช่ยุค 1970 แต่เป็น ปี 2001
    เพราะประสบการณ์เขียนโปรแกรมครั้งแรกของผมคือ “ภาษา” สคริปต์แบบกึ่งกราฟิกที่เครื่องมือพัฒนาเกม RPG Maker 2000 มีให้
    ถ้าไม่เคยเห็นการเขียนสคริปต์ของ RM2K ให้นึกถึงการผสมกันระหว่าง Scratch กับโหมด Emacs Paredit ตัวอย่าง: https://forums.rpgmakerweb.com/data/attachments/21/21958-f89...
    มันดูเหมือนข้อความ แต่แก้ไขเหมือนข้อความไม่ได้ แก้ได้เฉพาะเป็นบล็อกที่มีไดอะล็อกคุณสมบัติประกอบ
    แน่นอนว่าภาษาสคริปต์ของ RPG Maker ไม่มีของหรู ๆ อย่างสแตก ถ้าต้องการซับรูทีนที่นำกลับมาใช้ซ้ำได้ ก็ต้องจัดสรรตัวแปรโกลบอลลับ ๆ ไว้เป็นพารามิเตอร์ และไม่มี reentrancy
    มองย้อนกลับไป ถ้าดื้อพอ ก็น่าจะสร้างทั้งรีจิสเตอร์และ runtime stack ภายใน RPG Maker 2000 ได้
    ตอนแรกดูเหมือนง่าย เราสร้าง “รีจิสเตอร์” ปลอม ๆ แบบ zero page ของ 6502 ได้ และสร้างสแตกด้วยการเข้าถึงตัวแปรทางอ้อม (https://rpgmaker.net/tutorials/523/) ได้ด้วย
    ปัญหาคือ RM2K มีภาวะพร้อมกันในรูปแบบสคริปต์ “parallel process” ถ้าโปรเซสคู่ขนานใช้ abstraction แบบนี้ “เธรด” ต่าง ๆ ก็จะเขียนทับสถานะของกันและกันมั่วไปหมด
    ดังนั้นจึงต้องมี zero page และสแตกหลายชุดต่อ “คอร์เสมือน” แต่ละคอร์ และต้องจัดสรร/ผูก/กำหนดตารางเวลาให้คอร์เสมือนกับสคริปต์คู่ขนานแต่ละตัว พูดอีกอย่างคือต้องทำให้แต่ละสคริปต์มี stack pointer ที่รู้กันเฉพาะตัวมันเองให้ได้
    ถ้าจะให้เสถียรแม้มี race condition ปกติก็ต้องมีอะไรอย่าง mutex
    เมื่อคิดถึงความดื้อดึงของนักพัฒนาเกม RPG Maker แล้ว ผมว่าคงมีใครสักคนหาวิธีหลอกฟีเจอร์ runtime บางอย่างให้ทำงานเหมือน mutex ได้ แต่พูดตรง ๆ ผมกลัวจนไม่อยากรู้เลยว่าจริง ๆ เขาทำอะไรกัน

    • ผมก็เริ่มจาก rpgmaker เหมือนกัน พออ่านเรื่องนี้แล้วรู้สึกคิดถึงมากจริง ๆ
      ผมจำได้ว่าเคยดาวน์โหลดเกมจาก rpgmaker.net ที่มี custom battle system อยู่ข้างใน เป็นการ implement ที่แทนที่ระบบต่อสู้ในตัวทั้งหมดด้วยเทคนิคทำนองที่คุณอธิบาย
      ตอนเปิดดูใน editor ว่ามันทำงานยังไง ผมรู้สึกทึ่งจนรับไม่ไหว มี “ตัวแปร” หลายร้อยตัว และถ้าจำไม่ผิดอนุญาตแค่ i64 เท่านั้น อีกทั้งยังมี “สวิตช์” หลายร้อยตัวด้วย สวิตช์คือ boolean
      ตอนนั้นผมยังไม่มีแนวคิดเรื่องสแตก ฮีป หรือการเรียกฟังก์ชันเลย
      ผมนึกไม่ออกจริง ๆ ว่าต้องใช้พลังงานขนาดไหนในการสร้างและดูแล/ดีบักสิ่งนั้น
  • ถ้าจำไม่ผิด ตอนเขียน โปรแกรม BASIC บน ZX81 ผมเขียนในแบบที่ใกล้เคียงกับ “ไม่มีสแตก”
    1 GOTO 30
    10 LET C = A + B
    20 RETURN
    30 LET A = 1
    40 LET B = 2
    50 GOSUB 10
    60 LET A = C
    70 LET B = 3
    80 GOSUB 10
    90 PRINT C
    RUN
    6
    เท่ากับว่าผมทำสิ่งที่คอมไพเลอร์ทำในบทความด้วยตัวเอง เลขบรรทัดคือที่อยู่หน่วยความจำ และตัวแปรที่ซ่อนอยู่ก็ไม่ได้ซ่อนจากผม เพราะผมนั่นแหละคือคอมไพเลอร์
    สิ่งเดียวที่ interpreter ทำให้คือเก็บที่อยู่สำหรับกลับของ GOSUB
    แต่โค้ดอาจผิดไวยากรณ์ หรือความทรงจำผมอาจเพี้ยนไปก็ได้ 40 ปีเป็นเวลานาน แต่ไอเดียโดยรวมถูกต้อง
    อีกอย่าง โปรเซสเซอร์ Z80 ในเครื่องก็มีฟังก์ชันจัดการสแตกอยู่ BASIC interpreter นั้นเรียบง่ายมากจริง ๆ แต่ก็มีข้อแก้ตัวอยู่ เพราะมี RAM แค่ 1KB และ ROM 8KB ที่ต้องใส่ทั้ง OS, interpreter และทุกอย่างไว้ในนั้น

    • นั่นก็ยังใช้อย่างน้อย call stack อยู่ดี GOSUB จะเก็บเลขบรรทัดหรือ reference อื่น ๆ ที่ RETURN จะอ้างถึง และเมื่อซ้อนการเรียก GOSUB ก็ต้องจำจุดกลับหลายจุด จึงต้องมีสแตกในรูปแบบใดรูปแบบหนึ่ง
      เพียงแต่ BASIC บางตัวไม่ได้มีสแตกอเนกประสงค์ แต่มีแค่อาร์เรย์คงที่ของ return pointer กับดัชนีตำแหน่งปัจจุบัน เช่น กำหนดความลึกการเรียกไว้ตายตัวที่ 7 ระดับ จากมุมมองของโปรแกรมเมอร์ มันก็ทำงานเหมือน call stack
      แน่นอนว่าไม่ใช่สแตก “ของจริง” ที่มี local variable/parameter อย่างที่คนส่วนใหญ่คาดหวังเมื่อพูดถึงสแตก
      ในสภาพแวดล้อมพื้นฐานของ BBC BASIC มีเดโมสนุก ๆ ที่แสดงให้เห็นว่าเกิดอะไรขึ้นระหว่างการเรียกซ้อน รวมถึง recursion ได้ด้วย ถ้ากำหนดตำแหน่งสแตกไว้ที่ส่วนบนสุดของหน่วยความจำจอภาพ แล้วไม่วาดอะไรไว้ตรงนั้น ก็จะเห็นสแตกเติบโตระหว่างที่งานดำเนินไป
      เพราะความละเอียดหน้าจอต่ำ return address ขนาด 2 ไบต์จึงมองเห็นเป็นพิกเซลหนา ๆ 8 พิกเซลในโหมดจอ 1 หรือ 5 ในโหมด 2 จะเป็น 4 พิกเซลแต่มีสีที่กะพริบเลยไม่ค่อยดี ส่วนในโหมด 0, 3, 4, 6 จะเป็น 16 พิกเซล แต่การดูระดับบิตนั้นแยกแยะยากกว่าการเห็นสี 8 สีวนซ้ำ
  • ก่อนจะมีฮีปที่ขยายได้ตามใจ โปรแกรมเมอร์อย่างน้อยก็ต้องใช้ วิจารณญาณทางวิศวกรรม อยู่บ้าง
    เพราะต้องพิจารณาการกระจายเชิงความน่าจะเป็นของอินพุต และกำหนดขนาดพื้นที่เก็บข้อมูลชั่วคราวทั้งหมดให้เหมาะสม
    ดังนั้นจึงเกิดหัวข้อ “BUGS AND LIMITATIONS” ขึ้นมา

    • วิธีแบบเก่า ๆ นั้นยังเป็นปัจจุบันอยู่ ขึ้นอยู่กับว่าคุณทำอะไร ในงาน hard real-time แทบไม่ใช้หน่วยความจำแบบไดนามิก เหตุผลหลักคือเวลาในการ allocate/free หน่วยความจำนั้นไม่ deterministic
      ดังนั้นจึงจัดสรรทุกอย่างแบบ static ตอน compile time และต้องรู้ว่าอินพุตจะกินหน่วยความจำมากเท่าไร
      แต่การรู้ขอบเขตบนของการใช้หน่วยความจำก็เคยเป็นเรื่องปกติสำหรับโปรแกรมเมอร์แอปพลิเคชันด้วย เพราะไม่มีใครอยากให้หน่วยความจำไม่พอเด็ดขาด
      สมัยนี้เหมือนปล่อยการใช้หน่วยความจำแบบ YOLO กันไปเลยหรือเปล่า
    • ในเชิงประวัติศาสตร์ หนึ่งในเป้าหมายใหญ่ของ GNU ก็เป็นเรื่องทำนองนี้ด้วย คือพยายามลบข้อจำกัดเทียม ๆ ของยูทิลิตีหลักออก
      เช่น เมื่อเทียบกับข้อจำกัดแบบความยาวคำสั่งสูงสุดของ sed ที่มีขนาดจำกัดและสั้น ก็ถือเป็นการปรับปรุงครั้งใหญ่
    • จริง ๆ แล้วความผิดพลาดคือการทำให้มนุษย์เป็นผู้ป้อนอินพุตให้โปรแกรมคอมพิวเตอร์
  • ผมเขียนโปรแกรมเชิงฟังก์ชันมานานเกินไป จนบอกตามตรงว่านึกไม่ออกจริง ๆ ว่าจะเขียนโค้ดยังไง โดยไม่ใช้รีเคอร์ชัน
    ในเชิงเทคนิค ผมรู้วิธีเปลี่ยนอัลกอริทึมแบบรีเคอร์ซีฟให้เป็นแบบวนซ้ำ และเคยทำในที่ที่มีข้อจำกัดด้านทรัพยากรสูงด้วย แต่ก็ไม่ได้ชอบ
    โดยทั่วไปฝั่งรีเคอร์ชันมักจะสวยกว่า และใน 99% ของกรณีก็ถือว่าเร็วพอ ถ้าคอมไพเลอร์รองรับ tail recursion ก็เกือบ 100% แต่สำหรับงานส่วนใหญ่ที่น่าสนใจกว่า ยังไงก็ต้องดูแลสแตกเองอยู่ดี
    บางครั้งผมตั้งใจทำงานแบบนั้นเพื่อเรียนรู้ว่า ก่อนผมเกิดเขาทำกันยังไง ช่วงนี้ผมลองแงะ ๆ เกม Commodore 64 อยู่บ้าง แล้วก็ยิ่งรู้สึกชัดมากว่าทุกวันนี้เราโชคดีแค่ไหนที่คุ้นเคยกับฮาร์ดแวร์ที่เร็ว ถูก และใช้ง่าย

    • ชุดคำสั่ง สมัยนี้มีประโยชน์กว่ามากจริง ๆ
      ถ้าจะทำรีเคอร์ชันบนเครื่องเก่า ๆ แบบนั้น ก็ต้องสร้างกลไกสแตกขึ้นมาเอง และถึงอย่างนั้นก็ยังเหลือปัญหาที่ต้องจัดการ เพราะโดยพื้นฐานแล้วไม่มีทางเลือกให้ใช้ นอกจากที่เก็บข้อมูลแบบโกลบอล
      ผมเคยผ่านยุคนั้นมาแล้ว แต่ไม่อยากแนะนำให้ใครเลย
  • ในฟีเจอร์ @let ของ Enhanced GNU Awk นั้น บล็อก @let ที่อยู่นอกฟังก์ชัน เช่น ภายในบล็อก BEGIN หรือ END ถูกปล่อยให้คอมไพเลอร์จัดสรร ตัวแปรโกลบอลลับ
    ตัวแปรเหล่านี้จะถูกนำกลับมาใช้ซ้ำระหว่างบล็อกเท่าที่ทำได้
    $ ./gawk --dump-variables 'BEGIN { @let (a, b, c = 1) { } }'
    $ cat awkvars.out
    $let0001: untyped variable
    $let0002: untyped variable
    $let0003: 1
    ARGC: 1
    ARGIND: 0
    ARGV: array, 1 elements
    BINMODE: 0
    [ .. snip many ]
    https://www.kylheku.com/cgit/egawk/about/

    • เว็บไซต์นั้นใช้ไม่ได้จาก ISP ของผม ping ก็ไม่ได้ และ nc -z 104.37.63.7 443 ก็ไม่ได้
      อัปเดต: ดูเหมือนโครงสร้างพื้นฐานด้านความปลอดภัยจะพัง ผมไม่รู้ด้วยซ้ำว่ามันคืออะไร และไม่ได้ใช้ Twitter ด้วย พอตรวจ AS แล้วเป็น Google Fiber
      แล้วก็หวังว่าจะไม่มีใครขุดข้อมูลส่วนตัวของผมนะ