参考:
套接字通信部分
《TCP/IP 网络编程》以及《TCP/IP网络编程》学习笔记
socket 编程
1. 字节序
字节序,顾名思义字节的顺序,就是大于一个字节类型的数据在内存中的存放顺序,也就是说对于单字符来说是没有字节序问题的,字符串是单字符的集合,因此字符串也没有字节序问题。
目前在各种体系的计算机中通常采用的字节存储机制主要有两种:Big-Endian
和 Little-Endian
,下面先从字节序说起。
Little-Endian -> 主机字节序 (小端)
- 数据的
低位字节
存储到内存的低地址位
, 数据的高位字节
存储到内存的高地址位
- 我们使用的PC机,数据的存储默认使用的是小端
Big-Endian -> 网络字节序 (大端)
- 据的
低位字节
存储到内存的高地址位
, 数据的高位字节
存储到内存的低地址位
套接字通信过程中操作的数据都是大端存储的,包括:接收/发送的数据、IP地址、端口
。
以 PC 机为例:
int a = 0x12345678; // 从左往右,是从高位到低位
char *p = (char *) &a;
printf("sizeof(int, char) = %d, %d\n", sizeof(int), sizeof(char));
for(int i = 0; i < sizeof(int); ++i) {
printf("%d %p : 0x%02x\n", i, p, *p);
p++;
}
// 运行结果
/*
sizeof(int, char) = 4, 1
0 000000000070fe10 : 0x78
1 000000000070fe11 : 0x56
2 000000000070fe12 : 0x34
3 000000000070fe13 : 0x12
*/
大小端转换函数
BSD Socket 提供了封装好的转换接口,方便程序员使用。包括从主机字节序到网络字节序的转换函数:htons、htonl;从网络字节序到主机字节序的转换函数:ntohs、ntohl。
// u:unsigned
// 16: 16位, 32:32位
// h: host, 主机字节序
// n: net, 网络字节序
// s: short
// l: int
// 主机字节序 -> 网络字节序
u_short htons (u_short hostshort );
u_long htonl ( u_long hostlong);
// 网络字节序 -> 主机字节序
u_short ntohs (u_short netshort );
u_long ntohl ( u_long netlong);
// linux函数, window上没有这两个函数
inet_ntop();
inet_pton();
// windows 和 linux 都使用, 只能处理ipv4的ip地址
// 点分十进制IP -> 大端整形
unsigned long inet_addr (const char FAR * cp); // windows
in_addr_t inet_addr (const char *cp); // linux
// 大端整形 -> 点分十进制IP
// window, linux相同
char* inet_ntoa(struct in_addr in);
2. IP 地址转换
虽然IP地址本质是一个整形数,但是在使用的过程中都是通过一个字符串来描述,下面的函数描述了如何将一个字符串类型的IP地址进行大小端转换:
主机字节序的IP地址 ---> 网络字节序
// 主机字节序的IP地址是字符串, 网络字节序IP地址是整形
int inet_pton(int af, const char *src, void *dst);
参数:
af
: 地址族(IP地址的家族包括ipv4和ipv6)协议,AF_INET
: ipv4格式的ip地址,AF_INET6
: ipv6格式的ip地址src
: 传入参数, 对应要转换的点分十进制的ip地址: 192.168.1.100dst
: 传出参数, 函数调用完成, 转换得到的大端整形IP被写入到这块内存中
返回值:成功返回1,失败返回0或者-1;返回0是异常, 说明src指向的不是一个有效的ip地址。
#include <arpa/inet.h>
// 将大端的整形数, 转换为小端的点分十进制的IP地址
const char *inet_ntop(int af, const void *src, char *dst, socklen_t size);
参数:
af
: 地址族协议,AF_INET
: ipv4格式的ip地址,AF_INET6
: ipv6格式的ip地址src
: 传入参数,指向存储了大端的整形IP地址
的内存dst
: 传出参数, 指向存储了小端的点分十进制的IP地址
的内存size
: 修饰 dst 参数的, 标记 dst 指向的内存中最多可以存储多少个字节
返回值:
- 成功: 返回指向 dst 对应的内存地址的指针, 通过返回值也可以直接取出转换得到的IP字符串
- 失败: NULL
3. sockaddr 数据结构
AF_LOCAL
:表示的是本地地址
,对应的是 Unix 套接字,这种情况一般用于本地 socket 通信,很多情况下也可以写成AF_UNIX
、AF_FILE
AF_INET
:因特网使用的 IPv4 地址AF_INET6
:因特网使用的 IPv6 地址
这里的 AF_
表示的含义是 Address Family
,但是很多情况下,我们也会看到以PF_
表示的宏
,比如 PF_INET
、PF_INET6
等,实际上 PF_
的意思是 Protocol Family
,也就是协议族
的意思。我们用 AF_xxx
这样的值来初始化 socket 地址
,用 PF_xxx
这样的值来初始化 socket
。我们在 <sys/socket.h>
头文件中可以清晰地看到,这两个值本身就是一一对应的。
通用套接字地址(struct sockaddr)
typedef unsigned short int sa_family_t;
struct sockaddr { //早期的 sockaddr
sa_family_t sa_family; /* adress family: AF_XXX */
char sa_data[14]; /* 14 bytes of protocol */
};
// struct sockaddr 很多网络编程API诞生早于IPv4协议,那时候都使用的是sockaddr结构体
// 为了向前兼容,现在sockaddr退化成了(void *)的作用,传递一个地址给函数
// 至于这个函数是sockaddr_in还是其他的,由地址族确定,然后函数内部再强制转化为所需的地址类型。
IPv4 套接字格式地址(struct sockaddr_in)
sin_len
成员表示地址结构的长度,它是一个无符号的八位整数。需要强调的是,这个成员并不是地址结构必须有的。假如没有这个成员,其所占的一个字节被并入到 sin_family
成员中;同时,在传递地址结构的指针时,结构长度需要通过另外的参数来传递。
sin_family
成员指代的是所用的协议族,在有 sin_len
成员的情况下,它是一个8
位的无符号整数;在没有 sin_len
成员的情况下,它是一个16
位的无符号整数。由于IP协议属于TCP/IP协议族,所以在这里该成员应该赋值为 AF_INET
。
typedef uint32_t in_addr_t;
struct in_addr { // IPv4地址
in_addr_t s_addr; /* 32-bit IPv4 address; 网络字节序 */
};
struct sockaddr_in { //IPv4的 sockaddr
// 这个成员并不是地址结构必须有的
uint8_t sin_len; /* length of structure(地址结构) (16字节) */
sa_family_t sin_family; /* AF_INET */
in_port_t sin_port; /* 16-bit TCP or UDP port number; 网络字节序 */
struct in_addr sin_addr; /* 32-bit IPv4 address; 网络字节序 */
// sin_zero成员是不使用的, 通常会将它置为0
// 它的存在只是为了与通用套接字地址结构 struct sockaddr 在内存中对齐
char sin_zero[8];/* unused */
};
由于sock API的实现早于ANSI C标准化,那时还没有 void *
类型,因此像 bind、accept
函数的参数都用 struct sockaddr *
类型表示, 在传递参数之前要强制转换一下如:
struct sockaddr_in servaddr;
bind(listen_fd, (struct sockaddr*)&servaddr, sizeof(servaddr));
本地套接字地址(struct sockaddr_un)
struct sockaddr_un {
unsigned short sun_family; /* 固定为 AF_LOCAL */
char sun_path[108]; /* 路径名 */
};
IPv6 套接字地址(struct sockaddr_in6)格式
整个结构体长度是 28 个字节,其中流控信息和域 ID 先不用管,这两个字段,一个在 glibc 的官网上根本没出现,另一个是当前未使用的字段。这里的地址族显然应该是 AF_INET6
,端口同 IPv4 地址一样,关键的地址从 32 位升级到 128 位,这个数字就大到恐怖了,完全解决了寻址数字不够的问题。
struct sockaddr_in6 {
sa_family_t sin6_family; /* 16-bit */
in_port_t sin6_port; /* 传输端口号 # 16-bit */
uint32_t sin6_flowinfo; /* IPv6流控信息 32-bit*/
struct in6_addr sin6_addr; /* IPv6地址 128-bit */
uint32_t sin6_scope_id; /* IPv6域ID 32-bit */
};
4. 套接字函数
使用套接字通信函数需要包含头文件 <arpa/inet.h>
,包含了这个头文件 <sys/socket.h>
就不用在包含了。
socket()
// 创建一个套接字
int socket(int domain, int type, int protocol);
参数:
- domain:地址族协议,
AF_INET
: 使用IPv4格式的ip地址,AF_INET6
: 使用IPv6格式的ip地址 - type:
SOCK_STREAM
: 使用流式的传输协议;SOCK_DGRAM
: 使用报式(报文)的传输协议 - protocol: 一般写 0 即可, 使用默认的协议,
SOCK_STREAM
: 流式传输默认使用的是 TCP ;SOCK_DGRAM
: 报式传输默认使用的 UDP -
- 因为有这种情况:同一协议族中存在多个数据传输方式相同的协议,所以还需要第三个参数
protocol
来指定具体协议。
- 因为有这种情况:同一协议族中存在多个数据传输方式相同的协议,所以还需要第三个参数
-
- 但是
PF_INET
(IPv4 协议族)下的SOCK_STREAM
传输方式只对应IPPROTO_TCP
一种协议,SOCK_DGRAM
传输方式也只对应IPPROTO_UDP
一种协议,所以参数protocol
只要设为 0 即可。
- 但是
- 返回值:成功: 可用于套接字
通信的文件描述符
;失败: -1
函数的返回值是一个文件描述符,通过这个文件描述符可以操作内核中的某一块内存,网络通信是基于这个文件描述符来完成的。
bind()
给创建好的套接字分配地址信息(IP地址和端口号)
// 将文件描述符和本地的IP与端口进行绑定
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd
: 文件描述符, 通过socket()
调用得到的返回值addr
: 传入参数, 要绑定的 IP 和端口信息需要初始化到这个结构体中,IP 和端口要转换为网络字节序addrlen
: 参数 addr 指向的内存大小,sizeof(struct sockaddr)
返回值:成功返回 0,失败返回 -1
TCP - listen()
把套接字转换成可接受状态,进入等待连接请求状态,此时的套接字才是服务器端套接字
,此时的由socket返回的文件描述符才是用于监听的文件描述符
。
// 给监听的套接字设置监听
int listen(int sockfd, int backlog);
参数:
sockfd
: 文件描述符, 可以通过调用socket()
得到,在监听之前必须要绑定bind()
backlog
: 同时能处理的最大连接要求,最大值为128
返回值:函数调用成功返回 0,调用失败返回 -1
等待连接请求状态
:当服务器在此状态下时,在调用 accept
函数受理连接请求前,请求会处于等待状态。注意:这里说的是让来自客户端的请求处于等待状态,以等待服务器端受理它们的请求。
连接请求等待队列
:还未受理的连接请求在此排队,backlog
的大小决定了队列的最大长度,一般频繁接受请求的 Web 服务器的 backlog
至少为 15。
TCP - accept()
accept
函数会受理连接请求等待队列中待处理的客户端连接请求,它从等待队列中取出 1 个连接请求,创建套接字并完成连接请求。如果等待队列为空,accpet 函数会阻塞
,直到队列中出现新的连接请求才会返回。
它会在内部产生一个新的套接字并返回其文件描述符,该套接字用于与客户端建立连接并进行数据 I/O
。新的套接字是在 accept 函数内部自动创建的,并自动与发起连接请求的客户端建立连接。
accept 执行完毕后会将它所受理的连接请求对应的客户端地址信息存储到第二个参数 addr 中。
// 等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
参数:
sockfd
:监听的文件描述符
addr
: 传出参数, 里边存储了建立连接的客户端的地址信息addrlen
: 传入传出参数,用于存储addr指向的内存大小
返回值:函数调用成功,得到一个文件描述符, 用于和建立连接的这个客户端通信,调用失败返回 -1
这个函数是一个阻塞函数,当没有新的客户端连接请求的时候,该函数阻塞;当检测到有新的客户端连接请求时,阻塞解除,新连接就建立了,得到的返回值也是一个文件描述符,基于这个文件描述符就可以和客户端通信了。
read & recv
// 接收数据
ssize_t read(int sockfd, void *buf, size_t size);
ssize_t recv(int sockfd, void *buf, size_t size, int flags);
参数:
sockfd
:用于通信的文件描述符
,accept()
函数的返回值buf
: 指向一块有效内存, 用于存储接收数据size
: 参数 buf 指向的内存的容量flags
: 特殊的属性, 一般不使用, 指定为 0
返回值:
>0
:实际接收的字节数0
:对方断开了连接-1
:接收数据失败了
如果连接没有断开,接收端接收不到数据,接收数据的函数会阻塞等待数据到达,数据到达后函数解除阻塞,开始接收数据,当发送端断开连接,接收端无法接收到任何数据,但是这时候就不会阻塞了,函数直接返回0。
write & send
// 发送数据
ssize_t write(int fd, const void *buf, size_t len);
ssize_t send(int fd, const void *buf, size_t len, int flags);
- 参数:
fd
: 通信的文件描述符, accept() 函数的返回值buf
: 传入参数, 要发送的字符串len
: 要发送的字符串的长度flags
: 特殊的属性, 一般不使用, 指定为 0
返回值:
>0
:实际发送的字节数,和参数len是相等的-1
:发送数据失败了
write 函数和 Windows 的 send 函数并不会在完成向对方主机的数据传输时返回,而是在数据移到输出缓冲时。但是 TCP 会保证对输出缓冲数据的传输,所以说 write 函数在数据传输完成时返回。
connect()
向服务器端发送连接请求
// 成功连接服务器之后, 客户端会自动随机绑定一个端口
// 服务器端调用accept()的函数, 第二个参数存储的就是客户端的IP和端口信息
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd
: 通信的文件描述符, 通过调用socket()
函数就得到了addr
: 存储了要连接的服务器端的地址信息: IP 和 端口,这个IP和端口也需要转换为大端然后再赋值addrlen
: addr指针指向的内存的大小 sizeof(struct sockaddr)
返回值:连接成功返回 0,连接失败返回 -1
UDP - recvfrom()
理解:接收端本来是不知道发送端的地址的,但调用完 recvfrom 函数后,发送端的地址信息就会存储到参数 src_addr
指向的结构体中。
// 接收数据, 如果没有数据,该函数阻塞
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
参数:
sockfd
: 基于udp的通信的文件描述符buf
: 指针指向的地址用来存储接收的数据len
: buf 指针指向的内存的容量, 最多能存储多少字节flags
: 设置套接字属性,一般使用默认属性,指定为0
即可src_addr
: 发送数据的一端的地址信息,IP和端口都存储在这里边, 是大端存储的-
- 如果这个参数中的信息对当前业务处理没有用处, 可以指定为NULL, 不保存这些信息
addrlen
: 类似于accept()
函数的最后一个参数, 是一个传入传出参数-
- 传入的是
src_addr
参数指向的内存的大小, 传出的也是这块内存的大小
- 传入的是
-
- 如果
src_addr
参数指定为NULL, 这个参数也指定为NULL即可
- 如果
返回值:成功返回接收的字节数,失败返回 -1
UDP - sendto()
UDP 套接字不会保持连接状态,因此每次传输数据时都要添加目标地址信息
(相当于寄信前在信封上写收信地址)。
// 发送数据函数
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
参数:
sockfd
: 基于udp的通信的文件描述符buf
: 这个指针指向的内存中存储了要发送的数据len
: 要发送的数据的实际长度flags
: 设置套接字属性,一般使用默认属性,指定为0
即可dest_addr
: 接收数据的一端对应的地址信息, 大端的IP和端口addrlen
: 参数dest_addr
指向的内存大小
返回值:成功返回实际发送的字节数,调用失败返回-1
TCP 通信
TCP是一个面向连接的,安全的,流式传输协议,这个协议是一个传输层协议。
- 面向连接:是一个双向连接,通过三次握手完成,断开连接需要通过四次挥手完成。
- 安全:tcp通信过程中,会对发送的每一数据包都会进行校验, 如果发现数据丢失, 会自动重传
- 流式传输:发送端和接收端处理数据的速度,数据的量都可以不一致
创建套接字后,并不马上分为服务端和客户端。如果紧接着调用 bind
、listen
函数,将成为服务器端套接字;如果调用 connect
函数,将成为客户端套接字。
TCP 服务器端的两种文件描述符
- 监听的文件描述符:只需要有一个;负责检测客户端连接请求, 检测到之后调用
accept
建立新的连接 - 通信的文件描述符:负责和建立连接的客户端通信;如果有N个客户端和服务器建立了新的连接, 通信的文件描述符就有 N 个,每个客户端和服务器都对应一个通信的文件描述符
文件描述符对应的内存结构:
- 一个文件文件描述符对应两块内存, 一块内存是读缓冲区, 一块内存是写缓冲区
- 读数据: 通过
文件描述符
将内存中的数据读出, 这块内存称之为读缓冲区
- 写数据: 通过
文件描述符
将数据写入到某块内存中, 这块内存称之为写缓冲区
监听的文件描述符:
- 客户端的连接请求会发送到服务器端
监听的文件描述符
的读缓冲区
中 读缓冲区
中有数据, 说明有新的客户端连接- 调用
accept()
函数, 这个函数会检测监听文件描述符的读缓冲区
-
- 检测不到数据, 该函数阻塞
-
- 如果检测到数据, 解除阻塞, 新的连接建立
通信的文件描述符:
- 客户端和服务器端都有通信的文件描述符
- 发送数据:调用函数
write() / send()
,数据进入到内核中 -
- 数据并没有被发送出去, 而是将数据写入到了通信的文件描述符对应的写缓冲区中
-
- 内核检测到通信的文件描述符写缓冲区中有数据, 内核会将数据发送到网络中
- 接收数据: 调用的函数
read() / recv()
, 从内核读数据 -
- 数据如何进入到内核程序猿不需要处理, 数据进入到通信的文件描述符的读缓冲区中
-
- 数据进入到内核, 必须使用通信的文件描述符, 将数据从读缓冲区中读出即可
TCP 套接字中的 I/O 缓冲
在使用 read / write
函数对套接字进行读写数据时,实际上读写的是套接字输入 / 输出缓冲中的内容
。
套接字 I/O 缓冲的特性:
- I/O 缓冲在每个套接字中单独存在。
- I/O 缓冲在创建套接字时自动生成。
- 即使关闭套接字也会继续传递
输出缓冲
中遗留的数据。 - 关闭套接字将丢失
输入缓冲
中的数据。
为 Windows 套接字编程设置头文件和库
要在 Windows 上进行套接字编程,需要:
- 链接 ws2_32.lib 库。在 VS 中通过:项目–>属性–>配置属性–>链接器–>输入–>附加依赖项 添加 ws2_32.lib 库即可。
- 导入头文件 WinSock2.h。Windows 中有一个 winsock.h 和一个 WinSock2.h。其中 WinSock2.h 是较新版本,用来代替前者的。
- 实际上
client
在 windows 上还需要通过:项目–>属性–>配置属性–>C++ 将 SDL 检查设为否,否则使用旧函数inet_addr()
会报错。
将 Linux 平台下的示例代码转换成 Windows 平台:
- 通过
WSAStartup
、WSACleanup
函数初始化并清除套接字相关库 - 把数据类型和变量名切换为 Windows 风格
- 数据传输中用
recv / send
函数而非read / write
函数 - 关闭套接字时用
closesocket
函数而非close
函数
服务器端通信流程
- 创建用于监听的套接字, 这个套接字是一个文件描述符
- 将得到的监听的文件描述符和本地的 IP、 端口进行绑定
- 设置监听(成功之后开始监听, 监听的是客户端的连接)
- 等待并接受客户端的连接请求, 建立新的连接, 会得到一个新的文件描述符(通信的),
没有新连接请求就阻塞
- 通信,读写操作默认都是阻塞的
- 断开连接, 关闭套接字
服务器端:
本文代码给出的都是 windows 系统下的,在命令行中执行类似如下代码:
hello_server_win 5000 # 在端口 5000 处接收连接请求
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
void ErrorHandling(const char* message);
int main(int argc, char* argv[])
{
WSADATA wsaData;
SOCKET hServSock, hClntSock; // windows系统下的,SOCKET就是int
SOCKADDR_IN servAddr, clntAddr;
int szClntAddr;
char message[] = "Hello World!";
if (argc != 2) // 检查参数数量
{
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) // 初始化 Winsock 相关库
ErrorHandling("WSAStartup() error!");
hServSock = socket(PF_INET, SOCK_STREAM, 0); // 创建套接字
if (hServSock == INVALID_SOCKET)
ErrorHandling("socket() error");
// 网络地址信息初始化
memset(&servAddr, 0, sizeof(servAddr)); // 主要为了把zero数组清空
servAddr.sin_family = AF_INET; // 设置协议族
servAddr.sin_addr.s_addr = htonl(INADDR_ANY); // 设置 IP 地址 0.0.0.0
servAddr.sin_port = htons(atoi(argv[1])); // 设置端口号
if (bind(hServSock, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR) // 为套接字分配地址和端口
ErrorHandling("bind() error");
if (listen(hServSock, 5) == SOCKET_ERROR) // 使套接字转换为可接收连接的状态
ErrorHandling("listen() error");
szClntAddr = sizeof(clntAddr);
hClntSock = accept(hServSock, (SOCKADDR*)&clntAddr, &szClntAddr); // 接受连接请求,函数返回客户端的套接字
if (hClntSock == INVALID_SOCKET)
ErrorHandling("accept() error");
send(hClntSock, message, sizeof(message), 0); // 向客户端发送信息
closesocket(hClntSock); // 关闭客户端套接字
closesocket(hServSock); // 关闭服务器端套接字
WSACleanup(); // 注销 Winsock 相关库
return 0;
}
void ErrorHandling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
初始化服务器端套接字时应分配所属计算机的IP地址,因为初始化时使用的IP地址非常明确,那为何还要进行IP初始化呢?如前所述,同一计算机中可以分配多个IP地址,实际IP地址个数与计算机中安装的NIC的数量相等。即使是服务器端套接字,也需要决定应接收那个IP传来的(哪个NIC传来的)数据。因此服务器端套接字初始化过程要求IP地址信息。另外,如果只有一个NIC,直接使用 INADDR_ANY。
客户端的通信流程
在单线程的情况下客户端通信的文件描述符有一个, 没有监听的文件描述符
- 创建一个通信的套接字
- 连接服务器, 需要知道服务器绑定的IP和端口
- 进行通信
- 断开连接, 关闭文件描述符(套接字)
客户端:
在命令行中执行类似如下代码:
hello_client.exe 127.0.0.1 5000 # 向 127.0.0.1 5000 请求连接
#pragma execution_character_set("utf-8")
#include <stdio.h>
#include <stdlib.h>
#include <WinSock2.h>
void ErrorHandling(const char* message);
int main(int argc, char* argv[])
{
WSADATA wsaData;
SOCKET hSocket;
SOCKADDR_IN servAddr;
char message[30];
int strLen;
if (argc != 3)
{
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");
hSocket = socket(PF_INET, SOCK_STREAM, 0);
if (hSocket == INVALID_SOCKET)
ErrorHandling("socket() error");
memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_addr.S_un.S_addr = inet_addr(argv[1]); // 这里对书中代码进行了一些修改(源代码编译会报错,根据报错提示修改为当前代码)
servAddr.sin_port = htons(atoi(argv[2]));
if (connect(hSocket, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("connect() error!");
strLen = recv(hSocket, message, sizeof(message) - 1, 0);
if (strLen == -1)
ErrorHandling("read() error!");
printf("Message from server: %s \n", message);
closesocket(hSocket);
WSACleanup();
return 0;
}
void ErrorHandling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
迭代回声服务器端/客户端
回声服务器端:它会将客户端传输的字符串数据原封不动地传回客户端,像回声一样。
实现迭代服务器端:调用一次 accept 函数只会受理一个连接请求,如果想要继续受理请求,最简单的方法就是循环反复调用 accept 函数,在前一个连接 close 之后,重新 accept。
在不使用多进程/多线程情况下,同一时间只能服务于一个客户端。
迭代回声服务器端与回声客户端的基本运行方式:
- 服务器端同一时刻只与一个客户端相连接,并提供回声服务。
- 服务器端依次向 5 个客户端提供服务,然后退出。
- 客户端接收用户输入的字符串并发送到服务器端。
- 服务器端将接收到的字符串数据传回客户端,即”回声“。
- 服务器端与客户端之间的字符串回声一直执行到客户端输入 Q 为止。
服务器端:
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
void ErrorHandling(const char* message);
constexpr int BUF_SIZE = 1024;
int main(int argc, char* argv[])
{
WSADATA wsaData;
SOCKET hServSock, hClntSock;
SOCKADDR_IN servAddr, clntAddr;
int szClntAddr;
char message[BUF_SIZE];
int str_len;
if (argc != 2) // 检查参数数量
{
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) // 初始化 Winsock 相关库
ErrorHandling("WSAStartup() error!");
hServSock = socket(PF_INET, SOCK_STREAM, 0); // 创建套接字
if (hServSock == INVALID_SOCKET)
ErrorHandling("socket() error");
memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET; // 设置协议族
servAddr.sin_addr.s_addr = htonl(INADDR_ANY); // 设置 IP 地址
servAddr.sin_port = htons(atoi(argv[1])); // 设置端口号
if (bind(hServSock, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR) // 为套接字分配地址和端口
ErrorHandling("bind() error");
if (listen(hServSock, 5) == SOCKET_ERROR) // 使套接字转换为可接收连接的状态
ErrorHandling("listen() error");
szClntAddr = sizeof(clntAddr);
for (int i = 0; i < 5; ++i) {
hClntSock = accept(hServSock, (SOCKADDR*)&clntAddr, &szClntAddr); // 接受连接请求,函数返回客户端的套接字
if (hClntSock == INVALID_SOCKET)
ErrorHandling("accept() error");
else printf("Connnected client %d\n", i + 1);
while ((str_len = recv(hClntSock , message, BUF_SIZE, 0)) != 0) {
send(hClntSock, message, str_len, 0);
}
closesocket(hClntSock);
}
closesocket(hServSock); // 关闭服务器端套接字
WSACleanup(); // 注销 Winsock 相关库
return 0;
}
void ErrorHandling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
客户端:
#pragma execution_character_set("utf-8")
#include <stdio.h>
#include <stdlib.h>
#include <ws2tcpip.h>
void ErrorHandling(const char* message);
constexpr int BUF_SIZE = 1024;
int main(int argc, char* argv[])
{
WSADATA wsaData;
SOCKET hSocket;
SOCKADDR_IN servAddr;
char message[BUF_SIZE];
int str_len;
if (argc != 3)
{
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");
hSocket = socket(PF_INET, SOCK_STREAM, 0);
if (hSocket == INVALID_SOCKET)
ErrorHandling("socket() error");
memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_addr.S_un.S_addr = inet_addr(argv[1]); // 这里对书中代码进行了一些修改(源代码编译会报错,根据报错提示修改为当前代码)
servAddr.sin_port = htons(atoi(argv[2]));
if (connect(hSocket, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("connect() error!");
else printf("Connected....");
while (1) {
fputs("Input Message(Q to quit): ", stdout);
fgets(message, BUF_SIZE, stdin);
if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
break;
send(hSocket, message, strlen(message), 0);
str_len = recv(hSocket, message, BUF_SIZE - 1, 0);
if (str_len == -1)
ErrorHandling("read() error!");
message[str_len] = 0;
printf("Message from server: %s", message);
}
closesocket(hSocket);
WSACleanup();
return 0;
}
void ErrorHandling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
回声客户端存在的问题(拆包和粘包)
send(hSocket, message, strlen(message), 0);
str_len = recv(hSocket, message, BUF_SIZE - 1, 0);
在本章的回声客户端的实现中有上面这段代码,它有一个错误假设:每次调用 read、write 函数时都会执行实际的 I/O 操作。
但是注意:TCP 是面向连接的字节流传输,不存在数据边界。所以多次 write 的内容可能一直存放在发送缓存中,某个时刻再一次性全都传递到服务器端,这样的话客户端前几次 read 都不会读取到内容,最后才会一次性收到前面多次 write 的内容
。还有一种情况是服务器端收到的数据太大,只能将其分成多个数据包发送给客户端,然后客户端可能在尚未收到全部数据包时旧调用 read 函数
。
理解:问题的核心在于 write 函数实际上是把数据写到了发送缓存中,而 read 函数是从接收缓存读取数据。并不是直接对 TCP 连接的另一方进行数据读写。实际上就是没有考虑拆包和粘包的情况。
解决方法的核心: 提前确定接收数据的大小。
客户端上一次使用 write 从套接字发送了多少字节,紧接着就使用 read 从套接字读取多少字节。
// 接受完所以数据才打印
int recv_len = 0, recv_cnt;
while(recv_len < str_len) {
str_len = recv(hSocket, &message[recv_len], BUF_SIZE - 1, 0);
recv_len += recv_cnt;
}
message[str_len] = 0;
printf("Message from server: %s", message);
回声客户端可以提前知道接收的数据长度,但是更多情况下这不可能。这种情况下,要解决拆包和粘包的问题,就要定义应用层协议。
应用层协议实际就是在服务器端/客户端的实现过程中逐步定义的规则的集合。
在应用层协议中可以定好数据边界的表示方法、数据的长度范围等。
计算器服务端 / 客户端(实现应用层协议的例子)
为实现计算器功能,需要定义一个简单的应用层协议,用来约定在服务器端和客户端之间传输数据的规则。
协议内容包括:
- 客户端用 1 个字节整数形式传递操作数的个数。
- 客户端向服务器端传送的每个操作数占用 4 字节。
- 传递完操作数后紧跟着传递一个占用 1 字节的运算符。
- 操作符选用
*
、+
、-
其中之一 - 服务器端以 4 字节整数向客户端传回运算结果。
- 客户端得到运算结果后终止与服务器端的连接。
服务器端:
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
void ErrorHandling(const char* message);
int Calculate(int cnt, int nums[], char op);
constexpr int BUF_SIZE = 1024;
constexpr int opsz = 4;
int main(int argc, char* argv[])
{
WSADATA wsaData;
SOCKET hServSock, hClntSock;
SOCKADDR_IN servAddr, clntAddr;
int szClntAddr;
char message[BUF_SIZE];
int str_len;
if (argc != 2) // 检查参数数量
{
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) // 初始化 Winsock 相关库
ErrorHandling("WSAStartup() error!");
hServSock = socket(PF_INET, SOCK_STREAM, 0); // 创建套接字
if (hServSock == INVALID_SOCKET)
ErrorHandling("socket() error");
memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET; // 设置协议族
servAddr.sin_addr.s_addr = htonl(INADDR_ANY); // 设置 IP 地址
servAddr.sin_port = htons(atoi(argv[1])); // 设置端口号
if (bind(hServSock, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR) // 为套接字分配地址和端口
ErrorHandling("bind() error");
if (listen(hServSock, 5) == SOCKET_ERROR) // 使套接字转换为可接收连接的状态
ErrorHandling("listen() error");
szClntAddr = sizeof(clntAddr);
int opnd_cnt = 0, recv_len = 0, recv_cnt, result;
for (int i = 0; i < 5; ++i) {
hClntSock = accept(hServSock, (SOCKADDR*)&clntAddr, &szClntAddr); // 接受连接请求,函数返回客户端的套接字
if (hClntSock == INVALID_SOCKET)
ErrorHandling("accept() error");
else printf("Connnected client %d\n", i + 1);
recv(hClntSock, (char*) &opnd_cnt, 1, 0);// 先读一个字节,读出数组元素个数
while (opnd_cnt * opsz + 1 > recv_len) {
// 用messgae存剩下的消息,最后一个字节为操作符
recv_cnt = recv(hClntSock, &message[recv_len], BUF_SIZE - 1, 0);
recv_len += recv_cnt;
}
result = Calculate(opnd_cnt, (int*)message, message[recv_len - 1]);
send(hClntSock, (char*)&result, sizeof(result), 0);
printf("end\n");
closesocket(hClntSock);
}
closesocket(hServSock); // 关闭服务器端套接字
WSACleanup(); // 注销 Winsock 相关库
return 0;
}
int Calculate(int cnt, int nums[], char op) {
int result = nums[0];
if (op == '+') {
for (int i = 1; i < cnt; ++i) result += nums[i];
}
else if (op == '-') {
for (int i = 1; i < cnt; ++i) result -= nums[i];
}
else {
for (int i = 1; i < cnt; ++i) result *= nums[i];
}
return result;
}
void ErrorHandling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
客户端:
#pragma execution_character_set("utf-8")
#include <stdio.h>
#include <stdlib.h>
#include <ws2tcpip.h>
void ErrorHandling(const char* message);
constexpr int BUF_SIZE = 1024;
constexpr int opsz = 4;
constexpr int rlt_size = 4;
int main(int argc, char* argv[])
{
WSADATA wsaData;
SOCKET hSocket;
SOCKADDR_IN servAddr;
char message[BUF_SIZE];
int str_len;
if (argc != 3)
{
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0)
ErrorHandling("WSAStartup() error!");
hSocket = socket(PF_INET, SOCK_STREAM, 0);
if (hSocket == INVALID_SOCKET)
ErrorHandling("socket() error");
memset(&servAddr, 0, sizeof(servAddr));
servAddr.sin_family = AF_INET;
servAddr.sin_addr.S_un.S_addr = inet_addr(argv[1]); // 这里对书中代码进行了一些修改(源代码编译会报错,根据报错提示修改为当前代码)
servAddr.sin_port = htons(atoi(argv[2]));
if (connect(hSocket, (SOCKADDR*)&servAddr, sizeof(servAddr)) == SOCKET_ERROR)
ErrorHandling("connect() error!");
else printf("Connected....");
// 发送消息
int opnd_cnt, result;
fputs("Operand count:", stdout);
scanf("%d", &opnd_cnt);
message[0] = (char)opnd_cnt;
for (int i = 0; i < opnd_cnt; ++i) {
printf("Operand %d : ", i + 1);
scanf("%d", (int*)&message[i * opsz + 1]);
}
fgetc(stdin); // 吃掉回车
fputs("Operaotr: ", stdout);
scanf("%c", &message[opnd_cnt * opsz + 1]);
send(hSocket, message, opnd_cnt * opsz + 2, 0);
recv(hSocket, (char*) & result, rlt_size, 0);
printf("Operation result: %d\n", result);
closesocket(hSocket);
WSACleanup();
return 0;
}
void ErrorHandling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
UDP 通信
UDP套接字的特点
区分 TCP 与 UDP 的一个典型比喻:UDP 好比寄信,TCP 好比打电话:
- UDP:寄信前要在信封上填好寄信人和收信人的地址,然后放进邮筒。不能确认对方是否收到信件,并且邮寄过程中新建可能丢失。
- TCP:首先要拨打电话号码,打通后才能开始通话,但打通后的通话是可靠的。
TCP 和 UDP 最重要的区别在于流控制
。
理解:这里的流控制应该包含了 TCP 的可靠传输、流量控制、拥塞控制等机制,这些机制都是在流上实现的。
UDP的高效使用
网络实时传输多媒体数据一般使用 UDP。
TCP 比 UDP 慢的两个原因:
- TCP 数据传输前后要
进行连接的建立与释放
。 - TCP 数据传输过程中为了保证可靠性而添加的
流控制
。
当收发的数据量小但需要频繁连接时,UDP 的高效体现地更明显
。
UDP服务器端和客户端均只需 1 个套接字
TCP 中,服务器端和客户端的套接字是一对一的关系,服务器端每向一个客户端提供服务,就需要分配一个新的套接字(accept创建的)。而 UDP 的服务器端和客户端均只需 1 个套接字,服务器端只要有一个 UDP 套接字就可以和多台主机通信。
UDP客户端套接字的地址分配
在 TCP 的客户端中 conncect
函数会自动完成给套接字分配 IP 地址和端口号的过程,UDP 中则是 sendto
函数来完成此功能。如果调用 sendto 函数时发现尚未给套接字分配地址信息,就会在首次调用 sendto 函数时给套接字分配 IP 地址和端口
。
存在数据边界的UDP套接字
UDP 套接字编程时,接收端输入函数的调用次数必须和发送端输出函数的调用次数相同,这样才能接收完发送端发送的数据。
已连接UDP套接字和未连接UDP套接字
通过 sendto 函数传输数据的过程包括三个阶段:
- 向 UDP 套接字注册目标 IP 和端口号;(注意:是将 UDP 套接字与目标的地址信息相关联,不是给 UDP 分配地址信息。前者每次 sendto 都会执行,后者只有首次调用且套接字尚未分配地址时才会执行一次)。
- 传输数据;
- 删除 UDP 套接字中注册的目标地址信息。
当多次通过 sendto 向同一个目标发送信息时,每次 sendto 都进行上面的步骤 1 和 3,就会很浪费时间。
因此当要长时间与同一主机通信时,将 UDP 变为已连接套接字会提高效率。
创建已连接 UDP 套接字
创建 UDP 套接字只需要对 UDP 套接字调用 connect
函数,但是这并不意味着要与对方的 UDP 套接字连接,这只是向 UDP 套接字注册目标 IP 和端口信息
。
connect(sock, (struct sockaddr*)&adr, sizeof(adr)); // 注意:adr 是目标的地址信息
使用已连接的 UDP 套接字进行通信时, sendto 函数就不会再执行步骤 1 和步骤 3,每次只要传输数据即可。因为已经指定了收发对象,所以不止可以用 sendto、recvfrom,也可以用 write、read 函数进行通信
。
使用 UDP 进行通信,服务器和客户端的处理步骤比 TCP 要简单很多,不存在请求连接和受理过程,并且两端是对等的 (通信的处理流程几乎是一样的),也就是说并没有严格意义上的客户端和服务器端,只是在提供服务的一端称为服务器端
。
UDP 的通信流程如下:
在UDP通信过程中,服务器和客户端都可以作为数据的发送端和数据接收端,假设服务器端是被动接收数据,客户端是主动发送数据,那么在服务器端就必须绑定固定的端口了
。
服务器端通信流程
服务器端可以同时与多个客户端进行通信
假设服务器端是接收数据的角色:
- 创建通信的套接字
- 使用通信的套接字和本地的IP和端口绑定,IP和端口需要转换为大端(可选)
- 进行通信
- 关闭套接字(文件描述符)
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
#define BUF_SIZE 30
void error_handling(const char* message);
int main(int argc, char* argv[])
{
WSADATA wsaData;
int serv_sock;
char message[BUF_SIZE];
int str_len;
int clnt_adr_sz;
struct sockaddr_in serv_adr, clnt_adr;
if (argc != 2)
{
printf("Usage : %s <port>\n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) // 初始化 Winsock 相关库
error_handling("WSAStartup() error!");
serv_sock = socket(PF_INET, SOCK_DGRAM, 0);
if (serv_sock == -1)
error_handling("UDP socket creation error");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = htonl(INADDR_ANY);
serv_adr.sin_port = htons(atoi(argv[1]));
if (bind(serv_sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)) == -1)
error_handling("bind() error");
while (1)
{
clnt_adr_sz = sizeof(clnt_adr);
str_len = recvfrom(serv_sock, message, BUF_SIZE, 0, (struct sockaddr*)&clnt_adr, &clnt_adr_sz); // 接收数据同时获取发送端地址
sendto(serv_sock, message, str_len, 0, (struct sockaddr*)&clnt_adr, clnt_adr_sz);
}
closesocket(serv_sock); // 上面的 while 是无限循环,这里的 colse 函数没什么实际意义。
WSACleanup();
return 0;
}
void error_handling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
客户端通信流程
假设客户端是发送数据的角色:
- 创建通信的套接字
- 进行通信
- 关闭套接字(文件描述符)
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
#define BUF_SIZE 30
void error_handling(const char* message);
int main(int argc, char* argv[])
{
WSADATA wsaData;
int sock;
char message[BUF_SIZE];
int str_len;
int adr_sz;
struct sockaddr_in serv_adr, from_adr;
if (argc != 3)
{
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) // 初始化 Winsock 相关库
error_handling("WSAStartup() error!");
sock = socket(PF_INET, SOCK_DGRAM, 0);
if (sock == -1)
error_handling("socket() error");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
serv_adr.sin_port = htons(atoi(argv[2]));
while (1)
{
fputs("Insert message(q to quit): ", stdout);
fgets(message, sizeof(message), stdin);
if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
break;
sendto(sock, message, strlen(message), 0, (struct sockaddr*)&serv_adr, sizeof(serv_adr));
adr_sz = sizeof(from_adr);
str_len = recvfrom(sock, message, BUF_SIZE, 0, (struct sockaddr*)&from_adr, &adr_sz);
message[str_len] = 0;
printf("Message from server: %s", message);
}
closesocket(sock);
WSACleanup();
return 0;
}
void error_handling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}
这是一个使用已连接 UDP 套接字的例子,在上边代码的基础上修改得到
#include <stdio.h>
#include <stdlib.h>
#include <winsock2.h>
#define BUF_SIZE 30
void error_handling(const char* message);
int main(int argc, char* argv[])
{
WSADATA wsaData;
int sock;
char message[BUF_SIZE];
int str_len;
int adr_sz;
struct sockaddr_in serv_adr, from_adr;
if (argc != 3)
{
printf("Usage : %s <IP> <port>\n", argv[0]);
exit(1);
}
if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) // 初始化 Winsock 相关库
error_handling("WSAStartup() error!");
sock = socket(PF_INET, SOCK_DGRAM, 0);
if (sock == -1)
error_handling("socket() error");
memset(&serv_adr, 0, sizeof(serv_adr));
serv_adr.sin_family = AF_INET;
serv_adr.sin_addr.s_addr = inet_addr(argv[1]);
serv_adr.sin_port = htons(atoi(argv[2]));
connect(sock, (struct sockaddr*)&serv_adr, sizeof(serv_adr)); // 将套接字变为已连接套接字
while (1)
{
fputs("Insert message(q to quit): ", stdout);
fgets(message, sizeof(message), stdin);
if (!strcmp(message, "q\n") || !strcmp(message, "Q\n"))
break;
//sendto(sock, message, strlen(message), 0, (struct sockaddr*)&serv_adr, sizeof(serv_adr));
send(sock, message, strlen(message), 0);
adr_sz = sizeof(from_adr);
//str_len = recvfrom(sock, message, BUF_SIZE, 0, (struct sockaddr*)&from_adr, &adr_sz);
str_len = recv(sock, message, BUF_SIZE, 0);
message[str_len] = 0;
printf("Message from server: %s", message);
}
closesocket(sock);
WSACleanup();
return 0;
}
void error_handling(const char* message)
{
fputs(message, stderr);
fputc('\n', stderr);
exit(1);
}