实验一: 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 serverb 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.
}
}
}
}