{(MainTitle)} 【小记】与指针和二维数组过几招-Bottle小站 {(MainTitleEnd)} {(PostTitle)}【小记】与指针和二维数组过几招{(PostTitleEnd)} {(PostDate)}20220510{(PostDateEnd)} {(PostContent)} 在C/C++中有个叫指针的玩意存在感极其强烈,而说到指针又不得不提到内存管理。现在时不时能听到一些朋友说指针很难,实际上说的是内存操作和管理方面的难。(这篇笔记咱也会结合自己的理解简述一些相关的内存知识) 最近在写C程序使用指针的时候遇到了几个让我印象深刻的地方,这里记录一下,以便今后回顾。 ![embarrassed-2022-05-06](https://assets.xbottle.top/img/embarrassed-2022-05-06.png) > “经一蹶者长一智,今日之失,未必不为后日之得。” - 王阳明《与薛尚谦书》 <bblock style='display:none'> { "src":"https://api.xbottle.top/ne/url/1958727950", "cover":"https://api.xbottle.top/ne/cover/1958727950", "title":"シャル・ウィ・ダンス?", "artist":"ReoNa", "loop":"false", "float":"right" } </bblock> ## 指针和二级指针 简述下指针的概念。 ### 指针 一个指针可以理解为一条**内存地址**。 ![pointer-2022-05-06](https://assets.xbottle.top/img/pointer-2022-05-06.jpg) > 这里先定义了一个整型变量`test`,接着用取址运算符`&`取得这个变量的内存地址并打印出来。 > 可以看到该变量的内存地址是`000000000061FE1C` ### 指针变量 指针变量就是存放**指针**(也就是存放内存地址)的变量,使用`数据类型* 变量名`进行定义。 值得注意的是指针变量内储存的指针(内存地址)**所代表的变量的**数据类型,比如`int*`定义的指针变量就只能指向`int`类型的变量。 ```c int test = 233; int* ptr = &test; ``` > `test`**变量**的类型是整型`int`,所以`test`存放的就是一个整形数据。 > 而`ptr`**变量**的类型是整型指针类型`int*`,存放则的是**整性变量**`test`的指针(内存地址)。 ### 二级指针 二级指针指的是**一级指针变量**的地址。 ```c int main() { int test = 233; printf("%p\n", &test); int *ptr = &test; printf("%p", &ptr); return 0; } /* stdout 000000000061FE1C 000000000061FE10 */ ``` > 这个例子中二级指针就是`ptr`变量的地址`000000000061FE10`。 ### 二级指针变量 二级指针变量就是存放**二级指针**(二级指针的地址)的变量,使用`数据类型** 变量名`进行定义。 ```c int main() { int test = 233; int *ptr = &test; int **ptr2 = &ptr; return 0; } ``` > `ptr`**变量**的类型是整型指针类型`int*`,存放的是**整性(`int`)变量**```test```的指针(内存地址), > `ptr2`**变量**的类型是二级整型指针类型`int**`,存放的是**整性指针(`int*`)变量**`ptr`的内存地址。 ![doublePointerGraph-2022-05-06](https://assets.xbottle.top/img/doublePointerGraph-2022-05-06.png) ### 多级指针变量 虽然二级以上的指针变量相对来说不太常用,但我觉得基本的辨别方法还是得会的: 通过观察发现,指针变量的数据类型定义其实就是在**其所指向的数据类型名后加一个星号**, 比如说: * 指针`ptr`指向整型变量`int test`,那么它的定义写法就是`int* ptr`。(数据类型在`int`后加了一个星号) * 指针`ptr2`指向一级指针变量`int* ptr`,那么它的定义写法就是`int** ptr2`。(数据类型在`int*`后加了一个星号) 再三级指针变量`int*** ptr3`,乍一看星号这么多,实际上“剥”一层下来就真相大白了: ```(int**)*``` 实际上三级指针变量指向的就是**二级指针变量的地址**。 ![008-2022-05-06](https://assets.xbottle.top/img/008-2022-05-06.png) 其他更多级的指针变量可以依此类推。 ## 栈内存和堆内存 指针和内存操作关系紧密,提到指针总是令人情不自禁地想起内存。 程序运行时占用的内存空间会被划分为几个区域,其中和这篇笔记息息相关的便是**栈区(Stack)**和**堆区(Heap)**。 ### 栈区 (Stack) 栈区的操作方式正如数据结构中的栈,是**LIFO后进先出**的。这种操作模式的一个很经典的应用就是**递归函数**了。 每个函数被调用时需要从**栈区**划分出一块栈内存用来存放调用相关的信息,这块栈内存被称为函数的**栈帧**。 ------- **栈帧**存放的内容**主要是**(按入栈次序由先至后): 1. 返回地址,也就是**函数被调用处**的下一条指令的内存地址(内存中专门有代码区用于存放),用于函数调用结束返回时能接着原来的位置执行下去。 2. 函数调用时的**参数值**。 3. 函数调用过程中定义的**局部变量**的值。 4. and so on... 由LIFO后进先出可知一次函数调用完毕后相较而言**局部变量**先出栈,接着是**参数值**,最后栈顶指针指向**返回地址**,函数返回,接着下一条指令执行下去。 ------- **栈区**的特性: 1. 交由系统(C语言这儿就是编译器参与实现)**自动分配和释放**,这点在函数调用中体现的很明显。 2. **分配速度较快**,但并不受程序员控制。 3. 相对来说空间较小,如果申请的空间大于栈剩余的内存空间,会引发**栈溢出**问题。(栈内存大小限制因操作系统而异) > 比如递归函数控制不当就会导致栈溢出问题,因为每层函数调用都会形成新的栈帧“压到”栈上,如果递归函数层数过高,栈帧迟迟得不到“弹出”,就很容易挤**爆栈**内存。 4. **栈内存占用大小**随着函数调用层级升高而**增大**,随着函数调用结束逐层返回而**减小**;也随着**局部变量**的定义而增大,随着局部变量的销毁而减小。 > 栈内存中储存的数据的**生命周期**很清晰明确。 5. 栈区是一片**连续的**内存区域。 ------- ### 堆区 (Heap) 堆内存就真的是“一堆”内存,值得一提的是,这里的堆**和数据结构中的堆没有关系**。 相对栈区来说,堆区可以说是一个更加灵活的大内存区,支持按需进行动态分配。 ------ **堆区**的特性: 1. 交由**程序员或者垃圾回收机制进行管理**,如果不加以回收,在整个程序没有运行完前,分配的堆内存会一直存在。(这也是容易造成内存泄漏的地方) > 在C/C++中,堆内存需要程序员**手动申请分配和回收**。 2. 分配速度**较慢**,系统需要依照算法搜索(链表)足够的内存区域以分配。 3. 堆区**空间比较大**,只要还有可用的物理内存就可以持续申请。 4. 堆区是**不连续(离散)的**内存区域。(大概是依赖**链表**来进行分配操作的) 5. 现代操作系统中,在程序运行完后会**回收**掉所有的堆内存。 > 要养成不用就释放的习惯,不然运行过程中进程占用内存可能越来越大。 ------- ## 简述C中堆内存的分配与释放 ### 分配 这里咱就直接报菜名吧! ![alloc-2022-05-07](https://assets.xbottle.top/img/alloc-2022-05-07.png) 这一部分的函数的原型都定义在头文件`stdlib.h`中。 1. `void* malloc(size_t size)` 用于请求系统从**堆区**中分配一段**连续的内存块**。 2. `void* calloc(size_t n, size_t size);` 在和`malloc`一样申请到连续的内存块后,将所有分配的内存全部**初始化为0**。 3. `void* realloc(void* block, size_t size)` **修改**已经分配的内存块的大小(具体实现是重新分配),可以放大也可以缩小。 > `malloc`可以记成`Memory Allocate 分配内存`; > `calloc`可以记成`Clear and Allocate 分配并设置内存为0`; > `realloc`可以记成`Re-Allocate 重分配内存`。 ------ 简单来说原理大概是这样: * `malloc`内存分配依赖的数据结构是**链表**。简单说来就是所有空闲的内存块会被组织成一个**空闲内存块链表**。 * 当要使用`malloc`分配内存时,它首先会依据算法扫描这个链表,直到找到**一个大小满足需求**的空闲内存块为止,然后将这个空闲内存块传递给用户(通过指针)。 (如果这块的大小**大于**用户所请求的内存大小,则将多余部分“切出来”接回链表中)。 * 在不断的分配与释放过程中,由于内存块的“切割”,大块的内存可能逐渐被切成许多小块内存存在链表中,这些便是**内存碎片**。当`malloc`找不到合适大小的内存块时便会尝试**合并这些内存碎片**以获得大块空闲的内存。 * 实在找不到空闲内存块的情况下,`malloc`会返回`NULL`指针。 ------- ### 释放 释放手动分配的堆内存需要用到`free`函数: ```void free(void* block)``` 只需要传入**指向分配内存始址**的指针变量作为实参传入即可。 > 在`C/C++`中,对于**手动申请分配的堆内存**在使用完后一定要及时释放, > 不然在运行过程中进程占用内存**可能会越来越大**,也就是所谓的内存泄漏。 > 不过在现代操作系统中,程序运行完毕后OS会自动回收对应进程的内存,**包括泄露的内存**。内存泄露指的是在程序运行过程中**无法操作的内存**。 -------- `free`为什么知道申请的内存块大小? ![allocatedMem-2022-05-07](https://assets.xbottle.top/img/allocatedMem-2022-05-07.png) 简单来说,就是在`malloc`进行内存分配时会把内存大小**分配地略大一点**,多余的内存部分用于储存一些头部数据(这块内存块的信息),这块头部数据内就**包括分配的内存的长度**。 但是在返回**指针**的时候,`malloc`会将其**往后移动**,使得指针代表的是**用户请求的内存块的起始地址**。 **头部数据**占用的大小通常是**固定的**(网上查了一下有一种说法是`16`字节,也有说是`sizeof(size_t)`的),在将指针传入`free`后,`free`会将指针**向前移动**指定长度以获得头部数据,读取到**分配的内存长度**,然后**连同头部数据和所分配长度的内存一并释放掉**。 内存释放可以理解为**这块内存被重新接到了空闲链表上**,以备后面的分配。 (实际上内存释放后的情况其实挺复杂的,得要看具体的算法实现和运行环境) ------ ## 二维数组 ### 定义和初始化 C语言中二维数组的定义: ```数据类型 数组名[行数][列数];``` 初始化则可以使用**大括号**: ```c int a[3][4]={ {1,2,3,4}, {5,6,7,8}, {9,10,11,12} }; int b[3][4]={ // 内层不要大括号也是可以的,具体为什么后面再说 1,2,3,4, 5,6,7,8, 9,10,11,12 }; char str[2][6]={ "Hello", "World" }; ``` 此外,在**有初始化值**的情况下,定义二维数组时的一维长度(行数)是**可以省略**的: ```c int a[][4]={ // 如果没有初始化,则一维长度不可省略 1,2,3,4, 5,6,7,8, 9,10,11,12 } ``` ### 在内存中 按上述语句定义的数组,在进程内存中一般储存于: 1. **栈区** - 在函数内部定义的**局部**数组变量。 2. **静态储存区** - 当用`static`修饰数组变量或者在**全局作用域**中定义数组。 数组在内存中是**连续**且呈**线性储存的**,**二维数组也是不例外的**。 虽然在使用过程中二维数组发挥的是“二维”的功能,但其在内存中是被映射为一维线性结构进行储存的。 实践验证一下: ```c int i, j; int a[][4] = { // 如果没有初始化,则一维长度不可省略 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }; size_t len1 = sizeof(a) / sizeof(a[0]); size_t len2 = sizeof(a[0]) / sizeof(a[0][0]); for (i = 0; i < len1; i++) { for (j = 0; j < len2; j++) printf(" [%d]%p ", a[i][j], &a[i][j]); printf("\n"); } ``` <a id="inMem-outPut" for-anchor="true" title="示例输出结果"></a> 输出: ![continuousArr-2022-05-08](https://assets.xbottle.top/img/continuousArr-2022-05-08.jpg) 第一维有**3行**,第二维有**4列**。 一个`int`类型数据占用`4`个字节,从上面的图可以看出来: * `[1]000000000061FDD0` -> `[2]000000000061FDD4` 相隔4字节,说明这两个数组元素相邻,同一行中数组元素储存连续。 * `[4]000000000061FDDC` -> `[5]000000000061FDE0` 同样相隔4字节,这两个数组元素在内存中也是相邻的。 * 从`[1]000000000061FDD0`到`[12]000000000061FDFC`正好相差`44`个字节,整个二维数组元素在内存中是**连续储存**的。 ------- 这样一看,为什么**定义并初始化**的时候**二维数组**的第一维可以省略已经不言而喻了: 在初始化的时候编译器通过数组**第二维**的大小对元素进行“分组”,每一组可以看作是一个一维数组,这些一维数组在内存中从低地址到高地址连续排列储存形成二维数组: ![memOf2DArr-2022-05-08](https://assets.xbottle.top/img/memOf2DArr-2022-05-08.png) > 在上面例子中大括号中的元素`{1,2,3,4,5,6,7,8,9,10,11,12}`被按第二维长度`4`划分成了`{1,2,3,4}`,`{5,6,7,8}`,`{9,10,11,12}`三组,这样程序也能知道第一维数组长度为`3`了。 ### 二维数组名代表的地址 一维数组名代表的是数组的起始地址(也是第一个元素的地址)。 二维数组在内存中也是映射为一维进行连续储存的, 既然如此,二维数组名代表的地址其实也是**整个二维数组的起始地址**,在上面的例子中相当于`a[0][0]`的地址。 在上面的示例最后加一行: ```c printf("Arr address: %p", a); ``` 打印出来的地址和`a[0][0]`的地址完全一致,是`000000000061FDD0`。 ## 二维数组和二级指针 ### 二维数组不等于二级指针 首先要明确一点:**二维数组 ≠ 二级指针** 刚接触C语言时我总是想当然地把这两个搞混了,实际上根本不是一回事儿。 * **二级指针变量**储存的是**一级指针变量**的**地址**。 * **二维数组**是内存中连续储存的一组数据,二维数组名相当于一个**一级指针**(二维数组的起始地址)。 ```c int arr[][4]={ {1,2},{1},{3},{4,5} }; int** ptr=arr; // 这样写肯定是不行的!,ptr储存的是一级指针变量的地址 int* ptr=arr; // 这样写是可以的,但是不建议 int* ptr=&arr[0][0]; // 这样非常ok, ptr储存的是数组起始地址(也就是首个变量的地址) ``` 可以把之前二维数组的例子改一下: ```c int i; int a[][4] = { // 如果没有初始化,则一维长度不可省略 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12 }; size_t len1 = sizeof(a) / sizeof(a[0]); size_t len2 = sizeof(a[0]) / sizeof(a[0][0]); size_t totalLen = len1 * len2; // 整个二维数组的长度 int *ptr = &a[0][0]; // ptr指向二维数组首地址 for (i = 0; i < totalLen; i++) { // 一维指针操作就是基于一维的,所以整个二维数组此时会被当作一条连续的内存 printf(" [%d]%p ", ptr[i], &ptr[i]); // printf(" [%d]%p ", *(ptr + i), ptr + i); if (i % len2 == 3) // 换行 printf("\n"); } printf("Arr address: %p", ptr); ``` 输出结果和之前[遍历二维数组](#inMem-outPut)的是一模一样的。 ### 指针数组 #### 实现“二维数组” 既然二级指针变量不能直接指向二维数组,那能不能依赖二级指针来实现一个类似的结构呢?当然是可以的啦! 整型变量存放着整型`int`数据,整型数组`int a[]`中存放了整型数据; 如果是用申请堆内存来实现的整型数组: ```c int* arr = (int*)malloc(sizeof(int) * 3); ``` 指针`int*`变量`arr`此时指向的是**连续存放整型(`int`)数据的内存的起始地址**,相当于一个一维数组的起始地址。 ------ #### 代码实现 二级指针`int**`变量存放着一级指针变量的地址,那么就可以构建二级指针数组来存放二级指针数据(也就是每个元素都是**一级指针变量的地址**)。 ![pointerArray1-2022-05-08](https://assets.xbottle.top/img/pointerArray1-2022-05-08.png) 具体代码实现: ```c int rows = 3; // 行数/一维长度 int cols = 4; // 列数/二维长度 int **ptr = (int **) malloc(rows * sizeof(int *)); // 分配一段连续的内存,储存int*类型的数据 int i, j, num = 1; for (i = 0; i < rows; i++) { ptr[i] = (int *) malloc(cols * sizeof(int)); // 再分配一段连续的内存,储存int类型的数据 for (j = 0; j < cols; j++) ptr[i][j] = num++; // 储存一个整型数据1-12 } ``` 其中 `ptr[i] = (int *) malloc(cols * sizeof(int));` 这一行,等同于 `*(ptr+i) = ...` 也就是利用间接访问符`*`让一级指针变量**指向**在堆内存中分配的一段连续整形数据,这里相当于初始化了第二维。 而在给整型元素赋值时和二维数组一样用了中括号进行访问: `ptr[i][j] = i * j;` 其实就等同于: `*(*(ptr+i)+j) = i * j;` 1. 第一次访问第一维元素,用第一维起始地址`ptr`加上第一维下标`i`,取出对应的**一级指针变量**中**存放的地址**:`*(ptr+i)` 这个地址是第二维中**一段连续内存**的起始地址。 2. 第二次访问第二维元素,用1中取到的地址`*(ptr+i)`加上**第二维下标**`j`,再用间接访问符`*`访问对应的元素,并赋值。 ------ #### 在内存中的存放 指针数组在内存中的存放**不同于**普通定义的二维数组,它的**每一个维度是连续储存的**,但是**维度和维度之间**在内存中的存放是**离散**的。 用一个循环打印一下每个元素的地址: ```c for (i = 0; i < rows; i++) { for (j = 0; j < cols; j++) printf(" [%d]%p ", ptr[i][j], *(ptr + i) + j); printf("\n"); } ``` 输出: ![pointerArrAddress-2022-05-09](https://assets.xbottle.top/img/pointerArrAddress-2022-05-09.jpg) 可以看到第二维度的地址是连续的,但是第二维度“数组”**之间并不是连续的**。比如元素`4`和元素`5`的地址相差了`20`个字节,并不是四个字节。 ![pointerArray2-2022-05-09](https://assets.xbottle.top/img/pointerArray2-2022-05-09.png) 其在内存中的存放结构大致如上,并无法保证`*(ptr+0)+3`和`*(ptr+1)`的地址相邻,也无法保证`*(ptr+1)+3`和`*(ptr+2)`的地址相邻。 这种非连续的存放方式可以说是和二维数组相比**很大的一个不同点**了。 ---------------- #### 释放对应的堆内存 通常指针数组实现的“二维数组”是在**堆内存**中进行存放的,既然申请了堆内存,咱也应该养成好习惯,使用完毕后将其释放掉: ```c for (i = 0; i < rows; i++) free(ptr[i]); free(ptr); ``` 先利用一个循环释放掉每一个**一级指针变量**指向的**连续内存块**(储存整型数据),最后再把二级指针变量指向的**连续内存块**(储存的是一级指针变量的地址)释放掉。 ------ ### sizeof的事儿 `sizeof()`是C语言中非常常用的一个**运算符**,而**二级指针**和**二维数组**的区别在这里也可以很好地展现出来。 #### 对于直接定义的数组 对于**非变量长度**定义的数组,`sizeof`在**编译阶段**就会完成求值运算,被替换为对应数据的大小的常量值。 > `int arr[n];` 这种定义时数组长度为变量的即为**变量长度数组**(C99标准开始支持),不过还是不太推荐这种写法。 直接固定长度**定义二维数组**时,编译器是**知道这个变量是数组的**,比如: ```c int arr[3][4]; size_t arrSize = sizeof(arr); ``` 在编译阶段,编译器知道数组`arr`是一个整型`int`二维数组: 1. 每个**第二维数组**包含四个`int`数据,长度为`sizeof(int)*4=16`个字节。 2. 第一维数组包含三个**第二维数组**,每个第二维数组长度为`16`字节,整个二维数组总长度为`16*3=48`个字节。 即`sizeof(arr) = 48`。 ------- #### 对于指针数组 指针变量储存的是指针,也就是一个地址。内存地址在运算的时候会存放在CPU的**整数寄存器**中。 **64位**计算机中整数寄存器宽度有`64`bit(位),而指针数据要能存放在这里。 目前来说 `1` 字节(Byte) = `8` 位(bit),那么`64`位就是`8`个字节, 所以**64**位系统中指针变量的长度是`8`字节。 ```c int rows = 3; // 行数/一维长度 int **ptr = (int **) malloc(rows * sizeof(int *)); size_t ptrSize = sizeof(ptr); // 8 Bytes size_t ptrSize2 = sizeof(int **); // 8 Bytes size_t ptrSize3 = sizeof(int *); // 8 Bytes size_t ptrSize4 = sizeof(char *); // 8 Bytes ``` 虽然上面咱通过申请分配堆内存实现了二维数组(用二级指针变量`ptr`指向了指针数组起址), 但其实在编译器眼中,`ptr`就**单纯是一个二级指针变量**,占用字节数为`8 Bytes`(64位),储存着一个地址,因此在这里是**无法通过sizeof**获得这块连续内存的长度的。 通过上面的例子很容易能观察出来: ```sizeof(指针变量) = 8 Bytes``` (64位计算机) 无论**指针变量**指向的是什么数据的地址,它储存的**单纯只是一个内存地址**,所以所有指针变量的占用字节数**是一样的**。 ------- ### 函数传参与返回 得先明确一点:C语言中不存在所谓的**数组参数**,通常让函数接受一个数组的数据需要通过**指针变量参数**传递。 #### 传参时数组发生退化 ```c int test(int newArr[2]) { printf(" %d ", sizeof(newArr)); // 8 return 0; } int main() { int arr[5] = {1, 2, 3, 4, 5}; test(arr); return 0; } ``` 在上面这个例子中`test`函数的定义中声明了“看上去像数组的”形参`newArr`,然而`sizeof`的运算结果是`8`。 实际上这里的形参声明是等同于`int* newArr`的,因为把数组作为参数进行传递的时候,**实际上传递的是数组的首地址**(因为数组名就代表数组的首地址)。 > 这种情况下就发生了**数组**到**指针**的退化。 在编译器的眼中,`newArr`此时就**被当作了一个指针变量**,指向`arr`数组的首地址,因此声明中数组的长度怎么写都行:`int newArr[5]`,`int newArr[]`都可以。 为了让代码更加清晰,我觉得最好还是声明为`int* newArr`,这样一目了然能知道这是一个指针变量! ------- #### 函数内运算涉及到数组长度时 当函数内运算涉及到数组长度时,就需要在函数定义的时候**另声明一个形参**来接受数组长度: ```c int test(int *arr, size_t rowLen, size_t colLen) { int i; size_t totalLen = rowLen * colLen; for (i = 0; i < totalLen; i++) { printf(" %d ", arr[i]); if (i % colLen == colLen - 1) // 每个第二维数组元素打印完后换行 printf("\n"); } return 0; } int main() { int arr[3][3] = { 1, 2, 3, 4, 5, 6, 7, 8, 9 }; test(arr, sizeof(arr) / sizeof(arr[0]), sizeof(arr[0]) / sizeof(arr[0][0])); return 0; } ``` 输出: ![printFuncOutput-2022-05-09](https://assets.xbottle.top/img/printFuncOutput-2022-05-09.jpg) 这个例子中`test`函数就多接受了**二维数组的**一维长度`rowLen`和二维长度`colLen`,以对二维数组元素进行遍历打印。 ------ #### 返回“数组” 经常有应用场景需要函数返回一个“数组”,说是数组,实际上函数**并无法返回**一个局部定义的数组,哪怕是其指针(在[下面一节](#常见问题-函数返回局部变量)有写为什么)。 取而代之地,常常会返回一个**指针**指向分配好的一块连续的**堆内存**。 (在**算法题**中就经常能遇到要求返回指针的情况) ```c int *test(size_t len) { int i; int *arr = (int *) malloc(len * sizeof(int)); for (i = 0; i < len; i++) arr[i] = i + 1; return arr; } int main() { int i = 0; int *allocated = test(5); for (; i < 5; i++) printf(" %d ", allocated[i]); free(allocated); // 一定要记得释放! return 0; } ``` 这个示例中,`test`函数的**返回类型**是整型指针。当调用了`test`函数,传入要分配的连续内存长度后,其在函数内部定义了一个局部指针变量,指向分配好的内存,在内存中存放数据后将该指针返回。 在主函数中,`test`返回的整型指针被赋给了指针变量`allocated`,所以接下来可以通过一个循环打印出这块连续内存中的数据。 再次提醒,申请堆内存并使用完后,一定要记得使用`free`进行**释放**! ## 生疏易犯-函数返回局部变量 ### 错误示例 记得初学C语言的时候,我曾经犯过一个错误:将**函数内定义的数组的数组名作为返回值**: ```c int *test() { int arr[4] = {1, 2, 3, 4}; return arr; } int main() { int i = 0; int *allocated = test(); for (; i < 4; i++) printf(" %d ", *(allocated + i)); return 0; } ``` 这个例子中直到for循环前进程仍然正常运行,但是一旦尝试使用`*`运算符取出内存中的数据`*(allocated + i)`,进程立马接收到了系统发来的**异常信号**`SIGSEGV`,进而终止执行。 ### 原因简述 > `SIGSEGV`是比较常见的一种异常信号,代表`Signal Segmentation Violation`,也就是`内存分段冲突` > 造成异常的原因通常是进程 **试图访问一段没有分配给它的内存**,“**野指针**”总是伴随着这个异常出现。 上面简述[栈区](#栈区-stack)的时候提到了**栈帧**,每次调用函数时会在栈上给函数分配一个栈帧用来储存**函数调用相关信息**。 函数调用完成后,先把运算出来的**返回值存入**寄存器中,接着会在**栈帧**上进行**弹栈**操作,在这个过程中**分配的局部变量就会被回收**。 最后,程序在**栈顶**中取到函数的返回地址,返回上层函数继续执行余下的指令。**栈帧销毁**,此时**局部变量相关的栈内存已经被回收了**。 然而此时寄存器中仍**存着函数的返回值**,是一个内存地址,但是**内存地址代表的内存部分已经被回收了**。 当将返回值赋给一个**指针变量**时,**野指针**就产生了——此时这个指针变量**指向一片未知的内存**。 所以当进程**试图访问这一片不确定的内存时**,就容易引用到无效的内存,此时系统就会发送`SIGSEGV`信号让进程终止执行。 ------ ### 教训 教训总结成一句话就是: * 程序中请不要让函数返回代表**栈内存**的**局部变量的地址**。 ------ 延伸:返回**静态局部变量**是可以的,因为静态局部变量是**储存在静态储存区的**。 ```c int *test() { static int arr[4] = {1, 2, 3, 4}; return arr; } ``` 👆 如果之前例子中的`test`函数内这个局部数组变量声明为局部的**静态变量**,程序就可以正常执行了。 ## 实参结构体中的指针 ### 改变指针变量指向的变量 用一个拥有指针变量的结构体作为实参传入函数: ```c struct Hello { int num; int *ptr; }; int test(struct Hello testStruct) { printf(" [test]testStruct-Ptr: %p \n", ++testStruct.ptr); *testStruct.ptr = 2; return 1; } int main() { int *testPtr = (int *) calloc(4, sizeof(int)); struct Hello testStruct = { .num=5, .ptr=testPtr }; printf(" [main]testStruct-Ptr: %p \n\tptr[1]=%d\n", testStruct.ptr, testStruct.ptr[1]); test(testStruct); printf(" [main]testStruct-Ptr: %p \n\tptr[1]=%d\n", testStruct.ptr, testStruct.ptr[1]); free(testPtr); return 0; } ``` 输出: ``` [main]testStruct-Ptr: 0000000000A71420 ptr[1]=0 [test]testStruct-Ptr: 0000000000A71424 [main]testStruct-Ptr: 0000000000A71420 ptr[1]=2 ``` 在`test`函数中,通过自增操作和`*`运算符给`testStruct.ptr`指向的下一个元素赋值为`2`。 通过输出可以看到,`test`函数内结构体中指针变量的自增操作并没有影响到`main`函数中结构体的指针变量,这是因为**结构体作为参数传入时**实际上是被**拷贝了一份**作为局部变量以供操作。 之所以能赋值是因为```testStruct.ptr```是指针变量,存放着一个内存地址。无论怎么拷贝,**变量储存的内存地址是没有变**的,所以通过`*`运算符仍然能直接对相应数据进行赋值。 ### 改变原结构体的指针变量指向 如果要在`test`函数中改变原结构体中指针变量的指向,就需要把原结构体的地址传入函数: ```c int test(struct Hello *testStruct) { printf(" [test]testStruct-Ptr: %p \n", ++testStruct->ptr); *testStruct->ptr = 2; return 1; } int main() { int *testPtr = (int *) calloc(4, sizeof(int)); struct Hello testStruct = { .num=5, .ptr=testPtr }; printf(" [main]testStruct-Ptr: %p \n\t*ptr=%d\n", testStruct.ptr, *testStruct.ptr); test(&testStruct); printf(" [main]testStruct-Ptr: %p \n\t*ptr=%d\n", testStruct.ptr, *testStruct.ptr); free(testPtr); return 0; } ``` 输出: ``` [main]testStruct-Ptr: 00000000001A1420 *ptr=0 [test]testStruct-Ptr: 00000000001A1424 [main]testStruct-Ptr: 00000000001A1424 *ptr=2 ``` 可以看到通过在函数内通过地址访问到对应结构体,能直接修改结构体中指针变量的指向。这个例子中通过自增运算符让指针变量指向的内存地址后移了一个`int`的长度。 > 通过指针访问结构体时使用**箭头运算符**`->` 获取属性。 ## 最近摔了一跤的地方 ### 被自己绕进去 最近写的一个小工具中有个自动扩大堆内存以容纳数据的需求,最开始我写成了这个样: ```c #include <stdio.h> #include <stdlib.h> #include <string.h> #define SIZE_PER_ALLOC 10 void extend(int *arr, int arrPtr, int *arrMax) { *arrMax += SIZE_PER_ALLOC; // 新分配这么多 arr = (int *) realloc(arr, (*arrMax) * sizeof(int)); memset(arr + arrPtr, 0, SIZE_PER_ALLOC * sizeof(int)); // 将新分配的部分初始化为0 } int main() { int i; int arrPtr = 0; int arrMax = 10; // 当前最多能容纳多少元素 int *flexible = (int *) calloc(arrMax, sizeof(int)); for (i = 0; i < 95; i++) { // 模拟push 95 个元素 flexible[arrPtr++] = i + 1; if (arrPtr >= arrMax) // 数组要容纳不下了,多分配一点 extend(flexible, arrPtr, &arrMax); } for (i = 0; i < 95; i++) // 打印所有元素 printf("%d ", flexible[i]); return 0; } ``` 本来预期是`95`个元素能顺利推入`flexible`这个“数组”,“数组”大小也会扩展为足够容纳`100`个元素。 然而程序运行未半而中道崩殂,这个例子中系统送来了`SIGSEGV`信号(调试器Debugger可能会显示因为`SIGTRAP`而终止进程)。根据上面写到的`SIGSEGV`产生原因,很明显我又访问到了未分配给进程的无效内存(产生了野指针)。 ### 为什么呐 观察一下函数的声明和调用时的传参: ```c void extend(int *arr, int arrPtr, int *arrMax); ``` ```c extend(flexible, arrPtr, &arrMax); ``` 后面的`arrPtr`整型变量参数接受`main`函数传入的`arrPtr`的值,用以确定当前“数组”的下标指向哪;而`arrMax`指针变量参数接受`main`函数传入的`arrMax`的地址,用以修改当前“数组”的大小。这两个参数没有引发任何问题。 很明显了,问题就出现在`arr`参数这儿! 实际上,当我将指针变量`flexible`作为参数传入时也只是传入了一个**地址**,而不是指针本身。因此在`extend`里调用`realloc`重分配内存后,**新的内存块**的地址会被赋给**局部**变量`arr`,此时外部的指针变量`flexible`的指向**没有任何改变**。 ------- `realloc()` 在重分配内存时,会尽量在原有的内存块上进行扩展/缩减,尽量不移动数据,这种时候返回的地址**和原来一样**。 但是一旦**原有内存块及其后方相邻的空闲内存不足以提供分配**,就会找到一块足够大的新内存块,并将原内存块的数据“**移动**”过去,此时`realloc()`返回的地址**和原来的不同**,并且**原来的地址**所代表的内存**已经被回收**。 ------- 也就是当`realloc()`移动了**数据在内存中的位置**时,外面的`flexible`指针变量还**指向着原来的地址**,原来地址代表的内存已经被回收了。 因此,`extend`函数调用结束后的`flexible`指针变量就变成了**野指针**,指向了一片无效内存,所以试图访问这片内存时,就导致了`SIGSEGV`异常。 ------- ### 怎么解决 根本原因在于我传入函数的是**一个地址**而不是**指针变量本身**,所以把**指针变量的地址**传入就能解决了! ```c #include <stdlib.h> #include <string.h> #include <stdio.h> #define SIZE_PER_ALLOC 10 void extend(int **arr, int arrPtr, int *arrMax) { *arrMax += SIZE_PER_ALLOC; // 多分配这么多 *arr = (int *) realloc(*arr, (*arrMax) * sizeof(int)); memset(*arr + arrPtr, 0, SIZE_PER_ALLOC * sizeof(int)); // 将新分配的部分初始化为0 } int main() { int i; int arrPtr = 0; int arrMax = 10; // 当前最多能容纳多少元素 int *flexible = (int *) calloc(arrMax, sizeof(int)); for (i = 0; i < 95; i++) { // 模拟push 95 个元素 flexible[arrPtr++] = i + 1; if (arrPtr >= arrMax) // 数组要容纳不下了,多分配一点 extend(&flexible, arrPtr, &arrMax); } for (i = 0; i < 95; i++) // 打印所有元素 printf("%d ", flexible[i]); free(flexible); return 0; } ``` 因为**二级指针变量**存放一级指针变量的地址,所以在声明形参`arr`的时候需要声明为二级指针: ```c void extend(int **arr, int arrPtr, int *arrMax); ``` 调用函数的时候,将指针变量`flexible`的**地址**传入: ```c extend(&flexible, arrPtr, &arrMax); ``` 接下来在函数`extend`内部通过`*`运算符访问指针变量`flexible`以做出修改即可。 这样一来程序就能成功运行完成了,输出: ``` 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 ``` ### 教训 说到最开始遇到这个问题的时候,我真的是找了半天都没找着,因为push元素和数组扩展我分开写在了两个源文件中,而这个部分又涉及到其他内存分配的代码。我甚至查了`realloc`是怎么导致`SIGSEGV`的,结果就...打断点调试了好多次才发现是这个问题。 涉及到指针变量和内存操作的时候,一定要牢记**指针变量的指向**,也一定要**步步谨慎**,不然一旦出现问题,很可能难以定位。 ## 总结 C语言的内存管理很灵活,但正是因为灵活,在编写相关操作的时候要十分小心。 在接触这类和底层接壤的编程语言时对基础知识的要求真的很高...感觉咱还有超长的路要走呢。 那么就是这样,感谢你看到这里,也希望这篇笔记能对你有些帮助!再会~ ![bye-2022-05-10](https://assets.xbottle.top/img/bye-2022-05-10.png) ## 相关文章 * [【C语言】二十二步了解函数栈帧(压栈、传参、返回、弹栈)](https://blog.csdn.net/iluo12/article/details/122557685) * [逆向基础笔记:汇编二维数组 - 52pojie论坛](https://www.52pojie.cn/thread-1384913-1-1.html) <--- 这个笔记系列超棒的说! <script>/* bblock.s(); */</script> {(PostContentEnd)} {(PostTag)}学习,小记,C{(PostTagEnd)} {(PostID)}283{(PostIDEnd)} {(PostCover)}none{(PostCoverEnd)} {(PubTime)}1657800821377{(PubTimeEnd)} {(EditTime)}1657800821377{(EditTimeEnd)}
{(PageType)}post.otp.html{(PageTypeEnd)}