一、Socket简介
套接字( 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 接口。
二、函数接口
1)建立连接前
1、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 描述符跟 文件描述符一样,后续的操作都有用到它,把它作为参数,通过它来进行一些读写操作。
调用 socket() 与调用 open() 函数很类似,调用成功情况下,均会返回用于文件 I/O 的文件描述符,只不 过对于 socket() 来说,其返回的文件描述符一般称为 socket 描述符。当不再需要该文件描述符时,可调用 close()函数来关闭套接字,释放相应的资源。
如果 socket() 函数调用失败,则会返回 -1 ,并且会设置 errno 变量以指示错误类型。
domain
参数 domain 用于指定一个通信域;这将选择将用于通信的协议族。对于 TCP/IP 协议来说,通常选择 AF_INET 就可以了,也就是选择IPv4协议,当然如果你的 IP 协议的版本支持 IPv6,那么可以选择 AF_INET6。
type
参数 type 指定套接字的类型,当前支持的类型有:
protocol
参数 protocol 通常设置为 0 ,表示为给定的通信域和套接字类型选择默认协议。当对同一域和套接字类型支持多个协议时,可以使用 protocol 参数选择一个特定协议。在 AF_INET 通信域中,套接字类型为 SOCK_STREAM 的默认协议是传输控制协议( Transmission Control Protocol , TCP 协议)。在 AF_INET 通信域中,套接字类型为 SOCK_DGRAM 的默认协议时 UDP 。
2、bind()函数
bind() 函数原型如下所示:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
bind() 函数用于将一个 IP 地址或端口号与一个套接字进行绑定(将套接字与地址进行关联)。将一个客户端的套接字关联上一个地址没有多少新意,可以让系统选一个默认的地址。一般来讲,会将一个服务器的套接字绑定到一个众所周知的地址--- 即一个固定的与服务器进行通信的客户端应用程序提前就知道的地址 (注意这里说的地址包括 IP 地址和端口号,一般来说,80端口是http服务,22端口是ssh服务)。因为对于客户端来说,它与服务器进行通信,首先需要知道 服务器的 IP 地址以及对应的端口号,所以通常服务器的 IP 地址以及端口号都是众所周知的。
调用 bind() 函数将参数 sockfd 指定的套接字与一个地址 addr 进行绑定,成功返回 0 ,失败情况下返回 -1,并设置 errno 以提示错误原因。
Tips:bind()函数并不是总是需要调用的,只有用户进程想与一个具体的 IP 地址或端口号相关联的时候 才需要调用这个函数。如果用户进程没有这个必要,那么程序可以依赖内核的自动的选址机制来完成自动地址选择,通常在客户端应用程序中会这样做。
3、Listen()函数
listen() 函数只能在服务器进程中使用,让服务器进程进入监听状态,等待客户端的连接请求, listen() 函数在一般在 bind() 函数之后调用,在 accept() 函数之前调用,它的函数原型是:
int listen(int sockfd, int backlog);
无法在一个已经连接的套接字(即已经成功执行 connect()的套接字或由 accept()调用返回的套接字)上执行 listen()。
backlog
参数 backlog 用来描述 sockfd 的等待连接队列能够达到的最大值。在服务器进程正处理客户端连接请求的时候,可能还存在其它的客户端请求建立连接,因为 TCP 连接是一个过程,由于同时尝试连接的用户过多,使得服务器进程无法快速地完成所有的连接请求,那怎么办呢?直接丢掉其他客户端的连接肯定不是一个很好的解决方法。因此内核会在自己的进程空间里维护一个队列,这些连接请求就会被放入一个队列中,服务器进程会按照先来后到的顺序去处理这些连接请求,这样的一个队列内核不可能让其任意大,所以必须有一个大小的上限,这个 backlog 参数告诉内核使用这个数值作为队列的上限。而当一个客户端的连接请求到达并且该队列为满时,客户端可能会收到一个表示连接失败的错误,本次请求会被丢弃不作处理。
4、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 以指示错误原因。
5、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 以指示错误原因。
2)建立连接后
发送和接收函数
一旦客户端与服务器建立好连接之后,我们就可以通过套接字描述符来收发数据了(对于客户端使用 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 可以指定一些标志用于控制如何接收数据。
write()函数
通过 write() 函数可以向套接字描述符中写入数据,函数调用成功返回写入的字节数,失败返回 -1 ,并设置 errno 变量。
send()函数
函数原型如下所示:
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
send 和 write 很相似,但是 send 可以通过参数 flags 指定一些标志,来改变处理传输数据的方式。
close()函数
当不再需要套接字描述符时,可调用 close() 函数来关闭套接字,释放相应的资源。
3)IP 地址格式转换函数
对于人来说,我们更容易阅读的是点分十进制的 IP 地址,譬如 192.168.1.110 、 192.168.1.50 ,这其实是一种字符串的形式,但是计算机所需要理解的是二进制形式的 IP 地址,所以我们就需要在点分十进制字符串和二进制地址之间进行转换。
三、总结
1、编写服务器程序
编写服务器应用程序的流程如下:
①、调用 socket() 函数打开套接字,得到套接字描述符;
②、调用 bind() 函数将套接字与 IP 地址、端口号进行绑定;
③、调用 listen() 函数让服务器进程进入监听状态;
④、调用 accept() 函数获取客户端的连接请求并建立连接;
⑤、调用 read/recv 、 write/send 与客户端进行通信;
⑥、调用 close() 关闭套接字。
2、编写客户端程序
①、调用 socket()函数打开套接字,得到套接字描述符;
②、调用 bind()函数将套接字与 IP 地址、端口号进行绑定;
③、调用 connect()连接远端服务器;
④、调用 read/recv、write/send 与客户端进行通信;
⑤、调用 close()关闭套接字。