OOM

作者: forping | 来源:发表于2021-01-07 10:58 被阅读0次

本文是<<iOS开发高手课>> 第十四篇学习笔记.

OOM 的全称是 Out-Of-Memory,是由于 iOS 的 Jetsam 机制造成的一种“另类” Crash,它不同于常规的 Crash,通过 Signal 捕获等 Crash 监控方案无法捕获到 OOM 事件。

JetSam 机制,指的是操作系统为了控制内存资源过度使用而采用的一种资源管控机制。

2种情况触发 OOM:

  • 系统由于整体内存使用过高,会基于优先级策略杀死优先级较低的 App;
  • 当前 App 达到了 "highg water mark" ,系统也会强杀当前 App(超过系统对当前单个 App 的内存限制值)

通过 JetsamEvent 日志计算内存限制值

想要了解不同机器在不同系统版本的情况下,对 App 的内存限制是怎样的,有一种方法就是查看手机中以 JetsamEvent 开头的系统日志(我们可以从设置 -> 隐私 -> 分析中看到这些日志)。

在这些系统日志中,查找崩溃原因时我们需要关注 per-process-limit 部分的 rpages。rpages 表示的是 ,App 占用的内存页数量;per-process-limit 表示的是,App 占用的内存超过了系统对单个 App 的内存限制。

所有 App 彻底退出,只跑了一个为了测试内存临界值的 Demo App。循环申请内存,ViewController 代码如下

- (void)viewDidLoad {
    [super viewDidLoad];
    NSMutableArray *array = [NSMutableArray array];
    for (NSInteger index = 0; index < 10000000; index++) {
        UIImageView *imageView = [[UIImageView alloc] initWithFrame:CGRectMake(0, 0, 100, 100)];
        UIImage *image = [UIImage imageNamed:@"AppIcon"];
        imageView.image = image;
        [array addObject:imageView];
    }
}

崩溃后这部分日志的结构如下:

{
    "uuid" : "fa38f53d-55ba-37e0-8392-800ea0a88018",
// states:当前应用的运行状态,对于这个应用而言是正在前台运行的状态,这类崩溃我们称之为FOOM(Foreground Out Of Memory);与此相对应的也有应用程序在后台发生的 OOM 崩溃,这类崩溃我们称之为BOOM(Background Out Of Memory)。

    "states" : [
      "frontmost",// 前台
    ],
    "lifetimeMax" : 1488,
    "killDelta" : 20204,
    "age" : 229126847641,
    "purgeable" : 0,
    "fds" : 50,
    "genCount" : 0,
    "coalition" : 386,
// rpages:是resident pages的缩写,表明进程当前占用的内存页数量,
    "rpages" : 89600,
    "priority" : 0,
// reason:表明进程被终止的的原因,Heimdallr-Example这个应用被终止的原因是超过了操作系统允许的单个进程物理内存占用的上限。
    "reason" : "per-process-limit",
    "pid" : 4493,
    "idleDelta" : 35156829,
    "name" : "fp",
    "cpuTime" : 8.6578689999999998
  },

现在,我们已经知道了内存页数量 rpages 为 89600,只要再知道内存页大小的值,就可以计算出系统对单个 App 限制的内存是多少了。

  "memoryStatus" : {
  "compressorSize" : 29982,
  "compressions" : 10864669,
  "decompressions" : 7348104,
  "zoneMapCap" : 1109458944,
  "largestZone" : "APFS_4K_OBJS",
  "largestZoneSize" : 29573120,
// pageSize:指的是当前设备物理内存页的大小,当前设备是iPhoneXs Max,大小是 16KB,苹果 A7 芯片之前的设备物理内存页大小则是 4KB。
  "pageSize" : 16384,
  "uncompressed" : 87247,
  "zoneMapSize" : 147537920,
  "memoryPages" : {
    "active" : 58130,
    "throttled" : 0,
    "fileBacked" : 38218,
    "wired" : 30456,
    "anonymous" : 77619,
    "purgeable" : 8645,
    "inactive" : 53608,
    "free" : 8058,
    "speculative" : 4099
  }
},

内存页大小的值,我们也可以在 JetsamEvent 开头的系统日志里找到,也就是 pageSize 的值, 16384。

我们就可以计算出当前 App 的内存限制值:pageSize * rpages / 1024 /1024 =16384 * 89600 / 1024 / 1024 得到的值是 1400 MB,即 1.4G。

并不是所有的 JetsamEvent 中都可以拿到准确的阈值,有的存在偏差。。。
比如有些JetsamEvent日志里, rpages 非常小,可能是在后台,内存吃紧被杀死的.
不同手机 OOM 临界值不同

这些 JetsamEvent 日志,都是系统在杀掉 App 后留在手机里的。在查看这些日志时,我们就会发现,很多日志都是 iOS 系统内核强杀掉那些优先级不高,并且占用的内存超过限制的 App 后留下的。

这些日志属于系统级的,会存在系统目录下。App 上线后开发者是没有权限获取到系统目录内容的,也就是说,被强杀掉的 App 是无法获取到系统级日志的,只能线下设备通过连接 Xcode 获取到这部分日志。获取到 Jetsam 后,就能够算出系统对 App 设置的内存限制值。

翻阅XNU源码的时候我们可以看到在Jetsam机制终止进程的时候最终是通过发送SIGKILL异常信号来完成的。

/*
 * The jetsam no frills kill call
 *      Return: 0 on success
 *      error code on failure (EINVAL...)
 */
static int
jetsam_do_kill(proc_t p, int jetsam_flags, os_reason_t jetsam_reason)
{
    int error = 0;
    error = exit_with_reason(p, W_EXITCODE(0, SIGKILL), (int *)NULL, FALSE, FALSE, jetsam_flags, jetsam_reason);
    return error;
}

#define SIGKILL 9       /* kill (cannot be caught or ignored) */

iOS 系统是怎么发现 Jetsam

iOS 系统会开启优先级最高的线程 vm_pressure_monitor 来监控系统的内存压力情况,并通过一个堆栈来维护所有 App 的进程。另外,iOS 系统还会维护一个内存快照表,用于保存每个进程内存页的消耗情况。

当监控系统内存的线程发现某 App 内存有压力了,就发出通知,内存有压力的 App 就会去执行对应的代理,也就是你所熟悉的 didReceiveMemoryWarning 代理。通过这个代理,你可以获得最后一个编写逻辑代码释放内存的机会。这段代码的执行,就有可能会避免你的 App 被系统强杀。

系统在强杀 App 前,会先做优先级判断 , iOS 系统内核里有一个数组,专门用于维护线程的优先级。这个优先级规定就是:内核用线程的优先级是最高的,操作系统的优先级其次,App 的优先级排在最后。并且,前台 App 程序的优先级是高于后台运行 App 的;线程使用优先级时,CPU 占用多的线程的优先级会被降低。

iOS 系统在因为内存占用原因强杀掉 App 前,至少有 6 秒钟的时间可以用来做优先级判断。同时,JetSamEvent 日志也是在这 6 秒内生成的。

通过 XNU 获取内存限制值

除了 JetSamEvent 日志外,我们还可以通过 XNU 来获取内存的限制值。

在 XNU 中,有专门用于获取内存上限值的函数和宏。我们可以通过 memorystatus_priority_entry 这个结构体,得到进程的优先级和内存限制值。

// 获取进程的 pid、优先级、状态、内存阈值等信息,priority 表示的是进程的优先级,limit 就是我们想要的进程内存限制值。
typedef struct memorystatus_priority_entry {
    pid_t pid;
    int32_t priority;
    uint64_t user_data;
    int32_t limit;
    uint32_t state;
} memorystatus_priority_entry_t;
 
 
// 基于下面这些宏可以达到查询内存阈值等信息,也可以修改内存阈值等
/* Commands */
#define MEMORYSTATUS_CMD_GET_PRIORITY_LIST            1
#define MEMORYSTATUS_CMD_SET_PRIORITY_PROPERTIES      2
#define MEMORYSTATUS_CMD_GET_JETSAM_SNAPSHOT          3
#define MEMORYSTATUS_CMD_GET_PRESSURE_STATUS          4
#define MEMORYSTATUS_CMD_SET_JETSAM_HIGH_WATER_MARK   5    /* Set active memory limit = inactive memory limit, both non-fatal   */
#define MEMORYSTATUS_CMD_SET_JETSAM_TASK_LIMIT        6    /* Set active memory limit = inactive memory limit, both fatal   */
#define MEMORYSTATUS_CMD_SET_MEMLIMIT_PROPERTIES      7    /* Set memory limits plus attributes independently           */
#define MEMORYSTATUS_CMD_GET_MEMLIMIT_PROPERTIES      8    /* Get memory limits plus attributes                 */
#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_ENABLE   9    /* Set the task's status as a privileged listener w.r.t memory notifications  */
#define MEMORYSTATUS_CMD_PRIVILEGED_LISTENER_DISABLE  10   /* Reset the task's status as a privileged listener w.r.t memory notifications  */
/* Commands that act on a group of processes */
#define MEMORYSTATUS_CMD_GRP_SET_PROPERTIES           100
#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include "kern_memorystatus.h"
 
#define NUM_ENTRIES 1024
 
char *state_to_text(int State)
{
    // Convert kMemoryStatus constants to a textual representation
 
    static char returned[80];
 
    sprintf (returned, "0x%02x ",State);
 
    if (State & kMemorystatusSuspended) strcat(returned,"Suspended,");
 
    if (State & kMemorystatusFrozen) strcat(returned,"Frozen,");
 
    if (State & kMemorystatusWasThawed) strcat(returned,"WasThawed,");
 
    if (State & kMemorystatusTracked) strcat(returned,"Tracked,");
 
    if (State & kMemorystatusSupportsIdleExit) strcat(returned,"IdleExit,");
 
    if (State & kMemorystatusDirty) strcat(returned,"Dirty,");
 
    if (returned[strlen(returned) -1] == ',')
 
        returned[strlen(returned) -1] = '\0';
 
    return (returned);
}
 
int main (int argc, char **argv)
{
    struct memorystatus_priority_entry memstatus[NUM_ENTRIES];
 
    size_t  count = sizeof(struct memorystatus_priority_entry) * NUM_ENTRIES;
 
    // call memorystatus_control
 
    int rc = memorystatus_control (MEMORYSTATUS_CMD_GET_PRIORITY_LIST,    // 1 - only supported command on OS X
 
                                   0,    // pid
 
                                   0,    // flags
 
                                   memstatus, // buffer
 
                                   count); // buffersize
 
    if (rc < 0) { perror ("memorystatus_control"); exit(rc);}
 
    int entry = 0;
 
    for (; rc > 0; rc -= sizeof(struct memorystatus_priority_entry))
    {
        printf ("PID: %5d\tPriority:%2d\tUser Data: %llx\tLimit:%2d\tState:%s\n",
 
                memstatus[entry].pid,
 
                memstatus[entry].priority,
 
                memstatus[entry].user_data,
 
                memstatus[entry].limit,
 
                state_to_text(memstatus[entry].state));
 
        entry++;
    }
}

通过 XNU 的宏获取内存限制,需要有 root 权限,而 App 内的权限是不够的,所以正常情况下,作为 App 开发者你是看不到这个信息的。

通过内存警告获取内存限制值

还可以利用 didReceiveMemoryWarning 这个内存压力代理事件来动态地获取内存限制值。

iOS 系统在强杀掉 App 之前还有 6 秒钟的时间,足够你去获取记录内存信息了。那么,如何获取当前内存使用情况呢?

iOS 系统提供了一个函数 task_info, 可以帮助我们获取到当前任务的信息。关键代码如下:

#import <sys/sysctl.h>
#import <mach/mach.h>


struct mach_task_basic_info info;
mach_msg_type_number_t size = sizeof(info);
kern_return_t kl = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)&info, &size);

代码中,task_info_t 结构里包含了一个 resident_size 字段,用于表示使用了多少内存。这样,我们就可以获取到发生内存警告时,当前 App 占用了多少内存。代码如下:

    struct mach_task_basic_info info;
    mach_msg_type_number_t size = sizeof(info);
    kern_return_t kl = task_info(mach_task_self(), MACH_TASK_BASIC_INFO, (task_info_t)&info, &size);
    if (kl != KERN_SUCCESS)
    {
       return NSNotFound;
    }
    
    float used_mem = info.resident_size;
    return used_mem;
    NSLog(@"使用了 %f MB 内存", used_mem / 1024.0f / 1024.0f);

经过测试,上面代码获取的内存和Xcode上显示的不一样.

内存信息存在 task_info.h (完整路径 usr/include/mach/task.info.h)文件的 task_vm_info 结构体中,其中 phys_footprint 就是物理内存的使用,而不是驻留内存 resident_size。结构体里和内存相关的代码如下:


struct task_vm_info {
  mach_vm_size_t  virtual_size;       // 虚拟内存大小
  integer_t region_count;             // 内存区域的数量
  integer_t page_size;
  mach_vm_size_t  resident_size;      // 驻留内存大小
  mach_vm_size_t  resident_size_peak; // 驻留内存峰值
  ...
  /* added for rev1 */
  mach_vm_size_t  phys_footprint;     // 物理内存
  ...

可以使用下面的函数

    int64_t memoryUsageInByte = 0;
    task_vm_info_data_t vmInfo;
    mach_msg_type_number_t count = TASK_VM_INFO_COUNT;
    kern_return_t kernelReturn = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &vmInfo, &count);
    if(kernelReturn == KERN_SUCCESS) {
        memoryUsageInByte = (int64_t) vmInfo.phys_footprint;
        NSLog(@"使用了 %f MB 内存", memoryUsageInByte/1024.0f/1024.0f);
    } else {
        NSLog(@"Error with task_info(): %s", mach_error_string(kernelReturn));
    }

适用于 iOS13 系统的获取方式

if (@available(iOS 13.0, *)) {
    return os_proc_available_memory() / 1024.0 / 1024.0;
}

我们可以通过 os_proc_available_memory 获取到当前可以用内存,通过 phys_footprint 获取到当前 App 占用内存,2者的和也就是当前设备的内存上限,超过即触发 Jetsam 机制。

定位内存问题信息收集

现在,我们已经可以通过三种方法来获取内存上限值了,而且通过内存警告的方式还能够动态地获取到这个值。有了这个内存上限值以后,你就可以进行内存问题的信息收集工作了。

要想精确地定位问题,我们就需要 dump 出完整的内存信息,包括所有对象及其内存占用值,在内存接近上限值的时候,收集并记录下所需信息,并在合适的时机上报到服务器里,方便分析问题。

获取到了每个对象的内存占用量还不够,你还需要知道是谁分配的内存,这样才可以精确定位到问题的关键所在。一个对象可能会在不同的函数里被分配了内存并被创建了出来,当这个对象内存占用过大时,如果不知道是在哪个函数里创建的话,问题依然很难精确定位出来。

内存分配函数malloccalloc 等默认使用的是nano_zonenano_zone是 256B 以下小内存的分配,大于 256B 的时候会使用 scalable_zone 来分配。

如果主要是针对大内存的分配监控,可以只针对 scalable_zone 进行分析,同时也可以过滤掉很多小内存分配监控。比如,malloc 函数用的是 malloc_zone_malloc,calloc 用的是 malloc_zone_calloc。

malloc_zone_malloc 函数的实现

void *
malloc_zone_malloc(malloc_zone_t *zone, size_t size)
{
    return _malloc_zone_malloc(zone, size, MZ_NONE);
}

static void *
_malloc_zone_malloc(malloc_zone_t *zone, size_t size, malloc_zone_options_t mzo)
{
    MALLOC_TRACE(TRACE_malloc | DBG_FUNC_START, (uintptr_t)zone, size, 0, 0);

    void *ptr = NULL;

    if (malloc_check_start) {
        internal_check();
    }
    if (size > MALLOC_ABSOLUTE_MAX_SIZE) {
        goto out;
    }

    ptr = zone->malloc(zone, size);     // if lite zone is passed in then we still call the lite methods
        // 在 zone 分配完内存后就开始使用 malloc_logger 进行进行记录
    if (os_unlikely(malloc_logger)) {
        malloc_logger(MALLOC_LOG_TYPE_ALLOCATE | MALLOC_LOG_TYPE_HAS_ZONE, (uintptr_t)zone, (uintptr_t)size, 0, (uintptr_t)ptr, 0);
    }

    MALLOC_TRACE(TRACE_malloc | DBG_FUNC_END, (uintptr_t)zone, size, (uintptr_t)ptr, 0);
out:
    if (os_unlikely(ptr == NULL)) {
        malloc_set_errno_fast(mzo, ENOMEM);
    }
    return ptr;
}

其他使用 scalable_zone 分配内存的函数的方法也类似,所有大内存的分配,不管外部函数是怎么包装的,最终都会调用 malloc_logger 函数。
可以 去 Hook 这个函数,加上自己的统计记录就能够通盘掌握内存的分配情况。出现问题时,将内存分配记录的日志捞上来,你就能够跟踪到导致内存不合理增大的原因了。

malloc_logger hook代码

typedef void(malloc_logger_t)(
        uint32_t type,
        uintptr_t arg1,
        uintptr_t arg2,
        uintptr_t arg3,
        uintptr_t result,
        uint32_t num_hot_frames_to_skip);

extern malloc_logger_t *malloc_logger;

void malloc_logger_impl(uint32_t type,uintptr_t arg1,uintptr_t arg2,uintptr_t arg3,uintptr_t result,uint32_t num_hot_frames_to_skip){
    printf("%d-%lu-%lu-%lu-%lu-%d\n",type,arg1,arg2,arg3,result,num_hot_frames_to_skip);
}
int main(int argc, char * argv[]) {
    NSString * appDelegateClassName;
    @autoreleasepool {
        appDelegateClassName = NSStringFromClass([AppDelegate class]);
        malloc_logger = malloc_logger_impl;
    }
    return UIApplicationMain(argc, argv, nil, appDelegateClassName);
}

收集上报

分配堆栈可以用 backtrace 函数捕获,但捕获到的地址是虚拟内存地址,不能从符号表 dsym 解析符号。所以还要记录每个 image 加载时的偏移 slide,符号表地址 = 堆栈地址 - slide。

降低OOM

基于内存快照

抖音有一篇文章讲述了基于内存快照,降低OOM的文章:https://juejin.cn/post/6885144933997494280

简单总结:

线上 Memory Graph 采集内存快照主要是为了获取当前运行状态下所有内存对象以及对象之间的引用关系,用于后续的问题分析。主要需要获取的信息如下:

  • 所有内存的节点,以及其符号信息(如OC/Swift/C++ 实例类名,或者是某种有特殊用途的 VM 节点的 tag 等)。
  • 节点之间的引用关系,以及符号信息(偏移,或者实例变量名),OC/Swift成员变量还需要记录引用类型。

由于采集的过程发生在程序正常运行的过程中,为了保证不会因为采集内存快照导致程序运行异常,整个采集过程需要在一个相对静止的运行环境下完成。因此,整个快照采集的过程大致分为以下几个步骤:

  • 挂起所有非采集线程。
  • 获取所有的内存节点,内存对象引用关系以及相应的辅助信息。
  • 写入文件。
  • 恢复线程状态。

具体的过程

内存节点的获取

程序的内存都是由虚拟内存组成的,每一块单独的虚拟内存被称之为VM Region,通过 mach 内核的vm_region_recurse/vm_region_recurse64函数我们可以遍历进程内所有VM Region,并通过vm_region_submap_info_64结构体获取以下信息:

  • 虚拟地址空间中的地址和大小。
  • Dirty 和 Swapped 内存页数,表示该VM Region的真实物理内存使用。
  • 是否可交换,Text 段、共享 mmap 等只读或随时可以被交换出去的内存,无需关注。
  • user_tag,用户标签,用于提供该VM Region的用途的更准确信息。
kern_return_t krc = KERN_SUCCESS;
vm_address_t address = 0;
vm_size_t size = 0;
uint32_t depth = 1;
pid_t pid = getpid();
char buf[PATH_MAX];
while (1) {
    struct vm_region_submap_info_64 info;
    mach_msg_type_number_t count = VM_REGION_SUBMAP_INFO_COUNT_64;
    krc = vm_region_recurse_64(mach_task_self(), &address, &size, &depth, (vm_region_info_64_t)&info, &count);
    if (krc == KERN_INVALID_ADDRESS){
        break;
    }
    if (info.is_submap){
        depth++;
    } else {
        //do stuff
        proc_regionfilename(pid, address, buf, sizeof(buf));
        printf("Found VM Region: %08x to %08x (depth=%d) user_tag:%s name:%s\n", (uint32_t)address, (uint32_t)(address+size), depth, [visualMemoryTypeString(info.user_tag) cStringUsingEncoding:NSUTF8StringEncoding], buf);
        address += size;
    }
}

内存节点大致分为这几类:

  • App的二进制文件在内存的映射(如OnlineMemoryGraphDemo)
  • 动态库在内存中的映射(如libBacktraceRecording.dylib,libdispatch.dylib等)
  • 系统或自定义字体等资源(SFUI.ttf, PingFang.ttc)
  • 栈区(STACK name:/System/Library/Caches/com.apple.dyld/dyld_shared_cache_arm64)
  • Malloc Zone,Malloc Zone分为Nano和Scalable,Nano分配16B~256B(16B的整数倍)的内存,Scalable分配256B以上的大内存

大多数 VM Region 作为一个单独的内存节点,仅记录起始地址和 Dirty、Swapped 内存作为大小,以及与其他节点之间的引用关系;而 libmalloc 维护的堆内存所在的 VM Region 则由于往往包含大多数业务逻辑中的 Objective-C 对象、C/C++对象、buffer 等,可以获取更详细的引用信息

在 iOS 系统中为了避免所有的内存分配都使用系统调用产生性能问题,相关的库负责一次申请大块内存,再在其之上进行二次分配并进行管理,提供给小块需要动态分配的内存对象使用,称之为堆内存。

程序中使用到绝大多数的动态内存都通过堆进行管理,在 iOS 操作系统上,主要的业务逻辑分配的内存都通过libmalloc进行管理,部分系统库为了性能也会使用自己的单独的堆管理,例如WebKit内核使用bmalloc,CFNetwork也使用自己独立的堆,在这里我们只关注libmalloc内部的内存管理状态,而不关心其它可能的堆(即这部分特殊内存会以VM Region的粒度存在,不分析其内部的节点引用关系)。

我们可以通过malloc_get_all_zones获取libmalloc内部所有的zone,并遍历每个zone中管理的内存节点,获取 libmalloc 管理的存活的所有内存节点的指针和大小。
获取所有Malloc Zone

vm_address_t *zones = NULL;
unsigned int zoneCount = 0;
kern_return_t result = malloc_get_all_zones(TASK_NULL, memory_reader_callback, &zones, &zoneCount);
if (result == KERN_SUCCESS) {    
    for (unsigned int i = 0; i < zoneCount; i++) {        
        malloc_zone_t *zone = (malloc_zone_t *)zones[i];               
        printf("Found zone name:%s\n", zone->zone_name);
    }
}

获取Zone内所有分配的节点

malloc_introspection_t *introspection = zone->introspect;

if (!introspection) {
    continue;
}

void (*lock_zone)(malloc_zone_t *zone)   = introspection->force_lock;
void (*unlock_zone)(malloc_zone_t *zone) = introspection->force_unlock;

// Callback has to unlock the zone so we freely allocate memory inside the given block
malloc_object_enumeration_block_t callback = ^(__unsafe_unretained id object, __unsafe_unretained Class actualClass) {
    unlock_zone(zone);
    block(object, actualClass);
    lock_zone(zone);
};

BOOL lockZoneValid = mallocPointerIsReadable((void *)lock_zone);
BOOL unlockZoneValid = mallocPointerIsReadable((void *)unlock_zone);

// There is little documentation on when and why
// any of these function pointers might be NULL
// or garbage, so we resort to checking for NULL
// and whether the pointer is readable
if (introspection->enumerator && lockZoneValid && unlockZoneValid) {
    lock_zone(zone);
    introspection->enumerator(TASK_NULL, (void *)&callback, MALLOC_PTR_IN_USE_RANGE_TYPE, (vm_address_t)zone, memory_reader_callback, &vm_range_recorder_callback);
    unlock_zone(zone);
}
符号化

获取内存节点之后,我们需要为每个节点找到更加详细的类型名称,用于后续的分析。

  • 对于 VM Region 内存节点,我们可以通过 user_tag 赋予它有意义的符号信息;
  • 堆内存对象包含 raw buffer,Objective-C/Swift、C++等对象。对于 Objective-C/Swift、C++这部分,我们通过内存中的一些运行时信息,尝试符号化获取更加详细的信息。

Objective/Swift 对象的符号化相对比较简单,Swift在内存布局上兼容了Objective-C,也有isa指针,objc相关方法可以作用于两种语言的对象上。只要保证 isa 指针合法,对象实例大小满足条件即可认为正确。

获取所有OC/SwiftClass类型

CFMutableSetRef registeredClasses;

unsigned int updateRegisteredClasses() {
    if (!registeredClasses) {
        registeredClasses = CFSetCreateMutable(NULL, 0, NULL);
    } else {
        CFSetRemoveAllValues(registeredClasses);
    }
    unsigned int count = 0;
    Class *classes = objc_copyClassList(&count);
    for (unsigned int i = 0; i < count; i++) {
        CFSetAddValue(registeredClasses, (__bridge const void *)(classes[i]));
    }
    free(classes);
    return count;
}

判断isa是否合法

typedef struct {
    Class isa;
} malloc_maybe_object_t;

void vm_range_recorder_callback(task_t task, void *context, unsigned type, vm_range_t *ranges, unsigned rangeCount) {
    if (!context) {
        return;
    }

    for (unsigned int i = 0; i < rangeCount; i++) {
        vm_range_t range = ranges[i];
        malloc_maybe_object_t *tryObject = (malloc_maybe_object_t *)range.address;
        Class tryClass = NULL;
#ifdef __arm64__
        // See http://www.sealiesoftware.com/blog/archive/2013/09/24/objc_explain_Non-pointer_isa.html
        extern uint64_t objc_debug_isa_class_mask WEAK_IMPORT_ATTRIBUTE;
        tryClass = (__bridge Class)((void *)((uint64_t)tryObject->isa & objc_debug_isa_class_mask));
#else
        tryClass = tryObject->isa;
#endif
        // 1\. 判断是否为OC/SwiftObject
        if (CFSetContainsValue(registeredClasses, (__bridge const void *)(tryClass))) {
            (*(malloc_object_enumeration_block_t __unsafe_unretained *)context)((__bridge id)tryObject, tryClass);
        } 
        // 2\. 判断是否是一个保护type_info的C++对象
        else if ([CPPObjectUtil cppTypeInfoName:(void *)range.address] != NULL) {
            NSLog(@"Find a Cpp Object:%s!", [CPPObjectUtil cppTypeInfoName:(void *)range.address]);
        }
    }
}

C++对象根据是否包含虚表可以分成两类。对于不包含虚表的对象,因为缺乏运行时数据,无法进行处理。
对于对于包含虚表的对象, 可以通过 std::type_info 和以下几个 section 的信息获取对应的类型信息。

  • type_name string - 类名对应的常量字符串,存储在__TEXT/__RODATA段的__const section中。
  • type_info - 存放在__DATA/__DATA_CONST段的__const section中。
  • vtable - 存放在__DATA/__DATA_CONST段的__const section中。

如何判断是不是一个C++对象

获取App二进制加载到内存中起始地址,_dyld_register_func_for_add_image方法当App的二进制或者动态库等MachO格式的文件映射到内存后,启动App时的回调,我们可以通过这个拿到App执行二进制的起始地址,从而拿到段中的C++类型信息。

获取App二进制起始地址

/*
 * The following functions allow you to install callbacks which will be called   
 * by dyld whenever an image is loaded or unloaded.  During a call to _dyld_register_func_for_add_image()
 * the callback func is called for every existing image.  Later, it is called as each new image
 * is loaded and bound (but initializers not yet run).  The callback registered with
 * _dyld_register_func_for_remove_image() is called after any terminators in an image are run
 * and before the image is un-memory-mapped.
 */
extern void _dyld_register_func_for_add_image(void (*func)(const struct mach_header* mh, intptr_t vmaddr_slide))    __OSX_AVAILABLE_STARTING(__MAC_10_1, __IPHONE_2_0);

获取所有C++type_info

typedef std::vector<struct segment_command_64 const *>    Segment64Vector;
typedef std::set<uint64_t *>    CxxTypeInfoSet;

static Segment64Vector *segments_64 = NULL;
static CxxTypeInfoSet *cxxTypeInfoSet = NULL;

// 记录Data Segment中__const段的有效最大最小地址,合法的C++ type_info地址不会超出这里
uint64_t dataConstMinAddress = NULL;
uint64_t dataConstMaxAddress = NULL;

static void dyld_callback(const struct mach_header *mhp, intptr_t vmaddr_slide)
{
    // 这里只分析App的二进制
    if (mhp->filetype != MH_EXECUTE) {
        return;
    }

    segments_64 = new Segment64Vector();
    cxxTypeInfoSet = new CxxTypeInfoSet();
    size_t header_size = sizeof(struct mach_header_64);
    uint64_t *load_comandPtr = (uint64_t *)((unsigned char *)mhp + header_size);
    uint64_t address = (uint64_t)((uint64_t *)mhp);
    uint32_t ptrSize = sizeof(uint64_t);
    for (int i=0; i<mhp->ncmds; i++) {
        struct load_command *load_command = (struct load_command *)load_comandPtr;
        segments_64->push_back((struct segment_command_64 const *)load_command);
        NSString *cmdType = loadCommandMap[@(load_command->cmd)];
        NSLog(@"dyld_callback load_command cmd:%@", cmdType);
        // 分析 Data Segment中__const段,获取有效最大最小地址
        if (load_command->cmd == LC_SEGMENT_64) {
            struct segment_command_64 *segment_64 = (struct segment_command_64 *)load_command;
            if (strcmp(segment_64->segname, "__DATA") == 0) {
                const struct section_64 *sec = (struct section_64 *)(segment_64 + 1);
                for (int j=0; j<segment_64->nsects; j++) {
                    if (strcmp(sec[j].sectname, "__const") == 0) {
                        dataConstMinAddress = (((uint64_t)(uint64_t *)mhp) + sec[j].offset);
                        dataConstMaxAddress = (((uint64_t)(uint64_t *)mhp) + sec[j].offset + sec[j].size);
                    }
                }
            }

        } 
        // 分析动态链接段的信息,获取App内C++ type_info的地址
        else if (load_command->cmd == LC_DYLD_INFO ||
                   load_command->cmd == LC_DYLD_INFO_ONLY) {
            struct dyld_info_command *dyldCommand = (struct dyld_info_command *)load_command;
            uint8_t *bytePtr = (uint8_t *)((uint8_t *)mhp + dyldCommand->bind_off); // Dynamic Loader Info Bind部分的起始地址
            uint64_t dyldMaxAddress = (((uint64_t)(uint64_t *)mhp) + dyldCommand->bind_off + dyldCommand->bind_size);
            uint64_t doBindLocation = *((uint64_t *)bytePtr);

            int32_t libOrdinal = 0;
            uint32_t type = 0;
            int64_t addend = 0;
            NSString * symbolName = nil;
            uint32_t symbolFlags = 0;
            BOOL isDone = NO;
            while (((uint64_t)(uint64_t *)bytePtr) < dyldMaxAddress) {
                uint8_t byte = read_int8(&bytePtr);
                uint8_t opcode = byte & BIND_OPCODE_MASK;
                uint8_t immediate = byte & BIND_IMMEDIATE_MASK;
                NSLog(@"dyld_callback load_command opcode:%d, immediate:%d", opcode, immediate);
                switch (opcode)
                {
                    case BIND_OPCODE_DONE:
                        // The lazy bindings have one of these at the end of each bind.
                        isDone = YES;

                        doBindLocation = (*((uint64_t *)bytePtr) + 1);

                        break;

                    case BIND_OPCODE_SET_DYLIB_ORDINAL_IMM:
                        libOrdinal = immediate;
                        break;

                    case BIND_OPCODE_SET_DYLIB_ORDINAL_ULEB:

                        libOrdinal = (uint32_t)read_uleb128(&bytePtr);

                        break;

                    case BIND_OPCODE_SET_DYLIB_SPECIAL_IMM:
                    {
                        // Special means negative
                        if (immediate == 0)
                        {
                            libOrdinal = 0;
                        }
                        else
                        {
                            int8_t signExtended = immediate | BIND_OPCODE_MASK; // This sign extends the value

                            libOrdinal = signExtended;
                        }
                    } break;

                    case BIND_OPCODE_SET_SYMBOL_TRAILING_FLAGS_IMM:
                        symbolFlags = immediate;
                        symbolName = read_string(&bytePtr);
                        break;

                    case BIND_OPCODE_SET_TYPE_IMM:
                        type = immediate;
                        break;

                    case BIND_OPCODE_SET_ADDEND_SLEB:

                        addend = read_sleb128(&bytePtr);
                        break;
                        //
                    case BIND_OPCODE_SET_SEGMENT_AND_OFFSET_ULEB:
                    {
                        uint32_t segmentIndex = immediate;
                        uint64_t val = read_uleb128(&bytePtr);

                        if (segmentIndex < segments_64->size())
                        {
                            address += (*segments_64)[segmentIndex]->fileoff + val;
                        }
                    } break;

                    case BIND_OPCODE_ADD_ADDR_ULEB:
                    {
                        uint64_t val = read_uleb128(&bytePtr);
                        address += val;
                    } break;

                    case BIND_OPCODE_DO_BIND:
                    {
                        // 获取C++ type_info地址
                        NSLog(@"dyld_callback Bind SymbolName:%@", symbolName);
                        if ([symbolName hasPrefix:@"__ZTVN10__cxxabi"]) {
                            std::type_info *type_info = (std::type_info *)address;
                            NSLog(@"std::type_info name:%s address:%p", type_info->name(), type_info);
                            cxxTypeInfoSet->insert((uint64_t *)address);
                        }
                        doBindLocation = *((uint64_t *)bytePtr);

                        address += ptrSize;
                    } break;

                    case BIND_OPCODE_DO_BIND_ADD_ADDR_ULEB:
                    {
                        uint64_t startNextBind = *((uint64_t *)bytePtr);

                        uint64_t val = read_uleb128(&bytePtr);
                        doBindLocation = startNextBind;

                        address += ptrSize + val;
                    } break;

                    case BIND_OPCODE_DO_BIND_ADD_ADDR_IMM_SCALED:
                    {
                        uint32_t scale = immediate;
                        // 获取C++ type_info地址
                        if ([symbolName hasPrefix:@"__ZTVN10__cxxabi"]) {
                            std::type_info *type_info = (std::type_info *)address;
                            NSLog(@"std::type_info name:%s address:%p", type_info->name(), type_info);
                            cxxTypeInfoSet->insert((uint64_t *)address);
                        }

                        doBindLocation = *((uint64_t *)bytePtr);

                        address += ptrSize + scale * ptrSize;
                    } break;

                    case BIND_OPCODE_DO_BIND_ULEB_TIMES_SKIPPING_ULEB:
                    {
                        uint64_t startNextBind = *((uint64_t *)bytePtr);

                        uint64_t count = read_uleb128(&bytePtr);

                        uint64_t skip = read_uleb128(&bytePtr);

                        for (uint64_t index = 0; index < count; index++)
                        {
                            doBindLocation = startNextBind;

                            address += ptrSize + skip;
                        }
                    } break;

                    default:
                        break;
                }
            }
        }
        load_comandPtr = (uint64_t *)((unsigned char *)load_comandPtr + load_command->cmdsize);
    }
}

判断一个地址是否为一个C++Object(有type_info的)

typedef std::set<uint64_t *>    CxxTypeInfoSet;
static CxxTypeInfoSet *cxxTypeInfoSet = NULL;

+ (const char *) cppTypeInfoName:(void *) ptr {
    uint64_t *typeInfoPtr = (uint64_t*)(*((uint64_t *)ptr) - 8);
    uint64_t typeInfoAddress = (uint64_t)typeInfoPtr;
    if (typeInfoAddress >= dataConstMinAddress && typeInfoAddress < dataConstMaxAddress) {
        uint64_t *typeInfo = (uint64_t *)(*typeInfoPtr);
        if (cxxTypeInfoSet->find(typeInfo) != cxxTypeInfoSet->end()) {
            const char *name = ((std::type_info *)typeInfo)->name();
            return name;
        }
    }
    return NULL;
}

在 iOS 系统内,还有一类特殊的对象,即CoreFoundation。除了我们熟知的CFString、CFDictionary外等,很多很多系统库也使用 CF 对象,比如CGImage、CVObject等。从它们的 isa 指针获取的Objective-C类型被统一成__NSCFType。由于 CoreFoundation 类型支持实时的注册、注销类型,为了细化这部分的类型,我们通过逆向拿到 CoreFoundation 维护的类型 slot 数组的位置并读取其数据,保证能够安全的获取准确的类型。

UIImage *image = [UIImage imageNamed:@""];
CFTypeID typeId = CFGetTypeID(image.CGImage);
CFStringRef className = CFCopyTypeIDDescription(typeId);
NSLog(@"%@",className);
引用关系的构建

整个内存快照的核心在于重新构建内存节点之间的引用关系。在虚拟内存中,如果一个内存节点引用了其它内存节点,则对应的内存地址中会存储指向对方的指针值。有以下方案:

  • 遍历一个内存节点中所有可能存储了指针的范围获取其存储的值 A。
  • 搜索所有获得的节点,判断 A 是不是某一个内存节点中任何一个字节的地址,如果是,则认为是一个引用关系。
  • 对所有内存节点重复以上操作。

对于一些特定的内存区域,为了获取更详细的信息用于排查问题,对栈内存以及 Objective-C/Swift 的堆内存进行了一些额外的处理。
其中,栈内存也以VM Region的形式存在,栈上保存了临时变量和 TLS 等数据,获取相应的引用信息可以帮助排查诸如 autoreleasepool 造成的内存问题。由于栈并不会使用整个栈内存,为了获取 Stack 的引用关系,根据寄存器以及栈内存获取当前的栈可用范围,排除未使用的栈内存造成的无效引用。

而对于Objective-C/Swift对象,由于运行时包含额外的信息,我们可以获得Ivar的强弱引用关系以及Ivar的名字,带上这些信息有助于我们分析问题。 通过获得Ivar的偏移,如果找到的引用关系的偏移和Ivar的偏移一致,则认为这个引用关系就是这个Ivar,可以将Ivar相关的信息附加上去。

参考链接

https://juejin.cn/post/6885144933997494280

https://juejin.cn/post/6895583288451465230

相关文章

网友评论

      本文标题:OOM

      本文链接:https://www.haomeiwen.com/subject/mgjwoktx.html