《嵌入式 Linux 控制服务从零搭建(二):从目录结构到 CMakeLists,搭一个像样的 C++ 工程骨架》

《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_codelogger

所以 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,因为 MsgLooperAsyncTaskExecutor 内部使用了线程。

mini_ipc_frame

cmake 复制代码
add_library(mini_ipc_frame STATIC src/ipc/ipc_frame.cc)

它只负责长度前缀分帧。

这样拆的好处是:

  • uds_server 可以复用。
  • uds_client 也可以复用。
  • 将来如果扩展 TCP,也可以继续复用 ipc_frame

mini_uds_servermini_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 里的 MsgLooperAsyncTaskExecutor

六、最终可执行文件如何链接

服务端:

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::expectednlohmann::json 这类类型,所以依赖 mini_common 的模块也需要看到这些第三方头文件。

这就是为什么这里使用 PUBLIC,而不是 PRIVATE

八、PUBLICPRIVATE 的区别

CMake 里 target_link_librariesPUBLIC / 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之前

球球大佬们多多点赞收藏!

相关推荐
人道领域2 小时前
【LeetCode刷题日记】二叉树翻转:递归与迭代全解析
java·算法·leetcode
tankeven2 小时前
C++ 数组
c++
Cyan_RA92 小时前
SpringMVC 视图和视图解析器 万字详解
java·spring·mvc·springmvc·请求重定向·modelandview·视图解析器
宏笋2 小时前
C++ 完美转发和应用场景
c++
进击的荆棘2 小时前
递归、搜索与回溯——综合(上)
c++·算法·leetcode·深度优先·dfs
水云桐程序员9 小时前
C++可以写手机应用吗
开发语言·c++·智能手机
kyriewen9 小时前
百度用6%成本碾压硅谷?中国AI把性价比玩明白了
前端·百度·ai编程
kyriewen10 小时前
你还在手动敲命令部署?GitHub Actions 让你 push 即上线,摸鱼时间翻倍
前端·面试·github
想学习java初学者10 小时前
SpringBoot整合Vertx-Mqtt多租户(优化版)
java·spring boot·后端