夜雨聆风学习资料网

ARTICLE · 1310861

《从源码搭建嵌入式软件框架》06|换了系统,为什么上层代码可以少改一点

《从源码搭建嵌入式软件框架》06|换了系统,为什么上层代码可以少改一点
线程怎么创建,锁怎么申请,定时器到点以后在哪执行——不同系统,做法可能不同。

如果上层到处直接调用操作系统接口,换系统时就得四处修改。

平台适配层像一位翻译:上层表达“我要做什么”,下层负责翻译成当前系统的调用。🧩

这次沿 FreeRTOS 实现,看看它具体翻译了什么。下文名称已通用化,保留实际接口形状和关键分支;代码片段用于解释,不是完整独立程序。

示例假设已包含公共接口头文件,以及 、 等标准头文件。

一、创建线程:上层传需求,下层安排任务

线程可以先理解为一条独立推进的执行流程。上层需要告诉框架:执行哪个函数、带什么参数、需要什么属性。

公共接口形如:

intos_thread_create(    os_thread_handle_t *out,    void *(*entry)(void *),    void *arg,    const os_thread_attr_t *attr);

例如,创建一个持续输出心跳的线程:

staticvoid *heartbeat(void *arg){    (void)arg;    for (;;) {        puts(”线程还在工作”);        os_thread_sleep(1000);    }    return NULL;}os_thread_handle_t thread = NULL;os_thread_attr_t attr = {    .type = OS_THREAD_DYNAMIC,    .core = OS_CORE_ANY,       /* 不固定到某个核心 */    .priority = 3,    .stack_size = 4096,    .name = ”heartbeat”};int rc = os_thread_create(&thread, heartbeat, NULL, &attr);if (rc != 0) {    /* 创建或管理登记失败,不能继续使用这个输出句柄 */}

上层没有出现 TaskHandle_t,也没有直接调用 xTaskCreate()。

实际路径还多了一步:先由平台包装创建任务,再由任务管理层登记线程;登记失败,会尝试删除刚创建的任务。

创建出的任务可能在接口返回前就开始运行,所以参数与共享资源必须提前准备好。

平台包装内部再决定:动态还是自动管理缓冲区的静态任务、固定核心还是不固定核心。下面只展示动态任务的创建分支:

/* 适配层关键分支,省略资源分配和失败清理。 */if (core_is_pinned(core)) {    result = xTaskCreatePinnedToCore(        entry_bridge, name, stack_depth, context,        priority, &native_task, core);} else {    result = xTaskCreate(        entry_bridge, name, stack_depth, context,        priority, &native_task);}

entry_bridge 是入口桥接函数:取出先前保存的用户函数和参数,再调用它。

上面的心跳线程长期运行,退出与回收不在这个示例中展开。

二、毫秒怎么变成系统能理解的时间?

上层说“等 100 毫秒”,FreeRTOS 使用的通常是 tick,也就是系统计时节拍。

统一时间单位,才能避免上层自己到处换算。

线程休眠中的关键逻辑是:

/* 线程休眠实现节选,名称通用化,省略日志。 */if (milliseconds == 0) {    taskYIELD();              /* 让出执行机会,不进入定时休眠 */    return 0;}TickType_t ticks = pdMS_TO_TICKS(milliseconds);if (ticks == 0) {    ticks = 1;                /* 非零休眠,换算后至少取 1 tick */}vTaskDelay(ticks);return 0;

但锁和信号量的等待不是照抄这段代码:它们直接换算普通的毫秒值,没有上述“至少 1 tick”分支。

因此,同样写 1 毫秒,在较粗的系统节拍下,不同接口的实际行为可能不同。

心跳示例也不是精确的每秒调度:输出耗时和任务调度都会影响间隔。

三、互斥锁:保护一份共享数据

互斥锁解决的是:同一时刻,只有一个线程进入这段受保护的操作。🔒

比如多个线程都要增加计数。下面假设先调用初始化函数,再允许其他线程访问;使用期间不删除锁。

static os_mutex_handle_t counter_lock = NULL;static uint32_t counter;intcounter_init(void){    return os_mutex_create(&counter_lock);}intcounter_add_one(void){    int rc = os_mutex_wait(counter_lock, 100);    if (rc != 0) {        return rc;            /* 没拿到锁,不修改计数 */    }    ++counter;    return os_mutex_release(counter_lock);}

所有读写这份计数的线程,都要遵守同一把锁的规则。只给写入加锁、读取却随意访问,并没有完整保护共享数据。

适配层负责把锁操作翻译成 FreeRTOS 接口:

/* 创建时调用:xSemaphoreCreateMutex()。 */if (xSemaphoreTake(native_mutex, timeout_ticks) != pdTRUE) {    return ERR_TIMEOUT;}return STATUS_OK;/* 释放则通过 xSemaphoreGive(native_mutex) 完成。 */

等待参数还有三种约定:

传入值
适配方式
0
不等待,尝试获取
普通毫秒值
用 pdMS_TO_TICKS() 换算
UINT32_MAX
转成 portMAX_DELAY,按内核配置解释长时间等待

注意示例中:如果加一成功、但释放锁失败,返回的仍是失败状态。此时不能盲目重试整个加一操作,否则可能重复计数。

四、信号量:不是抢位置,而是等通知

互斥锁强调“谁正在使用资源”;这里的计数信号量强调“还有几份可领取的通知”。

初始计数为 0,发送一次增加一份,成功等待一次取走一份。

static os_sem_handle_t ready = NULL;intnotification_init(void){    return os_sem_create(&ready, 0);}/* 由普通线程调用:发送一份通知。 */intnotify_once(void){    return os_sem_post(ready);}/* 由普通线程调用:等待并领取一份通知。 */intreceive_once(void){    int rc = os_sem_wait(ready, 1000);    if (rc == 0) {        puts(”收到一份通知”);    }    return rc;}

在通知初始化成功、对象保持有效的前提下调用。这里的 post 是“增加计数”,不是给每个等待者都广播一次。

内部对应:

/* 创建;当前计数上限为 65535。 */native_sem = xSemaphoreCreateCounting(65535, initial_count);/* 发送与领取,分别调用: */xSemaphoreGive(native_sem);xSemaphoreTake(native_sem, timeout_ticks);

发送太多、计数达到上限时,继续发送会失败。

信号量本身也不携带消息内容;要传任务编号或数据包,还需要另外的数据结构。

这些包装使用普通的 Take/Give,不能因为名字通用,就直接拿到中断里调用。

五、定时器:到点以后,回调到底在哪执行?

软件定时器负责“到时间后发起处理”,不是给每个定时器创建一条独立线程。

这里可以传入一个任务执行环境,作为回调的投递目标:

沿用前面的信号量,做一次“200 毫秒后发送通知”:

staticvoid *timer_notify(void *arg){    (void)arg;    if (os_sem_post(ready) != 0) {        /* 通知未送达,应记录失败,不能当作发送成功。 */    }    return NULL;}os_timer_handle_t timer = NULL;os_timer_attr_t attr = {    .type = OS_TIMER_ONCE,    .name = ”notify_later”,    .param = NULL,    .cb = timer_notify};int rc = os_timer_create(&timer, worker, &attr);if (rc == 0) {    rc = os_timer_start(timer, 200);}

假设信号量已初始化,worker 是已准备好的执行环境,两者在定时器及回调使用期间保持有效。

单次定时器触发后也不会自动删除对象,后续仍需安排回收。

具体适配有三步:

  1. 用 xTimerCreate() 创建 FreeRTOS 软件定时器。
  2. 启动时,把毫秒换成 tick,非零间隔至少取 1 tick。
  3. 到点后,尝试把用户回调作为任务提交给指定执行环境。

如果没有指定执行环境,或者任务投递失败,包装会直接在 FreeRTOS 定时器服务任务中调用用户回调。

因此,不能假定“回调永远在 worker 线程里”。示例只发送通知,实际回调也应保持短小,避免长时间等待。

启动返回成功,不代表回调已经执行;停止或删除定时器,也不能直接推断之前投递出去的回调任务已被撤回。

六、接口统一了,不代表差异消失了

上层可以少改,是因为系统调用被集中到了适配层;不是换系统时,只换一个函数名就万事大吉。

还有几件事必须继续核对:

项目
仍然存在的边界
栈大小
创建包装把配置值除以 sizeof(StackType_t) 后传给底层;必须核对实际 SDK 的参数单位,不能只凭变量名判断
优先级与核心
优先级会按内核上限处理,核心选择也受硬件约束;同一数值不保证跨系统有相同调度效果
等待失败
锁等待失败映射为超时,信号量等待失败却用了另一套数值组合,错误语义没有完全对齐
取消与退出
可见的线程取消点实现为空;有接口,不等于已经具备完整取消和清理能力

公共线程头文件中也仍然有与 FreeRTOS 栈和任务状态相关的配置。抽象层已经建立,但平台细节并没有全部藏干净。

小结:把变化留在下面,把规则讲给上面

线程、锁、信号量和定时器,统一的不只是名称,还包括参数单位、返回状态和调用方式。

真正值得检查的是:时间怎么换算,回调在哪执行,失败怎样返回,资源何时释放。

这些规则对齐了,上层才有机会少改一点。🙂

下一篇:内存不只是一句malloc。内部 RAM、外部 RAM 都能分配,为什么还要统一入口,又该由谁负责释放?

回首页,搜索你想要的文档

在首页搜索框输入关键词,快速定位资料

文库首页搜索指引 立即搜索

相关学习资料