Linux的Socket开发概述

什么是Socket连接? Socket是应用层与TCP/IP协议簇通信的中间软件抽象层,它是一组接口。

套接字(socket)是 Linux 下的一种进程间通信机制(socket IPC),在前面的内容中已经给大家提到过,使用 socket IPC 可以使得在不同主机上的应用程序之间进行通信(网络通信),当然也可以是同一台主机上的不同应用程序。socket IPC 通常使用客户端<--->服务器这种模式完成通信,多个客户端可以同时连接到服务器中,与服务器之间完成数据交互。

内核向应用层提供了 socket 接口,对于应用程序开发人员来说,我们只需要调用 socket 接口开发自己的应用程序即可!socket 是应用层与 TCP/IP 协议通信的中间软件抽象层,它是一组接口。在设计模式中,socket 其实就是一个门面模式,它把复杂的 TCP/IP 协议隐藏在 socket 接口后面,对用户来说,一组简单的接口就是全部,让 socket 去组织数据,以符合指定的协议。所以,我们无需深入的去理解 tcp/udp 等各种复杂的 TCP/IP 协议,socket 已经为我们封装好了,我们只需要遵循 socket 的规定去编程,写出的程序自然遵循 tcp/udp 标准的。

当前网络中的主流程序设计都是使用 socket 进行编程的,因为它简单易用,它还是一个标准(BSD socket),能在不同平台很方便移植,比如你的一个应用程序是基于 socket 接口编写的,那么它可以移植到任何实现 BSD socket 标准的平台,譬如 LwIP,它兼容 BSD Socket;又譬如 Windows,它也实现了一套基于socket 的套接字接口,更甚至在国产操作系统中,如 RT-Thread,它也实现了 BSD socket 标准的 socket 接口。

BSD套接字是最早在1983年随着BSD操作系统发布的套接字接口的名称。后来,这个接口被整合到了POSIX规范中,并增加了一些更详细的操作规范。除了术语不同以外,可以认为没有任何区别。BSD套接字涵盖了随着BSD操作系统发布的API,而POSIX标准适用于任何希望符合POSIX的操作系统。

可参考:

具体了解该套接字,可参考:

伯克利套接字(BSD Socket)-CSDN博客

建立连接相关API 

socket()函数

socket()函数原型如下所示:

#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
int socket(int domain, int type, int protocol);

socket()函数类似于 open()函数,它用于创建一个网络通信端点(打开一个网络通信),如果成功则返回一个网络文件描述符,通常把这个文件描述符称为 socket 描述符(socket descriptor),这个 socket 描述符跟文件描述符一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。

该函数包括 3 个参数,如下所示:

domain

参数 domain 用于指定一个通信域;这将选择将用于通信的协议族。可选的协议族如下表所示:

对于 TCP/IP 协议来说,通常选择 AF_INET 就可以了,当然如果你的 IP 协议的版本支持 IPv6,那么可以选择 AF_INET6。

type

参数 type 指定套接字的类型,当前支持的类型有:

protocol

即协议类别,表示为给定的通信域和套接字类型选择默认协议,一般设置为0即可,因为该函数会通过前两个参数自动推导出第三个参数的协议类别。

调用 socket()与调用 open()函数很类似,调用成功情况下,均会返回用于文件 I/O 的文件描述符,只不过对于 socket()来说,其返回的文件描述符一般称为 socket 描述符。当不再需要该文件描述符时,可调用close()函数来关闭套接字,释放相应的资源。

如果 socket()函数调用失败,则会返回-1,并且会设置 errno 变量以指示错误类型。

使用示例

int socket_fd = socket(AF_INET, SOCK_STREAM, 0);//打开套接字
if (0 > socket_fd) {
    perror("socket error");
    exit(-1);
}
......
......
close(socket_fd); //关闭套接字

注意,初始时,客户端和服务器都需要调用socket,来指定使用的协议族和socket类型。

bind()函数

bind()函数原型如下所示:

int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

bind()函数用于将一个 IP 地址和端口号与一个套接字进行绑定,对于客户端来说,它与服务器进行通信,首先需要知道服务器的 IP 地址以及对应的端口号,通常服务器的 IP 地址以及端口号都是众所周知的。

调用 bind()函数将参数 sockfd 指定的套接字与一个地址 addr 进行绑定,成功返回 0,失败情况下返回-1,并设置 errno 以提示错误原因。

参数 addr 是一个指针,指向一个 struct sockaddr 类型变量,如下所示:

struct sockaddr {
    sa_family_t sa_family;
    char sa_data[14];
}

第二个成员 sa_data 是一个 char 类型数组,一共 14 个字节,在这 14 个字节中就包括了 IP 地址、端口号等信息,这个结构对用户并不友好,它把这些信息都封装在了 sa_data 数组中,这样使得用户不方便对sa_data 数组进行赋值。事实上,这是一个通用的 socket 地址结构体。

一般我们在使用的时候都会使用 struct sockaddr_in 结构体,sockaddr_in 和 sockaddr 是并列的结构(占用的空间是一样的),指向 sockaddr_in 的结构体的指针也可以指向 sockadd 的结构体,并代替它,而且sockaddr_in 结构对用户将更加友好,在使用的时候进行类型转换就可以了。该结构体内容如下所示:

struct sockaddr_in {
    sa_family_t sin_family;
    /* 协议族 */
    in_port_t sin_port;
    /* 端口号 */
    struct in_addr sin_addr;
    /* IP 地址 */
    unsigned char sin_zero[8];
};

这个结构体的第一个字段是与 sockaddr 结构体是一致的,而剩下的字段就是 sa_data 数组连续的 14 字节信息里面的内容,只不过从新定义了成员变量而已,sin_port 字段是我们需要填写的端口号信息,sin_addr字段是我们需要填写的 IP 地址信息,剩下 sin_zero 区域的 8 字节保留未用。

bind函数的最后一个参数 addrlen 指定了 addr 所指向的结构体对应的字节长度。

使用示例

struct sockaddr_in socket_addr;
memset(&socket_addr, 0x0, sizeof(socket_addr)); //清零
//填充变量
socket_addr.sin_family = AF_INET;
socket_addr.sin_addr.s_addr = htonl(INADDR_ANY);
socket_addr.sin_port = htons(5555);
//将地址与套接字进行关联、绑定
bind(socket_fd, (struct sockaddr *)&socket_addr, sizeof(socket_addr));

"将sin_addr设置为INADDR_ANY"的含义是什么?

INADDR_ANY 转换过来就是0.0.0.0,泛指本机的意思,也就是表示本机的所有IP,因为有些机子不止一块网卡,多网卡的情况下,这个就表示所有网卡ip地址的意思。 比如一台电脑有3块网卡,分别连接三个网络,那么这台电脑就有3个ip地址了,如果某个应用程序需要监听某个端口,那他要监听哪个网卡地址的端口呢? 如果绑定某个具体的ip地址,你只能监听你所设置的ip地址所在的网卡的端口,其它两块网卡无法监听端口,如果我需要三个网卡都监听,那就需要绑定3个ip,也就等于需要管理3个套接字进行数据交换,这样岂不是很繁琐? 所以出现INADDR_ANY,你只需绑定INADDR_ANY,管理一个套接字就行,不管数据是从哪个网卡过来的,只要是绑定的端口号过来的数据,都可以接收到。

注意,代码中的 htons 和 htonl 并不是函数,只是一个宏定义,主要的作用在于为了避免大小端的问题,需要这些宏需要在我们的应用程序代码中包含头文件<netinet/in.h>。

说明:

ip用来确定一台设备,端口号用来确定一个进程。

bind函数是在服务端使用,并且绑定的是服务器本身的ip和端口号。

为啥不是绑定对方的ip和端口?比如客户端绑定服务器的,服务器绑定客户端的,可根据使用情况来看,只有服务端需要用bind,并且绑定的是自己的ip和端口,好像客户端的ip和端口不用显式绑定,为啥?

所以,为什么TCP服务端需要调用bind函数而客户端通常不需要呢?

bind是为了将当前的socket(可以理解成网络连接实例)绑定到了服务端的ip和端口上,然后服务端就可以实时监听有没有客户端连接到了这个socket上,也就是说,一旦绑定的这个ip和端口有动静,服务端就可以响应了。

客户端上线是主动向服务器发出请求的,因为服务器已经绑定了IP和端口,所以客户端上线的就向这个IP和端口发出请求,这时因为客户开始发数据了(发上线请求),操作系统就给客户端分配一个随机端口,这个端口和客户端的IP会随着上线请求一起发给服务器。

服务器收到上线请求后就可以从中获发此请求的客户的IP(源ip字段)和端口,接下来服务器就可以利用获的IP和端口给客户端回应消息了。

总之一句话:客户端是主动连接(Connect),而服务器是等待接受连接(Accept)

注意:一旦建立了连接,客户端 和 服务器端 均可以发送(Send)或者接收(Receive)数据。

更多参考:

为什么TCP服务端需要调用bind函数而客户端通常不需要呢?_tcp bind-CSDN博客

Tips:bind()函数并不是总是需要调用的,只有用户进程想与一个具体的 IP 地址或端口号相关联的时候才需要调用这个函数。如果用户进程没有这个必要,那么程序可以依赖内核的自动的选址机制来完成自动地址选择,通常在客户端应用程序中会这样做。

难怪客户端可以使用动态IP地址,原来是客户端可以让操作系统来自动获取ip和端口。

tcp服务端必须有bind, 客户端通常不用bind,  当然如果你够无聊, 那也可以用一下bind。在这里, 我要说一下了: 客户端用bind的程序很容易出问题, 你想想啊, 操作系统指定的不会冲突的随机端口难道不比你自己指定的容易冲突的固定端口好?在很多场景下, 我们要在一个pc上开启多个客户端进程, 如果指定固定端口,必然会造成端口冲突,影响通信!所以,我们就不要费力不讨好了,客户端就不要指定端口了,让操作系统来搞。

listen()函数

listen()函数只能在服务器进程中使用,让服务器进程进入监听状态,等待客户端的连接请求,listen()函数在一般在 bind()函数之后调用,在 accept()函数之前调用,它的函数原型是:

int listen(int sockfd, int backlog);

无法在一个已经连接的套接字(即已经成功执行 connect()的套接字或由 accept()调用返回的套接字)上执行 listen()。

参数 backlog 用来描述 sockfd 的等待连接队列能够达到的最大值。在服务器进程正处理客户端连接请求的时候,可能还存在其它的客户端请求建立连接,因为 TCP 连接是一个过程,由于同时尝试连接的用户过多,使得服务器进程无法快速地完成所有的连接请求,那怎么办呢?直接丢掉其他客户端的连接肯定不是一个很好的解决方法。因此内核会在自己的进程空间里维护一个队列,这些连接请求就会被放入一个队列中,服务器进程会按照先来后到的顺序去处理这些连接请求,这样的一个队列内核不可能让其任意大,所以必须有一个大小的上限,这个 backlog 参数告诉内核使用这个数值作为队列的上限。而当一个客户端的连接请求到达并且该队列为满时,客户端可能会收到一个表示连接失败的错误,本次请求会被丢弃不作处理。 一般设置为5、10。

accept()函数

服务器调用 listen()函数之后,就会进入到监听状态,等待客户端的连接请求,使用 accept()函数获取客户端的连接请求并建立连接。函数原型如下所示:

int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);

为了能够正常让客户端能正常连接到服务器,服务器必须遵循以下处理流程:

①、调用 socket()函数打开套接字;

②、调用 bind()函数将套接字与一个端口号以及 IP 地址进行绑定;

③、调用 listen()函数让服务器进程进入监听状态,监听客户端的连接请求;

④、调用 accept()函数处理到来的连接请求。

accept()函数通常只用于服务器应用程序中,如果调用 accept()函数时,并没有客户端请求连接(等待连接队列中也没有等待连接的请求),此时 accept()会进入阻塞状态,直到有客户端连接请求到达为止。当有客户端连接请求到达时,accept()函数与远程客户端之间建立连接,accept()函数返回一个新的套接字。这个套接字与 socket()函数返回的套接字并不同,socket()函数返回的是服务器的套接字(以服务器为例),而accept()函数返回的套接字连接到调用 connect()的客户端,服务器通过该套接字与客户端进行数据交互,譬如向客户端发送数据、或从客户端接收数据。

所以,理解 accept()函数的关键点在于它会创建一个新的套接字,其实这个新的套接字就是与执行connect()(客户端调用 connect()向服务器发起连接请求)的客户端之间建立了连接,这个套接字代表了服务器与客户端的一个连接。如果 accept()函数执行出错,将会返回-1,并会设置 errno 以指示错误原因。

参数 addr 是一个传出参数,参数 addr 用来返回已连接的客户端的 IP 地址与端口号等这些信息。参数addrlen 应设置为 addr 所指向的对象的字节长度,如果我们对客户端的 IP 地址与端口号这些信息不感兴趣,可以把 arrd 和 addrlen 均置为空指针 NULL。

connect()函数

connect()函数原型如下所示:

int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);

该函数用于客户端应用程序中,客户端调用 connect()函数将套接字 sockfd 与远程服务器进行连接,参数 addr 指定了待连接的服务器的 IP 地址以及端口号等信息,参数 addrlen 指定了 addr 指向的 struct sockaddr对象的字节大小。

客户端通过 connect()函数请求与服务器建立连接,对于 TCP 连接来说,调用该函数将发生 TCP 连接的握手过程,并最终建立一个 TCP 连接,而对于 UDP 协议来说,调用这个函数只是在 sockfd 中记录服务器IP 地址与端口号,而不发送任何数据。

函数调用成功则返回 0,失败返回-1,并设置 errno 以指示错误原因。

数据读写相关API

一旦客户端与服务器建立好连接之后,我们就可以通过套接字描述符来收发数据了(对于客户端使用socket()返回的套接字描述符,而对于服务器来说,需要使用 accept()返回的套接字描述符),这与我们读写普通文件是差不多的操作,譬如可以调用 read()或 recv()函数读取网络数据,调用 write()或 send()函数发送数据。

read()函数

read()函数大家都很熟悉了,通过 read()函数从一个文件描述符中读取指定字节大小的数据并放入到指定的缓冲区中,read()调用成功将返回读取到的字节数,此返回值受文件剩余字节数限制,当返回值小于指定的字节数时并不意味着错误;这可能是因为当前可读取的字节数小于指定的字节数(比如已经接近文件结尾,或者正在从管道或者终端读取数据,或者 read()函数被信号中断等),出错返回-1 并设置 errno,如果在调 read 之前已到达文件末尾,则这次 read 返回 0。

套接字描述符也是文件描述符,所以使用 read()函数读取网络数据时,read()函数的参数 fd 就是对应的套接字描述符。

recv()函数

recv()函数原型如下所示:

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

不论是客户端还是服务器都可以通过 revc()函数读取网络数据,它与 read()函数的功能是相似的。参数sockfd 指定套接字描述符,参数 buf 指向了一个数据接收缓冲区,参数 len 指定了读取数据的字节大小,参数 flags 可以指定一些标志用于控制如何接收数据。

函数 recv()与 read()很相似,但是 recv()可以通过指定 flags 标志来控制如何接收数据,通常一般我们将 flags 参数设置为 0,当然,你可以根据自己的需求设置该参数。

flags取值有:

0:常规操作,与read()相同

MSG_DONTWAIT:将单个I/O操作设置为非阻塞模式

MSG_OOB:指明发送的是带外信息

MSG_PEEK:可以查看可读的信息,在接收数据后不会将这些数据丢失

MSG_WAITALL:通知内核直到读到请求的数据字节数时,才返回。

返回值
读出来的字节大小

recv函数返回值是-1代表什么

recv函数返回值为-1通常表示接收数据时出现了错误。

常见的错误包括:

  1. errno 被设置为 EAGAIN(11)或EWOULDBLOCK(11),表示套接字处于非阻塞模式且当前没有数据可用;
  2. errno被设置为 EINTR(4),表示接收过程中被信号中断;
  3. errno被设置为其他值,表示发生了其他的错误,例如连接已经关闭等;

在出现错误时,应该检査errno的值以判断具体的错误原因,并采取相应的措施。

补充:

阻塞socket上read/write出现errno为EAGAIN的原因解密_errno == eagain-CSDN博客

阻塞读取时如果设置了超时时间,也可能返回EAGAIN

参考:

socket编程:recv()函数详解_socket recv-CSDN博客

IO模式精细讲解: MSG_DONTWAIT 、 MSG_WAITALL - ITtecman - 博客园 (cnblogs.com)

write()函数

通过 write()函数可以向套接字描述符中写入数据,函数调用成功返回写入的字节数,失败返回-1,并设置 errno 变量。

send()函数

函数原型如下所示:

ssize_t send(int sockfd, const void *buf, size_t len, int flags);

send 和 write 很相似,但是 send 可以通过参数 flags 指定一些标志,来改变处理传输数据的方式。

即使 send()成功返回,也并不表示连接的另一端的进程就一定接收了数据,我们所能保证的只是当 send成功返回时,数据已经被无错误的发送到网络驱动程序上。

close()函数

当不再需要套接字描述符时,可调用 close()函数来关闭套接字,释放相应的资源。

参考示例

综合实践之TCP参考:

TCP(TCP客户端、服务器如何通信)_tcp客户端和服务器-CSDN博客

综合实践之UDP参考:

UDP编程流程(UDP客户端、服务器互发消息流程)_udp网络编程-CSDN博客

注意:在TCP连接中,服务器通常处于被动状态,等待客户端的连接请求。而客户端则处于主动状态,负责发起连接请求。一旦连接建立成功,双方就可以进行数据传输。  

TCP和UDP通信过程对比图如下:

更多待补充。

更多参考:

【网络】网络编程_编写网络程序-CSDN博客

InternetSocket和UnixSocket 

以前一直以为socket只是两个网络主机之间的进程通信,原来socket也可以用在一个主机上的两个进程之间的通信。

参考:

Internet socket和Unix socket_socket类型 internet unix-CSDN博客

unix socket通信,UNIX Domain SOCKET 是在Socket架构上发展起来的用于同一台主机的进程间通信(IPC)。它不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序列号应答等。只是将应用层数据从一个进程拷贝到另一个进程。

使用UNIX Domain Socket的过程和网络socket十分相似,也要先调用socket()创建一个socket文件描述符,address family指定为AF_UNIX,type可以选择SOCK_DGRAM或SOCK_STREAM,protocol参数仍然指定为0即可。

UNIX Domain Socket与网络socket编程最明显的不同在于地址格式不同,用结构体sockaddr_un表示,网络编程的socket地址是IP地址加端口号,而UNIX Domain Socket的地址是一个socket类型的文件在文件系统中的路径,这个socket文件由bind()调用创建,如果调用bind()时该文件已存在,则bind()错误返回。

无论是internet socket还是unix socket,服务端和客户端创建socket并进行通信的步骤都是一致的

服务端: socket -> bind -> listen -> accept -> write/send或read/recv -> close

客户端: socket -> connect -> write/send或read/recv -> close

并且每一步骤调用的函数也都是同样的函数,不同点主要在于

  1. socket函数domain字段的值不同,internet socket值为AF_INET,unix socket值为AF_UNIX
  2. socket函数的type参数的值都是SOCK_STREAM和SOCK_DGRAM,但是对于unix socket通信来说,由于是进程间的通信,这两种类型提供的服务都是可靠的
  3. bind函数中,internet socket传入的结构体是sockaddr_in,并且会绑定ip地址和端口号,unix socket传入的结构体是sockaddr_un,会绑定一个用于进程间通信的文件,但是都是需要进行强制转换的
  4. accept函数中,internet socket需要传入sockaddr_in结构体来保存发出请求的客户端的地址,但是在unix socket中,这一项为NULL,在进程通信中,在服务端上无法确定出来这个客户端进程是哪一个

后边的收发数据的调用过程都是一样的,总体来看,两种socket通信的步骤是一致的,但是用于是不一样的,一个是用于网络中两个主机之间的通信,另一个则是一个主机中两个进程之间的通信,这种一致性我觉得可能是因为linux系统中的一切皆文件的思想,无论是进程间通信或者是主机间通信,通过socket函数就能返回一个文件描述符,这个文件描述符的背后可能是用于网络传输的网络连接,或者是进程间的通信,但是对于编程者而言就是用于输入输出的一个文件描述符,这样进程间的通信和主机间的通信都被抽象成了对一个文件的输入输出操作,所以才造成了internet socket和unix socket通信的这种一致性;在细节方面,也正是因为有bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)函数,它提供了一个同一的结构体,在不同类型的socket编程时可以让sockaddr_un和sockaddr_in结构体强制类型转换,所以能够让编写不同类型的socket程序的时候可以调用同样的接口,才造成了在编程时候的这种一致性。

注意,UNIX计算机配置了一个只包含它自身的回路(loopback)网络,网络中只有一台计算机localhost,它有一个标准的IP地址127.0.0.1。

更多参考:

Linux的套接字——进程间通信的另一种方法_进程内部可以通过套接字进行通信吗-CSDN博客

套接字是一种通信机制,通过使用套接字接口,一台机器上的进程可以和另一台机器上的进程进行网络通信,同一台机器之间的进程也可以互相通信。

在客户端里看到一个奇怪的操作,那就是按照服务端的操作获取了几个socket描述符,并且进行了listen监听,然后把这些描述符加到了poll里,我奇怪的点是,明明是客户端,为啥用服务器的操作?

还是说,和字符设备以及块设备不同,这其实是网络设备进行poll监听的方式?并不是internet的进程间socket操作?

服务端正常是监听一个端口?用poll可以实现一次监听多个端口?

参考:

【C语言】Linux Socket poll多路复用_c socket poll pollin-CSDN博客

补充:socket 网络编程——端口复用技术

socket 网络编程——端口复用技术(setsockopt())(linux下多个进程监听同一个端口)_linux setsockopt-CSDN博客

使用函数setsockopt()来实现端口复用。

经过查看代码,发现这段操作用的地址是127.0.0.1,也就是环回地址

环回地址,是指不离开主机的数据包(也就是说,这些数据包不会通过外部网络接口)。

需要注意的是,它是一个 虚拟地址。是 IP地址中内部的一种。 使用环回地址,可以帮助我们在同一台 主机上实现client和server的功能。

环回地址是区分C/S的socket通信和本地进程间socket通信的重要标志。

这么一看,其实就是同一个程序中的socket进程间通信。

socket进程通信与网络通信使用的是同一套接口,只是地址结构与某些参数不同。

看到一篇文章:

AF_UNIX和127.0.0.1(AF_INET)回环地址写数据速度对比_af unix-CSDN博客

从里面可知,Socket类型使用AF_INET貌似也可以,只要地址是环回地址,相对AF_INET来说,AF_UNIX在bind时可以不用设置端口和IP。

这篇文章讲的挺好:

Unix domain socket 简介 - sparkdev - 博客园 (cnblogs.com)

Unix domain socket 又叫 IPC(inter-process communication 进程间通信) socket,用于实现同一主机上的进程间通信。socket 原本是为网络通讯设计的,但后来在 socket 的框架上发展出一种 IPC 机制,就是 UNIX domain socket。虽然网络 socket 也可用于同一台主机的进程间通讯(通过 loopback 地址 127.0.0.1),但是 UNIX domain socket 用于 IPC 更有效率:不需要经过网络协议栈,不需要打包拆包、计算校验和、维护序号和应答等,只是将应用层数据从一个进程拷贝到另一个进程。这是因为,IPC 机制本质上是可靠的通讯,而网络协议是为不可靠的通讯设计的。
UNIX domain socket 是全双工的,API 接口语义丰富,相比其它 IPC 机制有明显的优越性,目前已成为使用最广泛的 IPC 机制,比如 X Window 服务器和 GUI 程序之间就是通过 UNIX domain socket 通讯的。
Unix domain socket 是 POSIX 标准中的一个组件,所以不要被名字迷惑,linux 系统也是支持它的。

与网络 socket 编程不同的是,UNIX domain socket 客户端一般要显式调用 bind 函数,而不依赖系统自动分配的地址。客户端 bind 一个自己指定的 socket 文件名的好处是,该文件名可以包含客户端的 pid 等信息以便服务器区分不同的客户端。

recvmsg和sendmsg函数

recvmsg和sendmsg函数可以视作readv/writev在socket中的专用版和优化版函数。

recvmsg和sendmsg是最通用的I/O函数,只要设置好参数,read、readv、recv、recvfrom和write、writev、send、sendto等函数都可以对应换成这两个函数来调用。

#include <sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr *msg, int flags);
ssizt_t sendmsg(int sockfd, struct msghdr *msg, int flags);

函数的参数少,说明msghdr参数就比较复杂了,因为需要的参数都被封装到这个参数了。msghdr数据结构如下:

struct msghdr {
    void          *msg_name;            /* protocol address */
    socklen_t     msg_namelen;          /* sieze of protocol address */
    struct iovec  *msg_iov;             /* scatter/gather array */
    int           msg_iovlen;           /* # elements in msg_iov */
    void          *msg_control;         /* ancillary data ( cmsghdr struct) */
    socklen_t     msg_conntrollen;      /* length of ancillary data */
    int           msg_flags;            /* flags returned by recvmsg() */
}

msg_iov和msg_iovlen两个成员用于指定数据缓冲区数组,即iovec结构数组。

更多参考:

recvmsg和sendmsg函数-CSDN博客

好多读取和发送的函数比较

好多读取和发送的函数比较:

TCP Socket性能优化秘籍:掌握read、recv、readv、write、send、sendv的最佳实践-腾讯云开发者社区-腾讯云 (tencent.com)

recv、recvfrom、read、recvmsg、readv

先看readrecvreadv这几个函数

readrecvreadv这几个函数都可以用于从TCP Socket中读取数据。

其中,read就是通用的系统调用,可以基于任意文件描述符来读取数据;

read函数原型如下:

ssize_t read(int fd, void *buf, size_t count);

  • 功能:read函数从文件描述符(包括TCP Socket)中读取数据,并将读取的数据存储到指定的缓冲区中。

recv函数的原型如下:

ssize_t recv(int sockfd, void *buf, size_t len, int flags);

  • 功能:recv函数从TCP Socket中读取数据,并将读取的数据存储到指定的缓冲区中。

注意,这个函数只能从socket中读取数据,并且存到一个缓冲区里面。

readv函数的原型如下:

ssize_t readv(int fd, const struct iovec *iov, int iovcnt);

  • 功能:readv函数从文件描述符(包括TCP Socket)中读取数据,并将读取的数据存储到指定的多个缓冲区中。

可以看到,readv是基于read而改善的多缓冲区操作函数。

IO流程中IO向量iovec

IO流程中IO向量iovec - YoungerChina - 博客园 (cnblogs.com)

iovec结构体定义及使用 (转) - 鸭子船长 - 博客园 (cnblogs.com)

再来看看recvfrom和recvmsg函数。

recv、recvfrom、recvmsg函数_recvmsg返回值-CSDN博客

recvform 如果要定位数据发送者,可以使用recvfrom来得到数据发送者的源地址

#include <sys/socket.h>
ssize_t recvfrom(int sockfd,  //套接字
                 void * buf,  //接收数据缓冲区
                 size_t len,  //接收数据长度
                 int flags,   //标志
                 struct sockaddr * addr, //数据发送者地址,函数调用后该地址结构被填充
                 socklen_t * addrlen  //地址长度指针(注意这里是个指针)
                 );

返回值:以字节计数的消息长度,若无可用消息或对方已经按序结束则返回0,出错返回-1. 如果addr非空,他将包含数据发送者的套接字地址,当调用recvfrom时,需要设置addrlen参数指向一个包含addr所指套接字缓冲区字节大小的整数。返回时,该整数设为该地址的实际字节大小。 因为可以获得发送者的地址,recvfrom通常用于无连接套接字。否则recvfrom等同于recv。

recvmsg recvmsg将接收到的数据送入多个缓冲区,或者想接收辅助数据

#include <sys/socket.h>
ssize_t recvmsg(int sockfd, struct msghdr * msg, int flag);

返回值:以字节计数的消息长度,若无可用消息或对方已经按序结束则返回0,出错返回-1.

Linux msghdr结构讲解

sock通信中msghdr的使用-CSDN博客

可以说,readv/writevrecvmsg/sendmsg的简化版,是read/write的优化版;

网络设备及其描述符

每个字符设备在/dev目录下对应一个设备文件,linux用户程序通过设备文件(或称设备节点)来使用驱动程序操作字符设备。

每个块设备在/dev目录下对应一个设备文件,linux用户程序通过设备文件(或称设备节点)来使用驱动程序操作块设备。

/dev 中放的都是字符设备和块设备,而网络设备并不属于此二类,无法实现它们的接口。

参考网络设备驱动

【Linux驱动】网络设备驱动介绍_dev下有网卡设备吗-CSDN博客

(1)网络设备:软件层面的,如操作系统中看到的eth0(以太网)、wlan0(无线网)等。

(2)物理网卡: 真正的硬件网卡设备。

(3)/dev下没有设备文件,也不通过/sys下的属性文件访问。直观看来,应用层都是通过一些特殊的命令(如ifconfig、ping等)来访问网卡硬件(调用驱动)的。

(4)面向报文而不是面向流的,因此将网络接口映射到文件系统的节点比较困难。

(5)struct net_device来管理所有网络接口。

!!!!!!!!!注意网络设备的描述符;

我们知道,不管是打开字符设备还是块设备还是网络设备,都会有个文件描述符,网络设备对应的叫网络设备描述符;

对于字符设备和块设备来说,在dev下有对应的设备文件,我们操作时,需要先用open打开对应的设备文件,然后才能得到文件描述符;

但是,对于网络设备来说,我们并没有什么open函数可以使用,也没有对应的设备文件,所以说,我们直接操作的并不是某个具体的网卡,而是操作系统提供的网络接口,想想网络设备描述符是怎么得到的:对于客户端来说,使用socket函数得到自身的网络设备描述符,然后connect请求连接服务器;对于服务器来说,先用socket函数得到一个服务器的网络设备描述符,然后通过bind+listen+accept又得到一个用于和客户端通信的新的网络设备描述符,之后,客户端和服务器就能通过各自的描述符来通信了。

所以,通信时,客户端一直都只有一个描述符,服务端用的是第二个新的描述符。

总结来说,网络设备压根就没什么设备文件,不需要使用open函数,而是通过网络接口来获取的网络设备描述符。

通信的关键是ip和端口。

三种socket总结

Socket 通信不仅可以跨网络与不同主机的进程间通信,还可以在同主机上进程间通信。

我们来看看创建 socket 的系统调用:

int socket(int domain, int type, int protocal)

三个参数分别代表:

  • domain 参数用来指定协议族,比如 AF_INET 用于 IPV4、AF_INET6 用于 IPV6、AF_LOCAL/AF_UNIX 用于本机;
  • type 参数用来指定通信特性,比如 SOCK_STREAM 表示的是字节流,对应 TCP、SOCK_DGRAM 表示的是数据报,对应 UDP、SOCK_RAW 表示的是原始套接字;
  • protocal 参数原本是用来指定通信协议的,但现在基本废弃。因为协议已经通过前面两个参数指定完成,protocol 目前一般写成 0 即可;

根据创建 socket 类型的不同,通信的方式也就不同:

  • 实现 TCP 字节流通信:socket 类型是 AF_INET 和 SOCK_STREAM;
  • 实现 UDP 数据报通信:socket 类型是 AF_INET 和 SOCK_DGRAM;
  • 实现本地进程间通信:「本地字节流 socket 」类型是 AF_LOCAL 和 SOCK_STREAM,「本地数据报 socket 」类型是 AF_LOCAL 和 SOCK_DGRAM。另外,AF_UNIX 和 AF_LOCAL 是等价的,所以 AF_UNIX 也属于本地 socket;

接下来,简单说一下这三种通信的编程模式。

1. 针对 TCP 协议通信的 socket 编程模型

  • 服务端和客户端初始化 socket,得到文件描述符;
  • 服务端调用 bind,将绑定在 IP 地址和端口;
  • 服务端调用 listen,进行监听;
  • 服务端调用 accept,等待客户端连接;
  • 客户端调用 connect,向服务器端的地址和端口发起连接请求;
  • 服务端 accept 返回用于传输的 socket 的文件描述符;
  • 客户端调用 write 写入数据;服务端调用 read 读取数据;
  • 客户端断开连接时,会调用 close,那么服务端 read 读取数据的时候,就会读取到了 EOF,待处理完数据后,服务端调用 close,表示连接关闭。

这里需要注意的是,服务端调用 accept 时,连接成功了会返回一个已完成连接的 socket,后续用来传输数据。

所以,监听的 socket 和真正用来传送数据的 socket,是「两个」 socket,一个叫作监听 socket,一个叫作已完成连接 socket。

成功连接建立之后,双方开始通过 read 和 write 函数来读写数据,就像往一个文件流里面写东西一样。

2. 针对 UDP 协议通信的 socket 编程模型

UDP 是没有连接的,所以不需要三次握手,也就不需要像 TCP 调用 listen 和 connect,但是 UDP 的交互仍然需要 IP 地址和端口号,因此也需要 bind。

对于 UDP 来说,不需要要维护连接,那么也就没有所谓的发送方和接收方,甚至都不存在客户端和服务端的概念,只要有一个 socket 多台机器就可以任意通信,因此每一个 UDP 的 socket 都需要 bind。

另外,每次通信时,调用 sendto 和 recvfrom,都要传入目标主机的 IP 地址和端口。

3. 针对本地进程间通信的 socket 编程模型

本地 socket 被用于在同一台主机上进程间通信的场景:

  • 本地 socket 的编程接口和 IPv4 、IPv6 套接字编程接口是一致的,可以支持「字节流」和「数据报」两种协议;
  • 本地 socket 的实现效率大大高于 IPv4 和 IPv6 的字节流、数据报 socket 实现;

对于本地字节流 socket,其 socket 类型是 AF_LOCAL 和 SOCK_STREAM。

对于本地数据报 socket,其 socket 类型是 AF_LOCAL 和 SOCK_DGRAM。

本地字节流 socket 和 本地数据报 socket 在 bind 的时候,不像 TCP 和 UDP 要绑定 IP 地址和端口,而是绑定一个本地文件,这也就是它们之间的最大区别。

关于服务端的两种socket

一个服务器上有多少个socket,一个服务器上一般只有一个监听的socket.

在Socket编程中,服务器端会创建一个Socket对象并绑定到特定的IP地址和端口号上,然后监听这个端口等待客户端的连接请求。一旦有客户端连接,服务器就会接受连接,并为每个客户端创建一个新的Socket用于后续的数据传输。客户端则通过创建Socket对象,指定服务器的IP地址和端口号,然后尝试连接到服务器。

可以认为,客户端只有一个连接socket,服务端有监听socket和连接socket两种。

参考:https://www.cnblogs.com/liangjf/p/9900928.html

为什么有监听socket和连接socket,为什么产生两个socket

先看一般的socket建立连接的双方的过程:

客户端:

socket()---->创建出 active_socket_fd (client_socket_fd)

(可选)bind()--->把active_socket_fd与ip,port绑定起来

connect()--->client_socket_fd 主动请求服务端的 listen_socket_fd

read()/write()---->读/写 socket io

close()---->关闭socket_fd

服务端:

socket()---->创建出 active_socket_fd

bind()--->把active_socket_fd与ip,port绑定起来

listen()---->active_socket_fd--> listen_socket_fd 等待客户端的client_socket_fd来请求连接

accept()---->listen_socket_fd-->connec_socket_fd 把监听socket转变为连接socket,用于建立连接购的读写数据

read()/write()---->读/写 socket io

close()---->关闭socket_fd

linux内核, socket函数创建的套接字是主动套接字

一开始socket函数, 不管在客户端还是在服务端, 创建的都是主动socket, 但是在服务端经过listen(), 后把其转变为listen_socket_fd(被动监听socket), 经过accept()后转变为connect_socket_fd(已连接socket).

在转变为connect_socket_fd之前, 都是同一个socket, 只不过是socket的状态改变了, 但是服务端经过accept()后返回的socket是新的socket, 用于连接后的read()/write()

为什么服务端这么特殊, 需要两种状态的socket, 并且在这个过程中产生两个socket?

需要两种状态的socket?

对于前者, 这个比较好理解, 因为是现在的网络程序中是C/S结构的, 一般是客户端主动向服务端请求建立连接. 这个过程中, 主要涉及到两个状态, 一个是主动, 一个是被动的. 因此, 客户端的socket只用于主动向服务端的socket请求建立连接, 服务端的socket一直被动的等待客户端的请求连接就ok了. 所以这就解答了为什么需要两种状态的socket, 只有一方是主动, 另一方是被动, 才能否完成上面的过程. 如果双方都是主动, 或者被动, 就完成不了上面的过程了.

1.产生两个socket?

等等, 上面好像没有说到为什么服务端需要产生两个socket(监听socket和已连接socket)

这个我认为是, 监听socket,是服务器作为客户端连接请求的一个对端,只需创建一次能够让客户端请求到有这个端点就ok,所以监听socket(listen_socket_fd)存在于服务器的整个生命周期, 不需要每个连接都创建一个新的监听socket出来, 没必要呢。已连接socket(connect_socket_fd)是客户端与服务器之间已经建立起来了的连接的一个端点,服务器每次接受连接请求时都会创建一个新已连接socket,它的生命周期只是客户端请求服务端的时间范围内。

2.为什么不只使用一个listen_socket_fd完成从创建监听socket(listen_socket_fd), 到被请求连接, 处理请求, 关闭socket的整个过程呢? 而需要用一个listen_socket_fd作为监听客户端请求, 然后每个连接创建一个新的connect_socket_fd来完成服务端与客户端的"交流"?

假设前者那种情况, 只用一个socket完成整个过程. 那么这个socket就会一直被占用, 而不能被另外的客户端请求, 造成了服务端的性能极其低下, 如果没有存储后面的客户端请求, 就会被错过而丢弃, 因为当前的socket正在与当前一个客户端的socket建立连接.

所以从上面的情况可以得知, 在请求连接和连接后需要的socket应该不是同一个, 它们负责的工作是不一样的. 有了listen_socket_fd和connect_socket_fd后, 就可以专门用一listen_socket_fd负责响应客户端的请求, 每次新的connect_socket_fd专门负责当前这次连接的数据交互.

总结

为什么需要两种socket(监听socket和已连接socket)已经说得明白了, 总的来说是, 是为了职责分工, 分层协作, 提高服务端性能.

INADDR_ANY 的含义和用途

INADDR_ANY 是一个在网络编程中常用的选项,它用于 socket 函数中,表示服务器应该绑定到所有可用的IP地址上。在多网卡的环境下,这意味着服务器可以接收到发送到任何网卡的连接请求。具体来说,INADDR_ANY 被定义为 0.0.0.0,这个地址在实际上代表了“不确定地址”或“所有地址”、“任意地址”。

代码示例

在使用 socket 进行网络编程时,如果你想让你的服务器监听某个端口,你可以在调用 bind() 函数时使用 INADDR_ANY。这样,无论数据是从哪个网卡过来的,只要是绑定的端口号过来的数据,服务器都可以接收到。例如:

深入理解

当你的服务器有多个网卡,每个网卡上有不同的IP地址时,使用 INADDR_ANY 可以避免确定服务器上有哪些网络端口的麻烦。这在服务器操作系统可能随时增减IP地址的情况下尤其有用。此外,当服务器程序在 0.0.0.0 这个地址上进行监听时,它可以接收到所有网卡上指定端口的数据。

重要考虑

使用 INADDR_ANY 不会导致收到重复的数据包或重复发送数据。这是因为路由的关系,从客户端来的IP包只可能到达其中一个网卡。同时,服务器进程在发送数据时,操作系统会根据维护的路由表决定数据包应该从哪一个出站网关向目标发送。路由表记录有优先级别,不同的操作系统可能会有不同的处理方式。

客户端的情况

对于客户端,如果绑定 INADDR_ANY,在 TCP 连接中,系统调用会将其绑定到一个具体的IP地址。对于 UDP,即使使用 connect() 系统调用也不会绑定到一个具体地址,因为 UDP 是面向无连接的,系统只是将目标地址的信息记录在内部的socket数据结构中,供以后使用。

总的来说,INADDR_ANY 是一个非常有用的选项,它允许服务器在多网卡环境中灵活地接收来自任何网卡的连接请求,而无需单独绑定每个网卡的IP地址。这在实际应用中可以大大简化网络编程的复杂性,并提高服务器的可移植性和灵活性。

更多补充 

Linux网络通信----htonl()、htons()、ntohl()、ntohs()四个函数 

参考:

Linux网络通信----htonl()、htons()、ntohl()、ntohs()四个函数_linux ntohs-CSDN博客

注意,C语言中不能直接识别ip地址的点分十进制,我们一般都要写对应的十六进制,比如:

0.0.0.0对应0x00000000;

127.0.0.1对应0x7f000001;

255.255.255.255对应0xffffffff

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值