x86_64 下的段机制和 FS/GS

「段」……多么古老的概念,然而在 x86_64 下,老树也能开出新花,旧寄存器也能有新用途……

x86_64 下的段机制

段机制曾经是 x86 架构内存管理机制的重要部分,然而在功能更强大、粒度更细、安全性也更好的分页机制出现之后,段就显得有些多余甚至碍手碍脚了。绝大多数操作系统也不再使用分段机制,而是只在启动时建立几个段将逻辑地址空间对等映射到线性地址空间。

因此,在 x86_64 诞生之时,AMD 选择弃用 CS/DS/ES/SS 四个段:在 64-bit 下,这四个段总是被认为基址是 0,范围是整个 64-bit 地址空间;读写这四个段寄存器的指令要么产生错误,要么无效。换而言之,这四个段不再起到任何作用,也几乎不再需要任何操作。

然而,FS/GS 这两个段被保留了下来,并被赋予了新的机制和功能:段基址不再由段寄存器指向的描述符决定,而是由 IA32_FS/GS_BASE 这两个 MSR 决定;原有的范围检查和权限检查也不存在了。基本上可以说,在 64-bit 下,通过 FS/GS 段访问一个内存地址,就是单纯在地址上加上一个偏移量,这是将 IA32_FS/GS_BASE 当作了两个特殊的「基址寄存器」来使用了。

有这样两个「寄存器」的好处是显而易见的(除了「多多益善」本身以外):首先,使用 FS/GS 段访问内存的指令都是现成的,这样修改属于「废物利用」,只需要添加几个 MSR 即可,相比于增加几个新的通用寄存器代价更低改动更小(想想加入 R8 ~ R15 增添了多少麻烦事,而将 R16 ~ R31 真正投入应用又不知要等多长时间);其次,可以通过段实现更加复杂的寻址,例如 %gs:0x4(%rax,%rdx,8) 等,虽然实践中出现这样的寻址的概率并不大;另外,IA32_FS/GS_BASE 作为 MSR,更不容易被用户程序意外修改。

以上几个特点使 IA32_FS/GS_BASE 特别适合存储某些在线程运行过程中极少改变的指针,例如指向线程本地存储区域或 PerCPU 数据区域的指针。

FS/GS 段和 PerCPU 数据及线程本地存储

习惯上,GS 段主要用于内核存储 PerCPU 数据(每逻辑处理器一份的数据),为了方便内核使用,x86_64 还提供了额外的机制:IA32_KERNEL_GS_BASE MSR 和 swapgs 指令。

CPU在用户态执行用户程序时,IA32_GS_BASE 中存储的是用户态程序所使用的 GS 段基址,内核所用的 GS 段基址则被被暂存在 IA32_KERNEL_GS_BASE MSR 中;当控制流通过系统调用等方式进入到内核态时,内核可以通过 swapgs 指令将 IA32_KERNEL_GS_BASEIA32_GS_BASE 两个 MSR 的内容互换,同时保存用户态的 GS 段基址并加载内核态的 GS 段基址;当控制流回到用户态时,内核可以再次使用 swapgs 指令保存内核态的 GS 段基址并恢复用户态的 GS 段基址。

GS 段所指向的 PerCPU 数据具体如何排布取决于内核自身的设计,但一般都至少会包括系统调用时所需要切换到的内核栈地址,这是因为 syscall 指令并不会切换栈,需要内核手动处理,而内核如果手动指定某个固定地址则会在多处理器系统下出现冲突,因此从 GS 段读取一个 PerCPU 的地址是最为合理的。(顺带一提,在处理中断时就无需操心手动切换栈的问题,这种设计上的不统一也显示出整个 x86 架构的某种「历史厚重感」)

另外,GS 段有时也被栈保护机制所使用,放置 PerCPU 的 Stack Canary 原始值。在这种情况下,操作系统在分配 PerCPU 数据的存储空间时,必须考虑到 Stack Canary 的存在。

FS 则被用户态程序广泛使用于指向线程本地存储区域的地址。其具体排布可以参照 ABI 的定义及 glibc 或 musl 的实现。

另外需要注意的是,在 64-bit 下访问 FS/GS 段寄存器的效果是因厂家和型号而异的,因此在 64-bit 下不应该直接访问 FS/GS 段寄存器,而是应该访问相关的 MSR(仅 CPL 0,Linux 下可以使用 arch_prctl 系统调用间接访问)或者使用 FSGSBASE 扩展(CPL 3 可用)。如果想要自行实现的操作系统内核良好地支持面向 POSIX 标准的 C/C++用户态程序,一定要妥善实现 arch_prctl 系统调用以及上下文切换(无论涉不涉及特权级切换)时 FS/GS 段基址的保存和切换。

其他

拓展阅读