需要使用 Base64 格式吗?那么本网站正好适合您!使用我们的在线工具对数据进行编码或解码,便捷好用。

C#(CSharp)中的 Base64 编码:完整指南

你手上有字节。一张要在 JSON 响应里旅行的 PNG,一个必须塞进 URL 的令牌,一行即将进入只收字母和数字的系统里的文本。在你手心的 byte[] 和它必须穿越的通道之间的某个地方,C# 摆出一整张 Base64 编码器的菜单,而从中挑选才是这门学问真正的技艺。经典的一行代码从 2003 年起就在框架里,基于 span 和 URL 安全的选项随现代运行时到来,每一个都对大小、换行和字母表做出不同的承诺。这篇文章走完整张菜单,为编码器被要求干的每一项真实工作配上能运行的例子。

先把家规一口气说完,因为本站首页已经深入讲过这种格式:编码器每拿三个字节,就写下 64 符号字母表里的 4 个字符,用 1 个或 2 个 = 字符垫住尾巴,所以你的数据离开时比到达时肥了大约 33%。这个数字,不是任何代码,是这篇文章里最重要的事实,而下面所有内容都是关于如何明智地支付它。

编码器菜单:挑你的工具

这是 .NET 世界里编码 API 的完整家族,以及每一个各自为何种场景而生。这里的一切都在运行时本身,唯一的例外是旧框架上的 URL 安全类,它是靠一个小 NuGet 包搭载进来的:

API 可用版本 适用场景
Convert.ToBase64String(byte[]) .NET Framework 1.1(2003) 经典款。整块数组进,带填充字符串出。没有选项,没有意外。
Convert.ToBase64String(byte[], int, int) .NET Framework 1.1(2003) 编码更大数组中的一段,无需先把那段拷贝出来。
Convert.ToBase64String(byte[], Base64FormattingOptions) .NET 2.0(2005) 带旋钮的经典款:可选地在每 76 个字符后插入换行,MIME 的方式。
Convert.ToBase64String(ReadOnlySpan<byte>, Base64FormattingOptions) .NET Core 2.1(2018) span 版本:编码缓冲区里的一个视图,没有数组拷贝,没有切片分配。
Convert.ToBase64CharArray(byte[], int, int, char[], int) .NET Framework 1.1(2003) 写进你自己拥有的字符缓冲区,并拿回用了多少字符。
Convert.TryToBase64Chars(ReadOnlySpan<byte>, Span<char>, out int, ...) .NET Core 2.1(2018) 用布尔值代替异常:缓冲区放得下就编码,放不下报告 false。
System.Buffers.Text.Base64.EncodeToUtf8EncodeToUtf8InPlace .NET Core 2.1(2018) 严格的 span 家族:用状态码代替异常,还能为你已经拥有的缓冲区做原地膨胀。
System.Buffers.Text.Base64Url.EncodeToString 和兄弟们 .NET 9(2024) URL 安全字母表,输出不带填充。在 .NET Framework 4.6.2+ 和 .NET Standard 2.0 上:用 Microsoft.Bcl.Memory NuGet 包。
ToBase64Transform + CryptoStream .NET Framework 1.1(2003) 流式:让文件流动时编码,从不把整个负载装进内存。

如果你的项目目标是 2018 年及之后的某个 .NET 版本,前七行和底部那对流式搭档都在盒子里。Base64Url 需要 .NET 9 或更新版本,或者在更老的平台上装 Microsoft.Bcl.Memory 包。再往前看一眼:.NET 11 的库在本文撰写时还处于预览状态,通用版预计在 2026 年末发布,它会在现有类型上追加更多 Base64 便捷 API 和重载,所以这张菜单还在继续长大。本文不需要任何其他包。

标准调用:Convert.ToBase64String

C# 编码生涯的百分之九十,就是这一个调用。给它字节,它还给你承载这些字节的字符串:

using System;
using System.Text;

string text = "Man";
byte[] bytes = Encoding.UTF8.GetBytes(text);
string packed = Convert.ToBase64String(bytes);
Console.WriteLine(packed);
// TWFu

注意这个两步形状,因为它是 C# 里最常见的"为什么我的 Base64 对不上"问题。没有哪个重载直接接收 string,而且这是刻意的设计:C# 字符串是 UTF-16,当你说"编码这段文本"时,框架拒绝猜你到底指哪些字节。你先选定字节表示,用 Encoding.UTF8.GetBytes(或数据真正是什么字符集就用什么),然后 Base64 这一步才发生。经典家族的其余成员是同一个调用收细了腰:(byte[], int, int) 重载编码缓冲区里的一段而不把那段拷贝出来,span 重载从 ReadOnlySpan<byte> 干同样的事,当数据是一个更大读缓冲区里的窗口时,它是趁手的工具。经典编码器还有一个性质值得直说:它从不会失败,也从不多问。它永远输出标准字母表,永远包含填充,同样的输入永远给你同样的字符串,所以一根 Base64 字符串是产生它的那些字节可靠的指纹。

76 字符的问题:换行与 Base64FormattingOptions

经典编码器上只有一个旋钮,它从 .NET 2.0 起就在那里:Base64FormattingOptions。把它设为 InsertLineBreaks,编码器就在每 76 个输出字符之后插入一个换行,这正是 MIME 规范给邮件附件用的行长度。把它设为 None,或者使用不带该选项的重载,你得到一根长长的、不断开的字符串:

using System;

byte[] bytes = new byte[90];
string plain = Convert.ToBase64String(bytes);
string wrapped = Convert.ToBase64String(bytes,
  Base64FormattingOptions.InsertLineBreaks);

Console.WriteLine(plain.Length);   // 120
Console.WriteLine(wrapped.Length); // 122,在第 76 个字符后加了一个换行

关于那个旋钮,两个细节在实践中要紧。第一,它插入的换行是 Windows 那一对,回车加换行,不是单独一个换行。所以折行后的输出里含有 \r\n 序列,任何后来只删 \n 就"清理"字符串的代码,都会留下散落的回车躲在数据里。第二,折行发生在 76 个编码后输出字符处,这正是 MIME 标准能保证邮件传输 - 带着它 76 或 78 字符的行长限制 - 永远不会把一个四字符的 Base64 小组劈到两行的原因:76 是 4 的倍数,所以每行都结束在小组边界上。当你生成邮件正文、PEM 风格的文本块、或任何遗留邮件管道要承运的东西时,你要折行形式。其他地方你要不折行形式:JSON 负载、URL 令牌、API 响应,以及将被严格解析器解码的文件 - 那种解析器不喜欢意外。而在 JWT 内部,你永远不要折行形式,那里规范明确禁止换行、空白,甚至填充。

拥有输出:字符缓冲区与 Try API

有时候字符串不是目标,缓冲区才是。你正在往一个定长的字符数组里追加,你正在写一个协议帧,或者你就是不想让运行时替你分配输出。为这些时刻,编码器从 1.1 时代起就有字符缓冲区模式,从 span 时代起就有 Try 模式。字符缓冲区方法写进你提供的数组,并告诉你它用了多少字符,所以给缓冲区定大小是你的工作,标准库甚至把定大小的公式递到你手上:

using System.Buffers.Text;
using System.Text;

byte[] bytes = Encoding.ASCII.GetBytes("Man");
char[] buffer = new char[Base64.GetMaxEncodedToUtf8Length(bytes.Length)];
int written = Convert.ToBase64CharArray(bytes, 0, bytes.Length, buffer, 0);

string packed = new string(buffer, 0, written);
Console.WriteLine(packed);
// TWFu

Try 兄弟从 span 干同样的活,用布尔值作答。它把输入编码进你的目标 span,在出参里报告字符数,目标太小时返回 false,什么也不写。最后一个性质让它配不可信的输入尺寸使用是安全的:失败的调用永远不会给你半满的缓冲区:

using System;

byte[] bytes = { 1, 2, 3 };
char[] buffer = new char[4];

if (Convert.TryToBase64Chars(bytes, buffer, out int written,
  Base64FormattingOptions.None))
{
  Console.WriteLine(new string(buffer, 0, written));
  // AQID
}
else
{
  Console.WriteLine("Buffer too small, nothing was written.");
}

对于 System.Buffers.Text.Base64 里的严格 span 家族,同样的形状存在,只不过用 OperationStatus 契约代替布尔值:EncodeToUtf8 填满你拥有的一个 byte span,并用状态告诉你它是做完了、没地方了、还是需要更多输入;EncodeToUtf8InPlace 则在你愿意让它长进去的缓冲区里已经躺着二进制数据时拿来用:编码会膨胀数据,所以方法把 Base64 文本写在同一块缓冲区的尾部之上,并报告结果有多长。它们全部分享同一条定大小的规则:n 个输入字节的输出永远是 4 * ceil(n / 3) 个字符(含填充),GetMaxEncodedToUtf8LengthBase64Url.GetEncodedLength 这两个辅助方法实现了那个算术 - 后者算的是不带填充的长度,永远等于带填充的尺寸或更短 - 所以从辅助方法定大小,永远不要从记住的常数定。

URL 安全编码器:Base64Url

标准字母表有两个字符 URL 不待见。查询字符串里的 + 按表单解析规则通常被解码成空格,/= 都想要先百分号编码才能坐上路径或参数。RFC 4648 第 5 节定义了 Base64 的 URL 安全变体,把 +/ 换成 -_,这两个字符在任何地方都不需要转义,并且让末尾的 = 填充变成可选。从 .NET 9 起运行时为它有了一个专用类 System.Buffers.Text.Base64Url,它有一个第一次就让人意外的行为:它根本不输出填充:

using System.Buffers.Text;

byte[] bytes = { 1, 2 };
string classic = Convert.ToBase64String(bytes);
string urlSafe = Base64Url.EncodeToString(bytes);

Console.WriteLine(classic); // AQI=
Console.WriteLine(urlSafe); // AQI

那个差异就是全部要点。JWT 段、上传标识符、查询字符串里的令牌、URL 路径里的值:它们全都想要不带填充的 URL 安全形式,而 Base64Url.EncodeToString 直接给出,字母表和填充都按那些格式规定的方式处理。这个类有完整家族:编码到字符串、到字符 span、到 UTF-8 byte span,外加 GetEncodedLength 给缓冲区定大小、IsValid 在输入进来时验证它。如果你的项目跑在更老的运行时上,加 Microsoft.Bcl.Memory 包,微软发布它就是为了让这个类回溯移植到 .NET Framework 4.6.2 及以上:

dotnet add package Microsoft.Bcl.Memory

如果你不能用包,手写的版本就是经典编码器加两次替换加一次修剪,你会在许多 C# 代码库里遇到它:

using System;
using System.Text;

byte[] bytes = Encoding.UTF8.GetBytes("Hello World!");
string packed = Convert.ToBase64String(bytes)
  .Replace('+', '-')
  .Replace('/', '_')
  .TrimEnd('=');

Console.WriteLine(packed);
// SGVsbG8gV29ybGQh,URL 安全且不带填充

那条链的操作顺序值得注意:字符替换发生在标准输出上,填充最后修剪,因为先修剪什么都不会改变,只会让代码更难读,而修剪后再替换虽然也能工作,但正是微妙的 bug 的出生方式。用这个形状处理令牌、标识符和任何要住在 URL 里的东西,把标准字母表留给邮件正文、JSON 负载和文件,那里 +/= 完全如鱼得水。

喂给编码器:字符串、字符集与编码选择

每个从文本出发的编码工作,都始于同一个安静的决定:这段文本变成哪些字节?Base64 这一步是确定性的、无辜的,但它前面的 Encoding 一步才是输出分道扬镳的地方,而且分歧可能是静默的。UTF-8 是现代 Web 上的默认假设,在这里它也是正确的默认:它能往返每一种语言,它是其他每个平台解码你的负载时会假设的东西,也是 Encoding.UTF8 一次调用给你的东西:

using System;
using System.Text;

string original = "h\u00e9llo \u4e16\u754c";
byte[] utf8 = Encoding.UTF8.GetBytes(original);
string packed = Convert.ToBase64String(utf8);
Console.WriteLine(packed);
// aMOpbGxvIOS4lueVjA==

现在看同一个字符经过不同字符集编码,你就会明白为什么没有附着字符集的"同一段文本"不是定义良好的东西:

using System;
using System.Text;

string euro = "\u20ac";
string asUtf8 = Convert.ToBase64String(Encoding.UTF8.GetBytes(euro));
string asLatin1 = Convert.ToBase64String(
  Encoding.GetEncoding("ISO-8859-1").GetBytes(euro));

Console.WriteLine(asUtf8);   // 4oKs
Console.WriteLine(asLatin1); // Pw==

同一个欧元符号,两根不同的 Base64 字符串,都完全合法,而只有一根会在另一边解码回欧元符号。爆炸半径最大的陷阱是 Encoding.Default:在 Windows 上的 .NET Framework 里它是系统的 ANSI 代码页,而在 .NET(Core)里它是 UTF-8,所以一个用 Encoding.Default 编码的程序,在一台 2010 年的机器和一台 2025 年的机器上产生不同的 Base64,而且两个输出都会在各自的家园平台上"正确地"解码。如果解码回来的负载满是带重音符号的乱码,说明原始编码用的字符集和解码假设的不一样,而修复在这一侧的管道上:显式钉死编码,两个方向都钉,钉在那份将比写它的团队活得更久的代码里。最后再对类型系统本身说一句:C# 字符串是 UTF-16,所以你哪天把原始 UTF-16 码元(通过调用 Encoding.Unicode.GetBytes)塞给编码器,每个 ASCII 字符花两个字节,你的输出体积翻一番却毫无好处,因为另一边的解码器会把它读成 UTF-16 文本,而不是你原始字符串的字节。Base64 承载你给它的字节,它不在乎它们意味着什么。

文件:从磁盘到字符串

文件是最常见的编码负载,也是最宽容的,因为这里不存在字符集问题:磁盘上的字节就是数据,编码器不在乎它们拼成一个单词还是一段波形。往返是读、编码、写,唯一真正的决定是结果去哪里:

using System.IO;

byte[] bytes = File.ReadAllBytes("photo.png");
string packed = Convert.ToBase64String(bytes);
File.WriteAllText("photo.b64", packed);

Console.WriteLine(packed.Length + " characters for "
  + bytes.Length + " bytes of image.");

尺寸算术就是整个故事,而且值得在选运输方式之前先做。一 MB 的文件变成 1,333,336 个 Base64 字符,而由于 C# 字符串每个字符存两个字节,那个编码结果作为字符串在内存里占大约 2.7 MB。一个 10 MB 的文件变成一个 13 MB 的字符串,蹲在 26 MB 的托管内存里。对一张照片或一个配置块来说这些都不是问题,而当负载是一个视频时,它是使用下面流式编码器的好理由。上面的模式适合一切能舒适装进内存的东西,也是每个"在 JSON 体里以 Base64 上传文件"功能悄悄使用的模式:读文件,编码,把字符串放进 JSON,让 API 层干它的工作。

Web 上的图片:构建 Data URI

编码图片最显眼的消费者是 Web,而 Web 对"住在文档里的图片"的格式就是 data URI:data: 方案,后接 MIME 类型、;base64 标志、一个逗号和编码后的字节。在 C# 里搭一个就是字符串拼接,编码器干了所有真正的活:

using System.IO;

byte[] png = File.ReadAllBytes("logo.png");
string packed = Convert.ToBase64String(png);
string dataUri = "data:image/png;base64," + packed;

Console.WriteLine(dataUri.Substring(0, 30));
// data:image/png;base64,iVBORw0K

那个输出里的 iVBORw0KGgo 前缀是个有用的检查点:它是八字节 PNG 签名的 Base64 形态,所以你编码的任何 PNG 都会以它开头,任何不这么开头的 PNG data URI 就不是 PNG。三条实用备注属于这个模式。第一,data URI 是图片的完整副本,膨胀了三分之一,嵌在你的 HTML 或 CSS 里,所以它用一个网络请求换永久性的页面重量,对 4 KB 的 favicon 是划算的交易,对 4 MB 的 hero 图是宰客,而编码器不会在 33% 上跟你讨价还价。第二,如果图片大,编码前先缩放或重新压缩,因为原图的每一个字节都会出现在页面上。第三,小心面向用户 HTML 里用户提供的 SVG:SVG 可以携带脚本,所以内嵌它 - 内联的,或经 <object>/<embed> 的 - 是经典的 XSS 面。普通的 <img> data URI 在现代浏览器里不会运行它,但同样标记复用到那些上下文里就会。data URI 里的 PNG、JPEG、GIF 和 WebP 是惰性的;SVG 是那个不惰性的。

手工组装一个 JWT

从零构建一个 JSON Web Token 是一种成人礼,而在 C# 里它是比大多数语言更好的成人礼,因为零件都很短。一个 JWT 是三段 base64url 用点连接:编码后的头部、编码后的载荷和签名。前两段是 UTF-8 JSON 文档,签名是对前两段用点连接后的整体计算的。这里是完整的组装,配一个占位签名,因为密码学那一步属于你的签名密钥,不属于 Base64 的故事:

using System;
using System.Buffers.Text;
using System.Text;

string headerJson = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
string payloadJson = "{\"sub\":\"42\",\"name\":\"Ada\"}";

string header = Base64Url.EncodeToString(Encoding.UTF8.GetBytes(headerJson));
string payload = Base64Url.EncodeToString(Encoding.UTF8.GetBytes(payloadJson));
string signature = "c2lnbmF0dXJl"; // 顶替真实 HMAC 或 ECDSA 值的占位符

string jwt = header + "." + payload + "." + signature;
Console.WriteLine(jwt);
// eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJBZGEifQ.c2lnbmF0dXJl

Base64Url.EncodeToString 的两个性质在那个例子里做安静的工作。它输出 URL 安全字母表,所以 +/ 都不可能出现在令牌里;它省略填充,所以 = 也永远不会出现,这正是 JWS 规范所要求的,也正是 Convert.ToBase64String 不帮忙做不到的。如果你在 .NET 9 之前的运行时上,同样的活走标准编码器加 URL 安全那一节的修补链:编码,换两个字符,修剪填充。段的顺序对签名要紧,签名是对 header 加一个点加 payload 作为纯 ASCII 字节计算的,所以先组装两段,再对它们的精确拼接签名,而不是 JSON 的重新格式化版本。还有一条要划清的边界:凡用户够得到的东西,完全不要手工组装 JWT。System.IdentityModel.Tokens.Jwt 包替你处理构建、签名、验证和过期,而它对 base64url 的处理正是这套字母表加填充规则。手工组装留给测试、演示,以及你需要确切理解库在做什么的那一天。

HTTP 头部:Basic 认证

Base64 出现在明文 HTTP 里,位于 Basic 认证方案,而编码侧是协议里最短的头部构造器之一:用冒号连接用户名和密码,把结果按 UTF-8 编码,Base64 它,再冠上方案名:

using System;
using System.Text;

string user = "ada";
string password = "s3cret";
string credentials = user + ":" + password;

string header = "Basic " + Convert.ToBase64String(Encoding.UTF8.GetBytes(credentials));
Console.WriteLine(header);
// Basic YWRhOnMzY3JldA==

字符集是缠手的部分:RFC 7617 为了向后兼容把 Basic 方案的默认字符集留作未定义,只提供一条建议性的 UTF-8 提示,但所有现代服务器期待的就是这条提示,所以带重音符号的用户名应该走 Encoding.UTF8,而不是平台默认是什么就是什么,否则服务器会解码出一根不同的字节串并拒绝登录。头部里唯一的编码就是 Base64 这一步:不要对结果百分号编码,不要 URL 编码它,不要双重 Base64 它。那些"好心"的额外步骤每一个都是已知的 bug,其中双重编码那个最常见,因为凭证有时从已经 Base64 过它们的层预先编码到达,第二次编码产出一个看起来说得通却在服务器那里静默失败的头部。关于方案本身有两条警告,让它们落在这里而不是落进安全那一节被稀释:Basic 认证传输的密码距离可读只差一条命令,所以它只在 TLS 之上可接受,而即便那样,对大多数 API 工作它也是错误的工具,这就是 bearer 令牌和 JWT 接管的原因。编码器在这一切里的活是那件小而诚实的:把冒号连接的凭证变成一根头部安全的字符串,没有更多。

邮件:MIME 以及 ToBase64Transform 为什么不折行

邮件是 Base64 的历史之家,76 字符行规则至今也来自那里:MIME 规范把编码后的正文在 76 字符处折行,行与行之间用 CRLF,这样没有任何一个 SMTP 跳跃有理由重新折行。C# 给你两个编码器干这个活,它们做出不同的承诺,值得在挑一个之前先理解。第一个是经典的 Convert.ToBase64StringInsertLineBreaks,你在换行那一节见过,它就是 MIME 的形状,76 处折行加 CRLF,随时可以贴在 Content-Transfer-Encoding: base64 头部下面。第二个是 ToBase64Transform,流式的那位表亲,而这里有个意外:它不插入换行。它没有换行的模式,没有选项,没有构造函数标志,它的输出是一长条不折行的流:

using System.IO;
using System.Security.Cryptography;

using FileStream source = File.OpenRead("photo.png");
using MemoryStream destination = new MemoryStream();
using ToBase64Transform transform = new ToBase64Transform();
using CryptoStream encoder = new CryptoStream(source, transform, CryptoStreamMode.Read);

encoder.CopyTo(destination);
Console.WriteLine(destination.Length + " characters, no line breaks");

所以实用规则是:小到中等的邮件负载,读字节,用会折行的经典编码器,因为你直接拿到 MIME 形状。大附件用 ToBase64Transform 流式处理以让内存保持平坦,如果传输真的需要 76 字符的行,就自己折行,在小组边界处分割输出(每 76 个字符,那总是小组边界,如换行那一节所解释的)。transform 保持不折行是做对了:它以三个字节为一组处理输入,而换行是知道传输的那一层才该做的格式决定,不是管道里把字节转成字符的那一层的。

流式:不读两遍就编码大文件

当负载是一个视频、一个备份、或任何你不好意思装进字符串的东西,流式编码器就是完整解决方案。这个模式是解码侧流式的镜像:源文件上一个 CryptoStream,读模式配 ToBase64Transform,一个 CopyTo 到目标。文件流入,Base64 流出,进程持有的唯一内存是流内部使用的缓冲区:

using System.IO;
using System.Security.Cryptography;

using FileStream source = File.OpenRead("video.mp4");
using FileStream target = File.Create("video.b64");
using ToBase64Transform transform = new ToBase64Transform();
using CryptoStream encoder = new CryptoStream(source, transform, CryptoStreamMode.Read);

encoder.CopyTo(target);
Console.WriteLine("Wrote " + target.Length + " characters.");

关于这个模式,两个事实值得保留。第一,输出的大小完全由输入的大小决定,每 3 字节 4 个字符,所以你可以在一个字节流动之前预留目标空间、为 content-length 头部预计算长度、或预算磁盘配额。第二,transform 期望输入以三个字节为一组到达,CryptoStream 替你处理那个对齐,在文件流过的同时喂给 transform 它想要的恰好东西。如果你哪天用 TransformBlock 手动驱动 transform,按 3 的倍数喂,让 TransformFinalBlock 排干尾巴 - 那 1 或 2 个剩余字节,它们成为带 1 或 2 个填充字符的最终不完整小组。对大多数应用,CopyTo 形式是你唯一会写的,而它正是在内存限制下表现好的形式,内存限制正是大文件喜欢住的地方。

配置、环境变量与数据库

C# 应用里另一个常见的编码工作是存储工作:拿一个机密或一个二进制块,把它放进只收文本的地方。环境变量是看得见的那个例子,因为环境变量按定义就是一根字符串:

using System;
using System.Text;

string secret = "p@ssw0rd+and/symbols";
string packed = Convert.ToBase64String(Encoding.UTF8.GetBytes(secret));
Environment.SetEnvironmentVariable("SECRET_B64", packed);

string back = Encoding.UTF8.GetString(
  Convert.FromBase64String(Environment.GetEnvironmentVariable("SECRET_B64")));
Console.WriteLine(back == secret);
// True

在数据库里,同一个想法通常出现为一个文本列必须持有的 byte[] 属性,而 Entity Framework Core 恰好有一个内置机制,一个值转换器,在每次读写时运行你的编码和解码函数:

using Microsoft.EntityFrameworkCore;

modelBuilder.Entity<Avatar>()
  .Property(a => a.ImageData)
  .HasConversion(
    v => Convert.ToBase64String(v),
    v => Convert.FromBase64String(v));

那个转换器就是整个数据库集成:C# 代码看到 byte[],列看到一根 Base64 字符串,往返在调用点不可见。两条警告属于这一节。第一,列正在交 33% 的税:按编码长度定尺寸的文本列,比同宽度装二进制少装三分之一的数据,所以如果你有定宽列,按 Base64 长度定尺寸;如果你有 varchar(max) 或等价物,这笔税只是个计费问题。第二,这是那条反复回来的:配置文件里的 Base64 是一种形状,不是一面盾牌。它让值留在单行,让它不挡文本编辑器的道,而距离任何能读文件的人可读只差一条命令。机密需要真正的保护,机密库、密钥保管库,至少是文件权限,Base64 只是机密蹲在配置里时穿的运输格式。

从命令行

每个编码器都值得过 15 行的控制台生活,而 C# 这个令人愉快,因为输出就是一根普通字符串,标准输出就是为它造的。这里就是整个工具:它接收一个文件路径或标准输入,编码它,把 Base64 写到终端,任何 shell 管道都能接走:

using System;
using System.IO;
using System.Text;

string input = args.Length > 0
  ? File.ReadAllText(args[0])
  : Console.In.ReadToEnd();

byte[] bytes = Encoding.UTF8.GetBytes(input);
Console.WriteLine(Convert.ToBase64String(bytes));

构建一次,它就蹲在 shell 自带的 base64 工具旁边,供那些你恰好想要 .NET 编码器行为的日子里使用:同样的字母表,同样的填充,还有 C# 运行时对管道递给它的任何东西的 UTF-8 处理。对二进制文件,同样的骨架把 File.ReadAllText 换成 File.ReadAllBytes 就是全部改动,输出描述的就不再是文件的文本解释,而是它的精确字节。这个工具还是个好的探针:把一个文件管道进去,把输出再管道回解码那篇文章的解码器,然后 diff 两个文件,这是一个令人满意的端到端检查,证明管道的两边在每个字节上都一致。

填充,或者说末尾的等号

Base64 字符串末尾的 = 字符是这套格式记账用的,而 C# 的编码器对它们意见不一,这是某个具体而常见的互操作 bug 的源头。经典的 Convert.ToBase64String 永远填充,因为它搭配的经典解码器永远期待填充。Base64Url.EncodeToString 从不填充,因为它瞄准的 URL 安全消费者 - JWT 和令牌 API - 永远期待紧凑形式。当你的输出跨进一个持相反期待的世界,修复是算术,和解码那篇文章为反方向展示的同一个算术:

using System;

string padded = Convert.ToBase64String(new byte[] { 1, 2 });
Console.WriteLine(padded);           // AQI=
Console.WriteLine(padded.TrimEnd('=')); // AQI,URL 安全消费者想要的

string compact = "AQI";
string restored = compact + new string('=', (4 - compact.Length % 4) % 4);
Console.WriteLine(restored);         // AQI=,经典解码器想要的

(4 - length % 4) % 4 这个公式就是整个填充宇宙:它加上零个、一个或两个字符让长度落在 4 的倍数上,外层取模阻止已经带填充的输入再多拿。关于填充的两条警告,因为好心办坏事的代码都错在这里。永远不要把 = 当数据:它不携带任何信息,所以把一根已经包含填充的字符串当成负载来编码,或在查询字符串里把 = URL 编码成 %3D,都是产出"看起来对、解出来错"的输出之路。还有提防那个小型的遗留负载家族,那里的填充被写成别的字符,某些老系统里是一个点,而不是标准的 =:如果你收到的值在你期待填充的地方用了一个点,解码前把它归一化回 =,或者不带填充地走 URL 安全那条路。

它跑得多快

现代 .NET 里的 Base64 编码很快,而有意思的部分是内存故事,不是 CPU 故事。运行时的实现在硬件支持的地方都用 SIMD 向量指令优化过,几 MB 的输入在普通桌面机器上用个位数到低两位数毫秒就能编码完,快到任何你要写的应用里编码器实际上都是免费的。真正会改变代码的性能建议关乎形状。输出是一根 C# 字符串,C# 字符串每个字符存两个字节,所以编码结果在内存里的成本大约是每输入字节 2.7 字节(每 3 个输入字节 4 个字符,每字符 2 字节),当负载以 MB 计时时这是个值得知道的数字。如果你在循环里编码成千上万个小负载,优先用 span 和字符缓冲区 API,它们写进你复用的缓冲区,胜过字符串 API,后者每次调用都分配一根全新的托管字符串。如果你在编码一个大文件,完全跳过字符串,用流式 transform,因为持有一根 13 MB 字符串的每字符 2 字节成本,在 CopyTo 本可以把工作集留在流缓冲区里时,是纯粹的浪费。还有,如果你在产出 MIME 折行的输出,记住折行那趟是对数据的第二趟,所以只在传输需要时折行,而不是当作默认。

那场安全对话

Base64 的编码侧有一课安全,它是解码侧那课的反面:做出暴露可读数据这个选择的是你,而格式不会拦你。Base64 是编码,不是加密。它没有密钥,没有算法,没有任何形式的秘密,你的 ToBase64String 调用的输出距离输入只差一条命令,在任何机器上、任何语言里、任何人手里。所以第一条规则关乎你选择编码什么:永远不要把密码、令牌或机密放进配置文件里靠 Base64"保护",因为那层保护恰好只有一个解码调用的深度,而读配置的那个人手里就有那条命令。如果值必须是秘密,它就需要真正的保护,Base64 只是它蹲在文本字段里时穿的形状。

第二课关乎通道,具体到这篇文章搭出来的那些东西。Basic 认证头部以任何代理、任何日志、任何中间盒子都能读的形式携带密码,这就是为什么这个方案只在 TLS 之上可接受,在遗留集成之外基本过时。HTML 里的 data URI 携带图片,而如果图片是用户提供的 SVG,它就携带 SVG 所携带的一切,这就是为什么 SVG 进 data URI 这个案例需要和用户内容一样的小心。还有,URL 里的 Base64 值,字面意义上就在 URL 里,这意味着它在浏览器历史里、在服务器访问日志里、在 referrer 头部里、在代理缓存里,所以必须保持私密的令牌不属于查询字符串,带不带填充都一样。编码器在这三种情况里都在干诚实的活,把字节变成一根可以安全携带的字符串。安全在你携带什么、在哪里,格式是比大多数都好的信使,但它是信使,不是金库。

C# 编码器会栽进去的坑

这些是 C# 代码编码侧反复出现的陷阱,每一个都在框架的工作方式里有具体成因:

  • 你没选的字符集。Encoding.Default 编码字符串,在 .NET Framework(Windows ANSI 代码页)和 .NET(UTF-8)上产生不同的 Base64。两个输出都合法,都会在各自的家园平台上"正确地"解码,但它们不是同样的字节。显式钉死编码。
  • 双重编码。输入已经是 Base64 了(一份编码了已编码值的配置,一个重新编码其输入的 API),编码器照着指示做的,产出了 Base64 的 Base64。结果看起来说得通,一层一层地解码,一个需要两次解码才能修好的 bug 就是这样在生产环境里被发现的。
  • 换行在错误的地方。MIME 折行形式,带着它的 CRLF 对,落进一根 JSON 字符串、一个 JWT 段、或一个 URL 参数,严格的消费者被它从没被告知要期待的空白噎住。给邮件折行,别的地方原样留着,如果你剥掉别人的折行,\r\n 一起剥。
  • 标准字母表在 URL 里。查询字符串里的 + 按表单解析规则被解码成空格,所以放进 URL 的标准 Base64 值回来时,加号的位置变成了字母。用 URL 安全字母表,或者对整个值百分号编码,永远不要两者都做。
  • 填充不匹配。你的输出带填充,消费者要紧凑的,或者反过来,两边都没错 - 它们只是意见不一。修复是填充那一节的算术,应用在知道消费者期待的那一侧,通常是写令牌的那一侧。
  • 没预算的内存。编码后的字符串在内存里每字符两个字节,所以一个 10 MB 的文件变成一根 1300 万字符的字符串,在托管内存里重约 27 MB,而一个一次造一根这样的字符串的循环,会在剖析器里以分配抖动出现,找不到可见原因。用长度辅助方法给缓冲区定大小,大的走流式,热循环里复用缓冲区。
  • 不折行的 transform。ToBase64Transform 输出一整条长行。把"已 MIME 就绪"的附件流式过它然后直接发邮件的代码,产出一条 120,000 字符的行,某些传输会在小组中间重新折行,正是 76 字符规则被设计来防止的那种损坏。
  • 编码那个编码。因为"数据已经是文本"而把一根 Base64 字符串传进编码器,会产出第二层。编码器不知道、也不在乎它的输入看起来像 Base64;它编码字符串碰巧有多少字符,另一边的解码器在它期待你数据的地方收到一根 Base64 字符串。

编码器的成长:版本巡礼

API 的编码侧有自己的时间线,从第二个 .NET 发行版一直延伸到当前处于预览的那个:

  • .NET Framework 1.1,2003 年 4 月。Convert.ToBase64StringToBase64CharArray 到来,整个经典家族一个发行版全到,切片重载也已经包含在内,对一个 2003 年的 API 来说这是一小个远见的奇迹。
  • .NET 2.0,2005 年。Base64FormattingOptionsInsertLineBreaks 值加入家族,把 MIME 折行带进框架,终结了邮件代码里手工 Substring 循环的时代。
  • .NET Core 2.1,2018 年。Span 时代。Convert 获得基于 span 的编码和 TryToBase64Chars,新的 System.Buffers.Text.Base64 类带着它的 OperationStatus 契约和原地膨胀到来,为零分配的世界而建。
  • .NET 5,2020 年。十六进制兄弟们(Convert.ToHexString 和朋友们)发货,同一个设计模式应用到 16 符号字母表,转换类模式成了家规。
  • .NET 7,2022 年。X509Certificate2.ExportCertificatePem 让框架替你产出 PEM,护甲标记、64 字符折行和 Base64 正文一应俱全,悄悄退役了一整类手工证书格式化代码。
  • .NET 9,2024 年 11 月。System.Buffers.Text.Base64Url 在社区多年请求之后进了盒子,Microsoft.Bcl.Memory 包把它回溯移植到 .NET Framework 4.6.2 及以上,还有那个 JWT 代码一直手工搓的去除填充行为。
  • .NET 11,本文撰写时处于预览。下一个版本预计在 2026 年末,在现有类型上追加更多 Base64 便捷 API 和重载,继续走向更顺手的外表。

这套格式本身有更古老的传记,这正是 C# API 看起来像现在这样的原因。我们现在所称的 MIME Base64 的第一次标准化用途是 1987 年的 Privacy-Enhanced Mail 协议(RFC 989),MIME 规范在 1993 年把 76 字符折行的形式定了下来,RFC 4648 在 2006 年给了这套格式现代的、认识字母表的规范,包括 URL 安全变体,C# 到 2024 年才为它拿到一等编码器。三十年的邮件和 Web 惯例是换行、填充和两套字母表全都存在的原因,而 C# 编码器是三者相遇的地方。

小小的奇迹

  • 4 字符的下限。能存在的最小非空 Base64 输出是 4 个字符,因为这套格式按满四一组思考,哪怕你只给它 1 个字节。任何 1 个字节都编码成 2 个字母加 2 个 = 号,那个形状 - 两个数据字符戴着一顶填充帽 - 是一个你会开始在配置和令牌里认出来的指纹。
  • 零字节受欢迎。编码器对字节意味着什么没有意见,所以一缓冲区的零高高兴兴地编码成一面 A 字符的墙,一个 NUL 字节原样完好的二进制文件往返之后一个都不丢。"字符串装不了二进制"的焦虑属于类型系统的字符串一侧,不属于编码器 - 编码器从没见过字符串。
  • 确定性即特性。同样的字节,同样的选项,永远同样的字符串。没有时间戳,没有随机盐,没有变化,这就是为什么一根 Base64 字符串是文件内容可用的快糙指纹:两根 Base64 相同的文件是同一个文件,检查就是字符串比较。
  • 每字符两字节,免费获得。C# 字符串是 UTF-16,所以 Base64 输出里的每个字符在托管内存里占两个字节。编码器不宣布这一点,长度属性不报告这一点,一根 1300 万字符的字符串就是重 26 MB,负载大时这是该在脑子里有的数字。
  • 血统里的 CRLF。MIME 折行即使在代码跑在 Linux 上也插入回车换行对,因为那条规则来自邮件规范,不是来自平台。编码器是历史学家也是转换器,它在一台 2026 年的机器上保留着 1993 年的行尾。
  • 从第一天起的切片重载。ToBase64String(byte[], int, int) 从 2003 年起就在编码更大数组里的一个窗口,比 span 让那个想法时髦起来早了 15 年。1.1 时代的 API 设计师看着真实的缓冲区,加上了偏移加长度的形式,当数据是一次更大读取中的一段时,它至今仍是正确的选择。
  • 64 字符的证书行。PEM 在 64 字符处折行,不是 76,ExportCertificatePem 知道这一点并相应折行,这是让"让框架去做"成为证书工作正确建议的安静细节之一。两种折行宽度,一个格式家族,框架把它们分得清清楚楚。
  • 两套字母表,两个名字。那 64 个值在 API 的一部分里叫"standard",在另一部分里叫"URL-safe",它们恰好差两个字符:第 62 和第 63 个位置。一边是 +/,另一边是 -_,这篇文章里的每个互操作 bug 都住在某人假设两边相同的那一刻。

绕回原点

这就是编码器一侧,而决定在这里做:字母表、填充、换行、字符集、缓冲区。另一个方向,接收别人的 Base64,带着他们的填充选择、他们的换行、他们的字母表和他们的令牌,是大部分疼痛所在的地方,因为你没法和负载谈判。C# 的 Base64 解码,从经典的一行代码到 span 和 URL 安全家族,在下方链接的姊妹文章里深入覆盖,两者之间,整个主题装进你的工作记忆,正是一套如此古老又如此小巧的格式的要点。

最后更新: 2026-09-08

相关文章: C#(CSharp)中的 Base64 解码:完整指南