3、项目结构设计:src、include、lib、build、output 目录划分
做嵌入式系统构建这么多年,我见过太多人在项目结构上栽跟头。说白了,一个混乱的目录结构,比糟糕的代码更让人头疼。代码写得烂还能重构,目录结构乱了,连找文件都费劲。
我记得刚入行那会儿,接手过一个老项目。所有源文件堆在一个目录里,.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 项目结构关系图
下面这张图,把五个目录的关系画清楚了。你一看就明白。
从这张图你能看到,整个构建流程是单向的。源代码和头文件是输入,经过编译生成 .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 了。不过那是后面章节的事,咱们先把基础打牢。