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

2018-12-24  本文已影响116人  Lin__Chuan

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

这里提出了一个内存模型:


struct 实例内存分布

但是这个模型真的正确吗, 为什么是这样的呢?

我们可以借助一个大佬 Mike 开发的探索内存的工具 memorydumper2, Mike 在 GOTO 哥本哈根会议发布演讲, 探讨 Swift 如何在内存中布局数据,包括内部的变量和 Swift 的内部数据结构.

借由 Mike 的演讲内容, 我们来探究以下这个思考过程.

什么是 Memory ?

我们经常查看的内存是以 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. 以此类推.

在大部分的系统中, 数据采用 小端存储 的方式进行存储. 即数据的高字节保存在内存的高地址中,而数据的低字节保存在内存的低地址中. 比如

内存中数据的分布


有几点需要说明:

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

我们通过 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

注意看结果

j -> 0110 1010 (6a)
a -> 0110 0001 (61)
c -> 0110 0011 (63)
k -> 0110 1011 (6b)

ascii 表中, 16进制不区分大小写的, A = a.

image.png .

由此可以看出最开始设计的内存模型没有问题.

利用 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
  • 在 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之内存布局

上一篇下一篇

猜你喜欢

热点阅读