本文讨论一下linux下x86平台关于中断/异常的准备与退出,主要关注点在于进入C语言部分前与退出C语言部分后汇编/C代码所处理的上下文切换部分。
关于内容的说明
- 使用的内核版本为5.5.13
- 主要介绍64位下的内核实现
- 资料、引据都在最下方,文中对它们用到的地方可能有说明也可能只是概括一下,如果有兴趣可以去原出处细致了解
- 如果发现文中有任何错误,不论是评论还是私信,希望能够指出(能有资料佐证更好),感激不尽!
- 本文的讨论需要读者对以下内容有了解:
- 基本的C语言
- 中断、异常的基本概念
- 保护模式的段、虚拟地址
- 用户态与内核态的基本概念
- 能看懂简单的x86汇编代码(AT&T语法)
- (Optional)TSS---Task State Segment的基本概念
OK,下面进入正文!
Overview
总体上来说,linux下中断/异常的处理模式大致是这样:
- 中断向量表中每个entry(也就是idt)对应于一个中断/异常,它记录了进行处理的handler的地址。每个handler我们都会定义一个标号,并把这个标号的值(也就是虚拟地址)写入这个idt中,
- 这样一来,将中断向量表的起始地址写入对应的预留寄存器后,中断/异常发生时,硬件方面根据号码找到idt entry,执行必要的context switch(主要是寄存器)等任务后自动跳转到相应C函数。总的来说处理流程大致如下:
(用户态/内核态)执行-- (发生中断/异常)-->硬件context switch,并跳转到相应handler----> handler(汇编代码)执行必要的工作-- (跳转到C语言代码内)-->处理中断/异常-- (C语言代码返回,回到handler)--> handler(汇编代码)执行必要的工作---->回到用户态/内核态
黑体部分是本文关注点所在。
然而,在linux中,出于设计上的原因,异常/中断(还有系统调用,但这里按下不表)这些由用户态(以及可能的,内核态)进入内核态的过程在处理上多多少少有一些不同,因此本文将会对它们做一些区分,先分析对异常(exception)的处理,再据此分析中断(interrupt)中的不同之处,同时穿插一下为什么会有这些不同,窥探linux在设计上的思考。
异常---Exception
对处理过程的分析,很大程度上就是对handler实现的分析,由于不同handler的代码十分相似,它们的定义是通过汇编语言的宏来实现的。如上图2所示,宏的名字为idtentry,后续空格分隔的为不同参数。我们通过分析宏的定义来分析handler的实现(不懂宏的语法没关系,很正常,能看懂其意思即可)
1. 进入C语言前:
idtentry
我们先通过idtentry宏看看进入C语言前的准备工作:
// arch/x86/entry/entry_64.S
这里我删去了一些非公共的参数以达到简化的目的。关于idtentry宏的参数:
- sym:handler的名字,使用idtentry宏时会以此为名定义一个全局标号
- do_sym:handler要调用的处理中断/异常的C语言函数,也就是真正的中断/异常处理函数
- has_error_code:进入异常handler时硬件是否在栈上压入一个错误码
- paranoid:进入handler这一行为是内核发起的(=0)还是用户发起的(=1),默认为内核发起(=0)(除了极少部分如int3断点异常,其他大都是由内核检测到并发起的)
- read_cr2:是否要读取cr2寄存器并将其传入异常处理的C语言函数。主要是用于页错误异常,它会将引发页错误的虚拟地址保存在cr2寄存器中,handler需要将其传递给C语言函数
在这里,我们可以看到首先是保持栈结构,没有error code时额外压入一个error code:
.
这里我们需要先简单介绍下这时的栈上的异常栈帧结构。当进入handler前为用户态时:
*
由下到上从低地址到高地址。这里的error_code(对中断来说它是中断号,对非sysenter/sysexit实现的系统调用来说它是系统调用号)就是我们在为空时要额外压入的部分。当进入handler前为内核态时:
*
因为内核态到内核态不需要从内核栈到用户栈的恢复,因此栈上无需保存原来的ss和rsp。至此,我们仅在内核栈上保存了硬件自动保存的context。
接下来我们可以看到,殊途同归,都是执行:
idtentry_part
对paranoid == 0来说,条件后的代码是:
idtentry_part
对paranoid == 1来说,条件后的代码是:
testb
这里用到了栈帧偏移量CS,ORIG_RAX,定义如下:
// arch/x86/entry/calling.h
总之不管怎么样,都是带着paranoid=0执行idtentry_part宏(其实paranoid还可能取值为2,这里不讨论)。接下来我们展开identry_part。
identry_part
到这里,我们已经在栈上保存了硬件context和error_code。进入C语言之前,我们还剩下两件事:
- 保存通用寄存器,必要的话切换栈(后面会解释)
- 在栈上准备参数以调用C语言函数
这一部分我们就是要解决这些问题(删去了debug追踪等部分,并按照paranoid=0展开):
// arch/x86/entry/entry_64.S
在error_entry中,我们保存通用寄存器并在必要时切换栈:
// arch/x86/entry/entry_64.S
在这里,首先是保存并清除所有寄存器,也就是PUSH_AND_CLEAR_REGS save_ret=1,这里仍然是一个宏:
// arch/x86/entry/entry_64.S
这里的save_ret主要是控制是否要将返回地址转移到栈顶。具体地说,由于我们保存寄存器主要是想构造一个struct pt_regs结构给C语言函数:
// /usr/include/x86_64-linux-gnu/asm/ptrace.h
但在这里,我们在进入PUSH_AND_CLEAR_REGS宏之前的栈内容是这样的:
*
也就是,我们call error_entry时在栈上插入了一个返回地址(call convention)[1],如果要构造struct pt_regs结构,需要把这个返回地址迁移到保存完寄存器后的栈顶。因此,这里就先将其暂存在%rsi中,最后再压入栈顶。(对于没有这个问题的调用者来说,直接保存寄存器即可)。
接下来的这部分代码:
/*
切换了GS寄存器,切换到进程的内核态cr3(中间的FENCE_SWAPGS_USER_ENTRY加了一个memory barrier[2]防止后续访问gs段数据的代码被乱序执行以致读取到用户gs段的数据)。
- 切换GS寄存器主要是因为在用户态GS寄存器是预留给用户自定义使用的,而在linux内核中,GS主要是用于per-cpu variable数据段[3](紧接着就会用到,后面解释用途),因此需要切换到内核自己的GS寄存器内容
- 而“切换到进程的内核态cr3”听起来似乎有些绕口,什么叫“内核态cr3”,cr3也分内核态和用户态吗?实际上在早期是没有这个问题的,每个进程只有一个cr3(cr3存放顶级页目录的起始物理地址),而每个进程的顶级页目录都有对内核代码段、数据段的映射项,只是访问权限为ring0,因此进入内核态无需切换cr3内容,权限等级自然地允许其访问内核部分。但是,由于近几年出现的集中内核旁路攻击机制,如Meltdown[4],使得linux应当尽量避免在每个用户的页目录中包含内核映射,因此linux采用了KPTI[5](Kernel page-table isolation)对每个进程的内核态和用户态各分配一个顶级页目录,前者与原先(不采用该技术时)相同,后者对内核态映射只包含了进入内核所必须的一小部分(这些都可以再开一篇文章写了)。综上,进入内核态不久我们要先切换一次cr3使用该进程的内核态顶级页目录(有点拗口)。
这里我们digress一下,考虑关于内核栈的设计问题: 1. 我们首先必然要区分用户栈和内核栈以达到起码的保护目的 [6] ,在进入内核态前,如果用户态将栈指针放在一个恶意区域(比如内核地址空间等等),那么内核就会轻易地覆写不该写入的区域。 2. 再之,对于linux来说,给每个进程分配一个内核栈也是非常必要的,一方面进程进行系统调用时可能会阻塞在内核态(比如等待用户输入等等),此时进程在内核态的状态需要保留在内核态上,切换到其他进程(内核可抢占),假如其他进程没有自己的内核栈,则栈上又会压入其他进程的使用数据,这样一来原进程状态就无法恢复 [7] ;另一方面,为了提高进程响应速度,linux内核是可抢占的,也就是说,进程即便不是在执行系统调用而是在处理异常/中断,只要不是在time-critical region,进程也可能会被切换,也就是其他进程执行时发生中断/异常也可能触发切换到某个进程,为了确保进程状态即便是在内核态也能够恢复,内核栈内容仍然需要被保存 [8] ;除此之外,给每个进程分配一个内核栈使得进程从一个cpu迁移到另一个cpu变得十分便利 [8] ,因为进程内核栈上保存了context info,只需要保存栈指针等等的信息到进程的descriptor,其他cpu就可以轻易地恢复进程,而无需拷贝栈内容。 3. 最后,退出内核态时,当前进程的内核栈应当是全部清空的。
有了上面的分析,我们知道,每个进程都有一个内核栈,而按照我们说的,这一部分要保存寄存器和切换栈,保存寄存器已经做完了,这里的“切换栈”是什么意思呢?我们已经在进程的内核栈上了,有什么可切换的?这需要涉及到TSS在64位linux下的作用。
由于linux是采用software context switch[9],在进行context switch时大部分上下文是手动切换的,其中包括进程的内核栈指针。64位mode的TSS[10]中给ring0、1、2各reserve了一个栈指针位置,用来保存当由当前进程进入这些权限等级时要切换的栈指针:
原本按照这个思路,当前进程的内核栈,应该是保存在sp0的,然后每次进程task switch的时候将新进程的内核栈指针(保存在进程描述符中)加载到sp0中,当中断、异常等触发用户态到内核态的转移时,自动切换栈指针到sp0处并将用户态的ss、sp、cs、ip等压入内核栈中。从这个角度来看,有什么可切换栈的?我们现在不就在进程内核栈上吗?要切换到哪去?
操作方面是这样:我们为每个cpu引入了一个trampoline stack,将其设置为sp0,所有进程共用,而将原本sp0应存放的进程内核栈指针放在sp1,这样硬件在进入内核时仍然读取sp0,因此首次进入内核时会进入这个trampoline stack上,而后再进行栈的转换。(类似的,我们会看到,退出内核态时也会先切换到trampoline stack再退出,但那里有其他考量)
关于为什么要在内核的入口点引入trampoline,当时的考量是希望在触及当前进程的内核栈前能够做一些其他工作[11](个人推测也是为了避免在用户态页表中存在内核栈的映射,只保留trampoline stack的映射即可),对我们来说,我们只需要知道,我们目前需要一次栈切换,代码如下:
// arch/x86/entry/entry_64.S
这里,我们首先要找到当前进程的内核栈才能将内容复制过去,因此我们使用cpu_current_top_of_stack来找到当前进程的内核栈栈顶。注意这里包了一个this_cpu_read宏,具体来说它会把当前变量作为一个offset,以gs寄存器中的base为基址,再加上编译期计算出来的本cpu的per-cpu area offset,来对传入的变量进行重定位,从而获得其在本cpu下的虚拟地址(也就是这个cpu的local variable,毕竟linux下tss是每个cpu各一个的,sp1自然也是如此)。因此,重载gs是必需的,重载cr3是必需的(切换到完全的内核映射),加barrier也是必需的(防止读操作被reorder到切换gs前)。这里我们也可以看到,sync_regs通过指针运算,预留出一个struct pt_regs大小的区域,如果是用户态到内核态的切换,则两者地址(我们在trampoline stack上,而sp1是进程内核栈)不同,进行拷贝,如果是内核态到内核态的切换,sp1就是进入handler前的栈指针,offset一个struct pt_regs后恰好会与传入的地址相同,从而无需拷贝。
最后,函数返回值在%rax中,将其mov到%rsp中就真正实现了栈的切换。同时在代码周围,我们save和restore了当前的返回地址,ret正常执行。
我们完成了寄存器的保存和栈的切换,剩余的工作相对就比较简单了--准备参数:
.
这里因为我们是异常处理而且有错误码,原则上来说不应该是由系统调用触发的,因此save了-1。仍然是根据linux kernel call convention[1],前三个参数分别保存在%rdi,%rsi和%rdx中。这里我们首先根据read_cr2看是否需要保存cr2,再将相应的内容移入相应寄存器中。接着就到了C语言部分了:
call do_sym
2. 退出C语言后:
jmp
这里为了确保我们这部分操作的安全,首先是关中断,实际展开如下:
// arch/x86/include/asm/irqflags.h
接着,我们就是要检测一下我们即将要返回的是用户态还是内核态,分别跳转。
返回到内核态
我们先看一下内核态:
// arch/x86/entry/entry_64.S
这里我们的行为是,当且仅当之前的context开启中断又enable了内核抢占的情况下,才触发重新调度,否则直接恢复寄存器iret到之前的状态。
这里的两个条件我们要解释一下:1、为什么之前的context会存在关闭中断的情况?如果说我们当前是在中断的执行过程中,这(i.e. 之前的context表明其中断被关闭)当然是不可能的(除非是NMI),但我们现在是异常的执行,如果用户在内核态关闭中断的情况下发生了异常(如double fault[12]),就会使得这里test出原先的IF位为clear,在这种情况下,我们当然不应该触发调度。2、为什么要测试当前cpu的__preempt_count?这其实就是抢占的含义所在了,当且仅当__preempt_count为0时才说明当前是处于可抢占状态的,而可抢占的含义就是,之前被中断的进程处于内核态时被中断后允许触发调度来切换进程。如果这两个条件任意一个不满足,我们就直接执行恢复寄存器,执行返回:
// arch/x86/entry/calling.h
而对异常来说,这个 pt_regs->orig_rax指的是错误码,这个信息我们没必要保留,直接drop掉就可以了,然后最后的INTERRUPT_RETURN其实就是iret(删去了不相关信息):
// archx86includeasmirqflags.h
返回到之前的内核态。
上面分析的是关闭内核抢占的情形,而大多数情况下,内核抢占是打开的,因此我们看一下抢占调度:
// kernel/sched/core.c
这里的prev_state保存只是进行tracking,我们略去不看,可以看到,这里的抢占调度是进行了一个循环的,我们先忽略循环,先看看每一次都干了什么。
首先是关掉抢占并打开中断。这里先说为什么要打开中断,主要还是尽可能避免关掉中断而带来的吞吐量下降,在任何可以打开中断的地方都尽可能地打开中断。而也因此,我们才需要在打开中断前关闭抢占,因为我们本身就是在进行抢占调度,应当避免在抢占调度执行过程中发生中断然后触发抢占调度,从而不得不考虑可重入以及死锁的问题。
接着就是__schedule进行调度,这就不展开了。退出调度后,我们依次重新关闭中断,打开抢占(这里的sched_preempt_enable_no_resched实际就是加了个barrier的打开抢占)。
这个过程会不断进行,只要在__schedule中又设置了当前进程的TIF_NEED_RESCHED位。等到彻底不需要调度(__schedule中没有设置这个位)时,我们退出抢占函数,接着之前分析的返回内核态的恢复工作。
至此,返回内核态路径上的情况我们分析完了。
返回到用户态
接下来我们分析一下返回用户态的情况:
// archx86entryentry_64.S
来到返回用户态的路径上,首先是调用一个C语言函数进行准备(仍然是call convention第一个参数放入%rdi中),而后恢复寄存器(这里稍显复杂)回到用户态。首先看下准备工作:
// archx86entrycommon.c
在这里,首先是获取了当前cpu的进程(线程)信息放在ti中,紧接着开始检查是否需要进行“loop”。这里的loop是什么呢?我们打开看一下:
// archx86entrycommon.c
这个地方可以比较直观地理解一下,因为在处理中断/异常的过程中,可能进程会收到信号(signal)、需要重新调度等等,因此这里所做的就是检查这些,执行必要的工作。之所以做成loop,是因为每一次处理都可能refresh这些flag,我们需要loop直到没有新的flag被设置。
最后,当这些信息处理完毕后,最后检查一下该进程的iobitmap是否被修改(这部分主要是关于进程的io访问权限的),修改过的话就进行相应的更新:
if
最后,如有必要的话加载之前save的fpu状态(fpu主要是浮点运算方面的协处理器,有自己的状态寄存器等等):
if
当所有的这些准备工作做完后,我们开始切换栈和恢复寄存器。
在进入C语言的异常处理函数前,我们就分析过了栈的切换,并提到退出内核时仍然要用trampoline stack,但在这里的考量与之前不同,这里是为了给"kernel stack erasing"做准备以及别的工作[13]。
关于"kernel stack erasing",需要提到STACKLEAK[14]。通俗地说,我们希望在推出内核时将我们用过的内核栈内容擦除,防止后续的恶意系统调用利用它们。因此,我们需要将我们用过的内核栈内容抹除,因此我们首先要切换到trampoline stack上,再抹除我们用过的内核栈,并且切换回用户态cr3、gs,然后返回到用户态:
SYM_INNER_LABEL
至此,用户态路径也分析完毕。
硬件中断---Hardware Interrupt
有了前面对异常处理的详细讨论,我们对中断的讨论会轻松很多。总的来说,硬件中断就其处理模式上来说分为三类:
- APIC/SMP Interrupt
- Normal Interrupt
- Spurious Interrupt
最后的Spurious Interrupt意为“不期望发生的中断”,如硬件错误或者中断号根本没有设置处理程序等等,它们有统一的处理流程,我们就不讨论了,我们主要看前两者。
在这里我们可以先看一下关于它们的idt初始化方法:
可以看到,apic中断是基于表的,而normal和spurious中断是等间隔设置的,我们可以看一下apic的table:
这里之所以会产生apic和normal的这种差别我们在各自介绍完它们后再讨论。
APIC/SMP Interrupt
linux中可以找到许多关于apic handler的定义:
它们都是基于apicinterrupt/apicinterrupt3宏的:
// archx86entryentry_64.S
从这里,我们可以清晰地看出,仍然是分为进入C语言前、进入C语言、退出C语言后进行的(PUSH/POP_SECTION_IRQENTRY是编译期段检查,无需关心),因此,我们仍然这个思路分析。
1. 进入C语言前:
interrupt_entry
// archx86entryentry_64.S
回忆我们在异常中的内容,保存寄存器,切换GS、cr3,切换栈,在这里也是类似的。但是可以看到,这里切换栈并没有调用C函数,推测可能是为了避免cache miss,尽量加快中断的处理。
仍然可以看到,仍然是先判断我们进入handler前是用户态还是内核态:
testb
这里之所以+8是因为栈上有一个call interrupt_entry的返回地址。
对用户态而言,我们仍然是先切换到内核栈,然后将hardware context(cs ip等)迁移到内核栈上,代码与异常处理类似;而对内核栈我们就可以跳过这一部分了。接着保存其他通用寄存器,两条代码路径都来到了ENTER_IRQ_STACK,进入硬件中断栈。
这里首先要解释一下,所谓的硬件中断栈(IRQ_STACK)。除了给每个可调度的thread一个内核栈外,每个cpu还会有一些其他的内核栈[15],其中就包括interrupt stack,中断栈,旨在提升可嵌套的中断层数[15]。毕竟我们进入中断的可能有三种情况:用户态进入、系统调用中/异常中进入、中断上下文进入。对于前两者,我们都是会进入进程内核栈的,而这多多少少会降低可嵌套的中断层数,而为每个进程都多分配栈大小以应对这种情况又显得愚蠢,因此额外的interrupt stack是个不错的选择。
进入中断虽然会关闭中断,但中断处理并不是每一段都是不可被打断的 [16] ,实际上只有最紧要的不可被中断的部分中断会被关闭,在进入non-critical region后,中断处理函数会打开中断,以使得中断的响应能够尽快恢复,提升系统的吞吐量。
ENTER_IRQ_STACK宏根据输入的参数展开后如下:
// archx86entryentry_64.S
leaq额外的8个字节是为了跳过返回地址,这样%rdi就指向了内核栈上保存的先前context info(struct pt_regs)。紧接着inc下irq_count(初始为-1),若为0切换栈,否则无需切换,push即可。而实际的切换栈,只是将rsp指向hardirq_stack_ptr罢了,这个hardirq_stack_ptr其定义如下:
// archx86includeasmprocessor.h
其初始化如下:
// archx86kernelirq_64.c
其中的irq_stack_backing_store就是每个cpu分配的interrupt stack的起始地址,而由于栈的特性,irq_stack_backing_store+IRQ_STACK_SIZE就到了栈底,也就是我们需要的其实指针,也就是这里的hardirq_stack_ptr
// archx86kernelirq_64.c
在切换栈后,进行了两个push操作,这样一来,interrupt stack上的内容结构如下:
* | saved struct pt_reg* |
* | return address for "call interrrupt_entry" |<---- rsp
显然,如果进行了栈切换的话,struct pt_reg*就指向内核栈,否则就指向interrupt stack。(前者比较好理解,后者主要是,当发生嵌套中断时,我们本身已经在interrupt stack上了,而interrupt stack本身也是一个内核态栈,因此嵌套中断发生时不会load sp0(不会发生硬件栈切换),那么我们刚进入handler时的数据其实就已经在interrupt stack上了,这里save 的old_rsp也是指在interrupt stack上)
至此,栈切换完成,同时,这也到达了interrupt_entry的返回处,进入C语言前的准备结束,代码进入C语言(rdi保存参数struct pt_regs*):
call
2. 退出C语言后
ret_from_intr
// archx86entryentry_64.S
完成了中断后,我们准备要退出。可以看到,这里跟异常的退出非常类似,也是先关中断,以及后续根据saved cs值决定返回用户态还是内核态,唯一多的就是LEAVE_IRQ_STACK,退出interrupt stack,这也很好理解。
这里要说明的是,这个leave并不会显式地进行栈切换,因为没必要:
// archx86entryentry_64.S
如果我们要返回的是进程内核栈的话,因为save的rsp就是指在进程内核栈上,所以pop %rsp就完成了栈的切换(ss都是一样的),而如果我们要返回的仍然是interrupt stack的话(嵌套中断),save的rsp仍然是指在interrupt stack上的,因此这里我们无需手动切换。最后decl一下irq_count释放一次interrupt stack。
这样,我们就完成了退出。当然,其实只有在回到用户态/系统调用/用户异常时才会真正切换到进程内核栈。
剩下的事情就和异常的退出完全一致了,该回内核的回内核,该回用户的回用户,其中夹杂着重调度、信号检查等等,以及最后回到用户态前的trampoline stack、kernel stack erasing。
Normal Interrupt
从图5中可以看出,normal interrupt handler似乎像是通用(批量生产)的,似乎只是中断号码不一样,实际上,确实是这样:
// archx86entryentry_64.S
可以看出,rep了FIRST_SYSTEM_VECTOR - FIRST_EXTERNAL_VECTOR个entry,每个entry的不同之处仅仅在于它们push的num,接着都跳到了common_interrupt。关于它为什么与APIC有次差别,我们后续再讨论,先看common_interrupt,它就是nromal handler中断的处理部分了:
// archx86entryentry_64.S
可以看到,这里首先是将刚push的num rescale了一下,接着与APIC一样,调用interrupt_entry进行必要的context保存和栈切换,除了统一调用do_IRQ外,后续的返回与APIC也是一样的。
所以实际上所有中断是共用一个do_IRQ函数的,它是怎样针对不同中断工作的呢?
// archx86kernelirq.c
可以看到,除去错误情况(irq_desc为空或者错误),其实就是一句generic_handle_irq_desc(x86_32情况下的handle_irq只是执行了部分额外的检查,最后仍然调用了generic_handle_irq_desc)。当然还包括了entering_irq()、exiting_irq(),前者只是更新部分内核的统计信息,后者也会更新这部分信息,但是在检测发现当前是非嵌套中断的退出时回执行soft_irq[17](即处理中断过程中的deferrable任务),这里按下不表。
所以这个generic_handle_irq_desc究竟做了什么呢?其实它就一句话:
// includelinuxirqdesc.h
而这个struct irq_desc是什么呢?很长:
// includelinuxirqdesc.h
这个handle_irq是一个irq_flow_handler_t类型的变量,可以看出应该是一个函数指针:
// includelinuxirqhandler.h
这里我们就可以解释为什么两者的handler定义方式不同了。
PIC(Programmable Interrupt Controller)是比较早期的硬件,不同硬件之间不兼容,中断号与中断的对应可能随硬件变换;而APIC(Advanced Programmable Interrupt Controller)是后来出现的技术,对中断号的分配有linux的标准,可以一开始就定义好。但是中断的处理逻辑都类似,因此linux中使用了一套generic interrupt处理系统[18]来解决PIC的硬件不兼容问题。在这里,不同的中断会注册不同的irq_flow_handler_t,调用其来处理中断。但实际上,irq_flow_handler_t并不自己处理中断,而是将其delegate to其他函数,它本事是负责“中断流”处理的,也就是管理中断,使其有序地处理。根据中断触发的方式不同(edge、level、eoi等等),其对应的默认的flow handler也不同,以level触发为例,大体逻辑可以如下描述(简化后)[18]:
desc
这里的irq_data.chip是指这个中断所在的芯片,也是generic系统的一部分,不同的芯片创建不同的chip即可,也很长:
// includelinuxirq.h
所以这里是发一个ack接着处理中断,等处理完后再unmask这个中断,重新接受。这里handle_irq_event执行了诸多状态设置以及检查,最后的handle是delegate给了__handle_irq_event_percpu:
// kernel/irq/handle.c
这里我们大体上可以看出我们遍历了这个interrupt上的所有action,对这个interrupt进行处理:
for_each_action_of_desc
这里的aciton定义如下:
// includelinuxinterrupt.h
为什么要遍历所有action呢?这就引出了apic与normal interrupt的差别之处,normal interrupt覆盖了enternally connected I/O devices[19]的部分(见图8),也就是来自外围I/O设备的中断,可能出现两个设备共享一个irq的问题,在这里的体现就是,同一个irq_des上可以串联很多个action,每个action上挂的handler都会输入一个dev_id作为参数,用来确认到来的中断是否来自自己的设备,进而处理(因此应当尽量避免共享,处理效率低);对apic而言,它们有固定的中断号[20]dedicated出来,因此不存在这个问题:
因此,对APIC中断来说,我们在一开始进入handler就可以得知需要调用哪个C函数来处理它,并且flow handling也由C函数负责,我们可以用宏定义之,也相对高效;但对normal interrupt来说,我们执行的只是generic代码,甚至不知道具体调用了哪些handler(代码并不能体现之),这种“多态”有效地屏蔽了硬件的差异,使得代码管理变得十分方便,但带来的penalty就是难以定位调用函数(从静态代码方面来说)以及相对效率的稍低。总的来说,分工不同,它们的实现相应地就要因地制宜,这也都可以理解。
对中断的handle分析完后,剩余的中断处理代码(也就是退出C语言部分准备回到内核/用户态)与APIC并无两样,也就不再赘述了。
结语
至此,关于中断/异常我们就都分析完了,篇幅相对也比较长,其中或多或少夹杂了一些其他内容(KPTI等等),但总的来说,对中断/异常的总体思路还是较为明确的:
(用户态/内核态)执行-- (发生中断/异常)-->硬件context switch,并跳转到相应handler----> handler(汇编代码)执行必要的工作-- (跳转到C语言代码内)-->处理中断/异常-- (C语言代码返回,回到handler)--> handler(汇编代码)执行必要的工作---->回到用户态/内核态
它们都需要考虑:
- 中断向量的设置
- GS、CR3的切换
- context saving
- 对中断/异常的handle
- 返回到内核态前的抢占调度
- 返回到用户态前的重调度、信号处理等等
- context restore
对异常来说,我们需要额外考虑:
- trampoline stack与当前进程内核栈的切换
对中断来说,我们需要考虑:
- interrupt stack与进程内核栈的切换
- 对APIC interrupt,进入predefined C函数
- 对Normal interrupt,进入do_IRQ genenrric interrupt handling进行后续的flow handling以及actions等等
参考
- ^abWhat are the calling conventions for UNIX & Linux system calls on i386 and x86-64 https://stackoverflow.com/questions/2535989/what-are-the-calling-conventions-for-unix-linux-system-calls-on-i386-and-x86-6
- ^Memory barrier https://en.wikipedia.org/wiki/Memory_barrier
- ^x86 memory segmentation https://en.wikipedia.org/wiki/X86_memory_segmentation
- ^Meltdown https://en.wikipedia.org/wiki/Meltdown_(security_vulnerability)
- ^Kernel page-table isolation https://en.wikipedia.org/wiki/Kernel_page-table_isolation
- ^kernel stack and user space stack https://stackoverflow.com/questions/12911841/kernel-stack-and-user-space-stack
- ^https://www.cs.umb.edu/~eoneil/cs444_f06/class10.html
- ^abhttps://www.quora.com/Why-does-Linux-use-per-thread-kernel-stacks
- ^Context switch https://en.wikipedia.org/wiki/Context_switch#Hardware_vs._software
- ^'6.7 TASK MANAGEMENT IN 64-BIT MODE', in Intel® 64 and IA-32 Architectures Software Developer's Manual Volume 3A: System Programming Guide, Part 1.
- ^https://lore.kernel.org/patchwork/patch/855083/
- ^Double fault https://en.wikipedia.org/wiki/Double_fault
- ^https://lore.kernel.org/patchwork/patch/853034/
- ^How STACKLEAK improves Linux Kernel Security https://a13xp0p0v.github.io/2018/11/04/stackleak.html
- ^abKernel stacks on x86-64 bit https://www.kernel.org/doc/Documentation/x86/kernel-stacks
- ^Mauerer, W. (2010). Professional Linux Kernel Architecture. Somerset: Wiley.
- ^Softirqs and Tasklets - Understanding the Linux Kernel, 3rd Edition by Daniel P. Bovet, Marco Cesati https://www.oreilly.com/library/view/understanding-the-linux/0596005652/ch04s07.html
- ^abLinux generic IRQ handling https://www.kernel.org/doc/html/latest/core-api/genericirq.html
- ^'8.1 LOCAL AND I/O APIC OVERVIEW', in Intel® 64 and IA-32 Architectures Software Developer's Manual Volume 3A: System Programming Guide, Part 1.
- ^Interrupt vectors in Linux - Understanding the Linux Kernel, 3rd Edition by Daniel P. Bovet, Marco Cesati https://www.oreilly.com/library/view/understanding-the-linux/0596005652/ch04s06.html