CppCon 2024 学习: C++ Exceptions for Smaller Firmware 第一部分
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 总共 64,KB64,\text{KB}64,KB,动态分配反复产生碎片:
[#####][ ][#####][##][ ] 最终你可能“还有 10KB 空闲”,但没有连续块 ⇒ 分配失败。
❷ 分配时间无法保证(非确定性)
malloc() / new 的执行时间是不可预测的:
tmalloc=不确定,可长可短 t_{malloc} = \text{不确定,可长可短} tmalloc=不确定,可长可短
在实时系统(RTOS)或者中断环境中,这是不能接受的。
❸ 可能崩溃导致系统挂死
由于 MCU 没有操作系统,没有内存保护(MMU),越界或分配失败就会 直接死机、硬 fault。
3. 典型最佳实践:不要用动态分配
所以嵌入式行业通用的 best practice:
避免 new / malloc / free / delete / STL 容器
因为它们内部会触发动态分配。
不要用:
new/deletemalloc()/free()std::stringstd::vectorstd::unordered_mapstd::shared_ptrstd::unique_ptr(本身不分配,但托管对象通常是 on heap)
4. 那不用 STL 怎么办?
C++ 标准库很多容器在内部使用 动态分配(heap):
std::vector:内部用reallocstd::string:内部动态扩容std::map:节点动态分配std::unordered_map:动态桶扩容
这些对于 MCU 来说都 不可接受。
5. 解决方案:Embedded Template Library(ETL)
ETL 的核心思路:
所有数据结构都固定大小(编译期确定)
你必须在使用前定义最大容量。
例如,用 ETL 的 etl::vector< int, 32 >
这表示:
- 最大容量 = 32
- 永不动态分配内存
- 所有空间在编译期/静态区/栈上分配
与 STL 的对比:
| Feature | STL vector | ETL vector |
|---|---|---|
| 内存位置 | heap | stack/static |
| 是否动态扩容 | 是 | 否 |
| 最大容量可变 | 是 | 否(固定) |
| 适合 MCU? | 否 | 是 |
6. 为什么说 ETL 更适合 MCU?
确定性(Deterministic)
所有操作的时间复杂度固定,不会突然卡顿。
可预测的内存占用
你提前就知道它占用多少 RAM:
如果定义:
etl::vector<int,100> v;占用的 RAM 是:
100×sizeof(int) 100 \times sizeof(int) 100×sizeof(int)
绝对不会再多。
兼容 C++ 模板 & 现代功能
虽然很多 MCU 编译器只到 C++03,
但 ETL 提供:
optionalvariantspanstring_view- 固定容量容器
- 算法
相当于“嵌入式版的 STL + 现代特性”。
7. 总结(精炼版)
MCU 的问题:
- 内存极小
- 无操作系统
- 无文件系统
- 无线程
- 无系统调用
- 动态内存不可预测、易碎片化、易崩溃
常见经验:
嵌入式开发中几乎所有经验丰富工程师的风格都是:
尽量不用动态分配,用静态/固定容量替代。
ETL 的作用:
提供 完全不使用动态内存的 STL 替代品
同时兼容现代 C++ 风格。
1. std::inplace_vector(C++26 的新容器)
什么是 std::inplace_vector?
概念一句话:
一个“可增长但不动态分配”的 vector:容量固定,但元素数量可变。
它结合了:
std::array的 固定容量、无 heapstd::vector的 push_back / pop_back / resize 行为
也就是说:
std::inplace_vector<int,16> v;// 最大容量为 16// 永远不动态分配(不触发 new/malloc)// size() 可在 0~16 间变化这是 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)
标准 C++ 异常系统涉及:
- 捕获时分配对象
- 存储异常信息(type_info、what() 字符串等)
- 运行时需要异常表(EH tables)和 unwind 信息
在许多编译器实现中,抛出异常会触发堆分配,例如:
throw ⟹operator new() \text{throw} \ \Longrightarrow \text{operator new()} 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 \text{Firmware Size} = \text{Code} + \text{RTTI} + \text{Exception Tables} Firmware Size=Code+RTTI+Exception Tables
在 Flash 只有 64KB64\text{KB}64KB~256KB256\text{KB}256KB 的 MCU 中,这是致命的。
二、TIME(时间成本)
1) 非确定性(Nondeterministic)
异常抛出过程包含:
- 栈回溯(stack unwinding)
- 查找匹配的 catch
- 执行析构函数
- 运行时类型匹配(可能涉及
dynamic_cast)
这些操作时间不可预测:
tthrow=未知,不是常数时间 t_{\text{throw}} = \text{未知,不是常数时间} tthrow=未知,不是常数时间
对于实时系统(例如控制电机、读取传感器),
这种不确定性是不可接受的。
2) 慢
异常抛出不是简单的跳转(不像 setjmp/longjmp)。
它可能涉及:
- 二分查找异常表
- unwinder 调用 frame handler
- 逐层执行析构函数
- 寻找匹配类型
整个过程复杂而昂贵。
因此:
在 MCU 里 “异常抛出” 的成本可以达到 几十到几百微秒,甚至更多。
在实时系统,这是“灾难级”的慢。
三、动态内存依赖(DYNAMIC MEMORY)
需要 malloc(或 new)
在许多实现中,异常系统在内部需要分配存储空间。
嵌入式禁用 heap 的常见做法:
#definemalloc(x)error_must_not_use_heap()如果你开启异常,即使程序从不 throw,
编译器也可能需要链接异常运行时代码 → 编译失败或运行时崩溃。
总结:为什么嵌入式不用异常?
用一句话总结三类原因:
嵌入式避免 C++ 异常的三大理由:
① 空间不可接受:
- 异常运行库太大
- 需要 RTTI,平均增加数 KB~数十 KB Flash
- 需要
.eh_frame等元数据
② 时间不可接受:
- 栈展开(unwinding)速度慢且不可预测
- 匹配类型(typeinfo)是运行时操作
③ 动态内存不可接受:
- 可能隐式调用
malloc - 堆碎片化会导致系统崩溃
因此:
绝大多数嵌入式团队都禁用异常和 RTTI
例如 GCC/Clang 编译选项:
-fno-exceptions -fno-rtti 额外内容:嵌入式异常的安全替代方案
现代嵌入式 C++ 专案一般用:
std::expected<T, E>
无异常、无 heap 的错误处理方式。
etl::expected
ETL 的嵌入式版本。
状态码(error code)
最传统但最可靠。
[[nodiscard]] 强制检查返回值
避免 silent error。
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:定时器 PWMtimer.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(复用)
每个引脚不是只有一个功能,而是有 NNN 种可能:
Pin Function=GPIO,UART,SPI,I2C,... \text{Pin Function} = {\text{GPIO}, \text{UART}, \text{SPI}, \text{I2C}, ...} Pin Function=GPIO,UART,SPI,I2C,...
复用器(Mux)决定了当前选哪个功能:
PinMux[Pin]=FunctionID \text{PinMux[Pin]} = \text{FunctionID} PinMux[Pin]=FunctionID
例如:
Pin P2.0: FUNC0 = GPIO FUNC1 = UART0_TX FUNC2 = PWM3_OUT FUNC3 = TIMER_CAP0 如果你不处理 pinmux 就让外设初始化 → 肯定无法工作。
4. 为什么在库设计里要反复强调 “Consider the output pin”?
因为 MCU 外设库的 API 必须保证:
- 选择正确的引脚
- 配置正确的功能复用
- 配置方向(输入/输出)
- 配置速度模式
- 配置上拉/下拉
- 配置驱动特性(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
假设一个引脚有 kkk 个可选功能:
Pin P⇒f0,f1,f2,…,fk−1 \text{Pin P} \Rightarrow { f_0, f_1, f_2, \ldots, f_{k-1} } Pin P⇒f0,f1,f2,…,fk−1
选择功能就是从该集合中设置一个映射:
PinMux[P]=fi \text{PinMux[P]} = f_i PinMux[P]=fi
在 C/C++ 中,这通常对应寄存器:
PINSEL[P]= i;在 C++ HAL 中,通常是:
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); // ... } 每种外设都必须知道使用的 pin:
- 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 很重要?
因为每个引脚的电气特性也不同,例如:
- VOHV_{OH}VOH (输出高电压)
- VOLV_{OL}VOL (输出低电压)
- 输出驱动能力 IoutI_{out}Iout
- 上拉/下拉阻值
- 输入施密特触发
- 数字/模拟模式切换
例如一个输出 pin 的极限电流 ImaxI_{max}Imax:
Imax=20,mA I_{max} = 20 , \text{mA} Imax=20,mA
如果你驱动一个 LED:
I=V−VforwardR I = \frac{V - V_{forward}}{R} I=RV−Vforward
你需要检查是否会烧掉 MCU 引脚。
因此 “Consider the output pin” 不是废话,而是一堂课:
每次使用 MCU 外设前,必须考虑 Pin 的功能、电气特性与映射关系。
总结
MCU 外设驱动库设计的根本基础是“对引脚的理解”
所有外设功能都必须通过 pin mux 映射
单个引脚可能拥有 5~10 种不同功能
使用外设前,必须正确选择引脚、配置复用与方向
忽视 pin → peripheral 映射是初学者最大的错误来源
下面是对你给出的 SVG 电路图 的 完整解析,包括:
MicrocontrollerPinresistorLOW = ~0VtimeVoltage
<?xml version="1.0" encoding="UTF-8"?><svgwidth="800"height="400"font-family="Arial, sans-serif"version="1.1"xmlns="http://www.w3.org/2000/svg"><!-- Microcontroller box --><rectx="30.209"y="100.05"width="219.74"height="149.9"fill="none"stroke="#000"stroke-width="2"/><textx="138.6256"y="227.63908"font-size="28px"font-weight="bold"text-anchor="middle">Microcontroller</text><!-- GPIO pin line --><linex1="250"x2="335.92"y1="175"y2="175"stroke="#000"stroke-width="3"/><textx="285"y="160"font-size="20"text-anchor="middle">Pin</text><!-- Resistor --><rectx="338"y="155"width="100"height="40"rx="5"fill="none"stroke="#000"stroke-width="2"/><textx="388"y="182"font-size="20px"text-anchor="middle">resistor</text><gstroke="#000"><linex1="440"x2="500"y1="175"y2="175"stroke-width="3"/><!-- Transistor (NPN) --><linex1="515"x2="490"y1="175"y2="175"stroke-width="3"/><linex1="516.87"x2="516.87"y1="152.99"y2="196.59"stroke-width="2.2148"/><gstroke-width="3"><linex1="516.38"x2="556.38"y1="196.23"y2="176.23"/><linex1="517.52"x2="557.52"y1="155.01"y2="175.01"/><linex1="574"x2="574"y1="155"y2="195"/></g></g><polygontransform="matrix(5.0442 0 0 1.0682 -2401.7 -12.408)"points="578 200 578 150 590 175"/><gstroke="#000"><!-- Connection to ground --><linex1="574.69"x2="650"y1="175"y2="175"stroke-width="3.0464"/><linex1="650"x2="650"y1="175"y2="300"stroke-width="3"/><!-- Ground symbol --><linex1="610"x2="690"y1="300"y2="300"stroke-width="4"/><linex1="620"x2="680"y1="310"y2="310"stroke-width="3"/><linex1="630"x2="670"y1="320"y2="320"stroke-width="2"/><linex1="640"x2="660"y1="330"y2="330"/><!-- Voltage waveform --></g><textx="350"y="280"fill="#0066ff"font-size="36"font-weight="bold">LOW = ~0V</text><!-- Time axis --><linex1="305.36"x2="617.96"y1="340"y2="340"stroke="#000"stroke-width="2.3306"/><textx="400"y="370"font-size="24"text-anchor="middle">time</text><!-- LOW level waveform --><linex1="304.83"x2="615.82"y1="320"y2="320"stroke="#06f"stroke-width="4.3197"/><linex1="322.51"x2="322.51"y1="316.45"y2="336.45"stroke="#000"stroke-width="2"/><!-- Arrow for voltage --><linex1="322.75"x2="322.75"y1="220"y2="320"marker-end="url(#arrow)"stroke="#000"stroke-width="2"/><texttransform="rotate(-90)"x="-277.58295"y="309.57346"font-size="18px">Voltage</text><!-- Arrow marker definition --><defs><markerid="arrow"markerHeight="10"markerWidth="10"orient="auto"refX="8"refY="3"><pathd="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 | +------------------------+ 这是 MCU 芯片本体,里面有一个“Pin”。
(2) Pin(GPIO 输出引脚)
图中 MCU 的 GPIO 引脚抽象为一条线:
MCU Pin ----> 这个 Pin 可以输出高电平或低电平:
- HIGH:Vout=VDDV_\text{out} = V_{DD}Vout=VDD(比如 3.3 V)
- LOW:Vout≈0 VV_\text{out} \approx 0\text{ V}Vout≈0 V
重点:
当 GPIO 输出 LOW 时,它实际上是把该引脚内部连接到地(GND)。
这就是示波图上显示“LOW ≈ 0V” 的原因。
(3) Resistor(限流电阻)
GPIO Pin → 电阻 → 三极管基极。
该电阻的作用是:
限制基极电流 IBI_BIB
由于三极管基极电流不能太大,需要电阻保护。
电阻一般计算:
IB=VGPIO−VBERB I_B = \frac{V_\text{GPIO} - V_{BE}}{R_B} IB=RBVGPIO−VBE
其中:
- VGPIOV_\text{GPIO}VGPIO ≈ 3.3V(MCU 输出高电平)
- VBEV_{BE}VBE ≈ 0.7V(硅三极管导通电压)
- RBR_BRB:基极电阻
例如:
IB=3.3−0.710kΩ≈0.26mA I_B = \frac{3.3 - 0.7}{10k\Omega} ≈ 0.26\text{mA} IB=10kΩ3.3−0.7≈0.26mA
3. NPN 三极管部分详细解释
示意图中有:
Collector (C) Base (B) Emitter (E) 连接方式典型为:
MCU → Resistor → Base Collector → Load / Output Emitter → Ground 晶体管导通条件:
VB−VE>0.7V V_B - V_E > 0.7\text{V} VB−VE>0.7V
即:
VB>0.7V V_B > 0.7\text{V} VB>0.7V
因为 VEV_EVE 接地(0V)。
导通时:
- 晶体管变成 开关闭合
- 电流从 Collector → Emitter 流向地
4. LOW = ~0V 的电压图解释
图中有:
LOW = ~0V 并画了一条蓝色的直线靠近 0V。
这是解释:
当 GPIO 输出 LOW 时:
Vpin≈0V V_\text{pin} \approx 0\text{V} Vpin≈0V
因此晶体管基极电压也变成:
VB≈0V V_B \approx 0\text{V} VB≈0V
因此:
VB−VE=0−0=0<0.7 V_B - V_E = 0 - 0 = 0 < 0.7 VB−VE=0−0=0<0.7
晶体管不会导通。
5. 电压时间图(Voltage-Time Graph)
你画的坐标轴:
- Y 轴:Voltage(电压)
- X 轴:time(时间)
- 蓝线在接近 0V 的位置
这代表:
GPIO 引脚持续输出 LOW 电平(恒 0V)。
换句话说,在该时刻窗口中:
V(t)=0V V(t) = 0\text{V} V(t)=0V
6. 这幅图完整表达的意义
用一句话解释整个图:
MCU 的 GPIO 输出低电平 → 通过电阻拉低三极管基极 → 三极管关闭 → 电路不导通 → 输出保持 0V。
再多讲一句:
这是 MCU 控制晶体管的“关闭”状态。
当 MCU 输出 HIGH 时整个电路会完全改变(基极被驱动,三极管导通)。
MicrocontrollerPinresistorelectrical currentHIGH = ~3.3VtimeVoltage
<?xml version="1.0" encoding="UTF-8"?><svgwidth="800"height="400"font-family="Arial, Helvetica, sans-serif"version="1.1"xmlns="http://www.w3.org/2000/svg"><!-- Microcontroller box --><rectx="39.41"y="100.03"width="210.56"height="149.95"fill="none"stroke="#000"stroke-width="2"/><textx="145.82939"y="236.04265"font-size="28px"font-weight="bold"text-anchor="middle">Microcontroller</text><!-- Pin label and line --><linex1="250"x2="339.72"y1="175"y2="175"stroke="#000"stroke-width="3"/><textx="285"y="160"font-size="20"text-anchor="middle">Pin</text><!-- Resistor --><rectx="340"y="155"width="100"height="40"rx="5"fill="none"stroke="#000"stroke-width="2"/><textx="390"y="182"font-size="20"text-anchor="middle">resistor</text><linex1="440"x2="500"y1="175"y2="175"stroke="#000"stroke-width="3"/><!-- LED (green, glowing) --><polygonpoints="540 175 500 150 500 200"fill="#40c090"stroke="#000"stroke-width="3"/><!-- LED light rays --><pathd="m516.49 154.25 3.1897-4.52 11.12-15.757"fill="#d45500"opacity=".8"stroke="#40c090"stroke-width="3.8974"/><pathd="m527.13 160.93 14.309-20.277"fill="#40c090"fill-opacity=".8"opacity=".8"stroke="#40c090"stroke-width="3.8974"/><gstroke="#000"><gstroke-width="3"><!-- LED cathode line --><linex1="545.28"x2="620"y1="175"y2="175"/><linex1="544.02"x2="544.37"y1="154.71"y2="194.71"/><linex1="620"x2="620"y1="175"y2="300"/><!-- Ground symbol --></g><linex1="580"x2="660"y1="300"y2="300"stroke-width="4"/><linex1="590"x2="650"y1="310"y2="310"stroke-width="3"/><linex1="600"x2="640"y1="320"y2="320"stroke-width="2"/><linex1="610"x2="630"y1="330"y2="330"/><!-- Current arrow (red) --></g><texttransform="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><pathd="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"/><!-- Voltage waveform (HIGH = ~3.3V, red) --><textx="333.35107"y="278.24408"fill="#ff2222"font-size="40px"font-weight="bold">HIGH = ~3.3V</text><!-- Time axis --><linex1="266.78"x2="614.27"y1="341.14"y2="341.14"stroke="#000"stroke-width="2.5954"/><textx="450"y="370"font-size="24"text-anchor="middle">time</text><!-- HIGH level red waveform --><linex1="270.57"x2="613.51"y1="229.62"y2="229.62"stroke="#f22"stroke-width="5.2921"/><!-- Voltage axis arrow --><linex1="276.2"x2="277.1"y1="338.68"y2="232"marker-end="url(#arrowhead)"stroke="#000"stroke-width="2.0658"/><texttransform="rotate(-90)"x="-316.4455"y="267.1564"font-size="18px">Voltage</text><!-- Arrowhead definition --><defs><markerid="arrowhead"markerHeight="10"markerWidth="10"orient="auto"refX="8"refY="3"><pathd="m0 0v6l9-3z"/></marker></defs><polygontransform="matrix(.62671 -1.0664 .20835 .12244 131.88 729.32)"points="578 150 590 175 578 200"fill="#40c090"fill-opacity=".8"/><polygontransform="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.3V3.3\text{V}3.3V
- LOW(低电平)≈ 0V0\text{V}0V
高低电平的切换随图中时间轴变化。
2. GPIO Pin → Resistor(电阻)
GPIO 引脚通过一条导线连接到一个电阻(通常是限流电阻)。
为什么需要电阻?
当 GPIO 驱动 LED 时,如果不加电阻,LED 会因为电流过大而烧坏,同时 MCU 引脚也会因超出 ImaxI_{max}Imax 而损坏。
电阻的作用是:
I=VGPIO−VLEDR I = \frac{V_{GPIO} - V_{LED}}{R} I=RVGPIO−VLED
限制电流在安全范围,比如 5–20mA5–20\text{mA}5–20mA。
3. LED(发光二极管)
SVG 中 LED 被画成绿色三角形。
LED 有两个端:
- Anode(阳极) → 连接在靠近电阻这一侧
- Cathode(阴极) → 连接到地(GND)
LED 区域的绿色说明它处于亮灯状态(概念图)。
LED 只有在以下情况下才会导通发光:
VAnode−VCathode≥Vforward≈2V V_{Anode} - V_{Cathode} \ge V_{forward} \approx 2\text{V} VAnode−VCathode≥Vforward≈2V
例如绿色 LED 的典型压降 Vf≈2.1VV_f ≈ 2.1\text{V}Vf≈2.1V。
4. GND(地)
右侧是接地符号。
当 LED 阴极被接到 GND 时,电流可以形成闭环:
GPIO→R→LED→GND GPIO \rightarrow R \rightarrow LED \rightarrow GND GPIO→R→LED→GND
从而点亮 LED。
5. electrical current(电流方向)
图中红色箭头 “electrical current” 表示传统电流方向:
从正电位(3.3V)流向低电位(GND) \text{从正电位(3.3V)流向低电位(GND)} 从正电位(3.3V)流向低电位(GND)
也就是:
- HIGH 时:GPIO → 电阻 → LED → GND
- LOW 时:几乎没有电流(LED 灭)
注意:电子流方向与传统电流方向相反,但电路图习惯使用传统电流。
6. HIGH / LOW 电平波形
HIGH(红色水平线)
当 GPIO 输出:
VGPIO=3.3V V_{GPIO} = 3.3\text{V} VGPIO=3.3V
LED 阳极 ≈ 3.3V3.3\text{V}3.3V
阴极 ≈ 0V0\text{V}0V
满足导通条件,因此 LED 亮。
图中红色“HIGH = ~3.3V” 表示 GPIO 正输出高电平。
LOW(蓝色水平线)
当 GPIO 输出:
VGPIO=0V V_{GPIO} = 0\text{V} VGPIO=0V
LED 阳极 ≈ 0V0\text{V}0V
阴极 ≈ 0V0\text{V}0V
VAnode−VCathode=0<Vf V_{Anode} - V_{Cathode} = 0 \lt V_f VAnode−VCathode=0<Vf
LED 不导通,因此熄灭。
图中蓝色“LOW = ~0V” 表示 GPIO 拉低输出。
7. 时间轴(time)
底部横线是时间轴:
- 随时间变化,GPIO 输出电平可能在 HIGH/LOW 之间切换
- 对应 LED 的亮灭变化
- 对应电流有/无
总结(整张图的含义)
图示描述的核心是:
微控制器 GPIO 推挽输出 → 电阻限流 → LED → 接地
HIGH 输出时,电流从 GPIO 流向地,LED 亮
LOW 输出时,无电流流动,LED 灭
数学关系:
I=VGPIO−VfR I = \frac{V_{GPIO} - V_f}{R} I=RVGPIO−Vf
LED 亮灭完全由 GPIO 电平决定。
classoutput_pin{public:virtual~output_pin()=default;virtualvoidlevel(bool p_high)=0;};问题:level(bool) 这样返回 void 是否合理?
答案:
是合理的,而且对于 MCU GPIO 来说,这是最常见、最“嵌入式友好”的设计。
原因如下。
1. GPIO 输出通常“不失败”
当你设置 GPIO 输出电平:
GPIO_ODR=1(高电平) \text{GPIO\_ODR} = 1 \quad\text{(高电平)} GPIO_ODR=1(高电平)
或者:
GPIO_ODR=0(低电平) \text{GPIO\_ODR} = 0 \quad\text{(低电平)} GPIO_ODR=0(低电平)
这是一次寄存器写操作,执行必然成功。
所以:
- 不需要检查返回值
- 不需要错误处理
- 不需要
bool或error_code
能写寄存器就是成功,不存在失败路径。
因此返回void是自然的。
2. 嵌入式常见目标是:零开销抽象(Zero-cost abstractions)
虚函数接口本质上是抽象一个“GPIO 输出功能”。
硬件操作最终是:
GPIOx->ODR |=(1<< pin);如果 level() 返回值(比如 bool 或 error_code),优化器不能优化掉相关逻辑,会增加:
- 栈操作
- 分支判断
- 寄存器使用
- 代码体积
而嵌入式系统经常: - 没有异常
- 没有动态分配
- 没有 RTTI
- 不能使用 heavy STL
所以设计尽量简单:
set → done 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. 真实世界驱动库如何设计?
看看真实 MCU HAL:
STM32 HAL
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, GPIO_PIN_SET);// 返回 voidArduino
digitalWrite(pin, HIGH);// 返回 voidZephyr RTOS
gpio_pin_set(device, pin, value);// 返回 int,但用于复杂外设(非纯 GPIO)mbed OS
led =1;// operator= returns void可以看到:GPIO 输出一般都返回 void。
6. 如果你真的想要错误反馈,该怎么做?
极少数情况下,例如:
- pin 尚未初始化
- pin 处于保护状态
- pin 被复用为 SPI / I2C
- pin 在 run-time 变化
你可能想要错误处理。
这时可以:
Option A:返回 bool
virtualboollevel(bool p_high)=0;Option B:返回 error_code(但不常用)
virtual std::error_code level(bool p_high)=0;Option C:使用契约(assert 或 contract)
virtualvoidlevel(bool p_high){assert(initialized_);}但这些并不常见,因为:
- pin 的配置在 boot 时已确定
- “运行中改变 pin 模式”非常罕见
- GPIO 总是能成功写寄存器
总结:返回 void 是最佳选择
对于 GPIO 输出:
- 操作必然成功,无错误路径
- 不需要返回值
- 嵌入式最常用的做法
- 零开销抽象
- 简洁、直观、没有多余语义
因此你的接口设计:
virtualvoidlevel(bool p_high)=0;#⃣ 1. 整体概念:I²C 是“两根线的多设备通信协议”
A simplified Introduction to I2CI2C Bus ControllerSDASCLI2C DeviceSCLSDAI2C DeviceSCLSDAI2C DeviceSCLSDASTAADDRESS (7-bit)W/RNACK/ACKByte 0ACKByte 1ACKNACKSTOP
<?xml version="1.0" encoding="UTF-8"?><svgversion="1.1"viewBox="0 0 1600 800"xmlns="http://www.w3.org/2000/svg"><!-- Title --><textx="80"y="60"fill="#333"font-family="Arial, sans-serif"font-size="48">A simplified Introduction to I2C </text><!-- I2C Bus Controller --><rectx="80"y="280"width="320"height="200"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="99.597694"y="332.80142"fill="#333333"font-family="Arial, sans-serif"font-size="28px">I2C Bus Controller</text><!-- SDA and SCL labels on controller --><rectx="290"y="350"width="100"height="40"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="310"y="377"fill="#333"font-family="Arial, sans-serif"font-size="24">SDA</text><rectx="290"y="410"width="100"height="40"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="312"y="437"fill="#333"font-family="Arial, sans-serif"font-size="24">SCL</text><!-- I2C Device 1 --><rectx="430"y="140"width="200"height="180"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="465"y="180"fill="#333"font-family="Arial, sans-serif"font-size="26">I2C Device</text><rectx="450"y="220"width="70"height="80"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="467"y="267"fill="#333"font-family="Arial, sans-serif"font-size="24">SCL</text><rectx="540"y="220"width="70"height="80"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="554"y="267"fill="#333"font-family="Arial, sans-serif"font-size="24">SDA</text><!-- I2C Device 2 --><rectx="680"y="140"width="200"height="180"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="715"y="180"fill="#333"font-family="Arial, sans-serif"font-size="26">I2C Device</text><rectx="700"y="220"width="70"height="80"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="717"y="267"fill="#333"font-family="Arial, sans-serif"font-size="24">SCL</text><rectx="790"y="220"width="70"height="80"fill="#6dd4a8"stroke="#333"stroke-width="2"/><gfill="#333"><textx="804"y="267"font-family="Arial, sans-serif"font-size="24">SDA</text><!-- Dots indicating more devices --><circlecx="950"cy="230"r="10"/><circlecx="1010"cy="230"r="10"/><circlecx="1070"cy="230"r="10"/><circlecx="1130"cy="230"r="10"/><!-- I2C Device N --></g><rectx="1280"y="140"width="200"height="180"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="1315"y="180"fill="#333"font-family="Arial, sans-serif"font-size="26">I2C Device</text><rectx="1300"y="220"width="70"height="80"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="1317"y="267"fill="#333"font-family="Arial, sans-serif"font-size="24">SCL</text><rectx="1390"y="220"width="70"height="80"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="1404"y="267"fill="#333"font-family="Arial, sans-serif"font-size="24">SDA</text><!-- SDA Bus Line --><linex1="390"x2="1520"y1="370"y2="370"stroke="#333"stroke-width="3"/><gfill="#333"><circlecx="585.55"cy="370.38"r="8"/><circlecx="835.92"cy="369.62"r="8"/><circlecx="1425.6"cy="366.21"r="8"/><!-- SCL Bus Line --></g><linex1="390"x2="1520"y1="430"y2="430"stroke="#333"stroke-width="3"/><gfill="#333"><circlecx="480.14"cy="430"r="8"/><circlecx="740"cy="430"r="8"/><circlecx="1335"cy="430"r="8"/><!-- Connection lines from devices to bus --></g><gstroke="#333"stroke-width="2"><linex1="585"x2="585"y1="300"y2="370"/><linex1="619.12"x2="524.12"y1="369.62"y2="369.62"/><linex1="835"x2="835"y1="300"y2="370"/><linex1="835"x2="740"y1="370"y2="370"/><linex1="1425"x2="1425"y1="300"y2="370"/><linex1="1425"x2="1335"y1="370"y2="370"/><linex1="479.76"x2="479.76"y1="299.24"y2="429.24"/><linex1="740"x2="740"y1="300"y2="430"/><linex1="1335.2"x2="1335.2"y1="301.14"y2="431.14"/><!-- Learn more text --><!-- Protocol Diagram --><!-- STA --><rectx="30"y="650"width="90"height="60"fill="#6dd4a8"/></g><textx="50"y="687"fill="#333"font-family="Arial, sans-serif"font-size="24">STA</text><!-- ADDRESS --><rectx="120"y="650"width="260"height="60"fill="#a8d4ff"stroke="#333"stroke-width="2"/><textx="165"y="687"fill="#333"font-family="Arial, sans-serif"font-size="24">ADDRESS (7-bit)</text><!-- W/R --><rectx="380"y="650"width="90"height="60"fill="#f4a5a5"stroke="#333"stroke-width="2"/><textx="407"y="687"fill="#333"font-family="Arial, sans-serif"font-size="24">W/R</text><!-- NACK/ACK --><rectx="470"y="650"width="140"height="60"fill="#ffd4a3"stroke="#333"stroke-width="2"/><textx="482"y="677"fill="#333"font-family="Arial, sans-serif"font-size="22">NACK/</text><textx="500"y="697"fill="#333"font-family="Arial, sans-serif"font-size="22">ACK</text><!-- Byte 0 --><rectx="610"y="650"width="210"height="60"fill="#ffd4a3"stroke="#333"stroke-width="2"/><textx="675"y="687"fill="#333"font-family="Arial, sans-serif"font-size="24">Byte 0</text><!-- ACK --><rectx="820"y="650"width="90"height="60"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="840"y="687"fill="#333"font-family="Arial, sans-serif"font-size="24">ACK</text><!-- Byte 1 --><rectx="910"y="650"width="210"height="60"fill="#ffd4a3"stroke="#333"stroke-width="2"/><textx="975"y="687"fill="#333"font-family="Arial, sans-serif"font-size="24">Byte 1</text><!-- ACK --><rectx="1120"y="650"width="90"height="60"fill="#6dd4a8"stroke="#333"stroke-width="2"/><gfill="#333"><textx="1140"y="687"font-family="Arial, sans-serif"font-size="24">ACK</text><!-- Dots --><circlecx="1250"cy="680"r="6"/><circlecx="1280"cy="680"r="6"/><circlecx="1310"cy="680"r="6"/><!-- NACK --></g><rectx="1340"y="650"width="110"height="60"fill="#c0c0c0"stroke="#333"stroke-width="2"/><textx="1352"y="687"fill="#333"font-family="Arial, sans-serif"font-size="24">NACK</text><!-- STOP --><rectx="1450"y="650"width="110"height="60"fill="#6dd4a8"stroke="#333"stroke-width="2"/><textx="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 外设 ╚══════════════════════════════╝ 控制器有两个输出接口:
- SDA
- SCL
这两个接口连接到整个 I²C 总线。
主控器负责:
- 产生时钟 SCLSCLSCL
- 控制数据线 SDASDASDA
- 发送 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 的高电平来自总线上拉电阻:
VSDA=Rpull-up→VCCV_{SDA} = R_{\text{pull-up}} \rightarrow V_{\text{CC}}VSDA=Rpull-up→VCC
4. SCL(时钟线)总线
与 SDA 类似:
SCL ─────────────────────────────────────────────── 也由主控器驱动,但仍是开漏结构。
🟫 5. 图中的点(circle)
例如:
● ● ● ● 表示:
- 总线上可以继续挂更多设备(扩展性)。
I²C 可以轻松接 几十甚至上百个器件,只要:
Cbus<Cmax C_{\text{bus}} < C_{\text{max}} Cbus<Cmax
通常 ≈400pF\approx 400\text{pF}≈400pF 上限。
6. 下方:I²C 单帧传输结构(时序图)
下方一排矩形展示 I²C 的通讯流程:
[ START ][ ADDRESS ][ R/W ][ ACK ][ BYTE 0 ][ ACK ]...[ NACK ][ STOP ] 我们逐段讲。
6.1 START 条件(STA)
绿色方框:
STA I²C START 条件定义为:
当 SCL 为高电平时,SDA 从高跳变到低\text{当 SCL 为高电平时,SDA 从高跳变到低}当 SCL 为高电平时,SDA 从高跳变到低
它表示:
“一次新的通讯开始了”
🟦 6.2 ADDRESS(7-bit 地址)
蓝色方框:
ADDRESS (7-bit) I²C 地址是 7 位:
A6A5A4A3A2A1A0A_6 A_5 A_4 A_3 A_2 A_1 A_0A6A5A4A3A2A1A0
例如: MPU6050 默认地址 0x68。
6.3 W/R 位(读写位)
红色方框:
- 0 = 写 (Write)
- 1 = 读 (Read)
组成完整的 8 位地址:
Address Byte=(7bit Address)≪1;∣;R/W\text{Address Byte} = (7\text{bit Address}) \ll 1 ;|; R/WAddress Byte=(7bit Address)≪1;∣;R/W
🟧 6.4 ACK / NACK
黄色方框:
ACK NACK 由 从机(Slave)响应:
- ACK = 0(拉低 SDA):从机应答成功
- NACK = 1(释放 SDA):从机不应答
ACK 所在的时钟周期称为:
9th;clock9^\text{th}; \text{clock}9th;clock
前 8 个时钟发送 1 个 Byte,第 9 个时钟由从机发送 ACK。
🟫 6.5 后续数据字节 Byte 0, Byte 1, …
橙色方框:
Byte 0 → ACK → Byte 1 → ACK → ... 每个字节都必须有一个 ACK。
6.6 最后 NACK(停止接收)
灰色方框:
NACK 当主机读取最后一个字节,需要发送 NACK 告诉从机:
“我不要更多数据了”
6.7 STOP 条件(STOP)
绿色方框:
STOP 条件定义为:
当 SCL 为高电平时,SDA 从低跳变到高\text{当 SCL 为高电平时,SDA 从低跳变到高}当 SCL 为高电平时,SDA 从低跳变到高
表示通讯结束。
🟦 完整流程总结(图下部分对应)
START→7-bit Address⏟∗主机发送→R/W⏟∗0=写,;1=读→ACK⏟从机发送→Data Bytes→NACK→STOP \text{START} \rightarrow \underbrace{\text{7-bit Address}}*{\text{主机发送}} \rightarrow \underbrace{R/W}*{0=\text{写},;1=\text{读}} \rightarrow \underbrace{\text{ACK}}_{\text{从机发送}} \rightarrow \text{Data Bytes} \rightarrow \text{NACK} \rightarrow \text{STOP} 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 与可配置跳线。
- 降低速率:把 fSCLf_{SCL}fSCL 降低到标准模式(100 kHz)或更低做排查。
重试:常规做法是做 NNN 次重试(NNN=3~5),间隔短延迟(退避可选)。
伪码:
for(int i=0; i<retries;++i){start();write_address(addr_rw);if(read_ack())break;// 成功stop();delay(backoff_ms);}if(failed)report_device_not_present();三、Device Not Present(设备不存在)的额外排查
排查清单(从易到难):
- 电源/复位:测设备是否上电(Vcc 到位、RESET 引脚状态)。
- 接线:SDA/SCL、GND 是否连接,是否使用了上拉电阻(常见 4.7kΩ4.7\text{k}\Omega4.7kΩ 或 10kΩ10\text{k}\Omega10kΩ)。
- 上拉电阻值是否合适:上拉过大导致上升慢,过小导致总线功耗过高。
- 总线电容是否太大:每米线或 PCB 探针会增加总线电容 CbC_bCb,影响 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 到位时不要无限等待,必须有 ttimeoutt_{timeout}ttimeout,比如 ttimeout=5mst_{timeout}=5\text{ms}ttimeout=5ms 或依据时序而定。 - 检测仲裁丢失(多主场景)
如果主机在发送期间检测到 SDA 与自己写的不一致(即被别的主机拉低),则发生仲裁丢失,主机应放弃本次操作并成为从状态或重试。
总线恢复(Clock Pulse Recovery)
如果 SDA 被从机拉低(例如在字节传输中断裂),按照 I²C 规范常用的工程做法是给 SCL 提供多次时钟脉冲,直到 SDA 被释放(通常最多 9 次时钟):
伪码:
// 假设 SCL 和 SDA 控制为 GPIOfor(int i =0; i <9&&SDA_is_low();++i){drive_SCL_low();delay_half_period();drive_SCL_high();delay_half_period();}// 发一个 STOP:SDA 由低拉高(SCL 高时)drive_SCL_high();drive_SDA_low();delay();drive_SDA_high();这个序列是为了让可能处于中间状态的从机“推进”出它的内部状态机并释放 SDA。
五、硬件层面导致的常见物理问题(以及数学/定量说明)
1) 上拉电阻与总线电容(上升时间)
总线的上升时间由上拉电阻 RpuR_{pu}Rpu 与总线电容 CbC_bCb 决定:
τ=Rpu⋅Cb \tau = R_{pu} \cdot C_b τ=Rpu⋅Cb
近似上升时间(0.3→0.70.3\to0.70.3→0.7 的 70% 常数近似)可用:
tr≈0.8473⋅Rpu⋅Cb t_r \approx 0.8473\cdot R_{pu}\cdot C_b tr≈0.8473⋅Rpu⋅Cb
示例:Rpu=4.7kΩ,Cb=200pFR_{pu}=4.7\text{k}\Omega, C_b=200\text{pF}Rpu=4.7kΩ,Cb=200pF
τ=4.7×103⋅200×10−12=940×10−9,s \tau = 4.7\times10^3\cdot 200\times10^{-12}=940\times10^{-9}, \text{s} τ=4.7×103⋅200×10−12=940×10−9,s
tr≈0.8473×940ns≈0.8μs t_r \approx 0.8473\times940\text{ns}\approx0.8\mu\text{s} tr≈0.8473×940ns≈0.8μs
如果 trt_rtr 接近或超过 SCL 的高电平时间,将导致通信失败或总线看似被占用。
工程结论:在有较大布线或多个外设时应减小 RpuR_{pu}Rpu(例如 2.2kΩ)或降低 fSCLf_{SCL}fSCL。
2) 最大总线电容
I²C 规范通常限制总线负载电容 Cb≤400pFC_b \le 400\text{pF}Cb≤400pF(取决于模式)。超过会导致信号畸变。
3) 上拉阻值选择(经验)
- 标准模式(100 kHz):Rpu=4.7kΩR_{pu} = 4.7\text{k}\OmegaRpu=4.7kΩ 常用
- 快速模式(400 kHz):Rpu=2.2kΩR_{pu} = 2.2\text{k}\OmegaRpu=2.2kΩ 更佳
- 若线很短且设备少,可用更大阻值降低功耗
六、软件层面的健壮实现建议(伪代码 + 要点)
核心要点:
- 一次操作(读/写)必须有超时与重试
- 在 NACK 或异常时尝试 STOP + bus_recover()
- 使用逻辑分析仪进行调试
- 在多主场景检测仲裁丢失并优雅退让
伪代码:
booli2c_write_with_retry(uint8_t addr,uint8_t* data, size_t len){constint RETRIES =3;for(int attempt =0; attempt < RETRIES;++attempt){start();if(!write_byte(addr<<1|0)){// address + Wstop();// 如果是 NACK,做短延迟再重试delay_ms(1);continue;}// 发送数据for(size_t i=0; i<len;++i){if(!write_byte(data[i])){stop();delay_ms(1);continue;// 重试整个事务或根据策略退出}}stop();returntrue;}// 多次失败后做总线恢复bus_recover();returnfalse;}bus_recover()(见上文钟脉冲恢复伪码)。
七、调试与观察工具
- 逻辑分析仪(Saleae 等)或示波器:查看 SDA/SCL 波形是首要手段。
- I²C 扫描程序:轮询 0x03…0x770x03 \dots 0x770x03…0x77 地址,记录哪些地址返回 ACK。
- 测量电阻、电容、Vcc:物理层面排查。
八、工程级最佳实践清单(可打印)
- 添加适当上拉电阻(根据 fSCLf_{SCL}fSCL 与线路长度选择)。
- 对 I²C 操作实现超时与重试(NNN=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 SchematicFallible APIsInfallible APIsI2C or SMBus Controller(e.g. Processor)VCCSDASCLPCA9536GNDP0P1P2P3i2cPeripheral DevicesRESET, ENABLE, orcontrol inputsINT or status outputsLEDsButtonsoutput_pinoutput_pinoutput_pinoutput_pin
<?xml version="1.0" encoding="UTF-8"?><svgversion="1.1"viewBox="0 0 1500 600"xmlns="http://www.w3.org/2000/svg"><!-- Title --><textx="782.41028"y="562.65839"fill="#333333"font-family="Arial, sans-serif"font-size="36px"font-weight="bold"text-anchor="middle">Simplified Schematic</text><!-- Fallible APIs Label --><rectx="350"y="10"width="300"height="70"fill="none"stroke="#f90"stroke-width="3"/><textx="500"y="55"fill="#ff9900"font-family="Arial, sans-serif"font-size="36"text-anchor="middle">Fallible APIs </text><!-- Infallible APIs Label --><rectx="920"y="10"width="300"height="70"fill="none"stroke="#f90"stroke-width="3"/><textx="1070"y="55"fill="#ff9900"font-family="Arial, sans-serif"font-size="36"text-anchor="middle">Infallible APIs </text><!-- I2C Controller Box --><rectx="20"y="110"width="360"height="310"fill="#bbb"stroke="#333"stroke-width="3"/><textx="200"y="230"fill="#333"font-family="Arial, sans-serif"font-size="28"font-weight="bold"text-anchor="middle">I2C or SMBus Controller </text><textx="200"y="270"fill="#333"font-family="Arial, sans-serif"font-size="24"text-anchor="middle">(e.g. Processor) </text><gstroke="#333"><!-- PCA9536 Chip --><rectx="600"y="110"width="360"height="360"fill="#c93"stroke-width="3"/><!-- VCC with pull-up line --><linex1="780"x2="780"y1="45"y2="110"stroke-width="2"/><circlecx="780"cy="45"r="6"fill="none"stroke-width="2"/></g><gfill="#fff"font-family="Arial, sans-serif"font-weight="bold"><textx="780"y="145"font-size="28"text-anchor="middle">VCC </text><gfont-size="32"><!-- Left side pins --><textx="630"y="195">SDA </text><textx="630"y="245">SCL </text><!-- Center text --><textx="780"y="305"text-anchor="middle">PCA9536 </text><!-- Bottom left --><textx="630"y="450">GND </text><gtext-anchor="end"><!-- Right side pins --><textx="920"y="165">P0 </text><textx="920"y="235">P1 </text><textx="920"y="305">P2 </text><textx="920"y="375">P3 </text><!-- I2C Connection Lines --><!-- SDA line --></g></g></g><linex1="380"x2="600"y1="180"y2="180"stroke="#333"stroke-width="3"/><polygonpoints="370 180 390 173 390 187"fill="#333"/><polygonpoints="610 180 590 173 590 187"fill="#333"/><textx="490"y="170"fill="#e74c3c"font-family="Arial, sans-serif"font-size="28"font-weight="bold"text-anchor="middle">i2c </text><!-- SCL line --><linex1="380"x2="600"y1="230"y2="230"stroke="#333"stroke-width="3"/><polygonpoints="610 230 590 223 590 237"fill="#333"/><!-- Peripheral Devices Box --><rectx="1190.2"y="110.17"width="237.56"height="309.65"fill="#bbb"stroke="#333"stroke-width="3"/><textx="1306.79"y="171.6673"fill="#333333"font-family="Arial, sans-serif"font-size="24px"font-weight="bold"text-anchor="middle">Peripheral Devices</text><gfill="#333"><!-- Bullet points --><circlecx="1210"cy="200"r="4"/><textx="1220"y="207"font-family="Arial, sans-serif"font-size="18">RESET, ENABLE, or </text><textx="1240"y="227"font-family="Arial, sans-serif"font-size="18">control inputs </text><circlecx="1210"cy="255"r="4"/><textx="1220"y="262"font-family="Arial, sans-serif"font-size="18">INT or status outputs </text><circlecx="1210"cy="290"r="4"/><textx="1220"y="297"font-family="Arial, sans-serif"font-size="18">LEDs </text><circlecx="1210"cy="325"r="4"/><textx="1220"y="332"font-family="Arial, sans-serif"font-size="18">Buttons </text><!-- Output pin connections --><!-- P0 --></g><linex1="960"x2="1190"y1="150"y2="150"stroke="#333"stroke-width="2"/><textx="977"y="145"fill="#2ecc71"font-family="Arial, sans-serif"font-size="22px"font-weight="bold">output_pin</text><!-- P1 --><linex1="960"x2="1190"y1="220"y2="220"stroke="#333"stroke-width="2"/><textx="977"y="215"fill="#2ecc71"font-family="Arial, sans-serif"font-size="22px"font-weight="bold">output_pin</text><!-- P2 --><linex1="960"x2="1190"y1="290"y2="290"stroke="#333"stroke-width="2"/><textx="977"y="285"fill="#2ecc71"font-family="Arial, sans-serif"font-size="22px"font-weight="bold">output_pin</text><!-- P3 --><linex1="960"x2="1190"y1="360"y2="360"stroke="#333"stroke-width="2"/><textx="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 输出高):- 读取旧输出寄存器值 OoldO_{old}Oold
- 设置第 nnn 位为 1:
Onew=Oold∣(1≪n)O_{new} = O_{old} | (1 \ll n)Onew=Oold∣(1≪n) - 清第 nnn 位为 0:
Onew=Oold&∼(1≪n)O_{new} = O_{old} \& \sim(1 \ll n)Onew=Oold&∼(1≪n)
写入就是把 OnewO_{new}Onew 通过 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(最简单、最透明)
函数签名(示例):
// 立即通过 I2C 写入,返回是否成功boolset_pin_level_fallible(int pin,bool level);行为:
- 读取
OUTPUT(R),计算 OnewO_{new}Onew(RMW),写回OUTPUT(W) - 在 I²C 失败时返回
false,成功返回true
优点:语义简单、上层知道真实状态、适合需要强一致性的场景。
缺点:调用方必须处理失败、可能阻塞/重试/打日志。
模式 B — 不可失败/乐观本地缓存(Infallible wrapper + 后台提交)
函数签名:
// 不可失败接口:只更新本地 shadow state 并返回voidset_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 — 同步强一致但带自动重试(中间方案)
函数签名:
boolset_pin_level_strong(int pin,bool level,int retries =3);行为:
- 立即尝试 RMW 写入
OUTPUT,如果失败自动按指数回退重试 NNN 次(NNN 可配置) - 若在 NNN 次内成功返回
true,否则返回false并可能触发bus_recover()
重试退避公式(示例):
tk=t0⋅2k(k=0,1,…,N−1) t_k = t_0 \cdot 2^k \quad (k = 0,1,\dots,N-1) tk=t0⋅2k(k=0,1,…,N−1)
其中 t0t_0t0 可取 1 ms。
优点:兼顾一致性与鲁棒性。
缺点:会阻塞较久;若总线永久异常会浪费 CPU。
五、具体驱动实现要点(伪代码示例)
下面给一个比较完整的驱动框架(伪码),演示 shadow 缓存 + 同步 + 恢复流程。
classPCA9536_Driver{uint8_t shadow_output =0x00;// 4 位有效 Mutex m;public:// 立即返回(infallible):只修改 shadow,标记 dirtyvoidset_level_infallible(int pin,bool level){ std::lock_guard g(m);if(level) shadow_output |=(1<< pin);else shadow_output &=~(1<< pin);mark_dirty();}// 立即尝试写入(fallible)boolset_level_fallible(int pin,bool level){ std::lock_guard g(m);// read-modify-writeuint8_t current;if(!i2c_read_reg(INPUT/OUTPUT,¤t))returnfalse;uint8_t next = level ?(current |(1<<pin)):(current &~(1<<pin));if(!i2c_write_reg(OUTPUT, next))returnfalse; shadow_output = next;// keep in syncreturntrue;}// background sync (called periodically)voidsync_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));// exponential backoffbus_recover_if_needed();}log_error("PCA9536 write failed persistently");// keep dirty = true so next cycle retries}// attempt to fix bus when stuck (SDA low, etc.)voidbus_recover_if_needed(){// 将 I2C SCL/SDA 切成 GPIO,脉冲 SCL 最多 9 次,最后发 STOP}};说明:
set_level_infallible():对上层是“不会失败”的接口(立即返回),实际写到硬件由sync_to_hw()负责。上层如果需要确认写成功,可查询is_synced()或订阅状态事件。set_level_fallible():立即尝试并将结果返回给调用方,适合在关键操作(例如必须在 立即影响外围设备的重置线)时使用。
六、读-改-写(RMW)竞争与原子性
当多个线程或任务同时操作不同 pin 时,RMW 必须是原子的,否则你会覆盖别人写的位:
设两个线程同时:
- T1 将 P0 设为 1:读 OoldO_{old}Oold,生成 Oa=Oold∣(1<<0)O_a = O_{old} | (1<<0)Oa=Oold∣(1<<0),写 OaO_aOa
- T2 将 P1 设为 0:读 OoldO_{old}Oold(同一个旧值),生成 Ob=Oold& (1<<1)O_b = O_{old} \& ~(1<<1)Ob=Oold& (1<<1),写 ObO_bOb
若没有互斥,最终写回会丢失一方更新。解决方案: - 在驱动层加互斥(
Mutex)保护 RMW - 或使用 shadow + 单点写(所有更改合并后一次写回)
七、错误/恢复策略细节(实务建议)
- 重试次数:通常 N=3N=3N=3 是合理起点,视现场可靠性可增减。
- 退避策略:指数退避(tk=t02kt_k = t_0 2^ktk=t02k)避免总线拥塞。
- 总线恢复:如果检测到 SDA 被拉低不放,实行 9 次 SCL 脉冲 + 发 STOP(见你之前收到的恢复伪码)。
- 报警/监控:长期写失败应进入“告警态”,上报上层或点亮本地故障 LED。
- 性能考虑:对频繁变更的 pin(例如 PWM 模拟)不适合频繁通过 I²C 写入;应尽量把变化降采样或批量提交。
- 原子批量更新:如果一次需更新多个 pin,合并为一次写(减少 I²C 事务次数)
八、示例:如何把 output_pin 接口映射到 PCA9536 驱动
你的 output_pin 接口(不可失败):
classoutput_pin{public:virtual~output_pin()=default;virtualvoidlevel(bool p_high)=0;// 不返回错误};两种实现映射到 PCA9536:
实现 1(直接代理到 fallible driver,但吞掉错误)
classPCA9536_OutputPin:publicoutput_pin{ PCA9536_Driver &drv;int pin;public:voidlevel(bool p_high) override {// 调用可失败接口,但不让上层看到错误if(!drv.set_level_fallible(pin, p_high)){// 吃掉错误:记录日志并尝试恢复 drv.bus_recover_if_needed();// 可选:drv.enqueue_shadow_change(pin, p_high);}}};语义:上层不会知道失败,但系统会尝试恢复或记录错误(适用于 LED 等非关键 I/O)。
实现 2(infallible but eventually consistent:更新 shadow)
classPCA9536_ShadowOutputPin:publicoutput_pin{ PCA9536_Driver &drv;int pin;public:voidlevel(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)。
- 位操作公式:设置位/清位用 Onew=Oold∣(1≪n)O_{new}=O_{old}|(1\ll n)Onew=Oold∣(1≪n) / Onew=Oold&∼(1≪n)O_{new}=O_{old}\&\sim(1\ll n)Onew=Oold&∼(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)(不可失败)——把失败吞掉/隐藏- 优点:调用简洁,上层代码最简单。
- 缺点:丢失错误语义,无法保证关键操作成功;变成“事件ual 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 为基础)
首先定义 error/status 类型:
#include<expected>enumclassmy_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):
structoutput_pin{virtual~output_pin()=default;// 设置电平(可能失败)virtual status level(bool p_high)=0;// 读取电平(可能失败)virtual result_bool level()=0;};这样签名清楚表达了:设置/读取可能失败,调用者必须处理或传播错误。
4) toggle_led 的三种写法(示例)
A. 旧式(不可检测失败)
voidtoggle_led(output_pin& p_pin, std::chrono::milliseconds p_transition_time){ p_pin.level(true);// 如果返回 void,我们不知道是否成功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{};// success}优点:调用链显式地向上传播错误。缺点:重复的样板代码。
C. 使用 Error Propagator 宏(SJ_CHECK 风格)——简洁且类型安全
先给出简单宏实现(基于 std::expected):
#defineSJ_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、同步)
classPCA9536{public:// 读写寄存器低阶操作(fallible) std::expected<uint8_t, my_error>read_register(uint8_t reg); std::expected<void, my_error>write_register(uint8_t reg,uint8_t value);// set pin with read-modify-write (fallible) 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(强一致版本)
classPCA9536_Pin:publicoutput_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());returnstatic_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: \text{Input} \rightarrow \text{Output} f:Input→Output
但是在嵌入式系统中真实情况是:
f:Input→Output∪Error f: \text{Input} \rightarrow \text{Output} \cup \text{Error} 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)掉好了…”
于是他们写出:
(void)p_pin.level(true);直接忽略错误。
这虽然避免代码臃肿,但等于把错误丢进黑洞中。
对嵌入式来说非常危险!
4. Error virality(错误传播的“病毒式蔓延”)
为什么说它像“病毒”?
因为如果一个函数调用了会失败的函数,它也必须能返回错误:
如果:
status write_command(...);可能失败,那么:
status move_to(...); status rotate(...); status configure_pid(...);也都必须返回 status!
最终整个系统层层都变成:
status something(...);数学类比:
如果最底层操作是
f(x)∈A∪E f(x) \in A \cup E f(x)∈A∪E
则所有组合函数 g(f(x))g(f(x))g(f(x)) 必须也满足:
g(f(x))∈B∪E g(f(x)) \in B \cup E g(f(x))∈B∪E
即错误集 EEE 会向上传播。
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);returnsuccess();}这解决了:
- 代码冗长
- 错误返回类型“过于巨大”
- 需要写大量 if
7. 总结(非常关键)
| 方案 | 优点 | 缺点 |
|---|---|---|
| void 返回值 | 简单 | 无法传递错误 |
| bool / int 返回值 | 简单有效 | 错误信息不够丰富 |
| std::expected | 结构化错误,强类型,安全 | error virality,IF 爆炸 |
| std::expected + TRY 宏 | 自动传播 | 有一点宏魔法 |
| 统一返回 status 枚举 | 最简单最落地 | 错误信息较少 |
| 最终作者的观点: |
几乎所有 API 都必须返回错误。
但最佳方式不是 everywhere 使用 expected,
而是统一用 status + error propagation。
#⃣ 1. 背景:SJ_CHECK 是一个“错误自动传播”宏
SJ_CHECK(expr) 作用:
- 如果
expr返回std::expected<T, E>的 错误:
→ 自动return unexpected(error)向上返回 - 如果成功:
→ 提取T并返回
数学上:
SJ_CHECK(x)={return unexpected(e),x=unexpected(e)[6pt]value,x=expected(value) \text{SJ\_CHECK}(x) = \begin{cases} \text{return }\text{unexpected}(e), \\ x = \text{unexpected}(e) [6pt] \text{value}, \\ x = \text{expected(value)} \end{cases} SJ_CHECK(x)=⎩⎨⎧return unexpected(e),x=unexpected(e)[6pt]value,x=expected(value)
它是 C++ 手写版的 “Rust?运算符”。
#⃣ 2. 作者给出的示例代码
std::uint8_tconst device_select =SJ_CHECK(pin1.level())<<1|SJ_CHECK(pin1.level());autoconst voltage =SJ_CHECK(v_sense[device_select].read());下面我们分解解释。
#⃣ 3. 第一行:计算 device_select
std::uint8_tconst device_select =SJ_CHECK(pin1.level())<<1|SJ_CHECK(pin1.level());3.1 原始意图(无错误处理)应该是:
device_select = pin1.level()<<1| pin1.level();表示:
- 读取两次 IO 扩展器的某个 pin 电平
- 把高位与低位组合成 2-bit 的索引
数学形式:
让: - 第一次读取值 = b1b_1b1
- 第二次读取值 = b0b_0b0
则:
device_select=b1×2+b0 \text{device\_select} = b_1 \times 2 + b_0 device_select=b1×2+b0
也就是:
device_select=(b1≪1);∣;b0 \text{device\_select} = (b_1 \ll 1) ;|; b_0 device_select=(b1≪1);∣;b0
3.2 但因为 pin1.level() 可能失败
pin1.level() 返回:
expected<bool, error_t> \text{expected<bool, error\_t>} expected<bool, error_t>
所以不能直接使用,必须检查错误。
于是变成:
SJ_CHECK(pin1.level())<<1|SJ_CHECK(pin1.level());即:
每次读取都必须包装进 SJ_CHECK。
所以这一行变成:
device_select=SJ_CHECK(b1)≪1∣SJ_CHECK(b0) \text{device\_select} = SJ\_CHECK(b_1) \ll 1 | SJ\_CHECK(b_0) device_select=SJ_CHECK(b1)≪1∣SJ_CHECK(b0)
这就是作者说的 噪音(noisy)。
因为:
- 逻辑只想表达 “两次读取并组合”。
- 但错误处理宏让代码变得冗长难读。
#⃣ 4. 第二行:读取电压
autoconst voltage =SJ_CHECK(v_sense[device_select].read());含义:
- 根据前面组合的索引选通 ADC 通道
- 调用
.read()(可能失败) SJ_CHECK:自动抛出错误或返回值
数学形式:
voltage=SJ_CHECK(read(v_sense[device_select])) \text{voltage} = SJ\_CHECK(\text{read}(v\_sense[\text{device\_select}])) 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_tconst device_select =SJ_CHECK(pin1.level())<<1|SJ_CHECK(pin1.level());autoconst 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 —— 增加二进制体积
C++ 异常编译后会引入一系列运行时结构:
- 异常表(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)里,不会给你一个固定执行时间 TmaxT_{\max}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)?
因为:
- 异常对象在抛出时要构造
- 大多数实现需要为异常对象分配内存(通常在堆上)
- 若异常对象太大时,会动态分配临时缓冲区
因此:
必须依赖 mallocmallocmalloc(堆内存)或某些特殊的运行时缓冲。
这对无堆系统(no-heap RTOS, micro-controller)非常致命。
「Let’s make exceptions work on ARM!」
现在我们来真的:在 ARM 上让 C++ exceptions 跑起来
在 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 -Os -g -std=c++20:使用 C++20-Os:优化二进制体积(更能看出异常带来的膨胀)-g:加入调试符号
开启异常与 RTTI(关键部分)
-fexceptions -frtti -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]]intstart(){throw5;}intmain(){volatileint return_code =0;try{ return_code =start();}catch(...){ return_code =-1;}return return_code;}逐段解释:
[[gnu::noinline]]
告诉编译器不要内联 start():
- 让异常真正跨函数抛出
- 触发完整的栈展开过程(更能展示异常的真实成本)
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 时,执行:
throw5;等同于:
SYSTEM TERMINATED #2⃣ Breaking Barrier #1
(突破第一个障碍:让 ARM GCC 支持异常)
① Step 1: 下载并构建 ARM GNU Toolchain
(其实就是你本地要有一个支持编译 C++ 的 arm-none-eabi-g++)
② Step 2: 找到并修改编译器默认设置
把 -fno-exceptions 改成 -fexceptions
GCC 的裸机 specs 文件(如 nano.specs)默认包含:
-fno-exceptions -fno-rtti 所以必须明确在编译命令中加入:
-fexceptions -frtti 否则即使你写 try/catch 编译器也不会生成异常表。
③ Step 3: 定义底层堆内存接口 _sbrk
(这是关键!)
C++ 异常运行时需要:
- 分配异常对象
- 在栈展开期间创建一些辅助结构
- 运行时需要动态内存
所有这些都依赖 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()){returnnullptr;}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
等同于 malloc 失败,异常抛出时也会失败。
若空间足够 → 划分返回,并更新 available_memory
auto result = available_memory.subspan(0, p_amount); available_memory = available_memory.subspan(p_amount);return result.data();就像从一个大饼里切出前 p_amount 一块。
为什么 C++ 异常需要 _sbrk?
因为 C++ 异常在 throw 期间需要:
- 构造异常对象(例如
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),即使程序中只写了一个简单的:
throw5;整个程序的体积立刻飙升到原来的几十倍。
尺寸对比:令人震惊的差异(+56×)
比较了三种构建方式的二进制大小。
① 使用异常(exceptions)后的大小
text data bss dec hex filename 150008 2016 1328 89352 15d08 except.elf 解释:
text = 150008字节,主要是代码段(Flash)- 在常见 512 KB Flash 的 MCU 上占用比例:
150008512×1024≈0.286≈29 \frac{150008}{512 \times 1024} \approx 0.286 \approx 29% 512×1024150008≈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 的倍数:
1500082680≈56 \frac{150008}{2680} \approx 56 2680150008≈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(栈展开器)
GCC 的 ARM unwinder:
- 能解析 CFI(Call Frame Information)
- 能恢复每一层栈
- 需要大量逻辑支持
这是程序体积暴增的主要来源之一。
3. RTTI(Run-Time Type Information)
异常匹配需要类型信息:
typeinfodynamic_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 这段代码的本质
异常系统为了生成:
"what(): error code = 42""terminate called after throwing ..."
会调用 sprintf 和 strlen。
问题在这里:
即使你不用 printf,
只要用 C++ 异常,GCC 就会 偷偷链接 sprintf。
而sprintf会引入:
- 一整套
printf格式化逻辑 - 浮点格式(尽管你不需要)
- 大型状态机和解析器
最终导致 flash 增加 数十 KB。
3. __gnu_cxx::__verbose_terminate_handler — 致命膨胀源头
贴出的第三段(核心):
00009e14 <__gnu_cxx::__verbose_terminate_handler()>: 9e14: b570 push {r4, r5, r6, lr} 9e16: 4b3b ldr r3,[pc,#236] ;(9f04) 9e18: 781a ldrb r2,[r3,#0] 9e1a: b082 sub sp,#8 9e1c: 2a00 cmp r2,#0 9e1e: d141 bne.n 9ea4 9e20: 2401 movs r4,#1 9e22: 701c strb r4,[r3,#0] 9e24: f006 f8f0 bl 10008 <_cxa_current_exception_type> 9e28: 2800 cmp r0,#0 9e2a: d031 beq.n 9e90 9e2c: 6844 ldr r4,[r0,#4] 9e2e: 4d36 ldr r5,[pc,#216] 9e30: 7823 ldrb r3,[r4,#0] 9e32: 2b2a cmp r3,#42 ; '*' 9e36: 3401 addeq r4,#1 9e38: 2200 movs r2,#0 9e3a: f04f 30ff mov.w r0,#0xffffffff 9e3e: 4611 mov r1,r2 9e40: ab01 add r3,sp,#4 9e42: 9001 str r0,[sp,#4] 9e44: 4620 mov r0,r4 9e46: f006 f849 bl fedc <__cxa_demangle> 这段代码的本质
这是 GCC 的致命膨胀点:
- 它调用
_cxa_current_exception_type - 它试图获取异常真正的类型名(mangled name)
- 调用
__cxa_demangle __cxa_demangle是一个庞大的 C++ ABI 类型名解码器- 它需要动态 buffer → 调用 malloc/realloc/free
- 它需要字符串解析、重建树形 AST
你没用 RTTI?没关系!
一旦开启 C++ 异常,GCC 仍会带上 部分 RTTI + demangler。
结果就像你看到的:
except.elf text: 150008 bytes expected.elf text: 2680 bytes baseline text: 2656 bytes 异常机制单独就占了 147 KB!
总结:为什么这些汇编代表“异常巨大膨胀”?
根本原因:C++ 异常是一个完整的 runtime subsystem
它必须实现:
1. 栈展开(unwind tables + unwinder)
需要 DWARF unwind 信息
→ 大量 .gcc_except_table。
2. RTTI + 类型匹配
dynamic_cast、typeid
→ 带来 type_info 结构。
3. terminate handler(你看到的汇编)
必须打印错误信息
→ 字符串构造
→ sprintf
→ strlen
4. demangle 类型名
__cxa_demangle
→ 巨大代码库
5. 动态内存管理
异常对象必须分配在 heap
→ malloc/new/free
结论:为什么 std::expected 几乎不占空间?
因为:
- 不需要 RTTI
- 不需要 unwind table
- 不调用 demangle
- 不使用 sprintf/strlen
- 不用 malloc
- 完全是模板 inline 代码
- 和
struct { bool ok; T v; E err; }差不多
所以它的大小几乎与 baseline 完全一致:
baseline: 2656 bytes expected: 2680 bytes 1) 汇编片段在告诉我们什么(重点摘取与说明)
你贴了若干函数的反汇编,关键观察点:
d_growable_string_callback_adapter/d_append_num等函数里出现了对sprintf、strlen、memcpy(_aeabi_memcpy)的调用:这说明你的某段 runtime 或库代码使用了格式化/字符串函数,它们在libc/libstdc++中往往会拖入一大堆实现(尤其是sprintf会牵出浮点/格式化子系统)。- 例子:
bl 110f8 <sprintf>、bl 126dc <strlen>、bl 126b0 <_aeabi_memcpy> - 这类函数会把
printf/sprintf家族的实现拉进来,体积常常很可观。
- 例子:
- 你还贴了
__gnu_cxx::__verbose_terminate_handler()的反汇编,里面调用了__cxa_current_exception_type、__cxa_demangle、__cxa_throw相关函数:- 这些函数是 libstdc++ 在“产生可读异常信息/栈/类型名”时用到的,会引出 demangle、IO、字符串格式化、RTTI 相关代码。
__cxa_demangle自身就很大(符号名解析、内存分配等),verbose_terminate_handler还会尝试打印人类可读的信息 —— 这对嵌入式几乎是致命的空间开销。
d_append_num使用sprintf:很多 C++ 的异常/终止处理路径会调用字符串拼接/日志(例如把类型名打印出来),因此一旦启用了异常支持并且没有覆盖 terminate handler,你就会无意中把这整套“可读输出”拉进来。- 你最后的 x86 测试显示:程序在抛
my_error后terminate并崩溃(SIGSEGV),说明在桌面编译器上也触发了类似的 《terminate -> verbose handler -> demangle -> crash》流程——这些行为在 libstdc++ 层是复杂且“沉”的。
结论(从汇编看):膨胀的罪魁主要不是单个throw指令,而是 libstdc++ 的verbose terminate / demangle / formatting / unwinder support / sprintf / strlen / memcpy / ABI glue 被拉进来了。
2) 如何系统性找出谁占了多少(工具与命令)
在你已经能生成 .elf 的基础上,做这些步骤非常关键,能把“谁占多少”量化出来。
- 看 size
arm-none-eabi-size except.elf arm-none-eabi-size baseline.elf text 段大的就是问题点。
2. 生成链接 map
在链接时加 -Wl,-Map=build/except.map,查看 map 文件,找出大的对象/库段落和符号来源(libstdc++、libgcc、libc-nano 等)。
3. 用 nm/objdump 看符号
arm-none-eabi-nm -S --size-sort except.elf |tail -n 200 arm-none-eabi-objdump -d except.elf > except.s nm 显示哪个符号占用多少字节,按大小排序,能迅速定位大的函数(如 __cxa_demangle、unwinder、Unwind*)。
4. readelf 分析段
arm-none-eabi-readelf -SW except.elf arm-none-eabi-readelf -a except.elf |less看 .ARM.exidx, .eh_frame, .gcc_except_table 等段是否存在与大小。
5. 找出拉入哪个库arm-none-eabi-objdump -h except.elf 配合 map 可以看出哪些链接单元来自 libstdc++, libgcc, libc.a 等。
3) 消除 bloat:具体方法(按优先级 + 风险说明)
下面给出你可以立刻尝试的动作,按“建议顺序”列出,并说明副作用。
A — 编译/链接开关(优先级高、低风险)
这些开关能把未使用的函数/data 从最终二进制里剔除,并限制不必要的库被拉入。
- 函数/数据分段 + GC
-ffunction-sections -fdata-sections \ -Wl,--gc-sections 作用:把每个函数/数据放进独立段,链接器剔除未被引用的段。必做(通常能去掉很多没有被用到的支持代码)。
2. 禁用 RTTI(如果代码不用)
-frtti (enable) / -fno-rtti (disable) 如果你的代码不需要 typeid 或 dynamic_cast,使用 -fno-rtti 可以减少 typeinfo 相关的体积。
3. 使用 nano libc
你已经用了 --specs=nano.specs,这是有用的(比完整版 libc 小很多)。但注意 sprintf 在 nano 仍会带来实现。
4. 减小 libgcc/ libstdc++ 的拉入
-nostdlib/ 手工选择需要库(危险,需经验)-Wl,--as-needed有时有帮助
B — 直接消除 “verbose terminate / demangle / std::string formatting” 代码(中风险)
- 重定向 / 覆盖 terminate handler —— 目的:避免
__gnu_cxx::__verbose_terminate_handler被拉入- 在你的代码中自己定义
std::terminate()或__gnu_cxx::__verbose_terminate_handler的弱符号(weak/strong override)实现,做极简处理(比如直接跳转到abort()/while(1)),不要去打印或做 demangle。 - 例:
- 在你的代码中自己定义
extern"C"void __gnu_cxx::__verbose_terminate_handler(){for(;;);}// 或覆盖 std::terminatenamespace std {[[noreturn]]voidterminate(){for(;;);}}- 这样可以防止 libstdc++将 verbose terminate 相关的 demangle/format 代码拉进来(注意要把符号名写对并确认链接优先使用你的实现)。风险:你放弃了人类可读的 terminate 信息,但在 MCU 上这是合理的。
- 提供一个超精简的 demangle 或完全不使用 demangle
- 最好是不要让
__cxa_demangle被链接,覆盖该符号为简单 stub:返回 input 或 nullptr。 - 示例:
- 最好是不要让
extern"C"char*__cxa_demangle(constchar* __mangled_name,char* __buf, size_t* __len,int* __status){if(__buf && __len &&*__len >strlen(__mangled_name)+1){strcpy(__buf, __mangled_name);if(__status)*__status =0;return __buf;}if(__status)*__status =-1;returnnullptr;}- 这会切掉真正的 demangle 逻辑(很大一块),以代价换取体积。
- 覆盖
__cxa_allocate_exception/__cxa_throw等,做最小化实现- 如果你不需要完整的异常对象语义(比如只要能
throw并catch(...),但不需要异常传递对象拷贝),你可以提供极简版的__cxa_allocate_exception/__cxa_free_exception/__cxa_throw,使其不再使用 malloc 或不用复杂逻辑。但这非常危险且易错,除非你非常清楚 ABI 和 unwind 需要什么。
- 如果你不需要完整的异常对象语义(比如只要能
C — 替换/重写被拉入的大函数(中到高风险)
- 寻找并替换
sprintf、strlen的调用点- 例如
d_append_num使用sprintf;如果这个是来自某个库(如 libstdc++ 的 iostream 操作或异常消息),最好替换成自己写的itoa/utoa,或移除拼接逻辑。 - 在代码中把
sprintf改成轻量fast_itoa或snprintf的更小实现(注意 nano-libc 的snprintf也可能大)。
- 例如
- 避免使用 iostream / std::string 在异常路径
- iostream、std::string 的实现会拉入大量内存(allocator、locale、facets 等),尽量避免在异常/terminate 路径使用它们。
D — 更激进的:不使用异常,而用 std::expected/手动错误传播(最安全,常用)
- 你已经看到
std::expected<int,int>版本几乎没有额外开销,这是最佳实务:不要启用 exceptions,而用 expected + error-propagation macro。 - 如果目标是嵌入式并需极小体积,这是首选路线。启用 exceptions 代价巨大。
4) 一个推荐的“逐步消瘦”流程(可直接跑的步骤)
- 先做最安全的:启用节省但无侵入的优化
- 在编译选项里加:
-ffunction-sections -fdata-sections -fno-exceptions? (if you can) -Wl,--gc-sections -Os -ffunction-sections -fdata-sections - 重新构建并看 size/map。
- 如果你必须保留 exceptions(仅作实验),立刻覆盖 terminate/demangle
- 在你的工程里添加上面给的
std::terminate()或__gnu_cxx::__verbose_terminate_handler()stub,重链,查看 size 是否显著下降(通常能去掉几十 KB)。
- 在你的工程里添加上面给的
- 查 nm/map 找到最大的符号
arm-none-eabi-nm -S --size-sort except.elf |tail -n 50- 找到
__cxa_demangle、__cxa_throw、_Unwind_*、__gnu_cxx::__verbose_terminate_handler等占多的符号。优先替换或覆盖。
- 替换 sprintf/printf
- 把 printf/sprintf 替换成自己最小实现或禁用该调用路径。
- 如果可行,放弃 exceptions
- 将工程改为
-fno-exceptions,使用std::expected+ 宏来传播错误:这是嵌入式域的工程化选择,也是你之前走过的道路,得到的体积是可接受的。
- 将工程改为
5) 风险与权衡(必须明确的地方)
- 覆盖标准符号(demangle、terminate、_cxa*)能立刻削减体积,但你在实质上改变了语言/库的语义 —— 在复杂系统中这可能产生难以预料的问题。务必全面测试 unwind/异常流程(尤其跨模块时)。
- 提供极简
_sbrk会创建一个小堆;异常代码可能会不断分配,导致 exhaustion。你需要监控并限额。 - 剔除功能可能隐藏真实错误信息(不能打印异常类型/消息),调试困难。
- 最稳妥的做法仍是:在嵌入式上不要启用异常(
-fno-exceptions),改用返回值/std::expected、错误传播宏以及集中式错误处理。
6) 快速实践清单(copy-paste)
编译时(首选,不启用 exceptions):
# 推荐:不启用 exceptions,使用 expected arm-none-eabi-g++ -o baseline.elf main.cpp ... -std=c++20 -Os \ -fno-exceptions -fno-rtti -ffunction-sections -fdata-sections \ -Wl,--gc-sections --specs=nano.specs -mcpu=cortex-m4 -mthumb -T linker.ld 如果必须启(实验),先做这些以削减体积:
- 加
-ffunction-sections -fdata-sections -Wl,--gc-sections - 覆盖 verbose terminate & demangle(在你的 C++ 源里加入 stub)
- 用
nm/readelf/objdump定位大符号并替换/覆盖
7) 小结(结论)
- 已经亲自验证了:启用 C++ 异常 会把几十 KB 的运行时框架拉进 MCU,这会把程序体积暴增几十倍(你量化为 56×、占 Flash 29%),这是事实而且可重复。
- 现在的任务是 把这些运行时代码逐一剥离或替换:
- 最有效的做法是彻底不启用异常,改用
std::expected+ error-propagation macro(你已经在探索这条路)。 - 如果坚持要异常,请用
-ffunction-sections+--gc-sections+ 覆盖terminate、demangle等符号来尽量瘦身,或手工实现精简版__cxa_*(风险高)。
- 最有效的做法是彻底不启用异常,改用
Barrier #3:throw 为什么需要 动态分配?
C++ 的异常模型遵循 Itanium C++ ABI(GCC/Clang 都实现它)。
关键点:每次 throw 都要构造一个 “exception object” 并放到堆上。
在 C++ 标准模型中:
当你写:
throw5;实际发生的是:
allocate exception object on heap \text{allocate exception object on heap} allocate exception object on heap
然后:
copy value 5 into the exception object \text{copy value 5 into the exception object} copy value 5 into the exception object
最后:
__cxa_throw(exception pointer,typeinfo, destructor) \_\_cxa\_throw(\text{exception pointer}, \text{typeinfo},\ \text{destructor}) __cxa_throw(exception pointer,typeinfo, destructor)
这就是为什么 C++ 异常强依赖动态分配(malloc)。
汇编代码逐行解释
你的代码:
0000800c <start()>: 800c: b508 push {r3, lr} 800e: 2004 movs r0, #4 8010: f000 f93c bl 828c <__cxa_allocate_exception> 8014: 2305 movs r3, #5 8016: 4902 ldr r1, [pc, #8] 8018: 6003 str r3, [r0, #0] 801a: 2200 movs r2, #0 801c: f000 fd0a bl 8a34 <__cxa_throw> 8020: 0000af48 .word 0x0000af48 下面每一行讲得非常清楚(并配图公式)。
1. push {r3, lr}
保存寄存器,建立最小函数栈帧。
无关异常机制。
2. movs r0, #4
将 $4$ 放到参数寄存器 $r0$。
表示:
exception object 的大小=4 bytes \text{exception object 的大小} = 4\ \text{bytes} exception object 的大小=4 bytes
因为你 throw 5;,而类型 int 的大小是 4 字节。
3. bl __cxa_allocate_exception
调用:
__cxa_allocate_exception(4) \text{\_\_cxa\_allocate\_exception}(4) __cxa_allocate_exception(4)
此函数必须返回堆上的内存:
p=malloc(4+exception header) p = \text{malloc}(4 + \text{exception header}) p=malloc(4+exception header)
这就是 Barrier #3 的核心:
throw 一定要 malloc!
4. movs r3, #5
把 literal 值 $5$ 加载出来。
5. ldr r1, [pc, #8]
从常量池加载 typeinfo 指针。
r1=&typeinfo_for_int r1 = \text{\&typeinfo\_for\_int} r1=&typeinfo_for_int
typeinfo 指针长这样:
_ZTIi: typeinfo for int 这是 C++ 异常系统用于运行时匹配类型的唯一机制。
6. str r3, [r0, #0]
将值 $5$ 存入刚刚分配的 exception object:
∗(int∗)(p)=5 *(int*)(p) = 5 ∗(int∗)(p)=5
7. movs r2, #0
r2 = destructor(这里为 0 因为 int 无析构)。
ABI 的定义:
__cxa_throw(void* exception_object, std::type_info*, void (*destructor)(void*)); 8. bl __cxa_throw
这是关键:
__cxa_throw(p, typeinfo(int), nullptr) \_\_cxa\_throw(p,\ \text{typeinfo(int)},\ \text{nullptr}) __cxa_throw(p, typeinfo(int), nullptr)
作用:
- 把 exception object 放入全局异常指针
- 展开(unwind)当前栈帧
- 搜索能处理
int的 catch 块
这是 C++ 异常系统中最重、最昂贵的部分。
小总结(浓缩版)
一次 throw 5; 的真实行为:
p=__cxa_allocate_exception(4)∗p=5__cxa_throw(p, typeinfo(int), 0) p = \_\_cxa\_allocate\_exception(4) \\ *p = 5 \\ \_\_cxa\_throw(p,\ \text{typeinfo(int)},\ 0) p=__cxa_allocate_exception(4)∗p=5__cxa_throw(p, typeinfo(int), 0)
你看到的汇编完全对应这些步骤。
为什么 C++ 要用 “动态分配异常对象”?
根本原因就是:异常对象必须在栈展开(unwind)过程中保持合法存活。
如果异常对象在栈上:
- 栈帧被展开后,内存就没了
- 异常系统无法访问异常对象
- catch block 就无法读取异常的值
因此,必须放到:
堆 (heap) \text{堆 (heap)} 堆 (heap)
这样无论展开多少栈帧,异常对象都有效。
最终总结(Barrier #3 的本质)
C++ 的异常机制必须:
- 创建异常对象
- 放入堆内存
- 用
__cxa_throw驱动运行时类型匹配和栈展开
即使你 “只是 throw 一个整数”,也绕不过:
malloc + typeinfo + unwind \text{malloc + typeinfo + unwind} malloc + typeinfo + unwind
这就是它的 成本高 和 不适合嵌入式 的原因。
“Barrier #2 如何只用 5 行代码做到巨大体积缩减?”
Barrier #2:C++ 异常的隐藏代价是什么?
默认情况下,GCC 的异常系统带有一个全局弱符号:
__terminate_handler 它默认指向:
abort() 或打印异常信息的复杂逻辑 \text{abort() 或打印异常信息的复杂逻辑} abort() 或打印异常信息的复杂逻辑
这要求:
- 链接 libc
- 链接 C++ runtime
- 链接异常栈展开代码
- 链接 unwind tables (
.ARM.exidx,.eh_frame) - 链接 I/O 代码(因为终止处理器通常会打印内容)
- 链接 RTTI 胶水
因此,异常机制会:
带入大量运行库 → 代码体积巨大 \text{带入大量运行库 → 代码体积巨大} 带入大量运行库 → 代码体积巨大
Barrier #2 的破解方法:覆盖 weak symbol
你写的 5 行代码:
namespace __cxxabiv1 { std::terminate_handler __terminate_handler =+[](){while(true){continue;}};}这段代码做了什么?
1. 你覆盖了 GCC 的弱符号(weak symbol)
GCC 内置:
__terminate_handler __attribute__((weak))= default_handler;而你提供了:
__terminate_handler =<你的函数>;链接器看到你有一个同名强符号(non-weak),于是覆盖了系统弱符号。
数学形式表示:
your version of __terminate_handler⇒override GCC weak symbol \text{your version of } \_\_terminate\_handler \quad \Rightarrow \quad \text{override GCC weak symbol} your version of __terminate_handler⇒override GCC weak symbol
2. 你的处理器是无限循环(不会打印,不会退出)
也就是说:
异常终止⇒while(true) \text{异常终止} \quad \Rightarrow \quad \text{while(true)} 异常终止⇒while(true)
没有:
printfabort- unwinder error handler
- I/O runtime
- 全局异常处理器逻辑
3. 链接器不再需要任何异常运行库逻辑
当终止处理器是无穷循环,GCC 推断:
没有需要使用的复杂异常 API \text{没有需要使用的复杂异常 API} 没有需要使用的复杂异常 API
因此整个 C++ 异常运行库中的大部分内容会被 完整裁剪。
最终让编译器消除:
.eh_frame.ARM.exidx__cxa_begin_catch__cxa_end_catch__cxa_call_unexpected__cxa_call_terminate__gnu_unwind_frame
等等一大堆东西。
结果:代码体积大幅下降
你给的结果:
text data bss dec hex filename 13632 120 660 14412 384c except.elf 与未优化前相比:
size reduction=91 \text{size reduction} = 91% size reduction=91
因为:
C++ exception runtime≈几十 KB自定义 empty handler≈几字节 \text{C++ exception runtime} \approx \text{几十 KB} \\ \text{自定义 empty handler} \approx \text{几字节} C++ exception runtime≈几十 KB自定义 empty handler≈几字节
整个过程总结(数学形式)
覆盖弱符号使得:
default terminate handler→your minimal infinite loop \text{default terminate handler} \to \text{your minimal infinite loop} default terminate handler→your minimal infinite loop
然后导致:
no I/Ono unwinder helpersno runtime exception machinery \text{no I/O} \\ \text{no unwinder helpers} \\ \text{no runtime exception machinery} no I/Ono unwinder helpersno runtime exception machinery
最终:
final firmware size=tiny \text{final firmware size} = \text{tiny} final firmware size=tiny
最终一句总结
Barrier #2 的核心,就是替换掉 GCC 弱符号 __terminate_handler,从而让链接器裁剪掉几乎所有 C++ 异常运行库。Barrier #3 回顾
之前我们看到,C++ 的每一次 throw 都会调用:
__cxa_allocate_exception __cxa_free_exception 用于在 堆(heap) 上分配异常对象,哪怕你只是:
throw5;异常对象也必须动态分配。
所以 Barrier #3 的目标是:
把 malloc 替换掉!(消灭动态分配) \textbf{把 malloc 替换掉!(消灭动态分配)} 把 malloc 替换掉!(消灭动态分配)
Breaking Barrier #3:覆盖异常分配器
你提供的代码:
extern"C"{ std::array<std::uint8_t,256> storage{};void*__cxa_allocate_exception(unsignedint p_size)noexcept{staticconstexpr size_t offset =128;return storage.data()+ offset;}void__cxa_free_exception(void*)noexcept{// Do nothing here.}}// extern "C"下面逐行解释它做了什么以及为什么能破除 Barrier #3。
1. extern "C" —— 覆盖 C++ ABI 函数
为了覆盖 GCC 的异常分配器,你必须提供完全相同的链接名字(C linkage):
extern"C"void*__cxa_allocate_exception(...)否则编译器会产生 name mangling,导致无法覆盖 ABI 函数。
数学化表达:
Your symbol name=ABI required symbol name \text{Your symbol name} = \text{ABI required symbol name} Your symbol name=ABI required symbol name
只有这样,链接器才会使用你提供的版本。
2. std::array<std::uint8_t, 256> storage{}
你创建了静态 256 字节内存区域:
storage=256-byte static array \text{storage} = \text{256-byte static array} storage=256-byte static array
这块内存具有几个重要特性:
- 程序启动即存在
- 绝不会释放
- 不需要 malloc
- 无堆分配
它将作为 异常对象的固定缓冲区。
3. __cxa_allocate_exception —— 忽略大小,返回固定地址
staticconstexpr size_t offset =128;return storage.data()+ offset;无论 $p\_size$(即异常对象大小)是多少,你都返回同一个地址:
exception object pointer=storage+128 \text{exception object pointer} = \text{storage} + 128 exception object pointer=storage+128
这意味着:
- 永远不会分配多个异常对象
- 永远不会重叠多个异常
- 不支持嵌套 throw(这对嵌入式完全没问题)
你实际上把异常对象空间固定在:
storage[128] ~ storage[255] 共 128 字节。
这对典型异常(int、enum、small struct)已经足够。
4. __cxa_free_exception —— 什么都不做
void__cxa_free_exception(void*)noexcept{// Do nothing here.}因为异常对象存放在静态内存中,不需要释放。
数学形式:
free operation=no-op \text{free operation} = \text{no-op} free operation=no-op
这也完全合法,因为 C++ 标准指出:
__cxa_free_exception 只有在需要真正释放内存时才必须执行操作。整个 Override 起作用的原理总结
你成功用静态内存取代 malloc:
heap allocation→static storage \text{heap allocation} \to \text{static storage} heap allocation→static storage
于是:
no mallocno freeno heap dependencies \text{no malloc} \\ \text{no free} \\ \text{no heap dependencies} no mallocno freeno heap dependencies
这极大减少了对:
libgcclibstdc++malloc和new- unwinding runtime glue
的需求。
结果是:
最终固件体积大幅减少异常系统的运行时负担几乎为零 \text{最终固件体积大幅减少} \\ \text{异常系统的运行时负担几乎为零} 最终固件体积大幅减少异常系统的运行时负担几乎为零
最终效果:Barrier #3 完全突破
覆盖分配器之后:
- throw 不再分配动态内存
- 异常对象存储稳定可预期
- 你可以在裸机 (Bare-metal) 环境使用
throw - 代码尺寸不会暴涨
- 运行时不再依赖堆或 malloc
综上:
从根源上彻底消灭动态分配引起的所有异常开销 \textbf{从根源上彻底消灭动态分配引起的所有异常开销} 从根源上彻底消灭动态分配引起的所有异常开销
Barrier #4:RTTI 为什么是问题?
RTTI(运行时类型信息)提供两个能力:
dynamic_casttypeid
要支持它,编译器必须生成:
- 每个类的
typeinfo - 类型层次结构描述
- 指向 vtable 的 RTTI 链接
- 用于异常系统的类型匹配表
通常来说,RTTI 会带来规模较大的常量表,特别是包含虚函数的类。
数学上,RTTI 带来的额外开销可以表达为:
RTTI overhead=∑i=1N(typeinfoi+class_hierarchyi) \text{RTTI overhead} = \sum_{i=1}^{N} (\text{typeinfo}_i + \text{class\_hierarchy}_i) RTTI overhead=i=1∑N(typeinfoi+class_hierarchyi)
在嵌入式系统,这段数据非常昂贵。
Breaking Barrier #4:禁用 RTTI
你写的内容重点是:
Action: Replace -frtti with -fno-rtti 即:
开启 RTTI→禁用 RTTI \text{开启 RTTI} \to \text{禁用 RTTI} 开启 RTTI→禁用 RTTI
从而让编译器移除所有不必要的 typeinfo 数据。
但问题来了:
“异常(exceptions)依赖 RTTI”
为什么?因为异常匹配过程是:
try{throw some_type;}catch(const Foo& f){...}内部需要:
typeinfo(throw-type)⟶matchtypeinfo(catch-type) \text{typeinfo(throw-type)} \stackrel{match}{\longrightarrow} \text{typeinfo(catch-type)} typeinfo(throw-type)⟶matchtypeinfo(catch-type)
即异常系统需要 RTTI 来判断类型是否匹配。
数学表达:
exception dispatch=typeinfo(throw)⇒typeinfo(catch) \text{exception dispatch} = \text{typeinfo(throw)} \Rightarrow \text{typeinfo(catch)} exception dispatch=typeinfo(throw)⇒typeinfo(catch)
所以 完全禁用 RTTI 会影响异常机制。
但编译器会怎么处理呢?有 3 种可能性:
Barrier #4:3 种编译器行为
你的原文:
- 编译器告诉你不能关闭 RTTI
- 编译器只保留异常依赖的 RTTI(自动裁剪)
- 编译器保持安静 → UB
我们逐个解释。
行为 1: 编译器拒绝使用异常 + 禁用 RTTI
某些编译器可能会说:
exceptions ∧ -fno-rtti ⇏ valid \text{exceptions} \ \land\ \text{-fno-rtti} \ \not\Rightarrow\ \text{valid} exceptions ∧ -fno-rtti ⇒ valid
即直接报错。
但 GCC 和 Clang 不会这样做。
行为 2: 编译器移除所有 RTTI,只保留异常匹配必需部分
这是真实发生的,也是你看到的行为。
当使用:
-fno-rtti 编译器做的事情是:
移除全部 typeinfoexcept用于异常匹配的那部分 \text{移除全部 typeinfo} \quad\text{except}\quad \text{用于异常匹配的那部分} 移除全部 typeinfoexcept用于异常匹配的那部分
也就是说:
dynamic_cast→ 不可用typeid(x)→ 不可用- C++ 异常 → 仍然可用(保留必要 RTTI)
这样可以最大程度减少.rodata中的 RTTI 节点。
示例说明:
如果你的程序有 20 个类,而只有 1 个异常类型在用,那么最终:
RTTI kept=typeinfo(那个被 throw 的类型) \text{RTTI kept} = \text{typeinfo(那个被 throw 的类型)} RTTI kept=typeinfo(那个被 throw 的类型)
全部其他 RTTI 都会被裁剪掉。
这是极好的结果,高度优化体积。
行为 3: 编译器静默 → RTTI 被移除 → 异常变成 UB
即:
异常匹配需要 RTTI 编译器却完全移除了 RTTI ⇒行为未定义 (UB) \text{异常匹配需要 RTTI} \ \text{编译器却完全移除了 RTTI} \ \Rightarrow \text{行为未定义 (UB)} 异常匹配需要 RTTI 编译器却完全移除了 RTTI ⇒行为未定义 (UB)
但 GCC / Clang 都不会这样做。
它们严格遵守 Itanium ABI,
并且异常匹配 RTTI 是不能省略的。
所以(3)不会发生。
最终答案总结
禁用 RTTI 后:
| 情况 | 是否会发生? | 说明 |
|---|---|---|
| 编译器报错 | 否 | GCC/Clang 不会禁止异常 |
| RTTI 只保留异常用的 | 是 | 编译器自动裁剪大量 RTTI |
| 安静移除 → UB | 否 | 编译器不会破坏异常系统 |
| 总结成公式: | ||
| $$ | ||
| \text{-fno-rtti} \quad\Rightarrow\quad | ||
| \text{RTTI 被大量裁剪,只保留异常必要部分} | ||
| $$ |
简短总结(给读者的重点)
禁用 RTTI 不会破坏 C++ 异常;只有异常匹配所需的 RTTI 会被保留,其余全部裁剪。
这样可以显著减少.rodata大小,是嵌入式减少代码体积的关键一步。
背景:为什么项目作者决定全面使用 C++ 异常?
原作者(kammce)在 SJSU-Dev2 项目中开始测试异常机制后,发现:
在大项目中,异常反而比返回码更小、更快、更简单。
这听起来违反直觉,但前面所有 Barrier 的突破成果让它变成可能:
- Barrier #1:移除 unwinder
- Barrier #2:替换 terminate handler
- Barrier #3:覆盖异常分配器
- Barrier #4:禁用非必要 RTTI
最终二进制体积急剧缩小,再也不是“异常会让固件变大”的时代了。
于是得出一个重要数学结论:
cost(exceptions)≪cost(error-code framework) \text{cost(exceptions)} \ll \text{cost(error-code framework)} cost(exceptions)≪cost(error-code framework)
特别是在以下情况: - 返回码层层套娃
- 有大量错误类型
- 接口需要复杂错误传播
项目改造:全面迁移到异常(Exceptions)
PR 记录(你贴的内容)显示:
- 2020-09-18 的合并
- 从
error-all-the-things/exception-testing分支 - 代码变更巨大
- 实现了从 “Result / Returns<>” 完全迁移到 C++ 异常
数学上,可以理解为:
API: Returns<T>⇒API: Just T (throw on error) \text{API: Returns<T>} \quad\Rightarrow\quad \text{API: Just T (throw on error)} API: Returns<T>⇒API: Just T (throw on error)
修改内容(你贴的 PR 摘要)
移除返回值处理宏
例如:
SJ2_RETURN_ON_ERROR(...)变成:
try{...}catch(...){...}移除所有 “检查返回码” 的单测
因为:
return error code∉new exception-based model \text{return error code} \not\in \text{new exception-based model} return error code∈new exception-based model
硬件驱动统一使用异常处理
原文:
“It can not be determined that any particular implementation of a hardware driver will or will not throw an error.”
数学解释:
∀driver∃possible error \forall \text{driver} \quad \exists \text{possible error} ∀driver∃possible error
因此 API 设计必须允许异常传播。
关键观察:小项目 VS 大项目
作者的观察:
小项目 / Demo:异常可能更大
大项目:异常显著更小
为什么?
小项目 / Demo
示例:
intmain(){throw3;}此时异常机制的全套运行库都必须被引用,因此体积巨大:
tiny code+exception runtime≈big \text{tiny code} + \text{exception runtime} \approx \text{big} tiny code+exception runtime≈big
大项目 / 大量错误传播
如果你有大量函数写成:
Returns<int>foo(); Returns<Data>read_sensor(); Returns<void>init_driver(); Returns<bool>check();每个返回类型都带来:
- 模板膨胀
- error enum
- monadic 操作
- 分支检查
数学表示:
error-code cost≈O(N⋅template size) \text{error-code cost} \approx O(N \cdot \text{template size}) error-code cost≈O(N⋅template size)
相比之下,异常的 “零成本路径”:
no error happened⇒zero overhead \text{no error happened} \Rightarrow \text{zero overhead} no error happened⇒zero overhead
所以当 N 足够大时:
exceptions size<return-error size \text{exceptions size} < \text{return-error size} exceptions size<return-error size
这也是作者说的:
“For large enough projects exceptions are smaller in code size and faster when exceptions are not running.”
为什么异常更快?
因为:
返回码路径
每层函数都必须检查错误:
auto x =foo();if(!x)return x.error();时间复杂度:
O(call-depth) O(\text{call-depth}) O(call-depth)
异常路径(正常执行)
不抛异常 = 什么都不做:
O(1) O(1) O(1)
这就是零成本异常模型 (Zero-cost exception model)。
总结(超简版)
作者测试了大量裸机/嵌入式代码,发现:
小项目
异常带来的固定成本高
→ 使用异常可能增加二进制大小
大项目
返回码 × 模板 × 检查逻辑
模板爆炸 + 罗嗦的错误处理 = 巨大体积
反而:
exceptions becomes smaller \text{exceptions} \ \text{becomes smaller} exceptions becomes smaller
所以:
项目越大,异常越优越。
错误码的代码膨胀很快比异常还巨大。