1.socket编程接口
1.创建 socket 文件描述符(TCP/UDP,客户端 + 服务器)
- 原型:
int socket(int domain, int type, int protocol);
- **功能:**打开一个网络通讯端口,如果成功的话,就像open()一样返回一个文件描述符(本质就是打开网络文件)
- 参数:
- domain:指明协议族,即想要使用什么协议(IPV4、IPV6…),它是下列表格中的某个常值。该参数也往往被称为协议域
AF_INET | IPV4协议 |
---|---|
AF_INET6 | IPV6协议 |
AF_LOCAL | Unix域协议 |
AF_ROUTE | 路由套接字 |
AF_KEY | 密钥套接字 |
- type:指明套接字的类型,它是下列表格中的某个值
SOCK_STREAM | 字节流套接字 |
---|---|
SOCK_DGRAM | 数据报套接字 |
SOCK_SEQPACKET | 有序分组套接字 |
SOCK_RAW | 原始套接字 |
- protocol:创建套接字的协议类别,可以指明TCP/UDP,但该字段一般设置为0即可(0会根据前两个参数自动推导需要使用哪种协议)
IPPROTO_TCP | TCP传输协议 |
---|---|
IPPROTO_UDP | UDP传输协议 |
IPPROTO_SCTP | SCTP传输协议 |
- **返回值:**成功返回一个文件描述符,失败返回-1,同时错误码被设置
2.绑定端口号(TCP/UDP,服务器)
- 原型:
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
- **功能:**给一个协议地址赋予一个套接字,使socket这个用于网络通讯的文件描述符监听address所描述的地址和端口号
- 即:将用户设置的ip和port在内核中和当前的进程强关联
- 参数:
- socket**:**绑定的文件的文件描述符,也就是创建socket时获取到的文件描述符
- address**:**指向一个特定于协议的地址结构的指针
- address_len**:**该协议的地址结构的长度
- **返回值:**成功返回0,失败返回-1,同时错误码被设置
3.开始监听socket(TCP,服务器)
-
原型:
int listen(int socket, int backlog);
-
**功能:**使socket处于监听状态,表明服务器对外宣告它愿意接受连接请求,它做两件事
- 简单来说,服务器调用listen函数,就是告诉客户端你可以连接我了
- 一旦服务器调用了listen,所用的套接字就能接受连接请求。使用accept函数获得的连接请求并建立连接
-
参数:
- **socket:**需要设置为监听状态的套接字对应的描述符
- **backlog:**全连接队列的最大长度。如果有多个客户端同时发来连接请求,此时未被服务器处理的连接就会放入连接队列,该参数代表的就是这个全连接队列的最大长度
-
**返回值:**成功返回0,失败返回-1
-
说明:本函数通常应该在调用socket()和bind()之后,并在调用accept()之前
4.接收请求(TCP,服务器)
- 原型:
int accept(int socket, struct sockaddr *address, socklen_t *address_len);
- 功能:
- 用于从已完成连接队列队头返回下一个已完成连接,如果已完成连接队列为空,那么进程将被投入睡眠**(即阻塞等待直到有客户端连接上来)**
- 获取一个已经连接建立完成的socket套接字描述符(完成了三次握手),作为与指定客户端通信的句柄
- 参数:
- **socket:**特定的监听套接字,表示从该监听套接字中获取连接
- **address:**输出型参数,用于存储对端网络相关的属性信息
- **addrlen:**输入输出型参数,调用时传入期望读取的address结构体的长度,返回值代表实际读到的address结构体的长度
- 说明:
- 如果accept成功,那么其返回值是由内核自动生成的一个全新描述符,代表与所返回客户的TCP连接
- 称它的第一个参数为监听套接字(listening socket)
- 一个服务器通常仅仅创建一个监听套接字,它在该服务器的生命期内一直存在
- 称它的返回值为已连接套接字(connected socket)
- 内核为每个由服务器进程接受的客户连接创建一个已连接套接字
- 当服务器完成对某个给定客户的服务时,相应的已连接套接字就被关闭
- 总结:
- 返回的文件描述符是新的套接字描述符,该描述符连接到调用connect的客户端。这个新的套接字描述符和原始套接字(socket)具有相同的套接字类型和地址族
- 传给accept的原始套接字没有关联到这个连接,而是继续保持监听状态并接受其他连接请求
5.建立连接(TCP,客户端)
- **功能:**TCP客户用connect()来建立与TCP服务器的连接
- 原型:
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
- 参数:
- sockfd**:**特定的套接字,表示通过该套接字发起连接请求
- addr**:**指向一个特定于协议的地址结构的指针
- addrlen**:**该协议的地址结构的长度
- **返回值:**成功返回0,失败返回-1
- **注意:**在connect中指定的地址是我们想要与之通信服务器地址。如果sockfd没有绑定到一个地址,connect会给调用者绑定一个默认地址
6.setsockopt(进阶)(简介)
- **功能:**设置套接字描述符的属性
- 原型:
int setsockopt(int sockfd, int level, int optname, const void *optval, socklen_t optlen);
- 参数:
- **sockfd:**要设置的套接字描述符
- level:选项定义的层次,或为特定协议的代码(如IPv4,IPv6,TCP,SCTP),或为通用套接字代码(SOL_SOCKET)
- **optname:**选项名,level对应的选项,一个level对应多个选项,不同选项对应不同功能
- **optval:**指向某个变量的指针,该变量是要设置新值的缓冲区,可以是一个结构体,也可以是普通变量
- **非0:**打开功能
- 0**:**关闭功能
- optlen**:**optval的长度
- **返回值:**成功返回0,失败返回-1
- 说明:选项字段很多,需要的去查文档即可
- 实用举例:
// 将IP和端口设置为,关闭服务端后可立即复用
int opt = 1;
setsockopt(listensock, SOL_SOCKET, SO_REUSEADDR | SO_REUSEPORT, &opt, sizeof(opt));
2.地址转换函数
0.IP地址的表现形式
- 字符串IP:类似于127.0.0.1这种字符串形式的IP地址,叫做基于字符串的点分十进制IP地址
- **整数IP:**IP地址在进行网络传输时所用的形式,用一个32位的整数来表示IP地址
1.字符串IP转整数IP
- 功能:一次性完成以下两件事
- 将点分十进制字符串风格ip地址 --> 4字节
- 4字节主机序列 --> 网络序列
- 原型:
in_addr_t inet_addr(const char *strptr);
- **参数:strptr:**待转换的字符串IP
- **返回值:**成功返回转换后的整数IP,失败返回INADDR_NONE(-1)
- 原型:
int inet_aton(const char* strptr, struct in_addr* addrptr);
- 参数:
- **strptr:**待转换的字符串IP
- **addrptr:**输出型参数,存放着转换后的整数IP
- **返回值:**成功返回非0,失败返回0
- 参数:
- 原型:
int inet_pton(int family, const char *strptr, void *addrptr);
- 参数:
- family**:**协议家族
- strptr**:**待转换的字符串IP
- addrptr**:**输出型参数,存放着转换后的整数IP
- **返回值:**成功返回1,失败返回0
- 参数:
2.整数IP转字符串IP
- 功能:4字节的网络序列的IP --> 本主机的点分十进制字符串风格的IP,方便显示
- *原型:char inet_ntoa(struct in_addr addrptr)
- **参数:addrptr:**待转换的整数IP
- **返回值:**成功返回转换后的字符串IP
- 原型:
const char *inet_ntop(int family, const void *src, char *dest, size_t len);
- 参数:
- family**:**协议家族
- src**:**待转换的整数IP
- dest**:**输出型参数,转换后的字符串IP
- len**:**用于指明strptr中可用的字节数
- 参数:
- 返回值:
- 成功,返回指向dest的非空指针
- 失败,返回nullptr
3.关于inet_ntoa()
-
这个函数返回了一个char*,很显然是这个函数自己在内部为我们申请了一块内存来保存ip的结果
- 那么是否需要调用者手动释放呢?
- man手册上说,inet_ntoa函数,是把这个返回结果放到了静态存储区,这个时候不需要我们手动进行释放
- man手册上说,inet_ntoa函数,是把这个返回结果放到了静态存储区,这个时候不需要我们手动进行释放
- 那么是否需要调用者手动释放呢?
-
那么问题来了,如果多次调用这个函数,会有什么样的效果呢?
- 结果如下
- 因为inet_ntoa把结果放到自己内部的一个静态存储区,这样第二次调用时的结果会覆盖掉上一次的结果
int main()
{
struct sockaddr_in addr1;
struct sockaddr_in addr2;
addr1.sin_addr.s_addr = 0;
addr2.sin_addr.s_addr = 0xffffffff;
char *ptr1 = inet_ntoa(addr1.sin_addr);
char *ptr2 = inet_ntoa(addr2.sin_addr);
printf("ptr1:%s, ptr2:%s\n", ptr1, ptr2);
return 0;
}
result:ptr1:255.255.255.255, ptr2:255.255.255.255
- 如果有多个线程调用 inet_ntoa,是否会出现异常情况呢?
- 在APUE中,明确提出inet_ntoa不是线程安全的函数
- 在多线程环境下,推荐使用inet_ntop,这个函数由调用者提供一个缓冲区保存结果,可以规避线程安全问题
4.注意:
- 最常用的两个转换函数是inet_addr和inet_ntoa,因为这两个函数足够简单
- 这两个函数的参数就是需要转换的字符串IP或整数IP,而这两个函数的返回值就是对应的整数IP和字符串IP。
- 其中inet_pton和inet_ntop函数不仅可以转换IPv4的in_addr,还可以转换IPv6的in6_addr,因此这两个函数中对应的参数类型是void*
3.使用函数
1.sendto()
- 功能:UDP下,客户端/服务器发送数据
- 原型:
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen);
- 参数:
- **sockfd:**对应操作的文件描述符,表示从该文件描述符索引的文件当中读取数据
- **buf:**待写入数据的存放位置
- **len:**期望写入数据的字节数
- **flags:**写入的方式,一般设置为0,表示阻塞写入
- **dest_addr:**输出型参数,对端网络相关的属性信息
- **addrlen:**传入dest_addr结构体的长度
- **返回值:**成功返回实际写入的字节数,失败返回-1.同时错误码被设置
2.recvfrom()
- 功能:UDP下,客户端/服务器读取数据
- 原型:
ssize_t recvfrom(int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *src_addr, socklen_t *addrlen);
- 参数:
- **sockfd:**对应操作的文件描述符,表示从该文件描述符索引的文件当中读取数据
- **buf:**读取数据的存放位置
- **len:**期望读取数据的字节数
- **flags:**读取的方式,一般设置为0,表示阻塞读取
- **src_add:**输出型参数,对端网络相关的属性信息
- **addrlen:**输入输出型参数,调用时传入期望读取的src_addr结构体长度,返回时代表实际读到的src_addr结构体的长度
- **返回值:**成功返回实际读到的字节数,失败返回-1,同时错误码被设置
3.send() && write()
- 功能:TCP下,客户端/服务器发送数据
- 原型:
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
- 参数:
- **sockfd:**对应操作的文件描述符,表示从该文件描述符索引的文件当中读取数据
- **buf:**待写入数据的存放位置
- **len:**期望写入数据的字节数
- **flags:**读取的方式,一般设置为0,表示阻塞读取
- **返回值:**成功返回实际写入的字节数,失败返回-1,同时错误码被设置
4.recv() && read()
- 功能:TCP下,客户端/服务器读取数据
- 原型:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
- 参数:
- **sockfd:**对应操作的文件描述符,表示从该文件描述符索引的文件当中读取数据
- **buf:**读取数据的存放位置
- **len:**期望读取数据的字节数
- **flags:**读取的方式,一般设置为0,表示阻塞读取
- 返回值:
- 如果返回值大于0,则表示本次实际读取到的字节个数
- 如果返回值等于0,则表示对端已经把连接关闭了
- 如果返回值小于0,则表示读取时遇到了错误
5.popen()
- 功能:
- 创建一个连接到另一个进程的管道,然后读其输出或向其输入端发送数据
- 执行结果可以通过FILE*指针进行读取
- 原型:
FILE *popen(const char *command, const char *type);
- 参数:
- command:一个指向以NULL结束的shell命令字符串指针,这行命令将被传到bin/sh并使用**-c**标志,shell将执行这个命令
- type:只能是读和写的一种
- 如果是"r"则文件指针连接到command的标准输出,则返回的文件指针是可读的
- 如果是"w"则文件指针连接到command的标准输入,则返回的文件指针是可写的
- **返回值:**成功返回文件指针,失败返回nullptr
- **原理:**创建一个管道,fork一个子进程,关闭未使用的管道端,执行一个shell命令,然后等待命令终止
- 执行command --> pipe() fork() 子进程执行(exec)*
6.pclose()
- 原型:
int pclose(FILE *stream);
- **参数:stream:**popen()返回的文件指针
4.实际使用时注意事项
1.INADDR_ANY
- 给服务器设置IP时,若手动设置,就代表绑定一个特定的网卡(一个网卡就会有一个IP地址)
- 如果绑定了一个具体的网卡地址,那么就只监听这个网卡
- 如果给IP设置为INADDR_ANY(0.0.0.0),则可以监听从本地任意一块网卡来的数据
- 另外,代码的可移植性高,因为具体的网卡地址,换一个机器就得要变化,0.0.0.0没有这个烦恼
- 所以一般情况下,除非就是需要设定某个特定IP,否则设置INADDR_ANY就是一个比较好的选择,可参考以下代码
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
2.sockaddr初始化
- 将整个结构清零
- 设置地址类型为AF_INET
- 设置IP和PORT
struct sockaddr_in local;
memset(&local, 0, sizeof local);
local.sin_family = AF_INET;
local.sin_port = htons(_port);
local.sin_addr.s_addr = _ip.empty() ? INADDR_ANY : inet_addr(_ip.c_str());
3.客户端需不需要bind?
- 需要,但客户端不需要用户自己显式手动bind端口信息,OS会自动选择并分配
- 如果非要自己手动bind,会发生什么? – 会出现以下两种情况
- 此端口正好没有别的客户端bind,那么此端口被调用bind的客户端占有,以后其他客户端无法占用此端口
- 其他的客户端提前占用了这个端口,因为手动bind端口是被绑定死的,则客户端无法启动
- 当客户端首次发消息给服务器的时候,,OS会自动给客户端bind它的IP和端口
4.connected socket vs listening socket
- **listening socket:**一个服务器通常仅仅创建一个监听套接字,它在该服务器的生命期内一直存在
- 没有关联到客户端连接,而是继续保持监听状态并接受其他连接请求
- connected socket:
- 和listening socket具有相同的套接字类型和地址族
- 内核为每个由服务器进程接受的客户连接创建一个已连接套接字
- 当服务器完成对某个给定客户的服务时,相应的已连接套接字就被关闭
- 该描述符连接到调用connect的客户端
- 所以我也更愿意称之为**“service socket”**
- 总结:
- listening socket只获取连接,获取到连接后,他就不再管了,而是继续去获取新连接
- connected socket是那个真正为客户端提供服务的文件描述符
5.TCP协议通讯流程
0.基于TCP协议的客户端/服务器程序的一般流程
1.服务器初始化
- 调用socket,创建文件描述符
- 调用bind,将当前的文件描述符和ip/port绑定在一起;如果这个端口已经被其他进程占用了,就会bind失败
- 调用listen,声明当前这个文件描述符作为一个服务器的文件描述符,为后面的accept做好准备
- 调用accecpt,并阻塞,等待客户端连接过来
2.客户端建立连接的过程
- 调用socket,创建文件描述符
- 调用connect,向服务器发起连接请求
- connect会发出SYN段并阻塞等待服务器应答(第一次)
- 服务器收到客户端的SYN,会应答一个SYN-ACK段表示"同意建立连接"(第二次)
- 客户端收到SYN-ACK后会从connect()返回,同时应答一个ACK段(第三次)
- 这个建立连接的过程,通常称为三次握手
3.数据传输的过程
- 建立连接后,TCP协议提供全双工的通信服务
- 所谓全双工的意思是,在同一条连接中,同一时刻,通信双方可以同时写数据
- 相对的概念叫做半双工,同一条连接在同一时刻,只能由一方来写数据
- 服务器从accept()返回后立刻调用read(),读socket就像读管道一样,如果没有数据到达就阻塞等待
- 这时客户端调用write()发送请求给服务器,服务器收到后从read()返回,对客户端的请求进行处理,在此期间客户端调用read()阻塞等待服务器的应答
- 服务器调用write()将处理结果发回给客户端,再次调用read()阻塞等待下一条请求
- 客户端收到后从read()返回,发送下一条请求,如此循环下去
4.断开连接的过程
- 如果客户端没有更多的请求了,就调用close()关闭连接,客户端会向服务器发送FIN段(第一次)
- 此时服务器收到FIN后,会回应一个ACK,同时read会返回0(第二次)
- read返回之后,服务器就知道客户端关闭了连接,也调用close关闭连接,这个时候服务器会向客户端发送一个FIN(第三次)
- 客户端收到FIN,再返回一个ACK给服务器(第四次)
- 这个断开连接的过程,通常称为四次挥手
5.TCP vs UDP
- 可靠传输 vs 不可靠传输
- 有连接 vs 无连接
- 字节流 vs 数据报
6.其他
1.netstat
-
运行服务端后,可以通过netstat命令来查看当前网络的状态,常用选项如下
-n 直接使用ip地址,而不通过域名服务器 -l 显示监控中的服务器的socket -t 显示TCP传输协议的连线情况 -u 显示UDP传输协议的连线情况 -p 显示正在使用socket的程序识别码和程序名称