metacc: 用 libclang 给 C 项目做一层轻量静态元编程
项目地址houyuwen/metacc.git很多 C 项目都会在规模变大以后遇到同一个问题模块越来越多注册点越来越分散但运行时并不想引入复杂的动态注册机制。比如一个嵌入式平台里可能有这些需求各个模块在自己的.c文件里声明初始化函数最终按优先级组成一张启动表。不同板级文件各自贡献设备 provider平台层按优先级统一遍历。事件总线的订阅者分散在测试或业务模块中构建时自动收拢成静态数组。数据中心的 slot 分布在多个翻译单元里但运行时只想访问一张只读表。最直接的做法是手写一个全局数组。但这会把所有模块重新耦合到一个中心文件里每新增一个模块都要去修改那张数组。另一种做法是运行时注册但在 MCU、RTOS 或偏底层 SDK 里这通常意味着额外的初始化顺序、锁、链表、堆内存或不可控的启动路径。metacc选择第三条路在源码里放非常薄的标记宏构建时用libclang扫描所有编译单元把分散的条目生成普通 C 源码。最后得到的是普通的externconstmy_entry_tmy_table[];externconstuint32_tmy_table_count;没有运行时注册没有堆内存没有链接脚本段也没有编译器私有 section 约定。它只是把“人工维护表”的动作交给构建期工具来做。核心模型metacc的核心模型很小声明一张表再从多个编译单元向这张表追加条目。它主要由两个标记宏组成METACC_TABLE(name,type,...)METACC_TABLE_ITEM(name,...)其中METACC_TABLE声明一张表通常放在拥有该表类型定义的头文件里。METACC_TABLE_ITEM向表里追加一个条目通常分散写在各个.c或测试.cpp翻译单元里。工具会为每张表生成一个metacc_owner.h和metacc_owner.c。生成的.c会包含表声明所在的 owner header以及对应的 generated header。每张表生成两个符号const type name[]和const uint32_t name_count。为了让生成结果稳定、可读业务侧推荐直接使用这两个原生标记宏。条目里引用的回调函数或对象保持外部可链接生成文件就能像普通业务代码一样引用它们。三个典型示例示例一: 模块初始化表假设项目希望各个模块在自己的源文件里声明初始化入口最终由构建系统生成一张按优先级排序的启动表。先在头文件里定义条目类型并声明表#pragmaonce#includestdint.h#includemetacc.htypedefstruct{uint8_tlevel;constchar*name;int(*init)(void);void(*exit)(void);}module_init_entry_t;METACC_TABLE(module_init,module_init_entry_t,sort_col0,orderasc)然后任意.c文件都可以贡献条目#includemodule_init.hintdevice_init(void);voiddevice_exit(void);METACC_TABLE_ITEM(module_init,20,device,device_init,device_exit)构建后metacc会生成类似这样的 C 代码#include../../../include/module_init.h#include../include/metacc_module_init.hintdevice_init(void);voiddevice_exit(void);constmodule_init_entry_tmodule_init[]{{20,device,device_init,device_exit},};constuint32_tmodule_init_count1u;对业务代码来说这就是一张普通的只读数组for(uint32_ti0;imodule_init_count;i){module_init[i].init();}示例二: 设备树 provider 表另一个常见场景是设备树或板级设备表。每个板级文件静态定义自己的设备实例再提供一个 provider 函数按索引返回设备。平台层只关心最终生成的 provider 表不需要知道这些 provider 分散在哪些源文件里。表声明可以这样写typedefconststructdevice*(*plat_device_provider_t)(size_tindex);typedefstruct{uint8_tpriority;plat_device_provider_tprovider;}plat_devicetree_provider_entry_t;METACC_TABLE(device_tree,plat_devicetree_provider_entry_t,sort_col0,orderasc)某个板级文件贡献自己的 UART providerconststructdevice*board_uart_provider(size_tindex);METACC_TABLE_ITEM(device_tree,PLAT_DEVICETREE_PRIO_UART,board_uart_provider)构建后生成的device_tree[]会按priority排序。平台层可以基于这张表实现设备查找、初始化和反初始化查找时遍历 provider 匹配设备名初始化时按优先级顺序执行释放时按反向顺序执行。这个例子里metacc解决的是板级解耦问题。新增一个设备 provider 时不需要修改中心数组provider 写在对应板级文件里构建期自动进入device_tree[]。示例三: 事件总线订阅表事件总线适合拓扑相对固定的内部事件例如链路状态变化、传感器数据到达、模块健康状态更新。每个模块在自己的源文件里声明订阅关系构建期生成最终订阅表。订阅者结构可以这样定义typedefstructeventbus_subscriber{eventbus_event_tevent;void(*cb)(constvoid*data,size_tlen,void*user_data);void*user_data;}eventbus_subscriber_t;METACC_TABLE(eventbus_subscribers,eventbus_subscriber_t,sort_col0,orderasc)任意模块可以在自己的翻译单元里追加订阅者voidalpha_cb(constvoid*data,size_tlen,void*user_data);voidbeta_cb(constvoid*data,size_tlen,void*user_data);METACC_TABLE_ITEM(eventbus_subscribers,EVENTBUS_EVENT_ALPHA,alpha_cb,NULL)METACC_TABLE_ITEM(eventbus_subscribers,EVENTBUS_EVENT_BETA,beta_cb,NULL)生成结果是一张按event排序的eventbus_subscribers[]。eventbus_publish()先用 lower bound 找到某个事件的第一个订阅者再顺序通知同一事件下的所有回调。查找成本是O(log N)通知成本是O(K)其中K是该事件的订阅者数量。这个例子里metacc解决的是事件拓扑维护问题。发布者不需要知道订阅者在哪个文件里订阅者也不需要调用运行时注册接口构建期生成的静态表就是最终分发拓扑。宏参数语义METACC_TABLEMETACC_TABLE(table_name,item_type,sort_col0,orderasc)前两个参数是必需的。table_name生成的数组符号名。item_type数组元素类型。可选参数sort_col或col按第几个 payload 字段排序使用从 0 开始的索引。orderasc或desc默认asc。如果没有sort_col条目会按源码路径和行号排序。这适合不关心优先级、只想稳定输出的表。如果设置了sort_col工具会尝试把该字段解析为整数或枚举常量再排序。它支持常见 C 整数字面量后缀比如1u、0x10UL。如果字段不是数字也不是已知枚举会退回字符串排序。METACC_TABLE_ITEMMETACC_TABLE_ITEM(table_name,field0,field1,field2,...)第一个参数是目标表名后面的 payload 会成为数组元素初始化内容。如果 payload 只有一个顶层参数生成时不会额外套花括号METACC_TABLE_ITEM(callbacks,on_event)生成constcallback_tcallbacks[]{on_event,};如果 payload 有多个顶层参数会生成聚合初始化METACC_TABLE_ITEM(module_init,20,device,device_init,device_exit)生成constmodule_init_entry_tmodule_init[]{{20,device,device_init,device_exit},};为什么不用运行时注册运行时注册在桌面程序里很常见但在嵌入式或底层 SDK 中往往带来额外复杂度。注册函数需要被调用就会引入初始化顺序问题。注册容器需要存储就会引入数组容量、链表、堆内存或锁。注册发生在运行时也意味着错误发现更晚某个模块忘了调用注册函数可能要到系统启动后才暴露。metacc把这件事提前到构建期。源码里只留下声明METACC_TABLE_ITEM(...)构建时把它们收集起来constentry_ttable[]{...};运行时只读数组即可。没有注册动作也没有注册失败路径。为什么用 libclang而不是正则扫源码metacc会做一层快速文本预筛但真正提取宏实例依赖libclang。原因很简单C 项目里的源码形态并不单纯。一个文件可能通过-I引入头文件头文件还会再 include 其它头文件。宏参数里可能有函数指针、字符串、聚合初始化、括号表达式。单纯正则很容易在这些地方出错。当前实现的处理方式是从compile_commands.json读取每个编译单元的真实编译参数。根据-I、-iquote、-isystem等参数做快速 include 预扫跳过完全不含METACC_*的文件。对可能含有标记的编译单元调用libclang读取宏实例和 enum 值。只收集project_root内的注解并排除已生成目录避免外部依赖或旧生成物污染结果。将结果按表名归并、排序、去重、生成 C/H 文件。这也是为什么推荐通过 CMake 开启CMAKE_EXPORT_COMPILE_COMMANDSON。工具需要知道源码在项目里真实是怎么被编译的。生成目录和缓存默认输出目录project_root/build/metacc_files/ include/ metacc_owner.h src/ metacc_owner.c默认缓存目录project_root/build/.metacc/.cache/缓存按编译单元保存依赖文件的mtime和大小变化会触发失效。构建规则直接以真实生成的.c/.h文件作为输出首次构建会产出文件后续增量构建可以复用未变化的解析结果。命令行使用源码模式tools/metacc/venv/bin/python tools/metacc/metacc.py\-cbuild/compile_commands.json\-p.\-gbuild/metacc_files\-dbuild/.metacc/.cache\-j4发布包模式tools/metacc/release/metacc\-cbuild/compile_commands.json\-p.\-gbuild/metacc_files\-dbuild/.metacc/.cache\-j4参数说明-c, --compile-commandscompile_commands.json路径。不传时会尝试自动查找。-p, --project-root项目根目录。工具只会收集该目录内的注解。-g, --generated-root生成文件根目录默认是project_root/build/metacc_files。-d, --cache-dir缓存目录默认是project_root/build/.metacc/.cache。-j, --jobs解析进程数。传0表示使用os.cpu_count()。在 CMake 项目中集成最关键的前提是打开编译数据库set(CMAKE_EXPORT_COMPILE_COMMANDS ON)然后把metacc.h所在目录加入 include path。源码运行时通常引用tools/metacc发布包运行时则引用release两种方式都保持头文件来源清晰。一个简化版集成如下set(METACC_DIR ${CMAKE_SOURCE_DIR}/tools/metacc) set(METACC_OUT_DIR ${CMAKE_BINARY_DIR}/metacc_files) set(METACC_CACHE_DIR ${CMAKE_BINARY_DIR}/.metacc/.cache) set(METACC_PYTHON ${METACC_DIR}/venv/bin/python) set(METACC_SCRIPT ${METACC_DIR}/metacc.py) set(METACC_GENERATED ${METACC_OUT_DIR}/src/metacc_module_init.c ${METACC_OUT_DIR}/include/metacc_module_init.h ) add_custom_command( OUTPUT ${METACC_GENERATED} COMMAND ${CMAKE_COMMAND} -E make_directory ${METACC_OUT_DIR}/src ${METACC_OUT_DIR}/include ${METACC_CACHE_DIR} COMMAND ${CMAKE_COMMAND} -E env PYTHONPATH${METACC_DIR} ${METACC_PYTHON} ${METACC_SCRIPT} -c ${CMAKE_BINARY_DIR}/compile_commands.json -p ${CMAKE_SOURCE_DIR} -g ${METACC_OUT_DIR} -d ${METACC_CACHE_DIR} DEPENDS ${CMAKE_BINARY_DIR}/compile_commands.json ${METACC_SCRIPT} ${METACC_DIR}/metacc.h WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} VERBATIM ) add_custom_target(metacc_codegen ALL DEPENDS ${METACC_GENERATED}) target_include_directories(app PRIVATE ${METACC_DIR} ${METACC_OUT_DIR}/include ) target_sources(app PRIVATE ${METACC_OUT_DIR}/src/metacc_module_init.c ) add_dependencies(app metacc_codegen)安装源码运行环境如果直接从源码运行需要 Python 绑定和 nativelibclang。在tools/metacc下准备虚拟环境python3-mvenv venvsourcevenv/bin/activate pipinstall--upgradepip pipinstall-e.pyproject.toml里声明了dependencies [ libclang18.0.0, ]在上面的 CMake 示例里脚本使用tools/metacc/venv/bin/python如果你在其它项目里复用要么保持这个路径约定要么在自己的 CMake 封装里改成你自己的 Python 路径。打包发布发布使用package.shcdtools/metacc ./package.sh发布目录是单层平铺结构tools/metacc/release/ metacc metacc.h libclang.so *.so发布包可以直接作为归档根目录使用metacc是命令行可执行文件。metacc.h是业务代码需要包含的标记宏头文件。libclang.so和可执行文件同级发布时保持平铺结构即可。打包后可以直接验证tools/metacc/release/metacc--help常见问题为什么没有生成我的条目优先检查这几件事METACC_TABLE_ITEM是否直接写在源码里保持和示例一致的原生标记形式。包含该源码的编译单元是否出现在compile_commands.json中。--project-root是否设置正确。工具只收集 project root 内的注解。相关头文件能否通过 compile command 里的-I、-iquote、-isystem找到。METACC_TABLE_ITEM的第一个参数是否和METACC_TABLE的表名完全一致。为什么报 undefined METACC_TABLE说明某个 item 引用了不存在的表名。例如METACC_TABLE(my_table,item_t)METACC_TABLE_ITEM(my_tabel,1,2,3)/* 拼写错了 */工具会输出具体文件和行号。为什么函数有 prototype但链接失败生成文件和业务.c是不同翻译单元。条目里引用的函数如果是static生成文件无法链接到它。解决方法是让该函数具备外部链接intmy_callback(void);或者把条目设计成引用可见对象而不是引用static私有符号。为什么首次构建会解析很多文件首次构建没有缓存metacc需要扫描compile_commands.json中的源文件。之后缓存会按依赖文件的修改时间和大小判断是否复用。当CACHE_VERSION、工具脚本、编译参数或依赖文件变化时对应缓存会失效这是正常行为。为什么不用 section/linker setsection/linker set 是另一种常见方案但它依赖编译器和链接脚本约定跨平台行为更难统一。metacc生成普通 C 数组调试时可以直接打开生成的.c文件看最终顺序更适合需要强可读构建产物的 SDK。适合使用的场景metacc适合这些场景表项分散在多个模块里但运行时希望是一张静态数组。项目已经使用 CMake并能生成compile_commands.json。希望避免运行时注册机制。希望生成物是普通 C 源码方便审查、调试和发布。许可证metacc使用 Apache License 2.0。详见LICENSE。