分类: 操作系统 / 运维 / NAS & 嵌入式
标签: fanotify, 硬盘休眠, I/O 监控, C语言, ARM, OpenWrt, 静态编译
内容摘要
你的 NAS 或 OpenWrt 硬盘是否总是莫名其妙地自动启动?iowatch 是一个不到 1MB、零依赖的 C 语言小工具。它利用 Linux 内核的 fanotify 机制,能精准告诉你 “谁” 在 “什么时候” 读写了 “哪个文件”。它是分析磁盘休眠问题的终极利器,支持 ARM、x86 及 OpenWrt 环境。
简介
如果你拥有一台基于 ARM 的 NAS(如群晖、绿联或自建 NAS)或运行 OpenWrt 的路由器,你一定经历过这种痛苦:明明设置了 15 分钟硬盘休眠,但每隔一会儿硬盘就会发出“咔哒”一声重新转起来,节能计划瞬间化为泡影。
问题的难点在于:到底是谁在读写磁盘? 传统的 iotop 只能看到实时吞吐量,却难以捕捉瞬时且零星的文件访问。
iowatch 诞生了。它是一个专门为诊断磁盘休眠而设计的超轻量级工具,直接监控文件系统挂载点底层的每一丝动静。
核心功能
- 零依赖运行: 采用静态编译(Static Linking),生成后的单个二进制文件不依赖任何系统库(如 GLIBC),在任何 Linux 版本上都能直接跑。
- 全架构支持: 源码可在 ARM64/AArch64(现代 NAS)、x86_64(普通服务器)甚至 MIPS(老旧路由器)上编译运行。
- 深度内核集成: 基于 Linux
fanotify子系统(内核 2.6.37+ 支持),直接从虚拟文件系统(VFS)层获取最真实的 I/O 通知。 - 精准追踪: 不仅显示进程 ID(PID)和进程名,还能追溯触发该事件的原始执行程序文件名,即使在 Docker 环境下也能快速锁定元凶。
- 清晰简洁: 移除冗余信息,只保留时间戳、动作(OPEN/READ/WRITE)、PID 和文件路径。
作用与角色:如何解决休眠问题?
iowatch 在你的“磁盘休眠保卫战”中扮演着侦察兵的角色:
- 识别“心跳”进程: 许多后台服务会定期轮询配置文件(如每 30 秒读一次)。
iowatch的时间戳能帮你发现这些规律性的访问。 - 锁定“隐形”写入: 系统日志(rsyslog)或容器缓存的每一次
WRITE_CLOSE动作都会强制内核刷盘,唤醒磁盘。通过路径,你可以迅速知道是哪个日志文件在搞鬼。 - 容器透明化: 在 Docker 繁多的环境下,单纯看进程名很难判断归属。
iowatch显示的执行程序路径能帮你直接关联到具体的容器。
完整 C 语言源码:iowatch_clean.c
该代码已针对现代 Linux 内核(5.1+)优化,使用 FAN_MARK_FILESYSTEM 标志以获得最佳兼容性。
/*
* IOWATCH - 磁盘休眠分析工具
* 功能:监控指定挂载点的所有文件访问事件
* 编译:gcc -O3 -static iowatch_clean.c -o iowatch
*/
#include <stdio.h>
#include <stdlib.h>
#include <sys/fanotify.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
#include <errno.h>
#include <string.h>
#include <time.h>
#include <limits.h>
#include <libgen.h>
void print_usage(char *prog_name) {
printf("\n================================================================\n");
printf(" IOWATCH - 磁盘休眠分析工具 (ARM/x86_64) \n");
printf("================================================================\n");
printf("核心规则:\n");
printf(" 监控目标必须是一个【挂载点】根目录(如 / 或 /volume1)。\n");
printf(" 请通过 'df -h' 命令查看 \"Mounted on\" 一栏确认路径。\n\n");
printf("用法:\n");
printf(" sudo %s <挂载点路径>\n", prog_name);
printf("示例:\n");
printf(" sudo %s /volume1\n", prog_name);
printf("================================================================\n\n");
}
const char* get_action(uint64_t mask) {
if (mask & FAN_MODIFY) return "MODIFY";
if (mask & FAN_CLOSE_WRITE) return "WRITE_CLOSE";
if (mask & FAN_ACCESS) return "READ";
if (mask & FAN_OPEN) return "OPEN";
return "EVENT";
}
int main(int argc, char *argv[]) {
if (argc != 2) {
print_usage(argv[0]);
return 1;
}
// 初始化 fanotify 句柄
int fd = fanotify_init(FAN_CLASS_NOTIF, O_RDONLY);
if (fd < 0) {
if (errno == EPERM) fprintf(stderr, "[错误] 需要 root 权限。\n");
else perror("fanotify_init 失败");
return 1;
}
// 设置监控掩码:修改、写入关闭、读取、打开以及子目录事件
uint64_t mask = FAN_MODIFY | FAN_CLOSE_WRITE | FAN_ACCESS | FAN_OPEN | FAN_EVENT_ON_CHILD;
// 标记监控目标:FAN_MARK_FILESYSTEM 支持整个文件系统监控 (内核 5.1+)
if (fanotify_mark(fd, FAN_MARK_ADD | FAN_MARK_FILESYSTEM, mask, AT_FDCWD, argv[1]) < 0) {
fprintf(stderr, "[错误] 无法监控目标路径: %s\n", argv[1]);
if (errno == ENOENT) {
fprintf(stderr, "提示:请运行 'df -h' 确保该路径是基础挂载点。\n");
} else {
perror("原因");
}
return 1;
}
print_usage(argv[0]);
printf("%-20s | %-12s | %-7s | %-15s | %s\n",
"时间戳", "动作", "PID", "进程名", "文件路径 [触发程序]");
printf("----------------------------------------------------------------------------------------------------\n");
char buf[8192];
while (1) {
ssize_t len = read(fd, buf, sizeof(buf));
if (len < 0) break;
struct fanotify_event_metadata *metadata = (struct fanotify_event_metadata *)buf;
while (FAN_EVENT_OK(metadata, len)) {
if (metadata->fd >= 0) {
char f_path[PATH_MAX], e_path[PATH_MAX], ts[20], proc_comm[32];
// 1. 获取受影响的文件路径
sprintf(f_path, "/proc/self/fd/%d", metadata->fd);
ssize_t p_len = readlink(f_path, f_path, sizeof(f_path)-1);
if (p_len != -1) f_path[p_len] = '\0';
else strcpy(f_path, "未知路径");
// 2. 获取触发进程的简称 (Comm)
sprintf(proc_comm, "/proc/%d/comm", metadata->pid);
int c_fd = open(proc_comm, O_RDONLY);
if (c_fd >= 0) {
ssize_t c_len = read(c_fd, proc_comm, 31);
if (c_len > 0) proc_comm[c_len-1] = '\0';
close(c_fd);
} else strcpy(proc_comm, "已退出");
// 3. 获取触发程序的执行文件名 (basename)
char raw_exe[PATH_MAX];
sprintf(raw_exe, "/proc/%d/exe", metadata->pid);
ssize_t e_len = readlink(raw_exe, raw_exe, sizeof(raw_exe)-1);
if (e_len != -1) {
raw_exe[e_len] = '\0';
strcpy(e_path, basename(raw_exe));
} else {
strcpy(e_path, "未知");
}
// 4. 生成时间戳
time_t now = time(NULL);
strftime(ts, 20, "%Y-%m-%d %H:%M:%S", localtime(&now));
// 排除工具自身的事件
if (metadata->pid != getpid()) {
printf("%-20s | %-12s | %-7d | %-15s | %s [%s]\n",
ts, get_action(metadata->mask), metadata->pid, proc_comm, f_path, e_path);
}
close(metadata->fd);
}
metadata = FAN_EVENT_NEXT(metadata, len);
}
}
return 0;
}
编译与部署指南
第一步:安装编译器(GCC)
在你的目标机器(NAS 或路由器)上安装 GCC:
- Debian/Ubuntu/宿主机:
sudo apt install -y gcc libc6-dev - OpenWrt:
opkg update && opkg install gcc libc-dev
第二步:静态编译
静态编译是跨版本运行的关键。它会将所需的库直接包含在二进制文件中。
Bash
gcc -O3 -static iowatch_clean.c -o iowatch
第三步:运行与分析
- 运行
df -h找到你想要监控的硬盘挂载点(例如/volume1)。 - 启动工具:
sudo ./iowatch /volume1
- 观察输出,特别注意那些每隔几分钟就规律出现的
WRITE_CLOSE或OPEN动作。
兼容性注意事项
此脚本默认使用 FAN_MARK_FILESYSTEM,这需要 Linux 内核 5.1 或更高版本。
如果你的设备(如老旧 OpenWrt 路由器)内核版本较低(可通过 uname -r 查看),请将源码中的: FAN_MARK_FILESYSTEM 替换为: FAN_MARK_MOUNT
注意: 使用 FAN_MARK_MOUNT 时,必须确保输入的路径是 df 命令中显示的完全一致的挂载根路径,否则会报错。
希望这个小工具能帮你找回丢失的“硬盘休眠时间”!


