《深入理解C指针》阅读笔记
本篇文章是《深入理解C指针》的阅读笔记。
printf
函数的格式
格式控制符 | 说明 |
---|---|
%d | 按照十进制整型数据实际长度输出 |
%ld | 输出长整形数据 |
%md | m为指定输出字段宽度,如果数据位数小于m则左端补空格,否则按照实际位数输出 |
%u | 输出无符号整型数据,输出无符号整型也可以用 %d,这将无符号转换成有符号数,但没必要 |
%c | 输出一个字符 |
%f | 以小数形式输出单精度和双精度浮点数,不指定字段宽度由系统确定,整数部分全部输出,小数部分输出 6 位,超过 6 位的四舍五入 |
%.mf | 保留小数点后m位输出浮点数 |
%o | 按照八进制整型数据输出 |
%s | 输出字符串 |
%x | 按照十六进制整型数据输出 |
%x、%X、%#x、%#X的区别
1 | int main() |
输出
%d
、\
和""
要输出
%d
只需在前面再加上一个%
,要输出\
只需在前面再加上一个\
,要输出双引号也只需在前面加上一个\
即可
1.3 指针操作符
操作符 | 名称 | 含义 |
---|---|---|
* | 无 | 声明指针 |
* | 解引 | 解引指针 |
-> | 指向 | 访问指针引用结构的字段 |
+ | 加 | 对指针做加法 |
- | 减 | 对指针做减法 |
==/!= | 相等/不等于 | 比较两个指针 |
>/>=/</<= | 大于/大于等于/小于/小于等于 | 比较两个指针 |
(数据转换) | 转换 | 改变指针的类型 |
1.3.1 指针算术运算
- 指针加整数
实际上加这个整数和指针数据类型对应字节数的乘积,如
sizeof(int)*10
大部分编译器允许给
void
指针做算术运算,但可能导致语法错误
- 指针减整数
同指针加整数,实际上减去该整数和指针数据类型对应字节数乘积
- 指针相减
指针相减得到两个地址差值(整数1,2…),通常用于判断数组中的元素顺序
1.3.2 比较指针
指针可以用标准的比较操作符来比较。通常比较结果可以用来判断数组元素的相对顺序
1.4 指针常见用法
1.4.1 多层间接引用
双重指针:指针的指针
使用多层间接引用可以为代码编写使用提供更多的灵活性
例子:
mian()
函数的argv
和argv
参数
1.4.2 常量与指针
- 指向常量的指针
定义指向常量的指针
const int *p
意味着不能通过指针修改它所引用的值,但能访问该值和改变指针(指向另一个常量)
- 指向非常量的常量指针
定义指向非常量的常量指针
int *const p
意味着不能改变指针,但能修改它指向的数据
- 指向常量的常量指针
const int *const p
,很少使用,该指针本身不能修改,指向的数据也不能通过它修改
- 指向”指向常量的常量指针”的指针
const int *const *p
指针类型 | 指针是否可以修改 | 指向的数据是否可以修改 |
---|---|---|
指向非常量的指针 | 是 | 是 |
指向常量的指针 | 是 | 否 |
指向非常量的常量指针 | 否 | 是 |
指向常量的常量指针 | 否 | 否 |
第二章 C的动态内存管理
动态内存管理:使用分配和释放函数手动实现
2.1 动态内存分配
1.C
中动态分配内存基本步骤
1.用malloc类的函数分配内存
malloc函数的参数指定要分配的字节数。如果成功,它会返回从堆上分配的内存的指针;如果失败则会返回空指针
2.用这些内存支持应用程序
3.用free函数释放内存
2.内存泄露
如果不再使用已分配的内存却未将其释放就会发生内存泄漏,导致内存泄漏的情况可能如下:
- 丢失内存地址
- 应该调用
free
函数却没调用(隐式泄露)
2.2 动态内存分配函数
函数 | 说明 |
---|---|
malloc | 从堆上分配内存 |
realloc | 在之前分配的内存块的基础上,将内存重新分配为更大或者更小的部分 |
calloc | 从堆上分配内存并清零 |
free | 将内存块返回堆(释放内存) |
2.2.1 malloc函数
malloc函数从堆上分配一块内存,所分配的字节数由该函数唯一的参数指定,返回值是void指针,如果内存不足,就会返回NULL。此函数不会清空或者修改内存,所以我们认为新分配的内存包含垃圾数据
1 | void* malloc(size_t); |
传递参数给这个函数时要小心,因为如果参数是负数就会引发问题。在有些系统中,参数是负数会返回NULL。如果malloc的参数是0,其行为是实现相关的:可能返回NULL指针,也可能返回一个指向分配了0字节区域的指针。如果malloc函数的参数是NULL,那么一般会生成一个警告然后返回0字节
1 | 示例: |
- 要不要强制类型转换
C引入void指针之前,在两种互不兼容的指针类型之间赋值需要对malloc使用显式转换类型以避免产生警告。因为可以将void指针赋值给其他任何指针类型,所以不再需要显式类型转换了
如果没有引用malloc的头文件,类型转换可能会有问题,编译器可能会产生警告。C默认函数返回整数,如果没有引用malloc的原型,编译器会抱怨你试图把int赋值给指针
- 分配内存失败
如果声明了一个指针,但没有在使用之前为它指向的地址分配内存,那么内存通常会包含垃圾,这往往会导致一个无效内存引用的错误
- 没有给malloc传递正确参数
malloc函数分配的字节数是由它的参数指定的,在用这个函数分配正确的字节数时要小心
- 确认分配的内存数
malloc可分配的最大内存是跟系统相关的,看起来这个大小由size_t限制。不过这个限制可能受可用的物理内存和操作系统的其他限制所影响。
执行malloc时应该分配所请求数量的内存然后返回内存地址。如果操作系统采用“惰性初始化”策略直到访问内存才真正分配的话会怎样?这时候万一没有足够的内存用来分配就会有问题,答案取决于运行时和操作系统。开发者一般不需要处理这个问题,因为这种初始化策略非常罕见
- 静态、全局指针和malloc
- 初始化静态或局部变量时不能调用函数进行初始化
对于静态变量,可以通过在后面用一个单独的语句给变量分配内存来避免这个问题。但是全局变量不能用单独的赋值语句,因为全局变量是在函数和可执行代码外部声明的,赋值语句这类代码必须出现在函数中
2.2.2 calloc函数
calloc会在分配内存的同时清空内存即将内容置为0
calloc函数会根据numElements和elementSize两个参数的乘积来分配内存,并返回一个指向内存的第一个字节的指针。如果不能分配内存,则会返回NULL。此函数最初用来辅助分配数组内存
如果numElements或elementSize为0,那么calloc可能返回空指针。如果calloc无法分配内存就会返回空指针,而且全局变量errno会设置为ENOMEM(内存不足),这是POSIX错误码,有的系统上可能没有
1 | void *calloc(size_t numElements,size_t elementSize); |
2.2.3 recalloc函数
我们可能需要时不时地增加或减少为指针分配的内存,如果需要一个变长数组这种做法尤其有用,realloc函数会重新分配内存并释放掉原来旧的内存
realloc函数返回指向内存块的指针。该函数接受两个参数,第一个参数是指向原内存块的指针,第二个是请求的大小。重新分配的块大小和第一个参数引用的块大小不同。返回值是指向重新分配的内存的指针
请求的大小可以比当前分配的字节数小或者大。如果比当前分配的小,那么多余的内存会还给堆,不能保证多余的内存会被清空。如果比当前分配的大,那么可能的话,就在紧挨着当前分配内存的区域分配新的内存,否则就会在堆的其他区域分配并把旧的内存复制到新区域。
如果大小是0而指针非空,那么就释放内存。如果无法分配空间,那么原来的内存块就保持不变,不过返回的指针是空指针,且errno会设置为ENOMEM
1 | void *recalloc(void *ptr,size_t size); |
第一个参数 | 第二个参数 | 作用 |
---|---|---|
空 | 无 | 同malloc |
非空 | 0 | 释放内存 |
非空 | 小于原内存 | 利用当前块分配更小的块 |
非空 | 大于原内存 | 在当前位置或其他位置分配更大的块 |
2.2.4 alloca函数和变长数组
alloca函数(微软为malloca)在函数的栈帧上分配内存。函数返回后会自动释放内存。若底层的运行时系统不基于栈,alloca函数会很难实现,所以这个函数是不标准的,如果应用程序需要可移植就尽量避免使用它
C99引入了变长数组(VLA),允许函数内部声明和创建其长度由变量决定的数组
2.3 使用free函数释放内存
1 | void free(void *ptr); |
指针参数应该指向由malloc类函数分配的内存的地址,这块内存会被返还给堆。尽管指针仍然指向这块区域,但是我们应该将它看成指向垃圾数据。稍后可能重新分配这块区域,并将其装进不同的数据。
1 | int *pi=(int*)malloc(sizeof(int)); |
内存释放后仍然有可能包含原值,pi变量仍然指向之前的地址,这种情况称为迷途指针
如果传递给free函数的参数是空指针,通常它什么都不做。如果传入的指针所指向的内存不是由malloc类的函数分配,那么该函数的行为将是未定义的
2.3.1 将释放的指针赋值为NULL
已释放的指针仍然可能造成问题。如果我们试图解引一个已释放的指针,其行为将是未定义的,因此调用free将指针赋值为NULL,该技术目的是解决迷途指针问题
2.3.2 重复释放
指两次释放同一块内存,这种情况是不应该出现的。
2.4 迷途指针
内存已经释放而指针还在引用原始指针,这样的指针就是迷途指针,迷途指针没有指向有效对象,也被称为过早释放
使用迷途指针会造成一系列问题:
- 如果访问内存,则行为不可预期;
- 如果内存不可访问,则是段错误;
- 潜在的安全隐患。
2.4.1 迷途指针示例
执行free函数后继续访问或修改该内存
一个以上指针同时指向同一内存区域而其中一个指针被释放,另外一个指针就是迷途指针
指针在块语句中赋值为局部变量地址,该块语句出栈后地址不在有效,此时指针就是迷途指针
2.4.2 处理迷途指针
- 释放指针后置为NULL,后续使用这个指针会终止应用程序。不过,如果存在多个指针的话还是会有问题,因为赋值只会影响一个指针
- 写一个特殊的函数代替free函数
- 有些系统(运行时或调试系统)会在释放后覆写数据(比如0xDEADBEEF,取决于被释放的对象,Visual Studio会用0xCC、0xCD或者0xDD)。在不抛出异常的情况下,如果程序员在预期之外的地方看到这些值,可以认为程序可能在访问已释放的内存
- 用第三方工具检测迷途指针和其他问题
2.4.3 调试器对检测内存泄露的支持
微软提供解决动态内存分配的覆写和内存泄露的技术
- 检查堆的完整性;
- 检查内存泄漏;
- 模拟堆内存不够的情况
Mudflap库为GCC编译器提供了类似的功能,它的运行时库支持对内存泄漏的检测和其他功能,这种检测是通过监控指针解引操作来实现的。
2.5 动态内存分配技术
2.5.1 C的垃圾回收
malloc和free函数提供了手动分配和释放内存的方法。不过对于很多问题,需要考虑使用C的手动内存管理,比如性能、达到好的引用局部性、线程问题,以及优雅地清理内存。
有些非标准的技术可以用来解决部分问题,这些技术的关键特性在于自动释放内存。内存不再使用之后会被收集起来以备后续使用,释放的内存称为垃圾,因此,垃圾回收就是指这个过程
2.5.2 资源获取即初始化(RAII)
由Bjarne Stroustrup发明,用于解决C++资源分配和释放。即使有异常发生,这种技术也能保证资源的初始化和后续的释放。分配的资源最终总是会得到释放
2.5.3 使用异常处理函数
尽管异常处理不属于标准C,但如果可以使用它且不考虑移植问题,它会很有用
第三章 指针和函数
3.1 程序的栈和堆
3.1.1 程序栈
程序栈是支持函数执行的内存区域,通常和堆共享。也就是说,它们共享同一块内存区域。程序栈通常占据这块区域的下部,而堆用的则是上部。
程序栈存放栈帧(stack frame),栈帧有时候也称为活跃记录(activation record)或活跃帧(activation frame)。栈帧存放函数参数和局部变量。
- 调用函数时,函数的栈帧被推到栈上,栈向上“长出”一个栈帧。当函数终止时,其栈帧从程序栈上弹出。栈帧所使用的内存不会被清理,但最终可能会被推到程序栈上的另一个栈帧覆盖
- 动态分配的内存来自堆,堆向下“生长”。随着内存的分配和释放,堆中会布满碎片。尽管堆是向下生长的,但这只是个大体方向,实际上内存可能在堆上的任意位置分配
3.1.2 栈帧的组织
- 返回地址:函数完成后要返回的程序内部地址
- 局部数据存储:为局部变量分配的内存
- 参数存储:为函数参数分配的内存
- 栈指针和基指针:运行时系统用来管理栈的指针
栈指针通常指向栈顶部。基指针(帧指针)通常存在并指向栈帧内部的地址,比如返回地址,用来协助访问栈帧内部的元素。这两个指针都不是C指针,它们是运行时系统管理程序栈的地址。如果运行时系统用C实现,这些指针倒真是C指针
3.2 通过指针传递和返回数据
3.2.1 使用指针传递数据
使用指针来传递数据的一个主要原因是函数可以修改数据
3.2.2 使用值传递数据
3.2.3 传递指向常量的指针
传递指向常量的指针是C中常用的技术,效率很高,因为我们只传了数据的地址,能避免某些情况下复制大量内存。不过,如果只是传递指针,数据就能被修改。如果不希望数据被修改,就要传递指向常量的指针。
3.2.4 返回指针
返回指针很容易,只要返回的类型是某种数据类型的指针即可。从函数返回对象时经常用到以下两种技术:
- 使用malloc在函数内部分配内存并返回其地址。调用者负责释放返回的内存。
- 传递一个对象给函数并让函数修改它。这样分配和释放对象的内存都是调用者的责任。
从函数返回指针时可能存在几个潜在的问题:
- 返回未初始化的指针
- 返回指向无效地址的指针
- 返回局部变量的指针
- 返回指针但是没有释放内存
3.2.5 局部数据指针
比如说在函数中定义局部变量数据并返回该地址,此时返回的数据地址无效,因为函数的栈帧从占中弹出了
3.2.6 传递空指针
使用if(arr!==NULL)来判断arr指针是否是空指针
3.2.7 传递指针的指针
将指针传递给函数时,传递的是值。如果我们想修改原指针而不是指针的副本,就需要传递指针的指针
3.3 函数指针
函数指针是持有函数地址的指针,使用函数指针时要小心,因为C不会检查参数传递是否正确
3.3.1 声明函数指针
1 | void (*foo)(); |
3.3.2 使用函数指针
1 | int (*fptr)(int); |
为函数指针声明类型定义比较方便,类型定义看起来有点奇怪,通常类型定义的名字是声明的最后一个元素
1 | typedef int (*funcptr)(int); |
3.3.3 传递函数指针
传递函数指针很简单,只要把函数指针声明作为函数参数即可
1 | int add(int num1, int num2) { |
3.3.4 返回函数指针
返回函数指针需要把函数返回类型声明为函数指针
1 | fptrOperation select(char opcode) { |
3.3.5 使用函数指针数组
函数指针数组可以基于某些条件选择要执行的函数,声明这种数组很简单,只要把函数指针声明为数组的类型即可
1 | typedef int (*operation)(int, int); |
3.3.6 比较函数指针
使用相等和不等操作符来比较函数指针
3.3.7 转换函数指针
我们可以将指向某个函数的指针转换为其他类型的指针,不过要谨慎使用,因为运行时系统不会验证函数指针所用的参数是否正确。也可以把一种函数指针转换为另一种再转换回来,得到的结果和原指针相同,但函数指针的长度不一定相等
无法保证函数指针和数据指针相互转换后正常工作
void*指针不一定能用在函数指针上,不过在交换函数指针时通常会见到
typedef void (*fptrBase)()
的”基本”函数指针类型(基本指针),用做占位符,用来交换函数指针的值
第四章 指针和数组
数组和指针表示法紧密关联,在合适的上下文中可以互换
4.1 数组概述
数组是能用索引访问的同质元素连续集合。这里所说的连续是指数组的元素在内存中是相邻的,中间不存在空隙,而同质是指元素都是同一类型的。数组声明用的是方括号集合,可以拥有多个维度。
4.1.1 一维数组
一维数组是线性结构,使用一个索引访问成员;数组索引从0开始到声明的长度减1结束
1 | int vector[5]; |
4.1.2 二维数组
二维数组使用行和列来标识数组元素,这类数组需要映射为内存中的一维地址空间。在C中这是通过行–列顺序实现的。先将数组的第一行放进内存,接着是第二行、第三行,直到最后一行。
我们可以将二维数组当做数组的数组,也就是说,如果只用一个下标访问数组,得到的是对应行的指针
1 | int matrix[2][3]={{1,2,3},{4,5,6}}; |
多维数组具有两个及两个以上维度。对于多维数组,需要多组括号来定义数组的类型和长度
1 | int arr3d[3][2][4]={ |
4.2 指针表示法和数组
指针在处理数组时很有用,我们可以用指针指向已有的数组,也可以从堆上分配内存然后把这块内存当做一个数组使用。数组表示法和指针表示法在某种意义上可以互换。不过,它们并不完全相同
1 | int vector[5]={1,2,3,4,5}; |
pv变量是指向数组第一个元素而不是指向数组本身的指针。给pv赋值是把数组的第一个元素的地址赋给pv。
vector、&vector[0]、pv三者的值相同,都表示数组第一个元素的地址,而&vector表示数组的地址(整个数组的指针)
1 | pv[i] |
pv指针包含一个内存块的地址,方括号表示法会取出pv中包含的地址,用指针算术运算把索引i加上,然后解引新地址返回其内容
- 数组和指针的差别
vector[i]生成的代码和*(vector+i)生成的不一样,vector[i]表示法生成的机器码从位置vector开始,移动i个位置,取出内容。而*(vector+i)表示法生成的机器码则是从vector开始,在地址上增加i,然后取出这个地址中的内容
sizeof操作符对数组和同一个数组的指针操作也是不同的。对vector调用sizeof操作符会返回20,就是这个数组分配的字节数。对pv调用sizeof操作符会返回4,就是指针的长度
pv是一个左值,左值表示赋值操作符左边的符号。左值必须能修改。像vector这样的数组名字不是左值,它不能被修改。我们不能改变数组所持有的地址,但可以给指针赋一个新值从而引用不同的内存段
4.3 使用malloc创建一维数组
如果从堆上分配内存并把地址赋给一个指针,那就肯定可以对指针使用数组下标并把这块内存当成一个数组,不过用完记得释放内存
4.4 使用realloc调整数组长度
用malloc创建的已有数组的长度可以通过realloc函数来调整。C99标准支持变长数组,有些情况下这种解决方案可能比使用realloc函数更好。如果没有使用C99标准,那就只能用realloc。此外,变长数组只能在函数内部声明,如果数组需要的生命周期比函数长,那也只使用realloc
4.5 传递一维数组
将一维数组作为参数传递给函数实际是通过值来传递数组的地址,这样信息传递就更高效,因为我们不需要传递整个数组,从而也就不需要在栈上分配内存。通常,这也意味着要传递数组长度,否则在函数看来,我们只有数组的地址而不知道其长度
可以使用两种表示法在函数声明中声明数组:数组表示法和指针表示法
4.5.1 使用数组表示法
1 | void displayArray(int arr[],int size){ |
4.5.2 使用指针表示法
1 | void displayArray(int* arr,int size){ |
4.6 使用指针的一维数组
1 | int* arr[5]; |
下面是等价的指针表示法:
1 | *(arr+i)=(int *)malloc(sizeof(int)); |
子表达式(arr+i)表示数组的第i个元素的地址,用子表达式*(arr+i)修改这个地址的内容(该内容其实也是地址)。在第一条语句中我们将已分配的内存赋给这个位置。对(arr+i)子表达式做两次解引(如第二条语句所示),会返回所分配内存的位置,然后我们把i赋给它
4.7 指针和多维数组
可以将多维数组的一部分看做子数组。比如说,二维数组的每一行都可以当做一维数组。这种行为会对我们用指针处理多维数组有所影响
二维数组表示法:
arr[i][j]
的地址为address of arr+(i*size of row)+(j*size of element)
4.8 传递多维数组
1 | void display2DArray(int arr[][5],int rows); |
这两种写法都指明了数组的列数,这很有必要,因为编译器需要知道每行有几个元素。如果没有传递这个信息,编译器就无法计算想
arr[0][3]
这样的表达式在第一种写法中,表达式
arr[]
是数组指针的一个隐式声明,而第二种写法中的(*arr)
表达式则是指针的一个显式声明
4.9 动态分配二维数组
为二维数组动态分配内存涉及几个问题:(1)数组元素是否需要连续;(2)数组是否规则
通常声明的二维数组所分配的内存是连续的,不过,当我们用malloc这样的函数创建二维数组时,在内存分配上会有几种选择。由于我们可以将二维数组当做数组的数组,因而“内层”的数组没有理由一定要是连续的。如果对这种数组使用下标,数组的不连续对程序员是透明的。注意:连续性还会影响复制内存等其他操作,内存不连续就可能需要多次复制。
4.9.1 分配可能不连续的内存
下面的代码演示了如何创建一个内存可能不连续的二维数组。首先分配“外层”数组,然后分别用malloc语句为每一行分配,因此内存不一定连续,实际分配情况取决于堆管理器和堆的状态,也有可能是连续的
1 | int rows = 2; |
4.9.2 分配连续内存
第一种技术,第一个malloc分配了一个整数指针的数组,一个元素用来存储一行的指针,在for循环中,我们将第二个malloc所分配的内存的一部分赋值给第一个数组的每个元素
从技术上讲,第一个数组的内存可以和数组“体”的内存分开,为数组“体”分配的内存是连续的
1 | int rows = 2; |
第二种技术,数组所需的所有内存是一次性分配的,后面的代码用到这个数组时不能使用下标,必须手动计算索引,每个元素被初始化为其索引的积。不能使用数组下标是因为我们丢失了允许编译器使用下标所需的“形态”信息
1 | int *matrix = (int *)malloc(rows *columns * sizeof(int)); |
4.10 不规则数组和指针
不规则数组是每一行的列数不一样的二维数组
- 复合字面量创建二维数组
复合字面量是一种C构造,前面看起来像类型转换操作,后面跟着花括号括起来的初始化列表
1 | (const int){100} |
这个数组有3行3列,将数组元素用数字0到8按行–列顺序初始化
下面使用3个复合字面量声明不规则数组(复合字面量在创建不规则数组时很有用):
1 | int (*(arr2[]))={ |
第五章 指针和字符串
5.1 字符串基础
字符串是以ASCII字符NUL(即\0)结尾的字符序列。字符串通常存储在数组或者从堆上分配的内存中。不过,并非所有的字符数组都是字符串,字符数组可能没有NUL字符。字符数组也用来表示布尔值等小的整数单元,以节省内存空间
- C中有两种类型的字符串
单字节字符串
由char数据类型组成的序列。
宽字符串
由wchar_t数据类型组成的序列
wchar_t数据类型用来表示宽字符,要么是16位宽,要么是32位宽。这两种字符串都以NUL结尾。可以在string.h中找到单字节字符串函数,而在wchar.h中找到宽字符串函数。创建宽字符主要用来支持非拉丁字符集,对于支持外语的应用程序很有用
- 字符串的长度是字符串中除了NUL字符之外的字符数。为字符串分配内存时,要记得为所有的字符再加上NUL字符分配足够的空间。NULL和NUL不同。NULL用来表示特殊的指针,通常定义为((void*)0),而NUL是一个char,定义为\0,两者不能混用。字符常量是单引号引起来的字符序列。字符常量通常由一个字符组成,也可以包含多个字符,比如转义字符
5.1.1 字符串声明
声明字符串的方式有三种:字面量、字符数组和字符指针。字符串字面量是用双引号引起来的字符序列,常用来进行初始化。不要把字符串字面量和单引号引起来的字符搞混——后者是字符字面量。
- 字符数组声明
1 | char header[32]; |
- 字符指针声明
1 | char *header; |
5.1.2 字符串字面量池
定义字面量时通常会将其分配在字面量池中,这个内存区域保存了组成字符串的字符序列。多次用到同一个字面量时,字面量池中通常只有一份副本。这样会减少应用程序占用的内存。通常认为字面量是不可变的,因此只有一份副本不会有什么问题。不过,认定只有一份副本或者字面量不可变不是一种好做法,大部分编译器有关闭字面量池的选项,一旦关闭,字面量可能生成多个副本,每个副本拥有自己的地址
字符串字面量一般分配在只读内存中,所以是不可变的。字符串字面量在哪里使用,或者它是全局、静态或局部的都无关紧要,从这个角度讲,字符串字面量不存在作用域的概念。
- 字符串字面量不是常量的情况
大部分编译器中将字符串字面量看做常量,无法修改字符串。
5.1.3 字符串初始化
初始化字符串采用的方法取决于变量是被声明为字符数组还是字符指针,字符串所用的内存要么是数组要么是指针指向的一块内存。我们可以用字符串字面量或者一系列字符初始化字符串,或者从别的地方(比如说标准输入)得到字符
1.初始化char数组
1 | cahr header[]="Meadia Player";//初始化操作符初始化char数组 |
2.初始化char指针
1 | cahr *header=(char *)malloc(strlen("Media Player")+1); |
在使用malloc()函数确定字符串长度时:
- 记得算上终结符NUL(即\0)
- 不能使用sizeof操作符,而采用strlen()函数确定已有字符串长度
试图用字符字面量来初始化char指针不会起作用。因为字符字面量是int类型,这其实是尝试把整数赋给字符指针。这样经常会造成应用程序在解引指针时终止:
1
2
3
4
5
6 char *prefix='+';//不合法
---
//正确做法
char *prefix=(char *)malloc(2);
*prefix='+';
*(prefix+1)=0;
3.从标准输入初始化字符串
使用标准输入等外部源初始化字符串
4.字符串位置小结
5.2 标准字符串操作
5.2.1 比较字符串
1 | int strcmp(const char *s1,const char *s2); |
要比较的两个字符串都以指向char常量的指针的形式传递,这让我们可以放心地使用这个函数,而不用担心传入的字符串被修改
5.2.2 赋值字符串
1 | char* strcpy(char *s1,const char *s2); |
两个指针可以引用同一个字符串。两个指针引用同一个地址称为别名。尽管通常情况下这不是问题,但要知道,把一个指针赋值给另一个指针不会复制字符串,只是复制了字符串的地址
5.2.3 拼接字符串
1 | char *strcat(char *s1,const char *s2); |
此函数把第二个字符串拼接到第一个的结尾,第二个字符串是以常量char指针的形式传递的。函数不会分配内存,这意味着第一个字符串必须足够长,能容纳拼接后的结果,否则函数可能会越界写入,导致不可预期的行为。函数的返回值的地址跟第一个参数的地址一样
5.3 传递字符串
5.3.1 传递简单字符串
取决于不同的字符串声明方式,有几种方法可以把字符串的地址传递给函数
5.3.2 传递字符常量的指针
以字符常量指针的形式传递字符串指针是很常见也很有用的技术,这样可以用指针传递字符串,同时也能防止传递的字符串被修改
5.3.3 传递需要初始化的字符串
- 传递空缓冲区让函数填充后返回
- 函数动态分配缓冲区并返回
5.3.4 给应用程序传递参数
C用传统的argc和argv参数支持命令行参数。第一个参数argc,是一个指定传递的参数数量的整数。系统至少会传递一个参数,这个参数是可执行文件的名字。第二个参数argv,通常被看做字符串指针的一维数组,每个指针引用一个命令行参数
5.4 返回字符串
函数返回字符串时,它返回的实际是字符串的地址。这里应该关注的主要问题是如何返回合法的地址,要做到这一点,可以返回以下三种对象之一的引用:
字面量;
动态分配的内存;
本地字符串变量。
5.4.1 返回字面量的地址
1 | char *func(){ |
5.4.2 返回动态分配内存的地址
如果需要从函数返回字符串,可以在堆上分配字符串内存并返回其地址
- 返回局部字符串地址
返回局部字符串的地址可能会有问题,如果内存被别的栈帧覆写就会损坏,应该避免使用这种方法
5.5 函数指针和字符串
通过将函数传递给另一个函数对字符串进行灵活操作
第六章 指针和结构体
6.1 介绍
声明C结构体的方式有多种,这里只介绍其中两种。
第一种使用struct关键字声明结构体。第二种使用类型定义结构体
1 | struct _person{ |
1 | typedef struct _person{ |
- 为结构体分配内存
为结构体分配内存时,分配的内存大小至少是各个字段的长度和。不过,实际长度通常会大于这个和,因为结构体的各字段之间可能会有填充。某些数据类型需要对齐到特定边界就会产生填充。比如说,短整数通常对齐到能被2整除的地址上,而整数对齐到能被4整除的地址上
这些额外内存的分配意味着几个问题:
要谨慎使用指针算术运算;
结构体数组的元素之间可能存在额外的内存
6.2 结构体释放问题
在为结构体分配内存时,运行时系统不会自动为结构体内部的指针分配内存。类似地,当结构体消失时,运行时系统也不会自动释放结构体内部的指针指向的内存
当我们声明含有指针类型变量的结构体或对其动态分配内存时,相应的指针会包含垃圾数据,需要对其分配内存并把地址赋给每个指针,务必记得最后要手动释放该指针以及动态分配内存的结构体的指针
6.3 避免malloc/free开销
重复分配然后释放结构体会产生一些开销,可能导致巨大的性能瓶颈。解决这个问题的一种办法是为分配的结构体单独维护一个表。当用户不再需要某个结构体实例时,将其返回结构体池中。当我们需要某个实例时,从结构体池中获取一个对象。如果池中没有可用的元素,我们就动态分配一个实例。这种方法高效地维护一个结构体池,能按需使用和重复使用内存
6.4 使用指针支持数据结构
指针可以为简单或复杂的数据结构提供更多的灵活性。这些灵活性可能来自动态内存分配,也可能来自切换指针引用的便利性。内存无需像数组那样是连续的,只要总的内存大小对就可以
6.4.1 单链表
链表是由一系列互相连接的节点组成的数据结构。通常会有一个节点称为头节点,其他节点顺序跟在头节点后面,最后一个节点称为尾节点。我们可以用指针轻松实现节点之间的连接,动态按需分配每个节点
链表有好几种类型,最简单的是单链表,一个节点到下一个节点只有一个连接,连接从头节点开始,到尾节点结束。循环链表没有尾节点,链表的最后一个节点又指向头节点。双链表用了两个链表,一个向前连接,一个向后连接,我们可以在两个方向上查找节点,这类链表更灵活,但是也更难实现
6.4.2 使用指针支持队列
队列是一种线性数据结构,行为类似排队。它通常支持两种主要操作:入队和出队。入队操作把元素添加到队列中,出队操作从队列中删除元素。一般来说,第一个添加到队列中的元素也是第一个离开队列的元素,这种行为被称为先进先出(FIFO)
6.4.3 使用指针支持栈
栈数据结构也是一种链表。对于栈,元素被推入栈顶,然后被弹出。当多个元素被推入和弹出时,栈的行为是先进后出(FILO)。第一个推入栈的元素最后一个弹出
6.4.4 使用指针支持树
树是很有用的数据结构,它的名字源于元素之间的关系。通常,子节点连接到父节点,从整体上看就像一颗倒过来的树,根节点表示这种数据结构的开始元素。
树可以有任意数量的子节点,不过,二叉树比较常见,它的每个节点能有0个、1个或是2个子节点。子节点要么是左子节点,要么是右子节点。没有子节点的节点称为叶子节点,就跟树叶一样
第七章 安全问题和指针误用
地址空间布局随机化(Address Space Layout Randomization,ASLR)过程会把应用程序的数据区域随机放置在内存中,这些数据区域包括代码、栈和堆。随机放置这些区域导致攻击者更难预测内存的位置,从而更难利用它们。有些类型的攻击(比如说return-to-libc攻击),会覆写栈的一部分,然后把控制转移到这个区域。这个区域经常是共享C库libc。如果栈和libc的位置是未知的,这类攻击的成功率就会降低。
如果代码位于内存的不可执行区域,数据执行保护(Data Execution Prevention,DEP)技术会阻止执行这些代码。在有些类型的攻击中,恶意代码会覆写内存的某个区域,然后将控制转移到这个区域。如果这个区域(比如栈或是堆)的代码不可执行,那么恶意代码就无法执行了。这种技术可以用硬件实现,也可以用软件实现
7.1 指针的声明和初始化
7.1.1 不恰当的指针声明
1 | int* ptr1, ptr2; //ptr1为int指针变量,ptr2为int变量 |
用类型定义代替宏定义是另一个好习惯。类型定义允许编译器检查作用域规则,而宏定义不一定会
1 |
|
这里定义效果同上,ptr1为int指针变量,ptr2为int变量
1 | typedef int* PINT; |
这里ptr1和ptr2都是int指针变量
7.1.2 使用指针前未初始化
在初始化指针之前就使用指针会导致运行时错误,有时候将这种指针称为野指针
7.1.3 处理未初始化指针
以下三种方法用来处理未初始化指针
- 总是使用NULL来初始化指针
- 使用assert函数
- 如果assert()函数参数为真,那么什么都不会发生,如果表达式为假,程序会终止
- 使用第三方工具
7.2 指针的使用问题
很多安全问题聚焦的是缓冲区溢出的概念,以下几种情况可能导致缓冲区溢出:
访问数组元素时没有检查索引值;
对数组指针做指针算术运算时不够小心;
用gets这样的函数从标准输入读取字符串;
误用strcpy和strcat这样的函数
如果缓冲区溢出发生在栈帧的元素上,就可能把栈帧的返回地址部分覆写为对同一时间创建的恶意代码的调用。函数返回时会将控制转移到恶意函数,该函数可以执行任何操作,只受限于当前用户的特权等级
7.2.1 测试NULL
用malloc这类函数时一定要检查返回值,否则可能会导致程序非正常终止
1 | int *p=malloc(sizeof(int)); |
7.2.2 错误使用解引操作
1 | int num; |
7.2.3 迷途指针
释放指针后却仍然在引用原来的内存,就会产生迷途指针,这其内容可能已经改变。对这块内存进行写操作可能会损坏内存,而读操作则可能返回无效数据,这两种情况都可能导致程序终止
7.2.4 越过数组边界访问内存
没有什么可以阻止程序访问为数组分配空间以外的内存;使用下标计算的地址不会检查索引值
7.2.5 错误计算数组长度
将数组传递给函数时,一定要同时传递数组长度。这个信息帮助函数避免越过数组边界
7.2.6 错误使用sizeof操作符
1 | int buffer[20]; |
7.2.7 匹配指针类型
最好使用合适的指针类型来存储数据!
7.2.8 有界指针
有界指针是指指针的使用被限制在有效的区域内。C没有对这类指针提供直接支持,但可以显示确保这个限制得以执行。
- 创建指针检验函数
- 使用ANSI-C和C++的边界模型检查工具CBMC
C++中的智能指针提供了一种模仿指针同时支持边界检查的方法,不幸的是,C没有智能指针
7.2.9 字符串的安全问题
字符串相关的安全问题一般发生在越过字符串末尾写入的情况
如果使用strcpy和strcat这类字符串函数,稍不留神就会引发缓冲区溢出。strncpy和strncat函数可以对这种操作提供一些支持,它们的size_t参数指定要复制的字符的最大数量。不过,如果字符数量计算不正确,替代函数也容易出错
- C11中(Annex K)加入了strcat_s和strcpy_s函数,如果发生缓冲区溢出,它们会返回错误,目前只有Microsoft Visual C++支持
- 还有scanf_s和wscanf_s函数可以用来防止缓冲区溢出
gets函数从标准输入读取一个字符串,并把字符保存在目标缓冲区中,它可能会越过缓冲区的声明长度写入。如果字符串太长的话,就会发生缓冲区溢出。
7.2.10 指针算术运算和结构体
我们应该只对数组使用指针算术运算,因为数组肯定分配在连续的内存块上,指针算术运算可以得到有效的偏移量。不过,不应该将它们用在结构体内,因为结构体的字段可能分配在不连续的内存区域,因为存在内存对齐的情况
即使结构体内的内存是连续的,用指针算术运算来访问结构体的字段也不是好做法
7.2.11 函数指针的问题
函数和函数指针用来控制程序的执行顺序,但是它们可能会被误用,导致不可预期的行为
如果函数和函数指针的签名不同,不要把函数赋给函数指针,这样会导致未定义的行为
函数指针可以执行不同的函数,这取决于分配给它的地址
7.3 内存释放问题
7.3.1 重复释放
将同一块内存释放两次称为重复释放
避免这类问题的简单办法是释放指针后总是将其置为NULL,大部分堆管理器都会忽略后续对空指针的释放
7.3.2 清除敏感数据
一旦不再需要内存中的敏感数据,马上进行覆写掉是个好主意。当应用程序终止后,大部分操作系统都不会把用到的内存清零或是执行别的操作。系统可能会将之前用过的空间分配给别的程序,那么它就能访问内存中的内容。覆写敏感数据后别的应用程序就难以从之前持有这部分数据的内存中获取有用的信息
7.4 使用静态分析工具
有很多静态分析工具可以检查指针的误用,此外,大部分编译器都有选项来监测本章提到的很多问题。比如说,GCC编译器的-Wall选项可以启用编译器警告
第八章 其他重要内容
本章将研究以下几个很指针相关的主题
- 指针的类型转换;
- 访问硬件设备;
- 别名和强别名;
- 使用restrict关键字;
- 线程;
- 面向对象技术
关于线程,我们对两个方面感兴趣:一是用指针在线程之间共享数据这个基本问题,二是如何用指针支持回调函数。一个操作可能会调用某函数来执行任务,如果实际被调用的函数发生了改变,我们就称之为回调函数。我们可以使用回调函数在线程之间通信
本章会讲到两种在C中支持面向对象类型的方法:第一种是用不透明指针,这种技术对用户隐藏了数据结构的实现细节;第二种技术说明如何在C中实现多态类型。
8.1 转换指针
类型转换是一种基本操作,跟指针结合使用时很有用。转换指针对我们大有帮助,原因包括:
- 访问有特殊目的的地址;
- 分配一个地址来表示端口;
- 判断机器的字节序
机器的字节序一般是指数据类型内部的字节顺序。两种常见的字节序是小字节序和大字节序。小字节序是指将低位字节存储在低地址中,而大字节序是指将高位字节存储在低地址中
8.1.1 访问特殊用途的地址
访问特殊用途的地址的需求一般发生在嵌入式系统上,嵌入式系统对应用程序的介入很少
8.1.2 访问端口
端口既是硬件概念,也是软件概念。服务器用软件端口指明它们要接收发给这台机器的某类消息。硬件端口通常是一个连接到外部设备的物理输入输出系统组件。程序通过读写硬件端口可以处理信息和命令
机器用十六进制地址表示端口,将数据作为无符号整数处理。volatile关键字修饰符表示可以在程序以外改变变量。比如说,外部设备可能会向端口写入数据,且可以独立于计算机的处理器执行这个写操作。出于优化目的,编译器有时候会临时使用缓存或是寄存器来持有内存中的值,如果外部的操作修改了这个内存位置,改动并不能反映到缓存或寄存器中。
用volatile关键字可以阻止运行时系统使用寄存器暂存端口值,每次访问端口都需要系统读写端口,而不是从寄存器中读取一个可能已经过期的值。我们不应该把所有变量都声明为volatile,因为这样会阻碍编译器进行所有类型的优化,用非volatile变量访问volatile内存不是个好主意,这么做会导致未定义的行为
之后应用程序可以通过解引端口指针来读写端口
8.1.3 使用DMA访问内存
直接内存访问(Direct Memory Access,DMA)是一种辅助系统在内存和某些设备间传输数据的底层操作,它不属于ANSI C规范,但是操作系统通常提供对这种操作的支持。DMA操作一般与CPU并行进行,这样可以将CPU解放出来执行其他任务,从而得到更好的性能。
程序员先调用DMA函数,然后等待操作完成。通常,程序员会提供一个回调函数,当操作完成后,操作系统会调用回调函数,回调函数由函数指针指定
8.1.4 判断机器的字节序
我们可以使用类型转换操作来判断架构的字节序。字节序是指内存单元中字节的顺序,字节序一般分为小字节序和大字节序
8.2 别名、强别名和restrict关键字
如果两个指针引用同一内存地址,则其中一个指针是另一个指针的别名
当编译器为指针生成代码时,除非特别指定,它必须假设可能会存在别名。使用别名会对编译器生成代码有所限制,如果两个指针引用同一位置,那么任何一个都可能修改这个位置。当编译器生成读写这个位置的代码时,它就不能通过把值放入寄存器来优化性能。对于每次引用,它只能执行机器级别的加载和保存操作。频繁的加载/保存会很低效,在某些情况下,编译器还必须关心操作执行的顺序
强别名是另一种别名,它不允许一种类型的指针成为另一种类型的指针的别名。强别名规则对符号或修饰符不起作用
不过,有些情况下,对同样的数据采用不同的表现形式也是有用的,为了避免别名问题,可以采用这几种技术:
- 使用联合体
- 关闭强别名
- 使用char指针
编译器总是假定char指针是任意对象的潜在别名,所以,大部分情况下可以安全地使用。不过,把其他数据类型的指针转换成char指针,再把char指针转换成其他数据类型的指针,则会导致未定义的行为,应该避免这么做
8.2.1 使用联合体以多种方式表示值
C是类型语言,在声明变量时就得为其指定类型。可以存在不同类型的多个变量,有时候,可能需要把一种类型转换成另一种类型,这一般是通过类型转换实现的,不过也可以使用联合体。类型双关就是指这种绕开类型系统的技术。但如果转换涉及指针,可能会产生严重问题
8.2.2 强别名
编译器不会强制使用强别名,它只会产生警告。编译器假设两个或更多不同类型的指针永远不会引用同一个对象,这也包括除名字外其他都相同的结构体的指针。有了强别名,编译器可以做某些类型的优化
8.2.3 使用restrict关键字
C编译器默认假设指针有别名,用restrict关键字可以在声明指针时告诉编译器这个指针没有别名,这样就允许编译器产生更高效的代码。很多情况下这是通过缓存指针实现的,不过要记住这只是个建议,编译器也可以选择不优化代码。如果用了别名,那么执行代码会导致未定义行为,编译器不会因为破坏强别名假设而提供任何警告信息
8.3 线程和指针
线程之间共享数据会引发一些问题。常见的问题是数据损坏。线程可以写入对象,但可能时不时地被挂起,导致对象处于不一致的状态。之后另一个线程可能会在第一个线程继续写入之前读取对象,那么第二个线程就会使用无效的或损坏的数据
8.3.1 线程间共享指针
两个或更多线程共享数据可能损坏数据。
8.3.2 使用函数指针支持回调
是如果一个线程的事件导致另一个线程的函数调用,就称为回调。将回调函数的指针传递给线程,而函数的某个事件会引发对回调函数的调用,这种方法在GUI应用程序中处理用户线程事件很有用
8.4 面向对象技术
C不支持面向对象编程,不过,借助不透明指针,我们也可以使用C封装数据以及支持某种程度的多态行为。我们可以隐藏数据结构的实现和支持函数,用户没有必要知道数据结构的实现细节,减少这些实现细节就可以降低应用程序的复杂度
多态行为可以帮助提高应用程序的可维护性。多态函数的行为取决于它执行的目标对象,这意味着我们可以更容易地为应用程序添加功能
8.4.1 创建和使用不透明指针
不透明指针用来在C中实现数据封装。一种方法是在头文件中声明不包含任何实现细节的结构体,然后在实现文件中定义与数据结构的特定实现配合使用的函数。数据结构的用户可以看到声明和函数原型,但是实现会被隐藏(在.c/.obj文件中)。
8.4.2 C中的多态
C++这类面向对象语言的多态是建立在基类及派生类之间继承关系的基础上的。C不支持继承,所以我们得模拟结构体之间的继承