简介
在 Linux 环境下,Socket 套接字是计算机操作系统中用来编写 TCP/IP 通信的接口。它是一种 facade 模式,它把复杂的 TCP/IP 协议族隐藏在 Socket 接口谋面。在 TCP/IP 协议族里,Socket 的位置如下如所示:
Socket 起源于 Unix,而 Unix 基本哲学就是“一切皆文件”,都可以用“open->write/read->close”模式来操作。Socket 就是该模式的一个实现,Socket 即是一种特殊的文件,一些 Socket 函数就是对其进行的操作(读/写IO、打、关闭)
服务器端的套接字操作的流程为:socket -> bind -> listen -> accept -> write/read ->close
客户端的套接字操作的流程为:socket -> connect -> write/read -> close
服务器端先初始化Socket,然后与端口绑定(bind),对端口进行监听(listen),调用accept阻塞,等待客户端连接。在这时如果有个客户端初始化一个Socket,然后连接服务器(connect),如果连接成功,这时客户端与服务器端的连接就建立了。客户端发送数据请求,服务器端接收请求并处理请求,然后把回应数据发送给客户端,客户端读取数据,最后关闭连接,一次交互结束。
这些接口的实现都是内核来完成,我们的用户程序通过系统调用,使用内核接口,完成套接字的创建。
一个最简单的例子
服务器端
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <iostream>
#define PORT 12345
void Server(){
int socket_fd, connect_fd; // 套接字描述符
struct sockaddr_in servaddr;
char buff[4096];
// 创建 socket
if((socket_fd = socket(AF_INET, SOCK_STREAM, 0)) == -1){
std::cout << "套接字创建失败, 错误: " << strerror(errno) << errno << std::endl;
exit(0);
}
// 初始化
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY); // IP 地址设置成 INADDR_ANY, 让系统自动获取本机的 IP 地址
servaddr.sin_port = htons(PORT); // 设置端口
// 将本地地址 bind 到 socket 上
if(bind(socket_fd, (struct sockaddr*)&servaddr, sizeof(servaddr)) == -1){
std::cout << "绑定套接字失败, 错误: " << strerror(errno) << errno << std::endl;
exit(0);
}
// 开始 listen 客户端连接
if(listen(socket_fd, 10) == -1){
std::cout << "开启监听失败, 错误: " << strerror(errno) << errno << std::endl;
exit(0);
}
std::cout << "Waiting ......" << std::endl;
// 阻塞直到有客户端连接, accept
if((connect_fd = accept(socket_fd, (struct sockaddr*) nullptr, nullptr)) == -1){
std::cout << "接受连接失败, 错误: " << strerror(errno) << errno << std::endl;
exit(0);
}
// 接受客户端发送来的数据
int n = recv(connect_fd, buff, sizeof(buff), 0);
buff[n] = '\0';
std::cout << buff << std::endl;
// 向客户端发回数据
char sendBack[] = "I Received";
send(connect_fd, sendBack, strlen(sendBack), 0);
close(connect_fd);
close(socket_fd);
}
客户端
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <iostream>
#define PORT 12345
void Client() {
int socket_fd;
char buff[] = "hello";
struct sockaddr_in servaddr;
if((socket_fd = socket(AF_INET, SOCK_STREAM, 0)) < 0){
std::cout << "套接字创建失败, 错误: " << strerror(errno) << errno << std::endl;
exit(0);
}
memset(&servaddr, 0, sizeof(servaddr));
servaddr.sin_family = AF_INET;
servaddr.sin_port = htons(PORT);
servaddr.sin_addr.s_addr = inet_addr("127.0.0.1"); // 将点分十进制IP地址转换成整数
// 创建连接
if(connect(socket_fd, (struct sockaddr*)&servaddr, sizeof(servaddr)) < 0){
std::cout << "连接创建失败, 错误: " << strerror(errno) << errno << std::endl;
exit(0);
}
// 发送数据到服务器端
if(send(socket_fd, buff, strlen(buff), 0) < 0){
std::cout << "数据发送失败, 错误: " << strerror(errno) << errno << std::endl;
exit(0);
}
// 接受服务器发来的数据
char sendBack[2048];
int n = recv(socket_fd, sendBack, sizeof(sendBack), 0);
sendBack[n] = '\0';
std::cout << sendBack << std::endl;
close(socket_fd);
}
代码解释
创建套接字(socket)
首先,不管在客户端还是在服务器端,都需要调用 socket
函数,创建套接字。调用函数后,会返回一个整形的数字,这个数字被称为文件描述符(因为套接字也是一个特殊的文件)。在后面的套接字操作中,会使用到这个描述符。
对于文件描述符和文件指针,可以去看另外的文章详解
int socket(int protofamily, int type, int protocol)
函数接受三个参数。
-
protofamily
:协议族,常用的协议族有:AF_INET(IPv4)
;AF_INET6(IPv6)
;AF_LOCAL(Unix 域 Socket)
。协议族决定了 套接字的地址类型,在通信中必须采用对应的地址,如AF_INET
决定了要用IPv4
+PORT
的组合 -
type
:指定套接字的类型- SOCK_STREAM
- SOCK_DGRAM
- SOCK_RAW
- SOCK_PACKET
- SOCK_SEQPACKET
-
protocol
:指定运输层的协议,包括IPPROTO_TCP
;IPPTOTO_UDP
;IPPROTO_SCTP
;IPPROTO_TIPC
。分别对应 TCP,UDP,SCTP,TIPC
绑定 IP 地址(bind)
使用 bind 函数绑定 IP 地址,
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
-
sockfd
:套接字描述符,通过socket
创建,唯一标识一个 socket。bind 函数就是将给这个描述符绑定一个名字 -
addr
:一个const struct sockaddr*
指针,指向要绑定给 sockfd 的协议地址。其中传入这个参数的结构体如下:
// ipv4
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 */
};
/* Internet address. */
struct in_addr {
uint32_t s_addr; /* address in network byte order */
};
// ipv6
struct sockaddr_in6 {
sa_family_t sin6_family; /* AF_INET6 */
in_port_t sin6_port; /* port number */
uint32_t sin6_flowinfo; /* IPv6 flow information */
struct in6_addr sin6_addr; /* IPv6 address */
uint32_t sin6_scope_id; /* Scope ID (new in 2.4) */
};
struct in6_addr {
unsigned char s6_addr[16]; /* IPv6 address */
};
-
addrlen
:对应地址的长度
通常服务器在启动的时候都会绑定一个众所周知的地址(如ip地址+端口号),用于提供服务,客户就可以通过它来接连服务器;而客户端就不用指定,有系统自动分配一个端口号和自身的ip地址组合。这就是为什么通常服务器端在listen之前会调用bind(),而客户端就不会调用,而是在connect()时由系统随机生成一个。
监听(listen)和连接(connect)
绑定 ip 地址后,服务器端就开启接听,等待客户端的连接。客户端创建套接字后,就通过指定 ip 地址连接服务器。
int listen(int sockfd, int backlog)
-
sockfd
:套接字描述符 -
backlog
:可以排队的最大连接个数
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen)
与服务器端的 bind 函数参数一样
应答(accept)
当有客户端 connect 到服务器,这时 accept 函数就会有响应,接受客户端的请求,这样连接就建立好了。之后就可以开始网络 I/O 操作,类似于不同的文件的读写 I/O。accept默认会阻塞进程,直到有一个客户连接建立后返回,它返回的是一个新可用的套接字,这个套接字是连接套接字。
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen)
-
sockfd
:套接字描述符 -
addr
:这是一个结果参数,它用来接受一个返回值,这返回值指定客户端的地址,当然这个地址是通过某个地址结构来描述的,用户应该知道这一个什么样的地址结构。如果对客户的地址不感兴趣,那么可以把这个值设置为NULL。 -
addrlen
:上面 addr 结构的大小,同样也可置为 NULL
读(read)写(write)
建立连接后,就可以开始网络 I/O 的读写。网络 I/O 操作有下面几组:
- read()/write()
- recv()/send()
- readv()/writev()
- recvmsg()/sendmsg()
- recvfrom()/sendto()
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);
recv 函数从套接字描述符中读取内容,读取成功时,返回实际所读的字符数,如果小于 0 表示出错,send 函数同理。
flag
:默认为 0。其他取值如下:
-
MSG_DONTROUTE
绕过路由表查找,send 独占取值 -
MSG_DONTWAIT
仅本操作非阻塞,send 和 recv 均可设置 -
MSG_OOB
发送或接收带外数据,send 和 recv 均可设置 -
MSG_PEEK
窥看外来消息,recv 独占取值 -
MSG_WAITALL
等待所有数据,recv 独占取值
关闭(close)
在服务器与客户端建立连接之后,会进行一些读写操作,完成了读写操作就要关闭相应的socket描述字,好比操作完打开的文件要调用fclose关闭打开的文件。
int close(int fd)