2. 嵌入式系统基础

嵌入式C++的工程实践,最终都要落到一块具体的MCU上:代码被编译成机器指令,写入Flash;变量被分配到SRAM;外设通过寄存器与CPU交互;中断打断主流程并改变执行路径。理解这些底层机制,才能写出既高效又可预测的嵌入式C++代码。本章从系统组成、MCU架构、存储器映射、中断与异常模型、外设交互方式五个角度建立整体认知。

一、嵌入式系统的组成

一个典型的嵌入式系统可以划分为四个层次:硬件层(MCU、传感器、执行器、电源、时钟)、驱动层(寄存器操作、HAL、外设驱动)、系统层(启动代码、RTOS、内存管理、中断管理)、应用层(业务逻辑、通信协议、控制算法)。C++主要工作在驱动层、系统层和应用层,但它对硬件层的假设会直接影响代码风格。

例如,如果目标MCU没有硬件浮点单元(FPU),那么大量使用float和double会显著增加代码体积和运行时间;如果SRAM只有20KB,那么动态分配和深递归就必须谨慎。嵌入式C++不是“把桌面C++缩小”,而是“在资源约束下做工程取舍”。

二、MCU架构:从内核到总线

MCU通常由处理器内核、总线矩阵、存储器、外设和中断控制器组成。以ARM Cortex-M为例,内核负责取指、译码、执行;总线矩阵连接Flash、SRAM、外设;NVIC负责中断优先级和异常响应;外设通过APB/AHB总线挂载。

MCU与通用CPU的关键差异在于:存储器与I/O统一编址、中断向量表固定、外设寄存器映射到地址空间。这意味着C++代码中访问一个外设,本质上是通过指针访问某个固定地址。例如:

// 以STM32为例,GPIOA输出寄存器地址
volatile uint32_t* const GPIOA_ODR = reinterpret_cast<volatile uint32_t*>(0x40020014);
*GPIOA_ODR |= (1u << 5);   // 置位PA5
*GPIOA_ODR &= ~(1u << 5);  // 清零PA5

这里的volatile告诉编译器:该地址的内容可能被硬件改变,不能优化掉读写。这是嵌入式C++与桌面C++最直观的差异之一。

MCU架构与存储器映射示意 Cortex-M 内核 取指 / 译码 / 执行 寄存器组 R0-R15 SP / PC / LR / xPSR NVIC 中断控制器 优先级分组 中断使能 / 挂起 异常向量表 总线矩阵 AHB / APB 地址译码 主从设备仲裁 数据/指令总线分离 存储器映射(典型 Cortex-M 地址空间) 0x0000_0000 - 0x1FFF_FFFF Code / Flash / 启动区 只读/可执行 0x2000_0000 - 0x3FFF_FFFF SRAM / 数据区 读写/栈/堆 0x4000_0000 - 0x5FFF_FFFF 外设寄存器 GPIO/UART/SPI 0xE000_0000 - 0xE00F_FFFF 系统控制 / NVIC / SysTick 内核外设

三、存储器映射与链接脚本

存储器映射决定了代码和数据放在哪里。链接脚本(linker script)负责把不同段分配到具体地址:.text放Flash,.data放SRAM(初始化值在Flash),.bss放SRAM(清零),.stack和.heap通常位于SRAM末尾。

嵌入式C++中,const全局变量通常放Flash,constexpr在编译期求值,不占运行时存储。但要注意:如果const对象被取地址并传给需要RAM的函数,编译器可能把它放到RAM。使用constexpr和consteval可以更明确地表达编译期意图。

段名典型位置内容C++对应
.textFlash机器指令函数、模板实例
.rodataFlash只读常量const、字符串字面量
.dataSRAM已初始化全局/静态变量非const全局对象
.bssSRAM未初始化/零初始化变量零初始化全局对象
.stackSRAM函数调用栈局部变量、返回地址
.heapSRAM动态分配区new/malloc

四、中断与异常模型

中断是嵌入式系统实现实时响应的核心机制。Cortex-M把中断和异常统一管理:复位、NMI、HardFault、SysTick、外设中断都通过向量表跳转。中断服务程序(ISR)应当短小、无阻塞、无动态分配,并通过volatile或原子操作与主循环共享数据。

volatile bool g_data_ready = false;
volatile uint16_t g_adc_value = 0;

extern "C" void ADC1_IRQHandler() {
    g_adc_value = ADC1->DR;
    g_data_ready = true;
}

int main() {
    while (true) {
        if (g_data_ready) {
            g_data_ready = false;
            process(g_adc_value);
        }
    }
}

常见误区:在ISR中使用new、printf、std::vector或长时间循环,会导致中断延迟增大、栈溢出或死锁。ISR应只做“记录事件、拷贝数据、置标志”,复杂处理交给主循环或RTOS任务。

五、外设交互方式:轮询、中断与DMA

MCU与外设交互主要有三种方式:轮询(CPU反复读状态寄存器)、中断(外设就绪时通知CPU)、DMA(外设直接与存储器交换数据,不占用CPU)。轮询简单但浪费CPU;中断实时性好但频繁中断有开销;DMA适合大批量数据传输,如ADC连续采样、UART收发、SPI屏幕刷新。

在C++中,可以用类封装外设,把寄存器操作隐藏在成员函数中,同时保持零开销抽象:

class Uart {
public:
    explicit Uart(uint32_t base) : reg_(reinterpret_cast<volatile uint32_t*>(base)) {}
    void write(char c) {
        while (!(reg_[STATUS] & TX_READY)) {}
        reg_[DATA] = static_cast<uint32_t>(c);
    }
private:
    volatile uint32_t* reg_;
    static constexpr uint32_t STATUS = 0x04;
    static constexpr uint32_t DATA   = 0x00;
    static constexpr uint32_t TX_READY = 0x80;
};

工程建议:外设封装类应避免虚函数和异常,优先使用constexpr、inline和模板,让编译器在优化后生成与直接寄存器操作等价的代码。这就是“零开销抽象”在嵌入式C++中的具体含义。

六、案例与复盘:一个ADC采样系统的演进

假设要采集一路ADC并做滤波。最初版本用轮询:主循环不断读ADC状态,CPU占用率高。改进版本用中断:ADC转换完成触发ISR,主循环处理数据,CPU占用率下降。再进一步,用DMA把ADC结果直接搬到缓冲区,CPU只在半满/全满时处理,适合高速采样。

复盘要点:先明确实时性要求和数据量,再选择交互方式;不要为了“高级”而滥用DMA或RTOS;ISR与主循环之间的共享数据必须用volatile或原子类型保护;外设寄存器访问必须使用volatile指针,否则编译器优化可能导致读写被删除或重排。