
1. 为什么要折腾这么一件小事乱码问题的根源先说个我实际遇到的场景。上个月接手一个老项目的文档整理工作同事发过来一个压缩包里面是一百多个.txt、.ini、.sql文件说是从旧服务器上导出来的。我随手用记事本打开一个满屏的“锟斤拷”“烫烫烫”直接把我劝退了。再打开几个有的能正常显示中文有的全是乱码同一个文件夹里居然混着好几种编码。这种事儿干过活的人应该都不陌生ANSI 编码下的中文文本换到现代 Windows 11 环境里打开编码不对就是一团糟。其实这里头有个很典型的历史包袱。ANSI 在简体中文 Windows 环境下实际指的就是GBK/GB2312 编码体系它用两个字节表示一个汉字在当年 Windows XP、Windows 7 时代是绝对的默认编码。而 UTF-8 是后来互联网和跨平台场景下的主流编码用可变长度字节表示字符对中英文混合内容更友好。问题是现代编辑器VS Code、Notepad、甚至 Windows 11 自带的记事本默认对无 BOM 的文本几乎都按 UTF-8 处理。于是老文件里那些 GBK 编码的中文内容被按 UTF-8 去解读立刻就花了。如果只是几个文件用记事本另存为、或者 VS Code 里重新打开再改编码几秒钟就搞定。但当我面对的是一个文件夹里嵌套多个子文件夹、里面有几百个文本文件的情况时手工转换基本等于自虐。这个活儿真正高效的做法就是用命令行批量处理。标题里说的这件事本质上是三个关键词的组合Win11 环境、命令行工具链、批量编码转换。这篇文章我把自己实际验证过的方案完整梳理一遍包括踩过的坑和最终可用的脚本给你一条不走弯路的路径。2. 方案选型为什么偏偏是命令行以及哪个命令行2.1 转换编码的常见思路以及各自的局限性先说说除了命令行之外常见的几种思路方便你判断自己到底需不需要继续往下看。思路一用编辑器手动另存为。这个就不用多说了文件少可以文件多就是灾难。哪怕 VS Code 能一次打开多个文件每个文件都得手动改编码、保存、再切换下一个上百个文件下来手都得抽筋。而且这种方式没法递归处理子文件夹你得自己一层层翻目录。思路二用 Notepad 的“批量转换”插件。Notepad 确实有 Convert Encoding 相关的功能但“批量对指定文件夹内所有文件递归转换”这件事需要额外装插件、配置菜单而且它本质上还是一个图形界面工具没法直接嵌入到你自己的自动化流程里。如果你只是想临时转一批文件它确实能用但如果你想把这套逻辑写成脚本、以后定时跑或者换个电脑照样跑它就撂挑子了。思路三用第三方小工具。网上确实有人做了专门的编码转换 GUI 工具但这类小工具的来源和质量参差不齐有的还捆绑广告。我反正不太敢把几百个项目文件交给一个来历不明的 exe 去处理。更关键的是这类工具很多不支持“只转换指定扩展名”“跳过某些子目录”这种精细控制一旦转错了想批量改回来反而更麻烦。思路四用命令行。这才是适合批量、可重复、可脚本化的解法。Windows 下原生的命令行环境有两种一个是老的CMD命令提示符一个是PowerShell。老实说CMD 做这事很别扭它的编码处理和循环能力都太弱了。真正好用的是 PowerShell既有完整的循环、管道、过滤机制又能直接调用 .NET 底层 API读写文件时指定编码是分分钟的事。而且在 Win11 上PowerShell 5.1 是系统自带的不用装任何额外的东西PowerShell 7 需要单独装但也不是必须。所以我的结论是用 Windows PowerShell 脚本来做这件事是效率和灵活性的最佳平衡点。2.2 PowerShell 5.1 和 PowerShell 7 的区别这个必须先搞清楚写脚本之前有一个特别容易踩的坑必须先讲明白PowerShell 5.1 和 PowerShell 7对“编码”的处理逻辑完全不同。PowerShell 5.1 是 Win11 预装的老版本它有一个非常“远古”的设定——Get-Content命令的-Encoding参数里有一个Default选项这个Default在简体中文系统上指的就是ANSIGBK。你用Get-Content -Encoding Default去读一个 GBK 文件读出来的字符串是正确的再配合Out-File -Encoding UTF8写回去就能完成转换。这套逻辑在 5.1 里很顺手。但 PowerShell 7以前叫 PowerShell Core就完全不一样了。它对标的是跨平台所以默认编码几乎一律是 UTF-8 无 BOM-Encoding Default选项直接被移除了你不能再指望“Default”代表 ANSI。在 PowerShell 7 里如果你直接用Get-Content不指定编码去读一个 GBK 文件读出来的内容照样是乱码。所以百度一搜“PowerShell 批量转码”你可能会看到两种截然不同的答案根源就在这里。我在下面给出的脚本分成了两套版本一套是给系统自带的 5.1 用的一套是给 7 用的如果你已经升级过的话。用之前先跑一下$PSVersionTable.PSVersion确认版本就不会出错。2.3 为什么 .NET 方法比 Get-Content 更可靠如果你在网上搜索过相关代码会发现有人推荐直接用 .NET 的方法而不是用 PowerShell 的Get-Content/Set-Content我最终给出来的方案也确实主要用 .NET 库。为什么最核心的原因是Get-Content在读取文件时会把每行当成一个对象来处理对于超大文件或者没有结尾换行符的文件偶尔会出现莫名其妙的边界问题。而 .NET 的[System.IO.File]::ReadAllText()直接把整个文件作为字符串读入内存再用[System.IO.File]::WriteAllText()一次性写出去整个过程更接近“无脑搬运”对编码的掌控也更精确。当然它的代价是如果文件特别大比如几十 MB 的日志文件一次性读入内存会有一定压力。但我实际测试过普通文本文件几百 KB 到几 MB压根没压力。后面我会提到如果确实遇到超大文件怎么用流式读取来规避内存问题。提示转换前建议先备份。一次性写坏几百个文件再想恢复原样那才真的是欲哭无泪。3. 核心脚本实操从零到一完成批量转换3.1 环境确认与安全准备在敲命令之前先花两分钟做三件事确认 PowerShell 版本、创建测试目录、备份。打开 Win11 的“开始”菜单搜索 “PowerShell”右键选择“以管理员身份运行”。这里不强制要求管理员权限PowerShell 默认权限也能读写大部分目录但如果你要处理的文件夹在C:\Program Files这种受保护路径下还是用管理员省心。接着确认版本$PSVersionTable.PSVersion看到Major是 5 就是系统自带的 5.1看到Major是 7 就是新版这会决定你后面用哪套脚本。然后我强烈建议你先在本地建一个测试文件夹里面放三到五个用“ANSIGBK”编码保存的中文文本文件这样转码之后可以直观地验证效果不用一上来就动真正的业务文件。备份这一步推荐最笨但最可靠的方式——直接复制整个文件夹到另一个位置。或者如果你习惯用命令行PowerShell 里一条命令就行Copy-Item -Path D:\SourceFolder -Destination D:\BackupFolder -Recurse3.2 最简版本Win11 自带 PowerShell 5.1 一键转换如果你用的是系统自带的 PowerShell 5.1那代码可以写得非常简洁。脚本中最关键的参数有三个目标文件夹路径、文件扩展名过滤器、是否递归子目录。$targetDir D:\TargetFolder $extension *.txt $recurse $true Get-ChildItem -Path $targetDir -Filter $extension -Recurse:$recurse | ForEach-Object { $filePath $_.FullName # 读取 ANSI即 GBK内容到字符串 $contentText Get-Content -LiteralPath $filePath -Encoding Default -Raw # 以 UTF-8 无 BOM 格式写回 [System.IO.File]::WriteAllText( $filePath, $contentText, [System.Text.UTF8Encoding]::new($false) ) Write-Host Converted: $filePath }逐个拆解一下免得你复制完心里没底Get-ChildItem -Path $targetDir -Filter $extension -Recurse:$recurse这一步负责把指定目录下所有符合条件的文件都找出来。-Filter支持通配符*.txt就只转文本文件如果你要转.sql就改成*.sql想转所有文件就改成*.*非常灵活。-Recurse参数决定是否深入子文件夹。Get-Content -LiteralPath $filePath -Encoding Default -Raw这一行的重点是-Encoding Default。在 PowerShell 5.1 里Default就是 ANSI。加-Raw是为了让整个文件内容作为一个整体字符串返回而不是拆成行数组这样写回去的时候不会因为行尾符号差异而变形。[System.IO.File]::WriteAllText($filePath, $contentText, [System.Text.UTF8Encoding]::new($false))这一行把读出来的字符串用 UTF-8 编码写回原文件。new($false)里的布尔值代表“不带 BOM”。BOM 是文件开头的一段特殊字节序标记某些老程序不认它所以更多时候我们选择不带 BOM 的 UTF-8。写完脚本保存成.ps1文件或者在 PowerShell 窗口里直接粘贴逐行执行都能跑通。3.3 通用版本兼容 PowerShell 5.1 和 7 的写法刚才说过了PowerShell 7 里没有-Encoding Default。所以如果你的机器装的是 PowerShell 7或者你想写一个“任何版本都能跑”的脚本需要用 .NET 的编码类来手动指定 ANSI。$targetDir D:\TargetFolder $extension *.txt $recurse $true # 关键显式指定 GBK 编码 $ansiEncoding [System.Text.Encoding]::GetEncoding(GBK) $utf8Encoding [System.Text.UTF8Encoding]::new($false) Get-ChildItem -Path $targetDir -Filter $extension -Recurse:$recurse | ForEach-Object { $filePath $_.FullName # 用 .NET 方法读取显式指定 ANSI 编码 $contentText [System.IO.File]::ReadAllText($filePath, $ansiEncoding) # 写入 UTF-8 无 BOM [System.IO.File]::WriteAllText($filePath, $contentText, $utf8Encoding) Write-Host Converted: $filePath }这一版的关键区别就在[System.Text.Encoding]::GetEncoding(GBK)。注意这里我写的是GBK而不是GB2312。原因是GBK 是 GB2312 的超集它向下兼容 GB2312同时能处理更多生僻字。在简体中文 Windows 上ANSI 指的实际就是 GBK所以用 GBK 去读是在兼容性上最稳的。如果你处理的是繁体中文环境那得改成GetEncoding(BIG5)如果是日文环境对应的编码名是EUC-JP或Shift_JIS。这一点后面讲排错的时候还会再提到。3.4 按需扩展只处理特定目录层级、跳过特殊文件夹实际操作中经常会遇到“只需要转某个子目录下的文件其他子目录不要动”的需求。不用急着改脚本结构Get-ChildItem本身就支持路径过滤你可以把整个Get-ChildItem换成两次调用或者用Where-Object做二次过滤。举例说明假设目录结构是这样的D:\Project ├── docs\ ├── src\ # 只转这里面的 .java 文件 ├── test\ └── include\那么你的目标路径直接写成D:\Project\src把$extension改成*.java就行。如果不想递归到某些子目录一个比较笨但有效的做法是先枚举目录再在脚本里判断Get-ChildItem -Path $targetDir -Recurse -Directory | Where-Object { $_.Name -notin (node_modules, .git, dist) } | ForEach-Object { Get-ChildItem -Path $_.FullName -Filter $extension | ForEach-Object { # 转码逻辑和上面一样 } }这个写法会把指定目录下所有子目录都列出来过滤掉node_modules、.git这类你绝对不想碰的文件夹然后再逐层处理文件。虽然代码多了一点但在真实项目里非常实用。3.5 高级参数说明UTF-8 到底要不要 BOM关于 BOM 这个问题值得单独说一段。BOMByte Order Mark是 Unicode 规范里用来标识文本编码和字节序的一组特殊字节。UTF-8 的 BOM 在文件开头是EF BB BF这三个字节。很多从 Windows 老环境出来的人习惯了带 BOM 的 UTF-8因为记事本能一眼识别。但放到 Linux、Git、或者某些命令行工具里BOM 反而会被当成特殊字符导致一些莫名其妙的报错。比如在 Linux 下用grep匹配文件内容第一个字符如果带 BOM你可能匹配不上再比如有的编译工具链会因为你源码开头多了三个字节直接给你报错。我的做法是**如果这个文件要被程序读取源码、配置、脚本一律转成无 BOM 的 UTF-8如果只是给人看的文档带不带 BOM 其实无所谓。**上面脚本里写new($false)就是无 BOM如果你想改成带 BOM把$false换成$true就行。4. 常见问题与排查实录那些年我踩过的坑4.1 转完之后文件反而变成乱码了是怎么回事这是最让人崩溃的问题转之前源文件好歹在记事本里还能看转完之后直接全花。十有八九是你读取的时候编码就用错了。举一个我实际遇到的例子。同事给我的文件用 Notepad 看右下角显示的是 “ANSI”我当时就直接用Get-Encoding(GBK)去读了。转完一部分之后我发现有几个文件出现少量错别字个别生僻字直接变成了“?”。排查了一下发现这些文件不是标准 GBK而是GB18030编码保存的里面包含了超出 GBK 字符集的扩展汉字。解决办法是把编码读取参数换成GB18030。GB18030 是 GBK 的进一步扩展理论上能表示几乎所有中文字符而且它是向下兼容 GBK 的。换句话说用 GB18030 去读 GBK 文件没问题但用 GBK 去读 GB18030 文件就可能丢字。如果你不确定源文件到底是哪种直接统一用GetEncoding(GB18030)是最稳的选择。4.2 转换后中文正常但英文和数字旁边多了一个“?”这个症状我一开始碰到还以为是文件里原来就有的东西后来才发现问题出在编码转换器遇到无法映射的字符时的默认替代行为。当你用某种编码读文件时如果遇到该编码无法表示的字符比如 GBK 里没有某个特殊符号或者编码声明与实际内容不符.NET 会把那个位置替换成一个默认的替代字符——通常就是“?”。排查思路随便取一个出问题的文件先用十六进制编辑器看一下原始字节再对比转换后的字节看看多出来的“?”在哪些位置。如果“?”出现的规律和某些特殊字符比如€、™对得上那说明源文件其实不是纯中文 ANSI里面混了其他编码的字符。这时候你得先确认这些文件的真实编码再决定读的时候用什么编码。在脚本层面你可以显式指定替换策略$ansiEncoding [System.Text.Encoding]::GetEncoding( GBK, [System.Text.EncoderFallback]::ExceptionFallback, [System.Text.DecoderFallback]::ExceptionFallback )这样设置之后一旦遇到无法解码的字节会直接抛出异常提示你而不是静默地替换成“?”。4.3 PowerShell 脚本执行时提示“因为在此系统上禁止运行脚本”Win11 默认的 PowerShell 执行策略是Restricted只允许运行单个命令不允许执行.ps1脚本文件。这时候你直接执行脚本文件会报错。解决方法有几个临时开放当前会话的执行策略只对当前窗口有效关掉就恢复Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass用-ExecutionPolicy Bypass参数直接启动 PowerShellpowershell -ExecutionPolicy Bypass -File D:\Script\convert.ps1如果你是 Win11 系统自带终端Windows Terminal也可以在配置文件里把默认执行策略改宽。提示Set-ExecutionPolicy改的是系统级的执行策略改完记得用Get-ExecutionPolicy检查确认。如果你只是临时用一下用-Scope Process就够了别动全局。4.4 文件被占用或者无权限写入怎么处理如果你的目标文件夹在系统目录或者有一个程序正好打开了某个文件比如数据库服务正在读一个.sql文件WriteAllText就会抛 “UnauthorizedAccessException” 或 “IOException”。处理方式有两个维度一是权限维度用管理员身份运行 PowerShell或者给当前用户添加目标文件夹的“修改”权限。二是文件占用维度脚本里加一层容错遇到占用就跳过并记录日志try { [System.IO.File]::WriteAllText($filePath, $contentText, $utf8Encoding) Write-Host Converted: $filePath } catch { Write-Warning Failed: $filePath - $($_.Exception.Message) }4.5 超大文件转换时内存飙升怎么办前面说过的ReadAllText会把整个文件读入内存如果碰到一个几百 MB 的超大文件内存占用会非常难看。遇到这种极端场景用流式读写的思路更稳。简单的实现思路用StreamReader按块读取再用StreamWriter按块写入。你可以把整个脚本里的核心转换部分替换成$readEncoding [System.Text.Encoding]::GetEncoding(GB18030) $writeEncoding [System.Text.UTF8Encoding]::new($false) $reader [System.IO.StreamReader]::new($filePath, $readEncoding) $writer [System.IO.StreamWriter]::new($filePath, $false, $writeEncoding) try { while ($null -ne ($line $reader.ReadLine())) { $writer.WriteLine($line) } } finally { $writer.Dispose() $reader.Dispose() }这里有个注意事项用ReadLineWriteLine的方式会保证行尾统一为当前系统的换行符。如果你的源文件是 Linux 风格的LF换行转完后可能变成 Windows 风格的CRLF内容不变格式变了。要是你特别在意这个可以考虑用StreamReader.ReadBlock配合缓冲区原样复制不过常规场景下这种差异基本无感。4.6 常见问题速查表症状可能原因解决办法转换后全是“?”读取编码不对GBK 无法覆盖某些字符改用 GB18030 读取转换后文件变乱码源文件其实是 UTF-8被当成 ANSI 读了一遍先确认源文件真实编码不乱套“ANSI 一律是 GBK”脚本报错“禁止运行”执行策略限制Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass写入时提示文件被占用文件被其他程序锁定用 try/catch 跳过并记录或关闭占用程序转换后换行符变了用 ReadLine/WriteLine 导致用流式按块复制或接受换行符变化个别文件转出来是空的原文件本身就是空文件或纯 ASCII 无字符脚本里加个文件长度判断空文件直接跳过5. 从脚本到工具如何把这套逻辑固化成日常可用的命令5.1 保存成 .ps1 脚本文件最直接的固化方式就是把上面通用版脚本保存成一个.ps1文件比如命名为Convert-ToUtf8.ps1。这样以后要用的时候直接打开 PowerShell 执行一遍即可。如果你连打开 PowerShell 都嫌麻烦可以做一个.bat文件来调起它。比如你的脚本放在D:\Scripts\Convert-ToUtf8.ps1在同一个目录下建一个run_convert.bat内容如下echo off powershell -ExecutionPolicy Bypass -File D:\Scripts\Convert-ToUtf8.ps1 pause这样双击.bat文件就能执行转换。脚本里的目标路径改一次就行下次有同样需求直接双击。5.2 把脚本设计得更通用传参而不是改代码上面这种做法有一个缺点每次目标路径变了你得改脚本内容。如果你经常需要处理不同文件夹更合理的做法是把脚本变成一个“带参数的函数”。在 PowerShell 里这很容易实现脚本开头定义参数param( [Parameter(Mandatory$true)] [string]$TargetDir, [string]$Filter *.txt, [switch]$Recurse $true ) $ansiEncoding [System.Text.Encoding]::GetEncoding(GB18030) $utf8Encoding [System.Text.UTF8Encoding]::new($false) Get-ChildItem -Path $TargetDir -Filter $Filter -Recurse:$Recurse | ForEach-Object { $filePath $_.FullName $contentText [System.IO.File]::ReadAllText($filePath, $ansiEncoding) [System.IO.File]::WriteAllText($filePath, $contentText, $utf8Encoding) Write-Host Converted: $filePath }然后调用方式就变成.\Convert-ToUtf8.ps1 -TargetDir D:\NewProject -Filter *.sql -Recurse这样你就不用每次编辑脚本了路径、扩展名、是否递归都通过参数控制。甚至可以在.bat文件里用%1包装成“拖拽文件夹到批处理图标上就能转换”的效果echo off powershell -ExecutionPolicy Bypass -File D:\Scripts\Convert-ToUtf8.ps1 -TargetDir %1 pause把文件夹拖到这个批处理文件上它就会自动读取%1这个路径直接开转。我这两年处理外部交付的项目文件都是这么干的效率提升非常明显。5.3 什么情况下你需要额外注意源文件的编码识别脚本是无脑的但真实文件系统是混乱的。你可能会遇到一个文件夹里既有 ANSI 文件又有本来已经是 UTF-8 的文件。你又想只把 ANSI 的转换掉不碰已经是 UTF-8 的那些怎么办方法一转换前检测文件是否包含非法 UTF-8 序列。思路是用 UTF-8 编码去读取文件如果抛出异常说明它大概率不是 UTF-8function Test-IsValidUtf8($path) { try { $null [System.IO.File]::ReadAllText($path, [System.Text.UTF8Encoding]::new($false, $true)) return $true } catch { return $false } }这里[System.Text.UTF8Encoding]::new($false, $true)的第二个参数$true表示启用严格校验遇到非法字节就抛异常。实际用的时候先把所有文件过一遍这个函数把返回$false的文件收集起来再批量转换。方法二直接全量统一转换。如果你的应用场景能接受“所有文件都变成 UTF-8”那就无所谓了反正 UTF-8 文件再转一遍 UTF-8 也不会坏。这种方法省事唯一的风险是文件里如果有其他特殊编码比如 UTF-16你拿 ANSI 去读就会读错然后写回之后文件就真的坏了。所以我的建议还是如果文件夹来源复杂先用方法一筛一遍。5.4 与 Win11 自带功能的时间线为什么不用记事本批量处理有人可能会问Win11 的记事本不是已经能识别各种编码了吗确实新版记事本在打开文件时能自动识别编码右下角状态栏还能显示当前编码甚至另存为时能选择“UTF-8 with BOM”“UTF-8”“UTF-16 LE”等选项。但这里的关键是记事本没有批量处理能力它一次只能打开一个文件。对上百个文件来说打开一个、转换一个、保存一个、关掉一个这个流程重复一百遍任何正常人都受不了。命令行方案的真正价值是“一次性处理”而且你把脚本写好了以后它就是一件顺手拈来的工具不需要每次重新思考。6. 实际体会与尽可能少走的弯路最后想聊两句不扯技术纯经验。做这类批量转换最忌讳的就是上来就动手。我第一次给别人处理项目文件的时候没做备份也没验证直接跑脚本结果把一个本来就好的 UTF-8 文件用 ANSI 读了一遍写完直接报废最后用了半天时间去还原。从那以后我给自己定了一个铁律凡是批量操作第一件事永远是备份第二件事永远是先拿两三个文件做小范围测试确认没问题了再全量执行。另外源文件的编码识别不能想当然。很多工具显示“ANSI”其实指的是“系统当前默认 ANSI 代码页”简体中文系统上是 GBK繁体系统上可能是 BIG5如果你把这些文件拿到一个日文系统的 Win11 上去跑同样是“ANSI”读出来的内容完全不一样。所以如果你的工作环境涉及到其他语言版本的 Windows或者文件可能来自不同地区的同事写脚本时尽量用明确的编码名GB18030、Big5、Shift_JIS不要依赖“Default”这种模糊概念。这套脚本我现在还用着每隔一段时间就会因为不同需求加一点小功能比如支持把编码信息输出到一个日志文件、支持把转换结果统计汇总。本质上它已经不是一个“一次性脚本”而是一个不断积累的小工具箱。你只要把基础版本跑通后续按需扩展它会越来越顺手。希望这篇文章能让你少走几步弯路直接把这件事一次做对。