xv6學習筆記(4) : 程式
xv6所有程式都是單程式、單執行緒程式。要明白這個概念才好繼續往下看
1. XV6中程式相關的資料結構
在XV6中,與程式有關的資料結構如下
// 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)
};
與前述的兩類資訊的對應關係如下
- 作業系統管理程式有關的資訊:核心棧
kstack
,程式的狀態state
,程式的pid
,程式的父程式parent
,程式的中斷幀tf
,程式的上下文context
,與sleep
和kill
有關的chan
和killed
變數。 - 程式本身執行所需要的全部環境:虛擬記憶體資訊
sz
和pgdir
,開啟的檔案ofile
和當前目錄cwd
。
額外地,proc
中還有一條用於除錯的程式名字name
。
在作業系統中,所有的程式資訊struct proc
都儲存在ptable
中,ptable
的定義如下
下面是proc
結構體儲存的一些重要資料結構
-
首先是儲存了使用者空間執行緒暫存器的trapframe欄位
-
其次是儲存了核心執行緒暫存器的context欄位
-
還有儲存了當前程式的核心棧的kstack欄位,這是程式在核心中執行時儲存函式呼叫的位置
-
state欄位儲存了當前程式狀態,要麼是RUNNING,要麼是RUNABLE,要麼是SLEEPING等等
-
lock欄位保護了很多資料,目前來說至少保護了對於state欄位的更新。舉個例子,因為有鎖的保護,兩個CPU的排程器執行緒不會同時拉取同一個RUNABLE程式並執行它
struct {
struct spinlock lock;
struct proc proc[NPROC];
} ptable;
除了互斥鎖lock
之外,一個值得注意的一點是XV6系統中允許同時存在的程式數量是有上限的。在這裡NPROC
為64,所以XV6最多隻允許同時存在64個程式。
要注意作業系統的資源分配的單位是程式,處理機排程的單位是執行緒;
2. 第一個使用者程式
1. userinit函式
在 main
初始化了一些裝置和子系統後,它通過呼叫 userinit
建立了第一個程式。
userinit
首先呼叫 allocproc
。allocproc
的工作是在頁表中分配一個槽(即結構體 struct proc
),並初始化程式的狀態,為其核心執行緒的執行做準備。注意一點:userinit
僅僅在建立第一個程式時被呼叫,而 allocproc
建立每個程式時都會被呼叫。allocproc
會在 proc
的表中找到一個標記為 UNUSED
的槽位。當它找到這樣一個未被使用的槽位後,allocproc
將其狀態設定為 EMBRYO
,使其被標記為被使用的並給這個程式一個獨有的 pid
(2201-2219)。接下來,它嘗試為程式的核心執行緒分配核心棧。如果分配失敗了,allocproc
會把這個槽位的狀態恢復為 UNUSED
並返回0以標記失敗。
// Set up first user process.
void
userinit(void)
{
struct proc *p;
extern char _binary_initcode_start[], _binary_initcode_size[];
p = allocproc();
initproc = p;
if((p->pgdir = setupkvm()) == 0)
panic("userinit: out of memory?");
inituvm(p->pgdir, _binary_initcode_start, (int)_binary_initcode_size);
p->sz = PGSIZE;
memset(p->tf, 0, sizeof(*p->tf));
p->tf->cs = (SEG_UCODE << 3) | DPL_USER;
p->tf->ds = (SEG_UDATA << 3) | DPL_USER;
p->tf->es = p->tf->ds;
p->tf->ss = p->tf->ds;
p->tf->eflags = FL_IF;
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);
}
2. allocproc函式
- 在ptable中找到一個沒有被佔用的槽位
- 找到之後分配pid然後把他的狀態設定為
EMBRYO
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;
}
這裡進行呼叫完之後得到的狀態如下圖所示
3. mpmain函式
// Common CPU setup code.
static void
mpmain(void)
{
cprintf("cpu%d: starting %d\n", cpuid(), cpuid());
idtinit(); // load idt register
xchg(&(mycpu()->started), 1); // tell startothers() we're up
scheduler(); // start running processes
}
1. scheduler()函式
這個函式是非常重要的,進行程式之間的排程,在上面我們建立了第一個使用者程式但是還沒有進行執行。
void
scheduler(void)
{
struct proc *p;
struct cpu *c = mycpu();
c->proc = 0;
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.
c->proc = p;
switchuvm(p);
p->state = RUNNING;
swtch(&(c->scheduler), p->context);
switchkvm();
// Process is done running for now.
// It should have changed its p->state before coming back.
c->proc = 0;
}
release(&ptable.lock);
}
}
2. switchuvm函式
- 這裡要設定當前cpu的taskstate。關於taskstate的知識補充
taskstate的知識補充
- 對於cpu而言是沒有程式或者執行緒的概念,對於cpu只有任務的概念
- 對於ss0是儲存的0環的棧段選擇子
- 對於
esp
是儲存的0環的棧指標 - 而對於ring的概念也就是環的概念這裡可以簡單理解成特權集參考部落格
// Switch TSS and h/w page table to correspond to process p.
void
switchuvm(struct proc *p)
{
if(p == 0)
panic("switchuvm: no process");
if(p->kstack == 0)
panic("switchuvm: no kstack");
if(p->pgdir == 0)
panic("switchuvm: no pgdir");
pushcli();
mycpu()->gdt[SEG_TSS] = SEG16(STS_T32A, &mycpu()->ts,
sizeof(mycpu()->ts)-1, 0);
mycpu()->gdt[SEG_TSS].s = 0;
mycpu()->ts.ss0 = SEG_KDATA << 3;
mycpu()->ts.esp0 = (uint)p->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
mycpu()->ts.iomb = (ushort) 0xFFFF;
ltr(SEG_TSS << 3);
lcr3(V2P(p->pgdir)); // switch to process's address space
popcli();
}
3. 第一個程式Initcode.S
第一個程式會在虛擬地址[0-pagesize]這一段
# exec(init, argv)
.globl start
start:
pushl $argv
pushl $init
pushl $0 // where caller pc would be
movl $SYS_exec, %eax
int $T_SYSCALL
# 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
這裡是呼叫了exec執行init
函式
這個其實更像什麼,更像shell終端的啟動
int
main(void)
{
int pid, wpid;
if(open("console", O_RDWR) < 0){
mknod("console", 1, 1);
open("console", O_RDWR);
}
dup(0); // stdout
dup(0); // stderr
for(;;){
printf(1, "init: starting sh\n");
pid = fork();
if(pid < 0){
printf(1, "init: fork failed\n");
exit();
}
if(pid == 0){
exec("sh", argv);
printf(1, "init: exec sh failed\n");
exit();
}
while((wpid=wait()) >= 0 && wpid != pid)
printf(1, "zombie!\n");
}
}
4. 程式切換
程式切換解決之後,對於xv6的程式排程就會有一個比較清晰的分析了
這裡有幾個重要的概念就是
-
每一個程式都有一個對應的核心執行緒(也就是scheduler thread)執行緒。
-
在xv6中想要從一個程式(當然這裡叫執行緒也是無所謂的)切換到另一個執行緒中,必須要先從當前程式-->當前程式的核心執行緒-->目的執行緒的核心執行緒-->目的執行緒的使用者程式。這樣一個過程才能完成排程
1. 先從yied和sched開始
其實yield
函式並沒有幹很多事情,關於?的操作後面會單獨來講一下,這裡就先跳過去
這個函式就是當前程式要讓出cpu。所以把當前proc()的狀態設定成RUNNABLE
最後呼叫sched()
// Give up the CPU for one scheduling round.
void
yield(void)
{
acquire(&ptable.lock); //DOC: yieldlock
myproc()->state = RUNNABLE;
sched();
release(&ptable.lock);
}
這裡先進行一些狀態判斷,如果出問題就會panic。
2. 隨後呼叫swtch函式
其實這個函式就是switch
這裡為了不與c語言中的庫函式同名
void
sched(void)
{
int intena;
struct proc *p = myproc();
if(!holding(&ptable.lock))
panic("sched ptable.lock");
if(mycpu()->ncli != 1)
panic("sched locks");
if(p->state == RUNNING)
panic("sched running");
if(readeflags()&FL_IF)
panic("sched interruptible");
intena = mycpu()->intena;
swtch(&p->context, mycpu()->scheduler);
mycpu()->intena = intena;
}
swtch
函式就是傳說中的上下文切換。只不過和之前說的使用者狀態的上下文切換不一樣
這裡是把當前cpu的核心執行緒的暫存器儲存到p->context
中
這裡的(esp + 4)
儲存的就是edi
暫存器的值。而(esp + 8)
儲存的就是esi
暫存器的值,也就是第一個引數和第二個引數
.globl swtch
swtch:
movl 4(%esp), %eax
movl 8(%esp), %edx
# Save old callee-saved registers
pushl %ebp
pushl %ebx
pushl %esi
pushl %edi
# Switch stacks
movl %esp, (%eax)
movl %edx, %esp
# Load new callee-saved registers
popl %edi
popl %esi
popl %ebx
popl %ebp
ret
所以這裡最後就會把mycpu()->scheduler
中儲存的context資訊彈出到暫存器中。同時把esp暫存器更換成mycpu()->scheduler
那裡。所以這裡的ret的返回地址就是mycpu()->scheduler
儲存的eip的值。也就會返回到
紅色箭頭所指向的一行。
3. 回到scheduler
函式
現在我們在scheduler函式的迴圈中,程式碼會檢查所有的程式並找到一個來執行。隨後再來呼叫swtch函式
又呼叫了swtch函式來儲存排程器執行緒的暫存器,並恢復目標程式的暫存器(注,實際上恢復的是目標程式的核心執行緒)
這裡有件事情需要注意,排程器執行緒呼叫了swtch函式,但是我們從swtch函式返回時,實際上是返回到了對於switch的另一個呼叫,而不是排程器執行緒中的呼叫。我們返回到的是pid為目的程式的程式在很久之前對於switch的呼叫。這裡可能會有點讓人困惑,但是這就是執行緒切換的核心。
4. 回到使用者空間
最後的返回是利用了trapret
# Return falls through to trapret...
.globl trapret
trapret:
popal
popl %gs
popl %fs
popl %es
popl %ds
addl $0x8, %esp # trapno and errcode
iret
這個函式把儲存的trapframe恢復。最後通過iret恢復到使用者空間
5. 看一下fork、wait、exit函式
1. fork函式
- 建立一個程式
- 把父程式的頁表copy過來(這裡還不是cow方式的)
- 這裡比較重要的點是先加鎖。然後把子程式的狀態設定成runnable。如果在解鎖之前子程式就被排程的話。那返回值就是利用tf->eax來獲取
- 否則的話解鎖return父程式的pid,表示從父程式返回
// Create a new process copying p as the parent.
// Sets up stack to return as if from system call.
// Caller must set state of returned proc to RUNNABLE.
int
fork(void)
{
int i, pid;
struct proc *np;
struct proc *curproc = myproc();
// Allocate process.
if((np = allocproc()) == 0){
return -1;
}
// Copy process state from proc.
if((np->pgdir = copyuvm(curproc->pgdir, curproc->sz)) == 0){
kfree(np->kstack);
np->kstack = 0;
np->state = UNUSED;
return -1;
}
np->sz = curproc->sz;
np->parent = curproc;
*np->tf = *curproc->tf;
// Clear %eax so that fork returns 0 in the child.
np->tf->eax = 0;
for(i = 0; i < NOFILE; i++)
if(curproc->ofile[i])
np->ofile[i] = filedup(curproc->ofile[i]);
np->cwd = idup(curproc->cwd);
safestrcpy(np->name, curproc->name, sizeof(curproc->name));
pid = np->pid;
acquire(&ptable.lock);
np->state = RUNNABLE;
release(&ptable.lock);
return pid;
}
2. wait函式
- 如果找到了處於
ZOMBIE
狀態子程式會把他釋放掉。(分別釋放對於的pid、核心棧、頁表) - 否則如果沒有子程式則return -1
- 否則呼叫slepp函式等待
// Wait for a child process to exit and return its pid.
// Return -1 if this process has no children.
int
wait(void)
{
struct proc *p;
int havekids, pid;
struct proc *curproc = myproc();
acquire(&ptable.lock);
for(;;){
// Scan through table looking for exited children.
havekids = 0;
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
if(p->parent != curproc)
continue;
havekids = 1;
if(p->state == ZOMBIE){
// Found one.
pid = p->pid;
kfree(p->kstack);
p->kstack = 0;
freevm(p->pgdir);
p->pid = 0;
p->parent = 0;
p->name[0] = 0;
p->killed = 0;
p->state = UNUSED;
release(&ptable.lock);
return pid;
}
}
// No point waiting if we don't have any children.
if(!havekids || curproc->killed){
release(&ptable.lock);
return -1;
}
// Wait for children to exit. (See wakeup1 call in proc_exit.)
sleep(curproc, &ptable.lock); //DOC: wait-sleep
}
}
sleep函式會在後面講鎖的時候去看
3. exit函式
- 首先exit函式關閉了所有已開啟的檔案。這裡可能會很複雜,因為關閉檔案系統中的檔案涉及到引用計數,雖然我們還沒學到但是這裡需要大量的工作。不管怎樣,一個程式呼叫exit系統呼叫時,會關閉所有自己擁有的檔案。
- 程式有一個對於當前目錄的記錄,這個記錄會隨著你執行cd指令而改變。在exit過程中也需要將對這個目錄的引用釋放給檔案系統。
- 如果這個想要退出的程式,它又有自己的子程式,接下來需要設定這些子程式的父程式為init程式。我們接下來會看到,每一個正在exit的程式,都有一個父程式中的對應的wait系統呼叫。父程式中的wait系統呼叫會完成程式退出最後的幾個步驟。所以如果父程式退出了,那麼子程式就不再有父程式,當它們要退出時就沒有對應的父程式的wait。所以在exit函式中,會為即將exit程式的子程式重新指定父程式為init程式,也就是PID為1的程式。
- 最後把要exit的程式狀態設定成
ZOMBIE
- 執行
sched
函式重新回到核心執行緒。。。找新的執行緒去執行
void
exit(void)
{
struct proc *curproc = myproc();
struct proc *p;
int fd;
if(curproc == initproc)
panic("init exiting");
// Close all open files.
for(fd = 0; fd < NOFILE; fd++){
if(curproc->ofile[fd]){
fileclose(curproc->ofile[fd]);
curproc->ofile[fd] = 0;
}
}
begin_op();
iput(curproc->cwd);
end_op();
curproc->cwd = 0;
acquire(&ptable.lock);
// Parent might be sleeping in wait().
wakeup1(curproc->parent);
// Pass abandoned children to init.
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
if(p->parent == curproc){
p->parent = initproc;
if(p->state == ZOMBIE)
wakeup1(initproc);
}
}
// Jump into the scheduler, never to return.
curproc->state = ZOMBIE;
sched();
panic("zombie exit");
}
4. kill函式
最後我想看的是kill系統呼叫。Unix中的一個程式可以將另一個程式的ID傳遞給kill系統呼叫,並讓另一個程式停止執行。如果我們不夠小心的話,kill一個還在核心執行程式碼的程式,會有風險,比如我們想要殺掉的程式的核心執行緒還在更新一些資料,比如說更新檔案系統,建立一個檔案。如果這樣的話,我們不能就這樣殺掉程式,因為這樣會使得一些需要多步完成的操作只執行了一部分。所以kill系統呼叫不能就直接停止目標程式的執行。實際上,在XV6和其他的Unix系統中,kill系統呼叫基本上不做任何事情。
// Kill the process with the given pid.
// Process won't exit until it returns
// to user space (see trap in trap.c).
int
kill(int pid)
{
struct proc *p;
acquire(&ptable.lock);
for(p = ptable.proc; p < &ptable.proc[NPROC]; p++){
if(p->pid == pid){
p->killed = 1;
// Wake process from sleep if necessary.
if(p->state == SLEEPING)
p->state = RUNNABLE;
release(&ptable.lock);
return 0;
}
}
release(&ptable.lock);
return -1;
}