背景知识

  1. 并行和并发
    并行处理是指同时执行多个任务或操作。是把多个任务分别交给了不同的CPU核心去做
    并发处理是指多个任务通过调度算法实现任务的交替运行,同一时刻只有一个任务在运行,是在模拟"同时执行",不是真正的同时执行
  2. 线程和进程
    进程是CPU分配资源的最小单位,每个进程拥有自己的地址空间,内存和其他系统资源,进程间相互独立
    线程是CPU调度的最小单位,一个进程可以拥有多个线程但他们共享进程的地址空间和资源,线程可以执行多个任务实现并发处理
    • 为什么线程切换开销小?
      因为进程切换的时候需要把虚拟地址转换为物理地址,此时会导致页表,cache等内容的变化,但是线程切换的时候是共享进程的地址空间的
  3. 任务/进程的描述
  • FreeRTOS对于任务的描述—TCB
      FreeRTOS通过任务控制块struct TCB来保存任务达的属性信息,这样就可以通过操作TCB来操作任务了
    • 任务栈的起始地址,大小
    • 任务的优先级
      任务的状态等等
    • 剩余时间片
  • Linux对于进程/线程的描述–task_struct结构体
       Linux对于进程和线程都是用task_struct结构体表示,区别就是在初始化成员变量上
    • 进程/线程区别
        在task_struct中有一个重要的结构体 mm_struct 你可以理解为管理进程的地址空间的
      • 假设是创建一个线程的时候 只是进行了浅拷贝(拷贝了这个指针的值),增加了引用计数
      • 但是创建一个进程就不一样 是深拷贝 是对这片内存所有的东西都全拷贝了
  1. 任务/进程的状态
  • FreeRTOS的任务状态
    FreeRTOS的任务状态有:就绪态,运行态,阻塞态,挂起态,也有结束态
    在这里插入图片描述

    阻塞和挂起的区别:挂起之后调度器不可见,不参与调度;阻塞态在条件满足时恢复就绪态就可以调度了

  • Linux的进程状态
    Linux的进程状态有: R(运行) R(就绪) // T(暂停) / S(可中断睡眠) / D(不可中断睡眠) / Z(僵尸) / D(死亡)
    在这里插入图片描述

    • 孤儿进程与僵尸进程
      • 孤儿进程: 子进程还在运行 父进程提前结束了–init线程作为继父处理
      • 僵尸进程: 子进程在运行结束后,调用exit()进行资源的回收,同时留下一个叫做僵尸进程(Zombie)的数据结构,等待父线程回收,假设父线程不处理那完蛋了收不了,如果父线程早死了就由init线程来回收
      • 僵尸进程危害: 占用进程号 无法创建新进程 此时只能通过kill命令由我们手动杀死
      • 如何避免: 父线程为SIGCHLD信号创建信号处理函数
  • Linux的线程状态
    新建/就绪/运行/阻塞/结束

FreeRTOS的调度

  1. 总述概括
    通过FreeRTOSConfig.h可以配置三种调度模式
    • 协作式调度
      只有当前任务主动放弃CPU 其它任务(优先级最高的)才有执行的机会
    • 时间片轮转
      对于优先级相同的任务 每个任务运行相同时间片后切换下个任务;此时如果有人优先级低了那就得等了,等优先级高的挂起–时间片的大小可以通过宏来配置
    • 抢占式调度
      一旦高优先级的任务准备就绪,就可以抢占低优先级任务的运行,此时也开启时间片轮转 相同优先级的按时间片处理
  2. FreeRTOS的调度器
    与其说是调度器 不如说是切换任务的时机
    任务切换的核心是这样一段代码
        /* A context switch is required.  Context switching is performed in
        * the PendSV interrupt.  Pend the PendSV interrupt. */
        portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
    
    这段代码的作用就是软件触发了PendSV异常 从而会在合适的时机调用PendSVHandler进行任务切换,那么都会在什么情况下会触发代码的运行呢?
    • SysTickHandler
          if( xTaskIncrementTick() != pdFALSE )
          {
              /* A context switch is required.  Context switching is performed in
              * the PendSV interrupt.  Pend the PendSV interrupt. */
              portNVIC_INT_CTRL_REG = portNVIC_PENDSVSET_BIT;
          }
      
      大概讲讲这个函数做了什么:检查延时任务列表,如果有延时时间到的则把任务假如就绪列表,如果发现满足任务切换的条件就返回真
    • 任务的状态发生改变–portYIELD_WITHIN_API();
      portYIELD_WITHIN_API()本质也是触发PendSV异常
      比如当前正在运行的任务被挂起了/时间片耗尽了(运行态->挂起/就绪态),某些事件发生导致阻塞了(运行态->阻塞态),阻塞的条件满足了(阻塞态->就绪态)
  3. 任务切换的核心–PendSV函数
  • 什么是任务切换
       任务切换就是保存上个任务的运行现场,然后切换到下个任务的运行现场;只有每个任务使用自己单独的栈,才能实现多任务并发运行的效果,同时这个过程PSP指针也要发生切换(MSP不用 MSP是供handler模式使用的)

  • ARM的异常处理机制
       所谓保存运行现场,不过就是一堆寄存器的值 + 当前的栈,只要能恢复/保存寄存器的值就行。我们在任务切换/中断发生的时候,实际上触发了ARM的异常处理切换机制:ARM会自动的帮你把R0-R4,R12,LR等寄存器压入当前栈,所以我们要做的是把剩下的R4-R11寄存器也压入栈中***(如果启用硬件浮点数计算 则需要额外保存S16-S31寄存器,S1-S15会自动入栈)***

  • 中断服务函数与栈帧
        聪明的你可能会想到,虽然没设计过RTOS,但是我用过中断服务函数呀!那R4-R11没入栈的话,我即使是裸机难道也没在中断服务函数中操作过R4-R11寄存器吗?
       正确答案是:如果是C语言写的中断服务函数,编译器在生成汇编时会自动的把用到的R4-R11寄存器压入栈中,如果是纯汇编自己写的中断服务函数,就要小心了要自行保存好R4-R11寄存器前面介绍栈帧的时候提到过这个
    在这里插入图片描述

  • PendSV与任务切换
    非常推荐这篇博客,受益匪浅:为什么需要PendSV

    • PendSV是什么?
        PendSV(可悬起的系统调用),它是一种CPU系统级别的异常,它可以像普通外设中断一样被悬起,而不会像SVC服务那样,因为没有及时响应处理,而触发Fault。

    • 为什么要使用PendSV异常而不是其它异常/中断?

      1. PendSV的异常优先级是可编程的
      2. PendSV异常不用及时响应,可以被挂起
    • 问题一: 只有sysTickHandler没有PendSV行吗
        假设我们把任务切换的逻辑放在sysTickHandler中,而完全不用PendSV可行吗?
        结论: sysTickHandler优先级如果是最低可以满足部分需要,但远远不够。
        不管代码怎么写,任务的切换最终是要返回线程模式(但是触发任务切换可能是任意状态)
      在这里插入图片描述

       &emsp 如果sysTickHandler写为最低,任务切换没问题了,但是会使得对外部中断响应速度变慢

    • 问题二:PendSV和SystickHandler的优先级问题
      结论:PendSV的优先级必须是最低的 sysTickHandler的优先级可高可低

    • FreeRTOS中PendSV的实现

    __asm void xPortPendSVHandler( void )
    {
        extern uxCriticalNesting;
        extern pxCurrentTCB;
        extern vTaskSwitchContext;
    
    /* *INDENT-OFF* */
        PRESERVE8
    
        mrs r0, psp
        isb
        /* Get the location of the current TCB. */
        ldr r3, =pxCurrentTCB
        ldr r2, [ r3 ]
    
        /* Is the task using the FPU context?  If so, push high vfp registers. */
        tst r14, #0x10
        it eq
        vstmdbeq r0!, {s16-s31}
    
        /* Save the core registers. */
        stmdb r0!, {r4-r11, r14}
    
        /* Save the new top of stack into the first member of the TCB. */
        str r0, [ r2 ]
    
        stmdb sp!, {r0, r3}
        mov r0, #configMAX_SYSCALL_INTERRUPT_PRIORITY
        msr basepri, r0
        dsb
        isb
        bl vTaskSwitchContext
        mov r0, #0
        msr basepri, r0
        ldmia sp!, {r0, r3}
    
        /* The first item in pxCurrentTCB is the task top of stack. */
        ldr r1, [ r3 ]
        ldr r0, [ r1 ]
    
        /* Pop the core registers. */
        ldmia r0!, {r4-r11, r14}
    
        /* Is the task using the FPU context?  If so, pop the high vfp registers
        * too. */
        tst r14, #0x10  //这个就是通过LR的值判断等会是不是回到线程模式
        it eq
        vldmiaeq r0!, {s16-s31}
    
        msr psp, r0
        isb
        #ifdef WORKAROUND_PMU_CM001 /* XMC4000 specific errata */
            #if WORKAROUND_PMU_CM001 == 1
                push { r14 }
                pop { pc }
                nop
            #endif
        #endif
    
        bx r14 //最终返回下个任务了 PSP的值已经被修改了
    /* *INDENT-ON* */
    }
    
    • vTaskSwitchContext
      就是通过位图找到了当前就绪任务列表中最高优先级的任务
    • PendSV被打断了怎么办?
        无所谓! 假设PendSV正在运行,被其他中断打断了,注意现在可是用的MSP指针,就当做一个中断嵌套就行,既然是函数嵌套调用就是前面讲过的栈帧了,嵌套调用结束后恢复完现场就没事啦,PendSV操作的是PSP(除非新的中断服务函数自己汇编写的忘了什么)
  1. 切换到第一个任务–prvPortStartFirstTask
    • 主要是靠SVCHandler来实现的
      过程如下:
      复位MSP指针–>关闭所有中断->触发SVC异常–>进入SVC异常–>找到当前任务的堆栈–>设置PSP指针的值–>打开所有的中断–>进入第一个任务
    • 为什么要加载寄存器的值呢?–ldmia r0!, {r4-r11, r14}
      因为LR的值决定了我们返回的时候能不能返回到线程状态(之前介绍过LR寄存器)
      so 猜猜初始化的时候LR寄存器的值是多少呢?-- portINITIAL_EXC_RETURN
      要不然可能咋跳转到PSP指针指向的堆栈

    void vPortSVCHandler( void )
    {
        __asm volatile (
                        "	ldr	r3, =pxCurrentTCB		    \n" /*   (1) Restore the context. */
                        "	ldr r1, [r3]					\n" /*   (2)Use pxCurrentTCBConst to get the pxCurrentTCB address. */
                        "	ldr r0, [r1]					\n" /*   (3)The first item in pxCurrentTCB is the task top of stack. */
                        "	ldmia r0!, {r4-r11, r14}		\n" /*   (4) Pop the registers that are not automatically saved on exception entry and the critical nesting count. */
                        "	msr psp, r0						\n" /*   (5)Restore the task stack pointer. */
                        "	isb								\n" //   (5)
                        "	mov r0, #0 						\n" //   (6)
                        "	msr	basepri, r0					\n" //   (7)
                        "	bx r14							\n" //   (8)
                        "									\n"
                        "	.align 4						\n" //   (9)
    
                    );
    }
  1. 为什么FreeRTOS要有空闲任务?
    • 如果没有空闲任务 RTOS将很难做任务切换了–找不到下个就绪的任务
    • 空闲任务可以用来做一些统计/日志/钩子函数的操作
    • 空闲任务可以用来进入低功耗模式
  2. 手写RTOS这块的实现
    之前做的这个手写RTOS的项目比较简单 有好也有坏
  • 进入第一个任务–判断PendSV中PSP的值
void tTaskRunFirst()
{
    // 这里设置了一个标记,PSP = 0, 用于与tTaskSwitch()区分,用于在PEND_SV
    // 中判断当前切换是tinyOS启动时切换至第1个任务,还是多任务已经跑起来后执行的切换
    __set_PSP(0);

    MEM8(NVIC_SYSPRI2) = NVIC_PENDSV_PRI;   // 向NVIC_SYSPRI2写NVIC_PENDSV_PRI,设置其为最低优先级
    
    MEM32(NVIC_INT_CTRL) = NVIC_PENDSVSET;    // 向NVIC_INT_CTRL写NVIC_PENDSVSET,用于PendSV

    // 可以看到,这个函数是没有返回
    // 这是因为,一旦触发PendSV后,将会在PendSV后立即进行任务切换,切换至第1个任务运行
    // 此后,tinyOS将负责管理所有任务的运行,永远不会返回到该函数运行
}
__asm void PendSV_Handler ()
{   
    IMPORT  currentTask               // 使用import导入C文件中声明的全局变量
    IMPORT  nextTask                  // 类似于在C文文件中使用extern int variable
    
    MRS     R0, PSP                   // 获取当前任务的堆栈指针
    CBZ     R0, PendSVHandler_nosave  // if 这是由tTaskSwitch触发的(此时,PSP肯定不会是0了,0的话必定是tTaskRunFirst)触发
                                      // 不清楚的话,可以先看tTaskRunFirst和tTaskSwitch的实现
    STMDB   R0!, {R4-R11}             //     那么,我们需要将除异常自动保存的寄存器这外的其它寄存器自动保存起来{R4, R11}
                                      //     保存的地址是当前任务的PSP堆栈中,这样就完整的保存了必要的CPU寄存器,便于下次恢复
    LDR     R1, =currentTask          //     保存好后,将最后的堆栈顶位置,保存到currentTask->stack处    
    LDR     R1, [R1]                  //     由于stack处在结构体stack处的开始位置处,显然currentTask和stack在内存中的起始
    STR     R0, [R1]                  //     地址是一样的,这么做不会有任何问题

PendSVHandler_nosave                  // 无论是tTaskSwitch和tTaskSwitch触发的,最后都要从下一个要运行的任务的堆栈中恢复
                                      // CPU寄存器,然后切换至该任务中运行
    LDR     R0, =currentTask          // 好了,准备切换了
    LDR     R1, =nextTask             
    LDR     R2, [R1]  
    STR     R2, [R0]                  // 先将currentTask设置为nextTask,也就是下一任务变成了当前任务
 
    LDR     R0, [R2]                  // 然后,从currentTask中加载stack,这样好知道从哪个位置取出CPU寄存器恢复运行
    LDMIA   R0!, {R4-R11}             // 恢复{R4, R11}。为什么只恢复了这么点,因为其余在退出PendSV时,硬件自动恢复

    MSR     PSP, R0                   // 最后,恢复真正的堆栈指针到PSP  
    ORR     LR, LR, #0x04             // 标记下返回标记,指明在退出LR时,切换到PSP堆栈中(PendSV使用的是MSP) 
    BX      LR                        // 最后返回,此时任务就会从堆栈中取出LR值,恢复到上次运行的位置
}  
  • taskSched()函数
    通过位图判断就绪的最高优先级的任务
    但是没有放在PendSV中
  • sysTickHandler函数
    • 处于延时状态的列表的任务状态更新
    • 软件定时器的相关操作

Linux的调度

这玩意可别指望我能三言两语讲清楚了,就当科普一下了

  • 进程的分类
    进程可以分为实时进程与普通进程,这两种进程的调度策略不一样,如果发现存在实时进程则调度器优先按照实时调度策略调度实时进程
  • 实时调度策略–针对实时进程
    实时线程的优先级数值越大,优先级越高
    • SCHED_FIFO 调度
      只要进程在运行 除非阻塞/手动释放/更高优先级进程准备好才能抢占,否则当前线程不放弃CPU
    • SCHED_RR
      SCHED_RR = SCHED_FIFO + 同级时间片轮转机制
    • 软实时特点–实时性与非实时性
      实际上 SCHED_RR从字面上看 已经和RTOS常用的调度方式没有任何区别了,但是我们要强调由于Linux内核的特性,提供不了RTOS那样的硬实时,只是一个"软实时"
      RTOS最大的特点就是实时性–对于外部事件的响应时间是确定的,可预测的
      但是Linux内核的不可抢占/中断延迟等特性,无法保证对于中断的及时响应
  • 分时调度策略–CFS
    普通线程的优先级数值越大,优先级反而越低
    一句话:CFS永远选择具有最小vruntime的任务,不是最小实际运行时间
    对于优先级不同的普通进程,优先级越高的进程在实际运行时间折算vruntime的时候越小
  • 相关的命令
    • ps
      显示所有的进程
    • top
      实时显示系统的进程状态,按 CPU 或内存使用率排序
    • kill
      kill + pid 杀死对应的进程
    • pstree
      以树状结构显示线程
Logo

openvela 操作系统专为 AIoT 领域量身定制,以轻量化、标准兼容、安全性和高度可扩展性为核心特点。openvela 以其卓越的技术优势,已成为众多物联网设备和 AI 硬件的技术首选,涵盖了智能手表、运动手环、智能音箱、耳机、智能家居设备以及机器人等多个领域。

更多推荐