从零剖析YRTOS:多任务RTOS调度内核的实现与调试
简介压缩包内为一份基于多任务RTOS的嵌入式开发示例工程定位面向单片机/嵌入式学习者适合用来理解任务调度、并发执行与工程构建流程。整个包共29个文件以C源文件、头文件、Makefile及工程配置文件为核心并包含3组编译生成的hex、elf、map、lss和eep文件elf与map可协助分析代码段、数据段和内存布局hex文件可直接烧录验证整体约143KB属于可直接运行的小型工程。从内容预览可看出工程采用task.c、main.c、task.h的模块划分结构清晰便于按需求裁剪或扩展任务。已有23918人次浏览学习具备一定参考热度。借助这份工程读者既能查看RTOS任务的创建与切换实现又能结合map文件理解内存分配还能在现有任务基础上修改实验作为RTOS入门、课程设计或自学参考的起步模板都非常合适。1. 一个名字充满歧义的压缩包装着一套可运行的多任务RTOS打开压缩包的时候我差点以为解压错了文件没有文档、没有注释只有 task.c、task.h、main.c外加一堆.project、.cproject和Release目录。可正是这几个 C 文件把一个多任务实时操作系统最核心的调度骨架讲清楚了。这个叫 YRTOS 的小内核不依赖具体开发板在 Windows/Linux 上用 Eclipse CDT 加 GCC 就能编译运行。它没有 FreeRTOS 那套 heap 管理复杂度也没有 Linux 的模块化调度而是用链表加线程上下文切换把任务创建、优先级队列、时间片轮转完整地串起来。如果你是第一次接触 RTOS想搞明白任务到底是怎么“同时”跑的或者想在一个可断点调试的 IDE 工程里验证调度逻辑这份代码足够拆一天。2. 调度器的地基task.h 里的 TCB 与就绪队列多任务内核本质上就两件事描述任务的元数据结构以及决定下一个该跑谁的策略。YRTOS 把这两件事都压在 task.c 和 task.h 里结构非常直白适合顺着源码追。2.1 TCB存放任务全部现场的数据结构打开 task.h最先看到的是任务状态枚举和任务控制块YRTOS 给每个任务分配一个结构体实例我把它简化成下面这样typedef enum { YT_READY, YT_RUNNING, YT_WAITING, YT_SUSPENDED } yt_state_t; typedef struct yt_tcb { char name[YT_NAME_LEN]; void (*entry)(void *arg); /* 任务入口函数 */ void *arg; /* 传给入口函数的参数 */ unsigned char *stack_base; /* 栈底指针 */ size_t stack_size; /* 栈大小 */ jmp_buf ctx; /* 保存 CPU 寄存器现场 */ uint8_t priority; /* 0 最高数字越大优先级越低 */ yt_state_t state; /* 当前任务状态 */ struct yt_tcb *next; /* 就绪队列链表指针 */ } yt_tcb;每个字段的用途如下表字段作用选型理由entry任务的入口函数地址调度器切换后跳转到这个地址执行stack_base/stack_size任务独立栈的位置和大小采用外部指定而不是内部固定数组方便在栈上打水位标记ctx保存任务被切出时的寄存器现场用标准库jmp_buf保存不依赖架构汇编PC 上直接可跑priority优先级数值数值小优先遍历就绪数组时可以快速定位next指向下一个同优先级任务组建成就绪链表实现时间片轮转我在实际项目里最关心的其实是stack_base和stack_size。很多小型 RTOS 喜欢在 TCB 内嵌固定数组uint32_t stack[256]这会导致无法在运行时检测栈溢出。YRTOS 把栈地址单独提出来后面第 5 章我会专门用来做栈深度测量。2.2 创建任务把入口、栈和优先级塞进队列任务创建的完整流程是分配 TCB、分配栈、绑定入口函数、设置优先级、把状态置为YT_READY、插入就绪队列。下面是 task.c 里最常见的实现方式yt_tcb *yt_task_create(const char *name, void (*entry)(void *), void *arg, uint8_t prio, uint32_t stack_size) { yt_tcb *tcb (yt_tcb *)malloc(sizeof(yt_tcb)); unsigned char *stack (unsigned char *)malloc(stack_size); if (tcb NULL || stack NULL) { free(tcb); free(stack); return NULL; } memset(tcb, 0, sizeof(yt_tcb)); strncpy(tcb-name, name, YT_NAME_LEN - 1); tcb-entry entry; tcb-arg arg; tcb-priority prio; tcb-stack_base stack; tcb-stack_size stack_size; tcb-state YT_READY; yt_ready_insert(tcb); return tcb; }代码逻辑分三段先分配内存再逐字段初始化最后把任务插入就绪队列。参数prio是任务优先级的唯一入口调用方需要保证它在0 ~ 31之间否则调度时会数组越界。stack_size的最小值也有讲究如果小于 256 字节ucontext 切换时可能直接踩到相邻内存我一般会加一层校验if (stack_size YT_MIN_STACK) { stack_size YT_MIN_STACK; }这里用malloc的原因是为了让这份代码在 PC 模拟环境下不用预先定义一个大数组。如果要移植到单片机通常把这两处malloc替换成静态内存池TCB 池和栈池分开管理这个替换逻辑不复杂但能避免堆碎片问题。2.3 就绪队列与调度选择最高优先级先跑YRTOS 的就绪队列没有用二级链表跳表而是直接分成 32 条链表每条代表一个优先级#define YT_MAX_PRIO 32 static yt_tcb *ready_queue[YT_MAX_PRIO];插入时把任务挂到对应优先级的链表尾部static void yt_ready_insert(yt_tcb *tcb) { yt_tcb **head ready_queue[tcb-priority]; while (*head ! NULL) { head (*head)-next; } *head tcb; }调度器取任务时从优先级 0 开始找第一个非空链表摘掉头结点返回static yt_tcb *yt_next_task(void) { for (int prio 0; prio YT_MAX_PRIO; prio) { yt_tcb *head ready_queue[prio]; if (head ! NULL) { ready_queue[prio] head-next; head-next NULL; return head; } } return NULL; /* 没有就绪任务 */ }调度策略是“优先级驱动 同级轮转”高优先级队列只要非空就不会轮到低优先级任务同优先级任务按链表顺序循环切换。这种设计有一个内在风险就是高优先级任务如果占用 CPU 不释放低优先级任务会被饿死。所以 YRTOS 在任务切换上会在合适位置调用yt_task_yield()把当前任务放到所属优先级链表尾部再让出 CPU 给同优先级下一个任务。这套队列结构比 FreeRTOS 的可读性好缺点是没有把优先级反转和开销优化考虑进去但作为教学和快速原型完全够用。在下一章里我们会把这份源码放进 Eclipse CDT 工程让它真正跑起来。3. 在 Eclipse CDT 里把工程跑起来.cproject、Release 与 GCC 的配合源码包里的.project、.cproject和.settings是 Eclipse CDT 的工程描述文件不是编译产物。理解这几个文件的角色才能在导入后快速定位配置问题。3.1 工程文件到底做了什么用文本编辑器打开.project和.cproject可以看到它们本质上是 XML 配置。它们的区别如下文件/目录作用是否参与编译.projectEclipse 通用项目定义声明项目名、构建器类型否.cprojectCDT 核心配置记录工具链、编译参数、输出目录否.settings语言分析器、索引器选项否Release/增量生成的目标文件和最终可执行程序由构建生成main.c、task.c实际参与编译的 C 源码是.cproject里最重要的信息是“当前激活的构建配置”。示例里只看到Release目录说明文件保留了 release 配置的中间文件。这套配置通常指定gcc作为编译器并在Release/subdir.mk里维护每个源文件的依赖规则。3.2 导入现有工程并切换构建配置在 Eclipse 中导入时不要直接New C Project否则工程元信息会对不上。按下面步骤操作File - Import - C/C - Existing Projects into Workspace在弹出的对话框中“Select root directory” 选择解压出来的 YRTOS 目录确认项目出现在列表里后勾选点Finish右键项目选择Properties - C/C Build在设置里把Configuration切换为Release切换完成后检查Tool Settings中编译器命令。对于 PC 模拟环境我一般会看到这样的核心参数编译选项作用-O2开启优化避免太多无意义临时变量影响栈观察-g生成调试信息方便单步跟踪任务切换点-Wall开启所有警告建议配合-Werror使用-D_XOPEN_SOURCE解决 ucontext/setitimer 在部分环境下隐式声明的问题这里有一个坑如果按原工程直接编译可能报implicit declaration of function setitimer。这不是代码逻辑错而是_POSIX_C_SOURCE宏没有打开。最常见的做法是在.cproject的Command里加上-D_DEFAULT_SOURCE或者直接在task.c顶部加上#ifndef _DEFAULT_SOURCE #define _DEFAULT_SOURCE #endif3.3 Release 模式下的命令行构建如果你不喜欢 IDE 界面也可以直接在终端构建因为 Eclipse CDT 会在 Release 目录下生成 Makefilecd YRTOS make -C Release -j4这条命令的作用是进入 Release 目录读取 Eclipse 生成的Makefile并调用subdir.mk里的规则编译main.c和task.c。-j4表示最多用 4 个并行任务编译在源码只有两三个文件时优势不明显但能让你习惯大型项目的构建方式。编译通过后Release 目录下会出现YRTOS可执行文件。直接运行它你会看到任务调度的输出如果跑到这里没有反应优先检查第 3.2 节里的编译器选项是否生效尤其是宏定义。工程能跑通之后我们可以进入实战环节看看怎么用这份内核代码实现具体的多任务逻辑。4. 多任务实战创建任务、设置时间片与安全共享数据工程搭建完成后接下来需要在 main.c 里设计任务。很多初学者会把所有逻辑写在一个 while 死循环里这不算多任务多任务的关键是每个任务都有自己的入口、栈和调度状态。4.1 最小启动代码两个任务交替打印我用一个最简单的例子创建两个不同优先级的任务#include task.h #include stdio.h void task_led(void *arg) { while (1) { printf([%s] LED ON\n, (char *)arg); yt_task_delay(100); } } void task_log(void *arg) { while (1) { printf([%s] print alive\n, (char *)arg); yt_task_delay(200); } } int main(void) { yt_init(); yt_task_create(led, task_led, T1, 2, 1024); yt_task_create(log, task_log, T2, 1, 1024); yt_start(); return 0; }yt_task_create的四个参数都值得说明第一个是任务名只在调试状态输出时使用第二个是入口函数指针第三个是传给入口函数的参数这里直接传字符串指针第四个是优先级log任务优先级数字 1 比led任务的 2 更小所以会更早被调度。yt_task_delay(100)表示让出 CPU 并等待 100 个 tick而不是 100 毫秒。真正的时间由 tick 周期决定所以我们继续设置 tick。4.2 控制时间片用 setitimer 产生周期 tickYRTOS 在 PC 模拟环境下通常用setitimer产生周期性定时信号在信号处理函数中调用yt_tick_update()#include sys/time.h #include signal.h void tick_handler(int sig) { yt_tick_update(); /* 内核内部会推进时间片并唤醒等待任务 */ } int main(void) { struct itimerval timer {0}; yt_init(); yt_task_create(led, task_led, T1, 2, 1024); yt_task_create(log, task_log, T2, 1, 1024); timer.it_interval.tv_usec 10000; /* 10ms 一个 tick */ timer.it_value timer.it_interval; signal(SIGALRM, tick_handler); setitimer(ITIMER_REAL, timer, NULL); yt_start(); return 0; }这里it_interval.tv_usec 10000表示 tick 周期是 10 毫秒那么yt_task_delay(100)实际等待 1 秒。如果需要更精细的调度可以把 tick 调快到 1 毫秒但要注意信号处理函数本身的执行时间不能超过 tick 周期否则会丢失调度时机。任务切换的触发点有两个一是系统 tick 中断二是任务主动调用yt_task_delay或yt_task_yield。setitimer在这里承担了“时钟心跳”的角色它能保证即使任务本身没有主动让出 CPU同一优先级的任务也会按时间片被切换出去。时间片轮转策略对应到第 2 章的ready_queue[]就是同优先级链表头部的任务被摘下来放到尾部。4.3 共享数据与临界区volatile 解决不了原子问题两个任务之间经常要共享变量比如一个采集任务写缓冲区一个显示任务读缓冲区。用volatile只能让编译器不优化对变量的读取不能保证读改写操作的原子性。volatile int shared_counter 0; void task_a(void *arg) { while (1) { shared_counter; /* 非原子操作 */ yt_task_delay(10); } } void task_b(void *arg) { while (1) { printf(counter%d\n, shared_counter); yt_task_delay(10); } }shared_counter在编译后可能是三条汇编指令读取到寄存器、累加、写回内存。如果 task_a 执行到中间被 tick 中断task_b 读到的就是旧值。对于这种场景我一般会关掉调度器再操作yt_scheduler_lock(); shared_counter; yt_scheduler_unlock();yt_scheduler_lock的常见实现是维护一个全局计数只有计数为 0 时才允许切换任务。这里要小心临界区代码不能调用yt_task_delay因为延时本身就是一种调度行为会导致锁状态混乱。如果临界区较长应该改用信号量而不是长时间关调度。YRTOS 这套最小内核没有自带信号量实现需要时可以按yt_scheduler_lock的思路补一个二进制信号量模块。掌握了任务创建和共享资源访问接下来就是验证这套内核到底跑得稳不稳。5. 不黑盒调试给 YRTOS 加栈水印和任务状态快照内核光能跑功能还不够真正常遇到的不是逻辑问题而是任务栈溢出和优先级分配不当。YRTOS 的 TCB 结构里独立保存了栈地址和大小所以可以方便地写两个调试工具。5.1 栈高水位测量在yt_task_create分配栈后用固定字节填充整个栈空间。我在原工程基础上加了这样一段unsigned char *p stack; for (size_t i 0; i stack_size; i) { p[i] 0xA5; }任务运行后通过扫描栈底到栈顶找出第一个不是0xA5的位置就能算出实际使用深度size_t yt_stack_high_water_mark(const yt_tcb *tcb) { size_t unused 0; unsigned char *p tcb-stack_base; while (*p 0xA5 unused tcb-stack_size) { unused; p; } return tcb-stack_size - unused; }这个方法的前提是栈向下增长stack_base指向栈底低地址。如果发现某个任务stack_used超过栈大小的 85%我会把yt_task_create里的stack_size参数从1024加大到2048同时观察是否出现内存踩踏。5.2 任务状态快照与优先级观察另一个实用技巧是遍历 TCB 链表打印状态快照。因为 TCB 结构体里已经有name、priority、state和next可以这样输出void yt_dump_tasks(void) { yt_tcb *cur yt_first_tcb; while (cur ! NULL) { printf(%-8s prio%2d state%d stack_used%zu\n, cur-name, cur-priority, cur-state, yt_stack_high_water_mark(cur)); cur cur-next; } }运行时的输出大致长这样led prio 2 state2 stack_used132 log prio 1 state2 stack_used156这里state2代表YT_WAITING说明两个任务都卡在yt_task_delay等待 tick 信号。如果看到某个任务state0且一直没有变化说明它可能优先级太高并且在死循环里没有让出 CPU。此时可以在打印函数里同时记录连续占用 CPU 的 tick 次数就能快速定位是谁把低优先级任务饿死的。这种基于 TCB 的状态快照调试方式比单纯打断点更接近真实 RTOS 的开发体验。本文还有配套的精品资源点击获取