前言
众程序员所周知无论是简单的输出“hello world”,还是一个完整的App都必不可少的要有函数的调用和执行,得益于层层封装我们不用关心函数运行底层原理,就可以编写出给用户使用的程序。但是了解函数的运行原理,能帮我们更好的定位问题,编写高质量的代码。 我们平时使用的高级语言从执行方式来讲,可以分为编译后执行和解释执行。无论是哪种方式最终都要转换成本地代码才能由CPU解释运行。本地语言是最接近程序运行状态的语言,如果要了解函数的调用运行原理那就最直接的办法就是研究本地语言。我们只要使用文本编辑器打开一个可执行文件,就可以看到本地代码。比如我们有这样一个c程序
//代码清单-0-1 Sample.c
#include <stdio.h>
int main(int argc, char *argv[]) {
MyFunc();
}
int AddNum(int a, int b) {
return a+b;
}
void MyFunc() {
int c;
c = AddNum(11, 22);
}
我们通过命令行clang -c Sample.c
生成可执行文件,然后用文本编辑器打开,选取一段本地语言研究下
//代码清单-0-2
5548 89e5 897d fc89 75f8 8b45 fc03 45f8
5dc3
2000 years later 。。。 本地语言就是16进制数值的罗列,如果直接研究还真是不太容易。那么还有什么更好的办法呢?
1.汇编语言简介
为了能更好的理解本地代码,伟大的劳动人民发明了汇编语言。汇编语言就是在各本地代码中,附带上表示其功能的英文单词缩写。例如,在加法运算的本地代码中加上add(addition的缩写)、在比较运算的本地代码中加上cmp(compare的缩写)等。这些缩写称为助记符,所以使用助记符的编程语言就是汇编语言。 汇编语言和本地代码是一一对应的(如图1-1)。
图1-1
所以,本地代码可以反过来转换成汇编语言的源代码。持有这个功能的逆变换程序称为反汇编程序,逆变换这一处理本身称为反汇编。
当我们开发OC程序时,在xcode中进行使用断点进行debug的时候,经常看到如下图的代码(如图1-2)。
图1-2
这些就是汇编程序,因为对应的函数是非源码编译,所以只能通过汇编语言来展示调用过程,xcode自动进行了反汇编工作。
📌:用C编写的源代码,编译后会转换成特定CPU用的本地代码。而将其反汇编的话,就可以得到汇编语言的源代码。不过,本地代码变换成C语言源代码的反编译,则要比反汇编困难。这是因为,C语言的源代码同本地代码不是一一对应的,因此完全还原到原始的源代码是不太可能的
2.汇编语言的源代码
除了反汇编之外,我们还可以利用编译器直接将高级语言编写的代码转换为汇编语言源代码。这里使用编译器是clang,通过指定参数“-S”,就可以生成汇编语言的源代码了。
重写下代码清单0-1中两个主要函数,AddNum和MyFunc。这里细心的同学一定会纳闷——怎么多了这么多参数,如果想知道原因,一定不要走开啊,具体原因下面会说到。
//代码清单2-1
int AddNum(int a, int b, int c, int d, int e, int f, int g, int h) {
return a+b+c+d+e+f+g+h;
}
void MyFunc() {
int c;
c = AddNum(11, 22, 33, 44, 55, 66, 77, 88);
}
在命令行工具中直接使用clang -S Sample.c
生成汇编语言源代码,执行完命令之后我们会看到目录下多了一个Sample.s的文件
//代码清单2-2
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 11, 0 sdk_version 11, 3
.globl _AddNum ## -- Begin function AddNum
.p2align 4, 0x90
_AddNum: ## @AddNum
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movl 24(%rbp), %eax
movl 16(%rbp), %r10d
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl %edx, -12(%rbp)
movl %ecx, -16(%rbp)
movl %r8d, -20(%rbp)
movl %r9d, -24(%rbp)
movl -4(%rbp), %ecx
addl -8(%rbp), %ecx
addl -12(%rbp), %ecx
addl -16(%rbp), %ecx
addl -20(%rbp), %ecx
addl -24(%rbp), %ecx
addl 16(%rbp), %ecx
addl 24(%rbp), %ecx
movl %eax, -28(%rbp) ## 4-byte Spill
movl %ecx, %eax
popq %rbp
retq
.cfi_endproc
## -- End function
.globl _MyFunc ## -- Begin function MyFunc
.p2align 4, 0x90
_MyFunc: ## @MyFunc
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
subq $32, %rsp
movl $11, %edi
movl $22, %esi
movl $33, %edx
movl $44, %ecx
movl $55, %r8d
movl $66, %r9d
movl $77, (%rsp)
movl $88, 8(%rsp)
callq _AddNum
movl %eax, -4(%rbp)
addq $32, %rsp
popq %rbp
retq
.cfi_endproc
## -- End function
.subsections_via_symbols
乍一看上面的汇编语言源代码是不是也有点懵逼,没关系一切资本主义都是纸老虎,资本主义搞出来的汇编语言也是纸老虎,让我们来戳穿它!汇编语言由指令(后面介绍)和伪指令、标签和注释组成。
- 伪指令以"."开头,末尾没有冒号":"。伪指令是是辅助性的,汇编器在生成目标文件时会用到这些信息,但伪指令不是真正的 CPU 指令,就是写给汇编器的。每种汇编器的伪指令也不同。如:
.section __TEXT,__text,regular,pure_instructions
- 标签以冒号“:”结尾,用于对伪指令生成的数据或指令做标记。标签很有用,它可以代表一段代码或者常量的地址(也就是在代码区或静态数据区中的位置)。可一开始,我们没法知道这个地址的具体值,必须生成目标文件后,才能算出来。所以,标签会简化汇编代码的编写。如:
## %bb.0:
- 注释以“#”号开头,与C语言中//表示注释是一样的。如:
## -- Begin function AddNum
3.汇编语言指令
汇编语言指令的语法结构是操作码+操作数(也存只有操作码没有操作数的指令)。
操作码是动词,表示指令动作。操作数相当于宾语,表示操作对象。例如用汇编语言分析Give me money
, Give
就是操作码,me
和money
就是操作数。汇编语言中存在多个操作数的情况下用逗号分隔,就像Give me,money
这样。能够使用何种形式的操作码,取决于CPU的种类。表3-1中的都是用的操作码。
操作码 | 操作数 | 功能 |
---|---|---|
mov | A, B | 把A的值赋给B |
add | A, B | 把A和B的值相加,并将结果赋给A |
push | A | 把A的值存储在栈中 |
pop | A | 从栈中读取出值,并将其值赋给A |
call | A | 调用函数A |
ret | 无 | 将处理返回到函数的调用源 |
表3-1
📌:使用clang编译器生成的汇编源码,会在一些命令后面增加一个后缀,表示操作数的长度,所以就会有movl、movw、movq、callq等。后缀一共有b, w, l, q 四种,分别代表 8 位、16 位、32 位和 64 位
在指令中使用操作数,可以使用四种格式,它们分别是:立即数、寄存器、直接内存访问和间接内存访问。
- 立即数以
$
开头, 比如$40
。(下面这行代码是把 40 这个数字拷贝到 %eax 寄存器)。
movl $40, %eax
- 对寄存器的访问,在指令中最常见到的就是对寄存器的访问,寄存器要以 % 开头。
- 直接内存访问,当我们在代码中看到操作数是一个数字时,它其实指的是内存地址。不要误以为它是一个数字,因为数字(立即数)必须以
$
开头. - 间接内存访问:带有括号,比如(%rbp),它是指 %rbp 寄存器的值所指向的地址。
本地代码加载到内存后才能运行。内存中存储着构成本地代码的指令和数据。程序运行时,CPU会从内存中把指令和数据读出,然后再将其存储在CPU内部的寄存器中进行处理。寄存器的名称会通过汇编语言的源代码指定给操作数。内存中的存储区域是用地址编号来区分的。
在所有 cpu 体系架构中,每个寄存器通常都是有建议的使用方法的,而编译器也通常依照CPU架构的建议来使用这些寄存器。x86-64系列CPU的寄存器的主要种类和角色如表3-2所示。
63~0位 | 31~0位 | 15~0位 | 7~0位 | 用法 |
---|---|---|---|---|
%rax | %eax | %ax | %al | 返回值 |
%rbx | %ebx | %bx | %bl | 被调用者保存 |
%rcx | %ecx | %cx | %cl | 第4个参数 |
%rdx | %edx | %dx | %dl | 第3个参数 |
%rsi | %esi | %si | %sil | 第2个参数 |
%rdi | %edi | %di | %dil | 第1个参数 |
%rbp | %ebp | %bp | %bpl | 被调用者保存 |
%rsp | %esp | %sp | %spl | 栈指针 |
%r8 | %r8d | %r8w | %r8b | 第5个参数 |
%r9 | %r9d | %r9w | %r9b | 第6个参数 |
%r10 | %r10d | %r10w | %r10b | 调用者保存 |
%r11 | %r11d | %r11w | %r11b | 调用者保存 |
%r12 | %r12d | %r12w | %r12b | 被调用者保存 |
%r13 | %r13d | %r13w | %r13b | 被调用者保存 |
%r14 | %r14d | %r14w | %r14b | 被调用者保存 |
%r15 | %r15d | %r15w | %r15b | 被调用者保存 |
表3-2
📌:x86系列32位CPU的寄存器名称中,开头都带了一个字母e,例如eax、ebx、ecx、edx等。这是因为16位CPU的寄存器名称是ax、bx、cx、dx等。32位CPU寄存器的名称中的e,有扩展(extended)的意思。x86_64架构有16个通用寄存器,相比AI32多了8个(r8 至 r15时x86_64新增的),原始8个寄存器的64位扩展版本添加了一个r前缀。
4.常用命令
接下来我们来找几个常用的命令来理解下。
mov
最常用的就是mov指令。mov有两个操作数,第一个参数是源,第二个参数是目的地。操作数中可以指定寄存器、立即数、内存地址。如果指定了%开头的数字,就表示对该值进行处理;如果指定了没有%开头或用括号围起来的内容,方括号中的内容则会被解释为内存地址,然后就会对该内存地址对应的值进行读写操作。来看个例子
movq %rsp, %rbp
movl %eax, -4(%rbp)
movq %rsp, %rbp
, rsp寄存器中的值被直接存储在了rbp寄存器中。而在movl %eax, -4(%rbp)
的情况下,rbp寄存器的值减4得到的值会被解释为内存地址。
push和pop
程序运行时,会在内存上申请分配一个称为栈的存储空间。栈是存储临时数据的区域,它的特点是通过push指令和pop指令进行数据的存储和读出。往栈中存储数据称为“入栈”,从栈中读出数据称为“出栈”。
push指令和pop指令中只有一个操作数。该操作数表示的是“push的是什么及pop的是什么”,而不需要指定“对哪一个地址编号的内存进行push或pop”。这是因为,对栈进行读写的内存地址是由rsp寄存器(栈指针)进行管理的。push指令和pop指令运行后,rsp寄存器的值会自动进行更新(push指令是-4, pop命令是+4),因而程序员就没有必要指定内存地址了。
5.函数调用机制
经过上面对汇编语言的熟悉,我们终于可以分析函数的调用机制。让我们回顾下代码清单2.2的内容,先来看MyFunc函数调用AddNum函数的部分。将代码清单中的标签和伪指令部分去掉,加上行数的注释,MyFunc函数部分如下
//代码清单5-1
_MyFunc:
pushq %rbp //------------------(1)
movq %rsp, %rbp //------------------(2)
subq $32, %rsp //------------------(3)
movl $11, %edi //------------------(4)
movl $22, %esi //------------------(5)
movl $33, %edx //------------------(6)
movl $44, %ecx //------------------(7)
movl $55, %r8d //------------------(8)
movl $66, %r9d //------------------(9)
movl $77, (%rsp) //------------------(10)
movl $88, 8(%rsp) //------------------(11)
callq _AddNum //------------------(12)
movl %eax, -4(%rbp) //------------------(13)
addq $32, %rsp //------------------(14)
popq %rbp //------------------(15)
retq //------------------(16)
(1)、(2)、(3)、(14)、(15)、(16)的处理适用于C语言中所有的函数,我们会在后面展示AddNum函数处理内容时进行说明。我们重点关注(4)——(11)的部分。(4)到(9)表示将传给AddNum的参数存入对应的寄存器中。(10)和(11)表示将参数存入栈中,这里入栈没有使用push命令,而是使用了mov直接将参数的值存入rsp中存储的数值所指向的内存。用栈在进行参数传递时,即便参数<8字节,也要对齐放在8字节的空间中,所以存储参数88($88
)时,进行了8个地址的偏移(8(%rsp)
)。(12)的call指令,把程序流程跳转到了操作数中指定的AddNum函数所在的内存地址处.在汇编语言中,函数名表示的是函数所在的内存地址。AddNum函数处理完毕后,程序流程必须要返回到编号(13)这一行。AddNum的返回结果会存在%eax寄存器中,(13)的操作将结果入栈。最后通过对%rsp中的值增加32来进行栈清理处理。
如下图所以是AddNum函数调用前后栈的变换情况
图5-1
📌 在 X86-64 架构下,有很多的寄存器,所以程序调用约定中规定尽量通过寄存器来传递参数,只要参数不超过 6 个,都可以通过寄存器来传参,使用的寄存器如下:
32位名称 64位名称 所传参数 %eax %rax 参数1 %esi %rsi 参数2 %edx %rdx 参数3 %ecx %rcx 参数4 %r8d %r8 参数5 %r9d %r9 参数6 超过 6 个的参数的话,要用栈来传参。所以这就是为什么上面我们要为AddNum设置8个参数,主要是为了验证上面收的参数传递过程。
6.函数的内部处理
接下来,让我们透过执行AddNum函数的源代码部分,来看一下参数的接收、返回值的返回等机制。
//代码清单6-1
_AddNum: ## @AddNum
pushq %rbp //------------------(1)
movq %rsp, %rbp //------------------(2)
movl 24(%rbp), %eax //------------------(3)
movl 16(%rbp), %r10d //------------------(4)
movl %edi, -4(%rbp) //------------------(5)
movl %esi, -8(%rbp) //------------------(6)
movl %edx, -12(%rbp) //------------------(7)
movl %ecx, -16(%rbp) //------------------(8)
movl %r8d, -20(%rbp) //------------------(9)
movl %r9d, -24(%rbp) //------------------(10)
movl -4(%rbp), %ecx //------------------(11)
addl -8(%rbp), %ecx //------------------(12)
addl -12(%rbp), %ecx //------------------(13)
addl -16(%rbp), %ecx //------------------(14)
addl -20(%rbp), %ecx //------------------(15)
addl -24(%rbp), %ecx //------------------(16)
addl 16(%rbp), %ecx //------------------(17)
addl 24(%rbp), %ecx //------------------(18)
movl %eax, -28(%rbp) //------------------(19)
movl %ecx, %eax //------------------(20)
popq %rbp //------------------(21)
retq //------------------(22)
rbp寄存器的值在(1)中入栈,在(21)中出栈。这主要是为了把函数中用到的rbp寄存器的内容,恢复到函数调用前的状态。在进入函数处理之前,无法确定rbp寄存器用到了什么地方,但由于函数内部也会用到rbp寄存器,所以就暂时将该值保存了起来。CPU拥有的寄存器是有数量限制的。在函数调用前,调用源有可能已经在使用rbp寄存器了。因而,在函数内部利用的寄存器,要尽量返回到函数调用前的状态。为此,我们就需要将其暂时保存在栈中,然后再在函数处理完毕之前出栈,使其返回到原来的状态。
(2)中把负责管理栈地址的rsp寄存器的值赋值到了rbp寄存器中。开辟新的栈帧。
(5)到(10)将之前寄存器中的参入入栈。
(11)是用-4(%rbp)指定栈中存储的第1个参数11,并将其读出到ecx寄存器中。
(12)到(18)的add指令,把当前%ecx寄存器的值同剩余参数相加后的结果存储在ecx寄存器中。
📌 这里有个点需要注意下,按照寄存器的功能分类,ecx是计数寄存器,主要用于循环操作,累加计算应该使用eax。但是这里却使用了ecx,所以本人猜测编译器会根据实际情况选择使用寄存器,因为本质上ecx完全可以支持累加功能。所以我做了个简单的验证,在AddNum函数前面加了一个for循环,看下如果ecx被占用,累加是不是就会使用eax。结果也确实如我所料。
LBB0_1:
cmpl $10, -32(%rbp)
jge LBB0_4
## %bb.2: ## in Loop: Header=BB0_1 Depth=1
movl -4(%rbp), %eax
addl $1, %eax
movl %eax, -4(%rbp)
## %bb.3: ## in Loop: Header=BB0_1 Depth=1
movl -32(%rbp), %eax
addl $1, %eax
movl %eax, -32(%rbp)
jmp LBB0_1
LBB0_4:
movl -4(%rbp), %eax
addl -8(%rbp), %eax
addl -12(%rbp), %eax
addl -16(%rbp), %eax
addl -20(%rbp), %eax
addl -24(%rbp), %eax
addl 16(%rbp), %eax
addl 24(%rbp), %eax
popq %rbp
retq
.cfi_endproc
(20)将计算结果从%ecx中存入%eax。在C语言中,函数的返回值必须通过%eax寄存器返回,这也是规定。不过,和%rbp寄存器不同的是,%eax寄存器的值不用还原到原始状态
(22)中ret指令运行后,函数返回目的地的内存地址会自动出栈,据此,程序流程就会跳转返回到代码清单5-1的(12)(Call _AddNum的下一行)。这时,AddNum函数入口和出口处栈的状态变化,就如图6-1所示
图6-1
7.变量的存储
C语言中,在函数外部定义的变量称为全局变量,在函数内部定义的变量称为局部变量。全局变量可以引用源代码的任意部分,而局部变量只能在定义该变量的函数内进行引用。
代码清单7-1的C语言源代码中定义了初始化(设定了初始值)的a1~a5这5个全局变量,以及没有初始化(没有设定初始值)的b1~b5这5个全局变量,此外还定义了c1~c10这10个局部变量,且分别给各变量赋了值。
//代码清单7-1
int a1 = 1;
int a2 = 2;
int a3 = 3;
int a4 = 4;
int a5 = 5;
int b1, b2, b3, b4, b5;
void myFunc() {
//定义局部变量
int c1, c2, c3, c4, c5, c6, c7, c8, c9, c10;
//给局部变量赋值
c1 = 1;
c2 = 2;
c3 = 3;
c4 = 4;
c5 = 5;
c6 = 6;
c7 = 7;
c8 = 8;
c9 = 9;
c10 = 10;
//
a1 = c1;
a2 = c2;
a3 = c3;
a4 = c4;
a5 = c5;
b1 = c6;
b2 = c7;
b3 = c8;
b4 = c9;
b5 = c10;
}
以上代码装换成汇编语言如下
//代码清单7-2
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 11, 0 sdk_version 11, 3
.globl _myFunc ## -- Begin function myFunc
.p2align 4, 0x90
_myFunc: ## @myFunc
.cfi_startproc
## %bb.0:
pushq %rbp
.cfi_def_cfa_offset 16
.cfi_offset %rbp, -16
movq %rsp, %rbp
.cfi_def_cfa_register %rbp
movq _b5@GOTPCREL(%rip), %rax
movq _b4@GOTPCREL(%rip), %rcx
movq _b3@GOTPCREL(%rip), %rdx
movq _b2@GOTPCREL(%rip), %rsi
movq _b1@GOTPCREL(%rip), %rdi
movl $1, -4(%rbp)
movl $2, -8(%rbp)
movl $3, -12(%rbp)
movl $4, -16(%rbp)
movl $5, -20(%rbp)
movl $6, -24(%rbp)
movl $7, -28(%rbp)
movl $8, -32(%rbp)
movl $9, -36(%rbp)
movl $10, -40(%rbp)
movl -4(%rbp), %r8d
movl %r8d, _a1(%rip)
movl -8(%rbp), %r8d
movl %r8d, _a2(%rip)
movl -12(%rbp), %r8d
movl %r8d, _a3(%rip)
movl -16(%rbp), %r8d
movl %r8d, _a4(%rip)
movl -20(%rbp), %r8d
movl %r8d, _a5(%rip)
movl -24(%rbp), %r8d
movl %r8d, (%rdi)
movl -28(%rbp), %r8d
movl %r8d, (%rsi)
movl -32(%rbp), %r8d
movl %r8d, (%rdx)
movl -36(%rbp), %r8d
movl %r8d, (%rcx)
movl -40(%rbp), %r8d
movl %r8d, (%rax)
popq %rbp
retq
.cfi_endproc
## -- End function
.section __DATA,__data
.globl _a1 ## @a1
.p2align 2
_a1:
.long 10 ## 0xa
.globl _a2 ## @a2
.p2align 2
_a2:
.long 20 ## 0x14
.globl _a3 ## @a3
.p2align 2
_a3:
.long 30 ## 0x1e
.globl _a4 ## @a4
.p2align 2
_a4:
.long 40 ## 0x28
.globl _a5 ## @a5
.p2align 2
_a5:
.long 50 ## 0x32
.comm _b1,4,2 ## @b1
.comm _b2,4,2 ## @b2
.comm _b3,4,2 ## @b3
.comm _b4,4,2 ## @b4
.comm _b5,4,2 ## @b5
.subsections_via_symbols
看第一句代码.section __TEXT,__text,regular,pure_instructions
,我们前面介绍过.
开头的是伪指令,不会别翻译伪机器语言,是给编译器的一些特殊提示。.section
指示吧代码分厂若干个段,程序被操作系统加载时,每个段被夹在到不同的内存地址,操作系统对不同的页面设置不同的读、写、执行权限。紧接着.section后面的__TEXT表示段名,该段数据的读写权限是只读和可执行的,所以适合保存代码。所以从cfi_startproc
到cfi_endproc
之间的都是MyFunc函数的代码。
在接下来一行.section __DATA,__data
,声明了名为__DATA,__data的段,DATA段保存程序的数据,是可读可写的,全局变量就保存在这个段里。比如下面代码
.section __DATA,__data
.globl _a1 ## @a1
.p2align 2
_a1:
.long 10 ## 0xa
- 第一行用.section声明了该数据位于__DATA,__data段;
- 第二行用.globl声明说明变量符号_a1是个全局变量,即可在其他文件中通过 extern 的方式引入;
- 第三行的 .p2align 是用于指定程序的对齐方式,这类似于结构体的字节对齐,为的是加速程序的执行速度,p2align 的单位是指数,即按照 2 的 exp 次方对齐,上文中的 .p2align2 即为按照 2^2 = 4 字节对齐,也就是说,如果单行指令或数据的长度不足4字节,将用 0 补全,超过 4 但不是 4 的倍数,则按照最小倍数补全;
- 第四行是一个 label,用来表示 .long1 所在的地址,以便后续的读写。
除了TEXT和DATA段,还有一个叫BSS的段,主要声明未初始化的存储空间,就是代码的最后部分,.comm就是表示在bss进行变量声明。
.comm _b1,4,2 ## @b1
.comm _b2,4,2 ## @b2
.comm _b3,4,2 ## @b3
.comm _b4,4,2 ## @b4
.comm _b5,4,2 ## @b5
为什么局部变量只能在定义改变量的函数内进行引用呢?这是因为,局部变量是临时保存在寄存器和栈中的。正如之前讲的那样,函数内部利用的栈,在函数处理完毕后会恢复到初始状态,因此局部变量的值也就被销毁了,而寄存器也可能会被用于其他目的。因此,局部变量只是在函数处理运行期间临时存储在寄存器和栈上。
8.循环和条件分支的实现
了解了函数调用,那我们来看下for循环及if条件分支等C语言程序的流程控制是如何实现的。写个简单的带有for循环的代码
//代码清单8-1
void mySub() {
}
void myFunc() {
int i;
for (int i = 0; i < 10; ++i)
{
/* code */
mySub();
}
}
myFunc函数转为汇编语言如下
//代码清单8-2
_myFunc: ## @myFunc
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $0, -8(%rbp)
LBB1_1: ## =>This Inner Loop Header: Depth=1
cmpl $10, -8(%rbp)
jge LBB1_4
callq _mySub
movl -8(%rbp), %eax
addl $1, %eax
movl %eax, -8(%rbp)
jmp LBB1_1
LBB1_4:
addq $16, %rsp
popq %rbp
retq
C语言的for语句是通过在括号中指定循环计数器的初始值(i=0)、循环的继续条件(i < 10)、循环计数器的更新(i++)这3种形式来进行循环处理的。与此相对,在汇编语言的源代码中,循环是通过比较指令(cmpl)和跳转指令(jge)来实现的。 我们根据代码清单8-2来进行下说明。
首先前3行是函数通用的开辟新栈帧的操作。
函数中的局部变量i被分配存储在栈中(-8(%rbp)),通过mov语句将其初始化为0。
接下来通过cmpl指令比较-8(%rbp)与10的大小,如果前者大于等于后者(jge)则跳转至LBB1_4;
如果不大于等于,继续调用mySub(callq _mySub)函数;
将-8(%rbp)中的值放入%eax中进行+1操作,然后将值在写会-8(%rbp);
跳转至LBB1_1(jmp);
条件分支的实现方法同循环处理的实现方法类似,使用的也是cmp指令和跳转指令,这一点估计大家也预料到了。为了加深印象我们也来看下。
//代码清单8-3
void mySub1() {
}
void mySub2() {
}
void mySub3() {
}
void myFucn() {
int a = 123;
if (a > 100) {
mySub1();
} else if(a < 50) {
mySub2();
} else {
mySub3();
}
}
转为汇编如下
//代码清单8-4
_myFucn: ## @myFucn
pushq %rbp
movq %rsp, %rbp
subq $16, %rsp
movl $123, -4(%rbp)
cmpl $100, -4(%rbp)
jle LBB3_2
callq _mySub1
jmp LBB3_6
LBB3_2:
cmpl $50, -4(%rbp)
jge LBB3_4
callq _mySub2
jmp LBB3_5
LBB3_4:
callq _mySub3
LBB3_5:
jmp LBB3_6
LBB3_6:
addq $16, %rsp
popq %rbp
retq
代码清单8-4中用到了三种跳转指令,分别是比较结果小或相等时跳转的jle(jump on less or equal)、大或相等时跳转的jge(jump on greater orequal)、不管结果怎样都无条件跳转的jmp。在这些跳转指令之前还有用来比较的cmp指令,比较结果被保存在了标志寄存器中。这里我们添加了注释,大家不妨顺着程序的流程看一下。虽然同C语言源代码的处理流程不完全相同,不过大家应该知道处理结果是相同的。此外,还有一点需要注意的是,eax寄存器表示的是变量a。
9.了解程序运行方式的必要性
通过对C语言源代码和汇编语言源代码进行比较,想必大家对行数的运行原理有了更深的理解。而且,从汇编语言源代码中获得的知识,在某些情况下对查找bug的原因也是有帮助的。比如行间的,我们在两个行数里同时对一个数进行操作,如下代码所示
//代码清单9-1
int a = 100;
void Myfunc1() {
a += 100;
}
void Myfunc2() {
a += 100;
}
C语言源代码中a += 100;
这句代码在汇编语言源代码,也就是实际运行的程序中,分成了3个指令。如果只是看a += 100;
的话,就会以为a的数值被直接扩大为了100。然而,实际上执行的却是“把a的数值读入eax寄存器”,“将eax寄存器的数值增加100”,“把eax寄存器的数值写入a”这3个处理
movl _a(%rip), %eax
addl $100, %eax
movl %eax, _a(%rip)
在多线程处理中,用汇编语言记述的代码每运行1行,处理都有可能切换到其他线程(函数)中。因而,假设 MyFunc1函数在读出a的数值100后,还未来得及将增加100后的值写入a时,正巧MyFunc2函数读出了a的数值100,那么结果就会导致counter的数值变成了200。
为了避免该bug,我们可以采用以函数或C语言源代码的行为单位来禁止线程切换的锁定方法。通过锁定,在特定范围内的处理完成之前,处理不会被切换到其他函数中。至于为什么要锁定MyFunc1函数和MyFunc2函数,大家如果不了解汇编语言源代码的话想必是不明白的吧。
我们工作学习的过程中不但要知其然,更要知其所以然。不了解函数调用原理的程序员就如同对汽车结构不了解的司机,虽然有可以驾驶车辆,但是如果汽车出现故障或奇怪的现象,他们就无法自己找到原因,还可能减少汽车使用寿命,浪费汽油。与此相对,有汇编语言经验,了解函数运行原理的程序员,也就相当于了解计算机和程序机制的驾驶员,他们不仅能自己解决问题,还能在驾驶过程中省油。希望这篇文章能够让你对汇编语言和函数运行原理有更多的了解和认识。
参考:
汇编@data_iOS汇编入门教程(三)汇编中的 Section 与数据存取
《程序是怎样跑起来的》矢泽久雄 人民邮电出版社 2015-04
《计算机组成原理》 唐朔飞 高等教育出版社 2017-03
hi, 我是快手电商的仙鸣
快手电商无线技术团队正在招贤纳士🎉🎉🎉! 我们是公司的核心业务线, 这里云集了各路高手, 也充满了机会与挑战. 伴随着业务的高速发展, 团队也在快速扩张. 欢迎各位高手加入我们, 一起创造世界级的电商产品~
热招岗位: Android/iOS 高级开发, Android/iOS 专家, Java 架构师, 产品经理(电商背景), 测试开发... 大量 HC 等你来呦~
内部推荐请发简历至 >>>我们的邮箱: hr.ec@kuaishou.com <<<, 备注我的花名成功率更高哦~ 😘