计算机系统实验笔记
lab1_challenge2 打印异常代码
直接make后的情况:
1 | spike ./obj/riscv-pke ./obj/app_errorline |
1 | In m_start, hartid:0 |
我们是希望本代码在第8行提示后显示runtime error, 内核能够输出触发异常的用户程序的源文件名和对应代码行
- 注意:虽然在示例的app_errorline.c中只触发了非法指令异常,但最终测试时你的内核也应能够对其他会导致panic的异常和其他源文件输出正确的结果。
- 文件名规范:需要包含路径,如果是用户源程序发生的错误,路径为相对路径,如果是调用的标准库内发生的错误,路径为绝对路径。
- 为了降低挑战的难度,本实验在elf.c中给出了debug_line段的解析函数
make_addr_line
。这个函数接受三个参数,ctx为elf文件的上下文指针,这个可以参考文件中的其他函数;debug_line为指向.debug_line段数据的指针,你需要读取elf文件中名为.debug_line的段保存到缓冲区中,然后将缓冲区指针传入这个参数;length为.debug_line段数据的长度。 - 函数调用结束后,process结构体的dir、file、line三个指针会各指向一个数组,dir数组存储所有代码文件的文件夹路径字符串指针,如/home/abc/bcd的文件夹路径为/home/abc,本项目user文件夹下的app_errorline.c文件夹路径为user;file数组存储所有代码文件的文件名字符串指针以及其文件夹路径在dir数组中的索引;line数组存储所有指令地址,代码行号,文件名在file数组中的索引三者的映射关系。如某文件第3行为a = 0,被编译成地址为0x1234处的汇编代码li ax, 0和0x1238处的汇编代码sd 0(s0), ax。那么file数组中就包含两项,addr属性分别为0x1234和0x1238,line属性为3,file属性为“某文件”的文件名在file数组中的索引。
- 注意:dir、file、line三个数组会依次存储在debug_line数据缓冲区之后,dir数组和file数组的大小为64。所以如果你用静态数组来存储debug_line段数据,那么这个数组必须足够大;或者你也可以把debug_line直接放在程序所有需映射的段数据之后,这样可以保证有足够大的动态空间。
1 |
|
原代码中, 在第8行引起exceptions(risc.v)至mtrap.c 引起 CAUSE_ILLEGAL_INSTRUCTION 中断。
引起panic异常时, 系统时调用了mtrap.c
文件中的
handle_something_fault 函数,但是这个函数目前指挥报错,
并不能显示引发异常的位置,因此我们需要完善这部分的代码。
首先我们回顾一下process结构体和它的作用:
1 | //process 结构体 |
这个结构体是用来描述一个进程(process)的基本信息的。在操作系统中,一个进程代表了正在运行的一个程序实例。让我们逐个分析这个结构体中的成员:
kstack
:这是一个指向用于处理陷阱(trap)的堆栈(stack)的指针。当进程在内核模式下执行时,它会使用这个堆栈来保存执行上下文和临时数据。trapframe
:这是一个指向 trapframe 结构体的指针,用于存储进程的上下文信息。trapframe 中包含了进程在发生中断或异常时需要保存和恢复的寄存器值和其他相关信息。debugline
、dir
、file
、line
、line_ind
:这些成员似乎是在后续的lab1_challenge2
中添加的。debugline
可能是一个指向调试信息的字符串的指针,而dir
、file
、line
、line_ind
可能用于存储源代码文件、目录和行号等信息。
我们可以设计print_errorline
函数,
同时让各种handle_fault
函数调用这个函数打印信息
需要注意, 打印异常代码时, 我们需要知道文件路径, 文件名, 异常代码所处行数
在struct process
中,
新增加了debugline
、dir
、file
、line
、line_ind
这些成员,
因此考虑利用这些成员存储相应信息
我们来看elf.c
中的
load_bincode_from_host_elf
函数
1 | void load_bincode_from_host_elf(process *p) { |
这个函数的作用是从主机的 ELF 文件加载二进制代码到一个进程的内存中,并设置该进程的入口地址。具体步骤如下:
- 解析命令行参数,获取要加载的应用程序的文件名。
- 打开指定的应用程序 ELF 文件,如果打开失败则触发 panic。
- 初始化一个用于加载 ELF 文件的 elf_ctx 结构,并将其与进程关联起来。
- 使用 elf_init() 函数初始化 elfloader 上下文,如果初始化失败则触发 panic。
- 使用 elf_load() 函数加载 ELF 文件,如果加载失败则触发 panic。
- 将加载的 ELF 文件的入口地址设置为进程的 trapframe->epc。
- 关闭打开的应用程序 ELF 文件。
- 打印应用程序的入口地址(虚拟地址)。
总体来说,这个函数的目的是将一个应用程序的 ELF 文件加载到指定进程的内存中,并设置该进程的入口地址,以便后续可以执行该应用程序。
在这个函数中, 我们只使用了elf_load
来加载程序段
下面是elf_load
的实现:
1 | elf_status elf_load(elf_ctx *ctx) { |
这个
elf_load
函数的实现主要是根据 ELF 文件的程序头部表(Program Header Table)中的信息,将 ELF 文件中的程序段(Program Segment)加载到内存中的正确位置。具体来说,函数首先遍历 ELF 文件的程序头部表,每次读取一个程序头部(Program Header),然后根据程序头部的信息进行相应的操作:
- 如果程序头部的类型(type)不是
ELF_PROG_LOAD
,则跳过,不处理该程序头部。- 检查程序头部的
memsz
(内存大小)是否小于等于filesz
(文件大小),如果不是则返回EL_ERR
,表示加载出错。- 检查程序头部的虚拟地址加上内存大小是否溢出,如果溢出则返回
EL_ERR
,表示加载出错。- 在内存中为该程序段分配一块内存块(使用
elf_alloc_mb
函数),用于存放加载后的数据。- 使用
elf_fpread
函数将 ELF 文件中的数据加载到内存中相应的位置。最终,函数会返回
EL_OK
,表示 ELF 文件的加载成功;如果在加载过程中出现了错误,则会返回相应的错误码(如EL_EIO
或EL_ERR
)。总的来说,
elf_load
函数的实现是根据 ELF 文件的程序头部信息,将程序段加载到内存中的正确位置,为后续程序执行做准备。
从
elf_load
函数的实现来看,它只负责加载 ELF 文件的程序段(Program Segment),并没有加载调试信息(debug information)。调试信息通常存储在 ELF 文件的特定节(section)中,如 ".debug_line" 节。加载调试信息需要额外的处理逻辑,包括找到相应的节并将其内容加载到内存中。在
elf_load
函数中,并没有包含加载调试信息的逻辑,因此调试信息并未在该函数中被加载。加载调试信息通常在load_debug_line
函数或类似的函数中实现,这样可以使代码更清晰、更模块化。
elf_load_debugline实现
根据前文的提示, 我们使用数组存储degbugline内容1 ,那么首先我们需要shstrtab来解析debugline
节名字符串表(Section Header String Table,shstrtab)是 ELF 文件中的一种特殊节,用于存储所有节的名称。每个节的头部中包含一个指向节名字符串表中对应节名称的偏移量。通过这种方式,可以在不占用额外空间的情况下,为每个节指定一个唯一的名称。
当找到节名字符串表后,可以通过解析该表中的内容来获取每个节的名称。这对于解析 ELF 文件的结构非常有用,特别是在需要查找特定节(如代码段、数据段、调试信息段等)时。通过节名字符串表,可以根据节的名称快速找到对应的节,从而实现对 ELF 文件中不同部分的精确定位和处理。
总之,找到节名字符串表后,主要的操作就是根据需要解析该表,以获取每个节的名称,并根据这些名称进行后续的处理和操作。
试作elf_load_debugline
:
1 | elf_sect_header debugline_sh; |
代码思路:
- 先找到
shstrtab
的section header
, 这个可以利用elf文件的上下文指针ctx
中的shstrndx
, 它存储了节名字符表的索引 - 通过
shstrtab section header
读取shstrtab的内容 - 遍历, 找到名为
.debug_line
的节头部
设计好函数后,我们让load_bincode_from_host_elf
去调用一下这个函数就好了
回顾函数设计:
目前来说, 我们的函数设计是存在一些小问题的,
和elf_load
对比就知道目前是不完善的.
因为我们函数的返回值只有EL_OK
, 这个内容就留给后续完善了
print_errorline 的实现
首先我们尝试从mepc中读取异常地址
mepc
是 RISC-V 指令集架构中的一个寄存器,用于存储下一条要执行的指令的地址。具体来说,mepc
是 Machine Exception Program Counter 的缩写,它在发生异常或中断时用于保存当前正在执行指令的地址,以便在异常处理完成后能够正确地返回到异常发生的位置继续执行。在异常或中断处理过程中,操作系统会根据需要修改
mepc
寄存器的值,以指向相应的异常处理程序或中断服务程序的入口地址。这样,在处理完异常或中断后,处理器可以从mepc
中读取地址,继续执行下一条指令,实现异常处理的流程。
read_csr
是一个用于读取 RISC-V 控制状态寄存器(CSR)的函数。在 RISC-V 架构中,CSR 是一种特殊的寄存器,用于存储控制和状态信息,例如中断使能、异常处理等。mepc
是其中一个 CSR,用于存储下一条要执行的指令的地址。
read_csr(mepc)
的作用是读取当前mepc
寄存器的值,即当前发生异常或中断的指令地址。这样做的目的是为了根据这个地址来确定发生异常的指令在源代码中的位置,从而能够打印出相关的错误信息。为什么需要使用
read_csr
函数而不直接使用mepc
寄存器呢?这是因为在 RISC-V 架构中,对 CSR 的访问受到权限控制,不是所有的指令都能直接读写 CSR。read_csr
函数是一个合法的访问 CSR 的方法,它会在底层处理权限等问题,以确保能够正确地读取到 CSR 的值。
代码思路
- 通过mepc找到errorline
- 利用
process
中新增成员dir
,file
找到完整路径
- 通过完整路径和
spike_file.c
中的函数打开文件并保存到buffer中 - 逐行变量找到errorline的内容
如果使用这样的逻辑
1 | int i=0; |
结果是
1 | mepc:81000010 |
这并不符合预期, 而是显示errorline的下一行
仔细一看找到的确实是第13行, 所以这个逻辑没有错, 反而是后续打印errorline代码时出现了错误, 于是继续排查
当一个指令导致异常时,
mepc
寄存器中存储的是导致异常的指令的地址。这个地址通常是指令在内存中的地址,而不是源代码中的行号。让我们通过一个简单的示例来说明这一点。假设有如下的一段 C 语言代码:
1
2
3
4
5
6 int main() {
int a = 10;
int b = 0;
int c = a / b; // 这里会导致除零异常
return 0;
}当程序执行到
int c = a / b;
这行代码时,会发生除零异常。这时,处理器会将引起异常的指令的地址存储到mepc
寄存器中。假设这行代码对应的机器码指令在内存地址0x1000
处,那么mepc
寄存器中的值将是0x1000
。在这个示例中,
mepc
寄存器中的值0x1000
是导致异常的指令的地址,而不是int c = a / b;
这行代码在源代码中的行号。要确定源代码中的行号,您可能需要使用调试器或其他工具来查看对应机器码指令的源代码映射。
经过排查找到了错误: 因为我们的第n行代码存入buffer时, 是将其对应到第n-
1行的
1 | void print_errorline(){ |
显然这个修改也不是很好, 最后修改得到这个代码
1 | void print_errorline(){ |
next
参考
dir、file、line三个数组会依次存储在debug_line数据缓冲区之后,dir数组和file数组的大小为64。所以如果你用静态数组来存储debug_line段数据,那么这个数组必须足够大;或者你也可以把debug_line直接放在程序所有需映射的段数据之后,这样可以保证有足够大的动态空间。↩︎