Mở đầu: Con bug chỉ xảy ra "thỉnh thoảng", không bao giờ tái hiện được 2 lần giống nhau
Mạch của bạn chạy hoàn hảo trên testbench, hoàn hảo khi debug bằng waveform, hoàn hảo suốt hàng giờ thử nghiệm — rồi đột nhiên, một lần trong hàng triệu chu kỳ, dữ liệu sai lệch. Bạn thêm lại đúng điều kiện đó để tái hiện bug, mạch lại chạy đúng. Đây không phải bug logic thông thường — đây là dấu hiệu kinh điển của việc 2 tín hiệu từ 2 vùng đồng hồ (clock domain) khác nhau chạm mặt nhau mà không có biện pháp bảo vệ. Tín hiệu tưởng chừng "chỉ là 1 sợi dây" khi băng qua ranh giới đó có thể vi phạm cửa sổ setup/hold của flip-flop nhận, đẩy nó vào trạng thái bất ổn định (metastable) — không hẳn 0, không hẳn 1 — trong một khoảng thời gian không thể đoán trước.
Bài này giải quyết 2 vấn đề tưởng không liên quan nhưng luôn đi cùng nhau trong thiết kế thực tế: cách suy luận (infer) bộ nhớ RAM/ROM và register file từ SystemVerilog thuần, và cách xây dựng FIFO — hàng đợi phần cứng dùng để "đệm" dữ liệu giữa 2 khối chạy tốc độ khác nhau. Khi 2 khối đó dùng 2 đồng hồ độc lập, FIFO buộc phải vượt qua ranh giới miền xung nhịp một cách an toàn — đây chính là lúc bộ đồng bộ 2-FF (2-flip-flop synchronizer) và mã Gray xuất hiện, không phải vì "đúng chuẩn cho có" mà vì thiếu chúng, mạch của bạn sẽ mang đúng loại bug đã mô tả ở trên: hiếm gặp, không tái hiện được, và cực kỳ khó debug.
always_ff;
Series Điện Tử Bài 13 (Mạch tuần tự & setup/hold)
— bài này giả định bạn đã hiểu vì sao vi phạm setup/hold gây ra trạng thái bất ổn định.
Tài nguyên ngoài: HDLBits, ChipVerify, EDA Playground.
1. RAM/ROM inference: mảng bộ nhớ trong SystemVerilog
Không có khai báo module ram hay từ khoá đặc biệt nào cho bộ nhớ — công cụ tổng hợp
(synthesis) suy luận (infer) RAM/ROM từ một khai báo mảng logic thông
thường, được đọc/ghi theo một khuôn mẫu (pattern) mà nó nhận diện được:
// ⚠️ Ngoài subset VeriLite — cần mảng bộ nhớ (memory array) + indexing động
// bằng địa chỉ (addr), engine của trang này chưa hỗ trợ. Chạy thật trên
// Verilator/EDA Playground.
module ram_infer (
input logic clk,
input logic we, // write-enable
input logic [7:0] addr,
input logic [7:0] din,
output logic [7:0] dout
);
logic [7:0] mem [0:255]; // khai báo mảng 256 ô, mỗi ô 8-bit → tool suy luận RAM 256×8
always_ff @(posedge clk) begin
if (we) mem[addr] <= din; // ghi đồng bộ
dout <= mem[addr]; // đọc đồng bộ (registered read) — trễ 1 chu kỳ, phổ biến ở BRAM FPGA
end
endmodule
Điểm mấu chốt: dout được gán bên trong always_ff nên là
đọc đồng bộ — giá trị xuất hiện 1 chu kỳ sau khi địa chỉ đổi, không phải ngay
lập tức. Đây chính là cách hầu hết BRAM (Block RAM) trên FPGA hoạt động thật (sẽ gặp lại ở
Bài 9) — cố tình gán dout bằng assign
(đọc tổ hợp, không đăng ký) thường buộc tool phải dùng LUT-RAM (tốn logic) thay vì BRAM chuyên dụng (rẻ,
nhanh, có sẵn số lượng lớn trên chip).
2. Register File 2-Read/1-Write: trái tim tương lai của CPU
Một dạng bộ nhớ đặc biệt quan trọng: register file — tập hợp thanh ghi nhỏ (thường 16-32 thanh ghi) với khả năng đọc 2 địa chỉ cùng lúc (2 toán hạng nguồn của 1 lệnh ALU) và ghi 1 địa chỉ. Đây chính là cấu trúc bạn sẽ dùng để xây datapath CPU RISC-V ở Bài 13-14.
// ⚠️ Ngoài subset VeriLite — cùng lý do: mảng bộ nhớ + indexing động.
module regfile_2r1w (
input logic clk,
input logic we,
input logic [4:0] waddr, raddr1, raddr2, // 5 bit → 32 thanh ghi
input logic [31:0] wdata,
output logic [31:0] rdata1, rdata2
);
logic [31:0] regs [0:31];
always_ff @(posedge clk) begin
if (we) regs[waddr] <= wdata; // GHI: đồng bộ, chỉ 1 cổng ghi
end
// ĐỌC: tổ hợp (combinational) — không chờ clock, đọc 2 thanh ghi CÙNG LÚC
// trong 1 chu kỳ. Đây là lý do register file có "2 cổng đọc, 1 cổng ghi":
// 1 lệnh ALU cần 2 toán hạng nguồn NGAY LẬP TỨC, không thể đợi tuần tự.
assign rdata1 = regs[raddr1];
assign rdata2 = regs[raddr2];
endmodule
Khác với ram_infer ở Mục 1 (đọc đồng bộ, trễ 1 chu kỳ), register file thường đọc
tổ hợp (combinational) — vì CPU cần giá trị thanh ghi ngay trong cùng 1 chu kỳ để đưa vào
ALU, không có thời gian chờ thêm 1 chu kỳ như BRAM.
3. FIFO đồng bộ: con trỏ, bộ đếm, và cạm bẫy "đầy hay rỗng"
FIFO là hàng đợi phần cứng: dữ liệu ghi vào (push) theo thứ tự nào thì đọc ra (pop) đúng thứ tự đó. Bên trong là bộ nhớ (Mục 1) cộng với logic điều khiển — chính là phần dạy được trên VeriLite, vì chỉ cần đếm, không cần mảng bộ nhớ thật:
module fifo_ctrl (
input logic clk, rst,
input logic push, pop, // yêu cầu ghi / đọc từ 2 phía
output logic full, empty,
output logic [3:0] count // FIFO sâu 8 ô — count chạy 0..8
);
always_ff @(posedge clk) begin
if (rst)
count <= 0;
else
count <= (push && !full && pop && !empty) ? count : // ghi+đọc đồng thời: giữ nguyên
(push && !full) ? count + 1 : // chỉ ghi (còn chỗ)
(pop && !empty) ? count - 1 : // chỉ đọc (còn dữ liệu)
count; // ghi khi đầy / đọc khi rỗng: bỏ qua
end
assign full = (count == 8);
assign empty = (count == 0);
endmodule
Cạm bẫy kinh điển của FIFO: nếu bạn chỉ so sánh con trỏ ghi (wptr) và con trỏ đọc
(rptr) để suy ra trạng thái — wptr == rptr vừa có thể nghĩa là
"rỗng hoàn toàn" (chưa ghi gì) vừa có thể nghĩa là "đầy hoàn toàn" (đã
ghi đủ 1 vòng, con trỏ đọc đuổi kịp con trỏ ghi) — 2 trạng thái đối lập nhưng cùng 1 điều kiện so sánh!
Cách sửa kinh điển: dùng thêm 1 bộ đếm mức đầy riêng biệt (như count ở trên) hoặc thêm 1 bit
mở rộng cho con trỏ để phân biệt "đã vòng qua chưa". Demo bên dưới chạy đúng module
fifo_ctrl — bấm Push/Pop để tự kiểm chứng full/empty không bao giờ nhầm lẫn.
Bấm Push 8 lần liên tiếp để thấy full lên 1 và ô thứ 9 bị bỏ qua; bấm Pop khi rỗng để
thấy count không bao giờ tụt xuống âm.
// Xem module đầy đủ ở Mục 3 phía trên, hoặc tải file fifo_and_cdc.sv
// ở cuối bài để chạy trên Verilator/EDA Playground.
4. Vượt miền xung nhịp (CDC): Metastability & bộ đồng bộ 2-FF
Khi FIFO nối 2 khối chạy 2 đồng hồ độc lập (vd producer ở clk_a 100MHz, consumer ở
clk_b 133MHz không đồng bộ pha), các cờ full/empty phải
băng qua ranh giới đồng hồ để bên kia đọc được. Vấn đề: flip-flop nhận yêu cầu tín hiệu
đầu vào ổn định trong 1 cửa sổ nhỏ quanh cạnh clock (setup/hold — đã học ở
Series Điện Tử Bài 13). Tín hiệu đến từ 1
đồng hồ khác có thể đổi giá trị bất kỳ lúc nào, kể cả đúng lúc flip-flop đang "nhìn" nó —
vi phạm cửa sổ đó đẩy flip-flop vào trạng thái bất ổn định (metastable): điện áp lơ lửng giữa mức 0 và 1
trong một khoảng thời gian không xác định, trước khi ngẫu nhiên "rơi" về 0 hoặc 1.
Sơ đồ kiến trúc FIFO bất đồng bộ (đầy đủ ở Mục 5): 2 miền clock độc lập chỉ trao đổi đúng 2 tín hiệu con trỏ, cả 2 đều đi qua bộ đồng bộ 2-FF trước khi được dùng để tính full/empty ở phía đối diện — không có đường tắt nào bỏ qua bộ đồng bộ.
Đưa thẳng tín hiệu từ miền clock khác vào logic tổ hợp (hoặc chỉ qua 1 flip-flop) là không đủ an toàn: nếu flip-flop đó rơi vào trạng thái bất ổn định, giá trị lơ lửng có thể lan ra toàn mạch trước khi kịp ổn định, gây lỗi logic không tái hiện được — đúng bug mô tả ở đầu bài.
// ❌ SAI: chỉ 1 tầng flip-flop — không đủ thời gian để trạng thái bất ổn
// định (nếu có) ổn định lại trước khi giá trị này được logic khác dùng
module synchronizer_1ff_bad (
input logic clk, async_in,
output logic sync_out
);
always_ff @(posedge clk) begin
sync_out <= async_in; // async_in đổi bất kỳ lúc nào — vi phạm setup/hold ở đây
end // là "phơi" thẳng ra ngoài, không ai chặn lại
endmodule
// ✅ ĐÚNG: 2 tầng flip-flop nối tiếp — nếu ff1 lỡ bất ổn định, nó có TRỌN
// VẸN 1 chu kỳ clock để ổn định lại trước khi ff2 lấy mẫu. sync_out luôn
// là giá trị ĐÃ ổn định, đổi lại trễ thêm đúng 1 chu kỳ.
module synchronizer_2ff (
input logic clk, async_in,
output logic sync_out
);
logic ff1;
always_ff @(posedge clk) begin
ff1 <= async_in; // tầng 1: có thể bất ổn định, KHÔNG dùng trực tiếp giá trị này
sync_out <= ff1; // tầng 2: lấy mẫu ff1 SAU KHI đã có 1 chu kỳ để ổn định
end
endmodule
MTBF (thời gian trung bình giữa 2 lần lỗi) của bộ đồng bộ 2-FF tăng theo hàm mũ so với 1-FF, vì xác suất trạng thái bất ổn định vẫn chưa ổn định sau trọn 1 chu kỳ clock là cực nhỏ. Đây là lý do "2 tầng" gần như luôn đủ trong thực tế công nghiệp — 3+ tầng chỉ dùng cho hệ thống tần số cực cao hoặc yêu cầu an toàn khắt khe.
Bấm "Đổi tín hiệu SÁT cạnh clock" rồi "Step Clock" nhiều lần — 1-FF có thể "phơi" ra giá trị
❓ bất ổn định ngay chu kỳ đó, còn 2-FF luôn chờ đủ 1 chu kỳ đệm nên
sync_out không bao giờ bất ổn định (chỉ trễ hơn). Đây là mô hình khái niệm đơn giản hoá
để minh hoạ — VeriLite không mô phỏng điện áp thật, xem thêm khung "Đào sâu" bên dưới.
5. FIFO bất đồng bộ: vì sao con trỏ nhị phân "chết" khi vượt miền clock
FIFO đồng bộ ở Mục 3 dùng 1 bộ đếm chung cho cả ghi lẫn đọc — chỉ đúng khi ghi và đọc
cùng 1 đồng hồ. FIFO bất đồng bộ (asynchronous FIFO) phải tách thành 2 con trỏ độc lập
(wptr ở miền ghi, rptr ở miền đọc), rồi đồng bộ hoá TỪNG con trỏ sang miền đối
diện bằng bộ đồng bộ 2-FF ở Mục 4 để tính full/empty. Vấn đề: con trỏ nhị phân
thông thường có thể đổi nhiều bit cùng lúc khi tăng — ví dụ 011 → 100 đổi cả
3 bit. Nếu bộ đồng bộ 2-FF lấy mẫu đúng lúc con trỏ đang chuyển tiếp, mỗi bit có thể được "chụp" ở thời
điểm khác nhau, tạo ra một giá trị trung gian sai hoàn toàn (vd 111 hoặc 000) —
không phải giá trị cũ, cũng không phải giá trị mới.
Giải pháp: mã hoá con trỏ bằng Gray code — bảng mã nhị phân đặc biệt nơi chỉ đúng 1 bit đổi giữa 2 giá trị liên tiếp:
| Giá trị | Binary | Gray code | Số bit đổi so với dòng trước |
|---|---|---|---|
| 0 | 000 |
000 |
— |
| 1 | 001 |
001 |
1 |
| 2 | 010 |
011 |
1 |
| 3 | 011 |
010 |
1 |
| 4 | 100 |
110 |
1 |
So sánh dòng 3→4: binary đổi 011 → 100 (cả 3 bit), Gray code đổi 010 → 110 (chỉ
bit cao nhất). Vì bộ đồng bộ 2-FF lấy mẫu độc lập từng bit, việc chỉ 1 bit đổi đảm bảo
kết quả lấy mẫu chỉ có 2 khả năng: giá trị cũ hoặc giá trị mới — không bao giờ ra một giá trị "lai" không
tồn tại. Đổi lại, mọi phép so sánh wptr/rptr (để tính full/empty) phải làm việc
trực tiếp trên mã Gray, phức tạp hơn phép trừ số học thông thường của FIFO đồng bộ — đánh đổi độ an toàn
lấy độ phức tạp thiết kế.
Tóm lược
Bài này khép lại chủ đề bộ nhớ và ranh giới đồng hồ trong thiết kế số:
-
✅ RAM/ROM inference: khai báo mảng
logic+ khuôn mẫu đọc/ghi trongalways_ff, không cần cú pháp đặc biệt. - ✅ Register file 2R/1W: ghi đồng bộ 1 cổng, đọc tổ hợp 2 cổng cùng lúc — nền tảng datapath CPU.
- ✅ FIFO đồng bộ: bộ đếm mức đầy tránh cạm bẫy nhập nhằng "đầy hay rỗng" của phép so sánh con trỏ trần.
- ✅ CDC: bộ đồng bộ 2-FF cho tín hiệu đơn bit, đổi lấy 1 chu kỳ trễ để có MTBF an toàn.
- ✅ FIFO bất đồng bộ: Gray code đảm bảo con trỏ đa-bit chỉ đổi 1 bit mỗi bước, an toàn khi đồng bộ hoá qua ranh giới clock.
Trắc nghiệm ôn tập
Câu 1
Vì sao dout trong module ram_infer được gán bên trong
always_ff thay vì dùng assign?
Câu 2
Cạm bẫy "đầy hay rỗng" của FIFO xảy ra khi nào?
Câu 3
Bộ đồng bộ 2-FF an toàn hơn 1-FF vì lý do gì?
Câu 4
Vì sao FIFO bất đồng bộ dùng Gray code cho con trỏ thay vì nhị phân thường?
Tải file code thực hành minh họa bài học
File SystemVerilog gồm FIFO controller, bộ đồng bộ 1-FF/2-FF, và ví dụ RAM/register file (ngoài subset VeriLite nhưng chạy thật trên Verilator/EDA Playground), kèm testbench kiểm tra full/empty và so sánh độ trễ 2 bộ đồng bộ:
Tải về fifo_and_cdc.sv
Bình luận