在这个系列的第一篇文章里我们介绍了 Swift中指针的使用. 这篇文章会继续探究对象在内存中的分布.
问题再现
struct Person {
var name: String = "jack"
var age: Int = 18
var isBoy: Bool = true
var height: Double?
}
let p = Person()
MemoryLayout<Person>.size // 41 = 16 + 8 + 1 + 16
MemoryLayout<Person>.stride // 48
MemoryLayout<Person>.alignment // 8
对于这样的一个实例 p 来说,
- String 类型占 16 个字节, Int 类型占 8 个字节, Bool 类型占 1 个字节, Double 类型占 8 个字节, 由于可选类型占 1 个字节, 以及内存对齐的限制, 导致增加了8个字节的存储空间, 最终占16 个字节.
- size 指 Person 实例连续内存占用, stride 指存储在连续内存或Array 中时, 从Person 的实例的开始到下一个实例的开始的字节数. 虽然 Bool 类型 只占一个字节, 但是考虑的是连续的内存占用以及 内存对齐, 所以他在这里算作 8 个字节.
- alignment, 默认内存对齐方式.
这里提出了一个内存模型:
struct 实例内存分布
但是这个模型真的正确吗, 为什么是这样的呢?
我们可以借助一个大佬 Mike 开发的探索内存的工具 memorydumper2, Mike 在 GOTO 哥本哈根会议发布演讲, 探讨 Swift 如何在内存中布局数据,包括内部的变量和 Swift 的内部数据结构.
借由 Mike 的演讲内容, 我们来探究以下这个思考过程.
什么是 Memory ?
- 从硬件角度看, 计算机系统的内存目前采用硅芯片, 允许存储数十亿比特的信息. 常见的系统内存采用DRAM, 即 Dynamic Random Access Memory, 动态随机存取存储器. 它是 RAM 的一种, 特点是掉电之后丢失数据.
- 从软件角度来看, 信息的基本单位是比特
bit
, 即1或0. 传统上,我们以8个为一组组织比特, 称为字节byte
。 内存只是一个很长的字节序列.
我们经常查看的内存是以 word
为单位组织的内存, 而不是以 byte
为单位组织的内存, 这里 word
是计算机科学中一个含糊的术语, 通常用它来描述一个 pointer
的大小的单位. word 的大小, 与系统硬件(总线、cpu命令字位数等)有关.
若数据总线为16位, 1 word = 2 byte = 16 bit
若数据总线为32位, 1 word = 4 byte = 32 bit
在 64bit 设备里, 1 word = 8 byte = 64 bit
内存通常以十六进制映射, 这意味着使用 base16
来对数据进行编码. base 16
指使用16个字符, 对二进制数据进行编码的方式. 比如数字是从 0 到 9, A 到 F, 接下来是 10. 以此类推.
在大部分的系统中, 数据采用 小端存储 的方式进行存储. 即数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中. 比如
内存中数据的分布
有几点需要说明:
- 栈是存储所有局部变量的位置. 每次进行函数调用时, 它都会将该函数的局部变量添加到栈中的所有先前内容之上(分配到内存地址较大的位置), 因此, 如果你再次进行调用, 则会将其添加到栈顶部. 从函数返回时, 栈顶部的数据将被删除.
-
堆是存储动态分配的数据的位置, 比如, 在创建新对象时, 会在堆上分配一些内存. 堆上的所有东西都有一定的生命周期, 在
ARC
机制下, 由程序自行管理.
memorydumper 的工作原理.
这是一个获取值并返回无符号8位整数或字节数组的函数, 这个函数就做了一件事, 将给定类型的实例临时重新绑定到其他类型, 进行访问.
func bytes<T>(of value: T) -> [UInt8]{
var value = value
let size = MemoryLayout<T>.size
return withUnsafePointer(to: &value) {
$0.withMemoryRebound(to: UInt8.self, capacity: size, {
Array(UnsafeBufferPointer(start: $0, count: size))
})
}
}
let x = 0x0102030405060708
let x1 = bytes(of: x)
let x2 = bytes(of: 100)
print(x1) // [8, 7, 6, 5, 4, 3, 2, 1]
print(x2) // [100, 0, 0, 0, 0, 0, 0, 0]
通过以上我们可以发现, 当前的确是采用小端存储的.
这里还可以扩展一下
// 打印 struct 地址
func address_struct(ptr: UnsafeRawPointer) -> String {
return String(Int(bitPattern: ptr), radix: 16)
}
// 获取 struct 的 headPointer
func headPointerOfStruct<T>(instance: inout T) -> UnsafeMutablePointer<Int8> {
return withUnsafeMutablePointer(to: &instance) {
return UnsafeMutableRawPointer($0).bindMemory(to: Int8.self, capacity: MemoryLayout<T>.stride)
}
}
print("struct 地址: ", address_struct(ptr: &p))
print("地址2: ", bytes(of: UnsafeMutablePointer(&p)))
print("headPointer: ", headPointerOfStruct(instance: &p))
struct 地址: 1 00 58 85 88
bytes: [136, 133, 88, 0, 1, 0, 0, 0] -> 0x88, 0x85, 0x58, 0x00, 0x1, 0, 0, 0
headPointer: 0x0000000100588588
- 对于
struct
,headPointer
指向的地址就是struct
的地址. 也是元数据的地址. -
Int(bitPattern: ptr)
原来是unsafeBitCast(ptr, to: Int.self)
转变过来的, 后来被取代了. 内部的实现类似于headPointerOfStruct<T>
.
我们通过 bytes
这个方法, 将包含一堆字节的值转储为我们可以理解的方式, 但是对于复杂的对象而言, 一串字节数据或许包含更多内容, 比如他实际上是指向其他值的指针. 我们要做的就是尽可能获取到完整的结构.
memorydumper
这个框架内部通过下面这个方式获取实例在内存中的字节数组.
extension mach_vm_address_t {
init(_ ptr: UnsafeRawPointer?) {
self.init(UInt(bitPattern: ptr))
}
}
func lc_getBuffer(pointer: UnsafeRawPointer, instanceSize: UInt) {
func safeRead(ptr: UnsafeRawPointer, into: inout [UInt8]) -> Bool {
let result = into.withUnsafeMutableBufferPointer({ bufferPointer -> kern_return_t in
var outSize: mach_vm_size_t = 0
return mach_vm_read_overwrite(
mach_task_self_,
mach_vm_address_t(ptr),
mach_vm_size_t(bufferPointer.count),
mach_vm_address_t(bufferPointer.baseAddress),
&outSize)
})
return result == KERN_SUCCESS
}
var buffer: [UInt8] = Array(repeating: 0, count: Int(instanceSize))
// 获取指定对象的内存数据, 以 UInt8 数组输出.
let success = safeRead(ptr: pointer, into: &buffer)
if success == false {
print("lc_buffer: 解析出错")
}
// 讲 buffer 转为 16进制输出
let hexBuffer = hexString(bytes: buffer, limit: 64, separator: " || ")
print("lc_buffer", hexBuffer)
}
对于我们开头的那个例子而言, 利用上述方式可以看到 Person 实例的内存状况.
struct Person {
var name: String = "jack"
var age: Int = 18
var isBoy: Bool = true
var height: Double?
}
let p = Person()
lc_getBuffer(pointer: &p, instanceSize: UInt(MemoryLayout<Person>.size))
打印结果
lc_buffer: 00000000000000e4 || 6a61636b00000000 || 1200000000000000 || 0100000000000000 || 0000000000000000 || 01
注意看结果
- 这打印的内容是数据在内存中的真实状况.
- 第一个变量, name, String 类型, 在内存中占16个字节, 所以第一块和第二块表示
name
的内容, 字符串jack
在内存中是以 ascii 码的形式进行存储的.
j -> 0110 1010 (6a)
a -> 0110 0001 (61)
c -> 0110 0011 (63)
k -> 0110 1011 (6b)
在 ascii
表中, 16进制不区分大小写的, A = a.
- iOS 使用了
Tagged Pointer
技术, 将NSNumber, NSDate, NSString(包括String )等小对象直接存储在指针中, NSString指针里面存储的数据变成了:Tag + Data
, 如果指针不够存储数据时, 才会使用动态分配内存的方式来存储数据. - 在上面的例子中,
e4
是tag
, 第二块的是具体的data
. - 第二个变量, age, Int 类型, 在内存中占 8 个字节, 默认值为
18
, 所以在内存中为1200000000000000
- 第三个变量, isBool, 布尔类型, 在内存中占 1 个字节, 由于内存对齐, 占8个字节, 默认值为
true
, 对应于0100000000000000
. - 第四个变量, height, 可选类型 + Double 类型, Double 占 8 个字节, 默认值为 0 , 对应与内存中的
0000000000000000
, 可选类型占 1 个字节. 实际上就是 Bool 类型, 在这里为 true, 所以为01
.
由此可以看出最开始设计的内存模型没有问题.
利用 memorydumper 可以画出 Person 实例的内存图, 大致如下.
image.png
还有几点需要说明一下:
- 文章中举的例子比较简单, 并不涉及到复杂的对象, 所以没有循环遍历指针这一操作, 对于复杂的对象来说, 为了获取到完整的字节数组, 我们就需要调用下面的方法.
buffer.withUnsafeBufferPointer({ bufferPointer in
return bufferPointer.baseAddress?.withMemoryRebound(
to: Pointer.self,
capacity: bufferPointer.count / MemoryLayout<Pointer>.size,
{
let castBufferPointer = UnsafeBufferPointer(
start: $0,
count: bufferPointer.count / MemoryLayout<Pointer>.size)
return Array(castBufferPointer)
}) ?? []
})
00000000000000e4 || 6a61636b00000000 || 1200000000000000 || 0100000000000000 || 0000000000000000 || 01
- 对于上面这串字节码, 一开始我们是无法确定它是指针还是一般的整数, 我们试着将其切为 8 个字节的块, 读取每个切片的字节数据, 找到这些字节可能指示的所有指针, 可以采用这些指针并重复该过程, 并且基本上可以找到最后的树状结构. 只要有可以利用的指针, 就可以继续遍历. 这就是对上面
遍历buffer
这个代码做出的解释. - 这就又引出一个问题. 通常在程序中, 当你尝试从实际上不是指针的指针读取时, 它会返回一个非法的内存, 从而导致程序崩溃. 我们希望能够从指针中读取而不会崩溃.
mach_vm_read_overwrite
能做到这一点.
- 在 Mac 和 iOS 上,有一个名为
mach_vm_read_overwrite
的低级函数. 这个函数可以在其中指定两个指针以及从一个指针复制到另一个指针的字节数.- 这是一个系统调用, 即调用是在内核级别执行的, 因此可以安全地检查它并返回错误.
- 下面是函数原型, 它接受一个任务, 就像一个进程, 如果你有正确的权限, 你可以从其他进程读取.
public func mach_vm_read_overwrite( _ target_task: vm_map_t, _ address: mach_vm_address_t, _ size: mach_vm_size_t, _ data: mach_vm_address_t, _ outsize: UnsafeMutablePointer<mach_vm_size_t>!) -> kern_return_t
- 该函数接受一个源地址, 一个长度, 一个目标地址和一个指向长度的指针,它会告诉你它实际读取了多少字节.
总结
一句话总结, memorydumper
通过 mach_vm_read_overwrite
这个函数获取到实例对象的指针所对应的内存中的字节数组, 分析字节数组来获取到对象的内存分布.
后记
文中的那份探索内存的工具是需要借助 Graphviz 的, Graphviz 是一个可以轻松画出数据之间关系的开源工具, 我们可以从官网中进行下载安装. 推荐使用 Homebrew, 在这里我们可以看到这个工具能实现的所有图形.
安装工具
brew install graphviz
运行代码
Graphviz 是没有对应于 macOS Mojave 的 GUI 工具的. 所以利用 memorydumper2 直接运行是看不到效果的.
我们需要将源码中的下面这行代码注释掉.
NSWorkspace.shared.openFile(path, withApplication: "Graphviz")
添加
runScript
这个方法, 我们直接利用dot -Tpng xxx.dot -o xxx.png
这个指令来生成图片.// 执行脚本 func runScript(fileName: String) { // 初始化并设置shell 执行命令的路径(命令解释器) let task = Process() task.launchPath = "/bin/sh"; // -c 用来执行string-commands(命令字符串), // 也就说不管后面的字符串里是什么都会被当做shellcode来执行 // dot command let dotCmd = "/usr/local/bin/dot" task.arguments = ["-c", "\(dotCmd) -Tpng \(fileName).dot -o \(fileName).png"] // 开始 task task.launch() }
为了方便查看生成的图片, 我将目标文件设置在和 Unix 文件同目录下.
下面就是 Unix 文件所在的目录, 只需要
show in finder
就能找到.还有最后一点, 每次运行完工程, 我们只是相当于没有带参数运行文件, 但是这个 Unix 文件 设置了参数, 我们需要在 terminal
中添加参数并执行.
就像下面这样, 我们找到 Unix 文件, 在当前目录下执行文件, all
是参数.
写到这里即使你们前面没弄明白, 代码也能成功运行, 看到效果慢慢调试. enjoy :)
参考
base家族:base16、base32和base64,转码原理
ROM、RAM、DRAM、SRAM和FLASH的区别是什么.
大小端模式
探索内存的工具 memorydumper2
十分钟学会graphviz画图
swift之内存布局
网友评论