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

C 语言中的 Base64 编码:完整指南

你手里有字节。也许是从磁盘读出的 JPEG,也许是令牌,也许是客户端正要传输的密码,也许是某个流水线期望装在 JSON 字段里的文件原始字节。而在路上某处,有一个只会说文本的通道:一个 JSON 字符串、一个 URL、一封邮件正文、一个配置文件、一个假装自己是文本的数据库列。Base64 编码就在这里登场:它把每三个原始字节重写成 64 字母字母表里的四个字符,于是结果就是能活过地球上任何文本流水线的纯 ASCII。本站首页已经把格式完整讲过;这篇文章讲的是在 C 里把这件事做好,而照例,这门语言不会替你做好。

要记在脑子里的那一个数字:编码会让你的数据膨胀。三个字节变成四个字符,所以每个载荷离开你的程序时大约大了三分之一,加了换行还要再多一点。这就是税,没得商量 - 但在 C 里,这笔税有明细行,因为输出缓冲区是你自己分配的,而且必须刚好够大。把算术算对一次,本文每个编码器就变得可预测:没有上溢,没有下溢,也不用猜下一个字节会落在哪里。然后还有工具箱供你挑选 - OpenSSL、Mbed TLS、APR-Util、GLib - 选择很重要,因为每家折行不同、终止方式不同、报错方式也不同。

四个编码器,四种性格

四家库编码标准字母表都正确且一致 - 同样的字节进去,同样的字符出来,永远如此。差别在包装,而互操作 bug 就藏在包装里。全局图如下:

头文件 输出风格 失败模式
OpenSSL(libcrypto) <openssl/evp.h> 无换行;写一个 NUL 终止符 实际上没有(只可能是分配问题)
Mbed TLS <mbedtls/base64.h> 无换行;NUL 终止 带所需尺寸的缓冲区太小码
APR-Util <apr-1.0/apr_base64.h> 无换行;追加一个 NUL 没有 - 相信你的缓冲区大小
GLib <glib.h> 无换行;NUL 终止,堆上分配 返回 NULL(只可能是分配问题)

注意表格里缺了什么:它们默认一个都不折行。这是刻意的 - RFC 4648 说,除非外围规范明确要求,实现不得添加换行符 - 而且这是一件好事,因为 JSON 字符串或 URL 里混进一个换行是错误,不是特性。折行是为邮件和 PEM 准备的,需要时你可以从 OpenSSL 的流式路径得到,或者自己五行折好(邮件一节两种都给了)。选库:已经链接 OpenSSL 就用 OpenSSL,嵌入式构建每个 KB 都要争的话用 Mbed TLS,Apache 生态里用 APR-Util,程序其余部分已经是 GLib 就用 GLib。安装:libssl-dev(Debian/Ubuntu)或 openssl-devel(Fedora/RHEL)或 brew install openssl(macOS);Mbed TLS 是 libmbedtls-dev;APR-Util 是 libaprutil1-dev 加上 libapr1-dev;GLib 是 glib2.0-dev

先算账,再分配

代码之前,先算术,因为 C 不会从"缓冲区太小"里救你。每三个输入字节恰好产出四个输出字符。如果输入长度不是三的倍数,最后一组照样产出四个字符,未用的槽位用 = 填充标记:一个输入字节变成带两个填充的四个字符,两个输入字节变成带一个填充的四个字符。所以 n 字节的精确编码长度是:

size_t encoded_chars(size_t n) {
  return ((n + 2) / 3) * 4;
}

1000 字节是 1336 个字符;1 字节是 4;0 是 0。由此跟着两个修正。第一,OpenSSL 和 Mbed TLS 都会在数据之后追加一个 NUL 终止符(Mbed TLS 在你查询尺寸时还会为它预留空间),所以你的缓冲区要多一个字节:encoded_chars(n) + 1。第二,如果你要折行输出,每行加一个换行:OpenSSL 的流式编码器每 48 个输入字节产出一行 64 字符,所以折行后的长度是 encoded_chars(n) + (n + 47) / 48。用 1000 字节验证:1336 个字符加 21 个换行是 1357,而这正是编码器产出的结果。把公式写成函数用一次,到处都用它;它就是"装得下"和凌晨三点堆损坏之间的区别。

size_t b64_buffer_size(size_t in_len) {
  return ((in_len + 2) / 3) * 4 + 1; /* 字符数 + NUL */
}
size_t b64_buffer_size_wrapped(size_t in_len) {
  return ((in_len + 2) / 3) * 4 + (in_len + 47) / 48 + 1;
}

OpenSSL:一整块,或一个常开的水龙头

OpenSSL 的一次性函数是主力,也是这一组里最友好的:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  const char *text = "Mane";
  unsigned char out[32];
  int n = EVP_EncodeBlock(out, (const unsigned char *)text,
                          (int)strlen(text));
  printf("len=%d str=%s\n", n, (char *)out);
  return 0;
}

它把编码字符写进 out,在后面追加一个 NUL,返回的长度不含 NUL - 所以用 %s 打印是安全的,需要时长度也在手边。输出缓冲区必须装得下 encoded_chars(n) + 1 个字节。没有需要处理的错误路径:编码不可能失败,因为任何字节都是合法输入,函数也没有输入校验的概念可被绊住。它唯一的出错方式就是你给了它一个太小的缓冲区,而算术那一节就是解药。

流式组合是为数据很大或分片到达的场景准备的。EVP_EncodeUpdate 按 48 字节块处理输入,每个完整块写出 64 个字符加一个换行(65 字节),余数留在上下文里,直到更多数据到来或最后一次调用:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  if (ctx == NULL) {
    return 1;
  }
  EVP_EncodeInit(ctx);
  unsigned char in[1000];
  for (int i = 0; i < 1000; i++) {
    in[i] = (unsigned char)(i % 251);
  }
  unsigned char out[1400]; /* 1336 个字符 + 21 个换行 + 余量 */
  int outl = 0;
  int total = 0;
  EVP_EncodeUpdate(ctx, out + total, &outl, in, 1000);
  total += outl;
  EVP_EncodeFinal(ctx, out + total, &outl);
  total += outl;
  int nl = 0;
  for (int i = 0; i < total; i++) {
    if (out[i] == '\n') nl++;
  }
  printf("encoded 1000 bytes into %d chars, %d newlines\n",
      total, nl);
  EVP_ENCODE_CTX_free(ctx);
  return 0;
}

那个程序的输出是 1357 字节、21 个换行 - 算术一节的公式变成了现实。两个实务备注。版本备注:自 OpenSSL 1.1.0(2016 年)起上下文类型就是不透明的,所以用 EVP_ENCODE_CTX_new() 分配、用 EVP_ENCODE_CTX_free() 释放;老教程里针对 1.0.2 及更早版本的栈式写法 EVP_ENCODE_CTX ctx; 对着现代头文件(包括 OpenSSL 3.x)编译不过。设计备注:因为只有完整的 48 字节块才会从 EVP_EncodeUpdate 写出,最干净的分块管道喂给它 48 的倍数 - 于是函数写的每一行都是完整行,EVP_EncodeFinal 单独决定尾部怎么折。如果你的输入以任意大小到达(比如一次网络读取),上下文依然替你处理对齐;48 倍数这个习惯只是让输出变得可预测。

Mbed TLS:先问,再编码,拿到字符串

Mbed TLS 的编码有这一组里最干净的契约,围绕一个可以用 NULL 目标调用的尺寸查询构建:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <mbedtls/base64.h>
int main(void) {
  const char *text = "Mane";
  size_t slen = strlen(text);
  size_t needed = 0;
  int rc = mbedtls_base64_encode(NULL, 0, &needed,
      (const unsigned char *)text, slen);
  if (rc != MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL) {
    printf("size query failed: %d\n", rc);
    return 1;
  }
  printf("needs %zu bytes\n", needed);
  unsigned char *out = malloc(needed);
  size_t olen = 0;
  rc = mbedtls_base64_encode(out, needed, &olen,
      (const unsigned char *)text, slen);
  if (rc != 0) {
    printf("encode failed: %d\n", rc);
    free(out);
    return 1;
  }
  printf("olen=%zu str=%s\n", olen, out);
  free(out);
  return 0;
}

仔细读这些细节,因为它们是一堂友好 API 的大师课。尺寸查询报告的 needed 是编码字符数加一个 NUL 的空间 - 对 "Mane" 来说是 8 加 1,即 9 - 并以"缓冲区太小"这个码(MBEDTLS_ERR_BASE64_BUFFER_TOO_SMALL,即 -0x002A)作为信号,因为 NULL 目标按定义就是太小。真正的调用随后写入字符和 NUL,*olen 返回 8 - 不含终止符的长度 - 所以这个缓冲区本来就是一个可打印的 C 字符串。如果你交给它的缓冲区差一个字节,你会拿回同一个"太小"码,*olen 里是所需尺寸,所以失败会精确告诉你差了多少。再多一条备注:这个库通过常量时间辅助函数做表查找,是一处你看不太到的小心翼翼,大多数编码器都没有。

APR-Util 和 GLib:另外两个

APR-Util 的编码器是一对朴素的函数,长度是 int

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <apr-1.0/apr_base64.h>
int main(void) {
  const char *text = "Mane";
  int needed = apr_base64_encode_len((int)strlen(text));
  char *out = malloc((size_t)needed);
  int n = apr_base64_encode(out, text, (int)strlen(text));
  printf("n=%d (includes NUL) str=%s\n", n, out);
  free(out);
  return 0;
}

这里 apr_base64_encode_len() 和返回值都把 NUL 计入,所以 n 比字符数多一 - 这是与 OpenSSL 和 Mbed TLS 的记账差异,已在不止一个代码库里造成过差一 bug。同样的 32 位长度限制也适用:对接近或超过 2 GB 的值,这不是趁手的工具。还有 apr_base64_encode_binary(),它在 EBCDIC 机器上跳过输入的 EBCDIC 到 ASCII 转换 - 在那台本该发生这种转换的大型机上,在其他地方则是一个无操作的差异。那一对、这个二进制变体,以及对应的解码函数,事实上就是 apr-util 暴露的全部 base64 表面:没有 pool 分配变体,也没有流式变体,所以上面那个 malloc 模式就是唯一的模式。

GLib 的编码器是堆上分配的风格 - 你拿到一个 NUL 终止的字符串和一项义务:

#include <stdio.h>
#include <glib.h>
int main(void) {
  const char *text = "Mane";
  gchar *enc = g_base64_encode((const guchar *)text, strlen(text));
  printf("%s\n", enc);
  g_free(enc);
  return 0;
}

不折行,NUL 终止,用 g_free 释放 - 原型上的 G_GNUC_MALLOC 注释就是告诉静态分析器这件事的。当你确实想要换行时,增量组合是工具:g_base64_encode_step() 接收一个状态整数和一个 break_lines 标志,并告诉你它写入了多少输出字节,g_base64_encode_close() 收尾最后的未完成组。这和 OpenSSL 流式组合是同一个状态机形状,只是带着 GLib 的参数风格。

手工打造 URL 安全 Base64

上面四家库说的都是标准字母表:A-Z、a-z、0-9、加号和斜杠。然而 web 越来越多地说的是 RFC 4648 第 5 节的第二个方言,叫 base64url:同样的编码,只是把 + 换成 -/ 换成 _,并且当长度已知时丢掉尾部的 = 填充。JSON Web Token、OAuth 状态参数、无数 API ID 都用它,因为 +/ 在 URL 里都危险,而 -_ 是不保留字符,一路畅行。既然没有 C 库原生产出这个方言,就自己造 - 而且只是两处小改动,因为你已经有一个标准字母表编码器了:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
static void to_base64url(const char *std_b64, char *out, size_t out_cap) {
  size_t i = 0;
  for (const char *p = std_b64; *p && *p != '='; p++) {
    char c = *p;
    if (c == '+') c = '-';
    if (c == '/') c = '_';
    out[i++] = c;
  }
  out[i] = '\0'; /* 填充被刻意丢弃 */
}

用法两步 - 先标准编码,再翻译:

unsigned char enc[32];
EVP_EncodeBlock(enc, (const unsigned char *)"hi>there", 8);
char url_safe[32];
to_base64url((const char *)enc, url_safe, sizeof(url_safe));
printf("%s\n", url_safe); /* aGk-dGhlcmU */

两条告诫。循环在第一个 = 处停下,这正是丢掉填充的地方 - 不要"修"它,丢填充就是目的(需要填充的接收方可以从长度重新加上)。还有,out 要按完整编码长度来定大小,不能更少:翻译是逐字符对应直到填充为止,所以你已经为标准形式预留的容量恰好合适。一句关于互操作的诚实备注:如果你的数据恰好不含映射到 +/ 的字节,标准形式和 URL 安全形式完全相同,什么都不会抱怨混用 - bug 只在数据终于包含这类字节时才浮出水面。把这个方言当成通道(URL、令牌)的属性,而不是数据的属性。

文本与字符集:UTF-8 只是字节

一个会让 C 新手意外的问题:带重音的文本、表情符号、中日韩字符会怎么样?答案是本文最令人解放的事实 - 什么都不用发生。Base64 作用于字节,而 C 是字节的语言。如果你的文本是 UTF-8(在 2026 年,大概就是了),"café" 的 UTF-8 编码是五个字节 - 63 61 66 c3 a9 - Base64 编码这五个字节和编码任何其他五个字节毫无二致,产出 Y2Fmw6k=。没有字符集参数,没有 BOM,没有转换步骤,没有库调用。编解码器不知道也不关心字节意味着什么;这就是全部设计。

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  const char *utf8 = "caf\303\251"; /* café in UTF-8 */
  unsigned char enc[32];
  int n = EVP_EncodeBlock(enc, (const unsigned char *)utf8,
                          (int)strlen(utf8));
  printf("%.*s\n", n, (char *)enc); /* Y2Fmw6k= */
  return 0;
}

这一节的边缘上坐着两个坑。第一个是 wchar_t:如果你的数据以宽字符形式到达,你必须先把它转成字节序列(在 Linux 上,UTF-8,通过 wcstombs() 或你的 locale 机制)再编码 - 对 wchar_t 数组做 Base64,编码的是内部表示,不是文本,而且在不同平台之间会不一样。第二个是源码编码:你 C 文件里的字符串字面量用的是源文件自身的编码(任何现代项目里都是 UTF-8),所以直接写 "café" 也能工作,只要文件真的是 UTF-8 且编译器被告知了这一点(现代工具链默认就是这样)。编码你打算发送的字节,至于字节意味着什么,交给接收方处理。

图片:从缓冲区到字符串

web C 里最常见的"实战"编码任务:一个二进制文件 - JPEG、PNG、图标 - 需要穿过一个文本通道,于是它变成一个 Base64 字符串。配方就是:把文件读进缓冲区,按公式定输出大小,编码,然后继续走。读文件这一半值得当心,因为 C 程序真正会断的地方就在这里:

#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <openssl/evp.h>
int main(void) {
  FILE *f = fopen("photo.png", "rb");
  if (f == NULL) {
    return 1;
  }
  fseek(f, 0, SEEK_END);
  long size = ftell(f);
  fseek(f, 0, SEEK_SET);
  unsigned char *data = malloc((size_t)size);
  size_t got = fread(data, 1, (size_t)size, f);
  fclose(f);
  size_t out_cap = ((got + 2) / 3) * 4 + 1;
  unsigned char *enc = malloc(out_cap);
  int n = EVP_EncodeBlock(enc, data, (int)got);
  printf("png of %zu bytes becomes %d base64 chars\n", got, n);
  free(data);
  free(enc);
  return 0;
}

备注:读二进制用 rb - 在任何平台上都不可妥协,因为文本模式可能转换字节并改变 got;用 fseek/ftell 组合来定大小(对无法定位的管道和套接字,改读进一个增长的缓冲区);编码时用的是 got 而不是 size,因为短读是真实可能。一张 1 MB 的图片变成约 1.33 MB 的文本 - 这就是税,预先开出的账单,也是为什么 base64-in-JSON 的图片载荷应该让你停下来问一句:一个真正的文件上传是不是更便宜。

文件与 .b64 的习惯

图片任务的另一面:你需要把一个文件的 Base64 形式写到磁盘 - 一个 .b64 伴生文件、二进制的文本安全备份、给邮件发送器的附件。同样的算术,不同的写盘者。值得养成的习惯是:以接收方期望的长度、带显式换行地写文本输出 - 邮件用 76,PEM 风格的消费者用 64,或者接收方是你自己的代码就干脆不折行:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  FILE *f = fopen("data.bin", "rb");
  if (f == NULL) {
    return 1;
  }
  fseek(f, 0, SEEK_END);
  long size = ftell(f);
  fseek(f, 0, SEEK_SET);
  unsigned char *data = malloc((size_t)size);
  size_t got = fread(data, 1, (size_t)size, f);
  fclose(f);
  unsigned char *enc = malloc(((got + 2) / 3) * 4 + 1);
  int n = EVP_EncodeBlock(enc, data, (int)got);
  FILE *out = fopen("data.b64", "w");
  for (int i = 0; i < n; i += 76) {
    int chunk = i + 76 < n ? i + 76 : n;
    fwrite(enc + i, 1, (size_t)(chunk - i), out);
    fputc('\n', out);
  }
  fclose(out);
  free(data);
  free(enc);
  return 0;
}

循环写出 76 字符的行和最后一行较短的行;一个跳过空白的解码器(所有认真的都跳)根本不会在乎行长,所以要咨询的是接收方,不是你的口味。如果你想要平台行尾,写盘一侧保持文本模式(w);如果接收方严格数字符,就用 wb - 而它若严格计数,要的就是你承诺的东西:76 字符加一个换行,别无其他。让 .b64 文件成为格式的,是那个承诺,而不是字节。

data URI:真正的内嵌

data URI(RFC 2397)是解码文章里那个经典出场的另一面:不是接收 data:image/png;base64,...,而是构造一个。形状是 data:、媒体类型、;base64、逗号、载荷 - 在 C 里构造它,就是编码之后一个 snprintf

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  /* 8 字节的 PNG 签名 */
  const unsigned char png_sig[8] =
    { 0x89, 'P', 'N', 'G', '\r', '\n', 0x1a, '\n' };
  unsigned char enc[32];
  int n = EVP_EncodeBlock(enc, png_sig, 8);
  char uri[96];
  snprintf(uri, sizeof(uri), "data:image/png;base64,%.*s",
      n, (char *)enc);
  printf("%s\n", uri);
  return 0;
}

三条设计备注。你放进 URI 的媒体类型是一项你要负责的声称 - 先嗅探真实文件的魔数,否则一个装着 JPEG 的 data:image/png 会让每个消费者以不同方式困惑。载荷是 Base64 时,;base64 标志是必须的;省略它,载荷就必须是百分号编码的文本,那是另一种格式。而 RFC 自己的指导是 data URI 用于短值:把一个 5 MB 的 logo 内嵌进 HTML 页面能跑,但那是个设计异味,一个真正的资源 URL 就能治好。同样的构造在 JSON API 里到处都是,客户端想在表单数据的同一个请求里带上头像 - 编码,前缀,发送。

HTTP 与 JSON:活下来的载荷

现代 C 里编码的最大理由就是 JSON。JSON 字符串是带转义规则的字符序列,原始字节塞不进去:字符串字面量中间的 NUL 是 C 的问题,JSON 字符串里的字面换行是非法 JSON,任意字节则需要一套定义好的转义说法。Base64 通过只产出 JSON 永远不必转义的字符,绕开了整个问题 - 64 个字母表字符加上标准方言里的 =,没有一个引号或反斜杠。二进制以字符串进去,从另一头出来时和进去时一模一样:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  unsigned char enc[64];
  int n = EVP_EncodeBlock(enc, (const unsigned char *)"hello", 5);
  char json[160];
  snprintf(json, sizeof(json),
      "{\"avatar\": \"%.*s\"}", n, (char *)enc);
  printf("%s\n", json);
  return 0;
}

它打印出 {"avatar": "aGVsbG8="} - 一个完整的、合法的 JSON 对象,没有动用任何转义机器,而 %.*s 的精度让长度始终精确,哪怕你哪天换成一个不写 NUL 的编码器。诚实的代价是体积:你作为 JSON 字符串发出的每个字节,都花掉你 4/3 字节的空气,外加字段名和引号,所以 10 KB 的二进制在 JSON 里变成 13.3 KB 的字符串。对偶尔的小 blob(图标、缩略图、签名、令牌)这是好价钱;对 500 MB 的上传那是你会后悔的架构,真正的文件上传才是那个任务的工具。也值得写一行:标准字母表的 +/ 在 JSON 字符串里是安全的,但如果同一个字符串之后在 URL 查询里旅行,它们就不安全了 - 那是 URL 安全那一节的活。

JWT:三段,一个字母表

base64url 在 C 里的旗舰消费者是 JSON Web Token。按 RFC 7519,一个紧凑 JWT 是三段 base64url 编码用点连接 - 头部、载荷、签名 - 而构造一个是个愉快的练习,因为每一块都是你已经有的函数:标准编码、翻译成 URL 安全、签名、重复。下面是一个用 OpenSSL 的 HMAC 构建的 HS256 令牌:

#include <stdio.h>
#include <string.h>
#include <openssl/hmac.h>
#include <openssl/evp.h>
static void to_base64url(const char *std_b64, char *out, size_t out_cap) {
  size_t i = 0;
  for (const char *p = std_b64; *p && *p != '='; p++) {
    char c = *p;
    if (c == '+') c = '-';
    if (c == '/') c = '_';
    out[i++] = c;
  }
  out[i] = '\0';
}
int main(void) {
  const char *secret = "my-hmac-secret-key";
  const char *header = "{\"alg\":\"HS256\",\"typ\":\"JWT\"}";
  const char *payload = "{\"sub\":\"114365\",\"name\":\"Alice\"}";
  unsigned char hb[64], pb[64];
  EVP_EncodeBlock(hb, (const unsigned char *)header,
                  (int)strlen(header));
  EVP_EncodeBlock(pb, (const unsigned char *)payload,
                  (int)strlen(payload));
  char hu[64], pu[64];
  to_base64url((const char *)hb, hu, sizeof(hu));
  to_base64url((const char *)pb, pu, sizeof(pu));
  char signing_input[256];
  snprintf(signing_input, sizeof(signing_input), "%s.%s", hu, pu);
  unsigned char mac[EVP_MAX_MD_SIZE];
  unsigned int mac_len = 0;
  HMAC(EVP_sha256(), secret, (int)strlen(secret),
      (const unsigned char *)signing_input,
      (size_t)strlen(signing_input), mac, &mac_len);
  unsigned char mb[64];
  EVP_EncodeBlock(mb, mac, (int)mac_len);
  char mu[128];
  to_base64url((const char *)mb, mu, sizeof(mu));
  printf("%s.%s.%s\n", hu, pu, mu);
  return 0;
}

这个结构教会你两件事。第一,签名输入是点连接的两个 URL 安全段 - 正是接收方将看到的字节 - 所以翻译成 base64url 必须发生在签名之前,而不是之后;签标准字母表形式会让接收方的验证失败,那是一个能编译、能运行、看起来像密钥不匹配的 bug。第二,头部和载荷是套着 Base64 外壳的纯 JSON:人人能读,这就是设计。令牌是一张签过名的便条,不是一个密封的信封 - 所以别往里放你不介意被拦截者读到的东西之外的任何东西,也永远、永远不要把密码放进 JWT 载荷里"因为它是编码过的"。这份工作的 Base64 部分小而乏味,而你能给一个 JWT 实现最高的赞美就是这个。

HTTP Basic 认证:构造令牌

最古老的认证头也是最简单的 Base64 任务:username:password,标准字母表编码,跟在单词 Basic 后面。在 C 里构造它只要两行,唯一微妙的是密码可以包含冒号(接收侧的切分必须在第一个冒号处):

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  const char *user = "alice";
  const char *pass = "s3cr3t";
  char creds[128];
  snprintf(creds, sizeof(creds), "%s:%s", user, pass);
  unsigned char enc[160];
  int n = EVP_EncodeBlock(enc, (const unsigned char *)creds,
                          (int)strlen(creds));
  printf("Authorization: Basic %.*s\n", n, (char *)enc);
  return 0;
}

它打印出 Authorization: Basic YWxpY2U6czNjcjN0。RFC 的警告在发送侧同样适用:这是编码,不是保护。在明文 HTTP 连接上,凭证距离任何人只差一条 base64 -d,所以 Basic 认证是 HTTPS 专属的习惯。(现代替代品 - bearer 令牌、mTLS - 全都复用同一套机器:组装一个字符串,编码它,放进头里。自从协议有了头,Base64 就是 HTTP 把结构化数据夹带过文本头的方式。)

邮件与 PEM:折行的栖息地

邮件是折行存在的全部理由。SMTP 限制行长度,于是 MIME 把编码行定在 76 字符(前辈 PEM 是 64),每个邮件系统遵守这个上限已经三十年。如果你的 C 程序为邮件正文或附件产生 Base64,折行不是可选的美化 - 一条 200 KB 的未折行会被邮件基础设施的某些部分拒绝或弄坏。OpenSSL 的流式编码器免费给你折行输出(按它 64 字符的传统长度),而当你恰好需要 76 时,给一次性结果折行是一个五行循环:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  unsigned char enc[64];
  int n = EVP_EncodeBlock(enc, (const unsigned char *)"hello world", 11);
  for (int i = 0; i < n; i += 76) {
    int chunk = i + 76 < n ? i + 76 : n;
    printf("%.*s\r\n", chunk, (char *)enc + i);
  }
  return 0;
}

注意那个 \r\n:邮件要 CRLF 行尾,而且如果编码文本是几个 MIME 部分之一,base64 块周围的一切也遵循同样的规则 - 行长、CRLF 行尾,没有例外。PEM 文件(大多数密钥和证书的格式)用同样的想法,在 -----BEGIN-----END 标记之间用 64 字符行,OpenSSL 的工具在你重新保存密钥时期望看到那副护甲 - 所以如果你的程序碰 PEM,就在 64 处折行并保留标签。其他地方 - JSON、URL、API、数据库 - RFC 的规则适用,你根本不折行。

把值夹带过配置和列

安静的用例:会破坏文本格式的值得被打包成 Base64,让它们不再破坏。带分号的数据库 DSN、带引号的密码、带换行的令牌 - 运维人员编码一次,配置文件就再也没见过那些麻烦字符:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  const char *dsn = "pg:host=db;password=qu\"ote";
  unsigned char enc[128];
  int n = EVP_EncodeBlock(enc, (const unsigned char *)dsn,
                          (int)strlen(dsn));
  printf("DB_DSN_B64=%.*s\n", n, (char *)enc);
  return 0;
}

程序打印出可以粘贴进 .env 文件的整行,之后读取它的 C 代码就是一个 getenv 加一次解码。三条诚实的告诫,全都关于这不是什么。它不是加密:任何能读配置文件的人一次调用就能解出那个值,所以永远不要把秘密打包成 Base64 然后管它叫受保护。它不是转义:如果格式需要保留结构,真正的编码(URL 的百分号编码、JSON 的 JSON 转义)才是正确工具,Base64 是给那些格式无法表达的值 - 二进制那些。它还有体积代价:作为 Base64 存在数据库 TEXT 列里的值,比原始值多占约 33% 的空间,对令牌来说无妨,对文件列则是实打实的数字(那正是 BLOB 列的用武之地)。

从 shell 里编码

在伸手去写 cc 调用之前,记得两个标准工具都会编码,而且快。coreutils 是通用器械:base64 默认按 76 字符折行编码,-w 改变列宽,-w 0 完全禁用折行:

base64 photo.png > photo.b64
base64 -w 0 photo.png > photo-oneline.b64
cat note.txt | base64 -w 0

OpenSSL 的工具是同一份活、TLS 血统的包装:openssl base64openssl enc -base64 的友好别名)在 64 字符处折行,-A 切换成单行:

openssl base64 < photo.png > photo.b64
openssl base64 -A < photo.png > photo-oneline.b64

为什么要在意折行差异?因为如果下游解析器数字符,这两个工具的默认输出不能互换 - 每行 76 对每行 64 在文件里是看得见的差别,跳过空白的解析器不在乎,而校验行长的解析器绝对在乎。当你的 C 程序是生产者、shell 是消费者(或反过来),先就折行达成一致。一句给 BSD 血统系统的方言备注:那里的解码标志历史上是 -D,较老的 macOS 版本至今还记得;编码侧到处都是 base64,而这一节本来也只谈编码方向。

流式处理大家伙

用一次 malloc 编码一个多 GB 的文件,是一个你不需要自找的内存问题。流式路径正是为此存在,而 OpenSSL 的块纪律让代码几乎平凡:文件给多少就喂多少给 EVP_EncodeUpdate,让它把每个不完整 48 字节块的余数留在上下文里,每个块的 65 个输出字节直接写进目标文件。峰值内存是你的两块缓冲区 - 几十 KB - 无论文件多大:

#include <stdio.h>
#include <string.h>
#include <openssl/evp.h>
int main(void) {
  EVP_ENCODE_CTX *ctx = EVP_ENCODE_CTX_new();
  if (ctx == NULL) {
    return 1;
  }
  EVP_EncodeInit(ctx);
  FILE *in = fopen("video.mp4", "rb");
  FILE *out = fopen("video.b64", "w");
  if (in == NULL || out == NULL) {
    return 1;
  }
  char inbuf[48 * 1024];            /* 48 的倍数:干净的整行 */
  unsigned char outbuf[1024 * 65 + 65];  /* 每个 48 字节块输出 65 字节,外加余量 */
  size_t got;
  while ((got = fread(inbuf, 1, sizeof(inbuf), in)) > 0) {
    int outl = 0;
    EVP_EncodeUpdate(ctx, outbuf, &outl,
        (const unsigned char *)inbuf, (int)got);
    fwrite(outbuf, 1, (size_t)outl, out);
  }
  unsigned char tail[66];
  int outl = 0;
  EVP_EncodeFinal(ctx, tail, &outl);
  fwrite(tail, 1, (size_t)outl, out);
  EVP_ENCODE_CTX_free(ctx);
  fclose(in);
  fclose(out);
  return 0;
}

那个循环里有两个设计细节在干活。输入缓冲区是 48 字节的倍数,所以每次调用都交给编码器完整的块,它写的每一行都是完整的 64 字符行;最后一次调用收尾真正的尾部。如果你的输入以任意大小到达(套接字、慢速磁盘),上下文依然正确吸收不对齐 - 48 倍数这个选择关乎输出可预测性,不关乎正确性。第二个细节是输出缓冲区大小:每 48 输入字节 65 字节,加上最后块的余量(66),这是把算术一节的折行公式按块应用。进度报告只要一行 - 把写入 out 的字节数对上来,总数从文件大小算出 - 而且因为编码会让数据变大,输出文件会比输入大约大 33%:提前给磁盘开账单。

编码的锋利边缘

陷阱收集完毕,清一色 C 的形状:

  • 差一,双向都有。 OpenSSL 返回的长度不含它的 NUL;Mbed TLS 的尺寸查询包含 NUL 的空间;APR 的长度计入 NUL。三个库,三套记账惯例。把缓冲区大小函数写一次(算术一节),然后在调用点别再用脑内算术。
  • NUL 是你必须付钱的一个字节。 本文每个编码器都想要输出里多一个字节放终止符,你手写的任何缓冲区追加也是。恰好按 encoded_chars(n) 定大小的缓冲区,在任何人想让 printf("%s") 工作的那一刻就差一个字节。
  • 编码不会失败,所以你不能让它上溢。 没有任何错误码能抓住太小的缓冲区 - 编码器会毫不迟疑地写出末端。Base64 编码在 C 里的失败模型完全是你自己的:算对大小,否则它悄悄损坏内存,毫无诊断。
  • 双重编码是经典的沉默 bug。 一个已经是 Base64 的值,再过一遍编码器,产出一个完全合法的 Base64 字符串,解码后是 Base64 字符串而不是数据。症状 - "能解码,但解出来的东西不对" - 要花一下午找。如果值以"预编码"形式到达,先验证它的长度是四的倍数且只含字母表字符,再假设它是原始数据;如果它已编码,跳过编码。
  • URL 里的加号。 标准字母表的输出放进查询字符串,到你的服务器解析表单时 + 已经变成了空格 - 在百分号/表单编码里 + 就是空格。在 URL 里旅行的令牌和 ID,要 URL 安全方言,句号。
  • 管道错误一侧的文本模式。 用文本模式读二进制文件可能转换字节(在某些平台上)并改变长度;用错误的行尾约定写折行 Base64 会弄坏数字符的接收方。二进制输入用 rb,规范要求的换行处显式写 \r\n\n,永远别让 C 运行时悄悄替你决定行尾。
  • 尺寸算术里的 int 上溢。 ((n + 2) / 3) * 4int 算术在超过约 1.5 GB 的输入上上溢,产出一个小的正数"所需尺寸"和一次堆重击。用 size_t(或 uint64_t)算,这也是 APR 的 int 制 API 有 2 GB 上限、且你无法用工程手段绕开的原因。
  • 在接收方不期望的地方折行。 RFC 4648 说:除非外围规范开口,不加换行。JSON 字符串值里的换行是非法;URL 里是另一个请求。为邮件折行,为护甲折行,其他地方都不折。

短清单

用公式而不是猜测计算缓冲区大小,整个代码库只保留一个大小函数。即使缓冲区是 NUL 终止的,也把(指针、长度)放在一起,因为长度是契约,NUL 只是便利。按通道选方言:JSON 和正文用标准,URL 和令牌用 URL 安全,邮件和护甲用折行,其他地方都不折。在 data URI 里声称 MIME 类型之前,先验证魔数。永远不要用 Base64 当加密、当百分号编码的替代品、或当藏秘密的地方 - 它是盒子,不是锁。而数据大的时候,流式处理:块式编码器正是为此设计的,恒定内存就是全部意义。

历史:包装如何走向标准化

编码器的历史就是行长度的故事。第一个 Base64 是 1990 年代初的一个 C 程序。Privacy-Enhanced Mail(RFC 1421,1993 年)需要把二进制搬过 7 位邮件,作者们选择了每字符 6 比特、每行 64 字符 - 64 是 SMTP 行长度容忍度的遗迹,C 代码一次查表一次查表地做着包装。当 MIME 为 web 标准化了同样的字母表(1993 年 RFC 1521,1996 年 RFC 2045)时,它把行放宽到 76 字符,于是世界带着两个习惯走下去 - 64 和 76 - 都自称是"那个" Base64 行长度。编码器各自站队:OpenSSL 的流式路径保留 64(它的 PEM 血统),coreutils 的工具选了 76(它的 MIME 血统),同一台机器上的两个工具至今对换行落在哪还有分歧。标准终于在 2006 年表态:RFC 4648 说,除非引用它的规范明确指示,实现根本不应添加换行符 - 这就是本文每个库默认不折行输出的原因,也是折行如今成为邮件和护甲的可选特性的原因。字母表本身、填充规则、以及"填充位必须为零"的规范性规则,都出自更早的那些 PEM 和 MIME RFC,4648 把它们重申为这个家族的规范规则。而该 RFC 的第 11 节指向一个参考实现 - 一个 ISO C99 程序,因为代码本身"因程序性原因无法收录进本 RFC"而托管在外部 - 又一次提醒:在这种格式里,C 不是二等公民。至于 C 标准库,它从未追上:C89 在 1990 年冻结,这一切存在之前;2024 年的 C23 依然不带 Base64 函数。所以你链接的库就是标准,而它们之间的选择是一个小而真实的设计决策 - 这正是本文一直在讲的事。

奇奇怪怪的小知识

一些单纯知道就好玩的事实,全都关于 C 里的包装侧:

  • "64" 就是基数:每个输出字符是 6 比特,2 的 6 次方是 64。这个格式给字母表命名的方式,就像 C 给整数命名一样 - 按数字实际是什么。
  • OpenSSL 流式编码器的 48 字节块不是随意的记账:48 输入字节恰好是 16 组 3,64 输出字符恰好是 16 组 4。两个数都是 16 的倍数,正是那种让硬件和缓存行高兴的整数 - 或者至少让读代码的人高兴。
  • 一个输入字节编码成四个字符,其中两个是 =。最小的非空载荷有 50% 是填充 - 这个格式里最浪费的编码,也是每个测试套件都用的那一个,因为它太容易写错。
  • Mbed TLS 是本文唯一做常量时间查找的编码器,因为写嵌入式密码学的人不信任变时表索引,哪怕是在一个并非密码的编解码器里。偏执是会传染的。
  • 规范性编码规则 - 未用的填充位必须为零 - 听起来微不足道,直到你发现违反它意味着两个不同的字符串可以解码成同样的字节,这会让所有"这个字符串是不是那个文件的编码?"检查全部失效。你的编码器全都遵守,这就是 base64 能当哈希稳定表示、能在内容存储里顶替文件名的原因。
  • OpenSSL 的 EVP_EncodeBlock 是少数几个返回值、自身输出和 NUL 终止符全都一致的 C 函数:它写入 n 个字符、一个 NUL,返回 n。在一门以差一闻名的语言里,这是一刻宁静。
  • APR-Util 是这里唯一会问 EBCDIC 是什么的编码器,因为 Apache 仍运行在字母顺序不同于 ASCII 的机器上。在那些机器上,"编码"一个字符串包括先悄悄重排它的字母表。
  • 空输入在每家库中都编码成空字符串,没有填充也没有换行。这个格式的单位元,四家全都到场且正确,使它成为你能写出的最便宜的单元测试。

翻到解码那一侧

这就是包装侧:算术、四个编码器、方言,以及字节们要去的地方。它是这份工作中温顺的一半,因为编码没有非法输入,也没有一个解码器会跟你吵。另一个方向 - 接住外部世界的 Base64 并把字节拿回来 - 才是痛集中爆发的地方:补零的尾部、静默的截断、严格与宽容的字母表,还有一个会吃掉尾部换行的命令行。C 语言中的 Base64 解码在相关文章里有深入讲解,链接就在本页,它是本文的天然搭档:编码器造盒子,解码器开盒子,两者合起来,就覆盖了 C 程序会遇到的每一种 Base64 任务。

最后更新: 2026-09-08

相关文章: C 语言中的 Base64 解码:完整指南