Linux为什么不是实时操作系统?
从我们接触Linux系统开始,一直听到的都是它是非实时操作系统,怎么理解这个非实时呢?
我的理解,非实时,就是中断响应不及时,任务调度不及时。那么,真的是这样吗?下面先了解一下,Linux中断响应,是怎样的一个处理流程。
Linux是如何响应中断
ARM中断流程
ARM处理器的中断处理过程,可以分为以下几个步骤:
- 中断请求:外部设备或软件可以向ARM处理器发送中断请求信号,以通知处理器有需要处理的事件发生。中断请求可以是硬件中断(如外部设备的输入触发中断)或软件中断(通过软件指令触发中断)。
- 中断检测:ARM处理器会周期性地检测中断请求是否发生。这个过程通常在每个指令周期的某个时刻进行,被称为中断检测阶段。如果检测到中断请求,处理器将进入中断处理流程。
- 中断响应:当ARM处理器检测到中断请求时,它会保存当前的执行状态,并跳转到中断服务例程(ISR,Interrupt Service Routine)的入口地址。
- 中断处理:进入中断服务例程后,ARM处理器将执行特定的中断处理代码,以完成对中断事件的处理。中断服务例程通常包括保存现场、处理中断事件、恢复现场等步骤。
- 中断返回:在中断处理完成后,ARM处理器会从中断服务例程返回到原来的执行状态。处理器会恢复之前保存的现场,并继续执行被中断的指令。
我们在上面的第三步,存在一个保存现场的过程,它主要进行以下逻辑:
ARM异常处理:处理器对特定的异常事件进行的处理流程(CPU指导硬件自动完成:四大步三小步)。
一、保存现场(四大步):
-
保存CPSR到SPSR_mode
-
适当设置 CPSR 对应功能位(三小步):
a. 切换处理器进入ARM状态:T[5]
b. 根据需要,禁止中断位:F[6] / I[7]
c. 根据异常切换到对应的异常模式:M[4:0]
-
保存返回地址:把当前 PC 保存到 lr_mode
-
设置PC = 存放跳转到对应的异常向量表的固定首地址。
Linux的中断入口
我们关注中断响应和中断处理的过程,以ARM A55为例,Linux内核中,中断向量表在 arch/arm64/kernel/entry.S 中有定义:
c
/*
* Exception vectors.
*/
.pushsection ".entry.text", "ax"
.align 11
SYM_CODE_START(vectors)
kernel_ventry 1, t, 64, sync // Synchronous EL1t
kernel_ventry 1, t, 64, irq // IRQ EL1t
kernel_ventry 1, t, 64, fiq // FIQ EL1h
kernel_ventry 1, t, 64, error // Error EL1t
kernel_ventry 1, h, 64, sync // Synchronous EL1h
kernel_ventry 1, h, 64, irq // IRQ EL1h
kernel_ventry 1, h, 64, fiq // FIQ EL1h
kernel_ventry 1, h, 64, error // Error EL1h
kernel_ventry 0, t, 64, sync // Synchronous 64-bit EL0
kernel_ventry 0, t, 64, irq // IRQ 64-bit EL0
kernel_ventry 0, t, 64, fiq // FIQ 64-bit EL0
kernel_ventry 0, t, 64, error // Error 64-bit EL0
kernel_ventry 0, t, 32, sync // Synchronous 32-bit EL0
kernel_ventry 0, t, 32, irq // IRQ 32-bit EL0
kernel_ventry 0, t, 32, fiq // FIQ 32-bit EL0
kernel_ventry 0, t, 32, error // Error 32-bit EL0
SYM_CODE_END(vectors)
而这个向量表,又是在什么时候设置到CPU的呢?
在 arch/arm64/kernel/head.S 中,有以下汇编代码:
c
/*
* The following fragment of code is executed with the MMU enabled.
*
* x0 = __PHYS_OFFSET
*/
SYM_FUNC_START_LOCAL(__primary_switched)
adr_l x4, init_task
init_cpu_task x4, x5, x6
adr_l x8, vectors // load VBAR_EL1 with virtual
msr vbar_el1, x8 // vector table address
isb
...
SYM_FUNC_END(__primary_switched)
从上面可以看到,将 vectors 的地址写入 vbar_el1 寄存器。
Exception level的Vector Base Address Register (VBAR)寄存器,该寄存器保存了各个exception level的异常向量表的基地址。该寄存器有三个,分别是VBAR_EL1,VBAR_EL2,VBAR_EL3。
为什么 vectors 会有 4x4 个向量呢?
exceptions
在ARM.v8体系结构中,中断只是异常的一种类型,异常有4种类型。
- 同步异常 :这种类型的异常总是由当前执行的指令引起。例如。可以使用
str
指令将一些数据存储在不存在的内存位置。在这种情况下,将生成同步异常。同步异常也可以用于生成软件中断
。软件中断是由svc
指令有意产生的同步异常。 - IRQ(中断请求):这些是正常的中断。它们始终是异步的,这意味着它们与当前执行的指令无关。与同步异常相反,它们始终不是由处理器本身生成的,而是由外部硬件生成的。
- FIQ(快速中断请求) :这种类型的异常称为
快速中断
,仅出于优先处理异常的目的而存在。可以将某些中断配置为"正常",将其他中断配置为"快速"。快速中断将首先发出信号,并将由单独的异常处理程序处理。Linux
不使用快速中断。 - SError(系统错误) :像
IRQ
和FIQ
一样,SError
异常是异步的,由外部硬件生成。与IRQ
和FIQ
不同,SError
始终表示某种错误情况。
异常向量
每种异常类型都需要有自己的处理程序。另外,同一种异常类型下不同的状态也需要定义单独的处理程序。典型的有4种状态,以EL1 为例,这些状态可以定义如下:
- EL1t 与EL0共享堆栈指针时,
EL1
发生异常。当SPSel
寄存器的值为0
时,就会发生这种情况。 - EL1h 为EL1分配了专用堆栈指针时,EL1发生了异常。这意味着
SPSel
拥有值1
,这是我们当前正在使用的模式。 - EL0_64 以64位模式执行的EL0产生异常。
- EL0_32 以32位模式执行的EL0产生异常。
总共,我们需要定义16个异常处理程序(4个异常级别乘以4个执行状态)。
当外部gpio中断来了的时候,我们进入的是IRQ EL1h
,当访问内存产生的缺页异常,进入的是Synchronous EL1h
。
当外部gpio中断来了,查询向量表,满足kernel_ventry 1, h, 64, irq
,kernel_ventry 是在 arch/arm64/kernel/entry.S 实现的一个函数,上面展开后,就是调用 el1h_64_irq 这个函数。el1h_64_irq 还是一个汇编宏展开,在 arch/arm64/kernel/entry.S 有这样的代码:
c
.macro entry_handler el:req, ht:req, regsize:req, label:req
SYM_CODE_START_LOCAL(el\el\ht\()_\regsize\()_\label)
kernel_entry \el, \regsize
mov x0, sp
bl el\el\ht\()_\regsize\()_\label\()_handler
.if \el == 0
b ret_to_user
.else
b ret_to_kernel
.endif
SYM_CODE_END(el\el\ht\()_\regsize\()_\label)
.endm
/*
* Early exception handlers
*/
...
entry_handler 1, h, 64, irq
...
可以看到,通过SYM_CODE_START_LOCAL定义函数,在 kernel_entry 中做寄存器数据入栈等现场保护,然后bl跳转到 el1h_64_irq_handler,而el1h_64_irq_handler则是C语言实现的代码了。
el1h_64_irq_handler
el1h_64_irq_handler实际上是在 el1_interrupt 的基础上,再调用handle_arch_irq函数,在看handle_arch_irq函数之前,我们先看看el1_interrupt 都做了哪些操作。
el1_interrupt的逻辑:
- 关外部中断;
- 通过 do_interrupt_handler 调用到中断处理函数,do_interrupt_handler 会判断当前是否在任务的栈上,如果在栈上还需要保存现场,否则直接调用handle_arch_irq;
- 通过 arm64_preempt_schedule_irq 函数进行抢占式调度任务;
- 开外部中断;
上面的handle_arch_irq就是一个函数指针,以gicv3为例,该函数指针指向的是gic_handle_irq()。
gic_handle_irq
- do_read_iar 获取中断号;
- 检查是否支持NMI(非屏蔽中断),并读取RPR(运行优先级寄存器)的值。如果RPR的值等于GICD_INT_RPR_PRI(GICD_INT_NMI_PRI)则调用gic_handle_nmi()函数来处理NMI;
- 检查是否启用了GIC(通用中断控制器)的优先级屏蔽功能。如果启用了,则调用gic_pmr_mask_irqs()函数来屏蔽中断,并调用gic_arch_enable_irqs()函数来启用中断;
- gic_complete_ack将中断ID写入ICC_EOIR1_EL1寄存器来停止这个中断,我理解应该是类似清除gic的中断pending位信息;
- 接下来就是调用中断处理函数handle_domain_irq;
handle_domain_irq
- irq_resolve_mapping 通过hwirq查找irq_desc;
- 接着调用handle_irq_desc函数来处理中断描述符irq_desc,里面重点是调用 irq_desc->handle_irq 函数;
- 这个irq_desc->handle_irq函数由irq_chip->irq_nmi_teardown设置,SGI/PPI/EPPI对应handle_percpu_devid_irq,其他对应handle_fasteoi_irq。
- 最终handle_fasteoi_irq会调用action->handler来执行中断处理函数,这个action->handler就是我们通过request_irq或者request_threaded_irq设置的中断处理函数;
针对这个,建议查看参考文章i.MX8MP平台开发分享(gicv3篇)-- gic_handle_irq如何跳转到自定义的中断线程处理函数,里面针对硬件寄存器的介绍也比较详细。
中断的实时性讨论
在没有进行上面的代码跟进的时候,我之前一直以为,linux的中断非实时,是因为Linux系统在获知硬件中断之后,仅仅是立马清除了中断的pending信息,等到合适的时间再去进行中断函数的处理,从而导致中断非实时。但是从上面的代码分析下来发现,我之前的理解都是错误的,可以说,Linux系统在不关中断的情况下,只要产生了中断,都是会立马处理中断的。
那么为什么说Linux中断是非实时的呢?
因为Linux太多关闭中断的地方了,如上面,在处理中断的时候,关闭了中断(Linux不允许中断嵌套);在系统进入临界区的时候会调用spin_lock_irqsave等,关闭中断的地方多了,影响外部中断的时候,就需要等待使能中断后才可以响应,从而不满足非实时。
而像RT Linux,为了提高中断实时性,它主要进行了以下几点的修改:
- 临界区可抢占,将spinlock修改为可抢占类型,避免大量的关闭中断;
- 中断处理线程化,减少关闭中断的时间;
从RT Linux的修改,都是为了减少关闭中断的时间来提高实时性。从这个方面去理解Linux的中断非实时,就更容易理解了吧。
Linux任务什么时候调度
首先,Linux的进程是抢占式的,内核配置CONFIG_PREEMPT默认是开启的,当一个高优先级的进入可运行状态,内核将会检查它的动态优先级是否大于当前正在运行进程的优先级。如果是,当前运行的进程将会被中断,并调用调度程序选择另外一个程序运行。
在中断处理完之后,任务也会有机会发生调度,具体的代码段如下:
c
// arch/arm64/kernel/entry-common.c
static void noinstr el1_interrupt(struct pt_regs *regs,
void (*handler)(struct pt_regs *))
{
write_sysreg(DAIF_PROCCTX_NOIRQ, daif);
enter_el1_irq_or_nmi(regs);
do_interrupt_handler(regs, handler);
/*
* Note: thread_info::preempt_count includes both thread_info::count
* and thread_info::need_resched, and is not equivalent to
* preempt_count().
*/
if (IS_ENABLED(CONFIG_PREEMPTION) &&
READ_ONCE(current_thread_info()->preempt_count) == 0)
arm64_preempt_schedule_irq(); //可抢占,则发生调度
exit_el1_irq_or_nmi(regs);
}
时常我们会说,从内核返回用户空间的时候,也会发生任务调度,这个从用户空间进入内核的时候,也是通过异常进入的(或者说是软中断,PPI?),实际上这个和中断应该是没有本质的区别吧?所以他们归类为一种,都是异常处理后的任务调度。
最后,还有一种是时间片用完之后的任务调度,这个就很好理解了,给你的时间用完了,就要切换出去,不能让你自己一个人玩。
参考:
ARM64 kernel exception vectors
i.MX8MP平台开发分享(gicv3篇)-- gic_handle_irq如何跳转到自定义的中断线程处理函数