AT&T x86_32 汇编_001_一个示例程序.md

这一节先写一个简单的汇编程序. 输出cpu的出产厂商. 不对语法, 寄存器等内容进行深入讨论, 只是整体上先有个认知印象.

1. 一些基础知识

简单来说, Linux下的可执行程序文件中, 最重要的三个部分是: 数据段, 代码段, bss段. 关于可执行文件, 以及目标文件的内容构成, 其实这是一个十分复杂的话题, 这里不进行深入讨论, 你可以简单的理解为:

  1. 可执行文件由段(section)组成. 每个可执行文件中存在多个段. 段是一种划分可执行二进制程序内容的手段
  2. 其中最重要的三个段:
    1. 数据段存储了程序运行期间的所有数据. 典型的有: 在C代码中定义且初始化了的全局变量, 函数内的静态变量.
    2. 代码段中存储了程序运行期间的所有指令. 可以理解为你在C代码中所写的所有逻辑语句, 循环语句, 函数调用, 函数定义等, 都在这里
    3. bss段比较特殊. 对于一些未初始化的全局变量和函数内的静态变量, 这部分数据登记在bss段. 这里之所以用登记这个说法, 是因为bss段在可执行程序的文件中, 是不占用长度的. 即这个段占用的字节数其实是0. 你可以这样理解: 这些根据语言标准, 初始值为0的变量, 不需要将它们存储在数据段中, 因为它们的值均是0, 所以简单的登记一下就行了, 程序运行起来后, 凡是登记在这里的变量, 统一给赋值为0即可.

上面的简单理解, 用于应付学习汇编其实并不够, 上面的理解, 其实也存在很多错误的地方. 如果有兴趣深究的话, 建议阅读 <程序员的自我修养: 链接, 装载与库> 这本书的的第三章: 目标文件里面有什么. 其中详细讲解了ELF文件的组成, 这里就不展开篇幅讲解了.

既然现在我们简单的认为, 可执行二进制文件就由三部分构成, 那么反过来理解, 程序的编译链接其实就是把高级语言的代码, 转换成一个二进制文件的过程. 也就是说通过代码, 来构造数据段, 代码段, 与bss段的过程. 同样的道理其实也适用于汇编语言, 不同的, 高级语言对使用者完全封装了的概念, 但在汇编语言中, 所谓的编译器最主要的工作, 只是把汇编代码, 转换为机器指令, 而关于如何分配, 定义段, 则是需要程序员自己手动负责.

所以从逻辑上来讲, 我们写的第一个程序需要做以下几件事:

  1. 定义必要的段. 鉴于程序比较简单, 我们应该不需要声明类似于C语言中的未初始化的全局及静态变量, 所以可以省略掉bss段, 只需要声明且定义代码段数据段即可
  2. 在数据段中写上程序运行所需要的数据. 我们需要输出cpu的制造厂商, 最起码需要一个字符串常量, 类似于这台电脑的CPU生产厂商其实是xxxx这种常量.
  3. 在代码段中写上程序运行的逻辑, 这些逻辑通过汇编语言书写, 最终会被转换成cpu执行指令. 这里的逻辑包括两部分
    1. 通过某种手段, 询问CPU它的生产厂商是什么, 并得到一个字符串的回答
    2. 把生产厂商(字符串, 比如因特尔三个字), 通过程序逻辑, 拼接到我们的字符串常量后面去
    3. 最终把拼接完成的字符串, 通过某种手段, 输出到屏幕上去

具体到实际实施上, 就需要了解汇编语言的一点基础用法, 包括:

  1. 定义段的语法: GNU汇编使用.section命令语句声明一个段.
  2. 定义全局符号: .globl命令用心定义一个全局符号
  3. 定义程序运行的起始点: GNU汇编中, 默认以_start标签所标示的代码点, 为程序入口点

2. 一个简单的程序

上面的讲解肯定有很多你听不明白, 理解不了的东西, 不要紧, 我们先直接来看这个程序的全文

# cpuid.s: 查看CPU厂商信息

.section .data      # 数据段
output:
    .ascii "The processor Vendor ID is 'xxxxxxxxxxxx'\n"    # 单引号内是12个x, 厂商信息也共12个字节
#           ---|---|---|---|---|---|---|---|---|---|--
#           0  3   7  11  15  19  23  27  31
#   所以第一个x的位置, 其实是 output[28]

.section .text      # 代码段
.globl _start       # 定义程序入口点符号
_start:
    movl $0, %eax       # 为eax寄存器赋值为0
    cpuid               # 调用cpuid指令

    movl $output, %edi      # 将数据段中的字符串起始地址, 放在寄存器edi中
    movl %ebx, 28(%edi)     # edi寄存器中存储的地址取出来, 再偏移28字节, 之后把ebx四个字节放在后面
    movl %edx, 32(%edi)     # 同上, 只不过偏移是32
    movl %ecx, 36(%edi)     # 同上, 只不过偏移是36

    # 以下为调用显示函数的代码
    # 0x80软中断是调用内核预置函数的方法, 具体调用哪个预置函数, 由 eax 寄存器在中断时的值确定
    movl $4, %eax           # 为eax寄存器赋值为4, 表示调用的是名为 write 的内核预置函数
    movl $1, %ebx           # write 系统调用要求, 在ebx寄存器中存放要写入的文件描述符. 这里写入1, 代表标准输出
    movl $output, %ecx      # write 系统调用要求, 在ecx中存放要写入的字符串地址, 这里写入 $output, 即为符号 output 的值, 即为字符串的起始地址
    movl $42, %edx          # write 系统调用要求, 在edx中存放字符串的长度. 这个字符串的长度为42个字符
    int $0x80               # 软中断, 调用write

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

2.1 程序中的数据段

我们通过.section .data, 声明了一个数据段. 这里需要注意的是, 在汇编程序中, 是没有所谓的类型的. 我们之所以把这个段叫数据段, 并不是因为这个段的名字叫.data, 也并不是因为它是程序中第一个段, 而是因为: 这个段中存储了我们需要使用的字符串常量, 即程序运行中所需要的数据.

也就是说, 所谓段的类型, 其实是程序员出于对程序结构功能的划分, 人为制造出来的概念. 你可以声明一个叫.text的段, 但里面存储的是数据, 也可以声明一个名为.data的段, 里面写着指令. 更可以声明一个叫.fuckyou的段, 里面你爱存什么存什么.

而我们一般情况下, 约定俗成的把数据段命名为.data, 把代码段命名为.text等等, 其实是因为: 这些约定俗成的段名, 是GNU编译器编译高级语言, 特别是C代码时, 对各个功能段的命名.

.section .data下的两行, 做了两件事:

  1. 定义了一个符号, 叫output.
  2. 通过.ascii声明了一个字符串, 这个字符串有42个字符, 其中从索引28开始, 至索引40结束, 即字符串中的12个x, 是预留的空位, 用心在取到CPU厂商名字后, 把CPU厂商的名字写在其中.

通过单词+冒号的方式, 可以在汇编程序中声明一个符号. 这种写法有点类似于C语言中的label, 标签. 你现在可以这样简单的理解符号: 它就是一个变量, 或函数名!

所以, 用C的思维去看待数据段, 其实就做了一件事:

static char output[] = "The processor Vendor ID is 'xxxxxxxxxxxx'\n";

2.2 程序中的代码段

我们通过.section .text, 声明了一个段, 名为.text, 根据约定俗成的规则, 这是一个代码段.

我们还通过_start:, 声明了一个符号, 名为_start, 目前我们可以简单的理解为, 这是一个函数, 名为_start.

_start:以下, 都是汇编语句, 可以简单的理解为, 这就是函数的内部实现.

一个陌生的命令, 是.globl _start. 即是.globl命令. 目前你可以简单的理解为, 符号经过.globl修饰后, 就是一个全局符号. 类比于C语言中的函数或变量, 所谓的全局符号就是: 全局变量, 以及非static函数

2.2.1 向CPU发送请求: "你是哪家工厂生产的?"

cpuid是一个特殊的指令, 当你在汇编代码中使用cpuid时, 其实是在向cpu询问: 你从哪来? 到哪去? 家里几头牛? 地里几亩地?

寄存器eax中的值, 决定了你问的具体是哪一个问题. 所以我们在询问之前, 先把eax寄存器的值设置为0: 这其实是在问: 你是哪家厂商生产的?

eax设置为不同的值, 其实是在向cpu提出不同的问题. 并且对于不同的问题, cpu回应问题的方式也不同. 但就询问厂商这个请求来说, cpu会将厂商的名字, 分别放在三个寄存器中去. 分别是:

  1. ebx寄存器, 32位, 4字节, 里面放着厂商名字的前四个字符
  2. edx寄存器, 里面放着厂商名字的中间的四个字符
  3. ecx寄存器, 里面放着厂商名字的后四个字符

即是, 在cpuid这条指令执行时: cpu会去读取寄存器eax的值, 以确认你的提问到底是什么内容. 我们在cpuid这条指令之前, 赋值eax寄存器的值为0, 其实这是询问厂商名称的命令.

cpuid这条指令执行之后, cpu会将厂商的名称, 共12个字符, 切成三块, 分别放在三个寄存器中. 即下一步, 我们需要把三个寄存器中的内容, 挪到我们在数据段声明的字符串中, 并把其中的12个x给替换掉.

cpuid指令还可以询问很多其它内容, 有关这个指令的详情, 请参考x86指令参考文档 CPUID

2.2.2 把CPU的回答, 挪到字符串中去:

以下四个语句, 就是在执行这个操作

    movl $output, %edi      # 将数据段中的字符串起始地址, 放在寄存器edi中
    movl %ebx, 28(%edi)     # edi寄存器中存储的地址取出来, 再偏移28字节, 之后把ebx四个字节放在后面
    movl %edx, 32(%edi)     # 同上, 只不过偏移是32
    movl %ecx, 36(%edi)     # 同上, 只不过偏移是36

这里比较奇怪的是, 引入了一个名为edi的寄存器. 这里先感性认识一下. 至于为什么要引入edi, 以及这四条语句为什么要这样写, 后续章节再介绍

总之, 忽略掉细节, 这四条语句执行完毕之后, 我们定义在数据段中的字符串, 内容就会变成类似于下面这样:

static char output[] = "The processor Vendor ID is '因特尔'\n";  # 实际情况下, cpu的名字是12个字节的ASCII码英文字符, 而不是中文字符

2.2.3 将字符串输出到屏幕上

在学习高级语言时, 我们向屏幕输出内容, 一般都是调用语言的类库接口, 比如C中的printf, C++中的std::cout <<等. 这些类似背后做了什么工作, 高级语言的使用者是不关心的.

所以这里, 我们需要先回想一下, 从硬件到软件的抽象层次, 我们以"在屏幕上显示内容"为例子, 在这个过程中, 如果你全C中的printf来输出一串字符串, 其实要经过这么几个封装层级:

  1. libc层级. libc向你提供了printf函数. 程序员在这一层, 将要输出的内容, 交给printf函数.
  2. 操作系统层级. printf函数在各个操作系统上都可用. 但在其背后, 对于不同的操作系统, printf其实调用的是操作系统中输出内容的接口. 对于Linux系统来说, 在这一层, printf函数, 调用的是write系统调用, 即syscall
  3. 硬件驱动程序层级. 操作系统背后是千差万别的硬件, 对于输出字符来说, 可能是老式的CRT大屁股显示器, 也可能是VGA接口上的投影仪, 也可能是DELL的24寸液晶显示器, 甚至有可能是一个绘图仪, 打印机什么的. 显示设备可能是彩色的, 也可能是黑白的, 对于彩色显示器, 可能最高支持16位色, 32位色等等乱七八糟的. 但是操作系统不可能内部囊括所有显示设备的信息. 对于操作系统来说, 要输出一些内容并显示出来, 操作系统只是把这部分内容扔在一个中转站中. 可以简单的理解这个中转站就是所谓的显存. 操作系统将层层传递下来的字符串, 扔到内存空间中一块特定的区域, 也就是显存中, 然后就不管了, 至于这个东西怎么显示, 那就是显示设备驱动程序的责任了. 驱动程序是连接硬件与软件的桥梁, 显示设备的驱动程序在读取显存的内容后, 将内容进行翻译, 翻译成电压, 电流, 扔给硬件.
  4. 最终, 硬件接受到最简朴的电压, 电流的变换, 控制着设备上的像素变化, 这个字符串才显示到你面前.

以上的描述中, 有很多错误的地方, 对于程序员来说, 特别是上层程序员来说, 这样的认知和理解是无伤大雅的, 因为在上面的第三层, 所谓的我称为其为硬件驱动层级上, 其实有很多复杂的事情. 但这对于上层程序员来说, 并不是必须要了解的细节.

现在回头来想, 当我们用C语言输出中时, 我们位于最高的层级, 我们直接调用printf. 而当我们使用汇编语言时, 我们要输出一个字符串, 我们位于哪一层? 我们调用的是哪一层的接口呢?

严格的来说, 当使用汇编语言时, 并没有限定我们非得在某一层, 我们既可以调用libc中的printf函数: 是的, C语言中可以内联汇编, 当然汇编代码是可以调用C库的. 也可以位于操作系统层级, 我们可以通过特殊的方法调用write系统调用. 我们更可以直接写显存(可能绕过操作系统的屏障要做一些额外的工作), 甚至于, 我们可以在硬件驱动程序中去完成这个任务: 硬件驱动程序也是由C和汇编编写的. 总之, 用汇编要完成"输出字符串"这项任务, 其实只要位于软件层面上, 都可以, 无非就是每一层的实现难度不一样而已.

而我们学习汇编的目的, 不是进行驱动开发, 也不是为了研究操作系统的实现, 而是通过学习汇编

  1. 理解代码的本质
  2. 通过汇编语言的内联, 来优化高级语言编写的代码

换句话说, 我们学习汇编, 脚下踏着的还是操作系统. 我们并不是要用汇编日天日地, 而是使用汇编, 在操作系统的肩膀上, 做高级语言很难做到的细致活. 再换个说法, 其实就是用汇编去写应用程序, 我们应用汇编的层次, 和C语言的层次是一样的. 所以, 回到输出串的话题上, 最适合我们的方法是: 调用操作系统的接口, 即write系统调用.

所以在示例程序中, 我们这样写:

    # 以下为调用显示函数的代码
    # 0x80软中断是调用内核预置函数的方法, 具体调用哪个预置函数, 由 eax 寄存器在中断时的值确定
    movl $4, %eax           # 为eax寄存器赋值为4, 表示调用的是名为 write 的内核预置函数
    movl $1, %ebx           # write 系统调用要求, 在ebx寄存器中存放要写入的文件描述符. 这里写入1, 代表标准输出
    movl $output, %ecx      # write 系统调用要求, 在ecx中存放要写入的字符串地址, 这里写入 $output, 即为符号 output 的值, 即为字符串的起始地址
    movl $42, %edx          # write 系统调用要求, 在edx中存放字符串的长度. 这个字符串的长度为42个字符
    int $0x80               # 软中断, 调用write

在这里你可以这样简单的理解:

  1. Linux操作系统本身提供了很多系统调用.
  2. 系统调用类似于函数调用. 在系统调用之前, 需要将要调用的命令号, 以及调用所需要的参数, 填写在各个寄存器中
  3. 类似于cpuid指令, 系统调用触发使用的是软中断int $0x80. 不同的是, cpuid翻译成cpu指令后, 是一条切实的cpu指令, 是写给cpu看的. 而int $0x80软中断, 不是直接写给cpu看的, 而是写给Linux操作系统看的. 操作系统被软中断后, 会查看相应的寄存器, 以确认用户到底想干嘛, 然后给出回应. 在这个过程中, 当操作系统被int $0x80中断后, 操作系统会跳转执行内核中的一些代码去完成用户的请求. 在整个过程中, cpu并不知道中断前后发生了什么, 它只是机械的执行指令, 而这个指令是用户的汇编代码里写的, 还是受软中断而执行的操作系统内核代码, cpu是不知情的.

所以上面的五行语句, 其实就做了两件事:

  1. write系统调用所需要的所有参数, 写在各个寄存器中
  2. 调用int $0x80, 触发软中断, 将控制权交接给操作系统, 由操作系统内核代码接管cpu. 完成内容输出.

怎么样? 是不是像极了一次高级语言中的函数调用? 是的, 就是这样, 汇编语言也是这样. 没有什么复杂的.

而至于write系统调用背后发生了哪些故事, 如何写显存, 显示设备驱动程序如何工作, 显示器如何点亮像素, 和我们就没什么关系了, 我们也不关心.

Linux提供了数量众多的系统调用, 截止目前, 已经有300多个, 关于Linux系统调用的参考文档, 可以参考这里, 在这个页面, 可以查询到一个系统调用, 名为sys_write, 即是我们上面说的write系统调用.

2.2.4 优雅的退出程序

在C语言中, 有一个很有意思的函数, 叫exit, 而我们写的汇编程序, 要优雅的退出, 也需要做类似的事情. 这个系统调用在这里, 名称叫sys_exit, 即是我们这个示例程序最后两行做的事情:

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

3. 编译, 链接, 与运行

如下gif所示:

001_cpuid

4. 总结

"汇编语言"本身, 是一个范畴很大的概念, 多数科班出身的程序员, 大多都读过这样的一本书: 汇编语言, 有很多高校甚至在本科学习阶段, 将本书列为汇编语言的教学教材, 这本书讲的汇编的目的是什么呢? 其实和我们的目的是完全不同的. 这本书的教学目的在我看来, 主要是:

  1. 向学生展示汇编语言
  2. 让学生了解寄存器, cpu, 内存等硬件, 与软件的联系
  3. 让学生深刻理解中断

这样的教学目的, 有一个很大的盲点就是: 学完这本书之后, 你几乎还是什么有用的东西都做不出来! 它对于科班学生计算机思维的培养很有用, 但对于实际工作应用, 基本作用为0

而我们学习汇编的目的是什么呢? 我们学习汇编的目的很功利:

  1. 我们希望了解一些被编译链接隐藏起来的细节实现
  2. 在高级语言表面上得不到的功能, 我们希望用汇编去实现.
  3. 使用内联汇编去优化我们的业务二进制包, 使在一些特殊应用场景下的代码跑的更快.

典型的汇编在工程上的应用, 就是C/C++中的协程库, 而我厂的libco更是一个标杆. 这样的汇编才是有用的. libco库中, 最核心的协程切换, 寥寥不到100行汇编, 就是这100行汇编, 撑起了微信后台开发的核心. 这样的汇编, 才是有用的.

注意, 我不是在批王爽这本书没卵用, 并不是. 王爽的这本书写的非常好, 十分好, 只是王爽老师写的这本书, 不适用于我们这种"功利的目的".

所以, 我们学习汇编有以下几个点需要注意:

  1. 我们在Linux平台下, 以AT&T语法去学习汇编. 因为这是GNU编译器在编译阶段生成汇编的格式. 也是binutils工具链的服务对象.
  2. 学习x86 32位汇编是一个过渡. 因为x86_64位汇编的学习, 基本上没有成体系的书籍去介绍引领. 我们需要先通过学习32位汇编, 把汇编中普适性的概念, 思想, 最佳实践学习到手, 之后再从32位汇编转向64位汇编.
  3. 我们脚踏操作系统平台, 不向下深挖. 确切的说, 是脚踏Linux系统调用

这样学习汇编, 需要先行了解编译, 链接的一些基础知识, 所以, 建议大家有空看看这一本书: 程序员的自我修养, 特别是书中的第三章.

猜你喜欢

转载自www.cnblogs.com/neooelric/p/9693643.html