4239 字
11 分钟
TCP&IP网络编程学习札记_3
2026-07-17
浏览量 106 · 访客 10

基于TCP的服务器端/客户端(1)#

4.1 理解TCP和UDP#

根据数据传输方式的不同,基于网络协议的套接字一般分为TCPTCP套接字和UDPUDP套接字。TCPTCP是Transmission Control Protocol(传输控制协议)的简写,意为“对数据传输过程的控制”。

TCP/IP协议栈#

介绍一下TCP/IPTCP/IP所属的TCP/IPTCP/IP协议栈(StackStack, 层)。

graph TD A[应用层] B[TCP层] C[UDP层] D[IP层] E[链路层] A <--> B <--> D <--> E A <--> C <--> D

由图可以看出,TCP/IPTCP/IP协议栈分为四层。也就是说对于“基于互联网的有效数据传输”这个命题,并非通过一个庞大的协议解决问题,而是化整为零,通过层次化方案————TCP/IPTCP/IP协议栈解决。
各层可能通过操作系统等软件实现,也可能通过类似NICNIC的硬件设备实现。

NOTE

OSI 7 Layer(层)
数据通信中使用的协议栈分为7层,一般来说掌握本书分的4层也足够了。

TCP/IP协议的诞生背景#

计算机网络问题并非仅凭软件就能解决,还需要构建硬件系统,软件上实现算法,因此我们将“通过因特网完成有效数据传输”问题按照不同领域划分为小问题后,出现了多种协议,它们通过层级结构建立了紧密联系。
开放式系统(Open System)
设计开放式系统是为了标准化操作。标准本身就在于对外公开,引导更多人遵守规范,以多个标准为依据设计的系统称为开放式系统。(TCP/IPTCP/IP协议栈也属于其中之一)举例来说就是,不同的路由器和网卡通过这样标准化的设计,都可以实现数据互通,不会因为型号不同而导致数据无法传输。
链路层
链路层是物理链接领域标准化的结果,专门定义LANWANMANLAN、WAN、MAN等网络标准。若两台主机通过网络进行数据交换,就需要图中所示的物理连接,链路层就负责这些标准。

替换文本
IP层
IPIP层负责解决向目标传输数据选择哪条路径的问题。该层使用的协议就IPIP
IPIP本身是面向消息的、不可靠的协议。IPIP每次帮我们选择的路径可能都不一样,如果路径错误会选择其他路径,但是IPIP无法应对数据丢失和错误。
TCP/UDP层
TCPTCPUDPUDPIPIP层提供的路径信息为基础完成实际的数据传输,决定数据传输的方式。故该层也称传输层(TransportTransport)。
TCPTCP协议确认后向不可靠的IPIP协议赋予可靠性。首先IPIP层只关注一个数据包(数据传输的基本单位)的传输过程。所以传输顺序和传输本身是不可靠的。
但如果使用TCPTCP的话,我们每次的数据交换过程都会确认对方是否收到数据,并重传丢失的数据,那么即使IPIP层不保证数据传输,这类通信也是可靠的。(这点后面会详细介绍原理)
应用层
利用套接字编出程序软件,根据程序的特点决定服务器端和客户端之间的数据传输规则(规定),这就是应用层协议。网络编程的大部分内容就是设计并实现应用层协议。

4.2 实现基于TCP的服务器端/客户端#

本节实现完整的TCPTCP服务器端/客户端,在此过程中理解套接字的使用方法及数据传输方法。

TCP服务器端#

函数调用顺序
绝大部分TCPTCP服务器端都按照如下顺序调用函数:

graph TD A["socket() 创建套接字"] --> B["bind() 分配套接字地址"] B --> C["listen() 进入等待连接请求状态"] C --> D["accept() 受理连接"] D --> E["read()/write() 数据交换"] E --> F["close() 断开连接"]

前两步已经在之前的章节介绍过:调用socketsocket创建套接字,再初始化地址信息结构体并通过bindbind为套接字分配IPIP和端口。接下来还需要让套接字进入等待状态并受理客户端的连接请求。
进入等待连接请求状态
调用bindbind分配地址后,通过listenlisten使套接字进入等待连接请求状态。只有服务器端调用listenlisten后,客户端才能正常调用connectconnect发起连接请求。

int listen(int sock, int backlog);

socksock是要进入等待状态的套接字文件描述符,调用成功后该套接字就成为服务器端套接字,也称监听套接字backlogbacklog决定连接请求等待队列的长度,例如传入55表示最多允许55个连接请求进入队列等待处理。函数成功时返回00,失败时返回1-1
客户端的连接请求本身也是通过网络到达的数据。监听套接字就像门卫,负责接收这些请求并将它们放入等待队列,但不负责之后与客户端进行实际的数据交换。
受理客户端连接请求
服务器端通过acceptaccept依次受理等待队列中的连接请求:

int accept(int sock, struct sockaddr *addr, socklen_t *addrlen);

socksock是监听套接字,addraddraddrlenaddrlen用于保存发起请求的客户端地址信息。调用成功时,acceptaccept会自动创建一个与该客户端连接的套接字,并返回它的文件描述符;后续的readwriteread、writecloseclose都针对这个新套接字,而监听套接字继续负责接收其他连接请求。
如果等待队列为空,acceptaccept会进入阻塞(blockingblocking)状态,直到出现新的客户端连接请求才返回。

TCP客户端#

函数调用顺序
客户端的实现比服务器端简单,主要过程是创建套接字、请求连接、交换数据并关闭连接。

graph TD A["socket() 创建套接字"] --> B["connect() 请求连接"] B --> C["read()/write() 数据交换"] C --> D["close() 断开连接"]

请求连接通过connectconnect函数完成:

int connect(int sock, struct sockaddr *servaddr, socklen_t addrlen);

socksock是客户端套接字,servaddrservaddr保存目标服务器端的地址信息,addrlenaddrlen是该地址结构体的长度。服务器端将连接请求记录到等待队列,或连接因断网等异常而中断时,connectconnect才会返回。这里的“接收连接请求”不等同于服务器端已经调用acceptaccept,所以connectconnect返回后,服务器端不一定已经开始与该客户端交换数据。
客户端没有像服务器端一样显式调用bindbind,但并非无需地址。调用connectconnect时,操作系统会在内核中自动分配客户端地址:IPIP使用本机IPIP,端口则自动选择。
服务器端与客户端的函数调用关系
服务器端依次调用socketbindlistensocket、bind、listen进入等待状态,客户端随后调用connectconnect发起连接请求,服务器端再通过acceptaccept取得用于数据交换的新套接字。服务器端可以先调用acceptaccept,此时它会阻塞并等待客户端调用connectconnect

4.3 实现迭代服务器端/客户端#

本节编写回声(echoecho)服务器端/客户端。服务器端将客户端传来的字符串原样返回,就像回声一样。在此之前需要先理解迭代服务器端。

实现迭代服务器端#

此前的HelloWorldHello World服务器端处理完一个客户端的连接请求就退出,没有真正利用连接请求等待队列。若要继续服务后续客户端,最简单的办法就是用循环反复调用acceptaccept
服务器端函数调用顺序:

替换文本
可以看到,调用acceptaccept后紧接着调用I/OI/O相关的readwriteread、write函数,最后调用closeclose。这些操作针对acceptaccept新创建的连接套接字,而非监听套接字。
关闭连接套接字表示结束对当前客户端的服务,之后重新调用acceptaccept受理下一个请求。这种迭代方式在同一时刻只能服务一个客户端;后续学习进程和线程后,才能编写同时服务多个客户端的服务器端。

迭代回声服务器端/客户端#

程序的基本运行方式:

  1. 服务器端在同一时刻只与一个客户端相连,并提供回声服务。
  2. 服务器端依次向55个客户端提供服务并退出。
  3. 客户端接收用户输入的字符串并发送到服务端。
  4. 服务端将接收的字符串数据传回客户端,即“回声”。
  5. 服务端与客户端之间的字符串回声一直执行到客户端输入QQ为止。
    这里本书给出了回声服务器端和客户端的完整源码,与此前第一、二章的区别主要在于服务器端循环调用acceptaccept,依次为多个客户端提供服务。其核心结构如下:
for (i = 0; i < 5; i++)
{
clnt_sock = accept(serv_sock, (struct sockaddr *)&clnt_adr,
&clnt_adr_sz);
while ((str_len = read(clnt_sock, message, BUF_SIZE)) != 0)
write(clnt_sock, message, str_len);
close(clnt_sock);
}
close(serv_sock);

客户端输入QQ后调用closeclose关闭套接字,同时向服务器端传递EOFEOF。服务器端的readread因此返回00,结束当前回声循环并关闭对应的连接套接字,随后继续调用acceptaccept等待下一个客户端。

回声客户端存在的问题#

对应echo_client.cecho\_client.c的第45~48行代码。

write(sock, message, strlen(message));
str_len = read(sock, message, BUF_SIZE - 1);
message[str_len] = 0;
printf("Message from server: %s", message);

这段代码错误地假设每次调用readwriteread、write都会以一个字符串为单位完成实际的I/OI/O。因为TCPTCP不存在数据边界,多次writewrite发送的字符串可能被服务器端一次readread接收;一个较长字符串也可能被拆成多个数据包,客户端在全部数据到达前就调用readread,因而只能读取其中一部分。
示例之所以能正常运行,只是因为传输的数据较小,而且通常在同一台或相邻的计算机上测试,并不代表这种写法始终可靠。这个问题会在第五章解决。

4.4 基于Windows的实现#

基于Windows 的回声服务器端
相比于LinuxLinuxWindowsWindows只需修改以下44点即可。

  1. 通过WSAStartupWSACleanupWSAStartup、WSACleanup函数初始化并清除套接字相关库。
  2. 把数据类型和变量名切换成WindowsWindows风格。
  3. 数据传输中用recvsendrecv、send函数而非readwriteread、write函数。
  4. 关闭套接字时用closesocketclosesocket函数而非closeclose函数。

基于TCP的服务器端/客户端(2)#

5.1 回声客户端的完美实现#

44章中的回声服务器端会将接收的数据原样传回,因此问题不在服务器端,而在客户端只调用了一次readreadTCPTCP不存在数据边界,一次writewrite的数据可能需要多次readread才能全部读取,不能认为调用一次readread就一定能得到完整的回声。
回声客户端可以提前知道自己发送了多少字节,因此只要循环读取,直到接收字节数与发送字节数相同即可。

str_len = write(sock, message, strlen(message));
recv_len = 0;
while (recv_len < str_len)
{
recv_cnt = read(sock, &message[recv_len], BUF_SIZE - 1);
if (recv_cnt == -1)
error_handling("read() error!");
recv_len += recv_cnt;
}
message[recv_len] = 0;
printf("Message from server: %s", message);

循环条件使用recv_len<str_lenrecv\_len < str\_len而非recv_len!=str_lenrecv\_len != str\_len,是为了降低异常情况下接收长度超过预期后陷入无限循环的可能。

定义应用层协议#

回声客户端知道要接收的数据长度,但大部分程序无法提前得知。此时就需要定义应用层协议,通过规则表示数据边界,或提前告知对方数据大小。此前“收到QQ就终止连接”也是应用层协议的一部分。
书中通过计算器服务器端/客户端演示协议的设计,客户端发送多个整数和一个运算符,服务器端完成加、减、乘运算后返回结果。协议如下:

  1. 客户端连接服务器端后,先以11字节整数传递操作数个数。
  2. 每个操作数占44字节。
  3. 所有操作数之后传递11字节运算符,只能是++、-、*之一。
  4. 服务器端以44字节整数返回运算结果。
  5. 客户端收到运算结果后终止连接。
    所以客户端发送的数据可以表示为:
    [操作数个数:1字节][操作数:4字节 × n][运算符:1字节]
    因为同一数组中需要保存多种数据类型,示例将发送缓冲声明为charchar数组,并通过指针转换将intint型操作数写入其中。客户端根据操作数个数计算发送长度:
write(sock, opmsg, opnd_cnt * OPSZ + 2);
read(sock, &result, RLT_SIZE);

服务器端先读取11字节的操作数个数,再按照协议循环读取剩余的操作数与运算符,计算完成后将44字节结果传回。可以发现,应用层协议一旦确定,程序的收发顺序、每次需要读取的长度也就确定了。

5.2 TCP原理#

TCP套接字中的I/O缓冲#

writewrite调用后并非立即将数据交给对方程序,而是先把数据移入本套接字的输出缓冲;readread则从本套接字的输入缓冲读取数据。输出缓冲中的数据会在合适的时机传入对方的输入缓冲,因此一次写入的数据可以分多次读取。

替换文本
TCPTCP套接字的I/OI/O缓冲有以下特点:

  1. 每个TCPTCP套接字都有各自独立的输入缓冲和输出缓冲。
  2. I/OI/O缓冲在创建套接字时自动生成。
  3. 关闭套接字后,输出缓冲中遗留的数据仍会继续传递。
  4. 关闭套接字会丢失输入缓冲中尚未读取的数据。
    如果接收方输入缓冲只剩5050字节空间,发送方不会强行传递100100字节数据。TCPTCP通过滑动窗口协议SlidingWindowSliding Window)告知对方自己还能接收多少数据,接收方读取数据并腾出空间后再更新窗口大小,因此不会因为输入缓冲溢出而丢失数据。
NOTE

writewritesendsend在数据移入输出缓冲后就会返回,并不等待对方主机完成接收。但TCPTCP会负责继续传输输出缓冲中的数据,因此从协议保证的角度可以说数据传输已经完成。

TCP内部工作原理1:与对方套接字建立连接#

TCPTCP套接字以全双工(FullduplexFull-duplex)方式工作,连接建立后双方都能发送和接收数据。正式交换数据前,需要通过三次握手(ThreewayhandshakingThree-way handshaking)确认双方均已就绪。

替换文本
SYNSYNSynchronizationSynchronization的简写,用于同步双方的初始序号。SEQSEQ表示当前数据包的序号,ACKACK表示下一次希望收到的序号。为数据分配序号并进行确认,是发现数据丢失并重传的基础。

TCP内部工作原理2:与对方主机交换数据#

三次握手完成后开始正式收发数据。假设主机AA发送SEQSEQ12001200100100字节数据,主机BB接收后返回ACK1301ACK 1301。书中将确认号的关系表示为:ACK号 = SEQ号 + 传递的字节数 + 1

替换文本
ACKACK不仅确认数据包到达,还通过下一次期望的SEQSEQ号表示已经正确接收的数据长度。若数据包在传输中丢失,发送方在规定时间内收不到相应的ACKACK,计时器超时(TimeoutTime-out)后就会重传该数据包。这就是TCPTCP保证可靠传输的方式之一。

TCP内部工作原理3:断开与套接字的连接#

若一方直接断开连接,可能导致对方尚未发送的数据无法传输,因此TCPTCP断开连接也需要双方协商。双方各发送一次FINFIN并分别确认,整个过程称为四次握手(FourwayhandshakingFour-way handshaking)。

替换文本
第一次FINFIN只表示主机AA不再发送数据,主机BB确认后仍可以处理或发送剩余数据;等主机BB也准备好关闭时再发送自己的FINFIN,最后由主机AA确认,连接才完整断开。

5.3 基于Windows的实现#

本章介绍的TCPTCP缓冲、三次握手、数据确认和四次握手原理与操作系统无关,因此WindowsWindows平台没有额外的理论差异。计算器服务器端/客户端从LinuxLinux迁移到WindowsWindows时,仍按上一章的方式修改:使用WSAStartupWSACleanupWSAStartup、WSACleanup初始化并清理套接字库,以SOCKETSOCKET保存套接字,使用sendrecvsend、recv收发数据,并通过closesocketclosesocket关闭套接字。应用层协议及循环接收数据的逻辑均保持不变。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

TCP&IP网络编程学习札记_3
https://mkrari.cn/posts/tcp_ip_learn_3/
作者
Mkrari
发布于
2026-07-17
许可协议
CC BY-NC-SA 4.0