GoDm@'s Blog

FreeRTOS的内部机制3

版权信息

warning

本文章为博主原创文章。遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。


1. 甘、文、崔:三个重要的中断

在FreeRTOS中,使用三个宏重写了中断,他们一般定义在与硬件相关的 port.c 中。

例如 /portable/GCC/ARM_CM4F/port.c

注意还有一个文件夹叫 /portable/GCC/ARM_CM4_MPU/port.c。这是带内存保护单元MPU的版本,由于添加了内存保护功能,实现较为复杂,上下文开销也大。一般用在对安全性要求极高的产品上,比如医疗和车规级产品。一般产品不建议使用。

void xPortPendSVHandler( void ) __attribute__( ( naked ) );
void xPortSysTickHandler( void );
void vPortSVCHandler( void ) __attribute__( ( naked ) );

__attribute__( ( naked ) ) 告知编译器这个函数是“裸体”的,不要给他“穿衣服”(即给他添加额外的汇编代码)

1.1 SVC_Handler

—— Supervisor Call,系统服务调用。

它在RTOS中仅被调用一次,用来启动第一个任务。

芯片复位上电,默认运行在特权模式下,并且使用的是 MSP(主栈指针)。(我们在1里讲的双栈模式)但我们前面说过,RTOS 的任务必须使用自己的私有栈,也就是必须切换到 PSP(进程栈指针) 去运行。

标准做法就是使用SVC。省流:vTaskStartScheduler() 通过触发 svc 0 指令进入到了SVC中断。

如果对具体的调用链感兴趣可点击展开查看细节,篇幅原因折叠了
void vTaskStartScheduler( void )
{
	BaseType_t xReturn;
	xReturn = prvCreateIdleTasks();
	
	#if ( configUSE_TIMERS == 1 )
    {
        if( xReturn == pdPASS )
        {
            xReturn = xTimerCreateTimerTask();
        }
        else
        {
            mtCOVERAGE_TEST_MARKER();
        }
    }
    #endif /* configUSE_TIMERS */
    /* The return value for xPortStartScheduler is not required
         * hence using a void datatype. */
        ( void ) xPortStartScheduler();
}

可以看出,开始调度函数一般就做这些事:

  1. 创建空闲任务
  2. 如果使用了定时器,则创建定时器守护函数
  3. 调用 xPortStartScheduler(),这个函数又与硬件强相关

我们来看 /portable/GCC/ARM_CM4F/port.c 中的 xPortStartScheduler()

...
/* Start the first task. */
    prvPortStartFirstTask();
...

再看 prvPortStartFirstTask(),可以看到已经到汇编了,最重点的就是 svc 0 这个指令,直接触发了SVC中断。

static void prvPortStartFirstTask( void )
{
    /* Start the first task.  This also clears the bit that indicates the FPU is
     * in use in case the FPU was used before the scheduler was started - which
     * would otherwise result in the unnecessary leaving of space in the SVC stack
     * for lazy saving of FPU registers. */
    __asm volatile (
        " ldr r0, =0xE000ED08   \n" /* Use the NVIC offset register to locate the stack. */
        " ldr r0, [r0]          \n"
        " ldr r0, [r0]          \n"
        " msr msp, r0           \n" /* Set the msp back to the start of the stack. */
        " mov r0, #0            \n" /* Clear the bit that indicates the FPU is in use, see comment above. */
        " msr control, r0       \n"
        " cpsie i               \n" /* Globally enable interrupts. */
        " cpsie f               \n"
        " dsb                   \n"
        " isb                   \n"
        " svc 0                 \n" /* System call to start first task. */
        " nop                   \n"
        " .ltorg                \n"
        );
}

那在SVC中断里具体做什么呢?我们查看SVC回调函数的实现细节:

void vPortSVCHandler( void ) __attribute__ (( naked ));
void vPortSVCHandler( void )
{
    __asm volatile (
        /* 1. 找到当前最高优先级任务的 TCB 指针 */
        "   ldr r3, pxCurrentTCBConst2      \n" /* r3 = &pxCurrentTCB (获取指针变量本身的物理地址) */
        "   ldr r1, [r3]                    \n" /* r1 = pxCurrentTCB (解引用,拿到当前 TCB 的首地址) */
        
        /* 2. 读取 TCB 的第一个成员:pxTopOfStack */
        "   ldr r0, [r1]                    \n" /* r0 = TCB的首个4字节数据,即之前伪造好的栈顶指针 */
        
        /* 3. 手动出栈:恢复属于软件管理的寄存器 (R4-R11) 以及 EXC_RETURN (R14) */
        "   ldmia r0!, {r4-r11, r14}        \n" /* 从 r0 指向的栈内存中,连续弹出数据到 r4-r11 和 r14。 */
        
        /* 4. 移交栈指针给硬件 PSP */
        "   msr psp, r0                     \n" /* 将 r0 赋值给 CPU 的进程栈指针 (PSP) */
        "   isb                             \n" /* 指令同步屏障:清空流水线,强制 CPU 确认 PSP 已经更新 */
        
        /* 5. 打开中断总开关 */
        "   mov r0, #0                      \n" 
        "   msr basepri, r0                 \n" /* 清零 BASEPRI 寄存器,允许所有优先级的中断触发 */
        
        /* 6. 神奇的异空间跳跃:触发硬件自动出栈 */
        "   bx r14                          \n" /* 跳转到 r14 (LR) 寄存器里的特殊地址,触发中断返回 */
        "                                   \n"
        "   .align 4                        \n"
        "pxCurrentTCBConst2: .word pxCurrentTCB \n"
    );
}

简单来说,就是做了这么几件事:

新入栈的元素总是在栈顶,出栈总是栈顶元素出,是曰“后进先出”。

一些细节的说明:

  1. 对于汇编指令的含义,这里就不多赘述。
  2. 关于硬件自动出栈,我也没太搞明白,我只知道R14里的0xFFFFFFFD是有特殊含义的,当 CPU 执行到最后一句 bx r14 时,它发现 R14 里面装的是 0xFFFFFFFD ,会知道要使用PSP而且会硬件层面出栈。

至此,第一个任务的运行现场已准备就绪,开始运行了!

1.2 SysTick_Handler

——决策者。

SysTick 是 RTOS 的心跳,通常每 1 毫秒触发一次。它的核心职责是处理时间逻辑做出调度决策,但绝对不亲自执行上下文切换。来看看其核心代码:

void SysTick_Handler(void)
/*The SysTick runs at the lowest interrupt priority*/
{
    // 1. 屏蔽中断,保护 OS 内部数据结构
    uint32_t ulPreviousMask = portSET_INTERRUPT_MASK_FROM_ISR();

    // 2. 更新系统时间 (xTickCount++),检查有没有延时到期的任务
    if( xTaskIncrementTick() != pdFALSE )
    {
        //向硬件寄存器(ICSR)的特定位写 1,请求触发 PendSV 中断。
        portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT; 
    }

    // 4. 恢复中断屏蔽
    portCLEAR_INTERRUPT_MASK_FROM_ISR( ulPreviousMask );
}

就一个核心函数 xTaskIncrementTick() 做了很多事:

  1. 维持系统时间: 每次触发,将 FreeRTOS 的全局系统节拍计数器(xTickCount)加 1。

  2. 唤醒阻塞任务: 检查 pxDelayedTaskList(延时链表)。如果有任务的等待时间到了(比如 vTaskDelay(100) 结束,或者等待的超时时间到了),SysTick 负责把它从阻塞链表移到就绪链表(pxReadyTasksLists)。

  3. 时间片轮转裁决: 如果开启了时间片,SysTick 会检查当前运行任务的同优先级下,是否有其他任务也在就绪状态。如果有,说明当前任务的“1 毫秒配额”用完了,该换人了。

这个函数比较长,我们还是看一些核心的部分:

1. 增加计时器值,以及判断时钟是否溢出,若溢出则切换阻塞链表指针(第2节讲的)的部分
/* Tick increment should occur on every kernel timer event. Core 0 has the
     * responsibility to increment the tick, or increment the pended ticks if the
     * scheduler is suspended.  If pended ticks is greater than zero, the core that
     * calls xTaskResumeAll has the responsibility to increment the tick. */
    if( uxSchedulerSuspended == ( UBaseType_t ) 0U )
    {
        /* Minor optimisation.  The tick count cannot change in this
         * block. */  //1,增加计数
        const TickType_t xConstTickCount = xTickCount + ( TickType_t ) 1;

        /* Increment the RTOS tick, switching the delayed and overflowed
         * delayed lists if it wraps to 0. */
        xTickCount = xConstTickCount;

        if( xConstTickCount == ( TickType_t ) 0U )
        {
            taskSWITCH_DELAYED_LISTS();//2,切换链表指针
        }
        else
        {
            mtCOVERAGE_TEST_MARKER();
        }
2. 唤醒阻塞任务的部分
/* See if this tick has made a timeout expire.  Tasks are stored in
        * the  queue in the order of their wake time - meaning once one task
        * has been found whose block time has not expired there is no need to
        * look any further down the list. */
       if( xConstTickCount >= xNextTaskUnblockTime )
       {
           for( ; ; )
           {
               if( listLIST_IS_EMPTY( pxDelayedTaskList ) != pdFALSE )
               {
                   /* The delayed list is empty.  Set xNextTaskUnblockTime
                    * to the maximum possible value so it is extremely
                    * unlikely that the
                    * if( xTickCount >= xNextTaskUnblockTime ) test will pass
                    * next time through. */
                      //如果没有阻塞任务,则将其设为最大值,以便不进入大if判断中,而这个xNextTaskUnblockTime是个全局变量,有阻塞任务时会更新
                   xNextTaskUnblockTime = portMAX_DELAY;
                   break;
               }
               else
               {
                   /* The delayed list is not empty, get the value of the
                    * item at the head of the delayed list.  This is the time
                    * at which the task at the head of the delayed list must
                    * be removed from the Blocked state. */
                   /* MISRA Ref 11.5.3 [Void pointer assignment] */
                   /* More details at: https://github.com/FreeRTOS/FreeRTOS-Kernel/blob/main/MISRA.md#rule-115 */
                   /* coverity[misra_c_2012_rule_11_5_violation] */
                   pxTCB = listGET_OWNER_OF_HEAD_ENTRY( pxDelayedTaskList );
                   xItemValue = listGET_LIST_ITEM_VALUE( &( pxTCB->xStateListItem ) );
                   
                   if( xConstTickCount < xItemValue )
                   {
                       /* It is not time to unblock this item yet, but the
                        * item value is the time at which the task at the head
                        * of the blocked list must be removed from the Blocked
                        * state -  so record the item value in
                        * xNextTaskUnblockTime. */
                          //记录所有任务中离就绪时间最短的
                       xNextTaskUnblockTime = xItemValue;
                       break;
                   }
                   else
                   {
                       mtCOVERAGE_TEST_MARKER();
                   }

                   /* It is time to remove the item from the Blocked state. */
                   listREMOVE_ITEM( &( pxTCB->xStateListItem ) );

                   /* Is the task waiting on an event also?  If so remove
                    * it from the event list. */
                   if( listLIST_ITEM_CONTAINER( &( pxTCB->xEventListItem ) ) != NULL )
                   {
                       listREMOVE_ITEM( &( pxTCB->xEventListItem ) );
                   }
                   else
                   {
                       mtCOVERAGE_TEST_MARKER();
                   }

                   /* Place the unblocked task into the appropriate ready
                    * list. */
                      //加入就绪链表
                   prvAddTaskToReadyList( pxTCB );
3. 同优先级的时间片轮询
/* Tasks of equal priority to the currently running task will share
         * processing time (time slice) if preemption is on, and the application
         * writer has not explicitly turned time slicing off. */
        #if ( ( configUSE_PREEMPTION == 1 ) && ( configUSE_TIME_SLICING == 1 ) )
        //本质就是判断链表长度嘛
        {
            #if ( configNUMBER_OF_CORES == 1 )
            {
                if( listCURRENT_LIST_LENGTH( &( pxReadyTasksLists[ pxCurrentTCB->uxPriority ] ) ) > 1U )
                {
                    xSwitchRequired = pdTRUE;
                }
                else
                {
                    mtCOVERAGE_TEST_MARKER();
                }
            }

最后:

1.3 ⭐PendSV_Handler

——可悬起的系统调用。上下文切换的核心

PendSV(可悬起的系统调用),它是一种CPU系统级别的异常,它可以像普通外设中断一样被悬起,而不会像SVC服务那样,因为没有及时响应处理,而触发Fault。所以PendSV的最大特点就是,它是系统级别的异常,但它又支持挂起。

here are some question,hope they can help you understand PendSV better:

课后小科普:什么是中断丢失?
NVIC挂起寄存器(ISPR)中,每一个中断源只有 1 个 Bit 的记录空间。所以该寄存器只能记录“谁来找过”,不能记录“来了几次”。所以某个中断被挂起期间,无论这个中断又触发了几次,最后恢复的时候也只会执行一次,除了最后一次其余中断全部丢失了。避免这种情况的方法就是遵循中断“快进快出”原则。

要想复刻一个RTOS,PendSV的实现是不得不品的一环。关于PendSV的源码,我们下一章继续!

2. 调度的本质是链表操作

在上一节我们已经略微领悟到了三个中断的妙处,可以说是从微观角度来理解RTOS,下面我们从宏观角度继续看看RTOS的调度。正如本章标题所说,调度的本质就是链表操作,我们结合具体的场景来看看调度是怎么回事。

首先,在 FreeRTOS 中,所有的任务在任何时刻都必然存在于以下三种链表之一:

  1. 就绪链表数组 (pxReadyTasksLists):随时准备运行的任务。
  2. 阻塞/延时链表 (xDelayedTaskList):在睡觉,或者在等某个事件的任务。
  3. 挂起链表 (xPendingReadyList / xSuspendedTaskList):被强制暂停的特殊情况。

场景一:同级任务的时间片轮转

触发时机:硬件的 SysTick 定时器(比如每 1ms)产生一次中断。

链表操作

  1. 硬件中断打断当前运行的 Task A。
  2. 系统进入中断服务函数,调用内核的 xTaskIncrementTick(),将全局时间 xTickCount 加 1。
  3. 检查当前优先级下,是否还有其他就绪任务?如果有,触发底层 PendSV 异常
  4. 在切换函数 vTaskSwitchContext 中,调用 listGET_OWNER_OF_NEXT_ENTRY
  5. 核心动作:将 pxReadyTasksLists[TaskA的优先级] 这个链表的 pxIndex 游标往后移动一格,获取到 Task B 的 TCB。
  6. 将全局指针 pxCurrentTCB 指向 Task B。完成切换!

时间片轮转的实现方法,可以类比排队打饭,打完饭的人还想打,需要到队尾去排队。任务a运行1个时钟,就被移到队尾,让下一个任务运行,该任务又运行1个时钟,也被移到队尾,以此类推,循环往复。而我们的任务链表是环形的,它不需要执行耗时的“拔出再插入”操作,只是遍历环形链表就可以达到这种目的。还记得我们在 List_t 结构体里看到的那个 pxIndex(游标指针)吗?想想你是怎么遍历环形链表的?pxIndex = pxIndex->pxNext。游标往后挪一格,下一个任务自然就变成了队伍的第一个。

场景二:任务主动“睡觉” (调用 vTaskDelay)

触发时机:Task A 运行到一半,调用了 vTaskDelay(100),想休息 100 个 tick。 链表操作

  1. 计算唤醒时间:系统把当前的 xTickCount + 100,算出一个“闹钟时间”(ItemValue),存入 Task A TCB 的状态钩子 xStateListItem.xItemValue 中。
  2. 拔出:把 Task A 从它所在的就绪链表 pxReadyTasksListsList_Remove 拔出来。
  3. 插入等待:把 Task A 插入到延时链表 xDelayedTaskList 中。注意:延时链表是按唤醒时间升序排列的! 最早醒来的任务永远排在链表最前面。(这就是 vListInsert 按值插入的作用)。
  4. 让出 CPU:Task A 调用 portYIELD(),主动触发上下文切换。调度器会在剩下的就绪任务里挑一个最高优先级的去运行。

场景三:闹钟响了,任务唤醒 (滴答中断触发)

触发时机:又是 SysTick 中断。 链表操作

  1. xTaskIncrementTick() 除了增加时间,还会死死盯着 xDelayedTaskList第一个节点(因为它是最早醒的)。
  2. 判断第一个节点的闹钟时间(xItemValue)是否等于或者小于当前的 xTickCount
  3. 如果时间到了(比如 Task A 睡够了 100 毫秒):
    • 拔出:把它从 xDelayedTaskList 拔出。
    • 插回:重新把它插回对应优先级的 pxReadyTasksLists 的尾部。
  4. 抢占判定:如果刚刚醒来的 Task A 的优先级,大于或等于当前正在运行的 Task B 的优先级,立马触发上下文切换!Task B 瞬间被挂起,Task A 抢回 CPU。

共计约3.8k字。于2026/03/26首次发布,最后更新于2026/03/28。

本文章为博主原创文章。遵循 CC 4.0 BY-SA 版权协议,转载请附上原文出处链接和本声明。

  1. 1. 甘、文、崔:三个重要的中断
    1. 1.1 SVC_Handler
    2. 1.2 SysTick_Handler
    3. 1.3 ⭐PendSV_Handler
  2. 2. 调度的本质是链表操作
    1. 场景一:同级任务的时间片轮转
    2. 场景二:任务主动“睡觉” (调用 vTaskDelay)
    3. 场景三:闹钟响了,任务唤醒 (滴答中断触发)