Introduction to Computer Networking是Stanford的网络课,据说lab质量很高,就把它作为转System后的第一个小系统吧!
准备工作
Stanford大气!能让我们这些野鸡学校的同学接触到最顶级的教育资源,甚至开放了lab,也希望大伙不要把题解po到github上!
因此我做的是Fall 2021的版本,所有的starter code都在这里。
至于如何在自己的github上备份代码,参考这里。
虚拟机平台用的是VirtualBox,144官方提供了基于Ubuntu的系统镜像,CPU和RAM随便设。
大致按照官方文档配置环境,windows环境可以用powershell,无需Putty。
开启虚拟机,通过ssh client建立TCP连接到远程主机的某端口:ssh user@remote -p port,user是在远程主机的用户名,remote是远程机器的地址(IP/域名),port是ssh server监听的端口,默认22(即登录请求会送进远程主机的22端口),上面通过-p参数改变了该端口。
不过遇到了点问题:
奇怪!远程虚拟机明明安装了ssh服务!
从主机去ping虚拟机超时,但是虚拟机可以ping通主机,参考这个设置成功ping通,后来发现国内的这些blog都是在胡说八道,真正的原因和解决方案在这里,本质上是虚拟机的端口转发没设置好,设置好后VB会把连接localhost:2222的TCP请求转发到虚拟机的22号端口。
关于IDE,开始用的VIM,后来想用vscode,Host上安装vscode以及remote-ssh插件,关于配置网上一大堆教程,自行学习吧,powershell以后就负责编译运行了。
Lab 0
Networking by hand
这些小游戏都是为了翻译翻译:什么是可靠的双向字节流,网络通过这种抽象完成许多重要的交互,如上网冲浪、发邮件等。
第一个事是要手动模拟浏览器的请求过程(注意手速,不然还没输完就408 Timeout了):
-
telnet cs144.keithw.org http:telnet作为一种client程序,负责和服务器的某个服务建立连接。用telnet客户端程序在本机和服务器之间开一个可靠的字节流,并请求服务器的http服务(80端口),连接成功证明端口可用 - 建立连接后就要通过HTTP协议请求内容:需要告诉服务器所请求URL的path和host:
GET /hello HTTP/1.1Host: cs144.keithw.org,不过为啥需要host呢?难道服务器不知道自己的ip吗,好像是因为服务器可以同时运行多个网站/服务 -
Connection: close:表示希望服务器一旦完成响应,就关闭连接 - 输入回车(空行):表示HTTP请求头结束,接下来是请求数据(当然GET没有,POST有)
其实这就是一个HTTP请求报文,效果:
作业就是瞎玩:
第二个事是学着发邮件,请求服务器的SMTP服务(主要用来发邮件),我试试和自己的邮箱互动下:
这里要注意:首先要开启IMAP/SMTP服务,还需要获取第三方客户端登录的授权码,登录时邮箱名称和授权码都需要Base64格式。
文档里说From地址是可以伪造的,有点神奇,垃圾邮件可能挺喜欢干这事!但是我实际操作时是伪造不了的:
因为已经登录了本人账户,所以发件人必须一致,Stanford那个没有登录,也许是商业邮件系统一般都比较完善?
第三个事是作为服务器去监听,主要使用所谓的瑞士军刀netcat:netcat -v -l -p 9090:-v表示显示执行命令过程,-l表示开启监听,-p表示在指定端口监听telnet localhost 9090:
然后服务器(netcat)和客户端(telnet)就可以通信啦!
Network program using an OS stream socket
这部分让同志们利用操作系统内核提供的stream socket从Internet上抓网页,和上文中手动抓差不多,不过这次是把手动过程写成代码。
由于Internet只能提供尽最大努力交付的数据报服务,因此这些数据报可能会:丢失、乱序、内容更改、重复,所以通常OS会把Internet的这种抽象转为可靠的双向字节流,以便应用层软件使用。OS一般使用socket来完成这种转变并向程序员提供接口,socket和文件描述符类似,一旦建立连接就能进行可靠的通信。后续会自己实现一个TCP去揣摩这种转变。
这个简单的web client程序有几个要注意的地方:
- 由于
connection: close,因此服务器只会处理一次http请求 - 服务器响应后就会关闭从server到client的socket连接,但是client的
socket.read()可以持续读:If the connection is broken on a stream socket, but data is available, then the read() function reads the data and gives no error. If the connection is broken on a stream socket, but no data is available, then the read() function returns 0 bytes as EOF. - EOF一般是一个定义为-1的宏,因此没有对应的ASCII字符,因此也无法显示出来(可以强制转int),C语言将其定义在某个头文件的宏里(可以直接用EOF判断),C++一般使用函数判断。EOF的作用就是client可以判断是否读完了server发来的响应,终端输入windows环境是ctrl+Z,linux是ctrl+D
- 为什么一个
read()不够呢?因为read()是有limit的,超过上限就得多次读,std:string FileDescriptor::read(const size_t limit=std::numeric_limits<size_t>::max()) - 及时关掉socket的写功能是一个好习惯
An in-memory reliable byte stream
在单机上实现一个可靠的字节流(内存里当然是可靠的),writer可以结束字节流输入,reader读到EOF后就无法继续读。
基本可以理解为一个容量为capacity的buffer,capacity用来进行流量控制,文档说了只会进行单线程操作,因此不用担心并发的读/写。
需要注意:流本身可以无限长,capacity存储的是已经写入但还未读取的字节,哪怕capacity = 1,只要writer每次写入一个字节,reader读走,这个流就可以无限长。
开始想用queue,但是queue无法支持peek_output操作,那就用deque了。size_t write(const std::string &data):如果长度大于capacity该如何处理?这种情况多余的写入只能被丢弃,就和网络上超出线路容量的写入被丢弃一样。size_t bytes_read() const返回的是所有pop的字节数目,包括read(const size_t len)和pop_output(const size_t len)。bool input_ended() const返回流输入是否结束;bool eof() const是reader判断是否读取到了流输出的结束位置,因此必须满足writer已经有过写入且buffer为空。
记得先make format,再make编译,最后make check_lab0自动化测试。
Lab 1
接下来的4个lab要自行实现一个TCP,模块如下:
由于sender会将发送的字节流分割为若干segments,每段不超过1460B,封装为数据报交给网络传送,但这些segments可能会乱序、丢失、重复、交叉重叠、长度不一,但是不会出现inconsistent的段,因此Lab 1要实现一个流重组器,将收到的字节流中的segments拼接还原为其原本正确的顺序。
StreamReassembler会用一个可靠字节流ByteStream作为输出:as soon as the reassembler knows the next byte of the stream, it will write it into the ByteStream. 接着应用层就可以从ByteStream读取有序的字节流。StreamReassembler和ByteStream的容量大小是一样的,不过ByteStream真正的size(绿色部分)是动态变化的。
push_substring(const string &data, const uint64_t index, const bool eof)一旦超出StreamReassembler的容量,就只能丢弃该碎片(或者丢弃部分);
根据上图:可以想象为我们拥有一条index从0开始的无限长的字节流,每个段都有自己在流中的位置,随着应用层读取流中的数据,StreamReassembler就像一个滑动的窗口,落在该窗口内的段都需要被按序组装。
显然,需要用某种数据结构把不能直接写入ByteStream中的segments存起来:data+index即可唯一确定,因此单个segment可以用类、结构体或std::pair存储,为了方便起见,在segment结构体中增加成员变量len来指示其有效长度。
由于可能需要根据index快速查找合并位置,因此最好按序存储,并且自动去重,所有不能写入的segments可以用std::set来存,底层基于红黑树实现。
处理逻辑:
- 新来段是否超出/部分超出了
StreamReassembler的窗宽,如超过则进行剪切; - 新来段是否和
ByteStream之前(蓝色+绿色部分)有重叠,如有则切除重叠部分; - 合并新段和暂存段:确定新段插入位置,不断将其前后的暂存段往新段上合并,直到找不到可以继续合并的暂存段;
- 判断能否写入
ByteStream; -
处理后新段的eof为true:
若暂存区为空,结束向结束写入的时机可能会导致潜在bug,后面有血泪教训;若暂存区非空,ByteStream的写入报错,可能是last segment先到达但还不能写入,因此存入暂存区。
根据上述逻辑准备用3个函数完成:
-
void _cut_overlap(segment &seg);完成12 -
void _merge_segs(segment &seg);完成3 -
void _write_to_stream();完成4 - 直接在
push_substring()处理5
这个实验一般就会开始出bug,我直接跪在了corner case,来了一个eof为true的"",空串是要被忽略的,但是这个空串带了我们需要的eof信息,由于在_cut_overlap直接返回:
if (seg.index >= _first_unacceptable || seg.index + seg.len <= _first_unassembled) return;
所以没有正确设置_eof:
测试样例t_strm_reassem_single报错,所有的测试源码都在./tests文件夹下,对应的可执行程序在./build/tests。sudo apt-get install gdb安装GDB,找到对应的测试源码文件fsm_stream_reassembler_single.cc打断点开始调试,跳出launch.json稍作修改就可以愉快地debug(面向测试编程