基础认识:程序在内存中的基本结构
在大多数操作系统中,一个程序加载到内存后会被分配一个虚拟的内存地址,范围从0到一个非常大的不确定值。这个虚拟内存对应的物理内存映射是操作系统管理的,多数情况它在主存(main memory)中,但在空间不足时也有可能在硬盘里。作为一个应用程序的开发者,我们的看到的世界是被隔离开的。
程序运行时,代码区域(通常从0x00400000开始)的指令被依次执行,它可能操作一个内存区域,可能写入一个寄存器,可能做一个运算,也可能跳转到另一个指令等等。这些可能做的事情是完全是可以扩展的,可以是任何现在还想不到的功能,不过笔者认为这些具体的事情单独都不是那么了不起,计算机最核心的部分应该是提供了一套机制,按照某种清单样的方式执行那些功能,这个清单就是程序代码。
区分变量的定义和声明
声明和定义在经常被混淆,这里尝试把这个问题讲清楚
- 声明是指告诉编译器一个变量的名字、类型、如何初始化等信息
- 定义是告诉编译器给这个变量分配内存
声明和定义听起来就是紧密结合的两个操作,我们通常同时需要两者,一般不会只需要分配内存,而不需要知道它的类型等基本信息
考虑都这种紧密关系,许多语言都没有把声明和定义完全分开,在c语言中定义就包含了声明,而声明不一定包含定义
以下,都是定义+声明
int
有些时候,我们不需要定义(分配内存),只需要告诉编译器有这样一个变量就可以了,比如:
extern
之所以不需要分配内存,是因为这个变量可能在别的地方(文件)定义过了,我们只需要告诉编译器相信有这个函数,有这个变量,并且告诉它怎么用这个变量(类型)就可以了。当然这样编译器是不可能知道这个变量的内存具体分配到哪里了,它会把这种位置“留空”,等链接器填上。
为什么要等链接器?编译器自己不可以补上吗?
不可以,c 语言的编译器只会编译一个个单独的文件, 那些不明位置的声明,和知道位置的全局变量/函数会被临时写到一个符号表,等链接器把所有编译好的文件串起来,并把所有的符号表对应起来,最终成为可执行的程序。
这个过程最重要的是要理解编译器在编译的时候是没有全局观的,它只知道它编译的那一个文件,这也是为什么我们需要单独的声明,而不是仅用定义的原因。
备注:上面说的变量和函数都是指全局的,局部变量作用在局部根本不存在跨文件的问题,也不会产生符号表。
好了,知道编译链接的基本流程后我们要问几个十分重要的问题
- 为什么需要头文件
a. 方便跨文件调用别的函数/变量
b. 方便使用共用的结构体/宏
2. 头文件中可以定义函数/变量吗,当作共享函数/变量来使用
不应该,不应该,不应该,重要的事情说三遍,链接时出现duplicate symbol 的问题很多时候是这样错误的编码方式导致的
如果头文件中定义一个变量 int a; 那么所有include 它的源文件 xx.c 都会在编译后输出符号 a, 相当于定义了很多次a,也就是让同一个变量分配到了几个不同的地址,这显然是有问题的,链接器遇到这种情况就会报错。
正确的做法是仅在头文件中包含声明,比如extern int a; 然后在其对应的.c文件中定义这个变量(函数同理)。
3. 头文件中可以定义结构体/宏吗
可以的,也应该如此,结构体/宏不属于要分配内存地址的对象(c++的inline函数同理)。结构体只是定义了一种类型的结构,宏只是定义了一种展开代码的结构,其本身都不需要分配内存地址,这和上面提到的函数本体/变量是有很大不同的, 函数需要分配到代码段,而变量需要分配到全局变量段。
4. 没有头文件就没有办法跨文件调用别文件中的函数/变量了吗?
不是,只要你在调用前声明(e.g. extern int a;)就可以了,这种方法虽然及其不推荐,但是我们需要知道有这种机制,才能在实际工程中发现问题。
5. 头文件需要作为编译器的输入的编译文件吗
不需要,也不应该,编译器遇到#include 会自动导入头文件,作为正在处理的 .c 的一部分, 头文件本身不应该被编译成任何单独的目标文件参与后续的链接(按照之前讨论的,头文件里面都没有任何需要往分配内存地址的东西,编译出来本来应该就是空的)
6. 可以让全局变量不在链接时共享吗?
可以,用static全局变量,
更广为人知的static用法可能是函数里面的局部static变量,这样的变量在函数反复调用的时候会保留之前的值。这里说的static全局变量语义有所不同,这个相当于这个文件私有的全局变量,其它文件访问不到。
深入链接的过程,强弱定义
之前提到编译器会把每一个全局符号(函数和全局变量)写到符号表。我们已经区分过声明和定义了,这里我们进一步再深入一下定义,还可以分为弱定义和强定义,其实就是是否带有初始化的区别:
int a; //弱定义
int a = 10; // 强定义
extern int a; // 声明
强弱定义会影响链接器的行为:
- 对于强定义,编译器会导出强符号(.text/.data/.bss)
- 对于弱定义,编译器会导出弱符号(COMMON)
- 对于声明,编译器会导出未定义符号(UND)
链接器首先会检查有没有重名的强符号和弱符号,规则如下:
- 不允许有多个同名的强符号
- 如果有一个强符号和一些弱符号同名,那么选择强符号
- 如果有多个弱符号同名,那么随机选择一个
后两个十分危险,因为链接器是不知道变量类型的,它只关心地址!发生这种情况时许多链接器也会发出warning,但是不会停止链接,最终产生的程序可能会产生意想不到的效果。
比如下面,右边的x将给定左边x的地址,而编译的时候右边的编译器是把x当浮点数处理的,所有编译出来的操作也是针对浮点数的,这两边的不统一会导致许多意想不到的问题。
为了避免上述问题,我们不要使用弱定义,也就是所有全局变量都一定要初始化!
上面检查和选择成功后,链接器就会把UNF的符号,和这些已定义的符号匹配,如果没有匹配,则报错
ld: symbol(s) not found for architecture x86_64
解析变量定义和声明(未完待续)
你认识几个?
- int a;
- int * a;
- int const * a;
- int (*p(float q))(int a);
- void (*signal(int sig, void (*handler)(int)))(int);
- void (*(*(*funcArrays[10])[30])(int, void(*)(int)))(int);
识别步骤
比较核心的几点是
(1)分析从最左边的标识符开始,它就是变量名
(2)标识符右边的符号表示它是什么,() 就是函数, []就是数组, 剩下左边的部分对于函数来说就是返回值类型,对于数组就是内容类型
(3)向左边分析,*, const, volatile
(4)解析完一部分,比如解析完()中的内容,就把()当成一个整体,继续(2)解析
int
区分常量指针和指针常量
这里中文有点歧义,常量指针是说指向常量的指针(pointer to constant), 指针常量是说常量存在的指针(constant pointer)
int const *a; //常量指针
int const (*a); //常量指针
const int *a; //常量指针
int * const a; //指针常量