本文献给一位非常努力的穿皮鞋的同事。
有一天,因为一个已经忘却了的原因就没有在公司食堂吃午饭,当然是出去饭店来了一顿更好的咯…餐后,突然就是天昏地暗暴雨倾盆,当我们意识到这场雨一时半会儿停不下来的时候,我们就打了同事的电话,看看能不能帮忙送几把伞过来…
穿着皮鞋跑步总是不会慢的!
过了大概十分钟的样子,同事穿着皮鞋蹚着到小腿肚子深的水过来了…那双皮鞋因此进水了,那双皮鞋因为进水而胖了,那双皮鞋可能也就没有办法继续穿了,那双皮鞋的牌子可能是意尔康,自那以后,同事就不再穿皮鞋了。
这件事已经过去很久,作文而忆起,不禁唏嘘…
近日,曾经因为我们在公司外吃完午饭下暴雨被困而特意来送雨伞从而把皮鞋弄湿后再也不穿皮鞋的 (定语有点长)同事咨询了一个问题,涉及到Linux内核进程调度源码,问题是这样的:
Linux在进程调度时,调用了一个switch_to宏:
/* frame pointer must be last for get_wchan */ #define SAVE_CONTEXT "pushf ; pushq %%rbp ; movq %%rsi,%%rbp\n\t" #define RESTORE_CONTEXT "movq %%rbp,%%rsi ; popq %%rbp ; popf\t" #define __EXTRA_CLOBBER \ , "rcx", "rbx", "rdx", "r8", "r9", "r10", "r11", \ "r12", "r13", "r14", "r15" /* Save restore flags to clear handle leaking NT */ #define switch_to(prev, next, last) \ asm volatile(SAVE_CONTEXT \ "movq %%rsp,%P[threadrsp](%[prev])\n\t" /* save RSP */ \ "movq %P[threadrsp](%[next]),%%rsp\n\t" /* restore RSP */ \ "call __switch_to\n\t" \ "movq "__percpu_arg([current_task])",%%rsi\n\t" \ __switch_canary \ "movq %P[thread_info](%%rsi),%%r8\n\t" \ "movq %%rax,%%rdi\n\t" \ "testl %[_tif_fork],%P[ti_flags](%%r8)\n\t" \ "jnz ret_from_fork\n\t" \ RESTORE_CONTEXT \ : "=a" (last) \ __switch_canary_oparam \ : [next] "S" (next), [prev] "D" (prev), \ [threadrsp] "i" (offsetof(struct task_struct, thread.sp)), \ [ti_flags] "i" (offsetof(struct thread_info, flags)), \ [_tif_fork] "i" (_TIF_FORK), \ [thread_info] "i" (offsetof(struct task_struct, stack)), \ [current_task] "m" (current_task) \ __switch_canary_iparam \ : "memory", "cc" __EXTRA_CLOBBER)
该宏的SAVE_CONTEXT中,为什么要保存rsi寄存器?rsi有什么用?
为了理解这个,理解inline汇编比理解Linux内核的调度细节更加重要。可以说, “为什么保存rsi?” 这个问题和Linux内核调度机制的关系并不大。
为此需要先理解inline汇编的原理和用法。
本文不是专门的inline汇编教程文档,所以这里给出一个链接,如果还不懂inline汇编的可以直接锚过去:
GCC-Inline-Assembly-HOWTO: http://www.ibiblio.org/gferg/ldp/GCC-Inline-Assembly-HOWTO.html
后面的篇幅我尽量只说自己的想法。
gcc可以把C源文件编译成汇编码,背后依靠的是超级复杂的编译原理,详情参考圣经龙书。
那么在C文件中自己写一段汇编告诉gcc, “不用gcc你劳神来编译了,按我写的来就行! ,会怎样?当然是很好啊,但是会有问题。
一个C文件混杂这C源码和汇编码,gcc如何保证它使用的寄存器和C文件中程序员自己直接写的汇编使用的寄存器不会相互冲突影响,比如下面:
int i = 3, j = 6, k;
add $0x3, %%rbx
k = i + j;
这段代码让gcc如何是好?
这是比较简单的情况,gcc扫描一遍就知道汇编使用了rbx,然后它小心的避开和rbx冲突即可,但是如果嵌入的汇编逻辑非常复杂,这就意味着gcc不光要理解C语言,还要理解汇编语言,换言之,它同时要做一个汇编语言的汇编器。
所以说,更好的做法是,gcc只关注C代码,至于程序员自己直接写的汇编,不去管它的逻辑,直接inline到它该在的位置即可,为了避免冲突,需要编写这段汇编的程序员告诉gcc,自己动用了哪些寄存器,一切归结到一套接口而不是实现。
嗯,这就是 inline汇编!(确切说应该是扩展inline汇编)
inline汇编的接口和调用如下:
asm $修饰关键字 ( "实际汇编码"
: 输出列表(寄存器,内存…)
: 输入列表(寄存器,内存…)
: 会产生的副作用clobber列表(影响的寄存器,内存,标志寄存器…)
)
如果你希望gcc在inline这段汇编码之后,不再信任之前的某个寄存器的值依然有效,那就将这个寄存器加入到clobber列表即可。
那么是不是说,只要不在clobber列表中的寄存器,gcc就会信任它呢?严格来讲是的。
可以说 inline汇编的input和output就是gcc的结界!gcc不理解也不试图理解结界中发生了什么,它也做不到。 gcc能深入到inline汇编的最深的边界就是input列表和output列表!
我们知道,input列表和output列表都可以包含寄存器,很显然,对于output寄存器,gcc非常明确地知道发生了什么-- 将一个值送到了某个寄存器或者某个地址 ,那么此后离开了inline汇编,gcc依然可以使用这个最后被inline汇编送入值的寄存器并且信任它的值。
但是,对于input寄存器呢?
我们知道,gcc通过input列表为inline汇编的某个input寄存器送进去一个值,然后gcc就什么都不知道了,直到离开inline汇编。问题是,离开inline汇编后,gcc还会依然信任在刚进入inline汇编时通过input列表送入的那个寄存器的值吗?
我将会做一系列的实验,来证明答案是 “是的!gcc依然会信任这个input寄存器的值!”
先看第一个代码:
#include
#include
static unsigned long test(void)
{
// 使用register寄存器变量,更容易展示问题。不然使用O0的话,每次都访存,使用O1的话,又hold不住逻辑
register long i = 7, j;
// 下面的inline汇编什么也不做,只是将i赋值给j而已
asm volatile("nop"
: "=b"(j)
: "b"(i)
:);
// 此时,j保存在rbx中
asm volatile("nop"
:
:
: "rbx"); // 明确rbx是不可信任的
j += 3;
return j;
}
int main()
{
printf("%d\n", test());
}
用objdump看看汇编码:
000000000040051d :
40051d: 55 push %rbp
40051e: 48 89 e5 mov %rsp,%rbp
400521: 41 54 push %r12
400523: 53 push %rbx
400524: bb 07 00 00 00 mov $0x7,%ebx ;按照inline输入,将i的值7载入寄存器rbx
400529: 90 nop ;执行inline汇编代码
40052a: 48 89 d8 mov %rbx,%rax ;按照输出,将rbx的值输出给变量j,j的值先传递给中间寄存器rax
40052d: 49 89 c4 mov %rax,%r12 ;由于inline汇编的clobber列表声明,gcc不再信任inline汇编执行后rbx的值,所以使用另一个寄存器r12暂存j
400530: 90 nop ;执行inline汇编
400531: 49 83 c4 03 add $0x3,%r12 ;暂存j值的寄存器r12递增3
400535: 4c 89 e0 mov %r12,%rax ;j的值将通过rax返回
400538: 5b pop %rbx
400539: 41 5c pop %r12
40053b: 5d pop %rbp
40053c: c3 retq
符合预期,我们将rbx加入了clobber列表,于是gcc就为rbx里的输出j临时选择了另外一个寄存器r12。显然,gcc觉得第二个inline汇编已经将rbx蹂躏了,执行完inline汇编后,rbx里已经不再是j了。
现在,我们把rbx从clobber移除,作为input放到第二个inline汇编的input中去:
#include
#include
static unsigned long test(void)
{
register long i = 7, j;
asm volatile("nop"
: "=b"(j)
: "b"(i)
:);
// 此时,j保存在rbx中
asm volatile("nop"
:
: "b"(j)
:);
j += 3;
return j;
}
int main()
{
printf("%d\n", test());
}
看看objdump的汇编:
000000000040051d :
40051d: 55 push %rbp
40051e: 48 89 e5 mov %rsp,%rbp
400521: 53 push %rbx
400522: bb 07 00 00 00 mov $0x7,%ebx ;按照inline输入,将i的值7载入寄存器rbx
400527: 90 nop ;执行inline汇编代码
400528: 48 89 d8 mov %rbx,%rax ;按照输出,将rbx的值输出给变量j,j的值先传递给中间寄存器rax
40052b: 48 89 c3 mov %rax,%rbx ;由于inline汇编的clobber列表中未声明rbx,所以gcc相信rbx仅仅作为输入,inline汇编不会"干扰"rbx的值,j值在rbx中直接交给第二个inline汇编的input
40052e: 90 nop ;执行inline汇编
40052f: 48 83 c3 03 add $0x3,%rbx ;果然,直接使用了rbx的值作为j,因为gcc相信inline汇编只是将它作为输入,并不会改变它。
400533: 48 89 d8 mov %rbx,%rax ;j的值将通过rax返回
400536: 5b pop %rbx
400537: 5d pop %rbp
400538: c3 retq
可以看出,gcc虽然不知道inline汇编里发生了什么,但是它在inline汇编执行后,依然还是直接取rbx的值作为j的值的,也就是说,无论inline汇编里发生了什么,gcc始终相信这个代码的执行结果是10:
[root@localhost inlinetest]# ./a.out
10
可是现实情况如何呢?
我在inline汇编里只是塞了一个nop,并没有实际蹂躏rbx,所以结果并没有什么问题。如果在inline汇编里 以某种非常意外的方式改变了rbx的值 呢?比如用它来暂时存储一个指针什么的,最终的结果会被影响吗?
我们先来简单的在inline汇编中将rbx的值递增3看看:
#include
#include
static unsigned long test(void)
{
register long i = 7, j;
asm volatile("nop"
: "=b"(j)
: "b"(i)
:);
// 此时,j保存在rbx中
asm volatile("add $0x3, %%rbx"
:
: "b"(j)
:);
j += 3;
return j;
}
int main()
{
printf("%d\n", test());
}
依然看objdump的汇编码:
000000000040051d :
40051d: 55 push %rbp
40051e: 48 89 e5 mov %rsp,%rbp
400521: 53 push %rbx
400522: bb 07 00 00 00 mov $0x7,%ebx
400527: 90 nop
400528: 48 89 d8 mov %rbx,%rax
40052b: 48 89 c3 mov %rax,%rbx
40052e: 48 83 c3 03 add $0x3,%rbx ;inline汇编的"将rbx的值递增3"的逻辑
400532: 48 83 c3 03 add $0x3,%rbx ;gcc将C代码j += 3编译而成的汇编,但是这个时候,rbx还可以信任吗??嗯??
400536: 48 89 d8 mov %rbx,%rax
400539: 5b pop %rbx
40053a: 5d pop %rbp
40053b: c3 retq
OK,执行一下看看:
[root@localhost inlinetest]# ./a.out
13
rbx的值变化了!你可能会有疑问,inline汇编里明明就是想让rbx值增加3啊,这有什么问题吗?皮鞋湿了,没有胖!为此,我们来个更猛一点的:
#include
#include
static unsigned long test(void)
{
register long i = 7, j;
asm volatile("nop"
: "=b"(j)
: "b"(i)
:);
// 此时,j保存在rbx中
asm volatile("popq %%rcx\n\t"
"popq %%rbx\n\t" // 非常规方式操作rbx,用它暂存一个指针。gcc并不知道rbx被如此使用了!
"pushq %%rbx\n\t"
"pushq %%rcx\n\t"
:
: "b"(j)
: "rcx"); // 声明不再信任rcx
j += 3;
return j;
}
int main()
{
printf("%d\n", test());
}
执行之,看看效果,连续执行3遍:
[root@localhost inlinetest]# ./a.out
376178963
[root@localhost inlinetest]# ./a.out
2122995203
[root@localhost inlinetest]# ./a.out
2020342467
彻底乱了!皮鞋啊皮鞋!怎么办?
简单,将rbx写进clobber列表不就可以了吗?这样gcc就可以选择别的寄存器来存触j以及暂存j的中间结果了。
那么还是使用之前那个简单inline汇编中将rbx递增3的简单例子,我们试试看:
#include
#include
static unsigned long test(void)
{
register long i = 7, j;
asm volatile("nop"
: "=b"(j)
: "b"(i)
:);
// 此时,j保存在rbx中
asm volatile("add $0x3, %%rbx"
:
: "b"(j)
: "rbx"); // 这次我将rbx放进clobber列表,告诉gcc在inline汇编后别再信任它的值!
j += 3;
return j;
}
int main()
{
printf("%d\n", test());
}
然而,编译之:
[root@localhost inlinetest]# gcc inlineASM6.c
inlineASM6.c: 在函数‘test’中:
inlineASM6.c:13:2: 错误:‘asm’操作数中有不可能的约束
asm volatile("add $0x3, %%rbx"
^
语法错误!
原来,inline汇编约定,只要在input或者output列表中声明过的输入输出寄存器,就不能出现在clobber列表中了。这很容易理解,因为clobber列表的意思是,列表中的寄存器都可能会被随意使用和改变,仅此而已,gcc看到了这样的声明自然会避开使用它们,而input或者output列表中的寄存器那是 一定被改变的 ,这种改变是显式的,因此也就没有必要出现在clobber列表中了。
怎么办?
怎么办?只能自己动手了吗?看来这个事只能是编程者自己来做相关保证了!
因此, 如果你使用了rbx作为输入,rbx就不会出现在clobber列表,gcc就会无条件信任它,要保证这种信任不会出错,inline汇编逻辑必须自己保证rbx的不变性!
你不能指望gcc在inline汇编调用后,不会按照调用前的语义继续使用inline汇编的输入寄存器,所以正确的代码,必须保持输入寄存器的值在inline汇编代码中不会被改变。
当然了,gcc也可能根本不会再继续按照原来的语义使用inline汇编的input寄存器,但这也只是可能而已,谁敢冒这个险作出这个无法得到承诺的保证呢…
现在回到Linux内核的进程切换代码的switch_to宏,我们详细看下它之前,要明白的一件事就是:
现在可以看一下代码细节了:
ffffffff81718370 <__schedule>:
ffffffff81718370: e8 7b 0d 01 00 callq ffffffff817290f0 <__fentry__>
ffffffff81718375: 55 push %rbp
ffffffff81718376: 65 48 8b 0c 25 80 0e mov %gs:0x10e80,%rcx
ffffffff8171837d: 01 00
...
; r15中的next赋值给inline汇编的input参数rsi,0x20(%rsp)代表的prev赋值给inline汇编的另一个input参数rdi
ffffffff81718724: 4c 89 fe mov %r15,%rsi
ffffffff81718727: 48 8b 7c 24 20 mov 0x20(%rsp),%rdi
; 下面就是switch_to宏
;begin ----------------------------------------
ffffffff8171872c: 9c pushfq
ffffffff8171872d: 55 push %rbp
; rsi中保存的是next指针,后面以call的方式会调用__switch_to,调用约定是不改变rbp,因此rbp便可以用来暂存input参数之一rsi。
ffffffff8171872e: 48 89 f5 mov %rsi,%rbp
ffffffff81718731: 48 89 a7 b8 06 00 00 mov %rsp,0x6b8(%rdi)
ffffffff81718738: 48 8b a6 b8 06 00 00 mov 0x6b8(%rsi),%rsp
; 这里使用call而不是jmp的原因是需要依靠callee的rbp不变性来让rbp保存rsi!!
ffffffff8171873f: e8 8c 1d 91 ff callq ffffffff8102a4d0 <__switch_to>
ffffffff81718744: 65 48 8b 35 34 87 8f mov %gs:0x7e8f8734(%rip),%rsi # 10e80
ffffffff8171874b: 7e
ffffffff8171874c: 4c 8b 86 b0 04 00 00 mov 0x4b0(%rsi),%r8
ffffffff81718753: 65 4c 89 05 cd 78 8e mov %r8,%gs:0x7e8e78cd(%rip) # 28
ffffffff8171875a: 7e
ffffffff8171875b: 4c 8b 46 08 mov 0x8(%rsi),%r8
; 此时的__switch_to返回值rax就是prev指针,而input参数rdi也是prev,因此可以用__switch_to返回值rax恢复rdi
ffffffff8171875f: 48 89 c7 mov %rax,%rdi ; 恢复rdi
ffffffff81718762: 41 f7 40 10 00 00 04 testl $0x40000,0x10(%r8)
ffffffff81718769: 00
ffffffff8171876a: 0f 85 10 ce 00 00 jne ffffffff81725580
ffffffff81718770: 48 89 ee mov %rbp,%rsi ;恢复rsi
ffffffff81718773: 5d pop %rbp
ffffffff81718774: 9d popfq
;end ----------------------------------------
;swtich_to宏到此结束,虽然后面并没有再用到rsi和rdi,但是inline汇编不敢做这种保证!!
ffffffff81718775: 48 89 c6 mov %rax,%rsi ; 瞬间就冲刷了rsi
ffffffff81718778: 48 c7 c7 40 8b 01 00 mov $0x18b40,%rdi ; 冲刷rdi
ffffffff8171877f: 65 48 03 3d 19 8a 8f add %gs:0x7e8f8a19(%rip),%rdi # 111a0
ffffffff81718786: 7e
ffffffff81718787: e8 e4 3f 9b ff callq ffffffff810cc770
我们看到,为了保证rsi和rdi最终的恢复,使用了两种方式:
也许有人会问,既然从上述Linux内核vmlinux的汇编码都可以看到在switch_to宏之后,根本就没有再用到rsi和rdi,为什么要做这种保证从而在inline汇编逻辑的最后恢复它们成为input时的原始值?
答案已经在我上面的例子中了。当然,对于我手上的Linux 3.10内核这个特例,我把 SAVE_CONTEXT 中下面的代码去掉:
movq %%rsi,%%rbp
重新编译内核,压测了一个小时,并没有任何问题。但是,必须明白, 这也许是因为gcc恰好没有在inline汇编之后将rsi继续当作next指针使用!
在执行流被inline汇编接管之前,gcc可以理解的最后一条语句就是rsi和rdi的input赋值语句,接下来gcc就不再试图去编译inline汇编了,因为它本来就是汇编,所以说,gcc会如下以为:
mov $next,%rsi
mov $prev,%rdi
...这里是gcc不理解的inline汇编世界
; 结束inline汇编后,gcc会依然觉得rsi里面是next,而rdi里面是prev。
所以说,不使用rsi和rdi只是巧合而已。我们看看C代码,也会看出来rsi和rdi在switch_to宏之后就没有再被用到了:
static inline void
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next)
{
...
/* Here we just switch the register state and the stack. */
switch_to(prev, next, prev);
barrier();
/*
* this_rq must be evaluated again because prev may have moved
* CPUs since it called schedule(), thus the 'rq' on its stack
* frame will be invalid.
*/
finish_task_switch(this_rq(), prev);
}
但是如果哪一天,高版本内核的代码变成了下面的样子,如果没有在inline汇编中恢复rsi和rdi,会怎样呢?
static inline void
context_switch(struct rq *rq, struct task_struct *prev,
struct task_struct *next)
{
...
/* Here we just switch the register state and the stack. */
switch_to(prev, next, prev);
// 增加一个对next的引用,gcc可能就会直接使用rsi!
something_new(next);
...
}
幸运的是,我们再也无需担心这种事了,也不必纠结inline汇编到底如何提示gcc哪些寄存器使用会有风险了。在高版本内核中,不再使用inline汇编来实现switch_to了,而是直接采用汇编编码。
来自这个patch:
[PATCH 3/4] x86: Rewrite switch_to() code: https://lkml.org/lkml/2016/5/21/55
该patch特意说重构这个switch_to的收益:
It also improves code generation for __schedule() by using the C calling convention instead of clobbering all registers.
现在看看重构后的新的switch_to:
/*
* %rdi: prev task
* %rsi: next task
*/
ENTRY(__switch_to_asm)
UNWIND_HINT_FUNC
/*
* Save callee-saved registers
* This must match the order in inactive_task_frame
*/
pushq %rbp
pushq %rbx
pushq %r12
pushq %r13
pushq %r14
pushq %r15
/* switch stack */
movq %rsp, TASK_threadsp(%rdi)
movq TASK_threadsp(%rsi), %rsp
#ifdef CONFIG_CC_STACKPROTECTOR
movq TASK_stack_canary(%rsi), %rbx
movq %rbx, PER_CPU_VAR(irq_stack_union)+stack_canary_offset
#endif
/* restore callee-saved registers */
popq %r15
popq %r14
popq %r13
popq %r12
popq %rbx
popq %rbp
; 不用靠rbp的callee不变性来保存rsi了,所以直接使用jmp更加高效!
jmp __switch_to
END(__switch_to_asm)
简单直接多了!
整个过程细节和进程切换机制的关系并不大,本文分析的内容只是一种编程约定,而这种约定处处可见。
Intel处理器手册里对其每一款处理器的每一个寄存器都有其使用约定,比如是caller维护还是callee维护等等,这种约定是建议性的约定,如果你自己在保证不引起歧义的前提下,完全可以不遵守这种约定,但是,好的代码一定是遵守约定的代码。
这个和TCP/IP协议约定还不同,网络协议约定是运行时的约定,这种运行时约定最终一定映射到了机器,是MUST的,而寄存器的使用约定只是编码和编译期间的约定,并不是最终的机器码。
最后举一个例子,那就是C语言的malloc和free。
下面的代码有错吗?
#include
#include
int do_something(char *p)
{
// do nothing
return 1;
}
void func(char *mem, int *freed)
{
if (do_something(mem)) {
*freed = 0;
} else {
free(mem);
*freed = 1;
}
}
int main()
{
char *p;
int ret;
p = (char *)malloc(128);
func(p, &ret);
if (ret == 0) {
free(p);
}
}
没有什么大问题,但是这个代码并不好,总觉得别扭。好一点的写法是:
#include
#include
int do_something(char *p)
{
// do nothing
return 1;
}
void func(char *mem, int *freed)
{
do_something(mem);
}
int main()
{
char *p;
int ret;
p = (char *)malloc(128);
func(p, &ret);
free(p);
}
这就是一种编程约定, 谁申请的内存,谁负责释放。 但是如果你不这样,采用一些trick式的写法,程序依然可以正确运行,但是却为以后的维护和troubleshooting带来极大的困扰,就比如说,3.10版本的内核虽然在switch_to后没有再用到rsi和rdi,即便你不恢复rsi和rdi也不会有什么问题,但是这并不是说在inline汇编中不恢复rsi和rdi就是 无错 的做法。
关于inline汇编的以上这些约定,总而言之就是 为了把副作用限制在最小的范围内,阻止寄存器污染扩散!
没有问题,并不意味着是正确的。只是没有触发而已! 多少故障都是因为这句 “并没有什么问题啊!” 引起的!一定要引以为戒!
我们追求的是 正确的做法,而不是没有问题的做法!
32位要比64位更奇葩,它根本不用cobber列表的方式废掉寄存器,而是使用output列表的方式,想想看,如果所有寄存器都被赋值了,它们还能保持原意吗?
浙江温州皮鞋湿,下雨进水不会胖!