对于初学者,有几种运算符的优先级非常容易混淆,它们的优先级从高到低依次是:
- 定义中被括号
( )
括起来的那部分。 - 后缀 *** 作符:括号
( )
表示这是一个函数,方括号[ ]
表示这是一个数组。 - 前缀 *** 作符:星号
*
表示“指向xxx的指针”。
(相当于: (int *) p1[6] )
从 p1 开始理解,它的左边是 *,右边是 [ ],[ ] 的优先级高于 *,所以编译器先解析p1[6]
,p1 首先是一个拥有 6 个元素的数组,然后再解析int *
,它用来说明数组元素的类型。从整体上讲,p1 是一个拥有 6 个 int * 元素的数组,也即指针数组。
从 p3 开始理解,( ) 的优先级最高,编译器先解析(*p3)
,p3 首先是一个指针,剩下的int [6]
是 p3 指向的数据的类型,它是一个拥有 6 个元素的一维数组。从整体上讲,p3 是一个指向拥有 6 个 int 元素数组的指针,也即二维数组指针。
从 p4 开始理解,( ) 的优先级最高,编译器先解析(*p4)
,p4 首先是一个指针,它后边的 ( ) 说明 p4 指向的是一个函数,括号中的int, int
是参数列表,开头的int
用来说明函数的返回值类型。整体来看,p4 是一个指向原型为int func(int, int);
的函数的指针。
绿色粗体表明 c 是一个指针数组,红色粗体表明指针指向的数据类型,合起来就是:c 是一个拥有 10 个元素的指针数组,每个指针指向一个原型为char *func(int **p);
的函数。
pfunc 是一个函数指针(蓝色部分),该函数的返回值是一个指针,它指向一个指针数组(红色部分),指针数组中的指针指向原型为int func(int *);
的函数(橘黄色部分)。
struct bs
{
int m: 12;
int : 20; //该位域成员不能使用
int n: 4;
};
sizeof(struct bs) 的结果为 8
const 和指针const int *p1;
int const *p2;
int * const p3;
在最后一种情况下,指针是只读的,也就是 p3 本身的值不能被修改;在前面两种情况下,指针所指向的数据是只读的,也就是 p1、p2 本身的值可以修改(指向不同的数据),但它们指向的数据不能被修改。
const 通常用在函数形参中,如果形参是一个指针,为了防止在函数内部修改指针指向的数据,就可以用 const 来限制。
内存分页机制,完成虚拟地址的映射 Linux下C语言程序的内存布局(内存模型)我们暂时不关心内核空间的内存分布情况,下图是Linux下32位环境的一种经典内存模型:
对各个内存分区的说明:
内存分区 | 说明 |
---|---|
程序代码区 (code) | 存放函数体的二进制代码。一个C语言程序由多个函数构成,C语言程序的执行就是函数之间的相互调用。 |
常量区 (constant) | 存放一般的常量、字符串常量等。这块内存只有读取权限,没有写入权限,因此它们的值在程序运行期间不能改变。 |
全局数据区 (global data) | 存放全局变量、静态变量等。这块内存有读写权限,因此它们的值在程序运行期间可以任意改变。 |
堆区 (heap) | 一般由程序员分配和释放,若程序员不释放,程序运行结束时由 *** 作系统回收。malloc()、calloc()、free() 等函数 *** 作的就是这块内存,这也是本章要讲解的重点。 注意:这里所说的堆区与数据结构中的堆不是一个概念,堆区的分配方式倒是类似于链表。 |
动态链接库 | 用于在程序运行期间加载和卸载动态链接库。 |
栈区 (stack) | 存放函数的参数值、局部变量的值等,其 *** 作方式类似于数据结构中的栈。 |
在程序运行过程中,堆内存从低地址向高地址连续分配,随着内存的释放,会出现不连续的空闲区域,如下图所示:
图1:已分配内存和空闲内存相间出现
带阴影的方框是已被分配的内存,白色方框是空闲内存或已被释放的内存。程序需要内存时,malloc() 首先遍历空闲区域,看是否有大小合适的内存块,如果有,就分配,如果没有,就向 *** 作系统申请(发生系统调用)。为了保证分配给程序的内存的连续性,malloc() 只会在一个空闲区域中分配,而不能将多个空闲区域联合起来。
内存块(包括已分配和空闲的)的结构类似于链表,它们之间通过指针连接在一起。在实际应用中,一个内存块的结构如下图所示:
图2:内存块的结构
next 是指针,指向下一个内存块,used 用来表示当前内存块是否已被使用。这样,整个堆区就会形成如下图所示的链表:
图3:类似链表的内存管理方式
现在假设需要为程序分配100个字节的内存,当搜索到图中第一个空闲区域(大小为200个字节)时,发现满足条件,那么就在这里分配。这时候 malloc() 会把第一个空闲区域拆分成两部分,一部分交给程序使用,剩下的部分任然空闲,如下图所示:
图4:为程序分配100个字节的内存
仍然以图3为例,当程序释放掉第三个内存块时,就会形成新的空闲区域,free() 会将第二、三、四个连续的空闲区域合并为一个,如下图所示:
图5:释放第三个内存块
可以看到,malloc() 和 free() 所做的工作主要是对已有内存块的分拆和合并,并没有频繁地向 *** 作系统申请内存,这大大提高了内存分配的效率。
另外,由于单向链表只能向一个方向搜索,在合并或拆分内存块时不方便,所以大部分 malloc() 实现都会在内存块中增加一个 pre 指针指向上一个内存块,构成双向链表,如下图所示:
链表是一种经典的堆内存管理方式,经常被用在教学中,很多C语言教程都会提到“栈内存的分配类似于数据结构中的栈,而堆内存的分配却类似于数据结构中的链表”就是源于此。
链表式内存管理虽然思路简单,容易理解,但存在很多问题,例如:
- 一旦链表中的 pre 或 next 指针被破坏,整个堆就无法工作,而这些数据恰恰很容易被越界读写所接触到。
- 小的空闲区域往往不容易再次分配,形成很多内存碎片。
- 经常分配和释放内存会造成链表过长,增加遍历的时间。
针对链表的缺点,后来人们提出了位图和对象池的管理方式,而现在的 malloc() 往往采用多种方式复合而成,不同大小的内存块往往采用不同的措施,以保证内存分配的安全和效率。
不管具体的分配算法是怎样的,为了减少系统调用,减少物理内存碎片,malloc() 的整体思想是先向 *** 作系统申请一块大小适当的内存,然后自己管理,这就是内存池(Memory Pool)。
内存池的研究重点不是向 *** 作系统申请内存,而是对已申请到的内存的管理,这涉及到非常复杂的算法,是一个永远也研究不完的课题,除了C标准库自带的 malloc(),还有一些第三方的实现,比如 Goolge 的 tcmalloc 和 jemalloc。
我们知道,C/C++是编译型语言,没有内存回收机制,程序员需要自己释放不需要的内存,这在给程序带来了很大灵活性的同时,也带来了不少风险,例如C/C++程序经常会发生内存泄露,程序刚开始运行时占用内存很少,随着时间的推移,内存使用不断增加,导致整个计算机运行缓慢。
内存泄露的问题往往难于调试和发现,或者只有在特定条件下才会复现,这给代码修改带来了不少障碍。为了提高程序的稳定性和健壮性,后来的 Java、Python、C#、JavaScript、PHP 等使用了虚拟机机制的非编译型语言都加入了垃圾内存自动回收机制,这样程序员就不需要管理内存了,系统会自动识别不再使用的内存并把它们释放掉,避免内存泄露。可以说,这些高级语言在底层都实现了自己的内存池,也即有自己的内存管理机制。
在计算机中,有很多使用“池”这种技术的地方,除了内存池,还有连接池、线程池、对象池等。以服务器上的线程池为例,它的主要思想是:先启动若干数量的线程,让它们处于睡眠状态,当接收到客户端的请求时,唤醒池中某个睡眠的线程,让它来处理客户端的请求,当处理完这个请求,线程又进入睡眠状态。
所谓“池化技术”,就是程序先向系统申请过量的资源,然后自己管理,以备不时之需。之所以要申请过量的资源,是因为每次申请该资源都有较大的开销,不如提前申请好了,这样使用时就会变得非常快捷,大大提高程序运行效率。
auto 是自动或默认的意思,很少用到,因为所有的变量默认就是 auto 的。也就是说,定义变量时加不加 auto 都一样,所以一般把它省略,不必多次一举。
static 变量static 声明的变量称为静态变量,不管它是全局的还是局部的,都存储在静态数据区(全局变量本来就存储在静态数据区,即使不加 static)。
静态数据区的数据在程序启动时就会初始化,直到程序运行结束;对于代码块中的静态局部变量,即使代码块执行结束,也不会销毁。
注意:静态数据区的变量只能初始化(定义)一次,以后只能改变它的值,不能再被初始化,即使有这样的语句,也无效。
不过寄存器的数量是有限的,通常是把使用最频繁的变量定义为 register 的。
关于寄存器register 变量变量有以下事项需要注意:
1) 为寄存器变量分配寄存器是动态完成的,因此,只有局部变量和形式参数才能定义为寄存器变量。
2) 局部静态变量不能定义为寄存器变量,因为一个变量只能声明为一种存储类别。
3) 寄存器的长度一般和机器的字长一致,只有较短的类型如 int、char、short 等才适合定义为寄存器变量,诸如 double 等较大的类型,不推荐将其定义为寄存器类型。
4) CPU的寄存器数目有限,即使定义了寄存器变量,编译器可能并不真正为其分配寄存器,而是将其当做普通的auto变量来对待,为其分配栈内存。当然,有些优秀的编译器,能自动识别使用频繁的变量,如循环控制变量等,在有可用的寄存器时,即使没有使用 register 关键字,也自动为其分配寄存器,无须由程序员来指定。
在 Linux 的 ELF 标准中,主要包含以下四类文件:
文件类型 | 说明 | 实例 |
---|---|---|
可重定位文件 (Relocatable File) | 这类文件包含了代码和数据,可以被用来链接成为可执行文件或动态链接库。静态链接库其实也是可重定位文件。 | Linux 下的 .o 和 .a,Windows 下的 .obj 和 .lib。 |
可执行文件 (Executable File) | 这类文件包含了可以直接执行的程序。 | Windows 下的 .exe,Linux 下的可执行文件没有固定的后缀,一般不写。 |
共享目标文件 (Shared Object File) | 这种文件包含了代码和数据,可以在以下两种情况下使用:一种是链接器可以使用这种文件跟其他的可重定位文件和共享目标文件链接,产生新的目标文件;第二种是动态连接器可以将几个共享目标文件与可执行文件结合,作为进程的一部分来运行。 | Linux 下的 .so,Windows 下的 .dll。 |
核心转储文件 (Core Dump File) | 当进程意外终止时,系统可以将该进程的地址空间的内容以及其他信息保存到核心转储文件。 | Linux 下的 core dump。 |
从整体上看,编译生成的目标文件被划分成了多个部分,每个部分叫做一个段(Section)。下图是 Linux GCC 生成的目标文件的格式:
段名大都以.
作为前缀,表示这些名字是系统保留的。下面是对各个部分的说明:
段 名 | 说 明 |
---|---|
ELF Header | 文件头,描述了整个目标文件的属性,包括是否可执行、是动态链接还是静态链接、入口地址是什么、目标硬件、目标 *** 作系统、段表偏移等信息。 |
.text | 代码段,存放编译后的机器指令,也即各个函数的二进制代码。一个C语言程序由多个函数构成,C语言程序的执行就是函数之间的相互调用。 |
.data | 数据段,存放全局变量和静态变量。 |
.rodata | 只读数据段,存放一般的常量、字符串常量等。 |
.rel.text. rel.data | 重定位段,包含了目标文件中需要重定位的全局符号以及重定位入口,我们将在《符号——链接的粘合剂》一节中讲解。 |
.comment | 注释信息段,存放的是编译器的版本信息,比如“GCC:(GUN) 4.2.0”。 |
.debug | 调试信息。 |
.line | 调试时的行号表,即源代码行号与编译后指令的对应表。 |
Section Table | 段表,描述了 ELF 文件包含的所有段的信息,比如段的名字、段的长度、在文件中的偏移、读写权限以及其他属性。可以说,ELF 文件的段结构是由段表来决定的,编译器、链接器和装载器都是依靠段表来定位和访问各个段的。 |
.strtab | 字符串表,保存了 ELF 文件用到的字符串,比如变量名、函数名、段名等。因为字符串的长度往往是不定的,所以用固定的结构来表示它比较困难,常见的做法就是把字符串集中起来存放到一个表中,然后使用字符串在表中的偏移来引用字符串。 |
.symtab | 符号表,保存了全局变量名、局部变量名、函数名等在字符串表中的偏移。 |
可执行文件的组织形式和目标文件非常类似,也被划分成多个部分,如下图所示:
图中左半部分是可执行文件的结构:带阴影的是可执行文件增加的一些段,另外可执行文件删除了可重定位段(.rel.text
和.rel.data
)以及段表(Section Table)。
总体来说,目标文件包含了10个左右的段,而可执行文件包含了将近30个左右的段,上面的两张图只列出了一些关键段,剩下的段都隐藏在“Other Data(其他数据)”。
不同颜色的箭头表明了可执行文件应该被加载到地址空间的哪一个区域,可以发现, *** 作系统并不是为每个段都分配一个区域,而是将多个具有相同权限的段合并在一起,加载到同一个区域。
站在文件结构的角度,可执行文件包含了众多的段(Section),每个段都有不同的作用;站在加载和执行的角度,所有的段都是数据, *** 作系统只关心数据的权限,只要把相同权限的数据加载到同一个内存区域,程序就能正确执行。
常见的数据权限无外乎三种:只读(例如 .rodata 只读数据段)、读写(例如 .data 数据段)、读取和执行(例如 .text 代码段),我们将一块连续的、具有相同权限的数据称为一个 Segment,一个 Segment 由多个权限相同的 Section 构成。
不巧的是,“Segment”也被翻译为“段”,但这里的段(Segment)是针对加载和执行的过程。
这种在程序运行之前确定符号地址的过程叫做静态链接(Static Linking);如果需要等到程序运行期间再确定符号地址,就叫做动态链接(Dynamic Linking)。
在C语言中,编译器默认函数和初始化了的全局变量为强符号(Strong Symbol),未初始化的全局变量为弱符号(Weak Symbol)。强符号之所以强,是因为它们拥有确切的数据,变量有值,函数有函数体;弱符号之所以弱,是因为它们还未被初始化,没有确切的数据。
static 局部变量实际开发中,我们通常将不需要被其他模块调用的全局变量或函数用 static 关键字来修饰,static 能够将全局变量和函数的作用域限制在当前文件中,在其他文件中无效。下面我们通过一个实例来演示。
static 除了可以修饰全局变量,还可以修饰局部变量,被 static 修饰的变量统称为静态变量(Static Variable)。
不管是全局变量还是局部变量,只要被 static 修饰,都会存储在全局数据区(全局变量本来就存储在全局数据区,即使不加 static)。
全局数据区的数据在程序启动时就被初始化,一直到程序运行结束才会被 *** 作系统回收内存;对于函数中的静态局部变量,即使函数调用结束,内存也不会销毁。
注意:全局数据区的变量只能被初始化(定义)一次,以后只能改变它的值,不能再被初始化,即使有这样的语句,也无效。
静态局部变量虽然存储在全局数据区,但是它的作用域仅限于函数内部,func() 中的 n 在函数外无效,与 main() 中的 n 不冲突,除了变量名一样,没有任何关系。
总结起来,static 变量主要有两个作用:
1) 隐藏
程序有多个模块时,将全局变量或函数的作用范围限制在当前模块,对其他模块隐藏。
2) 保持变量内容的持久化
将局部变量存储到全局数据区,使它不会随着函数调用结束而被销毁。
extern 关键字1) 函数的声明
在《C语言函数声明以及函数原型》一节中我们讲到了函数声明,那时并没有使用 extern 关键字,这是因为,函数的定义有函数体,函数的声明没有函数体,编译器很容易区分定义和声明,所以对于函数声明来说,有没有 extern 都是一样的。
总结起来,函数声明有四种形式:
//不使用 extern
datatype function( datatype1 name1, datatype2 name2, ... );
datatype function( datatype1, datatype2, ... );
//使用 extern
extern datatype function( datatype1 name1, datatype2 name2, ... );
extern datatype function( datatype1, datatype2, ... );
2) 变量的声明
变量和函数不同,编译器只能根据 extern 来区分,有 extern 才是声明,没有 extern 就是定义。
变量的定义有两种形式,你可以在定义的同时初始化,也可以不初始化:
datatype name = value;
datatype name;
而变量的声明只有一种形式,就是使用 extern 关键字:
extern datatype name;
extern 是用来声明的
欢迎分享,转载请注明来源:内存溢出
评论列表(0条)