2026/9/14 13:15:17

Nucleus RTOS实战:任务调度、信号量与内存分区排查手册

Nucleus RTOS实战:任务调度、信号量与内存分区排查手册 简介Nucleus RTOS 2004年9月5日版本的源代码包面向嵌入式开发者与RTOS学习者旨在通过源码级研读理解实时操作系统的内核调度、内存管理、中断处理等核心机制并支撑后续的定制化移植与性能优化。包内共247个文件以80个C源文件和58个头文件为主体涵盖任务管理、定时器、信号量等核心模块另有少量汇编启动文件与链接脚本、makefile负责底层硬件适配与构建配置整体仅211KB结构紧凑尤其适合在资源受限环境中进行源码分析。目前已有222人学习下载属于经典嵌入式源码资源。通过研读这份源码可以掌握Nucleus微内核架构下抢占式调度器的实现思路、模块间接口设计以及设备驱动框架同时借助内容预览中的底层启动代码与CPU相关文件理解系统从加电引导到RTOS初始化的完整流程为学习其他RTOS和开展真实嵌入式项目开发提供扎实的参考基础。1. 从 nucleus-2004-09-05 的遗留设计说起这个 RTOS 为何仍值得读把 nucleus-2004-09-05 这个构建串到今天的开发环境里它怕的不是老而是没有人能说清“这个版本到底会怎么调度”。Nucleus 是 Accelerated Technology 推出的商业实时多任务内核后来的 Nucleus PLUS 分支以极小内核、低上下文切换开销和可裁剪组件集被广泛用在手机基带、车载控制和工业采集板上。它的任务控制块TCB、优先级调度和信号量机制十几年间变化很小2004 年版本的代码放到今天的固件工程里照样能编译、能烧录、能稳定跑起来。对于正在维护这类遗留项目的工程师把 NU_Create_Task 到 NU_Release_Semaphore 的每个参数吃准比追逐新 RTOS 更有复用价值。下文按调度、原语、资源预算、启动排错四层给出一份可直接落地的 Nucleus 使用与排查手册。2. Nucleus 任务调度与 TCB 剖析NU_Create_Task 参数与优先级位图2.1 创建任务的最小代码与 NU_Create_Task 参数表Nucleus 里的任务不是一个“动态 new 出来的线程”而是一段静态定义的 TCB 加一块独立栈。工程里一般把 NU_TASK 变量和栈缓冲写成全局数组任务表在编译期就确定运行期不需要向堆申请这样 TCB 表和栈不会产生碎片问题也方便用链接脚本控制内存布局。下面这段代码创建两个任务一个负责串口收数一个负责传感器处理。注意代码里的注释标记了每个参数的作用。#include nucleus.h #define G_STK_SIZE 1024 #define G_STK_ITEM (G_STK_SIZE / sizeof(UNSIGNED)) static UNSIGNED stk_uart[G_STK_ITEM]; static UNSIGNED stk_sensor[G_STK_ITEM]; static NU_TASK tsk_uart; static NU_TASK tsk_sensor; static void uart_entry(UNSIGNED arg) { for (;;) { /* 处理串口接收缓冲解析协议帧 */ NU_Resume_Task(tsk_sensor); /* 唤醒处理任务 */ NU_Suspend_Task(tsk_uart); /* 自己挂起等下一个中断 */ } } static void sensor_entry(UNSIGNED arg) { for (;;) { NU_Suspend_Task(tsk_sensor); /* 被唤醒后读取传感器并缓存结果 */ } } void app_init(void) { NU_Create_Task(tsk_uart, uart_task, uart_entry, 3, stk_uart, sizeof(stk_uart), 0, NU_PREEMPT, NU_START); NU_Create_Task(tsk_sensor, sensor_task, sensor_entry, 6, stk_sensor, sizeof(stk_sensor), 2, NU_PREEMPT, NU_NO_START); }这段代码里最容易被忽视的是栈大小参数。Nucleus 的多数移植版本规定栈地址按 4 字节对齐参数传递的是字节数而不是数组元素个数因此这里传sizeof(stk_uart)而不是G_STK_ITEM。如果底层移植包要求双字对齐还可以把stk_uart定义成UNSIGNED数组来保证对齐这也是上面写法沿用UNSIGNED类型的原因。NU_Create_Task 的参数需要逐个确认行为差别很大参数取值行为影响工程建议prio0~255 数值数值越小优先级越高同一优先级按照创建顺序和 tick 轮转关键链路用 3~8普通业务用 10~20别留 0 和 1 给极端情况time_slice0 或 10 表示不参与时间片轮转只有被更高优先级抢占才让出 CPU实时任务给 0平级批量任务给 1~4preemptNU_PREEMPT / NU_NO_PREEMPTNO_PREEMPT 任务不会主动被高优先级抢占没有特殊需求一律用 NU_PREEMPTauto_startNU_START / NU_NO_STARTNU_START 创建后立即进就绪队列NO_START 需要再调 NU_Start_Task依赖初始化顺序时用 NU_NO_START把启动权交给业务代码如果多个任务需要按顺序启动常见的做法是创建时都带NU_NO_START在初始化数据完成后统一调NU_Start_Task。这样可以避免任务在硬件外设尚未就绪时就开始跑也便于排查启动阶段的崩溃。2.2 就绪态的紧凑表达优先级位图与调度器选择逻辑Nucleus 的调度器不需要遍历整个任务表来寻找下一个运行者。每一个优先级对应一个二进制位该优先级上有任务就绪时对应位被置 1。调度器只要从最高位往下找第一个为 1 的位就知道应该运行哪个优先级的任务。这种设计把「下一个运行谁」的复杂度压缩到几次移位和判断在 2004 年前后主频几百兆赫兹的 ARM 平台上优势非常明显。用 C 语言模拟这个位图搜索逻辑可以看到为什么它比链表遍历更适合硬实时场景static volatile UNSIGNED ready_map; /* bit i 表示优先级 i 上有就绪任务 */ #define MAX_PRIO 32 static unsigned find_highest_ready(void) { unsigned prio 0; unsigned probe ready_map; while ((probe 1u) 0u) { prio; probe 1u; } return prio; }这个循环在最坏情况下要移 32 次但对于常见的 32 优先级系统实际优先级分布往往集中在前 10 位。如果芯片支持 CLZ数前导零指令还可以直接CLZ(ready_map)拿到最高优先级连循环都省掉。我在实际项目中见过有人在调试器里反复单步这个搜索函数最终把时间开销从几十微秒压到几个微秒。任务的状态转换也围绕就绪位图展开。运行中的任务调用NU_Suspend_Task、等待信号量或等待队列数据时调度器会把它从就绪集合移出等到超时、被NU_Resume_Task唤醒或事件满足条件再重新置回就绪位。所有同步原语最终都会落到「改某个任务的状态位 重新计算就绪位图」这两个动作上理解了这一点调试诡异的任务饿死问题就有一个固定的入手方向。2.3 状态机怎么演READY、SUSPEND、TERMINATED 的判定口诀任务调度的常见误判是把「运行慢」当成「被阻塞」。Nucleus 的任务状态可以简化成三句话来记处于 READY 集合中的任务只可能因为优先级低于当前运行任务而在等待 CPU。任务一旦调用带挂起的原语比如NU_Obtain_Semaphore(..., NU_SUSPEND)且资源不可得就进入 SUSPEND调度器不会再给它分配 CPU。被NU_Terminate_Task终止的任务进入 TERMINATED只有硬件复位能把它拉回。排错时最直接的手段是在任务入口第一行打印或点亮示波器引脚然后看任务是否真的被调度进来了。如果任务卡在某个资源上日志会停在等待原语附近如果任务是压根没被创建日志第一行就不会出现。曾经有现场现象是某任务每隔几秒钟才运行一次后来定位到它在等一个 1000 tick 的超时信号量超时到了才返回本质上不是死锁而是超时参数设得过大。3. Nucleus 的信号量、事件标志和队列三类原语与 ISR 调用边界3.1 计数信号量与互斥锁NU_Create_Semaphore 和 NU_Obtain_Mutex 的适用差异Nucleus 的信号量与互斥锁表面上都能做「取锁 / 放锁」但语义完全不同。信号量维护一个计数值每次NU_Obtain_Semaphore使计数减 1每次NU_Release_Semaphore使计数加 1它不关心是谁释放的也不提供优先级继承。互斥锁则绑定持有者NU_Obtain_Mutex成功之后只有同一个任务能释放并带有优先级继承机制防止低优先级任务持有锁时被更高优先级反转。选错原语的后果很典型用二进制信号量保护共享总线低优先级任务持有信号量后被中断打断高优先级任务开始等同一把锁而低优先级任务迟迟得不到 CPU就出现了优先级反转。互斥锁的优先级继承会在这一瞬间把低优先级任务临时提升到高优先级的等级让它可以快速释放锁。下面用 I2C 总线访问为例展示二值信号量保护共享资源的写法static NU_SEMAPHORE sem_i2c; static void i2c_periph_init(void) { /* 初始计数 1表示总线空闲 */ NU_Create_Semaphore(sem_i2c, i2c_bus, 1, NU_FIFO); } static int i2c_read_reg(unsigned int dev, unsigned int reg, unsigned char *out) { if (NU_Obtain_Semaphore(sem_i2c, NU_SUSPEND) ! NU_SUCCESS) { return -1; /* 挂起超时或中断返回 */ } /* 拉低片选发送寄存器地址读取数据 */ *out 0x00; NU_Release_Semaphore(sem_i2c); return 0; }这段代码里我刻意选了NU_SUSPEND作为挂起方式让任务在总线被占时阻塞等待。挂在中断上下文里的函数绝不能使用NU_SUSPEND来等待信号量因为中断没有任务上下文可以挂起那会让内核直接崩溃。ISR 中能做的只有释放操作比如收完一帧数据后NU_Release_Semaphore唤醒接收线程。信号量和互斥锁的直观对比可以按场景记住场景推荐原语原因保护共享 I2C/SPI 总线互斥锁或二值信号量排他访问互斥锁额外提供优先级继承记录硬件中断完成次数计数信号量每次中断释放一次任务可取累加值多任务读写同一块数据缓冲互斥锁防止写入中途被另一个任务读到半成品3.2 一个事件标志组让多个完成条件同时到达NU_Set_Events 与 NU_Retrieve_Events事件标志适合解决「等好几个条件都满足才继续」的场景。一个事件标志组里可以放 8 位或 16 位独立的标志位任务调用NU_Retrieve_Events时可以指定按NU_AND还是NU_OR来组合等待。典型用法是 DMA 传输和 ADC 采样同时完成后再汇总数据。任务侧代码如下static NU_EVENT_GROUP ev_phy; #define EV_ADC_DONE 0x01u #define EV_DMA_DONE 0x02u #define EV_TIMEOUT 0x04u static void phy_collect_entry(UNSIGNED arg) { UNSIGNED got 0; for (;;) { /* 等待任一事件用 OR 模式 */ NU_Retrieve_Events(ev_phy, EV_ADC_DONE | EV_TIMEOUT, NU_OR, got, NU_SUSPEND); if ((got EV_TIMEOUT) ! 0u) { /* 超时则重启动采集链路 */ continue; } /* ADC 完成开始 DMA 搬运 */ } }中断服务程序里只需要调用NU_Set_Events(ev_phy, EV_DMA_DONE, NU_OR)然后马上返回。注意NU_AND模式表示所有被请求的位都必须为 1 才唤醒所以任务如果同时等待两个事件必须用NU_AND才能把两个完成信号合在一个判断点。ISR 里设置事件标志是安全的因为NU_Set_Events不会阻塞它只更新事件组的状态并可能把符合条件的任务从挂起队列移到就绪位图。3.3 消息队列做成生产消费通道NU_Send_To_Queue 与 NU_Receive_From_QueueNucleus 的消息队列要求每条消息长度固定创建时就把缓冲区、消息大小、队列深度一次讲清楚。这样做的好处是没有链表节点开销消息在环形缓冲区里连续排列接收方拿到的是一块固定长度的内存拷贝。一个生产消费的队列配置如下#define MSG_SIZE 4 #define MSG_DEPTH 16 static UNSIGNED q_area[MSG_DEPTH * MSG_SIZE / sizeof(UNSIGNED)]; static NU_QUEUE q_tele; static void tele_queue_init(void) { NU_Create_Queue(q_tele, tele_q, q_area, MSG_SIZE, MSG_DEPTH, NU_FIFO, NU_SUSPEND); } /* 发送端 */ static void tele_send(unsigned int code) { if (NU_Send_To_Queue(q_tele, code, sizeof(code), NU_NO_SUSPEND) ! NU_SUCCESS) { /* 队列满丢弃旧帧或计数告警 */ } } /* 接收端 */ static void tele_recv(void) { unsigned int val 0, size 0; if (NU_Receive_From_Queue(q_tele, val, size, NU_SUSPEND) NU_SUCCESS) { /* 处理收到的 4 字节消息 */ } }创建队列时最后一个NU_SUSPEND决定任务在队列空时是否挂起等待。发送端在任务上下文可以用NU_SUSPEND但如果队列满任务会阻塞ISR 里发送消息只能用NU_NO_SUSPEND返回NU_QUEUE_FULL时直接丢弃或置溢出标志位。队列深度的预算公式是消息大小乘以深度等于缓冲区总字节数加上对齐字节一般按 4 字节对齐后向上取整。4. Nucleus 定时器、tick 与内存分区把 OS 资源算成固定预算4.1 软定时器的三种形态一次性、周期、余量查询Nucleus 的定时器依赖系统 tick 计数每个 tick 到来时内核检查定时器链表。定时器回调函数在中断上下文被调用因此回调里不能调用任何会挂起的 API常见做法是回调里只设置标记位或释放信号量把实际业务放到任务中处理。创建一个周期定时器通常这样写static NU_TIMER tmr_wdg; static void wdg_expire(UNSIGNED arg) { /* 中断上下文动作要短清看门狗或记录溢出计数 */ NU_Control_Timer(tmr_wdg, NU_ENABLE_TIMER); } static void wdg_setup(void) { NU_Create_Timer(tmr_wdg, wdg, wdg_expire, 1, 10, /* 首次 10 tick 后触发 */ 10, /* 之后每 10 tick 触发一次 */ NU_ENABLE_TIMER); }NU_Create_Timer的第二个 tick 参数如果设为 0定时器就是一次性触发如果设成和首次触发相同的正整数就是周期定时器。运行中通过NU_Control_Timer可以禁用、启用定时器NU_Remainder_Timer可以查询当前还剩多少 tick 才到触发点。定时器相关函数与调用位置可以对照记忆函数作用可在 ISR 调用NU_Create_Timer创建定时器并登记回调否NU_Control_Timer启用/禁用定时器是NU_Remainder_Timer返回剩余 tick 数是NU_Delete_Timer删除定时器否调试时最容易踩的坑是在定时器回调里打印日志。串口打印本身可能阻塞把它放在 tick 中断上下文里会拖长中断时间导致更高优先级任务被误判为超时。我一般把回调收敛成一条赋值语句具体日志放到一个专门的高优先级任务里去消化。4.2 固定长度内存分区NU_Create_Partition 与 NU_Allocate_Memory 的配对原则Nucleus 提供分区内存和可变内存池两种分配方式。分区内存把一整块区域切成固定大小的块分配和释放都不产生碎片代价是只能满足大小单一的对象。适合给硬件描述符、协议帧头和固定长度的收发缓冲使用。下面的例子为 DMA 描述符分配一块固定内存#define DMA_DESC_SIZE 64 #define DMA_DESC_NUM 16 static UNSIGNED dma_pool_area[DMA_DESC_SIZE * DMA_DESC_NUM / sizeof(UNSIGNED)]; static NU_PARTITION dma_part; static void dma_part_setup(void) { NU_Create_Partition(dma_part, dma_desc, dma_pool_area, DMA_DESC_SIZE, DMA_DESC_NUM, NU_FIFO); } static void *dma_desc_alloc(void) { void *mem NULL; if (NU_Allocate_Memory(dma_part, mem, NU_SUSPEND) ! NU_SUCCESS) { return NULL; } return mem; }NU_Create_Partition的块大小参数必须包含对齐尾巴AREA 总大小要能整除块大小。分配成功后返回的内存地址天然对齐不需要额外做挤位。当一部分配尽时任务会挂起所以分配函数挂在 ISR 里时也要传NU_NO_SUSPEND并且预先准备好失败路径。可变内存池适合申请大小差异很大的对象但要面对碎片问题。旧工程里常见的内存泄漏来源是驱动在每次传输时分配可变池内存完成后忘记释放。排查时打开内核调试宏记录当前可用字节数连续跑一晚上看曲线是否单调下降比肉眼查代码更快定位。4.3 裁剪与预算nucleus_conf.h 里的资源表怎么调Nucleus 通过配置头文件裁剪内核组件并预分配系统资源。需要重点关注的配置项大致有五类配置项类别典型限制超限表现NU_TASK_NUM任务 TCB 数组元素个数创建任务返回 NU_INVALID_TCBNU_TIMER_NUM定时器控制块数量创建定时器失败NU_QUEUE_NUM队列控制块数量队列创建失败NU_MEMORY_POOL_NUM分区/内存池数量内存池初始化失败NU_ISR_COUNT可注册的中断服务例程数量NU_Register_ISR 返回错误我一般先把所有任务、定时器、队列、信号量统计成一张表再在配置里预留 20% 余量。比如业务代码用 8 个任务配置值就填 10定时器用了 5 个配置值填 7。配置过小会导致运行时创建原语静默失败返回错误码又没有打印系统看起来像「任务没跑」实际是创建失败了。把配置值和实际业务对象数量对齐,是稳定性的第一道防线。5. Nucleus 启动序列与排错三招从复位向量到栈水位5.1 启动链必须按顺序走硬件初始化、内核初始化、业务任务Nucleus 工程启动时板级启动代码做的工作顺序是固定的先初始化 CPU 时钟、外部存储器和串口等基础外设再初始化内核内部对象链表最后创建业务任务。业务代码不要在硬件初始化之前调用任何内核 API否则对象链表还是空指针结果是无法预测的。典型的启动伪代码可以写成这样void board_startup(void) { /* 第一步时钟、内存、关键外设 */ early_hw_init(); /* 第二步关闭中断保证后续创建过程不被打断 */ cli(); /* 第三步建立任务、信号量、队列、定时器 */ app_init(); /* 第四步恢复中断把控制权交给调度器 */ sei(); }不同的 BSP 移植包对最后一步的函数命名不同核心是「先建对象、再开调度」这个顺序不能颠倒。如果业务任务在创建初期就依赖另一个任务发来的消息最好让接收任务带NU_NO_START等发送方初始化完成后再NU_Start_Task不然会出现首帧消息丢失。5.2 用 NU_Remainder_Timer 验证调度余量周期任务抖动排查排查周期任务抖动时一个可复用的办法是单独建一个监控任务在每次周期触发后立刻读取软件定时器的剩余时间把剩余时间的波动范围记录下来。如果剩余值忽大忽小说明有更高优先级任务在抢占这个定时器的回调或者该回调内部耗时过长。把监控任务的优先级设为最高检查结果才可信。这个方法比示波器量 GPIO 电平更精确因为 GPIO 翻转受中断延迟影响而NU_Remainder_Timer拿到的直接是内核计时值。5.3 栈水位哨兵法一行魔数找出最深调用栈栈溢出的现场往往表现为随机崩溃难以稳定复现。常见的做法是在每个任务启动时把整块栈填满魔数 0xA5A5A5A5运行一段时间后从栈顶向栈底扫描找到最后一个不再是魔数的位置余量就是当前水位最深水位则记录在监控变量里#define STACK_MAGIC 0xA5A5A5A5u void stack_mark_all(void) { UNSIGNED i; for (i 0; i G_STK_ITEM; i) { stk_uart[i] STACK_MAGIC; stk_sensor[i] STACK_MAGIC; } } unsigned int stack_watermark(UNSIGNED *stack_top, unsigned int len) { unsigned int depth 0; while (depth len stack_top[depth] STACK_MAGIC) { depth; } return len - depth; /* 已被使用的栈深度 */ }把这个函数放到低优先级监控任务里周期调用取两次调用的最大值保存下来。当发现最深水位超过栈大小的 80% 时就该扩大该任务对应的栈数组或者在调用链里减少大数组局部变量。对新加入的深递归代码用这个方法做回归测试能比崩溃日志提前几周暴露问题。本文还有配套的精品资源点击获取