Low-level Debug 笔记
本文最后更新于 2026年8月12日 凌晨
一次 debug 经验记录
序
我目前在实习一个嵌入式系统的岗位,其中就需要学习如何使用很多不同的 low-level debugger,很多学校里教的知识都是点到为止,但实践中就需要对不同个工具做一个基础的挖掘
个人认为这个学习的机会是十分难得的,毕竟是在校内少有的对于知识的应用,于是有了这篇笔记作为记录以及未来的参考
address sanitizer (Asan) 就是其中一个最常用的。可惜在学校系统课里并没有对 Asan 做特别多的覆盖,只是让我们知道有类似的 debug 工具而已(例如 Valgrind),这篇笔记对 Asan 的一些特性做了些记录
perf 是一个校内几乎没有提及的工具,毕竟这更像是一个优化工具,学校项目通常并不会教优化方面的只是,也能理解毕竟很多课的重点就不在优化上,所以这是一个非常好的机会去学习使用这类工具
由于是经验记录,此篇笔记将会从一个样例来讲解相关的知识,而非直接列出各种工具的特性及使用场景
一个程序
这个样例程序非常简单,只有一个主函数(main)以及线程函数,但埋下了不少问题
主函数先叫了一个 vfork 创建一个子进程,子进程只做了些简单的工作就退出了
父进程会等子进程结束后再继续
接下来就同时创建俩个线程,他们被安排执行同一个线程函数,这个线程函数有俩个部分:
-
循环 20000 次 malloc 一个随机大小(16 - 4096 字节)的内存,再循环 10000 次相隔一个分配的内存空间 free
-
无限循环(while true)malloc 一个随机大小(16 - 4096 字节)的内存,并马上 free 掉
1 | |
这是一个经过简化的伪代码,我还没试过是否能跑,这里只是为了展示大致逻辑
这个程序最后也是在开发板上跑的,板子上是一块双核 arm 的 soc,运行内存大概是 250M,不过即使不在开发板上一些问题也能在其他平台复现,理解原理即可 (;
三个问题
问题一:Abort
若尝试直接运行这个程序,会报 Mutex may not initialized before use 并 abort,可以尝试一下单看源码是否能找到问题所在
Mutex 会被认为还没有被初始化,那也就是说程序在尝试调用这个 mutex 的时候发现那些字节并非正常,既然初始化后为何会被篡改了呢?谁又会去篡改这些字节呢?
在跑多线程那段代码之前,看起来也就只有个标准创建子进程的部分,而且似乎没影响到任何多线程的部分啊
其实,如果细看的话,这里创建子进程和平时创建子进程的方式好像有点不一样;这里没用平时常见的
fork()而是vfork()
什么是 vfork(),和学校里教的 fork() 又有什么不同?
打开 vfork() 的 man pages,能看到他的描述和 fork() 几乎大差不差,但主要有两点不同:
-
vfork()父进程会等子进程结束后再继续执行 -
vfork()子进程会在执行execve()之前和父进程共享地址空间
主要看第二点,man page 里也说了,如果 vfork() 的子进程不应该执行 exit() 否则会有未定义的行为
上过系统编程课的话也知道,exit() 除了退出当前进程之外还会执行一些清理动作,例如关闭一些 fd
在这个情况下,很明显一些 mutex 就因为这个 exit() 被清理掉了,之后程序尝试去调用就会发现是一堆垃圾字节,并怀疑 mutex 没有被正确的初始化
修复这个问题也很简单,按照 man page 上说的一样正确使用 _exit() 直接退出即可
问题二:OOM
在修复 abort 的问题后,程序能正常运行
那就试试开启 Asan 来检查有没有内存相关的问题吧
启用 Asan 的 flag 来重新编译一遍,再次尝试在开发板上跑这个程序;不用几秒后,控制台就显示 OOM 并终止程序
没开 Asan 时没问题,但开了后却报 OOM,Asan 明明是检查内存的工具,为啥反而还导致了内存问题呢?
Asan 为何物,这些功能是如何被实现的?
Address Sanitizer 顾名思义为地址清洁器,专门用来找地址相关的 bug,例如 Use-After-Free (UAF),buffer overflow(越界),memory leak,double free / invalid free 等
那它又是如何实现这些检测的呢?
Asan 主要有 3 种方式去检测不同的内存 bug,分别为 Redzone,Shadow Memory,和 Quarantine
Redzone
Asan 会于每个分配的内存区块(Stack,Heap,或 Global)周围划分为 Redzone,这样只要程序尝试存取 Redzone 范围的内存地址就能知道有越界情况了
Shadow Memory
Asan 会使用一部分内存作为 shadow bytes,用于表示这整块内存的状态
通常一个 shadow byte 会表示真实内存的 8 个 bytes 的状态,是 fully addressable,partially addressable,还是 fully poisoned
poisoned 顾名思义就是下了毒的内存区域,若尝试读取 Asan 就知道有不符合规范的内存读写了
在每个内存读写之前,Asan 就会通过 Shadow Memory 检查一下这个内存块有没有问题
Quarantine
在 free 掉一个 heap 内存块后,正常来说这段内存会被释放回去一个内存池里,供下次 malloc 使用
在 Asan 的实现里,为了检查是否有 UAF 的情况,free 完之后的内存并不会马上释放回池子里,而是将其 quarantine(隔离),过段时间后才真正释放
隔离期间如果程序尝试再次读写该段内存,很明显就出现了 UAF
好了,在知道 Asan 实现的大概原理后,OOM 的成因就应该十分明显:要实现 Shadow Memory 和 Quarantine,无非都是需要更多的空间来记录这些内存的 metadata
正在 quarantine 的内存实际上也会被程序占用,malloc 不会从 quarantine 里找空闲内存,这个明显是内存占用的大头
再在网上简单搜索一下即可得知,arm 上的 Asan 默认 quarantine 大小为 256M,开发板上只有不到 250M 的内存,最后俩个线程无限 malloc 和 free 只把内存放到 quarantine 里,并没真正释放出来;quarantine 还没满,系统就已经没有内存能安排给 malloc 了,只好报 OOM 并把程序杀掉
解决方法很简单,把 quarantine 大小改小即可
举一反三,除了 quarantine 之外,redzone 是否也有可能导致 OOM 呢?毕竟用了多点内存
答案是有可能,不过 redzone 的大小也会根据申请的内存大小来决定;在这个样例里,16 bytes 的内存可能会有 4x 的膨胀,反而 4096 bytes 的只有大概 1.07x
粗略估算,应该会膨胀个 20-30%,2 个线程各重复 20000 次 malloc,取均值每次分配 2056 bytes,总共也就 82 MB。如果把 redzone 的膨胀算上,那大概是 100-110 MB 左右
开发板上有 250 MB 的内存,假设 OS 占用 30 MB,那剩下大概 220 MB 的空间,还要算上 shadow bytes 的占用,实际为 220 MB / 1.125 = 196 MB
按照前面算的每次 malloc 平均大小,那总共需要 malloc 196 MB / 2310 Bytes = 85000 次 allocation,也就是每个线程要 malloc 42500 次以上
修改数字为 50000,烧到开发板上测确实会有机会报 OOM,验证成功
问题三:性能瓶颈
最后俩个线程均为 while true 死循环,但看 top cpu 占用一直卡在 50%,无法占满,要找出性能瓶颈
提示:这里样例程序编译时的依赖库为 uclibc,非 glibc
如果熟悉 uclibc 和 glibc 的 malloc 实现的话其实这题挺明显的,不过人不可能了解所有情况,应该懂得用工具排查问题,这里想强调的是如何善用工具排查原因,再进行学习
若要排查性能相关的问题,基本上都是 perf + 火焰图来解
这个样例程序用 perf 来记录,然后 perf.unfold 再转换成 flamegraph 后可以看到,基本上都是 malloc 占用了最多的 cpu time(废话,这里就只有 malloc 能占用 cpu)
这样子给出的信息有点局限了,那就继续看看 malloc 内部是什么原因造成性能问题
如果要 perf 能把 library function 内部调用也展示出来,就得把依赖库的 debug symbol 也带上,重编 uclibc
使用新的 uclibc 重跑样例程序,用 perf 检查 cpu time,这样我们就有更多的信息了
看火焰图,依然是 malloc 占大头,不过内部也有不少调用占了很多时间,信息不多
不过这次的 uclibc 有 debug symbol,能在 perf.unfold 文件里查看 backtrace
找到 symbol name + offset,配合 gdb 得知样例程序的 malloc 其实用的是 uclibc malloc-standard 的实现(网上能查到 uclibc 有三个不同的 malloc 实现)
那问题基本上就是这个 malloc 的实现了,让 ai 看看源代码,发现 malloc-standard 的实现利用了一个全局锁,也就是说样例程序两个线程的 malloc 同时只能有一个在跑
前面的 20000 个批量 malloc 再隔个 free 产生了大量的内存碎片,malloc 每次申请内存用时变长,另一个线程的 malloc 需要花大量的时间等待这个线程的 malloc 释放锁
cpu 刚好有两个核心,每个线程分配给了一个核,全局锁导致只有一个线程能跑(一个核),最后导致 cpu 总利用率只有 50%
有没有解决办法呢?最简单的就是把依赖库换成 glibc 而不是 uclibc。glibc 的
malloc实现和 uclibc 的malloc-standard不同,glibc 的malloc更好的支持多线程,每个线程有单独的 arena 来分配内存如果要坚持使用 uclibc 这个库,其实除了
malloc-standard其他俩个库的实现也不会完全解决多线程的问题,要达到 glibc 的malloc效果只能手动修改 uclibc 的实现,使用类 glibc 单独给每个线程分配个 arena