# 实验一: GDB使用方法介绍 - 参考网页: https://pdos.lcs.mit.edu/6.828/2024/labs/syscall.html 这篇文章主要通过调试kernel启动过程中会创建第一个用户进程,而该进程会通过系统调用`exec`去执行`/init`程序,`init`进一步会通过`exec`执行`sh`来启动xv6的简化版本`shell`程序。 主要介绍了一些gdb的调试命令,如`b syscall`设置函数断点,`backtrace`查看调用堆栈,`p /x *p, p num`等输出变量内容等。 ## gdb使用的一些相关说明 ### 编译并启动虚拟器调试 当执行 make qemu-gdb 时,QEMU 会启动并进入一个暂停状态,等待 GDB 进行远程调试连接。这意味着内核还没有正式开始执行,它会处于等待状态,直到调试器(如 GDB)连接并发出继续执行的指令。具体过程如下: 执行`make qemu-gdb`:显示 qemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel -m 128M -smp 3 -nographic -global virtio-mmio.force-legacy=false -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 -S -gdb tcp::26000 其中的一些选项说明: `-S` 选项:该选项会让 QEMU 在启动后立即暂停虚拟机的执行,不会执行任何内核代码。内核会保持在暂停状态,直到通过 GDB 或其他调试工具发送继续执行的命令(如 continue)。 `-gdb tcp::26000` 选项:这个选项会在端口 26000 上启动一个 GDB 调试服务器,等待调试器连接。配合 -S 选项使用,GDB 可以在连接到 QEMU 后控制虚拟机的执行,进行调试。 QEMU 启动虚拟机并加载内核映像,但是不会立即开始执行内核代码。它会进入暂停状态,并开启一个 GDB 远程调试端口(通常是 localhost:1234,本示例为26000),等待调试器连接。 ### 使用GDB调试工具 cd到kernel子目录,执行`gdb-multiarch kernel`加载内核符号并连接:当使用`gdb-multiarch`加载内核调试符号(file kernel)时,GDB 仅加载了符号表和源代码信息,不会启动或影响 QEMU 中的内核执行。 执行`target remote localhost:1234` 进行连接后,GDB 会附加到 QEMU 上的虚拟机,并能够控制内核的执行状态。调试器控制内核的执行: 连接后,调试器可以使用命令(如 continue 或 step)来继续执行内核代码。这时,内核才真正开始执行。 因此,在 make qemu-gdb 时,内核实际上还没有正式启动,只有当 gdb-multiarch 连接到 QEMU 并发出继续执行的命令时,内核才会开始运行。 ## 实验步骤:(ubuntu24.04,按tools安装好相关条件) - 1、在一个终端里调用`make qemu-gdb` - 2、在另一个终端分别执行 - `gdb-multiarch kernel`(使用 gdb-multiarch 加载内核时,GDB 只是加载了内核的调试符号(符号表和源代码信息),以便在调试过程中提供源代码级别的调试体验。这一步不会真正启动或执行内核。连接到 gdbserver 时,gdb-multiarch 会附加到现有的内核进程,不会导致内核重新启动或产生冲突,注意,如果用gdb kernel不行,与模拟器的环境不一样) - `target remote localhost:26000` 连接到远程的gdb server - `b syscall`后返回如下,设置了断点 ```(gdb) b syscall Breakpoint 1 at 0x80001c72: file kernel/syscall.c, line 133. ``` - `c`后返回如下,c从开始持续运行到断点处停下 ``` (gdb) c Continuing. Thread 1 hit Breakpoint 1, syscall () at kernel/syscall.c:133 ``` - `layout src`, the layout command splits the window in two, showing where gdb is in the source code. - `backtrace` backtrace prints a stack backtrace. backtrace显示调用栈的信息 - `n`代表“执行下一行”,用于单步执行代码,连续执行多次`n`,直到运行` struct proc *p = myproc();`之后(代码在`kernel/syscall.c`第15行) - `p /x *p`打印p指针所指向的进程的proc struct (see kernel/proc.h>) in hex,/x代表十六进制,第一个p代表print - 通过`p num`获取`p->trapframe->a7`的值为7,在`./user/initcode.S`代码第11行中的语句`li a7, SYS_exec` 将`SYS_exec`(在kernel目录中的`syscall.h`中宏定义了SYS_exec为7,`#define SYS_exec 7`)加载到`a7`寄存器。在kernel目录的`syscall.c`的中定义了`SYS_exec`对应的系统调用函数为`sys_exec` ``` static uint64 (*syscalls[])(void) = { //(*syscalls[])意义为函数指针数组,数组的长度根据初始化的赋值动态确定,(void)表明函数的参数均为void [SYS_fork] sys_fork, [SYS_exit] sys_exit, [SYS_wait] sys_wait, [SYS_pipe] sys_pipe, [SYS_read] sys_read, [SYS_kill] sys_kill, [SYS_exec] sys_exec, [SYS_fstat] sys_fstat, [SYS_chdir] sys_chdir, [SYS_dup] sys_dup, [SYS_getpid] sys_getpid, [SYS_sbrk] sys_sbrk, [SYS_sleep] sys_sleep, [SYS_uptime] sys_uptime, [SYS_open] sys_open, [SYS_write] sys_write, [SYS_mknod] sys_mknod, [SYS_unlink] sys_unlink, [SYS_link] sys_link, [SYS_mkdir] sys_mkdir, [SYS_close] sys_close, }; ``` - `p /x $sstatus`查看权限寄存器的状态 sstatus寄存器是RISC-V架构中用于管理S模式状态的关键寄存器,涉及中断处理、特权级别切换以及内存访问控制等多个方面。理解和正确使用sstatus中的各个字段对于实现高效的操作系统调度和异常处理至关重要。 - 将kernel目录中的`syscall.c`文件中的语句`p->trapframe->a7`设置为`num = * (int *) 0;`后,重新启动make qemu启动后出现错误:终端输出内容如下: ``` xv6 kernel is booting hart 1 starting hart 2 starting scause=0xd sepc=0x80001c82 stval=0x0 panic: kerneltrap ``` - 针对0x80001c82位置进行调试,重新通过`make qemu-gdb`和`gdb-multiarch kernel`以及`target remote localhost:26000`等初始化程序后,设置上面panic处的断点,设置方法为:`b *0x80001c82`,会打印处断点在源代码中的位置行, 然后输入`layout asm`(分开split成上下窗口,按`c`后上面将显示执行的汇编的代码栈)。可以通过`p p->name`打印处出现kernel启动时的函数`initcode` ## 附录:相关代码及注释: - userinit ``` // Set up first user process. userinit(); // first user process在main函数里被调用 void userinit(void) { struct proc *p; p = allocproc(); //分配并初始化一个新的进程结构体,返回一个指向新进程的指针。 initproc = p; // initproc 是一个全局变量,它保存第一个用户进程的引用。 // allocate one user page and copy initcode's instructions // and data into it. uvmfirst(p->pagetable, initcode, sizeof(initcode));//uvmfirst() 函数将 initcode 中的指令和数据复制到新进程的用户空间。initcode 是一个内核中的静态数据(存放在 initcode.S 中的汇编代码),这段代码会被加载到进程的内存中,通常是一个单页大小(PGSIZE)。 p->sz = PGSIZE; // prepare for the very first "return" from kernel to user. p->trapframe->epc = 0; // user program counter, 这行代码设置进程的 epc(程序计数器),它指向 initcode.S 的起始位置。在 xv6 中,epc 是进程的程序计数器,指示程序执行的位置。这里设置为 0,意味着进程会从 initcode.S 的起始位置开始执行。 p->trapframe->sp = PGSIZE; // user stack pointer,这行代码设置进程的栈指针(sp)。它被设置为 PGSIZE(即 1 页的大小),表示栈的起始位置。栈指针通常指向内存页的末尾,在执行用户程序时,栈会从这里向下增长。 safestrcpy(p->name, "initcode", sizeof(p->name));//这行代码将进程的名称设置为 "initcode"。 p->cwd = namei("/");//这行代码设置进程的当前工作目录为根目录 /。 p->state = RUNNABLE; //这行代码将进程的状态设置为 RUNNABLE,表示它已经准备好被调度器执行。进程此时已经可以被调度器选中并开始执行。 release(&p->lock); //最后,释放进程的锁,允许其他操作(如调度和管理该进程)。 } ``` - initcode.S ``` # Initial process that execs /init. # This code runs in user space. #include "syscall.h" # exec(init, argv) .globl start start: la a0, init // 将 "/init" 字符串的地址加载到 a0 寄存器(第一个参数:路径) la a1, argv //将 argv 数组的地址加载到 a1 寄存器(第二个参数:参数列表) li a7, SYS_exec //将系统调用号 SYS_exec 加载到 a7 寄存器 , a7 寄存器保存了系统调用号 SYS_exec,这意味着 ecall 会触发执行 exec 系统调用。 ecall //执行系统调用(此时进行 exec("/init", argv)) # for(;;) exit(); 如果 exec 调用失败(例如,找不到 /init 程序,或加载失败),则会继续执行 start 中 ecall 之后的代码,即跳转到 exit 标签: exit: li a7, SYS_exit ecall jal exit # char init[] = "/init\0"; init: .string "/init\0" # char *argv[] = { init, 0 }; .p2align 2 argv: .quad init .quad 0 ``` - init程序 init 程序的主要作用就是启动并管理 sh(shell)程序。在 xv6 操作系统中,init 程序会启动一个名为 sh 的程序,并保持它的运行。如果 sh 退出,init 会重新启动它。sh 是一个 shell 程序,它的功能是提供一个交互式的命令行界面,允许用户输入并执行命令。 它是 xv6 操作系统中最简单的 shell 实现,通常用于启动其他程序并执行系统命令。在 xv6 中,sh 主要负责处理用户的输入并执行对应的系统调用,它并不包含更复杂的功能,如现代 shell 中常见的管道、文件重定向或脚本执行等功能。 ``` // init: The initial user-level program #include "kernel/types.h" #include "kernel/stat.h" #include "kernel/spinlock.h" #include "kernel/sleeplock.h" #include "kernel/fs.h" #include "kernel/file.h" #include "user/user.h" #include "kernel/fcntl.h" char *argv[] = { "sh", 0 }; int main(void) { int pid, wpid; if(open("console", O_RDWR) < 0){ //该段代码试图打开名为 "console" 的设备文件,如果文件不存在,则会调用 mknod 创建一个新的控制台设备节点,类型为 CONSOLE(通常是一个字符设备)。接着再次尝试打开控制台设备文件。 mknod("console", CONSOLE, 0); //这里的 open("console", O_RDWR) 尝试以读写模式打开控制台设备。控制台通常用于接收用户输入和输出程序的输出。 open("console", O_RDWR); } dup(0); // stdout //这两行代码分别将文件描述符 0(标准输入,通常指向控制台)复制到标准输出(文件描述符 1) dup(0); // stderr //和标准错误(文件描述符 2)。这样,标准输出和标准错误都将指向控制台,从而实现了输出到控制台。 for(;;){ printf("init: starting sh\n"); pid = fork(); //fork() 创建子进程:fork() 用于创建一个新的子进程。成功时,父进程返回子进程的 PID(进程 ID),而子进程返回 0。如果 fork() 失败,打印错误信息并退出。 if(pid < 0){ printf("init: fork failed\n"); exit(1); } if(pid == 0){//子进程返回为0 exec("sh", argv); printf("init: exec sh failed\n"); exit(1); } for(;;){ // this call to wait() returns if the shell exits, // or if a parentless process exits. wpid = wait((int *) 0);//父进程等待子进程结束 否则, if(wpid == pid){ //如果 wait() 返回的 PID 是刚才创建的子进程的 PID(即 pid),则说明 sh 程序已退出,父进程会跳出内层循环并重新启动 sh。 // the shell exited; restart it. break; } else if(wpid < 0){ //如果 wait() 调用失败,父进程会打印错误信息并退出。 printf("init: wait returned an error\n"); exit(1); } else { //如果 wait() 返回的是一个无父进程的孤儿进程的 PID,父进程不做任何操作。 // it was a parentless process; do nothing. } } } } ```