
目录
4.1.1 Unix Domain Socket (UDS)
4.5.1.3 shmat()函数——将共享内存段附加到进程的地址空间
4.5.2.1 shm_open()函数——创建或打开一个共享内存对
4.5.2.2 shm_unlink()函数——删除共享内存
4.5.2.3 truncate()和ftruncate()——将文件缩放到指定大小
4.5.2.4 mmap()函数——将一组设备或者文件映射到内存地址
4.6.1.4 msgctl函数()——控制操作(删除、获取信息等)
4.6.2.1 mq_open()函数——打开/创建消息队列
4.6.2.2 mq_timedsend()函数——发送消息
4.6.2.3 mq_timedreceive()——接收消息
1. 进程
1.1 什么是程序
程序(Program)是静态的文件(比如电脑里的QQ.exe、手机里的微信.apk),只包含代码和数据,存储在硬盘上,不占用 CPU、内存等运行资源,没有任何执行的概念。
1.2 什么是进程
在计算机科学和操作系统领域,进程(Process) 是操作系统进行资源分配和调度的基本单位,简单理解就是 “正在运行的程序”—— 但它并非程序本身,而是程序在计算机中的一次动态执行过程,包含了程序代码、运行数据、内存状态、CPU 寄存器信息等完整上下文。
我们来做个比喻,如果把一个可执行程序(比如一个.exe文件)看作是一张菜谱,那么进程就是厨师按照这张菜谱实际烹饪的过程。
1.3 进程的组成
一个完整的进程由 4 部分构成,其中进程控制块(PCB) 是核心,是操作系统管理进程的 “身份证”:
| 组成部分 | 核心作用 |
|---|---|
| 进程控制块(PCB) | 存储进程的所有关键信息,供操作系统调度和管理,比如:进程 ID(PID)、状态、CPU 寄存器值、内存地址、优先级等。 |
| 程序代码(Text) | 进程要执行的指令集合(即加载到内存的程序本身)。 |
| 数据段(Data) | 进程运行时使用的数据(包括全局变量、静态变量、常量等,分为初始化数据和未初始化数据)。 |
| 资源(Resources) | 操作系统分配给进程的资源,如内存空间、CPU 时间片、打开的文件、网络连接、I/O 设备句柄等。 |
1.4 进程的状态
进程不是一直 “运行” 的,而是在不同状态间切换,典型的生命周期包含 5 种核心状态,操作系统通过调度算法决定状态转换:
| 状态名称 | 含义 | 转换触发条件(示例) |
|---|---|---|
| 创建态(New) | 进程刚被创建,还未被操作系统纳入管理(比如双击程序后,系统正在加载代码到内存)。 | 加载完成 → 就绪态。 |
| 就绪态(Ready) | 进程已具备运行条件,但 CPU 正被其他进程占用,等待分配 CPU 时间片。 | CPU 空闲 → 运行态;有更高优先级进程进入 → 仍就绪。 |
| 运行态(Running) | 进程正在占用 CPU,执行指令(同一时刻,一个 CPU 核心只能有 1 个进程处于运行态)。 | 时间片用完 → 就绪态;需要等待 I/O(如读文件) → 阻塞态。 |
| 阻塞态(Blocked/Waiting) | 进程因等待某个事件(如 I/O 完成、信号量)而暂时无法运行,即使有 CPU 也不能执行。 | 等待的事件完成(如文件读完) → 就绪态。 |
| 消亡态(Terminated) | 进程执行完成(如关闭软件)或异常终止(如崩溃),资源被操作系统回收。 | 进程结束 → 资源回收(PCB 被删除)。 |
在Linux系统中,进程分为用户进程、守护进程、批处理进程:
- 用户进程:也成终端进程,用户通过终端命令启用的进程。
- 守护进程:也成精灵进程,即运行的守护进程,在系统引导时间就启动,是后台服务进程,大多数服务进程都是通过守护进程实现的。
- 批处理进程:执行的是批处理文件、shell脚本。
下面我们来了解一下进程的处理机制,为了方便演示我们先创建一个文件用来存放后续代码:
2. 启动进程
2.1 使用system函数生成子进程
我们先找到其函数原型:
/* Execute the given line as a shell command.
This function is a cancellation point and therefore not marked with
__THROW. */
extern int system (const char *__command) __wur;
其中参数 const char *__command ,表示使用Linux命令直接创建一个子进程,其返回值:
- 返回值为-1,表示错误;
- 返回值为0,表示调用成功但没有出现子进程;
- 返回值大于0,比哦啊是成功退出子进程ID;
- 如果调用/bin/sh时失败,则返回127,若参数 command 为空指针(NULL),则返回非零值。
我们创建一个system_test.c文件验证一下,首先我们创建一个变量sysR用来接收函数返回值,通过返回值,来判断函数:
#include<stdio.h>
#include<stdlib.h>
int main(int argc, char const *argv[])
{
//使用标准库函数创建子进程
int sysR = system("ping -c 5 www.baidu.com");
if (sysR != 0)
{
perror("system");
exit(EXIT_FAILURE);
}
return 0;
}
然后创建一个Makefile进行编译:
CC := gcc
system_test :system_test.c
-$(CC) -o $@ $^
-./$@
-rm ./$@
运行看一下,能够正常运行:
我们把ping百度的次数延长一下,通过终端 ps -ef 看一下进程管理:
可以看到system创建一个子进程,在子进程中执行command参数指定命令,父进程等待子进程执行完毕执行返回值。
感觉这个效果不明显,那就换一条命令 ls 这是我们常用的命令,我们把代码更改一下,这次如果命令打印完成,打印返回值:
#include<stdio.h>
#include<stdlib.h>
int main(int argc, char const *argv[])
{
int ret;
printf("执行ls命令(列出当前目录文件):\n");
ret = system("ls");
if (ret == -1)
{
printf("创建子进程失败\n");
return 1;
}
else
{
printf("命令执行完毕,返回值:%d\n\n", ret);
}
return 0;
}
因为这个文件保存在process_test文件下,因此执行命令后文件如下:
此时我们来到终端打印会发现:
那是因为打印文件,实在rm之前,我们后面通过rm将system_test删除了,我们可以现将rm这段注释掉看一下:
2.2 fork分叉函数调用
2.2.1 fork函数基础用法
fork创建一个新的进程,原进程称为父进程,新的进程称为子进程。fork创建进程后,函数在子进程返回0值,在父进程中返回子进程的PID。两个进程都有自己的数据段、BSS段、栈、堆等资源,父进程间不共享这些存储空间,而代码段为父进程和子进程共享。父进程和子进程各自从fork函数后开始执行代码,在创建子进程后,子进程复制了父进程打开的文件描述符,但是不复制文件锁。子进程未处理的闹钟定时被清除,子进程不继承父进程的未决信号集。
函数表头文件:
#include<unistd.h>
函数原型:
/* Clone the calling process, creating an exact copy.
Return -1 for errors, 0 to the new process,
and the process ID of the new process to the old process. */
extern __pid_t fork (void) __THROWNL;
返回值,如果失败,则返回-1,如果成功,则返回进程PID。
查看进程ID的函数:
pid_t getpid(void);
pid_t getppid(void);
我们开始编写一个代码看看,首先创建一个fork_test.c文件用来存放我们后续工程,我们先来查看一下当前进程:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
int main(int argc, char const *argv[])
{
printf("当前进程的ID为:%d\n",getpid());
return 0;
}
编写一个Makefile文件:
fork_test :fork_test.c
-$(CC) -o $@ $^
-./$@
-rm ./$@
可以看到此时的进程ID:
我们如果想要通过终端命令查看当前进程,则需要再主函数添加一个 while() 循环,不要让进程结束过快,否则看不见:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
int main(int argc, char const *argv[])
{
printf("当前进程的ID为:%d\n",getpid());
while(1)
{
}
return 0;
}
我们上面知道,fork创建一个新的进程,原进程称为父进程,新的进程称为子进程。fork创建进程后,函数在子进程返回0值,在父进程中返回子进程的PID:
#include<stdio.h> // 标准输入输出
#include<stdlib.h> // 标准库函数
#include<unistd.h> // Unix 标准函数(包含 fork(), getpid(), getppid())
#include <sys/types.h> // 系统类型定义(包含 pid_t)
int main(int argc, char const *argv[])
{
printf("当前进程的ID为:%d\n",getpid());
pid_t pid = fork();
if(pid == -1)
{
printf("子进程创建失败!\n");
return 1;
}
else if(pid == 0)//这里的代码都是新的子进程的
{
sleep(1); // 让父进程先执行
printf("子进程创建创建成功%d,它的父进程为%d\n",getpid(),getppid());
}
else//这里的代码都是父进程的
{
sleep(2); // 等待子进程执行
printf("我是父进程%d,我创建的子进程为%d\n",getpid(),pid);
}
return 0;
}
这里需要注意,代码中需要加入sleep进行一个延时,如上述,子进程在休眠1秒期间,父进程有足够时间执行并退出,子进程醒来时父进程还在,所以能正确显示父进程ID。
如果不加延时,或者父进程比子进程执行过快,就会出现,父进程执行太快,在子进程输出之前就退出了,子进程变成了"孤儿进程",被 init/systemd 进程收养:
孤儿进程会被其祖先自动领养。此时的子进程因为和终端切断了联系,所以很难再进行标准输入使其停止了,所以写代码的时候一定要注意避免出现孤儿进程。
我们也可以通过更改延时看一下:
我们可以创建一个for循环用来打印数据,去观察父进程和子进程的并发执行:
#include<stdio.h> // 标准输入输出
#include<stdlib.h> // 标准库函数
#include<unistd.h> // Unix 标准函数(包含 fork(), getpid(), getppid())
#include <sys/types.h> // 系统类型定义(包含 pid_t)
int main(int argc, char const *argv[])
{
printf("当前进程的ID为:%d\n",getpid());
pid_t pid = fork();
if(pid == -1)
{
printf("子进程创建失败!\n");
return 1;
}
else if(pid == 0)//这里的代码都是新的子进程的
{
int i,a=5;
for(i = 0;i < 5;i++)
{
printf("son: %d\n",i);
sleep(1); // 让父进程先执行
}
printf("子进程创建创建成功%d,它的父进程为%d\n",getpid(),getppid());
}
else//这里的代码都是父进程的
{
int i,a=10;
for(i = 5;i<a;i++)
{
printf("father: %d\n",i);
sleep(2); // 等待子进程执行
}
printf("我是父进程%d,我创建的子进程为%d\n",getpid(),pid);
}
return 0;
}
运行结果:
我们可以将上述结果拆分如下分析:
时间(秒) 父进程(PID:9542) 子进程(PID:9543)
--------------------------------------------------
t=0 father: 5 son: 0
t=1 (休眠中) son: 1
t=2 father: 6 son: 2
t=3 (休眠中) son: 3
t=4 father: 7 son: 4
t=5 (休眠中) 子进程创建成功...
t=6 father: 8 (子进程结束)
t=7 (休眠中)
t=8 father: 9
t=9 我是父进程...
更加正确的做法应当调用waitpid函数进行等待子进程的结束,而不是一味的通过时间进行判断,如:
#include<stdio.h> // 标准输入输出
#include<stdlib.h> // 标准库函数
#include<unistd.h> // Unix 标准函数(包含 fork(), getpid(), getppid())
#include <sys/types.h> // 系统类型定义(包含 pid_t)
#include <sys/wait.h> // 添加 waitpid 所需的头文件
int main(int argc, char const *argv[])
{
printf("当前进程的ID为:%d\n",getpid());
pid_t pid = fork();
if(pid == -1)
{
printf("子进程创建失败!\n");
return 1;
}
else if(pid == 0)//这里的代码都是新的子进程的
{
int i,a=5;
for(i = 0;i < 5;i++)
{
printf("son: %d\n",i);
sleep(1); // 让父进程先执行
}
printf("子进程创建创建成功%d,它的父进程为%d\n",getpid(),getppid());
}
else // 父进程
{
int i, a = 10;
for(i = 5; i < a; i++)
{
printf("father: %d\n", i);
sleep(2);
}
printf("我是父进程%d,我创建的子进程为%d\n", getpid(), pid);
// 等待子进程结束
int status;
waitpid(pid, &status, 0);
printf("子进程 %d 已结束\n", pid);
}
return 0;
}
这样做的好处就是可以防止孤儿进程的产生,假如我们将子进程的时间延长,父进程的时间缩短,如果不通过waitpid等待子进程结束,那么自己成就会变成孤儿进程:
而如果我们加上,父进程需要等待子进程结束而结束:
对于waitpid函数文章后续会详细解释,这里只是为了了解fork的调用,我们上面通过打印 i 和 a 大致可以看出进程间是并发执行的,我们下面再来看一下另一个点,两个进程都有自己的数据段、BSS段、栈、堆等资源,父进程间不共享这些存储空间,而代码段为父进程和子进程共享,这一点我们要怎么理解呢?我们在对上面的代码进行一个简单的修改,声明一个全局变量 count,为了方便观察现象,将父进程的延时在延长一点,通过for循环在父子进程中各自累加看看会是什么结果:
#include<stdio.h> // 标准输入输出
#include<stdlib.h> // 标准库函数
#include<unistd.h> // Unix 标准函数(包含 fork(), getpid(), getppid())
#include <sys/types.h> // 系统类型定义(包含 pid_t)
#include <sys/wait.h> // 添加 waitpid 所需的头文件
int main(int argc, char const *argv[])
{
printf("当前进程的ID为:%d\n",getpid());
int i,count = 1;
pid_t pid = fork();
if(pid == -1)
{
printf("子进程创建失败!\n");
return 1;
}
else if(pid == 0)//这里的代码都是新的子进程的
{
for(i = 0;i < 9;i++)
{
count++;
printf("son: %d\n",count);
sleep(1); // 让父进程先执行
}
printf("子进程创建创建成功%d,它的父进程为%d\n",getpid(),getppid());
}
else // 父进程
{
for(i = 0; i < 3; i++)
{
count++;
printf("father: %d\n", count);
sleep(3);
}
printf("我是父进程%d,我创建的子进程为%d\n", getpid(), pid);
// 等待子进程结束
int status;
waitpid(pid, &status, 0);
printf("子进程 %d 已结束\n", pid);
}
return 0;
}
可以看出,当 fork() 创建子进程后,此时父子进程各有独立的 count 变量副本,初始值都是 1,父进程和子进程各自累计自己的count的值,没有共享存储空间:
2.2.2 vfork函数
vfork相较于fork的主要区别是fork要复制父进程的数据段;而vfork则不需要完全复制父进程的数据段,子进程与父进程共享数据段。
fork不对父进程的执行次序进行限制,但是vfork需要子进程先运行、父进程挂起。
我们对上面代码更改一下,将fork改为vfork:
#include<stdio.h> // 标准输入输出
#include<stdlib.h> // 标准库函数
#include<unistd.h> // Unix 标准函数
#include<sys/types.h> // 系统类型定义
#include<sys/wait.h> // waitpid 头文件
int main(int argc, char const *argv[])
{
printf("当前进程的ID为:%d\n",getpid());
int i, count = 0;
// 使用 vfork() 替代 fork()
pid_t pid = vfork();
if(pid == -1)
{
printf("子进程创建失败!\n");
return 1;
}
else if(pid == 0) // 子进程
{
// 警告:vfork() 子进程与父进程共享内存空间!
// 修改 count 会影响父进程的 count 值
for(i = 0; i < 5; i++)
{
count++; // 这会修改父进程的 count 变量!
printf("son: %d\n", count);
sleep(1);
}
printf("子进程创建成功%d,它的父进程为%d\n", getpid(), getppid());
// vfork() 子进程必须使用 _exit(),不能使用 return
_exit(0);
}
else // 父进程
{
// 在 vfork() 中,父进程会等待子进程结束后才执行到这里
// 注意:此时 count 已经被子进程修改过了!
for(i = 0; i < 5; i++)
{
count++; // 在子进程修改的基础上继续增加
printf("father: %d\n", count);
sleep(1);
}
printf("我是父进程%d,我创建的子进程为%d\n", getpid(), pid);
// 在 vfork() 中,由于子进程已用 _exit() 退出,通常不需要 waitpid()
// 但为了代码清晰,可以保留
int status;
waitpid(pid, &status, 0);
printf("子进程 %d 已结束\n", pid);
}
return 0;
}
此时运行发现子进程先进行累加,然后父进程才进行运行,并且父子进程的数据是共享的:
| 特性 | fork() | vfork() |
|---|---|---|
| 内存复制 | 写时复制(Copy-on-Write) | 不复制,共享父进程内存空间 |
| 执行顺序 | 父子进程并发执行 | 子进程先执行,父进程阻塞等待 |
| 性能 | 相对较慢(需要设置页表) | 很快(几乎不消耗资源) |
| 安全性 | 安全,进程隔离 | 危险,可能破坏父进程状态 |
| 使用场景 | 通用进程创建 | 后接 exec() 族函数 |
| 现代系统 | 推荐使用 | 已过时,不推荐使用 |
2.2.3 使用fork复制文件描述符
我们重新创建一个.c文件,先将我们需要使用的头文件全部引入,然后调用open函数创建一个.txt文件,open返回的文件描述符为 fd:
#include<stdio.h> // 标准输入输出
#include<stdlib.h> // 标准库函数
#include<unistd.h> // Unix 标准函数(包含 fork(), getpid(), getppid())
#include<sys/types.h> // 系统类型定义(包含 pid_t)
#include<fcntl.h> // 文件控制选项
#include <sys/stat.h> // 文件状态信息
int main(int argc, char const *argv[])
{
int fd = open("io.txt",O_CREAT | O_WRONLY | O_APPEND ,0644);
if(fd == -1)
{
printf("打开文件失败\n");
perror("open");
exit(EXIT_FAILURE);
}
return 0;
}
对于open函数的调用,不熟悉可以了解:
然后在Makefile中编写代码:
fork_fd_test :fork_fd_test.c
-$(CC) -o $@ $^
-./$@
-rm ./$@
运行后发现创建一个 io.txt 文件:
调用fork函数创建子进程:
pid_t pid = fork();
if(pid == -1)
{
printf("子进程创建失败!\n");
perror("fork");
exit(EXIT_FAILURE);
}
else if(pid == 0)
{
printf("子进程开始写入数据······\n");
/*子进程需要写入的数据*/
}
else
{
printf("父进程开始写入数据······\n");
/*父进程需要写入的数据*/
}
我们可以通过strcpy函数进行数据复制,我们可以将想要写入的数据写入到缓冲区去,然后通过writer进行写入到io.txt文件当中:
#include<stdio.h> // 标准输入输出
#include<stdlib.h> // 标准库函数
#include<unistd.h> // Unix 标准函数
#include<sys/types.h> // 系统类型定义
#include<fcntl.h> // 文件控制选项
#include <sys/stat.h> // 文件状态信息
#include <string.h> // 字符串操作
#include <sys/wait.h> // 进程等待
int main(int argc, char const *argv[])
{
int fd = open("io.txt", O_CREAT | O_WRONLY | O_APPEND, 0644);
if(fd == -1)
{
printf("打开文件失败\n");
perror("open");
exit(EXIT_FAILURE);
}
char buffer[1024];
pid_t pid = fork();
if(pid == -1)
{
printf("子进程创建失败!\n");
perror("fork");
close(fd);
exit(EXIT_FAILURE);
}
else if(pid == 0)
{
// 子进程
printf("子进程开始写入数据······\n");
strcpy(buffer, "这是子进程写入的数据!\n");
ssize_t bytes_write = write(fd, buffer, strlen(buffer));
if (bytes_write == -1)
{
perror("子进程write失败");
} else {
printf("子进程写入数据成功,写入 %zd 字节\n", bytes_write);
}
close(fd);
printf("子进程写入完毕,并释放文件描述符\n");
exit(EXIT_SUCCESS); // 子进程明确退出
}
else
{
// 父进程
printf("父进程开始写入数据······\n");
strcpy(buffer, "这是父进程写入的数据!\n");
ssize_t bytes_write = write(fd, buffer, strlen(buffer));
if (bytes_write == -1)
{
perror("父进程write失败");
} else {
printf("父进程写入数据成功,写入 %zd 字节\n", bytes_write);
}
// 等待子进程结束
int status;
waitpid(pid, &status, 0);
printf("子进程已结束,状态: %d\n", status);
close(fd);
printf("父进程写入完毕,并释放文件描述符\n");
}
return 0;
}
我们运行看一下:
根据上述结果我们可以看出,子进程复制了父进程的文件描述符fd,二者指向的应是同一个底层文件描述(struct file结构体)。我们思考一个问题,子进程通过close()释放文件描述符之后,父进程对于相同的文件描述符执行write()操作仍然成功了。这是为什么?
struct file结构体中有一个属性为引用计数,记录的是与当前struct file绑定的文件描述符数量。close()系统调用的作用是将当前进程中的文件描述符和对应的struct file结构体解绑,使得引用计数减一。如果close()执行之后,引用计数变为0,则会释放struct file相关的所有资源。
我们通过图示来解释一下,最开始的时候,父进程open创建一个文件,其地址指向如下,此时引用计数为1:
当我们使用fork后会创建一个子进程,其地址也是执行图示:
此时的文件描述,引用计数就会变为2:
因此虽然我们子进程调用close使引用计数减1,并不会马上关闭文件,灯父进程也调用close后才会正式清零关闭:
拓展:打印错误perror
我们创建一个简单的函数,演示一下其功能,通过文件IO打开一个本就不存在的文件,其会发生报错,我们可以通过perror将错误打印下来:
#include <stdio.h>
int main(int argc, char const *argv[])
{
/**
* 输出错误信息报告到系统的错误输出
* char *__s: 自定义的错误信息前缀 会打印在输出的前面 中间补充": " 后面跟errno
* 隐藏参数: errno 用于保存错误信息的全局变量 系统调用和库函数出错会将信息存储到这里
* void perror (const char *__s)
*/
fopen("wobucunzai.txt","r");//只读打开一个本就不存在的文件
perror("fopen");
return 0;
}
Makefile文件:
cc := gcc
perror_test : perror_test.c
-$(cc) -o $@ $^
-./$@
-rm ./$@
可以发现将错误打印出来:
这是因为,当系统调用或库函数发生错误时,通常会通过设置全局变量errno来指示错误的具体原因。errno是在C语言(及其在Unix、Linux系统下的应用)中用来存储错误号的一个全局变量。每当系统调用或某些库函数遇到错误,无法正常完成操作时,它会将一个错误代码存储到errno中。这个错误代码提供了失败的具体原因,程序可以通过检查errno的值来确定发生了什么错误,并据此进行相应的错误处理。
- errno:定义在头文件<errno.h>中,引入该文件即可调用全局变量errno。
- perror函数:用于将errno当前值对应的错误描述以人类可读的形式输出到标准错误输出(stderr)。
- 参数s:指向一个字符串的指针,如果s不是空指针且指向的不是\0字符,则perror会在s后添加一个冒号和空格作为前缀,输出错误信息,否则不输出前缀,直接输出错误信息。
#include <stdio.h>
#include <errno.h>
int main(int argc, char const *argv[])
{
/**
* 输出错误信息报告到系统的错误输出
* char *__s: 自定义的错误信息前缀 会打印在输出的前面 中间补充": " 后面跟errno
* 隐藏参数: errno 用于保存错误信息的全局变量 系统调用和库函数出错会将信息存储到这里
* void perror (const char *__s)
*/
fopen("wobucunzai.txt","r");//只读打开一个本就不存在的文件
printf("errno: %d\n", errno);
perror("fopen");
return 0;
}
我们后续也可以直接使用perroer进行打印错误信息。
2.3 execve实现程序跳转
2.3.1 概述
在 Linux 进程控制中,exec 函数族用于替换当前进程的映像,即用新的程序替换当前正在运行的程序,简单来说就是使进程执行另一段程序。exec家族一共有6个函数分别是execl()、execlp()、execle()、execv()、execvp()、execvpe(),他们包含在unistd.h当中,函数声明如下:
/* 替换当前进程,使用参数 ARGV 和环境变量 ENVP 执行 PATH。
ARGV 和 ENVP 以 NULL 指针结束。*/
extern int execve (const char *__path, char *const __argv[],
char *const __envp[]) __THROW __nonnull ((1, 2));
#ifdef __USE_XOPEN2K8
/* 执行 FD 引用的文件,覆盖正在运行的程序映像。
ARGV 和 ENVP 会传递给新程序,如同 `execve`。*/
extern int fexecve (int __fd, char *const __argv[], char *const __envp[])
__THROW __nonnull ((2));
#endif
/* 使用参数 ARGV 和来自 `environ` 的环境变量执行 PATH。*/
extern int execv (const char *__path, char *const __argv[])
__THROW __nonnull ((1, 2));
/* 执行 PATH,使用 PATH 后的所有参数直到 NULL 指针,
以及该指针后的参数作为环境变量。*/
extern int execle (const char *__path, const char *__arg, ...)
__THROW __nonnull ((1, 2));
/* 执行 PATH,使用 PATH 后的所有参数直到 NULL 指针,
以及来自 `environ` 的环境变量。*/
extern int execl (const char *__path, const char *__arg, ...)
__THROW __nonnull ((1, 2));
/* 执行 FILE,如果文件名不包含斜杠则在 `PATH` 环境变量中搜索,
使用参数 ARGV 和来自 `environ` 的环境变量。*/
extern int execvp (const char *__file, char *const __argv[])
__THROW __nonnull ((1, 2));
/* 执行 FILE,如果文件名不包含斜杠则在 `PATH` 环境变量中搜索,
使用 FILE 后的所有参数直到 NULL 指针和来自 `environ` 的环境变量。*/
extern int execlp (const char *__file, const char *__arg, ...)
__THROW __nonnull ((1, 2));
#ifdef __USE_GNU
/* 执行 FILE,如果文件名不包含斜杠则在 `PATH` 环境变量中搜索,
使用参数 ARGV 和来自 `environ` 的环境变量。*/
extern int execvpe (const char *__file, char *const __argv[],
char *const __envp[])
__THROW __nonnull ((1, 2));
#endif
参数:
- path和file分别表示要执行的程序所包含的路径和文件名。
- arg为参数序列,中间用逗号分隔。
- argv为参数列表。
- envp为环境变量列表。
这里我们需要知道一点,上述所说的exec家族实际上都是execve函数的封装,其依赖关系可以参考下图:
exec函数执行的特点:
- exec把当前进程映像替换成新的程序文件,该进程完全由新程序代换,而且新程序从其main函数开始执行
- 因为调用exec并不创建新进程,所以前后的进程ID并未改变。exec只是用另一个新程序替换了当前进程的正文、数据、堆和栈段
- 如果exec函数执行成功,则原程序中的exec之后的代码都不会执行
- exec函数不会关闭原程序打开的文件描述符,除非该文件描述符被设置了类似SOCK_CLOEXEC的属性
只有execve函数是真正意义上的系统调用,其他都是在此基础上经过包装的库函数,exec函数族的作用是根据指定的文件名找到可执行文件,并且它来取代调用进程内容,即exec系列不创建进程,被调用者成为当前进程,同时清除了调用进程,下面我们来使用一下。
2.3.2 执行简单的ls命令
开始先简单使用一下,我们重新创建一个文件命名为execve_example,在此文件夹下创建.c文件,如execve_ls.c,然后编写代码:
#include <unistd.h>
#include <stdio.h>
int main()
{
/**
* exec系列函数 父进程跳转进入一个新进程
* int execve (const char *__path, char *const __argv[], char *const __envp[])
*
* char *__path: 需要执行程序的完整路径名
* char *const __argv[]: 指向字符串数组的指针 需要传入多个参数
* (1) 需要执行的程序命令(同*__path)
* (2) 执行程序需要传入的参数
* (3) 最后一个参数必须是NULL
* char *const __envp[]: 指向字符串数组的指针 需要传入多个环境变量参数
* (1) 环境变量参数 固定格式 key=value
* (2) 最后一个参数必须是NULL
* return: 成功就回不来了 下面的代码都没有意义
失败返回-1
*/
char *argv[] = {"ls", "-l", "-a", NULL};
char *envp[] = {"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin:/home/dky:/snap/bin:/opt/linux-devkit/bin:/home/dky", NULL};
printf("准备执行 ls -l -a...\n");
execve("/bin/ls", argv, envp);
// 如果执行到这里,说明 execve 失败了
perror("execve 失败");
return 1;
}
对于envp需要获取的环境变量,实际上如下即可:
char *envp[] = {"PATH=/bin:/usr/bin", "TERM=xterm", NULL};
至于为什么我们写这么长,主要是,如果你不知道需要哪些环境变量,直接终端输入echo $PATH查询系统PATH环境变量,将所有的都输入:
对于大多数程序,只需要这些基本环境变量:
char *minimal_envp[] = {
"PATH=/bin:/usr/bin", // 命令搜索路径
"TERM=xterm", // 终端类型
"HOME=/home/username", // 家目录
"USER=username", // 用户名
"PWD=/current/directory", // 当前目录
"SHELL=/bin/bash", // 默认shell
NULL
};
然后编写Makefile方便运行代码:
cc := gcc
execve_ls : execve_ls.c
-$(cc) -o $@ $^
-./$@
-rm ./$@
运行一下看看:
来终端看一下,注意终端没有打印出execve_ls文件,那是因为我们在Makefile运行ls -al的时候还没调用rm删除execve_ls,而我们运行完去终端在输入命令查看时,此时已经调用rm删除过了:
拓展:查看当前环境变量
如果想查看全部环境变量,可以通过env命令查看:
如果想查看某一个或者几个,可以进行如下操作:
env | grep -E '^(PATH|HOME)'
对于Linux常用命令的使用这里不在一一介绍,想要详细了解可以参考:
这里解释一下功能,我们上面也演示了,通过 env 打印当前系统的所有环境变量,然后通过管道 | 将 env 的输出作为 grep 的输入,grep 进行文本搜索,通过 ^ 保证值匹配行开头,避免变量值中包含这些词导致误匹配,进行匹配括号里的内容,这里需要注意加上 单引号‘’ 或者双引号“”括起来,否则Shell 会优先解析括号 (),但 HOME|PATH 不是合法的 Shell 命令,Shell 会直接报错,不进行下面操作,如:
2.3.3 程序跳转
我们可以创建两个程序,通过 execve 函数实现程序一执行程序二,注意这里不是创建新进程,而是用新程序替换当前进程,也就是说进程不会改变,我们来验证一下,首先我们来编写一个用来接受传递参数的代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(int argc, char *argv[])
{
//argv至少有想要传递的路径,以及需要传递的参数
//因此argc不能小于2
if (argc < 2)
{
printf("传递参数不足,请重新修改参数\n");
return 1;
}
else
{
printf("接收到的参数为:%s\n当前进程号为:%d\n", argv[1], getpid());
}
return 0;
}
拓展:C标准中的main函数声明
先来看一下无参形式的,通常情况下无参的形式如下两种:
int main();
int main(void);
这两种形式都表示main函数不接收命令行参数。在C99标准之前,main函数没有参数的形式被写为int main(),这在某些情况下可能导致与int main(void)行为不完全相同的问题,因为int main()在老式的C语言标准中不明确指出函数是否接受参数。
从C99标准开始,推荐使用int main(void)明确指明main函数不接受任何参数,以提高代码的可读性和一致性。
对于有参形式,如我们上面编写的代码:
int main(int argc, char *argv[]);
- argc:传递给程序的命令行参数的数量
- argv:指向字符串数组的指针,存储了命令行参数
对于argv通常情况下,argv[0]通常是程序的名称,argv[1]到argv[argc-1]是实际的命令行参数。
char *argv[] = {
"我是自定义程序名", // argv[0] - 可以任意设置
"参数1",
"参数2",
NULL
};
下面编写,调用execve跳转程序传递参数的代码,为了便于观察我们先使用printf打印一下需要传递的参数和当前进程:
char *name = "canshu";
printf("\n将要传递的参数:%s\n当前的进程号:%d\n\n",name,getpid());
然后编写需要传递的参数,我们上面了解到execve跳转,需要跳转路径,然后执行需要执行的程序命令以及程序需要传递的参数:
// 准备跳转到第二个程序
char *argv[] = {
"/home/dky/Linux_task/exec_example/execve_skip", // 要执行的程序路径
name, // 传递的参数
NULL // 参数结束标志
};
char *envs[] = {
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin:/home/dky:/snap/bin:/opt/linux-devkit/bin:/home/dky",
NULL
};
然后将数据写入,加一层判断,如果数据为传输出去打印错误:
int re = execve(argv[0], argv, envs);
// 如果execve成功,下面的代码不会执行
if (re == -1) {
printf("未将数据传递出去\n");
return -1;
}
完整代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
char *name = "canshu";
printf("\n将要传递的参数:%s\n当前的进程号:%d\n\n",name,getpid());
/**
* exec系列函数 父进程跳转进入一个新进程
* int execve (const char *__path, char *const __argv[], char *const __envp[])
*
* char *__path: 需要执行程序的完整路径名
* char *const __argv[]: 指向字符串数组的指针 需要传入多个参数
* (1) 需要执行的程序命令(同*__path)
* (2) 执行程序需要传入的参数
* (3) 最后一个参数必须是NULL
* char *const __envp[]: 指向字符串数组的指针 需要传入多个环境变量参数
* (1) 环境变量参数 固定格式 key=value
* (2) 最后一个参数必须是NULL
* return: 成功就回不来了 下面的代码都没有意义
失败返回-1
*/
// 准备跳转到第二个程序
char *argv[] = {
"/home/dky/Linux_task/exec_example/execve_skip", // 要执行的程序路径
name, // 传递的参数
NULL // 参数结束标志
};
char *envs[] = {
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin:/home/dky:/snap/bin:/opt/linux-devkit/bin:/home/dky",
NULL
};
// 关键操作:进程跳转
int re = execve(argv[0], argv, envs);
// 如果execve成功,下面的代码不会执行
if (re == -1) {
printf("未将数据传递出去\n");
return -1;
}
return 0;
}
编写Makefile代码:
execve_fath: execve_fath.c execve_skip
-$(CC) -o $@ $<
-./$@
-rm ./$@ ./execve_skip
编写完成,可以看到参数发生传递但是进程号并没有发生改变:
除此之外,我们还可以发下一点就是,我们并没有在make当中编写编译execve_skip.c的代码,但是为什么图上会出现编译其的代码呢?
那是因为make又一个非常强大的功能:自动推导。简单来说,我们编写:
execve_fath: execve_fath.c execve_skip
表示 execve_fath 依赖于两个文件:execve_fath.c - 源代码文件和execve_skip - 可执行文件,但是 make 发现 execve_skip 文件不存在时,它会自动寻找生成这个文件的规则。
make不熟悉的可以查看:
当然为了代码的可读性,我们可以添加上:
execve_skip: execve_skip.c
$(CC) -o $@ $^
2.3.4 execve和fork联动使用
根据上面操作,我们先思考一个问题,我们知道如果exec函数执行成功,则原程序中的exec之后的代码都不会执行,那么如果这样我们直接写在一起不就好了,为什么要这样用,这里就需要拿出我们上面提到的fork函数,其可以创建一个子进程,这样我们fork() 负责复制进程,execve() 负责替换程序,两者结合是不是就可以实现了灵活的进程管理和程序执行:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main(void)
{
char *name = "father";
printf("\n父进程当前参数:%s\n当前的进程号:%d\n\n",name,getpid());
__pid_t pid = fork();
if(pid == -1)
{
printf("进程创建失败\n");
}
else if(pid == 0)
{
char *name = "son";
printf("\n子进程将要传递的参数:%s\n当前的进程号:%d\n\n",name,getpid());
// 准备跳转到第二个程序
char *argv[] = {
"/home/dky/Linux_task/exec_example/execve_skip", // 要执行的程序路径
name, // 传递的参数
NULL // 参数结束标志
};
char *envs[] = {
"PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin:/home/dky:/snap/bin:/opt/linux-devkit/bin:/home/dky",
NULL
};
// 关键操作:进程跳转
int re = execve(argv[0], argv, envs);
// 如果execve成功,下面的代码不会执行
if (re == -1) {
printf("未将数据传递出去\n");
return -1;
}
}
else
{
printf("父进程当前参数:%d",getpid());
}
return 0;
}
拓展:进程树
我们这里在来了解一个概念:进程树。
Linux 进程树描述了系统中所有进程之间的父子关系,形成一个树状结构。在这个树中:
- 根节点:所有进程的祖先是 systemd(在现代系统中)或 init 进程,其 PID 为 1。
- 父进程:创建新进程的进程。
- 子进程:被创建的进程。
- 进程关系:当一个进程创建新进程时,它们形成父子关系,最终构成整个系统的进程树。
我们在上述代码中,对else的数据进行修改:
else
{
printf("\n父进程当前进程号:%d\n",getpid());
//等待子进程结束后,手动随便输入一个数据,回车结束
char bye = fgetc(stdin);
}
然后来到终端,输入:
ps -ef
查看进程号:
下滑找到对应进程:
我们上面也说了,所有进程的祖先是 systemd,也就是PID等于1,我们不断上翻看一下,如果不想上翻,可以通过管道命令查看:
ps -ef | grep 想要查询的PID
如:
ps -ef | grep 6216
继续往上:
可以发现此时的父进程已经为1:
这里需要注意一点,pid为1的进程和pid为2的内核线程,它们的父进程id均为0,这是因为二者都是由内核直接创建,都是“祖先”,不存在“辈分”更大的进程或内核线程。
如果我们不想这么麻烦可以通过pstree查看进程树:
通过-p显示进程号:
3. 等待进程
在我们上面介绍fork的时候,为了防止子进程结束时父进程已经结束,使用了sleep函数等待了一段时间,但是这样是不可取的,因为我们上面只是一个简单的演示没有什么过于繁杂的工程,而如果我们实际项目中,你并不能准确的把握父子进程结束的时间,可能因此而产生孤儿进程,所以这里引入了 wait() 和 waitpid() 函数,用于让父进程等待子进程结束,其二者的作用是一样的,在Linux内部实现 wait() 函数是直接调用的就是 waitpid() 函数,可以看做 wait() 函数只是 waitpid() 函数的特例。
3.1 wait()函数
表头文件:
#include<sys/wait.h>
函数声明:
/* Wait for a child to die. When one does, put its status in *STAT_LOC
and return its process ID. For errors, return (pid_t) -1.
This function is a cancellation point and therefore not marked with
__THROW. */
extern __pid_t wait (int *__stat_loc);
当wait()被调用的时候,系统会暂停父进程的执行,直到信号来到或者子进程结束,如果在调用wait()的时候子进程已经结束,则会立即返回子进程结束状态值。子进程的结束状态信息会有参数status返回,与此同时该函数会返回子进程的PID,它通常是已经结束运行的子进程的PID。
注意:wait()和fork()是配套出现的,且wait在fork调用之后,如果调用之前则会返回-1,正常情况下,返回子进程的PID;
3.2 waitpid()函数
表头文件:
#include<sys/wait.h>
函数声明,这里简单翻译了一下:
/* 等待一个与PID匹配的子进程终止。
如果PID大于0,则匹配任何进程ID为PID的进程。
如果PID为(pid_t) -1,则匹配任意进程。
如果PID为(pid_t) 0,则匹配与当前进程具有相同进程组的任何进程。
如果PID小于-1,则匹配任何进程组为PID绝对值的进程。
如果在OPTIONS中设置了WNOHANG位,且该子进程尚未终止,则返回(pid_t) 0。
若成功,返回PID并将已终止子进程的状态存入STAT_LOC。
错误时返回(pid_t) -1。
如果在OPTIONS中设置了WUNTRACED位,则返回处于停止状态子进程的状态;否则不予返回。
此函数是一个取消点,因此未使用__THROW标记。*/
extern __pid_t waitpid (__pid_t __pid, int *__stat_loc, int __options);
我们来实际使用一下,使用fork创建一个子进程,子进程多次ping百度,父进程不进行时间设置:
#include<stdio.h>
#include<stdlib.h>
#include <unistd.h>
#include<sys/wait.h>
#include<sys/types.h>
int main(int argc, char const *argv[])
{
int sum;
pid_t pid = fork();
if(pid < 0)
{
perror("fork");
return 1;
}
else if(pid == 0)
{
char *args[] = {"/usr/bin/ping","-c","10","www.baidu.com",NULL};
char *envs[] = {NULL};
printf("子进程:%d想要连接十次百度\n",getpid());
int exR = execve(args[0],args,envs);
if(exR < 0)
{
perror("execve");
return 1;
}
}
else
{
printf("父进程:%d正在等待子进程:%d\n",getpid(),pid);
/**
* pid:等待的模式
* (1)小于-1,例如 -1 * pgid,则等待进程组ID等于pgid的所有进程终止
* (2)等于-1,会等待任何子进程终止,并返回最先终止的那个子进程的进程ID -> 儿孙都算
* (3)等于0,等待同一进程组中任何子进程终止(但不包括组领导进程) -> 只算儿子
* (4)大于0,仅等待指定进程ID的子进程终止
* wstatus: 整数指针,子进程返回的状态码会保存到该int
* options: 选项的值是以下常量之一或多个的按位或(OR)运算的结果;二进制对应选项,可多选
* (1) WNOHANG 如果没有子进程终止,也立即返回;用于查看子进程状态而非等待
* (2) WUNTRACED 收到子进程处于收到信号停止的状态,也返回
* (3) WCONTINUED(自Linux 2.6.10起)如果通过发送SIGCONT信号恢复了一个已停止的子进程,则也返回
* return: (1) 成功等到子进程停止 返回pid
* (2) 没等到并且没有设置WNOHANG 一直等
* (3) 没等到设置WNOHANG 返回0
* (4) 出错返回-1
*/
waitpid(pid,&sum,0);
printf("父进程等待子进程结束\n");
}
return 0;
}
可以看到等子进程ping百度结束后,才打印“父进程等待子进程结束”,期间被waitpid阻塞在上条命令:
4. 进程通信
4.1 概述
进程间通信(IPC)是操作系统和多进程编程中的核心概念。由于每个进程都有自己独立的虚拟地址空间,由一个进程创建的数据对于另一个进程通常是不可见的,因此必须通过这种由操作系统提供的特殊机制来交换数据。
4.1.1 Unix Domain Socket (UDS)
这是在同一台主机上进行进程间通信的一种高效方式。虽然名为“Socket”,但它与网络Socket不同,它不需要经过网络协议栈,数据不会出主机,只是将数据从一个进程的缓冲区复制到另一个进程的缓冲区。
其特点:
-
高性能:比基于网络的TCP/IP Socket要快得多。
-
支持多种通信模式:流式(SOCK_STREAM,类似TCP,可靠)和数据报式(SOCK_DGRAM,类似UDP,不可靠)。
-
可以传递文件描述符和进程凭证(如PID),这是其他一些IPC机制不具备的强大功能。
-
面向连接(流式)或无连接(数据报式)。
主要适用于需要类似网络通信模型(如客户端/服务器模型)但又在本机进行的进程间通信。例如,数据库服务、图形界面程序与后台进程的通信。
4.1.2 管道
无名管道
也叫匿名管道,一种最基本的IPC机制,由pipe()系统调用创建。它提供一个单向数据流——一端用于写入,另一端用于读取。
需要注意,无名管道,是单向通信的,且只能在具有亲缘关系的进程之间使用,通常是在一个父进程fork()出子进程后,父子进程之间进行通信,并且其生命周期随进程的结束而结束。
主要用于,Shell中的命令管道(ls | grep "test")就是典型的无名管道应用。
有名管道
也称为命名管道,通过mkfifo命令或系统调用创建。它在文件系统中有一个路径名,就像一个特殊的文件。
其也是单向通信,和无名管道不同的是,有名管道允许无亲缘关系的进程之间通信,因为任何进程只要知道这个“文件名”,都可以打开它进行读写,并且其生命周期是持久的,直到被显式删除。
主要适用于,需要在不相关的进程之间进行简单的、单向数据流传输的场景。
4.1.3 共享内存
这是最快的IPC方式。它让多个进程将同一块物理内存映射到它们各自的虚拟地址空间。这样,一个进程写入的数据,另一个进程立刻就能看到。
特点:
-
极高性能:因为数据不需要在进程间复制,而是直接对同一块内存进行操作。
-
需要同步机制:由于共享内存本身没有提供同步,多个进程同时读写会导致数据混乱。因此必须配合使用信号量、互斥锁等同步机制。
-
生命周期与内核一致,除非显式删除,否则会一直存在。
主要适用于,对性能要求极高的场景,如大型数据处理、科学计算、图形图像处理等。
4.1.4 消息队列
一个由内核维护的消息链表。进程可以向队列中写入消息或从队列中读取消息。每个消息都是一个数据块,并且有特定的类型。
特点:
-
面向消息,数据有边界。
-
异步通信:写入者可以写入后立刻返回,不需要等待读取者。
-
支持优先级:消息可以赋予类型/优先级,读取时可以按类型读取,而不一定是严格的FIFO。
-
生命周期与内核一致
主要适用于,需要按特定顺序或优先级处理消息,且不希望进程间有紧密耦合的场景。
4.1.5 信号量
它本身不用于传递数据,而是作为一种同步原语,用于协调多个进程(或线程)对共享资源(如共享内存)的访问,防止出现竞态条件。
特点:
-
是一个计数器,用于控制多个进程对共享资源的访问。
-
提供
P(等待,信号量减一)和V(发送,信号量加一)两种原子操作。 -
通常与共享内存配合使用。
主要作为其他IPC机制(尤其是共享内存)的“保镖”,实现进程间的互斥与同步。
4.1.6 总结对比
| 机制 | 数据传输 | 亲缘关系要求 | 性能 | 关键特点 |
|---|---|---|---|---|
| 无名管道 | 单向字节流 | 必须 | 较低 | 简单,用于父子进程 |
| 有名管道 | 单向字节流 | 不需要 | 较低 | 有文件名,用于不相关进程 |
| 消息队列 | 有格式的消息 | 不需要 | 中等 | 异步,支持优先级,内核持久 |
| 共享内存 | 直接内存访问 | 不需要 | 最高 | 需要同步,数据无需复制 |
| Unix Domain Socket | 字节流/数据报 | 不需要 | 高 | 功能全面,可传递文件描述符 |
| 信号量 | 无(仅同步) | 不需要 | 高 | 协调者,用于互斥与同步 |
4.2 发展(了解一下即可)
4.2.1 System V
System V(读作System Five)是一种基于UNIX的操作系统版本,最初由AT&T(American TelePhone and Telegraph Company,美国电话电报公司,由Bell TelePhone Company发展而来)开发。它在1983年首次发布,对UNIX操作系统的发展产生了深远的影响。SystemV引入了许多新的特性和标准,后来被许多UNIX系统和类UNIX系统(如Linux)采纳。
4.2.2 System V IPC
System V IPC(Inter-Process Communication,进程间通信)是System V操作系统引入的一组进程间通信机制,包括消息队列、信号量和共享内存。这些机制允许不同的进程以一种安全且高效的方式共享数据和同步操作。
- 消息队列:允许进程以消息的形式交换数据,这些消息存储在队列中,直到它们被接收。
- 信号量:主要用于进程间的同步,防止多个进程同时访问相同的资源。
- 共享内存:允许多个进程访问同一块内存区域,提供了一种非常高效的数据共享方式。
System V IPC是UNIX和类UNIX系统中常用的IPC方法之一,它通过关键字(key)来标识和访问IPC资源。
4.2.3 POSIX IPC
POSIX IPC是POSIX标准中的一部分,提供了一种更现代和标准化的进程间通信方式,同样包括消息队列、信号量和共享内存三种方式。
- 消息队列:类似于System V,但通常具有更简洁的API和更好的错误处理能力。
- 信号量:提供了更多的功能和更高的性能,支持更大范围的操作。
- 共享内存:提供了更多的控制和配置选项,以支持更复杂的应用场景。
POSIX IPC 使用名字(name)作为唯一标识。这些名字通常是以正斜杠(/)开头的字符串,用于唯一地识别资源如消息队列、信号量或共享内存对象
4.2.4 总结
System V IPC和POSIX IPC在功能上有所重叠,但它们在实现和API设计上有明显的区别。
POSIX IPC通常被视为更现代、更标准化的解决方案,提供了更好的跨平台支持和更易于使用的API。
System V IPC在历史上更早地被大量UNIX系统所采用,因此在一些旧的或特定的环境中仍然非常重要。在选择使用哪种IPC机制时,应考虑应用程序的具体需求、目标系统的支持程度以及开发者的熟悉程度。
System V IPC和POSIX IPC各自提供了一组API,如果全部介绍未免太过冗长,他们实现的效果是类似的。本文只介绍POSIX IPC提供的API。
4.3 Unix Domain Socket (UDS)
这是在同一台主机上进行进程间通信的一种高效方式。虽然名为“Socket”,但它与网络Socket不同,它不需要经过网络协议栈,数据不会出主机,只是将数据从一个进程的缓冲区复制到另一个进程的缓冲区。
这个回头和网络编程一起,会发到下面博客当中:
4.4 管道通信
管道是Linux中最早支持的IPC机制,是一个链接两个进程的连接器,它实际上是在进程间开辟一个固定大小的缓冲区,需要发布信息的进程运行写操作,需要接收信息的进程运行读操作。管道是半双工的,输入输出原则是先入先出FIFO(First In First On)。
为什么要使用管道呢?举个例子,之前我们在进行fork介绍的时候知道,fork创建的子进程和父进程数据是不互通的,如:
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char const *argv[])
{
pid_t cpid = fork();
int sum = 0;
if(cpid < 0)
{
perror("fork");
return 1;
}
else if (cpid == 0)
{
sum = 1;
printf("子进程%d此时sum的数据为:%d\n",getpid(),sum);
}
else
{
sum = 10;
sleep(1);
printf("父进程%d此时sum的数据为:%d\n",getpid(),sum);
}
return 0;
}
可以发现,父子进程间各自赋值,并不影响到对方的值,那么如果我们想要进程数据间的传递怎么办呢?
我么就可以使用管道进程传递,下面我们来试用一下。
4.4.1 无名管道(匿名管道,Pipe)
也叫匿名管道,由pipe()系统调用创建。它提供一个单向数据流——一端用于写入,另一端用于读取。只能在具有亲缘关系的进程之间使用,通常是在一个父进程fork()出子进程后,父子进程之间进行通信,并且其生命周期随进程的结束而结束。
表头文件:
#include <unistd.h>
函数定义:
/* Create a one-way communication channel (pipe).
If successful, two file descriptors are stored in PIPEDES;
bytes written on PIPEDES[1] can be read from PIPEDES[0].
Returns 0 if successful, -1 if not. */
extern int pipe (int __pipedes[2])
对于其参数__pipedes[2],可以将其看做文件描述符,有函数填写数据,其中pipedes[0]用于管道的read端,pipedes[1]用于管道的writer端。如果返回值为0表示成功,如果返回值为-1,则表示失败。
管道的读写使用 read() 函数和 write() 函数,其采用字节流的方式。
- 读管道时,若管道为空,则会被阻塞,直到管道另一段 write() 函数将数据写入到管道为止,若写段已关闭,则返回 0;
- 写管道时,若管道已满,则会被阻塞,直到管道另一端 read() 函数将管道内数据读走为止,若读已关闭,则写端返回21,error被设为EPIPE。
管道关闭用close函数,在创建管道时,写端需要关闭pipedes[0]描述符,读端需要关闭pipedes[1]描述符。当进程关闭前,每个进程需要把没有关闭的描述符都进行关闭。无名管道需要注意以下问题:
- 管道是半双工方式,数据只能单向传输。如果在两个进程之间相互传送数据,要建立两条管道。
- 调用 pipe() 函数必须在调用 fork() 函数以前进行,否则子进程将无法继承文件描述符。
- 使用无名管道互相连接的任意进程必须位于一个相关的进程组中。
我们来进行一下数据的传递,对上面的代码进行一个简单的修改:
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main(int argc, char const *argv[])
{
int pipefd[2];
// 创建管道(必须在fork之前)
if (pipe(pipefd) == -1) {
perror("pipe");
return 1;
}
pid_t cpid = fork();
int sum = 0;
if(cpid < 0)
{
perror("fork");
return 1;
}
else if (cpid == 0)
{
sum = 1;
printf("\n读取之前子进程%d此时sum的数据为:%d\n",getpid(),sum);
printf("子进程开始读取数据\n");
// 关闭写端
close(pipefd[1]);
read(pipefd[0], &sum, sizeof(sum));
printf("子进程从管道读取的数据:%d\n", sum);
write(STDOUT_FILENO, &sum, sizeof(sum));
printf("读取之后子进程%d此时sum的数据为:%d\n",getpid(),sum);
close(pipefd[0]);
_exit(EXIT_SUCCESS);
}
else
{
sum = 10;
//关闭读端
close(pipefd[0]);
printf("父进程%d此时sum的数据为:%d\n",getpid(),sum);
//写入传入的参数到管道的写端
write(pipefd[1],&sum,sizeof(sum));
//写完之后关闭写端 读端会返回0
close(pipefd[1]);
// 等待子进程结束
waitpid(cpid,NULL,0);
exit(EXIT_SUCCESS);
}
return 0;
}
可以看到此时我们可以将父进程写入的数据,赋值到子进程使用:
这里其实就是简单的 read() 和 writer() 的调用,不熟悉的可以参考文件IO的介绍:
使用管道的限制:
两个进程通过一个管道只能实现单向通信,比如上面的例子,父进程写子进程读,如果有时候也需要子进程写父进程读,就必须另开一个管道。
管道的读写端通过打开的文件描述符来传递,因此要通信的两个进程必须从它们的公共祖先那里继承管道文件描述符。上面的例子是父进程把文件描述符传给子进程之后父子进程之间通信,也可以父进程fork两次,把文件描述符传给两个子进程,然后两个子进程之间通信,总之需要通过fork传递文件描述符使两个进程都能访问同一管道,它们才能通信。
4.4.2 有名管道(命名管道,FIFO)
上面介绍的Pipe是匿名管道,只能在有父子关系的进程间使用,某些场景下并不能满足需求。与匿名管道相对的是有名管道,在Linux中称为FIFO,即First In First Out,先进先出队列。
FIFO和Pipe一样,提供了双向进程间通信渠道。但要注意的是,无论是有名管道还是匿名管道,同一条管道只应用于单向通信,否则可能出现通信混乱(进程读到自己发的数据)。
有名管道可以用于任何进程之间的通信。它和无名管道不同之处在于:命名管道提供了一个路径名与之关联,以FIFO的文件形式存在于文件系统当中,在文件系统当中产生一个物理文件,其他进程只要访问该文件路径,就能彼此通过管道通信。在读数据端以只读方式打开管道文件,在写数据端以只写方式打开管道文件。
执行流程:
mkfifo(创建有名管道)→open(打开管道)→read/writer(读写管道)→close(关闭管道)→unlink(文件移出)
表头文件:
#include <sys/types.h>
#include <sys/stat.h>
定义函数:
/* Create a new FIFO named PATH, with permission bits MODE. */
extern int mkfifo (const char *__path, __mode_t __mode)
/**
* @brief 用于创建有名管道。该函数可以创建一个路径为pathname的FIFO专用文件,mode指定了FIFO的权限,FIFO的权限和它绑定的文件是一致的。FIFO和pipe唯一的区别在于创建方式的差异。一旦创建了FIFO专用文件,任何进程都可以像操作文件一样打开FIFO,执行读写操作。
*
* @param pathname 有名管道绑定的文件路径,为管道建立的临时文件,文件名在创建管道之前不能存在
* @param mode 有名管道绑定文件的权限
* @return int
*/
命名管道只需要在读写一端创建,不过读写两端都需要知道这个命名管道的存在。例如在写端创建后,就不需要在读端创建。
我们根据上面流程来写一下写端的代码,首先是创建有名管道,通过mkfifo创建,路径自己取一个,命名管道可以创建在任何你有写权限的目录中,这里我就复制我当前路径了:
char *pipe_path = "/home/dky/Linux_task/perror_example/example";
if(mkfifo(pipe_path,0664) != 0)
{
perror("mkfifo failed");
if(errno != 17)
{
exit(EXIT_FAILURE);
}
}
其中errno 17 含义:17 是 EEXIST,表示文件已存在。
然后通过open()函数打开管道,默认设置为阻塞模式,不需要使用创建的方法,也不需要设置访问权限,在读端以只读方式打开,在写端以只写方式打开下面我们是写端:
int fd;
fd = open(pipe_path, O_WRONLY);
if (fd == -1)
{
perror("open failed");
exit(EXIT_FAILURE);
}
对于读写使用 read() 和 writer(),进行数据读写的时候有两种工作模式:
阻塞模式:
读取数据时,以只读方式打开,若管道为空,则被阻塞,直到写数据端写入数据为止。读取数据端时,可能有多个进程读取管道,所有的读进程都被阻塞。当有任意一个进程能读取数据时,其他所有进程都被解阻,只不过返回值为 0,数据只能被其中一个进程读走。
写入数据时,以只写方式打开,若管道已满,则被阻塞,直到读进程将数据读走,管道最大长度为 4096 B,有些操作系统为 512 B.如果写入端是多个进程,当管道满时,Lima保证了写入的原子性,即采用互斥方式实现。
非阻塞模式:
读取数据时,立即返回,管道没有数据时,返回 0,且 ermo 值为EAGAIN,有数据时,返回实际读取的字节数。
写入数据时,当要写入的数据量不大于 PIPE_BUF 时,Linux 将保证写入的原子性如果当前 FIFO 空闲缓冲区能够容纳请求写入的字节数,写完后则成功返回;如果当前FIFO 空闲缓冲区不能够容纳请求写入的字节数,则返回 EAGAIN 错误,提醒以后再写。
写入数据:
char writer_buf[100];
ssize_t read_num;
while ((read_num = read(STDIN_FILENO, writer_buf, 100)) > 0)
{
write(fd, writer_buf, read_num);
}
if (read_num < 0)
{
perror("read");
printf("命令行数据读取异常,退出");
close(fd);
exit(EXIT_FAILURE);
}
关闭管道,就是调用close()函数,关闭各自描述符即可:
close(fd);
最后避免文件系统中留下无用的管道文件,我们可以调用 unlink() 函数,将刚刚创建的管道清理掉,在写端移除不需要再读端移除:
unlink(pipe_path);
完整写函数代码:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
int main(int argc, char const *argv[])
{
int fd;
char *pipe_path = "/home/dky/Linux_task/perror_example/example";
if(mkfifo(pipe_path,0664) != 0)
{
perror("mkfifo failed");
if(errno != 17)
{
exit(EXIT_FAILURE);
}
}
fd = open(pipe_path, O_WRONLY);
if (fd == -1)
{
perror("open failed");
exit(EXIT_FAILURE);
}
char writer_buf[100];
ssize_t read_num;
while ((read_num = read(STDIN_FILENO, writer_buf, 100)) > 0)
{
write(fd, writer_buf, read_num);
}
if (read_num < 0)
{
perror("read");
printf("命令行数据读取异常,退出");
close(fd);
exit(EXIT_FAILURE);
}
printf("发送管道退出,进程终止\n");
close(fd);
unlink(pipe_path);
return 0;
}
命名管道只需要在一端移除就可以了,通常是在创建管道的那一端进行移除。
读函数按照上述思路同理:
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <sys/stat.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
int main(int argc, char const *argv[])
{
int fd;
char *pipe_path = "/home/dky/Linux_task/perror_example/example";
fd = open(pipe_path, O_RDWR);
if (fd == -1)
{
perror("open failed");
exit(EXIT_FAILURE);
}
char read_buf[100];
ssize_t read_num;
while ((read_num = read(fd, read_buf, 100)) > 0)
{
write(STDOUT_FILENO, read_buf, read_num);
}
if (read_num < 0)
{
perror("read");
printf("命令行数据读取异常,退出");
close(fd);
exit(EXIT_FAILURE);
}
printf("发送管道退出,进程终止\n");
close(fd);
return 0;
}
Makefile函数中添加如下代码:
fifo_writer: fifo_writer.c
$(cc) -o $@ $<
fifo_read: fifo_read.c
$(cc) -o $@ $<
运行生成可执行文件:
开启两个终端,我们来试一下读写操作:
我们左侧运行写,右侧运行读:
可以看到当我们运行写后,会出现一个example的管道,我们在写端随意输入一段数据回车看一下:
可以看到同步更新相同的数据,不过我们结束两个进程后会发现,example并没有被删除:
主要问题,阻塞的 read() 调用,read(STDIN_FILENO, writer_buf, 100) 会一直等待标准输入,程序无法正常退出,除非收到 EOF 或错误,否则一直阻塞在 read(),unlink() 无法执行,程序卡在 read(),永远不会执行到清理代码。
而如果我们在其中输入数据,read() 的阻塞解除,数据正常返回,注意我们此时想要退出输入ctrl+c就会出现上述删除不掉的情况。
此时我们需要执行Ctrl+D正常退出才可以:
4.5 共享内存
共享内存是嵌入式 Linux 系统中一种非常重要且高效的进程间通信机制。它允许多个不相关的进程访问同一块物理内存区域,从而实现大规模数据的快速共享。
其核心思想是,在物理内存中开辟一块区域,映射到多个进程各自的虚拟地址空间。这样,一个进程写入的数据,另一个进程立刻就能看到,无需任何形式的数据拷贝。
共享内存是存在于内核级别的一种资源,在shell中可以使用ipcs命令来查看当前系统 IPC 中的状态,在文件系统/proc 目录下有对其描述的相应文件:
共享内存的实现方式有两种标准:System V IPC 和 POSIX IPC。
4.5.1 System V IPC
这是比较传统但广泛使用的方法。关键步骤:
- ftok:生成一个唯一的键值。
- shmget:根据键值创建或获取共享内存段。可以指定大小和权限。
- shmat:将共享内存段“附加”到当前进程的地址空间,返回其虚拟地址。
- 读写操作:通过 shm_addr 指针像操作普通内存一样进行读写。
- shmdt:进程分离共享内存段。
- shmctl:用于控制共享内存段,例如删除它(IPC_RMID)。
4.5.1.1 shmget()函数——创建或打开共享内存
创建或打开一块共享内存区。
表头文件:
#include<sys/shm.h>
函数定义:
/* Get shared memory segment. */
extern int shmget (key_t __key, size_t __size, int __shmflg) __THROW;
- key:用于唯一标识共享内存段的键值;其有三种创建方式:
①使用 IPC_PRIVATE(推荐用于亲缘进程)
②使用 ftok() 生成键值(推荐用于无亲缘进程)
③使用固定整数值 - size:请求的共享内存段大小(字节),如果是创建新段,必须指定 size > 0,如果是获取已存在的段,size 被忽略(但必须 ≤ 已存在段的大小),实际分配的大小会被向上取整到系统页大小的整数倍;
- shmflg:控制共享内存的创建和访问权限,通过位或
|组合,其可选标志位如下。
| 标志位 | 值(十六进制) | 说明 |
|---|---|---|
| IPC_CREAT | 0x200 | 创建新段或获取已存在段 |
| IPC_EXCL | 0x400 | 与 IPC_CREAT 配合,确保创建新段 |
| SHM_HUGETLB | 0x4000 | 使用大页内存 |
| SHM_NORESERVE | 0x1000 | 不预留交换空间 |
| SHM_HUGE_2MB | (21 << 26) | 2MB 大页 |
| SHM_HUGE_1GB | (30 << 26) | 1GB 大页 |
| S_IRUSR | 0x100 | 用户读权限 |
| S_IWUSR | 0x080 | 用户写权限 |
| S_IRGRP | 0x020 | 组读权限 |
| S_IWGRP | 0x010 | 组写权限 |
| S_IROTH | 0x004 | 其他用户读权限 |
| S_IWOTH | 0x002 | 其他用户写权限 |
函数的返回值,如果成功则返回内存IP,如果失败则返回-1。
下面我们来使用shmget()函数来创建一块共享内存,这里我们使用IPC_PRIVATE进行创建:
#include<sys/shm.h>
#include<sys/ipc.h>
#include<sys/types.h>
#include<stdio.h>
#include<stdlib.h>
#define BUFF 4096
int main(int argc, char const *argv[])
{
system("ipcs -m");//查看当前共享内存
//共享内存标识符
int shm_id;
shm_id = shmget(IPC_PRIVATE,BUFF,IPC_CREAT | 0666);
if(shm_id < 0)//创建共享内存失败
{
perror("shmget");
exit(1);
}
printf("成功创建共享内存:%d\n",shm_id);
system("ipcs -m");//查看当前共享内存
return 0;
}
编写Makefile:
cc := gcc
shmget_test : shmget_test.c
-$(cc) -o $@ $^
-./$@
-rm ./$@
可以看到创建了一个4096的共享内存:
对于使用ftok()生成键值,部分代码如下:
#include <sys/ipc.h>
key_t key = ftok("/some/existing/file", 'A');
shm_id = shmget(key, BUFF, IPC_CREAT | 0666);
对于使用固定数值(需要确保选择的键值不会与其他应用冲突):
#define MY_SHM_KEY 0x1234
// 或
#define MY_SHM_KEY 5678
shm_id = shmget(MY_SHM_KEY, BUFF, IPC_CREAT | 0666);
| 方法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| IPC_PRIVATE | 永不冲突,简单安全 | 只能亲缘进程使用 | 父子进程通信 |
| ftok() | 标准方法,无亲缘关系可用 | 依赖文件存在性 | 任意进程通信 |
| 固定值 | 完全控制,性能好 | 可能冲突,需要协调 | 内部系统,测试 |
4.5.1.2 shmctl()函数——删除共享内存
共享内存与消息队列以及信号量相同,在使用完毕后都应该进行释放,另外,当调用fork(函数创建子进程时,子进程会继承父进程已绑定的共享内存;当调用exec函数更改子进程功能以及调用exit()函数时,子进程中都会解除与共享内存的映射关系,因此在必要时仍应使用shmctl0函数对共享内存进行删除。
表头文件:
#include <sys/shm.h>
函数定义:
/* The following System V style IPC functions implement a shared memory
facility. The definition is found in XPG4.2. */
/* Shared memory control operation. */
#ifndef __USE_TIME_BITS64
extern int shmctl (int __shmid, int __cmd, struct shmid_ds *__buf) __THROW;
#else
- shmid:共享内存标识符,指定要操作的共享内存段,其来源由 shmget() 函数返回的正整数;
- cmd:控制命令,指定要执行的操作类型;
- buf:缓冲区指针,根据不同的 cmd,此参数有不同的用途。
其中对于cmd的一些类型:
| 命令 | 参数类型 | 作用 | 返回值 |
|---|---|---|---|
| IPC_STAT | struct shmid_ds * | 获取段状态 | 0/-1 |
| IPC_SET | struct shmid_ds * | 设置段参数 | 0/-1 |
| IPC_RMID | NULL | 标记删除段 | 0/-1 |
| IPC_INFO | struct shminfo * | 获取系统限制 | 最大索引/-1 |
| SHM_INFO | struct shm_info * | 获取使用统计 | 最大索引/-1 |
| SHM_STAT | struct shmid_ds * | 通过索引获取信息 | shmid/-1 |
| SHM_LOCK | NULL | 锁定内存 | 0/-1 |
| SHM_UNLOCK | NULL | 解除锁定 | 0/-1 |
我们创建在删除:
#include <stdio.h>
#include <stdlib.h>
#include <sys/shm.h>
int main()
{
int shmid = shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0666);
printf("创建共享内存: %d\n", shmid);
system("ipcs -m");
// 删除共享内存
if (shmctl(shmid, IPC_RMID, NULL) == -1) {
perror("shmctl");
exit(1);
}
printf("已标记删除共享内存\n");
system("ipcs -m");
return 0;
}
可以看到上面创建了32829,下面给删除了:
我们也可以直接输入相应的键值进行删除,如我们想要删除上方的32811,直接输进去即可:
#include <stdio.h>
#include <stdlib.h>
#include <sys/shm.h>
int main()
{
system("ipcs -m");
// 删除共享内存
if (shmctl(32811, IPC_RMID, NULL) == -1) {
perror("shmctl");
exit(1);
}
printf("已标记删除共享内存\n");
system("ipcs -m");
return 0;
}
4.5.1.3 shmat()函数——将共享内存段附加到进程的地址空间
将共享内存段“附加”到当前进程的地址空间,返回其虚拟地址。
表头文件:
#include <sys/shm.h>
函数定义:
/* Attach shared memory segment. */
extern void *shmat (int __shmid, const void *__shmaddr, int __shmflg)
- shmid:共享内存段标识符,必须是已存在的共享内存段 ID,由 shmget() 函数返回的有效标识符;
- shmaddr:指定期望的附加地址,NULL(推荐)让内核自动选择可用地址,或者尝试在指定地址附加;
- shmflg:控制附加行为的标志位,常用标志位如下。
| 标志 | 值 | 说明 |
|---|---|---|
0 | 0 | 默认,读写方式附加 |
SHM_RDONLY | 010000 | 只读方式附加 |
SHM_RND | 020000 | 自动对齐 shmaddr |
SHM_REMAP | 040000 | 强制重新映射(Linux 扩展) |
SHM_EXEC | 0100000 | 内存可执行(Linux 扩展) |
其返回值,若是成功则返回实际引入的地址,如果失败,则返回-1。
编写代码:
#include<sys/shm.h>
#include<sys/ipc.h>
#include<sys/types.h>
#include<stdio.h>
#include<stdlib.h>
int main()
{
int shmid = shmget(IPC_PRIVATE, 4096, IPC_CREAT | 0666);
printf("创建共享内存: %d\n", shmid);
system("ipcs -m");
char* shm_addr = shmat(shmid, NULL, 0);
if (shm_addr == (void *)-1) {
perror("shmat");
exit(EXIT_FAILURE);
}
printf("共享内存附加成功 at: %p\n", shm_addr);
system("ipcs -m");
// 删除共享内存
if (shmctl(shmid, IPC_RMID, NULL) == -1) {
perror("shmctl");
exit(1);
}
printf("已标记删除共享内存\n");
system("ipcs -m");
return 0;
}
我们首先成功创建 shmid=65554,其连接数为0,状态正常,而后成功附加到地址0x7dd226f2d000,此时的连接数从0变为1,当我们删除65554后,其状态变为"目标",连接数仍为1(因为进程还未分离),当整个工程结束,我们通过 ipcs 查看可以发现已经删除过了:
4.5.1.4 shmdt()函数——进程分离共享内存段
头文件:
#include <sys/shm.h>
函数原型:
/* Detach shared memory segment. */
extern int shmdt (const void *__shmaddr) __THROW;
shmaddr:要分离的共享内存地址指针,必须是 shmat() 函数返回的有效地址,必须是当前进程已附加的共享内存地址。
成功返回 0,失败返回-1。
基础用法,往共享内存中写入数据:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/shm.h>
int main() {
int shmid;
char *shm_addr;
// 创建共享内存
shmid = shmget(IPC_PRIVATE, 1024, IPC_CREAT | 0666);
if (shmid == -1) {
perror("shmget");
exit(EXIT_FAILURE);
}
printf("创建共享内存成功: shmid=%d\n", shmid);
// 附加共享内存
shm_addr = shmat(shmid, NULL, 0);
if (shm_addr == (void *)-1) {
perror("shmat");
exit(EXIT_FAILURE);
}
printf("共享内存附加成功 at: %p\n", shm_addr);
// 使用共享内存
strcpy(shm_addr, "Hello, Shared Memory!");
printf("写入的数据: %s\n", shm_addr);
// 分离共享内存 - 核心调用
if (shmdt(shm_addr) == -1) {
perror("shmdt");
exit(EXIT_FAILURE);
}
printf("共享内存分离成功\n");
// 删除共享内存段
shmctl(shmid, IPC_RMID, NULL);
return 0;
}
4.5.2 POSIX IPC
这是更现代、符合 POSIX 标准的方法,推荐在新项目中使用,关键步骤:
- shm_open:使用一个名字(如 /my_shm)来创建或打开一个共享内存对象。它返回一个文件描述符。
- ftruncate:设置共享内存对象的大小。
- mmap:将共享内存对象映射到进程的地址空间。
- 读写操作:通过 shm_addr 指针进行。
- munmap:解除内存映射。
- close:关闭文件描述符。
- shm_unlink:删除共享内存对象(当所有进程都关闭后,内核会释放资源)。
4.5.2.1 shm_open()函数——创建或打开一个共享内存对
头文件:
#include <sys/mman.h>
函数原型:
/* Open shared memory segment. */
extern int shm_open (const char *__name, int __oflag, mode_t __mode);
- name:共享内存对象的名字,其名字长度有限制(通常最多 255 字符),格式应该以 / 开头,如 "/my_shm";
- oflag:打开标志,控制创建和打开行为;
O_RDONLY // 只读
O_RDWR // 读写
O_CREAT // 如果不存在则创建
O_EXCL // 与 O_CREAT 一起使用,如果已存在则失败
O_TRUNC // 如果已存在,将其截断为0长度
- mode:权限模式(当创建新对象时使用),八进制权限,类似文件权限。
4.5.2.2 shm_unlink()函数——删除共享内存
删除一个先前由 shm_open() 创建的命名共享内存对象。尽管这个函数被称为“unlink”,但它并没有真正删除共享内存段本身,而是移除了与共享内存对象关联的名称,使得通过该名称无法再打开共享内存。当所有已打开该共享内存段的进程关闭它们的描述符后,系统才会真正释放共享内存资源。
头文件:
#include <sys/mman.h>
函数原型:
/* Remove shared memory segment. */
extern int shm_unlink (const char *__name);
- name: 要删除的共享内存对象名称
4.5.2.3 truncate()和ftruncate()——将文件缩放到指定大小
truncate和ftruncate都可以将文件缩放到指定大小,二者的行为类似:如果文件被缩小,截断部分的数据丢失,如果文件空间被放大,扩展的部分均为\0字符。缩放前后文件的偏移量不会更改。缩放成功返回0,失败返回-1。
不同的是,前者需要指定路径,而后者需要提供文件描述符;ftruncate缩放的文件描述符可以是通过shm_open()开启的内存对象,而truncate缩放的文件必须是文件系统已存在文件,若文件不存在或没有权限则会失败。
#include <unistd.h>
#include <sys/types.h>
/**
* 将指定文件扩展或截取到指定大小
*
* char *path: 文件名 指定存在的文件即可 不需要打开
* off_t length: 指定长度 单位字节
* return: int 成功 0
* 失败 -1
*/
int truncate(const char *path, off_t length);
/**
* 将指定文件描述符扩展或截取到指定大小
*
* int fd: 文件描述符 需要打开并且有写权限
* off_t length: 指定长度 单位字节
* return: int 成功 0
* 失败 -1
*/
int ftruncate(int fd, off_t length);
4.5.2.4 mmap()函数——将一组设备或者文件映射到内存地址
mmap系统调用可以将一组设备或者文件映射到内存地址,我们在内存中寻址就相当于在读取这个文件指定地址的数据。父进程在创建一个内存共享对象并将其映射到内存区后,子进程可以正常读写该内存区,并且父进程也能看到更改。使用man 2 mmap查看该系统调用声明:
#include <sys/mman.h>
/**
* 将文件映射到内存区域,进程可以直接对内存区域进行读写操作,就像操作普通内存一样,但实际上是对文件或设备进行读写,从而实现高效的 I/O 操作
*
* void *addr: 指向期望映射的内存起始地址的指针,通常设为 NULL,让系统选择合适的地址
* size_t length: 要映射的内存区域的长度,以字节为单位
* int prot: 内存映射区域的保护标志,可以是以下标志的组合
* (1) PROT_READ: 允许读取映射区域
* (2) PROT_WRITE: 允许写入映射区域
* (3) PROT_EXEC: 允许执行映射区域
* (4) PROT_NONE: 页面不可访问
* int flags:映射选项标志
* (1) MAP_SHARED: 映射区域是共享的,对映射区域的修改会影响文件和其他映射到同一区域的进程(一般使用共享)
* (2) MAP_PRIVATE: 映射区域是私有的,对映射区域的修改不会影响原始文件,对文件的修改会被暂时保存在一个私有副本中
* (3) MAP_ANONYMOUS: 创建一个匿名映射,不与任何文件关联
* (4) MAP_FIXED: 强制映射到指定的地址,如果不允许映射,将返回错误
* int fd: 文件描述符,用于指定要映射的文件或设备,如果是匿名映射,则传入无效的文件描述符(例如-1)
* off_t offset: 从文件开头的偏移量,映射开始的位置
* return void*: (1) 成功时,返回映射区域的起始地址,可以像操作普通内存那样使用这个地址进行读写
* (2) 如果出错,返回 (void *) -1,并且设置 errno 变量来表示错误原因
*/
void *mmap(void *addr, size_t length, int prot, int flags,
int fd, off_t offset);
/**
* 用于取消之前通过 mmap() 函数建立的内存映射关系
*
* void *addr: 这是指向之前通过 mmap() 映射的内存区域的起始地址的指针,这个地址必须是有效的,并且必须是 mmap() 返回的有效映射地址
* size_t length: 这是要解除映射的内存区域的大小(以字节为单位),它必须与之前通过 mmap() 映射的大小一致
* return: int 成功 0
* 失败 -1
*/
int munmap(void *addr, size_t length);
4.5.2.5 总结使用
简单来说就是父进程读取子进程在共享内存写入的数据:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/mman.h>
#include <sys/wait.h>
#include <string.h>
int main()
{
char *share;
pid_t pid;
char shmName[100] = {0};
sprintf(shmName, "/letter%d", getpid());
// 共享内存对象的文件标识符
int fd;
fd = shm_open(shmName, O_CREAT | O_RDWR, 0644);
if (fd < 0)
{
perror("共享内存对象开启失败!\n");
exit(EXIT_FAILURE);
}
// 将该区域扩充为100字节长度
ftruncate(fd, 100);
// 以读写方式映射该区域到内存,并开启父子共享标签 偏移量选择0从头开始
share = mmap(NULL, 100, PROT_READ | PROT_WRITE, MAP_SHARED, fd, 0);
// 注意:不是p == NULL 映射失败返回的是((void *) -1)
if (share == MAP_FAILED)
{
perror("共享内存对象映射到内存失败!\n");
exit(EXIT_FAILURE);
}
// 映射区建立完毕,关闭读取连接 注意不是删除
close(fd);
// 创建子进程
pid = fork();
if (pid == 0)
{
// 子进程写入数据作为回信
strcpy(share, "我收到信了!\n");
printf("子进程%d完成回信!\n", getpid());
}
else
{
// 等待回信
sleep(1);
printf("父进程%d看到子进程%d回信的内容: %s", getpid(), pid, share);
// 等到子进程运行结束
wait(NULL);
// 释放映射区
int ret = munmap(share, 100);
if (ret == -1)
{
perror("munmap");
exit(EXIT_FAILURE);
}
}
// 删除共享内存对象
shm_unlink(shmName);
return 0;
}
4.6 消息队列
早期通信机制之一的信号能够传送的信息量有限,管道则只能传送无格式的字节流,这无疑会给应用程序开发带来不便。消息队列则克服了这些缺点。消息队列的实质就是一个存放消息的链表,该链表由内核维护。可以把消息看作一个记录,具有特定的将式。一些进程可以向其中按照一定的规则添加新消息;另一些进程则可以从消息队列中读取消息。
目前主要有两种类型的消息队列: POSIX消息队列以及System V消息队列,System V消息队列目前被大量使用,该消息队列是随内核持续的,只有在内核重启或者人工删除时该消息队列才会被删除。消息队列的内核持续性要求每个消息队列都在系统范围内对应一的键值,所以,要获得一个消息队列的描述字,必须提供该消息队列的键值。
消息队列就是消息的一个链表,它允许一个或者多个进程向它写消息,或一个或多个进程向它读消息。在内核中以队列的方式管理,队列先进先出,是线性表。消息信息写在结构体中,并送到内核中,由内核管理。
消息的发送不是同步机制,而是先发送到内核,只要消息没有被清除,则另一个程序无论何时打开都可以读取消息。消息可以用在同一程序之间(多个文件之间的信息传递)也可以用在不同进程之间。消息结构体必须自己定义,并按系统的要求定义。
struct msgbuf//结构体的名称自己定义
{
long mtype;//必须是long,变量名必须是mtype
char mdata[256];//必须是char,数组名和数组长度自己定义
};
常见使用生产者-消费者模型,我们可以把消息队列想象成一个邮局或快递柜:
-
发送者(进程/线程)把数据打包成一个“消息”,然后放入队列中,无需等待接收者立即处理,就可以继续去做其他事情。
-
接收者(进程/线程)在方便的时候,从队列中取出消息进行处理。如果队列为空,接收者可以选择等待(阻塞)或立即返回。
特点:
异步通信:发送和接收不需要同步进行,发送者发送完即可返回,降低了进程间的耦合度。
消息有边界:读取操作总是以一条完整的消息为单位,不会出现半条消息的情况。这区别于管道和套接字中的字节流模型。
有优先级:Linux 消息队列支持给消息赋予优先级,允许高优先级的消息被优先处理。
内核持久性:消息队列存在于内核中,其生命周期与创建它的进程无关。只有当系统重启或显式地删除队列时,队列才会被销毁。
多对多通信:多个进程可以向同一个队列写入消息,多个进程也可以从同一个队列读取消息。
其实现方式有两种标准:System V IPC 和 POSIX IPC。
4.6.1 System V
这是 Linux 中传统的消息队列实现,使用一套标准的 System V IPC(进程间通信)函数。
4.6.1.1 msgget()函数——创建或获取消息队列
头文件:
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/msg.h>
函数原型:
/* Get messages queue. */
extern int msgget (key_t __key, int __msgflg) __THROW;
- key:键值,有 ftok() 函数获得,通常为一个整数,若键值为IPC_PRIVATE,则会创建一个只能被创建消息列表的进程读写的消息队列。
- msgflg:标志位,用于设置消息队列的创建方式或权限,通常有一个9为的权限与以下值进行位操作后获得:
①IPC_CREAT:若内核中不存在指定消息队列,该函数会创建一个消息队列;若内核中已存在执行消息队列,则获取该消息队列。
②IPC_EXCL:与IPC_CREAT一起使用,表示如果创建的消息队列已存在,则返回错误。
③IPC_NOWAIT:读写消息队列要求无法得到满足时,不阻塞。
返回值,如果成功则返回消息队列标识符(非负整数),否则为-1。
创建一个消息队列并打印其ID:
#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <errno.h>
int main()
{
key_t key;
int msqid;
// 1. 生成一个键值
key = ftok("/tmp", 'A');
if (key == -1)
{
perror("ftok");
exit(1);
}
// 2. 尝试创建消息队列,如果已存在则失败
msqid = msgget(key, IPC_CREAT | IPC_EXCL | 0666);
if (msqid == -1)
{
// 如果失败是因为队列已存在,那我们尝试获取它
if (errno == EEXIST)
{
printf("消息队列已存在,正在获取它...\n");
msqid = msgget(key, 0666); // 只获取,不创建
if (msqid == -1)
{
perror("msgget (get existing)");
exit(1);
}
}
else
{
// 其他错误
perror("msgget (create)");
exit(1);
}
}
else
{
printf("消息队列创建成功\n");
}
printf("消息队列ID: %d\n", msqid);
// ... 这里可以使用 msqid 进行 msgsnd, msgrcv 等操作 ...
return 0;
}
Makefile函数如下:
cc := gcc
system_test : system_test.c
-$(cc) -o $@ $^
-./$@
-rm ./$@
运行两次,可以看到一次是创建,一次是获取:
4.6.1.2 msgsnd()函数——发送消息
头文件:
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/msg.h>
函数原型:
/* Send message to message queue.
This function is a cancellation point and therefore not marked with
__THROW. */
extern int msgsnd (int __msqid, const void *__msgp, size_t __msgsz,
int __msgflg);
- msqid:消息队列标识符,指定要向哪个消息队列发送消息,由 msgget 函数返回;
- msgp:指向要发送消息的指针,第一个字段必须是 long mtype(消息类型),后面跟着实际的数据内容,消息类型必须是一个大于 0 的整数,如:
struct msgbuf {
long mtype; // 消息类型,必须 > 0
char mtext[1]; // 消息数据,可以是任意类型
};
// 或者更实用的定义:
struct my_message {
long mtype;
char text[100];
int number;
// 可以添加更多字段...
};
- msgsz:消息数据部分的大小(字节数),需要注意的是这个大小不包括 mtype 字段的大小,只计算 mtype 之后的数据部分;
- msgflg:控制发送行为的标志位,常用标志:
①0:阻塞模式。如果队列已满,调用进程会阻塞(睡眠),直到有空间可用。
②IPC_NOWAIT:非阻塞模式。如果队列已满,函数立即返回 -1,并设置 errno 为 EAGAIN。
返回值,成功返回0,失败返回-1。
我们重新创建一个用于发送的.c文件,当做生产者:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <errno.h>
// 定义消息结构
struct message
{
long mtype; // 消息类型,必须 > 0
char mtext[100]; // 消息内容
};
int main()
{
key_t key;
int msqid;
struct message msg;
// 1. 生成一个键值
key = ftok("/tmp", 'A');
if (key == -1)
{
perror("ftok");
exit(1);
}
// 2. 尝试创建消息队列,如果已存在则失败
msqid = msgget(key, IPC_CREAT | IPC_EXCL | 0666);
if (msqid == -1)
{
// 如果失败是因为队列已存在,那我们尝试获取它
if (errno == EEXIST)
{
printf("消息队列已存在,正在获取它...\n");
msqid = msgget(key, 0666); // 只获取,不创建
if (msqid == -1)
{
perror("msgget (get existing)");
exit(1);
}
}
else
{
// 其他错误
perror("msgget (create)");
exit(1);
}
}
else
{
printf("消息队列创建成功\n");
}
printf("消息队列ID: %d\n", msqid);
// 3. 准备并发送消息
msg.mtype = 1; // 设置消息类型为1
printf("请输入要发送的消息: ");
fgets(msg.mtext, sizeof(msg.mtext), stdin);
// 移除换行符
msg.mtext[strcspn(msg.mtext, "\n")] = '\0';
// 发送消息(不阻塞方式)
if (msgsnd(msqid, &msg, strlen(msg.mtext) + 1, IPC_NOWAIT) == -1)
{
perror("msgsnd");
// 如果是因为队列满,可以尝试阻塞方式
if (errno == EAGAIN)
{
printf("消息队列已满,正在以阻塞方式重新发送...\n");
if (msgsnd(msqid, &msg, strlen(msg.mtext) + 1, 0) == -1)
{
perror("msgsnd (blocking)");
exit(1);
}
}
else
{
exit(1);
}
}
printf("消息发送成功: %s\n", msg.mtext);
printf("消息大小: %zu 字节\n", strlen(msg.mtext) + 1);
return 0;
}
我们现在将数据发送出来了,那么怎么查看呢?我们可以通过接收函数查看。
4.6.1.3 msgrcv()函数——接收消息
头文件:
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/msg.h>
函数原型:
/* Receive message from message queue.
This function is a cancellation point and therefore not marked with
__THROW. */
extern ssize_t msgrcv (int __msqid, void *__msgp, size_t __msgsz,
long int __msgtyp, int __msgflg);
- msqid:消息队列标识符,指定要从哪个消息队列接收消息,由 msgget 函数返回;
- msgp:指向接收消息缓冲区的指针,缓冲区必须足够大以容纳消息,第一个字段必须是 long mtype;
- msgsz:接收缓冲区中数据部分的最大容量(字节数),这个大小不包括 mtype 字段的大小,只计算可用于存储数据部分的空间;
- msgtyp:指定要接收的消息类型,这是 msgrcv 最强大的特性之一:
msgtyp 值 | 行为描述 |
|---|---|
0 | 读取队列中的第一条消息(不管什么类型) |
> 0 | 读取队列中第一条类型等于 msgtyp 的消息 |
< 0 | 读取队列中类型值小于等于 |msgtyp| 的消息中类型值最小的第一条 |
- msgflg:控制接收行为的标志位,常用标志:
①0:阻塞模式。如果队列中没有符合条件的消息,调用进程会阻塞;
②IPC_NOWAIT:非阻塞模式。如果没有消息,立即返回 -1,设置 errno 为 ENOMSG;
③MSG_NOERROR:如果消息数据实际长度大于 msgsz,则截断消息而不报错。如果没有这个标志,会返回 E2BIG 错误;
④MSG_EXCEPT(Linux特有):当 msgtyp > 0 时,接收第一条类型不等于 msgtyp 的消息。
返回值,成功返回实际拷贝到 mtext 字段的字节数,失败返回-1。
编写消费者,进行接收生产者发送的数据:
#include <stdio.h>
#include <stdlib.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <errno.h>
// 定义消息结构(必须与发送端一致)
struct message {
long mtype;
char mtext[100];
};
int main()
{
key_t key;
int msqid;
struct message msg;
// 1. 生成相同的键值
key = ftok("/tmp", 'A');
if (key == -1)
{
perror("ftok");
exit(1);
}
// 2. 获取消息队列
msqid = msgget(key, 0666);
if (msqid == -1)
{
perror("msgget");
exit(1);
}
printf("等待接收消息...\n");
// 3. 接收消息(阻塞方式)
// 参数说明:消息队列ID, 消息缓冲区, 消息数据大小, 消息类型, 标志位
if (msgrcv(msqid, &msg, sizeof(msg.mtext), 1, 0) == -1)
{
perror("msgrcv");
exit(1);
}
printf("收到消息: %s\n", msg.mtext);
printf("消息类型: %ld\n", msg.mtype);
return 0;
}
运行一下看一下:
4.6.1.4 msgctl函数()——控制操作(删除、获取信息等)
头文件:
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/msg.h>
函数原型:
extern int msgctl (int __msqid, int __cmd, struct msqid_ds *__buf) __THROW;
- msqid:消息队列标识符,指定要操作的消息队列,由 msgget 函数返回;
- cmd:控制命令,指定要执行的操作;
| 命令 | 值 | 描述 |
|---|---|---|
IPC_RMID | 0 | 立即删除消息队列 |
IPC_STAT | 1 | 获取消息队列的状态信息 |
IPC_SET | 2 | 设置消息队列的参数 |
- buf:指向 msqid_ds 结构体的指针。
①IPC_STAT:用于存储获取到的状态信息;
②IPC_SET:提供要设置的新参数;
③IPC_RMID:忽略(通常为 NULL)。
4.6.2 POSIX
4.6.2.1 mq_open()函数——打开/创建消息队列
#include <fcntl.h> /* For O_* constants */
#include <sys/stat.h> /* For mode constants */
#include <mqueue.h>
/**
* @brief 创建或打开一个已存在的POSIX消息队列,消息队列是通过名称唯一标识的。
*
* @param name 消息队列的名称
* 命名规则:必须是以正斜杠/开头,以\0结尾的字符串,中间可以包含若干字符,但不能有正斜杠
* @param oflag 指定消息队列的控制权限,必须也只能包含以下三者之一
* O_RDONLY 打开的消息队列只用于接收消息
* O_WRONLY 打开的消息队列只用于发送消息
* O_RDWR 打开的消息队列可以用于收发消息
* 可以与以下选项中的0至多个或操作之后作为oflag
* O_CLOEXEC 设置close-on-exec标记,这个标记表示执行exec时关闭文件描述符
* O_CREAT 当文件描述符不存在时创建它,如果指定了这一标记,需要额外提供mode和attr参数
* O_EXCL 创建一个当前进程独占的消息队列,要同时指定O_CREAT,要求创建的消息队列不存在,否则将会失败,并提示错误EEXIST
* O_NONBLOCK 以非阻塞模式打开消息队列,如果设置了这个选项,在默认情况下收发消息发生阻塞时,会转而失败,并提示错误EAGAIN
* @param mode 每个消息队列在mqueue文件系统对应一个文件,mode是用来指定消息队列对应文件的权限的
* @param attr 属性信息,如果为NULL,则队列以默认属性创建
* @return mqd_t 成功则返回消息队列描述符,失败则返回(mqd_t)-1,同时设置errno以指明错误原因
*/
mqd_t mq_open(const char *name, int oflag, mode_t mode, struct mq_attr *attr);
/**
* @brief 当oflag没有包含O_CREAT时方可调用
*
* @param name 同上
* @param oflag 同上
* @return mqd_t 同上
*/
mqd_t mq_open(const char *name, int oflag);
4.6.2.2 mq_timedsend()函数——发送消息
#include <time.h>
#include <mqueue.h>
/**
* @brief 将msg_ptr指向的消息追加到消息队列描述符mqdes指向的消息队列的尾部。如果消息队列已满,默认情况下,调用阻塞直至有充足的空间允许新的消息入队,或者达到abs_timeout指定的等待时间节点,或者调用被信号处理函数打断。需要注意的是,正如上文提到的,如果在mq_open时指定了O_NONBLOCK标记,则转而失败,并返回错误EAGAIN。
*
* @param mqdes 消息队列描述符
* @param msg_ptr 指向消息的指针
* @param msg_len msg_ptr指向的消息长度,不能超过队列的mq_msgsize属性指定的队列最大容量,长度为0的消息是被允许的
* @param msg_prio 一个非负整数,指定了消息的优先级,消息队列中的数据是按照优先级降序排列的,如果新旧消息的优先级相同,则新的消息排在后面。
* @param abs_timeout 指向struct timespec类型的对象,指定了阻塞等待的最晚时间。如果消息队列已满,且abs_timeout指定的时间节点已过期,则调用立即返回。
* @return int 成功返回0,失败返回-1,同时设置errno以指明错误原因。
*/
int mq_timedsend(mqd_t mqdes, const char *msg_ptr, size_t msg_len, unsigned int msg_prio, const struct timespec *abs_timeout);
4.6.2.3 mq_timedreceive()——接收消息
#include <time.h>
#include <mqueue.h>
/**
* @brief 从消息队列中取走最早入队且权限最高的消息,将其放入msg_ptr指向的缓存中。如果消息队列为空,默认情况下调用阻塞,此时的行为与mq_timedsend同理。
*
* @param mqdes 消息队列描述符
* @param msg_ptr 接收消息的缓存
* @param msg_len msg_ptr指向的缓存区的大小,必须大于等于mq_msgsize属性指定的队列单条消息最大字节数
* @param msg_prio 如果不为NULL,则用于接收接收到的消息的优先级
* @param abs_timeout 阻塞时等待的最晚时间节点,同mq_timedsend
* @return ssize_t 成功则返回接收到的消息的字节数,失败返回-1,并设置errno指明错误原因
*/
ssize_t mq_timedreceive(mqd_t mqdes, char *msg_ptr, size_t msg_len, unsigned int *msg_prio, const struct timespec *abs_timeout);
4.6.2.4 mq_unlink()——删除队列
#include <mqueue.h>
/**
* @brief 清除name对应的消息队列,mqueue文件系统中的对应文件被立即清除。消息队列本身的清除必须等待所有指向该消息队列的描述符全部关闭之后才会发生。
*
* @param name 消息队列名称
* @return int 成功返回0,失败返回-1,并设置errno指明错误原因
*/
int mq_unlink(const char *name);
4.7 信号
在Linux中,信号是一种用于通知进程发生了某种事件的机制。信号可以由内核、其他进程或者通过命令行工具发送给目标进程。Linux系统中有多种信号,每种信号都用一个唯一的整数值来表示,如果想查看所有的Linux信号,请执行kill -l指令,会得到以下反馈:
每种信号都有其特定的含义和行为,进程可以通过注册信号处理函数来捕获信号并执行相应的操作,例如终止进程、忽略信号或者执行特定的处理逻辑。常见的信号包括:
- SIGINT(2):这是当用户在终端按下Ctrl+C时发送给前台进程的信号,通常用于请求进程终止。
- SIGKILL(9):这是一种强制终止进程的信号,它会立即终止目标进程,且不能被捕获或忽略。
- SIGTERM(15):这是一种用于请求进程终止的信号,通常由系统管理员或其他进程发送给目标进程。
- SIGUSR1(10)和SIGUSR2(12):这两个信号是用户自定义的信号,可以由应用程序使用。
- SIGSEGV(11):这是一种表示进程非法内存访问的信号,通常是由于进程尝试访问未分配的内存或者试图执行非法指令而导致的。
- SIGALRM(14):这是一个定时器信号,通常用于在一定时间间隔后向目标进程发送信号。
我们可以通过signal系统调用注册信号处理函数:
#include <signal.h>
// 信号处理函数声明
typedef void (*sighandler_t)(int);
/**
* signal系统调用会注册某一信号对应的处理函数。如果注册成功,当进程收到这一信号时,将不会调用默认的处理函数,而是调用这里的自定义函数
*
* int signum: 要处理的信号
* sighandler_t handler: 当收到对应的signum信号时,要调用的函数
* return: sighandler_t 返回之前的信号处理函数,如果错误会返回SEG_ERR
*/
sighandler_t signal(int signum, sighandler_t handler);
举个简单的例子,用户在终端按下Ctrl+C时发送给前台进程的信号,通常用于请求进程终止:
#include <stdio.h>
#include <signal.h>
#include <unistd.h>
#include <stdlib.h>
void sigint_handler(int sig)
{
printf("\n收到 SIGINT 信号,正在优雅退出...\n");
// 清理资源
exit(0);
}
int main()
{
signal(SIGINT, sigint_handler);
while(1)
{
printf("运行中... 按 Ctrl+C 中断\n");
sleep(1);
}
return 0;
}
按下Ctrl+C:


3万+

被折叠的 条评论
为什么被折叠?



