电子说
不少同学很好奇在SOC环境里面C代码是怎么执行的?
是通过DPI实现SV和C的交互,然后用 SV的task将C的数据转成对应的总线数据下发到各个外设?
DPI 调用例子
是在verilog里面调用PLI获取C里面的内容?
PLI调用例子
还是通过TLM1.0 或者TLM2.0 完成C和verilog 的交互?
TLM2.0 使用例子
实际上,以上三种都不是!
在SOC验证环境中,需要仿真一颗芯片真实的工作状态。通过上面三种手段验证不到CPU boot的过程,CPU处理 interrupt的过程,芯片进出低功耗的过程。
在SOC环境中怎么模拟芯片的工作过程呢?
下面是一个典型的RISCV CPU。在这个系统中,CPU会通过指令总线获取执行的程序指令,然后通过数据总线访问存储的数据和外设等。
在下面的这个系统中,我们将RISCV的指令总线和数据总线作为两个master 挂在AHB总线上,将程序指令存储在SRAM中,当SOC启动时,会通过指令总线访问SRAM获取指令信息。
CPU中拿到指令后会进行解码,然后通过执行单元执行解码的指令,如果需要用到外部的存储数据,则会通过数据总线访问SRAM获取存储的数据内容。如果解码内容配置外设寄存器,则通过数据总线访问外设,对外设进行配置等等。
这里需要注意的是程序代码放在SRAM里面,一些数据内容也是分在SRAM中,假如中间不作分割,那么指令和数据会混在一起,导致RISCV在执行程序的时候会跑飞。所以我们在后面编译指令的时候,需要对memory空间进行分割。
通过上面的描述,我们大概清楚了CPU是怎么工作起来的,但是这个和我们的C程序有什么关系呢?
CPU执行的是机器码指令,这些指令是由一个个特定数据和摆放的格式决定的。
下面是32bit RISCV的部分指令格式。
在这里R,I S,B U,J分别代表6种不同的指令。
R-format for register-register arithmetic/logical operations
I-format for register-immediate arith/logical operations and loads
S-format for stores
B-format for branches
U-format for 20-bit upper immediate instructions
J-format for jumps
R是寄存器类型指令, I是立即数类型指令,S是存储类指令,B是分支类指令,U是高20bit立即数指令,J是跳转指令。
我们以简单的立即数加法运算为例,比如我想做一个 a0= s0+16 这样一个立即数加法运算,伪代码就是 add a0,s0,16 。这个案例中我们用的是立即数类型的指令。
根据上述表格,该指令格式为
funct3,opcode又是什么呢?通过查询 riscv 手册可以查到以下结果。
这个立即数加法最终编译成机器码 0x01048513
将这个机器放在地址0x29a 的位置。
我们把这些机器码放在SRAM里面,CPU看到拿到 0x01048513 就可以解码出来这是一个a0= s0+16 的立即数操作。
到这里,我们大概知道底层的CPU是怎么执行的,现在要解决的问题是如何将C编译成机器码。
下面这个图是C语言编译成机器码的过程。
C语言首先编译成汇编语言,再由汇编语言编译成机器指令,最后通过链接形成目标机器指令。
我们以SOC3.0的环境为例子,看看这个过程怎么执行的。
首先我们看SOC3.0的环境里面都有哪些文件。
crt0.S
"crt"代表"C runtime"(零表示"一切的开始")。
crt0是一组执行启动例程,编译到程序中,在调用程序的主函数之前执行任何必要的初始化工作——它是一个基本的运行时库/运行时系统。crt0的工作取决于程序的语言、编译器、操作系统和C标准库的实现。
在SOC3.0里面crt0.S 是汇编语言写的。
这段代码做什么事情呢?
异常处理程序:
default_exc_handler 是一个通用的异常处理程序,似乎被设置为各种异常的处理程序,比如外部中断、非法指令和系统调用 (ecall)。
还有其他异常向量的占位符(nop指令),表明它们可能以类似的方式处理。
复位处理程序:
reset_handler 将所有寄存器设置为零,并通过加载堆栈起始地址来初始化堆栈。
清除BSS段:
它通过用零填充来清除BSS(由符号开始的块)段。通常这样做是为了确保所有未初始化的全局和静态变量都以零值开始。
跳转到主函数:
最后,它跳转到 main 函数,并将 argc 和 argv 设置为零。
注意我们C 代码的main 函数的命名不是天生就是这么命名的,是在这里给定的。
异常向量:
.vectors 部分定义了异常向量。它似乎对各种异常使用默认的异常处理程序,还有一个重置向量指向 reset_handler。
2. C函数
以spi1_test test为例子
common.c ,这里定义了一些打印字符串和读写寄存器的函数。
spi1_test.c ,这里实现对spi1这个IP的配置及自我检查。
3. 链接文件 link.ld
我们在上文说了,在SRAM里面如果不对程序存储空间和数据存储空间进行分割,那么CPU执行的时候很可能跑飞。为了组织内存分配,需要一个链接文件进行配置。
link.ld是一个链接脚本(linker script),用于指导链接器如何组织程序在内存中的布局。具体来说,它包含了以下关键信息:
内存布局:
内存被划分为两个区域:rom(48 kB)和stack(16 kB)。
rom从地址0x00000000开始,stack从地址0x0000C000开始。
堆栈信息:
_min_stack设置为0x2000(8 kB),表示要保留的最小堆栈空间。
_stack_len是stack区域的长度。
_stack_start是堆栈的起始地址。
各个段:
.vectors 段包含中断向量,位于rom的开头。
.text 段包含程序代码。构造函数和析构函数列表用于处理C++。
.rodata 段包含只读数据。
.shbss 段在rom中对齐并放置。
.data 段包含已初始化的数据。
.bss 段包含未初始化的数据。
.stack 段确保堆栈有足够的空间。
特殊段(NOLOAD):
.stack 段标记为 NOLOAD,意味着它不会加载到最终的二进制文件中。它只是为堆栈保留空间。
.stab 和 .stabstr 段也被定义,但标记为 NOLOAD,表明它们是调试信息。
有了上面这些文件,我们看看Makefile 怎么将spi1_test.c 编译成机器码的。
第一步,通过riscv提供的工具链将C和汇编.S的文件编译成目标文件。
--->
第二步,将生成的.o目标文件链接成elf文件
ELF(Executable and Linkable Format)是一种用于可执行文件、目标文件、共享库和核心转储文件的标准文件格式。它是一种二进制文件格式,设计用于在多种操作系统上支持可执行文件和可重定位代码的交互性。
第三步,用elf文件生成 二进制文件(机器码) .bin文件
到这里我们已经产生了机器码的文件.bin,按理说CPU拿这个文件就可以执行了。但是在我们环境里面,我们需要将机器码放到verilog 的sram memory里面去。所以我们还做了第四步。
第四步,将.bin 文件转换为一个包含二进制数据的Verilog内存初始化文件。
通过上面四步,我们实现了C到机器码的转换。
我们将生成的spi1_test.vmem 放到SOC环境中,sram的memory 通过readm 读进这些机器码。然后通过仿真,就可以模拟SOC执行的过程。
按理说到里面我们应该结束今天的文章,但是当我们回头看看我们环境似乎还缺些什么。
没错那就是 机器码的反标,当我们debug cpu的时候,cpu记录当前执行的指令和指令行数,我们通过这些信息可以定位具体在操作哪条机器码,但是我们怎么样才知道当前的机器码对应是C代码里面的拿哪段内容呢?
这就需要机器码的反标,在我们SOC3.0的环境里面,我们通过下面指令完成机器码到C的反标。
我们看看效果。
非常方便。
以上是我们文章的所有内容,上述所列案例在我们SOC3.0里面都有,欢迎感兴趣的小伙伴咨询。
关于 SOC3.0,小伙伴们可以点这里。
SOC3.0有哪些东西?
或者直接联系梨果。
审核编辑:刘清
全部0条评论
快来发表一下你的评论吧 !