跳转至

操作系统与编程语言

约 5065 个字 28 行代码 预计阅读时间 17 分钟

什么是操作系统

用最简单的话来说,操作系统负责管理计算机的软硬件资源,并给用户和其他软件提供接口和环境,是计算机中 最基础的软件

很显然,对于一个程序开发者而言,直接面向硬件编写程序是费时费力的。这样的话,大量的程序可能都会涉及到一些相似的功能,比如如何处理来自键盘等设备的输入,再比如如何管理文件与内存。从“合并同类项”的角度来看,建立一个新的抽象层次来负责管理系统资源相关的事项是自然且合理的。顺带一提,这种 建立抽象层次 的设计模式在计算机中是相当常见的。

抽象意味着上层次只需要知道下层次可以满足他们的某项需求,而无需了解这项需求是如何被实现的。这种设计理念可以让每一个层次专注于它们自己应该实现的功能,而避免被来自其他层次的细节所困扰。如果一台电脑更换了 CPU,我们显然不会重写运行在这台电脑上的所有程序,而是仅仅将 CPU 调用模块相关的代码“更换”掉。将不同的功能 封装 在不同的模块里所带来的好处是显而易见的,而这种封装就是抽象的一种体现。

操作系统可以被看做一个功能很强大的封装模块。在有了操作系统之后,我们可以实现更方便地实现一些功能,同时实现一些没有操作系统就实现不了的功能。以下有一些简单的例子:

  • 哪怕在只有一颗 CPU 的电脑上,也可以做到同时写代码和听音乐。但很明显,音乐播放器和代码编辑器的代码中不会包含自己如何和别的程序同时运行的功能,CPU 也不是生来就会同时运行多个程序。操作系统通过微观上协调多个程序 交替 执行,而在宏观上表现为多个程序 同时 执行。这种 并发 技术是操作系统的一个重要特征。

  • 在实际上的电脑使用中,我们会遇到各种各样的错误,一个软件的小错误就导致整台电脑蓝屏甚至硬件损坏显然是不可接受的。虽然程序内部也应该包含错误处理机制,但遇到一些不负责的程序或者程序遇到了一些它们自己处理不了的情况时,操作系统的错误处理机制是必要的。比如在信科相关的课上,会有同学尝试编写生成上万个子进程却不负责销毁的代码,再比如有些同学会编写占用远大于内存大小的空间的代码。此时则需要操作系统进行“兜底”,在软件层面即时地阻断错误与异常继续向下层传播。关于操作系统与错误处理,这里有一个(可能)有趣的知乎问题:如果抛开操作系统,直接在裸机上进行除零操作会发生什么情况?

用户界面 —— CLI,TUIGUI

目前常见的计算机操作系统有 Windows, LinuxmacOS,移动操作系统则包括 AndroidiOS

前文提到,操作系统负责给用户和其他软件提供接口,给其他软件提供的接口的使用方法往往藏身与各种繁杂的文档中,我们对他们并没有太大的兴趣。相比之下,我们则每天都在使用操作系统为用户提供的接口。从关机到新建文件夹再到打开一大堆程序,这都是我们直接与操作系统交互进行的例子。UI (User Interface),用户界面,则是直接涉及到用户应该如何与操作系统(或者是其他的软件)进行交互的核心模块。

目前大部分常见的 UI 都是 GUI (Graphics User Interface),图形用户界面,显著特征为通过鼠标(以及触摸屏)等输入设备与图标或菜单选项进行交互,启动对应的程序或执行相应的命令。这种交互方式最大的优点在于直观且易于上手,学习曲线平和,鼠标交互的方式可以省去大量指令的记忆成本,同时也有不错的效率。

相对应的,CLI (Command Line Interface),命令行界面、TUI (Text-based User Interface/Text-based User Interface),终端用户界面/基于文本的用户界面 则不依赖图形而是主要依赖键盘输入大量指令,对指令的记忆成本也造成了较为陡峭的学习曲线。CLI 是早期大部分计算机的交互方式,而 TUI 可以部分视作在 CLI 的基础上进行了丰富。

CLI 中,所有操作都通过在命令行中输入指令进行。相应地,系统会通过文本形式输出相应内容。CLI 与现代常见交互方式的一个主要不同是它并没有一个用来交互的“菜单”之类的东西。下面是 Wiki 上关于 CLI 条目里的一张图,可以看到用户在终端中输入了 ping, pwd, cd, ls, yum 这些常见的指令,之后计算机将这些指令的执行结果输出到了终端里。虽然输出结果中存在简单的排版与动态进度条之类的要素,但这些结果本身并不能做出“光标选中”之类的交互动作,而是仅仅作为“展示”之用。

TUI 虽然绝大部分都由字符组成,但有更为丰富的表现形式且可以接受更为丰富的输入。最经典的 TUI 恐怕要属(可能很)大名鼎鼎的文本编辑器 Vim。在 Vim 中,用户可以通过大量的功能强大的指令进行移动光标、修改文本以及其他更复杂的操作。从图中可以看到,TUI 相比 CLI 来说更像一个“界面”,包含了光标,窗口等要素,只不过这些要素都由文本构成(感谢 ╣ 之类的框线字符)。

CLITUI 的一个好处是摆脱了对于鼠标的依赖,不过更大的好处是避免机器将性能花费在渲染图形上。现在的家用电脑大多不需要对这种程度的性能精打细算,但涉及到服务器以及需要远程控制之类的场合,节省下来的这些性能以及空间还是显得十分实惠,以及远程传输文本总比远程传输图片方便,不至于被肉眼可见的鼠标移动延迟折磨心智、血压升高的尴尬情况出现。另外说句题外话,从代码编写者的角度来讲,由于为代码编写 UI 总是耗费精力的,所以我们大多时候与自己写的程序进行交互总是通过 CLI 的方式,所以熟悉 CLITUI 的这种交互方式总是没有坏处的。

一张 Wiki 上 Vim 的图片,注意到虽然界面还是全部由文字组成,但出现了“窗口”的概念,窗口中的内容也可以动态进行交互。

集成开发环境与文本编辑器

写在前面:以下内容更加适合配合计算概论相关课程食用。

入门的计算概论课往往会推荐 Visual Studio PyCharm 作为编写代码的工具,而更进阶的课程会推荐 VS Code,再深入了解一些还会发现 Vim,Emacs 这样的工具。

上面提到的这些代码工具可以大致分为两类,其中 Visual StudioPyCharm 通常被称作 IDE(Integrated Development Environment, 集成开发环境),而其他的则被称为 文本编辑器。虽然说随着时代的发展这两者之间的边界已经开始模糊,但理解这两个概念还是有必要的:

  • IDE 提供代码编辑、调试、构建、版本控制等多种功能于一体,通常集成了编译器、调试器和其他开发工具。而代码编辑器功能相对单一,通常只提供代码高亮等与代码编辑相关的功能。

  • IDE 中运行代码可能只需要点击“运行”按钮,而严格来说在代码编辑器中无法运行代码,而需要在终端中手动输入编译相关的指令才能让代码“跑起来”。因此想需要使用代码编辑器需要额外配置编译器、调试器相关的环境(环境配置是一个巨大的坑,新手在这方面遇到挫折是很正常的现象,千万不要灰心),这也略微加大了使用代码编辑器的上手难度。不过代码编辑器轻量简洁的优势值得额外配置环境的工作量,Vim 可以直接在 TUI 中运行,VS Code 的体感启动速度也明显快于 Visual Studio

代码编辑器的另一个特点是可以通过添加插件以丰富其原版略显单薄的功能。VS Code 被推荐的一个主要原因就是其可以通过添加插件的方式获得几乎对所有语言的支持。当然也可以添加类似 Code Runner 之类的插件将其改造成类似 IDE 的形式,很多 VSCode 的入门教程都会介绍这个插件。 虽然说对于 VS Code 来说更自然的运行代码方式是在其内的终端里手动输入编译/运行相关的指令,这也更符合其“代码编辑器”的定位。

什么是命令式语言

不同的计算概论课程会分别介绍 PythonC/C++ 这两种语言(计概范围内的 CC++ 其实可大致看成一种语言)。虽然上到设计理念下到编写细节这两种语言都有很多不同点,但其都属于命令式语言(虽然现代 C++ 以及 Python 都支持面向对象和函数式设计,但其主体还是属于命令式语言)。命令式语言的计算理论来自于 图灵机,通过对 状态的改变 描述计算的过程。它们的语句主要为对状态(也就是变量内部存储的值)的改变以及对控制流的改变(也就是条件或循环的跳转语句)。因此将简单的 C++Python 代码互相转换并不是什么难事,因为它们描述计算所用的方式所用的方式一样。另一种常见的编程范式是函数式语言,主要例子为 Haskell。其计算理论来自于 λ演算,通过创建匿名函数和应用函数描述计算。有和图灵机一样的描述能力。函数式语言相对来说更难理解,这里不再深入。

编译型语言与解释型语言

C++Python 的一个重要区别就是代码实际运行的方式。另一个重要的区别是类型系统,不过这一项相对直观我们不做深入。 计算机只能够运行字节形式的可执行文件,而可执行文件(中的代码部分)与编程语言最底层的汇编语言有着一一对应的关系,因此可以说计算机只能识别汇编模式的代码。

C/C++编译器 做的事情实际上就是把源代码翻译成汇编代码,再经由 链接器 进行与头文件的整合相关工作,最终得到一份可以被直接运行的可执行文件。因此,C++代码的运行分为两步:先是编译再是执行。这种方式的一个主要好处就是一份需要被反复运行的代码只需要被编译一次,而节省了编译部分的耗时。编译器与链接器报的错误大部分情况下很好解决,小部分情况下会因为涉及到环境问题而变得棘手。 编译器可以识别语法错误以及部分的语义错误,因此一份运行起来的 C++ 代码天然地有更小几率出现 bug。

拓展

在“通过编译器减少 bug”方向上一门叫 Rust 的语言做得更加极端,其在编译器内建立了大量的语义约束,保证能被运行的 Rust 代码 一定 不会出现内存安全问题与线程安全问题,不过相应地也显著提高了 Rust 的代码编写难度。

而 Python 则走向了另一个极端。Python 代码运行使用的是另一套称为 解释器 的代码翻译逻辑。直观来讲,解释器允许代码一边被翻译为汇编语言一边被执行。也因此,终端里可以发现 .py 后缀名的 Python 代码文件可以被直接视为可执行文件,而 .cpp 后缀名的 C++ 代码文件只能被视为文本文件。

解释器可以省去每次更新代码时都要将项目重新编译的时间花销,但代码的实际运行效率相比编译型语言来说更差。体感表现为 Python 代码比 C++ 代码慢很多,真的非常多! 一些完全不符合语法要求的代码也可以在解释器中跑起来——解释器只会在按顺序运行代码,直到在出现问题的的地方停止。

Python 自身的动态类型系统与缺少编译器带来的静态查错系统使得实际写出来的 Python 代码中经常包含大量的 bug,并除实际运行之外缺少 debug 的手段。但因为语言特性而引发的 bug 必然不是过于复杂的 bug,不需要在这点上过于担心。

Python 并不适合大型项目的开发,不过动态类型与解释器带来的灵活性使得 Python 在小型项目上拥有无可匹敌的竞争力。与 C++ 相比,Python 作为现代语言,拥有更为成熟的库文件机制,import 比起 include 实在是好用了太多。大量的第三方库以及 pip, anacondaPython 环境管理工具也是 Python 竞争力的重要来源。

如何阅读报错与调试代码

调试代码中的错误是每位写代码人都不可避免的一件事。错误可以简单地分为语法错误、语义错误与逻辑错误。其中语法错误和大部分语义错误会被编译器(为方便,以下“编译器”包含 Python 等语言中的“解释器)直接发现。而逻辑错误,也就是编写出的代码与预期不符的情况,由于代码逻辑本身所带有的复杂性,而不可避免地拥有复杂性,这种 debug(为方便,以下可能会混用 debug调试)往往需要优秀的经验、直觉、心态以及运气。

之所以在节标题中单列了如何阅读报错,就是因为语法语义错误是初学者最经常遇到的问题,而编译器会以报错信息的形式友情提示这些错误是什么、在哪里。而高级一些的代码编辑工具,还会把能被编译器检测出来的错误实时地标注在代码上面。这也是为什么写出来的 Python 代码往往会带有很多 bug 的原因,因为动态类型导致语法语义层面的错误无法被编译器静态检测出来,从而无法在编写代码时就获得提示。

以下是一个简单的编译器报错信息例子:

#include<iostream>
using namespace std;
int mian()
{
    cout<<"Hello World!"<<endl;
    return 0;
}

以下是笔者电脑上给出的报错信息:

> g++ example.cpp -o example.exe
D:/msys64/ucrt64/bin/../lib/gcc/x86_64-w64-mingw32/13.1.0/../../../../x86_64-w64-mingw32/bin/ld.exe: D:/msys64/ucrt64/bin/../lib/gcc/x86_64-w64-mingw32/13.1.0/../../../../lib/libmingw32.a(lib64_libmingw32_a-crtexewin.o): in function `main':
C:/M/B/src/mingw-w64/mingw-w64-crt/crt/crtexewin.c:70: undefined reference to `WinMain'
collect2.exe: error: ld returned 1 exit status

虽然信息略显抽象,但我们还是可以看到很多有用的信息。ld 是 c++中的链接器,再往上看可以发现对 WinMain 的引用是未定义的。这提示我们去看 main 函数,从而发现这里 main() 被输入成了 mian(),因此链接器无法找到 main 函数,从而引发错误。

下面是另一个例子:

def calc(numbers):
    total = sum(numbers)
    count = len(numbers)
    return total / count

numbers = [10, 20, 30, 40, 50]
print("Average:", calc(numbers))

numbers.append("60")
print("Updated Average:", calc(numbers))

以下是运行代码后输出的信息:

Average: 30.0
Traceback (most recent call last):
  File "example.py", line 10, in <module>
    print("Updated Average:", calc(numbers))
  File "example.py", line 2, in calc
    total = sum(numbers)
TypeError: unsupported operand type(s) for +: 'int' and 'str'

可以看到解释器对第 2 行和第 10 行进行了报错,其中最下面一行是错误实际发生的位置,而上面的则描述了函数调用,也即第 2 行的错误是在执行第 10 行中的calc函数时发生的。如果代码结构比较复杂或者错误实际发生的位置位于来自import的内部库中,则Traceback中可能会给出大量的函数调用记录。此时不要惊慌,找到属于自己写的代码最靠近错误位置的行数查看即可。 回到正题,最后一行给出了错误的原因TypeError:Python 中的+运算符作用在int类型与str类型的数据之间。这提示我们去看sum()作用的对象,也即numbers列表,并进一步发现numbers.append("60")numbers中添加了一个字符串形式的"60",而numbers其余的元素均为整数类型的数字。将这一句改为numbers.append(60),代码会正常运行。 顺带一提,虽然这段代码翻译到 C++中根本不会被编译器通过,但 Python 的解释器还是运行代码直到遇到了具体的问题,在输出信息中可以看到第一个print()语句仍然被正常地执行。

而关于更复杂的 debug 问题,很难给出具有代表性的例子或是在几段话内讲清楚,不过还是有一些具有普适性的建议。在这方面互联网上有很多的资料,感兴趣的同学可以去自行了解。 debug 最重要的一件事是缩小错误出现的范围,为达成这一目的我们通常会跟踪代码的行为,直到发现代码的行为与预期不符。调试器可以提供“跟踪代码行为“的功能,而不想学习调试器使用方法的话通过print语句打印代码中间状态也是一个不错的选择。前文提到的模块化的设计方式在这里仍然适用。先是找到出现问题的模块,再缩小搜索范围直到找到出现问题的具体语句。

而更棘手的情况是代码只在特定的数据上出现问题,比如 OJ 告诉你的代码出现了Wrong AnswerRuntime Error,而你却不知道导致代码出现问题的数据到底是什么。这时最应该做的是重新审视自己的预期(以及 OJ 题的题面),寻找是否遗漏了什么约束条件或关键信息。一份貌似运行正常的代码很有可能会在边界条件或复杂数据的情况下出问题,可以尝试手写一些处于边界条件之下的数据,或编写一个数据生成器来生成更复杂的数据。实在手足无措时,休息一下放空大脑也是很好的选择。实在走投无路之时,摇人求助也不是什么大不了的事情。debug 很可能会占用比编写代码更多的时间和精力,保持良好的心态才是 debug 的关键。