Android 定位 Native 内存问题

Android 定位 Native 内存问题

我在前面的文章 通过 dump 虚拟机线程方法栈和堆内存来分析 Android 卡顿和 OOM 问题介绍了如何定位 Java 内存的问题,今天就是介绍如何定位 Native 内存问题,相对于 Java 的内存问题,Native 的内存问题相对就更加隐蔽,这篇文章就是要介绍定位 Native 内存问题的工具。

ASan

大家也可以直接看官方文档ASan 的全称是 Address Sanitizer,它是用来检测 C/C++ 中内存问题的工具库。在 C/C++ 中就算方法调用当时访问了错误的内存地址,也不一定会抛出异常,当你对这段内存读写操作后会影响未来的某个方法调用然后造成崩溃,这样的话就比较难定位真正造成内存问题的代码。ASan 可以在你第一次访问错误的内存时就会抛出异常,然后帮助我们定位问题。ASan 可以探测到以下的内存问题:

  • 堆栈的 Buffer 溢出
  • 使用已经释放的堆内存
  • 栈地址超出范围
  • 多次释放内存

接入 ASan

ASan 只支持 Android 8 及其以上的版本,而且 ASan 只能用于测试包,不能用于线上环境。

  1. 修改 build.gradle 打包文件配置
KotlinScript 复制代码
android {
    // ...

    defaultConfig {
        // ...
        externalNativeBuild {
            cmake {
                arguments.add("-DANDROID_ARM_MODE=arm")
                arguments.add("-DANDROID_STL=c++_shared")
            }
        }
    }
    
    // ...
    

    packaging {
        jniLibs {
            // ..
            useLegacyPackaging = true
        }
    }
    
    // ...
}

我的构建使用的是 Kotlin Script,如果你是用的 Groovy 自己转换一下,首先在打包中添加 -DANDROID_ARM_MODE=arm-DANDROID_STL=c++_shared 参数;修改 useLegacyPackaging 参数为 true

  1. 修改 CMakeLists.txt 中的配置
CMake 复制代码
// ...
target_compile_options(${CMAKE_PROJECT_NAME} PUBLIC -fsanitize=address -fno-omit-frame-pointer)
set_target_properties(${CMAKE_PROJECT_NAME} PROPERTIES LINK_FLAGS -fsanitize=address)
// ...

需要为每个你生成的 .so 库添加上面的配置。

  1. Manifest 文件中添加可 debug
xml 复制代码
<application
    // ...
    android:debuggable="true"
    // ...
    >
    // ...
</application>
  1. 添加 ASan

库在 ndk 中有现成编译好的,直接复制到项目的 jniLibs 目录下就好了。

我用的 MacBook 是在下面的目录:

text 复制代码
**/Library/Android/sdk/ndk-bundle/toolchains/llvm/prebuilt/darwin-x86_64/lib64/clang/11.0.5/lib/linux

我们只需要复制下面的这些 .so 库,分别对应 Android 中支持的四种 CPU 架构,你也可以根据你们项目的实际情况选择复制。

text 复制代码
libclang_rt.asan-aarch64-android.so
libclang_rt.asan-arm-android.so
libclang_rt.asan-i686-android.so
libclang_rt.asan-x86_64-android.so

jniLibs 同级的目录下创建 resources 目录,在 resources 目录下也添加上 lib 目录,然后再添加上CPU 架构目录,然后在每个 CPU 架构目录下都添加一个 wrap.sh 脚本文件,脚本文件的内容如下:

shell 复制代码
#!/system/bin/sh
HERE="$(cd "$(dirname "$0")" && pwd)"
export ASAN_OPTIONS=log_to_syslog=false,allow_user_segv_handler=1
ASAN_LIB=$(ls $HERE/libclang_rt.asan-*-android.so)
if [ -f "$HERE/libc++_shared.so" ]; then
    # Workaround for https://github.com/android-ndk/ndk/issues/988.
    export LD_PRELOAD="$ASAN_LIB $HERE/libc++_shared.so"
else
    export LD_PRELOAD="$ASAN_LIB"
fi
"$@"

最终的目录结构就如下:

text 复制代码
<project root>
└── app
    └── src
        └── main
            ├── jniLibs
            │   ├── arm64-v8a
            │   │   └── libclang_rt.asan-aarch64-android.so
            │   ├── armeabi-v7a
            │   │   └── libclang_rt.asan-arm-android.so
            │   ├── x86
            │   │   └── libclang_rt.asan-i686-android.so
            │   └── x86_64
            │       └── libclang_rt.asan-x86_64-android.so
            └── resources
                └── lib
                    ├── arm64-v8a
                    │   └── wrap.sh
                    ├── armeabi-v7a
                    │   └── wrap.sh
                    ├── x86
                    │   └── wrap.sh
                    └── x86_64
                        └── wrap.sh

如果你的步骤没有问题就可以直接打包测试了,这里是我自己写的Demo(注意切换 asan 分支)。

测试 ASan

我的测试异常的代码如下:

C 复制代码
extern "C" JNIEXPORT void JNICALL
Java_com_tans_stacktrace_MainActivity_testVisitOutBoundArray(
        JNIEnv* env,
        jobject /* this */) {
    int size = sizeof(int) * 5;
    int *array = static_cast<int *>(malloc(size));
    memset(array, 1, size);
    int a = array[5];
    free(array);
}

我创建了一个大小为 5 的数组,然后我去访问它的第 6 个元素,也就是访问堆地址越界了。

当我调用上面的测试方法就可以得到以下错误:

text 复制代码
heap-buffer-overflow on address 0x003a70a5d9a4 at pc 0x0072080d5fa8 bp 0x007ff0d27da0 sp 0x007ff0d27d98
  READ of size 4 at 0x003a70a5d9a4 thread T0 (tans.stacktrace)
      #0 0x72080d5fa4  (/data/app/~~yRbBu2IUM8fNLLDRScE-XQ==/com.tans.stacktrace-XiTk9ASPoWB26xlzi3X_7A==/lib/arm64/libmemalloctest.so+0xfa4)
      #1 0x7271604ed4  (/apex/com.android.art/lib64/libart.so+0x13ced4)
      #2 0x72715fb564  (/apex/com.android.art/lib64/libart.so+0x133564)
      #3 0x7271670a78  (/apex/com.android.art/lib64/libart.so+0x1a8a78)
      #4 0x72717e0330  (/apex/com.android.art/lib64/libart.so+0x318330)
      #5 0x72717d665c  (/apex/com.android.art/lib64/libart.so+0x30e65c)
      #6 0x7271b46b70  (/apex/com.android.art/lib64/libart.so+0x67eb70)
      #7 0x72715f5914  (/apex/com.android.art/lib64/libart.so+0x12d914)
      #8 0x7271b476f0  (/apex/com.android.art/lib64/libart.so+0x67f6f0)
      #9 0x72715f5994  (/apex/com.android.art/lib64/libart.so+0x12d994)
      #10 0x7271b476f0  (/apex/com.android.art/lib64/libart.so+0x67f6f0)
      #11 0x72715f5994  (/apex/com.android.art/lib64/libart.so+0x12d994)
      #12 0x72717cdc58  (/apex/com.android.art/lib64/libart.so+0x305c58)
      #13 0x7271b331fc  (/apex/com.android.art/lib64/libart.so+0x66b1fc)
      #14 0x7271604ff8  (/apex/com.android.art/lib64/libart.so+0x13cff8)
      #15 0x720069e0  (/system/framework/arm64/boot-framework.oat+0x9209e0) 

我们看到错误信息 heap-buffer-overflow 也就是堆缓存溢出,不过我们看到的栈信息没有通过符号表翻译成方法,正好我在手把手教你如何 Dump Native 线程栈和监听崩溃信号文章中介绍了如何监听崩溃信号和获取崩溃栈,代码可以直接拿过来用。

然后我自己写的崩溃信号检测的代码拿到的崩溃栈如下:

less 复制代码
Fatal signal 6 (SIGABRT), code -1 (SI_QUEUE) in tid 13897 (tans.stacktrace), pid 13897 (com.tans.stacktrace)


     #00 pc 000000000000ad4c  /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libstacktrace.so (_Z9dumpStackP15DumpStackResulti+720)
     #01 pc 000000000000d6fc  /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libstacktrace.so
     #02 pc 00000000000005bc  [vdso]
     #03 pc 000000000007caa0  /apex/com.android.runtime/lib64/bionic/libc.so (abort+176)
     #04 pc 000000000004f020  /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libclang_rt.asan-aarch64-android.so
     #05 pc 000000000004dfe0  /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libclang_rt.asan-aarch64-android.so
     #06 pc 00000000000a6a28  /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libclang_rt.asan-aarch64-android.so
     #07 pc 00000000000a854c  /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libclang_rt.asan-aarch64-android.so
     #08 pc 00000000000a8df8  /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libclang_rt.asan-aarch64-android.so (__asan_report_load4+44)
     #09 pc 0000000000000fa4  /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libmemalloctest.so (Java_com_tans_stacktrace_MainActivity_testVisitOutBoundArray+168)
     #10 pc 000000000021a354  /apex/com.android.art/lib64/libart.so
     #11 pc 000000000020a2b0  /apex/com.android.art/lib64/libart.so
     #12 pc 0000000000209334  /apex/com.android.art/lib64/libart.so
     #13 pc 0000000000209334  /apex/com.android.art/lib64/libart.so
     at com.tans.stacktrace.MainActivity.testVisitOutBoundArray(Native Method)
     at com.tans.stacktrace.MainActivity.onCreate$lambda$10(MainActivity.kt:84)
     at com.tans.stacktrace.MainActivity.$r8$lambda$L6XZ7NlHHvoljHJjxVv09sxciGM(Unknown Source:0)
     at com.tans.stacktrace.MainActivity$$ExternalSyntheticLambda7.onClick(Unknown Source:2)
     at android.view.View.performClick(View.java:7558)
     at com.google.android.material.button.MaterialButton.performClick(MaterialButton.java:1213)
     at android.view.View.performClickInternal(View.java:7534)
     at android.view.View.-$$Nest$mperformClickInternal(Unknown Source:0)
     at android.view.View$PerformClick.run(View.java:29661)
     at android.os.Handler.handleCallback(Handler.java:942)
     at android.os.Handler.dispatchMessage(Handler.java:99)
     at android.os.Looper.loopOnce(Looper.java:240)
     at android.os.Looper.loop(Looper.java:351)
     at android.app.ActivityThread.main(ActivityThread.java:8377)
     at java.lang.reflect.Method.invoke(Native Method)
     at com.android.internal.os.RuntimeInit$MethodAndArgsCaller.run(RuntimeInit.java:584)
     at com.android.internal.os.WrapperInit.main(WrapperInit.java:94)
     at com.android.internal.os.RuntimeInit.nativeFinishInit(Native Method)
     at com.android.internal.os.RuntimeInit.main(RuntimeInit.java:391)

上面的栈很容易就能够看出异常的方法了,Java 栈都有的,最终触发异常的方法就是 #09 pc 0000000000000fa4 /data/app/~~kApqB69tP7GhTJ6jzHwE5Q==/com.tans.stacktrace-S3gYZAOZzY01y8ndX5ORYA==/lib/arm64/libmemalloctest.so (Java_com_tans_stacktrace_MainActivity_testVisitOutBoundArray+168) 也就是我们上面写的测试方法(地址也是和 ASan 报告的一样,都是 0x0fa4 地址)。

Tips: 如果你没有添加 ASan 上面的测试方法是不会崩溃的哦。

使用 Profiler 监控 Native 内存分配

通过 dump 虚拟机线程方法栈和堆内存来分析 Android 卡顿和 OOM 问题文章中介绍了如何通过 Profiler 如何 Dump Java 内存和监控 Java 内存的分配。这里就再介绍一下监控 Native 内存的分配。

我这里先给出一下我测试内存使用和回收的方法:

C 复制代码
static void **testMemArray = nullptr;
static int memArraySize = 0;

extern "C" JNIEXPORT void JNICALL
Java_com_tans_stacktrace_MainActivity_testAllocMem(
        JNIEnv* env,
        jobject /* this */,
        jlong size) {
    if (size <= 0) return;
    testMemArray = static_cast<void **>(realloc(testMemArray, (++memArraySize) * sizeof(void *)));
    auto data = malloc(size);
    memset(data, 0, size);
    testMemArray[memArraySize - 1] = data;
}

extern "C" JNIEXPORT void JNICALL
Java_com_tans_stacktrace_MainActivity_freeAllTestMem(
        JNIEnv* env,
        jobject /* this */) {
    if (memArraySize <= 0) return;
    for (int i = 0; i < memArraySize; i ++) {
        auto data = testMemArray[i];
        testMemArray[i] = nullptr;
        free(data);
    }
    free(testMemArray);
    testMemArray = nullptr;
    memArraySize = 0;
}

我分配内存用的 malloc 方法,然后分配好的内存还用 memset 方法把它的内存中的值设置为 0,这里为什么要写一下这段内存呢???哈哈哈,这也算是一个知识点吧,通过 malloc 分配的内存只是申请的虚拟内存,并没有映射到物理内存,只有当真正读写这块内存的时候才会执行物理内存的映射分配,Android 中的 Bitmap 也是一样的当创建 Bitmap 实例后也是分配的虚拟内存,只有真实的读写后才会分配物理内存,如果对虚拟内存不熟悉的同学可以看看我之前的文章:聊聊虚拟内存

这里要注意一点,使用了 ASan 是无法使用 Profiler 的,如果你用我的代码测试记得从 asan 分支切换到 master 分支重新打包。

然后一切准备好后打开 Profiler

然后选中 Record native allocations 然后点击 Record 这时就开始了监控 Native 的内存分配,当你需要结束的时候点击大红按钮就好了。 结束后我们就能看到如下的截图:

Profiler 是监控 malloccallocrealloc 这几个方法分配的内存的方法,上面的截图表示了不同的方法申请了多少内存,回收了多少内存。

我们再选中 Visualization tab,我们还能够看到每次分配的方法栈是什么样的:

我是分配了两次,每次分配内存 10MB

读取 smaps 文件

我们可以通过 adb shell dumpsys [pid] 去查询内存使用的整体情况,结果大致如下:

text 复制代码
Applications Memory Usage (in Kilobytes):
Uptime: 76374295 Realtime: 369954849

** MEMINFO in pid 15901 [com.phx.waha.dev] **
                   Pss  Private  Private  SwapPss      Rss     Heap     Heap     Heap
                 Total    Dirty    Clean    Dirty    Total     Size    Alloc     Free
                ------   ------   ------   ------   ------   ------   ------   ------
  Native Heap    77711    77684        0       23    79112   105984    88719    17264
  Dalvik Heap    13888    13788        0       77    15364    17353     8677     8676
 Dalvik Other     3702     2980        0        0     5416                           
        Stack     1908     1908        0        0     1920                           
       Ashmem       55       12        0        0      504                           
    Other dev       48        0       44        0      632                           
     .so mmap    43724     2460    35820       15    79656                           
    .jar mmap     6071        0     3344        0    37364                           
    .apk mmap      698        0      232        0     2288                           
    .ttf mmap     1047        0      264        0     2640                           
    .dex mmap    12702      512    12180        0    13428                           
    .oat mmap       52        0        4        0     1744                           
    .art mmap     8602     7948       92       43    19540                           
   Other mmap      112       40       64        0      908                           
    GL mtrack    67368    67368        0        0    67368                           
      Unknown     1277     1256        8        0     1688                           
        TOTAL   239123   175956    52052      158   329572   123337    97396    25940
 
 App Summary
                       Pss(KB)                        Rss(KB)
                        ------                         ------
           Java Heap:    21828                          34904
         Native Heap:    77684                          79112
                Code:    54836                         138068
               Stack:     1908                           1920
            Graphics:    67368                          67368
       Private Other:     4384
              System:    11115
             Unknown:                                    8200
 
           TOTAL PSS:   239123            TOTAL RSS:   329572       
           
// ...

如果不知道里面的参数什么意思,可以参考我之前的文章如何解读 dumpsys meminfo 的信息,通过 dumpsys 的方法拿到的是一个内存的整体的使用情况,分不同的类型统计。

我们还可以通过 showmap -a [pid] 来查看每段虚拟内存地址的内存使用情况:

text 复制代码
emulator_arm64:/ # showmap -a 2543
           start              end  virtual                     shared   shared  private  private
            addr             addr     size      RSS      PSS    clean    dirty    clean    dirty     swap  swapPSS object
-------- -------- -------- -------- -------- -------- -------- -------- -------- -------- -------- ------------------------------
        12c00000         2ac00000   393216     8304     8304        0        0        0     8304        0        0 [anon:dalvik-main space (region space)]
        70262000         704f1000     2620     2620     1229        0     1452        0     1168        0        0 [anon:dalvik-/apex/com.android.art/javalib/boot.art]
        704f1000         7054c000      364      364      229        0      140        0      224        0        0 [anon:dalvik-/apex/com.android.art/javalib/boot-core-libart.art]
        7054c000         70618000      816      816      379        0      456        0      360        0        0 [anon:dalvik-/apex/com.android.art/javalib/boot-core-icu4j.art]
        70618000         7064f000      220      220       74        0      152        0       68        0        0 [anon:dalvik-/apex/com.android.art/javalib/boot-okhttp.art]
// ...

showmap 又比 dumpsys meminfo 的信息更加详细了,包含每段地址,也包含内存对应的库 (大部分都是系统的,我们自己的库我不知道为什么无法显示出来)。

然后最最最详细的内存使用信息可能就是 proc/[pid]/smaps 文件中的内容了,我猜测 showmapdumpsys meminfo 中的数据也是来自这个文件。

text 复制代码
12c00000-2ac00000 rw-p 00000000 00:00 0                                  [anon:dalvik-main space (region space)]
Name:           [anon:dalvik-main space (region space)]
Size:             393216 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                2136 kB
Pss:                2136 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:      2136 kB
Referenced:         2136 kB
Anonymous:          2136 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 
6f706000-6f995000 rw-p 00000000 00:00 0                                  [anon:dalvik-/apex/com.android.art/javalib/boot.art]
Name:           [anon:dalvik-/apex/com.android.art/javalib/boot.art]
Size:               2620 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:                2620 kB
Pss:                1151 kB
Shared_Clean:          0 kB
Shared_Dirty:       1512 kB
Private_Clean:         0 kB
Private_Dirty:      1108 kB
Referenced:         2200 kB
Anonymous:          2620 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 
// ...

这个文件相对大一点,我给出一部分,开始就指出了该段虚拟内存的地址,然后对应的模块名字,然后 Size 是指虚拟内存地址大小,Pss 是实际的占用物理内存大小,我们看到 Java 虚拟机占用 393216 kB 的虚拟内存,实际占用内存为 2136 kB,我们通常看 PssRssPrivate_Dirty 等等参数,其他的参数数据可以参考一下。

然后我会回到我们自己的测试内存分配的代码,然后我在 smaps 中找到了我们测试代码的内存记录:

text 复制代码
// ...
6fd34d7000-6fd3ed8000 rw-p 00000000 00:00 0                              [anon:scudo:secondary]
Name:           [anon:scudo:secondary]
Size:              10244 kB
KernelPageSize:        4 kB
MMUPageSize:           4 kB
Rss:               10244 kB
Pss:               10244 kB
Shared_Clean:          0 kB
Shared_Dirty:          0 kB
Private_Clean:         0 kB
Private_Dirty:     10244 kB
Referenced:        10244 kB
Anonymous:         10244 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 
// ...

我们看到这段内存并没有指出来是我们的库分配的,是一段匿名的内存,由 scudo 内存分配器分配,我发现大部分的系统库会指出名字,我也测试了 ffmpeg 也是能够指出名字,我们能够知道的就是一段虚拟内存地址,通过这段地址讲道理应该是可以找到对应的 .so 库才对,不过我现在还没有找到方法,如果能够有方法把这个地址翻译成对应的 .so 库,我认为对我们分析线上的 OOM 有挺大的帮助。

最后

希望通过我自己的一些实践对大家去处理 Native 内存问题有一些帮助。

相关推荐
NRatel11 分钟前
Unity 游戏提升 Android TargetVersion 相关记录
android·游戏·unity·提升版本
John_ToDebug19 分钟前
Chromium base 库中的 Observer 模式实现:ObserverList 与 ObserverListThreadSafe 深度解析
c++·chrome·性能优化
Hilaku2 小时前
深入WeakMap和WeakSet:管理数据和防止内存泄漏
前端·javascript·性能优化
叽哥3 小时前
Kotlin学习第 1 课:Kotlin 入门准备:搭建学习环境与认知基础
android·java·kotlin
风往哪边走3 小时前
创建自定义语音录制View
android·前端
用户2018792831673 小时前
事件分发之“官僚主义”?或“绕圈”的艺术
android
用户2018792831673 小时前
Android事件分发为何喜欢“兜圈子”?不做个“敞亮人”!
android
Kapaseker5 小时前
你一定会喜欢的 Compose 形变动画
android
QuZhengRong5 小时前
【数据库】Navicat 导入 Excel 数据乱码问题的解决方法
android·数据库·excel
zhangphil6 小时前
Android Coil3视频封面抽取封面帧存Disk缓存,Kotlin(2)
android·kotlin