裸机主循环的极限
裸机程序的结构通常是一个大 while(1),里面按顺序调用若干功能函数。功能少时它非常好用,但随着需求增加会出现三个无法回避的问题:任何一个函数里的延时都会拖慢其他功能;长任务的执行时间决定了其他功能的最坏响应时间;各功能之间靠全局变量通信,时序一旦错位就出现难以复现的偶发故障。
RTOS 解决的核心问题是“谁先跑”:用优先级决定任务的重要性,用阻塞让出 CPU 而不是空转等待,用队列与信号量把共享数据变成有明确规则的传递。
FreeRTOS 是嵌入式领域最常见的实时内核之一,它的资源占用可以小到几 KB,代码结构清晰,被大量芯片厂商作为默认选择。下面从任务创建开始,把最常用的几个机制讲透。
任务与调度:优先级与状态
一个任务就是一个永不返回的函数,内部通常是一个死循环。FreeRTOS 采用抢占式调度:更高优先级的就绪任务会立刻抢走 CPU;同优先级任务按时间片轮转(可配置)。
#include "FreeRTOS.h"
#include "task.h"
static TaskHandle_t h_sensor, h_upload;
/* 采集任务:最高优先级,周期 10ms */
static void sensor_task(void *arg)
{
TickType_t last = xTaskGetTickCount();
for (;;) {
read_sensor();
/* vTaskDelayUntil 保证严格周期,不受任务执行时间影响 */
vTaskDelayUntil(&last, pdMS_TO_TICKS(10));
}
}
/* 上报任务:低优先级,等数据攒够再走网络 */
static void upload_task(void *arg)
{
for (;;) {
if (wait_for_batch(portMAX_DELAY)) {
do_upload(); /* 阻塞式网络发送,期间高优先级任务照常运行 */
}
}
}
int main(void)
{
HAL_Init();
SystemClock_Config();
hw_init();
xTaskCreate(sensor_task, "sensor", 256, NULL, 4, &h_sensor); /* 栈深度单位:字(word) */
xTaskCreate(upload_task, "upload", 512, NULL, 2, &h_upload);
vTaskStartScheduler(); /* 启动调度器后不会返回 */
for (;;) { }
}
两点经验:栈深度参数的单位是“字”而不是字节(Cortex-M 上一个字 4 字节,256 表示约 1KB),分配时要按最坏调用深度估算并留余量;vTaskDelayUntil 用于固定周期任务,vTaskDelay 用于“至少等这么久”的场景,混用会导致周期漂移累积。
任务间通信:队列、信号量与事件组
任务之间共享数据必须用内核提供的机制,直接读写全局变量在抢占式调度下随时可能出错。
#include "queue.h"
#include "semphr.h"
typedef struct { uint32_t ts; float temp; float hum; } sample_t;
static QueueHandle_t q_sample; /* 传数据 */
static SemaphoreHandle_t sem_dma; /* 传“事件发生了” */
static EventGroupHandle_t ev_net; /* 传“多个条件” */
#define BIT_LINK_UP (1 << 0)
#define BIT_GOT_IP (1 << 1)
static void producer(void *arg)
{
sample_t s;
for (;;) {
s.ts = xTaskGetTickCount(); s.temp = read_temp(); s.hum = read_hum();
/* 队列满时等 10ms,仍写不进就丢弃并计数,绝不无限阻塞 */
if (xQueueSend(q_sample, &s, pdMS_TO_TICKS(10)) != pdPASS) {
g_drop_cnt++;
}
vTaskDelay(pdMS_TO_TICKS(100));
}
}
static void consumer(void *arg)
{
EventBits_t bits;
for (;;) {
/* 等网络可用:两个位同时满足 */
bits = xEventGroupWaitBits(ev_net, BIT_LINK_UP | BIT_GOT_IP,
pdFALSE, pdTRUE, portMAX_DELAY);
if ((bits & (BIT_LINK_UP | BIT_GOT_IP)) != (BIT_LINK_UP | BIT_GOT_IP)) continue;
sample_t s;
if (xQueueReceive(q_sample, &s, pdMS_TO_TICKS(200)) == pdPASS) {
upload_one(&s);
}
}
}
选择哪种机制有清晰的标准:传数据用队列(拷贝语义,天然线程安全);通知“某件事发生了”用信号量;需要同时等待多个条件用事件组。队列的一个实用细节是“满了怎么办”:在采集型任务里宁可丢弃并计数,也不要无限阻塞,否则上游设备会产生连锁卡顿。
互斥与临界区:优先级反转怎么来的
多个任务访问同一块资源(如一条 SPI 总线、一个 Flash 区域),需要用互斥量保护。这里有一个容易被忽视的问题:普通二值信号量会导致优先级反转。
static SemaphoreHandle_t m_spi;
static void bus_init(void)
{
/* 用互斥量而不是二值信号量:它带优先级继承机制 */
m_spi = xSemaphoreCreateMutex();
}
/* 低优先级任务占用总线,中优先级任务抢走 CPU,高优先级任务被迫等待
—— 互斥量的优先级继承会把低优先级任务的优先级临时提高,缩短这段阻塞 */
static bool spi_write_safe(const uint8_t *buf, size_t len)
{
if (xSemaphoreTake(m_spi, pdMS_TO_TICKS(50)) != pdPASS) {
return false; /* 拿不到就返回失败,绝不无限等 */
}
bool ok = spi_write(buf, len);
xSemaphoreGive(m_spi);
return ok;
}
/* 极短、且不能被打断的操作才用临界区(会关闭中断,时间必须极短) */
static void counter_inc(void)
{
taskENTER_CRITICAL();
g_counter++; /* 几微秒内完成的原子性操作 */
taskEXIT_CRITICAL();
}
- 保护资源用互斥量二值信号量没有优先级继承,用于“通知”而不是“保护”。
- 临界区要短taskENTER_CRITICAL 会屏蔽中断,超过几十微秒就会影响实时性。
- 加超时不要 portMAX_DELAY除“必须等到”的场景外都设置超时,避免一处死锁拖死整机。
时基:延时、定时器与 tick
FreeRTOS 的时间基准来自一个周期性 tick 中断,configTICK_RATE_HZ 决定它的频率;设为 1000 表示 1 个 tick 等于 1ms。所有延时与超时参数都以 tick 为单位,为了可读性建议统一用 pdMS_TO_TICKS() 换算。
/* 软件定时器:适合“延迟执行”或“周期性小任务”,回调在定时器服务任务中执行 */
static TimerHandle_t t_report;
static void report_cb(TimerHandle_t xTimer)
{
/* 注意:这里运行在定时器服务任务上下文,不能调用阻塞 API */
g_flags |= FLAG_REPORT_DUE;
}
static void timer_setup(void)
{
t_report = xTimerCreate("report",
pdMS_TO_TICKS(5000),
pdTRUE, /* 周期定时器 */
NULL,
report_cb);
xTimerStart(t_report, 0);
}
/* 严格周期任务用 vTaskDelayUntil,避免执行时间造成的漂移 */
static void control_task(void *arg)
{
TickType_t last = xTaskGetTickCount();
const TickType_t period = pdMS_TO_TICKS(20);
for (;;) {
do_control(); /* 假设最坏 3ms 完成 */
vTaskDelayUntil(&last, period);
}
}
判断“用软件定时器还是独立任务”有个简单标准:回调执行时间短、不阻塞、不需要大栈,就用定时器;反之用专门的任务。把所有逻辑塞进定时器回调,是常见的设计失误。
内存与栈:最容易忽略的故障源
嵌入式系统没有虚拟内存,所有的错误都是真实的。栈溢出与堆耗尽是最常见的两类神秘故障,好在 FreeRTOS 提供了检测手段。
/* FreeRTOSConfig.h 关键配置 */
#define configTICK_RATE_HZ 1000
#define configTOTAL_HEAP_SIZE (16 * 1024)
#define configCHECK_FOR_STACK_OVERFLOW 2 /* 2:同时在切换时检查栈标记 */
#define configUSE_MALLOC_FAILED_HOOK 1
#define configMAX_SYSCALL_INTERRUPT_PRIORITY 5 /* 高于此优先级的中断不得调用 API */
/* 栈溢出钩子:名字固定,由内核在检测到溢出时回调 */
void vApplicationStackOverflowHook(TaskHandle_t xTask, char *pcTaskName)
{
(void)xTask;
/* 不要在这里做复杂操作,尽快留下证据并复位 */
log_emergency("STACK OVERFLOW: %s", pcTaskName);
NVIC_SystemReset();
}
void vApplicationMallocFailedHook(void)
{
log_emergency("MALLOC FAILED");
NVIC_SystemReset();
}
| 堆方案 | 特点 | 适用场景 |
|---|---|---|
| heap_1 | 只分配不释放,实现最简单 | 创建任务后不再动态申请 |
| heap_2 | 可释放但不合并碎片 | 已不推荐,仅用于兼容旧工程 |
| heap_4 | 可释放并合并相邻空闲块 | 最常用,通用性最好 |
| heap_5 | 支持多块不连续内存区 | 内部分散在多段 RAM 的芯片 |
栈大小怎么定?先把 configCHECK_FOR_STACK_OVERFLOW 打开跑一段时间,再用 uxTaskGetStackHighWaterMark() 读每个任务的历史最小剩余栈,按“最大占用 × 1.5 + 中断嵌套余量”设置。凭感觉设栈,往往不是溢出就是浪费。
常见坑与调试手段
最后汇总那些“用过一次就记住”的经验。
- 中断里必须用带 FromISR 后缀的 API:xQueueSendFromISR、xSemaphoreGiveFromISR,且要正确使用 pxHigherPriorityTaskWoken 参数触发切换。
- 中断优先级不能高于 configMAX_SYSCALL_INTERRUPT_PRIORITY:高于它的中断不允许调用任何 FreeRTOS API,否则会破坏内核数据结构。
- 不要在中断里调用 vTaskDelay:中断上下文没有任务概念,只能使用 FromISR 系列或直接操作硬件。
- 喂狗要放在最不可能失效的任务里:用高优先级监控任务检查各任务的心跳标志位,再喂狗,能顺便捕获任务卡死。
- printf 是重量级操作:重定向到串口时务必用互斥量保护,或改用环形缓冲 + 后台任务输出。
- 调试时先关优化:-Os 下断点跳转与变量观察都会失真,定位问题时可临时切到 -O0。
/* 用运行统计发现“某个任务没跑起来”:需开启 configGENERATE_RUN_TIME_STATS */
void dump_task_stats(void)
{
char buf[512];
vTaskGetRunTimeStats(buf); /* 每个任务的运行时间与占比 */
log_printf("task stats:\n%s", buf);
}
void watch_dog_task(void *arg)
{
for (;;) {
if (heartbeat_ok(TASK_SENSOR) && heartbeat_ok(TASK_UPLOAD) &&
heartbeat_ok(TASK_CONTROL)) {
iwdg_feed(); /* 所有关键任务都活着才喂狗 */
}
vTaskDelay(pdMS_TO_TICKS(200));
}
}
RTOS 的学习曲线不在 API,而在思维方式的切换:从“顺序执行”切换到“并发协作”。一旦习惯用阻塞代替空转、用队列代替全局变量,代码的可维护性会有明显跃升。
建议的推进路径是:先用两个任务把采集与上报拆开,跑通队列与延时;再加入一个控制任务,引入互斥量与优先级设计;最后接入看门狗与运行统计。每加一层都验证一次,比一次性搭好复杂框架更容易成功。