GDBWave–一个基于模拟后波形的RISC-V GDB调试服务器

2022-02-22 11:25:40

小型软核CPU是将控制和管理操作添加到FPGA设计中的好方法。在不改变RTL的情况下迭代不同版本的C代码是非常容易的,而且不需要重新合成也可以节省大量时间(如果您知道如何高效地更新RAM内容的话)这就是为什么我所有爱好的FPGA项目都有一个VexRiscv RISC-V CPU。

我最近写了一篇关于如何通过添加JTAG接口将GDB连接到实际硬件上运行的VexRiscv CPU的文章。您可以在模拟中做类似的事情,将GDB连接到OpenOCD,OpenOCD通过伪造的JTAG接口与模拟对话。这篇博文不是关于这个的。

相反,我想讨论的是,首先模拟一个RTL设计,其中包含一个软CPU,并在模拟完成后调试在该软CPU上运行的固件。

我的设计中通常没有JTAG接口:我常常懒得把USB JTAG加密狗连接到FPGA板上。但我一直在做的是查看模拟波形,并试图找出CPU在模拟中某个特定点的工作。或者反过来说,我试图弄清楚CPU执行特定代码行时硬件在做什么。

这是一个乏味的过程,几乎不可能更全面地了解CPU中正在发生的事情,因为没有简单的方法来转储程序调用堆栈、变量、寄存器等的内容。

我想知道其他人是如何处理这种调试的,并发出了以下推文:

问题:您正在模拟运行C程序的RISC-V CPU。您正在录制VCD(或FST)跟踪。波形中的指令地址和C代码行之间有什么关联?

- Tom Verbeure(@汤姆维尔贝雷)2021年11月3日

使用GCC工具链的一部分addr2line或llvm Symboler将PC值直接转换为C源代码文件和行号。

通过创建GTKWave translate过滤器来扩展前面的方法,以便文件和行号在波形查看器本身中显示为ASCII编码的波形。

有人指出,Quartus的SignalTap可以选择在波形中显示Nios II软CPU的活动汇编指令。这在我使用Nios CPU时肯定很有用。这也是可以为RISC-V CPU做的带有翻译过滤器的事情,但这并不是我想要的。

@whitequark建议将GDB服务器添加到CXXRTL模拟环境中。这是通过模拟JTAG接口将GDB连接到实时模拟的一种变体,但它仍然需要一个交互式模拟会话。

最后一个建议让我想到将波形数据输入GDB服务器:

对但最终结果与使用OpenOCD和jtag_vpi是一样的,对吗?一台读取VCD文件的GDB服务器怎么样?

- Tom Verbeure(@汤姆维尔贝雷)2021年11月3日

两个月后又过了几个小时,结果是GDBWave:一个基于模拟后波形的RISC-V GDB调试服务器。

在我的VexRiscv、OpenOCD和Traps博客文章中,我展示了调试器IDE和实际CPU之间的所有步骤。让我们只说,对于GDB来说,图片不那么复杂:

在远程调试环境中,GDB使用GDB远程串行协议(RSP)与链接到被测设备的外部实体进行对话。这种外部实体可以有两种形式:

GDB远程存根(或GDB存根)是一段与正在调试的程序链接的调试代码。存根代码通常在出现某种调试异常、中断或陷阱时被调用,此时它接管并开始与GDB通信。

这是调试没有操作系统且无法使用CPU硬件电路调试功能的嵌入式系统的常用方法。(例如,因为CPU的JTAG端口在PCB上不可用。)

GDB服务器是一个独立的程序,没有链接到必须调试的程序的一部分。它可以是像OpenOCD这样的中间程序,将RSP命令转换为JTAG命令,以控制CPU的硬件电路内调试逻辑,也可以是一个单独的进程,使用目标机器上的操作系统功能调试另一个程序。后者的一个好例子是Unix类型操作系统中的ptrace功能。在这些系统上,系统本机GDB通常带有一个标准的gdbserver,它允许您远程调试Unix程序。

从GDB客户端的角度来看,GDB存根和GDB服务器的行为是相同的:它们接收高级RSP请求,如“step”、“continue”、“read CPU registers”、“write to memory”或“set breakpoint”,使这些请求适应CPU运行的环境,并返回请求的数据(如果有的话)。

如果您想让GDB相信您记录的CPU模拟波形是调试中实际运行的CPU,您需要编写自己的GDB服务器:

这里有相当多的通用样板,还有大量的开源GDB存根,您可以根据自己的喜好进行修改。2我在下面的参考文献中列出了其中一些。

GbWAVE是用C++编写的,原因有两个:FST库,用C编写,没有任何绑定到流行的脚本语言,我也只是想尝尝一些新的C++特性,自从我上次使用它以来,已经添加到语言中了,15多年前…

我选择了mborgerson/GDBSub,这是一个轻量级的实现,旨在使其易于支持您自己的CPU体系结构。它非常小,甚至不支持断点,但这些断点很容易添加。

模拟一个包含嵌入式软核RISC-V CPU的设计,比如VexRiscv。

告诉GDBWave设计中的哪些信号可用于提取处理器状态:CPU程序计数器,以及(可选)CPU寄存器文件的内容,以及到内存的事务。

将GDBWave作为一个GDB服务器启动,它假装是一个真正运行的CPU系统,具有调试功能。

发出GDB命令,就好像你在处理一个真正的CPU:断点、观察点、代码中的行步进、检查变量等等。如果你愿意,你甚至可以回到过去。

一个非常好的额外功能是将GDBWave链接到你的GTKWave波形查看器,这样当你的GDBWave CPU遇到断点时,GTKWave会自动跳转到波形查看器中的那个时间点。然而,没有明显的方法可以通过外部程序控制GTKWave。

请注意,在模拟CPU中不启用任何硬件调试功能的情况下,所有这一切都是可能的:您可以在picorv32或获奖的位串行SERVRISC-V CPU上进行调试,它仍然可以工作。唯一的最低要求是,您可以在RTL和波形文件中找到正确的信号,以提取已成功执行和失效指令的程序计数器值。3.

无法更改正在调试的程序的流程。这是对已经完成的模拟中的数据运行调试器的一个明显的首要原则结果。

GDBWave目前仅适用于具有单个指令的CPU,以便执行管道。将GDBWave支持扩展到更复杂的CPU体系结构并不困难,但这超出了这个圣诞假期项目的范围。

这篇博客文章讨论了从模拟波形中提取的处理器跟踪,但如果您设计的CPU系统具有RISC-V处理器跟踪规范中描述的指令跟踪功能,那么您也可以从实际硬件中收集这些数据。

在业余爱好中,几乎每个人都将仿真波形作为VCD文件转储,这是Verilog规范中的标准格式,几乎所有现有的仿真和数字设计调试工具都支持这种格式。然而,GDBWave并不直接支持VCD。

这有一个很好的理由:得到普遍支持是一种糟糕的波形格式的唯一优点。

即使要从数千个或更多信号中提取信号值,也需要读取完整文件。

如果不先处理之前所有时间步长的值,也无法提取给定时间范围的值。

当你在一家负担得起的公司工作时,你可能正在使用Synopsys Verdi调试数字设计。Verdi配备了FSDB波形格式,它没有VCD的缺点。不幸的是,这种格式是专有的,据我所知,还没有经过逆向工程。如果想编写从FSDB文件提取数据的工具,需要链接Verdi安装附带的预编译二进制库。

幸运的是,有一个开源的替代方案:FST格式是由GTKWave的作者Tony Bybell开发的。它修复了VCD格式的所有缺陷。FST文件格式没有正式规范,但GTKWave手册中包含的“数字波形压缩有效方法的实现”一文在很大程度上描述了设计目标及其实现方式:

这是因为它使用了两阶段压缩方案:在第一阶段,它将信号值的变化编码为增量值。在可选的第二阶段,第一阶段的输出通过标准LZ4或GZIP方法进行压缩。

如果需要在大仿真的中间访问数据,它只会读取包含所需数据的块,并且跳过任何以前出现的数据。

压缩速度非常快,与转储VCD文件相比,模拟速度只降低了一小部分。FST库甚至支持多线程。对于转储大量数据的超大设计,可以在不同的CPU内核上并行压缩多个数据块。(请注意,在较小的情况下,这会稍微慢一点。只有在转储数十万个或更多信号时,这才有帮助。)

在从中提取数据之前,不需要处理整个文件。

如果您正在运行一个长时间运行的模拟,并且希望快速检查是否一切仍按计划运行,那么这非常有用。

没有正式的格式规范,而且,基于对GTKWave GitHub项目的讨论,人们不应该期望有一个正式的格式规范。其他文档仅以源代码中的注释或作者对其他GitHub问题的注释的形式存在。

有一个库可以读取和写入FST文件,但没有关于如何使用它的文档。您需要通过检查读写FST文件的现有实用程序来了解工作原理。

实际上,这并不难。我创建了FSTPrPro,一个C++类,它具有有限的功能,我需要从FST文件中提取数据。

没有一个独立的FST库具有单独的版本跟踪等功能。您应该从GTKWave源代码树中提取代码。

由于相关代码已经存在于自己的目录中,因此提取代码很容易。但由于缺乏版本,无法跟踪应用了哪些错误修复。

令人费解的是,FST不支持不以位0开头的向量信号:在RTL中定义为MySignal[31:2]的向量被另存为MySignal[29:0]。对于绝大多数设计来说,这不是一个问题,但考虑到它只需要在信号声明中增加一个参数,这种省略让我更加恼火。

不过,使用FST格式的好处远远大于缺点,尤其是在处理大型波形数据库时。

Verilator和Icarus Verilog支持FST开箱即用。当然,GTKWave也是。如果您的模拟工具无法生成FST文件,则始终可以使用GTKWave附带的vcd2fst转换实用程序。

如果您将FST格式用作Verilator测试台的一部分,请确保在每个模拟周期后不要对VerilatedFstC跟踪对象调用flush()方法。我在我的一个测试台上做了这个,和使用VCD相比,我的模拟速度下降了20倍!

以下几节将介绍GDBWave的一些实现方面。其中一些是我自己用的,这样我就不会忘记为什么事情是以某种方式进行的。如果您感兴趣的只是在自己的项目中使用GDBWave,那么您可以安全地跳过这一步。

正如前面提到的,我创建了FSTPrPro,一个围绕本地GTKWAVE FSTAPI的瘦C++包装器。h图书馆。

GDBWave至少需要知道哪些指令已被CPU成功执行。它通过跟踪程序计数器来实现。

在VexRiscv的情况下,我使用所有VexRiscv配置中存在的两个信号:

断言lastStageIsValid时,LastStageEPC包含已完成执行的指令的程序计数器值。完美的

首先,当我在波形数据库中行进时,我保存这两个信号的最新值:

if(signal->;handle==cpuTrace->;pcValid.handle){cpuTrace->;curPcValidVal=valueInt;return;}if(signal->;handle==cpuTrace->;pc.handle){cpuTrace->;curPcVal=valueInt;return;}

其次,当我看到时钟的下降沿时,如果有效信号被断言,我会记录程序计数器。所有程序计数器值都存储在向量数组中,以及它们更改的时间戳。

if(signal->;handle==cpuTrace->;clk.handle&;valueInt==0){if(cpuTrace->;curPcValidVal){PcValue pc={time,cpuTrace->;curPcVal};cpuTrace->;pcTrace。向后推(pc);}

为什么时钟会下降?因为在一个干净、同步的设计中,所有常规信号都会在时钟的上升沿发生变化,你可以确定所有信号都会在下降沿保持静止。你不必担心时钟是在功能信号改变之前还是之后立即上升。它只是让事情不那么容易出错。

如果要跟踪从未存储到内存中的局部变量的值,了解CPU寄存器文件内容是必不可少的。例如,紧For循环的计数器变量只存在于CPU寄存器中的可能性非常高。

要知道寄存器文件的状态,只要在模拟开始时知道完整寄存器文件的初始状态,只记录对它的写入就足够了。但即使不知道初始状态通常也没什么大不了的,因为大多数CPU启动代码都会通过写入适当的值来初始化寄存器。

从FST波形中提取寄存器文件写入的代码与提取程序计数器更改的代码一样简单。

最后,还有CPU操作的RAM内容的知识。GDB发出内存读取有两个原因:了解存储在RAM中的变量的值,检查arunning程序的调用堆栈,以及分解正在调试的代码。

内存内容也可以从对内存的写入中派生出来,但是,与寄存器文件相反,了解RAM的初始状态也非常重要。这是因为用于存储CPU指令的FPGA RAM通常在通电后预加载,并且从未写入。

为了获得RAM的初始状态,我的固件Makefile创建了一个包含RAM内容的二进制文件:

if(!memInitFilename.empty()){printf(";正在加载meminit文件:%s\n";,memInitFilename.c#str());ifstream initFile(memInitFilename,ios::in | ios::binary);memInitContents=vector<;char>;((std::istreambuf#迭代器<;char>;(initFile)),std::istreambuf迭代器<;char>;)

将来,我可能会扩展GDBWave来直接读取ELF文件,但目前的方法对我来说已经足够好了。

注意,也可以通过CPU指令获取总线上的observingread事务来迭代地计算程序RAM的内容。唯一的问题是你不能分解从未被CPU执行过的部分。在实践中,我不认为这是一个主要问题:在GDB中查看低级汇编代码不是我经常做的事情,尤其是对于从未执行过的代码。不过,大多数情况下,你都可以访问你试图调试的程序的二进制文件,所以我没有遇到麻烦,但是,看看指令读取事务…

一旦与GDB客户端建立了TCP/IP连接,服务器就会发送一个RSP信号包,通知客户端CPU的当前执行状态。在GDBWave中,执行状态为暂停。之后,GDBWave进入一个无休止的循环,等待RSP命令,并在它们到达时执行它们。

在GDB客户机中,“step”命令移动到下一条C指令。在RSP协议中,它只执行一条汇编指令。

在收到这个陷阱之后,GDB客户机总是查询寄存器文件的内容,因此在step操作期间准备好这些数据是有意义的。

寄存器文件的状态是通过将寄存器文件的写入操作重放到当前指令的点来导出的。现在,这段代码效率非常低:在每一个指令步骤之后,我从一开始就一次又一次地重放所有寄存器的寄存器写入。优化这段代码很容易。

在此之后,行为就像“步骤”:寄存器文件的状态被更新,陷阱被发送到上游的GDB。

RSP断点不仅仅用于使用GDB breakpoint命令显式设置的断点。在使用下一个命令时也会用到它们:GDB在程序的下一行设置一个临时断点。

但即使是明确的断点也一直在设置和清除。我不太清楚GDB为什么会这样做,但我认为这与让各种奇怪的嵌入式系统配置都能正常工作有关。

GDB区分了软件断点和硬件断点。硬件断点是CPU内部的专用硬件资源。如果有的话,通常只有少数几个,只有在您使用“hbreak”命令明确请求时,GDB才会使用它们。软件断点通常是通过在指令RAM中用某种陷阱指令替换常规指令来实现的。(在RISC-V的情况下,它是EBREAK指令。)一旦触发该陷阱,GDB服务器将在继续执行之前用原始指令替换陷阱指令。您可以有无限数量的软件断点。实际上,硬件断点仅在调试ROM中的代码时使用。

综上所述,当GDB要求服务器设置软件断点时,它所关心的只是断点由服务器处理。GDBWave维护一个关联数组,该数组由程序计数器索引,包含所有活动断点。

每当CPU停止时,GDB就会尝试读取CPU寄存器。在执行步骤或继续操作后,寄存器文件的状态已经更新,因此GDBWave需要做的是返回请求的数据。

读取内存部分是另一个非常流行的GDB请求:它用于获取变量的值、调用堆栈或正在被反汇编的代码的汇编指令。

在GDBWave中,获取特定时间戳的内存位置值的实现方式与寄存器文件的实现方式非常相似:从二进制文件加载的初始值开始,所有内存写入都应用到

......