您的位置:首页 > 运维架构 > Linux

深入介绍Linux内核(三)续篇

2007-05-24 23:55 232 查看

深入介绍Linux内核(三)续篇

深入介绍Linux内核

第三章 (续篇)

3.3.3 小括号中的组合语句

括弧对“{…}”用於把变数宣告和语句组合成一个复合语句(组合语句)或一个语句块,这樣在语义上这些语句就等同於一条语句。组合语句的右大括弧后面不需要使用分号。小括号中的组合语句,即形如“({…})”的语句,可以在GNU C中用作一个运算式使用。 这样就可以在运算式中使用loop、switch语句和区域变数,因此这种形式的语句通常称为语句运算式。语句运算式具有如下示例的形式:

({int y = foo ( ); int z;
if (y > 0) z = y;
else z = -y;
3 + z ; })

其中组合语句中最后一条语句必须是后面跟随一个分号的运算式。这个运算式(“3+Z”)的值即用作整个小括号括住语句的值。如果最后一条语句不是运算式,那麼整个语句运算式就具有void类型,因此沒有值。另外,这种运算式中语句宣告的任何区域变数都会在整块语句结束后失效。这个示例语句可以像如下形式的代入语句来使用:

res = x + ({略…})+ b;

当然,人们通常不会象上面这樣写语句,这种语句运算式通常都用来定义巨集,例如內核原始码init/main.c程式中读取CMOS时钟资讯的巨集定义:



3.3.4 寄存器变数

GNU对C语言的另一个扩充是允许我们把一些变数值放到CPU寄存器中,即所谓寄存器变数。这樣CPU就不用经常花费较长时间存取记忆体去取值。寄存器变数可以分为2种:全域寄存器变数和区域寄存器变数。全域寄存器变数会在程式的整个执行过程中保留寄存器专门用於几个全域变数。相反,区域寄存器变数不会保留指定的寄存器,而仅在內嵌asm组合语句中作为输入或输出操作数时使用专门的寄存器。gcc编译器的资料流程分析功能本身有能力确定指定的寄存器何时含有正在使用的值,何时可派上其他用场。当gcc资料流程分析功能认为储存在某个区域寄存器变数值无用时就可能会刪除之,並且对区域寄存器变数的引用也可能被刪除、移动或简化。因此,若不想让gcc作这些最佳化改动,最好在asm语句中加上volatile关键字。

如果想在嵌入组合语句中把组合指令的输出直接写到指定的寄存器中,那么此时使用区域寄存器变数就很方便。由於Linux內核中通常只使用区域寄存器变数,因此这裡我们只对区域寄存器变数的使用方法进行讨论。在GNU C程式中我们可以在函数中用如下形式定义一个区域寄存器变数:

register int res__asm__(“ax”);

这裡ax是变数res所希望使用的寄存器。定义这樣一个寄存器变数並不会专门保留这个寄存器不派其他用途。在程式编译过程中,当gcc资料流程控制确定变数值已经不用时就可能将该寄存器派作其他用途,而且对它的引用可能会被删除、移动或被简化。另外,gcc並不保证所编译出的代码会把变数一直放在指定的寄存器中。因此在嵌入组合的指令部分最好不要明确地引用该暂存器並且假设该寄存器肯定引用的是该变数值。然而把该变数用作为asm 的运算元还是能够保证指定的寄存器被用作该运算元。

3.3.5行內函数

在程式中,透过把一个函数宣告为行内(inline)函数,就可以让gcc把函数的代码整合到呼叫该函数的代码中去。这样处理经验去掉函数呼叫进入/退出时间开销,从而肯定能够加快执行速度。 因此把一个函数宣告为行内函数的主要目的就是能够尽量快速的执行函数体。另外,如果行内函数中有常数值,那么在编译期间gcc就可能用它来进行一些简化操作,因此并非所有行内所有行内函数的代码都会被嵌入进去。行内函数方法对程式码的长度影响并不明显。使用行内函数的程式编译产生的目标代码可能会长一些也可能会短一些,这需要根据具体情况来定。

行内函数嵌入呼叫者代码中的操作是一种最佳化操作,因此只有进行最佳化编译是才回执行代码嵌入处理。 若编译过程中没有使用最佳化选项“-O”,那么行内函数的代码就不会被真正地嵌入到呼叫者代码中,而是只作为普通函数呼叫来处理。 把一个函数宣告为行内函数的方法是在函数宣告中使用关键字“inline”,例如内核档fs/inode.c中的如下函数:

01 inline int inc(int *a)
02 {
03 (*a)+ +;
04 }

函数中的某些语句用法可能会使得行內函数的替換操作无法正常进行,或者甲适合进行替換操作。例如使用了可变参数、记忆体分配函数malloca( ),可变长度资料类型变数、非区域goto语句、以及递回函数。编译时可以使用选项-Winline让gcc对旗标成inline但不能被替換的函数给出警告资讯以及不能替換的原因。

当一个函数像下面內核档fs/inode.c中的函数定义一樣既使用inline又使用static关键字,那麼如果所有对该行內函数的呼叫都被替換而整合在呼叫者中,並且沒有引用过行內函数的位址,那麼这个行內函数自身的组合代码就不会被引用过。因此在这种情況下,除非我们使用选项-fkeep-inline-functions,否则gcc就不会再为这个函数自身生成实际组合代码。由於某些原因,一些对行内函数的呼叫並不能被整合到函数中去。特別是在行內函数定义之前的呼叫不会被替換整合,並且也都不能是递回定义的函数。如果存在一个不能被替換整合的呼叫,那麼行內函数就会像平常一樣被编译成组合代码。当然,如果程式中有引用行內函数位址的语句,那麼行內函数也会像平常一樣被编译成组合代码。因为对行內函数位址的引用不能被替換嵌入。

20 static inline void wait_on_inode (struct m_inode * inode)
21 {
22 cli ( );
23 while (inode->i_lock)
24 sleep_on(&inode->i_wait);
25 sti ( );
26 }

请注意,行內函数功能已经被包括在ISO标準C99中,但是该标準定义的行內函数与gcc定义的有较大区別。ISO标準C99的行內函数语义定义等同于这裡使用组合关键字inline和static的定义,即“省略”了关键字static。若在程式中需要使用C99标準的语义,那麼就需要使用编译选项 -std=gnu89。不过为了相容起见,在这种情況下还是最好使用inline和static组合。以后gcc将最终预设使用C99的定义,在希望仍然使用这裡定义的语义时,就需要使用选项 -std=gnu89来指定。

若一个行內函数的定义沒有使用关键字static那麼gcc就会假设其他程式档中也对这个函数有呼叫,因为一个全域符号只能被定义一次,所以该函数就不能再在其他原始档案中进行定义。因此这裡对行內函数的呼叫就不能被替换整合。因此,一个非靜态的行內函数总是会被编译出自己的组合代码来。在这方面ISO标準C99对不使用static关键字的行內函数定义等同於这裡使用static关键字的定义。

如果在定义一个函数时同时指定了inline和extern关键字,那麼该函数定义仅用於行內整合,並且在任何情況下都不会单独產生该函数自身的组合代码,即使明确引用了该函数的位址也不会產生。这樣的一个位址会变成一个外部参照引用,就好像你仅仅宣告了函数而沒有定义函数一樣。

关键字inline和extern组合在一起的作用几乎雷同一个巨集定义。使用这种组合的方式是把带有组合关键字的一个函数定义放在标头档中,並且把不含关键字的另一个同樣定义放在一个程式库档案中。此时标头档中的定义会让絕大多数对该函数的呼叫被替換嵌入。如果还有沒有被替換的对该函数的呼叫,那么就会使用(引用)程式库中的复制。Linux 0.1x内核原始码中档案include/string.h、lib/string.c就是这种使用方式的一个例子。例如string.h中定义了如下函数:



而在内核函数库目录中,lib/string.c档把关键字inline和extern都定义为空,见如下所示。因此实际上就在内核函数库中又包含了string.h纺所有这类函数的一个复制,即又对这些函数重新定义了一次。

11 #define extern // 定义为空。
12 #define inline // 定义为空。
13 #define __LIBRARY__
14 #define
15

此时程式库函数中重新定义的上述strcpy( )函数变成如下形式:



3.4 C与组合语言程式的相互呼叫

为了提高代码执行效率,內核原始码中有地方直接使用了组合语言编制。就会涉及到在两种语言编制的程式之间的相互呼叫问题。本节首先說明C语言函数的呼叫机制,然后使用示例来說明两者函数之间的呼叫方法。

3.4.1 C函数呼叫机制

在linux內核程式boot/head.s执行完基本初始化操作之后,就会跳转去执行init/main.lc程式。那麼head.s程式是如何把执行控制转交给init/main.c程式的呢? 即组合语言程式是如何呼叫执行C语言程式的? 这裡我们首先描述一下函数的呼叫机制、控制权传递方式,然后說明head.s程式跳转到C程式的方法。

函数呼叫操作包括从一块代码到另一块代码之间的双向资料传递和执行控制转移。资料传递透过函数参数和返回值来进行。另外,我们还需要在进入函数时为函数的区域变数分配储存空问,並且在退出函数时收回这部分空间。Intel80x86 CPU为控制传递提供了简单的指令,而资料的传递和区域变数储存空间的分配与回收则透过堆疊操作来实现。

堆栈框结构和控制转移权方式

大多数CPU上的程式实现使用堆栈来支援函数呼叫操作。堆栈被用来传递函数参数、储存返回资讯、临时保存暂存器原有值以备恢复以及用来储存区域资料。单个函数呼叫操作所使用的堆栈部分被称为堆栈框(Stack frame)结构,其通常结构见图3-4所示。堆栈框结构的两端由两个指标来指定。暂存器ebp通常用作框架指标(frame pointer) ,而esp则用作堆栈指标(stack pointer)。在函数执行过程中,堆栈指标esp会随著资料的入堆栈和出堆栈而移动,因此函数中对大部分资料的存取都基於框架指标ebp进行。



对於函数A呼叫函数B的情況,传递给B的参数包含在A的堆栈框中,当A呼叫B时,函数A的返回位址(呼叫返回后继续执行的指令位址)被压入堆栈中,堆栈中该位置也明确指明了A堆栈框的结束处。而B的堆栈框则从随后的堆栈部分开始,即图中保存框架指标(ebp)的地方开始。再随后则用於存放任何保存的寄存器值以及函数的临时值。

B函数同樣也使用堆栈来保存不能放在基础寄存器中的区域变数值。例如由於通常CPU的寄存器数量有限而不能夠存放函数的所有区域资料,或者有些区域变数是阵列或结构,因此必须使用阵列或结构引用来存取。还有就是C语言的位址操作符‘&’被应用到一个区域变数上时,我们就需要为该变数生成一个位址,即为变数的位址指标分配一空问。最后,B函数会使用堆栈来保存呼叫任何其他函数的参数。

堆栈是往低(小)位址方向扩展的,而esp指向当前堆栈顶端的元素。透过使用push和pop指令我们可以把资料压入堆栈中或从堆栈中弹出。对於沒有指定初始值的资料所需要的储存空间,我们可以透过把堆栈指标递減适当的值来做到。类似地,透过增加堆栈指标值我们可以回收堆栈中已分配的空间。

指令CALL和RET用於处理函数呼叫和返回操作。呼叫指令CALL的作理是把返回位址压入堆栈中並且跳转到被呼叫函数开始处执行。返回位址是程式中紧随呼叫指令CALL后面一条指令的位址。因此当被调函数返回时就会从该位置继续执行。返回指令RET用於弹出堆栈最上方的位址並跳转到该地址处。在使用该指令之前,应该先正确处理堆栈中內容,使得当前堆栈指标所指位置內容正是先前CALL指令保存的返回位址。另外,若返回值是一个整数或一个指标,那么寄存器eax将被预设用来传递返回值。

尽管某一时刻只有一个函数在执行,但我们还是需要确定在一个函数(呼叫者)呼叫其他函数(被呼叫者)时,被呼叫者不会修改或覆盖掉呼叫者今后要用到的寄存器內容。因此Intel CPU採用了所有函数必须遵守的寄存器用法统习惯例。该惯例指明,寄存器cax、edx和ecx的內容必须由呼叫者自己负责保存。当函数B被A呼叫时,函数B可以在不用保存这些寄存器內容的情況下任意使用它们而不会毀坏函数A所需要的任何资料。另外,寄存器ebx、esi和edi的內容则必须由被呼叫者B来保护。当被呼叫者需要使用这些寄存器中的任意一个时,必须首先在堆栈中保存其內容,並在退出时恢复这些寄存器的內容。因为呼叫者A(或者一些更高层的函数)並不负责保存这些寄存器內容,但可能在以后的操作中还需要用到原先的值。还有寄存器ebp和esp也必须遵守第二个惯例用法。

函数呼叫举例

作为一个例子,我们来观察下面C程式exch.c中函数呼叫的处理过程。该程式交換两个变数中的值,並返回它们的差值。

1 void swap (int * a, int *b)
2 {
3 int c;
4 c = *a; *a = *b; *b = c;
5 }
6
7 int main ( )
8 {
9 int a, b;
10 a = 16; b = 32;
11 exchange (&a, &b);
12 return (a – b);
13 }

其中函数swap ()用于交换两个变数的值。C程式中的主程序main()也是一个函数(将在下面说明),它在呼叫了swap()之后返回交换后的结果。这两个函数的堆叠框结构见图3-5所示。可以看出,函数swap()从呼叫者(main())的堆栈框中获取其参数。图中的位置资讯相对于寄存器ebp中的框架指标。堆栈框左边的数字指出了相对于框架指标的地址偏移值。在象gdb这样的除错器中,这些数值都用2的补数表示。例如‘-4’被表示‘0xFFFFFFFC’,‘-12’会被表示成‘0xFFFFFFF4’。

呼叫者main()的堆栈框结构中包括区域变数a和b的储存空间,相对于框架指标位于-4和-8偏移处。由于我们需要为这两个区域变数生成位址,因此它们必须保存在堆栈中而非简单地存放在寄存器中。



使用命令“gcc -Wall -S -oexch.s cxch.c”可以生成该C语言程式的组合语旨程式exch.s代码,见如下所示(刪除了几行与讨论无关的虛拟指令)。

1 .text
2 _swap:
3 pushl %ebp # 保存原ebp值,设置当前函数的框架指标。
4 mov1 %esp,%ebp
5 subl $4,%esp # 为区域变数c在堆栈内分配空间。
6 movl 8(%ebp),%eax # 取函数第l个参数,该参数是一个整数类型值的指标。
7 movl (%eax),%ecx # 取该指标位置的內容,並保存到区域变数c中。
8 inovl %ecx,-4(%ebp)
9 movl 8 (%ebp),%eax # 再次取第1个参数,然后取第2个参数。
10 movl 12 (%cbp),%edz
11 movl (%edx),%ecx # 把第2个参数所指内容放到第1个参数所指的位置。
12 movl %ecx,(%cax)
13 movl 12 (%ebp),%eax # 再次取第2个参数
14 mov1 -4 (%ebp), %ecx # 然後把區域變數c中的內容放到這個指標所指位置處。
15 mov1 %ecx, (%eax)
16 leave # 恢复原ebp、esp值(即movl %ebp,%esp;pop1 %ebp;)。
17 ret
18 _main :
19 pushl %ebp # 保存原ebp值,设置当前函数的框架指标。
20 movl %esp, %ebp
21 subl $8, %esp # 为整型区域变数a和b在堆疊中分配空间。
22 movl $16,-4(%ebp) # 为区域变数代入初始值(a=16,b=32)。
23 movl $32,-8(%ebp)
24 leal -8 (%ebp), %eax # 为呼叫swap( )函数作準备,取区域变数b的位址。
25 pushl %eax # 作为呼叫的参数並压入堆栈中.即先压入第2个参数。
26 leal -4(%ebp),%eax # 再取区域变数a的位址,作为第l个参数人堆疊。
27 pushl %eax
28 call _swap # 什呼叫函数swap( )。
29 movl -4(%ebp), %eax # 取第l个区域变数a的值,減去第2个变数b的值。
30 subl -8(%ebp), %eax
31 leave # 恢复原ebp、esp值(即movl %ebp,%esp; pop1 %ebp;)。
32 ret

这两个函数均可以划分成三个部分:“设置”,初始化堆栈框结构;“主体执行函数的实际计算操作;“结束”,恢复堆栈状态並从函数中返回。对於swap()函数,其设置部分代码是3--5行。前两行用来设置保存呼叫者的框架指标和设置本函数的堆栈框指标,第5行透过把堆栈指标esp下移4位元组为区域变数c分配空间。行6--15足swap函数的主体部分。第6--8行用於取呼叫者的第l个参数&a,並以该参数作为位址取所存內容到ecx寄存器中,然后保存到为区域变数分配的空间中(-4(%ebp)) 。第9—12行用於取第2个参数&b,並以该参数值作为位址取其內容放到第l个参数指定的位址处。第13--15行把保存在临时域变数c中的值存放到第2个参数指定的地址处。最后16--17行是函数结束部分。leave指令用於处理堆栈內容以準备返回,它的作用等价於下面两个指令:

movl %ebp,%esp #恢复原esp的值(指向堆栈框开始处)。
popl %ebp #恢复原ebp的值(通常是呼叫者的框架指标)。

这部分代码恢复了在进入swap()函数时暂存器esp和ebp的原有值,並执行返回指令ret。

第19--2l行是main ( )函数的设置部分,在保存和重新设置框架指标之后main()为区域变数a和b在堆栈中分配了空间。第22--23行为这两个区域变数代入。从2--28行可以看出main()中足如何呼叫swap( )函数的。其中首先使用leal指令(取有效位址)获得变数b和a的位址並分別压入堆栈中,然后呼叫swap()函数。变数位址压入堆栈中的顺序正好与函数申明的参数顺序相反。即函数最后一个参数首先压入堆栈中,而函数的第l个参数则是最后一个在呼叫函数指令call之前压入堆栈中的。第29--30两行将两个已经交換过的数字相减,並放在eax寄存器中作为返回值。

从以上分析可知,C语言在呼叫函数时是在堆栈上临时存放被调函数参数的值,即C语言是传值类语言,沒有直接的方式在被呼叫函数中修改呼叫者一个变数的值。因此为了达到修改的目的就需要向函数传递变数的指标(即变数的位址)。

Main ( )也是一个函数

上面这段组合语言程式足使用gcc 1.40编译產生的,可以看出其中有几行多余的代码。可见当时的gcc编译器还不能產生最高效率的代码,这也是为什麼某些关键代码需要直接使用组合语言编制的原因之…另外,上面提到C程式的程式main( )也是一个函数。这是因为在编译链结时它将会作为crt0.s组合语言程式的函数被呼叫。crt0.s是一个桩(stub)程式,它被链结在每个用戶执行程的开始部分,主要用於设置一些初始化全域变数等。 Linux 0.12中crt0.s组合语言程式见如下所示。其中建立並初始化全域变数_environ供程式中其他模组使用。

1 .text
2 .globl_environ # 宣告全域变数_environ(对应C程式中的environ变数)。
3
4 __entry: # 代码人口标号。
5 movl 8(%esp), %eaxt # 取程式的环境变数指标envp並保存在_environ中。
6 movl %eax, _environ # envp是execve( )函数在载入执行档时设置的。
7 call _main # 呼叫我们的主程序”其返回状态值在eax暂存器中。
8 pushl %eax # 压入返回值作为exit( )函数的参数並呼叫该函数。
9 1: call _exit
l0 jmp 1b # 控制应该不会到达这里。若到达这里则继续执行exit()。
11 .data
12 _environ: # 定义变数_environ,为其分配一个长字空间。
13 .long 0

通常使用gcc编译链结生成执行档时,gcc会自动把该档案的代码作为第一个模组链结在可执行程式中。在编译时使用显示详细资讯选项‘V’就可以明显地看出这个链结操作过程:

[/usr/root] #gcc -v -o exch exch.s
gcc version 1.40
/usr/local/lib/gcc-as -o exch.o exch.s
/usr/local/lib/gcc-ld -o exch/usr/local/lib/crt0.o exch.o/usr/local/lib/gnulib -lc
/usr/local/lib/gnulib
[/usr/root] #

因此在通常的编译过程中我们无需特別指定stub模组crt0.o,但是若想从上面给出的组合语言程式手工使用ld (gld)从exch.o模组链结產生可执行档exch,那麼我们就需要在命令行上特別指明crt0.o这个模组,並且链结的顺序应该是“crt0.o、所有程式模组、程式库档案”。

为了使用ELF格式的目标档以及建立共用程式库模组档,现在的gcc编译器(2.X)已经把这个crt0扩展成几个模组:crt1.o、crti.o、crtbegin.o、crtend.o和crtn.o。这些模组的链结顺序为“crt1.o、crti.o、crtbegin.o、(crtbeginS.o )、所有程式模组、crtend.o(crtendS.o)、crtn.o、程式库模组档”。gcc的配置档specfile指定了这种链接顺序。其中crt1.o、crti.o、和crtn.o、crti.o和crtn.o由C库提供,是C程式的“啟动”模组;crtbegin.o和crtend.o是C++语言的啟动模组,由编译器gcc提供;而crtl.o则与crt0.o的作用类似,主要用於在呼叫main( )之前做一些初始化工作,全域符号_start就定义在这个模组中。

crtbegin.o和crtend.o主要用於C++语言在.ctors和.dtors区中执行全域建构子(constructor)和解构子(destructor)函数。CrtbeginS.o和crtendS.O的作用与前两者类似,但用於建立共用模组中。Crti.o用於在.init区中执行初始化函数init( )。.init区中包含行程的初始化代码,即当程式开始执行时,系统会在呼叫main( )之前先执行.init中的代码。crtn.o则用於在.fini区中执行行程终止退出处理函数fini( )函数,即当程式正常退出时(main( )返回之后) ,系统会安排执行.fini中的代码。

Boot/head.s程式中第136—140行就是用於为跳转到init/main.c中的main( )函数作準备工作。第139行上的指令在堆栈中压入了返回位址,而第140行则压入了main( )函数代码的位址。当head.s最后在第2l 8行上执行ret指令时就会弹出main( )的位址,並把控制权转移到init/main.c程式中。

3.4.2在组合语言程式中呼叫C函数

从组合语言程式中呼叫C语书函数的方法实际上在上面已经带出。在上面C语言例子对应的组合语言程式代码中,我们可以看出组合语言程式语句是如何呼叫swap( )函数的。现在我们对呼叫方法作一总结。

在组合语言程式呼叫一个C函数时,程式需要首先按照逆向顺序把函数参数压入堆栈中,即函数最后(最右边的)一个参数先入堆栈,而最左边的第l个参数在最后呼叫指令之前入堆栈,见图3-6所示。然后执行CALL指令去执行被呼叫的函数。在呼叫函数返回后,程式需要再把先前压入堆栈中的函数参数清除掉。

在执行CALL指令时,CPU会把CALL指令下一条指令的位址压入堆栈中(见图中EIP) 。如果呼叫还涉及到代码特权级变化,那麼CPU还会进行堆栈切換,並且把当前堆栈指标、段描述符和呼叫参数压入新堆栈中。由於Linux內核中只使用中断门和陷阱门方式处理特权级变化时的呼叫情況,並沒有使用CALL指令来处理特权级变化的情況,因此这裡对特权级变化时的CALL指令使用方式不再进行說明。

组合中呼叫C函数比较“自由”。只要是在堆栈中适当位置的內容就都可以作为参数供C函数使用。这裡仍然以图3-6中具有3个参数的函数呼叫为例,如果我们沒有专门为呼叫函数func()压入参数就直接呼叫它的话,那麼func()函数仍然会把存放EIP位置以上的堆栈中其他內容作为自己的参数使用。如果我们为呼叫func( )而仅仅明确地压入了第l、第2个参数,那麼func( )函数的第3个参数p3就会直接使用p2前的堆栈中內容。在Linux 0.1x內核代码中就有几处使用了这种方式。例如在kernel/sys_call.s组合语言程式中第23 l 行上呼叫copy_process ( )函数(kernel/fork.c中第68行)的情況。在组合语言程式函数_sys_ fork中虽然只把5个参数压入了堆栈中,但是copy_process( )卻共带有多达17个参数,见下面所示:

// kernel/sys_call.s组合语言程式_sys_fork部分。
226 push %gs
227 push1 %esi
228 push1 %edi
229 push1 %ebp
230 push1 %eax
231 call copy_process # 呼叫C函数copy_process( )(kernel/fork.c,68)。
232 add1 $20, %esp # 丢弃这里所有压堆栈内容。
233 1: ret

// kernel/fork.c程式。
68 int copy_process(int nr, long ebp, long edi, long esi, long gs, long none,
69 long ebx, long ecx, long edx, long orig_eax,
70 long fs, long es, long ds,
71 long eip, long cs, long eflags, long esp, long as)

我们知道参数越是最后入堆栈,它越是靠近C函数参数左侧。因此实际上呼叫copy_process()函数之前入堆栈5个寄存器值就是copy_process()函数的最左面的5个参数。按顺序它们分別对应为入堆栈的eax(nr)’ebp、edi、esi和寄存器gs的值。而随后的其余参数实际上直接对应堆栈上已有的內容。这些内容是进入系统呼叫中断处理过程开始,直到呼叫本系统呼叫处理过程时逐步入堆叠的各寄存器的值。

参数none是sys_call.s程式第99行上利用位址跳转表sys_call_table[ ](定义在include/linux/sys.h,93行)呼叫_sys_fork时的下一条指令的返回位址值。随后的参数足刚进入system_call时在85—91行压入堆栈的寄存器ebx、ecx、edx、原eax和段寄存器fs、es、ds。最后5个参数是CPU执行中断指令压入返回位址eip和cs、标志寄存器eflags、用戶堆栈位址esp和ss。因为系统呼叫涉及到程式特权级变化,所以CPU会把标志寄存器值和用戶堆栈位址也压入了堆栈。在呼叫C函数copy_process( )返回后,_sys_fork也只把自己压入的5个参数丟棄掉,堆叠中其他还均保存著。其他採用上述用法的函数还有kernel/signal.c中的do signal( )、fs/exec.c()中的do_execve( )等,请自己加以分析。

另外,我们說组合语言程式呼叫C函数比较自由的另一个原因是我们可以根本不用CALL指令而採用JMP指令来同樣达到呼叫函数的目的,方法是在参数人堆栈后人工把下一条要执行的指令位址压入堆栈中,然后直接使用JMP指令跳转到被呼叫函数开始位址处去执行函数。此后当函数执行完成时就会执行RET指令把我们人工压入堆栈中的下一条指令位址弹出,作为函数返回的位址。Linux內核中也有多处用到了这种函数呼叫方法,例如kernel/asm.s程式第62行呼叫执行traps.c中的do_int3( )函数的情況。

3.4.3 在C程式中呼叫组合函数

从c程式中呼叫组合语言程式函数的方法与组合语言程式中呼叫C函数的原理相同,但Linux内核程式中不常使用。呼叫方法的着重点仍然是对函数参数在堆栈中位置的确定上。当然,如果呼叫的组合语言程式比较短,那么可以直接在C程式中使用上面介绍的行内组合语句来实现。下面我们以一个示范来说明编制这类程式的方法。包含两个函数的组合语言程式callee.s见如下所示:

/*
本组合语言程式利用系统呼叫sys_write( )实现显示函数intmywrite(intfd, char*buf, int count)。函数int myadd(int a,int b,int * res)用于执行a+b = res运算。若函数返回0,则说明溢出。注意:如果在现在的Linux系统(例如Red Hat9)下 编译,则请去掉函数名前的底线‘_’。

*/
SYSWRITE = 4 # sys_write ()系统呼叫号。
.global _mywrite, _myadd
.text
_mywrite:
push1 %ebp
mov1 %esp, %ebp
push1 %ebx
mov1 8(%ebp), %ebx # 取呼叫者第l个参数:档描述符fd。
mov1 12(%ebp), %ecx # 取第2个参数:缓冲区指标。
mov1 16(%ebp), %edx # 取第3个参数:显示主元数。
mov1 $SYSWRITE, %eax # %eax中放人系统呼叫号4 。
int $0x80 # 执行系统呼叫。
pop1 %ebx
mov1 %ebp, %esp
pop1 %ebp
ret
_myadd:
push1 %ebp
mov1 %esp, %ebp
mov1 8(%ebp), %eax # 取第l个参数a。
mov1 12(%ebp), %edx # 取第2个参数b。
xor1 %ecx, %ecx # %ecx为0表示计算溢出。
add1 %eax, edx # 执行加法运算。
jo lf # 若溢出则跳转。
mov1 16(%ebp), %eax # 取第3个参数的指标。
mov1 %edx, (%eax) # 把计算结果放入指标所指位置处。
incl %ecx # 沒有发生溢出,於是设置无溢出返回值。
mov1 %ecx, %eax       # %eax中足函数返回值。
mov1 %ebp, %esp
pop1 %ebp
ret

该组合档中的第 l个函数mywrite()利用系统中断0x80呼叫系统呼叫sys_write(int fd, 实现在萤幕上显示资讯。对应的系统呼叫功能号是4(参见include/unistd.h),三个参数分別为档描述符、显示缓冲区指标和显示字元数。在执行int 0x80之前,寄存器%cax中需要放入呼叫功能(4) ,寄存器%cbx、%ecx和%cdx要按呼叫规定分別存放fd、buf和count。函数mywrite()的呼叫参数个数和用途与sys_write()完全一样。

第2个函数myadd(int a,int b,int *res)执行加法运算。其中参数res是运算的结果。函数返回值用於判断是否发生溢出。如果返回值为0表示计算溢出,结果不可用。否则计算结果将透过参数res返回给呼叫者。

注意:如果在现在的Linux系统(例如RedHat 9)下编译callee.s程式则请去掉函数名前的下划線‘_’。呼叫这两个函数的C程式callcr.c见如下所示。

/*
呼叫组合函数mywrite(fd,buf,count)显示资讯:呼叫myadd(a,b,result)执行加运算。
如果myadd()返回0,则表示加函数发生溢出。首先显示开始计算资讯,然后显示运算结果。



该函数首先利用组合函数mywrite()在萤幕上显示开始计算的资讯“Calculating...”然后呼叫加法计算组合函数myadd()对a和b两个数进行运算並在第3个参数res中返回计算结果。最后再利用mywrite()函数把格式化过的结果资讯字串显示在萤幕上。如果函数myadd()返回0,则表示加函数发生溢出,计算结果无效。这两个档的编译和执行结果见如下所示:

[/usr/root]# as -o callee.o callee.s
[/usr/root]# gcc -o caller caller.c callee.o
[/usr/root]# ./caller
Calculating...
The result is 15
[/usr/root]#

3.5 Linux 0.12目标档格式

为了生成內核代码档,Linux 0.12使用了两种编译器。第一种是组合编译器as86和相应的链结程式(或称为链结器)ld86。它们专门用於编译和链结执行在实位址模式下的16位元內核开机磁区程式bootsect.s和设置程式setup.s。第种是GNU的组译器as(gas)和C语言编译器gcc以及相应的链结程式gld 。编译器用於为来源程式档產生对应的二进位码和资料目标档。链结程式用於对相关的所有目标档进行组合处理,形成一个可被內核载入执行的目标档,即可执行挡。

本节首先简单說明编译器產生的目标档结构,然后描述链结器如何把需要链结在一起的目标档模组组合在一起,以生成二进位可执行映射档或一个大的模组。最后說明Linux 0.12內核二进位码档Image的生成原理和过程。这裡给出Linux 0.12內核所支持的a.out目标档格式的资讯。as86和ld86生成的是MINIX专门的目标档格式,我们将在涉及这种格式的內核建立工具一章中给出。因为MINIX目标档结构与a.out目标档格式类似,所以这裡不对其进行說明。有关目标档和链结程式的基本工作原理可参见John R. Levine著的《Linkers & Loaders》一书。

为便於描述,这裡把编译器生成的目标档称为目的模组档(简称模组档) ,而把链结程式输出產生的可执行目标档称为可执行档。並且把它们都统称为目标档。

3.5.1 目标档格式

在Linux 0.12系统中,GNU gcc或gas编译输出的目的模组档和链结程式所生成的可执行档都使用了UNIX传统的a.out格式。这是一种被称为组合与链结输出(Assembly & linker editor output)的目标档格式。对於具有记忆体分页机制的系统来說,这是一种简单有效的目标档格式。a.out格式档由一个档头和随后的代码区(Text section,也称为正文段)、已初始化资料区(Data section,也称为资料段)、重定位资讯区、符号表以及符号名字串构成,见图3-7所示,其中代码区和资料区通常也被分别称为正文段(代码段)和资料段。



a.out格式7个区的基本定义和用途是:

▓ 执行头部分(exec header),执行档头部分。该部分中含有一些参数(exec结构)、是有关目标档的整体结构资讯。例如代码和资料区的长度、未初始化资料区的长度、对应来源程式档案名以及目标档建立时间等。內核使用这些参数把执行档载入到记忆体中並执行,而链结程式(1d)使用这些参数将一些模组档组合成一个可执行档。这是目标档唯一必要的组成部分。

▓ 代码区(text segment)。由编译器或组译器生成的二进位指令代码和资料资讯,这部分含有程式执行时被载入到记忆体中的指令代码和相关资料。可以以唯读形式被载入。

▓ 资料区(data segment)。由编译器或组译器生成的二进位指令代码和资料资讯这部分含有已经初始化过的资料,总是被载入到可读写的记忆体中。

▓ 代码重定位部分(text relocations)。这部分含有供链结程式使用的记錄资料。在组合目的模组档时用於定位代码段中的指标或地址。当链结程式需要改变目标代码的位址时就需要修正和维护这些地方。

▓ 资料重定位部分(data relocations)。类似于代码重定位部分的作用,但是用于资料段中指标的重定位。

▓ 符号表部分(symbol table)。这部分同樣含有供链结程式使用的记錄资料。这些记錄资料保存著模组档中定义的全域符号以及需要从其他模组档中输入的符号,或者是由链结器定义的符号,用於在模组档之间对命名的变数和函数(符号)进行交叉引用。

▓ 字串表部分(string table)。该部分含有与符号名相对应的字串。用於除错程式除错目标代码,与链结过程无关。这些资讯可包含来源程式代码和行号、区域符号以及资料结构描述资讯等。

对於一个指定的目标档並非一定会包含所有以上资讯。由於Linux 0.12系统使用了Intel CPU的记忆体管理功能,因此它会为每个执行程式单独分配一个64MB的位址空间(逻辑位址空间)使用。在这种情況下因为链结器已经把执行档处理成从一个固定位址开始执行,所以相关的可执行档中就不再需要重定位资讯。下面我们对其中几个重要区或部分进行說明。

执行头部分

目标档的档头中含有一个长度为32位元组的exec资料结构,通常称为档标头结构或执行标头结构。其定义如下所示。有关a.out结构的详细资讯请参见include/a.out.h档后的介绍。

struct exec {
unsigned long a_magic // 执行档魔数。使用N_ MAGIC等巨集存取。
unsigned a_text // 代码长度, 位元组数。
unsigned a_data // 资料长度,位元组数。
unsigned a_bss // 档中的未初始化资料区长度,位元组数 。
unsigned a_syms // 档中的符号表长度,位元组数。
unsigned a_entry // 执行开始位址。
unsigned a_trsize // 代码重定位资讯长度,位元组数。
unsigned a_drsize // 资料重定位资讯长度,位元组数。
}

根据a.out档中标头结构魔数栏位的值,我们又可把a.out格式的档案分成几种类型。Linux 0.12系统使用了其中两种类型:模组目标档使用了OMAGIC(01d Magic)类型的a.out格式,它指明档是目标档或者是不纯的可执行档。其魔数是0xl07(八进位0407) 。而执行档则使用了ZMAGIC类型的a.out格式,它指明档为需求分页处理(demang-paging,即需求载入load on demand)的可执行档。其魔数是0x10b(八进位0413)。这两种格式的主要区別在於它们对各个部分的储存分配方式上。虽然该结构的总长度只有32位元组,但是对于一个ZMAGIC类型的执行档来說,其档案开始部分卻需要专门留出1024位元组的空间给标头结构使用。除被标头结构佔用的32个位元组以外,其余部分均为0。从1024位元组之后才开始放置程式的正文段和资料段等资讯。而对於一侧OMAGIC类型的模组档来說,档开始部分的32位元组标头结构后面紧接著就是代码区和资料区。

执行标头结构中的a_text和a_data栏位分別指明后面唯读的代码段和可读写资料段的位元组长度。a_bss栏位指明內核在载入目标档时资料段后面未初始化资料区域(bss段)的长度。由於Linux在分配记忆体时会自动对记忆体清除零,因此bss段不需要被包括在模组档或执行档中。为了形象地表示目标档逻辑地具有一个bss段,在后面图示中将使用虛線框来表示目标档中的bss段。

a_entry栏位指定了程式码开始执行的位址,而a_syms、a_trsize和a_drsize栏位则分別說明了资料段后符号表、代码和资料段重定位资讯的大小,对於可执行档来說並不需要符号表和重定位资讯,因此除非链结程式为了除错目的而包括了符号资讯,执行档中的这几个栏位的值通常为0。

重定位资讯部分

Linux 0.12系统的模组档和执行档都是a.out格式的目标档,但是只有编译器生成的模组档中包含用於链结程式的重定位资讯。代码段和资料段的重定位资讯均有重定位记錄(项)构成,每个记錄的长度为8位元组,其结构如下所示。

struct relocation_info
{
int r_address; // 段內需要重定位的位址。
unsigned int r_symbolnum:24; // 含义与r_extern有关。指定符号表中一个符号或者一个段。
unsigned int r_rpcrel:1; // 1bit。PC相关旗标。
unsigned int r_length:2; // 2bit。指定要被重定位栏位长度(2的次方)。
unsigned int r_extern:1; // 外部旗标位元。1– 以符号的值重定位。0- 以段的地址重定位。
unsigned int r_pad:4; // 沒有使用的4个bit位。但最好将它们复位掉。
}

重定位项的功能有两个。一是当代码段被重定位到一个不同的基底位址处时,重定位项则用於指出需要修改的地方。二是在模组档中存在对未定义符号引用时,当此未定义符号最终被定义时链结程式就可以使用相应重定位项对符号的值进行修正。由上面重定位记錄项的结构可以看出,每个记錄项含有模组档代码区(代码段)和资料区(资料段)中需要重定位处长度为4位元组的位址以及规定如何具体进行重定位操作的资讯。位址栏位r_address是指可重定位项从代码段或资料段开始算起的偏移值。2bit的长度栏位r_length指出被重定位项的长度,0到3分別表示被重定位项的宽度是l位元组、2位元组、4位元组或8位元组。旗标位元r_pcrel指出被重定位项是一个“PC相关的的”项,即它作为一个相对位址被用於指令当中。外部旗标位元r_extern控制著r_symbolnum的含义,指明重定位项参考的是段还是一个符号。如果该旗标值是0,那麼该重定位项是一个普通的重定位项,此时r_symbolnum栏位指定是在哪个段中定址定位。如果该旗标是l,那麼该重定位项是对一个外部符号的引用,此时r_symbolnum指定目标档中符号表中的一个符号,需要使用符号的值进行重定位。

符号表和字串部分

目标挡的最后部分是符号表和相关的字串表。符号表记录项的结构如下所示。

struct nlist {
union {
char *n_name; // 字串指标。
Struct nlist *n_next; // 或者是指向另一个符号项结构的指标。
long n_strx; // 或者足符号名称在宇串表中的位元组偏栘值。
} n_un;
unsigned char n_type; // 该位元组分成3个栏位,参见a.out.h中146-154。
char n_other; // 通常不用。
short n_desc; //
unsigned long n_value; //符号的值。
};

由於GNU gcc编译器允许任意长度的识別字,因此识別字字串都位於符号表后的字串表中。每个符号表记錄项长度为12位元组,其中第一个栏位给出了符号名字串(以null结尾)在字串表中的偏移位置。类型栏位n_type指明了符号的类型。该栏位的最后一个bit位元用於指明符号是否是外部的(全域的) 。如果该位元为l的话,那麼說明该符号是一个全域符号。链结程式並不需要区域符号资讯,但可供除错程式使用。n_type栏位的其余bit位元用来指明符号类型。a.out.h标头档中定义了这些类型值常数符号。符号的主要的类型包括:

text、data或bbs指明是本模组档中定义的符号。符号的值就是该符号的可重定位位址。
abs指明呼号是一个绝对的(固定的)不可重定位的符号。符号的值就是该固定值。
undef指明是一个本模组档中未定义的符号。此时符号的值通常是0。

但作为一种特殊情況,编译器能夠使用一个未定义的符号来要求链结程式为指定的符号名保留一块储存空间。如果一个未定义的外部(全域)符号具有非零值,那麼对链结程式而言该值就是程式希望指定符号定址的储存空间的大小值。在链结操作期间,如果该符号确实沒有定义,那麼链结程式就会在bss段中为该符号名建立一块储存空间,空间的大小是所有被链结模组中该符号值最大的一个,这个就是bss段中所谓的公共区块(Common block)定义,主要用於支援未初始化的外部(全域)资料。例如程式中定义的末初始化的阵列。如果该符号在任意一个模组中已经被定义了,那麼链结程式就会使用该定义而忽略该值。

3.5.2 Linux 0.12中的目标档格式

在Linux 0.12系统中,我们可以使用objdump命令来查看模组档或执行档中档标头结构的具体值。例如,下面列出了hello.o目标档及其执行档中档头的具体值。

[/usr/root]# gcc -c -o hello.o hello.c
[/usr/root]# gcc -o hello hello.o
[/usr/root]#
[/usr/root]# hexdump -x hello.o
0000000 0107 0000 0028 0000 0000 0000 0000 0000
0000010 0024 0000 0000 0000 0010 0000 0000 0000
0000020 6548 6c6c 2c6f 7720 726f 646c 0a21 0000
0000030 8955 68e5 0000 0000 03e8 ffff 31ff ebc0
0000040 0003 0000 c3c9 0000 0019 0000 0002 0d00
0000050 0014 0000 0004 0400 0004 0000 0004 0000
0000060 0000 0000 0012 0000 0005 0000 0010 0000
0000070 0018 0000 0001 0000 0000 0000 0020 0000
0000080 6367 5f63 6f63 706d 6c69 6465 002e 6d5f
0000090 6961 006e 705f 6972 746e 0066
000009c
[/usr/root]# objdump -h hello.o
hello.o:
magic: 0xl07 (407)machine type:0 flags:0x0 text 0x28 data 0x0 bss 0x0
nsyms 3 entry 0x0 trsize 0x10 drsize 0x0
[/usr/root]#
[/usr/root]# hexdump -x hello | more
0000000 0l0b 0000 3000 0000 1000 0000 0000 0000
0000010 069c 0000 0000 0000 0000 0000 0000 0000
0000020 0000 0000 0000 0000 0000 0000 0000 0000
*
0000400 448b 0824 00a3 0030 e800 001a 0000 006a
0000410 dbe8 000d eb00 00f9 6548 6c6c 2c6f 7720
0000420 726f 646c 0a21 0000 8955 68e5 0018 0000
......
--more--q
[/usr/root]#
[/usr/root]# objdump -h hello
hello :
magic: 0x10b (413)machine type: 0 flags: 0x0 text 0x3000 data 0x1000 bss 0x0
nsyms 141 entry 0x0 rsize 0x0 drsize 0x0
[/usr/root]#

可以看出,hello.o模组档的魔数是0407(OMAGIC) ,代码段紧跟在标头结构之后。除了档标头结构以外,还包括一个长度为0x28位元组的代码段和一个具有3个符号项的符号表以及长度为0x10位元组的代码段重定位资讯。其余各段的长度均为0。对应的执行档hello的魔数足0413(ZMAGIC),代码段从档偏移位置1024位元组开始存放,代码段和资料段的长度分別为0x3000和0xl000位元组,並带有包含141个项的符号表。我们可以使用命令strip刪除执行档中的符号表资讯。例如下面我们刪除了hello执行档中的符号资讯。可以看出hello执行档的符号表长度变成了0,並且hello档的长度也从原来的2059 l位元组減小到17412位元组。

[/usr/root]# 11 hello
-rwx- -x- -x 1 root 4096 20591 Nov 14 18:30 hello
[/usr/root]# objdump -h hello
hello :
magic : 0x10b (413)machine type : 0flags : 0x0text 0x3000 data 0x1000 bss 0x0
nsyms 141 entry 0x0 trsize 0x0 drsize 0x0

[/usr/root]# strip hello
[/usr/root]# 11 hello
-rwx- -x- -x l root 4096 17412 Nov 14 18:33 hello
[/usr/root]# objdump -h hello
hello :
magic : 0x10b (413)machine type: 0flags: 0x0text 0x3000 data 0x1000 bss 0x0
nsyms 0 entry 0x0 trsize 0x0 drsize 0x0
[/usr/root]#

磁片上a.out执行档的各区在行程逻辑位址空间中的对应关系见图3-8所示。Linux 0.12系统中行程的逻辑空间大小足64MB。对於ZMAGIC类型的a.out执行档,它的代码区的长度是记忆体页面的整数倍。由於Linux 0.12內核使用需求页(Demand-paging)技术,即在一页代码实际要使用的时候才被载入到实体记忆体页面中,而在进行载入操作的fs/execve ( )函数中仅仅为其设置了分页机制的页目錄项和页表项,因此需求页技术可以加快程式的载入的速度.



图中bss是行程的未初始化资料区,用於存放靜态的未初始化资料。在开始执行程式时bss的第1页记忆体会被设置为全0。图中heap是堆空间区,用於分配行程在执行过程中动态申请的记忆体空间。

3.5.3 链结程式输出

链结程式对输入的一个或多个模组档以及相关的程式库函数模组进行处理,最终生成相应的二进位执行档或者是一个所有模组组合而成的大模组档。在这个过程中,链结程式的首要任务是给执行档(或者输出的模组档)进行储存空间分配操作。一旦储存位置确定,链结程式就可以继续执行符号梆定操作和代码修正操作。因为模组档中定义的大多数符号与档中的储存位置有关,所以在符号对应的位置沒有确定下来之前符号是沒有办法解析的。

每个模组档中包括几种类型的段,链结程式的第二个任务就是把所有模组中相同类型的段组合连接在一起,在输出档中为指定段类型形成单一一个段。例如,链结程式需要把所有输入模组档中的代码段合併成一个段放在输出的执行档中。

对於a.out格式的模组档来說,由於段类型是预先知道的,因此链结程式对a.out格式的模组档进行储存分配比较容易。例如,对於具有两个输入模组档和需要连接一个程式库函数模组的情況,其储存分配情況见图3-9所示。每个模组都有一个代码段(text) 、资料段(data)和一个bss段,也许还会有一些看似外部(全域)符号的公共区块。链结程式会收集每个模组档包括任何程式库函数模组中的代码段、资料段和bss段的大小。在读入並处理了所有模组之后,任何具有非零值的未解析的外部符号都将作为公共区块来看待,並且把它们分配储存在bss段的末尾处。



此后链结程式就可以为所有段分配位址。对於Linux 0.12系统中使用的ZMAGIC类型的a.out格式,输出档中的代码段被设置成从固定位址。开始。资料段则从代码段后下一个页面边界开始。bss段则紧随资料段开始放置。在每个段內,链结程式会把输入模组档中的同类型段顺序存放,並按字进行边界对齐。

当linux 0.12內核载入一个可执行档时,它会根据档头部结构中的资讯首先判断档案是否是一个合适的可执行档,即其魔数类型是否为ZMAGIC,然后系统在用戶态堆叠顶部为程式设置环境参数和命令行上输入的参数资讯块並为其构建一个任务资料结构。接著在设置了一些相关暂存器值后利用堆叠返回技术去执行程式。执行程式映射档中的代码和资料将会在实际执行到或用到时利用依需求载入技术(Load on demand)动态载入到记忆体中。

对於Linux 0.12內核的编译过程,它是根据內核的配置档Makefile使用make命令指挥编译器和链结程式操作而完成的。在建立过程中make还利用內核原始码tools/目錄下的build.c程式编译生成了一个用於组合所有模组的临时工具程式build。由於內核是由引导啟动程式利用ROM BIOS中断呼叫载入到记忆体中,因此编译產生的內核各模组中的执行标头结构部分需要去掉。工具程式build主要功能就是分別去掉bootsect、setup和system档中的执行标头结构,然后把它们顺序组合在一起產生一个名为Image的內核映射档。

3.5.4链结程式预定义变数

在链结过程中,链结器1d和ld86会使用变数记錄下执行程式中每个段的逻辑位址。因此在程式中可以透过存取这几个外部变数来获得程式中段的位置。链结器预定义的外部变数通常至少有etext、_etext、edata、_edata、end和_end。

变数名_etext和etext的位址是程式正文段结束后的第1个地址; _edata和edata的位址是初始化资料区后面的第1个位址;_end和end的位址是末初始化资料区(bss)后的第l个位址位置。带下划線‘_’首码的名称等同於不带下划線的对应名称,它们之间的唯一区別在於ANSI、POSIX等标準中沒有定义符号etext、edata和end。

当程式刚开始执行时,其brk所指位置与_end处於相同位置。但是系统呼叫sys_brk( )、记忆体分配函数malloc( )以及标準输入/输出等操作会改变这个位置。因此程式当前的brk位置需要使用sbrk( )来取得。注意,这些变数名必须看作是地址。因此在存取它们时需要使用取位址首码‘&’,例如&end等。例如:

extern int _etext
int et;
(int *) et = &_etext; //此时et含有正文段结束处后面的地址。

下面程式predef.c可用於显示出这几个变数的位址。可以看出带与不带下划線‘_’符号的位址值是相同的。



在Linux 0.1x系统下执行该程式可以得到以下结果。请注意,这些位址都是程式位址空间中的逻辑位址,即从执行程式被载入到记忆体位置开始算起的位址。

[/usr/root]# gcc -o predef predef.c
[/usr/root]# ./predef
&etext=4000, &edata=44c0, &end=48d8
&_etext=4000, &_edata=44c0, &_end=48d8
[/usr/root]#

如果在现在的Linux系统(例如RedHat 9)中执行这个程式,就可得到以下结果。我们知道现在Linux系统中程式码从其逻辑位址0x08048000处开始存放,因此可知这个程式的代码段长度足0x41b位元组。

[root@plinux]# ./predef
&etext=0x804841b, &edata=0x80495a8, &end=0x80495ac
&_etext=0x804841b, &_edata=0x80495a8, &_end=0x80495ac
[root@plinux]#

Linux 0.1x內核在初始化区块设备高速缓冲区时(fs/buffer.c) ,就使用了变数名_end来获取內核映射档Image在记忆体中的末端后的位置,並从这个位置起开始设置高速缓冲区。

3.5.5System.map档

当执行GNU链结器gld(1d)时若使用了‘_’选项,或者使用nm命令,则会在标準输出设备(通常是萤幕)上列印出链结映射(link map)资讯,即是指由连接程式產生的目的程式记忆体位址映射资讯。其中列出了程式段装入到记忆体中的位置资讯。具体来讲有如下资讯:

▓ 目标档及符号资讯映射到记忆体中的位置:

▓ 公共符号如何放置:

▓ 链结中包含的所有档成员及其参照引用的符号。
通常我们会把发送到标準输出设备的链结映射资讯重定向到一个档中(例如System.map)。在编译內核时,linux/Makefile档產生的System.map档就用於存放內核符号表资讯。符号表是所有內核符号及其对应位址的一个列表,当然也包括上面說明的_etext、_edata和_end等符号的位址资讯。随著每次內核的编译,就会產生一个新的对应System.map档。当內核执行出错时,透过System.map档中的符号表解析,就可以查到一个位址值对应的变数名,或反之。

利用System.map符号表档,在內核或相关程式出错时,就可以获得我们比较容易识別的资讯。符号表的樣例如下所示:

c03441a0 B dmi_broken
c03441a4 B is_sony_vaio_laptop
c03441c0 b dmi_ident
c0344200 b pci_bios_present
c0344204 b ping_table

其中每行說明一个符号,第l栏指明符号值(位址) :第2栏是符号类型,指明符号位於目标档的哪个区(sections)或其属性;第3栏是对应的符号名称。

第2栏中的符号类型指示符号通常有表3—5所示的几种,另外还有一些与採用的目标档格式相关。如果符号类型是小写字元,则說明符号是区域的:如果是大写字元,则說明符号是全域的(外部的)。参考include/a.out.h中nlist{}结构n_type栏位的定义(第110- -185行)。



可以看出名称为dmi_broken的变数位於內核位址0xc0344la0处。

System.map位於使用它的软体(例如內核日誌记錄后端程式klogd)能夠寻找到的地方。在系统啟动时,如果沒有以一个参数的形式为klogd给出的stem.map的位置,则klogd将会在三个地方搜寻System.map依次为:

/boot/system.map
/System.map
/usr/src/linux/System.map

尽管內核本身实际上不使用System.map,但其他程式,像klogd、lsof、ps以及其他像dosemu等许多软体都需要有一个正确的System.map档案。利用该档,这些程式就可以根据已知的记忆体位址查找出对应的內核变数名称,便於对內核的除错工作。

3.6 Make程式和Makefile档
Makefile(或makefile)档是make工具程式的配置档。 Make工具程式的主要用途是能自动地決定一个含有很多来源程式档的大型程式中哪个档需要被重新编译。 Makefile的使用比较复杂,这裡只是根据上面的Makefile档作些简单的介绍。详细說明请参考GNU make使用手冊。

为了使用make程式,你就需要Makefile档来告诉make要做些什麼工作。通常,Makefile档会告诉make如何编译和连接一个档。当明确指出时,Makefile还可以告诉make执行各种命令(例如,作为清理操作而刪除某些档) 。

make的执行过程分为两个不同的阶段。在第一个阶段,它读取所有的Makefile档以及包含的Makefile档等,记錄所有的变数及其值、隐式的或显式的规则,並构造出所有目标物件及其先決条件的一幅全景图。在第二阶段期间,make就使用这些內部结构来确定哪个目标物件需要被重建,並且使用相应的规则来操作。

当make重新编译程序时,每个修改过的C代码档必须被重新编译。如果一个标头档被修改过了,那麼为了确保正确,每一个包含该标头档的C代码程式都将被重新编译。每次编译操作都產生一个与来源程式对应的目标档。最终,如果任何原始码档被编译过了,那麼所有的目标档不管是刚编译完的还是以前就编译好的必须连接在一起以生成新的可执行档。

简单的Makefile档含有一些规则,这些规则具有如下的形式:

目标(target)┅ :先决条件(prerequisites)┅
命令(command)



其中‘目标’对象通常是程式生成的一个档的名称;例如是一个可执行档或目标档。目标也可以是所要採取活动的名字,比如‘清除’(‘clean’)。‘先決条件’是一个或多个档案名’是用作產生目标的输入条件。通常一个目标依赖几个档。而‘命令’是make需要执行的操作。一个规则可以有多个命令,每一个命令自成一行。请注意,你需要在每个命令行之前键入一个跳位字元! 这是粗心者常常怱略的地方。

如果一个先決条件透过目錄搜寻而在另外一个目錄中被找到,这並不会改变规则的命令;它们将被如期执行。因此,你必须小心地设置命令,使得命令能夠在make发现先決条件的目錄中找到需要的先決条件。这就需要透过使用自动变数来做到。自动变数是一种在命令行上根据具体情況能被自动替換的变数。自动变数的值是基於目标物件及其先決条件而在命令执行前设置的。例如,‘$∧’的值表示规则的所有先決条件,包括它们所处目錄的名称;‘$<’的值表示规则中的第一个先決条件‘$@’表示目标物件;另外还有一些自动变数这裡就不提了。

有时,先決条件还常包含标头档,而这些标头档並不愿在命令中說明。此时自动变数‘$<’正是第一个先決条件。例如:

foo.o :foo.c defs.h hack.h
cc -c $(CFLAGS) $< -o $@

其中的‘$<’就会被自动地替換成foo.c,,而$@则会被替換为foo.o。

为了让make能使用习惯用法来更新一个目标物件,你可以不指定命令,写一个不带命令的规则或者不写规则。此时make程式将会根据来源程式档的类型(程式的副档名)来判断要使用哪个隐式规则。副档名规则是为make程式定义隐式规则的老式方法(现在这种规则已经不
用了,取而代之的是使用更通用更清晰的模式匹配规则)。下面例子就是一种双副档名规则。双副档名规则是用一对副档名定义的:来源副档名和目标副档名。相应的隐式先決条件是透过使用档案名中的来源副档名替換目标副档名后得到。因此,此时下面的‘$<’值是 *‘.c’档案名称。而正条make规则的含义是将‘*.c’程式编译成‘*.s’代码。



通常命令是属於一个具有先決条件的规则,並在任何先決条件改变时用於生成一个目标(target)档。然而,为目标而指定命令的规则也並不一定要有先決条件。例如,与目标‘clean’相关的含有刪除(delete)命令的规则並不需要有先決条件。此时,一个规则說明了如何以及何时来重新制作某些档,而这些档是特定规则的目标。make根据先決条件来执行命令以建立或更新目标。一个规则也可以說明如何及何时执行一个操作。

一个Makefile档也可以含有除规则以外的其他文字,但一个简单的Makefile档只需要含有适当的规则。规则可能看上去要比上面示出的范本复杂得多,但基本上都是符合的。

Makefile档最后生成的依赖关系是用於让make来确定是否需要重建一个目标物件。比如当某个标头档被改动过后,make就透过这些依赖关系,重新编译与该标头档有关的所有‘*.c’档案。

本章小结
本章以几个可执行的组合语言程式作为描述对象,详细說明了as86和GNU as组合语言的基本语言和使用方法。同时对Linux內核使用的C语言扩展语句进行了详细介绍。对於学习作业系统来說,系统支援的目标档结构有著非常重要的作用,因此本章对Linux 0.12中使用的a.out目标档格式作了详细介绍。

下一章我们将围绕Intel 80X86处理器,详细地說明其执行在保护模式下的工作原理。並带出一个保护模式多工程式范例,透过閱读这个范例,我们可以对作业系统最初如何“运转”起来有一个基本了解,並为继续閱读完整的Linux 0.12內核原始码打下坚实基础。

注译:

Stack - 堆叠 或者称呼为 堆栈
Segment - 段
Flag - 旗标 或者称呼为 标志
暂存器又称呼为寄存器
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: