编译和链接
概述
- 编译器的任务
编译器把源代码(如,用C语言编写的代码)翻译成等价的机器语言代码(也叫作目标代码)。
- 链接器的任务
链接器把编译器翻译好的目标代码、库代码和启动代码组合起来,生成一个可执行程序。
Gcc
C程序的完整编译过程示例
- 预处理(Prepressing)
1
| gcc -E hello.c -o hello.i
|
- 编译(Compilation)
1
| gcc -S hello.i -o hello.s
|
- 汇编(Assembly)
1
| gcc -c hello.s -o hello.o
|
- 链接(Linking)
GCC基本语法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| -o [filename]:指定输出文件名称。
-c:仅编译,不进行链接,生成目标文件(.o)。
-E:仅执行预处理,生成预处理文件(.i)。
-S:将源代码编译为汇编代码(.s)。
-g:生成调试信息,便于使用调试工具(如GDB)。
-O:优化代码,支持-O1、-O2、-O3等不同优化级别。
-Wall:启用所有警告信息。
-static:使用静态链接。
-shared:生成共享库。
-I [dir]:指定头文件搜索路径。
-L [dir]:指定库文件搜索路径。
-l [libname]:链接指定的库。
|
sizeof和strlen
|
sizeof |
strlen |
| 性质 |
编译时运算符 |
库函数(string.h) |
| 作用 |
计算字节大小 |
计算字符串的长度(不包括结尾的\0字符) |
指针
指针(pointer)是一个值为内存地址的变量(或数据对象)。
可以对指针进行哪些操作?
- 赋值:可以把地址赋给指针。
- 解引用:
*运算符给出指针指向地址上储存的值。
- 取址:和所有变量一样,指针变量也有自己的地址和值。对指针而言,
&运算符给出指针本身的地址。
- 指针与整数相加:可以使用
+运算符把指针与整数相加,或整数与指针相加。无论哪种情况,整数都会和指针所指向类型的大小(以字节为单位)相乘,然后把结果与初始地址相加。
- 递增指针:递增指向数组元素的指针可以让该指针移动至数组的下一个元素。
- 指针减去一个整数:可以使用
-运算符从一个指针中减去一个整数。指针必须是第1个运算对象,整数是第2个运算对象。该整数将乘以指针指向类型的大小(以字节为单位),然后用初始地址减去乘积。
- 递减指针:递减指向数组元素的指针可以让该指针移动至数组的上一个元素。
- 指针求差:可以计算两个指针的差值。通常,求差的两个指针分别指向同一个数组的不同元素,通过计算求出两元素之间的距离。差值的单位与数组类型的单位相同。
- 比较:使用关系运算符可以比较两个指针的值,前提是两个指针都指向相同类型的对象。
复合字面量
- 语法形式为:
1 2 3
| (类型名){ 初始化列表 }
int* ptr = (int[2]){ 2, 4 };
|
- 类型名
指定了要创建的匿名对象的类型,如 int[5]、struct point等。
- 初始化列表
与普通变量初始化语法相同,用于给该匿名对象赋初值。
- 作用域与生命周期:
- 当复合字面量出现在函数体外时,它具有静态存储期(整个程序运行期间存在)。
- 当出现在函数体内时,它具有自动存储期(通常在其所在的块作用域结束时失效)。
- 其地址可以被获取,并且可以像普通变量一样被修改(除非被限定为
const)。
- 提高代码紧凑性与可读性:
- 避免了为仅使用一次的临时变量单独命名和声明,使代码更简洁。
函数指针&函数指针
|
指针函数 |
函数指针 |
| 性质 |
函数,返回指针类型 |
指针变量,存储函数地址 |
| 示例 |
int* fun(int a) |
int (*fun)(int, int) |
| 使用场景 |
返回数据地址(如数组、字符串) |
动态调用函数(如回调机制) |
内存管理
存储类别
作用域
作用域描述程序中可访问标识符的区域。一个C变量的作用域可以是块作用域、函数作用域、函数原型作用域或文件作用域。
链接
变量有3种链接属性:外部链接、内部链接或无链接。
- 具有块作用域、函数作用域或函数原型作用域的变量都是无链接变量。这些变量属于定义它们的块、函数或原型私有。
- 具有文件作用域的变量可以是外部链接或内部链接。外部链接变量可以在多文件程序中使用,内部链接变量只能在一个翻译单元中使用。
- 注:C 标准用“内部链接的文件作用域”描述仅限于一个翻译单元(即一个源代码文件和它所包含的头文件)的作用域,用“外部链接的文件作用域”描述可延伸至其他翻译单元的作用域。但是,对程序员而言这些术语太长了。一些程序员把“内部链接的文件作用域”简称为“文件作用域”,把“外部链接的文件作用域”简称为“全局作用域”或“程序作用域”。
存储期
作用域和链接描述了标识符的可见性。存储期描述了通过这些标识符访问的对象的生存期。C对象有4种存储期:静态存储期、线程存储期、自动存储期、动态分配存储期。
- 如果对象具有静态存储期,那么它在程序的执行期间一直存在。文件作用域变量具有静态存储期。注意,对于文件作用域变量,
static表明了其链接属性,而非存储期。以 static声明的文件作用域变量具有内部链接。但是无论是内部链接还是外部链接,所有的文件作用域变量都具有静态存储期。
- 线程存储期用于并发程序设计,程序执行可被分为多个线程。具有线程存储期的对象,从被声明时到线程结束一直存在。以关键字
_Thread_local声明一个对象时,每个线程都获得该变量的私有备份。
- 块作用域的变量通常都具有自动存储期。当程序进入定义这些变量的块时,为这些变量分配内存;当退出这个块时,释放刚才为变量分配的内存。
- 变长数组稍有不同,它们的存储期从声明处到块的末尾,而不是从块的开始处到块的末尾。
| 存储类别 |
存储期 |
作用域 |
链接 |
声明方式 |
| 自动 |
自动 |
块 |
无 |
块内 |
| 寄存器 |
自动 |
块 |
无 |
块内,register |
| 静态外部链接 |
静态 |
文件 |
外部 |
所有函数外 |
| 静态内部链接 |
静态 |
文件 |
内部 |
所有函数外,static |
| 静态无链接 |
静态 |
块 |
无 |
块内,static |
存储类别说明符
auto说明符表明变量是自动存储期,只能用于块作用域的变量声明中。由于在块中声明的变量本身就具有自动存储期,所以使用auto主要是为了明确表达要使用与外部变量同名的局部变量的意图。
register说明符也只用于块作用域的变量,它把变量归为寄存器存储类别,请求最快速度访问该变量。同时,还保护了该变量的地址不被获取。
- 用
static说明符创建的对象具有静态存储期,载入程序时创建对象,当程序结束时对象消失。如果static 用于文件作用域声明,作用域受限于该文件。如果 static用于块作用域声明,作用域则受限于该块。因此,只要程序在运行对象就存在并保留其值,但是只有在执行块内的代码时,才能通过标识符访问。块作用域的静态变量无链接。文件作用域的静态变量具有内部链接。
extern 说明符表明声明的变量定义在别处。如果包含 extern 的声明具有文件作用域,则引用的变量必须具有外部链接。如果包含 extern 的声明具有块作用域,则引用的变量可能具有外部链接或内部链接,这接取决于该变量的定义式声明。
分配内存:malloc和free
- 静态存储类别所用的内存数量在编译时确定,只要程序还在运行,就可访问储存在该部分的数据。该类别的变量在程序开始执行时被创建,在程序结束时被销毁。
- 自动存储类别的变量在程序进入变量定义所在块时存在,在程序离开块时消失。因此,随着程序调用函数和函数结束,自动变量所用的内存数量也相应地增加和减少。这部分的内存通常作为栈来处理,这意味着新创建的变量按顺序加入内存,然后以相反的顺序销毁。
- 动态分配的内存在调用
malloc()或相关函数时存在,在调用free()后释放。这部分的内存由程序员管理,而不是一套规则。所以内存块可以在一个函数中创建,在另一个函数中销毁。正是因为这样,这部分的内存用于动态内存分配会支离破碎。也就是说,未使用的内存块分散在已使用的内存块之间。另外,使用动态内存通常比使用栈内存慢。
ANSI C类型限定符
const
以const关键字声明的对象,其值不能通过赋值或递增、递减来修改。
在指针和形参声明中使用const
const放在*左侧任意位置,限定了指针指向的数据不能改变。
const放在*的右侧,限定了指针本身不能改变。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
|
const int* ptr_a;
int* const ptr_b;
const int* const ptr_c;
|
对全局数据使用const
- 方案一,在一个文件中使用定义式声明,在其他文件中使
用引用式声明(extern):
1 2 3 4
| const double PI = 3.14159;
extern const double PI;
|
- 方案二,把
const变量放在一个头文件中,然后在其他文件中包含该头文件:
1 2 3 4 5 6
| static const double PI = 3.14159;
#include "constant.h"
#include "constant.h"
|
- 必须在头文件中用
static声明全局const变量,否则在file1.c和file2.c中包含的constant.h将导致每个文件中都有一个相同标识符的定义式声明。
- 方法二相当于给每个文件提供了一个单独的数据副本。由于每个副本只对该文件可见,所以无法用这些数据和其他文件通信。不过它们都是完全相同(每个文件都包含相同的头文件)的
const数据(声明时使用了const关键字)。
- 头文件方案的好处是,所有文件都只需包含同一个头文件。但它的缺点是,数据是重复的。如果const数据包含庞大的数组,就不能视而不见了。
const与``#define
1 2 3 4
| #define LIMIT 20 const int LIM = 50; static int data1[LIMIT]; static int data2[LIM];
|
在文件作用域(即所有函数之外,全局变量区域)声明数组时,数组的大小必须是一个编译时常量表达式(整型常量的组合、枚举常量和sizeof表达式),不包括const声明的值(这也是C++和C的区别之一,在C++中可以把const值作为常量表达式的一部分)。
volatile
volatile限定符告知计算机,代理(而不是变量所在的程序)可以改变该变量的值。通常,它被用于硬件地址以及在其他程序或同时运行的线程中共享数据。
volatile的语法和const一样:
1 2
| volatile int loc; volatile int* ptr_loc;
|
- 可以同时用
const和volatile限定一个值。例如,通常用const把硬件时钟设置为程序不能更改的变量,但是可以通过代理改变,这时用volatile。只能在声明中同时使用这两个限定符,它们的顺序不重要,如下所示:
1 2
| volatile const int loc; const volatile int* ptr_loc;
|
restrict
restrict关键字允许编译器优化某部分代码以更好地支持计算。它只能用于指针,表明该指针是访问数据对象的唯一且初始的方式。
1 2 3 4 5
|
int* restrict restar = (int*) = malloc(10 * sizeof(int));
|
_Atomic(C11)
1 2 3
| _Atomic int hogs; atomic_store(&hogs, 12);
|
文件输入/输出
标准I/O
exit()
exit()函数关闭所有打开的文件并结束程序。
1 2 3 4 5 6
| #include <stdlib.h>
exit(EXIT_SUCCESS);
exit(EXIT_FAILURE);
|
- 根据ANSI C的规定,在最初调用的
main()中使用return与调用exit()的效果相同。因此,在main()中
return 0;与exit(0);的作用相同。
- 但是,我们说的是“最初的调用”。如果
main()在一个递归程序中,exit()仍然会终止程序,但是return只会把控制权交给上一级递归,直至最初的一级。然后return结束程序。
return和exit()的另一个区别是,即使在其他函数中(除main()以外)调用exit()也能结束整个程序。
fopen()
fopen()模式字符串说明
| 模式字符串 |
含义 |
| “r” |
以读模式打开文件(仅读,文件需存在) |
| “w” |
以写模式打开文件,把现有文件的长度戳截为0(新建/覆盖,不存在则创建) |
| “a” |
以写模式打开文件,在现有文件末尾添加内容(追加,不存在则创建) |
| “r+” |
以更新模式打开文件(读写,文件需存在) |
| “w+” |
以更新模式打开文件(读写,新建/覆盖) |
| “a+” |
以更新模式打开文件;可以读整个文件,但只能从末尾添加内容(读写,追加) |
“rb”、”wb”、”ab” “ab+”、”a+b” “wb+”、”w+b” |
二进制模式(对应文本模式功能) |
“wx”、”wbx”、”w+x” “wb+x”或”w+bx” |
(C11)独占模式(文件存在或以独占模式打开文件,则打开文件失败) |
getc()和putc()
getc()和putc()函数与getchar()和putchar()函数类似。所不同的是,要告诉getc()和putc()函数使用哪一个文件。
1 2 3 4 5 6 7
| int ch; FILE* fp;
ch = getc(fp);
putc(ch, fpout);
|
fclose()
fclose(fp)函数关闭fp指定的文件,必要时刷新缓冲区。对于较正式的程序,应该检查是否成功关闭文件。如果成功关闭,fclose()函数返回0,否则返回EOF:
1 2 3
| if (fclose(fp) != 0) { printf("Error in closing file %s\n", argv[1]); }
|
如果磁盘已满、移动硬盘被移除或出现I/O错误,都会导致调用fclose()函数失败。
文件I/O
fprintf()和fscanf()
与printf()和scanf()类似
1 2
| fprintf(stderr, "\033[1;31mCan't create output file.\n\033[0m");
|
fgets()和fputs()
1 2
| fgets(buf, len, fp); fputs(buf, fp);
|
和puts()函数不同,fputs()在打印字符串时不会在其末尾添加换行符。
随机访问:fseek()和ftell()
fseek()和ftell()
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
fseek(fp, 0L, SEEK_SET);
fseek(fp, 2L, SEEK_CUR);
fseek(fp, -1L, SEEK_END);
|
1 2 3 4 5
|
long last = ftell(fp);
|
fgetpos()和fsetpos()
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
|
int fgetpos(FILE* restrict stream, fpos_t* restrict pos);
int fsetpos(FILE* stream, const fpos_t* pos);
|
标准I/O的机理
- 调用
fopen()打开文件(C程序会自动打开3种标准文件)。fopen()函数不仅打开一个文件,还创建了一个缓冲区(在读写模式下会创建两个缓冲区)以及一个包含文件和缓冲区数据的结构。另外,fopen()返回一个指向该结构的指针。假设把该指针赋给一个指针变量fp,我们说fopen()函数“打开一个流”。如果以文本模式打开该文件,就获得一个文本流;如果以二进制模式打开该文件,就获得一个二进制流。这个结构通常包含一个指定流中当前位置的文件位置指示器。除此之外,它还包含错误和文件结尾的指示器、一个指向缓冲区开始处的指针、一个文件标识符和一个计数(统计实际拷贝进缓冲区的字节数)。我们主要考虑文件输入。
- 通常,第2步是调用一个定义在
stdio.h中的输入函数,如fscanf()、getc()或fgets()。一调用这些函数,文件中的数据块就被拷贝到缓冲区中。缓冲区的大小因实现而异,一般是512字节或是它的倍数,如4096或16384(随着计算机硬盘容量越来越大,缓冲区的大小也越来越大)。最初调用函数,除了填充缓冲区外,还要设置fp所指向的结构中的值。尤其要设置流中的当前位置和拷贝进缓冲区的字节数。通常,当前位置从字节0开始。在初始化结构和缓冲区后,输入函数按要求从缓冲区中读取数据。在它读取数据时,文件位置指示器被设置为指向刚读取字符的下一个字符。由于stdio.h系列的所有输入函数都使用相同的缓冲区,所以调用任何一个函数都将从上一次函数停止调用的位置开始。当输入函数发现已读完缓冲区中的所有字符时,会请求把下一个缓冲大小的数据块从文件拷贝到该缓冲区中。以这种方式,输入函数可以读取文件中的所有内容,直到文件结尾。函数在读取缓冲区中的最后一个字符后,把结尾指示器设置为真。于是,下一次被调用的输入函数将返回EOF。
- 输出函数以类似的方式把数据写入缓冲区。当缓冲区被填满时,数据将被拷贝至文件中。
二进制I/O:fread()和fwrite()
fwrite()
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
size_t fwrite(const void* restrict ptr, size_t size, size_t nmemb, FILE* restrict fp);
char buffer[256]; fwrite(buffer, 256, 1, fp);
|
fread()
1 2 3 4 5 6 7 8 9 10 11 12 13 14
|
size_t fread(void * restrict ptr, size_t size, size_t nmemb,FILE * restrict fp);
double earnings[10]; fread(earnings, sizeof(double), 10, fp);
|
数据类型
struct
和数组不同,结构名不是结构的地址,要在结构名前使用&运算符才能获得结构的地址。
union
union能在同一个内存空间中储存不同的数据类型(不是同时储存)。其大小取决于其最大成员的大小。
- 用一个成员把值储存在一个联合中,然后用另一个成员查看内容
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| typedef union { short short_value[2]; int int_value; float float_value; } converter_t;
int main(void){ ··· converter_t conv;
conv.float_value = 3.14159f; printf("float type: %.5f, addr: 0x%X (short_value[0]: %04X, short_value[1]: %04X)\n", conv.float_value, conv.int_value, conv.short_value[0], conv.short_value[1]); ··· }
|
输出:
1
| float type: 3.14159, addr: 0x40490FD0 (short_value[0]: 0FD0, short_value[1]: 4049)
|
enum (enumerated type)
使用enum关键字,可以创建一个新“类型”并指定它可具有的值(实际上,enum常量是int类型)。
复杂的声明
1 2 3 4 5 6 7 8 9 10 11
| int board[8][8]; int** ptr; int* risks[10]; int (*rusks)[10]; int* oof[3][4]; int (*uuf)[3][4]; int (*uof[3])[4];
char* fump(int); char (*frump)(int); char (*flump[3])(int);
|
- 数组名后面的
[]和函数名后面的()具有相同的优先级。它们比*(解引用运算符)的优先级高。
[]和()的优先级相同,都是从左往右结合。
C预处理器
宏和函数的选择
- 宏和函数的选择实际上是时间和空间的权衡。
- 宏生成内联代码,即在程序中生成语句。如果调用20次宏,即在程序中插入20行代码。如果调用函数20次,程序中只有一份函数语句的副本,所以节省了空间。
- 然而,函数的调用过程包括建立调用、传递参数、跳转到函数代码并返回,这显然比内联代码花费更多的时间。
泛型编程(C11)
1 2 3 4 5 6
| #define MYTYPE(X) _Generic((X),\ int: "int",\ float : "float",\ double: "double",\ default: "other"\ )
|
_Generic是C11的关键字。_Generic后面的圆括号中包含多个用逗号分隔的项。第1个项是一个表达式,后面的每个项都由一个类型、一个冒号和一个值组成。第1个项的类型匹配哪个标签,整个表达式的值是该标签后面的值。
inline function(C99)
- 把函数变成内联函数,编译器可能会用内联代码替换函数调用,并(或)执行一些其他的优化,但是也可能不起作用。
- 编译器优化内联函数必须知道该函数定义的内容。这意味着内联函数定义与函数调用必须在同一个文件中。鉴于此,一般情况下内联函数都具有内部链接。因此,如果程序有多个文件都要使用某个内联函数,那么把内联函数定义放入头文件即可。
_Noreturn函数(C11)
表明调用完成后函数不返回主调函数。exit()函数是
_Noreturn函数的一个示例。
断言库
1 2 3 4 5 6
| #include <assert.h> ... int a = b + c; assert(a >= 0); ...
|