怎么实现系统调用exec

986 阅读8分钟

例程

先通过一个例程体会execv的作用。

下面的代码是echo.c,功能是打印命令行参数。

#include <stdio.h>

int main(int argc, char **argv)
{
        for(int i = 1; i < argc; i++){
                printf("argv[%d] = %s\n", i, argv[i]);
        }

        return 0;
}

编译执行的结果:

[root@localhost c]# gcc -o echo echo.c -g -m32
[root@localhost c]# ./echo Hello World
argv[1] = Hello
argv[2] = World

使用execv调用echo的代码。

#include <unistd.h>
int main(int argc, char **argv)
{
        // execl("/bin/ls","ls", "-al", "./", (char *)0);
        //execl("./echo", "echo2", "Hello", "World", (char *)0);

        // argv2的四个元素分别是第0个参数,即文件本身,第1个参数,第2个参数,空指针。末尾的空指针必不可少。
  			char * argv2[] = {"echo", "Hello", "World",  (char*)0};
        execv("./echo", argv2);

        return 0;
}

编译执行:

[root@localhost c]# gcc -o execv execv.c -g -m32
[root@localhost c]# ./execv
argv[0] = Hello
argv[1] = World

execv的作用是调用另外一个程序。

概要

本文要讲解的就是execv的实现。

函数原型是:void execv(char *file, char *argv[])

execv的实现过程包含下列元素:

  1. 用户进程P1。
  2. 库函数execv
    1. P1直接调用execv
    2. execv会把argv中的数据存储起来,通过IPC传递给P2的do_exec。
  3. 内存管理进程P2。
    1. 内存管理模块MM包含do_exec。
    2. 内存管理模块运行起来就是P2。
    3. 也就是说,在P2中会运行do_exec。

execv的主要流程如下:

  1. 用户进程P1调用execv,通过IPC机制,把fileargv的值传递给内存管理进程P2的do_exec。
  2. do_exec读取file文件中的二进制指令,把程序计数器设置为file中的二进制指令;读取P1传递过来的堆栈数据并且重新放置,然后把esp指向新堆栈的栈顶。

execv在我们的操作系统中是一个系统调用。而我们的系统调用的实现方式如下:

  1. 库函数直接供用户进程使用,例如execv
  2. 库函数通过IPC调用内核进程,例如内存管理进程中的do_exec。
  3. 库函数所提供的功能,主要由内核进程完成。

主要环节

库函数execv

函数原型

函数原型是:void execv(char *file, char *argv[])

第一个参数file,是要调用的程序,例如,例程中的"echo"。

第二个参数argv,是echo的参数。当然不是原始参数,而是把原始参数经过处理之后存储到字符串数组中。

例如,假如echo的原始参数是hello world,那么,argv存储的数据是:argv[0]中存储helloargv[1]中存储world

功能

本函数做两件事:

  1. 把argv中的数据重新放置到合适的内存位置,形成新数据arg_stack。
  2. 通过IPC机制把arg_stack发送给内存管理中的do_exec。

只讲解第一件事。

新数据arg_stack的结构

arg_stack是什么样的数据?它由三部分组成:

  1. 第1部分是内存地址addr。
  2. 第2部分是空字符。
  3. 第3部分是argv中的数据data。
  4. addr是data的内存地址。

提供一张简单的示意图,再结合上面的文字说明,很容易明白arg_stack的构成。

内存地址0x10x20x30x40x50x60x7
数据0x40x5空字符ABCD
新数据arg_stack的内存地址

为什么要重新处理argv中的数据再发送给do_exec、而不直接把argv发送给do_exec呢?

do_exec中需要的不是原始数据argv而是arg_stack。更详细的原因,讲解do_exec时再探讨。

execv函数中,我会声明一个变量来存储经过处理后的原始数据argv。

char arg_stack[进程的栈大小];

arg_stack的内存地址是&arg_stack&arg_stack又是多少呢?

每个进程都拥有自己的内存空间M。进程中的局部变量arg_stack一定是在M中。

不需要知道arg_stack的具体数值,只需要知道:arg_stack是一个相对于M的初始地址的偏移量,并且arg+进程栈的大小必定小于等于M的末尾地址。

为什么“arg+进程栈的大小必定小于等于M的末尾地址”?我目前也不知道,但不知道这个问题的答案并不妨碍我们继续探讨本文的主题。

内核函数do_exec

回顾

do_exec调用其他程序,实质是两件事:使用用户进程中的堆栈;执行其他程序的二进制代码。

设置堆栈

复制堆栈

不同进程之间复制数据,不是本文的重点,不深入探讨。

其实,好像也不是什么很深入的问题,简单说一下。理解不了,也不妨碍读者继续理解后面的内容。

  1. 在执行execv的用户进程P1中,存储堆栈数据的变量是char arg_stack[进程的栈大小]
  2. 在执行do_exec的内核进程P2中,接收堆栈数据的变量是char stack[进程栈的大小]
  3. arg_stackstack分别是P1、P2中的局部变量。它们的内存地址是虚拟内存地址。
  4. 在不同进程之间复制数据,应该使用物理内存地址。
  5. arg_stackstack的虚拟内存地址转换成物理地址,就能在不同进程之间复制数据了。
  6. 怎么把虚拟内存地址转换成物理地址,这又是另外一个话题了。
重新放置堆栈

我们已经把arg_stack中的数据复制到了stack中了。这就是我们要使用的堆栈new_stack吗?

不是的。

需做两点改变。

前半部分

stack的数据分为三部分:内存地址、空字符、数据。

new_stack只要“内存地址”。

为什么?

经验告诉我,当一个C语言的函数的参数是char *arr[]时,这个参数对应的堆栈中的值就应该是arr中的元素的内存地址,而不是元素数据本身。

内存地址

new_stack中存储的内存地址是arg_stack的第三部分,也就是“数据”。

注意,我写的是arg_stack,而不是stackarg_stackstack中的第三部分(数据)中值虽然相同,但内存地址是不同的。

execv中,arg_stack是CPU自动分配的。在do_exec中,stack的值用下面这个公式计算。

stack=进程栈的内存空间的结束地址进程栈的大小stack = 进程栈的内存空间的结束地址 - 进程栈的大小

stack中的第三部分(数据)中的每个元素的内存地址是多少?并不能在本进程确定,无从得知,既不是参数,又不是局部变量,实在想不到还有什么方法能获得这些元素的内存地址。

那怎么办呢?可以修改arg_stack中的第三部分(数据)中的元素的内存地址获得。

先看一张图。

内存地址0x10x20x30x40x50x60x70x8
用户进程-arg_stack0x40x6空字符ABCD
内核进程-stack0x50x7空字符ABCD

补充说明:

  1. 假设用户进程和内核进程的内存空间都是8个字节。
  2. arg_stack数据分布如图中的用户进程-arg_stack这一行所示,内存地址范围是0x10x7
  3. stack数据分布如图中的内核进程-stack这一行所示,内存地址范围是0x20x8

我们的问题是什么?

  1. 已知上图中的用户进程-arg_stack这一行的信息。
  2. 已知内核进程-stack这一行的stack的初始地址是0x2,末尾地址是0x8
  3. 需要计算出内核进程-stack这一行的stack每个字节中的数据对应的内存地址。例如,存储A的内存的地址是多少?

肉眼观察,很容易发现规律:stack中的A、B、C、D所在的内存的地址比arg_stack中的对应数据所在的内存地址大1。

这个内存地址的差值的计算公式是:

delta=stack的初始地址arg_stack的初始地址delta = stack的初始地址 - arg\_stack的初始地址

stack的第一部分的值等于arg_stack的第一部分的值。我们把经过处理后的stack中的第一部分的值存储到new_stack中。

new_stack就是新程序运行所需要的栈。把堆栈栈顶esp的值设置成new_stack

至此,设置堆栈的任务完成。

设置程序计数器

程序计数器就是eip,就是下一条要执行的指令的地址。

这个指令是什么?就是要调用的程序的指令。

elf文件

要调用的程序,例如echo,是ELF格式。

ELF文件,由一些数据结构(元信息,例如,几个程序段,每个程序段多少字节)和二进制指令组成。

elf文件的结构大致如下:

ELF文件头
程序头表项0
程序头表项1
程序头表项2
...
解析elf文件

我们读取到内存中的被调用程序,例如echo,并不能直接把eip指向存储echo的内存地址就万事大吉了。

理由在前面说过,echo中不仅只有二进制数据。

我们的任务,根据echo中的元信息,读取二进制指令,放置到元信息中指定的内存地址所指向的内存空间中。

大致流程如下:

把echo读取到mmbuf中
for(int i = 0; i < Program header table entry count; i++){
  		程序头 = mmbuf + 程序头表在文件中的偏移量 + 程序头表项的大小 * i
  		物理复制(程序头项中指定的虚拟地址, 程序头中记录的二进制指令在ELF文件中的偏移量,二进制指令的长度)
}

重新放置echo中的二进制指令到内存中后,设置eip的值为Entry point virtual address,即ELF file header->e_entry。

小结

要执行调用程序echo,把echo的二进制代码放置到这个文件要求的内存位置,并将程序计数器指向这个文件指定的入口地址,是理所当然的。

设置堆栈栈顶呢?当然也是必需的。一个程序要正常运行,除了二进制指令,还需要配套的数据和堆栈。

小结

本文讲解了实现execv的绝大部分流程,放弃了对echo的汇编代码的讲解。读者看完本文,可能仍然不明白怎么实现execv,但是我自己却完全掌握了所有难点和整个流程。所以,本文只适合作为读者朋友阅读其他实现execv的材料的辅助材料。

学习一个知识点,以一种学习材料为主,看不懂的时候,看看其他辅助材料,或许能启发思路。