期末实验:实现 RISC-V 硬件断点

  • Deadline: 2025年12月30日16:00:00 加权系数:原始分数*100%
  • Hard Deadline: 2026年1月3日23:59:59 加权系数:abs(最终分数-原始分数)*60% + 原始分数

背景介绍

在复杂的软件系统开发中,调试是不可或缺的关键环节。调试器(如 GDB)为开发者提供了深入观察程序内部状态的能力,其中,调试事件能够帮助开发者在程序运行时捕捉特定的状态变化。常见的调试事件包括:

  • 断点(Breakpoint):程序执行到特定的指令地址
  • 观察点(Watchpoint):特定的数据(如寄存器、内存地址或表达式的求值结果)发生变化(通常是因为写入指令)
  • 信号(Signal):程序接收到特定的操作系统信号
  • 指令执行(Instruction Count):程序执行了一条或多条指令

在 PA 中,你已经实现了一个 RISC-V 全系统模拟器 NEMU,并为其配备了大量用于调试的基础设施,例如 PA1 中实现的调试器 sdb,PA2 中实现的 {i,f,m,d}trace,PA3 中实现的 strace 等。

然而 sdb、itrace 等调试功能目前都是由 NEMU 实现的,想使用这些功能必须要能够直接和 NEMU 交互,被 NEMU 模拟的程序则无法调用这些能力。想象一下,如果你现在需要实现一个能够运行在 NEMU 中的、类似 GDB 的调试器,仅凭借我们在 PA 中实现的功能似乎有些无从下手。 但是经过 PA 的训练,你应该对“计算机里没有魔法”有着坚定的信念。无论 gdb 是如何实现的,CPU、操作系统和调试器三者之间一定存在某种特殊的协议和接口,才能让调试器在用户态程序中运行,并通过操作系统和 CPU 的协作来实现断点、观察点等功能。

本次实验将会聚焦于观察点,我们将通过三个层层递进的任务,指导你一步步实现一个能正常工作的 ndb(Nju Debugger)。

本次实验的构成如下:

  1. 任务一(60分钟):在 NEMU 的调试器 sdb 中与模拟的硬件协作实现更高效的观察点功能。该功能通过判断指令是否可能改变观察点的表达式结果,减少表达式求值次数来实现优化。
  2. 任务二(60分钟):在 NEMU 中实现一个硬件断点功能。该功能需要符合 RISC-V Debug Specification 中定义的触发器模块 (Trigger Module) 的规范,在特定内存地址被写入时暂停 NEMU 的执行。
  3. 任务三(30分钟):在 NEMU 中进一步实现中断机制,通过 CSR 向 NEMU 内部的程序暴露设置硬件断点的能力。

其中任务一、二、三的前置条件分别是完成 PA1、PA2、PA3。

评分标准如下:

  • 任务一(60%):软件观察点功能正确,能正确识别观察点类型,并在寄存器或者内存被修改后自动重新求值对应的观察点表达式。
  • 任务二(80%):硬件断点功能正确,能根据 CSR 配置正确触发断点异常,mcause, mepc, mtval 的值符合规范。
  • 任务三(20%):系统调用功能正常,能正确通知调试器进程。AM 实现正确,能够保存和恢复触发器相关的上下文。

其中任务一是我们设置给只完成了 PA1 或对 PA2 不够熟悉的同学的保底,其得分和任务二三的得分不叠加。即最终成绩为 $\max(\mathrm{score_1}, \mathrm{score_2} + \mathrm{score_3})$。 如果你已经实现完 PA2,建议你直接跳过任务一,将更多的时间投入到任务二三中。

在本实验手册中,可能会出现一定的扩展内容作为知识点补充或额外挑战。这些内容通常是对 RISC-V 架构或调试器设计的深入探讨,旨在帮助你更好地理解相关概念和原理。然而,这些扩展内容并不是考试的评分点。因为考试时间有限,你可以暂时先跳过这些扩展,专注于核心任务的实现。等完成核心任务或者考试结束之后,再回头尝试这些扩展内容。

其他有用的手册:RISC-V Privileged Manual、RISC-V Unprivileged Manual、and RISC-V Debug Specification。

任务一 软件层面的观察点:性能优化

在进行本任务前, 请在工程目录下执行以下命令进行分支整理, 否则将影响你的成绩:

git commit --allow-empty -am "before starting task1"
git checkout pa1
git checkout -b task1

提交方法同 PA,在工作目录中执行 make submit 即可。提交结果可以通过 http://why.ink:8080/oj/ICS2025/TASK1/ 拼接你的 Token 查看。

在深入到 CPU 内部实现硬件断点之前,我们首先尝试在 sdb 中通过软件方式模拟观察点的行为。在 PA1 中,你已经实现了一个简单的 watch 命令,它通过在每条指令执行后检查变量值是否变化来实现观察点功能,这会对 NEMU 的性能带来明显的影响。

然而经过一学期的学习,我们知道 CPU 是一个状态机,任何状态的变化都必须通过指令来实现。因此,我们可以利用这一点,仅在可能改变变量值的指令执行后进行检查,从而减少不必要的检查次数。更加具体地说,我们根据要监视的表达式中是否存在对寄存器或内存的访问,来决定这个表达式是否需要重新求值并检查,以及在哪些指令执行之后进行检查。

功能描述

你需要为每一个观察点维护一个属性,表示该观察点是否需要某类指令执行后进行检查。具体规则如下:

  • 指针解引用:如果观察点表达式中包含对指针的解引用(例如 *0x80000000),需要在内存写入指令执行后对该观察点进行检查。
  • 寄存器访问:如果观察点表达式中包含对寄存器的访问(例如 $a0),需要在寄存器修改指令执行后对该观察点进行检查。
  • 其他情况:如果观察点表达式中既不包含对寄存器的访问,又不包含对指针的解引用,那么我们认为该表达式的值是一个常量,不会发生变化,因此永远不需要检查

每当创建一个新的观察点时,sdb 需要根据上述规则为该观察点计算并设置相应的属性,并且输出一个 debug 信息,例如:

(nemu) watch 0x80000000
Constant watchpoint 1
(nemu) watch *0x80000000
Memory watchpoint 2
(nemu) watch $a0
Register watchpoint 3
(nemu) watch *$a0
Memory, Register watchpoint 4

观察点编号必须从 1 开始递增,与 GDB 保持一致。日志内容必须严格符合上述格式,并通过 printf 输出至标准输出。每个观察点只能属于上述四种类型之一,同时属于寄存器和内存两种类型时,必须原样输出 Memory, Register watchpoint

为了进一步验证你的功能是否实现正确,你还需要在每次指令执行后,输出因为内存写入或寄存器修改被重新求值的观察点。例如:

(nemu) si
Re-evaluating watchpoint 3
Re-evaluating watchpoint 4
(nemu) si
Re-evaluating watchpoint 2
Re-evaluating watchpoint 4

这部分实验仅需要熟悉 PA1 阶段的内容即可完成。因此我们不要求你对 RISC-V 指令集有深入的了解,也不要求你分辨某条指令是否对寄存器或内存进行了修改。你只需要观察指令的执行结果即可,即判断这条指令的执行过程中是否调用了 NEMU 的内存写入函数 paddr_write。这个函数在你实现监视点的时候已经见过。只要在指令执行过程中调用过这个函数,我们就认为这条指令是内存写入指令,反之则是寄存器写入指令。

在上面的样例中,第一条指令没有修改内存,因此被视为修改了寄存器,需要对 Register 类型的观察点 3、4 进行重新求值。 第二条指令修改了内存,因此需要对 Memory 类型的观察点 2、4 进行重新求值。 当有多个观察点同时重新求值时,按观察点编号从小到大的顺序输出 Re-evaluating 的日志。

评分标准

共两个测试点:

  • NEMU 能够正确识别观察点的类型,并在创建观察点时输出正确的信息。
  • NEMU 能够根据指令执行时内存访问函数的调用情况,正确判断哪些观察点需要重新求值,并输出正确的信息。

如果 OJ 测试点无法通过,请检查你是否按照要求向标准输出打印了正确的日志信息,观察点功能是否被你自定义的宏开关关闭等。

任务二 CPU 层面的观察点:硬件实现

在进行本任务前, 请在工程目录下执行以下命令进行分支整理, 否则将影响你的成绩:

git commit --allow-empty -am "before starting task2"
# 不建议使用 PA4 分支作为起点,因为 PA4 对时钟中断机制的实现可能会和本次实验冲突。
git checkout pa3
# 如果你还没有完成 PA3,可以执行下面这行
# git checkout pa2
git checkout -b task2

提交方法同 PA,在工作目录中执行 make submit 即可。提交结果可以通过 http://why.ink:8080/oj/ICS2025/TASK2/ 拼接你的 Token 查看。

软件观察点的性能开销促使硬件设计者在 CPU 内部加入了专门的调试支持逻辑。考虑到多核支持和硬件实现的复杂度,RISC-V 官方的 Debug 规范将大部分 debug 功能独立成一个调试模块(Debug Module)而非集成在 CPU 内部。完整的系统架构在 RISC-V Debug Specification 的 Chapter 2. System Overview 中有介绍(注意考试时间,建议之后再详细学习)。

受限于考试时间,此次实验只会实现其中的一小部分内容,即内存访问触发器,允许调试器使用硬件机制监测内存被写入这一事件。 本阶段会简化系统设计,需要你基于 PA2/PA3 的代码完成实现。你可以假设程序直接运行在 M-mode,无需考虑操作系统和任何权限管理。

具体而言,你需要为 CPU 模拟器实现一个触发器模块 (Trigger Module),它能够:

  1. 通过 Debug Specification 指定的 CSR 进行配置和控制。
  2. 精确监视一个特定的物理地址。
  3. 当这个地址被写入时,触发器“开火” (fire)。
  4. 触发后的动作为产生一个触发器输出 (TM external trigger output),在本实验中表现为暂停 NEMU 的执行(在 PA1 的监视点一节你已经接触过如何暂停 NEMU 的执行)。

规范导读

你需要重点关注以下章节,结合示例程序和后续的说明理解 Trigger Module 的行为。

RISC-V Debug Specification 中存在大量细节描述,本次实验只需要实现最基础的功能即可。如果你没有在下面的提示中看到相关关键词,则可以安全地跳过这些段落。建议以下面给出的示例程序为导向选读文档。请注意考试时间!

关键章节:

  • 5.7.12. Match Control Type 6 (mcontrol6, at 0x7a1)

    作用:核心控制与状态寄存器,用于指定触发条件。

    本次实验简化

    • type:只可能为 0、6,分别表示关闭和这是一个地址/数据匹配触发器 mcontrol6
    • store:只可能为 1,表示监视写入操作。
    • m:只可能为 1,表示触发器在机器态 (M-mode)下生效。
    • size:只可能为 0、1、2、3,不会监听大于 32-bit 的内存写入。
    • hit:只可能为 2,表示在 store 指令完成之后被触发。
    • action:只可能为 8,表示触发后需要暂停 NEMU 的执行。

    以上字段的具体含义和作用可以参考规范中的描述。除了这些单独说明的字段,其余字段你都可以忽略并认为它们只可能为 0。

    手册中提到触发器的触发时机由实现自行决定,在本次实验中,我们选择 NEMU 实现起来最简单的方式,在内存写入指令执行完成之后进行触发检查。

  • 5.7.3. Trigger Data 2 (tdata2, at 0x7a2)

    作用:其具体含义会随 tdata1type 字段发生变化。在本实验中只会用于存储被监视的目标地址,硬件需要将内存访问指令的目标地址与该寄存器的值进行比对。

简单地说,你需要实现两个 CSR 寄存器 mcontrol6tdata2,并在每一次内存写入指令执行完成后检查该指令的目标物理地址是否与 tdata2 的值相等,若相等则暂停 NEMU 的执行。

关于 CSR

如果你还没有做到 PA3,不知道什么是控制状态寄存器 CSR 和 csrrw 指令,可以简单地认为 CSR 是和通用寄存器 GPR 类似的寄存器。csrrw rd, csr, rs1 指令表示将 CSR csr 的值读到通用寄存器 rd 中,并将寄存器 rs1 的值写入 CSR 寄存器 csr 中。相关的指令介绍在 RISC-V ISA Unprivileged Spec 的 6.1. CSR Instructions 一节中有描述。在本次实验中你只需要实现 csrrw 一条指令即可。csrw csr, rs1 指令是伪指令,不需要单独实现,它会被汇编器翻译成 csrrw x0, csr, rs1

csrrw 的行为用代码可以简单地描述成:

void csrrw(int rd, int csr, int rs1) {
    long old = read_csr(csr);
    write_csr(csr, read_reg(rs1));
    write_reg(rd, old);
}

已经完成了 PA3 的同学可以在课后思考一下如果 rs1 和 rd 是同一个寄存器时,RISC-V 规范中对 csrrw 指令的定义是如何描述的。本次实验中不会涉及到这种情况,但你可以对照着检查一下你的实现是否符合规范。

测试程序示例

#include <am.h>
#include <klib.h>

int arr[10];

void explain_mcontrol6(long mcontrol6) {
    printf("type = %d\n", (int)((mcontrol6 >> (32-4)) & 0xf));
    printf("dmode = %d\n", (int)((mcontrol6 >> (32-5)) & 0x1));
    printf("uncertain = %d\n", (int)((mcontrol6 >> 26) & 0x1));
    printf("vs = %d\n", (int)((mcontrol6 >> 24) & 0x1));
    printf("vu = %d\n", (int)((mcontrol6 >> 23) & 0x1));
    printf("hit = %d\n", (int)(((mcontrol6 >> 24) & 0x2) | ((mcontrol6 >> 22) & 0x1)));
    printf("select = %d\n", (int)((mcontrol6 >> 21) & 0x1));
    printf("size = %d\n", (int)((mcontrol6 >> 16) & 0x7));
    printf("action = %d\n", (int)((mcontrol6 >> 12) & 0xf));
    printf("chain = %d\n", (int)((mcontrol6 >> 11) & 0x1));
    printf("match = %d\n", (int)((mcontrol6 >> 7) & 0xf));
    printf("m = %d\n", (int)((mcontrol6 >> 6) & 0x1));
    printf("uncertainen = %d\n", (int)((mcontrol6 >> 5) & 0x1));
    printf("s = %d\n", (int)((mcontrol6 >> 4) & 0x1));
    printf("u = %d\n", (int)((mcontrol6 >> 3) & 0x1));
    printf("execute = %d\n", (int)((mcontrol6 >> 2) & 0x1));
    printf("store = %d\n", (int)((mcontrol6 >> 1) & 0x1));
    printf("load = %d\n", (int)(mcontrol6 & 0x1));
}

int main() {
    //                 33222222222211111111110000000000
    //                 10987654321098765432109876543210
    long mcontrol6 = 0b01100000000000001000000001000010L;
    explain_mcontrol6(mcontrol6);
    printf("Setting up trigger on &arr[5]=%x\n", &arr[5]);
    asm volatile(
        "csrw mcontrol, %0\n"
        "csrw tdata2, %1\n"
        :: "r"(mcontrol6), "r"(&arr[5])
    );

    printf("Trigger set, writing to arr\n");

    for (int i = 0; i < 10; i++) {
      printf("Storing arr[%d], addr = %x, value = %d\n", i, &arr[i], i + 1);
      arr[i] = i + 1;
    }

    for (int i = 0; i < 10; i++) {
        printf("Loading arr[%d], result = %d\n", i, arr[i]);
    }
}

运行该程序时,NEMU 应该在写入 arr[5] 后触发断点并暂停执行,输出类似如下内容:

Welcome to riscv32-NEMU!
For help, type "help"
type = 6
dmode = 0
uncertain = 0
vs = 0
vu = 0
hit = 0
select = 0
size = 0
action = 8
chain = 0
match = 0
m = 1
uncertainen = 0
s = 0
u = 0
execute = 0
store = 1
load = 0
Setting up trigger on &arr[5]=80001620
Trigger set, writing to arr
Storing arr[0], addr = 8000160c, value = 1
Storing arr[1], addr = 80001610, value = 2
Storing arr[2], addr = 80001614, value = 3
Storing arr[3], addr = 80001618, value = 4
Storing arr[4], addr = 8000161c, value = 5
Storing arr[5], addr = 80001620, value = 6
(nemu)

实验指南

在 NEMU 的 CPU 结构体中增加 mcontrol6tdata2 两个成员变量,分别用于存储对应 CSR 的值。

在 NEMU 的指令执行逻辑中:

  1. 实现 csrrw 指令:如果你还没有实现到 PA3,需要增加对 csrrw 指令的支持,允许用户读写 CSR。
  2. 拦截 Store 指令:对于任何写入内存的指令(如 sb, sh, sw),在某个位置增加地址检查逻辑。
    1. 触发器检查:在执行写入指令时,检查 mcontrol6 的配置,判断地址匹配触发器是否已启用。
    2. 地址比对:若触发器启用,将 store 指令的目标物理地址与 tdata2 的值进行比较。
    3. 触发异常:若地址匹配,则必须按照要求 在指令执行完成之后 触发 action 指定的动作。(提示:PA1 中的监视点被触发时,NEMU 是在指令完成之前暂停还是指令完成之后暂停的?是否可以借鉴?)

评分标准

共两个测试点:

  • NEMU 支持 csrrw 指令读写 mcontrol6tdata2 这两个 CSR 寄存器。
  • NEMU 能够正确在 mcontrol6tdata2 共同指定的地址被写入时暂停执行。

任务三 操作系统层面的观察点:软硬协同

在进行本任务前, 请在工程目录下执行以下命令进行分支整理, 否则将影响你的成绩:

git commit --allow-empty -am "before starting task3"
# 不建议使用 PA4 分支作为起点,因为 PA4 对时钟中断机制的实现可能会和本次实验冲突。
git checkout pa3
git checkout -b task3
git merge task2

提交方法同 PA,在工作目录中执行 make submit 即可。提交结果可以通过 http://why.ink:8080/oj/ICS2025/TASK3/ 拼接你的 Token 查看。

在任务二中,触发器输出动作后仍然需要外界硬件(NEMU)来处理,用户仍然是在和 NEMU 的 monitor 模块交互,CPU 并没有将事件传递给 CPU 内部的程序。

如果你已经完成了 PA3 的内容,就应该知道在这种情况下中断(或者说异常)是一个非常合适的机制。 事实上,绝大部分事件都是通过中断、异常机制来传递给软件的,例如键盘鼠标的输入,USB 设备的插入和拔出,CPU 执行了非法指令等。

在 RISC-V 架构中,触发器也可以通过产生一个中断来通知 CPU 监听的事件已经发生。相关的设计你已经在 5.7.12. Match Control Type 6 一节中见过了。

具体到实现,你需要为 mcontrol6 寄存器的 action 字段增加对值为 0 的支持。 当 action 字段为 0 时,触发器被触发后需要立即产生一个中断,并根据手册中的描述设置 mcause, mepc 等寄存器的值,你可能需要结合 RISC-V ISA privileged Table 14 中的中断编号定义得知。

你可以参考 PA3 中实现的环境调用 (ecall) 异常处理逻辑。 在正式开始之前,建议你先运行一遍下方的第一个测试程序示例,验证一下你的 ecall 指令是否实现正确,例如 mcause 寄存器的值是多少,mepc 寄存器的地址是当前指令地址还是下一条指令地址等。 否则错误的实现可能会误导你实现后续触发器中断的思路。

简单起见,这里直接给出 ecall 指令的异常处理逻辑,如果你发现你无法通过测试程序,可以参考下面的代码检查你的实现是否正确:

mepc.PC = $pc;
$pc = {mtvec.BASE, 2'b00};
mcause.INT = 1'b0;
mcause.CODE = $bits(ExceptionCode::Mcall);
mstatus.MPP = $bits(from_mode);

在实现触发器的过程中,你同样需要思考这个问题:当触发器产生中断时,mcause 寄存器的值是多少,mepc 寄存器的地址是当前指令地址还是下一条指令地址? 这个问题的答案分别位于 RISC-V Debug Specification mcontrol6 寄存器的 action 字段 (Table 12) 和 hit0 (after) 字段的描述中。(如果你对 PA3 足够熟悉,你也可以从测试程序的异常处理逻辑中推断出答案。)

与 ecall 不同的是,NEMU 目前实现修改内存的几条指令时并没有考虑过 store 指令可能会产生异常的情况,需要你适当扩展原有的代码。

没错,RISC-V 标准规定 store 指令出现内存访问越界等情况时应该产生异常,并跳转到操作系统的中断处理程序。CPU 执行到非法指令时同样也应该产生异常。 只不过为了方便大家在 PA2、PA3 尽早发现错误,也为了避免大家在 PA2 中过早地接触到异常机制,才会在 NEMU 中将上述行为改为立即终止 NEMU。

Linux 就大量使用了异常机制来一些特殊的逻辑。例如 Linux 会在启动的时候尝试执行一些指令并检测是否触发 Illegal Instruction 异常,从而判断 CPU 是否支持某些扩展指令集。在启动一个新进程时,Linux 也不会为进程分配任何内存页甚至页表。而是等到进程真正被执行并访问这些内存页时并触发 Page Fault 时,才会分配相应的内存页并更新页表。 因此,如果你想要在二周目用 NEMU 启动 Linux,就必须正确实现这些异常机制。

由于考试时间有限,这里建议你使用最简单暴力的做法即可:直接在 sb/sh/sw 指令的实现后面增加触发器检查逻辑,如果发现触发器触发,则直接修改 cpu 的状态产生中断。

完成触发器中断的实现后,你的实现应该能够通过下方的第一个测试程序。

最后,你还需要修改 AM 的异常处理逻辑,使其能够正确识别触发器产生的中断,并通知用户注册的异常处理函数。

mcontrol6tdata2 寄存器与 mcause 寄存器类似,它们同样属于上下文的一部分,你需要在 AM 对应的 Context 结构体中增加相应的成员变量,并在异常处理函数中实现对这个成员变量的保存和恢复。 你已经在 PA3.1 中做过类似的工作了,虽然 PA3.1 并没有让你编写 abstract-machine/am/src/riscv/nemu/trap.S 中的汇编代码,但是经过一个学期的训练再加上照猫画虎的能力,你应该能够补充完整这部分内容(不要忘记修改 CONTEXT_SIZE 宏定义)。

需要注意的是,由于 OJ 使用的 gcc 版本较老,暂时不认识 mcontrol6 CSR 寄存器,你可以在汇编中使用下面测试程序中出现的另一个名字 mcontrol 来访问该寄存器。

完成之后,你的实现应该能够通过下方的第二个测试程序。

测试程序示例

NEMU 测试程序示例:

#include <am.h>
#include <klib.h>
#include <klib-macros.h>

#define CAUSE_BREAKPOINT 3
#define CAUSE_ECALL_FROM_M_MODE 11

int arr[10];

Context *handler(Event ev, Context* c) {
    printf("Exception handler called, cause=%d\n", c->mcause);

    switch (c->mcause) {
        case CAUSE_BREAKPOINT:
            printf("Breakpoint, epc=%x, instr=%x, tdata2=%x(%d)\n", c->mepc, *(volatile unsigned int *)c->mepc, c->tdata2, *(volatile int *)c->tdata2);
            break;
        case CAUSE_ECALL_FROM_M_MODE:
            printf("Environment call from M-mode, epc=%x, instr=%x\n", c->mepc, *(volatile unsigned int *)c->mepc);
            c->mepc += 4;
            break;
        default:
            panic("Unknown trap cause, check your PA2 implementation\n");
            break;
    }
    return c;
}

void explain_mcontrol6(long mcontrol6) {
    printf("type = %d\n", (int)((mcontrol6 >> (32-4)) & 0xf));
    printf("dmode = %d\n", (int)((mcontrol6 >> (32-5)) & 0x1));
    printf("uncertain = %d\n", (int)((mcontrol6 >> 26) & 0x1));
    printf("vs = %d\n", (int)((mcontrol6 >> 24) & 0x1));
    printf("vu = %d\n", (int)((mcontrol6 >> 23) & 0x1));
    printf("hit = %d\n", (int)(((mcontrol6 >> 24) & 0x2) | ((mcontrol6 >> 22) & 0x1)));
    printf("select = %d\n", (int)((mcontrol6 >> 21) & 0x1));
    printf("size = %d\n", (int)((mcontrol6 >> 16) & 0x7));
    printf("action = %d\n", (int)((mcontrol6 >> 12) & 0xf));
    printf("chain = %d\n", (int)((mcontrol6 >> 11) & 0x1));
    printf("match = %d\n", (int)((mcontrol6 >> 7) & 0xf));
    printf("m = %d\n", (int)((mcontrol6 >> 6) & 0x1));
    printf("uncertainen = %d\n", (int)((mcontrol6 >> 5) & 0x1));
    printf("s = %d\n", (int)((mcontrol6 >> 4) & 0x1));
    printf("u = %d\n", (int)((mcontrol6 >> 3) & 0x1));
    printf("execute = %d\n", (int)((mcontrol6 >> 2) & 0x1));
    printf("store = %d\n", (int)((mcontrol6 >> 1) & 0x1));
    printf("load = %d\n", (int)(mcontrol6 & 0x1));
}

int main() {
    cte_init(handler);

    int tmp = 0;
    printf("Testing environment call\n");
    asm volatile("ecall\nli %0, 10" : "=r"(tmp));
    if (tmp == 10) {
        printf("Environment call test passed\n");
    } else {
        panic("Environment call test failed, checks your mepc implementation\n");
    }

    //                 33222222222211111111110000000000
    //                 10987654321098765432109876543210
    long mcontrol6 = 0b01100000000000000000000001000010L;
    explain_mcontrol6(mcontrol6);
    printf("Setting up trigger on &arr[5]=%x\n", &arr[5]);
    asm volatile(
        "csrw mcontrol, %0\n"
        "csrw tdata2, %1\n"
        :: "r"(mcontrol6), "r"(&arr[5])
    );

    printf("Enabling mstatus.mie\n");
    asm volatile(
        "csrs mstatus, %0\n"
        :: "r"(1 << 3)
    );
    printf("Trigger set, writing to arr\n");

    for (int i = 0; i < 10; i++) {
      printf("Storing arr[%d], addr = %x, value = %d\n", i, &arr[i], i + 1);
      arr[i] = i + 1;
    }

    for (int i = 0; i < 10; i++) {
        printf("Loading arr[%d], result = %d\n", i, arr[i]);
    }
}

期望输出:

Testing environment call
Exception handler called, cause=11
Environment call from M-mode, epc=800002e4, instr=73
Environment call test passed
type = 6
dmode = 0
uncertain = 0
vs = 0
vu = 0
hit = 0
select = 0
size = 0
action = 0
chain = 0
match = 0
m = 1
uncertainen = 0
s = 0
u = 0
execute = 0
store = 1
load = 0
Setting up trigger on &arr[5]=80001a64
Enabling mstatus.mie
Trigger set, writing to arr
Storing arr[0], addr = 80001a50, value = 1
Storing arr[1], addr = 80001a54, value = 2
Storing arr[2], addr = 80001a58, value = 3
Storing arr[3], addr = 80001a5c, value = 4
Storing arr[4], addr = 80001a60, value = 5
Storing arr[5], addr = 80001a64, value = 6
Exception handler called, cause=3
Breakpoint, epc=8000018c, instr=448493, tdata2=80001a64(6)
Storing arr[6], addr = 80001a68, value = 7
Storing arr[7], addr = 80001a6c, value = 8
Storing arr[8], addr = 80001a70, value = 9
Storing arr[9], addr = 80001a74, value = 10
Loading arr[0], result = 1
Loading arr[1], result = 2
Loading arr[2], result = 3
Loading arr[3], result = 4
Loading arr[4], result = 5
Loading arr[5], result = 6
Loading arr[6], result = 7
Loading arr[7], result = 8
Loading arr[8], result = 9
Loading arr[9], result = 10

AM 测试程序示例:

#include <am.h>
#include <klib.h>
#include <klib-macros.h>

int arr[10];

Context *handler(Event ev, Context* c) {
    printf("Exception handler called, cause=%d\n", c->mcause);

    switch (ev.event) {
        case EVENT_BREAKPOINT:
            printf("Breakpoint, epc=%x, instr=%x, tdata2=%x(%d)\n", c->mepc, *(volatile unsigned int *)c->mepc, c->tdata2, *(volatile int *)c->tdata2);
            if (c->tdata2 + 8 <= (unsigned int)&arr[9]) {
                c->tdata2 += 8;
                printf("Next trigger: &arr[%d]=%x\n", (int *)(c->tdata2) - &arr[0], c->tdata2);
            }
            break;
        case EVENT_SYSCALL:
            printf("Environment call from M-mode, epc=%x, instr=%x\n", c->mepc, *(volatile unsigned int *)c->mepc);
            c->mepc += 4;
            break;
        default:
            panic("Unknown trap cause, check your PA2 implementation\n");
            break;
    }
    return c;
}

void explain_mcontrol6(long mcontrol6) {
    printf("type = %d\n", (int)((mcontrol6 >> (32-4)) & 0xf));
    printf("dmode = %d\n", (int)((mcontrol6 >> (32-5)) & 0x1));
    printf("uncertain = %d\n", (int)((mcontrol6 >> 26) & 0x1));
    printf("vs = %d\n", (int)((mcontrol6 >> 24) & 0x1));
    printf("vu = %d\n", (int)((mcontrol6 >> 23) & 0x1));
    printf("hit = %d\n", (int)(((mcontrol6 >> 24) & 0x2) | ((mcontrol6 >> 22) & 0x1)));
    printf("select = %d\n", (int)((mcontrol6 >> 21) & 0x1));
    printf("size = %d\n", (int)((mcontrol6 >> 16) & 0x7));
    printf("action = %d\n", (int)((mcontrol6 >> 12) & 0xf));
    printf("chain = %d\n", (int)((mcontrol6 >> 11) & 0x1));
    printf("match = %d\n", (int)((mcontrol6 >> 7) & 0xf));
    printf("m = %d\n", (int)((mcontrol6 >> 6) & 0x1));
    printf("uncertainen = %d\n", (int)((mcontrol6 >> 5) & 0x1));
    printf("s = %d\n", (int)((mcontrol6 >> 4) & 0x1));
    printf("u = %d\n", (int)((mcontrol6 >> 3) & 0x1));
    printf("execute = %d\n", (int)((mcontrol6 >> 2) & 0x1));
    printf("store = %d\n", (int)((mcontrol6 >> 1) & 0x1));
    printf("load = %d\n", (int)(mcontrol6 & 0x1));
}

int main() {
    cte_init(handler);

    //                 33222222222211111111110000000000
    //                 10987654321098765432109876543210
    long mcontrol6 = 0b01100000000000000000000001000010L;
    explain_mcontrol6(mcontrol6);
    printf("Setting up trigger on &arr[5]=%x\n", &arr[5]);
    asm volatile(
        "csrw mcontrol, %0\n"
        "csrw tdata2, %1\n"
        :: "r"(mcontrol6), "r"(&arr[5])
    );

    printf("Enabling mstatus.mie\n");
    asm volatile(
        "csrs mstatus, %0\n"
        :: "r"(1 << 3)
    );
    printf("Trigger set, writing to arr\n");

    for (int i = 0; i < 10; i++) {
      printf("Storing arr[%d], addr = %x, value = %d\n", i, &arr[i], i + 1);
      arr[i] = i + 1;
    }

    for (int i = 0; i < 10; i++) {
        printf("Loading arr[%d], result = %d\n", i, arr[i]);
    }
}

期望输出:

type = 6
dmode = 0
uncertain = 0
vs = 0
vu = 0
hit = 0
select = 0
size = 0
action = 0
chain = 0
match = 0
m = 1
uncertainen = 0
s = 0
u = 0
execute = 0
store = 1
load = 0
Setting up trigger on &arr[5]=80001aac
Enabling mstatus.mie
Trigger set, writing to arr
Storing arr[0], addr = 80001a98, value = 1
Storing arr[1], addr = 80001a9c, value = 2
Storing arr[2], addr = 80001aa0, value = 3
Storing arr[3], addr = 80001aa4, value = 4
Storing arr[4], addr = 80001aa8, value = 5
Storing arr[5], addr = 80001aac, value = 6
Exception handler called, cause=3
Breakpoint, epc=8000018c, instr=448493, tdata2=80001aac(6)
Next trigger=80001ab4
Storing arr[6], addr = 80001ab0, value = 7
Storing arr[7], addr = 80001ab4, value = 8
Exception handler called, cause=3
Breakpoint, epc=8000018c, instr=448493, tdata2=80001ab4(8)
Next trigger=80001abc
Storing arr[8], addr = 80001ab8, value = 9
Storing arr[9], addr = 80001abc, value = 10
Exception handler called, cause=3
Breakpoint, epc=8000018c, instr=448493, tdata2=80001abc(10)
Loading arr[0], result = 1
Loading arr[1], result = 2
Loading arr[2], result = 3
Loading arr[3], result = 4
Loading arr[4], result = 5
Loading arr[5], result = 6
Loading arr[6], result = 7
Loading arr[7], result = 8
Loading arr[8], result = 9
Loading arr[9], result = 10

实验步骤

  1. 在 NEMU 的指令执行逻辑中修改 task2 触发器的行为:若地址匹配且 action 字段为 0,则和 ecall 类似产生中断并正确设置 mcause, mepc 等寄存器的值。
  2. 在 AM 的 __am_irq_handle 函数中增加对触发器中断的识别,并产生相应的 EVENT_BREAKPOINT 事件通知用户注册的异常处理函数。
  3. 在 AM 的 Context 结构体中增加 mcontrol6tdata2 成员变量,并在异常处理汇编代码中实现对这两个成员变量的保存和恢复。

评分标准

共三个测试点:

  • NEMU 在执行 ecall 指令时能够正确产生环境调用异常,且 mcausemepc 寄存器的值正确。
  • NEMU 能够正确在 mcontrol6tdata2 共同指定的地址被写入时触发中断,且 mcausemepc 寄存器的值正确。
  • AM 能够正确识别触发器中断,并通知用户注册的异常处理函数,且用户能够通过修改 Context 结构体中的 tdata2 成员变量来改变下一个触发地址。

后记

恭喜你完成了本次实验!至此,你已经实现了一个最简单的 CPU 调试支持。从软件层面的观察点分类,到硬件层面的触发器,再到软硬协同的中断机制,现在的你已经能够理解并实现一个完整的调试支持系统。

看似复杂的 GDB 调试,其实就是在这些基础功能之上进行的封装和扩展。例如 GDB 的 break foo 命令其实就是将函数入口处的第一条指令替换成一个 ebreak 指令(RISC-V 中的断点指令,和 ecall 类似,但是会产生一个 cause 为 3 的断点异常)。因此不再需要每执行一条指令就进行一次 PC 的检查,而是让 CPU 在执行到这个地址的时候主动发起一次异常从而进入 GDB 的断点逻辑。相关代码位于 12。而 watch var 命令则是通过 ptrace 系统调用让 Linux 帮忙设置设置 mcontrol6 和 tdata2 寄存器来实现的。由于 RISC-V 的 Debug Specification 较新(2025-02-21 正式发布),GDB 对 RISC-V 触发器的支持还不完善。如果你感兴趣,你可以参考 GDB 对 ARM 的实现

从此,GDB 对你来说将不再是一个神秘的黑盒子,而是一个可以被你理解和掌控的强大工具。祝你在未来的学习和工作中一切顺利!