惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
SegmentFault 最新的问题
J
Java Code Geeks
V
V2EX
Blog — PlanetScale
Blog — PlanetScale
博客园 - 司徒正美
Hugging Face - Blog
Hugging Face - Blog
F
Fortinet All Blogs
aimingoo的专栏
aimingoo的专栏
B
Blog
A
About on SuperTechFans
有赞技术团队
有赞技术团队
月光博客
月光博客
Microsoft Azure Blog
Microsoft Azure Blog
阮一峰的网络日志
阮一峰的网络日志
腾讯CDC
美团技术团队
大猫的无限游戏
大猫的无限游戏
爱范儿
爱范儿
N
Netflix TechBlog - Medium
C
Check Point Blog
Recent Announcements
Recent Announcements
博客园 - Franky
博客园 - 叶小钗
T
Tailwind CSS Blog

机核

游戏性能旗舰最强之选,一加 Ace 6 至尊版国补到手价2999元起 6元钱自己更换电动车刹车线 《生化危机9:安魂曲》编剧Haris Orkin专访 摸金游戏?音乐游戏!暗区新赛季这把能弹的琴有何来历? 好评国产武侠SRPG《息风谷战略》免费DLC现已推出 | 机核 GCORES 碎片 《生化危机:安魂曲》将于5月实装首个免费更新内容 | 机核 GCORES 新锐东方游戏,谱写世界新章! 沉浸式恋爱视觉小说游戏《心象演算》免费试玩版现已正式上线 | 机核 GCORES 互动影游《代号三国:龙起》上线!穿越三国与曹操并肩、与佳人同行、与权谋博弈! 《老头收集梦想生活》,游戏酒桌会6,录音笔VOL.689 | 机核 GCORES 破界·共生——《白日梦:无限世界》五大核心游戏特点解析 价格已到史低,锐龙5 9600X/锐龙7 9700X正适合抄底 时间循环之旅即刻启程!《归环》一周目测试今日开启 热门在线韩游变魂游,洛奇衍生作能否打破“花瓶”魔咒? LG UltraGear evo 全新高端显示器系列:当“5K”遇见“AI”,不止强大,更懂热爱 反套路三国互动影游《代号三国:龙起》今日上线! 愿望单登记人数突破10万!备受瞩目的“女儿养成游戏” 《梦幻魔法公主》今日于Steam平台上线!限时八折优惠中 《黑神话:悟空》全球音乐会2026巡演将于4月29日12时开票 | 机核 GCORES “Snowguelike”生存肉鸽挖矿新作《蛙穿雪境》公布发售日期,5月7日正式上线 | 机核 GCORES 重塑移动办公、AI创作新境!全新华硕灵耀Air系列、ProArt 骁龙版震撼首发,创芯未来 均分88:《Saros》媒体评分汇总 | 机核 GCORES 上海烛龙公布合作遗迹探险游戏《吉时已到》首支预告片 | 机核 GCORES 首个独立游戏《萝薇日记》已上线Steam! 烛龙新IP《吉时已到》首曝,打造国内首款中式合作遗迹探险游戏 喜加一:《暗黑破坏神Ⅳ》国服现已开启限时免费领取本体活动 | 机核 GCORES 新版《生化危机》电影定于9月18日上映,官方网站现已上线 | 机核 GCORES 《冲就完事模拟器2》“星球大战”联动DLC正式宣布 | 机核 GCORES SteamController将于5月4日发售,售价99美元 | 机核 GCORES 事已至此,内存这么用也算省钱了,“2+1”非对称双通道应用实测
通过C语言入门计算机科学6:内存管理
韩大 · 2024-11-07 · via 机核

上一篇我们介绍了指针的运算,这个操作实际上隐藏着相当大的危险性。因为通过指针的运算,你可以让指针指向任何一块内存,然后去读写里面的信息。为了了解这些危险性,我们需要知道 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 个字节开始变得奇怪了。

由于函数的局部变量所在的内存区域,是会随着函数的调用和返回被分配和回收,进行重复使用的,所以记录下这块内存的地址,留着在上层调用者函数中使用,就是一种明显的“错误”,因此编译器也会“警告”我们。

那么,问题来了,如果我要写一个函数,返回一个数组的内容,到底应该怎么做才是对的呢?答案有两个:

  1. 使用输出参数

  2. 使用堆

指针越界

上面说的第一种方法“使用输出参数”,其原理就是:在调用函数前,先把需要返回的数据所存放的内存准备好,然后把这块内存指针传入函数,让函数把内容写到这个指针指向的内存上。这有点像我们去食堂打饭,需要先拿个盘子,让服务员把食物放到我们选的盘子里,然后我们再从这个盘子里面拿食物。代码的示例如下:

#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结尾的字符数组。