尧图网络 高端网站定制 · 原创设计
免费咨询热线
400-888-6620
免费获取方案
SystemVerilog线程控制专题:fork/join家族与automatic变量深度解析
第一次在SystemVerilog代码里看到 fork/join 的时候我脑子里蹦出来的念头是这怎么长得跟多线程编程一样后来在调试一个总线功能模型时才彻底想明白——验证平台天生就是并发系统。激励要产生接口要驱动协议要采样参考模型要跑计分板要比对这些活儿要是排成一个串行队列一个仿真永远跑不完。线程控制就是用来管理这一堆同时存在的执行流的。这篇对应SV基础阶段的线程控制专题我会把 fork/join 家族、wait fork、disable fork、进程对象、automatic 变量这些内容串起来讲。如果你是 Verilog 背景转过来学验证的重点看 fork/join 和 automatic 的关系如果你已经在写 class 组件重点看进程对象和线程泄漏的部分要是你正在被仿真退不出来和仿真提前跑完折磨直接跳到最后章节。1. 验证平台为什么天生是多线程的从always块思维到事务级并发1.1 硬件并行基因与软件串行习惯的冲突写RTL的时候我们习惯的是每个 always 块、每段 assign 连续赋值各自独立并行运行。这是硬件描述语言刻在骨子里的基因。可一旦开始写验证很多人会下意识地把 Testbench 描述成一段先给复位、再发激励、再等结果的流水账。这个思路在极简单的定向测试里勉强能跑但只要场景一复杂串行写法就会卡住自己。SystemVerilog 的 initial 块本身只是表示一个仿真时间上的起点它内部默认还是顺序执行的。真正支撑并发的是 fork/join 这样的显式并行块。很多人在这里的第一个认知偏差是觉得 initial 块多了或者 always 块多了就是并发。其实每个 initial/always 确实是一个独立的进程但在一个 initial 块内部如果你不主动写 fork它永远是一条道走到黑。1.2 一个典型的并发验证场景假设现在要给一个 DUT 做基本的功能验证需要同时做三件事给复位、产生事务激励、在总线上监听响应。用串行写法的痛苦在于监听响应是一件持续要做的事情你不可能先发完激励再开始监听因为很多协议错误就发生在激励发送过程中。一个自然的写法是initial begin fork reset_dut(); gen_transactions(); monitor_bus(); join $display([%0t] all done, $time); end这个例子里reset_dut、gen_transactions、monitor_bus 三个任务会并行执行。join 的语义是等三个都结束后initial 才继续往下走。看起来很合理但实际工程里我们往往需要的是同时运行、互不等待或者等其中一个先完成就继续。线程控制解决的就是这一类问题哪些任务一起跑等到什么程度算结束中途如何停止停止谁而不误伤谁。1.3 线程控制在整个验证知识体系中的位置如果把验证环境比作一家餐厅线程就是厨师和服务员。主线程是店长fork/join 是排班方式wait fork 是打烊时等所有岗位收工disable fork 是紧急停业时把所有岗都拉掉。你后面要学的 sequence 机制、组件之间的启动关系本质上都建立在这套线程控制能力上。理解了这一层再看后面 UVM 里那些 start、run 方法就不会觉得它们是凭空冒出来的魔法。所有组件里的 task早就是在线程框架里运行的只是框架把细节藏起来了。这也是为什么基础阶段一定要把这个专题啃透——它不是孤立的知识点而是后面所有并发验证代码的地基。2. fork/join 三兄弟选错一个整轮回归就崩2.1 基本语法与每行一个线程的执行语义先看最基础的 fork/join。它和 begin/end 正好是一对begin/end 是顺序块里面的语句一条接一条执行fork/join 是并行块里面的每一条语句或每一个块都会作为独立线程并发执行。initial begin $display([%0t] test start, $time); fork begin #20; $display([%0t] thread A, $time); end begin #10; $display([%0t] thread B, $time); end join $display([%0t] test end, $time); end执行顺序是time 0 打印 test start然后两个线程并发跑time 10 打印 thread Btime 20 打印 thread Atime 20 之后打印 test end。注意 final display 是等到两个线程全部结束后才执行的这是 join 和另外两个兄弟最大的区别。一个很容易被忽略的细节fork 下面如果只写了一行语句而不是 begin/end 块那一行语句本身也是一个独立线程。所以下面这种写法里两个 $display 理论上都可能先执行顺序不确定。工程上我一般建议fork 下每条分支都显式写成 begin/end 块层级清楚也方便后面给块命名。2.2 join_any等任意一个先来join_any 的语义是父线程等着只要 fork 里任意一条分支线程结束父线程就立刻继续往下走。剩下的分支线程不会被取消它们还在后台继续跑。initial begin fork begin #100; $display([%0t] slow task done, $time); end begin #30; $display([%0t] fast task done, $time); end join_any $display([%0t] parent continues, $time); end这段代码里time 30 时 fast task 先结束父线程立刻打印 parent continues但 slow task 没有死time 100 时还会打印 slow task done。join_any 最常见的用途是等待多个条件里最先满足的那个。比如等待中断信号或者等待外部事件同时设一个超时看门狗哪个先来就按哪个处理。但是注意如果只写了 join_any 而没有配套的线程清理动作那个没跑完的线程可能会在仿真结束时造成意想不到的问题这一点后面会细说。2.3 join_none放出去就不管前提是仿真别结束join_none 的语义最轻父线程根本不等fork 一执行父线程立刻继续往下走子线程们在后台自己玩。initial begin fork begin #50; $display([%0t] background task, $time); end join_none $display([%0t] parent immediately continues, $time); #100; $display([%0t] test end, $time); end打印顺序会是time 0 父线程继续time 50 后台任务打印time 100 test end。子线程不会影响父线程的执行节奏。很多人对 join_none 的理解止步于不等待但真正的重点是从 fork/join_none 执行那一刻起父线程和子线程就分道扬镳了。如果父线程在 join_none 之后没有任何延时直接跑到 initial 末尾结束而仿真事件队列里又没有其他需要调度的事件子线程可能根本没机会执行就被仿真器终止。这是初学者最容易踩的坑后面调试章节还会再展开。2.4 三张面孔的对比结束方式父线程行为子线程后续状态典型应用join等所有子线程结束全部结束后父线程继续并行任务需要全部完成join_any等任意一个子线程结束其余子线程继续运行多个条件先到先得join_none完全不等待立即继续子线程后台运行启动后台监控、常驻任务选择这三兄弟本质上是在回答一个问题父线程愿意等在哪个同步点上。选错了不会导致编译失败但会导致仿真行为和你预期完全不同而且这种错误往往在回归测试里才爆发。2.5 嵌套fork与命名块的组合实战fork 是可以嵌套的。一个分支线程内部还可以继续 fork 出一组并行的子线程。这种情况下外层 join 等的是整个内层 fork/join 块的完成。task run_all(); fork begin : t1 fork driver_a(); driver_b(); join end begin : t2 monitor_c(); end join endtaskt1 内部那两个 driver 都跑完了t1 这个线程才算结束外层才会继续。这种嵌套写法在真实测试环境里很常见比如一个激励任务内部又拆成了多个并行的子协议序列。给 begin 块起名字是个好习惯因为后面可以用名字来做定点终止操作这一点在下一章会详细说。3. 等线程与杀线程wait fork、disable fork 的边界和误伤3.1 wait fork全体集合的哨声wait fork 的语义是当前线程阻塞直到它派生的所有后代线程全部结束。注意关键词是所有和后代。initial begin fork begin #50; $display([%0t] sub thread 1, $time); end begin #80; $display([%0t] sub thread 2, $time); end join_none // 做一些别的事 #20; wait fork; $display([%0t] all sub threads finished, $time); end父线程在前 20 个时间单位里干别的事然后 wait fork 等着两个子线程都结束。time 50 第一个子线程结束time 80 第二个子线程结束父线程才继续。wait fork 的实用价值在于你既想启动一堆后台线程又不想立刻等它们但又不希望在仿真结束前把它们丢掉。典型场景是测试主流程完成后需要确保所有后台监控线程都收尾了再结束仿真。3.2 wait fork 的适用范围和容易踩的误区wait fork 有一个显而易见的限制它不能指定某一个线程只能无差别地等所有后代线程。如果你启动了线程 A 和线程 B你只想等线程 A 结束wait fork 是做不到的。这时候要么用事件做显式同步要么用进程对象做精细化控制后面 3.5 会讲。还有一点经常被忽略wait fork 只会等待当前线程派生的后代线程。如果子线程自己又派生出了孙子线程孙子线程也在等待范围内这个没问题但别的 initial 块里派生出来的线程不归你管wait fork 也等不到它们。理解这一点对排查我明明 wait fork 了为什么仿真还在跑这类问题有很大帮助。3.3 disable fork无差别终止的误伤现场disable fork 的坑我见过太多次了。它的语义是终止当前线程派生的所有活动子线程包括它们的后代线程。注意是所有不是某一个。看这个经典翻车例子initial begin fork begin : load_cfg #100; $display([%0t] config loaded, $time); end begin : wait_irq wait(irq 1); $display([%0t] irq received, $time); end join_any disable fork; // 你以为只杀了 wait_irq其实 load_cfg 也被干掉了 end写这个代码的人本意是两个任务谁先完成就继续另一个别管了。如果 irq 在 time 30 到来wait_irq 先结束join_any 让父线程继续然后 disable fork 想把还没跑完的 load_cfg 终止掉。但事实上disable fork 把 load_cfg 和 wait_irq 全部终止了。如果 load_cfg 此时已经完成那还好如果它还在中途配置加载就被打断了后面所有依赖这个配置的代码都会开始出错。这种 bug 最恶心的地方在于它不一定每次仿真都触发。irq 来得早load_cfg 还没跑完就被杀了irq 来得晚load_cfg 已经跑完了disable fork 这一刀下去没伤到它。于是同一个测试有时候正常有时候挂典型的自己都说不清楚的问题。3.4 用命名块 disable 实现定点清除那到底怎么精确终止某一个线程SV 的老办法是给 begin/end 块起名字然后用 disable 加块名来定点终止。initial begin fork begin : cfg_thread // 加载配置 #100; end begin : irq_thread wait(irq 1); end join_any disable irq_thread; enddisable 定点终止命名块是合法的它只会终止那个命名块对应的线程。irq_thread 如果已经结束disable 不会有任何副作用如果还没结束它会被干净地干掉cfg_thread 不受影响。需要注意disable 在这里针对的是命名块不是进程对象。如果两个不同的 initial 里出现了同名的块disable 到底终止哪个取决于 disable 语句所在的层次语境。工程上我会尽量避免这种歧义给块起足够独特的名字或者干脆用下一节讲的进程对象。3.5 process类握在手里的线程句柄SystemVerilog 提供了一套进程对象机制可以拿到线程自己的句柄然后做精确控制。这是现代验证代码里更推荐的方式。process p; initial begin fork begin p process::self(); forever begin #10; $display([%0t] worker alive, $time); end end join_none #50; if (p.status ! process::KILLED) begin p.kill(); end $display([%0t] worker killed, $time); end这段代码里后台线程在内部通过 process::self() 拿到自己的句柄赋值给外面声明的 p。父线程等了 50 个时间单位后调用 p.kill() 把它终止。process::self() 必须在要获取句柄的线程内部调用如果在父线程里调用拿到的就是父线程自己的句柄这一点非常容易搞错。process 类的 status() 方法可以返回线程的当前状态比较有用的几个值是 FINISHED、RUNNING、WAITING、KILLED。kill() 之前先检查一下状态是为了避免对已经结束的线程重复操作。这种做法比 disable fork 精细得多也更适合在 class 组件里做线程生命周期管理。4. 循环变量与automatic十个fork join_none有九个死在这里4.1 捕获的是变量还是值经典打印翻车几乎所有学 SV 的人都写过这个代码initial begin for (int i 0; i 4; i) begin fork $display(Thread %0d, i); join_none end end本来预期打印 Thread 0、Thread 1、Thread 2、Thread 3实际运行起来你看到的可能是一堆 Thread 4或者是 Thread 2、Thread 3、Thread 4 这种乱七八糟的组合。原因是for 循环变量 i 在迭代之间共享同一个存储位置。fork/join_none 只是把执行代码的线程放到后台它并不是在 fork 那一刻把 i 的值快照进来。等到这些子线程真正被调度执行时循环可能早就跑完了i 已经是 4。线程看到的是变量不是创建那一刻的值。4.2 automatic变量快照每次迭代一份新存储解决办法是在循环内部声明一个 automatic 变量把当前值抄一份再让线程去读这个副本。initial begin for (int i 0; i 4; i) begin automatic int idx i; fork $display(Thread %0d, idx); join_none end endautomatic 变量的特点是每次进入声明它的作用域时都会创建一份独立的存储。循环每次迭代进入 begin 块idx 就有一份属于本次迭代的副本。子线程读到的是自己那份副本而不是共享的 i。这个写法是创建动态线程时铁打的标配。4.3 task里的静态变量串扰同样致命循环变量只是最显眼的一处。另一个极其常见的坑是task 或 function 内部声明的局部变量默认情况下是静态存储所有并发线程调用同一个 task 时访问的是同一份变量。task do_print(); string name; name get_name(); // 假设不同线程会传入不同的名字 #100; $display(%s, name); endtask如果 thread A 和 thread B 同时调用 do_print它们会互相覆盖 name。A 刚把名字写进去B 后脚就把它改掉了。等到 100 个时间单位后打印时A 和 B 看到的可能是同一个名字。这个问题的本质和循环变量完全一样静态共享存储。解决办法有两种。一是把 task 声明成 automatic task这样 task 内的所有局部变量都自动变成每次调用独立一份二是显式把变量声明为 automatic。我自己的习惯是新建 task 时一律用 automatic task只有明确需要在线程间共享的静态变量才用特殊标记。4.4 哪些场景需要主动写automatic总结一下需要主动用 automatic 的场景循环里用 fork/join_none 创建多份线程并且依赖循环变量时并发线程会调用同一个 task 且 task 内有跟线程相关的局部数据时以及任何你不确定变量会被多少线程访问、但希望它保持独立的情况。反过来class 成员变量不要机械套 automatic。class 本身是动态对象它的成员变量的生命周期和对象绑定跟线程里的局部变量是两码事。该用类成员的地方正常声明就行。5. class中的线程管理测试环境里线程泄漏的真相5.1 在class里启动线程的标准姿势面向对象写测试环境后我们很少直接在 initial 里堆 fork。更常见的做法是class 提供一个启动任务内部用 fork/join_none 把长任务挂到后台。class transaction_generator; task start(); fork generate_loop(); join_none endtask task generate_loop(); forever begin #10; // 产生并发送一笔事务 end endtask endclassstart() 调用后立即返回generate_loop 在后台线程里跑。好处是测试主流程可以按照自己的节奏启动多个组件而不需要一个个等它们先跑起来。5.2 线程泄漏是如何发生的线程泄漏这个词听起来抽象其实现象非常具体测试主流程已经跑完了你期望仿真结束但仿真器就是退不出去或者明明把所有对象都置空了后台还在疯狂打印。原因就是那个 forever 线程。class 对象被释放只意味着你失去了对对象的引用但线程本身是仿真内核的调度单位它和对象没有生命周期绑定。只要那个 forever 循环还在跑它就会不断产生新的延时事件仿真事件队列永远不会空仿真就永远结束不了。5.3 用事件协作关闭线程比kill安全正确的关闭方式是在 class 内部提供一个停止任务用事件告诉线程该收工了让线程自己从循环里干净地退出来。class driver; event stop_ev; task run(); fork begin while (1) begin if (stop_ev.triggered) break; drive_one_pkt(); #10; end end join_none endtask task stop(); - stop_ev; endtask endclassstop() 被调用后事件触发run 循环在下一轮检查到 stop_ev.triggered退出循环线程自然结束。这比直接调用 kill() 安全因为线程有机会做完当前这笔事务的收尾不会在驱动总线的半中间突然消失留下一个悬空的时序状态。5.4 测试环境退出的标准动作stop后wait fork拿到线程句柄后直接用 kill() 也不是不行但除非你非常确定线程当前停在哪个位置否则我仍然建议事件协作。工程里标准的收尾动作是initial begin env new(); env.start(); // 跑若干测试用例 env.stop(); wait fork; endstop 让所有循环线程都收到退出信号wait fork 保证它们真正退出、把收尾动作做完然后仿真才允许自然结束。这个模式组合了线程控制和自动变量之外的另一块核心拼图——线程间协调。如果你之前已经会用事件做同步这个套路就是水到渠成的事。6. 调试线程Bug的思路仿真退不出来和提前跑完怎么查6.1 现象一仿真卡死时间不再前进仿真的时间戳停在一个点上不动事件队列里剩下的一大堆线程都在等永远等不到的条件。最常见的两类根因一是某个线程在 wait 一个不会被触发的事件二是 join 在等一个后台线程而那个线程因为饥饿或者死循环一直不结束。排查思路是先定位到底是哪个线程卡住了。我的习惯是在关键任务的入口和出口打带时间的打印$display([%0t] %m enter task reset_dut, $time);%m 会打印当前模块实例和任务的层次路径配合时间戳能很快看出哪些任务进去了但没出来。如果所有该打印的出口都打了那就把注意力放在事件和信号上查那个 wait 条件是否真的有人负责改变。6.2 现象二仿真提前结束后台线程没跑完这个和上一个正好相反。你明明用 join_none 放了一个线程出去它还有活要干但仿真器提前宣布结束后台线程被强行终止。原因是仿真器决定结束的条件是没有更多事件需要调度。如果主线程跑完了剩下一堆等信号的线程没有产生任何未来事件仿真就会结束。不是你想让线程活着它就能活着调度器只认事件。解决办法很朴素主流程不要裸奔结束最后加一个 wait fork或者显式等待一个收尾事件。把期望的后台线程完成时间放到事件队列里仿真不会提前退出。6.3 现象三变量被多个线程改得乱七八糟某几个信号或变量的值飘忽不定换个随机种子就变更扭曲的方向。第一反应不要怀疑 DUT先查验证代码里的共享静态变量。排查时在变量被写入的地方加打印打印里带上 %m 和线程标识。如果可以用 process::self() 拿到线程句柄把句柄字符串打出来看看到底是谁在改。大概率最后会定位到某个 task 里的局部变量没有 automatic或者循环里的 fork/join_none 还在共享同一个循环索引。6.4 一张排查对照表现象可能原因优先检查点仿真不退出后台 forever 线程没收到停止信号是否调用了 stop事件是否触发仿真提前结束主线程跑完但后台线程序列没排上是否缺 wait fork是否少了一个延时事件线程被误杀disable fork 范围过大是否应该改成命名块或进程对象变量互相覆盖task 内静态变量共享是否忘了 automatic 或 automatic taskjoin_any 后行为诡异没处理未完成线程是否该用 disable 定点终止6.5 打印优先于断点的调试经验在线程相关的代码里断点反而不如打印好用。因为线程是并发的你停住一个线程其他线程还在跑你用调试器看到的现场很可能已经和问题时刻对不上了。我更推荐先把带时间戳、带层次、带线程标识的打印埋好跑一轮收集信息再决定在哪里加断点。还有一个小技巧调试时如果一堆 forever 线程让仿真停不下来先不要逐个去断而是临时把 forever 改成可退出循环用一个最大迭代次数或者事件来控制先让仿真能干净结束再做进一步排查。否则你一边加断点一边还有几十个线程在刷屏很容易把自己绕进去。最后再说点个人的体会。线程控制是 SV 验证从会写到能设计的一道坎。我自己的习惯是新建 task 一律用 automatic需要跨线程共享的变量再专门声明成静态或者类成员。这么多年下来因为这个习惯少踩了不知道多少次变量共享的坑。如果你现在正被 fork/join 三兄弟绕得头晕不用急把本章的代码敲一遍再故意制造几次线程泄漏和误杀亲身感受一下那些坑这个知识点就算是真长在你身上了。
RELATED

相关推荐

移动端订单管理系统架构设计与安全实践

移动端订单管理系统架构设计与安全实践

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/10/24 6:15:59
UE5自定义LiveLink数据源插件开发全流程:从动捕接入到蓝图交互

UE5自定义LiveLink数据源插件开发全流程:从动捕接入到蓝图交互

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/10/24 6:15:59
Python构建漏洞情报采集流水线:从数据抓取到资产匹配的实战指南

Python构建漏洞情报采集流水线:从数据抓取到资产匹配的实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

📅 2026/10/24 6:15:59
MORE NEWS

更多资讯

📰

AI论文工具全流程实测:从选题到排版一篇搞定

毕业论文难,难在你以为自己缺的是写作能力,其实你缺的是对“写作流程”的控制力。这几个月我把能接触到的AI论文工具都翻来覆去试了一遍,从选题、文献、大纲、初稿、润色、图表、降重一直试到排版,真正确认了它们能覆盖毕业论文的…

📰

Transformer深度解析:FFN与位置编码的原理与工程实践

很多人在啃 Transformer 源码或者读开源大模型实现的时候,注意力全放在 Attention 的 QKV 上,觉得 Self-Attention 就是整个架构的灵魂。但真正常年折腾模型训练的人心里都清楚,真正决定一个模型“能不能训起来”“知识存不存得住”“序列拉长…

📰

AC7840车规MCU CSE密钥加载与更新实操指南

做车规MCU底层开发的人,几乎都会撞上CSE这个绕不开的模块。杰发科技AC7840作为一颗面向车身控制、BMS、T-Box等场景的车规级MCU,它的CSE(Cryptographic Service Engine,加密服务引擎)承担着密钥生成、安全存储、加解密…

📰

ROS2 Humble 下 RealSense D435i 深度图、点云与 IMU 实战

玩机器人的朋友,迟早会碰到这么一天:手头多了台 RealSense D435i,想在 Ubuntu 22.04 ROS2 Humble 的环境里把它跑起来,顺手出深度图、点云,甚至把 IMU 数据也一起接了。这套组合在视觉 SLAM、导航避障、机械臂抓取里出…

📰

降AI率不是玄学:三步加工法把AI内容变成自己的论文

最近一段时间,私信里被问得最多的一件事,就是“2025届毕业生推荐的降AI率方案”到底靠不靠谱。不少同学正处在毕业论文初稿刚写完、准备降重的阶段,结果用AI查了一遍,跳出来一个刺眼的“高概率AI生成”提示,整个人就慌…

📰

Linux 高性能网络:NtyCo 协程实现百万 TCP 并发连接

NtyCo 这样的协程库,究竟能不能在客户端和服务端之间维持百万级 TCP 连接?协程在高并发服务端中能带来什么好处?Linux 内核需要怎样调优?出现 OOM 时,又应该从哪里定位和优化? 本文以一次 NtyCo 百万连接实…

TODAY

今日更新

THIS WEEK

本周精选

THIS MONTH

本月热门

读完文章,想聊聊您的网站?

告诉我们您的行业与需求,资深顾问一对一梳理方案与报价,全程免费。

📞 💬