
简介这是一份面向嵌入式初学者与RTOS入门开发者的轻量级实践资源聚焦C语言系统编程核心能力训练帮助理解实时操作系统底层机制。资源以手写方式实现一个基于双链表的任务调度内核涵盖任务管理、优先级调度、中断响应与基础时间管理等关键模块适用于STM32等裸机开发场景助力从单片机程序向多任务系统思维跃迁。压缩包共7个文件6KB含2个C源文件main.c、cx_rtos.c实现调度逻辑与主循环2个头文件cx_rtos.h、list.h定义数据结构与API接口另含项目配置文件.pro、用户设置.user及VS Code调试配置c_cpp_properties.json结构简洁、模块边界清晰便于逐行阅读与调试验证。目前已有551人学习下载读者可完整掌握RTOS任务链表组织方式、上下文切换原理及C语言在资源受限环境下的系统级编码规范。 手写一个实时操作系统这件事放在今天很多人觉得没必要——FreeRTOS、RT-Thread、uC/OS随便选一个开源免费、资料齐全直接移植就完事了。但如果你真的用C语言从头到尾把一个RTOS的内核代码写出来哪怕只是一个简化版那种对调度器、任务切换、临界区保护的理解深度是看十遍源码都换不来的。我最初也是在嵌入式开发中反复被“任务卡死”“中断丢数据”折磨才动了手写实时操作系统代码的念头。把这套代码整理出来打包成rar分享不是为了重复造轮子而是想给同样想深入内核的人一条可以走通的路径。这篇文章就围绕“手写c语言实时操作系统代码.rar”这个工程把整个RTOS的核心模块拆开讲清楚讲明白每一步为什么要这么设计以及你从零照做的时候会遇到哪些坑。适合正在学嵌入式、想搞懂操作系统原理、或者准备自己做一个小型RTOS的开发者阅读看完你也能拥有一份属于自己的mini内核。1. 为什么值得手写一套实时操作系统1.1 学习内核最好的方式不是读源码而是自己写一遍很多朋友学RTOS的习惯是先跑通FreeRTOS的移植demo然后用API创建两个任务点个灯再查一下信号量和队列怎么用就觉得自己会了。但真到项目里遇到问题——任务莫名其妙不跑了、优先级高的任务饿死了低优先级任务、中断里调API死机——你其实无从下手因为你不知道底层发生了什么。我自己写这套实时操作系统代码的初衷就是被这些问题逼出来的。当你从零开始写任务控制块、写上下文切换、写调度器的时候你会发现每一个API背后都有明确的因果链。比如“vTaskDelay”为什么能阻塞当前任务因为它把当前任务从就绪链表摘下来挂到延时链表上然后触发一次调度。这个动作里涉及的链表操作、临界区保护、SysTick中断服务函数全部串联起来你对“延时”这个概念才真正有了图景。别觉得这是重复劳动。FreeRTOS的代码为了兼容各种架构做了很多宏封装和条件编译直接读反而容易被细节淹没。自己写的版本虽然简陋但逻辑是完整的。你写一遍再看任何商业RTOS的源码都能快速对应上它做的是什么为什么这么做。1.2 手写RTOS的实际价值不只是教学玩具可能有人会质疑自己写的RTOS稳定性比不过FreeRTOS功能也不全能干什么实际的事情我的观点是它至少在三个层面有真实价值。第一用于教学和入职准备。很多嵌入式岗位面试会问“操作系统的任务切换过程”“中断里能不能调用printf”“优先级反转怎么解决”你如果亲手实现过一个RTOS这些问题的答案不是背的而是你写代码时反复调试过的聊起来底层细节完全不虚。第二用于定制化的小型项目。有些场景对代码体积极度敏感比如几块钱的单片机Flash只有8KRAM只有2K塞FreeRTOS就很吃力。你手写一个阉割版RTOS只保留任务调度和信号量完全可以跑得动。我就在一个用STM8做的传感器采集节点上用过自己写的极简调度器整个内核代码不到500行。第三用于理解系统级设计思维。手写RTOS过程中的每一个决策——内存怎么分配、怎么保证任务切换时数据不丢、怎么让系统在中断风暴下不崩溃——都是系统设计思维的训练。这种思维在做大型固件、甚至做上层应用系统时都通用。1.3 和主流RTOS的差异先追求“小而完整”再追求“功能丰富”在动笔之前我先给自己的目标定位不追求API数量不追求设备驱动框架不追求兼容POSIX只实现一个RTOS最核心的骨架。具体来说包括支持多任务任务数量可配置支持基于优先级的抢占式调度支持时间片轮转同优先级任务支持任务延时支持信号量、互斥量、消息队列能正确响应中断并在中断与任务间传递数据。这个范围比FreeRTOS小得多但当这些模块全部跑通你已经理解了RTOS的绝大部分核心机制。后续想扩充比如加软件定时器、事件标志组、内存管理都是在既有骨架上填肉。我没有选uC/OS或RT-Thread来“魔改”原因很简单魔改现成系统的难度不低于自己写而且会陷入阅读别人代码风格的泥潭。自己写每一行代码都是自己可控的调试时有底气。2. 实时操作系统代码核心模块逐层拆解2.1 任务控制块与任务管理RTOS里最基础的数据结构是任务控制块也就是TCB。它管理着一个任务的全部状态信息。我设计的TCB大致长这样typedef struct tcb { uint32_t *sp; // 栈指针保存任务切换时的上下文 uint8_t priority; // 任务优先级数值越小优先级越高 uint8_t state; // 任务状态就绪、阻塞、挂起等 struct tcb *next; // 链表指针 char name[8]; // 任务名称调试用 uint32_t stack_size; // 栈大小 } tcb_t;每个创建的任务都有自己独立的栈这是任务能“各干各的”的基础。任务栈的大小分配是个关键设计点。我采用静态数组的方式在创建任务时传入栈空间地址这样避免动态分配带来的碎片问题——在单片机上malloc和free用得越多内存碎片风险越高。任务管理的核心操作是任务创建函数。它干三件事第一分配并初始化任务控制块第二初始化任务栈把初始上下文按特定顺序压入栈中第三把任务挂到就绪链表里。这里最难理解的是图2-1中“初始化栈”这一步——其实是为了骗过CPU让第一次任务切换时从栈里恢复寄存器然后PC指针恰好指向任务入口函数。这就好像给一个新员工预填好了入职表格他第一天上班直接进工位干活。2.2 上下文切换整个RTOS的心脏任务切换的本质是把当前任务的寄存器现场保存到它自己的栈里再从下一个任务的栈里恢复现场。这个过程发生在两个地方一个是任务主动让出CPU比如调用延时函数另一个是外部事件触发——中断或PendSV异常。在Cortex-M3/M4上我强烈建议用PendSV来挂起任务切换动作。PendSV是一种可挂起的系统异常优先级可以设置成最低。它的好处是如果你在中断服务程序里调用了触发调度的API函数比如释放信号量任务切换不会立刻发生而是被打上“等待切换”的标记挂起等所有中断处理完毕后才执行PendSV。这样就天然避免了在中断上下文里直接操作寄存器现场带来的冲突问题。任务切换的汇编代码是绕不开的即使你主业务用C语言写这个地方必须用汇编。以Cortex-M4为例核心逻辑如下__asm void PendSV_Handler(void) { MRS R0, PSP // 取当前任务栈指针 STMDB R0!, {R4-R11} // 保存R4到R11 LDR R1, current_tcb // 获取当前任务TCB地址 STR R0, [R1] // 更新当前任务的栈指针 LDR R0, next_tcb // 获取下一个要运行的任务 LDR R0, [R0] LDR R0, [R0] // 取新任务的栈指针 LDMIA R0!, {R4-R11} // 恢复新任务的寄存器 MSR PSP, R0 // 更新栈指针 BX LR // 返回硬件自动弹出剩余寄存器 }如果你第一次写这段代码会踩一个经典的坑PendSV触发时硬件已经自动把xPSR、PC、LR、R0-R3压栈了所以汇编里只需要额外保存R4-R11。如果你不明白这一点调试时就会看到寄存器乱掉。这个设计是Cortex-M系列架构的便利之处——部分上下文切换由硬件完成软件只处理剩下的通用寄存器。2.3 调度算法优先级抢占加时间片轮转调度器是一个RTOS的“大脑”。我的实现用了一个很经典也很直观的方式优先级位图表。每个优先级对应位图中的一个位当某个优先级有就绪任务时对应位置1。查找最高优先级就绪任务时只需要扫描位图即可。比如32个优先级用一个uint32_t就存下了查询最高优先级就绪任务只用一条指令级的操作效率很高。#define MAX_PRIORITY 32 uint32_t ready_table; // 就绪位图bit置1表示该优先级有就绪任务 uint8_t find_highest_ready_task(void) { uint32_t temp ready_table; uint8_t priority 0; while ((temp 0x80000000) 0) { temp 1; priority; } return priority; }这种位图方案比遍历任务列表快得多而且实现简单。优先级抢占的逻辑是当有更高优先级的任务就绪时调度器立即剥夺当前任务的CPU使用权。这要求所有可能使高优先级任务就绪的路径延时超时、信号量释放、消息发送都必须检查是否需要重新调度。同优先级的任务则采用时间片轮转。每次SysTick中断到来时当前任务的时间片计数减一如果减到零且还有同优先级的其他就绪任务就切换给下一个任务。时间片长度我取10ms到50ms之间比较合适——太短频繁切换造成性能浪费太长交互性变差实时性体验下降。2.4 同步与通信机制信号量、互斥量与消息队列这部分是整个代码工程里最常用、也最考验设计的模块。先讲信号量。它本质是一个计数器加一个等待链表。释放信号量就是count加一如果之前有任务在这个信号量上等待就要做一个“是否立即切换”的决策。这里的核心问题是优先级反转——当低优先级任务持有信号量高优先级任务等待它时中等优先级任务会先把低优先级任务挤掉高优先级任务反而迟迟拿不到信号量。解决优先级反转有经典方法就是互斥量加优先级继承。我在工程里实现了简化版的互斥量当高优先级任务因拿不到互斥量而阻塞时把当前持有互斥量的低优先级任务临时提升到高优先级等它释放互斥量后再恢复原优先级。这样中等优先级任务就没机会插队了。消息队列则是最常用的任务间数据传递方式。我实现的是环形缓冲区加等待队列发送方把数据拷入环形缓冲接收方在空队列上等待。这里踩过最大的坑是——发送方中断里拷贝数据时接收方任务刚好也在读缓冲区造成数据竞争。解决办法是在操作队列的临界区加一个开关中断的保护或者用暂停调度器的方式。但要注意临界区不能太长否则中断响应会延迟这跟实时性要求是矛盾的。实际调优中我一般控制在几十条指令范围内完成临界区操作。2.5 时钟管理SysTick与软件定时器任何RTOS都需要一个最小时间粒度作为心跳。我在Cortex-M上选用了SysTick作为系统时基频率一般配置成1000Hz也就是1ms一个tick。每个tick到来时SysTick中断服务程序对延时链表上的每个结点做检查如果任务延时到期把任务恢复到就绪状态。设计延时链表时我用的是一种“按剩余时间排序”的方式新任务插入时把自身套入链表。还有一个细节释放信号量或者发送消息时如果唤醒了一个延时中的任务这个任务要从延时链表里摘除这个动作对应代码里一个比较繁琐的“unlink”操作。如果遗漏了这一步你可能花一天时间在调试任务为什么“睡不醒”原因就是它被同时挂在延时链表和就绪链表里造成状态混乱。3. 从零搭建代码工程的实操过程3.1 工程目录与代码组织的建议很多人写小项目习惯所有代码堆在main.c里。但手写RTOS不是小项目它至少包含内核、移植层、应用层三个层面。我的目录结构如下你可以直接参考rtos_kernel/ ├── include/ │ ├── rtos.h # 对外API头文件 │ ├── list.h # 链表操作 │ └── tcb.h # TCB结构体定义 ├── src/ │ ├── task.c # 任务创建、延时 │ ├── schedule.c # 调度器核心 │ ├── sem.c # 信号量互斥量 │ └── queue.c # 消息队列 ├── port/ │ ├── port.c # 移植层SysTick等 │ └── port_asm.s # 上下文切换汇编 └── app/ ├── main.c └── user_tasks.c这样分层的好处是内核代码跟硬件平台无关换个芯片时只需要重写port目录下的代码。这也是FreeRTOS的官方推荐做法——你如果认真读过它的结构会发现它也是这么设计的。3.2 核心代码实现从启动调度器到第一个任务跑起来一个新手最容易卡住的点是“调度器是怎么启动的”。我在工程里实现了一个rtos_start()函数它做了这样几件事初始化系统时基SysTick配置找到第一个要运行的任务手动触发一次PendSV完成上下文切换把控制权交给第一个任务。这里要特别强调调度器启动前系统处于“裸机模式”运行在启动线程模式启动后系统进入“任务模式”每个任务都在自己的栈上运行。这个切换不是C语言能完成的必须在汇编层面操作。为了手动触发PendSV我在C代码里这样写SCB-ICSR | SCB_ICSR_PENDSVSET_Msk;这一行设置PendSV挂起位让PendSV异常在合适时机执行。很多初学者不知道这个操作的存在以为要直接调用PendSV_Handler()——千万不要直接调因为PendSV_Handler前面的汇编逻辑依赖“当前处于异常状态”这个前提。第一个任务跑起来之后整个系统就像上了发条SysTick每毫秒打断一次当前任务检查延时和调度任务之间通过信号量、队列交互遇到需要原子操作的地方手动关中断或挂起调度器。3.3 实测运行效果两个LED轮询加一个按键任务为了让代码工程可验证我写了一个简单的应用三个任务Task_A每500ms翻转一次LED1Task_B每1000ms翻转一次LED2Task_C读按键并在按下时通过消息队列发事件给Task_A。运行起来后在逻辑分析仪或者示波器上看GPIO波形两个LED的周期非常稳定。这验证了任务延时是准确的调度器没有被阻塞。按下按键后Task_A的翻转速度立即改变说明消息队列和中断唤醒链路工作正常。实际调试时我建议在开发板上加一个空闲任务优先级最低在空闲任务里让一个独立的GPIO翻转。如果系统卡死这个GPIO的波形会异常能帮你快速定位是哪一步导致系统无法进入空闲状态。这个小技巧在长时间无人值守的测试中尤其重要比串口打印可靠得多。3.4 代码注释与文档别偷懒这决定了工程能不能长期维护如果你的手写RTOS只是自己玩注释随意一点也就算了。但如果打算打包成rar分享出去或者准备在后续项目中持续迭代那么注释和文档必须到位。我自己在这份代码里每一个模块的头部都写清了“模块职责、核心数据结构示意、关键函数说明、已知限制”。比如在sem.c的头部我写了这样一段注释/* * 信号量模块 * - 支持计数信号量和二值信号量 * - 支持超时等待避免死锁 * - 释放操作可在中断上下文中调用但获取操作带超时限制 * 已知限制 * - 不支持递归获取互斥量会死锁 * - 优先级继承只支持单级不支持多级嵌套 */这样的注释半年后你再拿起来看依然能快速进入状态。很多人觉得写注释浪费时间实际上它是在为未来的你省时间。4. 踩坑实录手写RTOS调试中的高频问题4.1 任务不切换最先怀疑栈初始化新手跑第一个RTOS demo时最经典的现象是调度器启动后系统直接进HardFault或者第一个任务跑完不切下一个任务。排查顺序我建议是先确认任务栈空间是否对齐到8字节边界。Cortex-M的AAPCS调用约定要求栈8字节对齐如果你的栈数组首地址不对齐压栈后寄存器操作会异常。再检查任务入口函数是不是死循环。RTOS任务如果从函数末尾返回属于未定义行为必须确保任务入口是一个永远不返回的循环。最后查汇编里保存现场的顺序和结构体内栈指针变量的类型是否匹配。有的开发者在初始栈时压入了8个寄存器恢复时却弹出10个马上整个系统就崩了。4.2 中断里调用API后系统卡死临界区嵌套保护写RTOS一定会遇到这个问题你在一个串口中断服务函数里调用了xSemaphoreGiveFromISR来唤醒一个任务结果系统直接卡死。根本原因是中断里释放信号量的操作需要进入临界区而进入临界区的操作通常是“关中断再恢复中断”。关键是“恢复”时必须根据嵌套层级恢复不能无条件开中断。我的习惯是用一个全局变量记录中断嵌套深度volatile uint32_t critical_nesting 0; void enter_critical(void) { __disable_irq(); critical_nesting; } void exit_critical(void) { critical_nesting--; if (critical_nesting 0) { __enable_irq(); } }如果你不用嵌套计数两层临界区嵌套时第一层退出就把中断开了这跟设计意图不一致可能引发潜在竞争。我代码里的所有临界区都走这对函数不允许直接在函数里裸用__disable_irq()和__enable_irq()这是一个值得从写第一版就养成的习惯。4.3 优先级反转与死锁实时系统的两大杀手优先级反转在我的2.4节提过这里补充死锁的实际场景。手写RTOS中死锁最容易出现在资源锁定顺序不一致的情况下比如Task_A持有信号量S1等待S2Task_B持有S2等待S1两个任务互不谦让系统就僵住了。排查死锁的经验是在每个等待信号量的调用上加一个超时参数。没有超时的等待就是无限期阻塞出问题后系统卡死无法恢复。我实现的信号量获取函数都支持设置等待时间超时后返回超时错误码至少让你知道是哪个任务、在哪一行卡住。设计API时加上超时参数调试能少无数根头发。4.4 内存碎片问题优先使用静态分配手写RTOS如果控制不好动态内存运行一段时间后会发现任务创建总是失败或者系统行为越来越诡异。原因就是malloc/free产生的碎片。我的建议是任务栈一律用静态数组创建任务时传入栈地址消息队列的存储区也由调用方静态提供如果确实需要动态分配只能用于不频繁申请释放的场景并且要加内存池管理。我在实际工程中只用一个简单的固定大小内存池配合位图记录块分配状态绝不直接调用malloc。这样既保证了确定性也让系统运行时间长了之后依然稳定。5. 关于rar打包与工程分发的几点经验5.1 为什么选rar而不是zip在分享这份手写c语言实时操作系统代码时我用rar格式压缩不是心血来潮。原因是这套工程里包含很多小的源码文件、头文件、编译脚本rar格式在压缩率上比zip有明显优势尤其是对文本类文件实测压缩率能提升20%到30%。更重要的是rar可以设置恢复记录如果压缩包在传输过程中出现个别字节损坏用恢复记录还能抢救回来。这在社区分享场景里很实用能减少“下载完发现解压失败”的尴尬。唯一要注意的是rar是闭源格式你分享给别人时对方可能没有解压工具。所以压缩包里最好附一份txt说明写上“推荐使用哪些解压工具打开”或者直接在文件名里标注“使用WinRAR或7-Zip解压”。如果是面向开源社区打包成.tar.gz或.7z更稳妥这边不展开但基本原则是一样的尽量选兼容性好的压缩配置。5.2 发包前必须做的三件事我分享过几份代码包踩过不少“被打差评”的坑总结出发包前必须做的三件事。第一目录结构整理干净。工程根目录只放README、工程文件、源码文件夹。源文件里不要出现“测试1最终版.c”“最终版2.c”这种文件名。打包之前先删除编译生成的临时文件obj、hex、map等压缩包体积会小很多别人解压看代码也不容易产生困惑。第二写一个能用的README。README开头三句话讲清楚这个工程是什么芯片平台、需要什么开发环境比如Keil MDK版本、ARM GCC版本、怎么编译和下载。我见过很多代码包源码写得不错但README一片空白用户根本不知道怎么跑起来。白白浪费了好工程的口碑。第三加入版本信息和已知问题清单。在README里用表格列出当前版本实现了哪些功能、哪些功能未实现或存在限制。如果你不想别人频繁发私信问你“怎么没有时间片调度”就主动在文档里说清楚。5.3 学习路径拿到这份代码后应该怎么读如果你刚拿到这份手写RTOS代码我建议的阅读顺序是先读README大概了解工程范围然后读include下的rtos.h看看向外暴露了哪些API接着读task.c理解任务是怎么创建和初始化的再读schedule.c搞清楚调度器是怎么找最高优先级任务最后再啃port_asm.s里的上下文切换汇编。顺着这个顺序你对整个系统的理解会成体系。初学者常犯的错误是拿起汇编代码就开始逐行抠结果被寄存器操作劝退。正确姿势是先掌握逻辑层面再往下钻到汇编层面。汇编只需要理解“保存现场、恢复现场、更新栈指针”三个核心动作即可不需要背每一行的含义。如果你愿意继续深入下一步可以尝试自己上手改代码。比如在调度器里增加一个“时间片轮转”的完整实现或者把位图调度算法改成更通用的链表遍历调度或者为信号量增加超时唤醒。改的过程中一定会踩坑但踩坑本身就是学习——把我整理的常见问题对照表当成排雷手册你的进度会快很多。最后我想说一点个人体会。手写一套实时操作系统最难的其实不是代码实现而是敢于拒绝“现成方案”的诱惑沉下心来从零推导完整流程。这件事带给我的不只是掌握了RTOS内核原理更是建立了一种系统级思考习惯任何看似神秘的系统行为本质上都是一条清晰的因果链顺着代码去寻找一定能找到那个最初的触发点。如果你也正在纠结要不要动手写自己的RTOS我建议你直接开始。不要等把所有基础理论都读明白再动手而是在写代码过程中再补齐理论这样效率高得多。这份“手写c语言实时操作系统代码.rar”就当作一个起点参考你真正写出来的那一版一定比我的更好。本文还有配套的精品资源点击获取