Unix 与互联网基础入门¶
注意
AI 直译 + 个人修饰
计算机基本组成¶
主板上有处理器和内存。
对于给电脑配置的外设,比如键盘、屏幕、磁盘、光驱、软驱等等,有些需要使用插在主板上的控制器卡(controller cards)来管理,有些由主板上的专用芯片组直接控制,键盘是特例,其很简单所以控制设备直接集成在键盘外壳内部。
所有的设备都通过总线(bus)连接,控制卡(声卡、显卡、磁盘控制器卡)都是插在总线上的。可以这样理解,主板是一座城市,这些设备是不同的城市设施,由不同种类的高速公路(不同种类的总线)连接。
ISA(Industry Standard Architecture),PCI(Peripheral Component Interconnect),PCMCIA(Personal Computer Memory Card International Association)都是总线类型。ISA 除了细节上的差异外,与 1980 年 IBM 最初的个人电脑上使用的总线基本相同,但现在已经不再使用。PCI,即外设部件互连,是大多数现代个人电脑以及现代 Macintosh 电脑上使用的总线。PCMCIA 是 ISA 的一种变体,连接器体积更小,主要用于笔记本电脑。
处理器是整个硬件系统的核心,但是无法直接访问硬件设备,必需通过总线进行通信,唯一能与处理器高速直连的只有内存,所以程序要运行必需先载入内存。
举个例子:当计算机读取磁盘中的数据,处理器通过总线给磁盘控制器发送读的请求,等到磁盘控制器处理好了再通过总线通知处理器你要的数据已经好了并已经放到内存的特定位置,随后处理器就可以通过总线访问这些数据了。
键盘和显示屏也通过总线与处理器通信,但方式更为简单。我们稍后会详细讨论这些内容。现在,你已经了解了足够的信息,可以明白开机时会发生什么了。
开机时电脑发生了什么¶
没有程序的电脑什么也做不了,电脑第一个启动的程序是操作系统,操作系统的主要职责就是帮助其他程序处理与计算机硬件交互的繁琐细节(其他程序无权直接访问硬件资源,通过操作系统代理)。
启动操作系统的过程叫引导(booting),起初叫自举(bootstrapping)。电脑之所以知道怎么“引导”,是因为相关启动指令存储在主板的 bios 芯片中。
bios 会指示电脑在固定位置(通常是 boot 磁盘)寻找名为 boot loader 的启动引导程序(linux 下叫做 Grub【GRand Unified Bootloader】 或 LILO【LInux LOader】)来启动真正的操作系统。
loader 会寻找内核程序(kernel,操作系统的核心部分,如果把操作系统看作一辆车,内核就是引擎),将其加载进内存并启动。
那为啥 bios 不直接加载内核,而是先加载 loader,loader 再加载内核?历史原因,loader 很蠢功能很简单,能访问的磁盘空间有限(BIOS 最初是为只有小容量磁盘的原始 8 位 PC 编写的)。额外需要 loader 这一步还有好处,可以让其选择其他操作系统启动(比如电脑刷了双系统)。
内核启动后需要检测周边环境、识别其他硬件设备并为运行程序做准备,它通过检测 I/O 端口(特殊的总线地址,设备控制器卡通常会监听这些地址)而非普通内存地址来实现这一点。内核不会随机探测,它内置了大量关于设备位置及控制器响应方式的知识。整个过程称为自动探测(autoprobing)。
现在都是图形化启动界面,你基本上看不到上述这些过程,不过可以切换到文本控制台看。你可以尝试按 Ctrl-Shift-F1 切换到文本控制台视图查看,如需返回图形界面,可以尝试其他 Ctrl-Shift 组合键(如 F7、F8 或 F9)。
启动时显示的大多数消息都是关于内核通过 I/O 端口自动探测硬件的内容。Linux 内核在这方面表现极为出色,远胜其他 Unix 系统和 DOS/Windows。事实上,许多 Linux 老用户认为其智能的启动探测功能(这使得安装相对容易)是 Linux 能从众多免费 Unix 实验中脱颖而出、吸引大量用户的关键原因。
加载内核其实只是启动操作系统的第一个阶段(被称为 level 1),此后内核会将控制权交给能够派生多个守护进程的特殊进程 init(linux 下现在是 systemd)。
init 的第一项工作通常是检查磁盘状态。文件系统十分脆弱,如果文件系统因硬件故障或断电造成了损坏,最好在完全启动前采取恢复措施。
init 的下一项工作是启动多个守护进程,比如打印机后台处理程序、邮件服务器、www 服务器,它们在后台默默运行,等待任务的到来。这些特殊程序通常需要协调多个可能冲突的请求。之所以称为守护进程,是因为编写一个持续运行并能处理所有请求的程序,往往比确保编写多个副本(每个副本处理一个请求且同时运行)不会互相干扰要来得简单。你系统启动的具体守护进程组合可能会有所不同,但几乎总是会包括一个打印队列管理器(作为打印机的看门守护进程)。
再下一步是为用户登录做准备。init 启动一系列 getty 程序副本来监听屏幕和键盘,每个 getty 会为你提供一个虚拟控制台(如果是 7 个,那么就有 tty1 ~ tty7),屏幕和键盘只会占用一个。但你也有可能压根都不看见这些 tty,因为 X server 会直接占用一个来显示图形化界面。
还没结束,下一步启动支持网络和其他服务的守护进程,尤其是 X server,X 是一个管理显示器、键盘和鼠标的守护进程,为你提供彩色像素图形界面。
当 X 服务器启动时,它会从之前的虚拟控制台接管控制硬件,这是你就会看到由显示管理器(display manager)生成的图形登录界面啦。
Note
你可能会注意到 init 有两次都是在启动守护进程,有什么区别呢?第一次启动的多为“长期运行、等待请求”的进程,与用户界面无关,第二次多是启动方便用户交互(比如网络)和用户界面的进程
登录时发生了什么?¶
你需要登陆才能使用计算机,不论是在图形化界面还是虚拟控制台,都需要输入用户名和对应的密码才能成功登录,系统会在每行描述一个用户账号的 /etc/passwd 文件中查找你输入的用户名。
passwd 文件中每行的其中一个字段是加密后的用户密码(有时存储在权限更严格的 /etc/shadow 文件下,安全性更高),你在登录过程中输入的密码也以同样的方式进行加密,登录程序对比加密结果检查是否匹配。这种方案的安全性在于从明文密码生成加密密码很容易,但是逆向破解很难。就算有人看到了你密码的加密版本,也依旧无法登录,当然这也意味着你忘记密码后只能重置没有办法找回。
一旦成功登录,你获得登录用户具有的所有权限。系统还可能识别你所属的用户组。用户组是管理员创建的用户集合,用户组可拥有独立于组成员的权限,一个用户可以在多个用户组。
(虽然通常使用名称指代用户和用户组,但他们实际是以数字 ID 形式存储的,密码文件将用户名映射到用户 ID,/etc/group 文件将组名映射到组 ID。具体用户名和用户组的命令将会自动进行转换。)
你的账号条目还包含主目录(home,文件系统中存储用户私人文件的位置)。账号条目还包含 shell(即用户运行命令的解释器)。
成功登陆后,如果你是在虚拟控制台,会启动 shell,如果你是在显示管理器(display manager),X server 会启动你的图形化桌面。
运行程序时发生了什么?¶
在启动完成后,运行程序前,你的计算机就像一个进程动物园,所有进程都在等着做些什么事。他们在等待事件发生——可能是你按下什么或者用鼠标点击什么,也可能是你在联网状态下接收到了什么。
内核是这些进程中的特殊进程,他控制着其他用户进程的运行,而且往往是唯一能直接访问机器硬件的进程。事实上,当用户进程想获取键盘输入、屏幕输出或进行磁盘读写等任何关于存储的操作前,必需先向内核发出请求。
通常所有的 I/O 都经由内核调度,以防止进程之间冲突。但某些特殊用户进程可以直接访问 I/O 端口,比如 X server。
你有 X server 或者 shell 两种方式来运行程序。你也会经常两者同时使用——比如启动终端模拟器获得 shell 环境,下面先解释 shell 方式,再解释在 X server 通过菜单或点击图标来运行的方式。
shell 之所以称为“壳”,因为其包裹了操作系统内核。Unix 的重要特性之一就是 shell 和内核是独立的程序,shell 通过系统调用来联系内核,这使得一个用户可以拥有多个 shell。
在你没有自定义的情况下,标准 shell 给你一个 “$” 提示符。我们暂不讨论 shell 语法,而是从计算机底层视角观察运行机制。
shell 只是一个普通的用户进程。它通过内核来监听键盘的 I/O 端口等待你按键。当内核注意到你输入了什么时,便将你输入的内容回显到虚拟控制台或者 X 上的终端模拟器。当内核注意到你按下回车时,便将所有内容作为命令传给 shell。
假设你输入 ls 以调用 Unix 的目录列表程序。shell 首先会应用内部规则判定需要运行的可执行程序在 /bin/ls 文件中。接下来通过系统调用请求内核启动 /bin/ls 来作为一个子进程,并通过内核授予子进程屏幕和键盘的访问权限,随后 shell 进入休眠等待 ls 完成任务。
当 /bin/ls 运行完毕,会通过 exit 系统调用来通知内核。内核会唤醒 shell 并告知其继续运行,shell 重新显示提示符并等待下一条用户命令(用户输入)。
在执行 ls 命令时(假设正在列超长目录),你可能转移到其他的虚拟控制台,登录,并启动雷神之锤,或者在联网状态下发一封电子邮件。
当你在 X server 中运行程序时(在菜单中选择或者双击图标),任何与 X server 关联的应用程序都能像终端那样启动程序。这里不讲细节,因为细节多变且不重要。关键点在于,X server 启动程序时不像普通 shell 那样进行休眠,而是作为中间层传递鼠标点击和键盘输入,并处理在屏幕上绘制像素的请求。
输入设备和中断是怎么工作的?¶
键盘是非常简单的输入设备——说它简单,是因为它生成的数据量小而且速度慢(就算你能一分钟打 200 字,电脑一秒钟可以处理几十亿条指令)。当你按下或释放按键时,键盘线缆会发送触发硬件中断的事件。
操作系统负责监听中断。对于每种可能的中断,都会有一个中断处理程序——操作系统的一部分,负责暂存相关数据(如你的按键/释放值)直到能被处理。
键盘中断处理程序的实际工作就是将键值存储到内存底部的系统区域。当操作系统将控制权交给应该使用键盘数据的程序时,该程序可以从这片区域进行读取。
更复杂的输入设备比如磁盘或者网卡的工作方式类似。之前提到了磁盘控制器使用总线信号表示磁盘请求已经完成,实际发生的是磁盘触发了中断。磁盘中断处理程序随后将检索到的数据复制到内存中,供发出请求的程序使用。
每种中断都有对应的优先级。低优先级的中断(比如键盘)必需等待高优先级的中断(时钟或磁盘)先处理,Unix 将赋予需要快速处理的任务高优先级,以保证系统流程。
在操作系统的启动信息中,一种常见的错误是两个不同的设备尝试使用相同的 IRQ 编号。
IRQ 是 Interrupt Request(中断请求)的缩写,操作系统在启动时需要知道每个硬件设备使用的中断编号,这样才可以分配正确的中断处理程序,如果两个设备使用相同的 IRQ,中断会被分配到错误的处理程序。这种情况至少造成设备锁死,严重情况下系统崩溃。
我的计算机怎么同时处理多项任务?¶
如果只有一个处理器事实上并不能,计算机每次只能执行一个任务(进程),但计算机可以快速切换任务,让反应迟钝的人类以为它在同时处理多件事,这项技术叫做分时处理(timesharing)。
内核的职责之一就是管理分时处理。内核包含一个调度器(scheduler)组件,存储系统中所有(非内核)进程的信息。每 1/60 秒,内核中的计时器就会触发一个中断,将当前运行进程挂起,将控制权交给另一个进程。
1/60 秒听起来可能很短,但在现在微处理器上,足以运行数万条机器指令,完成大量工作。因此即使有多个进程,每个进程也能在自己的时间片内完成相当多的工作。
实践中,程序可能无法用完整个时间片。如果来自 I/O 设备的中断到达,内核会立即停止当前任务,运行中断处理程序,然后再返回当前任务。大量高优先级中断可能会挤占正常处理时间,这种异常行为被称为 thrashing,幸运的是在现代 Unix 系统中很难出现。
事实上,程序运行速度很少受限于分配的机器时间(少数例外如音频或 3D 图形生成)。更多时候,延迟是由于程序需要等待磁盘驱动器或网络连接的数据造成的。
能常规支持多进程同时运行的操作系统被称为"多任务"系统。Unix 系列操作系统从设计之初就为多任务而生,表现非常出色——远比 Windows 或旧版 Mac OS 高效得多,后两者的多任务功能都是后期补加的,表现相当糟糕。高效可靠的多任务处理正是 Linux 在网络、通信和 Web 服务领域表现出色的重要原因。
我的计算机如何防止进程之间相互干扰?¶
内核调度器负责在时间上划分进程。操作系统还需要在空间上划分进程,以防止进程相互干扰。即使假设所有程序都良好合作,你也不希望其中一个程序的错误能够破坏其他程序。操作系统为解决此类问题所采取的措施称为内存管理。
每个进程都需要自己的内存区域,作为运行代码和存储变量及结果的空间。你可以将这组内存视为由只读代码段(包含进程指令)和可写数据段(包含所有进程的变量存储)组成。数据段对每个进程都是唯一的,但如果两个进程运行相同的代码,Unix 会自动安排它们共享一个代码段以提高效率。
虚拟内存(简化版)¶
效率很重要,因为内存很昂贵。有时你没有足够的内存来保存机器运行的所有程序,特别是当你使用像 X Server这样的大型程序时。为了解决这个问题,Unix 使用了一种称为虚拟内存的技术。它不会尝试将进程的所有代码和数据都保存在内存中,而是只保留一个相对较小的工作集;进程的其余状态则留在硬盘的特殊交换空间中。
需要注意的是,在过去,上一段中的"有时"实际上是"几乎总是"——内存的大小通常比运行程序的大小小得多,因此交换很频繁。如今内存便宜得多,即使是低端机器也有相当多的内存。在现代拥有 64MB 及以上内存的单用户机器上,在运行 X 和少量几个任务的情况下,初始加载后根本不需要交换。
虚拟内存(详细版)¶
实际上,上一节的描述有些过于简化。是的,程序将大部分内存视为一个比物理内存更大的平坦地址空间,而磁盘交换用于维持这种假象。但你的硬件实际上包含至少五种不同类型的内存,当程序需要调优以获得最大速度时,它们之间的差异可能非常重要。要真正理解机器中的运行机制,你应该了解所有这些内存的工作原理。
五种内存:处理器寄存器、内部(或 on-chip)缓存、外部(或 off-chip)缓存、主内存和磁盘。为什么有着多种呢?速度需要代价。我按访问时间递增和成本递减的顺序列出了这些内存类型。寄存器内存是最快最昂贵的,每秒可以进行约 10 亿次随机访问,而磁盘是最慢最便宜的,每秒只能进行约 100 次随机访问。
以下是反映 2000 年代初典型台式机速度的完整列表。虽然速度和容量会提高,价格会下降,但你可以预期这些比率将保持相当稳定——正是这些比率塑造了内存层次结构。
磁盘:
大小:13000MB 访问速度:100KB/秒
主内存:
大小:256MB 访问速度:100M/秒
外部缓存:
大小:512KB 访问速度:250M/秒
内部缓存:
大小:32KB 访问速度:500M/秒
处理器:
容量:28KB 访问速度:1000M/秒
Note
想想 2025 年,这 25 年的进步可真够大的
我们无法全部使用最快的内存类型来构建系统。那样成本会高得离谱——即便不考虑成本,高速内存也具有易失性,即断电后数据就会丢失。因此计算机必须配备硬盘或其他非易失性存储设备来保存断电后的数据。而处理器速度与磁盘速度之间存在巨大鸿沟,内存层次结构中的中间三层(内部缓存、外部缓存和主内存)本质上就是为了弥合这个差距而存在的。
Linux 和其他 Unix 系统具备称为虚拟内存的功能。这意味着操作系统表现得像是拥有比实际物理内存大得多的主内存。实际物理内存就像是在更大的"虚拟"内存空间上开了一组窗口或缓存区,这些虚拟内存大部分时候其实存储在磁盘的特定区域——交换区中。操作系统在用户程序无感知的情况下,通过在内核与磁盘间移动数据块(称为"页")来维持这种假象。最终效果是虚拟内存容量远大于物理内存,而速度却不会慢太多。
虚拟内存比物理内存慢多少,取决于操作系统的交换算法与程序使用虚拟内存方式的匹配程度。幸运的是,时间上接近的内存读写操作往往在内存空间上也具有聚集性,这种特性称为局部性(更专业的说法是访问局部性)——这是个好现象。如果内存访问在虚拟空间里随机跳转,那么每次新访问通常都需要进行磁盘读写,虚拟内存就会慢得像磁盘一样。但由于程序确实表现出强烈的局部性特征,操作系统只需为每次引用执行相对较少的交换操作。
实践经验表明,对于广泛类型的内存使用模式,最有效的算法非常简单——称为 LRU 或"最近最少使用"算法。虚拟内存系统根据需要将磁盘块抓取到工作集中,当物理内存不足以容纳工作集时,就丢弃最近最少使用的块。所有 Unix 系统和大多数其他支持虚拟内存的操作系统,使用的都是 LRU 算法的微小变体。
虚拟内存是弥合磁盘与处理器速度差距的第一道桥梁,由操作系统显式管理。但物理主内存的速度与处理器访问寄存器内存的速度之间仍存在巨大差距,外部和内部缓存通过类似前文描述的虚拟内存技术来解决这个问题。
就像物理主内存充当磁盘交换区的窗口,外部缓存也扮演着主内存的窗口角色。外部缓存速度更快(每秒 2.5 亿次访问而非 1 亿次)但容量更小。硬件(具体来说是计算机的内存控制器)会对从主内存获取的数据块执行 LRU 算法。由于历史原因,缓存交换的单位称为"行"而非"页"。
但优化还没结束。内部缓存通过缓存外部缓存的部分内容,实现了有效速度的最后跃升。它速度更快、容量更小——实际上它就位于处理器芯片上。
若想让程序真正快起来,了解这些细节很有帮助。当程序具有更强的局部性时,运行将更快,因为这能让缓存更高效工作。让程序变快的最简单方法就是让它们保持精简。如果程序不会因大量磁盘 I/O 或网络事件等待而减速,通常就能以适合其运行的最小缓存的速度执行。
若无法精简整个程序,着力优化关键代码段以增强局部性往往能事半功倍。具体优化技巧超出本教程范围;当你需要时,对编译器的深入了解将助你自行掌握多数方法。
内存管理单元(MMU)¶
即便内存充足无需交换,操作系统的内存管理器仍肩负重任。它必须确保程序仅能修改自身数据段——即防止某程序中的错误或恶意代码破坏其他程序数据。为此,内存管理器维护着数据段与代码段的映射表,该表会在进程申请/释放内存时更新(后者通常发生于进程退出时)。
该映射表用于向底层硬件中的专用模块 MMU 发送指令。现代处理器芯片往往集成 MMU,MMU能为内存区域设置防护栏,拒绝越界访问并触发特定中断。
若见 Unix 系统提示"Segmentation fault"或"core dumped"等信息,说明运行程序试图访问其内存段外的区域,触发了中断。这通常是程序 bug 所致,系统会生成 core dump 文件用于帮助开发者定位问题。
除内存隔离外,进程保护还有另一方面:通过文件访问控制防止故障程序或恶意程序破坏系统关键部分。这也是 Unix 设计文件权限机制的初衷。
我的计算机如何在内存中存储一切?¶
你可能知道计算机中的所有数据都以比特串(二进制数字,可以想象成大量的小开关)形式存储。这里我们将解释这些比特如何用来表示计算机处理的字母和数字。
首先你需要理解计算机的字长。字长是计算机处理信息单元的首选大小;技术上它是处理器寄存器的宽度,这些寄存器是处理器进行算术和逻辑运算的暂存区。当人们谈论计算机的位数(比如"32 位"或"64 位"计算机)时,指的就是这个。
现在大多数计算机的字长是 64 位。在不久前的 2000 年代初期,许多 PC 还是 32 位字长。1980 年代的旧 286 机器字长是 16 位。更早的大型机通常是 36 位字长。
计算机会将内存视为从零开始编号的连续字序列,最大编号取决于内存大小。这个值受限于字长,这就是为什么 286 等老机器上的程序需要复杂技巧才能访问大内存。这里就不详述了,这些技巧至今仍让老程序员心有余悸。
数字¶
整数根据处理器字长可能以单字或双字表示。64 位机器字是最常见的整数表示形式。
整数运算接近但不完全是数学上的二进制。低位是 1,接着 2,然后 4,依此类推。但有符号数采用二进制补码表示:最高位是符号位使数值为负,每个负数都可以通过对相应正数取反加一得到。这就是为什么 64 位机器的整数范围是-2^63 到 2^63-1——第 64 位用于符号位,0 表示正数或零,1 表示负数。
某些编程语言支持无符号算术运算,这是纯粹的二进制表示,仅包含零和正数。
大多数处理器和部分语言支持浮点数运算(近年所有处理器芯片都内置此功能)。浮点数提供比整数更广的数值范围,可以表示分数。具体实现方式各异且过于复杂不便详述,但基本原理类似"科学计数法"——例如 1.234×10²³,数字编码分为尾数部分(1.234)和指数部分(23)表示十的幂次乘数(展开后数字会有 20 个零,即 23 减去三位小数)。
字符¶
字符通常以 7 位 ASCII 码(美国信息交换标准代码)表示。现代机器上,每个 128 个 ASCII 字符占据 8 位字节或 octet 的低 7 位,这些字节会打包存入内存字(例如 6 字符的字符串只需占用一个 64 位内存字)。要查看 ASCII 码表,可在 Unix 终端输入"man 7 ascii"。
前段描述有两处不准确。较小的问题是"octet"这个术语虽然正式且正确但很少使用,多数人称其为 byte (字节)并默认指 8 位。严格来说"byte"更通用——例如曾存在 36 位机器使用 9 位 byte(尽管这类机器可能不会再出现)。
重要的问题是并非全世界都使用 ASCII。实际上很多地区无法使用——ASCII 虽然适合美式英语,但缺少其他语言用户需要的重音符号等特殊字符。比如对于英式英语来说,就缺少英镑符号。
已有多次尝试解决此问题。所有方案都利用了 ASCII 未使用的高位比特,使其成为 256 字符集的低半区。其中最广泛使用的是所谓的"Latin-1"字符集(正式名称为 ISO 8859-1)。这是 Linux 系统、旧版 HTML 和 X 的默认字符集。微软 Windows 使用了一个变种版本,在标准 Latin-1 因历史原因保留未分配的码位中,添加了诸如左右双引号等字符(关于由此引发问题的尖锐批评,可参阅 demoroniser 页面)。
Latin-1 支持西欧语言,包括英语、法语、德语、西班牙语、意大利语、荷兰语、挪威语、瑞典语、丹麦语和冰岛语。但这仍不完善,因此衍生出从 Latin-2 到 Latin-9 的系列字符集,用于处理希腊语、阿拉伯语、希伯来语、世界语和塞尔维亚-克罗地亚语等。详情请参阅 ISO 字符集说明页面。
终极解决方案是名为 Unicode 的巨大标准(其完全等同的双胞胎是 ISO/IEC 10646-1:1993)。Unicode 在低 256 个码位与 Latin-1 完全一致。在 16 位空间的高位部分,它包含希腊文、西里尔字母、亚美尼亚文、希伯来文、阿拉伯文、天城文、孟加拉文、古木基文、古吉拉特文、奥里亚文、泰米尔文、泰卢固文、卡纳达文、马拉雅拉姆文、泰文、老挝文、格鲁吉亚文、藏文、日文假名、完整的现代韩文谚文,以及统一的中日韩(CJK)表意文字集。详情请参阅 Unicode 官网。XML 和 XHTML 采用该字符集。
Linux 新版本采用称为 UTF-8 的 Unicode 编码方案。在 UTF-8 中,0-127 号字符与 ASCII 完全一致。128-255 号字符仅用于 2 到 4 字节的序列中,这些序列用于标识非 ASCII 字符。
我的计算机如何将数据存储到硬盘¶
在 Unix 系统下查看硬盘时,你会看到由命名目录和文件构成的树状结构。通常你无需深入了解,但在了解底层机制可以帮助你在磁盘崩溃时抢救你的重要文件。遗憾的是,从文件层面往下描述磁盘结构并不容易,因此我将从硬件层面开始说明。
底层磁盘与文件系统结构¶
磁盘存储数据的表面区域被划分得像镖靶——首先是同心圆的磁道,然后被分割成扇区。由于外圈磁道比靠近主轴的内圈面积更大,外圈磁道包含的扇区数量也更多。每个扇区(或称磁盘块)大小相同,在现代 Unix 系统中通常为 1 K(1024 个 8 位字节)【其实现在已经到 4K 了】。每个磁盘块都有唯一的地址或磁盘块编号。
Unix 将磁盘划分为多个分区。每个分区是连续的块集合,可独立用作文件系统或交换空间。最初设计分区是为了在磁盘速度慢且易出错的年代提升崩溃恢复能力——分区边界能降低因磁盘坏道导致数据不可访问的风险。如今分区更重要的意义在于可设置为只读(防止入侵者修改关键系统文件),或通过网络共享(具体方式不在本文讨论范围)。磁盘上编号最小的分区通常作为启动分区,存放可引导启动的内核。
每个分区要么是交换空间(用于实现虚拟内存),要么是存放文件的文件系统。交换空间分区只是简单的线性块序列。而文件系统需要将文件名映射到磁盘块序列。由于文件会增长、收缩和修改,其数据块可能分散在整个分区中(操作系统会在需要时寻找空闲块)。这种分散现象称为碎片化。
文件名与目录¶
每个文件系统内部,通过称为 i-node 的结构实现文件名称到数据块的映射。这些结构集中在文件系统"底部"(编号最小的块区域),最底部的 i-node 用于系统管理(此处不赘述)。每个 i-node 描述一个文件,文件数据块(包括目录)位于 i-node 上方(编号更大的块区域)。
每个 i-node 包含其描述文件的磁盘块编号列表。(严格来说这只适用于小文件,其他细节在此不重要。)注意 i-node 并不包含文件名。
补充:为什么不包含文件名呢?因为这样可以很方便的创建多个硬链接都是指向一个文件,而且对文件进行命名不需要改动 i-node。
Note
软链接会新创建一个文件(产生一个新的 inode),这个文件专门用来指向链接文件,而硬链接不会创建 inode,会增加 inode 的 link count,相当于为文件创建一个别名。
0 tmp ❯❯❯ touch test && echo "Hello World!" > test
0 tmp ❯❯❯ ln test hard
0 tmp ❯❯❯ ln -s test soft
0 tmp ❯❯❯ ls -li
total 12
946695 -rw-r--r-- 2 aron aron 13 Apr 28 14:15 hard
946696 lrwxrwxrwx 1 aron aron 4 Apr 28 14:15 soft -> test
946695 -rw-r--r-- 2 aron aron 13 Apr 28 14:15 test
可以看到,硬链接的 inode 号是一样的,而软链接不一样。
文件名存在于目录结构中。目录只是将名称映射到 i-node 编号的表结构。这就是为什么在 Unix 中,文件可以有多个真名(或称硬链接)——它们只是恰好指向同一个 i-node 的多个目录项。
挂载点¶
最简单情况下,整个 Unix 文件系统仅存在于单个磁盘分区。虽然某些小型 Unix 系统采用这种配置,但更典型的是分散在多个分区(可能位于不同物理磁盘)。例如,系统可能包含:存放内核的小分区、存放系统工具的中等分区,以及存放用户主目录的大分区。
系统启动后立即可访问的只有根分区(通常也是启动分区)。它包含文件系统的根目录——整个文件系统结构的顶级节点。
系统中的其他分区必须挂载到这个根分区下,才能访问整个多分区文件系统。在启动过程中期,Unix 系统会使这些非根分区变得可访问——将每个分区挂载到根分区的某个目录上。
例如,如果你看到名为 /usr 的 Unix 目录,这很可能是个挂载点,指向包含许多 Unix 安装程序(但非启动必需)的分区。
文件查找机制¶
现在我们可以自上而下观察文件系统。当你打开文件(如 /home/esr/WWW/ldp/fundamentals.xml )时会发生以下过程:
内核从 Unix 文件系统的根分区开始查找。首先寻找名为"home"的目录——通常这是指向其他大容量用户分区的挂载点。进入该用户分区的顶层目录结构后,查找名为"esr"的条目并获取其索引节点号。访问该索引节点时,系统会注意到其关联的数据块是目录结构,继而查找"WWW"。获取该索引节点后,进入对应子目录查找"ldp",这将指向另一个目录索引节点。最终在该节点中找到"fundamentals.xml"的索引节点,这个节点不是目录,而是存储着文件关联的磁盘块列表。
文件所有权、权限与安全机制¶
Unix 设计权限功能的初衷是防止程序意外或恶意篡改数据。这些功能是为支持分时系统而开发的——在 Unix 主要运行于昂贵共享小型机的年代,用于保护同一机器上的多用户互不干扰。
理解文件权限需要回顾登录时发生了什么章节中关于用户和组的描述。每个文件都有属主用户和属主组,初始状态由创建者决定,可通过 chown(1)和 chgrp(1)命令修改。
文件基本权限分为"读"(查看内容)、"写"(修改内容)和"执行"(作为程序运行)。每文件有三组权限:所属用户权限、所属用户组权限和其他用户权限。登录后获得的"特权"即指:当文件权限位匹配你的用户 ID、所属组或全局权限时,你才拥有相应的读写执行权限。
要理解这些权限如何相互作用以及 Unix 如何显示它们,让我们假设系统中存在文件:
这是个普通数据文件。列表显示其所属用户是'esr',创建时所属组为'users'。可能我们使用的系统默认将所有普通用户归入该组;在分时系统中你还常见'staff'、'admin'或'wheel'等组(显然,这些组在单用户工作站或 PC 上不太重要)。你的 Unix 系统可能使用不同的默认组,或许会用用户 ID 命名。
字符串"-rw-r--r--"表示文件权限位。首位的短横线是目录标识位(如果是目录会显示'd',符号链接则显示'l')。随后前三字符表示所属用户权限,中间三位是组权限,最后三位是其他用户权限(常称"全局权限")。本例中,所属用户'esr'可读写文件,'users'组成员可读,其他所有用户也可读。这是普通数据文件的典型权限设置。
现在让我们看一个权限完全不同的文件——GNU C 编译器(GCC
该文件属于名为'root'的用户和名为'bin'的组;只有 root 用户可写入(修改),但任何人都可读取或执行。这是预装系统命令的典型所有权和权限设置。'bin'组存在于某些 Unix 系统中用于归类系统命令(该名称是历史遗留,'binary'的缩写)。您的 Unix 系统可能使用'root'组代替(注意这与'root'用户不同!)。
'root'用户是数字用户 ID 0 的常规名称,这是一个特殊的特权账户,可以覆盖所有权限。root 访问权限很有用但也很危险;以 root 身份登录时,一个打字错误就可能破坏关键系统文件,而这些命令在普通用户账户下执行时根本无法触及。
由于 root 账户权限极大,必须非常谨慎地保护其访问权限。您的 root 密码是系统中最关键的安全信息,也是任何试图入侵的黑客最想获取的目标。
关于密码:不要写下密码——也不要选择容易被猜到的密码,比如伴侣的名字。这种糟糕做法出人意料地普遍,给黑客大开方便之门。通常不要选择字典中的任何单词;因为存在称为字典破解器的程序,会通过常见单词列表来猜测密码。一个好方法是选择由单词、数字和另一个单词组成的组合,如'shark6cider'或'jump3joy';这将使搜索空间大到字典破解器无法处理。但不要使用这些示例——黑客可能在阅读本文档后将其加入他们的字典。
现在让我们看第三种情况:
该文件是一个目录(注意第一个权限槽中的'd')。我们可以看到只有 esr 可以写入,但其他任何人都可以读取和执行。
读取权限使您能够列出目录——即查看其包含的文件和目录名称。写入权限使您能够在目录中创建和删除文件,这很好理解,因为前面讲过目录是包含其文件和子目录名称的列表。
偶尔您会看到全局可执行但不可读的目录;这意味着随机用户可以访问其下的文件和目录,但必须知道确切名称(无法列出目录内容)。
重要的是要记住,目录的读、写或执行权限与其下文件和目录的权限是独立的。特别是,对目录的写访问意味着您可以在其中创建新文件或删除现有文件,但不会自动获得对现有文件的写访问权限。
最后,让我们看看登录程序本身的权限:
这符合我们对系统命令应有的权限设置——除了那个本应是所有者执行权限位的地方出现了‘s’。这就是一种特殊权限“设置用户 ID”(setuid 位)的直观体现。
setuid 位通常附加在需要让普通用户以受控方式获得超级用户权限的程序上。当可执行程序设置此位时,在程序运行时你将获得该程序文件所有者的权限,无论这些权限是否与你自身权限匹配。
如同超级用户账户本身,setuid 程序既实用又危险。任何能攻破或修改 root 所有的 setuid 程序的人,都可以用它来获得具有超级用户权限的 shell 访问权限。因此,在大多数 Unix 系统中,以写入方式打开文件会自动关闭其 setuid 位。许多针对 Unix 安全的攻击都试图利用 setuid 程序中的漏洞进行攻破。注重安全的系统管理员会特别谨慎对待这些程序,不愿安装新的 setuid 程序。
前文讨论权限时略过了几个重要细节:即文件或目录初次创建时所属组和权限的分配方式。组的问题在于用户可能属于多个组,但其中某个组(在用户的 /etc/passwd 条目中指定)是用户的默认组,通常将拥有该用户创建的文件。
初始权限位的设置更为复杂。创建文件的程序通常会指定初始权限,但这些权限会被用户环境中称为 umask 的变量修改。umask 指定创建文件时要关闭哪些权限位;最常见的值(也是大多数系统的默认值)是-------w-或 002,表示关闭全局写入位。详情请参阅 shell 手册页中 umask 命令的文档。
初始目录组也有些复杂。在某些 Unix 系统中,新目录会继承创建用户的默认组(这是 System V 惯例);在其他系统中,则会继承其父目录的所属组(这是 BSD 惯例)。包括 Linux 在内的一些现代 Unix 系统,可以通过设置目录的 set-group-ID 位(chmod g+s)来选择后一种行为。
Note
发现我的系统上 login 已经不需要 setuid 提权了:
可能出现的故障¶
前文曾暗示文件系统可能很脆弱。现在我们知道了,要访问一个文件,可能需要跳转经过任意长的目录和 i-node 引用链。那么假设你的硬盘出现了坏道会怎样?
幸运的话,可能只会损坏某些文件数据。不幸的话,可能会破坏目录结构或 i-node 编号,导致系统的整个子树处于悬置状态——更糟的是,可能导致指向同一磁盘块或 i-node 的多重引用结构损坏。这种损坏会通过常规文件操作扩散,破坏原本不在坏道区域的数据。
幸运的是,随着磁盘硬件可靠性提升,这类意外情况已相当罕见。但这意味着你的 Unix 系统仍需定期进行文件系统完整性检查,以确保万无一失。现代 Unix 系统在启动挂载每个分区前会执行快速检查,而每隔几次重启则会进行更彻底的数分钟深度检查。
如果这些描述让你觉得 Unix 系统复杂且易出故障,那么启动时的自动检查修复机制或许能让你安心——它们通常能在问题恶化前及时处理。其他操作系统虽启动更快但缺乏这些机制,当需要手动恢复时你会更加抓狂(当然前提是你手头得有诺顿工具箱这类软件...)。
当前 Unix 设计的重要趋势是日志文件系统。它通过特殊磁盘写入机制确保系统始终处于可恢复的一致状态,这将大幅缩短启动时的完整性检查时间。
计算机语言如何工作?¶
我们已讨论过程序如何运行。所有程序最终都要以字节流形式执行,这些字节就是计算机机器语言的指令。但人类并不擅长直接处理机器语言,即使在黑客群体中,这也已成为罕见的黑魔法。
如今除内核中少量直接硬件接口支持外,几乎所有 Unix 代码都用高级语言编写。("高级"这个术语是历史遗留,用于区分"低级"汇编语言——后者本质上是机器码的浅层封装。)
高级语言有若干种类。讨论时需注意:程序源代码(人工创建的可编辑版本)必须经过某种翻译过程,转换为机器实际可执行的机器码。
编译型语言¶
最传统的语言是编译型语言。这类语言通过编译器(名称很直观)转换为可执行的二进制机器码文件。生成二进制文件后,可直接运行而无需再查看源代码。(大多数软件都以编译后的二进制形式分发,你看不到原始代码。)
编译型语言通常性能优异且能完全访问操作系统,但编程难度较大。
Unix 系统本身使用的 C 语言(及其变种 C++)是其中最重要的代表。FORTRAN 是工程师和科学家仍在使用但更古老原始的编译语言。Unix 世界中其他编译语言都不主流。此外,COBOL 在金融商业软件中应用广泛。
曾有许多其他编译语言,但大多已消亡或仅作研究工具。若你作为 Unix 开发者使用编译语言,极大概率会是 C 或 C++。
解释型语言¶
解释型语言依赖解释器程序,该程序读取源代码并实时将其转换为计算和系统调用。每次执行代码时都需要重新解释(且需解释器在场)。
解释型语言通常比编译型语言慢,且对底层系统和硬件的访问受限。但优点是编程更简单,对编码错误更宽容。
多 Unix 工具(包括 shell、bc(1)、sed(1)和 awk(1))本质上是小型解释语言。BASIC 通常也是解释型的,Tcl 亦然。历史上最重要的解释语言是 LISP(它比多数后继者都优秀)。如今,Unix shell 和 Emacs 编辑器内置的 Lisp 可能是最重要的纯解释语言。
中间代码语言¶
自 1990 年起,同时采用编译和解释的混合语言日益重要。中间代码语言类似编译语言之处在于源代码会被转换为紧凑的二进制形式来执行,但该形式并非机器码,而是伪代码(p-code)——通常比真实机器语言更简单但更强大。运行程序时实际是解释 p-code。
p-code 运行速度几乎媲美编译二进制(p-code 解释器可以做得非常简单轻快),同时保留优秀解释器的灵活性和强大功能。
重要的 p-code 语言包括 Python、Perl 和 Java。
互联网是如何工作的?¶
为了帮助您理解互联网的工作原理,我们将以典型的上网操作为例——用浏览器访问本文件在 Linux 文档项目网站上的首页。该文档位于
这意味着它存储在主机 www.tldp.org 的 WWW 导出目录下的 HOWTO/Unix-and-Internet-Fundamentals-HOWTO/index.html 文件中。
名称与定位¶
浏览器首先要建立与文档所在主机的网络连接。为此需要先找到主机 www.tldp.org 的网络位置("主机"是"主机设备"或"网络主机"的简称,www.tldp.org 是典型的主机名)。对应的位置实际上是一个称为 IP 地址的数字(我们稍后会解释"IP"这个术语的含义)。
浏览器会查询名为域名服务器的程序。该服务器可能运行在本地机器上,但更常见的是运行在服务商的机器上。当您注册 ISP 时,设置过程中通常需要配置 ISP 网络域名服务器的 IP 地址。
不同机器上的域名服务器会相互通信,交换并维护解析主机名(将其映射为 IP 地址)所需的所有最新信息。在解析 www.tldp.org 的过程中,您的域名服务器可能需要查询网络上三四个不同站点,但这个过程通常非常迅速(不到一秒)。我们将在下一节详细讨论域名服务器的工作机制。
域名服务器会告知浏览器 www.tldp.org 的 IP 地址是 152.19.254.81;获取该地址后,您的机器就能直接与 www.tldp.org 进行数据交换。
域名系统¶
将主机名转换为 IP 地址的整个程序和数据库网络被称为"DNS"(域名系统)。当您看到"DNS 服务器"的提法时,指的就是我们刚才所说的域名服务器。下面我将解释整个系统的工作原理。
互联网主机名由点分隔的多个部分组成。域(domain)是具有共同名称后缀的机器集合,域可以嵌套存在。例如,主机 www.tldp.org 位于.org 域的.tldp.org 子域中。
每个域都由权威域名服务器定义,该服务器知道域内其他机器的 IP 地址。权威(或"主")域名服务器可能有备份服务器(当您看到"辅助域名服务器"或"辅助 DNS"的提法时指的就是这些备份)。这些辅助服务器通常每隔几小时就会从主服务器刷新信息,因此主服务器上主机名到 IP 地址映射的变更会自动传播。
关键点在于:域的域名服务器不需要知道其他域(包括其子域)中所有机器的位置,只需知道这些域的域名服务器的位置。在我们的例子中,.org 域的权威域名服务器知道.tldp.org 域名服务器的 IP 地址,但不知道.tldp.org 中其他所有机器的地址
DNS 系统中的域就像一棵倒置的大树。顶端是根服务器,每台设备的 DNS 软件都内置了根服务器的 IP 地址。根服务器知道.com、.org 等顶级域的域名服务器地址,但不知道这些域内具体机器的地址。每个顶级域服务器都知道其直接下级域的域名服务器位置,以此类推。
DNS 经过精心设计,每台机器只需掌握树形结构中最基本的信息,对子树结构的本地化修改只需更改一个权威服务器的名称到 IP 地址映射数据库即可实现。
当你查询 www.tldp.org 的 IP 地址时,实际发生的过程是这样的:首先,你的域名服务器向根服务器询问.org 域名的服务器位置。获知后,它继续向.org 服务器查询.tldp.org 域名服务器的 IP 地址。得到该信息后,最终向.tldp.org 域名服务器询问主机 www.tldp.org 的地址。
大多数情况下,你的域名服务器并不需要如此费力工作。域名服务器会进行大量缓存——当你的服务器解析完主机名后,它会将该主机名与对应 IP 地址的关联关系在内存中保留一段时间。这就是为什么当你浏览新网站时,通常只会在首次访问页面时看到浏览器显示"正在查找"主机名的提示。最终,这种名称到地址的映射会过期,你的 DNS 必须重新查询——这很重要,可以避免主机名变更地址后,过时的信息永远残留。如果某主机无法访问,其对应的缓存 IP 地址也会被清除。
数据包与路由器¶
浏览器要做的是向 www.tldp.org 的 Web 服务器发送如下格式的命令:
具体过程如下:该命令会被封装成数据包,就像附带着三个关键信息的电报:源地址(你的计算机 IP 地址)、目标地址(152.19.254.81),以及表示万维网请求的服务端口号(本例中为 80)。
接着你的计算机会通过网线(连接 ISP 或本地网络的线路)发送数据包,直到抵达称为路由器的专用设备。路由器内存中存储着互联网的"地图"——虽然不一定是完整的,但至少包含你所在网络区域的完整信息,并且知道如何连接到互联网上其他区域的路由器。
你的数据包在到达目的地途中可能经过多个路由器。路由器很智能:它们会监测其他路由器接收数据包的响应时间,并利用这些信息引导流量选择快速链路。当检测到某个路由器(或电缆)脱离网络时,它们会尽可能寻找替代路由来补偿。
有个都市传说称互联网是为核战争幸存设计的。这并非事实,但互联网架构确实擅长在不可靠的硬件环境中保持稳定运行。这直接得益于其智能分布在成千上万台路由器中,而非集中在少数脆弱的大型交换机(如电话网络)。这意味着故障往往能被有效隔离,网络可以自动绕行。
当数据包抵达目标计算机后,该机器会根据服务端口号将其递交给 Web 服务器。Web 服务器通过查看命令包的源 IP 地址确定回复地址。当 Web 服务器返回本文档时,文档会被分割成多个数据包。数据包大小会根据网络传输介质和服务类型而变化。
TCP 与 IP 协议¶
要理解多数据包传输的处理机制,你需要知道互联网实际采用了两层叠加的协议。
底层 IP 协议(互联网协议)负责为网络通信中的数据包标注源地址和目标地址。例如访问 http://www.tldp.org 时,你发送的数据包会包含你的计算机 IP 地址(如 192.168.1.101)和 www.tldp.org 服务器的 IP 地址(152.2.210.81)。这些地址的工作原理就像邮寄信件时的家庭地址——邮局能读取地址并确定最佳投递路线,这与路由器处理互联网流量的方式如出一辙。
上层协议 TCP(传输控制协议)提供可靠性保障。当两台机器通过 IP 建立 TCP 连接时,接收方会向发送方返回数据包的确认应答。如果发送方在超时时间内未收到某数据包的确认,就会重发该数据包。此外,发送方为每个 TCP 数据包分配序列号,接收方可据此对乱序到达的数据包进行重组(这在网络连接状态波动时很常见)。
TCP/IP 数据包还包含校验和,用于检测链路故障导致的数据损坏(校验和根据数据包其余部分计算得出,若数据包内容或校验和受损,重新计算比对时极可能发现错误)。因此对于使用 TCP/IP 和域名服务的用户而言,这就像是在主机名/服务端口对之间可靠传输字节流的机制。网络协议开发者几乎无需关心底层的分包、重组、错误检查、校验和计算及重传机制。
HTTP:应用层协议¶
回到我们的示例。网页浏览器和服务器通过运行在 TCP/IP 之上的应用层协议通信,将其作为字节流传输通道。该协议称为 HTTP(超文本传输协议),前文展示的 GET 就是其指令之一。
当 GET 指令发往 www.tldp.org 的 80 端口 web 服务器时,将由监听该端口的服务器守护进程处理。多数互联网服务都通过这种守护进程实现,它们持续监听端口,等待并执行传入指令。
互联网设计若有一条核心准则,那就是所有组件都应尽可能简单且人性化。HTTP 及其相关协议(如主机间传输电子邮件的 SMTP 简单邮件传输协议)通常使用以回车换行结尾的可打印文本指令。
这种设计效率稍低——某些情况下采用紧凑的二进制协议可获得更高速度。但实践证明,指令的人性化描述与理解优势,远胜于通过复杂晦涩设计换取的微弱效率提升。
因此服务器守护进程通过 TCP/IP 返回的也是文本数据。响应开头类似这样(部分报头已省略):
HTTP/1.1 200 OK
Date: Sat, 10 Oct 1998 18:43:35 GMT
Server: Apache/1.2.6 Red Hat
Last-Modified: Thu, 27 Aug 1998 17:55:15 GMT
Content-Length: 2982
Content-Type: text/html
这些报头后接空行和网页正文(随后连接终止)。浏览器直接渲染该页面——报头指示渲染方式(特别是 Content-Type 报头声明返回数据实为 HTML 格式)。