简介: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 的参数需要逐个确认,行为差别很大:
| 参数 | 取值 | 行为影响 | 工程建议 |
|---|---|---|---|
| prio | 0~255 数值 | 数值越小优先级越高,同一优先级按照创建顺序和 tick 轮转 | 关键链路用 3~8,普通业务用 10~20,别留 0 和 1 给极端情况 |
| time_slice | 0 或 >=1 | 0 表示不参与时间片轮转,只有被更高优先级抢占才让出 CPU | 实时任务给 0,平级批量任务给 1~4 |
| preempt | NU_PREEMPT / NU_NO_PREEMPT | NO_PREEMPT 任务不会主动被高优先级抢占 | 没有特殊需求一律用 NU_PREEMPT |
| auto_start | NU_START / NU_NO_START | NU_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_Queue
Nucleus 的消息队列要求每条消息长度固定,创建时就把缓冲区、消息大小、队列深度一次讲清楚。这样做的好处是没有链表节点开销,消息在环形缓冲区里连续排列,接收方拿到的是一块固定长度的内存拷贝。
一个生产消费的队列配置如下:
#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_TCB |
| NU_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% 时,就该扩大该任务对应的栈数组,或者在调用链里减少大数组局部变量。对新加入的深递归代码,用这个方法做回归测试,能比崩溃日志提前几周暴露问题。
本文还有配套的精品资源,点击获取