一、网络通信概述
1. IP 和端口
所有的数据传输,都有三个要素:源、目的、长度。
怎么表示源跟目的呢?如下图:
所以,在网络传输中需要使用“IP和端口”来表示源或目的。
2. 网络传输中的两个对象:server 和 client
我们经常访问网站,这涉及 2 个对象:网站服务器,浏览器。网站服务器平时安静地呆着,浏览器主动发起数据请求。网站服务器、浏览器可以抽象成 2 个软件的概念:
server 程序、client 程序。
3. 网络协议层
在一般的网络书籍中,网络协议被分为5层,如图:
3.1 应用层
应用层是体系中的最高层,直接为用户的应用进程(如电子邮件、文件传输等)提供服务。在因特网中的应用层协议很多,如支持万维网应用的 HTTP 协议,支持电子邮件的 SMTP 协议,支持文件传送的 FTP 协议,DNS, POP3, SNMP, Telnet等。
3.2 运输层
运输层是负责向两个主机中进程之间的通信提供服务。
主要使用以下两种协议:
(1)传输控制协议 TCP(Transmission Control Protocol)
面向连接的,数据传输的单位是报文段,能够提供可靠的交付。
(2)用户数据包协议 UDP(User Datagram Protocol)
无连接的,数据传输的单位是用户数据报,不保证提供可靠的交付,只能提供“尽最大努力的交付”。
3.3 网络层
负责将被成为数据包(datagram)的网络层分组从一台主机移动到另一台主机。
3.4 链路层
因特网的网络层通过源和目的地之间的一系列路由器路由数据报。
3.5 物理层
在物理层上面所传数据的单位是比特,物理层的任务是透明地传送比特流。
这些层对于初学者来说很难理解,我们只需要知道:
- 我们需要用“运输层”编写应用程序,我们的应用程序位于“应用层”。
- 使用“运输层”时,可以选择 TCP 协议,也可以选择 UDP 协议。
4. 两种传输方式:TCP/UDP
4.1 TCP 和 UDP 原理上的区别
TCP 向它的应用程序提供了面向连接的服务。这种服务有2个特点:可靠传输、流量控制(即发送方/接收方速率匹配)。它包括了应用层报文划分为短报文,并提供拥塞控制机制。
UDP 协议向它的应用程序提供无连接服务。它没有可靠性,没有流量控制,也没有拥塞控制。
既然 TCP 提供了可靠数据传输服务,而 UDP 不能提供,那么 TCP 是否总是首选呢?
答案是否定的,因为有许多应用更适合用 UDP,举个例子:视频通话时,使用 UDP,偶尔的丢包、偶尔的花屏时可以忍受的;如果使用 TCP,每个数据包都要确保可靠传输,当它出错时就重传,这会导致后续的数据包被阻滞,视频效果反而不好。
关于何时发送什么数据控制的更为精细
采用 UDP 时只要应用进程将数据传递给 UDP,UDP 就会立即将其传递给网络层。而 TCP 有重传机制,并且不管可靠交付需要多长时间。但是实时应用通常不希望过分的延迟报文段的传送,可以能容忍一部分数据丢失。
TCP 特点:
- 基于流的方式;
- 面向连接;
- 可靠通信方式;
UDP 特点:
- 无需建立连接,不会引入建立连接时的延迟;
- 无连接状态,能支持更多的活跃客户;
- 分组首部开销较小;
4.2 TCP/UDP 网络通信大概交互图
面向连接的 TCP 流模式:
UDP 用户数据包模式:
二、网络编程主要函数介绍
1. socket 函数
此函数用于创建一个套接字,它的函数原型如下:
int socket(int domain, int type,int protocol);
-
domain 是网络程序所在的主机采用的通讯协族(AF_UNIX 和 AF_INET 等)。
- AF_UNIX 只能够用于单一的 Unix 系统进程间通信,而 AF_INET 是针对 Internet 的,因而可以允许远程通信使用。
-
type 是网络程序所采用的通讯协议(SOCK_STREAM,SOCK_DGRAM 等)。
- SOCK_STREAM 表明用的是 TCP 协议,这样会提供按顺序的,可靠,双向,面向连接的比特流。
- SOCK_DGRAM 表明用的是 UDP 协议,这样会提供不可靠,无连接的通信。
-
关于 protocol,由于指定了 type,所以这个地方一般只要用0来代替就可以了。
此函数执行成功时返回文件描述符,失败时返回-1,看errno可知道出错的详细情况。
2. bind 函数
此函数用于将地址绑定到一个套接字,它的函数原型如下:
int bind(int sockfd, const struct sockaddr *addr, int addrlen);
- sockfd 是由 socket 函数调用返回的文件描述符。
- my_addr 是一个指向 sockaddr 的指针。
- addrlen 是 sockaddr 结构的长度。
sockaddr 的定义:
struct sockaddr {
unisgned short as_family;
char sa_data[14];
};
不过由于系统的兼容性,我们现在都使用另外一个结构(struct sockaddr_in) 来代替。
sockaddr_in 的定义:
struct sockaddr_in {
short int sin_family; // 地址族,一般设为 AF_INET 表示IPv4
unsigned short int sin_port; // 端口号,使用网络字节序
struct in_addr sin_addr; // IPv4地址结构体,同样用网络字节序保存IP地址
char sin_zero[8]; // 填充字段,用于将结构体大小凑齐到和 sockaddr 结构体一样大小等用途
};
- sin_family 使用 Internet 一般设置为 AF_INET 即可。
- sin_addr 设置为INADDR_ANY表示可以和任何的主机通信。
- sin_port 是要监听的端口号。
- sin_zero 主要是用于填充字节,确保 struct sockaddr_in 结构体的大小和 struct sockaddr 结构体一样,并且其内容通常不影响前面已经设置好的关键信息的功能。在设置完关键信息后清零 sin_zero 可以保证结构体初始化的完整性和正确性,为后续的网络操作(如bind函数调用等)提供正确的参数。
bind 将本地的端口同 socket 返回的文件描述符捆绑在一起,成功是返回0,失败的情况和 socket 一样为 -1。
简单来说 bind 函数就是让套接字文件在通信时使用固定的IP和端口号(针对服务器来说),调用socket函数创建的套接字仅仅执行了通信等协议,但是并没有指定通信时所需的ip地址和端口号。
- ip 是对方设备的唯一标识
- 端口号区分同一台计算机上的不同的网络通信进程
如果不调用 bind 函数指定 ip 和端口,则会自己指定一个 ip 和端口,此时违背了 TCP 通信的可靠性和面向连接的特点。
服务器如何知道客户端的ip和端口号
可以通过上文TCP通信模型中看到,客户端通信时不需要指定ip和端口号,直接创建一个socket套接字文件描述符即可参与通信。
此时当客户端和服务器建立连接的时候,服务器会从客户的数据包中提取出客户端ip和端口,并保存起来,如果是跨网通信,那么记录的就是客户端所在路由器的公网ip。
3. listen 函数
此函数为服务器监听函数,宣告服务器可以接受连接请求,它的函数原型如下:
int listen(int sockfd, int backlog);
- sockfd 是bind后的文件描述符。
- backlog 设置请求排队的最大长度。当有多个客户端程序和服务端相连时,使用这个表示可以介绍的排队长度。
listen 函数将 bind 的文件描述符变为监听套接字,返回的情况和bind一样。
4. accept 函数
此函数用于服务器获得连接请求,并且建立连接,它的函数原型如下:
int accept(int sockfd, struct sockaddr *addr,int *addrlen);
- sockfd 是listen 后的文件描述符。
- addr,addrlen 用来存放客户端的信息。
listen监听客户端来的链接,accept将客户端的信息绑定到一个socket上,也就是给客户端创建一个socket,通过返回值返回给我们客户端的socket。
accept 调用时,服务器端的程序会一直阻塞到有一个客户程序发出了连接。 accept 成功时返回最后的服务器端的文件描述符,这个时候服务器端可以向该描述符写信息了,失败时返回 -1 。
5. connect 函数
此函数用来建立一个连接,它的函数原型如下:
int connect(int sockfd, struct sockaddr * serv_addr,int addrlen);
- sockfd 是socket 函数返回的文件描述符。
- serv_addr 储存了服务器端的连接信息,其中 sin_add 是服务端的地址。
- addrlen 是 serv_addr 的长度。
connect 函数是客户端用来同服务端连接的,成功时返回0,
6. send 函数
此函数用来发送数据,它的函数原型如下:
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
-
sockfd:指定发送端套接字描述符
- 服务器:服务器与特定客户端连接的套接字
- 客户端:客户端与服务器建立连接的套接字
-
buf 指明一个存放应用程序要发送数据的缓冲区;
-
len 指明实际要发送的数据的字节数;
-
flags 一般置0。
客户端或者服务器应用程序都用 send 函数来向 TCP 连接的另一端发送数据。
7. recv 函数
此函数用来接收数据,它的函数原型如下:
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
- sockfd 指定接收端套接字描述符;
- buf 指明一个缓冲区,该缓冲区用来存放recv函数接收到的数据;
- len 指明buf的长度;
- flags 一般置0。
客户或者服务器应用程序都用 recv 函数从 TCP 连接的另一端接收数据。
8. 其它常见转换函数
8.1 htons 函数
uint16_t htons(uint16_t hostshort);
- htons的功能:将一个无符号短整型数值转换为网络字节序,即大端模式(big-endian)。
第一个问题:为什么使用两个字节,也就是16位来存储。
这个简单一些,因为一个字节只能存储8位2进制数,而计算机的端口数量是65536个,也就是2^16,两个字节。
第二个为题:为什么计算机需要大端模式和小端模式?
- 小端模式 :强制转换数据不需要调整字节内容,1、2、4字节的存储方式一样。
- 大端模式 :符号位的判定固定为第一个字节,容易判断正负。
8.2 inet_aton 函数
此函数用于将 IP 地址字符串转换为网络字节序的二进制 IP 地址结构的函数,它的函数原型如下:
int inet_aton(const char *cp, struct in_addr *inp);
- cp是一个指向以点分十进制表示的 IP 地址字符串的指针(例如 “192.168.1.1”)。
- inp是一个指向 struct in_addr 类型的指针,用于存储转换后的 IP 地址。
如果转换成功,函数返回非零值;如果转换失败,返回 0。
8.3 inet_ntoa 函数
此函数用于将网络字节序的二进制 IP 地址转换为字符串 IP 地址。
char *inet_ntoa(struct in_addr in);
三、TCP编程示例
TCP 通信流程图:
通过流程图,我们可以分别写出服务器和客户端的代码实现步骤。
1. 服务器与客户端连接
1.1 服务器
我们先实现服务器与客户端连接的代码,再实现服务器与客户端收发数据。
主要实现步骤如下:
- socket 创建套接字
- bind 绑定套接字
- listen 监听客户端连接
- accept 连接客户端
服务器等待客户端连接代码示例:
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#define SERVER_PORT 8888
#define BACKLOG 10
int main(int argc, char **argv)
{
int iSocketServer;
int iSocketClient;
struct sockaddr_in tSocketServerAddr;
struct sockaddr_in tSocketClientAddr;
int iRet;
int iAddrlen;
int iClientNum = 0;
/* 1. socket */
iSocketServer = socket(AF_INET, SOCK_STREAM, 0);
tSocketServerAddr.sin_addr.s_addr = INADDR_ANY;
tSocketServerAddr.sin_family = AF_INET;
tSocketServerAddr.sin_port = htons(SERVER_PORT);
memset(tSocketServerAddr.sin_zero, 0, 8);
/* 2. bind */
iRet = bind(iSocketServer, (const struct sockaddr *)&tSocketServerAddr, sizeof(struct sockaddr_in));
/* 3. listen */
iRet = listen(iSocketServer, BACKLOG);
while (1)
{
/* 4. accept */
iAddrlen = sizeof(struct sockaddr_in);
iSocketClient = accept(iSocketServer, (struct sockaddr *)&tSocketClientAddr, &iAddrlen);
if (iSocketClient != -1)
{
iClientNum++;
printf("get connect from client %d:%s\n", iClientNum, inet_ntoa(tSocketClientAddr.sin_addr));
}
}
close(iSocketServer);
return 0;
}
1.2 客户端
在这里同样我们先实现客户端连接服务器的代码,再实现两者收发数据。
主要实现步骤如下:
- socket 创建套接字
- connect 连接服务器
客户端连接服务器代码示例:
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#define SERVER_PORT 8888
#define BACKLOG 10
int main(int argc, char **argv)
{
int iSocketClient;
struct sockaddr_in tSocketClientAddr;
int iRet;
if (argc != 2)
{
printf("Usage:%s IP\n", argv[0]);
return -1;
}
/* 1. socket */
iSocketClient = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == iSocketClient)
{
printf("socket error!\n");
return -1;
}
tSocketClientAddr.sin_family = AF_INET;
tSocketClientAddr.sin_port = htons(SERVER_PORT);
/* 2. inet_aton */
iRet = inet_aton(argv[1], &tSocketClientAddr.sin_addr);
if (0 == iRet)
{
printf("inet_aton error!\n");
return -1;
}
memset(tSocketClientAddr.sin_zero, 0, 8);
/* 3. connect */
iRet = connect(iSocketClient, (struct sockaddr *)&tSocketClientAddr, sizeof(struct sockaddr_in));
if (-1 == iRet)
{
printf("connect error!\n");
return -1;
}
while (1)
{
}
close(iSocketClient);
return 0;
}
编译运行测试:
在这里,我们已经初步实现了客户端与服务器连接,接着我们实现双方数据交互。
2. 服务器与客户端数据交互
2.1 服务器
我们只需要在原来的代码上实现发送和接收功能:
- send 发送
- recv 接收
代码示例:
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#define SERVER_PORT 8888
#define BACKLOG 10
int main(int argc, char **argv)
{
int iSocketServer;
int iSocketClient;
struct sockaddr_in tSocketServerAddr;
struct sockaddr_in tSocketClientAddr;
int iRet;
int iAddrlen;
int iClientNum = 0;
int cnt = 0;
int iRcvLen;
int iSendLen;
unsigned char ucSendBuf[1000];
unsigned char ucRcvBuf[1000];
/* 1. socket */
iSocketServer = socket(AF_INET, SOCK_STREAM, 0);
tSocketServerAddr.sin_addr.s_addr = INADDR_ANY;
tSocketServerAddr.sin_family = AF_INET;
tSocketServerAddr.sin_port = htons(SERVER_PORT);
memset(tSocketServerAddr.sin_zero, 0, 8);
/* 2. bind */
iRet = bind(iSocketServer, (const struct sockaddr *)&tSocketServerAddr, sizeof(struct sockaddr_in));
/* 3. listen */
iRet = listen(iSocketServer, BACKLOG);
while (1)
{
/* 4. accept */
iAddrlen = sizeof(struct sockaddr_in);
iSocketClient = accept(iSocketServer, (struct sockaddr *)&tSocketClientAddr, &iAddrlen);
if (iSocketClient != -1)
{
iClientNum++;
printf("get connect from client %d:%s\n", iClientNum, inet_ntoa(tSocketClientAddr.sin_addr));
/* 创建一个子进程 */
if (!fork())
{
while (1)
{
/* 接收服务器发过来的数据 */
iRcvLen = recv(iSocketClient, ucRcvBuf, 999, 0);
if (iRcvLen > 0)
{
ucRcvBuf[iRcvLen] = '\0';
printf("get msg from client:%s\n", ucRcvBuf);
/* 发送应答数据给客户端 */
sprintf(ucSendBuf, "send ACK %d to client", ++cnt);
iSendLen = send(iSocketClient, ucSendBuf, strlen(ucSendBuf), 0);
}
else
{
close(iSocketClient);
return -1;
}
}
}
}
}
close(iSocketServer);
return 0;
}
2.2 客户端
客户端同样也是在原来的代码上实现发送和接收功能:
- send 发送
- recv 接收
代码示例:
#include <sys/types.h> /* See NOTES */
#include <sys/socket.h>
#include <arpa/inet.h>
#include <string.h>
#include <stdio.h>
#include <unistd.h>
#define SERVER_PORT 8888
int main(int argc, char **argv)
{
int iSocketClient;
struct sockaddr_in tSocketClientAddr;
int iRet;
int iClientNum = 0;
int iSendLen;
int iRcvLen;
unsigned char ucSendBuf[1000];
unsigned char ucRcvBuf[1000];
if (argc != 2)
{
printf("Usage:%s IP\n", argv[0]);
return -1;
}
/* 1. socket */
iSocketClient = socket(AF_INET, SOCK_STREAM, 0);
if (-1 == iSocketClient)
{
printf("socket error!\n");
return -1;
}
tSocketClientAddr.sin_family = AF_INET;
tSocketClientAddr.sin_port = htons(SERVER_PORT);
/* 2. inet_aton */
iRet = inet_aton(argv[1], &tSocketClientAddr.sin_addr);
if (0 == iRet)
{
printf("inet_aton error!\n");
return -1;
}
memset(tSocketClientAddr.sin_zero, 0, 8);
/* 3. connect */
iRet = connect(iSocketClient, (struct sockaddr *)&tSocketClientAddr, sizeof(struct sockaddr_in));
if (-1 == iRet)
{
printf("connect error!\n");
return -1;
}
while (1)
{
/* 用来读取终端输入的一行数据 */
if (fgets(ucSendBuf, 999, stdin))
{
/* 发送该行数据给服务器 */
iSendLen = send(iSocketClient, ucSendBuf, strlen(ucSendBuf), 0);
if (iSendLen <= 0)
{
close(iSocketClient);
return -1;
}
/* 接收服务器发过来的数据 */
iRcvLen = recv(iSocketClient, ucRcvBuf, 999, 0);
if (iRcvLen > 0)
{
ucRcvBuf[iRcvLen] = '\0';
printf("get msg from server:%s\n", ucRcvBuf);
}
}
}
close(iSocketClient);
return 0;
}
编译运行测试:
四、TCP数据粘包处理
1. 何为粘包
假设我们有如下需求:客户端和服务器之间要进行基于TCP的套接字通信
- 通信过程中客户端会每次会不定期给服务器发送一个不定长度的有特定含义的字符串
- 通信的服务器端每次都需要接收到客户端这个不定长度的字符串,并对其进行解析
根据上面的描述,服务器在接收数据的时候有如下几种情况:
- 一次接收到了客户端发送过来的一个完整的数据包
- 一次接收到了客户端发送过来的N个数据包,由于每个包的长度不定,无法将各个数据包拆开
- 一次接收到了一个或者N个数据包 + 下一个数据包的一部分,还是很悲剧,无法将数据包拆开
- 一次收到了半个数据包,下一次接收数据的时候收到了剩下的一部分+下个数据包的一部分,更悲剧,头大了
- 另外,还有一些不可抗拒的因素:比如客户端和服务器端的网速不一样,发送和接收的数据量也会不一致
对于以上描述的现象很多时候我们将其称之为TCP的粘包问题,那么服务器端如果想保证每次都能接收到客户端发送过来的这个不定长度的数据包,一般有以下几种解决方案:
- 使用标准的应用层协议(比如:http、https)来封装要传输的不定长的数据包
- 在每条数据的尾部添加特殊字符, 如果遇到特殊字符, 代表当条数据接收完毕了
- 有缺陷: 效率低, 需要一个字节一个字节接收, 接收一个字节判断一次, 判断是不是那个特殊字符串
- 在发送数据块之前, 在数据块最前边添加一个固定大小的数据头, 这时候数据由两部分组成:数据头+数据块
- 数据头:存储当前数据包的总字节数,接收端先接收数据头,然后在根据数据头接收对应大小的字节
- 数据块:当前数据包的内容
2. 解决方案
如果使用TCP进行套接字通信,如果发送的数据包粘连到一起导致接收端无法解析,我们通常使用添加包头的方式轻松地解决掉这个问题。
关于数据包的包头大小可以根据自己的实际需求进行设定,这里没有啥特殊需求,因此规定包头的固定大小为4个字节,用于存储当前数据块的总字节数。
2.1 发送端
对于发送端来说,数据的发送分为4步:
根据待发送的数据长度N动态申请一块固定大小的内存:N+4(4是包头占用的字节数)
将待发送数据的总长度写入申请的内存的前四个字节中,此处需要将其转换为网络字节序(大端)
将待发送的数据拷贝到包头后边的地址空间中,将完整的数据包发送出去(字符串没有字节序问题)
释放申请的堆内存。
由于发送端每次都需要将这个数据包完整的发送出去,因此可以设计一个发送函数,如果当前数据包中的数据没有发送完就让它一直发送,处理代码如下:
/*
函数描述: 发送指定的字节数
函数参数:
- fd: 通信的文件描述符(套接字)
- msg: 待发送的原始数据
- size: 待发送的原始数据的总字节数
函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1
*/
int writen(int fd, const char* msg, int size)
{
const char* buf = msg;
int count = size;
while (count > 0)
{
int len = send(fd, buf, count, 0);
if (len == -1)
{
close(fd);
return -1;
}
else if (len == 0)
{
continue;
}
buf += len;
count -= len;
}
return size;
}
有了这个功能函数之后就可以发送带有包头的数据块了,具体处理动作如下:
/*
函数描述: 发送带有数据头的数据包
函数参数:
- cfd: 通信的文件描述符(套接字)
- msg: 待发送的原始数据
- len: 待发送的原始数据的总字节数
函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1
*/
int sendMsg(int cfd, char* msg, int len)
{
if(msg == NULL || len <= 0 || cfd <= 0)
{
return -1;
}
// 申请内存空间: 数据长度 + 包头4字节(存储数据长度)
char* data = (char*)malloc(len + 4);
int bigLen = htonl(len);
memcpy(data, &bigLen, 4);
memcpy(data + 4, msg, len);
// 发送数据
int ret = writen(cfd, data, len + 4);
// 释放内存
free(data);
return ret;
}
注意:字符串没有字节序问题,但是数据头不是字符串是整形,因此需要从主机字节序转换为网络字节序再发送。
2.2 接收端
了解了套接字的发送端如何发送数据,接收端的处理步骤也就清晰了,具体过程如下:
- 首先接收4字节数据,并将其从网络字节序转换为主机字节序,这样就得到了即将要接收的数据的总长度
- 根据得到的长度申请固定大小的堆内存,用于存储待接收的数据
- 根据得到的数据块长度接收固定数目的数据保存到申请的堆内存中
- 处理接收的数据
- 释放存储数据的堆内存
从数据包头解析出要接收的数据长度之后,还需要将这个数据块完整的接收到本地才能进行后续的数据处理,因此需要编写一个接收数据的功能函数,保证能够得到一个完整的数据包数据,处理函数实现如下:
/*
函数描述: 接收指定的字节数
函数参数:
- fd: 通信的文件描述符(套接字)
- buf: 存储待接收数据的内存的起始地址
- size: 指定要接收的字节数
函数返回值: 函数调用成功返回发送的字节数, 发送失败返回-1
*/
int readn(int fd, char* buf, int size)
{
char* pt = buf;
int count = size;
while (count > 0)
{
int len = recv(fd, pt, count, 0);
if (len == -1)
{
return -1;
}
else if (len == 0)
{
return size - count;
}
pt += len;
count -= len;
}
return size;
}
接收函数实现如下:
/*
函数描述: 接收带数据头的数据包
函数参数:
- cfd: 通信的文件描述符(套接字)
- msg: 一级指针的地址,函数内部会给这个指针分配内存,用于存储待接收的数据,这块内存需要使用者释放
函数返回值: 函数调用成功返回接收的字节数, 发送失败返回-1
*/
int recvMsg(int cfd, char** msg)
{
// 接收数据
// 1. 读数据头
int len = 0;
readn(cfd, (char*)&len, 4);
len = ntohl(len);
printf("数据块大小: %d\n", len);
// 根据读出的长度分配内存,+1 -> 这个字节存储\0
char *buf = (char*)malloc(len+1);
int ret = readn(cfd, buf, len);
if(ret != len)
{
close(cfd);
free(buf);
return -1;
}
buf[len] = '\0';
*msg = buf;
return ret;
}
这样,在进行套接字通信的时候通过调用封装的 sendMsg()
和 recvMsg()
就可以发送和接收带数据头的数据包了,解决了粘包的问题。