Skip to main content

第一章: Unix 基础知识

操作系统为其上运行的软件提供服务, 管理硬件资源. 它是一种特殊的软件, 随计算机启动而通过特殊方式启动. 严格意义的操作系统指的是内核.

下面是一个 UNIX 系统的整体组成部分:

  1. Kernel 内核
  2. System Call(系统调用, 图中未标出): 内核对外提供的接口.
  3. Library Functions(公共函数库): 基于 System Call 封装的便于使用的接口集合.
  4. Shell: 基于 System Call 实现的特殊应用程序, 为运行其他应用程序提供接口.
  5. 应用程序: 既可以使用 System Call 接口, 也可以使用 Library Function 接口.

广义的操作系统指的是: 内核 + shell + 实用程序 + 公共函数库等的集合.

系统登录

登录名

UNIX 系统登录时, 输入用户名和密码, 系统会在 password file 中查找用户名(一般是 /etc/passwd), 在这个文件中可以看到一系列以冒号分隔的字段, 比如:

root:*:0:0:System Administrator:/var/root:/bin/sh

分别是: 登录名, 加密的密码(或不展示), UID, GID, 注释域(System Administrator), 该用户的 Home 目录(这里是 /var/root), 用户的默认 shell(这里是 /bin/sh).

目前所有的系统均将用户实际密码移到了另外一个文件, 在 C6 中会介绍一些访问密码文件的办法.

Shell

当用户登录后, 先会显示一些系统信息, 然后我们可以键入命令到 shell 程序中. shell 程序 是一个命令行解释器, 用于读取用户输入并执行命令.

用户输入一般是通过 terminal(一种 interactive shell) 或通过一个文件(称为 shell script, 即 shell 脚本)传给 shell.

常用的 shell 包括: sh, bash, csh, zsh, ksh, tcsh 等.

系统通过用户在 passwd 中对应条目的最后一段(用户默认 shell)来找到 shell 并运行该 shell.

文件和目录(File & Directory)

文件系统(File System)

UNIX 文件系统是一个目录和文件的层次结构(树结构), 树根被称为 root 目录, root 目录的名字是 /.

directory(目录)是一种特殊的文件, 包含 directory entries. 我们可以从概念上将 directory entry 理解为是由: 文件名 + 该文件属性描述信息的结构 组成.

(其在存储器上的存储结构和逻辑结构可能是不同的, UNIX 为了保证在文件有多个硬链接的情况下能正确同步文件属性, 因此没有将文件属性存储在 directory entries 中, 但逻辑结构可以像上面这样理解).

其中文件属性描述包括: 文件类型(普通文件/目录/设备文件等), 文件大小, 文件所有者, 文件访问权限, 文件最后修改日期等.(通过 statfstat 等函数可以读取文件属性描述结构, C4 会讲).

文件名(Filename)

目录中的名字叫文件名(filename), 文件名不能使用字符: /空字符(null). 因为 / 用于在 pathname 中分隔文件名, 而空字符(null 或理解为 \0) 用于 pathname 的结束.

且由于可移植性需要, POSIX.1 建议使用的文件名字符集合是: a-z, A-Z, 0-9, ., -, _. 当然多语言的文件名支持又是另外一个课题了(比如中文文件名).

当创建目录时, 有两个特殊文件就已经被创建了, 分别是 .(当前目录) 以及 ..(父目录). 对于 root 目录, .. 仍然表示 root 目录本身.

文件名长度: 目前的主流系统中, 均支持至少 255 个字符的文件名长度.

文件路径(Pathname)

Pathname 是由一个或多个 / 分隔的文件名组成. 当 Pathname 由 / 开头时, 称为绝对路径, 否则是相对路径(比如 ... 开头). 相对路径表示的文件路径是相对当前目录(current directory)而言的.

简单例子

比如下面的最简代码, 实现的是和 ls 类似的列出目录中所有文件名的功能:

use std::{path::PathBuf, str::FromStr, fs::read_dir};

fn main() {
let path_name = PathBuf::from_str("/").unwrap();
read_dir(path_name).unwrap().for_each(|entry| {
println!("{:?}", entry.unwrap().file_name().to_str().unwrap());
});
}

上述代码和 C 实现相比, 隐藏了一些细节(如下的三个标准库函数调用):

  1. opendir(内部在合适时机调用libc::opendir)
  2. readdir(内部在合适时机会调用 libc::readdir)
  3. closedir(dir 被 drop 时会调用 libc::closedir)

rust 的实现相比 C 的就简单得多, 但隐藏的细节需要知道.

工作目录(Working Directory)

每个进程都有一个工作目录(Working Directory), 有时也被称为当前工作目录(current working directory, 或 cwd). 这个目录就是进程运行过程中所有相对路径解释时的基础目录.

进程可以通过 chdir 改变自己的当前工作目录.

用户 Home 目录

当用户登录后, 当前工作目录被默认设置为 passwd 文件中对应的用户 Home 目录.

输入输出

文件描述符(File Descriptor)

文件描述符是一些较小的非负整数, 内核使用它们来标识一个进程访问(打开)的文件. 当进程打开现有文件, 或进程创建新的文件时, 内核都会返回一个对应的文件描述符, 进程就可以通过这个描述符来操作对应的文件.

标准输入/输出/错误(Standard input, Standard output, Standard error)

UNIX 中所有的 shell 都会默认在进程启动后打开三个文件, 分别是标准输入(0), 标准输出(1), 标准错误(2).

在没有特别配置的情况下, 这三个文件都连接到的是终端(即直接向终端输出, 或从终端获取输入).

几乎所有的 Shell 都提供了重定向(redirect)的办法, 能够将这三个文件描述符重定向到任意文件(比如将标准输出重定向到一个文件 ls > file.list).

非缓冲 I/O(Unbuffered I/O)

非缓冲 I/O 即直接使用系统调用进行 I/O 操作, 此时缓冲区实际是由开发者自己指定的. 非缓冲 I/O 对应的系统调用族有这些: open, read, write, lseek, close. 这些系统调用都是使用文件描述符作为参数来操作文件.

此外还有一类称为"缓冲 I/O"的函数, 是由 C 标准库提供的, 它基于系统调用进行了封装, 提供高效的缓冲设置, 便于开发者使用.

非缓冲 I/O 的示例

如下示例将从标准输入读取数据, 并将读取的内容写到标准输出.

fn read_write_stdout() {
let mut buffer = String::new();
io::stdin().read_line(&mut buffer).unwrap();
io::stdout().write_all(buffer.as_bytes()).unwrap();
}

// 下面这段是 stdin() 的构造, 可以看到在其中通过 mutex 包裹了一个 stdin_raw. 单个进程中的 stdin 是通过 oncelock 提供的.
// 这个做法可以学习并应用到全局单例的写法上.

pub fn stdin() -> Stdin {
static INSTANCE: OnceLock<Mutex<BufReader<StdinRaw>>> = OnceLock::new();
Stdin {
inner: INSTANCE.get_or_init(|| {
Mutex::new(BufReader::with_capacity(stdio::STDIN_BUF_SIZE, stdin_raw()))
}),
}
}

标准 I/O(Standard I/O)

即使用标准库提供的 I/O 函数, 这样不用自己去选择缓冲区大小. 比如 fgets 函数可以读取整行, 而 read 仅能读取我们指定的缓冲区大小的字节数.

上面的例子中, 如果使用标准 I/O, 则可以通过 getcputc 两个函数就能实现.

程序和进程

程序是存放在存储器上的文件, 进程是程序被载入内存后的运行实例. 进程有进程 ID, 进程启动是内核通过 7 种 exec 族函数之一完成的.

在 rust 中获取进程 ID 时, 可以这样:

println!("当前进程 ID 是: {}", std::process::id());

在 c 中则可以调用 getpid() 获取当前进程 ID.

进程控制

UNIX 提供了三个基础的进程控制函数: fork, exec(有 7 种变体形式), waitpid.

比如 shell 的核心原理就是从输入中读取命令, 并执行命令. 我们可以通过下面这个例子来演示进程控制:

fn fork_child_and_execute(command: String) {
// rust 中 fork 孩子比较容易, 不用像 c 那样还需要去区分 fork 后返回的进程 ID 来判断是子进程还是自己(子进程返回的 pid 是 0).
let mut child = Command::new(command.trim()).spawn().unwrap_or_else(|_| panic!("fork {} 失败", command));
println!("子进程的 ID 是: {}", child.id());
let ecode = child.wait().unwrap();
assert!(ecode.success());
}

这里的例子实际仍然也是在当前线程中 wait 孩子, 如果是多线程环境下, 可以在独立线程 fork, 这样父进程可以继续处理其它的事情.

fork 的进程会继承父进程的运行环境, 以及打开的文件.

线程

通常情况下, 一个进程只有一个控制流在运行, 即同时只有一个机器指令在执行. 在现代计算机系统下, 我们可以利用多线程实现并行处理.

一个进程中的多个线程共享进程地址空间(内存空间), 文件描述符, 以及进程相关的属性等. 需要明确的是每个线程有其自己的栈, 同时不同线程可以访问同一进程下其他线程的栈.

在多线程环境下, 需要对共享数据的访问进行同步以避免出现数据不一致.

和进程类似, 线程也通过线程 ID 进行标识. 线程 ID 只是在一个进程内部的表示.

错误处理

当 UNIX 系统函数出现错误的时候, 会设置 errno 为整数以表示错误. errno 的值不会被清理, 因此如果一个函数没有错误, 此时去读取 errno 可能会读到之前的错误.

从错误恢复

errno.h 中定义的错误可以分为两大类: 致命错误和非致命错误(fatal & nonfatal). 致命错误无法恢复, 意味着我们可能只能打印错误信息并退出程序, 而非致命错误可以恢复. 大部分非致命错误都是临时性的, 比如资源忙. 对于大部分由于资源不可用导致的非致命错误, 都可以通过延迟或重试的方式来处理.

用户标识

User ID

之前我们在 passwd 文件中也看到了, 用户 ID 是一个整数, 用于唯一标识用户. 当新建用户后, 用户 ID 就固定下来了. 那内核是如何通过用户 ID 来区分用户的权限以及可用操作的呢?

首先, ID 为 0 的用户为超级管理员(root 或称 superuser). 这个用户所拥有的权限称为 root priviledges. 当一个进程拥有管理员权限时, 大部分和文件相关的权限检查都会被通过, 并且有一部分系统函数只能被 root 使用.

Group ID

在 password 文件中一个用户还有一个 Group ID, 并且大部分情况下, 有多个入口都设置为同一个 Group ID(即多个用户属于同一个用户组).

用户组的作用是: 在组里面共享文件或资源. 和用户类似, 组也有组名字和组 ID(类比用户名和用户 ID). 组 ID 和组名字的对应关系存放在 /etc/group 文件中.

存放在磁盘上的文件中, 都存放了该文件所有者的用户 ID 和 组 ID.

早期的 UNIX 使用 16 位整数来表示用户/组 ID, 现在大部分都采用了 32 位整数表示.

可以通过 getuid 和 getgid 来获取当前进程所属的用户 ID 和 组 ID:

// 此处用到了 nix 封装的 getuid 和 getgid 函数. 需要使用 user 这个 feature. 具体指定详见 https://doc.rust-lang.org/cargo/reference/features.html
fn get_my_uid_and_gid() {
let uid = getuid();
let gid = getgid();
println!("current uid and gid are: {uid}, {gid}");
}

从属多用户组

目前允许一个用户加入最多 16 个用户组. 且 POSIX 标准要求系统至少支持一个进程可以有 8 个用户组, 但大部分系统都提供了 16 个.

额外用户组信息是通过在系统启动后从 /etc/group 文件中读取对应关系拿到的.

信号(Signal)

Signal 用于通知一个进程某条件发生. 如一个进程中出现除零错误, 则 SIGFPE 就会被发送给该进程. 而进程针对这个 Signal 可以有如下选择:

  1. 忽略这个 Signal: 若信号为某种硬件异常时则不建议忽略(比如除零错误, 或引用进程地址空间外的内存, 因为这些都是 undefined 行为).
  2. 执行默认操作: 比如除零错误的默认操作是终止进程.
  3. 注册处理函数: 当 Signal 收到后, 这个函数会被执行.

许多条件下都可能生成 Signal:

  1. 键盘: 比如 cmd+C
  2. 特殊函数调用: 调用 kill/abort 等函数
  3. 由一个进程发送给另外一个进程: 前提是参与的进程是同一所有者的.

如下是一个简单的 Signal 注册示例:

static mut X:u32 = 0;

extern "C" fn devide_zero_handler(sig: c_int) {
let sig = Signal::try_from(sig).expect("应能正确从 sig 中拿到对应 sig");
println!("信号拿到: {sig}");
if sig == Signal::SIGINT {
println!("退出信号, 但不退出");
unsafe { X += 1; }
}
if unsafe { X } > 2 {
println!("退出");
exit(0);
}
}

fn signal_handing() {
let handler = SigHandler::Handler(devide_zero_handler);
unsafe { signal::signal(signal::Signal::SIGINT, handler) }
.expect("注册 Handler, 失败了...");
}

其中使用 nixsignal 注册 SIGINT 的处理(即用户按下了 CTRL+C), 且按了三次就退出.

一些有用的链接:

时间值

UNIX 系统维护有两套不同的时间值:

  1. 日历时间: 即从 1970 年 1 月 1 日 0 点(UTC+0) 开始的 Epoch 秒数. 由 time_t 数据类型表示.
  2. 进程时间: 也称为 CPU 时间, 用于衡量一个进程使用 CPU 资源的多少. 进程时间由 clock tick 来表示, 通常 clock tick 在 1 秒内有 50, 60, 100 三种规格. 它由 clock_t 表示, 可以在进程中通过 sysconf 函数获取当前系统的 clock tick 值.

当我们需要衡量进程的执行时间时, UNIX 系统实际维护了三个值:

  • Clock time: 也称 wall clock time(墙上时间), 指的是进程运行时间的长短.
  • User CPU time: 进程在用户空间下指令的运行总时间
  • System CPU time: 进程在内核空间下指令的运行总时间(比如 read, write 这类需要陷入内核运行的函数调用).

还有一个时间是进程的 CPU time, CPU time 等于 User CPU time + System CPU time.

要获取任意一个进程的 clock time, user time, system time 可以使用 time 命令获取.

系统调用和库函数

所有操作系统都会提供系统调用以便用户程序使用. 在 UNIX 范畴下, 4.4BSD 大约有 110 个系统调用, SVR4 有 120 个. 不同系统版本和系统类型提供的系统调用个数有不同, 比如 Linux 3.2.0 有大约 380 个系统调用, 而 FreeBSD 8.0 约有 450 个.

UNIX 用户手册的第二部分是系统调用文档.

UNIX 用户手册第三部分是库函数文档, 库函数并非内核的直接接口, 而是在系统调用基础上的封装, 便于开发使用, 但并非所有的库函数都封装了系统调用(比如 strlen 函数只是计算给定的字符串长度).

我们需要明确的是正常情况下我们可以在完全不使用库函数的情况下和内核交互(即库函数都可以看作是系统调用的封装). 比如 malloc 库函数, 我们可以直接使用系统调用 sbrk 结合我们自己的内存分配算法, 实现自己的内存分配函数.