首页 > 代码库 > linux下编译原理分析

linux下编译原理分析

linux下编译hello.c 程序,使用gcc hello.c,然后./a.out就可以运行;在这个简单的命令后面隐藏了许多复杂的过程,这个过程包括了下面的步骤:
======================================================================================

预处理:

  1. 宏定义展开,所有的#define 在这个阶段都会被展开
  2. 预编译命令的处理,包括#if #ifdef 一类的命令
  3. 展开#include 的文件,像上面hello world 中的stdio.h , 把stdio.h中的所有代码合并到hello.c中
  4. 去掉注释

gcc的预编译 采用的是预编译器cpp, 我们可以通过-E参数来看预编译的结果,如:

gcc -E hello.c -o hello.i生成的 hello.i 就是经过了预编译的结果(中间文件)。在预编译的过程中不会太多的检查与预编译无关的语法(#ifdef 之类的还是需要检查, #include文件路径需要检查), 但是对于一些诸如 ; 漏掉的语法错误,在这个阶段都是看不出来的。写过makefile的人都知道, 我们需要加上-Ipath 一系列的参数来标示gcc对头文件的查找路径

小提示:

1.在一些程序中由于宏的原因导致编译错误,可以通过-E把宏展开再检查错误 , 这个在编写 PHP扩展, python扩展这些大量需要使用宏的地方对于查错误很有帮助。

2.如果在头文件中,#include 的时候带上路径在这个阶段有时候是可以省不少事情, 比如 #include <public/connectpool/connectpool.h>, 这样在gcc的-I参数只需要指定一个路径,不会由于不小心导致,文件名正好相同出现冲突的麻烦事情. 带路径的方式要多写一些代码,也是麻烦的事情, 路径由外部指定相对也会灵活一些.
======================================================================================

编译:

这个过程才是进行语法分析和词法分析的地方, 他们将我们的C/C++代码翻译成为 汇编代码, 这也是一个编译器最复杂的地方

使用命令

gcc -S hello.i -o hello.s可以看到gcc编译出来的汇编代码, 现代gcc编译器一般是把预编译和编译合在一起,使用cc1 的程序来完成这个过程,编译大文件的时候可以用top命令看一个cc1的进程一直在占用时间,这个时候就是程序在执行编译过程. 后面提到的编译过程都是指 cc1的处理包括了预编译与编译.

=======================================================================================
汇编:

现在C/C++代码已经成为汇编代码了,直接使用汇编代码的编译器把汇编变成机器码(注意还不是可执行的) .

gcc -c hello.c -o hello.o这里的hello.o就是最后的机器码, 如果作为一个静态库到这里可以所已经完成了,不需要后面的过程.

对于静态库, 比如ullib, COM提供的是libullib.a, 这里的.a文件其实是多个.o 通过ar命令打包起来的, 仅仅是为了方便使用,抛开.a 直接使用.o 也是一样的

小提示:

1. gcc 采用as 进行汇编的处理过程,as 由于接收的是gcc生成的标准汇编, 在语法检查上存在不少缺陷,如果是我们自己写的汇编代码给as去处理,经常会出现很多莫名奇妙的错误.
======================================================================================
链接:

链接的过程,本质上来说是一个把所有的机器码文件组合成一个可执行的文件上面汇编的结果得到一个.o文件, 但是这个.o要生成二执行文件只靠它自己是不行的, 它还需要一堆辅助的机器码,帮它处理与系统底层打交道的事情.

gcc -o hello hello.o

这样就把一个.o文件链接成为了一个二进制可执行文件.

这个地方也是本文讨论的重点, 在后面会有更详细的说明

小提示:

有些程序在编译的时候会出现 "linker input file unused because linking not done" 的提示(虽然gcc不认为是错误,这个提示还是会出现的), 这里就是把 编译和链接 使用的参数搞混了,比如

g++ -c test.cpp -I../../ullib/include -L../../ullib/lib/ -lullib这样的写法就会导致上面的提示, 因为在编译的过程中是不需要链接的, 它们两个过程其实是独立的

静态链接
======================================================================================

链接的过程这里先介绍一下,链接器所做的工作

其实链接做的工作分两块: 符号解析和重定位

符号解析

符号包括了我们的程序中的被定义和引用的函数和变量信息

在命令行上使用 nm ./test

test 是用户的二进制程序,包括

可以把在二进制目标文件中符号表输出

00000000005009b8 A __bss_start 00000000004004cc t call_gmon_start 00000000005009b8 b completed.1 0000000000500788 d __CTOR_END__ 0000000000500780 d __CTOR_LIST__ 00000000005009a0 D __data_start 00000000005009a0 W data_start 0000000000400630 t __do_global_ctors_aux 00000000004004f0 t __do_global_dtors_aux 00000000005009a8 D __dso_handle 0000000000500798 d __DTOR_END__ 0000000000500790 d __DTOR_LIST__ 00000000005007a8 D _DYNAMIC 00000000005009b8 A _edata 00000000005009c0 A _end 0000000000400668 T _fini 0000000000500780 A __fini_array_end 0000000000500780 A __fini_array_start 0000000000400530 t frame_dummy 0000000000400778 r __FRAME_END__ 0000000000500970 D _GLOBAL_OFFSET_TABLE_ w __gmon_start__ U __gxx_personality_v0@@CXXABI_1.3 0000000000400448 T _init 0000000000500780 A __init_array_end ...当然上面由nm输出的符号表可以通过编译命令去除,让人不能直接看到。

链接器解析符号引用的方式是将每一个引用的符号与其它的目标文件(.o)的符号表中一个符号的定义联系起来, 对于那些和引用定义在相同模块的本地符号(注:static修饰的),编译器在编译期就可以发现问题,但是对于那些全局的符号引用就比较麻烦了.
======================================================================================

下面来看一个最简单程序:

#include <stdio.h> int foo(); int main() { foo(); return 0; }
我们把文件命名为test.cpp, 采用下面的方式进行编译

g++ -c test.cpp g++ -o test test.o

第一步正常结束,并且生成了test.o文件,到第二步的时候报了如下的错误

test.o(.text+0x5): In function `main‘: : undefined reference to `foo()‘ collect2: ld returned 1 exit status

由于foo 是全局符号, 在编译的时候不会报错,等到链接的时候,发现没有找到对应的符号,就会报出上面的错误。但是如果我们把上面的写法改成下面这样

#include <stdio.h> //注意这里的static static int foo(); int main() { foo(); return 0; }

在运行 g++ -c test.cpp, 马上就报出下面的错误:

test.cpp:19: error: ‘int foo()‘ used but never defined

在编译器就发现foo 无法生成目标文件的符号表,可以马上报错,对于一些本地使用的函数使用static一方面可以避免符号污染,另一方面也可以让编译器尽快的发现错误.

在基础库中提供的都是一系列的.a文件,这些.a文件其实是一批的目标文件(.o)的打包结果.这样的目的是可以方便的使用已有代码生成的结果,一般情况下是一个.c/.cpp文件生成一个.o文件,在编译的时候如果带上一堆的.o文件显的很不方便,像:

g++ -o main main.cpp a.o b.o c.o

这样大量的使用.o也很容易出错,在linux下使用 archive来讲这些.o存档和打包.

所以我们就可以把编译参数写成

g++ -o main main.cpp ./libullib.a

我们可以使用 ./libullib.a 直接使用 libullib.a这个库,不过gcc提供了另外的方式来使用:

g++ -o main main.cpp -L./ -lullib

-L指定需要查找的库文件的路径, -l 选择需要使用的库名字,不过库的名字需要用 lib+name的方式命名,才会被gcc认出来.不过上面的这种方式存在一个问题就是不区分动态库和静态库, 这个问题在后面介绍动态库的时候还会提到.

当存在多个.a ,并且在库之间也存在依赖关系,这个时候情况就比较复杂.

如果要使用lib2-64/dict, dict又依赖ullib, 这个时候需要写成类似下面的形式

g++ -o main main.cpp -L../lib2-64/dict/lib -L../lib2-64/ullib/lib -ldict -lullib

-lullib需要写在-ldict的后面, 这是由于在默认情况对于符号表的解析和查找工作是由后往前(内部实现是一个类似堆栈的尾递归).所以当所使用的库本身存在依赖关系的时候,越是基础的库就越是需要放到后面.否则如果上面把 -ldict -lulib的位置换一下,可能就会出现 undefined reference to xxx 的错误.

当然gcc提供了另外的方式的来解决这个问题

g++ -o main main.cpp -L../lib2-64/dict/lib -L../lib2-64/ullib/lib -Xlinker "-(" -ldict -lullib -Xlinker "-)"

可以看到我们需要的库被 -Xlinker "-(" 和 -Xlinker "-)"  包含起来,gcc在这里处理的时候会循环自动查找依赖关系,不过这样的代价就是延长gcc的编译时间,如果使用的库非常的多时候,对编译的耗时影响还是非常大.

-Xlinker有时候也简写成"-Wl, ",它的意思是 它后面的参数是给链接器使用的.-Xlinker 和 -Wl 的区别是一个后面跟的参数是用空格,另一个是用","

我们通过nm 命令查看目标文件,可以看到类似下面的结果

0000000000009740 T _Z11ds_syn_loadPcS_
0000000000009c62 T _Z11ds_syn_seekP16Sdict_search_synPcS1_i
0000000000007928 T _Z11dsur_searchPcS_S_
                &nbs p; U _Z11ul_readfilePcS_Pvi
                &nbs p; U _Z11ul_writelogiPKcz
00000000000000a2 T _Z12creat_sign32Pc

其中用 U 标示的符号_Z11ul_readfilePcS_Pvi (其实是ullib中的 ul_readfile) ,表示在dict的目标文件中没有找到ul_readfile函数.

在链接的时候,链接器就会去其他的目标文件中查找_Z11ul_readfilePcS_Pvi的符号

小提示:

编译的时候采用 -Lxxx -lyyy 的形式使用库,-L和-l这个参数并没有配对的关系,我们的一些Makefile 为了维护方便把他们写成配对的形式,造成了误解.其实完全可以写成 -Lpath1, -Lpath2, -Lpath3, -llib1 这样的形式.

在具体链接的时候,gcc是以.o文件为单位, 编译的时候如果写 g++ -o main main.cpp libx.o 那么无论main.cpp中是否使用到libx.o,libx.o中的所有符号都会被载入到mian函数中.但是如果是针对.a,写成g++ -o main main.cpp -L./ -lx, 这个时候gcc在链接的时候只会链接有被用到.o, 如果出现libx.a中的某个.o文件中没有任何一个符号被main用到,那么这个.o就不会被链接到main中
======================================================================================

重定位

经过上面的符号解析后,所有的符号都可以找到它所对应的实际位置(U表示的链接找到具体的符号位置).

as 汇编生成一个目标模块的时候,它不知道数据和代码在最后具体的位置,同时也不知道任何外部定义的符号的具体位置,所以as在生成目标代码的时候,对于位置未知的符号,它会生成一个重定位表目,告诉链接器在将目标文件合并成可执行文件时候如何修改地址成最终的位置
======================================================================================

g++和gcc

采用gcc 和g++ 在编译的时候产生的符号有所不同.

在C++中由于要支持函数重载,命名空间等特性,g++会把函数+参数(可能还有命名空间),把函数命变成一个特殊并且唯一的符号名.例如:

int foo(int a);

在gcc编译后,在符号表中的名字就是函数名foo, 但是在g++编译后名字可能就变成了_Z3fooi, 我们可以使用 c++filt命令把一个符号还原成它原本的样子,比如

c++filt _Z3fooi

运行的结果可以得到 foo(int)

由于在C++和纯C环境中,符号表存在不兼容问题,C程序不能直接调用C++编译出来的库,C++程序也不能直接调用C编译出来的库.为了解决这个问题C++中引入了 extern "C" 的方式.

extern "C" int foo(int a);

这样在用g++编译的时候, c++的编译器会自动把上面的 int foo(int a)当做C的接口进行符号转化.这样在纯C里面就可以认出这些符号.

不过这里存在一个问题,extern "C" 是C++支持的,gcc并不认识,所有在实际中一般采用下面的方式使用++

#ifdef __cplusplus extern "C" { #endif int foo(int a); #ifdef __cplusplus } #endif

这样这个头文件中的接口即可以给gcc使用也可以给g++使用, 当然在extern "C" { } 中的接口是不支持重载,默认参数等特性

在我们的64位编译环境中如果有gcc的程序使用上面方式g++编译出来的库,需要加上-lstdc++, 这是因为,对于我们64位环境下g++编译出来的库,需要使用到一个 __gxx_personality_v0 的符号,它所在的位置是/usr /lib64/libstdc++.so.6 (C++的标准库iostream都在里面,C++程序都需要的). 但是在32位2.96 g++编译器中是不需要__gxx_personality_v0,所有编译可以不加上 -lstdc++

小提示

  1. 在linux gcc 中, 只有在源代码使用 .c做后缀,并且使用gcc编译才会被编译成纯C的结果,其他情况像 g++ 编译.c文件,或者gcc 编译.cc, .cpp文件都会被当作C++程序编译成C++的目标文件, gcc和g++唯一的不同在于gcc不会主动链接-lstdc++
  2. 在 extern "C" { }中如果存在默认参数的接口,在g++编译的时候不会出现问题,但是gcc使用的时候会报错.因为对于函数重载,接口的符号表还是和不用默认参数的时候是一样的.
    ======================================================================================

符号表冲突

编译程序的时候时常会遇到类似于

multiple definition of `foo()‘

的错误.

这些错误的产生都是由于所使用的.o文件中存在了相同的符号造成的.

比如:

libx.cpp

int foo() { return 30; }

liby.cpp

int foo() { return 20; }

将libx.cpp, liby.cpp编译成 libx.o和liby.o两个文件

g++ -o main main.cpp libx.o liby.o这个时候就会报出 multiple definition of `foo()‘ 的错误(一些参数可以把这个警报关掉)

但是如果把libx.o和liby.o分别打包成libx.a和liby.a用下面的方式编译

g++ -o main main.cpp -L./ -lx -ly这个时候编译不会报错,它会选择第一个出现的库,上面的例子中会选择libx中的foo

可以通过 g++ -o main main.cpp -L./ -lx -ly -Wl,--trace-symbol=_Z3foov的命令查看符号具体是链接到哪个库中,

g++ -o main main.cpp -L./ -lx -ly -Wl, --cref 可以把所有的符号链接都输出(无论是否最后被使用)

小提示:

对于一些定义在头文件中的全局常量,gcc和g++有不同的行为,g++中const也同时是static的,但gcc不是

例如: foo.h 中存在一个

const int INTVALUE = http://www.mamicode.com/2000;

的全局常量

有两个库 a和b, 他们在生成的时候有使用到了  INTVALUE,如果有一个程序main同时使用到了 a库和b库,在链接的时候gcc编译的结果就会报错,但如果a和b都是g++编译的话结果却一切正常.

这个原因主要是在g++中会把INTVALUE 这种const常量当做static的,这样就是一个局部变量,不会导致冲突,但是如果是gcc编译的话,这个地方INTVALUE会被认为是一个对外的全局常量是非static的,这个时候就会造成链接错误
======================================================================================

动态链接

对于静态库的使用,有下面两个问题

  1. 当我们需要对某一个库进行更新的时候,我们必须把一个可执行文件再完整的进行一些重新编译
  2. 在程序运行的时候代码是会被载入机器的内存中,如果采用静态库就会出现一个库需要被copy到多个内存程序中,这个一方面占用了一定的内存,另一方面对于CPU的cache不够友好
  3. 链接的控制,从前面的介绍中可以看到静态库的连接行为我们不好控制,做不到在运行期替换使用的库
  4. 编译后的程序就是二进制代码,有些代码它们涉及到不同的机器和环境,假设在A 机器上编译了一个程序X, 把它直接放到B机器上去运行,由于A和B环境存在差异,直接运行X程序可能存在问题,这个时候如果把和机器相关的这部分做成动态库C,并且保证接口一致,编译X程序的时候只调用C的对外接口.对于一般的用户态的X程序而言,就可以简单的从A环境放到B环境中.但如果是静态编译,就可能做不到这点,需要在B机器上重新编译一次.

动态链接库在linux被称为共享库(shared library,下文提到的共享库和动态链接库都是指代shared library),它主要是为了解决上面列出静态库的缺点而提出的.。
======================================================================================

共享库的使用

共享库的使用主要有两种方式,一种方式和.a的静态库类似由编译器来控制,其实质和二进制程序一样都是由系统中的载入器(ld-linux.so)载入,另一种是写在代码中,由我们自己的代码来控制.

还是以前面的例子为例:

g++ -shared -fPIC -o libx.so libx.cpp编译的时候和静态库类似,只是加上了 -shared 和 -fPIC, 将输出命名改为.so

然后和可执行文件链接.a一样,都是

g++ -o main main.cpp -L./ -lx这样main就是调用 libx.so, 在运行的时候可能会出现找不到libx.so的错误, 这个原因是由于动态的库查找路径的问题, 动态库默认的查找路径是由/etc /ld.so.conf文件来指定,在运行可执行文件的时候,按照顺会去这些目录下查找需要的共享库。我们可以通过 环境变量 LD_LIBRARY_PATH来指定共享库的查找路径(注:LD_LIBRARY_PATH的优先级比ld.so.conf要高).

命令上运行 ldd ./main 我们可以看到这个二进制程序在运行的时候需要使用的动态库,例如:

libx.so => /home/bnh/tmp/test/libx.so (0x003cb000) libstdc++.so.6 => /usr/lib/libstdc++.so.6 (0x00702000) libm.so.6 => /lib/tls/libm.so.6 (0x00bde000) libgcc_s.so.1 => /lib/libgcc_s.so.1 (0x00c3e000) libc.so.6 => /lib/tls/libc.so.6 (0x00aab000)

这里列出了mian所需要的动态库, 如果有看类似 libx.so=>no found的错误,就意味着路径不对,需要设置LD_LIBRARY_PATH来指定路径
======================================================================================

手动载入共享库

除了采用类型于静态库的方式来使用动态库,我们还可以通过由代码来控制动态库的使用。

这种方式允许应用程序在运行时加载和链接共享库,主要有下面的四个接口

载入动态链接库

void *dlopen(const char *filename, int flag);
获取动态库中的符号
void *dlsym(void *handle, char *symbol);
关闭动态链接库
void dlclose(void *handle);
输出错误信息
const char *dlerror(void);

看下面的例子:

typedef int foo_t(); foo_t * foo = (foo_t*) dlsym(handle, "foo");

通过上面的方式我们可以载入符号"foo"所对应的地址,然后通过强制类型转换给一个函数指针,当然这里函数指针的类型需要和符号的原型类型保持一致,这些一般是由共享库所对应的头文件提供.

这里要注意一个问题,在dlsym中载入的符号表示是和我们使用nm 库文件所看到符号表要保持一致,这里就有一个前面提到的 gcc和g++符号表的不同,一个 int foo(), 如果是g++编译,并且没有extern "C"导出接口,那么用dlsym载入的时候需要用 dlsym(handle, "_Z3foov") 方式才可以载入函数 int foo(), 所以建议所以的共享库对外接口都采用 extern "C"的方式导出 纯C接口对外使用,这样在使用上也会比较方便

dlopen 的flag 标志可以选择 RTLD_GLOBAL , RTLD_NOW, RTLD_LAZY. RTLD_NOW, RTLD_LAZY只是表示载入的符号是一开始就被载入还等到使用的时候被载入,对于多数应用而言没有什么特别的影响.这两个标志都可以通过| 和RTLD_GLOBAL一起连用

这里主要是说明RTLD_GLOBAL的功能,考虑这样的一个情况:

我们有一个main.cpp ,调用了两个动态 libA, 和 libB, 假设A中有一个对外接口叫做 testA, 在main.cpp可以通过dlsym获取到testA的指针,进行使用.但是对于libB 中的接口,它是看到不libA的接口,使用testA 是不能调用到libA中的testA的,但是如果在dlopen 打开libA.so的时候,设置了RTLD_GLOBAL这个选项,就可以把libA.so中的接口升级为全局可见, 这样在libB中就可以直接调用libA中的testA,如果在多个共享库都有相同的符号,并且有RTLD_GLOBAL选项,那么会优先选择第一个。

另外这里注意到一个问题, RTLD_GLOBAL使的动态库之间的对外接口是可见的,但是动态库是不能调用主程序中的全局符号,为了解决这个问题, gcc引入了一个参数-rdynamic,在编译载入共享库的可执行程序的时候最后在链接的时候加上-rdynamic,会把可执行文件中所有的符号变成全局可见,对于这个可执行程序而言,它载入的动态库在运行中可以直接调用主程序中的全局符号,而且如果共享库(自己或者另外的共享库 RTLD_GLOBAL) 加中有同名的符号,会选择可执行文件中使用的符号,这在一些情况下可能会带来一些莫名其妙的运行错误。

小提示:

  1. /usr/sbin/lsof -p pid 可以查看到由pid在运行期所载入的所有共享库
  2. 共享库无论是通过dlopen方式载入还是载入器载入,实质都是通过 mmap的方式把共享库映射到内存空间中去。mmap的参数MAP_DENYWRITE可以在修改已经被载入某个进程文件的时候阻止对于内存数据的修改,由于现在内核中已经禁用这个参数,直接导致的结果就是如果对mmap的文件进行修改,这个时候的修改会被直接反映到已经被mmap映射的空间上。由于内核的不支持,使得共享库不能在运行期进行热切换,共享库在更新的时候需要由载入的程序通过一些外部的方式来判断,主动使用dlclose,并且dlopen 重新载入共享库,如果是载入器载入那么需要重启程序。另外这里的热切换指的是直接copy覆盖原有的共享库,如果是采用mv或者软连接的方式那么还是安全的,共享库被mv后不会影响原来的已经载入它的程序。
  3. g++ 加上 -rdynamic 参数实质上相当于ld链接的时候加上-E或者--export-dynamic参数,效果与g++ -Wl,-E或者g++ -Wl,--export-dynamic的效果是一样的。
    ======================================================================================

静态库和动态库的混合编译

静态库与动态库的混合使用,经常会出现一些奇怪的错误,使用的时候需要有所关注

对于一般情况下,只要静态库与共享库之间没有依赖关系,没有使用全局变量(包括static变量),不会出现太多的问题,下面以出现的问题作例子来说明使用的注意事项。