版权 © 2000, 2001, 2002, 2003, 2004, 2005, 2006 The FreeBSD Documentation Project
版权 © 2004, 2005, 2006 FreeBSD 中文计划
欢迎您阅读《FreeBSD系统结构手册》。 这本手册还在不断由许多人继续书写。 许多章节还是空白,有的章节亟待更新。 如果您对这个项目感兴趣并愿意有所贡献,请发信给 FreeBSD 文档计划邮件列表。
本文档的最新英文原始版本可从 FreeBSD Web 站点 获得, 由 FreeBSD 中文计划 维护的最新译本可以在 FreeBSD 中文计划 快照 Web 站点 和 FreeBSD 中文计划 文档快照 处获得, 这一译本会不断向主站同步。 此外, 您也可以从 FreeBSD FTP 服务器 或众多的 镜像站点 得到这份文档的各种其他格式以及压缩形式的版本。
FreeBSD是FreeBSD基金会的注册商标。
UNIX是Open Group在美国和其它国家的注册商标。
Sun, Sun Microsystems, SunOS, Solaris, and Java是Sun Microsystems, Inc. 在美国和其它国家的商标或注册商标。
Apple and QuickTime是Apple Computer, Inc.的商标, 在美国和其它国家注册。
Macromedia and Flash是Macromedia, Inc. 在美国和/或其它国家的商标或注册商标。
Microsoft, Windows, and Windows Media是Microsoft Corporation 在美国和/或其它国家的商标或注册商标。
PartitionMagic是PowerQuest Corporation在美国和/或其它国家的注册商标。
许多制造商和经销商使用一些称为商标的图案或文字设计来彰显自己的产品。 本文档中出现的, 为 FreeBSD Project 所知晓的商标,后面将以 '™' 或 '®' 符号来标注。
重要: 本文中许可证的非官方中文翻译仅供参考, 不作为判定任何责任的依据。如与英文原文有出入,则以英文原文为准。
在满足下列许可条件的前提下, 允许再分发或以源代码 (SGML DocBook) 或 “编译” (SGML, HTML, PDF, PostScript, RTF 等) 的经过修改或未修改的形式:
再分发源代码 (SGML DocBook) 必须不加修改的保留上述版权告示、 本条件清单和下述弃权书作为该文件的最先若干行。
再分发编译的形式 (转换为其它DTD、 PDF、 PostScript、 RTF 或其它形式), 必须将上述版权告示、本条件清单和下述弃权书复制到与分发品一同提供的文件, 以及其它材料中。
重要: 本文档由 FREEBSD DOCUMENTATION PROJECT “按现状条件” 提供, 并在此明示不提供任何明示或暗示的保障, 包括但不限于对商业适销性、 对特定目的的适用性的暗示保障。 任何情况下, FREEBSD DOCUMENTATION PROJECT 均不对任何直接、 间接、 偶然、 特殊、 惩罚性的, 或必然的损失 (包括但不限于替代商品或服务的采购、 使用、 数据或利益的损失或营业中断) 负责, 无论是如何导致的并以任何有责任逻辑的, 无论是否是在本文档使用以外以任何方式产生的契约、 严格责任或是民事侵权行为(包括疏忽或其它)中的, 即使已被告知发生该损失的可能性。
Redistribution and use in source (SGML DocBook) and 'compiled' forms (SGML, HTML, PDF, PostScript, RTF and so forth) with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code (SGML DocBook) must retain the above copyright notice, this list of conditions and the following disclaimer as the first lines of this file unmodified.
Redistributions in compiled form (transformed to other DTDs, converted to PDF, PostScript, RTF and other formats) must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
重要: THIS DOCUMENTATION IS PROVIDED BY THE FREEBSD DOCUMENTATION PROJECT "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE FREEBSD DOCUMENTATION PROJECT BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
这一章是对引导过程和系统初始化过程的总览。这些过程始于BIOS(固件)POST, 直到第一个用户进程建立。由于系统启动的最初步骤是与硬件结构相关的、是紧配合的, 这里用IA-32(Intel Architecture 32bit)结构作为例子。
一台运行FreeBSD的计算机有多种引导方法。这里讨论其中最通常的方法, 也就是从安装了操作系统的硬盘上引导。引导过程分几步完成:
BIOS POST
boot0阶段
boot2阶段
loader阶段
内核初始化
boot0和boot2阶段在手册 boot(8)中被称为bootstrap stages 1 and 2, 是FreeBSD的3阶段引导过程的开始。在每一阶段都有各种各样的信息显示在屏幕上, 你可以参考下表识别出这些步骤。请注意实际的显示内容可能随机器的不同而有一些区别:
|
视不同机器而定 |
BIOS(固件)消息 |
F1 FreeBSD F2 BSD F5 Disk 2 |
boot0 |
>>FreeBSD/i386 BOOT Default: 1:ad(1,a)/boot/loader boot: |
boot2a |
BTX loader 1.0 BTX version is 1.01 BIOS drive A: is disk0 BIOS drive C: is disk1 BIOS 639kB/64512kB available memory FreeBSD/i386 bootstrap loader, Revision 0.8 Console internal video/keyboard (jkh@bento.freebsd.org, Mon Nov 20 11:41:23 GMT 2000) /kernel text=0x1234 data=0x2345 syms=[0x4+0x3456] Hit [Enter] to boot immediately, or any other key for command prompt Booting [kernel] in 9 seconds..._ |
loader |
Copyright (c) 1992-2002 The FreeBSD Project.
Copyright (c) 1979, 1980, 1983, 1986, 1988, 1989, 1991, 1992, 1993, 1994
The Regents of the University of California. All rights reserved.
FreeBSD 4.6-RC #0: Sat May 4 22:49:02 GMT 2002
devnull@kukas:/usr/obj/usr/src/sys/DEVNULL
Timecounter "i8254" frequency 1193182 Hz
|
内核 |
| 表注: a. 这种提示仅在boot0阶段用户选择操作系统后 仍按住键盘上某一键时才出现。 |
|
当PC加电后,处理器的寄存器被设为某些特定值。在这些寄存器中, 指令指针寄存器被设为32位值0xfffffff0。 指令指针寄存器指向处理器将要执行的指令代码。cr1, 一个32位控制寄存器,在刚启动时值被设为0。cr1的PE(Protected Enabled, 保护模式使能)位用来指示处理器是处于保护模式还是实地址模式。 由于启动时该位被清位,处理器在实地址模式中引导。在实地址模式中, 线性地址与物理地址是等同的。
值0xfffffff0略小于4G,因此计算机没有4G字节物理内存, 这就不会是一个有效的内存地址。计算机硬件将这个地址转指向BIOS存储块。
BIOS表示Basic Input Output System (基本输入输出系统)。在主板上,它被固化在一个相对容量较小的 只读存储器(Read-Only Memory, ROM)。BIOS包含各种各样为主板硬件 定制的底层例程。就这样,处理器首先指向常驻BIOS存储器的地址 0xfffffff0。通常这个位置包含一条跳转指令,指向BIOS的POST例程。
POST表示Power On Self Test(加电自检)。 这套程序包括内存检查,系统总线检查和其它底层工具, 从而使得CPU能够初始化整台计算机。这一阶段中有一个重要步骤, 就是确定引导设备。现在所有的BIOS都允许手工选择引导设备。 你可以从软盘、光盘驱动器、硬盘等设备引导。
POST的最后一步是执行INT 0x19指令。 这个指令从引导设备第一个扇区读取512字节,装入地址0x7c00。 第一个扇区的说法最早起源于硬盘的结构, 硬盘面被分为若干圆柱形轨道。给轨道编号,同时又将轨道分为 一定数目(通常是64)的扇形。0号轨道是硬盘的最外圈,1号扇区, 第一个扇区(轨道、柱面都从0开始编号,而扇区从1开始编号) 有着特殊的作用,它又被称为主引导记录(Master Boot Record, MBR)。 第一轨剩余的扇区常常不使用[1]。
让我们看一下文件/boot/boot0。 这是一个仅512字节的小文件。如果在FreeBSD安装过程中选择 “bootmanager”,这个文件中的内容将被写入硬盘MBR
如前所述,INT 0x19指令装载MBR, 也就是boot0的内容,至内存地址0x7c00。 再看文件sys/boot/i386/boot0/boot0.s, 可以猜想这里面发生了什么 - 这是引导管理器, 一段由 Robert Nordier书写的令人起敬的程序片段。
MBR里,也就是boot0里, 从偏移量0x1be开始有一个特殊的结构,称为 分区表。其中有4条记录 (称为分区记录),每条记录16字节。 分区记录表示硬盘如何被划分,在FreeBSD的术语中, 这被称为slice(d)。16字节中有一个标志字节决定这个分区是否可引导。 有仅只能有一个分区可设定这一标志。否则, boot0的代码将拒绝继续执行。
一个分区记录有如下域:
1字节 文件系统类型
1字节 可引导标志
6字节 CHS格式描述符
8字节 LBA格式描述符
一个分区记录描述符包含某一分区在硬盘上的确切位置信息。 LBA和CHS两种描述符指示相同的信息,但是指示方式有所不同:LBA (逻辑块寻址,Logical Block Addressing)指示分区的起始扇区和分区长度, 而CHS(柱面 磁头 扇区)指示首扇区和末扇区
引导管理器扫描分区表,并在屏幕上显示菜单,以便用户可以 选择用于引导的磁盘和分区。在键盘上按下相应的键后, boot0进行如下动作:
标记选中的分区为可引导,清除以前的可引导标志
记住本次选择的分区以备下次引导时作为缺省项
装载选中分区的第一个扇区,并跳转执行之
什么数据会存在于一个可引导扇区(这里指FreeBSD扇区)的第一扇区里呢? 正如你已经猜到的,那就是boot2。
也许你想知道,为什么boot2是在 boot0之后,而不是在boot1之后。事实上, 也有一个512字节的文件boot1存放在目录 /boot里,那是用来从一张软盘引导系统的。 从软盘引导时,boot1起着 boot0对硬盘引导相同的作用:它找到 boot2并运行之。
你可能已经看到有一文件/boot/mbr。 这是boot0的简化版本。 mbr中的代码不会显示菜单让用户选择, 而只是简单的引导被标志的分区。
实现boot2的代码存放在目录 sys/boot/i386/boot2/里,对应的可执行文件在 /boot里。在/boot里的文件 boot0和boot2不会在引导过程中使用, 只有boot0cfg这样的工具才会使用它们。 boot0的内容应在MBR中才能生效。 boot2位于可引导的FreeBSD分区的开始。 这些位置不受文件系统控制,所以它们不可用ls 之类的命令查看。
boot2的主要任务是装载文件 /boot/loader,那是引导过程的第三阶段。 在boot2中的代码不能使用诸如 open()和read()
之类的例程函数,因为内核还没有被加载。而应当扫描硬盘, 读取文件系统结构,找到文件/boot/loader,
用BIOS的功能将它读入内存,然后从其入口点开始执行之。
除此之外,boot2还可提示用户进行选择, loader可以从其它磁盘、系统单元、分区装载。
boot2 的二进制代码用特殊的方式产生:
sys/boot/i386/boot2/Makefile
boot2: boot2.ldr boot2.bin ${BTX}/btx/btx
btxld -v -E ${ORG2} -f bin -b ${BTX}/btx/btx -l boot2.ldr \
-o boot2.ld -P 1 boot2.bin
这个Makefile片断表明btxld(8)被用来链接二进制代码。 BTX表示引导扩展器(BooT eXtender)是给程序(称为客户(client) 提供保护模式环境、并与客户程序相链接的一段代码。所以 boot2是一个BTX客户,使用BTX提供的服务。
工具btxld是链接器, 它将两个二进制代码链接在一起。btxld(8)和ld(1) 的区别是ld通常将两个目标文件 链接成一个动态链接库或可执行文件,而btxld 则将一个目标文件与BTX链接起来,产生适合于放在分区首部的二进制代码, 以实现系统引导。
boot0执行跳转至BTX的入口点。 然后,BTX将处理器切换至保护模式,并准备一个简单的环境, 然后调用客户。这个环境包括:
虚拟8086模式。这意味着BTX是虚拟8086的监视程序。 实模式指令,如pushf, popf, cli, sti, if,均可被客户调用。
建立中断描述符表(Interrupt Descriptor Table, IDT), 使得所有的硬件中断可被缺省的BIOS程序处理。 建立中断0x30,这是系统调用关口。
两个系统调用exec和 exit的定义如下:
sys/boot/i386/btx/lib/btxsys.s:
.set INT_SYS,0x30 # 中断号
#
# System call: exit
#
__exit: xorl %eax,%eax # BTX系统调用0x0
int $INT_SYS #
#
# System call: exec
#
__exec: movl $0x1,%eax # BTX系统调用0x1
int $INT_SYS #
BTX建立全局描述符表(Global Descriptor Table, GDT):
sys/boot/i386/btx/btx/btx.s:
gdt: .word 0x0,0x0,0x0,0x0 # 以空为入口
.word 0xffff,0x0,0x9a00,0xcf # SEL_SCODE
.word 0xffff,0x0,0x9200,0xcf # SEL_SDATA
.word 0xffff,0x0,0x9a00,0x0 # SEL_RCODE
.word 0xffff,0x0,0x9200,0x0 # SEL_RDATA
.word 0xffff,MEM_USR,0xfa00,0xcf# SEL_UCODE
.word 0xffff,MEM_USR,0xf200,0xcf# SEL_UDATA
.word _TSSLM,MEM_TSS,0x8900,0x0 # SEL_TSS
客户的代码和数据始于地址MEM_USR(0xa000),选择符(selector) SEL_UCODE指向客户的数据段。选择符 SEL_UCODE 拥有第3级描述符权限 (Descriptor Privilege Level, DPL),这是最低级权限。但是 INT 0x30 指令的处理程序存储于另一个段里, 这个段的选择符SEL_SCODE (supervisor code)由有着管理级权限。 正如代码建立IDT(中断描述符表)时进行的操作那样:
mov $SEL_SCODE,%dh # 段选择符
init.2: shr %bx # 是否处理这个中断?
jnc init.3 # 否
mov %ax,(%di) # 设置处理程序偏移量
mov %dh,0x2(%di) # 设置处理程序选择符
mov %dl,0x5(%di) # 设置 P:DPL:type
add $0x4,%ax # 下一个中断处理程序
所以,当客户调用 __exec()时,代码将被以最高权限执行。
这使得内核可以修改保护模式数据结构,如分页表(page tables)、全局描述符表(GDT)、
中断描述符表(IDT)等。
boot2 定义了一个重要的数据结构: struct bootinfo。这个结构由 boot2 初始化,然后被转送到loader,之后又被转入内核。 这个结构的部分项目由boot2设定,其余的由loader设定。 这个结构中的信息包括内核文件名、BIOS提供的硬盘柱面/磁头/扇区数目信息、 BIOS提供的引导设备的驱动器编号,可用的物理内存大小,envp 指针(环境指针)等。定义如下:
/usr/include/machine/bootinfo.h
struct bootinfo {
u_int32_t bi_version;
u_int32_t bi_kernelname; /* 用一个字节表示 * */
u_int32_t bi_nfs_diskless; /* struct nfs_diskless * */
/* 以上为常备项 */
#define bi_endcommon bi_n_bios_used
u_int32_t bi_n_bios_used;
u_int32_t bi_bios_geom[N_BIOS_GEOM];
u_int32_t bi_size;
u_int8_t bi_memsizes_valid;
u_int8_t bi_bios_dev; /* 引导设备的BIOS单元编号 */
u_int8_t bi_pad[2];
u_int32_t bi_basemem;
u_int32_t bi_extmem;
u_int32_t bi_symtab; /* struct symtab * */
u_int32_t bi_esymtab; /* struct symtab * */
/* 以下项目仅高级bootloader提供 */
u_int32_t bi_kernend; /* 内核空间末端 */
u_int32_t bi_envp; /* 环境 */
u_int32_t bi_modulep; /* 预装载的模块 */
};
boot2 进入一个循环等待用户输入,然后调用 load()。如果用户不做任何输入,循环将在一段时间后结束, load() 将会装载缺省文件(/boot/loader)。
函数 ino_t lookup(char *filename)和 int xfsread(ino_t inode, void *buf, size_t nbyte)
用来将文件内容读入内存。/boot/loader是一个ELF格式二进制文件,
不过它的头部被换成了a.out格式中的struct exec结构。 load()扫描loader的ELF头部,装载/boot/loader 至内存,然后跳转至入口执行之:
sys/boot/i386/boot2/boot2.c:
__exec((caddr_t)addr, RB_BOOTINFO | (opts & RBX_MASK),
MAKEBOOTDEV(dev_maj[dsk.type], 0, dsk.slice, dsk.unit, dsk.part),
0, 0, 0, VTOP(&bootinfo));
loader也是一个 BTX 客户,在这里不作详述。 已有一部内容全面的手册 loader(8) ,由Mike Smith书写。 比loader更底层的BTX的机理已经在前面讨论过。
loader 的主要任务是引导内核。当内核被装入内存后,即被loader调用:
sys/boot/common/boot.c:
/* 从loader中调用内核中对应的exec程序 */
module_formats[km->m_loader]->l_exec(km);
loader跳转至哪里呢?那就是内核的入口点。让我们来看一下链接内核的命令:
sys/conf/Makefile.i386: ld -elf -Bdynamic -T /usr/src/sys/conf/ldscript.i386 -export-dynamic \ -dynamic-linker /red/herring -o kernel -X locore.o \ <lots of kernel .o files>
在这一行中有一些有趣的东西。首先,内核是一个ELF动态链接二进制文件, 可是动态链接器却是/red/herring,一个莫须有的文件。 其次,看一下文件sys/conf/ldscript.i386, 可以对理解编译内核时ld的选项有一些启发。 阅读最前几行,字符串
sys/conf/ldscript.i386: ENTRY(btext)
表示内核的入口点是符号 `btext'。这个符号在locore.s 中定义:
sys/i386/i386/locore.s:
.text
/**********************************************************************
*
* This is where the bootblocks start us, set the ball rolling...
* 入口
*/
NON_GPROF_ENTRY(btext)
首先将寄存器EFLAGS设为一个预定义的值0x00000002, 然后初始化所有段寄存器:
sys/i386/i386/locore.s
/* 不要相信BIOS给出的EFLAGS值 */
pushl $PSL_KERNEL
popfl
/*
* 不要相信BIOS给出的%fs、%gs值。相信引导过程中设定的%cs、%ds、%es、%ss值
*/
mov %ds, %ax
mov %ax, %fs
mov %ax, %gs
btext调用例程recover_bootinfo(), identify_cpu(),create_pagetables()。 这些例程也定在locore.s之中。这些例程的功能如下:
recover_bootinfo |
这个例程分析由引导程序传送给内核的参数。引导内核有3种方式: 由loader引导(如前所述), 由老式磁盘引导块引导,无盘引导方式。 这个函数决定引导方式,并将结构struct bootinfo 存储至内核内存。 |
identify_cpu |
这个函数侦测CPU类型,将结果存放在变量 _cpu中。 |
create_pagetables |
这个函数为分页表在内核内存空间顶部分配一块空间,并填写一定内容 |
下一步是开启VME(如果CPU有这个功能):
testl $CPUID_VME, R(_cpu_feature)
jz 1f
movl %cr4, %eax
orl $CR4_VME, %eax
movl %eax, %cr4
然后,启动分页模式:
/* Now enable paging */
movl R(_IdlePTD), %eax
movl %eax,%cr3 /* load ptd addr into mmu */
movl %cr0,%eax /* get control word */
orl $CR0_PE|CR0_PG,%eax /* enable paging */
movl %eax,%cr0 /* and let's page NOW! */
由于分页模式已经启动,原先的实地址寻址方式随即失效。 随后三行代码用来跳转至虚拟地址:
pushl $begin /* jump to high virtualized address */
ret
/* 现在跳转至KERNBASE,那里是操作系统内核被链接后真正的入口 */
begin:
函数init386()被调用;随参数传递的是一个指针,
指向第一个空闲物理页。随后执行mi_startup()。 init386是一个与硬件系统相关的初始化函数, mi_startup()是个与硬件系统无关的函数 (前缀'mi_'表示Machine
Independent,不依赖于机器)。 内核不再从mi_startup()里返回;
调用这个函数后,内核完成引导:
sys/i386/i386/locore.s:
movl physfree, %esi
pushl %esi /* 送给init386()的第一个参数 */
call _init386 /* 设置386芯片使之适应UNIX工作 */
call _mi_startup /* 自动配置硬件,挂接根文件系统,等 */
hlt /* 不再返回到这里! */
init386() init386()定义在 sys/i386/i386/machdep.c中, 它针对Intel
386芯片进行低级初始化。loader已将CPU切换至保护模式。 loader已经建立了最早的任务。
译者注: 每个"任务"都是与其它“任务”相对独立的执行环境。 任务之间可以分时切换,这为并发进程/线程的实现提供了必要基础。 对于Intel 80x86任务的描述,详见Intel公司关于80386 CPU及后续产品的资料, 或者在清华大学图书馆 馆藏记录中用"80386"作为关键词所查找到的系统结构方面的书目。
初始化内核的可调整参数,这些参数由引导程序传来
准备GDT(全局描述符表)
准备IDT(中断描述符表)
初始化系统控制台
初始化DDB(内核的点调试器),如果它被编译进内核的话
初始化TSS(任务状态段)
准备LDT(局部描述符表)
建立proc0(0号进程,即内核的进程)的pcb(进程控制块)
init386()首先初始化内核的可调整参数,
这些参数由引导程序传来。先设置环境指针(environment pointer, envp)调用, 再调用init_param1()。 envp指针已由loader存放在结构bootinfo中:
sys/i386/i386/machdep.c:
kern_envp = (caddr_t)bootinfo.bi_envp + KERNBASE;
/* 初始化基本可调整项,如hz等 */
init_param1();
init_param1()定义在 sys/kern/subr_param.c之中。
这个文件里有一些sysctl项,还有两个函数, init_param1()和init_param2()。
这两个函数从init386()中调用:
sys/kern/subr_param.c
hz = HZ;
TUNABLE_INT_FETCH("kern.hz", &hz);
TUNABLE_<typename>_FETCH用来获取环境变量的值:
/usr/src/sys/sys/kernel.h #define TUNABLE_INT_FETCH(path, var) getenv_int((path), (var))
Sysctlkern.hz是系统时钟频率。同时, 这些sysctl项被init_param1()设定: kern.maxswzone,
kern.maxbcache, kern.maxtsiz, kern.dfldsiz, kern.maxdsiz, kern.dflssiz, kern.maxssiz,
kern.sgrowsiz。
然后init386() 准备全局描述符表 (Global Descriptors
Table, GDT)。在x86上每个任务都运行在自己的虚拟地址空间里,
这个空间由"段址:偏移量"的数对指定。举个例子,当前将要由处理器执行的指令在
CS:EIP,那么这条指令的线性虚拟地址就是“代码段虚拟段地址CS” + EIP。
为了简便,段起始于虚拟地址0,终止于界限4G字节。所以,在这个例子中,
指令的线性虚拟地址正是EIP的值。段寄存器,如CS、DS等是选择符,
即全局描述符表中的索引(更精确的说,索引并非选择符的全部, 而是选择符中的INDEX部分)。
译者注: 对于80386, 选择符有16位,INDEX部分是其中的高13位。
sys/i386/i386/machdep.c: union descriptor gdt[NGDT * MAXCPU]; /* 全局描述符表 */ sys/i386/include/segments.h: /* * 全局描述符表(GDT)中的入口 */ #define GNULL_SEL 0 /* 空描述符 */ #define GCODE_SEL 1 /* 内核代码描述符 */ #define GDATA_SEL 2 /* 内核数据描述符 */ #define GPRIV_SEL 3 /* 对称多处理(SMP)每处理器专有数据 */ #define GPROC0_SEL 4 /* Task state process slot zero and up, 任务状态进程 */ #define GLDT_SEL 5 /* 每个进程的局部描述符表 */ #define GUSERLDT_SEL 6 /* 用户自定义的局部描述符表 */ #define GTGATE_SEL 7 /* 进程任务切换关口 */ #define GBIOSLOWMEM_SEL 8 /* BIOS低端内存访问(必须是这第8个入口) */ #define GPANIC_SEL 9 /* 会导致全系统异常中止工作的任务状态 */ #define GBIOSCODE32_SEL 10 /* BIOS接口(32位代码) */ #define GBIOSCODE16_SEL 11 /* BIOS接口(16位代码) */ #define GBIOSDATA_SEL 12 /* BIOS接口(数据) */ #define GBIOSUTIL_SEL 13 /* BIOS接口(工具) */ #define GBIOSARGS_SEL 14 /* BIOS接口(自变量,参数) */
请注意,这些#defines并非选择符本身,而只是选择符中的INDEX域, 因此它们正是全局描述符表中的索引。 例如,内核代码的选择符(GCODE_SEL)的值为0x08。
下一步是初始化中断描述符表(Interrupt Descriptor Table, IDT)。 这张表在发生软件或硬件中断时会被处理器引用。例如,执行系统调用时, 用户应用程序提交INT 0x80 指令。这是一个软件中断, 处理器用索引值0x80在中断描述符表中查找记录。这个记录指向处理这个中断的例程。 在这个特定情形中,这是内核的系统调用关口。
译者注: Intel 80386支持“调用门”,可以使得用户程序只通过一条call指令 就调用内核中的例程。可是FreeBSD并未采用这种机制, 也许是因为使用软中断接口可免去动态链接的麻烦吧。另外还有一个附带的好处: 在仿真Linux时,当遇到FreeBSD内核不支持的而又并非关键性的系统调用时, 内核只会显示一些出错信息,这使得程序能够继续运行; 而不是在真正执行程序之前的初始化过程中就因为动态链接失败而不允许程序运行。
sys/i386/i386/machdep.c: static struct gate_descriptor idt0[NIDT]; struct gate_descriptor *idt = &idt0[0]; /* 中断描述符表 */
每个中断都被设置一个合适的中断处理程序。 系统调用关口INT 0x80也是如此:
sys/i386/i386/machdep.c:
setidt(0x80, &IDTVEC(int0x80_syscall),
SDT_SYS386TGT, SEL_UPL, GSEL(GCODE_SEL, SEL_KPL));
所以当一个用户应用程序提交INT 0x80指令时,
全系统的控制权会传递给函数_Xint0x80_syscall,
这个函数在内核代码段中,将被以管理员权限执行。
然后,控制台和DDB(调试器)被初始化:
sys/i386/i386/machdep.c:
cninit();
/* 以下代码可能因为未定义宏DDB而被跳过 */
#ifdef DDB
kdb_init();
if (boothowto & RB_KDB)
Debugger("Boot flags requested debugger");
#endif
任务状态段(TSS)是另一个x86保护模式中的数据结构。当发生任务切换时, 任务状态段用来让硬件存储任务现场信息。
局部描述符表(LDT)用来指向用户代码和数据。系统定义了几个选择符, 指向局部描述符表,它们是系统调用关口和用户代码、用户数据选择符:
/usr/include/machine/segments.h #define LSYS5CALLS_SEL 0 /* Intel BCS强制要求的 */ #define LSYS5SIGR_SEL 1 #define L43BSDCALLS_SEL 2 /* 尚无 */ #define LUCODE_SEL 3 #define LSOL26CALLS_SEL 4 /* Solaris >=2.6版系统调用关口 */ #define LUDATA_SEL 5 /* separate stack, es,fs,gs sels ? 分别的栈、es、fs、gs选择符? */ /* #define LPOSIXCALLS_SEL 5*/ /* notyet, 尚无 */ #define LBSDICALLS_SEL 16 /* BSDI system call gate, BSDI系统调用关口 */ #define NLDT (LBSDICALLS_SEL + 1)
然后,proc0(0号进程,即内核所处的进程)的进程控制块(Process Control Block) (struct pcb)结构被初始化。proc0是一个 struct proc 结构,描述了一个内核进程。 内核运行时,该进程总是存在,所以这个结构在内核中被定义为全局变量:
sys/kern/kern_init.c:
struct proc proc0;
结构struct pcb是proc结构的一部分, 它定义在/usr/include/machine/pcb.h之中, 内含针对i386硬件结构专有的信息,如寄存器的值。
mi_startup()这个函数用冒泡排序算法,将所有系统初始化对象,然后逐个调用每个对象的入口:
sys/kern/init_main.c:
for (sipp = sysinit; *sipp; sipp++) {
/* ... 省略 ... */
/* 调用函数 */
(*((*sipp)->func))((*sipp)->udata);
/* ... 省略 ... */
}
尽管sysinit框架已经在《FreeBSD开发者手册》中有所描述, 我还是在这里讨论一下其内部原理。
每个系统初始化对象(sysinit对象)通过调用宏建立。 让我们以announce sysinit对象为例。 这个对象打印版权信息:
sys/kern/init_main.c:
static void
print_caddr_t(void *data __unused)
{
printf("%s", (char *)data);
}
SYSINIT(announce, SI_SUB_COPYRIGHT, SI_ORDER_FIRST, print_caddr_t, copyright)
这个对象的子系统标识是SI_SUB_COPYRIGHT(0x0800001), 数值刚好排在SI_SUB_CONSOLE(0x0800000)后面。 所以,版权信息将在控制台初始化之后就被很早的打印出来。
让我们看一看宏SYSINIT()到底做了些什么。 它展开成宏C_SYSINIT()。 宏C_SYSINIT()然后展开成一个静态结构 struct sysinit。结构里申明里调用了另一个宏 DATA_SET:
/usr/include/sys/kernel.h:
#define C_SYSINIT(uniquifier, subsystem, order, func, ident) \
static struct sysinit uniquifier ## _sys_init = { \ subsystem, \
order, \ func, \ ident \ }; \ DATA_SET(sysinit_set,uniquifier ##
_sys_init);
#define SYSINIT(uniquifier, subsystem, order, func, ident) \
C_SYSINIT(uniquifier, subsystem, order, \
(sysinit_cfunc_t)(sysinit_nfunc_t)func, (void *)ident)
宏DATA_SET()展开成MAKE_SET(), 宏MAKE_SET()指向所有隐含的sysinit幻数:
/usr/include/linker_set.h
#define MAKE_SET(set, sym) \
static void const * const __set_##set##_sym_##sym = &sym; \
__asm(".section .set." #set ",\"aw\""); \
__asm(".long " #sym); \
__asm(".previous")
#endif
#define TEXT_SET(set, sym) MAKE_SET(set, sym)
#define DATA_SET(set, sym) MAKE_SET(set, sym)
回到我们的例子中,经过宏的展开过程,将会产生如下声明:
static struct sysinit announce_sys_init = {
SI_SUB_COPYRIGHT,
SI_ORDER_FIRST,
(sysinit_cfunc_t)(sysinit_nfunc_t) print_caddr_t,
(void *) copyright
};
static void const *const __set_sysinit_set_sym_announce_sys_init =
&announce_sys_init;
__asm(".section .set.sysinit_set" ",\"aw\"");
__asm(".long " "announce_sys_init");
__asm(".previous");
第一个__asm指令在内核可执行文件中建立一个ELF节(section)。 这发生在内核链接的时候。这一节将被命令为.set.sysinit_set。 这一节的内容是一个32位值——announce_sys_init结构的地址,这个结构正是第二个 __asm指令所定义的。第三个__asm指令标记节的结束。 如果前面有名字相同的节定义语句,节的内容(那个32位值)将被填加到已存在的节里, 这样就构造出了一个32位指针数组。
用objdump察看一个内核二进制文件, 也许你会注意到里面有这么几个小的节:
% objdump -h /kernel
7 .set.cons_set 00000014 c03164c0 c03164c0 002154c0 2**2
CONTENTS, ALLOC, LOAD, DATA
8 .set.kbddriver_set 00000010 c03164d4 c03164d4 002154d4 2**2
CONTENTS, ALLOC, LOAD, DATA
9 .set.scrndr_set 00000024 c03164e4 c03164e4 002154e4 2**2
CONTENTS, ALLOC, LOAD, DATA
10 .set.scterm_set 0000000c c0316508 c0316508 00215508 2**2
CONTENTS, ALLOC, LOAD, DATA
11 .set.sysctl_set 0000097c c0316514 c0316514 00215514 2**2
CONTENTS, ALLOC, LOAD, DATA
12 .set.sysinit_set 00000664 c0316e90 c0316e90 00215e90 2**2
CONTENTS, ALLOC, LOAD, DATA
这一屏信息显示表明节.set.sysinit_set有0x664字节的大小, 所以0x664/sizeof(void *)个sysinit对象被编译进了内核。 其它节,如.set.sysctl_set表示其它链接器集合。
通过定义一个类型为struct linker_set的变量, 节.set.sysinit_set将被“收集”到那个变量里:
sys/kern/init_main.c:
extern struct linker_set sysinit_set; /* XXX */
struct linker_set定义如下:
/usr/include/linker_set.h:
struct linker_set {
int ls_length;
void *ls_items[1]; /* ls_length个项的数组, 以NULL结尾 */
};
译者注: 实际上是说, 用C语言结构体linker_set来表达那个ELF节。
回到对mi_startup()的讨论,
我们清楚了sysinit对象是如何被组织起来的。 函数mi_startup()将它们排序,
并调用每一个对象。最后一个对象是系统调度器:
/usr/include/sys/kernel.h:
enum sysinit_sub_id {
SI_SUB_DUMMY = 0x0000000, /* 不被执行,仅供链接器使用 */
SI_SUB_DONE = 0x0000001, /* 已被处理*/
SI_SUB_CONSOLE = 0x0800000, /* 控制台*/
SI_SUB_COPYRIGHT = 0x0800001, /* 最早使用控制台的对象 */
...
SI_SUB_RUN_SCHEDULER = 0xfffffff /* 调度器:不返回 */
};
系统调度器sysinit对象定义在文件sys/vm/vm_glue.c中,
这个对象的入口点是scheduler()。
这个函数实际上是个无限循环,它表示那个进程标识(PID)为0的进程——swapper进程。
前面提到的proc0结构正是用来描述这个进程。
第一个用户进程是init, 由sysinit对象init建立:
sys/kern/init_main.c:
static void
create_init(const void *udata __unused)
{
int error;
int s;
s = splhigh();
error = fork1(&proc0, RFFDG | RFPROC, &initproc);
if (error)
panic("cannot fork init: %d\n", error);
initproc->p_flag |= P_INMEM | P_SYSTEM;
cpu_set_fork_handler(initproc, start_init, NULL);
remrunqueue(initproc);
splx(s);
}
SYSINIT(init,SI_SUB_CREATE_INIT, SI_ORDER_FIRST, create_init, NULL)
create_init()通过调用fork1()
分配一个新的进程,但并不将其标记为可运行。当这个新进程被调度器调度执行时, start_init()将会被调用。 那个函数定义在init_main.c中。 它尝试装载并执行二进制代码init, 先尝试/sbin/init,然后是/sbin/oinit, /sbin/init.bak,最后是/stand/sysinstall:
sys/kern/init_main.c:
static char init_path[MAXPATHLEN] =
#ifdef INIT_PATH
__XSTRING(INIT_PATH);
#else
"/sbin/init:/sbin/oinit:/sbin/init.bak:/stand/sysinstall";
#endif
这一章由 FreeBSD SMP Next Generation Project 维护。 请将评论和建议发送给FreeBSD 对称多处理 (SMP) 邮件列表.
这篇文档提纲挈领的讲述了在FreeBSD内核中的锁,这些锁使得有效的多处理成为可能。 锁可以用几种方式获得。数据结构可以用mutex或lockmgr(9)保护。 对于为数不多的若干个变量,假如总是使用原子操作访问它们,这些变量就可以得到保护。
译者注: 仅读本章内容,还不足以找出“mutex” 和“共享互斥锁”的区别。似乎它们的功能有重叠之处, 前者比后者的功能选项更多。它们似乎都是lockmgr(9)的子集。
Mutex就是一种用来解决共享/排它矛盾的锁。 一个mutex在一个时刻只可以被一个实体拥有。如果另一个实体要获得已经被拥有的mutex, 就会进入等待,直到这个mutex被释放。在FreeBSD内核中,mutex被进程所拥有。
Mutex可以被递归的索要,但是mutex一般只被一个实体拥有较短的一段时间, 因此一个实体不能在持有mutex时睡眠。如果你需要在持有mutex时睡眠, 可使用一个 lockmgr(9) 的锁。
每个mutex有几个令人感兴趣的属性:
在内核源代码中struct mtx变量的名字
由函数mtx_init指派的mutex的名字。
这个名字显示在KTR跟踪消息和witness出错与警告信息里。
这个名字还用于区分标识在witness代码中的各个mutex
Mutex的类型,用标志MTX_*表示。 每个标志的意义在mutex(9)有所描述。
MTX_DEF一个睡眠mutex
MTX_SPIN一个循环mutex
MTX_RECURSE这个mutex允许递归
这个入口所要保护的数据结构列表或数据结构成员列表。 对于数据结构成员,将按照 结构名.成员名的形式命名。
仅当mutex被持有时才可以被调用的函数
表 2-1. Mutex列表
| 变量名 | 逻辑名 | 类型 | 保护对象 | 依赖函数 |
|---|---|---|---|---|
| sched_lock | “sched lock”(调度器锁) | MTX_SPIN | MTX_RECURSE
|
_gmonparam, cnt.v_swtch,
cp_time, curpriority, mtx.mtx_blocked, mtx.mtx_contested, proc.p_procq, proc.p_slpq, proc.p_sflag, proc.p_stat, proc.p_estcpu, proc.p_cpticks proc.p_pctcpu, proc.p_wchan, proc.p_wmesg, proc.p_swtime, proc.p_slptime, proc.p_runtime, proc.p_uu, proc.p_su, proc.p_iu, proc.p_uticks, proc.p_sticks, proc.p_iticks, proc.p_oncpu, proc.p_lastcpu, proc.p_rqindex, proc.p_heldmtx, proc.p_blocked, proc.p_mtxname, proc.p_contested, proc.p_priority, proc.p_usrpri, proc.p_nativepri, proc.p_nice, proc.p_rtprio, pscnt, slpque, itqueuebits, itqueues, rtqueuebits, rtqueues, queuebits, queues, idqueuebits, idqueues, switchtime, switchticks |
setrunqueue, remrunqueue,
mi_switch, chooseproc, schedclock, resetpriority, updatepri, maybe_resched, cpu_switch, cpu_throw, need_resched, resched_wanted, clear_resched, aston, astoff, astpending, calcru, proc_compare |
| vm86pcb_lock | “vm86pcb lock”(虚拟8086模式进程控制块锁) | MTX_DEF |
vm86pcb |
vm86_bioscall |
| Giant | “Giant”(巨锁) | MTX_DEF | MTX_RECURSE
|
几乎可以是任何东西 | 许多 |
| callout_lock | “callout lock”(延时调用锁) | MTX_SPIN | MTX_RECURSE
|
callfree, callwheel, nextsoftcheck, proc.p_itcallout, proc.p_slpcallout, softticks, ticks |
原子保护变量并非由一个显在的锁保护的特殊变量,而是: 对这些变量的所有数据访问都要使用特殊的原子操作(atomic(9))。 尽管其它的基本同步机制(例如mutex)就是用原子保护变量实现的, 但是很少有变量直接使用这种处理方式。
mtx.mtx_lock
内核对象,也就是Kobj,为内核提供了一种面向对象 的C语言编程方式。被操作的数据也承载操作它的方法。 这使得在不破坏二进制兼容性的前提下,某一个接口能够增/减相应的操作。
译者注: 这一小节两段落中原作者的用词有些含混, 请参考我在括号中的注释阅读。
Kobj工作时,产生方法的描述。每个描述有一个唯一的标识和一个缺省函数。 某个描述的地址被用来在一个类的方法表里唯一的标识方法。
构建一个类,就是要建立一张方法表,并将这张表关联到一个或多个函数(方法); 这些函数(方法)都带有方法描述。使用前,类要被编译。编译时要为这个类分配一些缓存。 在方法表中的每个方法描述都会被指派一个唯一的标识, 除非已经被其它引用它的类在编译时指派了标识。对于每个将要被使用的方法, 都会由脚本生成一个函数(方法查找函数),以解析外来参数, 并在被查询时给出方法描述的地址。被生成的函数(方法查找函数) 凭着那个方法描述的唯一标识按Hash的方法查找对象的类的缓存。 如果这个方法不在缓存中,函数会查找使用类的方法表。如果这个方法被找到了, 类里的相关函数(也就是某个方法的实现代码)就会被使用。 否则,这个方法描述的缺省函数将被使用。
这些过程可被表示如下:
对象->缓存<->类
struct kobj_method
void kobj_class_compile(kobj_class_t cls); void kobj_class_compile_static(kobj_class_t cls, kobj_ops_t ops); void kobj_class_free(kobj_class_t cls); kobj_t kobj_create(kobj_class_t cls, struct malloc_type *mtype, int mflags); void kobj_init(kobj_t obj, kobj_class_t cls); void kobj_delete(kobj_t obj, struct malloc_type *mtype);
使用Kobj的第一步是建立一个接口。建立接口包括建立模板的工作。 建立模板可用脚本src/sys/kern/makeobjops.pl完成, 它会产生申明方法的头文件和代码,脚本还会生成方法查找函数。
在这个模板中如下关键词会被使用: #include, INTERFACE, CODE, METHOD, STATICMETHOD, 和 DEFAULT.
#include语句的整行内容将被一字不差的 复制到被生成的代码文件的头部。
例如:
#include <sys/foo.h>
关键词INTERFACE用来定义接口名。 这个名字将与每个方法名接合在一起,形成 [interface name]_[method name]。 语法是:INTERFACE [接口名];
例如:
INTERFACE foo;
关键词CODE会将它的参数一字不差的复制到代码文件中。 语法是CODE { [任何代码] };
例如:
CODE {
struct foo * foo_alloc_null(struct bar *)
{
return NULL;
}
};
关键词METHOD用来描述一个方法。语法是: METHOD [返回值类型] [方法名] { [对象 [, 参数若干]] };
例如:
METHOD int bar {
struct object *;
struct foo *;
struct bar;
};
关键词DEFAULT跟在关键词METHOD之后, 是对关键词METHOD的补充。它给这个方法补充上缺省函数。语法是: METHOD [返回值类型] [方法名] { [对象; [其它参数]] }DEFAULT [缺省函数];
例如:
METHOD int bar {
struct object *;
struct foo *;
int bar;
} DEFAULT foo_hack;
关键词STATICMETHOD类似关键词METHOD。 对于每个Kobj对象,一般其头部都有一些Kobj专有的数据。 METHOD定义的方法就假设这些专有数据位于对象头部; 假如对象头部没有这些专有数据,这些方法对这个对象的访问就可能出错。 而STATICMETHOD定义的对象可以不受这个限制: 这样描述出的方法,其操作的数据不由这个类的某个对象实例给出, 而是全都由调用这个方法时的操作数(译者注:即参数)给出。 这也对于在某个类的方法表之外调用这个方法有用。
译者注: 这一段的语言与原文相比调整很大。 静态方法是不依赖于对象实例的方法。 参看C++类中的“静态函数”的概念。
其它完整的例子:
src/sys/kern/bus_if.m src/sys/kern/device_if.m
使用Kobj的第二步是建立一个类。一个类的组有名字、方法表;
假如使用了Kobj的“对象管理工具”(Object Handling Facilities),
类中还包含对象的大小。建立类时使用宏DEFINE_CLASS()。
建立方法表时,须建立一个kobj_method_t数组,用NULL项结尾。 每个非NULL项可用宏KOBJMETHOD()建立。
例如:
DEFINE_CLASS(fooclass, foomethods, sizeof(struct foodata));
kobj_method_t foomethods[] = {
KOBJMETHOD(bar_doo, foo_doo),
KOBJMETHOD(bar_foo, foo_foo),
{ NULL, NULL}
};
类须被“编译”。根据该类被初始化时系统的状态,
将要用到一个静态分配的缓存和“操作数表”(ops table,
译者注:即“参数表”)。这些操作可通过声明一个结构体 struct
kobj_ops并使用 kobj_class_compile_static(),
或是只使用kobj_class_compile()来完成。
使用Kobj的第三步是定义对象。Kobj对象建立程序假定Kobj
专有数据在一个对象的头部。如果不是如此,应当先自行分配对象, 再使用kobj_init()初始化对象中的Kobj专有数据; 其实可以使用kobj_create()分配对象, 并自动初始化对象中的Kobj专有内容。kobj_init() 也可以用来改变一个对象所使用的类。
将Kobj的数据集成到对象中要使用宏KOBJ_FIELDS。
例如
struct foo_data {
KOBJ_FIELDS;
foo_foo;
foo_bar;
};
使用Kobj的最后一部就是通过生成的函数调用对象类中的方法。 调用时,接口名与方法名用'_'接合,而且全部使用大写字母。
例如,接口名为foo,方法为bar,调用就是:
[返回值 = ] FOO_BAR(对象 [, 其它参数]);
当一个用kobj_create()不再需要被使用时,
可对这个对象调用kobj_delete()。 当一个类不再需要被使用时,
可对这个类调用kobj_class_free()。
在大多数UNIX®系统中,用户root是万能的。这也就增加了许多危险。 如果一个攻击者获得了一个系统中的root,就可以在他的指尖掌握系统中所有的功能。 在FreeBSD里,有一些sysctl项削弱了root的权限, 这样就可以将攻击者造成的损害减小到最低限度。这些安全功能中,有一种叫安全级别。 另一种在FreeBSD 4.0及以后版本中提供的安全功能,就是jail(8)。 Jail将一个运行环境的文件树根切换到某一特定位置, 并且对这样环境中叉分生成的进程做出限制。例如, 一个被监禁的进程不能影响这个jail之外的进程、不能使用一些特定的系统调用, 也就不能对主计算机造成破坏。
译者注: 英文单词“jail”的中文意思是“囚禁、监禁”。
Jail已经成为一种新型的安全模型。 人们可以在jail中运行各种可能很脆弱的服务器程序,如Apache、 BIND和sendmail。 这样一来,即使有攻击者取得了jail中的root, 这最多让人们皱皱眉头,而不会使人们惊慌失措。 本文主要关注jail的内部原理(源代码)。 如果你正在寻找设置Jail的指南性文档, 我建议你阅读我的另一篇文章,发表在Sys Admin Magazine, May 2001, 《Securing FreeBSD using Jail》。
Jail由两部分组成:用户级程序, 也就是jail(8);还有在内核中Jail的实现代码:jail(2) 系统调用和相关的约束。我将讨论用户级程序和jail在内核中的实现原理。
Jail的用户级源代码在/usr/src/usr.sbin/jail, 由一个文件jail.c组成。这个程序有这些参数:jail的路径, 主机名,IP地址,还有需要执行的命令。
在jail.c中,我将最先注解的是一个重要结构体 struct jail j;的声明,这个结构类型的声明包含在 /usr/include/sys/jail.h之中。
jail结构的定义是:
/usr/include/sys/jail.h:
struct jail {
u_int32_t version;
char *path;
char *hostname;
u_int32_t ip_number;
};
正如你所见,传送给命令jail(8)的每个参数都在这里有对应的一项。 事实上,当命令jail(8)被执行时,这些参数才由命令行真正传入:
/usr/src/usr.sbin/jail.c
char path[PATH_MAX];
...
if(realpath(argv[0], path) == NULL)
err(1, "realpath: %s", argv[0]);
if (chdir(path) != 0)
err(1, "chdir: %s", path);
memset(&j, 0, sizeof(j));
j.version = 0;
j.path = path;
j.hostname = argv[1];
传给jail(8)的参数中有一个是IP地址。这是在网络上访问jail时的地址。 jail(8)将IP地址翻译成网络字节顺序,并存入j(jail类型的结构体)。
/usr/src/usr.sbin/jail/jail.c:
struct in_addr in;
...
if (inet_aton(argv[2], &in) == 0)
errx(1, "Could not make sense of ip-number: %s", argv[2]);
j.ip_number = ntohl(in.s_addr);
函数inet_aton(3)“将指定的字符串解释为一个Internet地址, 并将其转存到指定的结构体中”。inet_aton(3)设定了结构体in, 之后in中的内容再用ntohl(3)转换成主机字节顺序, 并置入jail结构体的ip_number成员。
最后,用户级程序囚禁进程。现在Jail自身变成了一个被囚禁的进程, 并使用execv(3)执行用户指定的命令。
/usr/src/usr.sbin/jail/jail.c
i = jail(&j);
...
if (execv(argv[3], argv + 3) != 0)
err(1, "execv: %s", argv[3]);
正如你所见,函数jail()被调用,参数是结构体jail中被填入数据项, 而如前所述,这些数据项又来自jail(8)的命令行参数。 最后,执行了用户指定的命令。下面我将开始讨论jail在内核中的实现。
现在我们来看文件/usr/src/sys/kern/kern_jail.c。 在这里定义了jail(2)的系统调用、相关的sysctl项,还有网络函数。
在kern_jail.c里定义了如下sysctl项:
/usr/src/sys/kern/kern_jail.c:
int jail_set_hostname_allowed = 1;
SYSCTL_INT(_security_jail, OID_AUTO, set_hostname_allowed, CTLFLAG_RW,
&jail_set_hostname_allowed, 0,
"Processes in jail can set their hostnames");
/* Jail中的进程可设定自身的主机名 */
int jail_socket_unixiproute_only = 1;
SYSCTL_INT(_security_jail, OID_AUTO, socket_unixiproute_only, CTLFLAG_RW,
&jail_socket_unixiproute_only, 0,
"Processes in jail are limited to creating UNIX/IPv4/route sockets only");
/* Jail中的进程被限制只能建立UNIX套接字、IPv4套接字、路由套接字 */
int jail_sysvipc_allowed = 0;
SYSCTL_INT(_security_jail, OID_AUTO, sysvipc_allowed, CTLFLAG_RW,
&jail_sysvipc_allowed, 0,
"Processes in jail can use System V IPC primitives");
/* Jail中的进程可以使用System V进程间通讯原语 */
static int jail_enforce_statfs = 2;
SYSCTL_INT(_security_jail, OID_AUTO, enforce_statfs, CTLFLAG_RW,
&jail_enforce_statfs, 0,
"Processes in jail cannot see all mounted file systems");
/* jail 中的进程查看系统中挂接的文件系统时受到何种限制 */
int jail_allow_raw_sockets = 0;
SYSCTL_INT(_security_jail, OID_AUTO, allow_raw_sockets, CTLFLAG_RW,
&jail_allow_raw_sockets, 0,
"Prison root can create raw sockets");
/* jail 中的 root 用户是否可以创建 raw socket */
int jail_chflags_allowed = 0;
SYSCTL_INT(_security_jail, OID_AUTO, chflags_allowed, CTLFLAG_RW,
&jail_chflags_allowed, 0,
"Processes in jail can alter system file flags");
/* jail 中的进程是否可以修改系统级文件标记 */
int jail_mount_allowed = 0;
SYSCTL_INT(_security_jail, OID_AUTO, mount_allowed, CTLFLAG_RW,
&jail_mount_allowed, 0,
"Processes in jail can mount/unmount jail-friendly file systems");
/* jail 中的进程是否可以挂载或卸载对jail友好的文件系统 */
这些sysctl项中的每一个都可以用命令sysctl(8)访问。在整个内核中, 这些sysctl项按名称标识。例如,上述第一个sysctl项的名字是 security.jail.set_hostname_allowed。
像所有的系统调用一样,系统调用jail(2)带有两个参数, struct thread *td和struct jail_args *uap。 td是一个指向thread结构体的指针,该指针用于描述调用jail(2)的线程。 在这个上下文中,uap指向一个结构体,这个结构体中包含了一个指向从用户级 jail.c传送过来的jail结构体的指针。 在前面我讲述用户级程序时,你已经看到过一个jail结构体被作为参数传送给系统调用 jail(2)。
/usr/src/sys/kern/kern_jail.c:
/*
* struct jail_args {
* struct jail *jail;
* };
*/
int
jail(struct thread *td, struct jail_args *uap)
于是uap->jail可以用于访问被传递给jail(2)的jail结构体。 然后,jail(2)使用copyin(9)将jail结构体复制到内核内存空间中。 copyin(9)需要三个参数:要复制进内核内存空间的数据的地址 uap->jail,在内核内存空间存放数据的j, 以及数据的大小。uap->jail指向的Jail结构体被复制进内核内存空间, 并被存放在另一个jail结构体j里。
/usr/src/sys/kern/kern_jail.c: error = copyin(uap->jail, &j, sizeof(j));
在jail.h中定义了另一个重要的结构体型prison。 结构体prison只被用在内核空间中。 下面是prison结构体的定义。
/usr/include/sys/jail.h:
struct prison {
LIST_ENTRY(prison) pr_list; /* (a) all prisons */
int pr_id; /* (c) prison id */
int pr_ref; /* (p) refcount */
char pr_path[MAXPATHLEN]; /* (c) chroot path */
struct vnode *pr_root; /* (c) vnode to rdir */
char pr_host[MAXHOSTNAMELEN]; /* (p) jail hostname */
u_int32_t pr_ip; /* (c) ip addr host */
void *pr_linux; /* (p) linux abi */
int pr_securelevel; /* (p) securelevel */
struct task pr_task; /* (d) destroy task */
struct mtx pr_mtx;
void **pr_slots; /* (p) additional data */
};
然后,系统调用jail(2)为一个prison结构体分配一块内存, 并在jail和prison结构体之间复制数据。
/usr/src/sys/kern/kern_jail.c:
MALLOC(pr, struct prison *, sizeof(*pr), M_PRISON, M_WAITOK | M_ZERO);
...
error = copyinstr(j.path, &pr->pr_path, sizeof(pr->pr_path), 0);
if (error)
goto e_killmtx;
...
error = copyinstr(j.hostname, &pr->pr_host, sizeof(pr->pr_host), 0);
if (error)
goto e_dropvnref;
pr->pr_ip = j.ip_number;
下面,我们将讨论另外一个重要的系统调用jail_attach(2),它实现了将进程监禁的功能。
/usr/src/sys/kern/kern_jail.c
/*
* struct jail_attach_args {
* int jid;
* };
*/
int
jail_attach(struct thread *td, struct jail_attach_args *uap)
这个系统调用做出一些可以用于区分被监禁和未被监禁的进程的改变。 要理解jail_attach(2)为我们做了什么,我们首先要理解一些背景信息。
在FreeBSD中,每个对内核可见的线程是通过其thread结构体来识别的, 同时,进程都由它们自己的proc结构体描述。 你可以在/usr/include/sys/proc.h中找到thread和proc结构体的定义。 例如,在任何系统调用中,参数td实际上是个指向调用线程的thread结构体的指针, 正如前面所说的那样。td所指向的thread结构体中的td_proc成员是一个指针, 这个指针指向td所表示的线程所属进程的proc结构体。 结构体proc包含的成员可以描述所有者的身份 (p_ucred),进程资源限制(p_limit), 等等。在由proc结构体的p_ucred成员所指向的ucred结构体的定义中, 还有一个指向prison结构体的指针(cr_prison)。
/usr/include/sys/proc.h:
struct thread {
...
struct proc *td_proc;
...
};
struct proc {
...
struct ucred *p_ucred;
...
};
/usr/include/sys/ucred.h
struct ucred {
...
struct prison *cr_prison;
...
};
在kern_jail.c中,函数jail()以给定的jid 调用函数jail_attach()。随后jail_attach()调用函数change_root()以改变 调用进程的根目录。接下来,jail_attach()创建一个新的ucred结构体,并在 成功地将prison结构体连接到这个ucred结构体后,将这个ucred结构体连接 到调用进程上。从此时起,这个调用进程就会被识别为被监禁的。 当我们以新创建的这个ucred结构体为参数调用内核路径jailed()时, 它将返回1来说明这个用户身份是和一个jail相连的。 在jail中叉分出来的所有进程的的公共祖先进程就是这个执行了jail(2)的进程, 因为正是它调用了jail(2)系统调用。当一个程序通过execve(2)而被执行时, 它将从其父进程的ucred结构体继承被监禁的属性, 因而它也会拥有一个被监禁的ucred结构体。
/usr/src/sys/kern/kern_jail.c
int
jail(struct thread *td, struct jail_args *uap)
{
...
struct jail_attach_args jaa;
...
error = jail_attach(td, &jaa);
if (error)
goto e_dropprref;
...
}
int
jail_attach(struct thread *td, struct jail_attach_args *uap)
{
struct proc *p;
struct ucred *newcred, *oldcred;
struct prison *pr;
...
p = td->td_proc;
...
pr = prison_find(uap->jid);
...
change_root(pr->pr_root, td);
...
newcred->cr_prison = pr;
p->p_ucred = newcred;
...
}
当一个进程被从其父进程叉分来的时候, 系统调用fork(2)将用crhold()来维护其身份凭证。 这样,很自然的就保持了子进程的身份凭证于其父进程一致,所以子进程也是被监禁的。
/usr/src/sys/kern/kern_fork.c: p2->p_ucred = crhold(td->td_ucred); ... td2->td_ucred = crhold(p2->p_ucred);
在整个内核中,有一系列对被囚禁程序的约束措施。 通常,这些约束只对被囚禁的程序有效。如果这些程序试图突破这些约束, 相关的函数将出错返回。例如:
if (jailed(td->td_ucred))
return EPERM;
System V 进程间通信 (IPC) 是通过消息实现的。 每个进程都可以向其它进程发送消息, 告诉对方该做什么。 处理消息的函数是: msgctl(3)、msgget(3)、msgsnd(3) 和 msgrcv(3)。前面已经提到,一些 sysctl 开关可以影响 jail 的行为, 其中有一个是 security.jail.sysvipc_allowed。 在大多数系统上, 这个 sysctl 项会设成0。 如果将它设为1, 则会完全失去 jail 的意义: 因为那样在 jail 中特权进程就可以影响被监禁的环境外的进程了。 消息与信号的区别是:消息仅由一个信号编号组成。
/usr/src/sys/kern/sysv_msg.c:
msgget(key, msgflg): msgget返回(也可能创建)一个消息描述符, 以指派一个在其它函数中使用的消息队列。
msgctl(msgid, cmd, buf): 通过这个函数, 一个进程可以查询一个消息描述符的状态。
msgsnd(msgid, msgp, msgsz, msgflg): msgsnd向一个进程发送一条消息。
msgrcv(msgid, msgp, msgsz, msgtyp, msgflg): 进程用这个函数接收消息。
在这些函数对应的系统调用的代码中,都有这样一个条件判断:
/usr/src/sys/kern/sysv_msg.c:
if (!jail_sysvipc_allowed && jailed(td->td_ucred))
return (ENOSYS);
信号量系统调用使得进程可以通过一系列原子操作实现同步。 信号量为进程锁定资源提供了又一种途径。 然而,进程将为正在被使用的信号量进入等待状态,一直休眠到资源被释放。 在jail中如下的信号量系统调用将会失效: semget(2), semctl(2) 和semop(2)。
/usr/src/sys/kern/sysv_sem.c:
semctl(semid, num, cmd, ...): semctl对在信号量队列中用semid标识的信号量执行cmd指定的命令。
semget(key, nsems, flag): semget建立一个对应于key的信号量数组。
参数key和flag与他们在msgget()的意义相同。
setop(semid, array, nops): semop对semid标识的信号量完成一组由array所指定的操作。
System V IPC使进程间可以共享内存。进程之间可以通过它们虚拟地址空间 的共享部分以及相关数据读写操作直接通讯。这些系统调用在被监禁的环境中将会失效: shmdt(2)、shmat(2)、shmctl(2)和shmget(2)
/usr/src/sys/kern/sysv_shm.c:
shmctl(shmid, cmd, buf): shmctl对id标识的共享内存区域做各种各样的控制。
shmget(key, size, flag): shmget建立/打开size字节的共享内存区域。
shmat(shmid, addr, flag): shmat将shmid标识的共享内存区域指派到进程的地址空间里。
shmdt(addr): shmdt取消共享内存区域的地址指派。
Jail以一种特殊的方式处理socket(2)系统调用和相关的低级套接字函数。 为了决定一个套接字是否允许被创建,它先检查sysctl项 security.jail.socket_unixiproute_only是否被设置为1。 如果被设为1,套接字建立时将只能指定这些协议族: PF_LOCAL, PF_INET, PF_ROUTE。否则,socket(2)将会返回出错。
/usr/src/sys/kern/uipc_socket.c:
int
socreate(int dom, struct socket **aso, int type, int proto,
struct ucred *cred, struct thread *td)
{
struct protosw *prp;
...
if (jailed(cred) && jail_socket_unixiproute_only &&
prp->pr_domain->dom_family != PF_LOCAL &&
prp->pr_domain->dom_family != PF_INET &&
prp->pr_domain->dom_family != PF_ROUTE) {
return (EPROTONOSUPPORT);
}
...
}
网络协议TCP, UDP, IP和ICMP很常见。IP和ICMP处于同一协议层次:第二层, 网络层。当参数nam被设置时, 有一些限制措施会防止被囚禁的程序绑定到一些网络接口上。 nam是一个指向sockaddr结构体的指针, 描述可以绑定服务的地址。一个更确切的定义:sockaddr“是一个模板,包含了地址的标识符和地址的长度”。 在函数in_pcbbind_setup()中sin是一个指向sockaddr_in结构体的指针, 这个结构体包含了套接字可以绑定的端口、地址、长度、协议族。 这就禁止了在jail中的进程指定不属于这个进程所存在于的jail的IP地址。
/usr/src/sys/kern/netinet/in_pcb.c:
int
in_pcbbind_setup(struct inpcb *inp, struct sockaddr *nam, in_addr_t *laddrp,
u_short *lportp, struct ucred *cred)
{
...
struct sockaddr_in *sin;
...
if (nam) {
sin = (struct sockaddr_in *)nam;
...
if (sin->sin_addr.s_addr != INADDR_ANY)
if (prison_ip(cred, 0, &sin->sin_addr.s_addr))
return(EINVAL);
...
if (lport) {
...
if (prison && prison_ip(cred, 0, &sin->sin_addr.s_addr))
return (EADDRNOTAVAIL);
...
}
}
if (lport == 0) {
...
if (laddr.s_addr != INADDR_ANY)
if (prison_ip(cred, 0, &laddr.s_addr))
return (EINVAL);
...
}
...
if (prison_ip(cred, 0, &laddr.s_addr))
return (EINVAL);
...
}
你也许想知道函数prison_ip()做什么。 prison_ip()有三个参数,一个指向身份凭证的指针(用cred表示), 一些标志和一个IP地址。当这个IP地址不属于这个jail时,返回1; 否则返回0。正如你从代码中看见的,如果,那个IP地址确实不属于这个jail, 就不再允许向这个网络地址绑定协议。
/usr/src/sys/kern/kern_jail.c:
int
prison_ip(struct ucred *cred, int flag, u_int32_t *ip)
{
u_int32_t tmp;
if (!jailed(cred))
return (0);
if (flag)
tmp = *ip;
else
tmp = ntohl(*ip);
if (tmp == INADDR_ANY) {
if (flag)
*ip = cred->cr_prison->pr_ip;
else
*ip = htonl(cred->cr_prison->pr_ip);
return (0);
}
if (tmp == INADDR_LOOPBACK) {
if (flag)
*ip = cred->cr_prison->pr_ip;
else
*ip = htonl(cred->cr_prison->pr_ip);
return (0);
}
if (cred->cr_prison->pr_ip != tmp)
return (1);
return (0);
}
如果完全级别大于0,即便是jail里面的root, 也不允许在Jail中取消或更改文件标志,如“不可修改”、“只可添加”、“不可删除”标志。
/usr/src/sys/ufs/ufs/ufs_vnops.c:
static int
ufs_setattr(ap)
...
{
...
if (!priv_check_cred(cred, PRIV_VFS_SYSFLAGS, 0)) {
if (ip->i_flags
& (SF_NOUNLINK | SF_IMMUTABLE | SF_APPEND)) {
error = securelevel_gt(cred, 0);
if (error)
return (error);
}
...
}
}
/usr/src/sys/kern/kern_priv.c
int
priv_check_cred(struct ucred *cred, int priv, int flags)
{
...
error = prison_priv_check(cred, priv);
if (error)
return (error);
...
}
/usr/src/sys/kern/kern_jail.c
int
prison_priv_check(struct ucred *cred, int priv)
{
...
switch (priv) {
...
case PRIV_VFS_SYSFLAGS:
if (jail_chflags_allowed)
return (0);
else
return (EPERM);
...
}
...
}
SYSINIT是一个通用的调用排序与分别执行机制的框架。 FreeBSD目前使用它来进行内核的动态初始化。 SYSINIT使得FreeBSD的内核各子系统可以在内核或模块动态加载链接时被重整、 添加、删除、替换,这样,内核和模块加载时就不必去修改一个静态的有序初始化 安排表甚至重新编译内核。这个体系也使得内核模块 (现在称为KLD可以与内核不同时编译、链接、 在引导系统时加载,甚至在系统运行时加载。这些操作是通过 “内核链接器”(kernel linker)和“链接器集合” (linker set)完成的。
SYSINIT要依靠链接器获取遍布整个程序源代码多处申明的静态数据 并把它们组成一个彼此相邻的数据块。这种链接方法被称为 “链接器集合”(linker set)。 SYSINIT使用两个链接器集合以维护两个数据集合, 包含每个数据条目的调用顺序、函数、一个会被提交给该函数的数据指针。
SYSINIT按照两类优先级标识对函数排序以便执行。 第一类优先级的标识是子系统的标识, 给出SYSINIT分别执行子系统的函数的全局顺序, 定义在<sys/kernel.h>中的枚举 sysinit_sub_id内。第二类优先级标识在子系统中的元素的顺序, 定义在<sys/kernel.h>中的枚举 sysinit_elem_order内。
有两种时刻需要使用SYSINIT:系统启动或内核模块加载时, 系统析构或内核模块卸载时。内核子系统通常在系统启动时使用SYSINIT 的定义项以初始化数据结构。例如,进程调度子系统使用一个SYSINIT 定义项来初始化运行队列数据结构。设备驱动程序应避免直接使用 SYSINIT(),对于总线结构上的物理真实设备应使用 DRIVER_MODULE()调用的函数先侦测设备的存在, 如果存在,再进行设备的初始化。这一系统过程中, 会做一些专门针对设备的事情,然后调用SYSINIT()本身。 对于非总线结构一部分的虚设备,应改用DEV_MODULE()。
<sys/kernel.h>
SYSINIT(uniquifier, subsystem, order, func, ident) SYSUNINIT(uniquifier, subsystem, order, func, ident)
宏SYSINIT()在SYSINIT启动数据集合中 建立一个SYSINIT数据项,以便SYSINIT在系统启动或模块加载时排序 并执行其中的函数。SYSINIT()有一个参数uniquifier, SYSINIT用它来标识数据项,随后是子系统顺序号、子系统元素顺序号、 待调用函数、传递给函数的数据。所有的函数必须有一个恒量指针参数。
例 5-1. SYSINIT()的例子
#include <sys/kernel.h>
void foo_null(void *unused)
{
foo_doo();
}
SYSINIT(foo, SI_SUB_FOO, SI_ORDER_FOO, foo_null, NULL);
struct foo foo_voodoo = {
FOO_VOODOO;
}
void foo_arg(void *vdata)
{
struct foo *foo = (struct foo *)vdata;
foo_data(foo);
}
SYSINIT(bar, SI_SUB_FOO, SI_ORDER_FOO, foo_arg, &foo_voodoo);
注意,SI_SUB_FOO和SI_ORDER_FOO 应当分别在上面提到的枚举sysinit_sub_id和 sysinit_elem_order之中。既可以使用已有的枚举项, 也可以将自己的枚举项添加到这两个枚举的定义之中。 你可以使用数学表达式微调SYSINIT的执行顺序。 以下的例子示例了一个需要刚好要在内核参数调整的SYSINIT之前执行的SYSINIT。
宏SYSUNINIT()的行为与SYSINIT()的相当, 只是它将数据项填加至SYSINIT的析构数据集合。
例 5-3. SYSUNINIT()的例子
#include <sys/kernel.h>
void foo_cleanup(void *unused)
{
foo_kill();
}
SYSUNINIT(foobar, SI_SUB_FOO, SI_ORDER_FOO, foo_cleanup, NULL);
struct foo_stack foo_stack = {
FOO_STACK_VOODOO;
}
void foo_flush(void *vdata)
{
}
SYSUNINIT(barfoo, SI_SUB_FOO, SI_ORDER_FOO, foo_flush, &foo_stack);
本文档是作为 DARPA CHATS 研究计划的一部分,由供职于 Security Research Division of Network Associates 公司Safeport Network Services and Network Associates Laboratories 的Chris Costello依据 DARPA/SPAWAR 合同 N66001-01-C-8035 (“CBOSS”),为 FreeBSD 项目编写的。
Redistribution and use in source (SGML DocBook) and 'compiled' forms (SGML, HTML, PDF, PostScript, RTF and so forth) with or without modification, are permitted provided that the following conditions are met:
Redistributions of source code (SGML DocBook) must retain the above copyright notice, this list of conditions and the following disclaimer as the first lines of this file unmodified.
Redistributions in compiled form (transformed to other DTDs, converted to PDF, PostScript, RTF and other formats) must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
重要: THIS DOCUMENTATION IS PROVIDED BY THE NETWORKS ASSOCIATES TECHNOLOGY, INC "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL NETWORKS ASSOCIATES TECHNOLOGY, INC BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS DOCUMENTATION, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
重要: 本文中许可证的非官方中文翻译仅供参考, 不作为判定任何责任的依据。如与英文原文有出入,则以英文原文为准。
在满足下列许可条件的前提下,允许再分发或以源代码 (SGML DocBook) 或 “编译” (SGML, HTML, PDF, PostScript, RTF 等) 的经过修改或未修改的形式:
再分发源代码 (SGML DocBook) 必须不加修改的保留上述版权告示、本条件清单和下述弃权书作为该文件的最先若干行。
再分发编译的形式 (转换为其它DTD、 PDF、 PostScript、 RTF 或其它形式),必须将上述版权告示、 本条件清单和下述弃权书复制到与分发品一同提供的文件,以及其它材料中。
重要: 本文档由 NETWORKS ASSOCIATES TECHNOLOGY, INC “按现状条件”提供,并在此明示不提供任何明示或暗示的保障, 包括但不限于对商业适销性、对特定目的的适用性的暗示保障。任何情况下, NETWORKS ASSOCIATES TECHNOLOGY, INC 均不对任何直接、 间接、 偶然、 特殊、 惩罚性的, 或必然的损失 (包括但不限于替代商品或服务的采购、 使用、 数据或利益的损失或营业中断) 负责, 无论是如何导致的并以任何有责任逻辑的, 无论是否是在本文档使用以外以任何方式产生的契约、严格责任或是民事侵权行为(包括疏忽或其它)中的, 即使已被告知发生该损失的可能性。
FreeBSD 以一个内核安全扩展性框架(TrustedBSD MAC 框架)的方式,为若干强制访问控制策略(也称“集权式访问控制策略”) 提供试验性支持。MAC 框架是一个插入式的访问控制框架,允许新的安全策略更方便地融入内核:安全策略可以静态链入内核,也可以 在引导时加载,甚至在运行时动态加载。该框架所提供的标准化接口,使得运行在其上的安全策略模块能对系统对象的安全属性进行诸如标记等一系列操作。 MAC 框架的存在,简化了这些操作在策略模块中的实现,从而显著降低了新安全策略模块的开发难度。
本章将介绍 MAC 策略框架,为读者提供一个示例性的 MAC 策略模块文档。
TrustedBSD MAC 框架提供的机制,允许在其上运行的内核模块在内核编译或者运行时,对内核的访问控制模型进行扩展。 新的系统安全策略作为一个内核模块实现,并被链接到内核中;如果系统中同时存在多个安全策略模块,则它们的决策结果将以某种确定的方式组合。 为了给简化新安全策略的开发,MAC 向上提供了大量用于访问控制的基础设施,特别是,对临时的或者持久的、策略无关的对象安全标记的支持。 该支持目前仍是试验性质的。
本章所提供的信息不仅将使在 MAC 使能环境下工作的潜在用户受益, 也可以为需要了解 MAC 框架是如何支持对内核访问控制进行扩展的策略模块开发人员所用。
强制访问控制(简称 MAC),是指由操作系统强制实施的一组针对用户的访问控制策略。 在某些情况下,强制访问控制的策略可能会与自主访问控制(简称 DAC)所提供的保护措施发生冲突, 后者是用来向非管理员用户对数据采取保护措施提供支持的。在传统的 UNIX 系统中, DAC 保护措施包括文件访问模式和访问控制列表;而 MAC 则提供进程控制和防火墙等。 操作系统设计者和安全机制研究人员对许多经典的 MAC 安全策略作了形式化的表述,比如, 多级安全(MLS)机密性策略,Biba 完整性策略,基于角色的访问控制策略(RBAC),域和型裁决策略(DTE),以及型裁决策略(TE)。 安全策略的形式化表述被称为安全模型。每个模型根据一系列条件做出安全相关的决策,这些条件包括, 用户的身份、角色和安全信任状,以及对象的安全标记(用来代表该对象数据的机密性/完整性级别)。
TrustedBSD MAC 框架所提供的对策略模块的支持,不仅可以用来实现上述所有策略, 还能用于实现其他利用已有安全属性(如,用户和组ID、文件扩展属性等)决策的系统安全强化策略。 此外,因为具体策略模块在访问授权方面所拥有的高度灵活性和自主性,所以MAC 框架同样可以用来实现完全自主式的安全策略.
TrustedBSD MAC 框架为大多数的访问控制模块提供基本设施,允许它们以内核模块的形式灵活地扩展系统中实施的安全策略。 如果系统中同时加载了多个策略,MAC 框架将负责将各个策略的授权结果以一种(某种程度上)有意义的方式组合,形成最后的决策。
MAC 框架由下列内核元素组成:
框架管理接口
并发与同步原语
策略注册
内核对象的扩展性安全标记
策略入口函数的组合操作
标记管理原语
由内核服务调用的入口函数 API
策略模块的入口函数 API
入口函数的实现(包括策略生命周期管理、标记管理和访问控制检查三部分)
管理策略无关标记的系统调用
复用的mac_syscall() 系统调用
以 MAC 的策略加载模块形式实现的各种安全策略
对 TrustedBSD MAC 框架进行直接管理的方式有三种:通过 sysctl 子系统、通过 loader 配置, 或者使用系统调用。
多数情况下,与同一个内核内部变量相关联的 sysctl 变量和 loader 参数的名字是相同的, 通过设置它们,可以控制保护措施的实施细节,比如,某个策略在各个内核子系统中的实施与否等等。 另外,如果在内核编译时选择支持 MAC 调试选项,内核将维护若干计数器以跟踪标记的分配使用情况。 通常不建议在实用环境下通过在不同子系统上设置不同的变量或参数来实施控制,因为这种方法将会作用于系统中所有的活跃策略。 如果希望对具体策略实施管理而不相影响其他活跃策略,则应当使用策略级别的控制,因为这种方法的控制粒度更细, 并能更好地保证策略模块的功能一致性。
与其他内核模块一样,系统管理员可以通过系统的模块管理系统调用和其他系统接口,包括 boot loader 变量,对策略模块执行加载与卸载操作; 策略模块可以在加载时,设置加载标志,来指示系统对其加载、卸载操作进行相应控制,比如阻止非期望的卸载操作。
在运行时,系统中活跃的策略集合可能发生变化,然而对策略入口函数的使用操作并不是原子性的,因此,当某一个入口函数正被使用时, 系统需要提供额外的同步机制来阻止对该策略模块的加载与卸载,以确保当前活跃的策略集合不会在此过程中发生改变。 通过使用"框架忙”计数器,就可以做到这一点:一旦某个入口函数被调用,计数器的值被增加1;而每当一个入口函数调用结束时,计数器的值被减少1。 检查计数器的值,如果其值为正,框架将阻止对策略链表的修改操作,请求操作的线程将被迫进入睡眠,直到计数器的值重新减少到0为止。 计数器本身由一个互斥锁保护,同时结合一个条件变量(用于唤醒等待对策略链表进行修改操作的睡眠线程)。 采用这种同步模型的一个副作用是,在同一个策略模块内部,允许嵌套地调用框架,不过这种情况其实很少出现。
为了减少由于采用计数器引入的额外开销,设计者采用了各种优化措施。其中包括,当策略链表为空或者其中仅含有静态表项 (那些只能在系统运行之前加载而且不能动态卸载的策略)时,框架不对计数器进行操作,其值总是为0,从而将此时的同步开销减到0。 另一个极端的办法是,使用一个编译选项来禁止在运行时对加载的策略链表进行修改,此时不再需要对策略链表的使用进行同步保护。
因为 MAC 框架不允许在某些入口函数之内阻塞,所以不能使用普通的睡眠锁。 故而,加载或卸载操作可能会为等待框架空闲而被阻塞相当长的一段时间。
MAC 框架必须对其负责维护的安全属性标记的存储访问提供同步保护。下列两种情形,可能导致对安全属性标记的不一致访问: 第一,作为安全属性标记的持有者,内核对象本身可能同时被多个线程访问;第二,MAC 框架代码是可重入的, 即允许多个线程同时在框架内执行。通常,MAC 框架使用内核对象数据上已有的内核同步机制来保护该其上附加的 MAC 安全标记。 例如,套接字上的 MAC 标记由已有的套接字互斥锁保护。类似的,对于安全标记的并发访问的过程与对其所在对象进行的并发访问在语义上是一样的, 例如,信任状安全标记,将保持与该数据结构中其他内容一致的"写时复制"的更新过程。 MAC 框架在引用一个内核对象时,将首先对访问该对象上的标记需要用到的锁进行断言。 策略模块的编写者必须了解这些同步语义, 因为它们可能会限制对安全标记所能进行的访问类型。 举个例子,如果通过入口函数传给策略模块的是对某个信任状的只读引用,那么在策略内部,只能读该结构对应的标记状态。
FreeBSD 内核是一个可抢占式的内核,因此,作为内核一部分的策略模块也必须是可重入的,也就是说, 在开发策略模块时必须假设多个内核线程可以同时通过不同的入口函数进入该模块。 如果策略模块使用可被修改的内核状态,那么还需要在策略内部使用恰当的同步原语,确保在策略内部的多个线程不会因此观察到不一致的内核状态, 从而避免由此产生的策略误操作。为此,策略可以使用 FreeBSD 现有的同步原语,包括互斥锁、睡眠锁、条件变量和计数信号量。 对这些同步原语的使用必须慎重,需要特别注意两点:第一,保持现有的内核上锁次序; 第二,在非睡眠的入口函数之内不要使用互斥锁和唤醒操作。
为避免违反内核上锁次序或造成递归上锁,策略模块在调用其他内核子系统之前,通常要释放所有在策略内部申请的锁。 这样做的结果是,在全局上锁次序形成的拓朴结构中,策略内部的锁总是作为叶子节点, 从而保证了这些锁的使用不会导致由于上锁次序混乱造成的死锁。
为了记录当前使用的策略模块集合,MAC 框架维护两个链表:一个静态链表和一个动态链表。 两个链表的数据结构和操作基本相同,只是动态链表还额外使用了一个"引用计数"以同步对其的访问操作。 当包含 MAC 框架策略的内核模块被加载时,该策略模块会通过 SYSINIT 调用一个注册函数; 相对应的,每当一个策略模块被卸载,SYSINIT 也会调用一个注销函数。 只有当遇到下列情况之一时,注册过程才会失败: 一个策略模块被加载多次,或者系统资源不足不能满足注册过程的需要( 例如,策略模块需要对内核对象添加标记而可用资源不足),或者其他的策略加载前提条件不满足(有些策略要求只能在系统引导之前加载)。 类似的,如果一个策略被标记为不可卸载的,对其调用注销过程将会失败。
内核服务与 MAC 框架之间进行交互有两种途径: 一是,内核服务调用一系列 API 通知 MAC 框架安全事件的发生; 二是,内核服务向 MAC 框架提供一个指向安全对象的策略无关安全标记数据结构的指针。 标记指针由 MAC 框架经由标记管理入口函数进行维护, 并且,只要对管理相关对象的内核子系统稍作修改,就可以允许 MAC 框架向策略模块提供标记服务。 例如,在进程、进程信任状、套接字、管道、Mbuf、网络接口、IP 重组队列和其他各种安全相关的数据结构中均增加了指向安全标记的指针。 另外,当需要做出重要的安全决策时,内核服务也会调用 MAC 框架,以便各个策略模块根据其自己的标准(可以使用存储在安全标记中的数据)完善这些决策。 绝大多数安全相关的关键决策是显式的访问控制检查; 也有少数涉及更加一般的决策函数,比如,套接字的数据包匹配和程序执行时刻的标记转换。
如果内核中同时加载了多个策略模块,这些策略的决策结果将由框架使用一个合成运算子来进行组合汇总,得出最终的结果。 目前,该算子是硬编码的,并且只有当所有的活跃策略均对请求表示同意时才会返回成功。 由于各个策略返回的出错条件可能并不相同(成功、访问被拒绝、请求对象不存在等等), 需要使用一个选择子先从各个策略返回的错误条件集合中选择出一个作为最终返回结果。 一般情况下,与“访问被拒绝”相比,将更倾向于选择“请求对象不存在”。 尽管不能从理论上保证合成结果的有效性与安全性,但试验结果表明,对于许多实用的策略集合来说,事实的确如此。 例如,传统的可信系统常常采用类似的方法对多个安全策略进行组合。
与许多需要给对象添加安全标记的访问控制扩展一样,MAC 框架为各种用户可见的对象提供了一组用于管理策略无关标记的系统调用。 常用的标记类型有,partition标识符、机密性标记、完整性标记、区间(非等级类别)、域、角色和型。 “策略无关”的意思是指,标记的语法与使用它的具体策略模块无关,而同时策略模块能够完全独立地定义和使用与对象相关联的元数据的语义。 用户应用程序提供统一格式的基于字符串的标记,由使用它的策略模块负责解析其内在含义并决定其外在表示。 如果需要,应用程序可以使用多重标记元素。
内存中的标记实例被存放在由 slab 分配的struct
label数据结构中。 该结构是一个固定长度的数组,每个元素是由一个 void * 指针和一个 long组成的联合结构。
申请标记存储的策略模块在向 MAC
注册时,将被分配一个“slot”值,作为框架分配给其使用的策略标记元素在整个标记存储结构中的位置索引。
而所分配的存储空间的语义则完全由该策略模块来决定:MAC
框架向策略模块提供了一系列入口函数用于对内核对象生命周期的各种事件进行控制,包括,
对象的初始化、标记的关联/创建和对象的注销。使用这些接口,可以实现诸如访问计数等存储模型。
MAC
框架总是给入口函数传入一个指向相关对象的指针和一个指向该对象标记的指针,因此,策略模块能够直接访问标记而无需知悉该对象的内部结构。
唯一的例外是进程信任状结构,指向其标记的指针必须由策略模块手动解析计算。今后的 MAC
框架实现可能会对此进行改进。
初始化入口函数通常有一个睡眠标志位,用来表明一个初始化操作是否允许中途睡眠等待; 如果不允许,则可能会失败返回,并要求撤销此次标记分配操作(乃至对象分配操作)。 例如,如果在网络栈上处理中断时因为不允许睡眠或者调用者持有一个互斥锁,就可能出现这种情况。 考虑到在处理中的网络数据包(Mbufs)上维护标记的性能损失太大,策略必须就自己对 Mbuf 进行标记的要求向 MAC 框架做出特别声明。 动态加载到系统中而又使用标记的策略必须为处理未被其初始化函数处理过的对象作好准备, 这些对象在策略加载之前就已经存在,故而无法在初始化时调用策略的相关函数进行处理。 MAC 框架向策略保证,没有被初始化的标记 slot 的值必为0或者 NULL,策略可以借此检测到未初始化的标记。 需要注意的是,因为对 Mbuf 标记的存储分配是有条件的,因此需要使用其标记的动态加载策略还可能需要处理 Mbuf 中值为 NULL 的标记指针。
对于文件系统对象的标记,MAC 框架在文件的扩展属性中为其分配永久存储。 只要可能,扩展属性的原子化的事务操作就被用于保证对 vnode 上安全标记的复合更新操作的一致性--目前,该特性只被 UFS2 文件系统支持。 为了实现细粒度的文件系统对象标记(即每个文件系统对象一个标记),策略编写者可能选择使用一个(或者若干)扩展属性块。 为了提高性能, vnode 数据结构中有一个标记 (v_label)字段,用作磁盘标记的缓冲; vnode 结构实例化时,策略可以将标记值装入该缓冲,并在需要时对其进行更新。 如此,不必在每次进行访问控制检查时,均无条件地访问磁盘上的扩展属性。
注意: 目前,如果一个使用标记的策略允许被动态卸载,则卸载该模块之后,其状态 slot 尚无法被系统回收重用, 由此导致了 MAC 框架对标记策略卸载-重载操作数目上的严格限制。
MAC 框架向应用程序提供了一组系统调用:其中大多数用于向进行查询和修改策略无关标记操作的应用 API提供支持。
这些标记管理系统调用,接受一个标记描述结构, struct
mac,作为输入参数。 这个结构的主体是一个数组,其中每个元素包含了一个应用级的 MAC
标记形式。每个元素又由两部分组成:一个字符串名字,和其对应的值。
每个策略可以向系统声明一个特定的元素名字,这样一来,如果需要,就可以将若干个相互独立的元素作为一个整体进行处理。
策略模块经由入口函数,在内核标记和用户提供的标记之间作翻译转换的工作,这种实现提供了标记元素语义上的高度灵活性。
标记管理系统调用通常有对应的库函数包装,这些包装函数可以提供内存分配和错误处理功能,从而简化了用户应用程序的标记管理工作。
目前的FreeBSD 内核提供了下列 MAC 相关的系统调用:
mac_get_proc() 用于查询当前进程的安全标记。
mac_set_proc() 用于请求改变当前进程的安全标记。
mac_get_fd() 用于查询由文件描述符所引用的对象( 文件、
套接字、 管道文件等等) 的安全标记。
mac_get_file()
用于查询由文件系统路径所描述的对象的安全标记。
mac_set_fd() 用于请求改变由文件描述符所引用的对象(
文件、套接字、 管道文件等等) 的安全标记。
mac_set_file()
用于请求改变由文件系统路径所描述的对象的安全标记。
mac_syscall()
通过复用该系统调用,策略模块能够在不修改系统调用表的前提下创建新的系统调用;
其调用参数包括:目标策略名字、 操作编号和将被该策略内部使用的参数。
mac_get_pid()
用于查询由进程号指定的另一个进程的安全标记。
mac_get_link() 与 mac_get_file() 功能相同, 只是当路径参数的最后一项为符号链接时,
前者将返回该符号链接的安全标记, 而后者将返回其所指文件的安全标记。
mac_set_link() 与 mac_set_file() 功能相同, 只是当路径参数的最后一项为符号链接时,
前者将设置该符号链接的安全标记, 而后者将设置其所指文件的安全标记。
mac_execve() 与 execve()
功能类似,
只是前者还可以在开始执行一个新程序时,根据传入的请求参数,设置执行进程的安全标记。
由于执行一个新程序而导致的进程安全标记的改变,被称为“转换”。
mac_get_peer(), 通过一个套接字选项自动实现,
用于查询一个远程套接字对等实体的安全标记。
除了上述系统调用之外, 也可以通过 SIOCSIGMAC 和 SIOCSIFMAC 网络接口的 ioctl 类系统调用来查询和设置网络接口的安全标记。
安全策略可以直接编入内核,也可以编译成独立的内核模块,在系统引导时或者运行时使用模块加载命令加载。 策略模块通过一组预先定义好的入口函数与系统交互。通过它们,策略模块能够掌握某些系统事件的发生,并且在必要的时候影响系统的访问控制决策。 每个策略模块包含下列组成部分:
可选:策略配置参数
策略逻辑和参数的集中实现
可选:策略生命周期事件的实现,比如,策略的初始化和销毁
可选:对所选内核对象的安全标记进行初始化、维护和销毁的支持
可选:对所选对象的使用进程进行监控以及修改对象安全标记的支持
策略相关的访问控制入口函数的实现
对策略标志、模块入口函数和策略特性的声明
策略模块可以使用 MAC_POLICY_SET() 宏来声明。
该宏完成以下工作:为该策略命名(向系统声明该策略提供的名字);提交策略定义的 MAC
入口函数向量的地址; 按照策略的要求设置该策略的加载标志位,保证 MAC
框架将以策略所期望的方式对其进行操作; 另外,还可能请求框架为策略分配标记状态 slot
值。
static struct mac_policy_ops mac_policy_ops =
{
.mpo_destroy = mac_policy_destroy,
.mpo_init = mac_policy_init,
.mpo_init_bpfdesc_label = mac_policy_init_bpfdesc_label,
.mpo_init_cred_label = mac_policy_init_label,
/* ... */
.mpo_check_vnode_setutimes = mac_policy_check_vnode_setutimes,
.mpo_check_vnode_stat = mac_policy_check_vnode_stat,
.mpo_check_vnode_write = mac_policy_check_vnode_write,
};
如上所示,MAC 策略入口函数向量,mac_policy_ops,
将策略模块中定义的功能函数挂接到特定的入口函数地址上。
在稍后的“入口函数参考”小节中,将提供可用入口函数功能描述和原型的完整列表。
与模块注册相关的入口函数有两个:.mpo_destroy和.mpo_init。 当某个策略向模块框架注册操作成功时,.mpo_init将被调用,此后其他的入口函数才能被使用。
这种特殊的设计使得策略有机会根据自己的需要,进行特定的分配和初始化操作,比如对特殊数据或锁的初始化。
卸载一个策略模块时,将调用 .mpo_destroy
用来释放策略分配的内存空间或注销其申请的锁。
目前,为了防止其他入口函数被同时调用,调用上述两个入口函数的进程必须持有 MAC
策略链表的互斥锁:这种限制将被放开,
但与此同时,将要求策略必须谨慎使用内核原语,以避免由于上锁次序或睡眠造成死锁。
之所以向策略声明提供模块名字域,是为了能够唯一标识该模块,以便解析模块依赖关系。选择使用恰当的字符串作为名字。 在策略加载和卸载时,策略的完整字符串名字将经由内核日志显示给用户。另外,当向用户进程报告状态信息时也会包含该字符串。
在声明时提供标志参数域的机制,允许策略模块在作为模块被加载时,就自身特性向 MAC 框架提供说明。 目前,已经定义的标志有三个:
表示该策略模块可以被卸载。 如果未提供该标志,则表示该策略模块拒绝被卸载。 那些使用安全标记的状态,而又不能在运行时释放该状态的模块可能会设置该标志。
表示该策略模块必须在系统引导过程时进行加载和初始化。 如果该标志被设置,那么在系统引导之后注册该模块的请求将被 MAC 框架所拒绝。 那些需要为大范围的系统对象进行安全标记初始化工作,而又不能处理含有未被正确初始化安全标记的对象的策略模块可能会设置该标志。
表示该策略模块要求为 Mbuf 指定安全标记,并且为存储其标记所需的内存空间总是提前分配好的。 缺省情况下,MAC 框架并不会为 Mbuf 分配标记存储,除非系统中注册的策略模块中至少有一个设置了该标志。 这种做法在没有策略需要对 Mbuf 做标记时,显著地提升了系统网络性能。另外,在某些特殊环境下,可以通过设置内核选项, MAC_ALWAYS_LABEL_MBUF,强制 MAC 框架为 Mbuf 的安全标记分配存储,而不论上述标志如何设置。
注意: 那些使用了 MPC_LOADTIME_FLAG_LABELMBUFS 标志但没有设置 MPC_LOADTIME_FLAG_NOTLATE 标志的 策略模块必须能够正确地处理通过入口函数传入的值为 NULL 的 Mbuf 安全标记指针。 这是因为那些没有分配标记存储的处理中的 Mbuf 在一个需要 Mbuf 安全标记的策略模块加载之后, 其安全标记的指针将仍然为空。 如果策略在网络子系统活跃之前被加载(即,该策略不是被推迟加载的),那么所有的 Mbuf 的标记存储的分配就可以得到保证。
MAC 框架为注册的策略提供四种类型的入口函数: 策略注册和管理入口函数;
用于处理内核对象声明周期事件,如初始化、 创建和销毁,的入口函数;
处理该策略模块感兴趣的访问控制决策事件的入口函数;
以及用于管理对象安全标记的调用入口函数。 此外, 还有一个 mac_syscall() 入口函数,
被策略模块用于在不注册新的系统调用的前提下, 扩展内核接口。
策略模块的编写人员除了必须清楚在进入特定入口函数之后, 哪些对象锁是可用的之外, 还应该熟知内核所采用的加锁策略。 编程人员在入口函数之内应该避免使用非叶节点锁, 并且遵循访问和修改对象时的加锁规程, 以降低导致死锁的可能性。 特别地, 程序员应该清楚, 虽然在通常情况下, 进入入口函数之后, 已经上了一些锁, 可以安全地访问对象及其安全标记, 但是这并不能保证对它们进行修改( 包括对象本身和其安全标记) 也是安全的。 相关的上锁信息,可以参考 MAC 框架入口函数的相关文档。
策略入口函数把两个分别指向对象本身和其安全标记的指针传递给策略模块。 这样一来,即使策略并不熟悉对象内部结构,也能基于标记作出正确决策。 只有进程信任状这个对象例外:MAC 框架总是假设所有的策略模块是理解其内部结构的。