C#(CSharp)中的 Base64 解码:完整指南
你一眼就能认出它:一条由字母和数字汇成的长河,偶尔冒出一个 + 或 /,末尾还可能垂着一两个 =。在 API 响应、邮件附件、配置文件和 JWT 之间的某个地方,有人把二进制数据打包成了文本,而现在打开它的活儿落到了你头上。这就是 C# 里 Base64 的解码一侧,而第一条好消息是:你除了框架本身什么都不需要。这个解码器在 System 命名空间里已经生活了二十多年,每个现代 .NET 运行时至今仍在搭载它,而且选项比原版更多,性能也比原版更好。
先来快速温习一下,因为本站首页已经把这种格式完整讲过:64 符号字母表中的 4 个字符承载 3 个字节的真实数据,末尾 1 个或 2 个 = 字符标记着剩下的零头字节。解码就是把这笔交换反过来做,所以结果的大小大约是输入的四分之三。带着对问题形状的了解,让我们拆开几个包裹吧。
解码器家族:认识你的选项
在第一个例子之前,先看看你手边能摸到的全部解码 API 家族,以及每一个各自为何种场景而生。这里列出的每一项都是 .NET 运行时本身的组成部分,唯一的例外是旧框架上的 URL 安全类,它是靠一个小 NuGet 包搭载进来的:
| API | 可用版本 | 适用场景 |
|---|---|---|
Convert.FromBase64String(string) |
.NET Framework 1.1(2003) | 经典款。进一根字符串,出一块全新的 byte[]。跳过普通空白,遇到其他任何东西就抛异常。 |
Convert.FromBase64CharArray(char[], int, int) |
.NET Framework 1.1(2003) | 同样的解码,只是从你自己已经持有的字符缓冲区中的一段来读。 |
Convert.TryFromBase64String,Convert.TryFromBase64Chars |
.NET Core 2.1(2018) | 用布尔值代替异常,写入你提供的 span。不可信输入的好帮手。 |
System.Buffers.Text.Base64 |
.NET Core 2.1(2018) | 严格的 span API:用状态码代替异常,原地解码,还有 IsValid 预检。 |
System.Buffers.Text.Base64Url |
.NET 9(2024) | URL 安全字母表(用 - 和 _ 代替 + 和 /),带不带填充都行。在 .NET Framework 4.6.2+ 和 .NET Standard 2.0 上:用 Microsoft.Bcl.Memory NuGet 包。 |
FromBase64Transform + CryptoStream |
.NET Framework 1.1(2003) | 流式解码:文件到文件、网络到磁盘,一块一块地处理,无需把整个负载装进内存。 |
如果你的项目目标是 2018 年及之后的某个 .NET 版本,前四行都在盒子里。Base64Url 需要 .NET 9 或更新版本,或者在更老的平台上装 Microsoft.Bcl.Memory 包。再往前看一眼:.NET 11 的库在本文撰写时还处于预览状态,通用版预计在 2026 年末发布,它会在现有类型上追加更多 Base64 便捷 API 和重载,所以这个家族还在继续长大。本文其余部分不需要任何包。
主力干将:Convert.FromBase64String
C# 解码生涯的百分之九十,就是这一个调用。给它一根字符串,它还给你当初打包进去的确切字节:
using System;
using System.Text;
string packed = "TWFu";
byte[] bytes = Convert.FromBase64String(packed);
string text = Encoding.UTF8.GetString(bytes);
Console.WriteLine(text);
// Man
有三个细节值得记进脑子里。第一,返回值是字节,不是文本:它是一个 byte[],解码器从头到尾都是面向字节的,而这恰恰是你想要的,因为负载可能是一句话、一张 PNG、一个证书或一个哈希,它们中的任何一位都不该被特殊对待。从字节跳回可读文本是一个独立的、刻意分开的步骤,要经过 Encoding,而字符集的决定就住在这一步里(下文细说)。第二,解码器每次调用都分配一块全新数组,大小恰好等于解码后的长度,所以它永远不会把一块富余容量的缓冲交给你。第三,契约很小也很诚实:空字符串解码为空数组,null 引用抛出 ArgumentNullException,任何不是合法 Base64 的东西抛出 FormatException。其余的一切,都是这三条规则的展开。
它原谅什么,又拒绝什么
这里能看到 C# 解码器的性格,而且是个很有个性的性格。它对恰好一件事大方 - 空白 - 对其他一切则毫不留情。解码器无论在字符串哪个位置出现,都会跳过恰好四个字符:空格(U+0020)、制表符(U+0009)、换行符(U+000A)和回车符(U+000D)。这项政策是对邮件的刻意致意,因为 Base64 负载在邮件里总是以 76 字符一行的形式包裹着到达,这也意味着一个 MIME 包裹的附件可以零预处理直接解码。任何落在 64 符号字母表之外的字符、任何违反长度规则的东西、任何把填充放在错误位置的东西,都会换来一个异常。看看同一个解码器面对几种不同输入时的表现:
| 输入 | 结果 |
|---|---|
"TWFu" |
解码为 Man(3 个字节)。 |
"TWF\nu"(中间有一个换行) |
解码为 Man。空白对解码器是隐形的。 |
"TWFu\u00A0"(末尾有一个不间断空格) |
FormatException。只有上面那四个空白字符会被跳过,NBSP 不在其中。 |
"TWE"(长度 3,不是 4 的倍数) |
FormatException。负载长度(忽略空白)必须是 4 的倍数。 |
"TWFu="(数据之后多了填充) |
FormatException。填充字符至多两个,而且只能出现在最末尾。 |
"-_88"(URL 安全字母表) |
FormatException。标准解码器只认识标准字母表里的 64 个字符。 |
null |
ArgumentNullException:值不能为 null。(参数 's') |
还有一个值得背下来的怪癖:每一种格式罪行换来的是同一条错误消息,输入不是有效的 Base-64 字符串,因为它包含非 base 64 字符、超过两个填充字符,或填充字符中存在非法字符。这条消息把三种可能的原因全列了出来,却不告诉你撞上了哪一种,也从不告诉你出在哪个位置。如果你在调试一个解码失败的负载,按顺序来:先数字符,再查字母表,最后查填充。
不靠异常解码:Try API
异常驱动的控制流是一种正当的模式,但对于高流量或不可信的输入,Try 家族才是更好的公民。它在 .NET Core 2.1 中加入,有两种风味:一种从字符串读,一种从字符 span 读。两者都写入你提供的缓冲区,并报告填了多少:
using System;
using System.Text;
string payload = "TWFu"; // 任意负载,合法与否均可
Span<byte> buffer = stackalloc byte[4096];
if (Convert.TryFromBase64String(payload, buffer, out int written))
{
string text = Encoding.UTF8.GetString(buffer[..written]);
Console.WriteLine(text);
}
else
{
Console.WriteLine("Not a valid Base64 payload.");
}
两种行为让 Try 变体感觉像另一个物种。非法输入返回 false 而不是抛出,所以一串畸形负载只花你一次分支判断的代价,而不是一次异常。一个注意事项:null 输入不在契约之内 - 它会抛出 ArgumentNullException - 所以 Try 守卫覆盖的是损坏的负载,而一个可能缺失的值仍然需要先做它自己的空值检查。兄弟方法 Convert.TryFromBase64Chars 从 ReadOnlySpan<char> 干同样的活,当负载住在一个更大的字符缓冲区里、你不想先切出一段子字符串时,这很方便。把输出缓冲区开足一点:解码后的长度最多是(非空白)输入长度的四分之三,written 出参会精确告诉你实际出了多少。
基于 Span 的解码:System.Buffers.Text.Base64
当你开始数分配次数,或者希望解码器描述它的失败而不是抛出异常时,System.Buffers.Text.Base64 类就是那件工具。它从 .NET Core 2.1 起就是标准库里的一个静态类,工作在 span 上而不是托管数组上。它的解码方法返回一个带四种心情的 OperationStatus 值:Done(成功)、DestinationTooSmall(你的缓冲区太小)、NeedMoreData(输入还不是 4 的倍数,继续读)和 InvalidData(这不是 Base64)。最后一个布尔参数 isFinalBlock 正是区分后两者的关键:它告诉解码器后面还有没有输入。这是一次性形式,用类自带的辅助方法确定大小:
using System.Buffers;
using System.Buffers.Text;
using System.Text;
string payload = "TWFu";
byte[] input = Encoding.ASCII.GetBytes(payload);
byte[] output = new byte[Base64.GetMaxDecodedFromUtf8Length(input.Length)];
OperationStatus status = Base64.DecodeFromUtf8(input, output,
out int consumed, out int written, isFinalBlock: true);
if (status == OperationStatus.Done)
{
Console.WriteLine(Encoding.UTF8.GetString(output.AsSpan(0, written)));
// Man
}
这个类还有两个成员值得一段话。第一个是 IsValid,它验证一个负载但不解码。它有 byte-span 和 character-span 两种风味,其中一个重载会连同裁决一起报告解码后的长度,这样你一次检查就能定好缓冲区大小:
using System.Buffers.Text;
string payload = "TWFu";
if (Base64.IsValid(payload, out int decodedLength))
{
Console.WriteLine("Valid, decodes to " + decodedLength + " bytes.");
// Valid, decodes to 3 bytes.
}
else
{
Console.WriteLine("Rejecting payload before allocating anything.");
}
第二个是 DecodeFromUtf8InPlace,适用场景是 Base64 文本已经住在你自己拥有的缓冲区里,而你不在乎覆盖它。解码会缩小数据,所以结果被写回同一块缓冲区的开头,方法会报告它有多长:
using System.Buffers;
using System.Buffers.Text;
using System.Text;
byte[] data = Encoding.ASCII.GetBytes("TWFu");
OperationStatus status = Base64.DecodeFromUtf8InPlace(data, out int written);
if (status == OperationStatus.Done)
{
Console.WriteLine(Encoding.ASCII.GetString(data, 0, written));
// Man,现在就住在同一块缓冲区的前三个字节里
}
还有一个要装进口袋的行为:这个类同样跳过那四个普通空白字符(空格、制表符、换行符、回车符),所以折行包裹的负载照样解码。它在要紧的地方很严格:一个非空白长度不是 4 的倍数的负载,如果是最后一个块,就是 InvalidData;标准字母表之外的字符会被直接拒收。这个类里没有任何地方做静默清理。
URL 安全 Base64:Base64Url 类
同样这 64 个值还有一套第二字母表,在 C# 的 Web 工作中你会天天遇见它。在标准字母表里,值 62 和 63 是 + 和 /,这两个字符在 URL 里都惹麻烦:查询字符串里的 + 通常会被解码成空格,而 / 和 = 各自都需要百分号编码。RFC 4648 第 5 节用 - 和 _ 换掉了它们 - 这两个字符在任何 URL 语境里都没有特殊含义 - 并且让末尾的 = 填充变成可选。结果就是所谓的 base64url,它是 JWT、API 令牌、文件上传 ID 和无数 URL 的字母表(YouTube 的 11 字符视频标识符就是不带填充的 base64url)。
从 .NET 9 起,标准库为它发货了一个专用类:System.Buffers.Text.Base64Url。它是 Base64 类的 URL 安全孪生兄弟,有自己的解码、验证和长度辅助方法:
using System.Buffers.Text;
using System.Text;
string token = "-__8";
byte[] bytes = Base64Url.DecodeFromChars(token);
Console.WriteLine(BitConverter.ToString(bytes));
// FB-FF-FC
注意经典 API 面对这个例子会做什么。同样三个字节在标准字母表里编码为 +//8,Convert.FromBase64String("+//8") 能工作,但 Convert.FromBase64String("-__8") 会抛出异常,因为 URL 安全字符在它字母表之外。而且 base64url 负载常常不带填充到达,经典解码器也拒绝它,因为它坚持要满四一组。Base64Url 类原生处理这个问题的两个变体:它把 TWE(三个字符,无填充)解码为两个字节 Ma,同样也解得开 TWE=。
如果你的项目跑在更老的运行时上,有两条实用路径。在 .NET Framework 4.6.2 及以上,加 Microsoft.Bcl.Memory NuGet 包,微软专门发布它就是为了让 Base64Url 回溯移植(连同其他几个现代类型):
dotnet add package Microsoft.Bcl.Memory
或者,完全不装任何包,在交给经典解码器之前先把负载归一化:把 URL 安全字符换回它们的标准孪生兄弟,再补齐缺失的填充。这个小助手是 C# 代码里最常见的手写 base64url 解码器,值得知道,因为它从 .NET Framework 1.1 起的每个运行时上都有效:
using System;
using System.Text;
string segment = "TWE";
segment = segment.Replace('-', '+').Replace('_', '/');
segment += new string('=', (4 - segment.Length % 4) % 4);
byte[] bytes = Convert.FromBase64String(segment);
Console.WriteLine(Encoding.ASCII.GetString(bytes));
// Ma
(4 - length % 4) % 4 这个公式就是填充算术的全部:它加上零个、一个或两个 = 字符,让长度落在 4 的倍数上,外层取模则阻止已经带填充的输入再多拿几个。
从字节到文字:文本、Unicode 与字符集
解码给你字节,而字节是完全中性的东西。只有当你选择用某个字符集去读它们时,它们才变成"文本",而这个选择由你来做,因为 Base64 不携带任何关于原作者用了什么字符集的信息。实践上这意味着:除非你有理由不这么干,就假设 UTF-8,并且在代码里把它写明白,因为一个显式的 Encoding.UTF8 调用,正是"碰巧正确"的程序和"设计正确"的程序之间的差别:
using System;
using System.Text;
string original = "h\u00e9llo \u4e16\u754c";
byte[] utf8 = Encoding.UTF8.GetBytes(original);
string packed = Convert.ToBase64String(utf8);
byte[] decoded = Convert.FromBase64String(packed);
string restored = Encoding.UTF8.GetString(decoded);
Console.WriteLine(restored == original);
// True: h\u00e9llo \u4e16\u754c 完美往返
微妙的陷阱出在字节不是合法 UTF-8 的时候,因为负载其实可能是 Latin-1、二进制,或者干脆就是坏了。默认情况下,.NET 的 UTF-8 解码器会把每个坏掉的序列替换成 Unicode 替换字符(U+FFFD)然后继续走。没有异常,没有警告:数据就这么消失了,在你的数据库里变成了问号。如果你需要知道这件事何时发生,就用严格回退构造编码,它把静默替换变成响亮的 DecoderFallbackException:
using System.Text;
byte[] bytes = Convert.FromBase64String("//4="); // 字节 FF FE,不是合法的 UTF-8
Encoding strictUtf8 = Encoding.GetEncoding(
"utf-8",
new EncoderExceptionFallback(),
new DecoderExceptionFallback());
string text = strictUtf8.GetString(bytes);
// 会抛出 DecoderFallbackException,因为 FF FE 不是一个 UTF-8 序列
对于你宁愿活下来也不愿失败的负载,替换回退是更温和的选项,而且替换文本由你自己挑:
using System.Text;
byte[] bytes = Convert.FromBase64String("//4="); // 字节 FF FE,不是合法的 UTF-8
Encoding forgivingUtf8 = Encoding.GetEncoding(
"utf-8",
EncoderFallback.ReplacementFallback,
new DecoderReplacementFallback("[bad]"));
string text = forgivingUtf8.GetString(bytes);
Console.WriteLine(text);
// [bad][bad],而不是静默的 U+FFFD 替换
再上一堂 C# 专属的历史课:Encoding.Default 在不同运行时上意思不同。在 Windows 上的 .NET Framework 里,它是系统的 ANSI 代码页(通常是 Windows-1252),而在 .NET(Core)里,它是不带 BOM 的 UTF-8。因此,一个把负载经 Encoding.Default 往返的代码,可能在一台 2010 年的机器和一台 2025 年的机器上产生不同的字节,而 Base64 会高高兴兴地把你递给它的任何一套都编码了。如果你哪天看到一段解码字符串里满是带重音符号的乱码,Encoding.Default 是第一个该查的地方。
文件与二进制负载
文件是最直截了当的解码目标,因为根本不存在字符集问题:你解码出来的字节就是文件,一个字节一个字节,零也原样保留。这个模式是两次调用加一个文件,从图片上传到备份工具到处都能见到:
using System.IO;
string b64 = File.ReadAllText("payload.b64");
byte[] original = Convert.FromBase64String(b64);
File.WriteAllBytes("restored.bin", original);
Console.WriteLine("Restored " + original.Length + " bytes.");
两条实用备注。如果文件可能包含空白或换行(既然它是文本文件,几乎肯定包含),经典解码器免费帮你处理,就像你前面看到的。而如果负载很大,就完全不要走字符串:跳过文件转字符串那一步,直接从流解码,那是下一节的内容。对于一个解码后是文本、而且你恰好知道字符集的负载,文件例子就是完整解决方案,字符集那一节的 Encoding.UTF8.GetString 步骤正好卡在解码和使用之间。
从流解码:FromBase64Transform
Convert 方法是为能装进字符串的负载设计的,官方文档说得再明白不过:流式数据请用 transform 类。FromBase64Transform 从 .NET Framework 1.1(2003)起就是 System.Security.Cryptography 的一部分,它接入 CryptoStream,后者是框架用来在数据流动时转换它的通用管道。整个文件到文件的解码只有四行搭建:
using System.IO;
using System.Security.Cryptography;
using FileStream source = File.OpenRead("payload.b64");
using FromBase64Transform transform =
new FromBase64Transform(FromBase64TransformMode.IgnoreWhiteSpaces);
using CryptoStream reader = new CryptoStream(source, transform, CryptoStreamMode.Read);
using FileStream target = File.Create("payload.bin");
reader.CopyTo(target);
Console.WriteLine("Done, " + target.Length + " bytes written.");
构造函数接收一个模式,两种模式都值得记住名字。IgnoreWhiteSpaces(默认值,与经典解码器的空白政策一致)在流流动时跳过那四个普通空白字符,这正是邮件包裹或满是换行的负载所需要的。DoNotIgnoreWhiteSpaces 是严格的:它遇到的第一个非字母表字符就抛出 FormatException,当负载里混进的一个空格应该是个 bug 而不是耸肩时,你要的就是它。在底层,transform 以四个字符为一组处理输入,交回每组产生的三个字节,尾巴由 TransformFinalBlock 收尾。你很少亲自调用那些方法,因为 CryptoStream 替你做,但"满四一组"这个事实要紧:如果你哪天手动喂 transform,请按 4 的倍数喂,否则最后一个不完整的小组就会赖在最终块里。
JWT:三段,一个点
JSON Web Token 是 C# Web 开发中流量最大的 base64url 负载,它的形状简单得有点骗人:三段用点分隔。第一段是编码后的头部,第二段是编码后的载荷(又称声明),第三段是签名。前两段各自是 UTF-8 JSON 文档的 base64url,不带填充,依照 JWS 规范。拆分和解码只需两行 C#:
using System;
using System.Buffers.Text;
using System.Text;
using System.Text.Json;
string jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiI0MiIsIm5hbWUiOiJBZGEifQ.c2lnbmF0dXJl";
string[] parts = jwt.Split('.');
string headerJson = Encoding.UTF8.GetString(Base64Url.DecodeFromChars(parts[0]));
string payloadJson = Encoding.UTF8.GetString(Base64Url.DecodeFromChars(parts[1]));
using JsonDocument doc = JsonDocument.Parse(payloadJson);
Console.WriteLine(doc.RootElement.GetProperty("name").GetString());
// Ada
在 .NET 9 之前的运行时上,同样的活经过 URL 安全那一节的归一化助手:把 - 和 _ 换回 + 和 /,把段补齐到 4 的倍数,再用 Convert.FromBase64String 解码。两种做法给出同样的 JSON;挑匹配你目标框架的那个。
有一条边界要划清楚:解码 JWT 不是验证 JWT。上面的解码会高高兴兴地读出一个签名是垃圾的令牌的声明,因为签名是对前两段单独做的密码学检查。生产环境的令牌工作,千万别手工解析:System.IdentityModel.Tokens.Jwt 包(来自 Microsoft.IdentityModel 家族)一站式处理解析、验证和过期,而它对 base64url 的处理正是本节描述的这套字母表。手工解码留给调试和小工具;凡用户够得到的东西,一律用库来验证。
Data URI 与内嵌图片
有一大类 C# 代码,工作就是接收 data: URI,因为 HTML、CSS 和大量 Web API 都用它们把二进制内容内嵌进文档。这个方案由 RFC 2397 标准化,形式是 data:[mediatype][;base64],payload:第一个逗号之前全是元数据(MIME 类型和 ;base64 标志),之后全是负载。当 ;base64 标志在场时,负载就是一根 Base64 字符串,在逗号处一劈就是完整的解析:
using System;
using System.Text;
string dataUri = "data:image/png;base64,iVBORw0KGgo=";
int comma = dataUri.IndexOf(',');
string mediaType = dataUri[..comma]; // data:image/png;base64
string b64 = dataUri[(comma + 1)..]; // iVBORw0KGgo=
byte[] imageBytes = Convert.FromBase64String(b64);
Console.WriteLine(imageBytes.Length);
// 8:PNG 签名字节 89 50 4E 47 0D 0A 1A 0A
例子里 iVBORw0KGgo= 这个前缀是八字节 PNG 魔数的 Base64 形态,是个有用的指纹:任何真实 PNG 的 data URI 都以它开头,所以解析不可信 HTML 时它是快速的 sanity check。给 C# 开发者两条实用备注。第一,Uri 类在 .NET 上原生理解 data URI:new Uri("data:text/plain;base64,TWFu") 解析得很好并报告 Scheme == "data",所以如果你的代码按 URI 分流,data URI 会出现在管道里,你得决定怎么处理它们。第二,记住 data URI 到底是什么:文件的完整副本,膨胀了三分之一,蹲在你的文档里。对一个 4 KB 的 favicon 来说没问题,对一个 4 MB 的 logo 来说很疼,所以当你自己是生成方时(编码那篇文章讲那一侧),先把图片的尺寸定好再编码。
HTTP:Basic 认证与 API 交互
Base64 织进 HTTP 的地方,至少有一个你会在任何 API 工作中摸到:Basic 认证方案。客户端发送 Authorization: Basic,后面跟着 username:password 用冒号连接后的 Base64 编码。服务器端解码传入的头部因此就是:剥掉 Basic 前缀,解码,再在第一个冒号处劈开:
using System;
using System.Text;
string header = "Basic YWRhOnMzY3JldA==";
string encoded = header["Basic ".Length..].Trim();
string credentials = Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
int colon = credentials.IndexOf(':');
string user = credentials[..colon];
string password = credentials[(colon + 1)..];
Console.WriteLine(user); // ada
Console.WriteLine(password); // s3cret
UTF-8 这一步比看起来更要紧:RFC 7617 其实没有钉死字符集,为了向后兼容把默认值留作未定义,只允许一条建议性的 UTF-8 提示,但所有现代服务器期待的就是这条提示,所以一个带重音符号的用户名,会产生和同一用户名按 Latin-1 读时不同(且正确)的字节串。Basic 认证的解码侧是这个模式里简单的一端;在 ASP.NET Core 里你通常通过认证处理器遇到它,而不是原始头部,但它们底层跑的就是同一套解码逻辑,而且这正是你写集成测试、假扮一个 API 服务器时需要的代码。镜像操作 - 在客户端搭这个头部 - 在编码侧是一行代码,它在编码那篇文章里有完整例子。
邮件:MIME 与折行负载
邮件是 Base64 打下名声的地方,至今仍是 C# 服务收到的大量负载的源头。SMTP 原本是 7 位协议,所以二进制附件不能裸奔:MIME 规范(RFC 2045)把它们编码为 Base64,配上 Content-Transfer-Encoding: base64 头部,把输出在 76 字符处折行,用回车换行对分隔各行。一个真实的附件正文因此看起来是一列 76 字符的行,而 C# 的好消息是经典解码器已经知道怎么读:因为它在字符串任何位置都跳过空白,你可以把整个折行后的正文连同换行一起交给它,它解码起来就像换行根本不存在:
using System;
using System.Text;
string attachmentBody = "TWFu\r\nTWFu\r\nTWFu";
byte[] bytes = Convert.FromBase64String(attachmentBody);
Console.WriteLine(Encoding.ASCII.GetString(bytes));
// ManManMan
对于通过流而不是字符串到达的负载,带空白忽略模式的 FromBase64Transform 是同一故事穿上流式外衣。而当你需要做的超出解码正文 - 当你需要遍历 MIME 结构、解析头部、处理嵌套的 multipart 节、或从一个真实 .eml 文件里抽出每个附件时,C# 生态的答案是 MimeKit 包:它是 .NET 的标准 MIME 库,内部处理 Base64 和 quoted-printable 内容传输编码,在"只是解码正文"这句话不再描述你的问题的那一刻,它就是你要拿的工具。框架自带的 MailMessage 类会替你解码简单附件,但按现代标准,它的 MIME 支持是刻意低调的。
PEM 证书
PEM 是 TLS 世界的加甲格式:一个 Base64 正文,夹在 -----BEGIN CERTIFICATE----- 和 -----END CERTIFICATE----- 标记之间,按 RFC 7468 的规定在 64 字符处折行。C# 开发者以每个 HTTPS 端点背后的证书文件形式遇见它,而这里的解码故事比你可能预期的要好,因为从 .NET 6 起框架就替你解析 PEM,Base64 正文一并处理:
using System.IO;
using System.Security.Cryptography.X509Certificates;
string pem = File.ReadAllText("server.pem");
X509Certificate2 certificate = X509Certificate2.CreateFromPem(pem);
Console.WriteLine(certificate.Subject);
// CN=server.example.com
里面没有任何手工 Base64:CreateFromPem 找到标记,剥开正文,解码,然后还你一张活的证书。(这个家族还有私钥的兄弟,以及证书加密钥合体形式的兄弟,如果你的基础设施递给你的是那些的话。)如果你在更老的运行时上,或者你需要护甲里面躺着的原始 DER 字节,手工版本就是两步的剥除加解码,值得知道,因为同一个模式对任何 PEM 加甲的东西都有效:
using System;
using System.Text;
string pem = File.ReadAllText("server.pem");
string body = pem
.Replace("-----BEGIN CERTIFICATE-----", "")
.Replace("-----END CERTIFICATE-----", "")
.Replace("\r", "")
.Replace("\n", "");
byte[] der = Convert.FromBase64String(body);
Console.WriteLine(der.Length);
// 就是护甲内那份 DER 证书的长度
这个角落的坑全是空白:PEM 文件从大多数证书工具那里带着 CRLF 行尾,所以解码前 \r 和 \n 都要剥掉,不只是换行。也别把证书正文和私钥正文搞混,它们有不同的标记和不同的内容;解码器救不了你这一回。
配置、环境变量与数据库
Base64 在 C# 应用里的第三个家是存储:配置文件、环境变量和数据库列。模式到处都一样。一个二进制或机密值在进来时被编码成字符串,出去时再解码回字节。环境变量是最显眼的例子,因为它们只能装文本:
using System;
using System.Text;
string? encoded = Environment.GetEnvironmentVariable("API_KEY_B64");
if (encoded == null)
{
throw new InvalidOperationException("Set the API_KEY_B64 environment variable first.");
}
byte[] keyBytes = Convert.FromBase64String(encoded);
string apiKey = Encoding.UTF8.GetString(keyBytes);
Console.WriteLine(apiKey.Length + " characters of API key, ready to use.");
在数据库里,同一个想法通常表现为一个你想存进文本列以图可移植的 byte[] 属性,而 Entity Framework Core 恰好有一个内置机制:一个值转换器,在每次读写时透明地运行你的编码和解码函数:
using Microsoft.EntityFrameworkCore;
modelBuilder.Entity<Avatar>()
.Property(a => a.ImageData)
.HasConversion(
v => Convert.ToBase64String(v),
v => Convert.FromBase64String(v));
这一个转换器就是整个数据库集成:ImageData 在你的 C# 代码里保持是 byte[],数据库看到的是一根 Base64 字符串。两条警告属于这一节。第一,同样宽度的列,作为编码文本装的数据比作为原始二进制少大约三分之一,这是 4 字符换 3 字节的税,所以定宽列要按编码后的长度来定。第二,这是安全那条:配置文件里的 Base64 是把值留在单行的便利,不是对值的保护。任何能读配置文件的人一条命令就能解出密钥,这就是为什么真正的机密属于机密库,而那里的 Base64 只是运输格式。
当负载很大时
Base64 解码有一个编码没有的愉快性质:输出永远比输入小,大约是它的四分之三。一个 10 MB 的文本负载解码为大约 7.5 MB 的字节,所以解码永远不会像编码那样把你的内存吹成气球。如果你需要预先定好缓冲区大小,算术归结为两个调用之一:严格 span 类用 Base64.GetMaxDecodedFromUtf8Length,经典 API 用朴素的除法 length / 4 * 3,输入如果折行过还要为空白加一点余量。(辅助方法返回的是可能的最大解码长度:真实长度只有在最后一组没有填充时才等于它,以 1 个或 2 个填充字符结尾时则少 1 或 2 个字节。)
但当负载真的很大时,正确的动作不是更大的缓冲区 - 而是根本没有缓冲区:完全跳过字符串,让 FromBase64Transform 像流那一节展示的那样,把解码从源头流到目标。唯一要尊重的规则是满四对齐:Base64 流只能在 4 的倍数个字符处切开(计入空白之后),所以如果你哪天手动喂 transform,请按 4 的倍数的块来读,让 TransformFinalBlock 排干余数。不到几百 MB 的任何东西,一次性解码都快到让这件事成为优化而不是必需,但流式形式也是在内存限制下表现好的那一个,而内存限制正是大负载喜欢住的环境。
终端里的解码器
在每一门语言里,都有一个令人满足的时刻:15 行的控制台程序变成了命令行工具,而 C# 的 Base64 解码器很适合干这个,因为从标准输入读取让它直接成为 shell 管道的即插即用件。这里就是整个工具:它从管道(或从参数)读入 Base64 负载,解码,把原始字节写入文件:
using System;
using System.IO;
using System.Text;
string input = args.Length > 0 ? File.ReadAllText(args[0]) : Console.In.ReadToEnd();
byte[] bytes = Convert.FromBase64String(input.Trim());
File.WriteAllBytes("output.bin", bytes);
Console.Error.WriteLine("Wrote " + bytes.Length + " bytes to output.bin.");
构建一次,它就蹲在 shell 自带的 base64 工具旁边,供那些你恰好想要 .NET 运行时解码器的日子:把文件管道进去,和其他工具串联,C# 严格的校验规则(宽容空白、严格字母表、严格填充)就成了你管道的一部分。那里的 Trim() 在安静地干活,接住文本编辑器最爱添加的末尾换行,尽管公平地说,解码器本来也会忽略它。对于 API 日志里越来越多出现的 URL 安全负载,同一个骨架加上 URL 安全那一节的 Base64Url 解码,就是全部改动。
速度:预期如何
现代 .NET 里的 Base64 很快,而且一直在变快。Convert 方法和 System.Buffers.Text 类的运行时实现,在硬件支持的地方都用 SIMD 向量指令优化过,一个周期处理许多字符。实践中这意味着:在普通桌面机器上,几 MB 的负载用个位数到低两位数毫秒就能解码完,快到任何你要写的应用里 Base64 解码实际上都是免费的。因此实用的性能建议关乎代码的形状,而不是解码器本身。在热路径上,优先用 Try 方法或返回状态的 span 方法,那里畸形输入是可能的,异常代价高昂。在循环里解码成千上万个小负载时,用原地和 span API 复用缓冲区,而不是每次调用都分配一块新数组。还有,永远不要解码同一个负载两次:一次是成本,对已经解码过的字段再来一次解码是纯浪费,它会在性能剖析里以一个神秘的第二个 Base64 尖峰出现。
安全:Base64 不做的事
关于 Base64,最重要的安全事实是初学者最容易错过的那条:它是编码,不是加密。一根 Base64 字符串,任何人、任何工具、零点几秒就能读懂,而 C# 把读取变成了一行代码,整篇文章已经证明了这一点。Base64 没有密钥,没有算法参数,也没有可利用的弱点,因为它从来就没想藏任何东西:它是运输格式,是让二进制在纯文本通道里存活的方式。相应地对待它。永远不要把密码、令牌或机密放进配置文件里靠 Base64"保护",因为那层保护只有 Convert.FromBase64String 一个调用的深度。如果值必须是秘密,它就需要真正的保护(机密管理器、加密存储,至少是操作系统访问控制),Base64 只是它运输时穿的外套。
第二条安全备注关于你自己的解码路径。你解码的每个负载都是不可信输入,直到被证明不是。要设计的两种失败模式是响亮的(非法输入,经典 API 用 FormatException 作答,你该捕获它并转成 400 而不是 500)和安静的(合法的 Base64,解码出的字节却不是你以为的:不是 UTF-8,不是你要的文件类型,或者比你预算的长)。先验证再信任:分配之前用 IsValid 或 Try 家族检查长度;把解码后的字节交给图像或证书解析器之前,先对照预期的签名(PNG 魔数、PKCS 头);缓冲区的尺寸在解码之前从编码后的长度定,而不是之后。Base64 会解码任何格式良好的东西;"格式良好"对你的应用意味着什么,做决定的工作是你的。
在被咬到之前值得知道的坑
这些是 C# 专属的、在真实代码里反复出现的陷阱,每一个都在框架的工作方式里有具体成因:
- 二进制穿过字符串。C# 的
string是 UTF-16 码元序列,解码后的 Base64 不是。当你把解码出的字节塞进字符串变量的那一刻(用Console.WriteLine打印解码后的 PNG、字符串与二进制拼接、一个把"文本"序列化的 JSON 库),下游的某样东西就会把它弄坏。解码后的二进制保持byte[],直到它到达真正想要字节的地方。 - Encoding.Default 的分歧。用
Encoding.Default读解码字节的代码,在 .NET Framework(Windows ANSI 代码页)和 .NET(UTF-8)上产生不同文本。同一负载,两种输出,没有异常。显式钉死你的编码。 - JWT 段与经典解码器。把原始 JWT 段喂给
Convert.FromBase64String会同时以两种方式失败:-/_字符在标准字母表之外,缺失的填充违反长度规则。先归一化,或者用Base64Url。 - 看得见的空白与看不见的空白。解码器跳过空格、制表符、换行符和回车符,别的什么都不跳。负载里一个不间断空格、一个 Unicode 行分隔符、一个垂直制表符(都可能从某些网页的复制粘贴中活下来),是
FormatException,不是耸肩。 - 每种罪行一条错误消息。经典解码器的
FormatException不告诉你哪条规则破了、破在哪里。按顺序调试:先长度,再字母表,最后填充;或者换到TryFromBase64String和IsValid,要一个布尔答案。 - 静默的 UTF-8 替换。
Encoding.UTF8.GetString把坏掉的字节序列变成 U+FFFD,一声不吭。如果负载可能不是合法 UTF-8,用字符集那一节的严格回退,否则你要在数据消失几周后才开始调查它去哪了。 - 流切在错误的地方。Base64 流只能在 4 的倍数个字符处切。在别的边界给流式解码分块,最后一个不完整的小组就落在
TransformFinalBlock里,那里它要么名正言顺,要么打破你的对齐计算。 - PEM 行尾。证书文件带着 CRLF。手工剥甲时
\n之外连\r一起剥,否则你"解码后" DER 的第一行是一个穿着数据字节衣服的回车。 - 双重编码。如果负载到你手上时已经是 Base64(一份把 Base64 字符串再 Base64 的配置文件,一个编码了另一个编码器输出的 API),一次解码给你的是更多 Base64,不是你的数据。往返只有在解码次数等于编码次数之后才闭合,而那个 bug 的编码器一侧正是编码那篇文章的主题。
C# 中 Base64 的简史
C# 里 Base64 的故事,也是 .NET 平台长大的故事,而且比大多数人想象的要长:
- .NET Framework 1.1,2003 年 4 月。
Convert.FromBase64String和它的兄弟们到来,带着至今仍定义这套 API 的设计:对字母表严格,对四个空白字符宽容,对错误直截了当。在接下来二十年里的大部分时间,这一个方法就是 C# 里"那个"Base64 解码器。 - .NET 2.0,2005 年。
Base64FormattingOptions枚举加入Convert,把 MIME 风格的换行带到编码一侧(解码一侧也有对应的空白宽容,那里它其实早就在悄悄工作)。 - .NET Core 2.1,2018 年。Span 时代。
Convert获得Try方法和基于 span 的编码,新的System.Buffers.Text.Base64类带着它的OperationStatus契约、原地解码和IsValid到来,为那次以内存为重点的重写打造的零分配世界而建。 - .NET 5,2020 年。十六进制兄弟们(
Convert.ToHexString和朋友们)发货,同一个设计模式应用到 16 符号字母表上,标志着转换类模式成了家规。 - .NET 6,2021 年。
X509Certificate2.CreateFromPem让 PEM 成为一等输入,一整类手工剥甲的代码在现代运行时上变成可选。 - .NET 9,2024 年 11 月。
System.Buffers.Text.Base64Url在社区多年请求之后终于进了盒子,Microsoft.Bcl.Memory包把它回溯移植到 .NET Framework 4.6.2 及以上,给那些仍跑一切的老代码库。 - .NET 11,本文撰写时处于预览。下一个版本预计在 2026 年末,在现有类型上追加更多 Base64 便捷 API 和重载,继续缓慢地走向更顺手的外表。
值得记住的是:编码本身比这一切古老得多。我们现在所称的 MIME Base64 的第一次标准化用途是 1987 年的 Privacy-Enhanced Mail 协议(RFC 989),MIME 在 1993 年把 76 字符折行的形式标准化,RFC 4648 在 2006 年给了这套格式现代的、认识字母表的规范,包括 URL 安全变体。C# 把它们全继承了:你在 30 年历史的邮件格式里遇到的每一个折行和填充怪癖,都是 C# 解码器被设计来吸收的怪癖。
令人好奇的 C# 事实
- 最小的冒烟测试。
"TWFu"解码为Man。三个字节,没有填充,没有借口。它是 C# 里 Base64 调试的 hello world,四个字符走完全程的顺利路径。 - 有邮政史的解码器。空白宽容不是实现的意外 - 是从 MIME 继承来的设计决定:一整个 76 字符折行的邮件正文,带着它所有的 CRLF 对,都是
Convert.FromBase64String一个有效的单一实参。这个解码器就是为了吞下邮件用了三十年的那种格式而造的。 - 一条错误,三个原因。经典的
FormatException消息列出它可能在报告的全部三种失败模式(坏字符、填充过多、填充错位),却不告诉你哪一个触发了。它是 API 表面里唯一一条像多选题一样工作的错误消息。 - 一个有点说谎的命名空间。
System.Buffers.Text听起来像关于文本处理,但它其实是二进制到文本转换整体的家:Utf8Parser和Utf8Formatter把数字和日期直接解析进 UTF-8,就住在 Base64 类的隔壁。 - 填充在家族的一侧是可选的。
Base64Url类把AQIDBA(六个字符,无填充)和AQIDBA==(同样的字节带填充)都解码为同样的四个字节,而经典解码器只接受带填充的形式。两个解码器,两份契约,一个运行时。 - 本不该存在的字符串。C# 字符串合法地可以包含 NUL 字节,所以
Encoding.UTF8.GetString作用于解码后的二进制,可以产出一根满是控制字符的"字符串",控制台、你的 CSV 写入器和世界上半数的 JSON 库会各处理各的。类型系统允许它;生态系统基本不允许。 - 一个状态良好的 1.1 遗物。
Convert.FromBase64CharArray从 2003 年 4 月起保持同一个三参数签名,活过了泛型革命、Span 革命和 URL 安全革命,没有添加哪怕一个重载。C# 的 char 数组时代没有消失;它只是在休息。 - 十一个字符,八个字节。YouTube 的视频标识符是不带填充的 base64url:11 个字符解码为 8 个字节。
Base64Url.GetMaxDecodedLength(11)告诉你这个 8,解码是一行代码。对那种会写这种东西的人来说,这是个很棒的收尾方式。
另一个方向
这就是解码一侧,大部分的疼痛住在这里,因为解码是你遇见别人数据的地方:他们的填充选择、他们的换行、他们的字母表、他们的令牌。相反的方向 - 拿自己的字节把它们打包进 Base64 - 是个更平静的问题,有它自己的一组决定,也有它自己的一组陷阱。C# 的 Base64 编码,从 76 字符的问题到 URL 安全令牌,在下方链接的姊妹文章里深入覆盖,一旦你知道该看什么,它是篇短小而令人满足的阅读。
最后更新: 2026-09-08