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

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,严格的消费者会看到一个空行 消费者挑剔时对结果 chomprtrim
数据库里的折行值 换行落进 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 解码:完整指南