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 "size=0" 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.ps1
3.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-8,Windows 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 盘永远有空间。