操作系统真象还原实验记录之实验三十二:加载用户进程
1.实现exec
exec.c之segment_load
#include "exec.h"
#include "thread.h"
#include "stdio-kernel.h"
#include "fs.h"
#include "string.h"
#include "global.h"
#include "memory.h"
extern void intr_exit(void);
typedef uint32_t Elf32_Word, Elf32_Addr, Elf32_Off;
typedef uint16_t Elf32_Half;
/* 32位elf头 */
struct Elf32_Ehdr {
unsigned char e_ident[16];
Elf32_Half e_type;
Elf32_Half e_machine;
Elf32_Word e_version;
Elf32_Addr e_entry;
Elf32_Off e_phoff;
Elf32_Off e_shoff;
Elf32_Word e_flags;
Elf32_Half e_ehsize;
Elf32_Half e_phentsize;
Elf32_Half e_phnum;
Elf32_Half e_shentsize;
Elf32_Half e_shnum;
Elf32_Half e_shstrndx;
};
/* 程序头表Program header.就是段描述头 */
struct Elf32_Phdr {
Elf32_Word p_type; // 见下面的enum segment_type
Elf32_Off p_offset;
Elf32_Addr p_vaddr;
Elf32_Addr p_paddr;
Elf32_Word p_filesz;
Elf32_Word p_memsz;
Elf32_Word p_flags;
Elf32_Word p_align;
};
/* 段类型 */
enum segment_type {
PT_NULL, // 忽略
PT_LOAD, // 可加载程序段
PT_DYNAMIC, // 动态加载信息
PT_INTERP, // 动态加载器名称
PT_NOTE, // 一些辅助信息
PT_SHLIB, // 保留
PT_PHDR // 程序头表
};
/* 将文件描述符fd指向的文件中,偏移为offset,大小为filesz的段加载到虚拟地址为vaddr的内存 */
static bool segment_load(int32_t fd, uint32_t offset, uint32_t filesz, uint32_t vaddr) {
uint32_t vaddr_first_page = vaddr & 0xfffff000; // vaddr地址所在的页框
uint32_t size_in_first_page = PG_SIZE - (vaddr & 0x00000fff); // 加载到内存后,文件在第一个页框中占用的字节大小
uint32_t occupy_pages = 0;
/* 若一个页框容不下该段 */
if (filesz > size_in_first_page) {
uint32_t left_size = filesz - size_in_first_page;
occupy_pages = DIV_ROUND_UP(left_size, PG_SIZE) + 1; // 1是指vaddr_first_page
} else {
occupy_pages = 1;
}
/* 为进程分配内存 */
uint32_t page_idx = 0;
uint32_t vaddr_page = vaddr_first_page;
while (page_idx < occupy_pages) {
uint32_t* pde = pde_ptr(vaddr_page);
uint32_t* pte = pte_ptr(vaddr_page);
/* 如果pde不存在,或者pte不存在就分配内存.
* pde的判断要在pte之前,否则pde若不存在会导致
* 判断pte时缺页异常 */
if (!(*pde & 0x00000001) || !(*pte & 0x00000001)) {
if (get_a_page(PF_USER, vaddr_page) == NULL) {
return false;
}
} // 如果原进程的页表已经分配了,利用现有的物理页,直接覆盖进程体
vaddr_page += PG_SIZE;
page_idx++;
}
sys_lseek(fd, offset, SEEK_SET);
sys_read(fd, (void*)vaddr, filesz);
return true;
}
将旧进程的fd指向的文件中,偏移为offset,大小为filesz的段加载到虚拟地址vaddr,
vaddr可能位于页框中的某个位置,而不是页首地址,所以计算出vaddr所在第一页框剩余部分size_in_first_page,
如果filesz小于size_in_first_page,那么此段可以装在vaddr的第一个页框内,此段即新进程所需一个页框;
如果filesz大于size_in_first_page,则计算出新进程需要的总页框。
接下来利用旧进程的页表来看看vaddr处开始需要的页框是否已被分配,若未被分配,则申请;已经分配就不管。
最后,将整个段读入vaddr。
注意:vaddr所在页虽然只覆盖了部分,但整页依然属于此段,之前的数据都无效了。
这个函数相当于使旧进程vaddr处的数据全部被某个新段给覆盖。
exec.c之load函数
/* 从文件系统上加载用户程序pathname,成功则返回程序的起始地址,否则返回-1 */
static int32_t load(const char* pathname) {
int32_t ret = -1;
struct Elf32_Ehdr elf_header;
struct Elf32_Phdr prog_header;
memset(&elf_header, 0, sizeof(struct Elf32_Ehdr));
int32_t fd = sys_open(pathname, O_RDONLY);
if (fd == -1) {
return -1;
}
if (sys_read(fd, &elf_header, sizeof(struct Elf32_Ehdr)) != sizeof(struct Elf32_Ehdr)) {
ret = -1;
goto done;
}
/* 校验elf头 */
if (memcmp(elf_header.e_ident, "\177ELF\1\1\1", 7) \
|| elf_header.e_type != 2 \
|| elf_header.e_machine != 3 \
|| elf_header.e_version != 1 \
|| elf_header.e_phnum > 1024 \
|| elf_header.e_phentsize != sizeof(struct Elf32_Phdr)) {
ret = -1;
goto done;
}
Elf32_Off prog_header_offset = elf_header.e_phoff;
Elf32_Half prog_header_size = elf_header.e_phentsize;
/* 遍历所有程序头 */
uint32_t prog_idx = 0;
while (prog_idx < elf_header.e_phnum) {
memset(&prog_header, 0, prog_header_size);
/* 将文件的指针定位到程序头 */
sys_lseek(fd, prog_header_offset, SEEK_SET);
/* 只获取程序头 */
if (sys_read(fd, &prog_header, prog_header_size) != prog_header_size) {
ret = -1;
goto done;
}
/* 如果是可加载段就调用segment_load加载到内存 */
if (PT_LOAD == prog_header.p_type) {
if (!segment_load(fd, prog_header.p_offset, prog_header.p_filesz, prog_header.p_vaddr)) {
ret = -1;
goto done;
}
}
/* 更新下一个程序头的偏移 */
prog_header_offset += elf_header.e_phentsize;
prog_idx++;
}
ret = elf_header.e_entry;
done:
sys_close(fd);
return ret;
}
接受一个绝对路径pathname,打开此文件,即将此文件装载到旧进程的文件描述符表中。然后将此文件的所有可加载段都加载到内存。
首先校验elf头,然后elf中的e_phoff是程序头表首地址、e_phentsize是程序头表表项大小、e_phnum是程序头表表项个数,以此遍历读取所有段,如果p_type是可加载,则调用segment_load此段加载到内存虚拟地址p_vaddr。
exec.c之sys_execv
/* 用path指向的程序替换当前进程 */
int32_t sys_execv(const char* path, const char* argv[]) {
uint32_t argc = 0;
while (argv[argc]) {
argc++;
}
int32_t entry_point = load(path);
if (entry_point == -1) { // 若加载失败则返回-1
return -1;
}
struct task_struct* cur = running_thread();
/* 修改进程名 */
memcpy(cur->name, path, TASK_NAME_LEN);
/* 修改栈中参数 */
struct intr_stack* intr_0_stack = (struct intr_stack*)((uint32_t)cur + PG_SIZE - sizeof(struct intr_stack));
/* 参数传递给用户进程 */
intr_0_stack->ebx = (int32_t)argv;
intr_0_stack->ecx = argc;
intr_0_stack->eip = (void*)entry_point;
/* 使新用户进程的栈地址为最高用户空间地址 */
intr_0_stack->esp = (void*)0xc0000000;
/* exec不同于fork,为使新进程更快被执行,直接从中断返回 */
asm volatile ("movl %0, %%esp; jmp intr_exit" : : "g" (intr_0_stack) : "memory");
return 0;
}
接受绝对路径path和数组argv,
先调用load将path的程序加载入内存,并返回该程序的入口地址。
然后获取旧进程pcb修改旧进程名,再获取中断栈,往ebx的位置放入argv,ecx放入argc,往eip放入入口地址,esp为3GB即最高用户空间地址,最后内联汇编进入intr_exit中断返回,跳转到程序的入口地址,类似jmp,使cpu头也不回的执行新进程了。
添加execv系统调用,内核实现为sys_execv
syscall.c
syscall.h
syscall_init.c
2.让shell支持外部命令exec
shell.c之my_shell修改
/* 简单的shell */
void my_shell(void) {
cwd_cache[0] = '/';
while (1) {
print_prompt();
printf("what the fuck?");
memset(final_path, 0, MAX_PATH_LEN);
memset(cmd_line, 0, cmd_len);
readline(cmd_line, cmd_len);
if (cmd_line[0] == 0) { // 若只键入了一个回车
continue;
}
argc = -1;
argc = cmd_parse(cmd_line, argv, ' ');
if (argc == -1) {
printf("num of arguments exceed %d\n", MAX_ARG_NR);
continue;
}
if (!strcmp("ls", argv[0])) {
buildin_ls(argc, argv);
} else if (!strcmp("cd", argv[0])) {
if (buildin_cd(argc, argv) != NULL) {
memset(cwd_cache, 0, MAX_PATH_LEN);
strcpy(cwd_cache, final_path);
}
} else if (!strcmp("pwd", argv[0])) {
buildin_pwd(argc, argv);
} else if (!strcmp("ps", argv[0])) {
buildin_ps(argc, argv);
} else if (!strcmp("clear", argv[0])) {
buildin_clear(argc, argv);
} else if (!strcmp("mkdir", argv[0])){
buildin_mkdir(argc, argv);
} else if (!strcmp("rmdir", argv[0])){
buildin_rmdir(argc, argv);
} else if (!strcmp("rm", argv[0])) {
buildin_rm(argc, argv);
}else { // 如果是外部命令,需要从磁盘上加载
int32_t pid = fork();
if (pid) { // 父进程
while(1);
} else { // 子进程
make_clear_abs_path(argv[0], final_path);
argv[0] = final_path;
/* 先判断下文件是否存在 */
struct stat file_stat;
memset(&file_stat, 0, sizeof(struct stat));
if (stat(argv[0], &file_stat) == -1) {
printf("my_shell: cannot access %s: No such file or directory\n", argv[0]);
} else {
execv(argv[0], argv);
}
while(1);
}
}
}
panic("my_shell: should not be here");
}
my_shell:当检测到为外部命令后,argv[0]就是可执行文件
调用make_clear_abs_path将argv[0]的绝对路径保存于final_path中,再将argv[0]指向final_path,先用stat系统调用检查argv[0]是否存在,再用execv系统调用将argv[0]文件加载于内存,修改进程并执行。
从而shell.c支持输入外部命令exec了。
比如输入./文件名,若文件系统存在此文件,那么就获得此文件绝对路径,调用系统调用exec加载此文件覆盖当前进程。
3. 加载硬盘上的用户程序执行
现在我们需要写一个不接受参数的用户程序于Seven80.img的文件系统中,然后在shell输入外部命令./文件名来执行此用户程序。
准备的无参数用户程序prog_no_arg.c
#include "stdio.h"
int main(void){
printf("prog_no_arg from disk\n");
while(1);
return 0;
}
有了用户程序,
1.我们需要把此用户程序先编译链接成二进制文件再dd写入Seven.img
2.然后再从Seven.img中读到内存堆里,利用Seven80.img的文件系统函数sys_open为其创建一个文件返回文件描述符,再用sys_write从内存写入此文件。
这样就把该用户程序写入了带有文件系统的Seven80.img。
注意:将文件写入文件系统,必须按照文件系统的规则写入磁盘,因为这涉及到文件系统元信息的同步,所以要利用文件系统函数,这些函数会帮助你同步元信息。
compile.sh脚本文件
#### 此脚本应该在command目录下执行
if [[ ! -d "../lib" || ! -d "../build" ]];then
echo "dependent dir don\`t exist!"
cwd=$(pwd)
cwd=${cwd##*/}
cwd=${cwd%/}
if [[ $cwd != "command" ]];then
echo -e "you\`d better in command dir\n"
fi
exit
fi
BIN="prog_no_arg"
CFLAGS="-Wall -c -fno-builtin -W -Wstrict-prototypes \
-Wmissing-prototypes -Wsystem-headers"
### LIB= "../lib"
OBJS="../build/string.o ../build/syscall.o \
../build/stdio.o ../build/assert.o"
DD_IN=$BIN
DD_OUT="/home/Seven/bochs2.68/bin/Seven.img"
gcc -m32 $CFLAGS -I "../lib" -o $BIN".o" $BIN".c"
ld -m elf_i386 -e main $BIN".o" $OBJS -o $BIN
SEC_CNT=$(ls -l $BIN|awk '{printf("%d", ($5+511)/512)}')
if [[ -f $BIN ]];then
dd if=./$DD_IN of=$DD_OUT bs=512 \
count=$SEC_CNT seek=300 conv=notrunc
fi
注意:这个脚本我修改过,写入字节数是4782字节。
这是一个shell脚本,功能是书上最后注释的三句话。
它的功能是将上述用户程序以及它调用的一些函数从而必须依赖的一些文件一起编译,链接成一个新的二进制elf格式可执行文件即prog_no_arg,然后dd刻入Seven.img。
注意格局,它依赖的文件包括string.o、syscall.o、assert.o。这些文件由makefile编译生成
这里的脚本相当于又把他们和prog_no_arg.o链接了一遍,在command目录下生成了prog_no_arg。再dd进Seven.img第300扇区
prog_no_arg刻进硬盘后,位于Seven.img第300扇区,而内核kernel.bin位于第200扇区,所以不会冲突。通过内核main函数里的代码,调用文件系统函数,将prog_no_arg从第300扇区先读到内存,再写入了Seven80.img的文件系统中。
最后在shell的外部命令处理,即解析外部命令然后系统调用中断执行sys_exec后,上了处理机,执行了prog_no_arg的入口函数main打印那一句话。
main.c
int main(void) {
put_str("I am kernel\n");
init_all();
/************* 写入应用程序 *************/
uint32_t file_size = 4782;
uint32_t sec_cnt = DIV_ROUND_UP(file_size, 512);
struct disk* sda = &channels[0].devices[0];
void* prog_buf = sys_malloc(file_size);
ide_read(sda, 300, prog_buf, sec_cnt);
int32_t fd = sys_open("/prog_no_arg", O_CREAT|O_RDWR);
if (fd != -1) {
if(sys_write(fd, prog_buf, file_size) == -1) {
printk("file write error!\n");
while(1);
}
}
/************* 写入应用程序结束 *************/
cls_screen();
console_put_str("[rabbit@localhost /]$ ");
while(1);
return 0;
}
实验效果
首先在command目录下执行脚本
sh compile.sh
这个bug就是因为makefile编译string.c的string.o是32位。
然而我们虚拟机的操作系统的gcc默认生成的prog_no_arg.o的是64位,所以格式不匹配,无法链接生成prog_no_arg。
故
gcc加上 -m32
ld加上 -m elf_i386
这里提醒一下,prog_no_arg和prog_no_arg.o是完全不同的东西。
prog_no_arg.o是由gcc编译prog_no_arg.c得到的二进制文件
prog_no_arg是prog_no_arg.o与string.o、assert.o等等链接最后生成的prog_no_arg
这个bug产生时,prog_no_arg.o已经生成,但链接失败,prog_no_arg未生成。
这个图是写kernel.bin,os内核。执行的是makefile
这个地方由于我不懂脚本,加上虚拟机版本和原书不同,调试花费了很多时间。
最后调试的时候要记住,先用rm命令把上次的prog_no_arg文件删了,再重新运行。
4.用户进程支持参数
c标准库与操作系统无关,跨os给用户程序提供函数调用;
c运行库与os紧密联系,补充c标准库的功能,为了适配本os环境而研发。
main函数其实是由c运行库代码call调用,
void main(argc, argv);
int main(argc, argv);
void main();
其中的参数也是call之前压栈传递的
start.S 用于模拟c运行库main前的代码
[bits 32]
extern main
extern exit
section .text
global _start
_start:
;下面这两个要和execv中load之后指定的寄存器一致
push ebx ;压入argv
push ecx ;压入argc
call main
prog_arg.c
#include "stdio.h"
#include "syscall.h"
#include "string.h"
int main(int argc, char** argv){
int arg_idx = 0;
while(arg_idx < argc){
printf("argv[%d] is %s\n", arg_idx, argv[arg_idx]);
arg_idx++;
}
int pid = fork();
if (pid){
int delay = 900000;
while(delay--);
printf("\n i'm father prog, my pid%d,i will show process list\n", getpid());
ps();
}else{
char abs_path[512] = {0};
printf("\n i'm chhild prog, my pid: %d, i will exec %s right now\n", getpid(), argv[1]);
if (argv[1][0] != '/'){
getcwd(abs_path, 512);
strcat(abs_path, "/");
strcat(abs_path, argv[1]);
execv(abs_path, argv);
}else{
execv(argv[1], argv);
}
}
while(1);
return 0;
}
梳理一下这次试验关键函数执行顺序
首先是shell脚本,编译链接输出prog_arg这个二进制文件,再把prog_arg利用dd写入磁盘Seven.img
然后操作系统内核kernel.bin也是磁盘Seven.img,被loader.s加载进内存后,jump到os内核入口函数main开始执行main,而本次实验的main函数将prog_arg从磁盘Seven.img读出后再写入已经挂载在os上的文件系统,该文件系统位于磁盘Seven80.img的某个分区(具体哪个分区我记不到了,之前格式化磁盘章节创建了很多分区)
其次是my_shell接收命令:./prog_arg prog_no_arg,
my_shell中cmd_parse解析此命令,然后argv[0] = ./prog_arg,argv[1] = prog_no_arg
然后execv(argv[0], argv)调用sys_execv,执行了prog_arg 这个程序,同时ecx存的是参数个数这里有两个等于2,ebx存的是所有参数argv。
由于start.S与prog_arg.o已经链接,在链接时不指定入口函数那么_start就是默认入口函数,其中_start已经将ebx、ecx入栈了,所以call main后执行prog_arg里的main后可以根据栈指针找到这两个参数。从而可以在prog_arg里的main里执行execv(argv[1], argv),来执行prog_no_arg这个程序。
文件系统根目录下存在两个文件prog_no_arg(上次实验加载的)、prog_arg(这次加载的)。
输入的命令为./prog_arg prog_no_arg
cmd_parse解析此命令,然后argv[0] = ./prog_arg,argv[1] = prog_no_arg。
make_clear_abs_path只是把argv[0]洗成了绝对路径,然后调用exec执行此绝对路径/prog_arg。
这个时候我们的start.S就派上了用场,start.o和prog_arg.o链接成prog_arg,start.o里面的_start是默认入口地址(compile2.sh里面的ld命令没有写-e main,所以就用默认的_start函数作为入口地址)
所以把ebx里面的argv和ecx里面的argc传入main函数。其中argv和argc当然是execv(argv[0], argv)系统调用中断返回压入ebx,ecx的。
然后就到了上述prog_arg的main函数,先是打印了ebx过来的argv[0]和argv[1],检验了一下start.S,然后调用fork,让子进程执行argv[1]的文件。
当然这里要注意,argv[0]是绝对路径,但是argv[1]还不是,所以写了个逻辑判断,如果argv[1]不是绝对路径,获得当前工作目录,然后加上/文件名强行拼成绝对路径,再exec执行prog_no_arg,打印那句话。
根据上述代码逻辑,你输入的相对路径即文件名,必须是当前目录下有的文件,不然会出现bug。
compile2.sh
#### 此脚本应该在command目录下执行
if [[ ! -d "../lib" || ! -d "../build" ]];then
echo "dependent dir don\`t exist!"
cwd=$(pwd)
cwd=${cwd##*/}
cwd=${cwd%/}
if [[ $cwd != "command" ]];then
echo -e "you\`d better in command dir\n"
fi
exit
fi
BIN="prog_arg"
CFLAGS="-Wall -c -fno-builtin -W -Wstrict-prototypes \
-Wmissing-prototypes -Wsystem-headers"
#LIBS= "-I ../lib -I ../lib/user -I ../fs"
OBJS="../build/string.o ../build/syscall.o \
../build/stdio.o ../build/assert.o start.o"
DD_IN=$BIN
DD_OUT="/home/Seven/bochs2.68/bin/Seven.img"
nasm -f elf ./start.S -o ./start.o
ar rcs simple_crt.a $OBJS start.o
gcc -m32 $CFLAGS -I "../lib/" -I "../lib/kernel/" -I "../lib/user/" -I "../kernel/" -I "../device/" -I "../thread/" -I "../userprog/" -I "../fs/" -I "../shell/" -o $BIN".o" $BIN".c"
ld -m elf_i386 $BIN".o" simple_crt.a -o $BIN
SEC_CNT=$(ls -l $BIN|awk '{printf("%d", ($5+511)/512)}')
if [[ -f $BIN ]];then
dd if=./$DD_IN of=$DD_OUT bs=512 \
count=$SEC_CNT seek=300 conv=notrunc
fi
这里要注意ld里面把 -e main去掉噢
ar命令把string.o 、syscall.o 、stdio.o 、assert.o 、start.o打包成simple_crt.a,
simple_crt.a类似于CRT(c运行库)作用。
当然不打包直接链接也行。
main.c
int main(void) {
put_str("I am kernel\n");
init_all();
/************* 写入应用程序 *************/
uint32_t file_size = 5329;
uint32_t sec_cnt = DIV_ROUND_UP(file_size, 512);
struct disk* sda = &channels[0].devices[0];
void* prog_buf = sys_malloc(file_size);
ide_read(sda, 300, prog_buf, sec_cnt);
int32_t fd = sys_open("/prog_arg", O_CREAT|O_RDWR);
if (fd != -1) {
if(sys_write(fd, prog_buf, file_size) == -1) {
printk("file write error!\n");
while(1);
}
}
/************* 写入应用程序结束 *************/
cls_screen();
console_put_str("[rabbit@localhost /]$ ");
while(1);
return 0;
}
我的compile2.sh将prog_arg写入Seven.img是5329字节
实验结果
注意:我输入的命令第二个参数是相对路径
./prog_arg prog_no_arg
而书上的是两个绝对路径
./prog_arg /prog_no_arg
只不过第一个绝对路径带点,
还记得吗,make_clear_abs_path的wash_path可以把带点的绝对路径洗成不带点的。所以第一个参数可以。
但是第二个参数只能接受不带点的绝对路径或或者文件名。