听说 cs144 的代码量不大,难度也不高,正好前几天刚发现今年的 cs144 github 仓库已经开放了,所以打算写一下新的。
如果你不知道如何快速搭建一个适用于 C++20 的环境,可以参考本文。
课程主页
check0.pdf
提示:本文建立在你有一个良好的代理环境的前提下。
课程本身对 AI 工具的态度比较有意思,就是把 GPT/GitHub Copilot 这样的工具当成已经做过往年的 cs144 的学生。其他的要求就是别公开你的代码、也不要抄袭别人的代码之类的。
课程推荐了 vm 虚拟机、google cloud 还有 ubuntu 原生系统什么的;显然在国内 google cloud 什么的就不用考虑了,我也没有双系统打算,vm 也很笨重,所以就打算使用 WSL2 + Docker 搭建一个镜像,然后使用 VSCode + Docker 的方式编写代码。
安装 WSL:在 Microsoft Store 里搜索并安装
Windows Subsystem for Linux
,
然后下载并安装 WSL2 升级包1,
打开 cmd/Powershell 执行wsl --set-default-version 2
,
安装过程中在不同步骤之间看心情重启你自己的电脑。Docker 入门
check0.pdf 告诉我们:
评测时使用的系统至少是 Ubuntu:23.10,为了避免麻烦我就从 Ubuntu:23.10 开始搭建镜像。然后需要注意的是前面那串 apt-get
的指令中有部分软件是文档文件(带 doc 的),为了加快安装我去除了这部分内容。
考虑到我们有可能在玩弄调试环境时搞坏容器环境,所以我使用 Dockerfile 搭建一个相对客制化一点的镜像出来。下面是我用到的 Dockerfile:
FROM ubuntu:23.10
ARG USR=你自己的 GitHub 名
ARG EMAIL=你自己使用 Git 时的邮箱
ENV TZ=Asia/Shanghai \
DEBIAN_FRONTEND=noninteractive
# 部署环境
RUN echo 'root:为 root 用户设置一个密码,不在这里设也可以,但记得进入系统后要设置' | chpasswd \
&& apt-get update -y \
&& apt-get install -y sudo vim wget git zsh \
&& apt-get clean -y \
&& userdel -rf ubuntu \
&& useradd -ms /bin/zsh ${USR} \
&& usermod -aG sudo root \
&& usermod -aG sudo ${USR} \
&& echo '你的 Git 用户名:你的用户在 Ubuntu 中的密码' | chpasswd
USER ${USR}
COPY --chown=${USR}:${USR} ./*.sh /home/${USR}/
RUN sh -c "$(wget -O- https://install.ohmyz.sh/)" \
&& git config --global user.name "${USR}" \
&& git config --global user.email "${EMAIL}" \
&& mkdir -p /home/${USR}/cs144
WORKDIR /home/${USR}/
可以看到这个镜像预先安装了 sudo
、vim
、wget
、git
和 zsh
,你也可以自己定义自己想要的软件。我用 zsh
纯粹是因为它比 bash 好看,并且还在最下面的 RUN
语句中从 https://install.ohmyz.sh/
安装 oh-my-zsh
配置 zsh
样式。
具体其他的指令细节不在这里阐述,可以参考 Docker 的官方文档。
构建得到的镜像最终包含以下两个用户:
root
用户;sudo
权限的普通用户。此外,还需要注意到在 Dockerfile 中有一个 COPY
部分,这里表示从宿主机拷贝一个文件到镜像中。这是因为如果在 Dockerfile 中直接执行 apt-get
安装课程要求的一大堆环境依赖,就很容易受到网络环境波动导致安装失败,进而导致镜像的构建失败。
所以环境依赖的安装需要在建立容器后再进行。在与 Dockerfile 同目录下创建一个文本文件 start-install.sh
,并写入以下内容(注意记得把文件的行尾序列转为 LF):
sudo apt-get -y install cmake gdb build-essential clang clang-tidy clang-format pkg-config tcpdump tshark;
directory="/home/$(whoami)/cs144/minnow"
repository="https://github.com/cs144/minnow"
if [ ! -d "$directory" ]; then
mkdir "$directory"
git clone "$repository" "$directory"
echo "Repository cloned successfully into '$directory'"
else
echo "Directory '$directory' already exists. Skipping cloning."
fi
这个 shell 脚本的功能是先使用 sudo apt-get
安装依赖项,然后在家目录下创建目录结构 cs144/minnow
,如果这个目录结构已经存在,就不会执行 git 拉取仓库。如果你的仓库来自其他仓库,就需要把 repository 的网址替换掉。
把 Dockerfile 的中文内容改成你自己想要的内容后,在 Dockerfile 所在的文件夹下使用命令行执行 docker build -t cs144-image:2024 .
并等待出现以下信息,就表示镜像已经构建完成。
如果构建失败了,那就多执行几次 docker build
直到成功为止(一般都是 502 错误导致的)。
顺便一提,Ubuntu:23.10 的国内镜像源截止目前只有中科大的 https://mirrors.ustc.edu.cn/repogen/
,但是我在切换到这个镜像源时 apt 一直告诉我软证书验证失败,所以我干脆一直开着代理算了。
另外,Docker 允许我们将容器中的某个目录挂载到宿主机上,也就是说我们可以将容器内的某些东西保存在我们的 Windows 文件系统中。为了防止每次跑容器的时候都需要重新拉取仓库,我就把 /home/你的用户名/cs144
这个文件夹挂载到了我自己的电脑上。挂载需要在运行容器时指定,具体使用的指令是 docker run -itd --name cs144-lab --mount type=bind,source="你的 Windows 文件系统路径",target="/home/把这里替换成你的用户名/cs144" cs144-image:2024
。
不懂这部分或者不想这么干,可以执行这个:docker run -itd --name cs144-lab cs144-image:2024
,这样就不会挂载数据了,不过记得要更改 shell 脚本中的仓库名称,还要记得及时 commit。
接下来我们进入容器中完成那一大串 apt-get
的环境依赖配置。怎么进入容器就不说了,我使用了 vsc 的 Docker 插件访问容器,你也可以用命令行连接到容器中。进阶一点的话可以配置一下 SSH,然后用 SSH 连接到容器。详情可以参阅 Docker 文档。
进去后可能会显示在 cs144 目录下,总之先回到家目录下执行刚刚编写的脚本。
一定记得不要使用 sudo ./start-install.sh
,这样会导致拉取的仓库被存放在 /home/root/cs144
而不是当前文件夹下,这里直接使用 ./start-install.sh
执行就 ok 了。
可以看出来环境需要占用将近 1.5 GB 的空间,而且类似于 tshark
的软件包在安装时还需要进行额外的确认工作(不过由于 Dockerfile 中设置了环境变量 DEBIAN_FRONTEND
,实际上是不会出现交互选项的),所以不把这部分内容放在 Dockerfile 中操作是正确的。
如果这部分内容显示 502 之类的错误,那就多执行几次脚本,直到软件包全部安装完毕。
配置完成后尝试 cmake 编译一下,可以发现编译工作丝滑地完成了。
由于 cs144/
文件夹被挂载在宿主机中,所以也可以在 Windows 的文件资源管理器中看到拉取的仓库文件。
喜欢折腾的话到了这里就可以开始装饰 zsh
了。
这一节主要是使用 telnet 访问远程终端之类的东西,要注意的是如果是使用 Docker 搭建的镜像,这里要自己安装 telnet
和 netcat
。
Send yourself an email 部分由于我没有 stanford 的邮箱(笑),也懒得使用我自己的邮箱服务倒腾,这里略过就好了。
Listening and connecting 部分比较有意思点,使用 netcat
在本地建立一个双工通信。先安装一下 netcat
,然后跟着指南走就可以看到结果。
再开一个终端窗口,使用 telnet
连接到本地 9090 端口,随便发点什么过去。
可以看到 netcat
窗口已经收到了刚刚发送的东西。
如果是从镜像搭建起的环境的话,直接使用 cmake
编译是毫无问题的,而且所有的环境配置已经在 start-install.sh
中完成了。 比如这里先进入 build/
目录编译一下:
课程本身很鼓励使用 Modern C++ 的特性,而且我正因为喜欢这点所以才入坑的。
代码规范方面看 check0.pdf 就好,简要概括就是:
std::string
作为字符串对象,以及 C++ 的四种类型转换符;cmake --build build --target tidy
静态分析代码获取优化建议,使用 cmake --build build --target format
格式化代码(是指调整代码布局而不是删光光)。正常遵循 Modern C++ 编码规范就好,总而言之就是使用 RAII、更加面向对象。
此外课程推荐尽量频繁地提交每次的工作进度,并且用心写好 commit messages 方便辨识每个 commit 的作用(也方便你回滚代码)。
如果不知道怎么做的话,先去查看实验文档,然后还可以在这个文档中查看所有的 Minnow Support Code 细节(工具函数一般都在 minnow/util
中)。
注意一下上面的文档提到: TCPSocket
继承自 Socket
,所以别忘了一起把父类的共有接口看一下。
在 Writing webget 中需要补充 /apps/webget.cc
内的拼接 HTTP 请求的函数,补充完毕后在 build
文件夹下 make
编译一下(这里前面已经做过了),然后执行 ./apps/webget cs144.keithw.org /hello
。
文档这里告诉我们 HTTP 协议本身是基于 TCP 协议传输数据的,所以在 get_URL
中需要使用一个 Socket 将拼接好的 HTTP 请求传输给接收方。并且 HTTP 需要的行尾序列是 CRLF 而非 LF,而 socket 接收到的信息的结尾是一个 EOF
符。所以参照 Session 2.1 的请求命令把函数补充完整就行了。
要小心的是:给好的 TCPSocket
的一整条继承链上的所有类都没有实现析构函数,也就是说你不能依靠 RAII 式语义要求 TCPSocket
对象自动释放连接。简单点说:记得手动调用 socket 对象的 close()
方法。
到了 check0 中最有意思的部分:课程需要我们根据给定的代码框架,在文件 src/byte stream.hh
和src/byte stream.cc
中完成一个可靠字节流对象,并且在补充完成后测试实现能够到达的速度(bit/s)。
根据 check0.pdf 的要求,将要完成的字节流对象需要做到以下几点:
EOF
字符标识字节流的结束;然后 pdf 还提到了这里只有单线程,不用考虑对象的读写抢占和加锁问题。接下来看一下给好的代码框架:
比较有意思的是代码用了一个持有所有状态变量的基类 ByteStream
,然后从这个基类中派生了两个实现了不同功能侧写的类 Reader
和 Writer
;不难猜出程序会在 reader()
和 writer()
方法中将基类做类型转换变为派生类。
并且从注释里可以看到课程要求我们把所有私有变量放在基类 ByteStream
里,显然就是为了防止强制类型转换时因为类的大小不同导致潜在的 UB 行为。
在派生类部分,可以注意到 Writer
和 Reader
的字节存取都基于 std::string
,那很明显我们要补充的私有变量的底层数据类型至少也是 char
(而且 char
的大小恰好是 1 字节)。而且很不同寻常的是,Writer
的 push()
方法的参数是一个值类型的 std::string
。
正常来说如果我们想要传递一个对象类型,一般都会使用只读引用语义 const T&
,或者说对于 std::string
可以使用视图语义 std::string_view
;在 C++ 中,对象的传递使用值语义时一般都表示“在函数内部无论如何都会产生一次不可避免的复制”,或者是“对象必然需要在函数内部被修改,且修改操作与外界无关”。很显然,我们在管理传入的字节流时不需要对字节本身做任何修改,那么这只剩下一个可能:无论如何我们都需要将外界的 std::string
拷贝并存储在我们自己的内部容器中。也就是说课程在要求我们存储外界传进来的 std::string
对象本身。
并且我们都知道 C++11 后对于所有数据类型都存在一个“左右值”语义之分,那么很明显对于这个在函数参数内的左值类型 std::string
对象,我们需要将它右值转发(使用 std::move
)给稍后我们要实现的内部容器,以避免无意义且耗时的拷贝工作。
再来分析一下字节读取时需要做的操作。根据需求,所有存入对象的字节都必须被按序取出,并且读端一旦取出一个字节,写端就必须立刻能够多接受一个字节,除非它被关闭。这听起来这很像一个“在无限长字节序列上滑动的窗口”,被窗口包围的部分就是能够被读端读取的字节(也是被写端写入的字节);这个窗口在处理输入的字节流时有个很特殊的性质:FIFO。
到这里就已经可以知道我们实际上要补充什么了:一个 FIFO 语义、能接受右值形式(因为需要避免拷贝开销)的 std::string
对象的容器,一个“滑动窗口”的实现,以及一些状态量。前面的容器估计提到 FIFO 就知道 STL 有什么满足这个条件了,这里需要关注的是滑动窗口这个东西怎么实现。
peek()
方法要求我们返回一个 std::string_view
对象,用于查看字节序列中未读取的部分,实际上就是返回一个窗口。并且已知所需的内部实现是存储了多个 std::string
的容器,所以必然需要在 peek()
中利用当前容器顶部的 std::string
给出一个在某一范围内的 std::string_view
视图。为了和 pop()
的操作保持一致,我们还需要在内部维护一个指向容器顶部的 std::string
的首字节的偏移量,使得我们能够知道应该给出的窗口范围,并且在该范围减少到 0 时立即丢弃容器顶部的 std::string
。
所谓的“偏移量”的维护方式既可以是手动控制一个数值类型游标,也可以使用 std::string_view
配合它的成员方法 remove_prefix()
实现。考虑到降低心智负担,我实际上使用了 std::string_view
实现这个窗口。
补充完所有细节,并且确认已经考虑到大部分边界条件后,就可以在 minnow/
文件夹下执行 cmake --build build --target check0
测试代码,并查看一下测速是多少。
文档提到“大于 0.1 Gbit/s 的速度都是可以接受的”,而且这里测速的结果和机器性能强相关(所以测个两三次取个平均值看个乐就好了)。因为我使用了 Docker,所以测得的速度相对会比较高:
比较好笑的是不知道为什么每次都是第一次编译测试得到的速度最高。
最后别忘了把文件推送到一个私有仓库里(推送前记得先清除原始的远端仓库)。
升级包程序来自微软。 ↩︎