LinuxC 提高篇

上面我们已经初步介绍了,C语言的一些基本语法和拓展应用,

  • 例如:枚举类型、结构体位字段、共用体以及内存的分配和释放,
  • 其次再对Linux环境下的gcc编译器、VIM、GDB等有一个初步的介绍,
  • 下面将进行一些核心技术的介绍。

5. linux进程控制

  • 使用ps -aux查看系统中正在进行的进程信息

我们理应知道:在Linux环境的进程特性

  • 动态性:进程是程序的执行,是程序在处理机上执行时的一个活动。
  • 并发性:多个程序可以同时运行在同一内存空间上
  • 独立性每个进程都是运行在各自的虚拟空间中,互不干扰,是独立获得资源和调度的基本单位。
  • 异步性:各个进程都是按照自己运行速度执行。
  • 结构特性:每个进程都具有自己的私有空间,在该空间中,进程由代码段、数据段以及堆栈段构成

此外,进程也有属性信息

  • ID、状态、进程切换、虚拟内存、文件描述符、用户ID、组ID等

5.1 创建进程

5.1.1 fork() 详解

在Linux系统下,进程的创建有函数:fork()、vfork()、exec()函数。

  • pid_t:用于定义进程的ID,可以理解为非负的整数。

  • fork():创建一个新的进程,新进程为当前进程的子进程,当前进程就被称为父进程,可以通过fork()函数的返回值,判断是在子进程还是父进程

pid_t fork(void);
//使用fork()函数,需要调用<sys/types.h>和<unistd.h>头文件,返回值类型为pid_t.

若返回值为PID,即非负值,则代表运行在父进程中,pid为子进程号;若返回值为0,则表示运行在子进程中;若返回值为-1,则创建进程失败。

错误信息有:

  • EAGAIN:表示fork()函数没有足够的内存用于复制父进程的分页表和进程结构数据
  • ENOMEM:表示fork()函数分配必要的内核数据结构时,内存不够
// 演示一段程序, fork.c 文件
#include<sys/types.h>
#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>

int main(void){
pit_t pid;
if(pid=fork()<0){
printf("进程创建失败!\n");
exit(1);
}
else if(pid==0)
printf("运行在子进程中!\n");
else
printf("运行在父进程中,子进程号:%ld\n",long(pid))
}
  • 系统运行如下:

image-20240302140313359

我们可以发现,fork()函数具有调用一次,返回两次的特性,这是一个值得深究的问题:

  • fork() 函数创建新进程,这个新的进程是原进程的副本,包括它的代码、数据、堆栈等
  • fork() 调用后,子进程会从调用 fork() 的地方开始执行,子进程会继承父进程的代码段,但是不会再次执行 fork() 调用之前的代码

下面看一段演示代码:

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
int main() {
pid_t pid;
printf("Before fork\n");

pid = fork();

if (pid < 0) {
fprintf(stderr, "Fork failed\n");
return 1;
} else if (pid == 0) { // 子进程
printf("This is the child process\n");
} else { // 父进程
printf("This is the parent process\n");
}

printf("After fork\n");
return 0;
}
  • 经过我的验证,结果如下,并不会执行fork()前的代码

image-20240509170650962


5.1.2 vfork() 详解

**vfork()函数与fork()**函数相同,但,

  • fork()函数会复制父进程的所有资源,包括:进程环境、内存资源等,而vfork()函数在创建子进程时,不会复制父进程的所有资源,父子进程共享地址空间。所以在子进程中修改内存空间中的变量时,实际是在修改父进程虚拟空间中的值

  • 值得注意的是,在使用vfork()函数时,父进程会被阻塞,需要在子进程中调用_exit()函数退出子进程,不能使用exit()函数退出。

#include<stdio.h>
#include<sys/types.h>
#include<unistd.h>
int gvar=2;
int main(void){
pid_t pid;
int var=5;
printf("process id: %ld\n",(long)getpid());
printf("gvar=%d var=%d\n",gvar,var);
if((pid=vfork())<0){ /*创建一个新进程*/
perror("error!");
return 1;
}
else if(pid==0){ /*子进程*/
gvar--;
var++;
printf("the child process id: %ld\ngvar=%d var=%d\n",(long)getpid(),gvar,var);
_exit(0); /*父进程被阻塞,退出子进程*/
}
else{
printf("the parent process id: %ld\ngvar=%d var=%d\n",(long)getpid(),gvar,var);
return 0;
}
}

如上图,其结果为:

image-20240302144704501

可以看出,在子进程中操作变量,也是改变父进程的中的值。可以将vfork函数换为fork函数。通过对比可知,符合结论。

image-20240302144826969

5.3 编辑进程

我们经过上面的学习后,知道如何建立一个进程,但复制父进程代码是没用的,我们希望子进程进行不同的操作,故:

  • exec()函数族:Linux系统提供了一个exec()函数族,用于修改进程(执行新程序)
  • 调用exec()函数时,子进程中的代码段、数据段和堆栈段都将被替换,由于exec()函数并没有创建新进程,故子进程ID不变。
  • exec函数族的函数执行成功后不会返回,调用失败时返回-1,然后从原程序的调用点接着往下执行。
  • exec()函数有
int execl(const char *path,const char *arg,...);
int execlp(const *file,const char *arg,...);
int execle(const char *path,const char *arg,...,char* const envp[]);
int execv(const char *path,const char *argv[]);
int execve(const char *path,const char*argv[],char *const envp[]); //envp为Linux系统下的全局指针
int execvp(const char *file,const char *argv[]);
  • 参数解释见后,

这些函数都定义在**<sys/types.h>、<unistd.h>两个库中,且必须在预定义时定义一个外部的全局变量**:

extern char **environ;
  • 上述定义的变量是一个指向Linux系统全局变量的指针,定义后,就可以在当前工作目录中执行系统程序,如同在shell中不输入路径直接运行VIM和等程序一样
函数 作用
execlp 带p path,直接使用文件名,会自动在PATH中寻找
execl 带l list,代表使用参数列表,参数个数不定,结尾以NULL结束。
execv 带v vector,代表需要先构建参数数组指针,并将该地址作为输入,数组中最后一个指针也要输入NULL
execv 带e environment,代表使用新的环境变量表代替调用进程的环境变量表。

5.3.1 execve()

  • execve()函数的作用是创建一个子进程,在子进程中执行另一个执行文件

  • int execve(const char *path,const char*argv[],char *const envp[]):其参数解析,

参数 含义
const char *path 指向字符型的常量指针,为文件的路径。
const char*argv[] 参数数组,每个指针指向一个以 null 结尾的字符串,第一个参数为程序名称,后面为命令行参数。
char *const envp[] 环境变量数组,每个字符串都表示一个环境变量。

其中execve()函数是其余5个exec函数的基础,只有execve()函数才经过系统调用,故使用其它函数后,都需要最后调用execve()函数

/*execve.c文件*/
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
extern char **environ;
int main (int argc,char* argv[]){
execve("new",argv,environ);
puts("正常无法输出此信息!\n");
return 1;
}

/*new2.c文件*/
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main(void){
puts("welcome to it.\n");
return 0;
}

主函数的参数我有点想说的:

这是 C 语言中定义主函数的典型方式之一。让我们一一解释每个参数的含义:

  1. int argc:这是表示命令行参数数量的整数。argc 是 “argument count” 的缩写,它表示在运行程序时通过命令行传递给程序的参数数量,包括程序的名称在内。
  2. char *argv[]:这是一个指向字符(char)指针的数组。argv 是 “argument vector” 的缩写。它包含了指向以 null 结尾的字符串的指针,每个指针指向一个命令行参数的字符串。argv[0] 通常是程序的名称,而后续的元素则是传递给程序的命令行参数。
  3. 在生成可执行文件时,操作系统不会直接提供主函数的参数。相反,当你在命令行中执行该可执行文件时操作系统会为你传递参数。在命令行中执行程序时,你可以通过在命令后面添加参数来传递给程序,如:
./my_program arg1 arg2 arg3

`arg1`、`arg2` 和 `arg3` 就是传递给程序的参数,它们将被存储在 `argv` 数组中,并且 `argc` 将会是 4,因为第一个参数是程序的名称(`my_program`)。

结果:

image-20240509210329602

  • 显然输出为welcome to it.,但是没有输出“正常无法…”,这是因为使用execve函数,已经把代码段、数据段、堆栈段进行替换了,原来的代码就被抛弃了。

  • 如果不想因为调用这个函数就抛弃了其余代码,则可以将其余部分放置在父进程中。

如:

/*修改execve文件*/
#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
extern char **environ;
int main(void){
pid_t pid;
if((pid=fork())<0) puts("Error!\n"); // 创建子进程
if(pid==0) execve("new",argv,environ); //子进程资源被另一个执行文件覆盖
else printf("正常无法输出此信息。\n"); //父进程输出这个消息
}

5.3.2 execlp()

下面演示**execlp()**函数:

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
int main(int argc,char *argv[]){
if(argc<2){
printf("vi的等效用法: %s filename\n",argv[0]);
return 1;
}
execlp("/bin/vi","vi",argv[1],(char*)NULL); //此处file为路径名,vi + argv[1]构成参数列表,NULL结束
return 0;
}

运行结果如下:

image-20240302161641284

  • 其余exec()函数都大同小异,只是参数不同。

5.4 进程等待

刚刚我们发现,由于子进程和父进程是两个独立的进程,会存在父子进程传递消息以及消息等待的需求,这就需要进程等待

  • 通常需要调用wait()等待函数使父进程等待子进程结束,否则子进程会进入**僵尸(Zombie)**状态。

  • 使用man 2 wait指令查看wait()函数原型:

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
pid_t waitpid(pid_t pid, int *status, int options);
int waitid(idtype_t idtype, id_t id, siginfo_t *infop, int options);

wait() 函数的工作方式如下:

  1. 如果当前进程没有子进程wait() 函数会立即返回 -1,表示没有子进程可以等待。
  2. 如果当前进程有一个或多个子进程正在运行,则 wait() 函数会挂起当前进程的执行,直到一个子进程结束
  3. 当某个子进程结束时,wait() 函数会返回结束的子进程的PID,并将其退出状态存储在 status 指向的位置。然后,该子进程的进程控制块会被清除,它占用的资源会被释放
  4. status :为指向整数的指针,存储子进程的退出状态信息,如果不关心子进程的退出状态,可以将 status 参数设置为 NULL
  5. wait() 函数只能等待一个子进程结束,如果希望等待所有子进程结束,可使用变体函数 waitpid() 或者 waitid()

若存在子进程,退出进程时的结束状态可分为:

  • 子进程正常结束:函数返回子进程的PID和状态,此时的参数status指向的状态变量就存放在子进程的退出码中,退出码是所谓的从子进程的main()函数中返回的值或子进程中exit()函数的参数
  • 信号引起子进程结束:wait()函数系统发送信号给子进程,可能导致子进程结束运行,若发送的信号被子进程捕获,则终止子进程,此时参数status返回的状态值为接受到的信号值,存放在最后一个字节中
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
#include<stdio.h>
#include<stdlib.h>
/*自定义功能函数,判断正常退出还是信号退出*/
void exit_s(int status)
{
if(WIFEXITED(status))
printf("normal exit,status=%d\n",WEXITSTATUS(status));
else if(WIFSIGNALED(status))
printf("signal exit!status=%d\n",WTERMSIG(status));
}
int main(void)
{
pid_t pid,pid1;
int status;
if((pid=fork())<0) //创建子进程
{
printf("child process error!\n");
exit(0);
}
else if(pid==0) //子进程
{
printf("the child process!\n");
exit(2); //调用exit退出函数正常退出
}
if(wait(&status)!=pid) //判断子进程是否正常结束
{
printf("this is a parent process!\nwait error!\n");
exit(0);
}

exit_s(status); //到这儿说明子进程正常结束

/*模拟信号结束*/
if((pid=fork())<0)
{
printf("child process error!\n");
exit(0);
}
else if(pid==0)
{
printf("the child process!\n");
pid1=getpid();
// kill(pid1,9); //结束进程
// kill(pid1,17); //进入父进程
kill(pid1,19); //暂停停止进程
}
if(wait(&status)!=pid)
{
printf("this is a parent process!\nwait error!\n");
exit(0);
}
exit_s(status);
exit(0);
}
  • Linux系统中可以使用 kill _l 查看这些信号的具体情况、信号类型以及对应数字
  • 上述代码用到了一些状态宏:
含义
WIFEXITED(status) 子进程正常结束,返回值为1,正常退出是用exit、_exit
WIFSIGNALED(status) 子进程没有被捕获的信号终止时,返回为1
WIFSTOPPED(status) 子进程接收到停止信号,返回1,这种情况只出现在waitpid()函数使用了WUNTRACED选项
WEXITSTUTAS(status) 当WIFEXITED(status)为真时调用,返回状态码的低8位。
WIFCONTINUED(status) 接受信号后,继续运行。
WTERMSIG(status) 当WIFSOGNALED(status)为真时调用,返回引起终止的信号代码。
WSTOPSIG 当WIFSTOPPED为真时,返回进程停止的信号类型。
  • 除了wait()外还有waitpid(),其区别是:wait用于等待所有子进程结束,而waitpid用于特定pid进程。而waitpid中pid参数与实际控制的pid参数,有:
关系 含义
pid<-1 等待进程组ID与pid绝对值的任一子进程相等时退出。
pid=-1 等待任意一个子进程退出。
pid=0 等待进程组ID与调用进程的组ID的任一子进程相等时退出。
pid>0 等待进程ID等于pid的子进程时退出。
WNOHANG 表示没有子进程退出就立即返回。
WUNTRACED 表示发现若子进程处于僵尸状态但未报告,则立即返回。

5.5 进程结束

想终止和结束一个进程时,使用以下函数:

  • exit():
#include<unistd.h>
void exit(int status);
// 函数调用成功与否都没有返回值。
  • _exit():对于fork()创建的子进程,只能使用此函数关闭。
#include<unistd.h>
void _exit(int status);
// 函数调用成功与否都没有返回值。

其区别:

  • exit()会在终止进程时关闭所有文件,清空缓冲区,会对输入/输出流刷新,可能会导致文件的丢失
  • 尽量减少使用exit()终止子进程

5.6 多个进程间的关系

随着硬件的发展,很多系统都拥有多个处理器,Linux系统是一个支持多进程同时运行的系统,但是想要多个进程合理运行,需要注意。

5.6.1 进程组

进程组:就是一个或多个进程的集合。

  • 在Linux系统中可以通过调用getpgrg()函数获取进程组ID,原型为:
#include<sys/types.h>
#include<unistd.h>
pid_t getpgrp(void); // 返回该函数所在进程的进程组ID
  • 进程的生命周期:从创建进程到进程终止,进程组的生命周期:进程组的创立到进程组中最后一个进程终止。
  • 在Linux系统中,可以说使用setpgid()函数创建一个新的进程组或将一个进程加入到一个进程组中,其原型:
#include<sys/types.h>
#include<unistd.h>
int setpgid(pid_t pid,pid_t pgid); // 当调用成功时,返回值为0,失败时为-1。

下面演示获取进程ID和获取进程组ID,以及创建一个新的进程组:

#include<sys/types.h>
#include<unistd.h>
int main(void){
int a;
pid_t pgid,pid;
pid=(long)getpid();
pgid=(long)getpgrp();
a=setpgid(pid,pgid);
printf("a=%d,pid=%ld,pgid=%ld\n",a,pid,pgid);
return 0;
}

5.6.1 时间片的分配

我们学过单片机,知道很多单片机都是单线进行,那么对于一个CPU来说可以实现多进程同时运行吗?

实则只是将多个进程进行时间切片,近似看做同时进行,那么,

  • 时间片:每个进程都有其时间段,这个时间段称为进程的时间片

要想多个进程的时间片合理运行,则需要进行任务调度,时间片切换的调度策略有:

  • 时间片轮转调度策略:遵循先来先得的原则运行,按顺序执行时间片。
  • 优先权调度策略:有些进程需要优先处理,则引入优先权调度算法,其分为两种,
    • 非抢占式优先权调度策略:将CPU分配给队列中优先权最高的进程,然后全速执行该进程。
    • 抢占式优先权调度策略:将CPU分配给当前优先级别最高的进程,每出现新的进程就进行比较

在Linux系统中提供以下函数用于设置和获取进程的调度策略等信息

#include<sched.h>
int sched_setscheduler(pid_t pid,int policy,const struct sched_param *param);
int sched_getscheduler(pid_t pid);
  • setpriority()、getpriority():用于设置和获取进程的动态优先级。
  • nice():用于改变进程的动态优先级。

5.7 线程

我们学习了进程,那么对于线程是什么呢?有:

  1. 定义
    • 进程:进程是计算机中运行中的程序的实例。它是程序的执行过程,包括程序、数据和进程控制块等资源的集合。
    • 线程:线程是进程内的一个执行单元,也称为轻量级进程。一个进程可以包含多个线程,这些线程共享进程的资源
  2. 资源占用
    • 进程:每个进程都拥有独立的内存空间、文件描述符、堆栈等资源。进程之间的通信需要使用特定的机制,比如管道、消息队列等。
    • 线程:线程是在进程内部运行的,它们共享相同的内存空间和其他资源,比如文件描述符、堆栈等。因此,线程之间的通信更为简单,可以直接访问共享的内存
  3. 切换开销
    • 进程:由于每个进程拥有独立的内存空间,进程切换的开销相对较大,涉及到切换页表、刷新 CPU 寄存器等操作,因此效率较低
    • 线程:线程切换的开销比进程小得多,因为它们共享相同的地址空间和其他资源,只需要切换线程的上下文即可
  4. 并发性和并行性
    • 进程:由于进程之间拥有独立的内存空间,因此进程之间的并发性较高,但是实现并行需要多个处理器
    • 线程:线程可以在同一进程内并发执行,共享相同的资源,因此线程的并发性比进程更高。在多核处理器上,线程可以实现真正的并行执行
  5. 稳定性
    • 进程:一个进程的崩溃通常不会影响其他进程,因为它们拥有独立的地址空间。
    • 线程:一个线程的崩溃可能会导致整个进程的崩溃,因为它们共享相同的资源
  • 总之:进程适合于执行独立任务或者需要较高隔离性的情况,而线程适合于需要共享资源并且需要更高并发性的情况

5.7.1 线程属性

同样的,我们要使用线程,就必须知道其属性,基于Linux系统,学习相关属性函数对线程进行操作。

  • 例如线程属性结构体
typedef struct{
int__detachstate; // 线程分离状态
int__schedpolicy; // 线程调度策略
struct sched_param__schedparam; // 线程调度参数
int__inheritsched; // 线程的继承性
int__scope; // 线程的作用域
size_t__guardsize;
int__stackaddr_set;
void* _stackaddr; // 线程堆栈位置
unsigned long int__stacksize; // 线程堆栈大小
}pthread_attr_t;
  • 值得一提的是:有人疑惑int后面怎么有双下划线,这是编程规范,防止与用户标识符冲突,一般在编译器和库时使用

下面解释以下参数:

  • detachstate:取PTHREAD_CREATE_JOINABLE:表示可连接状态;取PTHREAD_CREATE_DETACHED,表示分离状态。
  • schedpolicy:该变量表示线程的调度策略,取SCHED_OTHER:表示为普通、非实时的调度策略;取SCHED_RR:表示实时、轮转的调度策略;取SCHED_FIFO:表示实时、先进先出的调度策略。
  • schedparam表示线程的调度参数,该值由线程的调度策略决定。
  • inheritsched表示线程的继承性,取PTHEAD_EXPLTICIT_SCHED,表示从父进程处继承调度属性;取PTHEAD_INHERIT_SCHED,表示从父进程继承
  • scope:表示线程的作用域,取PTHREAD_SCOPE_SYSTEM时,表明每个线程占用一个系统时间片。

5.7.2 初始化属性对象

当需要使用一个线程的属性对象前,需要首先初始化该对象,初始化线程属性的函数为:

#include<pthread.h>
int pthread_attr_init(pthread_attr_t *attr);
  • 调用成功返回0,失败为非0值,
  • 该函数必须在创建线程函数前调用,可以使用参数attr初始化线程属性对象
#include<pthread.h>
int pthread_attr_destroy(pthread_attr_t *attr);
  • 该函数作用就是摧毁**attr指向的线程属性对象。

5.7.3 设置线程状态

线程的分离状态决定了线程是如何结束的,默认状态是可连接状态,这样线程在进程没有退出之前不会释放线程所占用的资源,可以调用pthread_join()函数来等待其它线程的结束,这样线程结束后会自动释放掉自身所占资源。

#include<pthread.h>
int pthread_attr_setdetachstate(pthread_attr_t* attr,int detachstate);
int pthread_attr_getdetachstate(pthread_attr_t* attr,int detachstate);
  • attr:用于设置线程属性的参数,指向要设置的线程属性对象;作为获取线程属性的参数,attr为获取的属性信息。
  • detachstate:同理。

5.7.4 设置调度策略

上文中介绍到的三种调度策略:SCHED_OTHER、SCHED_RR、SCHED_FIFO

然后使用以下函数进行设置和修改,

#include<pthread.h>
int pthread_attr_setschedpolicy(pthread_attr_t* attr,int policy);
int pthread_attr_setschedpolicy(pthread_attr_t* attr,int policy);
  • 其它属性见函数说明。

关于进程的一些碎碎念:

  • 我初学者,感觉这部分有点空,讲的也很浅,等我后面学厉害了进行补充,
  • 还有就是关于进程ID的获取、用户UID的获取、组ID和有效组ID,前面有讲解,也可以使用man命令查看
  • 可以进入/etc/passwd里查看,如图,

image-20240510141928028


5.7.5 设置进程标识

对于用户ID的设置:

#include<sys/types.h>
#include<unistd.h>
int setuid(uid_t uid); // 修改当前进程用户标识,调用成功返回0,否则-1
int setgid(gid_t uid); // 设置当前进程的有效用户组标识,成功返回0,否则-1
  • setuid(uid_t uid):如果传递时普通用户的用户标识,会成功将参数UID赋给进程UID;而使用管理员的UID作为参数,该函数会检查调用的有效用户ID,如果确定为管理员UID,则所有与用户进程有关的ID都会被设置为参数uid值
  • setgid(gid_t uid):如果调用该函数的用户为系统管理员,那么真实用户组ID和已保存用户组ID也会同时设置,该函数修改发出调用进程的ID时,不会检查用户身份。

代码演示为,

#include<sys/types.h>
#include<unistd.h>
#include<stdio.h>
int main(void){
int flag1,flag2;
flag1=setuid(0);
flag2=setgid(1001);
printf("flag1=%d,flag2=%d",flag1,flag2);
return 0;
}

结果,

image-20240510142217668

好了,我也不懂,那就做两个练习吧:

  • 练习1:创建一个新进程,在父进程中输出“我找马东梅。”,在子进程中输出“什么梅?”,父进程等待子进程结束,输出”马冬梅!“。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(){
printf("父:我找马冬梅。\n");
pid_t pid=fork(); // 创建子进程

if(pid<0){
fprintf(stderr,"子进程创建失败。\n");
exit(1);
}
else if(pid==0){
// 子进程
printf("子:什么梅?\n");
_exit(0);
}
else{
// 父进程
wait(NULL); // 等待子进程结束
printf("父:马冬梅!\n");
}
return 0;
}

结果:

image-20240510164717763

  • 练习2:使用execl()代替一个hello.c文件,在hello.c中实现"Hello world!"。
/*execl_test.c文件*/
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>
#include <unistd.h>
extern char **environ;
int main(void){
printf("下面创建一个子进程:\n");
execl("./hello","hello",NULL); // 打开hello文件
fprintf(stderr,"execvel函数使用错误。\n");
return 0;
}

/*hello.c文件*/
#include <sys/types.h>
#include <sys/wait.h>
#include <stdio.h>

int main() {
printf("Hello world!\n");
return 0;
}

结果:

image-20240510192320703


6. 进程间通讯

进程间通讯Inter-Process Communication(IPC),多个进程间传递或交换信息。

  • 进程与进程相互独立,互相传递信息,则需要在内核中开辟一块缓冲区域,在内核实现进程间的通讯。
  • 进程通讯包括:管道通讯、共享内存通讯、信号量通讯、消息队列通讯、套接口通讯(SOCKET)和全双工管道通信
  • 除了支持信号和管道外,还支持SYSV(System V)子系统中的进程间通讯机制,在该机制下,包括:共享内存、信号量、消息队列通信

6.1 管道

管道:主要用于父子或兄弟进程间的数据读写

6.1.1 管道定义

  1. 管道的定义:数据只能朝一个方向传播,不能同时出现两个方向,这跟单片机通讯中的半双工模式类似,一边读,一边写,但是有些UNIX系统中,管道支持全双工通讯
  2. 管道的创建和关闭:
#include <unistd.h>
int pipe(int filedes[2]); // 调用成功返回0:否则返回-1,并返回错误信息
  • pipe()函数:在内核创建一个管道,一端读,一端写,创建完成后会获得一对文件描述符,用于读写和写入,然后将获取到的filedes[0]传给读端,filedes[1]传给写端
  • 错误信息:EFAULT(参数filedes非法)、EMFILE(进程使用了过多文件描述符)、ENFILE(打开的文件超过系统最大值)
  • 管道的读写:read()、write(),关闭使用close()

6.1.2 管道通讯

pipe函数只允许有联系的进程进行通讯

管道通讯原理:

  1. 在父进程调用pipe()函数,父进程生成filedes[0]读和filedes[1]写。
  2. 在父进程中调用fork()函数,子程序复制所有,也包括filedes[0]读和filedes[1]写。
  3. 关闭父进程的filedes[1]和子进程的filedes[0],即留下父进程读和子进程的写。
  4. 即实现了pipe通讯

下面进行演示:

#include <unistd.h>	// UNIX SYSTEM,访问系统调用和其他系统级函数的接口,包含文件操作、进程控制等
#include <sys/types.h> // 包含系统有关的数据类型
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

#define MAXSIZE 100 // 存储消息

int main(void){
int fd[2],line;
pid_t pid;
char message[MAXSIZE];
/*创建管道*/
if(pipe(fd)==-1){
perror("create pipe failed!\n"); //程序中某个操作的描述信息与错误码一起输出到标准错误流(stderr)。
return 1;
}
/*创建新进程*/
else if((pid=fork())<0){
perror("create new process failed!\n");
return 1;
}
/*子进程*/
else if(pid==0){
close(fd[0]); // 关闭子进程的读
printf("子进程成功发送信息。\n");
write(fd[1],"Hello dad.\n",11); // 11为要写入的字节数
close(fd[1]);
_exit(0);
}
else{
close(fd[1]); // 关闭父进程写
wait(NULL); // 等待子进程结束
printf("父进程接受信息:");
line=read(fd[0],message,MAXSIZE); // 读取消息并存放在message
write(STDOUT_FILENO,message,line); // 将消息写入终端
printf("\n");
exit(0);
}
return 0;
}

根据我的实验:

  • write()函数:发送数据后,并不会显示在终端,需要自己打印
  • 有一个巨坑!!!,上述代码输出:

image-20240510215750468

没错,是不是怪怪的,打印顺序错了…,聪明的你能想到为什么吗?

  • 这是因为 printf() 函数和 write() 函数的输出缓冲机制导致的。在标准输出中,printf() 函数和 write() 函数的输出可能会被缓冲,直到缓冲区满或者遇到换行符 \n 时才会被刷新并输出到终端。由于子进程先于父进程结束,并且它的输出中包含了换行符,因此子进程的输出会立即被刷新并显示在终端上。
  • 故:
printf("父进程接受信息:\n");

是的就是这个问题,最终输出:

image-20240510221019586

双向通讯:使用两个管道


6.2 命名管道

命名管道解决的是没有关系的进程间通讯

  • 命名通常又称之为FIFO,遵循先进先出的原则,作为特殊的设备文件存在于文件系统中,在进程中可以使用open()和close()函数打开和关闭管道
  • 区别:命名管道提供一个路径名,以特殊的设备文件形式存放在文件系统中,因此两个进程间可以通过访问该路径来建立联系,进行数据交换
  • 有两种创建命名管道的方法:通过函数创建,通过终端命令

6.2.1 终端创建命名管道

在shell中输入mknodmkfifo命令可以创建一个命令管道。

mknod [选项]... 路径名称 类型	// 参数p是指创建一个命名管道文件
mkfifo [选项] 名称	// 建议使用mkfifo -help

可以看出mkfifo函数较为简单:

#include<sys/types.h>
#include<sys/stat.h>

int mkfifo(const char* pathname,mode_t mode);

参数解析:

  • pathname:文件路径名。
  • mode:文件权限,权限值取决于(mode&~umask)。
  • 访问管道文件跟其它文件一样。
  • 进程阻塞状态:命名管道读取数据时,没有其它进程向命名管道文件中写入数据,会导致阻塞,同样的,只写入,不读取,也会。
  • 读写操作前,要先使用open()函数打开文件。

代码演示:

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<fcntl.h>
#include<stdlib.h>
#define FIFO "/home/cff/8/fifo4"
int main(void)
{
int fd;
int pid;
char r_msg[BUFSIZ];
if((pid=mkfifo(FIFO,0777))==-1)
{
perror("create fifo channel failed!");
return 1;
}
else
printf("create success!\n");
fd=open(FIFO,O_RDWR);
if(fd==-1)
{
perror("cannot open the FIFO");
return 1;
}
if(write(fd,"hello world",12)==-1)
{
perror("write data error!");
return 1;
}
else
printf("write data success!\n");


if(read(fd,r_msg,BUFSIZ)==-1)
{
perror("read error!");
return 1;
}
else
printf("the receive data is %s!\n",r_msg);
close(fd);

return 0;
}

以上代码的实现流程:

  • mkfifo()创建一个命名管道,路径名称为:/home/cff/8/fifo4。
  • open()函数打开该命名管道文件,以读写方式打开。
  • 调用write()函数向文件写入信息”hello world“,同时用read()读取。
  • 调用close()函数关闭文件。
  • 使用open()函数打开文件后,记得读写同步,否则要出现进程阻塞!

6.3 共享内存

6.3.1 IPC对象

管道和命名管道都是基于文件系统的通讯方式,而SYSV子系统的进程间通讯是基于系统内核的

共享内存、信号量和消息队列通常被称为IPC对象,每个对象都有唯一标识。

  • IPC标识符:唯一,通过传递IPC对象的标识符可以访问该对象。

  • IPC键键是IPC对象的外部标识,由自己定义,多用于多个进程都访问一个特定的IPC对象,创建IPC对象时需指定键值,如果键值是公共的,则经过权限检查的都可以访问该IPC对象,如果该键私有,一般赋值为0,系统类型为系统定义的key_t类型。

  • IPC属性:查看<sys/ipc.h>,或者在shell中输入,man ipc.h

struct ipc_perm{
uid_t uid; // 拥有者的有效用户ID
gid_t gid; // 拥有者的有效组ID
uid_t cuid; // 创建者的有效用户ID
gid_t cgid; // 创建者的有效组ID
mode_t mode; // 访问权限
};
  • IPC命令
    • ipcs:查看ipc对象信息,有共享内存、消息队列、信号量。带参数:q 消息队列;s 信号量;m 共享内存信息。
    • ipcrm:用于删除指定的ipc信息,用法如下:
ipcrm -m shmid	// 删除值为shmid的共享内存消息
ipcrm -q msqid // 删除值为msqid的消息队列信息
ipcrm -s semid // 删除值为semid的信号量信息
ipcrm -M shmkey // 删除键值为shkey的共享内存消息
...

6.3.2 共享内存相关操作

共享内存所实现的进程间通讯是最快速的,但当多个进程读取同一内存时,易数据混乱。

  • 使用共享内存时注意进程间同步,即要用到信号量
  • 每个共享内存的对象都有其指定的定义类型,如:
struct shmid-ds{
struct ipc_perm shm_perm; // 共享内存的ipc_perm结构对象
int shm_segsz; // 共享区域的字节大小
ushort shm_lkcnt; // 共享区域被锁定的时间数
pid_t shm_cpid; // 创建该共享内存的进程ID
pid_t shm_lpid; // 最近一次调用shmop()的进程ID
ulong shm_nattch; // 使用该共享内存的进程数
time_t shm_atime; // 最近一次的附加操作
time_t shm_atime; // 最近一次分离操作的时间
time_t shm_ctime; // 最近一次改变的时间
};

下面进行相关函数讲解:

  • shmget():详情见man shmget
#include<sys/ipc.h>
#include<sys/shm.h>
/*调用成功返回共享区域的标识符,失败返回-1*/
int shmget(key_t key,size_t size,int shmflg); // key:共享内存的键值,size:区域字节大小,shmflg:访问权限
  • shmat():详情见man shmat
/*功能是将共享内存区域附加到指定进程的地址空间中*/
void *shmget(int shmid,const void *shmaddr,int shmflg);
// shmid:共享内存标识符,shmaddr:指定进程地址,shmflg:操作方式。
// 调用成功返回区域指针,失败-1
  • shmdt():详情见man shmdt,值得注意的是:shmaddr为shmat返回的地址指针,只是将其分离出来,共享区域仍然存在。
/*当某一进程不再使用该内存区域时,将shmdt指定的内存区域从该进程中分离出来。*/
int shmdt(const void *shmaddr); //
  • shmctl():详情见man shmct
/*
* 通过cmd指令对shmid提供的标识符指定的共享区域进行多种控制
* buf是一个指向shmid_ds结构体类型的指针
*/
int shmctl(int shmid,int cmd,struct shmid_ds *buf); // 对shmid提供的标识符指定的共享内存区域进行控制
cmd 含义
IPC_STAT 内核中,与标识符shmid相关的共享内存中的数据赋值到buf指向的共享区域中。
IPC_SET 根据参数buf指向的shmid_ds结构中值去设置shmid指向的共享内容的相关属性。
IPC_RMID 删除shmid标识符指向的共享内存区域,必须确保删除,否则该内存区域将不能释放
IPC_INFO 该值为Linux特有,用于获取关于系统共享内存限制和buf指向参数的信息。
SHM_INFO Linux特有的,用于获取一个shm_info结构共享内存消耗的系统资源信息。
SHM_STAT Linux特有的,与IPC_STAT相同,但是shmid不代表标识符,代表内核中维持所有共享内存区域信息数组的索引值
SHM_UNLOCK Linux特有的,用于解锁共享内存区域。

说了这么多,其实感觉就是废话,看代码吧:

#include<stdio.h>
#include<string.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/ipc.h>
#include<sys/shm.h>
int main()
{
int shmid; // 定义IPC对象标识符
int proj_id; //
key_t key; // IPC键值
int size; // 共享区域大小
char *addr; // 地址
pid_t pid; // 进程id
key=IPC_PRIVATE; // 键值取这个值,shmflg会自动取IPC_CREAT,自动创建新的共享内存区域
shmid=shmget(key,1024,IPC_CREAT|0660);
if(shmid==-1)
{
perror("create share memory failed!");
return 1;
}
addr=(char*)shmat(shmid,NULL,0);
if(addr==(char *)(-1))
{
perror("cannot attach!");
return 1;
}
printf("share memory segment's address:%x\n",addr);
strcpy(addr,"welcome to mrsoft!");
pid=fork();
if(pid==-1)
{
perror("error!!!!");
return 1;
}
else if(pid==0)
{
printf("child process string is' %s'\n",addr);
_exit(0);
}
else
{
wait(NULL);
printf("parent process string is '%s'\n",addr);
if(shmdt(addr)==-1)
{
perror("release failed!");
return 1;
}
if(shmctl(shmid,IPC_RMID,NULL)==-1)
{
perror("failed!");
return 1;
}
}
return 0;

}