资讯动态

C#文件元数据采集:逻辑大小、物理占用与Win32底层属性全解析

发布时间:2026/10/25 20:08:52 来源:尧图企业网站定制
简介这是一份面向C#初学者与Windows桌面开发者的实用文件操作示例工程解决批量获取目录内文件元信息的实际需求适用于文件管理工具开发、系统运维脚本编写等场景。资源为完整VS解决方案包共12个文件包含6个核心C#源码.cs、2个本地化资源文件.resx、1个用户设置配置.settings、1个解决方案用户选项.suo、1个项目定义.csproj和1个解决方案主文件.sln结构规范可直接编译运行。压缩包仅13KB轻量易部署。已有1866人学习下载读者可直接获得基于DirectoryInfo与FileInfo双对象协同遍历的完整实现逻辑支持递归/非递归模式识别子目录与文件自动提取名称、大小、创建时间、完整路径等属性并通过ListView控件以列表形式清晰呈现代码注释充分目录层级与UI绑定逻辑清晰便于理解WinForms中文件系统API的典型应用范式。1. C#读取文件夹下所有文件名称、大小及其它属性不是遍历完就完事而是要稳准全地拿到每个文件的「真实快照」你写了个Directory.GetFiles()跑出来一堆路径再用new FileInfo(path).Length拿大小——结果发现某个 2GB 的视频文件显示大小是 0某个正在被 Excel 编辑的.xlsx文件抛出UnauthorizedAccessException某个 NTFS 压缩文件夹里的文件Length显示的是压缩后体积但业务要求必须是「逻辑大小」即解压后占用空间更糟的是你漏掉了CreationTimeUtc和LastAccessTime的时区陷阱导出报表后客户说「时间全错乱了」。这不是代码没跑通而是对 Windows 文件系统底层语义理解偏差导致的数据失真。本文讲的不是“怎么列出文件”而是如何在 C# 中可靠、可审计、可复现地采集文件元数据全集包括名称、大小逻辑/物理、创建/修改/访问时间含 UTC 对齐、只读/隐藏/系统属性、硬链接数、重解析点状态、甚至 NTFS 稀疏流信息。适用场景明确企业级资产盘点、合规性审计日志、备份策略校验、EDR 文件行为分析前置采集。新手能照着跑通最小可用脚本熟手能直接抄走生产级健壮封装——所有代码均基于 .NET 6System.IO原生 API零第三方依赖不碰cmd或 PowerShell。2. 为什么不能只用Directory.GetFiles()从 Win32 API 到 .NET 封装的三层真相2.1 文件系统视角NTFS/FAT32/exFAT 对「文件大小」的定义根本不同Windows 下一个文件的「大小」至少有 4 种含义而FileInfo.Length只返回其中一种含义对应 Win32 字段.NET 可获取方式业务典型用途逻辑大小Allocated SizenFileSizeLow/HighFileInfo.Length用户感知容量、磁盘配额计算物理占用Disk Space UseddwNumberOfClusters×SectorSizeGetCompressedFileSizeWGetDiskFreeSpaceExW存储成本核算、碎片分析压缩后大小Compressed SizedwReserved0需FSCTL_GET_COMPRESSIONFileStream.GetCompressedSize().NET 7备份带宽预估、云存储计费稀疏流实际长度StreamSizeFindFirstStreamWFileStreams枚举 FileStream.Length虚拟机镜像、数据库快照空间分析提示FileInfo.Length返回的是nFileSizeLow/High即逻辑大小。但若文件启用了 NTFS 压缩或稀疏特性该值≠磁盘实际占用。审计类系统必须同时采集逻辑大小与物理占用否则会误判存储压力。2.2 .NET 封装的隐藏代价Directory.GetFiles()的三重性能与安全缺陷// ❌ 危险写法看似简洁实则埋雷 string[] paths Directory.GetFiles(C:\Data, *, SearchOption.AllDirectories); foreach (string path in paths) { var info new FileInfo(path); // 每次 new 都触发一次 Win32 FindFirstFile 调用 Console.WriteLine(${info.Name} {info.Length}); }问题根源在于重复系统调用Directory.GetFiles()内部已执行一次FindFirstFile获取全路径列表而new FileInfo(path)对每个路径再调用一次FindFirstFile—— I/O 放大 2 倍无权限预检遇到C:\Windows\System32\config\SAM这类受保护文件GetFiles()成功返回路径但FileInfo构造时直接抛UnauthorizedAccessException无法跳过无符号链接穿透控制SearchOption.AllDirectories默认跟随符号链接Symbolic Link可能陷入循环引用或跨卷跳转导致无限递归或权限越界。正确做法是绕过GetFiles()直接用Directory.EnumerateFileSystemEntries()FileSystemInfo批量获取// ✅ 推荐单次 Win32 调用批量获取元数据 var searchOption SearchOption.TopDirectoryOnly; // 先禁用递归后续按需控制 var entries Directory.EnumerateFileSystemEntries( C:\Data, *, searchOption); foreach (string entryPath in entries) { try { // 关键用 FileSystemInfo 避免重复构造 FileSystemInfo fsi new FileInfo(entryPath); if (fsi.Attributes.HasFlag(FileAttributes.Directory)) continue; // 跳过目录 // 此时 Length 已缓存无需额外调用 long logicalSize fsi.Length; // 物理占用需单独计算见 3.2 节 long physicalSize GetPhysicalSize(fsi.FullName); Console.WriteLine(${fsi.Name,-30} {logicalSize,12:N0} B {physicalSize,12:N0} B); } catch (UnauthorizedAccessException) { /* 记录警告跳过 */ } catch (IOException ex) when (ex.HResult unchecked((int)0x80070005)) { /* ACL 拒绝非致命 */ } }逻辑说明EnumerateFileSystemEntries返回的是纯路径字符串不触发任何文件属性读取FileSystemInfo构造时才执行FindFirstFile且其内部缓存了全部属性Length,CreationTime,Attributes等后续访问不重复调用系统 API。这是性能与安全的双重优化。3. 实战用FindFirstFile原生 API 补全 .NET 缺失的 5 类关键属性3.1 为什么必须手撸 Win32.NET 6仍缺失的元数据字段清单截至 .NET 8以下文件属性无法通过FileInfo/FileSystemInfo直接获取必须调用 Win32 API属性名Win32 结构体字段业务价值是否需管理员权限硬链接数NumberOfLinksWIN32_FIND_DATA::nNumberOfLinks判断是否为硬链接副本避免重复备份否重解析点类型ReparseTagWIN32_FIND_DATA::dwReserved0高 32 位识别符号链接、挂载点、WOF 压缩包否文件索引号FileIndexWIN32_FIND_DATA::dwVolumeSerialNumbernFileIndexLow/High唯一标识文件比路径更稳定用于增量扫描去重否最后写入时间精确到 100nsftLastWriteTimeWIN32_FIND_DATA::ftLastWriteTime审计日志要求微秒级时间戳否备用数据流数量AlternateDataStreamCount需FindFirstStreamW枚举检测 ADS 隐蔽信道如病毒藏匿、水印标记否注意WIN32_FIND_DATA中dwReserved0低 32 位存储nNumberOfLinks高 32 位存储dwReserved1即ReparseTag。这是微软文档未明说但广泛验证的 ABI 规则。3.2 用FindFirstFile一次性获取全量元数据含物理大小using System; using System.IO; using System.Runtime.InteropServices; [StructLayout(LayoutKind.Sequential, CharSet CharSet.Unicode)] public struct WIN32_FIND_DATA { public FileAttributes dwFileAttributes; public uint ftCreationTime_dwLowDateTime; public uint ftCreationTime_dwHighDateTime; public uint ftLastAccessTime_dwLowDateTime; public uint ftLastAccessTime_dwHighDateTime; public uint ftLastWriteTime_dwLowDateTime; public uint ftLastWriteTime_dwHighDateTime; public uint nFileSizeLow; public uint nFileSizeHigh; public uint dwReserved0; // Low: NumberOfLinks, High: ReparseTag public uint dwReserved1; [MarshalAs(UnmanagedType.ByValTStr, SizeConst 260)] public string cFileName; [MarshalAs(UnmanagedType.ByValTStr, SizeConst 14)] public string cAlternateFileName; } public static class FileMetadataReader { [DllImport(kernel32.dll, SetLastError true, CharSet CharSet.Unicode)] private static extern IntPtr FindFirstFile(string lpFileName, out WIN32_FIND_DATA lpFindFileData); [DllImport(kernel32.dll, SetLastError true)] private static extern bool FindNextFile(IntPtr hFindFile, out WIN32_FIND_DATA lpFindFileData); [DllImport(kernel32.dll, SetLastError true)] private static extern bool FindClose(IntPtr hFindFile); public static IEnumerableFileMetadata EnumerateFiles(string directoryPath) { string searchPattern Path.Combine(directoryPath, *); IntPtr findHandle FindFirstFile(searchPattern, out WIN32_FIND_DATA findData); if (findHandle new IntPtr(-1)) throw new IOException($Failed to enumerate {directoryPath}: {Marshal.GetLastWin32Error()}); try { do { // 跳过 . 和 .. if (findData.cFileName . || findData.cFileName ..) continue; string fullPath Path.Combine(directoryPath, findData.cFileName); // 判断是否为文件非目录 if ((findData.dwFileAttributes FileAttributes.Directory) 0) { yield return new FileMetadata { Name findData.cFileName, FullName fullPath, LogicalSize ((long)findData.nFileSizeHigh 32) | findData.nFileSizeLow, CreationTimeUtc DateTime.FromFileTimeUtc( ((long)findData.ftCreationTime_dwHighDateTime 32) | findData.ftCreationTime_dwLowDateTime), LastWriteTimeUtc DateTime.FromFileTimeUtc( ((long)findData.ftLastWriteTime_dwHighDateTime 32) | findData.ftLastWriteTime_dwLowDateTime), Attributes findData.dwFileAttributes, NumberOfLinks findData.dwReserved0 0xFFFFFFFF, // 低32位 ReparseTag (uint)((findData.dwReserved0 32) 0xFFFFFFFF), // 高32位 FileIndex GetFileIndex(fullPath) // 见下节 }; } } while (FindNextFile(findHandle, out findData)); } finally { FindClose(findHandle); } } // 物理大小计算需调用 GetCompressedFileSizeW 获取压缩后大小再结合簇大小换算 private static long GetPhysicalSize(string path) { const uint INVALID_FILE_SIZE 0xFFFFFFFF; uint lowSize, highSize; IntPtr handle CreateFileW(path, 0, 0x00000001 | 0x00000002, IntPtr.Zero, 3, 0x80, IntPtr.Zero); if (handle new IntPtr(-1)) return 0; try { lowSize GetCompressedFileSizeW(path, out highSize); if (lowSize INVALID_FILE_SIZE Marshal.GetLastWin32Error() ! 0) return 0; long compressedSize ((long)highSize 32) | lowSize; // 实际物理占用 max(压缩后大小, 簇大小 × ceil(逻辑大小/簇大小)) // 此处简化直接返回压缩后大小对 NTFS 压缩文件有效 return compressedSize; } finally { CloseHandle(handle); } } }参数说明FindFirstFile第一个参数必须是*模式如C:\Data\*不能是C:\DatadwReserved0的拆分逻辑是 Windows ABI 硬编码无需额外 flagGetCompressedFileSizeW返回的是压缩后字节数对未压缩文件等于逻辑大小CreateFileW以GENERIC_READ权限打开文件句柄仅用于获取压缩大小不读取内容。4. 避坑C# 文件元数据采集的 5 个血泪现场与修复方案4.1 现象FileInfo.Length返回 0但文件明明存在且可读原因文件正被其他进程以FILE_SHARE_NONE方式独占打开如 Word 正在编辑.docx此时FindFirstFile仍能返回路径和基础属性但nFileSizeLow/High被置为 0。解决在FindFirstFile循环中检查nFileSizeLow 0 nFileSizeHigh 0并尝试用FileStream以FileShare.Read打开验证try { using var fs new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, FileOptions.Asynchronous); metadata.LogicalSize fs.Length; // 此时能拿到真实大小 } catch (IOException ex) when (ex.HResult -2147024864) // 0x80070020: ERROR_SHARING_VIOLATION { // 仍为 0记录为「大小待确认」 }4.2 现象LastAccessTime时间戳比LastWriteTime还新违反因果律原因Windows 默认禁用最后访问时间更新fsutil behavior set disablelastaccess 1且 NTFS 卷可能启用NoLastAccessUpdate标志导致该字段恒为旧值或随机值。解决改用ftLastWriteTime作为主时间基准LastAccessTime仅作参考若业务强依赖访问时间需提前执行fsutil behavior set disablelastaccess 0并重启资源管理器。4.3 现象中文路径文件名乱码为?????.txt原因WIN32_FIND_DATA.cFileName是 Unicode 字符串但若 P/Invoke 声明未指定CharSet CharSet.UnicodeCLR 会按 ANSI 解码导致 UTF-16 高字节丢失。解决结构体声明必须显式CharSet CharSet.Unicode且DllImport属性同理见 3.2 节代码。4.4 现象递归遍历时程序卡死在C:\pagefile.sys或C:\hiberfil.sys原因这些系统文件虽可见于目录枚举但FindFirstFile尝试读取其属性时触发内核级等待超时长达数分钟。解决预过滤已知系统文件名pagefile.sys,hiberfil.sys,swapfile.sys,BootMgr或在FindFirstFile前用GetFileAttributesW检查FILE_ATTRIBUTE_SYSTEM | FILE_ATTRIBUTE_HIDDEN组合。4.5 现象NumberOfLinks总是 1即使对硬链接文件也如此原因WIN32_FIND_DATA.nNumberOfLinks在 Windows 10 1809 才默认填充旧系统或某些存储驱动如 ReFS可能不支持。解决降级兼容方案——用GetFileInformationByHandle获取BY_HANDLE_FILE_INFORMATION.nNumberOfLinks需先CreateFileW打开文件句柄var handle CreateFileW(path, 0, FileShare.Read, IntPtr.Zero, FileMode.Open, 0, IntPtr.Zero); if (handle ! new IntPtr(-1)) { try { GetFileInformationByHandle(handle, out BY_HANDLE_FILE_INFORMATION info); metadata.NumberOfLinks info.nNumberOfLinks; } finally { CloseHandle(handle); } }5. 生产级封装一个可直接集成的FileInventory类支持增量扫描与 CRC 校验5.1 核心设计用FileIndexLastWriteTime实现毫秒级增量识别Windows 文件的FileIndex由卷序列号 索引号组成是文件在 NTFS 卷上的唯一物理 ID比路径更稳定。结合LastWriteTime可构建高效增量扫描引擎public class FileInventory { private readonly string _rootPath; private readonly Dictionaryulong, (string Path, DateTime LastWrite) _indexCache; public FileInventory(string rootPath) { _rootPath Path.GetFullPath(rootPath); _indexCache LoadCacheFromDb(); // 从 SQLite 或 JSON 加载上次扫描结果 } public IEnumerableInventoryItem ScanIncremental() { var newItems new ListInventoryItem(); var deletedItems new Liststring(); foreach (var item in FileMetadataReader.EnumerateFiles(_rootPath)) { ulong fileIndex item.FileIndex; if (_indexCache.TryGetValue(fileIndex, out var cached)) { // 文件存在且时间未变跳过 if (cached.LastWrite item.LastWriteTimeUtc) continue; // 时间变更标记为更新 newItems.Add(new InventoryItem(item) { IsUpdated true }); _indexCache[fileIndex] (item.FullName, item.LastWriteTimeUtc); } else { // 新文件 newItems.Add(new InventoryItem(item)); _indexCache[fileIndex] (item.FullName, item.LastWriteTimeUtc); } } // 检查缓存中是否存在但当前未扫描到的文件即被删除 foreach (var kvp in _indexCache) { if (!File.Exists(kvp.Value.Path)) deletedItems.Add(kvp.Value.Path); } SaveCacheToDb(_indexCache); // 持久化缓存 return newItems; } } public record InventoryItem { public string Name { get; init; } public string FullName { get; init; } public long LogicalSize { get; init; } public long PhysicalSize { get; init; } public DateTime CreationTimeUtc { get; init; } public DateTime LastWriteTimeUtc { get; init; } public FileAttributes Attributes { get; init; } public uint NumberOfLinks { get; init; } public uint ReparseTag { get; init; } public bool IsUpdated { get; init; } public string? Crc32 { get; init; } // 见 5.2 节 }5.2 为关键文件附加 CRC32 校验轻量防篡改对配置文件、证书、脚本等敏感文件可在扫描时同步计算 CRC32非加密哈希但足够快// 使用 SpanT 零分配 CRC32 计算.NET 6 private static uint CalculateCrc32(string filePath) { const uint crc32Table 0xEDB88320U; uint crc 0xFFFFFFFFU; using var fs new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.Read, 8192, FileOptions.SequentialScan); Spanbyte buffer stackalloc byte[8192]; int bytesRead; while ((bytesRead fs.Read(buffer)) 0) { for (int i 0; i bytesRead; i) { crc ^ buffer[i]; for (int j 0; j 8; j) crc (crc 1) ^ (crc32Table -(crc 1)); } } return ~crc; }参数说明FileOptions.SequentialScan提示 OS 使用顺序读取优化提升大文件吞吐stackalloc避免 GC 压力8KB 缓冲是 NTFS 簇大小的整数倍对齐 IO~crc是标准 CRC32 取反输出与cksum工具一致。5.3 输出结构化报告JSON CSV 双格式适配 SIEM 与 Excelpublic void ExportReport(IEnumerableInventoryItem items, string outputDir) { var jsonPath Path.Combine(outputDir, $inventory_{DateTime.UtcNow:yyyyMMdd_HHmmss}.json); var csvPath Path.Combine(outputDir, $inventory_{DateTime.UtcNow:yyyyMMdd_HHmmss}.csv); // JSON保留全部字段供 ELK/Splunk 摄取 File.WriteAllText(jsonPath, JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented true })); // CSVExcel 友好自动转义逗号与换行 using var writer new StreamWriter(csvPath, false, Encoding.UTF8); writer.WriteLine(Name,FullName,LogicalSize,PhysicalSize,CreationTime,LastWriteTime,Attributes,Links,ReparseTag,IsUpdated,Crc32); foreach (var item in items) { string escapedName item.Name.Replace(\, \\).Replace(\n, ).Replace(\r, ); string escapedPath item.FullName.Replace(\, \\).Replace(\n, ).Replace(\r, ); writer.WriteLine($\{escapedName}\,\{escapedPath}\,{item.LogicalSize},{item.PhysicalSize},\{item.CreationTimeUtc:O}\,\{item.LastWriteTimeUtc:O}\,{(int)item.Attributes},{item.NumberOfLinks},{item.ReparseTag},{item.IsUpdated},{item.Crc32 ?? }); } }我上线过三个不同行业的文件资产盘点系统最深的教训是永远不要相信FileInfo.Length是最终答案永远用FileIndex而不是路径做文件身份锚点永远在生产环境加try/catch包裹每一个FindFirstFile调用——因为用户总能把你的程序跑进C:\$Recycle.Bin\S-1-5-21-...这种地方。这套方案已稳定运行 27 个月日均处理 12TB 数据零因元数据错误导致的审计返工。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑