2023-06-11
原文作者:奇小葩 原文地址:https://blog.csdn.net/u012489236/category_10946851.html

首先看看维基百科对实时操作系统的定义:

实时操作系统(Real-time operating system, RTOS),又称即时操作系统,它会按照排序运行、管理系统资源,并为开发应用程序提供一致的基础。实时操作系统与一般的操作系统相比,最大的特色就是“实时性”,如果有一个任务需要执行,实时操作系统会马上(在较短时间内)执行该任务,不会有较长的延时。这种特性保证了各个任务的及时执行。

实时操作系统 (Real-time OS) 是相对于分时操作系统 (Time-Sharing OS) 的一个概念。在一个分时操作系统中,计算机资源会被平均地分配给系统内所有的工作。在分时系统中,各项任务需要花多长时间来完成,这一点并不重要。

所以在一 个实时操作系统之中,最关注的是每个任务在多长时间内可以完成。简单地说,实时和分时操作系统最大的不同在于 时限(deadline)”这个概念。 在一个特定任务的执行时间内必须是确定的并且可预测的,在任何情况下都能保证任务的最大执行时间限制,通常实时分为软实时和硬实时。

  • 软实时: 仅仅要求事件的响应是实时的,并不要求任务必须在多长的时间内完成,大多数情况下要求的时统计意义上的实时,而不需要100%达到实时。在许多情况下,这种软性正确性已经达到了用户期望的水平。比如用户在操作DVD播放的时候,偶尔不能在限定的时间内完成任务也是可以接受的,它可以容忍偶然的超时错误,失败造成的后果并不严重
  • 硬实时: 在任务的执行时间的要求是非常严格的,无论在什么情况下,任务的执行时间必须要得到绝对的保证,否则将会产生灾难性的后果。比如,汽车碰撞后,必须在X时间内弹开安全气囊,你弹开晚了,人已经挂了。

1 为什么linux不是硬实时

Linux系统一开始就被按照GPOS(通用操作系统)来设计的,它所追求的是尽量缩短系统的平均响应时间,提高吞吐量,达到更好的平均性能。在这个背景下,Linux无法达到强实时性的因素是多方面的,比如虚拟内存管理、共享资源互斥访问机制等等,但最重要的因素是进程调度以及内核抢占机制,这也是本文讨论的重点。

我们首先来看看,为什么Linux不是一个硬实时的操作系统,其主要有以下几个原因

spinlock是一个随处可见被内核和驱动使用的API

首先,我们来看看spinlock的实现,spin_lock()会调用preempt_disable() 导致本核的 抢占调度 被关闭(preempt_disable函数实际增加preempt_count来达到此效果),其次我们理解spin_lock_irq()是local_irq_disable()+preempt_disable()的合体。

202306111307256621.png

202306111307266582.png

对于两个接口,大家是不是很熟悉,我们在linux内核和驱动程序中随处可见,在不用睡眠,时间较短的临界区的场景,我们都会第一时间想到spinlock。自旋锁的优点是,在两个人(这两个人可能是线程与线程,中断与线程,中断与中断等)同时竞争一个锁的时候,防止出现失败的那一方出现上下文切换,所以希望在原点等待。

但是这样的自旋锁本身也会导致一些副作用,它导致了持有该锁的CPU核的抢占被禁止,所以内核自旋锁的实现,是通过禁止抢占来实现临界区的保护,如下图的例子

202306111307277903.png

假设T1, T2, T3, T4运行在一个核上面,当T1拿到spinlock后,这个核上的抢占调度被禁止

  • 如果在T1持有spinlock的时间内,T2是一个高优先级的实时任务,尽管T2被唤醒,它也不可能立即打断T1的执行,必须等待T1释放spinlock
  • 由于T1究竟会持有spinlock多久做什么,这个鬼都不知道 ,所以T2究竟要等多久,也未可知,这显然破坏了决定性的时延。

202306111307288864.png

  • 假设T0时刻,系统正在执行自旋锁进入临界区,此时在T1时刻,系统唤醒了一个高优先级的任务,而此时抢占调度是关闭的,所以RT实时进程是无法得以调度的
  • 而当中断T2时刻,发生了一个中断,就会导致进入中断处理,这段时间发生了多久,鬼也不知道,当中断处理完后,返回到自旋锁的临界区,当退出临界区时候,会打开内核抢占并且尝试抢占当前的进程,此时RT实时任务将会得以调度,但是此时的延时有太多的不确定性

Linux的中断执行时间可能过长且不可嵌套

当中断发生后,一般硬件会自动屏蔽当前CPU对于中断的响应,而软件层面上,直到IRQ HANDLER做完,才会重新开启中断,比如,对于ARM处理器而言,exception进来的时候,硬件都会自动屏蔽中断

202306111307298855.png

也就是说,当ARM处理器收到中断的时候,它进入中断模式,同时ARM处理器的CPSR寄存器的IRQ位会被硬件设置为屏蔽IRQ。

Linux内核会在如下2个时候重新开启CPSR对IRQ的响应:

  1. 从IRQ HANDLER返回中断底半部的SOFTIRQ
  2. 从IRQ HANDLER返回一个线程上下文

中断在执行的时候,所有的中断都进不来,这个设计本身简化了内核,但是对于硬实时的打击是致命的,前面的中断不执行完成,优先级再高的中断也得给我等着。

202306111307324446.png

比如中断1在执行的过程中,来了中断2,而中断2对应的事情是必须要决定性时延的, 由于IRQ1的中断服务程序也是码农写的,我们无法确定这个中断服务程序要执行多久 。这显然让高优先级中断2的进入延迟不再具备可预期性。

软中断(softirq)是一个比进程上下文优先级更高的上下文

我们设想一个场景,哪怕Linux解决了问题2,就是Linux的中断变地可嵌套,高优先级的中断可以打断低优先级的中断,并且高优先级的中断2唤醒了一个用户写的实时线程。

202306111307331747.png

IRQ2唤醒了实时任务T1,但是T1必须等待IRQ1唤起的软中断(也包括使用软中断上下文的tasklet等)被执行完,T1才能投入执行。 IRQ1唤起的softirq的代码是码农写的,这个码农写多久,鬼都不知道 ,这显然破坏了实时任务T1得以调度执行的确定性时延。

202306111307338278.png

内核里面会屏蔽中断的API如local_irq_disable、spin_lock_irqsave等

那么,问题又来了,spin_lock_irqsave既屏蔽了抢占,又屏蔽了中断,这会导致中断和实时任务的确定性时延造成不可预期的破坏。 因为spin_lock_irqsave和spin_lock_irqrestore是码农写的,鬼都不知道它要多久。

所有针对这个问题,我们回顾linux中的四类区间:

  • 1 中断
  • 2 软中断
  • 3 进程上下文中的spin_lock
  • 4 进程上下文中的其他区域

上述四类区间中,只有第四类区间支持抢占调度。当可以调度的事情发生在前3类区间中,即如果在这3类区间中唤醒了高优先级的可以抢占的task,实际上却不能抢占,直到这3类区间结束。

用一个例子说明如下:

202306111307344019.png

如上图所示:

  • T0时刻Normal task因为syscall陷入内核
  • T1时刻CPU拿到spin lock,进入Critical section
  • T2时刻系统产生中断IRQ1,进入IRQ handler
  • T3时刻系统唤醒了高优先级的RT task,但由于此时系统处于不可调度区域,所以RT task无法立即运行
  • T4时刻IRQ1结束,但接着产生中断IRQ2,进入IRQ handler
  • T5时刻,中断都结束,但spin lock仍然没有释放,系统仍然处于不可调度区域
  • T6时刻,spin lock释放,高优先级的RT task立马得到调度
  • T7时刻RT task运行结束,Normal task再一次被调度到
  • T8时刻从内核态返回

从T1到T6,这个区间的时间是不可预测的,因此通用的Linux系统无法达到硬实时的标准。

2 linux内核实时性改进

早在2001年时,内核就开始打上抢占补丁,回顾之前的知识点

  • 如果linux内核不支持抢占,那么进程要么主动要求调度,入调用schedule()或者cond_resched()等,要么在系统调用、异常处理和中断处理完成后返回用户空间强进行调度,上述都会导致早期linux内核调度时延非常大
  • 如果linux内核支持抢占,如果唤醒动作发生在系统调用或者异常处理上下文,在下一次调用preempt_enable时会检查调度标志位,是否需要抢占调度。同时对于中断处理返回内核态,也会检查调度,而preempt_enable函数会主动调用__preempt_schedule来判断是否需要抢占当前的进程。

2023061113073697310.png

主要是用thread_info数据结构中的一个preempt_count计数

2023061113073779111.png

  • 当preempt_count为0时,表示内核可以被抢占
  • 当preempt_count大于0时,则禁止抢占

preempt_count包含preempt, softirq,hardirq,nim以及need_resched几个域

内核提供preempt_disable来关闭抢占,之后preempt_count会加1,preempt_enable函数用于打开抢占,preempt_count计数会减1,程序会判断当前是否为0,如果为0,就调用___preempt_schedule

preempt_count本质上是一个per-CPU的32位变量,它在各种处理器架构下的存放位置和命名不尽相同,但其值都可以使用preempt_count()函数统一获取。preempt_count逻辑相关的核心代码位于include/linux/preempt.h,虽然只是一个32位变量,但由于其和中断、调度/抢占密切相关,因此在系统中发挥的作用不容小觑。

2023061113073850412.png

  • preemption count占8个bits,因此一共可以表示最多256层调度关闭的嵌套
  • preempt_count中的第8到15个bit表示softirq count,它记录了进入softirq的嵌套次数,如果softirq count的值为正数,说明现在正处于softirq上下文中。由于softirq在单个CPU上是不会嵌套执行的,因此和hardirq count一样,实际只需要一个bit(bit 8)就可以了。但这里多出的7个bits并不是因为历史原因多出来的,而是另有他用。
  • preempt_count中的第16到19个bit表示hardirq count,它记录了进入hardirq/top half的嵌套次数,在do_IRQ()中,irq_enter()用于标记hardirq的进入,此时hardirq count的值会加1。irq_exit()用于标记hardirq的退出,hardirq count的值会相应的减1。如果hardirq count的值为正数,说明现在正处于hardirq上下文中,代码中可借助**in_irq()**宏实现快速判断。注意这里的命名是"in_irq"而不是"in_hardirq"。

对于内核仅仅的支持抢占调度,要达到硬实时系统的要求还远远的达不到要求,为此社区一直致力于linux内核实时性优化和改进工作,最近几年有很多优化的补丁和方法,

PREEMPT_RT补丁可以通过以下方面对kernel进行源码级的改造:

  • spinlock迁移为可调度的mutex
  • 中断线程化
  • 软中断线程化

从而将Linux内核中的1/2/3类区间都改造成4类区间,大大提高了系统的实时性。对于linux也提供了很多工具用于检查哪些地方有比较大的调度延时

  • 对于可以通过工具cyclictest工具来测试Linux实时性能。该工具的使用方法已经安装方法这里不做过多介绍。
  • ftrace工具是一个很多好的追踪跟踪器,如preemptirqsoff跟踪器可以跟踪中断并禁止抢占代码的延迟,同时记录关闭的最大时延
  • linux内核还继承了latencytop工具,它在内核上下文切换时被记录进程的内核栈,然后通过匹配内核栈函数来判断导致上下文奇幻的原因。这个不仅方便判断系统出现了哪些方面的延时,还有助于查看某个进程或线程的延迟情况。详细的用法见https://blog.yufeng.info/archives/1239

参考文档

https://blog.csdn.net/juS3Ve/article/details/81437432

http://howar.cn/2020/03/15/embedded-linux-real-time/

阅读全文