例程
先通过一个例程体会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
的实现过程包含下列元素:
- 用户进程P1。
- 库函数
execv
。- P1直接调用
execv
。 execv
会把argv中的数据存储起来,通过IPC传递给P2的do_exec。
- P1直接调用
- 内存管理进程P2。
- 内存管理模块MM包含do_exec。
- 内存管理模块运行起来就是P2。
- 也就是说,在P2中会运行do_exec。
execv的主要流程如下:
- 用户进程P1调用execv,通过IPC机制,把
file
和argv
的值传递给内存管理进程P2的do_exec。 - do_exec读取file文件中的二进制指令,把程序计数器设置为file中的二进制指令;读取P1传递过来的堆栈数据并且重新放置,然后把esp指向新堆栈的栈顶。
execv
在我们的操作系统中是一个系统调用。而我们的系统调用的实现方式如下:
- 库函数直接供用户进程使用,例如
execv
。 - 库函数通过IPC调用内核进程,例如内存管理进程中的do_exec。
- 库函数所提供的功能,主要由内核进程完成。
主要环节
库函数execv
函数原型
函数原型是:void execv(char *file, char *argv[])
。
第一个参数file
,是要调用的程序,例如,例程中的"echo"。
第二个参数argv
,是echo
的参数。当然不是原始参数,而是把原始参数经过处理之后存储到字符串数组中。
例如,假如echo
的原始参数是hello world
,那么,argv
存储的数据是:argv[0]
中存储hello
,argv[1]
中存储world
。
功能
本函数做两件事:
- 把argv中的数据重新放置到合适的内存位置,形成新数据arg_stack。
- 通过IPC机制把arg_stack发送给内存管理中的do_exec。
只讲解第一件事。
新数据arg_stack的结构
arg_stack
是什么样的数据?它由三部分组成:
- 第1部分是内存地址addr。
- 第2部分是空字符。
- 第3部分是argv中的数据data。
- addr是data的内存地址。
提供一张简单的示意图,再结合上面的文字说明,很容易明白arg_stack
的构成。
内存地址 | 0x1 | 0x2 | 0x3 | 0x4 | 0x5 | 0x6 | 0x7 |
---|---|---|---|---|---|---|---|
数据 | 0x4 | 0x5 | 空字符 | A | B | C | D |
新数据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调用其他程序,实质是两件事:使用用户进程中的堆栈;执行其他程序的二进制代码。
设置堆栈
复制堆栈
不同进程之间复制数据,不是本文的重点,不深入探讨。
其实,好像也不是什么很深入的问题,简单说一下。理解不了,也不妨碍读者继续理解后面的内容。
- 在执行
execv
的用户进程P1中,存储堆栈数据的变量是char arg_stack[进程的栈大小]
。 - 在执行
do_exec
的内核进程P2中,接收堆栈数据的变量是char stack[进程栈的大小]
。 arg_stack
和stack
分别是P1、P2中的局部变量。它们的内存地址是虚拟内存地址。- 在不同进程之间复制数据,应该使用物理内存地址。
- 把
arg_stack
和stack
的虚拟内存地址转换成物理地址,就能在不同进程之间复制数据了。 - 怎么把虚拟内存地址转换成物理地址,这又是另外一个话题了。
重新放置堆栈
我们已经把arg_stack
中的数据复制到了stack
中了。这就是我们要使用的堆栈new_stack
吗?
不是的。
需做两点改变。
前半部分
stack
的数据分为三部分:内存地址、空字符、数据。
new_stack
只要“内存地址”。
为什么?
经验告诉我,当一个C语言的函数的参数是char *arr[]
时,这个参数对应的堆栈中的值就应该是arr
中的元素的内存地址,而不是元素数据本身。
内存地址
new_stack
中存储的内存地址是arg_stack
的第三部分,也就是“数据”。
注意,我写的是arg_stack
,而不是stack
。arg_stack
和stack
中的第三部分(数据)中值虽然相同,但内存地址是不同的。
在execv
中,arg_stack
是CPU自动分配的。在do_exec
中,stack
的值用下面这个公式计算。
stack
中的第三部分(数据)中的每个元素的内存地址是多少?并不能在本进程确定,无从得知,既不是参数,又不是局部变量,实在想不到还有什么方法能获得这些元素的内存地址。
那怎么办呢?可以修改arg_stack
中的第三部分(数据)中的元素的内存地址获得。
先看一张图。
内存地址 | 0x1 | 0x2 | 0x3 | 0x4 | 0x5 | 0x6 | 0x7 | 0x8 |
---|---|---|---|---|---|---|---|---|
用户进程-arg_stack | 0x4 | 0x6 | 空字符 | A | B | C | D | |
内核进程-stack | 0x5 | 0x7 | 空字符 | A | B | C | D |
补充说明:
- 假设用户进程和内核进程的内存空间都是8个字节。
- arg_stack数据分布如图中的
用户进程-arg_stack
这一行所示,内存地址范围是0x1
到0x7
。 - stack数据分布如图中的
内核进程-stack
这一行所示,内存地址范围是0x2
到0x8
。
我们的问题是什么?
- 已知上图中的
用户进程-arg_stack
这一行的信息。 - 已知
内核进程-stack
这一行的stack
的初始地址是0x2
,末尾地址是0x8
。 - 需要计算出
内核进程-stack
这一行的stack
每个字节中的数据对应的内存地址。例如,存储A
的内存的地址是多少?
肉眼观察,很容易发现规律:stack
中的A、B、C、D
所在的内存的地址比arg_stack
中的对应数据所在的内存地址大1。
这个内存地址的差值的计算公式是:
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
的材料的辅助材料。
学习一个知识点,以一种学习材料为主,看不懂的时候,看看其他辅助材料,或许能启发思路。