iOS APP虚拟内存用量初探

5,171 阅读9分钟

项目中目前有关于APP物理内存、系统物理内存等内存状态的获取API,但是一直缺少获取虚拟内存相关的API。之前业务上也出现过因为虚拟内存耗尽导致的crash,后续也通过com.apple.developer.kernel.extended-virtual-addressing的设置为APP扩展虚拟内存的可用范围。本文主要基于以上背景对虚拟内存进行一些调研

task_vm_info简介

struct task_vm_info {
        mach_vm_size_t  virtual_size;       /* virtual memory size (bytes) */
        integer_t       region_count;       /* number of memory regions */
        integer_t       page_size;
        mach_vm_size_t  resident_size;      /* resident memory size (bytes) */
        mach_vm_size_t  resident_size_peak; /* peak resident size (bytes) */
        ...
        /* added for rev1 */
        mach_vm_size_t  phys_footprint;

        /* added for rev2 */
        mach_vm_address_t       min_address;
        mach_vm_address_t       max_address;

        /* added for rev3 */
        ...

        /* added for rev4 */
        uint64_t limit_bytes_remaining; //可以用来计算app的OOM阈值=(limit_bytes_remaining+phys_footprint)

        /* added for rev5 */
        integer_t decompressions;

        /* added for rev6 */
        int64_t ledger_swapins;
};

task_vm_info 结构体中可以看到以下几个和虚拟内存相关的值

  • virtual_size 当前虚拟内存的大小
  • region_count 内存区域的个数
  • min_address 最小地址
  • max_address 最大地址

通过测试如下:

iPhone6P(12.4.8) 1G第一次第二次第三次
virtual_size4.75G4.77G4.77G
region_count79812581449
min_address0x1004540000x1006300000x100084000
max_address0x2a00000000x2a00000000x2a0000000
aslr0x1004540000x1006300000x100084000
iPhone6s(13.3) 2G第一次第二次第三次
virtual_size4.88G4.88G4.88G
region_count284827422511
min_address0x10419c0000x10045c0000x100634000
max_address0x2d80000000x2d80000000x2d8000000
aslr0x10419c0000x10045c0000x100634000
iPhone13Pro(15.5) 6G第一次第二次第三次
virtual_size390.10G390.11G390.10G
region_count474946874691
min_address0x100cd40000x100e200000x1043f8000
max_address0x3d80000000x3d80000000x3d8000000
aslr0x100cd40000x100e200000x1043f8000

virtual_size和region_count仅代表测试时的虚拟内存用量。

可以发现:

  • 同一种机型max_address是固定的,而min_address和aslr的偏移保持一致
  • 不同机型(内存容量)的max_address发生了变化
  • 13Pro的virtual_size和另外两种机型相比差别相当大

接下来根据XNU源码探索一下原因:

Mac OS X Manual Page For posix_spawn(2)

本文中关联到的相关源码关系如下图:

image.png

设置虚拟内存范围时机

在创建进程时,系统会加载对应的Mach-O文件,同时为该进程创建对应的_vm_map,关于该结构完整的类定义可以在vm_map.h#L460中查看。本文暂时只关注其中的min_offsetmax_offset

相关调用如下:

/*
 *        vm_map_create:
 *
 *        Creates and returns a new empty VM map with
 *        the given physical map structure, and having
 *        the given lower and upper address bounds.
 */

vm_map_t
vm_map_create(
        pmap_t          pmap,
        vm_map_offset_t min,
        vm_map_offset_t max,
        boolean_t       pageable)
        
        
map = vm_map_create(pmap,
                        0,
                        vm_compute_max_offset(result-> is64bit),
                        TRUE);

max_offset

可以看到创建之初min_offset为0, max_offset的值为vm_compute_max_offset的返回值,该方法最终会调用pmap_max_64bit_offset,其中的option参数为ARM_PMAP_MAX_OFFSET_DEVICE

vm_map_offset_t
pmap_max_64bit_offset(
        __unused unsigned int option)
{
        vm_map_offset_t max_offset_ret = 0;

#if defined(__arm64__)
        #define SHARED_REGION_BASE_ARM64                0x180000000ULL
        #define SHARED_REGION_SIZE_ARM64                0x100000000ULL
        #define ARM64_MIN_MAX_ADDRESS (SHARED_REGION_BASE_ARM64 + SHARED_REGION_SIZE_ARM64 + 0x20000000) // end of shared region + 512MB for various purposes 
        // 0x2A0000000
        const vm_map_offset_t min_max_offset = ARM64_MIN_MAX_ADDRESS; // end of shared region + 512MB for various purposes
        if (xxx){
            ...
        } else if (option == ARM_PMAP_MAX_OFFSET_DEVICE) {
                if (arm64_pmap_max_offset_default) { //0
                        max_offset_ret = arm64_pmap_max_offset_default;
                } else if (max_mem > 0xC0000000) {   0x3D8000000
                        max_offset_ret = min_max_offset + 0x138000000; // Max offset is 13.375GB for devices with > 3GB of memory
                } else if (max_mem > 0x40000000) {   0x2D8000000
                        max_offset_ret = min_max_offset + 0x38000000;  // Max offset is 9.375GB for devices with > 1GB and <= 3GB of memory
                } else {   
                        max_offset_ret = min_max_offset;  //0x2A0000000
                }
        } else if (option == ARM_PMAP_MAX_OFFSET_JUMBO) {
                if (arm64_pmap_max_offset_default) {
                        // Allow the boot-arg to override jumbo size
                        max_offset_ret = arm64_pmap_max_offset_default;
                } else {
                        max_offset_ret = MACH_VM_MAX_ADDRESS;     // Max offset is 64GB for pmaps with special "jumbo" blessing
                }
        } else {
                panic("pmap_max_64bit_offset illegal option 0x%x\n", option);
        }
        ...
        return max_offset_ret;
}
  • 可以看到max的值确实是一个固定的值,确切的算法为(min_max_offset+物理内存对应的特定偏移),在64位情况下分别为:
  • 内存范围min_max_offset特定偏移max_address
    >3G0x2A00000000x1380000000x3D8000000
    1G-3G0x2A00000000x380000000x2D8000000
    <1G0x2A000000000x2A0000000

min_offset

同样的,在加载Mach-O文件时,也会设置min_offset,具体的逻辑在load_segment中:

  1. load_machfile中生成aslr 链接
  2. 调用parse_machfile,读取page_zero segment,一般来说page_zero的address为0size1G
  3. (aslr+1G)再补齐为16KB的倍数赋给map->min_offset

Reserved region

从上文的数据中还可以发现iPhone13Provurtual_size非常的大,差不多为390G。通过InstrumentsVMTracker观察可以发现:

在13pro的vm区域中多了两个特殊的vm_region,分别是:

  • GPU carveout
  • commpage

这两个区域的地址范围也是固定的而且是比较特殊的地址,同时没有任何的权限,加起来大概占了385G左右。

在xnu源码中寻找相关的信息可以发现:

/**
 * Represents regions of virtual address space that should be reserved
 * (pre-mapped) in each user address space.
 */
 
#define MACH_VM_MIN_GPU_CARVEOUT_ADDRESS_RAW 0x0000001000000000ULL
#define MACH_VM_MAX_GPU_CARVEOUT_ADDRESS_RAW 0x0000007000000000ULL
#define MACH_VM_MIN_GPU_CARVEOUT_ADDRESS     ((mach_vm_offset_t) MACH_VM_MIN_GPU_CARVEOUT_ADDRESS_RAW)
#define MACH_VM_MAX_GPU_CARVEOUT_ADDRESS     ((mach_vm_offset_t) MACH_VM_MAX_GPU_CARVEOUT_ADDRESS_RAW)

#define _COMM_PAGE64_NESTING_START                (0x0000000FC0000000ULL)
#define _COMM_PAGE64_NESTING_SIZE                 (0x40000000ULL) /* 1GiB */

SECURITY_READ_ONLY_LATE(static struct vm_reserved_region) vm_reserved_regions[] = {
        {
                .vmrr_name = "GPU Carveout",
                .vmrr_addr = MACH_VM_MIN_GPU_CARVEOUT_ADDRESS,
                .vmrr_size = (vm_map_size_t)(MACH_VM_MAX_GPU_CARVEOUT_ADDRESS - MACH_VM_MIN_GPU_CARVEOUT_ADDRESS)
        },
        /*
         * Reserve the virtual memory space representing the commpage nesting region
         * to prevent user processes from allocating memory within it. The actual
         * page table entries for the commpage are inserted by vm_commpage_enter().
         * This vm_map_enter() just prevents userspace from allocating/deallocating
         * anything within the entire commpage nested region.
         */
        {
                .vmrr_name = "commpage nesting",
                .vmrr_addr = _COMM_PAGE64_NESTING_START,
                .vmrr_size = _COMM_PAGE64_NESTING_SIZE
        }
};

这两块区域分别对应了上文中的两个vm_region,其对应的地址范围分别为:

  • GPU Carveout: 0x1000000000~0x7000000000
  • commpage nesting: 0xFC0000000~0x1000000000

可以发现这两个虚拟内存区域是固定的,是用户地址空间预留出来的范围,用户态并不能申请其中的虚拟内存,因此当我们计算APP占用的虚拟内存时,需要减去这两个预留的vm_region。

虚拟内存总大小

上文中介绍过task_vm_info中有max_address和min_address两个字段,根据含义猜测这两个的差值可能是APP的虚拟内存总大小。接下来进行验证代码如下:

    //fill task vm info
    task_vm_info_data_t task_vm;
    mach_msg_type_number_t task_vm_count = TASK_VM_INFO_COUNT;
    kern_return_t kr = task_info(mach_task_self(), TASK_VM_INFO, (task_info_t) &task_vm, &task_vm_count);
    if(kr != KERN_SUCCESS) {
        __builtin_trap();
    }
    
    mach_vm_address_t minAddress = task_vm.min_address;
    mach_vm_address_t maxAddress = task_vm.max_address;
    //打印猜测的APP的虚拟内存总大小
    fprintf(stdout, "vm_test: min = 0x%llx, max = 0x%llx, vm_total_size = %.2fG\n", minAddress, maxAddress, (maxAddress-minAddress)/GB);
    mach_vm_size_t virtual_used_size = task_vm.virtual_size;
    integer_t region_count = task_vm.region_count;
    static const mach_vm_address_t reserved_size = MACH_VM_MAX_GPU_CARVEOUT_ADDRESS - MACH_VM_MAX_ADDRESS;
    if (virtual_used_size > reserved_size) {
        fprintf(stdout, "vm_test: reserve vm ocuur\n");
        virtual_used_size -= reserved_size;
    }
    //打印task_vm获得的APP使用的虚拟内存大小virtual_used_size
    fprintf(stdout, "vm_test: vm_used_size = %.2fG, region_count = %d\n", virtual_used_size/GB, region_count);
    vm_address_t address;
    int i = 0;
    //循环申请16KB内存直到申请失败
    while (true) {
        kr = vm_allocate(mach_task_self(), &address, 16*KB, VM_FLAGS_ANYWHERE);
        if (kr != KERN_SUCCESS) {
            //统计APP还可以继续申请的虚拟内存大小
            fprintf(stdout, "vm_test: valid size = %.2fG\n", (i*16*KB)/GB);
            break;
        }
        i++;
    }

为了排除干扰便于统计,建一个空壳工程,在viewdidload中加入上述代码测试结果如下:

  • iPhone6S
APP使用的虚拟内存大小(virtual_size)还能申请的虚拟内存大小(到申请失败为止)二者之和猜测的虚拟内存总大小(max_address-min_address)
4.66G2.68G7.34G7.34G
4.66G2.71G7.37G7.37G
4.66G2.65G7.31G7.31G
  • iPhone6P
APP使用的虚拟内存大小(virtual_size)还能申请的虚拟内存大小(到申请失败为止)二者之和猜测的虚拟内存总大小(max_address-min_address)
4.56G1.93G6.49G6.49G
4.56G1.93G6.49G6.49G
4.56G1.92G6.48G6.49G
  • iPhone13Pro
APP使用的虚拟内存大小(virtual_size)还能申请的虚拟内存大小(到申请失败为止)二者之和猜测的虚拟内存总大小(max_address-min_address)
4.59G6.71G11.30G11.30G
4.60G6.73G11.33G11.33G
4.60G6.71G11.31G11.31G

可以看到数据统计结果和我们的猜测是一致的,虚拟内存总大小会有一定的差值也是可以解释的,因为min_address是一个随机的值(aslr)。同时可以发现,虽然64位系统的寻址空间非常大,其实留给用户的虚拟内存范围并没有我们想象的那么大,一个空壳工程就已经占用了大概4.6G的虚拟内存。同时,当虚拟内存申请失败后,往往也伴随着各种因为虚拟内存申请失败导致的错误的地址访问进而产生crash。

虚拟内存扩容

iOS14以后,苹果提供了一个新的能力可以允许APP用户态使用更多的虚拟内存范围:Extended Virtual Addressing Entitlement | Apple Developer Documentation

com.apple.developer.kernel.extended-virtual-addressing

在源码中搜索相关关键字:

#if CONFIG_MACF
/*
 * Processes with certain entitlements are granted a jumbo-size VM map.
 */
static inline void
proc_apply_jit_and_jumbo_va_policies(proc_t p, task_t task)
{
        bool jit_entitled;
        jit_entitled = (mac_proc_check_map_anon(p, 0, 0, 0, MAP_JIT, NULL) == 0);
        if (jit_entitled || (IOTaskHasEntitlement(task,
            "com.apple.developer.kernel.extended-virtual-addressing"))) {
                vm_map_set_jumbo(get_task_map(task));
                if (jit_entitled) {
                        vm_map_set_jit_entitled(get_task_map(task));
                }
        }
}
#endif /* CONFIG_MACF */

/*
 * Expand the maximum size of an existing map to the maximum supported.
 */
void
vm_map_set_jumbo(vm_map_t map)
{
#if defined (__arm64__) && !defined(CONFIG_ARROW)
        vm_map_set_max_addr(map, ~0);
#else /* arm64 */
        (void) map;
#endif
}

/*
 * Expand the maximum size of an existing map.
 */
void
vm_map_set_max_addr(vm_map_t map, vm_map_offset_t new_max_offset)
{
#if defined(__arm64__)
        vm_map_offset_t max_supported_offset = 0;
        vm_map_offset_t old_max_offset = map->max_offset;
        max_supported_offset = pmap_max_offset(vm_map_is_64bit(map), ARM_PMAP_MAX_OFFSET_JUMBO) ;

        new_max_offset = trunc_page(new_max_offset);

        /* The address space cannot be shrunk using this routine. */
        if (old_max_offset >= new_max_offset) {
                return;
        }

        if (max_supported_offset < new_max_offset) {
                new_max_offset = max_supported_offset;
        }

        map->max_offset = new_max_offset;

        if (map->holes_list->prev->vme_end == old_max_offset) {
                /*
                 * There is already a hole at the end of the map; simply make it bigger.
                 */
                map->holes_list->prev->vme_end = map->max_offset;
        } else {
                /*
                 * There is no hole at the end, so we need to create a new hole
                 * for the new empty space we're creating.
                 */
                struct vm_map_links *new_hole = zalloc(vm_map_holes_zone);
                new_hole->start = old_max_offset;
                new_hole->end = map->max_offset;
                new_hole->prev = map->holes_list->prev;
                new_hole->next = (struct vm_map_entry *)map->holes_list;
                map->holes_list->prev->links.next = (struct vm_map_entry *)new_hole;
                map->holes_list->prev = (struct vm_map_entry *)new_hole;
        }
#else
        (void)map;
        (void)new_max_offset;
#endif
}

同样的在posix_spawn中,会判断是否添加了com.apple.developer.kernel.extended-virtual-addressing,会为当前进程对应的map设置~0的max_address。此时pmap_max_offset函数传入的option为ARM_PMAP_MAX_OFFSET_JUMBO,根据上文代码可以发现此时max_offset为MACH_VM_MAX_ADDRESS即0xFC0000000,而这个值同样也是上文提到的预留vm_region(commpage nesting)的起始地址。也就是说开启了虚拟内存扩容之后,用户态的地址范围为aslr...0xFC0000000

简单验证一下,虚拟内存的总量来到了59G。

物理内存扩容

对于物理内存来说,在iOS15以后,苹果同样也提供了扩容的能力com.apple.developer.kernel.increased-memory-limit | Apple Developer Documentation

com.apple.developer.kernel.increased-memory-limit

/*
 * Check for any of the various entitlements that permit a higher
 * task footprint limit or alternate accounting and apply them.
 */
static inline void
proc_footprint_entitlement_hacks(proc_t p, task_t task)
{
        proc_legacy_footprint_entitled(p, task);
        proc_ios13extended_footprint_entitled(p, task);
        proc_increased_memory_limit_entitled(p, task);
}

static inline void
proc_ios13extended_footprint_entitled(proc_t p, task_t task)
{
#pragma unused(p)
        boolean_t ios13extended_footprint_entitled;

        /* the entitlement grants a footprint limit increase */
        ios13extended_footprint_entitled = IOTaskHasEntitlement(task,
            "com.apple.developer.memory.ios13extended_footprint");
        if (ios13extended_footprint_entitled) {
                task_set_ios13extended_footprint_limit(task);
        }
}

void
memorystatus_act_on_ios13extended_footprint_entitlement(proc_t p)
{
        if (max_mem < 1500ULL * 1024 * 1024 ||
            max_mem > 2ULL * 1024 * 1024 * 1024) {
                /* ios13extended_footprint is only for 2GB devices */
                return;
        }
        /* limit to "almost 2GB" */
        proc_list_lock();
        memorystatus_raise_memlimit(p, 1800, 1800);
        proc_list_unlock();
}

static inline void
proc_increased_memory_limit_entitled(proc_t p, task_t task)
{
        static const char kIncreasedMemoryLimitEntitlement[] = "com.apple.developer.kernel.increased-memory-limit";
        bool entitled = false;

        entitled = IOTaskHasEntitlement(task, kIncreasedMemoryLimitEntitlement);
        if (entitled) {
                memorystatus_act_on_entitled_task_limit(p);
        }
}

void
memorystatus_act_on_entitled_task_limit(proc_t p)
{
        if (memorystatus_entitled_max_task_footprint_mb == 0) {
                // Entitlement is not supported on this device.
                return;
        }
        proc_list_lock();
        memorystatus_raise_memlimit(p, memorystatus_entitled_max_task_footprint_mb, memorystatus_entitled_max_task_footprint_mb);
        proc_list_unlock();
}

同样的,在posix_spawn中会判断是否添加了物理内存扩容的能力,然后调用memorystatus_raise_memlimit增加APP的OOM内存阈值。经过测试发现,不同机型的可以提升的物理内存阈值也不一样:

  • iPhone13Pro: 3.00G->4.00G
  • iPhone13: 2.05G->2.29G

比较有意思的是在源码还发现了另外一项能力**com.apple.developer.memory.ios13extended_footprint** 看源码描述是iOS13系统下2G物理内存设备的OOM阈值可以提升到1800M,但是遗憾的在xCode中并不能添加该能力,不知道发生甚么事了~~