mov [0x100000], 0x12345
Nim 内存模型
简介
这是一个小教程,解释 Nim 如何在内存中存储数据。解释了 Nim 程序员应该知道的要点,让你深入了解 Nim 组织字符串和 seq 等数据结构的方式。
注:本文在小屏幕上显示不理想,部分表格可能无法展示作者意图。
出于最实践的目的,Nim 将负责程序的所有内存管理,您不必提供详细细节。只要你使用语言的安全部分,你就很少需要处理内存地址,或者进行显式的内存分配。然而,当您希望 Nim 代码与外部 C 代码或 C 库互操作时,这种情况会发生变化——在这种情况下,您可能需要知道 Nim 对象存储在内存中的位置和方式,以便将其传递给 C ,或者您需要知道如何访问 C 分配的数据,使其可由 Nim 访问。
本文档的第一部分对于具有 C 或 C++ 背景的读者来说很熟悉,因为它的很多部分并非 Nim 语言独有。相比之下,对于来自动态语言(如Python或Javascript)的程序员来说,有些东西可能是新的,因为在这些语言中,内存处理更加抽象。
注意:本文档的大部分(如果不是全部)适用于 C 和 C++ 代码生成器(Nim后端),因为 Javascript 后端不使用原始内存,而是依赖于 Javascript 对象。
计算机内存基础知识
本节简要而抽象地介绍了计算机内存,以及从 CPU 和计算机程序的角度来看它是什么样子的(警告:前面要做大量的简化!)。
单字节大小
计算机的主存储器(RAM)由许多存储单元组成,每个存储单元都有一个唯一的地址。根据CPU 架构,每个存储器位置的大小(字大小"word size")通常在一个字节(8位)到八个字节(64位)之间变化,而 CPU 通常也能够访问较大的字作为较小的块。有些体系结构可以从任意地址读取和写入内存,而其他体系结构只能在多个字大小的地址访问内存。
CPU 使用专用指令访问存储器,根据字大小,指令让 CPU 从内存地址读写数据。例如,它可以将 32 位数值 0x12345 存入地址 0x100000 。执行此操作的底层汇编指令可能如下所示:
这是上述指令完成后地址 0x100000 上的内存的样子,每一列表示一个字节:
00 01 02 03 04 +----+----+----+----+---- 0x100000 | 00 | 01 | 23 | 45 | .. +----+----+----+----+----
字节顺序
更为复杂的是,一个字中字节的实际顺序因 CPU 类型而异——一些 CPU 将最高有效字节放在第一位,而另一些 CPU 将最低有效字节放第一位。这称为CPU的大小端,用 endianess 标识。
-
现在大多数 CPU( Intel 兼容、x86、amd64、大多数 ARM 系列)都是小端的。整数 0x1234 首先存储 最低 有效字节:
00 01 +----+----+ | 34 | 12 | +----+----+
-
其他一些 CPU 如 Freescale 或 OpenRISC 是大端的。整数 0x1234 首先存储 最高 有效字节。大多数网络协议在将数据发送到网络时以大端顺序串行化数据;这就是为什么大端也称为 network endian :
00 01 +----+----+ | 12 | 34 | +----+----+
最重要的是:如果您想编写可移植的代码,在将二进制数据写入磁盘或通过网络写入时,不要对机器的端序做任何假设,应该将数据显式转换为正确的端序。
组织内存的两种方式:栈和堆
传统上,C 程序使用两种常用的方法来组织计算机内存中的对象: stack 和 heap 。这两种方法都有不同的目的和特点。Nim 代码被编译成C或C++代码,因此 Nim 自然共享这些语言的内存模型。
栈(Stack)
stack
译为堆栈,为防止歧义,称为栈。栈是内存的一个区域,数据总是从一端添加和删除,即 “后进先出”(LIFO)。
栈的原理
这就好比是餐厅厨房里的一堆盘子:新盘子从洗碗机中取出,放在上面;当需要盘子时,它们也从顶部取出。盘子永远不会插在中间或底部,盘子也永远不会从堆叠的中间或底部取出。
由于历史原因,计算机栈通常是自上而下的:新数据被添加到栈底部或从栈底移除,但这不会改变出入栈的机制。
+--------------+ <-- 栈顶 | | | 已使用 | | | | | +--------------+ <-- 栈指针 | | | | | 新的数据 v 添加到底部 : 未用 :
栈的管理非常简单:程序只需要跟踪一个指向当前栈底部的地址 —— 这通常称为 stack pointer 。当数据被添加到栈中时,它会被复制到位,栈指针也会减少。当数据从栈中删除时,它将被复制出来,栈指针将再次增加。
实际中的栈
在 Nim、 C 和大多数其他编译语言中,栈用于两个不同的目的:
-
首先,它被用作存储临时局部变量的地方。这些变量只存在于函数中,只要该函数处于活动状态(即未返回)。
-
编译器还使用栈进行不同类型的记录:每次调用函数时,
call
指令后的下一条指令的地址都会被放在栈上,这就是 return address 。当函数返回时,它在栈上找到该地址,并跳转到该地址。
上述两种机制的数据组合构成了一个栈帧 stack frame :这是栈的一部分,其中包含当前活动函数的返回地址及其所有本地变量。
在程序执行期间,如果您的程序嵌套了两个函数,栈将是这样的:
+----------------+ <-- 栈顶 | 返回地址 | | 内部变量 | <-- 栈帧 #1 | 内部变量 | | ... | +----------------+ | 返回地址 | | 内部变量 | <-- 栈帧 #2 | ... | +----------------+ <-- 栈指针 | 未用 | : :
将栈用于数据和返回地址是一个非常巧妙的技巧,并且给程序带来了个好功能:可以给数据提供自动的内存分配和清理。
栈也可以很好地与线程一起工作:每个线程都有自己的栈,存储自己的局部变量并保存自己的栈帧。
现在,您知道 Nim 在遇到运行时错误或异常时,生成 stacktrace 的栈跟踪,从何处获取信息:它将找到栈上最内部活动函数的地址,并打印其名称。然后,它在栈上进一步查找下一级活动函数,一直找到顶部。
堆(Heap)
在栈旁边,堆是计算机中存储数据的另一个位置,虽然栈通常用于保存本地变量,但堆可以用于更动态的存储。
堆的原理
堆是一个有点像仓库的内存区域。内存区域称为堆区 arena :
: : ^堆可以在顶部增长 | | | | | | 未分配! |<---堆区域 | | | | +--------------+
当程序想要存储数据时,它将首先计算它需要多少存储空间。然后,它将转到仓库管理员(内存分配器)并请求存储数据的位置。管理员有一个分类账本,它可以跟踪仓库中的所有分配情况,并且可以找到一个足够大的空闲位置来存放数据。然后,它将在分类账中输入该地址和大小的区域,并将地址返回给程序。程序现在就可以在内存中任意存储和检索该区域的数据。
: : | 未分配 | | | +--------------+ | 已分配 | <--- 分配的地址 +--------------+
可以重复上述过程,在堆上分配其他大小不同的块:
: : | 未分配 | +--------------+ | | | 已分配 #3 | | | +--------------+ | 已分配 #2 | +--------------+ | 已分配 #1 | +--------------+
当数据块不再使用时,程序将告诉内存分配器块的地址。分配器在分类账中查找地址,并删除条目。此块就可以释放,供将来使用。这是释放块 #2 时的上图:
: : | 未分配 | +--------------+ | | | 已分配 #3 | | | +--------------+ | 未分配 | <-- 堆里有个洞! +--------------+ | 已分配 #1 | +--------------+
如您所看到的,释放块 #2 会在堆中留下一个洞,这可能会导致未来的问题。有下一个分配请求时:
-
如果下一个分配比洞小,分配器可以重用洞中的空闲空间;如果新的请求较小,在新的区块之后就会留下一个较小的新洞
-
如果下一个分配比洞大,分配器必须在某处找到一个更大的空闲点。洞就会继续存在。
有效重复使用洞的唯一方法是,下一次分配的大小与洞完全相同。
大量使用具有很多不同大小对象的堆,可能会导致一种称为 fragmentation 的现象。这意味着分配器不能有效地使用 100% 的内存来满足分配请求,浪费了部分可用内存。
实际中的堆
在 Nim 中,所有数据都存储在栈中,除非您明确请求它进入堆: new()
过程通常用于在堆上,为新对象分配内存:
type Thing = object a: int var t = new Thing
上面的代码片段将在堆上分配内存,以存储类型为 Thing
的对象。新分配的内存块的地址 address 由 new
返回,为 ref Thing
类型。 ref
是一种特殊的指针,通常由 Nim 为您管理。有关这一点的更多信息,请参阅 [跟踪引用和垃圾收集器] 一节。
Nim 内存组织
只要你坚持使用语言的 安全 safe 部分,Nim 就会为你管理内存的分配。它将确保您的数据存储在适当的位置,并在您不需要时释放。但是,如果需要, Nim 也可以让您自己完全控制,允许您选择存储数据的方式和位置。
Nim 提供了一些方便的功能,允许您检查数据在内存中的组织方式。这些将在以下各节的示例中使用,以检查 Nim 存储数据的方式和位置:
addr(x)
-
此过程返回变量
x
的地址。对于变量类型T
,其地址将具有类型ptr T
unsafeAddr(x)
-
这个过程基本上与
addr(x)
相同,假设 Nim 认为获取对象地址不安全,也可以使用它,稍后将详细介绍。 sizeof(x)
-
返回变量
x
的字节大小。 typeof(x)
-
返回变量
x
类型的字符串表示。
在类型 T
对象上使用 addr(x)
和 unsafeAddr(x)
,返回类型为 ptr T
。 Nim 不知道默认如何打印,因此使用 repr()
格式化类型:
var a: int echo a.addr.repr # ptr 0x56274ece0c60 --> 0
使用指针
基本上,指针是一种特殊类型的变量,它持有一个内存地址——它指向内存中的其他东西。如上所述, Nim 中有两种类型的指针:
-
ptr T
用于 未跟踪的引用 ,也称为 指针 -
ref T
用于 跟踪的引用 ,用于 Nim 管理的内存`ptr T` 指针类型被视为 _不安全的_ 。指针指向手动分配的对象或内存中其他位置的对象,作为程序员,您的任务就是确保指针始终指向有效数据。
当您想要访问指针指向内存中的数据(即具有该数字索引的地址的内容)时,需要对指针进行 取引用(或简而言之,deref)地址的数据。。
在 Nim 中,可以使用空数组下标 []
来实现这一点,类似于在C中使用 *
前缀运算符。下面的代码片段显示了如何为 int 创建别名并更改其值。
var a = 20 (1) var p = a.addr (2) p[] = 30 (3) echo a # --> 30
-
这里声明一个变量
a
,初始化为 20 。 -
p
是类型为ptr int
的指针,指向 inta
的地址。 -
[]
运算符用于取指针p
的引用。由于p
是ptr int
类型的指针,指向a
的内存地址,因此取引用的变量p[]
也是int
类型的。变量a
和p[]
现在指的是相同的内存位置,因此为p[]
赋值也会更改a
值。
对于对象或元组的访问,Nim 将自动执行取引用: .
运算符与普通对象一样使用访问引用的元素。
栈里的局部变量
局部变量(也称为 自动 变量)是 Nim 存储变量和数据的默认方法。
Nim 为栈上的变量保留空间,只要它在作用域内,它就会一直保留在那里。实际上,这意味着只要声明变量的函数不返回,变量就会存在。函数一返回栈就 展开 ,变量就消失了。
下面是一些存储在栈上的变量示例:
type Thing = object a, b: int var a: int var b = 14 var c: Thing var d = Thing(a: 5, b: 18)
跟踪引用和垃圾收集
在前面的部分中,我们看到 addr()
返回的 Nim 中的指针类型为 ptr T
,但我们看到 new
返回的是 ref T
。
虽然 ptr
和 ref
都是指向数据的指针,但两者之间有一个重要区别:
-
ptr T
只是一个指针,一个保存着指向数据的地址变量。作为程序员,您有责任确保在使用该指针时该指针引用的是有效内存。 -
ref T
是一个跟踪引用:这也是一个指向其他对象的地址,但 Nim 会为您跟踪它指向的数据,并确保在不需要时将其释放。
获取 ref T
指针的唯一方法是使用 new()
过程分配内存。Nim 将为您保留内存,并开始跟踪代码中引用数据的位置。当 Nim 运行时发现数据不再被引用时,知道丢弃它是安全的时,会自动释放它。这称为 垃圾收集 ,简称 GC 。
Nim 如何在内存中存储数据
本节将进行一些实验,看看 Nim 如何在内存中存储各种数据类型。
基本类型
基本 的 标量 类型是 "单个" 值,如 int
、bool
或 float
。标量通常保存在栈中,除非它们是容器类型(如对象)的一部分。
看看 Nim 是如何为基本类型管理内存的。下面的代码片段首先创建了一个类型为int 的变量 a
,并打印该变量及其大小。然后,它将创建类型为 ptr int
的第二个变量 b
,称为 指针,保存变量 a
的 地址 。
var a = 9 echo a.repr echo sizeof(a) var b = a.addr echo b.repr echo sizeof(b)
在我的计算机上回得到下面的输出
9 (1) 8 (2) ptr 0x300000 --> 9 (3) 8 (4)
-
这里并不奇怪:这是变量
a
的值 -
这是变量的大小,以字节为单位。8 字节等于 64 位,这恰好是我机器上 Nim 中
int
类型的默认大小。到现在为止,一直都还不错。 -
此行显示变量
b
, 表示b
保存变量a
的地址,该变量恰好位于地址0x300000
。在 Nim 中,地址称为参考 ref 或指针 pointer 。 -
b
本身也是一个变量,它不是ptr int
类型。在我的机器上,内存地址的大小也为64位,相当于8字节。
以上内容可由下图表示:
+---------------------------------------+ 0x??????: | 00 | 00 | 00 | 00 | 30 | 00 | 00 | 00 | b: ptr int = +---------------------------------------+ 0x300000 | | v +---------------------------------------+ 0x300000: | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 09 | a: int = 9 +---------------------------------------+
复合类型:对象 object
让我们在栈上放置一个更复杂的对象,看看会发生什么:
type Thing = object (1) a: uint32 b: uint8 c: uint16 var t: Thing (2) echo "size t.a ", t.a.sizeof echo "size t.b ", t.b.sizeof echo "size t.c ", t.c.sizeof echo "size t ", t.sizeof (3) echo "addr t.a ", t.a.addr.repr echo "addr t.b ", t.b.addr.repr echo "addr t.c ", t.c.addr.repr echo "addr t ", t.addr.repr (4)
-
对象类型
Thing
的定义,它包含几种大小的整数 -
创建
Thing
类型的变量t
-
打印
t
及其所有字段的大小, -
打印
t
及其所有字段的地址。
在 Nim 中,对象是将变量分组到一个容器中的一种方式,确保它们在内存中以与 C 相同的方式相邻放置。
在我机器上的输出:
size t.a 4 (1) size t.b 1 size t.c 2 size t 8 (2) addr t ptr 0x300000 --> [a = 0, b = 0, c = 0] (3) addr t.a ptr 0x300000 --> 0 (4) addr t.b ptr 0x300004 --> 0 addr t.c ptr 0x300006 --> 0 (5)
来看看输出:
-
首先是对象字段的大小
a
被声明为 4 字节大的uint32
,b
是 1字节的uint8 `,`c
是 2 字节大的uint16
。检查一下。 -
这里有一点令人惊讶:打印对象
t
的大小,它有8个字节大。但这并不能简单相加,因为对象的内容只有 4+1+2=7 字节!下面将详细介绍。 -
让我们获取对象
t
的地址:在我的机器上,它被放置在栈的地址0x300000
上。 -
这里我们可以看到字段
t.a
与对象本身在内存中的位置完全相同:0x300000
。t.b
的地址是0x300004
,它在t.a
之后4个字节。这是有意义的,因为t.a
有4个字节大。 -
t.c
的地址是0x300006
,它是t.b
之后的 2(!) 字节,但t.b
只有一个字节大啊?
因此,让我们来描绘一下我们从上面学到的东西:
00 01 02 03 04 05 06 07 +-------------------+----+----+---------+ 0x300000: | a | b | ?? | c | +-------------------+----+----+---------+ ^ ^ ^ | | | t 和 t.a 地址 t.b addr t.c addr
这就是我们的 Thing
对象在内存中的样子。那么标记为 ??
的洞是怎么回事,为什么总大小不是7而是8字节?
这是由编译器做 对齐 的事情引起的,它使CPU更容易访问内存中的数据。通过确保对象在内存中以其大小的倍数(或体系结构单个字大小的倍数,单个字即8,16,32,64bit)对齐,CPU可以更有效地访问内存。这通常会导致更快的代码,代价是浪费一些内存。
(您可以指示 Nim 编译器不要进行对齐,而是使用 {.packed.}
编译指示将对象的字段紧挨着放在内存中,可参阅链接:https://nim-lang.github.io/Nim/manual.html#[尼姆语言手册]中详细信息)
字符串 string
和序列 seq
以上章节描述了 Nim 如何管理内存中相对简单的静态对象。本节将讨论作为 Nim 语言实现的更复杂部分,动态数据类型:string
和 seq
。
在 Nim 中, string
和 seq
数据类型密切相关。这些基本上都是一组相同类型的对象(字符串为字符,seq为任何其他类型)。这些类型的不同之处在于它们可以在内存中动态增长或收缩。
先讲讲 seqs
创建一个 seq
包含一些对象试验一下::
var a = @[ 30, 40, 50 ]
再打印出 a
的对象类型:
var a = @[ 30, 40, 50 ] echo typeof(a) # -> seq[int]
我们看到打印出了 seq[int]
, 正是我们期望的。
现在,我们看看在 Nim 中,seq
是如何存储数据的:
var a = @[ 0x30, 0x40, 0x50 ] echo a.repr echo a.len echo a[0].addr.repr echo a[1].addr.repr
我的机器输出为:
ptr 0x300000 --> 0x900000@[0x30, 0x40, 0x50] (1) 3 (2) ptr 0x900010 --> 0x30 (3) ptr 0x900018 --> 0x40 (4)
这能推断出什么?
-
变量
a
本身被放置在栈上,恰好位于我的计算机上的地址0x300000
。 A是指向堆上地址0x900000
的某种指针!这就是真正的seq 存的地方。 -
这个 seq 包含 3 个元素,正如它应该包含的那样。
-
a[0]
是 seq 的第一个元素。其值为0x30
,i 存储在地址0x900010
,该地址正好在 seq 本身之后。 -
seq 中的第二项是
a[1]
,位于地址0x900018
。这是非常合理的,因为int
的大小是 8 字节,seq 中的所有 int 都紧挨着放在内存中。
让我们再画个图。我们知道 a
是栈上的一个指针,它指的是堆上大小为 16 字节的东西,后跟 seq 的元素:
栈 +---------------------------------------+ 0x300000 | 00 | 00 | 00 | 00 | 90 | 00 | 00 | 00 | a: seq[int] +---------------------------------------+ | 堆 v +---------------------------------------+ 0x900000 | ?? | ?? | ?? | ?? | ?? | ?? | ?? | ?? | +---------------------------------------+ 0x900008 | ?? | ?? | ?? | ?? | ?? | ?? | ?? | ?? | +---------------------------------------+ 0x900010 | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 30 | a[0] = 0x30 +---------------------------------------+ 0x900018 | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 40 | a[1] = 0x40 +---------------------------------------+ 0x900020 | 00 | 00 | 00 | 00 | 00 | 00 | 00 | 50 | a[2] = 0x50 +---------------------------------------+
这几乎解释了 seq 所有部分,除了块开头的 16 个未知字节之外:这个区域是 Nim 存储 seq 内部信息的地方。
此数据通常对用户隐藏,但您可以在 Nim 系统库中找到 seq 标头 的实现,如下所示:
type TGenericSeq = object len: int (1) reserved: int (2)
-
Nim 使用
len
字段来保存 seq 的当前长度,即 seq 中的元素数。 -
reserved
字段用于跟踪 seq 中存储的实际大小,出于性能原因,Nim 可能会提前预留更大的空间,以避免在需要添加新项目时调整 seq 的大小。
让我们做一个小实验来检查 seq 标头中的内容(有不安全的代码!):
type TGenericSeq = object (1) len, reserved: int var a = @[10, 20, 30] var b = cast[ptr TGenericSeq](a) (2) echo b.repr
-
原始的
TGenericSeq
对象未从系统库导出,因此此处定义了相同的对象 -
这里,变量
a
被强制转换为TGenericSeq
类型。
当我们使用 echo b.repr
打印结果时,输出如下所示:
ptr 0x900000 --> [len = 3, reserved = 3]
我们的 seq 大小为 3,总共为 3 个元素预留了空间。下一节将解释在 seq 中添加更多字段时会发生什么。
增长序列 seq
下面的代码段以相同的 seq 开头,然后添加新元素。每次迭代都将打印 seq 标头:
type TGenericSeq = object len, reserved: int var a = @[10, 20, 30] for i in 0..4: echo cast[ptr TGenericSeq](a).repr a.add i
这是输出,你是否能发现有趣的位:
ptr 0x900000 --> [len = 3, reserved = 3] (1) ptr 0x900070 --> [len = 4, reserved = 6] (2) ptr 0x900070 --> [len = 5, reserved = 6] (3) ptr 0x900070 --> [len = 6, reserved = 6] ptr 0x9000d0 --> [len = 7, reserved = 12] (4)
-
这是原始的 3 元素 seq :它存储在堆中的地址
0x900000
,长度为 3 个元素,并且还保留了 3 个元素的存储空间 -
添加了一个元素,发生了一些值得注意的事情:
-
当添加上面的第 4 个元素时, Nim 调整了 seq 存储的大小,以容纳 6 个元素——这允许再添加两个元素,而不必进行更大的分配。现在 seq 中有 6 个元素,总共保留了 6 个元素的大小。
-
在这里,同样的情况再次发生:区块不够大,无法容纳第 7 项,因此整个 seq 被移动到另一个地方,分配被放大以容纳 12 个元素。
结论
这篇文章只简单的介绍 Nim 如何处理内存,还有很多事情要讲。以下是一些我认为也值得的主题,但我还没来写:
-
更详细地讨论了垃圾收集,以及 Nim 可用的 GC 策略。
-
在没有垃圾收集器/内存不足的嵌入式系统的情况下使用 Nim。
-
新的尼姆运行时!
-
闭包、迭代器、异步(closures/iterator/async)中的内存使用情况:局部变量不在栈中的情况。
-
FFI:C 和 Nim 之间传递数据的讨论和示例。
这是一份还在修改的文件,非常感谢您的任何意见。来源在github上找到https://github.com/zevv/nim-memory