
本文要点
现代嵌入式系统必须协调不断增加的软件复杂性和停滞不前的内存限制,促使开发人员采用像 C++这样的语言,同时要根据严格的硬件约束对二进制文件大小进行优化
C++提供了零成本的抽象,允许高级编程而不影响运行时的性能,但开发人员必须要注意模板、智能指针和 STL 使用等语言特性是如何影响二进制文件大小的。
像 Bloaty 和 Puncover 这样的工具对于理解和管理二进制文件膨胀是必不可少的,它们可以洞察哪些组件和设计模式对固件大小的贡献最大。
运行时效率和二进制文件大小之间的权衡应该影响架构决策,例如更喜欢概念而不是多态性,或者使用更简单的标准库替代方案,如<cstdio>而不是<iostream>。
二进制文件大小优化是一个完整的生命周期问题,最好通过将大小跟踪集成到 CI 管道中,并围绕语言特性、工具链标志和设计可扩展性做出有意识的决策来解决。
当思考软件开发人员所从事的产品类型时,我们大多会想到 Web 服务、桌面应用程序或高性能计算,比如在一组服务器集群中训练人工智能模型。
当我思考软件开发时,我通常会看着我桌子上的电路板、传感器和 LED 灯。这些“微型”的小玩意通常被称为嵌入式设备。尽管像树莓派(Raspberry Pi)这样的单板计算机也被称为嵌入式 Linux 系统,但我们将重点关注微控制器。
本文探讨了编写微控制器软件时面临的限制、C++开发的现状,以及在构建可扩展性和复杂性时如何应对一个重大的挑战:二进制文件的大小。
什么是微控制器?
这类芯片不运行完整的操作系统,如 Windows 或 Linux。通常,它们运行更轻量级的实时操作系统(RTOS)。有时,由于要求非常苛刻(或计算能力非常有限),以致应用程序是用裸机编写的,可以直接访问处理器的中断和寄存器。
在许多产品领域,微控制器被用作系统中的基本处理单元,其中需要高效和低功耗的计算,即环境监测、工业传感器和家庭自动化。物联网(IoT)这个术语通常用来定义这些依赖于微型传感器网络和边缘计算节点的用例。
与传统处理器相比,微控制器在硬件架构上具有更大的多样性。
这种分裂也反映在软件方面,我们看到对专有工具和软件开发套件(SDK)的依赖更大,以适配每个芯片及其架构的低级硬件访问。由于与硬件的这种密切关系,微控制器 SDK 几乎完全是用较低级别的语言编写的,特别是 C 语言。
框架和语言
在 SDK 的基础上,我们已经能够开发完整的应用程序,甚至可以直接访问硬件的特性,比如中断和寄存器。然而,由于如此靠近硬件,开发复杂程序并在多个平台上重用它们的工作量大大增加了。因此,我们通常在 RTOS 之上为微控制器构建应用程序。这些框架提供了一个抽象层,用于管理多个线程,配置它们的优先级,并适当地调度它们的执行。这个领域的典型例子是FreeRTOS和Zephyr。
为了跟上软件和产品需求的发展,嵌入式开发需要利用更高级语言的表达力和易用性。现在,像 C++和 Rust 这样的编程语言也被用在嵌入式领域了,这带来了一系列全新的挑战,因为这些语言最初在内存和计算能力方面没有如此严格的限制。
微控制器中的 C++
C++是一种通用的编程语言,已经发展了 40 年。语言的更新由标准化委员会以版本的形式引入。最近,委员会遵循了三年的节奏,C++ 23是最新的版本。C++特性通常分为两大类:语言特性和库特性。语言特性定义了你编写的代码的核心功能和语法,如关键字的含义、数学运算符及其操作的含义。
库特性引入了额外的实用工具,通常以是对象或函数的形式,它们是用核心语言编写的。常用的库包括字符串、数据容器和算法。虽然语言特性本质上是由编译器实现的,但库特性以你可以链接到你的程序的库的形式提供,称为标准库。
微控制器项目的开发人员通常避免使用标准库,因为一些标准库特性,如数据容器,使用动态内存分配。如前所述,微控制器芯片具有非常有限的易失性内存(RAM),并且通常在裸机或简单的 RTOS 上运行应用程序,没有内存管理单元(MMU)。由于这种内存限制,在应用程序的生命周期中,不断的内存分配和释放可能会产生碎片。内存碎片是一个通过分配和部分释放在程序中分散可用内存的过程。经过一段时间后,程序无法分配到新的连续内存块,尽管总的可用内存可能表明还有剩余。下图展示了这一过程:

在无法分配内存的情况下,程序将抛出 std::bad_alloc
异常或终止。在不支持异常处理的微控制器固件的上下文下,这意味着你的固件运行将始终消耗可用内存,直到它不可避免地崩溃并重新启动。
为了避免处理动态分配,一个流行的替代方案是使用嵌入式模板库(embedded template library ETL),在 ETL 中所有对象都拥有静态内存。通过这种方式,开发人员可以非常确定地知道在运行时会使用多少内存。
话虽如此,使用标准模板库(STL)也有其好处。通过 STL,你可以获得为 C++开发的最新的库特性以及它们与新语言特性的协同效应。对于开发人员来说,入门门槛更低,因为他们有更多的资源可以学习和依赖。随着我们使用constexpr和consteval限定符,在编译时越来越多地为对象和上下文提供评估,我们也可以在编译时执行更多的逻辑,从而减少运行时分配的需求。
更重要的是,我前面提到的使用标准库可能带来的动态分配劣势实际上可能并不重要,这取决于你正在处理的领域以及你如何设计你的应用程序。假设我们有一个应用程序,可以在启动时完全描述它的特征,例如一个单一功能的设备,如家电。这种系统的另一个例子是,如果它具有单独的执行和配置模式,并且在它们之间需要完全重置或重启应用程序。这意味着你可以在初始化期间完全定义和分配你的对象,然后在应用程序运行时拥有一个稳定的内存占用。如果你有一个稳定的分配和释放周期,或者不同任务的周期不会在同一时间跨度内执行,这样我们就不会有无规律的分配周期产生碎片,我甚至会进一步放宽这个限制。
因此,我们不应该一开始就排除标准库。分析你的应用程序领域和执行生命周期,然后评估它在内存占用方面是否合适。同样值得注意的是,标准库正在引入更多对嵌入式友好的特性。例如,C++26 将引入一个固定容量容器 std::inplace_vector
,用户可以在其中尝试推回一个元素,容器会检查是否还有足够的空间来执行操作。希望我们能在即将到来的标准中看到更多这样的相关特性。
C++演变
在过去的 20 年里,C++标准中增加了大量的语言和库特性。对于微控制器来说,它们是如何影响软件架构和开发的,有很多需要讨论方面。
我想强调的两个主要影响是,我们可以编写具有比以前更高抽象级别的 C++代码了,因为我们有了智能指针和自动类型推断。我们还有了更强大的库,比如 format 和 print,这些库在内存管理、类型系统等方面节省了大量的开发工作。此外,该语言及其库还引入了执行复杂操作的更强大的语法,如可变模板、折叠表达式和范围。
让我们将这些方面结合起来,通过一个小例子在实践中了解它们。我们有一个收集不同类型数据点的系统。我们可以在一个模板结构中泛化数据点的实现,在模板结构中还可以存储进一步的元数据:
规范是要有一个公共接口,通过该接口处理同一种类型的一系列值,然后将它们发送到某个外设。最终,我们的客户端代码将如下所示:
正如我们所看到的,客户端代码是相当高级的。尽管 C++是一个静态类型语言,但用户不需要担心显式指示要处理的类型,。底层代码应该负责解析正确的用例。
首先,让我们考虑一下外设。我们需要为串行驱动程序定义一个接口,通过该接口,我们想要发送一系列字符并返回一个布尔值以指示操作是否成功。我们不使用虚拟类和多态性,而是使用概念(concept)来定义一个类型可作为串行驱动程序所需满足的约束:
从这个概念出发,我们可以为每个满足这些约束的外设编写类。为了在每个实例中获得所需的外设,我们有一个函数,该函数在编译时根据枚举系统中所有外设的特定 id 号解析所需的外设,然后返回一个智能共享指针。我们省略了实现细节,将重点放在代码的整体架构上:
现在我们将注意力转向处理我们想要发送的值。为此,我们使用了 C++ 20 中引入的一个称为概念(concepts)的特性。概念为定义模板元编程中的类型需求提供了一种更简单的语法。在这个例子中,我们通过将不同概念的实现附加到不同的函数上来重载函数 process_and_send_dp
,这些概念限制了传入数据的类型。在每个实现中,数据点会被进行后置处理,然后我们获取相应的外设,并按照特定的格式化方案发送数据。
最后,我们需要一个函数,它不断地调用 process_and_send_dp
处理整个数据系列,并构建结果向量。同样,我们可以使用自动类型推断,现在结合可变模板和折叠表达式来处理整个输入参数列表。
由此产生的实现能够识别正在发送的值之间的公共类型,并使用相应的处理、格式化和外设:
通过上面的例子,我们可以看到我们有一个非常灵活和强大的接口。例如,我们不需要为每个输入类型提供额外的配置参数或不同的函数名。直观地说,带来这样的灵活性也意味着性能开销,因为底层实现必须在底层解决不同的用例。在 C++的面向对象编程中,这种开销通常以虚表(vtable)的形式出现,它的间接需要解决了将要使用哪个派生类。然而,情况并非必然如此,C++有许多技术可以减轻这种开销。
constexpr / consteval 上下文和模板元编程可以将大量逻辑和类型解析转移到编译时,当与移动(move)语义结合使用以避免复制时,内联代码可加快代码执行速度,甚至可以使用表预先计算值。
通过在编译时选择正确的函数调用,我们不会因为以这种方式编写代码而受到任何性能损失。这些技术的一个常见术语是零成本抽象,即我们可以以最小的或没有性能开销的方式编写更复杂的功能。我们可以以一种不太专业的方式编写客户端代码,这没有任何缺点。尽管这听起来像是一个完美的世界,但除了代码复杂性和运行时间之外,在其他领域可能存在我们没有考虑到的权衡。在 C++开发中,一些特性和设计可能对二进制文件大小有很大的影响。由于硬件和成本限制,二进制文件的大小对于嵌入式应用程序来说可能是一个特别重要的标准。在继续研究这些特性之前,让我们尝试了解为什么二进制文件的大小在某些类型的硬件中是如此关键的限制。
硬件视角
虽然我们在这里关注的是软件,但重要的是要说软件不是在真空中运行的。了解我们的程序运行的硬件,甚至硬件是如何开发的,可以为如何解决编程挑战提供重要的见解。
在软件世界中,我们有一个更加迭代过程,例如,新功能和修复通常可以稍后以在线升级(over-the-air)的形式合并。硬件却并非如此。硬件中的设计错误和故障充其量可以通过相当大的性能损失来减轻。这些错误可能会导致崩溃和幽灵漏洞,或者使整个设备无法使用。因此,与软件设计阶段相比,硬件设计阶段在发布之前有一个更长更严格的过程。这种严格的流程也影响了优化和计算能力方面的设计决策。一旦你为你的设备定义了布局和物料清单,期望就是尽可能长时间地保持这种状态不变,以降低成本。
嵌入式硬件平台的设计旨在非常具有成本效益。设计一个产品,如果其内存或 I/O 数量等规格被浪费,也意味着成本的增加,因为在这个行业,物料清单中每一分钱都很重要。
一个重要的规范是非易失性(NV)内存,例如闪存,它被用来存储应用程序所需的固件和数据。NV 存储器对微控制器的芯片尺寸有很大影响,因此芯片设计师会包含运行应用程序所需的最小数量。从主要供应商来看,我们仍然看到大多数微控制器提供了少于 1 兆字节的内部闪存存储。即使是更现代、功能更强的芯片也不会超过 2-4 兆字节。与此同时,架构和节点技术的发展使得这些微型芯片的计算能力大大提高了,因此能够运行更复杂的应用程序。从这些对比点来看,我们可以看到嵌入式开发中最大的挑战之一是能够在仍然有限的内存占用中容纳更大的应用程序,这使得分析已开发固件的二进制文件的大小至关重要。
二进制文件大小分析
一旦你得到了编译后的固件的二进制文件,我们就可以开始分析每个源代码组件、函数等占总大小的字节数。有一些免费可用的开源工具可以进行这种分析,如Bloaty和Puncover。Bloaty 是一个命令行工具,它可以在不同级别(如节、段和编译单元)对二进制文件中的组件大小进行分析和排序。结果以列表形式显示在终端上:
另一方面,Puncover 会启动一个网络服务器,因此结果可以以图形的方式显示。它创建了一个类似文件资源管理器的应用程序,其中显示每个源文件对二进制文件的贡献。然后,每个源文件都有自己的页面,用户可以在其中查看反汇编和按堆栈、代码或静态大小排序的符号列表。


在某种程度上,这些工具是相互补充的。Bloaty 为整个二进制文件提供了一个更高层次的快速概览,从而可以更快地识别出对二进制文件大小贡献最多的部分。然后 Puncover 可以用于更深入地查看已识别的组件,并通过比较符号列表或直接反汇编差异来更好地理解变化。
我创建了一个公共存储库,其中包含对 C++开发中二进制文件大小影响的不同研究案例,请参阅cpp_binary_size。上述工具可以用来比较每个案例中产生的二进制文件,并识别二进制文件大小变化的原因。
通过这些例子,我们可以看到二进制文件大小对 C++编程不同方面的影响。这也意味着在开发过程中有不同的方式来优化二进制文件的大小。以下是一些需要注意的相关评论:
在接口层面评估影响,比如构造函数和函数调用。寻找那些不必要的复制或转换被制作的地方。此外,还要分析这种影响在规模上是如何演变的,例如,当对象和/或调用站点的数量增加时。例如,将 char*
传递给具有 std::string
或甚至 std::string&
作为签名的函数将分配一个临时字符串。在另一个例子中,使用 push_back
或 emplace_back
向向量(vector)对象添加一个元素,可能会导致该元素的复制或移动。复制通常比移动引入更多的代码(因此二进制文件更大)。
调优带有编译标志的库的使用。在引入任何第三方库时,要熟悉它的构建系统和配置头文件,并探索可用选项可能如何影响二进制文件的大小。最常见的是,禁用未使用的特性可以在二进制文件大小上节省大量的空间。例如,与使用标准库中的 <format>
不同 , fmtlib
( <format>
所基于的格式化库)有许多不同的标志,可以减少二进制文件的大小。
这些标志不仅禁用特性,而且还使用了更简单的算法或以更小的二进制文件大小换取更慢的性能。特别是在标准库中,测试不同的对象和库以实现类似的功能可以产生很大的影响。在我的实验中,我注意到使用 <cstdio>
中的函数而不是 <iostream>
来打印到 stdout 标准输出可以节省超过 100 千字节的二进制文件大小,因为 iostream 带来了许多静态字符串和 locale 库。当使用 C++ 23 中较新的 <print>
库时,也会出现类似的内存节省效果。
优化二进制文件的大小不应是事后的想法,而应该在整体设计阶段评估。在设计应用程序的架构时,有一些设计决策可以对二进制文件的大小产生重大的影响,但我们必须注意其他方面的潜在缺点。例如,使用概念,C++ 20 的一个特性,而不是多态性,可以通过消除虚函数、间接函数和更大的析构函数来节省大量的二进制文件大小。然而,它也将代码库强制转换为大部分头文件,从而导致更长的编译时间,并且需要重新编译它所影响的所有编译单元。
即使是在一个特定的设计中,测试其实现方式的变化也是很重要的。例如,类型擦除是软件编程中常用的模式,它可以在运行时通过泛型接口使用具体类型,从而减少二进制文件的膨胀。它可以通过实现继承、静态函数或虚函数表(vtable)来实现。我们可以在增加基本对象数量的实验中实现和分析其对二进制文件大小的影响,然后是每种对象类型的实例,再然后寻找最适合我们应用程序大小的变体。
尽管你在编写的源代码时进行了所有分析,但你计划部署的目标架构也可能产生重大的影响。例如,有时设计变化可以在 64 位架构中产生相同的二进制文件大小,因为它的指令能够支持更大的地址,因此对函数签名的变化不那么敏感,比如输入变量的数量。与此同时,在 ARM 中,Thumb 指令可以是标准,也可以是宽指令,宽指令占用更多的二进制文件大小。因此,编译器可能需要在较小的函数签名中使用标准和宽指令的不同混合,然后为每种设计变化产生不同的二进制文件大小占用。
二进制文件大小优化
最后,C++能够为尺寸受限的设备生成非常高效的代码。然而,工具和应用程序上下文是至关重要的,但有一些注意事项和设计决策也需要了解:
为你的构建环境找到最佳的设置:你的工具链和链接器的标志对整个应用程序的大小有很大的总体影响。有时,甚至从带有专门标志的源代码重新构建工具链和/或标准库也可以产生很大的影响。例如,你可以构建具有非默认隐藏可见性的标准库,这可以允许链接器丢弃在静态链接固件中未使用的代码。
寻找语言特性和库的二进制文件大小成本。使用 Bloaty 和 Puncover,我们可以识别出具有二进制膨胀的符号和/或文件,然后可以寻找替代方案和优化。正如我们在上一节所看到的,使用
cstdio
而不是 iostream 调用来打印可以产生很大的影响。对于标准库和软件设计来说,这并不是一个孤立的事件,因为有许多不同的方法可以实现类似的功能。算法和数据结构是其他值得注意的特性,它们在二进制文件大小方面可能有很大差异。测试同一设计的不同变体,并分析它们在增加对象/实例数量时的可扩展性。例如,避免过度使用虚函数或需要对其参数进行不必要的复制或强制转换的函数调用。
注意类型(例如,多态性)、构造函数(例如,复制和移动构造函数的成本以及它们何时被使用)和函数调用(例如,当需要复制和/或分配临时函数时)之间的交互。
更喜欢简单的算法(例如,使用 for 循环而不是
<algorithm>
套件,如std::find_if
)和对象/数据结构(例如,std::map 而不是 std::unordered_map),因为它们通常生成的代码更少。通过模板元编程,许多类型检查可以推迟成编译类型,使用户可以在运行时使用更简单、原始的类型。这是依赖注入框架所采用的一种常用策略,它可以在编译期间检查注入的类型是否有效。Constexpr/consteval 上下文也可以将许多计算推迟到编译时,并减少运行时需要执行的代码量,尽管它也可以向二进制文件中添加大量预计算的数据。
在你的代码分析/CI 管道中集成固件大小,并作为一个自动化的度量指标,以报告传入代码更改的增量。这种报告允许开发团队跟踪传入功能带来的二进制大小的潜在增长,并及时采取行动。
总结
现代微控制器中的软件不仅从功能开发的角度来看是一个挑战,而且还需要注意对二进制文件大小和内存占用方面的影响。与在桌面和服务器上进行的计算不同,嵌入式平台在可用内存方面的演变与它们在计算能力方面的演变相比非常有限。由于这种有限的内存增长,二进制文件大小优化是整个开发生命周期中的一个关键标准,从架构到库选择再到实现方面都是。
使用固件分析工具并了解 C++语言特性对二进制文件大小的影响,对于更好地理解这种影响在应用程序中是如何扩展的至关重要。最后,使你的应用程序更小将允许产品在相同的固件信封内托管更多的功能和更强大的用例。
原文链接:
https://www.infoq.com/articles/complex-applications-storage-constraints/
评论