上传文件
  1. base64hub
  2. 在代码中使用 Base64
  3. C
代码

C 语言中的 Base64

C 标准库没有 Base64 函数,所以大多数项目都自带一份实现。下面是一个简短的实现,我们用 Python 的 base64 模块对它做过模糊测试,另外还列出了最容易出错的细节。

编码器

每 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=2hi
aGk-1长度不是 4 的倍数
aGk=!!-1长度和字符都有问题
aGl=-1填充位不为零
PDw_Pz8-Pg==-1URL 安全字符
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),从而拒绝这类输入。

继续阅读