ESP-IDF 项目结构与 CMake 讲解
一、ESP-IDF 项目目录结构
以 key 项目为例:
key/ ← 项目根目录(相当于 STM32 的一个 Keil/CubeIDE 工程)
├── CMakeLists.txt ← 顶层构建文件(相当于 Keil 的 .uvprojx 工程文件)
├── sdkconfig ← 芯片配置文件(相当于 CubeMX 生成的配置)
├── main/ ← 主程序目录
│ ├── CMakeLists.txt ← main 组件的构建文件
│ └── main.c ← 程序入口,app_main() 在这里(相当于 STM32 的 main.c)
└── components/ ← 自定义组件目录(相当于你在 STM32 里写的 BSP 驱动库)
├── key/ ← 按键驱动组件
│ ├── CMakeLists.txt
│ ├── key_app.c
│ └── key_app.h
└── led/ ← LED 驱动组件
├── CMakeLists.txt
├── led_app.c
└── led_app.h
和 STM32 的关键对比
| 概念 | STM32 (Keil/CubeIDE) | ESP-IDF |
|---|---|---|
| 程序入口 | main() |
app_main()(被 FreeRTOS 调用) |
| 工程配置 | .uvprojx / .ioc |
CMakeLists.txt + sdkconfig |
| 外设驱动 | HAL 库 / LL 库 | ESP-IDF 的 driver 组件 |
| 自己写的驱动 | 手动加 .c/.h 到工程 | 放到 components/ 下,每个文件夹是一个"组件" |
| 依赖管理 | 手动管理头文件路径 | CMakeLists.txt 里用 REQUIRES 声明 |
核心概念:组件(Component)
ESP-IDF 的核心思想是一切皆组件:
main/是一个特殊组件(程序入口)components/led/和components/key/是你自己写的组件- ESP-IDF 自带几百个组件(driver、freertos、esp_wifi 等),放在 ESP-IDF 安装目录的
components/下
组件名字就是它所在文件夹的名字:
components/led/→ 组件名叫ledcomponents/key/→ 组件名叫key$IDF_PATH/components/driver/→ 组件名叫driver
组件之间通过 REQUIRES 声明依赖关系,构建系统自动处理头文件路径和链接。
sdkconfig 是什么?
类似 STM32 CubeMX 里配置时钟、外设的界面。在终端运行 idf.py menuconfig 可以打开图形化配置界面,配置内容保存在 sdkconfig 里。常见配置项:
- Flash 大小、分区表
- WiFi/蓝牙开关
- FreeRTOS tick 频率
- 日志级别
二、CMake 是什么?解决什么问题?
从编译说起
假设你有一个 main.c,手动编译:
gcc main.c -o main
如果项目有 10 个 .c 文件:
gcc main.c led.c key.c uart.c spi.c i2c.c wifi.c http.c json.c utils.c -o app
问题:每次编译都要手敲这么长一串,慢且容易出错。
Makefile 解决了"手敲命令"的问题
把编译命令写成文件,用 make 一键执行。
但新问题:不同平台的编译器不一样(Linux 用 gcc,Windows 用 msvc,ESP32 用 xtensa-esp32-elf-gcc),Makefile 换个平台就废了。
CMake 解决了"跨平台"的问题
CMake 的思路:你只需要告诉我三件事:
- 编译哪些源文件?(SRCS)
- 头文件在哪里找?(INCLUDE_DIRS)
- 依赖哪些库?(REQUIRES)
然后 CMake 根据当前平台,自动生成对应的编译命令。
CMake 本身不编译代码,它是一个"生成构建脚本的工具"。
ESP-IDF 对 CMake 的封装
标准 CMake 要写三行:
add_library(led led_app.c)
target_include_directories(led PUBLIC .)
target_link_libraries(led driver)
ESP-IDF 封装了一个函数 idf_component_register,一行搞定:
idf_component_register(SRCS "led_app.c"
INCLUDE_DIRS "."
REQUIRES driver)
idf_component_register 不是 CMake 自带的,是 ESP-IDF 提供的快捷方式。ESP-IDF 开发只需要记住这一个函数。
本质上,CMake 就是用文本文件做了 Keil 图形界面里点点点做的事。
三、idf_component_register 三个参数详解
SRCS — 要编译的源文件
列出本组件的所有 .c 文件。不管别人用不用,只要是这个组件的 .c 文件就全部加进去。
不加到 SRCS 的 .c 文件不会被编译,里面的函数就不存在,谁也用不了。
SRCS "key_app.c" "key_init.c" "key_debounce.c" # 3个 .c 文件全部列上
INCLUDE_DIRS — 头文件搜索目录
告诉编译器去哪些目录找 .h 文件。
"." 表示当前目录。比如 components/led/CMakeLists.txt 里写 INCLUDE_DIRS ".",就是把 components/led/ 这个目录暴露为头文件搜索路径。
这个路径对所有人生效,没有"自己"和"别人"的区分:
- 组件自己的 .c 文件编译时,能从这些目录找 .h
- 别的组件
REQUIRES你之后,也从这些目录找 .h
如果头文件分布在多个子目录,空格隔开列出所有目录:
INCLUDE_DIRS "." "pwm" "effect"
注意:INCLUDE_DIRS 和 SRCS 互不对应,各管各的。 一个组件可能有 5 个 .c 文件,但只有 1 个目录需要暴露。
REQUIRES — 依赖哪些组件
声明本组件依赖谁。写了 REQUIRES led,就能在代码里 #include "led_app.h" 并调用 led 的函数。
三者总结
| 参数 | 作用 | 类比 Keil |
|---|---|---|
SRCS |
本组件所有 .c 文件,全部列上去编译 | 把 .c 文件拖进工程树 |
INCLUDE_DIRS |
头文件所在目录,暴露出去让所有人能找到 .h | Options → C/C++ → Include Paths |
REQUIRES |
本组件依赖哪些其他组件 | 勾选要用的库 |
三者各管各的,互不对应。
四、三个 CMakeLists.txt 各自的作用
1. 顶层 key/CMakeLists.txt — 定义项目名称
cmake_minimum_required(VERSION 3.16)
include($ENV{IDF_PATH}/tools/cmake/project.cmake) # 引入 ESP-IDF 构建系统
project(key) # 项目名叫 "key"
基本不用改,每个新项目复制一份改个项目名就行。
2. main/CMakeLists.txt — 注册 main 组件
idf_component_register(SRCS "main.c"
INCLUDE_DIRS ""
REQUIRES led key freertos)
3. components/led/CMakeLists.txt — 注册 led 组件
idf_component_register(SRCS "led_app.c"
INCLUDE_DIRS "."
REQUIRES driver)
五、完整构建流程(核心!)
执行 idf.py build 后发生的事:
第一阶段:CMake 登场(调度员)
CMake 读取所有 CMakeLists.txt,搞清楚谁需要谁,把路径整理好。
第1步:读 components/led/CMakeLists.txt
→ 记录:led 组件,头文件目录是 components/led/
第2步:读 components/key/CMakeLists.txt
→ 记录:key 组件,头文件目录是 components/key/
第3步:读 main/CMakeLists.txt
→ 发现 REQUIRES led key
→ 查表:led 的头文件目录是 components/led/
→ 查表:key 的头文件目录是 components/key/
→ 结论:编译 main.c 时需要搜索这两个目录
第4步:生成编译命令:
gcc main.c -I components/led/ -I components/key/
CMake 工作结束,退场。从此不再参与。
第二阶段:编译器登场(工人)
编译器拿到的东西很简单:
gcc main.c -I components/led/ -I components/key/
它不知道什么 CMake,什么组件,什么 REQUIRES。它只知道:
- 要编译
main.c - 找头文件时去
components/led/和components/key/这两个目录找
编译器读 main.c,遇到 #include "led_app.h":
去 components/led/ 找 led_app.h → 找到了 → 搞定
编译器眼里没有"组件"的概念,没有 CMakeLists.txt 的概念,它只有一堆目录路径和一个文件名。
如果 INCLUDE_DIRS 列了多个目录,编译器就挨个目录试,先找到谁就用谁。跟你在电脑上找文件一样——一个文件夹一个文件夹打开看。
流程一句话总结
你写 CMakeLists.txt(声明依赖关系)
↓
CMake 读它们,把路径整理成编译命令(-I 参数)
↓
编译器拿着命令干活,按 -I 路径找头文件,编译代码
| 角色 | 干什么 | 知道什么 |
|---|---|---|
| 你 | 写 CMakeLists.txt 和代码 | 知道谁依赖谁 |
| CMake | 读 CMakeLists.txt,生成编译命令 | 知道所有组件的路径和依赖关系 |
| 编译器 | 按命令编译代码 | 只知道"一堆目录路径 + 文件名" |
六、实际开发顺序
先写 CMakeLists.txt,再写 #include。 CMakeLists.txt 是基础设施,#include 建立在它之上。先修路,再通车。
第1步:你想在 main.c 里用 led 的功能
↓
第2步:在 main/CMakeLists.txt 里写 REQUIRES led
(告诉 CMake:我需要 led 组件)
↓
第3步:在 main.c 里写 #include "led_app.h"
(因为第2步已经让编译器能找到这个头文件了)
↓
第4步:在 main.c 里调用 led 的函数
如果跳过第2步直接写第3步,编译报错:
fatal error: led_app.h: No such file or directory
因为没人告诉编译器去哪里找这个文件。
七、常见场景
组件有多个 .c 文件
idf_component_register(SRCS "led_app.c" "led_pwm.c" "led_effect.c"
INCLUDE_DIRS "."
REQUIRES driver)
SRCS 后面列出所有 .c 文件。如果文件都在同一个目录下,INCLUDE_DIRS "." 不用改。
组件有子目录
components/led/
├── CMakeLists.txt
├── led_app.c
├── led_app.h
├── pwm/
│ ├── led_pwm.c
│ └── led_pwm.h
└── effect/
├── led_effect.c
└── led_effect.h
idf_component_register(SRCS "led_app.c" "pwm/led_pwm.c" "effect/led_effect.c"
INCLUDE_DIRS "." "pwm" "effect"
REQUIRES driver)
SRCS 和 INCLUDE_DIRS 的顺序不需要对应,各管各的。
不过实际开发中,一个组件的文件通常放在同一个目录下。如果复杂到需要分很多子目录,通常说明应该拆成多个组件了。
组件之间互相调用
key 组件要控制 LED:
# components/key/CMakeLists.txt
idf_component_register(SRCS "key_app.c"
INCLUDE_DIRS "."
REQUIRES driver led) # ← 加上 led
加了 REQUIRES led 后,key_app.c 里就可以直接 #include "led_app.h"。