程序编译、优化、调试大杂烩
发布日期:2021-10-10 05:31:16 浏览次数:22 分类:技术文章

本文共 22425 字,大约阅读时间需要 74 分钟。

程序编译、优化、调试大杂烩

文章目录

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)。下面是一段从中摘录的解释:

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)两种专门用途的缓存。 下面可以看出各级缓存之间的响应时间差距,以及内存到底有多慢!

cpu cache

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 
#include
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

如果我们把这些数据做成折线图后就会发现:总执行时间在数组大小超过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值得计算虽然耗时(),但是相比程序中其他操作(上百万的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段是从低位而不是高位开始的

下面是一段从摘录的解释:

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)。而如果我们在程序中又使用了所谓优化神器的“内存对齐”的时候,这种争抢就会越发增多。效率上的损失也会变得非常明显。具体的实际测试我们可以参考: 一文。 这里我们引用一张 中的测试结果图,来解释下内存对齐在极端情况下带来的性能损失。 memory_align

该图实际上是我们上文中第一个测试的一个变种。纵轴表示了测试对象数组的大小。横轴表示了每次数组元素访问之间的index间隔。而图中的颜色表示了响应时间的长短,蓝色越明显的部分表示响应时间越长。从这个图我们可以得到很多结论。当然这里我们只对内存带来的性能损失感兴趣。有兴趣的读者也可以阅读分析理解其他从图中可以得到的结论。

从图中我们不难看出图中每1024个步进,即每1024*4即4096Bytes,都有一条特别明显的蓝色竖线。也就是说,只要我们按照4K的步进去访问内存(内存根据4K对齐),无论热点数据多大它的实际效率都是非常低的!按照我们上文的分析,如果4KB的内存对齐,那么一个240MB的数组就含有61440个可以被访问到的数组元素;而对于一个每256K就会有set冲突的16Way二级缓存,总共有256K/4K=64个元素要去争抢16个空位,总共有61440/64=960个这样的元素。那么缓存命中率只有1%,自然效率也就低了。

除了这个例子,有兴趣的读者还可以查阅另一篇国人对Page Align导致效率低的实验:

想要知道更多关于内存地址对齐在目前的这种CPU-Cache的架构下会出现的问题可以详细阅读以下两篇文章:

1.2.5 Cache淘汰策略

在文章的最后我们顺带提一下CPU Cache的淘汰策略。常见的淘汰策略主要有LRURandom两种。通常意义下LRU对于Cache的命中率会比Random更好,所以CPU Cache的淘汰策略选择的是LRU。当然也有些实验显示

1.2.6 总结

CPU Cache对于程序猿是透明的,所有的操作和策略都在CPU内部完成。但是,了解和理解CPU Cache的设计、工作原理有利于我们更好的利用CPU Cache,写出更多对CPU Cache友好的程序

1.2.7 Reference

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()从阅读和理解的角度是一样的。

这两个宏在内核中定义如下:

#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
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 
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
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 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

img

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)]img

你会发现rbp-rsp=0×20,这个是由代码第11行造成的。

进入foo函数的栈帧:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-Ul8Lwxe2-1622166205363)(https://blog.csdn.net/u013982161/article/details/51347944)]img

回到main函数的栈帧,rbp和rsp恢复成进入foo之前的状态,就好像什么都没发生一样。

img

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)]img

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 mainDump of assembler code for function main:0x00000000004004e1 <+0>:    push   %rbp0x00000000004004e2 <+1>:    mov    %rsp,%rbp0x00000000004004e5 <+4>:    mov    $0xa,%esi            # 第二个参数入栈0x00000000004004ea <+9>:    mov    $0x1,%edi            # 第一个参数入栈0x00000000004004ef <+14>:   callq  0x4004ad 
# 调用sum函数,隐含把PC压入到栈中0x00000000004004f4 <+19>: pop %rbp0x00000000004004f5 <+20>: retqEnd of assembler dump.(gdb) disas sumDump of assembler code for function sum:0x00000000004004ad <+0>: push %rbp # 上层FP入栈0x00000000004004ae <+1>: mov %rsp,%rbp # 当前SP作为本层FP0x00000000004004b1 <+4>: sub $0x10,%rsp # 分配自动变量空间,调整SP0x00000000004004b5 <+8>: mov %edi,-0x4(%rbp)0x00000000004004b8 <+11>: mov %esi,-0x8(%rbp)0x00000000004004bb <+14>: mov -0x4(%rbp),%eax0x00000000004004be <+17>: cmp -0x8(%rbp),%eax0x00000000004004c1 <+20>: jne 0x4004c8
0x00000000004004c3 <+22>: mov -0x8(%rbp),%eax0x00000000004004c6 <+25>: jmp 0x4004df
0x00000000004004c8 <+27>: mov -0x4(%rbp),%eax0x00000000004004cb <+30>: lea 0x1(%rax),%edx0x00000000004004ce <+33>: mov -0x8(%rbp),%eax0x00000000004004d1 <+36>: mov %eax,%esi0x00000000004004d3 <+38>: mov %edx,%edi0x00000000004004d5 <+40>: callq 0x4004ad
0x00000000004004da <+45>: mov -0x4(%rbp),%edx0x00000000004004dd <+48>: add %edx,%eax # EAX保存返回值,函数返回值保存在(EDX, EAX)中,高位在EDX中,低位在EAX中0x00000000004004df <+50>: leaveq # 删除栈帧,隐含执行把FP赋给SP,然后POP上层FP给FP0x00000000004004e0 <+51>: retq # 函数返回,隐含执行POP栈顶元素(上层代码返回地址)赋给PCEnd of assembler dump.(gdb)
  • 说明:
    1. 函数参数优先使用寄存器进行传递。使用的寄存器为:
    2. 整形/指针:RDI, RSI, RDX, RCX, R8, R9(依序)
    3. 浮点型:XMM0 - XMM15
    4. 在-01级别以上的编译优化时,省略了FP(EBP),只使用SP(ESP)。 因为编译时栈的大小已经确定,因此可以通过SP偏移访问栈上元素。
    5. 不必每次函数调用都调整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 
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

转载地址:https://blog.csdn.net/qq_22054285/article/details/85256955 如侵犯您的版权,请留言回复原文章的地址,我们会给您删除此文章,给您带来不便请您谅解!

上一篇:内存分配
下一篇:三角函数

发表评论

最新留言

逛到本站,mark一下
[***.202.152.39]2024年04月17日 04时45分00秒

关于作者

    喝酒易醉,品茶养心,人生如梦,品茶悟道,何以解忧?唯有杜康!
-- 愿君每日到此一游!

推荐文章

《微观动机与宏观行为》精髓:个人的微观动机,是如何影响宏观行为结果的? 2019-04-26
《国富论》精髓:亚当·斯密提出的对后世影响深远的三个经济学理论:劳动分工理论、生产要素理论和宏观调控理论 2019-04-26
《动荡的世界》精髓:什么是动物精神?动物精神又是怎么影响2008年全球经济危机的,以及我们该如何预防动物精神,避免危机再次发生。 2019-04-26
《投资最重要的事》精髓:利用逆向思维,掌握既冷静又勇猛的投资方法,成为投资界真正厉害的人。 2019-04-26
《千年金融史》书中的精髓:金融是一部间机器,塑造了现代文明。 2019-04-26
《共同基金常识》书中的精髓:如何用好指数基金,做好理财投资? 2019-04-26
《金融的本质》书中的精髓:金融危机是如何产生的?以及美联储是如何应对金融危机的? 2019-04-26
《周期》书中的精髓:如何利用周期,掌握世界的发展趋势,实现财富积累。 2019-04-26
《伟大的博弈》书中的精髓:华尔街是如何从一条小街,一步步发展为世界金融中心的。 2019-04-26
《逃不开的经济周期》书中的精髓:经济周期是推动创新变革和经济增长以及复兴的关键力量。 2019-04-26
《朋友还是对手》书中的精髓:奥地利学派和芝加哥学派两派共识远多于分歧,两派首先是朋友,其次才是对手。 2019-04-26
《动物精神》书中的精髓:人类的非理性面影响经济决策,这些有可能是金融危机的根源。 2019-04-26
《赢家的诅咒》书中的精髓:人性的复杂让主流经济学出现了诸多失灵,如何用更多理论完善经济学大厦是经济学家的重要使命 2019-04-26
《巴菲特法则》书中的精髓:用好巴菲特企业前景投资法则,股票投资稳赚不赔。 2019-04-26
《战胜华尔街》书中的精髓:业余投资者如何根据行业特点选好股票,赚得比专业的投资者还要多? 2019-04-26
《时间的玫瑰》书中的精髓:知名投资人但斌眼中的价值投资是什么?我们如何秉承价值投资的原则选择有价值的股票? 2019-04-26
《巴菲特的估值逻辑》书中的精髓:在投资过程中不断总结经验,不断提升投资能力,不断探索、加深对好公司的理解,成就了巴菲特的投资神话。 2019-04-26
《股市稳赚》书中的精髓:用简单的神奇公式进行股票投资,获得稳定而持久的收益。 2019-04-26
《曾国藩的经济课》书中的精髓:我们如何像曾国藩一样,在遇到道德和环境冲突时,既能保持自己的道德,又能调动资源,帮助自己成事。 2019-04-26
《富人的逻辑》书中的精髓:为什么暴富起来的人会在短期内失去财富,我们又该如何去创造财富和持续拥有财富。 2019-04-26