标签 GCC 下的文章

'符号连接'那些事儿

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

三谈内存对齐-背后的故事

记得以前曾经两次谈到过内存对齐话题,一次在'也谈内存对齐'一文中,另一次则是'也谈内存对齐(续)',今天下午和同事又谈到内存对齐的问题了,遂想继续挖掘下去,看看其背后的故事。

关于内存对齐的中文文章多在介绍对齐的'法则',比如为什么sizeof(T)和我们估计的T的大小有出入呢等等,而对于内存对齐的本质少有介绍,我在Google上搜索了一阵后,在IBM开发社区上发现一篇叫'Data alignment: Straighten up and fly right'的文章,其中就有我想知道的关于'内存对齐背后的故事',下面的很多内容都是来自那篇文章的。

很多书籍中都讲到:内存可以看成一个byte数组,我们通过编程语言提供的工具对这个'大数组'中的每个元素进行读写,比如在C中我们可以用指针一次读写一个或者更多个字节,这是我们一般程序员眼中的内存样子。但是从机器角度更具体的说从CPU角度看呢,CPU发出的指令是一个字节一个字节读写内存吗?答案是'否'。CPU是按照'块(chunk)'来读写内存的,块的大小可以是2bytes, 4bytes, 8bytes, 16bytes甚至是32bytes. 这个CPU访问内存采用的块的大小,我们可以称为'内存访问粒度'。

程序员眼中的内存样子:

———————————
| | | | | | | | | | | | | | | | |
———————————
 0 1 2 3 4 5 6 7 8 9 A B C D E F  (地址)

CPU眼中的内存样子:(以粒度=4为例)
———————————————
| | | | |   | | | | |   | | | | |   | | | | |
———————————————
 0 1 2 3     4 5 6 7     8 9 A B     C D E F  (地址)

有了上面的概念,我们来看看粒度对CPU访问内存的影响。

假设这里我们需要的数据分别存储于地址0和地址1起始的连续4个字节的存储器中,我们目的是分别读取这些数据到一个4字节的寄存器中,

如果'内存访问粒度'为1,CPU从地址0开始读取,需要4次访问才能将4个字节读到寄存器中;
同样如果'内存访问粒度'为1,CPU从地址1开始读取,也需要4次访问才能将4个字节读到寄存器中;而且对于这种理想中的''内存访问粒度'为1的CPU,所有地址都是'aligned address'。

如果'内存访问粒度'为2,CPU从地址0开始读取,需要2次访问才能将4个字节读到寄存器中;每次访存都能从'aligned address'起始。
如果'内存访问粒度'为2,CPU从地址1开始读取,相当于内存中数据分布在1,2,3,4三个地址上,由于1不是'aligned address',所以这时CPU要做些其他工作,由于这四个字节分步在三个chunk上,所以CPU需要进行三次访存操作,第一次读取chunk1(即地址0,1上两个字节,而且仅仅地址1上的数据有用),第二次读取chunk2(即地址2,3上两个字节,这两个地址上的数据都有用),最后一次读取chunk3(即地址5,6上两个字节,而且仅仅地址5上的数据有用),最后CPU会将读取的有用的数据做merge操作,然后放到寄存器中。

同理可以推断如果'内存访问粒度'为4,那么从地址1开始读取,需要2次访问,访问后得到的结果merge后放到寄存器中。

是不是所有的CPU都会帮你这么做呢,当然不是。有些厂商的CPU发现你访问unaligned address,就会报错,或者打开调试器或者dump core,比如sun sparc solaris绝对不会容忍你访问unaligned address,都会以一个core结束你的程序的执行。所以一般编译器都会在编译时做相应的优化以保证程序运行时所有数据都是存储在'aligned address'上的,这就是内存对齐的由来。

我们可以指定按照何种粒度访问特定内存块儿:其中void *T为指向特定内存块的地址指针
char *p = (char*)T;每次操作一个字节
short *p = (short*)T;每次操作两个字节
int *p = (int*)T;每次操作4个字节
以此类推。

在'Data alignment: Straighten up and fly right'这篇文章中作者还得出一个结论那就是:"如果访问的地址是unaligned的,那么采用大粒度访问内存有可能比小粒度访问内存还要慢"。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 Go语言精进之路1 Go语言精进之路2 Go语言编程指南
商务合作请联系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