
字符串和二进制流之间的转换这个操作在C#里看起来不起眼但真到用的时候踩坑的人一抓一大把。我做数据采集和设备通信相关项目有些年头了序列化、网络传输、文件落盘、协议封包这些场景里字符串转二进制流几乎是每天都要碰的活。有人用Encoding.Default一把梭结果在另一台机器上中文全变问号有人忘了给流复位读出来永远是空数组还有人把字符串直接强转成byte[]编译不过还纳闷半天。这篇就把这件事从头到尾掰开揉碎讲清楚字符串在C#里到底是什么、转二进制流有哪几条路、每条路适合什么场景、参数怎么选、坑在哪里。不管你是刚接触C#的新手还是写过几年但一直靠复制粘贴过日子的老手看完都能找到能直接抄作业的代码和判断依据。1. 先把概念捋直字符串和二进制流到底是什么关系1.1 字符串不是字节这是所有问题的起点很多人对字符串有个根深蒂固的误解觉得字符串就是一堆字符排在一起转成字节无非是挨个取出来。这个理解在C#里是错的而且错得很关键。C#的string是UTF-16编码的字符序列内部每个char占2个字节它存的是字符的编号码位不是字节。而二进制流Stream面向的是字节序列一个字节就是8个bit没有字符的概念。这两者之间隔着一层编码Encoding编码决定了字符编号怎么映射成字节序列。打个生活化的比方字符串像是一份用中文写好的菜单二进制流像是一箱按规格打包好的食材。你不能把菜单直接塞进箱子得先按某种翻译规则把菜单上的每道菜翻译成食材清单再按清单打包。这个翻译规则就是编码。同一个中字用UTF-8编码是3个字节E4 B8 AD用GBK编码是2个字节D6 D0用UTF-16是2个字节2D 4E小端序。编码不同字节序列完全不同长度也不同。所以第一个必须刻进脑子里的结论是字符串转二进制流本质是按指定编码把字符序列编码成字节序列再写进流。跳过编码这一步直接谈转换就是在耍流氓。我见过太多线上乱码问题追根溯源都是编码没对齐——发送端用UTF-8接收端按GBK解字节没错但翻译规则错了出来的就是一堆问号。1.2 二进制流的两层含义Stream对象和byte数组标题里说的二进制流在实际开发中有两种常见理解得区分开。第一种是System.IO.Stream及其派生类比如MemoryStream、FileStream、NetworkStream它们是有状态的、可读可写的流对象支持分块读写、定位、异步操作。第二种是byte[]字节数组它是纯粹的内存数据块没有流的状态概念。两者经常互相转换MemoryStream可以ToArray()拿到byte[]byte[]可以塞进MemoryStream构造。为什么这个区分重要因为很多初学者把转成二进制流理解成转成byte[]然后拿byte[]去调需要Stream的API发现类型对不上反过来有人拿到了MemoryStream却不知道里面有多少有效数据直接ToArray()把整个缓冲区包括没写满的部分都取出来了多出一堆零字节。这两种情况我都遇到过后面会专门讲怎么避开。从实操角度看绝大多数字符串转二进制流的需求最终落地形态是byte[]或者MemoryStream。前者适合网络发送、哈希计算、存数据库BLOB字段后者适合喂给需要Stream参数的API比如序列化器、压缩库、加密流。选哪个取决于下游接口要什么而不是凭个人喜好。1.3 为什么这个操作值得单独拿出来讲有人可能觉得不就是Encoding.UTF8.GetBytes(str)一行的事吗有什么好讲的。单看这一行确实简单但真实项目里的复杂度在于编码选错导致乱码、BOM头多出来几个字节导致协议解析失败、流位置没复位导致读空、大字符串一次性转字节导致内存暴涨、跨平台默认编码不一致导致行为漂移。这些问题单拎出来都不难但组合在一起足够让一个功能卡上两三天。我印象很深的一次做一个设备上报数据的解析协议规定字符串字段用定长字节填充结果同事用Encoding.Default转字节在开发机上默认UTF-8长度刚好部署到另一台环境默认GBK后中文字段长度变了整个报文错位排查了一整天才定位到是编码问题。从那以后我们团队定了个规矩任何涉及字符串转字节的地方编码必须显式指定禁止用Encoding.Default。这条规矩后来救了我们无数次。2. 方案选型四条路各有各的适用场景2.1 路线一Encoding.GetBytes最直接也最常用这是最基础、最常用的方式一行代码搞定string text hello 世界; byte[] bytes Encoding.UTF8.GetBytes(text);Encoding.UTF8是一个静态只读实例GetBytes把字符串按UTF-8规则编码成字节数组。这条路的优点是简单、无状态、线程安全Encoding实例本身是线程安全的适合绝大多数我只要字节的场景。缺点是它只给你byte[]不给你Stream如果下游要Stream还得再包一层MemoryStream。选UTF-8的理由很充分它是互联网事实标准跨平台、跨语言兼容性最好中文占3字节英文占1字节空间效率对混合内容友好。但要注意UTF-8编码中文时一个字符变3字节如果你按字符数预估缓冲区大小会严重低估。我见过有人写new byte[str.Length]然后GetBytes中文一多直接数组越界正确做法是用Encoding.UTF8.GetByteCount(str)先算准确长度。还有一个细节Encoding.UTF8这个实例在编码时不会自动加BOM字节顺序标记。BOM是UTF-8的可选前缀EF BB BF主要给文本文件用告诉读取方我是UTF-8。如果你转字节是为了写文件且希望带BOM得用new UTF8Encoding(true)如果是网络协议或哈希计算绝对不能带BOM否则多出3个字节对端解析全乱。这个区别后面会展开。2.2 路线二MemoryStream StreamWriter适合要Stream的场景当下游API明确要Stream比如JsonSerializer.Serialize(stream, obj)、GZipStream、CryptoStream用StreamWriter往MemoryStream里写是最自然的using var ms new MemoryStream(); using (var writer new StreamWriter(ms, new UTF8Encoding(false), 1024, leaveOpen: true)) { writer.Write(text); } ms.Position 0; byte[] bytes ms.ToArray();这段代码里有几个关键点每一个都是坑。第一StreamWriter的构造函数要显式传Encoding不传的话默认是UTF8Encoding带BOM的那个.NET Framework下行为还有差异写出来的字节前面可能多3个BOM字节。第二leaveOpen: true必须加否则writer被using释放时会顺带把ms也关掉后面ms.ToArray()直接抛ObjectDisposedException。第三写完必须ms.Position 0复位否则下游从当前位置读读到的是流的末尾啥也没有。为什么用StreamWriter而不是直接ms.Write(bytes)因为StreamWriter帮你处理了编码转换和缓冲适合我有一段字符串要写进流的语义。但如果字符串本身已经是byte[]了就没必要绕这一圈直接ms.Write(bytes, 0, bytes.Length)更干脆。选型的原则是手上是字符串就用Writer手上是字节就直接Write别为了统一风格多绕一层。2.3 路线三BinaryWriter适合结构化二进制协议做设备通信、自定义协议封包的时候经常是字符串字段 数值字段混着写这时候BinaryWriter更顺手using var ms new MemoryStream(); using (var bw new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true)) { bw.Write((short)0x1234); // 协议头 bw.Write(Encoding.UTF8.GetBytes(text)); // 字符串按字节写 bw.Write(text.Length); // 长度字段 }注意这里有个容易混淆的点BinaryWriter.Write(string)这个方法内部会先写一个7位编码的长度前缀再写UTF-8字节这个格式是BinaryWriter自己定的跟你的协议大概率对不上。所以做自定义协议时不要直接用bw.Write(string)而是自己先GetBytes再Write(byte[])长度字段自己控制。这个坑我踩过当时对端设备按固定长度解析结果我们多发了一个变长前缀报文整体偏移查了半天。BinaryWriter的价值在于它把各种基础类型int、short、float、bool的字节序和写入逻辑都封装好了配合MemoryStream拼报文非常高效。但它的Write(string)行为要特别警惕这是它和StreamWriter最大的区别。2.4 路线四Convert 与 Span 系列新版本的高效玩法.NET Core 之后Encoding.GetBytes有了一批基于Spanbyte的重载可以避免中间数组分配string text hello 世界; int maxLen Encoding.UTF8.GetMaxByteCount(text.Length); Spanbyte buffer stackalloc byte[maxLen 256 ? 256 : maxLen]; int written Encoding.UTF8.GetBytes(text, buffer); ReadOnlySpanbyte result buffer.Slice(0, written);GetMaxByteCount给的是最坏情况下的字节数UTF-8下约等于字符数×33用它开缓冲区保证不会越界。stackalloc在栈上分配适合小数据一般控制在1KB以内大数据还是老老实实用ArrayPoolbyte.Shared.Rent租数组用完Return避免GC压力。这套玩法在高频转换场景比如每秒几万次的日志采集里性能提升很明显实测比每次new byte[]快30%以上GC次数也大幅下降。至于Convert类它主要处理Base64、十六进制这类表示形式转换不是严格意义上的字符串转二进制流但实际项目里经常配套使用。比如把字节流转成Base64字符串传输接收端再Convert.FromBase64String还原字节。这个组合在JSON里传二进制数据时特别常见后面单独讲。四条路线的对比如下路线输出形态适用场景主要风险点Encoding.GetBytesbyte[]网络发送、哈希、存库编码选错、长度预估不足MemoryStreamStreamWriterStream喂给序列化器/压缩/加密BOM、leaveOpen、Position复位BinaryWriterStream自定义二进制协议封包Write(string)的长度前缀Span重载Span/byte[]高频转换、性能敏感栈溢出、缓冲区生命周期3. 编码选择的门道UTF-8、GBK、Unicode到底怎么定3.1 默认编码是个陷阱永远别依赖它Encoding.Default在.NET Framework下取的是系统当前ANSI代码页简体中文Windows上是GBK/936在.NET Core及以后版本下固定是UTF-8。这意味着同一段代码在旧框架和新框架下行为可能完全不同在中文Windows和英文Windows下也可能不同。这种看环境脸色的行为是线上事故的温床。我的建议很直接代码里出现Encoding.Default就当成bug处理。要么显式写Encoding.UTF8要么显式写Encoding.GetEncoding(GBK)需要注册编码提供程序让意图清清楚楚。如果确实要跟随系统那也得把跟随系统这个决策写进注释并且做好跨环境测试而不是稀里糊涂用默认值。顺带说一句.NET Core默认不带GBK等非Unicode编码直接Encoding.GetEncoding(GBK)会抛NotSupportedException需要先注册CodePagesEncodingProvider.Instance。这个细节很多人不知道迁移项目时一跑就崩还以为是代码写错了。3.2 UTF-8、UTF-16、GBK的字节差异实测光说理论不够直观我写了个小测试把同一个字符串用不同编码转字节结果如下string s A中; Console.WriteLine(BitConverter.ToString(Encoding.UTF8.GetBytes(s))); // 41-E4-B8-AD Console.WriteLine(BitConverter.ToString(Encoding.Unicode.GetBytes(s))); // 41-00-2D-4E Console.WriteLine(BitConverter.ToString(Encoding.BigEndianUnicode.GetBytes(s))); // 00-41-4E-2DA在UTF-8下是1字节41中是3字节E4 B8 AD总共4字节。Encoding.Unicode是UTF-16小端序A变2字节41 00中变2字节2D 4E总共4字节。大端序则是00 41 4E 2D。可以看到同一个字符串不同编码下字节序列和排列顺序都不一样。这个实测的意义在于跨系统交互时双方必须约定同一个编码并且约定字节序。UTF-16有大小端之分如果发送方用小端、接收方按大端解出来的字符全是乱的。UTF-8没有字节序问题它是字节流编码所以跨平台场景优先选UTF-8能省掉一大类麻烦。3.3 BOM该带还是不该带这是个问题BOMByte Order Mark在UTF-8里是EF BB BF三个字节在UTF-16里是FF FE或FE FF。它的本意是标记字节序和编码类型但用错了地方就是灾难。判断标准很简单数据是给人看的文本文件可以带BOM数据是给程序解析的协议/哈希/存储绝对不能带BOM。因为BOM本质上是内容之外的元数据程序解析时如果没预期到它就会把它当成正文的第一个字符导致字段错位、哈希值对不上、JSON解析失败某些解析器不容忍BOM。控制BOM的方式Encoding.UTF8实例编码时不带BOMnew UTF8Encoding(true)带BOMnew UTF8Encoding(false)不带。StreamWriter的默认编码在不同框架下行为不一所以务必显式传new UTF8Encoding(false)。我见过最坑的情况是本地测试用File.WriteAllText写文件默认带BOM读取方用File.ReadAllText能正常读它会自动跳过BOM一切正常结果同样的字节流发给设备设备不认BOM直接报协议错误。所以涉及字节流的场景一律按不带BOM处理。3.4 编码选型速查表场景推荐编码理由网络传输、跨平台交互UTF-8无BOM兼容性最好无字节序问题存数据库文本字段UTF-8 或 数据库默认跟数据库字符集对齐与老系统/老设备对接按对方协议常见GBK对方只认这个没得选写给人看的文本文件UTF-8可带BOM编辑器识别方便哈希/签名计算UTF-8无BOM字节必须确定不能有额外前缀内部临时转换UTF-8统一标准减少心智负担这张表不是死规矩但能覆盖八成场景。剩下两成特殊情况判断依据永远是下游怎么解我就怎么编而不是我觉得哪个好。4. 完整实操从字符串到流再到字节的闭环4.1 场景设定模拟一个设备上报报文的封装假设我们要给某类设备封装一条上报报文格式约定为2字节魔数0xAA55、4字节字符串长度小端序、N字节UTF-8字符串内容、2字节CRC校验这里用简单累加代替仅为演示。这个场景涵盖了字符串转字节、流写入、长度计算、字节序处理是个很典型的综合案例。先明确几个决策字符串用UTF-8无BOM编码长度字段用int小端序整个报文用MemoryStream拼装最后ToArray()发给设备。为什么用MemoryStream而不是直接拼byte[]因为报文里有变长字段用流可以边算边写不用预先算总长度代码更清晰也不容易算错偏移。4.2 逐步实现与关键代码注释public static byte[] BuildReport(string content) { // 1. 先编码字符串拿到确定的字节 byte[] contentBytes Encoding.UTF8.GetBytes(content); using var ms new MemoryStream(); using var bw new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true); // 2. 写魔数注意BinaryWriter默认小端序 bw.Write((ushort)0xAA55); // 3. 写长度用编码后的字节数不是字符数 bw.Write(contentBytes.Length); // 4. 写内容 bw.Write(contentBytes); // 5. 写校验简单累加仅演示 byte sum 0; foreach (var b in contentBytes) sum b; bw.Write(sum); bw.Flush(); return ms.ToArray(); }这段代码有几个值得说的点。第一长度字段写的是contentBytes.Length也就是编码后的字节数不是content.Length字符数。中文场景下这两个值差三倍写错了对端按长度截取就会截错。第二BinaryWriter默认小端序跟大多数设备协议一致如果对方要求大端序得手动反转字节或用BinaryPrimitives.WriteInt32BigEndian。第三bw.Flush()在ToArray()前调用确保缓冲区数据都落到ms里虽然BinaryWriter写MemoryStream时通常即时生效但养成flush习惯没坏处。4.3 反向解析从字节流还原字符串有封装就有解析接收端拿到byte[]后要还原字符串public static string ParseReport(byte[] data) { using var ms new MemoryStream(data); using var br new BinaryReader(ms, Encoding.UTF8, leaveOpen: true); ushort magic br.ReadUInt16(); if (magic ! 0xAA55) throw new InvalidDataException(魔数不匹配); int len br.ReadInt32(); byte[] contentBytes br.ReadBytes(len); string content Encoding.UTF8.GetString(contentBytes); byte sum br.ReadByte(); // 校验略 return content; }这里的关键是ReadBytes(len)读的是字节数然后Encoding.UTF8.GetString按UTF-8解码。如果发送端用UTF-8、接收端用GBK解中文必然乱码。另外ReadBytes如果剩余数据不足len它不会抛异常而是返回实际读到的字节可能少于len这时候GetString出来的字符串是残缺的所以生产代码里要校验contentBytes.Length len。4.4 用Span优化高频路径如果这个封装方法每秒被调用上万次MemoryStream和BinaryWriter的对象分配会成为GC负担。这时候可以改用SpanArrayPoolpublic static byte[] BuildReportFast(string content) { int maxContent Encoding.UTF8.GetMaxByteCount(content.Length); int total 2 4 maxContent 1; byte[] rented ArrayPoolbyte.Shared.Rent(total); try { Spanbyte span rented.AsSpan(0, total); BinaryPrimitives.WriteUInt16LittleEndian(span, 0xAA55); int written Encoding.UTF8.GetBytes(content, span.Slice(6)); BinaryPrimitives.WriteInt32LittleEndian(span.Slice(2), written); byte sum 0; foreach (var b in span.Slice(6, written)) sum b; span[6 written] sum; return span.Slice(0, 7 written).ToArray(); } finally { ArrayPoolbyte.Shared.Return(rented); } }这个版本避免了MemoryStream和BinaryWriter的分配GetMaxByteCount保证缓冲区够大BinaryPrimitives直接按指定字节序写数值ArrayPool复用数组。实测在百万次调用下耗时从约1.2秒降到0.4秒GC次数从几百次降到个位数。代价是代码可读性下降所以只在性能确实成为瓶颈时才这么写别一上来就过度优化。5. 常见问题与排查实录5.1 乱码问题九成是编码不一致乱码是字符串转字节最高频的问题表现是中文变问号、方块、或者一堆看不懂的符号。排查思路固定三步第一步确认发送端用的什么编码看代码里Encoding.XXX第二步确认接收端用的什么编码第三步两边对齐。如果两边代码都不归你管那就抓包看字节用E4 B8 AD这种特征判断是不是UTF-8用D6 D0判断是不是GBK。有个隐蔽的乱码场景字符串本身没问题编码也对但中间经过了某个环节比如HTTP头、数据库连接、日志框架做了二次编码转换把UTF-8字节当成GBK字符串又转了一遍。这种双重编码问题抓包看字节会发现长度翻倍、内容面目全非。解决办法是逐层排查找到那个多转一次的环节去掉。5.2 流读出来是空的Position没复位用MemoryStream写完再读读出来空数组这是新手最常踩的坑。原因很简单写完以后Position在流的末尾直接读当然读不到东西。解决方法是读完写操作后立刻ms.Position 0或者用ms.Seek(0, SeekOrigin.Begin)。我个人的习惯是只要一个MemoryStream要经历写→读两个阶段写完必复位形成肌肉记忆。还有一种情况是StreamWriter或BinaryWriter被using释放时把底层流也关了导致后续操作抛异常。解决办法就是构造时传leaveOpen: true明确告诉它你走的时候别关门。5.3 长度对不上字符数不等于字节数str.Length返回的是字符数UTF-16码元数不是字节数。一个中文字符在UTF-8下占3字节在GBK下占2字节在UTF-16下占2字节。用字符数去分配字节缓冲区或者写长度字段中文一多就出错。正确做法永远是用Encoding.XXX.GetByteCount(str)算字节数或者直接GetBytes后取.Length。还有个更隐蔽的emoji和一些生僻字在UTF-16里占2个char代理对str.Length算出来是2但按字符算其实是1个。这种字符在UTF-8下占4字节。如果你的长度字段语义是字符数那还得用StringInfo按文本元素计数不能简单用Length。这个细节在涉及用户昵称、评论内容的场景里要特别注意。5.4 常见问题速查表现象可能原因排查方法解决中文变问号编码不一致抓包看字节特征两端统一编码读流为空Position在末尾打印Position值读完复位到0对象已释放异常Writer关了底层流看异常堆栈构造传leaveOpen:true长度字段对不上用了字符数对比Length和GetByteCount改用字节数多出3字节UTF-8带BOM看开头是否EF BB BF用new UTF8Encoding(false)协议解析错位BinaryWriter.Write(string)前缀看字节流前几位手动GetBytes再Write跨环境行为不同Encoding.Default检查是否用了Default显式指定编码5.5 几个容易被忽略的实操心得第一个心得转换前先想清楚下游要什么。是要byte[]还是要Stream要不要BOM字节序有没有要求长度字段是字节数还是字符数这几个问题在动手写代码前回答清楚能省掉后面80%的返工。第二个心得写单元测试锁定编码行为。给转换方法写个测试断言中字转出来的字节是E4 B8 AD这样以后谁改了编码测试立刻红比线上出问题再查强一百倍。我们团队所有涉及编码的工具方法都有这类字节快照测试。第三个心得大字符串别一次性转。如果字符串有几十MBGetBytes会分配一个同样大的数组内存峰值很高。这种场景应该分块处理用Encoder对象配合Convert方法逐块转换或者用StreamWriter直接写FileStream让流自己管理缓冲。我处理过一个日志导出功能一开始一次性转2GB字符串直接OOM后来改成分块写文件内存占用降到几MB。第四个心得注意字符串的不可变性带来的隐性开销。每次GetBytes都会产生新数组如果在一个循环里反复转换同一个字符串考虑把结果缓存起来。反过来如果字符串内容会变别缓存否则拿到的是旧数据。6. 延伸场景Base64、压缩、加密里的字符串转流6.1 Base64二进制数据的文本化桥梁JSON、XML、配置文件这些文本格式没法直接塞byte[]标准做法是转Base64字符串string text hello 世界; byte[] bytes Encoding.UTF8.GetBytes(text); string base64 Convert.ToBase64String(bytes); // 还原 byte[] back Convert.FromBase64String(base64); string restored Encoding.UTF8.GetString(back);Base64把每3个字节编码成4个字符体积膨胀约33%换来的是纯ASCII安全传输。这里要注意的是Base64编码的是字节所以必须先确定字符串的编码再转Base64否则还原时编码对不上照样乱码。另外Base64有标准变体和URL安全变体//换成-/_放在URL或文件名里要用后者否则会被转义或截断。6.2 压缩与加密流式处理的典型应用字符串转字节后接GZipStream压缩或者接CryptoStream加密是流式处理的经典组合byte[] raw Encoding.UTF8.GetBytes(text); using var output new MemoryStream(); using (var gzip new GZipStream(output, CompressionLevel.Optimal, leaveOpen: true)) { gzip.Write(raw, 0, raw.Length); } byte[] compressed output.ToArray();这种链式流的关键是leaveOpen和Dispose顺序。GZipStream释放时会flush并写完尾部数据所以必须先释放gzip再取output.ToArray()顺序反了会丢数据。加密流同理而且加密流对数据长度有块对齐要求写的时候要注意padding。这些细节在单独讲字符串转流时容易忽略但一旦组合使用就会暴露出来。6.3 文件落盘编码与BOM的最终考验把字符串写成文件是字符串转二进制流最落地的场景File.WriteAllBytes(out.txt, Encoding.UTF8.GetBytes(text)); // 无BOM File.WriteAllText(out2.txt, text, new UTF8Encoding(true)); // 带BOMWriteAllBytes配合GetBytes写出来的是无BOM的UTF-8适合给程序读WriteAllText配合UTF8Encoding(true)写出来带BOM适合给编辑器读。选哪个取决于文件的下游消费者。我个人的偏好是程序间交换的文件一律无BOM人看的文件可以带BOM但团队内部要统一别一半带一半不带否则版本管理工具会显示莫名其妙的差异。7. 性能与内存大数据量下的取舍7.1 分配开销从哪来每次Encoding.UTF8.GetBytes(str)都会分配一个新数组数组大小等于编码后的字节数。对于小字符串几百字节这点分配无所谓但对于大字符串或者高频调用分配和GC就成了瓶颈。MemoryStream内部也有一个可增长的缓冲区默认初始容量小数据多了会多次扩容复制这也是开销。优化的方向有两个一是减少分配次数用ArrayPool、Span二是减少复制次数预分配准确容量、避免中间流。前面4.4节的BuildReportFast就是这两个方向的实践。但优化要有度先profile再动手别凭感觉优化。我见过有人为了性能把简单的一行GetBytes改成几十行Span代码结果那个方法一天调用不到十次纯属给自己找麻烦。7.2 大字符串的分块处理字符串超过几MB时一次性GetBytes的内存峰值会很吓人。这时候用Encoder分块转换var encoder Encoding.UTF8.GetEncoder(); char[] chars text.ToCharArray(); byte[] buffer new byte[4096]; int charIndex 0; while (charIndex chars.Length) { encoder.Convert(chars, charIndex, Math.Min(1024, chars.Length - charIndex), buffer, 0, buffer.Length, false, out int charsUsed, out int bytesUsed, out bool completed); // 把buffer[0..bytesUsed]写出去 charIndex charsUsed; }Encoder对象会保存跨块的编码状态处理代理对emoji时不会把一个字符拆到两个块里导致乱码。这个细节很关键直接用Substring分块再GetBytes遇到代理对就会出问题。分块处理适合写文件、发网络流不适合需要完整byte[]的场景。7.3 性能对比实测数据我在一台普通开发机上做了个简单对比字符串为1000个中文字符循环10万次方式耗时GC Gen0次数Encoding.GetBytes约850ms约200次MemoryStreamStreamWriter约2100ms约600次SpanArrayPool约320ms约5次数据仅供参考不同机器和框架版本会有差异但趋势是明确的SpanArrayPool最快MemoryStream最慢对象多GetBytes居中。选型时按性能要求和代码可读性权衡绝大多数业务代码用GetBytes就够了只有热点路径才值得上Span。8. 跨框架与跨平台的差异提醒8.1 .NET Framework 与 .NET Core 的行为差异最典型的就是Encoding.Default前面说过旧框架跟随系统代码页新框架固定UTF-8。除此之外StreamWriter的默认编码在旧框架下是UTF8Encoding带BOM新框架下也是UTF-8但BOM行为有细微差别所以永远显式指定编码是最稳的。还有Encoding.GetEncoding(GBK)在新框架下需要注册CodePagesEncodingProvider不注册直接抛异常。迁移项目时凡是涉及编码和流的代码都要重点回归测试。我们做过一次从旧框架到新框架的迁移功能测试全过结果上线后发现导出的CSV文件在某些机器上中文乱码追下去就是Encoding.Default的行为变了。这种问题测试环境不一定能复现因为测试机的系统区域设置可能和生产机不同。8.2 不同操作系统下的注意事项Linux和macOS上默认没有GBK等代码页Encoding.GetEncoding(GBK)必须注册提供程序。文件路径、换行符\r\nvs\n也会影响字符串转字节的结果——如果你的字符串里包含换行符转成字节后在不同平台下字节序列不同跨平台传输时要统一成\n或者明确约定。这些细节单看很小但在跨平台项目里经常成为玄学bug的来源。9. 我踩过的坑与团队约定做这行久了坑踩得多了慢慢就沉淀出一些团队约定分享出来供参考。第一条所有编码显式声明禁止Encoding.Default代码评审时看到就打回。第二条涉及字节流的工具方法必须有字节快照测试锁定关键字符的编码结果。第三条协议相关的转换代码单独抽类把编码、字节序、长度语义都封装进去业务代码不直接碰Encoding。第四条写完流必复位用完流必释放释放顺序要理清leaveOpen该加就加。还有一个个人习惯调试编码问题时我第一件事是把字节用BitConverter.ToString打出来看比盯着乱码字符串猜有用得多。E4 B8 AD一看就是UTF-8的中文D6 D0一看就是GBKFF FE开头就是UTF-16带BOM。这个技能看起来土但排查效率极高。最后说个心态上的事字符串转二进制流这个操作代码量小但涉及编码、字节序、流状态、内存管理多个层面任何一个环节想当然都会出问题。写的时候多问一句下游怎么解比事后debug三天强。我现在的习惯是凡是跨边界的转换进程间、机器间、存储介质间都先把字节格式在纸上画一遍确认无误再写代码这个习惯帮我省了大量返工时间。