我们在编译自己开发的程序或者一些开源软件的时候,常常遇到类似如下的编译器错误信息:
未定义 文件中的
符号 在文件中
i /var/tmp//ccU4sj6I.o
func /var/tmp//ccU4sj6I.o

ld: 致命的: 符号参照错误. 没有输出被写入a.out
collect2: ld returned 1 exit status

或"undefined reference to 'i' or undefined reference to 'func'"
或"error LNK2001: unresolved external symbol _func" (Visual C++编译器输出)

通过加入-v编译选项(GCC的编译选项),我们可以清晰的看到错误输出并非出自编译阶段(生成.o或.obj目标文件),而是产生于连接阶段,即将.o文件转换成最的可执行文件阶段。

GCC错误信息中用的是undefined reference,而VC用的则是unsesolved external symbol。感觉用"unresolved external symbol"更容易理解一些。连接阶段的symbol到底所指什么呢?我们看下面这段代码:
/* testsymbollink.c */
extern int myvar;
extern void myfunc(int a, int b);
 int main() {
  myvar = 7;
  myfunc(100, 200);
  return 0;
  }

我们通过gcc -S输出其汇编码:
/* testsymblolink.s */
.file "testsymbollink.c"

.section ".text"
.align 4
.global main
.type main,#function
.proc 04

main:
!#PROLOGUE# 0
save %sp, -112, %sp
!#PROLOGUE# 1
sethi %hi(myvar), %o0
or %o0, %lo(myvar), %o1
mov 7, %o0
st %o0, [%o1]
mov 100, %o0
mov 200, %o1
call myfunc, 0
nop
mov 0, %o0
mov %o0, %i0
nop
ret
restore

.LLfe1:
.size main,.LLfe1-main
.ident "GCC: (GNU) 3.2"

对于上述汇编码,我们一般理解是包含三个部分:
1) 描述型信息:如:.file、.section、.align、.type等,这些信息用于直到连接器正确的连接代码而使用的。
2) 汇编指令:如mov、st等。
3) 一些待resolve的符号:如main、myvar和myfunc。

连接器负责将.o目标代码进行处理并生成可执行文件。在连接器处理时,描述型信息告知连接器.o中的指令和数据的应该存放的位置属性信息;汇编指令则直接转成机器码;只有那些待resolve的符号需要连接器做慎重处理:main是默认的入口函数的符号,连接器默认会认识,其余的符号连接器就要在其输入的.o文件中或者指定连接的库(.a)中寻找符号的定义了,就如上面的main。如果是数据,则需要获取其位置和大小,如果是函数,则要获取其具体的实现了。

我们再举一个例子来对比一下:
int myvar = 0;
void myfunc(int a, int b) {
;
}

int main() {
myvar = 7;
myfunc(100, 200);
}

转换成汇编码为:
.file "testsymbollink1.c"
.global myvar
.section ".data"
.align 4
.type myvar,#object
.size myvar,4

myvar:
.long 0
.section ".text"
.align 4
.global myfunc
.type myfunc,#function
.proc 020

myfunc:
!#PROLOGUE# 0
save %sp, -112, %sp
!#PROLOGUE# 1
st %i0, [%fp+68]
st %i1, [%fp+72]
nop
ret
restore

.LLfe1:
.size myfunc,.LLfe1-myfunc
.align 4
.global main
.type main,#function
.proc 04

main:
!#PROLOGUE# 0
save %sp, -112, %sp
!#PROLOGUE# 1
sethi %hi(myvar), %o0
or %o0, %lo(myvar), %o1
mov 7, %o0
st %o0, [%o1]
mov 100, %o0
mov 200, %o1
call myfunc, 0
nop
mov %o0, %i0
nop
ret
restore

.LLfe2:
.size main,.LLfe2-main
.ident "GCC: (GNU) 3.2"
从上述汇编码我们可以看到,myvar和myfunc都给出定义,这样连接器工作的时候就不会因找不到这两个符号而报错了。符号的定义既可以在同一个.o中,也可以在不同的.o中,这样便于软件分层次、分模块开发。

对比上面两个example中myvar和myfunc的书写方式:
extern int myvar;
extern void myfunc(int a, int b);

int myvar = 0;
void myfunc(int a, int b) { … }
可以看出,变量和函数的声明和定义的方式直接会影响到其连接的属性。

那么在C语言中,声明和定义又有哪些事呢?我们下面道来^_^
在"C语言参考手册"的第四章作者给了'声明'一个诠释:"声明一个名称就是把一个标识符与某个C语言对象相关联",这句很是给人以启发。名称、标识符是什么呢?就是一个符号;C语言对象呢?对于数据对象来说,就是一块存储块;对于函数对象来说,就是函数的定义,当然这个定义也是要存储在TEXT SECTION的。真正将标识符和C语言对象相关联的工作是在连接阶段完成的。我们的C源代码需要给连接器足够的信息,以保证其正确无误的将每个标识符(符号)与对应的存储相关联。C语言中的声明恰恰给予连接器以有效帮助。

C语言提供了extern和static存储说明符来对应两种连接属性:外部连接(External linkage)和内部连接(Internal linkage)。在源程序顶层的声明中,内部与外部的连接属性区别在于该符号是否为多个翻译单元(translate unit)的所共享。顶层static修饰的符号只能在其所在翻译单元中寻找C语言对象;而顶层extern修饰的符号既可以在其所在的翻译单元寻找C语言对象,也可以在其他翻译单元中寻找。

//foo.c
extern int i;
static int j;
extern void e_func(int a);
static void s_func(void);

int main() {
e_func(1);
s_func();
i = 17;
j = 16;
}
对于变量i而言,连接程序必须在其他翻译单元中查找其相关联的对象;如果找不到,则报错;
对于变量j而言,连接程序在其所在翻译单元中寻找相关联的对象,与i不同的是,如果找不到,这个声明就会被转化为定义;这个对象的初值被置为0;
对于函数e_func而言,连接程序必须在其他翻译单元中查找其相关联的对象;如果找不到,则报错;
对于函数e_func而言,连接程序必须在其所在翻译单元中查找其相关联的对象;如果找不到,则报错。

我们在一些程序中经常看到在顶层声明的变量,既没有extern修饰,也没有static修饰,又不像变量定义那样给出初值,那么这样的变量是如何被对待的呢?我们看例子:
/* testsymbollink2.c */
int myvar;
int g_var = 13;
static int l_var = 19;

int main() {
myvar = 7;
}

翻译成汇编代码后:
.file "testsymbollink2.c"
.global g_var
.section ".data"
.align 4
.type g_var,#object
.size g_var,4

g_var:
.long 13
.align 4
.type l_var,#object
.size l_var,4

l_var:
.long 19
.section ".text"
.align 4
.global main
.type main,#function
.proc 04

main:
!#PROLOGUE# 0
save %sp, -112, %sp
!#PROLOGUE# 1
sethi %hi(myvar), %i0
or %i0, %lo(myvar), %i1
mov 7, %i0
st %i0, [%i1]
nop
ret
restore

.LLfe1:
.size main,.LLfe1-main
.common myvar,4,4
.ident "GCC: (GNU) 3.2"
可以看出来,myvar与g_var、l_var的不同,myvar并未有具体定义信息,而是用.common这个描述信息进行了描述。在C89中这个叫做:tentative definition,也就是"暂时定义"。对于这样的变量,如果连接时发现其他翻译单元中没有同名定义,则系统会给该变量"转正",分配空间;如果在其他翻译单元中有同名定义,则该符号就会关联到那个定义上去。
//1.c
int i;

int main() {
printf("%d\n", i);
}

//2.c
int i = 198;

则gcc 1.c 2.c后执行a.out的结果是输出198。1.c中的i已经关联到了2.c中的i了。如果只gcc 1.c,则输出为0,系统默认给i分配空间并初始化为0。

使用外部连接的变量声明是有风险的,因为编译器很难在多个翻译单元之间做一致性检查。比如:
//3.c
extern int *a;

int main() {
(*a) = 5;
}

//4.c
char a = 'c';

我们gcc 3.c 4.c进行编译并执行a.out,在sparc solaris上会出现"段错误 ((主存储器)信息转储)"的错误。为什么呢?我们还要回到'符号'上来,从汇编码分析:
.file "3.c"
.section ".text"
.align 4
.global main
.type main,#function
.proc 04

main:
!#PROLOGUE# 0
save %sp, -112, %sp
!#PROLOGUE# 1
sethi %hi(a), %i0
or %i0, %lo(a), %i0
ld [%i0], %i1
mov 6, %i0
st %i0, [%i1]
nop
ret
restore

.LLfe1:
.size main,.LLfe1-main
.ident "GCC: (GNU) 3.2"

.file "4.c"
.global a
.section ".data"
.type a,#object
.size a,1

a:
.byte 99
.ident "GCC: (GNU) 3.2"

再重申:两个翻译单元中的a是通过符号形式联系在一起的。3.c中的符号a关联到了4.c中的a,而4.c中的a是一个char类型的变量,这点3.c并不知情,仍将它当作int*用,尝试将a的内容作为地址,去操作这个地址;由于a中的值是99,显然这不是一个应用层合法的地址,出core也就是必然的了。
同样对于函数也是如此,函数不过是一段指令集合,标识这个指令集合的也是'符号',不同翻译单元间也是靠符号关联在一起的。
//5.c
extern void func();

int main() {
func();
}

//6.c
void func(int a, int b) {
printf("%d\n", a + b);
}

我们通过gcc 5.c 6.c编译后,执行a.out,得到-13236124(不同环境得到的值不一样),这显然乱了套,func的调用者并没有给func传入参数,但是func并不知情,还是一味的通过%ebp在栈上定位两个参数后,将其相加输出,显然这两个值是随机的值,结果也是随机的。编译器显然对于检查func是否被正确调用显得束手无策。编译器唯一能做的就是在同一个翻译单元内部检查函数调用是否符合extern声明,所以要尽量使用原型声明,以保证在同一个翻译单元内函数调用的正确。

//7.c
extern void func(int a, char *p);

int main() {
func(5, 10); //warning: passing arg 2 of `func' makes pointer from integer without a cast
}

© 2007, bigwhite. 版权所有.

Related posts:

  1. C++咬文嚼字-'Functions'
  2. 走马观花ANSI C标准-类型表示
  3. GCC警告选项例解
  4. 也谈内存对齐(续)
  5. C++咬文嚼字-'0 or NULL'