上一篇我们介绍了指针的运算,这个操作实际上隐藏着相当大的危险性。因为通过指针的运算,你可以让指针指向任何一块内存,然后去读写里面的信息。为了了解这些危险性,我们需要知道 C 语言大概是如何使用内存的。
假如我们需要编写一个C语言函数,用来返回一个“字符串”,那么我们应该如何写呢?根据之前的知识,C字符串就是一个字符数组,我们似乎返回一个字符数组的指针就可以了。
#include <stdio.h>
char *test()
{
char arr[12] = {'a', 'b', 'c', 'd', 'e', 0};
return arr;
}
int main()
{
char *p = test();
printf("%s\n", p);
}
mem.c:7:12: warning: address of stack memory associated with local variable 'arr' returned [-Wreturn-stack-address]
7 | return arr;
| ^~~
1 warning generated.
abcd,s薌?
看起来字符串的前 4 个字节的内容是对的,但是后面的内容就看不懂了,显然这么写这个程序是有问题的。我们先来看看那条编译报警信息,它的意思是:在代码的第7行:返回了栈(stack)内存的地址,这个地址是一个局部变量 arr 的。这个“栈内存”又是啥意思呢?为了知道这里的意思,我们需要了解一下C语言在调用函数时,是怎么使用内存的。
C语言在每个函数开始调用时,都会分配一块内存,用于存放函数的输入参数、返回值、程序返回指令地址(危险)、函数内局部变量等。函数里面调用新的函数,内存会“向下”新增一块区域,放上被调用的函数参数、返回值、返回指令地址、局部变量。随着函数的层层嵌套调用,这个内存块的使用会持续的“往下”延申使用。
当一个函数调用结束,进行返回的时候,此函数所使用的内存块会被“释放”。上层调用者函数如果再次调用另外一个函数,刚才被“释放”的这块内存又会被新的函数所使用。——这样一块内存,随着函数的调用、返回而不断变化使用长度,就被称为“栈内存”(stack memory)。存放在“栈内存”中的数据,随着程序运行的情况,会不断的变化。同一个地址的内存,可能在不同时候,被不同的函数写上各自的内容。这就是上面那个例程出问题的原因。
当 main() 调用 test() 的时候,计算机分配了一块内存供 test() 使用,test() 的局部变量 arr 数组也存放其中。当 test() 函数完成运行返回时,这块内存就被计算机“回收”了,准备用作其他的功能。然而,曾经使用这块已经被回收的内存的变量 arr 的地址,却被 main() 的 p 指针变量记录了起来。当 main() 函数调用 printf() 函数时,之前被 test() 使用的内存,又被分配给 printf() 使用。而 printf() 在运行的时候,并不会在意之前 test() 的 arr 变量用了什么位置,直接就按自己的需要写入这块内存。因此 arr 曾经使用的内存,从第 5 个字节处开始,刚好被 printf() 给“覆盖”了,写上了他自己的数据(我们光从代码看,并没法预测是什么内容)。因此我们看到, arr 变量的内容,就从第 5 个字节开始变得奇怪了。
由于函数的局部变量所在的内存区域,是会随着函数的调用和返回被分配和回收,进行重复使用的,所以记录下这块内存的地址,留着在上层调用者函数中使用,就是一种明显的“错误”,因此编译器也会“警告”我们。
那么,问题来了,如果我要写一个函数,返回一个数组的内容,到底应该怎么做才是对的呢?答案有两个:
使用输出参数
使用堆
上面说的第一种方法“使用输出参数”,其原理就是:在调用函数前,先把需要返回的数据所存放的内存准备好,然后把这块内存指针传入函数,让函数把内容写到这个指针指向的内存上。这有点像我们去食堂打饭,需要先拿个盘子,让服务员把食物放到我们选的盘子里,然后我们再从这个盘子里面拿食物。代码的示例如下:
#include <stdio.h>
void test(char *output)
{
char arr[12] = {'a', 'b', 'c', 'd', 'e', 0}; // 字符常量
for (int i = 0; i < 12; i++)
{
output[i] = arr[i];
}
}
int main()
{
char p[12] = {0};
test(p);
printf("%s\n", p);
}
程序运行显示:abcde
在上面的例子中,test() 的参数 char *output 就是所谓的“输出参数”,而这个指针参数所指向的内存,就是上面举例的那个“盘子”。在 test() 函数中,我们通过一个循环对输出结果内存进行赋值,当 test() 返回后,main 的 p 数组里面就存好了函数的结果了。然而,上面的这个写法,其实是有一些漏洞甚至安全隐患的,我们稍微修改一下上面的代码。
#include <stdio.h>
void test(char *output)
{
char arr[12] = {'a', 'b', 'c', 'd', 'e', 0}; // 字符常量
for (int i = 0; i < 12; i++)
{
output[i] = arr[i];
}
}
int main()
{
char q[12] = {'x', 'y', 'z', 'r', 's', 't', 'u', 'v', 'w', 0};
char p[3] = {0};
test(p);
printf("p: %s\n", p);
printf("q: %s\n", q);
}
p: abcde
q: de
这个结果比较奇怪的有两个地方,一个是我们的数组 p 命名只有 3 个字节的长度,打印出来却有 6 个字节的内容(abcde\0);第二个问题是,我们定义的数组 q 本来没有参与 test() 的调用,最后结果从 "xyzrstuvw" 变成了 "de"。造成这个问题的原因,是因为在 test() 函数内部,并没有管输出参数 output,或者叫 p 数组,它的长度是不是有 12 个元素,而是直接去赋值了。当 test() 的 for 循环,运行到 i>2 的时候,其实已经超过了 p 数组的内存限定空间了,这个问题叫做“指针越界”。在 p 数组限定的长度以外的内存,到底是用来做什么的,一般是难以预料的。在这个例子里,p 数组的内存后面,就是数组 q 的内存,所以这次越界,就把 q 数组的内容给写错了。
从这个例子来看,指针越界造成了数组 q 被意料之外的改写了,这当然会造成程序运行的错误(bug),但其实指针越界有可能造成更危险的问题:在栈内存中,除了存放了函数中用到的局部变量以外,还有很多其他的重要数据,包括当前函数返回之后,应该执行的程序的地址信息。如果有个程序,会从网络或者其他外部输入数据,这些数据又被写入内存的时候越界了,那么这些数据就有可能被写入栈内存里存放了程序执行地址的内存。这样就提供了一个“漏洞”,让恶意使用者,通过构造专门的输入数据,改写程序执行地址——当前函数返回后,就会去执行这个恶意写入的程序地址,从而让计算机去执行开发者预料之外的代码。这种内存越界的 bug,也是很多程序的安全漏洞的原因。具体更多关于这类漏洞的知识,本文不作过多介绍,只是告诉大家这类漏洞非常容易出现。
最后,关于输出参数的正确写法,应该是如下例程:对于输出参数,除了有指针外,还需要再输入一个参数来表示输出参数内存长度。并且用返回值来告诉调用者,输出的内容的具体长度,避免调用者错误使用。
#include <stdio.h>
int test(char *output, int len)
{
char arr[12] = {'a', 'b', 'c', 'd', 'e', 0}; // 字符常量
int size = len > 12 ? 12 : len; // 计算应该返回的长度
for (int i = 0; i < size; i++)
{
output[i] = arr[i];
}
return size;
}
int main()
{
char q[12] = {'x', 'y', 'z', 'r', 's', 't', 'u', 'v', 'w', 0};
char p[3] = {0};
int len = test(p, 3);
printf("p: %.*s\n", len, p);
printf("q: %s\n", q);
}
p: abc
q: xyzrstuvw
上面的程序中 test() 增加了参数 len 和返回值 int,在循环赋值之前,先判断了一下输出参数所指向的内存(缓冲区)的长度,如果小于返回数值的长度,就最多写满这个缓冲区的长度,不会越界写到其他地方去,然后返回写了的数据长度给调用者。
在 main() 中,我们把输出参数内存的长度 3 传入 test(),然后从 test() 的返回值获得输出缓冲区中数据的长度,然后用了 printf() 的一个特殊占位符 %.*s 来打印字符串,这个占位符的意思是,根据一个长度和一个指针,来打印最多这个长度的数据,而不是一直读到碰到内存中的字节数字 0 为止。这样就避免了打印超过预定数据长度以外的数据。
输出参数需要在调用前,就要预设返回结果的内存长度,但是如果没法预测,是不是有其他方法也能完成任务呢?也是有的,就是使用堆内存,动态的申请内存使用。
所谓堆(heap)内存,就是一类不通过直接写变量名来使用的内存。而是通过一对特殊的函数,来分配和回收的内存。这些内存不会使用上面所说的栈(stack)内存的空间。同样是返回一个字符数组,我们可以用堆内存如下实现:
#include <stdio.h>
#include <stdlib.h>
char *test()
{
char arr[12] = {'a', 'b', 'c', 'd', 'e', 0}; // 字符常量
char *output = malloc(12);
for (int i = 0; i < 12; i++)
{
output[i] = arr[i];
}
return output;
}
int main()
{
char *p = test();
printf("p: %s\n", p);
free(p);
}
程序输出:abcde
在上面的例程中,出现了两个新的函数 malloc() 和 free(),这两个函数是来自 C 的标准库的,所以需要 #include <stdlib.h>。其中 malloc() 的参数表示要申请多少个字节的内存,返回值就是申请后的内存指针。注意申请多少就用多少,如果写入数据的时候,超过了申请内存的长度,有可能会把程序里其他变量给写“坏”了。而 free() 是释放通过 malloc() 申请的内存,传入指针即可,至于要释放的内存有多长,其实是存在申请的内存指针前面的某个位置(头部内存),所以千万要记得释放 malloc() 返回的数值,而不是随便在堆内存中选择一个地址用来释放。
在堆内存上分配的空间,C 语言是不会像栈内存一样自动跟随函数的调用来释放的。需要程序员主动调用 free() 进行释放,否则程序就会占用内存越来越多,形成了所谓的“内存泄漏”。当然这种问题在实际程序中非常常见,所以我们偶尔也会碰到,一个程序运行的时候,占内存越来越多,电脑越来越卡的情况。同样的,对于释放过的堆内存重复释放,也可能存在问题,通常会导致进程在这里退出(崩溃),或者是导致下一次 malloc() 的时候,错误的使用了被其他代码使用的内存而产生问题。所以,确保堆内存的 malloc() 和 free() 能正确的,一对一的运行,是一个非常考验程序员的需求。这也是后来很多编程语言,都设计了所谓内存自动管理的机制,避免让程序员来处理这个事情。
当我们掌握了指针,了解了栈内存和堆内存,我们才能比较完整的了解 C 语言的能力。下一篇,我们尝试用之前说的这些能力,做一点真正有用的东西,譬如说用起来没有那么麻烦的“字符串”,而不是以0结尾的字符数组。
评论区
共 9 条评论热门最新