解压一个别人发来的压缩包,进度条走完、软件回一句"Everything is Ok",打开目录却发现少了两个文件;想删掉一个列得出名字的文件,
del只回一句"文件名、目录名或卷标语法不正确",资源管理器里看得见它,双击却说找不到。保留名是 Windows 从 DOS 时代继承下来的一批设备别名(CON、PRN、AUX、NUL、COM1 到 COM9、LPT1 到 LPT9),它们在任何一个目录里都被当成设备而不是文件;非法字符是另外九个被路径语法占用的字符。这两类名字平时碰不到,一旦从压缩包、备份盘、Mac 或 Linux 共享里进到 Windows,就会出现"看得见、打不开、删不掉"的三件套。这篇把三件事拆开讲清楚:规则到底禁了哪些名字、为什么禁了还能落到磁盘上、以及各家解压工具遇到它们时的四种不同行为(含逐个的报错原文与退出码)。规则原文取自微软官方文档中文版,其余每一句结论都来自一台 Windows 10 专业版 22H2(内部版本 19045.6466)实机的复现记录。信息核验时点:2026-11-02(Windows 10 专业版 22H2,内部版本 19045.6466;Windows PowerShell 5.1.19041.6456;.NET Framework 4.8;系统自带解压与打包命令为
C:\Windows\System32\tar.exe,实测版本 bsdtar 3.5.2、libarchive 3.5.2;命令行里的7z由 NanaZip 7.0(version 2610.2,x64)提供;这台机器上直接敲tar命中的是 Git 附带的 GNU tar 1.35,它读不了 zip,实测退出码 128)。本文引用的微软文档页《命名文件、路径和命名空间》中文版在核验时点可访问,页面标注"上次更新时间为 2024-08-28";文中报错文字、退出码、落地文件名与改名结果都来自同一台机器上的同一批测试,未做重启后的复测,也没有在非管理员权限之外的环境验证过。
本文要点
- 一句话定义:保留名是设备别名而不是文件名,官方列出的名单是 CON、PRN、AUX、NUL、COM1 至 COM9、LPT1 至 LPT9,外加 COM 与 LPT 跟上标数字 ¹ ² ³ 的写法;官方原话是"另请避免这些名称紧跟扩展名;例如,NUL.txt 和 NUL.tar.gz 都等效于 NUL"。非法字符是另一批:尖括号、冒号、双引号、正斜杠、反斜杠、竖线、问号、星号,共九个。
- 核心结论:拒绝这些名字的是 Win32 的字符串解析层,不是 NTFS。所以加上扩展长度前缀
\\?\就能造出名为 CON.txt 的真文件,它被dir /b原样列出、普通接口却一个都打不开,del和ren也不认。能删掉它的只有带前缀的三条命令;反过来,尖括号那批字符即使加前缀也造不出来,NTFS 自己就禁。 - 解压时的四种结果:同一个含 6 个条目的测试包,PowerShell 的
Expand-Archive一声不响地解出 0 个文件;系统自带的 bsdtar 退出码 0、把不可达的 CON.txt 原样写到盘上;7z x(NanaZip 7.0)退出码 0 且自动改名成_CON.txt、a_b.txt、tail.txt_,落地全部可用;资源管理器干脆把坏条目藏起来,逐个拖出来等 60 秒也是 0 个文件。 - 适用场景:解压同事或客户发来的工程包后文件数对不上、备份还原后多出删不掉的文件夹、从 Mac 或 Linux 共享拷回来的目录里有奇怪名字、脚本里读文件永远报"路径不存在"。
- 短板与边界:本文所有实测都在本地 NTFS、同一台机器、同一个非管理员会话完成,网络共享(UNC)与 exFAT 优盘上的行为没有覆盖;
\\?\前缀那条删除路径不进回收站,误删无法回滚;上标数字与备用数据流只做了"能不能建、谁看得见"这一层,没测第三方软件怎么处理。
哪些名字 Windows 根本不让你用来命名文件?
官方文档《命名文件、路径和命名空间》把禁用的东西分成三类:整条名字被占用(保留名)、名字里某个字符被占用(保留字符)、名字的头尾被限制(结尾的点与空格)。三类的报错措辞完全不同,先分清楚,后面每一步都知道自己在跟哪一层打交道。
第一类:保留名。 官方原文:"不要将以下保留名称用于文件的名称:CON、PRN、AUX、NUL、COM1、COM2、COM3、COM4、COM5、COM6、COM7、COM8、COM9、COM¹、COM²、COM³、LPT1、LPT2、LPT3、LPT4、LPT5、LPT6、LPT7、LPT8、LPT9、LPT¹、LPT²、LPT³。另请避免这些名称紧跟扩展名;例如,NUL.txt 和 NUL.tar.gz 都等效于 NUL。"
同一页还有一条注意:"Windows 可将 8 位 ISO/IEC 8859-1 上标数字 ¹、² 和 ³ 识别为数字,并将其视为 COM# 和 LPT# 设备名称的有效部分,使其保留在每个目录中。例如,echo test > COM¹ 无法创建文件。"
这一串看着像历史包袱,实机上每一条都对得上。用 .NET 的 WriteAllText 挨个试,CON、CON.txt、con.txt、PRN、AUX、NUL、NUL.txt、COM1、COM9、COM1.txt、LPT1、LPT9、aux.log 十三个名字全部失败;上标的三种写法 COM¹.txt、LPT²、COM³.log 也一样失败,报错里连那个上标字符都原样打了出来(未能找到文件 \\.\COM¹)。用 cmd 建目录,md CON、md PRN、md CON.d、md AUX、md aux.txt 全部退出码 1,回一句"目录名称无效。"
第二类:保留字符。 官方列了九个,原样抄回来(这三个符号在 markdown 里会被吃掉,所以放进代码块):
< (小于)
> (大于)
: (冒号)
" (双引号)
/ (正斜杠)
\ (反斜杠)
| (竖线或管道符)
? (问号)
* (星号)同一句还补了两条:"整数值零,有时称为 ASCII NUL 字符"和"字符的整数表示范围介于 1 到 31 之间,但在备用数据流中允许使用这些字符"。实机结果:a<b.txt、a>b.txt、a"b.txt、a|b.txt、a?b.txt、a*b.txt 六个名字,普通接口全部报"路径中具有非法字符。";名字里插一个控制字符(编号 1 或 31)也报同一句。
冒号是唯一的例外,它不是"非法",而是另有含义——后面第六节专门讲。
第三类:头尾限制。 官方原文:"不要用空格或句点结束文件或目录名称。尽管基础文件系统可能支持此类名称,但 Windows Shell 和用户界面不支持。不过,指定句点作为名称的第一个字符是可接受的。例如,".temp"。"
这一段是三句话拼成的三个事实,实测分别是:
- 用普通接口创建
tail.会"成功",但盘上落地的名字是tail——结尾那个点被直接裁掉了,你拿不到自己以为的那个名字。 - 句点开头的
.temp真能建出来,dir /b正常列出。名字中间连续两个点也没问题,x..txt、a..b.txt都能建。 - 反过来,用扩展长度前缀强行造出一个以点开结尾的文件,普通接口既打不开也删不掉,而且加不加那个点都报"找不到"。
最后一句"不区分大小写"也是规则的一部分。官方在命名约定里写"不要假定区分大小写。例如,将名称 OSCAR、Oscar 和 oscar 视为相同",实测 con.txt 和 CON.TXT 的待遇一模一样,都被拒。
保留名明明不让建,为什么还能出现在硬盘上?
因为拦你的是 Win32 的字符串解析,不是磁盘。
官方文档里那段关于设备命名空间的说明解释了原因:"例如,如果要打开系统的串行通信端口 1,可以在调用 CreateFile 函数时使用"COM1"。这样做是因为 COM1-COM9 是 NT 命名空间中保留名称的一部分。"这些名字在每个目录里都会被先解释成设备,再考虑文件。而另一段说明了后门:路径字符串的 \\?\ 前缀"会告知 Windows API 禁用所有字符串分析,并将其后面的字符串直接发送到文件系统"。
把这句话当开关用,就能造出规则说"不许有"的文件。实机上用 \\?\ 前缀挨个创建那批保留名,每一次调用都返回成功,盘上确实有文件、也确实有内容:
[System.IO.File]::WriteAllText('\\?\D:\tmp\l51\t1\CON.txt', 'reserved-CON.txt')十三次创建调用全部返回成功,但盘上只落地 12 个文件——CON.txt 和 con.txt 在同一个目录里本来就是同一个名字,官方那句"不要假定区分大小写。例如,将名称 OSCAR、Oscar 和 oscar 视为相同"正好解释了这件事。这 12 个文件,PowerShell 不带前缀的 Get-ChildItem 数出来是 12 项,带 \\?\ 前缀数出来也是 12 项,名字原样是 AUX、aux.log、COM1、COM1.txt、COM9、CON、CON.txt、LPT1、LPT9、NUL、NUL.txt、PRN;另一次测试里,dir /b 也把前缀造出来的 NUL.log 原样列了出来。也就是说:创建时拦你、列举时不拦你,这是后面所有"看得见却打不开"的根。
那批非法字符却不一样。同一个前缀下再试一次,a<b.txt、a>b.txt、a"b.txt、a|b.txt、a?b.txt、a*b.txt 六个全部失败,报错从"路径中具有非法字符"换成了"文件名、目录名或卷标语法不正确。"——前缀关掉的是 Win32 的字符串解析,关不掉 NTFS 自己的字符规则。所以这两类名字要严格分开记:
| 名字类型 | 普通接口能不能建 | 带 \\?\ 前缀能不能建 | 建好后普通接口能不能用 |
|---|---|---|---|
| 保留名 CON / NUL / COM1 等 | 不能,报设备或找不到文件 | 能 | 不能,读、改名、删除全部失败 |
| 非法字符 尖括号、问号、星号等 | 不能,报路径中具有非法字符 | 也不能,报语法不正确 | 不存在这种情况 |
| 冒号 | 不能,报不支持给定路径的格式 | 能,但变成备用数据流 | 主名能用,流要用专门参数 |
| 结尾的点或空格 | "能",但名字被裁短 | 能,点是真名的一部分 | 不能,带不带那个点都报找不到 |
把保留名放在路径中间也照样被拒——用普通接口 CreateDirectory 一个 CON\sub 这样的路径,直接报"目录名称无效。",连父目录都不给建。
目录这一层还有三种截然不同的下场,实机上都复现过:
md CON退出码 1,报"目录名称无效。"——和普通文件一样被解析层拦住。md NUL退出码 0、什么错都不报,但盘上什么都没多出来:紧接着if exist NUL\回 MISSING,.NET 的普通枚举和前缀枚举都是 0 项。"命令成功"不等于"目录建好了",这是最容易骗过批量脚本的一种。- 换成前缀方式真把 CON 目录建出来了(前缀查询 Exists=True),但普通接口
Test-Path回 False,用dir /b去列它里面的内容回"找不到文件",rmdir /s /q CON报"拒绝访问。"——只有带\\?\前缀的删除才真的把它清掉。
还有一条值得记:这类文件不是只有"手工折腾"才会出现。robocopy 实测能把一个前缀造出来的 AUX.dat 原样复制到目标目录(退出码 1,官方定义"全部复制成功"),复制完目标里 dir /b 看得见 AUX.dat、普通接口 File.Exists 却是 False。备份、迁移、全盘拷贝这类批量工具,就是这些名字最现实的来源。
这种文件就在眼前,读它会得到什么?
三种下场,取决于那个名字对应的是哪种设备。这一节的结果最值得先记,因为它决定了你是否会以为自己"备份成功了"。
CON 会把你挂住。 CON 是控制台设备,读它就是等键盘输入。实机上对前缀造出来的 CON.txt 执行 type,给的 5 秒超时到点被强杀,进程始终没退出;换成 copy 把它拷到别处,同样超时。用 7z a 打包一个含 CON.txt 的文件夹,给的 30 秒超时到点被强杀;同样的命令换成 20 秒和 60 秒两轮,也一次都没返回过。强杀之后留下一个被占用、删不掉的 0 字节 zip,还得先把 NanaZip 的宿主进程(任务管理器里叫 NanaZip.Universal.Console.exe)结束,那个目录才删得干净。"命令卡住不报错"是 CON 类名字最典型的症状,而不是弹窗。
NUL 会静默吞掉内容。 NUL 是空设备,读它永远得到 0 字节。对前缀造出来的 NUL.log(里面确实写着 payload-text)执行 type,退出码 0、屏幕上一个字都没有;同一个文件改用 .NET 的 ReadAllText 配前缀路径去读,立刻拿到 payload-text。这是最危险的一种:命令成功、退出码 0、内容没了。
其余的多半是"找不到"。 同一个 NUL.log 交给别的读法,措辞各不相同:
more NUL.log → 无法访问 \\.\NUL 文件
certutil -dump → 系统找不到指定的文件。 0x80070002 (WIN32: 2 ERROR_FILE_NOT_FOUND)
.NET ReadAllText → 要求 FileStream 打开一个不是文件的设备。如果需要对"com1:"或"lpt1:"等
设备的支持,请调用 CreateFile……
PowerShell 读取 → 找不到路径"D:\tmp\l51\t2\CON.txt",因为该路径不存在。
删除 → 文件名、目录名或卷标语法不正确。
改名 → 系统找不到指定的文件。
.NET File.Delete → 对路径"\\.\CON"的访问被拒绝。最后那句报错里的 \\.\ 值得解释一句,否则很容易被当成路径写错了。\\.\ 是设备命名空间的前缀,官方的定位是:如果使用 Windows API 函数,则应使用 \\.\ 前缀来访问设备,而不访问文件。普通接口在处理保留名时会绕到这里去,于是报错里冒出来的是一串看着像设备名的东西。同一台机器上 PRN、COM9、LPT1、LPT9 的创建失败信息正是"未能找到文件 \\.\PRN"这种形状。看到 \\.\ 出现在报错里,基本可以断定是保留名,不是路径拼写问题。
同一个压缩包,四个解压入口为什么给出四种结果?
这是最常用、也最容易误判的场景。测试包用一个 715 字节、6 个条目的 zip,条目名是手工造的(zip 格式本身不限制这些字符,任何语言都能写进去):
readme.txt
logs/CON.txt
logs/NUL.log
data/LPT1.txt
data/a<b.txt
notes/tail.txt.这六条把三类坑全占齐了:三个保留名、一个非法字符(左尖括号)、一个以点开结尾。C:\Windows\System32\tar.exe -tvf 和 7z l 都能把它们原样列出来,Python 的 zipfile.namelist() 也一样:
['readme.txt', 'logs/CON.txt', 'logs/NUL.log', 'data/LPT1.txt', 'data/a<b.txt', 'notes/tail.txt.']四个入口解到空目录,结果如下。
| 入口 | 退出码与提示 | 落地结果 | 能不能普通删除 |
|---|---|---|---|
PowerShell 的 Expand-Archive | 不报错,一个字都不输出 | 0 个文件,连 readme.txt 都没有 | 目录是空的,随便删 |
.NET 的 ExtractToDirectory | 抛异常"路径中具有非法字符。" | 0 个文件 | 同上 |
系统自带 tar.exe(bsdtar 3.5.2) | 退出码 0,无提示 | 6 个文件全落地,保留名原样写入 | 不能,见下一节 |
7z x(NanaZip 7.0) | 退出码 0,"Everything is Ok Files: 6 Size: 77" | 6 个文件全落地,名字被自动改 | 能,rd /s /q 一句就干净 |
7z 的改名规则实测很清楚,落地名分别是 readme.txt、_CON.txt、_NUL.log、_LPT1.txt、a_b.txt、tail.txt_——保留名前加下划线、非法字符换成下划线、结尾的点换成下划线。改完的每一个名字都能普通读取、普通列出、普通删除,那次测试最后用一句 rd /s /q "D:\tmp\l51\y5" 退出码 0 就整棵树清干净了。四个入口里只有它把坑填平了。
bsdtar 的处理只对了一半。 它对字符类做了替换(a<b.txt 变成 a_b.txt,tail.txt. 丢掉结尾的点变成 tail.txt),但保留名照原样写盘。于是落地的是真·不可达文件:dir /b logs 列出 CON.txt 和 NUL.log,copy 第一个却超时,del 报"文件名、目录名或卷标语法不正确。"。更麻烦的是清理那一步:
rd /s /q "D:\tmp\l51\y3"
D:\tmp\l51\y3\data\LPT1.txt - 系统找不到指定的文件。
D:\tmp\l51\y3\logs\CON.txt - 拒绝访问。
D:\tmp\l51\y3\logs\NUL.log - 拒绝访问。这条命令的退出码是 0,输出里却有三次失败,目录也还在——rd 把进不去的项跳过去,不把它算成错误。同一棵目录改用带前缀的写法 rd /s /q "\\?\D:\tmp\l51\y3",退出码 0、一个字不输出、目录真的没了。
资源管理器则是第三种失败方式:它让你看不见。 双击打开这个 zip,Shell 自己列出的顶层条目只有 3 个(readme.txt、logs、data),notes 整个文件夹都不出现——因为里面唯一那个成员的名字以点开结尾。进 data 只看见 LPT1.txt,那个 a<b.txt 也不出现。把能看见的保留名条目逐个拖出来(测试里用 Shell 自动化接口逐项复制),60 秒后落地 0 个文件、也没有任何弹窗;同一个位置换成事先已经把名字改干净的对照包,6 秒落地 2 个文件。
所以"解压后文件数对不上"这一类反馈,先别怀疑包损坏。同一份包,四个入口给出的文件数可以是 0、0、6、6,而只有第三种是能用的。
顺便解释那句奇怪的 .NET 报错。测试时把这个包拆成几种只含一类名字的子包,用 .NET 的 ZipFile 打开数条目,结果是这样:
z_clean.zip 条目 2 readme.txt / logs/plain.log
z_reserved.zip 条目 3 readme.txt / logs/CON.txt / logs/NUL.log
z_reserved_root.zip 条目 2 readme.txt / LPT1.txt
z_illegal.zip 条目 0 (包里有 data/a<b.txt)
z_dot.zip 条目 2 readme.txt / notes/tail.txt.
z_space.zip 条目 2 readme.txt / notes/tail .txt保留名条目 .NET 能枚举(所以 Expand-Archive 解只含保留名的包时,抛的是另一句不相干的话:"无法将参数绑定到参数"LiteralPath",因为该参数是空值。");但只要包里有一个含尖括号的条目,.NET 就把整个包看成 0 个条目,于是一切基于它的工具——Expand-Archive、Compress-Archive、大部分 .NET 写的解压与扫描程序——不是"解错",而是"根本没看见"。这就是为什么有些工具显示"这个压缩包里是空的"。
反过来打包时,三个工具各是什么表现?
坑不只发生在解压端。如果一个目录里已经有一个 CON.txt(备份还原、别的系统拷回来都会造成这种状态),把它打包时:
Compress-Archive直接抛异常,不产出文件。实测抛的是 IOException,内容里带着那个保留名的完整路径(D:\tmp\l51\t11\CON.txt这样的形状),说它"不存在,或者不是有效的文件系统路径"。指定通配符压缩整个目录也一样。失败后目标 zip 根本不存在,不会留下半个包。7z a会卡在 CON 上。同一条7z a -tzip 目标.zip 目录\*,换成 NUL.txt 和一个普通文件都能正常打完(退出码 0,"Everything is Ok"),只有 CON.txt 那次 30 秒不返回。原因和第四节一致:它按文件名去读数据,读到的是控制台设备。C:\Windows\System32\tar.exe -czf一声不响地把毒传下去。退出码 0,tar -tzvf列出来的成员表里明明白白写着./CON.txt和./normal.txt。把这个包在另一台目录里解回去,dir /b列出 CON.txt,普通删除报"拒绝访问"。也就是说:你在自己机器上打包成功,同事那边解压就中招,包本身没有任何异常。
还有一个方向相反的细节:如果那个文件真的带备用数据流,打包会把流丢掉。实机上给 book.xlsx 挂一条名为 hidden.txt 的流,tar -czf 之后的成员表里只有 ./book.xlsx(9 字节,主流内容),7z a 也只收 1 个文件、9 字节。两条路径的流数据都没有进包,也没有任何提示。
一句话总结打包端:想把有怪名字的目录发给别人,先改名再打包,不要指望压缩软件会替你在包里改名——它们要么拒绝,要么卡住,要么原样传出去。
已经落地的坏名字文件,怎么删掉?
顺序按"代价从小到大"给三条,都在实机上跑过。
第一条:单文件用带前缀的 del。 这是最短的一条,实测有效:
del "\\?\D:\tmp\l51\t10\logs\CON.txt"
del "\\?\D:\tmp\l51\t10\logs\NUL.log"跑完同一个目录里只剩下那个正常文件。注意两点:路径必须是绝对路径(前缀不支持相对写法),而且不进回收站,删了就是删了。
第二条:整棵目录用带前缀的 rd。
rd /s /q "\\?\D:\tmp\l51\y3"退出码 0,无输出,目录不存在了。对照普通写法 rd /s /q "D:\tmp\l51\y3" 的"退出码 0 但三项失败、目录还在"——这就是第四节那段输出的用处:别只看退出码,要再看一次目录在不在。
第三条:想保住内容,就用带前缀的 ren 改名,之后一切恢复正常。
ren "\\?\D:\tmp\l51\y4\logs\CON.txt" CON_safe.txt
ren "\\?\D:\tmp\l51\y4\data\LPT1.txt" LPT1_safe.txt
ren "\\?\D:\tmp\l51\y4\logs\NUL.log" NUL_safe.log三条退出码都是 0。改完之后 dir /b 里是 CON_safe.txt、LPT1_safe.txt、NUL_safe.log,普通 del 立刻能删,普通 rd /s /q 也能整棵清掉。如果那个文件是你要留的数据,选第三条而不是前两条。
明确无效的几条,都实测过,别再花时间试:
| 试过的做法 | 实测结果 |
|---|---|
del 普通路径 | 退出码 123,"文件名、目录名或卷标语法不正确。" |
ren 普通路径 | 退出码 1,"系统找不到指定的文件。" |
PowerShell 的 Remove-Item 加 \\?\ 前缀 | 不报错,但文件还在(同一批测试里,它对普通名字的文件是真删掉了) |
.NET 的 File.Delete 普通路径 | 抛"对路径 \\.\CON 的访问被拒绝。" |
.NET 的 File.Delete 带前缀 | 成功,这是脚本里可用的写法 |
用 subst 把父目录映射成短盘符再删 | 无效:del "W:\CON.txt" 还是那句"语法不正确",ren 还是"找不到文件" |
倒数第二条值得说一句。.NET 的删除接口认前缀,PowerShell 的 Remove-Item 不认——它把前缀当普通字符串处理后没找到匹配项,于是既不报错也不干活。这是"我明明删了、文件还在"最省事的一种成因,脚本里宁可用 [System.IO.File]::Delete() 也别用 Remove-Item。
最后一条也值得说:subst 是路径太长那一篇里的头号解法,但在这里完全不管用。因为 260 字符那道坎量的是"路径字符串长度",而这里断的是"名字解析"——门牌换短了,名字照样被解释成设备。同一台机器上,那个前缀造出来的 CON.txt 从映射盘符里 dir /b 看得见,删除照样失败,只有 del "\\?\W:\CON.txt" 这种带前缀的写法能删掉它。
怎么在解压之前就把坏名字扫出来?
分两个位置:包里的名字和已经在盘上的名字。
看包里的名字,用能看见它们的读法。 上一节已经说明 .NET 那套会漏,所以列条目名要用这三条里任意一条:
C:\Windows\System32\tar.exe -tvf 包.zip
7z l 包.zip
python -c "import zipfile;print(zipfile.namelist())"三条实测都能原样列出 logs/CON.txt、data/a<b.txt、notes/tail.txt.。不方便用命令行时,本站的ZIP 打包与解压页面也能把包内条目名列出来。
扫盘上的名字,用这三个模式。 实测可用,判据正好覆盖前面三类:
$rxRes = '(?i)(^|[\\/])(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)'
$rxBad = '[<>:"/\\|?*]'
$rxTail = '[ .]$'三条的含义:第一条只在路径组件的开头到结尾整段匹配名字,避免误伤 MYCON、CONSOL 这种含子串的正常名字(实机验证过它们本来就能创建);第二条抓九个保留字符;第三条抓结尾的点或空格。注意 $rxRes 里带了 com[1-9] 和 lpt[1-9],不是 com[0-9]——实测 md COM0.txt、md COM56.txt、md LPT56.txt 都能建成功,官方对这一点有解释:COM56 这类编号"没有预定义的 NT 命名空间",所以不在保留名名单里。
扫描整棵目录树的写法,实测能从前缀造出的三个文件里准确标出两个保留名(普通名字那个不报):
$root = 'D:\tmp\需要检查的目录'
$rxRes = '(?i)(^|[\\/])(con|prn|aux|nul|com[1-9]|lpt[1-9])(\.|$)'
Get-ChildItem -LiteralPath ('\\?\' + $root) -Recurse -Force |
Where-Object { $_.Name -match $rxRes } |
ForEach-Object { $_.FullName.Substring(4) }关键在于 Get-ChildItem 那个 \\?\ 前缀。 不加前缀时,深层或超限的目录会整棵被跳过;加了前缀才是"照着磁盘实际的样子数一遍"。同一批测试里,普通 dir /b 和带前缀的递归枚举都列得出保留名文件,但后续的读写删要用前缀才认。
顺手给一条"扫出来就删掉"的组合,实测跑完目录里只剩正常文件:
Get-ChildItem -LiteralPath ('\\?\' + $root) -Recurse -Force |
Where-Object { $_.Name -match $rxRes } |
ForEach-Object { cmd /d /c ('del "' + $_.FullName + '"') }$_.FullName 在这个管道里已经带着前缀,所以能直接喂给 del。这条写法不进回收站,先在测试目录上跑一遍。
冒号和结尾的点、空格,各自是什么坑?
冒号在 NTFS 上不是非法字符,是备用数据流的分隔符。 官方在保留字符那一段就留了余地:"字符的整数表示范围介于 1 到 31 之间,但在备用数据流中允许使用这些字符。"实机验证:
普通接口创建 a:b.txt → 不支持给定路径的格式。
带前缀创建 q1.xlsx:evil.txt → 成功
dir /r 的显示 → 10 q1.xlsx
10 q1.xlsx:evil.txt:$DATA
Get-Item -Stream * → :$DATA, evil.txt也就是说它没有变成一个"怪文件",而是挂在另一个文件身上的一条流。主文件用普通方式读,拿到的还是主流内容,看不见那条流;打包时它会被整个丢掉(上一节实测)。这有两层现实含义:一是包里如果有 docs/report.xlsx:tmp.log 这种条目,解出来不会变成两个文件;二是流里的内容不参与常规读取、也不参与常规备份,安全扫描工具是否检查流内容取决于它自己——这一点本文没有实测,只把"流容易被漏"这个事实摆出来。
那三个解压入口对冒号条目的实际处理是这样:tar.exe 和 7z x 都把冒号换成了下划线,落地名都是 report.xlsx_tmp.log,内容原样、可以普通读写;Expand-Archive 因为创建 .ctor 时就失败,整个包 0 个文件落地(包括那个名字正常的 readme.txt),但 powershell.exe 进程的退出码仍然是 0。
结尾的点更阴。 官方那句"尽管基础文件系统可能支持此类名称,但 Windows Shell 和用户界面不支持"是整篇里最能解释现象的一句话。实测拆成四个现象:
- 用普通接口创建
tail.,创建"成功"、盘上却是tail——你以为的名字不是真名字。 - 用前缀造出真的
tail.txt.,普通接口对它加不加那个点都报找不到:File.Exists('rep.txt.')是 False,File.Exists('rep.txt')也是 False,del rep.txt.回"找不到",只有带前缀才删得掉。 dir /b却能把tail.txt.原样列出来——列表用名字、打开用另一套解析,两边不一致。- 三个入口处理不一致:
Expand-Archive解出来是notes\tail.txt,tar.exe解出来也是tail.txt(点被丢掉,如果同目录里本来就有tail.txt,就成了覆盖),只有7z x换成tail.txt_。资源管理器更彻底:那个只装着notes/tail.txt.的 notes 目录在 zip 视图里直接不出现。
空格同理但更常见:tail.txt (结尾一个空格)在普通接口创建时也被裁掉;notes/tail .txt(空格在中间)完全合法,三个入口都照样解得出来(实测各 2 个文件,不需要改名)。真正麻烦的只有结尾的空格和点。
这些坑和"路径太长打不开"是同一回事吗?
症状几乎一样(看得见、打不开、删不掉、解压退出码 0 却少文件),根因不同一层,解法也不同。这一张表可以直接当排查手册用。
| 现象 | 断在哪一层 | 本篇还是长路径那篇 | 一眼判断的方法 |
|---|---|---|---|
| 双击提示"找不到该文件" | 看情况 | 两篇都可能 | 先量路径长度,再查名字 |
报错里出现 \\.\CON 这类设备名 | 保留名解析 | 本篇 | 就是它 |
| 报"文件名、目录名或卷标语法不正确" | 保留名或单名超 255 | 本篇 | 用 dir /b 看名字 |
| 报"路径中具有非法字符" | 非法字符 | 本篇 | 有尖括号、问号、星号 |
| 报"指定的路径或文件名太长……260……248" | 整条路径长度 | 长路径那篇 | 数一遍字符 |
subst 短盘符能解决 | 长度问题 | 长路径那篇 | 本篇里 subst 无效 |
只有带 \\?\ 前缀的删除有效 | 两者都成立 | 两篇 | 先查名字再量长度 |
两者唯一共用的后门就是 \\?\ 前缀,但用途不同:在长路径里它让你够得着本来太远的文件,在这里它让你动得了本来被当成设备的名字。
顺带把"名字没问题却打不开"的另外两条常见原因指个路:如果提示是"文件正在被另一个程序使用",那是锁的问题,见文件正在被另一个程序使用、删不掉怎么办;如果是"您从 Internet 下载了此文件"那类安全提示,那是来源标记,见下载的文件打不开、提示来自其他计算机怎么办。这两种都不是本篇的病。
怎么让文件名从一开始就能跨系统用?
绝大多数这类文件不是 Windows 上造出来的:Linux 和 macOS 允许 CON.txt、允许尖括号、允许冒号,也允许结尾的点,所以同一套命名规则在三个系统上只有 Windows 会拦。要给一整套跨平台的东西命名时(站内那篇命名与归档法讲的是"怎么起好名字",这里只讲"怎么不起坏名字"),把下面这张表当底线:
| 想表达的意思 | 不要用 | 可以换成 |
|---|---|---|
| 控制台、串口、并口相关 | CON.txt、AUX.dat、COM1.log | CON_safe.txt、AUX_safe.dat、COM_port1.log |
| 空设备、占位文件 | NUL、NUL.txt | empty.txt、placeholder.txt |
| 比较符号、表达式 | a 小于 b、x 大于 y | a_less_b、x_gt_y |
| 冒号分隔的时间或标签 | report:20260101、标题:副标题 | report_20260101、标题-副标题 |
| 版本后缀 | v1.2.、final. | v1_2、final |
| 通配与匹配 | 全部、可选 | 换成中文描述或 under_score |
几条更省事的做法,都是本轮实测推出来的:
下载或接手别人的包,先列条目再解压。 7z l 或 tar.exe -tvf 花不了十秒,能提前看到全部真名,比解完再对着"少文件"猜要快得多。
要用命令行解压时,优先用会改名的那一个。 本篇的实测里,只有 7z x 会把坏名字自动改成可用名字;资源管理器的拖拽和 Expand-Archive 会让问题变得更隐蔽(前者不报错也不落地,后者不报错也不落地)。图形界面党如果只想把某个文件取出来,用压缩软件自己的"解压到"而不是拖拽。相关软件的基础用法在NanaZip和7-Zip两篇里,本文只谈名字这一层。
打包前先改名,别指望打包工具。 实测三个工具在打包端没有一个会替你把 CON.txt 换成别的名字——Compress-Archive 拒绝、7z a 卡住、tar -czf 原样传出去。
给脚本里的中间文件起名时避开整段命中。 最容易中招的是自动生成文件名:"CON"、"AUX"这些三四个字母的缩写在做产品代号、目录代号时特别自然,而规则是整段等于保留名才拒,所以只要名字后面带内容(xCON.txt、CONSOL.txt、REPORT_CON.TXT)就安全——但反过来,这意味着你没法靠肉眼判断,只能扫。用上一节那三条正则去扫,比记住二十几个名字省事。
常见问题
我在资源管理器里新建文本文档,改成 CON 它不让,那这问题到底怎么发生的?
新建时不让,是因为资源管理器会当场拦住你;但拦住只发生在你手工输入这一次。同一台机器上,只要换一个入口,这些名字照样能进来:robocopy 从别的盘原样搬过来(实测能搬成,目标目录 dir /b 看得见 AUX.dat、普通接口读不到),tar.exe -xf 解一个含 CON.txt 的包(实测退出码 0、原样落地),备份还原、从 Mac 或 Linux 共享拷贝、以及任何直接用底层接口写文件程序,都能造出来。所以现实里"这文件哪来的"通常不是你手滑,而是某个批量工具替你建的。
双击一个文件提示"系统找不到指定的文件",是不是文件丢了?
先复核再下结论。本篇这批测试里反复出现同一个分裂:dir /b 列得出名字,但普通接口 Test-Path 是 False、读取报"找不到路径"或"要求 FileStream 打开一个不是文件的设备"、删除报"文件名、目录名或卷标语法不正确"。用带 \\?\ 前缀的写法再问一次,文件在、内容也读得出来。所以这句提示的意思很可能不是"文件不存在",而是"这个名字被当成设备处理了"。最快的复核是读一次内容:type "\\?\完整路径"。
为什么我解压完只少了一个文件,其他的都在?
因为工具不同,缺的量差别很大。实测里同一个包含 3 个保留名、1 个非法字符、1 个结尾点,四种入口分别交出 0、0、6、6 个文件;而只有部分条目坏的情况,Expand-Archive 的行为是"整包 0 落地",tar.exe 是"坏名字照原样落地、好名字正常",7z x 是"全都落地、坏名字改名"。所以"少了一个"这种零星缺口的情况,更常见的是资源管理器拖拽:实测里它只把没有坏名字的条目解出来,含坏名字的那个子目录一个文件都不落地、也不报错。
用命令行删它,del 报语法不正确,加 /f 或者其他参数行不行?
不行,方向错了。del 的失败发生在把名字交给系统的那一步,跟属性、只读、权限都无关,加参数不改这一步。实测有效的只有三条:del "\\?\绝对路径"、rd /s /q "\\?\绝对目录"、ren "\\?\绝对路径" 新名字。注意前缀必须配绝对路径,而且这一路删除不进回收站。如果你之前试过 subst 映射短盘符,那是解决长路径的,本篇实测它对保留名无效。
文件名里加不加引号有区别吗?我用 PowerShell 删的时候明明没报错。
有区别,而且这正是坑。Remove-Item 即使带上 \\?\ 前缀,实测也是"不报错但文件还在"——同一次测试里它对旁边的普通文件是真删掉了,所以你会以为命令本身没问题。原因是 PowerShell 的删除走的是它自己的提供程序解析,前缀在某一环被抹平了。改用 [System.IO.File]::Delete('\\?\绝对路径') 实测就生效。写脚本清这类文件时,宁可绕开 cmdlet。
我是做导出功能的,能不能直接把非法字符换成下划线?
能,这就是最省事的做法,而且和 7-Zip 的自动改名规则基本一致:实测它把保留名前面加下划线(CON.txt 变 _CON.txt)、把非法字符换成下划线(a<b.txt 变 a_b.txt)、把结尾的点换成下划线(tail.txt. 变 tail.txt_)。自己实现时补三条就够严谨:一是判断保留名要整段等值(含扩展名的情况按"名字部分等于保留名"处理,别让 MYCON 也被改名),二是范围是 COM1 至 COM9 与 LPT1 至 LPT9,COM0 和 COM56 不在名单内(实测能创建),三是替换后要检查是否和同目录里已有文件撞名——本篇就遇到过 tail.txt. 去掉点后可能覆盖已有的 tail.txt 这种情况。
备用数据流会不会被杀软或同步盘带跑?
本文只测到"谁看得见它"这一层,不敢替安全软件下结论。实测到的事实是三条:dir /r 会把流原样列出来(q1.xlsx:evil.txt 这条 10 字节的流看得见);普通读取主文件的接口拿到的是主流内容,看不见流;打包时流会被丢掉,tar -czf 和 7z a 两条路径都只收了主文件那 9 个字节。至于同步盘、杀软分别怎么处理流,需要各自的实测,这里不写结论。
最后三件事
第一,这类问题的判据只有一条:看报错里有没有出现 \\.\CON 这种设备名,或者"文件名、目录名或卷标语法不正确"这句。出现这两句之一的,就不要再去检查路径长度、权限或文件是否损坏,那是名字的问题。
第二,动手顺序固定三步:先用 7z l 或 tar.exe -tvf 看清包里的真名字,再决定用哪个工具解压(本篇实测只有 7z x 会自动改名并留下可用文件),最后用带 \\?\ 前缀的 del / rd / ren 收尾。三条命令都用绝对路径,前两条不进回收站。
第三,别相信"退出码 0 加没弹窗"。这批测试里 rd /s /q 退出码 0 但输出里有三次失败、目录还在;tar -czf 退出码 0 却把 CON.txt 原样传给了同事;Expand-Archive 在 powershell.exe 里退出码 0、落地 0 个文件。要确认结果,就用带前缀的方式再列一遍——用普通方式去核对,你核对的正是那套会漏的接口。