【GCC编译优化系列】从KEIL转战GCC,一个C库函数让你的bin文件增大好十几KB!

网友投稿 4044 2022-05-29

1 写在前面

KEIL 这个玩意,相信大家都很熟悉,我想很多人上手开发嵌入式、单片机也是采用的这款入门级IDE。回想起我当初刚学习51单片机的时候,也是使用 KEIL-C51 编译环境来点灯的。后面工作了,开始接触嵌入式Linux方面的开发,慢慢地使用 KEIL 的机会就越来越少了。

受多年来在Linux环境下开发的重度影响,我现在基本的操作方式都是 Linux 系统下通过samba把代码共享出来,在Windows下通过samba获取共享,然后在Windows下编辑代码,随后在Linux下编译。这样的好处是,你可以随心所欲地选用你擅长的编辑工具,而不受限于任何一个IDE;同时Linux下对编译构建的控制、代码的查找、各种酷炫命令行的酸爽,真的只有谁用谁知道。

当然,不可否认,KEIL 还是有它强大的到底,毕竟基于ARM的开发,很多还是要依赖于 KEIL-MDK 的。KEIL-MDK-for-ARM 在处理ARM平台的编译,还是有它的独到之处,对比其他编译器,无论是代码尺寸还是汇编指令效率,都有不错的优势。正如网友所说:“毕竟是官网推荐的收费编译器,能不优秀吗?”

不过,这次我要带来的一个问题就是,代码从 KEIL 编译环境迁移到 GCC 编译环境后,生成的固件代码居然大了 20KB !详细内容,请看下文分解,通过本文你将可以了解到以下内容:

KEIL编译环境切换到GCC编译环境的一般思路和方法。

如何分析GCC编译器生成的各种文件?

bin文件生成的拆解

常见的编译选项和链接选项

2 问题描述

问题是这样的,最近我们在使用一款ARM芯片在做开发,它的内核架构是 ARM Cortex-M0,原厂先是提供了 KEIL 编译环境的基础例程,由于这方面的例程比较成熟,我们很快就在上面完成了应用部分的开发,调试和测试都没什么大问题。

后来由于各种商务原因的考虑,我们决定转战到GCC编译环境,这就需要把原本KEIL上面构建的代码全部迁移到GCC编译环境。

经过一番操作,总算是使用GCC把代码编译跑起来了,但是问题来了,在GCC编译的固件bin文件,居然比KEIL编译环境下生成的固件bin文件大了将近 20KB,如下图所示:

从代码量来,即便GCC版本与KEIL版本有些代码做了调整,核心业务逻辑代码基本没动呀,怎么会大这么多?要知道,我们这款芯片留给应用部分的Flash容量上限也就 50KB,另外在预留了 50KB 做OTA下载缓存,现在单应用部分就 60KB+ 了,这个肯定是不能接受的。

无奈只能硬着头皮去找根源,为何代码差异不大的情况下,编译出来的固件bin文件大小差异这么大 ?

3 场景复现

为了能够准确还原项目的真实场景,我分了下面两个小节来介绍。

3.1 项目迁移

由于我们早前的项目都在KEIL下构建的,团队内部在GCC方面有些积累,但都是在其他项目上积累,而对已有的KEIL的工程,并没有现成的脚本来实现 一键迁移,所以只能手把手做项目的迁移。

这里面遇到一个最头疼的问题,就是KEIL的工程文件管理,相信大家肯定也吐槽过它的槽点。这个就是 “KEIL里面的文件管理是自定义目录的,而这个目录并不对应真实的文件系统目录”。这样设计的好处是,开发人员可以在KEIL里面自定对不同文件进行分类管理,做分层设计之类的,然后把对应的文件添加到指定的类别里面。而这样设计的最大弊端就是,如果你不熟悉整个工程,你单从KEIL的文件管理那里,很难一下子就找到你想要的文件,同时,即便你在文件系统里面新建或删除了一个文件,在KEIL里面并不会自动帮你处理,你都需要重新去添加、删除。这个真的是反人类设计,每用一次,我吐槽一次!

只好借助于KEIL的工程文件了,后的后缀名是 .uvproj,这个文件是一个 XML 格式的文件,它基本就描述了整个KEIL工程配置,包括你需要的那些迁移信息,基本都可以从中找到。

这货大概长这样(因文件篇幅原因,我把Group部分折叠了):

项目迁移的过程,我们主要关注里面的四大部分:

头文件检索路径

这部分信息,可以搜索在KEIL的工程文件中搜索 IncludePath,那么就可以看到KEIL工程里配置的头文件检索路径有哪些。如下所示:

所有源码文件列表

要找源文件,这里所有显示看KEIL工程文件中的 Group,它对应的就是 KEIL 工程里面展示的一个个文件组,把它展开就可以看到对应组下面的文件列表,字段是 FileName 和 FilePath 。这两者的区别就是,一个是没有路径名,只有文件名,而另一个是带路径和文件名的。

有一个比较高效的方法,就是整个文件通篇搜索 FilePath> ,这样就找到了所有的文件,包括C文件、汇编文件、TXT文件等等。

不过这里需要注意的是,在KEIL工程中,有一些文件是被 排除 的,在上面检索出来的文件列表中,也需要将这部分文件给排除掉。

编译选项列表

关于编译选项这块,KEIL工程文件中的 Cads 和 Aads 就有说明,不过这里都是一个开关标志,可读性比较差,建议还是配合着KEIL IDE target配置界面来看。

链接选项列表

关于编译选项这块,KEIL工程文件中的 LDads 就有说明,与 Cads 和 Aads 类似,可读性并不是很强。

注:其实当时找编译选项和链接选项的时候,我想过能不能找到像 Makefile 那样,加个 V=1 就把所有编译参数、链接参数输出来;但我没找到KEIL有类似的操作,有知道该方法的,麻烦告知下。

3.2 编译复现

我们大家的GCC编译环境是在Linux下构建的,从上一节中取得的各项内容,整合到Linux环境中的Makefile中。

熟悉GCC编译和Makefile的都应该清楚,上面的各项内容需要这样整合:

头文件检索路径:这部分内容添加到 CFLAGS 中,使用 -Ixxx 把头文件路径加进去。

所有源码文件列表: 这部分内容添加到 SRC-y 中,使用(与Makefile文件的)相对路径添加进去。

编译选项列表: 这部分内容添加到 CFLAGS 中,这里主要包括两个方面,一个是传递GCC编译器的编译选项,比如 优化等级参数、编译特性参数、警告参数 等等;另一个是传递给源码的宏定义,这里需要对宏定义加字母D,比如 -Dxxx 或 -Dxxx=yyy 。

链接选项列表:这部分内容添加到 LDFLAGS 中,这里主要是指明链接器如何生成最终的可执行文件,常见的内容包括:链接脚本文件、生成MAP文件列表、是否启用段回收优化、是否使用标准库等等。

除了上面的部分,还有两个使用GCC编译比较关键的东西是:启动脚本 和 链接脚本,幸运的是这一块原厂提供了些支持,我们很快就搭起来了。

在Makefile中完成以上内容添加后,再加上一段使用objcopy生成 bin 文件的流程控制,就可以顺利执行make拿到基本的应用bin文件。这里需要注意的是,生成的bin文件不见得立马就可以拿来烧录,往往还需要结合原厂提供的打包工具,把应用bin文件打包或重组,生成可以被烧录工具成功烧录的固件bin文件,不过后续的流程并不在本文的讨论范围。

在上面迁移编译过程中,肯定多多少少会遇到各式各样的问题,一般来说,参考我之前的 解决编译问题的一般思路,基本都可以解决。

当我们顺利编译得到固件bin文件的时候,第一时间也是惊呆了,正如上一节描述的那样,居然整整比KEIL环境编译出来的大了 18KB,这可完全交不了差啊!

4 深入分析

既然问题出现了,那么就深入分析下到底是为何固件bin文件大了多?究竟是代码问题还是GCC编译器的锅?

4.1 分析工程代码

为了大家更好理解我下面的分析过程,我再次捋一下我们的工程情况。

KEIL版本工程,添加了我们的应用部门代码,编译出来大概 46KB;

GCC版本工程,原厂提供的基础demo工程,不含我们的应用部门代码,编译出来大概 19KB;

GCC版本工程,从KEIL版本工程移植过来,保留应用代码,编译出来大概 64KB;

GCC版本工程,从KEIL版本工程移植过来,删除应用部分代码,与原厂的基础demo功能是对标的,编译出来大概 37KB。

OK,从上面几个版本中,我们可以初步排除应用部分代码引入的bin文件增大,所以落脚点应重点放在工程2和工程4的对比上面。

这时候,BC要发挥作用了,简要拉出来对比下:

不比不知道,一比吓一跳!

这不一样的地方可太多了呀,哪个才是真正的差异啊?

原来,这个项目KEIL版本的工程原本由另一个团队基于原厂的demo调试过,修复了很多原厂的坑,但你不能说那些标红的地方都是,只能说都有可能,而且你也不能去找之前的团队说,为何会这样,毕竟人家在KEIL下跑得好好的。

虽说有改动,但整体还是延续了原厂demo的实现,只是部分代码上做了调整和优化,并没有大改动。

不过,要想从这些差异中一个个对比分析出来,难度可不小,只能再接下往下,换个思路分析看看了。

4.2 分析编译选项

熟悉GCC的朋友都知道,GCC有好几个优化级别,不同的优化级别对生成的bin文件大小会不一样,而在嵌入式工程代码中,大家用得最多的,我想应该是 Os 优化级别,这个优化级别和-O3有异曲同工之妙,当然两者的目标不一样,-O3的目标是宁愿增加目标代码的大小,也要拼命的提高运行速度,但是这个选项是在-O2的基础之上,尽量的降低目标代码的大小,这对于存储容量很小的设备来说非常重要。

基于这一点认知,我认为有必要检查对比下两个工程的编译选项,结合分析的结论下,两边用的都是 Os 优化级别。

未果,继续分析。

我之前写过一篇文章:【gcc编译优化系列】如何(不)回收未发生调用的函数,里面提到了要想缩小最终的固件bin文件,可以启用GCC的 –gc-sections 选项,即 段回收 机制。

这个选项可以把工程代码中没有被调用的函数或全局变量,在链接的时候去除掉,达到优化固件bin大小的目的。这个选项在实际使用过程中,需要与编译选项 -ffunction-sections 和 -fdata-sections 配合使用,感兴趣的可以从文章链接中找找答案。

但是,这个怀疑,在我仔细对比两个工程的编译选项之后,发现其实都启用了 -ffunction-sections 和 -fdata-sections,同时我还发现了一个我不太认识的编译选项 -flto。

这个 -flto 简单查了一下资料说是:在链接时优化,可以更大程度地发挥优化效果。具体其他的,没有调研没有发言权,况且现在两个工程都开了这个选项,显然很大可能不是它引入的,所以暂且跳过。

4.3 分析链接选项

到了分析链接选项这一步,除了上一小节提及的 –gc-sections 链接选项外,还需要关注一个比较常见的链接选项 –specs=xxx.specs。

关于这个选项,之前我写过一篇文章,简单研究过这个参数,感兴趣的可以读一读:GCC编译链接时的–specs=kernel.specs链接属性。

简单来说,这个文件就指明了在链接阶段,链接器按照那个约定的specs文件(规范、模板文件)去执行链接;对应的,一般有 kernel.spcs、nosys.specs等等,这些spec文件可以在交叉编译工具链目录可以找到。

很遗憾,在分析链接选项的时候,这个spec文件,两边都使用的 nosys.spec,并无差异,所以它也不是问题的关键。

补充一句:–specs=nosys.specs 表示使用静态库 libnosys.a 。

4.4 分析elf文件

到了分析elf文件这一步,我们可以用以下几个Linux命令做分析:

size 命令

size 用于查看目标文件、库或可执行文件中各段及其总和的大小,是 GNU 二进制工具集 GNU Binutils 的一员。

我们来执行一下 size 命令,看下对应的输出:

recan@ubuntu:~$ size test_app_*.elf text data bss dec hex filename 19174 2 5572 24748 60ac test_app_19KB.elf 35225 2544 5708 43477 a9d5 test_app_37KB.elf

我们可以看到 37KB的这个elf文件在 text 代码段中,明显比 19KB 这个elf文件大了很多。由于我们的bin文件就是由elf文件转换来的,所以对elf文件 求size,在一定程度上就反应了bin文件的大小。

这里再简单补充一个小知识:bin文件的大小约等于 TEXT + DATA,注意这里是不需要加上 BSS 的, 原因是BSS段在初始化的时候一般都被手动清零了,不需要体现在bin里面。

readelf 命令

readelf 用来显示一个或者多个 elf 格式的目标文件的信息,可以通过它的选项来控制显示哪些信息。这里的 elf-file(s) 就表示那些被检查的文件。可以支持32位,64位的 elf 格式文件,也支持包含 elf 文件的文档(这里一般指的是使用 ar 命令将一些 elf 文件打包之后生成的例如 lib*.a 之类的“静态库”文件)。

简单来说,它就是用于分析elf的组成的,它有几个选项比较常用:比如 -h 只查看elf头部的信息,-a 则查看所有elf文件内容。

为了更好地看到全貌,我采用了 -a 选项,并把结果分别导出到2个文本文件中:

recan@ubuntu:~$ readelf -a test_app_19KB.elf > test_app_19KB.elf.log recan@ubuntu:~$ readelf -a test_app_37KB.elf > test_app_37KB.elf.log

再次上BC,比较一下看看。

看着好像文件不是很少,但打开发现也有个千把行,但这对比看代码还是简略了很多。

这里需要对elf文件有一定的了解,内容那么多,我们需要抓重点,重点关注下 FUNC 字样的函数。

逐步往下翻,我看到了这一段对比,似乎有了一点点思路;

我们是不是可以合理怀疑下 C库 ?

当然,这里还给不了答案,我们接着往下看。

4.5 分析map文件

关于如何生成map文件,可以参考下 这里。

map文件就是通过编译器编译之后,生成的程序、数据及IO空间信息的一种映射文件,里面包含函数大小,入口地址等一些重要信息。从map文件我们可以了解到:

【GCC编译优化系列】从KEIL转战GCC,一个C库函数让你的bin文件增大好十几KB!

程序各区段的寻址是否正确;

程序各区段的size,即目前存储器的使用量;

程序中各个symbol的地址;

各个symbol在存储器中的顺序关系(这在调试时很有用);

各个程序文件的存储用量。

所以,elf的镜像分布,我们在map文件中是可以看出来的,在一定程度,为何bin文件大了那么多,多少从map文件是可以发现的。

使用BC一对比map文件,一打开的时候,就发现不对劲了:

这样真的很难不去怀疑C库了?

4.6 寻找突破口

接下来,开始对常见的C库函数进行排查,排查的方法是这样的:

先检查对应的C库函数是否在两个工程中都有使用,如果是,直接跳过;如果某个C库只在37KB的工程中出现,那么这个函数则需要重点关注。

为了,有效地准确检索函数 关键字,这里我强烈建议大家使用Linux下的命令行 grep 。因为它不仅可以精准地检索C文件、头文件、map文件,还可以检索到静态库.a文件、动态库.so文件、elf文件等等非文本文件。

由于一般我们的嵌入式工程都是启用高编译优化级别的,所以不能简单地搜索代码是否调用,更为准确的,我认为是检索elf文件,因为在高优化级别下有些看似调用了的函数在生成elf的时候却被优化移除了。

下面对常见的C库进行分类,整体上从用途上分,有以下一些:

内存管理类

这里包括 malloc、free、calloc、realloc 之类的;这几个函数常见于内存稍微富余的嵌入式工程中,比如那些可以抛 RTOS 的平台,这些函数需要重点看看。

字符串操作类

这里比较典型的函数是: strlen、strcpy、strcmp、strchr、strstr、memset、memcpy、memcmp、memmove 等等,这些函数太常用了,常用到我基本认为不太可能会问题出在这。

文件操作类

这里就包括关于文件的几个操作函数:open、lseek、read、write、close 等等;假如你的工程有用到文件系统或者定义了类似Linux的VFS中间层,那么这些函数你需要重点关注。

printf操作类

这类函数也非常常见,主要包括:printf、fprintf、sprintf、dprintf、vprintf、vsprintf、vfprintf、cdprintf 等等。其实大家可以 man 3 printf 查看到更多关于这些函数的信息。

man 3 printf PRINTF(3) Linux Programmer's Manual PRINTF(3) NAME printf, fprintf, dprintf, sprintf, snprintf, vprintf, vfprintf, vdprintf, vsprintf, vsnprintf - formatted output conver‐ sion SYNOPSIS #include int printf(const char *format, ...); int fprintf(FILE *stream, const char *format, ...); int dprintf(int fd, const char *format, ...); int sprintf(char *str, const char *format, ...); int snprintf(char *str, size_t size, const char *format, ...); #include int vprintf(const char *format, va_list ap); int vfprintf(FILE *stream, const char *format, va_list ap); int vdprintf(int fd, const char *format, va_list ap); int vsprintf(char *str, const char *format, va_list ap); int vsnprintf(char *str, size_t size, const char *format, va_list ap);

这一类函数,往往在嵌入式里面非常容易出问题,比如最常见的printf函数我们需要重定向到串口输出,那底层C库的实现肯定不知道你要从哪个串口输出,以及怎么输出,所以这个时候需要上层做些适配。

正是由于这样的原因,我们排查的过程中,应对这类函数多留一个心眼。

其他类别

这里还包括更多,我就不一一列举,具体可以参考下面这个头文件说明列表:

标准c库函数头文件列表 诊断 字符检测 错误检测 系统定义的浮点型界限 系统定义的整数界限 区域定义 数学 非局部的函数调用 异常处理和终端信号 可变长度参数处理 系统常量 输入输出 多种公用 字符串处理 时间与日期

5 修复验证

5.1 问题修复

基于上面的线索分析,基本排查的方向就比较清晰了。因为这个问题需要不断地试探和验证,所以我采用的是每找到一个存在可能性的C库函数,就在源码工程里面(含编译输出的各种文件)grep 一下,找到了位置,再回头来看看源码,同时配合这map文件来对比分析。

这样一套思路操作下来,问题点逐渐暴露了。我发现了这么一个函数:vsprintf !

elf文件和map文件都符合这个特性:37KB工程中有,但19KB工程中没有 !

立马靠拢定位对应代码,看到代码我开始恍然大悟:

int uart_printf(const char *fmt,...) { int n; va_list ap; va_start(ap, fmt); n = vsprintf(uart_buff, fmt, ap); va_end(ap); uart_putchar(uart_buff); if(n > sizeof(uart_buff)) { uart_putchar("buff full \r\n"); } return n; }

原来这个工程使用 uart_printf 做printf的重定向输出。

这时候立马跳出一个问题,为何19KB的工程没有啊?它没有重定向输出,显然不是!

看了下代码,原来它自个整了一个 vsprintf :

这里再捋一捋这段代码的历史,早期另一个团队用这份带vsprintf的工程仅在KEIL环境是编译,而原厂整的这个带 local_vsprintf 的工程仅在Linux GCC环境下编译。

我猜想可能原厂也遇到类似的bin大小的问题,所以在相关代码附近,依然可以看到 local_vsprintf 的源码实现。

一切都向着美好的方向进行着,下面开始重点验证。

5.2 问题验证

了解了缘由后,立马调整相关代码,调整的方式也很简单,就是把 vsprintf 重新改成 local_svprintf,即可。

之后,我需要重新清理工程,再次编译生成固件bin文件。还是以基础demo工程为例,我们的目标是生成与原厂工程生成大小类似的 19KB。

结果一试,果然,生成的大小就是 19KB。

长舒一口气,看到交差的希望了。

随后,把之前屏蔽的应用部分代码打开,一编译,结果让我有些骄傲,居然比KEIL的数据还好一些,固件bin大小是 42KB,比KEIL工程的是 46KB 还有小一些。

这个时候我想起了那个 -flto 参数,发现还是有点东西,回头有空再研究研究。

当然,固件bin大小小了是好事,但功能不能跑偏了呀,于是下载到板子一跑,基本功能都通过了。

完美,收工!

6 经验总结

KEIL有KEIL的优势,GCC有GCC的优势,两者有时候不可兼得;

KEIL(ARMCC)编译对ARM芯片有天然的优势,无论从代码性能和代码尺寸都有更佳的表现,毕竟是同一个爹妈生的;

GCC的优势在于开源,利于折腾;方便你做各种一键式(脚本)集成;

标准C库的函数多种多样,适当分类后,更容易区分掌握;

在嵌入式领域,谨慎使用诸如printf、vsprintf等原生的标准C库;

结论往往在不断实践、不断推导的过程中,变得越来越清晰;

GCC的编译选项,还有知识盲区,值得再深入研究研究,比如-flto;

了解历史代码的演变过程,可能会有助于你解决一些看似乱七八糟的问题。

7 参考链接

本次复盘分析中,引用了下列相关文档,若干知识点可以在下面文章中找到答案,感兴趣的可以一读。

【对比参考】KEIL与GCC的编译环境对比分析

【GCC编译优化系列】一文带你了解C代码到底是如何被编译的

【经验科普】实战分析C工程代码可能遇到的编译问题及其解决思路

【Linux编程】如何使用GCC编译源代码时输出map文件?

【GCC编译优化系列】如何(不)回收未发生调用的函数

【GCC编译优化系列】GCC编译链接时候–specs=kernel.specs链接属性究竟是个啥?

8 更多分享

架构师李肯

欢迎关注我的github仓库01workstation ,日常分享一些开发笔记和项目实战,欢迎指正问题。

同时也非常欢迎关注我的CSDN主页和专栏:

【CSDN主页-架构师李肯】

【RT-Thread主页-架构师李肯】

【C/C++语言编程专栏】

【GCC专栏】

【信息安全专栏】

【RT-Thread开发笔记】

【freeRTOS开发笔记】

有问题的话,可以跟我讨论,知无不答,谢谢大家。

ARM gcc Linux

版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:我去,码云Gitee原来是这样使用的
下一篇:【云驻共创】三本书帮你看淡元宇宙
相关文章