(二) 程序如何运行
(涉及到编译原理,操作系统,动态链接等知识 解释运行先不谈)
(1) c程序的编译过程.
一个c程序的文本文件(ASCII码文件)如何变成一个可执行的程序? C程序大概需要这么几步:
- 预处理
- 编译
- 汇编
- 链接
预处理是将c文件中的根据#开始的命令修改生成新的源程序,比如宏替换,#include将相应的文件内容包含进来.
编译就是把预处理后的c源文件转换成汇编语言源文件(仍然是文本文件).对于特定的宿主(操作系统和CPU体系),汇编语言是一样的,c和pascal编译后得到一样的汇编语言.所以这样类型的语言都可以叫做编译语言
汇编是把汇编语言文件翻译为机器指令(二进制文件).这种格式一般叫可重定位目标程序
就是大家熟悉的obj文件,(下面的文件格式再说)
链接链接的结果是一个可执行文件(一般指由操作系统直接加载的文件).程序中用到的外部变量和函数的定义是在链接过程中实际定位的!编译汇编时只需要有声明就可以了!(相关的知识需要了解链接时的符号解析等等,有兴趣的可以看看).
例子说明!
打开vim 编辑个strlen.c的文件
#define STREOF ‘\0’
int strlen_lf(char *str)
{
int len=0;
for(len=0; *str != STREOF; str++)
len++;
return len;
}
保存,用gcc编译看看
gcc -c strlen.c -save-temps
gcc 的基本用法
-save-temps 表示保留中间文件,一般情况
strlen.i 预处理后的c源文件
strlen.s 汇编语言文本文件
strlen.o obj文件
单独的参数为:
-E 只做预处理
-S 只做到编译
-c 只做到汇编
默认是一直做到链接
为了说明情况,我又写了一个strtest.h 文件用来声明
strtest.h---------------------
#define STREOF '\0'
int strlen_lf(char *str);
void strcpy_lf(char *dest ,const char *src);
strlen.c--------------------------
#include "strtest.h"
int strlen_lf(char *str)
{
int len=0;
for(len=0; *str != STREOF; str++)
len++;
return len;
}
编译
gcc -c strlen.c -save-temps
然后看看预处理后的文件strlen.i
# 1 "strlen.c"
# 1 ""
# 1 ""
# 1 "strlen.c"
# 1 "strtest.h" 1
int strlen_lf(char *str);
void strcpy_lf(char *dest ,const char *src);
# 2 "strlen.c" 2
int strlen_lf(char *str)
{
int len=0;
for(len=0; *str != '\0'; str++)
len++;
return len;
}
include “strtest.h” 被展开到文件, STREOF 被替换为 ‘\0’
有兴趣的可以看看strlen.s 一个标准的GAS文件(GNU 汇编程序).
现在写个执行程序调用一下strlen-lf 顺便看看c声明和定义在编译时期的表现!
再加入一个externvalue.c 文件 就一行
int externvalue=10;
下面是main1.c
#include "strtest.h"
#define HELLO "Hello,world"
extern int externvalue;
int main(int argc, char *argv[])
{
int len = strlen_lf(HELLO);
//printf("%s length is %d\n",HELLO,len);
return externvalue;
}
gcc –c main1.c –save-temps
没有执行链接!
看看main1.c
.file "main1.c"
.section .rodata
.LC0:
.string "Hello,world"
.text
.globl main
.type main, @function
main:
pushl %ebp
movl %esp, %ebp
subl $8, %esp
andl $-16, %esp
movl $0, %eax
addl $15, %eax
addl $15, %eax
shrl $4, %eax
sall $4, %eax
subl %eax, %esp
subl $12, %esp
pushl $.LC0
call strlen_lf
addl $16, %esp
movl %eax, -4(%ebp)
movl externvalue, %eax
leave
ret
.size main, .-main
.section .note.GNU-stack,"",@progbits
.ident "GCC: (GNU) 3.4.6 20060404 (Red Hat 3.4.6-9)"
externvalue 和 strlen_lf 只是一个符号而已!
编译strlen.c externvalue.c
gcc –c strlen.c externvalue.c
看看生成的汇编程序,循环在汇编里就是比较和跳转实现的.
(2) 可执行文件的格式和内存映像
在windows平台上可执行程序就是exe文件,dos时代的小于64K的com文件现在已经不见了!windows上可执行文件格式我们一般成为PE(Portable Executable)文件(.net 编译成的exe也是PE文件,但有本质的差别),PE文件源于Unix系统的COFF文件.现代Unix系统,比如Linux使用一种叫ELF(Executable and Linkable Format)的文件,意思是可执行和可链接的文件,名称概况的很好! 可执行文件,可重定位的目标文件(obj)以及动态链接库文件(.so)文件都是ELF文件. Windows上的动态库是DLL文件!
Windows的PE格式也比较简单,大概由4个部分组成文件头,节表,节以及其他如调试信息等部分.文件头至今还保留了Dos文件头(dos时代比较熟悉的4D5A(“MZ”)开头),以及输出一句提示语”This program must be run under Wind32”.
其实PE格式和ELF格式都有许多相同的地方,包括可执行文件加载到内存中的内存映像(就是进程,进程、文件和虚拟储存器是操作系统的几个重要的抽象概念,当然windows是以线程调度的,这在后面的内容讨论)也是大同小异!所以就简单的说说linux中的ELF。详细深入的研究可以参考各种关于ELF文件的文章(google,百度去吧)。
可执行文件,共享库文件(动态链接库)以及obj文件的ELF格式都有区别,但都有ELF头,节表,节的部分组成。有一条需要记住,可执行程序文件是由编译器生成的,所以具体语言如何实现(还有编译器优化的影响)是编译器决定的.下面简单的说说几个重要的节:
.init 可执行程序初始化部分,只读的
.text 已编译的机器代码 (windows上一般成为代码段),只读的.
.rodata 保存一些字符串字面量等等的只读数据段,只读的.
.data c中已经初始化的全局变量(就是数据段)
.bss c中未初始化的全局变量,不实际占有空间,只有占位符,目的是为了节省空间;
.symtab 符号表存放引用外部的全局变量和函数信息.链接时候需要符号解析,编译汇编时有符号(变量和函数)的声明就可以了,这样简单的设计也给链接带来很多麻烦(后来许多语言有了命名空间的概念),c提供了static 修饰外部变量和函数,不需要文件外访问的外部变量和函数就用static修饰可以解决许多问题..
如果是可链接的目标文件(obj .o文件) 还有 .rel.text 和.rel.data 节,一般是在链接时需要修改的(重新定位地址信息).
如果是-g参数编译,还有调试信息
.debug 调试符号表
.line 是c代码和.text机器代码的映射(调式器为何能定位到代码行?哈哈)
符号表根据汇编的符号生成,包括模块内部符号(static修饰的),外部引用(函数和extern 的外部变量)以及模块内部定义被其他模块引用的全局符号.符号表的解析在链接过程中处理.解析的原则也很简单.需要了解的可以找文章或书进一步研究(对c语言编写有很大帮助哦).Unix/linux的设计总是定义一些简单的原则,给程序员很大的发挥空间(这难道是一种哲学?)
可执行程序由操作系统加载到内存中(不是可执行程序的所有内容都加载,这涉及操作系统虚拟储存器的概念,另外调试信息等也不一定加载到内存中).linux可执行程序的内存映像大概是这样的(Win32也有许多相似的地方,可以参看windows内核方面的书籍,个版本windows的进程映像不太相同)
认识上面的这幅图非常重要!
在Linux系统上,代码段总是从0x08048000处开始.上面是用户堆,通过调用malloc分配(其实是Linux的brk系统调用分配,内存动态分配后面会讨论).0x40000000处是调用的共享库映射.
栈底是从0xbfffffff处开始(栈顶内存地址小),0xc0000000以上是内核使用(与win32上 1G内核内存模式类似).(我在十年前dos编程时遇到的代码段,数据段等内存映射知识到现在似乎变化不是很大,还有写汇编用的CPU寄存器,只是16位的AX,现在是32位的EAX,变化也不大,不是说计算机知识发展是一日千里吗?)
(3) 静态链接和动态链接.
关于可执行文件格式,有许多好于的工具可以使用:window平台上查看PE格式的工具很多(比如PEJump等,百度google去吧),Linux上有个很好的工具是objdump,自己看帮助文件objdump还可以反汇编哦.
静态链接比较简单,其实就是将obj文件打包一下.先做个试验看看吧!
就使用上面用到的main1.c strtest.h strlen.c externvalue.c ,我们再家一个文件strcpy.c
#include "strtest.h"
void strcpy_lf(char *dest,const char *src)
{
while((*dest++ = *src++) != STREOF)
;
}
我使用了stcpy_lf 这样的名字是区别于标准c库的,因为在gcc编译是默认链接标准库的,
会有名字冲突,你可以用-static 参数编译main2.c看看链接后的可执行文件大小
main2.c
#include
#include "strtest.h"
#define HELLO "Hello,world"
int main(int argc, char *argv[])
{
char str[16];
strcpy_lf(str,HELLO);
int len = strlen_lf(str);
printf("%s length is %d\n",str,len);
return 0;
}
这回使用了标准输出的printf函数,检测一下上面两个函数是否正确
编译三个c文件
gcc –c strlen.c strcpy.c externvalue.c
然后试试 gcc -o main2 main2.c
大概是下面的链接器错误信息,找不到引用的符号.
/tmp/ccUE4aQH.o(.text+0x29): In function `main':
: undefined reference to `strcpy_lf'
/tmp/ccUE4aQH.o(.text+0x38): In function `main':
: undefined reference to `strlen_lf'
/tmp/ccUE4aQH.o(.text+0x5e): In function `main':
: undefined reference to `externvalue'
collect2: ld returned 1 exit status
gcc -o main2 main2.c strlen.o strcpy.o externvalue.o
这回OK了!
运行结果如下:
./main2
Hello,world length is 11
externvalue =10
把上面的三个.o(obj)文件打包成静态库
ar rcs libstrtest.a strlen.o strcpy.o externvalue.o
gcc -c main2.c
使用静态链接库
gcc -o main2 main2.o ./libstrtest.a
对于静态链接,main2程序中用到的上面三个obj文件都被链接到main2程序中,成为一个整体.比如标准c库,系统中会有许多程序用到,也就是说有许多份标准库程序的拷贝,尤其是加载到内存中,会浪费大量的空间!
动态链接解决了这个问题!
动态链接库对于win32程序员来说太熟悉了.在Win32中操作系统的API(Linux下叫系统调用)都是由动态库承载的,包括一些系统资源文件也是动态库.动态库也是com组件的载体(dll,ocx),即使.net推广了N年后的今天,Win32的大多数核心功能还是dll和com提供的!未来会怎样?在可以预见的未来,动态库这种技术还会一直存在和发展.因为win32下的动态库是OS提供的统一标准,各种编程语言之间可以通过动态库互相访问(当然要统一函数调用规则和数据类型,com组件其实就是定义了一致的调用规则和元数据),因为动态库是动态链接加载的,所以只要输出的接口不变,可以修改动态库中实现,为只做比较大型的软件提供了封装模块的方法,而且动态库在OS中只被加载一次,可以多个执行程序共享,节省了空间.另外因为动态库中的数据和函数被映射的主程序的进程空间,可以很方便的互相访问(进程外com技术实现两个进程共享数据其实也是通过动态库作为桥梁实现的).不说了,总之win32下使用动态库似乎是必须掌握的技术.好在编写和使用都比较简单.
linux下的动态库和win32的机理大同小异,相比之下linux编写动态库更为简单,不用显示的export函数,也不需要DllMain. 在Linux下动态链接也是非常重要的,c标准库的提供.gcc默认链接标准c库的.可以试试看
编写testlibc.c
int main()
{
return 0;
}
就这样简单
gcc testlibc.c -o testlibc
然后用ldd看看testlibc需要的so ,下面是我电脑上的查看结果
ldd testlibc
libc.so.6 => /lib/tls/libc.so.6 (0x002a9000)
/lib/ld-linux.so.2 (0x0028f000)
libc.so.6 就是标准c库的动态库
ld-linux.so.2 是加载其他动态链接库的动态链接器(他本身也是个动态链接库在ELF的.interp节中指明)
可以用下面的命令看看
objdump -s testlibc |more
testlibc: file format elf32-i386
Contents of section .interp:
8048114 2f6c6962 2f6c642d 6c696e75 782e736f /lib/ld-linux.so
8048124 2e3200 .2.
好吧 先做个实验吧!
我们把上面的用到的几个c文件编译为动态链接库!
gcc -shared -fPIC -save-temps -o libstrtest.so externvalue.c strlen.c strcpy.c
gcc使用shared参数给链接器
-fPIC 要求生成与位置无关的代码(有兴趣的可以进一步研究,主要是因为动态库加载需要指定相应的内存映像地址信息,与位置无关则可以在任何地址加载)
然后编译main2.c
gcc –o main3 main2.c ./libstrtest.so
main3 和main2 两个文件运行结果相同
不同的是main3 运行需要libstrtest.so
ldd main3 看看!
这样的调用动态库属于隐式调用(win32上也一样)!还有一种是显式调用,就是在程序中通过库函数调用指定的动态库.在win32上使用下面三个API函数
LoadLibrary 装载动态库。
GetProcAddress 获取要引入的函数,将符号名或标识号转换为DLL内部地址。
FreeLibrary 释放动态链接库。
相对应的linux的函数为
dlopen
dlsym
dlclose
另外提供了dlerror 得到上面三个函数出错信息.
下面使用上面的libstrtest.so 修改main2.c 为main4.c
#include
#include //操作动态库的头文件
#define HELLO "Hello,world"
int main(int argc, char *argv[])
{
int *pExternvalue;
int (*strlen_lf)(char *);
void (*strcpy_lf)(char *, const char *);
void *handle;
char *error;
handle = dlopen("./libstrtest.so",RTLD_LAZY);
if (!handle)
{
fprintf(stderr,"%s\n",dlerror());
return 1;
}
strlen_lf = dlsym(handle,"strlen_lf");
if((error = dlerror()) != NULL)
{
fprintf(stderr,"%s\n",error);
return 1;
}
strcpy_lf = dlsym(handle,"strcpy_lf");
if((error = dlerror()) != NULL)
{
fprintf(stderr,"%s\n",error);
return 1;
}
pExternvalue = dlsym(handle,"externvalue");
if((error = dlerror()) != NULL)
{
fprintf(stderr,"%s\n",error);
return 1;
}
char str[16];
strcpy_lf(str,HELLO);
int len = strlen_lf(str);
printf("%s length is %d\n",str,len);
printf("externvalue =%d\n",*pExternvalue);
if (dlclose(handle)<0)
{
fprintf(stderr,"%s\n",dlerror());
return 1;
}
return 0;
}
编译
gcc -o main4 main4.c –ldl
-ldl 表示链接dl的库
看上去非常简单!
显式调用 (动态调用)动态库非常有用! 在比较大型的软件设计中,可以根据用户配置用动态库实现的各种模块,缺少某个或某几个动态库依然能运行系统,只是缺少相应的功能模块而已!
另外如果在自己实现的脚本语言中调用动态库就有一定的难度(需要借助于汇编实现,参数返回值等的运行时传递,需要比较底层的技术),相对来说指定了调用规则和数据格式的com就比较容易(例如,微软的JS就可以有条件的调用com组件).
在linux上比较大的应用软件也是借助动态链接库实现功能模块的灵魂配置和加载.
下一篇:重新学习 c 语言(4)- 库和宿主实现(三) 程序级异常
前一篇:重新学习 c 语言(4)- 库和宿主实现(一)概论