一、实验内容
backTCP 的目标是实现面向无连接的可靠传输功能,能够解决数据包在传输过程中出现的乱序以及丢包问题。由于考虑的是无连接的网络,因此该作业不需要考虑传统 TCP 中的三次握手连接建立过程。此外,假设传输中数据不会出现错误,因此只需要考虑如何解决数据包的乱序和丢包问题。
由于实验文档缺失细节,并且助教允许在非关键位置自由发挥,因此我的实现与实验文档中的内容有以下区别:
- 由于实验文档中指出 backTCP 是面向无连接的,因此我的实现采用了 UDP 作为传输层协议
- 出于各种考虑,我的实现采用了修改过的 backTCP 数据包头。与实验文档中指定的包头格式的唯一区别在于我的 backTCP 包头多了一个 1 字节的域
data_len
,使得包头总长度变成了 8 字节
因为上面两处修改涉及 backTCP 的底层结构,因此测试信道不能直接使用我公开的代码模板中的版本。当然,我也给出了一个修改版的测试信道程序,其后端实现与我的 backTCP 一致(尽管它是使用 Python 语言编写的)。该修改版测试信道在我的公开仓库的 udp-mod
分支中:https://github.com/iBug/backTCP-python/tree/udp-mod
二、实验环境
尽管我发布了一个使用 Python 语言编写的程序框架,但是由于我自己的代码很早就开始编写了,当初为了更加契合主题,直接使用 Linux 系统调用,因此我的代码使用 C 语言在 Linux 环境下编写。我自己经测试的系统环境为 Ubuntu 19.04 “Disco Dingo”,内核版本为 5.0,但理论上在任何较新的 Linux 系统中都可以正常运行,例如 Ubuntu 16.04, Debian 8 “Jessie” 以及 CentOS 7 等。编译环境需要 C 编译器和 Make (项目提供 Makefile
)
三、实现步骤
3.1 通用逻辑
3.1.1 backTCP 头
我采用的修改版 backTCP 头结构如下所示:
typedef struct _BTcpHeader {
uint8_t btcp_sport; // source port - unused
uint8_t btcp_dport; // destination port - unused
uint8_t btcp_seq; // sequence number
uint8_t btcp_ack; // acknowledgement number
uint8_t data_off; // data offset in bytes
uint8_t win_size; // window size
uint8_t flags; // flags
uint8_t data_len; // data length (excl. header)
} BTcpHeader;
各个域的意义如下:
-
btcp_sport
,btcp_dport
是助教给出的文档中的内容。我实在没想到它们有什么用,因此它们在所有数据包中都被置为零,并且被忽略 -
btcp_seq
为 backTCP 包序号,范围为 0~255,超出范围的取模处理(模 256) -
btcp_ack
为 backTCP 响应序号,指示下一个期望的包编号,而不是已全部收到的包中的最大编号(实际上等于该编号 +1)。这样做的一个好处是减少了多处的 +1 -1 运算,以及潜在的由它们带来的混乱 -
data_off
表示数据段的开始位置,本实现中应该恒为 8,因为 backTCP 头中不包含传统 TCP 头的可选字段(因此理论上它也是不必要的) -
win_size
指示当前发送窗口中剩余的包数量,因此连续发送的一串包中该字段应该逐渐减小至零,可供接收端参考以调整接收窗口(虽然实际上好像并没有根据发送窗口来调整接收窗口的逻辑) -
flags
在代码中有三个位是被使用的,但是实际上只有一个位 0x01 (重传) 会影响功能。另外两个无用字段为 0x02 (指示传输结束,实际采用另一种实现) 和 0x40 (模仿传统 TCP,指示该包为一个 ACK 包,由于各种原因未采用) -
data_len
是我自己加的,用于从被暂存的包中确定实际数据长度,避免产生错误输出
3.1.2 backTCP 连接句柄
为了方便使用与维护,我将底层的 UDP 套接字包装成为 BTcpConnection
句柄指针。使用方式与 FILE*
极为相似:
-
BTOpen
函数打开一个 backTCP 连接,初始化底层套接字与连接属性,并返回一个指向连接元信息的指针 -
BTSend
,BTRecv
等函数接收一个BTcpConnection
指针指示要使用的 backTCP 连接 -
BTClose
接收一个BTcpConnection
指针,进行清理工作,然后销毁这个指针
该句柄指针与 C 语言标准文件句柄指针的用法比较:
BTcpConnection *conn = BTOpen(...):
FILE *fp = fopen(...);
BTSend(conn, ...);
BTRecv(conn, ...);
fprintf(fp, ...);
fscanf(fp, ....);
BTClose(conn);
fclose(conn);
3.1.3 backTCP 连接信息
BTcpConnection
结构如下:
typedef struct _BTcpConnection {
int socket;
struct sockaddr_in addr;
BTcpState state;
BTcpConfig config;
} BTcpConnection;
其中:
-
socket
为底层套接字所使用的文件描述符 -
addr
指示另一端的地址(仅支持 IPv4) -
state
存储了连接的一些状态信息,因为一个BTcpConnection
可能会被多次用于调用发送或接收函数,需要存储中间状态,就像FILE*
可以多次用于调用fread
/fwrite
等函数一样 -
config
存储了连接的一些设置信息,例如最大数据段长度、接收超时时间、ACK 超时时间等。这个字段本来是准备提供通过命令行修改 backTCP 连接属性的功能的,但是由于时间匆忙未能实现。该字段在初始化连接时会被置为默认值(最大数据长度 64 字节,ACK 超时时间为 10 ms,这两个值与实验文档中的要求一致;接收窗口 10 个包,接收超时时间为 5 ms)
3.2 发送逻辑
对于发送部分,btcp_sport
, btcp_dport
和 btcp_ack
三个域是没有使用的(但是接收方返回的响应包中的 btcp_ack
字段会被检查)。
发送函数原型为 size_t BTSend(BTcpConnection* conn, const void* data, size_t len)
,各参数意义如下:
-
conn
为 backTCP 连接句柄,见 3.1.2 节 -
data
指向待发送数据,这些数据会被分割并包装成 backTCP 包然后发送 -
len
指示data
中数据的长度 - 返回值为实际成功发出的字节数
函数首先进行必要的初始化,例如分配一些缓冲区,从 conn->state
中恢复必要的状态信息等。
发送函数中主体循环的逻辑为:
- 检查窗口大小,从
data
中读取数据,拼装数据包,并将窗口中的数据包全部发出 - 等待对面发来 ACK 包,使用 poll(2) 系统调用来检测有没有收到包,或者超时
- 根据 ACK 结果作出响应
- 超时:将窗口中所有包重新发送一遍
- 收到 ACK 包:检查接收方发回的 ACK 值与窗口大小,调整发送窗口位置与大小,进入下一轮循环
- 发送完全部数据后,清理(释放申请的内存、将状态信息存回
conn->state
中)并返回
其中,主体循环第一次迭代时的发送窗口大小为 1,即仅发出一个包然后等待接收端的响应,方便根据接收端状态进行动态调整。
3.3 接收逻辑
对于接收部分,btcp_sport
, btcp_dport
, btcp_seq
, data_off
和 data_len
五个域是没有使用的(但发送方传来的后三个字段会被检查)
接收函数原型为 size_t BTRecv(BTcpConnection* conn, void* data, size_t len)
,各参数意义与 BTSend
相同,区别是 data
需要可写(这是显然的)
初始化工作与发送部分相似,但在进入主体循环之前会无限期等待第一个包(因为是面向无连接的),然后根据自身配置信息作出响应。
接收函数中主体循环的逻辑为:
- 等待并接收每一个包,直到以下事件之一发生,否则将收到的包根据
btcp_seq
序号放入暂存区(接收窗口)- 发送端指示发完了,即收到一个
win_size = 0
的包 - 本地的暂存区满了
- 超时没收到包(使用接收超时)
- 发送端指示发完了,即收到一个
- 收到的数据包如果遇到以下情况之一会被丢弃,而不是放入暂存区
-
btcp_seq
序号所指示的数据包已存在于暂存区中 - 该包含有错误的
data_off
或data_len
-
- 整理收到的数据包,将连续的数据包存入接收数据(即函数的
data
参数),然后移进窗口,并统计缺失的数据包。若有缺失的数据包,则发回的 ACK 包中会通过btcp_ack
和win_size
指示发送端将缺失的包发来,实现选择重传 (SR) - 若收到长度为零的包或接收数据区已存入足够的数据(通过函数的
len
参数指定),退出循环并清理
3.4 主程序逻辑
主程序负责处理命令行参数(使用 getopt 库灵活处理)并实际创建连接和发送/接收数据。命令行参数的用法可以通过 btsend --help
或 btrecv --help
命令查看,支持指定地址、端口等,其中文件名必须给出。
为了减少内存使用,同时由于后端 backTCP 也支持,文件将以 64 KiB 的块大小读入,按块发送或接收。
3.5 优点与不足
优点
- 能够可靠地处理丢包与乱序问题
- 支持选择重传(请加分)
- 实现方式为前后端分离,后端代码可以单独编译成库并在其他程序中调用(可以编译出
libbtcp.so
动态链接,尽管目前的Makefile
并没有指出如何编译,但是经测试这是可以正常使用的) - 支持在一个“连接”中多次调用发送/接收函数
缺点
- 命令行功能有限,例如不支持修改 backTCP 的属性等
- 窗口滑动采用 memmove(3) 而不是只修改索引,效率较低
- 前端程序仅支持单个函数的发送接收
- 部分异常未良好处理
- 指示关闭连接的 0 字节 UDP 包若丢包,会导致接收端无限等待(因此测试信道看到这个包就会原样转发,绕过“捣乱”逻辑)
3.6 测试信道实现
既然公开的测试信道是我写的,我就在这把它当做我提交的实验的一部分吧。
测试信道为了方便,使用 Python 语言编写,通过序列化 BTcpPacket.__bytes__
和反序列化 @staticmethod BTcpPacket.from_bytes
来在二进制序列和 BTcpPacket
类之间转换,同时使用 argparse
库提供丰富的命令行选项。
该程序在解析完命令行参数后,首先创建一个额外的线程将接收端返回的包原样转发给发送端,这是因为实验文档中(还是助教,我也忘了)说明了 ACK 包不会丢包。然后从一系列“动作”中随机选择一个,从发送端接收数据包并执行抽中的动作。目前测试信道支持以下 4 种动作:
- 什么都不做,直接转发给接收端
- 将包丢弃,除非包头指示该包为重传,此时什么都不做并转发
- 一次性接收两个包并将它们交换顺序后发出
- 一次性接收三个包,随机化它们的顺序,并随机复制或丢弃一个包。重传的包不会被复制或丢弃
若收到 0 字节的 UDP 包,则停止动作并将所有已收到的包发给接收端,然后关闭
四、结果展示
4.1 系统环境与编译
4.2 直接传输
4.3 经过测试信道传输
4.5 结论
根据控制台输出,可以看到,不论是直接传输还是经过测试信道“捣蛋”时的传输,都可以完整传输一个 150 KB 的文件。在有测试信道时,发送端和接收端都能正确检测丢包和乱序,并采取合适的措施确保接收到的数据块是完整的。同时接收端也能够使用 ACK 包头中的信息,让发送端仅传回丢失的包,然后从缓冲区中恢复完整的书架序列,说明选择重传的功能也正确工作。
Leave a comment