首页 关于
树枝想去撕裂天空 / 却只戳了几个微小的窟窿 / 它透出天外的光亮 / 人们把它叫做月亮和星星
目录

套接字与TCP通信

我们曾经在介绍STM32串口通信时,简单提到过网络通信的OSI七层模型。 我们所熟悉的互联网,很多时候都被认为是4层的。物理上,各个机器通过交换机、网线、WIFI信号等设备实现互联。数据上,通过以太网协议实现互通。网络上,各个节点通过IP协议找到彼此。 TCP,UDP这样的传输层协议负责搬运数据。建立在这些协议之上的应用和服务则负责产生数据。

套接字(Socket)是一种广泛使用的网络编程接口,操作系统一般都会提供原生的支持。根据UNP一书的说法, 这种API最早是从Berkeley Unix系统发展起来的,所以有时也称为"伯克利套接字"。可以说这是Unix类系统的天赋,不需要安装任何库,就可以直接开始网络开发。实际上,套接字是一种比较宽泛的概念, 不仅仅局限于实现基于TCP,UDP的网络应用。通过套接字,我们甚至可以直接处理原始的IP数据报文。此外,还有为进程间通信而设计的的Unix Socket。不过现在我们只关心基于TCP的服务和应用。

本文,我们先简单介绍一下TCP协议的一些核心概念,再来了解使用套接字建立连接的套路。最后实现一个提供echo服务的例程。

1. TCP简介

TCP是一种可靠的传输层协议,想要通信的双方需要先通过三路握手来建立连接。连接一旦建立起来,双方就可以全双工的通信了。但是为了通信的可靠性,接收方需要确认它所收到的每个数据包。 如果没收到确认,发送端将自动重传。此外,TCP还可以控制网络流量。虽然套接字已经提供了底层的封装,使得我们可以不必关心这些细节就可以撸起TCP服务,但是我们还是先了解一下TCP协议, 这有助于开发可靠的、鲁棒的web服务。

TCP通信可以看作是一个8bit字节构成的流。TCP并不关心字节流的数据都是嘛,需要由通信两端的应用程序自己解析内容。抽象成字节流,应用程序就可以自由的消费数据,而不必关心数据是一次还是多次发来的。比如说,服务器有100个字节需要发送给客户端,服务器可以选择先发送10个字节,再发送20个,最后发送70个。而客户端可以每次只消费10个字节,分10次接收完所有的100个字节。这样看起来TCP的数据传输,应用程序的分工就很清晰,也比较容易实现。

1.1 TCP报文首部

TCP协议以IP作为其网络层,它的包将被封装在一个IP数据报文中,由TCP首部和TCP数据两个部分构成。其中TCP首部描述了TCP报文的元信息,如下表所示,一般由20个字节构成。

012345678910111213141516171819202122232425262728293031
16位源端口号 16位目的端口号
32位序号
32位确认序号
4位首部长度 保留(6位) U
R
G
A
C
K
P
S
H
R
S
T
S
Y
N
F
I
N
16位窗口大小
16位检验和 16位紧急指针

每个TCP报文都会在它的首部用两个16位的字段记录通信两端的端口号,这两个端口号就分别对应着发送端和接收端相互通信的两个应用程序,或者说是进程。在配合上封装TCP包的IP报文首部中记录了的两端IP地址,我们就可以在网络中找到这两个进程所在的宿主计算机。所以,一个TCP连接可以用源IP地址、源端口、目的IP地址、目的端口来唯一的描述。

首部中的序号用来记录已经成功发送的字节数,而确认序号则记录已经成功接收的字节数。它们都是32位的无符号整数,如果溢出了将从0开始重新计数。由于TCP是一个全双工的通信协议,连接的两端都需要维护这两个序号。我们刚刚说TCP的首部有20个字节,实际上不是那么严谨。TCP首部还有一部分可选的字段,并没有体现在上图中。这些可选字段的长度是由4位的首部长度字段来描述的。

16位的窗口大小用于流控制。检验和覆盖了整个TCP报文。紧急指针只有当URG置1时才有效。TCP的数据部分并不是一定要有的。

1.2 三次握手建立连接

如右图所示,建立一个TCP连接需要三次握手。在建立连接之前,需要服务器端通过socket打开一个套接字文件,并通过bind和listen监听指定端口,最后通过accept等待建立连接。 客户端则需要通过socket打开一个套接字文件,并调用connect来主动打开一个TCP连接通道。

第一次握手:客户端调用connect之后其进程就会进入阻塞状态,同时向服务器发送一个SYN的同步信号。这个同步信号目的是要告诉服务器,客户端在连接中发送的数据将从J开始计数。 一般情况下这个同步信号就只有一个TCP首部,不包含数据。在首部的32位序号字段中记录了J,同时SYN比特位被置一,表示这是一个同步信号。

第二次握手:服务器在接收到SYN J的同步信号之后,会向客户端返回一个确认信号。在这个确认信号中,服务器会以J+1填写32位确认序号,表示服务器已经接收到来自客户端的初始序号。 于此同时,服务器还会填写32位的序号字段为K,表示以后服务器发送的数据将从K开始计数。这个信号是一个确认信号,同时也是一个同步信号,所以它的SYN和ACK比特位都会被置1,暂且称之为同步确认信号吧。

第三次握手:客户端收到服务器的同步确认信号之后,就会向服务器返回一个确认信号。对于客户端而言,它已经获得了序号和确认序号两个字段,连接已经被建立了,就会从connect调用中返回。 服务器会接收这个确认信号,不出意外的话,其32位确认序号为K+1,表示客户端收到了服务器的确认同步信号。此时服务器也获得了序号和确认序号两个字段,从accept返回,表示连接被建立了。

1.3 四次握手终止连接

如右图所示,终止一个连接需要四次握手。图中的终止连接是由客户发起的,实际上服务器也是可以发起的。

第一次握手:发起终止的一端调用close关闭套接字文件,此时会向服务器发送一个终止信号FIN M。表示数据发送完毕,以后不会在这个连接上发送任何数据了。

第二次握手:接收到FIN M的一端将被动关闭连接。它收到FIN M信号的一刻,将意识到以后不会在从这个连接上接收到任何数据了。相应的应用程序将从read中返回0,表示字节流结束。 作为回应,被动关闭的一端将发送一个确认信号。

第三次握手:服务器read返回0,知道客户端已经提起关闭连接了。正常的逻辑服务器也应当执行close来关闭连接,释放为之分配的各个资源。服务器也会发送一个终止信号FIN N。

第四次握手:作为回应,客户端也发送一个确认信号给服务器。

虽然说这是四次握手,但很多时候被动关闭一端所发起的第二次和第三次握手信号,常常被合并到一个包里面发送。所以很多时候通过终止连接时抓到的包只有3个。

2. echo服务器

现在我们来写一个echo的服务器,看一下如何使用Linux系统所提供的套接字API来写一个TCP服务,例程代码。 如下面的代码片段所示,我们先调用socket获取一个套接字文件描述符。关于文件描述符,我们将在后文中进行详细的分析。

我们可以通过指令$ man socket来查看这个接口的文档,右侧是对该文档的截图。 可以看到socket有三个输入参数,其中domain描述了通信双方的网络层协议,这里所用的AF_INET就是IPv4协议。type描述了传输层类型,SOCK_STREAM实际上就是指TCP协议。第三个参数protocol一般情况都是0。 如果socket运行出错,将会返回-1,否则将返回新打开的套接字文件描述符。在下面的代码中我们用listen_fd来记录文件描述符,检查发现运行出错就报错退出。

        int main() {
            int listen_fd = socket(AF_INET, SOCK_STREAM, 0);
            if (-1 == listen_fd) {
                perror("创建socket失败");
                exit(1);
            }

然后,我们创建一个sockaddr_in地址对象,这是一个用于IPv4通信的地址结构。我们设置字段sin_family为AF_INET表示使用IPv4协议,sin_port则是服务器将要监听的端口号。 这里在65530之外用htons进行了一次封装,这主要是把宿主计算机上字节序转换为网络字节序。在网络协议中,一般都是大端字节,而宿主计算机因为其体系架构的不同有的是大端存储有的是小端的。 为了防止出错,这里就直接通过htons进行依次转换。函数名中的h指的是host,n是net。最后用INADDR_ANY表示监听的IP地址,它其实就是0。表示接收来自任何IP端口为65530的连接请求。

            struct sockaddr_in serv_addr;
            memset(&serv_addr, 0, sizeof(serv_addr));
            serv_addr.sin_family = AF_INET;
            serv_addr.sin_port = htons(65530);
            serv_addr.sin_addr.s_addr = htonl(INADDR_ANY);

接着,通过bind将套接字地址与文件描述符绑定。

            if (bind(listen_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
                perror("bind failed");
                exit(1);
            }

最后,调用listen让程序监听serv_addr所描述的来自任何IP端口为65530的连接请求。listen有两个输入参数,第一个就是我们已经打开的文件描述符,第二个参数指的是连接请求的缓存队列大小, 这里设置的是10。

            if (listen(listen_fd, 10) < 0) {
                perror("listen failed");
                exit(1);
            }

现在服务器的准备工作就结束了。我们就可以在一个while循环中通过accept来接收连接请求。程序进入accept之后就会被阻塞,只有经历了三次握手被动的建立起一个连接,或者出错了才会从该函数中返回。 该函数的返回值是一个套接字文件描述符,这里用conn_fd保存,如果出错则返回-1。 该函数的后两个参数,实际上是两个结果参数,分别用于保存建立连接的另一端的套接字地址和套接字地址结构的长度。

            while (1) {
                struct sockaddr_in cli_addr;
                socklen_t len = sizeof(cli_addr);
                int conn_fd = accept(listen_fd, (struct sockaddr *)&cli_addr, &len);

如果accept异常返回了,我们就报错退出。

                if (-1 == conn_fd) {
                    perror("连接失败");
                    exit(1);
                }

否则就在一个while循环中通过read读取来自conn_fd的数据,并原样不动的通过send将是发送回去。这就是所谓的echo服务。其中read的返回值有三种,-1表示出错了,0表示字节流结束, 正数表示从文件中读取的字节数量。

                else {
                    char buf[1024];
                    while (1) {
                        int nread = read(conn_fd, buf, 1024);
                        if (nread <= 0)
                            break;
                        std::cout << "nread = " << nread << std::endl;
                        send(conn_fd, buf, nread, 0);
                    }
                    close(conn_fd);
            }   }

最后程序退出时,关闭监听端口的文件描述符listen_fd。

            close(listen_fd);
        }

3. echo客户端

下面我们再来实现一个echo的客户端,它接收来自标准输入stdin的数据,将之通过TCP套接字转发给服务器,并将服务器返回的内容写到标准输出中。 例程代码 同样的我们需要先通过socket打开一个套接字:

        int main() {
            int client = socket(AF_INET, SOCK_STREAM, 0); 
            if (-1 == listen_fd) {
                perror("创建socket失败");
                exit(1);
            }

然后创建一个sockaddr_in地址对象,同样使用AF_INET表示连接的协议类型为IPv4,sin_port指向将要连接的服务器端口65530,并通过inet_pton将点分十进制表示的IP地址转换为网络协议的IP表示。

            struct sockaddr_in serv_addr;
            memset(&serv_addr, 0, sizeof(serv_addr));
            serv_addr.sin_family = AF_INET;
            serv_addr.sin_port = htons(65530);
            inet_pton(AF_INET, "127.0.0.1", &serv_addr.sin_addr);

接着,通过connect主动发起连接,如果连接失败将返回-1。

            if (connect(client_fd, (struct sockaddr *)&serv_addr, sizeof(serv_addr)) < 0) {
                perror("连接失败");
                exit(1);
            }

最后,如果连接成功,我们就在一个while循环中不断的捕获来自标准输入的数据,并将之通过send发送,在read回来输出到标准输出上。

            std::string douniwan;
            char buf[1024];
            while (std::cin >> douniwan) {
                send(client_fd, douniwan.c_str(), douniwan.size(), 0);
                int nread = read(client_fd, buf, 1024);
                if (nread <= 0)
                    break;
                buf[nread] = '\0';
                std::cout << buf << std::endl;
            }

通过Ctrl-d可以终结标准输入,退出上面的while循环,主动关闭连接文件描述符,触发终止连接的四步握手后退出程序。

            close(client_fd);
            return 0;
        }

4. 抓包示例

我们在本机上同时运行服务器和客户端,在客户端里随便发送点数据后通过Ctrl-d终止连接。我们通过wireshark对通信端口65530进行抓包,如下图所示。 图中的33852是我们的客户端运行时,有操作系统自动分配的端口,65530则是我们的服务器端口号。

第一行是有客户端发起的SYN信号,它的序号为0。这里的Seq是wireshark经过处理之后的序号,其实它是一个随机的数字。wireshark显示的序号是一个相对值,也就是减去该包中序号的结果。 然后在第二行和第三行中,服务器和客户端分别发送了两个ACK包,完成三次握手。可以看到三次握手结束之后,客户端的序号(Seq)和确认序号(Ack)都是1。

接下来的8行数据包,分别是客户端发送数据,服务器确认接收,然后服务器返回数据,客户端在确认接收。如此重复了两遍,表示客户端请求了两次echo服务。

最后三行,就是在客户端Ctrl-d正常终止连接之后的四步握手信号。其中倒数第2行,实际上是四步握手中的第二步和第三步的合并。

5. 完

本文中,我们先简单介绍了一下TCP协议,重点了解了TCP报文首部的字段定义,三次握手建立连接和四次握手断开连接的过程。在使用套接字编程时,服务端需要依次调用socket、bind、listen、accept来监听端口, 并被动建立连接。客户端则要依次调用socket、connect来主动建立连接。




Copyright @ 高乙超. All Rights Reserved. 京ICP备16033081号-1