第一部分、基本概念
- 翻译环境与执行环境;交叉编译器;独立环境
- 编译、链接与执行
第二部分、各重要特性
2.1 数据
- 变量的三个属性:作用域、链接属性和存储类型。这三个属性决定了变量的作用域和生命期。
- int a;表示a产生的结果类型是int。
int* b, c, d;
int *b, *c, *d; //这才是声明三个指针的正确形式
- 关于常量指针,可以从语法分析角度,将*xxx看成一个整体进行分析
int const *pci; //pci是一个指向整型常量的指针,可以修改指针的值,但不能修改它所指向的值
int * const pci; //pci是一个指向整型的常量指针,指针的值无法修改,指向的值可以修改
int const * const cpci;//指向整型常量的常量指针
- 四种类型的作用域——文件作用域、函数作用域、代码块作用域和原型作用域。
1)任何在代码块(花括号之间)的开始位置声明的标识符都具有代码块作用域
2)文件作用域:任何在所有代码块之外的标识符都具有文件作用域,从声明之处知道所在源文件的结尾都可以访问。
3)原型作用域:适用于在函数原型中声明的参数名
4)函数作用域:适用于语句标签,语句标签用于goto语句。一个函数中的所用语句标签必须唯一(不建议使用!)。 - 三种链接属性——external internal none
当组成一个程序的各个源文件分别被编译后,所有的目标文件以及那些从一个或多个函数库中引用的函数链接在一起,形成可执行程序。标识符的链接属性决定如何处理在不同文件中出现的标识符。
1)none总是被当做单独的个体,该标识符的多个声明被当作独立不同的实体
2)internal在同一个源文件内的所有声明中都指同一个实体,但位于不同源文件的多个声明则分属不同实体
3)external标识符不论声明多少次、位于几个源文件都表示同一个实体
在缺省的情况下,文件作用域标识符和函数名的链接属性为external,其余标识符的链接属性为none。
1)static只对缺省链接属性为external的声明才有改变链接属性的效果。
2)extern一般为一个标识符指定external链接属性,这样就可以访问在其他任何位置定义的这个实体。 - 存储类型
变量的存储类型决定变量何时创建、何时销毁以及它的值将保持多久。有三个地方可以用于存储变量:普通内存、运行时堆栈、硬件寄存器。
变量的缺省存储类型取决于它的声明位置。凡是在任何代码块之外声明的变量总是存储于静态内存中,也就是不属于堆栈的内存,这类变量成为静态变量。静态变量在程序运行之前创建,在程序的整个执行期间始终存在。
在代码块内部声明变量的缺省存储类型是自动的,存储于栈中,称为自动变量。在程序执行到声明自动变量的代码块时,自动变量才被创建,当程序的执行流离开该代码块时,这些自动变量便自行销毁。static可以使代码块内部的自动变量变为静态变量。
register用于声明自动变量。寄存器变量需要一些额外的工作,在一个使用寄存器变量的函数返回之前,这些寄存器先前存储的值必须恢复,通过运行时栈。
由于寄存器值的保存和恢复,某个特定的寄存器在不同的时刻所保存的值不一定相同,因此,机器并不提供寄存器变量的地址。 - 初始化
当程序链接时还无法判断自动变量的存储位置,事实上,函数局部变量在函数的每次调用中可能占据不同的位置。基于这个理由,自动变量没有缺省的初始值。 - static总结
1)当用于函数定义时,或用于代码块之外的变量声明时,static用于修改标识符的链接属性,从external改为internal。用这种声明的函数或变量只能在声明它们的源文件中访问。
2)当用于代码块中的变量声明时,static用于修改变量的存储类型,从自动变量修改为静态变量。
2.2 语句
- 表达式语句的副作用
所谓语句“没有效果”只是表达式的值被忽略,printf的返回值被忽略,但是printf的打印作用是有用的,这个打印就是副作用。
++a;将a的值加1也是副作用。
2.3 操作符和表达式
- ++ --操作符的结果不是它们所修改的变量,而是变量值的拷贝。
- 左值是可以出现在赋值符号左边的东西,标识一个可以存储结果值的地址;右值是可以出现在赋值符号右边的东西。
- 当两个整型相乘或相加时,需要考虑溢出,可以将其中之一变为long
2.4 指针
- 值的类型并非值本身所固有的一种特性,而取决于它的使用方式。
- 未初始化指针进行赋值:UNIX称为段违例(segmentation violation)或内存错误(memory fault)
- NULL值作为返回值违背了软件工程的原则,用一个单一的值表示两种不同的意思。一种更为安全的策略是让函数返回两个独立的值:首先是个状态值,用于提示查找是否成功;其次是指针,当状态值提示查找成功时,它所指向的就是查到的元素。
- 如果已经知道指针将被初始化为什么地址,就把它初始化为该地址,否则就初始化为NULL。风格良好的程序会在指针解引用前对它进行检查。
- 一个变量当作右值使用时,就是变量的值;当作左值使用时,就是变量的地址;对于指针变量也一样!
char ch = 'a';
char *cp = &ch;
1)cp作为右值、左值都合法
2)&cp只能作为右值
3)*cp作为右值、左值均合法
4)*cp + 1只能作为右值
5)*(cp + 1)作为右值、左值均合法
6)++cp 和 cp++只能作为右值
7)*++cp和*cp++作为右值、左值均合法
8)++*cp和(*cp)++只能作为右值
后缀++操作符涉及的三个步骤:
step1.++操作符产生cp的一份拷贝
step2.++操作符增加cp的值
step3.在cp的拷贝上执行间接访问操作符
- 绝大多数编译器都不会检查指针表达式的结果是否位于合法的边界之内。因此,程序员应该负起责任,确保这一点。
- 指针比较
版本一:
for (vp = &values[N_VALUES]; vp > &values[0]; ) {
*--vp = 0;
}
版本二:
for (vp = &values[N_VALUES - 1]; vp >= &values[0]; vp--) {
*vp = 0;
}
版本二有个问题:标准允许指向数组元素的指针与指向数组最后一个元素后面的那个内存位置的指针进行比较,但不允许与指向数组第1个元素之前的那个内存位置的指针进行比较。实际上,绝大多数C编译器,这个循环将顺利完成任务。(这个应该加入到新标准?新标准有没有修改?)
2.5 函数
- C可以用于设计和实现ADT和黑盒,因为可以限制函数和数据定义的作用域。
1)对外的接口放在头文件里面
2)不可访问的私有数据和函数放在.c源文件里面,使用static进行声明。 - 递归函数保证正确性的三个步骤
1)保证每个步骤正确无误
2)限制条件设置正确(初始条件)
3)每次调用之后更接近限制条件(初始条件) - 可变参数列表——三个宏:va_start, va_arg, va_end
一个值的类型无法简单地通过检查它的位模式来判断,如下两个限制就是这个事实的直接结果。
1)这些宏无法判断实际存在的参数的数量
2)这些宏无法判断每个参数的类型
printf函数中的命名参数时格式字符串,它不仅指定了参数的数量,而且指定了参数的类型。
2.6 数组
- 数组名是一个指针常量,是数组第1个元素的地址。
数组具有一些和指针完全不同的特征。例如数组具有确定数量的元素,而指针只是一个标量值。编译器用数组名来记住这些属性。 - 在两种场合下,数组名并不用指针常量来表示:
1)当数组名作为sizeof操作符的操作数:返回整个数组的长度
2)数组名作为&的操作数:指向数组的指针,而不是一个指向某个指针常量值的指针。
int a[10];
int b[10];
int *c;
...
c = &a[0];//c指向数组的第1个元素
c = a; //意义同上一条语句
错 b = a;// 非法的,不能使用赋值进行整个数组赋值
错 a = c;//非法的, a b在此种情况下都是指针常量,不能进行赋值
- 指针比下标更有效率的场合:
在数组中1次1步(或某个固定的数字)地移动时,与固定数字相乘的运算在编译时完成,所以在运行时所需的指令就少一些。在绝大多数机器上,程序将会更小一些、更快一些。
下标版本:
int array[10];
for (int i = 0; i < 10; ++i){
array[i] = 0;
}
指针版本:
int array[10];
for (int *ap = array; ap < array + 10; ap++) {
*ap = 0;
}
- 将数组作为形参,形参声明为指针更准确
1)函数原型中的一维数组形参无需写明元素数目,因为函数并不为数组参数分配内存空间。形参只是一个指针,它指向的是已经在其他地方分配好内存的空间。
2)这种实现方法使函数无法知道数组的长度。如需要,必须作为一个显示的参数传递给函数。
int strlen(char *string);
int strlen(char string[]);
- 当字符串用于初始化一个字符数组,它就是一个初始化列表。在其他任何地方,它都表示一个字符串常量。
- 多维数组的数组名,也是指向第一个元素的指针,只不过第一个元素也是一个数组。
- 关于二维数组
int matrix[3][10];
matrix 指向第0个子数组
matrix + 1 指向第1个子数组
*(matrix + 1) 指向第1个子数组的第1个元素
*(matrix + 1) + 5 指向第1个子数组的第6个元素
*( *(matrix + 1) + 5 ) 取得是这个值(右值)
- 指向数组的指针
int vector[10], *vp = vector;
int matrix[3][10];
int (*p)[10] = matrix; // 可以使用这个指针一行一行地在matrix中移动,这里的10不能省略!因为指明了指针移动的长度。
int *pi = &matrix[0][0];
int *pi = matrix[0];// 这两个声明使得指针逐个访问数组中的元素。
- 作为函数参数的多维数组
关键在于编译器必须知道第2个及以后各维的长度才能对各下标进行求值。
int matrix[3][10];
void func2(int (*mat)[10]);
void func2(int mat[][10]);
错 void func2(int **mat);
2.7 字符串、字符和字节
- 关于strlen
无符号数size_t减法,进行模运算,所以还是无符号数,第二个式子将永远为真。同理,如果表达式同时包含了有符号数和无符号数,可能会产生奇怪的结果。
size_t strlen(char const *string);
正确 if (strlen(x) >= strlen(y))
错误 if (strlen(x) - strlen(y) >= 0)
- 关于strcpy
1)如果参数src和dst在内存中出现重叠,其结果是未定义的
2)程序员必须保证目标字符数组的空间足以容纳需要赋值的字符串。如果字符串比数组长,多余的字符仍被复制,它们将覆盖原先存储于数组后面的内存空间的值。
char *strcpy(char *dst, char const *src);
- 相关字符串函数
strcat strcmp
strncpy strncat strncmp
strchr strrchr strpbrk strstr
strspn strcspn strtok - 字符操作ctype.h
1)对字符分类
2)转换字符 - 内存操作——类似于字符串函数(遇到NUL停止),能够处理任意的字节序列
memcpy memmove memcmp memchr memset
2.8 结构和联合
- 关于声明:
错误:
typedef struct {
int a;
SELF_REF3 *b;
int c;
} SELF_REF3;
正确:
typedef struct SELF_REF3_TAG {
int a;
struct SELF_REF3_TAG *b;
int c;
} SELF_REF3;
- 不完整声明
struct B;
struct A {
struct B *partner;
...
};
struct B {
struct A *partner;
...
};
- 结构的存储分配——对齐
可以在声明中对结构的成员列表重新排列,让那些对边界要求最严格的成员首先出现,对边界要求最弱的成员最后出现。这种做法可以最大限度地减少因边界对齐而带来的空间损失。
offsetof表示这个指定成员开始存储的位置距离结构开始存储的位置偏移几个字节。 - 联合
联合的初始化必须是第一个成员的类型
struct VARIABLE {
enum {INT, FLOAT, STRING} type;
union {
int i;
float f;
char *s;
} value;
};
2.9 动态内存分配
- 如果内存池是空的,或者它的可用内存无法满足你的要求。malloc会向操作系统请求,要求得到更多的内存,并在这块新内存上执行分配任务。如果操作系统无法向malloc提供更多的内存,malloc就返回一个NULL指针。
- 常见的动态内存错误
对NULL指针进行解引用
对分配的内存进行越界操作
释放并非动态分配内存
释放动态分配内存的一部分
动态内存被释放后继续使用
内存泄漏
2.10 使用结构和指针
- 单链表、双链表
2.11 预处理器
- 为什么使用宏?
1)宏比使用函数在程序规模和速度方面都更胜一筹
2)函数必须声明为一种特定的类型,宏是与类型无关的。下面这个宏可以应用于整型、长整型、浮点数以及其他任何可以用>操作比较大小的类型。 - 使用宏的缺点
1)增加程序的长度,除非宏非常小
2)无法作为函数参数进行传递
#define MAX(a, b) ( (a) > (b) ) ? (a) : (b) )
-
宏和函数的不同之处
- 命令行定义
当我们根据同一个源文件编译一个程序的不同版本时,这个特性很有用。
-Dname
-Dname=stuff
cc -DARRAY_SIZE=100 prog.c
第三部分、高级指针话题
- 高级声明:首先找到所有操作符,然后按照正确的次序执行它们。
int (*f[])(int, float); //f是一个数组,数组元素的类型是函数指针,它所指向的函数的返回值是一个整型值。
- 函数指针
初始化表达式中的&是可选的,因为函数名使用时总是由编译器把它转换为函数指针。&只是显式地说明了编译器隐式执行的任务。
int f(int);
int (*pf)(int) = &f;
三种调用方法:
int ans;
ans = f(25);
ans = (*pf) (25);
ans = pf(25);//编译器需要的就是一个函数指针
- 回调函数 callback function
用户把一个函数指针作为参数传递其他函数,后者将“回调”用户的函数。任何时候,如果你所编写的函数必须能够在不同时刻执行不同类型的工作或者执行只能由函数调用者定义的工作,你都可以使用这个技巧。 - 转移表
把具体操作和选择的代码分开是一种良好的设计方案。转换表是一个函数指针数组。可以实现switch类似的功能。
需要保证转移表所使用的下标位于合法的范围,否则越界下标引用产生的错误可能会非常难以查明定位!
double add(double, double);
double sub(double, double);
double mul(double, double);
double div(double, double);
...
double (*oper_func[]) (double, double) = {
add, sub, mul, div, ...
};
result = oper_func[oper] (op1, op2);
- 字符串
字符串本身是个指针常量,所以关于指针常量的一些操作,字符串常量也可以。
putchar("0123456789ABCDEF" [value % 16] );
第四部分、标准库函数
4.1 输入输出
ANSI C函数库的许多函数调用操作系统来完成某些任务。标准库函数在一个外部整型变量errno中保存错误代码之后把这个信息传递给用户程序,提示操作失败的准确原因。
流
1)概念
硬盘驱动器、网络连接、通信端口和视频适配器都与I/O操作相关。每种设备具有不同的特性和操作协议。操作系统负责这些不同设备的通信细节,并向程序员提供一个更为简单和统一的I/O接口。 ANSI C进一步对I/O的概念进行了抽象。就C程序而言,所有的I/O操作只是简单地从程序移进或移出字节。这种字节流便称为流。程序只需要关心创建正确的输出字节数据,以及正确解释从输入读取的字节数据。特定的I/O设备的细节对程序员是隐藏。
2)缓冲
绝大多数流是完全缓冲的,读取和写入实际上从一块被称为缓冲区的内存区域来回复制数据。用于输出流的缓冲区只有当它写满时才会被刷新到设备或文件中。输入缓冲区当它为空时通过从设备或文件读取下一块较大的输入,重新填充缓冲区。
标准输入输出等与交互设备有联系程序不会进行完全缓冲。
3)printf确定出错的位置
这些函数调用的输出被写入到缓冲区中。解决方法是每个printf函数之后立即调用fflush。
4)文本流
文本流的有些特性在不同的系统中可能不同。比如文本行的最大长度和文本行的结束方式。
5)二进制流
不做改变文件
stdio.h声明了FILE结构,与存储于磁盘上的数据文件不同。FILE是一个数据结构,用于访问一个流。
对于ANSI C程序,运行时系统至少提供stdin stdout stderr三个流,它们都是指向FILE结构的指针。stdio.h中的标准I/O常量
EOF实际值比一个字符要多几位,为了避免二进制值被错误解释为EOF。
FOPEN_MAX FILENAME_MAX标准库函数关于文件I/O的一般情况
1)为处于活动的状态的每个文件声明一个指针变量,FILE *。
2)流通过fopen函数打开。fopen和操作系统验证文件或设备确实存在(在有些操作系统中,还验证你是否允许执行你所指定的访问方式)并初始化FILE结构。
3)根据需要对该文件进行读取或写入
4)调用fcolse函数关闭流。关闭一个流可以防止与它关联的文件被再次访问,保证任何存储于缓冲区的数据被正确地到文件中。-
标准流的I/O更为简单,不需要打开或关闭
1)只用于stdin或stdout
2)随作为参数的流使用
3)使用内存中的字符串而不是流
-
输入/输出函数家族
int fgetc(FILE *stream);
int getc(FILE *stream);
int getchar(void);
这里返回值定义为int是因为EOF,EOF被定义为一个整型,它的值在任何可能出现的字符范围之外。
fgetc和fputc是函数,getc putc getchar putchar是宏。
- ungetc将先前读入的字符返回流中
退回字符和流的当前的位置有关,如果用fseek、fsetpos或rewind函数改变了流动位置,所有退回的字符都将被丢弃。 - 行I/O
行I/O可以用两种方式执行——未格式化的或格式化的。未格式化的I/O简单读取或写入字符串,而格式化I/O则执行数字和其他变量的内部和外部表示形式之间的转换。 - 1)未格式化的行I/O
fgets gets fputs puts
fgets从指定的stream读取字符并把它们复制到buffer中。当读取一个换行符并存储到缓冲区之后就不再读取。若缓冲区的字符数达到buffer_size - 1时,也停止读取。在这种情况下,并不会出现数据丢失的情况,因为下一次调用fgets将从流的下一个字符开始读取。
如果函数要计数被复制的行的数目,太小的华冲去将产生一个不正确的计数,因为一个长行可能会被分成数段进行读取。可以通过增加代码,观察每段是否以换行符结尾来修正这个问题。
#include <stdio.h>
#define MAX_LINE_LENGTH 1024
void copylines(FILE *input, FILE *output) {
char buffer[MAX_LINE_LENGTH];
while ( fgets(buffer, MAX_LINE_LENGTH, input) ! = NULL ) {
fputs(buffer, output);
}
}
2)格式化的行I/O
scanf printf
sprintf是一个潜在的错误根源,因为缓冲区大小不是sprintf函数的一个参数,所以如果输出结果很长溢出缓冲区时,可能改写缓冲区后面内存位置中的数据。二进制I/O
将数据写入到文件效率最高的方法是二进制写入。刷新和定位函数
fflush ftell fseek rewind fgetpos fsetpos
fseek改变一个流的位置会带来三个副作用:
1)行末指示符被清除
2)fseek之前使用ungetc,这个退回的字符被丢弃,因为在定位操作以后,不再是下一个字符
3)定位允许你从写入模式切换到读取模式,或者回到打开的流以便更新。改变缓冲方式
setbuf setvbuf
为一个流自行指定缓冲区可以防止I/O函数库为它动态分配一个缓冲区。为流缓冲区使用一个自动数组是很危险的。如果在流关闭之前,程序的执行流离开了数组声明所在的代码块,流就会继续使用这块内存,但此时它可能已经分配给力其他函数另作他用。
行缓冲:每当一个换行符写入到缓冲区时,缓冲区便便进行刷新。流错误函数
feof ferror clearerr临时文件
tmpfile文件操纵函数
remove rename
4.2 整型函数
stdlib.h —— 算术 随机数 字符串转换
4.3 浮点型函数
math.h——三角函数 对数和指数函数 浮点表示形式 幂 底数、顶数、绝对值、余数
stdlib.h ——字符串转换
4.4 日期和时间函数
time.h ——处理时间 当天时间
4.5 非本地跳转
setjmp.h
4.6 信号
signal.h ——信号名 处理信号 信号处理函数
4.7 打印可变参数列表
stdarg.h
4.8 执行环境
stdlib.h —— 终止执行
assert.h —— 断言
stdlib.h —— 环境
stdlib.h ——执行系统命令
stdlib.h —— 排序和查找
4.9 locale
locale.h —— 数值和货币格式
locale <string.h> ——字符串
第五部分、经典抽象数据类型
- 所有ADT都必须确定一件事——如何获取内存来存储值。有三种可选方案:静态数组、动态分配数组,动态分配的链式结构
- C语言没有提供实现范型的能力,但是可以使用#define定义近似地模拟这种机制。
5.1 栈
5.2 队列
5.3 树
第六部分、运行时环境
6.1 判断运行时环境
- 写一段测试程序获得汇编语言代码,分析如下几个部分:
1)静态变量和初始化
2)栈帧
3)寄存器变量
4)外部标识符的长度
5)判断栈帧布局
6)表达式的副作用
6.2 C和汇编语言的接口
- 从汇编调用C程序
- 从C程序调用汇编
6.3 运行时效率
- 对程序进行性能评测,测算程序的每个部分在执行时所花费的时间。绝大多数UNIX系统都具有性能评测工具。