标签 编译 下的文章

'符号连接'那些事儿

我们在编译自己开发的程序或者一些开源软件的时候,常常遇到类似如下的编译器错误信息:
未定义 文件中的
符号 在文件中
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
}

小心库函数调用的'陷阱'

下午一同事发现代码中的一处问题,问题的现象是这样的:这位同事调用了一部门基础库函数,当使用32位编译后,程序正常运行;而当使用64位编译后,系统运行dump core。让这位同事奇怪的是他所修改的程序中还有其他模块也使用了同样的基础库函数,为什么偏偏他这块儿出错呢?恰恰该程序的其他模块是我写的。

该程序调用的基础库函数大致是这样的:
typedef unsigned long my_size_t;
int my_socket_recv(my_socket_t socket, char *buf, my_size_t *len, int timeout);

而这个库函数的实现主要就是调用了系统的库函数:recv,通过man命令查到recv函数原型为:ssize_t recv(int s, void *buf, size_t len, int flags);

而my_socket_recv的实现如下:
int my_socket_recv (my_socket_t socket, char *buf, my_size_t *len, int timeout)
{
     int rv;
     … …
 
     rv = recv(socket, buf, (*len), 0);
     … …
}

使用gdb察看core文件,通过栈上信息看出(*len)值有问题,有经验的人可能基本就可以断定问题所在了,对了,应该类型长度不匹配的问题,导致内存访问越界。同事的代码也证实了这一点。原来我的这位同事在调用my_socket_recv时第三个参数传入一个unsigned int型的地址,而不是unsigned long型的地址,我们知道在64位编译下,long型已经升级到8个字节,而int型依旧是4个字节,而size_t也是unsigned long的typedef,所以当调用recv时,通过len指针,recv系统调用毫无顾忌的去取8个字节,而实际上在栈上只有前四个字节是合法的数据,从第5个字节开始已经是非法的了,出core也就在情理之中了。我们可以简单的模拟出这种情况,如下面的例子:

/* test.c */
#include

void print_long_int(unsigned long *i) {
        printf("i is %ld\n", *i);
}

int main() {
        unsigned int n = 1024;
        print_long_int(&n);

        return 0;
}

64位编译:
gcc -m64 -g test.c

执行a.out出core.

问题虽然解决了,但是思前想后,发现一个问题:即库函数调用暗藏的’陷阱’问题,像这样的问题实际上是基础库函数的接口设计的不够好,实际上它的正常运行依赖某些条件,而不是’无偿’的,但是这些条件又很难识别出来,特别是对于新手而言更是难上难。

想出两种办法解决:
1、不要设计像上面my_socket_recv这样的带有’隐含’条件的接口(recv系统调用接口就没有隐含条件);
2、调用接口的人不要想当然的随便传入不同类型的数据,应尽量保持实参数据类型与形参数据类型完全匹配(这块儿编译器可以帮你做检查),如果不这样,很有可能像我的这位同事一样,掉入了库函数调用的陷阱中。^_^

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn
DigitalOcean Referral Badge

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats