在包含CPU的仿真环境中,如果要在C程序测试中通过打印做一些调试,通常需要重新实现 printf函数。在介绍CPU仿真环境中的printf实现之前,首先简单了解一下Linux系统和嵌入式系统中的printf实现。
Linux系统的打印实现
- GCC编译器链接glibc标准库
- 操作系统为程序创建进程并分配内存空间
- 调用sys_write等系统调用函数
- 数据格式化输出至标准I/O的缓存区
- 输出映射至console,转换输出到显示器或终端
其中sys_write函数声明在/usr/src/kernels/<内核版本>/include/linux/syscalls.h
文件中,感兴趣的同学可以自己研究下。
asmlinkage long sys_write(unsigned int fd, const char __user *buf, size_t count);
嵌入式系统的打印实现
在嵌入式系统中,由于存储器资源有限,需要小型运行库(如newlib),且一般没有显示终端,所以需要将输出重定向以支持printf函数。
- 调用newlib或其他嵌入式C运行库中的printf库函数
- 实现newlib的桩函数write();或在其他系统中重写printf调用的库函数
- 调用UART驱动并通过串口将打印信息重定向至PC主机的COM接口
- 在PC主机的串口调试助手中显示输出信息
标准输入函数scanf函数同样可由UART接口获取输入实现,而printf和scanf最终会调用fputc和fgetc进行字符串输出/输入。在某板级验证运行库中,重定向了fputc和fgetc,并通过UartPutc和UartGetc实现:
// retarget
int fputc(int ch, FILE *f) {
return (UartPutc(ch));
}
int fgetc(FILE *f) {
return (UartPutc(UartGetc()));
}
// uart stdout
unsigned char UartPutc(unsigned char my_ch) {
while ((CMSDK_UART0->STATE & 1)); // Wait if Transmit Holding register is full
CMSDK_UART0->DATA = my_ch; // write to transmit holding register
return (my_ch);
}
unsigned char UartGetc(void) {
while ((CMSDK_UART0->STATE & 2)==0); // Wait if Receive Holding register is empty
return (CMSDK_UART0->DATA);
}
RTL仿真环境的打印实现
在SOC仿真环境中,虽然同样可以用UART输出重定向实现printf,并通过UART monitor采样和打印信息,但这样仿真速度很慢,因此通过以下方式实现了printf:
- 调用仿真C运行库
- 重定向fputc,将字符串ASCII码写入指定地址
- 在仿真testbench/monitor中实现对指定地址写入的监测
- 将写入字符串用系统打印函数输出到终端或保存到log文件
CPU的打印实现
在某RISC-V CPU的仿真环境中,重新实现了io lib,printf函数最终会写到MSCRATCH(机器模式中断数据备份寄存器)并在仿真tb中监测和打印,其具体实现如下:
#include <stdarg.h>
#include <stdlib.h>
int printf(const char *format,...) {
int n;
va_list arg_ptr;
va_start(arg_ptr, format);
n=vprintf(format, arg_ptr);
va_end(arg_ptr);
return n;
}
通过va_start和va_end可以获取可变参数列表,然后调用vfprintf发送格式化输出到流stream中。在这之后还会调用 __v_printf等多个函数,对stream进行格式化处理,计算字符串长度,并通过函数指针调用 __stdio_outs函数。
int __stdio_outs(const char *s,size_t len) {
os_critical_enter();
for(int i = 0; i < len; i++) {
fputc(*(s+i), stdout);
}
os_critical_exit();
return 1;
}
最后调用fputc完成字符串输出。这里通过write_csr宏内联汇编重定向实现了fputc:
#define write_csr(reg, val) ({ \
if (__builtin_constant_p(val) && (unsigned long)(val) < 32) \
asm volatile ("csrw " #reg ", %0" :: "i"(val)); \
else \
asm volatile ("csrw " #reg ", %0" :: "r"(val)); }
int fputc(int ch, FILE *stream) {
write_csr(mscratch, ch);
}
在tb中,实现了一个CPU monitor,监测对MSCRATCH寄存器的写入,然后由仿真工具调用系统打印函数 $fwrite() 将打印字符逐个输出到log文件并更新流。
initial begin
file_cpu = $fopen("cpu_printf.log", "w");
end
always @(posedge `U_CPU.pll_core_cpuclk) begin
if(`CPU_S_EN == 1'b1 ) begin // mscratch_local_en
$fwrite(file_cpu, "%c", `CPU_S_VAL); // mscratch_value[31:0]
$fflush(file_cpu);
end
end
Memory中数据的打印方式
在RTL仿真环境中,虽然支持printf,但调用打印函数后生成的程序代码较大。实现printf的目的是为了支持C语言标准库和方便写C测试程序。如果需要连续打印一些memory内的数据进行debug,考虑到eda仿真的效率,可以通过SystemVerilog后门访问取代printf函数调用的方式。
在验证环境中,用SV实现了对SOC中各ram的后门读写方法,其特点在于不通过总线而是直接根据hierarchy路径对DUT内部寄存器或存储器进行读写操作,不消耗仿真时间。以CPU ram的后门读取方法为例:
task cpu_tcm_bkdoor_read(bit[31:0] raddr, ref bit[31:0] rdata);
rdata = `U_CPU_RAM[raddr[11:4]][raddr[3:2]];
$display("TCM MEMORY[%h][%h] read data %h\n",raddr[11:4],raddr[3:2],rdata);
endtask
然后,自定义了一段地址空间LOG_PRINT_ADDR ,通过对指定地址写入数据实现传参和仿真方法调用,以下是C调用仿真接口的函数实现。
void print_mem(uint32_t start_addr, int byte_num); {
OUTP32(LOG_PRINT_ADDR + 0x4, start_addr);
OUTP32(LOG_PRINT_ADDR + 0x8, byte_num);
OUTP32(LOG_PRINT_ADDR, 1); // write 1 at last to trigger
}
在仿真环境中,实现了一个monitor以监视对这段地址空间的写入,根据写入数据调用对应的SV task并传参。对于print_mem方法,会解析地址参数并选择对应的后门读取方法进行调用,然后将读取数据打印至run目录下的一个log文件中,这里的SV实现可以参考上节cpu monitor实现。参照以上方法,仿真环境还可提供许多其他仿真控制接口给C,例如仿真结束控制、外设VIP控制等,以此实现仿真环境内CPU控制外层仿真环境。