网络 编程
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; // 设置地址族为IPv4sock.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()
—— 创建通信端点
#include <sys/types.h>
#include <sys/socket.h>
//create an endpoint for communication
int socket(int domain, // 协议:AF_INET (IPv4)、AF_INET6 (IPV6)....int type, // 套接字类型: SOCK_STREAM (TCP)、SOCK_DGRAM (UDP)....int protocol// 协议:IPPROTO_TCP (TCP)、IPPTOTO_UDP (UDP)...; 当protocol为0时,会自动选择type类型对应的默认协议。
);
// 返回值: 返回值是一个非负整数, 代表一个文件描述符,用于标识创建的套接字,并通过这个描述符进行后续的网络I/O操作。
作用:创建一个套接字(通信端点),返回文件描述符(
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()
—— 绑定本地地址
#include <sys/types.h>
#include <sys/socket.h>
//bind a name to a socket
int bind(int sockfd, // socket端点文件描述符const struct sockaddr *addr,// 要绑定的IP地址和端口号socklen_t addrlen // 指定的addr代表结构体长度,确保bind函数可以正确解析给定的地址信息:sizeod(addr)
);
//返回值: 成功时返回0。失败返回-1
作用:将套接字绑定到本地 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()
—— 监听连接(服务端)
#include <sys/types.h>
#include <sys/socket.h>
//listen for connections on a socket
int listen(int sockfd, // socket端点文件描述符int backlog // 这个参数指定了套接字可以挂起的最大连接数
);
//返回值: 成功返回0, 失败返回-1
作用:将套接字设置为被动监听模式,等待客户端连接。
关键参数:
sockfd
:已绑定地址的套接字。backlog
:等待连接队列的最大长度(如5
)。
示例:
listen(sockfd, 5); // 允许最多 5 个连接排队
一旦启用了listen之后,操作系统就知道该套接字是服务端的套接字,操作系统内核就不再启用其发送和接收缓冲区(回收空间),转而在内核区维护两个队列结构: 半连接队列和全连接队列。
半连接队列用于管理成功第一次握手的连接
全连接队列用于管理已经完成三次握手的队列。
需要注意的是, 如果队列已经满了,那么服务端受到任何再发起的连接都会直接丢弃(大部分操作系统中服务端不会回复,以方便客户端自动重传)
4. connect()
—— 发起连接(客户端)
#include <sys/types.h>
#include <sys/socket.h>
//initiate a connection on a socket
int connect(int sockfd, // socket端点文件描述符const struct sockaddr *addr,// 目标服务器的地址和端口信息socklen_t addrlen // 指定的addr代表结构体长度,确保bind函数可以正确解析给定的地址信息
);
// 返回值: 成功0, 失败-1
作用:客户端向服务端的 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));
5. accept()
—— 接受连接(服务端)
#include <sys/types.h>
#include <sys/socket.h>
// accept a connection on a socket
int accept(int sockfd, // socket端点文件描述符struct sockaddr *addr, // 用来获取连接对端/客户端的地址信息。如果不需要对端的地址信息, 可设参数为NULLsocklen_t *addrlen // 用来获取addr结构体的大小。如果使用addr/非NULL,那么addrlen必须设置addr的大小/sizeof(addr); 如果addr是NULL,addrlen也必须是NULL。
);
// 返回值: 成功则返回一个新的套接字文件描述符,用于与客户端通信。失败返回-1。
作用:从监听队列中接受一个客户端连接,返回一个新的套接字(
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段。
accept对应TCP三次握手的第几次?
accept()
对应 TCP 三次握手的 第三次,但它的实际作用是在三次握手 完成之后 从已建立连接的队列中取出一个客户端套接字。
步骤 | 服务端状态 |
---|---|
第一次握手(SYN) | SYN_RCVD |
第二次握手(SYN+ACK) | 半完成队列 |
第三次握手(ACK) | 连接完成,进入已接受队列 |
accept() 调用 | 从队列中取出连接 |
简记:accept()
是握手完成后的“领证”环节,而非“求婚”过程。
6.Send和Recv
发送和获取数据: 客户端OR服务端使用 Send和 Recv
用于发送和接收TCP数据。 (man 2 send) (man 2 recv)
发送:
#include <sys/types.h>
#include <sys/socket.h>
// send a message on a socket
ssize_t send(int sockfd, // socket端点文件描述符const void *buf, // 指向要发送数据的缓冲区的指针size_t len, // buf中数据的长度,以字节为单位int flags // 用于指定发送操作的额外选项: MSG_OOB(发送紧急数据)、MSG_DONTROUTE(不经过路由器直接发送到本地网络上的目的地)...大多数情况下,flags参数设置为0。
);
// 返回值: 成功返回实际发送的字节数。失败返回-1
接收:
#include <sys/types.h>
#include <sys/socket.h>
// receive a message from a socket
ssize_t recv(int sockfd, // socket端点文件描述符void *buf, // 指向读出数据存放的缓冲区的指针size_t len, // buf的长度,以字节为单位int flags // 定接收行为的标志位:MSG_PEEK(查看数据但不从系统缓冲区中移除)、MSG_WAITALL(等待所有请求的数据才返回)...大多数情况下,flags设置为0。
);
// 返回值: 成功时返回实际读取的字节数。如果连接已经关闭返回0(对方close: 四次挥手)。读取失败返回-1
- 需要注意的是,
Send和 Recv
函数只是将数据在用户态空间和内核态的缓冲区之间进行传输。 send时
将数据拷贝到内核态并不意味着会马上传输,而是由操作系统
决定根据合适的时机, 再由内核协议栈按照协议的规范进行分节发送。(通常缓冲区如果数据过多会分节成 MSS的大小,然后根据窗口条件传输到网络层之中)- 对于发送和接收数据, 使用
Read和Write函数
可以实现同样的效果(本质是相同的),相当于flags 参数为0。
需要特别注意的是:
- send和recv的次数和网络上传输的TCP数据段的数量没有关系,多次的send和recv可能只需要一次TCP段的传输。
- 另外一方面, TCP是一种流式的通信协议,消息是以字节流的方式在信道中传输,这就意味着一个重要的事情, 消息和消息之间是没有边界的。
- 在不加额外约定的情况下,通信双方并不知道发送和接收到底有没有接收完一个消息,有可能多个消息会在一次传输中被发送和接收(江湖俗称"粘包"),也有有可能一个消息需要多个传输才能被完整的发送和接收(江湖俗称"半包")。
7.close
关闭连接: 客户端
或者服务器
使用close函数
关闭服务器. (man 2 close)
#include <unistd.h>
// close a file descriptor
int close(int fd // socket端点文件描述符
);
// 返回值: 关闭成功返回0, 失败返回-1
客户端
或者服务器
使用close函数
关闭连接的时候, 可能还有数据留在发送缓冲区中未被发送,close
操作会试图发送这些数据。close函数
给连接的对端发送FIN包
用于断开连接的四次挥手, 等待连接的另一端也发送FIN包,并且本端回应ACK确认关闭。- 释放端口等资源。
示例
服务端 | 客户端 |
---|---|
1. socket() 创建套接字 | 1. socket() 创建套接字 |
2. bind() 绑定地址和端口 | |
3. listen() 开始监听 | 4. connect() 连接服务端 |
5. accept() 接受客户端连接 | |
6. 用 recv/send 与客户端通信 | 6. 用 send/recv与服务端通信 |
7.close关闭连接 | 7.close关闭连接 |
客户端
#include <testfun.h>int main(){char *sourceIP = "192.168.106.129";char *sourcePort = "8080";int socketFd = socket(AF_INET, SOCK_STREAM, 0);// 方式一: inet_addr // 把 点分十进制, 转成in_addr_t类型(网络IP), 把其存储到结构体in_addr类型中//in_addr_t addrTIP = inet_addr(sourceIP);//struct in_addr inAddr;//inAddr.s_addr = addrTIP;// 方式二: inet_atonstruct in_addr inAddr;inet_aton(sourceIP, &inAddr);// 把端口转为int类型int sourcePortInt = atoi(sourcePort);// 把端口号: 有主机字节序, 转为网络字节序int sourcePortNet = htons(sourcePortInt);// 构建"struct sockaddr"类型struct sockaddr_in socketAddr;socketAddr.sin_family = AF_INET;socketAddr.sin_addr = inAddr;socketAddr.sin_port = sourcePortNet;// 客户端向服务器发起建立连接请求int res_connect = connect(socketFd, (struct sockaddr *)&socketAddr, sizeof(socketAddr));ERROR_CHECK(res_connect, -1, "connect");// while(1){char buf[60] = {0};// 读取标准输入 read(STDIN_FILENO, buf, sizeof(buf)-1);// 把标准输入, 发送给服务器int res_send = send(socketFd, buf, sizeof(buf), 0);ERROR_CHECK(res_send, -1, "send");char buf2[60] = {0}; // 读取对方输入int res_recv = recv(socketFd, buf2, sizeof(buf2), 0);ERROR_CHECK(res_recv, -1, "res_recv");ERROR_CHECK(res_recv, 0, "other close");// 打印到标准输出write(STDOUT_FILENO, buf2, sizeof(buf2));}close(socketFd); return 0;
}
服务端
#include <testfun.h>int main(){char *sourceIP = "192.168.106.129";char *sourcePort = "8080";int socketFd = socket(AF_INET, SOCK_STREAM, 0);// 方式一: inet_addr // 把 点分十进制, 转成in_addr_t类型(网络IP), 把其存储到结构体in_addr类型中//in_addr_t addrTIP = inet_addr(sourceIP);//struct in_addr inAddr;//inAddr.s_addr = addrTIP;// 方式二: inet_atonstruct in_addr inAddr;inet_aton(sourceIP, &inAddr);// 把端口转为int类型int sourcePortInt = atoi(sourcePort);// 把端口号: 有主机字节序, 转为网络字节序int sourcePortNet = htons(sourcePortInt);// 构建"struct sockaddr"类型struct sockaddr_in socketAddr;socketAddr.sin_family = AF_INET;socketAddr.sin_addr = inAddr;socketAddr.sin_port = sourcePortNet;// bind:绑定端口int res_bind = bind(socketFd,(struct sockaddr *)&socketAddr, sizeof(socketAddr));ERROR_CHECK(res_bind, -1, "bind");// listen:监听端口listen(socketFd, 10);// accept: 获取连接int connectFd = accept(socketFd, NULL, NULL);while(1){char buf2[60] = {0}; // 读取对方输入int res_recv = recv(connectFd, buf2, sizeof(buf2), 0);ERROR_CHECK(res_recv, -1, "recv");ERROR_CHECK(res_recv, 0, "other close");// 打印到标准输出write(STDOUT_FILENO, buf2, sizeof(buf2));char buf[60] = {0};// 读取标准输入 read(STDIN_FILENO, buf, sizeof(buf)-1);// 把标准输入, 发送给服务器int res_send = send(connectFd, buf, sizeof(buf), 0);ERROR_CHECK(res_send, -1, "send");}close(socketFd); return 0;
}
UDP通信流程
Sendto和Recvfrom
发送和获取数据: 客户端OR服务端使用 Sendto和 Recvfrom
用于发送和接收UDP数据。 (man 2 sendto) (man 2 recvfrom)
#include <sys/types.h>
#include <sys/socket.h>
// send a message on a socket
ssize_t sendto(int sockfd, // socket端点文件描述符const void *buf,// 指向要发送数据的缓冲区的指针size_t len, // buf中数据的长度,以字节为单位int flags, // 发送操作的额外选项: 紧急发送、直发...大多数情况下,flags参数设置为0。const struct sockaddr *dest_addr, // 要发送的目的地址的IP地址和端口号socklen_t addrlen // 指定的dest_addr代表结构体长度,确保bind函数可以正确解析给定的地址信息:sizeod(dest_addr)
);
// 返回值: 成功时返回实际发送的字节数。这个数值可能会小于在length参数中指定的数值,表示只有部分数据被发送。失败时,返回-1。
#include <sys/types.h>
#include <sys/socket.h>
//receive a message from a socket
ssize_t recvfrom(int sockfd, // socket端点文件描述符void *buf, // 指向读出数据存放的缓冲区的指针size_t len, // buf中数据的长度,以字节为单位int flags, // 接收行为的标志位, 默认0struct sockaddr *src_addr, // 用于存储发送方的地址信息socklen_t *addrlen // 指定的src_addr代表结构体长度,确保bind函数可以正确解析给定的地址信息: &sizeod(src_addr)
);
// 返回值: 成功时,返回接收到的字节数; 失败时返回-1。
- 和基于
TCP
的send和recv函数
不同的是, 基于UDP
的sendto和recvfrom函数
携带了地址信息, 用于确定目的地址和获取接收的信息的来源地址。 - 这也就意味着, 在使用UDP进行无连接的通信时, 因为没有建立连接的过程,所以必须总是由客户端先调用sendto发送消息给服务端,这样服务端才能知道对端的地址信息,从进入后续的通信。
- 在使用UDP进行无连接的通信时, 因为是无连接的, 所以客户端或服务端关闭, 对方无法直接感知。
- 需要注意的是类型和参数: socklen_t *addrlen (非int) (socklen_t 本身是unsigned int)