运行新进程

运行新进程

在UNIX中,把程序载入内存并执行程序映像的操作与创建新进程的操作是分离的。一次系统调用会把二进制程序加载到内存中,替换地址空间原来的内容,并开始执行。这个过程称为”执行(executing)”一个新的程序,是通过一系列exec系统调用来完成的

同时,另一个不同的系统调用是用于创建一个新的进程,它基本上相当于辅助其父进程。通常情况下,新进程会立即执行新的程序。创建新进程的操作称为派生(fork),是系统调用fork()来完成这个功能

在新进程中执行一个新的程序需要两个步骤:

  1. 创建一个新进程
  2. 通过exec系统调用把二进制程序加载到该进程中

exec系统调用

不存在单一的exec函数,而是基于单个系统调用,由一系列的exec函数构成。

execl()

1
2
#include <unistd.h>
int execl (const char *path, const char *arg, ...);

execl()调用会把path所指路径的映像载入内存,替换当前进程的映像。参数arg是它的第一个参数。省略号表示可变长度的参数列表——execl()函数是可变参数,也就是说后续还有一个或者多个参数,参数列表必须以NULL结尾,如:

1
2
3
4
5
6
int ret;
ret = execl ("/bin/vi", "vi", NULL);
if (ret == -1)
{
perror ("execl");
}

通常情况下,execl()不会返回。调用成功时,会跳转到新的程序入口点,而刚刚运行的代码是不再存在于进程的地址空间中。错误时execl()会返回-1,并设置响应的errno

成功的execl()调用不仅改变了地址空间和进程映像,还改变了一些其他属性:

  • 所有挂起的信号都会丢失
  • 捕捉到的所有信号都会还原为默认处理方式
  • 丢弃所有内存锁
  • 大多数线程的属性会被还原成默认值
  • 重置大多数进程相关的统计信息
  • 清空和进程相关的统计信息
  • 清空和进程内存地址空间相关的所有数据,包括所有映射文件
  • 清空所有只存在于用户空间的数据,包括C库的一些功能

但是有些属性还是没有改变:pid、父进程pid、优先级、所属的用户和组

通常打开的文件描述符也通过exec继承下来。这意味着如果新进程知道原进程所打开的文件描述符,他就可以访问这些文件。但是,这通常并不是期望的行为,所以实际操作中一般会在调用exec前关闭打开的文件,当然,也可以通过fcntl(),让内核自动完成关闭操作

exec系的其他函数

除了execl()外,还有其他5个函数:

1
2
3
4
5
6
7
#include <unnistd.h>

int execlp (const char *file, const char *arg, ...);
int execle (const char *path, const char *arg, ..., char *const envp[]);
int execv (const char *path, char *const argv[]);
int execvp (const char *file, char *const argv[]);
int execve (const char *filename, char *const argv[], char *const envp[]);

l和v分别表示参数是以列表形式还是数组形式,p表示会在用户的绝对目录下查找可执行文件,e表示会为新进程提供新的环境变量

在Linux中,exec系函数只有一个真正的系统调用,其他都是基于该系统调用在C库封装的函数,由于处理变长参数的系统调用难以实现,而且用户的路径只存在于用户空间,所以execve是唯一的系统调用,其原型和用户调用完全相同

错误返回值

成功时,exec系函数不会返回。失败时返回-1,并把errno设为以下值之一:

E2BIG : 参数列表或环境变量长度过长

EACCESS : 没有在path所指定路径的查找权限;path所指向的文件不是一个普通文件;目标文件不可执行;path或文件所位于的文件系统以不可执行方式挂载

EFAULT : 给定指针非法

EIO : 底层IO错误

EISDIR : 路径path的最后一部分或者路径解释器是个目录

ELOOP : 系统在解析path时遇到太多的符号连接

EMFILE : 调用进程打开的文件数达到进程上限

ENFILE : 打开文件达到系统上限

ENOENT : 目标文件不是一个有效的二进制可执行文件或其他体系结构上的可执行文件

ENOMEM : 内核内存不足,无法执行新的程序

ENOTDIR : path中除最后名称外的其中某个部分不是目录

EPERM : path或文件所在的文件系统以没有sudo权限的用户挂在,而且用户不是root用户,path或文件的suid或sgid位被设置

ETXTBSY : 目标目录或文件被另一个进程以可写方式打开

fork()系统调用

通过fork()系统调用,可以创建一个和当前进程映像一样的进程:

1
2
3
4
#include <sys/types.h>
#include <unistd.h>

pid_t fork (void);

当fork()调用成功时,会创建一个新的进程,他几乎与调用fork()的进程完全相同。这两个进程都会继续运行,调用者进程从fork()返回后,还是照常运行

新进程被称为“子进程”,原进程被称为“父进程”。在子进程中,成功的fork()调用会返回0.在父进程中会返回子进程的pid。除了一些本质性区别,父进程和子进程之间在其他各个方面都完全相同:

  • 子进程的pid是重新分配的,与父进程不同。
  • 子进程的ppid会设置为父进程的pid
  • 子进程中的资源统计信息会清零
  • 所有挂起的信号都会清除,也不会被子进程继承
  • 所有文件锁也都不会被子进程继承

返回值和错误码

出错时,不会创建子进程,fork()返回-1,并相应设置errno值。errno有两种可能,包括三种不同的含义:

EAGAIN : 内核申请资源时失败或达到了RLIMIT_NPROC设置的资源限制

ENOMEM : 内核内存不足,无法满足所请求的操作

fork()系统调用的用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
pid_t pid;
pid = fork();
if (pid >0)
{
printf ("I am the parent of pid = %d\n", pid);
}
else if (!pid)
{
printf ("I am the child!\n");
}
else if (pid == -1)
{
perror ("fork");
}

最常见的fork()用法是创建一个新的进程,载入新的二进制映像——类似shell为用户创建一个新进程,或一个进程创建一个辅助进程。

首先,该进程创建了一个进程,而这个进程会执行一个新的二进制可执行文件的映像。像这种“派生/执行”的方式很常见:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
pid_t pid;

pid = fork();
if (pid == -1)
{
perror ("fork");
}

/*the child */
if (!pid)
{
const char *args[] = { "windlass", NULL};
int ret;

ret = execv ("/bin/windlass",args);
if (ret == -1)
{
perror ("execv");
exit (EXIT_FAILUER);
}
}

写时复制(WOC)

由于大量的fork()创建之后都会紧跟着执行exec,因此把整个父进程地址空间中的内容复制到子进程的地址空间往往只是纯属浪费:如果子进程立刻执行一个新的二进制可执行文件的映像,他先前的地址空间就会被交换出去

因此,可以采用一种称为写时复制的技术,它通过允许父进程和子进程最初共享相同的页面来工作。这些共享页面标记为写时复制,这意味着如果任何一个进程写入共享页面,那么就创建共享页面的副本。

写时复制

例如,假设子进程试图修改包含部分堆栈的页面,并且设置为写时复制。操作系统会创建这个页面的副本,将其映射到子进程的地址空间。然后,子进程会修改复制的页面,而不是属于父进程的页面。显然,当使用写时复制技术时,仅复制任何一进程修改的页面,所有未修改的页面可以由父进程和子进程共享。


运行新进程
https://carl-5535.github.io/2021/04/28/Linux系统编程/运行新进程/
作者
Carl Chen
发布于
2021年4月28日
许可协议