c++26最近刚敲定标准,新增了许多重量级特性。

不过目前能实际上手测试的特性不多,毕竟标准刚刚确定,比较大的变更里只有“资源嵌入”或者用标准文档里英文名“resource inclusion”这个新特性可以尝鲜。

虽然这篇文章标题叫指南,但实际上更像实验记录,而且现在属于早期阶段编译器对资源嵌入的处理有可能会有改变(不过语法不会改了),所以把这篇文章当教程看也行但得注意文章内容与实际使用上可能存在差别。

测试环境:

  • 操作系统:macOS 15 和 Fedora 42
  • 编译器:GCC 15.1
  • 测试文本数据编码:UTF-8

网站上显示GCC15是完全支持#embed资源嵌入的,本来还想测试一下clang20,遗憾的是gcc测下来还有点小bug,况且clang在网站上显示只支持部分embed功能,因此我就不蹚雷了。

准备好环境之后我们来了解一下基础语法。

为什么需要embed

把数据嵌入到代码里有不少好处,比如:

  1. 部署更简单,不需要额外捆绑资源文件
  2. 程序可以更健壮,无需额外处理资源文件缺失或数据读取失败等意外情况
  3. 能避免一些权限问题,一些系统上对文件的存放和读写有比较严格的限制

市面上也有很多工具可以完成资源/数据的嵌入,有些工具还提供灵活的数据查找功能,比如Qt的rcc。但这些工具一直有如下几个缺点:

  1. 需要安装额外工具。这会增加项目的复杂性和管理成本,比如如何在CI里使用这些工具
  2. 显著增加代码体积。众所周知二进制数据很难直接原样放进文本格式的代码里,所以要么对数据序列化要么对数据进行编码(比如base64),不管那种都会让二进制数据体积膨胀
  3. 学习成本高。市面上的工具用法看似类似,实则差异明显,导致工具A的经验在工具B或C中难以通用

因此我们需要一种易学、通用、无需额外编解码或序列化即可嵌入二进制数据的方案。于是#embed就诞生了。

在正式进入C++26的embed提案里有一组性能对比测试,我们只看GCC相关的:

执行速度:

Strategy 40 KB 400 KB 4 MB 40 MB
#embed GCC 0.236 s 0.231 s 0.300 s 1.069 s
xxd-generated GCC 0.406 s 2.135 s 23.567 s 225.290 s

内存占用:

Strategy 40 KB 400 KB 4 MB 40 MB
#embed GCC 17.26 MB 17.96 MB 53.42 MB 341.72 MB
xxd-generated GCC 24.85 MB 134.34 MB 1,347.00 MB 12,622.00 MB

看着相当不错。美中不足的是缺少可执行文件体积对比,这个在我们学完了embed的用法之后可以额外做个测试。

基础语法

这次没有基础回顾环节,因为是全新的语法,直接学就完事了。

c和c++的embed语法形式差不多,所以放一起讲了。顺便我也不做标准文档的复读机,否则仅解释一个pp-token(预处理器可以接受的token)就可能占用大量篇幅,我会用简单的语言配上简单的例子做解释。

embed指令是预处理器的一种,语法如下:


# embed <header-name>|"header-name" parameters... new-line

#embed是指令名部分,这个很容易理解。

<header-name>|"header-name"是要嵌入的资源文件的名字,正如其中“header name”所暗示的,嵌入的资源文件搜索路径和头文件一样,引号代表优先搜索当前目录之后搜索编译器的头文件目录;尖括号则表示只搜索编译器限定的头文件存放目录。嵌入资源文件还可以使用绝对路径,比如#embed "/dev/urandom",注意要用引号。所以最基础的嵌入资源的语法是这样的:


#embed <my-data> // linux GCC 会去/usr/include和通过-I参数传给编译器的目录下查找有没有一个叫 my-data 的文件
#embed "data1.bin" // 先在源文件所在的当前目录下寻找 data1.bin,找不到则去编译器的搜索路径里查找

parameters...是一组形式类似选项A 选项B或者选项A(参数1) 选项B(参数1, 参数2, ...)的东西,学名叫embed-parameter,中译名还没有,暂且就叫嵌入参数好了。嵌入参数主要用于给嵌入的资源做一些限制或者添加某些属性,后面会单独开一节内容细讲。嵌入参数也可以有自己的参数,这些参数必须是编译期常量。比如:


#embed "data1.bin" limit(32) // limit是embed-parameters之一,用于限制数据长度
#embed <data-1> if_empty(0) // 如果文件是空的,则用0来替代嵌入内容

嵌入参数目前只有标准规定的几个可以用,但c++在这里做了扩展,允许编译器自己实现一些parameters,语法形式是A::B或者带参数的A::B(...)

new-line就不需要我多解释了吧,就和宏定义一样每一个#embed指令都以换行符结束,如果指令很长想拆分到多行,也需要像#define一样使用反斜杠\

另外从资源文件名开始到各种parameters的位置上都可以使用宏,预处理器会进行宏展开,举个例子:


#define FILE_NAME "data1.bin"
#define DATA_LEN 32
#define PARAM1 limit
#define PARAM2 limit(LEN)
constexpr unsigned char data1[] = {
#embed "data1.bin" limit(32) // 从data1.bin读一定长度的数据进来
}
constexpr unsigned char data2[] = {
#embed FILE_NAME limit(32) // 和上面data1等价,但文件名用了宏
}
constexpr unsigned char data3[] = {
#embed FILE_NAME PARAM1(32) // 等价,parameter用了宏,但参数没有
}
constexpr unsigned char data4[] = {
#embed FILE_NAME PARAM2 // 等价,parameter和参数都依赖宏替换
}

现在看不懂也没关系,只要知道宏展开和替换也会在#embed中进行就够了。

编译代码需要使用gcc -std=c23以及g++ -std=c++26,否则会报错。

#include一样,如果文件不存在或者不能正常读取的话编译器会报错。

embed的工作原理

到目前为止,我们还不知道 embed 会做什么,也不清楚如何使用。本节先带你了解 embed 的工作原理,下一节再讲具体用法。

在这之前需要了解两个新概念和一个旧知识点。

第一个新概念是implementation-resource-width,我叫它资源宽度,单位是bit,没错是“位”。它表示要嵌入的资源一共有多少“位”,恐怕没多少人会这么计算文件大小,不过标准是有意为之的。

第二个要了解的是旧知识点,CHAR_BIT,这是一个宏,在头文件<climits>/<limits.h>里,代表当前环境上一个“字节byte”有多少“位bit”。比如在macOS和linux上gcc给出的CHAR_BIT值都是8,代表在这些平台上至少在c/c++代码中一个字节有8位。现代的主流平台几乎都是8位一字节,但过去并不是这样,而且总有些奇妙的嵌入式环境会打破这一常识。

最后一个概念是resource-count,计算公式是implementation-resource-width / CHAR_BIT,或者是嵌入参数limit中指定的那个值。这个值必须是整数。这个东西起个像样的中文名很难,但也暂且允许我叫它资源长度吧。

如果资源长度不是整数,比如你的资源宽度是32位,但CHAR_BIT的值不巧是7,那么编译会报错。遗憾的是我手上没这种设备,所以报错就不演示了。

这三个概念说了半天有什么用?答案是这和embed的工作原理有关:#embed会把资源文件的内容替换成resource-count个整数字面量,这些字面量之间以半角逗号分隔

以c语言为例,c++也差不多:


#include <stdio.h>
int main()
{
const unsigned char text[] = {
#embed "data1.txt"
, 0
};
printf("embed: %s\n", text);
}
// 下面是data1.txt的内容:
// Hello! こんにちは、你好

可以算一下文件的资源宽度,在utf8下英语字母和半角标点还有空格是1字节,汉字、日语片假名和全角标点是3字节,所以资源一共有31字节,换算一下资源长度也正好是31。

我们可以用gcc -std=c23 -E main.c来查看完成预处理的源代码文件:


......
# 3 "main.c"
int main()
{
const unsigned char text[] = {
72,101,108,108,111,33,32,227,129,147,227,130,147,227,129,171,227,129,161,227,129,175,227,128,129,228,189,160,229,165,189,10
, 0
};
printf("embed: %s\n", text);
}

输出会非常长,所以我截取了有用的部分。这样看很明显,#embed "data1.txt"被替换成了72,101,108,108,111,33,32,227,129,147,227,130,147,227,129,171,227,129,161,227,129,175,227,128,129,228,189,160,229,165,189,10,数一数正好31个整数字面量。

而且可以看到前六个整数正好是Hello!每个字符对应的ASCII码。因为把数据转换成整数字面的过程类似于运行时不断调用std::fgetc,然后再将结果转换成整数字面量。也就是说如果你用fopen("data1.txt", "rb")在运行时打开资源文件,然后循环调用fgetc,会得到和#embed替换后一样的整数序列。c++里规定了必须是int类型的字面量,而c里只要求类型能完全兼容unsigned char即可,不过看上去GCC两边替换后的内容没什么区别。

如果embed发现给出的文件是空的,那么什么也不会生成,预处理器并不进行c++的语法检查,如果预期有数据的地方在替换完成后什么东西都没有,那编译的时候有可能爆出非常难以理解的错误,所以要注意处理这种情况。

这就是embed全部的工作原理。简单地说:资源文件的二进制数据 -> 用与fgetc相同的规则转换成一个逗号分隔的整数字面量序列。

顺带一提c和c++的整数字面量只有0和范围内的正整数,没有负数,所以如果想用字符类型接这些嵌入数据的话,最好使用unsigned char,这也是c标准里要求替换出来的字面量的类型要兼容unsigned char的原因之一。

更多推荐