概述(Overview)
在一个裸机(bare-metal)环境下,我们要展示C++ 的高效使用方法。
这里涉及几个核心问题:
- 为什么硬件交互(HW interactions)常用 C 语言?
- 历史原因:C 语言自 1970s 以来一直被用于系统编程和嵌入式开发。硬件厂商提供的寄存器访问库、启动代码(startup code)、中断向量表等,几乎都基于 C。
- 特性匹配:C 语言提供低开销、可预测的内存布局,适合直接操作硬件寄存器。例如,访问一个 32 位寄存器通常写成:
REG32=0x01; REG32 = 0x01;REG32=0x01; - 编译器支持:几乎所有嵌入式编译器对 C 的支持最成熟,生成的汇编最优化且可控。
- 从 C++ 调用 C 的最佳实践是什么?
在裸机开发中,我们常常需要在 C++ 中使用已有的 C 库或硬件驱动。最佳实践包括:- 使用
extern "C"防止名字修饰
这样可以保证 C++ 编译器调用 C 函数时,不会对函数名进行 name mangling,从而正确链接。extern"C"{#include"c_driver.h"} - 封装 C 接口:不要直接在业务代码中调用 C API,而是通过 C++ 封装一层类或函数。例如:
classTimer{public:voidstart(){c_timer_start();}voidstop(){c_timer_stop();}}; - 使用类型安全(type-safe)和 RAII:C++ 可以通过构造函数/析构函数保证资源正确释放,避免裸指针或手动管理错误。
- 使用
- 如何用 C++ 让硬件访问更清晰、安全和可测试?
- 封装寄存器访问
使用类封装寄存器,每个寄存器字段用位域或 getter/setter 访问。例:structReg{volatileuint32_tvalue;voidset_bit(intpos){value|=(1u<<pos);}voidclear_bit(intpos){value&=~(1u<<pos);}boolread_bit(intpos)const{returnvalue&(1u<<pos);}}; - 使用模板提高复用性
可以写通用寄存器类,模板化地址或字段宽度:template<uintptr_t addr>classReg32{public:staticvoidwrite(uint32_tval){*reinterpret_cast<volatileuint32_t*>(addr)=val;}staticuint32_tread(){return*reinterpret_cast<volatileuint32_t*>(addr);}}; - 抽象接口便于测试
将硬件访问抽象成接口,测试时可以替换为虚拟实现:classIHW{public:virtualvoidwrite(uint32_tval)=0;virtualuint32_tread()=0;};classMockHW:publicIHW{uint32_tdata=0;voidwrite(uint32_tval)override{data=val;}uint32_tread()override{returndata;}}; - 类型安全和范围检查
C++ 可以在编译期或运行期检查寄存器值合法性,减少错误。例如:
assert(value<=MAXREGVALUE); assert(value <= MAX_REG_VALUE);assert(value<=MAXREGVALUE);
- 封装寄存器访问
总结
- C 语言仍然是裸机硬件交互的主力,但 C++ 可以通过封装、类型安全、模板和抽象,让硬件访问更安全、清晰、可测试。
- 最佳实践:
extern "C"+ C++ 封装 + RAII + 模板化寄存器访问 + 接口抽象。
为什么 C 语言如此普及(Why is C so prolific?)
- 它是操作系统内核的语言
- “It’s the kernel, silly!” 直译就是:“它是内核语言,傻瓜!”
- C 语言从 UNIX 内核开始就被广泛使用,几乎所有现代操作系统的内核都以 C 为主。
- 由于操作系统内核对性能和可控性要求极高,C 语言的低开销、直接访问硬件能力正好满足需求。
- 历史原因
- 30 年前,所有低层次交互都是用 C 实现的。
- 当时嵌入式系统、驱动程序、启动代码等几乎完全依赖 C。
- 例如访问硬件寄存器通常写作:
REG32=0x01; REG32 = 0x01;REG32=0x01; - 这类直接内存操作在 C 中非常自然,并且编译器生成的汇编可预测。
- 组织的习惯与信任
- 许多组织在嵌入式应用中对 C 语言非常熟悉,已有大量成熟库、驱动和工具链。
- 因此团队在开发新项目时,倾向沿用已验证的 C 代码,而不是冒险切换到 C++。
- 对 C++ 的顾虑
- 在资源受限的环境(如微控制器、裸机系统)中,许多人仍然对 C++ 保持谨慎态度。
- 原因包括:
- 运行时开销(如虚函数表、异常处理、RTTI 等)
- 编译器生成代码的不确定性
- 对堆栈和内存占用的担忧
因此,即便 C++ 提供更高的抽象能力和类型安全性,很多嵌入式团队仍优先选择 C。
总结
C 语言之所以普及,是因为它:
- 是操作系统内核和裸机开发的历史基石
- 易于直接操作硬件
- 组织习惯与成熟工具链的依赖
- C++ 在资源受限环境中的成本和复杂性仍让人谨慎
C++ 的优势(What are the advantages of C++?)
显而易见的问题:为什么要用 C++?
C++ 相比 C 提供了更多的语言特性和安全保障,主要优势包括:
- 生命周期管理(Lifetime Management)
- C++ 可以通过构造函数和析构函数自动管理对象生命周期,减少手动释放资源的错误。
- RAII(Resource Acquisition Is Initialization,资源获取即初始化)模式是 C++ 管理资源的核心,例如:
这样可以保证文件总是被正确关闭,无论函数如何返回。classFileWrapper{FILE*f;public:FileWrapper(constchar*path){f=fopen(path,"r");}~FileWrapper(){if(f)fclose(f);}};
- 类型安全(Type Safety)
- C++ 提供更严格的类型检查,减少因类型错误导致的 bug。
- 例如:
inta=5;doubleb=a;// 自动转换,安全char*p=reinterpret_cast<char*>(&a);// 明确转换,强制风险 - 对比 C,C++ 在编译期可以捕捉更多类型相关的错误。
- 复杂且强类型系统(Sophisticated Strong Typing System)
- 支持模板、枚举类(enum class)、
constexpr等特性。 - 可以在编译期做更多检查和计算,提高性能和安全性。
- 示例:
enumclassColor{Red,Green,Blue};Color c=Color::Red;// 不能直接与整数比较,增强类型安全
- 支持模板、枚举类(enum class)、
- 继承与多态(Inheritance and Polymorphism)
- 支持面向对象编程(OOP),便于抽象硬件接口、复用代码和实现多态行为。
- 示例:
classIHW{public:virtualvoidwrite(uint32_tval)=0;virtualuint32_tread()=0;};classMockHW:publicIHW{voidwrite(uint32_tval)override{/* mock */}uint32_tread()override{return0;}}; - 这种方式使测试更简单、安全,并且代码更可维护。
- 仍可能需要 C 风格操作
即便使用 C++,在裸机环境中,有时仍需要:- 调用内核函数(Kernel functions)
- 使用现有 C API(Existing APIs)
- 转换到原始指针(raw pointers)
但是,关键在于:不要让 C++ 的优势被危险的 C 风格代码抵消。 - 也就是说,即便写裸指针、手动管理内存,也应尽量用 RAII、封装和类型安全机制保护代码。
总结
C++ 的优势主要体现在:
- 自动管理对象生命周期(RAII)
- 更严格的类型安全
- 强大且复杂的类型系统
- 支持面向对象抽象(继承与多态)
为什么不重写所有的 C 代码(Why not rewrite all the C code?)
- 代码量巨大(There’s LOTS of code out there!)
- 全球已有数十年累积的 C 语言代码库,包括操作系统、驱动、嵌入式库等。
- 这些代码经过长期验证,非常稳定且被广泛使用。
- 重写代价高(To rewrite a modest sized C code base would take years!)
- 即使是中等规模的 C 项目,完全用 C++ 重写也可能需要数年时间。
- 重写过程中可能引入新的 bug,风险高且成本大。
- Linux 内核重写几乎不可行(Rewriting the Linux kernel is likely intractable)
- Linux 内核是数百万行 C 代码组成的复杂系统。
- 试图完全用 C++ 重写,不仅工程量巨大,而且兼容性、性能和稳定性难以保证。
- 因此(Ergo):
- 必须继续使用 C
- 关键问题是:如何在继续使用 C 的同时,有效结合 C++ 的最佳实践。
- 即在裸机或嵌入式开发中,可以这样处理:
- 用 C++ 封装 C 接口
- 使用 RAII、类型安全和模板等特性保护代码
- 保留底层 C 函数和 API,但通过接口抽象提高安全性和可测试性
- 参考资料(See also Stroustrup, 2023)
- Bjarne Stroustrup(C++ 之父)在 2023 年的著作中,也提到:
- 不要盲目重写已有 C 代码
- 优先考虑封装和现代 C++ 的增量改进策略
- Bjarne Stroustrup(C++ 之父)在 2023 年的著作中,也提到:
总结
- C 代码库庞大且稳定,重写代价高且风险大
- Linux 内核等关键项目几乎不可能完全重写
- 策略:继续使用 C,同时用 C++ 提升安全性、可维护性和可测试性
C++ 关键安全特性(Key C++ Safety Features)
C++ 提供了很多安全特性,让开发者在编译期或运行期尽量避免错误,但主要关注以下几个方面:
- 生命周期管理(Lifetime Management)
- C++ 可以通过智能指针(smart pointers)自动管理对象的生命周期,减少手动调用
new/delete的错误。 - 常用的智能指针类型包括:
std::unique_ptr<T>:独占所有权std::shared_ptr<T>:共享所有权std::weak_ptr<T>:弱引用,不增加引用计数
- 示例:
std::unique_ptr<int>p=std::make_unique<int>(42);// 不需要手动 delete,p 超出作用域时自动释放内存 - 核心优势:避免内存泄漏和悬挂指针。
- C++ 可以通过智能指针(smart pointers)自动管理对象的生命周期,减少手动调用
- 严格类型(Strict Typing)
- C++ 有丰富的语言元素,能在编译阶段阻止许多类型错误。
- 示例:
- 枚举类(
enum class)防止隐式转换 - 模板参数类型检查
const/constexpr修饰符保证值不可修改
- 枚举类(
- 示例代码:
enumclassColor{Red,Green,Blue};Color c=Color::Red;// 不能直接与整数比较,编译器报错 - 通过这些机制,可以在编译期捕捉错误,减少运行时异常。
- 标准测试(Standard Tests)
- C++ 标准库提供了许多比 libc 更丰富的测试工具,帮助保证程序安全性。
- 示例:
std::is_same_v<T1, T2>:类型检查std::is_base_of<Base, Derived>:继承关系检查- 算法库中的
std::all_of、std::any_of等可以安全操作容器
- 这些工具可以在编译期或运行期检查逻辑,保证程序正确性。
总结
C++ 的关键安全特性包括:
- 生命周期管理
- 使用智能指针(如
unique_ptr<T>)管理资源,防止内存泄漏
- 使用智能指针(如
- 严格类型检查
- 利用语言特性在编译期捕捉类型错误
- 标准库测试工具
- 提供比 C libc 更丰富的检查和工具,提高程序安全性
C/C++ 边界的简单交互(Naive crossing of the C/C++ boundary)
在 C++ 中,智能指针非常强大,但在与 C 接口交互时要格外小心。
- 智能指针的基本概念(Smart Pointers are GREAT!)
- 智能指针负责自动管理对象生命周期,避免手动调用
delete导致的内存泄漏或悬挂指针。 - 例如:
std::unique_ptr<T>my_t_ptr=std::make_unique<T>(); my_t_ptr拥有对象的唯一所有权,当它超出作用域时,所管理的对象会被自动释放。
- 智能指针负责自动管理对象生命周期,避免手动调用
- 访问底层裸指针(Underlying Reference / Naked Pointer)
- 每个智能指针内部都有一个裸指针,可以通过
get()方法访问:T*raw_ptr=my_t_ptr.get(); - 注意:
get()返回的是裸指针,但智能指针仍然拥有对象的所有权。
- 每个智能指针内部都有一个裸指针,可以通过
- 调用 C 接口(Crossing the C/C++ boundary)
- 在需要传递给 C 函数时,可以使用
get()获取裸指针:my_c_api(my_t_ptr->get()); - 这里的裸指针被 C 函数使用,但智能指针仍然管理对象的生命周期。
- 在需要传递给 C 函数时,可以使用
- 继续使用裸指针是否安全?(Then we can use the result, right?)
- 有人可能想直接在后续函数继续使用裸指针:
next_function(my_t_ptr->get()); - 答案:也许(Maybe)
- 原因:
- 如果
my_c_api或其他函数内部释放了裸指针指向的对象,或者改变了对象的所有权,后续使用就可能悬挂或未定义行为。 - 即使 C 函数没有释放,跨函数使用裸指针也可能存在生命周期被破坏的风险,尤其在异常、返回早退或多线程环境下。
- 如果
- 有人可能想直接在后续函数继续使用裸指针:
总结
- 智能指针在 C++ 中管理对象生命周期
get()可用于调用 C 接口,但必须明确谁拥有对象的所有权- 不能随意假设裸指针在整个程序中一直有效
- 使用 C 接口时,要特别注意生命周期和所有权管理
智能指针跨 C/C++ 边界(Smart pointers across the C/C++ boundary)
在 C++ 与 C 接口交互时,智能指针提供了更安全的方式来管理对象,但需要注意潜在风险。
1. 使用场景(Use case)
- 在 C++ 中分配对象,需要通过C API传递给底层 C 代码
- C 代码处理对象后返回结果
- 关键问题是如何在 C++ 中安全地管理对象所有权
2. 风险(Risks)
- 同步问题(Synchronization Problem)
- 底层 C 代码可能修改了裸指针
- 如果 C++ 端继续直接使用裸指针,可能导致悬挂指针
- 内存安全问题(Memory Safety Problem)
- C 代码可能删除并重新分配内存
- 智能指针仍然认为自己拥有旧的对象,从而可能导致双重释放或未定义行为
3. 标准库解决方案(Standard Library Helper)
C++ 标准库提供了语法糖(syntactic sugar)来安全地跨 C/C++ 边界使用智能指针:
std::inout_ptr- 用于输入/输出参数,允许 C 函数修改裸指针,同时保持智能指针管理权
std::out_ptr- 用于仅输出参数,用于 C 函数创建对象并返回给智能指针
4. 示例(Example: make_unique into C API and back)
std::unique_ptr<T>my_t_ptr=std::make_unique<T>();// 传入 C API,C 代码可以修改指针my_c_api(std::inout_ptr(my_t_ptr));// 后续函数也安全使用修改后的指针next_function(std::inout_ptr(my_t_ptr));- 这里
std::inout_ptr(my_t_ptr)会自动管理裸指针的所有权 - 即使 C 代码修改了指针,智能指针仍然保证对象生命周期安全
总结
- 直接使用
get()传递裸指针有风险 - 使用
std::inout_ptr/std::out_ptr可以安全地跨 C/C++ 边界 - 智能指针与标准库工具结合,可以避免悬挂指针和双重释放等常见问题
out_ptr 和 inout_ptr 的工作原理(What’s really going on with out_ptr and inout_ptr?)
在 C++ 智能指针与 C 接口交互时,std::out_ptr和std::inout_ptr是用于安全传递裸指针的工具。
1. 底层原理(Underlying mechanism)
- 任何智能指针内部都有一个裸指针(raw pointer)或引用
out_ptr和inout_ptr在调用 C 函数时,会把这个裸指针传给 C 函数:T*raw_ptr=my_smart_ptr.get();my_c_api(raw_ptr);- C 函数可以读取或修改指针内容,但智能指针仍然管理对象的生命周期。
2. inout_ptr 的特性
inout_ptr用于输入/输出参数- 当 C 函数返回时,
inout_ptr会将智能指针重置(reset)为返回的新指针值- 这相当于下面的操作:
std::unique_ptr<T>my_t_ptr=std::make_unique<T>();T*my_raw_t_ptr=my_t_ptr->get();// 获取裸指针my_c_api(my_raw_t_ptr);// 调用 C 函数my_t_ptr->reset(my_raw_t_ptr);// 将智能指针重置为返回值next_function(my_t_ptr->get());// 安全使用智能指针管理的对象
- 这相当于下面的操作:
- 这样可以保证即使 C 函数修改了指针,智能指针依然正确管理生命周期,避免内存泄漏或悬挂指针。
3. out_ptr 的特性
out_ptr用于仅输出参数- C 函数负责创建对象,并将裸指针返回给智能指针
- 智能指针会在函数返回后接管新分配对象的所有权
- 例如:
std::unique_ptr<T>my_t_ptr;my_c_api(std::out_ptr(my_t_ptr));// C 函数分配对象// my_t_ptr 自动接管对象生命周期
- 例如:
4. 总结
- 智能指针底层是裸指针
inout_ptr和out_ptr都安全地传递裸指针给 C 函数inout_ptr会在返回时重置智能指针,保持生命周期管理- 使用这两个工具可以避免手动 reset,减少悬挂指针和内存泄漏风险
智能指针的get()函数(The smart pointer get function is a value)
在 C++ 中,智能指针提供了get()方法用于访问其底层裸指针,但需要理解它的行为特性。
1.get()返回的是值(get() returns a value)
- 当调用
get()时,返回的是裸指针的拷贝,即指针地址的副本:pointerget()constnoexcept;pointer是裸指针类型,如T*const noexcept表示不会修改智能指针本身,也不会抛异常
- 示例:
std::unique_ptr<T>my_ptr=std::make_unique<T>();T*raw_ptr=my_ptr.get();// 这是一个拷贝 - 注意:
raw_ptr只是智能指针管理对象的裸指针拷贝,它并不拥有对象的生命周期。
2. 潜在问题(Potential Issues)
- 如果底层 C 代码修改了指针,例如:
- 移动了指针(pointer moved)
- 删除并重新分配了对象(deleted and re-allocated)
- 那么智能指针的
get()返回的裸指针就会失效,变成悬挂指针(stale pointer):
T* stale_ptr = my_ptr.get(); // 如果 C 删除或重新分配,stale_ptr 不再有效 \text{T* stale\_ptr = my\_ptr.get(); // 如果 C 删除或重新分配,stale\_ptr 不再有效}T* stale_ptr = my_ptr.get(); //如果C删除或重新分配,stale_ptr不再有效 - 这种情况下,继续使用
stale_ptr会导致未定义行为(undefined behavior),可能崩溃或内存错误。
3. 安全实践
- 避免直接使用
get()后在 C++ 端长期保留裸指针 - 跨 C/C++ 边界时,应使用
std::inout_ptr/std::out_ptr,保证智能指针在 C 函数返回后正确更新内部指针my_c_api(std::inout_ptr(my_ptr));// 安全,内部自动 reset
总结
get()返回裸指针的拷贝,不改变智能指针所有权- 如果 C 代码删除或修改了底层对象,
get()返回的指针可能失效(stale) - 跨 C/C++ 边界时推荐使用
inout_ptr/out_ptr来安全管理对象生命周期
细节很重要(Details matter)
在裸机开发或与硬件交互时,不能让编译器做“奇怪的优化”,特别是内存布局和访问顺序相关的操作。
1. 编译器可能的重排(Memory Layout Reordering)
- 编译器为了优化性能,可能会重排类或结构体中的成员变量的内存顺序
- 例如:
classFoo{private:int32_ta;public:int32_tb;int32_tc;}; - 在这个例子中,
a是私有成员,而b和c是公有成员 - 编译器可能根据访问权限和对齐规则重排内存,以提高访问效率
- 比如把
b放在前面,a放在后面
- 比如把
- 这种重排在裸机或与硬件直接映射寄存器的场景下可能会导致严重错误
2. 为什么会发生重排
- 多个访问修饰符(access specifiers)可能让编译器认为成员间没有严格顺序依赖
- 对齐(alignment)和填充(padding)规则也可能导致内存顺序变化
- 例如在 32 位系统上,编译器可能按 4 字节对齐排列成员变量
- 注意:编译器在某些情况下也可能不做重排,但不能完全依赖这种行为
3. 安全实践
- 对于硬件寄存器或需要固定内存布局的结构体,使用
[[gnu::packed]]或#pragma pack等手段保证顺序:struct__attribute__((packed))FooPacked{int32_ta;int32_tb;int32_tc;}; - 这样可以确保内存布局与代码顺序一致,避免编译器优化带来的潜在问题
总结
- 编译器可能重排类/结构体的内存布局
- 在裸机或硬件访问场景中,这可能导致严重问题
- 解决方法:
- 使用
packed属性或#pragma pack保证布局 - 避免依赖编译器默认顺序
- 使用
C++ 在 C 边界的其他指导原则(Other C++ guidance at the C boundary)
在裸机或嵌入式开发中,C++ 对象如果需要跨 C 边界使用,需要遵循一些严格规则,以保证内存布局和行为兼容性:
- 使用相同的访问控制(Use same access control)
- 所有成员的访问权限(
public/protected/private)必须一致,避免编译器优化引起内存顺序变化。
- 所有成员的访问权限(
- 对象或基类不能有虚函数(No virtual in the object or any base class)
- 虚函数会引入虚函数表(vtable),改变对象内存布局,不兼容 C 结构。
- 最派生类不能有非静态数据成员(No non-static data members in the most derived class)
- 防止派生类增加额外成员,破坏基类在 C 中预期的内存布局。
- 所有基类必须是标准布局(All base class(es) are standard layout)
- 通过 C++ 标准的
std::is_standard_layout可以检测
- 通过 C++ 标准的
- 对象的所有成员也必须遵守这些规则
- 否则内存布局可能在 C++ 和 C 之间不一致
参考:Fertig (2020)
如何测试内存兼容性(How to test for memory compatibility)
为了保证对象在 C 和 C++ 中兼容,需要满足两个条件:
- 平凡类型(Trivial)
- 用
std::is_trivial<T>检查 - 平凡类型支持静态初始化(static initialization),并且默认构造、拷贝、移动不会引入额外逻辑
std::is_trivial<Foo>::value⇒类型可静态初始化 \text{std::is\_trivial<Foo>::value} \Rightarrow \text{类型可静态初始化}std::is_trivial<Foo>::value⇒类型可静态初始化
- 标准布局(Standard Layout)
- 用
std::is_standard_layout<T>检查 - 确保对象在 C++ 中的内存布局与 C 兼容
std::is_standard_layout<Foo>::value⇒类型在 C/C++ 中布局一致 \text{std::is\_standard\_layout<Foo>::value} \Rightarrow \text{类型在 C/C++ 中布局一致}std::is_standard_layout<Foo>::value⇒类型在C/C++中布局一致 - 组合这两个条件,可以安全地将 C++ 对象用于 C API 或裸机寄存器映射。
摆脱内核束缚(Break free of the kernel)
- 使用 C 的原因:内核使用 C
- 裸机嵌入式系统实际上做的事情和内核类似
- 思路:可以参考内核方式,但不必完全受限
- 可以使用 C++ 封装和安全特性
- 仍然遵循内存布局和生命周期管理规则
总结
- 跨 C 边界使用 C++ 对象时要遵守严格规则(访问控制、无虚函数、标准布局等)
- 使用
std::is_trivial和std::is_standard_layout检查内存兼容性 - 裸机开发参考内核设计,但可以利用 C++ 提供的安全性和封装能力
摆脱内核束缚(Break free of the kernel)
- 现状:我们使用 C 是因为内核使用 C
- 裸机嵌入式本质上做的事情类似内核操作
- 误区:直接模仿内核,全部使用 C
- 正确做法:
- C++ 提供封装、类型安全、RAII 等优势
- 应该充分利用 C++ 特性,而不是完全依赖 C
简单的硬件访问封装(Naive HW access)
- 第一原则:将所有硬件访问隐藏在函数内部,不暴露裸指针或寄存器地址
- 示例:UART 波特率寄存器封装
classmy_uart{public:voidset_baud_rate(constuint32_t&rate){*BAUD_RATE_REG=rate;// 写寄存器}uint32_tget_baud_rate()const{return*BAUD_RATE_REG;// 读寄存器}}; - 优点:
- 对寄存器的直接访问被封装
- 其他代码无需关心底层寄存器地址
- 提高可维护性和安全性
Pimpl 设计模式(pimpl Idiom)
- pimpl = pointer to implementation
- 通过指针将实现细节与接口分离
classmy_class{private:std::unique_ptr<my_class_impl>p_impl;}; - 为什么使用这种模式?
- 实现细节的修改不会影响类定义
- 调用者无需重新编译
- 减少依赖和编译时间,提高封装性和灵活性
- 核心思想:
- 类接口固定
- 内部实现可以自由修改
- 智能指针管理实现对象的生命周期
总结
- 不要盲目模仿内核,C++ 有自己的优势
- 硬件访问封装:用函数封装寄存器访问,避免裸指针泄露
- Pimpl 模式:分离接口与实现,修改实现不影响调用者,智能指针管理对象生命周期
Pimpl 启发的寄存器访问(pimpl inspired register access)
- Pimpl:指向实现的指针(pointer to implementation)
- 问题:为什么不直接用寄存器集合的指针?
1. 定义寄存器结构体(Register Set)
structmy_uart_regs{volatileuint32_tBAUD_RATE_REG;// ... 其他寄存器};- 使用
volatile修饰,防止编译器优化访问寄存器的代码 my_uart_regs封装所有 UART 寄存器
2. 在类中使用寄存器指针(Pointer to Register Set)
classmy_uart{private:std::unique_ptr<my_uart_regs>p_regs;};- 类中保存一个智能指针,指向寄存器集合
- 好处:将硬件寄存器访问抽象成对象
3. 为什么使用寄存器指针?(Why use a pointer to a register set?)
- 增加可测试性(Make more testable code)
- 通过构造函数注入寄存器指针:
classmy_uart{public:my_uart(std::unique_ptr<my_uart_regs>regs):p_regs(std::move(regs)){}private:std::unique_ptr<my_uart_regs>p_regs;};- 好处:
- 可以传入任意可转换为
my_uart_regs的对象,或者继承自my_uart_regs的对象 - 便于替换测试桩(test harnesses)
- 无需修改类本身,即可测试不同寄存器实现
- 可以传入任意可转换为
- 这种模式类似Pimpl 模式,只是指针指向寄存器集合而非内部实现
4. 核心思想
- 类接口固定,内部实现(寄存器集合)可替换
- 利用智能指针管理寄存器集合生命周期
- 便于单元测试或模拟寄存器行为
参考:CppCon 2023 关于HookableRegister的讲座
总结
- 使用
std::unique_ptr<my_uart_regs>代替直接访问裸寄存器,提高封装性 - 构造函数注入寄存器指针,使代码可替换、可测试
- 类似 Pimpl 思路:接口与实现(寄存器集合)分离
如何定义寄存器集合的结构体(How to define the struct of the register set)
在裸机开发中,需要一个与硬件寄存器内存布局完全一致的结构体。关键点包括顺序、对齐、填充以及数据宽度。
1. 寄存器顺序(Registers in order)
- 寄存器顺序必须与硬件手册一致
- 如果寄存器之间有空闲空间,需要显式使用填充(padding)占位
2. 数据宽度与修饰(Data width, packed, aligned, volatile)
- 不要改变数据宽度:每个寄存器的位宽必须与硬件一致
- 使用
volatile:防止编译器优化寄存器访问
volatile uint32_t reg; \text{volatile uint32\_t reg;}volatile uint32_t reg; - 使用
packed:避免编译器在结构体成员间插入额外填充 - 使用
aligned(4):确保结构体按 4 字节对齐
3. 示例代码(Example)
typedefstruct__attribute__((packed))__attribute__((aligned(4))){volatileuint32_treg1;// Offset 0volatileuint32_treg2;// Offset 4volatileuint32_tpad[4];// Padding 占位volatileuint32_treg3;// Offset 20// ...}regs_t;reg1和reg2是连续寄存器pad[4]占用空闲空间,保证reg3在正确的偏移量(Offset 20)- 结构体严格匹配硬件布局,保证裸机访问安全
4. 总结
- 定义寄存器结构体时要保证:
- 顺序一致:与硬件寄存器顺序相同
- 填充:空闲空间用数组或占位符补齐
- 宽度不变:每个寄存器位宽与硬件一致
volatile修饰:防止编译器优化packed与aligned:保证结构体内存布局与硬件一致
- 这样可以安全地使用结构体直接映射硬件寄存器,同时便于测试和封装。
1. 管理智能指针(Managing the smart pointer)
- 初始化智能指针
- 通过接口可以将原始指针初始化到智能指针中,例如:
uint32_tmy_regs_addr=0x10D030000;regs_t*my_regs_raw_ptr=reinterpret_cast<regs_t*>(my_regs_addr);std::unique_ptr<regs_t>p_regs;p_regs.reset(my_regs_raw_ptr); - 这里
reinterpret_cast<regs_t*>将一个整数地址转换为指针类型。 reset()方法将原始指针交给智能指针管理。
- 通过接口可以将原始指针初始化到智能指针中,例如:
- 注意潜在的崩溃风险
- 当智能指针
p_regs离开作用域时,它会自动调用deleter去释放所管理的对象。 - 如果指针指向的是硬件寄存器地址或非堆对象,直接调用
delete会导致程序崩溃。
- 当智能指针
2. 关于unique_ptr的注意事项(A note about unique_ptr)
- 直觉上的声明
- 我们通常认为
unique_ptr声明如下:template<typenameT>classunique_ptr{...}; - 意思是:
unique_ptr只管理类型T的对象,并在析构时自动释放。
- 我们通常认为
- 实际声明
- 实际上,
unique_ptr的声明更接近:template<typenameT,typenameDeleter=std::default_delete<T>>classunique_ptr{...}; - 这里多了一个deleter 类型参数:
Deleter默认为std::default_delete<T>,即普通的delete操作。- 用户可以自定义 deleter,例如针对特殊内存或硬件寄存器。
- 实际上,
- 智能指针崩溃的根源
- 当
unique_ptr管理的对象不是普通堆分配的对象时,默认 deleter (delete) 会错误地尝试释放它,导致程序崩溃。 - 解决方法:
- 使用自定义 deleter。
- 或者不使用
unique_ptr管理非堆对象。
- 当
3. 总结
unique_ptr的自动管理机制依赖于deleter。- 默认 deleter 对普通堆对象有效,对特殊内存(如硬件寄存器、内存映射 I/O)可能导致崩溃。
- 当管理非堆对象时,必须提供合适的 deleter:
std::unique_ptr<T, CustomDeleter> p(obj); \text{std::unique\_ptr<T, CustomDeleter> p(obj);}std::unique_ptr<T, CustomDeleter> p(obj);
1.shared_ptr与unique_ptr的自定义 deleter 区别
- 常规智能指针构造
- 我们平时习惯这样创建智能指针:
std::shared_ptr<T>my_ptr=rhs; - 这意味着当指针离开作用域时,会自动调用默认 deleter(
delete),释放所管理的对象。
- 我们平时习惯这样创建智能指针:
- 潜在问题
- 当对象不是普通堆对象(例如硬件寄存器或静态内存)时,默认 deleter 会在作用域结束时调用
delete,导致程序崩溃。 - 因此需要告诉语言“不要删除该对象”,即使用自定义 deleter。
- 当对象不是普通堆对象(例如硬件寄存器或静态内存)时,默认 deleter 会在作用域结束时调用
2. 自定义 deleter 的使用
unique_ptr的自定义 deleterstd::unique_ptr<T,D>my_uniqueptr=rhs;- 这里
D是自定义 deleter 类型。 - 可以定义一个“不执行删除操作”的 deleter,避免作用域结束时自动 delete。
- 这里
shared_ptr的自定义 deleterstd::shared_ptr<T>my_sharedptr(ref,D);ref是指向对象的原始指针。D是自定义 deleter 类型,同样可以让shared_ptr离开作用域时不删除对象。
- 危险提示
- 自定义 deleter 是危险操作(
dangerous!),一定要确保不会错误释放非堆对象。
- 自定义 deleter 是危险操作(
3. no_deleter 示例
- 一个“不删除对象”的 deleter:
structno_deleter{voidoperator()(T*ptr){// 什么也不做}}; - 注意事项:
- 不要模板化
no_deleter:- 错误写法:
template<T>structno_deleter{...}; - 正确写法:直接用具体类型或通过类型别名绑定。
- 错误写法:
- 这样
unique_ptr<T, no_deleter>或shared_ptr<T>在离开作用域时不会调用 delete。
- 不要模板化
4. 总结
- 默认 deleter 会在作用域结束时调用
delete:
std::unique_ptr<T> p(ptr); ⟹ delete ptr on scope exit \text{std::unique\_ptr<T> p(ptr);} \implies \text{delete ptr on scope exit}std::unique_ptr<T> p(ptr);⟹delete ptr on scope exit - 当对象不是普通堆对象时,需要自定义 deleter:
struct no_deleter void operator()(T* ptr) ; \text{struct no\_deleter { void operator()(T* ptr) {} };}struct no_deletervoid operator()(T* ptr); - 对于
unique_ptr和shared_ptr:unique_ptr<T, D>:在模板参数中绑定 deleter类型Dshared_ptr<T>:在构造函数中传入 deleter对象D
- 使用自定义 deleter可以安全管理非堆对象,但需谨慎操作,避免内存泄漏或非法释放。
1. 目标
在“纯 C++”中对硬件寄存器(memory-mapped registers)进行访问,同时安全管理指针,避免智能指针在作用域结束时尝试释放硬件寄存器。
这里采用unique_ptr+ 自定义 deleter的方式来安全访问硬件寄存器。
2. 定义硬件寄存器结构体
- 定义一个结构体表示硬件 UART 寄存器:
structmy_uart_regs__attribute__((packed))__attribute__((aligned(4))){volatileuint32_tBAUD_RATE_REG;// 其他寄存器};- 说明:
__attribute__((packed)):避免编译器对结构体进行填充(padding),保证寄存器布局与硬件一致。__attribute__((aligned(4))):保证对齐,通常硬件寄存器需要按 4 字节对齐。volatile:- 防止编译器对寄存器的读写进行优化。
- 确保每次访问都会真正读取/写入硬件。
3. 自定义 deleter(不释放硬件)
structuart_regs_no_deleter{voidoperator()(my_uart_regs*ptr){// 什么也不做}};- 作用:
unique_ptr在离开作用域时默认会调用delete,而这里的寄存器地址是硬件地址,不能释放。- 自定义 deleter 确保不做任何释放操作。
4. 封装为类my_uart
classmy_uart{public:my_uart(uintptr_t base_addr):p_regs(reinterpret_cast<my_uart_regs*>(base_addr)){// 初始化操作(如果有)}voidset_baud_rate(constuint32_t&rate){p_regs->BAUD_RATE_REG=rate;}uint32_tget_baud_rate()const{returnp_regs->BAUD_RATE_REG;}private:std::unique_ptr<my_uart_regs,uart_regs_no_deleter>p_regs;};关键点说明
- 构造函数:
p_regs(reinterpret_cast<my_uart_regs*>(base_addr))- 将硬件地址转换为指向寄存器结构体的指针,并由
unique_ptr管理。 - 由于使用了自定义 deleter
uart_regs_no_deleter,智能指针不会尝试释放硬件寄存器。
- 成员函数:
set_baud_rate和get_baud_rate直接操作寄存器:
p_regs->BAUD_RATE_REG = rate; \text{p\_regs->BAUD\_RATE\_REG = rate;}p_regs->BAUD_RATE_REG = rate;
return p_regs->BAUD_RATE_REG; \text{return p\_regs->BAUD\_RATE\_REG;}return p_regs->BAUD_RATE_REG;- 保证了对硬件寄存器的安全、清晰访问。
- 指针成员:
std::unique_ptr<my_uart_regs, uart_regs_no_deleter> p_regs;- 智能指针管理寄存器指针生命周期,同时避免非法 delete。
5. 总结
- 硬件寄存器访问在 C++ 中可以通过结构体映射 +
volatile+ 对齐实现。 - 智能指针可以管理寄存器指针的生命周期,但需要自定义 deleter避免释放硬件。
- 对寄存器操作的函数封装(如
set_baud_rate/get_baud_rate)保证接口安全、易用。 - 这个模式的核心思路:
unique_ptr<寄存器类型, 不释放 deleter> p_regs(base_addr); \text{unique\_ptr<寄存器类型, 不释放 deleter> p\_regs(base\_addr);}unique_ptr<寄存器类型,不释放deleter> p_regs(base_addr);
然后通过成员函数访问硬件寄存器。
1. 基本位操作(Bit Manipulation)
在 C++ 中,可以像在 C 语言里一样对寄存器进行位操作,例如:
uint32_tvalue=0;value|=0x1;// 将第 0 位设为 1value&=~(0x1);// 将第 0 位清零理解
value |= 0x1;- 按位或操作(OR)
- 把
value的第 0 位设置为 1。 - 数学上可以写作:
value←value ∣ 0x1 \text{value} \gets \text{value} \ | \ 0x1value←value∣0x1
value &= ~(0x1);- 按位与操作(AND) + 取反
- 将
value的第 0 位清零,而其他位保持不变。 - 数学上可以写作:
value←value & ∼0x1 \text{value} \gets \text{value} \ \& \ \sim 0x1value←value&∼0x1
2. C++ 的优势:强类型和constexpr
虽然以上操作在 C++ 中完全可用,但 C++ 提供了更安全的做法:
- 强类型(Strong Typing):
- 通过类型系统区分不同寄存器、不同位字段,减少误操作。
- 例如:
structControlReg{uint32_tenable:1;uint32_tmode:3;uint32_tunused:28;};ControlReg reg{};reg.enable=1;// 比直接写 value |= 0x1 更安全
constexpr优化:- 可以在编译期进行位操作计算,减少运行时开销。
- 例如:
constexpruint32_tFLAG=1<<0;constexpruint32_tCLEAR_MASK=~FLAG; - 数学上:
FLAG=1≪0 \text{FLAG} = 1 \ll 0FLAG=1≪0
CLEAR_MASK=∼FLAG \text{CLEAR\_MASK} = \sim \text{FLAG}CLEAR_MASK=∼FLAG
3. 总结
- C 风格位操作依然可用:
- OR (
|=) 设置位 - AND + NOT (
&=~) 清位
- OR (
- **C++ 强类型 +
constexpr**提供:- 类型安全:避免误操作错误地修改寄存器的其他位。
- 编译期计算:减少运行时开销,提高性能。
- 实际寄存器操作可以结合
unique_ptr+ 自定义 deleter管理硬件地址,再结合强类型位域和constexpr安全访问。
1. 摒弃#define的习惯
原始宏定义问题
传统 C 风格的宏定义使用#define,例如:
#defineREPLACE_BITS(x,mask,bits)((x&(~(mask)))|(bits&mask))理解
- 宏定义危险性:
- 宏在预处理阶段展开,没有类型检查。
- 容易出现命名冲突或逻辑错误。
- 例如操作位时,可能意外修改了不相关的位。
- 常见情况:
- 在遗留代码中仍能看到很多通过
#define定义的位操作宏。
- 在遗留代码中仍能看到很多通过
2. 使用std::bitset替代宏
C++ 提供了强类型位操作方法,可以用std::bitset封装宏逻辑:
template<std::size_t N>std::bitset<N>replace_bits(std::bitset<N>x,std::bitset<N>mask,std::bitset<N>bits){return(x&(~mask))|(bits&mask);}理解
- 使用
std::bitset<N>:- 类型安全:操作在编译期检查位宽。
- 可读性高:替代宏后,代码逻辑更清晰。
- constexpr 友好:可在编译期求值。
- 上述函数逻辑与宏类似,但更安全:
x′=(x & (∼mask)) ∣ (bits & mask) x' = (x \ \& \ (\sim mask)) \ | \ (bits \ \& \ mask)x′=(x&(∼mask))∣(bits&mask)
3. 利用constexpr进行位操作
示例:
std::bitset<32>my_bits=0x3433;constexprstd::bitset<32>fourteen=0xE;constexprstd::bitset<32>good_bits=fourteen+1;// 编译期可计算理解
- 字面值安全使用:
- 使用
constexpr和std::bitset,可以在编译期完成位运算。 - 避免了传统宏或浮点字面值中的“最大匹配(maximal munch)”问题,例如:
constexprstd::bitset<32>bad_bits=0xE+1;// 编译失败 - 正确方式:
constexprstd::bitset<32>good_bits=fourteen+1;// 编译成功
- 使用
std::bit_cast:- 可将浮点数转换为整数位表示:
std::bit_cast<std::uint32_t>(1.0f);
- 可将浮点数转换为整数位表示:
- 优势总结:
- 类型安全,避免错误位操作。
- 编译期求值,无需运行时开销。
- 可读性强,取代宏的魔法数字。
4. 总结
- 摒弃传统
#define位操作宏,使用强类型constexpr函数。 - 使用
std::bitset封装位操作逻辑,保证类型安全。 - 利用
constexpr和std::bit_cast可在编译期完成复杂位操作。 - 避免“最大匹配”问题和宏展开带来的潜在危险。
关于字节序(Endianness)
在裸机开发和跨平台通信中,字节序问题经常出现,需要根据具体情况进行字节交换(byte swap)。
1. 交换字节的常见原因
- 网络字节序(Network endianness)到处理器字节序(Processor endianness)
- 网络协议通常采用大端(Big Endian)
- 某些处理器可能是小端(Little Endian)
- 需要进行字节交换以正确解析数据
- 密码学字节序(Cryptographic endianness)到处理器字节序
- 某些加密算法规定字节序
- 与处理器默认字节序不一致时需要交换
- 硬件 BUG(HW Bugs)
- 硬件可能返回错误字节序的数据
- 必须在软件层修正
2. C++23 提供的解决方案
- C++23 提供了
std::byteswap,支持constexpr - 示例:
autoswapped=std::byteswap(my_integer);- 在循环中也可以对数组进行字节翻转(byte swizzle):
std::uint16_tarray[]={...};for(autoa:array){lhs=std::byteswap(a);}- 优势:
- 编译期常量表达式(
constexpr)可计算 - 统一、标准化字节交换函数
- 编译期常量表达式(
C++26 展望(Upcoming in C++26)
- 饱和算术(Saturated Arithmetic)
add_sat(uint32_tx,uint32_ty);- 自动处理溢出(rollover)检查
- 不必手动写边界判断
- 静态反射(Static Reflection)
- 提供更多元数据和编译期操作能力
- 示例用途:
- 枚举转字符串(enum to string)
- 按索引访问成员(members by index)
- 提升测试性和编译期检查能力
总结(Wrapping up)
- 开发者在 C 中为了安全做了很多工作
- C++ 的优势:
- 强类型系统:减少类型错误
- 生命周期管理:智能指针等特性自动管理资源
- 编译器提供更多静态检查,减少手动错误
- 静态分析依然重要,但 C++ 可以替你做更多工作
- 实践示例:Altera 已经在使用这种方式,鼓励大家借鉴