找回密码
立即注册
搜索
热搜: Java Python Linux Go
发回帖 发新帖

4201

积分

0

好友

553

主题
发表于 4 小时前 | 查看: 6| 回复: 0

在嵌入式 Linux 开发中,如果你的代码申请了内存却未能按预期释放,就会造成内存泄漏。严重时,整个程序可能因内存耗尽(OOM)而崩溃。本文先介绍两种通过 Linux 系统 自带的 /proc 虚拟文件系统查看进程内存使用情况的方法,然后通过一个 cJSON 的实例,带你直观感受内存泄漏前后 USS 与 RSS 的显著变化。

1. 查看 Linux 进程内存使用的两种方法

这里先介绍通过 /proc/[pid]/status/proc/[pid]/smaps 来获取内存信息的方式。

1.1 从 /proc/[pid]/status 读取 VmRSS

在 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_CleanPrivate_Dirty 的求和结果称为 USS(Unique Set Size,唯一集大小),它代表了进程独占的、确实驻留在物理内存中的页面大小。

  • Private(私有):该内存页仅被当前进程引用(引用计数为 1),其他进程无法访问,通常包括堆、栈以及私有数据段。
  • Clean(干净):内存页内容与后备存储(如磁盘文件映射)一致,系统回收时可直接丢弃,无需写回。
  • Dirty(脏):内存页内容已被修改,与原始来源不一致,回收前必须将数据写回 Swap 或对应文件。

USS 就是所有 Private_CleanPrivate_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_CleanPrivate_Dirty 字段的数值相加,就得到了 USS。

1.3 一键获取 RSS 与 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++ 内存管理有更多兴趣,欢迎来 云栈社区 一起交流。




上一篇:预测市场信息传导:Polymarket如何成为股票市场新因子,夏普1.95
下一篇:VSCode Markdown 插件实测:五款替代 Typora 的编辑器扩展怎么选
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-8-4 10:32 , Processed in 0.848421 second(s), 39 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表