C23和C++26的#embed嵌入資源指南
c++26最近剛敲定標準,新增了許多重量級特性。
不過目前能實際上手測試的特性不多,畢竟標準剛剛確定,比較大的變更里只有“資源嵌入”或者用標準文檔里英文名“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就誕生了。
在正式進入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位?,F代的主流平臺幾乎都是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的原因之一。
另一種不標準但簡單的理解是:想象資源文件是一個有n個bit的連續的二進制數據流,從數據流的開始處每次取CHAR_BIT個bit,然后把這些bit整體轉換成一個符合規則要求的整數字面量,轉換完成后按順序接著處理下面CHAR_BIT個bit,循環這一過程直到資源文件處理完成或者循環次數達到limit的限制。循環結束后一樣會得到一個以逗號分隔的整數數列。
如果覺得文字描述比較抽象,這里還有圖解:

embed參數
在弄清楚了embed是如何處理嵌入數據的之后,我們再來看看嵌入參數。
#embed負責把指定的數據源轉換成整數字面量序列,而embed參數則對這些整數字面量序列產生影響。
每種參數都有不同的效果,有些可以在序列開頭結尾添加內容,有的可以限制序列長度,還有的可以在序列為空時做特殊處理,不一而足。參數之間還可以組合發揮效力。每個參數自身只能在同一條#embed指令中出現一次。
下面我們來看看這些參數的具體功能和用法。
limit
語法:#embed "data" limit(預處理器能識別出來的數字常量)
limit顧名思義就是用來限制整數字面量序列長度的。
必須給limit一個0或者正整數參數。例子:
#define LENGTH 4
int main()
{
const unsigned char text1[] = {
#embed "data.txt" limit(2)
, 0
};
const unsigned char text2[] = {
#embed "data.txt" limit(LENGTH)
, 0
};
printf("embed: %s\n", text1); // 輸出:He
printf("embed: %s\n", text2); // 輸出:Hell
}
參數也可以是表達式,比如limit(1+1),但不可以出現#define。
當輸入參數為0時,embed會產生空序列,而輸入負數會產生編譯錯誤:error: negative embed parameter operand。
如果輸入參數的值大于嵌入數據的resource count,標準要求預處理器把輸入值改為resource count,相當于std::min(resource-count, limit-operand)。
在c語言中limit還有一個別名叫__limit__。
prefix
語法:#embed "data" prefix(token序列)
token序列是一個或多個符合語法的預處理器token的組合,后面講的suffix和if_empty的參數形式與此相同。通俗地說只要括號里的東西預處理器可以正常識別,那么就可以作為prefix的參數。
如果prefix的括號里是空的,則什么都不會做,否則會把括號里的token序列添加到整數序列的最前面。要注意的是它是把括號內的內容原樣復制黏貼上去的,因此你需要在最后一個token后面加上逗號或者其他符號讓整個處理后的序列符合c/c++語法規則,舉個例子:
// data.txt內容:Hello
#define ALPHABET 't'
int main()
{
const unsigned char text1[] = {
#embed "data.txt" prefix(1+)
, 0
};
const unsigned char text2[] = {
#embed "data.txt" prefix(ALPHABET, 'e', 's', ALPHABET, 0x20, )
, 0
};
printf("embed: %s\n", text1); // 輸出:Iello
printf("embed: %s\n", text2); // 輸出:test Hello
}
其中#embed "data.txt" prefix(1+)被替換為1+72, 101, 108, 108, 111;而#embed "data.txt" prefix(ALPHABET, 'e', 's', ALPHABET, 0x20, )替換成了't', 'e', 's', 't', 0x20, 72, 101, 108, 108, 111。
如果序列本身是空的,則prefix不生效。
prefix通常用于在embed進來的數據前加一些標記,因此參數多是一個或多個字符/整數常量,但如例子所示,任何合法的表達式也可以作為參數。
在c語言中prefix還有一個別名叫__prefix__。
suffix
語法:#embed "data" suffix(token序列)
suffix是prefix的對稱操作,它會把token序列原樣添加到整數序列的尾部。看個例子:
// data.txt內容:Hello
#define ALPHABET 't'
int main()
{
const unsigned char text1[] = {
#embed "data.txt" suffix(, 0) // 注意那個逗號,字符串以\x00結尾
};
const unsigned char text2[] = {
#embed "data.txt" suffix(-10, 0x20, ALPHABET, 'e', 's', ALPHABET,)
0 // 注意0前面沒有逗號了
};
printf("embed: %s\n", text1); // 輸出:Hello
printf("embed: %s\n", text2); // 輸出:Helle test
}
和prefix一樣,如果整數字面量序列是空的,則什么也不做。
c語言中還可以使用它的別名__suffix__。
if_empty
語法:#embed "data" if_empty(token序列)
如果嵌入的整數序列為空(資源不存在或者通過limit(0)使得序列長度為0),則會用括號內的內容替換整條embed指令,否則不生效。
看示例代碼輔助理解:
// data.txt內容:Hello
// data2.txt長度為0
int main()
{
const unsigned char text1[] = {
#embed "data.txt" if_empty('e', 'm', 'p', 't', 'y')
, 0
};
const unsigned char text2[] = {
#embed "data2.txt" if_empty('e', 'm', 'p', 't', 'y')
, 0
};
const unsigned char text3[] = {
#embed "data.txt" limit(0) if_empty('e', 'm', 'p', 't', 'y')
, 0
};
printf("embed: %s\n", text1); // 輸出:Hello,因為data.txt里有內容,所以if_empty不生效
printf("embed: %s\n", text2); // 輸出:empty,因為data2.txt里沒有數據
printf("embed: %s\n", text3); // 輸出:empty,因為limit(0)使得整數序列為空
}
c語言中if_empty的別名是__if_empty__。
__has_embed
__has_embed用來檢查embed指令是否有效以及嵌入的資源是否為空。它的語法形式是:__has_embed(<header-name>|"header-name" parameters...)。
其實就是把embed指令中的換行符和#embed去掉,其余部分放進括號里。
如果資源能正常嵌入且不為空,__has_embed返回__STDC_EMBED_FOUND__;如果資源正常嵌入但是空的,返回__STDC_EMBED_EMPTY__;其余情況返回__STDC_EMBED_NOT_FOUND__。
__has_embed可以配合條件編譯指令使用,比如:
#if __has_embed("data.txt" limit(2))
doing A
#else
doing B
#endif
不過要注意,__has_embed有TOCTOU問題,也就是說檢測是否能嵌入和實際讀取資源進行嵌入之間有時間間隔,可能會導致檢測通過但實際執行嵌入時文件被改變導致不再滿足前面的條件要求。c++的提案里說因為這個問題本來不想添加這個表達式,但考慮到c里已經有這個了,再加上存在同樣問題的__has_include已經包含在c++里,所以考慮再三還是提供了__has_embed。
我的建議是盡量少用這個,if_empty可以料理一部分文件為空的情況,資源文件不存在或者無法訪問更應該視為fatal error而不是采用一些迂回繞過手段掩蓋問題。當然一切以實際需求為準,想利用TOCTOU進行攻擊的操作難度很大,通常也不用過份擔心。
如何使用
現在我們知道了embed語法規則和控制它行為的方法,但除了embed會產生一串整數之外我們還不知道具體的用法。
其實用法也沒啥好講的——就把它當成一串整數構成的序列來用就行了。當然如果這么說的話那這篇指南就有點潦草了,所以我會列舉幾個常見的用法給大家做參考。由于embed指令配合參數可以生成各種各樣奇形怪狀的內容,我這羅列出來的肯定只是用法中的一小部分,但我還是得提醒一句,能力越大責任越大,不要濫用語言提供的功能。
第一種用法其實已經包含在例子里了,那就是把嵌入的數據存進unsigned char或者其他整數類型的數組里:
const unsigned char text1[] = {
#embed "data.txt" limit(3) // 數組長度自動推導,為3 + 1 = 4
, 0
};
const unsigned char text2[] = {
#embed "data2.txt" if_empty('e', 'm', 'p', 't', 'y')
, 0
};
const unsigned char text3[10] = { // 只有五個字符能嵌入,剩下的部分會被0值初始化
#embed "data.txt" limit(0) if_empty('e', 'm', 'p', 't', 'y')
};
在這種用法下,嵌入資源生成的整數序列只能小于等于數組的長度要求,大于的話會報錯。這也是使用嵌入資源的常見形式,別的工具比如xxd或者Qt rcc也是這樣組織被嵌入的數據的。
第二種用法是用來聚合初始化c++的聚合類型或者c的結構體:
typedef struct A {
int a;
int b;
long long c;
unsigned long d;
unsigned char e;
} A;
int main()
{
A a_obj = { #embed "data.txt" }; // 被替換為 { 72,101,108,108,111 }
}
與數組相同,這種使用方式整數序列中數字的數量必須小于等于字段的數量,否則是語法錯誤。
第三種用法是拿來做函數參數或者模板非類型參數,不知道你們看到整數序列時第一想法是什么,但我第一個冒出來的念頭是這個形式和參數列表太像了。用法可以是:
template <int... nums>
void calcAvg()
{
long sum = (... + nums);
std::cout << (sizeof...(nums) != 0 ? static_cast<double>(sum) / sizeof...(nums) : 0) << '\n';
}
void avg(int count, ...)
{
long sum = 0;
va_list args;
va_start(args, count);
for (int i = 0; i < count; i++) {
sum += va_arg(args, int);
}
va_end(args);
std::cout << (count ? static_cast<double>(sum) / count : 0.0) << '\n';
}
int main()
{
calcAvg<
#embed "data.txt"
>();
avg(5,
#embed "data.txt"
);
}
embed指令需要獨占一行,所以要注意代碼的格式。作為函數或者模板參數時,生成的整數序列元素數量必須等于要求的參數數量。這種做法已經有點偏向于雜耍了。
其他方式就不列舉了,最常見的就是上面這幾種。鑒于embed指令的靈活性,想必將來一定會出現很多體操式玩法吧。
生成文件大小測試
提案里有使用嵌入資源程序的性能對比,但沒有生成文件大小的數據。
對于現代軟硬件環境來說,可執行程序稍微大點其實也沒有多大影響,或者看看日益流行的Electron程序,打包了大半個瀏覽器進去大家還不是用得樂此不疲。但知道一點基礎數據總是沒壞處的,它可以讓我們估算程序的最終大小,以便采取合理的打包部署策略。
測試程序以c編寫,編譯的時候會從/dev/urandom讀取指定長度的隨機數據,然后以16進制格式打印每一個字節,程序不會真的執行,我們只測試編譯出來的文件大小:
#include <stdio.h>
int main()
{
const unsigned char text[] = {
#embed "/dev/urandom" limit(LEN)
};
printf("random: ");
for (int i = 0; i < sizeof(text); i++) {
printf("%02x", text[i]);
}
printf("\n");
}
測試用的編譯器都是GCC 15.1,操作系統分別是macOS arm64和Linux x86_64。編譯選項都是gcc -std=c23 -Wall -O2 -DLEN=n。下面是測試結果:
| 嵌入大小 | 40 KB | 400 KB | 4 MB | 40 MB | 400 MB |
|---|---|---|---|---|---|
| Linux x86_64 | 70 KB | 454 KB | 4.07 MB | 40.07 MB | 400.07 MB |
| macOS arm64 | 65 KB | 436 KB | 4.06 MB | 40.34 MB | 403.15 MB |
嵌入的數據越大程序也就越大。
一些坑點
我們已經學習了embed指令的語法以及使用方法,在收尾前還有一些注意事項需要知道:
- embed會拖慢編譯時間并占用大量內存,拿上一節的程序測試4GB大小的嵌入數據,在16g Linux筆記本上OOM。
- embed不限制嵌入資源的大小,比如/dev/urandom這種沒有大小限制的文件會導致資源耗盡。
- 文本編碼問題,embed是把數據原樣轉換后寫入程序的,因此它不知道你的文本是什么編碼,理論上也不應該知道。
- 文本數據的換行符問題,Windows上換行符是
\r\n,而macOS和Linux上是\n,標準庫的io很多時候幫我們處理掉了兩者之間的差異,但embed并不會,它之后原樣照搬數據,如果不注意這個問題可能會導致長度計算錯誤或者數據處理邏輯上出現缺陷。 - 字節序問題,到目前為止還沒有字節序問題出現,因為我們做測試的幾個平臺
CHAR_BIT都是8,也就是八位一字節,在embed里就是8位轉成一個整數,這是沒有字節序問題的。然而一個CHAR_BIT大于8的平臺,就可能會遇到16位甚至32位二進制數據轉成一個整數的情況,這時候就會出現字節序問題。標準沒有規定編譯器用哪種字節序處理嵌入數據,只是建議采用和程序運行時一致的字節序來處理。這導致了理論上#embed的可移植性會比較弱。不過實際上現在大部分服務器和桌面環境里CHAR_BIT都是8,因此遇到問題的機會不多,但得了解到存在這種坑。 - 編譯錯誤可能很難理解,
#embed是預處理器指令,預處理器處理完源代碼還需要編譯器進行編譯,有時候預處理器能正確處理完成的數據在編譯器那里不一定是合法的代碼,這時候編譯器在報錯的東西是完成替換后的內容了,不是第一現場導致報錯內容的有效信息不多。由于embed還支持大部分的宏擴展替換,導致代碼進一步變形,最終有可能會找不到錯誤的源頭所在(比如某個宏展開有問題導致生成的代碼不能編譯),你可以在介紹prefix的例子里刪掉一個逗號體驗一下。
不過目前只有少數編譯器支持#embed特性,等支持更廣泛之后其中一些坑可能會在后續的標準規范里修復或者交給編譯器自己定奪。
總結
embed算是一個比較實用的功能,不過要等生態鋪開可能還得好幾年。
同樣都是語言層面對內嵌數據的支持,c/c++相比golang的嵌入資源缺少了一些靈活性,但這也無可厚非。
總而言之,在看完這篇指南之后可以嘗試使用一下#embed,說不定可以從項目中刪掉一兩個外部工具減輕負擔。


浙公網安備 33010602011771號