一. 进程介绍

在linux操作系统中,进程是一个非常重要的概念,整个linux系统就是由大量的进程相互协作而组成的,涉及到如何创建使用进程,进程间如何进行数据交互等等,我们在使用中也会遇到多进程处理的模式,所以进程是我们必须掌握的。那么我们来深入了解一下什么是进程。

进程:是正在执行的程序实例。它不仅是存储在硬盘上的静态代码,而是程序被加载到内存后,从开始执行到结束的一个完整动态过程,包括创建、调度、执行和消亡。

它具有以下特点:

动态性:进程是程序的一次执行过程,具有生命周期(创建、就绪、运行、阻塞、终止)。

并发性:在宏观上,多个进程可以同时向前推进;在微观(单核CPU)上,它们交替轮流占用CPU时间片。

独立性:每个进程都拥有自己独立的虚拟地址空间(代码、数据、堆、栈)。在没有特定机制(如管道、共享内存)下,一个进程无法直接访问另一个进程的内存

异步性:进程的推进速度受CPU调度、I/O中断等外部因素影响,其执行速度不可预测,且执行结果可能是非确定的(取决于系统当时的负载)。

结构性:一个进程实体在物理上由三大部分严格组成:程序段(代码)+ 数据段(变量)+ 进程控制块(PCB)

需要注意  进程是系统资源分配的基本单位(主要指内存地址空间、文件句柄),而后面再说的线程则是cpu调度的基本单位。

进程跟程序是有区别的,程序是静态的,存放在磁盘上的指令和数据的有序集合(文件),而进程是程序的一次执行过程。

根据其结构,程序包括代码段  ,数据段 , BSS段 (组合成程序段),而进程在此基础上多了堆,栈和进程控制块。

  • BSS段:存放程序中未初始化全局变量
  • 数据段已初始化全局变量
  • 代码段:程序执行代码
  • 堆(heap):malloc等函数分配内存
  • 栈(stack)局部变量函数参数函数的返回值
  • 进程控制块(pcb)PID进程用户进程优先级文件描述符表

在这个中,我觉得重要的点是进程之间是独立的,也就是互不影响,如果不进行特殊方法是无法进行通讯数据交互的,同时,一个进程的崩溃是不会引起其他进程的,这与线程有很大区别,线程崩溃的话可能就会导致整个进程崩溃。

二. 查看和分析进程

在linux系统下有非常完善的进程查看,分析管理的工具,更多的是命令行的指令下面来一一介绍

1. 静态快照(pspstreepidof

日常用的比较多的就是ps命令了,可以显示当前系统的进程状态快照

 ps aux : 查看所有进程的详细信息

 ps -el : 以完整格式显示所有进程(也可以加上f这个参数,显示更多格式,如 aux 显示 %CPU%MEM,而 -el 显示 PRINI

 pstree :以树状的形式查看进程,可以直观的看到父子兄弟进程的关系(-p参数可以显示pid

 pidof :获取指定进程的进程pid

注意  ps 后面跟的参数有带-和不带-的,这是两种风格的参数写法,作用都差不多。

下面列一个表格解释一下这些参数的含义,大致了解一下

表头英文全称含义
FFlags进程的内核标志位(十六进制),表示该进程拥有的特殊属性。1:进程可以被复制,但是不能被执行;4:进程使用超级用户权限
SState进程状态,1.-D:不可被唤醒的睡眠状态,通常用于 I/O 情况。2.-R:该进程正在运行。3.-S:该进程处于睡眠状态,可被唤醒。4.-T:停止状态,可能是在后台暂停或进程处于除错状态。5.-W:内存交互状态(从 2.6 内核开始无效)。6.-X:死掉的进程(应该不会出现)。7.-Z:僵尸进程。进程已经中止,但是部分程序还在内存当中。8.-<:高优先级(以下状态在 BSD 格式中出现)。9.-N:低优先级。10.-L:被锁入内存。11.-s:包含子进程。12.-l:多线程(小写 L)。13.-+:位于后台。
UIDUser ID启动该进程的用户名(或数字ID)。
PIDProcess ID进程的唯一身份证号
PPIDParent PID父进程的PID
CCPU usageCPU 利用率(整数百分比,不含 % 号)。
PRIPriority内核调度优先级。数字越小,越优先被CPU执行。
NINice用户可调整的优先级(-20 到 19)。
ADDRAddress进程在内核空间的内存地址段偏移
SZSize进程虚拟内存大小,单位是 内存页(Page),通常 1页 = 4KB
WCHANWaiting CHANnel进程是否运行。"-"代表正在运行
STIMEStart TIME进程启动的时间(或日期)。
TTYTerminal关联的控制终端。? 表示无终端(守护进程/内核线程)。
TIMECPU Time进程累计占用的CPU时间(不是现实流逝时间)。
CMDCommand完整的命令行(包含路径和参数)。

2 . 进程动态信息(top)

top可以动态查看进程的运行状态,用crtl + <或者>进行前后翻页。

进入动态查看后可以直接按按键调整

按键作用实战场景
P按 %CPU 从高到低排序(默认就是这个)。想查谁在偷跑 CPU 时用。
M按 %MEM(物理内存)从高到低排序。服务器内存不够了,查谁内存占用大。
N按 PID 从大到小排序。想查最新启动的进程。
H显示线程(切到线程视角)。按一次显示所有线程(你会看到 PID 变 TID),再按一次切回进程视角。按 H 就能看到具体是哪个线程(TID)在吃 CPU。
f管理显示字段。按后会出现列选择器,按字母键可以把隐藏列(如 PPID、WCHAN)调出来。想看 ps -elf 里的 WCHAN,就用 f 把它勾选出来。
k杀掉进程。按后提示输入 PID,再输入 15(SIGTERM)或 9(SIGKILL)。发现无用进程,直接中止。
q退出 top

3 . 关于进程运行终端切换

  • jobs : 查看后台进程
  • bg : 将挂起的进程在后台运行
  • fg : 把后台运行的进程放到前台运行
  • ctrl+z : 把运行的前台进程转为后台并停止。
  • ./test & : 把test程序后台运行

大概运行流程我用示例来说

[root@iZ2zehrcw9x4jyxyo06saeZ mediamtx]# ./mediamtx & //此终端下后台运行程序
[1] 1040510
[root@iZ2zehrcw9x4jyxyo06saeZ mediamtx]# jobs  //查看终端下后台的程序
[1]+  Running                 ./mediamtx &
[root@iZ2zehrcw9x4jyxyo06saeZ mediamtx]# jobs -l  //查看更详细信息
[1]+ 1040510 Running                 ./mediamtx &
[root@iZ2zehrcw9x4jyxyo06saeZ mediamtx]# fg %1    //把后台程序放到前台运行
./mediamtx

[root@iZ2zehrcw9x4jyxyo06saeZ mediamtx]# ./mediamtx
2026/08/14 21:00:54 INF MediaMTX v1.11.3
2026/08/14 21:00:54 INF configuration loaded from /root/mediamtx/mediamtx.yml
2026/08/14 21:00:54 INF [RTSP] listener opened on :8554 (TCP), :8000 (UDP/RTP), :8001 (UDP/RTCP)
2026/08/14 21:00:54 INF [RTMP] listener opened on :1935
2026/08/14 21:00:54 INF [HLS] listener opened on :8890
2026/08/14 21:00:54 INF [WebRTC] listener opened on :8889 (HTTP), :8189 (ICE/UDP)
2026/08/14 21:00:54 INF [SRT] listener opened on :8890 (UDP)
^Z                                                //按crtl+z挂到后台
[1]+  Stopped                 ./mediamtx
[root@iZ2zehrcw9x4jyxyo06saeZ mediamtx]# bg %1    //将后台程序运行
[1]+ ./mediamtx &
[root@iZ2zehrcw9x4jyxyo06saeZ mediamtx]# jobs -l    
[1]+ 1042949 Running                 ./mediamtx &

大致就是这样

4 . 进程优先级

nice-按用户指定的优先级运行进程

nice [-n NI值] 命令

  • NI 范围是 -20~19数值越大优先级越低
  • 普通用户调整 NI 值的范围是 0~19,而且只能调整自己的进程。
  • 普通用户只能调高 NI 值,而不能降低。如原本 NI 值为 0,则只能调整为大于 0。
  • 只有 root 用户才能设定进程 NI 值为负值,而且可以调整任何用户的进程。

renice -改变正在运行进程的优先级

renice [优先级] PID

这个我感觉我用到的很少,所以先大概了解有这个东西,用到再查吧

三 . 进程的创建使用

通过上面对进程的介绍,应该有了个大致的了解了,接下来进入进程的具体使用

进程的创建 fork函数,进程的退出 exit函数 ,进程的回收 wait函数。

1.fork:进程创建函数

#include <unistd.h>
pid_t fork(void);

失败是返回-1 成功时父进程返回子进程的进程号(pid),子进程返回0,通常会通过pid_t的值来区分父子进程,并在对应的范围编写程序。

fork() 是一个系统调用,其核心作用是从已存在的进程中创建一个全新的进程(也就是父子进程)需要注意的是子进程会获得父进程的数据空间、堆、栈等资源的副本。这意味着,虽然初始状态完全一样,但父子进程拥有独立的地址空间,之后对内存的修改互不影响。并且现代 Linux 使用写时复制技术,使用fork后并不会立即复制整个内存而是共享物理页,只有写入时才复制。

#include <stdio.h>
#include <unistd.h> // 包含 pid_t 和 fork()、getpid()
#include <errno.h>  // 用于 perror
int main(){
    pid_t pid = fork();
    if(pid<0){
    perror("创建进程失败 \n");
    }
    else if(pid==0){
        printf("这里是子进程:%d \n",getpid()); //getpid()是获取当前进程pid的函数
    }
    else{
        printf("这里是父进程%d,我的子进程是%d \n",getpid(),pid);
    }
    printf("运行完毕 \n");
    return 0;
}

   这是执行结果,我们看到一个点是打印了两次运行完毕,这是因为父子进程都会运行共同区的代     码,在条件内才会执行各自进程的单独命令。还有执行顺序是操作系统定的,很难预测。

kickpi@kickpi:~/AllProject/teststudy$ ./test1
这里是父进程3093,我的子进程是3094
运行完毕
这里是子进程:3094
运行完毕
  • 子进程只执行fork之后的代码
  • 父子进程执行顺序操作系统决定
  • 子进程继承了父进程的所有内容
  • 父子进程有独立的地址空间,互不影响
  • 若父进程先结束,子进程成为孤儿进程,被init进程收养,子进程变成后台进程
  • 若子进程先结束,父进程如果没有及时回收,子进程变成僵尸进程

这里说一下僵尸进程是子进程已经执行结束(调用了exit退出),但父进程没有调用wait/waitpid回收它的 PCB 资源,子进程的退出状态无人接收,就会变成僵尸进程(僵尸进程已经停止运行,只是占用 PID 和 PCB 资源,无法用kill杀死)。处理方式是

  • 父进程主动调用wait/waitpid同步回收
  • 捕捉SIGCHLD信号,在处理函数中循环调用waitpid非阻塞回收
  • 父进程先退出,子进程成为孤儿进程,由init/systemd收养并自动回收

这些说到的函数后面会写到。

2.exec函数家族:执行指定程序

execl/execlp/execle     ,     execv/execvp/execvpe      ,       system

#include  <unistd.h>
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg, ..., char * const envp[]);

成功时执行指定的程序失败时返回 EOF(-1)

• path:可执行文件的完整路径(如 /bin/ls)。

• file文件名(如 ls)。系统会自动去 PATH 环境变量指定的目录里找。
• arg:可变参数列表,依次传入命令名、选项、参数,末尾必须以 (char *)NULL 结尾

• envp[]自定义环境变量数组(最后一个参数),格式如 {"HOME=/root", "PATH=/bin", NULL}

大概的使用和代码如下

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/wait.h>

int main(int argc, char *argv[]){
    int status;

    pid_t pif=fork();
    if(pif<0){
        // Error
    } else if(pif==0){
        // Child process
        char *envp[] = {"LANG=en_US.UTF-8", NULL};
        printf("Child process: PID=%d, PPID=%d\n", getpid(), getppid());
        //int result = execlp("ping", "ping", "-c", "4", "www.baidu.com", NULL); 
        //int result = execl("/bin/ls", "ls", "-l", NULL); 
        int result = execle("/bin/ping", "ping", "-c", "4", "www.baidu.com", NULL, envp);
        if (result == -1) {
            perror("exec failed");
        }
        exit(2);
    } else {
        // Parent process
        printf("Parent process: PID=%d, Child PID=%d\n", getpid(), pif);
        wait(&status); // Wait for child process to finish
        if (WIFEXITED(status)) {
            printf("Child exited with status: %d\n", WEXITSTATUS(status));
        } else {
            printf("Child did not exit successfully\n");
        }
    }

    return 0;
}

#include  <unistd.h>
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);

成功时执行指定的程序失败时返回EOF(-1)

• const char *path  程序的完整路径(如 /bin/ping

• const char *file  程序文件名(如 ping

• char *const argv[] 参数指针数组。数组第一个元素必须是 argv[0](程序名如ping),最后一个必须是 NULL

• envp[]        自定义环境变量数组(最后一个参数)

如果你对argv[0]有疑惑,int main(int argc, char *argv[])这里可以看到argv[0]就是指程序名,这是固定的,你可以自行printf打印一下看看,argv[1...]这之后的参数就是传入的参数了,argc则是参数个数,比如说execl("/bin/ls", "ls", "-l", NULL); 这里就可以和argv的数组一一对应,如何有效字符串是ls和-l也就是2。

#include <unistd.h>
int execve(const char *filename, char *const argv[], char *const envp[]);

这个是exec函数族的系统函数

  • 第1个参数filename —— 必须写完整路径(如 /bin/ls)。。

  • 第2个参数argv[] —— 参数数组,必须是指针数组,以 NULL 结尾。

  • 第3个参数envp[] —— 环境变量数组,必须是指针数组,以 NULL 结尾。这是强制必填的,不能省略!

注意:虽然有很多这样的函数,但是他们的功能都是一样的,都是清空当前缓存,然后执行指定的程序,所以最好挑几个好用的,比如说execvp。

使用示例

        // Child process
        char *args[] = {"ping", "-c", "4", "www.baidu.com", NULL};
        char *envp[] = {"LANG=en_US.UTF-8", NULL};
        printf("Child process: PID=%d, PPID=%d\n", getpid(), getppid());
        //int result = execv("/bin/ping", args); 
        //int result = execve("/bin/ping", args, envp);
        int result = execvp("ping", args);

3.exit:进程退出函数

#include <stdlib.h>
void exit(int status);  //库函数
#include <unistd.h>
void _exit(int status);  //系统调用

参数status

  • EXIT_SUCCESS:代表成功(通常定义为 0)。

  • EXIT_FAILURE:代表通用失败(通常定义为 1)。

函数的作用是 结束当前的进程并将status返回

需要注意的是exit会冲刷缓存区,而_exit不会

4.wait : 进程回收函数

#include <sys/wait.h>

pid_t wait(int *status);//阻塞方式回收
pid_t waitpid(pid_t pid, int *status, int options);//指定等待结束的子进程

父进程主动去等待并回收子进程

成功时返回回收的子进程的进程号失败时返回EOF(-1)

status:输出型参数,得到结束进程的死亡信息

pid_t pid : 要等待结束的子进程的pid

int options : 指定回收方式(阻塞非阻塞等)

这里列一下status的解包,感谢deepseek的总结

使用前提

返回值/作用

WIFEXITED(status)

如果子进程正常终止(调用 exit 或 return),返回 真(非0)

WEXITSTATUS(status)

必须在 WIFEXITED 为真时使用

返回子进程传给 exit 或 return 的退出码(低 8 位,0~255)。

WIFSIGNALED(status)

如果子进程被信号杀死(如 kill -9 或段错误),返回 

WTERMSIG(status)

必须在 WIFSIGNALED 为真时使用

返回杀死子进程的信号编号(如 9 代表 SIGKILL11 代表 SIGSEGV)。

WCOREDUMP(status)

必须在 WIFSIGNALED 为真时使用

如果子进程被杀时产生了 core dump 文件,返回 (非标准宏,但 Linux 支持)。

WIFSTOPPED(status)

如果子进程处于暂停状态(如收到 SIGSTOP/Ctrl+Z),返回 (通常配合 WUNTRACED 使用)。

WSTOPSIG(status)

必须在 WIFSTOPPED 为真时使用

返回导致子进程暂停的信号编号

WIFCONTINUED(status)

如果子进程在暂停后收到 SIGCONT 恢复运行,返回 (Linux 特有,需配合 WCONTINUED)。

还有option的回收方式参数(可以使用 | )

常量(宏)数值含义
00默认阻塞:如果子进程没结束,waitpid 会一直卡在这里,直到子进程终止。
WNOHANG1非阻塞轮询:如果子进程还在运行,waitpid 立即返回 0,不卡住父进程。
WUNTRACED2同时等待子进程暂停(如 Ctrl+Z)。如果子进程被 SIGSTOP 暂停了,waitpid 也会返回,且 WIFSTOPPED(status) 为真。
WCONTINUED4同时等待子进程在暂停后恢复(收到 SIGCONT)。

具体代码,上面有一个完整的,我就不再演示了。

到这里,进程的创建,使用,退出,回收就结束了

四 . 进程间通讯

我们上面说了,在进程之间彼此是独立的,互不影响,但是我们多进程之间需要进行进程间通讯又该怎么办呢,这时候就需要需要掌握进程间通讯的方法了,有 管道,共享内存,信号,消息队列,网络/套接字(socket)等,这几种方式根据情况进行选择

  • 管道 / 消息队列 / 套接字:数据需要 拷贝 2 次(用户A -> 内核缓冲区 -> 用户B)。优点是内核管理缓冲,收发双方不需要同步访问(解耦)。

  • 共享内存:数据 拷贝 0 次!内核把同一块物理页框映射到两个进程的虚拟地址空间。进程 A 直接写这块内存,进程 B 立刻就能看到。这是最快的 IPC,但缺点是需要自己用“信号量”加锁,防止 A 在写的时候 B 在读(竞态条件)。

1.管道

(1) . 管道介绍

管道是内核内存中一块大小为 4KB(或其他页大小)的“环形缓冲区”(循环队列),它通过两个文件描述符(fd[0] 读端,fd[1] 写端)向进程提供读写接口。

管道分为两种,匿名管道(也叫无名管道) 和 有名管道

管道是半双工的,必须一个进程读取,一个进程写入。

(2) . 匿名管道
#include <unistd.h>
int pipe(int fd[2]);
  • 成功:返回 0fd[0] 是读端,fd[1] 是写端。

  • 失败:返回 -1

单向流动:数据只能从写端(fd[1])流向读端(fd[0])。如果想双向通信,必须创建两根管道

进程间使用:在 fork() 之后,父子进程都拥有这两个 fd

  • 如果父写子读:父进程必须 关闭 fd[0](读端),子进程必须 关闭 fd[1](写端)。

  • 如果子写父读:方向反过来。

关掉不用的端:这一步需要注意!如果不关,写端一直存在,读端在读到数据后不会返回 0(EOF),而是会一直阻塞等待新数据,导致程序卡死。

使用场景:在有亲属关系的进程间使用,如父子进程,兄弟进程等

示例如下

#include<stdio.h>
#include<stdlib.h>
#include <sys/wait.h>
#include<unistd.h>
#include<fcntl.h>
#include<string.h>
#include<errno.h>
int main(int argc, const char *argv[])
{
	int pd[2];
	if(pipe(pd)<0)
	{
		perror("pipe");
		return -1;
	}
	pid_t pid=fork();
	if(pid<0){
		perror("fork");
		exit(0);
	}
	else if(pid==0)
	{
		//子进程
		close(pd[0]);
		char buffer[100];
		for(int i=0;i<5;i++){
			memset(buffer, 0, sizeof(buffer));
			sprintf(buffer,"Hello from child process %d!\n", i);
			write(pd[1],buffer,strlen(buffer));
			sleep(1);
		}
		exit(1);
	}
	else
	{
		//父进程
		close(pd[1]);
		char buffer[10240];
		while(read(pd[0],buffer,sizeof(buffer))>0)
		{
			printf("Parent received: %s\n", buffer);
		}
		wait(NULL); // 等待子进程结束
	}
	return 0;
}
(3) . 有名管道
#include <sys/types.h>
#include <sys/stat.h>

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

open(const char *path, O_RDONLY);//1
open(const char *path, O_RDONLY | O_NONBLOCK);//无阻塞读
open(const char *path, O_WRONLY);//3
open(const char *path, O_WRONLY | O_NONBLOCK);//无阻塞写
  • 参数pathname 是路径,mode 是权限(如 0666)。

  • 返回:成功返回 0,失败返回 -1

有名管道仍然是一个由内核管理的环形缓冲区,读写规则(阻塞、字节流)也和匿名管道一模一样

但是他又有很大区别

  • 有名管道可以使非亲缘的两个进程互相通信
  • 通过路径名来操作,在文件系统中可见,但内容存放在内存
  • 使用文件IO来操作有名管道
  • 遵循先进先出规则
  • 不支持leek定位操作
  • 使用半双工读写
  • 写入数据不超过PIPE_BUF 则是原子写入操作,超过则不保证原子性

另一个需要记住的点是open的阻塞规则

  • 默认行为(阻塞):当你 open 一个 FIFO 时,读写两端必须同时准备好,否则 open 会阻塞:

    • 如果你以只读方式打开(O_RDONLY),open 会阻塞,直到另一端以只写方式打开。

    • 如果你以只写方式打开(O_WRONLY),open 会阻塞,直到另一端以只读方式打开。

    • 目的:这叫做“握手”机制,确保通信双方都在线,防止数据一写入就丢失。

  • 非阻塞模式:如果你希望 open 立即返回,可以设置 O_NONBLOCK

    • 只读 + O_NONBLOCK:立即返回,即使没有写端。

    • 只写 + O_NONBLOCK:如果没有读端,open 返回 -1 并置 errno 为 ENXIO(设备不存在)

创建使用完成后记得使用 unlink(const char *path) 函数清除管道

有名管道可以用于亲缘进程和非亲缘进程。

#include<stdio.h>
#include<stdlib.h>
#include<sys/wait.h>
#include <sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>
#include<string.h>
#include<errno.h>
int main(int argc, const char *argv[])
{
	const char *path={"/tmp/fifo"};
	int fifo=mkfifo(path,0664);
	if(fifo==-1)
	{
		perror("mkfifo");
		unlink(path);
		return -1;
	}
	pid_t pid=fork();
	if(pid<0){
		perror("fork");
		exit(0);
	}
	else if(pid==0)
	{
		//子进程
		int fd=open(path,O_WRONLY);
		if(fd==-1)
		{
			perror("open write");
			exit(1);
		}
		ssize_t bytes_written = write(fd, "hello world", sizeof("hello world") - 1);
		if (bytes_written == -1) {
			perror("write");
			exit(1);
		}
		close(fd);
		exit(0);
	}
	else
	{
        int fd = open(path, O_RDONLY | O_NONBLOCK);
        if (fd == -1) {
            perror("open read");
            exit(1);
        }

        char buff[100];
        int n;
        int retry_count = 0;
		sleep(1);
        while (1) {
            n = read(fd, buff, sizeof(buff) - 1);

            if (n > 0) {
                // 读到了数据
                buff[n] = '\0';
                printf("读取成功: %s\n", buff);
                break; 
            } 
            else if (n == 0) {
                // 写端已经关闭,管道空了
                printf("写端已关闭,结束读取。\n");
                break;
            } else{
				perror("read");
			}
        }

        close(fd);
        unlink(path);
        wait(NULL);
    }
	return 0;
}

2.共享内存

共享内存(Shared Memory)是进程间通信(IPC)中最快、最高效的一种方式。它允许两个或多个不相关的进程访问同一块物理内存区域,就像访问自己的私有内存一样。

其实现原理是

  • 虚拟内存映射:不同进程拥有各自独立的虚拟地址空间。共享内存的原理是,内核将同一块物理内存页,分别映射到不同进程的虚拟地址空间中。

  • 数据流转:进程 A 往该内存写入数据,进程 B 随后去读,数据直接在物理内存层面完成传递,完全绕过了内核态与用户态之间的数据拷贝(即零拷贝),因此吞吐量极高,延迟极低。

所以共享内存的实现有两步,首先是创建共享内存,如何进行内存映射,之后就可以进行读写了

(1) . 内存映射

大概的意思就是使一个磁盘文件内存中一个缓冲区相映射,进程可以像访问普通内存一样对文件进行访问,不必再调用read,write。具体一下就是下面说的

内存映射(mmap) 是一种机制,它把文件或设备映射到进程的虚拟地址空间,建立映射关系。调用 mmap 时,内核主要做的是在虚拟地址空间里分配一段连续地址,并记录“这段虚拟地址对应文件的哪一部分”。 此时并没有真的把文件内容读进内存。第一次访问这段映射的内存时,CPU 发现页表里还没有对应的物理页,就会触发缺页异常,内核收到通知后,从磁盘把相应的数据页读到物理内存(Page Cache),更新页表,再返回给用户程序继续执行。第二次访问同一页时,页表已经建立好映射,直接访问内存就行,速度和普通内存访问几乎一样快。

他这样就实现了用户空间内核空间的高效交互方式、

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
int munmap(void *addr, size_t length);//解除内存映射
int msync(void *addr, size_t length, int flags);
mmap成功返回创建映射区的首地址,失败返回-1(MAP_FAILED),设置errno
这里的参数含义很重要
  • addr:通常填 NULL,让内核自己选一个合适的虚拟地址返回。

  • length:映射地址空间的字节数(>0),从被映射文件开头 offset 个字节开始算起

  • port :指定访问权限,如 PROT_READ(可读), PROT_WRITE(可写), PROT_EXEC(可执行), PROT_NONE(不可访问)。

  • flags :指定映射对进程、内核以及其他进程的可见性生命周期性能表现,有MAP_SHARED(共享的,修改的数据立即写入源目标,其他进程看到到变化),MAP_PRIVATE(私有的,修改的数据系统会用副本记录,原文件不会变),MAP_FIXED (表示必须使用 start 参数作为开始地址,如果失败不进行修正),MAP_ANONYMOUS (无文件映射,分配归零内存(fd 传 -1),用于血缘关系进程间通信)

  • fd :映射的文件句柄

  • offset :文件偏移量,一般设置为 0 表示从文件头部开始映射

注意事项:

(1) 创建映射区的过程中,隐含着一次对映射文件的读操作,将文件内容读取到映射区。
(2) 当MAP_SHARED时,要求:映射区的权限应 <= 文件打开的权限(出于对映射区的保护),如果不满足报非法参数(Invalid argument)错误。
当MAP_PRIVATE时候,mmap中的权限是对内存的限制,只需要文件有读权限即可,操作只在内存有效,不会写到物理磁盘,且不能在进程间共享。
(3) 映射区的释放与文件关闭无关,只要映射建立成功,文件可以立即关闭。
(4) 用于映射的文件大小必须>0,当映射文件大小为0时,指定非0大小创建映射区,访问映射地址会报总线错误,指定0大小创建映射区,报非法参数错误(Invalid argument)
(5) 文件偏移量必须为0或者4K的整数倍(不是会报非法参数Invalid argument错误).
(6)映射大小可以大于文件大小,但只能访问文件page的内存地址,否则报总线错误 ,超出映射的内存大小报段错误

(7)mmap创建映射区出错概率非常高,一定要检查返回值,确保映射区建立成功再进行后续操作

#include<stdio.h>
#include<stdlib.h>
#include<sys/wait.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<unistd.h>
#include<fcntl.h>
#include<sys/mman.h>
#include<string.h>
#include<errno.h>
int main(int argc, const char *argv[])
{
	int fd = open("test.txt", O_RDWR | O_CREAT, 0666);
    if (ftruncate(fd, 1024) == -1) { //因为初始创建的文件大小为0,所以给文件设置大小
        perror("ftruncate");
        close(fd);
        exit(1);
    }
	if(fd == -1)
	{
		perror("open");
		exit(1);
	}
	char *addrptr = mmap(NULL,1024,PROT_READ|PROT_WRITE,MAP_SHARED,fd,0);
	if(addrptr == MAP_FAILED){
		perror("内存映射失败");
		close(fd);
        exit(1);
	}
	close(fd);

    strcpy(addrptr, "Hello, mmap success!");  //写入
    printf("写入内容: %s\n", addrptr);

    if (msync(addrptr, 1024, MS_SYNC) == -1) { //强制落盘写入,只对MAP_SHARED有效
        perror("msync");
    }
	printf("读取数据:%s\n",addrptr);   //读取
    if (munmap(addrptr, 1024) == -1) {
        perror("munmap");
    }
	return 0;
}
(2) . 共享内存
A . System V 系统函数,

使用这个标准系列,整体的创建流程如下

#include <sys/types.h>
#include <sys/ipc.h>
key_t ftok(const char *pathname, int proj_id);//生成唯一key

#include <sys/ipc.h>
#include <sys/shm.h>
int shmget(key_t key, size_t size, int shmflg);//创建共享内存

#include <sys/shm.h>
void *shmat(int shmid, const void *shmaddr, int shmflg);//内存映射

#include <sys/shm.h>
int shmdt(const void *shmaddr);//解除映射

#include <sys/ipc.h>
#include <sys/shm.h>
int shmctl(int shmid, int cmd, struct shmid_ds *buf);//删除共享内存

key_t ftok(const char *pathname, int proj_id):生成唯一标识key

  • 成功:返回生成的 key_t 值。

  • 失败:返回 -1,并设置 errno

pathname:  必须是一个真实存在且进程有权限访问的文件或目录的路径

id   子序号,它是一个8bit的整数。即范围是0~255

int shmget(key_t key, size_t size, int shmflg): 创建共享内存

  • 成功:返回一个非负整数

  • 失败:返回 -1,并设置 errno

size        :  共享内存的大小

shmflg:权限和标志位。常用组合:

  • 0666:普通权限(所有用户可读可写,受 umask 影响)。

  • IPC_CREAT:如果 key 不存在则创建,存在则返回已存在的 ID。

  • IPC_EXCL必须配合 IPC_CREAT。如果 key 已存在,则报错返回 -1,保证你是第一个创建者

void *shmat(int shmid, const void *shmaddr, int shmflg):内存映射

  • 成功:返回映射到进程空间的起始地址指针

  • 失败:返回 (void *) -1

shmid:由 shmget 返回的那个 ID

shmaddr:虚拟地址,通常填NULL

shmflg : 权限,如0(可读可写),SHM_RDONLY(只读)

int shmdt(const void *shmaddr):解除映射

  • 成功:返回0

  • 失败:返回 -1

shmaddr : shmat 返回的那个指针地址

int shmctl(int shmid, int cmd, struct shmid_ds *buf):删除共享内存

  • 成功:IPC_RMID 返回 0IPC_STAT 返回 0 并填充 buf

  • 失败:返回 -1

shmid : 要操作的共享内存 ID

cmd : 要执行的命令。最核心的有两个:

  • IPC_RMID标记删除共享内存。注意:如果此时还有其他进程挂接着(nattch > 0),内存不会立即释放,而是等到所有进程都 shmdt 分离后,内核才真正回收物理内存。这是最常用的销毁方式。

  • IPC_STAT:获取共享内存的状态信息(如大小、创建时间、当前挂接数),填入 buf 指向的结构体。

  • IPC_SET:修改共享内存的权限或所有者信息(需 root 权限)

buf struct shmid_ds 结构体的指针。对于删除操作(IPC_RMID),直接填 NULL 即可

下面给出一段示例代码

//创建写入
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/ipc.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<sys/shm.h>
#include <string.h>
int main(int argc, const char *argv[])
{
	char *path="test.txt";
	//创建表示key
	key_t key=ftok(path,100);
	if(key==-1){
		perror("key error:");
		return -1;
	}
	printf("key=%d\n",key);
	//创建共享内存
	int shm_ID=shmget(key,1024,IPC_CREAT | 0664);
	if(shm_ID==-1){
		perror("shmget error:");
		return -1;
	}
	//内存映射
	char *addr_buf=shmat(shm_ID,NULL,0);
	if(addr_buf==(void *)-1){
		perror("shmat error:");
		return -1;
	}
	strcpy((void *)addr_buf,"这里是共享内存");

	return 0;
}
//读取并关闭
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/ipc.h>
#include<sys/types.h>
#include<sys/stat.h>
#include<sys/shm.h>
int main(int argc, const char *argv[])
{
	char *path="test.txt";
	key_t key=1677856027;
	int shmmid=shmget(key,1024,0666);
	char *ptr=shmat(shmmid,NULL,0);
	if(ptr==(void *)-1){
		perror("shmat error:");
		return -1;
	}
	printf("%s\n",ptr);
	shmdt(ptr);
    shmctl(shmmid,IPC_RMID,NULL);
	return 0;
}
B. POSIX 现代化函数

这个是基于系统函数的封装,安全性更好一点

#include <sys/mman.h>
#include <sys/stat.h>   // 为了 mode_t
#include <fcntl.h>      // 为了 O_* 常量

int shm_open(const char *name, int oflag, mode_t mode);//创建共享内存
  • 成功:返回一个非负整数文件描述符。

  • 失败:返回 -1,并设置 errno

name : 共享内存对象的名字,在 Linux 下,它必须以斜杠 / 开头,如 "/my_shm".

oflag : 打开方式 如

  • O_RDONLY / O_RDWR:只读 / 读写。

  • O_CREAT:对象不存在则创建。

  • O_EXCL:与 O_CREAT 配合,若对象已存在则报错,确保自己是创建者。

  • O_TRUNC:若对象存在,将其大小截断为 0。

mode :指定新创建对象的权限,如 0666。配合 O_CREAT使用

#include <sys/mman.h>
int shm_unlink(const char *name);//删除共享内存
  • 成功:返回 0

  • 失败:返回 -1,并设置 errno

#include <unistd.h>
#include <sys/types.h>
int ftruncate(int fd, off_t length);//设置共享内存大小
  • 成功:返回 0

  • 失败:返回 -1,并设置 errno

如果新创建共享内存大小为0,进行内存映射前需要设置内存大小

#include <sys/mman.h>
void *mmap(void *addr, size_t length, int prot, 
            int flags, int fd, off_t offset);  //内存映射  

内存映射,这里已经在内存映射的小结讲过了,我就不再说了

大概的使用流程就是如下

  1. shm_open:创建或打开一个共享内存对象,获得文件描述符 fd

  2. ftruncate:用 fd 设置共享内存对象的大小。

  3. mmap:将 fd 映射到进程的虚拟地址空间,获得指针 ptr

  4. 读写:通过 ptr 指针直接操作共享内存。

  5. munmap:解除映射。

  6. close:关闭文件描述符。

  7. shm_unlink:删除共享内存对

给出示例使用代码

//写入代码 write.c
#include <sys/mman.h>
#include <sys/stat.h>   // 为了 mode_t
#include <fcntl.h>      // 为了 O_* 常量
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, const char *argv[])
{
	int fd= shm_open("/my_shm",O_CREAT | O_RDWR, 0666);
	if(fd==-1){
		perror("shm_open error");
		return -1;
	}
	ftruncate(fd,1024);
	char *ptr_buff = mmap(NULL,1024,PROT_WRITE|PROT_READ,MAP_SHARED,fd,0);
	if(ptr_buff == (void *)-1){
		perror("mmap error :");
		exit(EXIT_FAILURE);
	}
	if(strcpy(ptr_buff,"这是共享内存")){
		printf("写入成功\n");
	}
	
	munmap(ptr_buff,1024);
	close(fd);
	//shm_unlink("/my_shm");
	return 0;
}
//读取代码 read.c
#include <sys/mman.h>
#include <sys/stat.h>   // 为了 mode_t
#include <fcntl.h>      // 为了 O_* 常量
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
int main(int argc, const char *argv[])
{
	int fd= shm_open("/my_shm",O_RDWR, 0666);
	if(fd==-1){
		perror("shm_open error");
		return -1;
	}
	ftruncate(fd,1024);
	char *ptr_buff = mmap(NULL,1024,PROT_WRITE|PROT_READ,MAP_SHARED,fd,0);
	if(ptr_buff == (void *)-1){
		perror("mmap error :");
		exit(EXIT_FAILURE);
	}
	printf("读取到数据:%s\n",ptr_buff);
	
	munmap(ptr_buff,1024);
	close(fd);
	int dele=shm_unlink("/my_shm");
	if(dele==-1){
		perror("delete error:");
	}
	printf("共享内存已经删除\n");
	return 0;
}

共享内存和管道是不一样,首先他是非阻塞的,不会等待数据,是双工的,就是两端都可以进行读写操作,但是这就会带来一下问题,两端都进行读写可能会造成数据紊乱或者读取紊乱,如

  1. 竞态条件(Race Condition):多进程同时写入同一个地址,后写的数据覆盖先写的,导致数据支离破碎。

  2. 内存可见性(Memory Visibility):进程 A 写入了数据,由于 CPU 多级缓存(L1/L2)的存在,数据可能还在 A 所在核心的 Cache 里,没有刷到物理 RAM,进程 B 去读,读到的还是旧数据。

所以就需要用到一定的方法来确保数据写入和读取的准确性和稳定性。

3 . 信号

简单了解信号完后可以看下这篇文章,说的非常深入。

Linux--信号(万字详解!超完整!)_linux 信号-CSDN博客

(1). 介绍

信号,又称为软中断信号,是Linux系统响应某些条件而产生的一个事件。它是操作系统向一个进程或者线程发送的一种异步通知,用于通知该进程或线程某种事件已经发生,需要做出相应的处理。一般是不携带数据,只是进程间的通知和处理

在linux系统中一共有64种信号,可以通过 kill -l 的命令查看

ubuntu1@ubuntu1:~$ kill -l
 1) SIGHUP	 2) SIGINT	 3) SIGQUIT	 4) SIGILL	 5) SIGTRAP
 6) SIGABRT	 7) SIGBUS	 8) SIGFPE	 9) SIGKILL	10) SIGUSR1
11) SIGSEGV	12) SIGUSR2	13) SIGPIPE	14) SIGALRM	15) SIGTERM
16) SIGSTKFLT	17) SIGCHLD	18) SIGCONT	19) SIGSTOP	20) SIGTSTP
21) SIGTTIN	22) SIGTTOU	23) SIGURG	24) SIGXCPU	25) SIGXFSZ
26) SIGVTALRM	27) SIGPROF	28) SIGWINCH	29) SIGIO	30) SIGPWR
31) SIGSYS	34) SIGRTMIN	35) SIGRTMIN+1	36) SIGRTMIN+2	37) SIGRTMIN+3
38) SIGRTMIN+4	39) SIGRTMIN+5	40) SIGRTMIN+6	41) SIGRTMIN+7	42) SIGRTMIN+8
43) SIGRTMIN+9	44) SIGRTMIN+10	45) SIGRTMIN+11	46) SIGRTMIN+12	47) SIGRTMIN+13
48) SIGRTMIN+14	49) SIGRTMIN+15	50) SIGRTMAX-14	51) SIGRTMAX-13	52) SIGRTMAX-12
53) SIGRTMAX-11	54) SIGRTMAX-10	55) SIGRTMAX-9	56) SIGRTMAX-8	57) SIGRTMAX-7
58) SIGRTMAX-6	59) SIGRTMAX-5	60) SIGRTMAX-4	61) SIGRTMAX-3	62) SIGRTMAX-2
63) SIGRTMAX-1	64) SIGRTMAX

1-31是不可靠信号(指不支持排队的信号) ,34-64是可靠信号(支持排队的信号)

信号大概有四个流程:产生,传递,处理,中止

要查看具体信号的作用可以使用 man 7 signal 命令,往下翻翻就有了下面这张图了

当进程接受到信号,会采取一些处理方式

忽略信号:进程可以选择忽略某些信号,即不对该信号进行任何处理。
捕捉信号(自定义处理):进程可以注册一个信号处理函数来捕捉特定的信号,并在接收到该信号时执行相应的处理逻辑。
默认处理:如果进程没有注册信号处理函数且没有选择忽略信号,则系统会按照默认的处理方式来处理该信号。通常情况下,默认处理方式会导致进程终止或停止,如忽略,终止,终止(并设置文件),停止,恢复。

然后我再列一下常用信号

编号信号名称含义 / 触发条件默认操作可捕获/忽略?
1SIGHUP终端挂起或控制进程终止(如关闭终端)终止进程
2SIGINT用户按 Ctrl+C 产生的中断终止进程
3SIGQUIT用户按 Ctrl+\ 产生,要求退出并产生 core终止进程 + 产生 core dump
4SIGILL执行非法指令(如损坏的二进制)终止进程 + 产生 core dump
6SIGABRT调用 abort() 函数产生终止进程 + 产生 core dump
7SIGBUS总线错误(如内存地址不对齐)终止进程 + 产生 core dump
8SIGFPE浮点运算异常(如除零)终止进程 + 产生 core dump
9SIGKILL强制杀死进程(不可捕获/忽略终止进程
10SIGUSR1用户自定义信号 1终止进程
11SIGSEGV段错误(访问非法内存)终止进程 + 产生 core dump
12SIGUSR2用户自定义信号 2终止进程
13SIGPIPE向已关闭的管道或 socket 写入数据终止进程
14SIGALRM由 alarm() 或 setitimer() 触发的定时器超时终止进程
15SIGTERM请求进程终止(默认 kill 命令终止进程
17SIGCHLD子进程状态改变(停止/继续/退出)忽略(父进程需用 wait 回收)
18SIGCONT让停止的进程继续运行继续执行(如果进程被停止)
19SIGSTOP停止进程(不可捕获/忽略停止进程(暂停执行)
20SIGTSTP用户按 Ctrl+Z 产生的挂起信号停止进程
21SIGTTIN后台进程试图读取终端停止进程
22SIGTTOU后台进程试图写入终端停止进程
28SIGWINCH终端窗口大小改变忽略
31SIGSYS非法系统调用终止进程 + 产生 core dump

同时我们在进行操作时可以向进程传递信号,有一些命令可以做到

kill [信号名称] pid   //向指定pid的进程传递信号
killall  -u user //向指定用户的进程发信号(默认信号杀死进程)
killall  proc //向指定名称的进程发信号(默认信号杀死进程)
参数:
-9 或 -SIGKILL:强制立即杀死(不可捕获)。例如 killall -9 firefox。

-s:指定发送的信号类型。例如 killall -s SIGUSR1 nginx。

-q(安静模式):如果没找到匹配的进程,不报错也不输出信息。

-v(详细模式):报告命令执行的具体细节。
(2).信号的产生

我们前面说过信号又叫软中断信号,是Linux系统响应某些条件而产生的一个事件。他的来源分为三大类:

硬件来源:这是比较底层的产生方式,由底层硬件或内核异常触发,像一些键盘组合键会致使驱动向前台进程发信号(如Ctrl + C → 产生 SIGINT (2)),或者一些cpu异常等等。

软件来源 :由进程主动调用系统调用产生,常用的主动触发信号模式,如kill() / raise(),alarm(seconds) 或 setitimer()等

内核来源 :内核或系统状态改变,自动触发的信号

这里有一些我们会用到的函数

通用函数:

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

int kill(pid_t pid, int sig);//向指定进程发送信号
  • 参数 pid

    • pid > 0:发给 PID 等于该值 的特定进程。

    • pid == 0:发给 当前进程所在的整个进程组 内的所有进程(即所有“同组兄弟”)。

    • pid == -1:发给 除 init (PID 1) 之外的所有进程(广播,需要 root 权限或当前用户权限)。

    • pid < -1:发给 进程组 ID 等于 |pid| 的所有进程(例如 kill(-1234, SIGINT) 发给 PGID 为 1234 的组)。

  • 参数 sig:传递的信号编号(如 SIGTERM,若传 0 则用于探测进程是否存在,不会发送实际信号)。

#include <signal.h>

int raise(int sig);//给自己发送信号

定时器:

#include <unistd.h>

unsigned int alarm(unsigned int seconds);//一次性定时器
  • 作用:经过 seconds 秒后,向当前进程发送 SIGALRM (14) 信号。

  • 返回值:上次定时剩余时间

  • 每个进程只能有一个alarm定时器,再次调用会覆盖之前的设置

#include <sys/time.h>
int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);
//设置一个一次性或周期性的定时器

struct itimerval {
    struct timeval it_interval; /* 周期性间隔时间 */
    struct timeval it_value;    /* 第一次到期时间 */
};
struct timeval {
    long tv_sec;                /* 秒 */
    long tv_usec;               /* 微秒 */
};

实际设定的时间长度 = tv_sec + tv_usec / 1,000,000(秒)。
  • 参数 which:指定计时器的类型:

    • ITIMER_REAL真实计时器,计算程序运行的实际时间(挂钟时间)。到期后发送 SIGALRM 信号。

    • ITIMER_VIRTUAL虚拟计时器,仅计算进程在用户态消耗的CPU时间。到期后发送 SIGVTALRM 信号。

    • ITIMER_PROF实用计时器,计算进程在用户态和内核态消耗的CPU时间。到期后发送 SIGPROF 信号。

  • 参数 new_value:负责设定 timout 时间
  • 参数 old_value: 存放旧的timeout值,一般指定为NULL
(3).信号传递

当信号产生后,他并不会立即被进程所处理,而是需要经过一系列的状态转换,才能被送达处理

未决:信号产生后,会进入一个等待的状态(未决),内核会立刻在目标进程的 PCB(进程控制块)里,把对应的信号位标记为 1(或者加入队列)。

阻塞:内核并不会立刻把信号交付给进程,而是先查看进程的信号屏蔽字(阻塞集)

  • 如果该信号被阻塞:内核就会让它继续停留在“未决”状态,直到解除阻塞为止。

  • 如果该信号未被阻塞:内核才会放行,进入下一步

递达:到这里信号就会实际交付给对应的进程

经过这样的一套流程后信号才能真正进入进程被后续处理

(4). 信号处理

当信号处于一个未决状态等待被送入进程处理时由内核检查这个过程

信号处理的检查时机由内核强制控制,发生在每次“内核态→用户态”的路径上。即便进程在死循环,硬件时钟中断也会强制触发该路径。用户层无需、也无法主动触发“递达”动作,只能通过 sigpending 被动查看未决列表,或通过 pause/sigsuspend 主动挂起等待信号到来。

当内核决定递达这个信号时,会进行以下几种处理方式之一:

  1. 执行默认动作(SIG_DFL):大部分是终止进程(Term)、忽略(Ign)、暂停(Stop)或核心转储(Core)。

  2. 忽略信号(SIG_IGN):内核直接丢弃该信号,什么也不做。

  3. 执行自定义处理函数(捕捉信号):内核会暂停进程当前正在执行的主逻辑,保存现场(寄存器、程序计数器等),然后跳转到你写的那个回调函数里去执行,执行完后再恢复现场,回到主程序被打断的地方继续执行。

这个处理是通过函数传入不同的参数实现的

#include <signal.h>
void (*signal(int signum, void (*handler)(int)))(int);

//原函数是这个函数指针,因为有点复杂,所以标准库头文件定义了别名
typedef void (*sighandler_t)(int);
//别名是sighandler_t
sighandler_t  signal(int signum, sighandler_t handler);//函数就变成了这样

作用:捕捉信号并执行自定义的函数

参数 signum :要设置的信号编号(如 SIGINT

参数handler : 指定的信号处理函数SIG_DFL代表执行默认动作SIG_IGN 代表忽略信号;

返回值成功时返回原先的信号处理函数失败时返回SIG_ERR

#include <signal.h>
#include <stdio.h>
#include <unistd.h>

typedef void (*sighandler_t)(int);

void my_handler(int sig) {
    printf("捕获到信号 %d\n", sig);
}

int main() {
    // 1. 设置自定义捕捉
    sighandler_t ret = signal(SIGINT, my_handler);  
    // 2. 设置忽略(此时按 Ctrl+C 毫无反应)
    // signal(SIGINT, SIG_IGN);  
    // 3. 设置恢复默认(按 Ctrl+C 直接杀进程)
    // signal(SIGINT, SIG_DFL);  

    while(1) 
    sleep(1);
    return 0;
}

还有一种能更精确处理信号的函数sigaction

#include <signal.h>
int sigaction(int signum, const struct sigaction *act, 
                struct sigaction *oldact);

//结构体
struct sigaction {
    void     (*sa_handler)(int);   // 处理函数指针,或者 SIG_DFL / SIG_IGN
        //另一个信号处理函数,它有三个参数,可以获得关于信号的更详细的信息
    void     (*sa_sigaction)(int, siginfo_t *, void *); 
    sigset_t   sa_mask;            // 处理本信号时,额外要阻塞的信号集
    int        sa_flags;           // 控制标志位(SA_RESTART 等)
    //sa_flags参考值如下:
          //SA_SIGINFO:使用 sa_sigaction 成员而不是 sa_handler 作为信号处理函数
          //SA_RESTART:使被信号打断的系统调用自动重新发起。
          //SA_RESETHAND:信号处理之后重新设置为默认的处理方式。
          //SA_NODEFER:使对信号的屏蔽无效,即在信号处理函数执行期间仍能发出这个信号。
    void     (*sa_restorer)(void); // 已废弃,不用管
};
  • signum:要捕捉的信号编号(如 SIGINT)。

  • act:指向新行为的结构体(非空则设置新动作)。

  • oldact:如果不为 NULL,则返回旧的行为(用于备份恢复)。

然后给一个综合的小示例

#include <stdio.h>
#include <signal.h>
#include <sys/time.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>

// volatile 防止编译器优化,sig_atomic_t 确保原子读写
volatile sig_atomic_t timer_flag = 0;

void sigalrm_handler(int sig) {
    timer_flag = 1;
}

int main() {
    struct sigaction sa;
    struct itimerval timer;

    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = sigalrm_handler;    // 指定处理函数
    sigemptyset(&sa.sa_mask);           // 处理期间不额外屏蔽信号
    sa.sa_flags = SA_RESTART;           // 自动重启被中断的系统调用

    if (sigaction(SIGALRM, &sa, NULL) == -1) {
        perror("sigaction 失败");
        return 1;
    }
    // 第一次时间:2 秒后
    timer.it_value.tv_sec = 2;
    timer.it_value.tv_usec = 0;
    // 每 2 秒触发一次
    timer.it_interval.tv_sec = 2;
    timer.it_interval.tv_usec = 0;

    if (setitimer(ITIMER_REAL, &timer, NULL) == -1) {
        perror("setitimer 失败");
        return 1;
    }

    printf("定时器已启动,每 2 秒触发一次。按 Ctrl+C 退出程序。\n");

    int count = 0;
    while (1) {
        // 检查标志位是否被信号处理函数置为 1
        if (timer_flag) {
            count++;
            printf("【%d】定时器到期,执行任务! (时间: %ld 秒)\n", 
                   count, time(NULL));
            
            // 重置标志位,等待下一次触发
            timer_flag = 0;

            if (count >= 5) {
                printf("任务执行 5 次,程序退出。\n");
                break;
            }
        }
        
        sleep(1);
    }

    // 停止定时器,将 it_value 置零
    timer.it_value.tv_sec = 0;
    timer.it_value.tv_usec = 0;
    setitimer(ITIMER_REAL, &timer, NULL);

    return 0;
}

//输出如下
ubuntu1@ubuntu1:~/c++project/test$ make signal 
定时器已启动,每 2 秒触发一次。按 Ctrl+C 退出程序。
【1】定时器到期,执行任务! (时间: 1787062847 秒)
【2】定时器到期,执行任务! (时间: 1787062849 秒)
【3】定时器到期,执行任务! (时间: 1787062851 秒)
【4】定时器到期,执行任务! (时间: 1787062853 秒)
【5】定时器到期,执行任务! (时间: 1787062855 秒)
任务执行 5 次,程序退出。

这里涉及到的函数的参数类型有点多,要针对性理解记忆一下再使用。

(5).信号的阻塞和信号集

信号阻塞:它是内核为每个进程维护的一个状态机制(具体存储在进程的 PCB 中)。这个机制决定了一组“当前被禁止递达”的信号名单,简单点来说就是暂时阻止信号被处理,并不是阻止信号的产生和忽略信号。

内核在进行递达动作时会查看阻塞集,根据阻塞状态判断是否递达

在信号阻塞状态如果持续收到这个信号,不可靠信号会进行覆盖,可靠信号有队列机制,支持排队,等阻塞状态解除,会全部执行。

要自由把握这个状态,就要掌握下面的信号集

#include <signal.h>

sigset_t set;                    // 定义一个信号集变量

sigemptyset(&set);               // 清空信号集(所有位都置 0)

sigfillset(&set);                // 填满清单(所有信号位都置 1,包含全部 64 个)

sigaddset(&set, SIGINT);         // 把 SIGINT (2号) 加入清单

sigdelset(&set, SIGINT);         // 把 SIGINT 从清单中移除

int sigpending(sigset_t *set);

int ret = sigismember(&set, SIGINT); // 检查 SIGINT 是否在清单里(在返回 1,不在返回 0),set是输出型参数

这是一些对应处理信号集的函数

int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);//设置信号屏蔽

//将进程的屏蔽字替换为由参数sigmask给出的信号集,然后挂起进程的执行
int sigsuspend(const sigset_t *sigmask);

返回值:若成功则返回0,若出错则返回-1

set:信号集变量

oleset :用来保存修改前的旧信号集,以便以后恢复

  • how

    • SIG_BLOCK:把 set 中的信号追加进当前屏蔽信号集(屏蔽更多)。

    • SIG_UNBLOCK:把 set 中的信号从屏蔽中移除(解除屏蔽)。

    • SIG_SETMASK:直接用 set 覆盖当前信号集。

  • 注意,不能阻塞SIGKILL和SIGSTOP信号

给出一段示例代码

#include <stdio.h>
#include <signal.h>
#include <sys/time.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <signal.h>

void handle(int sig){
    printf("信号%d被处理\n",sig);
}

int main(int argc ,char * argv[]){
    signal(SIGINT,handle);//重定义信号
    sigset_t set,old_set,pending_set;
    sigemptyset(&set);//清空信号集
    sigaddset(&set,SIGINT);//添加信号
    //设置阻塞
    sigprocmask(SIG_BLOCK,&set,&old_set);
    sleep(5);
    //检测是否有信号阻塞
    sigpending(&pending_set);
    if(sigismember(&pending_set,SIGINT)){
        printf("有信号处于阻塞中\n");
    }
    //解除阻塞
    sigprocmask(SIG_SETMASK,&old_set,NULL);
    while(1){
        sleep(1);
    }
    return 0;
}

4 . 消息队列

(1)介绍

消息队列是内核维护的一种先进先出(FIFO)的链表结构,允许进程以“消息”为单位进行数据交换。与管道传输无格式字节流不同,消息队列中的每条消息都带有类型字段,接收方可以按类型选择性读取。

上图示例可以看到每一个消息都有一个对应的类型,数据长度和具体的数据,彼此连接构成队列,其实本质上就是一个队列,遵循先进先出的原则(就是先存入的消息先读取),他是存放在内核区域的,也就是说他创建后的生命周期是有内核维护的,清除的话要在进程主动清理。

消息队列有以下几个特点

特点说明
异步通信发送方发送后无需等待接收方处理即可继续执行
进程解耦发送方和接收方不需要知道对方的存在,只需遵循相同的消息格式
消息缓冲队列可以缓冲消息,缓解生产者和消费者速度不匹配的问题
生命周期独立消息队列由内核维护,不随创建它的进程终止而销毁
消息有类型每条消息都有类型字段,支持按类型接收

现在主流有两种消息队列的使用标准System V 消息队列(系统函数),POSIX 消息队列(封装库)

(2)System V 消息队列

这个System V的标准大概流程是这样的

发送方:
ftok()  →  生成唯一 key
msgget() → 创建消息队列
msgsnd() → 发送消息
接收方:
msgget() → 获取消息队列
msgrcv() → 接收消息
msgctl() → 控制/删除消息队列
#include <sys/types.h>
#include <sys/ipc.h>

key_t ftok(const char *pathname, int proj_id);//生成队列唯一键值
  • 返回值:成功返回 key_t 整数,失败返回 -1

  • pathname:一个存在的文件路径

  • proj_id:项目 ID(0-255),用于区分不同 IPC 对象

#include <sys/msg.h>

int msgget(key_t key, int msgflg);//创建或获取消息队列
  • 返回值:成功返回消息队列 ID(msgid),失败返回 -1

  • keyftok() 生成的键值,或使用 IPC_PRIVATE(仅当前进程及其子进程可用)

  • msgflg:标志位组合:

    • IPC_CREAT:队列不存在则创建

    • IPC_EXCL:与 IPC_CREAT 配合,队列已存在则报错

    • 0666 等权限位:设置读写权限

#include <sys/msg.h>

int msgsnd(int msqid, const void *msg_ptr, size_t msg_sz, int msgflg);//发送消息
  • 返回值:成功返回 0,失败返回 -1

  • msqidmsgget() 返回的队列 ID

  • msg_ptr:指向消息结构体的指针

  • msg_sz:消息内容大小(不包含 mtype 字段)

  • msgflg:标志位 0 或 IPC_NOWAIT

    • 0:当消息队列满时,msgsnd将会阻塞,直到消息能写进消息队列
    • IPC_NOWAIT:当消息队列已满的时候,msgsnd函数不等待立即返回

需要注意msg_ptr这个指针指向的结构体需要我们自己进行创建且第一个字段必须是 long mtype,格式如下

struct msgbuf {
    long mtype;        // 消息类型(必须 > 0的数字,自己设置)
    char mtext[100];   // 消息内容(可自定义)
};

接着是接收信息的几个函数

#include <sys/msg.h>
//接收消息
ssize_t msgrcv(int msqid, void *msg_ptr, size_t msg_sz, long msgtyp, int msgflg);
  • 返回值:成功返回实际接收的字节数,失败返回 -1

  • msgid : 消息队列id
  • msg_ptr: 消息缓冲区地址
  • msg_sz : 指定接收的消息长度
  • msgtyp:消息类型选择(之前直接设置的mtype值):

    • = 0:接收队列中第一条消息(FIFO)

    • > 0:接收第一个类型等于该值的消息

    • < 0:接收类型小于或等于该值绝对值的最小类型消息

  • msgflg:标志位:

    • 0:阻塞式接收消息
    • IPC_NOWAIT:如果没有返回条件的消息调用立即返回,此时错误码为ENOMSG
    • MSG_EXCEPT:与msgtype配合使用返回队列中第一个类型不为msgtype的消息
#include <sys/msg.h>

int msgctl(int msqid, int cmd, struct msqid_ds *buf);//控制/删除消息队列
  • msgid : 消息队列id
  • cmd:控制命令:

    • IPC_RMID:删除消息队列

    • IPC_STAT:获取队列状态信息

    • IPC_SET:设置队列属性

  • buf : 存放消息队列属性的地址

给出一个示例

//write.c
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
struct msgbuf{
    long mtype;
    char mess[100];
};

int main(){
    int fd = open("./my_ipc.key", O_CREAT | O_WRONLY, 0666);
    struct msgbuf buf;
    buf.mtype=100;
    strcpy(buf.mess, "Hello from sender!");
    if (fd == -1) {
    perror("open file for ftok failed");
    return -1;
    }
    close(fd);
    key_t key=ftok("./my_ipc.key",1);
    if(key == -1){
        perror("ftok error:");
        return -1;
    }
    int msgid=msgget(key,IPC_CREAT|0666);
    if(msgid == -1){
        perror("msgget error");
        return -1;
    }
    if(msgsnd(msgid,&buf,sizeof(buf.mess),0)<0){
        perror("msgsnd error");
        return -1;        
    }
    printf("发送消息成功:%s\n",buf.mess);
    return 0;
}
//read.c
#include <sys/types.h>
#include <sys/ipc.h>
#include <sys/msg.h>
#include <fcntl.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
struct msgbuf{
    long mtype;
    char mess[100];
};

int main(){
    struct msgbuf buf;
    buf.mtype=100;
    key_t key=ftok("./my_ipc.key",1);
    if(key == -1){
        perror("ftok error:");
        return -1;
    }
    int msgid=msgget(key,0666);
    if(msgid == -1){
        perror("msgget error");
        return -1;
    }
    if(msgrcv(msgid,&buf,sizeof(buf.mess),100,0)<0){
        perror("msgsnd error");
        return -1;        
    }
    printf("接收消息成功:%s\n",buf.mess);
    msgctl(msgid,IPC_RMID,NULL);
    return 0;
}
(3)POSIX 消息队列

POSIX 消息队列 是 POSIX 标准定义的一种内核级进程间通信(IPC)机制

  • 本质:内核维护的一个 “优先级消息链表”。每个队列由字符串名称唯一标识(如 /myqueue)。读取时会首先读取优先级高的,随后在以先进先出的规则读取。

  • 数据单元有边界且有优先级的消息(不再是管道那样的无格式字节流)。

  • 生命周期:随内核持续存在,即使所有进程退出,队列依然保留在系统中,直到被显式删除(mq_unlink)或系统重启。

  • 文件系统映射:在 Linux 下,它被挂载在 /dev/mqueue 虚拟文件系统,可以用 ls 查看,但不能用普通的 read/write 操作,必须使用专用 API。

需要注意使用此标准函数需要链接liblrt.so动态库(gcc -o my_prog my_prog.c -lrt

#include <mqueue.h>
typedef int mqd_t;
#创建或打开
mqd_t mq_open(const char *name, int oflag, mode_t mode, struct mq_attr *attr);

struct mq_attr {
    long mq_flags;   # 标志(0 或 O_NONBLOCK)
    long mq_maxmsg;  # 队列最大消息数(必须 > 0)
    long mq_msgsize; # **每条消息的最大字节数**(必须 > 0)
    long mq_curmsgs; # 当前队列中的消息数(只读)
};


int mq_close(mqd_t mqdes);#关闭对队列的引用

int mq_unlink(const char *name);#删除队列
  • 成功返回消息队列描述符mqdes。失败返回-1

  • name:消息队列名字,以 / 开头,。

  • oflag:指定打开消息队列的方式

    • O_RDONLY–以只接收消息的形式打开消息队列
    • O_WRONLY–以只发送消息的形式打开消息队列
    • O_RDWR–以可接收可发送的形式打开消息队列
    • 还有O_CREAT, O_EXCL, O_NONBLOCK(非阻塞)
  • mode:权限(如 0666),只有在oflag包含O_CREAT时, 才需要指定mode参数。

    • S_IRUSR(4):用户读取权限
    • S_IWUSR(2):用户写入权限
    • S_IXUSR(1):用户执行权限
    • S_IWGGRP(4):组写入权限
    • S_IWOTH(1):其他用户写入权限
  • attr:指定新队列的属性,传 NULL 则使用系统默认值。

在mq_attr 结构体中

mq_maxmsg 受系统参数 /proc/sys/fs/mqueue/msg_max 限制(默认 10),mq_msgsize 受 /proc/sys/fs/mqueue/msgsize_max 限制(默认 8192)。

参数值要小于最大值限制

超出则 mq_open 报错 EINVAL

# 单个队列最大消息数(默认 10)
ubuntu1@ubuntu1:~/c++project/test$ cat /proc/sys/fs/mqueue/msg_max
10

# 单条消息最大字节数(默认 8192)
ubuntu1@ubuntu1:~/c++project/test$ cat /proc/sys/fs/mqueue/msgsize_max
8192

# 系统总共最多多少个队列(默认 256)
ubuntu1@ubuntu1:~/c++project/test$ cat /proc/sys/fs/mqueue/queues_max
256

下面是发送和接受的函数

#include <mqueue.h>
#发送信息
int mq_send(mqd_t mqdes, const char *msg_ptr, size_t msg_len, unsigned msg_prio);
  • 成功返回 0,失败返回 -1,常见 errnoEAGAIN(非阻塞且队列满)、EMSGSIZE(消息太大)、EINTR(被信号中断)

  • mqdes:队列描述符。

  • msg_ptr :指向要发送的数据缓冲区

  • msg_len : 要发送的消息长度(字节数),注意之前说的最大值限制

  • msg_prio : 消息队列的优先级(0 ~ 31),数值越大,优先级越高


#include <mqueue.h>
ssize_t mq_receive(mqd_t mqdes, char *msg_ptr, size_t msg_len, unsigned int *msg_prio);

成功:返回实际接收到的字节数,失败:返回 -1

  • mqdes:队列描述符。

  • msg_ptr :指向要发送的数据缓冲区

  • msg_len : 要发送的消息长度(字节数),注意之前说的最大值限制

  • msg_prio : 消息队列的优先级(0 ~ 31),数值越大,优先级越高

这些就是基本的创建,写入/读取,删除的基本函数,下面补充一些其他的

#加入了延时写入或读取的函数
#需要注意使用绝对时间,必须用 clock_gettime + 偏移计算

int mq_timedsend(mqd_t mqdes, const char *msg_ptr, size_t msg_len,
                 unsigned int msg_prio, const struct timespec *abs_timeout);

ssize_t mq_timedreceive(mqd_t mqdes, char *msg_ptr, size_t msg_len,
                        unsigned int *msg_prio, const struct timespec *abs_timeout);

#这两个函数是用于获取和修改队列属性,比如阻塞写入这种
int mq_getattr(mqd_t mqdes, struct mq_attr *attr);
int mq_setattr(mqd_t mqdes, const struct mq_attr *newattr, struct mq_attr *oldattr);


mq_notify这个函数最好要熟练掌握使用

#异步通知
int mq_notify(mqd_t mqdes, const struct sigevent *sevp);

struct sigevent {
    int sigev_notify;                // 通知方式
    int sigev_signo;                 // 信号编号(如果使用信号)
    union sigval sigev_value;        // 传递给回调/信号处理函数的参数
    void (*sigev_notify_function)(union sigval); // 线程回调函数
    void *sigev_notify_attributes;   // 线程属性(通常 NULL)
};

mq_notify用于注册一个“一次性”的通知。当消息队列从空变为非空时,内核会触发该通知。根据配置,它可以执行一个回调线程(SIGEV_THREAD)、向进程发送信号(SIGEV_SIGNAL),或仅作占位(SIGEV_NONE)。通知触发后自动失效,如需持续监听必须重新注册。

sigev_notify有几种取值

取值含义
SIGEV_NONE空通知。注册一个“占位符”,不实际通知。
SIGEV_SIGNAL发送信号。当消息到达时,内核向进程发送 sigev_signo 指定的信号(如 SIGUSR1)。
SIGEV_THREAD启动线程。 消息到达时,系统自动创建一个新线程,执行 sigev_notify_function,并传入 sigev_value

5.socket套接字

(1)介绍

套接字是通信端点(Endpoint)的抽象表示,它为一个正在执行的进程提供了一种通过文件描述符来访问通信媒介(网络或本地内核通道)的能力

或者说套接字是应用层传输层(TCP/UDP)内核内部通信层(Unix域)之间的编程接口(API),它屏蔽了底层协议栈的复杂性,让开发者像读写普通文件一样收发数据。

在底层实现上,套接字并非简单的函数库,而是一套由内核维护的复杂数据结构。当进程创建套接字时,内核会分配独立的struct socketstruct sock结构体,并为其建立专属的收发缓冲区队列(skb,即socket buffer)

其数据流转的实质是内核态与用户态之间的内存拷贝与调度

  • 网络通信(AF_INET):数据通过协议栈封装(TCP/UDP分段、IP路由)后,经DMA传输交由网卡驱动发出;接收时则通过硬/软中断将数据写入缓冲区等待用户读取。

  • 本地通信(AF_UNIX):则完全绕过网络协议栈,通过内核内存直接搬运数据,实现了更高效率的进程间通信。

他有如下几个特点

  1. 通信域:支持跨主机的网络通信(AF_INET/IPv6)或同主机的本地高效通信(AF_UNIX)。

  2. 传输类型:提供面向连接的流式(SOCK_STREAM)可靠字节流,与无连接的数据报(SOCK_DGRAM)独立报文两种对立语义,分别对应TCP与UDP协议。

  3. 全双工特性:收发通道相互独立,支持同时进行读写操作。

  4. I/O处理模式:根据并发场景需求,灵活支持阻塞(Blocking)非阻塞(Non-blocking)以及基于I/O多路复用(如epoll)的高并发模式,使得单线程可以轻松管理成千上万的连接。

  5. 架构:在逻辑组织上,可通过客户端-服务器(C/S)模式实现精准的“请求-响应”流程,或通过对等模式(P2P)实现无中心的广播与数据交换。

套接字的特性由3个属性确定,它们分别是域(Domain)类型(Type)协议(Protocol)这三个属性共同决定了数据如何传输、去往何处以及遵循何种规则:

域:它指定套接字通信中使用的网络介质,可以决定内核对于传入的地址结构使用哪一种解析的方式,其所对应的应用场景也会有所差别

  • AF_INET / AF_INET6:它指的是Internet网络。当客户使用套接字进行跨网络的连接时,它就需要用到服务器计算机的IP 地址和端口来指定一台联网机器上的某个特定服务,所以在使用socket作为通信的终点,服务器应用程序必须在开始通信之前绑定一个端口,服务器在指定的端口等待客户的连接。
    总的是说使用IP地址(IPv4/IPv6)和端口号作为地址。这意味着套接字将跨主机通信,数据需要经过完整的网络协议栈(网卡、路由)。

  • AF_UNIX / AF_LOCAL:使用文件系统路径(如 /tmp/socket.sock)作为地址。这意味着套接字仅用于同一台机器上的进程间通信(IPC),数据完全在内核内存中拷贝,不经过网卡,效率极高。

类型:它指定了套接字的通信方式,是面向连接还是面向报文。

因特网提供了两种通信机制:流(stream)和数据报(datagram),因而套接字的类型也就分为流套接字和数据报套接字

  • SOCK_STREAM(流式):流套接字提供的是一个有序、可靠、双向字节流的连接,因此发送的数据可以确保不会丢失、重复或乱序到达,而且它还有一定的出错后重新发送的机制。它的底层通常是在AF_INET域中通过TCP/IP连接实现。

  • SOCK_DGRAM(数据报):提供不可靠的、无连接的、有最大长度限制的独立报文。底层通常对应 UDP。它不需要建立连接和维持一个连接,在AF_INET中通常是通过UDP/IP实现的。它对可以发送的数据的长度有限制,数据报作为一个单独的网络消息被传输,它可能丢失、复制或错乱到达,UDP不是一个可靠的协议,但是它的速度比较高,因为它并不需要总是要建立和维持一个连接。

还有一种原始的类型

  • SOCK_RAW(原始):提供绕过传输层(TCP/UDP)的直接访问,允许用户态进程手动构造IP层或ICMP层数据包,通常用于网络诊断工具(如 ping)或抓包。

协议:它指定了在给定的“域”和“类型”下,具体使用哪一种底层传输协议

  • 通常置为默认值(0):在日常编程中,我们几乎都填默认值。因为内核非常智能,当你给定 AF_INET + SOCK_STREAM 时,内核默认就匹配 IPPROTO_TCP;给定 AF_INET + SOCK_DGRAM 时,内核默认匹配 IPPROTO_UDP

只有在原始套接字(SOCK_RAW)或同一类型支持多种协议的特殊场景下,才需要显式指定

再说一下套接字地址吧

套接字地址是通信端点的结构化标识,它由传输层端口(或本地文件路径)网络层IP地址共同构成。根据通信域(Domain)的不同,地址采用不同的数据结构(如 IPv4 的 sockaddr_in 与 Unix 域的 sockaddr_un)。在系统调用中,它通过通用指针(struct sockaddr *进行传递,内核依据头部的地址族字段自动解析其具体格式,从而实现bindconnect的核心寻址功能

其实的话,socket套接字现在用起来的都是已经完善了的一套api了,至于底层的机制,包括其内核,网卡,运行,状态机这些内部处理逻辑,linux操作系统都已经帮我们完善好了,提供给我们了这一套使用的接口。我们也都知道linux系统下一切皆文件的特点,如何让这个套接字网络通讯变成可以直接使用文件描述符来控制,write和read等来读取就是这个内核和操作系统已经完善的工作了。实际使用我们首先需要学会的是他的一些特点,api的使用,这些了解了以后再可以深入了解一下他的实现机制。

他的使用可以分为TCP 流式套接字和UDP 数据报套接字,每一种方式都是用客户端和服务端实现的

在TCP中

服务端(接收方)的步骤:

  1. socket() 创建监听套接字:在内核中创建一个套接字文件描述符,指定 AF_INET + SOCK_STREAM + 0

  2. bind() 绑定固定地址:将套接字与本地 IP 和端口号绑定。这一步是为了公布自己的门牌号,让客户端知道该找谁。

  3. listen() 转为被动监听:将主动套接字变为被动套接字,并维护一个半连接队列(等待客户端三次握手的请求)。

  4. accept() 接受连接:从已完成三次握手的队列中取出一个客户端连接。注意: accept() 会返回一个全新的已连接套接字(new_fd,而原来的监听套接字(listen_fd)继续负责监听从其他客户端发来的新连接。

  5. read()/write() 或 recv()/send() 收发数据:使用新返回的 new_fd 与客户端进行双向数据交换。

  6. close() 关闭连接:通信结束后关闭套接字,释放内核资源(触发四次挥手)。

客户端(发起方)的步骤:

  1. socket() 创建套接字:同样在内核创建一个客户端套接字。

  2. connect() 主动连接:传入服务端的 IP 和端口号,发起三次握手。阻塞直到连接建立成功或超时

  3. write()/read() 收发数据:连接成功后,直接进行数据通信。

  4. close() 关闭连接:关闭套接字。

在UDP中

1. 服务端:

  1. socket() 创建套接字:指定 AF_INET + SOCK_DGRAM + 0

  2. bind() 绑定固定地址:绑定 IP 和端口(必须绑定,否则客户端不知道往哪寄)。

  3. recvfrom() 阻塞等待接收:一直阻塞,直到收到一个来自任意客户端的完整数据报(保留了消息边界,一次收一个完整包,不存在粘包)。该函数会同时返回数据内容发送端的地址

  4. sendto() 发送回复:利用 recvfrom 拿到的客户端地址,直接回复数据。

  5. close() 关闭

2. 客户端:

  1. socket() 创建套接字

  2. sendto() 直接发送无需 connect,直接在参数里指定服务端的 IP 和端口,数据即刻发出。

  3. recvfrom() 接收回复(如果需要的话)。

  4. close() 关闭

大概就是这样,下面来看具体的使用

(2)TCP 流式套接字
#向内核申请创建一个套接字,并返回一个文件描述符供
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

成功返回一个非负整数(文件描述符),失败返回-1

domain :套接字的域,如AF_INET(IPv4 网络通信),AF_INET6(IPv6 网络通信),AF_UNIX / AF_LOCAL本地进程间通信(文件路径)

type        :套接字的类型,SOCK_STREAM(流式套接字(TCP)),SOCK_DGRAM(数据报套接字(UDP)),SOCK_RAW(原始套接字(需root权限))

protocol    :套接字协议,一般写0(默认)

#将套接字与一个具体的本地地址(IP + 端口)绑定在一起
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

成功返回 0,失败返回-1

sockfd :文件描述符

addr     :指向特定地址结构的指针,IPv4用 struct sockaddr_in,Unix域用 struct sockaddr_un)。传入时需要强制转换为 (struct sockaddr *)。

addrlen:传入的地址结构体的长度

#创建套接字队列(监听)
int listen(int sockfd, int backlog);

成功:返回 0,失败:返回 -1

backlog :用于指定队列的长度,等待处理的进入连续的个数最多不能超过这个数字,否则往后的连接将被拒绝,导致客户的连接请求失败。调用后,程序一直会监听这个IP端口,如果有连接请求,就把它加入到这个队列中。通常写SOMAXCONN(一般为128或更大)。

#接受连接
#从内核的已完成连接队列(全连接队列)中取出第一个连接请求,并为该连接创建一个全新的套接字
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

成功:返回一个新的非负整数文件描述符,失败:返回 -1

注意:原 sockfd(监听套接字)继续负责监听,新返回的套接字专门负责和这个客户端通信

addr :输出型参数。内核会将客户端的地址信息(IP+端口)填充到此指针指向的结构体中

addrlen :指定客户端结构的长度
 

#客户端调用此函数向服务端发起三次握手
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

成功:返回 0,此时连接建立成功,失败:返回 -1,常见错误包括 ETIMEDOUT(超时,服务端没开机)、ECONNREFUSED(服务端监听队列满或没启动,主动拒绝)和 EHOSTUNREACH(路由不可达)

sockfd :客户端创建的套接字

addr     :指向服务端地址结构体的指针

addrlen:地址结构体的长度(sizeof)

#发送数据
ssize_t send(int sockfd, const void *buf, size_t len, int flags);

#接受数据
ssize_t recv(int sockfd, void *buf, size_t len, int flags);

成功:返回实际成功写入/读取内核缓冲区的字节数,失败:返回 -1

如果接受数据的函数返回值为0,代表对端主动调用了 close() 关闭连接,连接关闭

sockfd :已连接的套接字(服务端的 client_fd 或客户端的 sockfd

buf       :指向要发送数据的用户态内存指针

len       :要发送的字节数。

flags    :标志位,通常填0(MSG_DONTWAIT是非阻塞发送,MSG_DONTWAIT是阻塞读取,MSG_NOSIGNAL如果对端关闭,不触发SIGPIPE信号,而是返回-1

#关闭连接
#include <unistd.h>
int close(int sockfd);

还有涉及到的几个结构体我在这里也说一下

#include <netinet/in.h>

#通用结构体 struct sockaddr
struct sockaddr {
    sa_family_t sa_family;    // 地址族,如 AF_INET
    char        sa_data[14];  // 存储具体地址数据(但14字节放不下IPv4+端口,所以实际没人用它存数据)
};

#IPv4 的结构体
struct sockaddr_in {
    short int  sin_family;   // 地址族,必须填 AF_INET
    unsigned short int     sin_port;     // 端口号(16位),必须使用网络字节序(大端)
    struct in_addr sin_addr;     // IPv4 地址(32位),必须使用网络字节序
    unsigned char  sin_zero[8];  // 填充字节,使结构体大小与 sockaddr 一致,必须全部置0
};
其中in_addr的定义
struct in_addr {
    unsigned long ints_addr;  // 实质是一个 32 位的无符号整数(uint32_t)
};

#IPv6 结构体
struct sockaddr_in6 {
    sa_family_t     sin6_family;   // 地址族,必须填 AF_INET6
    in_port_t       sin6_port;     // 端口号(16位),网络字节序(用 htons)
    uint32_t        sin6_flowinfo; // 流信息(通常填 0)
    struct in6_addr sin6_addr;     // IPv6 地址(128位),网络字节序
    uint32_t        sin6_scope_id; // 范围ID(用于链路本地地址,通常填 0)
};


#UNIX结构体
#include <sys/un.h>
struct sockaddr_un {
    sa_family_t sun_family;               // 地址族,必须填 AF_UNIX 或 AF_LOCAL
    char        sun_path[108];            // 文件路径字符串(以'\0'结尾)
};

虽然 bindconnect 等核心函数均接受统一的 struct sockaddr * 指针,但在实际编程中,我们需根据通信域选择对应的专用结构体:IPv4 场景填充 sockaddr_in,IPv6 场景填充 sockaddr_in6,本地进程间通信填充 sockaddr_un。值得注意的是,Unix 域套接字的地址是文件系统中的持久化节点,因此服务端重启时需主动 unlink 旧文件,以避免 EADDRINUSE 错误;而 IPv4/IPv6 的端口复用(SO_REUSEADDR)则通过套接字选项独立控制。

(3)UDP 数据报套接

前面说过了UDP的使用流程了,创建套接字,绑定函数,关闭的函数也都说过了,就不在说了,补充一些发送接受的就可以了

#接收数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
                 struct sockaddr *src_addr, socklen_t *addrlen);

#发送数据
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
               const struct sockaddr *dest_addr, socklen_t addrlen);

recvfrom

返回值:成功返回接收到的字节数(一定是完整的报文长度),失败返回 -1。

参数含义
sockfdUDP 套接字。
buf / len接收缓冲区。
flags通常填 0
src_addr输出型参数。内核会把发送方的 IP 和端口填到这个结构体里(服务端靠它知道是谁发的)。
addrlen值-结果参数。传入缓冲区大小,传出实际地址长度。

sendto()

返回值:成功返回实际发送的字节数,失败返回 -1。

参数含义
sockfdUDP 套接字。
buf / len要发送的数据。
flags通常填 0(或 MSG_DONTWAIT 非阻塞)。
dest_addr输入型参数。指定目标地址(服务端的 IP+Port 或客户端的 IP+Port)。
addrlen地址结构体长度。

需要注意的是虽然 UDP 是无连接的,但你可以对 UDP 套接字调用 connect(),传入目标地址。这并不会触发三次握手,而是在内核中记录这个目标地址。而连接之后有一些好处:

  1. 可以省略地址:你可以直接用 send() / recv() 替代 sendto / recvfrom(无需每次指定地址)。

  2. 过滤无效数据包:内核会丢弃任何来自非目标地址的传入数据包,只接收你 connect 的那个对端发来的数据。

  3. 提升效率:内核会缓存路由表项,发送时少了一次地址查找。

(4)地址转换

还有一个地址转换的问题,在socket套接字中涉及到一个ip地址和端口的地址转换问题。地址转换的实质是人类可读的字符串形式(如 "" + 8080)与操作系统内核/网络协议栈能识别的二进制形式(如 0x0100007f + 0x1f90)之间进行精准的互译。

在我们使用套接字时需要把对应的ip转换成二进制数,直接赋值给对应的结构体,再传给对应的函数。这样做的原因是因为cpu的架构。首先是数据结构的差异,字符串 "" 占用10个字节(含终止符),且是变长的。内核如果拿着字符串去查路由表,每次都要做字符串匹配,效率极低。而二进制 0x7f000001 占用固定的4字节(IPv4)或16字节(IPv6),CPU可以直接进行位运算比较,效率极高。所以,内核只认二进制,不认字符串。

其次是大端序和小端序的问题,我们的CPU架构,x86/ARM大多数是小端序的,而TCP/IP协议栈规定必须使用大端序,如果搞混会直接导致错误,所以要掌握一下大小端序的转换。

接着介绍一下这些会用到的转换的函数

#字符串 → 二进制

#include <arpa/inet.h>
int inet_pton(int af, const char *src, void *dst);
  • 返回 1:转换成功。

  • 返回 0src 不是合法的 IP 地址字符串(格式错误)。

  • 返回 -1:发生系统错误

  • af地址族(Address Family)。仅接受两个合法值:

    • AF_INET:表示处理 IPv4 地址(src 应为点分十进制,如 "")。

    • AF_INET6:表示处理 IPv6 地址(src 应为冒号十六进制,如 "::1")。

  • src输入源字符串指针。指向一个以空字符('\0')结尾的 C 字符串,包含待转换的人类可读 IP 地址。

  • dst输出二进制数据缓冲区指针。函数将转换后的网络字节序二进制地址写入此处。

    • 若 af = AF_INETdst 应指向 struct in_addr 类型(大小 4 字节)。

    • 若 af = AF_INET6dst 应指向 struct in6_addr 类型(大小 16 字节)。

#二进制 → 字符串

#include <arpa/inet.h>

const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);

成功时返回 dst 的指针,失败时返回 NULL

  • af地址族。同上,只接受 AF_INET 或 AF_INET6

  • src输入二进制数据指针。指向要转换的网络字节序二进制地址(即 struct in_addr 或 struct in6_addr)。

  • dst输出字符串缓冲区指针。函数将生成的可读字符串存入此处。

  • size输出缓冲区的大小(字节数)。调用者必须传入缓冲区实际长度。

    • 标准宏 INET_ADDRSTRLEN 定义为 IPv4 字符串最大长度(含终止符,值为 16)。

    • 标准宏 INET6_ADDRSTRLEN 定义为 IPv6 字符串最大长度(含终止符,值为 46)。

#整合增加了地址/端口/主机名解析,将主机/服务 → 二进制地址链表
#include <sys/types.h>
#include <sys/socket.h>
#include <netdb.h>
int getaddrinfo(const char *node, const char *service,
                const struct addrinfo *hints,
                struct addrinfo **res);

成功返回 0,失败返回非零错误码

  • node主机标识符。可为 NULL,或指向以下两类字符串之一:

    1. 数字地址字符串:如 "" 或 "::1"

    2. 主机域名:如 "www.baidu.com"(函数内部会发起 DNS 解析)。
      若 hints 中设置了 AI_PASSIVE 标志且 node 为 NULL,则返回的地址为通配地址(IPv4 为 INADDR_ANY,IPv6 为 in6addr_any)。

  • service服务标识符。可为 NULL,或指向以下两类字符串之一:

    1. 十进制端口号:如 "8080"

    2. 标准服务名:如 "http"(系统依据 /etc/services 文件自动转换为端口号 80)。

  • hints筛选与配置参数。指向一个由调用者填充的 struct addrinfo 结构体,用于指定期望的套接字类型、协议、地址族等。调用前必须将该结构体整体清零(memset,否则未初始化字节将导致未定义行为。

  • res输出参数(链表头指针)。函数内部动态分配内存,构造一个 struct addrinfo 链表(因为一个域名可对应多个 IP 地址),并将链表头地址存入 res。调用者必须负责在不再使用时调用 freeaddrinfo(res) 释放内存。

#二进制地址 → 主机/服务字符串

#include <sys/socket.h>
#include <netdb.h>
int getnameinfo(const struct sockaddr *sa, socklen_t salen,
                char *host, size_t hostlen,
                char *serv, size_t servlen,
                int flags);

成功返回 0,失败返回非零错误码

  • sa输入二进制地址指针。指向一个已填充的 struct sockaddr(或 sockaddr_in / sockaddr_in6)结构体,通常来源于 acceptgetsockname 或 getpeername 的返回结果。

  • salen地址结构体的长度。传入 sizeof(*sa)

  • host输出主机字符串缓冲区。转换后的域名或 IP 字符串存入此处。若调用者不关心主机名,可传入 NULL

  • hostlen主机缓冲区大小。若 host 不为 NULL,建议传入标准宏 NI_MAXHOST(通常为 1025,足以容纳任何域名或 IP)。

  • serv输出服务字符串缓冲区。转换后的服务名(如 "http")或端口号字符串(如 "8080")存入此处。若不关心,可传入 NULL

  • servlen服务缓冲区大小。若 serv 不为 NULL,建议传入标准宏 NI_MAXSERV(通常为 32)。

  • flags转换行为标志位(通过按位或 | 组合)。常用关键标志:

    • NI_NUMERICHOST强制不进行 DNS 反向查询,直接将二进制 IP 转为数字字符串(推荐默认设置,避免阻塞)。

    • NI_NUMERICSERV:强制将端口号输出为数字字符串(避免查询 /etc/services)。

    • NI_NAMEREQD:若无法解析出域名,则返回错误(而非返回数字 IP)。

#include <arpa/inet.h>
//处理端口
uint16_t htons(uint16_t hostshort);//16位主机序 → 网络序

uint16_t ntohs(uint16_t netshort);//16位网络序 → 主机序
//处理ip整数
uint32_t htonl(uint32_t hostlong);//32位主机序 → 网络序

uint32_t ntohl(uint32_t netlong);//32位网络序 → 主机序

大概就先说这些吧,遇到了再查吧。自己试试看会印象深刻点。

下面是简单的TCP机制的使用示例

//服务端 server.c

#include <stdio.h>
#include <unistd.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <sys/types.h>
#include <stdlib.h>
#include <errno.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <string.h>
int main(){
    struct sockaddr_in sock;
    sock.sin_family=AF_INET;
    char mess[1024];
    sock.sin_port = htons(8080);
    inet_pton(AF_INET, "127.0.0.1", &sock.sin_addr);

    int server_fd=socket(AF_INET,SOCK_STREAM,0);
    if(server_fd ==-1){
        perror("socket error:");
        return -1;
    }
    if(bind(server_fd,(struct sockaddr *)&sock,sizeof(sock)) == -1){
        perror("bind error :\n");
        close(server_fd);
        return -1;
    }
    if(listen(server_fd,20) == -1){
        perror("listen error :\n");
        close(server_fd);
        return -1;
    }
    printf("======waiting for client's request======\n");
    while(1){
        int con_fd = accept(server_fd,(struct sockaddr*)NULL, NULL);
        if(con_fd == -1){
            perror("accept error :\n");
            continue;
        }
        int n=recv(con_fd,mess,1024,0);
        if (n <= 0) {
        if (n == 0) printf("客户端已断开\n");
        else perror("recv error");
        close(con_fd);
        continue;
        }   
        mess[n]='\0';
        printf("接收到信息:%s\n",mess);
        close(con_fd);
    }
    close(server_fd);

    return 0;
}

客户端

//客户端client.c

#include <stdio.h>
#include <unistd.h>
#include <netinet/in.h>
#include <sys/socket.h>
#include <stdlib.h>
#include <errno.h>
#include <arpa/inet.h>
#include <fcntl.h>
#include <string.h>

int main(){
    struct sockaddr_in sock;
    sock.sin_family=AF_INET;
    sock.sin_port = htons(8080);
    inet_pton(AF_INET, "127.0.0.1", &sock.sin_addr);
    char mess[1024] = "hello server!";
    int client_fd=socket(AF_INET,SOCK_STREAM,0);
    if(client_fd ==-1){
        perror("socket error:");
        return -1;
    }
    if(connect(client_fd,(struct sockaddr *)&sock,sizeof(sock)) == -1){
        perror("connect error:");
        close(client_fd);
        return -1;
    }

    ssize_t size = send(client_fd,mess,strlen(mess),0);
    if(size == -1){
        perror("send error:");
        close(client_fd);
        return -1;
    }
    close(client_fd);
    return 0;
}

大概到这里就涉及到全部的进程的知识了,后面可能还需要更深入的理解,但是有这些对进程的知识点都会有个大概的认知和使用了吧,后面还有一个进程间通讯的同步机制,后面再说吧。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐