书名:嵌入式C++实战 : 从C语言无缝切换到现代C++
ISBN:978-7-115-69271-9
本书由人民邮电出版社发行数字版。版权所有,侵权必究。
您购买的人民邮电出版社电子书仅供您个人使用,未经授权,不得以任何方式复制和传播本书内容。
我们愿意相信读者具有这样的良知和觉悟,与我们共同保护知识产权。
如果购买者有侵权行为,我们可能对该用户实施包括但不限于关闭该帐号等维权措施,并可能追究法律责任。
著 [波黑] 阿马尔・马哈茂德贝戈维奇(Amar Mahmutbegović)
译 王士喜
责任编辑 傅道坤
人民邮电出版社出版发行 北京市丰台区成寿寺路11号
邮编 100164 电子邮件 315@ptpress.com.cn
网址 http://www.ptpress.com.cn
读者服务热线:(010)81055410
反盗版热线:(010)81055315
Copyright © Packt Publishing 2025. First published in the English language under the title C++ in Embedded Systems –(9781835881149)
All Rights Reserved.
本书由英国Packt Publishing公司授权人民邮电出版社出版。未经出版者书面许可,对本书的任何部分不得以任何方式或任何手段复制和传播。
版权所有,侵权必究。
本书系统阐述了现代C++(直至C++23)在资源受限嵌入式系统开发中的应用方法与实践技巧。本书不仅深入解析了C++相较于C语言在嵌入式开发中的核心优势,还全面覆盖了从语言基础到高阶技术,再到实际工程落地的完整知识体系。
全书共18章,分为四大部分。第1部分聚焦嵌入式开发中C++的认知普及与环境搭建,破除关于C++的常见误解,分析资源受限嵌入式系统的开发挑战,介绍嵌入式C++生态系统的工具链、分析工具与测试框架,并指导读者搭建现代化的嵌入式C++项目开发环境与仿真测试环境。第2部分夯实C++基础核心,详解类的核心机制、命名空间、函数重载等基础特性,以及C++与C语言的互操作性,同时系统梳理各类错误处理技术与异常机制。第3部分深入C++高级概念,包括模板与元编程、强类型安全强化、lambda表达式应用及编译时计算等关键技术,助力开发者编写通用、高效、高可读性的代码。第4部分聚焦嵌入式实际问题解决,演示硬件抽象层(HAL)实现、C库协作方法,讲解序列器优化、设计模式应用、有限状态机设计等实用方案,并介绍嵌入式开发常用库与框架及跨平台开发技巧。
本书适合长期使用C语言、希望转型现代C++的嵌入式开发者阅读,也适合从事低功耗设备、医疗设备、汽车电子、自动驾驶等领域的软硬件开发工程师、系统设计工程师及技术爱好者阅读。无论读者是否具备深厚的C++基础,都能通过本书掌握嵌入式C++开发的核心能力,开发出更可靠、灵活、易维护且高效的嵌入式系统。
在本书中,Amar Mahmutbegović展示了如何将现代C++应用于嵌入式系统开发。这类系统通常需运行在资源受限的环境中,CPU算力、内存及电力均十分有限,同时还需满足严苛的时序与可靠性要求,且可能需规避递归、动态内存管理等编程方式。
长期以来,C语言一直是嵌入式系统的首选语言。然而,C++在保留C语言底层硬件访问能力的基础上,还提供了更具表达性的抽象机制与设计范式,具备更优的类型安全、更完善的资源与内存安全性。或许你曾因担心C++是否适配嵌入式系统而不愿使用它,但数十年来,C++语言本身及其编译器不断演进,已彻底打消了这些顾虑。更完善的类型安全与内存安全特性,意味着编译器能在编译阶段发现更多错误,且不会增加运行时开销。
Amar将带你深入学习现代C++(直至C++ 23)中实现上述改进的关键特性,包括静态与动态绑定、动态与编译时多态、模板与元编程、资源管理、编译时计算等技术,以及支撑稳健设计的设计模式与原则。他以资深嵌入式系统开发者的视角,编写了简洁实用的示例代码。
他还介绍了如何使用Compiler Explorer、Renode等工具查看生成的机器码,并仿真嵌入式目标设备,让你亲身体验这些方法如何兑现其优势承诺。
通过本书所学的方法,你将能够开发出更可靠、更灵活、更易维护、更具可复用性与适应性,且效率更高的嵌入式系统。
Steve Branam
Amazon Robotics公司
资深软件开发工程师
Amar Mahmutbegović(阿马尔・马哈茂德贝戈维奇),Semblie公司联合创始人兼工程负责人,主导将现代C++应用于固件开发。他的专业领域涵盖低功耗蓝牙(BLE)消费类设备与医疗设备开发,为包括初创公司在内的各类客户提供高品质解决方案。Amar对创新的执着,助力电子产品理念走向世界。在嵌入式社区中,阿马尔通过博客与LinkedIn积极分享C++的应用价值,同时为年轻工程师提供技术指导,帮助他们提升现代开发技能。他的工作目标是让嵌入式系统更易于开发、维护且具备可扩展性,为全球开发者搭建传统开发方法与现代高效技术之间的桥梁。
我要向我的妻子、挚友,也是我一生的挚爱—Ferisa Živčić,致以最诚挚的感谢,感谢她在过去一年里对我的包容与理解。在全职工作的同时撰写本书,占用了我大量的空闲时间与精力。她的耐心、体谅与支持,我将永远感激。
我同样感恩拥有最棒的父母—Enisa与Safet。他们教会我学习与勤奋的重要性,并用坚定的道德准则指引我的人生。
还要向所有支持我以及以各种方式提供帮助的家人、朋友与同事,致以深深的谢意。
Dirk Jan ten Kate(德克・扬・滕卡特),资深嵌入式软件工程师,在嵌入式系统领域拥有15年以上的经验,专注于汽车与气体测量行业。在现任职的企业中,他主导开发了一套模块化、硬件无关的固件平台,该平台已成为所有新产品的核心基础。Dirk Jan对软件质量有着极致要求,积极推动测试驱动开发(TDD)与持续集成(CI)实践的落地。他的专业能力覆盖系统设计、微控制器开发、RTOS、控制器局域网(CAN)、BLE、高层通信协议、低层串行协议,以及C/C++编程语言的应用。
Rugved Hattekar(鲁格韦德・哈泰卡尔),资深工程师,在嵌入式系统、机器人技术与自动驾驶汽车领域拥有扎实的背景。他擅长使用C/C++开发安全关键型系统,核心研究方向包括软硬件集成、传感器融合与实时性能优化。在GPR公司任职期间,他参与开发了基于探地雷达(ground-penetrating radar)的车辆定位与地图构建系统;目前在Luminar Technologies公司担任资深激光雷达(Lidar)软件工程师,专注于激光雷达传感器的嵌入式数字信号处理(DSP)研发。Rugved在嵌入式C++应用领域具备深厚的技术积淀,其相关成果推动了高级传感与自动驾驶技术的发展。
Jacob Beningo(雅各布・贝宁戈),嵌入式技术专家,致力于帮助嵌入式团队实现架构、开发流程与技能的现代化升级,以交付高质量的实时系统。他拥有20多年的行业经验,已完成100余个项目,目前通过Beningo Embedded Group与Embedded Software Academy(嵌入式软件学院),为企业提供专业培训、技术咨询与工程指导服务。
C++是一种通用的多范式编程语言,支持过程式、面向对象编程,在一定程度上还支持函数式编程范式。它最初以“带类的C语言”起步,历经演变成为一门现代化语言—既能编写表达力极强的代码,又不会牺牲性能。尽管如此,C语言仍是嵌入式开发领域的主流选择,这主要得益于其简洁性和更平缓的学习曲线。
然而,C语言的简洁性往往导致复杂系统的代码过于冗长,既增加了开发者的认知负担,也使代码更容易出错。而这正是C++的优势所在。凭借泛型编程、运行时与编译时多态、编译时计算以及增强的类型安全和内存安全等特性,C++成为嵌入式系统开发的绝佳选择。
关于C++的误解(如代码膨胀、运行时开销过大等)仍广泛存在。本书开篇将逐一破除这些误解,并引导读者掌握C++基础知识,随后聚焦更高级的现代C++概念,应用这些概念解决嵌入式开发中的实际问题。
本书的目标是,通过精心挑选的示例并应用良好的软件开发实践,介绍如何在嵌入式系统中高效运用现代C++。
本书面向日常工作中主要使用C语言且希望了解现代C++的嵌入式开发者。读者无须具备深厚的C++基础(书中会涵盖C++基础知识),但最好有一定的C++认知。
• 第 1 章,“破除关于C++的常见误解”:探讨关于C++的普遍误解并系统澄清,同时让读者了解C++的发展历程及零开销原则。
• 第 2 章,“资源受限嵌入式系统的挑战”:分析资源受限嵌入式系统面临的设计难题,重点介绍性能分析技术和内存管理方法,同时说明如何规避异常、RTTI(运行时类型信息)等可能引发问题的语言特性。
• 第 3 章,“嵌入式C++生态系统”:介绍嵌入式领域C++开发可以使用的工具,包括工具链、静态分析器、性能分析工具和测试框架。
• 第 4 章,“搭建C++嵌入式项目开发环境”:逐步指导读者搭建现代化的C++嵌入式项目开发环境,包括使用模拟器在虚拟环境中测试代码。
• 第 5 章,“类—C++应用的构建块”:帮助读者理解C++中的类,涵盖存储期、初始化、继承和动态多态等内容。
• 第 6 章,“类外核心特性—C++进阶基础基石”:讲解命名空间、函数重载等C++基础特性,探讨与C语言的互操作性,并介绍标准库容器和算法。
• 第 7 章,“强化固件—实用的C++错误处理方法”:梳理C++中的各类错误处理技术(包括错误码、断言、全局处理器等),同时解释异常的底层机制和工作原理。
• 第 8 章,“使用模板构建通用、可复用的代码”:深入讲解模板和concept(概念),并介绍模板元编程和编译时多态。
• 第 9 章,“使用强类型提升类型安全”:探讨C++中的隐式和显式类型转换,引入强类型概念,并通过嵌入式库的实际示例展示如何提升类型安全。
• 第 10 章,“使用lambda编写高可读性代码”:介绍lambda表达式,并展示如何结合命令设计模式借助它实现一个高表现力的中断管理器。
• 第 11 章,“编译时计算”:探索C++的编译时计算能力,并演示如何利用该能力构建一个在编译时生成查找表的信号发生器库。
• 第 12 章,“编写C++硬件抽象层(HAL)”:演示如何用C++实现硬件抽象层,并借助模板元编程确保类型安全。
• 第 13 章,“使用C语言库”:介绍如何在C++项目中高效使用C语言库,并通过文件系统C库的示例展示RAII(资源获取即初始化)原则的应用。
• 第 14 章,“用序列器增强超级循环”:展示如何借助序列器改进基于简单超级循环的设计方案,同时介绍嵌入式模板库(ETL)及其编译时已知固定大小的容器类模板。
• 第 15 章,“实用模式—构建温度发布器”:引导读者学习观察者设计模式,并演示如何将其应用于恒温器、暖通空调(HVAC)控制器等系统。
• 第 16 章,“设计可扩展的有限状态机”:介绍有限状态机的多种实现方式,从基础的枚举-开关(enum-switch)方式入手,引入状态设计模式,最后介绍Boost SML库。
• 第 17 章,“库与框架”:重点介绍对受限系统固件开发有用的C++标准模板库(STL)组件,同时介绍Pigweed库和编译时初始化与构建(CIB)。
• 第 18 章,“跨平台开发”:探讨良好的软件设计对实现嵌入式软件可移植性和可测试性的重要性。
书中多个示例可在Compiler Explorer中运行。利用该工具可观察编译器生成的汇编代码。对示例进行实验、修改,并通过不同优化级别和编译器标志重新编译,可理解这些改动如何影响编译器的输出结果。
大部分示例也可在Renode模拟器中运行。本书提供了配套的Docker容器,内含GCC工具链和Renode模拟器,可在嵌入式目标设备仿真环境中运行代码。
| 本书涵盖的软硬件 | 所需的操作系统 |
| Docker | Windows、macOS或Linux |
读者可以通过异步社区的本书页面下载示例代码文件。
本书提供如下资源:
• 本书配套源代码与彩图文件;
• 本书思维导图;
• 异步社区7天VIP会员。
要获得以上资源,您可以扫描下方二维码,根据指引领取。

作者和编辑尽最大努力来确保书中内容的准确性,但难免会存在疏漏。欢迎您将发现的问题反馈给我们,帮助我们提升图书的质量。
当您发现错误时,请登录异步社区(https://www.epubit.com/),按书名搜索,进入本书页面,点击“发表勘误”,输入勘误信息,点击“提交勘误”按钮即可(见下页图)。本书的作者和编辑会对您提交的勘误进行审核,确认并接受后,您将获赠异步社区的100积分。积分可用于在异步社区兑换优惠券、样书或奖品。

我们的联系邮箱是fudaokun@ptpress.com.cn。
如果您对本书有任何疑问或建议,请您发邮件给我们,并请在邮件标题中注明本书书名,以便我们更高效地做出反馈。
如果您有兴趣出版图书、录制教学视频,或者参与图书翻译、技术审校等工作,可以发邮件给我们。
如果您所在的学校、培训机构或企业,想批量购买本书或异步社区出版的其他图书,也可以发邮件给我们。
如果您在网上发现有针对异步社区出品图书的各种形式的盗版行为,包括对图书全部或部分内容的非授权传播,请您将怀疑有侵权行为的链接发邮件给我们。您的这一举动是对作者权益的保护,也是我们持续为您提供有价值的内容的动力之源。
“异步社区”(www.epubit.com)是由人民邮电出版社创办的IT专业图书社区,于2015年8 月上线运营,致力于优质内容的出版和分享,为读者提供高品质的学习内容,为作译者提供专业的出版服务,实现作者与读者在线交流互动,以及传统出版与数字出版的融合发展。
“异步图书”是异步社区策划出版的精品IT图书的品牌,依托于人民邮电出版社在计算机图书领域30余年的发展与积淀。异步图书面向IT行业以及各行业使用IT技术的用户。
本书开篇将介绍关于C++的一些常见误解并逐一澄清,帮助读者了解C++的发展历程,理解零开销原则。此外,本部分还将深入探讨嵌入式系统中的设计难点,介绍如何用C++解决这些问题。本部分还会涵盖嵌入式C++生态系统相关的内容,并指导读者搭建C++嵌入式项目的开发环境,包括配置工具链、构建系统和模拟器。
本部分包含以下章节:
• 第1章,“破除关于C++的常见误解”;
• 第2章,“资源受限嵌入式系统的挑战”;
• 第3章,“嵌入式C++生态系统”;
• 第4章,“搭建C++嵌入式项目开发环境”。
为微控制器和嵌入式系统编写软件极具挑战性。为了充分利用资源受限的系统,嵌入式开发者需熟练掌握平台架构知识,清楚可用资源(包括处理器性能、可用的内存和外设配置)。半个世纪以来,由于嵌入式系统需要通过内存映射外设直接操作硬件,因此C语言一直是该领域的首选语言。
任何编程语言的目标都是将特定应用的抽象逻辑转化为可编译为机器码的代码。例如,面向商业的通用语言(COBOL)用于银行应用,Fortran 用于科学研究和复杂的数学计算。而 C语言是一种通用的编程语言,常用于操作系统和嵌入式系统应用开发。
C语言语法简洁易懂,但简洁性也导致它难以表达复杂思想。尽管C语言支持复杂操作,但相较于能抽象隐藏底层细节的高级语言,C语言需要更烦琐、细致的代码来管理复杂性。
20 世纪 70 年代末,高级语言的性能无法媲美 C 语言。这促使丹麦计算机科学家 Bjarne Stroustrup开始研发“带类的C语言”(C with Classes)——这便是C++的前身。如今,C++已成为一门兼顾性能设计的多范式语言。但C++的起源仍催生了一些误解,这往往让开发者在嵌入式系统编程中对采用C++犹豫不决。本章将介绍这些误解并逐一澄清,具体涵盖以下内容:
• C++的简要发展史;
• 带类的C语言;
• 代码膨胀与运行时开销。
为充分掌握本章内容,强烈建议阅读示例时使用Compiler Explorer。选择GCC作为编译器,目标架构是x86,这样可以查看标准输出(stdio)结果,更清晰地观察代码行为。
在20世纪60年代中期,仿真编程语言SIMULA将类和对象引入软件开发领域。类是一种抽象机制,能让我们在编程中简洁地表示现实世界中的概念,使代码更易读。在嵌入式开发中,UART、SPI、TemperatureSensor、PidController、TemperatureController等概念都可通过类来实现。SIMULA还引入了类之间的层次关系。例如,PT100类本质上也是TemperatureSensor类,TemperatureController类包含一个TemperatureSensor类的成员实例(对象)和一个PidController类的实例。这种编程方式后来被称为面向对象编程(OOP)。
在回顾编程语言的演变时,C++创始人Bjarne Stroustrup分享了他设计C++的思路。他旨在弥合高级抽象与底层效率之间的鸿沟,并表示:
我的想法非常简单:借鉴SIMULA语言中通用抽象的理念——这种理念能让人类更直观地表示事物,便于理解;同时融合低层开发的能力——当时在这方面表现最佳的是贝尔实验室的Dennis Ritchie设计的C语言。将这两种理念结合起来,就能实现既支持高级抽象,又具备足够高的效率和足够贴近硬件的特性,从而满足那些对性能要求极高的计算任务。
C++最初由Bjarne Stroustrup以“带类的C语言”为起点研发,如今已发展为一门现代化编程语言,同时仍保留直接操作硬件和内存映射外设的能力。借助强大的抽象特性,C++能够编写表达力丰富、模块化程度高的代码。它是一门通用的多范式语言,支持过程式、面向对象编程,在一定程度上还支持函数式编程范式。
尽管C语言仍是嵌入式开发的首选语言(约占嵌入式项目的60%),但C++的采用率正稳步提升。据估计,在嵌入式开发领域中,C++的使用率已达20%~30%,其具备的类、增强的类型安全、编译时计算等特性深受开发者青睐。
尽管C++拥有诸多优势,C语言在嵌入式编程领域依旧占据主导地位。造成这一事实的背后原因众多,本章将针对部分原因展开探讨。C++比C语言更为复杂,这使得它对新手开发者不够友好,而C语言易于学习,能让新手更快参与到项目开发中。
C语言的简洁性固然能让新手快速为项目贡献力量,但也导致复杂逻辑的代码过于冗长。由于缺乏足够的表达力,代码库规模往往会变得庞大——这正是C++的用武之地。C++提供更高级的抽象能力,运用这些能力可使代码更易阅读和理解。
C++未能更广泛普及的其他原因,与关于它的诸多误解有关。仍有不少人认为,C++不过是“带类的C语言”;因标准库中的动态内存分配,C++绝对不适合用于安全关键型系统;或是认为C++会生成冗余代码,增加空间和时间开销。本章将针对嵌入式开发场景下关于C++的一些常见误解进行澄清,打破这些固有认知,为嵌入式系统中的C++应用带来新的视角。
从历史角度来看,C++最初以“带类的C语言”起步。首个C++编译器Cfront会将C++代码转换为C代码,不过这已是很久以前的事了。随着时间的推移,C语言与C++各自独立发展,如今已拥有各自的语言标准。C语言始终保持简洁性,而C++则发展为一门现代化语言,能够在不牺牲性能的前提下,通过抽象方式解决问题。但有时人们仍将C++称作“带类的C语言”,这一说法暗示着C++除了类之外再无其他附加价值。
2011年发布的C++11标准是C++的第二个主版本,纳入了众多让语言现代化的特性,例如范围循环、lambda表达式和常量表达式(constexpr)。后续的C++14、C++17、C++20及C++23标准持续推动语言的现代化,引入的新特性使得“带类的C语言”仅成为现代C++一个遥远的前身。
为了证明C++绝非仅仅是“带类的C语言”,我们来看几个简短的C语言代码示例及其对应的现代C++实现。首先从一个简单的整数缓冲区元素打印示例开始:
#define N 20
int buffer[N];
for(int i = 0; i < N; i ++) {
printf("%d ", buffer[i]);
}上述C代码可以转换为如下C++代码:
std::array<int, 20> buffer;
for(const auto& element : buffer) {
printf("%d ", element);
}首先可以注意到,C++版本的代码更简短,语句更少,且比C语言代码更贴近自然语言(英语),可读性更高。如果你有C语言基础但未接触过高级语言,可能会觉得第一个版本(C代码)更易读,不过我们来详细对比一下。首先,C代码中定义了常量N,用于指定缓冲区大小,这个常量既用来定义缓冲区,也作为for循环的边界条件。
C++11引入的范围for循环,消除了在循环终止条件中使用容器大小的认知负担。大小信息已包含在std::array容器中,范围for循环会利用这一信息轻松遍历数组。此外,代码中无须通过索引访问缓冲区,而是通过常量引用访问元素,确保循环内部不会修改元素的值。
再来看一段简单的C语言代码:将array_a整数数组中所有小于10的元素复制到array_b中:
int w_idx = 0;
for(int i = 0; i < sizeof(array_a)/sizeof(int); i++) {
if(array_a[i] < 10) {
array_b[w_idx++] = array_a[i];
}
}具有相同功能的C++代码如下所示:
auto less_than_10 = [](auto x) -> bool {
return x < 10;
};
std::copy_if(std::begin(array_a), std::end(array_a), std::begin(array_b),
less_than_10);无须手动遍历array_a并仅复制大于10的元素,我们可以使用C++标准模板库中的copy_if函数。std::copy_if的前两个参数是迭代器,用于指定array_a中要处理的元素范围:第一个迭代器指向数组起始位置,第二个迭代器指向最后一个元素的下一个位置。第三个参数是指向array_b起始位置的迭代器,第四个参数是less_than_10 lambda表达式。
lambda表达式是一种匿名函数对象,可在调用位置声明,或作为参数传递给其他函数(第10章将详细讲解lambda)。在std::copy_if的示例中,less_than_10 lambda表达式用于判断array_a中的元素是否需要复制到array_b。我们也可以定义一个独立的less_than_10函数(接收整数参数,若大于10则返回布尔值),但使用lambda可将该功能代码写在传递给算法的位置附近,让代码更简洁、表达力更强。
前文示例中使用了标准库容器std::array,它是一个类模板,封装了C风格的数组及其大小信息(第8章将详细讲解模板)。当你为std::array指定具体的底层类型和大小时,编译器会在实例化过程中定义一个新类型。
std::array<int, 10>会创建一种容器类型,其底层是大小为10的整数型C风格数组;std::array<int, 20>则是底层为大小20的整数型C风格数组的容器类型——两者属于不同类型,尽管底层类型相同,但大小不同。
std::array<float, 10>会生成第三种类型,因为它与std::array<int, 10>的底层类型不同。使用不同的参数会生成不同的类型,模板类型属于泛型类型,仅在实例化后才会成为具体类型。
为了更好地理解并认识泛型类型的价值,我们来分析C语言中环形缓冲区的实现,并与C++中基于模板的解决方案进行对比。
环形缓冲区(又称循环缓冲区)在嵌入式编程中是常用的数据结构,通常通过一组围绕数组的函数实现,借助读写索引访问数组元素,同时用count变量管理数组空间。接口包含入队(push)和出队(pop)函数,功能说明如下。
• 入队(push)函数:用于将元素存入环形缓冲区。每次入队时,数据元素被存入数组,写索引自增;若写索引等于数据数组的元素个数,则重置为0。
• 出队(pop)函数:用于从环形缓冲区取出元素。每次出队时,若底层数组非空,则返回读索引指向的数组元素,随后读索引自增。
每次入队时count变量自增,出队时自减;若count等于数据数组的大小,则需要将读索引向前移动。
我们为C语言中要实现的环形缓冲区定义如下实现要求:
• 不使用动态内存分配;
• 缓冲区满时,覆盖最旧的元素;
• 提供入队(push)和出队(pop)函数,用于存储和读取数据;
• 环形缓冲区存储整数类型的数据。
以下是使用C语言编写的满足上述要求的简洁实现方案:
#include <stdio.h>
#define BUFFER_SIZE 5
typedef struct {
int arr[BUFFER_SIZE]; // Array to store int values directly
size_t write_idx; // Index of the next element to write (push)
size_t read_idx; // Index of the next element to read (pop)
size_t count; // Number of elements in the buffer
} int_ring_buffer;
void int_ring_buffer_init(int_ring_buffer *rb) {
rb->write_idx = 0;
rb->read_idx = 0;
rb->count = 0;
}
void int_ring_buffer_push(int_ring_buffer *rb, int value) {
rb->arr[rb->write_idx] = value;
rb->write_idx = (rb->write_idx + 1) % BUFFER_SIZE;
if (rb->count < BUFFER_SIZE) {
rb->count++;
} else {
// Buffer is full, move read_idx forward
rb->read_idx = (rb->read_idx + 1) % BUFFER_SIZE;
}
}
int int_ring_buffer_pop(int_ring_buffer *rb) {
if (rb->count == 0) {
return 0;
}
int value = rb->arr[rb->read_idx];
rb->read_idx = (rb->read_idx + 1) % BUFFER_SIZE;
rb->count--;
return value;
}
int main() {
int_ring_buffer rb;
int_ring_buffer_init(&rb);
for (int i = 0; i < 10; i++) {
int_ring_buffer_push(&rb, i);
}
while (rb.count > 0) {
int value = int_ring_buffer_pop(&rb);
printf("%d\n", value);
}
return 0;
}我们使用for循环初始化缓冲区。由于缓冲区大小为5,当环形缓冲区覆盖已有数据时,5~9的值将被存储到缓冲区中。但如果我们想在环形缓冲区中存储浮点数、字符或用户自定义的数据结构呢?我们可以为不同类型实现相同的逻辑,创建一组新的数据结构和函数(例如命名为float_ring_buffer或char_ring_buffer)。不过,能否设计一种支持存储不同数据类型、且可复用同一组函数的解决方案呢?
我们可以用无符号字符(unsigned char)数组作为不同数据类型的存储载体,通过void指针向入队(push)和出队(pop)函数传递不同类型的数据。唯一缺少的是数据类型的大小信息,这一点可以通过在ring_buffer结构体中添加一个size_t类型的elem_size成员来解决。
#include <stdio.h>
#include <string.h>
#define BUFFER_SIZE 20 // Total bytes available in the buffer
typedef struct {
unsigned char data[BUFFER_SIZE]; // Array to store byte values
size_t write_idx; // Index of the next byte to write
size_t read_idx; // Index of the next byte to read
size_t count; // Number of bytes currently used in the buffer
size_t elem_size; // Size of each element in bytes
} ring_buffer;
void ring_buffer_init(ring_buffer *rb, size_t elem_size) {
rb->write_idx = 0;
rb->read_idx = 0;
rb->count = 0;
rb->elem_size = elem_size;
}
void ring_buffer_push(ring_buffer *rb, void *value) {
if (rb->count + rb->elem_size <= BUFFER_SIZE) {
rb->count += rb->elem_size;
} else {
rb->read_idx = (rb->read_idx + rb->elem_size) % BUFFER_SIZE;
}
memcpy(&rb->data[rb->write_idx], value, rb->elem_size);
rb->write_idx = (rb->write_idx + rb->elem_size) % BUFFER_SIZE;
}
int ring_buffer_pop(ring_buffer *rb, void *value) {
if (rb->count < rb->elem_size) {
// Not enough data to pop
return 0;
}
memcpy(value, &rb->data[rb->read_idx], rb->elem_size);
rb->read_idx = (rb->read_idx + rb->elem_size) % BUFFER_SIZE;
rb->count -= rb->elem_size;
return 1; // Success
}
int main() {
ring_buffer rb;
ring_buffer_init(&rb, sizeof(int)); // Initialize buffer for int values
for (int i = 0; i < 10; i++) {
int val = i;
ring_buffer_push(&rb, &val);
}
int pop_value;
while (ring_buffer_pop(&rb, &pop_value)) {
printf("%d\n", pop_value);
}
return 0;
}该环形缓冲区方案可存储不同的数据类型,由于我们避免了使用动态内存分配,且数据缓冲区的大小是在编译时确定的,因此在为环形缓冲区的不同实例定义所需内存大小这一环节,我们无法做到灵活适配。另一个问题是类型安全不足。我们可能轻易地将浮点数指针传入ring_buffer_push函数,却将整数指针传入ring_buffer_pop函数。编译器无法检测这类问题,在实际应用中极易引发严重错误。此外,使用空指针会增加一层间接访问——从数据缓冲区读取数据时需依赖内存操作才能完成。
在C语言中能否解决类型安全问题,同时支持自定义环形缓冲区大小呢?我们可以利用宏的标记粘贴运算符(##),为不同类型和大小生成一组专属的函数。在通过该技术实现环形缓冲区之前,先快速看一个标记粘贴运算符的简单使用示例:
#include <stdio.h>
// Macro to define a function for summing two numbers
#define DEFINE_SUM_FUNCTION(TYPE) \
TYPE sum_##TYPE(TYPE a, TYPE b) { \
return a + b; \
}
// Define sum functions for int and float
DEFINE_SUM_FUNCTION(int)
DEFINE_SUM_FUNCTION(float)
int main() {
int result_int = sum_int(5, 3);
printf("Sum of integers: %d\n", result_int);
float result_float = sum_float(3.5f, 2.5f);
printf("Sum of floats: %.2f\n", result_float);
return 0;
}调用DEFINE_SUM_FUNCTION(int)会生成一个sum_int函数,该函数接受整数参数并返回整数结果;若传入float调用该宏,则会生成sum_float函数。既然我们已理解了标记粘贴运算符的用法,接下来继续讲解环形缓冲区的实现:
#include <stdio.h>
#include <string.h>
// Macro to declare ring buffer type and functions for a specific type and size
#define DECLARE_RING_BUFFER(TYPE, SIZE) \
typedef struct { \
TYPE data[SIZE]; \
size_t write_idx; \
size_t read_idx; \
size_t count; \
} ring_buffer_##TYPE##_##SIZE; \
void ring_buffer_init_##TYPE##_##SIZE(ring_buffer_##TYPE##_##SIZE *rb) { \
rb->write_idx = 0; \
rb->read_idx = 0; \
rb->count = 0; \
} \
void ring_buffer_push_##TYPE##_##SIZE(ring_buffer_##TYPE##_##SIZE *rb,
TYPE value) { \
rb->data[rb->write_idx] = value; \
rb->write_idx = (rb->write_idx + 1) % SIZE; \
if (rb->count < SIZE) { \
rb->count++; \
} else { \
rb->read_idx = (rb->read_idx + 1) % SIZE; \
} \
} \
int ring_buffer_pop_##TYPE##_##SIZE(ring_buffer_##TYPE##_##SIZE *rb, TYPE
*value) { \
if (rb->count == 0) { \
return 0; /* Buffer is empty */ \
} \
*value = rb->data[rb->read_idx]; \
rb->read_idx = (rb->read_idx + 1) % SIZE; \
rb->count--; \
return 1; /* Success */ \
}
// Example usage with int type and size 5
DECLARE_RING_BUFFER(int, 5) // Declare the ring buffer type and functions
for integers
int main() {
ring_buffer_int_5 rb;
ring_buffer_init_int_5(&rb); // Initialize the ring buffer
// Push values into the ring buffer
for (int i = 0; i < 10; ++i) {
ring_buffer_push_int_5(&rb, i);
}
// Pop values from the ring buffer and print them
int value;
while (ring_buffer_pop_int_5(&rb, &value)) {
printf("%d\n", value);
}
return 0;
}现在,这种解决方案解决了类型安全和环形缓冲区大小定义的问题,但无论是在实现还是使用层面,可读性都较差。我们需要在任何函数之外“调用”DECLARE_RING_BUFFER,因为它本质上是一个定义了一组函数的宏。我们还需要知道它的作用以及它将生成的函数的签名。使用模板,我们可以做得更好。让我们看看C++中环形缓冲区的实现是什么样子的。
让我们使用模板来实现一个通用的环形缓冲区。我们可以使用std::array类模板作为底层类型,并在其周围封装入队(push)和出队(pop)逻辑。以下代码展示了C++中ring_buffer类型的可能样子:
#include <array>
#include <cstdio>
template <class T, std::size_t N> struct ring_buffer {
std::array<T, N> arr;
std::size_t write_idx = 0; // Index of the next element to write (push)
std::size_t read_idx = 0; // Index of the next element to read (pop)
std::size_t count = 0; // Number of elements in the buffer
void push(T t) {
arr.at(write_idx) = t;
write_idx = (write_idx + 1) % N;
if (count < N) {
count++;
} else {
// buffer is full, move forward read_idx
read_idx = (read_idx + 1) % N;
}
}
T pop() {
if (count == 0) {
// Buffer is empty, return a default-constructed T.
return T{};
}
T value = arr.at(read_idx);
read_idx = (read_idx + 1) % N;
--count;
return value;
}
bool is_empty() const { return count == 0; }
};
int main() {
ring_buffer<int, 5> rb;
for (int i = 0; i < 10; ++i) {
rb.push(i);
}
while (!rb.is_empty()) {
printf("%d\n", rb.pop());
}
return 0;
}C++中使用模板实现的环形缓冲区,比C语言中基于标记粘贴运算符的方案更易读、更易用。ring_buffer模板类可实例化出多种环形缓冲区类型,支持整数、浮点数或其他任意底层类型,且能灵活指定不同大小。同一套入队(push)和出队(pop)逻辑可复用在不同底层类型的环形缓冲区上——借助模板,我们能将“避免重复”(DRY)原则应用于多种类型。模板让泛型的实现变得简洁易行,而这在C语言中实现起来既烦琐又极具挑战性。
模板还可用于模板元编程(TMP),这是一种让编译器使用模板来生成临时源代码的编程技术,生成的代码会与其他源代码合并后再编译。模板元编程最经典的示例之一是在编译时计算阶乘,这项高级技术将在第8章详细讲解。现代C++还引入了constexpr说明符,为编译时计算提供了更适合新手的实现方式。
C++11引入了constexpr说明符,用于声明函数或变量的值可在编译时计算。该说明符历经多个版本的演进,功能不断扩展。constexpr变量必须立即初始化,且其类型必须是字面量(literal)类型(如int、float等)。以下是constexpr变量的声明方式:
constexpr double pi = 3.14159265359;
在C++中,使用constexpr说明符声明编译时常量,是优于C风格宏定义的首选方式。我们通过一个C风格宏定义的简单示例来分析二者的差异:
#include <cstdio>
#define VOLTAGE 3300
#define CURRENT 1000
int main () {
const float resistance = VOLTAGE / CURRENT;
printf("resistance = %.2f\r\n", resistance);
return 0;
}这个简单程序的输出可能令人惊讶:
resistance = 3.00
VOLTAGE和CURRENT都会被解析为整数字面量,它们的除法结果也是整数字面量。浮点字面量需要用后缀f声明,而本例中省略了这一后缀。使用constexpr定义编译时常量更安全,因为它允许我们指定常量的类型。以下是用constexpr改写的相同示例:
#include <cstdio>
constexpr float voltage = 3300;
constexpr float current = 1000;
int main () {
const float resistance = voltage / current;
printf("resistance = %.2f\r\n", resistance);
return 0;
}这将产生如下结果:
resistance = 3.30
这个简单的例子表明,与传统的C风格宏常量相比,constexpr编译时常量更安全也更易读。constexpr说明符的另一主要用途是向编译器提示某个函数可以在编译时进行求值。constexpr函数必须满足的部分要求如下:
• 返回类型必须是字面量类型;
• 每个函数参数必须是字面量类型;
• 如果constexpr函数不是构造函数,则它必须恰好有一个return语句。
让我们看一个使用constexpr函数的简单示例:
int square(int a) {
return a*a;
}
int main () {
int ret = square(2);
return ret;
}为了更好地理解底层发生的事情,我们来查看上述代码的汇编输出。汇编代码非常接近机器码,也就是将在目标设备上执行的指令,因此查看汇编代码可以让我们大致了解处理器要执行的操作(指令数量)。下面是使用ARM GCC编译器在无优化情况下,为ARM架构编译上述程序得到的汇编输出:
square(int):
push {r7}
sub sp, sp, #12
add r7, sp, #0
str r0, [r7, #4]
ldr r3, [r7, #4]
mul r3, r3, r3
mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr
main:
push {r7, lr}
sub sp, sp, #8
add r7, sp, #0
movs r0, #2
bl square(int)
str r0, [r7, #4]
ldr r3, [r7, #4]
mov r0, r3
adds r7, r7, #8
mov sp, r7
pop {r7, pc}生成的汇编代码执行以下操作:
• 操作栈指针;
• 调用square函数;
• 将r0寄存器返回的值存储到r7寄存器指向地址的偏移量4处;
• 从r7寄存器指向地址的偏移量4处加载值到r3寄存器;
• 将r3寄存器中的值移至r0寄存器(根据ARM调用约定,r0是用于存储返回值的指定寄存器)。
可以看到,输出的二进制文件中存在一些不必要的操作,这些操作既增加了二进制文件的大小,也会影响性能。这个示例代码在C和C++中都有效,用C编译器和C++编译器编译都会生成相同的汇编代码。
如果我们为square函数添加constexpr说明符,就相当于告知编译器该函数可以在编译时进行求值:
constexpr int square(int a) {
return a*a;
}
int main() {
constexpr int val = square(2);
return ret;
}这段代码会在编译时对square(2)表达式进行求值,使val整数成为constexpr变量(即编译时常量)。以下是生成的汇编代码:
main:
push {r7}
sub sp, sp, #12
add r7, sp, #0
movs r3, #4
str r3, [r7, #4]
movs r3, #4
mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr从汇编代码可以看出,程序直接返回值4,这正是square(2)编译时计算的结果。生成的汇编代码中没有square函数,仅包含编译器预先计算好的结果。这个简单示例充分展现了编译时计算的强大:只要已知所有计算参数(在实际开发中很常见),就能将繁重的计算从运行时转移到编译时。这种方式可用于生成查找表或复杂的数学信号,本书后续章节将对此展开演示。
从“带类的C语言”发展至今,C++已取得了巨大进步。本章示例清晰呈现了C++相较于C语言的优势:代码更具表达力、可读性更强且更简洁拥有标准模板库容器、算法、用户自定义泛型类型以及编译时计算能力等。希望通过这些内容,能够打破“C++不过是带类的C语言”这一误解。下一个关于C++的常见误解是“C++会生成冗余代码并增加运行时开销”,让我们继续澄清这一误解!
“膨胀软件”(bloatware)一词指设备操作系统中预装的多余软件。在编程领域,“多余代码”指由框架、库或语言结构本身插入二进制文件中的代码。C++中被认为会导致代码膨胀的语言结构包括构造函数、析构函数和模板。我们将通过分析C++代码生成的汇编输出来分析这些误解。
非C++开发者提到C++时,首先想到的往往是它是一门面向对象语言,必须实例化对象。对象是类的实例,是占用内存的变量。用于构造或实例化对象的特殊函数称为构造函数。
构造函数用于初始化对象(包括类成员的初始化),析构函数用于清理资源,两者与对象的生命周期紧密相关。对象通过构造函数创建,当对象变量超出作用域时,则会调用析构函数。
构造函数和析构函数都会增加二进制文件的大小并带来运行时开销,因为它们的执行需要时间。我们来看一个简单示例,分析构造函数和析构函数的影响。这个示例包含一个带有私有成员、构造函数、析构函数和getter的类:
class MyClass
{
private:
int num;
public:
MyClass(int t_num):num(t_num){}
~MyClass(){}
int getNum() const {
return num;
}
};
int main () {
MyClass obj(1);
return obj.getNum();
}MyClass是一个非常简单的类,包含一个私有成员(通过构造函数初始化),我们可以通过getter访问该成员。为了演示,我们还声明了一个空的析构函数。以下是在未启用优化的情况下,上述代码编译生成的汇编等效代码:
MyClass::MyClass(int) [base object constructor]:
push {r7}
sub sp, sp, #12
add r7, sp, #0
str r0, [r7, #4]
str r1, [r7]
ldr r3, [r7, #4]
ldr r2, [r7]
str r2, [r3]
ldr r3, [r7, #4]
mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr
MyClass::~MyClass() [base object destructor]:
push {r7}
sub sp, sp, #12
add r7, sp, #0
str r0, [r7, #4]
ldr r3, [r7, #4]
mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr
MyClass::getNum() const:
push {r7}
sub sp, sp, #12
add r7, sp, #0
str r0, [r7, #4]
ldr r3, [r7, #4]
ldr r3, [r3]
mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr
main:
push {r4, r7, lr}
sub sp, sp, #12
add r7, sp, #0
adds r3, r7, #4
movs r1, #1
mov r0, r3
bl MyClass::MyClass(int) [complete object constructor]
adds r3, r7, #4
mov r0, r3
bl MyClass::getNum() const
mov r4, r0
nop
adds r3, r7, #4
mov r0, r3
bl MyClass::~MyClass() [complete object destructor]
mov r3, r4
mov r0, r3
adds r7, r7, #12
mov sp, r7
pop {r4, r7, pc}如果看不懂汇编代码也没关系,我们能看到其中包含一些函数标签和大量指令。对于一个简单的类抽象来说,这些指令实在过多——这就是我们不希望二进制文件中出现的膨胀代码。更具体地说,这段汇编代码共有59行。如果启用编译器优化,生成的汇编代码只会有几行,但我们先基于未优化的情况继续分析这个问题。首先能注意到,析构函数没有执行任何有用操作;如果从C++代码中删除析构函数,生成的汇编代码会减少到44行:
MyClass::MyClass(int) [base object constructor]:
push {r7}
sub sp, sp, #12
add r7, sp, #0
str r0, [r7, #4]
str r1, [r7]
ldr r3, [r7, #4]
ldr r2, [r7]
str r2, [r3]
ldr r3, [r7, #4]
mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr
MyClass::getNum() const:
push {r7}
sub sp, sp, #12
add r7, sp, #0
str r0, [r7, #4]
ldr r3, [r7, #4]
ldr r3, [r3]
mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr
main:
push {r7, lr}
sub sp, sp, #8
add r7, sp, #0
adds r3, r7, #4
movs r1, #1
mov r0, r3
bl MyClass::MyClass(int) [complete object constructor]
adds r3, r7, #4
mov r0, r3
bl MyClass::getNum() const
mov r3, r0
nop
mov r0, r3
adds r7, r7, #8
mov sp, r7
pop {r7, pc}从结果可以看出,二进制文件中不再有析构函数的调用指令,也没有析构函数的代码。这传递出一个核心原则:不使用则不付费,这是C++的设计原则之一。删除析构函数后,编译器无须为其生成任何代码,也不会在对象变量超出作用域时调用它。
我们还需要明确一点:C++并非纯面向对象(OOP)语言,而是一门多范式语言。它同时支持过程式、面向对象、泛型编程,甚至在一定程度上支持函数式编程。如果需要私有成员且只能通过构造函数初始化,那么就需要为此付出对应的代价。而C++中的结构体(struct)默认成员为公有,因此我们可以将MyClass类改为无构造函数的MyClass结构体:
struct MyClass
{
int num;
};
int main () {
MyClass obj(1);
return obj.num;
}setter和getter函数在面向对象编程范式中很常见,但C++并非(仅)是面向对象语言,我们不必受限于使用这两种函数。当删除getNum getter后,示例简化为仅含一个成员的基础结构体,生成的汇编代码仅14行:
main:
push {r7}
sub sp, sp, #12
add r7, sp, #0
movs r3, #1
str r3, [r7, #4]
ldr r3, [r7, #4]
mov r0, r3
adds r7, r7, #12
mov sp, r7
ldr r7, [sp], #4
bx lr尽管这个示例看似简单,但其目的是确立两个基本事实:
• 不使用则不付费;
• 使用C++并不意味着必须绑定到面向对象编程范式。
如果我们想使用构造函数和析构函数等抽象机制,就需要在二进制大小上付出相应代价。在C++中,使用类型(类和结构体)而不实例化对象,能为嵌入式软件设计带来超越传统面向对象方法的显著优势,我们将在后续章节通过详细示例探讨这一点。
在本节及之前的示例中,我们编译C++代码时禁用了优化,因此能看到生成的汇编代码包含可被移除的不必要操作。让我们看看最后一个示例在启用O3优化级别后的汇编代码:
main:
movs r0, #1
bx lr上述汇编是包含类、构造函数、析构函数和getter函数的原始示例的输出结果。最终程序只有两条指令:obj变量的num成员值作为返回值存储在r0寄存器中。汇编代码中移除了所有与栈操作相关的指令,以及使用r3在栈指针偏移4处存储值、重新加载到r3再移至r0的操作,最终汇编仅几行代码。
移除不必要的指令是优化过程的工作。然而,嵌入式项目中经常会避免优化,因为有人认为它会破坏代码。但事实果真如此吗?
未优化的代码会产生不必要的指令,影响二进制大小和性能。然而,许多嵌入式项目仍在禁用优化的情况下构建,因为开发者不信任编译器,担心它会破坏程序。这种说法有一定道理,但事实证明,这种情况只会出现在格式不良的程序中。而程序一旦包含未定义行为,就属于格式不良的范畴。
最著名的未定义行为示例之一是有符号整数溢出。标准没有定义当你在平台上对有符号整数的最大值加1时会发生什么。编译后的程序无须执行任何有意义的操作,这样的程序是不规范的。让我们分析以下代码:
#include <cstdio>
#include <limits>
int foo(int x) {
int y = x + 1;
return y > x;
}
int main() {
if(foo(std::numeric_limits<int>::max())) {
printf("X is larger than X + 1\r\n");
}
else {
printf("X is NOT larger than X + 1. Oh nooo !\r\n");
}
return 0;
}使用GCC为x86和Arm Cortex-M4架构编译这段代码,结果会完全一致。若程序在编译时未启用优化,foo函数会返回0,输出中会显示“X is NOT larger than X + 1. Oh nooo !”。此时编译器会执行整数溢出操作,当我们向foo传入最大整数值时,函数会返回0。但需注意,标准并未规定这种行为,其结果完全取决于编译器。
若启用优化编译程序,输出会变为“X is larger than X + 1”,这意味着foo函数返回1。让我们分析启用优化后程序的汇编输出:
foo(int):
movs r0, #1
bx lr
.LC0:
.ascii "X is larger then X + 1\015\000"
main:
push {r3, lr}
movw r0, #:lower16:.LC0
movt r0, #:upper16:.LC0
bl puts
movs r0, #0
pop {r3, pc}从汇编代码可以看出,foo函数并未执行任何计算。编译器假定程序是格式良好的(well formed),不存在未定义行为,因此foo函数总会返回1。确保程序中没有未定义行为是开发者的责任——这也正是“优化会破坏程序”这一误解依然存在的原因:人们更容易将问题归咎于编译器没有处理未定义的行为。
当然,编译器确实可能存在bug,导致启用优化时程序功能异常,而禁用优化时程序能正常工作。这种情况虽罕见但并非没有,这也是为什么需要单元测试和集成测试等验证技术,以确保无论是否启用优化,代码功能都能正常运行。
优化通过从机器码中移除不必要的指令来减小二进制文件大小并提升性能。未定义的行为具有编译器依赖性,必须由开发者处理以确保程序是格式良好的。应采用单元测试和集成测试等技术验证程序功能,缓解编译器导致程序异常的风险。优化过程对于在C++代码中使用抽象机制同时保持最小二进制的大小和最佳性能至关重要。本书后续示例将使用最高优化级别O3。
接下来我们要介绍的代码膨胀“嫌疑人”是模板。模板如何导致代码膨胀,它们又能为嵌入式代码库带来什么价值?
用不同参数实例化模板会导致编译器生成不同的类型,这确实会增大二进制文件的大小,这在预期之中。在C语言中使用标记粘贴运算符和宏实现泛型环形缓冲区时,也会出现完全相同的情况。另一种方案是类型擦除(type erasure),就像我们在C语言实现中使用void指针那样,但这会在限制静态数据分配时损失灵活性,且因指针间接访问而影响性能。
使用泛型类型是一种设计选择。我们可以使用它们并接受二进制文件大小增加的代价,但即便为不同数据类型分别实现环形缓冲区(如ring_buffer_int、ring_buffer_float等),也会导致同样的结果。维护单一的模板类型比在代码库的多个地方修复相同的bug要容易得多。使用泛型类型产生的二进制文件大小,与为每种类型单独实现的等效代码的大小相比,并不会更大。让我们以ring_buffer为例,分析模板对二进制大小的影响,并与单独实现进行对比:
int main() {
#ifdef USE_TEMPLATES
ring_buffer<int, 10> buffer1;
ring_buffer<float, 10> buffer2;
#else
ring_buffer_int buffer1;
ring_buffer_float buffer2;
#endif
for (int i = 0; i < 20; i++) {
buffer1.push(i);
buffer2.push(i + 0.2f);
}
for (int i = 0; i < 10; i++) {
printf("%d, %.2f\r\n", buffer1.pop(), buffer2.pop());
}
return 0;
}如果定义了USE_TEMPLATES宏,程序会使用泛型ring_buffer类型,否则会使用ring_buffer_int和ring_buffer_float类型。若使用GCC在未启用优化的情况下编译该示例,模板版本的二进制文件会稍大(24字节),这是因为使用模板版本时符号表中的符号更长。如果从目标文件中剥离符号表,两种版本的大小会完全相同。此外,以O3优化级别编译的两个版本,二进制大小也完全一致。
泛型类型并不会比手动将各种类型单独实现为独立类型,造成更大的二进制文件大小。由于需要在不同编译单元中实例化具体类型,模板会影响构建时间,但必要时也有技术可以避免这一问题。具有相同参数的实例化类型所关联的所有函数,最终在二进制文件中只会保留一个,因为链接器会移除重复的符号。
C++中的运行时类型信息(RTTI)是一种允许在运行时确定对象类型的机制。大多数编译器利用虚函数表实现RTTI。每个多态类(至少包含一个虚函数的类)都有一个虚函数表,其中包含用于运行时类型识别的类型信息。RTTI会带来时间和空间的开销:若使用类型识别功能,会增加二进制大小并影响运行时性能。这也是编译器提供RTTI禁用选项的原因。让我们看一个包含基类和派生类的简单示例:
#include <cstdio>
struct Base {
virtual void print () {
printf("Base\r\n");
}
};
struct Derived : public Base {
void print () override {
printf("Derived\r\n");
}
};
void printer (Base &base) {
base.print();
}
int main() {
Base base;
Derived derived;
printer(base);
printer(derived);
return 0;
}该程序的输出结果如下所示:
Base Derived
包含虚函数的类会拥有用于动态分发的虚表(vtable)。动态分发是选择多态函数具体实现的过程:printer函数接受Base类的引用,根据传入的引用类型(Base或Derived),动态分发会从对应的类中选择print方法执行。虚表同时也用于存储类型信息。
作为RTTI机制的一部分,通过dynamic_cast,我们可以利用超类的引用或指针获取对象的具体类型信息。让我们修改上一个示例中的printer方法:
void printer (Base &base) {
base.print();
if(Derived *derived = dynamic_cast<Derived*>(&base); derived!=nullptr)
{
printf("We found Base using RTTI!\r\n");
}
}输出结果如下:
Base Derived We found Base using RTTI!
如前所述,RTTI是可以禁用的。在GCC中,我们可以通过向编译器传递-fno-rtti标志来实现。如果尝试使用该标志编译修改后的示例,编译器会抛出错误:dynamic_cast' not permitted with '-fno-rtti'(使用'-fno-rtti'时不允许'dynamic_cast')。如果我们将printer方法恢复到原始实现,移除if语句,然后分别在启用和禁用RTTI的情况下编译,会发现启用RTTI时二进制文件更大。RTTI在某些场景下很有用,但会给资源受限的设备带来显著开销,因此通常会禁用它。
嵌入式项目中另一个常被禁用的C++特性是异常(exception)。异常是一种基于try-catch块的错误处理机制。让我们通过一个使用异常的简单示例来更好地理解它:
#include <cstdio>
struct A {
A() { printf("A is created!\r\n"); }
~A() { printf("A is destroyed!\r\n"); }
};
struct B {
B() { printf("B is created!\r\n"); }
~B() { printf("B is destroyed!\r\n"); }
};
void bar() {
B b;
throw 0;
}
void foo() {
A a;
bar();
A a1;
}
int main() {
try {
foo();
} catch (int &p) {
printf("Catching an exception!\r\n");
}
return 0;
}该程序的输出结果如下所示:
A is created! B is created! B is destroyed! A is destroyed! Catching an exception!
在这个简单的示例中,foo函数在try块中被调用。它创建局部对象a并调用bar函数,bar函数创建局部对象b后抛出异常。从输出中能看到,A和B被创建,随后B被销毁,接着A被销毁,最后catch块执行。这一过程称为栈展开,该过程的标准实现通常通过展开表(unwind table)完成,展开表存储了异常处理程序、待调用的析构函数等信息。展开表可能会变得庞大复杂,既增加应用程序的内存占用,又会因运行时异常处理机制引入不确定性——这也是嵌入式系统项目中常禁用异常的原因。
C++遵循零开销原则,唯一不遵循该原则的两个语言特性是RTTI和异常,因此编译器支持通过开关关闭它们。
零开销原则基于本章确立的两个核心观点:
• 不使用则不付费;
• 所用功能的效率,与你合理手动编写的代码效率相当。
大多数嵌入式项目会禁用RTTI和异常,因此不会为这些特性付出开销。使用泛型类型和模板是一种设计选择,其开销并不比手动编写单个类型(如ring_buffer_int、ring_buffer_float等)更大,却能让代码逻辑在不同类型间复用,提升代码的可读性和可维护性。
开发高风险系统并不是禁用编译器优化的理由,在编译程序时无论是否启用优化,都需要验证代码功能。启用优化时,bug最常见的来源是未定义行为,理解并避免未定义行为是开发者的责任。
现代C++能为嵌入式领域带来诸多价值。本书的宗旨是帮助你探索C++,以及它能为你的嵌入式项目提供哪些助力,现在就让我们开启探索之旅,学习如何运用C++解决嵌入式领域的问题。
下一章中,我们将探讨资源受限嵌入式系统面临的挑战,以及C++中的动态内存管理相关内容。