☰
Windows文本合并实战:copy与PowerShell避坑指南
2026/10/22 9:01:15 网站建设 项目流程

先说个真事。前一阵我接到一个活儿,要把散在好几个目录下的一批日志文件合并成一个 txt 交给下游处理。同事听到后的第一反应是:那你一个个打开复制粘贴吧,反正也没几个文件。我数了一下,六十多个。真要是打开、全选、复制、粘贴六十多次,光标还没移到一半我就先疯了。

那批日志都是纯文本,格式相近,但来源文件夹不同,文件名也乱。当时的真实需求其实很朴素:把所有内容按顺序拼到一个文件里,顺序不能错,中文不能乱,最后最好还能给每个片段做个来源标记。本来以为这种“毫无技术含量”的需求一条命令就结束了,结果真上手才发现,Windows 下合并文本文件这事儿,看起来简单,里面全是编码、换行、BOM、大文件性能的暗坑。今天这篇就专门聊聊我摸过的几条路线,什么场景用什么命令、什么时候不能碰 copy、乱码到底是怎么来的、几十 GB 的日志又该怎么合。

1. 合并文本文件之前:先搞清楚你要合并的到底是什么

很多人一上来就搜“Windows 合并文本文件”,然后抄了一条copy *.txt all.txt就跑,结果不是中文乱码就是文件内容重复。要我说,第一步压根不是找命令,而是先想清楚自己的需求属于哪一类,因为不同需求对应的工具和风险完全不一样。

1.1 三种主流合并路径与适用场景

我在实际项目里基本只见过三类合并需求,对应三条技术路线,先列个表格给各位一个全景:

技术路线典型命令/方式优点缺点最适合的场景
CMD 字节级合并copy /b a.txt + b.txt out.txt速度极快,不吃内存,纯磁盘流式复制不处理编码,不加工内容,BOM/换行问题多大批量纯文本/日志,不需要任何加工的裸合并
PowerShell 管道处理`Get-Content ...Add-Content ...`灵活,能排序、过滤、加前缀、统一编码大文件慢,编码默认值容易踩坑
.NET 流式读写脚本StreamReader + StreamWriter大文件又快又稳,可任意处理每行代码量稍长,新手觉得麻烦几十 GB 日志、需要逐行加工的大型合并

你以为最常见的“打开编辑器手动合并”我压根没放进去,因为它只适合临时合两三个文件,一旦超过十个,纯手工就是给自己找罪受。

1.2 选型判断:你是哪种合并需求

在动手之前,我建议你问自己四个问题:

  • 文件数量有多少?十个以内和五百个不是一个难度等级。
  • 单文件大概多大?几十 MB 和几个 GB 的处理策略完全不同。
  • 合并后要不要做二次加工?比如跳过空行、加文件名前缀、过滤 Debug 日志。
  • 源文件的中文编码是不是同一套?如果混着 GBK 和 UTF-8,后续怎么合都白搭。

这些问题的答案基本就决定了你该走哪条路。我先说结论:如果只是纯合并、不加工内容,永远优先考虑 CMD 的字节级合并,也就是copy /b;只要夹带了任何“每行处理”的需求,就老老实实写 PowerShell 脚本,别抱着copy硬凑。

2. 用 copy /b 合并:最快的方式,但有两个绕不开的坑

copy命令可能是 Windows 用户最早接触的合并方式了,网上一搜一大把教程。但大部分教程只告诉你“这样能合”,没告诉你“这样合会出事”。我自己第一次用就有过教训。

2.1 copy 的基本写法和 /b 参数为什么必要

裸合并多文件的基本写法是这样的:

copy /b a.txt + b.txt + c.txt merged.txt

注意/b这个参数,它表示按二进制模式复制。为什么要加它?因为copy在默认情况下是文本模式,文本模式下遇到文件里的Ctrl+Z(十六进制1A)会被当作文件结束标志,导致文件被截断。真实世界的文本文件里,这种控制字符并不算特别罕见,一旦碰上,你合并出来的结果就会莫名其妙少一截。

加上/b之后,copy变成逐字节复制,不解析任何内容,也几乎不消耗内存,几 GB 的文件都是直接流式写盘。这就是它在裸合并场景下无人能敌的原因。

还有一个非常实用的小参数:/y。不加它的话,如果merged.txt已经存在,命令会停下来问你是否覆盖。写自动脚本时这个交互提示会卡住流程,加上/y可以避免:

copy /b /y a.txt + b.txt + c.txt merged.txt

2.2 通配符合并与“自己合自己”的经典事故

很多人知道copy /b /y *.txt merged.txt这种写法,一条命令把所有 txt 按文件名顺序合到一起。但这里藏着一个大坑:如果merged.txt本身就在当前目录,而且它已经存在,它也会匹配到*.txt这个通配符里。结果就是 cmd 在合并过程中可能把输出文件自己也读进去了,轻则内容重复,重则文件指数级增大,直到磁盘满。

我见过一次事故,某个项目的自动化脚本里用了这招,跑了几轮之后输出文件从几十 MB 涨到几个 GB,最后把整个磁盘塞满了。解决方法很简单,两条路任选:

  1. 先把目标文件删除或改名,确保它不在通配符范围内。
  2. 把输出文件放到别的目录,比如copy /b /y *.txt D:\merged\all.txt。

另外注意,命令行长度是有限制的(Windows 下大约 8191 个字符)。文件数量特别多的时候,copy a.txt + b.txt + c.txt ...这种写法根本拼不出那么长的参数,这时候要改用 for 循环逐个追加:

cd /d D:\logs if exist merged.txt del merged.txt for %f in (*.txt) do copy /b /y merged.txt + "%f" merged.txt

但这个写法会反复打开关闭同一个输出文件,文件多的时候速度反而一般,且容易在断点时留下半成品。如果文件数量实在夸张,我后面会讲更合适的批量方案。

2.3 行尾换行与 BOM:copy 合并后为什么会出现怪字符

copy /b是字节级复制,所以它不管什么编码、换行、BOM,一切都原样拼在一起。这带来两个非常典型的“合并后问题”:

第一个是行粘连。如果第二个文件的最后一行后面没有换行符,第二个文件的第一行就会直接贴在上一行后面。比如abc和def两个文件,如果都有换行,合并结果是两行;如果文件末尾漏了换行符,合并后可能变成一行abcdef。这在日志合并里特别烦人,因为你根本看不出是哪两个文件粘了。

解决办法有两个。一个是合并前用文本编辑器给所有文件统一补上末尾换行;另一个是干脆不用copy,改用 PowerShell 在合并时逐行输出,天然保证换行。

第二个是BOM 伪字符。UTF-8 文件如果带 BOM,文件开头会有三个不可见字节(EF BB BF)。copy /b不会识别它,直接把第二个文件的 BOM 也拼进了输出。结果就是合并出来的文件中间出现锘、之类的怪字符,看起来像乱码,其实是 BOM 被当成了正文。

想知道自己是不是踩了这个坑,可以用 PowerShell 读一下文件头几个字节:

$bytes = [System.IO.File]::ReadAllBytes('D:\logs\a.txt')[0..3] $bytes | ForEach-Object { $_.ToString('X2') }

如果输出是EF BB BF开头,说明是带 BOM 的 UTF-8;如果直接是汉字编码的字节,说明可能是 ANSI/GBK。所以我的建议很直接:一旦你发现源文件编码不统一,或者文件里可能带 BOM,就别再用copy /b了,转去用 PowerShell 做编码感知的合并。

3. PowerShell 合并:灵活、可控,也最容易踩编码默认值

PowerShell 的好处是处理文本时有完整的“解码—加工—编码”能力,文件里的 BOM 会被识别并剥掉,不必担心伪字符混进正文。但它的坏处是——默认编码设置在不同版本里不一样,很多人就是死在这一步。

3.1 一行式合并与编码默认值问题

最简单的一行命令长这样:

Get-Content -Path D:\logs\a.txt, D:\logs\b.txt -Encoding UTF8 | Add-Content -Path D:\merged.txt -Encoding UTF8

这里的关键点是-Encoding UTF8必须显式写。为什么?因为Windows PowerShell 5.1 中Add-Content的默认编码是Default,也就是系统的 ANSI 代码页,简体中文环境下就是 GBK。如果你的源文件是 UTF-8,不指定输出编码,合并后的中文几乎必然乱码。

另一个版本差异是:在 Windows PowerShell 5.1 里,-Encoding UTF8写入的文件带 BOM;而在 PowerShell 7 里,默认写入的是不带 BOM的 UTF-8。这一差异不至于让内容出错,但会影响下游工具是否自动识别编码。如果你把脚本发给同事,对方环境里的 PowerShell 版本和你不一样,结果文件的编码可能就不一样。

所以我在自己的脚本里养成了一个习惯:永远显式指定编码,不依赖默认值。哪怕多打几个字,也避免了一次“神秘乱码”。

3.2 带文件来源标记的分段合并脚本

很多真实的合并需求不是简单拼内容,而是希望保留“这段来自哪个文件”的信息。尤其合并日志时,如果不带文件名,下游根本没法排查问题。

我常用的分段合并写法是这样:

$output = 'D:\merged\all.log' if (Test-Path $output) { Remove-Item $output } Get-ChildItem -Path 'D:\logs' -Filter '*.log' | Sort-Object Name | ForEach-Object { Add-Content -Path $output -Value "`n`n===== $($_.Name) =====" -Encoding UTF8 Get-Content -Path $_.FullName -Encoding UTF8 | Add-Content -Path $output -Encoding UTF8 }

这段脚本会给每段内容前面插入一个带文件名的小标题,并且按文件名排序。对于中小文件,跑起来没问题,但有个隐藏的效率问题:每次Add-Content都会打开一次输出文件,文件越多,系统调用越频繁。如果是几十个小文件还无所谓,要是几百个文件,速度会明显变慢。

3.3 更顺手的写法:一次性打开输出文件,逐行流式写入

当你开始觉得“每条管道都打开一次文件”不对劲时,就该换成流式写法了。核心思路是:用StreamReader读源文件,用StreamWriter写输出文件,整个合并过程只打开一次输出文件。

$writer = New-Object System.IO.StreamWriter('D:\merged\all.log', $false, [System.Text.Encoding]::UTF8) Get-ChildItem -Path 'D:\logs' -Filter '*.log' | Sort-Object Name | ForEach-Object { $writer.WriteLine("") $writer.WriteLine("===== $($_.Name) =====") try { $reader = New-Object System.IO.StreamReader($_.FullName, [System.Text.Encoding]::UTF8) while ($null -ne ($line = $reader.ReadLine())) { $writer.WriteLine($line) } } finally { $reader.Close() } } $writer.Close()

这样写的另一个好处是:你可以在WriteLine前对每一行做任意处理,比如跳过空行、给每行加时间戳、过滤掉包含指定关键字的行。这就是从“能合”到“合得顺手”的转折点。脚本看起来比一条命令长很多,但稳定性完全是另一个级别,尤其是处理几百个文件的场景。

4. 中文乱码的本质:编码、BOM 与换行符

乱码问题在文本合并里几乎避不开。我收到的反馈里,十个乱码投诉有九个都源于编码认知不清。借助一次真实的排查过程,我把这个问题彻底掰开说清楚。

4.1 一次乱码排查记录:乱码不是文件坏了,是读法错了

某次合并后的文件发给对方后,对方说“开头好好的,中间全是乱码”。我第一反应不是去重跑合并,而是问了句:你用的什么打开?对方答,记事本。

问题就在这。我打开合并后的文件用十六进制看了下头部,EF BB BF,说明第一个文件带 UTF-8 BOM,记事本识别正常;但中间某一段出现了D6 D0这类字节,这是 GBK/ANSI 编码下汉字的特征字节。也就是说,这批源文件根本不是同一套编码,前几个是 UTF-8,后几个是 GBK,合并后记事本只能按开头猜一种编码,猜错了中间就必然乱。

这不是文件“坏了”,而是文件内容里的编码本来就不一致。所以排查乱码的第一步永远是:用十六进制工具或者 PowerShell 看文件头部字节,确认源文件编码是否统一,而不是反复重跑合并命令。

4.2 编码概念速览:ANSI、UTF-8、BOM、UTF-16

为了避免大家被“编码”两个字绕晕,我用最直白的方式解释一下:

编码特点在合并且最容易出现的坑
ANSI/GBK中文 Windows 下系统默认代码页,汉字占 2 字节用 UTF-8 工具读取时中文乱码
UTF-8 无 BOM现代主流,英文 1 字节、中文 3 字节老版记事本打开时可能识别为 ANSI,产生乱码观感
UTF-8 带 BOM文件头多EF BB BFcopy /b合并时 BOM 变成可见伪字符
UTF-16 LE/BE字符固定 2 字节,带FF FE或FE FF头copy /b合并后大量00字节混入,整行面目全非

这里得补充一句:Win10 1809 之后的记事本已经能自动识别 UTF-8 无 BOM 文件,但很多老工具、老脚本、还有部分Excel 导入流程认的还是带 BOM 的 UTF-8。因此我自己的输出偏好是:如果下游环境不确定,输出用带 BOM 的 UTF-8,兼容性最好。Windows PowerShell 5.1 里-Encoding UTF8写出的恰好就是带 BOM,某种程度上反而省事。

4.3 批量统一编码:两个常用 PowerShell 片段

既然乱码根源多半是源文件编码不统一,那合并前最好先把所有文件统一成同一编码。批量转码我用过一个很稳的片段,把 UTF-8 带 BOM 或无 BOM 的文件统一转成带 BOM 的 UTF-8:

$files = Get-ChildItem 'D:\logs' -Filter '*.txt' foreach ($f in $files) { $content = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::UTF8) [System.IO.File]::WriteAllText($f.FullName, $content, (New-Object System.Text.UTF8Encoding($true))) }

注意这里的ReadAllText指定UTF8,如果原文件实际上是 GBK,读进来就是乱码,写出去会把乱码固化。所以你在批量转换前,必须先用前面看文件头的方法确认这批文件确实是 UTF-8 家族。如果文件是 GBK/ANSI,把编码参数换成系统 ANSI 代码页:

$content = [System.IO.File]::ReadAllText($f.FullName, [System.Text.Encoding]::GetEncoding('GB2312'))

我不建议新手直接批量转,因为一旦判断错,原文件可能被覆盖成永久乱码。更稳妥的顺序是:先复制一份到测试目录,转换后在测试目录打开确认无误,再对正式文件操作。转码这件事,慢就是快。

5. 大文件与批量场景:流式处理与断点续跑

合并几十个几 GB 的日志文件时,用Get-Content | Add-Content这种管道方案很容易卡死或运行数小时,不是命令写错,而是 PowerShell 管道的开销实在太大。我第一次在大文件上跑管道时,等了一个小时没跑完,心态直接崩了。

5.1 为什么 Get-Content 大文件很慢

Get-Content会把每一行都包装成一个对象,通过管道逐条传给Add-Content。这个过程里每个对象都要分配内存,管道还要做逐个元素传递,再加上编码转换开销,处理大文件时效率非常低。可以粗略理解成:明明可以用水管直接放水,你却拿个勺子一勺一勺舀。

所以我的铁律是:大文件合并,要么用copy /b做无加工裸合并,要么用StreamReader流式逐行读写。前者适合纯拼内容,后者适合必须要改每行内容的场景。

5.2 大文件合并的推荐模板:StreamReader + StreamWriter

这里给一个可以直接复制的模板,功能是合并指定目录下所有.log文件,并且可选给每行加来源文件名的前缀:

param( [string]$SourceDir = 'D:\logs', [string]$OutputFile = 'D:\logs\all.log', [switch]$WithSourcePrefix ) $writer = New-Object System.IO.StreamWriter($OutputFile, $false, [System.Text.Encoding]::UTF8) Get-ChildItem -Path $SourceDir -Filter '*.log' | Sort-Object LastWriteTime | ForEach-Object { $fileName = $_.BaseName Write-Host "Processing: $($_.Name)" try { $reader = New-Object System.IO.StreamReader($_.FullName, [System.Text.Encoding]::UTF8) while ($null -ne ($line = $reader.ReadLine())) { if ($WithSourcePrefix) { $writer.WriteLine("[$fileName] $line") } else { $writer.WriteLine($line) } } } finally { $reader.Close() } } $writer.Close() Write-Host "Done: $OutputFile"

这段脚本几个细节值得解释:

  • StreamReader.ReadLine()会一直处理到文件末尾,内存里只保留当前一行,不会撑爆内存。
  • Sort-Object LastWriteTime是按修改时间排序,很多日志合并场景按时间排序比按文件名排序更合理。
  • 输出路径用D:\logs\all.log这种完整路径,脚本里直接写死,不容易出现通配符匹配到自己的问题。
  • 每处理完一个文件就Write-Host一行,能够直观看到进度,如果任务中断也知道卡在哪个文件上。

5.3 中途断掉怎么办:分批合并与断点续跑

大文件合并最怕跑了一半机器重启或者硬盘写满。如果用了copy /b a.txt + b.txt ...这种一次性命令,断掉后没有明显的断点信息,重跑也可能重复叠加内容。

我的做法是把大任务拆成几个子任务:

  1. 把源文件按目录或按文件名分成 3 组,每组各自合并成一个临时文件。
  2. 最后再用一条copy /b或者一个流式脚本,把几个临时文件合成最终结果。

这样任何一组失败了,只重跑这一组就行,不用从零开始。配合上面脚本里的Write-Host进度日志,重跑也知道从哪断的。

如果断点发生在流式脚本里,更稳妥的方案是脚本支持“跳过已处理文件”。实现思路是:合并前先检查输出文件已经包含哪些来源标记,然后在ForEach循环里跳过这些文件。但说实话,这种复杂逻辑在日常场景里有点过度设计了,大多数时候把任务拆小就足够了。

5.4 性能实测:不同方案的速度差异

我拿手头一批普通 SATA 固态盘上的日志做过简单对比,10 个日志文件、每个约 200MB,纯合并不做任何加工:

合并方式耗时(参考值)内存占用
copy /b裸合并十几秒极低
PowerShell 管道 `Get-ContentAdd-Content`三到五分钟
StreamReader + StreamWriter流式合并一两分钟稳定在几十 MB

不同机器、不同文件系统下数值会有差别,量级可以参考。结论很直观:纯合并用copy /b;需要加工时用流式脚本;管道方案尽量只用于中小文件。

6. 一套可以直接复用的合并脚本与定时任务配置

文章最后这部分,我把前面所有经验浓缩成一个通用 PowerShell 脚本,并给出定时执行的配置。这个脚本我每次跨机器处理合并任务时都直接拿来改,省了很多重复劳动。

6.1 通用合并脚本的参数设计

脚本支持几个常用开关:

  • -SourceDir:源文件目录。
  • -OutputFile:输出文件完整路径。
  • -Pattern:文件匹配模式,默认*.log,可改成*.txt。
  • -WithSourcePrefix:是否在每行前加文件名前缀。
  • -SkipEmptyLines:是否跳过空行。

脚本启动时先清理已有输出文件,然后按文件名排序合并,最后打印统计信息。

6.2 完整脚本正文

param( [string]$SourceDir = 'D:\logs', [string]$OutputFile = 'D:\logs\merged.log', [string]$Pattern = '*.log', [switch]$WithSourcePrefix, [switch]$SkipEmptyLines ) if (Test-Path $OutputFile) { Remove-Item $OutputFile -Force } $fileCount = 0 $lineCount = 0 $writer = New-Object System.IO.StreamWriter($OutputFile, $false, [System.Text.Encoding]::UTF8) Get-ChildItem -Path $SourceDir -Filter $Pattern -File | Sort-Object Name | ForEach-Object { $fileCount++ $fileName = $_.BaseName Write-Host "[$fileCount] Merging: $($_.Name)" try { $reader = New-Object System.IO.StreamReader($_.FullName, [System.Text.Encoding]::UTF8) while ($null -ne ($line = $reader.ReadLine())) { if ($SkipEmptyLines -and $line.Trim() -eq '') { continue } if ($WithSourcePrefix) { $writer.WriteLine("[$fileName] $line") } else { $writer.WriteLine($line) } $lineCount++ } } finally { $reader.Close() } } $writer.Close() $size = (Get-Item $OutputFile).Length Write-Host "Merge complete." Write-Host "Files: $fileCount" Write-Host "Lines: $lineCount" Write-Host "Size: $([math]::Round($size / 1MB, 2)) MB"

注意-File参数是 PowerShell 3.0 之后才有的,如果你的环境特别老,请去掉这个参数,改用Where-Object { $_.PSIsContainer -eq $false }过滤目录。

6.3 定时执行:任务计划程序跑合并脚本

合并日志这种活通常有固定的执行周期。Windows 自带的“任务计划程序”就能搞定,没必要装第三方工具。配置要点就三个:

  1. “操作”里程序填powershell.exe,参数填-ExecutionPolicy Bypass -File "D:\scripts\merge-logs.ps1"。
  2. “触发器”按需求设置,比如每天凌晨 3 点。
  3. “设置”里勾选“如果任务运行时间超过以下时间,停止任务”,我一般设 2 小时,避免脚本卡死拖垮机器。

-ExecutionPolicy Bypass是为了绕开脚本执行策略限制,避免新加的脚本因为默认 Restricted 策略根本无法运行。

6.4 我的使用习惯和最后一个提醒

最后说几个我踩过坑之后养成的规矩:

  • 所有合并脚本第一件事就是删除旧的输出文件,彻底避免“自己合自己”。
  • 输出文件永远不和源文件放在同一层目录,或者即使放了也给输出文件一个独特的扩展名,例如.merged.tmp,确保匹配模式扫不到它。
  • 合并完成后至少抽样打开一次,看一眼上、中、下三段内容,确认编码和换行正常。别信脚本输出“完成”就以为万事大吉。
  • 源文件编码不确定时,先合并前 20 行到临时文件,确认无误后再跑全量。
  • 脚本里保留Write-Host "Lines: $lineCount"这样的统计信息,我靠它对比源文件行数,快速判断合并过程中有没有丢行。

我自己的习惯是:速度优先的裸合并,直接敲cmd /c copy /b,一条命令完事;但凡涉及编码、加前缀、过滤或者文件数超过五十,一律用上面这份流式脚本。文本合并这件事,没有银弹,但只要你分清楚了“裸拼”和“加工”这两类需求,再配合统一的编码策略,大部分问题都能在动手前就消掉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询