Mở đầu: 12 bài trước dạy từng viên gạch — bài này bắt đầu xây cả căn nhà

Từ Bài 2 tới Bài 12, bạn đã học từng mảnh ghép riêng lẻ: cổng logic tổ hợp, thanh ghi, máy trạng thái, bộ cộng, FIFO, timing, kiến trúc FPGA, flow ASIC. Nhưng tất cả những mảnh ghép đó tồn tại để phục vụ đúng 1 mục tiêu cuối: xây một bộ xử lý (CPU) chạy được chương trình thật. Bài 13-14 là capstone của series — ghép mọi kiến thức đã học thành 1 CPU RISC-V RV32I hoàn chỉnh, chạy đúng chương trình assembly bạn tự viết.

Phần 1 (bài này) tập trung vào datapath — con đường vật lý dữ liệu di chuyển qua CPU: tại sao chọn RISC-V, 6 định dạng lệnh, tập lệnh con ~14 lệnh của dự án, từng khối datapath (nhiều khối bạn đã tự tay xây ở các bài trước — register file chính là FIFO/bộ nhớ ở Bài 7, ALU là bản nâng cấp của Bài 2), và bảng tín hiệu điều khiển sơ khởi. Cuối bài, Datapath Visualizer cho bạn tự viết vài dòng assembly và xem chính xác đường dữ liệu nào "sáng lên" cho từng loại lệnh. Phần 2 (Bài 14) sẽ hoàn thiện control unit và chạy chương trình thật.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 2, Bài 3, Bài 4, Bài 5, Bài 7 — datapath dùng lại trực tiếp ALU, thanh ghi, FSM control, và cấu trúc bộ nhớ đã xây; Series C Bài 2 (biểu diễn số bù 2) — bắt buộc để hiểu sign-extension của immediate.
Tài nguyên ngoài: HDLBits, ChipVerify, EDA Playground.

1. Vì sao chọn RISC-V để học thiết kế CPU

RISC-V là tập lệnh (ISA) mở hoàn toàn miễn phí bản quyền — khác x86/ARM (phải trả phí license để thiết kế chip tuân theo), bất kỳ ai cũng tự do tổng hợp CPU RISC-V thật lên FPGA hoặc ASIC (đúng flow đã học ở Bài 10/Bài 12) mà không cần xin phép. Quan trọng hơn cho việc HỌC: định dạng lệnh RISC-V cực kỳ đều đặn — mọi lệnh dài đúng 32-bit, chia thành 6 định dạng cố định, các trường bit (opcode, thanh ghi, immediate) luôn nằm ở vị trí gần giống nhau giữa các định dạng. Đây chính là lý do RISC-V trở thành ISA phổ biến nhất để dạy kiến trúc máy tính.

Định dạng Dùng cho Trường bit đặc trưng
R Số học/logic 2 thanh ghi (add, sub...) opcode, rd, funct3, rs1, rs2, funct7
I Số học tức thời + load (addi, lw...) opcode, rd, funct3, rs1, imm[11:0]
S Store (sw...) opcode, imm[4:0], funct3, rs1, rs2, imm[11:5]
B Rẽ nhánh (beq, bne...) opcode, imm, funct3, rs1, rs2, imm (lệch bit, luôn chẵn)
U Hằng số 20-bit cao (lui...) opcode, rd, imm[31:12]
J Nhảy không điều kiện (jal...) opcode, rd, imm (lệch bit, luôn chẵn)

Dự án này chỉ dùng 4 trong 6 định dạng (R, I, S, B, J — bỏ U vì hằng số 20-bit cao ngoài phạm vi tập lệnh con), đủ để dựng 1 CPU hoạt động thật với các lệnh số học, truy cập bộ nhớ, và rẽ nhánh.

rtype_encoding.txt (ví dụ: add x3, x1, x2)
Bit:     31-25    24-20   19-15   14-12   11-7    6-0
Truong:  funct7   rs2     rs1     funct3  rd      opcode
Gia tri: 0000000  00010   00001   000     00011   0110011
                   (x2)    (x1)   (add)   (x3)    (R-type)

Doc tu phai sang trai: opcode (0110011) noi day la R-type -> control
unit biet ngay can doc CA rs1 LAN rs2 (dung vi tri co dinh, khong can
biet truoc la lenh gi) -> Register File xuat 2 gia tri -> ALU cong
theo funct3+funct7 (000+0000000 = add) -> ghi ket qua vao rd (x3).

2. Tập lệnh RV32I của dự án (~14 lệnh)

Nhóm Lệnh Ý nghĩa
Số học/logic (R-type) add rd, rs1, rs2 rd = rs1 + rs2
sub rd, rs1, rs2 rd = rs1 − rs2
and rd, rs1, rs2 rd = rs1 & rs2
or rd, rs1, rs2 rd = rs1 | rs2
slt rd, rs1, rs2 rd = (rs1 < rs2) ? 1 : 0
Số học tức thời (I-type) addi rd, rs1, imm rd = rs1 + imm
andi rd, rs1, imm rd = rs1 & imm
ori rd, rs1, imm rd = rs1 | imm
slti rd, rs1, imm rd = (rs1 < imm) ? 1 : 0
Nạp/cất bộ nhớ lw rd, imm(rs1) rd = mem[rs1 + imm]
sw rs2, imm(rs1) mem[rs1 + imm] = rs2
Rẽ nhánh (B-type) beq rs1, rs2, label nếu rs1 == rs2: PC = label
bne rs1, rs2, label nếu rs1 != rs2: PC = label
Nhảy (J-type) jal rd, label rd = PC + 4; PC = label

14 lệnh này đủ để viết chương trình có ý nghĩa thật: vòng lặp (kết hợp bne/beq với jal), tính toán số học, đọc/ghi mảng qua lw/sw — chính xác những gì Bài 14 sẽ dùng để chạy chương trình Fibonacci thật.

sum_loop.s (chạy đúng trên Datapath Visualizer cuối bài)
addi x1, x0, 0      # sum = 0
addi x2, x0, 5       # counter = 5
loop:
add  x1, x1, x2      # sum += counter
addi x2, x2, -1      # counter -= 1
bne  x2, x0, loop    # neu counter != 0, quay lai loop
# Ket qua: x1 = 5+4+3+2+1 = 15, x2 = 0

Chú ý bne ở đây "quay lại" (nhảy tới địa chỉ nhỏ hơn PC hiện tại) — immediate của lệnh nhảy lùi luôn là số âm, và Immediate Generator (Mục 3) phải sign-extend đúng để giữ nguyên dấu âm đó, không phải chỉ hoạt động đúng với nhảy tiến.

3. Các khối datapath: nơi mỗi bài học trước "gặp lại" nhau

Khối Vai trò Liên hệ bài đã học
PC (Program Counter) Thanh ghi giữ địa chỉ lệnh hiện hành, tự tăng 4 (hoặc nhảy) mỗi chu kỳ Đúng 1 thanh ghi always_ff đơn giản — Bài 3
Instruction Memory Bộ nhớ chỉ đọc, địa chỉ vào là PC, ra là 32-bit lệnh Suy luận ROM — Bài 7 Mục 1
Register File 32 thanh ghi 32-bit, đọc 2 cổng (rs1, rs2) đồng thời + ghi 1 cổng (rd) Chính xác thiết kế 2-read/1-write — Bài 7 Mục 2
Immediate Generator Trích + sign-extend hằng số từ lệnh, vị trí bit khác nhau theo từng định dạng Sign-extension số bù 2 — Series C Bài 2
ALU Tính toán số học/logic, so sánh cho rẽ nhánh, tính địa chỉ bộ nhớ Bản nâng cấp ALU 4-bit — Bài 2
Data Memory Bộ nhớ đọc/ghi cho lw/sw RAM inference — Bài 7 Mục 1

$$imm_I = \text{sext}(inst[31{:}20]) \qquad imm_B = \text{sext}(inst[31] \Vert inst[7] \Vert inst[30{:}25] \Vert inst[11{:}8] \Vert 0)$$

Công thức trên minh hoạ vì sao cần 1 khối Immediate Generator riêng: mỗi định dạng lệnh "giấu" các bit của hằng số ở vị trí khác nhau (để giữ vị trí rs1/rs2 cố định giữa mọi định dạng, giúp phần cứng đọc thanh ghi được ngay cả khi CHƯA biết chắc đây là lệnh loại gì) — Immediate Generator ghép lại đúng thứ tự rồi sign-extend (nối bit dấu) thành số 32-bit đầy đủ.

alu_rv32i.sv
// Ban nang cap cua ALU 4-bit o Bai 2 — gio nhan 32-bit + chon phep toan
// bang funct3 (dung dung tin hieu tu bang control unit Muc 4).
module alu_rv32i (
  input  logic [31:0] a, b,
  input  logic [2:0]  alu_op,   // suy tu funct3 (+ funct7 cho add/sub)
  output logic [31:0] result
);
  always_comb begin
    case (alu_op)
      3'b000:  result = a + b;             // add / addi
      3'b001:  result = a - b;             // sub
      3'b010:  result = a & b;             // and / andi
      3'b011:  result = a | b;             // or / ori
      3'b100:  result = (a < b) ? 1 : 0;   // slt / slti
      default: result = 32'b0;
    endcase
  end
endmodule

Cùng đúng cấu trúc case/always_comb đã học kỹ ở Bài 2 — chỉ đổi độ rộng bit và số phép toán. Đây chính là module trong file tải về cuối bài, đã tự kiểm chứng chạy đúng trên VeriLite trước khi đưa vào bài viết.

🔬 Đào sâu: Vì sao gọi là "single-cycle" — và vì sao CPU thật không làm vậy?
Datapath bài này là single-cycle: mọi lệnh, dù đơn giản (add) hay phức tạp (lw phải đi qua ALU RỒI Data Memory), đều hoàn tất trong đúng 1 chu kỳ clock. Hệ quả: chu kỳ clock phải đủ dài để lệnh CHẬM NHẤT (thường là lw, đường dài nhất qua datapath) kịp xong — mọi lệnh khác đều "lãng phí" phần lớn chu kỳ chờ đợi dù bản thân chúng xong sớm hơn nhiều. CPU thật gần như luôn dùng pipelining (chia datapath thành nhiều tầng, xử lý nhiều lệnh gối lên nhau) để tránh lãng phí này — đúng hướng mở rộng tự nhiên sau khi nắm vững single-cycle, xem thêm ở mục tài liệu tham khảo cuối bài.

4. Control Unit sơ khởi: bảng tín hiệu điều khiển theo opcode

Cùng 1 phần cứng datapath phải "biến hình" hành vi khác nhau tuỳ loại lệnh — đó là việc của control unit: đọc opcode (7 bit thấp của lệnh), xuất ra các tín hiệu điều khiển bật/tắt đường dữ liệu tương ứng. Bảng dưới đây là control unit sơ khởi (Phần 2 sẽ ghép nốt phần funct3 để phân biệt add/sub cùng opcode):

Opcode (loại lệnh) RegWrite ALUSrc MemRead MemWrite MemToReg Branch
R-type (0110011) 1 0 (dùng rs2) 0 0 0 (dùng kết quả ALU) 0
I-type số học (0010011) 1 1 (dùng imm) 0 0 0 0
lw (0000011) 1 1 1 0 1 (dùng dữ liệu từ Data Memory) 0
sw (0100011) 0 1 0 1 0
B-type (1100011) 0 0 0 0 1
J-type (1101111) 1 0 0 0 (dùng PC+4) 0

Đây chính xác cùng khái niệm "bảng tra opcode → hành vi" đã gặp ở mọi case statement xuyên suốt series — chỉ khác quy mô: thay vì 1 FSM nhỏ (Bài 4), control unit CPU là 1 bảng tra opcode → 6 tín hiệu điều khiển đồng thời.

5. Demo tương tác: Datapath Visualizer

Viết vài dòng assembly RV32I (hỗ trợ nhãn cho beq/bne/jal), bấm Step để chạy từng lệnh — datapath bên dưới tô sáng đúng khối đang hoạt động cho loại lệnh đó, bảng thanh ghi và bộ nhớ cập nhật theo thời gian thực.

🖥️ Datapath Visualizer — RV32I
Bấm "Nạp chương trình" để bắt đầu.

Thanh ghi (x0-x7):

Data Memory (địa chỉ 0-12):

Chương trình mặc định: tính x1+x2, cất vào bộ nhớ, đọc lại, so sánh rồi rẽ nhánh bỏ qua 1 lệnh — tự sửa assembly rồi bấm lại "Nạp chương trình" để thử lệnh khác.

⚠️ Cạm bẫy: Quên rằng x0 luôn luôn bằng 0

RISC-V đảm bảo x0 luôn đọc ra 0, kể cả khi có lệnh "ghi" vào nó — nhiều chương trình biên dịch thật cố tình lợi dụng điều này: lệnh nop (không làm gì) thực chất chỉ là addi x0, x0, 0, và rất nhiều lệnh dùng x0 làm "thùng rác" khi kết quả không cần dùng tới. Nếu bộ mô phỏng/RTL của bạn ghi thẳng vào thanh ghi 0 mà không có nhánh đặc biệt, x0 sẽ ngừng là 0 sau lệnh đầu tiên ghi vào nó — mọi lệnh sau đó giả định x0=0 đều sai theo, một lỗi cực khó phát hiện vì chương trình vẫn "chạy", chỉ cho kết quả sai dần.

regfile_x0_pitfall.js (JS minh hoạ — đúng logic dùng trong demo)
// ❌ SAI: ghi thẳng, không có trường hợp đặc biệt cho thanh ghi 0
function writeRegBad(regs, i, v) {
  regs[i] = v;   // neu i===0, x0 se ngung la 0 vinh vien tu day
}

// ✅ ĐÚNG: x0 luôn bị "chặn" ghi — đúng ngữ nghĩa RISC-V thật
function writeRegGood(regs, i, v) {
  if (i !== 0) regs[i] = v;   // ghi vao x0 la no-op hop le, khong loi
}

Tóm lược

Bài này dựng xong "bộ khung" datapath — Phần 2 sẽ thổi hồn bằng chương trình chạy thật:

  • RISC-V: ISA mở miễn phí bản quyền, 6 định dạng lệnh cố định 32-bit dễ decode.
  • ~14 lệnh RV32I đủ viết chương trình có ý nghĩa: số học, load/store, rẽ nhánh, nhảy.
  • Datapath: PC, Instruction Memory, Register File, Immediate Generator, ALU, Data Memory — mỗi khối đã tự tay xây ở 1 bài trước đó trong series.
  • Control unit: bảng tra opcode → 6 tín hiệu điều khiển, cùng khái niệm case-based FSM đã học, chỉ khác quy mô.
  • x0 luôn = 0: cạm bẫy kinh điển khi tự viết register file RISC-V, chương trình biên dịch thật dựa hẳn vào đảm bảo này.

Trắc nghiệm ôn tập

Câu 1

Vì sao RISC-V chia lệnh thành nhiều định dạng (R/I/S/B/U/J) thay vì 1 định dạng chung duy nhất?

Câu 2

Register File trong datapath CPU RISC-V cần bao nhiêu cổng đọc/ghi tối thiểu, và vì sao?

Câu 3

Vì sao cần 1 khối Immediate Generator riêng thay vì đọc thẳng immediate từ 1 vị trí bit cố định?

Câu 4

Nếu register file KHÔNG chặn ghi vào x0 (bug trong callout cạm bẫy), hậu quả thực tế là gì?

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

File SystemVerilog gồm ALU RV32I (nâng cấp từ Bài 2, hỗ trợ đủ 5 phép R-type/I-type của tập lệnh), cùng comment mô tả đầy đủ 6 định dạng lệnh và bảng control signal tham khảo:

Tải về riscv_alu_example.sv

📖 Tài liệu tham khảo

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

Bài 12: ASIC Flow mã nguồn mở: Standard Cell → GDSII Bài 14: Dự án CPU RISC-V RV32I — Phần 2: Chạy chương trình thật Quay lại Lộ trình Series Thiết Kế Vi Mạch Số & FPGA

Bình luận