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).


📚 Điều kiện tiên quyết
Bắt buộc đọc Bài 1 (ALU 4-bit + cờ trạng thái — Toy CPU bài này tái dùng thẳng 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_vs_harvard.c (mô tả cấu trúc bộ nhớ)
// 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
⚠️ Cạm bẫy: Self-modifying code — khi dữ liệu vô tình đè lên lệnh
Vì Von Neumann dùng CHUNG bộ nhớ, không có gì ngăn một lệnh 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:

  1. 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).
  2. Decode: giải mã IR để biết đây là lệnh gì, cần thanh ghi/địa chỉ nào.
  3. Execute: thực thi hiệu ứng thật (cập nhật thanh ghi, bộ nhớ, hoặc chính PC).
cpu_step.js (trích engine dùng chung cpu-core.js)
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:

linear_add_program.js (chương trình tuyến tính 5+3)
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.

⚠️ Cạm bẫy: thứ tự tăng PC trước lệnh nhảy
PC tăng lên 1 NGAY SAU bước fetch — TRƯỚC KHI lệnh được decode/execute. Với lệnh nhả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".

📝 Vì sao ISA này chưa phải một ISA thật
Toy CPU lưu mỗi lệnh thành một object JavaScript — CPU thật thì không: nó chỉ có các con số. Một lệnh RISC-V là đúng 32 bit, và việc tách 32 bit đó ra thành opcode, số thanh ghi và hằng số chính là bước decode thật sự. Bài 3 sẽ thay tập lệnh tự bịa này bằng RV32I — tập lệnh thật — và mã hoá nó thành số 32-bit đúng chuẩn.

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ũ)
risc_vs_cisc_add_const.asm (cùng tác vụ: cộng 1 hằng số vào ô nhớ)
; 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
⚠️ "RISC luôn nhanh hơn CISC" — không hoàn toàn đúng
Đây là hiểu lầm phổ biến. CPU x86 (CISC) hiện đại KHÔNG thực thi trực tiếp các lệnh phức tạp — chúng có một tầng giải mã ẩn dịch mỗi lệnh CISC thành một hoặc nhiều micro-ops đơn giản, rồi chạy các micro-ops đó trên một lõi thực thi mang tinh thần RISC ở bên trong. Nói cách khác, hầu hết CPU x86 hiện đại là "CISC ở vỏ ngoài, RISC ở lõi trong" — sự khác biệt hiệu năng thực tế phụ thuộc vi kiến trúc cụ thể, không phải nhãn RISC/CISC đơn thuần.

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ì:

loop_sum_program.js (chương trình chạy trong demo bên dưới)
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.
📟 Bộ giả lập Toy CPU — Fetch-Decode-Execute

RAM (Von Neumann — lệnh & dữ liệu chung)

    PC:0
    IR:
    Thanh ghi:R0=0 R1=0 R2=0 R3=0
    Đang khởi tạo…

    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 về cpu-core.js

    📖 Tài liệu tham khảo

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

    Bài 1: Cổng Logic đến Đơn Vị ALU Bài 3: Hợp Ngữ RISC-V & Đường Đi Của Dữ Liệu (Datapath) Quay lại Lộ trình Kiến Trúc Máy Tính

    Bình luận