x86-64:特权级保护及程序控制转移

769 阅读26分钟

本文采用Linux 内核 v3.10 版本 x86_64架构

一、特权级概述

Intel 处理器提供了0 ~ 3 一共 4 种特权级别,数值越小级别越高。其中操作系统工作在特权级 0,普通应用程序工作在特权级 3。

PL-Ring4.png

处理器使用特权级来阻止低特权级的程序访问高特权级的段(可控情况除外)。当处理器检测到违反特权级规则的行为时,就会产生通用保护异常(General-Protection exception,#GP)。

1.1 段描述符与 DPL

特权级检查是用来保护段的,段机制是 Intel 处理器的基本机制。最基本的段有代码段、数据段、栈段等等。为了管理这些段,处理器提供了一种数据结构,叫段描述符;段描述符里标明了该段的类型,基地址(64位模式下已废弃),段限制(64位模式下已废弃)等等。在 32 位保护模式下,段描述符格式如下:

Segment-Descriptor-32bit.png

各字段说明:

G 位是粒度(Granularity)位,用于解释段界限的含义。当 G 位是 0 时,段界限以字节为单位;当 G 位是 1 时,段界限以 4KB 为单位。

S 位用于表示描述符类型(Descriptor Type)。当该位是 0 时,表示是一个系统段;该位是 1 时,表示是一个代码段或数据段(栈段也是一种特殊的数据段)。

DPL(Descriptor Privilege Level) 用于表示描述符的特权级。DPL 占用 2 位,可用来表示 0 ~ 3 共 4 种特权级。

P 位是段存在位(Segment-Present)。用来指示描述符所对应的段否存在。

L 位是 64 位代码段标志(64-bit code segment )。在 IA-32 模式(保护模式)下,此位被保留并置 0;在 IA-32e 模式(长模式)下,如果处理器工作在 64 位模式,此位为 1 ;如果处理器工作在 32 位兼容模式,此位为 0。

AVL 是软件可用位(Available)。处理器不使用这些位,供软件使用。

D/B 位是”默认操作数大小“(Default Operation Size),或者”默认栈指针大小“(Default Stack Pointer Size ),又或者”上部边界“(Upper Bound)标志。该标志用来指示默认操作数的大小,对于 32 位的段来说,该位总是为 1;对于 16 位的段来说,该位为 0。设立该标志,主要是为了能够在 32 位处理器上兼容运行 16 位的程序。

该标志对不同的段有不同的效果。对于代码段,此位叫做 D 位,用于指示指令中凡的有效地址和操作大小。D = 0 指示地址或者操作数是 16 位的;D = 1 指示 32 位的地址或操作数。

对于栈段来说,该位被叫做 B 位,用于在进行隐式的栈操作(例如 pop、push 和 call)时,指示栈指针的大小。如果该位是 0,则使用 16 位的栈指针( SP )来访问栈;否则,使用 32 位的栈指针( ESP) 来访问栈。同时,B 位也决定了栈的上边界。如果 B=0,那么栈的上边界为 0xFFFF(64KB);如果 B=1,栈的上边界为 0xFFFFFFFF(4GB)。

Type 字段共 4 位,指示了描述符的子类型。对于数据段来说,这 4 位分别为 X、E、W 和 A;对于代码段来说,这 4 位分别为 X、C、R、A。Type 字段说明如下:

Type-Code-Data-Segment.png

X 位表示是否可以执行(eXecutable)。数据段总是不可执行的,所以为 0;代码段总是可执行的,所以为 1。

E 位,指示数据段的扩展方向( Expansion-Direction)。当 E = 0 时,段是向上扩展的,用于普通数据段;当 E = 1 时,段是向下扩展的,用于栈段。

W 位,指示段是否可写(Writable)。当 E = 0 时,段是不可写的;当 E = 1 时,段是可写的。

R 位,指示段是否可读(Readable)。当 R = 0 时,段是不可读的;当 R = 1 时,段是可读的。R 位用来控制指令和程序的行为。

C 位,指示代码段是否是特权级依从的(Conforming)。C = 0 表示非依从的代码段,这样的代码段可以从与它特权级相同的代码段调用,或者通过门调用;C = 1 表示依从的代码段,允许从低特权级的程序转移到该段执行。

32 位保护模式下,代码段描述符格式如下:

Code-Segment-Descriptor-32bit.png

在长模式(IA-32e)下,基地址和段界限已经废弃,代码段描述符格式如下所示:

Code-Segment-Descriptor-IA32e.png

根据段描述符,我们就能知道段的位置、界限和特权级别、访问权限等。段描述符里的 DPL 字段以及 Type.C 字段是需要我们重点关注的字段,涉及到特权级的检查。

1.2 段选择子与 RPL

为了存储这些段描述符,就需要在内存中开辟空间。在这个空间里,段描述符是紧挨着,集中存放的,这就形成了描述符表。描述符表有 2 种:全局描述符表(GDT)和 局部描述符表(LDT)。全局描述符表(GDT)是全局性的,是为所有任务服务的,所以有一个就够了。局部描述符表(LDT)的数量可以不止一个,具体有多少,视任务数量而定。为了追踪这 2 种描述符表,处理器内部提供了全局描述符表寄存器(GDTR)和局部描述符表寄存器(LDTR) 。

GDTR 中存储了全局描述符表(GDT)的基地址(保护模式下为 32位,IA-32e 模式下为 64位)和16 位的表限制 。

GDTR.png

局部描述符表(LDT)是作为全局描述符表(GDT)的一个段而存在的。LDTR 中保存了该段的段选择子(16 位),基地址(保护模式下为 32位,IA-32e 模式下为 64位),段限制(16 位)和段属性 。LDTR 本质上,是一个段寄存器,其格式如下:

LDTR.png

由于 GDT 和 LDT 中保存的都是段描述符,每个描述符 8 个字节; 而 GDT 和 LDT 的的基地址分别保存在 GDTR 和 LDTR 中;我们只要知道段在哪个表中以及在对应表的索引,就可以定位到指定的段。这就引出了段选择子的概念,段选择子的格式如下:

Segment_Selector.png

可以看到,段选择子里除了索引、TI 位之外,还有 RPL 位。 RPL 是 Requested Privilege Level 的缩写,用来指示请求者的特权级。

段选择子、GDT、GDTR、LDT 及 LDTR 之间的关系如下图:

SS_GDT_LDT_Relation.png

1.3 代码段寄存器与 CPL

Intel 处理器中内置了 6 个 16 位段寄存器(CS、DS、SS、ES、FS、GS),用于加载段选择子。其中代码段寄存器器(CS)用来加载代码段;栈段寄存器(SS)用来加载栈段;其它的 4 个寄存器用来加载数据段。只有把代码段选择子加载到代码段寄存器后,代码才能执行;同样的,数据段选择子、栈段选择子分别加载到对应的段寄存器后,数据段和栈段才能够被访问。

段寄存器格式:

Segment_Register.png

这几个段寄存器中,代码段寄存器(CS)比较特殊,因为只有它是跟代码打交道,其它几个段都是跟数据打交道。当代码段选择子被加载到代码段寄存器(CS)后,其最低的 2 位(位 0 和 位 1),被称为当前特权级(Current Privilege Level ,CPL)。即,当前正在执行的程序的特权级。

CPL.png

二、特权级保护

上面说了这么多,主要是为了引出与特权级检查相关的 3 个概念:

  • 当前特权级(Current Privilege Level ,CPL)— CPL 是指当前正在执行的程序的特权级。它位于代码段寄存器(CS)或栈段寄存器(SS)的位 0 和 位 1。一般来说,当程序控制转移到不同特权级的代码段时,CPL 的值会改变。但是,当访问依从的代码段,情况稍有不同。任何特权级比依从的代码段的 DPL 低(数值上比 DPL 大)的程序,都可以访问该依从的代码段。而且,当程序访问依从的代码段是,即使依从的代码段的特权级与当前特权级(CPL)不一致CPL 也不会改变
  • 描述符特权级(Descriptor Privilege Level ,DPL)— DPL 是指段或门描述符的特权级,也就是段描述符或门描述符里的 DPL 字段的内容。
  • 请求特权级(Requested Privilege Level ,RPL)— 指段选择子里的 RPL 字段(位 0 和位 1)

当段选择子被加载到段寄存器时,就会进行特权级检查。访问数据时进行的特权级检查与在代码段之间进行控制转移时的检查是不同的,需要分别进行说明。

2.1 访问数据段时的特权级检查

当需要访问数据段内的数据时,数据段的段选择子必须加载到数据段寄存器(DS, ES, FS, GS)或者栈段寄存器(SS)。在将段选择子加载到段寄存器之前,就会执行特权级检查。特权级检查会比较当前正在执行的程序的特权级(CPL)、段选择子的 RPL 以及 段描述符中的 DPL。如果 DPL ≥ CPL 且 DPL ≥ RPL,则检查通过,段选择子被加载到寄存器;否则,产生通用保护异常,段寄存器不会加载。也就是说,特权级高的程序,可以访问特权级低的数据段;反之,则不行。

2.2 访问代码段中的数据

在某些情况下,需要访问代码段中的数据。可以通过以下几种方式来访问代码段中的数据:

  • 把非依从的(nonconforming)、可读的(readable)代码段选择子加载到数据段寄存器;

  • 把依从的(conforming)、可读的(readable)代码段选择子加载到数据段寄存器;

  • 使用代码段超越前缀来读取可读的(readable)代码段中的数据(比如 movq %cs:0x10000, %rax),前提是该代码段的选择子已经加载到 CS 寄存器了。

    当使用方法 1 时,检查规则与访问数据段一致。方法 2 总是有效的,因为不管依从的代码段的 DPL 是多少,其特权级总会依从于当前程序的特权级。方法 3 也总是有效的,因为现在 CS 中既是代码段又是数据段,其 CPL 与 DPL 总是相同的。

2.3 加载段寄存器(SS)时的特权级检查

当把栈段选择子加载到栈段寄存器时,也会进行特权级检查。栈段相关的所有特权级要求必须是一致的,即 CPL = DPL = RPL。

2.4 在代码段间进行程序控制转移时的特权级检查

当进行程序控制转移时,目标代码段的段选择子需要加载到代码段寄存器(CS)中。在加载的过程中,就可以进行包括权限检查在内的一系列检查。程序控制转移可以通过 JMP、CALL / RET、SYSENTER / SYSEXIT、 SYSCALL / SYSRET、 INT n 、IRET 指令以及中断和异常处理机制来完成。

JMP 或者 CALL 指令可以使用以下 4 种方式中的任何一种来引用其它代码段:

  • 目标操作数包含目标代码段的段选择子
  • 目标操作数指向调用门描述符,该描述符包含目标代码段的段选择子
  • 目标操作数指向任务状态段 TSS,TSS 中包含目标代码段的段选择子
  • 目标操作数指向任务门,任务门有指向 TSS,TSS 中包含目标代码段的段选择子

2.4.1 直接调用(call)或跳转(jmp)到代码段

近跳转(Near Jmp)或近调用(Near Call)指令,会把程序控制转移到同一代码段,所以不会进行特权级检查。远跳转(Far Jmp)或远调用(Far Call)指令,会把控制转移到其它代码段,所以需要进行特权级检查。

当不通过调用门进行控制转移时,处理器会检查以下特权级及代码段类型信息:

  • 当前特权级 CPL。调用程序的特权级。
  • 目标代码段段描述符中的 DPL。
  • 目标代码段段选择子中的 RPL。
  • 目标代码段段描述符中的依从位(Type.C),以此来判断目标代码段是依从(C = 1)的还是非依从的(C = 0)。

依从位不同,处理器利用 CPL、DPL、RPL 进行特权级检查的规则也不同。

2.4.1.1 访问非依从的代码段

访问非依从的代码段时,调用程序的 CPL 必须等于目标代码段的 DPL。也就是说,两段代码的特权级必须是相同的。同时,要求 RPL ≤ CPL。

即:

CPL = 目标代码段描述符的 DPL
RPL ≤ CPL

当检查通过后,非依从代码段的选择子加载到 CS 寄存器时,不改变 CPL 的值,即使 RPL 的值与 CPL 不相同。

2.4.1.2 访问依从的代码段

此时,要求调用程序的 CPL 在数值上大于等于目标代码段的 DPL。

CPL ≥ 目标代码段的 DPL

也就是说,要求当前运行程序的优先级小于等于目标代码段的优先级。

举例来说,如果依从的代码段的 DPL = 1,那么只有特权级为 1、2、3的程序可以调用,特权级为 0 的程序不能调用。

另外,当控制向依从的代码段转移时,不检查 RPL。

注意,当程序控制转移成功后,CPL 的值不改变 , 即使目标代码段的 DPL 的值小于 CPL。也就是说,控制转移后,程序并不是在依从的代码段的 DPL 特权级上运行,而是在调用程序的特权级上运行。所谓依从的代码段,就是指该代码段的特权级依从于调用程序的特权级

2.4.2 使用门进行控制转移

除了使用依从的代码段,另一种在不同特权级之间进行控制转移的方法是使用门。门(Gate)也是一种描述符,被称为门描述符,简称门。根据用途不同,门分为以下 4 种:

  • 调用门(call gate)
  • 中断门(interrupt gate)
  • 陷阱门(trap gate)
  • 任务门(task gate)

调用门用于不同特权级之间的程序控制转移;中断门和陷阱门用于中断处理;任务门用于执行任务切换。这里主要介绍调用门(Call Gate)。

门描述符由段选择子、段内偏移、DPL、Type 等组成。没错,门描述符里也是有 DPL 的。

在保护模式下,调用门描述符是 64 位的,其格式如下:

Call_Gate_Desc_32.png

在 IA-32e 模式下,调用门描述符是 128 位的,其格式如下:

Call_Gate_Desc_64.png

调用门描述符可以存在于全局描述符表(GDT)或局部描述符表(LDT)中,但不能存在于中断描述符表(IDT)中。

可以使用远跳转(Far Jmp)或远调用(Far Call)指令来访问调用门进行控制转移,操作数为调用门描述符的段选择子和段内偏移(没用到)。

当处理器访问调用门时,使用调用门中的段选择子来定位目标代码段的段描述符(段描述符位于 GDT 或者 LDT 中)。然后把代码段描述符中的基地址和门描述符中的偏移量组合起来形成线性地址,以此作为代码段程序的入口点。

Call_Gate_Mechanism.png

使用调用门进行特权级控制转移时,以下 4 个特权级都会用来做特权检查:

  • 当前特权级 CPL(current privilege level)
  • 调用门的选择子的 RPL(requestor's privilege level)
  • 调用门描述符的 DPL(descriptor privilege level)
  • 目标代码段描述符的 DPL

目标代码段段描述符的 Type.C 标志(依从位)也会被检查。

Priv_Check_Call_Gate.png

当使用 JMP 和 CALL 指令进行控制转移时,特权级检查的规则是不同的。

Priv_Check_Call_Gate_Rule.png

可以看到,当目标代码段是依从的代码段时,CALL 指令和 JMP 指令的检查规则是相同的。此时,调用门描述符的 DPL 指定了 CPL 和 RPL 的数值上限(最小权限级);目标代码段描述符的 DPL 指定了CPL 和 RPL 的数值下限(最大权限级)。只有 CPL 和 RPL 在两者之间时,特权级检查才能通过。

Call_Gate_Limit.png

当目标代码段是非依从代码段时,CALL 指令和 JMP 指令的检查规则是不相同的。只有 CALL 指令才能使用调用门将控制转移到特权级更高的非依从代码段,该代码段描述符的 DPL ≤ CPL;JMP 指令只能通过调用门将控制转移到其 DPL 字段与当前特权级 CPL 相同的非依从代码段。

使用远跳转(Far Jmp) 指令,可以将控制通过门转移到比当前特权级高的代码段,但不改变当前特权级别

使用远调用(Far Call)指令时,当前特权级会提升到目标代码段的特权级。也就是说,处理器是在目标代码段的特权级上执行的。但是,除了从高特权级别的程序返回外,不允许从高特权级的代码将控制转移到低特权级的代码

2.4.3 再谈 RPL

通常来说,段选择子的 RPL 承载的是请求者的特权级,应该和调用程序的特权级( CPL)是一致的。

一个用户程序工作在特权级 3,它要访问自己的数据段,其提供的数据段选择子的 RPL 也应该是 3。在访问数据段之前,必须先把该段的选择子加载到数据段寄存器中,此时就会进行特权级检查。比如通过 MOV 指令,将段选择子传送到段寄存器 %ds。

mov cx, %ds

如果 cx 寄存器中段选择子的 RPL 小于 3,就会触发通用保护异常。

但是由于用户程序的特权级较低,有些任务需要操作系统支持才能工作。假如操作系统提供了一个例程,可以帮助应用程序读取数据,其入参是对应数据段的选择子及其它参数。操作系统把它定义成了一个调用门,供用户程序调用。

这是有人想钻空子,他借助调用门进入了特权级 0,此时再提供一个内核数据段的段选择子( RPL 为 0 ),权限检查就会通过,他就能够访问到内核的数据。这种情况当然不是我们想要的,好像 RPL 并没有起作用。的确,如果只靠处理器自己,这事没法搞定。 RPL 需要处理器和操作系统共同协作,才能达到预期的效果。也就是说,RPL 相当于处理器和操作系统的接口,处理器提供接口,操作系统负责往接口写数据,然后处理器使用这些数据来做特权检查。因为低特权级的程序是如何转移到高特权级的,操作系统最是清楚;控制转移后,原始的代码段寄存器的值保存在什么位置,操作系统也是知道的,原始代码段寄存器中保存着调用程序(请求者)真正的特权级。比如远调用时,处理器会把代码段寄存器(CS) 和指令指针寄存器 RIP 的值压入栈中,此时通过栈指针寄存器 RSP 就可以查找到 CS 的值,从而获取请求者真正的 RPL。找到真正的 RPL 之后,就可以修改当前提供的 RPL 值,使其与真正的 RPL 一致。处理器提供了ARPL (Adjust RPL)指令,用来调整段选择子中 RPL 的值。ARPL 指令有 2 个操作数,均为段选择子。ARPL 指令会比较这两个选择子,如果目的操作数的 RPL 比源操作数的小(特权级别高),那么增加目的操作数的值与源操作数相等,并将状态寄存器的 ZF 标志置位;否则,仅清除 ZF 标志位,并不改变目的操作数的值。源操作数必须为段寄存器,目的操作数可以为段寄存器或者内存。

2.5 系统调用

处理器提供了 2 对快速系统调用的指令:SYSENTER / SYSEXIT 和 SYSCALL / SYSRET。

2.5.1 SYSENTER / SYSEXIT

SYSENTER / SYSEXIT 指令对是从奔腾 II 处理器开始引入到 x86 架构的,后来扩展到 x86-64 架构。

该指令对会使用到以下几种模型特定寄存器(MSR):

  • IA32_SYSENTER_CS
  • IA32_SYSENTER_EIP
  • IA32_SYSENTER_CS

2.5.2 SYSCALL / SYSRET

SYSCALL / SYSRET 是专门给 x86-64 架构使用的,不能用于 x86 架构。

SYSCALL 指令使运行在特权级 3 的用户代码能够访问特权级 0 的操作系统程序。SYSRET 指令使特权级 0 的操作系统程序快速返回到用户代码。SYSCALL/SYSRET 指令会保存、恢复 RFLAGS 寄存器。当执行 SYSCALL 指令时,处理器会把 RFLAGS 保存到 R11,并把 SYSCALL 的下一条指令保存到 RCX;然后通过如下的方式获取到特权级为 0 的目标代码段,指令指针,栈段以及状态标志:

  • 目标代码段 — 从 IA32_STAR[47:32] 获取;
  • 目标指令指针 — 从 IA32_LSTAR 读取 64 位的地址;
  • 栈段 — 通过 IA32_STAR[47:32] 加 8 计算得到;
  • 状态标志 — 把 IA32_FMASK MSR 中的值按位取反后,与当前 RFLAGS 进行逻辑与得到

当使用 SYSRET 指令,将控制转换到 64 位的用户代码时,通过如下的方式获取到特权级为 3 的目标代码段,指令指针,栈段以及状态标志:

  • 目标代码段 — 从 IA32_STAR[63:48] + 16 获取到段选择子;
  • 目标指令指针 — 把 RCX 的值拷贝到 RIP;
  • 栈段 — IA32_STAR[63:48] + 8
  • EFLAGS — 从 R11 加载

当使用 SYSRET 指令及32位操作数,将控制转换到 32 位的用户代码时,通过如下的方式获取到特权级为 3 的目标代码段,指令指针,栈段以及状态标志:

  • 目标代码段 — 从 IA32_STAR[63:48] 获取到段选择子;
  • 目标指令指针 — 把 ECX 的值拷贝到 EIP;
  • 栈段 — IA32_STAR[63:48] + 8
  • EFLAGS — 从 R11 加载

2.6 中断和异常处理

中断和异常处理程序的特权级保护类似于调用门。处理器不允许将控制转移到比当前特权级(CPL)低的异常或中断处理程序。当违反规则时会导致通用保护异常( General-Protection exception,#GP)。

中断和异常处理程序的特权级保护机制与其它类型相比,有几点不同:

  • 由于中断和异常向量没有 RPL,所以当异常和中断处理程序被隐式调用时不会检查 RPL。

  • 只有通过 INT n, INT3, or INTO 指令生成的中断或异常,处理器才会检查中断或陷阱门的 DPL。此时,CPL 必须小于等于门的 DPL(CPL ≤ DPL)。此限制用于阻止特权级为 3 的用户程序使用软件中断去访问处于高特权级的异常处理程序,比如页故障(Page-Fault, #PF)处理程序。 对于硬件产生的中断以及处理器自己探测到的异常,处理器会忽略中断或陷阱门的 DPL

三、内核示例

3.1 系统调用

按照上面的介绍,结合 Linux 内核来分析下系统调用时的控制转移特权级变化

3.1.1 相关的 MSRs(Model-Specific Registers )

在 64位 模式下,x86 处理器提供了以下几个寄存器来配合系统调用相关指令使用:

IA32_KERNEL_GS_BASE — Used by SWAPGS instruction.

IA32_LSTAR — Used by SYSCALL instruction.

IA32_FMASK — Used by SYSCALL instruction.

IA32_STAR — Used by SYSCALL and SYSRET instruction.

这四种 MSR 寄存器的说明,详见Intel SDM Volume 4 第2.1节

根据Intel SDM Volume 2D文档的描述,wrmsr指令会把%edx:%eax的值写入指定的 64 位 MSR 寄存器中,具体写入哪个寄存器,是通过 %ecx 指定的。%edx的值存入MSR中的高32位,%eax的值存入MSR的低32位。在64位系统中,这三个寄存器的高32位会被忽略。

wrmsrl宏是对wrmsr指令的封装,其参数msr指定了要保存的MSR寄存器,参数val是要保存的内容,其中val的高32位保存到%edx,低32位保存到%eax

3.1.2 段选择子

内核在 arch/x86/include/asm/segment.h 头文件中,定义了多个选择子。其中,以 GDT_* 开头的,是相关段在全局描述符表(GDT)中的索引,其余的是段选择子。

// file: arch/x86/include/asm/segment.h
#define GDT_ENTRY_KERNEL32_CS 1
#define GDT_ENTRY_KERNEL_CS 2
#define GDT_ENTRY_KERNEL_DS 3
#define GDT_ENTRY_DEFAULT_USER32_CS 4
#define GDT_ENTRY_DEFAULT_USER_DS 5
#define GDT_ENTRY_DEFAULT_USER_CS 6

#define __KERNEL_CS	(GDT_ENTRY_KERNEL_CS*8)
#define __KERNEL_DS	(GDT_ENTRY_KERNEL_DS*8)
#define __USER_DS	(GDT_ENTRY_DEFAULT_USER_DS*8+3)
#define __USER_CS	(GDT_ENTRY_DEFAULT_USER_CS*8+3)
#define __USER32_CS   (GDT_ENTRY_DEFAULT_USER32_CS*8+3)
#define __USER32_DS	__USER_DS

可以看到,内核态段选择子等于 GDT索引*8,而用户态段选择子等于GDT索引*8+3,这是由段描述符的结构决定的。在x86架构中,段寄存器和段选择子都是16位的,但是这 16 位并不是全部用来存储索引值,而是由三部分组成:

  • RPL(Requested Privilege Level)位。段选择子最低 2 位(位 0~1)称为请求特权级位,保存的是段请求级别;
  • TI 位,即表指示位(Table Indicator Flag)。段选择子的位 2 是 TI 位,TI 位用来指示段的保存位置:是保存在全局描述符表 GDT 中,还是在本地描述符表LDT(Local Descriptor Table )中。当 TI 为1时,表示在LDT中,当 TI 为0时,表示在 GDT 中。
  • 位 3~15,才是真正保存索引的位置。

从以上分析可知,段描述符最低 3 位有其他用途不能用来存放索引,所以要把索引值左移3位(相当于乘以8)才能放到索引区。另外,因为用户态的特权级为 3,我们看到所有的用户段都要加 3,相当于把用户态段选择子的 RPL 级别硬编码到程序里了。

3.1.3 系统调用的初始化

// file: arch/x86/kernel/cpu/common.c
void syscall_init(void)
{
	/*
	 * LSTAR and STAR live in a bit strange symbiosis.
	 * They both write to the same internal register. STAR allows to
	 * set CS/DS but only a 32bit target. LSTAR sets the 64bit rip.
	 */
	wrmsrl(MSR_STAR,  ((u64)__USER32_CS)<<48  | ((u64)__KERNEL_CS)<<32);
	wrmsrl(MSR_LSTAR, system_call);
	wrmsrl(MSR_CSTAR, ignore_sysret);
    
	......

	/* Flags to clear on syscall */
	wrmsrl(MSR_SYSCALL_MASK,
	       X86_EFLAGS_TF|X86_EFLAGS_DF|X86_EFLAGS_IF|
	       X86_EFLAGS_IOPL|X86_EFLAGS_AC);
}

可以看到,Linux 内核是使用 SYSCALL / SYSRET 指令对来实现系统调用的

wrmsrl(MSR_STAR,  ((u64)__USER32_CS)<<48  | ((u64)__KERNEL_CS)<<32);

这行代码把 32 位用户代码段选择子(__USER32_CS)写入MSR_STAR[48:63],把内核代码段选择子(__KERNEL_CS)写入 MSR_STAR[32:47]

wrmsrl(MSR_LSTAR, system_call);

这行代码把 system_call函数的地址写入了 MSR_LSTAR寄存器。system_call是所有系统调用的入口函数,该函数是用汇编代码写的,在 arch/x86/kernel/entry_64.S 文件中。

// file: arch/x86/kernel/entry_64.S
ENTRY(system_call)
	...
	...

	call *sys_call_table(,%rax,8)  # XXX:	 rip relative
	movq %rax,RAX-ARGOFFSET(%rsp)
	
	...
	...
	
	USERGS_SYSRET64
	
	...
	...
END(system_call)

USERGS_SYSRET64是一个宏,该宏被扩展后,包含有 sysretq 指令。后缀 q (quad word ,四字)表示返回到 64 位的代码段。

// file: arch/x86/include/asm/irqflags.h
#define USERGS_SYSRET64             \
    swapgs;                 \
    sysretq;

最后,向 MSR_SYSCALL_MASK 寄存器中写入了一些标志位。

	/* Flags to clear on syscall */
	wrmsrl(MSR_SYSCALL_MASK,
	       X86_EFLAGS_TF|X86_EFLAGS_DF|X86_EFLAGS_IF|
	       X86_EFLAGS_IOPL|X86_EFLAGS_AC);

syscall指令执行时,凡是MSR_SYSCALL_MASK中置位的标志位,都会从EFALGS中清除,伪代码如下:

RFLAGS := RFLAGS AND NOT(IA32_FMASK);

3.1.4 控制转移及特权级变化分析

先来看下执行 syscall指令时的控制转移及特权级变化。

  • 目标代码段 — 从 IA32_STAR[47:32] 获取;
  • 目标指令指针 — 从 IA32_LSTAR 读取 64 位的地址;
  • 栈段 — 通过 IA32_STAR[47:32] 加 8 计算得到;
  • 状态标志 — 把 IA32_FMASK MSR 中的值按位取反后,与当前 RFLAGS 进行逻辑与得到

根据上文所述,执行syscall指令时,会把状态寄存器 RFLAGS 保存到 R11,并把 SYSCALL 的下一条指令保存到 RCX 寄存器。目标代码段会从 IA32_STAR[47:32] 获取,指令指针会从 IA32_LSTAR 寄存器中读取。

可以看到,MSR_STAR[32:47]被初始化为内核代码段 __KERNEL_CS,该代码段的 RPL 为 0。指令指针被初始化为 system_call 函数的入口地址。

所以,syscall指令执行后,控制转移到特权级 0 的system_call函数上执行。

再来看下使用 sysret指令返回时,控制转移及特权级变化。

  • 目标代码段 — 从 IA32_STAR[63:48] + 16 获取到段选择子;
  • 目标指令指针 — 把 RCX 的值拷贝到 RIP;
  • 栈段 — IA32_STAR[63:48] + 8
  • EFLAGS — 从 R11 加载

由于使用的是 sysretq指令返回的,会将控制转换到 64 位的代码。此时,目标代码段的选择子从 IA32_STAR[63:48] + 16 获取到,目标指令滋镇从 RCX 寄存器恢复。IA32_STAR[63:48]被初始化为 32 位的用户代码段 __USER32_CS,其 RPL 为 3,索引值为 4。加上 16 会将段选择子中的索引值加 2(因为最低 3 位和索引无关,16 右移 3 位得到索引增量),最终会得到索引值为6 的 64 位用户代码段的选择子 __USER_CS

#define GDT_ENTRY_DEFAULT_USER32_CS 4
#define GDT_ENTRY_DEFAULT_USER_DS 5
#define GDT_ENTRY_DEFAULT_USER_CS 6

#define __USER_CS	(GDT_ENTRY_DEFAULT_USER_CS*8+3)

所以,sysretq指令完成后,控制转移到特权级为 3 的用户代码。

四、参考资料

1、Intel 64 and IA-32 Architectures Software Developer Manuals Volume 3A Chapter 5 Protection

2、《x86汇编语言:从实模式到保护模式》第 14 章