在C++中优化空间而非速度

44
当你提到“优化”时,人们往往会想到“速度”。但是对于内存是主要限制因素的嵌入式系统,速度并不是那么重要。有哪些指导方针、技巧和诀窍可以用来节省ROM和RAM中的额外几千字节?如何“剖析”代码以查看内存膨胀的位置?
补充一下,有人可能认为在嵌入式系统中过早地优化空间并不那么恶劣,因为这样可以为数据存储和功能扩展留出更多的空间。它还可以通过使代码运行在更小的ROM/RAM上来降低硬件生产成本。
附注:欢迎参考文章和书籍!
这些问题与以下内容密切相关:4046151561629

请查看此答案:https://dev59.com/m3RC5IYBdhLWcg3wG9Xp#485660 - Mike Dunlavey
16个回答

34

我在一个极度受限的嵌入式内存环境中的经验教训:

  • 使用固定大小的缓冲区。不要使用指针或动态分配,因为它们有太多开销。
  • 使用最小的整数数据类型。
  • 永远不要使用递归。始终使用循环。
  • 不要传递大量的函数参数。使用全局变量代替。 :)

2
我以为每个人都是从经验中说话的...他们还有什么资格呢?! :D - James
3
实际上,如果你考虑过人们在内存受限的系统上编程的方式(以及随之而来的两位数年份问题,但那是另一个故事),这就非常合理了。这种程序架构会更加小巧。你可能会很惊讶于人们在真正的程序员时代将多么庞大的程序适配到了非常小的计算机系统上。;-) - ConcernedOfTunbridgeWells
9
一种替代全局变量或大量函数参数的方法是使用参数块。基本上,您创建一个“结构体”,可以供多个函数使用,每个函数从PB中使用其需要的任何参数。然后,调用代码可以设置PB并将其传递给一个或多个函数。旧的Mac OS中的低级文件系统调用从一开始就使用这种方法来帮助将所有内容打包到最初的Macintosh的128K中。这类似于贫民区类,不同之处在于(与类方法不同),您可以将两个PB传递给某些函数。 - Mike DeSimone
1
对于所有这些,答案都是肯定的:不要(永远)使用浮点数运算,确保你的结构体紧密打包,毫不犹豫地使用位域,仔细思考再创建另一个变量之前;如果你可以从现有变量中获取所需信息,请这样做。 - ArielP
1
@peterchen 是的,我也是这样期望的,但对于每个平台,必须包含一些代码(超出您编写的范围),以便执行fp操作。即从SP的先前答案中,这是我的平台上包含的代码:我的代码从223行开始,内存地址为001BC,使用_floatsisf,继续通过几个其他标签(_fpack,_divsf3等)并以_funpack结束,最后一行在535行,内存地址为0042C。如果您可以处理(42C-1BC = 0x270 =)624字节的丢失程序空间,那太好了,但有些芯片只有2k的空间,这不是一个选项。 - ArielP
显示剩余4条评论

14

有许多方法可以减少内存占用,我相信人们已经写过很多相关的书籍,但是其中一些主要的方法包括:

  • 使用编译器选项来减小代码大小(包括 -Os 和打包/对齐选项)

  • 使用链接器选项剥离无用代码

  • 如果你从闪存(或 ROM)加载到 RAM 中执行(而不是直接从闪存中执行),则使用压缩的闪存映像,并使用启动加载程序进行解压缩。

  • 使用静态分配:堆是分配有限内存的低效方式,并且如果受到约束可能会因碎片而失败。

  • 使用工具找到堆栈高水位线(通常是用模式填充堆栈,执行程序,然后查看模式留在哪里),以便可以最优地设置堆栈大小

  • 当然,还要优化算法以减少内存占用(通常以速度为代价)


1
另一方面,堆提供了静态分配所不具备的内存重用可能性。 - anon
嗯,堆使得重复利用内存变得更加容易,而无需显式地这样做。 - James
静态分配(通常是结构体数组)有时会集中事物并破坏封装性。很难解释。目前,我无法想到一个可以在几句话中输入的好例子。 - Emile Cormier
1
关于碎片化角度的问题,你说得很对:这是许多需要长时间运行的嵌入式系统拒绝使用动态分配的主要原因。 - Edan Maor
2
这样做的好处是,你不必在每个地方都处理失败,可以节省约30%的代码大小;-) - Steve Jessop
5
在非常有限的环境下,由于受到严格限制,你经常需要打破“好”的编程规范。 - mmmmmmmm

12

一些显而易见的内容

  • 如果速度不是关键,直接从闪存中执行代码。
  • 使用const声明常量数据表。这将避免数据从闪存复制到RAM。
  • 使用最小的数据类型紧密地打包大型数据表,并按正确顺序排列以避免填充。
  • 对于大型数据集,请使用压缩(只要压缩代码不超过数据大小)
  • 关闭异常处理和RTTI。
  • 有人提到使用-Os吗?;-)

将知识折叠到数据中

Unix哲学的一个规则可以帮助使代码更加紧凑:

表示法规则:将知识折叠到数据中,以便程序逻辑变得简单且健壮。

我已经看到了多少次复杂的分支逻辑,跨越许多页面,本可以折叠成一个漂亮的紧凑规则表、常量和函数指针。状态机经常可以用这种方式表示(状态模式)。命令模式也适用。这就是关于编程的声明性与命令性风格的区别。

记录代码+二进制数据而不是文本

记录事件代码和二进制数据,而不是纯文本。然后使用“短语书”重新构建事件消息。短语书中的消息甚至可以包含printf样式的格式说明符,以便事件数据值在文本中被整齐地显示。

最小化线程数量

每个线程都需要自己的内存块来存储堆栈和TSS。在不需要抢占的情况下,考虑使任务在同一线程中协作执行(协作式多任务处理)。

使用内存池而非囤积

为避免堆碎片化,我经常看到独立模块囤积大型静态内存缓冲区供自己使用,即使这些内存只有偶尔需要。可以使用内存池,以便只在“按需”时使用内存。但是,这种方法可能需要仔细分析和检测,以确保池在运行时不会被耗尽。

仅在初始化时进行动态分配

在仅运行一个应用程序的嵌入式系统中,可以以明智的方式使用动态分配,而不会导致碎片化:只需在各种初始化例程中动态分配一次,然后永远不释放内存。将容器reserve()到正确的容量,不要让它们自动增长。如果需要频繁地分配/释放数据缓冲区(例如,用于通信包),则使用内存池。我曾经甚至扩展了C/C++运行时,以便在初始化序列之后,如果有任何东西尝试动态分配内存,则会中止我的程序。

记录代码+二进制数据而非文本 - 我们曾经在二进制文件上运行strings命令,按长度排序结果,然后将最长的字符串注入到镜像中,重复此过程直到无聊得不得不去做更有趣的事情。虽然这不是C++,但我们确实忽略了混淆的函数名。 - Steve Jessop

8
与所有的优化一样,首先要优化算法,其次要优化代码和数据,最后再优化编译器。
我不知道你的程序是做什么的,所以无法就算法给出建议。很多人都已经写了关于编译器的文章。那么,在代码和数据方面给一些建议:
- 消除代码中的冗余。任何重复的代码,如果在你的代码中重复三遍或以上,并且超过三行,都应该改为函数调用。 - 消除数据中的冗余。找到最紧凑的表示方法:合并只读数据,并考虑使用压缩码。 - 通过常规分析器运行代码,消除所有未使用的代码。

1
请遵循这个建议 - 我正在处理一个系统,原始开发人员(20年前)非常关注堆栈,以至于他们在每个地方都复制了代码!这是一个史诗般的噩梦。 - Michael Kohne

7

从链接器生成一个映射文件,它将显示内存分配的情况。这是优化内存使用的良好起点。它还将显示所有函数以及代码空间的布局。


5

4
使用/ Os在VS中编译。通常情况下,这甚至比优化速度更快,因为较小的代码大小意味着较少的分页。
链接器应启用Comdat折叠(在发布版本中默认启用)。
要小心数据结构的打包;经常会导致编译器生成更多的代码(==更多的内存)来生成访问未对齐内存的汇编代码。使用1位布尔标志是一个经典例子。 此外,在选择内存效率算法和具有更好运行时间的算法时要小心。这就是过早优化的地方。

4

好的,大部分都已经被提到了,但这里是我的清单:

  • 学习你的编译器能做什么。阅读编译器文档,尝试代码示例。检查设置。
  • 检查目标优化级别下生成的代码。有时结果会让人惊讶,通常优化实际上会减慢速度(或者只是占用太多空间)。
  • 选择合适的内存模型。如果你的目标是非常小的紧凑系统,大型或巨大的内存模型可能不是最佳选择(但通常最容易编程...)
  • 首选静态分配。仅在启动时或超过静态分配缓冲区(池或最大实例大小的静态缓冲区)时使用动态分配。
  • 使用C99风格数据类型。使用最小的足够的数据类型进行存储类型。像循环变量这样的局部变量有时使用“快速”数据类型更有效率。
  • 选择内联候选项。一些参数重的函数与相对简单的主体在内联时效果更好。或考虑传递参数结构。全局变量也是一种选择,但要小心——如果其中任何人不够自律,测试和维护可能会变得困难。
  • 良好使用const关键字,了解数组初始化的影响。
  • 映射文件,最好也包括模块大小。还要检查从crt中包含了什么(是否真的需要?)。
  • 递归要说不(有限的堆栈空间)
  • 浮点数 - 更喜欢固定点数学。倾向于包含并调用大量代码(即使是简单的加法或乘法)。
  • C++你应该非常了解C++。如果不了解,请在C中编写受限嵌入式系统。敢于尝试的人必须小心所有高级C++结构(继承、模板、异常、重载等)。考虑到接近硬件代码是超级C,而C++是在高级逻辑、GUI等方面发挥作用。
  • 在编译器设置中禁用任何不需要的内容(无论是库的部分,语言结构等)

最后但同样重要的是,在追求最小可能的代码大小时,不要过度。同时注意性能和可维护性。过度优化的代码往往很快就会退化。


2

在其他人的建议之上:

限制使用C++功能,像使用ANSI C一样编写代码并增加一些扩展。标准(std::)模板使用了大量的动态内存分配。如果可能的话,应尽量避免使用模板。虽然它们本质上没有危害,但只需使用几个简单、干净、优雅的高级指令就可以生成大量机器代码。这鼓励以一种非常占用内存的方式编写代码,尽管存在所有“干净代码”的优点。

如果必须使用模板,则应编写自己的模板或使用专为嵌入式设备设计的模板,将固定大小作为模板参数传递,并编写测试程序,以便测试模板并检查-S输出以确保编译器不会生成可怕的汇编代码来实例化它。

手动对齐结构体,或使用#pragma pack。

{char a; long b; char c; long d; char e; char f; } //is 18 bytes, 
{char a; char c; char d; char f; long b; long d; } //is 12 bytes.

出于同样的原因,使用集中的全局数据存储结构而不是分散的本地静态变量。

明智地平衡malloc()/new和静态结构的使用。

如果您需要给定库的子集功能,请考虑编写自己的库。

展开短循环。

for(i=0;i<3;i++){ transform_vector[i]; }

长。

transform_vector[0];
transform_vector[1];
transform_vector[2];

不要对较长的文件这样做。

将多个文件打包在一起,让编译器内联短函数并执行各种优化。链接器无法进行此操作。


这些平台的链接器无法实现。此外,完全禁止使用模板是愚蠢的,我会说除非你知道自己在做什么,否则不要使用模板。 - peterchen
你绝对可以在原本使用函数式宏的地方使用模板。这不会增加更多的代码膨胀,而且还能提供额外的类型安全性。 - Emile Cormier
如果你指定了-Os参数,编译器不应该知道何时展开循环以节省空间吗? - Emile Cormier
如果你在使用模板时小心谨慎,那么一切都会很好。但是你确定在你之后维护代码的人不会被诱惑滥用它们吗?它们是有风险的,因为它们诱惑使用耗费内存的编程习惯(在所有其他情况下都是良好的编码实践-更清晰的源代码)。 - SF.

2

首先,告诉你的编译器优化代码大小。GCC 使用-Os标志进行此操作。

其他一切都在算法级别上 - 使用类似于查找内存泄漏的工具,但是要寻找可以避免的分配和释放。

还应该查看常用的数据结构打包方式 - 如果可以削减一两个字节,就可以大幅减少内存使用。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接