http://learn.akae.cn/media/ch20.html
目录
1. 多目标文件的链接 请点评
现在我们把例 12.1 “用堆栈实现倒序打印”拆成两个程序文件,stack.c
实现堆栈,而main.c
使用堆栈:
/* stack.c */ char stack[512]; int top = -1; void push(char c) { stack[++top] = c; } char pop(void) { return stack[top--]; } int is_empty(void) { return top == -1; }
这段程序和原来有点不同,在例 12.1 “用堆栈实现倒序打印”中top
总是指向栈顶元素的下一个元素,而在这段程序中top
总是指向栈顶元素,所以要初始化成-1才表示空堆栈,这两种堆栈使用习惯都很常见。
/* main.c */ #include <stdio.h> int a, b = 1; int main(void) { push('a'); push('b'); push('c'); while(!is_empty()) putchar(pop()); putchar('\n'); return 0; }
a
和b
这两个变量没有用,只是为了顺便说明链接过程才加上的。编译的步骤和以前一样,可以一步编译:
$ gcc main.c stack.c -o main
也分可以多步编译:
$ gcc -c main.c $ gcc -c stack.c $ gcc main.o stack.o -o main
如果按照第 2 节 “main
函数和启动例程”的做法,用nm
命令查看目标文件的符号表,会发现main.o
中有未定义的符号push
、pop
、is_empty
、putchar
,前三个符号在stack.o
中实现了,链接生成可执行文件main
时可以做符号解析,而putchar
是libc
的库函数,在可执行文件main
中仍然是未定义的,要在程序运行时做动态链接。
我们通过readelf -a main
命令可以看到,main
的.bss
段合并了main.o
和stack.o
的.bss
段,其中包含了变量a
和stack
,main
的.data
段也合并了main.o
和stack.o
的.data
段,其中包含了变量b
和top
,main
的.text
段合并了main.o
和stack.o
的.text
段,包含了各函数的定义。如下图所示。
为什么在可执行文件main
的每个段中来自main.o
的变量或函数都在前面,而来自stack.o
的变量或函数都在后面呢?我们可以试试把gcc
命令中的两个目标文件反过来写:
$ gcc stack.o main.o -o main
结果正如我们所预料的,可执行文件main
的每个段中来自main.o
的变量或函数都排到后面了。实际上链接的过程是由一个链接脚本(Linker Script)控制的,链接脚本决定了给每个段分配什么地址,如何对齐,哪个段在前,哪个段在后,哪些段合并到同一个Segment,另外链接脚本还要插入一些符号到最终生成的文件中,例如__bss_start
、_edata
、_end
等。如果用ld
做链接时没有用-T
选项指定链接脚本,则使用ld
的默认链接脚本,默认链接脚本可以用ld --verbose
命令查看(由于比较长,只列出一些片断):
$ ld --verbose ... using internal linker script: ================================================== /* Script for -z combreloc: combine and sort reloc sections */ OUTPUT_FORMAT("elf32-i386", "elf32-i386", "elf32-i386") OUTPUT_ARCH(i386) ENTRY(_start) ... SECTIONS { /* Read-only sections, merged into text segment: */ PROVIDE (__executable_start = 0x08048000); . = 0x08048000 + SIZEOF_HEADERS; .interp : { *(.interp) } .note.gnu.build-id : { *(.note.gnu.build-id) } .hash : { *(.hash) } .gnu.hash : { *(.gnu.hash) } .dynsym : { *(.dynsym) } .dynstr : { *(.dynstr) } .gnu.version : { *(.gnu.version) } .gnu.version_d : { *(.gnu.version_d) } .gnu.version_r : { *(.gnu.version_r) } .rel.dyn : ... .rel.plt : { *(.rel.plt) } ... .init : ... .plt : { *(.plt) } .text : ... .fini : ... .rodata : { *(.rodata .rodata.* .gnu.linkonce.r.*) } ... .eh_frame : ONLY_IF_RO { KEEP (*(.eh_frame)) } ... /* Adjust the address for the data segment. We want to adjust up to the same address within the page on the next page up. */ . = ALIGN (CONSTANT (MAXPAGESIZE)) - ((CONSTANT (MAXPAGESIZE) - .) & (CONSTANT (MAXPAGESIZE) - 1)); . = DATA_SEGMENT_ALIGN (CONSTANT (MAXPAGESIZE), CONSTANT (COMMONPAGESIZE)); ... .ctors : ... .dtors : ... .jcr : { KEEP (*(.jcr)) } ... .dynamic : { *(.dynamic) } .got : { *(.got) } ... .got.plt : { *(.got.plt) } .data : ... _edata = .; PROVIDE (edata = .); __bss_start = .; .bss : ... _end = .; PROVIDE (end = .); . = DATA_SEGMENT_END (.); /* Stabs debugging sections. */ ... /* DWARF debug sections. Symbols in the DWARF debugging sections are relative to the beginning of the section so we begin them at 0. */ ... } ==================================================
ENTRY(_start)
说明_start
是整个程序的入口点,因此_start
是入口点并不是规定死的,是可以改用其它函数做入口点的。
PROVIDE (__executable_start = 0x08048000); . = 0x08048000 + SIZEOF_HEADERS;
是Text Segment的起始地址,这个Segment包含后面列出的那些段,.plt
、.text
、.rodata
等等。每个段的描述格式都是“段名 : { 组成 }”,例如.plt : { *(.plt) }
,左边表示最终生成的文件的.plt
段,右边表示所有目标文件的.plt
段,意思是最终生成的文件的.plt
段由各目标文件的.plt
段组成。
. = ALIGN (CONSTANT (MAXPAGESIZE)) - ((CONSTANT (MAXPAGESIZE) - .) & (CONSTANT (MAXPAGESIZE) - 1)); . = DATA_SEGMENT_ALIGN (CONSTANT (MAXPAGESIZE), CONSTANT (COMMONPAGESIZE));
是Data Segment的起始地址,要做一系列的对齐操作,这个Segment包含后面列出的那些段,.got
、.data
、.bss
等等。
Data Segment的后面还有其它一些Segment,主要是调试信息。关于链接脚本就介绍这么多,本书不做深入讨论。
2. 定义和声明 请点评
2.1. extern
和static
关键字 请点评
在上一节我们把两个程序文件放在一起编译链接,main.c
用到的函数push
、pop
和is_empty
由stack.c
提供,其实有一点小问题,我们用-Wall
选项编译main.c
可以看到:
$ gcc -c main.c -Wall main.c: In function ‘main’: main.c:8: warning: implicit declaration of function ‘push’ main.c:12: warning: implicit declaration of function ‘is_empty’ main.c:13: warning: implicit declaration of function ‘pop’
这个问题我们在第 2 节 “自定义函数”讨论过,由于编译器在处理函数调用代码时没有找到函数原型,只好根据函数调用代码做隐式声明,把这三个函数声明为:
int push(char); int pop(void); int is_empty(void);
现在你应该比学第 2 节 “自定义函数”的时候更容易理解这条规则了。为什么编译器在处理函数调用代码时需要有函数原型?因为必须知道参数的类型和个数以及返回值的类型才知道生成什么样的指令。为什么隐式声明靠不住呢?因为隐式声明是从函数调用代码推导而来的,而事实上函数定义的形参类型可能跟函数调用代码传的实参类型并不一致,如果函数定义带有可变参数(例如printf
),那么从函数调用代码也看不出来这个函数带有可变参数,另外,从函数调用代码也看不出来返回值应该是什么类型,所以隐式声明只能规定返回值都是int
型的。既然隐式声明靠不住,那编译器为什么不自己去找函数定义,而非要让我们在调用之前写函数原型呢?因为编译器往往不知道去哪里找函数定义,像上面的例子,我让编译器编译main.c
,而这几个函数的定义却在stack.c
里,编译器又怎么会知道呢?所以编译器只能通过隐式声明来猜测函数原型,这种猜测往往会出错,但在比较简单的情况下还算可用,比如上一节的例子这么编译过去了也能得到正确结果。
现在我们在main.c
中声明这几个函数的原型:
/* main.c */ #include <stdio.h> extern void push(char); extern char pop(void); extern int is_empty(void); int main(void) { push('a'); push('b'); push('c'); while(!is_empty()) putchar(pop()); putchar('\n'); return 0; }
这样编译器就不会报警告了。在这里extern
关键字表示这个标识符具有External Linkage。External Linkage的定义在上一章讲过,但现在应该更容易理解了,push
这个标识符具有External Linkage指的是:如果把main.c
和stack.c
链接在一起,如果push
在main.c
和stack.c
中都有声明(在stack.c
中的声明同时也是定义),那么这些声明指的是同一个函数,链接之后是同一个GLOBAL
符号,代表同一个地址。函数声明中的extern
也可以省略不写,不写extern
的函数声明也表示这个函数具有External Linkage。
如果用static
关键字修饰一个函数声明,则表示该标识符具有Internal Linkage,例如有以下两个程序文件:
/* foo.c */ static void foo(void) {}
/* main.c */ void foo(void); int main(void) { foo(); return 0; }
编译链接在一起会出错:
$ gcc foo.c main.c /tmp/ccRC2Yjn.o: In function `main': main.c:(.text+0x12): undefined reference to `foo' collect2: ld returned 1 exit status
虽然在foo.c
中定义了函数foo
,但这个函数只具有Internal Linkage,只有在foo.c
中多次声明才表示同一个函数,而在main.c
中声明就不表示它了。如果把foo.c
编译成目标文件,函数名foo
在其中是一个LOCAL
的符号,不参与链接过程,所以在链接时,main.c
中用到一个External Linkage的foo
函数,链接器却找不到它的定义在哪儿,无法确定它的地址,也就无法做符号解析,只好报错。凡是被多次声明的变量或函数,必须有且只有一个声明是定义,如果有多个定义,或者一个定义都没有,链接器就无法完成链接。
以上讲了用static
和extern
修饰函数声明的情况。现在来看用它们修饰变量声明的情况。仍然用stack.c
和main.c
的例子,如果我想在main.c
中直接访问stack.c
中定义的变量top
,则可以用extern
声明它:
/* main.c */ #include <stdio.h> void push(char); char pop(void); int is_empty(void); extern int top; int main(void) { push('a'); push('b'); push('c'); printf("%d\n", top); while(!is_empty()) putchar(pop()); putchar('\n'); printf("%d\n", top); return 0; }
变量top
具有External Linkage,它的存储空间是在stack.c
中分配的,所以main.c
中的变量声明extern int top;
不是变量定义,因为它不分配存储空间。以上函数和变量声明也可以写在main
函数体里面,使所声明的标识符具有块作用域:
int main(void) { void push(char); char pop(void); int is_empty(void); extern int top; push('a'); push('b'); push('c'); printf("%d\n", top); while(!is_empty()) putchar(pop()); putchar('\n'); printf("%d\n", top); return 0; }
注意,变量声明和函数声明有一点不同,函数声明的extern
可写可不写,而变量声明如果不写extern
意思就完全变了,如果上面的例子不写extern
就表示在main
函数中定义一个局部变量top
。另外要注意,stack.c
中的定义是int top = -1;
,而main.c
中的声明不能加Initializer,如果上面的例子写成extern int top = -1;
则编译器会报错。
在main.c
中可以通过变量声明来访问stack.c
中的变量top
,但是从实现stack.c
这个模块的角度来看,top
这个变量是不希望被外界访问到的,变量top
和stack
都属于这个模块的内部状态,外界应该只允许通过push
和pop
函数来改变模块的内部状态,这样才能保证堆栈的LIFO特性,如果外界可以随机访问stack
或者随便修改top
,那么堆栈的状态就乱了。那怎么才能阻止外界访问top
和stack
呢?答案就是用static
关键字把它们声明为Internal Linkage的:
/* stack.c */ static char stack[512]; static int top = -1; void push(char c) { stack[++top] = c; } char pop(void) { return stack[top--]; } int is_empty(void) { return top == -1; }
这样,即使在main.c
中用extern
声明也访问不到stack.c
的变量top
和stack
。从而保护了stack.c
模块的内部状态,这也是一种封装(Encapsulation)的思想。
用static
关键字声明具有Internal Linkage的函数也是出于这个目的。在一个模块中,有些函数是提供给外界使用的,也称为导出(Export)给外界使用,这些函数声明为External Linkage的。有些函数只在模块内部使用而不希望被外界访问到,则声明为Internal Linkage的。
2.2. 头文件 请点评
我们继续前面关于stack.c
和main.c
的讨论。stack.c
这个模块封装了top
和stack
两个变量,导出了push
、pop
、is_empty
三个函数接口,已经设计得比较完善了。但是使用这个模块的每个程序文件都要写三个函数声明也是很麻烦的,假设又有一个foo.c
也使用这个模块,main.c
和foo.c
中各自要写三个函数声明。重复的代码总是应该尽量避免的,以前我们通过各种办法把重复的代码提取出来,比如在第 2 节 “数组应用实例:统计随机数”讲过用宏定义避免硬编码的问题,这次有什么办法呢?答案就是可以自己写一个头文件stack.h
:
/* stack.h */ #ifndef STACK_H #define STACK_H extern void push(char); extern char pop(void); extern int is_empty(void); #endif
这样在main.c
中只需包含这个头文件就可以了,而不需要写三个函数声明:
/* main.c */ #include <stdio.h> #include "stack.h" int main(void) { push('a'); push('b'); push('c'); while(!is_empty()) putchar(pop()); putchar('\n'); return 0; }
首先说为什么#include <stdio.h>
用角括号,而#include "stack.h"
用引号。对于用角括号包含的头文件,gcc
首先查找-I
选项指定的目录,然后查找系统的头文件目录(通常是/usr/include
,在我的系统上还包括/usr/lib/gcc/i486-linux-gnu/4.3.2/include
);而对于用引号包含的头文件,gcc
首先查找包含头文件的.c
文件所在的目录,然后查找-I
选项指定的目录,然后查找系统的头文件目录。
假如三个代码文件都放在当前目录下:
$ tree . |-- main.c |-- stack.c `-- stack.h 0 directories, 3 files
则可以用gcc -c main.c
编译,gcc
会自动在main.c
所在的目录中找到stack.h
。假如把stack.h
移到一个子目录下:
$ tree . |-- main.c `-- stack |-- stack.c `-- stack.h 1 directory, 3 files
则需要用gcc -c main.c -Istack
编译。用-I
选项告诉gcc
头文件要到子目录stack
里找。
在#include
预处理指示中可以使用相对路径,例如把上面的代码改成#include "stack/stack.h"
,那么编译时就不需要加-Istack
选项了,因为gcc
会自动在main.c
所在的目录中查找,而头文件相对于main.c
所在目录的相对路径正是stack/stack.h
。
在stack.h
中我们又看到两个新的预处理指示#ifndef STACK_H
和#endif
,意思是说,如果STACK_H
这个宏没有定义过,那么从#ifndef
到#endif
之间的代码就包含在预处理的输出结果中,否则这一段代码就不出现在预处理的输出结果中。stack.h
这个头文件的内容整个被#ifndef
和#endif
括起来了,如果在包含这个头文件时STACK_H
这个宏已经定义过了,则相当于这个头文件里什么都没有,包含了一个空文件。这有什么用呢?假如main.c
包含了两次stack.h
:
... #include "stack.h" #include "stack.h" int main(void) { ...
则第一次包含stack.h
时并没有定义STACK_H
这个宏,因此头文件的内容包含在预处理的输出结果中:
... #define STACK_H extern void push(char); extern char pop(void); extern int is_empty(void); #include "stack.h" int main(void) { ...
其中已经定义了STACK_H
这个宏,因此第二次再包含stack.h
就相当于包含了一个空文件,这就避免了头文件的内容被重复包含。这种保护头文件的写法称为Header Guard,以后我们每写一个头文件都要加上Header Guard,宏定义名就用头文件名的大写形式,这是规范的做法。
那为什么需要防止重复包含呢?谁会把一个头文件包含两次呢?像上面那么明显的错误没人会犯,但有时候重复包含的错误并不是那么明显的。比如:
#include "stack.h" #include "foo.h"
然而foo.h
里又包含了bar.h
,bar.h
里又包含了stack.h
。在规模较大的项目中头文件包含头文件的情况很常见,经常会包含四五层,这时候重复包含的问题就很难发现了。比如在我的系统头文件目录/usr/include
中,errno.h
包含了bits/errno.h
,后者又包含了linux/errno.h
,后者又包含了asm/errno.h
,后者又包含了asm-generic/errno.h
。
另外一个问题是,就算我是重复包含了头文件,那有什么危害么?像上面的三个函数声明,在程序中声明两次也没有问题,对于具有External Linkage的函数,声明任意多次也都代表同一个函数。重复包含头文件有以下问题:
-
一是使预处理的速度变慢了,要处理很多本来不需要处理的头文件。
-
二是如果有
foo.h
包含bar.h
,bar.h
又包含foo.h
的情况,预处理器就陷入死循环了(其实编译器都会规定一个包含层数的上限)。 -
三是头文件里有些代码不允许重复出现,虽然变量和函数允许多次声明(只要不是多次定义就行),但头文件里有些代码是不允许多次出现的,比如
typedef
类型定义和结构体Tag定义等,在一个程序文件中只允许出现一次。
还有一个问题,既然要#include
头文件,那我不如直接在main.c
中#include "stack.c"
得了。这样把stack.c
和main.c
合并为同一个程序文件,相当于又回到最初的例 12.1 “用堆栈实现倒序打印”了。当然这样也能编译通过,但是在一个规模较大的项目中不能这么做,假如又有一个foo.c
也要使用stack.c
这个模块怎么办呢?如果在foo.c
里面也#include "stack.c"
,就相当于push
、pop
、is_empty
这三个函数在main.c
和foo.c
中都有定义,那么main.c
和foo.c
就不能链接在一起了。如果采用包含头文件的办法,那么这三个函数只在stack.c
中定义了一次,最后可以把main.c
、stack.c
、foo.c
链接在一起。如下图所示:
同样道理,头文件中的变量和函数声明一定不能是定义。如果头文件中出现变量或函数定义,这个头文件又被多个.c
文件包含,那么这些.c
文件就不能链接在一起了。
2.3. 定义和声明的详细规则 请点评
以上两节关于定义和声明只介绍了最基本的规则,在写代码时掌握这些基本规则就够用了,但其实C语言关于定义和声明还有很多复杂的规则,在分析错误原因或者维护规模较大的项目时需要了解这些规则。本节的两个表格出自[Standard C]。
首先看关于函数声明的规则。
表 20.1. Storage Class关键字对函数声明的作用
Storage Class | File Scope Declaration | Block Scope Declaration |
---|---|---|
none |
previous linkage |
previous linkage |
extern |
previous linkage |
previous linkage |
static |
internal linkage |
N/A |
以前我们说“extern
关键字表示这个标识符具有External Linkage”其实是不准确的,准确地说应该是Previous Linkage。Previous Linkage的定义是:这次声明的标识符具有什么样的Linkage取决于前一次声明,这前一次声明具有相同的标识符名,而且必须是文件作用域的声明,如果在程序文件中找不到前一次声明(这次声明是第一次声明),那么这个标识符具有External Linkage。例如在一个程序文件中在文件作用域两次声明同一个函数:
static int f(void); /* internal linkage */ extern int f(void); /* previous linkage */
则这里的extern
修饰的标识符具有Interanl Linkage而不是External Linkage。从上表的前两行可以总结出我们先前所说的规则“函数声明加不加extern
关键字都一样”。上表也说明了在文件作用域允许定义函数,在块作用域不允许定义函数,或者说函数定义不能嵌套。另外,在块作用域中不允许用static
关键字声明函数。
关于变量声明的规则要复杂一些:
表 20.2. Storage Class关键字对变量声明的作用
Storage Class | File Scope Declaration | Block Scope Declaration |
---|---|---|
none |
external linkage |
no linkage |
extern |
previous linkage |
previous linkage |
static |
internal linkage |
no linkage |
上表的每个单元格里分成四行,分别描述变量的链接属性、生存期,以及这种变量如何初始化,是否算变量定义。链接属性有External Linkage、Internal Linkage、No Linkage和Previous Linkage四种情况,生存期有Static Duration和Automatic Duration两种情况,请参考本章和上一章的定义。初始化有Static Initializer和Dynamic Initializer两种情况,前者表示Initializer中只能使用常量表达式,表达式的值必须在编译时就能确定,后者表示Initializer中可以使用任意的右值表达式,表达式的值可以在运行时计算。是否算变量定义有三种情况,Definition(算变量定义)、Not a Definition(不算变量定义)和Tentative Definition(暂定的变量定义)。什么叫“暂定的变量定义”呢?一个变量声明具有文件作用域,没有Storage Class关键字修饰,或者用static
关键字修饰,那么如果它有Initializer则编译器认为它就是一个变量定义,如果它没有Initializer则编译器暂定它是变量定义,如果程序文件中有这个变量的明确定义就用明确定义,如果程序文件没有这个变量的明确定义,就用这个暂定的变量定义[32],这种情况下变量以0初始化。在[C99]中有一个例子:
int i1 = 1; // definition, external linkage static int i2 = 2; // definition, internal linkage extern int i3 = 3; // definition, external linkage int i4; // tentative definition, external linkage static int i5; // tentative definition, internal linkage int i1; // valid tentative definition, refers to previous int i2; // 6.2.2 renders undefined, linkage disagreement int i3; // valid tentative definition, refers to previous int i4; // valid tentative definition, refers to previous int i5; // 6.2.2 renders undefined, linkage disagreement extern int i1; // refers to previous, whose linkage is external extern int i2; // refers to previous, whose linkage is internal extern int i3; // refers to previous, whose linkage is external extern int i4; // refers to previous, whose linkage is external extern int i5; // refers to previous, whose linkage is internal
变量i2
和i5
第一次声明为Internal Linkage,第二次又声明为External Linkage,这是不允许的,编译器会报错。注意上表中标有[*]
的单元格,对于文件作用域的extern
变量声明,C99是允许带Initializer的,并且认为它是一个定义,但是gcc
对于这种写法会报警告,为了兼容性应避免这种写法。
[32] 由于本书没有提及将不完全类型进行组合的问题,所以这条规则被我简化了,真正的规则还要复杂一些。读者可以参考C99中有关Incomplete Type和Composite Type的条款。Tentative Definition的完整定义在C99的6.9.2节条款2。
3. 静态库 请点评
有时候需要把一组代码编译成一个库,这个库在很多项目中都要用到,例如libc
就是这样一个库,我们在不同的程序中都会用到libc
中的库函数(例如printf
),也会用到libc
中的变量(例如以后要讲到的environ
变量)。本节介绍怎么创建这样一个库。
我们继续用stack.c
的例子。为了便于理解,我们把stack.c
拆成四个程序文件(虽然实际上没太大必要),把main.c
改得简单一些,头文件stack.h
不变,本节用到的代码如下所示:
/* stack.c */ char stack[512]; int top = -1;
/* push.c */ extern char stack[512]; extern int top; void push(char c) { stack[++top] = c; }
/* pop.c */ extern char stack[512]; extern int top; char pop(void) { return stack[top--]; }
/* is_empty.c */ extern int top; int is_empty(void) { return top == -1; }
/* stack.h */ #ifndef STACK_H #define STACK_H extern void push(char); extern char pop(void); extern int is_empty(void); #endif
/* main.c */ #include <stdio.h> #include "stack.h" int main(void) { push('a'); return 0; }
这些文件的目录结构是:
$ tree . |-- main.c `-- stack |-- is_empty.c |-- pop.c |-- push.c |-- stack.c `-- stack.h 1 directory, 6 files
我们把stack.c
、push.c
、pop.c
、is_empty.c
编译成目标文件:
$ gcc -c stack/stack.c stack/push.c stack/pop.c stack/is_empty.c
然后打包成一个静态库libstack.a
:
$ ar rs libstack.a stack.o push.o pop.o is_empty.o ar: creating libstack.a
库文件名都是以lib
开头的,静态库以.a
作为后缀,表示Archive。ar
命令类似于tar
命令,起一个打包的作用,但是把目标文件打包成静态库只能用ar
命令而不能用tar
命令。选项r
表示将后面的文件列表添加到文件包,如果文件包不存在就创建它,如果文件包中已有同名文件就替换成新的。s
是专用于生成静态库的,表示为静态库创建索引,这个索引被链接器使用。ranlib
命令也可以为静态库创建索引,以上命令等价于:
$ ar r libstack.a stack.o push.o pop.o is_empty.o $ ranlib libstack.a
然后我们把libstack.a
和main.c
编译链接在一起:
$ gcc main.c -L. -lstack -Istack -o main
-L
选项告诉编译器去哪里找需要的库文件,-L.
表示在当前目录找。-lstack
告诉编译器要链接libstack
库,-I
选项告诉编译器去哪里找头文件。注意,即使库文件就在当前目录,编译器默认也不会去找的,所以-L.
选项不能少。编译器默认会找的目录可以用-print-search-dirs
选项查看:
$ gcc -print-search-dirs install: /usr/lib/gcc/i486-linux-gnu/4.3.2/ programs: =/usr/lib/gcc/i486-linux-gnu/4.3.2/:/usr/lib/gcc/i486-linux-gnu/4.3.2/:/usr/lib/gcc/i486-linux-gnu/:/usr/lib/gcc/i486-linux-gnu/4.3.2/:/usr/lib/gcc/i486-linux-gnu/:/usr/libexec/gcc/i486-linux-gnu/4.3.2/:/usr/libexec/gcc/i486-linux-gnu/:/usr/lib/gcc/i486-linux-gnu/4.3.2/:/usr/lib/gcc/i486-linux-gnu/:/usr/lib/gcc/i486-linux-gnu/4.3.2/http://www.cnblogs.com/http://www.cnblogs.com/i486-linux-gnu/bin/i486-linux-gnu/4.3.2/:/usr/lib/gcc/i486-linux-gnu/4.3.2/http://www.cnblogs.com/http://www.cnblogs.com/i486-linux-gnu/bin/ libraries: =/usr/lib/gcc/i486-linux-gnu/4.3.2/:/usr/lib/gcc/i486-linux-gnu/4.3.2/:/usr/lib/gcc/i486-linux-gnu/4.3.2/http://www.cnblogs.com/http://www.cnblogs.com/i486-linux-gnu/lib/i486-linux-gnu/4.3.2/:/usr/lib/gcc/i486-linux-gnu/4.3.2/http://www.cnblogs.com/http://www.cnblogs.com/i486-linux-gnu/lib/../lib/:/usr/lib/gcc/i486-linux-gnu/4.3.2/http://www.cnblogs.com/../i486-linux-gnu/4.3.2/:/usr/lib/gcc/i486-linux-gnu/4.3.2/http://www.cnblogs.com/http://www.cnblogs.com/lib/:/lib/i486-linux-gnu/4.3.2/:/lib/../lib/:/usr/lib/i486-linux-gnu/4.3.2/:/usr/lib/../lib/:/usr/lib/gcc/i486-linux-gnu/4.3.2/http://www.cnblogs.com/http://www.cnblogs.com/i486-linux-gnu/lib/:/usr/lib/gcc/i486-linux-gnu/4.3.2/http://www.cnblogs.com/../:/lib/:/usr/lib/
其中的libraries
就是库文件的搜索路径列表,各路径之间用:
号隔开。编译器会在这些搜索路径以及-L
选项指定的路径中查找用-l
选项指定的库,比如-lstack
,编译器会首先找有没有共享库libstack.so
,如果有就链接它,如果没有就找有没有静态库libstack.a
,如果有就链接它。所以编译器是优先考虑共享库的,如果希望编译器只链接静态库,可以指定-static
选项。
那么链接共享库和链接静态库有什么区别呢?在第 2 节 “main
函数和启动例程”讲过,在链接libc
共享库时只是指定了动态链接器和该程序所需要的库文件,并没有真的做链接,可执行文件main
中调用的libc
库函数仍然是未定义符号,要在运行时做动态链接。而在链接静态库时,链接器会把静态库中的目标文件取出来和可执行文件真正链接在一起。我们通过反汇编看上一步生成的可执行文件main
:
$ objdump -d main ... 08048394 <main>: 8048394: 8d 4c 24 04 lea 0x4(%esp),%ecx 8048398: 83 e4 f0 and $0xfffffff0,%esp 804839b: ff 71 fc pushl -0x4(%ecx) ... 080483c0 <push>: 80483c0: 55 push %ebp 80483c1: 89 e5 mov %esp,%ebp 80483c3: 83 ec 04 sub $0x4,%esp
有意思的是,main.c
只调用了push
这一个函数,所以链接生成的可执行文件中也只有push
而没有pop
和is_empty
。这是使用静态库的一个好处,链接器可以从静态库中只取出需要的部分来做链接。如果是直接把那些目标文件和main.c
编译链接在一起:
$ gcc main.c stack.o push.o pop.o is_empty.o -Istack -o main
则没有用到的函数也会链接进来。当然另一个好处就是使用静态库只需写一个库文件名,而不需要写一长串目标文件名。
4. 共享库 请点评
4.1. 编译、链接、运行 请点评
组成共享库的目标文件和一般的目标文件有所不同,在编译时要加-fPIC
选项,例如:
$ gcc -c -fPIC stack/stack.c stack/push.c stack/pop.c stack/is_empty.c
-f
后面跟一些编译选项,PIC
是其中一种,表示生成位置无关代码(Position Independent Code)。那么用-fPIC
生成的目标文件和一般的目标文件有什么不同呢?下面分析这个问题。
我们知道一般的目标文件称为Relocatable,在链接时可以把目标文件中各段的地址做重定位,重定位时需要修改指令。我们先不加-fPIC
选项编译生成目标文件:
$ gcc -c -g stack/stack.c stack/push.c stack/pop.c stack/is_empty.c
由于接下来要用objdump -dS
把反汇编指令和源代码穿插起来分析,所以用-g
选项加调试信息。注意,加调试信息必须在编译每个目标文件时用-g
选项,而不能只在最后编译生成可执行文件时用-g
选项。反汇编查看push.o
:
$ objdump -dS push.o push.o: file format elf32-i386 Disassembly of section .text: 00000000 <push>: /* push.c */ extern char stack[512]; extern int top; void push(char c) { 0: 55 push %ebp 1: 89 e5 mov %esp,%ebp 3: 83 ec 04 sub $0x4,%esp 6: 8b 45 08 mov 0x8(%ebp),%eax 9: 88 45 fc mov %al,-0x4(%ebp) stack[++top] = c; c: a1 00 00 00 00 mov 0x0,%eax 11: 83 c0 01 add $0x1,%eax 14: a3 00 00 00 00 mov %eax,0x0 19: 8b 15 00 00 00 00 mov 0x0,%edx 1f: 0f b6 45 fc movzbl -0x4(%ebp),%eax 23: 88 82 00 00 00 00 mov %al,0x0(%edx) } 29: c9 leave 2a: c3 ret
指令中凡是用到stack
和top
的地址都用0x0表示,准备在重定位时修改。再看readelf
输出的.rel.text
段的信息:
Relocation section '.rel.text' at offset 0x848 contains 4 entries: Offset Info Type Sym.Value Sym. Name 0000000d 00001001 R_386_32 00000000 top 00000015 00001001 R_386_32 00000000 top 0000001b 00001001 R_386_32 00000000 top 00000025 00001101 R_386_32 00000000 stack
标出了指令中有四处需要在重定位时修改。下面编译链接成可执行文件之后再做反汇编分析:
$ gcc -g main.c stack.o push.o pop.o is_empty.o -Istack -o main $ objdump -dS main ... 080483c0 <push>: /* push.c */ extern char stack[512]; extern int top; void push(char c) { 80483c0: 55 push %ebp 80483c1: 89 e5 mov %esp,%ebp 80483c3: 83 ec 04 sub $0x4,%esp 80483c6: 8b 45 08 mov 0x8(%ebp),%eax 80483c9: 88 45 fc mov %al,-0x4(%ebp) stack[++top] = c; 80483cc: a1 10 a0 04 08 mov 0x804a010,%eax 80483d1: 83 c0 01 add $0x1,%eax 80483d4: a3 10 a0 04 08 mov %eax,0x804a010 80483d9: 8b 15 10 a0 04 08 mov 0x804a010,%edx 80483df: 0f b6 45 fc movzbl -0x4(%ebp),%eax 80483e3: 88 82 40 a0 04 08 mov %al,0x804a040(%edx) } 80483e9: c9 leave 80483ea: c3 ret 80483eb: 90 nop ...
原来指令中的0x0被修改成了0x804a010和0x804a040,这样做了重定位之后,各段的加载地址就定死了,因为在指令中使用了绝对地址。
现在看用-fPIC
编译生成的目标文件有什么不同:
$ gcc -c -g -fPIC stack/stack.c stack/push.c stack/pop.c stack/is_empty.c $ objdump -dS push.o push.o: file format elf32-i386 Disassembly of section .text: 00000000 <push>: /* push.c */ extern char stack[512]; extern int top; void push(char c) { 0: 55 push %ebp 1: 89 e5 mov %esp,%ebp 3: 53 push %ebx 4: 83 ec 04 sub $0x4,%esp 7: e8 fc ff ff ff call 8 <push+0x8> c: 81 c3 02 00 00 00 add $0x2,%ebx 12: 8b 45 08 mov 0x8(%ebp),%eax 15: 88 45 f8 mov %al,-0x8(%ebp) stack[++top] = c; 18: 8b 83 00 00 00 00 mov 0x0(%ebx),%eax 1e: 8b 00 mov (%eax),%eax 20: 8d 50 01 lea 0x1(%eax),%edx 23: 8b 83 00 00 00 00 mov 0x0(%ebx),%eax 29: 89 10 mov %edx,(%eax) 2b: 8b 83 00 00 00 00 mov 0x0(%ebx),%eax 31: 8b 08 mov (%eax),%ecx 33: 8b 93 00 00 00 00 mov 0x0(%ebx),%edx 39: 0f b6 45 f8 movzbl -0x8(%ebp),%eax 3d: 88 04 0a mov %al,(%edx,%ecx,1) } 40: 83 c4 04 add $0x4,%esp 43: 5b pop %ebx 44: 5d pop %ebp 45: c3 ret Disassembly of section .text.__i686.get_pc_thunk.bx: 00000000 <__i686.get_pc_thunk.bx>: 0: 8b 1c 24 mov (%esp),%ebx 3: c3 ret
指令中用到的stack
和top
的地址不再以0x0表示,而是以0x0(%ebx)
表示,但其中还是留有0x0准备做进一步修改。再看readelf
输出的.rel.text
段:
Relocation section '.rel.text' at offset 0x94c contains 6 entries: Offset Info Type Sym.Value Sym. Name 00000008 00001202 R_386_PC32 00000000 __i686.get_pc_thunk.bx 0000000e 0000130a R_386_GOTPC 00000000 _GLOBAL_OFFSET_TABLE_ 0000001a 00001403 R_386_GOT32 00000000 top 00000025 00001403 R_386_GOT32 00000000 top 0000002d 00001403 R_386_GOT32 00000000 top 00000035 00001503 R_386_GOT32 00000000 stack
top
和stack
对应的记录类型不再是R_386_32
了,而是R_386_GOT32
,有什么区别呢?我们先编译生成共享库再做反汇编分析:
$ gcc -shared -o libstack.so stack.o push.o pop.o is_empty.o $ objdump -dS libstack.so ... 0000047c <push>: /* push.c */ extern char stack[512]; extern int top; void push(char c) { 47c: 55 push %ebp 47d: 89 e5 mov %esp,%ebp 47f: 53 push %ebx 480: 83 ec 04 sub $0x4,%esp 483: e8 ef ff ff ff call 477 <__i686.get_pc_thunk.bx> 488: 81 c3 6c 1b 00 00 add $0x1b6c,%ebx 48e: 8b 45 08 mov 0x8(%ebp),%eax 491: 88 45 f8 mov %al,-0x8(%ebp) stack[++top] = c; 494: 8b 83 f4 ff ff ff mov -0xc(%ebx),%eax 49a: 8b 00 mov (%eax),%eax 49c: 8d 50 01 lea 0x1(%eax),%edx 49f: 8b 83 f4 ff ff ff mov -0xc(%ebx),%eax 4a5: 89 10 mov %edx,(%eax) 4a7: 8b 83 f4 ff ff ff mov -0xc(%ebx),%eax 4ad: 8b 08 mov (%eax),%ecx 4af: 8b 93 f8 ff ff ff mov -0x8(%ebx),%edx 4b5: 0f b6 45 f8 movzbl -0x8(%ebp),%eax 4b9: 88 04 0a mov %al,(%edx,%ecx,1) } 4bc: 83 c4 04 add $0x4,%esp 4bf: 5b pop %ebx 4c0: 5d pop %ebp 4c1: c3 ret 4c2: 90 nop 4c3: 90 nop ...
和先前的结果不同,指令中的0x0(%ebx)
被修改成-0xc(%ebx)
和-0x8(%ebx)
,而不是修改成绝对地址。所以共享库各段的加载地址并没有定死,可以加载到任意位置,因为指令中没有使用绝对地址,因此称为位置无关代码。另外,注意这几条指令:
494: 8b 83 f4 ff ff ff mov -0xc(%ebx),%eax 49a: 8b 00 mov (%eax),%eax 49c: 8d 50 01 lea 0x1(%eax),%edx
和先前的指令对比一下:
80483cc: a1 10 a0 04 08 mov 0x804a010,%eax 80483d1: 83 c0 01 add $0x1,%eax
可以发现,-0xc(%ebx)
这个地址并不是变量top
的地址,这个地址的内存单元中又保存了另外一个地址,这另外一个地址才是变量top
的地址,所以mov -0xc(%ebx),%eax
是把变量top
的地址传给eax
,而mov (%eax),%eax
才是从top
的地址中取出top
的值传给eax
。lea 0x1(%eax),%edx
是把top
的值加1存到edx
中,如下图所示:
top
和stack
的绝对地址保存在一个地址表中,而指令通过地址表做间接寻址,因此避免了将绝对地址写死在指令中,这也是一种避免硬编码的策略。
现在把main.c
和共享库编译链接在一起,然后运行:
$ gcc main.c -g -L. -lstack -Istack -o main $ ./main ./main: error while loading shared libraries: libstack.so: cannot open shared object file: No such file or directory
结果出乎意料,编译的时候没问题,由于指定了-L.
选项,编译器可以在当前目录下找到libstack.so
,而运行时却说找不到libstack.so
。那么运行时在哪些路径下找共享库呢?我们先用ldd
命令查看可执行文件依赖于哪些共享库:
$ ldd main linux-gate.so.1 => (0xb7f5c000) libstack.so => not found libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7dcf000) /lib/ld-linux.so.2 (0xb7f42000)
ldd
模拟运行一遍main
,在运行过程中做动态链接,从而得知这个可执行文件依赖于哪些共享库,每个共享库都在什么路径下,加载到进程地址空间的什么地址。/lib/ld-linux.so.2
是动态链接器,它的路径是在编译链接时指定的,我们在第 2 节 “main
函数和启动例程”讲过gcc
在做链接时用-dynamic-linker
指定动态链接器的路径,它也像其它共享库一样加载到进程的地址空间中。libc.so.6
的路径/lib/tls/i686/cmov/libc.so.6
是由动态链接器ld-linux.so.2
在做动态链接时搜索到的,而libstack.so
的路径没有找到。linux-gate.so.1
这个共享库其实并不存在于文件系统中,它是由内核虚拟出来的共享库,所以它没有对应的路径,它负责处理系统调用。总之,共享库的搜索路径由动态链接器决定,从ld.so(8)
的Man Page可以查到共享库路径的搜索顺序:
-
首先在环境变量
LD_LIBRARY_PATH
所记录的路径中查找。 -
然后从缓存文件
/etc/ld.so.cache
中查找。这个缓存文件由ldconfig
命令读取配置文件/etc/ld.so.conf
之后生成,稍后详细解释。 -
如果上述步骤都找不到,则到默认的系统路径中查找,先是/lib然后是/usr/lib。
先试试第一种方法,在运行main
时通过环境变量LD_LIBRARY_PATH
把当前目录添加到共享库的搜索路径:
$ LD_LIBRARY_PATH=. ./main
这种方法只适合在开发中临时用一下,通常LD_LIBRARY_PATH
是不推荐使用的,尽量不要设置这个环境变量,理由可以参考Why LD_LIBRARY_PATH is bad(http://www.visi.com/~barr/ldpath.html)。
再试试第二种方法,这是最常用的方法。把libstack.so
所在目录的绝对路径(比如/home/akaedu/somedir)添加到/etc/ld.so.conf
中(该文件中每个路径占一行),然后运行ldconfig
:
$ sudo ldconfig -v ... /home/akaedu/somedir: libstack.so -> libstack.so /lib: libe2p.so.2 -> libe2p.so.2.3 libncursesw.so.5 -> libncursesw.so.5.6 ... /usr/lib: libkdeinit_klauncher.so -> libkdeinit_klauncher.so libv4l2.so.0 -> libv4l2.so.0 ... /usr/lib64: /lib/tls: (hwcap: 0x8000000000000000) /usr/lib/sse2: (hwcap: 0x0000000004000000) ... /usr/lib/tls: (hwcap: 0x8000000000000000) ... /usr/lib/i686: (hwcap: 0x0008000000000000) /usr/lib/i586: (hwcap: 0x0004000000000000) ... /usr/lib/i486: (hwcap: 0x0002000000000000) ... /lib/tls/i686: (hwcap: 0x8008000000000000) /usr/lib/i686/cmov: (hwcap: 0x0008000000008000) ... /lib/tls/i686/cmov: (hwcap: 0x8008000000008000)
ldconfig
命令除了处理/etc/ld.so.conf
中配置的目录之外,还处理一些默认目录,如/lib
、/usr/lib
等,处理之后生成/etc/ld.so.cache
缓存文件,动态链接器就从这个缓存中搜索共享库。hwcap是x86平台的Linux特有的一种机制,系统检测到当前平台是i686而不是i586
或i486
,所以在运行程序时使用i686的库,这样可以更好地发挥平台的性能,也可以利用一些新的指令,所以上面ldd
命令的输出结果显示动态链接器搜索到的libc
是/lib/tls/i686/cmov/libc.so.6
,而不是/lib/libc.so.6
。现在再用ldd
命令查看,libstack.so
就能找到了:
$ ldd main linux-gate.so.1 => (0xb809c000) libstack.so => /home/akaedu/somedir/libstack.so (0xb806a000) libc.so.6 => /lib/tls/i686/cmov/libc.so.6 (0xb7f0c000) /lib/ld-linux.so.2 (0xb8082000)
第三种方法就是把libstack.so
拷到/usr/lib
或/lib
目录,这样可以确保动态链接器能找到这个共享库。
其实还有第四种方法,在编译可执行文件main
的时候就把libstack.so
的路径写死在可执行文件中:
$ gcc main.c -g -L. -lstack -Istack -o main -Wl,-rpath,/home/akaedu/somedir
-Wl,-rpath,/home/akaedu/somedir
表示-rpath /home/akaedu/somedir
是由gcc
传递给链接器的选项。可以看到readelf
的结果多了一条rpath
记录:
$ readelf -a main ... Dynamic section at offset 0xf10 contains 23 entries: Tag Type Name/Value 0x00000001 (NEEDED) Shared library: [libstack.so] 0x00000001 (NEEDED) Shared library: [libc.so.6] 0x0000000f (RPATH) Library rpath: [/home/akaedu/somedir] ...
还可以看出,可执行文件运行时需要哪些共享库也都记录在.dynamic
段中。当然rpath
这种办法也是不推荐的,把共享库的路径定死了,失去了灵活性。
4.2. 动态链接的过程 请点评
本节研究一下在main.c
中调用共享库的函数push
是如何实现的。首先反汇编看一下main
的指令:
$ objdump -dS main ... Disassembly of section .plt: 080483a8 <__gmon_start__@plt-0x10>: 80483a8: ff 35 f8 9f 04 08 pushl 0x8049ff8 80483ae: ff 25 fc 9f 04 08 jmp *0x8049ffc 80483b4: 00 00 add %al,(%eax) ... 080483d8 <push@plt>: 80483d8: ff 25 08 a0 04 08 jmp *0x804a008 80483de: 68 10 00 00 00 push $0x10 80483e3: e9 c0 ff ff ff jmp 80483a8 <_init+0x30> Disassembly of section .text: ... 080484a4 <main>: /* main.c */ #include <stdio.h> #include "stack.h" int main(void) { 80484a4: 8d 4c 24 04 lea 0x4(%esp),%ecx 80484a8: 83 e4 f0 and $0xfffffff0,%esp 80484ab: ff 71 fc pushl -0x4(%ecx) 80484ae: 55 push %ebp 80484af: 89 e5 mov %esp,%ebp 80484b1: 51 push %ecx 80484b2: 83 ec 04 sub $0x4,%esp push('a'); 80484b5: c7 04 24 61 00 00 00 movl $0x61,(%esp) 80484bc: e8 17 ff ff ff call 80483d8 <push@plt> ...
和第 3 节 “静态库”链接静态库不同,push
函数没有链接到可执行文件中。而且call 80483d8 <push@plt>
这条指令调用的也不是push
函数的地址。共享库是位置无关代码,在运行时可以加载到任意地址,其加载地址只有在动态链接时才能确定,所以在main
函数中不可能直接通过绝对地址调用push
函数,也是通过间接寻址来找push
函数的。对照着上面的指令,我们用gdb
跟踪一下:
$ gdb main ... (gdb) start Breakpoint 1 at 0x80484b5: file main.c, line 7. Starting program: /home/akaedu/somedir/main main () at main.c:7 7 push('a'); (gdb) si 0x080484bc 7 push('a'); (gdb) si 0x080483d8 in push@plt () Current language: auto; currently asm
跳转到.plt
段中,现在将要执行一条jmp *0x804a008
指令,我们看看0x804a008这个地址里存的是什么:
(gdb) x 0x804a008 0x804a008 <_GLOBAL_OFFSET_TABLE_+20>: 0x080483de
原来就是下一条指令push $0x10
的地址。继续跟踪下去:
(gdb) si 0x080483de in push@plt () (gdb) si 0x080483e3 in push@plt () (gdb) si 0x080483a8 in ?? () (gdb) si 0x080483ae in ?? () (gdb) si 0xb806a080 in ?? () from /lib/ld-linux.so.2
最终进入了动态链接器/lib/ld-linux.so.2
,在其中完成动态链接的过程并调用push
函数,我们不深入这些细节了,直接用finish
命令返回到main
函数:
(gdb) finish Run till exit from #0 0xb806a080 in ?? () from /lib/ld-linux.so.2 main () at main.c:8 8 return 0; Current language: auto; currently c
这时再看看0x804a008这个地址里存的是什么:
(gdb) x 0x804a008 0x804a008 <_GLOBAL_OFFSET_TABLE_+20>: 0xb803f47c (gdb) x 0xb803f47c 0xb803f47c <push>: 0x53e58955
动态链接器已经把push
函数的地址存在这里了,所以下次再调用push
函数就可以直接从jmp *0x804a008
指令跳到它的地址,而不必再进入/lib/ld-linux.so.2
做动态链接了。
4.3. 共享库的命名惯例 请点评
你可能已经注意到了,系统的共享库通常带有符号链接,例如:
$ ls -l /lib ... -rwxr-xr-x 1 root root 1315024 2009-01-09 22:10 libc-2.8.90.so lrwxrwxrwx 1 root root 14 2008-07-04 05:58 libcap.so.1 -> libcap.so.1.10 -rw-r--r-- 1 root root 10316 2007-08-01 03:20 libcap.so.1.10 lrwxrwxrwx 1 root root 14 2008-11-01 08:55 libcap.so.2 -> libcap.so.2.10 -rw-r--r-- 1 root root 13792 2008-06-12 21:39 libcap.so.2.10 ... lrwxrwxrwx 1 root root 14 2009-01-13 09:28 libc.so.6 -> libc-2.8.90.so ... $ ls -l /usr/lib/libc.so -rw-r--r-- 1 root root 238 2009-01-09 21:59 /usr/lib/libc.so
按照共享库的命名惯例,每个共享库有三个文件名:real name、soname和linker name。真正的库文件(而不是符号链接)的名字是real name,包含完整的共享库版本号。例如上面的libcap.so.1.10
、libc-2.8.90.so
等。
soname是一个符号链接的名字,只包含共享库的主版本号,主版本号一致即可保证库函数的接口一致,因此应用程序的.dynamic
段只记录共享库的soname,只要soname一致,这个共享库就可以用。例如上面的libcap.so.1
和libcap.so.2
是两个主版本号不同的libcap
,有些应用程序依赖于libcap.so.1
,有些应用程序依赖于libcap.so.2
,但对于依赖libcap.so.1
的应用程序来说,真正的库文件不管是libcap.so.1.10
还是libcap.so.1.11
都可以用,所以使用共享库可以很方便地升级库文件而不需要重新编译应用程序,这是静态库所没有的优点。注意libc
的版本编号有一点特殊,libc-2.8.90.so
的主版本号是6而不是2或2.8。
linker name仅在编译链接时使用,gcc
的-L
选项应该指定linker name所在的目录。有的linker name是库文件的一个符号链接,有的linker name是一段链接脚本。例如上面的libc.so
就是一个linker name,它是一段链接脚本:
$ cat /usr/lib/libc.so /* GNU ld script Use the shared library, but some functions are only in the static library, so try that secondarily. */ OUTPUT_FORMAT(elf32-i386) GROUP ( /lib/libc.so.6 /usr/lib/libc_nonshared.a AS_NEEDED ( /lib/ld-linux.so.2 ) )
下面重新编译我们的libstack
,指定它的soname:
$ gcc -shared -Wl,-soname,libstack.so.1 -o libstack.so.1.0 stack.o push.o pop.o is_empty.o
这样编译生成的库文件是libstack.so.1.0
,是real name,但这个库文件中记录了它的soname是libstack.so.1
:
$ readelf -a libstack.so.1.0 ... Dynamic section at offset 0xf10 contains 22 entries: Tag Type Name/Value 0x00000001 (NEEDED) Shared library: [libc.so.6] 0x0000000e (SONAME) Library soname: [libstack.so.1] ...
如果把libstack.so.1.0
所在的目录加入/etc/ld.so.conf
中,然后运行ldconfig
命令,ldconfig
会自动创建一个soname的符号链接:
$ sudo ldconfig $ ls -l libstack* lrwxrwxrwx 1 root root 15 2009-01-21 17:52 libstack.so.1 -> libstack.so.1.0 -rwxr-xr-x 1 akaedu akaedu 10142 2009-01-21 17:49 libstack.so.1.0
但这样编译链接main.c
却会报错:
$ gcc main.c -L. -lstack -Istack -o main /usr/bin/ld: cannot find -lstack collect2: ld returned 1 exit status
注意,要做这个实验,你得把先前编译的libstack
共享库、静态库都删掉,如果先前拷到/lib
或者/usr/lib
下了也删掉,只留下libstack.so.1.0
和libstack.so.1
,这样你会发现编译器不认这两个名字,因为编译器只认linker name。可以先创建一个linker name的符号链接,然后再编译就没问题了:
$ ln -s libstack.so.1.0 libstack.so $ gcc main.c -L. -lstack -Istack -o main
5. 虚拟内存管理 请点评
我们知道操作系统利用体系结构提供的VA到PA的转换机制实现虚拟内存管理。有了共享库的基础知识之后,现在我们可以进一步理解虚拟内存管理了。首先分析一个例子:
$ ps PID TTY TIME CMD 29977 pts/0 00:00:00 bash 30032 pts/0 00:00:00 ps $ cat /proc/29977/maps 08048000-080f4000 r-xp 00000000 08:15 688142 /bin/bash 080f4000-080f9000 rw-p 000ac000 08:15 688142 /bin/bash 080f9000-080fe000 rw-p 080f9000 00:00 0 09283000-09497000 rw-p 09283000 00:00 0 [heap] b7ca8000-b7cb2000 r-xp 00000000 08:15 581665 /lib/tls/i686/cmov/libnss_files-2.8.90.so b7cb2000-b7cb3000 r--p 00009000 08:15 581665 /lib/tls/i686/cmov/libnss_files-2.8.90.so b7cb3000-b7cb4000 rw-p 0000a000 08:15 581665 /lib/tls/i686/cmov/libnss_files-2.8.90.so ... b7e15000-b7f6d000 r-xp 00000000 08:15 581656 /lib/tls/i686/cmov/libc-2.8.90.so b7f6d000-b7f6f000 r--p 00158000 08:15 581656 /lib/tls/i686/cmov/libc-2.8.90.so b7f6f000-b7f70000 rw-p 0015a000 08:15 581656 /lib/tls/i686/cmov/libc-2.8.90.so ... b7fbd000-b7fd7000 r-xp 00000000 08:15 565466 /lib/ld-2.8.90.so b7fd7000-b7fd8000 r-xp b7fd7000 00:00 0 [vdso] b7fd8000-b7fd9000 r--p 0001a000 08:15 565466 /lib/ld-2.8.90.so b7fd9000-b7fda000 rw-p 0001b000 08:15 565466 /lib/ld-2.8.90.so bfac5000-bfada000 rw-p bffeb000 00:00 0 [stack]
用ps
命令查看当前终端下的进程,得知bash
进程的id是29977,然后用cat /proc/29977/maps
命令查看它的虚拟地址空间。/proc
目录中的文件并不是真正的磁盘文件,而是由内核虚拟出来的文件系统,当前系统中运行的每个进程在/proc
下都有一个子目录,目录名就是进程的id,查看目录下的文件可以得到该进程的相关信息。此外,用pmap 29977
命令也可以得到类似的输出结果。
在第 4 节 “MMU”讲过,x86平台的虚拟地址空间是0x0000 0000~0xffff ffff,大致上前3GB(0x0000 0000~0xbfff ffff)是用户空间,后1GB(0xc000 0000~0xffff ffff)是内核空间,在这里得到了印证。0x0804 8000-0x080f 4000是从/bin/bash
加载到内存的,访问权限为r-x
,表示Text Segment,包含.text
段、.rodata
段、.plt
段等。0x080f 4000-0x080f 9000也是从/bin/bash
加载到内存的,访问权限为rw-
,表示Data Segment,包含.data
段、.bss
段等。
0x0928 3000-0x0949 7000不是从磁盘文件加载到内存的,这段空间称为堆(Heap),以后会讲到用malloc
函数动态分配内存是在这里分配的。从0xb7ca 8000开始是共享库的映射空间,每个共享库也分为几个Segment,每个Segment有不同的访问权限。可以看到,从堆空间的结束地址(0x0949 7000)到共享库映射空间的起始地址(0xb7ca 8000)之间有很大的地址空洞,在动态分配内存时堆空间是可以向高地址增长的。堆空间的地址上限(0x09497000)称为Break,堆空间要向高地址增长就要抬高Break,映射新的虚拟内存页面到物理内存,这是通过系统调用brk
实现的,malloc
函数也是调用brk
向内核请求分配内存的。
/lib/ld-2.8.90.so
就是动态链接器/lib/ld-linux.so.2
,后者是前者的符号链接。标有[vdso]
的地址范围是linux-gate.so.1
的映射空间,我们讲过这个共享库是由内核虚拟出来的。0xbfac 5000-0xbfad a000是栈空间,其中高地址的部分保存着进程的环境变量和命令行参数,低地址的部分保存函数栈帧,栈空间是向低地址增长的,但显然没有堆空间那么大的可供增长的余地,因为实际的应用程序动态分配大量内存的并不少见,但是有几十层深的函数调用并且每层调用都有很多局部变量的非常少见。总之,栈空间是可能用尽的,并且比堆空间更容易用尽,在第 3 节 “递归”讲过,无穷递归会用尽栈空间最终导致段错误。
虚拟内存管理起到了什么作用呢?可以从以下几个方面来理解。
第一,虚拟内存管理可以控制物理内存的访问权限。物理内存本身是不限制访问的,任何地址都可以读写,而操作系统要求不同的页面具有不同的访问权限,这是利用CPU模式和MMU的内存保护机制实现的。例如,Text Segment被只读保护起来,防止被错误的指令意外改写,内核地址空间也被保护起来,防止在用户模式下执行错误的指令意外改写内核数据。这样,执行错误指令或恶意代码的破坏能力受到了限制,顶多使当前进程因段错误终止,而不会影响整个系统的稳定性。
第二,虚拟内存管理最主要的作用是让每个进程有独立的地址空间。所谓独立的地址空间是指,不同进程中的同一个VA被MMU映射到不同的PA,并且在某一个进程中访问任何地址都不可能访问到另外一个进程的数据,这样使得任何一个进程由于执行错误指令或恶意代码导致的非法内存访问都不会意外改写其它进程的数据,不会影响其它进程的运行,从而保证整个系统的稳定性。另一方面,每个进程都认为自己独占整个虚拟地址空间,这样链接器和加载器的实现会比较容易,不必考虑各进程的地址范围是否冲突。
继续前面的实验,再打开一个终端窗口,看一下这个新的bash
进程的地址空间,可以发现和先前的bash
进程地址空间的布局差不多:
$ ps PID TTY TIME CMD 30697 pts/1 00:00:00 bash 30749 pts/1 00:00:00 ps $ cat /proc/30697/maps 08048000-080f4000 r-xp 00000000 08:15 688142 /bin/bash 080f4000-080f9000 rw-p 000ac000 08:15 688142 /bin/bash 080f9000-080fe000 rw-p 080f9000 00:00 0 082d7000-084f9000 rw-p 082d7000 00:00 0 [heap] b7cf1000-b7cfb000 r-xp 00000000 08:15 581665 /lib/tls/i686/cmov/libnss_files-2.8.90.so b7cfb000-b7cfc000 r--p 00009000 08:15 581665 /lib/tls/i686/cmov/libnss_files-2.8.90.so b7cfc000-b7cfd000 rw-p 0000a000 08:15 581665 /lib/tls/i686/cmov/libnss_files-2.8.90.so ... b7e5e000-b7fb6000 r-xp 00000000 08:15 581656 /lib/tls/i686/cmov/libc-2.8.90.so b7fb6000-b7fb8000 r--p 00158000 08:15 581656 /lib/tls/i686/cmov/libc-2.8.90.so b7fb8000-b7fb9000 rw-p 0015a000 08:15 581656 /lib/tls/i686/cmov/libc-2.8.90.so ... b8006000-b8020000 r-xp 00000000 08:15 565466 /lib/ld-2.8.90.so b8020000-b8021000 r-xp b8020000 00:00 0 [vdso] b8021000-b8022000 r--p 0001a000 08:15 565466 /lib/ld-2.8.90.so b8022000-b8023000 rw-p 0001b000 08:15 565466 /lib/ld-2.8.90.so bff0e000-bff23000 rw-p bffeb000 00:00 0 [stack]
该进程也占用了0x0000 0000-0xbfff ffff的地址空间,Text Segment也是0x0804 8000-0x080f 4000,Data Segment也是0x080f 4000-0x080f 9000,和先前的进程一模一样,因为这些地址是在编译链接时写进/bin/bash
这个可执行文件的,两个进程都加载它。这两个进程在同一个系统中同时运行着,它们的Data Segment占用相同的VA,但是两个进程各自干各自的事情,显然Data Segment中的数据应该是不同的,相同的VA怎么会有不同的数据呢?因为它们被映射到不同的PA。如下图所示。
从图中还可以看到,两个进程都是bash
进程,Text Segment是一样的,并且Text Segment是只读的,不会被改写,因此操作系统会安排两个进程的Text Segment共享相同的物理页面。由于每个进程都有自己的一套VA到PA的映射表,整个地址空间中的任何VA都在每个进程自己的映射表中查找相应的PA,因此不可能访问到其它进程的地址,也就没有可能意外改写其它进程的数据。
另外,注意到两个进程的共享库加载地址并不相同,共享库的加载地址是在运行时决定的,而不是写在/bin/bash
这个可执行文件中。但即使如此,也不影响两个进程共享相同物理页面中的共享库,当然,只有只读的部分是共享的,可读可写的部分不共享。
使用共享库可以大大节省内存。比如libc
,系统中几乎所有的进程都映射libc
到自己的进程地址空间,而libc
的只读部分在物理内存中只需要存在一份,就可以被所有进程共享,这就是“共享库”这个名称的由来了。
现在我们也可以理解为什么共享库必须是位置无关代码了。比如libc
,不同的进程虽然共享libc
所在的物理页面,但这些物理页面被映射到各进程的虚拟地址空间时却位于不同的地址,所以要求libc
的代码不管加载到什么地址都能正确执行。
第三,VA到PA的映射会给分配和释放内存带来方便,物理地址不连续的几块内存可以映射成虚拟地址连续的一块内存。比如要用malloc
分配一块很大的内存空间,虽然有足够多的空闲物理内存,却没有足够大的连续空闲内存,这时就可以分配多个不连续的物理页面而映射到连续的虚拟地址范围。如下图所示。
第四,一个系统如果同时运行着很多进程,为各进程分配的内存之和可能会大于实际可用的物理内存,虚拟内存管理使得这种情况下各进程仍然能够正常运行。因为各进程分配的只不过是虚拟内存的页面,这些页面的数据可以映射到物理页面,也可以临时保存到磁盘上而不占用物理页面,在磁盘上临时保存虚拟内存页面的可能是一个磁盘分区,也可能是一个磁盘文件,称为交换设备(Swap Device)。当物理内存不够用时,将一些不常用的物理页面中的数据临时保存到交换设备,然后这个物理页面就认为是空闲的了,可以重新分配给进程使用,这个过程称为换出(Page out)。如果进程要用到被换出的页面,就从交换设备再加载回物理内存,这称为换入(Page in)。换出和换入操作统称为换页(Paging),因此:
系统中可分配的内存总量 = 物理内存的大小 + 交换设备的大小
如下图所示。第一张图是换出,将物理页面中的数据保存到磁盘,并解除地址映射,释放物理页面。第二张图是换入,从空闲的物理页面中分配一个,将磁盘暂存的页面加载回内存,并建立地址映射。