Perl 中的 Base64 编码:完整指南
你手里有数据,而它必须穿过一个不喜欢它的通道活下去。一团需要住进 JSON 字段的二进制。一张必须活在 HTML 标签里的图片。一个属于配置文件的证书。一枚要穿越 URL、头部和查询字符串的令牌。这就是 Base64 编码的日常生活:它把原始数据每三个字节重写成四个字符,取自一个 64 字母的字母表,再用一两个 = 收尾,于是结果是任何东西都能携带的纯文本,通常比开始时长约 33%。本站首页已经完整解释了这个格式,所以本文聚焦于 Perl 给你什么、它替你悄悄决定了什么,以及陷阱都在哪里。
先说好消息:encode_base64 从 2002 年起就住在 Perl 核心里,用 C 实现,速度相当快。有意思的是这个函数有脾气。它把输出在 76 字符处折行,它追加一个结尾换行,而且它不会编码那些你还没先转成字节的 Unicode 字符:超出 Latin-1 范围时它直接死掉,低于那条线时它默默假设是 Latin-1 字节。这份指南走一遍 Perl 开发者真正会产出的每一种 Base64 风味:单行命令、MIME 折行的邮件正文、PEM 包裹的密钥、URL 安全的令牌,以及为大到装不进内存的文件准备的流式版本。
一个函数,一条隐藏的换行
完整 API 如下,正是现代文档的样子:
encode_base64( $bytes )
encode_base64( $bytes, $eol )
再读一遍。两个参数,其中一个是可选的,一个返回值,没有标志。可选的第二个参数是行结束序列,默认值就是一个普通换行,这意味着 Perl 里看起来最人畜无害的调用,产出的却是折过行、带结尾换行的输出:
use MIME::Base64 qw(encode_base64);
my $wrapped = encode_base64("Aladdin:open sesame");
print length($wrapped), "\n"; # 29:28 个字符外加一个换行
my $single = encode_base64("Aladdin:open sesame", "");
print length($single), "\n"; # 28:传空字符串表示不折行
输出大小遵循一个固定模式,调用之前就能预测:
| 输入字节 | 输出字符 | 填充 |
|---|---|---|
| 0 | 0 | 无 |
| 1 | 4 | 两个 = |
| 2 | 4 | 一个 = |
| 3 | 4 | 无 |
| 100,000 | 133,336 | 无,单行 |
| 3,000,000 | 4,000,000 | 无 |
模式是:每满三个字节一组产出四个字符,外加最后一组不完整的用一两个 = 补齐。一个值得知道的推论:一个字节和三个字节都产出四个字符,所以编码后的长度藏着确切的输入大小。如果你需要知道大小又不想真的干活,这个模块自 2010 年的 3.10 起就有长度函数了。只是它默认不导出,所以要用包名调用:
use MIME::Base64 ();
my $with_wrap = MIME::Base64::encoded_base64_length($bytes); # 76 字符的行,默认 eol
my $single_line = MIME::Base64::encoded_base64_length($bytes, ""); # 不折行
my $mime_body = MIME::Base64::encoded_base64_length($bytes, "\r\n");
还有一条规则要背下来,因为这是编码器唯一会大喊大叫的方式:如果你交给它的字符串包含码点超过 255 的字符,encode_base64 会死在子程序入口处的宽字符上。低于那条线时,失败要安静得多:码点 255 以下的字符被悄悄降级成它们的 Latin-1 字节,所以一个跳过了转换的带重音字符串会按 Latin-1 而不是 UTF-8 编码,而且没人告诉你。Base64 编码只对单字节字符有定义,而 Perl 5.8 及以上允许字符串里出现扩展字符,所以这个转换是你刻意做出的决定,用 Encode 来做 - 就在下一节。
文本还是字节?函数做不了的那一步
Perl 字符串带着一个安静的标志,说明它装的是字符还是字节,而 Base64 活在那条线的字节那一侧。如果你的文本是字符字符串 - 它来自 JSON 解析器、模板,或 UTF-8 源文件里带重音字母的字面量时就是 - 编码器拒绝为超出 Latin-1 范围的字符猜测你想要什么字节,而且它会明说;低于那个范围时,它默默地猜 Latin-1。两种情况下的解法相同,就是 Encode 模块里的一个核心函数:
use MIME::Base64 qw(encode_base64);
use Encode qw(encode);
my $chars = "H\x{eb}llo W\x{f6}rld"; # 一个字符字符串
my $utf8 = encode("UTF-8", $chars); # 现在:字节
my $b64 = encode_base64($utf8, "");
print $b64, "\n"; # SMOrbGxvIFfDtnJsZA==
那个 encode 调用就是整套舞步:挑一个字节表示法,现代的一切都选 UTF-8,把字符转成那些字节,然后才把字节交给编码器。对于那些以 Windows 1252 形式到来的老派西方文本,转换是同一个函数换个名字:encode("Windows-1252", $legacy),它交给你原来的单字节形式。Encode 模块是核心的,所以这一切免费。
现在说陷阱。如果你手里的字节已经是 UTF-8,你又把它们再过一遍 encode("UTF-8", ...),以为自己在把它们变成 UTF-8,你得到的不是一份副本:你会得到一个双重编码,里面每个带重音的字符都膨胀成两个字符。经典症状是原本读作 Hëllo 的文本现在读作 Hëllo,而互联网上每个解码器都会忠实地替你把那个解码出来:
use Encode qw(encode decode);
my $right = encode("UTF-8", "H\x{eb}llo");
my $wrong = encode("UTF-8", $right); # 把字节当字符重新编码
print decode("UTF-8", $right), "\n"; # Hëllo
print decode("UTF-8", $wrong), "\n"; # Hëllo
能防住它的手感规则是:字节恰好被编码一次,而 utf8::is_utf8() 告诉你字符串在那条线的哪一侧。如果标志已设置,你手里是字符,encode() 调用就是正确的下一步;如果没设置,你手里是字节,已经准备好迎接 Base64 了。
折行:三种方言,一条规则
因为默认输出是折行且带结尾换行的,所以每个编码任务的第一道决定都是去向问题:这个字符串要住在哪儿?统一一切的事实是:解码器完全无视换行:RFC 2045 告诉解码软件忽略所有换行和字母表之外的字符,所以折行只是对行式工具和人类的一种客气,不是语义差异。实践中三个答案:
无换行。关掉折行的函数输出,正好一行。URL、JSON 载荷、头部、数据库值,以及其他任何换行都会变成 bug 的地方,要的都是这个。大多数人说"就要 Base64"时指的可能也是它:
my $single = encode_base64($bytes, "");
MIME:76 字符加 CRLF。RFC 2045 的邮件惯例:编码后的行不得超过 76 字符,而 MIME 世界说的是 CRLF。这个活是模块自己干的,用第二个参数完成:
my $mime_body = encode_base64($bytes, "\r\n");
PEM:64 字符加 LF。密钥和证书用更老的惯例,行更短,64 字符,而模块自己产不出这个宽度,所以一个四行的助手来补上缺口:
sub wrap_lines {
my ($text, $width) = @_;
return join "\n", $text =~ /(.{1,$width})/g;
}
my $pem = "-----BEGIN CERTIFICATE-----\n"
. wrap_lines(encode_base64($der, ""), 64) . "\n"
. "-----END CERTIFICATE-----\n";
同一个助手也服务其他 64 字符的方言 - RFC 7468 的 PKIX 文本编码和 OpenPGP 护甲,它们的数据行宽 64 字符,而结尾的 CRC24 校验和行是 GnuPG 加的,不是你加的。一个陷阱适用于它们全部:encode_base64 会在结果的最末尾追加行结束符,哪怕最后一行正好占满了宽度。如果下游消费者被那个结尾空行绊倒,对结果来一次 rtrim 就好了。
URL 安全 Base64:- 和 _ 的字母表
标准字母表里有 + 和 /,而在文本文件之外,这两个都是麻烦:表单编码的查询字符串里的 +,会在你的应用看到它之前就变成空格,/ 是 URL 里的路径分隔符。文件名和令牌各有各的抱怨。RFC 4648 第 5 节用 URL 和文件名安全字母表解决了这个问题:+ 变成 -,/ 变成 _,结尾的 = 填充通常也丢掉。RFC 坚持这不应被视为与 base64 编码相同,所以把它当成一种独立的格式,通常叫 base64url。Perl 自 2010 年的 3.11 版起一次调用就能产出它:
use MIME::Base64 qw(encode_base64url);
my $seg = encode_base64url("sunset-42");
print $seg, "\n"; # c3Vuc2V0LTQy:无填充,无换行
那一次调用干完了全部三处变化:换字母表、无填充、无换行。如果你手里已经是标准 Base64,而去向要的是 URL 安全方言,两个字符串操作就能原地转换:
sub to_urlsafe {
my ($b64) = @_;
$b64 =~ tr{+/}{-_};
return $b64 =~ s/=+\z//r;
}
my $seg = to_urlsafe(encode_base64($bytes, ""));
什么时候用它:JSON Web Token 的各部分、OAuth 的 state 和 nonce 参数、放进 URL 路径里的 API ID,以及需要熬过地址栏或文件名的不透明键 - CPAN 上的 Data::UUID::Base64URLSafe 就是为这个而存在的。什么时候不用它:邮件正文、PEM 护甲,以及任何线那头坐着标准字母表消费者的地方,因为 - 和 _ 不在它们的词汇里。也千万不要悄悄混用两种字母表:按 URL 安全方式编码的值,必须按 URL 安全方式解码,到处,永远。在 3.11 之前的 Perl 上,2006 年的独立模块 MIME::Base64::URLSafe - Python urlsafe 编解码器的移植版 - 提供 urlsafe_b64encode;在任何现代版本上,内置函数才是趁手的工具。
手工构建 JWT:每个部分都亲手来
JSON Web Token 是现代 API 里 Base64 的头号消费者,它们用的是上一节那个 URL 安全、无填充的方言。按 RFC 7515,一个紧凑 JWT 是三个用点号分开的 base64url 部分:受保护的头部、载荷,以及签名。亲手构建一个,是看清每个活动部件的愉快方式:
use MIME::Base64 qw(encode_base64url);
use JSON::PP qw(encode_json);
use Digest::SHA qw(hmac_sha256);
my $secret = "correct-horse-battery-staple";
my $head = encode_base64url(encode_json({ alg => "HS256", typ => "JWT" }));
my $claims = encode_base64url(encode_json({ sub => "homer", role => "admin" }));
my $sig = encode_base64url(hmac_sha256("$head.$claims", $secret));
my $jwt = "$head.$claims.$sig";
print $jwt, "\n"; # 一个紧凑的 HS256 令牌;每个 JSON 部分内部的键顺序每次运行都可能不同
有三个细节值得注意。第一,核心模块 JSON::PP 里的 encode_json 产出紧凑的 UTF-8 字节,不含任何空白,这正是 JOSE 规范在令牌内部想要的样子。第二,载荷任何人都可读,而且是刻意如此:JWT 是一张签过名的票,不是秘密,所以永远不要把机密值放进 claims。第三,签名是原始 HMAC 字节的 base64url 编码,所以 hmac_sha256 不用做任何十六进制格式化就径直进了编码器。
到了生产环境,别手工搓签名。基于 CryptX 构建的 CPAN 模块 Crypt::JWT 实现了 JWS 和 JWE,算法一套不缺:
use Crypt::JWT qw(encode_jwt);
my $jwt = encode_jwt(
payload => { sub => "homer", role => "admin" },
alg => "HS256",
key => $secret,
);
而在接收方,用 accepted_alg 钉住算法,让攻击者无法把令牌翻成更弱的变体:decode_jwt(token => $jwt, key => $secret, accepted_alg => "HS256") 会验证签名,失败时 croak。手工搓着玩是为了理解;涉及真金白银,库才是正经。
HTTP:认证头、data URI 与 WebSocket 握手
Authorization: Basic 头是最古老的现役用例:用户名和密码用冒号连起来,编码成一行,前面加上方案词。空字符串的第二个参数在这里是承重墙,因为头部字段里的结尾换行就是一个 bug:
use MIME::Base64 qw(encode_base64);
my $user = "alice";
my $pass = "s3cr3t";
my $header = "Basic " . encode_base64("$user:$pass", "");
print $header, "\n"; # Basic YWxpY2U6czNjcjN0
RFC 2397 的 data URI 是把同一个想法用在图片上:载荷直接坐在 URL 里,所以不需要第二个请求去取它。二进制媒体用 ;base64 标志,所以载荷正是 encode_base64 关掉折行后产出的那个东西:
my $uri = "data:" . $mime_type . ";base64," . encode_base64($bytes, "");
不过取舍是真实的。编码后的载荷比文件大约大 33%,HTML 文档本身也就变大了。浏览器不会像缓存文件 URL 那样缓存 data URI - 没有单独的抓取可供缓存,所以每次页面浏览都会把字节作为文档的一部分再寄一次,而 RFC 自己都说 data URI 只对短值有用。头像、图标、小型内联图形用它;其他一切用真正的文件。还有第三个 HTTP 角落在悄悄使用 Base64:RFC 6455 的 WebSocket 握手,客户端在那里发一个 Sec-WebSocket-Key 头,它是十六个随机字节的 Base64。像 Mojolicious 这样的框架会替你做完,但如果你哪天在网络上见到它,现在你知道它是什么了:
use MIME::Base64 qw(encode_base64);
my $key = encode_base64(pack("C16", map { int(rand 256) } 1 .. 16), "");
print length($key), "\n"; # 24:十六个随机字节,补齐到四字符一组
文件:一次性读取、57 字节分块与命令行
最直截了当的编码任务:文件变成文本。Perl 字符串就是字节,所以不用满世界找什么二进制模式 - :raw 层就是全部。以原始方式打开,读,编码,写:
use MIME::Base64 qw(encode_base64);
open my $in, "<:raw", $ARGV[0] or die $!;
local $/;
my $bytes = <$in>;
close $in;
open my $out, ">:raw", $ARGV[1] or die $!;
print {$out} encode_base64($bytes);
close $out;
:raw 层很重要。没有它们,Perl 会在进和出的时候试图把字节解读成平台文本,而在默认编码不同的系统上,那正是你看不见的损坏 - 直到文件在别处被打开。规划存储时记得那份体积账单:一张 500 KB 的图片变成 670 KB 的文本文件,一个 1 GB 的视频变成 1.33 GB。
对于大到装不进内存的文件,模块自己的文档给你规则:按 57 字节的整数倍分块编码,因为 57 字节的数据正好填满一行 76 字符,76 就是 57 乘以 4 再除以 3。在那个边界上分块,流中间就永远不会出现填充:
use MIME::Base64 qw(encode_base64);
open my $in, "<:raw", $ARGV[0] or die $!;
while (read($in, my $buf, 57 * 10)) {
print encode_base64($buf);
}
close $in;
每个分块都正好落在行边界上,最后一个可能较短的分块带着最终的填充,结果与整个文件一次性读取再编码逐字节相同,只是内存占用恒定。当你根本不需要脚本时,单行命令就够了,-0777 吞掉输入,空字符串参数让输出保持在一行上:
perl -MMIME::Base64 -0777 -ne 'print encode_base64($_, "")' < file > file.b64
邮件:MIME 正文与附件
邮件是 Base64 得名之处。MIME 标准说,不能安全地以原始文本形式搭载的数据,应该带着 Content-Transfer-Encoding: base64 发送,行不超过 76 字符。如果你用 MIME::Lite 构建邮件,整件事就是一个参数,模块替你完成编码、折行和头部:
use MIME::Lite;
my $mime = MIME::Lite->new(
From => 'me@example.com',
To => 'you@example.com',
Subject => 'A file',
Type => 'text/plain',
Data => 'The body text.',
);
$mime->attach(
Type => 'application/octet-stream',
Data => $bytes,
Encoding => 'base64',
Filename => 'hello.txt',
);
Encoding 参数就是扳机:MIME::Lite 把附件按 76 字符一行做 Base64 编码(用模块默认的裸换行;是邮件传输把它们变成 CRLF),并给这个部分盖上对应的 Content-Transfer-Encoding 头。Email::MIME 立场相同,把你交给它的任何原始数据字符串附件都做 Base64 编码(它的文档说:"以这种方式创建的所有部分都用 base64 编码,以防万一")。如果你手工拼装原始 MIME 消息,等价做法就是折行那一节的两行:encode_base64($bytes, "\r\n") 加上一行头部,协议侧的故事到此讲完。
数据库、配置与环境变量
数据库:二进制数据经常以 Base64 的形式搭乘 TEXT 列,因为这个列不能承诺让任意字节原封不动地通过。存单行形式,永远不要存折行形式,否则你下一次 SELECT 会取出一个值中间夹着换行的字符串:
use MIME::Base64 qw(encode_base64);
# $dbh 是一个已经连接好的 DBI 句柄
my $stmt = $dbh->prepare(q{UPDATE photos SET data = ? WHERE id = ?});
$stmt->execute(encode_base64($bytes, ""), 42);
配置文件是同样的形状:一个 JSON 文档,其中二进制或机密字段是单行 Base64 字符串,这正是第二个参数存在的原因:
use JSON::PP qw(encode_json);
my $config = {
api_key => encode_base64($key_bytes, ""),
logo_png => encode_base64($png_bytes, ""),
};
open my $fh, ">:raw", "app.json" or die $!;
print {$fh} encode_json($config);
close $fh;
环境变量值得一句警告。环境里的小令牌用 Base64 没问题,但编码后的形式比原始的大 33%,而操作系统给每个参数设了上限。Linux 上每个字符串的限额是 128 KB,由 execve 强制执行,而且环境变量里的大数据块不会客气地失败:子进程一出生就带着一个费解的错误死去。小值放环境,大值放文件或数据库。
性能:干活的不是 Perl,是 C
核心模块用 C 实现,而那 C 源自 1991 年为 metamail 写下的代码,这是个趣闻,直到你注意到它的含义:编码器享受了三十年的调优。在现代机器上,它以每秒数吉字节的节奏处理数据,这比它通常要喂饱的磁盘或网络还快,所以 Base64 本身几乎从不是瓶颈。I/O 才是。
至于那几台没有 C 编译器的稀奇系统,CPAN 上的纯 Perl 孪生版 MIME::Base64::Perl 提供同样的基础接口,慢上几倍,但对普通负载仍然从容。还有两个习惯让大任务保持可预测:用 57 字节分块流式处理而不是一次性读取,分配之前先用 encoded_base64_length 把缓冲区大小定好,两样都替你省了猜测,也省了重新分配。
陷阱,按一个下午的代价排序
陷阱们,大致按咬人的顺序排列:
| 陷阱 | 发生了什么 | 修法 |
|---|---|---|
| 忘了第二个参数 | 输出以 76 字符折行、带结尾换行的样子到达,你的 URL、JSON 字段或头部在值的中段断掉 | 传 "" 得到单行输出,期望折行的去向则保留折行 |
| 折行宽度不对 | PEM 消费者期望 64 字符的行却拿到 76,或者 MIME 正文超过 76 字符上限 | 让宽度匹配方言:"" 表示不折行,"\r\n" 用于 MIME,PEM 用助手 |
| 宽字符的 croak | 码点超过 255 的字符字符串在请求中途死在子程序入口处的宽字符上 | 先让字符过一遍 Encode,刻意指名,再调 encode_base64 |
| 双重编码 | 把已经是 UTF-8 的字节再过一遍 encode("UTF-8", ...),Hëllo 变成 Hëllo |
字节恰好编码一次;拿不准时查 utf8::is_utf8() |
| 悄悄混用字母表 | 用 - 和 _ 编码的值撞上标准字母表解码器,回来的是垃圾 |
每个值一种方言,端到端:在边界上选定 base64url 或标准 |
| 结尾的行结束符 | encode_base64 哪怕最后一行正好占满也会追加 eol,严格的消费者会看到一个空行 |
消费者挑剔时对结果 chomp 或 rtrim |
| 数据库里的折行值 | 换行落进 TEXT 列,下一次 SELECT 返回一个坏掉的令牌 |
存单行形式;只在去向处折行 |
| 环境变量里的大数据块 | 33% 的膨胀加上操作系统的每参数限额,让子进程在出生时带着费解的错误死去 | 小值放环境,大值放文件或数据库 |
| 以为 Base64 是保护 | 这个格式什么都不藏,公开记录里就有真实事件:用户粘贴了一段 IMAP 交互,意外泄露了密码 | 从产出那一刻起就把输出当机密,让它远离日志 |
| 规划时没算体积账单 | 一张 500 KB 的图片变成 670 KB 的文本,你没查过的存储或载荷限额咬了你一口 | 承诺之前,按原始大小的 4/3 预留预算 |
一段由编码器讲述的历史
Perl 的 Base64 编码器有一段值得花一分钟了解的职业生涯,而且它始于第一个 web 工具箱:
- 生于 libwww perl。编码器最初叫
LWP::Base64,由 Martijn Koster 和 Joerg Reichelt 编写,后来被 Gisle Aas 收进 libwww perl,改叫MIME::Base64;1997 年 4 月它毕业成自己的 CPAN 发行包,版本 2.00,变更日志条目只说它基于 libwww perl 5.08。 - 速度年代。1998 年的 2.07 版带来了更快也更聪明的解码器 C 实现,在当时还算现代的 Linux 机器上快了大约 25%,调优又持续了十年。
- Unicode 年代。2002 年的 Perl 5.8 把码点超过 255 的字符带进了普通字符串,模块分步回应:2001 年的 2.12 在编码前把 UTF-8 字符串降级,而现代那条子程序入口处的宽字符的 croak 就是编码器兑现那个承诺的方式。同年 2.13 与核心同步时顺带带来了 EBCDIC 支持,提醒我们 Perl 里的 Base64 仍跑在主帧上。
- 命令行年代。从 2003 年的 2.14 到 2004 年的 3.05,各版本捆绑了一个真正的
encode-base64命令,外加它的 decode 和 quoted-printable 孪生兄弟;2005 年的 3.06 把这些脚本搬去了单独的 MIME Base64 Scripts 发行包。 - URL 安全版登场。RFC 4648 在 2006 年把 URL 安全字母表写成标准,同年出现独立模块
MIME::Base64::URLSafe,核心模块在 2010 年的 3.11 追了上来,encode_base64url一次调用搞定。 - 现代系列。2020 年的 3.16 版重做了打包,把门槛提到 Perl 5.6;现核心 Perl 都随附 3.16 系列,模块在核心发行版内部维护,对一个核心模块来说,这差不多是所能拥有的最安全的家了。
趣闻,Perl 专属
让这个故事变得精彩的趣闻:
- POD 里的例子是一句咒语。从 1997 年起,模块自己的文档就在编码
Aladdin:open sesame,所以字符串QWxhZGRpbjpvcGVuIHNlc2FtZQ==做了这个模块近三十年的名片。 - 默认行结束符可能不是你预期的那个。它就是普通的
\n,不是 MIME 说的那种 CRLF。RFC 自己的惯例需要第二个参数,而模块出厂时带的是程序员默认值,不是协议默认值。 - 空字符串有特殊规则。什么都不编码,什么都不回来,也不追加换行:结尾 eol 规则唯一被文档记录的例外,也是空文件能干净往返的原因。
- IMAP 表亲带着逗号。RFC 3501 的邮箱名变体把字母表里的
/换成了逗号,所以 IMAP 服务器上的 Base64 字符串可能含有一个标准解码器当噪音处理的字母。 - 1991 年的血统是真的。C 实现源自 metamail,Bellcore 1991 年的邮件程序,比 Perl 5 的出生早三年,所以每次
encode_base64调用都有一部分是九十年代的代码。 - 纯 Perl 孪生版有自己的故事。当 2004 年的 3.00 版把纯 Perl 实现从核心模块里移除时,变更日志称它们是掩盖 XS 实现里真正问题的臃肿,然后把它们重新发布为
MIME::Base64::Perl,它们至今仍住在那里。
所以下一次,原始字节需要穿过一个只有文字的世界时,你已经知道全部故事了。一个函数调用干完活,隐藏的换行是你用第二个参数做出的决定,宽字符的 croak 是编码器让你的 Unicode 保持诚实的方式,URL 安全方言自 2010 年起一次调用搞定,文件按 57 字节分块流动,而 33% 的账单是入场费。如果哪天你需要走反方向,拿着一串字母把最初的字节取回来,下面那篇关于 Perl 里 Base64 解码的相关文章会用同样的深度讲那场仪式。
最后更新: 2026-09-08
相关文章: Perl 中的 Base64 解码:完整指南