Mở đầu: từ mạch đếm bit đến một "bộ não" biết đọc chương trình
Bài 1 xây một khối ALU biết cộng, trừ, và so sánh — nhưng ALU đó chỉ tính MỘT phép toán khi có ai đó bơm đúng 2 toán hạng vào tay nó. Câu hỏi lớn hơn: làm sao một chuỗi phép toán — một CHƯƠNG TRÌNH — tự động chảy qua ALU đó theo đúng thứ tự, không cần con người bấm nút từng bước? Câu trả lời là một cỗ máy nhỏ gọi CPU, và một vòng lặp vô hạn ba bước đã điều khiển mọi chiếc máy tính từ 1945 tới nay: Fetch — Decode — Execute.
Bài này xây Toy CPU đầu tiên của series — một cỗ máy tối giản chạy đúng vòng lặp đó trên một tập lệnh mini tự thiết kế, tái dùng thẳng ALU của Bài 1 cho các lệnh số học. Đi kèm là 2 câu hỏi kiến trúc nền tảng: bộ nhớ nên CHUNG hay TÁCH cho lệnh/dữ liệu (Von Neumann vs Harvard), và tập lệnh nên ĐƠN GIẢN hay PHỨC TẠP (RISC vs CISC).
aluExecute() cho lệnh ADD/SUB).
1. Mô hình Von Neumann vs Harvard
Cả mục này xoay quanh một từ, nên định nghĩa nó trước: bus là bó dây dẫn vật lý mà CPU và bộ nhớ dùng để chuyển dữ liệu cho nhau. Nó là đường truyền, không phải nơi chứa. Hình dung một cây cầu một làn nối nhà máy (CPU) với kho (bộ nhớ): mọi thùng hàng đi qua cầu đó đều phải xếp hàng, bất kể trong thùng là gì. Một bus cũng vậy — tại mỗi thời điểm nó chỉ chở được một lần truyền.
Vì sao điều đó quan trọng: CPU cần lấy hai loại thứ khác nhau từ bộ nhớ. Loại thứ nhất là lệnh — việc cần làm tiếp theo. Loại thứ hai là dữ liệu — con số mà lệnh đó cần đọc hoặc ghi. Nếu hai loại này đi CHUNG một cây cầu, chúng phải xếp hàng chờ nhau; nếu có HAI cây cầu riêng, chúng đi song song. Đó chính là toàn bộ khác biệt giữa hai mô hình dưới đây.
Trong kiến trúc Von Neumann (John von Neumann, 1945), lệnh chương trình và dữ liệu nằm CHUNG một bộ nhớ, truy cập qua CHUNG một bus — CPU không phân biệt được nó đang đọc "một lệnh" hay "một con số" cho tới khi thực sự dùng giá trị đó. Kiến trúc Harvard đối lập: 2 bộ nhớ vật lý tách biệt, 2 bus riêng cho lệnh và dữ liệu — CPU có thể fetch lệnh KẾ TIẾP đồng thời đọc/ghi dữ liệu của lệnh HIỆN TẠI, tránh nút thắt cổ chai Von Neumann bottleneck (một bus dùng chung giới hạn băng thông tổng).
Toy CPU của bài này dùng đúng mô hình Von Neumann: một mảng ram DUY NHẤT chứa cả object lệnh
lẫn số liệu dữ liệu — không có ranh giới cứng nào phân biệt "vùng lệnh" với "vùng dữ liệu", đúng bản chất
"bus chung" của mô hình gốc.
// VON NEUMANN: ONE memory array, shared by both instructions and data.
// The CPU cannot tell whether a cell holds "an instruction" or "a number"
// until the moment it uses that cell.
uint32_t unified_memory[MEM_SIZE]; // program and variables both live here
// HARVARD: two PHYSICALLY separate memory arrays, two separate buses.
// Fetching the NEXT instruction and reading/writing the CURRENT one's data
// can happen AT THE SAME TIME.
uint32_t instruction_memory[PROG_SIZE]; // only instruction fetch reads it; never written
uint32_t data_memory[DATA_SIZE]; // freely read/written; never fetched as code
STORE ghi dữ liệu ĐÈ LÊN đúng
địa chỉ đang chứa một lệnh khác trong CHÍNH chương trình đó. Verified thật (không suy diễn): cho chạy
một chương trình có STORE R0, [2] trong khi địa chỉ 2 đang là lệnh
LOADI R1, 7 — CPU fetch tới địa chỉ 2 gặp phải một con số (99) thay vì object lệnh hợp lệ,
và crash thật với lỗi "Invalid instruction: undefined". Đây chính là lớp
lỗi bảo mật/hành vi-không-xác-định nghiêm trọng nhất của self-modifying code — và là lý do các hệ điều
hành hiện đại đánh dấu vùng nhớ chứa code là read-only + non-writable (bit NX/XD) ngay
ở tầng phần cứng MMU.
2. Chu kỳ Fetch-Decode-Execute
Đây là vòng lặp vô hạn điều khiển toàn bộ CPU kể từ lúc khởi động chip tới lúc tắt máy:
- Fetch: đọc lệnh tại địa chỉ mà thanh ghi PC (Program Counter) đang trỏ tới, nạp vào thanh ghi IR (Instruction Register).
-
Decode: giải mã
IRđể biết đây là lệnh gì, cần thanh ghi/địa chỉ nào. - Execute: thực thi hiệu ứng thật (cập nhật thanh ghi, bộ nhớ, hoặc chính PC).
function cpuStep(state) {
const instr = state.ram[state.pc]; // FETCH
state.ir = instr;
state.pc = state.pc + 1; // PC advances NOW - BEFORE decode/execute!
switch (instr.op) { // DECODE
case 'JMP': state.pc = instr.addr; break; // EXECUTE: overwrites the bumped PC
case 'ADD': /* ... calls aluExecute() from Lesson 1 directly ... */ break;
// ...
}
}
Chương trình nhỏ nhất chạy được trên cỗ máy này là một dãy lệnh thẳng, không nhảy: nạp hai số, cộng chúng, ghi kết quả ra bộ nhớ, rồi dừng. Đây là ví dụ để đếm chu kỳ, vì với chương trình tuyến tính thì số chu kỳ bằng đúng số lệnh:
const LINEAR = [
{ op: 'LOADI', rd: 0, imm: 5 }, // 0: R0 = 5
{ op: 'LOADI', rd: 1, imm: 3 }, // 1: R1 = 3
{ op: 'ADD', rd: 2, rs1: 0, rs2: 1 }, // 2: R2 = R0 + R1 (via aluExecute, Lesson 1)
{ op: 'STORE', rs: 2, addr: 10 }, // 3: RAM[10] = R2 (STORE names its source "rs")
{ op: 'HALT' }, // 4: stop the loop
];
// Verified by running it: runProgram(LINEAR) -> 5 cycles, R2 = 8, RAM[10] = 8,
// and PC stops at 5.
Hai con số đáng đọc kỹ ở đây. 5 chu kỳ — đúng bằng 5 lệnh, vì không lệnh nào nhảy, nên
mỗi lệnh được fetch đúng một lần. Và PC dừng ở 5, chứ không phải 4 — bởi bước fetch của
lệnh HALT (ở địa chỉ 4) vẫn tăng PC lên 5 trước khi máy dừng. Nói cách khác, PC cuối cùng cho
biết đã fetch bao nhiêu lệnh, không phải địa chỉ lệnh cuối. Chi tiết nhỏ này là gốc của cạm bẫy ngay dưới
đây.
JMP/BEQ), giá trị PC vừa tăng đó bị GHI ĐÈ ở bước execute
bằng địa chỉ đích tuyệt đối. Nếu nhầm thứ tự (tưởng PC tăng SAU khi nhảy), lập trình vi kiến trúc sẽ
tính sai địa chỉ đích — verified bằng vòng lặp tính tổng $1+2+3+4+5$: lệnh JMP 4 ở địa chỉ
7 phải nhảy về ĐÚNG địa chỉ 4 (không phải 4+1=5) để lặp lại đúng phép kiểm tra điều kiện dừng.
Verified bằng vòng lặp thật: chương trình tính $1+2+3+4+5$ bằng bộ đếm giảm dần và lệnh
BEQ/JMP cho ra đúng $R_{sum} = 15$, bộ đếm về đúng $0$, sau đúng
26 chu kỳ fetch-decode-execute — con số này do chính engine chạy ra, không bịa tay.
3. Kiến trúc tập lệnh ISA
ISA (Instruction Set Architecture — kiến trúc tập lệnh) là "hợp đồng" giữa phần mềm và phần cứng: tập hợp MỌI lệnh mà CPU hiểu được, kèm quy định mỗi lệnh cần những tham số nào. Hợp đồng này là thứ duy nhất người viết chương trình được phép dựa vào — miễn CPU tôn trọng nó, nhà sản xuất muốn đổi mạch bên trong thế nào cũng được.
3.1 Tập lệnh của Toy CPU — hợp đồng chúng ta vừa tự viết
Ở hai mục trên bạn đã thấy các lệnh LOADI, ADD, STORE,
BEQ, JMP, HALT xuất hiện trong chương trình mà chưa ai nói rõ chúng
làm gì. Đó chính là ISA của Toy CPU, và đây là toàn bộ nó — 8 lệnh, không hơn:
| Lệnh | Tham số | Ý nghĩa |
|---|---|---|
LOADI |
rd, imm |
Nạp một hằng số viết thẳng trong lệnh vào thanh ghi: R[rd] = imm. "I" là
immediate — giá trị nằm ngay trong lệnh, không phải lấy từ bộ nhớ.
|
ADD |
rd, rs1, rs2 |
R[rd] = R[rs1] + R[rs2], gọi thẳng aluExecute() của Bài 1 và cập nhật cả
cờ trạng thái.
|
SUB |
rd, rs1, rs2 |
R[rd] = R[rs1] − R[rs2], cũng qua ALU của Bài 1. |
LOAD |
rd, addr |
Đọc từ bộ nhớ vào thanh ghi: R[rd] = RAM[addr]. |
STORE |
rs, addr |
Ghi từ thanh ghi ra bộ nhớ: RAM[addr] = R[rs]. Lưu ý tham số tên là
rs (một nguồn duy nhất), không phải rs1 như các lệnh số học.
|
JMP |
addr |
Nhảy vô điều kiện: PC = addr. Là địa chỉ TUYỆT ĐỐI, không phải bước dịch. |
BEQ |
rs1, rs2, addr |
Nhảy CÓ điều kiện: nếu R[rs1] == R[rs2] thì PC = addr, ngược lại không làm
gì và chương trình chạy tiếp lệnh kế. "BEQ" = branch if equal.
|
HALT |
— | Dừng vòng lặp fetch-decode-execute. |
Nhìn lại chương trình vòng lặp ở Mục 4 với bảng này trong tay, dòng
{ op: 'BEQ', rs1: 0, rs2: 2, addr: 8 } đọc ra được ngay: "nếu R0 bằng R2 thì nhảy tới địa chỉ
8". Vì R2 được giữ bằng 0 suốt vòng lặp, câu đó chính là "nếu bộ đếm đã về 0 thì thoát".
3.2 Hai triết lý thiết kế ISA: RISC vs CISC
Toy CPU trên có 8 lệnh, mỗi lệnh làm đúng một việc — đó là một lựa chọn thiết kế, không phải điều tất yếu. Ngoài đời có hai trường phái đối lập. RISC (Reduced Instruction Set Computer — ví dụ ARM, RISC-V) chọn một tập lệnh nhỏ, đơn giản, độ dài cố định, dễ giải mã bằng mạch cứng tối giản. CISC (Complex Instruction Set Computer — ví dụ x86) cho phép lệnh phức tạp, độ dài biến đổi, một lệnh có thể làm việc của nhiều lệnh RISC gộp lại.
| Tiêu chí | RISC (ARM, RISC-V) | CISC (x86) |
|---|---|---|
| Độ dài lệnh | Cố định (RISC-V: luôn 32-bit) | Biến đổi (x86: 1-15 byte) |
| Độ phức tạp giải mã phần cứng | Đơn giản, ít transistor hơn cho decoder | Phức tạp — cần decoder biến đổi lệnh thành micro-ops |
| Số lệnh cho 1 tác vụ | Nhiều lệnh đơn giản hơn | Ít lệnh, mỗi lệnh làm nhiều việc hơn |
| Ưu thế thực tế | Hiệu quả điện năng, thiết kế chip đơn giản (di động) | Tương thích ngược cực cao (hàng thập kỷ phần mềm cũ) |
; CISC (x86) - ONE instruction does it all: read, add, write back
add DWORD PTR [rax], 5 ; 1 instruction, variable length; hardware does read-add-write
; RISC (RISC-V) - MUST be split into simple instructions, one job each
lw t0, 0(a0) ; read the value from memory into a register
addi t0, t0, 5 ; add the constant IN THE REGISTER (no memory involved)
sw t0, 0(a0) ; write the result back to memory
4. Thực hành: Bộ giả lập Toy CPU đơn giản
Chạy đúng chương trình vòng lặp tính $1+2+3+4+5$ đã verify ở Mục 2 — bấm "Từng bước" để xem PC, IR, thanh ghi cập nhật theo ĐÚNG một chu kỳ fetch-decode-execute mỗi lần bấm, hoặc "Chạy hết" để xem toàn bộ 26 chu kỳ diễn ra tức thì:
const PROGRAM = [
{ op: 'LOADI', rd: 0, imm: 5 }, // 0: R0 = counter = 5
{ op: 'LOADI', rd: 1, imm: 0 }, // 1: R1 = sum = 0
{ op: 'LOADI', rd: 2, imm: 0 }, // 2: R2 = 0 (the constant BEQ compares against)
{ op: 'LOADI', rd: 3, imm: 1 }, // 3: R3 = 1 (the constant we subtract)
{ op: 'BEQ', rs1: 0, rs2: 2, addr: 8 }, // 4: counter == 0 -> leave the loop
{ op: 'ADD', rd: 1, rs1: 1, rs2: 0 }, // 5: sum += counter
{ op: 'SUB', rd: 0, rs1: 0, rs2: 3 }, // 6: counter -= 1
{ op: 'JMP', addr: 4 }, // 7: go back and re-test the condition
{ op: 'HALT' }, // 8
];
// Verified: runProgram(PROGRAM) -> R1 (sum) = 15, R0 (counter) = 0, exactly 26 cycles.
RAM (Von Neumann — lệnh & dữ liệu chung)
Tóm lược
- ✅ Bus là đường truyền dùng chung giữa CPU và bộ nhớ — một cây cầu một làn, nên mọi lần truyền phải xếp hàng.
- ✅ Von Neumann dùng CHUNG bộ nhớ/bus cho lệnh và dữ liệu; Harvard tách riêng để tránh nút thắt cổ chai.
- ✅ Verified: self-modifying code (STORE ghi đè vùng lệnh) khiến fetch kế tiếp đọc phải rác và crash thật — đúng lý do MMU hiện đại đánh dấu vùng code read-only.
- ✅ Fetch-Decode-Execute là vòng lặp vô hạn; PC tăng NGAY sau fetch, TRƯỚC KHI lệnh nhảy có cơ hội ghi đè nó.
-
✅ Verified: chương trình tuyến tính 5+3 chạy đúng 5 chu kỳ, ghi
RAM[10] = 8, PC dừng ở 5; vòng lặp BEQ/JMP tính tổng 1..5=15 sau đúng 26 chu kỳ. - ✅ ISA là hợp đồng phần mềm–phần cứng. Toy CPU có đúng 8 lệnh (LOADI/ADD/SUB/LOAD/STORE/JMP/BEQ/HALT), đủ để viết cả vòng lặp có điều kiện.
- ✅ RISC (đơn giản, cố định) vs CISC (phức tạp, biến đổi) — CISC hiện đại dịch ngầm về micro-ops kiểu RISC.
Trắc nghiệm ôn tập
Câu 1
Vì sao mô hình Von Neumann tạo ra "nút thắt cổ chai" (Von Neumann bottleneck)?
Câu 2
Verified: một lệnh STORE ghi đè đúng địa chỉ đang chứa một lệnh khác khiến CPU crash khi
fetch tới đó. Đây là hiện tượng gì, và tại sao nó CÓ THỂ xảy ra trong Von Neumann?
Câu 3
Trong chu kỳ Fetch-Decode-Execute, PC (Program Counter) tăng lên tại thời điểm nào?
Câu 4
Nhận định nào về RISC và CISC là ĐÚNG?
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 2
vừa thêm createCpuState(), cpuStep(), runProgram() — Toy CPU
fetch-decode-execute trên tập lệnh mini (LOADI/ADD/SUB/STORE/LOAD/JMP/BEQ/HALT), 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 liệu tham khảo
- Sách giáo khoa cốt lõi: Computer Organization and Design, MIPS Edition (Patterson & Hennessy) — Chương 4 trình bày chi tiết chu kỳ fetch-decode-execute và datapath.
- Von Neumann vs Harvard: Wikipedia — Von Neumann architecture — nguồn gốc lịch sử và so sánh với Harvard architecture.
- RISC vs CISC: Wikipedia — Reduced instruction set computer — lịch sử tranh luận RISC/CISC và thực tế vi kiến trúc hiện đại.
Bình luận