3. 大型项目构建挑战

做嵌入式系统构建这么多年,我见过太多人在项目结构上栽跟头。说白了,一个混乱的目录结构,比糟糕的代码更让人头疼。代码写得烂还能重构,目录结构乱了,连找文件都费劲。

我记得刚入行那会儿,接手过一个老项目。所有源文件堆在一个目录里,.c、.h、.o 文件混在一起,还有几个临时生成的 .bak 文件。想找个驱动文件?得用 ls | grep 翻半天。后来我花了整整两天才把那个项目理清楚。

所以这次做 Makefile 课程,我先把项目结构讲清楚。你想想看,结构定好了,后面的 Makefile 规则才能写得顺。

3.1 标准五目录模型

我推荐的项目结构,核心就五个目录。不多不少,刚好够用。

目录 用途 存放内容
src/ 源代码 .c、.cpp、.S 等源文件
include/ 头文件 .h 文件,对外接口声明
lib/ 第三方库 .a、.so 静态/动态库
build/ 编译中间产物 .o、.d 依赖文件
output/ 最终产物 可执行文件、固件镜像

这五个目录各司其职。源文件只管写代码,头文件只管声明接口,编译中间件和最终产物彻底分开。这样做的好处很明显:你不会误删源文件,也不会把 .o 文件提交到版本库。

核心原则:源目录只读,构建目录可写,输出目录只存最终产物。

3.2 为什么要把 build 和 output 分开?

这个问题我经常被问到。有人觉得,反正都是编译出来的东西,放一起不就行了?

嗯,这里要注意。build 目录里存的是 .o 文件、.d 依赖文件,这些是中间产物。output 目录里存的是可执行文件、固件镜像,这些是最终产物。

我曾经在一个项目里,把 .o 文件和可执行文件混在一起。结果客户要发布版本时,我得手动从一堆 .o 文件里挑出那个可执行文件。更糟的是,有一次我误删了 .o 文件,结果把刚编译好的固件也带走了——因为它们在同一个目录,rm *.o 的时候手一抖,多打了个星号。

分开之后,清理中间产物只需要 rm -rf build/*,完全不影响 output 里的发布包。干净利落。

3.3 目录结构示例

一个实际项目的目录长这样:

my_project/
├── src/
│   ├── main.c
│   ├── uart.c
│   ├── spi.c
│   └── subdir/
│       ├── timer.c
│       └── timer.h
├── include/
│   ├── uart.h
│   ├── spi.h
│   └── platform.h
├── lib/
│   ├── libfreertos.a
│   └── liblwip.a
├── build/
│   ├── main.o
│   ├── uart.o
│   ├── spi.o
│   └── subdir/
│       └── timer.o
├── output/
│   └── firmware.elf
└── Makefile

注意看,src/subdir/timer.h 是模块内部头文件,只给本模块用。对外接口的头文件放在 include/ 里。这样分层清晰,别人看你的代码,一眼就知道哪些是公开接口,哪些是内部实现。

我的习惯:模块内部头文件放在 src 子目录里,对外接口头文件放在 include 里。Makefile 里把头文件搜索路径配好,两边都能找到。

3.4 目录划分的避坑指南

做项目结构设计,有几个坑我踩过,你注意避开。

  • 不要把 .c 和 .h 混放。有人喜欢把 .h 和 .c 放同一个目录,理由是"方便对应"。但项目大了之后,include 目录会变得很乱。我建议 .h 统一放 include,除非是模块内部头文件。
  • 不要把第三方库源码直接放 lib/。lib/ 只放编译好的 .a 或 .so 文件。第三方库的源码应该单独放一个 third_party/ 目录,或者用 git submodule 管理。
  • build 目录不要提交到版本库。.o 文件是编译产物,每次构建都会重新生成。在 .gitignore 里加上 build/ 和 output/,只提交源码和 Makefile。
  • output 目录要区分 debug 和 release。我习惯在 output 下再建两个子目录:output/debug/ 和 output/release/。调试版带符号表,发布版做优化和裁剪。

我曾经踩过的坑:有一个项目,我把所有 .o 文件和可执行文件都放在 build 目录里。结果客户要发布版本时,我得手动从几百个 .o 文件里挑出那个可执行文件。后来我改成 build 只放 .o,output 只放最终产物,再也没出过问题。

3.5 项目结构关系图

下面这张图,把五个目录的关系画清楚了。你一看就明白。

项目五目录结构关系图 项目五目录结构关系图 项目根目录 src/ 源代码 include/ 头文件 lib/ 第三方库 编译 + 链接 build/ 中间产物 (.o) 链接 (Linker) output/ 最终产物 关键说明 • src/include 只读,不修改 • build 可清理,不影响源码 • output 区分 debug/release 版本库提交建议 • 提交:src/ include/ lib/ • 忽略:build/ output/

从这张图你能看到,整个构建流程是单向的。源代码和头文件是输入,经过编译生成 .o 文件,再链接成最终产物。lib 目录里的第三方库在链接阶段参与进来。

这个结构的好处是:每个环节的产物都放在固定位置。你永远不会搞混"这是源码还是编译产物"。

3.6 多模块项目的目录扩展

如果你的项目有多个模块,比如 bootloader、kernel、app,那可以在 src 下再分层:

src/
├── bootloader/
│   ├── start.S
│   └── main.c
├── kernel/
│   ├── scheduler.c
│   ├── task.c
│   └── ipc.c
└── app/
    ├── main.c
    ├── sensor.c
    └── display.c

对应的 include 目录也按模块分:

include/
├── bootloader/
│   └── boot_cfg.h
├── kernel/
│   ├── scheduler.h
│   └── task.h
└── app/
    ├── sensor.h
    └── display.h

这样做的好处是,每个模块的代码都在自己的目录里,互不干扰。Makefile 里可以针对不同模块设置不同的编译选项。比如 bootloader 用 -Os 优化大小,app 用 -O2 优化性能。

我的建议:模块划分不要太细。一个模块目录里放 5-10 个源文件比较合适。太少了显得零碎,太多了又不好管理。

3.7 总结一下

项目结构设计这件事,说白了就是把不同类型的文件分开放。源码归源码,头文件归头文件,编译中间件归中间件,最终产物归最终产物。各司其职,互不干扰。

你按照这个五目录模型来组织项目,后面的 Makefile 写起来会非常顺手。因为每个目录的路径都是固定的,Makefile 里的变量定义也就清晰了。

嗯,结构定好了,下一步就是写 Makefile 了。不过那是后面章节的事,咱们先把基础打牢。