深度探究HandyJSON(三) 再探内存

在这个系列的第一篇文章里我们介绍了 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.

image.png
.

  • iOS 使用了 Tagged Pointer 技术, 将NSNumber, NSDate, NSString(包括String )等小对象直接存储在指针中, NSString指针里面存储的数据变成了: Tag + Data, 如果指针不够存储数据时, 才会使用动态分配内存的方式来存储数据.
  • 在上面的例子中, e4tag, 第二块的是具体的 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之内存布局

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 218,284评论 6 506
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 93,115评论 3 395
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 164,614评论 0 354
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 58,671评论 1 293
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 67,699评论 6 392
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 51,562评论 1 305
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 40,309评论 3 418
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 39,223评论 0 276
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 45,668评论 1 314
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,859评论 3 336
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,981评论 1 348
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 35,705评论 5 347
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 41,310评论 3 330
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,904评论 0 22
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 33,023评论 1 270
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 48,146评论 3 370
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,933评论 2 355

推荐阅读更多精彩内容