SystemC 内核详解:模块、进程、事件与 delta cycle 调度
最后更新:2026-09-13
C1 讲了为什么用 SystemC。本篇(C2)钻进仿真内核——
wait()到底在等什么、同一时刻多个进程谁先跑、为什么notify(SC_ZERO_TIME)不会引起无限循环。这些语义直接决定性能模型的时序是否确定、可复现,是后面写流水线/Cache 模型的地基。
一、结构:sc_module 与模块层次
SystemC 用 SC_MODULE(实质是 sc_module 的宏子类)表达一个硬件块。模块在构造函数里命名、例化子模块、绑定端口,形成和硬件框图一致的层次树;每个对象在仿真里有唯一层次名(如 top.cpu0.fe),这是波形、调试器、transaction recording 定位的依据。
SC_MODULE(FeStage) { // 取指阶段
sc_in<bool> clk;
SC_CTOR(FeStage) { SC_THREAD(run); sensitive << clk.pos(); }
void run();
};
SC_MODULE(CpuCore) {
sc_in<bool> clk;
FeStage fe; // 子模块:层次名为 <core名>.fe
SC_CTOR(CpuCore) : fe("fe") { fe.clk(clk); }
};层次只表达结构,不推进时间;推进时间的是进程与内核调度器。
二、两类进程:METHOD 与 THREAD
进程是内核可独立调度的并发单元。现代代码主要用两种(老代码里的 SC_CTHREAD 是时钟沿专用 THREAD,已不推荐):
| 进程类型 | 可否 wait() | 触发方式 | 典型用途 | 硬件直觉 |
|---|---|---|---|---|
SC_METHOD | 否(必须立即返回) | 敏感列表事件,运行到返回 | 组合/唤醒逻辑、仲裁、译码 | always @(*) 组合逻辑 |
SC_THREAD | 是,可多次挂起 | 首次运行后由 wait() 恢复 | 时序行为、流水线阶段、顺序激励 | always @(posedge clk) 时序块 |
关键区别:METHOD 不允许 wait(),在一个 evaluate 阶段内一口气跑完;THREAD 遇到 wait() 就把执行点(协程上下文)挂起,等被等待的事件到来再恢复。 描述"跨周期状态机"几乎都用 THREAD,纯组合判决用 METHOD。
wait() 的常见形态:
wait(); // 等敏感列表上的下一个事件
wait(10, SC_NS); // 推进 10ns 仿真时间后恢复
wait(e); // 等事件 e 被 notify
wait(e1 | e2); // 任一事件(or 敏感)
wait(2, SC_NS, e); // 超时或事件,先到者唤醒wait(10, SC_NS) 推进的是仿真时间,不是真睡眠,宿主机执行瞬时跳过去——这正是性能模型能"1 秒模拟几毫秒硬件"的原因。
三、事件 sc_event:不带数据的通知
sc_event 是进程同步的核心原语,只通知"发生了",不携带数据(数据由模块成员变量/信号承载)。三种通知时机是理解调度的关键:
| 写法 | 生效时机 | 典型用途 |
|---|---|---|
e.notify() | 立即:唤醒当前 delta 内已在等待的进程 | 少用,易产生顺序依赖 |
e.notify(SC_ZERO_TIME) | 下一个 delta cycle(同一仿真时刻、0 时间差) | 时钟进程内安全广播 |
e.notify(t, SC_NS) | 未来 t ns 的某时刻 | 建模延迟:Cache 命中、总线响应、定时器 |
cancel() 可撤销尚未发生的定时通知(已发生的撤不掉)。为什么零时间通知不立即生效? 因为 SystemC 要保证确定性:同一时刻可能一批信号同时更新,立即通知会让结果依赖进程执行顺序。引入"delta"——一个不消耗用户可见时间的微步——把"本拍算出新值"和"下一拍对新值反应"干净分开。
四、核心机制:delta cycle 三阶段
SystemC 内核把同一个仿真时刻切成若干个 delta cycle,每个 delta cycle 经典地经历两到三个阶段:

五、一个可编译的事件握手骨架
下面用"取指→译码"两个 THREAD + 一个事件,演示跨周期握手与零时间通知。
// file: kernel_handshake.cpp —— SystemC 2.3.x + C++17,IEEE 1666 API
#include <systemc.h>
SC_MODULE(FeStage) {
sc_in_clk clk;
sc_event e_valid; // 通知译码:本条指令就绪
unsigned pc_reg = 0x8000;
SC_CTOR(FeStage) { SC_THREAD(run); sensitive << clk.pos(); }
void run() {
while (true) {
wait(); // 每个上升沿取一条
pc_reg += 4;
std::cout << sc_time_stamp() << " FE pc=" << std::hex << pc_reg << "\n";
e_valid.notify(SC_ZERO_TIME); // 同拍稍后唤醒译码
}
}
};
SC_MODULE(DecStage) {
sc_in_clk clk;
FeStage* fe = nullptr;
SC_CTOR(DecStage) { SC_THREAD(run); sensitive << clk.pos(); }
void run() {
while (true) {
wait(fe->e_valid); // 等"指令就绪"
std::cout << sc_time_stamp() << " DEC latch instruction\n";
wait(); // 与 FE 保持每拍一指令节奏
}
}
};
int sc_main(int, char*[]) {
sc_clock clk("clk", 10, SC_NS);
FeStage fe("fe");
DecStage dec("dec");
fe.clk(clk); dec.clk(clk); dec.fe = &fe;
sc_start(50, SC_NS);
return 0;
}输出里 FE 与 DEC 的时间戳按 10ns 整齐推进、FE 总在同拍先于 DEC——这是 SC_ZERO_TIME 通知 + 时钟敏感共同作用的确定顺序。把零时间通知误写成裸 notify(),顺序就可能依赖进程注册先后,是真实 bug 高发点。
六、建模时的常见陷阱
| 陷阱 | 后果 | 正确做法 |
|---|---|---|
METHOD 里调 wait() | 编译报错 | 跨周期逻辑改用 THREAD |
| 用普通 C++ 变量当"线",多进程同时写 | 立即生效、顺序依赖、不确定 | 跨模块通信用 sc_signal/通道,成员变量只做模块内状态 |
notify() 立即通知依赖执行顺序 | 换台机器结果变化 | 用 notify(SC_ZERO_TIME) 或定时通知 |
THREAD 的 while(true) 内无 wait() | 仿真卡死在零时间 | 每条循环路径至少一个 wait |
用 wait(n, SC_NS) 当精确流水延迟 | 阻塞阶段、忽略反压/多指令重叠 | 精确流水用事件/队列建模,延迟只注解给统计(见 D3/D4) |
| 误以为 delta cycle 花了真实时间 | 性能计数错位 | 延迟以 sc_time/显式周期计数表达,与 sc_time_stamp() 对齐 |
七、从内核到 CPU 模型:怎么把微架构装进来
理解调度后,就能把真实机制逐一映射:
- 流水线各级 = 一个或一组 THREAD,沿事件/队列向下游传"指令 token",每拍
wait()一次;冒险与插泡表现为 token 停留或插入气泡。 - 分支预测器/唤醒逻辑 = 纯函数 + METHOD,输入 PC/历史,输出预测与方向,分支预测的真机原理在概念站讲透,这里只负责确定性求值。
- Cache/总线延迟 = 未来时间
notify(latency),延迟参数来自后续校准(E1 模型校准)。 - 统计计数器(周期、命中、发射、阻塞)在每拍/每事件累加,仿真末汇总成 IPC、CPI 栈。

SystemC 的进程/事件只是"执行与时间"的机器,真正要建的 CPU 机制在真机上如何工作,见 concepts/microarch 那组文章;本篇提供把它们写成确定性模型的调度语义。有了内核,下一步解决"模块之间怎么连":C3 的端口、接口与通道(sc_fifo/sc_semaphore/sc_mutex 等 primitive channel,以及自定义通道——TLM 通道的雏形)。
八、为什么确定性值得专门强调:从调试到回归
在普通多线程软件里,"这次跑通、下次崩"往往是竞争条件,复现要靠运气。性能模型如果也这样,整个工作流就垮了:架构师对比两个配置,A 比 B 快 3%,到底是配置带来的还是调度随机性带来的?回归系统发现 IPC 掉了 2%,是新提交引入的,还是仿真器换了台主机后事件顺序变了?这类问题无法回答,模型就会迅速失去团队信任。SystemC 内核用确定性协程而非操作系统线程来表达并发,核心理由正在于此——同样的输入、同样的参数、同样的模型版本,必须得到逐位一致的事件序列和统计结果。理解这一点,你才会明白为什么规范反复强调不要在事件通知上耍"立即生效"的小聪明:任何让结果依赖进程注册顺序、依赖宿主机调度的写法,都是在用短期方便换取长期不可复现。
delta cycle 机制在实践中还有一个常被忽略的作用:它让模型的行为和硬件信号在时钟沿后的稳定过程一一对应。真实电路里,时钟上升沿之后触发器输出变化,组合逻辑经过一连串门延迟逐级稳定,最后下一级在下一个沿采到稳定值;SystemC 不建模门延迟,但用若干个 delta 表达"信号在同一仿真时刻被反复更新直到没有反应"这一结构。写模型时把这两件事对应起来,很多调度行为就有了物理直觉:为什么信号本拍读旧值、下一拍才读新值(对应触发器采样保持),为什么零时间通知能在不推进可见时间的情况下把变化传播下去(对应组合链在沿后稳定),为什么一串纯 METHOD 互相敏感却不会让仿真时间往前走(对应纯组合逻辑不消耗周期)。反过来,如果模型里出现"同一时刻 delta 数异常膨胀",往往说明你无意中写出了一条过长的零时间反应链,这在真实硬件上可能对应一条频率难以收敛的组合路径,值得警惕。
调试 SystemC 模型和调试普通程序也有不同的着力点。普通断点看的是调用栈和变量,模型调试更关注三类信息:当前仿真时间与 delta 数(判断是不是卡在零时间循环)、哪些进程处于可运行/等待状态(判断是不是漏了通知导致永久挂起)、信号与事件的变化序列(判断握手是否按预期推进)。推荐的最小工作习惯是:第一,给每个模块和事件起有意义的层次名,因为波形和事务日志里看到的就是这些名字;第二,对关键握手加带时间戳的 trace 打印,输出"谁在何时通知了谁",很多死锁靠这张事件时间线就能定位;第三,把仿真做成可注入固定随机种子的,凡是内部用到随机(如随机替换、地址随机化)的地方都走一个全局可配置的随机源,保证任何一次失败都能原样复现。第四,警惕"看起来在跑但时间不动":如果日志在刷而 sc_time_stamp() 不变,几乎一定是某个 THREAD 的循环里缺了有效 wait,或零时间事件互相触发形成了环。
最后要把内核语义和"性能"二字挂钩:SystemC 内核本身也要消耗宿主机时间,事件数就是仿真成本。同样的微架构,用逐信号、逐拍 METHOD 建模会产生海量事件,而用事务一次表达成块传输,事件数能降几个数量级——这不是风格偏好,而是决定模型能不能跑完真实负载的硬约束。C 模块让你看懂事件内核,D 模块的 TLM 则教你如何大幅减少事件数量,两者合起来才构成"又准又快"的工程基础。
九、从内核视角回看常见 CPU 模型结构
最后把内核机制和岗位 JD 里那些高频词直接对应起来,帮助建立"看到机制就知道用什么原语建"的反射。流水线各级在模型里通常实现为各自独立的 THREAD,沿时钟敏感,级间用 FIFO 或信号传指令 token;冒险检测和旁路网络是纯组合判决,适合 METHOD,输入是各级的有效位和寄存器号,输出是转发选择与插泡信号。分支预测器是无时间推进的查表/更新逻辑,更新发生在提交点,预测发生在取指点,两个点之间的时延用流水线上携带的预测结果表达。乱序执行的调度器是"事件 + 优先级队列":指令在操作数就绪事件触发时进入可发射集合,发射选择由 METHOD 式的仲裁完成,功能执行不占仿真时间,写回通过未来时间通知模拟延迟。保留站、重排序缓冲、load/store 队列都是带容量的通道或受保护容器,容量满产生的反压等价于下游 FIFO 写阻塞。
缓存与总线的模型则自然地把用法分成两层:缓存内部的 tag 查找、命中判定是即时组合逻辑(METHOD 或普通函数),而缺失后的填充是一次带未来通知的事件等待,缺失状态保持表(MSHR)用"地址→等待事件/请求上下文"的映射实现,多个缺失命中同一缓存行时合并到同一 MSHR——这个合并逻辑用普通 C++ 容器加事件就能表达,不必引入任何特殊框架。中断与异常本质是带优先级的异步事件:中断源在未来时刻 notify,流水线的最上层 THREAD 等待该事件并在安全点采样优先级;精确异常要求副作用在提交点才生效,这在模型里体现为"功能结果先写进重排序缓冲,提交时才更新架构状态"的纪律。可以看到,没有哪种 CPU 机制需要超出 C2 讲过的进程、事件、信号、通道四件套;难的从来不是 API,而是把真机机制(concepts/microarch 里讲的那些)正确地分解成"哪些即时求值、哪些等待未来、谁对谁反压"。养成这个分解习惯,比记忆任何调度器实现细节都更重要。
总结
SystemC 内核用"并发协程进程 + 事件通知 + evaluate/update/notify 三阶段 delta cycle",把硬件同一时刻的并发反应变成确定、可复现的微步序列;吃透"信号非即时可见"与"零时间通知"两条语义,就能避开绝大多数时序 bug。
延伸阅读
- 上一篇:硅前 CPU 性能建模为什么用 SystemC(C1)
- 下一篇:SystemC 端口、接口与通道(C3)
- 总线延迟建模:TLM-2.0 AT 近似定时四相握手(D3)
- 真机机制:CPU 微架构总览

两条最容易踩的语义:
sc_signal的写不是即时可见的。evaluate 阶段sig.write(v)后,其他进程本 delta 读到的仍是旧值,新值要到 update 阶段才提交。这精确对应 RTL 里"非阻塞赋值(Verilog<=)"的语义,避免组合反馈环在零时间里振荡。- delta cycle 不增加
sc_time_stamp()。同一时刻可跑很多个 delta(像真实硬件里信号在时钟沿后逐级稳定),直到没有可唤醒进程,内核才跳到下一个带时延的时刻。
工程价值是确定性与可复现:同一激励跑两次,事件顺序、IPC、延迟计数必须逐字节一致,否则性能回归无法比对。SystemC 用确定性调度替你消掉了"线程竞争"这类不确定性。