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

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

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

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 <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 中的测试结果图,来解释下内存对齐在极端情况下带来的性能损失。 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导致效率低的实验:http://evol128.is-programmer.com/posts/35453.html

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

1.2.5 Cache淘汰策略

在文章的最后我们顺带提一下CPU Cache的淘汰策略。常见的淘汰策略主要有LRURandom两种。通常意义下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

  1. Gallery of Processor Cache Effects
  2. How Misaligning Data Can Increase Performance 12x by Reducing Cache Misses
  3. 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)说明
EAXRAX函数返回值
EBXRBX
ECXRCX
EDXRDX
ESIRSI
EDIRDI
ESPRSP栈顶地址(SP)
EBPRBP当前栈帧地址(FP)
R8-R15
  • 段寄存器:
x86(16bit)x86-64(32bit)说明
CSCSCode Segment
DSDSData Segment
SSSSStack
ESESData
FSFSData
GSGSData
  • 状态和指令寄存器:
x86(32bit)x86-64(64bit)说明
EFLAGSRFLAGS状态字,x86-64也只使用了低32bit
EIPRIP代码指针(PC)
  • 浮点寄存器(IEEE754):
x86(128bit)x86-64(128bit)说明
XMM0-XMM7XMM0-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

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 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)
  • 说明:
    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 <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
  • 1
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值