Mở đầu: Con bug 1 dòng code không ai nhìn ra bằng mắt

Hãy tưởng tượng bạn viết một thanh ghi dịch 2 tầng (2-stage shift register) — mạch cơ bản đến mức sách giáo khoa nào cũng có. Bạn mô phỏng, waveform hiện ra, tín hiệu vẫn "nhấp nháy" đúng nhịp clock, không có báo lỗi đỏ nào. Nhưng khi so kỹ với bảng chân trị kỳ vọng, đầu ra trễ ít hơn 1 chu kỳ so với thiết kế — như thể một trong hai flip-flop đã "biến mất" khỏi mạch. Không có gì sai về mặt cú pháp. Không có cảnh báo tổng hợp. Chỉ có 1 ký tự bạn gõ nhầm: dấu = thay vì <=. Đây là cạm bẫy được xem là "kinh điển" nhất trong toàn bộ HDL — đến mức gần như mọi kỹ sư RTL đều từng dính ít nhất một lần, thường là ở dự án đầu tiên.

Bài này giải quyết đúng cạm bẫy đó, đồng thời xây nền tảng logic tuần tự: always_ff khai báo ý định flip-flop rõ ràng hơn cú pháp cũ, sự khác biệt không thể xem nhẹ giữa gán blocking (=) và non-blocking (<=), và 2 kiểu reset (đồng bộ/bất đồng bộ) với đánh đổi riêng của từng kiểu. Cuối bài, bạn sẽ tự tay dựng một Counter 4-bit và bộ tạo PWM số — bản song song kỹ thuật số của mạch PWM analog dùng IC 555 ở series Điện Tử.


📚 Điều kiện tiên quyết
Bắt buộc: Bài 2 (SystemVerilog tổ hợp) — cần hiểu always_comb/case trước khi học đối chiếu với logic tuần tự.
Nên có: Series 10 Bài 14 (IC 555 & PWM analog) — nền tảng so sánh với PWM số ở demo cuối bài.
Tài nguyên ngoài: HDLBits, ChipVerify.

1. always_ff @(posedge clk) — khai báo rõ ý định flip-flop

Logic tổ hợp (Bài 2) không có "trí nhớ" — đầu ra luôn được tính lại từ đầu dựa trên đầu vào hiện tại. Logic tuần tự thì ngược lại: đầu ra phụ thuộc vào giá trị trước đó, được lưu trong flip-flop, và chỉ cập nhật tại đúng thời điểm cạnh lên của tín hiệu clock (rising edge / posedge). Verilog cũ dùng always @(posedge clk) — cú pháp này chỉ là quy ước, trình biên dịch không kiểm tra gì thêm để đảm bảo đây thực sự là 1 flip-flop hợp lệ.

old_vs_new_ff.sv
// Verilog cũ: chỉ là quy ước, không được kiểm tra
module old_style_ff (
  input wire clk, d,
  output reg q
);
  always @(posedge clk)
    q <= d;
endmodule

// SystemVerilog: always_ff khai báo RÕ RÀNG đây là flip-flop
module new_style_ff (
  input logic clk, d,
  output logic q
);
  always_ff @(posedge clk)
    q <= d;
endmodule

always_ff giúp công cụ tổng hợp xác nhận tĩnh (static check) rằng khối này thực sự mô tả 1 flip-flop hợp lệ — nếu bạn vô tình viết logic không đúng mẫu tuần tự chuẩn (vd trộn lẫn blocking/non-blocking sai cách, xem Mục 2), một số công cụ sẽ cảnh báo ngay tại bước lint, trước cả khi mô phỏng.

Đặc điểm Tổ hợp (always_comb, Bài 2) Tuần tự (always_ff, bài này)
Có "trí nhớ"? Không — luôn tính lại từ đầu vào hiện tại Có — giữ giá trị qua flip-flop
Khi nào cập nhật? Ngay khi bất kỳ input đổi Chỉ tại cạnh lên (posedge) của clock
Tổng hợp ra Cổng logic thuần (AND/OR/MUX...) Flip-flop (D-FF) + logic tổ hợp phía trước

2. Blocking = vs Non-blocking <= — cạm bẫy số 1 của HDL

Đây là điểm khác biệt quan trọng nhất mà bài này muốn bạn nhớ suốt series: bên trong always_ff, luôn dùng non-blocking <=. Lý do nằm ở cách 2 toán tử này định nghĩa thời điểm đọc giá trị vế phải:

  • Blocking =: giống hệt ngôn ngữ lập trình — câu lệnh thực thi xong mới tới câu tiếp theo, và câu sau thấy được giá trị vừa gán của câu trước.
  • Non-blocking <=: mọi vế phải trong cùng 1 khối always_ff đều được đọc từ giá trị TRƯỚC cạnh clock này (như "đóng băng" toàn bộ trạng thái tại đầu chu kỳ), rồi mọi phép gán mới được áp dụng đồng loạt ở cuối. Đây chính xác là cách hàng nghìn flip-flop vật lý hoạt động: tất cả cùng "chốt" giá trị mới tại đúng 1 cạnh clock, không có thứ tự trước sau.
🕳️ Cạm bẫy: Thanh ghi dịch 2 tầng "mất" 1 tầng vì gõ nhầm blocking

❌ Sai — dùng blocking trong always_ff:

always_ff @(posedge clk) begin
  q1 = d;   // Gán ngay, q1 đổi tức thời
  q2 = q1;  // Đọc q1 VỪA gán ở trên (giá trị MỚI = d) — mất 1 tầng trễ!
end

= là blocking, dòng thứ 2 đọc q1 đã bị dòng thứ 1 ghi đè trong CÙNG chu kỳ. Kết quả: q1q2 luôn bằng nhau, đổi cùng lúc — 2 flip-flop vật lý vẫn được tổng hợp ra, nhưng hành vi mô phỏng lại giống như chỉ có 1 tầng trễ, không phải 2. Sai lệch này cực khó phát hiện bằng mắt vì sóng vẫn "nhấp nháy" bình thường.

✅ Đúng — dùng non-blocking:

always_ff @(posedge clk) begin
  q1 <= d;   // Đọc d tại đầu chu kỳ
  q2 <= q1;  // Đọc q1 CŨ (trước cạnh clock này) — đúng 2 tầng trễ
end

Đừng chỉ tin lời giải thích — hãy tự kiểm chứng bằng engine thật bên dưới. Bấm Step Clock nhiều lần với 1 xung d, quan sát waveform: bản đúng cho q2 trễ đúng 2 nhịp so với d; bản sai cho q1q2 luôn thay đổi cùng lúc.

🔬 Thanh ghi dịch 2 tầng: Blocking vs Non-blocking
Đang mô phỏng:
q1=0 q2=0

Bấm d để bật xung đầu vào 1 nhịp, rồi bấm Step Clock nhiều lần và theo dõi waveform. Đổi giữa ❌ Blocking / ✅ Non-blocking và so sánh trực tiếp độ trễ của q2.

shift_bad.sv
module shift_bad (
  input logic clk, d,
  output logic q1, q2
);
  always_ff @(posedge clk) begin
    q1 = d;
    q2 = q1;
  end
endmodule
shift_good.sv
module shift_good (
  input logic clk, d,
  output logic q1, q2
);
  always_ff @(posedge clk) begin
    q1 <= d;
    q2 <= q1;
  end
endmodule
💡 Quy tắc ghi nhớ
Non-blocking <= trong always_ff (tuần tự) — Blocking = trong always_comb (tổ hợp). Trộn lẫn 2 quy tắc này là nguồn cạm bẫy phổ biến thứ nhì sau latch inference (Bài 2).

3. Reset đồng bộ vs bất đồng bộ

Hầu hết mạch tuần tự cần 1 tín hiệu reset để đưa thanh ghi về giá trị khởi tạo biết trước (vd counter về 0). Có 2 cách kích hoạt reset, mỗi cách một đánh đổi:

reset_styles.sv
// Reset ĐỒNG BỘ: chỉ có hiệu lực tại cạnh clock (nằm trong danh sách sensitivity list @())
always_ff @(posedge clk) begin
  if (rst)
    count <= 4'b0000;
  else
    count <= count + 1;
end

// Reset BẤT ĐỒNG BỘ: có hiệu lực NGAY LẬP TỨC, không cần chờ cạnh clock
// (Lưu ý: cú pháp "or posedge rst" ngoài tập con VeriLite của trang này — chỉ minh hoạ)
always_ff @(posedge clk or posedge rst) begin
  if (rst)
    count <= 4'b0000;
  else
    count <= count + 1;
end
Kiểu reset Ưu điểm Nhược điểm
Đồng bộ Không cần thêm phần cứng đặc biệt, không rủi ro metastability Cần clock đang chạy mới reset được — không dùng được nếu clock bị treo
Bất đồng bộ Reset ngay lập tức, hoạt động kể cả khi clock dừng Nếu tín hiệu reset thả xuống (release) quá gần cạnh clock, có thể gây metastability — cần thêm synchronizer (sẽ học ở Bài 7)
📚 Sẽ học sâu ở Bài 7
Metastability và kỹ thuật synchronizer 2-tầng flip-flop để giải quyết nó sẽ được trình bày chi tiết ở Bài 7: Bộ nhớ, FIFO & Clock Domain Crossing. Ở bài này bạn chỉ cần biết đây là lý do phần lớn thiết kế công nghiệp ưu tiên reset đồng bộ khi có thể.

Demo tương tác của bài (Mục 4) và file tải về đều dùng reset đồng bộ — đúng tập con mà VeriLite (engine của trang này) hỗ trợ mô phỏng chính xác.

4. Demo tương tác: Counter 4-bit + Bộ tạo PWM số

Counter là mạch tuần tự cơ bản nhất: mỗi cạnh clock, giá trị tăng thêm 1, tự "cuộn vòng" về 0 khi tràn số (4-bit: 15 → 0). Ghép thêm 1 bộ so sánh tổ hợp (count < duty), bạn có ngay 1 bộ tạo PWM số — thay vì dùng tụ điện nạp/xả như IC 555 (mạch PWM analog đã học ở series Điện Tử), độ rộng xung được điều khiển hoàn toàn bằng số đếm nhị phân.

🎛️ Counter 4-bit + PWM số
Duty threshold:
count=0 pwm=0

Bấm Step Clock để đi từng nhịp, hoặc Run 16 nhịp để xem trọn 1 chu kỳ counter. Đổi duty threshold rồi Run lại — tỉ lệ thời gian pwm=1 trên waveform thay đổi đúng theo threshold/16.

pwm_digital.sv
module pwm_digital (
  input  logic clk, rst,
  input  logic [3:0] duty,
  output logic pwm_out
);
  logic [3:0] count;

  always_ff @(posedge clk) begin
    if (rst)
      count <= 4'b0000;
    else
      count <= count + 1;
  end

  assign pwm_out = (count < duty) ? 1'b1 : 1'b0;
endmodule
🔬 Đào sâu: PWM số vs PWM analog (555)
IC 555 tạo PWM bằng cách nạp/xả tụ điện qua điện trở — độ rộng xung phụ thuộc giá trị linh kiện analog (R, C), dễ trôi theo nhiệt độ và sai số dung sai linh kiện. PWM số ở đây hoàn toàn xác định bởi 2 con số nhị phân (count, duty) — cùng 1 thiết kế chạy trên FPGA/ASIC bất kỳ đều cho chính xác cùng 1 kết quả, không lệch dù chỉ 1 phần nghìn giây, đổi lại cần 1 nguồn clock ổn định làm "nhịp đếm".

Tóm lược

Bài này chuyển từ logic tổ hợp (không nhớ) sang logic tuần tự (có nhớ qua clock):

  • always_ff @(posedge clk) khai báo rõ ý định flip-flop, được kiểm tra tĩnh — an toàn hơn always @(posedge clk) cũ.
  • Luôn dùng non-blocking <= trong always_ff — blocking = có thể làm "mất" cả 1 tầng flip-flop trong thanh ghi dịch nhiều tầng.
  • ✅ Reset đồng bộ an toàn hơn (không metastability) nhưng cần clock chạy; bất đồng bộ phản hồi ngay nhưng cần synchronizer (Bài 7).
  • ✅ Counter + bộ so sánh tổ hợp = PWM số, xác định 100%, khác PWM analog dùng linh kiện R/C.

Bài tiếp theo chuyển sang máy trạng thái hữu hạn (FSM) — cách mô hình hoá hành vi có nhiều "chế độ" khác nhau, xây trên chính nền tảng always_ff vừa học.

Trắc nghiệm ôn tập

Câu 1

Trong 1 khối always_ff, phép gán non-blocking (<=) đọc giá trị vế phải từ đâu?

Câu 2

Thanh ghi dịch 2 tầng viết bằng blocking (q1 = d; q2 = q1;) trong always_ff sẽ có hành vi gì so với thiết kế đúng (non-blocking)?

Câu 3

Reset bất đồng bộ (asynchronous reset) có rủi ro chính nào?

Câu 4

PWM số (digital PWM) khác PWM analog (IC 555) ở điểm cốt lõi nào?

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

File SystemVerilog gồm counter 4-bit, PWM số, và cả 2 phiên bản thanh ghi dịch (đúng/sai) kèm testbench so sánh trực tiếp. Chạy được trên Verilator hoặc dán vào EDA Playground:

Tải về sequential_flops.sv

📖 Tài liệu tham khảo

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

Bài 2: SystemVerilog tổ hợp (Combinational Logic) Bài 4: Máy trạng thái hữu hạn (FSM) Quay lại Lộ trình Series Thiết Kế Vi Mạch Số & FPGA

Bình luận