这一节先写一个简单的汇编程序. 输出cpu的出产厂商. 不对语法, 寄存器等内容进行深入讨论, 只是整体上先有个认知印象.
1. 一些基础知识
简单来说, Linux下的可执行程序文件中, 最重要的三个部分是: 数据段
, 代码段
, bss段
. 关于可执行文件, 以及目标文件的内容构成, 其实这是一个十分复杂的话题, 这里不进行深入讨论, 你可以简单的理解为:
- 可执行文件由
段(section)
组成. 每个可执行文件中存在多个段. 段是一种划分可执行二进制程序内容的手段 - 其中最重要的三个段:
数据段
存储了程序运行期间的所有数据. 典型的有: 在C代码中定义且初始化了的全局变量, 函数内的静态变量.代码段
中存储了程序运行期间的所有指令. 可以理解为你在C代码中所写的所有逻辑语句, 循环语句, 函数调用, 函数定义等, 都在这里bss段
比较特殊. 对于一些未初始化的全局变量和函数内的静态变量, 这部分数据登记在bss段. 这里之所以用登记
这个说法, 是因为bss段
在可执行程序的文件中, 是不占用长度的. 即这个段占用的字节数其实是0
. 你可以这样理解: 这些根据语言标准, 初始值为0的变量, 不需要将它们存储在数据段中, 因为它们的值均是0, 所以简单的登记一下就行了, 程序运行起来后, 凡是登记在这里的变量, 统一给赋值为0即可.
上面的简单理解, 用于应付学习汇编其实并不够, 上面的理解, 其实也存在很多错误的地方. 如果有兴趣深究的话, 建议阅读 <程序员的自我修养: 链接, 装载与库>
这本书的的第三章: 目标文件里面有什么
. 其中详细讲解了ELF文件的组成, 这里就不展开篇幅讲解了.
既然现在我们简单的认为, 可执行二进制文件就由三部分构成, 那么反过来理解, 程序的编译链接其实就是把高级语言的代码, 转换成一个二进制文件的过程. 也就是说通过代码
, 来构造数据段
, 代码段
, 与bss段
的过程. 同样的道理其实也适用于汇编语言, 不同的, 高级语言对使用者完全封装了段
的概念, 但在汇编语言中, 所谓的编译器最主要的工作, 只是把汇编代码, 转换为机器指令
, 而关于如何分配, 定义段, 则是需要程序员自己手动负责.
所以从逻辑上来讲, 我们写的第一个程序需要做以下几件事:
- 定义必要的段. 鉴于程序比较简单, 我们应该不需要声明类似于C语言中的
未初始化的全局及静态变量
, 所以可以省略掉bss段
, 只需要声明且定义代码段
与数据段
即可 - 在数据段中写上程序运行所需要的数据. 我们需要输出cpu的制造厂商, 最起码需要一个字符串常量, 类似于
这台电脑的CPU生产厂商其实是xxxx
这种常量. - 在代码段中写上程序运行的逻辑, 这些逻辑通过汇编语言书写, 最终会被转换成cpu执行指令. 这里的逻辑包括两部分
- 通过某种手段, 询问CPU它的生产厂商是什么, 并得到一个字符串的回答
- 把生产厂商(字符串, 比如
因特尔
三个字), 通过程序逻辑, 拼接到我们的字符串常量后面去 - 最终把拼接完成的字符串, 通过某种手段, 输出到屏幕上去
具体到实际实施上, 就需要了解汇编语言的一点基础用法, 包括:
- 定义段的语法: GNU汇编使用
.section
命令语句声明一个段. - 定义全局符号:
.globl
命令用心定义一个全局符号 - 定义程序运行的起始点: 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
下的两行, 做了两件事:
- 定义了一个
符号
, 叫output
. - 通过
.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会将厂商的名字, 分别放在三个寄存器中去. 分别是:
ebx
寄存器, 32位, 4字节, 里面放着厂商名字的前四个字符edx
寄存器, 里面放着厂商名字的中间的四个字符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
来输出一串字符串, 其实要经过这么几个封装层级:
libc
层级.libc
向你提供了printf
函数. 程序员在这一层, 将要输出的内容, 交给printf
函数.- 操作系统层级.
printf
函数在各个操作系统上都可用. 但在其背后, 对于不同的操作系统,printf
其实调用的是操作系统中输出内容的接口. 对于Linux系统来说, 在这一层,printf
函数, 调用的是write
系统调用, 即syscall
- 硬件驱动程序层级. 操作系统背后是千差万别的硬件, 对于输出字符来说, 可能是老式的CRT大屁股显示器, 也可能是VGA接口上的投影仪, 也可能是DELL的24寸液晶显示器, 甚至有可能是一个绘图仪, 打印机什么的. 显示设备可能是彩色的, 也可能是黑白的, 对于彩色显示器, 可能最高支持16位色, 32位色等等乱七八糟的. 但是操作系统不可能内部囊括所有显示设备的信息. 对于操作系统来说, 要输出一些内容并显示出来, 操作系统只是把这部分内容扔在一个中转站中. 可以简单的理解这个中转站就是所谓的
显存
. 操作系统将层层传递下来的字符串, 扔到内存空间中一块特定的区域, 也就是显存
中, 然后就不管了, 至于这个东西怎么显示, 那就是显示设备驱动程序的责任了. 驱动程序是连接硬件与软件的桥梁, 显示设备的驱动程序在读取显存的内容后, 将内容进行翻译, 翻译成电压, 电流, 扔给硬件. - 最终, 硬件接受到最简朴的电压, 电流的变换, 控制着设备上的像素变化, 这个字符串才显示到你面前.
以上的描述中, 有很多错误的地方, 对于程序员来说, 特别是上层程序员来说, 这样的认知和理解是无伤大雅的, 因为在上面的第三层, 所谓的我称为其为硬件驱动层级
上, 其实有很多复杂的事情. 但这对于上层程序员来说, 并不是必须要了解的细节.
现在回头来想, 当我们用C语言输出中时, 我们位于最高的层级, 我们直接调用printf
. 而当我们使用汇编语言时, 我们要输出一个字符串, 我们位于哪一层? 我们调用的是哪一层的接口呢?
严格的来说, 当使用汇编语言时, 并没有限定我们非得在某一层, 我们既可以调用libc
中的printf
函数: 是的, C语言中可以内联汇编, 当然汇编代码是可以调用C库的. 也可以位于操作系统层级, 我们可以通过特殊的方法调用write
系统调用. 我们更可以直接写显存(可能绕过操作系统的屏障要做一些额外的工作), 甚至于, 我们可以在硬件驱动程序中去完成这个任务: 硬件驱动程序也是由C和汇编编写的. 总之, 用汇编要完成"输出字符串"这项任务, 其实只要位于软件层面上, 都可以, 无非就是每一层的实现难度不一样而已.
而我们学习汇编的目的, 不是进行驱动开发, 也不是为了研究操作系统的实现, 而是通过学习汇编
- 理解代码的本质
- 通过汇编语言的内联, 来优化高级语言编写的代码
换句话说, 我们学习汇编, 脚下踏着的还是操作系统. 我们并不是要用汇编日天日地, 而是使用汇编, 在操作系统的肩膀上, 做高级语言很难做到的细致活. 再换个说法, 其实就是用汇编去写应用程序, 我们应用汇编的层次, 和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
在这里你可以这样简单的理解:
- Linux操作系统本身提供了很多系统调用.
- 系统调用类似于函数调用. 在系统调用之前, 需要将要调用的命令号, 以及调用所需要的参数, 填写在各个寄存器中
- 类似于
cpuid
指令, 系统调用触发使用的是软中断int $0x80
. 不同的是,cpuid
翻译成cpu指令后, 是一条切实的cpu指令, 是写给cpu看的. 而int $0x80
软中断, 不是直接写给cpu
看的, 而是写给Linux操作系统
看的. 操作系统被软中断后, 会查看相应的寄存器, 以确认用户到底想干嘛, 然后给出回应. 在这个过程中, 当操作系统被int $0x80
中断后, 操作系统会跳转执行内核中的一些代码去完成用户的请求. 在整个过程中,cpu
并不知道中断前后发生了什么, 它只是机械的执行指令, 而这个指令是用户的汇编代码里写的, 还是受软中断而执行的操作系统内核代码, cpu是不知情的.
所以上面的五行语句, 其实就做了两件事:
- 把
write
系统调用所需要的所有参数, 写在各个寄存器中 - 调用
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所示:
4. 总结
"汇编语言"本身, 是一个范畴很大的概念, 多数科班出身的程序员, 大多都读过这样的一本书: 汇编语言, 有很多高校甚至在本科学习阶段, 将本书列为汇编语言的教学教材, 这本书讲的汇编的目的是什么呢? 其实和我们的目的是完全不同的. 这本书的教学目的在我看来, 主要是:
- 向学生展示汇编语言
- 让学生了解寄存器, cpu, 内存等硬件, 与软件的联系
- 让学生深刻理解中断
这样的教学目的, 有一个很大的盲点就是: 学完这本书之后, 你几乎还是什么有用的东西都做不出来! 它对于科班学生计算机思维的培养很有用, 但对于实际工作应用, 基本作用为0
而我们学习汇编的目的是什么呢? 我们学习汇编的目的很功利:
- 我们希望了解一些被编译链接隐藏起来的细节实现
- 在高级语言表面上得不到的功能, 我们希望用汇编去实现.
- 使用内联汇编去优化我们的业务二进制包, 使在一些特殊应用场景下的代码跑的更快.
典型的汇编在工程上的应用, 就是C/C++中的协程库, 而我厂的libco更是一个标杆. 这样的汇编才是有用的. libco
库中, 最核心的协程切换, 寥寥不到100行汇编, 就是这100行汇编, 撑起了微信后台开发的核心. 这样的汇编, 才是有用的.
注意, 我不是在批王爽这本书没卵用, 并不是. 王爽的这本书写的非常好, 十分好, 只是王爽老师写的这本书, 不适用于我们这种"功利的目的".
所以, 我们学习汇编有以下几个点需要注意:
- 我们在Linux平台下, 以AT&T语法去学习汇编. 因为这是GNU编译器在编译阶段生成汇编的格式. 也是
binutils
工具链的服务对象. - 学习x86 32位汇编是一个过渡. 因为x86_64位汇编的学习, 基本上没有成体系的书籍去介绍引领. 我们需要先通过学习32位汇编, 把汇编中普适性的概念, 思想, 最佳实践学习到手, 之后再从32位汇编转向64位汇编.
- 我们脚踏操作系统平台, 不向下深挖. 确切的说, 是脚踏Linux系统调用
这样学习汇编, 需要先行了解编译, 链接的一些基础知识, 所以, 建议大家有空看看这一本书: 程序员的自我修养, 特别是书中的第三章.
原文地址:https://www.cnblogs.com/neooelric/p/9693643.html