技术解码 | 内存问题的分析与定位

2021-04-29 14:10:48 浏览数 (1)

本期的技术解码,为您解析

编程中,内存问题的分析与定位方法

对编程语言设计来说,内存管理分为两大类:手动内存管理(manual memory management) 和垃圾回收(garbage collection). 常见的如C、C 使用手动内存管理,Java使用垃圾回收。本文主要关注手动内存管理。

GC

GC使内存管理自动化,缺点是引入了GC时不可预测的暂停(unpredictable stall),对实时性要求高的场景不适用。现代的GC实现一直朝着减小“stop-the-world"影响的方向进行优化。

有GC机制的编程语言不代表彻底告别了内存泄漏(此时内存泄漏的含义与手动内存管理语言稍有不同)。当短生命周期对象被长生命周期对象一直持有时,短生命周期对象实际不再被调用但又得不到GC,即为内存泄漏。这类泄漏在Android应用开发中普遍存在,尤其要注意匿名内部类的使用。可以用LeakCanary工具进行内存泄漏检查LeakCanary(https://square.github.io/leakcanary/).

手动内存管理

对于手动内存管理,引用计数(reference counting)是常用的避免内存泄漏的手段。实际上,引用计数可以解决两大问题

  1. 内存泄漏(memory leak)
  2. 重复释放(double free)

引用计数存在一个缺点,无法解决循环引用(reference cycles)的问题。当存在循环引用时,计数始终 > 0,对象得不到释放。

  • 如果编程时能够识别出循环引用的场景,可以使用弱引用来解决。C 11引入了std::shared_ptr和std::weak_ptr。多个shared_ptr和weak_ptr共享一个control block,control block内记录了shared_ptr和weak_ptr的数量,如下图所示。当所有的shared_ptr out of scope之后,被管理对象会被析构,但是control block本身占用的内存不会被释放,用来记录还有多少个weak_ptr。只有所有weak_ptr也析构之后,control block才被释放。 注意这里有个细节:当使用std::make_shared()时,被管理对象的内存和control block可能一起分配(占用一块大内存)。如果使用weak_ptr,当shared_ptr都析构之后,weak_ptr还在使用control block. 因为被管理对象的内存和control block是一起分配的,所以被管理对象也只能析构,而不能释放内存。细节见附录:std::make_shared and std::weak_ptr.
  • 识别循环引用,就像识别死锁的lock-order-inversion一样,可能非常困难。有些循环引用比较隐蔽,特别是不同的模块由不同的开发者开发时,调用者被调用者存在复杂的引用关系。GC可以使用可达性判断,彻底解决循环引用问题

C语言和C 都可以使用引用计数,但只有引用计数与RAII(Resource Acquisition Is Initialization)的结合,才使得手动内存管理的便捷程度接近于GC. C语言必须手动调用hold, release等方法来对引用计数做增减和释放内存。如果某些代码路径特别是错误处理上漏了一个release,即导致内存泄漏。而RAII可以通过对象的构造和析构来自动增减引用计数,即使出现exception的场景,也可以保证正确的引用计数。

RAII本身可以独立使用,可以用于非内存对象的场景,比如文件描述符。GC的一个缺点是无法及时自动释放非内存资源,例如Java的finalizer并不等于C 的析构,finalizer可以作为最后的兜底策略,不能作为关闭文件描述符的第一选择。

Rust也是使用引用计数 RAII来解决内存安全问题。Rust的语言设计使得简单的循环引用场景在编译时报错,降低循环引用出现的可能性,但不能彻底避免循环引用。

常见的内存问题有:

  • 内存泄漏 (memory leak)
  • 空指针解引用 (null pointer dereference)
  • 释放后又访问 (use after free, dangling pointer)
  • 重复释放 (double free)
  • 越界访问 (buffer overflow, index out of range)
    • 堆上和栈上都可能出现
  • 栈溢出(stack overflow)
  • 读取未初始化的数据
  • 内存地址不对齐 (aligment)
    • 例如,把char 强转成int , 再解引用,可能导致crash
  • 线程安全中的内存问题

有一些常见的误区:

  • 通过空指针调用对象方法一定崩溃吗? 不一定崩溃。如果成员函数是实函数,又没有直接或间接访问成员变量,则不会发生崩溃。这种情况下,普通成员函数与静态成员函数类似。
  • 通过野指针调用对象方法一定崩溃吗? 不一定崩溃。取决于对象的内存是否被重新分配、是否被覆写、是否访问成员变量、是否为虚函数等。可能不立即崩溃但误操作内存数据,导致程序后续运行逻辑异常或crash,即埋下一颗地雷。
  • 内存不足malloc一定返回空指针吗? 不一定。涉及内存分配的overcommit问题:https://www.kernel.org/doc/Documentation/vm/overcommit-accounting。C new更复杂一些。开启exception的情况下,内存分配失败可能throw std::bad_alloc,不返回空指针。可以通过 new(std::nothrow) 让new不抛出异常,例如:
代码语言:javascript复制
 void test() {      try {          int *p = new int[1ULL << 50U];          std::cout << p << 'n';          delete[] p;      } catch (const std::bad_alloc &e) {          std::cout << e.what() << 'n';      }
      int *p = new(std::nothrow) int[1ULL << 50U];      if (p == nullptr) {          std::cout << "Allocation returned nullptrn";      }      delete[] p;  }

另外,free(NULL)和delete nullptr都是安全的,是否判断非空指针再delete是代码风格问题。

内存操作错误会导致undefined behavior,可能让程序逻辑异常,最明显的是crash。通过crash来分析、定位和解决内存相关bug,是一种亡羊补牢的做法,如果能够在程序灰度过程中及时解决,犹未晚矣。

NDK开发是Android应用开发的重要组成部分,尤其是包含音视频功能的应用。Java、Kotlin等语言异常crash时,往往有清晰的backtrace,理清crash现场相对容易。而面对native的crash以及上报系统上报的一堆寄存器信息等,一些开发同学可能觉得无从下手。下面以Android平台为例,简述native crash的分析工具、分析方法。

native crash 现场分类

Android平台App出现native crash,少数情况是发生了不可恢复的错误,主动abort (SIGABRT),多数情况是由内存问题导致的。

主动abort()的场景,一般会给出abort()的原因。例如Android的日志打印LOG_FATAL(),会先打印出log message,再abort(). 应用一般不调用LOG_FATAL(), 偶尔可以看到Android系统因为一些异常情况而LOG_FATAL(). 如果crash上报系统有崩溃现场完善的日志,通过日志分析原因是比较容易的。

因不同的内存问题导致的crash,呈现不同的现场,例如:

  • SIGSEGV: segmentation violation
    • 访问内存地址非法,可能是空指针,可能是空指针加了一个比较小的offset,也可能是任意数值
  • SIGILL: illegal instruction 非法指令问题
    • 少数情况是cpu架构、toolchain编译配置等导致
    • 多数情况是程序跑飞了
  • SIGBUS: 内存地址不对齐
  • 内存不足
    • 可能是程序逻辑正常但使用了过多的内存
    • 可能是内存泄漏导致的内存不足

崩溃现场信息

crash上报系统通常会上报如下信息:

  • 日志
  • backtrace调用栈
  • 寄存器信息
  • 动态库加载地址

日志中可能同时包含backtrace和寄存器信息,例如:

图中的一些关键信息:

  • Signal: SIGSEGV,说明访问了非法的内存地址(invalid memory reference). SEGV有多种,常见的两种是:
    • SEGV_MAPERR: address not mapped to object, 访问的内存没有映射到用户地址空间,空指针或野指针导致
    • SEGV_ACCERR: invalid permissions for mapped object, 权限错误,比如往只读内存区域写数据 其他见:asm-generic/siginfo.h
  • Fault address: 非法访问的内存地址
  • 线程号和线程名: tid 13876(network_thread)
    • 各个系统平台没有统一的设置线程名的API,即使同为类Unix系统也不一样。例如,gstreamer设置线程名的方法:https://gitlab.gnome.org/GNOME/glib/-/blob/master/glib/gthread-posix.c#L1382
代码语言:javascript复制
  void    g_system_thread_set_name (const gchar *name)    {    #if defined(HAVE_PTHREAD_SETNAME_NP_WITHOUT_TID)      pthread_setname_np (name); /* on OS X and iOS */    #elif defined(HAVE_PTHREAD_SETNAME_NP_WITH_TID)      pthread_setname_np (pthread_self (), name); /* on Linux and Solaris */    #elif defined(HAVE_PTHREAD_SETNAME_NP_WITH_TID_AND_ARG)      pthread_setname_np (pthread_self (), "%s", (gchar *) name); /* on NetBSD */    #elif defined(HAVE_PTHREAD_SET_NAME_NP)      pthread_set_name_np (pthread_self (), name); /* on FreeBSD, DragonFlyBSD, OpenBSD */    #endif    }
  • 寄存器和backtrace
    • 注意,上图除了#00 pc,其他的值并不是pc寄存器的值,而是已经减去了动态库加载的基地址,还原成了动态库中的相对地址

不同信息用不同工具来分析:

  • addr2line, ndk-stack等,可以根据backtrace定位代码行
  • 用objdump反编译动态库,再根据pc地址、寄存器信息,可以找到导致崩溃的汇编指令和操作符的值
  • pc寄存器的值和动态库加载地址信息,可以算出对应动态库中的相对地址。logcat打印的backtrace已经是转换之后的地址,一般不需要手动换算

基本分析流程

第0步:编译时保存带符号动态库

如果在编译构建环节没有保存带符号动态库,而是crash发生之后再重新生成动态库,新生成的动态库不一定与上线发布的版本匹配。能够准确还原调用栈,这一步尤其重要。无符号分析和调试也是一种手段,但复杂性显著增加。

另外,要特别注意是否关掉了unwind table. 例如编译webrtc release会关闭unwind table,然后导致crash时backtrace不完整

build/config/compiler/BUILD.gn:

代码语言:javascript复制
if (!is_nacl) {      if (exclude_unwind_tables) {        cflags  = [          "-fno-unwind-tables",          "-fno-asynchronous-unwind-tables",        ]        defines  = [ "NO_UNWIND_TABLES" ]      } else {        cflags  = [ "-funwind-tables" ]      }    }

webrtc为何关闭unwind table在compiler.gni里有说明:

代码语言:javascript复制
# Exclude unwind tables for official builds as unwinding can be done from stack# dumps produced by Crashpad at a later time "offline" in the crash server.# For unofficial (e.g. development) builds and non-Chrome branded (e.g. Cronet# which doesn't use Crashpad, crbug.com/479283) builds it's useful to be able# to unwind at runtime.exclude_unwind_tables =    is_official_build || (is_chromecast && !is_cast_desktop_build &&                          !is_debug && !cast_is_debug && !is_fuchsia)

关于符号的一些说明:

  • 符号有调试符号和函数符号等,strip命令有参数控制strip级别,是只裁剪调试符号还是裁剪所有不需要的符号
  • 编译器优化级别和是否带调试符号两者是正交的,并不是只有-O0/-Og才能加-g
  • CMake有几个CMAKE_BUILD_TYPE
    • Debug
    • Release
    • RelWithDebInfo
    • MinSizeRel ... 如果脱离Android Studio,单独用cmake构建Android的动态库,可以使用RelWithDebInfo生成带符号的release版动态库,再strip
  • 有时候为了避免符号冲突,有几种处理方式:
    • 编译控制,例如cmake
代码语言:javascript复制
   set(CMAKE_C_VISIBILITY_PRESET hidden)    set(CMAKE_CXX_VISIBILITY_PRESET hidden)
  • 或直接配置: -fvisibility=hidden
  • JNI函数用注册的方式: https://developer.android.com/training/articles/perf-jni
  • linker version script: https://ftp.gnu.org/old-gnu/Manuals/ld-2.9.1/html_node/ld_25.html 几种方式可以配合使用

还原调用栈

第一步通常是用addr2line还原调用栈。ndk提供了简化工具ndk-stack,可以直接输入日志输出还原的调用栈。例如

代码语言:javascript复制
adb logcat > /tmp/foo.txt$NDK/ndk-stack -sym $PROJECT_PATH/obj/local/armeabi-v7a -dump foo.txt

使用addr2line,常用参数有:(https://linux.die.net/man/1/addr2line

  • -e filename Specify the name of the executable for which addresses should be translated. The default file is a.out.
  • -C Decode (demangle) low-level symbol names into user-level names. 不使用此参数,得到的是C mangle之后的符号,可读性差,demangle之后可以得到与源码一致的class和函数名。另有一个专门的工具做demangle: c filt. 例如
代码语言:javascript复制
  $ c  filt -n _Z1fv  f()
  • -f Display function names as well as file and line number information. 注意,-f 可以用于strip前的动态库也可以用于strip后的动态库,取决于strip的级别,多个函数代码段可能被合并到一个符号处,-f不一定能得到正确的符号名

还原调用栈之后,结合日志信息,有些崩溃可以立刻定位出原因,比如对象空指针;有些则原因不明,或者看起来像是发生了“不可能的崩溃”,需要进一步分析。

反编译

addr2line定位出代码行之后,一行代码可能包含多次解引用,可能包含多个条件语句判断,不能确定具体是哪个操作触发了crash(另一方面我们可以反思,应当避免把一行代码写的过于复杂)。通过反编译可以找出触发crash的汇编指令。

可以使用objdump进行反编译。objdump的功能很多,用于反编译时,常用参数如下:(https://linux.die.net/man/1/objdump

  • -d Display the assembler mnemonics for the machine instructions from objfile.
  • -D Like -d, but disassemble the contents of all sections, not just those expected to contain instructions.
  • -C 与addr2line的-C一样,也是demangle功能

例如:

代码语言:javascript复制
aarch64-linux-android-objdump -D -C libvlc.so > dump

在objdump输出的文件中,查找pc地址。例如,前面crash在0x91c30的例子:

代码语言:javascript复制
0000000000091c04 <cricket::Connection::ToString() const>:  91c04:  a9bb7bfd   stp  x29, x30, [sp,#-80]!  91c08:  f9000bfc   str  x28, [sp,#16]  91c0c:  a9025ff8   stp  x24, x23, [sp,#32]  91c10:  a90357f6   stp  x22, x21, [sp,#48]  91c14:  a9044ff4   stp  x20, x19, [sp,#64]  91c18:  910003fd   mov  x29, sp  91c1c:    d104c3ff   sub  sp, sp, #0x130  91c20:  f9400009   ldr  x9, [x0]  91c24:  aa0003f4   mov  x20, x0  91c28:  aa0803f3   mov  x19, x8  91c2c:  f9400929   ldr  x9, [x9,#16]  91c30:  d63f0120   blr  x9

可以看到,是 blr x9 时crash. crash时的寄存器信息,fault address等都可以一一对应起来。

反编译之后,需要一些汇编的基础知识来阅读分析汇编代码:

  • ARM的汇编指令见:Arm Architecture Reference. 注意区分不同架构的ARM指令。编译armeabi-v7a架构动态库时,默认会开启thumb2指令. thumb2指令是16位的,可以让生成的动态库更小。objdump输出的汇编中,pc每次增加4字节的是arm指令,增加2字节的是thumb2指令
  • 除了汇编指令之外,还要了解ARM的ABI,在C和C 语言中如何传递参数和返回值。ARM ABI,见:https://developer.arm.com/architectures/system-architectures/software-standards/abi.
    • 注意Apple虽然也使用ARM架构,但Apple用的ABI与ARM自己定义的ABI有差异。Apple使用的ABI,见:https://developer.apple.com/documentation/xcode/writing_arm64_code_for_apple_platforms

因为这段汇编逻辑不复杂,已经可以定位出是Connection对象野指针,从虚函数表读取了错误的虚函数地址,再去执行虚函数时crash。

有时候代码逻辑复杂,能够定位出crash时的指令,但不清楚怎么和C 代码对应的,可以借助调试器来分析和验证猜想。

调试器调试

代码调试通常只需要单步调试,但在crash分析场景,单指令调试更加方便。单指令调试结合打印寄存器值,可以快速找出汇编指令和C 的对应关系。例如,通过调试可以确认,x9是哪个虚函数的地址。

单指令调试:

溯因

通过还原调用栈、反编译、调试验证等,可以理清楚崩溃现场,找到crash的直接原因。但是问题的根本原因可能还未暴露。比如,从虚函数表加载的虚函数地址异常,可以推出Connection对象异常,但问题未必出在Connection. 需要把思路拓宽,避免紧盯着crash的一行代码而找不到根本原因。考虑如下方向

  • 空指针,实函数内操作成员变量crash
  • 野指针
    • 实函数内操作成员变量crash
    • 虚函数寻址crash
    • 成员的成员函数,父类的成员的成员函数
    • 被其他野指针破坏了内存数据
  • ABI兼容问题
    • 头文件和库不匹配,导致越界访问或代码逻辑错乱

调试器在溯因过程中也非常有用。gdb和lldb都支持设置watchpoint. watchpoint可以用来分析越界读写数据:当发生了对某些地址的读写行为时,暂停程序。基本用法见下表GDB to LLDB command map:(https://lldb.llvm.org/use/map.html)

watchpoint实现原理见:Hardware Breakpoint (or watchpoint) usage in Linux Kernel

Android Studio中调试ndk代码见:https://developer.android.com/studio/debug

从崩溃分析定位和解决内存问题是亡羊补牢,而在开发过程中,我们应当做到未雨绸缪。一些工具可以方便的进行内存问题检查,与持续集成相结合,可以有效减少crash问题,提高软件质量。

基础手段

一些基础手段可以用来验证是否有内存泄漏。

  • top/htop 查看程序的内存占用和变化趋势,可以发现一些大块的内存泄漏
  • malloc hook
    • 在程序内对内存的使用做一个统计分析
    • Android和Linux下都有提供: https://android.googlesource.com/platform/bionic/ /master/libc/malloc_hooks/README.md https://man7.org/linux/man-pages/man3/malloc_hook.3.html 注意,malloc_hook()有严重的线程安全性问题
  • 封装自己的内存管理函数,添加调试开关,记录内存的分配和释放。例如,mpv使用ta ("Tree Allocator") https://github.com/mpv-player/mpv/tree/master/ta
代码语言:javascript复制
TA ("Tree Allocator") is a wrapper around malloc() and related functions,adding features like automatically freeing sub-trees of memory allocations ifa parent allocation is freed.
Generally, the idea is that every TA allocation can have a parent (indicatedby the ta_parent argument in allocation function calls). If a parent is freed,its child allocations are automatically freed as well. It is also allowed tofree a child before the parent, or to move a child to another parent withta_set_parent().
It also provides a bunch of convenience macros and debugging facilities.
The TA functions are documented in the implementation files (ta.c, ta_utils.c).
TA is intended to be useable as library independent from mpv. It doesn'tdepend on anything mpv specific.
  • 对特定class的简单计数

Valgrind

简介

Valgrind是Linux平台常用的内存检查工具。用Valgrind启动应用,Valgrind相当于一个虚拟机,跟踪记录应用的内存申请释放等操作。Valgrind工具集包含多个工具,最常用的是memcheck. memcheck能够检查如下问题:

  • Use of uninitialized memory
  • Reading/writing memory after it has been free'd
  • Reading/writing off the end of malloc'd blocks
  • Memory leaks

Valgrind memcheck的优点是不需要重新编译应用,缺点是运行速度慢,比正常运行的应用慢20~30倍,并且占用更多内存。因此,Valgrind不适用于强实时性应用,如播放器。

另外,massif是heap profiler工具,可以量化各个模块的内存占用,以便有针对性的进行内存优化。

详细文档见:http://valgrind.org/docs/manual/index.html

Android早期版本支持Valgrind,AOSP源码中包含Valgrind的编译构建。但是Android 8.0以后,Valgrind基本无法运行。而且运行Valgrind需要root权限,因此很难找到一个可以运行Valgrind的Android设备。下面简述一下在Android上使用Valgrind的基本流程。

下载编译
  1. 从此页面http://valgrind.org/downloads/current.html下载最新Release压缩包
  2. 编译Android平台版本主要参考压缩包里的README.android
代码语言:javascript复制
export TOOLCHAINS_PATH=$ANDROID_NDK/toolchains/arm-linux-androideabi-4.9/prebuilt/darwin-x86_64/bin    export AR=$TOOLCHAINS_PATH/arm-linux-androideabi-ar    export LD=$TOOLCHAINS_PATH/arm-linux-androideabi-ld    export CC=$TOOLCHAINS_PATH/arm-linux-androideabi-gcc    export RANLIB=$TOOLCHAINS_PATH/arm-linux-androideabi-ranlib    export CPPFLAGS="--sysroot=$ANDROID_NDK/platforms/android-9/arch-arm"    export CFLAGS="--sysroot=$ANDROID_NDK/platforms/android-9/arch-arm"    ./configure --prefix=/data/local/tmp/Inst         --host=armv7-unknown-linux --target=armv7-unknown-linux         --with-tmpdir=/sdcard    make -j8 && make -j8 install DESTDIR="$PWD/Inst"
安装Valgrind
  1. 首先确认要安装的设备有root权限否则无法通过Valgrind启动应用
  2. adb push到设备,注意: 安装到设备时,安装的目录必须和交叉编译时--prefix指定的目录一致
代码语言:javascript复制
   adb push Inst/data/local/tmp/Inst/ /data/local/tmp/
准备应用程序

进行内存检查时,Valgrind能够给出异常的代码行和调用栈,前提是应用程序包含调试符号信息

启动应用程序
  1. 创建Valgrind日志输出目录 adb shell mkdir /sdcard/valgrind/
  2. adb push start_valgrind.sh 到 /data/local/tmp/ 目录,并添加执行权限,start_valgrind.sh 内容如下(请根据实际情况修改包名PACKAGE)
代码语言:javascript复制
    #!/system/bin/sh    PACKAGE="com.example.helloworld"    # Callgrind tool    #VGPARAMS='-v --error-limit=no --trace-children=yes --log-file=/sdcard/valgrind.log.%p --tool=callgrind --callgrind-out-file=/sdcard/callgrind.out.%p'    # Memcheck tool    #VGPARAMS='-v --error-limit=no --trace-children=yes --tool=memcheck --leak-check=full --show-reachable=yes'    VGPARAMS='-v --error-limit=no --trace-children=yes --track-origins=yes --log-file=/sdcard/valgrind/log.%p --tool=memcheck --leak-check=full --show-reachable=yes'    export TMPDIR=/data/data/$PACKAGE    exec /data/local/tmp/Inst/bin/valgrind $VGPARAMS $*

在设备上执行:

代码语言:javascript复制
    PACKAGE="com.example.helloworld"    setprop "wrap.$PACKAGE" 'logwrapper /data/local/tmp/start_valgrind.sh'    getprop "wrap.$PACKAGE" #make sure setprop success    am force-stop $PACKAGE    am start -a android.intent.action.MAIN -n $PACKAGE/.MainActivity

setprop生效之后,通过am start方式和UI界面操作方式都可以启动应用。启用应用时,会先执行 start_valgrind.sh,start_valgrind.sh 执行Valgrind,Valgrind再启动要测试的应用程序。耐心等待应用程序启动,然后进行常规操作测试。

输出结果

程序执行过程中,Valgrind会把部分检查结果(如未初始化,越界访问等)输出到 /sdcard/valgrind/ 目录下。但只有程序完全退出后,Valgrind才会给出内存泄漏汇总的结果。

Android上,可以通过kill -TERM让程序退出。避免使用kill -KILL,kill -KILL会让程序会立刻终止,Valgrind无法输出结果。

在Linux系统上对demo程序做检查

代码语言:javascript复制
#include <iostream>int main(int argc, char *argv[]){    char *p = new char[2];    p[2] = 123;    if (p[0] > 'a')        std::cout << __LINE__ << std::endl;    else        std::cout << __LINE__ << std::endl;    return 0;}

Valgrind输出如下:

代码语言:javascript复制
==1157== Memcheck, a memory error detector==1157== Copyright (C) 2002-2015, and GNU GPL'd, by Julian Seward et al.==1157== Using Valgrind-3.11.0 and LibVEX; rerun with -h for copyright info==1157== Command: ./foo==1157== ==1157== Invalid write of size 1==1157==    at 0x40088B: main (foo.cpp:7)==1157==  Address 0x5ab6c82 is 0 bytes after a block of size 2 alloc'd==1157==    at 0x4C2E80F: operator new[](unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)==1157==    by 0x40087E: main (foo.cpp:5)==1157== ==1157== Conditional jump or move depends on uninitialised value(s)==1157==    at 0x400897: main (foo.cpp:9)==1157==  Uninitialised value was created by a heap allocation==1157==    at 0x4C2E80F: operator new[](unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)==1157==    by 0x40087E: main (foo.cpp:5)==1157== 12==1157== ==1157== HEAP SUMMARY:==1157==     in use at exit: 72,706 bytes in 2 blocks==1157==   total heap usage: 3 allocs, 1 frees, 73,730 bytes allocated==1157== ==1157== 2 bytes in 1 blocks are definitely lost in loss record 1 of 2==1157==    at 0x4C2E80F: operator new[](unsigned long) (in /usr/lib/valgrind/vgpreload_memcheck-amd64-linux.so)==1157==    by 0x40087E: main (foo.cpp:5)==1157== ==1157== LEAK SUMMARY:==1157==    definitely lost: 2 bytes in 1 blocks==1157==    indirectly lost: 0 bytes in 0 blocks==1157==      possibly lost: 0 bytes in 0 blocks==1157==    still reachable: 72,704 bytes in 1 blocks==1157==         suppressed: 0 bytes in 0 blocks==1157== Reachable blocks (those to which a pointer was found) are not shown.==1157== To see them, rerun with: --leak-check=full --show-leak-kinds=all==1157== ==1157== For counts of detected and suppressed errors, rerun with: -v==1157== ERROR SUMMARY: 3 errors from 3 contexts (suppressed: 0 from 0)

可以看到,p[2] 越界访问、p[0] 未初始化、内存泄漏,三个bug都能被定位到。

sanitizer

简介

Sanitizers包含如下几种工具

https://github.com/google/sanitizers

  • AddressSanitizer (detects addressability issues) and LeakSanitizer (detects memory leaks)
  • ThreadSanitizer (detects data races and deadlocks) for C  and Go
  • MemorySanitizer (detects use of uninitialized memory)
  • HWASAN, or Hardware-assisted AddressSanitizer, a newer variant of AddressSanitizer that consumes much less memory
  • UBSan, or UndefinedBehaviorSanitizer
AddressSanitizer

实现原理见:《AddressSanitizer: A Fast Address Sanity Checker》

平台支持情况:

功能:

  • Use after free (dangling pointer dereference)
  • Heap buffer overflow
  • Stack buffer overflow
  • Global buffer overflow
  • Use after return
  • Use after scope
  • Initialization order bugs
  • Memory leaks.

注意: 检查内存泄漏的功能LeakSanitizer当前只支持Linux和macOS,且macOS上需要另外安装llvm toolchain,Xcode自带的不支持。Linux上默认开启LeakSanitizer,macOS上需要环境变量控制开启:ASAN_OPTIONS=detect_leaks=1

与Valgrind相比,Address Sanitizers对程序执行速度影响小,大约是正常执行速度的1/2.

LLVM 3.1,GCC 4.8开始支持Address Sanitizer.

编译参数,配置cflags, cxxflags, link flags: -fsanitize=address. 为了输出结果更具可读性,还需要配置下编译器优化级别、开启调试符号、跳过strip等。

Xcode内开启asan的方法:

Android上使用asan的基本步骤https://developer.android.com/ndk/guides/asan

  • 修改编译选项
代码语言:javascript复制
 target_compile_options(${TARGET} PUBLIC -fsanitize=address -fno-omit-frame-pointer)  set_target_properties(${TARGET} PROPERTIES LINK_FLAGS -fsanitize=address)
  • 把asan运行时libclang_rt.asan-arm-android.so、libclang_rt.asan-aarch64-android.so等放到app动态库目录
  • 在app动态库目录添加一个wrap.sh
代码语言:javascript复制
 #!/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  "$@"

整体结构如下:

代码语言:javascript复制
 <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
  • 编译运行启动app,当发生内存操作错误时,日志输出错误信息

示例代码:

代码语言:javascript复制
#include <stdlib.h>int main() {  char *x = (char*)malloc(10 * sizeof(char*));  free(x);  return x[5];}

编译: /tmp$ gcc -fsanitize=address foo.c -o foo -g -O0

执行:

代码语言:javascript复制
/tmp$ ./foo===================================================================6145==ERROR: AddressSanitizer: heap-use-after-free on address 0x60700000dfb5 at pc 0x0000004007d4 bp 0x7ffcf79be420 sp 0x7ffcf79be410READ of size 1 at 0x60700000dfb5 thread T0    #0 0x4007d3 in main /tmp/foo.c:5    #1 0x7f84f366482f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6 0x2082f)    #2 0x4006a8 in _start (/tmp/foo 0x4006a8)0x60700000dfb5 is located 5 bytes inside of 80-byte region [0x60700000dfb0,0x60700000e000)freed by thread T0 here:    #0 0x7f84f3aa62ca in __interceptor_free (/usr/lib/x86_64-linux-gnu/libasan.so.2 0x982ca)    #1 0x400797 in main /tmp/foo.c:4    #2 0x7f84f366482f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6 0x2082f)previously allocated by thread T0 here:    #0 0x7f84f3aa6602 in malloc (/usr/lib/x86_64-linux-gnu/libasan.so.2 0x98602)    #1 0x400787 in main /tmp/foo.c:3    #2 0x7f84f366482f in __libc_start_main (/lib/x86_64-linux-gnu/libc.so.6 0x2082f)SUMMARY: AddressSanitizer: heap-use-after-free /tmp/foo.c:5 main...
Thread Sanitizer

ThreadSanitizer (tsan) 用来检查多线程竞争问题。clang 3.2版本、gcc 4.8版本开始支持ThreadSanitizer.

编译参数,配置cflags, cxxflags, link flags: -fsanitize=thread.


示例代码:

代码语言:javascript复制
#include <pthread.h>#include <stdio.h>int Global;void *Thread1(void *x) {  Global  ;  return NULL;}void *Thread2(void *x) {  Global--;  return NULL;}int main() {  pthread_t t[2];  pthread_create(&t[0], NULL, Thread1, NULL);  pthread_create(&t[1], NULL, Thread2, NULL);  pthread_join(t[0], NULL);  pthread_join(t[1], NULL);}

编译

代码语言:javascript复制
/tmp$ gcc -fsanitize=thread -o foo foo.c -g -O0

执行

代码语言:javascript复制
/tmp$ ./foo==================WARNING: ThreadSanitizer: data race (pid=6761)  Read of size 4 at 0x00000060107c by thread T2:    #0 Thread2 /tmp/foo.c:12 (foo 0x000000400998)    #1 <null> <null> (libtsan.so.0 0x0000000230d9)  Previous write of size 4 at 0x00000060107c by thread T1:    #0 Thread1 /tmp/foo.c:7 (foo 0x00000040095b)    #1 <null> <null> (libtsan.so.0 0x0000000230d9)  Location is global 'Global' of size 4 at 0x00000060107c (foo 0x00000060107c)  Thread T2 (tid=6764, running) created by main thread at:    #0 pthread_create <null> (libtsan.so.0 0x000000027577)    #1 main /tmp/foo.c:19 (foo 0x000000400a23)  Thread T1 (tid=6763, finished) created by main thread at:    #0 pthread_create <null> (libtsan.so.0 0x000000027577)    #1 main /tmp/foo.c:18 (foo 0x000000400a04)SUMMARY: ThreadSanitizer: data race /tmp/foo.c:12 Thread2==================ThreadSanitizer: reported 1 warnings
Memory Sanitizer

MemorySanitizer (MSan)用来检查对未初始化内存的访问,clang 3.3版本开始支持。

编译配置:-fsanitize=memory.


示例代码:

代码语言:javascript复制
#include <stdio.h>#include <stdlib.h>#include <string.h>int main(int argc, char *argv[]){    int n;    if (n > 0)        printf("%dn", n);    return 0;}

编译

代码语言:javascript复制
/tmp$ clang -fsanitize=memory -fPIE -pie -fno-omit-frame-pointer -g bar.c -o bar

执行

代码语言:javascript复制
/tmp$ ./bar==201433==WARNING: MemorySanitizer: use-of-uninitialized-value    #0 0x494fc7 in main /tmp/bar.c:7:7    #1 0x7f9e2ba4b0b2 in __libc_start_main /build/glibc-YbNSs7/glibc-2.31/csu/../csu/libc-start.c:308:16    #2 0x41c26d in _start (/tmp/bar 0x41c26d)SUMMARY: MemorySanitizer: use-of-uninitialized-value /tmp/bar.c:7:7 in mainExiting

借鉴开源项目经验——以FFmpeg为例

多媒体音视频领域涉及对不可信输入做解析、处理IO错误、进行汇编优化等等,会出现各种内存操作错误。FFmpeg为了减小这类错误的发生,做了以下工作:

  1. Patch review: 大体来说,项目代码质量与参与review人数成正比
  2. Fate测试 FFmpeg Automated Testing Environment
  3. 把内存问题检测工具引入编译构建
代码语言:javascript复制
$ ./configure --help |less...  --toolchain=NAME         set tool defaults according to NAME                           (gcc-asan, clang-asan, gcc-msan, clang-msan,                           gcc-tsan, clang-tsan, gcc-usan, clang-usan,                           valgrind-massif, valgrind-memcheck,                           msvc, icl, gcov, llvm-cov, hardened)...

可以看到,通过简单配置configure option,可以开启gcc/clang的几个sanitizer工具,valgrind memcheck工具等。结合out-of-tree编译和fate测试FFmpeg Automated Testing Environment,每次代码改动可以执行多种编译配置,用多种工具对单元测试、系统测试进行检查。

  1. oss-fuzz https://github.com/google/oss-fuzz/tree/master/projects/ffmpeg

附录

std::make_shared and std::weak_ptr

看下面这段代码:

代码语言:javascript复制
 1 #include <iostream>  2 #include <memory>  3  4 class Foo {  5 public:  6   ~Foo() {  7       std::cout << __PRETTY_FUNCTION__ << 'n';  8   }  9 10   char buf[256]; 11 }; 12 13 void test() { 14   auto a = std::make_shared<Foo>(); 15   std::weak_ptr<Foo> b(a); 16   a = nullptr; 17   b.lock(); 18 } 19 20 int main() { 21   test(); 22   return 0; 23 }

先看内存分配的size:

代码语言:javascript复制
(lldb) bt *   thread #1, name = 'foo', stop reason = breakpoint 1.2 *   frame #0: 0x00007ffff7af7260 libc.so.6`__GI___libc_malloc(bytes=272) at malloc.c:3023:1     frame #1: 0x00007ffff7e60c29 libstdc  .so.6`operator new(unsigned long)   25     frame #2: 0x0000000000401d44 foo`__gnu_cxx::new_allocator<std::_Sp_counted_ptr_inplace<Foo, std::allocator<Foo>, (__gnu_cxx::_Lock_policy)2> >::allocate(this=0x00007fffffffdd50, __n=1, (null)=0x0000000000000000) at new_allocator.h:114:27     frame #3: 0x0000000000401cb4 foo`std::allocator_traits<std::allocator<std::_Sp_counted_ptr_inplace<Foo, std::allocator<Foo>, (__gnu_cxx::_Lock_policy)2> > >::allocate(__a=0x00007fffffffdd50, __n=1)2> >&, unsigned long) at alloc_traits.h:444:20     frame #4: 0x0000000000401aba foo`std::__allocated_ptr<std::allocator<std::_Sp_counted_ptr_inplace<Foo, std::allocator<Foo>, (__gnu_cxx::_Lock_policy)2> > > std::__allocate_guarded<std::allocator<std::_Sp_counted_ptr_inplace<Foo, std::allocator<Foo>, (__a=0x00007fffffffdd50)2> > >(std::allocator<std::_Sp_counted_ptr_inplace<Foo, std::allocator<Foo>, (__gnu_cxx::_Lock_policy)2> >&) at allocated_ptr.h:97:21     frame #5: 0x0000000000401950 foo`std::__shared_count<(__gnu_cxx::_Lock_policy)2>::__shared_count<Foo, std::allocator<Foo> >(this=0x00007fffffffdea8, __p=0x00007fffffffdea0, __a=_Sp_alloc_shared_tag<std::allocator<Foo> > @ 0x00007fffffffdd68) at shared_ptr_base.h:677:19     frame #6: 0x00000000004018f0 foo`std::__shared_ptr<Foo, (__gnu_cxx::_Lock_policy)2>::__shared_ptr<std::allocator<Foo> >(this=0x00007fffffffdea0, __tag=_Sp_alloc_shared_tag<std::allocator<Foo> > @ 0x00007fffffffdd98) at shared_ptr_base.h:1344:14     frame #7: 0x00000000004018a8 foo`std::shared_ptr<Foo>::shared_ptr<std::allocator<Foo> >(this=0x00007fffffffdea0, __tag=_Sp_alloc_shared_tag<std::allocator<Foo> > @ 0x00007fffffffddc8) at shared_ptr.h:359:4     frame #8: 0x000000000040182b foo`std::shared_ptr<Foo> std::allocate_shared<Foo, std::allocator<Foo> >(__a=0x00007fffffffde40) at shared_ptr.h:701:14     frame #9: 0x0000000000401494 foo`std::shared_ptr<Foo> std::make_shared<Foo>() at shared_ptr.h:717:14     frame #10: 0x0000000000401271 foo`test() at foo.cpp:14:12

可以看到,std::make_shared<Foo>()申请分配内存size为272,比sizeof(Foo)多了16字节。

再看何时执行析构:

代码语言:javascript复制
(lldb) c  Process 206788 resumingProcess 206788 stopped  * thread #1, name = 'foo', stop reason = breakpoint 3.1     frame #0: 0x000000000040218c foo`Foo::~Foo(this=0x0000000000418ec0) at foo.cpp:7:17    4    class Foo {    5    public:    6      ~Foo() {  -> 7          std::cout << __PRETTY_FUNCTION__ << 'n';    8      }    9    10     char buf[256];  (lldb) bt  * thread #1, name = 'foo', stop reason = breakpoint 3.1   * frame #0: 0x000000000040218c foo`Foo::~Foo(this=0x0000000000418ec0) at foo.cpp:7:17     frame #1: 0x0000000000402179 foo`void __gnu_cxx::new_allocator<Foo>::destroy<Foo>(this=0x0000000000418ec0, __p=0x0000000000418ec0) at new_allocator.h:153:10     frame #2: 0x0000000000402110 foo`void std::allocator_traits<std::allocator<Foo> >::destroy<Foo>(__a=0x0000000000418ec0, __p=0x0000000000418ec0) at alloc_traits.h:497:8     frame #3: 0x0000000000401edf foo`std::_Sp_counted_ptr_inplace<Foo, std::allocator<Foo>, (__gnu_cxx::_Lock_policy)2>::_M_dispose(this=0x0000000000418eb0) at shared_ptr_base.h:557:2     frame #4: 0x00000000004016dc foo`std::_Sp_counted_base<(__gnu_cxx::_Lock_policy)2>::_M_release(this=0x0000000000418eb0) at shared_ptr_base.h:155:6     frame #5: 0x000000000040168a foo`std::__shared_count<(__gnu_cxx::_Lock_policy)2>::~__shared_count(this=0x00007fffffffddf8) at shared_ptr_base.h:730:11     frame #6: 0x000000000040252e foo`std::__shared_ptr<Foo, (__gnu_cxx::_Lock_policy)2>::~__shared_ptr(this=0x00007fffffffddf0) at shared_ptr_base.h:1169:31     frame #7: 0x0000000000402423 foo`std::__shared_ptr<Foo, (__gnu_cxx::_Lock_policy)2>::operator=(this=0x00007fffffffdea0, __r=0x00007fffffffde80)2>&&) at shared_ptr_base.h:1265:2     frame #8: 0x0000000000401554 foo`std::shared_ptr<Foo>::operator=(this=0x00007fffffffdea0, __r=nullptr) at shared_ptr.h:335:27     frame #9: 0x0000000000401298 foo`test() at foo.cpp:16:5

a = nullptr 时执行了析构。

再看何时free:

代码语言:javascript复制
* thread #1, name = 'foo', stop reason = breakpoint 2.2    frame #0: 0x00007ffff7af7850 libc.so.6`__GI___libc_free(mem=0x0000000000418eb0) at malloc.c:3087:1(lldb) bt  * thread #1, name = 'foo', stop reason = breakpoint 2.2    * frame #0: 0x00007ffff7af7850 libc.so.6`__GI___libc_free(mem=0x0000000000418eb0) at malloc.c:3087:1      frame #1: 0x00000000004022f0 foo`__gnu_cxx::new_allocator<std::_Sp_counted_ptr_inplace<Foo, std::allocator<Foo>, (__gnu_cxx::_Lock_policy)2> >::deallocate(this=0x00007fffffffddb0, __p=0x0000000000418eb0, (null)=1)2>*, unsigned long) at new_allocator.h:128:2      frame #2: 0x00000000004022c8 foo`std::allocator_traits<std::allocator<std::_Sp_counted_ptr_inplace<Foo, std::allocator<Foo>, (__gnu_cxx::_Lock_policy)2> > >::deallocate(__a=0x00007fffffffddb0, __p=0x0000000000418eb0, __n=1)2> >&, std::_Sp_counted_ptr_inplace<Foo, std::allocator<Foo>, (__gnu_cxx::_Lock_policy)2>*, unsigned long) at alloc_traits.h:470:13      frame #3: 0x0000000000401c44 foo`std::__allocated_ptr<std::allocator<std::_Sp_counted_ptr_inplace<Foo, std::allocator<Foo>, (__gnu_cxx::_Lock_policy)2> > >::~__allocated_ptr(this=0x00007fffffffdda0) at allocated_ptr.h:73:4      frame #4: 0x0000000000401f45 foo`std::_Sp_counted_ptr_inplace<Foo, std::allocator<Foo>, (__gnu_cxx::_Lock_policy)2>::_M_destroy(this=0x0000000000418eb0) at shared_ptr_base.h:567:7      frame #5: 0x00000000004017e9 foo`std::_Sp_counted_base<(__gnu_cxx::_Lock_policy)2>::_M_weak_release(this=0x0000000000418eb0) at shared_ptr_base.h:194:6      frame #6: 0x000000000040179a foo`std::__weak_count<(__gnu_cxx::_Lock_policy)2>::~__weak_count(this=0x00007fffffffde98) at shared_ptr_base.h:823:11      frame #7: 0x000000000040175e foo`std::__weak_ptr<Foo, (__gnu_cxx::_Lock_policy)2>::~__weak_ptr(this=0x00007fffffffde90) at shared_ptr_base.h:1596:29      frame #8: 0x00000000004015e8 foo`std::weak_ptr<Foo>::~weak_ptr(this=0x00007fffffffde90) at shared_ptr_base.h:351:11      frame #9: 0x00000000004012c4 foo`test() at foo.cpp:18:1

可以看到,直到weak_ptr out of scope时才执行free(). 所以make_shared和weak_ptr 一块使用的场景需要谨慎处理


参考文献

1. Scott Meyers, "Effective Modern C ", 2014.

2. Bjarne Stroustrup, "The C Programming Language, 4th Edition", 2013.

3. "Arm Architecture Reference Manual Armv8, for Armv8-A architecture profile", https://developer.arm.com/documentation/ddi0487/latest/.

4. Prasad Krishnan, "Hardware Breakpoint (or watchpoint) usage in Linux Kernel".

5. Konstantin Serebryany, Derek Bruening, etc., "AddressSanitizer: A Fast Address Sanity Checker".

0 人点赞