
C 盘飘红大概是每个用 Windows 的人都会遇到的日常更烦的是想知道“到底哪个文件夹占了空间”往往只能一层层打开资源管理器右键点属性看几秒进度条再点下一层。装 TreeSize、WinDirStat 这类工具确实方便但在公司环境里要申请安装权限流程走完耐心也没了。后来我养成了一个习惯直接跑一段 Windows 脚本把指定路径下所有直接文件/文件夹的大小一次性列出来按大到小排序几秒钟心里就有数。这篇就把我从第一版踩坑到最终可复用脚本的完整过程写出来适合系统运维、开发同学也适合只想把磁盘空间搞清楚的家用用户。1. 需求拆解先搞清楚“所有直接文件/文件夹的大小”到底算什么1.1 关键词里的“直接”二字标题里的“直接”很容易被忽略但恰恰决定了脚本怎么写。所谓“指定路径下所有直接文件/文件夹”意思是只看路径下的第一层子项假设指定D:\Data脚本要列出D:\Data下面每一个子目录和每一个文件而不用把子目录里的三级四级内容单独列出来。这里有个很容易想当然的误区结构上只看一层但大小计算上必须递归到底。一个文件夹的“大小”显然是把它里面所有文件、所有嵌套子目录里的文件全部加起来。只统计第一层文件的大小忽略子目录里的内容那结果毫无意义。我在第一版脚本里就踩过这个坑每个文件夹算出来都是 0因为DirectoryInfo对象本身没有Length属性。所以需求可以拆成两层遍历层面枚举指定路径的直接子项不继续列子目录内部的直接子项。统计层面文件直接拿Length文件夹要递归累加里面所有文件的大小。1.2 典型使用场景这种脚本在实际工作中非常高频至少这几类场景我全遇到过磁盘告警某台服务器 C 盘空间不足登录上去先跑一遍脚本看哪个目录占用最大。备份前评估要迁移数据或做项目预算需要快速比较几个目录的量级。用户目录配额多人共用一台机器扫一遍每个用户目录的大小。日志清理日志目录特别大脚本排序后直接定位到最大的日志子目录再针对性做清理策略。这种场景的共同特点是不需要精确到每一个字节但需要尽快分清主次。先列 Top 20把大头找出来后续再针对可疑目录精细排查。1.3 为什么不自带工具反而要写脚本Windows 自带功能不是不能用而是不适合批量操作。右键属性只能看单个文件夹磁盘管理的“存储感知”面向整个磁盘不会按你的指定路径一层层列出来“磁盘清理”更是只针对系统缓存体系用户很难把它当目录分析器用。第三方分析工具功能强大但至少有三个现实阻力需要安装权限公司环境常常不允许图形界面在远程服务器上体验很差工具本身有学习成本和后台服务驻留。脚本的好处是零依赖Windows 自带的 PowerShell 5.1 就能跑把.ps1文件拷过去直接执行用完即走特别适合批量交付给运维团队共用。2. 方案选型cmd 批处理有几个硬伤PowerShell 才是合适底座2.1 批处理不是不能写但容易写到一半怀疑人生看到“Windows 脚本”很多人第一反应是写.bat。我早年也这么干过但认真写完一个像样的目录统计脚本后就彻底放弃用纯批处理做这类事了。三个硬伤绕不开第一set /a只支持 32 位整数运算。文件大小超过 2GB 就可能溢出而现代硬盘上一个视频文件动辄几个 GB累加几个大文件直接算出负数这结果没人敢信。第二递归逻辑非常别扭。批处理没有一个干净的“遍历目录返回集合”的抽象靠for /r嵌套和dir /s搭配文本解析代码绕来绕去可读性极差。举个例子echo off setlocal enabledelayedexpansion set target%~1 for /d %%d in (%target%\*) do ( set size0 for /r %%d %%f in (*) do ( set /a size%%~zf ) echo %%d !size! )这段批处理看着能跑实际上一堆问题空文件取不到%~zf时可能把变量置空、2GB 以上溢出、文件名带空格或特殊字符时解析错乱、显示结果的分隔符还要自己拼。用批处理解决这个问题是在跟语言本身的短板较劲。第三本地化输出不稳定。dir /s的汇总行在中文系统显示“个文件”英文系统显示File(s)用findstr提取数量时还得区分语言版本换一台系统脚本就失效。2.2 PowerShell 的优势恰好补上这些短板同样是系统自带脚本环境Windows PowerShell 5.1 从 Win7/Win2008 R2 开始就一直内置完全不需要额外安装。它更适合这个任务原生 64 位整数[long]算 TB 级别也不溢出。Get-ChildItem支持递归枚举返回结构化对象可以直接拿Length、Attributes、FullName。Measure-Object -Property Length -Sum一行完成求和。结果可以统一输出为对象后续要排序、格式化、导出 CSV 都非常方便。这几点不是锦上添花而是决定了脚本能写得多稳。尤其是对象管道模型让每一步操作都清晰可见取数据、计算、排序、格式化各管一段调试时在任意步骤一截就能看到中间结果。这和批处理里满屏字符串解析完全是两种开发体验。3. 脚本落地从一条管道命令到带参数的 Get-DirectSize.ps13.1 第一版一条命令先探路文件夹全是 0刚接到这个需求时我先用一条 PowerShell 管道命令试水Get-ChildItem -Path D:\Data -Force | ForEach-Object { if ($_.PSIsContainer) { [PSCustomObject]{ Name $_.Name; Size 0 } } else { [PSCustomObject]{ Name $_.Name; Size $_.Length } } } | Sort-Object Size -Descending | Format-Table -AutoSize结果文件的大小正常所有文件夹的大小都是 0。这个结果直观暴露了问题文件夹的大小必须递归计算。于是进入第二版。3.2 第二版递归函数计算目录总大小递归函数的思路很直接进入一个目录遍历它的直接子项遇到文件就累加Length遇到子目录就调用函数本身再把返回值累加进总大小。代码如下function Get-DirSize { param([string]$DirPath) $total 0L try { $items Get-ChildItem -LiteralPath $DirPath -Force -ErrorAction Stop foreach ($item in $items) { if ($item.PSIsContainer) { # 跳过符号链接和目录联接避免重复计算和死循环 $isLink $item.Attributes -band [System.IO.FileAttributes]::ReparsePoint if (-not $isLink) { $total Get-DirSize -DirPath $item.FullName } } else { $total $item.Length } } } catch { Write-Warning 无法访问目录: $DirPath - $($_.Exception.Message) } return $total }这段函数有几个关键点值得展开。-ErrorAction Stop配合try/catch是为了让权限受限的目录能暴露出来。如果不加 Stop错误会一直被静默吞掉脚本跑完你根本不知道哪些目录没统计进去结果偏小还不自知。加上 Stop 之后每个无法访问的目录都会打印一条警告最终结果里哪些部分可信、哪些部分缺失一目了然。-Force参数也很重要。它让Get-ChildItem包含隐藏文件和系统文件。如果不加用户目录里常见的AppData这类隐藏目录直接不会被枚举统计结果会严重偏低。判定符号链接的那一行是实践里总结出来的$item.Attributes -band [System.IO.FileAttributes]::ReparsePoint。文件夹大小统计最怕的就是遇到 junction 或符号链接指向自己所在的目录树递归会无限循环。跳过这类链接只统计物理存在的真实目录结果才可靠。3.3 第三版单次遍历分组大目录下的性能优化递归函数版逻辑准确但有一个性能问题每进入一个子目录就要调用一次Get-ChildItem目录层级深、子目录多的时候系统调用开销很大。如果只是需要“指定路径下每个直接子项的大小排行”还有一条更快的路一次性递归枚举路径下的所有文件按它们在顶层的归属分组再对每组求和。代码如下$target (Resolve-Path -LiteralPath D:\Data).Path.TrimEnd(\) Get-ChildItem -LiteralPath $target -Recurse -File -Force -ErrorAction SilentlyContinue | Group-Object { $rel $_.FullName.Substring($target.Length).TrimStart(\) $rel.Split(\)[0] } | ForEach-Object { $first $_.Group | Select-Object -First 1 $rel $first.FullName.Substring($target.Length).TrimStart(\) $isDirectFile (-not $first.PSIsContainer) -and ($rel -notmatch \\) [PSCustomObject]{ Name $_.Name Type if ($isDirectFile) { 文件 } else { 文件夹 } Size ($_.Group | Measure-Object -Property Length -Sum).Sum } } | Sort-Object Size -Descending | Format-Table -AutoSize原理解释一下每个文件的FullName去掉目标路径前缀后取第一段路径作为分组键。D:\Data\sub1\1.txt这样的文件相对路径是sub1\1.txt第一段是sub1就和sub1目录下所有文件分到同一组。D:\Data\readme.txt的相对路径没有反斜杠第一段就是文件名本身所以每个直接文件单独成组同时也被识别成“文件”类型。这个方案比递归函数版快得多整棵树只枚举一次文件不反复创建目录枚举对象也不递归调用函数。代价是空文件夹不会出现在结果里——这通常不影响使用因为你关注的是“谁占了空间”空文件夹本来就占不了空间。3.4 整合成正式脚本Get-DirectSize.ps1把两种策略整合到一个脚本里默认用递归函数版保证准确加-Fast参数切换到分组优化版另外支持设置显示条数、导出 CSV。完整源码如下param( [string]$Path ., [int]$Top 20, [switch]$Fast, [switch]$ExportCsv ) function Format-Size { param([long]$Bytes) if ($Bytes -lt 1KB) { return $Bytes B } if ($Bytes -lt 1MB) { return {0:N2} KB -f ($Bytes / 1KB) } if ($Bytes -lt 1GB) { return {0:N2} MB -f ($Bytes / 1MB) } if ($Bytes -lt 1TB) { return {0:N2} GB -f ($Bytes / 1TB) } return {0:N2} TB -f ($Bytes / 1TB) } function Get-DirSize { param([string]$DirPath) $total 0L try { $items Get-ChildItem -LiteralPath $DirPath -Force -ErrorAction Stop foreach ($item in $items) { if ($item.PSIsContainer) { $isLink $item.Attributes -band [System.IO.FileAttributes]::ReparsePoint if (-not $isLink) { $total Get-DirSize -DirPath $item.FullName } } else { $total $item.Length } } } catch { Write-Warning 无法访问目录: $DirPath - $($_.Exception.Message) } return $total } if (-not (Test-Path -LiteralPath $Path)) { Write-Host 路径不存在: $Path exit 1 } if (Test-Path -LiteralPath $Path -PathType Leaf) { Write-Host 这是文件路径请传入目录路径。 exit 1 } $target (Resolve-Path -LiteralPath $Path).Path.TrimEnd(\) $results () if ($Fast) { $results Get-ChildItem -LiteralPath $target -Recurse -Force -ErrorAction SilentlyContinue | Group-Object { $rel $_.FullName.Substring($target.Length).TrimStart(\) $rel.Split(\)[0] } | ForEach-Object { $first $_.Group | Select-Object -First 1 $rel $first.FullName.Substring($target.Length).TrimStart(\) $isDirectFile (-not $first.PSIsContainer) -and ($rel -notmatch \\) [PSCustomObject]{ Name $_.Name Type if ($isDirectFile) { 文件 } else { 文件夹 } Size ($_.Group | Measure-Object -Property Length -Sum).Sum } } } else { foreach ($item in (Get-ChildItem -LiteralPath $target -Force -ErrorAction SilentlyContinue)) { if ($item.PSIsContainer) { $results [PSCustomObject]{ Name $item.Name Type 文件夹 Size Get-DirSize -DirPath $item.FullName } } else { $results [PSCustomObject]{ Name $item.Name Type 文件 Size $item.Length } } } } if ($ExportCsv) { $results | Sort-Object Size -Descending | Export-Csv -Path DirectSize-$(Get-Date -Format yyyyMMddHHmmss).csv -NoTypeInformation -Encoding UTF8 } $results | Sort-Object Size -Descending | Select-Object -First $Top | ForEach-Object { [PSCustomObject]{ 名称 $_.Name 类型 $_.Type 大小 Format-Size -Bytes $_.Size 字节 $_.Size } } | Format-Table -AutoSize这段脚本里有几个细节是我实际打磨过的。先校验路径是否存在再分辨是文件还是目录。很多人输入的路径是复制过来的末尾可能带一个看不见的空格或者引号Test-Path会在第一时间拦住避免后面报错。传入文件路径时直接提示“请传入目录路径”也比让Resolve-Path抛一个突兀的异常友好得多。统一在最后用Format-Size做单位换算。之前我试过直接在对象里存格式化的字符串结果导出 CSV 时只能拿到“1.23 GB”这种文本后续要做数值排序非常麻烦。正确做法是对象里保留原始字节数Size显示层做格式化导出 CSV 也保留原始数值。4. 运行方式与常见报错执行策略、中文乱码、路径带特殊字符4.1 推荐运行姿势脚本保存为Get-DirectSize.ps1后最简单的运行方式是打开 PowerShell 窗口切到脚本所在目录然后执行.\Get-DirectSize.ps1 -Path D:\Data -Top 10如果需要规避系统执行策略限制用-ExecutionPolicy Bypass临时放行只对当前进程生效不修改系统全局配置powershell -NoProfile -ExecutionPolicy Bypass -File C:\scripts\Get-DirectSize.ps1 -Path D:\Data -Top 10在 cmd 或双击bat时也推荐用这个姿势。-NoProfile是为了不加载用户配置避免某些机器上的自定义函数或别名干扰执行。4.2 报错无法加载 ps1因为在此系统上禁止运行脚本这恐怕是新手遇到最多的报错本质是执行策略默认 Restricted。我之前说过用命令行的-ExecutionPolicy Bypass绕过如果想在当前用户级别放开也可以执行Set-ExecutionPolicy -Scope CurrentUser RemoteSignedRemoteSigned意味着本地脚本可以运行从网络下载的脚本必须有数字签名比完全放开安全得多。日常使用这个设置就够了不建议用Unrestricted。4.3 中文乱码问题脚本里混用了中文提示和列名如果编辑器把文件保存为无 BOM 的 UTF-8Windows PowerShell 5.1 有可能把中文字符解读成乱码直接导致脚本语法错误或者输出乱码。解决办法有两个一是用 VS Code 保存文件时选择“UTF-8 with BOM”编码二是脚本内不加中文全部用英文。考虑到最终用户还是希望看到中文输出我建议第一种。PowerShell 7 以后对无 BOM UTF-8 的支持好了很多但在老系统上跑 5.1 时依然推荐带 BOM。4.4 路径带方括号和空格路径里的空格其实还好PowerShell 的引号能处理。真正容易出问题的是方括号比如D:\data[2024]\log。Get-ChildItem -Path默认把方括号当通配符处理路径就找不到了。脚本里统一用了-LiteralPath就是为这个问题留的后门。如果你想手动敲命令也建议全程用-LiteralPath。4.5 结果偏小但没有报错还有一种隐蔽问题脚本跑完没报错但结果明显偏小。常见原因有三个隐藏目录没算进去、符号链接被跳过、权限不足的目录被SilentlyContinue吞掉了。递归函数版遇到权限问题会打印警告所以默认模式下结果可信度更高-Fast模式为了性能用了SilentlyContinue看到可疑结果时建议切回默认模式看警告信息。5. 边界情况踩坑实录权限拒绝、符号链接、隐藏文件5.1 权限拒绝宁可警告不要静默Windows 文件系统里总有那么几个目录当前用户没有读权限比如系统卷信息目录、其他用户的配置文件目录。你用一个普通用户身份跑脚本这些目录会报UnauthorizedAccessException。很多教程喜欢在Get-ChildItem后面挂-ErrorAction SilentlyContinue图省事把错误全部吞掉。我强烈不建议在默认模式这么干。正确的做法是让脚本打印警告告诉你哪个目录没读到。这样你至少能判断如果只少一个无关紧要的目录结果可以接受如果少的是要重点分析的目录你会立刻意识到需要换更高权限的身份执行而不是对着一个偏小的结果做错误判断。实战里我遇到过一种情况同一个目录用普通权限跑出来 20GB用管理员权限跑出来 60GB差距大得吓人。原因就是普通权限没进到几个子目录里。所以权限问题不只是一个报错处理问题它直接决定了统计结果能不能用。5.2 符号链接和目录联接防止重复计算和死循环Windows 下的符号链接和目录联接junction是隐藏很深的坑。典型场景是 C 盘用户目录里某些文件夹被重定向到 D 盘或者网盘同步目录建了链接指向本地其他位置。如果不做处理脚本可能遇到两种问题链接指向的目录在目标路径之外结果里重复计入不该算的空间。链接指向父目录或自身形成环递归无限循环脚本卡死。我在递归函数里通过ReparsePoint属性判断并跳过所有链接这是最稳妥的做法。代价是链接指向的目标内容不会被统计但这类内容在物理上往往不属于当前目录树跳过反而更符合“计算该路径下目录大小”的语义。这里也解释一下为什么-Fast模式没有显式跳过链接。Get-ChildItem -Recurse对链接目录的跟随行为在不同系统版本上有差异万一遇到特殊目录用-Fast跑出来的结果可能会包含链接内容。所以涉及链接较多的目录默认模式的结果优先级更高。5.3 隐藏文件和系统文件不加 -Force 等于白跑Windows 资源管理器里隐藏文件和系统文件默认是不显示的。很多人统计目录大小时继承了这种“视觉盲区”脚本也没加-Force结果把一个 200GB 的目录统计成 80GB。最典型的例子就是用户目录下的AppData它默认隐藏却常常是整个用户目录里最大的部分。脚本里的Get-ChildItem统一加了-Force就是强迫自己不要忽略这类目录。统计磁盘占用时物理文件只要存在于磁盘上不管隐藏属性如何都应该算进去。5.4 空文件夹和空文件不显示的意外-Fast模式因为基于“文件分组”空文件夹不会出现在结果里。这在大多数场景下合理但也有例外有人想确认目录结构里到底有多少空文件夹用来做清理参考。这种需求就得回到默认模式因为它是枚举直接子项空文件夹也会被列出来并把大小计为 0。另一个小细节是 0 字节文件。这些文件会正常出现在结果里大小显示为 0 B排序后通常会沉到底部。如果想清理大量小文件建议保留这些结果数量积少成多也值得关注。6. 性能优化与右键菜单扩展大目录下的实测经验和进阶玩法6.1 慢的根源重复枚举成了性能瓶颈我在一个文件数量超过 30 万的目录上试过递归函数版完整跑完要等很久。分析下来瓶颈很明确每个子目录都要重新调用一次Get-ChildItem几十万文件分布在几万个目录里就会产生几万次系统枚举调用大部分时间花在创建枚举对象、排序目录项、解析元数据上。针对这类场景两个原则很重要尽量把-Path缩小到目标子目录。比如你知道是某个项目目录很大就不要从整个盘符开始扫先把范围缩小一圈。关注数量级而不是精确字节。使用-Fast模式拿到 Top 排序定位到最有嫌疑的几个目录后再单独用默认模式细算。这个组合拳比任何参数调优都有效。6.2 -Fast 模式为什么快单次遍历与流式处理-Fast模式的核心是一次性递归枚举所有文件然后按顶层归属分组汇总。相比递归函数版它少了反复创建目录枚举对象的开销而且整个管道是流式的文件被枚举出来后马上进入分组阶段内存压力也小。实测在同样的目录上-Fast模式快一个数量级也不夸张。代价前面说过空文件夹不显示链接目录可能被跟随。所以我的建议是日常快速判断用-Fast严谨统计用默认模式两者互补而不是替代。6.3 用 Robocopy 核对目录总大小如果你需要验证整个路径的总大小是否准确可以用系统自带的 Robocopy 做交叉核对。Robocopy 的/L参数表示只列出不真正复制输出里会给出文件大小汇总robocopy D:\Data NULL /L /S /BYTES /XJ /NFL /NDL /NJH /NJS其中/BYTES让输出使用纯字节/XJ排除 junction 点/NFL和/NDL不显示每个文件和目录/NJH和/NJS不显示作业头和摘要最终只留汇总信息。这个方法用来验证脚本结果是否在一个合理范围内很实用尤其是刚部署脚本时我会先用它做一次交叉验证确认脚本没有系统性漏算。6.4 右键菜单扩展在文件夹上直接运行脚本好不好用很大程度上取决于调用是否方便。我后期把脚本注册到了资源管理器右键菜单在任意文件夹上点右键选择“计算文件夹大小”脚本就会自动以该目录为-Path执行。注册表操作如下保存为.reg文件导入即可Windows Registry Editor Version 5.00 [HKEY_CLASSES_ROOT\Directory\shell\SizeCalc] 计算文件夹大小 [HKEY_CLASSES_ROOT\Directory\shell\SizeCalc\command] powershell -NoProfile -ExecutionPolicy Bypass -File \C:\\scripts\\Get-DirectSize.ps1\ -Path \%V\注意.reg文件里的双引号和反斜杠都要转义路径里的\\scripts\\是注册表格式的要求。导入后右键文件夹即可看到菜单项。如果不想要了删除注册表里对应的SizeCalc键即可。6.5 配合清理脚本做自动化脚本还有一个进阶用法接入定时任务。比如每周跑一次把结果导出 CSV再对超过某个阈值的目录执行归档或清理。我这边接过一个实际需求日志目录超过 10GB 自动删除 7 天前的旧日志。实现思路就是在ExportCsv输出的基础上再加一段按日期过滤并执行删除的逻辑统计分析让Get-DirectSize.ps1完成清理动作单独写脚本职责清晰出问题时也好排查。自动化场景下更推荐使用默认模式因为它对权限问题有明确警告不会在静默状态下做出错误的清理决策。定时任务里执行时建议统一用-ExecutionPolicy Bypass的完整命令避免任务计划程序的执行环境策略差异导致脚本跑不起来。最后说点大实话这个脚本我在自己工作机上至少用了半年最大的收获不是那几行代码而是“分层排查”的思路先用快模式排序定位嫌疑目录再对重点目录用慢而准的模式细算中间穿插权限检查和符号链接判断。很多看起来“玄学”的占用异常最后都是某个隐藏目录或者链接目录在作怪脚本里显式处理这些边界比出了问题再猜要省心得多。如果你第一次跑脚本就发现某个目录大得离谱先别急着下结论重点看一下它的子目录里有没有指向其他盘的 junction再用管理员身份跑一遍对比结果。祝你的 C 盘永远有空间。