Topic 11: RV32I ISA & Assembly

310-2202 โครงสร้างระบบคอมพิวเตอร์ — สรุปเนื้อหา Topic 11

ภาพรวม Topic 11 — RV32I ISA และ Assembly

เจาะลึก ISA (instruction set architecture, ชุดคำสั่ง) ของ RISC-V RV32I: รีจิสเตอร์ 32 ตัวและ ABI (Application Binary Interface — ข้อตกลงระหว่างซอฟต์แวร์ เช่น ชื่อ ra/sp/a0–a7), หกรูปแบบคำสั่ง (R/I/S/B/U/J), การเข้ารหัส/ถอดรหัสระดับบิต, pseudo-instructions ที่ assembler ขยายให้, calling convention ครบวง (prologue/epilogue, stack frame, การส่ง argument/return value) และปิดท้ายด้วยเลขคณิตของเครื่อง: ส่วนขยาย M (คูณ/หาร) และ F (เลขจุดลอยตัว)

เมื่อจบ Topic นี้ ผู้เรียนจะสามารถ: ใช้รีจิสเตอร์ตามชื่อ ABI และอธิบาย caller-saved กับ callee-saved · แยกหกรูปแบบคำสั่ง R/I/S/B/U/J และฟิลด์ของมัน · ถอด/เข้ารหัสคำสั่งพื้นฐาน ใช้ pseudo-instruction และเขียนซับรูทีนตาม calling convention · อธิบายว่าวงจรคูณ (เลื่อนแล้วบวก, shift-add) หาร (ลบแล้วคืนค่า, restoring division) และบวกเลขจุดลอยตัว (สี่ขั้น + guard/round/sticky bit) ของส่วนขยาย M และ F ทำงานอย่างไร

ถ้าจำได้ประโยคเดียวRV32I มีคำสั่งเพียงหกรูปแบบ ทุกอย่างที่เหลือสร้างจากหกแบบนี้กับข้อตกลงการเรียกฟังก์ชัน
หัวข้อเนื้อหา
11.1รู้จักรีจิสเตอร์ทั้ง 32 ตัว ชื่อ ABI และกติกา caller-saved เทียบ callee-saved
11.2หกรูปแบบคำสั่ง R/I/S/B/U/J และการจัดเรียงบิตของ immediate
11.3เข้ารหัส/ถอดรหัสคำสั่งเป็นบิตจริง และ pseudo-instructions ที่ assembler ขยายให้
11.4calling convention ครบวง: prologue/epilogue, stack frame และตัวอย่าง recursion
11.5เลขคณิตของเครื่อง: ส่วนขยาย M (คูณ/หาร) และ F (เลขจุดลอยตัว)

11.1 Register File และ ABI ของ RV32I

32 รีจิสเตอร์ x0–x31 และทำไมต้องใช้ฟิลด์ 5 บิต

RV32I มีชุดรีจิสเตอร์ทั่วไป (general-purpose register) 32 ตัว ชื่อ x0–x31 แต่ละตัวกว้าง 32 บิตเท่าความกว้างของ word ใช้ฟิลด์ 5 บิตในคำสั่งเพื่อเลือกรีจิสเตอร์ (เพราะ 2^5 = 32) นี่คือ "พื้นที่ทำงานความเร็วสูง" ของ CPU ที่ ALU อ่าน/เขียนได้ในรอบสัญญาณนาฬิกาเดียว เร็วกว่าการเข้าถึง memory มาก คำสั่งคำนวณจึงระบุ operand ด้วยเลขรีจิสเตอร์ 5 บิตแทนที่อยู่ memory เต็ม ๆ ทำให้คำสั่งสั้นและ datapath เร็ว (ถ้ามีรีจิสเตอร์ 8 ตัวแบบ LC-3 จะใช้แค่ 3 บิต)

CPU ใช้ยังไงฮาร์ดแวร์เห็นรีจิสเตอร์เป็นแค่ x0–x31 ส่วน ABI คือกติกาฝั่งซอฟต์แวร์ที่บอกว่าใครใช้ตัวไหน และใครต้องรักษาค่าตอน call/return ถ้าไม่มีกติกานี้ compiler กับไลบรารีคนละเจ้าจะประกอบกันไม่ติด

x0 = 0 เสมอ (hardwired zero — ต่อสายตายตัว)

ในมุมฮาร์ดแวร์ x1–x31 เหมือนกันหมด ใช้แทนกันได้อิสระ มีเพียง x0 ที่พิเศษ เพราะถูก hardwired ให้มีค่า 0 เสมอ อ่าน x0 ได้ 0 ทุกครั้ง เขียนลงไปก็ถูกทิ้ง สมบัติข้อเดียวนี้ให้ idiom ฟรีหลายแบบ: NOP, คัดลอกค่า และเทียบกับศูนย์

ตัวอย่าง — ไล่ผลของลำดับคำสั่งที่อาศัย x0

กำหนดค่าเริ่มต้น x7 = 25 และ x6 = 99 ไล่ผลของ (1) add x6, x7, x0 (2) addi x0, x0, 0 (3) beq x6, x7, DONE ทีละคำสั่ง

  1. คำสั่ง 1: x0 = 0 เสมอ → x6 ← 25 + 0 = 25 — นี่คือ "คัดลอกค่า" x7 → x6 (เท่ากับ pseudo mv x6, x7) ตอนนี้ x6 = 25, x7 = 25
  2. คำสั่ง 2: คำนวณ 0 + 0 = 0 แล้วเขียนลง x0 ซึ่ง hardwired = 0 เขียนไม่ติด → ค่าถูกทิ้ง เป็น NOP มาตรฐาน
  3. คำสั่ง 3: x6 = 25, x7 = 25 → เท่ากัน → กระโดดไป DONE
สรุป: x6 = 25, x7 = 25 — คำสั่ง 1 คัดลอก, คำสั่ง 2 เป็น NOP, คำสั่ง 3 เทียบค่า ทั้งสามอาศัย x0 = 0 โดยไม่เปลืองรีจิสเตอร์จริง

ABI: ตั้งชื่อและหน้าที่ให้รีจิสเตอร์

ฮาร์ดแวร์มองรีจิสเตอร์ (ยกเว้น x0) เท่ากันหมด แต่ระดับซอฟต์แวร์มี ABI (Application Binary Interface) เป็นข้อตกลงร่วมที่กำหนด "ชื่อ ABI" และ "หน้าที่" ให้แต่ละตัว เพื่อให้โค้ดจากคนละ compiler/คนละไลบรารีเรียกกันได้ถูกต้อง การเขียน assembly จึงนิยมใช้ชื่อ ABI (เช่น sp, ra, a0) แทนเลข x ดิบ เพราะสื่อ "บทบาท" ของค่าได้ชัดกว่า

รีจิสเตอร์ชื่อ ABIหน้าที่ใครรักษาค่าข้าม call
x0zeroค่าศูนย์เสมอ (hardwired)— (คงที่)
x1rareturn addresscaller-saved
x2spstack pointercallee-saved
x3gpglobal pointer(พิเศษ)
x4tpthread pointer(พิเศษ)
x5–x7t0–t2temporarycaller-saved
x8s0 / fpsaved / frame pointercallee-saved
x9s1savedcallee-saved
x10–x17a0–a7argument / return valuecaller-saved
x18–x27s2–s11savedcallee-saved
x28–x31t3–t6temporarycaller-saved

สรุปกลุ่มให้จำง่าย: a0–a7 ส่ง argument (และ a0/a1 รับ return value), t0–t6 เป็น temporary, s0–s11 เป็น saved, ra เก็บที่อยู่กลับ, sp ชี้ยอด stack ส่วน gp/tp เป็นรีจิสเตอร์พิเศษที่ตั้งค่าตอนเริ่มโปรแกรม (gp ชี้ฐานพื้นที่ข้อมูล global, tp ชี้ thread-local storage) และปกติโค้ดทั่วไปไม่แตะ นับรวม: callee-saved 13 ตัว (sp กับ s0–s11), caller-saved 16 ตัว (ra, t0–t6, a0–a7), พิเศษ 3 ตัว (zero, gp, tp) รวม 32 พอดี — ที่มา: Patterson & Hennessy, Computer Organization and Design (RISC-V Edition) รูปที่ 2.14

🎮 เกมในต้นฉบับตัวจำลองให้คลิกช่อง x0–x31 ดูชื่อ ABI/หน้าที่ แล้วกดปุ่ม callee-saved/caller-saved/พิเศษ เพื่อไฮไลต์แต่ละกลุ่มและนับจำนวนช่องที่สว่าง (13/16/3) และคลิกช่อง x8 ดูว่ารีจิสเตอร์ตัวเดียวมีสองชื่อ s0/fp

Pseudo-instruction ที่อาศัย x0: mv, li, neg, nop

RV32I ไม่มีคำสั่ง move, load-immediate หรือ negate แยกต่างหาก — assembler ขยาย pseudo-instruction เหล่านี้เป็นคำสั่งจริงที่อาศัย x0 ทั้งสิ้น

pseudo ที่เขียนassembler ขยายเป็นความหมาย
mv x6, x7addi x6, x7, 0x6 ← x7 + 0 (คัดลอกค่า)
li x10, 25addi x10, x0, 25x10 ← 0 + 25 (ใส่ค่าคงที่)
neg x5, x6sub x5, x0, x6x5 ← 0 - x6 (กลับเครื่องหมาย)
nopaddi x0, x0, 0คำนวณ 0 + 0 แล้วเขียนลง x0 ซึ่งไม่ติด

ขยายความ: คำสั่งคำนวณใดก็ตามที่ rd = x0 เช่น add x0, x5, x6 ก็คำนวณแล้วผลถูกทิ้งเหมือนกัน จึงเป็น NOP โดยอัตโนมัติ — datapath ไม่ต้องมีเส้นทางพิเศษสำหรับ "ไม่เขียนผล"

เช็กว่าเป็นศูนย์หรือไม่: สองท่า beq กับ sltiu

ต้องการรู้ว่า x6 เป็นศูนย์หรือไม่ มีสองท่า: ท่า branch — beq x6, x0, IS_ZERO ให้ผลเป็น "ทิศทางการกระโดด" ทันที กับ ท่าคำนวณเป็นค่า — sltiu x5, x6, 1 (assembler ตั้งชื่อ pseudo ว่า seqz x5, x6) ให้ค่า 0/1 เก็บไว้คำนวณต่อได้

ตัวอย่าง — sltiu x5, x6, 1 เมื่อ x6 = 0, 7, 0xFFFFFFFD

กติกา sltiu: x5 ← 1 ถ้า x6 น้อยกว่า 1 แบบ unsigned ไม่เช่นนั้น x5 ← 0 (เลข unsigned ที่น้อยกว่า 1 มีตัวเดียวคือ 0)

  1. x6 = 0 → 0 น้อยกว่า 1 จริง → x5 = 1
  2. x6 = 7 → 7 ไม่น้อยกว่า 1 → x5 = 0
  3. x6 = 0xFFFFFFFD → มองแบบ unsigned คือ 4294967293 → ไม่น้อยกว่า 1 → x5 = 0 (ไม่ใช่ −3 เพราะ sltiu ไม่มอง sign)
x5 = 1 เฉพาะเมื่อ x6 = 0 → นี่คือ is-zero flag ตัว u ใน sltiu แปลว่า unsigned ถ้าต้องการเทียบแบบ signed ต้องใช้ slti แทน
assembler ขยาย pseudo neg x5, x6 (กลับเครื่องหมาย) เป็นคำสั่งจริงใด
ตอบ: sub x5, x0, x6 — 0 − x6 คือค่าลบของ x6 ตรงตามนิยาม negate (รหัสเครื่อง 0x406002B3) ระวังลำดับ operand: sub x5, x6, x0 ผิด เพราะ x6 − 0 = x6 เท่ากับคัดลอกค่าเฉย ๆ
อ้างอิง
  • D. Patterson และ J. Hennessy, Computer Organization and Design (RISC-V Edition) — บทที่ 2

Caller-saved เทียบ Callee-saved — ใครรับผิดชอบรักษาค่ารีจิสเตอร์ข้าม call

หัวใจของ calling convention ใน ABI คือกติกาว่า "ใครรับผิดชอบรักษาค่ารีจิสเตอร์ข้าม subroutine call" เพราะ subroutine ที่เรียก (callee) กับผู้เรียก (caller) ใช้รีจิสเตอร์ชุดเดียวกัน ถ้าไม่มีข้อตกลง ค่าจะทับกันมั่ว ABI จึงแบ่งรีจิสเตอร์เป็นสองกลุ่ม

กลุ่มรีจิสเตอร์callee ทับได้ไหมใครต้องเซฟเซฟเมื่อไร
caller-savedt0–t6, a0–a7, raได้ตามใจcallerก่อน jal ถ้ายังต้องใช้ค่าหลัง call แล้วโหลดคืนหลังกลับ
callee-saveds0–s11, spไม่ได้calleeใน prologue ก่อนใช้ แล้วคืนใน epilogue (sp ต้องกลับชี้ที่เดิม)

เป็นการประนีประนอม (trade-off) เพื่อลดงานเซฟโดยรวม ถ้าทุกตัวเป็น callee-saved จะเซฟ/รีสโตร์เปลืองแม้กับค่าที่ caller ไม่สนใจแล้ว ถ้าทุกตัวเป็น caller-saved ก็เปลืองอีกแบบ การแบ่งครึ่งให้ compiler วางตัวแปร "อายุยาว" (ต้องอยู่รอด call) ไว้ใน s-register และตัวแปร "อายุสั้น" ไว้ใน t-register จึงเซฟเฉพาะที่จำเป็น — ra อยู่ฝั่ง Preserved จากมุม caller เพราะตอน ret ค่า ra ต้องชี้จุดเดิม (จึงต้อง sw ra ใน prologue) แต่จัดในตารางฝั่ง caller-saved เพราะ jal ของตัวเองทับ ra ทันที จึงต้องเซฟก่อนเรียกซ้อน — สองมุมนี้พูดเรื่องเดียวกัน — ที่มา: Patterson & Hennessy, Computer Organization and Design (RISC-V Edition) รูปที่ 2.11

โครงร่าง subroutine ตามมาตรฐาน ABI

# func: เซฟเฉพาะ callee-saved ที่จะใช้จริง และ ra ถ้าจะเรียกต่อ
func:
    addi sp, sp, -16      # จองเฟรมบน stack (sp ชี้ลงต่ำเพราะ stack โตลงล่าง)
    sw   ra, 12(sp)       # เซฟ ra ถ้าภายในจะเรียก subroutine อีกที
    sw   s0, 8(sp)        # เซฟ s0 เพราะเป็น callee-saved และกำลังจะใช้
    # ... ตัวงาน: ใช้ a0-a7 รับ argument, t0-t6 เป็นที่พักชั่วคราว ...
    # ... ผลลัพธ์ใส่ a0 (และ a1 ถ้าต้องคืน 2 ค่า) ...
    lw   s0, 8(sp)        # restore s0 คืน
    lw   ra, 12(sp)       # restore ra คืน
    addi sp, sp, 16       # คืนพื้นที่ stack (sp กลับที่เดิม)
    ret                   # = jalr x0, 0(ra) กระโดดกลับตามที่อยู่ใน ra

สังเกตว่า t-register ในตัวงานไม่ต้องเซฟ เพราะ caller รู้อยู่แล้วว่ามันเป็น caller-saved ส่วน a0 ที่ใช้คืนผล ก็ไม่ต้องเซฟกลับเพราะ a-register เป็น caller-saved เช่นกัน

ตัวอย่าง — เห็นภาพ: callee-saved ถูกฝากไว้ตรงไหนบน stack

สมมุติก่อนเข้า func ค่า sp = 0x7FFFFFF0 ไล่ prologue สามคำสั่งแรกทีละขั้น

  1. addi sp, sp, -16 (0xFF010113) — sp ← 0x7FFFFFF0 − 16 = 0x7FFFFFE0 จองเฟรมกว้าง 16 ไบต์ (ABI ของ RISC-V กำหนดให้ sp เกาะแนว 16 ไบต์เสมอ แม้ใช้จริงแค่ 8 ไบต์)
  2. sw ra, 12(sp) (0x00112623) — เก็บ ra ลงที่ 0x7FFFFFE0 + 12 = 0x7FFFFFEC
  3. sw s0, 8(sp) (0x00812423) — เก็บ s0 ลงที่ 0x7FFFFFE0 + 8 = 0x7FFFFFE8
ที่อยู่สูง
              +--------------------+
 0x7FFFFFF0   |   (caller frame)   |   ← sp เดิม ก่อน prologue
              +--------------------+
 0x7FFFFFEC   |  ra        12(sp)  |  ┐
 0x7FFFFFE8   |  s0         8(sp)  |  │  เฟรมของ func กว้าง 16 ไบต์
 0x7FFFFFE4   |  (pad)      4(sp)  |  │  4(sp), 0(sp) เผื่อไว้ให้ sp เกาะแนว 16
 0x7FFFFFE0   |  (pad)      0(sp)  |  ┘  ← sp ใหม่
              +--------------------+
ที่อยู่ต่ำ    (เฟรมของฟังก์ชันที่ถูกเรียกซ้อนถัดไป จะงอกลงทางนี้)
epilogue ทำย้อนกลับทีละขั้น: lw s0 (0x00812403) → lw ra (0x00C12083) → addi sp, sp, 16 (0x01010113) ได้ sp = 0x7FFFFFF0 เท่าเดิมเป๊ะ — นี่คือความหมายของ "stack สมดุล"

เรียกซ้อนสองชั้น: ใครต้องเซฟ ra

สมมุติ main เรียก A และ A เรียก B (B เป็น leaf ไม่เรียกใครต่อ) jal ra, A ของ main อยู่ที่ 0x1000 และ jal ra, B ของ A อยู่ที่ 0x2010

เหตุการณ์ra (ในรีจิสเตอร์)ช่อง 12(sp) ในเฟรมของ A
main รัน jal ที่ 0x10000x1004—
A เซฟ ra (prologue)0x10040x1004
A รัน jal ที่ 0x20100x20140x1004
B ret0x2014 (กระโดดกลับเข้า A)0x1004
A โหลด ra คืน (epilogue)0x1004—
A retกระโดดไป 0x1004 ใน main—
กับดักคลาสสิกถ้า A ลืมเซฟ ra: ตอน A ret จะกระโดดไป 0x2014 ซึ่งอยู่กลางตัว A เอง → โปรแกรมวนกลับเข้าตัวเองไม่รู้จบ กฎจำง่าย: ฟังก์ชันใดมี jal/call อยู่ข้างใน (non-leaf) ต้องเซฟ ra ใน prologue เสมอ ส่วน leaf function ไม่ต้องเซฟ ra เลย (ในตัวอย่างนี้ A ต้องเซฟ เพราะเป็นฟังก์ชันเดียวที่มี jal ข้างในซึ่งจะทับ ra ส่วน B เป็น leaf ไม่ต้อง)
subroutine A เก็บค่าไว้ 2 ตัว — ค่าแรก 100 ใน t3 และค่าที่สอง 200 ใน s3 — จากนั้น A เรียก B โดย B เขียนทับทุกรีจิสเตอร์ที่มันมีสิทธิ์ทับ เมื่อ B return กลับมา ค่าใน t3 กับ s3 เชื่อถือได้หรือไม่
ตอบ: t3 อยู่กลุ่ม t → caller-saved → B ทับได้ → หลัง return เชื่อถือไม่ได้ ส่วน s3 อยู่กลุ่ม s → callee-saved → B ต้องเซฟ/คืน → หลัง return ยัง 200 เท่าเดิม ดังนั้น A ต้องเซฟค่า 100 ใน t3 ลง stack เองก่อน jal (หรือย้ายไป s-register ตั้งแต่แรก) ส่วนค่า 200 ใน s3 ไม่ต้องทำอะไร ABI รักษาให้
อ้างอิง
  • D. Patterson และ J. Hennessy, Computer Organization and Design (RISC-V Edition) — บทที่ 2

11.2 หกรูปแบบคำสั่ง (Instruction Formats)

ทำไมคำสั่งยาวเท่ากันหมด แต่ต้องมีหกรูปแบบ

ทุกคำสั่งของ RV32I ยาว 32 บิตเท่ากันหมด (fixed-length) นี่คือหัวใจของปรัชญา RISC: decoder รู้ขนาดคำสั่งทันทีโดยไม่ต้องอ่านต่อ จึงดึงคำสั่งถัดไปและทำ pipeline (Topic 12+) ได้ง่าย แต่คำสั่งต่างชนิดต้องการข้อมูลต่างกัน — บางตัวใช้รีจิสเตอร์สามตัว บางตัวต้องการ immediate ก้อนใหญ่ RV32I จึงจัดการวางบิตเป็น หกรูปแบบ (six instruction formats): R, I, S, B, U, J

แนวคิดกุญแจRV32I ออกแบบให้ฟิลด์ opcode, rd, rs1, rs2, funct3 อยู่ตำแหน่งบิตเดิมในทุกรูปแบบที่มีฟิลด์นั้น ผลคือฮาร์ดแวร์อ่านหมายเลขรีจิสเตอร์ออกมาได้ทันทีแบบขนาน โดยยังไม่ต้องรู้ว่าเป็นคำสั่งชนิดใด ลดความซับซ้อนของวงจรถอดรหัส
ฟิลด์บิตกว้างหน้าที่มีในรูปแบบ
opcode6–07กลุ่ม/รูปแบบของคำสั่ง ตัวแรกที่ decoder อ่านทุกแบบ
rd11–75รีจิสเตอร์ปลายทางR, I, U, J
funct314–123แยกคำสั่งใน opcode เดียวกัน (add/sll, beq/bne)R, I, S, B
rs119–155ต้นทางตัวที่ 1 / baseR, I, S, B
rs224–205ต้นทางตัวที่ 2R, S, B
funct731–257แยกคู่ที่ opcode+funct3 ชนกัน (add 0000000 / sub 0100000)R
immต่างกันตามรูปแบบ12/20ค่าคงที่ฝังในคำสั่งI, S, B, U, J

ตารางสรุปหกรูปแบบ

รูปแบบใช้กับมี imm?จุดเด่น
Rreg-reg: add, sub, and, or, sll, sltไม่มีใช้ rs1, rs2, rd ครบ + funct7
Iaddi, lw, jalr, ecall12 บิตrs1 + imm → rd (ไม่มี rs2)
Sstore: sw, sh, sb12 บิตเก็บ rs2 ลงที่ (rs1 + imm) ไม่มี rd
Bbranch: beq, bne, blt, bge13 บิต (signed, PC-relative)เทียบ rs1 กับ rs2 แล้วกระโดด
Ului, auipc20 บิตบนสร้างค่าคงที่ก้อนใหญ่ → rd
Jjal21 บิต (signed)กระโดดไกล + เก็บ return addr ลง rd

ที่มา: Patterson & Hennessy, Computer Organization and Design (RISC-V Edition) รูปที่ 2.19

ขั้นตอนเข้ารหัส 4 ขั้น (ใช้ได้ทุกรูปแบบ)

  1. แยก operand — อ่านคำสั่งแล้วระบุให้ครบว่าอะไรคือ rd, rs1, rs2 และ immediate (ถ้ามี)
  2. แปลงเป็นบิตต่อฟิลด์ — เปิดค่า opcode/funct3/funct7 ของคำสั่งนั้น แปลงหมายเลขรีจิสเตอร์เป็นฐานสอง 5 บิต และแปลง immediate เป็นฐานสองตามจำนวนบิตที่รูปแบบกำหนด (ค่าลบต้องเขียนเป็น two's complement ก่อน)
  3. วางตามผัง — นำบิตของแต่ละฟิลด์ไปวางลงตำแหน่งที่รูปแบบกำหนด เรียงจากบิต 31 ลงมาถึงบิต 0
  4. อ่านเป็นฐานสิบหก — จับกลุ่มทีละ 4 บิตจากขวาสุด แปลงแต่ละกลุ่มเป็นเลขฐานสิบหกหนึ่งหลัก ได้คำตอบ 8 หลัก
จุดพลาดบ่อยลืมแปลงค่าลบเป็น two's complement และวางฟิลด์สลับตำแหน่ง (โดยเฉพาะ rs1 กับ rs2) วิธีกันพลาด: วาดผังฟิลด์เป็นช่องเปล่าก่อน แล้วเติมบิตทีละช่องเหมือนเติมตาราง

รูปแบบ R — register-register

คำสั่งคำนวณที่ทำงานกับรีจิสเตอร์ล้วน ๆ: rd = rs1 (op) rs2 ผังบิต: funct7(7) | rs2(5) | rs1(5) | funct3(3) | rd(5) | opcode(7)

add  x1, x2, x3   # x1 = x2 + x3
sub  x1, x2, x3   # x1 = x2 - x3   (funct3 เดียวกับ add แต่ funct7 ต่าง)
sll  x1, x2, x3   # shift left logical
slt  x1, x2, x3   # set less than: x1 = (x2 น้อยกว่า x3) ? 1 : 0
and  x1, x2, x3   # x1 = x2 & x3
or   x1, x2, x3   # x1 = x2 | x3

ทุกตัวข้างบนใช้ opcode เดียวกัน (กลุ่ม OP) แล้วแยกด้วย funct3 คู่ที่ funct3 ชนกัน (add/sub และ srl/sra) แยกเพิ่มด้วย funct7 — นี่คือเหตุผลที่รูปแบบ R ต้องมี funct7: opcode 7 บิต + funct3 3 บิต ยังไม่พอแยกทุกคำสั่งคำนวณ

ตัวอย่าง — เข้ารหัส add x1, x2, x3 เป็น 32 บิต

รู้ว่า add มี opcode = 0110011, funct3 = 000, funct7 = 0000000

  1. แยก operand: rd=x1, rs1=x2, rs2=x3
  2. แปลงบิต: opcode=0110011, rd=00001, funct3=000, rs1=00010, rs2=00011, funct7=0000000
  3. วางตามผัง (บิต 31→0): 0000000 00011 00010 000 00001 0110011
  4. จับกลุ่ม 4 บิต: 0000 0000 0011 0001 0000 0000 1011 0011
คำตอบ = 0x003100B3 — ถ้าเปลี่ยนเป็น sub x1, x2, x3 มีแค่ funct7 เปลี่ยนเป็น 0100000 → ได้ 0x403100B3 ต่างกันแค่หลักหน้าสุด

รูปแบบ I — immediate / load / jalr

รูปแบบ I แทน rs2 และ funct7 ด้วย immediate 12 บิต (บิต 31–20) ใส่ค่าคงที่มีเครื่องหมายช่วง −2048 ถึง +2047 ได้ในคำสั่งเดียว ใช้กับสามงานหลัก:

addi x1, x2, 5     # คำนวณกับค่าคงที่: x1 = x2 + 5
lw   x1, 8(x2)     # load: x1 = MEM[x2 + 8]   (imm คือ offset)
jalr x1, 0(x2)     # กระโดดทางอ้อม: PC = x2 + 0, เก็บ return addr ลง x1
ecall              # system call (หมายเลขบริการอยู่ใน a7)

สังเกตว่า lw และ addi ใช้รูปแบบเดียวกัน เพราะทั้งคู่ต้องการ "base register + offset → ปลายทางเดียว" ทำให้ตารางถอดรหัสของ RV32I เล็กและสม่ำเสมอ ส่วน slli ก็ยืมรูปแบบ I เช่นกัน โดยฟิลด์ imm ใช้แค่ 5 บิตล่างเป็น shamt (shift amount, จำนวนตำแหน่งที่เลื่อน — รีจิสเตอร์ 32 บิตเลื่อนได้มากสุด 31 ตำแหน่ง) วางที่บิต 24–20 (ตำแหน่งเดียวกับ rs2 ของรูปแบบ R) ส่วนบิต 31–25 เป็นรหัสคงที่: slli/srli ใช้ 0000000 ส่วน srai ใช้ 0100000

ตัวอย่าง — addi t0, zero, -1 (immediate ติดลบ)
  1. แยก operand: t0=x5, zero=x0 → rd=x5, rs1=x0, imm=-1
  2. เขียน -1 แบบ two's complement 12 บิต: +1 = 000000000001 → กลับบิต 111111111110 → บวกหนึ่ง = 111111111111
  3. วางตามผัง I: 111111111111 00000 000 00101 0010011
  4. อ่านฐานสิบหก: 1111 1111 1111 0000 0000 0010 1001 0011
คำตอบ = 0xFFF00293 — sign bit ของ imm ตกที่บิต 31 ของคำสั่งพอดี เห็นคำสั่งขึ้นต้นด้วย 0xFFF เมื่อไร ให้เดาไว้ก่อนว่า immediate เป็นค่าลบ

รูปแบบ S — store (imm ถูกแตกเป็นสองท่อน)

sw x3, 8(x2) ต้องการสองรีจิสเตอร์ต้นทาง (rs1=base, rs2=ค่าที่จะเก็บ) และ immediate offset แต่ไม่มี rd เพราะไม่เขียนผลลงรีจิสเตอร์ เนื่องจาก rs1/rs2 ต้องอยู่บิต 19–15 และ 24–20 เหมือนรูปแบบ R (เพื่อให้ decoder อ่านรีจิสเตอร์ได้แบบขนาน) และช่องบิต 11–7 (เดิมเป็น rd) ว่างอยู่ RV32I จึงแตก immediate 12 บิตเป็นสองท่อน: imm[11:5] ไปช่องบิต 31–25 และ imm[4:0] ไปช่องบิต 11–7 — รวมกันยังเป็นค่า 12 บิตเดิม แค่กระจายตำแหน่ง (ตอนเขียน assembly ยังเขียน offset เป็นก้อนเดียวเหมือนเดิม การฉีกเป็นงานของ assembler)

ตัวอย่าง — sw x5, -4(x2)
  1. แยก operand: rs2=x5, rs1=x2, imm=-4
  2. -4 ใน 12 บิต two's complement = 111111111100 → ฉีก: imm[11:5]=1111111 (sign bit อยู่ท่อนนี้), imm[4:0]=11100
  3. วางตามผัง S (imm[11:5]|rs2|rs1|funct3|imm[4:0]|opcode): 1111111 00101 00010 010 11100 0100011
  4. อ่านฐานสิบหก: 1111 1110 0101 0001 0010 1110 0010 0011
คำตอบ = 0xFE512E23 — sign bit ของ imm (บิตที่ 11 ของ imm) ตกที่บิต 31 ของคำสั่งเสมอ store offset ติดลบจึงขึ้นต้นด้วย 0xF เหมือน addi ค่าลบ

รูปแบบ B — branch (PC-relative, signed 13 บิต)

beq x1, x2, LABEL เปรียบเทียบ rs1 กับ rs2 (ไม่มี rd) offset เป็น PC-relative มีเครื่องหมาย บวกเข้ากับที่อยู่ของคำสั่ง branch เอง เราพูดว่า immediate ของ branch มี 13 บิต แต่เก็บจริงเพียง 12 บิต เพราะคำสั่ง RV32I ทุกตัว align ที่ขอบ 2 byte เสมอ บิตล่างสุด (bit 0) ของ offset จึงเป็น 0 เสมอ ฮาร์ดแวร์ไม่ต้องเก็บมัน — ครอบคลุมระยะกระโดดประมาณ −4096 ถึง +4094 byte imm ของรูปแบบ B ถูกแยกเป็น 4 กล่อง (สับบิต, scramble): imm[12] ที่บิต 31, imm[10:5] ที่บิต 30–25, imm[4:1] ที่บิต 11–8, และ imm[11] ที่โดดเดี่ยวอยู่บิต 7

ตัวอย่าง — beq x1, x2, 8
  1. แยก operand: rs1=x1, rs2=x2, offset=+8 → imm 13 บิต = 0000000001000
  2. แจกแจง: imm[12]=0, imm[11]=0, imm[10:5]=000000, imm[4:1]=0100, imm[0]=0 (ไม่เก็บ)
  3. วางตามผัง B: บิต31=imm[12]=0, บิต30–25=imm[10:5]=000000, บิต24–20=rs2=00010, บิต19–15=rs1=00001, บิต14–12=funct3=000, บิต11–8=imm[4:1]=0100, บิต7=imm[11]=0, บิต6–0=opcode=1100011
  4. อ่านฐานสิบหก: 0000 0000 0010 0000 1000 0100 0110 0011
คำตอบ = 0x00208463

รูปแบบ U — upper immediate 20 บิต

immediate 12 บิตของ I/S ใส่ค่าคงที่ 32 บิตเต็มไม่ได้ รูปแบบ U แก้ด้วยการเก็บ immediate 20 บิตบน (วางตรง ๆ ที่บิต 31–12) มีแค่ rd ไม่มีรีจิสเตอร์ต้นทาง: lui x1, 0x12345 วาง 0x12345 ลงบิต [31:12] บิตล่าง 12 บิตเป็น 0, auipc x1, 0x12345 บวก (0x12345 << 12) เข้ากับ PC ของคำสั่งนี้ คู่ lui+addi (หรือ auipc+addi) จึงประกอบกันเป็นค่าคงที่/ที่อยู่ 32 บิตได้: 20 บิตบนจาก U + 12 บิตล่างจาก I

ตัวอย่าง — lui x5, 0x12345
  1. แยก operand: rd=x5, imm(20 บิตบน)=0x12345
  2. แปลงบิต: 0x12345 → 0001 0010 0011 0100 0101, rd=00101
  3. วางตามผัง U (imm[31:12]|rd|opcode): 00010010001101000101 00101 0110111
  4. อ่านฐานสิบหก: 0001 0010 0011 0100 0101 0010 1011 0111
คำตอบ = 0x123452B7 — ผลตอนรัน x5 = 0x12345000 (20 บิตบนคือ 0x12345 ส่วน 12 บิตล่างเป็น 0)

รูปแบบ J — jump 21 บิต

jal x1, LABEL (jump and link) กระโดดไกลแบบ PC-relative และเก็บที่อยู่คืน (PC+4) ลง rd immediate เป็น signed 21 บิต เก็บจริง 20 บิต (บิต 0 = 0 เสมอ) ครอบคลุมระยะประมาณ ±1 MB เรียงในคำสั่งเป็น imm[20|10:1|11|19:12] ที่บิต 31–12 — jal x0, LABEL เขียนลง x0 คือทิ้ง return address = กระโดดอย่างเดียว (เท่ากับ pseudo j LABEL)

ทำไม immediate ถูกจัดเรียง (scramble)

ถ้าดูดิบ ๆ immediate ของ S, B, U, J ดูยุ่งเหยิง แต่เป็นผลของการตัดสินใจออกแบบสามขั้น:

  1. ตรึงฟิลด์รีจิสเตอร์ไว้ที่เดิมก่อน — rs1 บิต 19–15, rs2 บิต 24–20, rd บิต 11–7 เหมือนกันทุกรูปแบบ เพื่อให้ decoder ส่งหมายเลขรีจิสเตอร์เข้า register file ได้ทันที ผลพวงคือ immediate ของ S/B ถูกบังคับให้แตกเป็นสองท่อนล้อมรอบฟิลด์รีจิสเตอร์
  2. ตรึง sign bit ไว้ที่บิต 31 เสมอ — บิตบนสุดของ immediate (imm[11] ของ I/S, imm[12] ของ B, imm[31] ของ U, imm[20] ของ J) ถูกวางที่บิต 31 ของคำสั่งทุกรูปแบบ sign-extend จึงเป็นวงจรเดียว ไม่ต้องมี mux เลือกว่าบิตไหนคือ sign
  3. บิตที่เหลือจัดให้น้ำหนักเดียวกันอยู่ตำแหน่งเดิมให้มากที่สุด — เช่น imm[10:5] อยู่บิต 30–25 เหมือนกันทั้ง I, S, B, J
│31        25│24    20│19    15│14  12│11        7│6      0│
R:    │   funct7   │  rs2   │  rs1   │  f3  │     rd    │ opcode │
I:    │      imm[11:0]      │  rs1   │  f3  │     rd    │ opcode │
S:    │ imm[11:5]  │  rs2   │  rs1   │  f3  │  imm[4:0] │ opcode │
B:    │imm[12|10:5]│  rs2   │  rs1   │  f3  │imm[4:1|11]│ opcode │
U:    │              imm[31:12]             │     rd    │ opcode │
J:    │        imm[20|10:1|11|19:12]        │     rd    │ opcode │
หลักการ scramble ในประโยคเดียวรักษาตำแหน่งบิตให้คงที่ข้ามรูปแบบ (โดยเฉพาะ sign bit ที่บิต 31) สำคัญกว่าการเรียง immediate ให้สวย — ที่มา: Patterson & Hennessy, Computer Organization and Design (RISC-V Edition) รูปที่ 2.17/2.18

ทำไมต้อง sign-extend

immediate ใน RV32I (ยกเว้นบางคำสั่ง shift) เป็นค่ามีเครื่องหมาย (signed, two's complement) ก่อนนำไปคำนวณกับรีจิสเตอร์ 32 บิต ต้องขยายให้ครบ 32 บิตด้วยสองขั้น: อ่านบิตบนสุดของ immediate (sign bit) แล้วคัดลอกบิตนั้นไปเติมบิตบนที่เหลือทั้งหมด ผลคือค่าคงเดิม เช่น addi x1, x2, -1 ได้ −1 จริง (0xFFFFFFFF) ไม่ใช่เลขบวกใหญ่ (ถ้าเผลอเติม 0 แทนจะได้เลขบวกผิดมหาศาล) รูปแบบ U ไม่ต้อง sign-extend เพราะวาง 20 บิตลงบิต [31:12] ตรง ๆ แล้วเติม 0 ที่บิตล่าง

offset ของคำสั่ง branch (รูปแบบ B) มองเป็น signed 13 บิตที่บิตล่างสุดเป็น 0 เสมอ ระยะกระโดดไปข้างหน้าที่ไกลที่สุดเป็นกี่ byte
ตอบ: signed 13 บิต ให้ค่าบวกมากสุด = 2^12 − 1 = 4095 แต่บิตล่างสุดถูกบังคับเป็น 0 (align 2 byte) ค่าบวกมากสุดที่ลงตัวคือ 4094 byte (ฝั่งลบไปได้ถึง −4096)
อ้างอิง
  • D. Patterson และ J. Hennessy, Computer Organization and Design (RISC-V Edition) — บทที่ 2

11.3 เข้ารหัส/ถอดรหัส และ Pseudo-instructions

Decode: หั่นเลข 32 บิตกลับเป็น fields

สำหรับ CPU ไม่มี "คำสั่ง add" เป็นข้อความ มีแต่เลข 32 บิตที่ดึงจากหน่วยความจำทุกไซเคิล decode หั่นก้อนนี้ตามตำแหน่งบิต: opcode คืออะไร อ่านรีจิสเตอร์เบอร์ไหน (rs1, rs2) เขียนเบอร์ไหน (rd) ให้ ALU ทำอะไร (funct3/funct7) — ทั้งหมดกลายเป็นสัญญาณควบคุม datapath opcode อยู่ที่บิตล่างสุด 7 บิต (บิต 6-0) เสมอทุกรูปแบบ ฮาร์ดแวร์อ่าน opcode ก่อนเพื่อรู้รูปแบบ แล้วจึงตีความบิตที่เหลือถูก — encode (assembler แปลงข้อความเป็นบิต) กับ decode (ฮาร์ดแวร์แปลงบิตกลับเป็น fields) คือกระบวนการกลับทางกัน ใช้กติกาเดียวกันคือ "ช่องไหนอยู่บิตไหน"

บันไดถอดรหัส: ขั้นตอนมาตรฐาน 4 ขั้น

  1. ดู opcode 7 บิตล่าง (บิต 6-0) → รู้กลุ่มคำสั่งและรูปแบบที่ต้องใช้หั่นบิตที่เหลือ
  2. หั่น fields ตามผังของรูปแบบนั้น (rs1/rs2/rd/funct3/funct7 อยู่ตำแหน่งตายตัว)
  3. ประกอบ immediate (ถ้ามี) — บางรูปแบบเก็บท่อนเดียว บางรูปแบบต้องนำสองท่อนมาต่อกัน แล้ว sign-extend เป็น 32 บิต
  4. เขียนกลับเป็น assembly จาก fields ที่ได้
opcode (บิต 6-0)กลุ่มรูปแบบ
0010011OP-IMM (addi, srli, srai, ...)I-type
0000011LOAD (lw, lb, ...)I-type
0100011STORE (sw, sb, ...)S-type
0110011OP (add, sub, ...)R-type
ตัวอย่าง — ถอดรหัส 0xFE512E23 (imm สองท่อน + sign-extend)
  1. opcode 7 บิตล่าง: 0xFE512E23 = 1111 1110 0101 0001 0010 1110 0010 0011 → opcode = 0100011 → กลุ่ม STORE → S-type
  2. หั่น fields S-type (ไม่มี rd! บิต 11-7 เป็น imm ท่อนล่าง): imm[11:5]=1111111, rs2=00101→x5, rs1=00010→x2, funct3=010 (SW)
  3. ประกอบ imm สองท่อน: 1111111 ต่อ 11100 = 111111111100 = 0xFFC, บิต 11=1 → sign-extend: 0xFFC − 0x1000 = −4
คำตอบ: sw x5, -4(x2) — ความหมาย: Mem[x2 − 4] ← x5 (จุดพลาดบ่อย: หลงอ่านบิต 11-7 เป็น rd จะได้ x28 ซึ่งผิด เพราะ store ไม่เขียนรีจิสเตอร์ ยืมตำแหน่งนั้นเก็บ imm ท่อนล่างแทน)
ตัวอย่าง — ถอดรหัส 0x407302B3 (จุดหลอกที่ funct7)
  1. opcode: 0100 0000 0111 0011 0000 0010 1011 0011 → opcode=0110011 → R-type
  2. หั่น fields: funct7=0100000, rs2=00111→x7, rs1=00110→x6, funct3=000, rd=00101→x5
  3. R-type ไม่มี immediate แต่ต้องอ่าน funct7 ให้ครบ: funct3=000 + funct7=0100000 (0x20) → SUB (ถ้า funct7=0000000 ที่ funct3 เดียวกันจะเป็น ADD — 0x007302B3 คือ add x5,x6,x7)
คำตอบ: sub x5, x6, x7 — add กับ sub มี opcode และ funct3 เหมือนกันทุกบิต ต่างกันแค่ funct7 (บิต 30) กับดักเดียวกันนี้ใช้แยก srli/srai ด้วย
ถอดรหัส 0x4062D293 — opcode = 0010011 (OP-IMM), funct3 = 101, บิต 31-25 = 0100000 ได้คำสั่งใด
ตอบ: srai x5, x5, 6 — ในกลุ่ม shift ขวา บิต 31-25 = 0100000 เลือก srai (เลื่อนขวาแบบคงเครื่องหมาย) ถ้าเป็น 0000000 จะเป็น srli แทน (เข้ารหัสได้ 0x0062D293 ต่างกันแค่บิต 30)

Pseudo-instruction = ทางลัดที่ assembler ขยายให้

pseudo-instruction ไม่มี opcode ของตัวเอง ฮาร์ดแวร์ไม่เคยเห็น มันถูกแปลงตั้งแต่ตอน assemble ตัวที่พบบ่อย:

pseudoขยายเป็นคำสั่งจริงความหมาย
mv rd, rsaddi rd, rs, 0คัดลอกค่า rs → rd
nopaddi x0, x0, 0ไม่ทำอะไร (no operation)
j offsetjal x0, offsetกระโดดไม่มีเงื่อนไข ทิ้ง return address
retjalr x0, 0(ra)กลับจากซับรูทีน (กระโดดไปที่ ra)
li rd, immaddi หรือ lui+addiโหลดค่าคงที่
la rd, symbolauipc + addiโหลดที่อยู่ของ symbol (PC-relative)

สี่ตัวแรกขยายเป็นคำสั่งเดียว เพราะใช้ทางลัดสองทาง — "บวกด้วย 0 แล้วได้ค่าเดิม" (mv) และ "เขียนผลลง x0 ที่ผูกค่าเป็นศูนย์เสมอ เท่ากับทิ้งผล" (nop, j, ret) เพื่อให้โค้ดอ่านง่ายและสื่อเจตนาชัด โดยไม่ต้องเพิ่ม opcode ในฮาร์ดแวร์ ISA จึงคงเล็กและถอดรหัสง่าย ภาระความสะดวกย้ายไปอยู่ที่ assembler แทน

assembler ขยาย pseudo-instruction ret เป็นคำสั่งจริงใด
ตอบ: jalr x0, 0(ra) (รหัสเครื่อง 0x00008067) — กระโดดไปยังที่อยู่ใน ra (x1) โดยเขียน return address ใหม่ลง x0 ซึ่งถูกทิ้ง

ค่าคงที่ใหญ่ และที่อยู่ PC-relative: ทำไมต้องสองคำสั่ง

คำสั่ง RV32I หนึ่งคำสั่งยาว 32 บิต แต่ใส่ immediate ได้แค่ส่วนหนึ่ง (I-type ได้ 12 บิต) จึงใส่ค่าคงที่เต็ม 32 บิตในคำสั่งเดียวไม่ได้ ต้องแบ่งเป็น "20 บิตบน + 12 บิตล่าง": lui rd, imm20 วางค่า 20 บิตที่บิต 31-12 ของ rd เติม 0 ที่บิตล่าง ตามด้วย addi rd, rd, lo12 เติมส่วนล่าง 12 บิต ได้ค่าครบ 32 บิต สำหรับ "ที่อยู่" ที่ต้องย้ายตามตำแหน่งโปรแกรม (PC-relative) ใช้ auipc แทน lui — นำ 20 บิตบวกกับ PC ปัจจุบัน

ระวังบิตที่ 11: hi อาจถูกบวกชดเชย 1immediate ของ addi กว้าง 12 บิตและถูก sign-extend ถ้าบิตที่ 11 ของส่วนล่างเป็น 1 ค่าที่บวกจะกลายเป็นลบ assembler จึงบวก "ส่วนบน 20 บิต" เพิ่ม 1 เพื่อชดเชย

บันได li: assembler เลือกวิธีขยายอย่างไร

เมื่อ assembler เจอ li rd, imm ตัดสินใจตามลำดับนี้เสมอ: (1) ค่าอยู่ในช่วง −2048 ถึง 2047 → ใช้ addi ตัวเดียว (2) ค่าเกินช่วงนั้น แต่ 12 บิตล่างเป็นศูนย์ทั้งหมด → ใช้ lui ตัวเดียว (3) นอกนั้น → ต้องใช้ lui+addi สองคำสั่ง และต้องตรวจบิตที่ 11 ของท่อนล่างว่าต้องชดเชยหรือไม่

ตัวอย่าง — li t0, 0x12345FFF (ต้องชดเชยบิต 11)
  1. แยกสองท่อน: บน (บิต 31-12) = 0x12345, ล่าง (บิต 11-0) = 0xFFF
  2. ตรวจบิต 11 ของท่อนล่าง: 0xFFF = 1111 1111 1111 → บิต 11 = 1 → addi sign-extend มองเป็น −1 (ไม่ใช่ +4095)
  3. ลองแบบไม่ชดเชย: lui t0,0x12345 (0x12345000) + addi t0,t0,-1 = 0x12344FFF (ผิด! ขาดไป 0x1000 พอดี)
  4. ชดเชยที่ส่วนบน: hi = 0x12345 + 1 = 0x12346 → lui t0,0x12346 (0x12346000) + addi t0,t0,-1 = 0x12345FFF (ถูก)
การ +1 ที่ hi คือการ "จ่ายเผื่อ" ไว้ล่วงหน้า 0x1000 เพื่อให้ addi ที่ติดลบดึงกลับลงมาพอดีค่าเป้าหมาย

la ผ่าน auipc: ไล่เลขด้วย PC จริง

สมมุติ la t0, msg วางที่ address 0x10000 และ label msg อยู่ที่ 0x12345

  1. หา offset จากตำแหน่งคำสั่ง (PC-relative): offset = 0x12345 − 0x10000 = 0x2345
  2. แยก offset เป็นสองท่อน: บน=0x2, ล่าง=0x345, บิต 11 ของ 0x345=0 → ไม่ต้องชดเชย
  3. auipc t0, 0x2 → t0 = PC + (0x2<<12) = 0x10000 + 0x2000 = 0x12000
  4. addi t0, t0, 0x345 → t0 = 0x12000 + 0x345 = 0x12345 (ชี้ msg พอดี)

เทียบกับ li: lui/addi ได้ "เลขที่อยู่ตายตัว" แต่ auipc ได้ "ที่อยู่อิง PC" — ถ้าย้ายโปรแกรมทั้งก้อนไป address อื่น ระยะ 0x2345 ยังเท่าเดิม la จึงยังชี้ msg ถูกโดยไม่ต้องแก้เลขสักตัว

อ้างอิง
  • D. Patterson และ J. Hennessy, Computer Organization and Design (RISC-V Edition) — บทที่ 2

11.4 Calling Convention ครบวง และ Recursion

Calling convention: กติกาเรียก subroutine ระดับ ABI

Calling convention กำหนดว่า: argument ส่งทางไหน, return value คืนทางไหน, return address เก็บที่ไหน, และใครรับผิดชอบเซฟ register ตัวใด เมื่อทุกฝ่าย (compiler/library/โค้ดเรา) ทำตามกติกาเดียวกัน โค้ดคนละทีมจึงเรียกข้ามกันได้ ซีพียูไม่ "รู้จัก" ฟังก์ชัน มันมีแค่คำสั่งกระโดด (jal/jalr) รีจิสเตอร์ และหน่วยความจำ — calling convention คือชั้นข้อตกลงที่ทำให้ของดิบ ๆ นี้กลายเป็นการเรียกฟังก์ชันซ้อนกันลึก ๆ ได้จริง

argument ส่งใน a0–a7 (x10–x17) ได้ถึง 8 ตัวแรกผ่านรีจิสเตอร์ เกินนั้นส่งผ่าน stack · return value คืนใน a0 (และ a1 สำหรับค่า 64 บิตหรือคู่ค่า) — a0/a1 ทำหน้าที่สองอย่าง: รับ argument ขาเข้าและส่งค่าคืนขากลับ · return address เก็บใน ra (x1) โดยอัตโนมัติเมื่อใช้ jal/jalr

# ฝั่งผู้เรียก (caller): เตรียม argument แล้วเรียก
    li   a0, 6          # argument ตัวที่ 1 → a0
    li   a1, 7          # argument ตัวที่ 2 → a1
    jal  ra, mul_func   # กระโดดไป mul_func, เซฟ PC+4 ลง ra อัตโนมัติ
    # หลังกลับมา ผลลัพธ์อยู่ใน a0
ฟังก์ชัน int g(int p, int q) — argument q อยู่รีจิสเตอร์ใด และผลลัพธ์ส่งคืนทางใด
ตอบ: q อยู่ a1 (argument เริ่มนับที่ a0: p→a0, q→a1) ผลใน a0 เสมอ (ค่าคืนตัวแรกอยู่ a0 ไม่ใช่ a ตัวสุดท้ายที่ใช้)

Prologue และ Epilogue: กรอบของทุก subroutine

subroutine มาตรฐานห่อตัวงานด้วย prologue (ตอนเข้า) และ epilogue (ตอนออก) เพื่อจองพื้นที่ทำงานบน stack และคืนค่า callee-saved register ให้ครบก่อนกลับ Prologue: จอง stack frame ด้วย addi sp, sp, -N (เลื่อน sp ลง เพราะ stack โตลงทาง address ต่ำ) แล้ว sw เซฟ ra กับ s-register ที่จะใช้ Epilogue: lw restore ค่ากลับ, คืนพื้นที่ด้วย addi sp, sp, +N, แล้ว ret

กฎเหล็กของ spsp เป็น callee-saved subroutine ต้องคืน sp ให้เท่าค่าตอนเข้าเป๊ะ ๆ ก่อน return ทุกครั้ง ถ้า addi sp,sp,-N ตอนเข้า ก็ต้อง addi sp,sp,+N ตอนออกด้วยตัวเลข N เท่ากัน ไม่งั้น caller จะหา frame ของตัวเองไม่เจอและโปรแกรมพังทั้งสาย (RISC-V ไม่มีคำสั่ง push อัตโนมัติ ต้องเลื่อน sp เอง)
ตัวอย่าง — ครบวงจร 4 ขั้นตอนด้วย address จริง

สมมุติ sp ตอนเข้า = 0x7FFFFFF0 และ caller เรียกด้วย jal ที่ address 0x00010100 ดังนั้น ra ขาเข้า = 0x00010104

func:
    addi sp, sp, -16    # ขั้น 1: sp 0x7FFFFFF0 → 0x7FFFFFE0
    sw   ra, 12(sp)     # ขั้น 2: MEM[0x7FFFFFEC] = ra = 0x00010104
    sw   s0, 8(sp)      # ขั้น 2: MEM[0x7FFFFFE8] = s0 เดิมของ caller
    # ...เนื้องาน...    # ขั้น 3: jal ได้ตามสบาย เพราะ ra/s0 เดิมปลอดภัยแล้ว
    lw   s0, 8(sp)      # ขั้น 4: กู้ s0 คืน
    lw   ra, 12(sp)     # ขั้น 4: กู้ ra คืน = 0x00010104
    addi sp, sp, 16     # ขั้น 4: sp 0x7FFFFFE0 → 0x7FFFFFF0 (เท่าตอนเข้า)
    ret                 # ขั้น 4: PC = ra = 0x00010104 กลับ caller
ขั้น 1–2 คือ prologue, ขั้น 4 คือ epilogue ที่ทำ "ย้อนกลับทุกอย่าง" ของขั้น 1–2 แบบกระจกเงา — offset 12(sp)/8(sp) ตอนเซฟกับตอนกู้คืนเป็นตัวเดียวกัน sp จึงกลับที่เดิมและ ra ได้ค่าเดิมครบ (ที่มา: Patterson & Hennessy, COD RISC-V Edition รูปที่ 2.10)

Leaf vs Non-leaf

Leaf function: ไม่เรียกใครต่อ จึงไม่มีใครมาทับ ra ของมัน — ถ้าใช้แค่ t-register (caller-saved) อาจไม่ต้องเซฟ ra หรือสร้าง stack frame เลย ทำงานเสร็จ ret ได้ทันที Non-leaf function: เรียก subroutine อื่นต่อ การ jal ครั้งใหม่จะทับ ra เดิม จึงต้องเซฟ ra ลง stack ใน prologue แล้ว restore ก่อน return เสมอ

ตัวอย่าง — double(x) เป็น leaf ล้วน ไม่ต้องแตะ stack เลย
double:                 # int double(int x) — x มาใน a0, คืนผลใน a0
    add  a0, a0, a0     # a0 = x + x
    ret                 # กลับได้ทันที ไม่ต้องเก็บกวาดอะไร
เช็กลิสต์ 2 ข้อของฟังก์ชันที่ไม่ต้องแตะ stack: (1) ไม่เรียก subroutine อื่นต่อ (เป็น leaf) → ไม่มี jal มาทับ ra (2) ใช้แต่ register ฝั่ง caller-saved (a0–a7, t0–t6) → ไม่ต้องเซฟ s-register ใด double ผ่านทั้งสองข้อ → เหลือเนื้องาน + ret รวม 2 คำสั่งจบ
ตัวอย่าง — add_double(x,y) = double(x) + y เรียกฟังก์ชันอื่นหนึ่งครั้ง

มีของ 2 อย่างที่ต้อง "รอดข้าม" การเรียก double: return address ของเราเอง (ra) และค่า y ที่ยังต้องใช้หลังกลับมา

add_double:                     # a0 = x, a1 = y
    addi sp, sp, -16    # ขั้น 1: จอง frame 16 ไบต์
    sw   ra, 12(sp)     # ขั้น 2: เซฟ ra — เดี๋ยว jal จะทับ
    sw   s0, 8(sp)      # ขั้น 2: เซฟ s0 เดิม — เราจะยืม s0 ใช้
    mv   s0, a1         # ขั้น 3: ย้าย y ไปที่ปลอดภัยข้าม call
    jal  ra, double     # ขั้น 3: เรียก double(x) → ผลใน a0
    add  a0, a0, s0     # ขั้น 3: a0 = double(x) + y
    lw   s0, 8(sp)      # ขั้น 4: กู้ s0 คืนให้ caller
    lw   ra, 12(sp)     # ขั้น 4: กู้ ra คืน
    addi sp, sp, 16     # ขั้น 4: คืน sp เท่าตอนเข้า
    ret                 # ขั้น 4: กลับ caller
ให้ x=7, y=5: หลัง jal ra,double ค่า ra ถูกทับ (ทับ 0x00010054) แต่เซฟไว้แล้วบน stack ใน double: a0=14, ret กลับมา a0=14+5=19 แล้ว epilogue restore s0/ra/sp ครบ — ค่า y เก็บใน s0 ไม่ใช่ t0 เพราะ double มีสิทธิ์ทับ t-register ได้เสมอ ค่าที่ต้องรอดข้าม call ต้องอยู่ใน s-register หรือบน stack เท่านั้น
จากโค้ด add_double ถ้าลืมเซฟ ra (ตัดบรรทัด sw/lw ra ทิ้ง) เมื่อรันมาถึง ret ท้ายฟังก์ชันจะเกิดอะไรขึ้น
ตอบ: ret กระโดดไปที่คำสั่งถัดจาก jal ใน add_double เอง วนทำท่อนท้ายฟังก์ชันซ้ำไม่รู้จบ — jal เขียน address ของคำสั่งถัดไป (PC+4) ทับ ra เสมอ ไม่ได้ช่วยเก็บค่าเก่าลง stack ให้ ปัญหาหลักคือกลับผิดที่ ไม่ใช่แค่ค่าคำตอบเพี้ยน

Recursion: stack frame ซ้อนกันชั้นต่อชั้น

Recursion คือ subroutine ที่เรียกตัวเอง — เป็นกรณีพิเศษของ non-leaf จึงต้องเซฟ ra ทุกครั้ง และต้องเก็บค่าที่ "ยังจำเป็นหลังการเรียกซ้ำ" ไว้ใน register แบบ callee-saved (เช่น s0) หรือบน stack เพราะการเรียกซ้ำจะทับ a0/ra

# int fact(int n)  — n อยู่ใน a0, คืนผลใน a0
fact:
    addi sp, sp, -16    # prologue: จอง frame
    sw   ra, 12(sp)     #   เซฟ ra (non-leaf เพราะเรียกตัวเอง)
    sw   s0, 8(sp)      #   เซฟ s0 (callee-saved) เพื่อจำค่า n ข้ามการเรียกซ้ำ
    li   t0, 2
    blt  a0, t0, base   # ถ้า n น้อยกว่า 2 (คือ n = 1) ไปที่ base case
    mv   s0, a0         # จำ n ไว้ใน s0 (a0 จะถูกทับตอนเรียกซ้ำ)
    addi a0, a0, -1     # เตรียม argument: n-1
    jal  ra, fact       # เรียก fact(n-1) → ผลกลับมาใน a0
    mul  a0, a0, s0     # a0 = fact(n-1) * n   (mul อยู่ใน extension M)
    j    done
base:
    li   a0, 1          # base case: fact(1) = 1
done:
    lw   s0, 8(sp)      # epilogue: restore s0, ra
    lw   ra, 12(sp)
    addi sp, sp, 16     #   คืน frame
    ret

จุดสำคัญ: li t0,2 + blt a0,t0,base — RV32I ไม่มี branch ที่เทียบกับค่าคงที่ตรง ๆ จึงต้องเอา 2 ใส่ t0 ก่อน · mv s0,a0 ต้องมาก่อน addi a0,a0,-1 เสมอ — จำ n ไว้ก่อนแล้วค่อยเตรียม n-1 ถ้าสลับกันจะกลายเป็นจำ n-1 แทน

ตัวอย่าง — trace fact(3) เต็มรูป: ขาลง push, ขาขึ้น unwind + คูณสะสม
ชั้นเรียกra ที่เซฟs0 ที่เซฟ (n ของชั้นก่อน)a0 ขาไปa0 ขากลับ
1fact(3)0x00010104 (กลับ main)0x00000000 ของ main36
2fact(2)0x00010020 (กลับกลาง fact)3 = n ชั้น 122
3fact(1)0x00010020 (กลับกลาง fact)2 = n ชั้น 211
  1. ขาลง: แต่ละชั้นเซฟ s0 ของผู้เรียกลง frame ก่อน แล้วค่อยตั้ง s0 = n ของตัวเอง จนถึง fact(1) ซึ่งเป็น base case (a0=1) ไม่เรียกต่อ
  2. ขาขึ้น (unwind): ชั้น 3 คืนค่า a0=1, กู้ s0=2, ret → ชั้น 2 ทำ mul: a0=1×2=2, กู้ s0=3, ret → ชั้น 1 ทำ mul: a0=2×3=6, กู้ s0=0 ของ main, ret กลับ main
ผลสุดท้ายใน a0 = 6 (= 3×2×1) ra ของชั้น 2 กับชั้น 3 เท่ากัน (0x00010020) เพราะถูกเรียกจาก jal บรรทัดเดียวกันกลางตัว fact ส่วนชั้น 1 ถูกเรียกจาก main — ค่า n แต่ละชั้นถูกฝากคนละ address บน stack จึงไม่มีวันทับกัน นี่คือหัวใจที่ทำให้ recursion ทำงานได้บนรีจิสเตอร์ชุดเดียว
เรียก fact(5) โดย sp ตอนอยู่ใน main คือ 0x7FFFFFF0 และแต่ละชั้นจอง frame 16 ไบต์เท่ากันหมด ขณะลงลึกสุด (อยู่ใน fact(1)) ค่า sp เป็นเท่าใด
ตอบ: ตอนลึกสุดมี frame ซ้อนกัน 5 ชั้น (fact(5) ถึง fact(1)) รวม 5×16=80 ไบต์ → sp = 0x7FFFFFF0 − 80 = 0x7FFFFFA0 (จุดพลาดบ่อยคือนับแค่ 4 ชั้น ลืมว่า fact(1) ก็รัน prologue จองเฟรมของตัวเองก่อนเช็ก base case)

Modular ISA: base เล็ก + extension ตามงาน

flowchart LR I["RV32I base(integer, บังคับมี)"] --> M["+ M : mul/div"] I --> F["+ F : float 32-bit"] I --> D["+ D : float 64-bit"] I --> A["+ A : atomic"] I --> C["+ C : compressed 16-bit"]

หัวใจของ RISC-V คือออกแบบเป็นโมดูล: มี base ขนาดเล็กที่ทุกชิปต้องมี แล้วเลือกเสริม extension ตามความต้องการของงาน base I มี 51 คำสั่ง ส่วนขยาย M เติม 13, A เติม 22, F เติม 30, D เติม 32, C เติม 36 คำสั่ง (ที่มา: Patterson & Hennessy, COD RISC-V Edition รูปที่ 2.42)

ส่วนขยายความหมาย
RV32Ibase integer ISA ทุกชิป RISC-V (32 บิต) ต้องมี
Mคูณ/หาร (mul, div, rem) — base ไม่มีคูณ/หารฮาร์ดแวร์
F / Dfloating point single (32 บิต) / double (64 บิต) ตาม IEEE 754 พร้อม register ชุด f0–f31
Aatomic (load-reserved/store-conditional) สำหรับ synchronization บนระบบหลายคอร์
Ccompressed คำสั่ง 16 บิตสำหรับคำสั่งที่ใช้บ่อย ช่วยลดขนาดโค้ด
base RV32I ก็ Turing-complete แล้วextension ทุกตัวเป็นเรื่องของ "ความเร็ว/ความสะดวก/ขนาดโค้ด" ไม่ใช่ "ความสามารถในการคำนวณ" ถ้าไม่มี M ก็คูณ/หารด้วยซอฟต์แวร์ได้ (loop บวก/เลื่อนบิตซ้ำ) ถ้าไม่มี F ก็จำลอง float ด้วยจำนวนเต็มได้ ทิศทางความเข้ากันได้เป็นแบบ subset ⊂ superset: โปรแกรม base ล้วน ๆ รันบนชิปที่มี extension เพิ่มได้ แต่ถ้าใช้ mul/float ไปรันบนชิป RV32I เปล่า จะเจอ illegal instruction เพราะ CPU ไม่รู้จัก opcode นั้น
อ้างอิง
  • D. Patterson และ J. Hennessy, Computer Organization and Design (RISC-V Edition) — บทที่ 2

11.5 เลขคณิตของเครื่อง: ส่วนขยาย M (คูณ/หาร) และ F (จุดลอยตัว)

คำสั่งทั้ง 8 ของส่วนขยาย M

RV32I base ไม่มีคำสั่งคูณ — คูณได้ด้วยซอฟต์แวร์ (ลูปบวกและเลื่อนบิต) แต่ช้ากว่าฮาร์ดแวร์หลายสิบเท่า ส่วนขยาย M จึงเพิ่มคำสั่ง 8 ตัว: mul ผลคูณ 32 บิตล่าง · mulh ผลคูณ 32 บิตบน (มีเครื่องหมายทั้งคู่) · mulhu บิตบนแบบไม่มีเครื่องหมาย · mulhsu บิตบนแบบตัวตั้งมีเครื่องหมาย ตัวคูณไม่มี · div ผลหารมีเครื่องหมาย · divu ผลหารไม่มีเครื่องหมาย · rem เศษมีเครื่องหมาย · remu เศษไม่มีเครื่องหมาย

คูณอย่างไรด้วยวงจร — เลื่อนแล้วบวก (shift-add)

หลักการเดียวกับคูณเลขยกกำลังสิบด้วยมือ แต่ฐานสองง่ายกว่า เพราะแต่ละหลักของตัวคูณเป็นได้แค่ 0 หรือ 1 ทำซ้ำเท่าจำนวนบิตของตัวคูณ รอบละสามจังหวะ: (1) ดูบิตต่ำสุดของตัวคูณ ถ้าเป็น 1 บวกตัวตั้งเข้าผลสะสม (2) เลื่อนตัวตั้งไปทางซ้ายหนึ่งบิต (คูณสอง) (3) เลื่อนตัวคูณไปทางขวาหนึ่งบิต

ตัวอย่าง — 6 × 5 (0110 × 0101) ด้วยวิธีเลื่อนแล้วบวก
รอบbit0ผลสะสมตัวตั้งหลังเลื่อนการกระทำ
1166 → 12บวก 6
20612 → 24ไม่บวก
313024 → 48บวก 24
403048 → 96ไม่บวก
ผลคูณ = 30 ✓ (รอบที่ 3 บวก 24 ไม่ใช่ 6 เพราะตัวตั้งถูกเลื่อนมาแล้วสองครั้ง เท่ากับบวก 6×4 ซึ่งคือน้ำหนักของบิตตำแหน่งที่สามพอดี)

มุมเลขคณิตกับมุมฮาร์ดแวร์ — อัลกอริทึมเดียวกัน วงจรต่างกัน

ถ้าสร้างวงจรตามตารางข้างบนตรง ๆ (มุมเลขคณิต) ต้องมีตัวบวกกว้างเท่าผลลัพธ์และรีจิสเตอร์ตัวตั้งกว้างเท่ากัน เพราะมันเลื่อนซ้ายเรื่อย ๆ ฮาร์ดแวร์จริงพลิกกลับด้าน (มุมฮาร์ดแวร์): ให้ ผลคูณเลื่อนขวา แทนที่จะให้ตัวตั้งเลื่อนซ้าย

ใครเลื่อนใครนิ่งตัวบวกที่ต้องใช้
มุมเลขคณิตตัวตั้งเลื่อนซ้ายผลสะสม8 บิต
มุมฮาร์ดแวร์ผลคูณเลื่อนขวาตัวตั้ง4 บิต เหลือครึ่งเดียว

เคล็ดของมุมฮาร์ดแวร์คือใช้รีจิสเตอร์ผลคูณ 8 บิตตัวเดียว ครึ่งขวาเริ่มต้นเป็นตัวคูณ บวกตัวตั้งเข้าครึ่งซ้ายเท่านั้น จึงต้องการตัวบวกแค่ 4 บิต เลื่อนขวาหนึ่งบิตทุกรอบ ตัวคูณจึงถูกกินหายไปทางขวา ขณะที่ผลคูณค่อย ๆ งอกเข้ามาแทนที่ — กุญแจ: "แทนที่จะเลื่อนตัวตั้งขึ้นไปหาผลสะสม ก็เลื่อนผลสะสมลงมาหาตัวตั้งแทน" ผลลัพธ์เท่ากันเป๊ะแต่ตัวบวกไม่ต้องกว้างตามอีกต่อไป (ที่มา: Patterson & Hennessy, COD RISC-V Edition รูปที่ 3.3 และ 3.5)

ชิปจริงไม่ทำแบบวนทีละรอบ (32 รอบสำหรับ 32 บิตช้าเกินไป) แต่ "คลี่ลูป" ออกเป็นตัวบวกหลายสิบตัวต่อกันเป็นชั้นทำงานขนานกัน ได้ผลคูณใน 3-4 ไซเคิล ผลลัพธ์เหมือนกันเป๊ะกับการไล่ทีละรอบ

คูณด้วยค่าคงที่ — บ่อยครั้งไม่ต้องใช้ mul เลย

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

ตัวอย่าง — x × 10 โดยไม่ใช้ mul (แตก 10 = 8 + 2)
slli  t0, a0, 3        # t0 ← x × 8
slli  t1, a0, 1        # t1 ← x × 2
add   a0, t0, t1       # a0 ← x × 10
ตรวจกับ x=7: (7×8)+(7×2) = 56+14 = 70 ✓ — สามคำสั่งนี้จบใน 3 ไซเคิล ขณะที่ mul ตัวเดียวกินราว 3-4 ไซเคิล พอ ๆ กัน แต่บนชิปจิ๋วที่ไม่มี M นี่คือทางเดียวและเร็วกว่าลูปบวกมหาศาล

ผลคูณ 64 บิต — ทำไมต้องมีทั้ง mul และ mulh

คูณเลข 32 บิตสองตัว ผลลัพธ์ยาวได้ถึง 64 บิต แต่รีจิสเตอร์กว้างแค่ 32 บิต RISC-V จึงแยกเป็นสองคำสั่ง: mul ให้ครึ่งล่าง mulh ให้ครึ่งบน คำสั่งคูณของ RISC-V ไม่ยกข้อผิดพลาดใด ๆ เมื่อผลล้น วิธีตรวจ overflow ตามตำราคือรัน mulh ควบกับ mul — ถ้าบิตบนทั้ง 32 บิตเป็นแค่สำเนาบิตเครื่องหมายของครึ่งล่าง แปลว่าผลจริงอยู่ใน 32 บิต ไม่ล้น (RISC-V ไม่มีธงสถานะแบบ N/Z/P ของ LC-3 การตรวจจึงเป็นหน้าที่ของซอฟต์แวร์)

ทำไมต้องมีทั้ง mulh/mulhu/mulhsu: ครึ่งล่างของผลคูณไม่สนเครื่องหมาย (คณิตศาสตร์มอดุโล 2^32 ให้บิตเหมือนกันเสมอ) จึงมี mul ตัวเดียวพอ แต่ครึ่งบนขึ้นกับการตีความ (signed/unsigned) จึงต้องแยกให้ครบทุกคู่การตีความ

หารอย่างไรด้วยวงจร — ลบ ลอง แล้วคืนค่า (restoring division)

การหารคือหารยาวฐานสอง: ดึงบิตของตัวตั้งลงมาทีละบิต แล้วถามว่า "ลบตัวหารได้ไหม" — ลบได้ บิตผลหารเป็น 1 ลบแล้วติดลบ ต้องคืนค่าเดิม (restore) บิตผลหารเป็น 0

ตัวอย่าง — 13 ÷ 4 (1101 ÷ 100) ด้วย restoring division
bitRR−4qR'หมายเหตุ
11−301ติดลบ → คืนค่าเดิม
13−103ติดลบ → คืนค่าเดิม
06212ลบได้ เก็บ 2
15111ลบได้ เก็บ 1
ผลหาร = 0011 = 3 เศษ = 1 ✓ (3×4+1=13) — ความสัมพันธ์ที่ฮาร์ดแวร์รักษาไว้เสมอ: ตัวตั้ง = ผลหาร×ตัวหาร+เศษ (div ให้ผลหาร, rem ให้เศษ)

วงจรหารสองรุ่น — เหมือนฝั่งคูณเป๊ะ ๆ

ใครเลื่อนใครนิ่งALU ที่ต้องใช้ตอนติดลบจำนวนรอบ
FIGURE 3.8 แบบแรกตัวหารเลื่อนขวาเศษ8 บิตต้องบวกตัวหารกลับคืนจริง ๆ5 รอบ (n+1)
FIGURE 3.11 แบบปรับปรุงเศษเลื่อนซ้ายตัวหาร4 บิตแค่ไม่เขียนกลับ ไม่เสียงานเพิ่ม4 รอบ (n)

รูปแบบการปรับปรุงเหมือนฝั่งคูณเป๊ะ ๆ — ให้รีจิสเตอร์ผลลัพธ์เป็นฝ่ายเลื่อน แล้วยัดตัวดำเนินการที่เหลือเข้าไปในครึ่งที่ว่าง เคล็ดที่มองข้ามง่าย: Fig 3.11 ไม่ต้องบวกกลับจริงเมื่อคืนค่า — มันจะเขียนผลกลับก็ต่อเมื่อผลไม่ติดลบ ถ้าติดลบก็แค่ไม่เขียน Remainder จึงคงค่าเดิมโดยไม่ต้องทำอะไรเลย ชิปจริงจึงมักใช้วงจรก้อนเดียวกันทำทั้งคูณและหาร (ที่มา: Patterson & Hennessy, COD RISC-V Edition รูปที่ 3.8 และ 3.11)

หารเลขติดลบ — ตัดเข้าหาศูนย์ และเศษตามเครื่องหมายตัวตั้ง

ผลหาร −13 ÷ 4 ควรเป็น −3 หรือ −4? RISC-V (เช่นเดียวกับภาษา C) เลือก ตัดเข้าหาศูนย์ (truncate toward zero)

ตัวอย่าง — −13 ÷ 4
  1. ค่าจริง −3.25 ตัดเข้าหาศูนย์ → ผลหาร −3
  2. เศษ = ตัวตั้ง − ผลหาร×ตัวหาร = −13 − (−3×4) = −13+12 = −1
div ตอบ −3, rem ตอบ −1 ตรวจเอกลักษณ์: (−3)×4+(−1) = −13 ✓ — กติกาจำง่าย: เศษมีเครื่องหมายเดียวกับตัวตั้งเสมอ (คนละแบบกับ Python ที่ปัดลงเสมอ: −13 // 4 = −4 และ −13 % 4 = 3)
หารด้วยศูนย์ — RISC-V เลือกไม่หยุดโปรแกรมx ÷ 0: div ได้ −1 (บิตเป็น 1 ทั้งหมด), divu ได้ 2^32−1, rem ได้ x เดิม, remu ได้ x เดิม — ไม่มี trap ไม่มีธง สเปกกำหนดผลตายตัวเพื่อให้วงจรง่ายและพฤติกรรมทายได้ (กรณีล้นเพียงกรณีเดียวของ div: (−2^31)÷(−1) ผลหารตอบ −2^31 เศษตอบ 0)

ส่วนขยาย F: รีจิสเตอร์ชุดใหม่ทั้งชุด

ส่วนขยาย F ไม่ได้แค่เพิ่มคำสั่ง แต่เพิ่มรีจิสเตอร์จุดลอยตัว 32 ตัวแยกจาก x0–x31 ไปเลย คือ f0–f31 (กว้าง 32 บิต ส่วนขยาย D ขยายเป็น 64 บิต) เหตุผลเชิงวิศวกรรม: วงจร FPU อยู่คนละมุมของชิปกับ ALU จำนวนเต็ม แยกรีจิสเตอร์แล้วไม่ต้องแย่งพอร์ตอ่านเขียนกัน คำสั่งหลัก: โหลด/สโตร์ flw, fsw · คำนวณ fadd.s, fsub.s, fmul.s, fdiv.s, fsqrt.s · เปรียบเทียบ (ผลเขียนลงรีจิสเตอร์ x) feq.s, flt.s, fle.s · แปลงชนิด fcvt.w.s (float→int), fcvt.s.w (int→float) — ต่อท้าย .s คือ single (32 บิต), .d คือ double (64 บิต)

ทำไมคำสั่งเปรียบเทียบอย่าง flt.s x5, f1, f2 จึงเขียนผลลง x5 ฝั่งจำนวนเต็ม ไม่ใช่ฝั่ง f
ตอบ: เพราะผลเปรียบเทียบมีไว้ใช้ตัดสินใจกระโดด และคำสั่ง branch ทั้งหมดอ่านรีจิสเตอร์ x เท่านั้น การส่งผลลง x จึงต่อเข้ากลไก control flow ได้ทันที

สี่ขั้นของการบวกเลขจุดลอยตัว

การบวกจำนวนเต็มจบในวงจรเดียว แต่บวกเลขจุดลอยตัวเป็นงานสี่จังหวะ เพราะตัวเลขสองตัวอาจอยู่คนละสเกลกัน:

  1. จัดเลขชี้กำลัง (align) — เลื่อนซิกนิฟิแคนด์ของตัวที่เลขชี้กำลังน้อยกว่าไปขวา จนเลขชี้กำลังเท่ากัน
  2. บวกซิกนิฟิแคนด์
  3. normalize — เลื่อนผลให้กลับมาอยู่ในรูป 1.xxx (เลื่อนซ้ายหลายบิตถ้าผลใกล้ศูนย์ หรือเลื่อนขวาหนึ่งบิตถ้าบวกแล้วทด)
  4. ปัดเศษ — เพราะขั้น 1 กับ 3 ทำให้บิตงอกเกินความจุของส่วนเศษ

(ที่มา: Patterson & Hennessy, COD RISC-V Edition รูปที่ 3.14)

ตัวอย่าง — 0.5 + (−0.4375) เก็บเศษ 3 บิต
  1. ตั้งต้น: 0.5 = 1.000×2⁻¹, −0.4375 = −1.110×2⁻²
  2. ขั้น 1: เลขชี้กำลังต่างกัน 1 → เลื่อน −1.110×2⁻² ไปขวาหนึ่งบิต ได้ −0.111×2⁻¹
  3. ขั้น 2: 1.000 − 0.111 = 0.001 × 2⁻¹
  4. ขั้น 3: ผลไม่อยู่ในรูป 1.xxx → เลื่อนซ้าย 3 บิต ลดเลขชี้กำลังลง 3 → 1.000×2⁻⁴
  5. ขั้น 4: เศษไม่เกินความจุ ไม่ต้องปัด
คำตอบ = 1.000×2⁻⁴ = 0.0625 ✓ (0.5−0.4375=0.0625 พอดี) เก็บลงรีจิสเตอร์ f จริง (เศษ 23 บิต): บิตเครื่องหมาย=0, ฟิลด์เลขชี้กำลัง = −4+127=123=01111011, ส่วนเศษ=0 ทั้งหมด → 0x3D800000

guard, round, sticky — สามบิตที่กันคำตอบเพี้ยน

ขั้นจัดเลขชี้กำลังเลื่อนบิตหลุดออกไปทางขวา ถ้าทิ้งบิตเหล่านั้นทันที ผลบวกจะเพี้ยนสะสม IEEE 754 จึงให้ฮาร์ดแวร์ถือบิตแถมไว้สามตัว: guard (G) บิตแรกถัดจากความจุ ตัวชี้ขาดว่าจะปัดขึ้นหรือไม่ · round (R) บิตที่สอง · sticky (S) OR ของบิตที่เหลือทั้งหมดที่หลุดไป (จำว่า "เคยมีอะไรหลุดไปบ้างไหม")

กติกาปัดแบบปริยาย (round to nearest, ties to even): GRS เกิน 100 → ปัดขึ้น · GRS ต่ำกว่า 100 → ปัดทิ้ง · GRS เท่ากับ 100 พอดี → เสมอกัน เลือกฝั่งที่บิตต่ำสุดเป็นศูนย์

ผลบวกก่อนปัดคือ 1.011 | G=1 R=0 S=0 (เก็บเศษ 3 บิต) ตามกติกา round to nearest, ties to even ต้องปัดอย่างไร
ตอบ: GRS=100 คือครึ่งพอดีเป๊ะ (เสมอกัน) กติกา ties-to-even เลือกตัวเลือกที่บิตต่ำสุดเป็น 0: ระหว่าง 1.011 (ลงท้าย 1) กับ 1.100 (ลงท้าย 0) จึงเลือก 1.100 — กันอคติสะสม เพราะถ้าเสมอแล้วปัดขึ้นทุกครั้ง ค่าเฉลี่ยของข้อมูลจะค่อย ๆ ลอยขึ้น
ตัวอย่างที่บิต sticky เปลี่ยนคำตอบจริง16.0 + 1.125 → ผลก่อนปัด 1.0001001×2⁴ เก็บ 1.000 เหลือ G=1, R=0 และบิต 1 ที่หลุดไปไกลกว่า R ทำให้ S=1 → GRS=101 (ไม่ใช่ 100) ถ้าไม่ OR รวมไว้เป็น sticky ฮาร์ดแวร์จะเข้าใจผิดว่าเสมอกันพอดี ทั้งที่ค่าจริงเกินครึ่ง — ผลจึงปัดขึ้นเป็น 18 ไม่ใช่ 16

การคูณง่ายกว่าการบวก — ไม่ต้องจัดเลขชี้กำลัง

การคูณข้ามขั้นจัดเลขชี้กำลังไปได้เลย เพราะกำลังของสองคูณกันคือเอาเลขชี้กำลังมาบวกกันตรง ๆ สามจังหวะจบ: บวกเลขชี้กำลัง · คูณซิกนิฟิแคนด์ · จัดเครื่องหมายด้วย XOR

ทำไมขั้น "จัดเลขชี้กำลัง" จึงจำเป็นกับการบวก แต่ไม่จำเป็นกับการคูณ
ตอบ: บวกได้ก็ต่อเมื่อหลักเดียวกันตรงกัน (เหมือนบวกเลขทศนิยมต้องตั้งจุดตรงกัน) จึงต้องเลื่อนให้เลขชี้กำลังเท่ากันก่อน แต่การคูณมีสมบัติ (a×2^m)(b×2^n) = ab×2^(m+n) — ซิกนิฟิแคนด์คูณกัน เลขชี้กำลังบวกกัน แยกอิสระจากกันได้เลย

fused multiply-add และการไม่มีสมบัติเปลี่ยนหมู่

ระยะห่างระหว่างเลขจุดลอยตัวสองตัวที่อยู่ติดกันเรียกว่า ulp (unit in the last place) ความเพี้ยนสะสมได้เมื่อคำนวณต่อเนื่องหลายขั้น จึงมีคำสั่ง fused multiply-add (fmadd.s f1, f2, f3, f4 คือ f1 ← f2×f3+f4): คูณและบวกด้วยความละเอียดเต็มภายใน แล้วปัดครั้งเดียวตอนจบ แทนที่จะปัดสองครั้งถ้าใช้ fmul ตามด้วย fadd

ตัวอย่าง — ลำดับการบวกเปลี่ยนคำตอบได้จริง (16.0 + 1.0 + 1.0)
  1. ลำดับที่หนึ่ง (16+1)+1: 16+1 → จัดเลขชี้กำลัง → เก็บ 1.000 | GRS=100 เสมอพอดี → ties-to-even เลือก 1.000×2⁴=16 (การบวก 1 หายไปทั้งก้อน!) แล้ว +1 อีกครั้งเหมือนเดิม → ได้ 16
  2. ลำดับที่สอง 16+(1+1): 1+1=2 พอดีเป๊ะ ไม่ต้องปัด แล้ว 16+2=18 พอดีเป๊ะ ไม่ต้องปัด
คำตอบ: 16 กับ 18 — สลับวงเล็บแล้วคำตอบต่างกัน 2 หน่วย นี่คือเหตุผลที่การบวกเลขจุดลอยตัวไม่มีสมบัติการเปลี่ยนหมู่ (associativity) และเหตุผลที่การเรียงลำดับการบวกใน GPU ทำให้ผลเทรนโมเดล machine learning ไม่ตรงกันข้ามเครื่อง — โปรแกรมรวมเงินหรือรวม gradient ที่บวกค่าเล็กจำนวนมากเข้าตัวสะสมใหญ่ จึงนิยมรวมค่าเล็กด้วยกันก่อน แล้วค่อยบวกเข้าตัวสะสมใหญ่
อ้างอิง
  • D. Patterson และ J. Hennessy, Computer Organization and Design (RISC-V Edition) — บทที่ 3 (หัวข้อ 3.3–3.5)
  • Y. Patt และ S. Patel, Introduction to Computing Systems — บทที่ 2 (การแทนค่า IEEE 754)