先问一个问题:当你手上有几十个分区,想立刻知道“哪块盘最大、哪块盘快满了”,你会怎么做?反正我最早是df -h一下然后拿眼睛扫,十几个挂载点还能硬看,几十个的时候就真的眼神不太行了。后来我花了点时间做了个磁盘容量排序小项目:让程序自己读取挂载点,把容量统一换算成字节,再按我指定的字段和方向排序,最后用对齐的表格打出来。整个过程不依赖外部命令解析,纯 C 语言从头实现,连排序算法都手写了一遍。这篇文章就把完整思路、代码、踩过的坑一次说清楚,适合学 C 语言、做课程设计或者想真正搞懂“排序”背后细节的人。
1. 项目起点:磁盘容量排序到底在解决什么问题
1.1 一个真实场景:df -h 的输出为什么不够用
在 Linux 上查磁盘空间,大家最熟的就是df -h:
filesystem size used avail use% mounted on /dev/sda2 98G 45G 48G 49% / /dev/sda1 512M 96M 417M 19% /boot /dev/sdb1 458G 300G 136G 69% /data tmpfs 7.7G 0 7.7G 0% /dev/shm平时看个三五个分区还行,可一旦你管理的是一台存储服务器、一个 Kubernetes 节点,或者一台挂着大量数据盘的备份机,挂载点一多,这个输出就没法直接用了。它默认按文件系统构建顺序输出,完全不考虑容量大小;你很难一眼看出“最大的盘挂在哪”,也看不出“哪块盘已经用了 90% 以上”。
我当时的需求其实就一句话:把磁盘按容量从大到小排序,让人一眼看到最该关注的分区。更进一步,最好还能按“剩余容量”排、按“使用率”排,想升序就升序,想降序就降序。这就是这个项目的核心动机。
1.2 核心难点:字母数字组合的排序陷阱
真正动手之后我才发现,最难的部分根本不是“排序”,而是“容量数据的表示方式”。我们看到的1.5T、458G、8000M、512M这种字符串,带着不同单位、不同数值长度,属于典型的字母数字组合的字符串。
如果不懂这个坑,很容易写出最天真的“排序”:直接对容量字符串做字典序比较,也就是把"9G"、"11G"、"1.5T"当成普通字符串拿来strcmp()。结果就是灾难:
| 字符串排序结果 | 直觉上应该的结果 | 错在哪 |
|---|---|---|
"1.5T"<"458G"(因为'1'<'4') | 1.5T = 1536G远大于458G | 单位完全被忽略 |
"11G"<"9G"(因为'1'<'9') | 9G应该排在11G前面 | "11"字典序小于"9" |
这个问题在 SQL 里也特别常见:如果一张表的容量字段是varchar,存的是"10G"、"9G"、"1.5T",你用ORDER BY capacity排序,得到的就是上面这种错乱结果。所以“字母数字混合字段”必须拆成“数字部分 + 单位换算后的数值部分”再做排序,这是绕不过去的第一关。
1.3 技术选型:为什么用 C 语言手写一遍
你可能会问:这不是df -h | sort -h一行命令的事吗?确实,GNUsort提供了-h选项,能识别K、M、G、T后缀,实际生产环境我强烈建议直接用。但把这个题目当成项目来做,用 C 语言手写一下,价值完全不同:
第一,C 语言能让你把“字符串解析、单位换算、排序算法、格式化输出”每一个环节都拆到自己手里,弄明白底层发生了什么。用 Shell 一条命令跑完,学到的只有命令参数本身。
第二,排序是数据结构课程的核心,光看书上的伪代码和《算法导论》(CLRS)里的“循环不变量”证明,很容易“看懂了但写不出来”。亲手实现一遍选择排序,再对比标准库的qsort,才能体会工程师写排序和教科书讲排序的差异。
第三,用statvfs()系统调用读取磁盘容量,比解析df的文本输出更接近系统层,也避免了不同发行版本地化输出格式不同导致的解析失败问题。
2. 核心方案设计:数据从哪里来、往哪里排
2.1 读取挂载点:/proc/mounts 与 statvfs 的正确姿势
在 Linux 下获取磁盘容量的正路是系统调用statvfs()。它给定一个路径,返回这个文件系统的块大小、总块数、空闲块数等信息,乘一下就得到字节数。问题在于:我该对哪些路径调用它?
我的做法是读取系统的挂载点列表。传统教材会教你打开/proc/mounts或者/etc/mtab,逐行用sscanf解析。为了处理那些带空格的文件名,挂载点里会用\040转义,所以更稳健的办法是用 glibc 的getmntent()函数,它直接帮你把转义处理掉。
拿到挂载点之后,过滤条件也很关键。/proc、/sys、/dev/shm、cgroup这些特殊文件系统也挂在系统里,总容量为 0 或者根本是内存,并不是我们想看的“磁盘”。我的过滤规则很简单:只看设备路径以/dev/开头的挂载点,这一下就把真实磁盘、SSD、LVM 逻辑卷都纳进来了,把伪文件系统全部挡在外面。
然后对剩下的挂载点调用statvfs:
struct statvfs vfs; if (statvfs(mnt->mnt_dir, &vfs) != 0) { continue; } uint64_t block_size = vfs.f_frsize ? vfs.f_frsize : vfs.f_bsize; uint64_t total = (uint64_t)vfs.f_blocks * block_size; uint64_t avail = (uint64_t)vfs.f_bavail * block_size; uint64_t used = total - avail;提示:必须先转成
uint64_t再做乘法。f_blocks和f_frsize都是unsigned long,在 32 位平台上直接相乘会溢出,容量算出来变成负数,排序自然全乱。
2.2 容量统一:字节、KB、MB、GB 之间的换算
一个分区我们拿到手的原始数据是字节数,比如500107862016这种看着头晕的数字。展示的时候需要转成人类可读的465.8G,而排序的时候必须用原始字节数。
这里有一个约定俗成的细节:操作系统和绝大多数 GNU 工具按1024 进制换算,1G = 1024M = 1073741824 字节;而硬盘厂商按1000 进制标称容量。所以一块“1TB”的硬盘,在df里显示实际是931.5G。项目里我统一采用 1024 进制,与df保持一致。
换算函数写起来不难,但要注意精度:字节数很大,不能直接放进int或long,必须用uint64_t;除以 1024 时用 double 保留一位小数,展示效果才正常。我习惯写一个human_size()函数,把所有容量统一转成B / KB / MB / GB / TB字符串,这样排序、显示、日志都共用一条逻辑。
2.3 排序字段与排序方向:按总容量、剩余容量还是使用率
数据读出来之后,排序字段也是个值得想清楚的问题。大部分人默认想要“总容量降序”,但实际运维里另外两种需求也很常见:
- 按可用容量排序:帮我看还有哪块盘剩余空间最多,适合往里塞大文件。
- 按使用率排序:找出快满的盘,用于容量告警,使用率 92% 的分区一定比一个总容量很小但用了 80% 的分区更值得关注。
我的程序里用一个枚举变量控制排序字段:SORT_TOTAL、SORT_AVAIL、SORT_USE_PERCENT,再用一个布尔变量控制升序还是降序。这样“参数化”之后,不管以后需求怎么变,都只是改个字段值的事,不用重写排序逻辑。
3. 排序算法落地:从循环不变量到可用的代码
3.1 手写选择排序:CLRS 循环不变量证明的实用版本
前面说了,这个项目我不要一上来就调qsort,而是先手写一个选择排序。为什么挑选择排序?因为它结构最简单,交换次数少,对“规模通常只有几十个分区”的磁盘列表来说性能完全够,又能清晰展示排序的核心规律。
先看代码:
void selection_sort_disk(DiskInfo arr[], int n, int reverse) { for (int i = 0; i < n - 1; i++) { int target = i; for (int j = i + 1; j < n; j++) { if (reverse) { if (arr[j].total > arr[target].total) target = j; } else { if (arr[j].total < arr[target].total) target = j; } } if (target != i) { DiskInfo tmp = arr[i]; arr[i] = arr[target]; arr[target] = tmp; } } }教科书里的选择排序通常只写升序,我加了reverse参数就能直接降序。这里面的关键证明思路,就是 CLRS 里反复强调的循环不变量(loop invariant):每次外层循环开始时,前i个元素已经是整个数组中最小(或最大)的i个,并且它们彼此有序。初始化时i = 0,前 0 个元素自然有序;保持时,算法在剩余区间[i, n-1]里找到全局最小,和arr[i]交换,于是前i+1个元素依然有序成立;终止时i = n-1,整个数组有序。这个证明看着抽象,但对着代码走一遍,每个词都能对上。
顺带提一个冷知识:选择排序是不稳定的排序——两个容量相同的分区,交换后相对顺序可能改变。磁盘分区互不相同,影响不大,但如果面试被问到“稳定排序”,能说出这个点会加分不少。
3.2 工程实现:qsort 与比较函数怎么写
手写选择排序是“学习模式”,代码里真正干活的时候,我会优先用标准库qsort。原因很简单:平均复杂度 O(n log n),底层是快速排序,调优充分,而且用比较函数把“排序规则”和“排序算法”解耦,将来想加“按使用率排”“按可用空间排”都不用改排序本身。
qsort有它的坑,比如比较函数几乎人人都写过错误写法:
// 错误写法,uint64_t 相减会溢出 int compare_total(const void *a, const void *b) { DiskInfo *da = (DiskInfo *)a; DiskInfo *db = (DiskInfo *)b; return (int)(da->total - db->total); }两个uint64_t相减,结果根本塞不进int,可能溢出变成负数,排序结果就随机了。正确的写法是显式比较大小:
int compare_total_desc(const void *a, const void *b) { DiskInfo *da = (DiskInfo *)a; DiskInfo *db = (DiskInfo *)b; if (da->total > db->total) return -1; if (da->total < db->total) return 1; return 0; }这个细节想明白之后,你会发现 C 语言里“返回值只表达大小关系,不表达差值”这件事特别重要。
3.3 输出格式化:如何把排序结果排成“能看的表”
排序结果出来之后,如果直接printf一坨数据,效果并不比df好多少。我花了点心思把表格排整齐:
printf("%-20s %-20s %10s %10s %10s %6s\n", "Filesystem", "Mountpoint", "Total", "Used", "Avail", "Use%");%-20s表示左对齐占 20 列,%10s表示右对齐占 10 列,这样不同位数的容量值能整齐排列。关键点在于:不能直接把数字%lld打印,因为 458G 和 1.5T 的宽度差别太大;也不要先转成"458G"字符串再排,因为你还得保留原始字节数。正确做法是排序时用原始字节数值,展示时才转成人类可读字符串。
4. 完整实现与运行效果
4.1 完整 C 代码
下面给出一个可以直接编译运行的版本。代码量不大,我尽量保持结构清晰,每个函数只做一件事。基于常见的getmntent+statvfs方案,过滤非/dev/设备,按总容量降序输出。
#include <stdio.h> #include <stdlib.h> #include <string.h> #include <stdint.h> #include <mntent.h> #include <sys/statvfs.h> #define MAX_DISKS 128 #define MAX_NAME 256 typedef struct { char filesystem[MAX_NAME]; char mountpoint[MAX_NAME]; uint64_t total; uint64_t used; uint64_t avail; int use_percent; } DiskInfo; void human_size(uint64_t bytes, char *buf, size_t size) { static const char *units[] = {"B", "KB", "MB", "GB", "TB", "PB"}; int i = 0; double val = (double)bytes; while (val >= 1024.0 && i < 5) { val /= 1024.0; i++; } if (i == 0) snprintf(buf, size, "%.0f %s", val, units[i]); else snprintf(buf, size, "%.1f %s", val, units[i]); } int compare_total_desc(const void *a, const void *b) { DiskInfo *da = (DiskInfo *)a; DiskInfo *db = (DiskInfo *)b; if (da->total > db->total) return -1; if (da->total < db->total) return 1; return 0; } int main(void) { DiskInfo disks[MAX_DISKS]; int count = 0; FILE *fp = setmntent("/etc/mtab", "r"); if (!fp) { perror("setmntent"); return 1; } struct mntent *mnt; while ((mnt = getmntent(fp)) != NULL && count < MAX_DISKS) { if (strncmp(mnt->mnt_fsname, "/dev/", 5) != 0) continue; struct statvfs vfs; if (statvfs(mnt->mnt_dir, &vfs) != 0) continue; uint64_t block_size = vfs.f_frsize ? vfs.f_frsize : vfs.f_bsize; uint64_t total = (uint64_t)vfs.f_blocks * block_size; if (total == 0) continue; uint64_t avail = (uint64_t)vfs.f_bavail * block_size; uint64_t used = total - avail; snprintf(disks[count].filesystem, MAX_NAME, "%s", mnt->mnt_fsname); snprintf(disks[count].mountpoint, MAX_NAME, "%s", mnt->mnt_dir); disks[count].total = total; disks[count].used = used; disks[count].avail = avail; disks[count].use_percent = (int)((double)used * 100.0 / total + 0.5); count++; } endmntent(fp); if (count == 0) { printf("No disk partitions found.\n"); return 0; } qsort(disks, count, sizeof(DiskInfo), compare_total_desc); char total_str[32], used_str[32], avail_str[32]; printf("%-20s %-20s %10s %10s %10s %6s\n", "Filesystem", "Mountpoint", "Total", "Used", "Avail", "Use%"); for (int i = 0; i < count; i++) { human_size(disks[i].total, total_str, sizeof(total_str)); human_size(disks[i].used, used_str, sizeof(used_str)); human_size(disks[i].avail, avail_str, sizeof(avail_str)); printf("%-20s %-20s %10s %10s %10s %5d%%\n", disks[i].filesystem, disks[i].mountpoint, total_str, used_str, avail_str, disks[i].use_percent); } return 0; }4.2 编译与运行
编译十分简单,不需要链接第三方库:
gcc -o disksort disksort.c -Wall ./disksort我在一台挂着系统盘和数据盘的普通 Linux 机器上跑出来的效果大概是这样的:
Filesystem Mountpoint Total Used Avail Use% /dev/sdc1 /mnt/archive 931.5G 331.2G 553.8G 36% /dev/sdb1 /data 465.8G 300.4G 135.6G 65% /dev/sda2 / 237.9G 87.1G 138.4G 37% /dev/sda1 /boot 511.0M 95.6M 400.5M 19%你看,最大的归档盘排在最上面,系统盘在中间,500M 的 /boot 沉底,一眼就能抓住重点。这比默认的df -h输出舒服多了。
4.3 输出调整:如何换成升序、按可用容量或按使用率排
想改成升序,写一个compare_total_asc把返回 1 和 -1 的位置换一下就行。想按可用容量排,把比较函数里的total换成avail即可。我实际使用中最常切换的是“按使用率降序”,这时候compare_use_percent_desc比的是整数百分比,逻辑完全一致。
提示:如果以后要支持命令行参数
-k total、-k avail、-r等,只需要在main里把对应函数指针赋给一个int (*cmp)(const void *, const void *)变量,再传给qsort。这也是工程上“策略模式”的一种原始形态。
5. 常见问题与排查技巧实录
5.1 容量数字“乱序”的坑:字符串排序 vs 数值排序
我一开始偷懒,把 DiskInfo 直接按char total_str[16]存了"1.5T",结果qsort的结果惨不忍睹。调试的时候人会很崩溃,因为看起来每行都对,排出来的顺序却毫无逻辑。后来想明白了:容量字段必须保存原始字节值,字符串只用于展示。
这个坑在别的技术栈里也一样存在。比如 MySQL 里如果你把容量存成VARCHAR,ORDER BY capacity就是按字母序排,会出现上面表格里那种"11G"排在"9G"前面的诡异现象。Java 的stream多字段排序里,如果对容量字符串调Comparator.comparing(Disk::getSizeLabel),同样会遇到字母数字组合排序问题;正确做法是Comparator.comparing(Disk::getSizeBytes)。ORM 框架也一样,像 Sequelize 里写别名排序时order: [['capacityBytes', 'DESC']],必须确保排序字段是数值而别名映射正确,一旦别名排序落在字符串列上,结果就是错的。
所以不管什么语言、什么框架,通用原则就一个:排序永远用数值,展示才用格式化字符串。
5.2 读取不到容量或者容量显示为 0 的排查顺序
如果程序跑出来一堆空行,或者某个分区容量是 0,按以下顺序排查:
statvfs调用失败:权限不足或者路径被 unmount,返回值为 -1。把errno打出来,一般是EACCES或ENOENT。- 过滤条件太严格:我只保留了
/dev/开头,如果你有 overlay、网络存储、内存盘,它们会被过滤掉。想保留就放宽条件,比如用strstr(src, "/mapper/")或者加入白名单类型。 - 乘法溢出:32 位平台必须做强转,否则
blocks * frsize的乘积在unsigned long里就炸了,结果变成 0 或负数。 - 循环上限
MAX_DISKS太小:挂载点多的时候会被静默截断。建议越界时输出一行提示,而不是默默丢弃。
5.3 getmntent 与解析 /proc/mounts 的选择
我在代码里用了getmntent,它读的是/etc/mtab,在绝大多数 Linux 上等价于/proc/mounts。直接fgets手工解析/proc/mounts也可以,但遇到挂载点含空格时(比如外接移动硬盘挂成/media/user/My Disk),普通sscanf("%s %s")会把它拆成两段,容量排序直接错乱。getmntent内部把空格转义处理成了\040,读回来自动还原为空格,这个细节让我少踩一个坑。
如果你要在 macOS 或者 BSD 上移植,statvfs是有的,但挂载点遍历要用getmntinfo,API 不同,代码需要做一层兼容封装。我在 Linux 上做得很顺手,跨平台这个事留给你当扩展练手。
6. 项目扩展与我的心得体会
6.1 扩展方向一:定时监控与自动告警
排序本身只是第一步。做完这个项目后,我立刻想到的扩展是加一个 cron 任务,每 5 分钟跑一次,把“使用率超过 90% 的分区”挑出来,配合邮件或者 Webhook 推送告警。这个时候会发现,前面费劲把容量统一成字节、把使用率算好,几乎是白捡的便宜,直接写个 for 循环过滤就行。需要筛选条件的断言式的语句,往main里加一个-w 90参数就搞定。
6.2 扩展方向二:Web 化与“点击表头排序”
如果你想把分区信息做成一个监控页面,前端表格就会遇到“点击表头排序”的需求。这个需求背后的逻辑和这个项目完全一致:点击“容量”列,实际上是对sizeBytes这一列的数值做排序;点击“使用率”列,就按百分比排序。前端框架的排序函数底层也都是数值比较,只要数据模型里没有sizeLabel充当排序字段,就不会出问题。Tableau 这类 BI 工具里做容量排名,也是同样的道理:报表工具之间可以不同,但底层必须有一个数值字段承载排序语义。
6.3 我个人的几点受用心得
做完这个项目,我最大的收获不是“学会了选择排序”,而是对“数据表示方式决定排序正确性”这件事有了肌肉记忆。后来在公司给监控系统写“磁盘容量排行”的排序查询接口时,我特地去查了数据库表结构,确认容量字段是BIGINT而不是VARCHAR,避免了线上排序错乱。那次排查最后只花了五分钟,同事都在问怎么定位这么快,其实全靠这个十几行代码的小项目打底。
另一个心得是:手动实现一次排序算法,远比看十遍博客更有效。循环不变量那套证明,只看书我是真的记不住,但自己把target指针挪错一次、把qsort比较函数写溢出一回之后,那些概念就再也忘不掉了。如果你也想加深对排序的理解,强烈建议按这个路线自己走一遍:先解析真实数据,再手写一次选择排序,最后换qsort对比体验。整套流程下来,你收获的绝对不止是一个“磁盘容量排序”脚本。