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 ทำงานอย่างไร
| หัวข้อ | เนื้อหา |
|---|---|
| 11.1 | รู้จักรีจิสเตอร์ทั้ง 32 ตัว ชื่อ ABI และกติกา caller-saved เทียบ callee-saved |
| 11.2 | หกรูปแบบคำสั่ง R/I/S/B/U/J และการจัดเรียงบิตของ immediate |
| 11.3 | เข้ารหัส/ถอดรหัสคำสั่งเป็นบิตจริง และ pseudo-instructions ที่ assembler ขยายให้ |
| 11.4 | calling 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 บิต)
x0 = 0 เสมอ (hardwired zero — ต่อสายตายตัว)
ในมุมฮาร์ดแวร์ x1–x31 เหมือนกันหมด ใช้แทนกันได้อิสระ มีเพียง x0 ที่พิเศษ เพราะถูก hardwired ให้มีค่า 0 เสมอ อ่าน x0 ได้ 0 ทุกครั้ง เขียนลงไปก็ถูกทิ้ง สมบัติข้อเดียวนี้ให้ idiom ฟรีหลายแบบ: NOP, คัดลอกค่า และเทียบกับศูนย์
กำหนดค่าเริ่มต้น x7 = 25 และ x6 = 99 ไล่ผลของ (1) add x6, x7, x0 (2) addi x0, x0, 0 (3) beq x6, x7, DONE ทีละคำสั่ง
- คำสั่ง 1: x0 = 0 เสมอ → x6 ← 25 + 0 = 25 — นี่คือ "คัดลอกค่า" x7 → x6 (เท่ากับ pseudo
mv x6, x7) ตอนนี้ x6 = 25, x7 = 25 - คำสั่ง 2: คำนวณ 0 + 0 = 0 แล้วเขียนลง x0 ซึ่ง hardwired = 0 เขียนไม่ติด → ค่าถูกทิ้ง เป็น NOP มาตรฐาน
- คำสั่ง 3: x6 = 25, x7 = 25 → เท่ากัน → กระโดดไป DONE
ABI: ตั้งชื่อและหน้าที่ให้รีจิสเตอร์
ฮาร์ดแวร์มองรีจิสเตอร์ (ยกเว้น x0) เท่ากันหมด แต่ระดับซอฟต์แวร์มี ABI (Application Binary Interface) เป็นข้อตกลงร่วมที่กำหนด "ชื่อ ABI" และ "หน้าที่" ให้แต่ละตัว เพื่อให้โค้ดจากคนละ compiler/คนละไลบรารีเรียกกันได้ถูกต้อง การเขียน assembly จึงนิยมใช้ชื่อ ABI (เช่น sp, ra, a0) แทนเลข x ดิบ เพราะสื่อ "บทบาท" ของค่าได้ชัดกว่า
| รีจิสเตอร์ | ชื่อ ABI | หน้าที่ | ใครรักษาค่าข้าม call |
|---|---|---|---|
| x0 | zero | ค่าศูนย์เสมอ (hardwired) | — (คงที่) |
| x1 | ra | return address | caller-saved |
| x2 | sp | stack pointer | callee-saved |
| x3 | gp | global pointer | (พิเศษ) |
| x4 | tp | thread pointer | (พิเศษ) |
| x5–x7 | t0–t2 | temporary | caller-saved |
| x8 | s0 / fp | saved / frame pointer | callee-saved |
| x9 | s1 | saved | callee-saved |
| x10–x17 | a0–a7 | argument / return value | caller-saved |
| x18–x27 | s2–s11 | saved | callee-saved |
| x28–x31 | t3–t6 | temporary | caller-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
Pseudo-instruction ที่อาศัย x0: mv, li, neg, nop
RV32I ไม่มีคำสั่ง move, load-immediate หรือ negate แยกต่างหาก — assembler ขยาย pseudo-instruction เหล่านี้เป็นคำสั่งจริงที่อาศัย x0 ทั้งสิ้น
| pseudo ที่เขียน | assembler ขยายเป็น | ความหมาย |
|---|---|---|
mv x6, x7 | addi x6, x7, 0 | x6 ← x7 + 0 (คัดลอกค่า) |
li x10, 25 | addi x10, x0, 25 | x10 ← 0 + 25 (ใส่ค่าคงที่) |
neg x5, x6 | sub x5, x0, x6 | x5 ← 0 - x6 (กลับเครื่องหมาย) |
nop | addi 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 ← 1 ถ้า x6 น้อยกว่า 1 แบบ unsigned ไม่เช่นนั้น x5 ← 0 (เลข unsigned ที่น้อยกว่า 1 มีตัวเดียวคือ 0)
- x6 = 0 → 0 น้อยกว่า 1 จริง → x5 = 1
- x6 = 7 → 7 ไม่น้อยกว่า 1 → x5 = 0
- x6 = 0xFFFFFFFD → มองแบบ unsigned คือ 4294967293 → ไม่น้อยกว่า 1 → x5 = 0 (ไม่ใช่ −3 เพราะ sltiu ไม่มอง sign)
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-saved | t0–t6, a0–a7, ra | ได้ตามใจ | caller | ก่อน jal ถ้ายังต้องใช้ค่าหลัง call แล้วโหลดคืนหลังกลับ |
| callee-saved | s0–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 เช่นกัน
สมมุติก่อนเข้า func ค่า sp = 0x7FFFFFF0 ไล่ prologue สามคำสั่งแรกทีละขั้น
addi sp, sp, -16(0xFF010113) — sp ← 0x7FFFFFF0 − 16 = 0x7FFFFFE0 จองเฟรมกว้าง 16 ไบต์ (ABI ของ RISC-V กำหนดให้ sp เกาะแนว 16 ไบต์เสมอ แม้ใช้จริงแค่ 8 ไบต์)sw ra, 12(sp)(0x00112623) — เก็บ ra ลงที่ 0x7FFFFFE0 + 12 = 0x7FFFFFECsw 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 ใหม่
+--------------------+
ที่อยู่ต่ำ (เฟรมของฟังก์ชันที่ถูกเรียกซ้อนถัดไป จะงอกลงทางนี้)
เรียกซ้อนสองชั้น: ใครต้องเซฟ ra
สมมุติ main เรียก A และ A เรียก B (B เป็น leaf ไม่เรียกใครต่อ) jal ra, A ของ main อยู่ที่ 0x1000 และ jal ra, B ของ A อยู่ที่ 0x2010
| เหตุการณ์ | ra (ในรีจิสเตอร์) | ช่อง 12(sp) ในเฟรมของ A |
|---|---|---|
| main รัน jal ที่ 0x1000 | 0x1004 | — |
| A เซฟ ra (prologue) | 0x1004 | 0x1004 |
| A รัน jal ที่ 0x2010 | 0x2014 | 0x1004 |
| B ret | 0x2014 (กระโดดกลับเข้า A) | 0x1004 |
| A โหลด ra คืน (epilogue) | 0x1004 | — |
| A ret | กระโดดไป 0x1004 ใน main | — |
- 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
| ฟิลด์ | บิต | กว้าง | หน้าที่ | มีในรูปแบบ |
|---|---|---|---|---|
| opcode | 6–0 | 7 | กลุ่ม/รูปแบบของคำสั่ง ตัวแรกที่ decoder อ่าน | ทุกแบบ |
| rd | 11–7 | 5 | รีจิสเตอร์ปลายทาง | R, I, U, J |
| funct3 | 14–12 | 3 | แยกคำสั่งใน opcode เดียวกัน (add/sll, beq/bne) | R, I, S, B |
| rs1 | 19–15 | 5 | ต้นทางตัวที่ 1 / base | R, I, S, B |
| rs2 | 24–20 | 5 | ต้นทางตัวที่ 2 | R, S, B |
| funct7 | 31–25 | 7 | แยกคู่ที่ opcode+funct3 ชนกัน (add 0000000 / sub 0100000) | R |
| imm | ต่างกันตามรูปแบบ | 12/20 | ค่าคงที่ฝังในคำสั่ง | I, S, B, U, J |
ตารางสรุปหกรูปแบบ
| รูปแบบ | ใช้กับ | มี imm? | จุดเด่น |
|---|---|---|---|
| R | reg-reg: add, sub, and, or, sll, slt | ไม่มี | ใช้ rs1, rs2, rd ครบ + funct7 |
| I | addi, lw, jalr, ecall | 12 บิต | rs1 + imm → rd (ไม่มี rs2) |
| S | store: sw, sh, sb | 12 บิต | เก็บ rs2 ลงที่ (rs1 + imm) ไม่มี rd |
| B | branch: beq, bne, blt, bge | 13 บิต (signed, PC-relative) | เทียบ rs1 กับ rs2 แล้วกระโดด |
| U | lui, auipc | 20 บิตบน | สร้างค่าคงที่ก้อนใหญ่ → rd |
| J | jal | 21 บิต (signed) | กระโดดไกล + เก็บ return addr ลง rd |
ที่มา: Patterson & Hennessy, Computer Organization and Design (RISC-V Edition) รูปที่ 2.19
ขั้นตอนเข้ารหัส 4 ขั้น (ใช้ได้ทุกรูปแบบ)
- แยก operand — อ่านคำสั่งแล้วระบุให้ครบว่าอะไรคือ rd, rs1, rs2 และ immediate (ถ้ามี)
- แปลงเป็นบิตต่อฟิลด์ — เปิดค่า opcode/funct3/funct7 ของคำสั่งนั้น แปลงหมายเลขรีจิสเตอร์เป็นฐานสอง 5 บิต และแปลง immediate เป็นฐานสองตามจำนวนบิตที่รูปแบบกำหนด (ค่าลบต้องเขียนเป็น two's complement ก่อน)
- วางตามผัง — นำบิตของแต่ละฟิลด์ไปวางลงตำแหน่งที่รูปแบบกำหนด เรียงจากบิต 31 ลงมาถึงบิต 0
- อ่านเป็นฐานสิบหก — จับกลุ่มทีละ 4 บิตจากขวาสุด แปลงแต่ละกลุ่มเป็นเลขฐานสิบหกหนึ่งหลัก ได้คำตอบ 8 หลัก
รูปแบบ 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 มี opcode = 0110011, funct3 = 000, funct7 = 0000000
- แยก operand: rd=x1, rs1=x2, rs2=x3
- แปลงบิต: opcode=0110011, rd=00001, funct3=000, rs1=00010, rs2=00011, funct7=0000000
- วางตามผัง (บิต 31→0): 0000000 00011 00010 000 00001 0110011
- จับกลุ่ม 4 บิต: 0000 0000 0011 0001 0000 0000 1011 0011
รูปแบบ 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
- แยก operand: t0=x5, zero=x0 → rd=x5, rs1=x0, imm=-1
- เขียน -1 แบบ two's complement 12 บิต: +1 = 000000000001 → กลับบิต 111111111110 → บวกหนึ่ง = 111111111111
- วางตามผัง I: 111111111111 00000 000 00101 0010011
- อ่านฐานสิบหก: 1111 1111 1111 0000 0000 0010 1001 0011
รูปแบบ 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)
- แยก operand: rs2=x5, rs1=x2, imm=-4
- -4 ใน 12 บิต two's complement = 111111111100 → ฉีก: imm[11:5]=1111111 (sign bit อยู่ท่อนนี้), imm[4:0]=11100
- วางตามผัง S (imm[11:5]|rs2|rs1|funct3|imm[4:0]|opcode): 1111111 00101 00010 010 11100 0100011
- อ่านฐานสิบหก: 1111 1110 0101 0001 0010 1110 0010 0011
รูปแบบ 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
- แยก operand: rs1=x1, rs2=x2, offset=+8 → imm 13 บิต = 0000000001000
- แจกแจง: imm[12]=0, imm[11]=0, imm[10:5]=000000, imm[4:1]=0100, imm[0]=0 (ไม่เก็บ)
- วางตามผัง 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
- อ่านฐานสิบหก: 0000 0000 0010 0000 1000 0100 0110 0011
รูปแบบ 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
- แยก operand: rd=x5, imm(20 บิตบน)=0x12345
- แปลงบิต: 0x12345 → 0001 0010 0011 0100 0101, rd=00101
- วางตามผัง U (imm[31:12]|rd|opcode): 00010010001101000101 00101 0110111
- อ่านฐานสิบหก: 0001 0010 0011 0100 0101 0010 1011 0111
รูปแบบ 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 ดูยุ่งเหยิง แต่เป็นผลของการตัดสินใจออกแบบสามขั้น:
- ตรึงฟิลด์รีจิสเตอร์ไว้ที่เดิมก่อน — rs1 บิต 19–15, rs2 บิต 24–20, rd บิต 11–7 เหมือนกันทุกรูปแบบ เพื่อให้ decoder ส่งหมายเลขรีจิสเตอร์เข้า register file ได้ทันที ผลพวงคือ immediate ของ S/B ถูกบังคับให้แตกเป็นสองท่อนล้อมรอบฟิลด์รีจิสเตอร์
- ตรึง sign bit ไว้ที่บิต 31 เสมอ — บิตบนสุดของ immediate (imm[11] ของ I/S, imm[12] ของ B, imm[31] ของ U, imm[20] ของ J) ถูกวางที่บิต 31 ของคำสั่งทุกรูปแบบ sign-extend จึงเป็นวงจรเดียว ไม่ต้องมี mux เลือกว่าบิตไหนคือ sign
- บิตที่เหลือจัดให้น้ำหนักเดียวกันอยู่ตำแหน่งเดิมให้มากที่สุด — เช่น 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 │
ทำไมต้อง 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 ที่บิตล่าง
- 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 ขั้น
- ดู opcode 7 บิตล่าง (บิต 6-0) → รู้กลุ่มคำสั่งและรูปแบบที่ต้องใช้หั่นบิตที่เหลือ
- หั่น fields ตามผังของรูปแบบนั้น (rs1/rs2/rd/funct3/funct7 อยู่ตำแหน่งตายตัว)
- ประกอบ immediate (ถ้ามี) — บางรูปแบบเก็บท่อนเดียว บางรูปแบบต้องนำสองท่อนมาต่อกัน แล้ว sign-extend เป็น 32 บิต
- เขียนกลับเป็น assembly จาก fields ที่ได้
| opcode (บิต 6-0) | กลุ่ม | รูปแบบ |
|---|---|---|
| 0010011 | OP-IMM (addi, srli, srai, ...) | I-type |
| 0000011 | LOAD (lw, lb, ...) | I-type |
| 0100011 | STORE (sw, sb, ...) | S-type |
| 0110011 | OP (add, sub, ...) | R-type |
- opcode 7 บิตล่าง: 0xFE512E23 = 1111 1110 0101 0001 0010 1110 0010 0011 → opcode = 0100011 → กลุ่ม STORE → S-type
- หั่น fields S-type (ไม่มี rd! บิต 11-7 เป็น imm ท่อนล่าง): imm[11:5]=1111111, rs2=00101→x5, rs1=00010→x2, funct3=010 (SW)
- ประกอบ imm สองท่อน: 1111111 ต่อ 11100 = 111111111100 = 0xFFC, บิต 11=1 → sign-extend: 0xFFC − 0x1000 = −4
- opcode: 0100 0000 0111 0011 0000 0010 1011 0011 → opcode=0110011 → R-type
- หั่น fields: funct7=0100000, rs2=00111→x7, rs1=00110→x6, funct3=000, rd=00101→x5
- R-type ไม่มี immediate แต่ต้องอ่าน funct7 ให้ครบ: funct3=000 + funct7=0100000 (0x20) → SUB (ถ้า funct7=0000000 ที่ funct3 เดียวกันจะเป็น ADD — 0x007302B3 คือ add x5,x6,x7)
Pseudo-instruction = ทางลัดที่ assembler ขยายให้
pseudo-instruction ไม่มี opcode ของตัวเอง ฮาร์ดแวร์ไม่เคยเห็น มันถูกแปลงตั้งแต่ตอน assemble ตัวที่พบบ่อย:
| pseudo | ขยายเป็นคำสั่งจริง | ความหมาย |
|---|---|---|
| mv rd, rs | addi rd, rs, 0 | คัดลอกค่า rs → rd |
| nop | addi x0, x0, 0 | ไม่ทำอะไร (no operation) |
| j offset | jal x0, offset | กระโดดไม่มีเงื่อนไข ทิ้ง return address |
| ret | jalr x0, 0(ra) | กลับจากซับรูทีน (กระโดดไปที่ ra) |
| li rd, imm | addi หรือ lui+addi | โหลดค่าคงที่ |
| la rd, symbol | auipc + addi | โหลดที่อยู่ของ symbol (PC-relative) |
สี่ตัวแรกขยายเป็นคำสั่งเดียว เพราะใช้ทางลัดสองทาง — "บวกด้วย 0 แล้วได้ค่าเดิม" (mv) และ "เขียนผลลง x0 ที่ผูกค่าเป็นศูนย์เสมอ เท่ากับทิ้งผล" (nop, j, ret) เพื่อให้โค้ดอ่านง่ายและสื่อเจตนาชัด โดยไม่ต้องเพิ่ม opcode ในฮาร์ดแวร์ ISA จึงคงเล็กและถอดรหัสง่าย ภาระความสะดวกย้ายไปอยู่ที่ assembler แทน
ค่าคงที่ใหญ่ และที่อยู่ 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 ปัจจุบัน
บันได li: assembler เลือกวิธีขยายอย่างไร
เมื่อ assembler เจอ li rd, imm ตัดสินใจตามลำดับนี้เสมอ: (1) ค่าอยู่ในช่วง −2048 ถึง 2047 → ใช้ addi ตัวเดียว (2) ค่าเกินช่วงนั้น แต่ 12 บิตล่างเป็นศูนย์ทั้งหมด → ใช้ lui ตัวเดียว (3) นอกนั้น → ต้องใช้ lui+addi สองคำสั่ง และต้องตรวจบิตที่ 11 ของท่อนล่างว่าต้องชดเชยหรือไม่
- แยกสองท่อน: บน (บิต 31-12) = 0x12345, ล่าง (บิต 11-0) = 0xFFF
- ตรวจบิต 11 ของท่อนล่าง: 0xFFF = 1111 1111 1111 → บิต 11 = 1 → addi sign-extend มองเป็น −1 (ไม่ใช่ +4095)
- ลองแบบไม่ชดเชย: lui t0,0x12345 (0x12345000) + addi t0,t0,-1 = 0x12344FFF (ผิด! ขาดไป 0x1000 พอดี)
- ชดเชยที่ส่วนบน: hi = 0x12345 + 1 = 0x12346 → lui t0,0x12346 (0x12346000) + addi t0,t0,-1 = 0x12345FFF (ถูก)
la ผ่าน auipc: ไล่เลขด้วย PC จริง
สมมุติ la t0, msg วางที่ address 0x10000 และ label msg อยู่ที่ 0x12345
- หา offset จากตำแหน่งคำสั่ง (PC-relative): offset = 0x12345 − 0x10000 = 0x2345
- แยก offset เป็นสองท่อน: บน=0x2, ล่าง=0x345, บิต 11 ของ 0x345=0 → ไม่ต้องชดเชย
auipc t0, 0x2→ t0 = PC + (0x2<<12) = 0x10000 + 0x2000 = 0x12000addi 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
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
สมมุติ 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
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: # int double(int x) — x มาใน a0, คืนผลใน a0
add a0, a0, a0 # a0 = x + x
ret # กลับได้ทันที ไม่ต้องเก็บกวาดอะไร
มีของ 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
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 แทน
| ชั้น | เรียก | ra ที่เซฟ | s0 ที่เซฟ (n ของชั้นก่อน) | a0 ขาไป | a0 ขากลับ |
|---|---|---|---|---|---|
| 1 | fact(3) | 0x00010104 (กลับ main) | 0x00000000 ของ main | 3 | 6 |
| 2 | fact(2) | 0x00010020 (กลับกลาง fact) | 3 = n ชั้น 1 | 2 | 2 |
| 3 | fact(1) | 0x00010020 (กลับกลาง fact) | 2 = n ชั้น 2 | 1 | 1 |
- ขาลง: แต่ละชั้นเซฟ s0 ของผู้เรียกลง frame ก่อน แล้วค่อยตั้ง s0 = n ของตัวเอง จนถึง fact(1) ซึ่งเป็น base case (a0=1) ไม่เรียกต่อ
- ขาขึ้น (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
Modular ISA: base เล็ก + extension ตามงาน
หัวใจของ 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)
| ส่วนขยาย | ความหมาย |
|---|---|
| RV32I | base integer ISA ทุกชิป RISC-V (32 บิต) ต้องมี |
| M | คูณ/หาร (mul, div, rem) — base ไม่มีคูณ/หารฮาร์ดแวร์ |
| F / D | floating point single (32 บิต) / double (64 บิต) ตาม IEEE 754 พร้อม register ชุด f0–f31 |
| A | atomic (load-reserved/store-conditional) สำหรับ synchronization บนระบบหลายคอร์ |
| C | compressed คำสั่ง 16 บิตสำหรับคำสั่งที่ใช้บ่อย ช่วยลดขนาดโค้ด |
- 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) เลื่อนตัวคูณไปทางขวาหนึ่งบิต
| รอบ | bit0 | ผลสะสม | ตัวตั้งหลังเลื่อน | การกระทำ |
|---|---|---|---|---|
| 1 | 1 | 6 | 6 → 12 | บวก 6 |
| 2 | 0 | 6 | 12 → 24 | ไม่บวก |
| 3 | 1 | 30 | 24 → 48 | บวก 24 |
| 4 | 0 | 30 | 48 → 96 | ไม่บวก |
มุมเลขคณิตกับมุมฮาร์ดแวร์ — อัลกอริทึมเดียวกัน วงจรต่างกัน
ถ้าสร้างวงจรตามตารางข้างบนตรง ๆ (มุมเลขคณิต) ต้องมีตัวบวกกว้างเท่าผลลัพธ์และรีจิสเตอร์ตัวตั้งกว้างเท่ากัน เพราะมันเลื่อนซ้ายเรื่อย ๆ ฮาร์ดแวร์จริงพลิกกลับด้าน (มุมฮาร์ดแวร์): ให้ ผลคูณเลื่อนขวา แทนที่จะให้ตัวตั้งเลื่อนซ้าย
| ใครเลื่อน | ใครนิ่ง | ตัวบวกที่ต้องใช้ | |
|---|---|---|---|
| มุมเลขคณิต | ตัวตั้งเลื่อนซ้าย | ผลสะสม | 8 บิต |
| มุมฮาร์ดแวร์ | ผลคูณเลื่อนขวา | ตัวตั้ง | 4 บิต เหลือครึ่งเดียว |
เคล็ดของมุมฮาร์ดแวร์คือใช้รีจิสเตอร์ผลคูณ 8 บิตตัวเดียว ครึ่งขวาเริ่มต้นเป็นตัวคูณ บวกตัวตั้งเข้าครึ่งซ้ายเท่านั้น จึงต้องการตัวบวกแค่ 4 บิต เลื่อนขวาหนึ่งบิตทุกรอบ ตัวคูณจึงถูกกินหายไปทางขวา ขณะที่ผลคูณค่อย ๆ งอกเข้ามาแทนที่ — กุญแจ: "แทนที่จะเลื่อนตัวตั้งขึ้นไปหาผลสะสม ก็เลื่อนผลสะสมลงมาหาตัวตั้งแทน" ผลลัพธ์เท่ากันเป๊ะแต่ตัวบวกไม่ต้องกว้างตามอีกต่อไป (ที่มา: Patterson & Hennessy, COD RISC-V Edition รูปที่ 3.3 และ 3.5)
ชิปจริงไม่ทำแบบวนทีละรอบ (32 รอบสำหรับ 32 บิตช้าเกินไป) แต่ "คลี่ลูป" ออกเป็นตัวบวกหลายสิบตัวต่อกันเป็นชั้นทำงานขนานกัน ได้ผลคูณใน 3-4 ไซเคิล ผลลัพธ์เหมือนกันเป๊ะกับการไล่ทีละรอบ
คูณด้วยค่าคงที่ — บ่อยครั้งไม่ต้องใช้ mul เลย
การเลื่อนซ้ายหนึ่งบิตคือการคูณสอง ดังนั้นการคูณด้วยกำลังสองใช้ slli คำสั่งเดียวจบ และตัวคูณอื่นก็แตกเป็นผลบวกของกำลังสองได้ — คอมไพเลอร์ทำให้อัตโนมัติเมื่อตัวคูณเป็นค่าคงที่
slli t0, a0, 3 # t0 ← x × 8
slli t1, a0, 1 # t1 ← x × 2
add a0, t0, t1 # a0 ← x × 10
ผลคูณ 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
| bit | R | R−4 | q | R' | หมายเหตุ |
|---|---|---|---|---|---|
| 1 | 1 | −3 | 0 | 1 | ติดลบ → คืนค่าเดิม |
| 1 | 3 | −1 | 0 | 3 | ติดลบ → คืนค่าเดิม |
| 0 | 6 | 2 | 1 | 2 | ลบได้ เก็บ 2 |
| 1 | 5 | 1 | 1 | 1 | ลบได้ เก็บ 1 |
วงจรหารสองรุ่น — เหมือนฝั่งคูณเป๊ะ ๆ
| ใครเลื่อน | ใครนิ่ง | 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)
- ค่าจริง −3.25 ตัดเข้าหาศูนย์ → ผลหาร −3
- เศษ = ตัวตั้ง − ผลหาร×ตัวหาร = −13 − (−3×4) = −13+12 = −1
ส่วนขยาย 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 บิต)
สี่ขั้นของการบวกเลขจุดลอยตัว
การบวกจำนวนเต็มจบในวงจรเดียว แต่บวกเลขจุดลอยตัวเป็นงานสี่จังหวะ เพราะตัวเลขสองตัวอาจอยู่คนละสเกลกัน:
- จัดเลขชี้กำลัง (align) — เลื่อนซิกนิฟิแคนด์ของตัวที่เลขชี้กำลังน้อยกว่าไปขวา จนเลขชี้กำลังเท่ากัน
- บวกซิกนิฟิแคนด์
- normalize — เลื่อนผลให้กลับมาอยู่ในรูป 1.xxx (เลื่อนซ้ายหลายบิตถ้าผลใกล้ศูนย์ หรือเลื่อนขวาหนึ่งบิตถ้าบวกแล้วทด)
- ปัดเศษ — เพราะขั้น 1 กับ 3 ทำให้บิตงอกเกินความจุของส่วนเศษ
(ที่มา: Patterson & Hennessy, COD RISC-V Edition รูปที่ 3.14)
- ตั้งต้น: 0.5 = 1.000×2⁻¹, −0.4375 = −1.110×2⁻²
- ขั้น 1: เลขชี้กำลังต่างกัน 1 → เลื่อน −1.110×2⁻² ไปขวาหนึ่งบิต ได้ −0.111×2⁻¹
- ขั้น 2: 1.000 − 0.111 = 0.001 × 2⁻¹
- ขั้น 3: ผลไม่อยู่ในรูป 1.xxx → เลื่อนซ้าย 3 บิต ลดเลขชี้กำลังลง 3 → 1.000×2⁻⁴
- ขั้น 4: เศษไม่เกินความจุ ไม่ต้องปัด
guard, round, sticky — สามบิตที่กันคำตอบเพี้ยน
ขั้นจัดเลขชี้กำลังเลื่อนบิตหลุดออกไปทางขวา ถ้าทิ้งบิตเหล่านั้นทันที ผลบวกจะเพี้ยนสะสม IEEE 754 จึงให้ฮาร์ดแวร์ถือบิตแถมไว้สามตัว: guard (G) บิตแรกถัดจากความจุ ตัวชี้ขาดว่าจะปัดขึ้นหรือไม่ · round (R) บิตที่สอง · sticky (S) OR ของบิตที่เหลือทั้งหมดที่หลุดไป (จำว่า "เคยมีอะไรหลุดไปบ้างไหม")
กติกาปัดแบบปริยาย (round to nearest, ties to even): GRS เกิน 100 → ปัดขึ้น · GRS ต่ำกว่า 100 → ปัดทิ้ง · GRS เท่ากับ 100 พอดี → เสมอกัน เลือกฝั่งที่บิตต่ำสุดเป็นศูนย์
การคูณง่ายกว่าการบวก — ไม่ต้องจัดเลขชี้กำลัง
การคูณข้ามขั้นจัดเลขชี้กำลังไปได้เลย เพราะกำลังของสองคูณกันคือเอาเลขชี้กำลังมาบวกกันตรง ๆ สามจังหวะจบ: บวกเลขชี้กำลัง · คูณซิกนิฟิแคนด์ · จัดเครื่องหมายด้วย XOR
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+1)+1: 16+1 → จัดเลขชี้กำลัง → เก็บ 1.000 | GRS=100 เสมอพอดี → ties-to-even เลือก 1.000×2⁴=16 (การบวก 1 หายไปทั้งก้อน!) แล้ว +1 อีกครั้งเหมือนเดิม → ได้ 16
- ลำดับที่สอง 16+(1+1): 1+1=2 พอดีเป๊ะ ไม่ต้องปัด แล้ว 16+2=18 พอดีเป๊ะ ไม่ต้องปัด
- 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)