0%

初探 Linux 系统编程之进程

0 前言

本文对 Linux 系统编程的进程相关知识进行总结,包含了进程的创建方法、IPC 实现等。

1 进程相关概念

  • 单道程序设计模式: DOS 操作系统

  • 多道程序设计模式: 通过时钟中断在硬件级别控制 CPU 轮转

  • MMU: 内存管理单元,位于 CPU 内部,完成虚拟内存与物理内存的映射和设置修改内存访问级别

  • PCB: 进程控制块,定义在 /usr/src/linux-haeders-3.16.0-30/include/linux/sched.h

    查看资源上限的命令: ulimit -a

  • PCB的组成:

    1. 进程号
    2. 进程的状态,有就绪、运行、挂起、停止等状态
    3. 进程切换时需要保存和回复的一些 CPU 寄存器
    4. 描述虚拟地址空间的信息
    5. 描述控制终端的信息
    6. 当前工作目录
    7. UMASK 掩码
    8. 文件描述表,包含很多指向 File 结构体的指针
    9. 和信号相关的信息
    10. 用户 ID 和组 ID
    11. 会话和进程组
    12. 进程可以使用的资源上限

2 环境变量

环境变量,是指在操作系统中用来指定操作系统运行环境的一些参数。通常具备以下特征:

  • 字符串
  • 有统一的格式: 名称=值,多个值用冒号分隔
  • 值用来描述进程环境信息
  1. 存储形式: 与命令行参数类似,char *[] 数组,数组名 environ,内部存储字符串,NULL 作为哨兵结尾
  2. 使用形式: 与命令行参数类似
  3. 加载位置: 与命令行参数类似,位于用户区,高于 stack 的起始位置
  4. 引入环境变量表,必须声明环境变量,extern char ** environ

2.1 常见的环境变量

  • PATH: 可执行文件的搜索路径,从前往后搜索,所以新版本的环境变量因放置在前面
  • SHELL: 记录当前使用的命令解释器,如 /bin/bash
  • HOME: 当前主目录
  • LANG: 当前语言
  • TERM: 当前的终端信息

echo $PATH 打印当前的 PATH 变量

2.2 相关函数

  • char* getenv(const char *name); 成功:返回环境变量到值,失败:NULL
  • int setenv(const char *name, const char *value, int overwrite); 成功:0,失败:-1,override 取1表示覆盖原环境变量值
  • int unsetenv(const char *name); 成功:0,失败:-1,当name不存在是仍然返回0,当name命名为”ABC=”时则会出错

通过 man [函数名] 可以查看函数相关 API

3 进程控制

3.1 fork 函数

pid_t fork(void),创建一个子进程,返回值有两个(一个进程变为两个进程,各自的 fork() 都返回):返回子进程的 PID(非负整数)和返回 0。可以判断返回值确定子进程执行的代码或是父进程执行的代码

3.2 创建多个子进程

使用以下语句

1
2
3
for (i=0; i<n; i++) {
fork();
}

并不是创建 N 个子进程,而是 (2^N-1)个子进程,正确的做法是在循环体中判断,如果是子进程(返回值=0),那么就 break

ps aux 显示所有进程
unistd.h 是 UNIX 系统标准库头文件
vim下使用:vs可以分屏

3.3 补充函数

  • uid_t getuid(void),获取当前进程的实际用户 ID
  • uid_t geteuid(void),获取当前进程的有效用户 ID
  • gid_t getgid(void),获取当前进程使用组 ID
  • gid_t getegid(void),获取当前进程有效用户组 ID

3.4 进程共享

父子进程共享之后的异同:
相同点:

  • 全局变量
  • .data .text
  • 栈、堆
  • 环境变量、信号处理方式
  • 用户ID、宿主目录、进程工作目录

不同点:

  • 进程 ID、父进程 ID
  • fork 返回值
  • 进程运行时间
  • 定时器
  • 未决信号集

注意:

  1. 子进程并非将空间完全拷贝一份,而是遵循读时共享写时复制的原则
  2. 父子进程共享文件描述符(所以进程通信可以通过文件共享方式实现)和 MMAP 建立的映射区

3.5 GDB 调试

第一步,在 gcc 编译选项中增加 -g 选项;第二步,gdb 运行程序
通过 set follow-fork-mode child 跟踪子进程,通过 set follow-fork-mode parent 跟踪父进程,默认跟踪父进程

4 Exec 函数族

fork 创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种 exec 函数以执行另一个程序。当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用 exec 并不创建新进程,所以调用 exec 前后该进程的 id 并未改变。
将当前进程的.text、.data 替换为所要加载的.text、.data,然后让进程从新的.text 第一条指令开始执行,但进程 ID 不变,换核不换壳。
其中有六种以 exec 开头的函数,统称 exec 函数:

  • int execl(const char *path, const char *arg, …); // list
    加载一个进程,通过路径+程序名来加载,成功无返回,失败返回-1,;对比execlp,如加载”ls”命令带有-l,-F参数

    1
    2
    execlp("ls", "ls", "-l", "-F", NULL); // 使用程序名在PATH中搜索
    execl("/bin/ls", "ls", "-l", "-F", NULL); //使用参数1给出的绝对路径搜索
  • int execlp(const char *file, const char *arg, …); // list path
    加载一个进程,借助 PATH 环境变量,成功无返回,失败返回-1;参数1:要加载到程序的名字,该函数通常用来调用系统程序,如:ls、date、cp、cat 等命令

  • int execle(const char *path, const char *arg, …, char *const envp[]); // list environment
    借助环境变量表

  • int execv(const char *path, char *const argv[]);
    携带参数表

  • int execvp(const char *file, char *const argv[], char *const envp[]);
    携带环境变量表和参数表

  1. argv[0] 是程序名,arg[1~n-1] 是携带参数,arg[n] 是 NULL 结束符
  2. exec 族函数只在失败时才有返回值,成功无返回值,也不会继续再执行下面的程序

DUP2

引入: 将当前的进程信息输出到文件

  • 方法一: 通过 ps aux > out.txt 命令可以实现,但是 > 符并不属于参数,需要转义才可以
  • 方法二: 使用 DUP2 函数实现文件输出拷贝
    1
    int dup2(int oldfd, int newfd);

将输出指针 oldfd 复制到 newfd,即 newfd 所指向的文件和 oldfd 所指向的文件是一样的,也就实现了 newfd 重定向到 oldfd。

需要添加头文件 fcntl.h

5 回收子进程

  • 孤儿进程
    父进程先于子进程结束,则子进程成为孤儿进程,子进程的父进程成为 init 进程,称为 init 进程领养孤儿进程
  • 僵尸进程
    进程终止,父进程尚未回收,子进程残余资源(PCB)存放于内核中,变成僵尸(Zombie)进程

特别注意,僵尸进程是不能使用 kill 命令清除掉的,因为 kill 命令只是用来终止进程的,而僵尸进程已经终止。
ps aux 命令显示的进程列表中,STATE 栏表示当前状态,R 表示运行,S 表示后台运行,Z 表示僵尸进程

5.1 Wait 函数

一个进程在终止时会关闭所有的文件描述符,释放在用户空间分配的内存,但它的 PCB 还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号信息。这个进程的父进程可以调用 wait 或 waitpid 获取这些信息,然后彻底清除这个进程。我们知道一个进程的退出状态可以在 Shell 中用特殊变量$?查看,因为 Shell 是它的父进程,当它终止时 Shell 调用 wait 或 waitpid 得到它的退出状态,同时彻底清除掉这个进程。

1
pid_t wait(int *status);

成功返回清理掉的子进程 ID,失败返回-1(没有子进程)
父进程调用 wait 函数可以回收子进程终止信息。该函数有三个功能:

  • 阻塞等待子进程退出
  • 回收子进程残余资源
  • 获取子进程结束状态(退出原因)

当进程终止时,操作系统的隐式回收机制会完成:

  • 关闭所有文件描述符
  • 释放用户空间分配的内存
    内核的 PCB 仍存在,其中保存该进程的退出状态(正常终止->退出值,异常终止->终止信号)。

可使用 wait 函数传出参数 status 来保存进程的退出状态,借助宏函数来进一步判断进程终止的具体原因。宏函数可分为如下三组:

  • WIFEXITED(status) [wait if exited] 非零 进程正常退出,使用 WEXITSTATUS(status) 可获取进程退出状态(exit的参数)
  • WIFSIGNALED(status) 非零 进程异常终止,使用 WTERMSIG(status) 取得使进程终止的那个信号的编号
  • WIFSTOPPED(status) 非零 进程处于暂停状态,使用 WSTOPSIG(status) 取得使进程暂停的那个信号的编号,WIFCONTINUED(status) 如果为真说明进程暂停后已经继续运行

5.2 waitpid 函数

作用同 wait,但可以指定 pid 进程清理,可以不阻塞

1
pid_t waitpid(pid_t pid, int *status, in)

成功返回清理掉的子进程 ID,失败返回-1(无子进程)
参数 pid:

  • 0 回收指定 ID 的子进程

  • -1 回收任意进程(相当于 wait)

  • 0 回收和当前调用 waitpid 一个组的所有子进程

  • <-1 回收指定进程组内的任意子进程
    参数3:

  • 0,阻塞回收

  • WNOHANG,非阻塞回收(一般使用轮询)

返回值:

  • pid 成功
  • -1 失败
  • 0 参数3为WNOHANG 并且子进程尚未结束

一次 wait 或 waitpid 调用只能清理一个子进程,清理多个子进程应使用循环

6 进程间通信

Linux 环境下,进程地址空间相互独立,每个进程各自有不同的用户地址空间。任何一个进程的全局变量在另一个进程中都看不到,所以进程和进程之间不能相互访问,要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷贝到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC)
在进程间完成数据传递需要借助操作系统提供特殊的方法,如文件、管道、信号、共享内存、消息队列、套接字、命名管道等。随着计算机的蓬勃发展,一些方法由于自身设计缺陷被淘汰或者弃用,现今常用的进程间通信方式有:

  • 管道(使用最简单)
  • 信号(开销最小)
  • 共享映射区(无血缘关系)
  • 本地套接字(最稳定)

6.1 管道 PIPE

6.1.1 概念

管道是一种最基本的 IPC 机制,作用于有血缘关系的进程之间,完成数据传递。调用 pipe 系统函数即可创建一个管道,有如下特质:

  • 其实质是一个伪文件(实为内核缓冲区)
  • 由两个文件描述符引用,一个表示读端,一个表示写端
  • 规定数据从管道的写端流入管道,从读端流出

管道的原理: 管道实为内核使用环形队列机制,借助内核缓冲区(4K)实现
管道的局限性:

  • 数据自己读不能自己写
  • 数据一旦被读走,便不在管道中存在,因此,数据只能在一个方向上流动
  • 只能在有公共祖先的进程间使用管道

常见的通信方式有,单工通信、半双工通信、半双工通信

Linux 中的7中文件类型

  • 文件 d 目录 l 符号链接 s 套接字 b 块设备 p 管道
    前三种才占用存储空间,后四种称之为伪文件

6.1.2 pipe 函数

1
int pipe(int pipefd[2]);

成功: 0;失败: -1
函数调用成功会在传入参数返回 r/w 两个文件描述符,无需 open,但需 close

6.2 有名管道 FIFO

6.3 共享内存 MMAP

使用文件进行进程间通信

6.3.1 mmap 函数

1
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);

成功,返回创建的映射区首地址;失败,返回 MAP_FAILED 宏
参数:

  • addr 建立映射区的首地址,由 Linux 内核指定,使用时,直接传递 NULL
  • length 域创建映射区的大小
  • prot 映射区权限 PROT_READ、PROT_WRITE、PROT_READ|PROT_WRITE
  • flags 标志位参数(常用于设定更新物理区域、设置共享、创建匿名映射区)
    MAP_SHARED 会将映射区所做的操作反映到物理设备(磁盘)上
    MAP_PRIVATE 映射区所做的修改不会反映到物理设备
  • fd 用来建立映射区的文件描述符
  • offset 映射文件的偏移(4K的整数倍)

6.3.2 注意事项

  1. malloc 分配内存可以分配 0 字节,也可以将其释放,但映射区并不可以分配 0 字节
  2. 不能对 mmap 的返回值进行修改,否则 munmap 无法成功
  3. 打开文件只读,则不论是否开启映射物理设备,均不能在映射区进行写操作
  4. 创建映射区的权限要小于等于打开映射区文件的权限,映射区的创建中隐含着一次对映射区文件的读操作
  5. 最后一个参数的 offset 必须要是 4k 的整数倍,即 4096 的整数倍,页大小
  6. 映射区大小不能大于文件大小
  7. 文件描述符先关闭对读写映射区操作无影响,因为现在是通过映射区读写,不再使用文件句柄

unlink(filename) 函数,删除零食临时文件目录项,使之具备被释放条件
truncate() 和 ftruncate() 两个函数可用于改变文件长度

6.3.3 父子进程共享

父子进程共享的内容有:

  • 共享打开的文件
  • mmap 建立的映射区(但必须要使用 MAP_SHARED)

6.3.3 匿名映射区

mmap 足够方便,但问题在于每次建立映射一定要依赖一个文件才能实现,通常为了建立映射区要 open 一个 temp 文件,从创建好了再 unlink、close,比较麻烦。于是可以直接使用匿名映射来代替,借助标志位 MAP_ANONYMOUSMAP_ANON,注意该宏仅在 Linux 操作系统中可用

MAP_ANON 宏仅在 Linux 操作系统中可用,在类 Unix 系统中如果没有该宏,可以使用 fd = open(“/dev/zero”, O_RDWR) 代替

用法

1
int *p = mmap(NULL, 4, PROT_READ|PROT_WRITE, MAP_SHARED|MAP_ANONYMOUS, -1, 0);
  1. 注意的是 fd 需要配置为-1
  2. mmap 可用于非血缘关系进程通信
  3. 使用 memcpy() 函数可以拷贝结构体
1
memcp(map, $student, sizeof(student));