趣谈Linux3
计算机的基本结构和X86结构
CPU:中央处理器
- 包括三个部分:运算单元、数据单元、控制单元
- 运算单元:运算器等,做加法、做位移等
- 数据单元:如CPU内部的缓存和寄存器组,可暂时存放数据和运算结果
- 控制单元:统一的指挥中心

Bus:总线,高速通道
地址总线(Address Bus)
数据总线(Data Bus)
Memory:内存,可保存中间结果
I/O:输入设备和输出设备

8086结构

数据单元
- 16位的通用寄存器:8个
- AX、BX、CX、DX、SP、BP、SI、DI
- 可分成两个8位使用的寄存器:AX、BX、CX、DX
控制单元
- IP寄存器:指令指针寄存器,指向代码段中下一条指令位置
- 16位的段寄存器:4个,指向不同进程的地址空间
- CS、DS、SS、ES
- CS:代码段寄存器(Code Segment Register)
- DS:数据段寄存器
- SS:栈寄存器
- CS和DS两者都存储着一个段的起始地址
- 可知,一个段的大小为\(2^{16}=64k\)
注意点:8086的地址总线地址是20位,即空间为\(1M\),而CS、DS都是16位,需要凑足20位,即“起始地址*16+偏移量”
32位处理器
- 地址总线变为32根,即内存变为\(2^{32}=4G\)
- 扩展通用寄存器:8个16位拓展到8个32位的,保留16位和8位的使用方式

- 重新定义段寄存器
- 内存中某个位置存储着一个表格,表格存储着多项段描述符,而段描述符存放着段起始位置
- CS、SS、DS、ES仍为16位,但存储的是选择子(Selector),即段描述符(Segment Descriptor)在表格中的位置
- 实模式(Real
Pattern)和保护模式(Protected Pattern)
- 前者是8086的段寻址方式,系统刚刚启动时,CPU是处于实模式的
- 后者是重新定义的段寻址方式,当需要更多内存时,便转为保护模式
- 通过切换模式进行兼容

BIOS到bootloader
BIOS时期
- 全称:Basic Input and Output
System,即基本输入输出系统
- 位于主板的ROM上,该ROM固化了一些初始化的程序
- X86系统中,将1M空间最上面的0xF0000到0xFFFFF这64K映射给ROM,到这部分地址访问时,会访问ROM

- 电源刚加电的时候,会进行重置工作
- CS设置为0xFFFF
- IP设置为0x0000
- 第一条指令会指向0xFFFF0,该指令有一个JMP命令,跳到ROM中做初始化工作的代码,BIOS便开始进行初始化的工作
- 首先,BIOS会检查系统的硬件
- 然后,建立中断向量表和中断服务程序
bootloader时期
操作系统安装在硬盘上,BIOS界面上有个启动盘的选项
启动盘一般位于第一个扇区,占512字节,且以0xAA55结束,会在512字节内启动相关的代码
Linux中有个叫Grub2的工具,是搞系统启动的
全称:Grand Unified Bootloader Version 2
配置系统启动的选项:
1
grub2 -mkconfig -o /boot/grub2/grub.cfg
将启动程序安装到相应的位置:
1
grub2 -install /dev/sda
grub2第一个安装的是boot.img,它由boot.S编译而成,有512字节,正式安装到启动盘的第一个扇区,该扇区通常称为MBR(Master Boot Record,主引导记录/扇区)
BIOS完成任务后,会将boot.img从硬盘加载到内存中的0x7c00来运行,boot.img会加载grub2的另一个镜像core.img
core.img由lzma_decompress.img、diskboot.img、kernel.img和一系列模块组成,功能丰富
boot.img先加载的是core.img的第一个扇区,从硬盘启动的话,这个扇区存储着diskboot.img,对应代码为diskboot.S
boot.img将控制权交给diskboot.img,diskboot.img的任务是将core.img的其他部分加载进来
- 首先是解压缩程序lzma_decompress.img,对应代码是stratup_raw.S,执行时,会调用real_to_prot,切换到保护模式
- 然后是kernel.img,它是压缩过的,执行时需要解压缩
- 最后是各个模块module对应的映像
从实模式切换到保护模式
- 启用分段
- 在内存里面建立段描述符表,将寄存器里面的段存储器变成段选择子,指向某个段描述符
- 启动分页
- 将内存分成相等大小的块
- 打开Gate A20
- 即第21根地址线的控制线
- 切换保护模式的函数DATA32 call real_to_prot会打开Gate A20
- 解压缩kernel.img,跳转到kernel.img开始运行
- kernel.img对应代码为startup.S和一堆c文件
- startup.S会调用grub_main,是grub kernel的主函数,该函数里的grub_load_config()会开始解析grub.conf文件中的配置信息
- 正常启动时,grub_main最后会调用grub_command_execute("normal", 0, 0),最终会调用grub_normal_execute()函数,该函数中的grub_show_menu()会显示出让你选择的操作系统的列表
- 选定启动某个操作系统后,就开始调用grub_menu_execute_entry(),解析并执行选择项
- 如linux16命令,表示装载指定的内核文件,并传递内核启动参数,grub_cmd_linux() 函数会被调用,会首先读取 Linux 内核镜像头部的一些数据结构,放到内存中的数据结构来,进行检查。如果检查通过,则会读取整个 Linux 内核镜像到内存
- initrd 命令,用于为即将启动的内核传递 init ramdisk 路径。于是grub_cmd_initrd() 函数会被调用,将 initramfs 加载到内存中来。
- 然后,grub_command_execute("boot", 0, 0)才开始真正启动内核
内核初始化
内核的启动
从入口函数strat_kernel()开始
在 init/main.c 文件中,start_kernel 相当于内核的 main 函数,里面是各种各样的初始化函数XXXX_init

- 进程列表初始化:创始进程
- set_task_stack_end_magic(&init_task)
- 也称为0号进程,是唯一一个没有通过 fork 或者 kernel_thread 产生的进程,是进程列表的第一个
- Process List(进程列表)
- 中断初始化:trap_init()中设置了很多中断门(Interrupt
Gate),用于处理各种中断,响应用户需求
- 其中有一个set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32),是系统调用的中断门
- 系统调用也是通过发送中断的方式进行的
- 64位的有另外的系统调用方法
- 内存初始化:mm_init()就是用来初始化内存管理模块的
- 调度初始化:sched_init()是用来初始化调度模块的
- 基于内存的文件系统rootfs的初始化:调用vfs_caches_init()
- 在该函数里面,会调用mnt_init()->init_rootfs(),其中有一行代码,register_filesystem(&rootfs_fs_type)。在 VFS 虚拟文件系统里面注册了一种类型,我们定义为 struct file_system_type rootfs_fs_type
- 为兼容各种各样的文件系统,将文件的相关数据结构和操作抽象出来,形成一个抽象层对上提供统一的结构,该抽象层即为VFS(Virtual File System),虚拟文件系统
- 其他方面的初始化:调用rest_init()
初始化一号进程
rest_init()的第一个大工作便是,用 kernel_thread(kernel_init, NULL, CLONE_FS) 创建第二个进程,这个是1 号进程。
- 该进程是用户态所有进程的祖先
- 1号进程将运行一个用户进程,一旦有了用户进程,资源便需要进行区分以及分配,所以x86提供了分层的权限机制,把区域分成了四个Ring,越往里权限越高,越往外权限越低

- Ring0:存放访问关键资源的代码,也称为内核态(Kernel Mode)
- Ring3:存放普通的代码程序,也称为用户态(User Mode)
- 系统处于保护模式时,除了可访问空间变大,另一个功能便是保护,当处于用户态的代码想要执行更高权限的指令,这种行为是被禁止的
- 当用户态程序要访问核心资源时,可以进行系统调用,此时会暂停用户态程序的执行,进入内核态,内核完成相应操作后,系统调用结束,暂停的程序继续运行
- 暂停的实现:把程序运行的情况保存下来,把当前CPU寄存器的值暂存到一个地方,系统调用结束后,返回时再恢复回去

- 整个过程:用户态 -- 系统调用 -- 保存寄存器 -- 内核态执行系统调用 -- 恢复寄存器 -- 返回用户态继续运行

从内核态到用户态
在执行kernel_thread函数,即创建1号进程的时候,我们处于内核态
当需要到用户态去运行一个程序时
- kernel_thread的一个参数为一个函数kernel_init,该进程会运行这个函数
- 在kernel_init里面,会调用kernel_init_freeable(),其中有段代码
1
2if(!ramdisk_execute_command)
ramdisk_execute_command = "/init"- 而在kernel_init函数中,有段代码块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17if(ramdisk_execute_command) {
ret = run_init_process(ramdisk_execute_command);
...
}
if(!try_to_run_init_process("/sbin/init") ||
!try_to_run_init_process("/etc/init") ||
!try_to_run_init_process("/bin/init") ||
!try_to_run_init_process("/bin/sh"))
return 0;
// run_init_process代码
static int run_init_process(const char *init_filename) {
argv_init[0] = init_filename;
return do_execve(getname_kernel(init_filename),
(const char __user *const __user *)argv_init,
(const char __user *const __user *)envp_init);
}这说明,1号进程运行的是一个文件,run_init_process函数中调用的是do_execve
- execve是一个系统调用,其作用是运行一个执行文件
它尝试取运行ramdisk的"/init",或者普通文件系统上的"/sbin/init"、"/etc/init"、"/bin/init"、"/bin/sh"。不同版本的Linux会选择不同的文件启动,只要启动一个就可以了
利用执行init文件的机会,从内核态回到用户态,在运行init时,调用了do_execve,其中,do_execve -> do_execveat_common ->exec_binprm -> search_binary_handler,这里面会调用一段代码:
1
2
3
4
5
6
7int search_binary_handler(struct linux_binprm *bprm) {
......
struct linux_binfmt *fmt;
......
retval = fmt->load_binary(bprm);
.....
}- 也就是说,要运行一个程序,需要加载这个二进制文件,他是有一定格式的
- Linux一个常用的格式是ELF(Executable and Linkable Format,可执行与可链接格式)
1
2
3
4
5
6
7static struct linux_binfmt elf_format = {
.module = THIS_MODULE,
.load_binary = load_elf_binary,
.load_shlib = load_elf_library,
.core_dump = elf_core_dump,
.min_coredump = ELF_EXEC_PAGESIZE,
};- 其实就是先调用load_elf_binary,最后调用start_thread
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void
start_thread(struct pt_regs *regs, unsigned long new_ip,
unsigned long new_sp)
{
set_user_gs(regs, 0);
regs->fs = 0;
regs->ds = __USER_DS;
regs->es = __USER_ES;
regs->ss = __USER_SS;
regs->cs = __USER_CS;
regs->ip = new_ip;
regs->sp = new_sp;
regs->flags = X86_EFLAGS_IF;
force_iret();
}
EXPORT_SYMBOL_GPL(start_thread);- pt_regs便是寄存器,是在系统调用时内核用于保存用户态运行上下文的
- 将用户态的代码段CS设置为_USER_CS,将用户态的数据段DS设置为_USER_DS,还有指令指针寄存器IP和栈指针寄存器SP,补上了系统调用中保存寄存器的步骤
- iret是用于从系统调用中返回,这时候会恢复寄存区,CS和IP恢复了,指向用户态下一个要执行的语句,DS和SP也恢复了,指向用户态函数栈的栈顶,下一条指令便从用户态开始运行
ramdisk的作用
- init在上面步骤中从内核态到用户态了,一开始到用户态的是ramdisk的init,后来会启动真正根文件系统上的init,成为所有用户态进程的祖先
- Linux访问存储设备,要有驱动才能访问,如果存储系统数目很有限,那驱动可直接放到内核中
- 但如果存储系统太多了,都放进内核,内核就太大了,因此,我们先弄一个基于内存的文件系统
- 内存访问是不需要驱动的,这个就是ramdisk,此时它便是根文件系统
- 运行ramdisk上的/init,等它运行完了就已经在用户态了
- /init程序会先根据存储系统的类型来加载驱动,有了驱动就可以设置真正的根文件系统,ramdisk上的/init会启动文件系统上的init
- 接下来便是各种系统的初始化,启动系统的服务和控制台,用户便可以登录进来。
创建2号进程
- rest_init第二件大事情便是第三个进程--2号进程
- 该进程统一管内核态的进程
- kernel_thread(kthreadd, NULL, CLONE_FS|CLONE_FILES)又一次使用kernel_thread函数创建进程
- 从内核态来看,无论是进程还是线程,我们可以统称为任务(Task),都使用相同结构
- kthreadd函数,负责所有内核态的线程的调度和管理,是内核态所有线程运行的祖先
小结

系统调用
glibc对系统调用的封装
| glibc | 作用 |
|---|---|
| syscalls.list | 罗列所有glibc的函数对应的系统调用 |
| make-syscall.sh | 根据syscalls.list配置文件,对封装好的系统调用生成文件 |
| syscall-template.S | 使用宏,定义了系统调用的调用方式 |
以系统调用open为例子,看系统调用是如何实现的,解析从glibc中是如何调用到内核的open
- 我们开始在用户态进程里面调用open函数,为了方便,大部分用户会选择使用中介,即调用的是glibc里面的open函数
1 | \\ open函数的定义 |
- 在glibc的源代码中,有个文件syscalls.list,里面列着所有glibc的函数对应的系统调用
1 | # File name Caller Syscall name Args Strong name Weak names |
- glibc还有一个脚本make-syscall.sh,可根据上面的配置文件,对于每一个封装好的系统调用,生成一个文件
- 该文件里面定义了一些宏,例如#define SYSCALL_NAME open
- glibc还有一个文件syscall-template.S,使用上面这个宏,定义了这个系统调用的调用方式
1 | T_PSEUDO (SYSCALL_SYMBOL, SYSCALL_NAME, SYSCALL_NARGS) |
这里的PSEUDO也是一个宏,定义如下:
1 |
里面对于任何一个系统调用,都会调用DO_CALL,这也是一个宏,这个宏32位和64位的定义不同
32位系统调用过程
sysdep.h文件
1 | * Linux takes system call arguments in registers: |
- 我们请求参数放在寄存器里面,根据系统调用的名称,得到系统调用号,放在寄存器eax中,然后执行ENTER_KERNEL
1 |
int就是interrupt,中断的意思,int $0x80就是触发一个软中断,通过它可以陷入(trap)内核
内核启动的时候,有一个trap_init(),其中有这样的代码
1
set_system_intr_gate(IA32_SYSCALL_VECTOR, entry_INT80_32)
- 这是一个软中断的陷入门,当接收到一个系统调用的时候,entry_INT80_32就被调用了
1
2
3
4
5
6
7
8
9
10ENTRY(entry_INT80_32)
ASM_CLAC
pushl %eax /* pt_regs->orig_ax */
SAVE_ALL pt_regs_ax=$-ENOSYS /* save rest */
movl %esp, %eax
call do_syscall_32_irqs_on
.Lsyscall_32_done:
......
.Lirq_return:
INTERRUPT_RETURN- 通过push和SAVE_ALL将当前用户态的寄存器,保存在pt_regs结构里面
进入内核之前,保存所有的寄存器,然后调用do_syscall_32_irqs_on(即DO_CALL),实现如下
1 | static __always_inline void do_syscall_32_irqs_on(struct pt_regs *regs) |
在这里,可以看到,将系统调用号从eax里面取出来,然后根据系统调用号,在系统调用表中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数
根据宏定义,#define ia32_sys_call_table sys_call_table,系统调用就是放在这个表里面
当系统调用结束之后,在entry_INT80_32之后,紧接着调用的是INTERRUPT_RETURN,找到它的定义,也就是iret
1 |
- iret指令将原来用户态保存的现场恢复回来,包含代码段、指令指针寄存器等,此时用户态进程恢复执行
小结

64位系统调用过程
x86_64的sysdep.h文件
1 | /* The Linux/x86-64 kernel expects the system call parameters in |
- 跟32位的步骤一样,将系统调用名称转换为系统调用号,放到寄存器rax。但是这里是真正进行调用,不是用中断了,改用syscall指令了,传递参数的寄存器也变了
- syscall指令使用了一种特殊的寄存器,叫特殊模块寄存器(Model
Specific Registers,简称MSR)
- 这种寄存器是CPU为完成某些特殊控制功能为目的的寄存器,其中就有系统调用
- 系统初始化的时候,trap_init除了初始化上面的中断模式,还会调用cpu_init->syscall_init,其中有段代码:
1 | wrmsrl(MSR_LSTAR, (unsigned long)entry_SYSCALL_64); |
- rdmsr和wrmsr是用来读写特殊模块寄存器的
- MSR_LSTAR是一个特殊的寄存器,当syscall指令调用的时候,会从寄存器里面拿出函数地址来调用,也就是调用entry_SYSCALL_64
- 在arch/x86/entry/entry_64.S中定义了entry_SYSCALL_64
1 | ENTER(entry_SYSCALL_64) |
- 这里先保存了很多寄存器到pt_regs结构里面,例如用户态的代码段、数据段、保存参数的寄存器
- 然后调用entry_SYSCALL64_slow_pat->do_syscall_64
1 | __visible void do_syscall_64(strcut pt_regs *regs) { |
在do_syscall_64里面,从rax里面拿出系统调用号,然后根据系统调用号,在系统调用表sys_call_table中找到相应的函数进行调用,并将寄存器中保存的参数取出来,作为函数参数
- 无论是32位还是64位,都会到系统调用表sys_call_table这里来
- 系统调用返回时,执行的是USERGS_SYSRET64
1 |
|
返回用户态的指令变成了sysretq
小结

系统调用表
32位的系统调用表定义在arch/x86/entry/syscalls/syscall_32.tbl
以open的定义为例
1 | 5 i386 open sys_open compat_sys_open |
64位的系统调用定义在另一个文件arch/x86/entry/syscalls/syscall_64.tbl里
1 | 2 common open sys_open |
- 第一列数字是系统调用号
- 可看出,32位和64位的系统调用号是不一样的
- 第三列是系统调用的名字
- 第四列是系统调用在内核的实现函数
系统调用在内核中的实现函数要有一个声明,往往在include/linux/syscall.h文件中
1 | // sys_open的声明 |
- 真正实现这个系统调用的一般在一个.c文件里面,例如sys_open的实现在fs/open.c里面
1 | SYSCALL_DEFINE3(open, const char __user *, filename, int, flags, umode_t, mode) |
SYSCALL_DEFINE3是一个宏系统调用,最多6个参数,根据参数的数目选择宏,具体定义如下
1 |
|
把宏展开之后,实现如下,和声明是一样的
1 | asmlinkage long sys_open(const char __user *filename, |
- 声明和实现都好了,接下来,在编译过程中,根据syscall_32.tbl和syscall_64.tbl生成自己的unistd_32.h和unistd_64.h。生成方式在arch/x86/entry/syscalls/Makefile中
- 这里面会使用两个脚本
- 第一个脚本arch/x86/entry/syscalls/syscallhdr.sh,会在文件中生成#define __NR_open
- 第二个脚本arch/x86/entry/syscalls/syscalltbl.sh,会在文件中生成__SYSCALL(__NR_open, sys_open)
- 这样,unistd_32.h和unistd_64.h是对应的系统调用号和系统调用实现函数之间的对应关系
- 在文件arch/x86/entry/syscall_32.c中,定义了这样一个表,里面include了这个头文件,从而所有的sys_系统调用都在这个表里面了
1 | __visible const sys_call_ptr_t ia32_sys_call_table[__NR_syscall_compat_max+1] = { |
同理,在文件arch/x86/entry/syscall_64.c,定义了这样一个表,里面include了这个头文件,这样所有的sys_系统调用都在这个表里面了
1 | asmlinkage const sys_call_ptr_t sys_call_table[__NR_syscall_max+1] = { |
小结
