问题概述
C++中函数可以分为三种,静态成员函数 (static member function)、非静态成员函数 (non-static member function)和普通函数(本文非静态成员函数不考虑虚函数,因为其实现更为独特,容易使文章偏题),那么他们的本质区别到底在哪里?
我觉得这个问题可以从三个视角去思考:
- 开发者的视角
- 编译器的视角
- 可执行文件的视角
分析
开发者视角
对于开发者而言,这三者的区别往往是显而易见的:
- 普通函数没有“从属”的概念,或者说,它是直接属于某个namespace的。
- 非静态成员函数是和实例联系在一起的,我们需要一个实例才能调用非静态成员函数。
- 静态成员函数是和类绑定的,可以直接通过类名调用,但是静态成员函数内部不能调用非静态成员变量。
这里的概念都很浅显,不再赘述。
编译器视角
到了编译器的视角,就必须想明白一个问题,代码里调用一个函数的时候,编译器到底做了什么事情?
而要明白上面这个问题,就要明白这个问题:“函数”到底以何种形式存在于编译生成的可执行文件中?
在以前,我陷入过一个误区,认为函数主体存在于.text段中,而.data段中留存了所有函数的起始地址,然后在调用的时候就先在.data段中找到函数的地址,然后再进行调用。
这种理解在实际开发中一般无伤大雅,但这其实是一种想当然——把编译器想的太愚蠢了。对于人类而言,上面这种思路似乎是开发中很自然的一个举措,既然函数散落在.text段的各个地方,那么我在.data段中集中存放所有函数的地址信息,就可以很方便地管理。然而对于编译器而言,这种方式实际上是非常无理的,因为不仅没有简化编译器的工作,还由于两次寻址降低了实际代码执行的效率。
事实上,“函数”区分于“数据”的地方就在于,它直接体现在.text段中,而在数据区,你是难觅其踪迹的,当我们调用一个函数的时候,会直接执行一个CALL
的汇编指令,后面跟随的是硬编码进了可执行文件中的函数地址。
我们在代码中调用一个函数,编译器做的最重要的事情就两个:
- 确定函数地址(有的会在链接时重定位才确定)。
- 确定把哪些数据入栈。
回过头来看,编译器视角下,这三种函数有什么区别呢?
首先是成员函数和普通函数的区别,这里可能会比较意外:他们的主要区别只在于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的人耳熟能详,但是到底如何证明这一切呢?我在试图查找更深一层的验证的时候,发现这里鲜有人至。
可执行文件视角
承接上文,想要证明编译器做了这些事情,大抵只能考虑两个方向:
- 读编译器源码。
- 分析可执行文件。
读编译器的源码暂时有心无力,所以只好从可执行文件里管中窥豹了。
我们写出如下代码,命名为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.a
和ins
起始地址相同。
为什么把rax寄存器的值赋给rdi寄存器呢?这是一个函数入栈操作,我们马上要进入下一层函数了,那么当然要把参数入栈,那么这里入栈的到底是什么已经很显然了,那就是this指针。
这也可以解释为什么我们对于const实例调用非const方法是编译报错而不是运行时报错——因为汇编上都只是传个地址(其实正经来讲应该从编译原理分析)。
最后,我们可以看出,静态成员函数和普通函数调用上除了Name Mangling毫无区别,这也印证了前面的结论。
总结
这个分析不算很难,但奇怪的是网上没有找到这种针对汇编的分析,让我对编译器角度的理解感觉没有底,我想,一个事情终究是了解全貌才能更肯定自信地说出自己的结论吧。