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 / delete
  • malloc() / free()
  • std::string
  • std::vector
  • std::unordered_map
  • std::shared_ptr
  • std::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 的 etl::vector< int, 32 >
这表示:

  • 最大容量 = 32
  • 永不动态分配内存
  • 所有空间在编译期/静态区/栈上分配
    与 STL 的对比:

FeatureSTL vectorETL vector
内存位置heapstack/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 提供:

  • 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::vectorpush_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:定时器 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(复用)

每个引脚不是只有一个功能,而是有 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 必须保证:

  1. 选择正确的引脚
  2. 配置正确的功能复用
  3. 配置方向(输入/输出)
  4. 配置速度模式
  5. 配置上拉/下拉
  6. 配置驱动特性(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 → 电阻 → 晶体管基极 → 晶体管导通 → 电流流向地 

图中包含以下元素:

  1. Microcontroller(微控制器)
  2. Pin(GPIO 输出脚)
  3. resistor(限流电阻)
  4. NPN transistor(NPN 三极管)
  5. Ground(地)
  6. 输出电压示波图:LOW ≈ 0V
  7. 电压-时间坐标轴

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​=RB​VGPIO​−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(低电平)
这是一次寄存器写操作,执行必然成功。
所以:

  • 不需要检查返回值
  • 不需要错误处理
  • 不需要 boolerror_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);// 返回 void

Arduino

digitalWrite(pin, HIGH);// 返回 void

Zephyr 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 总线。
    主控器负责:
  1. 产生时钟 SCLSCLSCL
  2. 控制数据线 SDASDASDA
  3. 发送 START/STOP
  4. 发送地址
  5. 收/发数据
  6. 读取 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_0A6​A5​A4​A3​A2​A1​A0​
例如: 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)。
常见原因

  1. 设备没上电或复位中(硬件供电问题)。
  2. 地址写错(7-bit/8-bit 地址混淆,或者器件有可配置地址)。
  3. 设备被其他主机占用或处于睡眠模式(没有应答)。
  4. 总线接线错误(SDA、SCL 反接、没上拉电阻)。
  5. 器件被拉至其他功能(pinmux/alternate function 导致脚不是 I²C 模式)。
  6. 总线通信速率过高导致设备无法跟上。
    如何判断
  • 在发送地址后读 ACK 位:若为 1 则为 NACK。
  • 用示波器/逻辑分析仪观察 SDA、SCL 波形确认第 9 时钟位 SDA 未被拉低。
  • 在软件:实现超时(timeout),如果等待 ACK 超时或立即读为 1 则判定 NACK。
    处理建议
  1. 检查硬件:确认 Vcc、GND、上拉电阻值、线长与接法。
  2. 确认地址:查数据手册,确认 7-bit vs 8-bit 与可配置跳线。
  3. 降低速率:把 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(设备不存在)的额外排查

排查清单(从易到难):

  1. 电源/复位:测设备是否上电(Vcc 到位、RESET 引脚状态)。
  2. 接线:SDA/SCL、GND 是否连接,是否使用了上拉电阻(常见 4.7kΩ4.7\text{k}\Omega4.7kΩ 或 10kΩ10\text{k}\Omega10kΩ)。
  3. 上拉电阻值是否合适:上拉过大导致上升慢,过小导致总线功耗过高。
  4. 总线电容是否太大:每米线或 PCB 探针会增加总线电容 CbC_bCb​,影响 rise time(见下面公式)。
  5. 器件地址/跳线/电阻/配置确认。
  6. 用逻辑分析仪扫地址(bus scan)以确认是否有器件响应。

四、Unexpected Bus State / IO ERROR(总线异常)详解与恢复

常见异常状态

  • SDA 被持续拉低(总线被“卡住”在低电平)
  • SCL 被持续拉低(主机或从机把时钟拉低,阻塞总线)
  • 仲裁丢失(arbitration lost):多主模式下发生
  • 时钟伸展(clock stretching)长时间占用:从机拉低 SCL 导致主机等待
  • 噪声 / 总线漂移:虚假的 START/STOP,导致协议错位
    诊断手段
  • 观察 SDA/SCL 电平:是否有持续低电平?
  • 观察时序:是否有长时间 SCL 低、SDA 不符合 START/STOP 条件?
  • 逻辑分析仪追踪帧,分析是否出现错误的 START、重复 START、或奇数个时钟没有对应数据位等。
    恢复策略
  1. 优雅停止 + 重试策略
    • 发送 STOP,然后再重试一次完整序列(START → ADDR …)。
    • 如果 STOP 无法生成(SDA 被拉低),进行以下更激进的恢复。
  2. 复位从机/总线
    • 对可控从机进行硬复位(toggle RESET 引脚),或断电重上电。
    • 对主机 I²C 外设做软复位/关闭再打开。
  3. 增加超时保护
    在等待 SCL/SDA 到位时不要无限等待,必须有 ttimeoutt_{timeout}ttimeout​,比如 ttimeout=5mst_{timeout}=5\text{ms}ttimeout​=5ms 或依据时序而定。
  4. 检测仲裁丢失(多主场景)
    如果主机在发送期间检测到 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:物理层面排查。

八、工程级最佳实践清单(可打印)

  1. 添加适当上拉电阻(根据 fSCLf_{SCL}fSCL​ 与线路长度选择)。
  2. 对 I²C 操作实现超时与重试(NNN=3~5)。
  3. 在 NACK 或异常时发送 STOP 并调用 bus_recover()
  4. 使用 9 次 SCL 脉冲 + STOP 的恢复方案来释放被卡住的 SDA。
  5. 在多主环境实现仲裁丢失检测与退让。
  6. 在生产/调试阶段用逻辑分析仪记录波形并保存样本。
  7. 对设备扫描做自动化测试(有助于发现地址冲突或设备未响应)。
  8. 若频繁错误,检查上拉、总线电容与供电稳定性。

九、结论(短句总结)

  • 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,&current))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 + 单点写(所有更改合并后一次写回)

七、错误/恢复策略细节(实务建议)

  1. 重试次数:通常 N=3N=3N=3 是合理起点,视现场可靠性可增减。
  2. 退避策略:指数退避(tk=t02kt_k = t_0 2^ktk​=t0​2k)避免总线拥塞。
  3. 总线恢复:如果检测到 SDA 被拉低不放,实行 9 次 SCL 脉冲 + 发 STOP(见你之前收到的恢复伪码)。
  4. 报警/监控:长期写失败应进入“告警态”,上报上层或点亮本地故障 LED。
  5. 性能考虑:对频繁变更的 pin(例如 PWM 模拟)不适合频繁通过 I²C 写入;应尽量把变化降采样或批量提交。
  6. 原子批量更新:如果一次需更新多个 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) 设计选项(概览)及权衡

  1. 保持 void level(bool)(不可失败)——把失败吞掉/隐藏
    • 优点:调用简洁,上层代码最简单。
    • 缺点:丢失错误语义,无法保证关键操作成功;变成“事件ual consistency”,需另行监控。
    • 何时用:仅限指示灯等可以最终一致的场景。
  2. level 设计为返回状态(如 bool / status / std::expected)——同步可失败
    • 优点:调用者能知晓成功/失败并采取措施(重试 / fallback / 报警)。
    • 缺点:调用链膨胀(必须把错误往上抛或处理),代码更啰嗦。
    • 何时用:控制关键外设(继电器、RESET 等)。
  3. 提供两套 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_fallibleasync_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) 作用:

  1. 如果 expr 返回 std::expected<T, E>错误
    → 自动 return unexpected(error) 向上返回
  2. 如果成功:
    → 提取 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());

含义:

  1. 根据前面组合的索引选通 ADC 通道
  2. 调用 .read()(可能失败)
  3. 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)里,不会给你一个固定执行时间 Tmax⁡T_{\max}Tmax​,导致不可用于硬实时场景

为什么 C++ exceptions 很慢(slow)?

因为抛出异常不是简单的“跳转”,而是:

  1. 在 LSDA(Language Specific Data Area)表中查找异常处理器
    → 通常是 binary search(二分查找)
  2. 执行栈展开(Frame Unwinding)
    → 要逐帧清理、调用析构函数
  3. 匹配类型
    → 依赖 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 默认把异常关闭是完全合理的。
启用异常的步骤:
  1. 有异常支持的 GCC
  2. -fexceptions-frtti
  3. 提供 _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)

异常匹配需要类型信息:

  • typeinfo
  • dynamic_cast
  • 异常类型树结构
    这会增加几 KB~十几 KB 的体积。

4. C++ ABI 支持代码

例如:

  • __cxa_throw
  • __cxa_begin_catch
  • __cxa_end_catch
  • personality function(人格例程)
    这些都是异常系统的基础设施。

关键点总结

不是你 throw 了什么导致程序变大

而是启用异常机制需要整个运行时系统,这个系统非常庞大。

三种方案体积对比总结


配置text(字节)相对 baseline
baseline2656
expected26801.01×
exceptions15000856×

最终结论

在 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 ..."
    会调用 sprintfstrlen

问题在这里:

即使你不用 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 的致命膨胀点

  1. 它调用 _cxa_current_exception_type
  2. 它试图获取异常真正的类型名(mangled name)
  3. 调用 __cxa_demangle
  4. __cxa_demangle 是一个庞大的 C++ ABI 类型名解码器
  5. 它需要动态 buffer → 调用 malloc/realloc/free
  6. 它需要字符串解析、重建树形 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_casttypeid
→ 带来 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 等函数里出现了对 sprintfstrlenmemcpy_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_errorterminate 并崩溃(SIGSEGV),说明在桌面编译器上也触发了类似的 《terminate -> verbose handler -> demangle -> crash》流程——这些行为在 libstdc++ 层是复杂且“沉”的。
    结论(从汇编看):膨胀的罪魁主要不是单个 throw 指令,而是 libstdc++ 的verbose terminate / demangle / formatting / unwinder support / sprintf / strlen / memcpy / ABI glue 被拉进来了。

2) 如何系统性找出谁占了多少(工具与命令)

在你已经能生成 .elf 的基础上,做这些步骤非常关键,能把“谁占多少”量化出来。

  1. 看 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 从最终二进制里剔除,并限制不必要的库被拉入。

  1. 函数/数据分段 + GC
-ffunction-sections -fdata-sections \ -Wl,--gc-sections 

作用:把每个函数/数据放进独立段,链接器剔除未被引用的段。必做(通常能去掉很多没有被用到的支持代码)。
2. 禁用 RTTI(如果代码不用)

-frtti (enable) / -fno-rtti (disable) 

如果你的代码不需要 typeiddynamic_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” 代码(中风险)

  1. 重定向 / 覆盖 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 上这是合理的。
  1. 提供一个超精简的 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 逻辑(很大一块),以代价换取体积。
  1. 覆盖 __cxa_allocate_exception / __cxa_throw 等,做最小化实现
    • 如果你不需要完整的异常对象语义(比如只要能 throwcatch(...),但不需要异常传递对象拷贝),你可以提供极简版的 __cxa_allocate_exception/__cxa_free_exception/__cxa_throw,使其不再使用 malloc 或不用复杂逻辑。但这非常危险且易错,除非你非常清楚 ABI 和 unwind 需要什么。

C — 替换/重写被拉入的大函数(中到高风险)

  1. 寻找并替换 sprintfstrlen 的调用点
    • 例如 d_append_num 使用 sprintf;如果这个是来自某个库(如 libstdc++ 的 iostream 操作或异常消息),最好替换成自己写的 itoa/utoa,或移除拼接逻辑。
    • 在代码中把 sprintf 改成轻量 fast_itoasnprintf 的更小实现(注意 nano-libc 的 snprintf 也可能大)。
  2. 避免使用 iostream / std::string 在异常路径
    • iostream、std::string 的实现会拉入大量内存(allocator、locale、facets 等),尽量避免在异常/terminate 路径使用它们。

D — 更激进的:不使用异常,而用 std::expected/手动错误传播(最安全,常用)

  • 你已经看到 std::expected<int,int> 版本几乎没有额外开销,这是最佳实务:不要启用 exceptions,而用 expected + error-propagation macro
  • 如果目标是嵌入式并需极小体积,这是首选路线。启用 exceptions 代价巨大。

4) 一个推荐的“逐步消瘦”流程(可直接跑的步骤)

  1. 先做最安全的:启用节省但无侵入的优化
    • 在编译选项里加:
-ffunction-sections -fdata-sections -fno-exceptions? (if you can) -Wl,--gc-sections -Os -ffunction-sections -fdata-sections 
  • 重新构建并看 size/map。
  1. 如果你必须保留 exceptions(仅作实验),立刻覆盖 terminate/demangle
    • 在你的工程里添加上面给的 std::terminate()__gnu_cxx::__verbose_terminate_handler() stub,重链,查看 size 是否显著下降(通常能去掉几十 KB)。
  2. 查 nm/map 找到最大的符号
arm-none-eabi-nm -S --size-sort except.elf |tail -n 50
  • 找到 __cxa_demangle__cxa_throw_Unwind_*__gnu_cxx::__verbose_terminate_handler 等占多的符号。优先替换或覆盖。
  1. 替换 sprintf/printf
    • 把 printf/sprintf 替换成自己最小实现或禁用该调用路径。
  2. 如果可行,放弃 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 

如果必须启(实验),先做这些以削减体积:

  1. -ffunction-sections -fdata-sections -Wl,--gc-sections
  2. 覆盖 verbose terminate & demangle(在你的 C++ 源里加入 stub)
  3. nm / readelf / objdump 定位大符号并替换/覆盖

7) 小结(结论)

  • 已经亲自验证了:启用 C++ 异常 会把几十 KB 的运行时框架拉进 MCU,这会把程序体积暴增几十倍(你量化为 56×、占 Flash 29%),这是事实而且可重复。
  • 现在的任务是 把这些运行时代码逐一剥离或替换
    • 最有效的做法是彻底不启用异常,改用 std::expected + error-propagation macro(你已经在探索这条路)。
    • 如果坚持要异常,请用 -ffunction-sections + --gc-sections + 覆盖 terminatedemangle 等符号来尽量瘦身,或手工实现精简版 __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)
作用:

  1. 把 exception object 放入全局异常指针
  2. 展开(unwind)当前栈帧
  3. 搜索能处理 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++ 的异常机制必须:

  1. 创建异常对象
  2. 放入堆内存
  3. __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)
没有:

  • printf
  • abort
  • 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
这极大减少了对:

  • libgcc
  • libstdc++
  • mallocnew
  • unwinding runtime glue
    的需求。
    结果是:
    最终固件体积大幅减少异常系统的运行时负担几乎为零 \text{最终固件体积大幅减少} \\ \text{异常系统的运行时负担几乎为零} 最终固件体积大幅减少异常系统的运行时负担几乎为零

最终效果:Barrier #3 完全突破

覆盖分配器之后:

  • throw 不再分配动态内存
  • 异常对象存储稳定可预期
  • 你可以在裸机 (Bare-metal) 环境使用 throw
  • 代码尺寸不会暴涨
  • 运行时不再依赖堆或 malloc
    综上:
    从根源上彻底消灭动态分配引起的所有异常开销 \textbf{从根源上彻底消灭动态分配引起的所有异常开销} 从根源上彻底消灭动态分配引起的所有异常开销

Barrier #4:RTTI 为什么是问题?

RTTI(运行时类型信息)提供两个能力:

  1. dynamic_cast
  2. typeid
    要支持它,编译器必须生成:
  • 每个类的 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)⟶match​typeinfo(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 种编译器行为

你的原文:

  1. 编译器告诉你不能关闭 RTTI
  2. 编译器只保留异常依赖的 RTTI(自动裁剪)
  3. 编译器保持安静 → 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
所以:
项目越大,异常越优越。
错误码的代码膨胀很快比异常还巨大。

Read more

OpenClaw 最新功能大揭秘!2026年最火开源AI Agent迎来史诗级升级,手机变身AI终端不是梦

OpenClaw 最新功能大揭秘!2026年最火开源AI Agent迎来史诗级升级,手机变身AI终端不是梦 大家好,我是Maynor。最近开源社区彻底炸锅了——OpenClaw(前身Clawdbot/Moltbot)又一次刷屏!这个能真正“干活”的本地AI助手,在3月2日刚刚发布v2026.3.1版本,紧接着2月底的v2026.2.26也是里程碑式更新。 从外部密钥管理、线程绑定Agent,到Android深度集成、WebSocket优先传输……OpenClaw正在把“AI常驻员工”从概念变成现实。 今天这篇图文并茂的干货,带你一口气看懂最新功能、安装上手和实战价值!

By Ne0inhk
从新加坡《Companion Guide on Securing AI Systems 》看可信AI全生命周期防护框架构建

从新加坡《Companion Guide on Securing AI Systems 》看可信AI全生命周期防护框架构建

从新加坡《AI系统安全指南配套手册》看可信AI全生命周期防护框架构建 一、引言 1.1 研究背景与意义 近年来,人工智能(AI)技术以前所未有的速度蓬勃发展,已然成为推动各行业变革与创新的核心驱动力。从医疗领域辅助疾病诊断,到金融行业的风险预测与智能投顾,再到交通领域的自动驾驶技术,AI 的身影无处不在,为社会发展带来了巨大的效益 。据国际数据公司(IDC)预测,全球 AI 市场规模在未来几年将持续保持高速增长态势,到 2025 年有望突破千亿美元大关。 然而,随着 AI 技术的广泛应用,其安全问题也逐渐浮出水面,成为制约 AI 健康发展的关键因素。AI 系统面临着来自传统网络安全威胁以及 AI 技术特有的新兴安全挑战。在传统网络安全威胁方面,诸如网络钓鱼、DDoS 攻击、恶意软件入侵等问题屡见不鲜,这些攻击手段不仅会破坏 AI 系统的正常运行,还可能导致数据泄露、隐私侵犯等严重后果。

By Ne0inhk
OpenClaw Cron 深度解读:让 AI Agent 学会自主定时工作

OpenClaw Cron 深度解读:让 AI Agent 学会自主定时工作

OpenClaw Cron 深度解读:让 AI Agent 学会自主定时工作 一句话总结:OpenClaw 的 Cron 系统让 AI Agent 具备了"设闹钟"的能力——不仅能定时提醒用户,还能自己悄悄去执行后台任务,干完活再汇报结果。 🎯 为什么 Agent 需要定时任务? 想象一下这个场景:你让 AI 助手帮你"每天早上9点检查一下服务器状态"。 传统的做法是什么?你得自己设个闹钟,到点了打开对话框,再敲一遍"帮我检查服务器"。这跟没有 AI 助手有什么区别? 真正智能的 Agent 应该能够: * 自主调度:记住用户的需求,到点自动执行 * 后台执行:不打扰用户,

By Ne0inhk
2026 AI十大趋势:木头姐《Big Ideas 2026》深度解读,解锁大加速时代的技术红利

2026 AI十大趋势:木头姐《Big Ideas 2026》深度解读,解锁大加速时代的技术红利

木头姐《Big Ideas 2026》报告指出,AI已成为撬动全球经济“大加速”的核心引擎,不再孤军奋战。本文结合报告核心数据与观点,以幽默接地气的语气,拆解2026年AI十大核心趋势,助力普通人轻松读懂技术红利。 引言 全球科技投资圈“顶流”木头姐(凯茜·伍德),带着她的十周年力作《Big Ideas 2026》如约而至!作为科技圈的“预言家手册”,这份报告每年都能精准预判行业走向,今年更是以“The Great Acceleration”(大加速)为核心,抛出震撼论断:AI早已告别“闭门造车”,成为五大创新平台的“发动机”,正引爆全球经济的变革狂欢。不同于往年聚焦单一技术,今年木头姐重点凸显AI的“全能辅助”角色——自身迭代升级的同时,还在疯狂“带飞”其他技术。接下来,我们就用最轻松的语气,拆解报告里最劲爆的AI十大趋势,

By Ne0inhk