基本结构
与一般的第三方库不同的是,SystemC(下文简称SC)是会提供一个main()函数的,而且默认情况下是个强符号(这里的weak是我后来加上的)。这也就意味着用户不能定义自己的main(),因此从设计之初SC就不是用来构建某种API接口以供用户使用的,而是一个侵入式的异步编程框架,用来让使用者以接近于RTL的方式来编写代码。SC初始化的核心代码如下:
// sc_main.cpp
/* __attribute__((weak)) */ int
main( int argc, char* argv[] )
{
return sc_core::sc_elab_and_sim( argc, argv );
}
// ...
int
sc_elab_and_sim( int argc, char* argv[] )
{
// ...
try
{
pln();
// Perform initialization here
sc_in_action = true;
status = sc_main( argc, &argv_call[0] );
// Perform cleanup here
sc_in_action = false;
}
catch( ... )
{
// ...
}
}
SC的思路是接管可执行文件的入口点main()函数,要求用户在sc_main()中编写自己的逻辑来构造好相应的硬件模块对象以后,通过主动调用sc_start()以启动事件循环;事件循环将会在指定的时间之后结束,或者更优雅的做法是当所有事件都处理完成以后自然地退出进程。一个简单的例子如下:
#include <iostream>
#include <systemc>
SC_MODULE (hello_world) {
SC_CTOR (hello_world) {
SC_METHOD(say_hello);
}
void say_hello() {
std::cout << "Hello World.\n";
}
};
// sc_main in top level function like in C++ main
int sc_main(int argc, char* argv[]) {
hello_world hello("HELLO");
sc_core::sc_start();
return 0;
}
这样默认的使用场景限定了我们必须把所有东西编译成一个executable来用,这也就导致它只能固定执行一些基于文件接口的case。事实上,这个思路与硬件的仿真流程非常相似,因为本来SC就是Synopsys开发的……
事件循环
本质上,SC框架中的事件循环就是响应事件并在时间轴上不断前进的过程。首先贴一个具体的例子,后面将结合框架代码来进行解释。以下是一个与门的SC实现:
#include <iostream>
#include <systemc>
using namespace sc_core;
SC_MODULE(and2)
{
sc_in<bool> A, B; // 输入信号
sc_out<bool> F; // 输出信号
void do_and2() { F.write( A.read() && B.read() ); }
SC_CTOR(and2) // 构造函数
{
SC_METHOD(do_and2); // 将类的相应方法注册为SC_METHOD,即向SystemC kernel注册方法
sensitive << A << B; // 相当于声明always @(*):在信号发生变化的时候自动调用相应的方法
}
};
SC_MODULE(monitor) {
sc_in<bool> clk;
sc_in<bool> F;
SC_CTOR(monitor) {
SC_THREAD(thread);
sensitive << clk.neg();
}
void thread() {
while (true) {
std::cout << sc_time_stamp() << ": " << F.read() << std::endl;
wait();
}
}
};
SC_MODULE(testbench)
{
sc_in<bool> clk;
sc_out<bool> A, B;
void thread()
{
for (auto& p: {std::make_pair(false, false), std::make_pair(false, true),
std::make_pair(true, false), std::make_pair(true, true)}) {
A.write(p.first);
B.write(p.second);
wait();
}
sc_stop();
}
SC_CTOR(testbench)
{
SC_THREAD(thread);
sensitive << clk.pos();
}
};
// sc_main in top level function like in C++ main
int sc_main(int argc, char* argv[]) {
sc_clock clk("Clock", 1, SC_NS, 0.5, 0.0, SC_NS);
sc_signal<bool> A, B, F;
and2 a("And");
testbench tb("TestBench");
monitor mon("Monitor");
a << A << B << F;
mon << clk << F;
tb << clk << A << B;
sc_start();
return 0;
}
时间更新
在调用了sc_start()之后,经过一系列的初始化过程,SC最终会进入sc_simcontext::simulate(),这个方法里面是真正的主事件循环。主事件循环中的主要任务是管理事件和时间轴——模拟器中的时间轴会随着事件的发生而逐步向前推移。
主事件循环中的核心代码如下所示:
bool
sc_simcontext::next_time( sc_time& result ) const
{
while( m_timed_events->size() ) {
sc_event_timed* et = m_timed_events->top();
if( et->event() != 0 ) {
result = et->notify_time();
return true;
}
delete m_timed_events->extract_top();
}
return false;
}
void
sc_simcontext::do_timestep(const sc_time& t)
{
// ...
m_curr_time = t;
m_change_stamp++;
m_initial_delta_count_at_current_time = m_delta_count;
}
void
sc_simcontext::simulate( const sc_time& duration )
do {
// 处理当前时间点上的所有SC_METHOD和SC_THREAD所产生的事件
crunch();
// ...
// 接下来准备将时间轴向前推进,也就是更新m_curr_time这个时间戳
t = m_curr_time;
do {
// 调用next_time()取出下一个事件所对应的时间戳
if ( !next_time(t) || (t > until_t) ) {
if ( (t > until_t) || m_prim_channel_registry->async_suspend() ) {
// requested simulation time completed or no external updates
goto exit_time;
}
// received external updates, continue simulation
break;
}
// 将m_curr_time更新到下一个时间戳
if ( t > m_curr_time ) do_timestep(t);
// 当前时间已经更新,触发所有应该于此时发生的事件
do {
sc_event_timed* et = m_timed_events->extract_top();
sc_event* e = et->event();
delete et;
if( e != 0 ) {
e->trigger();
}
} while( m_timed_events->size() &&
m_timed_events->top()->notify_time() == t );
// 如果更新时间以后,没有产生任何可执行的事件,那么时间线就可以继续往前走
} while( m_runnable->is_empty() );
} while ( t < until_t ); // hold off on the delta for the until_t time.
// ...
}
原理上,管理时间轴上的离散事件的最直观方式就是用优先队列来维护一系列事件所对应的时间戳,然后从中取出事件来执行,事实上SC也就是这样实现的。从更新时间戳的next_time(t)方法的具体实现来看,很明显这里就是将当前时间给更新到优先队列中的下一个事件所对应的时间点,然后再去触发这个时间点所对应的event,看能否产生可执行的事件。
事件处理
上文已经提到,在每一个时间点上,主事件循环会调用sc_simcontext::crunch()方法来处理所有的可执行的任务(即所有就绪的SC_METHOD和SC_THREAD),这样就会更新当前时间点上的各种事件和状态。这个方法里面其实也是在执行一个小的事件循环,但这个循环的过程中不会发生时间线的推进。也就是说SC在处理process事件的时候只负责执行组合逻辑,而时序逻辑可以由组合逻辑结合wait()调用来实现。
sc_simcontext::crunch()中的事件循环大致上可以分为三个阶段,其核心代码如下:
inline void
sc_simcontext::crunch( bool once ) {
while ( true ) {
// EVALUATE PHASE
// ...
while( true ) {
// 执行所有的SC_METHOD
m_runnable->toggle_methods();
sc_method_handle method_h = pop_runnable_method();
while( method_h != 0 ) {
empty_eval_phase = false;
if ( !method_h->run_process() ) { goto out; }
method_h = pop_runnable_method();
}
// 执行所有的SC_(C)THREAD,逻辑类似,省略
// ...
// 已经执行的任务可能会触发新的任务并投入就绪列表。但如果没有,就退出内层循环。
if( m_runnable->is_empty() ) { break; }
}
// UPDATE PHASE
// 这里其实就是调用了一个全局registry的成员函数,用来更新所有sc_signal的状态
// 这个过程会产生一些可以被触发的事件并挂到m_delta_events里面
m_prim_channel_registry->perform_update();
SC_DO_PHASE_CALLBACK_(update_done);
// ...
// NOTIFICATION PHASE:
// 如果有事件可以触发,就触发他们
int size = m_delta_events.size();
if ( size != 0 ) {
sc_event** l_events = &m_delta_events[0];
int i = size - 1;
// 按照逆序触发所有事件,此时会将新的任务投入就绪列表m_runnable
do { l_events[i]->trigger(); } while( -- i >= 0 );
m_delta_events.clear();
}
// 如果NOTIFICATION阶段完成以后,找不到更多的任务可以运行了,就退出循环
if( m_runnable->is_empty() ) { break; }
// ...
}
}
- EVALUATE:执行就绪队列中所有当前可以执行的任务,直到没有任何任务可以执行。在软件上,“任务”对应于所有非阻塞状态的用户态线程以及被触发的
SC_METHOD回调。 - UPDATE:更新所有在evaluation阶段中数据发生变化的信号(
sc_signal),将其对应的事件(sc_event)加入一个队列m_delta_events。 - NOTIFICATION:触发队列中的事件。此时由于事件所产生的回调,导致又有新的任务可以投入执行,此时将这些任务加入就绪队列,然后回到步骤1,重新执行可以执行的逻辑。但如果没有新的任务可以执行,那么就退出事件循环。
系统初始化期间,也就是在用户编写的sc_main()上下文中,在执行and2的构造函数的时候,会将系统注册do_and2()函数,产生一个sc_method_process对象。这个对象本质上就是一个用户态线程描述符,它会被丢进就绪任务队列中等待执行。当sc_start()开始以后,所有就绪列表中的用户态线程会以一个随机的顺序开始运行:所有的SC_METHOD默认会被执行一次,而所有的SC_THREAD会根据它的sensitive list在适当的时间被触发启动,随后就一直执行自己的循环。
在某一个时刻,例如1ns开始的时刻,整个系统中事件生成和处理的过程如下:
- 在主事件循环中,由于时间线的推进而使得
testbench的SC_THREAD结束了自己的wait()被激活,因而这个任务被加入就绪列表等待执行。 - 在
sc_simcontext::crunch()的evaluation阶段会遍历就绪任务列表,执行所有能够执行的任务——也就是这个SC_THREAD。 - 该任务的执行导致两个
sc_signal:A和B发生了改变,因此在update阶段会将相应的signal数据进行更新,在此过程中会把这些发生变化了的signals所对应的事件(sc_event)挂到一个名为m_delta_events的队列里面去。 - 在notification阶段会去检查这个
m_delta_events队列,并按照倒序对其中的所有事件进行触发(trigger())。 - 此时,由于signal的更新就会导致
and2里的SC_METHOD受到触发,变为可执行状态加入就绪队列。这个过程是在当前时刻发生的,相当于组合逻辑。 - notification阶段结束以后第一轮循环结束,此时发现就绪队列非空,因而回到evaluation阶段,再去执行这个
SC_METHOD。 SC_METHOD执行完以后修改了信号F,但由于并没有哪个任务在监听这个信号,因此不会产生新的可执行任务。此时循环结束。
可以看到,SC的事件循环kernel是一个非常精简的实现,在框架的最底层,只有两种事件会导致用户态线程的挂起:wait()调用,以及等待sc_event事件——而wait()调用本质上也是基于sc_event来实现的。因此,通过底层提供的原语作为基础,SC包装出了一整套层与层之间解耦得非常彻底的上层框架,从而实现了功能丰富的接口组件,并且依托于C++语言的强大表达能力,从而适用于各种级别的硬件建模。