Linux内核入门(四)—— 内核汇编语言规则

时间:2023-01-03 03:25:24

    任何一个用高级语言编写的操作系统,其内核源代码中总有少部分代码是用汇编语言编写的。读 过Unix Sys V源代码的读者都知道,在其约3万行的核心代码中用汇编语言编写的代码约2000行,分 成不到20个扩展名为.s和.m的文件,其中大部分是关于中断与异常处理的底层程序,还有就是与初始 化有关的程序以及一些核心代码中调用的公用子程序。

    用汇编语言编写核心代码中的部分代码,大体上是出于如下几个方面的考虑的:

   ●   操作系统内核中的底层程序直接与硬件打交道,需要用到一些专用的指令,而这些指令在C 语言中并无对应的语言成分。例如,在386系统结构中,对外设的输入/输出指令如inb, outb 等均无对应的C语言语句。因此,这些底层的操作需要用汇编语言来编写。CPU中的一些对 寄存器的操作也是一样,例如,要设置一个段寄存器时,也只好用汇编语言来编写。
   ●   CPU中的一些特殊指令也没有对应的C语言成分,如关中断,开中断等等。此外,在同一种 系统结构的不同CPU芯片中,特别是新开发出来的芯片中,往往会增加一些新的指令,例如 Pentium, Pentium II和Pentium MMX,都在原来的基础土扩充了新的指令,对这些指令的使用 也得用汇编语言。
   ●   内核中实现某些操作的过程、程序段或函数,在运行时会非常频繁地被调用,因此其(时间) 效率就显得很重要。而用汇编语言编写的程序,在算法和数据结构相同的条件下,其效率通常 要比用高级语言编写的高。在此类程序或程序段中,往往每一条汇编指令的使用都需要经过推 敲。系统调用的进入和返回就是一个典型的例子。系统调用的进出是非常频繁用到的过程,每 秒钟可能会用到成千上万次,其时间效率可谓举足轻重。再说,系统调用的进出过程还牵涉到 用户空间和系统空间之间的来回切换,而用于这个目的的一些指令在C语言中本来就没有对 应的语言成分,所以,系统调用的进入和返回显然必须用汇编语言来编写。
   ●   在某些特殊的场合,一段程序的空间效率也会显得非常重要。操作系统的引异程序就是一个例 子。系统的引导程序通常一定要能容纳在磁盘上的第一个扇区中。这时候,哪怕这段程序的大 小多出一个字节也不行,所以就只能以汇编语言编写。

    在Linux内核的源代码中,以汇编语言编写的程序或程序段,有几种不同的形式:

    第一种是完全的汇编代码,这样的代码采用.s作为文件名的后缀。事实上,尽管是“纯粹”的汇编代码,现代的汇编工具也吸收了C语言预处理的长处,也在汇编之前加上了一趟预处理,而预处理 之前的文件则以.S为后缀。此类(.S)文件也和C程序一样,可以使用#include, #ifdef等等成分,而 数据结构也一样可以在.h文件中加以定义。

    第二种是嵌入在C程序中的汇编语言片段。虽然在ANSI的C语言标准中并没有关于汇编片段的 规定,事实上各种实际使用的C编译中都作了这方面的扩充,而GNU的C编译gcc也在这方面作了 很强的扩充。

    此外,内核代码中也有几个Intel格式的汇编语言程序,是用于系统引导的。 由于我们专注于Intel i386系统结构下的Linux内核,下面我们只介绍GNU对i386汇编语言的支持

    对于新接触Linux内核源代码的读者,哪怕他比较熟悉i386汇编语言,在理解这两种汇编语言的 程序或片段时都会感到困难,有的甚至会望而却步。其原因是:在内核“纯”汇编代码中GNU采用了 不同于常用386汇编语言的句法;而在嵌入C程序的片段中,则更增加了一些指导汇编工具如何分配 使用寄存器、以及如何与C程序中定义的变量相结合的语言成分。这些成分使得嵌入C程序中的汇编 语言片段实际上变成了一种介乎386汇编和C之间的一种中间语言。

    所以,我们先集中地介绍一下在内核中这两种情况下使用的386汇编语言,以后在具体的情景中 涉及具体的汇编语言代码时还会加以解释。

 

1 GNU的386汇编语言


    在Dos/Windows领域中,386汇编语言都采用由Intel定义的语句(指令)格式,这也是几乎在所 有的有关386汇编语言程序设计的教科书或参考书中所使用的格式。可是,在Unix领域中,采用的却 是由AT&T定义的格式。当初,当AT&T将Unix移植到80386处理器上时,根据Unix圈内人上的习 惯和需要而定义了这样的格式。Unix最初是在PDP-11机器上开发的,先后移植到VAX和68000系列 的处理器上。这些机器的汇编语言在风格上、从而在格式上与Intel的有所不同。而AT&T定义的386 汇编语言就比较接近那些汇编语言。后来,在Unixware中保留了这种格式。GNU主要是在Unix领域 内活动的(虽然GNU是“GNU is Not Unix”的缩写)。为了与先前的各种Unix版本与工具有尽可能好 的兼容性,由GNU开发的各种系统工具自然地继承了AT&T的386汇编语言格式,而不采用Intel的 格式

    那么,这两种汇编语言之间的差距到底有多大呢?其实是大同小异。可是有时候小异也是很重要 的,不加重视就会造成困扰。具体讲,主要有下面这么一些差别:

   (1)   在Intel格式中大多使用大写字母,而在AT&T格式中都使用小写字母。
   (2)   在AT&T格式中,寄存器名要加上“%”作为前缀,而在Intel格式中则不带前缀。
   (3)   在AT&T的386汇编语言中,指令的源操作数与目标操作数的顺序与在Intel的386汇编语言 中正好相反。在Intel格式中是目标在前,源在后;而在AT&T格式中则是源在前,目标在后。 例如,将寄存器eax的内容送入ebx,在Intel格式中为"MOVE EBX,EAX",而在AT&T格 式中为"move %eax, %ebx"看来,Intel格式的设计者所想的是"EBX=EAX", 而AT&T 格式的设计者所想的是“%eax一>%ebx”。
   (4)   在AT&T格式中,访内指令的操作数大小(宽度)由操作码名称的最后一个字母(也就是操 作码的后缀)来决定。用作操作码后缀的字母有b(表示8位),w(表示16位)和l(表示 32位)。而在Intel格式中,则是在表示内存单元的操作数前面加卜"BYTE PTR","WORD PTR",或"DWORD PTR"来表示。例如,将FOO所指内存单元中的字节取入8位的寄存 器AL,在两种格式中不同的表示如下:
      MOV AL, BYTE PTR FOO(Intel格式)
      movb FOO,%a1(AT&T格式)
   (5)   在AT&T格式中,直接操作数要加上“$”作为前缀,而在Intel格式中则不带前缀。所以, Intel格式中的"PUSH 4",在AT&T格式中就变为"pushl $4"。
   (6)   在AT&T格式中,绝对转移或调用指令jump/call的操作数(也即转移或调用的目标地址), 要加上“*”作为前缀(读者大概会联想到C语言中的指针吧),而在Intel格式中则不带。
   (7)   远程的转移指令和子程序调用指令的操作码名称,在AT&T格式中为“ljmp”和“lcall气而 在Intel格式中,则为"JMP FAR"和"CALL FAR"。当转移和调用的目标为直接操作数时, 两种不同的表示如下:
      CALL FAR SECTION:OFFSET (Intel格式)
      JMP FAR SECTIOM:OFFSET (Intel格式)
      lcall $section, $offset (AT&T格式)
      ljmp $section,$offset (AT&T格式)
与之相应的远程返回指令,则为:
      RET FAR STACK_ADJUST(Intel格式)
      lret $stack_adjust (AT&T格式)
   (8)   间接寻址的一般格式,两者区别如下:
      SECTION: [BASE + INDEX*SCALE + DISP](Intel格式)
      section: disp (base, index, scale)(AT&T格式)

    注意在AT&T格式中隐含了所进行的计算。例如,当SECTION省略,INDEX和SCALE也省略, BASE为EBP,而DISP(位移)为4时,表示如下:
      [ebp-4](Intel格式)
      -4(%ebp) (AT&T格式)
在AT&T格式的括号中如果只有一项base,就可以省略逗号,否则不能省略,所以(%ebp)相当 +(%ebp,,),进一步相当于(ebp, 0, 0)。又如,当INDEX为EAX, SCALE为4 (32位),DISP 为foo,而其他均省略,则表示为:
      [foo+EAX*4](Intel格式)
      foo(,%EAX,4)(AT&T格式)
这种寻址方式常常用于在数据结构数组中访问特定元素内的一个字段,base为数组的起始地址, scale为每个数组元素的大小,index为下标。如果数组元素是数据结构,则disp为具体字段在结构中 的位移。

 

2 嵌入在C语言中的汇编语言


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

__asm__ ("汇编代码段")
__asm__ __volatile__ (指定操作 + "汇编代码段")

    由于具体的汇编语言规则相当复杂,所以我们只关心与内核源代码相关主要规则,并通过几个例子来加以描述,其他规则具体请参考相关CPU的手册。

    例1 :在include/asm-i386/io.h中有这么一行:
     #define __SLOW_DOWN_IO __asm__ __volatile__ ("outb %al, $0x80")
    表示8位输出指令。b表示这是8位的,而0x80是常数,即所谓“直接操作数”,所以要加上前缀“$”,而寄存器名al也加了前缀“%”。

    例2 :在同一个asm语句中也可以插入多行汇编程序。就在同一个文件中,在不同的条件下,__SLOW_DOWN_IO又有不同的定义:
     #define __SLOW_DOWN_IO __asm__ __volatile__("/njmp 1f/n1:/tjmp 1f/n1:")
    这里就不那么直观了,这里,一种插入了三行汇编语句,“/n”就是换行符,而“/t”则表示TAB符。这些规则跟printf语句中转义字符的规则一样:

      jmp lf
l:    jmp lf
l:

    这里转移指令的目标lf表示前往(f表示forward)找到第一个标号为l的那一行。相应地,如果是lb就表示往后找。所以这一小段代码的用意就在于使CPU空做两条转移指令而消耗一些时间。

    例3 :下面看一段来自include/asm-i386/atomic.h的代码。

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

一般而言,往C代码中插入汇编语言的代码是很复杂的,因为这里有个分配寄存器呵与C语言代码中的变量结合的问题。为了这个目的,必须对所使用的汇编语言做更多的扩充,增加对汇编工具的指导作用。
     下面,先介绍一下插入C代码中的汇编成分的一般格式,并加以解释。以后在我们碰到具体代码时还会加以提示:
    插入C代码中的一个汇编语言代码片断可以分成四部分,以“:”号加以分隔,其一般形式为:
                指令部:输出部:输入部:损坏部
    注意不要把这些“:”和程序标号中所用的(如前面的1:)混淆。
    第一部分就是汇编语句本身,其格式与汇编程序中使用的基本相同,但也有区别,不同支出马上会讲到。这一部分可以称为“指令部”,是必须有的,而其他各部分则可视具体情况而省略,所以最简单的情况下就与常规的汇编语句基本相同,如前面两个例子那样。
    在指令部中,数字加上前缀%,如%0、%1等等,表示需要使用寄存器的样板操作数。那么,可以使用此类操作数的总数取决于具体CPU中通用寄存器的数量, 这样,指令部中用到了几个不同的操作数,就说明有几个变量需要与寄存器结合,由gcc和gas在编译时根据后面的约束条件变通处理。
     那么,怎样表达对变量结合的约束条件呢?这就是其余几个部分的作用。“输出部 ”,用以规定对输出变量,即目标操作数 如何结合的约束条件。必要时输出部中可以有多个约束,以逗号分隔。每个输出约束以“=”号开头,然后时以个字母表示对操作数类型的说明,然后时关于变量结合的约束。例如:
:"=m" (v->counter),这里只有一个约束,“=m”表示相应的目标操作数(指令部中的%0)是一个内存单元
v->counter。凡是与输出部中说明的操作数相结合的寄存器或操作数本身,在实行嵌入汇编代码以后均部保留执行之前的内容,这就给gcc提供了调度使用这些寄存器的依据。
     输出部后面是“输入部 ”。 输入约束的格式与输出约束相似,但不带“=”号。在前面例子中的输入部有两个约束。第一个为“ir”(i),表示指令中的%1可以是一个在寄存器中的“直 接操作数”,并且该操作数来自于C代码中的变量名i(括号中)。第二个约束为"m" (v->counter),意义与输出约束中相同。
     回过头来,我们再来看指令部中的%号加数字,其代表指令的操作数的编号,表示从输出部的第一个约束(序号为0)开始,顺序数下来,每个约束计数一次。
     另外,在一些特殊的操作中,对操作数进行字节操作时也允许明确指出是对哪一个字节操作,此时在%与序号之间插入一个”b“表示最低字节,插入一个”h“表示次低字节。

常用约束条件一览
m, v, o —— 表示内存单元;
r —— 表示任何寄存器;
q —— 表示寄存器eax、ebx、ecx、edx之一;
i, h —— 表示直接操作数;
E, F —— 表示浮点数;
g —— 表示”任意“;
a, b, c, d —— 分表表示要求使用寄存器eax、ebx、ecx和edx;
S, D —— 分别表示要求使用寄存器esi和edi;
I —— 表示常数(0到31)。

回到上面的例子,读者现在应该很容易理解这段代码的作用是将参数I的值加到v->counter上。代码中的关键字LOCK表示在执行addl指令时要把系统的总线锁住,保证操作的”原子性(atomic)“

    例4 :再看一段嵌入汇编代码,这一次取自include/asm-i386/bitops.h

#ifdef CONFIG_SMP
#define LOCK_PREFIX "lock ; "
#else
#define LOCK_PREFIX ""
#endif

#define ADDR (*(volatile long *) addr)

static __inline__ void set_bit(int nr, volatile void * addr)
{
    __asm__ __volatile__( LOCK_PREFIX
        "btsl %1,%0"
        :"=m" (ADDR)
        :"Ir" (nr));
}

   这里的指令btsl将一个32位操作数中的某一位设置成1。参数nr和addr表示将内存地址为addr的32位数的nr位设置成1。

    例5 :再来看一个复杂,但又非常重要的例子,来自include/asn-i386/string.h:

static inline void * __memcpy(void * to, const void * from, size_t n)
{
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);
}

    这里的__memcpy函数就是我们经常调用的memcpy函数的内核底层实现,用来复制内存空间的内容。参数to是复制的目的地址,from是源地址,n位复制的内容的长度,单位是字节。gcc生成以下代码:

rep ; movsl
      testb $2, %b4
      je 1f
      movsw
1:    testb $1, %b4
      je 2f
      movsb
2:

    其中输出部有三个约束,函数内部变量d0、d1、d2分别对应操作数%0至%2,其中d0必须放在ecx寄存器中;d1必须放在edi寄存器中;d2必须放在esi寄存器中。再看输入部,这里又有四个约束分别对应操作数%3、
%4、%5、%6。其中操作数%3与操作数%0使用同一个寄存器ecx,表示将复制长度从字节个数换算成长字个数(n/4);%4表示n本身,要求任意分配一个寄存器存放;%5、%6即参数to和from,分别与%1和%2使用相同的寄存器(edi和esi)
     再看指令部。第一条指令是”rep“,只是一个标号,表示下一条指令movsl要重复执行,每重复一遍就把寄存器ecx中的内容减1,直到变成0为止。所 以,在这段代码中一共执行n/4次。movsl是386指令系统中一条很重要的复杂指令,它从esi所指到的地方复制一个长字到edi所指的地方,并使 esi和edi分别加4。这样,当代码中的movsl指令执行完毕,准备执行testb指令的时候,所有的长字都复制好了,最多只剩下三个字节了。在这个 过程中隐含用到了上述三个寄存器,这就说明了为什么这些操作数必须在输入和输出部中指定必须存放的寄存器。
    接着就是处理剩下的字节了(最多三个)。先通过testb测试操作数%4,即复制长度n的最低字节中的bit2,如果这一位位1就说明至少还有两 个字节,所以就通过movesw复制一个短字(esi和edi则分别加2),否则就把它跳过。再通过testb测试操作数%4的bit1,如果这一位为 1,就说明还剩一个字节,所以通过指令movsb再复制一个字节,否则跳过。当达到标号2的时候,执行就结束了。

    

    在include/asm-i386中有许多最基本的汇编函数,有时间的话,大家不妨随便找几个练习一下。