Mở đầu: từ datapath tới máy tính thật
Bài 13 đã dựng xong "bộ khung": PC, Instruction Memory, Register File,
Immediate Generator, ALU, Data Memory, và một control unit sơ khởi mới chỉ đọc opcode. Bộ khung đó CHẠY
được, nhưng còn 2 lỗ hổng: control unit chưa phân biệt được add và sub (cùng
opcode, cùng funct3, chỉ khác funct7), và CPU chưa có cách nào "nói chuyện" với thế giới bên ngoài — không
có màn hình, không có cách in kết quả ra. Bài này (Phần 2, cũng là bài cuối cùng của
Series Thiết Kế Vi Mạch Số & FPGA) giải quyết cả hai, rồi đi xa hơn:
viết một assembler mini bằng JavaScript dịch assembly ra đúng mã máy 32-bit thật (không phải object trung
gian như Bài 13), nạp và chạy 2 chương trình có ý nghĩa — tính dãy Fibonacci và đảo một mảng số — trên
chính CPU bạn tự thiết kế.
Phần cuối bài là một buổi "debug thật": một bug lệch branch offset kinh điển được cài sẵn, bạn sẽ tự lần theo waveform (bảng trace từng chu kỳ) để tìm ra chỗ sai — tổng ôn đúng kỹ năng đọc sóng đã luyện xuyên suốt cả series, từ Bài 3 tới Bài 5. Bài viết khép lại bằng hướng mở rộng lên pipeline 5 tầng, và một bản đồ nghề nghiệp cho ai muốn học tiếp.
Tài nguyên ngoài: HDLBits, ChipVerify, TinyTapeout.
1. Hoàn thiện Control Unit: decode toàn bộ tập lệnh
Bảng control unit ở Bài 13 chỉ đọc opcode (7 bit thấp)
— đủ để phân biệt R-type/I-type/lw/sw/B-type/J-type, nhưng
không đủ để phân biệt các lệnh CÙNG opcode. Ví dụ kinh điển: add và
sub đều là R-type (opcode 0110011), đều có funct3 = 000 — điểm khác
biệt DUY NHẤT nằm ở funct7 (bit 30 của lệnh gốc): 0000000 là add,
0100000 là sub. Một control unit thật phải đọc cả 3 trường (opcode, funct3, funct7) mới decode đúng 100% tập lệnh.
| Lệnh | Opcode | funct3 | funct7 | ALUOp | RegWrite | MemR/W | Branch |
|---|---|---|---|---|---|---|---|
add |
0110011 |
000 |
0000000 |
cộng | 1 | 0/0 | 0 |
sub |
0110011 |
000 |
0100000 |
trừ | 1 | 0/0 | 0 |
and / andi |
0110011/0010011 |
111 |
— | and | 1 | 0/0 | 0 |
or / ori |
0110011/0010011 |
110 |
— | or | 1 | 0/0 | 0 |
slt / slti |
0110011/0010011 |
010 |
— | slt | 1 | 0/0 | 0 |
lw |
0000011 |
010 |
— | cộng | 1 | 1/0 | 0 |
sw |
0100011 |
010 |
— | cộng | 0 | 0/1 | 0 |
beq |
1100011 |
000 |
— | so sánh | 0 | 0/0 | 1 |
bne |
1100011 |
001 |
— | so sánh | 0 | 0/0 | 1 |
jal |
1101111 |
— | — | — | 1 | 0/0 | 0 |
Đây chính xác là bảng chân lý mà demo cuối bài dùng để chạy chương trình thật — không phải một sơ đồ vẽ
minh hoạ suông. Hàm decode() dưới đây (rút gọn từ demo) đọc trực tiếp 32-bit lệnh, tách 5
trường theo đúng vị trí bit cố định của từng định dạng (bảng đã học ở
Bài 13 Mục 1), không cần biết trước "đây là lệnh gì" — điều này chính là
lý do RISC-V thiết kế vị trí opcode/rd/funct3 luôn cố định giữa các
định dạng.
function decode(word) {
word = word >>> 0;
const opcode = word & 0x7f;
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 === OPCODE.R) return { fmt: 'R', opcode, rd, funct3, rs1, rs2, funct7 };
if (opcode === OPCODE.I || opcode === OPCODE.LOAD) {
let imm = word >>> 20;
if (imm & 0x800) imm -= 0x1000; // sign-extend 12-bit
return { fmt: 'I', opcode, rd, funct3, rs1, imm };
}
// ... S/B/J tuong tu, moi dinh dang ghep lai dung thu tu bit cua no
}
Với funct7 đã tách riêng, phân biệt add/sub chỉ còn là 1 dòng:
funct3 === 0 && funct7 === 0 → add,
funct3 === 0 && funct7 === 0x20 → sub. Bảng phần cứng tương đương (2 module
control_unit + alu_decoder, tự kiểm chứng bằng VeriLite trước khi đưa vào bài)
nằm trong file tải về cuối trang, kèm testbench self-checking theo đúng kỹ thuật đã học ở
Bài 5 — máy chấm tự động so sánh từng tín hiệu, không "nhìn bằng mắt thấy đẹp
là xong".
2. Load/Store & Memory-Mapped I/O: nối vòng về Series 10
CPU đọc/ghi Data Memory qua lw/sw — nhưng bộ nhớ đó dùng để làm gì
nếu CPU không có cách nào "nói ra ngoài"? Câu trả lời công nghiệp thật:
memory-mapped I/O — dành riêng MỘT vài địa chỉ trong không gian nhớ, và phần cứng "gài
bẫy" ở đó: khi CPU ghi vào địa chỉ đó bằng sw (giống hệt ghi vào RAM bình thường, CPU
không biết có gì khác biệt), mạch ngoài Data Memory chặn giá trị đó lại và làm một việc khác —
bật đèn LED, gửi ký tự ra UART, hoặc (trong demo bài này) in ra "màn hình console ảo".
Đây chính xác là cơ chế đứng sau GPIO đã học ở Series 10 Bài 15 — lúc đó bạn nhìn từ phía phần cứng MCU có sẵn (thanh ghi GPIO đã tồn tại, code chỉ việc ghi vào). Giờ bạn tự xây phía bên kia của chính thanh ghi đó: quyết định địa chỉ nào là "ảo", và mạch nào bắt lấy giá trị ghi vào địa chỉ ấy.
| Địa chỉ | Vai trò |
|---|---|
0 – 251 |
Data Memory bình thường — lw/sw đọc/ghi RAM thật |
252 (0xFC) |
OUTPUT port ảo — sw vào đây KHÔNG lưu vào RAM, giá trị bị chặn lại và
đẩy vào "console ảo" hiển thị trong demo
|
const OUTPUT_ADDR = 252; // 0xFC — "cong xuat" anh xa vao khong gian nho
writeMem(addr, v) {
if (addr === OUTPUT_ADDR) {
this.output.push(v | 0); // KHONG luu vao RAM — day vao console ao
return;
}
this.mem.set(addr, v | 0); // dia chi khac: RAM binh thuong
}
Chỉ 4 dòng JS, nhưng đây là đúng khái niệm đứng sau mọi driver phần mềm bạn từng viết cho vi điều khiển: "ghi vào một địa chỉ" và "điều khiển một thiết bị vật lý" là cùng một hành động, chỉ khác nhau ở việc mạch nào đang lắng nghe địa chỉ đó.
3. Assembler mini bằng JS: Assembly → Mã máy Hex → Chạy chương trình thật
Bài 13 chạy assembly qua một object trung gian ({'{'}mnem, rd, rs1, ...{'}'}) — tiện cho việc
dạy datapath, nhưng KHÔNG phải cách CPU thật hoạt động: CPU thật chỉ nhìn thấy các con số 32-bit trong
Instruction Memory, không có khái niệm "tên lệnh". Bài này viết một assembler
hai lượt duyệt (two-pass) đúng nghĩa: lượt 1 xây bảng nhãn (label → địa chỉ), lượt 2 mã
hoá từng dòng ra đúng số 32-bit theo bảng bit-encoding đã học ở
Bài 13 Mục 1.
// B-type "xao" thu tu bit immediate de giu vi tri rs1/rs2 co dinh —
// encode phai ghep dung tung manh vao dung vi tri trong tu lenh 32-bit.
function encodeB(funct3, rs1, rs2, imm) {
const imm12 = (imm >> 12) & 0x1; // bit dau (dat o bit 31 cua tu lenh)
const imm10_5 = (imm >> 5) & 0x3f; // 6 bit giua (bit 30-25)
const imm4_1 = (imm >> 1) & 0xf; // 4 bit thap (bit 11-8)
const imm11 = (imm >> 11) & 0x1; // 1 bit con lai (bit 7)
return (
(imm12 << 31) | (imm10_5 << 25) | (rs2 << 20) | (rs1 << 15) |
(funct3 << 12) | (imm4_1 << 8) | (imm11 << 7) | OPCODE.B
) >>> 0;
}
Bit imm[0] không bao giờ được lưu — RISC-V giả định nó LUÔN bằng 0 (mọi lệnh dài 4 byte, địa
chỉ nhảy luôn chẵn), tiết kiệm đúng 1 bit để tầm nhảy xa hơn gấp đôi. Bộ mã hoá/giải mã đầy đủ (5 định
dạng, cả immediate âm) đã được tự kiểm chứng bằng Node
đối chiếu với 2 hằng số RISC-V thật trước khi đưa vào bài: lệnh nop (addi x0, x0, 0) phải mã hoá đúng ra 0x00000013, và add x0, x0, x0 phải ra
0x00000033 — 2 con số này là hằng số RISC-V ai cũng có thể tự kiểm tra lại.
Demo dưới đây chạy đúng pipeline CPU thật: assemble → mã máy hex → decode → thực thi, không còn đi tắt qua object trung gian. Thử nạp preset Fibonacci hoặc Đảo mảng — cả hai đều dùng memory-mapped output vừa xây ở Mục 2 để "in kết quả ra màn hình".
Mã máy (hex):
Trace (waveform dạng bảng):
Thanh ghi (x0-x7):
Console ảo (memory-mapped output, địa chỉ 252):
"Offset đúng"/"Offset sai" nạp cùng 1 chương trình chỉ khác duy nhất giá trị immediate của
beq — xem Mục 4 để hiểu vì sao 1 con số sai lệch làm CPU chạy nhầm hoàn toàn.
4. Debug CPU bằng Waveform: bug lệch branch offset kinh điển
Đây là bug thật, cực kỳ phổ biến khi ai đó lần đầu tự tay tính immediate cho lệnh rẽ nhánh: nhầm lẫn giữa "số lệnh cần nhảy qua" và "số byte cần nhảy qua". RISC-V đo khoảng cách nhảy bằng byte (mỗi lệnh dài 4 byte), không phải bằng số lệnh — quên điều này là nguồn lỗi off-by-4 kinh điển.
addi x1, x0, 5
addi x2, x0, 5
beq x1, x2, 8 # DUNG: PC lenh beq nay = 8, muon nhay toi PC=16
# (bo qua dung lenh addi x5 ngay duoi) -> imm = 16-8 = 8
addi x5, x0, 111 # PC=12 — SE BI BO QUA neu offset dung
addi x6, x0, 1 # PC=16 — diem den dung
# --- Ban BUG: nham "nhay qua 1 lenh" = do rong 1 lenh (4), quen rang
# offset phai TINH TU CHINH DIA CHI CUA LENH BEQ, khong phai "1 lenh
# phia sau" ---
# beq x1, x2, 4 # SAI: nhay toi PC=8+4=12 — DUNG LA lenh dinh bo qua!
# # -> addi x5 VAN CHAY (x5=111 thay vi 0), branch coi
# # nhu KHONG CO TAC DUNG GI
Bấm preset "✅ Offset đúng" rồi Step qua từng lệnh trong demo Mục 3: đúng như kỳ vọng,
x5 giữ nguyên 0 (bị bỏ qua), x6 = 1. Đổi sang
"🐛 Offset sai (bug)" và Step lại — bảng trace cho thấy PC vẫn nhảy (branch "coi như có
chạy"), nhưng nhảy tới đúng địa chỉ của lệnh lẽ ra phải bị bỏ qua: x5 đổi thành
111, một kết quả sai hoàn toàn nhưng CPU không hề báo lỗi gì — chương trình
vẫn "chạy xong", chỉ cho kết quả sai.
Đây chính là kỹ năng "đọc waveform" được luyện xuyên suốt series: khi kết quả sai mà không có lỗi cú pháp/crash nào, cách duy nhất để tìm ra là lần theo trace từng chu kỳ (PC, lệnh, giá trị thanh ghi) và so sánh với kỳ vọng — đúng thao tác bạn vừa làm với bảng trace ở trên, chỉ khác quy mô so với việc soi waveform D-FF ở Bài 3 hay debug FSM ở Bài 4.
Lỗi này đặc biệt nguy hiểm vì im lặng: CPU không crash, không báo lỗi decode (giá trị
immediate sai vẫn là một số 12-bit hợp lệ), chương trình vẫn chạy hết và dừng đúng lúc — chỉ có kết
quả cuối cùng là sai. Trình biên dịch/assembler thật (như GNU Binutils) tính offset này TỰ ĐỘNG từ
nhãn (label - addr, đúng công thức demo Mục 3 dùng) chính là để loại bỏ hoàn toàn khả
năng con người tính sai — đây là lý do không ai viết tay địa chỉ nhảy tuyệt đối
trong assembly thật, luôn dùng nhãn.
Cách phát hiện khi debug: nếu một nhánh "có vẻ chạy" nhưng hành vi sau đó sai khó hiểu, kiểm tra PC ngay SAU lệnh rẽ nhánh trong trace — nếu nó KHÔNG khớp địa chỉ nhãn đích mong muốn, offset đã bị tính sai, không phải lỗi logic ở nơi khác.
5. Hướng mở rộng: Pipeline 5 tầng, Hazard, và $f_{max}$
CPU bài này là single-cycle (nhắc lại từ Bài 13): mọi
lệnh xong trong đúng 1 chu kỳ, nên chu kỳ phải đủ dài cho lệnh CHẬM NHẤT (lw, đường dài nhất
qua ALU rồi Data Memory). CPU thật hầu như luôn dùng pipeline 5 tầng: chia datapath thành
IF (Instruction Fetch) → ID (Decode) → EX (Execute/ALU) → MEM (truy cập bộ nhớ) → WB (ghi lại register
file), mỗi tầng là một lệnh khác nhau đang chạy đồng thời — giống dây chuyền lắp ráp.
$$T_{min} = t_{clk \to q} + t_{comb,max} + t_{setup} \qquad f_{max} = \frac{1}{T_{min}}$$
Công thức $f_{max}$ đã học kỹ ở Bài 8 giải thích chính xác vì sao pipeline nhanh hơn: $t_{comb,max}$ của single-cycle phải bao trùm CẢ 5 khối (PC→IMem→RegFile→ALU→DMem nối tiếp nhau); pipeline cắt đường đó thành 5 đoạn ngắn, $t_{comb,max}$ giờ chỉ còn là đoạn CHẬM NHẤT trong 5 đoạn (thường là EX qua ALU) — chu kỳ ngắn hơn hẳn, dù mỗi lệnh riêng lẻ vẫn mất 5 chu kỳ để hoàn tất (latency tăng, nhưng throughput — số lệnh hoàn tất mỗi giây — tăng mạnh hơn nhiều).
| Single-cycle (bài này) | Pipeline 5 tầng (minh hoạ) | |
|---|---|---|
| $T_{min}$ ước lượng | ≈ 3.7 ns (bao trùm cả PC→DMem) | ≈ 1.0 ns (chỉ tầng EX/ALU, chậm nhất) |
| $f_{max}$ ước lượng | ≈ 270 MHz | ≈ 660 MHz |
| Latency 1 lệnh | 1 chu kỳ (≈ 3.7 ns) | 5 chu kỳ (≈ 5.0 ns) — chậm hơn cho 1 lệnh đơn lẻ |
| Throughput (chương trình dài) | ≈ 270 triệu lệnh/giây | ≈ 660 triệu lệnh/giây — cao hơn hẳn nhờ chạy gối lên nhau |
Số liệu trên chỉ mang tính minh hoạ khái niệm (không phải kết quả synthesis thật) — bạn có thể tự dựng lại đúng phép so sánh này bằng cách kéo slider độ trễ trên STA Workbench ở Bài 8, thêm 1 tầng pipeline và quan sát $f_{max}$ đổi trực tiếp.
Chạy nhiều lệnh gối lên nhau sinh ra 2 loại xung đột (hazard) không tồn tại ở single-cycle:
Data hazard (RAW — Read After Write): lệnh add x1, x2, x3 rồi ngay lập
tức addi x4, x1, 1 — ở pipeline, lệnh thứ 2 cần đọc x1 ở tầng ID trong khi
lệnh 1 CHƯA kịp ghi x1 ở tầng WB (còn cách 3 chu kỳ). Giải pháp công nghiệp:
forwarding (chuyển thẳng kết quả ALU từ tầng EX/MEM ngược về đầu vào ALU của lệnh
sau, không đợi qua register file).
Control hazard: chính là bug ở Mục 4, nhưng nguy hiểm hơn nhiều ở pipeline — CPU đã lỡ fetch vài lệnh tiếp theo trước khi biết branch có nhảy hay không (vì kết quả so sánh chỉ có ở tầng EX, vài chu kỳ SAU khi đã fetch). Nếu đoán sai hướng nhảy, các lệnh đã fetch nhầm phải bị huỷ (flush) — đúng lý do vì sao CPU hiện đại đầu tư rất nhiều vào bộ dự đoán nhánh (branch predictor).
6. Tổng kết lộ trình nghề & bạn đã đi được bao xa
Thiết kế vi mạch số là một ngành rộng với nhiều hướng chuyên môn hoá — CPU RV32I bạn vừa xây chạm vào gần như tất cả:
| Hướng nghề | Làm gì | Nối với bài nào trong series |
|---|---|---|
| RTL Designer | Viết SystemVerilog mô tả datapath/control, tối ưu diện tích và timing | Bài 2-4, 6-7, 13 |
| Verification Engineer | Viết testbench, UVM, đo coverage, săn bug trước tape-out | Bài 5, 14 Mục 1 |
| FPGA Engineer | Synthesis/P&R lên FPGA, timing closure, tích hợp IP | Bài 9-10 |
| Physical Design Engineer | Floorplan, placement, CTS, routing, DRC/LVS signoff cho ASIC | Bài 8, 12 |
Tài nguyên học tiếp: sách Digital Design and Computer Architecture, RISC-V Edition (Harris & Harris) đi sâu đúng CPU bạn vừa xây nhưng ở mức pipeline đầy đủ; HDLBits và ChipVerify để luyện SystemVerilog; TinyTapeout nếu muốn tape-out một thiết kế thật với chi phí sinh viên.
Và đây là hành trình bạn vừa đi qua, từ những bước đầu tiên:
- ☑️ Series 10 Bài 1: đo điện áp bằng VOM → giờ tự tay thiết kế một CPU 32-bit đọc/ghi hàng chục thanh ghi mỗi giây.
- ☑️ Series 10 Bài 12 (cổng logic) → Bài 2 (SystemVerilog tổ hợp) → ALU RV32I hoàn chỉnh ở bài này.
- ☑️ Series 10 Bài 13 (flip-flop, setup/ hold) → Bài 8 (STA) → giải thích được vì sao pipeline nhanh hơn ở Mục 5.
- ☑️ Series 10 Bài 15 (GPIO) → Mục 2 bài này: tự xây phía phần cứng của chính cơ chế memory-mapped I/O đó.
- ☑️ Bài 7 (register file, FIFO) → trái tim của Register File CPU ở Bài 13.
- ☑️ Bài 12 (ASIC flow) → biết chính xác CPU này sẽ đi đâu nếu tape-out thật.
Tóm lược
Đây là bài cuối của series — CPU RV32I giờ đã hoàn chỉnh và chạy chương trình thật:
-
✅ Control unit đầy đủ: decode cả opcode + funct3 + funct7, phân biệt được
add/submà Bài 13 còn nợ. - ✅ Memory-mapped I/O: 1 địa chỉ dành riêng (252) làm "cổng xuất" — cùng cơ chế với GPIO thật, chỉ khác góc nhìn (xây phần cứng thay vì dùng có sẵn).
- ✅ Assembler 2-lượt thật: assembly → mã máy hex 32-bit đúng chuẩn RISC-V (kiểm chứng bằng 2 hằng số thật), chạy Fibonacci và đảo mảng bằng chính CPU tự thiết kế.
- ✅ Bug lệch branch offset: lỗi im lặng kinh điển — nhầm số lệnh với số byte — tìm ra bằng cách đọc trace từng chu kỳ, không phải đoán.
- ✅ Pipeline 5 tầng: cắt critical path tăng $f_{max}$, đổi lại phải giải quyết data/ control hazard — hướng học tiếp tự nhiên sau single-cycle.
Trắc nghiệm ôn tập
Câu 1
Vì sao control unit chỉ đọc opcode (như ở Bài 13) là KHÔNG đủ để decode đúng toàn bộ tập
lệnh RV32I?
Câu 2
Trong demo CPU Runner, khi sw x1, 252(x0) được thực thi, điều gì xảy ra với giá trị của
x1?
Câu 3
Bug lệch branch offset ở Mục 4 (dùng imm=4 thay vì imm=8) gây hậu quả gì, và vì sao khó phát hiện?
Câu 4
Vì sao pipeline 5 tầng thường có $f_{max}$ cao hơn single-cycle, dù tổng công việc mỗi lệnh phải làm là như nhau?
Tải file code thực hành minh họa bài học
File SystemVerilog gồm control_unit + alu_decoder đầy đủ (opcode + funct3 +
funct7, phân biệt đúng add/sub), ghép cùng ALU RV32I của Bài 13, kèm testbench self-checking kiểm chứng
6 nhóm lệnh:
Bình luận