📝 摘要
-
• 🎯 主题:协程在 RTL 仿真器中的应用,把 Verilog 里的 initial、always @(posedge clk)、#delay、wait(cond)、fork...join映射成协程。 -
• 🧠 核心观点:RTL 仿真器本质上就是一个协程调度器,它维护时间轮、边沿敏感表、电平敏感表和就绪队列。 -
• 🛠️ 产出:一份几乎逐行对应的 Verilog / 协程代码,加上一个极简调度器,能真正跑起来。 -
• 🔍 重点:理解 @(posedge clk)和wait(ready)的区别:一个是边沿触发,一个是电平触发。 -
• 🌍 语言无关:Python、C++20、Rust、JS、Lua 都能表达同样的协程模型,本文用 Python 只是因为它最适合教学演示。 -
• ⚠️ 小提醒:无限时钟会让调度器不退出,后文给出小补丁。
🗂️ 目录
-
1. 为什么协程适合用于 RTL 仿真器 -
2. 仿真器内核的总体架构 -
3. Verilog 侧:一个覆盖多种时序控制的 demo -
4. 协程侧:三种 await 原语 -
5. 极简调度器:时间、边沿、电平、就绪队列 -
6. 进程逐块对应:从 initial 到 always -
7. 逐条对照表 -
8. 运行时间线与两个小坑 -
9. 关键点总结
1. 为什么协程适合用于 RTL 仿真器
Verilog 里的并发进程,比如:
-
• initial begin ... end -
• always @(posedge clk) ... -
• fork ... join
它们看起来像“并行线程”,但又不是操作系统线程。每个进程有自己的程序计数器 PC,能在某个时间点挂起,等条件满足后再从挂起处继续。
这正好就是协程的语义:
-
• 🧩 协程可以顺序执行; -
• ⏸️ 可以在 await处挂起; -
• ▶️ 被调度器唤醒后,从上次挂起的位置继续; -
• 🧵 多个协程可以并发推进,但底层通常还是单线程事件循环。
所以,用协程模拟 Verilog 时序控制,非常自然——Python、Lua、Rust、C++20、JS 都能做,语言不是重点,模型同构才是重点。
🔍 图说明
-
• 🟦 蓝色框:Verilog 进程。 -
• 🟪 紫色框:协程原语。 -
• 🟨 黄色核心:两者共享的“可挂起/可恢复”模型。 -
• 🟩 绿色框:仿真器内核。 -
• 粗黄线表示 Verilog 与协程在模型层面对齐;粗绿线表示它们都落到同一个调度器上。
2. 仿真器内核的总体架构
先看整个系统长什么样。理解这张图,后面代码就是它的填色版。
🔍 图说明
-
• 🟦 蓝色:用户进程层,等价于 Verilog 里的 initial/always/fork。 -
• 🟧 橙色:就绪队列 _ready,本 delta 可运行的协程。 -
• 🟪 紫色虚线:时间堆 _timers,对应#N延时。 -
• 🩷 粉色:边沿敏感表 _edge,对应@(posedge sig)/@(negedge sig)。 -
• 🟩 青绿:电平敏感表 _level,对应wait(cond)。 -
• 🟢 浅绿:信号值表 _val,所有信号当前值。 -
• ➡️ 实线粗箭头:await 原语向上进入内核。 -
• ⬅️ 虚线箭头:内核唤醒协程,放回就绪队列。 -
• 🔔 绿色双线:信号 write 触发边沿/电平检查。
🧩 协程的五个状态
一个协程从生到死,会在这几个状态之间来回切换:
🔍 图说明
-
• 🟦 起点: spawn()把协程塞进_ready。 -
• 🟧 Ready:等待被调度器挑选。 -
• 🟩 Running: _step()里执行到下一个await。 -
• 🟪 三种等待状态互相平行,各自挂到不同的数据结构。 -
• ⬅️ 三条回到 Ready 的边,就是三种唤醒路径:定时器到期 / 边沿跳变 / 谓词变真。 -
• 🩶 灰色终点: StopIteration表示协程自然结束。
🆚 边沿 vs 电平:两种等待的根本区别
这是初学者最容易混的地方,用一张并排图钉死。
🔍 图说明
-
• 📈 左边(粉色 _edge):只关心跳变的那一刻,0→1 或 1→0 才算命中。 -
• 🟢 右边(青绿 _level):关心当前是否为真,只要谓词为真就放行。 -
• 🔑 一句话记忆:边沿是“瞬间事件”,电平是“持续状态”。 -
• 🧠 这就是为什么调度器里 _edge和_level必须分开维护。
3. Verilog 侧:一个覆盖多种时序控制的 demo
// demo.v —— 只用于仿真,目的是覆盖各种时序控制结构
module demo #(parameter int N = 3);
logic clk = 0;
logic rst_n = 0;
logic go = 0;
logic ready = 0;
logic req = 0;
logic done = 0;
logic [3:0] ticks = 0;
logic selftest = 0;
// ---------- 进程 0:时钟 ----------
initial forever #1 clk = ~clk;
// ---------- 进程 1:initial + #delay ----------
initial begin
selftest = 0;
#10; // 等 10 个时间单位
selftest = 1;
#5;
selftest = 0;
end
// ---------- 进程 2:always @(posedge clk) ----------
always @(posedge clk) begin
if (!rst_n) begin
req <= 0;
done <= 0;
ticks <= 0;
end else begin
done <= 0;
if (go && !req) begin
req <= 1;
wait (ready); // 电平敏感:等到 ready==1
repeat (N) @(posedge clk); // 等 N 个上升沿
req <= 0;
done <= 1;
ticks <= ticks + 1;
end
end
end
// ---------- 进程 3:fork ... join ----------
initial begin
fork
begin : BR1
#3;
$display("[%0t] BR1 done", $time);
end
begin : BR2
#7;
$display("[%0t] BR2 done", $time);
end
join
$display("[%0t] both branches joined", $time);
end
// ---------- 进程 4:激励 ----------
initial begin
#2; rst_n = 1;
#4; go = 1;
#2; go = 0;
#5; ready = 1;
#20; $finish;
end
endmodule
🔍 代码说明
-
• 🕒 进程 0: initial forever #1 clk = ~clk;是一个永远运行的时钟发生器。 -
• ⏳ 进程 1:纯 initial顺序块,用#10、#5控制selftest的翻转。 -
• 🔁 进程 2: always @(posedge clk)是典型同步逻辑,混合了三种时序控制: -
• wait(ready):电平敏感等待; -
• repeat (N) @(posedge clk):等 N 个时钟上升沿; -
• 非阻塞赋值 <=,真实 Verilog 中有 NBA 语义。 -
• 🍴 进程 3: fork ... join启动两个并行分支,等两个都结束后再打印 joined。 -
• 🎛️ 进程 4:激励进程,依次释放复位、拉高 go、拉高ready,最后$finish。
🗺️ Verilog 进程的职责拆解
🔍 图说明
-
• 🧠 这是一张思维导图,把每个 Verilog 进程的职责可视化。 -
• 📌 每个叶子节点,后面都对应一个协程或一段协程逻辑。 -
• 🎯 注意: fork分支的#3、#7是并发等待,不是顺序的。
4. 协程侧:三种 await 原语
"""
verilog_coroutine.py —— Verilog 时序控制 ↔ 协程 一一对应
"""
import heapq
from collections import defaultdict
# ============================================================
# 1) 三个原语:协程里 await 它们,对应 Verilog 的三种时序控制
# ============================================================
class _Delay: # 对应 #N
def __init__(self, sched, dt):
self.sched, self.dt = sched, dt
def __await__(self):
yield self
return None
class _Edge: # 对应 @(posedge sig) / @(negedge sig)
def __init__(self, sched, sig, posedge):
self.sched, self.sig, self.posedge = sched, sig, posedge
def __await__(self):
yield self
return None
class _Level: # 对应 wait(cond)
def __init__(self, sched, pred):
self.sched, self.pred = sched, pred
def __await__(self):
yield self
return None
🔍 代码说明
-
• ⏱️ _Delay对应#N:await s.delay(10)后,协程被放进时间堆。 -
• 📈 _Edge对应@(posedge sig)或@(negedge sig):协程挂到某个信号的边沿等待表。 -
• 🟢 _Level对应wait(cond):协程挂到电平敏感表,谓词为真就放行。 -
• 🧪 三个类都实现 __await__,通过yield self把请求交给调度器。 -
• 🧠 这是关键: await不是阻塞线程,而是把控制权交回调度器。 -
• 🎯 这套原语与语言无关,C++20 的 co_await、JS 的yield*、Rust 的Future都能表达同样的语义。
5. 极简调度器:时间、边沿、电平、就绪队列
# ============================================================
# 2) 调度器:等价于 Verilator 的 VlTriggerScheduler
# ============================================================
class Scheduler:
def __init__(self):
self.now = 0
self._seq = 0
self._ready = [] # 本 delta 可运行
self._timers = [] # (when, seq, coro)
self._edge = defaultdict(list) # sig -> [(coro, posedge)]
self._level = [] # [(coro, pred)]
self._val = {} # sig -> 值
# ---- 协程里可以 await 的原语 ----
def delay(self, dt): return _Delay(self, dt)
def wait_edge(self, sig, posedge=True): return _Edge(self, sig, posedge)
def wait_until(self, pred): return _Level(self, pred)
# ---- 信号读写;写会触发边沿/电平检查 ----
def read(self, name, default=0):
return self._val.get(name, default)
def write(self, name, v):
old = self._val.get(name, 0)
self._val[name] = v
if old != v:
self._on_change(name, old, v)
# ---- 启动协程 / 主循环 ----
def spawn(self, coro):
self._ready.append(coro)
def run(self):
while self._ready or self._timers:
while self._ready:
self._step(self._ready.pop(0))
if self._timers:
t, _, coro = heapq.heappop(self._timers)
self.now = t
self._ready.append(coro)
# ---- 内部 ----
def _step(self, coro):
try:
req = coro.send(None)
except StopIteration:
self._recheck_levels() # 协程结束也可能让谓词变真
return
if isinstance(req, _Delay):
self._seq += 1
heapq.heappush(self._timers, (self.now + req.dt, self._seq, coro))
elif isinstance(req, _Edge):
self._edge[req.sig].append((coro, req.posedge))
elif isinstance(req, _Level):
self._level.append((coro, req.pred))
self._recheck_levels()
def _recheck_levels(self):
still = []
for coro, pred in self._level:
if pred():
self._ready.append(coro)
else:
still.append((coro, pred))
self._level = still
def _on_change(self, name, old, new):
for coro, posedge in list(self._edge.get(name, [])):
hit = (old == 0 and new == 1) if posedge else (old == 1 and new == 0)
if hit:
self._edge[name].remove((coro, posedge))
self._ready.append(coro)
self._recheck_levels()
🔍 代码说明
-
• 📦 _ready:当前 delta 周期内可运行的协程队列。 -
• ⏰ _timers:最小堆,保存(唤醒时间, 序号, 协程),对应#delay。 -
• 📌 _edge:边沿等待表,sig -> [(coro, posedge)],对应@(posedge/negedge sig)。 -
• 🟢 _level:电平等待表,保存(coro, pred),对应wait(cond)。 -
• 🗂️ _val:信号当前值表,read/write模拟信号读写。 -
• 🚀 run():主循环,先清空_ready,再从_timers取出最早到期的协程,推进now。 -
• 🔔 _on_change():信号变化时检查边沿等待者,同时重新检查电平等待者。 -
• 🧠 这就是简化版 RTL 仿真器内核:时间轮 + 敏感表 + 就绪队列。
🔄 调度器主循环的流程
🔍 图说明
-
• 🟢 绿色起点:进入主循环。 -
• 🟨 黄色菱形:所有判断节点。 -
• 🟩 绿色步骤:执行协程 _step()。 -
• 🟪 紫色虚线:入时间堆。 -
• 🩷 粉色:边沿表。🟩 青绿:电平表。 -
• 🩶 灰色:协程结束。 -
• 🟧 橙色:从时间堆取出下一个协程,放回就绪队列。 -
• 🎯 整个循环就是一个事件驱动引擎:清就绪队列 → 取最早定时器 → 回到就绪队列。
6. 进程逐块对应:从 initial 到 always
6.1 时钟、initial、always
# ============================================================
# 3) 进程:与上面的 Verilog 逐块对应
# ============================================================
# ---- 进程 0:initial forever #1 clk = ~clk; ----
async def clock_gen(s):
while True:
s.write('clk', 1)
await s.delay(1)
s.write('clk', 0)
await s.delay(1)
# ---- 进程 1:initial + #delay ----
async def proc_initial(s):
s.write('selftest', 0)
await s.delay(10) # #10
s.write('selftest', 1)
await s.delay(5) # #5
s.write('selftest', 0)
# ---- 进程 2:always @(posedge clk) ----
async def proc_fsm(s):
s.write('req', 0)
s.write('done', 0)
s.write('ticks', 0)
while True:
await s.wait_edge('clk', posedge=True) # @(posedge clk)
if not s.read('rst_n'):
s.write('req', 0)
s.write('done', 0)
s.write('ticks', 0)
continue
s.write('done', 0)
if s.read('go') and not s.read('req'):
s.write('req', 1)
await s.wait_until(lambda: s.read('ready')) # wait (ready)
for _ in range(3): # repeat (N=3)
await s.wait_edge('clk', posedge=True)
s.write('req', 0)
s.write('done', 1)
s.write('ticks', s.read('ticks') + 1)
🔍 代码说明
-
• 🕒 clock_gen对应initial forever #1 clk = ~clk;,是一个无限循环协程。 -
• ⏳ proc_initial对应纯initial块,顺序执行,遇到await s.delay(...)就挂起。 -
• 🔁 proc_fsm对应always @(posedge clk): -
• 外层 while True表示永远循环; -
• await s.wait_edge('clk', posedge=True)表示等上升沿; -
• wait_until(lambda: s.read('ready'))对应wait(ready); -
• for _ in range(3)对应repeat (N) @(posedge clk)。 -
• 🧠 注意:这里 write是立即更新,简化了 Verilog 的非阻塞赋值 NBA 和 delta 周期。 -
• ⚠️ 原版 clock_gen在 t=0 就写了clk=1,和 Verilog 初始clk=0、第一个上升沿在 t=1 的相位不完全一致。严格对齐可改成先await delay(1)再翻转。
6.2 fork...join 与激励
# ---- 进程 3:fork ... join ----
async def fork_join(s, *branches):
"""fork ... join:所有分支结束后才返回。"""
state = {'remaining': len(branches)}
async def run_one(p):
await p
state['remaining'] -= 1
for p in branches:
s.spawn(run_one(p)) # fork
await s.wait_until(lambda: state['remaining'] == 0) # join
async def branch1(s):
await s.delay(3) # #3
print(f"[{s.now}] BR1 done")
async def branch2(s):
await s.delay(7) # #7
print(f"[{s.now}] BR2 done")
async def proc_fork(s):
await fork_join(s, branch1(s), branch2(s))
print(f"[{s.now}] both branches joined")
# ---- 进程 4:激励 ----
async def stimulus(s):
await s.delay(2); s.write('rst_n', 1)
await s.delay(4); s.write('go', 1)
await s.delay(2); s.write('go', 0)
await s.delay(5); s.write('ready', 1)
await s.delay(20)
print(f"[{s.now}] $finish")
🔍 代码说明
-
• 🍴 fork_join对应fork ... join:每个分支s.spawn(run_one(p))启动成独立协程,父协程用wait_until等计数器归零。 -
• 🧵 branch1和branch2分别对应#3和#7。 -
• 🎛️ stimulus对应激励进程,按时间依次设置rst_n、go、ready。 -
• 🛑 原版 $finish只是打印,没有真正停止调度器;因为clock_gen是无限循环,s.run()不会自动退出。
🍴 fork...join 的结构
🔍 图说明
-
• 🟧 橙色:父协程 proc_fork。 -
• 🟦 蓝色:两个 fork 出来的子协程。 -
• 🟩 绿色:分支完成,计数器递减。 -
• 🟨 黄色菱形:join 的判定条件。 -
• ⏸️ 灰色虚线:谓词为假时父协程挂在 _level。 -
• 🎯 结论: fork...join= 多个子协程 + 一个计数器谓词,完全不需要线程。
6.3 顶层启动
# ============================================================
# 4) 顶层
# ============================================================
def main():
s = Scheduler()
for name in ('clk', 'rst_n', 'go', 'ready',
'req', 'done', 'ticks', 'selftest'):
s._val[name] = 0
s.spawn(clock_gen(s)) # initial forever
s.spawn(proc_initial(s)) # initial begin ... end
s.spawn(proc_fsm(s)) # always @(posedge clk)
s.spawn(proc_fork(s)) # initial fork ... join
s.spawn(stimulus(s)) # 激励
s.run()
if __name__ == '__main__':
main()
🔍 代码说明
-
• 🧱 初始化所有信号为 0,对应 Verilog 中声明时的初值。 -
• 🚀 用 s.spawn(...)启动五个协程,对应五个 Verilog 进程。 -
• ▶️ s.run()进入调度循环,直到_ready和_timers都为空。 -
• ⚠️ 因为时钟协程永不结束,实际会一直有定时器,所以需要手动加停止机制才能真正退出。
7. 逐条对照表
🔍 表格说明
-
• 📌 这张表是整篇博客的“速查卡”。 -
• 🧠 最关键的是: @(posedge)是边沿敏感,wait(cond)是电平敏感,它们在调度器里必须分开维护。 -
• 🍴 fork...join不是魔法,就是“启动多个协程 + 等计数器归零”。
8. 运行时间线与两个小坑
跑一遍,fork...join 部分会看到:
[3] BR1 done
[7] BR2 done
[7] both branches joined
🔍 说明
-
• 🕒 BR1在 t=3 完成。 -
• 🕖 BR2在 t=7 完成。 -
• 🤝 join必须等两个分支都完成,所以在 t=7 才打印both branches joined。
⏱️ 完整时间线
🔍 图说明
-
• 🟥 crit红色:时钟和$finish是全局性事件。 -
• 🟩 done绿色:分支等待区间,milestone是完成时刻。 -
• 🟦 active蓝色:激励信号依次设置。 -
• 📌 从图上可以清楚看到: BR1在 t=3 到达,BR2在 t=7 到达,join 也在 t=7 通过。 -
• 🕒 仿真在 t=33 结束( 2+4+2+5+20)。
⚠️ 两个小坑
-
• ⚠️ 坑 1:无限时钟导致调度器不退出。 -
• clock_gen是while True,会不断产生新的定时器。 -
• s.run()的退出条件是_ready和_timers都为空,因此永远不会自然退出。 -
• 若只想看 demo,可以加一个 stop标志,或抛异常终止。 -
• ⚠️ 坑 2:时钟相位与原 Verilog 不完全一致。 -
• 原版 clock_gen在 t=0 就write('clk', 1),相当于 t=0 产生上升沿。 -
• Verilog 中 clk初始为 0,第一个上升沿在 t=1。 -
• 严格对齐可写成:
async def clock_gen(s):
while True:
await s.delay(1)
s.write('clk', 1 - s.read('clk'))
🔍 小补丁说明
-
• 🛑 如果要真正 $finish,可以自定义StopSim异常,在stimulus最后抛出,并在run()中捕获。 -
• 🕒 修正时钟后, clk的相位会更接近 Verilog 的initial forever #1 clk = ~clk;。 -
• 🧪 这些不影响理解协程调度器的核心结构。
9. 关键点总结
-
• 🧩 initial就是协程:顺序执行、可中断、有独立 PC。 -
• 🔁 always @(posedge clk)也是协程:每次边沿被唤醒,跑完继续等下一次边沿。 -
• ⏳ #N是定时器:调度器用最小堆维护唤醒时间。 -
• 📈 @(posedge sig)是边沿敏感:只有 0→1 跳变那一刻才放行。 -
• 🟢 wait(cond)是电平敏感:谓词为真就放行,每次信号变化或协程推进都要重判。 -
• 🍴 fork...join是 gather 语义:spawn 多个子协程,父协程等计数归零。 -
• 🚀 信号写操作驱动调度器: write会触发_on_change,检查边沿表和电平表。 -
• 🧠 RTL 仿真器内核 ≈ 协程调度器:时间轮、敏感表、就绪队列、信号值表。 -
• 🌍 与语言无关:Python、C++20、Rust、JS、Lua 都能表达同样的模型。 -
• 🛠️ 这个极简实现适合教学,但还没有处理 NBA、delta 周期、多驱动、未知态、 join_any/join_none等真实仿真器细节。 -
• 📚 下一步可以扩展: -
• 支持 join_any/join_none; -
• 支持非阻塞赋值和 delta 周期; -
• 支持 fork...join的异常传播; -
• 支持 $finish/$stop; -
• 支持信号位宽和 x/z状态。
🔍 图说明
-
• 🟩 绿色节点:你现在掌握的内容。 -
• 🟦 蓝色虚线节点:可以继续深入的方向。 -
• 🎯 每条虚线代表一个扩展点,互不依赖,可按需选择。
🎉 一句话总结:Verilog 的时序控制,本质上是在描述“进程何时挂起、何时唤醒”;协程正好提供了同构的挂起/恢复机制,而调度器则负责时间、边沿和电平的敏感判断。理解了这个映射,你就理解了 RTL 仿真器的心脏。

