《MiniDeviceProxy 专栏(二):从目录结构到 CMakeLists,搭一个像样的 C++ 工程骨架》
上一篇主要确定了项目要做什么:一个面向嵌入式 Linux 单设备场景的轻量级控制服务。
这一篇开始进入工程组织层面:如何明晰项目的目录结构、模块边界和构建系统
MiniDeviceProxy 不是单文件 demo,而是一个模拟真实设备端后台服务的 C++ 项目。因此我在
一开始就没有把所有代码堆在
main.cc里,而是用目录层级和 CMake target把模块边界先划出来,这也是我们搭建工程的第一步。这样做的收益是什么,大家可以自行思考一下,欢迎大家在评论区讨论
一、为什么先讲工程结构
很多初学项目的问题不是功能写不出来,而是代码越写越乱。
常见情况是:
main.cc里既有 socket,又有配置,又有业务判断。- 所有
.cc文件混在一个目录。 CMakeLists.txt里直接把所有源码塞进一个可执行文件。- 后面想加测试、加客户端、加公共库时,不知道怎么拆。
MiniDeviceProxy 的目标不仅是做一套可应用的业务系统,更是训练一种工程思维:
用 C++ 写一个结构清晰、模块边界明确、能持续扩展的 Linux 服务端项目。
所以在第一版里,我把项目拆成了几个层次。
二、当前目录结构
当前项目核心目录大致如下:
text
MiniDeviceProxy/
├── CMakeLists.txt
├── makefile
├── config/
│ └── default_config.json
├── DOC/
│ ├── architecture.md
│ ├── ipc_protocol.md
│ ├── base_infrastructure.md
│ ├── state_machine.md
│ └── 项目目标.md
├── src/
│ ├── app/
│ │ ├── mini_proxyd/
│ │ └── mini_ctl/
│ ├── common/
│ │ ├── logger/
│ │ ├── error_code/
│ │ ├── status/
│ │ ├── json_utils/
│ │ ├── protocol/
│ │ └── command_utils/
│ ├── base/
│ │ ├── msg_looper/
│ │ ├── async_task_executor/
│ │ └── signal/
│ ├── ipc/
│ │ ├── ipc_frame.cc
│ │ ├── uds_server/
│ │ └── uds_client/
│ └── service/
│ ├── proxy_mgr/
│ ├── config_mgr/
│ ├── state_mgr/
│ ├── task_mgr/
│ └── event_bus/
├── test/
│ ├── protocol/
│ ├── base/
│ └── service/
└── third_party/
三、目录层级的职责划分
1. app/:进程入口层
app/ 放真正的可执行程序入口:
text
src/app/mini_proxyd/main.cc
src/app/mini_ctl/main.cc
其中:
mini_proxyd是服务端,负责启动后台服务。mini_ctl是客户端,负责通过 Unix Domain Socket 发命令。
要注意,main.cc的约束是不承担无法的业务逻辑,只负责简单的初始化工作,这是一个宏大的工程性目标,在搭建项目的过程中,我们有时可以临时的在main.cc中堆积业务逻辑以方便调试和迭代,但是一定不能忘记这个终极约束,它会使我们的项目结构清晰,每一行代码都落在对应的文件中,而不是一股脑塞进main.cc
2. common/:公共能力层
common/ 放和业务无关、多个模块都需要用到的基础公共能力。
例如:
logger:统一日志。error_code:统一错误码。status:统一结果表达。json_utils:JSON 解析辅助。protocol:IPC 请求/响应结构。command_utils:命令定义和 CLI 参数辅助。
这些模块为应用层提供相关的基础设施,也是大家项目中可以借鉴的点,这些基础设施能很大程度上为我们提供方便快捷的DEBUGE和排错手段,比如错误码和日志,我们后续很多DEBUGE都需要借助这些基础设施来完成
3. base/:基础执行设施层
base/ 放进程内部调度和并发相关的基础设施。
当前主要包括:
msg_looper:进程内消息循环。async_task_executor:后台任务执行器。signal:后续用于优雅退出。
这一层和 common/ 的区别是:
common/更偏数据结构、错误码、协议、工具。base/更偏线程、事件循环、异步执行、退出控制。
4. ipc/:进程间通信层
ipc/ 放 Unix Domain Socket 通信相关代码。
当前包括:
uds_server:服务端监听、accept、收发消息。uds_client:客户端连接、发送请求、接收响应。ipc_frame:长度前缀分帧协议。
这里有一个重要边界:
IPC 层只负责"外部进程如何访问服务",不负责业务裁决。
也就是说,uds_server 可以负责接收 JSON 请求、解析成 Request,但它不应该自己判断系统状态、不应该直接执行任务、不应该直接修改配置。这个边界不仅对IPC,对所有的通信协议设计都适用,不涉及业务的通信层才能具备更好的可移植性和可扩展性
真正的业务调度要交给 service/proxy_mgr。
5. service/:平台核心服务层
service/ 是 MiniDeviceProxy 的核心。
当前包括:
proxy_mgr:统一调度入口。config_mgr:配置管理。state_mgr:系统状态机。task_mgr:后台任务管理。event_bus:进程内事件分发。
这里的核心原则是:
text
mini_ctl
-> UDS
-> uds_server
-> proxy_mgr
-> config_mgr / state_mgr / task_mgr
外部请求不能绕过 proxy_mgr 直接打到底层模块。proxy_mgr是系统的绝对核心,系统依靠他将各个模块紧密结合在一起,很多共有资源都被proxy_mgr持有,并在构造其他模块类时注入其所需的资源,比如MsgLooper,在StateMgr、TaskMgr等多个模块都有用到,但最终被proxy_mgr持有,不过这些都是后话了。
四、为什么 CMakeLists 要拆多个 target
当前 CMakeLists.txt 没有把所有源码直接编进一个可执行文件,而是拆成了多个静态库:
cmake
add_library(mini_common STATIC ...)
add_library(mini_base STATIC ...)
add_library(mini_uds_server STATIC ...)
add_library(mini_uds_client STATIC ...)
add_library(mini_ipc_frame STATIC ...)
add_library(mini_service STATIC ...)
然后再生成两个可执行文件:
cmake
add_executable(mini_proxyd src/app/mini_proxyd/main.cc)
add_executable(mini_ctl src/app/mini_ctl/main.cc)
这样设计的原因是:CMake target 本身就是工程边界。
如果所有源码都直接写进:
cmake
add_executable(mini_proxyd ...)
短期也能跑,但问题是:
- 客户端
mini_ctl复用不了公共协议。 - 测试没法单独链接某个模块。
- 模块依赖关系不清楚。
- 后续扩展时 CMake 会越来越乱。
拆成多个库后,依赖关系就比较清楚了。
五、当前六个 target 的职责
mini_common
cmake
add_library(mini_common STATIC
src/common/logger/logger.cc
src/common/status/status.cc
src/common/error_code/error_code.cc
src/common/json_utils/json_utils.cc
src/common/event/event.cc
src/common/protocol/protocol.cc
src/common/command_utils/command_utils.cc)
它是公共基础库,服务端和客户端都会用。
例如:
mini_ctl需要protocol构造请求。mini_proxyd需要protocol解析请求。- 两边都需要
error_code和logger。
所以 mini_common 是一个底层公共 target。
mini_base
cmake
add_library(mini_base STATIC
src/base/msg_looper/msg_looper.cc
src/base/async_task_executor/async_task_executor.cc
src/base/signal/signal.cc)
它放消息循环、异步任务和信号退出这些基础执行能力。
这个库依赖 mini_common,因为它也需要日志和错误码:
cmake
target_link_libraries(mini_base PUBLIC mini_common Threads::Threads)
这里还链接了 Threads::Threads,因为 MsgLooper 和 AsyncTaskExecutor 内部使用了线程。
mini_ipc_frame
cmake
add_library(mini_ipc_frame STATIC src/ipc/ipc_frame.cc)
它只负责长度前缀分帧。
这样拆的好处是:
uds_server可以复用。uds_client也可以复用。- 将来如果扩展 TCP,也可以继续复用
ipc_frame。
mini_uds_server 和 mini_uds_client
cmake
add_library(mini_uds_server STATIC src/ipc/uds_server/uds_server.cc)
add_library(mini_uds_client STATIC src/ipc/uds_client/uds_client.cc)
这两个库分别给服务端和客户端使用。
依赖关系是:
cmake
target_link_libraries(mini_uds_server PRIVATE mini_common mini_base mini_ipc_frame)
target_link_libraries(mini_uds_client PRIVATE mini_common mini_ipc_frame)
这里服务端依赖 mini_base,是因为后续 UDS 服务端会接入优雅退出或运行循环控制。
客户端不需要后台消息循环,所以不依赖 mini_base。
mini_service
cmake
add_library(mini_service STATIC
src/service/proxy_mgr/proxy_mgr.cc
src/service/state_mgr/state_mgr.cc
src/service/task_mgr/task_mgr.cc
src/service/config_mgr/config_mgr.cc
src/service/event_bus/event_bus.cc)
这个库是服务端内部核心能力。
它依赖:
cmake
target_link_libraries(mini_service PRIVATE mini_common mini_base)
原因是:
- 需要
mini_common里的协议、日志、错误码、JSON。 - 需要
mini_base里的MsgLooper和AsyncTaskExecutor。
六、最终可执行文件如何链接
服务端:
cmake
target_link_libraries(mini_proxyd PRIVATE
mini_service
mini_uds_server
mini_common)
客户端:
cmake
target_link_libraries(mini_ctl PRIVATE
mini_uds_client
mini_common)
这体现了两者职责不同。
mini_proxyd 是服务端,需要:
- 服务管理能力。
- UDS 服务端。
- 公共协议和日志。
mini_ctl 是客户端,只需要:
- UDS 客户端。
- 公共协议和日志。
这样一来,服务端和客户端虽然共享协议,但不会互相依赖。这里也要提醒大家,不要认为依赖越多越好,各个目标之间必须划清界限,该依赖什么,不该依赖什么,这些必须在编写代码之前就想清楚,否则代码会越写越乱
七、第三方库为什么用 INTERFACE target
当前项目引入了几个头文件型第三方库:
cmake
add_library(third_party INTERFACE)
target_include_directories(third_party INTERFACE
${CMAKE_CURRENT_SOURCE_DIR}/third_party/expected/include
${CMAKE_CURRENT_SOURCE_DIR}/third_party/magic_enum/include
${CMAKE_CURRENT_SOURCE_DIR}/third_party/json/include/
)
这里用的是 INTERFACE,因为这些库大多是 header-only,不需要编译成 .a 或 .so。
然后:
cmake
target_link_libraries(mini_common PUBLIC third_party)
因为 mini_common 的头文件里可能会暴露 tl::expected、nlohmann::json 这类类型,所以依赖 mini_common 的模块也需要看到这些第三方头文件。
这就是为什么这里使用 PUBLIC,而不是 PRIVATE。
八、PUBLIC 和 PRIVATE 的区别
CMake 里 target_link_libraries 的 PUBLIC / PRIVATE 很关键。
简单理解:
PRIVATE:只有当前 target 自己需要。PUBLIC:当前 target 需要,依赖它的人也需要。INTERFACE:当前 target 自己不编译,只把依赖传递给别人。
例如:
cmake
target_link_libraries(mini_base PUBLIC mini_common Threads::Threads)
mini_base 的头文件可能包含 common/error_code/error_code.h,所以依赖 mini_base 的模块也需要知道 mini_common 的 include 路径,因此这里用 PUBLIC 是合理的。
而:
cmake
target_link_libraries(mini_service PRIVATE mini_common mini_base)
如果 mini_service 的公共头文件没有向外暴露 mini_base 的具体类型,理论上可以用 PRIVATE。
不过当前项目还处于第一版,为了开发方便,include 目录暴露得比较宽,后面可以继续收敛。
九、为什么还有一个 makefile
这一定也是绝大多数同学的疑问,为什么一个项目同时保留了Cmakelists和makefile。实际上,我用makefile来简化一些常用的命令,比如编译命令,不再需要输入一大堆cmake -s...,只需要一个make,即可完成项目整编非常方便
项目根目录有一个小写的 makefile:
makefile
.PHONY: all clean proxyd ctl run test rebuild
all:proxyd
proxyd:
cmake -S . -B build
cmake --build build --target mini_proxyd
ctl:
cmake -S . -B build
cmake --build build --target mini_ctl
run:proxyd
./build/mini_proxyd
test:
cmake -S . -B build -DBUILD_TESTING=ON
cmake --build build
cd build && ctest --output-on-failure
clean:
rm -rf build
rebuild:clean all
这个 makefile 不是项目真正的构建系统核心,真正的构建系统是 CMake。
这个 makefile 更像是一个命令快捷入口,用来简化常用操作:
bash
make proxyd
make ctl
make run
make test
make clean
它内部仍然调用的是:
bash
cmake -S . -B build
cmake --build build
所以这个 makefile 的定位是:
给开发者提供短命令入口,而不是替代 CMake 管理工程依赖。
这在学习项目里很实用,因为平时不用每次都手写完整 CMake 命令。
十、为什么不用手写 Makefile 管所有模块
手写 Makefile 当然可以,但当项目变成多模块以后,维护成本会上升。
例如你需要自己处理:
- 每个
.cc文件如何编译。 - 头文件依赖变化后是否重新编译。
- 静态库链接顺序。
- 第三方 include 路径。
- 测试目标。
- Debug / Release 配置。
- 跨平台编译差异。
CMake 的优势是:
- 用 target 描述模块。
- 自动生成底层 Makefile。
- 可以切换 Ninja、Unix Makefiles 等生成器。
- 更适合中大型 C++ 项目组织。
所以当前项目采用:
text
CMakeLists.txt 负责工程结构
makefile 负责常用命令快捷入口
build/Makefile 由 CMake 自动生成
这三个东西的关系一定要分清。
十一、这一版工程结构的核心思想
MiniDeviceProxy 当前的工程结构可以概括为:
text
app 负责进程入口
ipc 负责进程间通信
service 负责业务调度和系统管理
base 负责进程内调度、线程、退出
common 负责公共协议、日志、错误码、JSON
third_party 负责外部依赖
test 负责单元测试
CMakeLists.txt 的设计也围绕这个结构展开:
text
mini_common
mini_base
mini_ipc_frame
mini_uds_server
mini_uds_client
mini_service
mini_proxyd
mini_ctl
这比"所有源码编成一个 exe"麻烦一点,但换来的是:
- 模块职责更清晰。
- 服务端和客户端可以共享公共库。
- 测试可以单独链接模块。
- 后续扩展 IPC、任务、状态机更容易。
- 项目更像真实嵌入式 Linux 工程。
十二、总结
MiniDeviceProxy 虽然是一个学习型项目,但我希望它从一开始就具备真实工程的基本形态:
- 有服务端和客户端。
- 有清晰目录结构。
- 有公共库和服务库。
- 有 CMake target 边界。
- 有 Makefile 快捷入口。
- 有后续可测试、可扩展的空间。
这一步讲清了整个项目的结构和层级,相当于搭好了框架,明确了每个target的目标,后续只需要沿着这套框架继续填充,就能让我们的项目日趋完善啦!
下一篇文章发布时间:5.16之前
球球大佬们多多点赞收藏!