被知乎大佬嘲讽后的一个月,我重新研究了一下内联函数(上)

逃离我推掉我的手 2023-05-28 09:51 147阅读 0赞

前言

这绝不仅仅是一篇讲内联意义的文章

参考我的学习过程,可能对你的知识整合有很大帮助

之前写了一篇总结c++面试的文章,被大佬纠出来很多关于内联的问题与错误。抱着不误导别人的态度(也因为上篇文章承诺要给大家深入分析一下内联函数),我在最近的一个月里抽了很多时间去重新研究inline,确实学到了很多以前不了解的知识。学习么~就是一个不断打破之前认知并重构知识的过程,每个人都是从一个什么都不懂的菜鸟逐渐成长为一个大牛的。

在这篇文章里,我会由浅入深的分析不同阶段的我对内联函数的认识,重构我的知识体系。即使你之前对inline不了解,也可以看得懂这篇文章。

由于篇幅比较长,会分成上下两个部分。另外,文中会有很多引用的参考链接,我会统一放到文末的位置。这次我也重新的对文章做了排版,方便大家阅读。(不过由于我公众号开的较晚,开启评论功能是遥遥无期了)

01:菜鸟阶段

上大学第一次接触C++,然后了解到了内联函数。啥是内联函数?简单理解就是编译时把函数的定义替换到调用的位置。

inline int Add(int a, int b)
{
return a + b;
}
int main()
{
int num1 = 1;
int num2 = 2;
int myNum = Add(num1, num2);
}
//这样的代码内联之后大概就是
int main()
{
int num1 = 1;
int num2 = 2;
int myNum = num1 + num2;
}

好的,感觉好像还挺简单的。啥?你问我啥是编译?嗯。。编译就是把你的代码通过编译器分析一下然后转换成计算机能直接读懂的语言(汇编),最后生成一个可执行的程序(或可被调用的库)。

当然,我这么解释有点不太权威,咱们再看看维基百科关于内联函数的定义:

在计算机科学中,内联函数(有时称作在线函数或编译时期展开函数)是一种编程语言结构,用来建议编译器对一些特殊函数进行内联扩展(有时称作在线扩展);也就是说建议编译器将指定的函数体插入并取代每一处调用该函数的地方(上下文),从而节省了每次调用函数带来的额外时间开支。但在选择使用内联函数时,必须在程序占用空间和程序执行效率之间进行权衡,因为过多的比较复杂的函数进行内联扩展将带来很大的存储资源开支。【参考1:内联函数维基百科】

那么内联函数有什么有点呢?当然是减少函数调用带来的开销了,几乎每本C++入门书籍、百科以及博客都是这么说的。不过,什么是函数调用开销?额,反正调用函数肯定要消耗CPU运算吧,肯定也有内存参与,肯定有开销,嗯。

另外,我还从书上了解一些相关的知识,如直接在类的头文件里面定义的函数都是自动内联的(并不对),内联相比宏定义有类型检查、可支持类的访问控制等优点。

这时候的我知道的专业名词有:汇编、编译、内联、CPU、函数调用、内存地址,但是他们之间的关系几乎是一头雾水了。就如下图一样,

format_png

02:初识阶段

之前总是说减少函数调用开销,那么这个调用开销到底是指什么?这时候的我发现有一些面试里面会问到这个问题,所以还真有必要理解一下了。

我们常说,C语言程序内存分为常量区、代码区、静态全局区、栈区、堆区。当我们的程序运行时,我们的编译后的二进制程序(这个二进制程序的分布格式差不多就是前面说的那几个区,里面会有各种汇编命令)就会被放到操作系统的内存里面,函数代码段被放在所谓的代码区,局部变量与函数参数被放在栈区。函数调用就发生在栈区里面,每次调用的时候会把当前函数的相关内容压入到栈里面处理寄存器相关的数据信息(所谓没有地址的右值一般就是通过寄存器存储的数据);然后,调用地址指向我们要执行的函数位置,开始处理函数内部的指令进行计算,当函数执行结束后,要弹出相关数据,处理栈内数据以及寄存器数据。【参考2:浅谈C/C++堆栈指引——C/C++堆栈很强大】

这个过程也就是所谓的“函数调用开销”。

format_png 1

到现在为止,我们不妨先总结一下消除函数调用的直接好处【参考3:Inline expansion 】:

1.它消除了函数调用过程中所需的各种指令:包括在堆栈或寄存器中放置参数,调用函数指令,返回函数过程,获取返回值,从堆栈中删除参数并恢复寄存器等。

2.由于不需要寄存器来传递参数,因此减少了寄存器溢出的概率。当使用引用调用(或通过地址调用或通过共享调用)时,它消除了必须传递引用然后取消引用它们。

当然缺点我们也应该了解,使用不当的话就会造成代码膨胀(也就是生成的可执行程序会变大);影响cache对数据的命中;如果你设计了一个函数库,调用你的内联函数还会造成客户代码的重新编译。

一般一级高速缓存里面会分为指令缓存(instruction cache)以及数据缓存(data cache),inline的使用不当对二者都可能造成影响。首先,过多的内联代码会使原来本可以存储到ICache的指令分散,导致指令缓存的命中降低,从内存取数据会严重影响效率。其次,inline会导致代码膨胀,增加可执行程序(动态库、静态库)体积,造成额外的换页行为,进而可能会导致数据缓存的命中率降低。

上面说的缺点还比较抽象,很多情况好像都可以接受。而还有一些特定情况,内联将会造成很严重的后果,如递归函数的内联可能造成代码的无限inline循环。所以编译器在这些特殊情况下会拒绝内联,常见的包括虚调用,函数体积过大,有递归,可变数目参数,通过函数指针调用,调用者异常类型不同,declspec宏、使用alloca、使用setjump等。不过这些情况编译器也并不是一定会拒绝,虚调用在某些情况下就可以被内联,会在第三部分细说。

这时候,我认识到,其实内联inline只是建议性的关键字,编译器并不一定会听你的,毕竟他比你更了解你的代码编译后是什么样子的,而所谓的内联也不单单是指inline这个关键字了,他本质上是一种编译器的优化方式。另外,在windows上平台我还经常能看到forceinline【参考4:MS Doc】(GCC上的【always_inline】)这样的关键字,字面意思是强制内联。不过经过查阅,发现一般只是对代码体积不做限制了,或者说在Debug模式(不不开启优化的情况)下也会尽量按照开发者的意愿去内联。无论如何,最终的决定权还是交给编译器去处理。

在这个阶段的学习过程中,我发现想理解程序的编译与运行,还不得不去看看程序的反汇编代码,看看编译器编译后的代码是什么样子的。毕竟很多时候,我们需要亲自手动操作才能真正的理解其中的原理。

虽然我上学时很讨厌这门课,但是我发现想大概看懂反汇编代码,并不需要非常完善的汇编知识,只要把常见的一些命令记住并理解就行了。【参考5:手把手教你栈溢出从入门到放弃 】

format_png 2

还是前面那段代码,测试在VS2017下的汇编代码(方法参考上图,代码主要看红色部分)

inline int Add(int a, int b)
{
return a + b;
}

int main()
{
int num1 = 1;
int num2 = 2;
int myNum = Add(num1, num2);
}

//Debug模式下无内联优化的汇编代码,需要跳到Add函数的地址去执行计算
int main()
{
//前面汇编代码省略

01232547 mov eax,0CCCCCCCCh
0123254C rep stos dword ptr es:[edi]
0123254E mov ecx,offset _5BD3FBCE_consoleapplication2.cpp (01247008h)
01232553 call @__CheckForDebuggerJustMyCode@4 (0123142Eh)

int num1 = 1;

01232558 mov dword ptr [num1],1

int num2 = 2;

0123255F mov dword ptr [num2],2

int myNum = Add(num1, num2);

01232566 mov eax,dword ptr [num2]
01232569 push eax
0123256A mov ecx,dword ptr [num1]
0123256D push ecx
0123256E call Add (01231726h)
01232573 add esp,8
01232576 mov dword ptr [myNum],eax
}

int Add(int a, int b)
{
//前面汇编代码省略

00891E67 mov eax,0CCCCCCCCh
00891E6C rep stos dword ptr es:[edi]
00891E6E mov ecx,offset _5BD3FBCE_consoleapplication2.cpp (08A7008h)
00891E73 call @__CheckForDebuggerJustMyCode@4 (089142Eh)

return a + b;
00891E78 mov eax,dword ptr [a]
00891E7B add eax,dword ptr [b]
}

format_png 3

//Debug模式下开启内联(/Ob2,参考上图)后的汇编代码,无需跳转到Add函数的位置,直接优化计算

int main()
{
//前面汇编代码省略

00F41F67 mov eax,0CCCCCCCCh
00F41F6C rep stos dword ptr es:[edi]
00F41F6E mov ecx,offset _5BD3FBCE_consoleapplication2.cpp (0F57008h)
00F41F73 call @__CheckForDebuggerJustMyCode@4 (0F4142Eh)

int num1 = 1;

00F41F78 mov dword ptr [num1],1

int num2 = 2;

00F41F7F mov dword ptr [num2],2

int myNum = Add(num1, num2);

00F41F86 mov eax,dword ptr [num1]
00F41F89 add eax,dword ptr [num2]
00F41F8C mov dword ptr [myNum],eax
}

通过观察汇编代码,我发现经过内联处理后的汇编代码可以直接进行两个参数的累加而不需要去调用Add函数。

当然你也可以在这里【参考6:Compiler Explorer】试试其他的编译器,如GCC、ICC、Clang。关于VS控制内联的参数,可以看这里【参考7: Microsoft Doc Inline Option】。

后来,我又看了《深入探索C++对象模型》这本书,印象很深的就是我们以为的代码在编译器处理后并不是我们以为的那样,里面有各种mangling【参考8:name mangling】,添加各种附加代码,那些看起来空空如也的的构造函数(析构函数同理)里面也可能有着几十行或者上百行的复杂代码。想象一下,你把这些构造代码内联的到处都是,你确定你的程序能得到优化么?

到这个阶段,我发现我能稍微的理解高级语言与汇编语言之间的关系,函数调用的基本原理,程序与内存之间的关系等,现在知识图谱大概变成这样了:

format_png 4

在下个阶段,我开始了解到一些编译器相关的内容,对内联的认识也进一步提升。

未完待续

format_png 5

format_png 6

游戏开发那些事

长安图片识别二维码关注获取更多学习资料

参考链接:

【1】.https://zh.wikipedia.org/wiki/%E5%86%85%E8%81%94%E5%87%BD%E6%95%B0 内联函数维基百科

【2】.https://blog.csdn.net/mynote/article/details/5835615 浅谈C/C++堆栈指引——C/C++堆栈很强大

【3】.https://en.wikipedia.org/wiki/Inline\_expansion 内联扩展Wiki

【4】.Microsoft内联函数

https://docs.microsoft.com/zh-cn/cpp/cpp/inline-functions-cpp

【5】.https://zhuanlan.zhihu.com/p/25892385 手把手教你栈溢出从入门到放弃

【6】.https://godbolt.org Compiler Explorer

【7】. Microsoft Doc Inline Option

https://docs.microsoft.com/zh-cn/cpp/build/reference/ob-inline-function-expansion

【8】.https://zh.wikipedia.org/wiki/%E5%90%8D%E5%AD%97%E4%BF%AE%E9%A5%B0 Name mangling

发表评论

表情:
评论列表 (有 0 条评论,147人围观)

还没有评论,来说两句吧...

相关阅读