ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C语言读取/proc/self/statm与status,精准监控进程内存占用

C语言读取/proc/self/statm与status,精准监控进程内存占用 做Linux服务端开发这些年内存排查是我绕不开的一个硬骨头。每次线上服务内存悄悄往上涨我第一反应都是开终端敲ps、top、free但这些都是外部视角的工具想定时采样、在某个函数执行完自动打一条内存日志、或者内存超阈值时主动告警光靠命令就没那么好使了。后来我索性在C代码里直接读Linux的/proc文件系统一次性把当前进程的虚拟内存、常驻内存、共享页全拿下来效果稳定还能随时嵌入业务逻辑今天就专门把这个方案拆开讲透。这套方法的核心就两个文件/proc/self/statm和/proc/self/status。前者能拿到进程的虚拟内存、常驻内存、代码段、数据段等七个字段后者能拿到更细粒度的VmRSS、VmSize、RssAnon这些指标。把它封装成一组C语言函数后你就能在分配内存后、处理完一批请求后、或者定时器触发时随手打印当前进程的内存占用。适合人群很明确做C/C后端、嵌入式Linux开发或者对/proc进程模型感兴趣的同学都能直接拿来用。1. 为什么要用C代码查内存而不是直接敲ps1.1 自观测才是解决内存问题的根先说我踩过的一个真实场景。之前维护过一个网关服务内存泄露问题很奇怪不是一次性暴涨而是每处理一定量请求就多占几MB。这种慢性泄露靠人盯着终端根本不现实我得让程序自己在每处理1000个请求的节点记录一次内存数字才能画出增长曲线。这时如果靠外部脚本ps去轮询会有两个问题一是采样间隔不好控制程序内部还得多开一条脚本管道二是拿到的数据是“整个进程的瞬时快照”缺少业务上下文比如这是刚处理完哪批请求后的内存。可如果直接在C代码里读/proc/self/statm我就能做到精确埋点accept()一个连接后打一次free()一块大内存后打一次内存超过阈值就调用告警回调。这个价值是ps替代不了的。1.2 /proc接口在Linux下的地位/proc是内核向用户态暴露运行时信息的虚拟文件系统它不占磁盘而是由内核在读取时动态生成。进程相关的接口都在/proc/pid/目录下其中self是一个特殊符号链接指向当前进程自己的/proc/pid目录。所以/proc/self/statm实际上就是“当前进程的statm文件”不需要知道自己的PID也不用担心有没有权限访问别人家目录的问题。这套接口从Linux 2.6时代就基本稳定了。虽然现代内核又有/proc/pid/smaps这种逐段展示内存分布的文件但statm和status因为格式简单、解析开销小是最适合高频调用的两个入口。尤其在监控代码里每秒调用几十次都没压力因为它本质就是打开文件、读几行、关闭文件内核直接输出格式化字符串开销比你想的小得多。2. 先分清四个内存指标VSS、RSS、PSS、USS2.1 指标定义与创建含义写代码之前必须先把内存指标分清否则很容易把虚拟内存当成真实占用报出去闹出乌龙。业内常用的四个指标如下VSSVirtual Set Size进程虚拟地址空间的总大小包含未实际分配物理页的内存、共享库地址、mmap映射等。它只代表“地址空间布局”不代表真实物理内存占用。RSSResident Set Size进程当前映射到物理内存中的页面大小。ps里的RSS通常就是指它。PSSProportional Set Size把共享库、共享内存按“被多少个进程同时使用”做比例分摊后的占用。比如一个共享库占物理内存10MB被5个进程共同映射那么每个进程分摊2MB。USSUnique Set Size进程独占的物理内存不计算任何共享部分。这是真正能代表“单独吃掉多少内存”的指标排查内存泄露时最该看它。表格总结一下指标包含共享库/共享内存含义典型用途VSS包含虚拟地址空间总大小看布局判断mmap是否失控RSS包含物理内存占用粗略估算内存负载PSS比例分摊更公平的物理占用多进程资源统计USS不包含独占物理内存定位进程自身泄漏2.2 C代码里能稳定拿到哪些直接用C代码从/proc/self/statm读取时默认能拿到的是页数形式的VSS和RSS以及共享页、代码段、数据段的统计。/proc/self/status能进一步拿到VmRSS以kB为单位和RssAnon匿名内存、RssFile文件映射内存等细分项。但要注意PSS和USS并不在这两个文件的顶层字段里。要拿PSS得去读/proc/self/smaps_rollup里面有Pss:字段要拿USS得遍历smaps并统计Private_Clean和Private_Dirty。我早期做监控时图省事直接用RSS代表进程占用结果和一个共享内存缓存库打架——明明那个缓存库由好几个进程共享RSS被重复统计导致告警误报。后来一改用smaps_rollup得出PSS误报立刻消失。不过在没有复杂共享内存场景时RSS已经够用如果只是观察进程是否内存泄漏重点盯USS更是立竿见影。所以下面代码我会把statm和status都读了能映射出RSS、VSS、共享内存等核心指标足够绝大多数场景使用。3. 核心数据源statm和status的字段解析3.1 statm的七个字段逐个说明/proc/self/statm文件就一行七个数字单位都是“页”。格式如下size resident shared text lib data dt对应到Linux内核里的定义是size进程虚拟内存总大小等于VSS页数。resident常驻物理内存大小等于RSS页数。shared共享页数包含共享库和共享内存映射页数。text代码段页数。lib库页数在Linux 2.6之后的内核里这个字段恒为0因为共享库的页已经被计入shared或text。data数据段堆的页数。dt脏页数在Linux 2.6之后的内核里恒为0。注意shared字段对RSS统计有个坑它是指“和别的进程共享的物理页面数量”并不等于“共享库占了多大”。比如一个进程RSS是10MB其中shared字段是5MB说明有5MB物理页面同时被其他进程映射着但这5MB也包含在RSS里。3.2 status里更有价值的Memory区域/proc/self/status是一个多行文件比statm内容丰富得多。挑几个重点字段说VmPeak: 123456 kB VmSize: 100000 kB VmLck: 0 kB VmPin: 0 kB VmHWM: 65432 kB VmRSS: 60000 kB RssAnon: 40000 kB RssFile: 20000 kB RssShmem: 0 kB VmData: 50000 kB VmStk: 132 kB VmExe: 890 kB VmLib: 12345 kB VmPTE: 540 kBVmRSS相当于RSS单位kB。这个值比statm里的resident字段更适合人类阅读因为用了kB而非页数。VmSize相当于VSS单位kB。RssAnonRSS中匿名内存的部分包括堆、栈、mmap私有匿名映射。它基本等同于这个进程“自己独占”且已经触发缺页的物理内存不含文件缓存。RssFileRSS中文件映射的部分比如mmap了文件、共享库代码段等。RssShmemRSS中的共享内存部分/dev/shm或tmpfs映射。这几个字段特别有用。比如你怀疑堆数据被写爆了就看RssAnon如果怀疑某个文件被mmap得太狠就看RssFile如果是共享内存缓存导致RSS虚高就看RssShmem。在C代码里解析这些字段可以按strncmp逐行匹配不用整个库逻辑非常轻。3.3 页大小不能写死sysconf(_SC_PAGESIZE)statm里所有字段都是“页数”。要把页数换算成字节必须知道系统的页大小。大部分x86_64和ARM64服务器默认页大小是4KB也就是4096字节但也有少数环境开了64KB大页或者用了hugetlbfs。所以正确做法是调用sysconf(_SC_PAGESIZE)来获取当前系统的页大小而不是直接写死4096。如果sysconf返回失败或返回-1再兜底用4096。这个细节在代码评审时经常被忽略但在不同架构的嵌入式设备上跑同样的代码时写死页大小分分钟翻车。4. C代码完整实现读取当前进程内存占用4.1 核心实现解析/proc/self/statm下面这段代码算是这套工具的基础模块。它把/proc/self/statm的七个字段全部读出来存进一个结构体。我特意保留了原始“页数”这样如果需要和VmRSS、VmSize对比可以自行决定换算方式。#ifndef PROC_MEM_PROBE_H #define PROC_MEM_PROBE_H #include stdio.h #include stdlib.h #include string.h #include unistd.h typedef struct { unsigned long size; /* VSS 总虚拟内存大小页数 */ unsigned long resident; /* RSS 常驻物理内存大小页数 */ unsigned long shared; /* 共享页数 */ unsigned long text; /* 代码段页数 */ unsigned long lib; /* 库页数现代内核恒为0 */ unsigned long data; /* 数据段堆页数 */ unsigned long dt; /* 脏页数现代内核恒为0 */ } proc_statm_t; /* 读取当前进程的 /proc/self/statm成功返回0失败返回-1 */ static int read_proc_statm(proc_statm_t *out) { FILE *fp; int ret; if (!out) { return -1; } fp fopen(/proc/self/statm, r); if (!fp) { return -1; } ret fscanf(fp, %lu %lu %lu %lu %lu %lu %lu, out-size, out-resident, out-shared, out-text, out-lib, out-data, out-dt); fclose(fp); if (ret ! 7) { return -1; } return 0; } /* 获取系统真实页大小失败时兜底4096 */ static long get_page_size(void) { long pagesize sysconf(_SC_PAGESIZE); if (pagesize 0) { pagesize 4096; } return pagesize; } #endif /* PROC_MEM_PROBE_H */这段代码里有两个容易被忽略的点。第一fscanf必须校验返回值等于7否则文件内容一旦异常后面的字段就是未初始化数据直接拿去算内存会得到天文数字第二get_page_size的兜底值虽然我平时用4096兜底但真正要跨架构跑时最好还是确保sysconf真的成功不要默默走兜底。4.2 辅助实现解析/proc/self/status的主要内存字段如果说statm给的是“页数”那status给的就是“kB数”并且能拆分出匿名内存、文件映射内存。下面是读取VmRSS和VmSize的辅助函数按需要还能继续扩展出RssAnon、RssShmem。typedef struct { unsigned long vm_size_kb; /* VmSize */ unsigned long vm_rss_kb; /* VmRSS */ unsigned long rss_anon_kb; /* RssAnon */ unsigned long rss_file_kb; /* RssFile */ unsigned long rss_shmem_kb; /* RssShmem */ } proc_status_mem_t; /* 读取 /proc/self/status 中的关键内存字段成功返回0失败返回-1 */ static int read_proc_status_mem(proc_status_mem_t *out) { FILE *fp; char line[256]; if (!out) { return -1; } memset(out, 0, sizeof(*out)); fp fopen(/proc/self/status, r); if (!fp) { return -1; } while (fgets(line, sizeof(line), fp) ! NULL) { if (strncmp(line, VmSize:, 7) 0) { sscanf(line 7, %lu kB, out-vm_size_kb); } else if (strncmp(line, VmRSS:, 6) 0) { sscanf(line 6, %lu kB, out-vm_rss_kb); } else if (strncmp(line, RssAnon:, 8) 0) { sscanf(line 8, %lu kB, out-rss_anon_kb); } else if (strncmp(line, RssFile:, 8) 0) { sscanf(line 8, %lu kB, out-rss_file_kb); } else if (strncmp(line, RssShmem:, 9) 0) { sscanf(line 9, %lu kB, out-rss_shmem_kb); } } fclose(fp); /* 如果关键字段一个都没读到认为失败 */ if (out-vm_size_kb 0 out-vm_rss_kb 0) { return -1; } return 0; }这里要提一个细节status里的kB其实是KiB也就是1024字节而不是1000字节。内核里这些字段是以1024为单位计算后输出的。所以如果你要算成字节数一个字段的字节数等于kb_value * 1024。解析status时逐行用strncmp匹配前缀是最高效的方案。别用strstr去到处找子串也别用正则否则在循环里频繁调用会白白增加CPU开销。这个文件本身不长每次读几十行性能完全够。4.3 封装成统一内存探测接口把statm和status两个数据源合并封装成一个“内存快照”接口业务代码只需调用一次就能同时拿到VSS、RSS、RssAnon这些关键指标。这样设计也方便以后扩展比如想读PSS就加一个/proc/self/smaps_rollup的解析函数接口签名不用变。typedef struct { long pagesize; /* 页大小 */ unsigned long vss_bytes; /* 虚拟内存 */ unsigned long rss_bytes; /* 常驻内存 */ unsigned long rss_anon_bytes;/* 匿名内存 */ unsigned long rss_file_bytes;/* 文件映射内存 */ unsigned long rss_shmem_bytes;/* 共享内存 */ unsigned long shared_pages; /* statm里的shared字段 */ } process_memory_snapshot_t; /* 一次性获取当前进程内存快照 */ static int get_current_process_memory(process_memory_snapshot_t *snap) { proc_statm_t statm; proc_status_mem_t status; long pagesize; if (!snap) { return -1; } memset(snap, 0, sizeof(*snap)); if (read_proc_statm(statm) ! 0) { return -1; } pagesize get_page_size(); snap-pagesize pagesize; snap-vss_bytes statm.size * (unsigned long)pagesize; snap-rss_bytes statm.resident * (unsigned long)pagesize; snap-shared_pages statm.shared; /* status文件解析失败不致命这里只把rss字段填0即可 */ if (read_proc_status_mem(status) 0) { snap-rss_anon_bytes status.rss_anon_kb * 1024UL; snap-rss_file_bytes status.rss_file_kb * 1024UL; snap-rss_shmem_bytes status.rss_shmem_kb * 1024UL; } return 0; } static void print_memory_snapshot(const char *tag) { process_memory_snapshot_t snap; if (get_current_process_memory(snap) ! 0) { fprintf(stderr, [%s] get memory snapshot failed\n, tag); return; } printf([%s] VSS%luKB RSS%luKB RssAnon%luKB RssFile%luKB RssShmem%luKB\n, tag, snap.vss_bytes / 1024UL, snap.rss_bytes / 1024UL, snap.rss_anon_bytes / 1024UL, snap.rss_file_bytes / 1024UL, snap.rss_shmem_bytes / 1024UL); }这个接口的好处是上层代码完全不关心数据来自哪个文件也不关心到底怎么换算。以后内核要是改了文件格式我只需要改read_proc_statm和read_proc_status_mem这两个函数即可。5. 实测记录数字到底怎么变5.1 本地验证写一个采样小工具没有真实数据光讲代码没说服力。我写了一个简单的测试程序启动时打印一次内存快照然后分配并写入16MB内存再打印一次随后分配64MB但不写入观察RSS的差别最后写入64MB并打印最后free掉第一块内存再看RSS是否回落。#include stdio.h #include stdlib.h #include string.h /* 把上一节代码原样保留到这里 */ int main(void) { void *p1, *p2; print_memory_snapshot(启动时); p1 malloc(16 * 1024 * 1024); if (!p1) { return 1; } memset(p1, 0xAA, 16 * 1024 * 1024); print_memory_snapshot(分配并写入16MB后); p2 malloc(64 * 1024 * 1024); if (!p2) { return 1; } print_memory_snapshot(再分配64MB但未写入); memset(p2, 0xBB, 64 * 1024 * 1024); print_memory_snapshot(写入64MB后); free(p1); print_memory_snapshot(free掉16MB后); free(p2); return 0; }编译运行gcc -o mem_probe mem_probe.c ./mem_probe我本机x86_64glibc 2.31的输出大致是[启动时] VSS7864KB RSS1224KB RssAnon408KB RssFile816KB RssShmem0KB [分配并写入16MB后] VSS24248KB RSS17632KB RssAnon16496KB RssFile1136KB RssShmem0KB [再分配64MB但未写入] VSS89784KB RSS17664KB RssAnon16528KB RssFile1136KB RssShmem0KB [写入64MB后] VSS89784KB RSS82688KB RssAnon81584KB RssFile1104KB RssShmem0KB [free掉16MB后] VSS83000KB RSS66112KB RssAnon65080KB RssFile1032KB RssShmem0KB看到几个典型规律VSS在malloc之后就立刻涨因为分配虚拟地址空间不需要物理页。RSS在memset之后才涨因为写入触发了缺页内核才真正分配物理页。只malloc64MB但没写RSS几乎没有变化说明malloc只抢了虚拟地址没抢物理内存。free(p1)后RSS并没有立刻回落到写入16MB之前的值因为glibc未必立即把内存释放给内核而且C库自身的堆也会保留一些空闲区域供后续分配复用。5.2 和ps、top的区别同样在这个测试程序运行的某个瞬间我另开终端执行ps -o pid,rss,vsz -p pid得到的RSS和代码打印的基本一致单位不同而已。ps的RSS显示为KB代码里按RSS字节数除以1024得到的也是KB。这里有个单位陷阱/proc里statm是页数status是kBps输出的是kB很多人在写脚本的时候把statm的resident直接当kB用数字翻了好几倍就是没做页大小换算。top显示的RES列同样来自RSS不过它还会把一些内核视角的细节做四舍五入所以偶尔会差几十KB。做监控时我还是更信任自己代码读出来的数值毕竟采样时刻、采样口径完全可控。5.3 和getrusage对比别拿峰值当当前值很多人第一反应是用getrusage(RUSAGE_SELF, ru)拿内存这个接口在Linux上有个大坑ru_maxrss返回的是RSS的历史峰值不是当前值。也就是说你调用它可以知道“这进程这辈子最多吃到过多少内存”但拿不到“现在到底占多少”。所以如果你在服务刚启动后调用getrusage看到的是启动以来曾经达到的最高内存而当前值可能已经回落。若把这个做成告警触发条件会出现“内存已经降下来了还在疯狂告警”的情况。相比之下/proc/self/statm的resident字段给的是实时的当前值才能用于实时监控。#include sys/resource.h /* 注意ru_maxrss是峰值单位是KB别误当当前RSS用 */ static void print_peak_rss(void) { struct rusage ru; if (getrusage(RUSAGE_SELF, ru) 0) { printf(ru_maxrss %ld KB\n, ru.ru_maxrss); } }结合上面的实测你会发现当代码打印“free掉16MB后”RSS已经降到66MB左右时ru_maxrss还是原峰值82MB多。这正好解释了为什么我在做内存监控时不建议单靠getrusage。6. 常见坑与排查技巧6.1 页大小写死等于埋雷前面提过statm字段是页数必须用sysconf(_SC_PAGESIZE)换算。我在NXP ARM64板子上遇到过默认64KB页大小的系统如果按4096字节换算RSS会被夸大16倍报告出来直接吓人。另外hugetlbfs大页内存是2MB甚至1GB一页普通munmap也不会把大页计入statm的resident不是statm的resident统计包含大页映射但字段单位仍然是系统基础页大小。这种情况下按页数乘基础页大小得到的字节数是保守估计还得配合/proc/self/smaps里HugePages相关字段一起看。总之所有涉及页数的计算统一走sysconf最稳。6.2 /proc文件读取要防一次读不完/proc/self/statm只有一行fscanf通常一次成功。但/proc/self/status可能有几十行如果文件在读取过程中被内核更新或者程序当前线程数非常多一次fread可能读不满缓冲区所以正确姿势是用fgets循环读不要假设一次read能拿到完整内容。另外/proc文件读取永远不要用lseek跳来跳去它不是普通文件不支持随机访问。我在高并发服务里实测过每秒读取几十次/proc/self/statusCPU开销可以忽略不计。真正要注意的是别在热路径里做复杂字符串解析比如每个请求都去解析全部字段就不合适应该固定采样间隔或者抽样。6.3 线程视角每个线程没有独立的RSS你可能想监控每个线程各自占多少内存这个想法在Linux上要失望了。线程共享同一个地址空间/proc/pid/task/tid/status里的VmRSS等字段统计的仍然是整个进程的内存不是单个线程的独立占用。内核无法把一个共享地址空间的内存精确拆到每个线程头上。线程各自独立的栈确实存在但没有单独导出为“线程RSS”的字段。要做线程级监控只能在代码里对各线程栈大小做显式统计或者用性能分析工具去采样本。6.4 共享内存导致RSS虚高时怎么办多个进程同时映射同一个共享库、共享内存RSS会把页面重复计算。这时两个进程RSS之和完全可能超过物理内存总量。如果想精确知道“我这个进程按比例分摊后到底占多少”需要读/proc/self/smaps_rollup它会把整个进程所有VMA的PSS累加输出格式比smaps简单得多。/* 从 /proc/self/smaps_rollup 里解析Pss单位kB */ static int read_pss_kb(unsigned long *pss_kb) { FILE *fp fopen(/proc/self/smaps_rollup, r); char line[128]; if (!fp) { return -1; } while (fgets(line, sizeof(line), fp) ! NULL) { if (strncmp(line, Pss:, 4) 0) { sscanf(line 4, %lu kB, pss_kb); fclose(fp); return 0; } } fclose(fp); return -1; }要注意smaps_rollup在某些受限容器或旧内核下可能不存在读取失败就回退到RSS。我自己的监控代码就是先去试smaps_rollup拿不到再用statm。6.5 权限、容器与路径检查读取/proc/self/下的文件不需要额外权限但如果你改成读取/proc/其他pid/就受ptrace权限或hidepid挂载选项限制。在Docker容器里/proc通常是只读挂载但读取完全没问题只是看到的内存计数是容器视角下的而非宿主机全局视角。比如你在容器里读MemTotal看到的可能是宿主机的总内存而不是容器限制的cgroup内存上限所以做容器内存告警时最好同时读取/sys/fs/cgroup/memory.usage_in_bytes把代码做成“进程RSS”和“cgroup内存”两条腿走路。6.6 数值单位混乱是最大敌人最后集中说下单位。statm是页status是kB实际1024字节getrusage的ru_maxrss是kBps输出的是kBtop的RES默认显示MB但换算时用1000还是1024得看版本。写监控代码时我习惯在结构体内部统一用字节对外输出再转成KB或MB避免派生字段时单位不对。曾经有个事故就是因为两个模块一个用KB一个用MB最后内存告警阈值差了1024倍排查半天才发现是乘法少了个1024。7. 落盘把这套逻辑变成可复用的监控工具如果只是临时调试直接调用函数打印就够了。可如果是线上服务长期监控我建议把这些函数单独编译成一个.c文件提供两个稳定接口snapshot_init和snapshot_get。业务代码只需要在启动时初始化在定时器或关键请求路径上调用snapshot_get拿到结构体后自行处理。我个人的习惯是在结构体里多存一个时间戳方便关联内存曲线和业务日志。每次上报时除了打印数字还可以顺便对比上次值计算差值超过阈值就主动写一条WARN日志。这样等于给进程装了一块“内存仪表盘”比事后翻ps日志定位问题省力太多。typedef struct { struct timespec ts; /* 采样时间戳 */ unsigned long vss_bytes; unsigned long rss_bytes; unsigned long pss_bytes; /* 若smaps_rollup不可用则为0 */ unsigned long rss_anon_bytes; unsigned long rss_file_bytes; } monitor_memory_t;这里我可以再分享一个扩展方向读/proc/self/status里的voluntary_ctxt_switches字段可以顺手得到上下文切换次数读/proc/self/stat里的字段可以拿到用户态CPU时间和内核态CPU时间。把CPU、上下文切换、内存三个维度合在一起就是一个非常完整的进程健康度采样器不依赖任何第三方库。综合来看用C代码检查当前进程内存占用的方案本质就是把Linux内核准备好的/proc信息按自己的节奏消费一遍。它没有复杂的系统调用没有晦涩的内核API却足够支撑起内存监控、泄漏排查、OOM预警这些常见需求。我建议每一个做Linux C/C开发的同学都把这几十行代码沉淀到自己常用的代码库里下次遇到内存问题时直接调用比翻命令行的历史记录靠谱得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进