Mở đầu: từ tập lệnh mini tự bịa sang tập lệnh THẬT

Bài 2 xây một Toy CPU chạy tập lệnh MINI tự bịa — mỗi lệnh là một object JavaScript tiện lợi như { op: 'ADD', rd: 2, rs1: 0, rs2: 1 }. Dễ hiểu, nhưng KHÔNG CPU thật nào chạy object JavaScript — mọi CPU thật chỉ hiểu SỐ NHỊ PHÂN. Bài này thay tập lệnh mini bằng RV32I — tập lệnh RISC-V 32-bit thật, đang chạy trên hàng tỷ chip nhúng/laptop ngoài đời — và xây một datapath đơn chu kỳ (single-cycle) để thực thi nó.


📚 Điều kiện tiên quyết
Bắt buộc đọc Bài 2 (chu kỳ Fetch-Decode-Execute) và Bài 1 (ALU — datapath bài này tái dùng thẳng aluExecute()).

1. Tập lệnh RISC-V (RV32I)

Mọi lệnh RV32I đều dài đúng 32-bit cố định (đặc trưng RISC — Bài 2 đã so sánh với độ dài biến đổi của CISC), nhưng chia thành 6 bit trường khác nhau tuỳ dạng lệnh. Bài này dùng 3 dạng phổ biến nhất:

Dạng Cấu trúc bit (31→0) Dùng cho
R-type funct7[7] rs2[5] rs1[5] funct3[3] rd[5] opcode[7] ADD, SUB, AND, OR, XOR — cả 2 toán hạng là thanh ghi
I-type imm[12] rs1[5] funct3[3] rd[5] opcode[7] ADDI, ANDI, ORI, XORI, LW — toán hạng thứ 2 là hằng số (immediate)
S-type imm[7] rs2[5] rs1[5] funct3[3] imm[5] opcode[7] SW — cần ĐỊA CHỈ (rs1+imm) và giá trị cần lưu (rs2), không có ô cho rd

Vì sao ADD và SUB CÙNG opcode nhưng KHÔNG lẫn lộn? Chúng dùng đúng opcode R-type (0110011) nhưng khác funct7 — "mã phụ" 7-bit đóng vai trò như một switch phân biệt các lệnh cùng họ. Bit cao nhất của funct7 chính là "cờ trừ": 0000000 cho ADD, 0100000 cho SUB.

rv32i_bit_layout.txt (sơ đồ trường bit, MSB→LSB)
R-type:  funct7[31:25] rs2[24:20] rs1[19:15] funct3[14:12] rd[11:7]  opcode[6:0]
I-type:  imm[31:20]              rs1[19:15] funct3[14:12] rd[11:7]  opcode[6:0]
S-type:  imm[31:25]    rs2[24:20] rs1[19:15] funct3[14:12] imm[11:7] opcode[6:0]

# Vi du that: ADD x3, x1, x2  ->  0000000 00010 00001 000 00011 0110011
#                                 funct7  rs2   rs1   f3  rd    opcode
#                                 (=0)    (=2)  (=1)  (0) (=3)  (R-type)

2. Dịch Assembly sang Mã máy

Dịch (assemble) là ánh xạ CƠ HỌC: tra bảng mnemonic → opcode/funct3/funct7, rồi xếp các trường vào đúng vị trí bit. Verified bằng chính engine (không suy diễn tay): ADD x3, x1, x2 dịch thành đúng 0x2081b3; đổi CHỈ funct7 (giữ nguyên toán hạng) cho SUB x3, x1, x2 ra 0x402081b3 — đúng khác biệt DUY NHẤT nằm ở 7 bit cao nhất.

assemble_rv32i.js (trích engine dùng chung cpu-core.js)
function assembleRV32I(mnemonic, args) {
  const info = RV32I_MNEMONIC[mnemonic];   // tra bang: {type, funct3, funct7}
  if (info.type === 'R') {
    return ((info.funct7 << 25) | (args.rs2 << 20) | (args.rs1 << 15)
          | (info.funct3 << 12) | (args.rd << 7) | RV32I_OPCODE.R) >>> 0;
  }
  // ... I-type, S-type tuong tu, xep dung vi tri bit ...
}
// Verified: assembleRV32I('ADD', {rd:3,rs1:1,rs2:2}) === 0x2081b3
// Verified: assembleRV32I('SUB', {rd:3,rs1:1,rs2:2}) === 0x402081b3 (chi funct7 doi)

Giải mã (decode) là chiều NGƯỢC LẠI: tách bit theo đúng vị trí, tra ngược (funct3, funct7) → mnemonic. Verified bằng phép khứ hồi: dịch rồi giải mã LẠI cả 8 mnemonic (ADD/SUB/AND/OR/XOR/ADDI/LW/SW) đều cho ĐÚNG lại mnemonic ban đầu — không có lệnh nào "mất tích" qua vòng dịch-giải mã.

decode_rv32i.js (trích engine dùng chung cpu-core.js)
function decodeRV32I(word) {
  const opcode = word & 0x7f;              // 7 bit thap nhat luon la opcode
  const rd     = (word >>> 7)  & 0x1f;
  const funct3 = (word >>> 12) & 0x7;
  const rs1    = (word >>> 15) & 0x1f;
  const rs2    = (word >>> 20) & 0x1f;
  const funct7 = (word >>> 25) & 0x7f;
  if (opcode === RV32I_OPCODE.R) {
    // tra NGUOC (funct3, funct7) -> mnemonic trong bang RV32I_MNEMONIC
    const mnemonic = /* tim mnemonic co type R khop dung funct3+funct7 */;
    return { mnemonic, type: 'R', rd, rs1, rs2 };
  }
  // ... I-type, ILOAD, S-type tuong tu ...
}
// Verified: 8/8 mnemonic khu hoi dung (assemble roi decode ra DUNG lai mnemonic).
⚠️ Cạm bẫy: địa chỉ tuyệt đối vs địa chỉ tương đối PC (PC-relative)
Toy CPU của Bài 2 dùng lệnh nhảy JMP addr với addr là địa chỉ TUYỆT ĐỐI (vd "nhảy tới ô nhớ số 4"). RISC-V THẬT mã hoá lệnh nhảy/rẽ nhánh (B-type/J-type, ngoài phạm vi bộ mã hoá R/I/S của bài này) bằng độ lệch TƯƠNG ĐỐI so với PC (vd "nhảy lùi 8 byte so với vị trí hiện tại") — lý do: chương trình có thể được nạp vào BẤT KỲ vùng nhớ nào (định vị lại — relocatable code) mà không cần sửa lại địa chỉ nhảy bên trong. Nhầm 2 kiểu này là lỗi kinh điển khi đọc mã máy thật: cùng một con số offset có thể mang ý nghĩa hoàn toàn khác nhau tuỳ dạng lệnh.

3. Thiết kế Datapath đơn chu kỳ (Single-Cycle)

Datapath đơn chu kỳ là "đường đi vật lý" của dữ liệu qua CPU: lệnh được fetch từ RAM → giải mã để biết cần đọc thanh ghi nào → chảy qua ALU (Bài 1, tái dùng thẳng không viết lại) để tính toán/tính địa chỉ → (nếu là LW/SW) chạm vào bộ nhớ dữ liệu → ghi kết quả trở lại Register File. TOÀN BỘ hành trình này diễn ra trong ĐÚNG MỘT chu kỳ xung nhịp — vì vậy chu kỳ xung nhịp CPU phải đủ DÀI để chứa lệnh CHẬM NHẤT (thường là LW, phải đi qua cả ALU tính địa chỉ LẪN bộ nhớ).

⚠️ Cạm bẫy: tưởng mọi lệnh tốn thời gian bằng nhau trên silicon thật
Vì thiết kế đơn chu kỳ ép TẤT CẢ lệnh chạy trong đúng 1 chu kỳ xung nhịp có độ dài CỐ ĐỊNH, xung nhịp đó buộc phải dài bằng lệnh CHẬM NHẤT (LW: fetch + decode + ALU tính địa chỉ + truy cập bộ nhớ) — kể cả một lệnh đơn giản như ADD (chỉ cần fetch + decode + ALU) cũng bị "giam" trong cùng khoảng thời gian dài đó, dù bản thân nó có thể xong sớm hơn nhiều. Đây chính là động lực thiết kế pipeline ở Bài 4 (sắp ra mắt) — chia nhỏ công việc để mỗi giai đoạn có xung nhịp ngắn hơn, thay vì ép mọi lệnh theo nhịp của lệnh chậm nhất.

Verified bằng một chương trình RV32I THẬT (không phải tập lệnh mini của Bài 2): tính $(5+3)-2$ bằng đúng 5 lệnh ADDI/ADD/ADDI/SUB, lưu kết quả vào mem[100] bằng SW, rồi đọc lại bằng LW — chạy qua executeRV32I() cho đúng $x_3=8$, $x_5=6$, mem[100]=6, và đọc lại đúng $x_6=6$. Phép ADD/SUB bên trong gọi THẲNG aluExecute() của Bài 1 — không viết lại phép cộng/trừ lần thứ hai.

datapath_verified_program.js (chương trình đã verify trong self-test)
const program = [
  assembleRV32I('ADDI', { rd: 1, rs1: 0, imm: 5 }),    // x1 = 5
  assembleRV32I('ADDI', { rd: 2, rs1: 0, imm: 3 }),    // x2 = 3
  assembleRV32I('ADD',  { rd: 3, rs1: 1, rs2: 2 }),    // x3 = x1+x2 = 8
  assembleRV32I('ADDI', { rd: 4, rs1: 0, imm: 2 }),    // x4 = 2
  assembleRV32I('SUB',  { rd: 5, rs1: 3, rs2: 4 }),    // x5 = x3-x4 = 6
  assembleRV32I('SW',   { rs1: 0, rs2: 5, imm: 100 }), // mem[100] = x5
  assembleRV32I('LW',   { rd: 6, rs1: 0, imm: 100 }),  // x6 = mem[100]
];
const { regs, mem } = runRV32IProgram(program);
// Verified: regs[3]=8, regs[5]=6, mem[100]=6, regs[6]=6

4. Thực hành: Trực quan hóa Datapath đơn chu kỳ RISC-V

Gõ một lệnh Assembly (vd ADD x3, x1, x2 hoặc LW x5, 8(x2)), bấm "Dịch (Assemble)" để xem breakdown bit 32-bit THẬT, rồi bấm "Thực thi" để chạy lệnh đó qua datapath — khối đang hoạt động sẽ phát sáng cam:

📟 Datapath RISC-V đơn chu kỳ — Assemble & Execute

Mã máy 32-bit (breakdown bit):

Thanh ghi (khác 0):

(mọi thanh ghi đều = 0)
Đang khởi tạo…
RAM (lệnh) Fetch/Decode Register File ALU (Bài 1) Bộ nhớ dữ liệu (LW/SW chạm bộ nhớ — ADD/SUB dừng ở ALU)

Tóm lược

  • ✅ RV32I: mọi lệnh dài cố định 32-bit, chia trường theo 3 dạng R/I/S-type (bài này dùng).
  • ✅ Verified: ADD x3,x1,x2 = 0x2081b3, SUB x3,x1,x2 = 0x402081b3 — chỉ khác 7 bit funct7.
  • ✅ Verified: khứ hồi assemble→decode khớp đúng mnemonic trên cả 8 lệnh test.
  • ✅ Pitfall: địa chỉ tuyệt đối (Toy CPU Bài 2) khác hẳn địa chỉ tương đối PC của lệnh nhảy RISC-V thật.
  • ✅ Datapath đơn chu kỳ: TOÀN BỘ lệnh chạy trong 1 chu kỳ, xung nhịp bị ép dài theo lệnh CHẬM NHẤT (LW) — động lực trực tiếp cho pipeline ở Bài 4.
  • ✅ Verified: chương trình RV32I thật (5+3)-2 chạy đúng qua datapath, tái dùng thẳng ALU của Bài 1.

Trắc nghiệm ôn tập

Câu 1

Verified: ADD x3,x1,x2 = 0x2081b3, SUB x3,x1,x2 = 0x402081b3 (cùng toán hạng). Vì sao 2 lệnh khác nhau chỉ ở giá trị này?

Câu 2

Vì sao lệnh nhảy/rẽ nhánh RISC-V thật mã hoá địa chỉ đích TƯƠNG ĐỐI so với PC, thay vì tuyệt đối như Toy CPU của Bài 2?

Câu 3

Trong thiết kế datapath đơn chu kỳ, vì sao xung nhịp CPU phải dài bằng đúng lệnh CHẬM NHẤT (thường là LW)?

Tải file code thực hành minh họa bài học

File JavaScript CPUJS — thư viện kiến trúc máy tính mini dùng xuyên suốt cả 12 bài, Bài 3 vừa thêm assembleRV32I(), decodeRV32I(), executeRV32I(), runRV32IProgram() — bộ dịch/giải mã/thực thi RV32I R/I/S-type thật, kèm self-test đối chiếu đúng mọi con số trong bài (chạy node cpu-core.js, không cần cài thêm gì):

Tải về cpu-core.js

📖 Tài liệu tham khảo

Bài viết liên quan trong series

Bài 2: Kiến Trúc Von Neumann & Tập Lệnh ISA Bài 4: Pipeline CPU & Xung Đột Dữ Liệu (Data Hazards) Quay lại Lộ trình Kiến Trúc Máy Tính