二十二. 让shell支持外部命令

exec

在linux的bash shell中,执行外部命令时,该shell会fork一个子进程,这个子进程调用exec从磁盘上加载外部命令对应的程序。这是exec的一个应用。通过它的应用来看原理

exec会把一个可执行文件的绝对路径作为参数,把当前正在运行的用户进程的进程体用该可执行文件的进程体替换,从而实现新进程的执行,而进程的pid是不变的。可以这样认为,exec并没有创建新的进程,只是替换了原来进程上下文的内容。原进程的代码段,数据段,堆栈段被新的进程所代替。

从实现上来说,只需要加载一个新的进程,然后将当前进程的堆栈环境替换即可,下面是实现代码

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
/* 用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;
}

task_struct *cur = running_thread();
/* 修改进程名 */
memcpy(cur->name, path, TASK_NAME_LEN);
cur->name[TASK_NAME_LEN - 1] = 0;

intr_stack *intr_0_stack = (intr_stack *)((uint32_t)cur + PG_SIZE - sizeof(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;
}

想要让shell支持外部命令的话需要对当前的shell进行改进,当遇到外部命令时,fork一个子进程,让这个子进程来调用exec,那么外部的进程便执行起来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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)
{
execv(argv[0], argv);
}
while(1);
}

这里稍微解释一下这里的两个while(1)。

第一次while(1),也就是当前的shell进程,这个shell本身是一个死循环,处于不断的检测输入的状态,当fork一个子进程后,为了不使当前进程的堆栈数据被修改,使其在此空转,因为父进程一般会先与子进程执行,它再执行的话会对栈中的数据进行修改,比如子进程中将会使用到的final_path。

第二个while(1),也就是子进程复制了父进程的数据会会执行到的代码,因为目前没有实现进程的退出,为了不使该进程乱执行,在其应该结束的位置使其空转。等到后面实现了wait和exit后,就可以将其替换掉。

目前的shell能够支持外部命令了,先随便写个进程测试一下效果。


在我的linux上写了一个prog的程序,它的作用是输出一句话,将其用gcc编译链接成可执行文件后,将其写入到虚拟机的文件系统当中,图上就是它的执行效果啦。当然这个链接的过程必须使用目前这个kernel中支持的目标文件,比如printf函数,必须用我们自己实现的printf,而不是linux中自带的printf。所以用户进程所能实现的功能比较有限。

支持参数的用户进程

在之前的内建命令中,传递参数还是非常简单的,在调用的时候将参数直接传递过去即可,内建的命令只是一个函数,还可以通过栈来传递参数。但是对于外部的命令来说,如何让其支持形如 “cat file”这样的命令呢,这个file就是前面所说的参数。

获取参数是在命令执行之前,要想把获取到的参数传递给某个命令,该命令所属的进程必须先有栈,但外部命令的执行,实质上是加载一个用户进程的过程,进程都没创建,更何况进程的栈了。

大家应该见过main函数的这种形式

1
2
3
4
int main(int argc, char **argv)
{
return 0;
}

main函数有了参数,那么这个参数是谁传递给他的,既然他有参数,那么肯定是被调用执行的,调用者将参数传给了它。这个调用者就是CRT(c 运行库),CRT最主要的工作就是初始化运行环境,在进入main函数之前为用户进程准备条件,参数等。在main函数执行完返回之后,对用户进程的资源进行回收。

我们编译好的程序主体大概长这样

main函数只是夹在中间的一部分,既不是从它开始,也不是从它结束

当然这里不可能真的实现一个CRT,我们要做的工作只是将参数push进来,call main即可

所以这个简陋的CRT只有8行代码

1
2
3
4
5
6
7
8
[bits 32]
extern main
section .text
global _start
;这两个要和exec中指定的寄存器一致
push ebx ;压入argv
push ecx ;压入argc
call main

这个才是程序真正的入口,既然真正的入口在这里,那么main函数的函数名是什么都不重要了,我们可以使用自己想取的任意名字,只是在习惯上使用main作为入口。

接下来测试一下带参数的版本,将参数打印出来。

cat命令

前面说了那么多进程方面的概念,但目前为止还没有实现一个真正能用的进程。下面就实现一下cat命令,当前,是简化之后的,通过这个命令查看普通文件的数据。

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
int main(int argc, char **argv)
{
int buf_size = 1024;
char abs_path[512] = {0};
void *buf = malloc(buf_size);
if (buf == NULL)
{
return -1;
}

if (argv[1][0] != '/')
{
getcwd(abs_path, 512);
strcat(abs_path, "/");
strcat(abs_path, argv[1]);
}
else
{
strcpy(abs_path, argv[1]);
}

int fd = open(abs_path, O_RDONLY);
if (fd != -1)
{
int read_bytes = 0;
while (true)
{
read_bytes = read(fd, buf ,buf_size);
if (read_bytes == -1)
{
break;
}
write(std_out, buf, read_bytes);
}
free(buf);
close(fd);
}
return 0;
}

这就是cat的实现了。下面来测试一下其功能

进程的基本功能有了,我们可以从磁盘上加载用户进程,可以通过shell执行外部命令。后面会实现一种进程同步的方式和一种进程间通信的方式