等级森严的制度
我们在dummy程序中碰到了一条看似奇怪的int
指令.
为了解释它, 我们还需要了解它背后折射出来的计算机和谐社会的故事.
为了构建计算机和谐社会, i386强化了保护模式(protected mode)和特权级(privilege level)的概念: 简单地说, 只有高特权级的进程才能去执行一些系统级别的操作, 如果一个特权级低的进程尝试执行它没有权限执行的操作, CPU将会抛出一个异常. 一般来说, 最适合担任系统管理员的角色就是操作系统了, 它拥有最高的特权级, 可以执行所有操作; 而除非经过允许, 运行在操作系统上的用户进程一般都处于最低的特权级, 如果它试图破坏社会的和谐, 它将会被判"死刑".
在i386中, 存在0, 1, 2, 3四个特权级, 0特权级最高, 3特权级最低. 特权级n所能访问的资源, 在特权级0~n也能访问. 不同特权级之间的关系就形成了一个环: 内环可以访问外环的资源, 但外环不能进入内环的区域, 因此也有"ring n"的说法来描述一个进程所在的特权级.
+---------------------------------------------------+
| +-----------------------------------------------+ |
| | APPLICATIONS | |
| | +-----------------------------------+ | |
| | | CUSTOM EXTENSIONS | | |
| | | +-----------------------+ | | |
| | | | SYSTEM SERVICES | | | |
| | | | +-----------+ | | | |
| | | | | OS | | | | |
|-|-----+-----+-----+-----+-----+-----+-----+-----|-|
| | | | | |LEVEL|LEVEL|LEVEL|LEVEL| |
| | | | | | 0 | 1 | 2 | 3 | |
| | | | +-----+-----+ | | | |
| | | | | | | | |
| | | +-----------+-----------+ | | |
| | | | | | |
| | +-----------------+-----------------+ | |
| | | | |
| +-----------------------+-----------------------+ |
+------------------------+ +------------------------+
虽然80386提供了4个特权级, 但大多数通用的操作系统只会使用0级和3级: 操作系统处在ring 0, 一般的程序处在ring 3, 这就已经起到保护的作用了. 那CPU是怎么判断一个进程是否执行了无权限操作呢? 在这之前, 我们还要简单地了解一下i386中引入的与特权级相关的概念:
- DPL(Descriptor Privilege Level)属性描述了一段数据所在的特权级
- RPL(Requestor's Privilege Level)属性描述了请求者所在的特权级
- CPL(Current Privilege Level)属性描述了当前进程的特权级,
一次数据的访问操作是合法的, 当且仅当
data.DPL >= requestor.RPL # <1>
data.DPL >= current_process.CPL # <2>
两式同时成立, 注意这里的>=
是数值上的(numerically greater). <1>式表示请求者有权限访问目标数据, <2>式表示当前进程也有权限访问目标数据.
如果违反了上述其中一式, 此次操作将会被判定为非法操作,
CPU将会抛出异常, 跳转到一个约定好的代码位置, 然后通知操作系统进行处理.2>1>
你可能会觉得RPL十分令人费解, 我们先举一个生活上的例子.
- 假设你到银行找工作人员办理取款业务, 这时你就相当于requestor,
你的账户相当于data, 工作人员相当于current_process.
业务办理成功是因为
- 你有权限访问自己的账户(
data.DPL >= requestor.RPL
) - 工作人员也有权限对你的账户进行操作(
data.DPL >= current_process.CPL
)
- 你有权限访问自己的账户(
- 如果你想从别人的账户中取钱, 虽然工作人员有权限访问别人的账户(
data.DPL >= current_process.CPL
), 但是你却没有权限访问(data.DPL < requestor.RPL
), 因此业务办理失败 - 如果你打算亲自操作银行系统来取款, 虽然账户是你的(
data.DPL >= requestor.RPL
), 但是你却没有权限直接对你的账户金额进行操作(data.DPL < current_process.CPL
), 因此你很有可能会被保安抓起来
在计算机中也存在类似的情况: 用户进程(requestor)想对它自己拥有的数据(data)进行一些它没有权限的操作, 它就要请求有权限的进程(current_process, 通常是操作系统)来帮它完成这个操作, 于是就会出现"操作系统代表用户进程进行操作"的场景. 但在真正进行操作之前, 也要检查这些数据是不是真的是用户进程有权使用的数据.
通常情况下, 操作系统运行在ring 0, CPL为0, 因此有权限访问所有的数据; 而用户进程运行在ring 3, CPL为3, 这就决定了它只能访问同样处在ring3的数据. 这样, 只要操作系统将其私有数据放在ring 0中, 恶意程序就永远没有办法访问到它们. 这些保护相关的概念和检查过程都是通过硬件实现的, 只要软件运行在硬件上面, 都无法逃出这一天网. 硬件保护机制使得恶意程序永远无法全身而退, 为构建计算机和谐社会作出了巨大的贡献.
这是多美妙的功能! 遗憾的是, 上面提到的很多概念其实只是一带而过, 真正的保护机制也还需要考虑更多的细节. i386手册中专门有一章来描述保护机制, 就已经看出来这并不是简单说说而已. 根据KISS法则, 我们并不打算在NEMU中加入保护机制. 我们让所有用户进程都运行在ring 0, 虽然所有用户进程都有权限执行所有指令, 不过由于PA中的用户程序都是我们自己编写的, 一切还是在我们的控制范围之内. 毕竟, 我们也已经从上面的故事中体会到保护机制的本质了: 在硬件中加入一些与特权级检查相关的门电路, 如果发现了非法操作, 就会抛出一个异常, 让CPU跳转到一个固定的地方, 并进行后续处理.
操作系统的义务
既然操作系统位于ring 0享受着至高无上的权利, 自然地它也需要履行相应的义务, 那就是: 管理系统中的所有资源, 为用户进程提供相应的服务. 举一个银行的例子, 如果银行连最基本的取款业务都不能办理, 是没有客户愿意光顾它的. 但同时银行也不能允许客户亲自到金库里取款, 而是需要客户按照规定的手续来办理取款业务. 同样地, 操作系统并不允许用户进程直接操作显示器硬件进行输出, 否则恶意程序就很容易往显示器中写入恶意数据, 让屏幕保持黑屏, 影响其它进程的使用. 因此, 用户进程想输出一句话, 也要经过一定的合法手续向操作系统进行申请, 这一合法手续就是系统调用.
我们到银行办理业务的时候, 需要告诉工作人员要办理什么业务, 账号是什么, 交易金额是多少,
这无非是希望工作人员知道我们具体想做什么.
用户进程执行系统调用的时候也是类似的情况,
要通过一种方法描述自己的需求, 然后告诉操作系统.
用来描述需求最方便的手段就是使用通用寄存器了,
用户进程将系统调用的参数依次放入各个寄存器中(第一个参数放在%eax
中, 第二个参数放在%ebx
中...).
为了让操作系统注意到用户进程提交的申请, 系统调用通常都会触发一个异常, 然后陷入操作系统.
在GNU/Linux中, 系统调用产生的异常通过int $0x80
指令触发.
这个异常和上文提到的非法操作产生的异常不同, 操作系统能够识别它是由系统调用产生的.
Navy-apps已经为用户程序准备好了系统调用的接口了.
navy-apps/libs/libos/src/nanos.c
中定义的_syscall_()
函数已经蕴含着上述过程:
int _syscall_(int type, uintptr_t a0, uintptr_t a1, uintptr_t a2) {
int ret;
asm volatile("int $0x80": "=a"(ret): "a"(type), "b"(a0), "c"(a1), "d"(a2));
return ret;
}
上述内联汇编会先把系统调用的参数依次放入%eax
, %ebx
, %ecx
, %edx
四个寄存器中,
然后执行int $0x80
手动触发一个特殊的异常.
操作系统捕获这个异常之后, 发现是一个系统调用, 就会调出相应的处理函数进行处理,
处理结束后设置好返回值, 然后返回到上述的内敛汇编中.
内联汇编最后从%eax
寄存器中取出系统调用的返回值,
并返回给调用该接口的函数, 告知其系统调用执行的情况(如是否成功等).
我们可以在GNU/Linux下编写一个程序, 来手工触发一次write
系统调用:
const char str[] = "Hello world!\n";
int main() {
asm volatile ("movl $4, %eax;" // system call ID, 4 = SYS_write
"movl $1, %ebx;" // file descriptor, 1 = stdout
"movl $str, %ecx;" // buffer address
"movl $13, %edx;" // length
"int $0x80");
return 0;
}
如果你在64位操作系统上运行它, 你需要在编译的时候加入-m32
参数来生成32位的代码.
用户进程执行上述代码, 就相当于告诉操作系统: 帮我把从str
开始的13字节写到1号文件中去.
其中"写到1号文件中去"的功能相当于输出到屏幕上.
虽然操作系统需要为用户进程服务, 但这并不意味着操作系统需要把所有信息都暴露给用户程序. 有些信息是用户进程没有必要知道的, 也永远不应该知道, 例如一些与内存管理相关的数据结构. 如果一个恶意程序获得了这些信息, 可能会为恶意攻击提供了信息基础. 因此, 通常不存在一个系统调用来获取这些操作系统的私有数据.