大家往往高估自己一天能学会的东西,低估三年能学会的东西
大家好,我是柒八九。
今天,我们继续计算机底层知识的探索。我们来谈谈关于汇编语言的相关知识点。
如果,想了解该系列的文章,可以参考我们已经发布的文章。如下是往期文章。
文章list
你能所学到的知识点
- 汇编语言和本地代码是一一对应的 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- 不会转换成本地代码的伪指令 推荐阅读指数 ⭐️⭐️⭐️
- 汇编语言的语法是操作码 + 操作数 推荐阅读指数⭐️⭐️⭐️⭐️⭐️
- mov指令 推荐阅读指数 ⭐️⭐️⭐️
- 对栈进行push 和 pop 推荐阅读指数 ⭐️⭐️⭐️
- 函数调用机制 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
- 函数内部的处理 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
- 全局变量用的内存空间 推荐阅读指数 ⭐️⭐️⭐️
- 循环处理的实现方法 推荐阅读指数 ⭐️⭐️⭐️⭐️⭐️
好了,天不早了,干点正事哇。
汇编语言和本地代码是一一对应的
在前面的文章中我们多次提到,计算机CPU
能直接解释运行的只有本地代码(机器语言)程序。用C语言
等编写的源代码,需要通过各自的编译器编译后,转换成本地代码。
通过调用本地代码的内容,可以了解程序最终是以何种形式来运行的。但是,直接打开本地代码来看,只能看到数值的罗列。
我们可以采用另外一种方式,在各本地代码中,附带上表示其功能的英语单词缩写。例如,在加法运算的本地代码中加上add
、在比较运算的本地代码中加上cmp
等。这些缩写被称为助记符,使用助记符的编程语言称为汇编语言
不过,即使是用汇编语言编写的源代码,最终也必须要转换成本地代码才能运行。负责准换工作的程序称为汇编器,转换这个一处理本身称为汇编。
用汇编语言编写的源代码,和本地代码是一一对应的
本地代码也可以反过来转换成汇编语言的源代码。持有该功能的逆变换程序称为反汇编程序,逆变换这一处理本身称为反汇编。
不会转换成本地代码的伪指令
汇编语言的源代码,是由转换本地代码的指令和针对汇编器的伪指令构成的。伪指令负责把程序的构造及汇编的方法指示给汇编器(转换程序)。不过,伪指令是无法汇编转换成本地代码。
如上是一个汇编代码片段。其中彩色部分是伪指令。
由伪指令segment
和ends
围起来的部分,是给构成程序的命令和数据的集合体加上一个名字而得到的,称为段定义。 段定义的英文表达segment
具有区域的意思。在程序中,段定义指的是命令和数据等程序的集合体的意思。
一个程序由多个段定义构成
如上图所示。源代码的开始位置,定义了3个名称分别为_TEXT
、_DATA
、_BSS
的段定义。
_TEXT
是指令的段定义_DATA
是被初始化(有初始值)的数据的段定义_BSS
是尚未初始化的数据的段定义
而这些段定义的名称及划分方法,不同的编译器都有自己的一套规则。
伪指令proc
和endp
围起来的部分,表示的是{过程|Proceduce}的范围。在汇编语言中,这种相当于C
语言的函数的形式称为过程。
汇编语言的语法是操作码 + 操作数
在汇编语言中,1行表示对CPU
的一个指令。汇编语言指令的语法结构是操作码+操作数。
- 操作码表示的是指令动作
- 操作数表示的是指令对象
操作码和操作数罗列在一起的语法,就是一个英文的指令文本。操作码是动词,操作数相当于宾语。
能够使用何种形式的操作码,是由CPU
的种类决定的。
本地代码加载到内存后才能运行。内存中存储着构成本地代码的指令和数据。程序运行时,CPU
会从内存中把指令和数据读出,然后再将存储在CPU
内部的寄存器中进行处理。
寄存器是CPU
中的存储区域。不过,寄存器并不仅仅具有存储指令和数据的功能,也有运算功能。寄存器的名称会通过汇编语言的源代码指定给操作数。内存中的存储区域是用地址编号来区分的。CPU
内的寄存器是用eax
及ebx
这些名称开区分的。
下图是CPU
的寄存器的主要种类和角色
mov指令
mov
指令的两个操作数,分别是用来指定数据的存储地和读出源。
操作数可以指定寄存器、常数、标签(附近在地址前)以及用方括号([]
)围起来的这些内容。
- 如果指定了没有用方括号围起来的内容,就表示对该值进行处理
- 如果指定了用方括号围起来的内容,方括号中的值则会被解释为内存地址,然后就会对该内存地址对应的值进行读写操作
mov ebp,esp; mov eax,dword ptr [ebp+8]; 复制代码
mov ebp,esp
中,esp
寄存器中的值被直接存储在ebp
寄存器中。 esp
寄存器的值是100
时ebp
寄存器的值也是100
。
而mov eax,dword ptr [ebp+8];
中,ebp
寄存器的值加8后得到的值会被解释为内存地址。如果ebp
寄存器的值是100的话,那么eax
寄存器中存储的就是100 + 8 = 108
地址的数据。
对栈进行push 和 pop
程序运行时,会在内存上申请分配一个称为栈的数据空间。
在栈中,数据在存储时是从内存的下层(大的地址编号
)逐渐往上层(小的地址编号
)累积,读出时则是按照从上往下的顺序进行。
栈是存储临时数据的区域,它的特点是通过push
指令和pop
指令进行数据的存储和读出。push
指令和pop
指令中只有一个操作数。该操作数表示的是push的是什么及pop的是什么,而不需要指定”对哪一个地址编号的内存进行push
或pop
“。
这是因为,对栈进行读写的内存地址是有esp
寄存器(栈指针)进行管理的。push
指令和pop
指令运行后,esp
寄存器的值会自动进行更新(push
指令是-4
,pop
指令是+4
),因而就没有必要指定内存地址了。
函数调用机制
假设存在如下的C语言
代码片段。
// 返回两个参数值之和的函数 int AddNum(int a,int b){ return a + b; } // 调用AddNum函数的函数 void MyFunc(){ int c; c = AddNum(123,456); } 复制代码
转换成对应的汇编语言的代码如下。
这里我们先介绍(3)~(6)
的部分,这对了解函数调用的机制很重要。
(3)
和(4)
表示的是将传递给AddNum
函数的参数通过push
入栈。在C语言
中,虽然记述为函数AddNum(123,456)
,但入栈的则会按照456
、123
这样的顺序,也就是位于后面的数值先入栈。
(5)
的call
指令,把程序流程跳转到了操作数中指定的AddNum
函数所在的内存地址处。 在汇编语言中,函数名表示的是函数所在的内存地址。AddNum
函数处理完毕后,程序流程必须要返回到编号(6)
这一行。 call
指令运行后,call
指令的下一行((6)
这一行)的内存地址会自动push
入栈。 该值会在AddNum
函数处理的最后通过ret
指令pop
出栈,然后程序流程就会返回到(6)
这一行。
(6)
部分会把栈中存储的两个参数(456
和123
)进行销毁处理,也就是栈清理处理。虽然通过使用两次pop
指令也可以实现,不过采用esp
寄存器加8的方式更有效率(处理一次)。对栈进行数值的输入输出时,数值的单位是4字节。因此,通过在栈地址管理的esp
寄存器加上4的2倍8,就可以达到和运行两次pop
命令同样的效果。
函数内部的处理
继续分析执行AddNum
函数的源代码部分。
ebp
寄存器的值在(1)
中入栈,在(5)
中出栈。这主要是为了把函数中用到的ebp
寄存器的内容,恢复到函数调用前的状态。CPU
拥有的寄存器是有数量的限制的。在函数调用前,调用源有可能已经在使用ebp
寄存器了。因而,在函数内部用的寄存器,要尽量返回到函数调用前的状态。
(2)
中负责管理栈地址的esp
寄存器的值赋值到了ebp
寄存器中。这是因为,在mov
指令中方括号内的参数,是不允许指定esp
寄存器的。因此,这里就采用了不直接通过esp
,而是用ebp
寄存器来读写栈内容的方法。
(3)
是用[ebp+8]
指定栈中存储的第1个参数123
,并将其读出到eax
寄存器中。eax
寄存器是负责运算的累加寄存器
通过(4)
的add
指令,把当前eax
寄存器的值同第2个参数相加后的结果存储在eax
寄存器中。函数的参数是通过栈来传递,返回值是通过寄存器来返回的。
(6)
中ret
指令运行后,函数返回目的地的内存地址会自动出栈。
全局变量用的内存空间
在一些高级编程语言中,在函数外部定义的变量称为全局变量,在函数内部定义的变量称为局部变量。全局变量可以在源代码的任意部分被引用,而局部变量只能在定义该变量的函数内进行引用。
高级程序语言被编译后,会被归类到名为段定义的组。
- 初始化的全局变量被汇总到名为
_DATA
的段定义中 - 没有初始化的全局变量被汇总到名为
_BSS
的段定义中 - 指令被汇总到名为
_TEXT
的段定义中
局部变量的内存空间
局部变量只能在定义该变量的函数内进行引用,这是因为,局部变量是临时保存在寄存器和栈中的。
函数内部利用的栈,在函数处理完毕后会恢复到初始状态,因此局部变量的值也就会被销毁,而寄存器也可能被用于其他目的。因此,局部变量只是在函数处理运行期间临时存储在寄存器和栈上。
循环处理的实现方法
假设我们存在如下的代码,将局部变量i
作为循环计数器连续进行10次循环的C
语言源代码。
// 定义MySub函数 void MySub(){ // 省略部分处理 } // 定义MyFunc函数 void MyFunc(){ int i; for(i=0;i<10;i++){ // 重复调用MySub函数10次 MySub(); } } 复制代码
将上述的代码转换成汇编语言如下(仅展示for
片段)
C语言
的for
语句是通过在括号中指定循环计数器的初始值(i=0
)、循环的继续条件(i<10
)、循环计数器的更新(i++
)这3种形式来进行循环处理。与此相对,
在汇编语言的源代码中,循环是通过比较指令(
cmp
)和跳转指令(jl
)来实现。
具体流程我们就不在这里赘述。这里挑选比较重要的点来分析下。
cmp
指令是用来对第一个操作数和第二个操作数的数值进行比较的指令。cmp ebx,10
就相当于C语言
的i<10
这一处理,意思是把ebx
寄存器的数值同10进行比较。汇编语言中比较指令的结果,会存储在CPU
的标志寄存器中。
最后一行的jl
是jump on less than
(小于的话就跳转)的意思。也就是说,jl short @4
的意思就是,前面运行的比较指令的结果,若小的话就跳转到@4
这个标签。
条件分支的实现方式
条件分支的实现方法同循环的实现方法类似,使用的也是cmp
指令和跳转指令。
后记
分享是一种态度。
参考资料:《程序是怎样跑起来的》
全文完,既然看到这里了,如果觉得不错,随手点个赞和“在看”吧。