linux内核源代码中的汇编语言代码

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

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

  • 操作系统内核中的底层程序直接与硬件打交道,需要用到一些专用指令,而这些指令在C语言中并无对应的语言成分。例如,在i386系统结构中,对外设的输入、输出指令如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内核源代码的读者,哪怕他比较熟悉386汇编语言,在理解这两种汇编语言的程序或片段时都会感到困难,有的甚至会望而却步。其原因是:在内核纯汇编代码中GNU采用了不同于常用i386汇编语言的句法;而在嵌入C程序的片段中,则更增加了一些指导汇编工具如何分配使用寄存器、以及如何与C程序中定义的变量相结合的语言成分。这些成分使得嵌入C程序中的汇编语言片段实际上变成了一种介乎386汇编和C之间的一种中间语言。

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

GNU的386汇编语言

在DOS、Windows领域中,386汇编语言都采用Intel定义的语句(指令)格式,这也是几乎在所有的有关386汇编语言程序设计的教科书或参考书中所使用的格式。可是,在Unix领域中,采用的却是由at&t定义的格式。当初,at&t将Unix移植到80368处理器上时,根据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, %al        (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 SECTION: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为ESP,而DISP(位移)为4时,表示如下:

[ebp-4]          (Intel格式)

-4(%esp)      (at&t格式)

这种寻址方式常常用于在数据结构数组中访问特定元素内的一个字段,base为数组的起始地址,scale为每个数组元素的大小,index为下标。如果数组元素是数据结构,则disp为具体字段在结构中的位移。

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

当需要在C语言的程序中嵌入一段汇编语言程序段时,可以使用gcc提供的asm语句功能。例如,在include/asm-i386/io.h中有这么一行:

#define __SLOW_DOWN_IO "\noutb %%al,$0x80"

这是一条8位输出指令,如前所述在操作符上加了后缀b以表示这是8位的,而0x80因为是常数,即所谓直接操作数,所以要加上前缀$,而寄存器名al也加了前缀%。知道了前面所讲at&t格式与Intel格式的不同,这就是一条很普通的汇编指令,很容易理解。

在同一个asm语句中也可以插入多行汇编程序。就在同一个文件中,在不同的条件下,__SLOW_DOWN_IO又有不同的定义:

#define __SLOW_DOWN_IO "\njmp 1f\n1:\tjmp 1f\n1:"

这就不那么直观了。这里,一共插入了三行汇编语句,\n就是换行符,而\t则表示制表符。所以,gcc将之翻译成下面的格式而交给gas去汇编:


jmp 1f
1:    jmp 1f
1:

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

如果读者绝地这毕竟还是容易理解的话,那么下面这一段(取自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代码中的汇编成分的一般格式,并加解释。以后,在我们走过各种情景时碰到具体的代码时还会加以提示。

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

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

注意不要把这些:号跟程序标号中所用的(如前面的1:)混淆。

第一部分就是汇编语句本身,其格式与汇编语言程序中使用的基本相同,但也有区别,不同之处下面会讲到。这一部分可以称为指令部,是必须有的,而其他各部分则可视具体的情况而省略,所以在最简单的情况下就与常规的汇编语句基本相同,如前面的两个例子那样。

当将汇编语言代码片段嵌入到C代码中时,操作数与C代码中的变量如何结合显然是个问题。在本博客开头的两个例子中,汇编指令都没有产生与C程序中的变量结合的问题,所以比较简单。当汇编指令中的操作数需要与C程序中的某些变量结合时,情况就复杂的多了。这是因为,程序员在编写嵌入的汇编代码时,按照程序逻辑的要求很清楚应该选用什么指令,但是却无法确切地知道gcc在嵌入点的前后会把哪一个寄存器分配用于哪一个变量,以及哪一个或哪几个寄存器是空闲着的。而且,光是被动地知道gcc对寄存器的分配情况也还是不够,还得有个手段把使用寄存器的要求告知gcc。反过来影响它对寄存器的分配。当然,如果gcc的功能非常强,那么通过分析嵌入的汇编代码也应该能够归纳出这些要求,在通过优化,最后也能达到目的。但是,即使那样,所引入的不确定性也还是个问题,更何况要做到这样还是不容易。针对这问题,gcc采取了一种折中的办法:程序员只提供具体的指令,而对寄存器的使用则一般只提供一个样板和一些约束条件,而把到底如何与变量结合的问题留给gcc和gas去处理。

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

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

		:"=m" (v->counter)

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

输出部后面是输入部。输入约束的格式与输出约束相似,但不带=号。在前面例子中的输入部有两个约束。第一个为ir(i),表示指令中的%1可以是一个寄存器中的直接操作数(i表示immediate)。并且该操作数来自于C代码中的变量名(这里是调用参数)i。第二个约束为"m" (v->counter),意义与输出约束中相同。如果输入约束要求使用寄存器,则在预处理时gcc会为之分配一个寄存器,并且自动插入必要的指令将操作数即变量的值装入该寄存器。与输入部中说明的操作数结合的寄存器或操作数本身,在执行嵌入的汇编代码以后也不保留执行之前的内容。例如,这里的%1要求使用寄存器,所以gcc会为之分配一个寄存器,并且自动插入一条movl指令把参数i的数值装入寄存器,可是这寄存器原来的内容就不复存在了。如果这个寄存器本来就是空闲的,那倒无所谓,可是如果所有的寄存器都在使用,而只好暂时借用一个,那就得保证在使用以后恢复其原有的内容。此时gcc会自动在开头处插入一条pushl指令,将该寄存器原来的内容保存在堆栈中,而在结束以后插入一条popl指令,恢复寄存器的内容。

在有些操作中,除用于输入操作数和输出操作数的寄存器以外,还要将若干个寄存器用于计算或操作的中间结果。这样,这些寄存器原有的内容就破坏了,所以要在损坏部对操作的副作用加以说明,让gcc采取相应的措施。不过,有时候就直接把这些说明放在输出部了,那也并无不可。

操作数的编号从输出部的第一个约束(序号为0)开始,顺着数下来,每个约束计数一次。在指令部中引用这些操作数或分配用于这些操作数的寄存器时,就在序号前面加上一个%号。在指令部中引用一个操作数时总是把它当成一个32位的长字,但是对其实施的操作,则根据需要也可以是字节操作或字(16位)操作。对操作数进行的字节操作默认为对其最低字节的操作,字操作也是一样。不过,在一些特殊的操作中,对操作数进行字节操作时也允许明确指出是哪一个字节操作,此时在%与序号之间插入一个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)。

此外,如果一个操作数要求使用与前面某个约束中要求的是同一个寄存器,那就把与那个约束对应的操作数编号放在约束条件中。在损坏部常常会以memory为约束条件,表示操作完成后内存中的内容已有改变,如果原来某个寄存器(也许在本次操作中并未用到)的内容来自内存,则现在可能已经不一致。

还要注意,当输出部为空,即没有输出约束时,如果有输入约束存在,则须保留分割标记;号。

回到上面的例子,读者现在应该可以理解这段代码的作用是将参数i的值加到v->counter上。代码中的关键字LOCK表示在执行addl指令时要把系统的总线锁住,不让别的CPU(如果系统中有不止一个CPU)干扰。读者也许会问,将两个数相加是很简单的操作,C语言中明明有相应的语言成分,例如v->counter+=i;,为什么要用汇编呢?原因在于,这里要求整个操作只由一条指令完成,并且要将总线锁住,以保证操作的原子性(atomic)。相比之下,上述的C语句在编译之后到底有几条指令时没有保证的,也无法要求计算过程中对总线加锁。

再看一段嵌入汇编代码,这一次取自include/asm-i386/bitios.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表示该位的位置。现在读者应该不感到困难,也明白为什么要用汇编语言的原因了。

再来看一个复杂一点的例子,取自include/asm-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就是内核中对memcpy的底层实现,用来复制一块内存空间的内容,而忽略其数据结构。这是使用非常频繁的一个函数,所以其运行效率十分重要。

先看约束条件和变量与寄存器的结合。输出部有三个约束,对应于操作数%0至%2。其中变量d0为操作数%0,必须放在寄存器ecx中,原因等一下就会明白。同样,d1即%1必须放在寄存器edi中;d2即%2必须放在esi中。再看输入部,这里有四个约束,对应于操作数%3-%6。其中操作数%3与操作数%0使用同一个寄存器,所以也必须是ecx;并且要求gcc自动插入必要的指令,事先将其设置成n/4,实际上是将复制长度从字节个数n换算成长字个数n/4。至于n本身,则要求gcc任意分配一个寄存器存放。操作数%5与%6,即参数to和from,分别与%1和%2使用相同的寄存器,所以也必须时寄存器edi和esi。

再看指令部,读者马上就能看到这里似乎只用了%4。为什么那么多的操作数似乎都没有用呢?读完这些指令就明白了。

第一条指令是rep,表示下一条指令movsl要重复执行,每重复一遍就把寄存器ecx中的内容减1,直到变成0为止。所以,在这段代码中一共执行n/4次。那么,movsl又干些什么呢?它从esi所指地方复制一个长字到edi所指的地方,并使esi和edi分别加4。这样,当代码中203行执行完毕,到达204行时,所有的长字都已复制好了,最多只剩下3个字节了。在这个过程中,实际上使用ecx、esi和edi三个寄存器,即%0(同时也是%3)、%2(同时也是%6)以及%1(同时也是%5)三个操作数,这些都隐含在指令中,从字面上看不出来。同时,这也说明了为什么这些操作数必须放在指定的寄存器中。

接着就是处理剩下的三个字节了。先通过testb测试操作数%4,即复制长度n的最低字节中的bit2,如果这一位为1就说明还有至少两个字节,所以通过指令movsw复制一个短字(esi和edi则分别加2),否则就把它跳过。再通过testb(注意它前面是\t,表示在预处理后的汇编代码中插入一个TAB字符)测试操作数%4的bit1,如果这一位为1就说明还剩下一个字节,所以通过指令movsb再复制一个字节,否则就把它跳过。到达标号2的时候,执行就结束了。读者不妨自己写一段C代码来实现这个函数,编译以后用objdump看它的实现,并与此做一比较,相信就能体会到为什么这里要采用汇编语言。

作为读者的复习材料,下面是strncmp的代码,不熟悉i386指令的读者可以找一本Intel的指令手册对照着阅读。

static inline int strncmp(const char * cs,const char * ct,size_t count)
{
register int __res;
int d0, d1, d2;
__asm__ __volatile__(
	"1:\tdecl %3\n\t"
	"js 2f\n\t"
	"lodsb\n\t"
	"scasb\n\t"
	"jne 3f\n\t"
	"testb %%al,%%al\n\t"
	"jne 1b\n"
	"2:\txorl %%eax,%%eax\n\t"
	"jmp 4f\n"
	"3:\tsbbl %%eax,%%eax\n\t"
	"orb $1,%%al\n"
	"4:"
		     :"=a" (__res), "=&S" (d0), "=&D" (d1), "=&c" (d2)
		     :"1" (cs),"2" (ct),"3" (count));
return __res;
}

Guess you like

Origin blog.csdn.net/guoguangwu/article/details/121043952