00_前言
接下来需要理解的源码文件是kernel/exec.c
。在xv6中,exec
是一个系统调用,它用于加载并运行一个新的程序。在调用exec
之后,当前进程的内存空间将被新的程序代码和数据覆盖,旧的程序将不再执行。所以exec
会用一个新程序替换当前进程的内容,但是进程ID保持不变
1.exec.c文件的主要逻辑
在kernel/exec.c
中,实现的主要逻辑是:
- 如何加载可执行文件
- 如何设置新的内存布局
- 如何配置栈和寄存器
2.exec系统调用的作用
注意:exec
系统调用是创建一个地址空间的用户部分,一个用户进程的地址空间除了用户部分之外,还需要有内核栈等位于内核的部分。所以exec
不负责初始化地址空间的内核部分
3.用ELF文件初始化地址空间的用户部分
exec
系统调用用存储在文件系统里的ELF格式的文件来初始化地址空间的用户部分
一般来说ELF
文件会有多个program header
,它们每一个指向必须读入内存的一个程序段,xv6
中为了简单起见,只有一个program header
,ELF格式的定义全部在kernel/elf.h
中
01_loadseg
函数
1.函数的作用
seg
是程序段的意思,这个函数 loadseg
的主要作用是在操作系统加载一个可执行文件时,将该文件的一个段(如代码段、数据段等)从存储设备(如磁盘)读取到内存中,并将其放置在指定的虚拟地址空间内。
2.代码
// Load a program segment into pagetable at virtual address va.
// va must be page-aligned
// and the pages from va to va+sz must already be mapped.
// Returns 0 on success, -1 on failure.
// 译:将一个程序段读入页表pagetable的虚拟地址va处
// va必须是页对齐的,va到va + sz范围内的页必须已经被映射好
// 返回0表示成功,-1表示失败
static int
loadseg(pagetable_t pagetable, uint64 va, struct inode *ip, uint offset, uint sz)
{
uint i, n;
uint64 pa;
// 迭代地复制程序段,每次复制一个页面
for(i = 0; i < sz; i += PGSIZE){
// 使用walkaddr找到对应的物理地址
pa = walkaddr(pagetable, va + i);
// pa返回0表示walkaddr调用失败,对应的虚拟地址可能:
// 1.PTE不存在
// 2.没有建立映射关系
// 3.用户无权访问
if(pa == 0)
panic("loadseg: address should exist");
// 这里是防止最后一页要复制的字节不满一页而导致的复制溢出
if(sz - i < PGSIZE)
n = sz - i;
else
n = PGSIZE;
// readi系统调用是从索引节点对应的文件数据缓冲区中读取数据的函数
// 下面的调用从索引节点ip指向的文件的off偏移处读取n个字节放入物理地址pa
// 第二个参数为0表示处于内核地址空间
// 再次地,我们用到了内核地址空间的直接映射(direct-mapping)
// 就算pa是某个用户页表翻译出来的物理地址,在内核地址空间中也会被译为同等的地址
if(readi(ip, 0, (uint64)pa, offset+i, n) != n)
return -1;
}
return 0;
}
3.代码逻辑
假设我们一个简单的可执行文件,其代码段大小为 8192
字节,并且该段存储在硬盘的某个位置。现在我们需要将这个代码段加载到某个进程的内存空间中。这个进程的虚拟地址空间已经分配好了虚拟内存页面,并且这些页面已经映射到实际的物理内存中。
- 确定加载位置:
- 假设虚拟地址
va = 0x4000
(即虚拟地址 16KB 处)是页对齐的,并且已经映射到物理内存。 - 文件在磁盘的偏移量
offset = 0
处。 - 段的大小
sz = 8192
字节。
- 假设虚拟地址
- 迭代加载:
- 程序按页面大小(通常是 4096 字节,也即
PGSIZE = 4096
)分块加载这个段。因此,需要两次迭代,每次加载一个页面。 - 第一次迭代:
i = 0
,va + i = 0x4000
。- 使用
walkaddr
查找0x4000
处的物理地址pa
,假设返回物理地址为0x10000
。 - 检查
sz - i = 8192 - 0 = 8192
,大于PGSIZE
,所以n = PGSIZE = 4096
。 - 从文件的
offset + i = 0
处读取 4096 字节到物理地址pa = 0x10000
处。 - 第二次迭代:
i = 4096
,va + i = 0x5000
。- 使用
walkaddr
查找0x5000
处的物理地址pa
,假设返回物理地址为0x11000
。 - 检查
sz - i = 8192 - 4096 = 4096
,刚好等于PGSIZE
,所以n = PGSIZE = 4096
。 - 从文件的
offset + i = 4096
处读取 4096 字节到物理地址pa = 0x11000
处。
- 程序按页面大小(通常是 4096 字节,也即
- 加载完成:
- 两次迭代后,文件的这个代码段已经被加载到了内存中的两个连续的物理页面中,分别位于
0x4000
和0x5000
虚拟地址对应的物理内存位置。
- 两次迭代后,文件的这个代码段已经被加载到了内存中的两个连续的物理页面中,分别位于
02_exec
函数
1.函数的作用
exec
函数的作用是在操作系统中替换当前进程的地址空间,使其执行一个新的程序。它会加载一个指定的可执行文件到当前进程的内存中,设置好新的程序入口点和栈,然后开始执行这个程序。
2.代码
int
exec(char *path, char **argv)
{
char *s, *last;
int i, off;
uint64 argc, sz = 0, sp, ustack[MAXARG+1], stackbase;
struct elfhdr elf;
struct inode *ip;
struct proghdr ph;
pagetable_t pagetable = 0, oldpagetable;
struct proc *p = myproc();
begin_op();
if((ip = namei(path)) == 0){
end_op();
return -1;
}
ilock(ip);
// Check ELF header
if(readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf))
goto bad;
if(elf.magic != ELF_MAGIC)
goto bad;
if((pagetable = proc_pagetable(p)) == 0)
goto bad;
// Load program into memory.
for(i=0, off=elf.phoff; i<elf.phnum; i++, off+=sizeof(ph)){
if(readi(ip, 0, (uint64)&ph, off, sizeof(ph)) != sizeof(ph))
goto bad;
if(ph.type != ELF_PROG_LOAD)
continue;
if(ph.memsz < ph.filesz)
goto bad;
if(ph.vaddr + ph.memsz < ph.vaddr)
goto bad;
uint64 sz1;
if((sz1 = uvmalloc(pagetable, sz, ph.vaddr + ph.memsz)) == 0)
goto bad;
sz = sz1;
if(ph.vaddr % PGSIZE != 0)
goto bad;
if(loadseg(pagetable, ph.vaddr, ip, ph.off, ph.filesz) < 0)
goto bad;
}
iunlockput(ip);
end_op();
ip = 0;
p = myproc();
uint64 oldsz = p->sz;
// Allocate two pages at the next page boundary.
// Use the second as the user stack.
sz = PGROUNDUP(sz);
uint64 sz1;
if((sz1 = uvmalloc(pagetable, sz, sz + 2*PGSIZE)) == 0)
goto bad;
sz = sz1;
uvmclear(pagetable, sz-2*PGSIZE);
sp = sz;
stackbase = sp - PGSIZE;
// Push argument strings, prepare rest of stack in ustack.
for(argc = 0; argv[argc]; argc++) {
if(argc >= MAXARG)
goto bad;
sp -= strlen(argv[argc]) + 1;
sp -= sp % 16; // riscv sp must be 16-byte aligned
if(sp < stackbase)
goto bad;
if(copyout(pagetable, sp, argv[argc], strlen(argv[argc]) + 1) < 0)
goto bad;
ustack[argc] = sp;
}
ustack[argc] = 0;
// push the array of argv[] pointers.
sp -= (argc+1) * sizeof(uint64);
sp -= sp % 16;
if(sp < stackbase)
goto bad;
if(copyout(pagetable, sp, (char *)ustack, (argc+1)*sizeof(uint64)) < 0)
goto bad;
// arguments to user main(argc, argv)
// argc is returned via the system call return
// value, which goes in a0.
p->trapframe->a1 = sp;
// Save program name for debugging.
for(last=s=path; *s; s++)
if(*s == '/')
last = s+1;
safestrcpy(p->name, last, sizeof(p->name));
// Commit to the user image.
oldpagetable = p->pagetable;
p->pagetable = pagetable;
p->sz = sz;
p->trapframe->epc = elf.entry; // initial program counter = main
p->trapframe->sp = sp; // initial stack pointer
proc_freepagetable(oldpagetable, oldsz);
return argc; // this ends up in a0, the first argument to main(argc, argv)
bad:
if(pagetable)
proc_freepagetable(pagetable, sz);
if(ip){
iunlockput(ip);
end_op();
}
return -1;
}
3.代码逻辑
例如,当前在xv6中有一个shell进程,该进程正在运行并接受用户输入。现在用户在shell中输入了命令ls,以列出当前目录中的文件。shell进程通过调用exec函数来执行ls程序。
-
进程初始化:
- 当前shell进程通过输入的ls命令决定要执行ls程序
- shell进程通过exec("ls",argv)语句来调用exec函数
-
查找可执行文件:
exec
函数通过namei
系统调用找到ls
程序的文件路径并返回对应的索引节点(inode)。这个索引节点包含了ls
程序的元数据和内容。
// namei同样是一个文件系统操作,它返回对应路径文件的索引节点(index node)的内存拷贝 // 索引节点中记录着文件的一些元数据(meta data) // 如果出错就使用end_op结束当前调用的日志功能 if((ip = namei(path)) == 0){ end_op(); return -1; } // 给索引节点加锁,防止访问冲突 ilock(ip);
-
验证 ELF 文件:
exec
函数读取ls
文件的 ELF 头部信息,并检查它是否是一个合法的 ELF 可执行文件
if(readi(ip, 0, (uint64)&elf, 0, sizeof(elf)) != sizeof(elf)) goto bad; if(elf.magic != ELF_MAGIC) goto bad;
-
创建新地址空间:
exec
函数调用proc_pagetable
创建一个新的页表,用来为ls
程序的执行创建虚拟地址空间。
// 创建一个用户页表,将trampoline页面和trapframe页面映射进去 // 保持用户内存部分留白 if((pagetable = proc_pagetable(p)) == 0) goto bad;
-
加载程序段:
-
exec
函数遍历 ELF 文件中的 Program Header(程序头),并逐个将ls
程序的代码段和数据段加载到新创建的虚拟地址空间中。 -
比如,
ls
程序的代码段被加载到进程的虚拟地址0x400000
,数据段被加载到0x600000
。
-
-
设置用户栈:
exec
函数分配了新的栈空间,并将argv
参数数组的内容拷贝到栈中,更新栈指针sp
。这是为了让ls
程序在启动时能够接收到正确的命令行参数。
-
更新进程信息:
exec
函数用新加载的ls
程序的入口地址elf.entry
更新进程的epc
(程序计数器)。- 更新进程的栈指针
sp
以指向新的用户栈。 - 同时,进程的名称被更新为
"ls"
,用于调试和管理。
-
切换到新程序:
-
旧的页表和内存空间被释放。
-
exec
函数返回argc
,表示参数个数,这个值会被传递给ls
程序的main(argc, argv)
函数,进程开始执行ls
程序的代码。
-
-
最终效果:
- 当
exec
函数执行完毕后,原本是 shell 的进程现在被替换成了ls
程序的进程。它的内存空间、代码段、数据段、栈等都被ls
程序的内容覆盖。当进程继续执行时,实际执行的是ls
程序中的代码,而不是原来的 shell 代码。
- 当
Comments | NOTHING