• 作者:老汪软件技巧
  • 发表时间:2024-12-29 21:03
  • 浏览量:

背景

最近在做某个需求的性能测试时发现:采集的内存数据时而会跌落到0,导致内存准入无法判断(其实这个问题也同样会影响到线上的内存数据采集)。采集的截图主要就下面2种case:

时而跌落到 0,时而又恢复正常

meminfo-1.png

跌落到 0,后续一直为 0

meminfo-2.png

上面红框框出来的部分就是内存采集为 0 的时段,并且可以确认:

整个采集过程中app运行良好,进程并没有挂(所以内存不可能为 0)好几个设备都必现分析数据是如何采集的

首先我们需要先看一下上面的内存数据是如何采集的,其实比较简单:定时执行 adb shell dumpsys meminfo --local ${package_name} 来获取内存数据并渲染的。

那么Android 系统又是如何响应上面的这条adb命令的呢?大概的流程如下:

根据包名找到对应进程的 pid(如果有多个进程,每个都会dump。当然这个命令也可以直接指定pid)读取对应进程的smaps:/proc/${pid}/smaps解析smaps文件,根据 name 进行聚合、分类。比如 [anon:dalvik-xxx 就算 dalvik heap(具体解析过程可以看ActivityManagerService.dumpApplicationMemoryUsage)

整个过程比较简单,我们可以自己获取smaps解析一下看看~

解析smaps

我们在复现上面内存数据为0的时候把smaps dump出来,然后用Android提供的解析工具发现解析存在异常:

➜ adb shell showmap -f /sdcard/smaps.txt
Failed to parse file /sdcard/smaps.txt

可以看到smaps文件解析失败了。在此我们可以先明确:之所以上面取到的内存数据为 0,是因为smaps文件解析失败了。

为啥smaps文件会解析失败呢?showmap 并没有输出解析失败的原因,也没有输出导致解析失败的相关文件内容。修改showmap源码后发现导致解析错误的vma如下:

6ee9dd0000-6ee9e10000 rw-p 00000000 00:00 0                              [anon: return 
vec4]
Name:           [anon:  return 
vec4]
Size:                256 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                   0 kB
Pss:                   0 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:         0 kB
Referenced:            0 kB
Anonymous:             0 kB
LazyFree:              0 kB
AnonHugePages:         0 kB
ShmemPmdMapped:        0 kB
FilePmdMapped:         0 kB
Shared_Hugetlb:        0 kB
Private_Hugetlb:       0 kB
Swap:                  0 kB
SwapPss:               0 kB
Locked:                0 kB
THPeligible:    0
VmFlags: rd wr mr mw me ac 

从这儿可以看到这个 vma 的name很奇怪:名称中包含换行符。抓过几次,也出现过name是json字符串的case。。

解析报错的主要代码如下:

static bool parse_smaps_field(const char* line, MemUsage* stats) {
    const char *end = line;
​
    // https://lore.kernel.org/patchwork/patch/1088579/ introduced tabs. Handle this case as well.
    while (*end && !isspace(*end)) end++;
    if (*end && end > line && *(end - 1) == ':') {
        const char* c = end;
        while (isspace(*c)) c++;
        switch (line[0]) {
            // ... 省略
        }
        return true;
    }
​
    return false;
}

因为smaps文件格式:由map line分隔的vma block,map line下面每一行都是由:分隔的key-value对。而上面的 vma name包含换行符,存在没有 :的case,所以上面的 parse_smaps_field 会返回 false => 解析失败。

vma的名称是如何而来的呢?

从上面名称 [anon:xxx 可以看出这是一段匿名映射,而为匿名映射设置名称是通过 prctl 系统调用来的:

int prctl(PR_SET_VMA, long attr, unsigned long addr, unsigned long size,
                 const char *_Nullable val);
​
Currently, attr must be one of:
​
       PR_SET_VMA_ANON_NAME
              Set a name for anonymous virtual memory areas.  val should

内核重定位__内核panic定位方法

             be a pointer to a null-terminated string containing the              name.  The name length including null byte cannot exceed              80 bytes.  If val is NULL, the name of the appropriate              anonymous virtual memory areas will be reset.  The name              can contain only printable ascii characters (isprint(3)),              except '[', ']', '', '$', and '`'.RETURN VALUE       On success, 0 is returned.  On error, -1 is returned, and errno       is set to indicate the error.

从手册可以看到设置的名称对字符和长度都是有要求的,而上面的smaps中的名称明显不符合要求,比如:包含换行符 。

按手册的说法如果不符合要求的话,是会报错的。我在模拟器上做了个测试,测试代码如下:

static void testVmaName() {
    void* res = mmap(nullptr, 4096, PROT_READ | PROT_WRITE, MAP_ANONYMOUS | MAP_PRIVATE, 0, 0);
    if (res == MAP_FAILED) {
        LOGE("map failed");
    }
​
    int code = prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, res, 4096, "invalid \n name");
    if (code != 0) {
        LOGE("set vma name error: %s", strerror(errno));
    } else {
        LOGI("set vma name ok");
    }
    
    // todo munmap
}

在Android 12模拟器上输出:set vma name ok在Android 14模拟器上输出:set vma name error: Invalid argument

可以看到:

Android 12上不符合预期,并且这个时候通过 Debug.getMemoryInfo(meminfo) 也是取不到内存数据的,logcat中同样可以看到错误日志:Failed to parse /proc/10476/smapsAndroid 14模拟器上符合预期,名称中包含非法字符:\n,prctl 调用会报错:Invalid argument为什么 prctl 在部分系统中设置vma name不符合预期呢?

通过搜索发现了这个问题:在设置vma name的时候,老的Linux内核只存储了这个name在用户空间的虚拟地址!!!

也就是说如果这个name是一个局部变量(或者在munmap之前被释放),那么后续生成smaps时,从这个虚拟地址读字符串就是UB行为(use after free!!!)这个倒是跟上面vma name可能是代码或者json字符串的现象匹配了。

做了个简单测试,发现确实如此(还是在上面那个Android 12模拟器上):

{
  std::string name = "my_vma_";
  name += std::to_string(getpid());
  int code = prctl(PR_SET_VMA, PR_SET_VMA_ANON_NAME, res, 4096, name.c_str());
}

可以看到虽然名称是合法的,但是prctl调用之后name就被销毁了,这个时候dump smaps看到他的name变成了其他值,比如:[anon:8r:�{](当然这个值倒是不影响解析。。)

而在上面那个Android 14模拟器上就没有问题:[anon:my_vma_5735]。因为后面内核中增加了一个字段用于保存这个name的值。(并且也增加了对这个字符串的校验,比如特殊字符,可以参考:mm: add a field to store names for private anonymous memory)

验证

从上面的信息可以得到2点:

vma的名称不要超过80个字符,并且不要包含特殊字符,比如:\n,具体手册上有vma的名称在对应内存映射被munmap之前不能释放

按上面的说法,通过全局hook prctl 来动态分配一块内存,做个保护性copy,理论上应该能规避这个问题:

static const char* newVmaName(const char* oldName) {
    char* name = strndup(oldName, 79);// todo should free this memory after munmap
    while (name) {
        char c = *name;
        if (c == '\0') {
            break;
        }
        if (!isprint(c) || c == '[' || c == ']' || c == '\' || c == '$' || c == '`') {
            *name = '_';
        }
        ++name;
    }
    return name;
}
​
static int prctlProxy(int op, unsigned long arg1, unsigned long arg2, unsigned long arg3, unsigned long arg4) {
    BYTEHOOK_STACK_SCOPE();
​
    if (op == PR_SET_VMA && arg1 == PR_SET_VMA_ANON_NAME) {
        if (arg4 != 0) {
            arg4 = (unsigned long)newVmaName((const char*)arg4);
        }
    }
    return syscall(SYS_prctl, op, arg1, arg2, arg3, arg4);
}

经测试发现取不到内存数据的现象不复现了(在proxy方法中抓栈也能定位到导致问题的业务代码,修改下相关的prctl调用就好了)


上一条查看详情 +实现简易Zustand
下一条 查看详情 +没有了