☰
QEMU ARM64 环境下的内存泄漏排查:从 GODService 崩溃到 libdlt strdup 陷阱
2026/10/28 18:26:53 网站建设 项目流程

1. 从一份崩溃日志说起:GODService 内存泄漏到底是怎么回事

GODService 这个进程名,第一次看到的人多半会以为是某个游戏服务或者系统守护进程。实际上它是一套跑在 QEMU 模拟的 ARM64 环境里的后台服务,负责处理设备侧的数据转发和日志采集。项目部署在 Alpine Linux 上,宿主环境是 Termux,整个链路是 Windows 上用 QEMU 跑 openEuler ARM 虚拟机做验证,最终落到 Termux 里的 QEMU 实例上跑 Alpine。这套组合听起来绕,但做嵌入式模拟开发的人应该很熟悉——用 QEMU 模拟 ARM64 来跑真实业务逻辑,成本低、迭代快,唯一的问题就是资源受限,内存泄漏在这种环境里会被无限放大。

事情的起因很简单:GODService 跑上两三天之后,RSS 内存从初始的 12MB 一路涨到 200MB 以上,最后被 OOM Killer 干掉。重启之后又能撑两天,周而复始。用top看的时候,内存曲线是一条非常标准的缓慢上升斜线,没有突刺,没有回落,典型的持续泄漏特征。这种曲线最让人头疼,因为它不像崩溃那样有明确的堆栈,你得一点点去扒。

排查的第一步是确认泄漏点。我用了最土但最有效的办法:在服务里挂一个定时任务,每隔十分钟把/proc/self/status里的 VmRSS 和/proc/self/smaps的汇总信息打到日志里。同时用valgrind --leak-check=full在 QEMU 里跑了一遍,但 QEMU 模拟环境下 valgrind 的性能损耗太大,服务跑不到泄漏复现就超时了。后来改用mtrace和LD_PRELOAD挂钩 malloc/free,才定位到问题集中在libdlt这个库上。

libdlt是 Diagnostic Log and Trace 的缩写,是 AUTOSAR 体系里常用的日志追踪库,很多车载和嵌入式项目都在用。GODService 用它来做日志上下文管理,每次收到一条消息就创建一个 DLT context,用完销毁。问题就出在这个"用完销毁"上——代码里有一处strdup调用,把消息内容复制了一份存到 context 里,但在销毁 context 的时候只 free 了 context 结构体本身,没有 free 那个 strdup 出来的字符串。每次消息处理泄漏几十个字节,量不大,但 GODService 每秒要处理上百条消息,两天下来就是几百 MB。

这个 bug 的隐蔽性在于:strdup的返回值被存到了一个二级指针里,销毁函数遍历的时候因为一个边界条件判断写反了,跳过了最后一个节点。也就是说不是每次都泄漏,而是每 N 次泄漏一次,这就让内存曲线看起来是平滑上升而不是阶梯状,进一步增加了定位难度。

2. 定位手段拆解:在 QEMU 模拟环境里怎么抓内存泄漏

2.1 为什么常规工具在 QEMU ARM64 下不好使

在 x86 原生环境里抓内存泄漏,valgrind、ASan、heaptrack 随便挑,但在 QEMU 模拟的 ARM64 环境里,这些工具要么跑不起来,要么性能损耗大到无法接受。我实测过,valgrind 在 QEMU TCG 模式下跑 GODService,启动就要三分钟,处理一条消息的延迟从 2ms 涨到 400ms,根本没法复现真实负载。ASan 稍微好一点,但交叉编译工具链里 libasan 的版本和 Alpine 的 musl libc 有兼容问题,链接阶段就报错。

所以在这种环境下,我一般用三层递进的排查策略:

第一层是轻量级采样,不依赖任何外部工具,直接在代码里埋点。具体做法是写一个mem_probe.c,用__attribute__((constructor))在进程启动时注册一个SIGUSR1信号处理函数,收到信号就把当前的内存统计写到/tmp/mem_probe.log。统计内容包括mallinfo()返回的 arena 信息、/proc/self/status的 VmRSS、以及通过malloc_hook记录的总分配次数和总释放次数。这个方案的好处是零依赖,musl 和 glibc 都能跑,性能损耗可以忽略。

第二层是分配栈追踪,用LD_PRELOAD拦截malloc、free、strdup、calloc、realloc这几个函数。拦截之后不记录每次调用的完整栈(那样开销太大),而是用backtrace()取前 5 层,算一个哈希值,然后按哈希值聚合统计。这样既能知道哪个调用路径分配得最多,又不会把日志撑爆。这个方案在 QEMU 里的性能损耗大概在 15% 左右,可以接受。

第三层才是精确工具,等前两层把范围缩小到某个模块之后,再针对性地用 valgrind 跑单元测试,而不是跑整个服务。比如我定位到libdlt之后,就单独写了一个测试程序,模拟消息创建和销毁的循环,用 valgrind 跑这个测试程序,几分钟就能出结果。

2.2 关键参数与埋点代码

mem_probe.c的核心逻辑不复杂,但有几个细节要注意。首先是信号处理函数里不能调用非异步信号安全的函数,malloc、free、printf都不安全,所以实际写日志要用write()系统调用直接写文件描述符。其次是mallinfo()在 musl 里返回的结构体和 glibc 不一样,需要条件编译。

#define _GNU_SOURCE #include <stdio.h> #include <stdlib.h> #include <string.h> #include <signal.h> #include <unistd.h> #include <fcntl.h> #include <execinfo.h> static int log_fd = -1; static unsigned long alloc_count = 0; static unsigned long free_count = 0; static void probe_handler(int sig) { char buf[512]; int len = 0; FILE *f = fopen("/proc/self/status", "r"); if (f) { char line[256]; while (fgets(line, sizeof(line), f)) { if (strncmp(line, "VmRSS:", 6) == 0) { len += snprintf(buf + len, sizeof(buf) - len, "%s", line); break; } } fclose(f); } len += snprintf(buf + len, sizeof(buf) - len, "alloc=%lu free=%lu diff=%lu\n", alloc_count, free_count, alloc_count - free_count); if (log_fd >= 0) { write(log_fd, buf, len); } } __attribute__((constructor)) static void probe_init(void) { log_fd = open("/tmp/mem_probe.log", O_WRONLY | O_CREAT | O_APPEND, 0644); signal(SIGUSR1, probe_handler); }

这段代码编译成.so之后,用LD_PRELOAD=./mem_probe.so ./GODService启动,然后kill -USR1 <pid>就能采样。我一般写个脚本每十分钟发一次信号,跑一天下来看alloc和free的差值曲线,如果差值持续增长,就说明有泄漏。

2.3 从采样数据到泄漏点

采样数据出来之后,alloc - free的差值从 0 涨到 120 万,对应大约 80MB 内存(平均每次分配 70 字节左右)。这个数字和 RSS 的增长基本吻合,说明泄漏确实来自堆分配,不是 mmap 或者线程栈。

接下来用LD_PRELOAD的分配追踪版本,把backtrace的哈希值和分配次数打出来。跑一个小时之后,按分配次数排序,前五名里有一个调用路径特别可疑:

hash=0x3a7f2c1e count=48213 ./libdlt.so.2(dlt_context_new+0x4c) ./libdlt.so.2(dlt_log_write+0x1a8) ./GODService(msg_dispatch+0x2f4) ./GODService(main_loop+0x8c)

dlt_context_new被调用了 48213 次,但对应的dlt_context_free只有 47000 多次,差了 1200 次左右。这个差值不算大,但考虑到每次泄漏的字节数,累积起来就很可观了。

3. libdlt 里的 strdup 陷阱:一个边界条件引发的血案

3.1 问题代码长什么样

libdlt里dlt_context_new的简化逻辑是这样的:

DltContext *dlt_context_new(DltContextList *list, const char *id) { DltContext *ctx = calloc(1, sizeof(DltContext)); if (!ctx) return NULL; ctx->id = strdup(id); ctx->log_buf = NULL; ctx->next = list->head; list->head = ctx; list->count++; return ctx; } void dlt_context_free(DltContextList *list, DltContext *ctx) { DltContext *prev = NULL; DltContext *cur = list->head; while (cur) { if (cur == ctx) { if (prev) prev->next = cur->next; else list->head = cur->next; free(cur->id); free(cur); list->count--; return; } prev = cur; cur = cur->next; } }

看起来没问题对吧?free(cur->id)确实释放了 strdup 出来的字符串。但问题在于dlt_log_write里还有一段逻辑:

int dlt_log_write(DltContext *ctx, const char *msg) { if (!ctx->log_buf) { ctx->log_buf = strdup(msg); } else { char *new_buf = realloc(ctx->log_buf, strlen(ctx->log_buf) + strlen(msg) + 1); if (new_buf) { ctx->log_buf = new_buf; strcat(ctx->log_buf, msg); } } return 0; }

ctx->log_buf也是 strdup 出来的,但dlt_context_free里只 free 了ctx->id,没有 freectx->log_buf。这就是泄漏的真正来源。每次dlt_log_write第一次调用时分配一个 log_buf,销毁 context 的时候这个 buffer 就丢了。

那为什么不是每次都泄漏?因为dlt_log_write只在ctx->log_buf为 NULL 的时候才 strdup,如果同一个 context 被多次写入,只有第一次会分配。而 GODService 里 context 的创建和销毁频率和消息处理频率不完全一致,有些 context 被复用了,有些没有,所以泄漏是间歇性的。

3.2 为什么这个 bug 难发现

这个 bug 的隐蔽性有三层:

第一层是代码审查容易漏。dlt_context_free里明明有free(cur->id),审查的人看到这一行就会觉得"哦,释放了",不会再去检查结构体里还有没有其他指针成员。log_buf这个字段在结构体定义里排在id后面,很容易被忽略。

第二层是测试覆盖不到。单元测试里如果只测 context 的创建和销毁,不调用dlt_log_write,就永远不会触发这个泄漏。而集成测试里因为泄漏量小,短期跑不出来,长期跑又没人盯着内存曲线。

第三层是QEMU 环境掩盖了问题。在真实 ARM64 硬件上,内存充足,泄漏几百 MB 可能几天才触发 OOM。但在 QEMU 模拟环境里,虚拟机分配的内存本来就少,泄漏被加速暴露了。这其实是好事,但排查的时候容易误以为是 QEMU 本身的问题,方向跑偏。

3.3 修复方案与验证

修复本身很简单,在dlt_context_free里加一行:

free(cur->id); free(cur->log_buf); // 新增 free(cur);

但修复之后要验证,不能改完就完事。我的验证步骤是:

  1. 用mem_probe重新跑 24 小时,确认alloc - free的差值稳定在 0 附近,不再持续增长。
  2. 用LD_PRELOAD的分配追踪版本跑 1 小时,确认dlt_context_new和dlt_context_free的调用次数差值在合理范围内(因为并发场景下可能有短暂的未配对,但不应持续累积)。
  3. 在 QEMU 里跑 72 小时压力测试,RSS 稳定在 15MB 左右,不再上涨。
  4. 用valgrind跑单元测试,确认definitely lost为 0。

这里有个细节要注意:realloc之后的log_buf指针可能变了,但dlt_context_free里 free 的是当前的cur->log_buf,所以没问题。但如果代码里有其他地方保存了log_buf的旧指针,就会出问题。我检查了一遍,GODService 里没有这种用法,所以安全。

4. 在 Termux + QEMU + Alpine 环境里复现和验证的完整流程

4.1 环境搭建的坑

这套环境搭起来本身就有不少坑。Termux 里装 QEMU 要用pkg install qemu-system-aarch64-headless,注意是 headless 版本,带 GUI 的版本在 Termux 里跑不起来。装完之后要准备 Alpine 的 ARM64 镜像,从 Alpine 官网下载alpine-virt-3.x.x-aarch64.iso,然后用qemu-img create创建一个 qcow2 磁盘。

启动命令大概是这样:

qemu-system-aarch64 \ -M virt \ -cpu cortex-a72 \ -smp 2 \ -m 512M \ -kernel vmlinuz-virt \ -initrd initramfs-virt \ -append "console=ttyAMA0" \ -drive file=alpine.qcow2,if=virtio \ -nographic \ -netdev user,id=net0,hostfwd=tcp::2222-:22 \ -device virtio-net-device,netdev=net0

几个关键参数的解释:-M virt是 QEMU 的通用虚拟平台,ARM64 上最常用;-cpu cortex-a72模拟的是比较常见的 ARM 核心,兼容性好;-m 512M给 512MB 内存,故意给小一点,让泄漏更容易暴露;-nographic不开图形界面,直接串口输出,Termux 里必须这样。

网络配置用 user mode,把宿主机的 2222 端口转发到虚拟机的 22 端口,这样可以用ssh -p 2222 root@localhost登录。注意 Alpine 默认没有 sshd,要先apk add openssh并启动。

4.2 编译和部署 GODService

GODService 和 libdlt 都是 C 项目,用 CMake 构建。交叉编译工具链用aarch64-linux-musl-gcc,在 Termux 里可以通过pkg install musl-cross安装。CMake 的 toolchain 文件大概这样写:

set(CMAKE_SYSTEM_NAME Linux) set(CMAKE_SYSTEM_PROCESSOR aarch64) set(CMAKE_C_COMPILER aarch64-linux-musl-gcc) set(CMAKE_CXX_COMPILER aarch64-linux-musl-g++) set(CMAKE_FIND_ROOT_PATH /data/data/com.termux/files/usr/aarch64-linux-musl) set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)

编译的时候要注意,libdlt 默认会链接一些系统库,在 musl 环境下可能找不到,需要手动指定-static或者把依赖库也交叉编译一遍。我图省事,直接静态链接了,反正 GODService 本身不大。

部署就是把编译好的二进制 scp 到虚拟机里,然后写一个 systemd 或者 OpenRC 的服务脚本。Alpine 用的是 OpenRC,服务脚本放在/etc/init.d/GODService,内容很简单:

#!/sbin/openrc-run name="GODService" command="/usr/local/bin/GODService" command_args="-c /etc/GODService.conf" command_background=true pidfile="/run/GODService.pid" output_log="/var/log/GODService.log" error_log="/var/log/GODService.err"

4.3 压力测试与监控

压力测试我用了一个简单的 Python 脚本,跑在宿主机上,通过 TCP 往 GODService 发消息。消息格式是自定义的二进制协议,每条消息大概 200 字节,发送速率控制在每秒 100 条左右,模拟真实负载。

监控方面,除了前面说的mem_probe,我还在宿主机上写了一个脚本,每五分钟通过 SSH 到虚拟机里执行ps -o rss= -p $(pidof GODService),把 RSS 值记到 CSV 文件里。跑 72 小时之后用 gnuplot 画出来,修复前是一条上升斜线,修复后是一条水平线,效果非常直观。

这里有个小技巧:QEMU 的虚拟磁盘 IO 性能很差,日志写多了会拖慢整个系统。所以mem_probe的日志我写到/tmp里,/tmp是 tmpfs,内存文件系统,不落盘。但要注意 tmpfs 也占内存,日志写太多会干扰 RSS 测量。我的做法是每次采样只写一行,一天下来也就几百行,影响可以忽略。

5. 内存泄漏排查的通用套路与避坑清单

5.1 排查流程的标准化

经过这次 GODService 的排查,我总结了一套在资源受限环境里抓内存泄漏的标准流程,适用于 QEMU 模拟、嵌入式设备、容器等场景:

阶段手段工具耗时适用场景
确认泄漏RSS 曲线监控ps/top + 脚本小时级所有场景
粗定位分配计数埋点mallinfo + 信号天级无外部工具依赖
精定位分配栈追踪LD_PRELOAD + backtrace小时级能接受 15% 性能损耗
确认修复单元测试 + valgrindvalgrind分钟级可独立运行的模块
回归验证长时间压力测试自定义脚本天级修复后必做

这个流程的核心思路是从粗到细,从低开销到高开销。先用零开销的手段确认问题存在,再用低开销的手段缩小范围,最后用高开销的手段精确定位。不要一上来就上 valgrind,在 QEMU 里那是自找麻烦。

5.2 避坑清单

坑一:把 QEMU 的性能问题当成内存泄漏。QEMU TCG 模式下,翻译缓存(TB cache)会占用大量内存,看起来像泄漏,实际上是正常的。判断方法是看 QEMU 进程本身的 RSS,如果 QEMU 进程的 RSS 也在涨,那可能是 TB cache 的问题,跟 guest 里的服务无关。可以在 QEMU 启动参数里加-accel tcg,tb-size=64限制 TB cache 大小。

坑二:musl 和 glibc 的 malloc 行为差异。musl 的 malloc 实现比 glibc 简单,没有 tcache 和 arena 的复杂逻辑,但这也意味着mallinfo()返回的数据不如 glibc 详细。在 musl 环境下,mallinfo().uordblks可能不准确,最好直接用malloc_hook自己计数。

坑三:strdup 的返回值检查。strdup在内存不足时返回 NULL,但很多代码不检查,直接往结构体里塞。这本身不是泄漏,但会导致后续的strlen或strcat崩溃。修复泄漏的时候顺手把 NULL 检查加上,能避免很多奇怪的问题。

坑四:realloc 的失败处理。realloc失败时返回 NULL,但原来的内存块不会被释放。如果代码写成ptr = realloc(ptr, size),失败后ptr变成 NULL,原来的内存就泄漏了。正确写法是用临时变量:

char *tmp = realloc(ptr, size); if (!tmp) { // 处理错误,ptr 仍然有效 return -1; } ptr = tmp;

坑五:多线程环境下的计数不准。如果 GODService 是多线程的,alloc_count和free_count要用原子操作或者加锁,否则计数会错乱。我这次排查的版本是单线程的,所以没遇到这个问题,但如果是多线程,__sync_fetch_and_add是必须的。

5.3 一个容易被忽略的细节:strdup 和 strndup 的区别

strdup会复制整个字符串直到\0,strndup最多复制 n 个字节。如果消息内容里没有\0结尾(比如二进制协议),strdup会一直读到内存里的下一个\0,可能复制出超长的字符串,甚至越界。GODService 的消息是二进制格式,理论上不应该用strdup,应该用memcpy加长度。但代码里用了strdup,好在消息内容恰好是文本,没有触发这个问题。修复的时候我顺手把strdup换成了strndup,指定最大长度,更安全。

这个细节说明一个问题:内存泄漏的修复不能只盯着泄漏本身,还要看代码逻辑是否合理。有时候泄漏只是表象,背后是更严重的设计问题。

6. 修复后的效果与长期观察

修复上线之后,GODService 在 QEMU 环境里连续跑了 7 天,RSS 稳定在 14-16MB 之间波动,没有上涨趋势。alloc - free的差值在 0 附近小幅震荡,最大偏差不超过 200 次分配,属于正常的并发波动。OOM Killer 再也没有触发过。

但这次排查也暴露了 GODService 在内存管理上的一些其他问题,比如日志缓冲区没有上限,如果消息量突增,log_buf会一直 realloc 增长。虽然不会泄漏,但可能导致单次分配过大。我在后续版本里加了一个上限,超过 1MB 就截断,避免极端情况下的内存尖峰。

另外,libdlt这个库本身的质量参差不齐,不同版本的实现差异很大。我用的这个版本是从某个开源项目里扒出来的,代码风格比较老,错误处理也不完善。如果项目允许,建议换用维护更活跃的日志库,或者自己封装一层,把资源管理集中起来,减少这类低级 bug 的出现概率。

最后分享一个我在排查过程中养成的习惯:每次修复内存泄漏之后,都会在代码里留一个注释,写明泄漏的原因、修复方式和验证方法。比如这次我在dlt_context_free上面加了这样一段:

/* * 注意:ctx->log_buf 由 dlt_log_write 通过 strdup/realloc 分配, * 必须在销毁 context 时释放,否则每次创建/销毁 context 都会泄漏。 * 2024-XX-XX 修复,验证方式:mem_probe 跑 24h,alloc-free 差值稳定。 */

这样做的好处是,下次有人改这段代码的时候,能看到历史教训,不至于把 free 又删掉。内存泄漏的 bug 往往不是第一次写错,而是修好之后又被改回去。留个注释,能省很多事。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询