跳到主要内容C++
CppCon 2024 学习:C++ 异常在小型固件中的应用
综述由AI生成探讨了在资源受限的 MCU 嵌入式系统中使用 C++ 异常的可行性与优化策略。文章首先分析了 MCU 的限制(无 OS、内存小、无动态内存),解释了为何传统嵌入式开发避免异常和动态分配,并介绍了 ETL 和 std::inplace_vector 等替代方案。随后,文章深入剖析了 C++ 异常在 ARM Cortex-M 上的巨大开销(空间、时间、动态内存依赖),并通过四个 Barrier(禁用异常、二进制膨胀、动态分配、RTTI)的突破方案,展示了如何通过覆盖弱符号、禁用 RTTI、使用静态内存分配等方式,使异常在嵌入式环境下变得可行且高效。最后,对比了异常与错误码在大型项目中的优劣,指出在大规模工程中,经过优化的异常机制可能比返回码模式更节省空间且更高效。
DockerOne5.8K 浏览 1. Motivation(动机)
微控制器限制:没有操作系统(No OS)
在 STM32、ESP32、NRF52 等 MCU 上通常没有操作系统。
这意味着很多 PC 上常见的 API 不存在:
你不能用这些:
fopen()
std::chrono::steady_clock::now()
std::print()
std::thread
- 网络 API (sockets)
原因很简单:
- MCU 没有文件系统 ⇒ 不能
fopen()
- MCU 没有高精度系统时钟 ⇒
steady_clock 不可用
- MCU 没有操作系统线程调度 ⇒
std::thread 不存在
- MCU 没有系统调用(syscall) ⇒ 标准 I/O、网络不可用
MCU 的资源极其有限
行业数据告诉你,大部分 MCU 的 flash 和 RAM 都非常小:
- Flash < 1 MB 的设备:33725 个
- Flash ≥ 1 MB:只有 4964 个
更极端:
- Flash < 100 KB 的设备:31098 个
- Flash ≥ 100 KB 的设备:只有 7095 个
也就是说,大部分 MCU 是:
Flash 64KB ~ 512KB RAM 8KB ~ 128KB
你不可能像 PC 那样做事。
2. 为什么要避免动态内存?
MCU 上动态内存(new/malloc)的问题
❶ 内存很小,经不起碎片化
假设 MCU RAM 总共 64KB,动态分配反复产生碎片:
[#####][ ][#####][##][ ]
最终你可能'还有 10KB 空闲',但没有连续块 ⇒ 分配失败。
❷ 分配时间无法保证(非确定性)
malloc() / new 的执行时间是不可预测的:
t_malloc = 不确定,可长可短
在实时系统(RTOS)或者中断环境中,这是不能接受的。
❸ 可能崩溃导致系统挂死
由于 MCU 没有操作系统,没有内存保护(MMU),越界或分配失败就会 直接死机、硬 fault。
3. 典型最佳实践:不要用动态分配
所以嵌入式行业通用的 best practice:
避免 new / malloc / free / delete / STL 容器
因为它们内部会触发动态分配。
不要用:
free()
std::stringstd::vectorstd::unordered_mapstd::shared_ptrstd::unique_ptr(本身不分配,但托管对象通常是 on heap)4. 那不用 STL 怎么办?
C++ 标准库很多容器在内部使用 动态分配(heap):
std::vector:内部用 realloc
std::string:内部动态扩容
std::map:节点动态分配
std::unordered_map:动态桶扩容
这些对于 MCU 来说都 不可接受。
5. 解决方案:Embedded Template Library(ETL)
所有数据结构都固定大小(编译期确定)
你必须在使用前定义最大容量。
例如,用 ETL 的 etl::vector< int, 32 >
这表示:
- 最大容量 = 32
- 永不动态分配内存
- 所有空间在编译期/静态区/栈上分配
与 STL 的对比:
| Feature | STL vector | ETL vector |
|---|
| 内存位置 | heap | stack/static |
| 是否动态扩容 | 是 | 否 |
| 最大容量可变 | 是 | 否(固定) |
| 适合 MCU? | 否 | 是 |
6. 为什么说 ETL 更适合 MCU?
确定性(Deterministic)
可预测的内存占用
占用的 RAM 是:
100 * sizeof(int)
绝对不会再多。
兼容 C++ 模板 & 现代功能
虽然很多 MCU 编译器只到 C++03,
但 ETL 提供:
optional
variant
span
string_view
- 固定容量容器
- 算法
相当于'嵌入式版的 STL + 现代特性'。
7. 总结(精炼版)
MCU 的问题:
- 内存极小
- 无操作系统
- 无文件系统
- 无线程
- 无系统调用
- 动态内存不可预测、易碎片化、易崩溃
常见经验:
嵌入式开发中几乎所有经验丰富工程师的风格都是:
尽量不用动态分配,用静态/固定容量替代。
ETL 的作用:
提供 完全不使用动态内存的 STL 替代品
同时兼容现代 C++ 风格。
1. std::inplace_vector(C++26 的新容器)
什么是 std::inplace_vector?
一个'可增长但不动态分配'的 vector:容量固定,但元素数量可变。
它结合了:
std::array 的 固定容量、无 heap
std::vector 的 push_back / pop_back / resize 行为
也就是说:
std::inplace_vector<int,16> v;
这是 C++26(P0843R8 提案)正式加入的容器,
正是为了 嵌入式 和 高性能无 heap 场景。
2. 为什么 C++ 标准要加入 inplace_vector?
我想要一个像 vector,但不能动态分配的容器。
在 C++26 之前,许多人自己写:
- LLVM 的
SmallVector
- Folly
small_vector
- EASTL 的
fixed_vector
- Boost
static_vector
- Embedded Template Library 的
etl::vector
- Qt 的
QVarLengthArray
C++26 的 std::inplace_vector 就是把这些'民间标准'收归国标。
3. 现在回到核心问题:
为什么嵌入式开发者避免 C++ 异常(Exceptions)?
- SPACE(空间)
- TIME(时间)
- DYNAMIC MEMORY(动态内存)
做系统性的解释。
4. 原因分类详解
一、SPACE(空间成本)
1) 需要动态内存 / 堆(Heap)
- 捕获时分配对象
- 存储异常信息(type_info、what() 字符串等)
- 运行时需要异常表(EH tables)和 unwind 信息
在许多编译器实现中,抛出异常会触发堆分配,例如:
throw ⟹ operator new()
对 MCU 来说这有两个大问题:
(1) MCU 如果完全禁用 heap,则异常无法使用
(2) 即使 heap 允许,也很容易碎片化
2) 二进制体积膨胀(Binary Size Bloat)
- 完整 C++ STL 支持
- RTTI(运行时类型信息)
.eh_frame / .gcc_except_table 等段落
- 异常类型表,typeinfo
这些会导致固件明显变大:
Firmware Size = Code + RTTI + Exception Tables
在 Flash 只有 64KB~256KB 的 MCU 中,这是致命的。
二、TIME(时间成本)
1) 非确定性(Nondeterministic)
- 栈回溯(stack unwinding)
- 查找匹配的 catch
- 执行析构函数
- 运行时类型匹配(可能涉及
dynamic_cast)
这些操作时间不可预测:
t_throw = 未知,不是常数时间
对于实时系统(例如控制电机、读取传感器),
这种不确定性是不可接受的。
2) 慢
异常抛出不是简单的跳转(不像 setjmp/longjmp)。
它可能涉及:
- 二分查找异常表
- unwinder 调用 frame handler
- 逐层执行析构函数
- 寻找匹配类型
整个过程复杂而昂贵。
因此:
在 MCU 里 '异常抛出' 的成本可以达到 几十到几百微秒,甚至更多。
在实时系统,这是'灾难级'的慢。
三、动态内存依赖(DYNAMIC MEMORY)
需要 malloc(或 new)
在许多实现中,异常系统在内部需要分配存储空间。
嵌入式禁用 heap 的常见做法:
#define malloc(x) error_must_not_use_heap()
如果你开启异常,即使程序从不 throw,
编译器也可能需要链接异常运行时代码 → 编译失败或运行时崩溃。
总结:为什么嵌入式不用异常?
嵌入式避免 C++ 异常的三大理由:
① 空间不可接受:
- 异常运行库太大
- 需要 RTTI,平均增加数 KB~数十 KB Flash
- 需要
.eh_frame 等元数据
② 时间不可接受:
- 栈展开(unwinding)速度慢且不可预测
- 匹配类型(typeinfo)是运行时操作
③ 动态内存不可接受:
- 可能隐式调用
malloc
- 堆碎片化会导致系统崩溃
因此:
绝大多数嵌入式团队都禁用异常和 RTTI
例如 GCC/Clang 编译选项:
-fno-exceptions -fno-rtti
额外内容:嵌入式异常的安全替代方案
std::expected<T, E>
etl::expected
状态码(error code)
[[nodiscard]] 强制检查返回值
1. 背景:你正在阅读一个 MCU 外设库(Example: SJSU-Dev2)
gpio.hpp i2c.hpp pin.hpp pulse_capture.hpp pwm.hpp spi.hpp system_controller.hpp timer.hpp uart.hpp
这些是 MCU 外设抽象层(HAL/Driver)代码,例如:
gpio.hpp:封装 GPIO 输入输出
i2c.hpp:I²C 驱动
spi.hpp:SPI 驱动
uart.hpp:串口
pwm.hpp:定时器 PWM
timer.hpp:通用定时器
pin.hpp:单独的 IO Pin 抽象
system_controller.hpp:时钟/电源管理
你看到的 'Consider the output pin' 说明他们在讲:
在设计 MCU 外设接口时,必须认真考虑 微控制器的引脚(pin)如何映射到硬件外设功能。
2. 什么叫 'Consider the output pin'?
这句话其实是嵌入式课程或库文档里常出现的一句核心原则:
在使用 MCU 外设(GPIO/I2C/SPI/UART/PWM/Timer)之前,你必须首先理解'引脚的实际功能'。
为什么?
因为 MCU 的引脚并不是简单的 0/1 输出
而是有非常复杂的 复用功能(pin multiplexing)。
比如:
一个 MCU 引脚 P0.15 可能有:
- GPIO 输出
- GPIO 输入
- UART TX
- UART RTS
- SPI MOSI
- PWM 输出
- Timer Capture
- ADC 通道
- Comparator 输入
- 低功耗唤醒引脚
同时 多个功能共存但互斥。
所以在写它的外设库时,你需要'考虑输出引脚'(考虑引脚功能)。
3. MCU 引脚的复杂性:Pin Multiplexing(复用)
每个引脚不是只有一个功能,而是有 N 种可能:
Pin Function = {GPIO, UART, SPI, I2C, ...}
复用器(Mux)决定了当前选哪个功能:
PinMux[Pin] = FunctionID
例如:
Pin P2.0: FUNC0 = GPIO FUNC1 = UART0_TX FUNC2 = PWM3_OUT FUNC3 = TIMER_CAP0
如果你不处理 pinmux 就让外设初始化 → 肯定无法工作。
4. 为什么在库设计里要反复强调 'Consider the output pin'?
- 选择正确的引脚
- 配置正确的功能复用
- 配置方向(输入/输出)
- 配置速度模式
- 配置上拉/下拉
- 配置驱动特性(OD, PP, etc.)
例如要初始化 UART:
uart0.Initialize(baud); uart0.SetTxPin(Pin::Name::P2_0); uart0.SetRxPin(Pin::Name::P2_1);
为什么必须显式告诉它 TX 用哪根 pin?
因为:
- 并不是所有引脚都支持 UART TX
- 必须正确写 mux register
- 必须切换 pin 功能模式
5. 用数学类比解释 pin function selection
假设一个引脚有 k 个可选功能:
Pin P ⇒ f_0, f_1, f_2, …, f_{k-1}
选择功能就是从该集合中设置一个映射:
PinMux[P] = f_i
在 C/C++ 中,这通常对应寄存器:
pin.ConfigureFunction(Function::UartTx);
6. MCU 外设库必须清楚 Pin → Peripheral 的绑定关系
嵌入式库(如 SJSU-Dev2)通常会有一个类似的结构:
class Gpio { Pin pin; void SetHigh(); void SetLow();
class Uart { Pin tx; Pin rx; void Initialize(uint32_t baud);
- SPI 需要 MOSI / MISO / SCK / CS
- I2C 需要 SDA / SCL
- PWM 需要 Timer Channel
- UART 需要 TX/RX
- ADC 需要 ADC Channel + Pin
要使外设可用,你必须'考虑引脚'。
7. 那句重复的文字反复出现的真正原因
Microcontroller Consider the output pin Microcontroller pins Microcontroller Consider the output pin
'理解 MCU 引脚是使用外设前的第一步。'
因为驱动外设不是写软件就可以,还需要:
- 正确配置硬件复用
- 正确选择支持该功能的 pin
因此这句话被反复强调。
8. 更深入的解释:为什么 pin 很重要?
- V_OH (输出高电压)
- V_OL (输出低电压)
- 输出驱动能力 I_out
- 上拉/下拉阻值
- 输入施密特触发
- 数字/模拟模式切换
例如一个输出 pin 的极限电流 I_max:
I_max = 20 mA
如果你驱动一个 LED:
I = (V - V_forward) / R
你需要检查是否会烧掉 MCU 引脚。
因此 'Consider the output pin' 不是废话,而是一堂课:
每次使用 MCU 外设前,必须考虑 Pin 的功能、电气特性与映射关系。
总结
MCU 外设驱动库设计的根本基础是'对引脚的理解'
所有外设功能都必须通过 pin mux 映射
单个引脚可能拥有 5~10 种不同功能
使用外设前,必须正确选择引脚、配置复用与方向
忽视 pin → peripheral 映射是初学者最大的错误来源
下面是对你给出的 SVG 电路图 的 完整解析,包括:
Microcontroller Pin resistor LOW = ~0V time Voltage
<?xml version="1.0" encoding="UTF-8"?><svg width="800" height="400" font-family="Arial, sans-serif" version="1.1" xmlns="http://www.w3.org/2000/svg"><rect x="30.209" y="100.05" width="219.74" height="149.9" fill="none" stroke="#000" stroke-width="2"/><text x="138.6256" y="227.63908" font-size="28px" font-weight="bold" text-anchor="middle">Microcontroller</text><line x1="250" x2="335.92" y1="175" y2="175" stroke="#000" stroke-width="3"/><text x="285" y="160" font-size="20" text-anchor="middle">Pin</text><rect x="338" y="155" width="100" height="40" rx="5" fill="none" stroke="#000" stroke-width="2"/><text x="388" y="182" font-size="20px" text-anchor="middle">resistor</text><g stroke="#000"><line x1="440" x2="500" y1="175" y2="175" stroke-width="3"/><line x1="515" x2="490" y1="175" y2="175" stroke-width="3"/><line x1="516.87" x2="516.87" y1="152.99" y2="196.59" stroke-width="2.2148"/><g stroke-width="3"><line x1="516.38" x2="556.38" y1="196.23" y2="176.23"/><line x1="517.52" x2="557.52" y1="155.01" y2="175.01"/><line x1="574" x2="574" y1="155" y2="195"/></g></g><polygon transform="matrix(5.0442 0 0 1.0682 -2401.7 -12.408)" points="578 200 578 150 590 175"/><g stroke="#000"><line x1="574.69" x2="650" y1="175" y2="175" stroke-width="3.0464"/><line x1="650" x2="650" y1="175" y2="300" stroke-width="3"/><line x1="610" x2="690" y1="300" y2="300" stroke-width="4"/><line x1="620" x2="680" y1="310" y2="310" stroke-width="3"/><line x1="630" x2="670" y1="320" y2="320" stroke-width="2"/><line x1="640" x2="660" y1="330" y2="330"/></g><text x="350" y="280" fill="#0066ff" font-size="36" font-weight="bold">LOW = ~0V</text><line x1="305.36" x2="617.96" y1="340" y2="340" stroke="#000" stroke-width="2.3306"/><text x="400" y="370" font-size="24" text-anchor="middle">time</text><line x1="304.83" x2="615.82" y1="320" y2="320" stroke="#06f" stroke-width="4.3197"/><line x1="322.51" x2="322.51" y1="316.45" y2="336.45" stroke="#000" stroke-width="2"/><line x1="322.75" x2="322.75" y1="220" y2="320" marker-end="url(#arrow)" stroke="#000" stroke-width="2"/><text transform="rotate(-90)" x="-277.58295" y="309.57346" font-size="18px">Voltage</text><defs><marker id="arrow" markerHeight="10" markerWidth="10" orient="auto" refX="8" refY="3"><path d="m0 0v6l9-3z"/></marker></defs></svg>
1. 整体结构讲解
微控制器(Microcontroller)通过一个 GPIO pin 驱动一颗 NPN 晶体管,晶体管再把电流导向地(GND)。
流程是:
MCU Pin → 电阻 → 晶体管基极 → 晶体管导通 → 电流流向地
- Microcontroller(微控制器)
- Pin(GPIO 输出脚)
- resistor(限流电阻)
- NPN transistor(NPN 三极管)
- Ground(地)
- 输出电压示波图:LOW ≈ 0V
- 电压 - 时间坐标轴
2. 电路结构逐段解析
(1) Microcontroller 框
+------------------------+
| Microcontroller |
+------------------------+
(2) Pin(GPIO 输出引脚)
- HIGH:Vout=VDD(比如 3.3 V)
- LOW:Vout≈0 V
重点:
当 GPIO 输出 LOW 时,它实际上是把该引脚内部连接到地(GND)。
这就是示波图上显示'LOW ≈ 0V' 的原因。
(3) Resistor(限流电阻)
GPIO Pin → 电阻 → 三极管基极。
该电阻的作用是:
限制基极电流 I_B
由于三极管基极电流不能太大,需要电阻保护。
电阻一般计算:
I_B = (V_GPIO - V_BE) / R_B
其中:
- V_GPIO ≈ 3.3V(MCU 输出高电平)
- V_BE ≈ 0.7V(硅三极管导通电压)
- R_B:基极电阻
例如:
I_B = (3.3 - 0.7) / 10kΩ ≈ 0.26mA
3. NPN 三极管部分详细解释
Collector (C) Base (B) Emitter (E)
MCU → Resistor → Base Collector → Load / Output Emitter → Ground
晶体管导通条件:
V_B - V_E > 0.7V
即:
V_B > 0.7V
因为 V_E 接地(0V)。
导通时:
- 晶体管变成 开关闭合
- 电流从 Collector → Emitter 流向地
4. LOW = ~0V 的电压图解释
当 GPIO 输出 LOW 时:
V_pin ≈ 0V
因此晶体管基极电压也变成:
V_B ≈ 0V
因此:
V_B - V_E = 0 - 0 = 0 < 0.7
晶体管不会导通。
5. 电压时间图(Voltage-Time Graph)
- Y 轴:Voltage(电压)
- X 轴:time(时间)
- 蓝线在接近 0V 的位置
这代表:
GPIO 引脚持续输出 LOW 电平(恒 0V)。
换句话说,在该时刻窗口中:
V(t) = 0V
6. 这幅图完整表达的意义
MCU 的 GPIO 输出低电平 → 通过电阻拉低三极管基极 → 三极管关闭 → 电路不导通 → 输出保持 0V。
这是 MCU 控制晶体管的'关闭'状态。
当 MCU 输出 HIGH 时整个电路会完全改变(基极被驱动,三极管导通)。
Microcontroller Pin resistor electrical current HIGH = ~3.3V time Voltage
<?xml version="1.0" encoding="UTF-8"?><svg width="800" height="400" font-family="Arial, Helvetica, sans-serif" version="1.1" xmlns="http://www.w3.org/2000/svg"><rect x="39.41" y="100.03" width="210.56" height="149.95" fill="none" stroke="#000" stroke-width="2"/><text x="145.82939" y="236.04265" font-size="28px" font-weight="bold" text-anchor="middle">Microcontroller</text><line x1="250" x2="339.72" y1="175" y2="175" stroke="#000" stroke-width="3"/><text x="285" y="160" font-size="20" text-anchor="middle">Pin</text><rect x="340" y="155" width="100" height="40" rx="5" fill="none" stroke="#000" stroke-width="2"/><text x="390" y="182" font-size="20" text-anchor="middle">resistor</text><line x1="440" x2="500" y1="175" y2="175" stroke="#000" stroke-width="3"/><polygon points="540 175 500 150 500 200" fill="#40c090" stroke="#000" stroke-width="3"/><path d="m516.49 154.25 3.1897-4.52 11.12-15.757" fill="#d45500" opacity=".8" stroke="#40c090" stroke-width="3.8974"/><path d="m527.13 160.93 14.309-20.277" fill="#40c090" fill-opacity=".8" opacity=".8" stroke="#40c090" stroke-width="3.8974"/><g stroke="#000"><g stroke-width="3"><line x1="545.28" x2="620" y1="175" y2="175"/><line x1="544.02" x2="544.37" y1="154.71" y2="194.71"/><line x1="620" x2="620" y1="175" y2="300"/></g><line x1="580" x2="660" y1="300" y2="300" stroke-width="4"/><line x1="590" x2="650" y1="310" y2="310" stroke-width="3"/><line x1="600" x2="640" y1="320" y2="320" stroke-width="2"/><line x1="610" x2="630" y1="330" y2="330"/></g><text transform="scale(.99601 1.004)" x="316.57861" y="120.12102" fill="#d02020" font-size="19.657px" font-weight="bold" stroke-width=".75603">electrical current</text><path d="m351.76 134.98 63.346-0.40409 0.13312-11.481 10.397 15.292-10.575 11.275 0.0195-9.2619-63.396 0.83869" fill="#f44" stroke="#c02020" stroke-width="1.2474"/><text x="333.35107" y="278.24408" fill="#ff2222" font-size="40px" font-weight="bold">HIGH = ~3.3V</text><line x1="266.78" x2="614.27" y1="341.14" y2="341.14" stroke="#000" stroke-width="2.5954"/><text x="450" y="370" font-size="24" text-anchor="middle">time</text><line x1="270.57" x2="613.51" y1="229.62" y2="229.62" stroke="#f22" stroke-width="5.2921"/><line x1="276.2" x2="277.1" y1="338.68" y2="232" marker-end="url(#arrowhead)" stroke="#000" stroke-width="2.0658"/><text transform="rotate(-90)" x="-316.4455" y="267.1564" font-size="18px">Voltage</text><defs><marker id="arrowhead" markerHeight="10" markerWidth="10" orient="auto" refX="8" refY="3"><path d="m0 0v6l9-3z"/></marker></defs><polygon transform="matrix(.62671 -1.0664 .20835 .12244 131.88 729.32)" points="578 150 590 175 578 200" fill="#40c090" fill-opacity=".8"/><polygon transform="matrix(.62671 -1.0664 .20835 .12244 142.82 736.52)" points="590 175 578 200 578 150" fill="#40c090" fill-opacity=".8"/></svg>
图示详解()
1. Microcontroller(微控制器)
左侧矩形框表示 MCU,本质上是一个具有 GPIO(通用输入/输出)引脚的微控制器芯片。
MCU 的 Pin(输出引脚)能输出两种数字电压:
- HIGH(高电平)≈ 3.3V
- LOW(低电平)≈ 0V
高低电平的切换随图中时间轴变化。
2. GPIO Pin → Resistor(电阻)
GPIO 引脚通过一条导线连接到一个电阻(通常是限流电阻)。
为什么需要电阻?
当 GPIO 驱动 LED 时,如果不加电阻,LED 会因为电流过大而烧坏,同时 MCU 引脚也会因超出 I_max 而损坏。
电阻的作用是:
I = (V_GPIO - V_LED) / R
限制电流在安全范围,比如 5–20mA。
3. LED(发光二极管)
SVG 中 LED 被画成绿色三角形。
LED 有两个端:
- Anode(阳极) → 连接在靠近电阻这一侧
- Cathode(阴极) → 连接到地(GND)
LED 区域的绿色说明它处于亮灯状态(概念图)。
LED 只有在以下情况下才会导通发光:
V_Anode - V_Cathode >= V_forward ≈ 2V
例如绿色 LED 的典型压降 V_f ≈ 2.1V。
4. GND(地)
右侧是接地符号。
当 LED 阴极被接到 GND 时,电流可以形成闭环:
GPIO → R → LED → GND
从而点亮 LED。
5. electrical current(电流方向)
图中红色箭头 'electrical current' 表示传统电流方向:
从正电位(3.3V)流向低电位(GND)
也就是:
- HIGH 时:GPIO → 电阻 → LED → GND
- LOW 时:几乎没有电流(LED 灭)
注意:电子流方向与传统电流方向相反,但电路图习惯使用传统电流。
6. HIGH / LOW 电平波形
HIGH(红色水平线)
当 GPIO 输出:
V_GPIO = 3.3V
LED 阳极 ≈ 3.3V
阴极 ≈ 0V
满足导通条件,因此 LED 亮。
图中红色'HIGH = ~3.3V' 表示 GPIO 正输出高电平。
LOW(蓝色水平线)
当 GPIO 输出:
V_GPIO = 0V
LED 阳极 ≈ 0V
阴极 ≈ 0V
V_Anode - V_Cathode = 0 < V_f
LED 不导通,因此熄灭。
图中蓝色'LOW = ~0V' 表示 GPIO 拉低输出。
7. 时间轴(time)
- 随时间变化,GPIO 输出电平可能在 HIGH/LOW 之间切换
- 对应 LED 的亮灭变化
- 对应电流有/无
总结(整张图的含义)
微控制器 GPIO 推挽输出 → 电阻限流 → LED → 接地
HIGH 输出时,电流从 GPIO 流向地,LED 亮
LOW 输出时,无电流流动,LED 灭
数学关系:
I = (V_GPIO - V_f) / R
LED 亮灭完全由 GPIO 电平决定。
class output_pin {
public:
virtual ~output_pin() = default;
virtual void level(bool p_high) = 0;
};
问题:
level(bool) 这样返回 void 是否合理?
答案:
是合理的,而且对于 MCU GPIO 来说,这是最常见、最'嵌入式友好'的设计。
原因如下。
1. GPIO 输出通常'不失败'
当你设置 GPIO 输出电平:
GPIO_ODR = 1(高电平)
或者:
GPIO_ODR = 0(低电平)
这是一次寄存器写操作,执行必然成功。
所以:
- 不需要检查返回值
- 不需要错误处理
- 不需要
bool 或 error_code
能写寄存器就是成功,不存在失败路径。
因此返回 void 是自然的。
2. 嵌入式常见目标是:零开销抽象(Zero-cost abstractions)
虚函数接口本质上是抽象一个'GPIO 输出功能'。
硬件操作最终是:
如果 level() 返回值(比如 bool 或 error_code),优化器不能优化掉相关逻辑,会增加:
- 栈操作
- 分支判断
- 寄存器使用
- 代码体积
而嵌入式系统经常:
- 没有异常
- 没有动态分配
- 没有 RTTI
- 不能使用 heavy STL
所以设计尽量简单:
3. 返回值的语义其实很弱,不必要
你可能会考虑如下返回值:
| 返回类型 | 含义 | 是否适合 GPIO |
|---|
bool | 成功 / 失败 | GPIO 设置几乎不会失败 |
error_code | 错误码 | 多余,让 API 变重 |
expected<void, error> | C++23 风格 | 嵌入式几乎不用,且需要动态对象或大结构 |
void | 不关心错误 | 适合寄存器写操作 |
| GPIO 输出通常无法失败,因此返回值没有实际价值。 | | |
4. 嵌入式软件通常不使用 C++ 异常
- no RTTI
- no exceptions
- no heap
- no dynamic memory
在这种设计规范下:
- 函数失败无法
throw
- 也不希望返回值来处理错误(成本太高)
因此:
返回 void 是最自然的选择。
5. 真实世界驱动库如何设计?
STM32 HAL
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);
Arduino
Zephyr RTOS
gpio_pin_set(device, pin, value);
mbed OS
6. 如果你真的想要错误反馈,该怎么做?
- pin 尚未初始化
- pin 处于保护状态
- pin 被复用为 SPI / I2C
- pin 在 run-time 变化
你可能想要错误处理。
这时可以:
Option A:返回 bool
virtual bool level(bool p_high) = 0;
Option B:返回 error_code(但不常用)
virtual std::error_code level(bool p_high) = 0;
Option C:使用契约(assert 或 contract)
virtual void level(bool p_high) { assert(initialized_); }
- pin 的配置在 boot 时已确定
- '运行中改变 pin 模式'非常罕见
- GPIO 总是能成功写寄存器
总结:返回 void 是最佳选择
- 操作必然成功,无错误路径
- 不需要返回值
- 嵌入式最常用的做法
- 零开销抽象
- 简洁、直观、没有多余语义
因此你的接口设计:
virtual void level(bool p_high) = 0;
#⃣ 1. 整体概念:I²C 是'两根线的多设备通信协议'
A simplified Introduction to I2C I2C Bus Controller SDA SCL I2C Device SCL SDA I2C Device SCL SDA I2C Device SCL SDA STA ADDRESS (7-bit) W/R NACK/ACK Byte 0 ACK Byte 1 ACK NACK STOP
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 1600 800" xmlns="http://www.w3.org/2000/svg"><text x="80" y="60" fill="#333" font-family="Arial, sans-serif" font-size="48">A simplified Introduction to I2C </text><rect x="80" y="280" width="320" height="200" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="99.597694" y="332.80142" fill="#333333" font-family="Arial, sans-serif" font-size="28px">I2C Bus Controller</text><rect x="290" y="350" width="100" height="40" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="310" y="377" fill="#333" font-family="Arial, sans-serif" font-size="24">SDA</text><rect x="290" y="410" width="100" height="40" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="312" y="437" fill="#333" font-family="Arial, sans-serif" font-size="24">SCL</text><rect x="430" y="140" width="200" height="180" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="465" y="180" fill="#333" font-family="Arial, sans-serif" font-size="26">I2C Device</text><rect x="450" y="220" width="70" height="80" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="467" y="267" fill="#333" font-family="Arial, sans-serif" font-size="24">SCL</text><rect x="540" y="220" width="70" height="80" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="554" y="267" fill="#333" font-family="Arial, sans-serif" font-size="24">SDA</text><rect x="680" y="140" width="200" height="180" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="715" y="180" fill="#333" font-family="Arial, sans-serif" font-size="26">I2C Device</text><rect x="700" y="220" width="70" height="80" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="717" y="267" fill="#333" font-family="Arial, sans-serif" font-size="24">SCL</text><rect x="790" y="220" width="70" height="80" fill="#6dd4a8" stroke="#333" stroke-width="2"/><g fill="#333"><text x="804" y="267" font-family="Arial, sans-serif" font-size="24">SDA</text><circle cx="950" cy="230" r="10"/><circle cx="1010" cy="230" r="10"/><circle cx="1070" cy="230" r="10"/><circle cx="1130" cy="230" r="10"/></g><rect x="1280" y="140" width="200" height="180" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="1315" y="180" fill="#333" font-family="Arial, sans-serif" font-size="26">I2C Device</text><rect x="1300" y="220" width="70" height="80" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="1317" y="267" fill="#333" font-family="Arial, sans-serif" font-size="24">SCL</text><rect x="1390" y="220" width="70" height="80" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="1404" y="267" fill="#333" font-family="Arial, sans-serif" font-size="24">SDA</text><line x1="390" x2="1520" y1="370" y2="370" stroke="#333" stroke-width="3"/><g fill="#333"><circle cx="585.55" cy="370.38" r="8"/><circle cx="835.92" cy="369.62" r="8"/><circle cx="1425.6" cy="366.21" r="8"/></g><line x1="390" x2="1520" y1="430" y2="430" stroke="#333" stroke-width="3"/><g fill="#333"><circle cx="480.14" cy="430" r="8"/><circle cx="740" cy="430" r="8"/><circle cx="1335" cy="430" r="8"/></g><g stroke="#333" stroke-width="2"><line x1="585" x2="585" y1="300" y2="370"/><line x1="619.12" x2="524.12" y1="369.62" y2="369.62"/><line x1="835" x2="835" y1="300" y2="370"/><line x1="835" x2="740" y1="370" y2="370"/><line x1="1425" x2="1425" y1="300" y2="370"/><line x1="1425" x2="1335" y1="370" y2="370"/><line x1="479.76" x2="479.76" y1="299.24" y2="429.24"/><line x1="740" x2="740" y1="300" y2="430"/><line x1="1335.2" x2="1335.2" y1="301.14" y2="431.14"/><rect x="30" y="650" width="90" height="60" fill="#6dd4a8"/></g><text x="50" y="687" fill="#333" font-family="Arial, sans-serif" font-size="24">STA</text><rect x="120" y="650" width="260" height="60" fill="#a8d4ff" stroke="#333" stroke-width="2"/><text x="165" y="687" fill="#333" font-family="Arial, sans-serif" font-size="24">ADDRESS (7-bit)</text><rect x="380" y="650" width="90" height="60" fill="#f4a5a5" stroke="#333" stroke-width="2"/><text x="407" y="687" fill="#333" font-family="Arial, sans-serif" font-size="24">W/R</text><rect x="470" y="650" width="140" height="60" fill="#ffd4a3" stroke="#333" stroke-width="2"/><text x="482" y="677" fill="#333" font-family="Arial, sans-serif" font-size="22">NACK/</text><text x="500" y="697" fill="#333" font-family="Arial, sans-serif" font-size="22">ACK</text><rect x="610" y="650" width="210" height="60" fill="#ffd4a3" stroke="#333" stroke-width="2"/><text x="675" y="687" fill="#333" font-family="Arial, sans-serif" font-size="24">Byte 0</text><rect x="820" y="650" width="90" height="60" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="840" y="687" fill="#333" font-family="Arial, sans-serif" font-size="24">ACK</text><rect x="910" y="650" width="210" height="60" fill="#ffd4a3" stroke="#333" stroke-width="2"/><text x="975" y="687" fill="#333" font-family="Arial, sans-serif" font-size="24">Byte 1</text><rect x="1120" y="650" width="90" height="60" fill="#6dd4a8" stroke="#333" stroke-width="2"/><g fill="#333"><text x="1140" y="687" font-family="Arial, sans-serif" font-size="24">ACK</text><circle cx="1250" cy="680" r="6"/><circle cx="1280" cy="680" r="6"/><circle cx="1310" cy="680" r="6"/></g><rect x="1340" y="650" width="110" height="60" fill="#c0c0c0" stroke="#333" stroke-width="2"/><text x="1352" y="687" fill="#333" font-family="Arial, sans-serif" font-size="24">NACK</text><rect x="1450" y="650" width="110" height="60" fill="#6dd4a8" stroke="#333" stroke-width="2"/><text x="1460" y="687" fill="#333" font-family="Arial, sans-serif" font-size="24">STOP</text></svg>
整体介绍:I²C 总线简化示意图详解
这张 SVG 图展示了 I²C(Inter-Integrated Circuit)总线如何把一个主控制器(Master)与多个从设备(Slave)连接起来,并且在下面用一个时序示例展示了 I²C 一帧数据的结构。
I²C 只有两根线:
- SDA(Serial Data):串行数据线
- SCL(Serial Clock):串行时钟线
它是一个 多主多从, '开漏 + 上拉电阻' 结构的总线。
🟦 1. 图左:I²C Bus Controller(主控器)
╔══════════════════════════════╗ ║ I2C Bus Controller ║ ← MCU 上的 I²C 外设 ╚══════════════════════════════╝
- 产生时钟 SCL
- 控制数据线 SDA
- 发送 START/STOP
- 发送地址
- 收/发数据
- 读取 ACK/NACK
2. 多个 I²C 设备共享同一总线
I2C Device 1 I2C Device 2 ... I2C Device N
- SDA
- SCL
这些引脚全部连接到 相同的 SDA 总线与 SCL 总线。
这是 I²C 的关键特性:
所有设备 '并联' 在同一条两线总线上
🟧 3. SDA(数据线)总线
SDA ───────────────────────────────────────────────
所有设备的 SDA 引脚通过垂直线连接到 SDA 总线上。
SDA 是开漏(Open-Drain)结构
- 拉低 SDA(输出 0)
- 不能拉高 SDA(不能直接输出 1)
SDA 的高电平来自总线上拉电阻:
V_SDA = R_pull-up → V_CC
4. SCL(时钟线)总线
SCL ───────────────────────────────────────────────
🟫 5. 图中的点(circle)
- 总线上可以继续挂更多设备(扩展性)。
I²C 可以轻松接 几十甚至上百个器件,只要:
C_bus < C_max
通常 ≈400pF 上限。
6. 下方:I²C 单帧传输结构(时序图)
[ START ][ ADDRESS ][ R/W ][ ACK ][ BYTE 0 ][ ACK ]...[ NACK ][ STOP ]
6.1 START 条件(STA)
I²C START 条件定义为:
当 SCL 为高电平时,SDA 从高跳变到低
它表示:
'一次新的通讯开始了'
🟦 6.2 ADDRESS(7-bit 地址)
I²C 地址是 7 位:
A_6 A_5 A_4 A_3 A_2 A_1 A_0
例如:MPU6050 默认地址 0x68。
6.3 W/R 位(读写位)
- 0 = 写 (Write)
- 1 = 读 (Read)
组成完整的 8 位地址:
Address Byte = (7bit Address) << 1 ; | ; R/W
🟧 6.4 ACK / NACK
- ACK = 0(拉低 SDA):从机应答成功
- NACK = 1(释放 SDA):从机不应答
ACK 所在的时钟周期称为:
9th clock
前 8 个时钟发送 1 个 Byte,第 9 个时钟由从机发送 ACK。
🟫 6.5 后续数据字节 Byte 0, Byte 1, …
Byte 0 → ACK → Byte 1 → ACK → ...
6.6 最后 NACK(停止接收)
当主机读取最后一个字节,需要发送 NACK 告诉从机:
6.7 STOP 条件(STOP)
绿色方框:
STOP 条件定义为:
当 SCL 为高电平时,SDA 从低跳变到高
表示通讯结束。
🟦 完整流程总结(图下部分对应)
START → 7-bit Address (主机发送) → R/W (0=写,1=读) → ACK (从机发送) → Data Bytes → NACK → STOP
一、错误汇总(概念速览)
- ADDR NACK(地址应答为 NACK):主机发送地址后,从机没有拉低 ACK(即没有应答)。常见提示'Device Not Present'。
- Device Not Present:物理上设备不存在、未通电、地址不对或总线接线错误。
- IO ERROR / Unexpected Bus State(总线异常状态):如 SDA 被拉低卡住、SCL 被拉低卡住、仲裁丢失、时钟伸展永久阻塞、总线噪声导致非法 START/STOP 等。
- 这些错误在时序上通常表现为:在第 9 个时钟(ACK 位)读到
1(NACK),或读不到预期电平,或总线一直处于低电平/高电平且无法恢复。
二、ADDR NACK 的原因与判断
现象:发送地址字节([7-bit addr] + R/W)后,期待的 ACK(第 9 位)为 0,但读到 1(NACK)。
常见原因:
- 设备没上电或复位中(硬件供电问题)。
- 地址写错(7-bit/8-bit 地址混淆,或者器件有可配置地址)。
- 设备被其他主机占用或处于睡眠模式(没有应答)。
- 总线接线错误(SDA、SCL 反接、没上拉电阻)。
- 器件被拉至其他功能(pinmux/alternate function 导致脚不是 I²C 模式)。
- 总线通信速率过高导致设备无法跟上。
如何判断:
- 在发送地址后读 ACK 位:若为
1 则为 NACK。
- 用示波器/逻辑分析仪观察 SDA、SCL 波形确认第 9 时钟位 SDA 未被拉低。
- 在软件:实现超时(timeout),如果等待 ACK 超时或立即读为 1 则判定 NACK。
处理建议:
- 检查硬件:确认 Vcc、GND、上拉电阻值、线长与接法。
- 确认地址:查数据手册,确认 7-bit vs 8-bit 与可配置跳线。
- 降低速率:把 f_SCL 降低到标准模式(100 kHz)或更低做排查。
重试:常规做法是做 N 次重试(N=3~5),间隔短延迟(退避可选)。
伪码:
for(int i=0; i<retries;++i){start();write_address(addr_rw);if(read_ack())break;
if(failed)report_device_not_present();
三、Device Not Present(设备不存在)的额外排查
- 电源/复位:测设备是否上电(Vcc 到位、RESET 引脚状态)。
- 接线:SDA/SCL、GND 是否连接,是否使用了上拉电阻(常见 4.7kΩ 或 10kΩ)。
- 上拉电阻值是否合适:上拉过大导致上升慢,过小导致总线功耗过高。
- 总线电容是否太大:每米线或 PCB 探针会增加总线电容 C_b,影响 rise time(见下面公式)。
- 器件地址/跳线/电阻/配置确认。
- 用逻辑分析仪扫地址(bus scan)以确认是否有器件响应。
四、Unexpected Bus State / IO ERROR(总线异常)详解与恢复
- SDA 被持续拉低(总线被'卡住'在低电平)
- SCL 被持续拉低(主机或从机把时钟拉低,阻塞总线)
- 仲裁丢失(arbitration lost):多主模式下发生
- 时钟伸展(clock stretching)长时间占用:从机拉低 SCL 导致主机等待
- 噪声 / 总线漂移:虚假的 START/STOP,导致协议错位
诊断手段:
- 观察 SDA/SCL 电平:是否有持续低电平?
- 观察时序:是否有长时间 SCL 低、SDA 不符合 START/STOP 条件?
- 逻辑分析仪追踪帧,分析是否出现错误的 START、重复 START、或奇数个时钟没有对应数据位等。
恢复策略:
- 优雅停止 + 重试策略
- 发送 STOP,然后再重试一次完整序列(START → ADDR …)。
- 如果 STOP 无法生成(SDA 被拉低),进行以下更激进的恢复。
- 复位从机/总线
- 对可控从机进行硬复位(toggle RESET 引脚),或断电重上电。
- 对主机 I²C 外设做软复位/关闭再打开。
- 增加超时保护
在等待 SCL/SDA 到位时不要无限等待,必须有 t_timeout,比如 t_timeout=5ms 或依据时序而定。
- 检测仲裁丢失(多主场景)
如果主机在发送期间检测到 SDA 与自己写的不一致(即被别的主机拉低),则发生仲裁丢失,主机应放弃本次操作并成为从状态或重试。
总线恢复(Clock Pulse Recovery)
如果 SDA 被从机拉低(例如在字节传输中断裂),按照 I²C 规范常用的工程做法是给 SCL 提供多次时钟脉冲,直到 SDA 被释放(通常最多 9 次时钟):
伪码:
for(int i = 0; i < 9 && SDA_is_low();++i){drive_SCL_low();delay_half_period();drive_SCL_high();delay_half_period();}
drive_SCL_high();drive_SDA_low();delay();drive_SDA_high();
这个序列是为了让可能处于中间状态的从机'推进'出它的内部状态机并释放 SDA。
五、硬件层面导致的常见物理问题(以及数学/定量说明)
1) 上拉电阻与总线电容(上升时间)
总线的上升时间由上拉电阻 R_pu 与总线电容 C_b 决定:
τ = R_pu · C_b
近似上升时间(0.3→0.7 的 70% 常数近似)可用:
t_r ≈ 0.8473 · R_pu · C_b
示例:R_pu=4.7kΩ, C_b=200pF
τ = 4.7×10^3 · 200×10^-12 = 940×10^-9 s
t_r ≈ 0.8473 × 940ns ≈ 0.8μs
如果 t_r 接近或超过 SCL 的高电平时间,将导致通信失败或总线看似被占用。
工程结论:在有较大布线或多个外设时应减小 R_pu(例如 2.2kΩ)或降低 f_SCL。
2) 最大总线电容
I²C 规范通常限制总线负载电容 C_b ≤ 400pF(取决于模式)。超过会导致信号畸变。
3) 上拉阻值选择(经验)
- 标准模式(100 kHz):R_pu = 4.7kΩ 常用
- 快速模式(400 kHz):R_pu = 2.2kΩ 更佳
- 若线很短且设备少,可用更大阻值降低功耗
六、软件层面的健壮实现建议(伪代码 + 要点)
- 一次操作(读/写)必须有超时与重试
- 在 NACK 或异常时尝试 STOP + bus_recover()
- 使用逻辑分析仪进行调试
- 在多主场景检测仲裁丢失并优雅退让
伪代码:
bool i2c_write_with_retry(uint8_t addr, uint8_t* data, size_t len){
const int RETRIES = 3;
for(int attempt = 0; attempt < RETRIES;++attempt){
start();
if(!write_byte(addr<<1|0)){
stop();
delay_ms(1);
continue;
}
for(size_t i=0; i<len;++i){
if(!write_byte(data[i])){
stop();
delay_ms(1);
continue;
}
}
stop();
return true;
}
bus_recover();
return false;
}
bus_recover()(见上文钟脉冲恢复伪码)。
七、调试与观察工具
- 逻辑分析仪(Saleae 等)或示波器:查看 SDA/SCL 波形是首要手段。
- I²C 扫描程序:轮询 0x03…0x77 地址,记录哪些地址返回 ACK。
- 测量电阻、电容、Vcc:物理层面排查。
八、工程级最佳实践清单(可打印)
- 添加适当上拉电阻(根据 f_SCL 与线路长度选择)。
- 对 I²C 操作实现超时与重试(N=3~5)。
- 在 NACK 或异常时发送 STOP 并调用
bus_recover()。
- 使用 9 次 SCL 脉冲 + STOP 的恢复方案来释放被卡住的 SDA。
- 在多主环境实现仲裁丢失检测与退让。
- 在生产/调试阶段用逻辑分析仪记录波形并保存样本。
- 对设备扫描做自动化测试(有助于发现地址冲突或设备未响应)。
- 若频繁错误,检查上拉、总线电容与供电稳定性。
九、结论(短句总结)
- ADDR NACK 通常是设备未出现或地址/硬件问题;先做重试、再做硬件排查。
- Unexpected Bus State 多由 SDA/SCL 卡住、仲裁、时钟伸展或上拉/电容问题引起;使用 9 次 SCL 脉冲 + STOP 恢复总线。
- 良好实现需超时/重试/总线恢复/诊断工具配合,并在硬件上确保合适的上拉与低电容。
研究的器件 PCA9536(4 位 I²C I/O 扩展器) 当作一个小系统来逐步拆解:硬件寄存器模型、常见故障、驱动设计(特别是 '可失败 / 不可失败 API' 的映射)、错误处理与恢复策略、以及具体的位运算与伪代码示例。
Simplified Schematic Fallible APIs Infallible APIs I2C or SMBus Controller(e.g. Processor) VCC SDA SCL PCA9536 GND P0 P1 P2 P3 i2c Peripheral Devices RESET, ENABLE, or control inputs INT or status outputs LED Buttons output_pin output_pin output_pin output_pin
<?xml version="1.0" encoding="UTF-8"?><svg version="1.1" viewBox="0 0 1500 600" xmlns="http://www.w3.org/2000/svg"><text x="782.41028" y="562.65839" fill="#333333" font-family="Arial, sans-serif" font-size="36px" font-weight="bold" text-anchor="middle">Simplified Schematic</text><rect x="350" y="10" width="300" height="70" fill="none" stroke="#f90" stroke-width="3"/><text x="500" y="55" fill="#ff9900" font-family="Arial, sans-serif" font-size="36" text-anchor="middle">Fallible APIs </text><rect x="920" y="10" width="300" height="70" fill="none" stroke="#f90" stroke-width="3"/><text x="1070" y="55" fill="#ff9900" font-family="Arial, sans-serif" font-size="36" text-anchor="middle">Infallible APIs </text><rect x="20" y="110" width="360" height="310" fill="#bbb" stroke="#333" stroke-width="3"/><text x="200" y="230" fill="#333" font-family="Arial, sans-serif" font-size="28" font-weight="bold" text-anchor="middle">I2C or SMBus Controller </text><text x="200" y="270" fill="#333" font-family="Arial, sans-serif" font-size="24" text-anchor="middle">(e.g. Processor) </text><g stroke="#333"><rect x="600" y="110" width="360" height="360" fill="#c93" stroke-width="3"/><line x1="780" x2="780" y1="45" y2="110" stroke-width="2"/><circle cx="780" cy="45" r="6" fill="none" stroke-width="2"/></g><g fill="#fff" font-family="Arial, sans-serif" font-weight="bold"><text x="780" y="145" font-size="28" text-anchor="middle">VCC </text><g font-size="32"><text x="630" y="195">SDA </text><text x="630" y="245">SCL </text><text x="780" y="305" text-anchor="middle">PCA9536 </text><text x="630" y="450">GND </text><g text-anchor="end"><text x="920" y="165">P0 </text><text x="920" y="235">P1 </text><text x="920" y="305">P2 </text><text x="920" y="375">P3 </text></g></g></g><line x1="380" x2="600" y1="180" y2="180" stroke="#333" stroke-width="3"/><polygon points="370 180 390 173 390 187" fill="#333"/><polygon points="610 180 590 173 590 187" fill="#333"/><text x="490" y="170" fill="#e74c3c" font-family="Arial, sans-serif" font-size="28" font-weight="bold" text-anchor="middle">i2c </text><line x1="380" x2="600" y1="230" y2="230" stroke="#333" stroke-width="3"/><polygon points="610 230 590 223 590 237" fill="#333"/><rect x="1190.2" y="110.17" width="237.56" height="309.65" fill="#bbb" stroke="#333" stroke-width="3"/><text x="1306.79" y="171.6673" fill="#333333" font-family="Arial, sans-serif" font-size="24px" font-weight="bold" text-anchor="middle">Peripheral Devices</text><g fill="#333"><circle cx="1210" cy="200" r="4"/><text x="1220" y="207" font-family="Arial, sans-serif" font-size="18">RESET, ENABLE, or </text><text x="1240" y="227" font-family="Arial, sans-serif" font-size="18">control inputs </text><circle cx="1210" cy="255" r="4"/><text x="1220" y="262" font-family="Arial, sans-serif" font-size="18">INT or status outputs </text><circle cx="1210" cy="290" r="4"/><text x="1220" y="297" font-family="Arial, sans-serif" font-size="18">LEDs </text><circle cx="1210" cy="325" r="4"/><text x="1220" y="332" font-family="Arial, sans-serif" font-size="18">Buttons </text></g><line x1="960" x2="1190" y1="150" y2="150" stroke="#333" stroke-width="2"/><text x="977" y="145" fill="#2ecc71" font-family="Arial, sans-serif" font-size="22px" font-weight="bold">output_pin</text><line x1="960" x2="1190" y1="220" y2="220" stroke="#333" stroke-width="2"/><text x="977" y="215" fill="#2ecc71" font-family="Arial, sans-serif" font-size="22px" font-weight="bold">output_pin</text><line x1="960" x2="1190" y1="290" y2="290" stroke="#333" stroke-width="2"/><text x="977" y="285" fill="#2ecc71" font-family="Arial, sans-serif" font-size="22px" font-weight="bold">output_pin</text><line x1="960" x2="1190" y1="360" y2="360" stroke="#333" stroke-width="2"/><text x="977" y="355" fill="#2ecc71" font-family="Arial, sans-serif" font-size="22px" font-weight="bold">output_pin</text></svg>
一、器件与图示概览(高层语义)
- 左侧:I²C/SMBus 控制器(主机)
- 中间:PCA9536 芯片(4 个 GPIO:P0…P3),带 VCC / GND / SDA / SCL
- 右侧:外设(LED、RESET、按钮、INT 等) 由 PCA9536 的输出/输入脚驱动或读取
- 顶部区分:Fallible APIs(易失败 API) 与 Infallible APIs(不可失败 API) 的语义分界
直观含义:
任何通过 I²C 对 PCA9536 做 I/O 操作的动作,在物理层上都有可能失败(比如 ADDR NACK、总线异常)——因此基于它的 API 本质上是'可失败'的。但在软件设计上,我们经常希望向上层提供'不抛错/不返回错误'的便捷接口(infallible)——这就要求在驱动层做额外的策略(缓存、重试、异步提交等)来掩盖或处理失败。
二、常见寄存器模型(PCA9536 的典型寄存器集合)
(下列寄存器是 PCA9536 / 类似 4-bit I/O 扩展器常见的寄存器集合,便于后文讨论)
INPUT(0x00):读取实际的引脚电平(只读)
OUTPUT(0x01):写入将驱动输出的位(读/写)
POLARITY(0x02):极性反转寄存(可选)
CONFIG(0x03):方向配置(1 = input, 0 = output)
所以,最常做的操作就是读 INPUT、写/读 OUTPUT、写 CONFIG 来设置方向。
位运算表达(例如设置 P2 输出高):
- 读取旧输出寄存器值 O_old
- 设置第 n 位为 1:
O_new = O_old | (1 << n)
- 清第 n 位为 0:
O_new = O_old & ~(1 << n)
写入就是把 O_new 通过 I²C 写回 OUTPUT 寄存器。
三、为什么 API 会'可失败'(Fallible)?
ADDR NACK(器件没有 ACK)→ 设备不存在 / 断电 / 地址错误
IO ERROR / Unexpected Bus State → SDA/SCL 异常、仲裁/时钟伸展、线路噪声
- R/W 时发生中断或总线被其他主机干预(多主)
- 上拉电阻、总线电容导致波形错误(导致设备无法可靠响应)
因此直接对
OUTPUT 寄存器做一次 I²C 写是可能失败的操作;所以驱动层最基础的 API 往往会把失败通过返回值/错误码告知调用者:bool write_output_reg(uint8_t value) 或 expected<void, err>。
四、软件层设计:Fallible vs Infallible API 的对比与设计模式
模式 A — 直接可失败同步 API(最简单、最透明)
bool set_pin_level_fallible(int pin, bool level);
- 读取
OUTPUT(R),计算 O_new(RMW),写回 OUTPUT(W)
- 在 I²C 失败时返回
false,成功返回 true
优点:语义简单、上层知道真实状态、适合需要强一致性的场景。
缺点:调用方必须处理失败、可能阻塞/重试/打日志。
模式 B — 不可失败/乐观本地缓存(Infallible wrapper + 后台提交)
void set_pin_level_infallible(int pin, bool level);
- 在内存里维护
shadow_output(4 位)
set_pin_level_infallible() 只修改 shadow_output(原子更新)并enqueue 或 标记需要提交
- 单独的提交任务(由调度/主循环/定时器触发)去把
shadow_output 同步到 PCA9536,若写失败则按策略重试或报警
优点:上层不必处理网络/硬件错误,UI/逻辑更简洁。
风险/缺点:
- '不可失败' 只是表象:真正的物理写可能会延迟或失败 → 导致实际引脚状态与 shadow 不一致
- 需要仔细设计何时、如何重试、如何报告长期失败(例如提供状态查询
bool is_synced())
适用场景:对时序与一致性要求不严格(比如 LED 指示灯),但需要高层代码简洁。
模式 C — 同步强一致但带自动重试(中间方案)
bool set_pin_level_strong(int pin, bool level, int retries = 3);
- 立即尝试 RMW 写入
OUTPUT,如果失败自动按指数回退重试 N 次(N 可配置)
- 若在 N 次内成功返回
true,否则返回 false 并可能触发 bus_recover()
重试退避公式(示例):
t_k = t_0 · 2^k (k = 0,1,…,N-1)
其中 t_0 可取 1 ms。
优点:兼顾一致性与鲁棒性。
缺点:会阻塞较久;若总线永久异常会浪费 CPU。
五、具体驱动实现要点(伪代码示例)
下面给一个比较完整的驱动框架(伪码),演示 shadow 缓存 + 同步 + 恢复流程。
class PCA9536_Driver {
uint8_t shadow_output = 0x00;
Mutex m;
public:
void set_level_infallible(int pin, bool level) {
std::lock_guard g(m);
if(level) shadow_output |= (1 << pin);
else shadow_output &= ~(1 << pin);
mark_dirty();
}
bool set_level_fallible(int pin, bool level) {
std::lock_guard g(m);
uint8_t current;
if(!i2c_read_reg(INPUT/OUTPUT, ¤t)) return false;
uint8_t next = level ? (current | (1 << pin)) : (current & ~(1 << pin));
if(!i2c_write_reg(OUTPUT, next)) return false;
shadow_output = next;
return true;
}
void sync_to_hw() {
std::lock_guard g(m);
if(!is_dirty()) return;
int retries = 3;
for(int k=0; k<retries;++k){
if(i2c_write_reg(OUTPUT, shadow_output)){
clear_dirty();
return;
}
delay((1 << k));
bus_recover_if_needed();
}
log_error("PCA9536 write failed persistently");
}
void bus_recover_if_needed(){
}
};
set_level_infallible():对上层是'不会失败'的接口(立即返回),实际写到硬件由 sync_to_hw() 负责。上层如果需要确认写成功,可查询 is_synced() 或订阅状态事件。
set_level_fallible():立即尝试并将结果返回给调用方,适合在关键操作(例如必须在 立即影响外围设备的重置线)时使用。
六、读 - 改 - 写(RMW)竞争与原子性
当多个线程或任务同时操作不同 pin 时,RMW 必须是原子的,否则你会覆盖别人写的位:
设两个线程同时:
- T1 将 P0 设为 1:读 O_old,生成 O_a = O_old | (1<<0),写 O_a
- T2 将 P1 设为 0:读 O_old(同一个旧值),生成 O_b = O_old & ~(1<<1),写 O_b
若没有互斥,最终写回会丢失一方更新。解决方案:
- 在驱动层加互斥(
Mutex)保护 RMW
- 或使用 shadow + 单点写(所有更改合并后一次写回)
七、错误/恢复策略细节(实务建议)
- 重试次数:通常 N=3 是合理起点,视现场可靠性可增减。
- 退避策略:指数退避(t_k = t_0 2^k)避免总线拥塞。
- 总线恢复:如果检测到 SDA 被拉低不放,实行 9 次 SCL 脉冲 + 发 STOP(见你之前收到的恢复伪码)。
- 报警/监控:长期写失败应进入'告警态',上报上层或点亮本地故障 LED。
- 性能考虑:对频繁变更的 pin(例如 PWM 模拟)不适合频繁通过 I²C 写入;应尽量把变化降采样或批量提交。
- 原子批量更新:如果一次需更新多个 pin,合并为一次写(减少 I²C 事务次数)
八、示例:如何把 output_pin 接口映射到 PCA9536 驱动
class output_pin {
public:
virtual ~output_pin() = default;
virtual void level(bool p_high) = 0;
};
实现 1(直接代理到 fallible driver,但吞掉错误)
class PCA9536_OutputPin : public output_pin {
PCA9536_Driver &drv;
int pin;
public:
void level(bool p_high) override {
if(!drv.set_level_fallible(pin, p_high)){
drv.bus_recover_if_needed();
}
}
};
语义:上层不会知道失败,但系统会尝试恢复或记录错误(适用于 LED 等非关键 I/O)。
实现 2(infallible but eventually consistent:更新 shadow)
class PCA9536_ShadowOutputPin : public output_pin {
PCA9536_Driver &drv;
int pin;
public:
void level(bool p_high) override {
drv.set_level_infallible(pin, p_high);
}
};
语义:API 总是'成功返回',但实际硬件状态可能有延迟或失败;需提供额外查询 drv.is_synced()。
九、何时用哪种语义(设计建议)
- 关键控制线(RESET、ENABLE、继电器控制) → 使用 fallible 强一致 API(上层必须知道写是否成功)
- 指示灯/非关键状态(LED) → infallible shadow + 后台同步 更方便,不会阻塞 UI
- 频繁变更的输出 → 批量/降采样/直接本地驱动(避免频繁 I²C 写)
- 多任务并发访问 → 驱动层必须保证原子性(互斥或单线程提交)
十、简短总结(要点回顾)
- PCA9536 通过 I²C 操作寄存器 —— 每次写入都有失败风险(所以基础驱动是 fallible)。
- 若需要对外提供'不失败'接口(
output_pin::level() 返回 void),必须在驱动层做 shadow 缓存 / 异步提交 / 重试 / 报警等机制来封装物理失败。
- 关键引脚要保证强一致性(建议用同步可失败接口并让调用方处理错误)。
- RMW 必须原子化;遇到 SDA/SCL 卡住需要总线恢复(9 次 SCL 脉冲 + STOP)。
- 位操作公式:设置位/清位用
O_new = O_old | (1 << n) / O_new = O_old & ~(1 << n)。
当 output_pin::level() 的接口是不可失败(void)而底层操作可能失败(I²C 写出错)时,会出现'返回不匹配 / 责任不清'问题。下面把问题拆成原因、可选设计、实用范例与推荐实现一并给出(包含 std::expected + 错误传播宏的具体用法),用逐点解释并给出可直接拷贝的代码片段。
1) 问题本质:Return mismatch(返回语义不一致)
当上层代码把 output_pin::level(bool) 设计为 不可失败(void),但底层实现(比如通过 I²C 写 PCA9536)有真实失败路径(ADDR NACK、总线异常等),就会出现责任不清:
- 上层调用方无法得知写入是否成功(不能重试、不能降级、不能报警)。
- 某些用户(LED)可以容忍失败( not severe),但是关键控制(继电器、加热器、断电继电器)失败会造成严重后果(‼ severe )。
因此接口层要把'失败可能性'明确定义在类型签名上,使调用方能选择合适策略。
2) 设计选项(概览)及权衡
- 保持
void level(bool)(不可失败)——把失败吞掉/隐藏
- 优点:调用简洁,上层代码最简单。
- 缺点:丢失错误语义,无法保证关键操作成功;变成'eventual consistency',需另行监控。
- 何时用:仅限指示灯等可以最终一致的场景。
- 把
level 设计为返回状态(如 bool / status / std::expected)——同步可失败
- 优点:调用者能知晓成功/失败并采取措施(重试 / fallback / 报警)。
- 缺点:调用链膨胀(必须把错误往上抛或处理),代码更啰嗦。
- 何时用:控制关键外设(继电器、RESET 等)。
- 提供两套 API:infallible(快速、修改 shadow) + fallible(同步并确认)
- 优点:上层可选策略;UI 用 infallible,关键控制用 fallible。
- 缺点:驱动复杂度增加(需要后台同步、状态查询接口)。
倾向于:用
std::expected + Error Propagator 宏(P2561R1 风格,SJ_CHECK)来保持上层简洁同时保留错误传播能力——这是很好的平衡方案。
3) 接口建议(以 std::expected 为基础)
#include<expected>
enum class my_error {
device_disconnected,
bus_error,
nack,
timeout,
invalid_argument,
};
using status = std::expected<void, my_error>;
using result_bool = std::expected<bool, my_error>;
然后定义 output_pin 接口(fallible):
struct output_pin {
virtual ~output_pin() = default;
virtual status level(bool p_high) = 0;
virtual result_bool level() = 0;
};
这样签名清楚表达了:设置/读取可能失败,调用者必须处理或传播错误。
4) toggle_led 的三种写法(示例)
A. 旧式(不可检测失败)
void toggle_led(output_pin& p_pin, std::chrono::milliseconds p_transition_time){
p_pin.level(true);
delay(p_transition_time/2);
p_pin.level(false);
delay(p_transition_time/2);
}
B. 使用 std::expected(显式错误处理)
status toggle_led(output_pin& p_pin, std::chrono::milliseconds p_transition_time){
if(auto s = p_pin.level(true); !s){
return std::unexpected(s.error());
}
delay(p_transition_time / 2);
if(auto s = p_pin.level(false); !s){
return std::unexpected(s.error());
}
delay(p_transition_time / 2);
return {};
}
优点:调用链显式地向上传播错误。缺点:重复的样板代码。
C. 使用 Error Propagator 宏(SJ_CHECK 风格)——简洁且类型安全
先给出简单宏实现(基于 std::expected):
#define SJ_CHECK(EXPR) do {
auto _sj_res = (EXPR);
if(!_sj_res){
return std::unexpected(_sj_res.error());
}
} while(0)
status toggle_led(output_pin& p_pin, std::chrono::milliseconds p_delay){
SJ_CHECK(p_pin.level(true));
delay(p_delay / 2);
SJ_CHECK(p_pin.level(false));
delay(p_delay / 2);
return {};
}
优点:代码清爽,错误路径被快速返回,保留函数返回 status 以向上层传达失败原因。非常贴合 P2561R1 建议(error propagation convenience)。
(注:更高级的宏/函数模板可支持提取值 T,类似 TRY/TRY_ASSIGN,但上面宏已适用于 std::expected<void,...>。)
5) 如何映射到 PCA9536 驱动(实践建议)
驱动层接口(fallible、同步)
class PCA9536 {
public:
std::expected<uint8_t, my_error> read_register(uint8_t reg);
std::expected<void, my_error> write_register(uint8_t reg, uint8_t value);
status set_pin_level(uint8_t pin, bool level){
auto r = read_register(OUTPUT_REG);
if(!r) return std::unexpected(r.error());
uint8_t out = *r;
uint8_t next = level ? (out | (1 << pin)) : (out & ~(1 << pin));
auto w = write_register(OUTPUT_REG, next);
if(!w) return std::unexpected(w.error());
shadow_output_ = next;
return {};
}
private:
uint8_t shadow_output_{0};
};
将 output_pin 实现为使用 PCA9536(强一致版本)
class PCA9536_Pin : public output_pin {
PCA9536& driver_;
uint8_t pin_;
public:
status level(bool p_high) override {
return driver_.set_pin_level(pin_, p_high);
}
result_bool level() override {
auto r = driver_.read_register(OUTPUT_REG);
if(!r) return std::unexpected(r.error());
return static_cast<bool>((*r >> pin_) & 1);
}
};
or:infallible shadow 方案(适用于 LED)
驱动维护 shadow_output,并在后台周期性将其 flush 到硬件(带重试、bus_recover)。level() 只更新 shadow 并立即返回 void(上层更简单)。同时暴露 status is_synced() 或 std::expected<void,my_error> sync_now() 以便需要时强制同步。
6) 其他工程注意点(嵌入式约束下)
- 不要用异常(你之前的约束);
std::expected 是一个很好的替代(零异常,类型安全)。
- 返回类型选择:
std::expected<void, E> 适合只需知道是否成功;std::expected<T, E> 适合返回值 + 错误。
- 错误分类:定义清晰的
my_error 枚举(nack, bus_error, timeout, device_off, invalid_arg 等),便于上下层策略化处理。
- RMW 原子性:读 - 改 - 写需要互斥/序列化(互斥锁或运行于单线程 I2C task),避免并发覆盖。
- 重试与恢复:在驱动层实现有限次重试 +
bus_recover()(9 次 SCL 脉冲 + STOP)策略,避免上层每次都实现重复逻辑。
- 接口分层:提供两类 API(
sync_fallible 与 async_infallible)满足不同调用方的需求。
- 可观测性:在系统层面记录失败事件(日志 / 指示灯 / 告警)以便长期监控。
7) 结论(工程建议)
- 关键控制(继电器、加热器、安全回路)请使用 同步、可失败 的
std::expected 接口,让调用方能处理错误。
- 非关键指示(LED)可以提供
infallible 快速接口(shadow + 后台 sync),但仍应提供 status/health 查询接口以发现长期不同步的问题。
- 使用
std::expected + Error Propagator 宏(SJ_CHECK)能在 保持调用处简洁 的同时 明确传播错误,非常适合嵌入式项目(无异常、可控开销)。
- 在驱动层集中实现重试/恢复/互斥/诊断,避免把硬件复杂度泄露到应用层。
为什么'几乎所有 API 最终都要返回错误'?为什么 std::expected 会产生'病毒式蔓延'(virality)?为什么学生抱怨 IF/ELSE 到处都是?为什么最后所有 API 都返回 status?
1. Which APIs need to return errors?
'几乎所有 API 都需要返回错误'
- I/O 可能失败(I2C、SPI、GPIO、ADC、Flash)
- 电源 可能不稳定
- 通信 可能超时
- 传感器 可能 NACK
- EEPROM 可能还没准备好
换句话说:
只要 API 和外部世界交互,就必须能报告错误。
数学上你可以把一个函数模型化为:
f: Input → Output
但是在嵌入式系统中真实情况是:
f: Input → Output ∪ Error
即函数不再是纯函数,其值域包含错误。
2. 所以作者决定使用 std::expected
std::expected<value_t, error_t>
- 明确错误
- 没有异常开销
- 适合嵌入式 C++
缺点:
- 返回类型会不断传染(virality)
- 所有调用者都必须处理错误
3. 学生使用体验:
IF/ELSE 到处都是
if(auto e = p_pin.level(true); !e){return std::unexpected(e.error());}
'到处都是 IF/ELSE,好啰嗦,我直接 (void) 掉好了…'
于是他们写出:
直接忽略错误。
这虽然避免代码臃肿,但等于把错误丢进黑洞中。
对嵌入式来说非常危险!
4. Error virality(错误传播的'病毒式蔓延')
为什么说它像'病毒'?
因为如果一个函数调用了会失败的函数,它也必须能返回错误:
如果:
status write_command(...);
status move_to(...);
status rotate(...);
status configure_pid(...);
也都必须返回 status!
最终整个系统层层都变成:
数学类比:
如果最底层操作是
f(x) ∈ A ∪ E
则所有组合函数 g(f(x)) 必须也满足:
g(f(x)) ∈ B ∪ E
即错误集 E 会向上传播。
5. motion_controller 最终的 API
status rotate(...);
status move_to(...);
status configure_pid(...);
内部任意一步失败,都必须返回 error。
这就是 error virality 的典型体现。
6. 为什么最终是 'Everything returns status'?
- 到处写 IF/ELSE
- 到处写
expected<T,E>
- 复杂嵌套错误处理
- 错误类型膨胀
作者反思后采用:
- 所有 API 统一返回一个轻量级
status(枚举/整数)
- 使用 error propagator macro(例如 P2561R1 的
TRY(expr))自动传播错误
示例:
status toggle_led(output_pin& p_pin, milliseconds t){
SJ_CHECK(p_pin.level(true));
delay(t / 2);
SJ_CHECK(p_pin.level(false));
delay(t / 2);
return success();
}
- 代码冗长
- 错误返回类型'过于巨大'
- 需要写大量 if
7. 总结(非常关键)
| 方案 | 优点 | 缺点 |
|---|
| void 返回值 | 简单 | 无法传递错误 |
| bool / int 返回值 | 简单有效 | 错误信息不够丰富 |
| std::expected | 结构化错误,强类型,安全 | error virality,IF 爆炸 |
| std::expected + TRY 宏 | 自动传播 | 有一点宏魔法 |
| 统一返回 status 枚举 | 最简单最落地 | 错误信息较少 |
| 最终作者的观点: | | |
几乎所有 API 都必须返回错误。
但最佳方式不是 everywhere 使用 expected,
而是统一用 status + error propagation。
#⃣ 1. 背景:SJ_CHECK 是一个'错误自动传播'宏
- 如果
expr 返回 std::expected<T, E> 的 错误:
→ 自动 return unexpected(error) 向上返回
- 如果成功:
→ 提取
T 并返回
数学上:
SJ_CHECK(x) = { return unexpected(e), x = unexpected(e) [6pt] value, x = expected(value) }
它是 C++ 手写版的'Rust ? 运算符'。
#⃣ 2. 作者给出的示例代码
std::uint8_t const device_select = SJ_CHECK(pin1.level()) << 1 | SJ_CHECK(pin1.level());
auto const voltage = SJ_CHECK(v_sense[device_select].read());
#⃣ 3. 第一行:计算 device_select
std::uint8_t const device_select = SJ_CHECK(pin1.level()) << 1 | SJ_CHECK(pin1.level());
3.1 原始意图(无错误处理)应该是:
device_select = pin1.level() << 1 | pin1.level();
- 读取两次 IO 扩展器的某个 pin 电平
- 把高位与低位组合成 2-bit 的索引
数学形式:
让:
- 第一次读取值 = b_1
- 第二次读取值 = b_0
则:
device_select = b_1 × 2 + b_0
也就是:
device_select = (b_1 << 1) | b_0
3.2 但因为 pin1.level() 可能失败
pin1.level() 返回:
expected<bool, error_t>
所以不能直接使用,必须检查错误。
于是变成:
SJ_CHECK(pin1.level()) << 1 | SJ_CHECK(pin1.level());
所以这一行变成:
device_select = SJ_CHECK(b_1) << 1 | SJ_CHECK(b_0)
这就是作者说的 噪音(noisy)。
因为:
- 逻辑只想表达'两次读取并组合'。
- 但错误处理宏让代码变得冗长难读。
#⃣ 4. 第二行:读取电压
auto const voltage = SJ_CHECK(v_sense[device_select].read());
- 根据前面组合的索引选通 ADC 通道
- 调用
.read()(可能失败)
SJ_CHECK:自动抛出错误或返回值
数学形式:
voltage = SJ_CHECK(read(v_sense[device_select]))
这也是符合预期的,但是:
5. 为什么叫'MACROs were OK, but noisy'?
这些宏功能是对的,
但让代码阅读体验很糟糕。
原因:
1. 表达式里插满了宏,破坏原本的数学结构——变得难懂
2. 业务逻辑和错误处理纠缠在一起
3. 比如:
- '组合两次 pin1.level() 的电平' —— 本来是简洁的逻辑
- 却变成了两次
SJ_CHECK(...) 并排出现
- 打断视觉流
- 很'吵'
4. 宏隐藏了控制流(return error),影响可读性
6. 代码噪音示例对比(作者真正想表达的)
理想(没有 expected、没有宏)
device_select = pin1.level() << 1 | pin1.level();
voltage = v_sense[device_select].read();
真实(expected + 宏)
std::uint8_t const device_select = SJ_CHECK(pin1.level()) << 1 | SJ_CHECK(pin1.level());
auto const voltage = SJ_CHECK(v_sense[device_select].read());
最终总结:
错误传播宏(像 SJ_CHECK)虽然能正确传播错误,
但在表达式当中使用太频繁时,代码会变得非常啰嗦。
功能 OK,阅读体验 NOISY。
为什么不使用 C++ 异常(exceptions)?
用一个图表解释了为何在某些系统(尤其是嵌入式、实时系统、安全关键系统)中不使用 C++ exceptions。核心理由分为两大类:
1. Space 空间开销(代码大小、内存占用)
Requires Dynamic Memory / Heap —— 需要动态内存(堆)
C++ 异常机制在抛出和捕获过程中往往需要额外内存:
- 在抛出异常时,为了构造异常对象(例如
std::runtime_error),通常会使用堆分配。
- 某些 runtime 部件(如异常表)也需要额外空间。
如果你在一个无堆系统(no heap)、不允许 malloc 的系统中工作(如硬实时 RTOS,或微控制器),这会是致命问题。
图中写:
Requires Dynamic Memory / Heap
{ Requires Malloc, Unbounded Memory Usage }
意为:
- 使用 C++ 异常等于强依赖堆内存
$malloc$
- 异常对象大小不确定,可能造成不可预测的内存占用,导致系统不稳定
→ 'unbounded memory usage(失去内存上界)'
Increases Binary Size —— 增加二进制体积
- 异常表(Exception Tables)
- 栈回溯信息
- 类型识别支持(RTTI)
- 标准库运行时代码(用于构造异常类型)
Requires whole C++ STL
Run Time Type Info (RTTI)
Exception Tables
Exception Code
也就是说,即便你只 throw 一次,编译器也必须包含完整的异常处理支持代码,这会:
- 显著增大二进制体积
- 对嵌入式系统或固件(Flash/RAM 很小)很不友好
2. Time 时间开销(运行时不确定性)
Nondeterministic —— 非确定性
- 栈回溯需要动态检查当前 frame
dynamic_cast 需要执行类型匹配,依赖 RTTI
→ 可能包含哈希、字符串比较或表查找
Nondeterministic
Type Comparison (dynamic_cast)
在实时(real-time)系统中,任何不可预测的操作都是不允许的:
- 不能确定
throw 之后恢复执行的时机
- 步骤复杂,可能花费未定义的时间
- 在极端情况下可能导致 deadline miss(实时系统违例)
Slow —— 慢
- 查找异常处理器( binary search in tables )
- 栈展开(frame unwinding)
- 执行清理代码(frame evaluation)
图中写:
Slow
Binary Search
Frame Unwinding
Frame Evaluation
这都意味着:
总结:为什么不能用 C++ 异常?
| 维度 | 问题 |
|---|
| 空间 Space | 需要堆、需要 malloc、不确定的内存使用、二进制膨胀(RTTI、异常表等) |
| 时间 Time | 执行路径不可预测、类型比较耗时、栈展开慢、不能保证实时性 |
| 确定性 | 异常流程是非确定性的,对安全关键系统不可信 |
| 可控性 | 异常传播难以精确控制,容易跨越函数边界破坏系统结构 |
| 因此,在某些领域(如: | |
- 嵌入式固件
- 航空航天软件
- 汽车 ECU
- 高可靠系统
- 实时系统
)禁用 C++ exceptions 是常见实践。
「Most accepted it… Some asked why?」—— 大多数人接受了,但有些人问:为什么?
作者之前解释了'为什么在嵌入式系统上不用 C++ 异常(exceptions)',但仍然有人继续问几个关键问题:
为什么 C++ exceptions 是**非确定性(non-deterministic)**的?
- 异常抛出后需要在异常表中寻找处理器
→ 处理器查找过程是数据驱动的,开销不可预测
- 栈展开(unwinding)的深度与当前调用栈有关
→ 深度不固定
- 每一次
throw 的执行时间无法保证固定上界
所以在实时系统(RT)里,不会给你一个固定执行时间 Tmax,导致不可用于硬实时场景。
为什么 C++ exceptions 很慢(slow)?
- 在 LSDA(Language Specific Data Area)表中查找异常处理器
→ 通常是 binary search(二分查找)
- 执行栈展开(Frame Unwinding)
→ 要逐帧清理、调用析构函数
- 匹配类型
→ 依赖 RTTI 结构,需要比较 type_info
任何一步都不轻量。
为什么 C++ exceptions 会导致代码膨胀(bloat code)?
- Exception Table(异常表)
- LSDA(语言特定数据区)
- RTTI(类型信息)
- frame unwind 信息
- 清理路径代码(landing pads)
因此,即使你只
throw 一次,编译器也必须生成完整异常支持框架。
所以 二进制大小显著增加。
为什么 C++ exceptions 需要堆(heap)?
- 异常对象在抛出时要构造
- 大多数实现需要为异常对象分配内存(通常在堆上)
- 若异常对象太大时,会动态分配临时缓冲区
因此:
必须依赖 malloc(堆内存)或某些特殊的运行时缓冲。
这对无堆系统(no-heap RTOS, micro-controller)非常致命。
「Let's make exceptions work on ARM!」
现在我们来真的:在 ARM 上让 C++ 异常跑起来
在 ARM Cortex-M MCU 上用 GCC 开启 exceptions 和 RTTI。
使用的工具链:
Arm GNU Toolchain GCC 11.3
编译命令详解(逐项解释)
arm-none-eabi-g++ -o except.elf except.cpp -std=c++20 -Os -g \
-fexceptions -frtti \
-mcpu=cortex-m4 -mfloat-abi=hard -mthumb \
--specs=nosys.specs --specs=nano.specs \
-Wl,-T linker.ld
编译器和输出文件
arm-none-eabi-g++ -o except.elf except.cpp
这是 ARM 裸机(bare-metal)编译器,输出 except.elf 固件。
C++ 标准与优化
-std=c++20:使用 C++20
-Os:优化二进制体积(更能看出异常带来的膨胀)
-g:加入调试符号
开启异常与 RTTI(关键部分)
-fexceptions:启用 C++ 异常支持
-frtti:启用运行时类型识别(typeid、dynamic_cast 所必需)
这两个开关会显著增加代码大小。
ARM 目标配置
-mcpu=cortex-m4 -mfloat-abi=hard -mthumb
cortex-m4:目标 MCU,常见于 STM32、NXP、TI 等
-mfloat-abi=hard:使用硬浮点
-mthumb:生成 Thumb 指令(更小更快)
链接规格
--specs=nosys.specs --specs=nano.specs -Wl,-T linker.ld
nosys.specs:不使用全功能 libc(适用于裸机)
nano.specs:使用 nano libc,占空间更小
-T linker.ld:使用自定义链接脚本(控制 FLASH、RAM 布局)
示例代码讲解
[[gnu::noinline]]int start(){throw 5;}
int main(){
volatile int return_code = 0;
try{
return_code = start();
}catch(...){
return_code = -1;
}
return return_code;
}
[[gnu::noinline]]
- 让异常真正跨函数抛出
- 触发完整的栈展开过程(更能展示异常的真实成本)
start() 中直接 throw 5;
抛出一个整数异常,这是最简单的异常类型。
尽管简单,但依然会触发:
- 构造异常对象
- 在异常表中查找 catch handler
- frame unwinding
- 调用 landing pad
try / catch (...)
捕获 任何类型 的异常,然后将 return_code 设为 -1。
这个例子非常小,但在 ARM Cortex-M 上:
- 二进制大小会急剧增长
- 需要大量 unwinding 表
- 导致堆栈、Flash 占用明显上升
这正是作者要展示的重点:
即使如此简单的例子,也能显示异常的巨大成本。
#1⃣ Barrier #1 — 'Exceptions disabled!'
- 在 默认的 ARM GCC/Arm GNU Toolchain 配置下
- C++ 异常机制是被彻底禁用的(-fno-exceptions)
- 因此:
只要你的代码执行了
throw,程序就立刻死掉
通常表现为 HardFault、UsageFault 或直接 Reset。
原因:
- 底层运行时根本没有异常处理表(EH tables)
- 没有栈展开器(unwinder)
- 也没有异常对象内存分配(依赖
_sbrk)
所以在未启用 exceptions 时,执行:
#2⃣ Breaking Barrier #1
① Step 1: 下载并构建 ARM GNU Toolchain
(其实就是你本地要有一个支持编译 C++ 的 arm-none-eabi-g++)
② Step 2: 找到并修改编译器默认设置
把 -fno-exceptions 改成 -fexceptions
GCC 的裸机 specs 文件(如 nano.specs)默认包含:
-fno-exceptions -fno-rtti
否则即使你写 try/catch 编译器也不会生成异常表。
③ Step 3: 定义底层堆内存接口 _sbrk
- 分配异常对象
- 在栈展开期间创建一些辅助结构
- 运行时需要动态内存
所有这些都依赖 Unix 兼容的
_sbrk 来提供堆。
因为 MCU 上 默认没有堆,所以你必须自己提供 _sbrk。
_sbrk 实现讲解
extern "C" {
std::array<std::byte, 1024> heap_memory{};
std::span<std::byte> available_memory = heap_memory;
void *_sbrk(int p_amount){
if(p_amount > available_memory.size()){return nullptr;}
auto result = available_memory.subspan(0, p_amount);
available_memory = available_memory.subspan(p_amount);
return result.data();
}
}
1. 定义 1024 字节'手工堆内存'
std::array<std::byte, 1024> heap_memory{};
这块内存模拟 堆区(heap)。
异常运行时抛异常时需要在堆里'构造异常对象',这段内存就是为此准备的。
2. 用 span 标记未使用的部分
std::span<std::byte> available_memory = heap_memory;
available_memory 是当前剩余堆空间。
3. 实现 _sbrk
嵌入式系统的 new/malloc 会调用 _sbrk 申请更多内存。
sbrk(amount) 的作用是:
'给我 amount 字节的堆空间'
代码逻辑是:
若空间不足 → 返回 nullptr
若空间足够 → 划分返回,并更新 available_memory
auto result = available_memory.subspan(0, p_amount);
available_memory = available_memory.subspan(p_amount);
return result.data();
为什么 C++ 异常需要 _sbrk?
- 构造异常对象(例如
throw std::string("xxx"))
- 存储'被抛出的值'(哪怕是
throw 5,也要存储)
- C++ runtime 会建立辅助结构(如 unwinding context)
这些都需要动态内存。
所以没有
_sbrk 就无法支持 exceptions。
小结:屏障 #1 本质是——没有堆就不能使用 C++ 异常
Barrier #1 的本质:
| 必需组件 | 缺失导致的后果 |
|---|
| Exception Tables | 没有信息可供 unwinder 工作 |
| Stack Unwinder | 无法进行栈展开 |
| RTTI | 无法匹配 type_info |
_sbrk(堆) | 无法分配异常对象 |
| 编译器 flags | 没有生成异常元数据 |
| 所以 MCU 默认把异常关闭是完全合理的。 | |
| 启用异常的步骤: | |
- 有异常支持的 GCC
- 开
-fexceptions 和 -frtti
- 提供
_sbrk(模拟堆内存)
🟦 IT WORKS!
异常机制终于能在 ARM Cortex-M 上运行了。
但是……
Barrier #2 — Large Binary Sizes
(第二道障碍:二进制尺寸极大)
一旦启用了 C++ 异常(-fexceptions),即使程序中只写了一个简单的:
尺寸对比:令人震惊的差异(+56×)
① 使用异常(exceptions)后的大小
text data bss dec hex filename
150008 2016 1328 89352 15d08 except.elf
text = 150008 字节,主要是代码段(Flash)
- 在常见 512 KB Flash 的 MCU 上占用比例:
150008 / (512 × 1024) ≈ 0.286 ≈ 29%
也就是说:
仅仅为了让异常能工作,就占掉了 MCU Flash 的 29%!
② 使用 std::expected<int, int>(不启用异常)
text data bss dec hex filename
2680 1372 840 7516 1d5c expected.elf
text 段只有 2680 字节
- 与 baseline 几乎一样 → expected 几乎无额外开销
异常版本 vs expected 的倍数:
150008 / 2680 ≈ 56
也就是:
异常版本比 expected 大 56 倍
③ Baseline(普通 C++ 程序,无异常、无 expected)
text data bss dec hex filename
2656 1372 840 7516 1d5c baseline.elf
- baseline 与 expected 相同
- 说明 expected 的成本几乎忽略不计
为什么异常会让 MCU 程序变大?
启用 C++ 异常后,GCC 会自动加入大量运行时代码,包括:
1. Exception Tables(异常查找表)
.ARM.exidx
.ARM.extab
.eh_frame
用于描述:
- 栈展开(stack unwinding)
- 每个函数的 unwind 信息
- 异常流控制跳转
它们通常就几十 KB。
2. Stack Unwinder(栈展开器)
- 能解析 CFI(Call Frame Information)
- 能恢复每一层栈
- 需要大量逻辑支持
这是程序体积暴增的主要来源之一。
3. RTTI(Run-Time Type Information)
typeinfo
dynamic_cast
- 异常类型树结构
这会增加几 KB~十几 KB 的体积。
4. C++ ABI 支持代码
__cxa_throw
__cxa_begin_catch
__cxa_end_catch
- personality function(人格例程)
这些都是异常系统的基础设施。
关键点总结
不是你 throw 了什么导致程序变大
而是启用异常机制需要整个运行时系统,这个系统非常庞大。
三种方案体积对比总结
| 配置 | text(字节) | 相对 baseline |
|---|
| baseline | 2656 | 1× |
| expected | 2680 | 1.01× |
| exceptions | 150008 | 56× |
最终结论
在 MCU 上启用 C++ 异常等同于:
把一个完整的异常运行时系统(几十 KB)强行塞进只有几百 KB 的 Flash 中。
相比之下:
std::expected 是零成本抽象,非常适合嵌入式。
三个完整的汇编片段 做成 结构化、成段、逐句的解释
1. d_growable_string_callback_adapter — 字符串拼接逻辑(异常系统所依赖的工具函数之一)
0000a344 <d_growable_string_callback_adapter>:
a344: b5f0 push {r4,r5, r6,r7, lr}
a346: 4614 mov r4, r2
a348: 6852 ldr r2,[r2,#4]
a34a: 68a5 ldr r5, [r4, #8]
a34c: 1c4b adds r3,r1,#1
a34e: 4413 add r3,r2
a350: 42ab cmp r3, r5
a352: b083 sub sp, #12
a354: 460e mov r6,r1
a356: 4607 mov r7,r0
a358: d811 bhi.n a37e <...+0x3a>
a35a: 68e5 ldr r5, [r4,#12]
a35c: b96d cbnz r5, a37a <...+0x36>
a35e: 6863 ldr r3, [r4,#4]
a360: 6820 ldr r0, [r4,#0]
a362: 4632 mov r2,r6
a364: 4418 add r0, r3
a366: 4639 mov r1, r7
a368: f008 f9a2 bl 126b0 <_aeabi_memcpy>
a36c: e9d4 3200 ldrd r3,r2,[r4]
a370: 4433 add r3, r6
a372: 549d strb r5, [r3,r2]
a374: 6863 ldr r3, [r4,#4]
a376: 4433 add r3,r6
这段代码的本质
这是 GCC 用于 构造异常消息字符串、栈展开日志字符串 的内部函数之一。
看到:
memcpy
- 字节追加
- growable buffer(可扩展字符串)
- 大量边界检查
这些都是
libstdc++ 在格式化异常消息时会使用的。
对 MCU 是巨大的灾难
std::exception::what()
- terminate handler 输出信息
- demangle 类型名、拼接字符串
所以 你只要启用 C++ 异常,编译器就必须把这些用上,哪怕你根本没有手动构造字符串。
结果:
- 引入大量字符串处理代码
- memcpy / strlen
- 可增长 buffer
- 异常信息格式化代码
最终导致:
binary size × 数十倍增长。
2. d_append_num — 格式化数字(sprintf !)
0000a5c4 <d_append_num>:
a5c4: e92d 41f0 push {r4,r5,r6,r7,r8,lr}
a5c8: b088 sub sp,#32
a5ca: 460a mov r2,r1
a5cc: 4604 mov r4,r0
a5ce: 491a ldr r1,[pc,#104] ; (a638)
a5d0: a801 add r0,sp,#4
a5d2: f006 fd91 bl 110f8 <sprintf>
a5d6: a801 add r0,sp,#4
a5d8: f008 f880 bl 126dc <strlen>
a5dc: b340 cbz r0, a630
a5de: ad01 add r5,sp,#4
a5e0: f8d4 1100 ldr.w r1,[r4,#256] ; 0x100
a5e4: 182f adds r7, r5, r0
a5e6: f04f 0800 mov.w r8,#0
a5ea: e009 b.n a600
a5ec: 460b mov r3,r1
a5ee: 42bd cmp r5,r7
这段代码的本质
相关免费在线工具
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
- Base64 文件转换器
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
- Markdown转HTML
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
- HTML转Markdown
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
- JSON 压缩
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
- JSON美化和格式化
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online