问题概述

C++中函数可以分为三种,静态成员函数 (static member function)、非静态成员函数 (non-static member function)和普通函数(本文非静态成员函数不考虑虚函数,因为其实现更为独特,容易使文章偏题),那么他们的本质区别到底在哪里?

我觉得这个问题可以从三个视角去思考:

  1. 开发者的视角
  2. 编译器的视角
  3. 可执行文件的视角

分析

开发者视角

对于开发者而言,这三者的区别往往是显而易见的:

  1. 普通函数没有“从属”的概念,或者说,它是直接属于某个namespace的。
  2. 非静态成员函数是和实例联系在一起的,我们需要一个实例才能调用非静态成员函数。
  3. 静态成员函数是和类绑定的,可以直接通过类名调用,但是静态成员函数内部不能调用非静态成员变量。

这里的概念都很浅显,不再赘述。

编译器视角

到了编译器的视角,就必须想明白一个问题,代码里调用一个函数的时候,编译器到底做了什么事情?

而要明白上面这个问题,就要明白这个问题:“函数”到底以何种形式存在于编译生成的可执行文件中?

在以前,我陷入过一个误区,认为函数主体存在于.text段中,而.data段中留存了所有函数的起始地址,然后在调用的时候就先在.data段中找到函数的地址,然后再进行调用。

这种理解在实际开发中一般无伤大雅,但这其实是一种想当然——把编译器想的太愚蠢了。对于人类而言,上面这种思路似乎是开发中很自然的一个举措,既然函数散落在.text段的各个地方,那么我在.data段中集中存放所有函数的地址信息,就可以很方便地管理。然而对于编译器而言,这种方式实际上是非常无理的,因为不仅没有简化编译器的工作,还由于两次寻址降低了实际代码执行的效率。

事实上,“函数”区分于“数据”的地方就在于,它直接体现在.text段中,而在数据区,你是难觅其踪迹的,当我们调用一个函数的时候,会直接执行一个CALL的汇编指令,后面跟随的是硬编码进了可执行文件中的函数地址。

我们在代码中调用一个函数,编译器做的最重要的事情就两个:

  1. 确定函数地址(有的会在链接时重定位才确定)。
  2. 确定把哪些数据入栈。

回过头来看,编译器视角下,这三种函数有什么区别呢?

首先是成员函数和普通函数的区别,这里可能会比较意外:他们的主要区别只在于Name Mangling

Name Mangling就是指,编译器会生成一个和你定义的函数名不一样的函数名,比如我定义了两个函数,一个是类A中的成员函数void A::func(),另一个是普通函数void func(),用gcc编译之后实际函数名分别为_ZN1A5funcEv_Z5funcv。至于为什么要这么做,感兴趣可以查一下Name Mangling。

换句话说,成员函数和普通函数在调用效率上的差别是微乎其微的(见《深度探索C++对象模型》4.4章),这也算是cpp设计的一个基本理念吧。

而静态成员函数和非静态成员函数的区别还要更大:非静态成员函数默认带一个函数指针。唔……不得不说,在不断变卷的今天,这个已经成了C++校招面试必备知识了,这个知识也不难理解,比如一个非静态成员函数void A::func()在编译的时候实际上可以认为生成的函数签名是void func(A* const this),通过这种方式,非静态成员函数实现了对非静态成员变量的访问,也正是因此,我们有时候会说非静态成员函数是和实例绑定的(其实严格来讲并不准确)。

这部分知识相信对于熟练使用cpp的人耳熟能详,但是到底如何证明这一切呢?我在试图查找更深一层的验证的时候,发现这里鲜有人至。

可执行文件视角

承接上文,想要证明编译器做了这些事情,大抵只能考虑两个方向:

  1. 读编译器源码。
  2. 分析可执行文件。

读编译器的源码暂时有心无力,所以只好从可执行文件里管中窥豹了。

我们写出如下代码,命名为test.cpp(这里只是为了方便测试,实际开发绝不应该这么写):

class A {
 public:
  int a;
  int b;
  void func() { b = 2; }
  static void func2() { int c = 1; }
};

void func3() { int a = 0; }

int main() {
  A ins;
  ins.a = 5;
  ins.func();
  A::func2();
  func3();
  ins.func();
  return 0;
}

我们编译后用先objdump查看一下各个段的信息(objdump -h),会发现.data段大小是0x10,这说明函数确实不会影响到.data段的存储(可以自行加几个函数尝试,.data段大小不会变)。

然后我们将ELF文件反汇编(objdump -l -S),可以看到.text段其中一部分如下所示:

0000000000001149 <_Z5func3v>:
_Z5func3v():
    1149:       f3 0f 1e fa             endbr64 
    114d:       55                      push   %rbp
    114e:       48 89 e5                mov    %rsp,%rbp
    1151:       c7 45 fc 00 00 00 00    movl   $0x0,-0x4(%rbp)
    1158:       90                      nop
    1159:       5d                      pop    %rbp
    115a:       c3                      retq   

000000000000115b <main>:
main():
    115b:       f3 0f 1e fa             endbr64 
    115f:       55                      push   %rbp
    1160:       48 89 e5                mov    %rsp,%rbp
    1163:       48 83 ec 10             sub    $0x10,%rsp
    1167:       64 48 8b 04 25 28 00    mov    %fs:0x28,%rax
    116e:       00 00 
    1170:       48 89 45 f8             mov    %rax,-0x8(%rbp)
    1174:       31 c0                   xor    %eax,%eax
    1176:       c7 45 f4 05 00 00 00    movl   $0x5,-0x10(%rbp)
    117d:       48 8d 45 f0             lea    -0x10(%rbp),%rax
    1181:       48 89 c7                mov    %rax,%rdi
    1184:       e8 31 00 00 00          callq  11ba <_ZN1A4funcEv>
    1189:       e8 46 00 00 00          callq  11d4 <_ZN1A5func2Ev>
    118e:       e8 b6 ff ff ff          callq  1149 <_Z5func3v>
    1193:       48 8d 45 f0             lea    -0x10(%rbp),%rax
    1197:       48 89 c7                mov    %rax,%rdi
    119a:       e8 1b 00 00 00          callq  11ba <_ZN1A4funcEv>
    119f:       b8 00 00 00 00          mov    $0x0,%eax
    11a4:       48 8b 55 f8             mov    -0x8(%rbp),%rdx
    11a8:       64 48 33 14 25 28 00    xor    %fs:0x28,%rdx
    11af:       00 00 
    11b1:       74 05                   je     11b8 <main+0x5d>
    11b3:       e8 98 fe ff ff          callq  1050 <__stack_chk_fail@plt>
    11b8:       c9                      leaveq 
    11b9:       c3                      retq   

00000000000011ba <_ZN1A4funcEv>:
_ZN1A4funcEv():
    11ba:       f3 0f 1e fa             endbr64 
    11be:       55                      push   %rbp
    11bf:       48 89 e5                mov    %rsp,%rbp
    11c2:       48 89 7d f8             mov    %rdi,-0x8(%rbp)
    11c6:       48 8b 45 f8             mov    -0x8(%rbp),%rax
    11ca:       c7 40 04 02 00 00 00    movl   $0x2,0x4(%rax)
    11d1:       90                      nop
    11d2:       5d                      pop    %rbp
    11d3:       c3                      retq   

00000000000011d4 <_ZN1A5func2Ev>:
_ZN1A5func2Ev():
    11d4:       f3 0f 1e fa             endbr64 
    11d8:       55                      push   %rbp
    11d9:       48 89 e5                mov    %rsp,%rbp
    11dc:       c7 45 fc 01 00 00 00    movl   $0x1,-0x4(%rbp)
    11e3:       90                      nop
    11e4:       5d                      pop    %rbp
    11e5:       c3                      retq   
    11e6:       66 2e 0f 1f 84 00 00    nopw   %cs:0x0(%rax,%rax,1)
    11ed:       00 00 00 

可以看到,1184到119a处的代码有4个call指令,刚好对应我们的四次函数调用,如果再仔细一点就会发现call后面尖括号的名称恰好就存在于.text段中,并且是我们定义的三个函数Name Mangling之后的名称,而尖括号前的数字就是这几个函数在.text段里面的起始位置。这证明了函数其实是存在于.text段中的,并且经过了Name Mangling。

那么,这四个call指令长得几乎一模一样,静态成员函数和非静态成员函数的区别体现在哪里呢?

如果再仔细看就会发现,第一次和第四次调用其实并不仅仅是一个call指令,而是三个指令:

    117d:       48 8d 45 f0             lea    -0x10(%rbp),%rax
    1181:       48 89 c7                mov    %rax,%rdi
    1184:       e8 31 00 00 00          callq  11ba <_ZN1A4funcEv>

首先,这里是AT & T的汇编写法,而不是intel的,这个最开始也困惑了我好久(毕竟我本来汇编就不是很懂)。

第一行lea指令意思是把rbp寄存器表示的地址减去10这个数值赋值给rax寄存器。

第二行mov指令意思是把rax寄存器的值赋给rdi寄存器。

为什么是减去0x10呢?这是因为栈区一般是高地址向低地址生长的,减去0x10其实是指调用方栈指针(main函数的初始栈指针)减去0x10,也就是表示一个调用方(main函数)里定义的新变量,在这里其实就是指我们实例化出的ins这个变量,证据在1176行,movl $0x5,-0x10(%rbp)与代码中ins.a = 5对应,而根据c++对象模型,ins.ains起始地址相同。

为什么把rax寄存器的值赋给rdi寄存器呢?这是一个函数入栈操作,我们马上要进入下一层函数了,那么当然要把参数入栈,那么这里入栈的到底是什么已经很显然了,那就是this指针。

这也可以解释为什么我们对于const实例调用非const方法是编译报错而不是运行时报错——因为汇编上都只是传个地址(其实正经来讲应该从编译原理分析)。

最后,我们可以看出,静态成员函数和普通函数调用上除了Name Mangling毫无区别,这也印证了前面的结论。

总结

这个分析不算很难,但奇怪的是网上没有找到这种针对汇编的分析,让我对编译器角度的理解感觉没有底,我想,一个事情终究是了解全貌才能更肯定自信地说出自己的结论吧。