SystemVerilog并发与线程同步:fork-join、semaphore与mailbox实战解析

发布时间:2026/9/6 2:42:01
SystemVerilog并发与线程同步:fork-join、semaphore与mailbox实战解析 1. 从“并发”说起为什么SV验证绕不开这些基础做验证做了这么多年我一直有个感觉System Verilog入门阶段最容易被忽视、但后来越来越觉得重要的就是并发与线程同步。前面笔记里我们聊过数据类型、接口、类、随机化、覆盖率这些基本上都是在搭建“单兵作战”的能力——一个sequence怎么组织、一个driver怎么驱动、一个monitor怎么采样。但到了真实项目里验证环境从来不是单线程跑的激励要一直发监测要一直开参考模型要一直算计分板要一直比。这些任务如果不并发执行仿真效率会低到让人怀疑人生。所以这篇笔记想跟你聊聊我眼中SV里最核心、也最容易被新手搞混的并发基础线程的创建与调度、fork-join家族的三种模式、以及事件、信箱、信号量这“三件套”。它们解决的问题很实际——怎么让多个任务同时跑起来怎么安全地交换数据怎么协调上下游的节奏。标题虽然挂着“学习笔记9”的编号但我写的时候尽量按“能直接抄进环境里用”的标准来代码片段都是我在实际环境里跑过或者踩过坑的不是那种只能看书不能落地的例子。适合读这篇的人我觉得有两类一类是刚把SV语法过完一遍准备写真正的验证组件但看到fork和join就发怵的入门者另一类是已经写了些环境但偶尔会出现“数据比来比去对不上查了半天原来是同步姿态出了问题”的同行。前者可以从头读完后者可以直接跳到问题排查那一节对照看看。至于纯做数字前端设计、不怎么接触验证的同学这篇对你可能帮助有限但如果你要跟验证工程师联调了解这些基础也能让你在debug时更清楚对方在忙什么。2. 线程建模的底层逻辑2.1 从程序块到动态线程System Verilog里的“线程”简单说就是一段可以独立调度执行的代码流。它不是操作系统里的那种重量级线程而是仿真器调度器管理的一个执行单元——每一次initial块、always块、fork...join块、fork...join_none块、fork...join_any块的执行体都会在仿真时间轴上产生一个或多个线程。理解这一点很关键因为仿真器本质上是一个离散事件驱动的调度器它把事件分成active、inactive、NBA、observe、reactive等不同region在每个时间片按优先级逐层处理。线程不是“同时跑”而是“轮着跑”只不过切换速度快到你感觉不到。这个特性和软件线程有相似之处但调度模型完全不同不能用写Linux多线程的思路硬套。模块里的initial和always是最早接触的线程形式。initial块在0时刻开始执行执行完就结束always块则是死循环靠事件触发生效。到了class环境里我们很少直接在模块里写一堆always更多是用fork...join块在task里开并发线程。比如我常写的driver run_phase里通常会fork出两个线程一个处理reset一个处理正常激励这样复位和业务并行互不干扰。需要注意的点是SV的线程都是“软”的它受仿真时间驱动也受事件调度顺序影响。如果你在同一个时间槽里fork了多个线程它们的执行顺序并不是“写在前面的先跑”而是取决于你触发的region和优先级。这也是为什么很多人第一次看到$display乱序会一脸懵——不是写错了是调度顺序和直觉不一样。想控制顺序就得靠后面会聊到的(event)、semaphore、mailbox来人为加同步。2.2 线程的生命周期与会话控制线程从被创建那一刻起就进入仿真器的调度队列。它的生命周期可以很短比如一个发完激励就退出的线程也可以很长比如一个用于持续采样的monitor线程从仿真开始一直活着到$finish。控制线程生命周期的核心手段就是fork块的模式选择。一个常见的坑是fork块里开的线程如果不加控制父线程退出时子线程并不会自动杀掉。这就像你在公司开了一堆子任务自己先下班了活儿还挂在那没人管。在验证环境里这会导致某些线程在test_done之后还试图访问已经销毁的对象轻则仿真警告重则产生意外的数据竞争。所以我个人习惯是能收敛的线程尽量收敛不能收敛的也要在shutdown阶段做一次统一的kill或wait避免野线程残留在环境里。另外还要提醒一句线程里如果用了局部变量要搞清楚这个变量是静态的还是自动的。class方法里的变量默认是自动存储但如果你在fork块里引用循环变量并且该变量是静态存储那么所有子线程看到的可能是同一个变量的最终值——这就是经典的“fork里所有线程拿到的i都一样”问题。解决办法要么用automatic变量拷贝要么在fork前先把值存到局部变量里再传进去。这个坑我在后文会专门展示。3. fork-join家族三种模式怎么选3.1 三种模式的行为差异与最佳实践fork-join家族在SV里就三个成员join、join_any、join_none。它们的区别一句话就能讲清楚父线程到底等不等子线程。join等所有子线程都跑完父线程才继续。适合“并发收集完毕再做后续处理”的场景比如同时采样多个接口都采样完了统一比对。join_any只要任意一个子线程先完成父线程就继续其他子线程继续在后台跑。适合“谁先到谁先服务”的场景比如等多个握手信号任意一个先到就立即响应。join_none父线程根本不等fork完自己立刻往下走子线程在后台跑。适合“发起即忘”的后台任务比如开一个持续监视器或者启动一个独立的统计线程。实际项目里我最常用的是join_none和join_any。join其实用得少因为它会让父线程阻塞到所有子线程结束在很多场景下反而拖慢了节奏。比如我想同时启动driver、monitor、reference model三个线程肯定不希望它们全跑完才继续——那环境就串行了。这时候用join_none开三个后台线程父线程run_phase继续往下走子线程各自干活是标准打法。但join_none也有副作用你不好掌控这些后台线程到底啥时候干完。所以通常我会配合一个wait语句或者一个done标志来收敛。比如fork...join_none启动采集线程后接着一个wait(collect_done)等到采集完成再往下走。这样既享受了并发的效率又保住了可控性。下面给一个简单的选择对照方便你们以后直接抄场景推荐模式原因并发采样多路数据全部完成后再处理fork...join保证所有数据就绪等待多个事件任意一个触发就继续fork...join_any响应最快的事件在后台启动长期运行的监视线程fork...join_none父线程不阻塞监视线程独立存活需要启动一组任务但不关心它们什么时候结束fork...join_none最大程度并发父线程任务继续3.2 fork-join结合disable与wait fork的实战姿势fork块用得多了光会开线程还不够还得会关线程、等线程。SV提供了一组配套控制手段其中最常用的是disable fork和wait fork。disable fork的作用是杀掉当前线程派生的所有子线程。注意是“当前线程派生的所有子线程”不是全局杀。这给了我们很细粒度的控制例如在reset阶段我想把之前开出来的激励线程全部终止掉重新来过就可以在reset任务里调用disable fork。但有个坑disable fork连自己所在线程派生的“兄弟线程”也会杀掉如果你在父线程里先fork了采集线程A再fork采集线程B然后想单独杀掉B直接disable fork会把A也带走。所以更安全的做法是给每个fork块命名或者用disable 标签来精准控制。wait fork则是反过来它会让当前线程等待所有由它派生的子线程执行完毕。这个在shutdown阶段特别好用——保证环境里没有遗留的未完成任务。但wait fork只能等“当前线程派生的”如果你在某个fork_none块里又开了孙线程孙线程的状态wait fork管不着需要额外设计。我自己的习惯是在每个phase结束时先disable fork再wait fork双保险。虽然有点“一刀切”的粗暴但对大多数环境来说是够用的。如果遇到需要精细控制多级线程的场景我建议你在fork块上明确标注名字然后精准enable/disable不要怕代码啰嗦debug时你会感谢当时的自己。这里还要提醒一个常见的坑fork_none创建的子线程里如果引用了调用者栈上的变量可能在父线程退出后就失效了。SV的class方法是自动存储但如果子线程访问的是一个静态变量那它拿到的是全局共享的一份拷贝。想要每个线程都拿到独立的当前值一定要把数据在fork之前通过参数传进去或者用automatic变量重新声明一份。下面给一个演示代码你们可以自己跑一下感受差异task automatic test_fork_value(); for (int i 0; i 3; i) begin fork automatic int j i; begin #10; $display(fork with automatic j%0d, j); end join_none end #50; endtask如果不用automatic int j而是直接引用i你会发现三个线程打印出来的都是3因为循环变量i在迭代结束后保留的是最终值。这是SV里特别经典的“闭包陷阱”写并发代码时一定要格外留意。4. 线程间的“三件套”event、semaphore、mailbox4.1 event最轻量的同步原语event是三种同步机制里最轻量的适合做“事件通知”。一个线程等待某个事件另一个线程触发事件两者之间通过事件完成握手。它的使用方式很简单event e1; task wait_event(); (e1); $display(event received at %0t, $time); endtask task trigger_event(); #10; - e1; endtask上面代码里wait_event线程会在(e1)处挂起直到trigger_event线程执行-e1后才会继续。注意事件触发是瞬时的如果wait_event还没执行到(e1)触发就已经发生了那么这个事件就“错过”了wait_event会永远等下去。这就是所谓的“事件丢失”问题。想避免这个要么用电平方式的触发要么用event的triggered属性配合wait语句来检测。我用event最多的场景是phase之间的先后控制。比如在driver里复位任务执行完之后触发一个reset_done事件后面需要等待复位完成的sequence就会等待这个事件。事件本身不携带数据它只传达“某个时间点发生了某事”所以它不适合传数据传数据应该用mailbox。还有一个使用细节event有两种等待方式一种是用一种是用wait(e.triggered)。的灵敏度是边沿wait是电平检查。如果你在同一个时间槽里又想触发又想等待可能出现竞态。我的经验是跨线程同步用wait(e.triggered)比用更稳因为wait不会错过事件只要检查到曾经被触发过就会立即返回。4.2 semaphore控制资源访问的神器semaphore最早的感觉就是“票务系统”。它维护一个整数计数线程想要进入临界区就先key.get()用完再key.put()计数不够时线程被阻塞。这个过程和操作系统的信号量概念一致只是SV里已经封装好了不需要我们自己用锁去实现。我在总线建模里用semaphore非常多。比如一个AHB总线模型多个master通过它发起访问但物理总线只有一个不能让两个master同时驱动总线。semaphore的初始值为1每个master访问前先拿key用完归还这样天然地保证了同一时刻只有一个master持有总线控制权。如果某个master拿到key后长时间不归还其他master就只能等着——这时候要小心死锁尤其是在带分支判断的代码里千万别忘了在return之前释放key。semaphore的另一个用法是做“资源池”。初始化时给多个key表示有多个同类型资源可用。比如你有四个DMA通道可以分给四个不同的agent使用。这样做的好处是资源和任务之间的映射关系完全由semaphore协调代码逻辑清晰不需要自己维护一套状态机。semaphore bus_lock new(1); task automatic master_access(int id); bus_lock.get(); $display(master %0d acquired bus at %0t, id, $time); #20; bus_lock.put(); $display(master %0d released bus at %0t, id, $time); endtask这段代码里两个master同时调用master_access你会看到第二个master会在get处等20个时间单位直到第一个master释放后才开始。这种串行化的效果正是我们想要的。4.3 mailbox数据搬运和线程解耦mailbox本质上是一个线程安全的队列一个线程往里放数据另一个线程从里面取数据。它有两种用法有界和无界。无界mailbox可以无限放数据只受仿真内存限制有界mailbox在创建时指定容量放满后put会阻塞取空后get会阻塞。我在参考模型到计分板的数据通路中最常用的是有界mailbox。参考模型算出一个期望值就塞进mailbox计分板线程从mailbox里取出来和DUT采集到的实际值比对。如果参考模型跑得比DUT快mailbox满了之后put会被阻塞这相当于一个自然的背压防止数据无限堆积如果DUT跑得快计分板取数据会有等待但不会丢数据。这种机制让两边可以被适度解耦时序上不用精确对齐。mailbox还有个特性它内部可以带一个内置的semaphore所以多个生产者和多个消费者并发访问时数据不会错乱。这一点在我之前没仔细想后来在写多通道环境时深有体会——如果用简单队列自己实现多写多读很容易出问题而用mailbox就省了很多事。mailbox #(transaction) mbx new(4); // 容量为4的有界mailbox task automatic producer(); transaction t; for (int i 0; i 10; i) begin t new(); t.data i; mbx.put(t); $display(produced %0d at %0t, i, $time); #5; end endtask task automatic consumer(); transaction t; repeat (10) begin mbx.get(t); $display(consumed %0d at %0t, t.data, $time); #7; end endtask看到这里你可能会发现mailbox实际上已经涵盖了semaphore的很多用法——它内部自带同步。但两者的语义不同semaphore侧重“对资源数量的控制”mailbox侧重“数据的传递”。在同一个环境里两者往往要配合使用。比如多个master共享总线用semaphore控制总线使用权而master发出的请求数据用mailbox传给总线仲裁器。各司其职代码结构就清晰了。5. 实操复盘一次多通道数据比对环境的设计5.1 需求与架构前面讲了一堆概念这里用一个我近期做过的多通道数据比对环境来收个尾。场景是这样的DUT有4个输入通道每个通道连续送数据DUT处理后输出到单路总线。验证环境需要同时监控4个输入通道的数据经过参考模型计算期望值再把期望值跟DUT的输出现采值比对。听起来不复杂但实现上有几个并发关键点。首先是数据采集每个通道都要有独立的monitor线程持续采样并打包成transaction。其次是参考模型它要同时接收4路输入按特定算法算出期望输出顺序发往scoreboard。最后是scoreboard它要跟DUT输出端的monitor配合一个线程负责接收参考模型期望值另一个线程负责接收DUT实际输出然后二者比对。如果环境里只用一个线程去做接收和比对大概率会丢数据或者对不上。于是我的环境结构是这样的4个input monitor每个monitor的run_phase里fork一个循环线程持续采集数据并put到各自的mailbox。参考模型是一个独立线程它同时从4个mailbox里get数据按时间戳对齐后计算出期望输出放入期望mailbox。scoreboard里两个线程一个从期望mailbox取期望值一个从输出monitor的mailbox取实际值配对后做比较。5.2 关键代码摘录参考模型的对齐逻辑是这里最微妙的部分。4个通道的数据不是同时到来的为了对齐它们我的做法是给每个transaction打上采样时刻的时间戳然后参考模型循环里先等到4路数据都有再统一处理。这里涉及到跨线程等待多个数据源直接用mailbox的get会阻塞不方便做“等待4路同时就绪”的逻辑。所以我用了一个更朴素的方案每个通道配一个独立的mailbox参考模型每次循环先从一个通道的mailbox get一个数据检查它的时间戳再从另三个通道里get时间戳最接近的数据统一处理。这种“先取一路再取其余”的方式虽然简单但要注意死锁如果某一路数据迟迟不来参考模型会一直阻塞在get上其他通道的mailbox可能会被塞满。为了避免这种情况我把每路mailbox的容量设得足够大同时在参考模型里设置超时机制——用fork...join_any或者定时器来实现。这里就不展开全部代码了但核心思路你可以复制到自己的环境里。5.3 仿真结果与调试过程第一次跑这个环境时scoreboard报了经第一对数据就不匹配而且错误出现得很随机。我花了大半天查结果发现根本不是DUT的问题而是输入monitor的采样时机和参考模型的时间戳对齐逻辑对不上。原因是DUT内部有流水线延时输入数据到达输出端的时间比参考模型估计的要多一拍导致scoreboard总是拿前一个周期的实际值和后一个周期的期望值去比。后来在scoreboard里加了一个可配置的延时补偿参数对齐之后比对就全绿了。这个坑给我的教训是参考模型和DUT之间的时序对齐不能只靠功能逻辑正确还必须显式处理流水级数和握手延时。如果你在环境里发现比对错误随机出现优先怀疑对齐而不是急着怀疑DUT逻辑。这类问题用debug波形看很直观但定位效率不高我的办法是把期望值和实际值同时打印出来加上时间戳肉眼观察它们的相位差确定偏移量后反推延时参数。6. 常见问题与排查技巧实录6.1 线程不退出导致仿真卡住这是刚写并发环境时最容易遇到的问题。一个任务里fork...join_none开了一个循环线程循环条件是while(1)而父线程本身又没有任何停止机制仿真到$finish也不退出。表现就是仿真器一直不结束或者卡在某个phase里。排查思路先看是不是有线程在等待一个永远等不到的事件或mailbox。如果是再加上一层超时保护。我常用的办法是给每个长期运行的线程配一个“软停止”标志在仿真的shutdown阶段置位线程每轮循环检查这个标志真了就退出。bit stop_flag 0; task automatic monitor_loop(); while (!stop_flag) begin // 采样逻辑 end endtask在test的shutdown_phase里置位stop_flag再wait fork就能优雅退出。6.2 mailbox死锁死锁的典型场景是两个线程互相等在对方的mailbox上谁也没法先发数据。比如线程A等B的数据B等A的ack结果两边都阻塞在get上永远没有数据进来。解决死锁的思路无非两种一是改变通信方式让数据流单向化避免循环等待二是增加超时机制如果get超时了说明存在异常——报错并强制退出。在关键路径上我倾向于两种都做尽量从设计上规避循环依赖同时用超时兜底。SV里mailbox的get不支持超时参数如果我想做超时一般用fork...join_any配合一个延时器来实现。等mailbox和等一个时间戳谁先完成就继续如果超时完成就告警。代码示意task automatic get_with_timeout(mailbox mbx, output transaction t, int timeout); fork mbx.get(t); join_any disable fork; if (t null) begin $display(ERROR: mailbox get timeout at %0t, $time); end endtask这种手法在排障时特别好用能快速定位是数据没产生还是数据没送达。6.3 事件丢失与竞态事件丢失的根源在第一节就提过事件的触发是瞬时的如果接收方没有在触发瞬间挂起等待这个事件就再也等不到了。典型的竞态场景是两个线程在同一时刻一个执行-e一个执行(e)。如果触发线程先跑接收线程后跑接收线程会永远等下去。解决方式就是在接收端用wait(e.triggered)替代(e)。wait检查的是“事件曾经被触发过”这个状态因此不会丢失。但要注意wait(e.triggered)不会自动清除触发状态如果你后续还想再用这个事件需要用-或者event的reset操作来手动重置。跨线程同步时我一般会明确区分“一次性事件”和“可重复事件”一次性事件用wait(e.triggered)没问题可重复事件就得自己做状态管理。6.4 调试建议让并发问题“现形”写并发代码最怕的是问题不可复现。我这边有一个习惯在所有关键的线程入口和同步点加上时间戳打印这能帮你快速判断线程的执行顺序和阻塞位置。$display([%0t] thread %s starts, $time, name);仿真跑挂之后从打印日志里看顺序通常能一眼找到问题要么某个线程没启动要么某个线程卡在了某个同步点。这个方法比打开波形一点点翻效率高得多尤其是环境很大的时候。另一个技巧是把fork块里的每个线程都起一个名字配合打印的线程名debug时能直接定位是哪个任务出的问题。7. 我的几条实操心得写到这里冒昧分享几点个人心得不一定正确但都是我踩过坑之后总结出来的。第一并发代码宁笨勿巧。能用mailbox就少用全局变量能用event就少用轮询能用semaphore就少用sleep。SV提供的这三种同步原语已经把常见并发模式封装好了直接拿来用就好别自己发明轮子。第二fork块里能命名就命名。disable一个命名的fork比disable fork一刀切来得精准得多尤其在多级线程嵌套的环境里这也是我在大项目里踩过“误杀兄弟线程”的坑之后学乖的。第三收敛比并发更重要。开线程很容易但确保线程按照预期退出很难。在大型验证环境里野线程不仅会导致仿真卡住还可能因为访问了已经释放的对象而产生随机崩溃。我在每个phase结束时都会显式检查一次线程状态宁可多写几句冗余代码也不留隐患。第四不要迷信“参考模型算得一定对”。在我多年经验里scoreboard报错有一半以上是参考模型自身的时序或对齐问题而不是DUT的真实bug。先把参考模型和比对逻辑的自检做扎实再判断DUT对错。最后想对还处在入门阶段的读者说一句System Verilog的并发模型并不难难的是在真实环境里把“同时产生、安全传递、正确汇合”这三件事做好。多写、多跑、多debug慢慢地你会找到自己的节奏。后续我打算写一篇专门讲sequence和driver之间如何通过mailbox联动控制复杂总线协议的文章如果你也在关注这两块咱们下篇笔记见。