从猫蛇之战三看内核戏CPU原创
先说明一下,“连续剧”的成本有点高,无论如何,这一篇会把这个问题写完。
回顾一下,最初的问题是“为什么在调试器里读写空指针不会崩溃?” 第一篇通过读源代码的方法揭示了调试器会使用特殊的probe函数:
- probe_kernel_read
- probe_kernel_write
上一篇通过试验证实,使用probe函数时CPU也会发怒报异常。本篇继续介绍CPU报了异常之后,内核是如何处理这个事件,将其“摆平”的。
在著名的《幽梦影》一书中有很多妙语,其中有不少是关于写作技巧的,比如:
“作文之法: 意之曲折者,宜写之以显浅之词; 理之显浅者,宜运之以曲折之笔; ”
因为这个系列讨论的问题有点复杂和曲折,所以我们是遵循“意之曲折者,宜写之以显浅之词”的原则来写的。
继续贯彻这个原则,直接回答刚才的问题,“摆平”CPU靠的是LINUX内核里一种基于表的异常处理机制,这个机制一般被称为“异常表(Exception Table)”,简称extable
。
下面继续结合我们故意访问地址880的例子来理解extable机制。
在CPU查找页表发现线性地址0x880无效而发怒后,它通过IDT表中登记地址跳转到LINUX中处理异常的入口函数,这个入口函数是以汇编语言编写的,名为page_fault
,在arch/x86/entry/entry_64.S
中。
汇编函数不适合做太多逻辑,只是保存寄存器等信息后便调用C语言编写的do_page_fault
。
do_page_fault
内部获取CR2的值后便调用__do_page_fault
。
__do_page_fault
内部的逻辑错综复杂,一个条件判断接着另一个,我们只挑与我们有关的说。
与try{}catch等异常捕捉机制类似,extable机制也是需要编译期就做好准备的。
仔细观察probe函数所调用的拷贝函数,可以看到在它的末尾是有些特别机关的。
注意上图中的两个_ASM_EXTABLE宏,它们就是给危险代码增加保险(异常处理)的“安全带”。
这个宏定义在asm.h,如下图所示。
阅读上面的宏,其作用是在专门描述异常处理器的异常表(__extable)里增加一行,这一行包含三个信息:
-
from
-
to
-
handler
简单来说,前两个都是代码地址,一个是触发异常的,一个是处理异常的,最后一个是函数指针。最后一个是4.6版本内核新增的,为了支持更复杂的处理策略。在_ASM_EXTABLE宏中,使用的是ex_handler_default,选择这个的处理器的效果是:如果from处发生异常,那么就跳转到to处执行,不要panic,也不要发信号,封锁信息,低调处理,像什么都发生一样。
异常表表项的结构体定义在extable.h中,即:
- struct exception_table_entry {
- int insn, fixup, handler;
- };
在extable.c文件中,有ex_handler_default函数的代码,摘录如下:
- __visible bool ex_handler_default(
- const struct exception_table_entry *fixup,
- struct pt_regs *regs, int trapnr)
- {
- regs->ip = ex_fixup_addr(fixup);
- return true;
- }
- EXPORT_SYMBOL(ex_handler_default);
各位看官请睁大眼睛,到关键地方了。请特别注意加粗的那一行代码,左边写的是regs结构体中的程序指针(ip),右边是处理异常代码的位置(即to参数)。
进一步说,这个regs结构体是在栈上形成的,报告异常时,CPU在准备起飞前先压入当时的执行位置,也就是段寄存器和程序指针,跳到page_fault后,内核中的代码继续把其它寄存器也压入栈,于是就在栈上形成了一个数据结构。对于熟悉NT内核的朋友来说,这相当于那个著名的陷阱帧(TRAP_FRAME)。
这种直接修改程序指针的方法是内核处理危机的杀手锏。经过这样飞针后,__do_page_fault就直接返回了,do_page_fault也返回,到了汇编写的page_fault函数后,就开始恢复寄存器了,也就是把保存在栈上的regs结构体中的寄存器弹出栈,加载到CPU中的物理寄存器。
软件保存的寄存器都恢复好后,执行iret指令。
执行iret指令时,CPU从栈上弹出已经被修改了的ip寄存器,跳过去执行。于是便开始执行to指定的异常处理代码了。这个代码在Linux内核中,被称为fixup,意思是“修修补补”。下图记录了这个特别飞跃的过程。
上面是CPU执行iret前的栈内容,最上面便是IP和CS。单步一下后,CPU执行iret,从栈上弹出CS:IP,跳转到修补代码。
好一个飞跃,这一跃,从随时可能跌入深渊的do_page_fault中跳出,告别了敏感的异常处理上下文,化险为夷了。
这一跳跃,很像是猫蛇之战时小猫的紧急后退。小猫伸爪挑逗毒蛇是为了消耗蛇的体力,被激怒的毒蛇举头袭击,很是危险,小猫巧妙躲闪,灵活后退,华丽转身。
在源代码中,修补函数是有特别标注的,放在特殊的.fixup段中,比如:
- .section .fixup,"ax"
- .L_fixup_4x8b_copy:
- shll $6,%ecx
- addl %ecx,%edx
- jmp .L_fixup_handle_tail
执行好修补代码片段后,因为保存在栈上的copy函数的返回地址并没有变化,所以当修补函数返回时,线程会返回到probe函数中继续执行。并且,从probe函数看来,copy函数的返回值不为0,代表剩下的字节数,正常copy时,copy函数返回前会将ax寄存器置零,代表完成所有复制任务。因此,probe函数便可以根据copy函数的返回值不为0而返回-EFAULT了,也就是我们在第一篇文章中曾经解释过的这个代码。
讲到这里,第三个问题(Q3)的答案也有了。那么第二个问题呢?如果充分理解了上面描述的过程,那么也可以回答了,留着给大家思考吧。