news 2026/9/17 10:40:17

手搓RTOS内核:GD32F103上实现极简双任务调度

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手搓RTOS内核:GD32F103上实现极简双任务调度

1. 项目概述:这不是“点灯”,而是一次对嵌入式系统底层逻辑的重新校准

“点灯大师进阶,从手搓操作系统开始(10)”——这个标题乍看像极了新手入门的趣味实验,但如果你真把它当成“让LED闪一下就完事”的玩具项目,那接下来的每一步都会让你额头冒汗。我带过十几届嵌入式方向的实习生,90%的人在看到“手搓操作系统”四个字时,第一反应是:“这得写多少行代码?是不是要从汇编开始写调度器?”其实恰恰相反,真正难的从来不是写多少行,而是删掉多少行、压住多少想“加功能”的冲动、守住哪些最原始的边界。这一期之所以标为(10),是因为它不是孤立的技术切片,而是前九期层层递进后必然抵达的临界点:你已经能用裸机跑通UART、SPI、SysTick,能手动配置NVIC优先级分组,甚至自己写了内存池管理;现在,该把“任务”这个概念,从纸面定义变成可调度、可抢占、可验证的实体了。核心关键词“RTOS”在这里不是指移植一个现成的FreeRTOS或Zephyr,而是指亲手构建一个具备最小可行调度语义的内核骨架——它不支持动态创建任务、没有消息队列、连堆内存分配都刻意绕开,但它必须能精确响应SysTick中断、能在两个任务间完成上下文切换、能通过寄存器状态回溯出每一次切换的完整路径。为什么选GD32F103?不是因为它多先进,而是因为它的Cortex-M3内核手册公开透明、启动流程清晰、Flash擦写时序稳定,且市面上有大量二手开发板(不到30元)可供反复“烧坏重来”。我试过用STM32F407做同样实验,结果卡在FPU寄存器保存顺序上整整两天——M4的浮点单元状态比M3复杂太多,对初学者反而成了干扰项。所以这一期的起点,就是把“操作系统”这个词从宏大叙事里拎出来,钉死在GD32F103的SRAM起始地址0x20000000上,用纯C+少量汇编,让两盏LED以完全独立的周期闪烁,且彼此互不阻塞。这不是炫技,而是为了让你亲手摸到“任务隔离”这个概念的物理温度。

2. 整体设计思路:为什么放弃“移植”,选择“手搓”?

2.1 “移植RTOS”和“手搓内核”的本质差异

很多人混淆了“用RTOS”和“懂RTOS”。就像会开车不等于懂发动机原理,能调通FreeRTOS的xTaskCreate()也不代表理解PendSV_Handler里那几行汇编到底在搬动哪些寄存器。我们来看一组真实数据:在某次嵌入式笔试中,给出一段含vPortSVCHandler的汇编代码,要求指出第7行LDR R0, [R1, #24]读取的是哪个寄存器值,87%的应届生答错。错误集中在两点:一是误以为R1指向的是任务栈顶,二是不知道#24这个偏移量对应的是R4还是R12。这种细节的缺失,直接导致他们在调试任务切换失败时,只会盲目改configTOTAL_HEAP_SIZE,而不是去检查pxCurrentTCB是否被意外覆盖。所以本项目的设计原点非常明确:不追求功能完整,只追求控制路径可见。整个内核代码最终控制在327行以内(含注释),其中汇编部分仅43行,全部集中在一个.s文件里。所有C代码均禁用全局变量(除pxCurrentTCB这个必需指针),每个函数严格遵循AAPCS调用规范,连局部变量都强制指定存储位置(如register uint32_t ulCriticalNesting __attribute__((used));)。这种“自缚手脚”的设计,是为了逼你在写每一行时都问自己:“如果我把这行删了,系统会在哪里崩?”——答案必须能精确到PC值和SP值。

2.2 Cortex-M3异常模型的精简利用

ARM官方文档里关于Cortex-M3异常处理的章节长达60页,但我们只提取三个关键锚点:

  • SysTick作为唯一周期性中断源:不启用任何外设中断(如EXTI、TIM),避免中断嵌套带来的栈管理复杂度。SysTick的LOAD值设为999999(假设系统主频72MHz),即每10ms触发一次,这是任务调度的绝对心跳。
  • SVC(Supervisor Call)用于任务创建入口:所有任务函数必须通过__svc(0)指令触发SVC异常进入内核,而非直接调用。这样做的好处是,内核能统一捕获任务启动点,并在第一次切换前完成初始栈帧构造。我试过让任务函数直接返回,结果发现R0-R3寄存器值全乱——因为裸机环境下函数返回后PC跳转到未知地址,而SVC异常能确保每次进入都有干净的栈环境。
  • PendSV作为唯一上下文切换通道:这是最关键的取舍。很多教程用SysTick直接调用vTaskSwitchContext(),但这样会导致中断服务程序里执行复杂C代码,极易引发栈溢出。而PendSV是“可悬起”的异常,SysTick ISR里只需执行SCB->ICSR |= SCB_ICSR_PENDSVSET_Msk;即可,真正的切换逻辑延后到PendSV Handler中执行。这个设计让中断响应时间稳定在1.2μs以内(实测),且切换过程完全可控。

提示:不要试图在PendSV Handler里加入printf调试。我踩过的最大坑是,在PendSV里调用半主机printf,结果发现每次切换后串口输出都延迟300ms——因为半主机依赖ARM调试接口,而PendSV执行时调试器可能正在读取寄存器,造成死锁。所有调试信息必须通过GPIO翻转+逻辑分析仪抓取,这才是嵌入式底层的真相。

2.3 GD32F103硬件特性的针对性适配

GD32F103虽是STM32F103的国产替代,但存在几个必须绕开的“坑”:

  • Flash编程电压敏感:官方手册标注VDDA需≥2.7V才能稳定擦写,但实测当VDDA=3.0V时,连续擦写100次后第101次会失败。解决方案是每次擦除前插入10μs延时,并读取FLASH_SR寄存器的BSY位确认空闲。
  • NVIC优先级分组不兼容:GD32的AIRCR.PRIGROUP位域定义与ARM标准不同,直接写0x0500会触发HardFault。正确做法是调用nvic_priority_group_config(NVIC_PRIGROUP_PRE2_SUB2),这个宏内部做了位掩码转换。
  • SysTick校准值偏差:GD32的SysTick CALIB寄存器默认值为10000,但实测在72MHz下应为9999。这个1的误差会导致1000次SysTick后累积10ms偏差。我们在初始化时强制写入SysTick->CALIB = 9999;

这些细节看似琐碎,但正是它们决定了你的“手搓OS”是能稳定运行一周,还是每次复位都随机崩溃。我见过太多人把问题归咎于“RTOS不稳定”,其实只是没读懂GD32的手册第3.4.2节那个不起眼的Note。

3. 核心细节解析:从栈帧构造到任务切换的原子操作

3.1 任务控制块(TCB)的极简设计

传统RTOS的TCB动辄包含20+字段(堆栈高水位、任务状态、事件列表等),但我们的TCB只保留4个成员:

typedef struct { uint32_t *pxTopOfStack; // 指向当前任务栈顶,关键! StackType_t xStack[128]; // 静态分配128字深度栈,足够跑基础逻辑 const char *pcName; // 仅用于调试识别,不参与调度 uint8_t ucPriority; // 优先级,0最高,数值越小优先级越高 } TCB_t;

重点在于pxTopOfStack。它不是栈底指针,也不是栈顶地址,而是指向下一个将被压入栈的空闲位置。当任务首次启动时,我们需要手动构造一个完整的栈帧,使其看起来就像刚从中断返回一样。这个栈帧必须严格符合Cortex-M3的异常返回约定(即EXC_RETURN值为0xFFFFFFF9)。我们用汇编函数prvInitialiseNewTask()完成此事:

EXPORT prvInitialiseNewTask prvInitialiseNewTask: PUSH {R4-R11, LR} @ 保存R4-R11和LR(此时LR是SVC返回地址) MOV R4, #0x01000000 @ 构造EXC_RETURN: 0xFFFFFFF9的低字节 MOV R5, #0xFFFFFFFA @ 高字节 STR R4, [R0, #0] @ 存入栈底(R0是TCB->xStack起始地址) STR R5, [R0, #4] MOV R4, #0x00000000 @ R0-R3清零(任务初始参数) STR R4, [R0, #8] STR R4, [R0, #12] STR R4, [R0, #16] STR R4, [R0, #20] STR R4, [R0, #24] @ R4=0 STR R4, [R0, #28] @ R5=0 LDR R4, =0x01000000 @ R6=1 STR R4, [R0, #32] MOV R4, #0x00000000 @ R7=0 STR R4, [R0, #36] LDR R4, =0x01000000 @ R8=1 STR R4, [R0, #40] MOV R4, #0x00000000 @ R9=0 STR R4, [R0, #44] MOV R4, #0x00000000 @ R10=0 STR R4, [R0, #48] MOV R4, #0x00000000 @ R11=0 STR R4, [R0, #52] LDR R4, =0x01000000 @ R12=1 STR R4, [R0, #56] LDR R4, =0x01000000 @ LR=1(伪返回地址,实际由PendSV设置) STR R4, [R0, #60] LDR R4, =0x01000000 @ PC=1(任务入口地址,由调用者传入R1) STR R4, [R0, #64] MOV R4, #0x01000000 @ xPSR=0x01000000(Thumb状态) STR R4, [R0, #68] POP {R4-R11, PC} @ 返回到调用者,此时栈已构造完毕

这段汇编的精妙之处在于:它没有使用任何C库函数,所有地址计算都在寄存器内完成;pxTopOfStack被初始化为&TCB->xStack[128] - 17(17个32位字),确保后续压栈不会越界。我曾因少减1个字导致第128次任务切换时覆盖了pcName字段,结果调试器显示任务名变成乱码,花了6小时才定位到栈指针偏移错误。

3.2 PendSV Handler的原子切换逻辑

PendSV Handler是整个内核的“心脏手术室”,必须保证绝对原子性。我们的实现分为三步:

  1. 保存当前任务上下文:将R4-R11、PRIMASK、xPSR压入当前任务栈
  2. 切换TCB指针:更新pxCurrentTCB指向下一个就绪任务
  3. 恢复新任务上下文:从新TCB的栈中弹出R4-R11、PRIMASK、xPSR

关键陷阱在于第1步的保存时机。如果在保存前发生更高优先级中断,会导致当前任务栈被污染。因此,我们在进入PendSV Handler第一行就执行:

MRS R0, PRIMASK @ 读取当前屏蔽状态 CPSID I @ 立即关中断 PUSH {R0} @ 保存PRIMASK

这样即使SysTick在PendSV执行中途再次触发,也会被挂起等待,不会打断上下文保存。恢复时则先弹出PRIMASK再执行CPSIE I,确保中断使能状态与切换前完全一致。

注意:不要在PendSV里调用任何C函数!我曾为图省事在切换后加了一句debug_log("switch to task2"),结果发现任务2永远无法运行——因为C函数调用会修改R0-R3,而这些寄存器本该由新任务的栈帧恢复。所有日志必须用GPIO翻转+逻辑分析仪解码,这是硬性纪律。

3.3 任务就绪列表的位图实现

没有链表,没有队列,就用一个32位整数uxReadyPriorities作为就绪位图。每位代表一个优先级(0-31),置1表示该优先级下有就绪任务。查找最高优先级就绪任务的代码只有3行:

static uint32_t uxTopPriority = 0; __asm volatile ( "clz %0, %1" : "=r" (uxTopPriority) : "r" (uxReadyPriorities) ); uxTopPriority = 31 - uxTopPriority; // CLZ返回前导零个数,需反转

CLZ(Count Leading Zeros)是Cortex-M3的硬件指令,单周期完成,比循环查表快10倍以上。当任务1(优先级2)就绪时,执行uxReadyPriorities |= (1UL << 2);;当它被切换出去时,执行uxReadyPriorities &= ~(1UL << 2);。这种位操作的极致简洁,正是嵌入式实时系统的灵魂——用硬件特性换代码简洁,用确定性换灵活性。我测试过,当就绪位图中同时有优先级0、5、12、28的任务时,uxTopPriority计算耗时恒定为12ns(基于72MHz主频),而同等条件下链表遍历平均耗时83ns,且方差极大。

4. 实操过程:从零构建可验证的双任务系统

4.1 工程搭建与启动文件定制

我们不使用Keil或IAR的默认启动文件,而是手写startup_gd32f103.s。关键修改点有三处:

  • 栈大小重定义:将默认的0x400改为0x200(512字节),因为我们的任务栈是静态分配的,主栈只需处理启动初期的C库初始化。
  • 异常向量表重映射:GD32支持向量表偏移到SRAM,我们在SystemInit()后执行:
    SCB->VTOR = 0x20000000; // 指向SRAM起始地址
    这样所有异常向量(包括PendSV)都从SRAM读取,避免Flash访问延迟影响实时性。
  • SVC Handler入口修正:Keil默认将SVC Handler放在向量表第11位,但GD32的SVC异常号是11(0-indexed),必须确保__Vectors[11]指向我们自定义的vPortSVCHandler

工程结构极简:

/Project ├── startup_gd32f103.s # 启动文件,定义向量表和初始栈 ├── port.c # 内核端口层,含PendSV/SVC实现 ├── kernel.c # 调度器主逻辑,含xTaskCreate/xTaskStartScheduler ├── main.c # 应用层,创建两个LED闪烁任务 └── gd32f103c8t6.ld # 链接脚本,将TCB段强制放在SRAM起始

链接脚本gd32f103c8t6.ld的核心段定义:

MEMORY { FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 64K SRAM (rwx) : ORIGIN = 0x20000000, LENGTH = 20K } SECTIONS { .tcb_data (NOLOAD) : { _tcb_start = .; *(.tcb_data) _tcb_end = .; } > SRAM }

然后在kernel.c中声明:

__attribute__((section(".tcb_data"))) static TCB_t xTask1, xTask2;

这样两个TCB被强制链接到SRAM最前端(0x20000000),方便调试器直接查看内存布局。我曾因TCB被链接到SRAM中部,导致pxCurrentTCB指针计算错误,现象是LED闪烁频率忽快忽慢——因为栈指针指向了未初始化内存。

4.2 双任务LED闪烁的完整实现

任务1:以200ms周期翻转PA0(红灯)
任务2:以500ms周期翻转PA1(绿灯)
关键代码如下:

void vTask1(void *pvParameters) { while(1) { GPIO_ToggleBit(GPIOA, GPIO_PIN_0); vTaskDelay(20); // 20 * 10ms = 200ms } } void vTask2(void *pvParameters) { while(1) { GPIO_ToggleBit(GPIOA, GPIO_PIN_1); vTaskDelay(50); // 50 * 10ms = 500ms } } int main(void) { rcu_periph_clock_enable(RCU_GPIOA); gpio_init(GPIOA, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, GPIO_PIN_0 | GPIO_PIN_1); xTaskCreate(vTask1, "LED_RED", 128, NULL, 0, &xTask1); xTaskCreate(vTask2, "LED_GREEN", 128, NULL, 1, &xTask2); xTaskStartScheduler(); // 启动调度器,永不返回 while(1); // 不会执行到这里 }

xTaskCreate()的参数含义:

  • pvTaskCode:任务函数指针
  • pcName:任务名(仅调试用)
  • usStackDepth:栈深度(单位:字,非字节)
  • pvParameters:传递给任务的参数(此处为NULL)
  • uxPriority:优先级(0最高,1次之)
  • pxCreatedTask:指向TCB的指针(必须是静态分配)

这里有个易错点:usStackDepth是128个uint32_t,即512字节,不是128字节。我最初误以为是字节单位,结果任务运行几秒后就崩溃——栈溢出覆盖了相邻TCB的pxTopOfStack字段。

4.3 调度器启动与临界区保护

xTaskStartScheduler()的实现是成败关键:

void xTaskStartScheduler(void) { // 1. 初始化SysTick为10ms周期 SysTick_Config(SystemCoreClock / 100); // 2. 启用PendSV和SVC异常 NVIC_EnableIRQ(PendSV_IRQn); NVIC_EnableIRQ(SVCall_IRQn); // 3. 设置第一个任务为当前任务 pxCurrentTCB = &xTask1; // 4. 开启全局中断 __enable_irq(); // 5. 强制触发一次PendSV,启动第一个任务 portYIELD(); // 6. 永不执行到这里 for(;;); }

portYIELD()的实现就是触发PendSV:

EXPORT portYIELD portYIELD: CPSID I LDR R0, =0xE000ED04 MOV R1, #0x10000000 STR R1, [R0] CPSIE I BX LR

注意CPSID ICPSIE I的配对,这是防止在触发PendSV瞬间被其他中断打断。实测中,如果去掉这两行,系统在启动瞬间有30%概率卡死在PendSV_Handler里——因为SysTick和PendSV同时触发,导致栈管理混乱。

5. 常见问题与排查技巧实录

5.1 典型故障速查表

现象可能原因排查步骤解决方案
LED完全不亮主函数未执行到xTaskStartScheduler()用SWD调试器单步,检查main()是否进入检查启动文件Reset_Handler是否正确跳转,确认SystemInit()无HardFault
红灯常亮,绿灯不闪任务2未被调度PendSV_Handler开头加GPIO翻转,用逻辑分析仪看是否触发检查uxReadyPriorities是否被正确置位,确认xTaskCreate()uxPriority=1的位图操作
两灯同频闪烁(200ms)任务2被抢占但未恢复PendSV_Handler末尾加GPIO翻转,观察波形是否对称检查pxCurrentTCB是否在切换后正确指向&xTask2,用调试器查看xTask2.pxTopOfStack
系统运行10秒后崩溃栈溢出用调试器查看xTask1.xStack[0]xStack[127]是否被写脏减小任务函数复杂度,或增大usStackDepth参数;禁用所有printf类函数
串口输出乱码SysTick中断频率错误用示波器测PA0翻转周期,反推SysTick LOAD值重新计算SysTick_Config()参数,确认SystemCoreClock值准确(GD32需调用rcu_system_clock_freq_get()

5.2 独家避坑技巧

技巧1:用GPIO模拟逻辑分析仪通道
不用买昂贵设备,直接用3个空闲GPIO:

  • PA0:标记PendSV进入(拉高)
  • PA1:标记PendSV退出(拉低)
  • PA2:标记任务1执行(翻转)
    用普通示波器就能看到完整的调度时序。我就是靠这个发现了一个致命bug:vTaskDelay()函数里忘记清除uxReadyPriorities对应位,导致任务1延时结束后仍处于就绪态,抢占了任务2的CPU时间。

技巧2:HardFault调试的黄金三步
当出现HardFault时,按顺序检查:

  1. SCB->HFSR寄存器的FORCED位是否为1(表示强制进入HF)
  2. 若是,查SCB->CFSRMMARVALID位,若为1则读SCB->MMFAR得到非法访问地址
  3. 用调试器查看该地址附近内存,确认是否为未初始化指针解引用
    我曾因pxCurrentTCB初始化为NULL,导致PendSV_HandlerLDR R0, [R0]触发总线fault,CFSR显示IBUSERR=1BFAR指向0x00000000。

技巧3:TCB内存布局可视化
在调试器Memory View中输入0x20000000,按Word格式查看:

0x20000000: 0x200000CC // pxTopOfStack 指向0x200000CC 0x20000004: 0x00000000 // xStack[0] ... 0x200000CC: 0x01000000 // 栈顶的xPSR值

如果pxTopOfStack值小于0x20000000或大于0x20000200(128字栈上限),说明栈指针已越界,必须立即检查任务函数是否有无限递归。

5.3 性能实测数据

在GD32F103C8T6(72MHz)上实测:

  • 上下文切换耗时:从PendSV触发到新任务第一条指令执行,共87个时钟周期,即1.21μs
  • 最小任务周期:两个同优先级任务交替运行,最小稳定周期为20ms(即SysTick周期),低于此值会出现任务饥饿
  • 内存占用:内核代码+数据共1.8KB Flash,256字节SRAM(不含任务栈)
  • 中断延迟:SysTick中断从触发到PendSV_Handler第一条指令,最大延迟3.2μs(含流水线刷新)

这些数据不是理论值,而是用ST-Link V2的SWO trace功能实测得出。对比FreeRTOS v10.4.3在相同平台上的数据:切换耗时2.8μs,内存占用3.2KB Flash——我们的手搓内核在确定性上胜出132%,这正是硬实时场景的核心诉求。

6. 后续演进路径:从“能跑”到“可靠”的必经之路

这个(10)期项目不是终点,而是你嵌入式底层能力的分水岭。接下来三个月,我建议你按此路径深化:

  • 第11期:加入内存保护单元(MPU)——利用GD32F103的MPU(虽然简化版),为每个任务划分独立地址空间,让任务1的野指针无法破坏任务2的数据。关键是要理解MPU_RASR寄存器的SIZE字段如何计算,以及为什么MPU_RBAR必须4字节对齐。
  • 第12期:实现软件定时器队列——不再依赖vTaskDelay()的简单计数,而是构建一个按到期时间排序的链表,支持xTimerCreate()xTimerStart()。难点在于如何在不阻塞调度器的前提下,安全地插入/删除定时器节点。
  • 第13期:添加轻量级消息队列——用环形缓冲区实现,但只支持xQueueSendToBack()xQueueReceive(),禁用xQueueSendToFront()。重点是解决生产者-消费者并发访问时的临界区保护,你会重新认识__disable_irq()__enable_irq()的代价。

最后分享一个真实体会:去年帮一家医疗设备公司优化监护仪固件,他们原来的FreeRTOS任务切换偶尔出现200μs抖动,导致ECG波形采样点偏移。我们替换成类似本项目的极简内核后,抖动被压制在±0.5μs内,客户说这是他们十年来第一次在EMC测试中一次性通过。所以,“手搓操作系统”从来不是为了证明你能写多少代码,而是为了在每一个微秒、每一个字节、每一个寄存器里,亲手刻下你对确定性的理解。当你能看着逻辑分析仪上那条笔直的PendSV脉冲,知道它背后是327行代码的绝对掌控时,那种踏实感,是任何现成RTOS都无法给予的。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/17 10:35:21

MATLAB语音识别GUI:HMM+MFCC完整教学实现

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 10:33:38

UART发送器设计:波特率精度与状态机健壮性实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/17 10:25:44

银河麒麟V10磁盘管理实战:LVM逻辑卷创建、格式化与挂载全流程

银河麒麟V10环境下做磁盘管理&#xff0c;建分区、做LVM逻辑卷、格式化、挂载&#xff0c;这一套流程我实操过很多次。特别是给服务器加数据盘、给系统盘扩容、调整home目录大小的时候&#xff0c;如果LVM规划不对&#xff0c;后面会非常折腾。这篇就按照从零开始的实际操作顺序…

作者头像 李华