0 - Hand on system programming with Linux - Linux 系统架构
UNIX 哲学简介
下面是 UNIX
设计架构哲学核心:
- 一切皆进程,如果它不是进程,那么它是文件
- 一个工具实现一个任务
- 三个标准
I/O
通道 - 组合使用工具
- 纯文本
- 命令行
- 模块化
- 提供机制,而非策略
一切皆进程,若非进程则是文件
一个进程是执行程序的实例。一个文件是文件系统上的一个对象,除了包含纯文本或二进制的纯文本,它也可以是一个目录,一个符号链接,一个设备文件,一个命名管道,或一个套接字(UDS
)。
UNIX
设计哲学将外设(比如键盘、鼠标、显示器、传感器、触摸板等)抽象为文件,并称之为设备文件。通过这么做,UNIX
允许应用程序忽略设备文件与普通文件的区别,只需要将它们视作常规盘文件就好。
内核为这个抽象提供一个层,称作虚拟文件系统切换(VFS: Virtual Filesystem Switch
)。这样,应用开发者可以打开一个设备文件,并对这个打开的设备文件执行 I/O
读写操作,所有这些都使用常规的 API
。
每一个进程在被创建时都会继承三个文件:
- 标准输入
stdin: fd 0
,默认是键盘设备 - 标准输出
stdout: fd 1
,默认是显示器 - 标准错误
stderr: fd 2
,默认是显示器
fd
是对文件描述符的简称。
一个工具做一个任务
在 UNIX
设计中,需要避免制作瑞士军刀,相反,最好一个工具就用来做一个特定的工作。
举个例子,在使用 Linux
命令行工作时,可以查看现在挂载的文件系统,找到具有最多可用空间的地方:
$ df --local
Filesystem 1K-blocks Used Available Use% Mounted on
rootfs 20640636 1155492 18436728 6% /
udev 10240 0 10240 0% /dev
tmpfs 51444 160 51284 1% /run
tmpfs 5120 0 5120 0% /run/lock
tmpfs 102880 0 102880 0% /run/shm
为了对输出进行排序,我们可以先将输出保存到文件中,之后使用 sort
对文件内容进行排序:
$ df --local > tmp
$ sort -k4nr tmp
rootfs 20640636 1155484 18436736 6% /
tmpfs 102880 0 102880 0% /run/shm
tmpfs 51444 160 51284 1% /run
udev 10240 0 10240 0% /dev
tmpfs 5120 0 5120 0% /run/lock
Filesystem 1K-blocks Used Available Use% Mounted on
这么做会保留之前的第一行,我们可以使用 sed
删除第一行:
$ df --local > tmp
$ sed --in-place '1d' tmp
$ sort -k4nr tmp
rootfs 20640636 1155484 18436736 6% /
tmpfs 102880 0 102880 0% /run/shm
tmpfs 51444 160 51284 1% /run
udev 10240 0 10240 0% /dev
tmpfs 5120 0 5120 0% /run/lock
$ rm -f tmp
三个标准 I/O 通道
一些 UNIX
工具会从标准输入文件描述符,标准输入读取输入,并对读取到的数据进行操作,之后通过标准输出输出出来。任何错误输出可以输出到标准输出中。
组合使用工具
UNIX
工具可以从标准输入读入数据,并将数据打印到标准输出,并可以进行重定向的这个机制。可以使用进程间通讯的机制管道,来组合使用多个小工具。
纯文本
UNIX
程序一般设计工作伴随着纯文本。
一个常用的例子:一个应用,在启动时,解析一个配置文件。配置文件可以以二进制块形式组织。如果以纯文本文件形式,会有较高的可读性,理解与操作时会更加简单。可能会有人说,解析二进制文件会更快一点,但是考虑下面的情况:
- 一个更新的硬件,但是区别不很大
- 一个标准化的纯文本文件,比如
XML
可能有优化的代码去处理它了
命令行
UNIX
操作系统,已经它的应用,工具等,通常是通过命令行使用的,一个典型的使用情况是通过 shell
。显然如果有图形用户界面对于用户而言是更加方便的。
MIT
的 Robert Scheifler
考虑在 X Window
系统下面设计一个架构,为它提供干净优雅的框架,GUI
做为操作系统上的一层,为 GUI
客户(GUI
应用)提供库。
这个架构保持到现在。在安卓中使用 Linux
内核,需要给终端用户提供一个图形界面,但是安卓系统开发者的接口 ADB
依然是命令行形式的。
模块化
UNIX
操作系统设计来面向多个程序员同时在系统上工作的。因此写干净、优雅便于理解的代码成了它的一种文化。
提供机制,而非策略
以一个简单的例子理解这个原则。
当设计一个应用时,你需要用户输入登录与密码。这个功能的实现,需要调用检查密码的函数,比如 mygetpas()
,而这个函数又是由登录函数 mylogin()
调用,就是说:
mylogin()
{
...
mygetpass();
...
}
再然后,要遵守的协议是,如果获取到三次错误的用户密码,程序将拒绝访问。
UNIX
哲学是,如果密码输错三次,不要直接在 mygetpass()
函数中退出。相反,mygetpass()
仅需要返回一个布尔值(若密码正确返回 ture
,若密码错误则返回 false
),并通过 mylogin()
函数调用实现后续的逻辑。
错误逻辑的伪代码:
mygetpass()
{
numtries = 1;
//获取密码
if(password-is-wrong)
{
numtries++;
if(numtries >= 3)
{
//写错误信息并记录错误信息
//退出
}
}
}
//密码正确,继续
mylogin()
{
mygetpass()
}
下面是 UNIX
逻辑的伪代码:
mygetpass()
{
//获取密码
if(password-is-wrong)
return false;
return ture;
}
mylogin()
{
maxtries = 3;
while(maxtries--)
{
if(mygetpass() == true)
//继续后续操作
}
//如果执行到这里,则表示密码获取失败
//写错误信息并记录错误信息
//退出
}
系统架构
ABI
让我们写一个简单的 C
程序,并在设备上运行它。
不过,C
代码不能直接在 CPU
上运行,需要转化为机器语言。因为,我们需要安装工具立案,包括编译器、链接器、库对象以及其他的工具。我们对 C
源代码编译并链接将它们转化为可以在系统上执行的可执行格式。
处理器指令集架构 ISA
,包括机器的指令格式,支持的地址架构,以及寄存器模型。实际上,CPU
OEM
厂商释放描述机器是如何工作的文档,这个文档一般称作 ABI
。ABI
相对于 ISA
有更详细的描述,它描述了机器指令集格式,寄存器集细节,调用传统,链接语义,以及可执行文件格式,比如 ELF
。
以下面的 C
代码为例:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
int main(void)
{
int a;
printf("Hello, Linux System Programming, World!\r\n");
a = 5;
exit(0);
}
可以使用下面的命令编译这个源码:
$ gcc -Wall -Wextra hello.c -o hello
hello.c: In function ‘main’:
hello.c:7:6: warning: variable ‘a’ set but not used [-Wunused-but-set-variable]
7 | int a;
在产品代码中不要忽视编译器警告信息,避免所有的警告信息,即便是最简单的警告,这可以令代码变得正确,稳定,安全
使用 objdump
工具,查看机器码与其对应的汇编码:
$ objdump --source ./hello
让我们看一下源码 a = 5
对应的行:
1181: c7 45 fc 05 00 00 00 movl $0x5,-0x4(%rbp)
我们现在可以得到:
源码 | 汇编语言 | 机器码 |
---|---|---|
a = 5; | movl $0x5, -0x4(%rbp) |
c7 45 fc 05 00 00 00 |
因此,程序执行时,它会取指并执行机器指令,得到期望的结果。
通过内联汇编访问寄存器内容
现在让我们看一下更深入的挑战:使用内联汇编访问 CPU
寄存器。
x86_64
具有多个寄存器,让我们看一下 RCX
寄存器,这里我们会使用 x86_64
的一个特性,x86
的 ABI
做函数调用返回值会放在 RAX
中。使用这个知识,我们可以写一个使用内联汇编的函数,来将我们期望的寄存器的值放到 RAX
中,这将保证值能够返回到调用者:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
typedef unsigned long u64;
static u64 get_rcx(void)
{
/* 将寄存器值移入 RAX 请求一个寄存器值,RAX 通过函数返回 */
__asm__ __volatile__(
"push %rcx\n\t"
"movq $5, %rcx\n\t"
"movq %rcx, %rax");
__asm__ __volatile__("pop %rcx");
}
int main(void)
{
printf("Hello, inline assembly:\r\n [RCX] = 0x%lx\r\n]", get_rcx());
exit(0);
}
$ gcc -Wall -Wextra getreg_rcx.c -o getreg_rcx
getreg_rcx.c: In function ‘get_rcx’:
getreg_rcx.c:14:1: warning: no return statement in function returning non-void [-Wreturn-type]
14 | }
$ ./getreg_rcx
Hello, inline assembly:
[RCX] = 0x5
通过内联汇编访问控制寄存器
x86_64
处理器具有六个控制寄存器,从 CR0
到 CR4
以及 CR8
。这些控制寄存器对于系统而言是十分重要的。
让我们考虑 CR0
寄存器,Intel
手册中表示,CR0
包括系统控制标志位,控制着操作系统的工作模式,以及处理器的状态。
我们修改前一个程序访问并展示它的内容:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
typedef unsigned long u64;
static u64 get_rcx(void)
{
__asm__ __volatile__("movq %cr0, %rax");
}
int main(void)
{
printf("Hello, inline assembly:\r\n [RCX] = 0x%lx\r\n]", get_rcx());
exit(0);
}
$ make getreg_cr0
cc getreg_cr0.c -o getreg_cr0
$ ./getreg_cr0
Segmentation fault (core dumped)
好像没有获得我们预期的结果。
CPU 权限等级
如果一个程序员可以随意访问处理器的指令集,盲目地执行它,将会令设备损坏。
使用处理器的 CR0
控制寄存器,这个寄存器包含控制标志位,控制着处理器的操作模式。如果有不受限的访问 CR0
寄存器,可以切换这个标志位,可以做下面的操作:
- 打开或关闭硬件页
- 关闭
CPU cache
- 修改
cache
以及分配的属性 - 关闭由操作系统标识的只读的内存写保护
为了系统及其硬件资源的安全、鲁棒、正确,所有现代 CPU
都支持权限等级的概念。
现代 CPU
支持至少两个权限等级,或者说是模式,通常为:
- 超级用户
- 普通用户
权限等级或模式名 | 权限等级 | 用途 | 术语 |
---|---|---|---|
超级用户 | 高 | 操作系统以此权限运行 | 内核空间 |
普通用户 | 低 | 应用代码运行在此模式 | 用户空间 |
x86
上的权限等级或权限 ring
Intel
处理器支持四个权限等级或 ring,Ring 0
、Ring 1
、Ring2
以及 Ring 3
,在 Intel CPU
上,Ring 0
具有最高权限:
ring | 权限 | 用途 |
---|---|---|
Ring 0 | 最高 | 操作系统以此权限运行 |
Ring 1 | < ring 0 | 未使用 |
Ring 2 | < ring 1 | 未使用 |
Ring 3 | 最低 | 应用代码运行在此模式 |
处理器指令集架构为每一条指令分配一个权限等级,在某个权限下它们才可以被执行。一条可以在普通用户权限下指令的指令,自然可以在超级用户权限下执行。这种权限对一些寄存器的访问也是类似的。
为了使用 Intel
数据,当前权限等级 CPL: Current Privilege Level
是处理器执行当前代码所处的权限。
比如:
foo1
机器指令允许在超级用户权限下执行foo2
机器指令允许在普通用户权限下执行
机器指令 | 允许的模式 | CPL | 是否可以工作 |
---|---|---|---|
foo1 | 超级用户权限 | 0 | 可以 |
foo1 | 超级用户权限 | 3 | 不行 |
foo2 | 普通用户权限 | 0 | 可以 |
foo2 | 普通用户权限 | 3 | 可以 |
现在我们再看之前的两个程序,getreg_rcx.c
能够工作,是因为它访问的寄存器可以再用户空间被访问。而 getreg_cr0.c
不能执行,因为它企图访问 CR0
控制寄存器,这在普通用户权限下是禁止的,只在超级用户权限下可以访问。只有操作系统或者内核代码可以访问控制寄存器。
Linux 架构
Linux
系统架构是分层的。
分层 |
---|
应用程序 |
库程序接口 Lib API |
glibc /系统调用接口 SysCall API |
操作系统内核,驱动 Kernel API |
硬件 |
分层的一个好处,每一个层只需要考虑它上一层与下一层。这会有如下优点:
- 干净设计,降低复杂度
- 标准化,互通性
- 从栈中交换各层
- 可以按照需要引入新层
库
库是代码的归档,使用库来帮助将代码模块化,标准化,避免重复造轮子。Linux
桌面系统可能具有上千的库。
经典的 hello world
程序使用 printf
应用程序接口来展示字串:
printf("hello world\r\n");
printf
是哪里来的呢?他是 C
标准库的一部分,在 Linux
上,它被成为 GNU libc (glibc)
。glibc
不仅包括 C
程序,实际上,它也是操作系统的程序接口,通过系统调用实现。
系统调用
系统调用是实际的内核功能,可以从用户空间通过 glibc
的程序调用。它们做为临界函数将用户空间与内核空间连接到一起。如果一个用户程序希望请求内核的服务,它通过系统调用。因此,系统调用是内核的唯一合法入口。没有其他用户空间进程调用内核的方式。
可以换个角度思考,Linux
内核内部具有成千的接口,只有一小部分是暴露给用户空间的,这些暴露的接口就是系统调用。现代 glibc
具有接近 300 个系统调用。
有一个关键点,系统调用与其他接口是不同的。它们最终会调用内核代码,它们有能力穿越用户空间界限,它们具有切换普通权限到超级权限的能力。
所有现代 CPU ABI
都提供至少一个切换普通用户模式到超级用户模式的指令,在 x86
处理器上,传统的方式是使用中断 0x80
机器指令,这是一个软件中断 (或陷阱)。
CPU | 机器指令提供陷阱 | 系统调用分配的寄存器 |
---|---|---|
x86 /x86_64 |
中断 0x80 或 系统调用 |
EAX/RAX |
ARM |
swi/svc |
R0 - R7 |
Aarch64 |
svc |
X8 |
MIPS |
syscall |
$v0 |
Linux 一个单体架构操作系统
操作系统通常是两类:单体架构或微内核。Linux
是单体架构操作系统。
这是什么意思
monolith
英文单词表示一大块直立的石头。
在 Linux
操作系统上,应用以独立的称作进程的实例运行。一个线程可能是单线程或多线程的。现在,我们会考虑进程运行在 Linux
上,一个进程是正在执行程序的实例。
当一个用户空间进程调用一个库接口时,库 API
可能进行系统调用也可能不会进行系统调用。比如,执行 atoi(3)
时,这个 API
不会执行系统调用,因为它不需要内核就可以实现字符串与整型数的转换。
我们看一个经典的例子:
#include <stdio.h>
main()
{
printf("hello, world\r\n");
}
那么 printf
是怎么将内容打印到设备上的呢?实际上,printf
并不能自己实现这个功能。printf
只会组成一个字串,完成之后,它会调用 write
系统调用。write
系统调用会将 buffer
内容写到设备文件(显示器)中,标准输出。简言之,内核代码 write
最终切换到需要的驱动代码,设备驱动可以直接操作外设。
从顶而下,glibc
由于两个部分组成:
- 与架构无关的
glibc
,常规的libc
接口,比如printf
、sprintf
、snprintf
、vprintf
、memcpy
、memcmp
、atoi
等 - 与架构相关的
glibc
,系统调用的接口
一般会被理解成下面的执行过程:
hello world
程序调用printf
库函数printf
执行write
系统调用- 从普通用户模式切换到超级用户模式
- 内核接管将
hello world
写到显示器 - 切换回到普通用户模式
实际上并不是这样,事实是,在单体设计中,没有内核。换言之,内核也是进程的一部分,它按照如下过程工作:
hello world
程序调用printf
库函数printf
执行write
系统调用- 进程调用系统调用从普通用户模式切换到超级用户模式
- 进程运行后续的内核代码,以及后续的设备驱动代码,并将
hello world
写到显示器 - 进程之后切换回普通用户模式
在内核内执行上下文
内核代码总是执行下面两个之一的上下文:
- 进程
- 中断
进程上下文
我们现在清楚,可以通过系统调用来获取内核服务。当这种情况发生时,这个进程会以内核态运行系统调用需要的内核代码。这是进程上下文,内核代码现在运行在进程的上下文。
进程上下文代码具有如下的属性:
- 总是由一个进程或线程执行一个系统调用触发
- 从上到下的方式
- 由进程同步执行内核代码
中断上下文
考虑一个场景,一个网络包以你的以太网卡地址做为目标,到达了适配器,硬件检测它、收集它并将它填充到 buffer
中。现在它必须要让操作系统知道,详细地说,它希望让网卡设备驱动知道这件事,这样驱动可以将数据打包接收。通过置一个中断来通知网卡驱动清楚这件事。
我们知道,设备驱动是在内核空间中,因此它们代码以内核态运行。驱动代码中断服务例程(ISR: Interrupt service routine
)现在执行,提取数据包,将它发送给操作系统网络协议栈,等待处理。
网卡设备驱动地中断服务例程是内核代码,它不是任何进程的上下文。实际上,硬件中断可能会中断通知给多个进程,因此我们称它为中断上下文。
中断上下文具有如下属性:
- 总是由硬件中断触发(不是软件中断,故障或异常,因为它们依然是进程上下文)
- 从底向上
- 异步执行