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。显然如果有图形用户界面对于用户而言是更加方便的。

MITRobert 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 厂商释放描述机器是如何工作的文档,这个文档一般称作 ABIABI 相对于 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 的一个特性,x86ABI 做函数调用返回值会放在 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 处理器具有六个控制寄存器,从 CR0CR4 以及 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 0Ring 1Ring2 以及 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 接口,比如 printfsprintfsnprintfvprintfmemcpymemcmpatoi
  • 与架构相关的 glibc,系统调用的接口

一般会被理解成下面的执行过程:

  1. hello world 程序调用 printf 库函数
  2. printf 执行 write 系统调用
  3. 从普通用户模式切换到超级用户模式
  4. 内核接管将 hello world 写到显示器
  5. 切换回到普通用户模式

实际上并不是这样,事实是,在单体设计中,没有内核。换言之,内核也是进程的一部分,它按照如下过程工作:

  1. hello world 程序调用 printf 库函数
  2. printf 执行 write 系统调用
  3. 进程调用系统调用从普通用户模式切换到超级用户模式
  4. 进程运行后续的内核代码,以及后续的设备驱动代码,并将 hello world 写到显示器
  5. 进程之后切换回普通用户模式

在内核内执行上下文

内核代码总是执行下面两个之一的上下文:

  • 进程
  • 中断

进程上下文

我们现在清楚,可以通过系统调用来获取内核服务。当这种情况发生时,这个进程会以内核态运行系统调用需要的内核代码。这是进程上下文,内核代码现在运行在进程的上下文。

进程上下文代码具有如下的属性:

  • 总是由一个进程或线程执行一个系统调用触发
  • 从上到下的方式
  • 由进程同步执行内核代码

中断上下文

考虑一个场景,一个网络包以你的以太网卡地址做为目标,到达了适配器,硬件检测它、收集它并将它填充到 buffer 中。现在它必须要让操作系统知道,详细地说,它希望让网卡设备驱动知道这件事,这样驱动可以将数据打包接收。通过置一个中断来通知网卡驱动清楚这件事。

我们知道,设备驱动是在内核空间中,因此它们代码以内核态运行。驱动代码中断服务例程(ISR: Interrupt service routine)现在执行,提取数据包,将它发送给操作系统网络协议栈,等待处理。

网卡设备驱动地中断服务例程是内核代码,它不是任何进程的上下文。实际上,硬件中断可能会中断通知给多个进程,因此我们称它为中断上下文。

中断上下文具有如下属性:

  • 总是由硬件中断触发(不是软件中断,故障或异常,因为它们依然是进程上下文)
  • 从底向上
  • 异步执行
posted @ 2022-12-05 22:12  ArvinDu  阅读(210)  评论(0编辑  收藏  举报