跳转至

嵌入式的资源与实时约束

一、前言:为什么嵌入式不能“随便写”

在 PC 或服务器开发中,我们习惯于一种“默认”前提:内存不够可以加、算力不足可以扩、系统调度由操作系统兜底。程序的目标往往只需满足“功能正确 + 平均性能尚可”。

但嵌入式系统并不生活在这样的世界里。在嵌入式环境中,资源是被严格量化的:Flash 可能只有几十 KB,RAM 只有几 KB,CPU 主频只有几十 MHz,而系统却承担着实时控制、设备安全、工业或消费级可靠性等责任。在这里,程序不只是“能跑”就够了,它还必须:

  • 在规定时间内完成任务
  • 在最坏情况下依然行为正确
  • 在有限资源下保持长期稳定运行

嵌入式工程的本质,是在一个资源受限的世界里,追求系统行为的确定性。当然,这一部分跟C++喜欢把一些事情藏起来存在冲突,但是用好C++中的大部分特性,的确可以推动不小的性能改进。

二、Flash / ROM 约束:代码不是“免费”的

2.1 Flash 的现实规模

嵌入式系统中,程序存储空间首先受到 Flash / ROM 容量的严格限制:

  • STM32F103:64KB ~ 128KB Flash
  • STM32F4 系列:512KB ~ 2MB Flash
  • 低端 MCU:甚至只有 16KB

与动辄几十 MB 可执行文件的 PC 程序相比,这种容量差异是数量级的鸿沟。

2.2 Flash 约束如何影响软件设计

在这样的环境下,“写什么代码”本身就是一种工程决策。代码体积直接决定系统是否可部署,功能冗余意味着真实的存储浪费。引入库不再是“好不好用”,而是“能不能放下”(对的,笔者真见过printf一拉进来二进制文件爆炸性拉大)。因此,嵌入式工程师必须掌握一些常见的编译器优化选项:

  • 编译器优化选项(如 -Os 优化代码大小)
  • 函数与段级别的垃圾回收(-ffunction-sections, -fdata-sections 配合 --gc-sections
  • 精确控制链接行为

这个别着急,后面我们有一个专门的章节好好了解。

三、RAM 约束:内存不是“用完就算”

如果说 Flash 约束限制了“能写多少功能”,那么 RAM 约束则直接影响系统是否能稳定运行。

3.1 RAM 的数量级现实

嵌入式系统中,RAM 往往只有:2KB / 8KB / 20KB / 64KB。在这样的环境下,我们真有可能搞出来栈溢出,把SP指针跑飞了,而且,如果内存管理算法搞的不好,我们的实时系统可能在数小时或数天后,其堆碎片就会把系统搞崩溃了(allocate行为找不到合适的buffer了)

3.2 栈的风险

栈空间主要消耗于:函数调用深度、中断嵌套、局部变量。在嵌入式中,以下行为往往被严格限制甚至禁止。

  • 你肯定是不允许搞递归的——我们都知道递归的本质是自己调自己,一不小心把栈叠太大了,会直接把系统搞崩了(毕竟我们没办法预知到底要迭代多少,你算的过来多少,用户和其他任务堆栈可不管你)
  • 大型的局部数组也不要开,仍然一样——把栈叠太大了,会直接把系统搞崩了

一次不可预期的栈增长就可能直接破坏系统。

如果你真的需要大数组,这样玩:

// 避免大型局部数组
void process_data(void) {
    // 不建议:uint8_t buffer[4096]; // 可能溢出
    // 建议:使用静态或全局内存,或分段处理
    static uint8_t buffer[256]; // 或从内存池分配
}

3.3 堆的风险

运行期动态内存分配在嵌入式系统中一直是高风险操作:

  • malloc/free 的时间复杂度不可预测
  • 长期运行会产生内存碎片
  • 错误难以复现与调试

成熟的嵌入式系统通常会采用:

  • 启动阶段一次性分配
  • 内存池 / 对象池
  • 完全静态内存模型
#define POOL_SIZE 1024
#define BLOCK_SIZE 32
#define NUM_BLOCKS (POOL_SIZE / BLOCK_SIZE)

static uint8_t memory_pool[POOL_SIZE];
static bool block_used[NUM_BLOCKS] = {0};

void* mempool_alloc(void) {
    for (int i = 0; i < NUM_BLOCKS; i++) {
        if (!block_used[i]) {
            block_used[i] = true;
            return &memory_pool[i * BLOCK_SIZE];
        }
    }
    return NULL; // 无可用内存
}

在嵌入式系统中,内存管理首先服务于确定性,而不是便利性。

四、CPU 约束:算力是被精确计数的

在 PC/服务器的世界里,我们习惯把 CPU 当成一个“几乎用不完”的资源: 算法慢一点?加个缓存;分支多一点?交给乱序执行;浮点算不动?硬件来兜底。CPU 在那儿更像个背景板——只要不太慢就行。但在嵌入式里,事情不是“快不快”的问题,CPU 是个需要被精确计量与精确预算的资源。当然,现代的芯片,如果资源不是很紧张,犯不着这样做,但是成本在这,你的老板一定会狠狠要求你压榨的,不是吗?


4.1 MCU 的算力特征

典型 MCU 的算力特征,和桌面 CPU 几乎处在两个世界:

  • 主频有限(几十到几百 MHz)
  • 无乱序执行,基本严格顺序流水
  • 分支预测能力弱,甚至没有
  • Cache 极小,甚至没有 Cache

结论很直接:在 MCU 上,代码的行为几乎可以直接映射到指令流。你写下的每一个 if、每一个循环、每一次函数调用,最终都会变成实打实的一条条指令,按顺序执行。


4.2 时间复杂度的“工程化”

在嵌入式世界,时间复杂度往往不是 O(n) 那样的数学讨论,真正的问题是:

这段代码,能不能在一个控制周期内跑完?

比如:

  • 在没有 FPU 的 MCU 上,一个浮点运算可能要几十个周期。
  • 一次整数除法,往往比几十次加减更昂贵。
  • 中断响应时间取决于 CPU 当时正在执行的指令路径。

所以嵌入式工程师会做一些对桌面程序员看着“反直觉”的事:

  • 分析 最坏执行时间(WCET)
  • 避免不可预测的循环次数
  • 控制分支数量,减少执行路径的不确定性
  • 必要时看反汇编、手动估算 cycle 数

下面的例子看起来只是微小重构,但在 MCU 上意义重大:

// 优化前:条件判断在循环内
for (int i = 0; i < n; i++) {
    if (condition) {
        process_a(data[i]);
    } else {
        process_b(data[i]);
    }
}

问题不在逻辑错误,而在于:每一次循环都要经历一次分支判断。在没有分支预测的 CPU 上,这就是稳定且可观的性能损耗。改法也很朴素:

// 优化后:减少分支预测失败
if (condition) {
    for (int i = 0; i < n; i++) {
        process_a(data[i]);
    }
} else {
    for (int i = 0; i < n; i++) {
        process_b(data[i]);
    }
}

优化点不是“更聪明”,而是:把一次不确定的分支,换成一次确定的执行路径。在嵌入式里,这种“看起来啰嗦”的写法,常常才是工程上真正安全且可分析的代码。


五、功耗约束:程序在“消耗能量”

很多新手以为功耗完全是硬件的事:芯片型号、供电电压、工艺制程。可事实是,软件行为在功耗上起着直接且可观的作用

一句话概括:

你的程序在运行的每一秒,都在真实地消耗能量。


5.1 软件行为决定功耗

下列看似“无害”的软件行为,都会直接转成电流消耗:

  • 忙等待(busy loop)
  • 高频轮询外设状态
  • 外设常年保持开启
  • 系统被频繁、无意义地唤醒

即便 CPU “什么都不做”,只要还在执行指令、还在跑时钟,功耗就持续在发生。换言之:“CPU 在忙”本身就是一种能量消耗状态。


5.2 面向低功耗的软件设计

嵌入式低功耗设计的核心,不是“算得更快”,而是:

该醒的时候醒,该睡的时候睡。

常见策略有:

  • 用事件驱动替代轮询
  • 使用中断而不是 while-loop
  • 合理进入 Sleep / Stop / Standby 模式
  • 把零散工作合并成批量处理

典型的低功耗主循环长这样:

void main_loop(void) {
    while (1) {
        // 检查是否有事件待处理
        if (!event_pending()) {
            // 无事件时进入低功耗模式
            enter_sleep_mode();
            wait_for_interrupt(); // 硬件特定指令
        }
        // 处理所有待处理事件
        process_all_events();
    }
}

高级之处不在复杂逻辑,而在明确告诉系统:没事别硬撑,让硬件帮你省电。在嵌入式里,写得“更聪明”的代码——往往比写得“更快”的代码更省电。


六、启动时间约束:从上电到可用

在很多嵌入式场景里,“启动完成”不是模糊概念,而是写进需求的硬指标:必须在限定时间内进入可用状态。


6.1 启动时间为何重要

这些场景尤其敏感:

  • 工业控制(上电即需进入控制状态)
  • 汽车电子(不能“慢慢想”)
  • 消费电子(用户体验)

你不能像 PC 那样“转圈加载”,系统必须在规定时间内、以可预测的方式变为可用。


6.2 启动链路的成本

典型启动链路:

  1. 上电复位
  2. BootROM 执行
  3. Bootloader 初始化
  4. 外设与内存初始化
  5. 进入主控制逻辑

链路上的每一步,都会消耗启动时间。原则就是:只做必须做的事,复杂或非关键的初始化尽量延后

// 只初始化必要的外设,延迟初始化其他
void system_init(void) {
    init_clock();       // 必须首先初始化
    init_watchdog();    // 尽早启用看门狗
    init_critical_io(); // 关键 IO 初始化

    // 非关键外设延迟初始化
    // init_uart();    // 移到需要时初始化
    // init_spi();     // 同上
}

这种“克制”的初始化方式,常常是达成启动时间指标的关键。


七、实时性与确定性:嵌入式系统的灵魂

7.1 实时性并不等于“快”

新手常把“实时”等同于“更快”,其实实时系统更关心的是:

时间约束是否可被满足。

  • 硬实时(Hard Real-Time):一旦超时,系统判失败。
  • 软实时(Soft Real-Time):允许偶尔超时,但必须可控。

是否实时,取决于能否在最坏情况下仍按时完成任务。


7.2 确定性(Determinism)

确定性意味着:在相同输入与状态下,程序的执行路径、耗时与结果都是可预测的。回头看前面的约束,你会发现它们都指向同一个目标:

  • Flash 约束,限制功能规模
  • RAM 策略,避免运行期不确定性
  • CPU 约束,强迫可分析的执行路径
  • 功耗与启动约束,限制系统行为模型

嵌入式系统真正的价值,不在于“跑得多快”,而在于:

在最坏情况下,依然可控。

下面是一个极简但确定性的调度器示例:

// 简单的周期任务调度器
typedef struct {
    void (*task)(void);
    uint32_t period_ticks;
    uint32_t last_run;
} scheduled_task_t;

void scheduler_run(void) {
    uint32_t now = get_system_tick();

    for (int i = 0; i < NUM_TASKS; i++) {
        if ((now - tasks[i].last_run) >= tasks[i].period_ticks) {
            tasks[i].task();            // 执行任务
            tasks[i].last_run = now;    // 更新执行时间
        }
    }
}

它不复杂、不华丽,但行为是可分析、可推导、可验证的——这正是嵌入式最看重的特质。