Alone Cáfe
There is no limit to learning.
阿龙咖啡
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 标签表明程序将从此处开始运行,若未指定则会报告警告,并且链接器会试图查找并且自行断定起始点,但不能保证它对所有程序都做出正确的判断

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

自定义起始点

也可以使用 _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 个字节
https://yenyu.cn/wp-content/uploads/2020/03/image-5.png

程序正文

编写汇编语言源程序 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

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

运行程序

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

./cpuid

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

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

关于 GCC

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

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

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

所以在直接使用 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 命令上做了一些强制指定

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

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

发表评论

textsms
account_circle
email

阿龙咖啡

AT&T 汇编语言 (二) : 程序组成
程序的组成 汇编语言程序由定义好的段构成,每个段都有不同的目的,三个最常用的段是 data 段(.data):声明带有初始值的数据元素,一般作为汇编语言的变量bss 段(.bss):声明使…
扫描二维码继续阅读
2020-03-22