标签 内存 下的文章

今天的帖子来自于最近的 Go 语言的一次小测试,观察下面的测试基础片段 [1]

func BenchmarkSortStrings(b *testing.B) {
        s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
                sort.Strings(s)
        }
}

sort.Stringssort.StringSlice(s) 的便捷包装器,sort.Strings 在原地对输入进行排序,因此不会分配内存(或至少 43% 回答此问题的 Twitter 用户是这么认为的)。然而,至少在 Go 的最近版本中,基准测试的每次迭代都会导致一次堆分配。为什么会是这种情况?

正如所有 Go 程序员应该知道的那样,接口是以 双词结构 实现的。每个接口值包含一个字段,其中保存接口内容的类型,以及指向接口内容的指针。 [2]

在 Go 语言伪代码中,一个接口可能是这样的:

type interface struct {
        // the ordinal number for the type of the value
        // assigned to the interface 
        type uintptr

        // (usually) a pointer to the value assigned to
        // the interface
        data uintptr
}

interface.data 可以容纳一个机器字(在大多数情况下为 8 个字节),但一个 []string 却需要 24 个字节:一个字用于指向切片的底层数组;一个字用于存储切片的长度;另一个字用于存储底层数组的剩余容量。那么,Go 是如何将 24 个字节装入个 8 个字节的呢?通过编程中最古老的技巧,即间接引用。一个 []string,即 s,需要 24 个字节;但 *[]string —— 即指向字符串切片的指针,只需要 8 个字节。

逃逸到堆

为了让示例更加明确,以下是重新编写的基准测试,不使用 sort.Strings 辅助函数:

func BenchmarkSortStrings(b *testing.B) {
        s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"}
        b.ReportAllocs()
        for i := 0; i < b.N; i++ {
                var ss sort.StringSlice = s
                var si sort.Interface = ss // allocation
                sort.Sort(si)
        }
}

为了让接口正常运行,编译器将赋值重写为 var si sort.Interface = &ss,即 ss 的地址分配给接口值。 [3] 我们现在有这么一种情况:出现一个持有指向 ss 的指针的接口值。它指向哪里?还有 ss 存储在哪个内存位置?

似乎 ss 被移动到了堆上,这也同时导致了基准测试报告中的分配:

Total:    296.01MB   296.01MB (flat, cum) 99.66%
      8            .          .           func BenchmarkSortStrings(b *testing.B) { 
      9            .          .               s := []string{"heart", "lungs", "brain", "kidneys", "pancreas"} 
     10            .          .               b.ReportAllocs() 
     11            .          .               for i := 0; i < b.N; i++ { 
     12            .          .                   var ss sort.StringSlice = s 
     13     296.01MB   296.01MB                   var si sort.Interface = ss // allocation 
     14            .          .                   sort.Sort(si) 
     15            .          .               } 
     16            .          .           } 

发生这种分配是因为编译器当前无法确认 sssi 生存期更长。Go 编译器开发人员对此的普遍态度是,觉得 这个问题改进的余地,不过我们另找时间再议。事实上,ss 就是被分配到了堆上。因此,问题变成了:每次迭代会分配多少个字节?为什么不去询问 testing 包呢?

% go test -bench=. sort_test.go
goos: darwin
goarch: amd64
cpu: Intel(R) Core(TM) i7-5650U CPU @ 2.20GHz
BenchmarkSortStrings-4          12591951                91.36 ns/op           24 B/op          1 allocs/op
PASS
ok      command-line-arguments  1.260s

可以看到,在 amd 64 平台的 Go 1.16 beta1 版本上,每次操作会分配 24 字节。 [4] 然而,在同一平台先前的 Go 版本中,每次操作则消耗了 32 字节。

% go1.15 test -bench=. sort_test.go
goos: darwin
goarch: amd64
BenchmarkSortStrings-4          11453016                96.4 ns/op            32 B/op          1 allocs/op
PASS
ok      command-line-arguments  1.225s

这引出了本文的主题,即 Go 1.16 版本中即将推出的一项便利改进。不过在讨论这个内容之前,我需要聊聊 “ 尺寸类别 size class ”。

尺寸类别

在解释什么是 “ 尺寸类别 size class ” 之前,我们先考虑个问题,理论上的 Go 语言在运行时是如何在其堆上分配 24 字节的。有一个简单的方法:追踪目前为止已分配到的所有内存的动向——利用指向堆上最后分配的字节的指针。分配 24 字节,堆指针就会增加 24,然后将前一个值返回给调用函数。只要写入的请求 24 字节的代码不超出该标记的范围,这种机制就没有额外开销。不过,现实情况下,内存分配器不仅要分配内存,有时还得释放内存。

最终,Go 语言程序在运行时将释放这些 24 字节,但从运行的视角来看,它只知道它给调用者的开始地址。它不知道从该地址起始之后又分配了多少字节。为了允许释放内存,我们假设的 Go 语言程序运行时分配器必须记录堆上每个分配的长度值。那么这些长度值的分配存储在何处?当然是在堆上。

在我们的设想中,当程序运行需要分配内存的时候,它可以请求稍微多一点,并把它用来存储请求的数量。而对于我们的切片示例而言,当我们请求 24 字节时,实际上会消耗 24 字节加上存储数字 24 的一些开销。这些开销有多大?事实上,实际上的最小开销量是一个字。 [5]

用来记录 24 字节分配的开销将是 8 字节。25% 不是很大,但也不算糟糕,随着分配的大小增加,开销将变得微不足道。然而,如果我们只想在堆上存储一个字节,会发生什么?开销将是请求数据量的 8 倍!是否有一种更高效的方式在堆上分配少量内存?

与其在每个分配旁边存储长度,不如将相同大小的内容存储在一起,这个主意如何?如果所有的 24 字节的内容都存储在一起,那么运行时会自动获取它们的大小。运行时所需要的是一个单一的位,指示 24 字节区域是否在使用中。在 Go 语言中,这些区域被称为 Size Classes,因为相同大小的所有内容都会存储在一起(类似学校班级,所有学生都按同一年级分班,而不是 C++ 中的类)。当运行时需要分配少量内存时,它会使用能够容纳该分配的最小的尺寸类别。

无限制的尺寸类别

现在我们知道尺寸类别是如何工作的了,那么问题又来了,它们存储在哪里?和我们想的一样,尺寸类别的内存来自堆。为了最小化开销,运行时会从堆上分配较大的内存块(通常是系统页面大小的倍数),然后将该空间用于单个大小的分配。不过,这里存在一个问题————

将大块区域用于存储同一大小的事物的模式很好用 [6] ,如果分配大小的数量是固定的,最好是少数几个。那么在通用语言中,程序可以要求运行时以任何大小分配内存 [7]

例如,想象一下向运行时请求 9 字节。9 字节是一个不常见的大小,因此可能需要一个新的尺寸类别来存储 9 字节大小的物品。因为 9 字节大小的物品不常见,所以分配的其余部分(通常为 4KB 或更多)可能会被浪费。由于尺寸类别的集合是固定的,如果没有精确匹配的 size class 可用,分配将并入到下一个尺寸类别。在我们的示例中,9 字节可能会在 12 字节的尺寸类别中分配。未使用的 3 字节的开销要比几乎未使用的整个尺寸类别分配好。

总结一下

这是谜题的最后一块拼图。Go 1.15 版本没有 24 字节的尺寸类别,因此 ss 的堆分配是在 32 字节的尺寸类别中分配的。由于 Martin Möhrmann 的工作,Go 1.16 版本有一个 24 字节的尺寸类别,非常适合分配给接口的切片值。

相关文章

  1. 我在 Devfest 2017年西伯利亚大会谈 Go 语言
  2. 如果对齐的内存写操作是原子性的,为什么我们还需要 sync/atomic 包呢?
  3. 为你的树莓派创建一个真实的串行控制台
  4. 为什么 Go 语言线程的栈是无限制的?

(题图:MJ/01d5fe46-778f-48fe-9481-162f4d0289dc)


  1. 这不是正确的对排序函数进行基准测试的方式,因为在第一次迭代之后,输入已经排序。但这又是另外一个话题了。 ↩︎
  2. 此语句的准确性取决于所使用的 Go 版本。例如,Go 1.15 版本添加了直接将一些 整数存储在接口值 中的功能,从而节省了分配和间接性。然而,对于大多数值来说,如果它不是指针类型,它的地址将被取出并存储在接口值中。 ↩︎
  3. 编译器在接口值的类型字段中跟踪了这种手法,因此它记住了分配给 si 的类型是 sort.StringSlice 而不是 *sort.StringSlice↩︎
  4. 在 32 位平台上,这个数字减半,但我们不再关注它↩︎
  5. 如果你准备限制分配为 4G 或者可能是 64KB,你可以使用较少内存来存储分配的尺寸,但实际上使用小于一个字来存储长度标头的节省会受到填充的影响。 ↩︎
  6. 将相同大小的物品存储在一起也是一种有效的对抗碎片化的策略。 ↩︎
  7. 这并不是一个不切实际的设想,字符串有各种形状和大小,生成以前未见过的大小的字符串可能就像附加空格一样简单。 ↩︎

via: https://dave.cheney.net/2021/01/05/a-few-bytes-here-a-few-there-pretty-soon-youre-talking-real-memory

作者:Dave Cheney 选题:lujun9972 译者:Drwhooooo 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

内存 RowHammer 保护措施被新的方法击破

2015 年,研究人员发现了一种被称之为 RowHammer 的攻击方式,当一个用户级应用程序反复访问 DDR 内存芯片的特定区域,可以导致比特翻转。 比特翻转 Bitflips 是指储存在电子设备上的个别比特发生翻转的事件,比如从 0 变为 1 或反之。各个内存厂商在后续产品中纷纷加入了抵御 RowHammer 攻击的保护措施,主要方法是限制程序在给定时间内打开和关闭目标芯片区域的次数。现在,研究人员发明了被称之为 RowPress 的新方法,能在部署了最新的 RowHammer 保护措施的 DRAM 中诱发比特翻转。RowPress 不是反复“锤击”选定区域,而是让其保持更长的打开时间。将这一方法和 RowHammer 结合可进一步提高这种攻击的威力。研究人员表示,“这本身并不是一种攻击。它只是表明比特翻转是可能的,而且很多,这很容易成为攻击的基础”。

消息来源:Ars Technica
老王点评:如果类似的方法层出不穷,那或许是该考虑新的内存技术了。

一种新的路灯技术可以避免光污染对天文望远镜的影响

光污染对天文学的威胁与日俱增,现代望远镜高度敏感,能感受到距离 50 甚至 200 公里的城市室外照明的影响。一项研究发现,星星正在以平均每年 10% 的速度从天空中“消失”。一项名为 “DarkSkyProtector” 的技术可以“让望远镜看到几乎是一片黑暗的天空”。该方案依赖于三个组件:一个能让 LED 灯以人眼无法察觉的极高频率闪烁的简单装置、一个全球定位系统接收器,以及望远镜摄像头上一个能与 LED 灯同步闪烁的特殊设计的快门。全球定位系统技术引导望远镜的快门只在 LED 灯熄灭的瞬间打开。该技术可以将天文图像中不必要的天空辉光减少 94%。

消息来源:Space
老王点评:技术的进步可以解决技术带来的副作用。另外,是不是可以顺便把马斯克的星链轨迹也一起消除了。

Debian 继续支持 /usr 合并

Debian 12 的目标是采用与其他 Linux 发行版类似的合并 /usr 文件系统布局,即 /{bin,sbin,lib}/ 目录成为 /usr/{bin,sbin,lib}/ 的符号链接。但 Debian 技术委员会在今年早些时候决定暂停合并 /usr 文件。不过,随着 Debian 12 正式发布几个月后,这一暂停决定被废止了。Debian 的合并 /usr 动作有望在两年后的 Debian 13 发布时全部完成。

消息来源:Phoronix
老王点评:这就是历史遗留问题,依我看,其实大可不必这么小心翼翼。

PyTorch 和 Triton 正在打破英伟达 CUDA 的垄断

大部分机器学习软件开发框架严重依赖于英伟达 CUDA,并在英伟达 GPU 上表现最佳。但随着 PyTorch 2.0 和 OpenAI Triton 的到来,英伟达 CUDA 对机器学习的垄断地位正逐渐瓦解。即将到来的 PyTorch 2.0 在英伟达 A100 上的训练性能提升了 86%,在 CPU 上的推理性能提升了 26%。而且这种优势可以扩展到 AMD、英特尔、特斯拉、谷歌、亚马逊、微软等等各个公司生产的 GPU 和 AI 加速器上。而 Triton 能让高级语言达到与使用低级语言相当的性能,提高了可用性。

消息来源:Semi Analysis
老王点评:又一次证明了开源胜过闭源,无论闭源的护城河有多深。

Basecamp 因巨额账单退出云计算

Basecamp 的 CTO,也是 Ruby On Rails 的创建者 DHH 介绍了让该公司退出云计算的巨额账单。其 2022 年的费用为 320 万美元,绝大部分都花在了 AWS 上,其中 S3 花费 90 万美元,RDS 47 万美元,OpenSearch 52 万美元,Elasticache 12 万美元。即便如此,也是经过大量工作才减少到这一费用的,该团队不但运行了成本检查计划,还就作为私人协议就长期使用达成了协议。DHH 还用戴尔服务器的三年均摊成本做了对比。

消息来源:The Register
老王点评:云计算确实有很多好处,但也可能是个让你花钱上瘾的无底洞。

使用了 25 年的笔记本内存规范 SO-DIMM 将被替换

制定内存标准的组织 JEDEC 正在制定新规范,以取代已经使用了 25 年的 SO-DIMM 规范。新的 CAMM 标准将基于戴尔公司的设计,目标是在 2023 年下半年完成 1.0 规范,到明年推出基于 CAMM 的系统。现有的 SO-DIMM 在 DDR5/6400 时已经遇到了“困境”,CAMM 的主要吸引力在于它可以实现更高的内存密度,同时还可以扩展到更高的时钟速度。

消息来源:PC World
老王点评:这是不是代表以后笔记本会需要更多内存?

新的“行锤”攻击打破了 DDR4 内存保护

行锤 Rowhammer ”攻击是通过每秒数百万次访问(或者说锤打)脆弱芯片内的物理行,导致相邻行的比特翻转,这意味着 1 变成 0,0 变成了 1。一个没有特权的应用程序可以通过每秒数百万次以特定模式访问 DDR 内存的“行锤”来破坏内存中的数据,从而 获得系统特权。这个漏洞已经在 2015 年被发现,对 DDR3 造成了严重影响后,在 DDR4 中有所缓解。以前的“行锤”攻击都是以统一的模式锤击行,这些“攻击者”行被访问的次数相同。而 新发表的研究 中使用了非均匀模式,以不同的频率访问两个或更多的攻击者行。结果是:在一个测试池中,所有 40 个随机选择的 DIMM 都经历了比特翻转。

老王点评:如果这个攻击技术被武器化,那所有使用 DDR 内存的计算机都处于被攻击的阴影笼罩之下。

英特尔 4004 微处理器推出 50 周年

1971 年 11 月 15 日 英特尔推出的 4004 微处理器仅使用了 2,300 个晶体管,一次可在芯片中运行四位数据。这种半英寸长的矩形集成电路的时钟速度为 750 kHz,每秒可进行约 92,000 次运算。英特尔于 1972 年推出有 3,500 个晶体管的八位的 8008;1978 年推出有 29,000 个晶体管的十六位的 8086,每秒可进行 710,000 次运算。IBM 将下一代的英特尔 8088 用于其首款 PC。而如今,苹果新的 M1 Max 处理器有 570 亿个晶体管,每秒可执行 10.4 万亿次浮点运算。这意味着计算机能力在 50 年内至少提高了 10 亿倍。

老王点评:50 年对于人类历史来说是短短一瞬间,但是这小小的芯片却整个改变了世界。

因文字太难输入,Facebook Messenger 五成语音流量来自柬埔寨

2018 年 Facebook 工程师注意到了一个令他们不解的现象:Messenger 近五成语音流量 来自柬埔寨。高棉语有 74 个字符,比大部分文字都多。高棉语在每个键位都有两个字符,需要在两个按键层反复切换。因此在柬埔寨人们大量使用语音,虽然后来高棉语输入法也得到了改善。当然,语音沟通的不便之处就是没法快速搜索,只能根据语音条长短大致记忆。

老王点评:还好我们的中文输入法比较好,或者说拼音普及的好,要不中文输入更难。

smem 命令允许你快速查看你的网页应用的内存使用情况。

 title=

在我的编程工作中,我经常需要了解网页应用的内存使用情况。在深入研究细节和浏览器剖析工具之前,一个粗略的估计通常就足够了。

为了了解 Linux 或 macOS 上的内存使用情况,人们通常使用 tophtop。我很想看到一个单一的数字:一个进程占用了多少内存。但这些工具所显示的统计数据可能很难理解。对于网页浏览器来说,它甚至更加复杂,因为它们经常运行许多独立的进程。它们在 top 输出中显示为一个长长的列表,每一个都有自己的单独指标。

 title=

smem 命令

幸运的是有 smem,另一个用于查看内存使用统计的命令行工具。用你选择的包管理器安装它,例如:

sudo apt install smem

要获得 Firefox 的总内存使用量,请执行:

smem -c pss -P firefox -k -t | tail -n 1

这些开关做了什么?

  • -c 开关指定要显示的列。我只对 pss 列感兴趣,它显示一个进程分配的内存。
  • -P 开关过滤进程,只包括那些名字里有 firefox 的进程。
  • -k 开关显示以 MB/GB 为单位的内存使用情况,而不是单纯的字节数。
  • -t 开关显示总数。
  • tail -n 1 过滤器只输出最后一行,也就是总数的地方。

输出是非常简单的:

$ smem -t -k -c pss -P firefox | tail -n 1
4.9G

开门见山!而且,经过又一天忙碌的工作,打开了 50 多个选项卡,Firefox 仍然只使用 5 GB。看看吧,Google Chrome。

用一个脚本更容易

为了方便起见,我创建一个名为 memory-use 的小脚本,它将进程名称作为参数。我把所有的脚本都放在 ~/bin 里,所以:

echo 'smem -c pss -P "$1" -k -t | tail -n 1' > ~/bin/memory-use && chmod +x ~/bin/memory-use

现在我可以很容易地测量任何应用的内存使用:

memory-use firefox
memory-use chrome
memory-use slack

还有更多!

该工具不仅可以显示总的内存使用情况。它甚至可以生成图形输出。

比如:

smem --pie name -c pss

显示类似这样的内容:

 title=

关于更多的细节,我建议查看 smem 手册页

你可以在 https://linoxide.com/memory-usage-reporting-smem/ 上找到另一个很棒的教程。

希望你喜欢!

本文最初发表在作者的博客上,并经许可转载。


via: https://opensource.com/article/21/10/memory-stats-linux-smem

作者:Tomasz Waraksa 选题:lujun9972 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

了解有关内存安全和效率的更多信息。

 title=

C 是一种高级语言,同时具有“ 接近金属 close-to-the-metal ”(LCTT 译注:即“接近人类思维方式”的反义词)的特性,这使得它有时看起来更像是一种可移植的汇编语言,而不像 Java 或 Python 这样的兄弟语言。内存管理作为上述特性之一,涵盖了正在执行的程序对内存的安全和高效使用。本文通过 C 语言代码示例,以及现代 C 语言编译器生成的汇编语言代码段,详细介绍了内存安全性和效率。

尽管代码示例是用 C 语言编写的,但安全高效的内存管理指南对于 C++ 是同样适用的。这两种语言在很多细节上有所不同(例如,C++ 具有 C 所缺乏的面向对象特性和泛型),但在内存管理方面面临的挑战是一样的。

执行中程序的内存概述

对于正在执行的程序(又名 进程 process ),内存被划分为三个区域: stack heap 静态区 static area 。下文会给出每个区域的概述,以及完整的代码示例。

作为通用 CPU 寄存器的替补, 为代码块(例如函数或循环体)中的局部变量提供暂存器存储。传递给函数的参数在此上下文中也视作局部变量。看一下下面这个简短的示例:

void some_func(int a, int b) {
   int n;
   ...
}

通过 ab 传递的参数以及局部变量 n 的存储会在栈中,除非编译器可以找到通用寄存器。编译器倾向于优先将通用寄存器用作暂存器,因为 CPU 对这些寄存器的访问速度很快(一个时钟周期)。然而,这些寄存器在台式机、笔记本电脑和手持机器的标准架构上很少(大约 16 个)。

在只有汇编语言程序员才能看到的实施层面,栈被组织为具有 push(插入)和 pop(删除)操作的 LIFO(后进先出)列表。 top 指针可以作为偏移的基地址;这样,除了 top 之外的栈位置也变得可访问了。例如,表达式 top+16 指向堆栈的 top 指针上方 16 个字节的位置,表达式 top-16 指向 top 指针下方 16 个字节的位置。因此,可以通过 top 指针访问实现了暂存器存储的栈的位置。在标准的 ARM 或 Intel 架构中,栈从高内存地址增长到低内存地址;因此,减小某进程的 top 就是增大其栈规模。

使用栈结构就意味着轻松高效地使用内存。编译器(而非程序员)会编写管理栈的代码,管理过程通过分配和释放所需的暂存器存储来实现;程序员声明函数参数和局部变量,将实现过程交给编译器。此外,完全相同的栈存储可以在连续的函数调用和代码块(如循环)中重复使用。精心设计的模块化代码会将栈存储作为暂存器的首选内存选项,同时优化编译器要尽可能使用通用寄存器而不是栈。

提供的存储是通过程序员代码显式分配的,堆分配的语法因语言而异。在 C 中,成功调用库函数 malloc(或其变体 calloc 等)会分配指定数量的字节(在 C++ 和 Java 等语言中,new 运算符具有相同的用途)。编程语言在如何释放堆分配的存储方面有着巨大的差异:

  • 在 Java、Go、Lisp 和 Python 等语言中,程序员不会显式释放动态分配的堆存储。

例如,下面这个 Java 语句为一个字符串分配了堆存储,并将这个堆存储的地址存储在变量 greeting 中:

String greeting = new String("Hello, world!");

Java 有一个垃圾回收器,它是一个运行时实用程序,如果进程无法再访问自己分配的堆存储,回收器可以使其自动释放。因此,Java 堆释放是通过垃圾收集器自动进行的。在上面的示例中,垃圾收集器将在变量 greeting 超出作用域后,释放字符串的堆存储。

  • Rust 编译器会编写堆释放代码。这是 Rust 在不依赖垃圾回收器的情况下,使堆释放实现自动化的开创性努力,但这也会带来运行时复杂性和开销。向 Rust 的努力致敬!
  • 在 C(和 C++)中,堆释放是程序员的任务。程序员调用 malloc 分配堆存储,然后负责相应地调用库函数 free 来释放该存储空间(在 C++ 中,new 运算符分配堆存储,而 deletedelete[] 运算符释放此类存储)。下面是一个 C 语言代码示例:
char* greeting = malloc(14);       /* 14 heap bytes */
strcpy(greeting, "Hello, world!"); /* copy greeting into bytes */
puts(greeting);                    /* print greeting */
free(greeting);                    /* free malloced bytes */

C 语言避免了垃圾回收器的成本和复杂性,但也不过是让程序员承担了堆释放的任务。

内存的 静态区 为可执行代码(例如 C 语言函数)、字符串文字(例如“Hello, world!”)和全局变量提供存储空间:

int n;                       /* global variable */
int main() {                 /* function */
   char* msg = "No comment"; /* string literal */
   ...
}

该区域是静态的,因为它的大小从进程执行开始到结束都固定不变。由于静态区相当于进程固定大小的内存占用,因此经验法则是通过避免使用全局数组等方法来使该区域尽可能小。

下文会结合代码示例对本节概述展开进一步讲解。

栈存储

想象一个有各种连续执行的任务的程序,任务包括了处理每隔几分钟通过网络下载并存储在本地文件中的数字数据。下面的 stack 程序简化了处理流程(仅是将奇数整数值转换为偶数),而将重点放在栈存储的好处上。

#include <stdio.h>
#include <stdlib.h>

#define Infile   "incoming.dat"
#define Outfile  "outgoing.dat"
#define IntCount 128000  /* 128,000 */

void other_task1() { /*...*/ }
void other_task2() { /*...*/ }

void process_data(const char* infile,
          const char* outfile,
          const unsigned n) {
  int nums[n];
  FILE* input = fopen(infile, "r");
  if (NULL == infile) return;
  FILE* output = fopen(outfile, "w");
  if (NULL == output) {
    fclose(input);
    return;
  }

  fread(nums, n, sizeof(int), input); /* read input data */
  unsigned i;
  for (i = 0; i < n; i++) {
    if (1 == (nums[i] & 0x1))  /* odd parity? */
      nums[i]--;               /* make even */
  }
  fclose(input);               /* close input file */

  fwrite(nums, n, sizeof(int), output);
  fclose(output);
}

int main() {
  process_data(Infile, Outfile, IntCount);
  
  /** now perform other tasks **/
  other_task1(); /* automatically released stack storage available */
  other_task2(); /* ditto */
  
  return 0;
}

底部的 main 函数首先调用 process_data 函数,该函数会创建一个基于栈的数组,其大小由参数 n 给定(当前示例中为 128,000)。因此,该数组占用 128000 * sizeof(int) 个字节,在标准设备上达到了 512,000 字节(int 在这些设备上是四个字节)。然后数据会被读入数组(使用库函数 fread),循环处理,并保存到本地文件 outgoing.dat(使用库函数 fwrite)。

process_data 函数返回到其调用者 main 函数时,process_data 函数的大约 500MB 栈暂存器可供 stack 程序中的其他函数用作暂存器。在此示例中,main 函数接下来调用存根函数 other_task1other_task2。这三个函数在 main 中依次调用,这意味着所有三个函数都可以使用相同的堆栈存储作为暂存器。因为编写栈管理代码的是编译器而不是程序员,所以这种方法对程序员来说既高效又容易。

在 C 语言中,在块(例如函数或循环体)内定义的任何变量默认都有一个 auto 存储类,这意味着该变量是基于栈的。存储类 register 现在已经过时了,因为 C 编译器会主动尝试尽可能使用 CPU 寄存器。只有在块内定义的变量可能是 register,如果没有可用的 CPU 寄存器,编译器会将其更改为 auto。基于栈的编程可能是不错的首选方式,但这种风格确实有一些挑战性。下面的 badStack 程序说明了这点。

#include <stdio.h>;

const int* get_array(const unsigned n) {
  int arr[n]; /* stack-based array */
  unsigned i;
  for (i = 0; i < n; i++) arr[i] = 1 + 1;

  return arr;  /** ERROR **/
}

int main() {
  const unsigned n = 16;
  const int* ptr = get_array(n);
  
  unsigned i;
  for (i = 0; i < n; i++) printf("%i ", ptr[i]);
  puts("\n");

  return 0;
}

badStack 程序中的控制流程很简单。main 函数使用 16(LCTT 译注:原文为 128,应为作者笔误)作为参数调用函数 get_array,然后被调用函数会使用传入参数来创建对应大小的本地数组。get_array 函数会初始化数组并返回给 main 中的数组标识符 arrarr 是一个指针常量,保存数组的第一个 int 元素的地址。

当然,本地数组 arr 可以在 get_array 函数中访问,但是一旦 get_array 返回,就不能合法访问该数组。尽管如此,main 函数会尝试使用函数 get_array 返回的堆栈地址 arr 来打印基于栈的数组。现代编译器会警告错误。例如,下面是来自 GNU 编译器的警告:

badStack.c: In function 'get_array':
badStack.c:9:10: warning: function returns address of local variable [-Wreturn-local-addr]
return arr;  /** ERROR **/

一般规则是,如果使用栈存储实现局部变量,应该仅在该变量所在的代码块内,访问这块基于栈的存储(在本例中,数组指针 arr 和循环计数器 i 均为这样的局部变量)。因此,函数永远不应该返回指向基于栈存储的指针。

堆存储

接下来使用若干代码示例凸显在 C 语言中使用堆存储的优点。在第一个示例中,使用了最优方案分配、使用和释放堆存储。第二个示例(在下一节中)将堆存储嵌套在了其他堆存储中,这会使其释放操作变得复杂。

#include <stdio.h>
#include <stdlib.h>

int* get_heap_array(unsigned n) {
  int* heap_nums = malloc(sizeof(int) * n); 
  
  unsigned i;
  for (i = 0; i < n; i++)
    heap_nums[i] = i + 1;  /* initialize the array */
  
  /* stack storage for variables heap_nums and i released
     automatically when get_num_array returns */
  return heap_nums; /* return (copy of) the pointer */
}

int main() {
  unsigned n = 100, i;
  int* heap_nums = get_heap_array(n); /* save returned address */
  
  if (NULL == heap_nums) /* malloc failed */
    fprintf(stderr, "%s\n", "malloc(...) failed...");
  else {
    for (i = 0; i < n; i++) printf("%i\n", heap_nums[i]);
    free(heap_nums); /* free the heap storage */
  }
  return 0; 
}

上面的 heap 程序有两个函数: main 函数使用参数(示例中为 100)调用 get_heap_array 函数,参数用来指定数组应该有多少个 int 元素。因为堆分配可能会失败,main 函数会检查 get_heap_array 是否返回了 NULL;如果是,则表示失败。如果分配成功,main 将打印数组中的 int 值,然后立即调用库函数 free 来对堆存储解除分配。这就是最优的方案。

get_heap_array 函数以下列语句开头,该语句值得仔细研究一下:

int* heap_nums = malloc(sizeof(int) * n); /* heap allocation */

malloc 库函数及其变体函数针对字节进行操作;因此,malloc 的参数是 nint 类型元素所需的字节数(sizeof(int) 在标准现代设备上是四个字节)。malloc 函数返回所分配字节段的首地址,如果失败则返回 NULL .

如果成功调用 malloc,在现代台式机上其返回的地址大小为 64 位。在手持设备和早些时候的台式机上,该地址的大小可能是 32 位,或者甚至更小,具体取决于其年代。堆分配数组中的元素是 int 类型,这是一个四字节的有符号整数。这些堆分配的 int 的地址存储在基于栈的局部变量 heap_nums 中。可以参考下图:

                 heap-based
 stack-based        /
     \        +----+----+   +----+
 heap-nums--->|int1|int2|...|intN|
              +----+----+   +----+

一旦 get_heap_array 函数返回,指针变量 heap_nums 的栈存储将自动回收——但动态 int 数组的堆存储仍然存在,这就是 get_heap_array 函数返回这个地址(的副本)给 main 函数的原因:它现在负责在打印数组的整数后,通过调用库函数 free 显式释放堆存储:

free(heap_nums); /* free the heap storage */

malloc 函数不会初始化堆分配的存储空间,因此里面是随机值。相比之下,其变体函数 calloc 会将分配的存储初始化为零。这两个函数都返回 NULL 来表示分配失败。

heap 示例中,main 函数在调用 free 后会立即返回,正在执行的程序会终止,这会让系统回收所有已分配的堆存储。尽管如此,程序员应该养成在不再需要时立即显式释放堆存储的习惯。

嵌套堆分配

下一个代码示例会更棘手一些。C 语言有很多返回指向堆存储的指针的库函数。下面是一个常见的使用情景:

1、C 程序调用一个库函数,该函数返回一个指向基于堆的存储的指针,而指向的存储通常是一个聚合体,如数组或结构体:

SomeStructure* ptr = lib_function(); /* returns pointer to heap storage */

2、 然后程序使用所分配的存储。

3、 对于清理而言,问题是对 free 的简单调用是否会清理库函数分配的所有堆分配存储。例如,SomeStructure 实例可能有指向堆分配存储的字段。一个特别麻烦的情况是动态分配的结构体数组,每个结构体有一个指向又一层动态分配的存储的字段。下面的代码示例说明了这个问题,并重点关注了如何设计一个可以安全地为客户端提供堆分配存储的库。

#include <stdio.h>
#include <stdlib.h>

typedef struct {
  unsigned id;
  unsigned len;
  float*   heap_nums;
} HeapStruct;
unsigned structId = 1;

HeapStruct* get_heap_struct(unsigned n) {
  /* Try to allocate a HeapStruct. */
  HeapStruct* heap_struct = malloc(sizeof(HeapStruct));
  if (NULL == heap_struct) /* failure? */
    return NULL;           /* if so, return NULL */

  /* Try to allocate floating-point aggregate within HeapStruct. */
  heap_struct->heap_nums = malloc(sizeof(float) * n);
  if (NULL == heap_struct->heap_nums) {  /* failure? */
    free(heap_struct);                   /* if so, first free the HeapStruct */
    return NULL;                         /* then return NULL */
  }

  /* Success: set fields */
  heap_struct->id = structId++;
  heap_struct->len = n;

  return heap_struct; /* return pointer to allocated HeapStruct */
}

void free_all(HeapStruct* heap_struct) {
  if (NULL == heap_struct) /* NULL pointer? */
    return;                /* if so, do nothing */
  
  free(heap_struct->heap_nums); /* first free encapsulated aggregate */
  free(heap_struct);            /* then free containing structure */  
}

int main() {
  const unsigned n = 100;
  HeapStruct* hs = get_heap_struct(n); /* get structure with N floats */

  /* Do some (meaningless) work for demo. */
  unsigned i;
  for (i = 0; i < n; i++) hs->heap_nums[i] = 3.14 + (float) i;
  for (i = 0; i < n; i += 10) printf("%12f\n", hs->heap_nums[i]);

  free_all(hs); /* free dynamically allocated storage */
  
  return 0;
}

上面的 nestedHeap 程序示例以结构体 HeapStruct 为中心,结构体中又有名为 heap_nums 的指针字段:

typedef struct {
  unsigned id;
  unsigned len;
  float*   heap_nums; /** pointer **/
} HeapStruct;

函数 get_heap_struct 尝试为 HeapStruct 实例分配堆存储,这需要为字段 heap_nums 指向的若干个 float 变量分配堆存储。如果成功调用 get_heap_struct 函数,并将指向堆分配结构体的指针以 hs 命名,其结果可以描述如下:

hs-->HeapStruct instance
        id
        len
        heap_nums-->N contiguous float elements

get_heap_struct 函数中,第一个堆分配过程很简单:

HeapStruct* heap_struct = malloc(sizeof(HeapStruct));
if (NULL == heap_struct) /* failure? */
  return NULL;           /* if so, return NULL */

sizeof(HeapStruct) 包括了 heap_nums 字段的字节数(32 位机器上为 4,64 位机器上为 8),heap_nums 字段则是指向动态分配数组中的 float 元素的指针。那么,问题关键在于 malloc 为这个结构体传送了字节空间还是表示失败的 NULL;如果是 NULLget_heap_struct 函数就也返回 NULL 以通知调用者堆分配失败。

第二步尝试堆分配的过程更复杂,因为在这一步,HeapStruct 的堆存储已经分配好了:

heap_struct->heap_nums = malloc(sizeof(float) * n);
if (NULL == heap_struct->heap_nums) {  /* failure? */
  free(heap_struct);                   /* if so, first free the HeapStruct */
  return NULL;                         /* and then return NULL */
}

传递给 get_heap_struct 函数的参数 n 指明动态分配的 heap_nums 数组中应该有多少个 float 元素。如果可以分配所需的若干个 float 元素,则该函数在返回 HeapStruct 的堆地址之前会设置结构的 idlen 字段。 但是,如果尝试分配失败,则需要两个步骤来实现最优方案:

1、 必须释放 HeapStruct 的存储以避免内存泄漏。对于调用 get_heap_struct 的客户端函数而言,没有动态 heap_nums 数组的 HeapStruct 可能就是没用的;因此,HeapStruct 实例的字节空间应该显式释放,以便系统可以回收这些空间用于未来的堆分配。

2、 返回 NULL 以标识失败。

如果成功调用 get_heap_struct 函数,那么释放堆存储也很棘手,因为它涉及要以正确顺序进行的两次 free 操作。因此,该程序设计了一个 free_all 函数,而不是要求程序员再去手动实现两步释放操作。回顾一下,free_all 函数是这样的:

void free_all(HeapStruct* heap_struct) {
  if (NULL == heap_struct) /* NULL pointer? */
    return;                /* if so, do nothing */
  
  free(heap_struct->heap_nums); /* first free encapsulated aggregate */
  free(heap_struct);            /* then free containing structure */  
}

检查完参数 heap_struct 不是 NULL 值后,函数首先释放 heap_nums 数组,这步要求 heap_struct 指针此时仍然是有效的。先释放 heap_struct 的做法是错误的。一旦 heap_nums 被释放,heap_struct 就可以释放了。如果 heap_struct 被释放,但 heap_nums 没有被释放,那么数组中的 float 元素就会泄漏:仍然分配了字节空间,但无法被访问到——因此一定要记得释放 heap_nums。存储泄漏将一直持续,直到 nestedHeap 程序退出,系统回收泄漏的字节时为止。

关于 free 库函数的注意事项就是要有顺序。回想一下上面的调用示例:

free(heap_struct->heap_nums); /* first free encapsulated aggregate */
free(heap_struct);            /* then free containing structure */

这些调用释放了分配的存储空间——但它们并 不是 将它们的操作参数设置为 NULLfree 函数会获取地址的副本作为参数;因此,将副本更改为 NULL 并不会改变原地址上的参数值)。例如,在成功调用 free 之后,指针 heap_struct 仍然持有一些堆分配字节的堆地址,但是现在使用这个地址将会产生错误,因为对 free 的调用使得系统有权回收然后重用这些分配过的字节。

使用 NULL 参数调用 free 没有意义,但也没有什么坏处。而在非 NULL 的地址上重复调用 free 会导致不确定结果的错误:

free(heap_struct);  /* 1st call: ok */
free(heap_struct);  /* 2nd call: ERROR */

内存泄漏和堆碎片化

“内存泄漏”是指动态分配的堆存储变得不再可访问。看一下相关的代码段:

float* nums = malloc(sizeof(float) * 10); /* 10 floats */
nums[0] = 3.14f;                          /* and so on */
nums = malloc(sizeof(float) * 25);        /* 25 new floats */

假如第一个 malloc 成功,第二个 malloc 会再将 nums 指针重置为 NULL(分配失败情况下)或是新分配的 25 个 float 中第一个的地址。最初分配的 10 个 float 元素的堆存储仍然处于被分配状态,但此时已无法再对其访问,因为 nums 指针要么指向别处,要么是 NULL。结果就是造成了 40 个字节(sizeof(float) * 10)的泄漏。

在第二次调用 malloc 之前,应该释放最初分配的存储空间:

float* nums = malloc(sizeof(float) * 10); /* 10 floats */
nums[0] = 3.14f;                          /* and so on */
free(nums);                               /** good **/
nums = malloc(sizeof(float) * 25);        /* no leakage */

即使没有泄漏,堆也会随着时间的推移而碎片化,需要对系统进行碎片整理。例如,假设两个最大的堆块当前的大小分别为 200MB 和 100MB。然而,这两个堆块并不连续,进程 P 此时又需要分配 250MB 的连续堆存储。在进行分配之前,系统可能要对堆进行 碎片整理 以给 P 提供 250MB 连续存储空间。碎片整理很复杂,因此也很耗时。

内存泄漏会创建处于已分配状态但不可访问的堆块,从而会加速碎片化。因此,释放不再需要的堆存储是程序员帮助减少碎片整理需求的一种方式。

诊断内存泄漏的工具

有很多工具可用于分析内存效率和安全性,其中我最喜欢的是 valgrind。为了说明该工具如何处理内存泄漏,这里给出 leaky 示例程序:

#include <stdio.h>
#include <stdlib.h>

int* get_ints(unsigned n) {
  int* ptr = malloc(n * sizeof(int));
  if (ptr != NULL) {
    unsigned i;
    for (i = 0; i < n; i++) ptr[i] = i + 1;
  }
  return ptr;
}

void print_ints(int* ptr, unsigned n) {
  unsigned i;
  for (i = 0; i < n; i++) printf("%3i\n", ptr[i]);
}

int main() {
  const unsigned n = 32;
  int* arr = get_ints(n);
  if (arr != NULL) print_ints(arr, n);

  /** heap storage not yet freed... **/
  return 0;
}

main 函数调用了 get_ints 函数,后者会试着从堆中 malloc 32 个 4 字节的 int,然后初始化动态数组(如果 malloc 成功)。初始化成功后,main 函数会调用 print_ints函数。程序中并没有调用 free 来对应 malloc 操作;因此,内存泄漏了。

如果安装了 valgrind 工具箱,下面的命令会检查 leaky 程序是否存在内存泄漏(% 是命令行提示符):

% valgrind --leak-check=full ./leaky

绝大部分输出都在下面给出了。左边的数字 207683 是正在执行的 leaky 程序的进程标识符。这份报告给出了泄漏发生位置的详细信息,本例中位置是在 main 函数所调用的 get_ints 函数中对 malloc 的调用处。

==207683== HEAP SUMMARY:
==207683==   in use at exit: 128 bytes in 1 blocks
==207683==   total heap usage: 2 allocs, 1 frees, 1,152 bytes allocated
==207683== 
==207683== 128 bytes in 1 blocks are definitely lost in loss record 1 of 1
==207683==   at 0x483B7F3: malloc (in /usr/lib/x86_64-linux-gnu/valgrind/vgpreload_memcheck-amd64-linux.so)
==207683==   by 0x109186: get_ints (in /home/marty/gc/leaky)
==207683==   by 0x109236: main (in /home/marty/gc/leaky)
==207683== 
==207683== LEAK SUMMARY:
==207683==   definitely lost: 128 bytes in 1 blocks
==207683==   indirectly lost: 0 bytes in 0 blocks
==207683==   possibly lost: 0 bytes in 0 blocks
==207683==   still reachable: 0 bytes in 0 blocks
==207683==   suppressed: 0 bytes in 0 blocks

如果把 main 函数改成在对 print_ints 的调用之后,再加上一个对 free 的调用,valgrind 就会对 leaky 程序给出一个干净的内存健康清单:

==218462== All heap blocks were freed -- no leaks are possible

静态区存储

在正统的 C 语言中,函数必须在所有块之外定义。这是一些 C 编译器支持的特性,杜绝了在另一个函数体内定义一个函数的可能。我举的例子都是在所有块之外定义的函数。这样的函数要么是 static ,即静态的,要么是 extern,即外部的,其中 extern 是默认值。

C 语言中,以 staticextern 修饰的函数和变量驻留在内存中所谓的 静态区 中,因为在程序执行期间该区域大小是固定不变的。这两个存储类型的语法非常复杂,我们应该回顾一下。在回顾之后,会有一个完整的代码示例来生动展示语法细节。在所有块之外定义的函数或变量默认为 extern;因此,函数和变量要想存储类型为 static ,必须显式指定:

/** file1.c: outside all blocks, five definitions  **/
int foo(int n) { return n * 2; }     /* extern by default */
static int bar(int n) { return n; }  /* static */
extern int baz(int n) { return -n; } /* explicitly extern */

int num1;        /* extern */
static int num2; /* static */

externstatic 的区别在于作用域:extern 修饰的函数或变量可以实现跨文件可见(需要声明)。相比之下,static 修饰的函数仅在 定义 该函数的文件中可见,而 static 修饰的变量仅在 定义 该变量的文件(或文件中的块)中可见:

static int n1;    /* scope is the file */
void func() {
   static int n2; /* scope is func's body */
   ...
}

如果在所有块之外定义了 static 变量,例如上面的 n1,该变量的作用域就是定义变量的文件。无论在何处定义 static 变量,变量的存储都在内存的静态区中。

extern 函数或变量在给定文件中的所有块之外定义,但这样定义的函数或变量也可以在其他文件中声明。典型的做法是在头文件中 声明 这样的函数或变量,只要需要就可以包含进来。下面这些简短的例子阐述了这些棘手的问题。

假设 extern 函数 foofile1.c定义,有无关键字 extern 效果都一样:

/** file1.c **/
int foo(int n) { return n * 2; } /* definition has a body {...} */

必须在其他文件(或其中的块)中使用显式的 extern 声明 此函数才能使其可见。以下是使 extern 函数 foo 在文件 file2.c 中可见的声明语句:

/** file2.c: make function foo visible here **/
extern int foo(int); /* declaration (no body) */

回想一下,函数声明没有用大括号括起来的主体,而函数定义会有这样的主体。

为了便于查看,函数和变量声明通常会放在头文件中。准备好需要声明的源代码文件,然后就可以 #include 相关的头文件。下一节中的 staticProg 程序演示了这种方法。

至于 extern 的变量,规则就变得更棘手了(很抱歉增加了难度!)。任何 extern 的对象——无论函数或变量——必须 定义 在所有块之外。此外,在所有块之外定义的变量默认为 extern

/** outside all blocks **/
int n; /* defaults to extern */

但是,只有在变量的 定义 中显式初始化变量时,extern 才能在变量的 定义 中显式修饰(LCTT 译注:换言之,如果下列代码中的 int n1; 行前加上 extern,该行就由 定义 变成了 声明):

/** file1.c: outside all blocks **/
int n1;             /* defaults to extern, initialized by compiler to zero */
extern int n2 = -1; /* ok, initialized explicitly */
int n3 = 9876;      /* ok, extern by default and initialized explicitly */

要使在 file1.c 中定义为 extern 的变量在另一个文件(例如 file2.c)中可见,该变量必须在 file2.c 中显式 声明extern 并且不能初始化(初始化会将声明转换为定义):

/** file2.c **/
extern int n1; /* declaration of n1 defined in file1.c */

为了避免与 extern 变量混淆,经验是在 声明 中显式使用 extern(必须),但不要在 定义 中使用(非必须且棘手)。对于函数,extern 在定义中是可选使用的,但在声明中是必须使用的。下一节中的 staticProg 示例会把这些点整合到一个完整的程序中。

staticProg 示例

staticProg 程序由三个文件组成:两个 C 语言源文件(static1.cstatic2.c)以及一个头文件(static.h),头文件中包含两个声明:

/** header file static.h **/
#define NumCount 100               /* macro */
extern int global_nums[NumCount];  /* array declaration */
extern void fill_array();          /* function declaration */

两个声明中的 extern,一个用于数组,另一个用于函数,强调对象在别处(“外部”)定义:数组 global_nums 在文件 static1.c 中定义(没有显式的 extern),函数 fill_array 在文件 static2.c 中定义(也没有显式的 extern)。每个源文件都包含了头文件 static.hstatic1.c 文件定义了两个驻留在内存静态区域中的数组(global_numsmore_nums)。第二个数组有 static 修饰,这将其作用域限制为定义数组的文件 (static1.c)。如前所述, extern 修饰的 global_nums 则可以实现在多个文件中可见。

/** static1.c **/
#include <stdio.h>
#include <stdlib.h>

#include "static.h"             /* declarations */

int global_nums[NumCount];      /* definition: extern (global) aggregate */
static int more_nums[NumCount]; /* definition: scope limited to this file */

int main() {
  fill_array(); /** defined in file static2.c **/

  unsigned i;
  for (i = 0; i < NumCount; i++)
    more_nums[i] = i * -1;

  /* confirm initialization worked */
  for (i = 0; i < NumCount; i += 10) 
    printf("%4i\t%4i\n", global_nums[i], more_nums[i]);
    
  return 0;  
}

下面的 static2.c 文件中定义了 fill_array 函数,该函数由 main(在 static1.c 文件中)调用;fill_array 函数会给名为 global_numsextern 数组中的元素赋值,该数组在文件 static1.c 中定义。使用两个文件的唯一目的是凸显 extern 变量或函数能够跨文件可见。

/** static2.c **/
#include "static.h" /** declarations **/

void fill_array() { /** definition **/
  unsigned i;
  for (i = 0; i < NumCount; i++) global_nums[i] = i + 2;
}

staticProg 程序可以用如下编译:

% gcc -o staticProg static1.c static2.c

从汇编语言看更多细节

现代 C 编译器能够处理 C 和汇编语言的任意组合。编译 C 源文件时,编译器首先将 C 代码翻译成汇编语言。这是对从上文 static1.c 文件生成的汇编语言进行保存的命令:

% gcc -S static1.c

生成的文件就是 static1.s。这是文件顶部的一段代码,额外添加了行号以提高可读性:

    .file    "static1.c"          ## line  1
    .text                         ## line  2
    .comm    global_nums,400,32   ## line  3
    .local    more_nums           ## line  4
    .comm    more_nums,400,32     ## line  5
    .section    .rodata           ## line  6
.LC0:                             ## line  7
    .string    "%4i\t%4i\n"       ## line  8
    .text                         ## line  9
    .globl    main                ## line 10
    .type    main, @function      ## line 11
main:                             ## line 12
...

诸如 .file(第 1 行)之类的汇编语言指令以句点开头。顾名思义,指令会指导汇编程序将汇编语言翻译成机器代码。.rodata 指令(第 6 行)表示后面是只读对象,包括字符串常量 "%4i\t%4i\n"(第 8 行),main 函数(第 12 行)会使用此字符串常量来实现格式化输出。作为标签引入(通过末尾的冒号实现)的 main 函数(第 12 行),同样也是只读的。

在汇编语言中,标签就是地址。标签 main:(第 12 行)标记了 main 函数代码开始的地址,标签 .LC0:(第 7 行)标记了格式化字符串开头所在的地址。

global_nums(第 3 行)和 more_nums(第 4 行)数组的定义包含了两个数字:400 是每个数组中的总字节数,32 是每个数组(含 100 个 int 元素)中每个元素的比特数。(第 5 行中的 .comm 指令表示 common name,可以忽略。)

两个数组定义的不同之处在于 more_nums 被标记为 .local(第 4 行),这意味着其作用域仅限于其所在文件 static1.s。相比之下,global_nums 数组就能在多个文件中实现可见,包括由 static1.cstatic2.c 文件翻译成的汇编文件。

最后,.text 指令在汇编代码段中出现了两次(第 2 行和第 9 行)。术语“text”表示“只读”,但也会涵盖一些读/写变量,例如两个数组中的元素。尽管本文展示的汇编语言是针对 Intel 架构的,但 Arm6 汇编也非常相似。对于这两种架构,.text 区域中的变量(本例中为两个数组中的元素)会自动初始化为零。

总结

C 语言中的内存高效和内存安全编程准则很容易说明,但可能会很难遵循,尤其是在调用设计不佳的库的时候。准则如下:

  • 尽可能使用栈存储,进而鼓励编译器将通用寄存器用作暂存器,实现优化。栈存储代表了高效的内存使用并促进了代码的整洁和模块化。永远不要返回指向基于栈的存储的指针。
  • 小心使用堆存储。C(和 C++)中的重难点是确保动态分配的存储尽快解除分配。良好的编程习惯和工具(如 valgrind)有助于攻关这些重难点。优先选用自身提供释放函数的库,例如 nestedHeap 代码示例中的 free_all 释放函数。
  • 谨慎使用静态存储,因为这种存储会自始至终地影响进程的内存占用。特别是尽量避免使用 externstatic 数组。

本文 C 语言代码示例可在我的网站(https://condor.depaul.edu/mkalin)上找到。


via: https://opensource.com/article/21/8/memory-programming-c

作者:Marty Kalin 选题:lujun9972 译者:unigeorge 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出