三月 23

AT&T 汇编语言 (三) : 数据传送

数据定义

带有初值的区段

程序中 data 段和 rodata 段都是带有初始值(initialized)的区段,这些区段一般被直接汇编进目标文件中,写死在程序可执行文件内部。但是,在运行时刻它的副本会被拷贝到内存中,程序能够通过指令读写(rodata 段不可写)其中的数据元素

data 段

程序的 data 段是最常见的定义数据元素的位置。data 段定义用于存储数据的特定内存空间,这些内存空间可以被指令引用、读取和修改

rodata 段

rodata 段是另一种类型的 data 段,它与 data 段类似,但是这数据段中定义的任何数据元素只能以只读(read-only)模式访问

汇编器的数据定义命令

在数据段中定义数据元素需要用到两个语句:标签汇编器命令

标签: 汇编器命令
  • 标签:指示引用数据元素所使用的标记,标签本身对处理器没有任何意义,它的值即内存位置
  • 汇编器命令:指示为数据元素保留多少字节的内存空间

声明命令后,必须定义一个(或者多个)默认值。把保留的内存中的数据设置为特定值。下表介绍可以用于为特定数据元素类型保留内存的不同命令

.ascii文本字符串
.asciz以 0 结尾的文本字符串
.byte字节值
.double双精度浮点数
.float单精度浮点数
.int32 位整数
.long32 位整数(和 .int 相同)
.octa16 字节整数
.quad8 字节整数
.short16 位整数
.single单精度浮点数(与 .float 相同)

注意:笔者并没有验证 64 位环境下的情况

数据定义示例

可以在以下的 data 段,定义一些带有初值的数据元素

.section .data
msg:
    .ascii "This is a test message"
factors:
    .double 37.45, 45.33, 12.30
height:
    .int 54
length:
    .int 62, 35, 47

按照这样的定义顺序,每个数据元素被存放到内存中。带有多个值的元素,也按照列出的顺序存放。例如从 height 开始的内存位置,内存的情况如图所示

定义数据元素时,也需要考虑数据元素大小与指令操作数宽度匹配的问题,否则容易发生一些意想不到的错误

静态符号定义命令

虽然数据段主要用于定义变量数据,但是也可以在这里声明静态数据符号

.equ 命令用于把常量值设置为可以在 text 段中使用的符号,汇编时会被一一替换,有点类似于 C 语言中的 #define 常量定义,设置方式如下

.equ factor, 3              # 使 factor 在汇编时最终被 3 替换
.equ LINUX_SYS_CALL, 0x80   # 使 LINUX_SYS_CALL 在汇编时最终被 0x80 替换

定义完之后,可以在 text 段代码里这样使用

movl $LINUX_SYS_CALL, %eax

这将与以下指令等价,并且最终被替换成如下指令

movl $0x80, %eax

零值初始化的区段

bss 段与前者不同,它不会被占用目标文件的体积,它的数据不会写进程序本体中,自然也无需初始化。当程序运行时,操作系统会给 bss 段分配一块指定大小的内存空间,并进行零值初始化(zero-initialized),此时程序可以读写 bss 段

bss 段

bss 段中定义数据元素无须声明特定数据类型,只需声明需要保留的内存大小。一般作为缓冲区使用,可以用于存放大量的需要动态处理的数据

汇编器的数据定义命令

GNU 汇编器使用两个命令来定义缓冲区,如下表所示

.comm声明零值初始化的通用内存区域
.lcomm声明零值初始化的局部(local)通用内存区域

这两个命令大致相同,但是 .lcomm 命令声明的区域中的数据是不允许外部模块访问的,即不能使用 .globl 命令去访问它们(类似 C 语言的 static 修饰符的效果)

其中,标签用于指示该缓冲区的起始内存位置,字节数量用于指示缓冲区需要的内存大小

.comm 标签, 字节数量
.lcomm 标签, 字节数量

标签也可以在命令前面再加上一个,但是命令的第一个参数的标签不能省略

额外的数据定义命令

.fill 命令

.fill 命令可以使汇编器自动地创建指定数量的数据元素(每一个元素作为一个字节),这个命令是在所有区段都可以通用的

.fill 字节数量

数据传送

MOV 指令族(无条件传送)

MOV 指令族的基本格式如下

movx 源操作数, 目的操作数

x 可以是如下字符(AT&T 语法)

  • l:用于 32 位整数(长字)
  • w:用于 16 位整数(字)
  • b:用于 8 位整数(字节)

MOV 指令族有非常特殊的规则,源和目标操作数的组合如下。为了方便,我在这里制定一套代号。

  • GR 代表通用寄存器
  • M 代表内存某个位置
  • CR 代表控制寄存器
  • DR 代表调试寄存器
  • SR 代表段寄存器
  • X 代表立即数

以下是 MOV 指令族的传送规则

  • X ➡ GR
  • X ➡ M
  • GR ⬅➡ GR
  • GR ⬅➡ SR
  • GR ⬅➡ CR
  • GR ⬅➡ DR
  • M ⬅➡ GR
  • M ⬅➡ SR

有一种特殊的 MOV 指令 —— MOVS 指令,此处排除在外,暂不做讨论

立即数与寄存器

这里有两个快速的示例,演示如何将立即数传送到寄存器,以及将寄存器的值传送至另一个寄存器

立即数传送至寄存器

movl $0, %eax        # 将立即数 0 传送至 EAX 寄存器
movl $0x80, %ebx     # 将立即数 0x80 传送至 EBX 寄存器

寄存器传送至寄存器

movl %eax, %ecx  # 将 EAX 寄存器的值传送至 ECX
movw %ax, %bx    # 将 AX 寄存器的值传送至 BX
movb %al, %cx    # 错误!!!操作数宽度不一致

内存与寄存器

定址

.section .data
value: 
    .int 1
.section .text
.globl _start
_start:
    nop
    movl value, %ecx   # 将 value 指示内存位置处取出一个长字,送入 ECX 寄存器
    movl %edx, $0
    movl %edx, value   # 将 EDX 寄存器的值传送至 value 所指示的内存位置
    movl $1, %eax
    movl $0, %ebx
    int $0x80

变址

就像之前说过的,可以在一个数据定义命令中指定多个元素存放到内存中

values:
    .int 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60

如同数组一样,这些元素每一个都占用 4 个字节的空间,要在程序中引用该数组中的某个数据,就必须使用变址系统确定访问该元素的位置。完成这种操作的方式称为变址内存模式(indexed memory mode),其内存位置由下列因素决定

  • base_address:基址
  • offset_address:基址的偏移地址
  • size:单个元素的长度
  • index:确定选择哪个数据元素的变址(也就是下标)

变址表达式的格式为

base_address (offset_address, index, size)

最终复合形成的地址值

linear\_address = base\_address + offset\_address + index \times size

示例程序如下

.section .data
output:
    .asciz "The value is %d\n"
values:
    .int 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60
.section .text
.globl _start
_start:
    nop
    movl $0, %edi
loop:
    movl values(, %edi, 4), %eax  # 最终线性地址 = values + %edi * 4, 而刚好 %edi 充当下标的角色
    pushl %eax
    pushl $output
    call printf
    addl $8, %esp
    inc %edi
    cmpl $11, %edi
    jne loop
    movl $0, %ebx
    movl $1, %eax
    int $0x80

当然,因为兼容性问题,我自己也写了 64 位版本的

.section .data
output:
    .asciz "The value is %ld\n"
values:
    .int 10, 15, 20, 25, 30, 35, 40, 45, 50, 55, 60
.section .text
.globl _start
_start:
    nop
    movq $0, %rbx
loop:
    movl values(, %rbx, 4), %eax
    movq $output, %rdi
    movq %rax, %rsi
    callq printf
    inc %rbx
    cmpq $11, %rbx
    jne loop
    movq $0, %rbx
    movq $1, %rax
    int $0x80

间接寻址

除了保存一般数据之外,寄存器经常用于保存内存地址,当寄存器保存内存地址时,一般将其称为指针(pointer)使用指针访问存储在内存某个位置的数据,称为间接寻址(indirect addressing)

可以在指令中使用 $ 作为标签名称的前缀,以提取标签的内存地址

movl $values, %edi    # 将 values 标签引用的内存地址传送给 EDI 寄存器

可以在指令中使用括号 “ ( ) ” 将寄存器括起,将寄存器当做指针来解引用(类似 C 语言的 “ * ” 运算符)

movl %ebx, (%edi)    # 将 EBX 寄存器的值传送至 EDI 寄存器指向的内存位置

这里如果不加括号,作用完全不一样,那就是将 EBX 寄存器的值传送至 EDI 寄存器中去

当然,也可以在括号前面紧贴着设置一个整数(可以为负),这将使该整数与该地址相加得到最终地址

movl %ebx, 4(%edi)   # 将 EBX 寄存器的值传送至 EDI 寄存器指向的内存地址+4偏移地址的位置
movl %ebx, -4(%edi)  # 将 EBX 寄存器的值传送至 EDI 寄存器指向的内存地址-4偏移地址的位置

CMOV 指令族(条件传送)

MOV 指令是可以随意使用的,功能强大的指令。多年以来,Intel 已经改进了 IA-32 平台以便提供附加的功能,使汇编语言程序员的工作更加容易。条件传送(conditional move)指令就是这些改进之一,这些指令从奔腾处理器的 P6 系列(奔腾 Pro、奔腾 II 以及更新的型号)开始就可以在编程中使用了

CMOV 指令就是使得 MOV 指令应该发生在特定条件下。假设有一段代码,在传统汇编语言代码下应该这样写

    dec %ecx
    jz continue
    movl $0, %ecx
continue:

上面代码的算法是先使得 ECX 寄存器递减 1,如果 ECX 寄存器的值不为 0,那么就将 ECX 置为立即数 0。如果使用 CMOV 的相关指令,就可以避免编写多余的 JMP 跳转指令,这有利于处理器的预取缓存状态,通常可以提高程序在处理器上执行的速度。于是上面可以改为

dec %ecx
xor %eax, %eax       # 生成 0 值
cmovnz %eax, %ecx    # DEC 指令的结果非 0 则发生传送,给 ECX 寄存器置 0

的确,貌似 CMOV 指令无法直接传送立即数,但是操作数可以是寄存器或者内存地址

CMOVcc 指令

CMOV 指令的后缀(一个或两个字符)决定它的条件。而 EFLAGS(标志位)将直接影响指令的行为,通常来说,标志位就是指令执行的“风向标”

CMOVcc 中的 cc 可以是以下字符组合,笔者这些常用的 CMOV 指令以及它们的描述、受到的标志位的影响也汇总起来

下表是对于无符号数而言的条件传送指令(依靠 CF、ZF、PF 来确定操作数之间的区别)

cc 后缀描述EFLAGS 状态
A / BE大于 / 不小于等于(CF or ZF) = 0
AE / NB大于等于 / 不小于CF = 0
NC无进位CF = 0
B / NAE小于 / 不大于等于CF = 1
C进位CF = 1
BE / NA小于等于 / 不大于(CF or ZF) = 1
E / Z等于 / 零ZF = 1
NE / NZ不等于 / 非零ZF = 0
P / PE奇偶校验 / 偶校验PF = 1
NP / PO非奇偶校验 / 奇校验PF = 0

若操作数是有符号值,那么就必须使用另一套指令

cc 后缀描述EFLAGS 状态
GE / NL大于等于 / 不小于(SF xor OF) = 0
L / NGE小于 / 不大于等于(SF xor OF) = 1
LE / NG小于等于 / 不大于((SF xor OF) or ZF) = 0
O溢出OF = 1
NO未溢出OF = 0
S负数SF = 1
NS非负数SF = 0

数据交换

数据交换指令

通常在编程时需要对数据进行交换,对于两个寄存器而言,使用常规的 MOV 指令还需要额外的寄存器或者内存作为临时存放交换数据

# 交换 EAX 寄存器和 EBX 寄存器中的值
movl %eax, %ecx
movl %ebx, %eax
movl %ecx, %ebx

为了方便编程,Intel 处理器有一套很有用的数据交换指令

XCHG在两个寄存器之间或者寄存器与内存之间交换值
BSWAP反转一个 32 位寄存器中的字节顺序
XADD交换两个值并且把总和存储在目标操作数中
CMPXCHG对两个操作数进行比较,并且交换它们的值
CMPXCHG8B对两个操作数进行比较,并且交换它们的值(针对64位值)

XCHG 指令

XCHG 指令是这组指令中最简单的。它可以在两个寄存器之间或者寄存器与内存之间交换值

xchg 操作数1, 操作数2

两个操作数可以同时是寄存器,也可以一个是寄存器另一个是内存地址,但不能同时都是内存地址,并且两个操作数长度必须相同。

当一个操作数是内存地址时,处理器的 LOCK 信号自动激活(它保证数据操作的硬件层面的互斥性

BSWAP 指令

BSWAP 指令十分强大,当某项数据具有不同字节排列顺序时(比如网络字节序),它的作用就体现出来了。BSWAP 指令反转寄存器中字节的顺序,第 0~7 位和第 24~31 位进行交换,第 8~15 位与第 16~23 位交换。数据的大端字节序(big-endian)和小端字节序(liitle-endian)形式之间可以通过此指令进行互相转换

XADD 指令

XADD 指令用于交换两个寄存器或者寄存器与内存数据的值,并且把两个数相加,结果存放至目标操作数中(内存位置或者寄存器)。XADD 的限制条件与 XCHG 类似,这里不再赘述

CMPXCHG 指令

CMPXCHG 比较 EAX(AX、AL)寄存器和目标操作数,若相等,就把源操作数加载至目标操作数中。若不相等,就把目标操作数加载至 EAX(AX、AL)寄存器中。此指令无法在 80486 之前的处理器上使用

CMPXCHG8B 指令

CMPXCHG8B 指令与 CMPXCHG 指令类似,区别在于 CMPXCHG8B 指令处理 8 字节值(故末尾是 8B),它只有一个操作数 —— 目标操作数。

目标操作数会作为指针,来引用一个内存位置,其中的 8 字节值会与 EDX:EAX 寄存器的值进行比较。若目标内存的值与 EDX:EAX 匹配,就把 ECX:EBX 寄存器的值送至该目标内存位置。否则,就将目标内存的值加载至 EDX:EAX 寄存器中

实现冒泡排序

数据交换的典型例程就是冒泡排序,对整数乱序数组进行从小到大的排序,使用汇编语言编写 bubble.s 例程如下

# bubble.s - 使用 XCHG 指令实现冒泡排序
.section .data
values:
    .int 105, 235, 61, 315, 134, 221, 53, 145, 117, 5
.section .text
.globl _start
_start:
    movl $values, %esi
    movl $9, %ecx
    movl $9, %ebx
_loop:
    movl (%esi), %eax
    cmp %eax, 4(%esi)
    jge skip
    xchg %eax, 4(%esi)
    movl %eax, (%esi)
skip:
    add $4, %esi
    dec %ebx
    jnz loop
    dec %ecx
    jz end
    movl $values, %esi
    movl %ecx, %ebx
    jmp loop
end:
    movl $1, %eax
    movl $0, %ebx
    int $0x80

调试运行结果

堆栈

堆栈是内存中用于存放数据的专用保留区域。它的特殊之处就是数据插入堆栈区域和从堆栈区域删除数据的方式

堆栈的行为刚好相反,它被保留在内存区域的末尾位置(高地址),当往堆栈中填充数据时,它往下增长。并且对于数据操作而言,保持后进先出(LIFO)的原则

数据的压入与弹出

将新的数据存到堆栈称为压入(pushing),将数据从堆栈中取出称为弹出(popping)

PUSH 指令

PUSH 指令用于将目标操作数压入堆栈,它只有一个目标操作数,操作数既可以是立即数、内存值、段寄存器、通用寄存器

同样地,PUSHx 中的这个 “ x ” 表示数据的宽度,这一点与 MOV 类似,此处不再赘述

POP 指令

与 PUSH 相反,POP 指令的作用是从堆栈中弹出数据,并存放至目标操作数中(目标操作数不能是立即数)。同样,POPx 中有关 AT&T 语法中的 “x”,表示数据的宽度

保护 / 还原寄存器现场

有一类附加的 PUSH / POP 指令,用于快速保存和还原所有通用寄存器、EFLAGS 寄存器的状态,如下表所示

PUSHA / POPA压入或者弹出所有 16 位通用寄存器
PUSHAD / POPAD压入或者弹出所有 32 位通用寄存器
PUSHF / POPF压入或者弹出 EFLAGS 寄存器的低 16 位
PUSHFD / POPFD压入或者弹出 EFLAGS 寄存器的全部 32 位

PUSHA 和 PUSHAD 按照 DI(EDI),SI(ESI),BP(EBP),BX(EBX),DX(EDX),CX(ECX),AX(EAX) 的顺序压入堆栈

POPA 和 POPAD 则弹栈,并按照与压入过程相反的顺序获得数据

POPF 和 POPFD 指令的行为与处理器操作模式相关。当处理器运行在 ring0(特权模式)下时,EFLAGS 寄存器的所有非保留标志位都可以被修改(除了 VIP、VIF 和 VM 标志位),VIP 和 VIF 会被清零,VM 则不会被修改。当处理器运行在更高 ring 时,会得到与 ring0 模式下相同结果,但是不允许修改 IOFL 字段

内存访问优化

众所周知,寄存器访问速度比内存快得多,一般来说,尽量降低内存访问的次数,把变量保存在寄存器中,这将提升程序性能。但是,很多时候数据不可能全部都被保存在寄存器中,应当试图优化程序的内存访问。对于使用数据缓存的处理器而言,在内存中按照连续的顺序访问内存能够帮助提高缓存命中率,因为内存块会被一次性读进缓存中(如果程序之后想要处理的数据刚好在缓存中,就不需要额外再访问内存了)

大多数处理器(包括 IA-32)都被优化为从数据段开始位置,在特定缓存块中读取和写入内存位置。在 P4 处理器中,缓存块长度是 64 位,若定义的数据元素超过 64 位块的边界,那就必须使用两次缓存操作

为了解决问题,Intel 建议定义数据时遵循下面规则

  • 按照 16 字节边界对齐 16 位数据
  • 对齐 32 位数据,使其基址是 4 的倍数
  • 对齐 64 位数据,使其基址是 8 的倍数
  • 避免小的数据传输。换为使用单一的大型数据传输(尽量避免碎片化读写)
  • 避免在堆栈中使用大的数据长度(比如 80 位或者 128 位浮点数)

GNU 汇编器可以使用 .align 命令对数据进行对齐操作,它紧贴在数据定义前面,它指示汇编器按照内存边界安置数据元素

三月 22

AT&T 汇编语言 (二) : 程序组成

程序的组成

汇编语言程序由定义好的段构成,每个段都有不同的目的,三个最常用的段是

  • data 段(.data):声明带有初始值的数据元素,一般作为汇编语言的变量
  • bss 段(.bss):声明使用 0 值初始化的数据元素,一般用于汇编语言程序的缓冲区
  • text 段(.text):声明可执行程序内的指令码

bss 段也可以说是用来声明未被初始化的数据元素的段,因为所谓 “ 未被初始化 ”(uninitialized),在此处换句话说就是默认填充为 0 (zero-initialized)

定义段

GNU 汇编器使用 .section 语句声明段,此语句需要一个参数来声明该段的类型。比如对上述常用的三个段分别进行声明,代码如下所示

.section .data
.section .bss
.section .text

注意:一般来说,bss 段总是应该安排在 text 段之前,但是 data 段可以安排在 text 段之后。为了源代码良好的可阅读性,将所有数据集中定义在开头是很好的习惯

定义起始点

对于链接器而言,它必须清楚指令码的起始点在哪里。就像 C 语言程序在链接时一般情况下也需要定义 main 函数。

_start 标签

为了解决这个问题,GNU 汇编器需要程序员声明一个 _start 标签(标识符),来指定程序的起始点位置。_start 标签表明程序将从此处开始运行,若未指定则会报告警告,并且链接器会试图查找并且自行断定起始点,但不能保证它对所有程序都做出正确的判断

自定义起始点

也可以使用 _start 之外的标签作为起始点,只需使用链接器的 -e 参数指定起始点的名称

.globl 命令

除了在程序内部声明起始标签之外,还需要为外部程序提供入口点(entry point)。它将提供程序被外部的汇编语言程序或者 C 语言程序使用的一种途径

.globl 命令(注意不是 .global)用来声明外部程序可以访问的程序的标签(这里可以理解为类似 public 那样的东西)

程序的基础模板

有了以上的程序结构知识,其实很容易联想到程序的大致蓝图是什么样子的,这里用少量的代码和文字来描述

.section .data
    这里是需要赋初值的数据

.section .bss
    这里是要被0填充的数据

.section .text
.globl _start
_start:
    这里是程序的指令代码

第一个程序(cpuid.s)

现在可以演示一下如何编写一个简单的汇编语言程序,这将围绕着它的整个构建流程一步一步展开

编写代码

CPUID 指令概述

CPUID 指令是一条汇编语言指令,不容易从高级语言的层面执行它。CPUID 指令的作用是请求处理器的特定信息并把信息返回到特定寄存器中

CPUID 指令生成的信息类型取决于 EAX 寄存器。根据 EAX 寄存器的值,CPUID 指令在 EBX、ECX 和 EDX 寄存器中生成关于处理器的不同信息,信息以一系列位值、标志的形式返回。CPUID 不同输出的选项如下

0厂商 ID(Vendor ID)字符串和支持的最大 CPUID 选项值
1处理器类型、系列、型号和分步信息
2处理器缓存配置
3处理器序列号
4缓存配置(线程数量、核心数量和物理属性)
5监视信息
80000000h扩展的厂商 ID 字符串和支持的级别
80000001h扩展的处理器类型、系列、型号和分步信息
80000002h~80000004h扩展的处理器名称字符串

获取厂商信息

下文的程序将从处理器获得简单的厂商 ID(Vendor ID)字符串。当 EAX 寄存器的值为 0 时,执行 CPUID 指令,处理器将把厂商 ID 分成三个部分返回到 EBX、EDX 和 ECX 寄存器中,并以小端字节序(little-endian)的形式存放于寄存器中

  • EBX 包含字符串的最低 4 个字节
  • EDX 包含字符串的中间 4 个字节
  • ECX 包含字符串的最高 4 个字节

程序正文

编写汇编语言源程序 cpuid.s,该程序利用 CPUID 指令获取 CPU 厂商 ID(Vendor ID)并显示在终端上

# cpuid.s 一个获取CPU厂商ID的示例程序
.section .data
output:
   .ascii "The processor Vendor ID is 'xxxxxxxxxxxx'\n"
.section .text
.globl _start
_start:
    movl $0, %eax
    cpuid
    movl $output, %edi
    movl %ebx, 28(%edi)
    movl %edx, 32(%edi)
    movl %ecx, 36(%edi)
    movl $4, %eax
    movl $1, %ebx
    movl $output, %ecx
    movl $42, %edx
    int $0x80
    movl $1, %eax
    movl $0, %ebx
    int $0x80

代码分析

第 3~4 行使用 .ascii 语句声明了一个文本字符串,字符串元素被预定义并且存放在内存中,并以 output 标签指示。字符串中的字符 ‘ x ’ 作为占位符,将在程序运行过程中被逐个替换为 Vendor ID 字符串中的字符

从第 8 行开始是程序的指令代码部分,第 8 行使 EAX 寄存器加载 0 值,并在第 9 行调用 CPUID 指令。这时,Vendor ID 分散地存放在 EBX、EDX 和 ECX 中

第 10 行是使 EDI 寄存器指向 output 字符串的开头位置

第 11~13 行,分别从 EBX、EDX 和 ECX 中取出 4 个字节的数据,并存放至 EDI (现在指向字符串开头位置)寄存器指示的内存地址的第 28 字节开始、第 32 字节开始和第 36 字节开始的三个偏移位置。圆括号的作用相当于“从寄存器中取地址”,而加在圆括号之前的数字即是偏移量,两者相加最终确定内存位置

第 14~18 行,这 5 行内容是一个申请系统调用的过程,作用是将字符串显示在终端上面

Linux 内核提供了很多可以从汇编语言层面访问的预置函数,为了访问这些函数,就需要使用 int 指令。int 0x80 这条指令将生成具有 0x80 值的软件中断,执行的具体函数由 EAX 寄存器的值决定。若没有这个内核函数,程序员就必须亲自将每个输出的字符发送到正确的显示器终端的 I/O 地址。

其中,第 14 ~ 17 行是设置正确的寄存器值,也就是设置正确的内核函数(也叫做系统调用)的参数

EAX 指示系统调用的具体函数,此例中 EAX 的值是 4,即 write 系统调用(具体可以查阅 Linux 内核函数的文档手册)。于是,EBX、ECX 和 EDX 的作用也能从手册中查阅得到

  • EBX 包含要写入的文件描述符(file descriptor)
  • ECX 包含字符串的起始地址
  • EDX 包含字符串的长度

在此程序中,EBX 被设置成 1,即表示标准输出(stdout),学习过 C 语言的应该很能理解。ECX 刚好也被设置成 output 字符串的起始地址。EDX 被设置成 42,也就是字符串长度。设置好参数之后,执行 int 0x80 指令,字符串随后就会被系统调用显示在终端上

第 19~21 行也是一个申请系统调用的过程,EAX 寄存器的值为 1,表示 exit 系统调用,即退出程序。EBX 值设置为 0 表示程序是正常退出的(可以暂且类比成 C 语言中的 “ return 0; ” 语句)

到此程序运行结束

构建可执行文件

编写并保存好汇编语言文件 cpuid.s 之后,现在就可以使用 GNU 汇编器和 GNU 链接器构建可执行文件,在终端上运行以下命令

as -o cpuid.o cpuid.s
ld -o cpuid cpuid.o

这些汇编、链接过程准确无误后,将会在目录下生成可执行文件 cpuid

运行程序

在终端上运行链接好的可执行文件

./cpuid

在笔者的机器下,Vendor ID 如图所示

关于 GCC

GNU 通用编译器(GNU Common Compiler,gcc)使用 GNU 汇编器编译 C 代码。同样也可以直接一步到位地对汇编语言程序进行编译、链接。

这里有一个问题,就是 gcc 确定程序起始点时查找的不是 _start 标签,而是 main 标签(正如 C 语言 main 函数一样)

所以在直接使用 gcc 对当前的汇编语言程序进行汇编的时候,需要将_start 标签附近的代码改成如下这样

....
.section .text
.globl main
main:
....

这时,就可以使用 gcc 对本程序进行汇编(一步到位)

gcc -o cpuid cpuid.s

第二个程序(cpuid2.s)

对于前面编写的 CPUID 程序而言,如果要求不直接使用系统调用,而是使用 C 语言的 printf 之类的函数。而且要是在工程上需要从汇编语言的层面对 C 语言程序做联调,该怎么办呢?

编写代码

前面编写的 cpuid.s 程序使用 Linux 系统调用显示程序结果。如果系统上安装了 GNU C 编译器,那么就可以使用早已熟悉的通用 C 函数

可以在 write 系统调用和 exit 系统调用的基础上对 cpuid.s 程序进行修改,使用 C 库中的 printf 函数和 exit 函数来实现几乎等价的程序功能

程序正文

# cpuid2.s 一个获取CPU厂商ID的示例程序(使用C库函数)
.section .data
output:
    .asciz "The processor Vendor ID is '%s'\n"
.section .bss
    .lcomm buffer, 12
.section .text
.globl _start
_start:
    movl $0, %eax
    cpuid
    movl $buffer, %edi
    movl %ebx, (%edi)
    movl %edx, 4(%edi)
    movl %ecx, 8(%edi)
    pushl $buffer
    pushl $output
    call printf
    addl $8, %esp
    pushl $0
    call exit

关于 “ invalid instruction suffix for `push’ ” 的错误,若是 64 位系统上编译,那么可以在 “ .section .text ” 之后插入一行 “ .code32 ”,并且在后文跟笔者同样需要使用 32 位的编译方式

代码分析

第 3~4 行是声明文本字符串,跟第一个程序类似。不同的是,这里是使用了 .asciz 语句进行声明的, ‘ z ’ 是 zero 的缩写,这表示预定义的字符串将以字节 0 结尾(因为这里要兼容 printf 函数,需要 C 风格的字符串)

第 5~6 行声明 bss 段,这个 .lcomm 语句接受两个参数,第一个是标签,用于识别该内存起始位置,第二个是缓冲区大小,以字节为单位。.lcomm 语句声明局部(local)通用(common)内存缓冲区,可以理解为类似于 C 语言的 static 一样,它不会占用可执行文件的体积,而且它是局部的,不能被其他模块访问。与 .lcomm 一样,还有一个 .comm 语句,只不过 .comm 语句是全局的,可以被其他模块访问(具体的细节笔者并不是特别深入地了解过,如果有看法,可以在评论区留言)。这里预定义了 12 个字节的缓冲区,用来存放 Vendor ID

在书上看到这里我有一个疑惑,也是笔者的愚见。虽说 bss 区段是以 0 初始化的(zero-initialized),printf 也确实需要接收 C 风格的字符串(以 0 结尾),我个人认为,为了内存安全起见,这里应该定义为 13 个字节(最后一个字节初始化成 0,保证字符串正确闭合)而不是 12 个字节。当然,我目前也没有了解过链接器是如何处理 bss 段的,加载到内存中的哪个位置?是否能从另一方面保证字符串的内存安全性呢?

中间一段代码与之前类似,执行 CPUID 指令,并取出返回结果存放到 buffer 缓冲区中

第 16~18 行,根据 C 函数传递参数的一般规律,参数应该从右到左依次压栈。比如函数 f (1, 2, 3),用汇编来描述就是 push 3, push 2, push 1, call f。这里,第 16 行先将 buffer 压栈,这是 printf 的第二个参数。第 17 行再对 output 压栈,这是 printf 的第一个参数,最后执行 call 指令调用 printf。这三步一起共同完成 printf (output, buffer) 的动作

第 19 行的作用是将 ESP 寄存器拉高 8 字节,因为之前传的参数是两个参数,也就是 8 个字节的参数,这样才能清栈并保持堆栈平衡。也许你并不是特别清楚这其中的机制,可以参考我一年多以前在博客园(现在已迁移至本博客)写的两篇文章,如果有疑问也可以留言。

第 20~21 行主要是调用 C 库中的 exit 函数,调用 exit 函数以使得程序退出

到此程序运行结束

链接 C 库

在汇编语言程序中使用 C 库函数时,必须把 C 库文件与该程序的目标文件链接起来,如果 C 库不可用,那么链接器就会报告类似 “找不到符号” 的错误

在 Linux 系统上,将 C 函数链接到汇编语言程序有两种方式,即静态链接(static linking)和动态链接(dynamic linking),关于它们的区别这里不再赘述

进行动态链接

在 Linux 系统上,标准的 C 动态库位于 libc.so.N 文件中(其中 N 的值代表库的版本),这个库中包含标准 C 函数,比如这里用到的 printf 和 exit 函数

GNU 链接器提供 -l 参数,用于指定链接到的动态库。用法很简单,形如 /lib/libX.so 的库文件,只需使用 -lX 参数即可指定(此处的 X 代表库的名称)

在这里我们需要链接 libc.so.N (在我机器上 N 是 6),所以只需要对 GNU 链接器使用 -lc 参数。但是至此我们仅仅只是使得链接器能够解析 C 函数(或者说从库中获得符号信息),但是我们的程序并不包含函数本体的代码(当然我们这里也不是静态链接),所以至少也需要指定在运行环境下从何处加载动态库(下面将使用动态加载器自动加载动态库)。

这里可以这样理解。对于动态链接而言,程序在两个时刻需要考虑库的问题。

第一个就是链接时刻(通常是在开发机上):链接时刻需要从库中获取符号信息,因为链接器需要这些信息,才能生成可执行文件

第二个就是运行时刻(通常是在生产机上):运行时刻,可执行文件当然是已经编译好的,它自身也包含库的相关信息,这些信息像地址一样告知程序库在文件系统的位置,但如果运行时刻,库不存在或者其它问题,即便有地址信息,但是找不到这个库了(人去楼空),于是运行时刻报错 “ 找不到文件 ”

我这里表述为了形象性,也许有失偏颇。但是如果你不甚理解,那么可以将就一下暂且这么去理解它

运行时刻找库?我可以自动帮你找!—— 动态加载器

GNU 链接器提供一个程序 —— 动态加载器,它通常是 /lib 目录下的 ld-linux.so.2。可以在链接时使用 -dynamic-linker 参数指定动态加载器的位置。它将在运行时刻帮助程序定位到动态库文件

ld -dynamic-linker /lib/ld-linux.so.2 -o cpuid2 -lc cpuid2.o

就像上面笔者阐述的一样。其中 -dynamic-linker /lib/ld-linux.so.2 是针对运行时刻的,而 -lc 则是针对链接时刻的。这样,可执行文件才能成功生成,并正常运行

运行程序

现在我们将汇编、链接和运行综合执行一遍,如图所示。因为笔者是 64 位操作系统,库默认是 64 位的,所以在执行 as 和 ld 命令上做了一些强制指定

笔者对 GNU 汇编器生成的目标代码的架构做了指定(–32

同时也对 GNU 链接器 32 位的链接库目录做了指定(-L/usr/lib32),动态加载器也必须是 32 位的(-dynamic-linker /usr/lib32/ld-linux.so.2),生成的可执行文件的架构同样也得设置成 32 位 ELF 文件(-melf_i386)。

当然,也可以把指令代码改成 64 位的

# cpuid2.s 一个获取CPU厂商ID的示例程序(使用C库函数)
.section .data
output:
    .asciz "The processor Vendor ID is '%s'\n"
.section .bss
    .lcomm buffer, 12
.section .text
.globl _start
_start:
    movq $0, %rax
    cpuid
    movq $buffer, %rdi
    movl %ebx, (%rdi)
    movl %edx, 4(%rdi)
    movl %ecx, 8(%rdi)
    movq $buffer, %rsi
    movq $output, %rdi
    callq printf
    xorq %rdi, %rdi
    callq exit
三月 22

AT&T 汇编语言 (一) : 软件包概述

GNU Binutils 软件包

GNU Binutils 软件包包含以下程序(软件包版本为 2.15)

addr2line把地址转换为文件名和行号
ar创建、修改和展开文件存档
as把汇编语言代码汇编为目标代码
c++filt还原 C++ 符号的过滤器
gprof显示程序简档信息的程序
ld把目标代码文件转换为可执行文件的连接器
nlmconv把目标代码转换为 Netware Loadable Module 格式
nm列出目标文件中的符号
objcopy复制和翻译目标文件
objdump显示来自目标文件的信息
ranlib生成存档文件内容的索引
readelf按照 ELF 格式显示来自目标文件的信息
size列出目标文件或者存档文件的段长度
strings显示目标文件中可打印字符串
strip丢弃符号
windres编译 Microsoft Windows 资源文件

GNU 汇编器(as)

GNU 汇编器(gas)是 UNIX 环境下最流行的汇编器

简单用法

使用以下命令,可以将汇编语言源程序 test.s 转换为目标文件 test.o

as -o test.o test.s

GNU 链接器(ld)

GNU 链接器 ld 用于把目标代码文件链接为可执行程序文件或者库文件

简单用法

使用以下命令,可以从目标文件 test.o 创建可执行文件 test

ld -o test test.o

GNU 调试器(gdb)

GNU 调试器程序名称为 gdb。要想使用对可执行文件使用调试器,就必须先前使用 -gstabs 选项编译(汇编)可执行文件,以便调试器知道指令码与源代码文件中什么位置是相关联的

关于 -gstabs 选项,man 英文手册中有关 as 汇编器的内容原文如下

-g
       --gen-debug
           Generate debugging information for each assembler source line using whichever debug format is preferred by the target.  This currently means either STABS, ECOFF or DWARF2.

       --gstabs
           Generate stabs debugging information for each assembler line.  This may help debugging assembler code, if the debugger can handle it.

       --gstabs+
           Generate stabs debugging information for each assembler line, with GNU extensions that probably only gdb can handle, and that could make other debuggers crash or refuse to read your program. This may help debugging assembler code.  Currently the only GNU extension is the location of the current working directory at assembling time.

GDB 常用调试命令

以下是使用 gdb 命令对可执行文件 test 运行调试器的效果

运行 gdb 调试器后,在 gdb 命令提示下,可以输入调试命令,这里列出比较常用的命令

break在源代码中设置断点以便断下
(标号前加 * 号,比如 break *_start)
watch设置监视点,当变量变为特定值时断下
info观察寄存器、堆栈和内存
x检查内存位置
格式:x/nyz
n:要显示字段数
y:c 表示字符,d 表示十进制,x 表示十六进制
z:b 表示字节,h 表示双字节,w 表示四字节
print显示变量的值
格式:print/a
a:d 以十进制显示,t 二进制,x 十六进制
run在调试器内开始程序的执行
list列出指定的函数或者行
next执行程序中的下一条指令(与 step 等价)
step执行程序中的下一条指令(与 next 等价)
cont从断下的位置继续执行程序
until运行直到到达指定的源代码行

GNU 目标转储程序(objdump)

GNU objdump 程序是 binutils 软件包中十分有用的工具。程序员经常需要查看目标文件中由把编译器生成的指令码。objdump 既能显示汇编语言代码又能显示生成的原始指令码

简单用法

objdump 程序的 -d 选项可以将目标代码反汇编为指令码

GNU 简档器(gprof)

GNU 简档器(gprof)用于分析程序的执行和确定应用程序中的“热点”在什么位置。

应用程序的“热点”是程序运行时需要最多处理时间的函数。通常,它们是数学密集型的函数,但是情况不总是如此。I/O 密集型的函数也会增加处理时间。

对一个可执行文件的热点分析步骤有三步:

  1. 使用 -pg 选项编译源程序
  2. 运行该程序以生成 gmon.out 调用图表
  3. 对该程序运行 gprof 输出分析报告

这里有一个例程,包含两个函数

#include <stdio.h>

void fun1() {
    int i, j;
    for (i = 0; i < 100000; i++) 
        j += i;
}

void fun2() {
    int i, j;
    fun1();
    for (i = 0; i < 200000; i++) 
        j = i;
}

int main() {
    int i, j;
    for (i = 0; i < 100; i++)
        fun1();
    for (i = 0; i < 50000; i++)
        fun2();
    return 0;
}

执行结果如图

五月 28

x86 保护模式浅析(三)

工具准备

  • NASM 汇编器,以及一个文本编辑器
  • VirtualBox 虚拟机(或者 Bochs 也可以)

开始实战

进入保护模式的汇编代码

gdt_base equ 7e00h  ; gdt_base 表示 GDT 头部的地址

; 这一段代码主要是将 GDT 的头部地址,转换为 “段:偏移” 的形式,在实模式下的寻址
mov ax, gdt_base
xor dx, dx
mov bx, 10h
div bx
mov ds, ax
mov bx, dx

; 构造 GDT(系统约定,第一个选择子要用 0 填充)
mov dword [ds:bx], 0         ; 填为 0
mov dword [ds:bx + 04h], 0   ; 填为 0

; 代码段选择子(设置属性:可执行,可读,ring0 特权级执行)
mov dword [ds:bx + 08h], 7c0001ffh
mov dword [ds:bx + 0Ch], 00409A00h

; 数据段选择子(设置属性:不可执行,可写,地址向上增长)
mov dword [ds:bx + 10h], 8000ffffh
mov dword [ds:bx + 14h], 0040920Bh

; 堆栈段选择子(设置属性:不可执行,可写,地址向下增长)
mov dword [ds:bx + 18h], 00007A00h
mov dword [ds:bx + 1Ch], 00409600h

; 对向量的低字,写入 GDT 界限(GDT 总字节数 - 1)
mov word [cs:gdt_vector], 20h - 1

; 对向量的高双字,写入 GDT 的线性地址
mov dword [cs:gdt_vector + 2], gdt_base

; 启用 A20 地址线
in al, 92h
or al, 2
out 92h, al

; 关闭中断
cli

; 加载 GDTR 寄存器
lgdt [cs:gdt_vector]

; 设置 PE 位,使 CPU 进入保护模式
mov eax, cr0
or al, 1
mov cr0, eax

; 命运的跳转,清空流水线,并串行化地执行跳转后的机器代码
jmp 8h:flush

[bits 32]
flush:	
	mov ax, 10h  ; 设置段选择子(这里设置的数据段即是显存区域)
	mov ds, ax

    ; 通过段选择子,直接访问内存,向屏幕第一行第一列打印红色的 'P'
	mov byte [ds:0], 'P'
	mov byte [ds:1], 0Ch
	
    ; 死循环(进入保护模式就大功告成了,现在什么也不做,让 CPU 空转)
	jmp $

; GDT 向量,低字表示 GDT 界限,高双字表示 GDT 线性地址
gdt_vector dw 0
           dd 0

times 510 - ($ - $$) db 0  ; 填充剩余扇区空间
db 55h, 0aah  ; 可引导扇区标识符

使用汇编器进行汇编

NASM 汇编器的目录位置如果被添加到 PATH 环境变量中去了,就可以直接在命令提示符中使用。笔者这里源文件叫做 mbr.asm,运行如下命令将在当前目录下生成一个 mbr.bin 文件,这就是生成的机器码文件

nasm -o mbr.bin mbr.asm

写 VHD 虚拟磁盘

如果你需要将引导程序写入虚拟磁盘中,最好使用 VHD 格式的固定大小的磁盘,因为笔者写了一个命令行工具 —— Fixed VHD Writer 放在了 Github 上。它可以使用 LBA 的方式对固定大小的 VHD 磁盘进行写扇区的操作,如果要使用笔者的工具,在创建虚拟机时,使用固定大小的 VHD 格式虚拟磁盘是必要的

Fixed VHD Writer
[-h] 显示帮助
[-r] 指定读取的文件
[-w] 指定写入的固定大小的 VHD 磁盘
[-a] 指定 LBA 扇区编号
Fixed VHD Writer 使用示范

使用笔者的工具,将 mbr.bin 写入固定大小的 VHD 磁盘的 0 扇区,写入工具输出了一些信息,写入字节数、写入扇区数等

启动虚拟机

现在可以启动虚拟机了,如果一切顺利,那么你将看到虚拟机屏幕开头会显示出一个红色的 ‘P’ 字符,这表示 CPU 成功进入保护模式,大功告成!

进入保护模式成功,红色的 ‘P’ 字符

结语

基本上,进入保护模式可以简单地划分为 5 个步骤:

  • 设置 A20 地址线(本篇采用 Fast A20 Gate 方式)
  • 关闭中断
  • 加载 GDT 到 GDTR 寄存器
  • 设置 CR0 控制寄存器的 PE 位
  • 命运的跳转(清空流水线)

至于 GDT 的数据结构、GDT 向量,其实都只是起到描述作用。读者可以先不看笔者的代码自己实现一下,这样可以加深印象,真正地理解并且吃透

参考资料

[1] 李忠 , 王晓波 . x86汇编语言:从实模式到保护模式 [M] . 北京 : 电子工业出版社 , 2013.

五月 28

x86 保护模式浅析(二)

走进保护模式

A20 地址线

在本文中,要完全解释 A20 地址线的作用以及原理是不太现实的。对于我们程序员而言,我们只需要了解一些大致的概念。简单来说,因为考虑到向下兼容性,A20 地址线在默认情况下是禁用的。于是,我们得启用它,这样我们就可以访问全部内存,这对于 CPU 在保护模式下正常工作是十分重要的

至于如何启用 A20 地址线,OSDev 上面列举了很多方法,可以参考链接:https://wiki.osdev.org/A20
这里,我们列举比较简单的一种方法,在大多数情况下是可正常使用的(对于一些老主板,需要主板CMOS设置中启用快速 A20 支持 )

  • 快速 A20 门(Fast A20 Gate)
in al, 92h    ; 从 92h 端口读一字节
or al, 2      ; 置字节的第 2 位为 1
out 92h, al   ; 写回 92h 端口

上述的 Fast A20 Gate 方式只需要 3 条指令就可以启用 A20 地址线,当执行完这 3 条指令后,CPU 就有能力访问全部内存了

关闭中断(包括 NMI 在内)

保护模式下,CPU 的中断机制与实模式是不相同的。BIOS 中断、中断向量表都不再适用于保护模式,在进入保护模式之前,需要先使用 CLI 指令以关闭中断

cli    ; 关闭中断

设置 GDTR 寄存器

在上一篇文章中,我们讲到 GDT、段选择子的概念,在进入保护模式前,首先要正确地设置 GDTR 寄存器,确保 GDT 的设置是有效的

lgdt m48    ; 48 位(6字节)向量,低字表示 GDT 界限(字节大小减1),高双字表示 GDT 头部的线性地址

设置控制寄存器的PE位

CPU 内部的 0 号控制寄存器(CR0)中的最低位即是PE(Protection Enable,保护生效)位,将其置为 1 时,CPU 将工作在保护模式下,汇编代码可以像下面这样写

mov eax, cr0
or al, 1      ; 置 PE 位为 1
mov cr0, eax

命运的跳转(jmp dword 方式)

jmp dword 段选择子偏移:跳转到的地址

其中,段选择子偏移就是对应段选择子(一般是代码段选择子)相对于GDT头部的偏移字节数,而跳转到的地址,即是在段选择子的段界限内的地址。因为 PE 位的设置,现在 CPU 在保护模式下,所以得用段选择子和线性地址的方式去访问内存

至于为什么要 jmp dword,是因为现在 CPU 还在 16 位机器代码上执行跳转,但是跳转的地址却是用 32 位保护模式的方式寻址的。jmp dword 能够使 CPU 的流水线清空,并串行化执行跳转后的 32 位机器代码,这样 CPU 就能在保护模式下正常运行。

后续

光说不练假把式,也许你还是只有一些笼统的概念。下一篇文章,笔者将用整个实例来演示并穿插解释一段引导扇区程序是如何一步步进入保护模式的。感谢阅读本文!

五月 28

x86 保护模式浅析(一)

实模式下的内存访问

实模式下,CPU通过段寄存器(16位)和偏移地址(16位)来访问物理地址,这个过程可以抽象地简述成以下步骤:
1、将段寄存器中存放的地址,左移4位(乘以2的4次方,即乘以16)得到基址(为了方便理解暂且这么叫吧)
2、将上一步得到的基址加上偏移地址,得到真实的物理地址

在上面的步骤中,我们不难看出的这几点特性:

  • 在实模式下,当段寄存器为 0 时,物理地址所能表示的最大范围是 0 ~ FFFFF,即 1MB 连续内存空间
  • 在实模式下,无论段寄存器为何值(无论基址为何值),CPU 总是只能访问 1MB 的连续内存空间
  • 在实模式下,段寄存器的作用更像是确定一个访问起点,偏移地址是用于基于这个起点开始计算距离,最终确定内存位置

初探 GDT

x86 保护模式使得 CPU 不再局限于 1MB 的内存空间,使其寻址能力达到了 4GB(后文会解释原因),同时它提供了更多优良的特性。废话少说,我们可以先来了解一下以下概念。

段选择子(Selector)

要进入保护模式,CPU 必须要设置一些必要的参数,这个工作由程序员完成。程序员通过设置全局描述符表(简称 GDT),使得 CPU 从中获取必要的参数,这样 CPU 才能顺利进入保护模式。全局描述符表(GDT)一般由多个段选择子(Selector)组成。下面笔者使用类 C 伪代码表示单个段选择子的相关结构,并给出解释

  • 单个全局描述符表的表项(即段选择子)的数据结构(从低字节到高字节排列):
struct gdt_entry {    // 单个段选择子,也就是全局描述符表的每一个表项的结构
    WORD low_limit;        // 段界限的  0 ~ 15 位
    WORD low_base;         // 段基址的  0 ~ 15 位
    BYTE high_base;        // 段基址的 16 ~ 23 位
    BYTE access_byte;      // 访问控制(一字节)(下文有展开说明)
    HALF_BYTE high_limit;  // 段界限的 16 ~ 19 位(段界限共20位)
    HALF_BYTE flags;       // 标志(半字节)(下文有展开说明)
    BYTE high_high_base;   // 段基址的 24 ~ 31 位(段基址共32位)
}selector; // 段选择子
  • 访问控制字节(access_byte)(从低位到高位排列)
BYTE access_byte {  // 没办法,理解性地,暂且这么定义吧
    _bit AC;  // 被访问标志位:一般置为 0,当该段被访问时,CPU将其置为 1
    _bit RW;  // 读写标志位:若是代码段,则此位表示该段是否可读;若是数据段,则此位表示该段是否可写
    _bit DC;  // 方向/顺应标志位:若是代码段,则此位表示是否可以从相等或较低的特权级开始执行(否则只能被“特权级标志”中描述的特权级所执行);若是数据段,则此位表示该段是否是向下(向低地址方向)增长的
    _bit Ex;  // 可执行标志位:此位表示该段是否可被执行,若置 0 则是数据段(不可执行)
    _bit S;   // 描述符类型标志位:置 1 表示该段是代码段或数据段,置 0 表示该段是系统段(暂时先置 1 吧)
    _double_bit Privl; // 特权级标志双位:双位可置 0 ~ 3,表示该段所具有特权级(ring0 ~ ring3)
    _bit Pr;  // 存在标志位:应当永远置 1,表示该选择子(Selector)有效
};
  • 标志位半字节(flags)(从低位到高位排列)
HALF_BYTE flags {  // 没办法,理解性地,暂且这么定义吧
    _bit zero;  // 一般置为 0
    _bit zero;  // 一般置为 0
    _bit Sz;    // 宽度标志位:此位表示选择子是否定义的是 32 位保护模式(置 0 表示选择子定义了 16 位保护模式)
    _bit Gr;    // 粒度标志位:此位置 1 表示段界限粒度为页(4KB),置 0 表示段界限粒度为字节(1Byte)
};

在保护模式下,段已经不同于实模式中的段。实际上,更多地是访问方式、起到的作用发生了改变。这可能需要读者自己去循序渐进地体会。下文将会讲到全局描述符表的结构以及加载方式,段选择子的使用方式

全局描述符表(Global Descriptor Table)

我们从前述中得知,段选择子就是全局描述符表的表项,也就是说 GDT 的每一个表项都是 8 字节的段选择子。段选择子中定义了段的基址、段的界限、还有一些描述性的信息。因为系统中一般将用到多个段,所以用线性的方式去存放这些段的选择子,于是构成了 GDT。我们可以先用类 C 的伪代码来定义 GDT 的数据结构

  • GDT(全局描述符表)(从低字节到高字节)
struct gdt {
    gdt_entry selector_null;  // 第 1 个选择子(全局描述符表项)(填充为 0)
    gdt_entry selector1;      // 第 2 个选择子(全局描述符表项)
    gdt_entry selector2;      // 第 3 个选择子(全局描述符表项)
    /* ...... */
};

GDT 的一个段选择子(第一个表项),必须全部填充为 0,这是系统约定俗成

看到这里,你应该已经对 GDT 的结构豁然开朗了。下文我们将介绍如何加载 GDT,为初探 x86 保护模式的难点画上句号

加载 GDT

假设我们在内存的某个位置定义了一个包含多个段选择子的 GDT,它包含我们进入保护模式后,CPU 在保护模式正常运行的相关信息。在我们进入保护模式之前需要让 CPU 加载这个 GDT,现在,我们有两样法宝,一个是 LGDT 机器指令、另外一个是 GDTR 寄存器。

顾名思义,LGDT 指令能够将 GDT 载入到 GDTR 寄存器中,这样 CPU 就“得到”了在保护模式下正常运作的相关信息。问题是,具体该如何做到。我们先来了解 GDTR 寄存器的结构,如图所示

Size 即是GDT所占总字节数减 1 的值(边界值),Offset 即是 GDT 头部的线性地址(即绝对地址)

不难看出,GDT 中最多能容纳 0x10000 / 8 = 8192 个段选择子。话说回来,我们可以使用 LGDT 指令从指定内存位置读取这个 48 位(6字节)的 GDT 向量到 GDTR 寄存器中。这样,CPU 就将 GDT 的相关信息“牢记在心”

后续

下一篇文章,笔者将讲述如何为真正进入保护模式扫清障碍,有关 A20 地址线、PE 标志位的设置、以及段选择子的用法、保护模式下的内存寻址方式。感谢阅读本文!