6.S081 Lab00 xv6启动过程
0. 说明与简介
说明:这里为了和lab 0区分用了lab 00来代替,以后还可能会有Lab 000 (xv6的启动代码分析 – bios)
主要内容:简单的介绍XV6是如何从0开始直到第一个Shell程序运行起来
如果时间不够,可以只看3.关键知识点总结。
文章目录
1. 调试命令
(1) gdb开启qemu的调试
首先在xv6-riscv目录下,运行命令make CPUS=1 qemu-gdb
:
注意:这里为什么是 CPUS = 1
?–为了方便调试,我们把CPU设置为1,而不是默认的4(正如前面6.S081-2内核的隔离性(isolation)所讲,qemu模拟的riscv是4核的)。 在单核或者单线程场景下,单个断点就可以停止整个程序的运行。
wc@r740:~/OS_experiment/xv6-riscv-fall19$ make CPUS=1 qemu-gdb
sed "s/:1234/:26017/" < .gdbinit.tmpl-riscv > .gdbinit
*** Now run 'gdb' in another window.
qemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel -m 128M -smp 1 -nographic -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 -S -gdb tcp::26017
(2) 开启gdb
然后根据提示,Now run ‘gdb’ in another window. – 在新的terminal下,打开gdb进行调试,调试命令如下
(注意最后一行的tcp::26017这点后面要用到(可能你的端口数字未必和我一样,后面用自己的端口号就行))
$ riscv64-unknown-elf-gdb kernel/kernel
运行上面命令后可以看到一大堆输出,如果此时最后一行变成了(gdb)说明我们成功进入了gdb。但是我们还没有成功进入调试环境!!!– 这里还是主机环境上的gdb,但是想要调试xv6的kernel,需要在riscv的机器上进行。
(3) 将gdb远程连接到qemu上
在新打开的gdb环境下,远程链接qemu (这里的26017是tcp的端口号,来源于make CPUS=1 qemu-gdb
命令的最后一行输出,因为自己电脑上的某些端口可能被占用,因此这个端口号每个人或许不同,请注意修改)
(gdb) target remote localhost:26017
结果为
Remote debugging using localhost:26017
0x0000000000001000 in ?? ()
以上结果表示成功进入gdb,接下来可以对kernel进行调试。这部分可以参考 HITSZ_OS手册
# b //设置断点(breakpoint)
# c //继续运行(Continue)--到断点处就会停止
# n //单步调试
# p //打印变量内容
# si //断点定位
# display/i $pc //每次停止时,都可以显示下一条指令的反汇编
# layout asm //可以显示当前的汇编指令
# display /3i $pc //如果您希望在单步执行程序时自动显示下3条指令,可以使用display命令
# p *path@6 //这里是打印path指向的内容,打印长度为6(如果不写@6那么只能打印一个char)
2. 设置断点,进行调试
(0) 第一条指令的执行 + 第一个断点 (运行在machine mode)
首先是在_entry处设置断点(因为_entry是kernel的第一条指令 – 可以通过查看kernel.asm查看)
kernel.asm (内核的汇编代码)部分代码如下:
kernel/kernel: file format elf64-littleriscv
Disassembly of section .text:
0000000080000000 <_entry>:
80000000: 0000b117 auipc sp,0xb
80000004: 80010113 addi sp,sp,-2048 # 8000a800 <stack0>
80000008: 6505 lui a0,0x1
8000000a: f14025f3 csrr a1,mhartid
8000000e: 0585 addi a1,a1,1
80000010: 02b50533 mul a0,a0,a1
80000014: 912a add sp,sp,a0
80000016: 070000ef jal ra,80000086 <start>
000000008000001a <junk>:
8000001a: a001 j 8000001a <junk>
000000008000001c <timerinit>:
// which arrive at timervec in kernelvec.S,
// which turns them into software interrupts for
// devintr() in trap.c.
void
timerinit()
{
8000001c: 1141 addi sp,sp,-16
8000001e: e422 sd s0,8(sp)
80000020: 0800 addi s0,sp,16
// which hart (core) is this?
static inline uint64
- 设置断点,并继续运行代码到断点处,如下:
(gdb) b _entry
Breakpoint 1 at 0x8000000a
(gdb) c
Continuing.
Breakpoint 1, 0x000000008000000a in _entry ()
可以看到,我们并没有停在0x80000000
(即_entry处),而是停在了0x8000000a
处
问题:为什么第一条指令的地址是0x80000000
???
– 0x80000000
是一个被qemu认可的地址,如果想要使用qemu,第一条指令就必须在0x80000000
处
这一点可以打开kernel.ld查看(关于kernel.ld的分析,可以看好文链接)
OUTPUT_ARCH( "riscv" )
ENTRY( _entry )
SECTIONS
{
/*
* ensure that entry.S / _entry is at 0x80000000,
* where qemu's -kernel jumps.
*/
. = 0x80000000;
.text :
{
*(.text)
. = ALIGN(0x1000);
*(trampsec)
}
. = ALIGN(0x1000);
PROVIDE(etext = .);
/*
* make sure end is after data and bss.
*/
.data : {
*(.data)
}
.bss : {
*(.bss)
*(.sbss*)
PROVIDE(end = .);
}
}
- 回到gdb中,可以看到已经在_entry里面了,
0x8000000a
处的汇编代码为:csrr a1,mhartid
它的长度是:2B。因此它的下一条指令地址为:0x000000008000000e
,对应的汇编是:addi a1,a1,1
.
(1) main() 的执行 (进入kernel mode)
- 接下来继续打一个断点在
main()
处,可以得到如下结果 (XV6从entry.s开始启动,这个时候没有内存分页,没有隔离性,并且运行在M-mode(machine mode)。XV6会尽可能快的跳转到kernel mode或者说是supervisor mode。我们在main函数设置一个断点,main函数已经运行在supervisor mode了。接下来我运行程序,代码会在断点,也就是main函数的第一条指令停住。)
26017: No such file or directory.
(gdb) target remote localhost:26017
Remote debugging using localhost:26017
0x0000000000001000 in ?? ()
(gdb) b _entry
Breakpoint 1 at 0x8000000a
(gdb) display/i $pc
1: x/i $pc
=> 0x1000: auipc t0,0x0
(gdb) c
Continuing.
Breakpoint 1, 0x000000008000000a in _entry ()
1: x/i $pc
=> 0x8000000a <_entry+10>: csrr a1,mhartid
(gdb) si
0x000000008000000e in _entry ()
1: x/i $pc
=> 0x8000000e <_entry+14>: addi a1,a1,1
(gdb) b main
Breakpoint 2 at 0x80000d38: file kernel/main.c, line 17.
(gdb) c
Continuing.
Breakpoint 2, main () at kernel/main.c:17
17 if(cpuid() == 0){
1: x/i $pc
=> 0x80000d38 <main+8>: auipc ra,0x1
(2) 一系列(kernel)初始化
- 接下来是单步调试(紧接上面的内容)↓,可以看到调用了
consoleinit()
函数,这个函数就是像它的名字一样,初始化console。
(gdb) n
18 consoleinit();
1: x/i $pc
=> 0x80000d8e <main+94>: auipc ra,0xfffff
- init完console之后,就可以向控制台输出内容了,继续单步调试,可以看到
printfinit()
,打印\n,打印"xv6 kernel is booting\n"
– 可以回到qemu的界面,可以看到如下结果(在下面第二个)
调试命令和结果:
(gdb) n
19 printfinit();
1: x/i $pc
=> 0x80000d96 <main+102>: auipc ra,0x0
(gdb) n
20 printf("\n");
1: x/i $pc
=> 0x80000d9e <main+110>: auipc a0,0x7
(gdb) n
21 printf("xv6 kernel is booting\n");
1: x/i $pc
=> 0x80000dae <main+126>: auipc a0,0x7
qemu的结果:
wc@r740:~/OS_experiment/xv6-riscv-fall19$ make CPUS=1 qemu-gdb
*** Now run 'gdb' in another window.
qemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel -m 128M -smp 1 -nographic -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 -S -gdb tcp::26017
xv6 kernel is booting
- 查看kernel的主程序如下(
kernel/main.c
),可以看到,除了以上的console和printf的初始化,还有很多代码做初始化
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "riscv.h"
#include "spinlock.h"
#include "sleeplock.h"
#include "fs.h"
#include "file.h"
#include "defs.h"
volatile static int started = 0;
// start() jumps here in supervisor mode on all CPUs.
void
main()
{
if(cpuid() == 0){
consoleinit();
printfinit();
printf("\n");
printf("xv6 kernel is booting\n");
printf("\n");
kinit(); // physical page allocator
kvminit(); // create kernel page table
kvminithart(); // turn on paging
procinit(); // process table
trapinit(); // trap vectors
trapinithart(); // install kernel trap vector
plicinit(); // set up interrupt controller
plicinithart(); // ask PLIC for device interrupts
binit(); // buffer cache
iinit(); // inode cache
fileinit(); // file table
virtio_disk_init(minor(ROOTDEV)); // emulated hard disk
userinit(); // first user process
__sync_synchronize();
started = 1;
} else {
while(started == 0)
;
__sync_synchronize();
printf("hart %d starting\n", cpuid());
kvminithart(); // turn on paging
trapinithart(); // install kernel trap vector
plicinithart(); // ask PLIC for device interrupts
}
scheduler();
}
(3) 用户初始化
- 继续单步调试,然后s进入
userinit()
函数,其c语言代码如下
// Set up first user process.
void
userinit(void)
{
struct proc *p;
p = allocproc();
initproc = p;
// allocate one user page and copy init's instructions
// and data into it.
uvminit(p->pagetable, initcode, sizeof(initcode));
p->sz = PGSIZE;
// prepare for the very first "return" from kernel to user.
p->tf->epc = 0; // user program counter
p->tf->sp = PGSIZE; // user stack pointer
safestrcpy(p->name, "initcode", sizeof(p->name));
p->cwd = namei("/");
p->state = RUNNABLE;
release(&p->lock);
}
userinit有点像是胶水代码/Glue code(胶水代码不实现具体的功能,只是为了适配不同的部分而存在),它利用了XV6的特性,并启动了第一个进程。我们总是需要有一个用户进程在运行,这样才能实现与操作系统的交互,所以这里需要一个小程序来初始化第一个用户进程。这个小程序定义在initcode中。
// a user program that calls exec("/init")
// od -t xC initcode
uchar initcode[] = {
0x17, 0x05, 0x00, 0x00, 0x13, 0x05, 0x05, 0x02,
0x97, 0x05, 0x00, 0x00, 0x93, 0x85, 0x05, 0x02,
0x9d, 0x48, 0x73, 0x00, 0x00, 0x00, 0x89, 0x48,
0x73, 0x00, 0x00, 0x00, 0xef, 0xf0, 0xbf, 0xff,
0x2f, 0x69, 0x6e, 0x69, 0x74, 0x00, 0x00, 0x01,
0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00
};
可以看到,这个函数是二进制代码的形式,其实它对应着inticode.S中的代码↓
# Initial process execs /init.
# This code runs in user space.
#include "syscall.h"
# exec(init, argv)
.globl start
start:
la a0, init
la a1, argv
li a7, SYS_exec
ecall
# for(;;) exit();
exit:
li a7, SYS_exit
ecall
jal exit
# char init[] = "/init\0";
init:
.string "/init\0"
# char *argv[] = { init, 0 };
.p2align 2
argv:
.long init
.long 0
解释上面代码
# 把init对应的地址 load 到a0
la a0, init
# 把argv对应的地址 load 到a1
la a1, argv
# exec系统调用对应的数字load 到a7
li a7, SYS_exec
# 调用ECALL
ecall
(4) 用户初始化 - 系统调用(执行exec)
- 在
syscall
处设置断点,并跳进去,可以看到执行第一条代码是struct proc *p = myproc();
num = p->trapframe->a7
会读取使用的系统调用对应的整数。当代码执行完这一行之后,我们可以在gdb中打印num,可以看到是7,查看syscall.h,可以看到7对应的是exec系统调用,所以,这里本质上是告诉内核,某个用户应用程序执行了ECALL指令,并且想要调用exec系统调用。
(gdb) b syscall
Breakpoint 3 at 0x80002ae0: file kernel/syscall.c, line 138.
(gdb) c
Continuing.
Breakpoint 3, syscall () at kernel/syscall.c:138
138 {
1: x/i $pc
=> 0x80002ae0 <syscall>: addi sp,sp,-32
(gdb) s
140 struct proc *p = myproc();
1: x/i $pc
=> 0x80002aec <syscall+12>: auipc ra,0xfffff
(gdb) p num
$1 = 7
查看syscall.h
// System call numbers
#define SYS_fork 1
#define SYS_exit 2
#define SYS_wait 3
#define SYS_pipe 4
#define SYS_read 5
#define SYS_kill 6
#define SYS_exec 7
#define SYS_fstat 8
#define SYS_chdir 9
#define SYS_dup 10
#define SYS_getpid 11
#define SYS_sbrk 12
#define SYS_sleep 13
#define SYS_uptime 14
#define SYS_open 15
#define SYS_write 16
#define SYS_mknod 17
#define SYS_unlink 18
#define SYS_link 19
#define SYS_mkdir 20
#define SYS_close 21
// System calls for labs
#define SYS_ntas 22
#define SYS_crash 23
#define SYS_mount 24
#define SYS_umount 25
- 查看syscall的代码(要去执行
sys_exec
)
void
syscall(void)
{
int num;
struct proc *p = myproc();
num = p->tf->a7;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
p->tf->a0 = syscalls[num]();
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->tf->a0 = -1;
}
}
- 在来看
sys_exec
的定义:
uint64
sys_exec(void)
{
char path[MAXPATH], *argv[MAXARG];
int i;
uint64 uargv, uarg;
if(argstr(0, path, MAXPATH) < 0 || argaddr(1, &uargv) < 0){
return -1;
}
memset(argv, 0, sizeof(argv));
for(i=0;; i++){
if(i >= NELEM(argv)){
goto bad;
}
if(fetchaddr(uargv+sizeof(uint64)*i, (uint64*)&uarg) < 0){
goto bad;
}
if(uarg == 0){
argv[i] = 0;
break;
}
argv[i] = kalloc();
if(argv[i] == 0)
panic("sys_exec kalloc");
if(fetchstr(uarg, argv[i], PGSIZE) < 0){
goto bad;
}
}
int ret = exec(path, argv);
for(i = 0; i < NELEM(argv) && argv[i] != 0; i++)
kfree(argv[i]);
return ret;
bad:
for(i = 0; i < NELEM(argv) && argv[i] != 0; i++)
kfree(argv[i]);
return -1;
}
sys_exec
处设置断点,可以看到,首先读取了path (打印出来发现是"\init"
),也就是exec("\init", argv)
。所以,综合来看,initcode完成了通过exec调用init程序。
(gdb) b sys_exec
Breakpoint 4 at 0x80005b34: file kernel/sysfile.c, line 414.
(gdb) c
Continuing.
Breakpoint 4, sys_exec () at kernel/sysfile.c:414
414 {
1: x/i $pc
=> 0x80005b34 <sys_exec>: addi sp,sp,-464
(gdb) s
419 if(argstr(0, path, MAXPATH) < 0 || argaddr(1, &uargv) < 0){
1: x/i $pc
=> 0x80005b46 <sys_exec+18>: li a2,128
(gdb) p *path@6
$10 = "/init"
- 让我们来看看init程序
// init: The initial user-level program
#include "kernel/types.h"
#include "kernel/stat.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){
mknod("console", 1, 1);
open("console", O_RDWR);
}
dup(0); // stdout
dup(0); // stderr
for(;;){
printf("init: starting sh\n");
pid = fork();
if(pid < 0){
printf("init: fork failed\n");
exit(1);
}
if(pid == 0){
exec("sh", argv);
printf("init: exec sh failed\n");
exit(1);
}
while((wpid=wait(0)) >= 0 && wpid != pid){
//printf("zombie!\n");
}
}
}
-
可以看到,init为用户设置了console,stdout,stderr,并fork(),然后在子程序中打开shell。 – 如果继续运行代码,又会陷入syscall和sys_exec的断点,这次是用exec运行shell导致的。shell运行之后,可以看到qemu也有了shell。(这个部分有点像CSAPP的内容,init是用户的第一个进程(第二个是shell),所有其他用户进程,都是由init,fork()而来)
——而如上面过程init又是kernel的main函数调用的。 -
就这样,xv6就彻底启动起来了(没有分析page table的设置,以后会补上)
wc@r740:~/OS_experiment/xv6-riscv-fall19$ make CPUS=1 qemu-gdb
*** Now run 'gdb' in another window.
qemu-system-riscv64 -machine virt -bios none -kernel kernel/kernel -m 128M -smp 1 -nographic -drive file=fs.img,if=none,format=raw,id=x0 -device virtio-blk-device,drive=x0,bus=virtio-mmio-bus.0 -S -gdb tcp::26017
xv6 kernel is booting
virtio disk init 0
page table 0x0000000087f6e000
..0: pte 0x0000000021fda801 pa 0x0000000087f6a000
.. ..0: pte 0x0000000021fda401 pa 0x0000000087f69000
.. .. ..0: pte 0x0000000021fdac1f pa 0x0000000087f6b000
.. .. ..1: pte 0x0000000021fda00f pa 0x0000000087f68000
.. .. ..2: pte 0x0000000021fd9c1f pa 0x0000000087f67000
..255: pte 0x0000000021fdb401 pa 0x0000000087f6d000
.. ..511: pte 0x0000000021fdb001 pa 0x0000000087f6c000
.. .. ..510: pte 0x0000000021fdd807 pa 0x0000000087f76000
.. .. ..511: pte 0x000000002000200b pa 0x0000000080008000
init: starting sh
$
3. 关键知识点总结
1. 过程总结
- 首先是bootloader找到kernel的第一条指定的地址;
- 然后进入machine mode,开始执行一些简单的kernel mode运行前的准备;
- 很快就进入到kernel mode (kernel main()函数),一系列的kernel初始化(第一条是console,然后是printf,然后是…)
- kernel main()函数调用init,进入用户初始化(user mode);-- 用户初始化需要通过系统调用syscall,调用sys_exec
- 用户初始化(user mode):init为用户设置了console,stdout,stderr,并fork(),然后在子程序中打开shell。
2. 精彩摘要
可以看到,init为用户设置了console,stdout,stderr,并fork(),然后在子程序中打开shell。 – 如果继续运行代码,又会陷入syscall和sys_exec的断点,这次是用exec运行shell导致的。shell运行之后,可以看到qemu也有了shell。(这个部分有点像CSAPP的内容,init是用户的第一个进程(第二个是shell),所有其他用户进程,都是由init,fork()而来)
——而如上面过程init又是kernel的main函数调用的。