3. 嵌入式软件基础:裸机编程与RTOS、中断与上下文切换、内存管理(堆栈溢出风险)

好,咱们进入第三章。这一章,说白了就是嵌入式软件的「内功心法」。你写再多外设驱动,如果底子不牢,系统一跑起来就崩,那都是白搭。我个人习惯把这一章看作是「从单片机菜鸟到系统架构师」的第一道坎。

3.1 裸机编程 vs RTOS:你该怎么选?

很多新手会纠结:我到底要不要上RTOS?其实这个问题没有标准答案,得看场景。

裸机编程,就是那个经典的超级循环(Super Loop):

void main(void) {
    system_init();
    while(1) {
        task_led_blink();
        task_key_scan();
        task_uart_send();
    }
}

这种结构简单、直观。我在做一个小型温控器项目时,就用裸机。任务就那么三五个,跑得稳稳的。但问题来了——如果某个任务卡住了,比如 task_key_scan() 里有个防抖延时,整个系统都得等。这就是裸机的痛点:实时性差,任务之间互相阻塞

RTOS(实时操作系统) 就不一样了。它把任务拆成独立的线程,由调度器决定谁先跑。比如 FreeRTOS:

void task_led(void *pvParameters) {
    while(1) {
        GPIO_Toggle(LED_PIN);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

void task_key(void *pvParameters) {
    while(1) {
        if(KEY_PRESSED()) {
            // 处理按键
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

你看,两个任务互不干扰。LED 任务延时 500ms 时,CPU 会跑去执行按键任务。这就是 RTOS 的魅力——看起来像是「同时」在做多件事

我的建议:

  • 任务数 < 5,且对实时性要求不高 → 裸机就够了
  • 任务数 > 5,或有严格的时间要求(比如 1ms 内必须响应)→ 上 RTOS
  • 项目后期可能加功能 → 直接上 RTOS,省得重构

3.2 中断与上下文切换:系统的「心跳」

中断,是嵌入式系统的灵魂。没有中断,CPU 就得一直轮询,效率低得吓人。

我记得有一次调试一个电机控制板,电机一转,屏幕就闪。查了半天,发现是中断服务函数(ISR)里干了太多活——我在 ISR 里调用了 printf()!你知道的,printf 是阻塞的,而且耗时很长。这导致其他低优先级中断被延迟,屏幕刷新就卡顿了。

中断处理的原则:快进快出。 ISR 里只做最紧急的事,比如置一个标志位,或者把数据塞进队列。真正的处理,放到任务里去。

// 错误示范:在 ISR 里做复杂处理
void EXTI0_IRQHandler(void) {
    uint32_t data = read_sensor();
    process_data(data);  // 这可能会花好几毫秒!
    CLEAR_EXTI_LINE();
}

// 正确做法:ISR 只做标记
volatile uint8_t sensor_ready = 0;
uint32_t sensor_value;

void EXTI0_IRQHandler(void) {
    sensor_value = read_sensor();
    sensor_ready = 1;
    CLEAR_EXTI_LINE();
}

void task_sensor(void *pvParameters) {
    while(1) {
        if(sensor_ready) {
            sensor_ready = 0;
            process_data(sensor_value);  // 在任务里慢慢处理
        }
        vTaskDelay(1);
    }
}

说到上下文切换,这是 RTOS 的核心机制。说白了,就是 CPU 在任务 A 和任务 B 之间「切来切去」。每次切换,都要保存当前任务的寄存器、堆栈指针,再恢复下一个任务的。这个过程叫 上下文切换(Context Switch)

为什么会造成开销?因为每次切换,CPU 都要做一堆「保存现场」和「恢复现场」的操作。如果切换太频繁(比如每 1ms 切一次),那 CPU 大部分时间都在做切换,而不是干正事。我见过一个项目,把时间片设成了 100μs,结果系统 30% 的 CPU 时间都浪费在切换上了。嗯,这显然不合理。

避坑指南: 我曾经把 RTOS 的时间片设得太小,导致系统响应反而变慢。后来改成 5ms,一切正常。记住:时间片不是越小越好,要根据任务的实际需求来定。

3.3 内存管理:堆栈溢出的「定时炸弹」

嵌入式系统的内存,就那么几 KB 到几 MB。用不好,系统随时可能崩溃。而 堆栈溢出,是嵌入式开发中最常见、也最难排查的问题之一。

先说说栈(Stack)。 每个任务都有自己的栈空间。局部变量、函数调用参数、返回地址,都放在栈里。如果栈太小,函数嵌套太深,或者局部变量太大,栈就会溢出——覆盖掉相邻的内存区域。后果?可能是程序跑飞,也可能是某个全局变量莫名其妙被改了。

我遇到过最诡异的一个 bug:一个全局的 uint8_t flag,明明没代码改它,但每隔一段时间就会变成 0xFF。查了三天,最后发现是一个任务的栈溢出了,刚好覆盖了 flag 的地址。从那以后,我养成了一个习惯:每个任务至少分配 1.5 倍于估算的栈空间

// 估算栈大小
void task_deep(void *pvParameters) {
    uint8_t buffer[512];  // 局部数组,占 512 字节
    // 再加上函数调用链的栈帧...
    // 所以这个任务至少需要 600 字节的栈
}

// 创建任务时,分配足够的栈
xTaskCreate(task_deep, "Deep", 1024, NULL, 1, NULL);  // 给 1KB,留余量

再说说堆(Heap)。 堆是动态内存分配的地方,比如 mallocnew。在嵌入式系统里,我强烈建议:尽量避免动态内存分配。为什么?

  • 碎片化: 频繁分配释放,堆会变得支离破碎。最后明明有足够的总空间,却分配不出一块连续的内存。
  • 不确定性: malloc 的执行时间不确定,可能造成实时任务超时。
  • 内存泄漏: 忘了 free,内存越用越少,系统最终崩溃。

警告: 我曾经在一个产品里用了 malloc 来动态创建消息缓冲区。运行了 72 小时后,系统突然死机。分析发现,堆碎片化导致 malloc 返回 NULL,而代码没做 NULL 检查,直接往 NULL 地址写数据...嗯,后果你懂的。

那怎么办? 我的做法是:

  1. 静态分配优先: 所有缓冲区、任务栈、队列,都在编译时分配好大小。
  2. 如果必须动态分配: 使用固定大小的内存池(Memory Pool),而不是通用堆。
  3. 检查返回值: 每次 mallocpvPortMalloc 后,必须检查是否为 NULL。
// 使用内存池(FreeRTOS 示例)
StaticTask_t task_buffer;
StackType_t task_stack[1024];

TaskHandle_t xTaskCreateStatic(
    TaskFunction_t pvTaskCode,
    const char * const pcName,
    uint32_t ulStackDepth,
    void *pvParameters,
    UBaseType_t uxPriority,
    StackType_t *puxStackBuffer,
    StaticTask_t *pxTaskBuffer
);

// 这样,任务栈在编译时就分配好了,不存在运行时分配失败的问题

3.4 总结:打好地基,才能盖高楼

这一章的内容,说实话,有点「枯燥」。但它是你从「调通代码」到「设计系统」的必经之路。裸机还是 RTOS?中断怎么处理?内存怎么管?这些问题,你在每个项目里都会遇到。

我个人觉得,嵌入式开发最迷人的地方,就是这种「在资源受限的环境下做最优决策」的挑战。你想想看,用几 KB 的内存、几十 MHz 的主频,去实现一个稳定、实时、可靠的系统——这不比在 PC 上写个 CRUD 有意思多了?

下一章,我们会深入外设驱动开发。到时候,你会用到今天学的这些知识——尤其是中断和内存管理。嗯,做好准备吧。