Mở đầu: "Nhìn waveform thấy đẹp" không phải là bằng chứng mạch đúng

Bạn vừa viết xong bộ cộng 4-bit, mô phỏng với vài giá trị a=0, b=5 — sóng ra 5, đúng. Thử thêm a=1, b=0 — ra 1, vẫn đúng. Bạn tự tin gõ "done", commit, chuyển sang việc tiếp theo. Ba tuần sau, mạch chạy trên board thật cho kết quả sai lệch ngẫu nhiên. Hoá ra bộ cộng của bạn có lỗi chỉ lộ ra khi 2 bit cùng vị trí của a và b đều bằng 1 (cần cộng có nhớ/carry) — mọi test case bạn thử đều tình cờ né đúng trường hợp đó. Đây không phải chuyện hiếm: phần lớn bug HDL nghiêm trọng nhất đều "trốn" trong đúng những case bạn không nghĩ tới thử.

Bài này chuyển trọng tâm từ "viết mạch" sang "chứng minh mạch đúng" — kỹ năng chiếm đến ~70% công sức của một dự án chip thực tế. Bạn sẽ học cấu trúc testbench cơ bản, vì sao self-checking testbench (máy tự chấm) thay thế được thói quen "nhìn waveform bằng mắt", khái niệm SVA (SystemVerilog Assertions) để viết "hợp đồng hành vi", và coverage — thước đo trả lời câu hỏi "test bao nhiêu là đủ". Cuối bài, bạn tự tay săn 2 con bug được cài sẵn trong 1 bộ cộng bằng chính testbench tự động, y hệt tình huống mở đầu.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 2 (SystemVerilog tổ hợp), Bài 3 (tuần tự), Bài 4 (FSM) — testbench cần biết viết code RTL trước để có gì mà kiểm chứng.
Tài nguyên ngoài: HDLBits, ChipVerify.

1. Vì sao verification chiếm 70% công sức dự án chip?

Chi phí sửa 1 bug tăng theo cấp số nhân tuỳ giai đoạn phát hiện ra nó:

Giai đoạn phát hiện bug Chi phí sửa
Khi viết RTL (mô phỏng nội bộ) Gần như miễn phí — sửa 1 dòng code, mô phỏng lại
Sau synthesis/P&R (đã layout) Tốn thời gian — phải chạy lại toàn bộ flow vật lý
Sau tape-out (đã gửi nhà máy in chip) Respin — hàng triệu đô la, mất 2-3 tháng chờ lô chip mới

Khác với phần mềm (có thể vá lỗi bằng bản cập nhật), 1 con chip đã in ra silicon không sửa được nữa. Đây là lý do các công ty thiết kế chip dành đội ngũ verification đông đảo, đôi khi đông hơn cả đội thiết kế RTL — phát hiện bug càng sớm, chi phí càng thấp.

2. Cấu trúc testbench cơ bản

Testbench là 1 module SystemVerilog không tổng hợp thành phần cứng — nó chỉ tồn tại trong mô phỏng, với vai trò: khởi tạo DUT (Design Under Test), sinh tín hiệu clock/reset, đưa vào các giá trị đầu vào (stimulus), và quan sát đầu ra.

testbench_structure.sv
module adder_tb;
  reg [3:0] a, b;
  wire [3:0] sum;
  wire cout;

  // 1. Instance DUT (Design Under Test)
  adder4 dut (.a(a), .b(b), .sum(sum), .cout(cout));

  // 2. Stimulus: đưa giá trị vào, quan sát bằng $display
  initial begin
    a = 4'd3; b = 4'd1;
    #10; // chờ 10 đơn vị thời gian để tín hiệu ổn định
    $display("a=%d b=%d sum=%d cout=%b", a, b, sum, cout);

    a = 4'd7; b = 4'd9;
    #10;
    $display("a=%d b=%d sum=%d cout=%b", a, b, sum, cout);

    $finish; // kết thúc mô phỏng
  end
endmodule

$display in ra giá trị tại 1 thời điểm; $monitor (biến thể liên quan) tự động in lại mỗi khi bất kỳ tín hiệu trong danh sách theo dõi thay đổi — hữu ích để quan sát liên tục mà không cần gọi lại nhiều lần.

3. Self-checking testbench & Golden Model

Testbench ở Mục 2 chỉ in ra giá trị — bạn vẫn phải tự đọc và so sánh bằng mắt. Đây chính là thói quen nguy hiểm ở phần mở đầu bài. Giải pháp: golden model — 1 đoạn code tính kết quả "đúng tuyệt đối" bằng công thức độc lập (thường đơn giản hơn nhiều so với RTL), rồi để testbench tự so sánh và báo PASS/FAIL, không cần con người nhìn từng dòng số.

self_checking_tb.sv
module adder_tb_self_checking;
  reg [3:0] a, b;
  wire [3:0] sum;
  wire cout;
  integer errors = 0;

  adder4 dut (.a(a), .b(b), .sum(sum), .cout(cout));

  initial begin
    for (integer i = 0; i < 16; i = i + 1) begin
      for (integer j = 0; j < 16; j = j + 1) begin
        a = i; b = j;
        #10;
        // Golden model: công thức cộng số học thuần, độc lập với RTL
        if (sum !== ((i + j) & 4'hF) || cout !== ((i + j) > 15)) begin
          $display("FAIL: a=%d b=%d sum=%d cout=%b", a, b, sum, cout);
          errors = errors + 1;
        end
      end
    end
    if (errors == 0)
      $display("TAT CA 256 TO HOP DEU DUNG.");
    else
      $display("%0d loi duoc phat hien.", errors);
    $finish;
  end
endmodule
🕳️ Cạm bẫy: "Nhìn waveform thấy đẹp" không phải verification
Đây là pitfall kinh điển nhất của người mới học verification: mở waveform viewer, thấy vài xung "trông hợp lý", kết luận "mạch chạy đúng rồi". Vấn đề: mắt người không thể kiểm tra hết hàng trăm/hàng nghìn tổ hợp đầu vào, và xu hướng tự nhiên là chỉ thử những case "dễ đoán kết quả" (thường lại là case không kích hoạt được bug). Self-checking testbench giải quyết đúng điểm yếu này: máy tính không mệt, không thiên vị, và có thể quét hết mọi tổ hợp trong vài giây.

4. SVA — SystemVerilog Assertions cơ bản

Ngoài testbench kiểm tra đầu ra cuối cùng, SystemVerilog còn cho phép viết trực tiếp "hợp đồng hành vi" (behavioral contract) ngay trong code — những điều kiện phải luôn đúng tại mọi thời điểm mô phỏng, gọi là SVA. Đây là điểm mạnh riêng của SystemVerilog so với Verilog cũ.

sva_basics.sv
// Immediate assertion: kiểm tra ngay tại 1 thời điểm, giống if() báo lỗi
always_comb begin
  assert (sum < 5'd16) else $error("sum tràn quá 4-bit!");
end

// Concurrent assertion: kiểm tra 1 QUY LUẬT theo thời gian, đồng bộ với clock
// "Nếu req lên 1, thì trong vòng tối đa 3 chu kỳ clock, ack phải lên 1"
property req_then_ack;
  @(posedge clk) req |-> ##[1:3] ack;
endproperty
assert property (req_then_ack) else $error("Vi pham hop dong req/ack!");

Lưu ý: cú pháp assert property với toán tử thời gian (##[1:3], |->) nằm ngoài tập con VeriLite mà engine của trang này mô phỏng được — đây là code minh hoạ khái niệm, không chạy được trên demo tương tác. Trong công nghiệp thật, các công cụ như Verilator, VCS, hay Questa hiểu đầy đủ cú pháp này.

5. Coverage — Test bao nhiêu là đủ?

Testbench chạy xong, không báo lỗi nào — nhưng liệu bạn đã test đủ chưa, hay chỉ mới chạm một góc nhỏ của không gian trạng thái? Coverage trả lời câu hỏi này bằng số liệu cụ thể:

Loại coverage Đo cái gì
Code coverage Bao nhiêu % dòng code/nhánh if/case đã được thực thi ít nhất 1 lần
Functional coverage Bao nhiêu % kịch bản có ý nghĩa (theo góc nhìn thiết kế) đã được kiểm tra — vd "đã test trường hợp tràn số chưa?", "đã test cả 2 bit cùng =1 chưa?"

100% code coverage không đảm bảo hết bug — code có thể chạy qua mọi dòng nhưng vẫn sai logic ở 1 tổ hợp cụ thể (chính là tình huống mở đầu bài: dòng assign sum = ... luôn được "chạy qua" trong mọi test case, chỉ có KẾT QUẢ là sai). Functional coverage bổ sung góc nhìn "kịch bản nào quan trọng cần test", không chỉ "dòng nào đã chạy qua".

6. Demo tương tác: Săn bug trong bộ cộng 4-bit

Bộ cộng dưới đây cài sẵn 2 bug khác nhau (chọn qua nút bên dưới). Bạn có thể test thủ công từng tổ hợp a/b, hoặc bấm "Chạy toàn bộ 256 tổ hợp" để xem self-checking testbench tự phát hiện bug — đúng kỹ thuật vừa học ở Mục 3.

🐛 Săn bug: Bộ cộng 4-bit
Phiên bản:
a=3 b=1 → sum=?, cout=?

Bấm bit a/b để thử 1 tổ hợp — chú ý vài tổ hợp "PASS trùng hợp" dù bug vẫn còn đó. Bấm "Chạy toàn bộ 256 tổ hợp" để self-checking testbench quét hết và báo cáo chính xác số lỗi.

adder4_variants.sv
// ✅ Đúng
module adder4_correct (
  input logic [3:0] a, b,
  output logic [3:0] sum,
  output logic cout
);
  assign sum = a + b;
  assign cout = (a + b) > 15;
endmodule

// 🐛 Bug 1: quên cộng có nhớ giữa các bit — chỉ XOR đơn thuần
module adder4_bug1 (
  input logic [3:0] a, b,
  output logic [3:0] sum,
  output logic cout
);
  assign sum = a ^ b;             // SAI: thiếu lan truyền carry giữa các bit
  assign cout = (a + b) > 15;
endmodule

// 🐛 Bug 2: logic tính cờ nhớ (carry-out) sai hoàn toàn
module adder4_bug2 (
  input logic [3:0] a, b,
  output logic [3:0] sum,
  output logic cout
);
  assign sum = a + b;
  assign cout = a > b;            // SAI: không liên quan gì tới tràn số
endmodule
🔬 Đào sâu: Vì sao Bug 1 "PASS trùng hợp" với vài tổ hợp?
a ^ b (XOR) và phép cộng thật a + b chỉ khác nhau khi có ít nhất 1 vị trí bit mà cả a và b đều bằng 1 (cần lan truyền carry sang bit kế tiếp). Nếu a=0, hoặc b=0, hoặc a/b không "chồng" bit nào (vd a=0100, b=0001), thì XOR và cộng cho ra cùng kết quả — bug ẩn mình hoàn hảo. Đây chính xác là lý do testbench chỉ thử vài case "dễ đoán" (thường vô tình chọn toàn case không chồng bit) sẽ không bao giờ bắt được bug này.

Tóm lược

Bài này chuyển trọng tâm từ "viết mạch" sang "chứng minh mạch đúng":

  • ✅ Verification chiếm ~70% công sức dự án — bug phát hiện càng muộn, chi phí sửa càng cao theo cấp số nhân.
  • ✅ Testbench: instance DUT, sinh stimulus, quan sát bằng $display/$monitor.
  • Self-checking testbench + golden model thay thế "nhìn waveform bằng mắt" — máy tự so sánh, quét hết mọi tổ hợp không mệt mỏi.
  • ✅ SVA viết "hợp đồng hành vi" trực tiếp trong code — immediate và concurrent assertion.
  • ✅ Coverage (code + functional) trả lời "test bao nhiêu là đủ", không chỉ "có lỗi hay không".

Bài tiếp theo đi sâu vào số học phần cứng — cách các bộ cộng thật (ripple-carry, carry-lookahead) được xây dựng, và đánh đổi tốc độ/diện tích giữa chúng.

Trắc nghiệm ôn tập

Câu 1

Vì sao chi phí sửa bug sau tape-out (respin) lại đắt hơn hẳn sửa lúc viết RTL?

Câu 2

Self-checking testbench khác gì so với testbench chỉ dùng $display?

Câu 3

100% code coverage có đảm bảo mạch không còn bug không?

Câu 4

Trong demo Bug 1 (sum = a ^ b), vì sao 1 số tổ hợp a/b vẫn cho kết quả đúng?

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

File SystemVerilog gồm bộ cộng đúng + 2 phiên bản cài bug, kèm self-checking testbench quét toàn bộ 256 tổ hợp và báo cáo chi tiết từng lỗi. Chạy được trên Verilator hoặc dán vào EDA Playground:

Tải về adder_testbench.sv

📖 Tài liệu tham khảo

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

Bài 4: Máy trạng thái hữu hạn (FSM) Bài 6: Số học phần cứng (Hardware Arithmetic) Quay lại Lộ trình Series Thiết Kế Vi Mạch Số & FPGA

Bình luận