编程语言程序设计

创建时间 2021-04-28
更新时间 2021-05-12

什么是汇编语言

处理器指令

IA-32 指令码格式由四部分组成:

  • 可选指令前缀
  • 指令码
  • 可选指令修改器
  • 可选数据元素

assembly-01.jpg

指令码

指令码是指令格式中必须提供的,指示处理器应该执行什么功能或者任务。

指令码的长度为 1 ~ 3 字节不等,例如 OF A2 表示了 cupid 指令,执行该指令时,处理器会把当前微处理器的信息存储到不同的寄存器。

指令前缀

指令前缀可以包含 1 ~ 4 个字节的信息,来修改指令码的行为。指令前缀可分为四类:

  • 锁前缀和重复前缀
  • 段重写前缀和分支提示前缀
  • 操作数大小前缀
  • 地址大小前缀

每一类在前缀中只能只能用一次来修改指令码,或者说描述指令码。

锁前缀指示任何共享内存区域将被该指令排他性的使用。这对于多处理器和混合多线程系统特别重要。

重复前缀用于指示重复的功能,通常用于操作字符串。

段重写前缀指示指令可以重写预定义的段寄存器。

分支提示前缀尝试给处理器一些线索,用于指示条件转义指令的时候,最可能跳转的分支,常用于分支预测硬件。

操作数大小前缀用于提示处理器,当前操作码是 16 位 还是 32 位的。可以帮助处理器使用大操作数,和加速数据赋值到寄存器。

地址大小签注提示处理器使用的是那种大小的内存地址,每种大小都可以被设置为程序的默认大小,前缀可用于临时修改该大小。

修改器

一些指令码需要额外的修改器来定义什么寄存器或者内存地址参与其中。修改器包含三个不同的值。

  • 地址格式指示字节
  • SIB 字节
  • 1,2 或 4 个地址位移字节

汇编语言

汇编语言程序由三部分来定义程序的操作:

  • 操作码助记符
  • 数据段
  • 汇编指令

操作码助记符

例如:指令码的样例:

55
89 E5
83 EC 08
C7 45 FC 01 00 00 00
83 EC 0C
6A 00
E8 D1 FE FF FF

可以重写成为:

push %ebp
mov %esp, %ebp
sub $0x8, %esp
movl $0x1, -4(%ebp)
sub $0xc, %esp
push $0x0
call 8048348

IA-32 平台

使用汇编语言只是为了在应用中探索处理器的底层特性。来理解程序怎样才能尽可能的高效。

控制单元

处理器的核心是控制单元,主要的目的是控制处理器中什么时候该做什么,处理器运行的时候指令必须从内存中加载到处理器中进行处理,那么控制单元有四个基础功能:

  • 从内存读取指令
  • 解码指令操作
  • 如果需要从内存读取数据
  • 如果需要将处理结果写入内存

指令指针指示下一个需要处理的指令,指令解码器用于将指令翻译成微指令,微指令用来控制处理器芯片上的信号,一般而言,微指令将输出寄存器的三态门控制端置为高电平,使得寄存器(内部一般为D边沿触发器)中的数据流入片内总线,另一方面微指令还控制另一寄存器的写控制端,使得总线上的数据写入另一个寄存器。

为了加速处理器的速度,现代处理器一般会有更加高级的手段,比如 Intel NetBurst 控制器技术,有如下的特性:

  • 预取指和译码
  • 分支预测
  • 乱序执行
  • 指令引退

预取指令和译码流水线

由于取指令需要操作内存,所以执行指令一般来说要比取指令快;这样的话就可能出现积压,所以预取指令就登场了。

为了支持预取指令,CPU中需要一个特殊的存储区域来存放取出的指令。可以让处理器简单而快速的访问数据,然后流水线就登场了。缓存(cache)的存取速度一般要比内存快得多。

流水线在CPU中创建了一撮内存的缓存,,里面存储了预先取出的指令和数据,当执行单元可以执行下一条指令的时候,流水线中已经准备好了指令,于是可以快速的执行。

IA-32 平台通过多级 cache 实现流水线,第一级缓存 (L1) 尝试预先从内存中取出指令和数据。

当然,一个问题是无法保证程序按照顺序执行指令,如果指令指针因逻辑分支转移到了其他的地方,那么整条流水线的数据就没用了,需要清空还需要重新注入流水线。

为了解决这个问题,第二级缓存(L2)登场,第二级缓存同样保存指令和数据,当逻辑跳转到另一个分支时,第二级缓存还保存着相关的指令和数据,如果程序逻辑再次跳转回原来的分支,那么第二级缓存就派上用场了。

尽管汇编语言无法访问缓存中的指令和数据,但直到他们是如何工作的同样有用。尽量减少分支的使用,可以帮助加速程序的执行速度。

分支预测单元

为了解决转移指令后的流水线重置,有了分支预测,也就是说尽可能地让流水线中分支命中,而避免清空流水线。

特殊的统计和分析算法,决定了哪个分支最可能被执行,然后在流水线中载入相关地指令和数据。

奔腾 4 处理器有以下几种方式实现分支预测:

  • 深分支预测
  • 动态数据流分析
  • 推测执行

深分支预测 让处理器可以尝试译码多个分支,同样,统计算法预测哪个分支最可能被执行,尽管这个技术很有用,但也不是万无一失的。

动态数据流分析 执行实时数据流分析,如果必要的话,指令将被乱序执行,任何指令都可能在等待数据的时候被执行。

推测执行 可以让处理器确定分支中较远的代码,然后尝试处理这些指令,同样使用乱序执行引擎。

乱序执行引擎

乱序执行引擎中有以下几部分:

  • 分配器
  • 寄存器重命名
  • 微操作调度器

分配器用来提前分配好缓存空间

寄存器重命名,分配逻辑寄存器代替通用寄存器。可以用于重命名的寄存器有 128 个。

位操作调度器,向退役单元(Retirement Unit) 发送微操作,在保持程序依赖的时候,微操作调度器使用两个对列,一个队列保存请求内存访问的微操作,一个队列保存没有访问内存的微操作。队列与分发端口相关联,不同类型的处理器可能包含不同的分发端口,分发端口向退役单元发送微操作。

退役单元(Retirement Unit)

退役单元保证乱序执行的指令处理的数据和正常执行的指令是一致的。

执行单元

一个处理器可能包含多个执行单元,来同时执行多个指令。

浮点执行单元包含 MMX 和 SSE 支持

寄存器

  • 通用寄存器
  • 段寄存器
  • 指令指针寄存器
  • 浮点数数据寄存器
  • 控制寄存器
  • 调试寄存器

通用寄存器

寄存器 描述
EAX 累加结果数据
EBX 数据段数据指针
ECX 字符串和循环计数器
EDX I/O 指针
EDI 目的数据指针
ESI 源数据指针
ESP 栈指针
EBP 栈数据指针

EAX / EBX / ECX / EDX 四个寄存器可以拆分成 16位,8位,来访问。

段寄存器

段寄存器与内存地址相关联,IA-32 处理器有几种不同的方法来访问内存:

  • 平坦内存模型
  • 段内存模型
  • 实地址模式

平坦模型将所有系统内存连续表示成一个段,所有数据、栈,代码都存储在相同的内存空间。每个内存地址通过特定的地址来访问,叫做线性地址。

段内存模型,将内存分为独立的几段,依赖于段寄存器中的指针,每个段来保存特定类型的数据,分别包括代码、数据、栈等等。

段寄存器 描述
CS 代码段寄存器
DS 数据段寄存器
SS 栈段寄存器
ES 额外的寄存器
FS 额外的寄存器
GS 额外的寄存器

指令指针寄存器

EIP,保存了下一条需要执行的指令

转移指令、函数返回指令等可以修改该寄存器的内容,程序无法直接操作。

控制寄存器

控制寄存器 描述
CR0 处理器系统标志,控制操作模式
CR1 预留,目前没用
CR2 内存缺页信息
CR3 内存页目录信息
CR4 处理器功能标志
为啥 CR1 寄存器被预留了?Intel 并没有任何的官方回应,只说该寄存器留作未来使用。反正控制寄存器确实是有点乱。

标志寄存器

状态标志 名称
CF 0 进位标志
PF 2 奇偶标志
AF 4 调整标志
ZF 6 零标志
SF 7 符号标志
OF 11 溢出标志
系统标志 名称
TF 8 陷阱标志
IF 9 中断允许标志
IOPL 12-13 I/O 权限级别标志
NT 14 嵌套任务标志
RF 16 恢复标志
VM 17 虚拟 8086 模式 标志
AC 18 对齐检测标志
VIF 19 虚拟中断标志
VIP 20 虚拟中断阻塞标志
ID 21 识别标志

一个例子

程序的模板如下:

.section.data

    < initialized data here>

.section .bss

    < uninitialized data here>

.section .text
.globl _start
_start:

    <instruction code goes here>

第一个例子:

# cpuid 
.section .data
output:
    .ascii "The processor Vendor ID is "
_ebx:
    .ascii "xxxx"
_edx:
    .ascii "xxxx"
_ecx:
    .ascii "xxxx\r\n"
output_end:

.section .text
.globl _start
_start:

    movl $0, %eax # show vendor ID String
    cpuid

    movl $_ebx, %edi # move registers data to somewhere
    movl %ebx, (%edi)
    movl $_ecx, %edi
    movl %ecx, (%edi)
    movl $_edx, %edi
    movl %edx, (%edi)

    movl $4, %eax # sys_write(dest, src, length)
    movl $1, %ebx # stdout
    movl $output, %ecx # output 
    movl $(output_end - output), %edx # length
    int $0x80

    movl $1, %eax # exit
    movl $0, %ebx # code = 0
    int $0x80

指令 .ascii 声明了 ASCII 字符串,所在位置用 output 标识。


下面是调用 printf 的例子

# cpuid2.s View the CPUID Vendor ID string using C library calls
.section .data
output:
    .asciz "The processor Vendor ID is '%s'\n"

.section .bss
    .lcomm buffer, 12

.section .text

.globl main
main:

    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

传送数据

数据段

.data 用于声明数据段,还有另一种数据段 .rodata,表示只读数据段。标签对处理器是无意义的,它只对汇编器有用。

命令 数据类型
.ascii 字符串
.asciz 以空字符结尾的文本字符串
.byte 字节
.double 双精度浮点
.float 单精度浮点
.int 32位整数
.long 32位整数
.octa 16 字节整数
.quad 8位整数
.short 16位整数
.single 单精度浮点

output:
    .ascii "The processor Vendor ID is "

这段代码开辟了一段内存,初始化成以上的字符串。


sizes:
    .long 0x100, 0x150, 0x200, 0x250,

声明多个长整型


.equ 指令可以声明静态数据,并不占用实际的内存,只在使用时具体产生数据,是完全的汇编指令。

.equ factor, 3
.equ LINUX_SYS_CALL, 0x80

bss 段中定义数据和数据段中有所不同,bss 是程序运行时产生的数据,有点像是某种意义上的缓存,这部分内容不会存储在程序中,程序中只标记需要多大的空间。

命令 描述
.comm 通用内存区域
.lcomm 局部内存区域
.section .bss
.lcomm buffer1, 0x1111
.comm buffer2, 0x2222

数据传送指令

mov 指令的基本格式如下,AT&T 语法

movx source, destination

source 和 destination 的值可以是立即数、内存地址、存储在内存中的数据、或者寄存器。它与英特尔的语法是反着的,我更喜欢英特尔的语法,但是奈何 gcc 使用了 AT&T 语法。以下是所有 mov 指令。

movx 描述
movl 32位
movw 16位
movb 8位

下面是一些 mov 指令

.section .text

movl %eax, %ebx
movw %ax, %bx
movb %al, %bl

mov 指令有一些约束,也是可以使用 mov 的规则,这些规则如下:

  • 把 立即数 传送给 通用寄存器
  • 把 立即数 传送给 内存位置
  • 把 通用寄存器 传送给 另一个通用寄存器
  • 把 通用寄存器 传送给 段寄存器
  • 把 段寄存器 传送给 通用寄存器
  • 把 通用寄存器 传送给 控制寄存器
  • 把 控制寄存器 传送给 通用寄存器
  • 把 通用寄存器 传送给 调试寄存器
  • 把 调试寄存器 传送给 通用寄存器
  • 把 内存位置 传送给 通用寄存器
  • 把 内存位置 传送给 段寄存器
  • 把 通用寄存器 传送给 内存位置
  • 把 段寄存器 传送给 内存位置

movs 指令是一种特殊的指令,把字符串从一个内存位置,传送到另一个内存位置。

变址内存位置

变址内存模式(indexed memory mode),内存位置由下列因素确定:

  • 基址
  • 添加到基址上的偏移地址
  • 数据元素的长度
  • 确定选择哪个数据元素的变址

表达式的格式是:

base_address(offset_address, index, size)

获取的数局值位于:

base_address + offset_address + index * size

如果其中的任何值为零,就可以忽略它们 (但是仍然需要用逗号作为占位符)。offset_addrcssindex 的值必须是寄存器,size 的值可以是数字。

例如:

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

...

movl $2, %edi
movl values(, %edi, 4), %eax

一个例子

.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 main
main:
    movl $0, %edi
loop1:
    movl values(, %edi, 4), %eax
    pushl %eax
    pushl $output
    call printf
    addl $8, %esp
    inc %edi
    cmpl $11, %edi
    jne loop1

    pushl $0
    call exit

寄存器间接寻址

除了保存数据之外,寄存器也可以用于保存内存地址。当寄存器保存内存地址时,它被称为 指针(pointer)。使用指针访问存储在内存位置中的数据称为间接寻址(indirect addressing)。

这种技术可能是访问数据的操作中最容易令人困惑的部分,如果读者早已习惯于在 C 或者 C++ 中使用指针,那么理解间接寻址就不会有什么问题。否则,那么可能需要花些时间去了解这一概念。

当使用标签引用内存中包含的数据值时,可以通过在指令中的标签前面加上 $ 符号获得数据值的内存位置的地址。因此,下面这条指令

movl $values, %edi

用于把 values 标签引用的内存地址传送给 EDI 寄存器。

movl %ebx, (%edi)

是间接寻址模式的另一半。如果 EDI 寄存器外面没有括号,那么指令只是把 EBX 寄存器中的值加载到 EDI 寄存器中,如果 EDI 寄存器外面加上了括号,那么指令就把 EBX 寄存器中的值传送给 EDI 寄存器中包含的内存位置。

这是一种功能非常强大的工具。与 C 和 C++ 中的指针类似,它使得可以使用寄存器来控制内存地址位置。通过递增寄存器中包含的间接寻址值,就能够体会到它真正的强大功能。不幸的是, GNU 汇编器在进行这些操作时有些古怪。

GNU 汇编器不允许把值与寄存器相加,必须把值放在括号之外,就像这样:

movl %edx, 4(%edi)

这条指令把 EDX 寄存器中的值存放在 EDI 寄存器指向的位置之后 4 个字节的内存位置中。也可以把它存放到相反的方向:

movl %edx, -4(%edi)

这条指令把值存放在 EDI 寄存器指向的位置之前 4 个字节的内存位置中。

另一个例子。

.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 main
main:
    movl values, %eax
    movl $values, %edi
    movl $100, 4(%edi)

    addl $16, %edi
    movl $99, -4(%edi)

    movl $0, %edi
loop1:
    movl values(, %edi, 4), %eax
    pushl %eax
    pushl $output
    call printf
    addl $8, %esp
    inc %edi
    cmpl $11, %edi
    jne loop1

    pushl $0
    call exit

条件传送指令

无符号条件传送指令

指令对 描述 EFLAG 状态
cmova/cmovnbe 大于/不小于或者等于 (CF或ZF) = 0
cmovae/cmovnb 大于或者等于/不小于 CF = 0
cmovnc 无进位 CF = 0
cmovb/cmovnae 小于/不大于或者等于 CF = 1
cmovc 进位 CF = 1
cmovbe/cmovna 小于或者等于/不大于 (CF或ZF) = 1
cmove/cmovz 等于/零 ZF = 1
cmovne/cmovnz 不等于/不为零 ZF = 0
cmovp/cmovpe 奇偶校验/偶校验 PF = 1
cmovnp/cmovpo 非奇偶校验/奇校验 PF = 0

带符号条件传送指令

指令对 描述 EFLAG 状态
cmovge/cmovnl 大于或者等于/不小于 (SF异或OF) = 0
cmovl/cmovnge 小于/不大于或者等于 (SF异或OF) = 1
cmovle/cmovng 小于或者等于/不大于 ((SF异或OF) 或 ZF) = 1
cmovo 溢出 OF = 1
cmovno 未溢出 OF = 0
cmovs 带符号(负) SF = 1
cmovns 元符号(非负) SF = 0

交换数据

xchg %eax, %ebx

指令 描述
xchg 交换后面两个位置的值
bswap 反转 32 位寄存器中的字节序
xadd 交换两个值且把总和存储在目的寄存器中
cmpxchg 把一个值和个外部值进行比较,并且交换它和另一个值
cmpxchg8b 比较两个 64 位值并且交换他们

堆栈

pushx source
popx source

其中 x :

  • l 用于 32 位
  • w 用于 16 位
指令 描述
PUSHA/POPA 压入或者弹出所有 16 位通用寄存器
PUSHAD/POPAD 压入或者弹出所有 32 位通用寄存器
PUSHF/POPF 压入或者弹出 EFLAGS 寄存器的低 16 位
PUSHFD/POPFD 压入或者弹出 EFLAGS 寄存器的全部 32 位

控制执行流程

确定下一条指令在何时和何处并不总是容易的任务。随着指令预取缓存技术的发明,很多指令在实际准备好执行之前就被预先载入了处理器缓存。随着乱序引擎技术的发明,很多指令甚至在应用程序中提前执行了,其结果被安排为适当的顺序以便满足应用程序退役单元的要求。

由于所有这些无秩序的执行方式,确定什么是确切的“下一条指令”可能是困难的。虽然有很多工作在幕后进行、用以提高程序的执行速度,但是处理器仍然需要顺序地单步执行程序逻辑以便生成正确的结果。在这个框架之内,指令指针对于确定程序中执行到了什么位置是至关重要的。

当指令指针在程序指令中移动时, EIP 寄存器会递增到下一条指令。

程序不能直接修改指令指针寄存器,程序员不具有使用 MOV 指令直接将 EIP 寄存器的值改为指向内存中的不同位置的能力。可以使用转移指令来做到这一点。

无条件转移指令(分支)

无条件转移指令有三种:

  • 跳转 jmp
  • 调用 call
  • 中断 int

在结构化程序设计中,goto 被认为是不良编码的标志。程序被划分为几个区域并且按照顺序执行,调用函数,而不是在程序代码中跳转。

在汇编语言程序中,不认为跳转指令是不良的程序设计,而且实际上必须使用它实现很多功能。但是,它们对程序的性能有负面的影响。

跳转指令使用单一指令码:

jmp location

在幕后,单一汇编跳转指令被汇编为跳转操作码的 3 种不同类型之一:

  • 短跳转
  • 近跳转
  • 远跳转

这 3 种跳转类型是由当前指令的内存位置和目的点(跳转到 的位置)的内存位置之间的距离决定的,依据跳过的字节数目决定使用哪种跳转类型。当跳转偏移量小于 128 字节时使用短跳转。

在分段内存模式下,当跳转到另一个段中的指令时使用远跳转,近跳转用于所有其他跳转。

使用汇编语言助记符指令时,不需要担心跳转的长度。单一跳转指令用于跳转到程序代码中的任何位置。


无条件分支的下一种类型是调用。调用和跳转指令类似,但是它保存发生跳转的位置,并且它具有在需要的时候返回这个位置的能力。

调用指令有两个部分。第一个部分是实际的 call 指令

call address

address 操作数引用程序中的标签,它被转换为函数中的第一条指令的内存地址。

第二个部分是返回指令。它使函数可以返回代码的原始部分,就是紧跟在 call 指令后面的位置。返回指令没有操作数,只有助记符 ret


条件分支

条件跳转指令的格式如下:

jxx address

条件跳转允许两种跳转类型:

  • 短跳转
  • 近跳转

短跳转使用 8 位带符号地址偏移量,而近跳转使用 16 位或者 32 位带符号地址偏移址。偏移量被加到指令指针 eip 上。

指令 描述 EFLAGS
JA 如果大于(above),则跳转 CF = 0 与 ZF=0
JAE 如果大于(above)或等于,则跳转 CF = 0
JB 如果小于(below),则跳转 CF = 1
JBE 如果小于(below)或等于,则跳转 CF = 1 或 ZF = 1
JC 如果进位,则跳转 CF = 1
JCXZ 如果 CX 寄存器为 0,则跳转
JECXZ 如果 ECX 寄存器为 0,则跳转
JE 如果相等,则跳转 ZF = 1
JG 如果大于(greater),则跳转 ZF = 0 与 SF = OF
JGE 如果大于(greater)或等于,则跳转 SF = OF
JL 如果小于(less),则跳转 SF != OP
JLE 如果小于(less)或等于,则跳转 ZF = 1  或 SF != OF
JNA 如果不大于(above),则跳转 CF = 1 或 ZF = 1
JNAE 如果个大于(above)或等于,则跳转 CF = 1
JNB 如果不小于(below),则跳转 CF = 0
JNBE 如果不小于(below)或等于,则跳转 CF = 0 与 ZF = 0
JNC 如果无进位,则跳转 CF = 0
JNE 如果不等于,则跳转 ZF = 0
JNG 如果不大于(greater),则跳转 ZF = 1 或 SF != OF
JNGE 如果不大于(greater)或等于,则跳转 SF != OF
JNL 如果不小于(less),则跳转 SF = OF
JNLE 如果不小于(less)或等于,则跳转 ZF = 0 与 SF = OF
JNO 如果不溢出,则跳转 OF = 0
JNP 如果无奇偶校验,则跳转 PF = 0
JNS 如果无符号,则跳转 SF = 0
JNZ 如果非零,则跳转 ZF = 0
JO 如果溢出,则跳转 OF = 1
JP 如果奇偶校验,则跳转 PF = 1
JPE 如果偶校验,则跳转 PF = 1
JPO 如果奇校验,则跳转 PF = 0
JS 如果带符号,则跳转 SF = 1
JZ 如果为零,则跳转 ZF = 1

比较指令是为进行条件跳转而比较两个值的最常见的途径,比较指令的作用就像它的名称表示的,它比较两个值并且相应地设置 EFLAGS 寄存器。

CMP 指令的格式如下:

cmp operand1, operand2

CMP 指令把第二个操作数和第一个操作数进行比较。在幕后,它对两个操作数执行减法操作 operand2 - operand1,比较指令不会修改这两个操作数,只设置相关的 EFLAGS 寄存器位。

循环

循坏指令使用 ECX 寄存器作为计数器,并且随着循坏指令的执行自动递减它的值。下表介绍循环系列中的指令。当 ECX 的值为 0 时结束循环。

指令 描述
LOOP 循环直到 ECX 寄存器为零
LOOPE/LOOPZ 循环直到 ECX 寄存器为零,或者没有设置 ZF 标志
LOOPNE/LOOPNZ 循环直到 ECX 寄存器为零,或者设置了 ZF 标志

模仿高级条件分支

if 语句

高级语言中最常见的条件语句就是 if 语句。

/* ifthen.c – A sample C if-then program */
#include <stdio.h>
int main()
{
    int a = 100;
    int b = 25;
    if (a > b)
    {
        printf("The higher value is %d\n", a);
    }
    else
        printf("The higher value is %d\n", b);
    return 0;
}

它转换成了汇编语言:

    .file   "ifthen.c"
    .text
    .section    .rodata
.LC0:
    .string "The higher value is %d\n"
    .text
    .globl  main
    .type   main, @function
main:
    leal    4(%esp), %ecx
    andl    $-16, %esp
    pushl   -4(%ecx)
    pushl   %ebp
    movl    %esp, %ebp
    pushl   %ebx
    pushl   %ecx
    subl    $16, %esp
    call    __x86.get_pc_thunk.ax
    addl    $_GLOBAL_OFFSET_TABLE_, %eax
    movl    $100, -16(%ebp)
    movl    $25, -12(%ebp)
    movl    -16(%ebp), %edx
    cmpl    -12(%ebp), %edx
    jle .L2
    subl    $8, %esp
    pushl   -16(%ebp)
    leal    .LC0@GOTOFF(%eax), %edx
    pushl   %edx
    movl    %eax, %ebx
    call    printf@PLT
    addl    $16, %esp
    jmp .L3
.L2:
    subl    $8, %esp
    pushl   -12(%ebp)
    leal    .LC0@GOTOFF(%eax), %edx
    pushl   %edx
    movl    %eax, %ebx
    call    printf@PLT
    addl    $16, %esp
.L3:
    movl    $0, %eax
    leal    -8(%ebp), %esp
    popl    %ecx
    popl    %ebx
    popl    %ebp
    leal    -4(%ecx), %esp
    ret
    .size   main, .-main
    .section    .text.__x86.get_pc_thunk.ax,"axG",@progbits,__x86.get_pc_thunk.ax,comdat
    .globl  __x86.get_pc_thunk.ax
    .hidden __x86.get_pc_thunk.ax
    .type   __x86.get_pc_thunk.ax, @function
__x86.get_pc_thunk.ax:
    movl    (%esp), %eax
    ret
    .ident  "GCC: (GNU) 10.2.0"
    .section    .note.GNU-stack,"",@progbits

for 循环

/* for.c – A sample C for program */
#include <stdio.h>
int main()
{
    int i = 0;
    int j;
    for (i = 0; i < 1000; i++)
    {
        j = i * 5;
        printf("The answer is %d\n", j);
    }
    return 0;
}

生成汇编语言

    .file   "for.c"
    .text
    .section    .rodata
.LC0:
    .string "The answer is %d\n"
    .text
    .globl  main
    .type   main, @function
main:
    leal    4(%esp), %ecx
    andl    $-16, %esp
    pushl   -4(%ecx)
    pushl   %ebp
    movl    %esp, %ebp
    pushl   %ebx
    pushl   %ecx
    subl    $16, %esp
    call    __x86.get_pc_thunk.bx
    addl    $_GLOBAL_OFFSET_TABLE_, %ebx
    movl    $0, -16(%ebp)
    movl    $0, -16(%ebp)
    jmp .L2
.L3:
    movl    -16(%ebp), %edx
    movl    %edx, %eax
    sall    $2, %eax
    addl    %edx, %eax
    movl    %eax, -12(%ebp)
    subl    $8, %esp
    pushl   -12(%ebp)
    leal    .LC0@GOTOFF(%ebx), %eax
    pushl   %eax
    call    printf@PLT
    addl    $16, %esp
    addl    $1, -16(%ebp)
.L2:
    cmpl    $999, -16(%ebp)
    jle .L3
    movl    $0, %eax
    leal    -8(%ebp), %esp
    popl    %ecx
    popl    %ebx
    popl    %ebp
    leal    -4(%ecx), %esp
    ret
    .size   main, .-main
    .section    .text.__x86.get_pc_thunk.bx,"axG",@progbits,__x86.get_pc_thunk.bx,comdat
    .globl  __x86.get_pc_thunk.bx
    .hidden __x86.get_pc_thunk.bx
    .type   __x86.get_pc_thunk.bx, @function
__x86.get_pc_thunk.bx:
    movl    (%esp), %ebx
    ret
    .ident  "GCC: (GNU) 10.2.0"
    .section    .note.GNU-stack,"",@progbits

优化分支

分支指令严重地影响了应用程序的性能,大多数现代的处理器(包括IA-32 系列的处理器)利用指令预取缓存提高性能。


分支预测

  1. 无条件分支

对于无条件分支,不难确定下一条指令,但是根据跳转距离的长度,下一条指令在指令预取缓存中有可能是不存在的。

在确定内存中新的指令位置时,乱序引擎必须首先确定指令在预取缓存中是否存在。如果不存在,那么必须清空整个预取缓存,然后从新的位置重新加载指令。这对应用程序的性能而言是代价很高的。

  1. 条件分支

条件分支给处理器提出了更大的挑战。对于每个条件分支,分支预测单元必须确定是否采用分支。通常,当乱序引擎准备执行条件分支时,没有足够的信息用来确定肯定会采用哪个分支。

作为替换的做法,分支预测算法试图猜测特定的条件分支将采用哪条路径。这是使用规则和学习的历史实现的,分支预测算法使用 3 个主要规则:

  • 假设会采用向后分支
  • 假设不会采用向前分支
  • 以前曾经采用过的分支会再次采用

最常见的向后分支(跳转到前面的指令码的分支)是在循环中使用的,例如下面的代码片断:

movl $100, $ecx
loop1:
    addl %ex, %eax
    decl %ecx
    jns loop1

将跳转回 loop1 标签 100 次,但是执行下一条指令只有一次。第一条分支原则永远假设将采用向后分支。分支执行的 101 次里面,只会出现 1 次方向预测错误。

向前分支有些难于处理。分支预测算法假设大多数情况下条件分支不会采用向前的方向。在程序设计逻辑中,假设紧跟在跳转指令后面的代码最可能被执行,而不是跳转到代码的其他位置。从下面的代码片断可以观察到这一情况:

    movl -4(%ebp), %eax
    cmpl -8(%ebp), %eax
    jle .L2
    movl -4(%ebp), %eax
    movl %eax, 4(%esp)
    movl $.LC0, (%esp)
    call printf
    jmp .L3
.L2:
    movl -8(%ebp), %eax
    movl %eax, 4(%esp)
    movl $.LC0, (%esp)
    call printf
.L3:

最后一条规则暗示,执行了多次的分支在多数情况下可能采用相同的路径。分支目标缓冲区 (Branch Target Buffer, BTB) 跟踪处理器执行的每个分支指令,分支的结果存储在缓冲区区域中。

BTB 信息高于分支的前两个规则。例如,如果第一次遇到分支时,没有采用向后的方向,分支预测单元就会假设任何后续分支都不会采用向后方向,而不是假设会应用向后分支的规则。

BTB 的问题在于它可能被充满。当 BTB 被充满时,查找分支结果会花费更长时间,并且降低执行分支的性能。


优化技巧

  1. 消除分支

    条件传送指令可以用于消除分支

  2. 首先编写可预测分支的代码

    可以利用分支预测单元的规则提高应用程序的性能。就像在前面 if 语句中看到的,把最可能采用的代码安排在向前跳转的顺序执行语句中,会提高需要它时它在指令预取缓存中的可能性。允许跳转指令跳转到使用的可能性低一些的代码段。

    对于使用向后分支的代码,要试图使用向后分支作为最可能被采用的路径。实现循坏时这通常不是问题,但是在某些情况下也许必须改变程序逻辑以便实现这个目的

  3. 展开循环

    尽可能消除小型循环,但这意味着手动编写多次同样的代码。对于人来说简直不能忍。


使用数字

数字数据类型

数字数据类型如下:

  • 无符号整数
  • 带符号整数
  • 二进制编码的十进制
  • 打包的二进制编码的十进制
  • 单精度浮点数
  • 双精度浮点数
  • 双精度扩展浮点数

除了基本的数字数据类型之外,奔腾处理器的 SIMD 扩展还添加了其他高级数字数据类型:

  • 64 位打包整数
  • 128 位打包整数
  • 128 位打包单精度浮点数
  • 128 位打包双精度浮点数

整数

基本的 IA -32 平台支持 4 种不同的整数长度:

  • 字节(Byte): 8 位
  • 字(Word): 16 位
  • 双字(Doubleword): 32 位
  • 四字(Quadword ): 64 位

IA-32 采用小端方式存储数据


无符号整数

整数值
8 0 \sim 2^{8} - 1 = 255
16 0 \sim 2^{16} - 1 = 65535
32 0 \sim 2^{32} - 1 = 4294967295
64 0 \sim 2^{64} - 1 = 18446744073709551615

带符号数

带符号数值的方法把组成带符号整数的位分为两部分:

  • 符号位
  • 数值位

字节的最大有效位(最左侧的一位)用于表示值的符号。正数的最大有效位为 0, 而负数的这个位置是 1。

值中的其余位使用一般的二进制值表示数字的数值

对于负整数值,值的反码加上1 就是它的补码.

整数值
8 -128 \sim 127
16 -32768 \sim 32767
32 -2147483648 \sim 2147483647
64 -9223372036854775808 \sim 9223372036854775807

使用带符号整数

.section .data
data:
    .int -45
.section .text
.globl main
main:
    nop
    movl $-345, %ecx
    movw $0xffb1, %dx
    movl data, %ebx
    mov $1, %eax
    int $0x80

扩展无符号整数

把无符号整数值转换为位数更大的值时(比如把字转换为双字),必须确保所有的高位部分都为 0,不应该简单的把一个值赋值给另一个值。

为了解决这种情况 Intel 提供了 movzx 指令,这条指令把长度小的无符号整数值(可以在寄存器中,也可以在内存中)传送给长度大的无符号整数值(只能在寄存器中)。


扩展带符号整数

扩展带符号整数值和扩展无符号整数是不同的。用 0 填充高位会改变负数的数据值。所以负数必须在前面填充 1,为了解决这个问题,Intel 提供了 movsx 指令。它允许扩展带符号整数并保留符号。它和 MOVZX 指令类似。


MMX (MultiMedia eXtension)

由英特尔奔腾2处理器引入

SSE (Streaming SIMD Extension)

由英特尔奔腾4处理器引入

浮点数

从 80486 (1989) 处理器开始,Intel 直接支持浮点数操作。

浮点数用科学计数法表示,科学计数法把数字表示成 系数(coefficient) (也称尾数 (mantissa)) 和指数(exponent),比如 3.6845 \times 10^ 2,在十进制中,指数的基数为 10,并且表示小数点移动多少位以生成系数。

科学计数法中 253.92 可以表示成 2.5392 \times 10^2,其中 2.5392 是系数,10^2 是指数。


计算机使用二进制浮点数,这种格式用二进制科学计数法表示值。因为数字按照二进制格式表示,所以系数和指数都基于二进制值。而不是十进制。

为了对二进制浮点数进行译码,首先要了解小数数字的意义。在十进制中,0.159 表示 \displaystyle 0 + {1\over 10} + {5 \over 100} + {9 \over 1000},同样的原则也应用于二进制浮点数。

系数值 1.0101 \times 2^2 应该生成二进制值 101.01,表示十进制 \displaystyle 5 + {0 \over 2} + {1 \over 2^2} = 5.25

二进制小数数字是浮点数处理过程中最容易混淆的部分,下面列出二进制小数以及他们对应的十进制值。

二进制小数 十进制分数 十进制小数
0.1 {1 \over 2} 0.5
0.01 {1 \over 4} 0.25
0.001 {1 \over 8} 0.125
0.0001 {1 \over 16} 0.0625
0.00001 {1 \over 32} 0.03125
0.000001 {1 \over 64} 0.015625

十进制小数都以 5 结尾,另外写出十进制分数可能更容易对二进制小数有一个感性的认识。

为了帮助说明二进制小数,下面列出了使用二进制浮点数的几个例子:

二进制值 十进制分数 十进制值
10.101 2 + 1/2 + 1/8 2.625
10011.001 19 + 1/8 19.125
10110.1101 22 + 1/2 + 1/4 + 1/16 22.8125
1101.011 13 + 1/4 + 1/8 13.375

十进制可能有循环小数,例如 \displaystyle {1 \over 3} = 0.3333333\cdots,二进制也可能有循环小数,所以必须在某个位置截断这些值,并只能以二进制格式估计十进制小数。

编写二进制浮点值时,通常被规格化了,这个操作把小数点移动到最左侧的位置,并且修改指数进行补偿。例如 1101.011 规格化成了 1.101011 \times 2^3


在计算机时代的早期,试图在计算机系统中正确的表示二进制浮点数是一个挑战。幸运的是,现在已经有了相关的标准。

1985 年,电气和电子工程师学会(Institute of Electrical and Electronics Engineers, IEEE) 创建了称为 IEEE 标准 754 的浮点格式。这些格式用于在计算机系统中通用地表示实数。Intel 在 IA-32 平台中采用这种标准来表示浮点值。

类型 符号位 指数 系数 开发者
32 bit 1 bit 8 bit 23 bit IEEE
64 bit 1 bit 11 bit 52 bit IEEE
80 bit 1 bit 15 bit 64 bit INTEL

IA-32 平台使用 IEEE 标淮 754 的单精度和双精度浮点格式,还使用它自己的 80 位格式,称为扩展双精度浮点格式。在执行浮点运算时,这 3 种格式提供不同的精度度级别。在浮点运算处理的过程中,扩展双精度浮点格式使用在 80 位 FPU 寄存器内。


GNU 汇编浮点值

.float 命令用于创建 32 位单精度值,.double 命令用于创建 64 位双精度值。

传送浮点值

FLD 指令用于把浮点值传送入和传送出 FPU 寄存器。FLD 指令的格式是:

fldx source

下面是一个例子:

.section .data

value1:
    .float 12.34
value2:
    .double 2353.631

.section .bss
    .lcomm data, 8

.section .text
.globl main
main:
    flds value1
    fldl value2
    fstl data

    movl $1, %eax
    movl $0, %ebx
    int $0x80

FSTL 指令把 ST0 寄存器中的值加载到了 data 标签指向的内存位置中。


使用预置的浮点值

指令 描述
FLDI 把 +1.0 压入 FPU 堆栈中
PLDL2T 把 10 的对数(底数2) 压入 FPU 堆栈中
FLDL2E 把 e 的对数(底数2) 压入 FPU 堆栈中
FLDPI \pi 的值压入 FPU 堆栈中
PLDLG2 把 2 的对数(底数10) 压入 FPU 堆栈中
FLDLN2 把 2 的对数(底数e) 压入 FPU 堆栈中
FLDZ 把 +0.0 压入 FPU 堆栈中

读者也许会注意到 FLDZ 指令有些奇怪。在浮点数据类型中,+0.0 和 -0.0 之间是有区别的。对于大多数操作,它们被认为是相同的值,但是使用在除法中时,它们产生不同的值(正无穷大和负无穷大)。

.section .text
.globl main
main:
    fld1
    fldl2t
    fldl2e
    fldpi
    fldlg2
    fldln2
    fldz

    movl $1, %eax
    movl $0, %ebx
    int $0x80

转换

IA - 32 指令集包含众多指令,用于把以一种数据类型表示的数据转换为另一种数据类型。需要把浮点数据转换为整数值(或者相反的转换)的情况并不少见。这些指令提供完成这种操作的简便方式,无需编写自己的算法。

指令 转换
CVTDQ2PD 打包双字整数到打包双精度 FP (XMM)
CVTDQ2PS 打包双字整数到打包单精度 FP (XMM)
CVTPD2Q 打包双精度 FP 到打包双字整数 (XMM)
CVTPD2PI 打包双精度 FP 到打包双字整数 (MMX)
CVTPD2PS 打包双精度 FP 到打包单精度 FP (XMM)
CVTPI2PD 打包双字整数到打包双精度 FP (XMM)
CVTPI2PS 打包双字整数到打包单精度 FP (XMM)
CVTPS2DQ 打包单精度 FP 到打包双字整数 (XMM)
CVTPS2PD 打包单精度 FP 到打包双精度 FP (XMM)
CVTPS2PI 打包单精度 FP 到打包双字整数 (MMX)
CVTTPD2PI 打包双精度 FP 到打包双字整数 (MMX, 截断)
CVTTPD2DQ 打包双精度 FP 到打包双字整数 (XMM ,截断)
CVTTPS2DQ 打包单精度 FP 到打包双字整数 (XMM, 截断)
CVTTPS2PI 打包单精度 FP 到打包双字整数 (MMX, 截断)

基本数学功能

加法

addx source, destination

下面是一个例子。

.section .data
data:
    .int 40

.section .text
.globl main
main:

    movl $0, %eax
    movl $0, %ebx
    movl $0, %ecx
    movb $20, %al
    addb $10, %al
    movsx %al, %eax

    movw $100, %cx
    addw %cx, %bx

    movsx %bx, %ebx

    movl $100, %edx
    addl %edx, %edx
    addl data, %eax
    addl %eax, data

    movl $1, %eax
    movl $0, %ebx
    int $0x80

进位加法

adcx source, destination

减法

subx source, destination

借位减法

sbbx source, destination

递增和递减

inc destination
des destination

乘法

无符号整数乘法

mul source

其中 source 可以是 8 位、16 位或者 32 位寄存器或内存值。读者也许会奇怪在这个指令行中只提供一个操作数,怎么能进行两个值的乘法。答案是目标操作数是隐含的。

目标位置总是使用 EAX 寄存器的某种形式,这取决于源操作数的长度。因此,根据源操作数的值的长度,乘法操作中使用的另一个操作数必须存放在 ALAX 或者 EAX 寄存器中。

由于乘法可能产生很大的值,所以 MUL 指令的目标位置必须是源操作数长度的两倍。如果源值是 8 位,那么目标操作数就是AX 寄存器,因为结果是 16 位。当源操作数更大时,情况甚至会更加复杂。

不幸的是,当和 16 位源操作数相乘时, EAX 寄存器不被用于保存 32 位结果。为了向下兼容老式的处理器, Intel 使用 DX:AX 寄存器对保存 32 位乘法结果值(这一格式源自 16 位处理器的年代)。结果的高位字存储在 DX 寄存器中,低位字存储在 AX 寄存器中。

对于 32 位源值,目标位置使用 64 位 EDX: EAX 寄存器对于高位双字存储在 EDX 寄存器中,低位双字在 EAX 寄存器中。当使用 MUL 的 16 位或者 32 位版本时,如果在 EDX(或者 DX) 寄存器中存储着数据,那么一定要把数据保存到其他位置。

为了帮助总结这些情况,下表列出无符号整数乘法的需求。

源操作数长度 目标操作数 目标位置
8 位 AL AX
16 位 AX DX:AX
32 位 EAX EDX:AX
.section .data
data1:
    .int 315814
data2:
    .int 165432
result:
    .quad 0
output:
    .asciz "The result is %qd\n"

.section .text
.globl main
main:
    movl data1, %eax
    mull data2
    movl %eax, result
    movl %edx, result + 4

    pushl %edx
    pushl %eax
    pushl $output

    call printf

    add $12, %esp
    pushl $0
    call exit

带符号乘法

MUL 指令只能用于无符号整数,而 IMUL 指令可以用于带符号和和符号整数,但是必须小心结果不使用目标的最高有效位。对于较大的值,IMUL 指令只对带符号整数是合法的。为应付比较复杂的情况,IMUL 指令有 3 种不同的指令格式

IMUL 指令的第种格式使用一个操作数,其行为和 MUL 指令完全一样:

imul source

IMUL 指令的第二种格式允许指定 EAX 寄存器之外的目标操作数:

imul source , destination

其中 source 可以是 16 位或者 32 位寄存器或内存中的值, destination 必须是 16 位或者 32 位通用寄存器。这种格式允许指定把乘法操作的结果存放到哪个位置(而不是强制使用 AXDX 寄存器)

这种格式的缺陷在于乘法操作的结果被限制为单一目标寄存器的长度 (非64位结果)。使用这种格式时必须非常小心,不要溢出目标寄存器

IMUL 指令的第三种格式允许指定 3 个操作数

imul multiplier, source, destination

其中 multiplier 是一个立即数, source 是 16 位或者 32 位寄存器或内存中的值,destination 必须
是通用寄存器。这种格式允许执行一个值(source) 和一个带符号整数(multiplier) 的快速乘法操作,把结果存储到通用寄存器(destination) 中。

下面是一个例子

.section .data
value1:
    .int 10
value2:
    .int -35
value3:
    .int 400

.section .text
.globl main
main:
    movl value1, %ebx
    movl value2, %ecx
    imull %ebx, %ecx
    movl value3, %edx
    imull $2, %edx, %eax

    pushl $0
    call exit

除法

除法和乘法类似

无符号除法

div divisor

其中 divisor(除数)是隐含的被除数要除以的值,它可以是 8 位、16 位或者 32 位寄存器或内存中的值。在执行 DIV 指令之前,被除数必须已经存储到了 AX 寄存器(对于 16 位值)、DX:AX 寄存器对(对于 32 位值)或者 EDX:EAX 寄存器对(对于 64 位值)。

允许的除数的最大值取决于被除数的长度。对于 16 位被除数,除数只能是 8 位;对于 32 位被除数,除数只能是 16 位;对于 64 位被除数,除数只能是 32 位。

除法操作的结果是两个单独的数字:商和余数

这两个值都存储在被除数值使用的相同寄存器中。下表列出了其设置的情况。

被除数 被除数长度 余数
AX 16 位 AL AH
DX:AX 32 位 AX DX
EDX:EAX 64 位 EAX EDX

这就是说,当除法操作完成时,会丢失被除数。如果需要建议提前保存该值。

下面是一个例子

.section .data
dividend:
    .quad 8885
divisor:
    .int 25
quotient:
    .int 0
remainder:
    .int 0
output:
    .asciz "The quotient is %d, and the remainder is %d\n"

.section .text
.globl main
main:
    movl dividend, %eax
    movl dividend+4, %edx
    divl divisor
    movl %eax, quotient
    movl %edx, remainder

    pushl remainder
    pushl quotient
    pushl $output
    call printf

    add $12, %esp

    push $0
    call exit

带符号除法

IMUL 指令不同,IDIV 指令只有一种格式,它指定除法操作中使用的除数:

idiv divisor

同样,其中的 divisor 叶以是 8 位、16 位或者 32 位寄存器或内存中的值。


移位指令


移位乘法

可以使用两个指令使整数值向左移位,SAL (向左算术移位)和SHL (向左逻辑移位)。这两个指令执行相同的操作,井且是可以互换的,它们有 3 种不同格式:

sal destination
sal %cl, destination
sal shifter, destination

第一种格式把 destination 的值向左移 1 位,这等同于使值乘以2。
第二种格式把 destination 的值向左移动 CL 寄存器中指定的位数
最后一个版本把 destination 的值向左移动 shifter 值指定的位数。在所有的格式中,目标操作数可以是 8 位、16 位或者 32 位寄存器或内存中的值。

和以往一样, GNU 汇编器需要在助记符的结尾附加上一个字符,用于指出目标值的长度。

.section .data
value1:
    .int 25

.section .text
.globl main
main:
    movl $10, %ebx
    sall %ebx
    movb $2, %cl
    sall %cl, %ebx
    sall $2, %ebx
    sall value1

    pushl $0
    call exit

有两个向右移位指令。SHR 指令清空移位造成的空位,所以它只能用于对无符号整数进行移位操作,SAR 指令根据整数的符号位,要么清空,要么设置移位造成的空位,对于负数,空位被设置为 1,对于正数,它们被设位 0.


循环移位指令

和移位指令关系密切的指令是循环移位指令。循环移位指令执行的功能和移位指令一样,只不过溢出位被存放回值的另一端,而不是被丢弃。

指令 描述
ROL 向左循坏移位
ROR 向右循环移位
RCL 向左循环移位,并且包含进位标志
RCR 向右循坏移位,并且包含进位标志

逻辑操作

布尔逻辑操作如下

  • AND
  • NOT
  • OR
  • XOR

AND, ORXOR 指令使用相同的格式:

and source, destination

TEST 指令在 8 位、16 位或 32 位值之间执行按位逻辑 AND 操作,并且相应地设置符号、零和奇偶校验标志,而且不修改目标值。

TEST 指令的格式和 AND 指令相同。尽管没有数据写入目标位置,但是仍然必须指定任意立即值作为源值,这类似于 CMP 指令的工作方式和 SUB 指令一样,但是它不会把结果存储到任何位置。

TEST 指令最常见的用途是检查 EFLAGS 寄存器中的标志。

高级数学功能

FPU 环境

FPU 是一个独立的单元,它使用与标准处理器寄存器分离的另一组寄存器处理浮点操作。附加的 FPU 寄存器包括 8 个 80位数据寄存器和 3 个 16 位寄存器,称为控制(control)、状态(status) 和标记(tag) 寄存器。

FPU 数据寄存器称为 R0 到 R7,它们的操作和标准寄存器有些不同,不同之处在于它们连接在一起形成一个堆栈,和内存中的堆栈不同,
FPU 寄存器堆栈是循环的。这就是说,堆栈中的最后一个寄存器连接回堆栈中的第一个寄存器。堆栈顶部的寄存器是在 FPU 的控制字寄存器中定义的,名为 ST(0) 。除了顶部寄存器外的其他寄存器名称是 ST(x) ,其中 x 可以是 1 到 7。

当数据被加载到 FPU 堆栈时,堆栈顶部沿着 8 个寄存器向下移动,当 8 个值被加载到堆栈中之后,所有 8 个 FPU 数据寄存器就都被使用了。如果把第 9 个数据加载到堆栈中,堆栈指针回绕到第一个寄存器,并且使用新的值替换这个寄存器中的值,这会产生 FPU 异常错误。

因为 FPU 独立于主处理器,所以它一般不使用 EFLAGS 寄存器来表示结果和确定行为。

状态寄存器

状态寄存器表明 FPU 的操作情况,它包含在一个 16位 寄存器中,不同的位作为不同标志。下表介绍状态寄存器位。

状态位 描述
0 非法操作异常标志
1 非规格化操作数异常标志
2 除数为零异常标志
3 溢出异常标志
4 下溢异常标志
5 精度异常标志
6 堆栈错误
7 错识汇总状态
8 条件代码位0 (C0)
9 条件代码位1 (C1)
10 条件代码位2 (C2)
11-13 堆栈顶部指针
14 条件代码位3 (C3)
15 FPU 繁忙标志

使用 FSTSW 指令,可以把状态寄存器读取到一个双字内存位置 或者 AX 寄存器中。下面是一个例子

.section .bss
    .lcomm status, 2

.section .text
.globl main
main:
    fstsw %ax
    fstsw status

    push $0
    call exit

控制寄存器

控制寄存器控制 FPU 内的浮点功能,这里定义了相关设置,比如 FPU 用于计算浮点值的精度,以及用于舍人浮点结果的方法。

控制寄存器使用一个 16 位寄存器,下表列出了相应位的含义。

控制位 描述
0 非法操作异常掩码
1 非规格化操作数异常掩码
2 除数为零异常掩码
3 溢出异常掩码
4 下溢异常掩码
5 精度异常掩码
6-7 保留
8-9 精度控制
10-11 舍入控制
12 无穷大控制
13-15 保留

控制寄存器的前 6 位用于控制使用状态寄存器中的哪些异常标志,当这些位中的一位被设置时,就会防止状态寄存器中对应的异常标志被设置。默认情况下,所有掩码位都被设置,即屏蔽所有异常。

精度控制位可以设置 FPU 中用于数学计算的浮点精度。这是非常有用的控制特性,可以改变 FPU 计算浮点值花费的时间。精度控制位可能的设置如下:

  • 00 单精度(24 位有效位)
  • 01 未使用
  • 10 双精度(53 位有效位)
  • 11 扩展双精度(64 位有效位)

默认情况下, FPU 精度被设置为扩展双精度。这是最为精确的值,但是也最耗费时间。如果不打算使用这么高的精度.可以把这个值设置为单精度以便加快浮点值的计算速度。

类似地,舍入控制位可以设置 FPU 如何舍入浮点计算的结果。舍入控制位的可能设置如下:

  • 00 舍入到最近值
  • 01 向下舍入(向无穷大负值)
  • 10 向上舍入(向无穷大正值)
  • 11 向零舍入

默认情况下,舍入控制位被设置为舍入到最近值。

控制寄存器的默认值是 0x037F,可以使用 FSTCW 指令把控制寄存器的设置加载到双字内存位置中查看设置的内容。也可以使用 FLDCW 指令改变设置。这条指令把双字内存值加载到控制寄存器中。下面是一个例子。

.section .data
newvalue:
    .byte 0x7f, 0x00
.section .bss
    .lcomm control, 2

.section .text
.globl main
main:
    fstcw control
    fldcw newvalue
    fstcw control

    push $0
    call exit

标记寄存器

标记寄存器用于标识 8 个 80 位 FPU 数据寄存器中的值。标记寄存器使用 16 位(每个寄什器2位)标识每个 FPU 数据寄存器的内容。

每个标记值对应一个物理的 FPU 寄存器。每个寄存器对应的 2 位值可以包含表明寄存器内容的 4 个特殊代码之一,在任何给定的时刻,FPU数据寄存器可以包含下面的内容:

  • 一个合法的扩展双精度值 (代码 00)
  • 零值 (代码 01)
  • 特殊的浮点值 (代码 10)
  • 无内容(空)(代码 11)

这使程序员可以快速检查标记寄存器以便确定 FPU 寄存器中是否包含合法数据,而不必读取和分析寄存器的内容

FPU 堆栈

一个例子:

.section .data
value1:
    .int 40
value2:
    .float 92.4405
value3:
    .double 221.440321
.section .bss
    .lcomm int1, 4
    .lcomm control, 2
    .lcomm status, 2
    .lcomm result, 4

.section .text
.globl main
main:
    finit # 初始化 FPU 环境
    fstcw control # 复制控制寄存器的值到内存
    fstsw status # 复制状态寄存器的值到内存
    filds value1 # 加载双字到 FPU 堆栈
    fists int1 # 获取寄存器堆栈顶部的值
    flds value2 # 加载内存中的单精度浮点值
    fldl value3 # 加载内存中的双精度浮点值
    fst %st(4) # 把 ST0 寄存器的数据传送到给出的 FPU 寄存器
    fxch %st(1) # 交换 ST0 和给出的 FPU 寄存器的值
    fstps result # 复制 ST0 中的值,然后弹出堆栈,这步有问题,不知道为啥

    push $0
    call exit

基本浮点运算

指令 描述
FADD 浮点加法
FDIV 浮点除法
FDIVR 反向浮点除法
FMUL 浮点乘法
FSUB 浮点减法
FSUBR 反向浮点减法

这些功能的每一个都具有单独的指令和格式,可以生成 6 个可能的功能,这取决于希望执行的确切操作是什么。例如, FADD 指令可以像下面这样使用:

  • FADD source: 内存中的 32 位或者 64 位值和 ST0 寄存器相加
  • FADD %st(x), %st(0): st(x) 和 st(0) 相加,结果存储到 st(0) 中
  • FADD %st(0), %st(x): st(0) 和 st(x) 相加,结果存储到 st(x) 中
  • FADDP %st(0), %st(x): st(0) 和 st(x) 相加,结果存储到 st(x) 中,井且弹出 st(0)
  • FADDP: st(0) 和 st(1) 相加,结果存储到 st(1) 中,并且弹出 st(0)
  • FIADD source: 16 位或者 32 位整数值和 st(0) 相加,结果存储到 st(0) 中

FSUBR 和 FDIVR 指令用于执行反向减法和除法,就是说,运算结果是目的值减去(或者除)源值,并且把结果存放在目的操作数中。这与 FSUB 和 FDIV 指令执行运算的方式是相反的。

为了演示如何工作,下面计算如下表达式:

((43.65 / 22) + (76.34 * 3.1)) / ((12.43 * 6) - (140.2 / 94.21))

\displaystyle{43.65 \over 22} + 76.34 \times 3.1 \over 12.43 \times 6 - \displaystyle{ 140.2 \over 94.21}
.section .data
value1:
    .float 43.65
value2:
    .int 22
value3:
    .float 76.34
value4:
    .float 3.1
value5:
    .float 12.43
value6:
    .int 6
value7:
    .float 140.2
value8:
    .float 94.21
output:
    .asciz "The result is %f\n"

.section .text
.globl main
main:
    finit # 初始化 FPU 环境
    flds value1 # 从内存中读入浮点数 43.65
    fidiv value2 # 除以内存中的整数 22 -> 1.98
    flds value3 # 从内存中读取浮点数 76.34
    flds value4 # 从内存中读取浮点数 3.1
    fmul %st(1), %st(0) # st(1) 和 st(0) 相乘,结果存储到 st(0) 中 -> 236.65
    fadd %st(2), %st(0) # st(2) 和 st(0) 相加,结果存储到 st(0) 中 -> 238.63
    flds value5 # 从内存中读取浮点数 12.43
    fimul value6 # 乘以内存中的整数 6 -> 74.58
    flds value7 # 从内存中读取浮点数 140.2
    flds value8 # 从内存中读取浮点数 94.21
    fdivrp # st(1) 和 st(0) 相除,结果存储到 st(1) 中,并且弹出 st(0) -> 1.48
    fsubr %st(1), %st(0) # st(1) 和 st(0) 相减,结果存储到 st(0) 中 -> 73.09
    fdivr %st(2), %st(0) # st(2) 和 st(0) 相除,结果存储到 st(0) 中 -> 3.264

    subl $8, %esp # 从堆栈中获取8个字节
    fstpl (%esp) # 将数据存储到这里,并弹出

    pushl $output # 将字符串压入堆栈
    call printf # 调用 printf 函数

    add $12, %esp # 恢复堆栈

    pushl $0
    call exit

高级浮点运算

指令 描述
F2XM1 计算 2 的乘方 (次数为 ST0 中的值) 减去 1
FABS 计算 ST0 中的值的绝对值
FCHS 改变 ST0 中的值的符号
FCOS 计算 ST0 中的值的余弦
FPATAN 计算 ST0 中的值的部分反正切
FPREM 计算 ST0 中的值除以 ST1 中的值的部分余数
FPREM1 计算 ST0 中的值除以 ST1 中的值的 IEEE 部分余数
FPTAN 计算 ST0 中的值的部分正切
FRNDINT 把 ST0 中的值舍入到最近的整数
FSCALE 计算 ST0 乘以 2 的 ST1 次方
FSIN 计算 ST0 中的值的正弦
FSINCOS 计算 ST0 中的值的正弦和余弦
FSQRT 计算 ST0 中的值的平方根
FYL2X 计算 ST1 * log ST0 (以2为基数)
FYL2XP1 计算 ST1 * log (ST0 + 1) (以2 为基数)

下面是一些例子

.section .data
value1:
    .float 395.21
value2:
    .float -9145.290
value3:
    .float 64.0

.section .text
.globl main
main:
    finit # 初始化 FPU 环境
    flds value1 # 从内存读入浮点数
    fchs # 变 ST0 的符号

    addl $8, %esp

    flds value2
    fabs # 求 ST0 的绝对值

    flds value3
    fsqrt # 求 ST0 的平方根

    pushl $0
    call exit
.section .data
value1:
    .float 3.65
rdown:
    .byte 0x7f, 0x07
rup:
    .byte 0x7f, 0x0b

.section .bss
    .lcomm result1, 4
    .lcomm result2, 4
    .lcomm result3, 4

.section .text
.globl main
main:
    finit
    flds value1
    frndint
    fists result1

    fldcw rdown
    flds value1
    frndint
    fists result2

    fldcw rup
    flds value1
    frndint
    fists result3

    pushl $0
    call exit

部分余数

计算除法迭代过程中的余数,就是部分余数,例如 29 / 7: 第一次迭代 29 - 7 = 22,那么 22 就是部分余数。

FPREM 和 FPREM1 指令都计算浮点除法的余数值,但是它们的工作方法稍有区别。

确定除法余数的基本方法是确定被除数和除数的除法的浮点商,然后把这个值舍入到最近的整数。那么,余数就是除数和商相乘的结果与被除数之间的差值。

例如,为了计算 20.65 除以 3.97 的余数,可以执行如下步骤:

  1. 20.65 / 3.97 = 5.201511335, 合入到 5(这是商)
  2. 5 * 3.97 = 19.85
  3. 20.65 - 19.85 = 0.8 (这是余数)

困难的部分在于舍入过程。在创建部分余数的任何标准之前, Intel 就开发了 FPREM 指令。Intel 的开发人员选择使用默认的 FPU 向零舍入的方法,用于计算整数商值,然后确定余数。

不幸的是,IEEE 创建标准时,它选择在计算余数之前,使商值向上舍入到从近的整数值。虽然这似乎只有细微的区别,但是在处理过程中计算部分余数时造成很大影响。出于这个原因,Intel 选择保持原始 FPREM 指令的原始形式,并另外创建了 FPREM1 指令,它使用 IEEE 方法计算部分余数。

计算部分余数的问题在于必须知道迭代过程在什么时候完成。 FPREM 和 FPREM1 指令都使用 FPU 状态寄有器的条件代码位 2 (状态寄存器的第 10 位)表示迭代何时完成。当需要更多的迭代时,就设置 C2 位,当迭代完成时,就清空 C2 位。

为了检查 C2 位,必须首先使用 FSTSW 指令把状态寄存器的内容复制到内存或者 AX 寄存器中、然后使用 TEST 指令判断这一位。

下面是一个例子:

.section .data
value1:
    .float 20.65
value2:
    .float 3.97

.section .bss
    .lcomm result, 4

.section .text
.globl main
main:

    finit
    flds value2
    flds value1
loop:
    fprem1
    fstsw %ax
    testb $4, %ah
    jnz loop

    fsts result

    pushl $0
    call exit

虽然余数值存储在 ST0 寄存器中,但是实际的商值没有存储在寄存器中。商值的最后 3 个有效位存储在控制寄存器中,使用控制寄存器中剩余的条件代码位,如下:

  • 商位 0 在条件位 1
  • 商位 1 在条件位 3
  • 商位 2 在条件位 0

必须手动地提取这些位以便构成商值的最低 3 位。

FPREM 指令的输出显得有点儿奇怪,这是有原因的。在旧式 80287 FPU 协处理为的年代,FPTAN 指令不能处理大于 \pi \over 4 的弧度。对于确定源角度值位于哪个象限中,FPREM 指令是至关重要的。因为这涉及象限,所以只需要商的最低3位。从 80387 FPU 协处埋器开始, FPTAN指令就没有这个限制了,并且 FPREM 指令的商值很难被用到。

三角函数

在 FPU 中,基本的三角函数都按照相同的方式实现。这些指令都使用一个隐含的源操作数,它位于 ST0 寄存器中。当函数完成时,结果存放在 ST0 寄存器中。

下面是一个例子:

.section .data
degree1:
    .float 90.0
val180:
    .int 180

.section .bss
    .lcomm radian1, 4
    .lcomm result1, 4
    .lcomm result2, 4

.section .text
.globl main
main:
    finit
    flds degree1
    fidivs val180
    fldpi
    fmul %st(1), %st(0)
    fsts radian1
    fsin
    fsts result1
    flds radian1
    flds radian1
    fcos
    fsts result2

    pushl $0
    call exit

浮点条件分支

FCOM 指令系列用于在 FPU 中比较两个浮点值。指令比较的一个值是加载到 FPU 寄存器 ST0 中的值,另一个值是另一个 FPU 寄存器或者内存中的浮点值。还有在比较之后把一个值或者两个值弹出 FPU 堆栈的选项。下表介绍可以使用的不同指令版本。

指令 描述
FCOM 比较 ST0 寄存器和 ST1 寄有器
FCOM ST (x) 比较 ST0 寄存器和另一个 FPU 存存器
FCOM source 比较 ST0 寄存器和 32 位或者 64 位的内存值
FCOMP 比较 ST0 寄存器和 ST1 寄存器,并弹出堆栈
FCOMP ST (x) 比较 ST0 寄存器和另一个 FPU 寄存器,并弹出堆栈
FCOMP source 比较 ST0 寄存器和 32 位或者 64 位的内存值,并弹出堆栈
FCOMPP 比较 ST0 寄存器和 ST1 寄存器,并弹出堆栈两次
FTST 比较 ST0 寄存器和值 0.0

比较的结果设置在状态寄存器的 C0、C2 和 C3 条件代码位中,比较可能产生的值列在下表中。

条件 C3 C2 C1
ST0 > source 0 0 0
ST0 < source 0 0 1
ST0 = source 1 0 0

必须用 FSTSW 指令把状态寄存器的值复制到 AX 寄存器或者内存中,然后用 TEST 指令判断比较的结果。

保存和恢复 FPU 状态

FSTENV 指令用于把 FPU 的环境存储到一个内存块中。下面的 FPU 寄存器被存储:

  • 控制寄存器
  • 状态寄存器
  • 标记寄存器
  • FPU 指令指针偏移量
  • FPU 数据指针
  • FPU 最后执行的操作码

这些值存储在个 28 字节的内存块中。FLDENV 指令用于把内存块的值加载回 FPU 环境中。

等待和非等待指令

大多数浮点指令在执行之前必须等待,以便确保前面的指令没有抛出异常。如果出现异常,在能够执行下一条指令之前必须先处理异常。

还有另一种方式,一些指令包含非等待版本,它们不等待浮点异常的检查。这些指令允许程序保存或者复位当前的 FPU 状态,而不处理任何从而未决的异常。下及介绍可以使用的非等待指令。

指令 描述
FNCLEX 清空浮点异常标志
FNSAVE 把 FPU 状态保存到内存中
FNSTCW 保存 FPU 控制寄存器
FNSTENV 把 FPU 操作环境保存到内存中
FNSTSW 把 FPU 状态寄存器保存到内存或者 AX 寄存器中

浮点运算优化

  • 确保浮点值不会上溢或者下溢出数据元素
  • 把精度控制位设置为单精度
  • 使用查找表实现简单的三角由数
  • 在可能的情况下,断开依赖链。例如,不计算 z=a+b+c+d, 而计算 x=a+b; y=c+d; z=x+y

    这样干其实就是分治法,把一个 O(n) 的算法换成了 O(\lg n) 不过,问题就是编码的时候可能很不舒服。

  • 在FPU寄存器中尽可能多地保留方程式的值

  • 在处理整数和浮点值时,把整数加载到 FPU 寄存器中并且执行运算,这样比对整数使用浮点指令要快。例如,不使用 FIDIV, 而是使用 FILD 加载整数,然后对 FPU 寄存器中的值执行 FDIVP 指令
  • 尽可能使用 FCOMI 指令,不使用 FCOM 指令

处理字符串

传送字符串

创建 MOVS 指令是为了向程序员提供把字符串从一个内存位置传送到另一个内存位置的简单途径。MOVS 指令有 3 种格式:

  • MOVSB: 传送字节
  • MOVSW: 传送个字(2 字节)
  • MOVSL: 传送一个双字(4 字节)

Intel 文档使用 MOVSD 传送双字。GNU 汇编器使用 MOVSL

MOVS 指令使用隐含的源和目标操作数。隐含的源操作数是 ESI 寄存器。它指向源字符串的内存位置。隐含的目标操作数是EDI 寄存器。它指向字符串要被复制到的目标内存位置。

使用 GNU 汇编器时,有两种方式加载 ESI 和 EDI 值,第一种方式是使用间接寻址。

通过在内存位置标签前面添加美元符号,内存位置的地址被加载到了 ESI 或者 EDI 寄存器中:

movl $output, %edi

这条指令把 output 标签的 32 位内存位置传送给 EDI 寄存器。

指定内存位膛的另一种方式是 LEA 指令。LEA 指令加载一个对象的有效地址。

leal output, %edi

output 标签的 32 位内存地址加载到 EDI 寄存器中。

.section .data
value1:
    .ascii "This is a test string.\n"

.section .bss
    .lcomm output, 40

.section .text
.globl main
main:
    leal value1, %esi
    leal output, %edi
    movsb
    movsw
    movsl

    pushl $output
    call printf

    add $4, %esp


    pushl $0
    call exit

每次执行 MOVS 指令时,数据传送后,ESI 和 EDI 寄存器会自动改变,为另一次传送做准备。通常这是件好事儿,但是有时候会变得有些难于处理。

这一操作难于处理的部分之一就是寄存器向哪个方向改变。ESI 和 EDI 寄存器可能自动地递增,也可能自动地递减,这取决于EFLAGS 寄存器中的 DF 标志。

如果 DF 标志被清零,那么每条 MOVS 指令执行之后 ESI 和 EDI 寄存器就会递增。如果 DF 标志被设置,那么每条 MOVS 指令执行之后 ESI 和 EDI 寄存器就会递减。因为上面的程序没有专门设置 DF 标志。为了确保 DF 标志被设置为正确的方向,可以使用下面的命令:

  • CLD 用于将 DF 标志清零
  • STD 用千设置 DF 标志

REP 指令的特殊之处在于它自己不执行什么操作。这条指令用于按照特定次数重复执行字符串指令,由 ECX 寄存器中的值进行控制。这和使用循环类似,但是不需要额外的 LOOP 指令。

REP 指令项复地执行紧跟在它后面的字符串指令,直到 ECX 寄存器中的值为零。这就是为什么称它为前缀的原因。

.section .data
value1:
    .asciz "This is a test of conversion program!\n"
length:
    .int (length - value1)

.section .bss
    .lcomm output, (length - value1)

.section .text
.globl main
main:
    leal value1, %esi
    leal output, %edi
    movl length, %eax
    xorl %edx, %edx

    movl $4, %ecx
    divw %cx

    cld

    movl %eax, %ecx
    rep movsl

    movl %edx, %ecx
    rep movsb

    pushl $output
    call printf

    add $4, %esp


    pushl $0
    call exit

其他 rep 指令,监视零标志(ZF) 的状态的 REP 指令。

指令 描述
REPE 等于时煎复
REPNE 不等于时重复
REPNZ 不为零时重复
REPZ 为零时重复

存储和加载字符串

LODS 指令用于把内存中的字符串值传送到 EAX 寄存器中。和 MOVS 指令一样, LODS 指令有 3 种不同格式;

  • LODSB: 把一个字节加载到 AL 寄存器中
  • LODSW: 把个字(2字节)加载到 AX 寄存器中
  • LODSL: 把一个双字(4字节)加载到 EAX 寄存器中

Intel 使用 LODSD 加载双字, GNU 汇编器使用 LODSL。

使用 STOS 指令把 EAX 存放到另一个内存位置中。和 LODS 指令类似,根据要传送的数据的数脸,STOS 指令有3 种格式:

  • STOSB: 存储AL寄存器中一个字节的数据
  • STOSW: 存储AX寄存器中一个字(2字节)的数据
  • STOSL: 存储EAX寄存器中一个双字(4个字节)的数据

把空格字符(ASCII值 0x20) 复制到 256 字节的缓冲区区域。\

一个例子:

.section .data
space:
    .ascii "A"

.section .bss
    .lcomm buffer, 30


.section .text
.globl main
main:
    leal space, %esi
    leal buffer, %edi
    movl $29, %ecx
    cld
    lodsb
    rep stosb

    pushl $buffer
    call printf

    addl $4, %esp

    push $0
    call exit

比较字符串

CMPS 指令系列用于比较字符串值。和其他字符串指令一样, CMPS 指令有 3 种格式:

  • CMPSB: 比较字节值
  • CMPSW: 比较字(2字节) 值
  • CMPSL: 比较双字(4字节) 值

扫描字符串

SCAS 指令系列用于扫描字符串搜索一个或者多个字符。和其他字符串指令一样,SCAS 指令有 3 个版本:

  • SCASB: 比较内存中的个字节和 AL 寄存器的值
  • SCASW: 比较内存中的个字和 AX 寄存器的值
  • SCASL: 比较内存中的一个双字和 EAX 寄存器的值

SCAS 指令本身没有什么令人兴奋的地方。它仅仅是把 EDI 寄存器当前指向的字符和 AL 寄存器中的字符进行比较,这和CMPS 指令类似。把 SCAS 与 REPE 和 REPNE 前缀一起使用时,它的方便性才显现出来。

使用函数

汇编函数

.type func1, @function
func1:
    ...

C 样式的参数传递

C 的把输入值传递给函数的解决方案是使用堆栈。主程序可以访问堆栈,程序中使用的任何函数也可以。这样就创建了在通用的位置在主程序和函数之间传递数据的明确途径,而无需担心破坏寄存器或者定义全局变量。

同样,C 样式定义了把值返问主程序的通用方法:

  • EAX 寄存器用于 32位结果(比如短整数)
  • EDX:EAX 寄存器对用于 64 位整数值
  • FPU 的 ST(0) 寄存器用于浮点值

可以通过距离 ESP 寄存器值的偏移量使用间接寻址访间每个参数,而不必使用 POP 指令把值弹出堆栈。

但是,这种技术有个问题。因为在函数中,函数处理的某个部分可能包含把数据压入堆栈的操作。如果发生这种情况,就会改变 ESP 堆栈指针的位置,并且丢失用于访问堆栈中的参数的间接寻址值。

为了避免这个问题,通用的做法是进入函数时把 ESP 寄存器复制到 EBP 寄存器,这样确保有一个寄存器永远包含指向调用函数时的堆栈顶部的正确指针。函数执行过程中压人堆栈的任何数据都不会影响 EBP 寄存器的值,为了避免破坏原始的 EBP 寄存器值,如果主程序中使用它的话,在复制 ESP 寄存器的值之前,EBP 寄存器的值也被存放到堆栈中。

函数模板如下所示:

function:
    pushl %ebp
    movl %esp, %ebp

    ...

    movl %ebp, %esp
    popl %ebp
    ret

定义局部函数数据

当程序控制权在函数代码中时,处理过程很可能需要在某个位置存储数据元素。前面讨论过,可以在函数代码中使用寄存器,但是这种方式只提供有限的工作区域。也可以使用全局变量来处理数据,但是问题在于这会额外要求主程序为函数提供专门的数据元素。当在函数中为数据元素的存储寻找方便的位置时,堆栈再一次提供了帮助。

EBP 寄存器被设置为指向堆栈的顶部之后.函数中使用的任何附加的数据,都可以存放在堆栈中这个指针之后,这不会影响对输入值的访问。

在堆栈中定义局部变量之后,可以使用 EBP 寄存器很容易地引用它们。假设对于 4 字节的数据值,可以通过引用 -4(%ebp) 访问第一个局部变量,引用 -8(%ebp) 访问第二个局部变量。

这种设置还有一个残留的问题,如果函数把任何数据压人堆栈,ESP 寄存器仍然指向局部变量被存放之前的位置,并且将覆盖这些变量。

为了解决这个问题,在函数代码的开始添加了另一行,通过从 ESP 寄存器减去一个值,为局部变量保留一定数量的堆栈空间。

使用独立的函数文件

使用 C 样式函数调用的另一个好处是函数是完全自包含的。不需要为访问数据而定义全局变量,所以函数中不帮要包含 .data 指令。

这种自由带来了另一个好处,再也不需要在主程序的源代码文件中包含函数源代码了。对于涉及到很多人员的大型项目的程序员来说,这个好处非常有帮助。各个函数可以自包含在它们自己的文件中,并且连接在一起成为最终产品。在编写包含在独立文件中的函数的代码时,程序员会发现继续使用全局变量来传递数据很快就变成了一个问题。每个函数文件都需要跟踪用到的全局变量。

必须把函数名称声明为全局标签,以便其他程序能够访问它。这是使用 .globl 指令完成的:

命令行参数

不同的操作系统使用不同的方法把命令行参数传递给程序,在试图解释 Linux 中如何把命令行参数传递给程序之前,最好首先解释 Linux 如何从命令行执行程序。

在 Linux 中,分配给程序运行的虚拟内存地址从地址 0x80480000 开始,到地址 0xbfffffff 结束。Linux 橾作系统按照专门的格式把程序存放在虚拟内存地址中。

内存区域中的第一块区域包含汇编程序的所有指令和数据 (来自 .bss 和 .data 段)。指令不仅包含汇编程序的指令代码,而且包含 Linux 运行程序的连接过程所需的指令信息。

bss 是 block starting symbol 的缩写.

内存区域中的第二块区域是程序堆栈。堆栈从内存区域的底部向下增长。鉴于此,读者会认为程序每次启动时,堆栈指针会被设为 0xbfffffff, 但是情况并非如此。在加载程序之前,Linux 把一些内容放到堆栈中,命令行参数就在这里。

程序启动时, Linux 把 4 种类型的信息存放到程序堆栈中:

  • 命令行参数(包括程序名称)的数目
  • 从 shell 提示符执行的程序的名称
  • 命令行中包含的任何命令行参数
  • 在程序启动时的所有当前 Linux 环境变量

程序名称、命令行参数和环挽变址是以 0 结尾的长度可变的字符串。为了使工作更加简单,Linux 不仅把字符串加载到堆栈中,它还把指向每
个这些元素的指针加载到堆栈中,所以可以容易地在程序中定位它们。

.section .data
output1:
    .asciz "There are %d parameters:\n"

output2:
    .asciz "%s\n"

.section .text
.globl main
main:
    movl (%esp), %ecx
    pushl %ecx

    pushl $output1
    call printf

    addl $4, %ebp
    popl %ecx
    movl %esp, %ebp
    addl $4, %ebp
loop1:
    pushl %ecx
    pushl (%ebp)
    pushl $output2
    call printf
    addl $8, %esp
    popl %ecx
    addl $4, %ebp
    ; loop loop1

    pushl $0
    call exit

上面这个例子是书上的,但是我没有调通,不知道为什么。

Linux 系统调用

内存管理

操作系统内核的一个主要功能是内存管理。内核不仅管理可用的物理内存。它还负责创建和管理虚拟内存,或者说在物理上不存在于主板上的内存。

内核通过使用硬盘上的空间完成这个工作,这种空间称为交换空间(swap space),它从硬盘到实际的物理内存来回地交换内存位置。这使系统能够假设可用的内存比物理内存要多。内存位置被分组为称为页(page)的块,每个内存页要么位于物理内存中,要么位于交换空间中。内核必须维护一个表明哪些页在哪些位置的内存页表。

内核自动地把一段时间内没有被访问的内存页复制到硬盘上的交换空间区域。当程序要访问已经被换出的页时,内核必须交换出其他内存页并从交换空间换入所需的页。

在 Linux 系统中,通过查看 /proc/meminfo 文件可以确定虚拟内存的节前状态。在不同的 Linux 系统之间, meminfo 文件的输出是不同的。

运行在 Linux 系统上的每个进程都有其自己的私有内存区域。一个进程不能访问另一个进程正在使用的内存。没有进程能够访问内核进程使用的内存。为了便于进行数据共享,可以创建共享内存段,多个进程可以读取和写入通用共享内存区域。内核必须维护和管理共享内存区域。可以使肋 ipcs 命令查看系统上当前的共享内存段。

知个段还有标准的 UNIX 权限设置,它设置这个段对其他用户的可用性。key 值用于使其他用户可以访问共享内存段。

设备管理

内核的另一个任务是硬件管理。必须与 Linux 系统进行通信的任何设备都需要插入到内核代码中的驱动代码。驱动代码使内核可以在通用接口到设备之间来回传递数据。有两种方法用于把设备驱动代码插入到 Linux 内核中。

  • 把驱动代码编译到内核代码中
  • 把驱动代码插入到正在运行的内核中

以前,插入设备驱动代码的唯一途径是重新编译内核。每次把新的设备添加到系统中时,都要重新编译内核代码。随着 Linux 内核支持的硬件越来越多,这种方式的效率就越低。

把驱动代码插入到正在运行的内核中的更好的方法被开发出来了。这就是内核模块 (kernel module) 的概念, 它允许把驱动代码插入到正在运行的内核中,当设备使用完毕时也可以从内核删除驱动代码。

在 UNIX 服务器上,硬件设备被标识为特殊的设备文件。有 3 种不同类别的设备文件。

  • 字符
  • 网络

字符文件代表一次只处理一个字符数据的设备。大多数类型的终端接口被创建为字符文件。

块文件代表一次处理一大块数据的设备,比如磁盘驱动器。

网络文件类型代表使用包发送和接收数据的设备。这包括网卡和特殊的回送设备,

回环设备允许 Linux 系统使用通用网络编程协议和自身进行通信。

在文件系统中,设备文件被创建为节点。每个节点都具有对 Linux 内核标识它的唯一的数字对。这个数字对包含主要设备号和次要设备号。相似的设备被分配到相同的主设备号组中。次设备号用于在主设备号相同的设备之间标识设备。

文件系统

和一些其他的操作系统不同,Linux 内核可以支持不同类型的文件系统,对硬盘驱动器读取和写入数据。现在, Linux 系统上可用的有 15 种不同类型的文件系统,内核必须被编译为支持系统将使用的所有文件系统类型,下表介绍Linux 系统上可用的标准文件系统。

文件系统 描述
affs Amiga文件系统
ext Linux 扩展文件系统
ext2 第二扩展文件系统
ext3 第三扩展文件系统
hpfs OS/2 高性能文件系统
iso9660 ISO 9660 的文件系统(CD-ROM)
miniX MINIX 文件系统
msdos Microsoft 的 FAT16
ncp Netware 文件系统
proc 访问系统信息
reiserfs 日志文件系统
sysv 旧式的 UNIX 文件系统
ufs BSD 文件系统
umsdos 驻留于 MS-DOS 上的类 UNIX 文件系统
vfat Windows 95 文件系统(fat32)

Linux 内核使用虚拟文件系统(Virtual File System, VFS) 与每种文件系统进行交互。这为内核与任何类型的文件系统的通信提供标准的接口,挂载和使用每种文件系统时,VFS 把信息缓存在内存中。

内核提供系统调用,帮助使用 VFS 来管理和访问每种不同文件系统上的文件。单一系统调用可以用于访问任何文件系统类型上的文件。

进程管理

Linux 操作系统把程序作为进程进行管理。内核控制如何在系统中管理进程。内核创建的第一个进程 (称为 init 进程) 启动系统上的所有其他进程。内核启动时, init 进程被加载到虚拟内存中。每个进程启动时,为它分配虚拟内存中的区域,用于存储数据和系统将执行的代码。

一些 Linux 实现包含在引导时自动启动的终端进程的列表。每个终端进程都提供一个访问点,用于交互地登录到 Linux 系统。init 进程启动时, 它读取文件/etc/inittabs 以便确定它必须在系统上启动什么终端进程。

Linux 操作系统使用一种利用运行级别 (run level) 的 init 系统。运行级别用于指示 init 进程只运行特定类型的进程。在 Linux 操作系统上有 5 个 init 运行级别。

在运行级别1, 只启动基本的系统进程,还有一个控制台终端进程。这称为单一用户模式 (single-user mode)。单一用户模式经常用于文件系统维护。

标准的 init 运行级别是3 。在这个运行级别,启动大多数应用程序软什(比如网络支持软件)。在 Linux 中,另一个常用的运行级别是 5。在这个运行级别上启动 X Window 软件。注意 Linux 系统如何通过控制 init 运行级别来控制全面的系统功能。通过把运行级别从 3 改动为 5, 系统可以从基于控制台的系统改变为高级的、图形化的 X Window 系统。

为了查看 Linux 系统上当前活动的进程,可以使用 ps 命令。下面是选项:

选项 描述
l 使用长格式进行显示
u 使用用户格式(显示用户名称和启动时间)
j 使用作业格式(显示进程 gid 和 sid )
s 使用信号格式
v 使用 vm 格式
m 显示内存信息
f 使用 “森林型” 格式 (将进程显示为树型)
a 显示其他用户的进程
x 显示不带控制终端的进程
S 显示子 CPU 和时间以及页面错误
c 用于 task_struct 的命令名称
e 在命令行和 a+ 后显示环境
w 使用宽输出格式
h 不显示标题
r 只显示正在运行的进程
n 显示 USER 和 WCHAN 的数字输出
txx 显示终端 ttyxx 控制的进程
O 使用排序键 K1、k2 等对进程进行排序
pids 只显示指定的 pid

显示进程的当前状态。下表介绍可能的进程状态代码。

代码 描述
D 不可中断的睡眠
L 进程具有内存中锁定的页面
N 低优先权的任务
R 可运行
S 进程要求页面替换 (正在睡眠)
T 被跟踪或停止
Z 死亡(僵死)的进程
W 无驻留页面的进程
< 高优先权的进程

系统调用

系统调用的定义在下面的文件中:

/usr/include/unistd.h

为了访问系统调用定义,可以从命令提示符使用 man 命令:

man 2 exit

命令行中的 2 指定 man 页的第 2 部分,不要忘记包含它,因为一些系统调用也包含在 man 页第 1 部分中列出的Shell 命令。

下表介绍访问内核内存的系统调用:

系统调川 描述
brk 改变数据段长度
mlock 禁止对内存部分进行分页
mlockall 禁止对调用进程进行分页
mmap 把文件或者设备映射到内存中
mprotect 控制对内存区域的许可访问
mremap 重新映射虚拟内存地址
msync 同步文件和内存映射
munlock 允许对内存部分进行分页
munlockall 允许对调用进程进行分页
munmap 取消文件或设备在内存中的映射

下表介绍常用的设备访问内核系统调用。

系统调用 描述
access 检查设备的权限
chmod 改变设备的权限
chown 改变设备的所有关系
close 关闭设备文件描述符
dup 复制设备文件描述符
fcntl 操作文件描述符
fstat 获得设备的状态
ioctl 控制设备的参数
link 把新的名称分配给文件描述符
lseek 重新定位读取 / 写入文件偏移量
mknod 为设备创建新的文件描述符
open 为设备或文件打开/创建文件描述符
read 读取设备文件描述符
write 写入设备文件描述符

下表介绍文件系统系统调用。

系统调用 描述
chdir 改变工作目录
chroot 改变根目录
flock 在打开的文件上应用或删除协同锁 (advisory lock)
statfs 获得文件系统的统计数据
getcwd 获得当前工作目录
mkdir 创建目录
rmdir 删除目录
symlink 生成文件的新名称
umask 设置文件创建掩码
mount 挂装和卸载文件系统
swapon 开始内存和文件系统的交换
swapoff 停止内存和文件系统的交换

下表介绍进程系统调用。

系统调用 描述
acct 打开或者关闭进程计数
capget 获得进程功能
capset 设置进程功能
clone 创建子进程
execve 执行程序
exit 终止当前进程
fork 创建子进程
getgid 获得组标识
getpgrp 获得 / 设置进程组
gctppid 获得进程标识
getpriority 获得程序调度优先权
getuid 获得用户标识
kill 发送信号杀死进程
nice 改变进程优先权
vfork 创建子进程并且阻塞父进程

使用系统调用

unistd.h 文件中系统调用名称旁边列出的整数就是系统调用值(value)。每个系统调用都被分配了唯一的数字以便标识它。在执行 INT 指令之前,期望的值披传送到 EAX 寄存器中。


在 C 样式的函数中,输入值被存放在堆栈中;系统调用与之不同,需要的输入值被存放在寄存器中。每个输入值要按照特定的顺序存放到寄存器中。把错误的输入值存放在错误的寄存器中可能导致灾难性的结果。

系统调用的输入值顺序如下:

  • EBX(第1个参数)
  • ECX(第2个参数)
  • EDX(第3个参数)
  • ESI(第4个参数)
  • EDI(第5个参数)

需要超过 6 个输入参数的系统调用使用不同的方法把参数传递给系统调用。EBX 寄存器用于保存指向输入参数的内存的指针,输入参数按照连续的顺序存储。系统调用使用这个指针访问内存读取参数。


系统调用的返回值存放在 EAX 寄存器中。程序员负责检查 EAX 寄存器中的这个值,特别是在失败的情况下。

复杂的系统调用返回值

有时候系统调用返回涉及 C 样式结构的复杂数据。在汇编语言程序中使用它们时,打时候难以决定如何处理返回的 C 结构,以及如何把它转换为汇编语言程序能够处理的数据类型。首先需要在汇编中定义好相关的数据结构。

跟踪系统调用

strace 程序截取程序发出的系统调用并显示它们以供查看。被跟踪的程序可以是从 strace 命令运行的,也可以是系统上已经运行的进程。如果具有适当的权限,就可以研究现有的进程并监视发出的系统调用。在调试汇编语言和高级语言的程序时它是价值无法估量的工具。

下表介绍可用的命令行参数:

参数 描述
-c 统计每个系统调用的时间、调用和错误
-d 显示 strace 的一些调试输出
-e 指定输出的过滤表达式
-f 在创建子进程的时候跟踪它们
-ff 如果写入到输出文件,则把每个子进程写入到单独的文件中
-i 显示执行系统调用时的指令指针
-o 把输出写入到指定的文件
-p 附加到由 PID 指定的现有进程
-q 抑制关于附加和分离的悄息
-r 对每个系统调用显示一个相对的时间戳
-t 把时间添加到每一行
-tt 把时间添加到每一行 包括微妙
-ttt 添加 epoch 形式的时间 (从 1970 年 1 月 1 日开始的秒数) 包括微秒
-T 显示每个系统调用化费的时间
-v 显示系统调用信息的不经省略的版本 (详细的)
-x 以十六进制格式显示所有非 ASCII 字符
-xx 以十六进割格式显示所有字符串

系统调用和 C 库

C 库函数为程序员提供很多有用的功能。函数包含在 libc 库中,必须把它连接到汇编语言程序中。

使用原始 Linux 系统调用的主要原因如下:

  • 它创建长度尽可能短的代码,因为不需要把外部库连接到程序中。
  • 它创建尽可能快的代码,同样因为不需要把外部库连接到程序中。
  • 连接后的可执行文件独立于任何外部库代码。

在汇编程序中使用 C 库函数的要原因如下:

  • C 库包含很多函数,模拟它们需耍许多汇编代码 (比如 ASCII 到整数或者浮点数类型的转换)
  • C 库在操作系统之间是可移植的 (比如在 Intel 平台上运行的 Free BSD 上编译的程序也可以运行在 Linux 系统上
  • C 库函数可以在程序之间利用共享库,减少内存需求。

显然,使用哪种类型的函数都有其原因。基本原则是哪种方法更加适合现有应用程序程序员更喜欢使用哪种方法。如果正在编写和 C 或者 C++ 程序进行交互的汇编语言程序,也许读者已经习惯于使用 C 库函数,井且知道它们是可以使用的。

内联汇编

asm 扩展版本的格式如下:

asm ("assembly code" : output locations: input operands: changed registers);

这种格式由 4 个部分构成,使用冒号分隔:

  • 汇编代码:使用和基本 asm 格式相同的语法的内联汇编代码
  • 输出位置:包含内联汇编代码的输出值的寄存器和内存位置的列表
  • 输入操作数:包含内联汇编代码的输入值的寄存器和内存位置的列表
  • 改动的寄存器:内联代码改变的任何其他寄存器的列表

在扩展 asm 格式中,并不是所有这些部分都必须出现。如果汇编代码不生成输出值,这个部分就必须为空,但是必须使用两个冒号把汇编代码和输入操作数分隔开,如果内联汇编代码不改动寄存器的值,那么可以忽略最后的冒号。

在扩展格式中,可以从寄存器和内存位置给输入和输出赋值。输入值和输出值列表的格式是:

"constraint" (variable)

其中 variable 是程序中声明的 C 变量。在扩展 asm 格式中,局部和全局变量都可以使用。

constraint 定义把变量存放到哪里(对于输入值)或者从哪里传送变量(对于输出值)。使用它定义把变量存放在寄存器中还是内存位置中。

约束时单一字符的代码,约束代码如下所示:

约束 描述
a 使用 eax 寄存器
b 使用 ebx 寄存器
c 使用 ecx 寄存器
d 使用 edx 寄存器
S 使用 esi 寄存器
D 使用 edi 寄存器
r 使用任何可用的通用寄存器
q 使用 eax,ebx,ecx,edx 之一
A 对于 64 位值使用 eax 和 edx 寄存器
f 使用浮点寄存器
t 使用第一个(顶部的)浮点寄存器
u 使用第二个浮点寄存器
m 使用变量的内存位置
o 使用偏移的内存位置
V 只使用直接内存位置
i 使用立即数整型
n 使用值已知的立即数整型
g 使用任何可用的寄存器或者任何内存位置

除了这些约束之外,输出值还包含一个约束修饰符,它指示编译器如何处理输出值,可以使用的输出修饰符如下表所示。

输出修饰符 描述
+ 可以读取和写入操作数
= 只能写入操作数
% 如果必要,操作数可以和下一个操作数切换
& 在内联函数完成之前,可以删除或者重新使用操作数

在扩展asm 格式中,为了在汇编代码中引用寄存器,必须使用两个百分号符号,而不是一个(其原因将在稍后讨论)。这使代码看上去稍显奇怪,但是没有太多区别。

#include <stdio.h>

int main()
{
    int data1 = 10;
    int data2 = 20;
    int result;

    asm(
        "imul %%edx, %%ecx\n"
        "movl %%ecx, %%eax\n"
        : "=a"(result)
        : "d"(data1), "c"(data2));

    printf("The result is %d\n", result);

    asm(
        "movl $1, %eax \n"
        "movl $0, %ebx \n"
        "int $0x80 \n"
    );

}

编译器把 data1data2 的值传送到为 C 变量保留的堆栈空间,然后按照内联汇编代码的要求。把这些值加载到 EDXECX 寄存器中。然后把 EAX 寄存器中生成的结果输出传送到堆栈中的变量位置 result

不一定总要在内联汇编段中指定输出值。一些汇编指令已经假设输入值包含输出值。

#include <stdio.h>

int main()
{
    char input[] = {"This is a test message."};
    char output[sizeof(input)];

    int length = sizeof(input);

    asm volatile(
        "cld \n"
        "rep movsb \n"
        :
        : "S"(input), "D"(output), "c"(length));
    printf("%s\n", output);
    exit(0);
}

movstest.c 程序把 MOVS 指令需要的 3 个输入值指定为输入值。要复制的字符串的位置存放在 ESI 寄存器中,目标位置存放在 EDI 寄存器中,要复制的字符串的长度存放在 ECX 寄存器中(记住字符串长度中包含结尾的空字符)。

输出值已经被定义为输人值之一,所以在扩展格式中没有专门定义输出值。因为没有定专门的输出值,所以使用关键字 volatile 很重要;否则,编译器也许会认为这个 asm 段是不必要的而删除它,因为它不生成输出。

使用占位符的例子

#include <stdio.h>

int main()
{
    int data1 = 10;
    int data2 = 20;
    int result;

    asm(
        "imul %1, %2\n"
        "movl %2, %0\n"
        : "=r"(result)
        : "r"(data1), "r"(data2));

    printf("The result is %d\n", result);

    asm(
        "movl $1, %eax \n"
        "movl $0, %ebx \n"
        "int $0x80 \n"
    );
}

引用占位符的例子

#include <stdio.h>

int main()
{
    int data1 = 10;
    int data2 = 20;

    asm(
        "imul %1, %0\n"
        : "=r"(data2)
        : "r"(data1), "0"(data2));

    printf("The result is %d\n", data2);

    asm(
        "movl $1, %eax \n"
        "movl $0, %ebx \n"
        "int $0x80 \n"
    );
}

调用汇编库

C 函数调用的汇编语言函数的基本模板如下:

.section .text
.type func, @function
func:
    pushl %ebp
    movl %esp, %ebp
    subl $12, %esp
    pushl %edi
    pushl %esi
    pushl %ebx

    ...

    popl %ebx
    popl %esi
    popl %edi
    movl %ebp, %esp
    popl %ebp
    ret

在 C++ 程序中使用汇编函数

在 C++ 程序中使用汇编函数的规则几乎和在 C 程序中使用它们的规则相同。只有一处区别,但这一区别是重要的。

默认情况下,C++ 程序假设在 C++ 程序中使用的所有函数都使用 C++ 样式的命名和调用约定。

但是、程序中使用的汇编语言函数使用 C 语言的调用约定,必须通知编译器使用的哪些函数是 C 函数。这是通过 extern 语句完成的。

extern 关键字用于定义使用 C 调用约定的函数,它使用下面的格式:

extern "C"
{
    int square(int);
    float areafunc(int);
    char *cpuidfunc();
}

创建静态库

在 Linux 环境中, 使用 ar 命令创建静态库文件。ar 命令创建可供编译器读取的函数目标文件的存档文件。可以使用 ar 命令的若干命令行选项,如下表所示:

选项 描述
d 从存档文件中删除文件
m 把文件移动到存档文件中
p 把存档文件中指定的文件输出到标准输出
q 快速地把文件追加到存档文件中
r 把文件插入 (替换) 到存档文件中
t 显示存档中文件的列表
x 从存档文件提取文件

可以使用一个或者多个修饰符修改基本选项,如下表所示。

修饰符 描述
a 把新的文件添加到存档文件中现有的文件之后
b 把新的文件添加到存档文件中现有的文件之前
c 创建新的存档文件
f 截短存档文件中的名称
i 在存档文件中现有文件之前插入新的文件
P 在存档文件中使用文件的完整路径名称
s 编写存档文件的索引
u 更新存档文件中的文件 (使用新文件替换旧的)
v 使用详细模式

在创建库文件之前,应该知道库的命名约定。不同操作系统使用不同约定标识库文件。Linux 操作系统使用下面的约定:

libx.a

创建共享库(动态库)

使用 gcc 编译器从目标文件创建共享库。在创建共享库之前,必须使用 as 对汇编语言函数进行汇编。和处理静态库一样, Linux 具有用于共享库的命名约定:

libx.so

用于创建共享库的 gcc 命令行选项是 -shared 选项。

动态加载器必须知道如何访问共享库,有两种方式通知它文件在什么地方:

  • LD_LIBRARY_PATH 环境变量
  • /etc/ld.so.conf 文件

优化程序

使用编译器优化代码

编译器的 -O 选项系列提供 GNU 编译器的优化步骤。每个步骤都提供更高级别的优化。当前优化可用的有 3 个级别:

  • -O:提供基础级别的优化
  • -O2: 提供更加高级的代码优化
  • -O3: 提供最高级别的优化

在优化的第一个级别执行基础代码优化。在这个级别试图执行 9 种单独的优化功能。试图 这个词是因为不能保证任何优化功能肯定实现,编译器只是试图执行它们而已。下列清单介绍这个级别包含的 -f 优化功能:

  • -fdefer-pop: 这种优化技术,与汇编代码在函数完成时如何进行操作有关,一般情况下,函数的输入值被存放到堆栈中被函数访问。函数返回时输入值还在堆栈中,函数返回之后,输入值被立即弹出堆栈。这个选项允许编译器跨越函数调用,使输入值累积在堆栈中。然后使用单一指令一次把所有这些累积的输入值删除(通常通过把堆栈指针改动为适当的值完成,对于大多数操作,这完全合法的,因为新的函数的输入值被存放到堆栈中旧的输入值的顶部,但是,这样使堆栈中的内容有些杂乱。

  • -fmerge-constants: 使用这种优化技术,编译器试图合并相同的常量。这一特性有时候会导致很长的编译时间,因为编详器必须分析 C 或者 C++ 程序中用到的每个常量,并且相互比较它们。

  • -fthread-jumps: 这种优化技术,与编译器如何处理汇编代码中的条件和非条件分支有关。在某些情况下,一条跳转指令可能转移到另一条分支语句。通过一连串跳转,编译器确定多个跳转之间的最终目标并把第一个跳转重新定向到最终目标。

  • -floop-optimize: 通过优化如何生成汇编语言中的循坏,编译器可以在很大程度上提高应用程序的性能;通常、程序由很多大型且复杂的循坏构成。通过删除在循环内没有改变值的变量赋值操作,可以减少循环内执行的指令的数量,在很大程度上提高性能,此外,优化那些确定何时离开循环的条件分支,以便减少分支的影响。

  • -fif-conversion: if-then 语句是应用程序中仅次于循环的最消耗时间的部分,简单的 if-then 语句可能在最终的汇编语言代码中生产众多条件分支。通过减少或者删除条件分支,以及使用条件传送、设置标志和使用运算技巧替换它们,编译器可以减少 if-then 语句中花费的时间

  • -fif-conversion2: 这种技术结合更加高级的数学特性,减少实现 if-then 语句所需的条件分支

  • -fdelayed-branch: 这种技术试图根据指令周期时间重新安排指令,它还试图把尽可能多的指令移动到条件分支之前,以便最充分地利用处理器指令缓存。

  • -fguess-branch-probability: 就像其名称所暗示的,这种技术试图确定条件分支最可能的结果,并且相应地移动指令,这和延迟分支(delayed-branch) 技术类似。因为是在编译时预测代码的安排,所以使用这一选项两次编译相同的 C 或者 C++ 代码很可能会产生不同的汇编语言源代码,这取决于在编译时编译器认为会使用哪些分支。因为这个原因.很多程序员不喜欢采用这个特性,并且专门地使用 -fno-guess-branch-probability 选项关闭这个特性。

  • -fcprop-registers: 因为在函数中把寄存器分配给变量,所以编译器执行第二次检查以便减少调度依赖性(两个段要求使用相同的寄存器)并且删除不必要的寄存器复制操作。


代码优化的第二个级别 (-O2) 结合了第一个级别的所有优化技术,再加上很多其他技术。下面的清单描述这个级别试图执行的附加的 -f 优化选项:

  • -fforce-mem: 这种优化在任何指令使用变量之前,强制把存放在内存中的所有变量都复制到寄存器中。对于只涉及单一指令的变量,这样也许不会有很大的优化效果。但是,对于在很多指令(比如数学操作)中都涉及到的变量来说气这会是很显著的优化,因为和访问内存中的值相比令处理器访问寄存器中的值要快得多。

  • -foptimize-sibling-calls: 这种技术处理相关的和递归的函数调用。通常,递归的函数调用可以被展开为系列一系列一般的指令,而不是使用分支。这样使处理器的指令缓存能够加载展开的指令井且处理它们和指令保持为需要分支操作的单独函数调用相比,这样更快。

  • -fstrength-reduce: 这种优化技术对循环执行优化,并删除迭代变量,迭代变量是捆绑到循环计数器的变量,比如使用变量、然后使用循环计数器变量执行数学操作的 for-next 循环。

  • -fgcse: 这种技术对生成的所有汇编语言代码执行全局通用子表达式消除(Global Common Subexpression Elimination, gcse) 例程。这些优化操作试图分析生成的汇编语言代码并且组合通用片断,消除冗余的代码段。注意,如果代码使用计算性的 goto,gcc 指令推荐使用 -fno-gcse 选项。

  • -fcse-follow-jumps: 这种特别的通用子表达式消除(Common Subexpression Elimination. cse) 技术扫描跳转指令,查找程序中通过任何其他途径都不会到达的目标代码,这种情况最常见的例子就是 if-then-else 语句的 else 部分。

  • -frerun-cse-after-loop: 这种技术在对任何循环已经进行过优化之后重新运行通用子表达式消除例程。这样确保在展开循环代码之后更进一步地优化循环代码。

  • -fdelete-null-pointer-checks: 这种优化技术扫描生成的汇编代码,查找检查空指针的代码。编译器假设间接引用空指针将停止程序。如果在间接引用之后检查指针,它就不可能为空。

  • -fexpensive-optimizations: 这种技术执行从编译时的角度来说代价高昂的各种优化技术,但是它可能对运行时的性能产生负面影响。

  • -fregmove: 编译器试图重新分配 MOV 指令中使用的寄存器,并且将其作为其他指令的操作数中以便最大化捆绑的寄存器的数量

  • -fschedule-insns: 编译器将试图重新安排指令,以便消除等待数据的处理器。对于在进行浮点运算时有延迟的处理器来说,这使处理器在等待浮点结果时可以加载其他指令。

  • -fsched-interblock: 这种技术使编译器能够跨越指令块调度指令,这可以非常灵活地移动指令以便使等待期间完成的工作最大化。

  • -fcaller-saves: 这个选项指示编译器针对函数调用保存和恢复寄存器,使函数能够访问寄存器值,而且不必保存和恢复它们。如果调用多个函数,这样能够节省时间,因为只进行一次寄存器的保存和恢复操作,而不是在每个函数调用中都进行。

  • -fpeephole2: 这个选项允许进行任何计算机特定的观察孔优化。

  • -freorder-blocks: 这种优化技术允许重新安排指令块以便改进分支操作和代码局部性

  • -fstrict-aliasing: 这种技术强制实行高级语言的严格变量规则。对于 C 和 C++ 程序来说,它确保不在数据类型之间共享变量。例如,整数变量不和单精度浮点变量使用相同的内存位置。

  • -funit-at-a-time: 这种优化技术指示编译器在运行优化例程之前读取整个汇编语言代码。这使编译器可以重新安排不消耗大量时间的代码以便优化指令缓存。但是,这会在编译时花费相当多的内存,对于小型计算机可能是一个问题。

  • -falign-functions: 这个选项用于使函数对齐内存中特定边界的开始位置。大多数处理器按照页读取内存,并且确保全部函数代码位于单一页之内能够改进性能。如果函数跨越页,为了完成函数就必须处理内存的另一个页。

  • -falign-loops: 和对齐函数类似,在内存中的页边界内对齐包含多次被处理的代码的循环是有好处的。处理循环时.如果它包含在单一内存页面内,就不需要交换代码所需的页。

  • -fcrossjumping: 这是对跨越跳转的转换代码的处理,以便组合分散在程序各处的相同代码。这样可以减少代码的长度,但是也许不会对程序性能有直接影响。


编译器优化级别3

使用 -O3 选项访问编译器提供的最高级别的优化,它整合了第一和第二级别中的所有优化技术,还有一些非常专门的附加优化技术。下面是这个级别包含的 -f 优化选项:

  • -finline-functions: 这种优化技术不为函数创建单独的汇编代码,而是把函数代码包含在调用程序的代码中。对于多次被调用的函数来说,为每次函数调用复制函数代码。虽然这样对减少代码长度不利,但是通过最充分地利用指令缓存代码,而不是在每次函数调用时进行分支操作,可以提高性能。

  • -fweb: 构建用于保存变量的伪寄存器网络。伪寄存器包含数据,就像它们是寄存器一样,但是可以使用各种其他优化技术进行优化,比如 cse 和 loop 优化技术。

  • -fgcse-after-reload: 这种技术在完全重新加载生成的且优化后的汇编代码之后,执行第二次 gcse 优化,帮助消除不同优化方式创建的任何冗余段。


优化运算

处理方程式时,几乎总有机会可以简化一些运算。有时候,为了在使用涉及到的变量时显示方程式流程,按照未经简化的形式把这些运算输入到 C 或者 C++ 源代码中。还有些时候,缺乏经验的程序员输入了混乱的源代码。

未经优化的运算

#include <stdio.h>

int main()
{
    int a = 10;
    int b, c;
    a = a + 15;
    b = a + 200;
    c = a + b;
    printf("The result is %d\n", c);
    return 0;
}
gcc -m32 -S calctest.c -fno-asynchronous-unwind-tables

生成如下代码:

    .file   "calctest.c"
    .text
    .section    .rodata
.LC0:
    .string "The result is %d\n"
    .text
    .globl  main
    .type   main, @function
main:
    leal    4(%esp), %ecx
    andl    $-16, %esp
    pushl   -4(%ecx)
    pushl   %ebp
    movl    %esp, %ebp
    pushl   %ebx
    pushl   %ecx
    subl    $16, %esp
    call    __x86.get_pc_thunk.ax
    addl    $_GLOBAL_OFFSET_TABLE_, %eax
    movl    $10, -20(%ebp)
    addl    $15, -20(%ebp)
    movl    -20(%ebp), %edx
    addl    $200, %edx
    movl    %edx, -16(%ebp)
    movl    -20(%ebp), %ecx
    movl    -16(%ebp), %edx
    addl    %ecx, %edx
    movl    %edx, -12(%ebp)
    subl    $8, %esp
    pushl   -12(%ebp)
    leal    .LC0@GOTOFF(%eax), %edx
    pushl   %edx
    movl    %eax, %ebx
    call    printf@PLT
    addl    $16, %esp
    movl    $0, %eax
    leal    -8(%ebp), %esp
    popl    %ecx
    popl    %ebx
    popl    %ebp
    leal    -4(%ecx), %esp
    ret
    .size   main, .-main
    .section    .text.__x86.get_pc_thunk.ax,"axG",@progbits,__x86.get_pc_thunk.ax,comdat
    .globl  __x86.get_pc_thunk.ax
    .hidden __x86.get_pc_thunk.ax
    .type   __x86.get_pc_thunk.ax, @function
__x86.get_pc_thunk.ax:
    movl    (%esp), %eax
    ret
    .ident  "GCC: (GNU) 10.2.0"
    .section    .note.GNU-stack,"",@progbits
gcc -m32 -S calctest.c -fno-asynchronous-unwind-tables -O3

生成如下代码:

    .file   "calctest.c"
    .text
    .section    .rodata.str1.1,"aMS",@progbits,1
.LC0:
    .string "The result is %d\n"
    .section    .text.startup,"ax",@progbits
    .p2align 4
    .globl  main
    .type   main, @function
main:
    leal    4(%esp), %ecx
    andl    $-16, %esp
    pushl   -4(%ecx)
    pushl   %ebp
    movl    %esp, %ebp
    pushl   %ebx
    call    __x86.get_pc_thunk.bx
    addl    $_GLOBAL_OFFSET_TABLE_, %ebx
    pushl   %ecx
    subl    $8, %esp
    pushl   $250
    leal    .LC0@GOTOFF(%ebx), %eax
    pushl   %eax
    call    printf@PLT
    addl    $16, %esp
    leal    -8(%ebp), %esp
    xorl    %eax, %eax
    popl    %ecx
    popl    %ebx
    popl    %ebp
    leal    -4(%ecx), %esp
    ret
    .size   main, .-main
    .section    .text.__x86.get_pc_thunk.bx,"axG",@progbits,__x86.get_pc_thunk.bx,comdat
    .globl  __x86.get_pc_thunk.bx
    .hidden __x86.get_pc_thunk.bx
    .type   __x86.get_pc_thunk.bx, @function
__x86.get_pc_thunk.bx:
    movl    (%esp), %ebx
    ret
    .ident  "GCC: (GNU) 10.2.0"
    .section    .note.GNU-stack,"",@progbits

优化变量

优化应用程序的最明显的途径之一是控制汇编程序如何处理变量,处理变量有 3 种方式:

  • 使用 .data 或者 .bss 段在内存中定义变量
  • 使用 EBP 基指针在堆栈中定义局部变量
  • 使用可用的寄存器保存变量值

使用文件

打开和关闭文件

系统调用 open 的格式如下

int open(const char *pathname, int flags, mode_t mode);

对文件的所有 open 请求都必须声明用于打开文件的访问类型,这些常量在下表中:

常量 数字值 描述
O_RDONLY 00 打开文件,用于只读访问
O_WRONLY 01 打开文件,用于只写访问
O_RDWR 02 打开文件,用于读写访问
O_CREAT 0100 如果文件不存在,就创建文件
O_EXCL 0200 和 O_CREAT 一起使用时,如杲文件存在,就不打开它
O_TRUNC 01000 如果文件存在并按照写模式打开,则把文件长度截断为 0
O_APPEND 02000 把数据追加到文件的结尾
O_NONBLOCK 04000 按照非阻塞模式打开文件
O_SYNC 010000 按照同步模式打开文件 (同时只允许一个写入操作)
O_ASYNC 020000 按照异步模式打开文件(固肘允许多个写入操作)

UNIX 权限

描述
001 1 执行权限
010 2 写权限
011 2 执行权限、写权限
100 4 读权限
101 5 读权限、执行权限
110 6 读写权限
111 7 读写权限、执行权限

打开错误返回代码

错误名称 错误值 描述
EPERM 1 操作不允许
ENOENT 2 没有此文件
EBADF 9 坏文件句柄数字
EACCES 13 权限被拒绝
EFAULT 14 坏文件地址
EBUSY 16 设备或资源忙
EEXIST 17 文件存在
EISDIR 21 是目录
EMFILE 24 打开文件太多了
EFBIG 27 文件过大
EROFS 30 只读文件系统
ENAMERTOOLONG 36 文件名过长

内存映射文件

内存映射文件使用系统调用 mmap 把部分文件映射到系统的内存中。被存放到内存中之后,程序可以使用标准内存访问指令访问内存位置,并且如果必须的话,可以修改它们。可以在多个进程之间共享内存位置,这使多个程序可以同时更新同一个文件。

参考资料