编码器
每 3 个输入字节变成 4 个字符,每个字符占 6 位。最后剩下 1 或 2 个字节的那一组用 = 填充。比特布局的详细讲解见 Base64 的工作原理。
#include <stddef.h>
#include <stdint.h>
static const char B64[] =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
/* Output size for n input bytes, without the terminating NUL. */
size_t b64_encoded_len(size_t n) { return 4 * ((n + 2) / 3); }
/* Writes b64_encoded_len(n) chars plus a NUL to out. */
void b64_encode(const uint8_t *in, size_t n, char *out) {
size_t i = 0;
for (; i + 2 < n; i += 3) {
uint32_t v = (uint32_t)in[i] << 16 | (uint32_t)in[i + 1] << 8 | in[i + 2];
*out++ = B64[v >> 18 & 63];
*out++ = B64[v >> 12 & 63];
*out++ = B64[v >> 6 & 63];
*out++ = B64[v & 63];
}
if (i < n) { /* 1 or 2 bytes left */
uint32_t v = (uint32_t)in[i] << 16;
if (i + 1 < n) v |= (uint32_t)in[i + 1] << 8;
*out++ = B64[v >> 18 & 63];
*out++ = B64[v >> 12 & 63];
*out++ = (i + 1 < n) ? B64[v >> 6 & 63] : '=';
*out++ = '=';
}
*out = '\0';
}
循环条件写成 i + 2 < n 而不是 i < n - 2,这一点很重要:对于 size_t,当 n 为 0 或 1 时,n - 2 会回绕成一个巨大的数。转换为 uint32_t 是为了避免 in[i] << 16 在有符号的 int 上进行。
严格的解码器
这个解码器会拒绝一切不规范或缺少填充的 Base64:长度错误、字母表之外的字符、出现在中间的填充,以及不为零的填充位。
static int b64_val(unsigned char c) {
if (c >= 'A' && c <= 'Z') return c - 'A';
if (c >= 'a' && c <= 'z') return c - 'a' + 26;
if (c >= '0' && c <= '9') return c - '0' + 52;
if (c == '+') return 62;
if (c == '/') return 63;
return -1;
}
/* Returns the number of bytes written, or -1 on invalid input.
out needs room for 3 * (len / 4) bytes. */
long b64_decode(const char *in, size_t len, uint8_t *out) {
if (len % 4 != 0) return -1;
size_t o = 0;
for (size_t i = 0; i < len; i += 4) {
int a = b64_val(in[i]), b = b64_val(in[i + 1]);
int c = b64_val(in[i + 2]), d = b64_val(in[i + 3]);
int last = (i + 4 == len);
if (a < 0 || b < 0) return -1;
if (c < 0) { /* must be "xx==" at the end */
if (!last || in[i + 2] != '=' || in[i + 3] != '=' || (b & 15)) return -1;
out[o++] = (uint8_t)(a << 2 | b >> 4);
} else if (d < 0) { /* must be "xxx=" at the end */
if (!last || in[i + 3] != '=' || (c & 3)) return -1;
out[o++] = (uint8_t)(a << 2 | b >> 4);
out[o++] = (uint8_t)(b << 4 | c >> 2);
} else {
out[o++] = (uint8_t)(a << 2 | b >> 4);
out[o++] = (uint8_t)(b << 4 | c >> 2);
out[o++] = (uint8_t)(c << 6 | d);
}
}
return (long)o;
}
b64_val 接受 unsigned char 参数,这样在 char 为有符号类型的平台上,大于 127 的字节不会变成负数索引。用 256 项的查找表会更快;if 链则更容易用肉眼核对。
用法与结果
#include <stdio.h>
#include <string.h>
int main(void) {
const char *s = "héllo wörld"; /* source file saved as UTF-8 */
char enc[64];
b64_encode((const uint8_t *)s, strlen(s), enc);
printf("%s\n", enc); /* aMOpbGxvIHfDtnJsZA== */
uint8_t dec[64];
long n = b64_decode(enc, strlen(enc), dec);
printf("%ld %.*s\n", n, (int)n, dec); /* 13 héllo wörld */
return 0;
}
C 语言没有文本编码的概念:字符串字面量里的字节就是源文件里存的那些字节。源文件为 UTF-8、GCC 使用默认的执行字符集时,得到的就是 UTF-8。
b64_decode 对一些典型输入的返回值:
| 输入 | 返回值 | 原因 |
|---|---|---|
"" | 0 | 空字符串是有效的 |
aGk= | 2 | hi |
aGk | -1 | 长度不是 4 的倍数 |
aGk=!! | -1 | 长度和字符都有问题 |
aGl= | -1 | 填充位不为零 |
PDw_Pz8-Pg== | -1 | URL 安全字符 |
a=== | -1 | 填充过多 |
我们用 gcc -std=c99 -Wall -Wextra -fsanitize=address,undefined 编译,对 2,000 个随机缓冲区做了往返测试,并在 20,000 个随机字符串上,把接受或拒绝的判断与 Python 的 b64decode(validate=True) 加重新编码检查的结果进行对比。没有任何差异,sanitizer 也没有报告问题。想测试你自己的字符串,可以用规则相同的 Base64 校验工具。
分块编码文件或 stdin
每次读取大小为 3 的倍数的块,拼接起来的输出就和一次性编码整个文件完全相同:
int main(void) {
uint8_t in[3 * 1024]; /* multiple of 3: no padding mid-stream */
char out[4 * 1024 + 1];
size_t n;
while ((n = fread(in, 1, sizeof in, stdin)) > 0) {
b64_encode(in, n, out);
fputs(out, stdout);
}
return ferror(stdin) ? 1 : 0;
}
对一个 1,000,003 字节的随机文件,./b64 < big.bin 的输出与 base64 -w 0 big.bin 完全相同。fread 会一直读到缓冲区填满或输入结束,所以从管道短读时也不会在中间插入填充。在 Windows 上,要先调用 _setmode(_fileno(stdin), _O_BINARY),否则 CRLF 转换会损坏二进制输入。
URL 安全输出
编码后再转换:替换两个字符,并去掉填充。
void b64_to_url(char *s) {
char *w = s;
for (char *r = s; *r; r++) {
if (*r == '=') break;
*w++ = (*r == '+') ? '-' : (*r == '/') ? '_' : *r;
}
*w = '\0';
}
/* bytes {0xfb, 0xff}: "+/8=" -> "-_8" */
解码时,在 b64_val 中把 - 映射为 62、_ 映射为 63,并接受最后一组为 2 或 3 个字符且不带 = 的情况。见 Base64 与 Base64URL 的区别 和 填充详解。
手写 Base64 的常见 bug
- 缓冲区差一错误:忘了结尾的 NUL,或者把输出大小算成
n * 4 / 3,只要n不是 3 的倍数就会偏小。大小计算器 和 体积开销指南 给出了精确数字。 - 用有符号
char查表:c为负数时,table[c]会读到数组之外。 - 对二进制数据用
strlen:解码结果可能包含0x00,请使用函数返回的长度,而不是strlen。 - 三字符组(trigraph):在严格的 C99/C11 模式下,像
"<<???>>"这样的测试字符串包含三字符组??>,编译器会把它变成}。GCC 在-Wtrigraphs下会给出警告。可以转义成"<<?\?\?>>",或者改用字节数组。C23 已经移除了三字符组。
这段代码的 C++ 版本(使用 std::string 和 std::vector)见 C++ 中的 Base64。
常见问题
输出缓冲区要多大?
编码时需要 4 * ((n + 2) / 3) 个字符,再加 1 个字节存放结尾的 NUL。解码时 3 * (len / 4) 个字节一定够用;由于填充,实际长度最多会少两个字节。
OpenSSL 或 glibc 里有现成的 Base64 函数可以用吗?
OpenSSL 有 EVP_EncodeBlock 和 EVP_DecodeBlock,它的 BIO 层还能流式处理 Base64。glibc 没有公开的 Base64 API。如果项目已经链接了这类库,直接用它是合理的;否则,下面的代码比引入新依赖更省事。
为什么我的解码器会接受 aGl= 这样的字符串?
aGl= 和 aGk= 都解码为 hi,前者的填充位不为零。宽松的解码器会忽略这些位。本页的解码器会在最后一组中检查 (c & 3) 和 (b & 15),从而拒绝这类输入。