性能文章>从猫蛇之战三看内核戏CPU>

从猫蛇之战三看内核戏CPU原创

4年前
8042315

image.png

先说明一下,“连续剧”的成本有点高,无论如何,这一篇会把这个问题写完。

回顾一下,最初的问题是“为什么在调试器里读写空指针不会崩溃?” 第一篇通过读源代码的方法揭示了调试器会使用特殊的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函数所调用的拷贝函数,可以看到在它的末尾是有些特别机关的。

image.png

注意上图中的两个_ASM_EXTABLE宏,它们就是给危险代码增加保险(异常处理)的“安全带”。

这个宏定义在asm.h,如下图所示。

image.png

阅读上面的宏,其作用是在专门描述异常处理器的异常表(__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指令。

image.png

执行iret指令时,CPU从栈上弹出已经被修改了的ip寄存器,跳过去执行。于是便开始执行to指定的异常处理代码了。这个代码在Linux内核中,被称为fixup,意思是“修修补补”。下图记录了这个特别飞跃的过程。

image.png

上面是CPU执行iret前的栈内容,最上面便是IP和CS。单步一下后,CPU执行iret,从栈上弹出CS:IP,跳转到修补代码。

好一个飞跃,这一跃,从随时可能跌入深渊的do_page_fault中跳出,告别了敏感的异常处理上下文,化险为夷了。

这一跳跃,很像是猫蛇之战时小猫的紧急后退。小猫伸爪挑逗毒蛇是为了消耗蛇的体力,被激怒的毒蛇举头袭击,很是危险,小猫巧妙躲闪,灵活后退,华丽转身。
image.png

在源代码中,修补函数是有特别标注的,放在特殊的.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了,也就是我们在第一篇文章中曾经解释过的这个代码。

image.png

讲到这里,第三个问题(Q3)的答案也有了。那么第二个问题呢?如果充分理解了上面描述的过程,那么也可以回答了,留着给大家思考吧。

点赞收藏
分类:标签:
张银奎

前英特尔软件架构师

请先登录,查看3条精彩评论吧
快去登录吧,你将获得
  • 浏览更多精彩评论
  • 和开发者讨论交流,共同进步
15
3