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++最直观的差异之一。
三、存储器映射与链接脚本
存储器映射决定了代码和数据放在哪里。链接脚本(linker script)负责把不同段分配到具体地址:.text放Flash,.data放SRAM(初始化值在Flash),.bss放SRAM(清零),.stack和.heap通常位于SRAM末尾。
嵌入式C++中,const全局变量通常放Flash,constexpr在编译期求值,不占运行时存储。但要注意:如果const对象被取地址并传给需要RAM的函数,编译器可能把它放到RAM。使用constexpr和consteval可以更明确地表达编译期意图。
| 段名 | 典型位置 | 内容 | C++对应 |
|---|---|---|---|
| .text | Flash | 机器指令 | 函数、模板实例 |
| .rodata | Flash | 只读常量 | const、字符串字面量 |
| .data | SRAM | 已初始化全局/静态变量 | 非const全局对象 |
| .bss | SRAM | 未初始化/零初始化变量 | 零初始化全局对象 |
| .stack | SRAM | 函数调用栈 | 局部变量、返回地址 |
| .heap | SRAM | 动态分配区 | 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指针,否则编译器优化可能导致读写被删除或重排。