“段寄存器”的故事

一、 段寄存器的产生

段寄存器的产生源于Intel 8086 CPU体系结构中数据总线与地址总线的宽度不一致。

 

数据总线的宽度,也即是ALU(算数逻辑单元)的宽度,平常说一个CPU是“16位”或者“32位”指的就是这个。8086CPU的数据总线是16位。

 

地址总线的宽度不一定要与ALU的宽度相同。因为ALU的宽度是固定的,它受限于当时的工艺水平,当时只能制造出16位的ALU;但地址总线不一样,它可以设计得更宽。地址总线的宽度如果与ALU相同当然是不错的办法,这样CPU的结构比较均衡,寻址可以在单个指令周期内完成,效率最高;而且从软件的解决来看,一个变量地址的长度可以用整型或者长整型来表示会比较方便。

 

但是,地址总线的宽度还要受制于需求,因为地址总线的宽度决定了系统可寻址的范围,即可以支持多少内存。如果地址总线太窄的话,可寻址范围会很小。如果地址总线设计为16位的话,可寻址空间是2^16=64KB,这在当时被认为是不够的;Intel最终决定要让8086的地址空间为1M,也就是20位地址总线。

 

地址总线宽度大于数据总线会带来一些麻烦,ALU无法在单个指令周期里完成对地址数据的运算。有一些容易想到的可行的办法,比如定义一个新的寄存器专门用于存放地址的高4位,但这样增加了计算的复杂性,程序员要增加成倍的汇编代码来操作地址数据而且无法保持兼容性。

 

Intel想到了一个折中的办法:把内存分段,并设计了4个段寄存器,CS,DS,ES和SS,分别用于指令、数据、其它和堆栈。把内存分为很多段,每一段有一个段基址,当然段基址也是一个20位的内存地址。不过段寄存器仍然是16位的,它的内容代表了段基址的高16位,这个16位的地址后面再加上4个0就构成20位的段基址。而原来的16位地址只是段内的偏移量。这样,一个完整的物理内存地址就由两部分组成,高16位的段基址和低16位的段内偏移量,当然它们有12位是重叠的,它们两部分相加在一起,才构成完整的物理地址。

 

Baseb15 ~ b12b11 ~ b0 
Offset o15 ~ o4o3 ~ o0
Addressa19 ~ a0

 

这种寻址模式也就是“实地址模式”。在8086中,段寄存器还只是一个单纯的16位寄存器,而且操作寄存器的指令也不是特权指令。通过设置段寄存器和段内偏移,程序就可以访问整个物理内存,无安全性可言。

 

总之一句话,段寄存器的设计是一个权宜之计,现在看来可以说是一个临时性的解决方案,设计它的目的是为了把地址空间从64KB扩展为1MB,仅此而已。但是它的加入却为日后Intel系列芯片的发展带来诸多不便,也为理解i386体系带来困扰。

二、 实现保护模式

到了80386问世的时候,工艺已经有了很大的进步,386的ALU有已经从16位跃升为32位,也就是说,38086是32位的CPU,而且结构也已经比较成熟,接下来的80486一直到Pentium系列虽然速度提高了几个数量级,但并没有质的变化,所以被统称为i386结构。

 

对于32位的CPU来说,只要地址总线宽度与数据总线宽度相同,就可以寻址2^32=4GB的内存空间,这已经足够用,已经不再需要段寄存器来帮助扩展。但这时Intel已经无法把段寄存器从产品中去掉,因为新的CPU也是产品系列中的一员,根据兼容性的需要,段寄存器必须保留下来。

 

这时,技术的发展需求Intel在其CPU中实现“保护模式”,用户程序的可访问内存范围必须受到限制,不能再任意地访问内存所有地址。Intel决定利用段寄存器来实现他们的保护模式,把保护模式建立在段寄存器的基础之上。

 

对于段的描述不再只是一个20位的起始地址,而是全新地定义了“段描述项”。段描述项的结构如下:

 

B31 ~ B24DES1 (4 bit)L19 ~ L16
DES2 (8 bit)B23 ~ B16
B15 ~ B0
L15 ~ L0

 

每一行是两个字节,总共8个字节,64位。

 

DES1和DES2分别是一些描述信息,用于描述本段是数据段还是代码段,以及读写权限等等。B0~B31是段的基地址,L0~L19是段的长度。

 

注意,规定段的长度是非常必要的,如果不限定段长度,“保护”就无从谈起,用户程序的访问至少不能超过段的范围。另外,段长度只有20位,所代表的最大可能长度为2^20=1M,而整个地址空间是2^32=4GB,这样来看,段的长度是不是太短了?其实,在DES1中,有一位用于表示段长度的单位,当它被置1时(一般情况下都是如此),表示长度单位为4KB,这样,一个段的最大可能尺寸就成了1M*4K=4G,与地址空间相稳合。4KB也正是一个内存页的大小,说明段的大小也是向页对齐的。

 

另外,注意到一个有趣的现象吗?段描述项的结构被设计得不连续,不论是段基地址还是段长度,都被分成了两节表示。这样的设计与80286的过渡有关。上面的段描述项结构去掉第一行后剩下的三行正是286的段描述项。286被设计为24位地址总线,所以段基址是24位,相应地段长是16位。在386的地址总线扩展为32位之后,还必须兼容286产品的设计,所以只好在段描述项上“打补丁”。

 

在386中,段寄存器还是16位,那么16位的段寄存器如何存放得下64位的段描述项? 段描述项不再由段寄存器直接持有。段描述项存放在内存里,系统中可以有很多个段描述项,这些项连续存放,共同构成一张表,16位的段寄存器里只是含有这张表里的一个索引,但也并不仅是一个简单的序号,而是存储了一种数据结构,这种结构的定义如下:

 

index (b15 ~ b3)TI (b2)RPL (b1 ~ b0)

 

其中index是段描述表的索引,它指向其中的某一个段描述项。RPL表示权限,00最高,11最低。

 

还有一个关键的问题,内存中的段描述表的起始地址在哪里?显然光有索引是有不够的。为此,Intel又设计了两个新的寄存器:GDTR(global descriptor table register)和LDTR(local descriptor table register),分别用来存储段描述表的地址。段寄存器中的TI位正是用于指示使用GDTR还是LDTR。

 

当用户程序要求访问内存时,CPU根据指令的性质确定使用哪个段寄存器,转移指令中的地址在代码段,取数指令中的地址在数据段;根据段寄存器中的索引值,找到段描述项,取得段基址;指令中的地址是段内偏移,与段长比较,确保没有越界;检查权限;把段基址和偏移相加,构成物理地址,取得数据。

 

新的设计中处处有权限与范围的限制,用户程序只能访问被授权的内存空间,从而实现了保护机制。就这样,在段寄存器的基础上,Intel实现了自己的“保护模式”。

三、 与页式存管并存

现代操作系统的发展要求CPU支持页式存储管理。

 

页式存管本身是与段式存管分立的,两者没有什么关系。但对于Intel来说,同样是由于“段寄存器”这个历史的原因,它必须把页式存管建立在段式存管的基础之上,尽管这从设计的角度来说这是没有道理,也根本没有必要的。

 

在段式存管中,由程序发出的变量地址经映射(段基址+段内偏移)之后,得到的32位地址就是一个物理地址,是可以直接放到地址总线是去取数的。

 

在页式存管中,过程也是相似的,由程序发出的变量地址并不是实际的物理地址,而是一个三层的索引结构,这个地址经过一系统的映射之后才可以得到物理地址。

 

现在对于Intel CPU来说,以上两个映射过程就要先后各做一次。由程序发出的变量地址称为“逻辑地址”,先经过段式映射成为“线性地址”,线性地址再做为页式映射的输入,最后得到“物理地址”。

 

Linux内核实现了页式存储管理,而且并没有因为两层存管的映射而变得更复杂。Linux更关注页式内存管理,对于段式映射,采用了特殊的方式把它简化。让每个段寄存器都指向同一个段描述项,即只设了一个段,而这个段的基地址为0,段长度设为最大值4G,这个段就与整个物理内存相重合,逻辑地址经映射之后就与线性地址相同,从而把段式存管变成“透明”的。

 

 

这,就是Intel处理器中“段寄存器”的故事。

 

http://www.the2ndmoon.net/weblog/?p=53

 

# 深入CPU寄存器:从C语言register关键字看计算机体系结构 记得刚开始学C语言那会儿,教材里提到`register`关键字,总是语焉不详。书上说它能“建议编译器把变量放到寄存器里,加快访问速度”,但紧接着又补一句“现代编译器很聪明,通常会自动优化,所以这个关键字用处不大”。这种解释让人似懂非懂,反而勾起了更深的好奇:寄存器到底是什么?编译器凭什么“聪明”?CPU内部又是如何运作的?今天,我们就以这个看似过时的`register`关键字为引子,真正潜入计算机体系结构的深海,看看那些隐藏在高级语言之下的精妙机械。 这篇文章适合那些不满足于“知其然”,更想“知其所以然”的计算机组成原理爱好者。我们将从一段简单的C代码出发,一路追踪到编译器内部、CPU流水线深处,甚至用工具亲眼看看不同指令集架构(ISA)下的真实差异。你会发现,一个简单的变量存储位置选择,背后牵连着指令级并行、数据冒险、寄存器重命名等一系列硬核概念。准备好了吗?我们这就开始。 ## 1. 寄存器:CPU的“工作台”与速度之源 要理解`register`,必须先理解寄存器本身。你可以把内存想象成一个巨大的仓库,里面堆满了数据(变量),而CPU是处理这些数据的工人。如果工人每次需要数据都跑回遥远的仓库去取,效率无疑极低。因此,CPU内部设置了一组极小的、但速度极快的存储单元,这就是**寄存器**。它们就像工人手边的工作台,最常用的工具和数据就放在上面,随取随用。 寄存器的速度到底有多快?我们来看一个粗略的对比: | 存储层级 | 典型访问延迟(周期) | 容量范围 | 物理位置 | | :--- | :--- | :--- | :--- | | **寄存器** | 1 周期 | 几十到几百字节 | CPU核心内部 | | **L1缓存** | ~4 周期 | 几十KB | CPU核心内部 | | **L2缓存** | ~12 周期 | 几百KB | CPU核心内部/共享 | | **L3缓存** | ~40 周期 | 几MB到几十MB | CPU芯片内,多核共享 | | **主内存** | ~200 周期 | 几GB到几百GB | 主板上的内存条 | > **提示**:这里的“周期”指的是CPU时钟周期。一个3GHz的CPU,每秒有30亿个时钟周期,一个周期大约是0.33纳秒。主存访问延迟200周期,意味着大约66纳秒,这比寄存器访问慢了**两个数量级**。 正是这巨大的速度差异,让寄存器的使用策略变得至关重要。早期的C语言设计者提供了`register`关键字,本质上是一种**程序员对编译器的提示**:“我觉得这个变量会用得很频繁,麻烦你尽量把它安排在手边(寄存器)上,别老往仓库(内存)里跑。” 但是,为什么现代编译器常常忽略这个提示呢?因为编译器看到的“全局视野”往往比程序员更精准。它通过**数据流分析**和**活跃变量分析**,能精确计算出每个变量在程序执行过程中的使用频率和生命周期,从而做出更优的全局寄存器分配决策。一个程序员标记为`register`的变量,可能在某个循环里疯狂使用,但循环结束后就再也没出现过;而另一个未标记的变量,却可能贯穿整个函数始终被引用。编译器有能力做出更明智的判断。 ## 2. 编译器视角:从C代码到机器指令的旅程 让我们写一段简单的代码,并用编译器“解剖”它,看看`register`关键字到底经历了什么。 ```c // test.c int sum_with_register(register int n) { register int i, sum = 0; for (i = 0; i < n; ++i) { sum += i; } return sum; } int sum_without_register(int n) { int i, sum = 0; for (i = 0; i < n; ++i) { sum += i; } return sum; } ``` 使用GCC编译器,并指定`-O2`优化级别和`-S`参数输出汇编代码,我们可以窥见其处理过程: ```bash gcc -O2 -S test.c -o test.s ``` 查看生成的`test.s`汇编文件(以x86-64为例),你可能会发现一个有趣的现象:**两个函数生成的汇编代码很可能一模一样!** 无论你是否使用了`register`关键字,在`-O2`优化级别下,GCC的寄存器分配器(Register Allocator)都会自行决定将变量`i`和`sum`放在哪个硬件寄存器中(比如`eax`, `ecx`等)。 那么,`register`关键字在编译器前端(如词法分析、语法分析后)到底扮演什么角色?我们可以在Clang/LLVM的源码中寻找线索。在Clang的`SemaDecl.cpp`文件中,有对存储类说明符(Storage Class Specifier)的处理逻辑。当解析到`register`时,它主要做两件事: 1. **语义检查**:确保`register`只用于自动变量(局部变量)和函数参数,不能用于全局变量、静态变量,也不能用于取地址操作(`&`运算符)。 2. **设置标志**:在生成的抽象语法树(AST)节点中,为该变量设置一个“建议寄存器分配”的标志。 这个标志会一路传递到中间表示(IR)阶段。但在LLVM的优化管道(Pass Pipeline)中,有一个至关重要的环节叫做**寄存器分配**。LLVM使用的是一种基于图着色(Graph Coloring)的全局寄存器分配算法。在这个算法眼里,所有需要分配的变量(在IR中称为“虚拟寄存器”)都是平等的。它根据变量的**活跃区间**、**冲突关系**和**硬件寄存器数量**来构建干涉图并进行着色分配。此时,前端设置的“建议寄存器分配”标志,其权重已经非常低,几乎不会影响分配结果。 > **注意**:这解释了为什么对`register`变量取地址会导致编译错误。在LLVM IR和最终的机器码中,寄存器是没有内存地址的。一旦允许取地址,编译器就必须为这个变量在栈上分配一个内存位置(即“溢出到内存”),这与`register`的初衷完全背道而驰,所以语言标准直接禁止了这种行为。 ## 3. CPU的魔法:寄存器重命名与流水线冒险 即使编译器完美地将变量分配到了物理寄存器,故事也远未结束。现代CPU为了提升性能,采用了**超标量(Superscalar)**和**乱序执行(Out-of-Order Execution)**技术。这带来了一个新的问题:**指令间的数据依赖会阻碍并行**。 看一个简单的例子: ```assembly mov eax, [mem1] ; 指令1:从内存加载数据到eax add eax, 10 ; 指令2:eax = eax + 10 mov ebx, eax ; 指令3:ebx = eax add eax, 20 ; 指令4:eax = eax + 20 ``` 指令2依赖于指令1的结果(`eax`),指令3依赖于指令2的结果,指令4也依赖于指令2的结果。这是一种**写后读(RAW)** 数据冒险。在顺序执行的CPU中,这没问题。但在乱序执行的CPU中,它希望指令4能尽早执行,只要它的操作数就绪。然而,指令2、3、4都使用同一个架构寄存器`eax`,造成了**虚假依赖**。 为了解决这个问题,CPU内部引入了一项堪称神技的机制:**寄存器重命名(Register Renaming)**。CPU内部维护的物理寄存器数量(比如256个)远多于程序员/编译器可见的架构寄存器数量(比如x86-64的16个通用寄存器)。一个**重命名器(Renamer)** 组件会动态地将指令中的架构寄存器(如`eax`)映射到内部的物理寄存器上。 以上面的代码为例,重命名过程可能是这样的: 1. 指令1:`eax` -> 物理寄存器P1 2. 指令2:它需要`eax`(即P1)的值,并产生一个新的`eax`值。重命名器为这个新结果分配一个新的物理寄存器P2。所以指令2实际变为:`P2 = P1 + 10`。 3. 指令3:它需要指令2产生的`eax`值,即P2。它产生`ebx`,分配P3:`P3 = P2`。 4. 指令4:它也需要指令2产生的`eax`值(P2),并产生新的`eax`值。重命名器分配P4:`P4 = P2 + 20`。 经过重命名,指令4(`P4 = P2 + 20`)和指令3(`P3 = P2`)之间不再有写寄存器的依赖,它们可以并行执行,只要P2的值就绪。这就打破了虚假依赖,极大地挖掘了指令级并行潜力。 从这里回看C语言的`register`,它只是指定了源代码级别的变量与架构寄存器的**建议映射**。而在CPU实际执行时,这个架构寄存器早已被重命名器映射到了多个不同的物理寄存器上。程序员在语言层面所能控制的,与CPU底层的复杂运作相比,已经隔了太多层抽象。 ## 4. 架构差异实践:用Godbolt观察x86与ARM 理论说了很多,是时候用眼睛来验证了。**Compiler Explorer (Godbolt)** 是一个极其强大的在线工具,它可以即时展示多种编译器、多种优化级别、多种架构下的汇编输出。我们用它来直观感受不同平台对寄存器使用的差异。 访问Godbolt网站,输入我们之前的两个`sum`函数。在编译器选择栏,分别选择: * `x86-64 gcc 13.2` * `ARM64 gcc 13.2.0` 在优化选项中都填入`-O2`。你会看到左右两栏分别显示x86-64和ARM64的汇编代码。 **x86-64汇编可能类似这样:** ```assembly sum_without_register: test edi, edi jle .L4 lea eax, [rdi-1] lea edx, [rdi-2] imul rdx, rax shr rdx mov eax, edi imul rax, rdx shr rax, 1 ret .L4: xor eax, eax ret ``` 惊喜吗?循环不见了!GCC在`-O2`下识别出这是一个等差数列求和,直接使用了数学公式 `(n*(n-1))/2` 进行优化,完全跳过了循环和累加。寄存器`edi`(存放参数`n`)、`rax`、`rdx`等被用于计算。这里根本看不出`register`关键字的影响,因为优化器已经从根本上重写了算法。 **ARM64汇编则可能如下:** ```assembly sum_without_register: cmp w0, 0 ble .L4 sub w1, w0, #1 mul w1, w0, w1 mov w0, w1 lsr w0, w0, 1 ret .L4: mov w0, 0 ret ``` 同样,循环被优化掉了。但我们可以注意到架构差异: * **寄存器命名**:x86使用`edi`, `eax`, `rdx`;ARM64使用`w0`, `w1`。`w`开头的寄存器是64位`x`寄存器的低32位。 * **指令集**:x86有`imul`(乘法)、`shr`(右移);ARM64有`mul`、`lsr`。 * **调用约定**:x86-64在Linux下通常用`edi`传递第一个整型参数;ARM64用`w0`传递。 如果我们把优化级别降到`-O0`(禁用几乎所有优化),就能看到循环,也能更清晰地看到寄存器分配。但即便如此,两个版本(带/不带`register`)的汇编代码在绝大多数现代编译器上依然会相同。这强有力地证明了,在寄存器分配这个领域,编译器的算法已经全面超越了程序员的手动提示。 ## 5. 超越关键字:现代高性能编程中的寄存器思维 既然`register`关键字基本失效,是不是意味着我们不需要关心寄存器了?恰恰相反。在高性能计算、内核开发、嵌入式系统等场景中,对寄存器的深刻理解至关重要。只不过,我们的关注点要从“建议编译器”提升到“配合编译器与CPU”。 **1. 限制变量作用域,帮助编译器分析** 尽量缩小变量的作用域。在循环内部使用的临时变量,就在循环内部声明。这使得编译器的活跃变量分析更精确,更容易将其分配到寄存器并保持更长时间。 ```c // 不利于分析 int i, temp; for (i = 0; i < N; ++i) { temp = heavy_computation(i); result += temp; } // 更利于分析(C99及以上) for (int i = 0; i < N; ++i) { int temp = heavy_computation(i); // temp的作用域仅限于循环体 result += temp; } ``` **2. 关注数据局部性,减少缓存失效** 虽然寄存器最快,但容量极小。大部分数据还是要待在缓存和内存中。编写**缓存友好**的代码,让CPU能高效地将需要的数据从内存预取到缓存,再送到寄存器,是提升性能的关键。这包括顺序访问数组、使用紧凑的数据结构等。 **3. 理解ABI与调用约定** 应用程序二进制接口(ABI)规定了函数调用时,参数通过哪些寄存器传递,返回值放在哪个寄存器,哪些寄存器是调用者保存,哪些是被调用者保存。在编写汇编代码或进行深度优化时,必须遵守这些约定。例如,在x86-64 System V ABI中,整数参数依次使用`rdi`, `rsi`, `rdx`, `rcx`, `r8`, `r9`寄存器。 **4. 使用内联汇编进行极致控制** 在极少数需要绝对控制寄存器分配的场合(如编写特定硬件驱动、实现加密算法或性能极其关键的代码段),可以使用GCC/Clang的内联汇编。这让你可以直接指定使用哪个硬件寄存器。 ```c // 一个简单的例子:使用内联汇编进行64位乘法,结果存在RDX:RAX中 unsigned long long mul64(unsigned long long a, unsigned long long b) { unsigned long long hi, lo; __asm__("mulq %3" : "=a"(lo), "=d"(hi) // 输出:lo在rax, hi在rdx : "a"(a), "rm"(b) // 输入:a到rax, b到寄存器或内存 : "cc" // 告知编译器标志寄存器被修改了 ); return lo; // 或者处理128位结果 } ``` > **注意**:内联汇编是一把双刃剑,它破坏了编译器的优化能力,且高度依赖编译器和目标平台,应作为最后的手段谨慎使用。 从`register`这个小小的关键字出发,我们穿越了编译器优化的长廊,窥探了CPU乱序执行的引擎,并对比了不同架构的指令集。最终我们发现,真正的“寄存器优化”早已不是通过一个关键字就能实现的简单操作,而是建立在**对计算机系统各层次抽象(语言、编译器、ISA、微架构)的协同理解**之上。下次当你编写代码时,或许可以多一层思考:这个变量,正走在一段从高级语言到晶体管开关的、漫长而精彩的旅程中。理解这段旅程,正是我们探索计算机体系结构的乐趣所在。
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值