C23和C++26的#embed嵌入资源指南
c26最近刚敲定标准新增了许多重量级特性。不过目前能实际上手测试的特性不多毕竟标准刚刚确定比较大的变更里只有“资源嵌入”或者用标准文档里英文名“resource inclusion”这个新特性可以尝鲜。虽然这篇文章标题叫指南但实际上更像实验记录而且现在属于早期阶段编译器对资源嵌入的处理有可能会有改变不过语法不会改了所以把这篇文章当教程看也行但得注意文章内容与实际使用上可能存在差别。测试环境操作系统macOS 15 和 Fedora 42编译器GCC 15.1测试文本数据编码UTF-8网站上显示GCC15是完全支持#embed资源嵌入的本来还想测试一下clang20遗憾的是gcc测下来还有点小bug况且clang在网站上显示只支持部分embed功能因此我就不蹚雷了。准备好环境之后我们来了解一下基础语法。为什么需要embed把数据嵌入到代码里有不少好处比如部署更简单不需要额外捆绑资源文件程序可以更健壮无需额外处理资源文件缺失或数据读取失败等意外情况能避免一些权限问题一些系统上对文件的存放和读写有比较严格的限制市面上也有很多工具可以完成资源/数据的嵌入有些工具还提供灵活的数据查找功能比如Qt的rcc。但这些工具一直有如下几个缺点需要安装额外工具。这会增加项目的复杂性和管理成本比如如何在CI里使用这些工具显著增加代码体积。众所周知二进制数据很难直接原样放进文本格式的代码里所以要么对数据序列化要么对数据进行编码比如base64不管那种都会让二进制数据体积膨胀学习成本高。市面上的工具用法看似类似实则差异明显导致工具A的经验在工具B或C中难以通用因此我们需要一种易学、通用、无需额外编解码或序列化即可嵌入二进制数据的方案。于是#embed就诞生了。在正式进入C26的embed提案里有一组性能对比测试我们只看GCC相关的执行速度Strategy40 KB400 KB4 MB40 MB#embed GCC0.236 s0.231 s0.300 s1.069 sxxd-generated GCC0.406 s2.135 s23.567 s225.290 s内存占用Strategy40 KB400 KB4 MB40 MB#embed GCC17.26 MB17.96 MB53.42 MB341.72 MBxxd-generated GCC24.85 MB134.34 MB1,347.00 MB12,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 -stdc23以及g -stdc26否则会报错。和#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.hint main(){const unsigned char text[] {#embed data1.txt, 0};printf(embed: %s\n, text);}// 下面是data1.txt的内容// Hello! こんにちは、你好可以算一下文件的资源宽度在utf8下英语字母和半角标点还有空格是1字节汉字、日语片假名和全角标点是3字节所以资源一共有31字节换算一下资源长度也正好是31。我们可以用gcc -stdc23 -E main.c来查看完成预处理的源代码文件......# 3 main.cint 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的原因之一。