Alone Cáfe
There is no limit to learning.
阿龙咖啡
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 开始的内存位置,内存的情况如图所示

https://yenyu.cn/wp-content/uploads/2020/03/image-10.png

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

静态符号定义命令

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

.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

调试运行结果

https://yenyu.cn/wp-content/uploads/2020/03/image-11.png

堆栈

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

堆栈的行为刚好相反,它被保留在内存区域的末尾位置(高地址),当往堆栈中填充数据时,它往下增长。并且对于数据操作而言,保持后进先出(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 命令对数据进行对齐操作,它紧贴在数据定义前面,它指示汇编器按照内存边界安置数据元素

赞赏
知识共享许可协议本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。欢迎转载、使用、重新发布,但务必保留文章署名。

发表评论

textsms
account_circle
email

阿龙咖啡

AT&T 汇编语言 (三) : 数据传送
数据定义 带有初值的区段 程序中 data 段和 rodata 段都是带有初始值(initialized)的区段,这些区段一般被直接汇编进目标文件中,写死在程序可执行文件内部。但是,在运行时刻它…
扫描二维码继续阅读
2020-03-23