在嵌入式 Linux 开发中,如果你的代码申请了内存却未能按预期释放,就会造成内存泄漏。严重时,整个程序可能因内存耗尽(OOM)而崩溃。本文先介绍两种通过 Linux 系统 自带的 /proc 虚拟文件系统查看进程内存使用情况的方法,然后通过一个 cJSON 的实例,带你直观感受内存泄漏前后 USS 与 RSS 的显著变化。
1. 查看 Linux 进程内存使用的两种方法
这里先介绍通过 /proc/[pid]/status 和 /proc/[pid]/smaps 来获取内存信息的方式。
在 Linux 系统中,/proc/[pid]/status 文件中的 VmRSS(Virtual Memory Resident Set Size)表示进程当前实际占用的物理内存大小。
例如,查看进程 15746 的 status 文件,VmRSS 字段会被高亮显示,其值表示该进程当前驻留在物理内存中的大小:
$ cat /proc/15746/status
Name: test5
Umask: 0002
State: S (sleeping)
Tgid: 15746
Ngid: 0
Pid: 15746
PPid: 7815
TracerPid: 0
Uid: 1000 1000 1000 1000
Gid: 1000 1000 1000 1000
FDSize: 256
Groups: 4 24 27 30 46 116 126 1000
NStgid: 15746
NSpid: 15746
NSpgid: 15746
NSsid: 7815
VmPeak: 4552 kB
VmSize: 4540 kB
VmLck: 0 kB
VmPin: 0 kB
VmHWM: 732 kB
VmRSS: 732 kB # ← 进程实际占用的物理内存
RssAnon: 64 kB
RssFile: 668 kB
RssShmem: 0 kB
VmData: 176 kB
VmStk: 132 kB
VmExe: 32 kB
VmLib: 2116 kB
VmPTE: 52 kB
VmSwap: 0 kB
HugetlbPages: 0 kB
CoreDumping: 0
THP_enabled: 1
Threads: 1
...
这里 VmRSS 为 732 kB,表明该进程目前实际只用了不到 1 MB 物理内存。
1.2 从 /proc/[pid]/smaps 计算 USS(Private_Clean + Private_Dirty)
/proc/[pid]/smaps 文件提供了更为详细的内存映射信息,其中 Private_Clean 和 Private_Dirty 的求和结果称为 USS(Unique Set Size,唯一集大小),它代表了进程独占的、确实驻留在物理内存中的页面大小。
- Private(私有):该内存页仅被当前进程引用(引用计数为 1),其他进程无法访问,通常包括堆、栈以及私有数据段。
- Clean(干净):内存页内容与后备存储(如磁盘文件映射)一致,系统回收时可直接丢弃,无需写回。
- Dirty(脏):内存页内容已被修改,与原始来源不一致,回收前必须将数据写回 Swap 或对应文件。
USS 就是所有 Private_Clean 和 Private_Dirty 的累加值,更能精确反映一个进程“独自”消耗的内存。
以下是一个 test5 程序的 smaps 文件示例(共 18 个内存映射段),其中 r‑xp 段(代码段)只包含 Private_Clean,而 rw‑p 段(数据段)可能出现 Private_Dirty:
$ cat /proc/[pid]/smaps
55ee5c381000-55ee5c389000 r-xp 00000000 08:01 2559385 /home/…/test5
Size: 32 kB
KernelPageSize: 4 kB
MMUPageSize: 4 kB
Rss: 32 kB
Pss: 32 kB
Shared_Clean: 0 kB
Shared_Dirty: 0 kB
Private_Clean: 32 kB # ← 可执行代码,未被修改
Private_Dirty: 0 kB
Referenced: 32 kB
Anonymous: 0 kB
...
55ee5c588000-55ee5c589000 r--p 00007000 08:01 2559385 /home/…/test5
Size: 4 kB
...
Private_Clean: 0 kB
Private_Dirty: 4 kB # ← 只读段被修改?实际为 .rodata 段的脏页
...
55ee5c589000-55ee5c58a000 rw-p 00008000 08:01 2559385 /home/…/test5
...
55ee5d904000-55ee5d925000 rw-p 00000000 00:00 0 [heap] # ← 堆内存
...
7f8fb5397000-7f8fb557e000 r-xp 00000000 08:01 2694255 /lib/libc-2.27.so
...
7ffe646ef000-7ffe64710000 rw-p 00000000 00:00 0 [stack] # ← 栈内存
...
将上面所有 Private_Clean 和 Private_Dirty 字段的数值相加,就得到了 USS。
既然需要反复计算,不如写个脚本自动完成。下面这个 Bash 脚本先通过 ps | grep | awk 过滤出指定进程的 PID,然后分别从 status 读取 VmRSS、从 smaps 累加 Private_Clean + Private_Dirty 得到 USS:
#!/bin/bash
PID=`ps -aux | grep test5 | grep -v grep | grep -v test5.c | awk '{print $2}'`
FILE=/proc/$PID/smaps
if [ ! -f "$FILE" ]; then
echo "进程 $PID 不存在"
exit 1
fi
# 计算 USS = Private_Clean + Private_Dirty
USS_KB=$(awk '/Private_Clean/ {pc += $2}
/Private_Dirty/ {pd += $2}
END {print pc + pd}' "$FILE")
RSS_KB=$(grep VmRSS /proc/$PID/status | awk '{print $2}')
echo "PID: $PID"
echo "USS: $USS_KB KB ($((USS_KB/1024)) MB)"
echo "RSS: $RSS_KB KB ($((RSS_KB/1024)) MB)"
USS_KB 计算原理:
awk 逐行扫描 $FILE,遇到含 Private_Clean 的行则将第二列(数值)累加到变量 pc,遇到 Private_Dirty 则累加到 pd。
- 所有行处理完毕后,
END 块执行 print pc + pd,结果通过 $(...) 赋值给 Shell 变量 USS_KB。
若想持续监控,可将脚本放入循环,例如 while true; do ./get_uss.sh; sleep 2; done。
2. 内存泄漏实例——cJSON 的坑
在 C/C++ 中使用 cJSON 解析 JSON 时,cJSON_Parse 会分配内存生成 JSON 树,使用完毕后必须调用 cJSON_Delete 释放。如果指针使用不当,就容易埋下内存泄漏的隐患。
2.1 错误写法:一级指针传参
下面这个函数试图把解析好的 JSON 树通过参数传出,但只接受了一个一级指针 cJSON *jRoot:
// 错误示例
void test_parse_json(char *buf, cJSON *jRoot)
{
jRoot = cJSON_Parse(buf);
if (jRoot)
{
cJSON *json = NULL;
if ((json = cJSON_GetObjectItem(jRoot, "version")))
printf("[%s:%d] version:%d\n", __func__, __LINE__, json->valueint);
char *data = cJSON_PrintUnformatted(jRoot);
printf("[%s:%d] ok:%s\n", __func__, __LINE__, data);
free(data);
}
}
//================== 使用示例
cJSON *jRoot = NULL;
test_parse_json(buf, jRoot);
if (jRoot)
{
cJSON_Delete(jRoot); // 释放 cJSON_Parse 申请的内存
printf("[%s:%d] [%d] cJSON_Delete\n", __func__, __LINE__, i);
}
else
{
printf("[%s:%d] [%d] err, null jRoot!\n", __func__, __LINE__, i);
}
为什么这样做会泄漏?
- C 语言参数按值传递,
test_parse_json 内部的 jRoot 实际上是外部 NULL 值的一个副本。
- 函数内对该副本赋值(
jRoot = cJSON_Parse(buf))并不会影响外部的 jRoot。
- 函数返回后,外部的
jRoot 依旧是 NULL,因此 if (jRoot) 永远不成立,cJSON_Delete 从未被执行,解析时分配的内存全部泄漏。
如果这里没有对 NULL 的错误打印,你甚至发现不了问题——程序照常输出 JSON 解析结果,但内存却在悄悄被吃掉。
2.2 正确写法:使用二级指针
要解决这个问题,必须让函数内部能够修改外部的指针变量,这就需要传递 二级指针(cJSON **jRoot):
// 正确示例
void test_parse_json_2(char *buf, cJSON **jRoot)
{
*jRoot = cJSON_Parse(buf);
if (*jRoot)
{
cJSON *json = NULL;
if ((json = cJSON_GetObjectItem(*jRoot, "version")))
printf("[%s:%d] version:%d\n", __func__, __LINE__, json->valueint);
char *data = cJSON_PrintUnformatted(*jRoot);
printf("[%s:%d] ok:%s\n", __func__, __LINE__, data);
free(data);
}
}
//================== 使用示例
cJSON *jRoot = NULL;
test_parse_json_2(buf, &jRoot);
if (jRoot)
{
cJSON_Delete(jRoot); // 释放 cJSON_Parse 申请的内存
printf("[%s:%d] [%d] cJSON_Delete\n", __func__, __LINE__, i);
}
else
{
printf("[%s:%d] [%d] err, null jRoot!\n", __func__, __LINE__, i);
}
为什么二级指针能解决问题?
- 外部
cJSON *jRoot 的地址被传入,函数内的 *jRoot 就等价于外部的那个指针变量。
- 对
*jRoot 的赋值会直接反映到外部,因此外部的 jRoot 就能真正指向解析出的 JSON 树。
- 函数返回后
jRoot 非空,cJSON_Delete 得以正确释放内存。
关于 二级指针 的更多细节,可参考之前的解说文章:用 cJson 的例子,来理解二级指针。
2.3 完整测试代码
在此基础上整合出一个完整的测试程序(改编自之前的文章:使用 cJSON 读写配置文件),通过宏 #if 0 切换错误与正确的调用方式:
// 编译: gcc test5.c cjson/cJSON.c -o test5
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "cjson/cJSON.h"
#define CONFIG_FILE1 "config1.json"
int read_json_file(const char *filePath, char **buf, int *buf_len)
{
FILE *fp = fopen(filePath, "r");
if (!fp) {
printf("fopen:%s failed\n", filePath);
return -1;
}
fseek(fp, 0, SEEK_END);
*buf_len = ftell(fp);
fseek(fp, 0, SEEK_SET);
*buf = malloc(*buf_len + 1);
if (NULL == *buf) {
fclose(fp);
return -1;
}
memset(*buf, 0, *buf_len + 1);
fread(*buf, 1, *buf_len, fp);
fclose(fp);
cJSON_Minify(*buf); // 删除注释并压缩
printf("[%s:%d] buf_len:%d, buf:%s]\n", __func__, __LINE__, *buf_len, *buf);
return 0;
}
// 错误示例
void test_parse_json(char *buf, cJSON *jRoot)
{
jRoot = cJSON_Parse(buf);
if (jRoot) {
cJSON *json = NULL;
if ((json = cJSON_GetObjectItem(jRoot, "version")))
printf("[%s:%d] version:%d\n", __func__, __LINE__, json->valueint);
char *data = cJSON_PrintUnformatted(jRoot);
printf("[%s:%d] ok:%s\n", __func__, __LINE__, data);
free(data);
}
}
// 正确示例
void test_parse_json_2(char *buf, cJSON **jRoot)
{
*jRoot = cJSON_Parse(buf);
if (*jRoot) {
cJSON *json = NULL;
if ((json = cJSON_GetObjectItem(*jRoot, "version")))
printf("[%s:%d] version:%d\n", __func__, __LINE__, json->valueint);
char *data = cJSON_PrintUnformatted(*jRoot);
printf("[%s:%d] ok:%s\n", __func__, __LINE__, data);
free(data);
}
}
int main(void)
{
char *buf = NULL;
int buf_len = 0;
read_json_file(CONFIG_FILE1, &buf, &buf_len);
printf("[%s:%d] buf_len:%d, buf:%s]\n", __func__, __LINE__, buf_len, buf);
for (int i = 0; i < 5000; i++) // 循环多次,更容易观察内存变化
{
cJSON *jRoot = NULL;
#if 0
// 错误示例
test_parse_json(buf, jRoot);
#else
// 正确示例
test_parse_json_2(buf, &jRoot);
#endif
if (jRoot) {
cJSON_Delete(jRoot);
printf("[%s:%d] [%d] cJSON_Delete\n", __func__, __LINE__, i);
} else {
printf("[%s:%d] [%d] err, null jRoot!\n", __func__, __LINE__, i);
}
}
free(buf);
buf = NULL;
while (1) {
sleep(1); // 暂停在这里,方便执行监控脚本
}
return 0;
}
3. 运行结果对比
用前面写的 get_uss.sh 分别在两种模式下监控 test5 进程。
正确释放内存的情况(调用 test_parse_json_2):
循环 5000 次后,内存占用仅为数百 KB,USS 和 RSS 基本无变化:
$ ./get_uss.sh
PID: 24577
USS: 132 KB (0 MB)
RSS: 792 KB (0 MB)
内存泄漏的情况(调用 test_parse_json):
同样循环 5000 次,因为没有释放 cJSON 的解析树,USS 飙升到约 5 MB,RSS 达到约 6 MB,差异非常明显:
$ ./get_uss.sh
PID: 24747
USS: 5584 KB (5 MB)
RSS: 7004 KB (6 MB)
4. 总结
本文带你走通了 Linux 下进程内存监控的简单方案:通过 /proc/[pid]/status 的 VmRSS 和 /proc/[pid]/smaps 的 USS 来精确了解进程的内存占用。随后以一个常犯的 cJSON 使用错误为例,展示了当一级指针传递导致无法释放解析内存时,USS 和 RSS 会持续增长;而改用二级指针后,内存占用始终保持平稳。希望这些动手实验能让你对内存泄漏的排查更有感觉。若对 Linux 系统 或 C/C++ 内存管理有更多兴趣,欢迎来 云栈社区 一起交流。