iOS APP虛擬記憶體用量初探

語言: CN / TW / HK

專案中目前有關於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_size | 4.75G | 4.77G | 4.77G | | region_count | 798 | 1258 | 1449 | | min_address | 0x100454000 | 0x100630000 | 0x100084000 | | max_address | 0x2a0000000 | 0x2a0000000 | 0x2a0000000 | | aslr | 0x100454000 | 0x100630000 | 0x100084000 |

| iPhone6s(13.3) 2G | 第一次 | 第二次 | 第三次 | | ------------------------- | ----------- | ----------- | ----------- | | virtual_size | 4.88G | 4.88G | 4.88G | | region_count | 2848 | 2742 | 2511 | | min_address | 0x10419c000 | 0x10045c000 | 0x100634000 | | max_address | 0x2d8000000 | 0x2d8000000 | 0x2d8000000 | | aslr | 0x10419c000 | 0x10045c000 | 0x100634000 |

| iPhone13Pro(15.5) 6G | 第一次 | 第二次 | 第三次 | | ------------------------ | ----------- | ----------- | ----------- | | virtual_size | 390.10G | 390.11G | 390.10G | | region_count | 4749 | 4687 | 4691 | | min_address | 0x100cd4000 | 0x100e20000 | 0x1043f8000 | | max_address | 0x3d8000000 | 0x3d8000000 | 0x3d8000000 | | aslr | 0x100cd4000 | 0x100e20000 | 0x1043f8000 |

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 | | -------- | ------------------ | ----------- | --------------- | | >3G | 0x2A0000000 | 0x138000000 | 0x3D8000000 | | 1G-3G | 0x2A0000000 |0x38000000 | 0x2D8000000 |
| <1G | 0x2A0000000 |0 | 0x2A0000000 |

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.66G | 2.68G | 7.34G | 7.34G | | 4.66G | 2.71G | 7.37G | 7.37G | | 4.66G | 2.65G | 7.31G | 7.31G |

  • iPhone6P

| APP使用的虛擬記憶體大小(virtual_size) | 還能申請的虛擬記憶體大小(到申請失敗為止) | 二者之和 | 猜測的虛擬記憶體總大小(max_address-min_address) | | -------------------------- | -------------------- | --------- | ----------------------------------- | | 4.56G | 1.93G | 6.49G | 6.49G | | 4.56G | 1.93G | 6.49G | 6.49G | | 4.56G | 1.92G | 6.48G | 6.49G |

  • iPhone13Pro

| APP使用的虛擬記憶體大小(virtual_size) | 還能申請的虛擬記憶體大小(到申請失敗為止) | 二者之和 | 猜測的虛擬記憶體總大小(max_address-min_address) | | -------------------------- | -------------------- | ---------- | ----------------------------------- | | 4.59G | 6.71G | 11.30G | 11.30G | | 4.60G | 6.73G | 11.33G | 11.33G | | 4.60G | 6.71G | 11.31G | 11.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中並不能新增該能力,不知道發生甚麼事了~~