13.进程间通信
13.进程间通信
1.学习目标
-
熟练使用pipe进行父子进程间通信
-
熟练使用pipe进行兄弟进程间通信
-
熟练使用fifo进行无血缘关系的进程间通信
-
使用mmap进行有血缘关系的进程间通信
-
使用mmap进行无血缘关系的进程间通信
2.进程间通信相关概念
2.1 什么是进程间通信
Linux环境下,进程地址空间相互独立,每个进程各自有不同的用户地址空间。任何一个进程的全局变量在另一个进程中都看不到,所以进程和进程之间不能相互访问,要交换数据必须通过内核,在内核中开辟一块缓冲区,进程1把数据从用户空间拷到内核缓冲区,进程2再从内核缓冲区把数据读走,内核提供的这种机制称为进程间通信(IPC,InterProcess Communication)。
2.2进程间通信的方式
在进程间完成数据传递需要借助操作系统提供特殊的方法,如:文件、管道、信号、共享内存、消息队列、套接字、命名管道等。随着计算机的蓬勃发展,一些方法由于自身设计缺陷被淘汰或者弃用。现今常用的进程间通信方式有:
-
管道 (使用最简单)
-
信号(开销最小)
-
共享映射区(无血缘关系)
-
本地套接字(最稳定)
3.管道-pipe
3.1管道的概念
管道是一种最基本的IPC机制,也称匿名管道,应用于有血缘关系的进程之间,完成数据传递。调用pipe函数即可创建一个管道。
有如下特质:
-
管道的本质是一块内核缓冲区
-
由两个文件描述符引用,一个表示读端,一个表示写端。
-
规定数据从管道的写端流入管道,从读端流出。
-
当两个进程都终结的时候,管道也自动消失。
-
管道的读端和写端默认都是阻塞的。
3.2管道的原理
-
管道的实质是内核缓冲区,内部使用环形队列实现。
-
默认缓冲区大小为4K,可以使用ulimit -a命令获取大小。
-
实际操作过程中缓冲区会根据数据压力做适当调整。
3.3管道的局限性
-
数据一旦被读走,便不在管道中存在,不可反复读取。
-
数据只能在一个方向上流动,若要实现双向流动,必须使用两个管道
-
只能在有血缘关系的进程间使用管道。
3.4创建管道-pipe函数
- 函数作用:
创建一个管道
- 函数原型:
int pipe(int fd[2]);
- 函数参数:
若函数调用成功,fd[0]存放管道的读端,fd[1]存放管道的写端
- 返回值:
▶成功返回0;
▶失败返回-1,并设置errno值。
函数调用成功返回读端和写端的文件描述符,其中fd[0]是读端,fd[1]是写端,向管道读写数据是通过使用这两个文件描述符进行的,读写管道的实质是操作内核缓冲区。
管道创建成功以后,创建该管道的进程(父进程)同时掌握着管道的读端和写端。如何实现父子进程间通信呢?
3.5父子进程使用管道通信
一个进程在由pipe()创建管道后,一般再fork一个子进程,然后通过管道实现父子进程间的通信(因此也不难推出,只要两个进程中存在血缘关系,这里的血缘关系指的是具有共同的祖先,都可以采用管道方式来进行通信)。父子进程间具有相同的文件描述符,且指向同一个管道pipe,其他没有关系的进程不能获得pipe()产生的两个文件描述符,也就不能利用同一个管道进行通信。
第一步:父进程创建管道
第二步:父进程fork出子进程
第三步:父进程关闭fd[0],子进程关闭fd[1]
创建步骤总结:
-
父进程调用pipe函数创建管道,得到两个文件描述符fd[0]和fd[1],分别指向管道的读端和写端。
-
父进程调用fork创建子进程,那么子进程也有两个文件描述符指向同一管。
-
父进程关闭管道读端,子进程关闭管道写端。父进程可以向管道中写入数据,子进程将管道中的数据读出,这样就实现了父子进程间通信。
3.6 管道练习
█一个进程能否使用管道完成读写操作呢? 可以但没必要。
█使用管道完成父子进程间通信?
read是没有数据读取时阻塞,write是空间写满了阻塞
子进程一定是先退出,父进程一定是后退出。因为wait函数是阻塞函数。
加sleep(5)阻塞了,阻塞在read行,子进程中"reader over"暂时输出不出来,5秒后输出
管道:
1.管道的本质是一块内核缓冲区,内部的实现是环形队列
2.管道有读写两端,读写两端是两个文件描述符
3.数据的流向是从管道的写端流到管道的读端(数据的流向是单向的)
4.数据被读走之后,在管道中就消失了
5.pipe只能用于有血缘关系的进程间通信
6.管道的读写两端是阻塞的
7.管道的大小默认是4K,但是会根据实际情况做适当调整
pipe用于父子间进程通信:
1.父进程创建pipe
2.父进程调用fork函数创建子进程
3.父进程关闭一端
4.子进程关闭一端
5.父进程和子进程分别执行read或者write操作
█父子进程间通信,实现ps aux | grep bash
使用execlp函数和dup2函数
ps aux | grep bash只关注包含bash的
/dev/tty:标准输出
ps aux:往标准输出写
ps aux
是一个在 Unix 和 Linux 操作系统中用于显示当前进程信息的命令组合。这条命令提供了关于系统上所有运行的进程的详细信息。让我们分解这个命令的每个部分:
-
ps
:这是“process status”的缩写,用于显示系统进程的信息。 -
aux
:这是传递给ps
命令的选项组合,每个字母都有其特定的意义。-
a
:列出所有的进程,包括那些由其他用户启动的进程。 -
u
:用户格式化输出,它会显示关于进程的更多信息,例如进程的所有者、CPU 使用率、启动时间等。 -
x
:列出没有控制终端的进程。这通常用于显示那些后台或守护进程。
-
当你运行ps aux
命令时,你会看到一个关于所有进程的详细列表,包括其PID(进程ID)、用户、CPU和内存使用率、进程状态、开始时间、所用时间以及命令名称/路径等信息。
这个命令在系统管理和故障排查中是非常有用的,因为它可以帮助管理员或用户查看系统上正在运行的进程和它们的状态。
ps aux | grep bash先往管道写入
dup2(fd[1],STDOUT_FILENO);//写到了管道的写端
grep bash原来从标准输入读,现在从管道读
两次重定向
dup2(fd[0],STDIN_FILENO);//将标准输入重定向到管道读端
实现:
wait(NULL);可以不写:父进程先退出,子进程后退出,子进程会被1号进程领养
效果一样
标红了
3.7 管道的读写行为
- 读操作
▶有数据
read正常读,返回读出的字节数
▶无数据
▷写端全部关闭
read解除阻塞,返回0,相当于读文件读到了尾部
▷没有全部关闭
read阻塞
- 写操作
▶读端全部关闭
管道破裂,进程终止, 内核给当前进程发SIGPIPE信号
▶读端没全部关闭
▷缓冲区写满了
write阻塞
▷缓冲区没有满
继续write
无数据、写端全部关闭->read解除阻塞,返回0,相当于读文件读到了尾部
无数据、没有全部关闭(意味着可能还要写数据)->read阻塞
阻塞了,没有关闭写端又想读
写操作,读端没全部关闭,缓冲区写满了、write阻塞;缓冲区没有满、继续write。
3.8 如何设置管道为非阻塞
默认情况下,管道的读写两端都是阻塞的,若要设置读或者写端为非阻塞,则可参
考下列三个步骤进行:
第1步: int flags = fcntl(fd[0], F_GETFL, 0);
第2步: flag |= O_NONBLOCK;
第3步: fcntl(fd[0], F_SETFL, flags);
若是读端设置为非阻塞:
▶写端没有关闭,管道中没有数据可读,则read返回-1;
▶写端没有关闭,管道中有数据可读,则read返回实际读到的字节数
▶写端已经关闭,管道中有数据可读,则read返回实际读到的字节数
▶写端已经关闭,管道中没有数据可读,则read返回0
立刻退出。没有数据可读read返回-1,有数据可读将其读出来立刻返回
读到了
写端已经关闭,管道中有数据可读,则read返回实际读到的字节数11
3.9 如何查看管道缓冲区大小
- 命令
ulimit -a
- 函数
long fpathconf(int fd, int name);
printf("pipe size==[%ld]\n", fpathconf(fd[0], _PC_PIPE_BUF));
printf("pipe size==[%ld]\n", fpathconf(fd[1], _PC_PIPE_BUF));
4.FIFO
4.1 FIFO介绍
FIFO常被称为命名管道,以区分管道(pipe)。管道(pipe)只能用于“有血缘关系”的进程间通信。但通过FIFO,不相关的进程也能交换数据。
FIFO是Linux基础文件类型中的一种(文件类型为p,可通过ls -l查看文件类型)。但FIFO文件在磁盘上没有数据块,文件大小为0,仅仅用来标识内核中一条通道。进程可以打开这个文件进行read/write,实际上是在读写内核缓冲区,这样就实现了进程间通信。
4.2 创建管道
- 方式1-使用命令 mkfifo
命令格式: mkfifo 管道名
例如:mkfifo myfifo
- 方式2-使用函数
int mkfifo(const char *pathname, mode_t mode);
参数说明和返回值可以查看man 3 mkfifo
当创建了一个FIFO,就可以使用open函数打开它,常见的文件I/O函数都可用于FIFO。如:close、read、write、unlink等。
FIFO严格遵循先进先出(first in first out),对FIFO的读总是从开始处返回数据,对它们的写则把数据添加到末尾。它们不支持诸如lseek()等文件定位操作。
例子:
fifo_read.c
fifo_write.c
我make之后,又./了输出mkfifo error: File exists为什么?
这个错误提示是因为在你执行程序时,名为"myfifo"的FIFO文件已经存在。每次当你尝试运行第一个程序,它都会尝试创建一个名为"myfifo"的FIFO。如果该文件已经存在,mkfifo
函数会失败,并返回-1。
为了解决这个问题,你有几个选择:
-
检查文件是否存在再创建:在调用
mkfifo
之前,可以先检查"./myfifo"是否已经存在。如果不存在,再去创建。struct stat st; if (stat("./myfifo", &st) == -1) { int ret = mkfifo("./myfifo", 0777); if (ret < 0) { perror("mkfifo error"); return -1; } }
-
忽略"File exists"错误:如果
mkfifo
返回错误,并且这个错误是"File exists",那么可以选择简单地忽略这个错误,因为你知道FIFO已经存在,可以继续执行后面的代码。int ret = mkfifo("./myfifo", 0777); if (ret < 0 && errno != EEXIST) { perror("mkfifo error"); return -1; }
这里,
errno
是一个全局变量,当系统调用返回一个错误时,它会被设置为一个特定的值。EEXIST
是一个错误码,表示文件已经存在。 -
每次运行前删除FIFO:你可以选择在每次执行程序前手动删除"./myfifo",或者在程序开始执行时自动删除它。
unlink("./myfifo"); int ret = mkfifo("./myfifo", 0777); ...
请注意,
unlink
函数会删除指定的文件,所以只有当你确定要删除这个文件时才使用它。
你可以根据你的具体需求和使用场景选择最适合的方法。
File exists为什么会输出出来?
当你调用mkfifo
试图创建一个已经存在的文件时,mkfifo
会返回-1,并设置全局变量errno
为EEXIST
,表示文件已经存在。
你的程序中使用了perror("mkfifo error");
来输出错误信息。perror
函数的工作原理是:它会打印你提供的字符串,然后根据当前的errno
值输出相应的系统错误描述。由于在这种情况下errno
被设置为EEXIST
,perror
将输出"File exists"来描述这个错误。
因此,你看到的错误信息"mkfifo error: File exists"是由以下两部分组成的:
- "mkfifo error":这是你传递给
perror
的字符串。 - "File exists":这是
perror
根据errno
的值生成的系统错误描述。
如果你希望不显示这个错误,可以按照我之前的回答中提到的方法进行处理。
char buf[64];
memset(buf, 0x00, sizeof(buf));
这段代码的作用是:
- 定义一个字符数组
buf
,其大小为64字节。 - 使用
memset
函数将buf
中的所有字节设置为0x00
。
简单地说,这段代码创建了一个64字节的字符数组并将其所有字节初始化为0。
这两段代码是一个简单的示例,展示了Linux下FIFO(有时也叫做命名管道)的基本使用。FIFO允许不相关的进程进行双向通信。
让我们分析一下这两段代码:
第一个程序(写入FIFO):
- 使用
mkfifo
函数创建一个FIFO文件。这是一个特殊类型的文件,用于进程间通信。文件名为“myfifo”,权限设置为0777。 - 使用
open
函数打开FIFO以进行读写。 - 使用
write
函数向FIFO写入字符串"hello world"。 - 使用
sleep
函数让进程休眠10秒,这可能是为了模拟一些处理延迟,或者等待第二个进程读取数据。 - 使用
close
函数关闭FIFO。
第二个程序(从FIFO中读取):
- 使用
open
函数打开先前创建的FIFO。 - 准备一个字符数组
buf
,用于从FIFO读取数据。 - 使用
read
函数从FIFO读取数据并存储到buf
中。 - 打印从FIFO读取的字节数和数据内容。
- 使用
close
函数关闭FIFO。
执行顺序和注意事项:
-
首先,你需要运行第一个程序来创建FIFO并写入数据。
-
在第一个程序休眠的时候(即等待10秒的期间),你应该在另一个终端或进程中运行第二个程序来读取FIFO中的数据。这样,第二个程序会打印出从FIFO读取的"hello world"。
-
注意,在实际使用中,当一个FIFO被打开以供读取时,直到有其他进程打开该FIFO以供写入,否则该读取进程会被阻塞。同样,当一个FIFO被打开以供写入时,直到有其他进程打开该FIFO以供读取,否则写入进程会被阻塞。
-
如果你在第一个程序中不使用
sleep
函数,那么你需要确保第二个程序在第一个程序结束之前开始运行,否则第一个程序可能会在第二个程序开始运行之前结束,这可能会导致第二个程序无法读取到数据。
总体来说,这是一个非常基础的FIFO使用示例。在实际应用中,你可能需要考虑更多的同步和并发控制来确保数据的完整性和通信的正确性。
循环读循环写
循环读
4.3FIFO
1.何为命名管道
管道(pipe)只能用于“有血缘关系”的进程间。
pipe与FIFO之间最大的区别就是FIFO提供一个路径名与之关联,在文件系统中有一个索引块,以文件路径的形式存在(在磁盘中没有数据块,所有数据都存放在内核),而这个文件路径是FIFO被称为命名管道的重要原因。
FIFO与管道类似,其本质上也是内核的一块缓冲区,FIFO也有一个写入端和读取端,FIFO中的数据读写顺序和管道PIPE中是一样的。进程就像打开普通文件一样,调用open函数打开FIFO文件进行读写操作,实现任意两个没有关系的进程通信。
2.使用mkfifo函数创建FIFO
mkfifo函数就是用于创建一个FIFO文件
#include <sys/types.h>
#include <sys/stat.h>
int mkfifo(const char *pathname, mode_t mode);
返回值说明:成功返回0; 失败返回-1,设置errno
参数pathname:要创建的FIFO文件名且该文件必须不存在
参数mode:指定FIFO文件的权限(8进制数,如0664)
一旦使用mkfifo创建了一个FIFO,就可以使用open打开它,常见的文件I/O函数都可用于fifo。如:close、read、write、unlink等。
调用mkfifo函数创建FIFO进行进程间通信的大概过程:
如图所示,当调用mkfifo函数创建一个名为myfifo的FIFO文件时,任何有权限的进程都能打开myfifo这个文件。
使用FIFO进行进程间通信的时候,通常会设置一个写入进程和读取进程,例如A进程通过open函数以只写方式打开FIFO文件,并通过文件描述符把数据写入FIFO内核缓冲区,B进程也通过open函数以只读方式打开FIFO文件,通过文件描述符从FIFO的缓冲区中读取数据。
还使用命令创建管道方式,例如:mkfifo test,其实mkfifo命令本质上就是调用了mkfifo函数来创建FIFO文件。
我们可以看到创建的FIFO文件test,另外在文件权限位的最前面的p就表示文件类型,即管道文件。
3.FIFO的打开规则
1)如果当前打开操作是为读而打开FIFO时,若已经有相应进程为写而打开该FIFO,则当前打开操作将成功返回;否则,可能阻塞直到有相应进程为写而打开该FIFO(当前打开操作设置了阻塞标志);或者,成功返回(当前打开操作没有设置阻塞标志)。
2)如果当前打开操作是为写而打开FIFO时,如果已经有相应进程为读而打开该FIFO,则当前打开操作将成功返回;否则,可能阻塞直到有相应进程为读而打开该FIFO(当前打开操作设置了阻塞标志);或者,返回ENXIO错误(当前打开操作没有设置阻塞标志)。
总之,一旦设置了阻塞标志,调用mkfifo建立好之后,那么管道的两端读写必须分别打开,有任何一方未打开,则在调用open的时候就阻塞。对管道或者FIFO调用lseek,返回ESPIPE错误。
4.使用FIFO进行进程通信
一旦使用mkfifo创建了一个FIFO,就可以使用open打开它,常见的文件I/O函数都可用于fifo,如:close、read、write、unlink等,但不能使用lseek函数。
实验:fifo1程序创建FIFO文件test,然后打开test文件写数据,fifo2程序打开test文件读数据
fifo1写数据
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
int main(){
int fd = 0;
int len = 0;
int ret;
char buf[1024] = {0};
//创建FIFO文件
ret = mkfifo("test" , 0664);
if(ret != 0){
perror("mkfifo error:");
exit(-1);
}
//只写方式打开test
fd = open("test", O_WRONLY);
if(fd < 0){
perror("open error");
}
puts("open fifo write");
//向FIFO写入数据
while((len = read(STDIN_FILENO, buf, sizeof(buf))) > 0){
write(fd, buf, len);
}
close(fd);
return 0;
}
fifo2读数据
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
int main(){
int fd = 0;
int len = 0;
char buf[1024] = {0};
//只读打开test
fd = open("test", O_RDONLY);
if(fd < 0){
perror("open error:");
}
puts("open fifo read");
//从管道中读取数据
while((len = read(fd, buf, sizeof(buf))) > 0){
write(STDOUT_FILENO, buf, len);
}
//如果read返回0,说明读到文件末尾或对端已关闭
if(len == 0){
puts("peer is close or file end");
}else{
puts("read fifo");
}
close(fd);
return 0;
}
程序执行结果:
1 . 当执行./fifo1时会出现阻塞,因为./fifo2没有打开
2 . 接着再执行./fifo2后,程序执行结果如图所示:
程序的执行结果来看,fifo1进程打印open fifo write,fifo2进程打印了open fifo read,然后fifo1写入的数据会马上被fifo2读走。
当我们在fifo1处按下Ctrl+c时,该进程的写端会被关闭掉,对应的在fifo2的进程的读端的read就会读到0(对端已关闭)。我们再次查看test管道文件时,发现文件内容为0,这说明FIFO是一个管道,具有管道的特性(管道中的数据只能读取一次,一旦读完就不存在了)。
由此也证明了test文件在文件系统中只是以一个文件路径的形式而存在,在磁盘中并没有数据,所有的数据都在内核缓冲区中,当进程结束时,数据就会释放掉,但是test文件则会在文件系统中保存着,之后进程再次使用FIFO进行通信时,只需直接打开test文件就行了,然后通过test文件读写内核缓冲区的数据(可以把test文件理解为C语言中的指针,通过指针去操作这块内存,虽然这么理解有些不严谨),所以这才是FIFO与管道(PIPE)的真正区别。
5.使用FIFO通信的注意事项
1.当一个进程调用open打开FIFO文件读取数据时会阻塞等待,直到另一进程打开FIFO文件写入数据为止。也就是说,FIFO文件必须读和写同时打开才行,如果FIFO的读写两端都已打开,那么open调用会立即返回成功,否则一个进程单独打开写或者读都会引发阻塞(上一小节已经证明了这一点)。
2.如果一个进程调用open函数指定O_RDWR选项来打开FIFO时不会发生阻塞,open会立即返回,不会出错,但是大多数unix实现(包括linux)对于这样的行为是未知的,这会导致进程无法正确使用管道进行通信,因为这种做法破坏了FIFO文件的I/O模型(其实就是违背了管道的通信方式)。
换句话说,此时调用进程使用open函数返回的文件描述符读取数据时,read永远都不会读到文件末尾(至于详细原因请参考上一篇的第五小节:为何要关闭未使用的管道文件描述符)。
现在对fifo2程序做以下修改:
//以读写方式打开test
fd = open("test", O_RDWR);
程序的执行结果为:
当在fifo1出按下Ctrl+c时,fifo2进程并没有终止,而是一直在阻塞。
原因在于:fifo2进程因为是以O_RDWR(读写方式)打开test文件的,掌握着FIFO的读写两端,系统内核发现FIFO的写端还没有完全关闭,所以啥也不会做,于是fifo2就会阻塞在FIFO的读端处,等待着数据到来,但此时只有fifo2掌握着FIFO的写端,那么fifo2将会永远阻塞在读端。
3.如果在打开FIFO文件不希望阻塞时,在调用open函数可以指定O_NONBLOCK。
6.使用非阻塞I/O
上一小节中说过一个进程在打开FIFO不希望阻塞时,可以在调用open函数时指定O_NONBLOCK来实现非阻塞。但是O_NONBLOCK标志在不同情况下可能会产生不同的影响,甚至会导致程序出现一些不可预料的错误。
例如在FIFO的读端没有被打开的情况下,如果当前进程以写方式打开FIFO,那么open会调用失败,并将errno设置为ENXIO。
我们对fifo1程序做以下修改:
//以只写和非阻塞方式打开test
fd = open("test", O_WRONLY|O_NONBLOCK);
if(fd < 0){
//判断是否为对端没打开导致ENXIO错误
if(errno == ENXIO){
perror("open error");
}
exit(1);
}
程序执行结果:
No such device or address错误的大意就是没有这样的设备或地址,出错原因在于,当你打开FIFO写入数据,但是对方没打开读端就会出现这样的错误。
7.避免打开FIFO引发的死锁问题
通常,进程间通信都是有两个或两个以上进程的,假设这么一种情况,当每个进程都在等待对方完成某个动作而阻塞时,这可能会产生死锁问题,下图中就展示了两个进程产生死锁的情况:
在上图中的两个进程都因等待打开一个FIFO文件读取数据而阻塞。
fifo1进程
#include <stdio.h>
#include <fcntl.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
int main(){
int fd1 = 0;
int fd2 = 0;
int ret = 0;
//创建A_FIFO
ret = mkfifo("A_FIFO" , 0664);
if(ret != 0){
perror("mkfifo A_FIFO error:");
exit(-1);
}
//创建B_FIFO
ret = mkfifo("B_FIFO" , 0664);
if(ret != 0){
perror("mkfifo B_FIFO error:");
}
//以只读方式打开A_FIFO
fd1 = open("A_FIFO", O_RDONLY);
if(fd1 < 0){
perror("open A_FIFO error:");
exit(1);
}
puts("open A_FIFO read");
//以只写方式打开B_FIFO
fd2 = open("B_FIFO" , O_WRONLY);
if(fd2 < 0){
perror("open B_FIFO error");
exit(1);
}
puts("open B_FIFO write");
close(fd1);
close(fd2);
return 0;
}
fifo2进程
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h>
#include <sys/stat.h>
#include <sys/types.h>
#include <errno.h>
int main(){
int fd1 = 0;
int fd2 = 0;
//以读方式打开B_FIFO
fd1 = open("B_FIFO", O_RDONLY);
if(fd1 < 0){
perror("open B_FIFO error:");
exit(1);
}
puts("open B_FIFO read");
//以写方式打开A_FIFO
fd2 = open("A_FIFO" , O_WRONLY);
if(fd2 < 0){
perror("open A_FIFO error:");
exit(1);
}
puts("open A_FIFO write");
//关闭管道
close(fd1);
close(fd2);
return 0;
}
程序执行结果:
分析死锁的原因:
因为fifo1进程在打开A_FIFO读数据之前,fifo2进程并没有打开A_FIFO的写端,所以fifo1进程会阻塞等待在open调用处,对于fifo2进程来说也是如此,双方进程都在等待对方打开FIFO的另一端,如果不采取有效措施,双方将会一直死等下去。
为了避免这个问题,我们可以让其中一个进程或两个线程在打开FIFO时都指定O_NONBLOCK选项以非阻塞方式解决这个问题。
5.1 存储映射区介绍
存储映射I/O (Memory-mapped I/O) 使一个磁盘文件与存储空间中的一个缓冲区相映射。从缓冲区中取数据,就相当于读文件中的相应字节;将数据写入缓冲区,则会将数据写入文件。这样,就可在不使用read和write函数的情况下,使用地址(指针)完成I/O操作。
使用存储映射这种方法,首先应通知内核,将一个指定文件映射到存储区域中。这个映射工作可以通过mmap函数来实现。
5.2 mmap函数
- 函数作用:
建立存储映射区
-
函数原型
void *mmap(void *addr, size_t length, int prot, int flags, int fd, off_t offset);
-
函数返回值:
▶成功:返回创建的映射区首地址;
▶失败:MAP_FAILED宏
- 参数:
▶addr: 指定映射的起始地址, 通常设为NULL,由系统指定
▶length:映射到内存的文件长度
▶prot:映射区的保护方式, 最常用的:
◆读:PROT_READ
◆写:PROT_WRITE
◆读写:PROT_READ | PROT_WRITE
▶flags: 映射区的特性, 可以是
◆MAP_SHARED: 写入映射区的数据会写回文件,且允许其他映射该文件的进程共享。
◆MAP_PRIVATE: 对映射区的写入操作会产生一个映射区的复制(copy-on-write),对此区域所做的修改不会写回原文件。
▶fd:由open返回的文件描述符, 代表要映射的文件。
▶offset:以文件开始处的偏移量,必须是4k的整数倍,通常为0,表示从文件头开始映射。
addr:一班传NULL,表示让内核去指定一个内存起始地址
length:文件大小
lseek(打开了用这个方便)或者stat函数(获取文件大小)
prot:PROT_READ PROT_WRITE PROT_READ | PROT_WRITE
flags:
MAP_SHARED:对映射区的修改会反映到文件中(可以对文件进行修改) 通过内存
MAP_PRIVATE:对映射区的修改不会对文件产生影响
fd:打开的文件描述符
fd = open();
offset:从文件的哪个位置开始映射,一般传0
1.使用map函数完成父子进程间通信
mmap1.c
O_RDWR是一个打开文件的模式,表示以读写方式打开文件。
上面是直接覆盖文件,文件改回去,用MAP_PRIVATE
不能反应到文件当中去,无法写
使用mmap完成毫无血缘关系的进程间的通信
mmap_read.c
mmap_write.c
5.3 munmap函数
- 函数作用:
释放由mmap函数建立的存储映射区
- 函数原型:
int munmap(void *addr, size_t length);
- 返回值:
成功:返回0
失败:返回-1,设置errno值
- 函数参数:
▶addr:调用mmap函数成功返回的映射区首地址
▶length:映射区大小(mmap函数的第二个参数)
5.4mmap注意事项
-
创建映射区的过程中,隐含着一次对映射文件的读操作,将文件内容读取到映射区
-
当MAP_SHARED时,要求:映射区的权限应 <=文件打开的权限(出于对映射区的保护)。而MAP_PRIVATE则无所谓,因为mmap中的权限是对内存的限制。
-
映射区的释放与文件关闭无关,只要映射建立成功,文件可以立即关闭。
-
特别注意,当映射文件大小为0时,不能创建映射区。所以,用于映射的文件必须要有实际大小;mmap使用时常常会出现总线错误,通常是由于共享文件存储空间大小引起的。
-
munmap传入的地址一定是mmap的返回地址。坚决杜绝指针++操作。
-
文件偏移量必须为0或者4K的整数倍
-
mmap创建映射区出错概率非常高,一定要检查返回值,确保映射区建立成功再进行后续操作。
5.5 有关mmap函数的使用总结
-
第一个参数写成NULL
-
第二个参数要映射的文件大小 > 0
-
第三个参数:PROT_READ 、PROT_WRITE
-
第四个参数:MAP_SHARED 或者 MAP_PRIVATE
-
第五个参数:打开的文件对应的文件描述符
-
第六个参数:4k的整数倍
5.6 mmap函数相关思考题
-
可以open的时候O_CREAT一个新文件来创建映射区吗?
-
如果open时O_RDONLY, mmap时PROT参数指定PROT_READ|PROT_WRITE会怎样?
-
mmap映射完成之后, 文件描述符关闭,对mmap映射有没有影响?
-
如果文件偏移量为1000会怎样?
-
对mem越界操作会怎样?
-
如果mem++,munmap可否成功?
-
mmap什么情况下会调用失败?
-
如果不检测mmap的返回值,会怎样?
6.信号
6.1学习目标
-
了解信号中的基本概念
-
熟练使用信号相关的函数
-
参考文档使用信号集操作相关函数
-
熟练使用信号捕捉函数signal
-
熟练使用信号捕捉函数sigaction
-
熟练掌握使用信号完成子进程的回收
信号介绍
- 信号的概念
信号是信息的载体,Linux/UNIX 环境下,古老、经典的通信方式, 现下依然是主要的通信手段。
▶信号在我们的生活中随处可见,例如:
▷古代战争中摔杯为号;
▷现代战争中的信号弹;
▷体育比赛中使用的信号枪......
▶信号的特点
▷简单
▷不能携带大量信息
▷满足某个特点条件才会产生
6.2信号的机制
进程A给进程B发送信号,进程B收到信号之前执行自己的代码,收到信号后,不管执行到程序的什么位置,都要暂停运行,去处理信号,处理完毕后再继续执行。与硬件中断类似——异步模式。但信号是软件层面上实现的中断,早期常被称为“软中断”。
每个进程收到的所有信号,都是由内核负责发送的。
进程A给进程B发送信号示意图:
1.信号的状态
信号有三种状态:产生、未决和递达。
- 信号的产生
▶按键产生,如:Ctrl+c、Ctrl+z、Ctrl+\
▶系统调用产生,如:kill、raise、abort
▶软件条件产生,如:定时器alarm
▶硬件异常产生,如:非法访问内存(段错误)、除0(浮点数例外)、内存对齐出错(总线错误)
▶命令产生,如:kill命令
-
未决:产生和递达之间的状态。主要由于阻塞(屏蔽)导致该状态。
-
递达:递送并且到达进程。
2.信号的处理方式
-
执行默认动作
-
忽略信号(丢弃不处理)
-
捕捉信号(调用用户的自定义的处理函数)
3.信号的特质
信号的实现手段导致信号有很强的延时性,但对于用户来说,时间非常短,不易察觉。
Linux内核的进程控制块PCB是一个结构体,task_struct, 除了包含进程id,状态,工作目录,用户id,组id,文件描述符表,还包含了信号相关的信息,主要指阻塞信号集和未决信号集。
注:表示PCB的task_struct结构体定义在:
/usr/src/linux-headers-4.4.0-97/include/linux/sched.h:1390
4.阻塞信号集和未决信号集
Linux内核的进程控制块PCB是一个结构体,这个结构体里面包含了信号相关的信息,主要有阻塞信号集和未决信号集。
-
阻塞信号集中保存的都是被当前进程阻塞的信号。若当前进程收到的是阻塞信号集中的某些信号,这些信号需要暂时被阻塞,不予处理。
-
信号产生后由于某些原因(主要是阻塞)不能抵达,这类信号的集合称之为未决信号集。在屏蔽解除前,信号一直处于未决状态;若是信号从阻塞信号集中解除阻塞,则该信号会被处理,并从未决信号集中去除。
5.信号的四要素
- 通过man 7 signal可以查看信号相关信息
1.信号的编号
▶使用kill -l命令可以查看当前系统有哪些信号,不存在编号为0的信号。其中1-31号信号称之为常规信号(也叫普通信号或标准信号),34-64称之为实时信号,驱动编程与硬件相关。
2.信号的名称
3.产生信号的事件
4.信号的默认处理动作
▶Term:终止进程
▶Ign:忽略信号 (默认即时对该种信号忽略操作)
▶Core:终止进程,生成Core文件。(查验死亡原因,用于gdb调试)
▶Stop:停止(暂停)进程
▶Cont:继续运行进程
-
特别需要注意的是:
The signals SIGKILL and SIGSTOP cannot be caught, blocked, or ignored.
-
几个常用到的信号
SIGINT、SIGQUIT、SIGKILL、SIGSEGV、SIGUSR1、SIGUSR2、SIGPIPE、SIGALRM、SIGTERM、SIGCHLD、SIGSTOP、SIGCONT
6.3信号相关函数
1.signal函数
-
函数作用:注册信号捕捉函数
-
函数原型
typedef void (*sighandler_t)(int);
//这里int类型是信号编号
sighandler_t signal(int signum, sighandler_t handler);
- 函数参数
▶signum:信号编号
▶handler:信号处理函数
信号是内核产生的,发给当前进程
1.利用pipe创建一个管道,内核中有缓冲区,用读取两端调取这个缓冲区
2.给内核注册一个信号SIGPIPE(信号编号),sighandler是信号处理函数,当SIGPIPE信号发生之后,去处理sighandler函数
3.运行到write行时,往一个没有读端的管道写数据,写完了,内核检测到一个错误,产生的信号时SIGPIPE信号,当前进程已经注册了这个信号,知道去执行sighandler函数(内核去执行的)
2.kill函数/命令
-
描述:给指定进程发送指定信号
-
kill命令:kill -SIGKILL 进程PID
-
kill函数原型:int kill(pid_t pid, int sig);
-
函数返回值:
▶成功:0;
▶失败:-1,设置errno
- 函数参数:
▶sig信号参数:不推荐直接使用数字,应使用宏名,因为不同操作系统信号编号可能不同,但名称一致。
▶pid参数:
▷pid > 0: 发送信号给指定的进程。
▷pid = 0: 发送信号给与调用kill函数进程属于同一进程组的所有进程。
▷pid < -1: 取|pid|发给对应进程组。
▷pid = -1:发送给进程有权限发送的系统中所有进程。
进程组:每个进程都属于一个进程组,进程组是一个或多个进程集合,他们相互关联,共同完成一个实体任务,每个进程组都有一个进程组长,默认进程组ID与进程组长ID相同。
自杀
子进程杀死父进程(实际上是子进程给父进程发信号)
kill1.c
第一行,2106是kill进程的父进程,自己的PID是2369,组ID是自己,2106是bash
后面3个都是子进程,组进程都一样,会话ID也一样,在同一个组中,同一个会话中,一个会话可以包含一个组或者多个组
加上
#include<signal.h>
父进程用完时间片后子进程再使用CPU
父进程杀死子进程
#include <stdio.h> // 标准输入输出库
#include <stdlib.h> // 标准库,包含了一些常用的函数如:exit()、system()等
#include <unistd.h> // Unix标准库,包含了对POSIX操作系统API的访问功能
#include <signal.h> // 信号处理库,定义了一些用于处理各种信号的宏和函数
int main()
{
pid_t pid = fork(); // 创建一个新的进程,并返回新进程的PID
if (pid == -1) // 如果fork失败,则打印错误信息并退出程序
{
perror("fork failed");
exit(EXIT_FAILURE);
}
else if (pid == 0) // 如果fork成功,则在子进程中执行以下代码
{
// 子进程
printf("Child process, PID: %d\n", getpid()); // 打印子进程的PID
while (1) // 无限循环,模拟子进程正在执行的任务
{
// 子进程执行的代码
}
}
else // 如果fork成功,则在父进程中执行以下代码
{
// 父进程
printf("Parent process, PID: %d\n", getpid()); // 打印父进程的PID
sleep(2); // 让父进程等待2秒钟
kill(pid, SIGKILL); // 向子进程发送一个SIGKILL信号,通知子进程立即停止运行
printf("Sent SIGKILL to child process with PID: %d\n", pid); // 打印发送信号的PID
}
return 0; // 程序正常结束
}
这段代码首先创建了一个子进程。父进程和子进程都会打印出自己的进程ID,然后父进程会等待2秒钟,之后发送一个SIGKILL信号给子进程,子进程收到信号后会被立即终止。如果子进程在收到SIGKILL信号之前正在执行某个任务,那么这个任务会被立即停止。
杀死同一组所有进程
3.abort函数raise函数
- raise函数
▶函说描述:给当前进程发送指定信号(自己给自己发)
▶函数原型:int raise(int sig);
▶函数返回值:成功:0,失败非0值
▶函数拓展:raise(signo) == kill(getpid(), signo);
- abort函数
▶函数描述:给自己发送异常终止信号 6) SIGABRT,并产生core文件
▶函数原型:void abort(void);
▶函数拓展:abort() == kill(getpid(), SIGABRT);
raise.c
abort.c
core文件大小为0
4.alarm函数
-
函数原型:unsigned int alarm(unsigned int seconds);
-
函数描述:设置定时器(闹钟)。在指定seconds后,内核会给当前进程发送14)SIGALRM信号。进程收到该信号,默认动作终止。每个进程都有且只有唯一的一个定时器。
-
函数返回值:返回0或剩余的秒数,无失败。例如:
- 常用操作:取消定时器alarm(0),返回旧闹钟余下秒数。
alarm使用的是自然定时法,与进程状态无关,就绪、运行、挂起(阻塞、暂停)、终止、僵尸...无论进程处于何种状态,alarm都计时。
练习题1:编写一个程序测试alarm函数
练习题2:编写程序,测试你的电脑1秒种能数多个数字。
使用time命令查看程序执行的时间。程序运行的瓶颈在于IO,优化程序,首选优化IO。
实际执行时间 = 系统时间 + 用户时间 + 损耗时间
损耗的时间主要来来自文件IO操作,IO操作会有用户区到内核区的切换,切换的次数越多越耗时。
alarm测试函数
测试1秒钟数多少数字
time ./alarm_uncle
闹钟
实际执行时间 = 系统时间 + 用户时间 + 损耗时间
损耗时间= 实际执行时间-(系统时间 + 用户时间 )
每一个数字都直接打印:printf("[%d]\n", i++);
real 0m1.217s
user 0m0.120s
sys 0m0.252s
15734次
损耗时间= 1.217-(0.120+0.252)=0.845
文件重定向之后:
time ./alarm_uncle > test.log
real 0m1.003s
user 0m0.520s
sys 0m0.428s
2191879次
损耗时间=1.003-(0.520+0.428)=0.055
原因是: 调用printf函数打印数字遇到\n才会打印, 打印过程涉及到从用户区到内核区的切换, 切换次数越多消耗的时间越长, 效率越低;
而使用文件重定向, 由于文件操作是带缓冲的, 所以涉及到用户区到内核区的切换次数大大减少,从而使损耗降低.
int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);
功能: 设置时钟, 能够周期性的触发时钟
5.setitimer函数
- 函数原型
int setitimer(int which, const struct itimerval *new_value, struct itimerval *old_value);
- 函数描述
设置定时器(闹钟),可代替alarm函数,精度微秒us,可以实现周期定时。
- 函数返回值
▶成功:0;
▶失败:-1,设置errno值
- 函数参数:
▶which:指定定时方式
▶自然定时:ITIMER_REAL →14)SIGALRM计算自然时间
▶虚拟空间计时(用户空间):ITIMER_VIRTUAL → 26)SIGVTALRM只计算进程占用cpu的时间
▶运行时计时(用户+内核):ITIMER_PROF → 27)SIGPROF计算占用cpu及执行系统调用的时间
▶new_value:struct itimerval,负责设定timeout时间。
▷itimerval.it_value: 设定第一次执行function所延迟的秒数
itimerval.it_interval: 设定以后每几秒执行function
struct itimerval
{
struct timerval it_interval; // 闹钟触发周期
struct timerval it_value; // 闹钟触发时间
};
struct timeval
{
long tv_sec; // 秒
long tv_usec; // 微秒
}
▶old_value: 存放旧的timeout值,一般指定为NULL
练习: 使用setitimer实现每隔一秒打印一次hello,world。
函数测试
3秒钟后每隔1秒运行一次sighandler,时钟第一次产生是第4秒
6.4信号集相关
1.未决信号集和阻塞信号集的关系
阻塞信号集是当前进程要阻塞的信号的集合,未决信号集是当前进程中还处于未决状态的信号的集合,这两个集合存储在内核的PCB中。
产生方式: 键盘Ctrl+C
产生结果: 只对当前前台进程,和他的所在的进程组的每个进程都发送SIGINT信号,之后这些进程会执行信号处理程序再终止。
- 下面以SIGINT为例说明信号未决信号集和阻塞信号集的关系:
当进程收到一个SIGINT信号(信号编号为2),首先这个信号会保存在未决信号集合中,此时对应的2号编号的这个位置上置为1,表示处于未决状态;在这个信号需要被处理之前首先要在阻塞信号集中的编号为2的位置上去检查该值是否为1:
▶如果为1,表示SIGNIT信号被当前进程阻塞了,这个信号暂时不被处理,所以未决信号集上该位置上的值保持为1,表示该信号处于未决状态;
▶如果为0,表示SIGINT信号没有被当前进程阻塞,这个信号需要被处理,内核会对SIGINT信号进行处理(执行默认动作,忽略或者执行用户自定义的信号处理函数),并将未决信号集中编号为2的位置上将1变为0,表示该信号已经处理了,这个时间非常短暂,用户感知不到。
当SIGINT信号从阻塞信号集中解除阻塞之后,该信号就会被处理。
2.信号集相关函数
由于信号集属于内核的一块区域,用户不能直接操作内核空间,为此,内核提供了一些信号集相关的接口函数,使用这些函数用户就可以完成对信号集的相关操作。
信号集是一个能表示多个信号的数据类型,sigset_t set,set即一个信号集。既然是一个集合,就需要对集进行添加、删除等操作。
sigset_t类型的定义在signal.h文件中的第49行处:
typedef __sigset_t sigset_t;
__sigset_t的定义在sigset.h文件中的26,27行处:
# define _SIGSET_NWORDS (1024 / (8 * sizeof (unsigned long int)))
typedef struct
{
unsigned long int __val[_SIGSET_NWORDS];
} __sigset_t;
上述变量类型的定义的查找有个小窍门: 可以执行gcc的预处理命令:
gcc -E test.c -o test.i 这样头文件就会展开,可以直接到test.i文件中看到相关变量类型的定义。
- 信号集相关函数
▶int sigemptyset(sigset_t *set);
函数说明:将某个信号集清0
函数返回值:成功:0;失败:-1,设置errno
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
void signal_handler(int signal_number)
{
printf("Received signal %d\n", signal_number);
exit(signal_number);
}
int main()
{
struct sigaction sa;
sigset_t signal_set;
// 初始化信号集
if (sigemptyset(&signal_set) == -1)
{
perror("sigemptyset");
return 1;
}
// 设置信号处理程序
sa.sa_handler = signal_handler;
sa.sa_mask = signal_set;
sa.sa_flags = 0;
// 注册信号处理程序
if (sigaction(SIGINT, &sa, NULL) == -1)
{
perror("sigaction");
return 1;
}
// 进入无限循环,等待信号
while (1)
{
pause();
}
return 0;
}
▶int sigfillset(sigset_t *set);
函数说明:将某个信号集置1
函数返回值:成功:0;失败:-1,设置errno
▶int sigaddset(sigset_t *set, int signum);
函数说明:将某个信号加入信号集合中
函数返回值:成功:0;失败:-1,设置errno
▶int sigdelset(sigset_t *set, int signum);
函数说明:将某信号从信号清出信号集
函数返回值:成功:0;失败:-1,设置errno
▶int sigismember(const sigset_t *set, int signum);
函数说明:判断某个信号是否在信号集中
函数返回值:在:1;不在:0;出错:-1,设置errno
▶sigprocmask函数
函数说明:用来屏蔽信号、解除屏蔽也使用该函数。其本质,读
取或修改进程控制块中的信号屏蔽字(阻塞信号集)。
特别注意,屏蔽信号只是将信号处理延后执行(延至解除屏蔽);而忽略表示将信号丢弃处理。
▷函数原型:int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
▷函数返回值:成功:0;失败:-1,设置errno
▷函数参数:
◆how参数取值:假设当前的信号屏蔽字为mask
◇SIG_BLOCK: 当how设置为此值,set表示需要屏蔽的信号。相当于 mask = mask|set
◇SIG_UNBLOCK: 当how设置为此,set表示需要解除屏蔽的信号。相当于 mask = mask & ~set
◇SIG_SETMASK: 当how设置为此,set表示用于替代原始屏蔽及的新屏蔽集。相当于mask = set若,调用sigprocmask解除了对当前若干个信号的阻塞,则在sigprocmask返回前,至少将其中一个信号递达。
◆set:传入参数,是一个自定义信号集合。由参数how来指示如何修改当前信号屏蔽字。
◆oldset:传出参数,保存旧的信号屏蔽字。
▶sigpending函数
▷函数原型:int sigpending(sigset_t *set);
▷函数说明:读取当前进程的未决信号集
▷函数参数:set传出参数
▷函数返回值:成功:0;失败:-1,设置errno
练习:编写程序,设置阻塞信号集并把所有常规信号的未决状态打印至屏幕。
自定义信号集函数
为了方便对多个信号进行处理,一个用户进程常常需要对多个信号做出处理,在 Linux 系统中引入了信号集(信号的集合)。
这个信号集有点类似于我们的 QQ 群,一个个的信号相当于 QQ 群里的一个个好友。
信号集是一个能表示多个信号的数据类型,sigset_t set,set即一个信号集。既然是一个集合,就需要对集合进行添加/删除等操作。
相关函数说明如下:
#include <signal.h>
int sigemptyset(sigset_t *set); //将set集合置空
int sigfillset(sigset_t *set); //将所有信号加入set集合
int sigaddset(sigset_t *set, int signo); //将signo信号加入到set集合
int sigdelset(sigset_t *set, int signo); //从set集合中移除signo信号
int sigismember(const sigset_t *set, int signo); //判断信号是否存在
除sigismember外,其余操作函数中的set均为传出参数。sigset_t类型的本质是位图。但不应该直接使用位操作,而应该使用上述函数,保证跨系统操作有效。
示例程序:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<signal.h>
//显示信号集
void show_set(sigset_t* s)
{
int i = 0;
for (i = 1; i < 32; i++)
{
//判断指定的信号是否在集合中
if (sigismember(s, i))
{
printf("1");
}
else
{
printf("0");
}
}
putchar("\n");
}
int main()
{
int i = 0;
//定义一个信号集变量
sigset_t set;
// 清空信号集的内容
sigemptyset(&set);
//将所有的信号加入到set集合中
sigfillset(&set);
show_set(&set);
//将信号2和3从信号集中移除
sigdelset(&set, SIGINT);
sigdelset(&set, SIGQUIT);
show_set(&set);
//将信号2添加到集合中
sigaddset(&set, SIGINT);
show_set(&set);
return 0;
}
sigprocmask函数
信号阻塞集也称信号屏蔽集、信号掩码。每个进程都有一个阻塞集,创建子进程时子进程将继承父进程的阻塞集。信号阻塞集用来描述哪些信号递送到该进程的时候被阻塞(在信号发生时记住它,直到进程准备好时再将信号通知进程)。
所谓阻塞并不是禁止传送信号,而是暂缓信号的传送。若将被阻塞的信号从信号阻塞集中删除,且对应的信号在被阻塞时发生了,进程将会收到相应的信号。
我们可以通过 sigprocmask() 修改当前的信号掩码来改变信号的阻塞情况。
#include <signal.h>
int sigprocmask(int how, const sigset_t *set, sigset_t *oldset);
功能:
检查或修改信号阻塞集,根据 how 指定的方法对进程的阻塞集合进行修改,新的信号阻塞集由 set 指定,而原先的信号阻塞集合由 oldset 保存。
参数:
how : 信号阻塞集合的修改方法,有 3 种情况:
SIG_BLOCK:向信号阻塞集合中添加 set 信号集,新的信号掩码是set和旧信号掩码的并集。相当于 mask = mask|set。
SIG_UNBLOCK:从信号阻塞集合中删除 set 信号集,从当前信号掩码中去除 set 中的信号。相当于 mask = mask & ~ set。
SIG_SETMASK:将信号阻塞集合设为 set 信号集,相当于原来信号阻塞集的内容清空,然后按照 set 中的信号重新设置信号阻塞集。相当于mask = set。
set : 要操作的信号集地址。
若 set 为 NULL,则不改变信号阻塞集合,函数只把当前信号阻塞集合保存到 oldset 中。
oldset : 保存原先信号阻塞集地址
返回值:
成功:0,
失败:-1,失败时错误代码只可能是 EINVAL,表示参数 how 不合法。
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<signal.h>
//显示信号集
void fun1(int signum)
{
printf("捕捉到信号:%d\n", signum);
}
void fun2(int signum)
{
printf("捕捉到信号:%d\n", signum);
}
int main(void)
{
int ret = -1;
//信号集
sigset_t set;
sigset_t oldset;
//信号注册
//Ctrl + C
signal(SIGINT, fun1);
//Ctrl + \ akjfdskj
signal(SIGQUIT, fun2);
printf("按下任意键 阻塞信号2");
getchar();
sigemptyset(&oldset);
sigemptyset(&set);
sigaddset(&set, SIGINT);
//设置屏蔽编号为2的信号
ret = sigprocmask(SIG_BLOCK, &set, &oldset);
if (-1 == ret)
{
perror("sigprocmask");
return 1;
}
printf("设置拼比编号为2的信号成功...\n");
printf("按下任意键解除编号为2的信号的阻塞...\n");
getchar();
//将信号屏蔽集设置为原来的集合
ret = sigprocmask(SIG_SETMASK, &oldset, NULL);
if (-1 == ret)
{
perror("sigprocmask");
return 1;
}
printf("按下任意键 退出...\n");
getchar();
return 0;
}
信号不支持排队,信号被丢失
sigprocmask函数
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<unistd.h>
#include<signal.h>
//显示信号集
void fun1(int signum)
{
printf("捕捉到信号:%d\n", signum);
}
void fun2(int signum)
{
printf("捕捉到信号:%d\n", signum);
}
int main(void)
{
int i = 0;
int ret = -1;
//信号集
sigset_t set;
sigset_t oldset;
//信号注册
//Ctrl + C
signal(SIGINT, fun1);
//Ctrl + \ akjfdskj
signal(SIGQUIT, fun2);
printf("按下任意键 阻塞信号2");
getchar();
sigemptyset(&oldset);
sigemptyset(&set);
sigaddset(&set, SIGINT);
sigaddset(&set, SIGQUIT);
//设置屏蔽编号为2的信号
ret = sigprocmask(SIG_BLOCK, &set, &oldset);
if (-1 == ret)
{
perror("sigprocmask");
return 1;
}
printf("设置拼比编号为2的信号成功...\n");
printf("按下任意键获取未决的信号的阻塞...\n");
getchar();
//获取未决的信号集
sigemptyset(&set);
ret = sigpending(&set);
if (-1 == ret)
{
perror("sigpending");
return 1;
}
//输出获取未决的信号
for (int i = 1; i < 32; i++)
{
if (sigismember(&set, i))
{
printf("%d", i);
}
}
putchar("\n");
///////////////////////////////////
printf("按下任意键解除编号为2的信号的阻塞...\n");
getchar();
//将信号屏蔽集设置为原来的集合
ret = sigprocmask(SIG_SETMASK, &oldset, NULL);
if (-1 == ret)
{
perror("sigprocmask");
return 1;
}
printf("按下任意键 退出...\n");
getchar();
return 0;
}
//信号集相关函数测试
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
//信号处理函数
void sighandler(int signo)
{
printf("signo==[%d]\n", signo);
}
int main()
{
//注册SIGINT和SIGQUIT的信号处理函数
signal(SIGINT, sighandler);
signal(SIGQUIT, sighandler);
//定义信号集变量
sigset_t set;
//初始化信号集
sigemptyset(&set);
//将SIGINT和SIGQUIT加入到set信号集中
sigaddset(&set, SIGINT);
sigaddset(&set, SIGQUIT);
//将set集合中的SIGINT和SIGQUIT信号加入到阻塞信号集中
sigprocmask(SIG_BLOCK, &set, NULL);
int i = 0;
int j = 1;
sigset_t pend;
while (1)
{
//获取未决信号集
sigemptyset(&pend);
sigpending(&pend);
for (i = 1; i < 32; i++)
{
//判断某个信号是否在集合中
if (sigismember(&pend, i) == 1)
{
printf("1");
}
else
{
printf("0");
}
}
printf("\n");
//循环10次从阻塞信号集中解除对SIGINT和SIGQUIT的阻塞
if (j++ % 10 == 0)
{
sigprocmask(SIG_UNBLOCK, &set, NULL);
}
else
{
sigprocmask(SIG_BLOCK, &set, NULL);
}
sleep(1);
}
return 0;
}
信号不支持排队
6.5信号捕捉函数
1.signal函数
2.sigaction函数
▶函数说明:
注册一个信号处理函数
▶函数原型:
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
▶函数参数:
▷signum:捕捉的信号
▷act: 传入参数,新的处理方式。
▷oldact: 传出参数,旧的处理方式
#include <signal.h>
int sigaction(int signum, const struct sigaction *act, struct sigaction *oldact);
功能:
检查或修改指定信号的设置(或同时执行这两种操作)。用捕捉到的信号signum注册act,act会对其进行处理
参数:
signum:要操作的信号。
act: 要设置的对信号的新处理方式(传入参数)。
oldact:原来对信号的处理方式(传出参数)。
如果 act 指针非空,则要改变指定信号的处理方式(设置),如果 oldact 指针非空,则系统将此前指定信号的处理方式存入 oldact。
返回值:
成功:0
失败:-1
struct sigaction {
void(*sa_handler)(int); //旧的信号处理函数指针
void(*sa_sigaction)(int, siginfo_t *, void *); //新的信号处理函数指针
sigset_t sa_mask; //信号阻塞集
int sa_flags; //信号处理的方式
void(*sa_restorer)(void); //已弃用
};
1) sa_handler、sa_sigaction:信号处理函数指针,和 signal() 里的函数指针用法一样,应根据情况给sa_sigaction、sa_handler 两者之一赋值,其取值如下:
a) SIG_IGN:忽略该信号
b) SIG_DFL:执行系统默认动作
c) 处理函数名:自定义信号处理函数
2) sa_mask:信号阻塞集,在信号处理函数执行过程中,临时屏蔽指定的信号。
3) sa_flags:用于指定信号处理的行为,通常设置为0,表使用默认属性。它可以是一下值的“按位或”组合:
Ø SA_RESTART:使被信号打断的系统调用自动重新发起(已经废弃)
Ø SA_NOCLDSTOP:使父进程在它的子进程暂停或继续运行时不会收到 SIGCHLD 信号。
Ø SA_NOCLDWAIT:使父进程在它的子进程退出时不会收到 SIGCHLD 信号,这时子进程如果退出也不会成为僵尸进程。
Ø SA_NODEFER:使对信号的屏蔽无效,即在信号处理函数执行期间仍能发出这个信号。
Ø SA_RESETHAND:信号处理之后重新设置为默认的处理方式。
Ø SA_SIGINFO:使用 sa_sigaction 成员而不是 sa_handler 作为信号处理函数。
例子:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<signal.h>
//显示信号集
void fun(int signo)
{
printf("捕捉到信号:%d\n", signo);
}
//演示sigaction函数使用
int main(void)
{
int ret = -1;
struct sigaction act;
//使用旧的信号处理函数指针
act.sa_handler = fun;
//标志为默认 默认使用旧的信号处理函数指针
act.sa_flags = 0;
//信号注册
ret = sigaction(SIGINT, &act, NULL);
/*
这行代码使用了`sigaction`函数来注册信号处理程序。让我们逐步解释这行代码的含义:
`ret`: 这是一个变量,用于存储`sigaction`函数的返回值。在后续的代码中,可以根据需要检查该值以确定函数调用是否成功。
`sigaction(SIGINT, &act, NULL)`: 这是`sigaction`函数的调用,它接受三个参数:
`SIGINT`: 这是一个信号常量,表示中断信号(通常由用户按下Ctrl+C触发)。在这里,我们将其用作第一个参数,表示我们希望注册的信号处理程序将针对这个信号。
`&act`: 这是一个指向`sigaction`结构体的指针,用于指定信号处理程序的行为。在这里,我们将第二个参数设置为`&act`,表示我们希望将当前的信号处理程序(即`act`)作为新的信号处理程序。
`NULL`: 这是一个指向`sa_mask`结构体的指针,用于指定信号处理程序的信号屏蔽字。在这里,我们将第三个参数设置为`NULL`,表示我们不希望对任何信号进行屏蔽。
综上所述,这行代码的作用是将当前的信号处理程序`act`注册为中断信号(SIGINT)的处理程序。
*/
if (-1 == ret)
{
perror("sigaction");
return 1;
}
printf("按下任意键退出......\n");
getchar();
return 0;
}
改一下:
while(1)
{
getchar();
}
改一下:
printf("按下任意键退出......\n");
getchar();
getchar();
//while(1)
//{
// getchar();
//}
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<signal.h>
//显示信号集
void fun(int signo)
{
printf("捕捉到信号:%d\n", signo);
}
void fun1(int signo, siginfo_t *info, void *context)
{
printf("捕捉到信号:%d\n", signo);
}
//演示sigaction函数使用
int main(void)
{
int ret = -1;
#if 0
struct sigaction act;
//使用旧的信号处理函数指针
act.sa_handler = fun;
//标志为默认 默认使用旧的信号处理函数指针
act.sa_flags = 0;
#else
struct sigaction act;
//使用新的信号处理函数指针
act.sa_sigaction = fun1;
//标志指定使用新的信号处理函数指针
act.sa_flags = SA_SIGINFO;
#endif
//信号注册
ret = sigaction(SIGINT, &act, NULL);
/*
这行代码使用了sigaction函数来注册信号处理程序。让我们逐步解释这行代码的含义:
ret: 这是一个变量,用于存储sigaction函数的返回值。在后续的代码中,可以根据需要检查该值以确定函数调用是否成功。
sigaction(SIGINT, &act, NULL): 这是sigaction`函数的调用,它接受三个参数:
SIGINT: 这是一个信号常量,表示中断信号(通常由用户按下Ctrl+C触发)。在这里,我们将其用作第一个参数,表示我们希望注册的信号处理程序将针对这个信号。
&act: 这是一个指向sigaction结构体的指针,用于指定信号处理程序的行为。在这里,我们将第二个参数设置为&act,表示我们希望将当前的信号处理程序(即act)作为新的信号处理程序。
NULL: 这是一个指向sa_mask结构体的指针,用于指定信号处理程序的信号屏蔽字。在这里,我们将第三个参数设置为NULL,表示我们不希望对任何信号进行屏蔽。
综上所述,这行代码的作用是将当前的信号处理程序`act`注册为中断信号(SIGINT)的处理程序。
*/
if (-1 == ret)
{
perror("sigaction");
return 1;
}
printf("按下任意键退出......\n");
getchar();
return 0;
}
//信号集相关函数测试
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
//信号处理函数
void sighandler(int signo)
{
printf("signo==[%d]\n", signo);
}
int main()
{
//注册SIGINT和SIGQUIT的信号处理函数
signal(SIGINT, sighandler);
signal(SIGQUIT, sighandler);
//定义信号集变量
sigset_t set;
sigset_t oldset;
//初始化信号集
sigemptyset(&set);
sigemptyset(&oldset);
//将SIGINT和SIGQUIT加入到set信号集中
sigaddset(&set, SIGINT);
sigaddset(&set, SIGQUIT);
//将set集合中的SIGINT和SIGQUIT信号加入到阻塞信号集中
//sigprocmask(SIG_BLOCK, &set, NULL);
sigprocmask(SIG_BLOCK, &set, &oldset);
int i = 0;
int j = 1;
sigset_t pend;
while (1)
{
//获取未决信号集
sigemptyset(&pend);
sigpending(&pend);
for (i = 1; i < 32; i++)
{
//判断某个信号是否在集合中
if (sigismember(&pend, i) == 1)
{
printf("1");
}
else
{
printf("0");
}
}
printf("\n");
//循环10次从阻塞信号集中解除对SIGINT和SIGQUIT的阻塞
if (j++ % 10 == 0)
{
//sigprocmask(SIG_UNBLOCK, &set, NULL);
sigprocmask(SIG_SETMASK, &oldset, NULL);
}
else
{
sigprocmask(SIG_BLOCK, &set, NULL);
}
sleep(1);
}
return 0;
}
3.总结:
▶sa_handler:指定信号捕捉后的处理函数名(即注册函数)。也可赋值为SIG_IGN表忽略 或 SIG_DFL表执行默认动作
▶sa_mask: 用来指定在信号处理函数执行期间需要被屏蔽的信号,特别是当某个信号被处理时,它自身会被自动放入进程的信号掩码,因此在信号处理函数执行期间这个信号不会再度发生。注意:仅在处理函数被调用期间屏蔽生效,是临时性设置。
▶sa_flags:通常设置为0,使用默认属性。
▶sa_restorer:已不再使用
▶练习:编写程序,使用sigaction函数注册信号捕捉函数,并使用这个程序验证信号是否支持排队。
▶知识点: 信号处理不支持排队:
▷在XXX信号处理函数执行期间,XXX信号是被阻塞的, 如果该信号产生了多次,在XXX信号处理函数结束之后,该XXX信号只被处理一次。
▷在XXX信号处理函数执行期间,如果阻塞了YYY信号,若YYY信号产生了多次, 当XXX信号处理函数结束后,YYY信号只会被处理一次。
▶内核实现信号捕捉的过程
如果信号的处理动作是用户自定义函数,在信号递达时就调用这个函数,这称为捕捉信号。由于信号处理函数的代码是在用户空间的,处理过程比较复杂,举例如下:
1.用户程序注册了SIGQUIT信号的处理函数sighandler。
2.当前正在执行main函数,这时发生中断或异常切换到内核态。
3.在中断处理完毕后要返回用户态的main函数之前检查到有信号SIGQUIT递达。
4.内核决定返回用户态后不是恢复main函数的上下文继续执行,而是执行sighandler函数,sighandler和main函数使用不同的堆栈空间,它们之间不存在调用和被调用的关系,是两个独立的控制流程。
5.sighandler函数返回后自动执行特殊的系统调用sigreturn再次进入内核态。
6.如果没有新的信号要递达,这次再返回用户态就是恢复main函数的上下文继续执行了。
6.6SIGCHLD信号
1.产生SIGCHLD信号的条件
-
子进程结束的时候
-
子进程收到SIGSTOP信号
-
当子进程停止时,收到SIGCONT信号
2.SIGCHLD信号的作用
子进程退出后,内核会给它的父进程发送SIGCHLD信号,父进程收到这个信号后可以对子进程进行回收。
使用SIGCHLD信号完成对子进程的回收可以避免父进程阻塞等待而不能执行其他操作,只有当父进程收到SIGCHLD信号之后才去调用信号捕捉函数完成对子进程的回收,未收到SIGCHLD信号之前可以处理其他操作。
//父进程使用SICCHLD信号完成对子进程的回收
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
void sighandler(int signo)
{
printf("signo==[%d]\n", signo);
}
int main()
{
pid_t pid;
int i = 0;
pid = fork();
pid_t pid = fork();
if (pid < 0) //fork失败的情况
{
perror("fork error");
return -1;
}
else if (pid > 0) //父进程
{
printf("father: fpid==[%d], cpid==[%d]\n", getpid(), pid);
while (1)
{
sleep(1);
}
}
else if (pid == 0) //子进程
{
printf("child: fpid==[%d], cpid==[%d]\n", getppid(), getpid());
while (1)
{
sleep(1);
}
}
return 0;
}
//父进程使用SICCHLD信号完成对子进程的回收
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
void sighandler(int signo)
{
printf("signo==[%d]\n", signo);
}
int main()
{
pid_t pid;
int i = 0;
signal(SIGCHLD, sighandler);
pid = fork();
pid_t pid = fork();
if (pid < 0) //fork失败的情况
{
perror("fork error");
return -1;
}
else if (pid > 0) //父进程
{
printf("father: fpid==[%d], cpid==[%d]\n", getpid(), pid);
while (1)
{
sleep(1);
}
}
else if (pid == 0) //子进程
{
printf("child: fpid==[%d], cpid==[%d]\n", getppid(), getpid());
while (1)
{
sleep(1);
}
}
return 0;
}
//父进程使用SICCHLD信号完成对子进程的回收
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <signal.h>
#include <sys/wait.h>
void waitchild(int signo)
{
pid_t wpid = waitpid(-1, NULL, WNOHANG);
if(wpid>0)
{
printf("child is quit, wpid==[%d]\n", wpid);
}
else if(wpid==0)
{
printf("child is living, wpid==[%d]\n", wpid);
}
else if(wpid==-1)
{
printf("no child is living, wpid==[%d]\n", wpid);
}
}
int main()
{
pid_t pid;
int i = 0;
for(i=0; i<3; i++)
{
//fork子进程
pid = fork();
if(pid<0) //fork失败的情况
{
perror("fork error");
return -1;
}
else if(pid>0) //父进程
{
printf("father: fpid==[%d], cpid==[%d]\n", getpid(), pid);
}
else if(pid==0) //子进程
{
printf("child: fpid==[%d], cpid==[%d]\n", getppid(), getpid());
break;
}
}
if(i==0)
{
printf("[%d]:child: cpid==[%d]\n", i, getpid());
sleep(1);
}
if(i==1)
{
printf("[%d]:child: cpid==[%d]\n", i, getpid());
sleep(2);
}
if(i==2)
{
printf("[%d]:child: cpid==[%d]\n", i, getpid());
sleep(3);
}
//父进程
if (i == 3)
{
printf("the father: pid==[%d]\n", getpid());
//signal(SIGCHLD, waitchild);
//注册SIGCHLD信号处理函数
struct sigaction act;
act.sa_handler = waitchild;
sigemptyset(&act.sa_mask);
act.sa_flags = 0;
sigaction(SIGCHLD, &act, NULL);
while (1)
{
sleep(1);
}
}
return 0;
}
3个sleep同时注释掉
6.SOCKET编程
传统的进程间通信借助内核提供的IPC机制进行,但是只能限于本机通信,若要跨机通信,就必须使用网络通信。( 本质上借助内核-内核提供了socket伪文件的机制实现通信----实际上是使用文件描述符),这就需要用到内核提供给用户的socket API函数库。
既然提到socket伪文件,所以可以使用文件描述符相关的函数read write
可以对比pipe管道讲述socket文件描述符的区别。
使用socket会建立一个socket pair。
如下图,一个文件描述符操作两个缓冲区,这点跟管道是不同的,管道是两个文件描述符操作一个内核缓冲区。
6.1socket编程预备知识
- 网络字节序:
▶大端和小端的概念
▷大端: 低位地址存放高位数据,高位地址存放低位数据
▷小端: 低位地址存放低位数据,高位地址存放高位数据
- 大端和小端的使用使用场合???
大端和小端只是对数据类型长度是两个及以上的,如int short,对于单字节没限制,在网络中经常需要考虑大端和小端的是IP和端口。
思考题: 0x12345678如何存放???
如何验证本机上大端还是小端??-----使用共用体。
编写代码endian.c进行测试, 测试本机上是大端模式还是小端模式?
网络传输用的是大端法,如果机器用的是小端法,则需要进行大小端的转换。
下面4个函数就是进行大小端转换的函数:
#include <arpa/inet.h>
uint32_t htonl(uint32_t hostlong);
uint16_t htons(uint16_t hostshort);
uint32_t ntohl(uint32_t netlong);
uint16_t ntohs(uint16_t netshort);
函数名的h表示主机host,n表示网络network,s表示short,l表示long
上述的几个函数,如果本来不需要转换函数内部就不会做转换。
#include <stdio.h>
#include <stdlib.h>
union
{
short s;
char c[sizeof(short)];
}un2;
union
{
int s;
char c[sizeof(int)];
}un4;
int main()
{
printf("[%d][%d][%ld]\n", sizeof(short), sizeof(int), sizeof(long int));
//测试short
un2.s = 0x0102;//0x0102 = ? 16*16+2
printf("%d, %d, %d\n", un2.c[0], un2.c[1], un2.s);
//测试int类型
//un4.s = 0x12345678;
un4.s = 0x01020304;
printf("%d, %d, %d, %d, %d\n", un4.c[0], un4.c[1], un4.c[2], un4.c[3], un4.s);
return 0;
}
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day10$ gcc endian.c
endian.c: In function ‘main’:
endian.c:18:19: warning: format ‘%d’ expects argument of type ‘int’, but argument 2 has type ‘long unsigned int’ [-Wformat=]
18 | printf("[%d][%d][%ld]\n", sizeof(short), sizeof(int), sizeof(long int));
| ~^ ~~~~~~~~~~~~~
| | |
| int long unsigned int
| %ld
endian.c:18:23: warning: format ‘%d’ expects argument of type ‘int’, but argument 3 has type ‘long unsigned int’ [-Wformat=]
18 | printf("[%d][%d][%ld]\n", sizeof(short), sizeof(int), sizeof(long int));
| ~^ ~~~~~~~~~~~
| | |
| int long unsigned int
| %ld
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day10$ ./a.out
[2][4][8]
2, 1, 258
4, 3, 2, 1, 16909060
低端字节序
- IP地址转换函数:
p->表示点分十进制的字符串形式
to->到
n->表示network网络
int inet_pton(int af, const char *src, void *dst);
▶函数说明: 将字符串形式的点分十进制IP转换为大端模式的网络IP(整形4字节数)
▶参数说明:
af: AF_INET
AF_INET
表示IPV4,AF_INET6
表示IPV6
src: 字符串形式的点分十进制的IP地址
dst: 存放转换后的变量的地址
例如: inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr.s_addr);
手工也可以计算:如192.168.232.145,先将4个正数分别转换为16进制数,
192--->0xC0 168--->0xA8 232--->0xE8 145--->0x91,
最后按照大端字节序存放: 0x91E8A8C0,这个就是4字节的整型值。
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
函数说明:网络IP转换为字符串形式的点分十进制的IP
参数说明:
af:AF_INET
AF_INET
表示IPV4,AF_INET6
表示IPV6
src:网络的整形的IP地址
dst:转换后的IP地址,一般为字符串数组
size:dst的长度
返回值:
成功--返回指向dst的指针
失败--返回NULL,并设置errno
例如:IP地址为010aa8c0,转换为点分十进制的格式:
01---->1 0a---->10 a8---->168 c0---->192
由于从网络中的IP地址是高端模式,所以转换为点分十进制后应该为:192.168.10.1
socket编程用到的重要的结构体:struct sockaddr
struct sockaddr14个字节中2个字节存放端口号、6个字节存放IP地址、8个字节空闲
struct sockaddr结构说明:
struct sockaddr
{
sa_family_t sa_family;
char sa_data[14];
}
这是一个描述网络编程中地址结构的C语言数据结构,通常用于套接字编程。struct sockaddr
定义了通用的套接字地址结构,它的目的是能够表示不同类型的套接字地址,如IPv4地址、IPv6地址等。
结构体成员包括:
-
sa_family_t sa_family
:这是一个表示地址家族(Address Family)的成员,用于指示地址的类型,例如AF_INET表示IPv4地址家族,AF_INET6表示IPv6地址家族,AF_UNIX表示本地套接字等。这个成员确定了sa_data
字段的解释方式。 -
char sa_data[14]
:这个字段是一个长度为14字节的字符数组,通常用于存储具体的地址信息,但其解释方式依赖于sa_family
字段。不同的地址家族可能会在这里存储不同类型的信息。例如,在IPv4地址家族中,通常存储IPv4地址和端口号。
struct sockaddr
提供了一种通用的方式来表示不同类型的套接字地址,这对于网络编程非常有用,因为你可以在不同的地址家族之间切换而不需要改变代码中的地址处理逻辑。在实际编程中,通常会使用类型转换将 struct sockaddr
转换为更具体的地址结构,如 struct sockaddr_in
(IPv4地址结构)或 struct sockaddr_in6
(IPv6地址结构),以便更方便地操作地址信息。
这个结构体是用于底层网络编程中的基本数据结构,一般情况下,应用程序开发者在网络编程中不需要直接操作它,而是使用更高层次的库或API来处理套接字地址。
struct sockaddr_in结构:
struct sockaddr_in
{
sa_family_t sin_family; /* address family: AF_INET */
in_port_t sin_port; /* port in network byte order */
struct in_addr sin_addr; /* internet address */
};
struct sockaddr_in
是用于表示IPv4地址的C语言数据结构,通常在网络编程中用于套接字地址的表示。它包含以下成员:
-
sa_family_t sin_family
:这是一个表示地址家族(Address Family)的成员,通常被设置为AF_INET
,指示这是一个IPv4地址。 -
in_port_t sin_port
:这是一个16位整数,用于表示端口号。端口号以网络字节顺序(big-endian)表示,这是网络编程中的一种标准格式。 -
struct in_addr sin_addr
:这是一个结构体,用于表示IPv4地址。它包含一个32位的IPv4地址,以二进制形式存储。in_addr
结构体的定义如下:struct in_addr { in_addr_t s_addr; /* 32-bit IPv4 address */ };
s_addr
成员包含了32位的IPv4地址。
struct sockaddr_in
主要用于表示IPv4套接字地址,其中包括IP地址和端口号。这在套接字编程中非常常见,因为IPv4是互联网上广泛使用的地址家族。在使用 struct sockaddr_in
表示的套接字地址中,sin_family
字段指示了地址家族(AF_INET),sin_port
表示了端口号,而 sin_addr
表示了IPv4地址。
通常,开发人员使用这个结构体来初始化套接字并指定其地址,以便在网络通信中使用。然后,可以将这个结构体作为参数传递给各种套接字函数来建立连接、监听端口等。这个结构体在网络编程中起着关键作用,使得程序能够指定通信目标的IP地址和端口。
/* Internet address. */
struct in_addr
{
uint32_t s_addr; /* address in network byte order */
}; //网络字节序IP--大端模式
通过man 7 ip可以查看相关说明
6.2socket编程主要的API函数介绍
int socket(int domain, int type, int protocol);
▶函数描述: 创建socket
▶参数说明:
domain: 协议版本
AF_INET IPV4
AF_INET6 IPV6
AF_UNIX AF_LOCAL本地套接字使用
▶type:协议类型
SOCK_STREAM 流式,默认使用的协议是TCP协议
SOCK_DGRAM 报式,默认使用的是UDP协议
▶protocal:
一般填0,表示使用对应类型的默认协议。
▶返回值:
成功:返回一个大于0的文件描述符
失败:返回-1,并设置errno
当调用socket函数以后,返回一个文件描述符,内核会提供与该文件描述符相对应的读和写缓冲区,同时还有两个队列,分别是请求连接队列和已连接队列。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
▶函数描述: 将socket文件描述符 和IP、PORT绑定
▶参数说明:
sockfd: 调用socket函数返回的文件描述符
addr: 本地服务器的IP地址和PORT,
struct sockaddr_in serv;
serv.sin_family = AF_INET;
serv.sin_port = htons(8888);//转换成高端字节序
//serv.sin_addr.s_addr = htonl(INADDR_ANY);
//INADDR_ANY: 表示使用本机任意有效的可用IP
inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr.s_addr);
addrlen: addr变量的占用的内存大小
▶返回值:
成功: 返回0
失败: 返回-1,并设置errno
服务端提供服务需要向外提供一个固定端口(一般不想允许随便动)
这是 bind()
函数的声明,它是 UNIX 和类 UNIX 系统(例如 Linux)中的套接字 API 的一部分。这个函数为一个套接字分配(或“绑定”)一个本地地址,通常用在服务器端的程序中,以便该套接字可以在指定的地址和端口上接收客户端的连接请求。
以下是这个函数参数的详细解析:
-
sockfd:
- 这是一个整数值,代表要绑定的套接字的文件描述符。这个描述符通常由之前的
socket()
函数调用返回。
- 这是一个整数值,代表要绑定的套接字的文件描述符。这个描述符通常由之前的
-
addr:
- 这是一个指向结构体
struct sockaddr
的指针,但实际上,你通常会使用一个特定于地址类型的结构(如struct sockaddr_in
用于 IPv4)并进行类型转换。 - 这个结构体保存了你希望绑定到的本地地址和端口号。
- 这是一个指向结构体
-
addrlen:
- 这是一个值,指定
addr
结构体的大小。例如,如果addr
指向一个struct sockaddr_in
的实例,则addrlen
应该被设置为sizeof(struct sockaddr_in)
。
- 这是一个值,指定
该函数的返回值:如果成功,bind()
返回0。如果失败,它返回 -1,并在全局变量 errno
中设置一个错误代码,以指示发生了什么错误。
以下是一个简单的示例,展示如何使用 bind()
函数为一个流套接字(TCP)绑定一个IPv4地址和端口:
#include <sys/socket.h>
#include <netinet/in.h>
#include <stdio.h>
int main()
{
int sockfd;
struct sockaddr_in serv_addr;
// 创建套接字
sockfd = socket(AF_INET, SOCK_STREAM, 0);
if (sockfd < 0)
{
perror("socket error");
return 1;
}
// 设置地址结构体
serv_addr.sin_family = AF_INET;
serv_addr.sin_addr.s_addr = htonl(INADDR_ANY); // 绑定到任意地址
serv_addr.sin_port = htons(8080); // 使用端口8080
// 绑定套接字
if (bind(sockfd, (struct sockaddr *) &serv_addr, sizeof(serv_addr)) < 0)
{
perror("bind error");
return 1;
}
// ... 其他代码,例如开始监听连接等
return 0;
}
上述示例中,服务器创建一个套接字,并将其绑定到所有可用的 IPv4 地址(通过 INADDR_ANY
)和端口 8080 上。
int listen(int sockfd, int backlog);
▶函数描述: 将套接字由主动态变为被动态
▶参数说明:
sockfd: 调用socket函数返回的文件描述符
backlog: 同时请求连接的最大个数(还未建立连接) (填一个大于0的数)
▶返回值:
成功: 返回0
失败: 返回-1,并设置errno
listen函数仅由TCP服务器调用
它做两件事:
1.当socket函数创建一个套接字时,它被假设为一个主动套接字,也就是说,它是一个将调用connect发起连接的客户套接字。listen函数把一个未连接的套接字转换为一个被动套接字,指示内核应该接受指向该套接字的连接请求。根据TCP状态转换图,调用listen导致套接字从CLOSED状态转换到LISTEN状态。
2.listen函数的第二个参数规定了内核应该为相应套接字排队的最大连接个数:
#include<sys/socket.h>
int listen(int sockfd, int backlog);
返回:若成功则为0,若出错则为-1
为了理解其中的backlog参数,我们必须认识到内核为任何一个给定的监听套接字维护两个队列:
(1)未完成连接队列,每个这样的SYN分节对应其中一项:已由某个客户发出并到达服务器,而服务器正在等待完成相应的TCP三路握手过程。这些套接字处于SYN_RECV状态
(2)已完成连接队列,每个已完成TCP三路握手过程的客户对应其中一项。这些套接字处于ESTABLISHED状态。
下图描绘了监听套接字的两个队列
每当在未完成连接队列中创建一项时,来自监听套接字的参数就复制到即将建立的连接中,连接的创建机制是完全自动的。无需服务器进程插手。下图展示了用这两个队列建立连接时所
交换的分组:
当来自客户的SYN到达时,TCP在未完成连接队列中创建一个新项,然后响应以三路握手的第二个分节:服务器的SYN响应,其中捎带对客户SYN的ACK。这一项一直保留在未完成连接队列中,直到三路握手的第三个分节(客户对服务器的SYN的ACK)到达或者该项超时为止。
如果三路握手正常完成,该项从未完成连接队列移到已完成连接队列的队尾。当进程调用accept时,已完成连接队列中的队头项将返回给进程,或者该队列为空,那么进程就被投入睡眠,直到TCP在该队列中放入一项才唤醒它。
总结:
1.accept()函数不参与三次握手,而只负责从建立连接队列中取出一个连接和socketfd进行绑定;
2.backlog参数决定了未完成队列和已完成队列中连接数目之和的最大值(从内核的角度,是否这个和就是等于sock->recv_queue ?);
3.accept()函数调用,会从已连接队列中取出一个“连接”(可以说描述的数据结构listensocket->sock->recv_queue[sk_buff] ? ),未完成队列和已完成队列中连接目之和将减少1;即accept将监听套接字对应的sock的接收队列中的已建立连接的sk_buff取下(从该sk_buff中可以获得对端主机的发送过来的tcp/ip数据包)
4.监听套接字的已完成队列中的元素个数大于0,那么该套接字是可读的。
5.当程序调用accept的时候(设置阻塞参数),那么判定该套接字是否可读,不可读则进入睡眠,直至已完成队列中的元素个数大于0(监听套接字可读)而唤起监听进程)
TCP(传输控制协议)的三次握手是TCP建立连接的过程,其中的seq
序号是其中的一个关键部分。seq
代表“序列号”(sequence number)。
seq
序号是什么?
在TCP中,每一个字节都有一个序列号。序列号的主要作用是为了确保TCP的数据流是有序的,并且确保接收端能够正确地组装数据流,即使数据包是无序或重复到达的。在TCP三次握手中,seq
序号用于标识这个连接的初始序列号。
seq
序号的作用是什么?
-
确保数据完整性和顺序:通过检查序列号,接收方可以确定数据是否按预期到达,是否有数据缺失,或是否需要重新组装到正确的顺序。
-
避免重复:如果接收方收到了具有相同序列号的两个数据包,它会知道其中一个是重复的,并将其丢弃。
-
流量控制和拥塞控制:序列号允许接收方告诉发送方它已经收到了哪些数据,因此发送方知道需要重新传输哪些数据,或者可以继续发送更多的数据。
-
建立和维护连接:在TCP的三次握手中,序列号用于确保两端都同意并了解连接的起始状态。在握手的步骤中,每一方都发送其初始的序列号,通常是一个随机生成的数字。
具体到三次握手:
- 第一步:客户端发送SYN包并设置一个初始的
seq
序号,例如x
。 - 第二步:服务器收到SYN包,返回SYN-ACK包,其中ACK的确认号是
x+1
,并设置其自己的初始seq
序号,例如y
。 - 第三步:客户端收到SYN-ACK包,返回ACK包,并将确认号设置为
y+1
。
通过这个过程,客户端和服务器都知道对方的初始序列号,并准备开始数据传输。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
▶函数说明:获得一个连接,若当前没有连接则会阻塞等待。
▶函数参数:
sockfd:调用socket函数返回的文件描述符
addr:传出参数,保存客户端的地址信息
addrlen:传入传出参数,addr变量所占内存空间大小
▶返回值:
成功:返回一个新的文件描述符,用于和客户端通信
失败:返回-1,并设置errno值。
accept函数是一个阻塞函数,若没有新的连接请求,则一直阻塞。
从已连接队列中获取一个新的连接,并获得一个新的文件描述符,该文件描述符用于和客户端通信。 (内核会负责将请求队列中的连接拿到已连接队列中)
既然服务端已经很虔诚了,很真诚了,处于倾听状态,那么该是去尝试接受客户端请求的时候了,别只顾着倾听,不去接纳别人。
接纳客户端请求的函数是accept, 我们先来看看函数的原型:
WINSOCK_API_LINKAGE
SOCKET
WSAAPI
accept(
SOCKET s,
struct sockaddr FAR * addr,
int FAR * addrlen
);
函数的第一个参数用来标识服务端套接字(也就是listen函数中设置为监听状态的套接字),第二个参数是用来保存客户端套接字对应的“地方”(包括客户端IP和端口信息等), 第三个参数是“地方”的占地大小。返回值对应客户端套接字标识。
实际上是这样的: accept函数指定服务端去接受客户端的连接,接收后,返回了客户端套接字的标识,且获得了客户端套接字的“地方”(包括客户端IP和端口信息等)。
accept函数非常地痴情,痴心不改:
如果没有客户端套接字去请求,它便会在那里一直痴痴地等下去,直到永远(注意, 此处讨论的是阻塞式的socket. 如果是非阻塞式的socket, 那么accept函数就没那么痴情了, 而是会立即返回, 并意犹未尽地对未来的客户端扔下一句话: 我等了你, 你不来, 那就算了, 我懒得鸟你)。
来看看accpt函数的用法:
unsigned int sockConn = accept(sockSrv,(SOCKADDR*)&addrClient, &len);
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
▶函数说明: 连接服务器
▶函数参数:
sockfd:调用socket函数返回的文件描述符
addr:服务端的地址信息
addrlen:addr变量的内存大小
▶返回值:
成功:返回0
失败:返回-1,并设置errno值
接下来就可以使用write和read函数进行读写操作了。
除了使用read/write函数以外,还可以使用recv和send函数
- 读取数据和发送数据:
ssize_t read(int fd, void *buf, size_t count);
ssize_t write(int fd, const void *buf, size_t count);
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
对应recv和send这两个函数flags直接填0就可以了。
注意:如果写缓冲区已满,write也会阻塞,read读操作的时候,若读缓冲区没有数据会引起阻塞。
使用socket的API函数编写服务端和客户端程序的步骤图示:
服务端开发流程:
1 创建socket,返回一个文件描述符lfd---socket()
--该文件描述符用于监听客户端连接
2 将lfd和IP PORT进行绑定----bind()
3 将lfd由主动变为被动监听----listen()
4 接受一个新的连接,得到一个文件描述符cfd----accept()
---该文件描述符是用于和客户端进行通信的
5 while(1)
{
接收数据---read或者recv
发送数据---write或者send
}
6 关闭文件描述符----close(lfd) close(cfd);
服务端开发流程中将lfd和IP PORT进行绑定做什么?
在服务端开发中,将套接字描述符(lfd,listening socket)与特定的IP地址和端口进行绑定是为了将该套接字绑定到服务端的特定地址和端口上,以便服务端能够监听来自客户端的连接请求。
具体作用包括:
-
指定服务端的地址:通过将lfd与特定的IP地址绑定,可以确定服务端监听的网络接口。这是非常重要的,因为服务端可以拥有多个网络接口,每个接口对应一个IP地址,而绑定指定的IP地址可以确保服务端监听在预期的网络接口上。
-
指定服务端的端口:通过将lfd与特定的端口绑定,可以确保服务端监听在指定的端口上。这是关键的,因为客户端需要知道服务端监听的端口以建立连接。
-
允许多个服务端运行在同一台主机上:通过绑定不同的IP地址和端口,可以在同一台主机上运行多个服务端程序,每个程序监听不同的地址和端口。这使得多个服务可以并行运行,而不会发生冲突。
-
限定连接源:通过绑定特定的IP地址,服务端可以限制只接受来自特定源IP地址的连接请求,从而增强安全性。
总之,绑定lfd到特定的IP地址和端口是服务端建立网络连接的第一步,它确定了服务端监听的网络位置,以便客户端能够找到并建立连接。
客户端的开发流程:
1 创建socket,返回一个文件描述符cfd---socket()
---该文件描述符是用于和服务端通信
2 连接服务端---connect()
3 while(1)
{
//发送数据---write或者send
//接收数据---read或者recv
}
4 close(cfd)
根据服务端和客户端编写代码的流程,编写代码并进行测试。
测试过程中可以使用netstat命令查看监听状态和连接状态
netstat命令:
a表示显示所有,
n表示显示的时候以数字的方式来显示
p表示显示进程信息(进程名和进程PID)
作业:
自己编写代码熟悉一下服务端和客户端的代码开发流程;
设计服务端和客户端通信协议(属于业务层的协议)
如发送结构体
typedef struct teacher_
{
int tid;
char name[30];
int age;
char sex[30];
int sal;
} teacher;
typedef struct student_
{
int sid;
char name[30];
int age;
char sex[30];
}student;
typedef struct SendMsg_
{
int type;//1 - teacher;2 - student
int len;//
char buf[0];//变长发送数据
}SendMsg;
例子:
01-sever.c
//服务端程序
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>
int main()
{
//创建socket
//int socket(int domain, int type, int protocol);
int lfd = socket(AFINET, SOCK_STREAM, 0);
if(lfd < 0)
{
perror("socket error");
return -1;
}
//int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
//绑定
struct sockaddr_in serv;
serv.sin_family = AF_INET;
serv.sin_port = htos(8888);
serv.sin_addr.s_addr = htol(INADDR_ANY);
int ret = bind(lfd, (struct sockaddr *)&serv, sizeof(serv));
if(ret < 0)
{
perror("bind error");
return -1;
}
//监听
//int listen(int sockfd, int backlog);
listen(lfd, 128);
//int accept(int sockfd, struct sockaddr *_Nullable restrict addr,
// socklen_t *_Nullable restrict addrlen);
int cfd = accept(lfd, NULL, NULL);
printf("lfd == [%d], cfd == [%d]\n", lfd, cfd);
int i = 0;
int n = 0;
char buf[1024];
while(1)
{
//读数据
memset(buf, 0x00, sizeof(buf));
n = read(cfd, buf, sizeof(buf));
for(i = 0; i < n; i++)
{
buf[i] = toupper(buf[i]);
}
//发送数据
write(cfd, buf, n);
}
//关闭监听文件描述符和通信文件描述符
close(lfd);
close(cfd);
return 0;
}
先启动终端1再启动终端2
终端1:
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day10$ gcc -o server 01-server.c
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day10$ ./server
lfd == [3], cfd == [4]
n==[6], buf==[nihao
]
n==[5], buf==[fasf
]
n==[6], buf==[fasdf
]
n==[5], buf==[sdfa
]
n==[5], buf==[asdf
]
n==[5], buf==[asdf
]
n==[5], buf==[asdf
]
终端2:
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day10$ nc 127.0.0.1 8888
nihao
NIHAO
fasf
FASF
fasdf
FASDF
sdfa
SDFA
asdf
ASDF
asdf
ASDF
asdf
ASDF
关闭终端2,终端1进入死循环
这是一个基于TCP的服务器程序,使用了套接字API来实现。下面是对该代码的详细解析:
1.包含头文件:
- 各种系统头文件被引入以支持套接字编程和相关操作。
2.main函数开始:
3.创建套接字:
int lfd = socket(AF_INET, SOCK_STREAM, 0);
使用socket
函数创建一个IPv4(AF_INET)的TCP套接字(SOCK_STREAM)。
4.检查套接字创建是否成功:
如果lfd
的值小于0,表示套接字创建失败,然后程序会打印错误并退出。
5.绑定:
struct sockaddr_in serv;
bzero(&serv, sizeof(serv));
serv.sin_family = AF_INET;
serv.sin_port = htons(8888);
serv.sin_addr.s_addr = htonl(INADDR_ANY);//表示使用本地任意可用IP
int ret = bind(lfd, (struct sockaddr*)&serv, sizeof(serv));
使用bind
函数绑定套接字lfd
到指定的地址和端口。
- 使用
struct sockaddr_in
来指定地址和端口。 - IP地址设置为
INADDR_ANY
,这意味着服务器可以在任何网络接口上接收客户端连接。 - 端口号设置为8888。
struct sockaddr
是一个通用的套接字地址结构体,而 struct sockaddr_in
是一个与IP专门相关的地址结构体。
代码使用 TCP/IP 协议族,所以需要特定的结构体来存储IP地址和端口号。这就是 struct sockaddr_in。但是,一些socket函数(如bind、accept)的参数设计时考虑到了通用性,所以它们接收一个指向 struct sockaddr的指针。
总结以下几点:
1. 为什么定义了`sockaddr_in:因为它是专门为IPv4设计的。它包含了IP地址和端口号。
2. 为什么函数参数使用sockaddr:这是为了通用性。在socket编程中,还有其他类型的地址结构体,例如sockaddr_in6(用于IPv6)和 sockaddr_un(用于Unix domain sockets)。使用通用的sockaddr可以确保这些函数与不同的地址结构体兼容。
3. 类型转换:当我们使用这些函数时,会将 sockaddr_in 或其他特定的地址结构体的指针转换为 `sockaddr` 指针。这就是为什么你经常在代码中看到(struct sockaddr*)&serv这样的类型转换。
因此,你的代码中使用sockaddr_in是因为你正在使用IP协议族,而函数参数中使用sockaddr是为了保持函数接口的通用性。
在这段代码中,8888
是用于服务器套接字的端口号。
在TCP/IP网络中,端口号是用于区分同一台计算机上不同应用程序或进程的网络通信的方式。端口号是一个16位的数字,范围从0到65535。其中,0到1023的端口被定义为“众所周知的端口”(Well-Known Ports),通常用于标准服务。例如,HTTP使用端口80,HTTPS使用端口443等。
在这个服务器程序中,选择了8888
作为监听端口,意味着该服务器将等待客户端在该端口上发起连接请求。当客户端尝试连接到服务器的IP地址上的8888端口时,这个服务器程序就可以接受该连接。
serv.sin_port = htons(8888);
这里的htons
函数是用来将端口号从主机字节序转换为网络字节序。这是网络编程中的常见做法,因为不同的计算机架构可能有不同的字节序(大端和小端)。htons
函数确保端口号在所有平台上都被正确解释。
bzero(&serv, sizeof(serv));
是一个用于清零内存区域的函数调用,通常用于初始化数据结构,特别是在网络编程中常用于清零套接字地址结构。
具体作用如下:
①清零内存区域:bzero
函数会将指定内存区域(在这里是 &serv
所指向的内存区域)的所有字节都设置为0。它通过将每个字节都设置为0来实现清零。
②初始化数据结构:在网络编程中,bzero
常常用于初始化套接字地址结构,例如 struct sockaddr_in
。在这种情况下,通过调用 bzero
可以将整个地址结构的字节都清零,以确保不会包含任何垃圾数据。
③避免数据泄漏:在敏感的上下文中,清零内存区域可以帮助防止数据泄漏。如果内存区域中包含敏感数据,使用 bzero
清零可以确保数据不会留在内存中,从而提高安全性。
总之,bzero(&serv, sizeof(serv));
的作用是将套接字地址结构 serv
的内存区域清零,以便用于初始化和确保内存中不包含垃圾数据。这是在网络编程和其他领域中常见的一种初始化操作。
6.开始监听:
listen(lfd, 128);
使用listen
函数,该函数使得lfd
套接字变为被动套接字,等待客户端的连接请求。128
是待处理连接的队列长度。
7.接受客户端连接:
使用accept
函数等待并接受客户端的连接。这会阻塞,直到有一个客户端连接。
8.显示客户端信息:
使用inet_ntop
函数将客户端的IP地址从二进制转换为点分十进制格式,并显示其IP和端口。
9.数据交互:
- 服务器进入一个无限循环,不断地读取客户端发送的数据。
- 读取的数据存储在
buf
中。 - 如果读取失败或客户端关闭连接,服务器将打印消息并跳出循环。
- 读取到的数据被转换为大写。
- 转换后的数据会发送回客户端。
10.关闭连接:
使用close
函数关闭与客户端的连接(cfd
)和监听的文件描述符(lfd
)。
总之,这是一个简单的TCP回显服务器,它接收客户端发送的数据,将其转换为大写,然后将其发送回客户端。
代码改进如下:
//服务端程序
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>
int main()
{
//创建socket
//int socket(int domain, int type, int protocol);
int lfd = socket(AF_INET, SOCK_STREAM, 0);
if(lfd < 0)
{
perror("socket error");
return -1;
}
//绑定
//int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
struct sockaddr_in serv;
serv.sin_family = AF_INET;
serv.sin_port = htons(8888);
serv.sin_addr.s_addr = htonl(INADDR_ANY);
int ret = bind(lfd, (struct sockaddr *)&serv, sizeof(serv));
if(ret < 0)
{
perror("bind error");
return -1;
}
//监听
//int listen(int sockfd, int backlog);
listen(lfd, 128);
//int accept(int sockfd, struct sockaddr *_Nullable restrict addr,
// socklen_t *_Nullable restrict addrlen);
int cfd = accept(lfd, NULL, NULL);
printf("lfd == [%d], cfd == [%d]\n", lfd, cfd);
int i = 0;
int n = 0;
char buf[1024];
while(1)
{
//读数据
memset(buf, 0x00, sizeof(buf));
n = read(cfd, buf, sizeof(buf));
if(n <= 0)
{
printf("read error or client close, n==[%d]\n", n);
break;
}
printf("n==[%d], buf==[%s]\n", n, buf);
for(i = 0; i < n; i++)
{
buf[i] = toupper(buf[i]);
}
//写数据
write(cfd, buf, n);
}
//关闭文件描述符
close(lfd);
close(cfd);
return 0;
}
客户端1:
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day10$ gcc -o server 01-server.c
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day10$ ./server
lfd == [3], cfd == [4]
n==[6], buf==[nihao
]
n==[9], buf==[niahidxj
]
read error or client close, n==[0]
客户端2:
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day10$ nc 127.0.0.1 8888
nihao
NIHAO
niahidxj
NIAHIDXJ
^C
1 创建socket,返回一个文件描述符cfd---socket()
---该文件描述符是用于和服务端通信
2 连接服务端---connect()
3 while(1)
{
//发送数据---write或者send
//接收数据---read或者recv
}
4 close(cfd)
客户端代码:
//客户端代码
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
int main()
{
//创建socket---用于和服务端进行通信
int cfd = socket(AF_INET, SOCK_STREAM, 0);
if(cfd<0)
{
perror("socket error");
return -1;
}
//连接服务端
//int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
struct sockaddr_in serv;
serv.sin_family = AF_INET;
serv.sin_port = htons(8888);
inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr.s_addr);
printf("[%x]\n", serv.sin_addr.s_addr);
int ret = connect(cfd, (struct sockaddr *)&serv, sizeof(serv));
if(ret<0)
{
perror("connect error");
return -1;
}
int n = 0;
char buf[256];
while(1)
{
//读标准输入数据
memset(buf, 0x00, sizeof(buf));
n = read(STDIN_FILENO, buf, sizeof(buf));
//发送数据
write(cfd, buf, n);
//读服务端发来的数据
memset(buf, 0x00, sizeof(buf));
n = read(cfd, buf, sizeof(buf));
if(n<=0)
{
printf("read error or server closed, n==[%d]\n", n);
break;
}
printf("n==[%d], buf==[%s]\n", n, buf);
}
//关闭套接字cfd
close(cfd);
return 0;
}
这段代码是一个简单的TCP客户端程序,它连接到服务器,然后进入一个无限循环,读取标准输入(例如键盘输入)的数据,发送给服务器,然后等待服务器的回应。
我将逐步解析代码:
-
头文件包含:
- 这些头文件提供了网络编程和标准I/O函数所需的函数、数据类型和结构定义。
-
创建socket:
int cfd = socket(AF_INET, SOCK_STREAM, 0);
socket()
函数用于创建一个新的socket,并返回一个描述符。AF_INET
表示IPv4。SOCK_STREAM
表示使用TCP(面向连接的)。- 第三个参数为0,让操作系统自动选择正确的协议(在这种情况下是TCP)。
-
配置服务器信息:
struct sockaddr_in serv; serv.sin_family = AF_INET; serv.sin_port = htons(8888); inet_pton(AF_INET, "127.0.0.1", &serv.sin_addr.s_addr);
sockaddr_in
结构用于IPv4地址。AF_INET
表示IPv4。htons(8888)
表示端口号8888,htons
函数用于将端口号从主机字节序转换为网络字节序。inet_pton()
函数用于将点分十进制字符串的IP地址(如"127.0.0.1")转换为网络字节序的二进制格式。
-
连接到服务器:
int ret = connect(cfd, (struct sockaddr *)&serv, sizeof(serv));
connect()
函数尝试建立与服务器的连接。
-
通信循环:
while(1)
- 这是一个无限循环,程序将持续运行,直到出现错误或手动停止。
-
读取标准输入数据:
n = read(STDIN_FILENO, buf, sizeof(buf));
- 使用
read()
函数从标准输入(键盘)读取数据。STDIN_FILENO
是标准输入的文件描述符。
- 使用
-
发送数据到服务器:
write(cfd, buf, n);
- 使用
write()
函数将从键盘读取的数据发送到服务器。
- 使用
-
读取服务器响应:
n = read(cfd, buf, sizeof(buf));
- 从服务器读取数据。如果读取的数据长度小于或等于0,表示有错误或服务器已关闭连接。
-
关闭连接:
close(cfd);
- 使用
close()
函数关闭socket连接。
- 使用
这个客户端程序的基本工作流程是:连接到服务器 -> 读取用户的键盘输入 -> 发送输入到服务器 -> 等待并读取服务器的回应 -> 重复此过程。
先运行服务端
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day10$ ./server
lfd == [3], cfd == [4]//在运行客户端后出现
客户端:
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day10$ gcc -o client 02-client.c
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day10$ ./client
[100007f]
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day10$ netstat -anp | grep 8888
(并非所有进程都能被检测到,所有非本用户的进程信息将不会显示,如果想看到所有信息,则必须切换到 root 用户)
tcp 0 0 0.0.0.0:8888 0.0.0.0:* LISTEN 6737/./server
tcp 0 0 127.0.0.1:8888 127.0.0.1:49300 ESTABLISHED 6737/./server
tcp 0 0 127.0.0.1:49300 127.0.0.1:8888 ESTABLISHED 6739/./client
客户端输入:
ni hao ya
客户端:
n == [10], buf == [NI HAO YA
]
服务端:
n==[10], buf==[ni hao ya
]
服务端关掉客户端不会退出
6.3本地socket通信
通过查询: man 7 unix 可以查到unix本地域socket通信相关信息:
#include <sys/socket.h>
#include <sys/un.h>
int socket(int domain, int type, int protocol);
函数说明: 创建本地域socket
函数参数:
domain: AF_UNIX or AF_LOCAL
type: SOCK_STREAM或者SOCK_DGRAM
protocol: 0 表示使用默认协议
函数返回值:
成功: 返回文件描述符。
失败: 返回-1,并设置errno值。
创建socket成功以后,会在内核创建缓冲区,下图是客户端和服务端内核缓冲区示意图。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
函数说明: 绑定套接字
函数参数:
socket: 由socket函数返回的文件描述符
addr: 本地地址
addlen: 本地地址长度
函数返回值:
成功: 返回文件描述符
失败: 返回-1,并设置errno值
需要注意的是: bind函数会自动创建socket文件,若在调用bind函数之前socket文件已经存在,则调用bind会报错,可以使用unlink函数在bind之前先删除文件。
struct sockaddr_un
{
sa_family_t sun_family; /* AF_UNIX or AF_LOCAL*/
char sun_path[108]; /* pathname */
};
通过man 2 bind,可以查看bind函数的相关信息,后面还有示例代码,可以参考。
本地套接字服务器的流程:
▶可以使用TCP的方式,必须按照tcp的流程
▶也可以使用UDP的方式,必须按照udp的流程
tcp的本地套接字服务器流程:
1.创建socket(AF_UNIX,SOCK_STREAM,0)
lfd= socket(AF_UNIX,SOCK_STREAM,0)
2.绑定 struct sockaddr_un &强转
struct sockaddr_un serv;
bzero(&serv, sizeof(serv));
serv.sun_family = AF_UNIX;
strcpy(serv.sun_path, "./serv.sock");
bind(lfd, (struct sockaddr*)&serv, sizeof(serv));
strcpy(serv.sun_path, "./serv.sock");
总体而言,这一行代码的目的是为 Unix 域套接字指定一个文件系统中的路径,客户端可以通过这个路径连接到服务器的套接字。在使用 Unix 域套接字进行进程间通信时,套接字的路径是唯一标识一个套接字的重要信息。
3.监听listen
4.获得新的客户端连接 cfd = accept()
5.循环通信 read-write
while (1)
{
//读数据 --- read()
if (error)
{
break;
}
//发送数据---write()
}
6.关闭文件描述符 close
close(cfd);
close(lfd);
tcp本地套接字客户端流程:
1.创建socket(AF_UNIX,SOCK_STREAM,0)
lfd= socket(AF_UNIX,SOCK_STREAM,0)
2.调用bind函数将socket文件描述和socket文件进行绑定。不是必须的,若无显示绑定会进行隐式绑定,但服务器不知道谁连接了。
3.调用connect函数连接服务端
4.循环通信read-write
while (1)
{
//读数据 --- read()
if (error)
{
break;
}
//发送数据---write()
}
5.关闭文件描述符 close
编写代码并进行测试
测试客户端工具:
man nc
-U Specifies to use UNIX-domain sockets.
例如: nc -U sock.s
size = offsetof(struct sockaddr_un, sun_path) +strlen(un.sun_path);
#define offsetof(type, member) ((int)&((type *)0)->member)
03-unix_server.c
//本地socket通信服务段
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <ctype.h>
#include <sys/un.h>
int main()
{
//创建socket
int lfd = socket(AF_UNIX, SOCK_STREAM, 0);
if(lfd < 0)
{
perror("socket error");
return -1;
}
//绑定
struct sockaddr_un serv;
serv.sun_family = AF_UNIX;
strcpy(serv.sun_path, "./serv.sock");
bind(lfd, (struct sockaddr *)&serv, sizeof(serv));
//监听
listen(lfd, 128);
//接受新的客户端连接
struct sockaddr_un client;
socklen_t len = sizeof(client);
int cfd = accept(lfd, (struct sockaddr*)&client, &len);
if(cfd < 0)
{
perror("accept error\n");
return -1;
}
int i;
int n;
char buf[1024];
while(1)
{
//读数据
memset(buf, 0x00, sizeof(buf));
int n = read(cfd, buf, sizeof(buf));
if(n <= 0)
{
perror("read error or client closed");
break;
}
for(i = 0; i < n; i++)
{
buf[i] = toupper(buf[i]);
}
//发送数据
write(cfd, buf, n);
}
//关闭文件描述符
close(cfd);
close(lfd);
return 0;
}
终端1
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day12$ ./03-unix_server
终端2
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day12$ nc -U serv.sock
终端3
unix 2 [ ACC ] 流 LISTENING 72333 4343/./03-unix_serv ./serv.sock
unix 3 [ ] 流 已连接 72334 4343/./03-unix_serv ./serv.sock
终端1发个数据
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day12$ nc -U serv.sock
nihao
NIHAO
终端2关闭 客户端
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day12$ nc -U serv.sock
nihao
NIHAO
^C
终端1
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day12$ ./03-unix_server
read error or client closed: Success
再次启动
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day12$ ./03-unix_server
accept error
: Invalid argument
这里错误应该是bind,将其错误打印出来
int ret = bind(lfd, (struct sockaddr *)&serv, sizeof(serv));
if(ret<0)
{
perror("bind error");
return -1;
}
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day12$ make 03-unix_server
cc 03-unix_server.c -o 03-unix_server
cmt@cmt-VMware-Virtual-Platform:~/C_Program/day12$ ./03-unix_server
bind error
: Address already in use
发生错误本质原因是本地已经有serv.sock这个文件了,系统认为这个文件已经被占用了,就不能绑定了。
改进
//本地socket通信服务端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/un.h>
int main()
{
//创建socket
int lfd = socket(AF_UNIX, SOCK_STREAM, 0);
if(lfd<0)
{
perror("socket error");
return -1;
}
//删除socket文件,避免bind失败
unlink("./server.sock");//引用计数减1
//绑定bind
struct sockaddr_un serv;
bzero(&serv, sizeof(serv));
serv.sun_family = AF_UNIX;
strcpy(serv.sun_path, "./server.sock");
int ret = bind(lfd, (struct sockaddr *)&serv, sizeof(serv));
if(ret<0)
{
perror("bind error");
return -1;
}
//监听listen
listen(lfd, 10);
//接收新的连接-accept
struct sockaddr_un client;
bzero(&client, sizeof(client));
int len = sizeof(client);
int cfd = accept(lfd, (struct sockaddr *)&client, &len);
if(cfd<0)
{
perror("accept error");
return -1;
}
printf("client->[%s]\n", client.sun_path);
int n;
char buf[1024];
while(1)
{
//读数据
memset(buf, 0x00, sizeof(buf));
n = read(cfd, buf, sizeof(buf));
if(n<=0)
{
printf("read error or client close, n==[%d]\n", n);
break;
}
printf("n==[%d], buf==[%s]\n", n, buf);
//发送数据
write(cfd, buf, n);
}
close(lfd);
return 0;
}
客户端程序
//本地socket通信客户端
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/types.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netinet/in.h>
#include <sys/un.h>
int main()
{
//创建socket
int cfd = socket(AF_UNIX, SOCK_STREAM, 0);
if(cfd<0)
{
perror("socket error");
return -1;
}
//删除socket文件,避免bind失败
unlink("./client.sock");
//绑定bind
struct sockaddr_un client;
bzero(&client, sizeof(client));
client.sun_family = AF_UNIX;
strcpy(client.sun_path, "./client.sock");
int ret = bind(cfd, (struct sockaddr *)&client, sizeof(client));
if(ret<0)
{
perror("bind error");
return -1;
}
struct sockaddr_un serv;
bzero(&serv, sizeof(serv));
serv.sun_family = AF_UNIX;
strcpy(serv.sun_path, "./server.sock");
ret = connect(cfd, (struct sockaddr *)&serv, sizeof(serv));
if(ret<0)
{
perror("connect error");
return -1;
}
int n;
char buf[1024];
while(1)
{
memset(buf, 0x00, sizeof(buf));
n = read(STDIN_FILENO, buf, sizeof(buf));
//发送数据
write(cfd, buf, n);
//读数据
memset(buf, 0x00, sizeof(buf));
n = read(cfd, buf, sizeof(buf));
if(n<=0)
{
printf("read error or client close, n==[%d]\n", n);
break;
}
printf("n==[%d], buf==[%s]\n", n, buf);
}
close(cfd);
return 0;
}
参考资料: