内核

内核是操作系统的核心,它提供硬件抽象,通过进程间通信机制以及系统调用让应用程序间接访问控制硬件资源。

分类

现有的内核架构一般有两类,一种是宏内核架构,传统的Unix系统以及Unix-like系统采用的就是这种架构。宏内核的抽象程度更高,提供的服务也更多,因此移植性较弱。微内核则只有基本的进程以及内存的管理,进程间通信以及调度这些功能,与宏内核正好相对。另外还有妥协产生的混合内核架构。

kernel-arch.png

这里的学习主要以Linux为主,所以内核架构一般是指宏内核架构。

功能

内核的主要功能如下:

  • 进程调度

    Linux是抢占式多任务操作系统,每个进程对CPU的使用规则都由内核进程调度程序决定。

  • 内存管理

    Linux采用了虚拟内存管理机制,优势是:

    • 进程与进程,进程与内核之间彼此隔离,保证了安全性
    • 只需将进程一部分保持在内存,降低进程对内存的需求量,使内存能够加载更多的进程,保证了内存的高效率使用
  • 文件系统

  • 进程控制

    创建和终止进程,包括对进程资源的分配和回收。

  • 设备访问

    内核对程序访问硬件设备(键盘,鼠标,磁盘等)提供简化的标准接口,同时调度多个进程对每一个设备的访问。

  • 网络连接

    内核以用户进程的名义收发网络数据。

  • 系统调用

    给应用程序的进程提供系统调用接口,进程可以利用这些接口请求内核执行各种任务。

内核态和用户态

现代处理器架构一般允许CPU至少在两种不同状态下运行:内核态和用户态。与之对应,虚拟内存区域也划分为用户空间与内核空间。

  • 用户态时:CPU只能访问被标记为用户空间的内存,访问内核空间内存会引发硬件异常。
  • 内核态时:CPU既能访问用户空间内存,也能访问内核空间内存。并且仅当处理器在核心态时,才能执行一些特殊操作,如执行宕机指令关闭系统,访问内存管理硬件等

操作系统一般会被置于内核态,这样确保用户进程即不能访问内核指令和数据结构,也无法执行不利于系统运行的操作。

进程与内核

对进程来讲,许多事件的发生以及状态都一无所知。比如进程对CPU的使用,进程在内存中的位置,进程所访问的文件在磁盘中的位置。同时,进程间彼此不能直接通信,进程本身无法创建新进程,进程不能与计算机外接设备直接通信。

内核则是系统的中枢,无所不知,无所不能,为系统上所有的进程提供便利。内核维护的数据结构中,包含了与所有正在运行的进程有关的信息,并且会随着进程的创建,状态改变或者终结,内核会及时更新这些数据结构。内核维护的底层数据结构可以将程序使用的文件名转换为磁盘的物理位置。每个进程的虚拟内存与计算机物理内存以及磁盘交换区(swap)之间的映射关系,也在内核维护的数据结构中。进程间的所有通信都由内核提供的通信机制完成。进程的创建终结也都由内核控制。进程与外部设备的通信也是由内核控制。

shell

shell是一种有特殊用途的程序,主要用于读取用户输入的命令,执行响应的程序以响应命令,也称为命令解释器。shell种类繁多,一台机器上可能有好几种,比如bash,zsh等,一般常用的就是bash。

用户和组

系统会对每个用户身份做唯一标识,用户可以属于多个组。

用户

系统每个用户都由唯一的登录名和与之对应的整数型用户ID(UID)。这些信息保存在系统密码文件/etc/passwd中。

~ cat /etc/passwd
root:x:0:0:root:/root:/bin/zsh
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
...

passwd文件每个用户都有一行记录,以:间隔,依次标识不同的含义。

  • 用户名
  • 已加密的密码:目前大多数系统中,这部分信息设置为x*等。实际密码保存在/etc/shadow文件中,只有root用户才能查看。
  • UID:用户标识
  • GID:用户所属组标识
  • Gecos:用户详细描述信息,全名,联系方式等
  • 主目录:用户登录后进入的初始目录
  • 命令:用户每次登录之后执行的命令,一般执行登录shell。

每个用户组对应系统组文件/etc/group中的一行记录。

~ cat /etc/group
root:x:0:
daemon:x:1:
bin:x:2:
sys:x:3:
adm:x:4:
tty:x:5:
disk:x:6:
lp:x:7:
...

也是以:间隔,依次标识不同的含义。

  • 组名
  • GID:组ID
  • 用户列表:每个用户之间用,隔开。

超级用户

UID为0,通常登录名为root。在系统中享有一切特权。

单根目录层级、目录、链接、文件

文件类型

文件系统内会对文件类型进行标记,表明其种类。主要有:

标识 类型 说明
- 普通文件 包括纯文本文件,二进制文件,数据格式文件以及各种压缩文件等
d 设备文件 目录
c 字符设备文件 串行端口的接口设备,如键盘、鼠标等
b 块设备文件 存储数据以供系统存取的接口设备文件,如硬盘等
s 套接字文件 通常用在网络连接,可以通过套接字来通信
p 管道文件 解决多个程序同时存取一个文件所造成的错误
l 符号链接 指向源文件的链接

符号链接

如果系统调用中用到了路径名,内核会自动解除路径名中的符号链接引用,以符号链接所指向的文件名来替换符号链接。若符号链接的目标文件自身也是符号链接,这个过程就会以递归的方式重复下去。内核对解除引用的次数做了限制。

文件名

文件名可以包含除/和空字符\0以外的所有字符,但是只建议使用字母、数字、._以及-。同时避免-开头,否则会被当作命令选项。.开头的文件在Linux中是隐藏文件。

文件的所有权和权限

每个文件都有一个与之相关的UID和GID,分别定义文件的所属用户和组。,系统根据文件的所有权来判定用户对文件的访问权限。

文件I/O模型

在Linux系统中,一切即文件,所以系统调用执行的I/O操作对所有文件类型都适用。就本质而言,内核只提供一种文件类型:字节流序列。

文件描述符

I/O系统调用使用为非负整数的文件描述符来指代打开的文件。获取文件描述符的常用手法是调用open()

shell启动的进程会继承3个已经打开的文件描述符:

  • 0:标准输入,为进程提供输入的文件
  • 1:标准输出,供进程写入输出的文件
  • 2:标准错误,供进程写入错误消息或异常通告的文件

交互式shell中,上述三者一般指向终端,在stdio函数库中,分别与文件流stdin,stdout和stderr对应。

stdio函数库

C语言I/O函数的标准库,系统编程时经常使用。

进程

进程是正在执行的程序实例。执行程序时,内核会将程序代码载入虚拟内存,为程序变量分配空间,建立内核记账(bookkeeping)数据结构,以记录与进程有关的各种信息。

进程的内存布局

逻辑上一个进程分为以下几个部分:

  • 文本:程序的指令
  • 数据:程序使用的静态变量
  • 堆:程序可以从这个区域动态分配额外内存
  • 栈:随函数的调用和返回而增减的一片内存,用于为局部变量和函数调用链接信息分配存储空间。

创建进程和执行程序

进程可以使用系统调用fork()来创建一个新进程。调用fork()的进程为父进程,新创建的为子进程。内核通过对父进程的复制来创建子进程。子进程从父进程继承数据段,栈段以及堆段的副本后可以修改这些内容,不会影响父进程原版的内容,内存中标记为只读的程序文本段由父子进程共享。

然后子进程要么去执行与父进程共享代码段中的另一组不同函数,或者,更为常见的是使用系统调用execve()去加载并执行一个全新的程序。execve()会销毁现有的文本段、数据段、栈段以及堆段,并根据新程序的代码,创建新段来替换它们。

进程ID和父进程ID

每个进程都有一个唯一的整数型进程标识符PID,此外每一个进程还有一个父进程标识符PPID,用以标识请求内核创建自己的进程。

进程终止和终止状态

可以使用以下两种方式终止一个进程:

  • 进程使用_exit()系统调用请求退出,此时进程会指明自己的终止状态。
  • 向进程传递信号,将其kill,此时会根据导致进程死亡的信号来行设置进程的终止状态。

无论以何种方式退出,进程都会生成“终止状态”,一个非负小整数,可供父进程的wait()系统调用检测。

一般终止状态为0表示成功退出,非0表示有错误发生。大多数shell会将前一执行程序的终止状态保存在$?变量中。

进程的用户和组标识符

每个进程都有一组与之相关的UID和GID。

  • 真实UID和GID:标识进程所属的用户和组,新进程继承于父进程。登录shell则从系统密码文件中的相应字段获取。
  • 有效UID和GID:进程在访问受个保护资源(如文件和进程间通信对象等)时,会使用这两个ID(结合补充组ID)来确定访问权限。一般情况下,进程的有效ID和真实ID相同。
  • 补充组ID:标识进程所属的额外组。新进程继承于父进程。登录shell则从系统组文件中的相应字段获取。

特权进程

特权进程是指有效用户ID为0的进程。由某一特权进程创建的进程,也可以是特权进程。成为特权进程的另一方法是利用set-user-ID机制,该机制允许某进程的有效用户ID等同于该进程所执行程序文件的用户ID。

能力(Capabilities)

始于内核2.2,Linux把传统上赋予超级用户的权限划分为一组相互独立的单元(称之为能力)。赋予某进程部分能力,使得其既能执行某些特权操作,又防止其执行其他特权操作。

init进程

系统引导时,内核会创建一个名为init的特殊进程,即“所有进程之父”,该进程相应的程序文件为/sbin/init。系统的所有进程不是由init亲自创建(fork()),就是由其后代创建。init进程号总为1,且总是以超级权限运行,谁都无法杀死它,只有关闭系统才能终止它。init的主要任务是创建并监控系统运行所需要的一系列进程。

守护进程

守护进程是指有特殊用途的进程,系统创建和处理此类进程与其他的进程相同。

其独有的特征:

  • 通常在系统引导时启动,直到系统关闭前都会一直运行。
  • 后台运行,并且没有控制终端供其读取或写入数据。

环境列表

每个进程都有份环境列表,即在进程用户空间内存中维护的一组环境变量。列表的每一个元素都由一个名称及其值组成。由fork()创建的新进程,会继承父进程的环境副本,这也为父子进程间通信提供了一种机制。当进程调用exec()替换当前正在运行的程序时,新程序要么继承老程序的环境,要么在exec()调用的参数中指定新环境并接收。

shell中一般使用export命令来创建环境变量。

C程序可以使用外部变量char **enversion来访问环境,而库函数也允许进程去获取或修改自己环境中的值。

资源限制

每个进程都会消耗资源,例如打开文件数,内存以及CPU,使用系统调用setrlimit(),进程可为自己消耗的格雷资源设定一个上限。此类资源限制的每一项均有两个相关值:

  • 软限制(soft limit):限制了进程可以消耗的资源总量。
  • 硬限制(hard limit):软限制的调整上限。

非特权进程在针对特定资源调整软限制值时,可将其设置为0到相应硬限制值之间的任意值,但硬限制值则只能调低,不能调高。

fork()创建的新进程,会继承其父进程对资源限制的设置。

使用unlimit命令可以调整shell的资源限制。shell为执行命令所创建的子进程会继承上述资源设置。

内存映射

调用系统函数mmap()的进程,会在其虚拟地址空间中创建一个新的内存映射。

映射分为两类:

  • 文件映射:将文件的部分区域映射到调用进程的虚拟内存。一旦映射完成,对文件映射内容的访问则转化为对相应内存区域的字节操作。映射页面会按需自动从文件中加载。
  • 匿名映射:无文件对应,其映射页面的内容会被初始化为0。

由某一进程所映射的内存可以与其他进程的映射共享。达成共享的方式有两个:

  • 两个进程都针对某一文件相同部分加以映射。
  • fork()创建的子进程从父进程继承映射。

两个或多个进程共享的页面相同时,其中一个进程对页面内容的修改对其他进程是否可见取决于创建映射时所传入的标志参数。

  • 若传入参数为私有,则对其他进程不可见,并且这些改动不会真的落实到文件上。
  • 若传入参数为共享,则对其他进程可见,并且这些改也会造成对文件的改动。

内存映射的用途很多,主要有:

  • 以可执行文件的相应段来初始化进程的文本段
  • 内存分配:内容填充为0。
  • 文件I/O:映射内存I/O。
  • 进程间通信:共享映射。

静态库和共享库

目标库:将逻辑相关的一组函数代码加以编译,置于一个文件中,供其他程序调用,主要分为两种。

静态库

早期Unix系统中唯一的一种目标库。主程序对静态库中隶属于各目标模块的不同函数加以引用,链接器在解析引用之后,会从库中抽取所需目标模块的副本,将其复制到最终的可执行文件中,这就是所谓的静态链接。

缺点:

  • 不同可执行文件中,可能都存有相同目标代码的副本,浪费磁盘。
  • 调用同一库函数的程序,若都以静态链接方式生成,且又同时执行,会浪费内存。
  • 修改库函数后,需要重新编译,生成新的静态库,所有调用库函数的程序都必须与新生成的静态库重新链接。

共享库

共享库的目的是为了解决静态库的缺点。

如果程序链接到共享库,链接器不需要复制目标模块到可执行文件中,而是在可执行文件中写入一条记录。一旦运行时将可执行文件载入内存,动态链接器会确保可执行文件找到动态库,并载入内存实施运行时链接,解析函数调用。运行时,共享代码在内存中保存一份,可供所有运行中的程序使用。

进程间通信及同步

Linux进程多是独立运行,如果需要合作以达成预期目的,需要通信和同步机制。Linux提供了丰富的进程间通信(IPC)机制:

机制 主要用途
信号(signal) 用来表示事件发生。
管道 用于在进程间传递数据。
套接字 供同一台主机或是联网的不同主机上所运行的进程之间传递数据。
文件锁定 防止其他进程读取或更新文件内容,允许进程对文件的部分区域锁定。
消息队列 用于进程间交换消息(数据包)。
信号量(semaphore) 用来同步进程动作。
共享内存 允许两个及以上进程共享一块内存,其中某一进程改变了共享内存内容时,其他所有进程会立即知晓。

信号

信号除了作为IPC机制之一,还有其他广泛的用途。

信号又称做“软件中断”。进程收到信号,意味着某一时间或异常情况的发生。信号有很多种类,每一种分别表示不同的事件或情况。内核、其他进程(只要具有相应权限)或进程自身都可以向进程发送信号。

信号从产生到送达进程期间,一直处于挂起状态。通常,系统会在接受进程下次获得调度时,将处于挂起状态的信号同时送达。如果接受进程正在运行,则会立即将信号送达。然而程序可以将信号屏蔽以求阻塞该信号,此时信号会一直处于挂起状态,直到解除该信号的阻塞。

进程一般有三种处理信号的方式:

  • 忽略信号。
  • 按照系统默认方式处理。
  • 提供一个函数,信号发生时调用该函数,也称为捕捉该信号。

线程

每个进程可执行多个线程。可以将线程想象为共享同一虚拟内存以及一干其他属性的进程。

每个线程都会执行相同的程序代码,共享同一数据区域和堆。但是,每个线程都有自己的栈,用来装载本地变量和函数调用链接信息。

线程之间可以通过共享全局变量进行通信。借助于线程API所提供的条件变量和互斥机制,进程所属的线程之间得以相互通信并同步行为。

线程的主要优点在于协同线程之间的数据共享更为容易,在某些算法上比多线程比多进程实现更加自然,多线程应用也能从多处理器硬件的并行处理中获益匪浅。

日期和时间

进程涉及两种类型的时间。

  • 真实时间:进程生命周期内,以某个标准时间点(日历时间)或固定时间点(进程启动时间)为起点测量得出的时间。在Unix系统中是自1970年1月1日00:00:00(UTC)起,按秒计量的时间。
  • 进程时间:也称为CPU时间,指进程自启动起来,所占用的CPU时间总量。可以进一步划分为系统CPU时间和用户CPU时间,前者是内核模式中执行代码花费的时间,后者是用户模式种执行代码花费的时间。

time命令会显示出真实时间,系统CPU时间以及为执行管道中多个进程花费的用户CPU时间。

/proc文件系统

/proc文件系统是一种虚拟文件系统,以文件系统目录和文件形式,提供一个指向内核数据结构的接口。为查看和改变各种系统属性提供方便。

参考资料