蓝色步行者

每个人都有自己的梦想


  • 首页

  • 归档

  • 标签

  • 分类

  • 搜索

笔记07 - xv6 启动到执行第一个进程

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

xv6的boot loader从硬盘加载xv6内核到内存并在entry处开始执行,此时xv6还没开启分页,virtual addresses直接映射到physical addresses。boot loader将内核加载到物理地址0x100000,不加载在0x80100000(内核期望由此地址寻找指令和数据)的原因是机器不一定有这么多内存,不加载在0x0的原因是0xa0000:0x100000的范围内包含了IO设备。为了允许剩余的内核代码可以正常运行,entry设置了页表,映射virtual addresses 0x80000000(KERNBASE)到physical addresses 0x0。entry分别映射virtual addresses 0:0x400000 到 physical addresses 0:0x400000,以及virtual addresses KERNBASE:KERNBASE+0x400000 到 physical addresses 0:0x400000,这也要求内核指令和数据占据的空间在4M以内。以下主要分析x86启动分页、内核初始化、启动多处理器、启动init进程的过程。

xv6的进程结构以及进程调度涉及到进程的有三大数据结构:struct cpu、struct proc、struct context。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
// proc.h

// Per-CPU state
struct cpu {
uchar apicid; // Local APIC ID
struct context *scheduler; // swtch() here to enter scheduler
struct taskstate ts; // Used by x86 to find stack for interrupt
struct segdesc gdt[NSEGS]; // x86 global descriptor table
volatile uint started; // Has the CPU started?
int ncli; // Depth of pushcli nesting.
int intena; // Were interrupts enabled before pushcli?

// Cpu-local storage variables; see below
struct cpu *cpu;
struct proc *proc; // The currently-running process.
};

enum procstate { UNUSED, EMBRYO, SLEEPING, RUNNABLE, RUNNING, ZOMBIE };

// Per-process state
struct proc {
uint sz; // Size of process memory (bytes)
pde_t* pgdir; // Page table
char *kstack; // Bottom of kernel stack for this process
enum procstate state; // Process state
int pid; // Process ID
struct proc *parent; // Parent process
struct trapframe *tf; // Trap frame for current syscall
struct context *context; // swtch() here to run process
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
};


//PAGEBREAK: 17
// Saved registers for kernel context switches.
// Don't need to save all the segment registers (%cs, etc),
// because they are constant across kernel contexts.
// Don't need to save %eax, %ecx, %edx, because the
// x86 convention is that the caller has saved them.
// Contexts are stored at the bottom of the stack they
// describe; the stack pointer is the address of the context.
// The layout of the context matches the layout of the stack in swtch.S
// at the "Switch stacks" comment. Switch doesn't save eip explicitly,
// but it is on the stack and allocproc() manipulates it.
struct context {
uint edi;
uint esi;
uint ebx;
uint ebp;
uint eip;
};

附上file、inode以及trapframe结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
// file.h
struct file {
enum { FD_NONE, FD_PIPE, FD_INODE } type;
int ref; // reference count
char readable;
char writable;
struct pipe *pipe;
struct inode *ip;
uint off;
};

// in-memory copy of an inode
struct inode {
uint dev; // Device number
uint inum; // Inode number
int ref; // Reference count
struct sleeplock lock;
int flags; // I_VALID

short type; // copy of disk inode
short major;
short minor;
short nlink;
uint size;
uint addrs[NDIRECT+1];
};

// x86.h
//PAGEBREAK: 36
// Layout of the trap frame built on the stack by the
// hardware and by trapasm.S, and passed to trap().
struct trapframe {
// registers as pushed by pusha
uint edi;
uint esi;
uint ebp;
uint oesp; // useless & ignored
uint ebx;
uint edx;
uint ecx;
uint eax;

// rest of trap frame
ushort gs;
ushort padding1;
ushort fs;
ushort padding2;
ushort es;
ushort padding3;
ushort ds;
ushort padding4;
uint trapno;

// below here defined by x86 hardware
uint err;
uint eip;
ushort cs;
ushort padding5;
uint eflags;

// below here only when crossing rings, such as from user to kernel
uint esp;
ushort ss;
ushort padding6;
};

启动分页

启动分页之前必须创建页表并设置给cr3寄存器,然后给cr0寄存器的PG位置为1。除此之外,x86还允许创建不同粒度的内存页,这涉及到cr4寄存器。在x86中,一个也目录中可以同时存在两种粒度的内存页,4K或者4M。page dir是必须的,这是一个长度最大为1024的整数数组,如果页目录表项的PS位置为1且cr4寄存器的PSE位置为1,那么CPU自动使用4M大小的内存页,即该页目录表项中保存的就是内存页的起始地址,这相当于进行二级分页而不是更常见的三级分页。如果这两个要求不能同时满足就进行三级分页。注意x86运行两种分页同时存在,比如cr4的PSE位设为1,而有些page dir表项设置PS位而有些则不设置,这样就同时存在两种分页机制。为何要使用4MB页呢,考虑这种场景:kernel img大小大约为1M,如果使用4M页映射kernel img,则TLB只需缓存一个页目录项即可,而如果是4K页则需要256个页目录项,这么多的表项是无法都缓存到TLB中的,这会使得地址翻译变慢很多。所以kernel img部分一般用一个4M页进行映射,而其他则使用4K页。对于xv6来说,使用4M页只是临时,不用创建复杂的页表,如此而已,内核启动之后很快就会重新创建页表。

为何要将0~4M进行1:1映射呢?在开启分页之前我们都是小心翼翼的使用“低地址”,
而打开分页之后我们将会跳转到“高地址”,低地址还有必要映射吗?有必要。在启动多处理器的时候,还需要从低地址启动,因为这些CPU(non-boot CPU,也叫做AP)要需要从real mode启动,见entryother.S。

内核初始化

进入main函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
int
main(void)
{
//根据entry.S里面对栈的设置,可知栈顶位置在end之前。(存疑:根据objdump -h kernel结果和kernel.asm,entry.S里stack的位置并不在.data节内,为什么?)
//将[end,4M](end先4K对齐)范围free为kmem,此时未开启分页,使用的是硬编码的4m地址映射。
//kmem锁初始化,处于解锁状态,占有kmem锁的CPU数目为0
//end: first address after kernel loaded from ELF file
kinit1(end, P2V(4*1024*1024)); // phys page allocator // see in kalloc.c
//为scheduler进程创建内核页目录,根据kmap设定将所有涉及范围内的内核空间虚拟地址
//(从KERNBASE开始,把IO空间、内核镜像等都建立映射,这些空间全都是用户空间可见的,位于2GB以上)按页大小映射到物理地址上,
//实际上是创建二级页表,并在二级页表项上存储物理地址。将页目录地址存储到cr3中。
//页目录项所存页表的权限是用户可读写
//二级页表项所存物理页的权限按照kmap设定
kvmalloc(); // kernel page table //see in vm.c
//
mpinit(); // detect other processors
lapicinit(); // interrupt controller
//初始化段寄存器,更新当前cpu
seginit(); // segment descriptors // see in vm.c
//
cprintf("\ncpu%d: starting xv6\n\n", cpunum());
//
picinit(); // another interrupt controller
//
ioapicinit(); // another interrupt controller
//
consoleinit(); // console hardware
//
uartinit(); // serial port
//进程表锁初始化,设置进程表处于解锁状态,占有进程表锁的CPU数目为0
pinit(); // process table //see in proc.c
//建立正常的中断/陷阱门描述符,中断处理锁初始化,处于解锁状态,占有中断处理锁的CPU数目为0
tvinit(); // trap vectors //see in trap.c
//
binit(); // buffer cache
//
fileinit(); // file table
//
ideinit(); // disk
//
if(!ismp)
timerinit(); // uniprocessor timer
//
startothers(); // start other processors
//将[4M,PHYSTOP]范围free为kmem,此时已经开启分页。
//设置计数表示kmem锁处于使用状态
kinit2(P2V(4*1024*1024), P2V(PHYSTOP)); // must come after startothers() // see in kalloc.c

//1.
//在进程表中寻找slot,成功的话更改进程状态embryo和pid,并初始化进程的内核栈
//2.
//为进程创建内核页目录,根据kmap设定将所有涉及范围内的内核空间虚拟地址
//(从KERNBASE开始,把IO空间、内核镜像等都建立映射,这些空间全都是用户空间可见的,位于2GB以上)按页大小映射到物理地址上,
//实际上是创建二级页表,并在二级页表项上存储物理地址。
//每个进程都会新建页目录和二级页表
//页目录项所存页表的权限是用户可读写
//二级页表项所存物理页的权限按照kmap设定
//3.
//从kmem上分配一页的物理空间给进程,在新建进程的页目录上映射[0,PGSIZE]的虚拟地址到分配的物理地址上,将指向内容复制到物理内存上
//页目录项所存页表的权限是用户可读写
//二级页表项所存物理页的权限为用户可读写
//4.
//设置进程的trapframe
//设置进程状态runnable
userinit(); // first user process //see in proc.c
//idtinit()加载中断描述符表寄存器
//scheduler()无限循环寻找进程状态为runnable的进程
//对于每次循环:
//开中断运行当前进程
//找到可执行的进程后,更新全局变量proc为当前被选中的进程
//设置cpu环境后,加载选中进程的页目录地址到cr3,切换为进程的页目录
//更改进程状态为running
//CPU调度
//加载内核的页目录地址到cr3,切换为内核的页目录
mpmain(); // finish this processor's setup
}

//vm.c
static struct kmap {
void *virt;
uint phys_start;
uint phys_end;
int perm;
} kmap[] = {
{ (void*)KERNBASE, 0, EXTMEM, PTE_W}, // I/O space
{ (void*)KERNLINK, V2P(KERNLINK), V2P(data), 0}, // kern text+rodata
{ (void*)data, V2P(data), PHYSTOP, PTE_W}, // kern data+memory
{ (void*)DEVSPACE, DEVSPACE, 0, PTE_W}, // more devices
};

启动多处理器

(此部分待后续理解)
多处理器的启动是通过IPI,即Inter-Processor Instructions进行的,这是CPU间的通讯方式。startothers()来启动其他non-boot CPU,做法是:
复制启动代码到0x7000处,这部分代码相当于boot CPU的启动扇区代码。
为每个AP分配stack(是的,每个CPU都一个自己的stack)。
告诉每个AP,kernel入口在哪里(mpenter函数)。
告诉每个AP,页目录在哪里(entrypgdir)。
然后控制local apic进行CPU间通讯,依次启动其他CPU。启动之后他们会执行mpenter(),进而进入scheduler()开始执行程序。

启动init进程

boot CPU启动其他CPU之后,自己继续执行kinit2()初始化剩余的内存空间,然后开始启动init进程。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
//proc.c
//在进程表中寻找slot,成功的话更改进程状态embryo和pid,并初始化进程的内核栈
static struct proc*
allocproc(void)
{
struct proc *p;
char *sp;

acquire(&ptable.lock);

for(p = ptable.proc; p < &ptable.proc[NPROC]; p++)
if(p->state == UNUSED)
goto found;

release(&ptable.lock);
return 0;

found:
p->state = EMBRYO;
p->pid = nextpid++;

release(&ptable.lock);

// Allocate kernel stack.
if((p->kstack = kalloc()) == 0){
p->state = UNUSED;
return 0;
}
sp = p->kstack + KSTACKSIZE;

// Leave room for trap frame.
sp -= sizeof *p->tf;
p->tf = (struct trapframe*)sp;

// Set up new context to start executing at forkret,
// which returns to trapret.
sp -= 4;
*(uint*)sp = (uint)trapret;

sp -= sizeof *p->context;
p->context = (struct context*)sp;
memset(p->context, 0, sizeof *p->context);
p->context->eip = (uint)forkret;

return p;
}

//PAGEBREAK: 32
// Set up first user process.
void
userinit(void)
{
struct proc *p;
extern char _binary_initcode_start[], _binary_initcode_size[];

//在进程表中寻找slot,成功的话更改进程状态和pid,并初始化进程的内核栈
p = allocproc();

initproc = p;
//为进程创建内核页目录,根据kmap设定将所有涉及范围内的内核空间虚拟地址
//(从KERNBASE开始,把IO空间、内核镜像等都建立映射,这些空间全都是用户空间可见的,位于2GB以上)按页大小映射到物理地址上,
//实际上是创建二级页表,并在二级页表项上存储物理地址。
//每个进程都会新建页目录和二级页表
//页目录项所存页表的权限是用户可读写
//二级页表项所存物理页的权限按照kmap设定
if((p->pgdir = setupkvm()) == 0) //see in vm.c
panic("userinit: out of memory?");
//从kmem上分配一页的物理空间给进程,在新建进程的页目录上映射[0,PGSIZE]的虚拟地址到分配的物理地址上,将指向内容复制到物理内存上
//页目录项所存页表的权限是用户可读写
//二级页表项所存物理页的权限为用户可读写
inituvm(p->pgdir, _binary_initcode_start, (int)_binary_initcode_size);//see in vm.c
p->sz = PGSIZE;
memset(p->tf, 0, sizeof(*p->tf));
p->tf->cs = (SEG_UCODE << 3) | DPL_USER; // todo ???
p->tf->ds = (SEG_UDATA << 3) | DPL_USER; // todo ???
p->tf->es = p->tf->ds;
p->tf->ss = p->tf->ds;
p->tf->eflags = FL_IF;//allow hardware interrupts
p->tf->esp = PGSIZE;
p->tf->eip = 0; // beginning of initcode.S

safestrcpy(p->name, "initcode", sizeof(p->name));
p->cwd = namei("/");

// this assignment to p->state lets other cores
// run this process. the acquire forces the above
// writes to be visible, and the lock is also needed
// because the assignment might not be atomic.
acquire(&ptable.lock);

p->state = RUNNABLE;

release(&ptable.lock);
}

创建进程数据结构

allocproc()函数在全局变量ptable中寻找UNUSED的进程结构,如果找到就做必要的初始化,然后将其返回,否则返回0,即空指针。其初始化过程包括修改进程状态,设置进程pid,构建进程的kenel stack(每个进程都有一个对应的内核栈)。进程的内核栈是调用p->kstack = kalloc()分配的,而p->tf和p->context都在内核栈中。由上可知,进程结构体proc是在内核指令和数据所处的内存空间上,而内核栈是在end[]之后的内存空间上。
每个进程的创建都会调用allocproc()函数,不论是init进程还是fork创建的普通进程。调用allocproc()函数之后,进程的kernel stack初始化状态如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
                     +---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| | ss |
| +---------------+
| | esp |
| +---------------+
| | eflags |
| +---------------+
| | cs |
| +---------------+
| | eip | <-- 这里往上在iret时自动弹出到相关寄存器中
| +---------------+
| | err |
| +---------------+
| | trapno |
| +---------------+
| | ds |
| +---------------+
| | es |
| +---------------+
| | fs |
struct trapframe | +---------------+
| | gs |
| +---------------+
| | eax |
| +---------------+
| | ecx |
| +---------------+
| | edx |
| +---------------+
| | ebx |
| +---------------+
| | oesp |
| +---------------+
| | ebp |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +---------------+ <-- p->tf
| trapret | <-- 弹出进程tf,执行用户代码
/ +---------------+ <-- forkret will return to
| | eip(=forkret) | <-- return addr,启动log
| +---------------+
| | ebp |
| +---------------+
struct context | | ebx |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +-------+-------+ <-- p->context
| | |
| v |
| empty |
+---------------+ <-- p->kstack

注意trapframe里的eip正是进入用户态之后执行的程序的入口,init进程跟普通进程
的差别就在这里了,init进程设置的eip=0(见userinit()函数,可知进程将分配一页空间来存放initcode的指令和数据,其虚拟地址范围为0x0:4K,因此eip=0则会跳转执行initcode的第一条指令),在此之前0这里已经放置了initcode.S的内容,而普通进程这里设置的是elf->entry,即程序的main()函数。

构建进程地址空间

userinit函数调用setupkvm函数为进程创建内核页目录,根据kmap设定将所有涉及范围内的内核空间虚拟地址(从KERNBASE开始,把IO空间、内核镜像等都建立映射,这些空间全都是用户空间可见的,位于2GB以上)按页大小映射到物理地址上,实际上是创建二级页表,并在二级页表项上存储物理地址。每个进程都会新建页目录和二级页表,页目录项所存页表的权限是用户可读写,二级页表项所存物理页的权限按照kmap设定。根据kmap把kernel也映射到进程空间的原因是:执行initcode代码前将切换为进程页目录,为了能继续执行内核代码,需要把kernel也映射到进程空间。
之后userinit函数调用inituvm函数从kmem(end[]之后的内存空间)上分配一页的物理空间给进程,在新建进程的页目录上映射[0,PGSIZE]的虚拟地址到分配的物理地址上,将_binary_initcode_start指向内容复制到物理内存上。页目录项所存页表的权限是用户可读写,二级页表项所存物理页的权限为用户可读写。然后设置进程的tf值,这部分值将在执行trapret时弹出到相关寄存器中,将进程的状态更改为RUNNABLE。

进入调度器开始执行

boot CPU继续执行进入scheduler()函数,它线性遍历ptable寻找RUNNABLE的进程,找到之后就执行,无穷循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//proc.c
void
scheduler(void)
{
struct proc *p;

for(;;){
// Enable interrupts on this processor.
//进程开中断
sti();

// Loop over process table looking for process to run.
acquire(&ptable.lock);
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
if(p->state != RUNNABLE)
continue;

// Switch to chosen process. It is the process's job
// to release ptable.lock and then reacquire it
// before jumping back to us.
//更新全局变量proc为当前被选中的进程
proc = p;
//设置cpu环境后,加载选中进程的页目录地址到cr3,切换为进程的页目录
switchuvm(p); //see in vm.c
//更改进程状态为running
p->state = RUNNING;
swtch(&cpu->scheduler, p->context); //see in swtch.S
//调用swtch之后将不会返回内核,但会将下一条指令的eip进栈,swtch会保存context寄存器,并将cpu->scheduler指向保存位置,程序执行完毕后应该会返回执行内核代码,重新调度下一个进程。(中断时会执行相关中断处理函数,不会返回这里。)
//加载内核的页目录地址到cr3,切换为内核的页目录
switchkvm(); //see in vm.c

// Process is done running for now.
// It should have changed its p->state before coming back.
proc = 0;
}
release(&ptable.lock);
}
}

//vm.c
// 设置cpu环境后,加载进程的页目录地址到cr3
void
switchuvm(struct proc *p)
{
pushcli();
cpu->gdt[SEG_TSS] = SEG16(STS_T32A, &cpu->ts, sizeof(cpu->ts)-1, 0);
cpu->gdt[SEG_TSS].s = 0;
cpu->ts.ss0 = SEG_KDATA << 3;
cpu->ts.esp0 = (uint)proc->kstack + KSTACKSIZE;
// setting IOPL=0 in eflags *and* iomb beyond the tss segment limit
// forbids I/O instructions (e.g., inb and outb) from user space
cpu->ts.iomb = (ushort) 0xFFFF;
ltr(SEG_TSS << 3);
if(p->pgdir == 0)
panic("switchuvm: no pgdir");
lcr3(V2P(p->pgdir)); // switch to process's address space
popcli();
}

switchuvm(p),它会设置TSS段并且将其中的ss0设置为SEG_KDATA,把esp0设置成p->kstack+KSTACKSIZE,也就是该进程内核栈的栈底,然后ltr(SEG_TSS<<3),这会让CPU在执行iret时使用该TSS的内容。设置ss0,esp0的意思是:该进程以后被中断或者trap时,只要进入kernel,就使用这个stack,上述代码表明trap时将使用进程的内核栈。然后切换到进程页目录,注意,因为kernel空间也映射进用户也目录了,所以kernel在切换页目录之后依然正常执行,此时仍然使用的是CPU栈。标记进程状态为RUNNING之后开始执行swtch,看swtch(&cpu->scheduler, p->context);。注意到swtch会将当前context相关寄存器进栈,然后将cpu的context(即scheduler)指针指向当前esp(仍然是cpu栈,即在内核的指令和数据内存空间上),再将esp指向到进程的context,由于会改变cpu的scheduler指针指向,因此需要传递&cpu->scheduler。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
//swtch.S
.globl swtch
swtch:
movl 4(%esp), %eax #load *(esp + 4) into eax, that is &cpu->scheduler, pointer's addr
movl 8(%esp), %edx #load *(esp + 8) into edx, that is p->context, pointer's value

# Save old callee-save registers #进入swtch函数之前,eip已经进栈
pushl %ebp
pushl %ebx
pushl %esi
pushl %edi

# Switch stacks
movl %esp, (%eax) #cpu->scheduler现在指向了当前cpu栈顶,保存了相关context寄存器
movl %edx, %esp #esp指向进程的context

# Load new callee-save registers
popl %edi
popl %esi
popl %ebx
popl %ebp
ret

swtch的作用是保存相关context寄存器到cpu栈,将当前cpu的scheduler指向保存位置,然后将栈顶指针指向进程的context(进程的内核栈),将相关寄存器弹出之后执行ret指令,由于此时esp指向进程context的eip,因此将从栈中弹出该数据作为eip。该eip实际指向forkret函数入口地址,不同于call指令,调用该函数不会将下一条指令的地址进栈。因此后面forkret启动了一个log之后返回时将继续弹栈作为新的eip,即trapret地址,见tramasm.S。

1
2
3
4
5
6
7
8
9
10
//trapasm.S
.globl trapret
trapret:
popal #弹出通用寄存器中的上下文环境
popl %gs
popl %fs
popl %es
popl %ds
addl $0x8, %esp # trapno and errcode
iret

popal弹出一堆寄存器值,其中部分寄存器值在userinit函数中被指定了,如cs。popl又弹出一堆,然后addl $0x8, %esp越过trapno和errcode,此时esp指向进程tf的eip。执行iret时会把int指令自动压栈的内容再自动恢复回去,这里是eip、cs、eflags、esp、ss。eip在userinit函数中被设置为0,此处已经被inituvm()放置上initcode.S的内容了;esp在userinit函数中被设置为PGSIZE,即进程分配到的物理地址空间的顶部,此处作为栈顶(由此可以看出esp的地址也是虚拟地址,将根据进程页目录和页表被转化为物理地址)。之后回到用户空间的起始地址处执行initcode.S的内容。这个文件通过系统调用exec执行/init程序。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
//initcode.S
# exec(init, argv)
.globl start
start:
pushl $argv
pushl $init
pushl $0 // where caller pc would be
movl $SYS_exec, %eax
int $T_SYSCALL

//exec指令用新程序替代当前进程的内存和寄存器,但保留文件描述符、进程id、父进程不变。如果一切运行正常,exec将不会返回到这里,它将执行一个新的名为$init的程序。init进程根据需要创建终端设备文件,以文件描述符0、1、2打开它,然后循环开启一个控制台shell、处理孤立僵尸进程直到shell退出,重复循环。整个系统启动。
# for(;;) exit();
exit:
movl $SYS_exit, %eax
int $T_SYSCALL
jmp exit

# char init[] = "/init\0";
init:
.string "/init\0"

# char *argv[] = { init, 0 };
.p2align 2
argv:
.long init
.long 0

$argv、$init、$0将被存储在进程物理空间顶部。movl $SYS_exec, %eax把trapno放到%eax里面,之后int的时候会跳转到alltraps,其会执行pushal将包括eax在内的通用寄存器进栈(其实是保存到proc->tf),之后在类似syscall函数就可以通过proc->tf->eax获取trapno了。此时的esp指向0x00000ff4,根据initcode.asm可知int指令的下一条指令地址(即执行int时的eip为)0x00000013。
CPU在执行int指令时会执行以下动作:
1、栈转换,stack转换到tr寄存器指定的tss里面的ss0:esp0,这是该进程对应的内核栈,esp指向内核栈栈顶。
2、自动将相关寄存器的内容进栈:ss、esp、eflag、cs、eip。
3、跳转到中断处理程序处(trap.c),也就是vectorXXX处,这里为每个中断设置了一个中断处理程序,xv6的中断处理表是用vector.pl生成的(make之后见vectors.S),基本每个表项都是一样的。
(上述动作还应包含触发中断时的权限检查。)

1
2
3
4
5
.globl vector64
vector64:
pushl $0
pushl $64
jmp alltraps

内核栈如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
CPU执行int指令时自动压栈的,加上中断处理程序压栈
+---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| ss |
+---------------+
| esp | <-- 0x00000ff4(压进三个参数)
+---------------+
| eflags |
+---------------+
| cs |
+---------------+
| eip | <-- 0x00000013(int指令的下一条指令地址)
+---------------+
| err |
+---------------+
| trapno |
+---------------+ <-- %esp

跳转到alltrap处执行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
  # vectors.S sends all traps here.
.globl alltraps
alltraps:
# Build trap frame. //将用户进程的寄存器保存
pushl %ds
pushl %es
pushl %fs
pushl %gs
pushal

# Set up data and per-cpu segments.
#此时已经由p->tf->esp指向的进程用户栈切换到ssX:espX指定的栈中,
#这里是p->kstack指向的进程内核栈,当前进程相关的寄存器保存到了进程内核栈中。
movw $(SEG_KDATA<<3), %ax
movw %ax, %ds
movw %ax, %es
movw $(SEG_KCPU<<3), %ax
movw %ax, %fs
movw %ax, %gs

# Call trap(tf), where tf=%esp
pushl %esp #push %esp的原因是将当前esp指向的tf结构基地址作为传递的参数
call trap
addl $4, %esp

# Return falls through to trapret...
.globl trapret
trapret:
#主要是弹出保存在ssX:espX指定的栈中的进程相关的寄存器,返回执行进程代码。
#第一次切换到用户态执行代码时,ssX:espX指定的栈中的进程相关的寄存器是在userinit函数中指定的。
popal #弹出通用寄存器中的上下文环境
popl %gs
popl %fs
popl %es
popl %ds
addl $0x8, %esp # trapno and errcode
iret

alltraps将进程相关的寄存器压入到ssX:espX指定的栈中,这里是p->kstack指向的进程内核栈。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
                 /   +---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| | ss |
| +---------------+
| | esp |
| +---------------+
| | eflags |
| +---------------+
| | cs |
| +---------------+
| | eip | <-- 这里往上在iret时自动弹出到相关寄存器中
| +---------------+
| | err |
| +---------------+
| | trapno |
| +---------------+
| | ds |
| +---------------+
| | es |
| +---------------+
| | fs |
struct trapframe | +---------------+
| | gs |
| +---------------+
| | eax |
| +---------------+
| | ecx |
| +---------------+
| | edx |
| +---------------+
| | ebx |
| +---------------+
| | oesp |
| +---------------+
| | ebp |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +---------------+ <-- %esp

然后执行pushl %esp为trap()函数准备参数,调用trap()函数,处理所有的中断、trap,参数是一个trapframe。完成后弹出之前压栈的参数,%esp又指向trapframe结构了。到此为止,系统调用的实质工作已经完成了,接下来就要返回用户态了,也就是trapret,把进程状态相关的寄存器恢复回去。iret指令把int自动压栈的内容再自动恢复回去,这样程序就回到eip处了,即中断发生的指令处。

系统调用的执行结果和参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//syscall.c
void
syscall(void)
{
int num;
num = proc->tf->eax;
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) {
proc->tf->eax = syscalls[num]();
//cprintf("\n%s -> %d\n", syscall_name[num], proc->tf->eax);
} else {
cprintf("%d %s: unknown sys call %d\n",
proc->pid, proc->name, num);
proc->tf->eax = -1;
}
}

inidcode.S执行movl $SYS_exec, %eax把trapno放到%eax里面,之后int的时候会跳转到alltraps,其会执行pushal将包括eax在内的通用寄存器进栈(其实是保存到proc->tf),之后在syscall函数就可以通过proc->tf->eax获取trapno了。而系统调用的执行结果也放在了eax中。

1
2
3
4
5
6
7
//syscall.c
// 获取第n个32-bit参数
int
argint(int n, int *ip)
{
return fetchint(proc->tf->esp + 4 + 4*n, ip);
}

参考argint等函数,可知道系统调用的参数保存在tf->esp指向的进程用户栈中,userinit函数中将其指向进程数据和指令物理内存空间的顶端PAGESIZE,initcode传递的参数保存在这里。

以下是scheduler调度器调用swtch函数到initcode调用的第一个系统调用结束的栈变化。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//设置cpu环境后,加载选中进程的页目录地址到cr3,切换为进程的页目录。此时仍然使用的是cpu栈(即在内核的指令和数据内存空间上),且由于内核空间映射到了进程页目录中,所以仍可执行内核代码。
当前是swtch阶段,保存当前cpu的context,并将栈顶指针指向进程的context,以下是cpu栈:
+---------------+ <-- $(stack + KSTACKSIZE)(see in entry.S)
| ... |
+---------------+
| p->context |
+---------------+
|&cpu->scheduler|
+---------------+
| eip | <-- 调用swtch函数的下一条指令
+---------------+
| ebp | <-- see in swtch.S
+---------------+
| ebx |
+---------------+
| esi |
+---------------+
| edi |
+---------------+ <-- %esp
esp此时指向上面四个寄存器信息,现在改变cpu->scheduler指针的指向(指向位置仍然是cpu栈的某个单元),使其指向esp的位置。即让cpu的context变量保存了当前的cpu寄存器状态。
然后esp指向进程的context,其位于进程内核栈内。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
//当前是swtch阶段,栈顶指针指向进程的context,将相关寄存器弹出之后执行ret指令,从栈中弹出数据作为eip,进程context的eip指向forkret函数入口地址。
//forkret启动了一个log之后返回时将继续弹栈作为新的eip(不同于call指令,调用该函数不会将下一条指令的地址进栈。),即trapret地址。转入trapret阶段,弹出已设定的进程状态相关寄存器,由内核态切换到用户态,执行iret指令后开始执行用户代码。这时候esp指针将指进程的用户栈,其位于所分配的进程指令和数据物理地址空间(一页)的顶端。以下是进程内核栈:

+---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| | ss | <-- (SEG_UDATA << 3) | DPL_USER;
| +---------------+
| | esp | <-- PGSIZE
| +---------------+
| | eflags | <-- FL_IF(allow hardware interrupts)
| +---------------+
| | cs | <-- (SEG_UCODE << 3) | DPL_USER;
| +---------------+
| | eip | <-- 0(init进程)/main函数入口(其他进程)
| +---------------+ <-- 这里往上在iret时自动弹出到相关寄存器中
| | err |
| +---------------+
| | trapno |
| +---------------+
| | ds | <-- (SEG_UDATA << 3) | DPL_USER;
| +---------------+
| | es | <-- (SEG_UDATA << 3) | DPL_USER;
| +---------------+
| | fs |
struct trapframe | +---------------+
| | gs |
| +---------------+ 第一次切换到用户态执行代码前,trapframe
| | eax |
| +---------------+ 的值是在userinit函数中指定的
| | ecx |
| +---------------+
| | edx |
| +---------------+
| | ebx |
| +---------------+
| | oesp |
| +---------------+
| | ebp |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +---------------+ <-- p->tf
| trapret | <-- 跳转到trapret,由allocproc()设置
/ +---------------+ <-- forkret返回
| | eip(=forkret) | <-- 由allocproc()设置
| +---------------+ <-- 执行ret指令,forkret启动log
| | ebp |
| +---------------+
struct context | | ebx |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +-------+-------+ <-- p->context, %esp
| | |
| | |
| empty |
+---------------+ <-- p->kstack
1
2
3
4
5
6
7
8
9
10
11
12
13
//当前是initcode阶段,esp指向了进程的用户栈,参数进栈,然后执行int指令调用系统调用。以下是进程的用户栈:

+---------------+ <-- stack base(PGSIZE)
| $argv |
+---------------+
| $init |
+---------------+
| $0 |
+-------+-------+ <-- %esp
| | |
| | |
| | |
+---------------+ <-- 0,initcode的代码放置在此
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
//当前是int阶段,stack转换到tr寄存器指定的tss里面的ss0:esp0,这是该进程对应的内核栈。自动将进程相关寄存器的内容进栈:ss、esp、eflag、cs、eip,这样返回用户态之后才能恢复执行。然后跳转到中断处理程序处,将errcode和trapno压栈,再跳转到alltraps处。以下是进程的内核栈:

+---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| ss |
+---------------+
| esp | <-- 0x00000ff4(压进三个参数)
+---------------+
| eflags |
+---------------+
| cs |
+---------------+
| eip | <-- 0x00000013(int指令的下一条指令地址)
+---------------+
| err |
+---------------+
| trapno |
+---------------+ <-- %esp
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
//当前是alltraps阶段,将用户进程的寄存器保存到进程内核栈中,构成tramframe,由用户态切换到内核态。push %esp,将当前esp指向的tf结构基地址作为传递的参数,调用trap函数。以下是内核栈:

/ +---------------+ <-- stack base(= p->kstack + KSTACKSIZE)
| | ss |
| +---------------+
| | esp | <-- 0x00000ff4(压进三个参数)
| +---------------+
| | eflags |
| +---------------+
| | cs |
| +---------------+
| | eip | <-- 0x00000013(int指令的下一条指令地址)
| +---------------+
| | err |
| +---------------+
| | trapno |
| +---------------+
| | ds |
| +---------------+
| | es |
| +---------------+
| | fs |
struct trapframe | +---------------+
| | gs |
| +---------------+
| | eax |
| +---------------+
| | ecx |
| +---------------+
| | edx |
| +---------------+
| | ebx |
| +---------------+
| | oesp |
| +---------------+
| | ebp |
| +---------------+
| | esi |
| +---------------+
| | edi |
\ +---------------+
| esp |
+---------------+ <-- %esp

trap阶段将会使用内核栈,而系统调用所传递的参数将由p->tf->esp处取得,这个位置一开始指向了虚拟地址0xPAGE处,即进程的用户栈。系统调用的工作完成后,跳过之前压栈的参数esp,由内核态切换到用户态,弹出保存在ssX:espX指定的栈中的进程相关的寄存器,返回执行进程代码。
上述所有阶段关于栈的变化是:
切换到进程页目录/使用cpu栈–swtch切换到进程的内核栈–trapret根据设定切换内核态为用户态/使用进程的用户栈–initcode.S调用系统调用切换到进程的内核栈–alltraps保存进程寄存器并切换用户态为内核态–执行系统调用后trapret出栈进程相关的寄存器切换内核态为用户态/使用进程的用户栈

exec执行“/init”程序。

进入系统调用时,stack转换到tr寄存器指定的tss里面的ss0:esp0,即该进程对应的内核栈。exec将会执行新程序,替换掉当前执行的用户程序,执行“/init”程序具体如下:1、根据“/init”路径查找对应的inode;2、根据inode读取程序的elf信息;3、调用setupkvm()创建页目录,并映射内核空间(页目录项所存页表的权限是用户可读写,二级页表项所存物理页的权限按照kmap设定);4、根据inode和elf程序头表偏移量,按段读取程序。每次循环时:4.1、先读取段的程序头表项,程序头表项包含段的虚拟地址及段占据的内存大小,4.2、根据信息调用allocuvm()分配出物理空间,分配过程会将程序的虚拟地址映射到分配到的物理页地址上,二级页表项所存物理页的权限是用户可读写,4.3、根据程序头表项信息(段偏移及段的文件大小)读取段到内存中;5、继续为程序分配两页物理空间,二级页表项所存物理页的权限是用户可读写,取消第一页用户可读写权限,第二页将作为用户栈。6、构建用户栈数据(参数等,见下);7、更新进程的页目录、用户空间size、进程的tf->eip为elf.entry(main)、tf->esp为用户栈指针当前位置,然后调用switchuvm()函数设置cpu环境,加载进程的页目录地址到cr3,释放旧页目录空间;8、返回trapret处,切换为用户态,返回执行init程序代码,不返回initcode.S。

由此可以注意到,exec改变的有进程的页目录、用户空间内容、用户空间size、进程的tf->eip、tf->esp、用户栈、进程名,没有改变的有进程文件描述符、进程pid、父进程、内核栈、进程状态、当前目录等。

其他问题

进程的内核栈有没有设置访问权限?
所谓的访问权限指的是进程页目录、二级页表的权限。自始自终进程的内核栈都没有映射进页目录,所以不存在设置访问权限的问题,从进程用户的角度来讲,它是无法访问到进程的内核栈的,这是由内核维护的。

遗留的问题

各种寄存器的作用?

笔记06 - x86 v6 book | Chapter 1 Operating system organization

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

Operating system organization

操作系统组织

os requiement: 实现一个操作系统必须完成三个要求,multiplexing、isolation和interaction,即多路复用、隔离和交互。
isolation: 如果一个进程因为出现bug而失败,不应该影响那些没有依赖于该进程的其他进程。由于操作系统必须保证进程间的交互,因此完全隔离的难度大。

Abstracting physical resources

对物理资源进行抽象

why have an os at all?: 原本可以将系统调用实现为库,并使应用程序链接该库来访问物理资源。这样的话,每个应用程序甚至可以拥有自己的库,应用程序可以直接与硬件资源进行交互,并通过最好的方法来使用这些资源(如实现高性能和可预测的性能)。这种方案的缺点是应用可以自由使用库,也可以不使用库。如果它们不使用操作系统的库,操作系统将无法强制实施分时共享。为了实现分时,需要依赖应用程序本身正确的行为(比如按时放弃处理器),这种合作性的分时共享方案在所有应用程序都信任彼此的情况下可能是可行的,一旦应用程序相互不信任,将不提供strong isolation。
how to achieve strong isolation?: 不允许应用程序直接访问硬件资源,而是将资源抽象为services。比如,应用程序与文件系统的交互仅通过open/read/write/close的系统调用,这为应用程序提供了方便的路径名,也允许操作系统管理硬盘。比如,应用程序通过fork系统调用来执行进程,当切换不同进程的时候由操作系统替代应用程序来保存和恢复寄存器,应用程序不需要知道进程切换,进一步地,这允许OS强行切换使用处理器的应用程序。比如,应用程序通过exec来创建进程的内存映像,而不是直接与物理内存进行交互,这允许OS能决定将进程的内存位置,内存缺页时可以移动内容,并提供文件系统来存储映像。
how to support controled interaction between applications: unix的应用程序只能使用文件描述符,而不能自行编写一些共享约定(如保留一块内存)。文件描述符抽象了所有共享细节,隐藏了应用程序与终端、文件系统或管道交互的细节,并允许OS控制交互过程。

User mode, kernel mode, and system calls

why need kernel mode and user mode?: 应用程序和OS之间需要严格界限来为使用系统调用的软件和实现系统调用的软件提供强隔离。这种强隔离意味着应用程序不能改写由OS维护的数据结构,不能改写OS指令。处理器在内核模式和用户模式下执行指令,在内核模式下,处理器可以执行特权指令。如果在用户模式下的应用程序尝试执行特权指令,处理器将不会执行该指令,而是切换到内核模式,这样软件就可以在出现非法行为时清理该应用程序。应用程序在用户空间执行user-mode instructions,在内核空间执行kernel-mode instructions的软件被称为内核。如果用户模式下的应用程序需要读写硬盘,它必须过渡到内核去执行IO指令。处理器提供了特殊指令将处理器从用户模式切换到内核模式,并在内核指定的入口点进入内核。在切换到内核模式时由内核设置入口点是非常重要的,如果是由应用程序设定,则恶意应用程序能够在进入内核时跳过验证参数。

Kernel organization

what part of the OS should run in kernel mode: 所有内核接口都是系统调用接口,因此 fork, exec, open, close, read, write等都是内核调用,意味着OS的完整实现都是在内核模式下执行,这种内核组织称为monolithic kernel,即单内核。
monolithic kernel and microkernel: 在单内核方式下整个OS在full hardware privilege下运行,OS设计者不需要决定哪部分OS不需要运行在full hardware privilege下,OS不同部分也能更容易地协作。单内核方式的缺点是OS不同部分的接口经常很复杂,开发者容易出现错误。为了降低这个风险,OS设计者可以最小化运行在内核模式下的代码,将不需要执行特权指令的部分作为用户级应用程序,这种组织称为microkernel。作为普通用户程序运行的服务通常被称为server,比如,文件系统以用户级应用程序执行。为了允许应用程序和file server的交互,内核提供了一个最小机制从一个用户模式的应用程序发送消息到另一个应用程序。

Process overview

process: xv6实现隔离的单元是process。进程抽象阻止一个进程破坏或监视另一个进程的内存、CPU、文件描述符等,阻止进程破坏内核本身。内核使用的进程实现机制包括user/kernel mode标识、地址空间、线程的time slicing。一个进程是一个抽象,提供给程序这样的错觉:程序拥有自己抽象机器、内存系统和CPU。xv6使用页表(由硬件实现)为每一个进程提供地址空间,维护一个单独的页表用以定义每个进程的地址空间。

process’s address space: 每个进程的地址空间会映射到内核指令、数据,以及用户程序的内存。当一个进程调用系统调用,系统调用将在映射到该进程地址空间的内核上执行。这样的设计使得内核的系统调用代码可以直接访问用户内存。

thread: 每个进程都有一个执行线程来执行进程指令。为了透明地在不同进程间进行切换,内核中止当前正在运行的线程并恢复另一个进程的线程。大部分的线程状态(局部变量、函数调用返回地址)存储在线程的stacks中。每个线程都有两种stacks,user stack和kernel stack。当进程执行用户指令时,只有用户堆栈被使用,其内核栈是空的。当进程进入内核时,内核代码在进程的内核栈上执行。当一个进程进入内核模式,其用户栈仍然保存数据,但不被使用。进程的执行线程在用户栈和内核栈之间进行交替使用。内核栈是分开的并被用户代码保护,即使进程破坏了它的用户栈,内核也可以执行。当一个进程调用系统调用,处理器切换到内核堆栈,提高硬件特权级别,并开始执行内核指令以实现系统调用。当系统调用完成时,内核返回用户空间,硬件降低其特权级别,交换回用户堆栈,恢复执行系统调用指令之后的用户指令。

Layout of a virtual address space

笔记05 - Lab2: Memory Management

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

Part 1: Physical Page Management

程序的几乎所有代码都集中在 pmap.c 文件中。此部分为物理页面管理,强调对机器拥有的物理内存的管理,包括建立对应的数据结构、处理分配和回收动作等。需要完成boot_alloc()、mem_init() (only up to the call to check_page_free_list(1))、page_init()、page_alloc()、page_free()等函数。

背景知识

JOS 的启动过程实际上是先把 bootsector 的内容读到 0x7c00 处 (注意,bootsector 的代码在编译的时候已经故意地把逻辑地址的首地址定在了 0x7c00 上),bootsector 中的代码开始执行后,会从磁盘上紧接着自己的第 2 个扇区开始,一直读 8 个扇区的内容(一共是 8×512=4KB, ELF 头的大小)到 0x10000(64KB)的地方,然后,通过对 ELF 头的解析,得到 kernel 模块编译出来后所占的大小,并将 kernel 读到物理内存 0x100000(1MB)开始的地方。然后加载 entry_pgdir 的物理地址到 cr3 中开启分页,并调用 i386_init() 函数,而 i386_init() 函数在将自己的 BSS 区域清零后,调用 cons_init() 函数设置好屏幕显示设备为 cprintf 的运行做好准备后就调用 mem_init() 函数。 mem_init(); 函数调用 i386_detect_memory(); 函数读 CMOS 取得物理内存的实际大小。i386_init() 函数最后会调用 monitor(NULL); 并进入循环,处理用户通过终端输入的命令。
在调用 i386_init() 函数以前,内存分布如下所示:

以下是物理内存管理设计思想

调用i386_detect_memory()函数获取到物理内存的基本内存页数和总内存页数(包括扩展内存)。

调用boot_alloc()函数为页目录分配 4KB 的空间。外部字符数组变量 edata 和 end,其中 edata 表示的是 bss 节起始位置(虚拟地址),而 end 则是表示内核可执行程序结束位置(虚拟地址)。boot_alloc()函数从 end 表示的位置(注意是线性地址)开始进行 4K 位置对齐,然后分配足够页空间。nextfree 表示空闲的线性地址,npages 表示总内存页数。根据(uint32_t)nextfree - KERNBASE > npages * PGSIZE判断是否越界。每次分配后对 nextfree 进行 4K 对齐。

使用boot_alloc()函数分配 pages 结构体数组空间(struct PageInfo 类型),共 npages 项,将其清零,后续将从该数组空间中动态分配出页空间(比如分配页存储页表等)。pages 数组与实际物理空间存在映射关系,不需要通过pages 数组存储物理页位置。struct PageInfo 包括 pp_link 和 pp_ref, pp_ref标识页空间是否可用,pp_link用于回溯前一个空闲的PageInfo,形成双向列表(具体见page_init())。pages 始终指向页空间首位置,使用下标向后访问所有可用/不可用内存;page_free_list 始终指向可用页空间的末尾位置,往回指的指针 pp_link 要越过非空物理页,这样 page_free_list 才能通过 pp_link 往回获取空闲空间。由此可知, PageInfo 结构体数组空间是连续的,对于每次分配出的PageInfo指针,都可以计算出其与数组头之间的偏移,从而计算出当前是分配到第几页物理页;另一方面,从page_free_list角度看,连续的 PageInfo 结构体数组空间又是由指针回溯的不连续空间,从而可以自由合并和分出PageInfo空间。

关于内存可用与否:
第一页数据是被使用的,该页保存了实模式IDT和BIOS数据结构。
第二页至基本内存结束位置(640K)是可用的。
[IOPHYSMEM, EXTPHYSMEM)即[0x0A0000,0x100000)即[640K,1MB)是被使用的。io_hole = 96 pages。
扩展内存开始位置至当前 pages 数组存储的末端为不可使用的部分(准确来说应该是使用 boot_alloc(0) 获取得到的线性地址,此线性地址才是代表 boot_alloc() 后 4K 对齐的位置),boot_alloc(0) 的线性地址开始至可用内存末端即为可用的。
注意:扩展内存开始位置为 1M,内核代码存放于 0x100000 处,即 1M 处,KERNBASE 是内核代码存储起点的线性地址。对于存于物理地址 1M 后的数据,其线性地址减去 KERNBASE 的值相当于该处与内核代码存储起点线性地址位置的差,而不是与 0x0 的差, 需要加上 1M 的空间,即 256 page。

page_alloc()函数将从 pages 数组空间中由后往前分配,通过使用 page_free_list 指针和 pp_link 成员。当传入参数标识非 0 时,分配的空间将被清零。虽然一开始 pages 数组空间被清零,但此刻分配的空间可能是之前被使用后回收的,不一定为空。
page_free()函数回收页空间,将page_free_list指向回收的空间。

Part 2: Virtual Memory

通过 Part1 的几个函数,我们得以在可用内存空间上调用boot_alloc()函数分配足够的页对齐的空间大小,并且通过映射物理页使用情况的 pages 数组来分配二级页表页和索引页目录项到该二级页表页,以及分配物理页和索引二级页表项到该物理页。

背景知识

1、x86的虚拟地址包括段选择子和偏移部分,经过段地址转换以后,获得线性地址,再经过页地址转换以后,获得物理地址。
2、一个C指针就是代表虚拟地址的偏移地址部分。
3、boot/boot.S对GDT表进行设置,其中所有段基址设为0,段限长设为0xffffffff,因此段选择子部分失去了对段地址转换的影响,线性地址等于偏移地址(那也就是说一个C指针就是代表线性地址?)。
在内核还没动态创建页目录和页表之前,为了能在0xf0100000地址上运行内核代码,jos通过手写、静态初始化页目录和页表映射了前4M内存空间。而lab2实验则是动态映射物理地址空间的低256MB(0x00000000 - 0x0fffffff)到线性地址(0xf0000000 - 0xffffffff)。
4、qemu使用info pg命令可以查看当前页表的详细信息,包括映射的内存范围、权限以及标识。info mem命令概述了哪个范围的虚拟地址被映射,以及其权限。
5、页目录所在的物理页面的首地址在启动 x86 的页式地址管理前,需要放到 CR3 中,这样 x86 在进行页式地址转换时会自动地从 CR3 中取得页目录地址,从而找到当前的页目录。
6、在现代操作系统中,我们经常可以听到“用户进程的虚地址空间为 4GB”的说法,怎么来实现呢?原理其实很简单:为每个用户进程创建一个页目录,并将这个页目录保存到用户进程的上下文中,当该用户进程被切换过来执行的时候,就将该用户进程的页目录地址写入 CR3,并重新启动一次页式内存管理。当然如果采用这种方式,势必占用更多的内存用于页式地址变换。对于老版本的 Linux 系统以及我们现在接触的 JOS 系统, 为了避免太大的内存开销(另一个原因是因为还没有虚拟内存的支持),在整个系统中只使用一个内核页目录。这就意味着,整个系统的线性地址空间只有 4GB,而且是所有(操作系统内核、各个用户程序)代码所公用的!这种情况下,就必须对线性地址进行合理的规划了。
7、对于页式地址管理,由于页目录以及页表都存放在物理内存的页面中,要进行地址变换先要到内存中访问页目录和页表。由于 CPU 和内存速度的不匹配,为了最小化用于地址转换的总线周期, x86 系统中设计了用于地址翻译的缓存来解决这一问题,这一缓存称为 TLB( Translation Look-aside buffer,即旁路转换缓冲,或称为页表缓冲,或转换后备缓冲区),在该缓存中存放了最近访问的页目录和页表条目,由于程序执行的局部性原理,下一次的地址转换往往跟上一次的地址转换采用的是同一个页目录表项和页表项),同时,由于 TLB 跟处理器的距离更近,这样就极大地提高了地址翻译的效率和速度。但是,这样做可能存在一个潜在的问题:页目录(表)数据项的不一致性。以前系统里对应一个线性地址只有唯一的存放在内存中的页目录和页表,用于完成翻译的工作,但是现在由于 TLB 的存在,系统可能在高速缓存中也存放了一份页表项数据,用于更快地对地址进行翻译。因为 TLB 中的数据对于程序员来说是不可见的,程序对于页表项或者页目录项的修改并不能马上反映到 TLB 中,这样就可能导致错误的地址翻译,因为为了提高翻译的速度,处理器总是尽量地采用 TLB 中的页表数据进行地址的翻译。所以,为了避免这种数据的不一致性所导致的地址翻译的错误情形的出现,系统程序员就必须在对页表进行修改后使 TLB 中旧的页表数据失效。使其失效的办法有两个,一个是重载 CR3,使整个 TLB 中的数据都失效,也可以采用 invlpg 指令。

以下是线性地址到物理页映射的设计思想:
pgdir_walk()函数根据线性地址返回二级页表项入口。首先获取页目录项位置,使用指针指向其索引位置。如果页目录项不存在二级页表映射,且create标识为0,则返回NULL。否则使用page_alloc函数分配页表空间,分配失败则返回NULL,否则将分配的PageInfo结构体引用加1,并将其对应的物理页地址和权限(二级页表权限设为 kern R/W, user R/W)存在页目录项中。二级页表物理地址是4K对齐的,将其低12位清零然后转化为内核地址,使用指针指向它。注意到页目录和页表存的是物理地址,而指向页目录和页表的指针需要使用逻辑地址。最后根据指向二级页表的指针和通过线性地址求得的二级页表项索引,返回指向二级页表项的指针。pgdir_walk()只负责创建二级页表,然后返回指向二级页表项的指针,不对二级页表做处理,也不做其对物理页映射。我们知道,pages结构体数组是用于映射所有内存空间的,page_free_list指针指向的结构体所映射的内存空间均可用。page_alloc函数将从 pages 数组空间中分配可用空间,结构体映射的物理页地址将作为线性地址的页表,将其存储到页目录里。

page_lookup()函数根据线性地址返回二级页表项所指的物理页对应的PageInfo数据结构。pte_t **pte_store存储的是二级页表项的地址(传递过来的是某个指针的地址,对于那个指针来说,函数执行后它就指向对应的二级页表项了),设计这个指针的含义是:当通过page_remove()移除线性地址对应的物理页时,可以清空二级页表项的内容。page_lookup()函数调用pgdir_walk()获得指向二级页表项的指针,不允许创建二级页表。如果页目录项不存在二级页表映射且不允许创建二级页表,或者没有空间分配二级页表,pgdir_walk()会返回NULL,则page_lookup()返回NULL。如果获得的二级页表项不存在物理页映射,则page_lookup()返回NULL。否则将二级页表项的地址存于pte_t **pte_store,pgdir_walk()返回的二级页表项指针内容是所指的物理页物理地址,将其低12位清零,并返回对应的PageInfo数据结构。

page_remove()函数移除线性地址对应的物理页,清空二级页表项,使tlb无效化。如果不同的线性地址映射到同一个二级页表项,它们就映射到同一个物理页。另一种情况是不同的线性地址映射到不同的二级页表项,且它们通过page_insert映射到同一个物理页,这代表的是不同的二级页表项存取同一个物理页地址。通过page_remove即可解除情况1到二级页表项的映射,在这里,一旦线性地址对应到二级页表项,即将其清空。page_remove()函数通过调用page_lookup()函数获得线性地址对应的物理页的PageInfo数据结构,然后调用page_decref()函数将PageInfo对象引用减1,如果引用为0则释放该内存页。

page_insert()函数将映射物理页的PageInfo数据结构的地址及访问权限存于线性地址所对应的二级页表项内。page_insert()函数首先调用pgdir_walk()函数获得指向线性地址的二级页表项的指针,允许创建二级页表。当对应的二级页表不存在且没有足够内存空间创建二级页表时,pgdir_walk()函数返回NULL,则page_insert()函数返回-1。否则pgdir_walk()函数返回指向二级页表项的指针,当该指针的P存在位为1时,代表当前二级页表项存在物理页映射,这时候需要判断该物理页与要插入的物理页是否是同一页,判断方法是:将指针内容(已经是物理地址)低12位清零,转化为PageInfo指针p,并与函数参数PageInfo *pp进行比较。如果是同一页(p与pp指向同个结构体),应该允许修改权限(包括降低权限,所以先清空低12位)后直接返回0,否则调用page_remove()函数移除线性地址对应的物理页,清空二级页表项。接着待插入的PageInfo指针pp的引用加1,将pp对应的物理页物理地址及权限存于二级页表项处,使tlb无效化。注意如果已映射的物理页和待插入的物理页是同一页的话,不能先移除物理页p再将pp的引用加1,因为一旦移除物理页p,p可能因为引用为0而被释放,进而page_free_list指向这块内存页,将其标记为可用,而此时pp的物理页是不可用的。

boot_map_region()函数将线性地址空间[va, va+size)映射到物理地址空间[pa, pa+size)。pa、va是页对齐的,size是页大小的倍数。boot_map_region()函数循环调用pgdir_walk()函数返回指向二级页表项的指针,然后将对应的物理地址和权限存储到该指针。jos只有一个内核页目录,最多映射4G空间。jos将[KERNBASE,4G)的空间映射到物理内存上,其中KERNBASE=0xf0000000=3840M,4G=4096M,两者相差256M。因此,物理内存最多映射256M,即[0,256M)。4G等于0x100000000,其被映射的最后一页的起点地址是0xfffff000,这也就是boot_map_region()中能被映射的最后一个线性地址。

Part 3: Kernel Address Space

jos一共有三个线性地址到物理地址的映射是需要的,此部分负责进行映射:
[UPAGES, sizeof(PAGES) ] => [pages, sizeof(PAGES)],这里 PAGES 代表页面管理结构所占用的空间;
[KSTACKTOP – KSTKSIZE, 8] => [bootstack, 8],
其中 bootstack 为内核编译时预先留下的 8 个页面(用做内核堆栈);
[KERNBASE, 4G) => [0, pages in the memory),这个地址映射范围比较广,含盖了所有物理内存。这里是256M。

以下是对question的解答:
2. 哪些页目录项已经被填充?它们映射到哪些地址并指向哪些内容?
使用技巧:使用qemu调试后,在执行qemu的控制台里,先后按下ctrl、a、h查看帮助,先后按下ctrl、a、c可切换qemu监控器和终端控制台,切换到qemu监控器后,执行info pg可查看当前页表结构。

以其中的[ef000-ef3ff] PDE[3bc] -------UWP为例,它表示该页目录项映射的线性空间范围是[ef000000, ef3fffff],两者之差为3fffff,即4M大小。3bc表示索引是956。UWP表示该项是kernel RW, user RW且president的。

上图表示的页目录项如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
Entry       Base Virtual Address    Points to (logically):
1023(3ff) 0xffc0000 Page table for top 4MB of phys memory, kernel RW, user NONE
1022(3fe) 0xff80000 Page table for second to top 4MB of phys mem, kernel RW, user NONE
.
960(3c0) 0xf0000000 Page table for bottom 4MB of phys memory, kernel RW, user NONE
959(3bf) 0xefc00000 (0xefff8000 - 0xf0000000 Kernel stack, kernel RW, user NONE)
(0xefc00000 - 0xefff8000 Invalid memory, or maybe other cpu's stack)
957(3bd) 0xef400000 Current page table kernel R-, user R- (pgdir)
956(3bc) 0xef000000 User pages, kernel R, user R
.
2 0x00800000
1 0x00400000
0 0x00000000 <Nothing, mapping got cleared>

3. 内核环境和用户环境被映射到同一个地址空间,为什么用户程序不会读写内核内存?有什么保护机制吗?
ULIM和UTOP将虚拟内存分为各个段。(ULIM, 4GB)只有内核环境能读写,(UTOP, ULIM]内核和用户环境都可以读取,[0x0, UTOP]是用户环境空间。这些内存空间被权限位所保护,如PTE_W(可写)和PTE_U(用户),这些是在页表/页目录项中设置的标识。
具体用于保护的机制是当前特权级别(CPL),CS的低2位。CPL=0则代表特权O/S,CPL=3 代表用户可访问。这也被用来检测当前的模式,以及我们是否可以写入虚拟内存地址。

4.jos能支持的最大物理内存是多少?为什么?
这个操作系统能支持的最大物理内存是256 MB。这是因为我们希望能够将所有的线性地址映射到物理地址。事实证明这样做使它更容易从物理地址反向映射到虚拟地址。

5.如果我们真的有最大数量的物理内存,需要多少空间开销来管理内存?
管理内存有页表和页目录,4G空间需要1个页目录和1024个页表,总共需要的空间开销是4KB * 1024 + 4KB。

6.重新阅读kern/entry.S和kern/entrypgdir.c,我们开启分页后,EIP仍然是小于1MB的值,我们是在哪个point开始在基于KERNBASE的EIP上执行指令?从我们开启分页的时刻,到我们开始在基于KERNBASE的EIP上执行指令的时刻,这段时间为什么能够使用low EIP?
在entry.S中,执行jmp *%eax指令之后eip将会加上KERNBASE。在开启分页到执行jmp *%eax指令这段期间,之所以能够使用low EIP,是因为线性地址[0, 4MB)和[KERNBASE, KERNBASE+4MB)同时映射到[0,4MB)物理地址,如果不映射线性地址[0, 4MB),则low eip的线性地址不在可访问线性地址范围内,将出现非法访问。

以下是对Challenge的解答:

Changeling1

我们使用了很多二级页表来映射KERNBASE地址,一个更有效的方法是使用页目录项的PTE_PS (“Page Size”)位,最初的80386不支持该位,但近年来的x86处理器可支持。阅读【Intel® 64 and IA-32 Architectures Software Developer’s Manual】第3.6 节【PAGING (VIRTUAL MEMORY) OVERVIEW】。**
如果将CR4中的PSE位打开,那么就可以开启4MB物理页。那么对应使用该物理页的虚拟地址寻址方式变为:

这个时候相应的页目录表项也要修改,要修改其PS位表明该表项对应的地址是4MB,不用再进行二级页表寻址了:

设置了使用4MB页表以后,那些二级页表查找和插入的过程就不能通用了,即pgdir_walk()、page_insert()函数等。但这不影响check_page()函数的检查,因为check_page()函数虽然调用了pgdir_walk()、page_insert()等函数,但是其检查逻辑是先模拟插入page再检查,二级页表的创建和页目录存储二级页表地址这些操作仍会被模拟(虽然此时页目录项的内容应该是4MB页地址,但其实是存了二级页表的地址)。相反,check_kern_pgdir()函数则不会成功执行,因为该函数是直接检查线性地址到物理地址的实际映射情况,再没有模拟插入page、创建二级页表的情况,所以其在涉及到二级页表的查询将会出错。所以修改为4MB页以后,应该注释掉check_kern_pgdir()函数的调用。如果qemu模拟器没有出现Triple fault,则代表寻址是正确的。实现代码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
void
mem_init(void)
{
...
// Turn on CR4_PSE for 4MB page
lcr4 (rcr4 () | CR4_PSE);
...
//boot_map_region(kern_pgdir,UPAGES,PTSIZE,PADDR(pages),PTE_U | PTE_P);
boot_map_region_4m(kern_pgdir,UPAGES,PTSIZE,PADDR(pages),PTE_U | PTE_P);
...
//boot_map_region(kern_pgdir, KSTACKTOP - KSTKSIZE, KSTKSIZE, PADDR(bootstack), PTE_W | PTE_P);
boot_map_region_4m(kern_pgdir, KSTACKTOP - KSTKSIZE, KSTKSIZE, PADDR(bootstack), PTE_W | PTE_P);
...
//boot_map_region(kern_pgdir, KERNBASE, 0xffffffff - KERNBASE + 1, 0, PTE_W | PTE_P);
boot_map_region_4m(kern_pgdir, KERNBASE, 0xffffffff - KERNBASE + 1, 0, PTE_W | PTE_P);
...
// Check that the initial page directory has been set up correctly.
//check_kern_pgdir();
...
}

/*
jos只有一个内核页目录,最多映射4G空间。
jos将[KERNBASE,4G)的空间映射到所有物理内存上,这段内存是256M,
其中KERNBASE=0xf0000000=3840M,4G=4096M,两者相差256M。
因此,物理内存最多映射256M。4G等于0x100000000,其被映射的最后一页的起点地址是0xfffff000,
这也就是boot_map_region中能被映射的最后一个线性地址。

todo...可能存在的问题是:映射的是256M的内存,但是实际上只有64M可用的内存空间,
这些内存空间有对应的页面管理结构,其中一部分被作为映射256M内存空间时的二级页表。
*/
static void
boot_map_region(pde_t *pgdir, uintptr_t va, size_t size, physaddr_t pa, int perm)
{
// Fill this function in
/*
使用下述方法时,在映射[KERNBASE,4G)地址时
只能使用 boot_map_region(kern_pgdir, KERNBASE, 0xffffffff - KERNBASE, 0, PTE_W | PTE_P);,
不能使用 boot_map_region(kern_pgdir, KERNBASE, 0xffffffff - KERNBASE + 1, 0, PTE_W | PTE_P);
0xffffffff - KERNBASE + 1 = 0x10000000,KERNBASE+ 0x10000000会越界。
*/
/*
uintptr_t current_va;
physaddr_t current_pa = pa;
// The highest possible address of a virtual page
uint32_t last_page_addr = 0xfffff000;
for (current_va = va; current_va < va+size; current_va += PGSIZE)
{
pte_t *pte = pgdir_walk(pgdir,(void *)current_va,true);
if(pte == NULL) return;
*pte = (current_pa | perm | PTE_P);

//此判断是有必要的,当将[KERNBASE,4G)映射到[0,256M)时,线性地址累积到0xfffff000的时候,for循环的判断条件仍然是成立的。
if (current_va == last_page_addr)
break;

current_pa += PGSIZE;
}
*/
/*
使用下述方法时,在映射[KERNBASE,4G)地址时
本应该使用 boot_map_region(kern_pgdir, KERNBASE, 0xffffffff - KERNBASE + 1, 0, PTE_W | PTE_P);,
不能使用 boot_map_region(kern_pgdir, KERNBASE, 0xffffffff - KERNBASE, 0, PTE_W | PTE_P);,
因为这会导致循环次数减1,246M物理内存最后的1页没有被映射。
但由于实际的npages为16639,检测的总空间为64M,
check_kern_pgdir()函数会针对npages大小检测KERNBASE以上的地址映射情况,
超过64M以上的物理内存不会被检测到,所以并没有导致出错。
但严谨来说应该使用第一种情况。
*/
int i;
for (i = 0; i < size/PGSIZE; ++i, va += PGSIZE, pa += PGSIZE) {
pte_t *pte = pgdir_walk(pgdir, (void *) va, 1); //create
if (pte == NULL) panic("boot_map_region panic, out of memory");
*pte = pa | perm | PTE_P;
}
}

static void
boot_map_region_4m(pde_t *pgdir, uintptr_t va, size_t size, physaddr_t pa, int perm)
{
pde_t *pde;
int i;
for (i = 0; i < size/PTSIZE; ++i, va += PTSIZE, pa += PTSIZE) {
pde = pgdir + PDX(va);
*pde = pa | perm | PTE_P | PTE_PS;
}
}

Changeling2

扩展jos的命令,1、当给定线性地址的范围时,以一种易读的格式展示内存页映射情况。如’showmappings 0x3000 0x5000’命令可以展示线性地址0x3000、0x4000、0x5000的物理页映射及其权限位。2、能显式清除、设置或改变当前地址空间映射的权限。3、给定一段线性地址/物理地址范围,显示其内容,必须确保当给定范围跨页面边界的时候显示代码的正确性。**
1、提供两个函数,一个是将地址字符串转换为整数,一个是将读入参数,按页大小输出二级页表项内容。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
uint32_t jz_xtoi(char* pstr){
uint32_t res = 0;
pstr += 2;//0x...
while(*pstr){
if(*pstr >= 'a' && *pstr <= 'z') *pstr = *pstr - 'a' + 10 + '0';
else if(*pstr >= 'A' && *pstr <= 'Z') *pstr = *pstr - 'A' + 10 + '0';
res = res*16 + *pstr - '0';
++pstr;
}
return res;
}

int
mon_showmappings(int argc, char **argv, struct Trapframe *tf)
{
if(argc != 3) {
cprintf("Make sure the correct style: showmappings 0xbegin_addr 0xend_addr\n");
return 0;
}
//将地址字符串转换int,然后判断低12位是不是0
uint32_t begin = jz_xtoi(argv[1]), end = jz_xtoi(argv[2]);
if((begin & 0xfff) != 0 || (end & 0xfff) != 0 ){
cprintf("Make sure the addr's low 12 bits is zero\n");
return 0;
}
cprintf("Attention! You may test addr above UPAGES(0xef000000)\n");
cprintf("begin:%p, end:%p\n",begin,end);
pde_t *kpgdir = KADDR(rcr3()), *pde;
pte_t *pte, *p;
uint32_t va;
for (va = begin; va <= end; va += PGSIZE)
{
//or you can use pgdir_walk
pde = &kpgdir[PDX(va)];
if (*pde & PTE_P){
pte = (pte_t*) KADDR(PTE_ADDR(*pde));
if (*pte & PTE_P){
p = &pte[PTX(va)];
cprintf("va: %p, pa: %p, PTE_P: %x, PTE_W: %x, PTE_U: %x\n",
va, *p, *p&PTE_P, *p&PTE_W, *p&PTE_U);
} else {
cprintf("page mapping not exist: %x\n", va);
}
} else {
cprintf("page mapping not exist: %x\n", va);
}
}
return 0;
}

结果如下:

2、使用page_walk()函数实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
int 
mon_setpermissions(int argc, char **argv, struct Trapframe *tf)
{
if(argc != 4) {
cprintf("Make sure the correct style: setpermissions 0xaddr [0|1 :clear or set] [P|W|U]\n");
return 0;
}
//将地址字符串转换int,然后判断低12位是不是0
uint32_t va = jz_xtoi(argv[1]);
if((va & 0xfff) != 0 ){
cprintf("Make sure the addr's low 12 bits is zero\n");
return 0;
}

pte_t *pte = pgdir_walk((pde_t *)KADDR(rcr3()),(void *)va,false);
if (pte && (*pte & PTE_P)){
cprintf("before setpermissions %p\n",va);
cprintf("va: %p, pa: %p, PTE_P: %x, PTE_W: %x, PTE_U: %x\n",
va, *pte, *pte&PTE_P, *pte&PTE_W, *pte&PTE_U);
uint32_t perm = 0;
if (argv[3][0] == 'P') perm = PTE_P;
if (argv[3][0] == 'W') perm = PTE_W;
if (argv[3][0] == 'U') perm = PTE_U;
if (argv[2][0] == '0') //clear
*pte = *pte & ~perm;
else //set
*pte = *pte | perm;
cprintf("after setpermissions %p\n",va);
cprintf("va: %p, pa: %p, PTE_P: %x, PTE_W: %x, PTE_U: %x\n",
va, *pte, *pte&PTE_P, *pte&PTE_W, *pte&PTE_U);
} else {
cprintf("page mapping not exist: %x\n", va);
}
return 0;
}

结果如下:

3、代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int 
mon_dumpcontents(int argc, char **argv, struct Trapframe *tf)
{
if(argc != 4) {
cprintf("Make sure the correct style: dumpcontents [p|v :physical or virtual] 0x3000 10\n");
return 0;
}
void** begin = NULL;
long length = strtol(argv[3],0,0);
uint32_t i;
if (argv[1][0] == 'p') {
begin = (void**)(jz_xtoi(argv[2]) + KERNBASE);
} else if (argv[1][0] == 'v') {
begin = (void**)(jz_xtoi(argv[2]));
}
if(begin > begin + length){
cprintf("out of memory.\n");
return 0;
}
for (i = 0; i < length; ++i){
cprintf("va at %x is %x\n", begin+i, begin[i]);
}
return 0;
}

关于指针的指针可以参考C语言指针部分的博客。上述设计思想是:指针所加内容是指针存储的地址,所加范围是由指针指向的对象类型决定。因此,二级指针begin+i所加内容是二级指针存储的一级指针地址,所加范围是由二级指针所指向的一级指针决定,即每次加4个字节。begin[i]是*(begin+i),即一级指针的内容。因此,这里是将内存内容当作一级指针内容看待!

Changeling3

操作系统一般会映射内核到低线性地址上,留下高地址部分给用户程序。x86内核由于存在向后兼容的虚拟8086模式,处理器会“硬连线式”使用线性空间的低地址部分,所以即使内核映射在那里也无法使用所有映射地址。但是,可以通过设计内核,不再为内核保存任何固定的线性地址部分,允许用户级进程整个无限制使用4GB的虚拟地址空间,同时还从这些进程中充分保护内核,和保护相互不同的进程。请写出满足上述要求的内核设计大纲(相关技术可称为follow the bouncing kernel),明确指出处理器在内核和用户模式之间转换会发生什么,描述内核会如何访问物理内存和IO设备,以及如何通过系统调用访问用户环境的虚拟地址空间。最后从灵活性、性能、内核的复杂性等阐述该方案的优缺点**

Changeling4

jos系统是从页粒度上分配和释放内存,但没有通用的malloc/free工具。如果我们要支持某些类型的I/O设备,它们需要物理上大于4KB的连续缓冲区,或者如果我们为了最大的处理器效率,需要在用户级环境(不仅仅是内核)上分配和映射4MB的superpages,那么将会产生问题。请概括出一个内核内存分配系统,支持管理2的幂大小的页,分配单元大小从4KB至一个合理大小值。确保存在将大分配单元按需求切分为小单元和将小单元合并为大单元的方案。**

笔记04 - HW02: boot xv6

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

JOS 是麻省理工学院(MIT)从 xv6 直接改过来的实验用工具,xv6-public 是官方在 Github 上放出的最新 xv6 源代码。

获取 xv6 源码

git://pdos.csail.mit.edu/xv6/xv6.git 不成功,git://github.com/mit-pdos/xv6-public.git 成功。

构建 xv6

以下是makefile的部分内容:

1
2
3
4
5
gcc -O -nostdinc -I. -c bootmain.c
gcc -nostdinc -I. -c bootasm.S
ld -m elf_i386 -N -e start -Ttext 0x7C00 -o bootblock.o bootasm.o bootmain.o
objdump -S bootblock.o > bootblock.asm
objcopy -S -O binary -j .text bootblock.o bootblock

==以下是对 gcc 参数的解释:==
-O 使用编译优化级别1编译程序。级别为1~3,级别越大优化效果越好,但编译时间越长。具体如下:
-O0:这个等级(字母“O”后面跟个零)关闭所有优化选项,也是CFLAGS或CXXFLAGS中没有设置 -O 等级时的默认等级。这样就不会优化代码,这通常不是我们想要的。
-O1:这是最基本的优化等级。编译器会在不花费太多编译时间的同时试图生成更快更小的代码。这些优化是非常基础的,但一般这些任务肯定能顺利完成。
-O2:-O1的进阶。这是推荐的优化等级,除非你有特殊的需求。-O2会比-O1启用多一些标记。设置了-O2后,编译器会试图提高代码性能而不会增大体积和大量占用的编译时间。
-O3:这是最高最危险的优化等级。用这个选项会延长编译代码的时间,并且在使用gcc4.x的系统里不应全局启用。自从3.x版本以来gcc的行为已经有了极大地改变。在3.x,-O3生成的代码也只是比-O2快一点点而已,而gcc4.x中还未必更快。用-O3来编译所有的软件包将产生更大体积更耗内存的二进制文件,大大增加编译失败的机会或不可预知的程序行为(包括错误)。在gcc 4.x.中使用-O3是不推荐的。
-Os:这个等级用来优化代码尺寸。其中启用了-O2中不会增加磁盘空间占用的代码生成选项。这对于磁盘空间极其紧张或者CPU缓存较小的机器非常有用。但也可能产生些许问题,因此软件树中的大部分ebuild都过滤掉这个等级的优化。使用-Os是不推荐的。

-nostdinc:可能默认的头文件库会首先调用,而-I下面的目录由于名字相同,优先级可能低于默认所以没能被调用,于是需要gcc编译的时候不要在标准系统目录中找头文件。

-I:用来指定头文件目录,/usr/include目录一般是不用指定的,gcc知道去那里找,但是如果头文件不在/usr/include里我们就要用-I参数指定了,比如头文件放在/myinclude目录里,那编译命令行就要加上-I/myinclude参数了,如果不加你会得到一个”xxxx.h: No such file or directory”的错误。-I参数可以用相对路径,比如头文件在当前目录,可以用-I.来指定。

-c:仅执行编译操作,不进行链接操作。将c语言文件bootmain.c(也可以是汇编输出文件*.S)编译输出bootmain.o文件。

==以下是对ld(GNU linker连接器)参数的解释:==
-m emulation:模仿 emulation 连接器。如-m elf_i386是模仿elf_i386连接器

-N 或 –omagic:把text和data节设置为可读写,同时取消数据节的页对齐。同时取消对共享库的连接。如果输出格式支持Unix风格的magic number,把输出标志为’OMAGIC’。

-e ENTRY:使用符号ENTRY作为程序的开始执行点,而不是使用缺省的进入点。如果没有叫做ENTRY的符号,连接器会企图把ENTRY作为一个数字进行分析,并使用它作为入口地址(数字会被解释为10进制的;可以使用前导的’0x’强制为16进制,或’0’作为8进制。)

-Tbss ORG,-Tdata ORG,-Ttext ORG:跟-section-start同义,不过把SECTIONNAME替换为’.bss’,’.data’或’.text’。-section-start的格式是:`–section-start SECTIONNAME=ORG’,通过指定ORG,指定节在输出文件中的绝对地址。可以多次使用这个选项来定位多个节。ORG必须是一个十六进制整数;为了跟其他连接器兼容,可以忽略前导’0x’。注意,在SECTIONNAME、等号、ORG之间不允许有空格出现。

-o OUTPUT:使用OUTPUT作为’ld’产生的程序的名字。如果这个选项没有指定,缺省的输出文件名是’a.out’。脚本命令’OUTPUT’也可以被用来指定输出文件的文件名。

==以下是对objdump(查看目标文件或者可执行的目标文件的构成的gcc工具)参数的解释:==
-S:尽可能反汇编出源代码,尤其当编译的时候指定了-g这种调试参数时,效果比较明显。隐含了-d参数。

==以下是对objcopy(用于将object的部分或全部内容拷贝到另一个object,从而可以实现格式的变换。)参数的解释:==
-S:去掉源文件的符号信息和relocation信息。

-O bfdname:使用指定的格式来写输出文件(即目标文件),bfdname是BFD库中描述的标准格式名。

-j sectionname:只将由sectionname指定的section拷贝到输出文件。

探究堆栈内容

bootasm.S 中在哪里初始化堆栈?
在 call bootmain 之前调用 movl $start, %esp 初始化堆栈,由于历史原因,boot loader 部分将会被加载到 0x7c00 的位置,即 $start 所代表的位置。此处将 0x7c00 赋值给 esp,而堆栈栈顶指针是向低地址方向增长的。

bootmain 函数对堆栈所做的第一条汇编指令是什么(查看bootblock.asm)?
push %ebp,将 ebp 进栈,此后将当前的栈顶指针的值赋给 ebp,从而可以实现递归调用和返回。

单步调试直到 call bootmain,堆栈的内容是什么?
设置断点到 0x10000c(内核程序入口地址),当执行该地址的指令后,堆栈发生什么变化?
以下是进入到内核程序入口后,以栈顶指针 esp 为指标查看到的内存内容(x/24x $esp,其中 esp 的值为0x7bcc)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
0x7bcc:	        #进入内核后, esp 指向 0x7bcc
0x00007db7 #7db7 是进入内核elf->entry指令的下一条指令地址
0x00000000
0x00000000
0x00000000
0x7bdc:
0x00000000
0x00000000
0x00000000
0x00000000
0x7bec:
0x00000000
0x00000000
0x00000000
0x00000000
0x7bfc:
0x00007c4d # 7c4d 是 bootasm.S 调用 bootmain 函数时下一条指令的地址,即 call bootmain 后堆栈会存储 0x7c4d。
0x7c00: # !!! 此处即为栈顶指针的初始化位置,此后向低地址方向增长
0x8ec031fa
0x8ec08ed8
0xa864e4d0
0x7c0c:
0xb0fa7502
0xe464e6d1
0x7502a864
0xe6dfb0fa
0x7c1c:
0x16010f60
0x200f7c78
0xc88366c0
0xc0220f01

笔记03.5 - Lab 1:Jos内核

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

在保护模式下线性地址 = GDT 表项中段基址 + 偏移地址,而实际上指针的值通常就是偏移地址。在 Boot Loader 完成了将内核可执行文件加载到内存中的工作后,将用 ((void (*)(void)) (ELFHDR->e_entry))(); 这一句代码执行指令的跳转。内核程序在 kern/entry.S 中设置 CR0_PG 标识符,虚存硬件开始将线性地址映射为物理地址。在标识符设置之前,由于没有开启分页,逻辑地址通过段映射得到的线性地址都被当作物理地址。开启分页后,内核程序使用 [KERNBASE, KERNBASE+4MB) 的线性地址,该地址范围会被映射到物理地址 [0, 4MB),线性地址 [0, 4MB) 也会映射到物理地址 [0, 4MB) ,其中 KERNBASE = 0xF0000000。

也就是说,boot/boot.S切换到保护模式之后,开始了逻辑地址到线性地址的转换,但是没有开启分页,线性地址被当为物理地址,所以一般使用的是低地址。kern/entry.S开启分页之后,开始在保护模式下使用高线性地址,因此需要加载页表。此时还没开始内存管理,因此页表是手工静态文件。

部分内核代码解析

首先来看看 kern/entry.S 文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
/* See COPYRIGHT for copyright information. */

#include <inc/mmu.h>
#include <inc/memlayout.h>

# Shift Right Logical
#define SRL(val, shamt) (((val) >> (shamt)) & ~(-1 << (32 - (shamt))))


###################################################################
# The kernel (this code) is linked at address ~(KERNBASE + 1 Meg),
# but the bootloader loads it at address ~1 Meg.
#
# RELOC(x) maps a symbol x from its link address to its actual
# location in physical memory (its load address).
###################################################################

#define RELOC(x) ((x) - KERNBASE)

#define MULTIBOOT_HEADER_MAGIC (0x1BADB002)
#define MULTIBOOT_HEADER_FLAGS (0)
#define CHECKSUM (-(MULTIBOOT_HEADER_MAGIC + MULTIBOOT_HEADER_FLAGS))

###################################################################
# entry point
###################################################################

.text

# The Multiboot header
.align 4
.long MULTIBOOT_HEADER_MAGIC
.long MULTIBOOT_HEADER_FLAGS
.long CHECKSUM

# '_start' specifies the ELF entry point. Since we haven't set up
# virtual memory when the bootloader enters this code, we need the
# bootloader to jump to the *physical* address of the entry point.
.globl _start
_start = RELOC(entry)

.globl entry
entry:
movw $0x1234,0x472 # warm boot

# We haven't set up virtual memory yet, so we're running from
# the physical address the boot loader loaded the kernel at: 1MB
# (plus a few bytes). However, the C code is linked to run at
# KERNBASE+1MB. Hence, we set up a trivial page directory that
# translates virtual addresses [KERNBASE, KERNBASE+4MB) to
# physical addresses [0, 4MB). This 4MB region will be
# sufficient until we set up our real page table in mem_init
# in lab 2.

# 注意到内核代码的执行是从KERNBASE+1MB开始的,而不是KERNBASE!!!
# 由于虚拟内存机制还没建立,所以在kern/entrypgdir.c里面手写了4MB的页表,
# 把[0,4MB)物理地址同时映射到线性地址[0, 4MB)和[KERNBASE, KERNBASE+4MB)中

# Load the physical address of entry_pgdir into cr3. entry_pgdir
# is defined in entrypgdir.c.
# 此时还没开启分页,需要RELOC将线性地址转化为物理地址
movl $(RELOC(entry_pgdir)), %eax
movl %eax, %cr3
# Turn on paging.
movl %cr0, %eax
orl $(CR0_PE|CR0_PG|CR0_WP), %eax
movl %eax, %cr0

# Now paging is enabled, but we're still running at a low EIP
# (why is this okay?). Jump up above KERNBASE before entering
# C code.
# 线性地址[0, 4MB)和[KERNBASE, KERNBASE+4MB)同时映射到[0,4MB)物理地址
# 如果不映射线性地址[0, 4MB),则low eip的线性地址不在可访问线性地址范围内,将出现非法访问
mov $relocated, %eax
jmp *%eax # jmp 之后eip将会加上KERNBASE !!!
relocated:

# Clear the frame pointer register (EBP)
# so that once we get into debugging C code,
# stack backtraces will be terminated properly.
movl $0x0,%ebp # nuke frame pointer

# 可以看到在这里定义了两个全局变量 bootstack 和 bootstacktop, bootstack 标识了内存中
# 的一个位置,表示从这里开始的 KSTKSIZE 个字节的区域都是属于这个临时堆栈的
# (KSTKSIZE 在 inc/memlayout.h 中定义为 32k),而 bootstacktop 则指向的是这段区域后的第
# 一个字节,由于刚开始的时候堆栈是空的,所以栈顶便是 bootstacktop 所指向的位置,于是
# 程序便将 bootstacktop 的值赋给了 esp 寄存器。该位置位于.data节内。

# Set the stack pointer
movl $(bootstacktop),%esp

# now to C code
call i386_init

# Should never get here, but in case we do, just spin.
spin: jmp spin


.data
###################################################################
# boot stack
###################################################################
.p2align PGSHIFT # force page alignment
.globl bootstack
bootstack:
.space KSTKSIZE
.globl bootstacktop
bootstacktop:

在初始化堆栈指针后,程序调用了 i386_init 函数,这个函数是在 kern/init.c 中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
void
i386_init(void)
{
extern char edata[], end[];

// 在完成任何事之前,先完成 ELF 的加载过程。清除未初始化的全局数据 (BSS) 节,确保所有 static/global 变量置零。
// 可以看到两个外部字符数组变量 edata 和 end,其中 edata 表示的是 bss 节在内存中开始的位置,而 end 则是表示内核可执行程序在内存中结束的位置。由 2.1 节中对 ELF 文件的讲解我们可以知道 bss 节是文件在内存中的最后一部分,于是 edata 与 end 之间的部分便是 bss 节的部分,我们又知道 bss 节的内容是未初始化的变量,而这些变量是默认为零的,所以在一开始的时候程序要用 memset(edata, 0, end - edata)这句代码将这些变量都置为零。
memset(edata, 0, end - edata);

// 初始化控制台,有显存的初始化、键盘的初始化之类的
// Can't call cprintf until after we do this!
cons_init();
// 测试输出
cprintf("6828 decimal is %o octal!\n", 6828);
// 通过堆栈来对函数调用进行回溯(lab 1 only)
test_backtrace(5);

// Drop into the kernel monitor.
// 无限循环的调用了 monitor 函数,这个函数的原型在 kern/monitor.c 中,它的功能是提示用户输入命令与操作系统进行交互。
while (1)
monitor(NULL);
}

定义在 kern/monitor.c 中的 runcmd 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
static int
runcmd(char *buf, struct Trapframe *tf)
{
int argc;
char *argv[MAXARGS];
int i;

// Parse the command buffer into whitespace-separated arguments
argc = 0;
argv[argc] = 0;
while (1) {
// gobble whitespace
while (*buf && strchr(WHITESPACE, *buf))
*buf++ = 0; //把所有空格字符都置为空字符
if (*buf == 0)
break;// 命令结束

// save and scan past next arg
if (argc == MAXARGS-1) {
cprintf("Too many arguments (max %d)\n", MAXARGS);
return 0;// 参数个数超过最大个数的限制
}
argv[argc++] = buf;// 指向相应的字符串
while (*buf && !strchr(WHITESPACE, *buf))
buf++; // 跳过非空格的字符
}
argv[argc] = 0;

// Lookup and invoke the command
if (argc == 0)
return 0;
for (i = 0; i < NCOMMANDS; i++) {
if (strcmp(argv[0], commands[i].name) == 0)
return commands[i].func(argc, argv, tf);
}
cprintf("Unknown command '%s'\n", argv[0]);
return 0;
}

程序让指针指向了每个子字符串并且把命令字符串中的空格都换成了空字符, 因为用户在输入命令的时候, 命令名和参数之间, 参数和参数之间都是由空格相隔开的,这样处理后每个子字符串的结尾便都是一个空字符,便可以方便之后读取这个字符串,就如下图所示:

堆栈分析

先进后出是堆栈的特点,内存中栈顶在内存的低地址处,而栈底是在高地址处。栈底是固定的,栈顶是可以变化的。假设我们需要堆栈出栈一个字的数据,即 4 个字节,这个时候我们需要当前 esp 指向位置开始的 4 个字节读出来,并且在这之后把 esp 加 4。当需要进栈一个字的数据时,将 esp 减 4,并把这个字存放在 esp 指向位置开始的 4 个字节处。


关键的寄存器: eip 存储当前执行指令的下一条指令在内存中的偏移地址, esp 存储指向栈顶的指针,而 ebp 则是存储指向当前函数需要使用的参数的指针。在程序中,如果需要调用一个函数,首先(1)会将函数需要的参数进栈,然后(2)将 eip 中的一个字进栈,也就是下一条指令在内存中的位置,这样在函数调用结束后便可以通过堆栈中的 eip 值返回调用函数的程序(CALL将下一条指令的CS:EIP压入堆栈,但真实的情况是,下一条指令的地址压入堆栈,但EIP装入跳转函数的地址)。而在一进入调用函数的时候,第一件事便是(3)将 ebp 进栈(调用本函数的过程的栈指针),然后将当前的 esp 的值赋给 ebp。(4)ebp 以下部分一般作为临时数据区(esp 下调),包含本函数要调用的过程的参数的空间。
当前函数的 ebp 设置在 3 和 4 之间的地址,因此 0x0[%ebp] 可以读出上个函数的栈指针, 0x4[%ebp] 可以读出返回地址 %eip, 0x8[%ebp] 可以读出第一个参数……

笔记03.4 - Lab 1:控制台输出函数

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

以下是 JOS 内核控制台输出代码关于 kern/printf.c、 lib/printfmt.c、 kern/console.c 的解析。摘抄自【系统的启动和初始化】一文。

C 语言中解决变参问题的一组宏

1) 用 va_list 可以定义一个 va_list 型的变量,这个变量是指向参数的指针。
2) 用 va_start 宏可以初始化一个 va_list 变量,这个宏有两个参数,第一个是 va_list 变量本身,第二个是可变的参数的前一个参数,是一个固定的参数。
3) 用 va_arg 宏可以返回可变的参数,这个宏也有两个参数,第一个是 va_list 变量,即指向参数的指针,第二个是返回的参数类型。
4) 用 va_end 宏结束可变参数的获取。

cprintf()函数原型

kern/printf.c 之 cprintf()函数的原型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/*
第一个参数 fmt 代表的是显示字符串的指针,诸如 “%d %c”。之后的参数值用来在显示的时候替代 %d、 %c,可以是常数、整形变量、浮点变量、字符、字符串指针。
*/

int
cprintf(const char *fmt, ...)
{
va_list ap;
int cnt;

/*
定义了 va_list 变量 ap 后马上就用 va_start 宏对其进行了初始化,用 va_start 宏进行初始化的时候第二个参数是 fmt,也就是 cprintf 函数的第一个参数,即可变参数之前的固定参数,于是这时 ap 便指向了后面的可变参数。函数的参数实际上都是存放在内存的堆栈中的,而且参数会按照先后顺序依次存放,靠前的参数会存放在较低的地址处,其中每个参数会根据其类型被分配相应大小的空间。于是 ap 在这个时候便指向了可变参数 1 的存放地址,这样我们就可以用 va_arg 宏依次读取之后的可变参数。
*/

va_start(ap, fmt);
cnt = vcprintf(fmt, ap);
va_end(ap);

return cnt;
}

vcprintf函数原型

kern/printf.c 之 cprintf 函数所调用的 vcprintf 函数的原型

1
2
3
4
5
6
7
8
9
10
11
12
int
vcprintf(const char *fmt, va_list ap)
{
int cnt = 0;

/*
第一个参数实际上是一个函数指针, putch 函数被当做成了一个参数, putch 函数的功能是输出一个字符在屏幕上
*/

vprintfmt((void*)putch, &cnt, fmt, ap);
return cnt;
}

putch函数原型

kern/printf.c 之 putch 函数的原型

1
2
3
4
5
6
7
8
9
10
/*
整形变量 ch 代表的是要输出的字符,因为 int 的变量是 32 位的,而一个字符的 ASCII 码只需要有 8 位,所以实际上 32 位整形变量的低八位代表的是字符的 ASCII 码,而第 8 位到 15 位代表的是输出字符的格式,因此 int 变量的高 16 位实际上没有用的;而 cnt 指针指向一个整形变量,这个整形变量每当用 putch 函数输出一个字符后就加 1。
*/

static void
putch(int ch, int *cnt)
{
cputchar(ch); /*见 kern/console.c */
*cnt++;
}

cputchar函数原型

kern/console.c 之 cputchar 函数的原型

1
2
3
4
5
void
cputchar(int c)
{
cons_putc(c);
}

cons_putc函数原型

kern/console.c 之 cons_putc 函数的原型

1
2
3
4
5
6
7
8
/*将一个字符输出到控制台*/
static void
cons_putc(int c)
{
serial_putc(c);
lpt_putc(c); /*进行一些硬件初始化的工作*/
cga_putc(c);
}

下图表示的是整形参数的 8 到 15 位是如何确定字符输出的格式的:

可以看到高 4 位决定了字符的背景色,可以有 16 种颜色,同样低四位则决定了字符本
身的颜色,在这里, R 代表红色的色素, G 代表绿色的色素, B 代表蓝色的色素,这三个色素的组合就可以组成 8 种不同的颜色,而 I 则表示颜色是否是高亮的,于是这样便可以有 16 种颜色。
下图表示的是显存与显示屏的对应关系:

cga_putc函数原型

kern/console.c 之 cga_putc 函数的原型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
static void
cga_putc(int c)
{

/*
整形参数 c 的低 8 位是字符的 ASCII 码,而 8 到 15 位则是字符输出的格式,函数首先判断字符的格式有没有事先设定,如果没有,即 8 到 15 位皆为 0,则系统将会将这个字符的设定为默认格式。
*/
// if no attribute given, then use black on white
if (!(c & ~0xFF))
c |= 0x0700;

/*
crt_buf 是一个指向 16 位无符号整形数的静态指针,它实际上指向的是内存中
物理地址为 0xb8000 的位置,(物理内存的 0xa0000 到 0xc0000 这 128KB 的空间是留给 VGA 显示缓存的,实际上在我们的试验中从 0xb8000 这个位置开始的一部分内存空间便是可以直接与显示屏相关联的显存)
在本实验中,显示屏规定为 CRT_ROWS = 25 行,每行可以输出 CRT_COLS = 80 个字符,由于每个字符实际上占据显存中的两个字节,于是物理内存中从 0xb8000 到 0xb8fa0 之间的内容都会以字符的形式在屏幕上显示出来。 crt_pos 是一个静态的 16 位无符号整形变量,如果把 crt_buf 指向的内存空间看做 16 位整形数的数组,则 crt_pos 则是数组的下标,在这里它实际上表示的是光标的位置,而 CRT_COLS 则是一个常量,表示一行可以输出的字符数,即为 25。
*/

switch (c & 0xff) {

/*
把 crt_pos 减 1 表示光标向后退一格,并且将光标
当前指向位置对应的显存中的两个字节的值置为(c & ~0xff) | ' ', 即把原来的字符替换为了一个空格,这样便完成了退格的操作。
*/

case '\b': // 表示退格
if (crt_pos > 0) {
crt_pos--;
crt_buf[crt_pos] = (c & ~0xff) | ' ';
}
break;

/*把 crt_pos 加上 25,即将光标的位置换到下一行相同的位置。*/

case '\n': // 表示换行
crt_pos += CRT_COLS;
/* fallthru */

/*将 crt_pos 减去 (crt_pos % CRT_COLS)。*/

case '\r': // 表示光标退到这一行的开头处
crt_pos -= (crt_pos % CRT_COLS);
break;

/*递归的调用 cons_putc 函数连续打印 5 个空格。*/

case '\t': // 光标向前移动 5 格
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
cons_putc(' ');
break;

/*不是以上的这些特殊字符时,程序便将其直接写入显存中,并且将光标的位置加 1,值得注意的是在这里 crt_buf[crt_pos++]表示的是内存中 16 位的空间,然而整形变量 c 是 32 位的,于是在写入内存的时候只取 c 的低 16 位。*/

default: // 往屏幕上打印一个字符
crt_buf[crt_pos++] = c; /* write the character */
break;
}

// What is the purpose of this?

/*
在物理地址超过 0xb8fa0 的内存部分中存储字符数据,此时实际上显示屏就无法显示超过的部分,这个时候通常下显示屏都会滚屏好让最新输出的字符能够显示出来。在每输出一个字符后都先判断 crt_pos 是否大于或等于 CRT_SIZE,而 CRT_SIZE 实际上就是一个屏幕可以输出的字符数,即 80*25。当等于 CRT_SIZE 时,说明此时已经满屏,当大于 CRT_SIZE 时,说明有字符没有显示出来。当满足这两种情况的中的一种时,程序所做的处理是将屏幕上第二行到最后一行的的字符数据复制到第一行到倒数第二行去,然后将屏幕的最后一行输出为空格。
*/
if (crt_pos >= CRT_SIZE) { // CRT_SIZE = (CRT_ROWS * CRT_COLS)
int i;

memmove(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t)); // 执行滚屏操作
for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
crt_buf[i] = 0x0700 | ' ';
crt_pos -= CRT_COLS;
}

/* move that little blinky thing */
outb(addr_6845, 14);
outb(addr_6845 + 1, crt_pos >> 8);
outb(addr_6845, 15);
outb(addr_6845 + 1, crt_pos);
}

vprintfmt函数程序流程图

vcprintf (见 kern/printf.c) 函数所调用的 vprintfmt (见 lib/printfmt.c) 函数的程序流程图

上图 5 个格式变量: padc、 width、 precision、 lflag、 altflag。 padc 代表的是填充字符,在初始化的时候 padc 变量会被初始化为空格符,而当程序在显示字符串的 ’%’ 字符后读到 ’-’ 或者 ’0’ 的字符时便会将 ’-’ 或者 ’0’ 赋值给 padc。 width 代表的是打印的一个字符串或者一个数字在屏幕上所占的宽度,而 precision 则特指一个字符串在屏幕上应显示的长度。当打印字符串的时候, padc = ’-’ 代表着字符串需要左对齐,右边补空格, padc =’ ’ 代表字符串右对齐, 而左边由空格补齐, padc = ’0’ 代表字符串右对齐, 左边由 0 补齐。在我们这个实验中当输出数字时会一律的右对齐,左边补 padc,数字显示长度为数字本身的长度。 lfag 变量则是专门在输出数字的时候起作用,在我们这个实验中为了简单起见实际上是不支持输出浮点数的,于是 vprintfmt 函数只能够支持输出整形数,,输出整形数时,当 lflag = 0 时,表示将参数当做 int 型的来输出,当 lflag = 1 时,表示当做 long 型的来输出,而当 lflag = 2 时表示当做 long long 型的来输出。最后, altflag 变量表示当 altflag = 1 时函数若输出乱码则用 ’?’ 代替。

vprintfmt函数打印字符串

lib/printfmt.c 之 vprintfmt 函数打印一个字符串具体是如何实现的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
case 's':
if ((p = va_arg(ap, char *)) == NULL)
p = "(null)"; /* 当字符串指针为空时,将它指向"(null)"字符串*/
if (width > 0 && padc != '-')
for (width -= strnlen(p, precision); width > 0; width--)
putch(padc, putdat); /*字符串右对齐,左边补相应数量的空格或者 0*/
for (; (ch = *p++) != '\0' && (precision < 0 || --precision >= 0); width--)
if (altflag && (ch < ' ' || ch > '~'))
putch('?', putdat);
else
putch(ch, putdat);/*打印相应长度的字符串*/
for (; width > 0; width--)
putch(' ', putdat); /*当字符串是左对齐的时候打印相应数量的空格*/
break;

当程序识别了显示字符串中 ’%’ 后的 ’s’ 字符后便从可变参数中读入字符串指针,若指针为空,则让它指向一个 “(null)” 字符串。然后再判断输出是左对齐还是右对齐,若 padc = ’-’ , 表示是左对齐, 否则是右对齐。确认是右对齐的话,按照我们之前所讲的, 用 width 减去字符串实际显示长度便得到需要在左边补空格或 0 的个数,注意到 int strnlen(char *str, int maxlen) 函数原型是计算字符串 str 的 (unsigned int 型)长度,不包括结束符NULL,该长度最大为 maxlen , maxlen 传入 -1 的话会返回字符串的长度。在这之后程序便开始打印字符串本身,可以看到若 precision 大于 0 则显示长度等于 precision 与字符串长度之间的最小值, precision 小于 0 则显示长度等于字符串本身的长度(无论 width 多大)。最后程序判断如果字符串是左对齐的话则在右侧剩余空间补充空格。

printnum函数原型

在打印数字的时候则会用到 printnum 这个函数,该函数的主体如下所示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*参数 num 代表需要打印出来的整形数, base 代表整形数的进制,其它的参数和 vprintfmt 函数中代表同样的意思。当 num 超过 1 位时函数递归的调用自己本身,这样便可以先打印高位的数字,当 num 只有 1 位时,程序便首先按照右对齐的格式在左侧打印填充字符,然后打印这个数字。*/

static void
printnum(void (*putch)(int, void*), void *putdat,
unsigned long long num, unsigned base, int width, int padc)
{
// first recursively print all preceding (more significant) digits
if (num >= base) {
printnum(putch, putdat, num / base, base, width - 1, padc);
} else {
// print any needed pad characters before first digit
while (--width > 0)
putch(padc, putdat);
}

// then print this (the least significant) digit
putch("0123456789abcdef"[num % base], putdat);
}

可以看到无论是打印字符串还是数字,都是将它们分解成一个个单个的字符然后用
putch 函数一个一个的打印出来。所以 putch 函数可谓是显示输出的基本函数。

笔记03.3 - Lab 1:bootmain.c

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

解析main.c

以下是 JOS Boot Loader 部分关于 main.c 的解析,关于 main.c 的介绍请阅读【学习笔记03 - Lab 1:Booting a PC】
以下内容摘抄自《系统的启动和初始化》一书

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
#include <inc/x86.h>
#include <inc/elf.h>

/**********************************************************************
* 这是一个简单的引导加载程序,它的唯一工作是从第一个 IDE 硬盘引导 ELF 格式的
* 内核镜像
*
* DISK LAYOUT
* * 本程序(boot.S and main.c)是开机引导程序
* 它必须被存储在硬盘的第一个扇区
*
* * 从第二个扇区开始存储内核镜像
*
* * 内核镜像必须是 ELF 格式
*
* 开机步骤
* * CPU 启动时加载 BIOS 到内存并执行 BIOS
*
* * BIOS 初始化设备,设置终端程序,
* 读取启动设备的第一个扇区到内存并跳转
*
* * 假设本引导加载程序存储在硬盘的第一个扇区,这段代码接管控制权
*
* * 由 boot.S 开始进行控制 -- 设置保护模式、堆栈指针,然后调用 bootmain()
* 执行 c 语言代码
*
* * bootmain() 读取内核镜像并跳转
**********************************************************************/

#define SECTSIZE 512
#define ELFHDR ((struct Elf *) 0x10000) // 定义一个指向内存中 ELF文
//件头存放位置的结构体指针。定义 ELF 文件头应该存放在内存的 0x10000 处

void readsect(void*, uint32_t); //读取磁盘上一个扇区
void readseg(uint32_t, uint32_t, uint32_t); //读取 ELF 文件中一段

void
bootmain(void)
{
struct Proghdr *ph, *eph;

// 将文件的前 4KB 读入内存,4KB 为一页,其中包括 ELF 文件头以及程序头表
readseg((uint32_t) ELFHDR, SECTSIZE*8, 0);

// 判断该文件是否为 ELF 文件
if (ELFHDR->e_magic != ELF_MAGIC)
goto bad;

// 将指针指向程序头表的首地址 (ignores ph flags),其中的 (uint8_t *) 是为了让 ELFHDR 指针每 +1 增加 1 而不是 32 位指针默认的 +4。
ph = (struct Proghdr *) ((uint8_t *) ELFHDR + ELFHDR->e_phoff);
eph = ph + ELFHDR->e_phnum;// 明确文件段的个数
for (; ph < eph; ph++)
// p_pa是该段的加载地址(物理地址)
readseg(ph->p_pa, ph->p_memsz, ph->p_offset);

// 在将内核加载到内存中后转移到内核入口地址处执行,并且不会再返回(直接寻址方式)
((void (*)(void)) (ELFHDR->e_entry))();

bad:
outw(0x8A00, 0x8A00);
outw(0x8A00, 0x8E00);
while (1)
/* do nothing */;
}

// pa 代表该段的加载地址, count 代表该段在内存中所占字节数,
// offset 代表该段在磁盘文件中的相对于文件首的偏移
// 从内核的 offset 偏移处读取 count 字节到内存的 pa 地址上
// 可能实际复制的字节数多于 count
void
readseg(uint32_t pa, uint32_t count, uint32_t offset)
{
uint32_t end_pa;

end_pa = pa + count; // 找到内存中加载地址的最末端

// 由于硬盘中的每一扇区加载到内存的时候都需要 512 字节对齐,
// 于是在这里把起始加载地址向下对齐到 512 字节的倍数的地址处
pa &= ~(SECTSIZE - 1);

// 将在硬盘中的偏移由字节数转换成扇区数,由于内核可执行程序
// 是从磁盘的第二个扇区开始存储的,所以需要加 1
offset = (offset / SECTSIZE) + 1;

// 如果读取很慢,我们可以一次读取多个扇区
// 可能写入到内存的字节数多于要求的,但没有关系,因为我们是递增加载的
while (pa < end_pa) {
// 因为我们还没有启用分页,而是使用一致的段映射 (见 boot.S)
// 所以可以直接使用物理地址 一旦 JOS 允许 MMU 将不会是这种情况
readsect((uint8_t*) pa, offset);
pa += SECTSIZE;
offset++;// 用 readsect 函数一个扇区一个扇区地读取文件的这一段
}
}

void
waitdisk(void)
{
// 循环直到硬盘准备好
while ((inb(0x1F7) & 0xC0) != 0x40)
/* do nothing */;
}

void
readsect(void *dst, uint32_t offset)
{
// 等待直到硬盘准备好
waitdisk();

outb(0x1F2, 1); // count = 1
outb(0x1F3, offset);
outb(0x1F4, offset >> 8);
outb(0x1F5, offset >> 16);
outb(0x1F6, (offset >> 24) | 0xE0);
outb(0x1F7, 0x20); // cmd 0x20 - read sectors

// 等待直到硬盘准备好
waitdisk();

// 读取一个扇区的数据
insl(0x1F0, dst, SECTSIZE/4);
}

笔记03.2 - Lab 1:ELF

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

以下内容摘抄自《系统的启动和初始化》一书

ELF = “Executable and Linkable Format”
在 JOS 操作系统实验中,内核的可执行程序实际上是一个 ELF 文件。 ELF 文件可以分为这样几个部分: ELF 文件头(定长)、程序头表(program header table)(不定长)、节头表(section header table)和文件内容。而其中文件内容部分又可以分为这样的几个节: .text 节、 .rodata 节、 .stab节、 .stabstr 节、 .data 节、 .bss 节、 .comment 节。程序头表实际上是将文件的内容分成了好几个段,而每个表项就代表了一个段,有可能就是同时几个节包含在同一个段里。

加载 ELF 文件到内存中是先加载文件头信息,通过 ELF 文件头获取所有的程序头表项数据,然后按段的形式加载文件内容的,下面从段的角度介绍一下 ELF 文件结构。

我们先来看看 ELF 文件头的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct Elf {
uint32_t e_magic; // 标识文件是否是 ELF 文件
uint8_t e_elf[12]; // 魔数和相关信息
uint16_t e_type; // 文件类型
uint16_t e_machine;// 针对体系结构
uint32_t e_version; // 版本信息
uint32_t e_entry; // Entry point 程序入口点,是虚拟的链接地址
uint32_t e_phoff; // 程序头表偏移量,是程序头表的第一项相对于 ELF 文件的开始位置的偏移
uint32_t e_shoff; // 节头表偏移量
uint32_t e_flags; // 处理器特定标志
uint16_t e_ehsize; // 文件头长度
uint16_t e_phentsize;// 程序头部长度
uint16_t e_phnum; // 程序头部表项个数
uint16_t e_shentsize;// 节头部长度
uint16_t e_shnum; // 节头部个数
uint16_t e_shstrndx; // 节头部字符索引
};

可以用“ objdump -f 可执行文件”这样的命令来查看硬盘中 ELF 文件的文件头信息,如“ objdump -f obj/kern/kernel ”:

再看看程序头表项的数据结构:

1
2
3
4
5
6
7
8
9
10
struct Proghdr {
uint32_t p_type; // 段类型
uint32_t p_offset; // 段位置相对于 ELF 文件开始处的偏移量,标识出了磁盘位置(相对的位置,这里没有包括 ELF 文件之前的磁盘)
uint32_t p_va; // 段放置在内存中的地址(虚拟的链接地址)
uint32_t p_pa; // 段的物理地址
uint32_t p_filesz; // 段在文件中的长度
uint32_t p_memsz; // 段在内存中的长度
uint32_t p_flags; // 段标志
uint32_t p_align; // 段在内存中的对齐标志
};

用 ELF 文件头与程序头表项如何找到 ELF 文件的第 i 段:

从段的角度介绍完 ELF 文件结构后,下面从节的角度介绍一下 ELF 文件结构:

1
2
3
4
5
6
7
.text 节:可执行指令的部分。
.rodata 节:只读全局变量部分,如 C 编译器产生的 ASCII string 常量。
.stab 节:符号表部分,这一部分的功能是程序报错时可以提供错误信息。
.stabstr 节:符号表字符串部分。
.data 节:可读可写的全局变量部分,保存已初始化的数据,如已初始化的全局变量 int x = 5。
.bss 节:未初始化的全局变量部分,如 int x,这一部分不会在磁盘有存储空间,因为这些变量并没有被初始化,全部默认为 0。内核代码编译时,链接器为未初始化全局变量保留了.bss 空间,并计算出.bss 的地址和大小。在将这节装入到内存的时候程序需要为其分配相应大小的初始值为 0 的内存空间。
.comment 节:注释部分,这一部分不会被加载到内存。

可以用“ objdump –h 可执行文件”这样的命令来查看硬盘中 ELF 文件的每个节的信息(不是内存布局),如“ objdump -h obj/kern/kernel ”:

可以发现.bss 节与.comment 节在文件中的偏移是一样的,这就说明.bss 在硬盘中式不占用空间的,仅仅只是记载了它的长度。但是.bss 节在装入内存后是占据一定的空间的。
另一方面,“ VMA ”表示链接地址 link address ,“ LMA ”表示加载地址 load address ,节的 LMA 表示该节会被加载到内存中的 LMA 地址上。

可以用“ objdump –x 可执行文件”这样的命令来查看硬盘中 ELF 文件的文件头、段、节、符号表等信息(不是内存布局),如“ objdump -x obj/kern/kernel ”:

从 start address 可以看出 kernel 的入口地址是 0x0010000c。
段信息部分,vaddr是线性地址,paddr是物理地址,可以看出,第一段在文件中的偏移是 0x1000,而在内存中占据的字节数是 0x717b,链接地址 0xf0100000,物理地址 0x100000,包括了.text 、.rodata 、.stab 、.stabstr 节;第二段在文件中的偏移是 0x9000,而在内存中占据的字节数是 0xa944,链接地址 0xf0108000,物理地址 0x108000,包含了.data 节以及在硬盘上不占用空间但在内存中占据 644 字节的.bss 节。在这里程序头表的第二项会用p_filesz 成员变量标注该段在文件占用的字节数并且同时用 p_memsz 标注在内存中占用的字节数,这样 Boot Loader 便会在从硬盘读入第二段的同时为.bss 节在内存中分配空间。我们可以看到,.comment 节没有被包含在任意一段中,这表明它没有被装入内存。
注意到:链接地址是高地址,而加载地址是低地址。这表示内核代码告诉 Boot Loader 在加载它时要加载到内存低地址(1M),而它将从内存高地址执行。

stab 节在 ELF 文件结构为符号表部分,stabstr 是符号表的字符串部分。可以通过 objdump -G obj/kern/kernel 查看 stab 节的内容,jos 中解析 stab 节的数据结构见于 inc/stab.h。其中,n_type 有几种类型,SO 表示主函数的文件名,SOL 表示包含进的文件名,SLINE 表示代码段的行号,FUN 表示函数的名称。结合 grep SO、 grep FUN 等可以对结果进行分类。objdump -G 命令还可以看到每个文件在编译后在 ELF 文件中的链接地址,从小到大依次排列。objdump -G obj/kern/kernel | grep FUN 可以发现函数也是按照它们属于各自文件的顺序 ,依次排列在链接地址空间里。

kern/kdebug.c 中有二分查找函数 stab_binsearch,实现了在 stab 中查找 addr 对应表项的过程(根据链接地址查找)。根据 %eip 读取当前指令所在文件、所在行、所在函数的实现过程则见于 kern/kdebug.c 的 debuginfo_eip 函数。其中,stab 节的位置 __STAB_BEGIN__ 是链接器在链接时得到的,在 kern/kernel.ld 中可以看到用于初始化 stabs 和 stab_end 两个变量的部分。调用 stab_binsearch 的两个参数 int region_left, int region_right 是表项序号,不是内存地址,使用 stab_end - stabs 即可,因为同类型指针相减会自动除去该类型的 size 的,即这里可以获取相隔的元素个数之差。

最后,我们介绍一下节头表。
节头表的功能是让程序能够找到特定的某一节,通过 ELF 文件头与节头表找到文件的某一节的方式和之前所说的找到某一段的方式是类似的。我们来看看节头表项的数据结构:

1
2
3
4
5
6
7
8
9
10
11
12
struct Secthdr {
uint32_t sh_name; // 节名称
uint32_t sh_type; // 节类型
uint32_t sh_flags; // 节标志
uint32_t sh_addr; // 节在内存中的线性地址
uint32_t sh_offset; // 相对于文件首部的偏移
uint32_t sh_size; // 节大小(字节数)
uint32_t sh_link; // 与其它节的关系
uint32_t sh_info; // 其它信息
uint32_t sh_addralign; // 字节对齐标志
uint32_t sh_entsize; // 表项大小
};

笔记03.1 - Lab 1:boot.S

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

解析boot.S

以下是 JOS Boot Loader 部分关于 boot.S 的解析,关于 boot.S 的介绍请阅读【学习笔记03 - Lab 1:Booting a PC】
以下内容摘抄自《系统的启动和初始化》一书

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
#include <inc/mmu.h> 

# 启动 CPU ,切换到 32 位保护模式,跳转到 C 代码
BIOS 将这份代码从硬盘的第一个扇区读取到内存的 0x7c00 物理地址上
# 设置 %cs=0 %ip=7c00 并在 real mode 下开始执行

# 首先 boot 程序会进行初始化,先把代码段选择子与数据段选择子以及保护模式
# 启动标识设置为常量,然后关中断并且将 ds、 es、 ss 这些段寄存器全部清零
.set PROT_MODE_CSEG, 0x8 # 内核代码段选择子常量,gdt 表中第二项
.set PROT_MODE_DSEG, 0x10 # 内核数据段选择子常量,gdt 表中第三项
.set CR0_PE_ON, 0x1 # 内核保护模式启动标识位 常量

.globl start
start:
.code16 # 16 位模式
cli # 关中断,只能在内核模式下执行
cld # 使方向标识位 DF 复位,内存地址向高地址增加

# 设置重要的数据段寄存器(DS, ES, SS)
xorw %ax,%ax # ax 清零
movw %ax,%ds # 数据段寄存器清零
movw %ax,%es # 附加段寄存器清零
movw %ax,%ss # 堆栈段寄存器清零

# A20对实模式和保护模式的影响:
# 实模式下:在8086/8088中,分段模式能够表示的最大内存为FFFFh:FFFFh=FFFF0h+FFFFh=10FFEFh=1M+64K-16Bytes(1M多余出来的部分被称做高端内存区HMA)。但8086/8088只有20位地址线,如果访问100000h~10FFEFh之间的内存,则必须有第21根地址线。所以当程序员给出超过1M(100000H-10FFEFH)的地址时,系统并不认为其访问越界而产生异常,而是自动从重新0开始计算,系统计算实际地址的时候是按照对1M求模的方式进行的,这种技术被称为wrap-around。到了80286,系统的地址总线发展为24根,如果A20 Gate被打开,则当程序员给出100000H-10FFEFH之间的地址的时候,系统将真正访问这块内存区域;如果A20 Gate被禁止,则当程序员给出100000H-10FFEFH之间的地址的时候,系统仍然使用8086/8088的方式。在80286以及更高系列的PC中,即使A20 Gate被打开,在实模式下所能够访问的内存最大也只能为10FFEFH,尽管它们的地址总线所能够访问的能力都大大超过这个限制。
# 保护模式下:如果A20 Gate被禁止,则其第20-bit在CPU做地址访问的时候是无效的,永远只能被作为0;如果A20 Gate被打开,则其第20-bit是有效的,其值既可以是0,又可以是1。所以,在保护模式下,如果A20 Gate被禁止,则可以访问的内存只能是奇数1M段,即1M,3M,5M…,也就是00000-FFFFF, 200000-2FFFFF,300000-3FFFFF…。如果A20 Gate被打开,则可以访问的内存则是连续的。
# 下面的代码打开 A20 地址线
seta20.1:
inb $0x64,%al # 从 0x64 端口读入一个字节的数据到 al 中
# 等待空闲的时候

testb $0x2,%al # 测试 al 的第 2 位是否为 0
jnz seta20.1 # 如果 al 的第 2 位不为 0,循环检查

movb $0xd1,%al # 将 0xd1 写入到 al 中
outb %al,$0x64 # 将 al 中的数据写入到端口 0x64 中

seta20.2:
inb $0x64,%al # 从 0x64 端口读入一个字节的数据到 al 中
# 等待空闲的时候

testb $0x2,%al # 测试 al 的第 2 位是否为 0
jnz seta20.2 # 如果 al 的第 2 位不为 0,循环检查

movb $0xdf,%al # 将 0xdf 写入到 al 中
outb %al,$0x60 # 将 al 中的数据写入到端口 0x60 中

# 将系统从实模式切换到保护模式
# 首先用 “lgdt gdtdesc” 这条指令将 GDT 表的首地址加载到 GDTR, 然后将 cr0 寄存器的最低位置 1, 标志着系统进入保护模式,最后用一个跳转指令让系统开始使用 32 位的寻址模式。

# 在装载 cr0 之前需要先使用指令 lgdt gdtdesc 加载段表,原因是:
# 开启保护模式之后,基址:偏移 这种寻址方式就变成了 段选择子:偏移 这种方
# 式,而所谓的段选择子就是段表中的索引,因此为了正确的进行段式地址变换,还
# 需要加载段表。
lgdt gdtdesc # 将全局描述符表标识符加载到全局描述符表寄存器

# 开启保护模式
# cr0 中的第 0 位为 1 表示处于保护模式
# cr0 中的第 0 位为 0 表示处于实模式
movl %cr0, %eax # 把控制寄存器 cr0 的数据加载到 eax 中
orl $CR0_PE_ON, %eax # 将 eax 中的第 0 位设置为 1
movl %eax, %cr0 # 将 eax 中的值装入 cr0 中

# 跳转到 32 位模式中的下一条指令,将处理器切换为 32 位工作模式
# 由于是在保护模式中,所以 $PROT_MODE_CSEG 被当作段选择子,而 $protcseg 是偏移地址。从后面的 GDT 表中可以看到,段选择子的值是 0x8,于是对应的段描述符会是表中的第二项(因为段选择子的低三位表示了RPL和TI),即是 SEG(STA_X|STA_R, 0x0, 0xffffffff)这一项,0x0 表示段首地址是 0,所以最终得到的线性地址为 0+$protcseg,程序便会跳到 protcseg 所标识的位置来执行。由于此时尚未开启分页,因此该链接地址会被当做加载地址,所以 boot loader 的加载地址必须和链接地址保持一致。 lab1 中的 boot/Makefrag 文件的第 28 行实际上规定了 Boot Loader 的固定链接地址是 0x7C00 。
# 将代码段选择子常量 $PROT_MODE_CSEG 加载到 cs 中,cs 对应的高速缓冲存储器会自动加载代码段描述符,同样将 $protcseg 加载到 ip 中(此后如果没有修改段寄存器的内容,cs 的内容将不会改变)。

ljmp $PROT_MODE_CSEG, $protcseg

# 进入保护模式后,程序重新对段寄存器进行初始化并且赋值堆栈指针,然后调用
# bootmain 函数。可以看到,在 “call bootmain” 之后便是一个无限循环的跳转指令,
# 之所以是无限循环就是这个函数调用永远都不会有返回的可能性,这句程序仅仅只是
# 让整个代码看起来有完整性。
.code32 # 32 位模式
protcseg:
# 设置保护模式下的数据寄存器
movw $PROT_MODE_DSEG, %ax # 将数据段选择子常量装入到 ax 中
# 将 ax 装入到其他数据段寄存器中,在装入的同时,这些段寄存器对应的高速缓冲
# 寄存器会自动加载数据段描述符
movw %ax, %ds # -> DS: Data Segment
movw %ax, %es # -> ES: Extra Segment
movw %ax, %fs # -> FS
movw %ax, %gs # -> GS
movw %ax, %ss # -> SS: Stack Segment

# 设置栈指针,并且调用 main.c 中的 bootmain 函数
# 值得注意的是,这是进入保护模式之后第一个栈顶,在任何函数调用前都要初始化栈,boot.S 里很巧妙的将 start 作为栈的基址,因为栈空间是向下增长的,$start 的地址是 0x7c00,在正式的设定之前,0-start 这段空间做为栈,应该足够了。
movl $start, %esp
call bootmain

# 如果 bootmain 返回的话,就一直循环
spin:
jmp spin

.p2align 2 # 强制 4 字节对齐
# GDT 表的存放位置是 4 字节对齐的,也
# 就是说 GDT 表的物理首地址是 4 的倍数
# GDT 全局描述符表描述符
gdt:
SEG_NULL # 空表项,连续 8 个值为 0 的 字节
SEG(STA_X|STA_R, 0x0, 0xffffffff) # 代码段表项
SEG(STA_W, 0x0, 0xffffffff) # 数据段表项
# SEG 宏的第一个参数是 type,第二个是base,
# 第三个是 limit,所以我们可知定义的第二、
# 第三个段均是基址为 0,长度是 4G 的段

# 全局描述符表对应的描述符
gdtdesc:
.word 0x17 # gdt 表长度 - 1,说明 gdt 表长度为 24 字节
.long gdt # gdt 表物理地址

补充概念

物理地址:将主板上的物理内存条所提供的内存空间定义为物理内存空间,把内存看成一个从 0 字节一直到最大空量逐字节的编号的大数组,然后把这个数组叫做物理地址。
逻辑地址:逻辑地址指的是机器语言指令中,用来指定一个操作数或者是一条指令的地址。Intel中段式管理中,对逻辑地址要求:一个逻辑地址,是由一个段标识符加上一个指定段内相对地址的偏移量,表示为 [段标识符:段内偏移量],也就是说,给定 0xFFFFFFFF ,应该表示为[A的代码段标识符: 0xFFFFFFFF],这样才完整一些。
线性地址:也叫链接地址。Intel为了兼容,将远古时代的段式内存管理方式保留了下来。程式代码会产生逻辑地址,或说是段中的偏移地址,加上相应段的基地址就生成了一个线性地址(这种情况下,个人认为逻辑地址的概念已经由[段标识符:段内偏移量]变为[段内偏移量]了)。Linux中逻辑地址等于线性地址。jos 的代码段和数据段基址都是从 0x0 开始,长度 4G(内核gdt指定),这样线性地址=逻辑地址+ 0x0,相当于禁用了段机制的寻址功能,也就是说逻辑地址等于线性地址了。这样的情况下Linux只用到了GDT,不论是用户任务还是内核任务,都没有用到LDT。线性地址空间是指一段连续的、不分段的、范围为 0~4GB 的地址空间。一个线性地址就是线性地址空间的一个绝对地址。
虚拟内存空间中的地址转换为物理地址: CPU 将一个虚拟内存空间中的地址转换为物理地址,需要进行两步:首先将给定一个逻辑地址(其实是段内偏移量,这个一定要理解!!!),逻辑地址实际上就是程序自己假设在内存中存放的位置,即编译器在编译的时候会认定程序将会连续的存放在从起始处的线性地址开始的内存空间,于是像 protcseg 这样的地址标识符就被编译成了那段代码开始处的偏移地址。 CPU 要利用其段式内存管理单元,先将逻辑地址加上基址,转换成一个线性地址,再利用其页式内存管理单元,转换为最终物理地址。JOS 实验的 Boot Loader 阶段段基地址为0,偏移量即为逻辑地址,由于没有启用分页,线性地址甚至直接使用为物理地址。后续操作中,内核将被加载到低地址,但使用高地址进行访问,其映射工作就是通过开启分页机制来完成的。

Boot Loader 的起始链接地址:lab1 中的 boot/Makefrag 文件的第 28 行实际上规定了 Boot Loader 的链接地址:$(V)$(LD) $(LDFLAGS) -N -e start -Ttext 0x7C00 -o $@.out $^。其中“ 0x7C00”便是规定的链接地址。假设我们修改了 Boot Loader 的起始链接地址(不再是固定的 7c00 ),重新编译代码后,boot.asm 反汇编文件展示的指令代码地址和指令跳转全是按照链接地址重新计算过的。首先我们先在物理内存的 0x7c00 处设置一个断点,因为 Boot Loader 一定会加载在这个位置,所以 0x7c00 处的第一条指令便是 boot.S 的第一条可执行指令。gdb 对应的是物理地址,单步调试时每一行显示的是物理地址。 Boot Loader 加载到 0x7c00 后,由于尚未涉及到跳转,因此取下一条指令时只要 ip 累加即可继续正确执行。涉及到 jne 等跳转指令时,如果条件未满足,仍然继续向下取指令执行。当碰上如 ljmp 等指令进行跳转时,目标链接地址是重新计算过的,该地址在 boot.asm 中对应的指令并不同于此时内存中相应位置的指令,因此当以该地址取内存中的指令时,导致出错。综上,为了正确地跳转到下一条指令的位置,需要使加载地址等于链接地址,因此 Boot Loader 的起始链接地址必须固定为 0x7c00 。

笔记03 - Lab 1:Booting a PC

发表于 2017-08-20 | 分类于 MIT6.828 Operating System Engineering

安装仿真环境

课程内核程序git仓库地址:https://pdos.csail.mit.edu/6.828/2014/jos.git 运行机器要求:i386 Athena 机器(uname -a 提示 i386 GNU/Linux 或者 i686 GNU/Linux)。可使用qemu模拟器运行内核程序和Compiler Toolchain工具。虽然qemu的内置监控只提供了有限的调试支持,qemu可以作为GNU调试器(GDB)的远程调试目标。课程项目包含了一个分级评分项目,运行 make grade 可以测试自己的解决方案。

本课程提供的qemu源码安装步骤:
1、Clone the IAP 6.828 QEMU git repository
git clone https://github.com/geofft/qemu.git -b 6.828-1.7.0
2、Configure the source code
Linux:
./configure –disable-kvm [–prefix=PFX] [–target-list=”i386-softmmu x86_64-softmmu”]
OS X:
./configure –disable-kvm –disable-sdl [–prefix=PFX] [–target-list=”i386-softmmu x86_64-softmmu”]
The prefix argument specifies where to install QEMU; without it QEMU will install to /usr/local by default. The target-list argument simply slims down the architectures QEMU will build support for.
3、Run make && make install

执行 ./configure –disable-kvm 安装qemu 1.7.0过程可能出现依赖错误,提前安装的软件如下:

1
2
3
4
5
6
sudo apt-get install zlib1g-dev
sudo apt-get install libglib2.0
sudo apt-get install autoconf
sudo apt-get install libtool
sudo apt-get install libsdl-console
sudo apt-get install libsdl-console-dev

如果出现以下错误的话,按照提示执行,如执行(2) git submodule update –init pixman

1
2
3
4
ERROR: pixman not present. Your options:
(1) Preferred: Install the pixman devel package (any recent distro should have packages as Xorg needs pixman too).
(2) Fetch the pixman submodule, using:
git submodule update --init pixman

执行 make install 的时候,可能会出现 以下错误:

1
make[1]: bison: Command not found

解决方案是执行 sudo apt-get install bison

1
2
3
make[4]: Nothing to be done for `all-am'.
install -d -m 0755 "/usr/local/share/qemu/keymaps"
...提示不存在相关qemu目录...

解决方法是以root的方式执行 sudo make install

Part 1: PC Bootstrap

搭建并熟悉实验环境,包括QEMU虚拟机、以及GDB调试器

仿真xv6

安装完qemu并下载课程内容后,进入课程目录并执行

1
2
cd lab
make

上述步骤会生成obj/kern/kernel.img文件,这是仿真PC的虚拟硬盘内容,包括引导加载程序boot loader (obj/boot/boot)和内核kernel (obj/kernel)。
返回到lab目录,执行以下命令启动qemu

1
make qemu

‘Booting from Hard Disk…’之后的内容都是由JOS内核程序输出。K>是内核的交互式控制程序打印的提示。为了测试和实验分级的目的,JOS内核的控制台输出被设置为写到虚拟VGA显示(见qemu窗口)和模拟PC的虚拟串口(见终端)。JOS内核从键盘和串行端口接收输入,可以在VGA显示窗口或运行QEMU的终端输入命令,Ctrl-Alt 可以切换终端和VGA显示窗口,VGA显示窗口输入如下所示:

执行kerninfo命令时,内核监控程序将运行在仿真PC的“原生(虚拟)硬件”上。

PC的物理地址空间

以32位地址空间为例

早期PC基于16位的因特尔8088处理器,只有1MB物理内存,地址范围是0x0000000 - 0x000fffff,非上图所示的0x0000000 - 0xffffffff。Low Memory是早期PC唯一能使用的随机存取存储器RAM,占了640KB。事实上,非常早期的PC仅仅只能使用16KB、32KB或者64KB的RAM。剩余的384KB空间有诸如作为视频显示缓冲区和非易失性内存保存固件等特殊用途。从0x000A0000到0x000FFFFF的384kB的区域是被硬件保留着用于特殊通途的,比如像作为VGA的显示输出的缓存或者是被当作保存系统固化指令的非易失性存储器。这一部分内存区域中最重要的应该是保存在0x000F0000到0x00100000处占据64KB的基本输入输出系统(BIOS)。早期PC使用只读存储器ROM存储BIOS,而不是现今的可更新闪存。BIOS作用是:执行基本的系统初始化,如激活显卡和检查已装置的内存总量;执行初始化后,BIOS从一个适当的位置加载操作系统到内存,这些位置可以是如软盘、硬盘、CD-ROM或网络,将机器控制权移交给操作系统。
因特尔80286处理器支持16MB地址空间,80386处理器支持4G地址空间,为了软件的向后兼容,仍然保留1MB的低地址空间。因此PC的RAM被0x000A0000 - 0x00100000这块物理内存(第一个hole)分为两部分,前640KB成为常规内存,0x00100000以上部分成为扩展内存。另外,现在一般由BIOS保留32位物理地址空间的高地址部分(所有物理RAM之上),供32位PCI设备使用。现有x86处理器能支持4G以上地址空间,0xFFFFFFFF以上地址能继续扩展RAM,因此,BIOS必须绕过上述32位设备映射空间(第二个hole)。本课程的内核程序基于80386处理器,只使用PC物理内存的前256MB,因此只考虑PC只支持32位物理地址空间。

使用qemu调试工具研究IA-32兼容计算机的启动过程

开启两个终端,分别进入lab目录后,一个终端执行 make qemu-gdb (或 make qemu-nox-gdb) 命令开启qemu,qemu在处理器执行第一条指令之前将停止并等到GDB的调试连接;另一个终端执行 gdb 命令,该命令使用已提供的.gdbinit文件来设置GDB,.gdbinit文件确保GDB能在早期引导期间进行16位代码调试工作,并引导它附属到监听的qemu,如若出现gdb无法执行.gdbinit文件的情况,按照提示添加add-auto-load-safe-path到主目录。
实验证明,启动qemu监听以后,开启gdb连接,如果gdb断开并重新开启,会出现类似以下错误,解决方法是重新开启qemu。

1
2
Ignoring packet error, continuing...
warning: unrecognized item "timeout" in "qSupported" response

注意到这一行:[f000:fff0] 0xffff0: ljmp $0xf000,$0xe05b,它是GDB对第一条执行指令的反汇编结果(GDB连接qemu后qemu执行的第一条指令),表示的意思是:IBM PC开启时CS段寄存器内容是0xf000,IP段寄存器是0xfff0,执行的物理地址是0x000ffff0(BIOS 64KB地址范围内),第一条执行的指令是jmp,跳转的段寄存器地址是CS = 0xf000 和 IP = 0xe05b。由于BIOS物理地址范围是0x000f0000-0x000fffff(“硬连线的”),IBM早期的PC沿用以上因特尔8088处理器的设计,确保开启电源或重启后BIOS能控制机器。qemu仿真器附带的BIOS也安置在这个位置(位于处理器模拟的物理地址空间上)。一旦处理器复位,模拟的处理器将进入实地址模式并设置CS = 0xf000 和 IP = 0xe05b,在(CS:IP)段地址开始执行。实模式下,PC启动时段地址转换为物理地址过程如下:

1
2
3
4
physical address = 16 * segment + offset
16 * 0xf000 + 0xfff0 # in hex multiplication by 16 is
= 0xf0000 + 0xfff0 # easy--just append a 0.
= 0xffff0

这个位置距离BIOS结束地址0x100000只有16字节空间,因此BIOS执行的第一条指令是跳转到BIOS地址空间更靠前的位置。BIOS运行时,设置一个中断描述符表,初始化VGA显示器等各种设备。初始化PCI总线和BIOS知道的所有重要设备后,BIOS搜索软盘、硬盘或光盘等可引导设备。最终,当它发现一个引导盘时,BIOS从磁盘读取引导加载程序并将控制权移交给引导加载程序。此BIOS部分的其他解释见博客【计算机原理-计算机启动】

Part 2: The Boot Loader

了解PC启动过程以及内核装载过程

启动扇区和引导程序

硬盘由于传统的原因被默认分割成了一个个大小为 512 字节的扇区,而扇区则是硬盘最小的读写单位,即每次对硬盘的读写操作只能够对一个或者多个扇区进行并且操作地址必须是 512 字节对齐的。如果说操作系统是从磁盘启动的话,则磁盘的第一个扇区就被称作“启动扇区”,因为 Boot Loader 的可执行程序就存放在这个扇区。在本实验中,当 BIOS 找到启动的磁盘后,便将 512 字节的启动扇区的内容装载到物理内存的 0x7c00 到 0x7dff 的位置,紧接着再执行一个跳转指令将 CS 设置为 0x0000, IP 设置为 0x7c00,这样便将控制权交给了 Boot Loader 程序。
PC 发展到很后来的时候才能够从 CD-ROM 启动,而 PC 架构师也重新考虑了 PC 的启动过程。然而从 CD-ROM 启动的过程略微有点复杂。 CD-ROM 的一个扇区的大小不是 512 字节而是 2048 字节,并且 BIOS 也能够从 CD-ROM 装载更大的 BootLoader 程序到内存。在本实验中由于规定是从硬盘启动,所以我们暂且不考虑从 CD-ROM启动的问题。
本实验 Boot Loader 的源程序是由一个叫做的 boot.S 的 AT&T 汇编程序与一个叫做 main.c 的 C 程序组成的。这两部分分别完成两个不同的功能。其中 boot.S 主要是将处理器从实模式转换到 32 位的保护模式,这是因为只有在保护模式中我们才能访问到物理内存高于 1MB 的空间,保护模式下段地址(段:偏移)到物理地址的转化不同于实模式,模式转化后偏移是 32 bits 而不是 16 bits 。main.c 的主要作用是将内核的可执行代码从硬盘镜像中读入到内存中,具体的方式是运用 x86 专门的 I/O 指令。
obj/boot/boot.asm是引导程序的反汇编结果,obj/kern/kernel.asm是内核程序的反汇编结果,可被用于调试参考。比如使用 b *0x7c00 命令在0x7c00处设置断点,并使用 c 命令跳转到该断点,参考obj/boot/boot.asm进行追踪。

引导程序之模式转换

以下分析尝试解决的问题是: 1.什么原因导致了 16-bit 模式转换为 32-bit 模式? 2.处理器什么时候开始执行 32-bit 代码?
Boot Loader 会被加载到 0x7c00 处,设置断点可看出改为知道第一条指令是 boot.S 的第一条可执行指令。
gdb 图示

boot.S 图示

boot.S 的具体分析请阅读【学习笔记03.1 - Lab 1:boot.S】
16位模式和32位模式的区别请阅读【计算机原理 - Intel 32 位处理器的工作模式】
《x86汇编语言:从实模式到保护模式》一书把实模式和 16 位的保护模式统称为 “ 16 位模式”;把 32 位保护模式称为 “ 32 位模式”。简单来讲, 16 位模式下,处理器把所有指令都看成是 16 位的,数据的大小是 8 位或者 16 位的,控制转移和内存访问时偏移量是 16 位的;32 位模式下,数据的大小是 8 位或者 32 位的,但兼容 80286 的 16 位保护模式。
8086 CPU(16位)实模式下数据总线为16位(一次最多能取2^16=64KB数据,实模式下每个段最大只有64KB),地址总线为20位(寻址的能力是2^20=1MB,实模式下CPU的最大寻址能力),实模式下所有寄存器都是16位。
从80386开始CPU数据总线和地址总线均为32位,而且寄存器都是32位。
问题 1.什么原因导致了 16 位模式转换为 32 位模式?: boot.S 首先在实模式下执行 16 位代码,然后切换到保护模式,通过跳转执行 32 位代码。转换到 32 位模式(这里指保护模式)才能访问 1M 以上的地址空间,同时更灵活地进行存储管理,并且对程序能够访问的物理地址进行限制。具体改变是ljmpl指令改变了%cs寄存器,其对应的段描述符由16位变为32位。
问题 2.处理器什么时候开始执行 32 位代码?: 处理器开A20地址线,装载 GDT 表,装载 cr0 为1开启保护模式后,就跳转到 32 位模式中的下一条指令,将处理器切换为 32 位工作模式,从而执行 32 位代码。跳转代码以及执行的第一条 32 位代码见下:

1
2
3
4
5
6
    ljmp    $PROT_MODE_CSEG, $protcseg 
...
.code32 # 32 位模式
protcseg:
movw $PROT_MODE_DSEG, %ax
...

引导程序之内核装载

以下分析尝试解决的问题是:1. boot loader 如何决定读取多少个扇区,而不是从磁盘上获取整个内核,它是从哪里取得信息进行判断的?2.内核程序的第一条指令在哪里?3.boot loader 执行的最后一句指令是什么?4.内核程序装载后执行的第一条指令是什么?
bootmain.c 的具体分析请阅读【学习笔记03.3 - Lab 1:bootmain.c】
问题 1.boot loader 如何决定读取多少个扇区,而不是从磁盘上获取整个内核,它是从哪里取得信息进行判断的?: bootmain.c 首先将磁盘第二个扇区文件的前 4KB 读入内存,4KB 为一页,其中包括 ELF 文件头以及程序头表。根据程序头表的信息明确文件段的个数,然后将文件逐段读入内存。逐段读入时,将段在硬盘中的偏移由字节数转换成扇区数,并找到段在内存中加载地址的最末端 end_pa ,以当前加载的物理地址小于 end_pa 为循环条件逐扇区读取。
问题 2.内核程序的第一条指令在哪里?: 装载内核文件到内存后,内核程序的第一条指令在物理地址 0x10000c 处。(eip 为 0x10000c,cs 为 0x8 不变!)(boot loader 的入口地址为 0x7c00,内核文件 ELF 文件读写到内存 0x10000 开始的地方, kernel 的入口地址为 0x10000c,0x10000C 是系统内核的第一条指令所在的物理地址处)
问题 3.boot loader 执行的最后一句指令是什么?: 在将内核装载到内存中后转移到内核入口地址处执行,c语言是 ((void ()(void)) (ELFHDR->e_entry))(); ,指令是 **=> 0x7d61: call 0x10018,执行该指令会转移到物理地址 0x10000c 处(直接寻址方式)。 问题 4.内核程序装载后执行的第一条指令是什么?: 通过 gdb 调试,可知内核程序装载后执行的第一条指令是 => 0x10000c: movw $0x1234,0x472**。

Part 3: The Kernel

了解页表机制、库函数实现机制、内核堆栈
内核程序也是通过部分汇编代码进行一些设置,然后跳转到 C 语言代码执行函数。kern/entry.S 第 80 行通过 call i386_init 调用了 kern/inic.c 的 i386_init 函数。

虚拟内存

操作系统内核通常会链接和运行在非常高的线性地址上,这是为了留出处理器的线性地址空间的中低部分给用户程序使用。许多机器没有 0xf0100000 的物理地址,因此不能指望将内核装载在这里。相反,将使用处理器的内存管理硬件映射线性地址 0xf0100000 (内核代码将在该链接地址运行)到物理地址 0x00100000 (引导加载程序加载内核到该物理内存)。这种情况下,内核代码将被装载到物理内存中 1MB 的内存,略高于 BIOS ROM。
下次实验将会映射物理地址空间的低 256MB (0x00000000 - 0x0fffffff) 到线性地址(0xf0000000 - 0xffffffff)。现在只需映射物理内存的前 4MB 就足以启动和运行 JOS 内核,这部分工作将通过手写、静态初始化页目录和页表来完成,详见 kern/entrypgdir.c。 kern/entry.S 将设置 CR0_PG 标识符,虚存硬件开始将线性地址映射为物理地址。在标识符设置之前,由于没有开启分页,逻辑地址通过段映射得到的线性地址都被当作物理地址。(能访问的到的 0 到 4G 的地址空间实际上是线性地址空间,在开启分页机制后,还要经过页表转换才能得到真实地址,而在开启分页之前系统一般会控制只访问低地址。) entry_pgdir 将 0xf0000000 - 0xf0400000 和 0x00000000 - 0x00400000 的虚拟空间映射到物理空间 0x00000000 - 0x00400000 。使用 qemu 和 gdb 调试,当开始执行内核代码到 => 0x100025: mov %eax,%cr0 处后,查看 0x00100000 和 0xf0100000 位置上的内容,将会发现内容一样(注意观察前后的movl汇编指令,可以发现开启分页后就开始使用程序内 KERNBASE 开始的线性地址。): gdb之x命令

1
2
3
4
(gdb) x/3xw 0x100000
0x100000: 0x1badb002 0x00000000 0xe4524ffe
(gdb) x/3xw 0xf0100000
0xf0100000 <_start+4026531828>: 0x1badb002 0x00000000 0xe4524ffe

如果停用页表机制,即注释掉 kern/entry.S 的 movl %eax, %cr0 再重新编译执行,将会在 => 0x10002d: jmp *%eax 处出现错误,因为这里存放的是线性地址。
为了在进程地址空间中保证多个进程能够读写同样的地址数值,并且保证不同的进程的地址空间不会相互影响,硬件 TLB (Translation Lookaside Buffer)提供了从物理地址到线性地址的抽象映射,称为“页表”。在这里, boot loader 将内核装载进物理地址的低位,而在进程空间模型当中则将内核置于高位,页表正好可以解决此冲突,将物理地址在低位的页表映射到线性地址的高位当中去。
在 CPU 当中 %cr0 控制着是否使用页表寻址方式;而 %cr3 则存放页表一级目录基地址。 entry.S 当中第 57~62 行代码就通过修改 %cr3 与 %cr0 寄存器开启了页表机制。

格式化输出到控制台

以下分析尝试解决的问题是:
1.在 lab1 中,与实现显示输出相关的文件有 3 个,它们分别是 kern/printf.c、 lib/printfmt.c、 kern/console.c,了解它们之间的关系以及将 printfmt.c 独立到一个库目录的原因。
详细信息请阅读【笔记03.4 - Lab 1:控制台输出函数】,一言以蔽之, kern/console.c 完成“如何打印”的逻辑,而 lib/printfmt.c 完成“打印什么”的逻辑,它们的链接纽带就是 kern/printf.c。 kern/printf.c 定义了 cprintf、vcprintf、putch 三个函数, cprintf 功能类似于 C 语言的 printf 函数。 cprintf 初始化后调用 vcprintf, vcprintf 调用了 lib/printfmt.c 定义的 vprintfmt 函数进行输出,并将 kern/printf.c 定义的 putch 函数指针传递给 vprintfmt 函数作为第一个参数, putch 函数的功能是通过调用 kern/console.c 定义的 cputchar 函数将一个字符输出在屏幕上。

2.解释 printf.c 和 console.c 的接口,特别是 console.c 暴露了什么函数,这些函数又如何被 printf.c 所使用?
详细信息请阅读【笔记03.4 - Lab 1:控制台输出函数】, kern/printf.c 中的 putch 函数调用了 kern/console.c 定义的 cputchar 函数, cputchar 函数又调用了 serial_putc(c)、 lpt_putc(c)、 cga_putc(c) 三个函数,分别对应于写串口、写并口、写显示器。(写了串口/并口后,再由 qemu 将串口/并口输出信息打印到控制台,所以输出信息既可以在 qemu 中显示,也可以在控制台显示)

3.解释 console.c 中的代码段:

1
2
3
4
5
6
7
1      if (crt_pos >= CRT_SIZE) {
2 int i;
3 memcpy(crt_buf, crt_buf + CRT_COLS, (CRT_SIZE - CRT_COLS) * sizeof(uint16_t));
4 for (i = CRT_SIZE - CRT_COLS; i < CRT_SIZE; i++)
5 crt_buf[i] = 0x0700 | ' ';
6 crt_pos -= CRT_COLS;
7 }

crt_buf 是一个指向 16 位无符号整形数的静态指针,它实际上指向的是内存中物理地址为 0xb8000 的位置,(物理内存的 0xa0000 到 0xc0000 这 128KB 的空间是留给 VGA 显示缓存的,实际上在我们的试验中从 0xb8000 这个位置开始的一部分内存空间便是可以直接与显示屏相关联的显存)。在物理地址超过 0xb8fa0 的内存部分中存储字符数据,此时实际上显示屏就无法显示超过的部分,这个时候通常下显示屏都会滚屏好让最新输出的字符能够显示出来。在每输出一个字符后都先判断 crt_pos 是否大于或等于 CRT_SIZE,而 CRT_SIZE 实际上就是一个屏幕可以输出的字符数,即 80*25。当等于 CRT_SIZE 时,说明此时已经满屏,当大于 CRT_SIZE 时,说明有字符没有显示出来。当满足这两种情况的中的一种时,程序所做的处理是将屏幕上第二行到最后一行的的字符数据复制到第一行到倒数第二行去,然后将屏幕的最后一行输出为空格(并通过 crt_buf[i] = 0x0700 | ‘ ‘; 设置默认字符格式)。

4.追踪以下代码,了解 x86 平台 GCC 的调用约定。当调用 cprintf() 时, fmt 指向什么? ap 指向什么?

1
2
int x = 1, y = 3, z = 4;
cprintf("x %d, y %x, z %d\n", x, y, z);

GCC 的默认函数调用约定是 stdcall。 stdcall 调用约定声明的语法为:

1
int __stdcall function(int a,int b)

stdcall的调用约定意味着:1)参数从右向左压入堆栈,2)函数自身修改堆栈,3)函数名自动加前导的下划线,后面紧跟一个@符号,其后紧跟着参数的尺寸。
以上述这个函数为例,参数 b 首先被压栈,然后是参数 a,函数调用 function(1,2) 调用处。

查看 /obj/kern/kernel.asm 第 61 行,可以看到 /kern/entry.S 最后调用 kern/inic.c 的 i386_init 函数的反汇编结果。

1
2
61:	call	i386_init
62:f0100039: e8 5f 00 00 00 call f010009d <i386_init>

我们可以将上述代码添加到 i386_init 函数入口处(切记需要在调用 cons_init() 之后),删除 obj 目录后重新在 lab 目录下 make,然后启动 qemu 和 gdb。设置断点到 f0100039 处,然后一步一步调试获取结果。在这里 “x %d, y %x, z %d\n” 便是显示字符串,整形变量 x、 y、 z 便是可变参数。在初始化了 va_list 变量 ap 后, ap 便指向了 x 所存放的位置,之后便可以通过 va_arg 宏依次得到变量 x、 y、 z。 %x 代表以无符号 16 进制整数的形式打印出来,如果设置 y = -3,输出 fffffffd,因为 -3 在内存中是以补码的形式存储的,于是在内存中-3 实际上是 fffffffd,所以将它看做是一个无符号数时,打印出来的结果便是 fffffffd。

5.执行以下代码,它的输出是什么? X86 系列 CPU 都是 little-endian 的字节序,如果你设定了 big-endian ,输出是什么?需要修改 57616 吗?

1
2
unsigned int i = 0x00646c72;
cprintf("H%x Wo%s", 57616, &i);

大小端分析请阅读【计算机原理-大端和小端】。
输出 He110 World。因为十进制数 57616 用 16 进制数来表示便是 0xe110。由于无符号整形数 i 是占 4 个字节,而低位数字是存在低地址处。若将这四个字节看做一个字符串,则每个字节代表的就是一个字符的 ASCII 码,所以低位的 0x72 代表的是字符 ‘r’,而最高位的 0x00 代表就是空字符,即标识字符串的结束。于是字符串与 “Wo” 组成了 “World”,所以最终在屏幕上输出了 “He110 World”。如果要想在大端法机器上运行得到相同的结果,i 的值应为 0x726c6400。57616 不需要更改顺序,因为存储时也会采用大端法,所以读出来的十六进制数的顺序不会改变。(注意%x是一次性读出57616并以16进制形式打印,所以即使存储方式不同,读出后仍然一样。而%s是一字节一字节从低地址取出并输出,存储顺序不同则打印顺序不同。)

6.以下代码在 “y=” 之后将打印什么内容(提示:答案不是一个特殊值)?为什么会出现这种现象?

1
cprintf("x=%d y=%d", 3);

显示出来 y = 1604,是个随机的数字,这是因为可变参数只有一个,而可变参数指针指向的是这一个参数存放的位置,当函数试图去在内存中寻找不存在的第二个参数的时候便会在内存中存放第一个参数之后的位置中去取,而这个位置存放的内容我们无法确定,因此打印出来的便是一个随机的数字。

7.假设 GCC 修改了调用约定,按照声明顺序将参数进栈,最后声明的参数就最后进栈。如何修改接口使得仍能给 cprintf 传递数量可变的参数?
需要注意的是, gcc 的默认调用约定有两个事实,一是 C 程序栈的内存生长方式是往低地址内存生长,这也说明为什么局部变量无法申请太大内存,因为栈内容有限;二是函数参数的入栈的顺序是从右往左的。 因此,默认情况下,调用 va_start(ap, fmt) 函数时,ap 指向的地址是在 fmt 地址的基础上使用加法得到的(传入的参数在 fmt 字符串的高地址处),va_arg 则是在 ap 地址的基础上使用加法得到的。 jos 系统中,inc/stdarg.h 定义的 va_start 等宏都是使用了编译器内置函数。当进栈顺序变为按照声明顺序时,应该需要修改 inc/stdarg.h 中的宏定义,将 va_start 和 va_arg 改成用减法获得新地址(va_start 仍是基于 fmt 的地址,只是获取 ap 的地址时是通过做减法)。

8.如何修改 qemu 的控制台颜色?
添加 kern/color.h 定义颜色值,kern/monitor.c 添加 setcolor 指令和 mon_setcolor 函数,mon_setcolor 函数接收指令为:setcolor bg=[背景颜色] ch=[字符颜色],其中字符颜色见新增的 kern/color.h。mon_setcolor 函数去除 “bg=” 和 “ch=” 后调用 kern/console.c 新增的 setcolor(const char bg, const char ch) 函数,判断字符串的值并根据 CGA 的文本模式修改背景颜色和字体颜色,并保存结果,用于设置新输入的字符。

9.补充使用 “%o” 形式打印八进制数字的部分代码。

1
2
3
4
putch('0', putdat);
num = getuint(&ap, lflag);
base = 8;
goto number;

堆栈

以下分析尝试解决的问题是:
1.内核在哪里初始化堆栈,内核如何给自己的栈留出空间?栈指针一开始在内核栈的哪端?
内核在 kern/entry.S 中初始化堆栈。内核初始化堆栈的时候将寄存器 ebp 初始化为 0, esp 初始化为 bootstacktop。

1
2
3
4
5
6
7
8
9
10
.data
###################################################################
# boot stack
###################################################################
.p2align PGSHIFT # force page alignment
.globl bootstack
bootstack:
.space KSTKSIZE
.globl bootstacktop
bootstacktop:

栈的空间定义在 ELF 的 data 字段,载入内核时根据 data 段在 ELF 文件中的相对位置被载入内存。栈有两部分,第一部分是实际栈空间,一共 KSTKSIZE = 8*PGSIZE = 8*4096B = 32KB。 第二部分是栈底指针 bootstacktop, 指向栈空间定义以后的高地址位置。

2.熟悉 Linux 的 c 语言调用约定,在 obj/kern/kernel.asm 中找到 test_backtrace 函数的地址,设置断点,检查在内核启动后该函数每次被调用时的变化。test_backtrace 每次递归嵌套会将什么内容进栈?请按照以下形式输出,其中第一行映射到当前执行函数,第二行映射到调用它的函数,以此类推。

1
2
3
4
Stack backtrace:
ebp f0109e58 eip f0100a62 args 00000001 f0109e80 f0109e98 f0100ed2 00000031
ebp f0109ed8 eip f01000d6 args 00000000 00000000 f0100058 f0109f28 00000061
...

查看 test_backtrace 函数的汇编代码,如下:

可以看出一共有四类栈空间被使用,分别是:77 行 %ebp 入栈;79 行 %ebx 入栈以保护现场;80 行出栈顶指针下移 20 字节,作为临时变量存储,包括 call 其他函数时,传给该函数的参数也放在这部分空间里;92 行 call 时(递归)自动将 eip 入栈。共 4 + 4 + 20 + 4 = 32 byte 空间压栈。可阅读【笔记03.5 - Lab 1:Jos内核】进一步了解。为了输出 kern/monitor.c 代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
uint32_t *ebp, eip;
uint32_t arg0, arg1, arg2, arg3, arg4;
ebp = (uint32_t*)read_ebp();
eip = ebp[1];
arg0 = ebp[2];
arg1 = ebp[3];
arg2 = ebp[4];
arg3 = ebp[5];
arg4 = ebp[6];
cprintf("Stack backtrace:\n");
/*
当发现 ebp 的值为 0 时便停止循环。
因为最外层的程序是 kern/entry.S 中的入口程序,记得在之前我们看到过入口程序中有一句代码是“ movl $0x0,%ebp”,也就是说在入口程序调用 i386_init 函数之前便把 ebp 的值置为 0,也就是说入口程序的 ebp 实际上为 0
*/
while(ebp != 0){
cprintf("ebp %08x eip %08x args %08x %08x %08x %08x %08x\n",
ebp,eip,arg0,arg1,arg2,arg3,arg4);
ebp = (uint32_t*)ebp[0]; // 0x0[%ebp] 可以读出上个函数的栈指针
eip = ebp[1];
arg0 = ebp[2];
arg1 = ebp[3];
arg2 = ebp[4];
arg3 = ebp[5];
arg4 = ebp[6];
}
return 0;
}

3.如何实现“输出函数调用者的栈地址,并输出跟这些地址关联的函数名”?请完成 kern/kdebug.c 中 debuginfo_eip 的实现过程,加入调用 stab_binsearch 的实现。在内核监视部分添加 backtrace 命令,完成 mon_backtrace 的实现过程,加入调用 debuginfo_eip 以实现输出关联的函数名和行号。
这里需要看一下符号表里的结构。用 objdump -G obj/kern/kernel 指令查看 stab,发现在每一种类型(SO/SLINE/…)中都会按照地址的顺序逐渐有行号的递增。仿照 debuginfo_eip 函数里对 SO/FUN (文件名/函数名)的写法,使用 stab_binsearch 这个给定的二分查找方法,找到对应的行数,然后取出行号。

1
2
3
4
5
stab_binsearch(stabs, &lline, &rline, N_SLINE, addr);
if (lline > rline)
info->eip_line = -1;
else
info->eip_line = stabs[lline].n_desc;

修改后的 kern/monitor.c 文件的 mon_backtrace 函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
int
mon_backtrace(int argc, char **argv, struct Trapframe *tf)
{
// Your code here.
uint32_t *ebp, eip;
uint32_t arg0, arg1, arg2, arg3, arg4;
ebp = (uint32_t*)read_ebp();
eip = ebp[1];
arg0 = ebp[2];
arg1 = ebp[3];
arg2 = ebp[4];
arg3 = ebp[5];
arg4 = ebp[6];
cprintf("Stack backtrace:\n");
/*
当发现 ebp 的值为 0 时便停止循环。
因为最外层的程序是 kern/entry.S 中的入口程序,记得在之前我们看到过入口程序中有一句
代码是“ movl $0x0,%ebp”,也就是说在入口程序调用 i386_init 函数之前便把 ebp 的值置
为 0,也就是说入口程序的 ebp 实际上为 0
*/
while(ebp != 0){
cprintf("ebp %08x eip %08x args %08x %08x %08x %08x %08x\n",
ebp,eip,arg0,arg1,arg2,arg3,arg4);
struct Eipdebuginfo info;

if(debuginfo_eip(eip, &info) == 0){
//file name : line
cprintf("\t%s:%d: ", info.eip_file, info.eip_line);
//function name + the offset of the eip from the first instruction of the function
//注意:printf("%.*s", length, string)打印string的至多length个字符
cprintf("%.*s+%d\n", info.eip_fn_namelen, info.eip_fn_name, eip - info.eip_fn_addr);
}

ebp = (uint32_t*)ebp[0];
eip = ebp[1];
arg0 = ebp[2];
arg1 = ebp[3];
arg2 = ebp[4];
arg3 = ebp[5];
arg4 = ebp[6];
}
return 0;
}

1…567
zoro

zoro

如果我后退的话,我曾重视的誓言和约定就会全部消失,然后,再也不会回来这个地方了

65 日志
12 分类
18 标签
© 2020 zoro
由 Hexo 强力驱动
|
主题 — NexT.Gemini