基础设施
「工欲善其事,必先利其器。」
计算机系统一瞥
作为人类工业和智慧的结晶,计算机系统拥有无比强大的硬件,使得它能够精细、高速地执行指令,操作数据。在此之上,还有前人用千万行代码筑成的,由操作系统和应用程序构成的庞大软件生态。利用现代计算机系统,我们可以方便地学习,娱乐,购物,或利用编程达成我们想做的任何事情。
计算机系统中的基础设施
前人搭建了层级有序的计算机系统,填充以丰富的工具和软件,还封装了完善易用的接口。在计算机系统中,这些被接口封装/抽象了的可用实体就是基础设施。
名词解释
在这里,让我们围绕接口解释一些在计算机系统设计中十分重要的名词。
首先,什么是接口? 如果你没有编程经验,接口这个词也许只能让你联想起诸如电源接口或者 type-C 接口之类的物理接口,而它们提供的是设备的输入/输出通道。 同理,接下来讲的接口也是系统中实体的输入/输出通道。
接口的英文原词是 Interface,除了翻译为接口之外,这个词还被翻译成「界面」或「介面」(台湾)。 可以总结出,它指的是一实体和外界或另一实体以事先约定的方式进行沟通的渠道。 这种事先约定的方式可以被称为接口的语言或协议。
如果我们只允许实体通过预先定义的接口与外界交互,对于和实体交互的其他实体而言,该实体的具体操作细节就被隐藏(或者说变得透明),而实体本身则为其提供的接口所定义,我们把这称作对实体的抽象或封装。
例如,我们使用的图形界面(GUI,Graphical User Interface) 其实就是我们和计算机交互(人机交互)的接口之一,其语言就是图形输出和键鼠或触摸输入。 对于只使用 GUI 的一般终端用户而言,整个计算机的运行细节对他们是透明的,他们认知中的计算机就由图形界面/接口所定义。
又如,应用程序编程接口(API, Application Programming Interface) 定义了软件模块之间通过调用或请求访问的,基于特定数据格式进行交互的接口。我们用它对程序对象或者软件模块进行封装。在项目后续阶段,我们会进一步接触 API。
总而言之,计算机系统中的可用实体,或者叫做基础设施,都是以封装或抽象后的接口的形式出现的。 在项目后续阶段,我们也会接触到各种各样的接口,而进一步加深我们对它的认识。
计算机系统层级
宏观看来,计算机系统中的抽象存在一定层级结构。例如,操作系统
在后续的学习中,你会渐渐理解这些抽象层次的原理,并了解到如此设计的意义。
举个基础设施的例子,项目通过 Make 为大家自动化了编译和启动的流程。你可以通过 make clean; make compile -n
来观察 Make 为你省去的功夫,想想你需要多少时间来手动输入这些代码?如果你一学期编译项目几百次,那么浪费的时间将是海量的。好的基础设施能够提高开发效率。「时间就是金钱,效率就是生命」。基础设施的重要性可见一斑。
也许你还能发现,「基础设施」的建设的本身也在利用之前的基础设施。 比如上述的 Make 本身也是 GNU 建设的基础设施的一环。 计算机世界就这么通过层层抽象的基础设施实现着指数速度的扩张。
计算机系统中的编程思维
理论部分的教学中强调教会大家计算思维,而编程思维作为计算思维中的重要实践外化,也是程设部分竭力要教会大家的。从基础设施的角度看来,编程思维可以作如此阐释:
- 利用基础设施:学会利用已有的抽象完成看似困难的工作(比如本讲义网站就是用 mkdocs-material 框架搭建的)。
- 建设基础设施:尝试利用计算机的力量自动化自己的工作流程(这里的编程不一定非得使用代码作为接口语言,比如所谓低代码的 Power Automate)。
本部分受到 PA 讲义的启发,详见其简易调试器一节。
探索计算机系统的原则:深入浅出
make -n
不执行真实的命令,而是仅输出它想要执行的命令。这是一种反抽象,把抽象的细节展开显示的命令。此种命令的存在折射出探索计算机系统时「深入」和「浅出」原则的对立统一。
计算机系统利用逐级抽象省去了大量人力工作的同时,也省去了大量水面下的细节(即所谓「透明」)。但鉴于「计算机科学与技术」专业的培养要求和更好利用计算机系统进行工作的现实需要,我们需要时刻问问自己「这个效果是怎么实现的?每个抽象层级都做了些什么?」。只有理解了这些,在开发和调试时才能通行无碍。
而在实际操作中,我们则要继续利用我们对计算机系统的理解进一步进行抽象,透明化繁复的细节。此之谓「深入」而「浅出」。事实上,在大家后续学习计算机系统系列课程的过程中,也都离不开这样的原则。
认识游戏启动器
闲话说了太多,我们赶快进入正题吧。
为什么老讲这么多大道理?
我国的老话说:授人以鱼不如授人以渔。传统地,鱼可以理解为成果,而渔可以理解为方法。
但高中哲学知识告诉我们,方法也许也只是「鱼」,真正在我们认知和改变世界中起着「渔」的作用的应当是方法之上的方法论(methodology)。对于我们的教学而言,完成项目只是训练手段,而藉由这种手段实现的目的则是教会大家如何开发一个项目,而更进一步地,我们希望能一定程度地教会大家如何编程。
当然,真正的心得还是要在实践中获取,平淡的文字也难以传达深刻的体会。如果你对编程毫无经验,那看完一段段的大道理也许会懵逼——但这是正常的,不必惊慌。在完成项目后,抑或是完成 PA 以后,甚至是本科毕业以后,你也许会多少会想起这些大道理。某种意义上,我们写这么多大道理,提供的也是一种「回味」。
在上一部分你应当已经拉取并运行了框架代码。事实上,框架代码编译为两个独立的可执行程序。一个是游戏主体,另一个则是游戏启动器。在你先前执行的两条命令中, make compile
编译的是游戏本身,而 make shell
会编译并运行游戏启动器。它们的可执行文件都存放在 build
目录下,分别叫做 nju_universalis
和 shell
。
游戏启动器是游戏的「外壳」,也同样是我们项目开发的重要基础设施。一个功能强大,支持调试的启动器可以帮助我们省去许多麻烦。
在第零阶段,我们的主要任务是完善游戏启动器的功能,使得它可以:
- 在启动器运行时重新编译游戏(全部重编译/增量重编译)。
- 可选以调试模式启动游戏(运行 gdbserver 并启动调试端口)。
- 可选用 gdb 连接上正在运行的游戏进程(无论是否用调试模式打开)或用 gdb 连接调试模式的 gdbserver。
要完成上述任务,我们必须理解游戏启动器的源代码。幸运的是,启动器是一个单文件 C 程序,理解起来较为容易。它在项目中的位置是 wrapper/shell.c
1。
shell.c
在大部分代码经过封装的前提下仍有一百多行,阅读起来也并不容易。幸运的是,我们可以根据我们使用它的经验和 main()
函数的行为理解它执行的流程。
启动器的 main()
函数只做了一件事:调用了 cmd_mainloop()
函数。而这个函数所做的事情要复杂一些,它进入了一个无限循环等待的用户的输入,每得到用户的一行输入,就进行解析,并根据解析结果执行特定的任务。
轮询设计
许多需要持续和用户交互的程序都具备类似的设计:开启一个无限循环,不断监听用户的输入或其他事件并进行处理。我们把这种设计称为轮询(polling)。在项目的后续部分我们还会见到类似的设计。
具体来说,解析时启动器利用字符串处理函数将输入的命令以空格为分隔符分成了若干 token
,并将这些 token
存放入 argv
数组中。
此后,启动器会以第一个 token
为命令名在 cmd_table
中检索命令,并执行对应的处理函数。
从上述流程可以看出,cmd_table
是启动器保存命令信息的核心数据结构。只要理解了它,增加新的命令便不是难事了。
注意我们要求你在 cmd_table
中添加新的命令,如果你绕开它添加命令,可能会因为代码结构不佳而被酌情扣分。
学习框架代码
阅读他人的代码是学习掌握一门编程语言的重要一环。他人的代码可能涉及到你从未使用过的语言接口与特性,也可能使用了一些比较特殊的设计方法。在试图理解这些内容时,你也可以学到新的知识。
就启动器代码而言,你可能并不熟悉里面使用的 strchr
、strtok
等库函数,也可能对 int (*handler) (char**, int)
这样的函数指针产生困惑。因此在阅读代码时,你需要自行搜索并理解相关内容。
添加一条命令
试着添加一条没有太多实际意义的命令,例如 hello
,执行后输出 hello world!
。
怎么用不了 cin
/cout
了?
启动器是用 C 编写的,不能使用 C++ 的语法特性和库函数。
完成重编译命令
有时你会遇到一个讨厌的场景:你修改了游戏项目的代码,想要重新运行它看看效果,但是为了重新编译游戏,你需要退出游戏启动器,执行 make compile
命令后再重新启动游戏启动器。这种操作无疑是很浪费时间的。
为了实现这个功能,我们需要在 shell.c
中添加代码,使得其支持 compile
命令。
增量重编译
我们已经写好了增量编译的 make
脚本,你只需要能够在程序中执行 make compile
就行。
stdlib.h
中提供了对系统命令的执行函数 int system(const char *)
。
因此,一行代码就可以实现重编译的功能:
system("make compile");
添加 compile
命令
为启动器添加对 compile
命令的支持。在执行不带任何参数的 compile
命令时增量编译程序。
全部重编译
make
的编译是增量的:它只会编译那些修改过了的文件,跳过自上次编译没有修改的文件。
但有时这种增量机制可能出现问题,抑或是我们确实需要重编译整个项目。在这时,我们需要在调用 make compile
之前删除所有先前的编译产物,这可以通过 make clean
做到。
实现具备参数的 compile
命令
除了命令名,参数也是一行命令的重要组成部分。它会被传给执行命令的函数或程序,拓展了命令的表达能力。
在描述启动器运行机制时,我们提及用户的输入会被分成若干 token
存到 argv
中。这个数组名字的意思就是「参数值」,并会在处理函数被调用时传给处理函数。
因此,只需要在处理函数中检查 argv
的值,并添加一些条件判断,就可以实现具备参数的 compile
命令了。
具体地,我们要求启动器在执行 compile -c
命令时重新编译整个项目。
完成调试模式
Linux 用户请注意
由于部分发行版不会在安装 gdb 的同时安装 gdbserver,你可能需要手动安装 gdbserver。
在开发项目时,我们不可避免地会遇到 bug,因此就需要 debug。相信大家在完成 OJ 的时候已经体会过了 debug 的流程,尤其是输出调试法。在这里我们将接触一个更加强大和便捷的 Debug 工具——GDB,并将 GDB 调试功能集成到项目中。
那输出调试法呢?
别忘了我们的项目日志,这就是一种输出调试法。
你可以阅读框架代码中的 minitui/include/debug.h
获取详细信息。
GDB 简介
友校 ZJU 的 2023 秋季 OS 实验讲义介绍了 GDB 的功能和基本使用方法,请移步这里阅读。
尝试使用 gdb
尝试着使用 gdb 调试一个简单的 A+B 问题的程序。
你可以尝试的一些指令:
- b 设置断点。
- c 令程序继续运行,直到执行到某断点时停下。
- n 单步执行,跨越函数调用。
- s 单步进入,会进入函数调用。
- p 打印变量的值。
记得在编译程序时打开生成调试信息的选项 -g
。
令 GDB 附加到游戏上
GDB 需要使用标准输入输出进行用户交互,而我们的游戏也需要使用它们,这会和 GDB 产生冲突。因此我们不能直接在启动器中执行 gdb build/nju_universalis
这样的命令。
而且有的时候游戏没有以调试模式启动,但我们还是发现了 bug,并希望在不终止游戏的情况下进行调试。在这些场景下我们需要令 GDB 在附加到正在另一终端窗口运行的游戏中,并在现终端中和用户进行交互,不影响游戏的终端窗口。
幸运的是,GDB 直接提供了这样的功能,可以通过 gdb -p PID
来附加到正在运行的程序中。那么如何获取游戏程序的 PID 呢?我们这里提供一个简单直接的方法:让游戏自己把 PID 写到 build/pid.info
中去。
至于如何获取用户程序的 PID,你就需要自己上网搜索了。注意 Windows 和 Linux 获取 PID 的方法是不同的。
此后在启动器中,你就可以在 debug
命令执行时检查 build/pid.info
是否存在,若存在则读取其中的 pid
作为 gdb 的参数。
如何进行文件操作?
请移步指南的这一页,学习 fopen
的使用方法。
如何将 pid 写到命令字符串中?
请自行学习 sprintf
的使用方法,它和 printf
类似,但可以将输出定位到字符串流中。
添加 debug
命令
按照上述介绍的流程,按序完成如下任务:
- 在
minitui/source/minitui/init.cpp
的tui_init()
函数中将程序 PID 写入build/pid.info
。 - 添加
debug
命令,并在执行该命令时试图获取build/pid.info
的 PID 并令 GDB 附加到程序上。
注意如下两点:
- 在初始情况下,程序会闪退而不会留下令 GDB 附加的时间。因此,你需要根据闪退日志定位闪退的地方,并在闪退处使用
while(1){}
阻止程序闪退(但在第一阶段开始时你需要删除这段代码)。 - 你需要检查
build/pid.info
是否不存在。
如果它不存在你需要报错,如果它存在而游戏没有在运行,你不需做额外的处理。
添加调试模式
上一个方法没法从程序一开始执行时就用 gdb 跟踪它,而有时我们需要用到这个功能。
因此,我们需要改造 game
命令,使得在加上 -g
参数时它会以调试模式启动这个程序。上面说过,我们必须把游戏执行的终端和启动器执行的终端分开,因此我们需要使用 gdbserver 在另一终端启动游戏,并指定其监听某端口,如 8117
。
同时,我们需要让启动器支持连接上调试模式的程序。
由于这里涉及的技术细节比较繁难,我们直接为大家准备好了需要的命令。
以调试模式启动游戏
wt --size 100,41 --pos 100,100 gdbserver :8117 build/nju_universalis
konsole -e gdbserver :8117 `pwd`/build/nju_universalis -p TerminalColumns=100 -p TerminalRows=40 -p ICON=`pwd`/resources/dbcq.ico 2> /dev/null &
连接上调试模式的游戏
gdb -ex "target remote localhost:8117" -ex "file build/nju_universalis" -ex "b main" -ex "c"
gdb -ex "target remote localhost:8117" -ex "b main" -ex "c"
完善调试功能
你需要按照上述描述完善 game
和 debug
的功能。
其中,game
接收到 -g
参数后会以调试模式启动,debug
接收到 -r
参数后会试图连接处于调试模式的游戏。
作业提交
在完成以上任务后,你需要提交第零阶段作业。
请仔细阅读要求
经历过中学阶段教育的大家应该懂得,无论作业完成情况如何,没交就是没做。
且由于项目的工程性质,提交项目作业的流程可能对于没有经验的同学比较复杂,但按要求提交作业对于我们的批改工作进行是至关重要的(我们可能使用脚本来对你的作业进行评分)。
因此,我们不对任何由于没有按要求进行提交的得分损失负责。
请不要拖延作业
第零阶段作业不接受补交。
如果你没有在 Hard ddl 前提交第零阶段作业,你将失去该阶段的所有分数(约占项目 15%)。
请遵守学术诚信
若你的项目被定性为抄袭,被记零分的「本次作业」将是整个第一学期项目而不是单个阶段。
须提交的作业包含两个部分,一是完成第零阶段后工程的压缩包,二是实验报告。
请把要求的所有文件提交到这个链接,不要重复提交重名的文件。
提交工程压缩包
请谨慎操作项目文件
如果你没有相关经验,建议你在进行作业提交前备份项目文件夹,以免意外删除或毁坏项目文件夹的内容。
在提交工程压缩包之前,请务必在 git 中提交更改,例如运行:
git add .
git commit -m "finish phase0"
压缩包须使用 .tar.gz
格式,并以 $(STU_ID)_PSPJ0_$(PLATFORM)_$(VERSION).tar.gz
命名。
其中 $(STU_ID)
应替换为你的学号,$(PLATFORM)
应替换为你使用的平台(PLATFORM := windows | linux
)。该压缩包的体积不应超过 10 MB,若包太大请在上传前运行 make clean
。
VERSION
是你给提交文件指定的版本号(0~99),我们将只处理版本号最大的压缩包。
例如,一位学号为 211240088
的同学在 linux 完成了第零阶段工程后,他应当将工程压缩包命名为 211240088_PSPJ0_linux_0.tar.gz
。
具体地,你可以通过在工程的直接父级路径运行下述命令来获得工程压缩包:
make -C $(FOLDER_NAME) clean
tar -czvf $(STU_ID)_PSPJ0_$(PLATFORM)_$(VERSION).tar.gz $(FOLDER_NAME)
其中 $(FOLDER_NAME)
应替换为你项目文件夹的名字。
我们在接受到你的压缩包后,会去除文件名中的 _$(VERSION)
,并在你使用的平台上依次运行:
tar -xzvf $(STU_ID)_PSPJ0_$(PLATFORM).tar.gz
cd project-1
git reset --hard HEAD
cp path/to/std_makefile Makefile
make clean
make shell
如果其中的任何一步发生错误,本阶段的工程得分按零分计算。
请谨慎修改 Makefile
我们会用我们的 Makefile 替换你的 Makefile,如果你的 Makefile 行为与我们不同可能会导致测试时出现错误。
如果我们发现你为通过项目评分恶意修改了 Makefile,视同抄袭。
在进入 shell
界面后,我们按如下得分点计算项目得分(得分点比例待定):
- 运行
compile
后能够编译游戏。 - 编译游戏后,运行
game
与log
后表现正常。 debug
指令能够附加到正在运行的游戏上。- 游戏能够以调试模式启动(
game -g
)。 debug -r
指令能够附加到正在运行的调试模式游戏上。
我们还会检查你的代码,如果你的代码不符合编码规范可能会被酌情扣分。
提交实验报告
实验报告须使用 Markdown 撰写,并命名为 $(STU_ID)_PSPJ0_$(VERSION).md
。
什么是 Markdown
Markdown 是一种轻量级的标记语言,你很容易通过搜索教程学会它。
实验报告没有特定的格式要求,只需要可读并含有要求必做的内容即可,但不得包含图片和超链接,文件大小不得超过 6 KB(约 2000 字)。
我们要求你完成的必做内容包括:
- 如实介绍你项目的完成情况(可以参考我们设置的得分点,只要结果不要过程)。
- 完成如下思考题:
- 将输出的前景色设为非高亮绿色,背景色设为非高亮白色的 ANSI 转义序列是什么?
- 阅读程序闪退时输出的日志,游戏为什么会闪退?闪退时执行了什么代码?
- 执行
make shell
时,编译shell.c
的命令是什么? - 如何使用 gdb 定位到程序闪退执行到的那行代码?
shell.c
中的*strchr(str, '\n') = '\0'
是什么意思?shell.c
中的cmd = strtok(NULL, " ")
是什么意思?cmd_table[]
的结构体类型的每个成员分别是什么意思?都分别在什么时候用到了?
少女祈祷中……
本项目的第零阶段到此结束。喝口水休息一下吧。
-
致谢:你现在看到的启动器源代码是 2021 级匡亚明学院计算机方向的孙飞宇同学友情帮助编写的。 ↩