代码体积优化
一直以来,嵌入式应用程序的代码体积一直是被关注的问题。如今,编译器在优化应用程序的代码体积方面已经取得了不小的进展。虽然,大多数编译器优化都主要集中在应用程序性能上,但近年来也有不少针对代码体积的优化。本文会从以下几个方面简单介绍一下减少应用程序代码体积的一些常用技术:
- 测量方法:测量二进制大小的工具
- 编译器优化:帮助减少应用程序二进制大小的编译选项
- 源代码优化:减少应用程序二进制大小的编程技巧
测量方法
简单介绍一下3种常用的测量二进制文件大小的工具:
- size:可以显示二进制文件每个部分的大小。
$ size gcc/11/libstdc++.dylib
__TEXT __DATA __OBJC others dec hex
1703936 65536 0 1851392 3620864 374000
- strings:可以显示二进制中的所有字符串。
$ strings gcc/11/libstdc++.dylib | wc -l
2180
- bloaty:可以用于对不同平台的二进制文件进行更深入的分析。甚至将代码体积标注到源文件中,从而帮助更好地发现缩减代码体积的机会。
$ bloaty gcc/11/libstdc++.dylib
FILE SIZE VM SIZE
-------------- --------------
29.1% 1.00Mi 29.0%. 1.00Mi __TEXT,__text
25.0% 882Ki 25.0% 882Ki String Table
16.6% 583Ki 16.5% 583Ki Symbol Table
12.3% 433Ki 12.2% 433Ki __TEXT,__eh_frame
5.0% 176Ki 5.0% 176Ki Export Info
4.1% 146Ki 4.1% 146Ki __TEXT,__const
2.5% 87.8Ki 2.5% 87.8Ki Weak Binding Info
1.2% 41.6Ki 1.2% 41.6Ki __DATA,__gcc_except_tab
1.0% 36.9Ki 1.0% 36.9Ki __DATA_CONST,__const
0.9% 33.3Ki 0.9% 33.3Ki __TEXT,__text_cold
0.5% 16.1Ki 0.5% 16.1Ki [10 Others]
0.5% 15.9Ki 0.0% 945 [__DATA]
0.4% 15.0Ki 0.4% 15.0Ki __TEXT,__cstring
0.0% 4 0.3% 11.3Ki [__LINKEDIT]
0.0% 0 0.2% 8.12Ki __DATA,__bss
0.2% 8.01Ki 0.2% 8.01Ki [__DATA_CONST]
0.2% 7.43Ki 0.2% 7.43Ki Function Start Addresses
0.0% 0 0.2% 6.88Ki __DATA,__common
编译器优化
简单介绍一下最常见的几个可以减少二进制文件体积的编译优化选项。以下被提到的选项均在行业种被广泛使用。
- -Os:之前介绍过,详见编译器工具链(三)——编译优化。
- -Wl,–strip-all(或者不添加-g选项):该选项告诉链接器删除调试部分。
- -fno-unroll-loops:关闭循环展开,循环展开优化是一种常见的性能优化方法,会增加代码大小。
- -fno-exceptions:从二进制文件中移除异常处理代码。
- lto (-flto):通过-flto选项启用链接时优化会触发积极的编译器优化。许多函数和全局变量被优化掉,许多调用点会被去虚拟化。生成的二进制文件速度更快,同时也更小。但同时编译时长也会显著增长。
源码优化
除了编译器优化,通过软件的方式利用一些编程语言特性也可以缩减代码大小。
1. 代码重构
将函数定义挪到.c/.cpp文件。当函数定义放在头文件中时,它会在包含头文件的每个翻译单元中都拥有一份拷贝。即使只有一个定义,这些函数可能已经内联在它们的调用者中,这样,额外的代码会被保存在二进制文件中。因此,最好是将函数定义放在.c/.cpp文件中。
除了由开发人员编写的函数之外,还有编译器生成的函数,例如构造函数、析构函数、操作符重载等,这些函数也可能会因为类型结构或语言规则从而影响代码的大小。因此,程序员可以在.cpp文件中显式地实例化这些函数。例如,在test.h文件中,class被定义如下:
class A {
A();
A(A const&);
~A();
};
在test.cpp文件中,这些定义被实例化:
A::A() = default;
A(A const&) = default;
A::~A() = default;
与头文件中的函数定义类似的,模板函数也也会对代码大小产生影响。然而,要减少这些开销也不简单。通常情况下,一些类型会比其他类型使用得更加频繁,对于这种常用类型,我们可以在.cpp文件中显式地实例化它们。
例如,在test.h文件中,模板函数被定义如下:
template<class T>
struct a {
void f(T t) { /* */ }
};
在test.cpp文件中,模板函数被显式实例化:
template struct A<int>;
显式实例化还可以节省编译时间,因为实例化只发生一次。
2. 函数属性
可以减少内联可能性的函数属性也可以帮助减少代码体积,例如:
__attribute__((cold))
__attribute__((noinline))
注意:
在某些情况下,内联也有可能会减少代码体积。特别是对于小函数,内联删除了函数调用的开销,这有可能比函数体本身还要大。另外,建议有限地使用这些属性,因为它们可能会影响程序的可读性。
3. 从二进制中移出计算
当有了对于编译器优化和编程语言需求的良好知识储备,就有可能将计算从二进制文件中移出。一些表达式可以在编译时计算,而另一些表达式可以延迟到运行时再计算。这两种方法都有助于减小二进制大小。
- 提前运算:使用c++的constexpr,static_assert等语言特性,一些表达式可以提前运算,例如:
constexpr auto gcd(int a, int b){
while (b != 0){
auto t = b;
b = a % b;
a = t;
}
return a;
}
int main(){
int a = 11;
int b = 121;
int j = gcd(a,b);
constexpr int i = gcd(10,12); // saves ‘2’ in the final assembly.
return i + j;
}
使用 g++ -std=c++17 -fno-exceptions -S
将上述程序编译:
main:
mov edx, 121
mov eax, 11
.L2: # inlined call to gcd(a, b)
mov ecx, edx
cdq
idiv ecx
mov eax, ecx
test edx, edx
jne .L2
add eax, 2 # Precomputed value of gcd(10,12)
ret
可以在汇编中看到,第二个gcd在编译时已经求值了,但是对gcd的第一个调用包含了完整代码,这是因为对gcd函数的第二次调用是一个constexpr。
移除死代码
几乎在任何大型程序中,都可能因为各种原因出现死代码。一些死代码可以用简单的技巧移除,例如
- 查找产品中的测试和调试代码。nm工具可用于搜索二进制文件中的符号名称:
nm <Binary> | grep -i "test\|debug"
- 使用strings工具在二进制文件中查找字符串。之前讲过,strings可以打印所有在二进制中硬编码的C-strings。通过了解这些字符串,可以研究为什么某个特定的字符串会出现在二进制文件中。
References:
https://en.cppreference.com/w/cpp/language/constexp
https://learning.edx.org/course/course-v1:LinuxFoundationX+LFD113x+3T2021/block-v1:LinuxFoundationX+LFD113x+3T2021+type@sequential+block@a0bb2522e3de4eafaedaf26f44ac956e/block-v1:LinuxFoundationX+LFD113x+3T2021+type@vertical+block@1b3d0cfe5478439c8c694c7db3b35f2a