实验一: 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后返回如下,设置了断点

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_execSYS_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-gdbgdb-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.
     }
   }
 }
}