程序编译、优化、调试大杂烩
文章目录
1 编译、链接、汇编、调试
题外话,粗浅的谈些自己的理解。笔者认为做技术的同学要有追求,不要给自己设限,因为你不逼一下你自己,你永远不知道,其实你可以做很多事情,你可以搞定很多场景。架构也好,算法也好,学就是了,踏踏实实,脚踏实地,没有什么是搞不定的,只是你想不想而已。
技术人要有好的技术视野,多关注些前沿的技术,同时要把基础锻炼的扎扎实实,很多上层的、前沿的技术就是你的基础知识的组合,加上一些创新的点,所谓“没吃过猪肉,还没见过猪跑”,大抵也就是这个意思吧,你的积累够了,思考足了,很多难题就不是难题,仅仅是一个有意思的稍有阻碍的小问题而已,仅此而已。
1.1 议题
- cpu的cache line相关;
- likely、unlikely、switch等优化;
- 程序的调用过程、寄存器、调试;
- 位运算为何快于加减乘除运算;
1.2 CPU cache
参考资料:http://cenalulu.github.io/linux/all-about-cpu-cache/ ,cpu cache的基本就是转载的这个,有兴趣的可以去看原版。
1.2.1 为什么要有CPU Cache
随着工艺的提升最近几十年CPU的频率不断提升,而受制于制造工艺和成本限制,目前计算机的内存主要是DRAM并且在访问速度上没有质的突破。因此,CPU的处理速度和内存的访问速度差距越来越大,甚至可以达到上万倍。这种情况下传统的CPU通过FSB直连内存的方式显然就会因为内存访问的等待,导致计算资源大量闲置,降低CPU整体吞吐量。同时又由于内存数据访问的热点集中性,在CPU和内存之间用较为快速而成本较高的SDRAM做一层缓存,就显得性价比极高了。
1.2.2 为什么要有多级CPU Cache
随着科技发展,热点数据的体积越来越大,单纯的增加一级缓存大小的性价比已经很低了。因此,就慢慢出现了在一级缓存(L1 Cache)和内存之间又增加一层访问速度和成本都介于两者之间的二级缓存(L2 Cache)。下面是一段从What Every Programmer Should Know About Memory中摘录的解释:
Soon after the introduction of the cache the system got more complicated. The speed difference between the cache and the main memory increased again, to a point that another level of cache was added, bigger and slower than the first-level cache. Only increasing the size of the first-level cache was not an option for economical rea- sons.
此外,又由于程序指令和程序数据的行为和热点分布差异很大,因此L1 Cache也被划分成L1i (i for instruction)和L1d (d for data)两种专门用途的缓存。 下面一张图可以看出各级缓存之间的响应时间差距,以及内存到底有多慢!
1.2.3 什么是Cache Line
Cache Line可以简单的理解为CPU Cache中的最小缓存单位。目前主流的CPU Cache的Cache Line大小都是64 Bytes。假设我们有一个512字节的一级缓存,那么按照64 B的缓存单位大小来算,这个一级缓存所能存放的缓存个数就是
512/64 = 8
个。具体参见下图:
为了更好的了解Cache Line,我们还可以在自己的电脑上做下面这个有趣的实验。
下面这段C代码,会从命令行接收一个参数作为数组的大小创建一个数量为N的int数组。并依次循环的从这个数组中进行数组内容访问,循环10亿次。最终输出数组总大小和对应总执行时间。
#include "stdio.h"
#include <stdlib.h>
#include <sys/time.h>
long timediff(clock_t t1, clock_t t2) {
long elapsed;
elapsed = ((double)t2 - t1) / CLOCKS_PER_SEC * 1000;
return elapsed;
}
int main(int argc, char *argv[])
#*******
{
int array_size=atoi(argv[1]);
int repeat_times = 1000000000;
long array[array_size];
for(int i=0; i<array_size; i++){
array[i] = 0;
}
int j=0;
int k=0;
int c=0;
clock_t start=clock();
while(j++<repeat_times){
if(k==array_size){
k=0;
}
c = array[k++];
}
clock_t end =clock();
printf("%lu\n", timediff(start,end));
return 0;
}
如果我们把这些数据做成折线图后就会发现:总执行时间在数组大小超过64Bytes时有较为明显的拐点(当然,由于博主是在自己的Mac笔记本上测试的,会受到很多其他程序的干扰,因此会有波动)。原因是当数组小于64Bytes时数组极有可能落在一条Cache Line内,而一个元素的访问就会使得整条Cache Line被填充,因而值得后面的若干个元素受益于缓存带来的加速。而当数组大于64Bytes时,必然至少需要两条Cache Line,继而在循环访问时会出现两次Cache Line的填充,由于缓存填充的时间远高于数据访问的响应时间,因此多一次缓存填充对于总执行的影响会被放大,最终得到下图的结果:
如果读者有兴趣的话也可以在自己的linux或者MAC上通过gcc cache_line_size.c -o cache_line_size
编译,并通过./cache_line_size
执行。
了解Cache Line的概念对我们程序猿有什么帮助? 我们来看下面这个C语言中常用的循环优化例子下面两段代码中,第一段代码在C语言中总是比第二段代码的执行速度要快。具体的原因相信你仔细阅读了Cache Line的介绍后就很容易理解了。
for(int i = 0; i < n; i++) {
for(int j = 0; j < n; j++) {
int num;
//code
arr[i][j] = num;
}
}
for(int i = 0; i < n; i++) {
for(int j = 0; j < n; j++) {
int num;
//code
arr[j][i] = num;
}
}
1.2.4 CPU Cache 是如何存放数据的
1.2.4.1 你会怎么设计Cache的存放规则
我们先来尝试回答一下那么这个问题:
假设我们有一块4MB的区域用于缓存,每个缓存对象的唯一标识是它所在的物理内存地址。每个缓存对象大小是64Bytes,所有可以被缓存对象的大小总和(即物理内存总大小)为4GB。那么我们该如何设计这个缓存?
如果你和博主一样是一个大学没有好好学习基础/数字电路的人的话,会觉得最靠谱的的一种方式就是:Hash表。把Cache设计成一个Hash数组。内存地址的Hash值作为数组的Index,缓存对象的值作为数组的Value。每次存取时,都把地址做一次Hash然后找到Cache中对应的位置操作即可。 这样的设计方式在高等语言中很常见,也显然很高效。因为Hash值得计算虽然耗时(10000个CPU Cycle左右),但是相比程序中其他操作(上百万的CPU Cycle)来说可以忽略不计。而对于CPU Cache来说,本来其设计目标就是在几十CPU Cycle内获取到数据。如果访问效率是百万Cycle这个等级的话,还不如到Memory直接获取数据。当然,更重要的原因是在硬件上要实现Memory Address Hash的功能在成本上是非常高的。
1.2.4.2 为什么Cache不能做成Fully Associative
Fully Associative 字面意思是全关联。在CPU Cache中的含义是:如果在一个Cache集内,任何一个内存地址的数据可以被缓存在任何一个Cache Line里,那么我们成这个cache是Fully Associative。从定义中我们可以得出这样的结论:给到一个内存地址,要知道他是否存在于Cache中,需要遍历所有Cache Line并比较缓存内容的内存地址。而Cache的本意就是为了在尽可能少得CPU Cycle内取到数据。那么想要设计一个快速的Fully Associative的Cache几乎是不可能的。
1.2.4.3 什么是N-Way Set Associative
为了避免以上两种设计模式的缺陷,N-Way Set Associative缓存就出现了。他的原理是把一个缓存按照N个Cache Line作为一组(set),缓存按组划为等分。这样一个64位系统的内存地址在4MB二级缓存中就划成了三个部分(见下图),低位6个bit表示在Cache Line中的偏移量,中间12bit表示Cache组号(set index),剩余的高位46bit就是内存地址的唯一id。这样的设计相较前两种设计有以下两点好处:
- 给定一个内存地址可以唯一对应一个set,对于set中只需遍历16个元素就可以确定对象是否在缓存中(Full Associative中比较次数随内存大小线性增加)
- 每
2^18(256K)*16(way)
=4M
的连续热点数据才会导致一个set内的conflict(Direct Mapped中512K的连续热点数据就会出现conflict)
为什么N-Way Set Associative的Set段是从低位而不是高位开始的
下面是一段从How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses摘录的解释:
The vast majority of accesses are close together, so moving the set index bits upwards would cause more conflict misses. You might be able to get away with a hash function that isn’t simply the least significant bits, but most proposed schemes hurt about as much as they help while adding extra complexity.
由于内存的访问通常是大片连续的,或者是因为在同一程序中而导致地址接近的(即这些内存地址的高位都是一样的)。所以如果把内存地址的高位作为set index的话,那么短时间的大量内存访问都会因为set index相同而落在同一个set index中,从而导致cache conflicts使得L2, L3 Cache的命中率低下,影响程序的整体执行效率。
了解N-Way Set Associative的存储模式对我们有什么帮助
了解N-Way Set的概念后,我们不难得出以下结论:2^(6Bits <Cache Line Offset> + 12Bits <Set Index>)
= 2^18
= 256K
。即在连续的内存地址中每256K都会出现一个处于同一个Cache Set中的缓存对象。也就是说这些对象都会争抢一个仅有16个空位的缓存池(16-Way Set)。而如果我们在程序中又使用了所谓优化神器的“内存对齐”的时候,这种争抢就会越发增多。效率上的损失也会变得非常明显。具体的实际测试我们可以参考: How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses 一文。 这里我们引用一张Gallery of Processor Cache Effects 中的测试结果图,来解释下内存对齐在极端情况下带来的性能损失。
该图实际上是我们上文中第一个测试的一个变种。纵轴表示了测试对象数组的大小。横轴表示了每次数组元素访问之间的index间隔。而图中的颜色表示了响应时间的长短,蓝色越明显的部分表示响应时间越长。从这个图我们可以得到很多结论。当然这里我们只对内存带来的性能损失感兴趣。有兴趣的读者也可以阅读原文分析理解其他从图中可以得到的结论。
从图中我们不难看出图中每1024个步进,即每1024*4
即4096Bytes,都有一条特别明显的蓝色竖线。也就是说,只要我们按照4K的步进去访问内存(内存根据4K对齐),无论热点数据多大它的实际效率都是非常低的!按照我们上文的分析,如果4KB的内存对齐,那么一个240MB的数组就含有61440个可以被访问到的数组元素;而对于一个每256K就会有set冲突的16Way二级缓存,总共有256K/4K
=64
个元素要去争抢16个空位,总共有61440/64
=960
个这样的元素。那么缓存命中率只有1%,自然效率也就低了。
除了这个例子,有兴趣的读者还可以查阅另一篇国人对Page Align导致效率低的实验:http://evol128.is-programmer.com/posts/35453.html
想要知道更多关于内存地址对齐在目前的这种CPU-Cache的架构下会出现的问题可以详细阅读以下两篇文章:
- How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses
- Gallery of Processor Cache Effects
1.2.5 Cache淘汰策略
在文章的最后我们顺带提一下CPU Cache的淘汰策略。常见的淘汰策略主要有LRU
和Random
两种。通常意义下LRU对于Cache的命中率会比Random更好,所以CPU Cache的淘汰策略选择的是LRU
。当然也有些实验显示在Cache Size较大的时候Random策略会有更高的命中率
1.2.6 总结
CPU Cache对于程序猿是透明的,所有的操作和策略都在CPU内部完成。但是,了解和理解CPU Cache的设计、工作原理有利于我们更好的利用CPU Cache,写出更多对CPU Cache友好的程序
1.2.7 Reference
- Gallery of Processor Cache Effects
- How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses
- Introduction to Caches
1.3 程序优化
1.3.1 程序优化的手段
- likely、unlikely、if、switch
1.3.2 likely 与 unlikely优化过程
likely()与unlikely()在2.6内核中,随处可见,那为什么要用它们?它们之间有什么区别呢?
首先明确:
if (likely(value))等价于if (value)
if (likely(a>b)) {
fun1();
if (unlikely(value))等价于if (value)
也就是说likely()和unlikely()从阅读和理解的角度是一样的。
这两个宏在内核中定义如下:
<linux/compiler>
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) builtin_expect(!!(x), 0)
这里的__built_expect()函数是gcc(version >= 2.96)的内建函数,提供给程序员使用的,目的是将"分支转移"的信息提供给编译器,这样编译器对代码进行优化,以减少指令跳转带来的性能下降
__buildin_expect((x), 1)表示x的值为真的可能性更大。
__buildin_expect((x), 0)表示x的值为假的可能性更大。
也就是说,使用likely(),执行if后面的语句的机会更大,使用unlikely(),执行else后面的语句机会更大一些。通过这种
方式,编译器在编译过程中,会将可能性更大的代码紧跟着后面的代码,从而减少指令跳转带来的性能上的下降。
比如 :
if (likely(a>b)) {
fun1();
}
这里就是程序员可以确定 a>b 在程序执行流程中出现的可能相比较大,因此运用了likely()告诉编译器将fun1()函数
的二进制代码紧跟在前面程序的后面,这样就cache在预取数据时就可以将fun1()函数的二进制代码拿到cache中。
这样,也就添加了cache的命中率。
同样的,unlikely()的作用就是告诉编译器,a<b 的可能性很小所以这里在编译时,将fun2()的二进制代码尽量
不要和前边的编译在一块。咱们不用对likely和unlikely感到迷惑,须要知晓的就是 if(likely(a>b)) 和 if(a>b)在功能
上是等价的,同样 if(unlikely(a<b)) 和 if(a<b) 的功能也是一样的。不一样的只是他们声称的二进制代码有所不一
样,这一点咱们也可以从他们的汇编代码中看到。总之,likely和unlikely的功能就是添加 cache的命中率,提高系统
执行速度。
1.3.3 switch VS if … else
switch和if-else相比,由于使用了Binary Tree算法,绝大部分情况下switch会快一点,除非是if-else的第一个条件就为true.
说实话 我也没有深入研究过这个问题的根源
只是在实际开发中 没有人会去用很多很多else if的
都是用 switch case 的 后者比较清晰 给人感觉就是一个脑子很清楚的人写出来的东西
至于效率的本质 就让大企鹅去操心吧
编译器编译switch与编译if…else…不同。不管有多少case,都直接跳转,不需逐个比较查询。
昨天发现了一本叫做CSAPP的书,终于找到了关于switch问题的解答。
这是一段C代码:
/* $begin switch-c */
int switch_eg(int x)
{
int result = x;
switch (x) {
case 100:
result *= 13;
break;
case 102:
result += 10;
/* Fall through */
case 103:
result += 11;
break;
case 104:
case 106:
result *= result;
break;
default:
result = 0;
}
return result;
}
/* $end switch-c */
用GCC汇编出来的代码如下:
.file "switch.c"
.version "01.01"
gcc2_compiled.:
.text
.align 4
.globl switch_eg
.type switch_eg,@function
switch_eg:
pushl %ebp
movl %esp,%ebp
movl 8(%ebp),%edx
leal -100(%edx),%eax
cmpl ,%eax
ja .L9
jmp *.L10(,%eax,4)
.p2align 4,,7
.section .rodata
.align 4
.align 4
.L10:
.long .L4
.long .L9
.long .L5
.long .L6
.long .L8
.long .L9
.long .L8
.text
.p2align 4,,7
.L4:
leal (%edx,%edx,2),%eax
leal (%edx,%eax,4),%edx
jmp .L3
.p2align 4,,7
.L5:
addl ,%edx
.L6:
addl ,%edx
jmp .L3
.p2align 4,,7
.L8:
imull %edx,%edx
jmp .L3
.p2align 4,,7
.L9:
xorl %edx,%edx
.L3:
movl %edx,%eax
movl %ebp,%esp
popl %ebp
ret
.Lfe1:
.size switch_eg,.Lfe1-switch_eg
.ident "GCC: (GNU) 2.95.3 20010315 (release)"
在上面的汇编代码中我们可以很清楚的看到switch部分被分配了一个连续的查找表,switch case中不连续的部分也被添加上了相应的条目,switch表的大小不是根据case语句的多少,而是case的最大值的最小值之间的间距。在选择相应 的分支时,会先有一个cmp子句,如果大于查找表的最大值,则跳转到default子句。而其他所有的case语句的耗时都回事O(1)。
相比于if-else结构,switch的效率绝对是要高很多的,但是switch使用查找表的方式决定了case的条件必须是一个连续的常量。而if-else则可以灵活的多。
可以看到if-else只是单纯地一个接一个比较,效率比较低,可以看出,switch的效率一般比if-else高
switch 效率高, 从汇编代码可以看出来
switch 只计算一次值 然后都是test , jmp,
if…else 是每个条件都要计算一遍的.
switch的效率与分支数无关
当只有分支比较少的时候,if效率比switch高(因为switch有跳转表) 分支比较多,那当然是switch
1.3.4 加减乘除与位操作
在hash中查找key的时候,经常会发现用&取代%,先看两段代码吧。
- java代码:
/**
* Returns index for hash code h.
*/
static int indexFor(int h, int length) {
return h & (length-1);
}
- redis的c代码
n.size = realsize;
n.sizemask = realsize-1;
//此处略去xxx行
hile(de) {
unsigned int h;
nextde = de->next;
/* Get the index in the new hash table */
h = dictHashKey(d, de->key) & d->ht[1].sizemask;
de->next = d->ht[1].table[h];
d->ht[1].table[h] = de;
d->ht[0].used--;
d->ht[1].used++;
de = nextde;
}
大家可以看到a%b取模的形式都被替换成了a&(b-1) ,当hashtable的长度是2的幂的情况下(疏忽,一开始没写),这两者是等价的,那为什么要用后者呢?
另一方面,为什么hashtable的长度最好要是2的n次方呢,这个不在本次讨论范围之列,原因简单说一下就是1、分布更均匀 2、碰撞几率更小 详情自己思考,JDK中的HashMap就会在初始化时,保证这一点:
- java代码
public HashMap(int initialCapacity, float loadFactor) {
if (initialCapacity < 0)
throw new IllegalArgumentException("Illegal initial capacity: " +
initialCapacity);
if (initialCapacity > MAXIMUM_CAPACITY)
initialCapacity = MAXIMUM_CAPACITY;
if (loadFactor <= 0 || Float.isNaN(loadFactor))
throw new IllegalArgumentException("Illegal load factor: " +
loadFactor);
// Find a power of 2 >= initialCapacity
int capacity = 1;
while (capacity < initialCapacity)
capacity <<= 1;
this.loadFactor = loadFactor;
threshold = (int)(capacity * loadFactor);
table = new Entry[capacity];
init();
}
- redis的c代码
/* Our hash table capability is a power of two */
static unsigned long _dictNextPower(unsigned long size)
{
unsigned long i = DICT_HT_INITIAL_SIZE;
if (size >= LONG_MAX) return LONG_MAX;
while(1) {
if (i >= size)
return i;
i *= 2;
}
}
言归正传,大家都知道位运算的效率最高,这也是&取代%的原因,来看个程序:
int main(int argc, char* argv[])
{
int a = 0x111;
int b = 0x222;
int c = 0;
int d = 0;
c = a & (b-1);
d = a % b;
return 0;
}
反汇编结果:
13: c = a & (b-1);
00401044 mov eax,dword ptr [ebp-8]
00401047 sub eax,1
0040104A mov ecx,dword ptr [ebp-4]
0040104D and ecx,eax
0040104F mov dword ptr [ebp-0Ch],ecx
14: d = a % b;
00401052 mov eax,dword ptr [ebp-4]
00401055 cdq
00401056 idiv eax,dword ptr [ebp-8]
00401059 mov dword ptr [ebp-10h],edx
可以看到,&操作用了:3mov+1and+1sub %操作用了:2mov+1cdp+1idiv
我们可以查阅Coding_ASM_-_Intel_Instruction_Set_Codes_and_Cycles资料,发现前者只需5个CPU周期,而后者至少需要26个CPU周期(注意,是最少!!!) 效率显而易见。所以以后自己在写的时候,也可以使用前者的写法。
1.4 程序的调用过程、寄存器
1.4.1 程序代码
1 #include<stdio.h>
2 int sum(int x, int y)
3 {
4 int accum = 0;
5 int t;
6 t = x + y;
7 accum += t;
8 return accum;
9 }
10 int main( int argc, char **argv)
11 {
12 int x = 1, y = 2;
13 int result = sum( x, y );
14 printf("\n\n result = %d \n\n", result);
15 return 0;
16 }
1.4.2 汇编代码
3 (gdb) disas main
4 Dump of assembler code for function main(int, char**):
5 0x0000000000400564 <+0>: push %rbp // 寄存器rbp指向调用函数栈的栈底,压入新函数栈的栈底
6 0x0000000000400565 <+1>: mov %rsp,%rbp // 寄存器rsp指向调用函数栈的栈顶,赋值给rbp
7 0x0000000000400568 <+4>: sub $0x20,%rsp //将rsp栈顶指针向下移动32个字节,即用于开辟内存,存储该函数的局部变量
8 0x000000000040056c <+8>: mov %edi,-0x14(%rbp)
9 0x000000000040056f <+11>: mov %rsi,-0x20(%rbp)
10 0x0000000000400573 <+15>: movl $0x1,-0x4(%rbp) //将1存储在rbp位置-12偏移量的位置,即x的值
11 0x000000000040057a <+22>: movl $0x2,-0x8(%rbp) //将2存储在rbp位置-8偏移量的位置,即y的值
12 0x0000000000400581 <+29>: mov -0x8(%rbp),%edx
13 0x0000000000400584 <+32>: mov -0x4(%rbp),%eax
14 0x0000000000400587 <+35>: mov %edx,%esi
15 0x0000000000400589 <+37>: mov %eax,%edi
16 0x000000000040058b <+39>: callq 0x40053d <sum(int, int)>
17 0x0000000000400590 <+44>: mov %eax,-0xc(%rbp)
18 0x0000000000400593 <+47>: mov -0xc(%rbp),%eax
19 0x0000000000400596 <+50>: mov %eax,%esi
20 0x0000000000400598 <+52>: mov $0x400640,%edi
21 0x000000000040059d <+57>: mov $0x0,%eax
22 0x00000000004005a2 <+62>: callq 0x400420 <printf@plt>
23 0x00000000004005a7 <+67>: mov $0x0,%eax
24 0x00000000004005ac <+72>: leaveq
25 0x00000000004005ad <+73>: retq
26 End of assembler dump.
1.4.3 寄存器、函数调用
1.4.3.1 寄存器
- 通用寄存器(x86-64的低32bit可直接以x86使用):
x86(32bit) | x86-64(64bit) | 说明 |
---|---|---|
EAX | RAX | 函数返回值 |
EBX | RBX | |
ECX | RCX | |
EDX | RDX | |
ESI | RSI | |
EDI | RDI | |
ESP | RSP | 栈顶地址(SP) |
EBP | RBP | 当前栈帧地址(FP) |
R8-R15 |
- 段寄存器:
x86(16bit) | x86-64(32bit) | 说明 |
---|---|---|
CS | CS | Code Segment |
DS | DS | Data Segment |
SS | SS | Stack |
ES | ES | Data |
FS | FS | Data |
GS | GS | Data |
- 状态和指令寄存器:
x86(32bit) | x86-64(64bit) | 说明 |
---|---|---|
EFLAGS | RFLAGS | 状态字,x86-64也只使用了低32bit |
EIP | RIP | 代码指针(PC) |
- 浮点寄存器(IEEE754):
x86(128bit) | x86-64(128bit) | 说明 |
---|---|---|
XMM0-XMM7 | XMM0-XMM15 | 单精度32bit, 双精度64bit, 扩展精度128bit |
- 其它控制寄存器(略)
让寄存器为己所用,就得了解它们的用途,这些用途都涉及函数调用,X86-64有16个64位寄存器,分别是:
%rax,%rbx,%rcx,%rdx,%esi,%edi,%rbp,%rsp,%r8,%r9,%r10,%r11,%r12,%r13,%r14,%r15。
其中:
-
%rax 作为函数返回值使用。
-
%rsp 栈指针寄存器,指向栈顶
-
%rdi,%rsi,%rdx,%rcx,%r8,%r9 用作函数参数,依次对应第1参数,第2参数。。。
-
%rbx,%rbp,%r12,%r13,%14,%15 用作数据存储,遵循被调用者使用规则,简单说就是随便用,调用子函数之前要备份它,以防他被修改
-
%r10,%r11 用作数据存储,遵循调用者使用规则,简单说就是使用之前要先保存原值
1.4.3.4 栈帧结构
C语言属于面向过程语言,他最大特点就是把一个程序分解成若干过程(函数),比如:入口函数是main,然后调用各个子函数。在对应机器语言中,GCC把过程转化成栈帧(frame),简单的说,每个栈帧对应一个过程。X86-32典型栈帧结构中,由%ebp指向栈帧开始,%esp指向栈顶。
参考资料:
栈帧调用分析的很好的例子
函数的进入和退出,通过指令call和ret来完成,给一个例子
#include <code>
int foo ( int x )
{
int array[] = {1,3,5};
return array[x];
} /* ----- end of function foo ----- */
int main ( int argc, char *argv[] )
{
int i = 1;
int j = foo(i);
fprintf(stdout, "i=%d,j=%d\n", i, j);
return EXIT_SUCCESS;
} /* ---------- end of function main ---------- */
命令行中调用gcc,生成汇编语言:
Shell > gcc –S –o test.s test.c
Main函数第40行的指令Callfoo其实干了两件事情:
- Pushl %rip //保存下一条指令(第41行的代码地址)的地址,用于函数返回继续执行
- Jmp foo //跳转到函数foo
Foo函数第19行的指令ret 相当于:
- popl %rip //恢复指令指针寄存器
1.4.3.4.1 栈帧的建立和撤销
还是上一个例子,看看栈帧如何建立和撤销。
说题外话,以”点”做为前缀的指令都是用来指导汇编器的命令。无意于程序理解,统统忽视之,比如第31行。
栈帧中,最重要的是帧指针%ebp和栈指针%esp,有了这两个指针,我们就可以刻画一个完整的栈帧。
函数main的第30~32行,描述了如何保存上一个栈帧的帧指针,并设置当前的指针。
第49行的leave指令相当于:
Movq %rbp %rsp //撤销栈空间,回滚%rsp。
Popq %rbp //恢复上一个栈帧的%rbp。
同一件事情会有很多的做法,GCC会综合考虑,并作出选择。选择leave指令,极有可能因为该指令需要存储空间少,需要时钟周期也少。
你会发现,在所有的函数中,几乎都是同样的套路,我们通过gdb观察一下进入foo函数之前main的栈帧,进入foo函数的栈帧,退出foo的栈帧情况。
Shell> gcc -g -o testtest.c
Shell> gdb --args test
Gdb > break main
Gdb > run
进入foo函数之前:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-WoufjgqP-1622166205362)(https://blog.csdn.net/u013982161/article/details/51347944)]
你会发现rbp-rsp=0×20,这个是由代码第11行造成的。
进入foo函数的栈帧:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ul8Lwxe2-1622166205363)(https://blog.csdn.net/u013982161/article/details/51347944)]
回到main函数的栈帧,rbp和rsp恢复成进入foo之前的状态,就好像什么都没发生一样。
1.4.3.4.1 可有可无的帧指针
你刚刚搞清楚帧指针,是不是很期待要马上派上用场,这样你可能要大失所望,因为大部分的程序,都加了优化编译选项:-O2,这几乎是普遍的选择。在这种优化级别,甚至更低的优化级别-O1,都已经去除了帧指针,也就是%ebp中再也不是保存帧指针,而且另作他途。
在x86-32时代,当前栈帧总是从保存%ebp开始,空间由运行时决定,通过不断push和pop改变当前栈帧空间;x86-64开始,GCC有了新的选择,优化编译选项-O1,可以让GCC不再使用栈帧指针,下面引用 gcc manual 一段话 :
-O also turns on -fomit-frame-pointer on machines where doing so does not interfere withdebugging.
这样一来,所有空间在函数开始处就预分配好,不需要栈帧指针;通过%rsp的偏移就可以访问所有的局部变量。说了这么多,还是看看例子吧。同一个例子, 加上-O1选项:
Shell>: gcc –O1 –S –o test.s test.c
分析main函数,GCC分析发现栈帧只需要8个字节,于是进入main之后第一条指令就分配了空间(第23行):
Subq $8, %rsp
然后在返回上一栈帧之前,回收了空间(第34行):
Addq $8, %rsp
等等,为啥main函数中并没有对分配空间的引用呢?这是因为GCC考虑到栈帧对齐需求,故意做出的安排。再来看foo函数,这里你可以看到%rsp是如何引用栈空间的。等等,不是需要先预分配空间吗?这里为啥没有预分配,直接引用栈顶之外的地址?这就要涉及x86-64引入的牛逼特性了。
1.4.3.4.3 访问栈顶之外
通过readelf查看可执行程序的header信息:
红色区域部分指出了x86-64遵循ABI规则的版本,它定义了一些规范,遵循ABI的具体实现应该满足这些规范,其中,他就规定了程序可以使用栈顶之外128字节的地址。
这说起来很简单,具体实现可有大学问,这超出了本文的范围,具体大家参考虚拟存储器。别的不提,接着上例,我们发现GCC利用了这个特性,干脆就不给foo函数分配栈帧空间了,而是直接使用栈帧之外的空间。@恨少说这就相当于内联函数呗,我要说:这就是编译优化的力量。
1.4.3.4.4 寄存器保存惯例
过程调用中,调用者栈帧需要寄存器暂存数据,被调用者栈帧也需要寄存器暂存数据。如果调用者使用了%rbx,那被调用者就需要在使用之前把%rbx保存起来,然后在返回调用者栈帧之前,恢复%rbx。遵循该使用规则的寄存器就是被调用者保存寄存器,对于调用者来说,%rbx就是非易失的。
反过来,调用者使用%r10存储局部变量,为了能在子函数调用后还能使用%r10,调用者把%r10先保存起来,然后在子函数返回之后,再恢复%r10。遵循该使用规则的寄存器就是调用者保存寄存器,对于调用者来说,%r10就是易失的,举个例子:
#include <stdio.h>
#include <stdlib.h>
void sfact_helper ( long int x, long int * resultp)
{
if (x<=1)
*resultp = 1;
else {
long int nresult;
sfact_helper(x-1,&nresult);
*resultp = x * nresult;
}
} /* ----- end of function foo ----- */
long int
sfact ( long int x )
{
long int result;
sfact_helper(x, &result);
return result;
} /* ----- end of function sfact ----- */
int
main ( int argc, char *argv[] )
{
int sum = sfact(10);
fprintf(stdout, “sum=%d\n”, sum);
return EXIT_SUCCESS;
} /* ---------- end of function main ---------- */
命令行中调用gcc,生成汇编语言:
Shell>: gcc –O1 –S –o test2.s test2.c
在函数sfact_helper中,用到了寄存器%rbx和%rbp,在覆盖之前,GCC选择了先保存他们的值,代码6~9说明该行为。在函数返回之前,GCC依次恢复了他们,就如代码27-28展示的那样。
看这段代码你可能会困惑?为什么%rbx在函数进入的时候,指向的是-16(%rsp),而在退出的时候,变成了32(%rsp) 。上文不是介绍过一个重要的特性吗?访问栈帧之外的空间,这是GCC不用先分配空间再使用;而是先使用栈空间,然后在适当的时机分配。第11行代码展示了空间分配,之后栈指针发生变化,所以同一个地址的引用偏移也相应做出调整。
X86时代,参数传递是通过入栈实现的,相对CPU来说,存储器访问太慢;这样函数调用的效率就不高,在x86-64时代,寄存器数量多了,GCC就可以利用多达6个寄存器来存储参数,多于6个的参数,依然还是通过入栈实现。了解这些对我们写代码很有帮助,起码有两点启示:
- 尽量使用6个以下的参数列表,不要让GCC为难啊。
- 传递大对象,尽量使用指针或者引用,鉴于寄存器只有64位,而且只能存储整形数值,寄存器存不下大对象
让我们具体看看参数是如何传递的:
#include <stdio.h>
#include <stdlib.h>
int foo ( int arg1, int arg2, int arg3, int arg4, int arg5, int arg6, int arg7 )
{
int array[] = {100,200,300,400,500,600,700};
int sum = array[arg1]+ array[arg7];
return sum;
} /* ----- end of function foo ----- */
int
main ( int argc, char *argv[] )
{
int i = 1;
int j = foo(0,1,2, 3, 4, 5,6);
fprintf(stdout, “i=%d,j=%d\n”, i, j);
return EXIT_SUCCESS;
} /* ---------- end of function main ---------- */
命令行中调用gcc,生成汇编语言:
Shell>: gcc –O1 –S –o test1.s test1.c
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-9vmw36YD-1622166205364)(https://blog.csdn.net/u013982161/article/details/51347944)]
Main函数中,代码31~37准备函数foo的参数,从参数7开始,存储在栈上,%rsp指向的位置;参数6存储在寄存器%r9d;参数5存储在寄存器%r8d;参数4对应于%ecx;参数3对应于%edx;参数2对应于%esi;参数1对应于%edi。
Foo函数中,代码14-15,分别取出参数7和参数1,参与运算。这里数组引用,用到了最经典的寻址方式,-40(%rsp,%rdi,4)=%rsp + %rdi *4 + (-40);其中%rsp用作数组基地址;%rdi用作了数组的下标;数字4表示sizeof(int)=4。
1.4.3.4.5 结构体传参
应@桂南要求,再加一节,相信大家也很想知道结构体是如何存储,如何引用的,如果作为参数,会如何传递,如果作为返回值,又会如何返回。
看下面的例子:
#include <stdio.h>
#include <stdlib.h>
struct demo_s {
char var8;
int var32;
long var64;
};
struct demo_s foo (struct demo_s d)
{
d.var8=8;
d.var32=32;
d.var64=64;
return d;
} /* ----- end of function foo ----- */
int
main ( int argc, char *argv[] )
{
struct demo_s d, result;
result = foo (d);
fprintf(stdout, “demo: %d, %d, %ld\n”, result.var8,result.var32, result.var64);
return EXIT_SUCCESS;
} /* ---------- end of function main ---------- */
我们缺省编译选项,加了优化编译的选项可以留给大家思考。
Shell>gcc -S -o test.s test.c
上面的代码加了一些注释,方便大家理解,
问题1:结构体如何传递?它被分成了两个部分,var8和var32合并成8个字节的大小,放在寄存器%rdi中,var64放在寄存器的%rsi中。也就是结构体分解了。
问题2:结构体如何存储? 注意看foo函数的第15~17行注意到,结构体的引用变成了一个偏移量访问。这和数组很像,只不过他的元素大小可变。
问题3:结构体如何返回,原本%rax充当了返回值的角色,现在添加了返回值2:%rdx。同样,GCC用两个寄存器来表示结构体。
恩, 即使在缺省情况下,GCC依然是想尽办法使用寄存器。随着结构变的越来越大,寄存器不够用了,那就只能使用栈了。
1.4.3.4.6 总结
了解寄存器和栈帧的关系,对于gdb调试很有帮助;过些日子,一定找个合适的例子和大家分享一下。
1.4.3.4.7 参考
\1. 深入理解计算机体系结构
\2. x86系列汇编语言程序设计
http://ju.outofmemory.cn/entry/769
1.4.3.3 函数调用过程以及汇编代码含义
- 代码code
int sum(int a, int b)
{
if (a == b) return b;
return a + sum(a+1 , b);
}
int main()
{
sum(1, 10);
}
- 汇编代码
(gdb) disas main
Dump of assembler code for function main:
0x00000000004004e1 <+0>: push %rbp
0x00000000004004e2 <+1>: mov %rsp,%rbp
0x00000000004004e5 <+4>: mov $0xa,%esi # 第二个参数入栈
0x00000000004004ea <+9>: mov $0x1,%edi # 第一个参数入栈
0x00000000004004ef <+14>: callq 0x4004ad <sum> # 调用sum函数,隐含把PC压入到栈中
0x00000000004004f4 <+19>: pop %rbp
0x00000000004004f5 <+20>: retq
End of assembler dump.
(gdb) disas sum
Dump of assembler code for function sum:
0x00000000004004ad <+0>: push %rbp # 上层FP入栈
0x00000000004004ae <+1>: mov %rsp,%rbp # 当前SP作为本层FP
0x00000000004004b1 <+4>: sub $0x10,%rsp # 分配自动变量空间,调整SP
0x00000000004004b5 <+8>: mov %edi,-0x4(%rbp)
0x00000000004004b8 <+11>: mov %esi,-0x8(%rbp)
0x00000000004004bb <+14>: mov -0x4(%rbp),%eax
0x00000000004004be <+17>: cmp -0x8(%rbp),%eax
0x00000000004004c1 <+20>: jne 0x4004c8 <sum+27>
0x00000000004004c3 <+22>: mov -0x8(%rbp),%eax
0x00000000004004c6 <+25>: jmp 0x4004df <sum+50>
0x00000000004004c8 <+27>: mov -0x4(%rbp),%eax
0x00000000004004cb <+30>: lea 0x1(%rax),%edx
0x00000000004004ce <+33>: mov -0x8(%rbp),%eax
0x00000000004004d1 <+36>: mov %eax,%esi
0x00000000004004d3 <+38>: mov %edx,%edi
0x00000000004004d5 <+40>: callq 0x4004ad <sum>
0x00000000004004da <+45>: mov -0x4(%rbp),%edx
0x00000000004004dd <+48>: add %edx,%eax # EAX保存返回值,函数返回值保存在(EDX, EAX)中,高位在EDX中,低位在EAX中
0x00000000004004df <+50>: leaveq # 删除栈帧,隐含执行把FP赋给SP,然后POP上层FP给FP
0x00000000004004e0 <+51>: retq # 函数返回,隐含执行POP栈顶元素(上层代码返回地址)赋给PC
End of assembler dump.
(gdb)
- 说明:
- 函数参数优先使用寄存器进行传递。使用的寄存器为:
- 整形/指针:RDI, RSI, RDX, RCX, R8, R9(依序)
- 浮点型:XMM0 - XMM15
- 在-01级别以上的编译优化时,省略了FP(EBP),只使用SP(ESP)。 因为编译时栈的大小已经确定,因此可以通过SP偏移访问栈上元素。
- 不必每次函数调用都调整SP,因为存在128B的redzone,所以当前栈帧可以从SP开始拓展128B使用。
1.5 gdb、汇编相关、objdump、addr2line等
https://www.cnblogs.com/jlmgary/p/6170435.html
1 # a.out
2 - 0x401049
3
4 # objdump -d -C a.out
5 - 401049
6
7 # gdb
8 1. gdb a.out
9 2. disassemble /m main | /m 源码和汇编一起排列 | /r 还可以看到16进制代码/r 还可以看到16进制代码
10 3. x/15i main
11
12 #
13 - 476 401044: e8 d2 ff ff ff callq 40101b <test1()>
14 - 477 401049: b8 00 00 00 00 mov $0x0,%eax
15
16
17 - https://blog.csdn.net/xuleilx/article/details/7365424
18 - https://wangchujiang.com/linux-command/c/objdump.html