linux 内核源代码情景分析——linux 内核源码中的汇编语言代码

时间:2022-05-30 03:24:46

1. 用汇编语言编写部分核心代码的原因:

① 操作系统内核中的底层程序直接与硬件打交道,需要用到一些专用的指令,而这些指令在C语言中并无对应的语言成分;

② CPU中的一些特殊指令也没有对应的C语言成分,如关中断、开中断等等;

③ 内核中的某些函数在运行时会非常频繁的被调用,因此效率就显得很重要,用汇编语言写的程序效率通常要比高级语言编写的高;

 在某些特殊场合,一段程序的空间效率也会显得很重要;

 

2. linux 采用了AT&T的386汇编语言格式,而没有用Intel的,它们之间的差别主要有:

 在Intel格式中大多使用大写字母,而在AT&T格式中都使用小写字母;

 在AT&T格式中,寄存器名要加上“%”作为前缀,而在Intel格式中则不带前缀;

 在AT&T的386汇编语言中,指令的源操作数与目标操作数的顺序与在Intel的386汇编语言中正好相反;在Intel格式中是目标在前,源在后;而在AT&T格式中则是源在前,目标在后;

 在AT&T格式中,访内指令的操作数大小由操作码名称的最后一个字母来决定,用作操作码后缀的字母有b(表示8位),w(表示16位)和l(表示32位)。而在Intel格式中,则是在表示内存单元的操作数前面加上“BYTE PTR”,“WORD PTR”,或“DWORD PTR”来表示;

 在AT&T格式中,直接操作数要加上“$”作为前缀,而Intel则不用带前缀;

⑥ 在AT&T格式中,绝对转移或调用指令jump/call 的操作数(也即转移或调用的目的地址),要加上“*”作为前缀,而在Intel中则不带;

 远程的转移指令和子程序调用指令的操作码名称,在AT&T格式中位“ljmp”和“lcall”,而在Intel格式中,则为“JMP FAR”和“CALL FAR”。当转移和调用的目标位直接操作数时,两种不同的表示如下:

CALL FAR SECTION:OFFSET(Intel 格式)

JMP FAR SECTION:OFFSET(Intel 格式)

lcall $section, $offset (AT&T 格式)

ljmp $section, $offset (AT&T格式)

 间接寻址的一般格式,两者区别如下:

SECTION:[BASE+INDEX*SCALE+DISP] (Intel 格式)

section: disp(base, index, scale) (AT&T 格式)

3. 嵌入C代码中的386汇编语言程序段

当需要在C语言的程序中嵌入一段汇编语言程序段时,可以使用gcc提供的“asm”语句功能,例如:

#define __SLOW_DOWN_IO __asm__ __volatile__ ("outb %al, $0x80")

这里暂且忽略在asm和volatile前后的两个“__”字符,这也是gcc对C语言的一种扩充

在同一个asm语句中可以插入多行汇编程序,例如:

#define __SLOW_DOWN_IO __asm__ __volatile__ ("jmp 1f \n1:\tjmp 1f \n1:")

这里,一共插入了3行汇编语句,“\n”是换行符,而“\t”则表示TAB符,所以gcc将之翻译成下面的格式而交给gas去汇编:

jmp 1f
1:    jmp1f
1:

这里转移指令的目标1f表示往前找到第一个标号为1的那一行。相应地,如果1b就表示往后找。所以,这一小段汇编代码的用意就是使CPU空做两条转移指令而消耗掉一些时间。既然是要消耗掉一些时间,而不是要节省一些时间,那么为什么要用汇编语句来实现,而不是在C里面来实现呢?原因在于想要对此有比较确切的控制。如果用C语句来消耗一些时间的话,你常常不能确定地知道经过编译以后,特别是如果经过优化的话,最后产生的汇编代码究竟怎样。

接着看例子:

static __inline__ void atomic_add(int i, atomict_t *v)
{
    __asm__ __volatile__ (
        LOCK "addl %1, %0"
        : "=m" (v->counter)
        : "ir" (i), "m" (v->counter));
}

下面,先介绍一下插入C代码中的汇编成分的一般格式

插入C代码中的一个汇编语言代码片段可以分成四部分,以“:”号加以分隔,其一般形式为:

指令部:输出部:输入部:损坏部

第一部分就是汇编语句本身,其格式与在汇编语言程序中使用的基本相同,但也有区别,后面会讲到不同之处。

指令不是必须得有的,其他各部分则可视具体的情况而省略,在最简单的情况下就与常规的汇编语句基本相同,如前面的两个例子那样。

当将汇编语言代码片段嵌入到C代码中时,操作数与C代码中的变量如何结合显然是个问题,gcc采取了办法是:程序员只提供具体的指令,而对寄存器的使用则一般只提供一个“样板”和一些约束条件,而把到底如何与变量结合的问题留给gcc和gas去处理。

在指令部中,数字加上前缀%,如%0,%1等等,表示需要使用寄存器的样板操作数,可以使用的此类操作数的总数取决于具体CPU中通用寄存器的数量。这样,指令部中用到了几个不同的这种操作数,就说明有几个变量需要与寄存器结合,由gcc和gas在编译和汇编时根据后面的约束条件自行变通处理。由于这些样板操作数也使用“%”前缀,在涉及到具体的寄存器时就要在寄存器名前面加上两个“%”符,以免混淆。

    那么,怎样表达对变量的结合的约束条件呢?这就是其余几个部分的作用。紧接在指令部后面的是“输出部”,用于规定对输出变量,即目标操作数如何结合的约束条件。每个这样的条件称为一个“约束”,必要时输出部中可以有多个约束,互相以逗号隔开,每个输出约束以“=”号开头,然后是一个字母表示对操作数类型的说明,然后是关于变量结合的约束。例如:上面例子中,输出部为  

   : "=m" (v->counter)

这里只有一个约束,“=m”表示相应的目标操作数(指令部中的%0)是一个内存单元v->counter。凡是与输出部说明的操作数相结合的寄存器或操作数本身,在执行嵌入的汇编代码以后均不保留执行之前的内容,这就给gcc提供了调度使用这些寄存器的依据。

    输出部后面就是“输入部”,输入约束的格式和输出约束相似,但不带“=”号。前面例子中的输入部有两个,第一个为“ir”(i),表示指令中的%1可以是一个在寄存器中的“直接操作数”,第二个约束为“m”(v->counter),意义与输出约束相同,如果一个输入约束要求使用寄存器,则在预处理时gcc会为之分配一个寄存器,并自动插入必要的指令将操作数即变量的值装入该寄存器。与输入部中说明的操作数结合的寄存器或操作数本身,在执行嵌入的汇编代码以后也不保留执行之前的内容。例如,这里的%1要求使用寄存器,所以gcc为其分配一个寄存器,并自动插入一条movl指令把参数i的数值装入该寄存器,可是这个寄存器原来的内容就不复存在了。

    操作数的编号从输出部的第一个约束(序号为0)开始,顺序数下来,每个约束计数一次,表示约束条件的字母很多,主要有:

“m”,“v”,“o”                 ——表示内存单元;

“r”                             ——表示任何寄存器;

“q”                             ——表示eax,ebx,ecs,edx 之一;

“i”和“h”                      ——表示直接操作数;

“E”和“F”                      ——表示浮点数;

“g”                             ——表示“任意”;

“a”,“b”,“c”,“d”        ——表示使用寄存器eax,ebx,ecs,edx

“S”和“D”                      ——表示要求使用寄存器esi和edi

“I”                             ——表示常数(0至31)

看一个例子就明白了,例:

static inline void * __memcpy(void *to, const void *from, size_t)
{
    int d0, d1, d2;
    __asm__ __volatile__(
        "rep; movsl\n\t"
        "testb $2, %b4\n\t"
        "je 1f\n\t"
        "movsw\n"
        "1:\ttestb $1, %b4\n\t"
        "je 2f\n\t"
        "movsb\n"
        "2:"
        : "=&c" (d0), "=&D" (d1) , "=&S" (d2)/*输出部*/
        : "0" (n/4), "q" (n), "1" ((long) to), "2" ((long) from)/*输入部*/
        : "memory");
    return (to);
}

输出部有3个约束,变量d0为操作数%0,必须放在寄存器ecx中,d1即%1必须放在寄存器edi中,d2即%2放在esi中;

输入部有4个约束,对应于操作数%3到6%,其中操作数%3与操作数%0使用同一个寄存器即ecx,并且要求由gcc自动插入必要的指令,事先将其设置成n/4,这里的作用是将复制长度从字节个数n换算成长字个数n/4.至于n本身,则要求gcc分配任意一个寄存器存放,对应于操作数%4.操作数5%和6%,即参数to和from,分别与%1和%2使用相同的寄存器,所以,必须是edi和esi。

   因为我搞不懂,所以下面解释一下这段代码的含义:

第一条指令是“rep”,表示下一条指令movsl要重复执行,每重复一遍就要把寄存器ecx的内容减1,知道变为0为止。因为在执行指令之前,ecx被放进了n/4,所以,movsl指令执行了n/4次,那么movsl又干些什么呢?它从esi所指的地方复制一个长字到edi所指的地方,并使esi和edi分别加4.这样,当执行完第5行后,所有的长字都已复制好,最多只剩下3个字节了。

接着就是处理剩下的字节了,先通过testb测试操作数%4即复制长度n的最低字节中的bit2(对操作数进行的字节操作默认为对其最低字节操作,也可以明确指出是对哪一个字节操作,在%与序号之间插入b表示最低字节,插入h表示次低字节,$2表示立即数),如果这一位为1,说明还有至少2个字节,所以通过指令movw复制1个短字(esi和edi分别加2),否则就把它跳过。(testb是做AND运算,但不会把结果写回目的操作数,仅根据结果的值来影响标志位,je当EFLAGS的ZF标志为1时才跳转,ZF为1说明上一次运算结果为0)再通过testb测试操作数%4的bit1,如果为1说明还剩下一个字节,所以通过指令movsb再复制一个字节,否则就把它跳过。到达标号2的时候,执行就结束了。