1. 从一次交叉编译翻车说起:NNIE工程为什么不能直接套用普通CMake模板
我第一次在嵌入式视觉项目里接触NNIE,是在一块带硬件推理加速单元的板子上。当时手里已经有一个跑得通的CMake工程,心想无非就是换个编译器、加几个库路径的事,结果整整折腾了两天才把第一个模型跑起来。问题出在哪?普通CMake工程默认面向的是x86主机环境,而NNIE相关的推理代码必须交叉编译到目标板上运行,同时还要链接芯片厂商提供的一整套专有运行时库。这两件事叠加在一起,CMake的默认行为几乎处处和你作对。
NNIE全称是Neural Network Inference Engine,是某类嵌入式芯片上专门做神经网络推理的硬件单元。它的工作模式很有意思:主机端负责把模型转成芯片能识别的指令流,目标板上的运行时负责加载和执行。这意味着一个完整的NNIE工程通常包含两部分产物——主机端的模型转换工具,以及目标板上运行的推理程序。这两部分用的编译器和链接库完全不同,如果用一个CMake工程硬扛,配置会变得非常混乱。
所以这篇内容想解决的问题很具体:怎么用CMake把NNIE工程组织得清清爽爽,让交叉编译、库依赖、模型转换、部署验证这几个环节各归各位。适合已经在做嵌入式AI部署、手里有NNIE开发板、但被构建系统折磨过的开发者。如果你还没接触过交叉编译,建议先补一下工具链文件的基本概念,否则后面的内容会有点吃力。
我踩过的第一个坑,就是把主机端工具和目标端程序塞进了同一个CMakeLists.txt,结果CMake在配置阶段就报了一堆找不到头文件的错误。后来才想明白,这两类产物的编译环境根本不同,必须用不同的工具链分别配置。这个认知转变,是整个工程结构设计的起点。
2. 拆解NNIE工程的真实构成:哪些部分该进CMake,哪些不该
2.1 主机端与目标端的职责边界
在动手写CMake之前,得先把工程里的文件按"运行位置"分个类。NNIE工程里常见的文件大致有这么几类:
- 模型转换工具源码:跑在主机上,把训练好的模型转成NNIE能识别的格式。这类代码依赖主机上的标准库和厂商提供的转换库。
- 推理程序源码:跑在目标板上,加载转换后的模型并执行推理。依赖交叉编译工具链和厂商的目标端运行时库。
- 公共头文件:主机端和目标端都可能引用的接口定义,比如模型结构体、错误码等。
- 厂商SDK:通常是一堆
.so和.h文件,分主机版和目标版,路径往往还不一样。 - 模型文件与测试数据:构建产物之外的运行时资源,不该被编译,但需要被部署脚本处理。
我见过不少工程把这些东西全堆在一个目录里,CMake配置里到处是if(CMAKE_SYSTEM_NAME STREQUAL "Linux")这样的判断。能跑,但维护起来是灾难。正确的做法是按运行位置拆成两个独立的构建单元,各自有独立的CMakeLists.txt和工具链配置。
2.2 为什么推荐"一个工程两个构建目标"而不是两个仓库
有人会问,既然主机端和目标端差别这么大,干脆拆成两个git仓库不就行了?我试过,不推荐。原因是模型转换和推理程序之间有一份隐式的契约——转换工具输出的模型格式必须和推理程序期望的输入格式严格对应。这份契约通常体现在公共头文件里。如果拆成两个仓库,头文件同步就成了大问题,改一个结构体字段要提两次PR,很容易漏。
放在同一个仓库里,用CMake的add_subdirectory或者独立的构建目录来隔离,既能保证头文件同步,又能让两套工具链互不干扰。我的做法是在仓库根目录下建host/和target/两个子目录,各自放一份CMakeLists.txt,根目录再放一个顶层CMakeLists.txt负责调度。这样cmake --build的时候可以指定只构建其中一部分。
2.3 厂商SDK的目录结构通常长什么样
不同厂商的SDK布局差异很大,但大体逃不出这个模式:一个include/放头文件,一个lib/放库文件,库文件里再按平台分host/和target/。有些SDK还会提供cmake/目录,里面是厂商写好的FindXXX.cmake或者XXXConfig.cmake。如果厂商提供了config文件,优先用find_package,别自己手写路径,因为厂商的config文件里通常处理了一些你意想不到的依赖关系。
如果厂商没提供,那就得自己写FindNNIE.cmake。这个文件的核心任务是找到头文件目录和库文件路径,并定义成CMake变量供后续使用。写的时候要注意区分主机版和目标版,可以用CMAKE_SYSTEM_NAME或者自定义变量来切换。
3. 交叉编译工具链文件的写法:NNIE场景下的关键配置项
3.1 工具链文件的最小可用集合
交叉编译的核心是工具链文件,CMake通过-DCMAKE_TOOLCHAIN_FILE=xxx.cmake来加载。一个针对NNIE目标板的工具链文件,至少需要这几项:
set(CMAKE_SYSTEM_NAME Linux) set(CMAKE_SYSTEM_PROCESSOR arm) set(TOOLCHAIN_PREFIX /opt/toolchain/bin/arm-linux-gnueabihf-) set(CMAKE_C_COMPILER ${TOOLCHAIN_PREFIX}gcc) set(CMAKE_CXX_COMPILER ${TOOLCHAIN_PREFIX}g++) set(CMAKE_FIND_ROOT_PATH /opt/toolchain/arm-linux-gnueabihf) set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER) set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)CMAKE_SYSTEM_NAME设为Linux会触发CMake的交叉编译模式,这是最关键的一行。CMAKE_FIND_ROOT_PATH告诉CMake去哪里找目标平台的库和头文件,配合后面三个MODE变量,可以避免CMake误用主机上的库。我一开始漏了MODE_PROGRAM NEVER,结果CMake在找pkg-config的时候找到了主机版本,链接阶段直接报架构不匹配。
3.2 那些容易配错的编译与链接选项
NNIE目标板通常是ARM架构,浮点运算单元的支持情况因芯片而异。如果工具链默认的浮点ABI和板子上运行时的ABI不一致,程序跑起来会直接段错误,而且报错信息毫无提示性。我的经验是显式指定浮点选项:
add_compile_options(-mfloat-abi=hard -mfpu=neon-vfpv4) add_link_options(-mfloat-abi=hard -mfpu=neon-vfpv4)具体用hard还是softfp,得看板子上的系统库是用哪种ABI编译的。查的方法很简单,用readelf -A看一个系统库的Tag_ABI_VFP_args属性。这个细节看起来小,但配错了能让你查一整天。
另一个容易忽略的是-Wl,-rpath-link。交叉编译时,链接器需要找到目标平台的间接依赖库,但默认的搜索路径里没有。如果厂商SDK的库之间有依赖关系,不加这个选项会报"undefined reference",而实际上库是存在的。可以在工具链文件里加上:
set(CMAKE_EXE_LINKER_FLAGS "${CMAKE_EXE_LINKER_FLAGS} -Wl,-rpath-link,${CMAKE_FIND_ROOT_PATH}/lib")3.3 用CMake变量区分主机端和目标端构建
顶层CMakeLists.txt里可以用一个自定义变量来控制构建哪一端:
option(BUILD_HOST_TOOLS "Build host-side model conversion tools" OFF) option(BUILD_TARGET_APP "Build target-side inference application" ON) if(BUILD_HOST_TOOLS) add_subdirectory(host) endif() if(BUILD_TARGET_APP) add_subdirectory(target) endif()这样主机端构建时传-DBUILD_HOST_TOOLS=ON -DBUILD_TARGET_APP=OFF,目标端反过来。两个构建目录完全独立,互不污染。我习惯在仓库根目录下建build-host/和build-target/两个目录,分别执行cmake,这样清理的时候直接删目录就行。
4. 链接NNIE运行时库的三种姿势与踩坑记录
4.1 直接指定库路径:最土但最可控
最直接的方式是在target/CMakeLists.txt里硬编码库路径:
target_include_directories(nnie_app PRIVATE ${CMAKE_SOURCE_DIR}/third_party/nnie/include) target_link_directories(nnie_app PRIVATE ${CMAKE_SOURCE_DIR}/third_party/nnie/lib/target) target_link_libraries(nnie_app PRIVATE nnie_runtime nnie_util pthread dl)这种方式的好处是路径一目了然,出问题容易排查。坏处是换SDK版本时要改CMakeLists。我早期项目都这么干,后来SDK升级频繁,改得烦了才换成find_package。
4.2 写FindNNIE.cmake:一次编写到处复用
自己写一个FindNNIE.cmake放在cmake/目录下,然后在顶层CMakeLists.txt里list(APPEND CMAKE_MODULE_PATH ${CMAKE_SOURCE_DIR}/cmake)。这个文件的核心逻辑是:
find_path(NNIE_INCLUDE_DIR nnie_api.h PATHS ${NNIE_ROOT}/include NO_DEFAULT_PATH) find_library(NNIE_RUNTIME_LIB nnie_runtime PATHS ${NNIE_ROOT}/lib/target NO_DEFAULT_PATH) include(FindPackageHandleStandardArgs) find_package_handle_standard_args(NNIE REQUIRED_VARS NNIE_INCLUDE_DIR NNIE_RUNTIME_LIB)NO_DEFAULT_PATH很重要,防止CMake跑到主机系统目录里去找同名库。NNIE_ROOT可以通过环境变量或者CMake变量传入,这样不同机器上SDK路径不同也能适配。
4.3 厂商提供Config文件时的正确用法
如果厂商给了NNIEConfig.cmake,用法就简单了:
find_package(NNIE REQUIRED) target_link_libraries(nnie_app PRIVATE NNIE::runtime)但要注意,厂商的Config文件里可能写死了绝对路径,换机器就失效。我遇到过一家厂商的Config文件里用了${CMAKE_CURRENT_LIST_DIR}/../../../lib这种相对路径,结果我把SDK挪了个位置就找不到了。遇到这种情况,要么改Config文件,要么在调用find_package之前设置好NNIE_DIR变量指向正确位置。
4.4 链接顺序引发的undefined reference排查实录
有一次链接报错说nnie_util里的某个符号找不到,但那个符号明明在nnie_runtime里。查了半天发现是链接顺序问题。GNU链接器处理静态库时是从左到右扫描的,如果nnie_util依赖nnie_runtime,那nnie_util必须写在nnie_runtime前面。CMake的target_link_libraries默认保持书写顺序,所以写成:
target_link_libraries(nnie_app PRIVATE nnie_util nnie_runtime)如果依赖关系复杂,可以用--start-group和--end-group把库包起来,让链接器反复扫描:
target_link_libraries(nnie_app PRIVATE -Wl,--start-group nnie_util nnie_runtime -Wl,--end-group)这个坑我踩过两次,第二次是因为换了个SDK版本,库之间的依赖关系变了,但CMakeLists没改。所以升级SDK后一定要重新验证链接顺序。
5. 模型转换与推理程序的构建衔接:让产物自动流转
5.1 用add_custom_command把模型转换纳入构建流程
模型转换工具构建好之后,通常需要跑一遍把原始模型转成NNIE格式。这一步可以用add_custom_command自动化:
add_custom_command( OUTPUT ${CMAKE_BINARY_DIR}/model.nnie COMMAND model_converter ${CMAKE_SOURCE_DIR}/model/original.model ${CMAKE_BINARY_DIR}/model.nnie DEPENDS model_converter ${CMAKE_SOURCE_DIR}/model/original.model COMMENT "Converting model to NNIE format")这样每次原始模型有改动,构建时会自动重新转换。DEPENDS里要同时写上转换工具和原始模型,缺一不可。我一开始只写了原始模型,结果改了转换工具代码后模型没重新生成,调试了半天才发现。
5.2 转换工具与推理程序的版本对齐问题
模型格式是有版本的。转换工具升级后生成的模型,旧版推理程序可能加载不了。反过来也一样。所以工程里要有一个版本号定义,放在公共头文件里,两边都引用。构建时可以用CMake的configure_file把版本号写进生成的配置头文件:
configure_file( ${CMAKE_SOURCE_DIR}/include/version.h.in ${CMAKE_BINARY_DIR}/generated/version.h @ONLY)version.h.in里写#define NNIE_MODEL_VERSION @NNIE_MODEL_VERSION@,顶层CMakeLists里set(NNIE_MODEL_VERSION 2)。这样改一处就全同步了。
5.3 部署脚本该不该由CMake生成
部署到板子上通常需要把可执行文件、模型文件、配置文件一起拷过去。有人喜欢用CMake的install规则来做,我觉得不太合适。install更适合做系统级安装,而嵌入式部署往往是往一个临时目录里拷东西然后打包。我的做法是单独写一个deploy.sh,CMake只负责构建,部署脚本负责拷贝。这样职责清晰,改部署逻辑不用动CMake。
不过CMake可以生成一个包含路径信息的配置文件供部署脚本读取:
file(GENERATE OUTPUT ${CMAKE_BINARY_DIR}/deploy_paths.txt CONTENT "APP=${CMAKE_BINARY_DIR}/nnie_app\nMODEL=${CMAKE_BINARY_DIR}/model.nnie\n")部署脚本source这个文件就能拿到路径,避免硬编码。
6. 实测中那些让人抓狂的构建报错与应对
6.1 头文件找到了但符号找不到:ABI不匹配的典型症状
编译通过、链接报错说某个函数未定义,但nm看库文件里明明有这个符号。这种情况十有八九是C和C++混编导致的符号修饰问题。NNIE的SDK头文件通常是C接口,但你的推理程序可能是C++写的。如果头文件里没有extern "C"包裹,C++编译器会把函数名修饰成_Z11nnie_initv这种形式,而库里的符号是nnie_init,自然找不到。
解决办法是在包含SDK头文件时用:
extern "C" { #include "nnie_api.h" }或者检查SDK头文件里有没有#ifdef __cplusplus extern "C" {这样的保护。如果没有,就得自己包一层。
6.2 运行时找不到.so:rpath与LD_LIBRARY_PATH的取舍
程序在板子上跑起来报"cannot open shared object file",但库文件明明拷过去了。这是因为运行时链接器不知道去哪里找。两种解法:一是设置LD_LIBRARY_PATH环境变量,二是在链接时指定rpath。嵌入式场景下我倾向于用rpath,因为部署时不用额外配环境变量:
set_target_properties(nnie_app PROPERTIES BUILD_RPATH "$ORIGIN/lib" INSTALL_RPATH "$ORIGIN/lib")$ORIGIN表示可执行文件所在目录,这样只要把库放在可执行文件旁边的lib/目录里就能找到。注意BUILD_RPATH和INSTALL_RPATH要分别设置,前者影响构建目录里的可执行文件,后者影响安装后的。
6.3 构建缓存导致的诡异问题与清理策略
CMake的缓存有时候会记住旧的工具链配置,换了工具链文件但没清缓存,会导致各种奇怪的问题。我的习惯是每次换工具链或者改了大配置,直接删掉整个构建目录重新来。如果不想全删,至少删CMakeCache.txt和CMakeFiles/目录。
另外,交叉编译时如果主机上也装了同名的库,CMake的find_library可能会找到主机版本。除了前面说的NO_DEFAULT_PATH,还可以在工具链文件里设置CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY,双保险。
6.4 一个完整的构建命令序列参考
把上面的东西串起来,主机端构建:
mkdir -p build-host && cd build-host cmake .. -DBUILD_HOST_TOOLS=ON -DBUILD_TARGET_APP=OFF cmake --build . -j$(nproc)目标端构建:
mkdir -p build-target && cd build-target cmake .. -DCMAKE_TOOLCHAIN_FILE=../cmake/toolchain-arm.cmake \ -DBUILD_HOST_TOOLS=OFF -DBUILD_TARGET_APP=ON \ -DNNIE_ROOT=/path/to/sdk cmake --build . -j$(nproc)部署:
scp build-target/nnie_app build-target/model.nnie board:/opt/app/ scp -r third_party/nnie/lib/target/*.so board:/opt/app/lib/这套流程我在三个不同项目里用过,基本不用改。唯一要调的是工具链前缀和SDK路径。
7. 工程组织上的一些个人习惯
我现在的NNIE工程目录结构大致是这样:根目录下host/、target/、common/、cmake/、third_party/、model/、scripts/。common/放公共头文件,cmake/放工具链文件和Find模块,scripts/放部署和测试脚本。每个子目录都有自己的CMakeLists.txt,顶层只做调度。
有一点想提醒:不要把厂商SDK直接提交到git仓库里。SDK通常很大,而且有许可限制。我的做法是在third_party/下放一个README说明SDK的获取方式,然后通过NNIE_ROOT变量在构建时指定路径。团队协作时每个人本地路径可能不同,但CMake配置是统一的。
另外,模型文件也不建议提交到git。用git-lfs或者单独的文件服务器管理。CMake构建时通过变量指定模型路径,不要硬编码。
最后说一个调试技巧:如果链接报错看不懂,加-Wl,--verbose让链接器打印详细的搜索过程,能看到它到底在哪些目录里找了哪些库。这个输出很长,但关键时刻能省很多时间。我每次遇到找不到库的问题,第一件事就是看这个输出,比瞎猜快得多。