找回密码
 立即注册→加入我们

QQ登录

只需一步,快速开始

搜索
热搜: 下载 VB C 实现 编写
查看: 6939|回复: 2

【C】认识GCC的链接时间优化

[复制链接]
发表于 2018-5-24 08:18:03 | 显示全部楼层 |阅读模式

欢迎访问技术宅的结界,请注册或者登录吧。

您需要 登录 才可以下载或查看,没有账号?立即注册→加入我们

×
gcc的链接时间优化(-flto -fuse-linker-plugin)是很强的优化(配合-O2或-O3),可以实现跨obj文件优化。也就是说,假设你的程序有foo.c和bar.c两个源码文件,你在foo.c里面写了一个简短函数,它可以被bar.c内联。

原理上gcc是在编译单个obj的时候,解析你的.c,记住你的逻辑,然后存为类似java、.net那样的字节码,并以“附加数据”的方式附加到obj(虽说是elf格式)中间文件里。在编译时间gcc只是读取你源码里面的逻辑部分,并没有生成实际的机器指令。这依然可以是一种节省编译时间的方案,因为解析源码需要先经过预处理,再解析各种名字长短不一的类型、变量或函数等。

在链接的时候,你要用gcc来链接,而不是ld。你同样需要把-O3 -flto -fuse-linker-plugin告诉给你的gcc,然后用-Xlinker去传递你的链接器参数,包括链接器脚本等。经过我的实际测试,我发现在开启了链接时间优化后,编译的速度大幅上升,而链接的速度并没有明显下降,并且优化的效果非常显著。我在使用gcc arm toolchain编译STM32程序的时候,它确实把STM32外设驱动提供的函数都内联了,并且删掉了多余的东西。

20180524080922.png

观察一个编译器优化效果的最好办法,就是直接看编译器输出的bin。我把链接后得到的elf丢进IDA以后,就可以直接看到它的逻辑了。那些标红的部分,是代码里面通过直接写绝对地址来对外设寄存器进行操作的部分。这里面包括了清空.bss段、从ROM复制.data段到RAM的过程(这部分是我写的),以及SystemInit();内部的所有过程(升频、把频率值写到一个全局变量里给你看、以及对各种外设的初始化等)(这个就是STM32外设驱动带的东西了)。

为什么要进行链接时间优化呢?简要来说是以下的原因:
  • 在编译时间,编译器只知道当前obj里面的函数、变量等,不知道别的obj的情况。这导致优化的效果受到的局限性较大,因为编译器并不知道别的obj提供的函数是个什么情况。你调用别的obj的过程就像调用API那样,不得不按照调用约定来严格调用,这是保证代码可以正确运行的必要过程。

    你写C的时候一定会把声明放到头文件,实现放到obj(.c)里面吧?对于编译器,万一你声明的这个函数所在的obj并不是用同一个编译器编译的呢?在这种情况下,调用约定统一了二进制接口,保证你可以把多个不同编译器生成的bin组装到一起(这就是所谓的“链接”了),来实现模块化开发。

    然而实际的情况就是,你的编译器就是GCC,你用不着混合多个编译器来开发,而且你拥有你的每个模块的源码。

    编译器无法做到跨文件的优化,对于全局变量、非static的空函数等(你也许觉得你肯定不会瞎写什么空函数),编译器并不能拿定主意决定要不要保留它,于是就都保留了。
    .
  • 你封装的模块,随着不断地升级、进化,有的函数被取缔,有的函数只做非常简单的赋值(比如初始化、各种属性set/get等,比如你的对象的构造、析构等。注意C语言也经常有这种情况)。为了兼容旧的调用者,那些被取缔的函数依然要保留,只不过它的实现部分啥都不做。

    然而如果每次调用者都call/bl你的空函数,导致你的bin里面有各种call->ret、bl->bx lr(调用后直接返回),这难道不是很蠢嘛?“你妈叫你回家吃饭!”->“你回来啦?我逗你的,你妈今晚在外面吃”

    以及之前说的简单赋值,call->mov->ret无非多了些mov而已,还不如直接内联呢。然而跨obj了,对于编译器而言,它并不知道别的obj里面这函数只是几个mov和一个ret,所以依然还是老老实实地排列参数,并进行一次正式的函数调用。生活中太细节的东西就用不着这么讲究仪式感了吧,更何况这并不是“生活”。
    .
  • 然后你意识到这太蠢了。怎么办呢?你要自己亲自把这些简短的函数写成“宏函数”,比如 #define RGB(r,g,b) ((((r) & 0xFF) << 16)|(((g) & 0xFF) << 8)|((b) & 0xFF)) 这种样子的。或者,用inlinestatic等,然后在头文件里定义这些函数的实现部分

    我确实这么做过,结果仔细一想,我为啥非要自己判断我的函数会被编译器编译成什么样子呢?这难道不是编译器的工作嘛?事实上,在C++,很多的类构造和析构写起来都很简短(“无非搞了个局部变量嘛”),然而真正展开后,它还是复杂得吓人的,并且这类函数会因为过长而内联失败。

    我还发现了一个严重的问题,那就是有的函数它最终被编译出来的指令还是非常长的,长到以至于不适合被内联。这还分两种情况,一种是我用#define实现了这个“函数”,另一种是我用inline、static等实现了这个函数。然而对于前者(#define的情况),它确实必定被“内联”(预处理器帮它强行内联),然而最终我得到的bin非常啰嗦、冗长。对于后者(inline或static的情况),如果一个函数太长,编译器“觉得”它不应该被内联的话,那我只要在obj里面调用了它,这个obj里面就会有这个函数的bin。那也就是说,我在多个obj里面调用这个被内联失败的函数的话,我就会得到多个这个函数的bin。这同样很蠢,你有几个obj你就有几个这种函数的副本,很多100 MB以上的exe我丢到IDA里面一看,确实就是这么一回事儿。
    .
  • 由于以上的特性,写Java、C#的人会得瑟,认为你很蠢。因为Java、C#生成的字节码可以在运行的时候进行JIT编译,这个过程就像一次全程序编译一样,可以让编译器进行全程序优化。

    而且你要是写C的功底不够强,被各种指针问题、内存问题困扰的话,对方就更得瑟了。怎能如此丢脸呢?
    .
  • 那假设我把所有的函数实现都写到头文件里,然后我只有main.c(或main.cpp),它包含了所有头文件、所有函数的声明和实现,是不是就没有这些担忧了呢?

    确实如此。

    然而如果你的工程够大,编译一次你大概得花很久,这不是一杯咖啡两杯咖啡的问题,而是一边写,一边调试、编译的情况,你的耐心会被很快消磨殆尽,并且被挫败感充斥。
    “兄弟你这里是不是忘了打一个分号啊?”
    “哦对啊。卧槽,白等了一上午了。”
    于是你默默地补了个分号,然后重新开始编译,并且在等了5小时后编译成功,并且它依然有各种各样的B
    U
    G。看着这些代码,你大概是相当痛苦的。毕竟每次编译都要等5小时……

    .
  • 所以,有了链接时间优化的话,你就可以在不牺牲编译速度的情况下,实现全程序优化了。

    完。


但你要注意,如果你要把你的现有工程改为使用链接时间优化的编译方式的话,你要把你的所有的写在头文件的inline宏函数,转移到一个obj里面,否则在链接时间,GCC会告诉你,你的各种obj文件都有这个函数,冲突了。不给你链接。
所以你要老老实实地把声明写到头文件,把实现写到obj(.c、.cpp等),然后祈祷链接时间优化带你超神吧。

顺带介绍一个。clang,llvm,是一款通过把C系源码编译为字节码,然后把字节码优化编译为bin,或者,直接JIT解释+编译运行字节码的平台以及工具集。据说很棒,值得一试。

参考资料:
编写快得像宏一样的内联函数
htt
ps
://gcc.gnu.org/
on
linedocs/gcc/Inline.h
tm
l

GCC优化选项
ht
tps://gcc.gnu.org/
on
linedocs/gcc/Op
timi
ze-Options.html

注意看-flto的部分。

本帖被以下淘专辑推荐:

回复

使用道具 举报

发表于 2018-5-24 21:14:12 | 显示全部楼层
对于bin来说空函数确实不是啥好东西 但是对于人来说空函数有很多妙用
回复 赞! 靠!

使用道具 举报

发表于 2020-8-9 12:17:36 | 显示全部楼层
这个帖子写的太牛逼了
回复 赞! 靠!

使用道具 举报

本版积分规则

QQ|Archiver|小黑屋|技术宅的结界 ( 滇ICP备16008837号 )|网站地图

GMT+8, 2024-11-25 03:03 , Processed in 0.037731 second(s), 30 queries , Gzip On.

Powered by Discuz! X3.5

© 2001-2024 Discuz! Team.

快速回复 返回顶部 返回列表