Socket简要
Socket 即套接字是指网络中 一种用来建立连接、网络通信的设备,用户创建了Socket之后,可以通过其发起或者接受TCP连接、可以向TCP的发送和接收缓冲区当中读写TCP数据段,或者发送UDP文本。
大小端转化
TCP/IP协议规定,当数据在网络中传输的时候,一律使用网络字节序即大端法
。而"一般"主机比较多的用小端法存储数据(以x86,x64架构为例, 即大多数个人电脑和服务器,包括 Intel 和 AMD 的处理器)。
根据上面所述: 网络字节序使用大端法, 主机字节序使用小端法, 我们需要对大小端进行转化。
在Linux中定义了相关转化的函数。( man htonl )
#include <arpa/inet.h>
//convert values between host and network byte order
uint32_t htonl(uint32_t hostlong);//host to network long
uint16_t htons(uint16_t hostshort);//host to network short
uint32_t ntohl(uint32_t netlong);//network to host long
uint16_t ntohs(uint16_t netshort);//network to host short
// uint32_t: 无符号 int
// uint16_t: 无符号 short int
点分十进制转化
在Socket编程中POSIX 套接字接口设计上提供了多个结构体, 以供我们适用在不同的情况。
比如sockaddr
结构体, 这是一种通用的地址结构,它可以通用的描述IPv4和IPv6的结构,而且基本上所有涉及到地址的接口都使用了该类型作为参数。(比如: 上面addrinfo
结构体中, sockaddr *ai_addr
参数, 就使用sockaddr
类型 )
但是由于它定义的过于通用, 它直接把一个具体的IP地址和端口信息混在一起, 使用起来过于麻烦; 我们需要更具体的IPV4和IPV6类型, 所以POSIX标准又更进一步的定义了sockaddr_in
和 sockaddr_in6
分别用于描述IPV4和IPV6类型。
并且, 在需要通用地址参数的函数调用中(例如,bind()
、connect()
、accept()
等, 他们需要sockaddr
类型的参数),我们可以直接将 sockaddr_in
或 sockaddr_in6
结构体的指针转换为 sockaddr
类型使用, 这种转换是安全的。
在日常生活中我们更习惯与把IP地址书写成点分十进制, eg: 192.168.10.100...; 当我们需要通过Socket进行网络交互的时候, 我们怎么把它转化为合适的类型?
在POSIX 套接字接口设计上提供了结构体in_addr
和in6_addr
, 分别用来存储IPv4和IPv6类型
的IP地址( man inet_aton)。以IPv4为例。(man 7 ip)
struct sockaddr_in {
sa_family_t sin_family; // 地址类型: AF_INET (IPv4)
in_port_t sin_port; // 端口号: 注意in_port_t实际类型short int (网络字节序)
struct in_addr sin_addr; // IP地址: internet address
};
struct in_addr {
in_addr_t s_addr; // in_addr_t -> uint32_t -> 无符号int
}
这也就意味着, 我们需要一套把点分十进制
的IP地址, 转为无符号int
的手段。POSIX 套接字接口同时也设计了一套函数来实现该问题。(man inet_aton)
#include <header.h> // 包含自定义头文件(可能是项目相关的头文件)
#include <arpa/inet.h> // 提供IP地址转换和网络字节序操作的函数(如inet_addr、htons)
int main(int argc, char** argv) { // 主函数,接收命令行参数
char *ip = "196.168.106.103"; // 定义IP地址字符串
char *port = "900"; // 定义端口号字符串
uint16_t port2 = htons(atoi(port)); // 将端口号转换为网络字节序
uint32_t ip2 = inet_addr(ip); // 将IP地址转换为网络字节序格式
struct sockaddr_in sock; // 定义IPv4地址结构体
sock.sin_family = AF_INET; // 设置地址族为IPv4
sock.sin_port = port2; // 设置端口号(网络字节序)
sock.sin_addr.s_addr = ip2; // 设置IP地址(网络字节序)
int sockfd = socket(AF_INET, SOCK_STREAM, 0); // 创建TCP套接字
if (bind(sockfd, (struct sockaddr*)&sock, sizeof(sock)) == -1) {
perror("bind failed");//如果bind失败进行错误处理
close(sockfd);
return 1;
}
return 0; // 程序正常退出
}
基于TCP的Socket通信流程
这些函数是 TCP Socket 编程的核心,共同完成网络通信的建立和管理。以下是它们的基本作用和典型调用顺序:
1. socket()
—— 创建通信端点
作用:创建一个套接字(通信端点),返回文件描述符(
sockfd
)。关键参数:
domain
:协议族(如AF_INET
对应 IPv4,AF_INET6
对应 IPv6)。type
:通信类型(如SOCK_STREAM
对应 TCP,SOCK_DGRAM
对应 UDP)。protocol
:通常填0
(自动选择默认协议)。
示例:
int sockfd = socket(AF_INET, SOCK_STREAM, 0);
实际上, socket函数本质是在内核态中创建了一个对象
。这个函数虽然返回一个文件描述符来标识这个对象 但是它并不是通俗意义上的文件对象
在这个socket对象中, 包含了进行网络通信所需要的各种信息和状态(Eg: 地址族/Address Family, 类型/Type, 协议/Protocol, 地址/Socket Address ...)。
除了这些信息以外, 这个对象中还维护了两个极其重要的缓冲区输入缓冲区/SO_RCVBUF
和输出缓冲区/SO_SNDBUF
, 这两个缓冲区分别用于临时存储从网络接收的数据和待发送到网络的数据。
2. bind()
—— 绑定本地地址
作用:将套接字绑定到本地 IP 地址和端口(服务端必用,客户端通常不需要)。
关键参数:
sockfd
:socket()
返回的文件描述符。addr
:指向struct sockaddr
的指针(需填充IP
和端口
)。addrlen
:地址结构体的长度。
示例:
struct sockaddr_in addr; addr.sin_family = AF_INET; addr.sin_port = htons(8080); // 绑定端口 8080 addr.sin_addr.s_addr = INADDR_ANY; // 绑定所有本地 IP bind(sockfd, (struct sockaddr*)&addr, sizeof(addr));
ps1: 一般我们都是给服务端bind
, 那么客户端也可以bind
吗?
- 正常来讲客户端不需要bind; 客户端不bind操作系统都会分配一个临时的随机端口, 这已经足够使用了。
- 当然如果有特殊需求, 也可以对客户端进行bind, 用以指明发送和接收数据的IP和端口。
ps2: 服务端可不可以不bind?
- 如果服务端不进行bind操作, 一般操作系统都会分配一个临时的随机端口以供使用, 但是从逻辑上完全没有任何意义, 不允许这样操作。
3. listen()
—— 监听连接(服务端)
作用:将套接字设置为被动监听模式,等待客户端连接。
关键参数:
sockfd
:已绑定地址的套接字。backlog
:等待连接队列的最大长度(如5
)。
示例:
listen(sockfd, 5); // 允许最多 5 个连接排队
一旦启用了listen之后,操作系统就知道该套接字是服务端的套接字,操作系统内核就不再启用其发送和接收缓冲区(回收空间),转而在内核区维护两个队列结构: 半连接队列和全连接队列。
半连接队列用于管理成功第一次握手的连接
全连接队列用于管理已经完成三次握手的队列。
需要注意的是, 如果队列已经满了,那么服务端受到任何再发起的连接都会直接丢弃(大部分操作系统中服务端不会回复,以方便客户端自动重传)
4. accept()
—— 接受连接(服务端)
作用:从监听队列中接受一个客户端连接,返回一个新的套接字(
connfd
),用于与该客户端通信。关键参数:
sockfd
:处于监听状态的套接字。addr
:保存客户端地址信息(可填NULL
表示不关心)。addrlen
:地址结构体的长度(需初始化)。
示例:
struct sockaddr_in client_addr; socklen_t client_len = sizeof(client_addr); int connfd = accept(sockfd, (struct sockaddr*)&client_addr, &client_len);
- 需要特别注意的是, addrlen参数是一个传入传出参数,所以使用的时候(非NULL)需要主调函数提前分配好内存空间:sizeof(addr)
- accept 函数由服务端调用,用于从全连接队列中取出下一个已经完成的TCP连接(三次握手)。如果全连接队列为空(没有新的客户端成功三次握手),那么accept会陷入阻塞。 一旦全连接队列中到来新的连接,此时accept操作就会就绪 (注意: 这种就绪是
读就绪
)。 - 当accept执行完了之后,内核会创建一个新的套接字文件对象,该文件对象关联的文件描述符是accept的返回值,文件对象当中最重要的结构是一个发送缓冲区和接收缓冲区,可以用于服务端通过TCP连接发送和接收TCP段。
5. connect()
—— 发起连接(客户端)
作用:客户端向服务端的 IP 和端口 发起连接请求。
关键参数:
sockfd
:客户端套接字。serv_addr
:服务端地址(需提前填充服务端 IP 和端口)。addrlen
:地址结构体的长度。
示例:
struct sockaddr_in serv_addr; serv_addr.sin_family = AF_INET; serv_addr.sin_port = htons(8080); inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr); // 设置服务端 IP connect(sockfd, (struct sockaddr*)&serv_addr, sizeof(serv_addr));
典型调用顺序
服务端 | 客户端 |
---|---|
1. socket() 创建套接字 |
1. socket() 创建套接字 |
2. bind() 绑定地址和端口 |
|
3. listen() 开始监听 |
2. connect() 连接服务端 |
4. accept() 接受客户端连接 |
|
5. 用 connfd 与客户端通信 |
3. 用 sockfd 与服务端通信 |
accept对应TCP三次握手的第几次?
accept()
对应 TCP 三次握手的 第三次,但它的实际作用是在三次握手 完成之后 从已建立连接的队列中取出一个客户端套接字。
步骤 | 服务端状态 |
---|---|
第一次握手(SYN) | SYN_RCVD |
第二次握手(SYN+ACK) | 半完成队列 |
第三次握手(ACK) | 连接完成,进入已接受队列 |
accept() 调用 |
从队列中取出连接 |
简记:accept()
是握手完成后的“领证”环节,而非“求婚”过程。