ESP32-S3 Hello World 学习笔记

4592 字
23 分钟
ESP32-S3 Hello World 学习笔记

ESP32-S3 Hello World 学习笔记#

本章重要 API 函数#

API功能参数说明头文件
app_main()ESP-IDF 应用程序入口函数void:无参数,无返回值。由 FreeRTOS 在启动流程末尾调用,用户代码从此处开始执行ESP-IDF 内置
printf()串口调试输出const char *format, ...:格式化字符串和可变参数,底层通过 UART0 输出到串口终端<stdio.h>
esp_chip_info()获取芯片硬件特性信息esp_chip_info_t *out_info:输出芯片信息结构体,包含核心数、Wi-Fi/BLE 特性标志、芯片型号等"esp_chip_info.h"
idf_component_register()注册组件到构建系统(CMake)SRCS:源文件列表;INCLUDE_DIRS:头文件目录(多个用空格分隔);REQUIRES:(可选)依赖的其他组件,如 driverfreertosnvs_flashCMake 函数
idf.py build编译项目无参数(命令行),内部调用 CMake + Ninja 构建体系命令行工具
idf.py flash烧录到设备-p PORT:指定串口端口,烧录前自动调用 build命令行工具
idf.py monitor查看串口输出-p PORT:指定串口端口,波特率默认 115200命令行工具

本章目录#

点击条目可跳转到对应小节。

章节导航#


学习前后依赖#

方向内容
前置基础ESP-IDF 工程目录、main 组件、CMake 构建入口与串口监视基本流程。
本章核心理解 app_main()、组件注册、编译烧录和 Hello World 输出链路。
后续承接承接 01_LED 的 GPIO 输出实验,并为后续所有外设章节建立工程结构基础。

1. 概述#

Hello World 是 ESP-IDF 开发框架最基础的入门项目,用于验证开发环境配置是否正确,并展示 ESP32-S3 项目的基本结构。

它看似简单到只有一行 printf("Hello world!\n"),但背后隐藏着整个 ESP-IDF 框架的启动链路:ROM Bootloader → Second Stage Bootloader → FreeRTOS 初始化 → app_main() 调用。理解这一行代码是怎么”跑到屏幕上”的,就等于打开了嵌入式 Linux/RTOS 系统的大门。

为什么 ESP-IDF 用 app_main() 而不是 main() 因为 FreeRTOS 在启动时会先初始化内核数据结构(就绪链表、定时器、调度器),然后创建一个默认的”main 任务”来执行 app_main()。换句话说,你的 app_main() 实际上是跑在一个 FreeRTOS 任务上下文中——不是裸机程序的 main()。这个设计让用户在写第一行代码时就”无感”地进入了 RTOS 环境。


2. ESP-IDF 项目架构#

2.1 项目目录结构#

0_hello_world/
├── main/ # 主应用代码目录
│ ├── CMakeLists.txt # main 组件的构建配置
│ └── hello_world_main.c # 主程序源文件(未显示)
├── CMakeLists.txt # 项目根目录构建配置
├── sdkconfig # 项目配置文件(menuconfig 生成)
├── sdkconfig.old # 旧配置备份
└── build/ # 构建输出目录

每个 ESP-IDF 项目都是一个组件的集合main 是最基本的组件,你可以把任何功能模块(LED 驱动、传感器驱动、网络协议)封装为独立的 components/ 组件。这种组件化设计的好处是:模块之间通过 REQUIRES 声明依赖关系,构建系统自动处理编译顺序和链接——你不需要手动管理头文件路径和链接库。

2.2 构建系统传递树#

项目根 CMakeLists.txt
include($ENV{IDF_PATH}/tools/cmake/project.cmake)
project(项目名称)
自动发现并注册 components/
├── main 组件
│ │
│ ▼
│ main/CMakeLists.txt
│ │
│ ▼
│ idf_component_register(...)
└── 其他自定义组件
components/CMakeLists.txt

关键点:include($ENV{IDF_PATH}/tools/cmake/project.cmake) 这一行是整个构建系统的入口。它导入 ESP-IDF 提供的 CMake 脚本,该脚本会:

  1. 加载 ESP-IDF 工具链文件(xtensa-esp32s3-elf-gcc 等)
  2. 扫描 components/ 目录,自动发现所有组件
  3. 解析 REQUIRES 依赖,构建依赖图
  4. 调用 project() 后生成 Ninja 构建文件

为什么用 Ninja 而不是 Make? Ninja 的设计目标是”极快的增量编译”——它不解析 Makefile 的复杂语法,只执行预生成的构建图。对于 ESP-IDF 这种有数百个源文件的项目,Ninja 的增量编译比 Make 快数倍。


3. CMake 构建系统详解#

3.1 根目录 CMakeLists.txt#

每个 ESP-IDF 项目必须在根目录包含以下标准结构:

cmake_minimum_required(VERSION 3.16) # 指定 CMake 最低版本
include($ENV{IDF_PATH}/tools/cmake/project.cmake) # 导入 IDF 构建系统
project(项目名称) # 定义项目名称

关键要点:

  • IDF_PATH 环境变量:指向 ESP-IDF 安装路径。这个变量由 export.sh(Linux/macOS)或 export.bat(Windows)脚本设置,必须在打开终端后先 source 一遍,否则 idf.py 找不到工具链。
  • project.cmake:提供 ESP-IDF 特有的构建功能。它定义了 idf_component_register() 等 CMake 函数,以及 Flash 烧录目标、分区表生成等构建逻辑。
  • 项目名称决定最终输出的 .bin.elf 文件名,也用作 idf.py flash 时寻找固件的默认名称。

3.2 main 组件 CMakeLists.txt#

idf_component_register(SRCS "hello_world_main.c"
INCLUDE_DIRS "")

参数解析:

参数说明
SRCS组件源文件列表,可包含多个 .c 文件,用空格分隔
INCLUDE_DIRS头文件目录列表,"" 表示当前目录。对其他组件暴露的头文件应放在这里
REQUIRES(可选)依赖的其他组件,如 driverfreertos。构建系统会自动把被依赖组件的头文件路径和链接库传给当前组件
PRIV_REQUIRES(可选)私有依赖——头文件不暴露给依赖本组件的其他组件

为什么需要显式注册源文件? CMake 默认不会自动发现 .c 文件。idf_component_register() 告诉构建系统:这些源文件属于这个组件,请编译它们并把目标文件链接到最终固件中。如果你新增了一个 .c 文件但没有加入 SRCS,编译不会报错——但你的函数不会出现在固件中,运行时链接失败。


4. 程序执行流程图#

4.1 ESP32-S3 启动流程#

┌─────────────────┐
│ 电源上电/复位 │
└────────┬────────┘
┌─────────────────┐
│ ROM Bootloader │ ◄── 第一级启动加载器(固化在 ROM)
│ (检查启动模式) │ 芯片出厂时烧录,不可修改
└────────┬────────┘ 功能:根据 GPIO 电平判断启动模式
│ (正常启动 / 下载模式)
┌─────────────────┐
│ Second Stage │ ◄── ESP-IDF 提供的第二级 bootloader
│ Bootloader │ 从 Flash 0x0000 处加载
└────────┬────────┘ 功能:初始化 Flash、PSRAM、加载分区表
│ 选择运行哪个 App 分区(支持 OTA 回滚)
┌─────────────────┐
│ 加载 App │ ◄── 从 Flash 加载应用程序镜像到 RAM
│ 到 RAM/Flash │ ESP32-S3 支持 XIP(片内执行),
└────────┬────────┘ 代码可直接在 Flash 上运行,无需全量加载到 RAM
┌─────────────────┐
│ FreeRTOS 初始化 │ ◄── 创建 IDLE 任务、定时器任务
│ 启动调度器 │ 初始化 esp_timer、事件循环等系统组件
└────────┬────────┘
┌─────────────────┐
│ app_main() │ ◄── 用户程序入口点
│ 开始执行 │ 在 main 任务上下文中运行
└─────────────────┘

两级 Bootloader 的设计原因:ROM Bootloader 是固化在芯片中的,代码量极少(几 KB),只能做最基本的启动模式判断。复杂的逻辑(Flash 初始化、分区表解析、固件校验、OTA 回滚)需要更大的代码空间——这就是 Second Stage Bootloader 存在的意义。它在 Flash 中独立存放,可以通过 ESP-IDF 升级。

什么是 XIP(Execute In Place)? 传统的 MCU 会把 Flash 中的代码全部复制到 RAM 再执行。ESP32-S3 支持直接从外部 Flash 取指令执行——CPU 通过 SPI 总线读 Flash,Cache 缓存热点代码。这让 16 MB 的 Flash 成为”低速但巨大的代码空间”,而 512 KB 的 SRAM 只存放数据和栈。

4.2 FreeRTOS 任务调度#

app_main() 执行完毕后
┌─────────────────────┐
│ FreeRTOS 调度器启动 │
└─────────┬───────────┘
┌─────────────────────┐
│ 创建默认任务 │ ◄── main 任务自动转换为 FreeRTOS 任务
│ (IDF 默认配置) │ 优先级为 1(configMAIN_TASK_PRIORITY)
└─────────┬───────────┘
┌─────────────────────┐
│ 任务调度循环 │ ◄── 按优先级抢占调度 + 同优先级时间片轮转
│ (Preemptive + RR) │ 调度器在每次 SysTick 中断时评估是否需要切换
└─────────────────────┘

app_main() 执行完毕 ≠ 程序结束。在裸机程序中 main() return 意味着程序结束(通常会触发复位)。但在 ESP-IDF 中,app_main() 返回后,main 任务被删除,但 FreeRTOS 调度器继续运行——IDLE 任务、Wi-Fi 任务、Timer 任务依然在工作。如果你在 app_main() 中创建了其他任务,它们会继续执行。这就是 RTOS 和裸机最本质的区别之一:程序不会”结束”,调度器永远在运转


5. Hello World 核心代码解析#

典型的 Hello World 主程序结构:

#include <stdio.h>
void app_main(void)
{
printf("Hello world!\n");
// 可选:获取芯片信息
esp_chip_info_t chip_info;
esp_chip_info(&chip_info);
printf("This is ESP32 chip with %d CPU cores\n", chip_info.cores);
printf("WiFi information%s, Bluetooth information%s\n",
chip_info.features & CHIP_FEATURE_WIFI ? "" : " disabled",
chip_info.features & CHIP_FEATURE_BT ? "" : " disabled");
}

关键概念:

函数/结构说明
app_main()ESP-IDF 应用程序入口点,替代传统 main()。它运行在 FreeRTOS main 任务上下文(优先级 1)中,栈大小由 CONFIG_MAIN_TASK_STACK_SIZE 控制(默认 4096 字节)
printf()通过 UART0 输出到串口终端。ESP-IDF 的 printf 底层调用 esp_rom_uart_tx_one_char() 逐字节发送,默认波特率 115200
esp_chip_info()获取芯片硬件特性信息,返回结构体包含核心数、内嵌 Flash 大小、Wi-Fi/BLE/EMB_FLASH 等特性标志位

printf 的数据流向

printf("Hello world!\n")
格式化字符串 → 生成字符序列
stdout 缓冲(newlib 提供的标准 I/O)
esp_rom_uart_tx_one_char() 逐字节发送
UART0 TX FIFO(硬件 128 字节 FIFO)
TX 移位寄存器 → GPIO43 (U0TXD) → USB-Serial-JTAG 芯片
电脑串口终端显示

理解这个链路很重要——后续当你使用 xTaskCreate 创建多任务时,多个任务可以同时 printf,输出在串口缓冲区被序列化。但如果一次性 printf 数据量超过 UART FIFO 大小(128 字节),发送函数会阻塞等待 FIFO 空闲——此时整个 FreeRTOS 调度器都在等,所有任务暂停。这就是为什么不要在 ISR 中用 printf,以及大数据量输出应该用 ESP_LOG 系列函数(它们内部做了缓冲和分片)。


6. 构建与烧录命令#

6.1 常用命令#

Terminal window
# 配置项目(进入 menuconfig 界面)
idf.py menuconfig
# 编译项目
idf.py build
# 烧录到设备
idf.py -p PORT flash
# 查看串口输出
idf.py -p PORT monitor
# 组合命令(编译+烧录+监控)
idf.py -p PORT flash monitor

命令背后的实际调用链(以 idf.py build 为例):

idf.py build
→ 调用 CMake 生成 Ninja build.ninja(首次或 sdkconfig 变化时)
→ 调用 Ninja 执行增量编译
→ xtensa-esp32s3-elf-gcc 编译每个 .c → .o
→ xtensa-esp32s3-elf-ar 打包组件 → libxxx.a
→ xtensa-esp32s3-elf-ld 链接所有 .a → .elf
→ esptool.py elf2image .elf → .bin
→ 生成 partition-table.bin、bootloader.bin

6.2 构建产物#

build/
├── 项目名称.bin # 最终应用程序二进制文件
├── 项目名称.elf # ELF 格式(包含调试信息)
├── bootloader.bin # 二级 bootloader
├── partition-table.bin # 分区表
└── flash_args # 烧录参数配置(esptool 使用的参数文件)

7. Flash 分区布局#

┌────────────────────────────────────┐ 0x000000
│ Bootloader Partition │
│ (默认 0x0000 ~ 0x8000) │ 32 KB
├────────────────────────────────────┤ 0x8000
│ Partition Table │
│ (分区表配置) │ 通常 4 KB
├────────────────────────────────────┤ 0x10000
│ NVS Partition │
│ (非易失性存储) │ 存放 Wi-Fi 配置、校准数据等
├────────────────────────────────────┤
│ OTA Data Partition │
│ (OTA 升级状态) │ 记录当前激活的 App 分区
├────────────────────────────────────┤
│ Application Partition │
│ (用户应用程序) │ factory / ota_0 / ota_1
└────────────────────────────────────┘

分区表的作用:ESP32-S3 没有独立的 ROM 来存放”哪个地址是 App 代码”。分区表就是一个存储在已知地址(0x8000)的元数据表,告诉 Bootloader 每个分区的类型、起始地址和大小。这比固定地址方案灵活得多——你可以自定义分区大小、添加 OTA 分区、设置 NVS 容量,而不需要改 Bootloader 代码。

NVS 分区为什么在 OTA Data 之前? 因为 NVS 存放 Wi-Fi 配置和校准数据——这些数据的地址不应随 OTA 升级而变化。Wi-Fi 校准时写入的 PHY 参数需要在任何 App 版本下都能读取到。把它放在分区表之后、App 分区之前,保证了地址的稳定性。


容易踩坑#

现象常见原因排查方向
idf.py build 找不到工程不在项目根目录执行或缺少根 CMakeLists.txt确认当前目录、IDF_PATH 环境和项目根 CMake 文件
烧录失败或串口打不开串口号错误、驱动未安装或 monitor 占用端口检查设备管理器端口,关闭其他串口工具后重试
monitor 没有 Hello 输出烧录的不是当前工程或波特率/复位时机不对重新 flash monitor,观察 bootloader 日志和应用入口
新增源文件未参与编译idf_component_register() 未加入 SRCS检查 main/CMakeLists.txt 的源文件列表和组件注册
printf 输出乱码波特率不匹配确认 monitor 波特率与 sdkconfig 中 CONFIG_ESP_CONSOLE_UART_BAUDRATE 一致(默认 115200)
多任务同时 printf 输出错乱printf 不是线程安全的底层操作使用 ESP_LOGI 系列日志宏(内部加了互斥锁),或自定义互斥锁保护 printf

8. 学习要点总结#

8.1 核心知识点#

  1. ESP-IDF 构建系统:基于 CMake + Ninja,通过 idf_component_register 注册组件。与标准 CMake 的最大区别是 project.cmake 导入了 IDF 特有的构建逻辑
  2. 程序入口app_main() 是 ESP-IDF 特有的入口函数,运行在 FreeRTOS main 任务上下文中,而不是裸机 main()
  3. FreeRTOS 集成:ESP-IDF 默认使用 FreeRTOS 实时操作系统,app_main() 返回后调度器继续运行,程序不会”结束”
  4. 组件化架构:代码按功能模块划分为独立组件,通过 REQUIRES 声明依赖关系,构建系统自动处理编译顺序

8.2 关键 API#

API功能
idf_component_register()注册组件到构建系统
app_main()应用程序入口函数
printf()串口调试输出
esp_chip_info()获取芯片信息

8.3 下一步学习#

  • 理解 GPIO 配置(01_LED 章节)
  • 学习 FreeRTOS 任务管理(05_multitask 章节)
  • 掌握外设驱动开发(后续章节)

Tips#

最容易忽视的细节#

  1. app_main() 不是裸机的 main()。它跑在 FreeRTOS 任务上下文中,意味着 vTaskDelayxQueueReceive 等 RTOS API 都可以在这里直接使用——你从一开始就在写多任务程序。
  2. printf 是通过 UART 逐字节发送的。如果一次性 printf 超过 128 字节(UART FIFO 大小),任务会阻塞等待。这在单任务场景下无感,但多任务时可能导致优先级反转——低优先级任务因为 printf 阻塞而占着 CPU,高优先级任务反被延迟。
  3. sdkconfig 是编译时的”宪法”。很多宏开关(Wi-Fi 使能、FreeRTOS 任务栈大小、Flash 大小)都在 sdkconfig 中定义。修改 sdkconfig 后必须 idf.py build 全量重编,不能用增量编译。

踩坑记录#

  • “Hello World 跑起来了但我什么都没做”的错觉:实际上 ESP-IDF 在 app_main() 之前已经完成了大量初始化——两级 Bootloader 启动、FreeRTOS 内核初始化、IDLE 任务创建、esp_timer 初始化、日志系统初始化。你的 printf 之所以能工作,是因为整个系统框架已经就位。理解这一点,后续调试外设问题时就不容易迷茫——初始化失败的根源往往在框架层,不在你的代码里。
  • idf.py flashidf.py monitor 的端口冲突:当 monitor 连接着设备时,再开另一个终端执行 idf.py flash 会失败(串口被占用)。正确做法是用 Ctrl+] 退出 monitor,烧录后再重启 monitor,或者直接用 idf.py flash monitor 一条命令搞定。

与后续章节的知识衔接点#

  1. GPIO 章节(01_LED):理解 app_main() 任务上下文后,你可以在里面写无限循环,也可以创建新任务——这是后续所有外设编程的基础范式选择。
  2. NVS 章节:Hello World 的分区表中 NVS 分区已经就位,后续 Wi-Fi 配置、OTA 状态、校准参数都存储在这里。nvs_flash_init() 必须在 app_main() 开头调用——这也是本章 Flash 分区布局的直接应用。
  3. FreeRTOS 多任务章节:本章的 FreeRTOS 调度器启动流程是多任务编程的基石。理解 app_main() 就是一个任务,后续 xTaskCreate 就是”创建更多这样的任务”。
  4. Wi-Fi 章节printf 的数据流向(UART FIFO → TX 移位寄存器)和 Wi-Fi 的数据流向(lwIP → Wi-Fi MAC → 射频)是同一思路的不同载体——都是”应用层数据 → 硬件发送路径”的逐层传递。

设计决策思考#

  • 为什么 ESP-IDF 选择 CMake 而不是 Makefile? CMake 的跨平台特性比 Make 好得多——Windows、Linux、macOS 都能用同一套 CMakeLists.txt。ESP-IDF 的用户群体中 Windows 用户占比很高,Makefile 在 Windows 上依赖 MSYS2/MinGW,而 CMake 原生支持 Visual Studio 和 Ninja,体验更好。此外,CMake 的 find_package 和组件依赖管理也比 Makefile 的递归包含更清晰。
  • 为什么 ESP32-S3 有两级 Bootloader 而 51 单片机没有? 51 单片机的程序空间只有几 KB,用一个固定地址跳转就够了——没有 OTA、没有分区表、没有 Flash 校验的需要。ESP32-S3 的固件可能达到几 MB,需要支持 OTA 升级、固件回滚、安全启动、Flash 加密——这些逻辑 ROM Bootloader 放不下(ROM 只有几十 KB),必须有一个独立的 Second Stage Bootloader。
  • 为什么用 Ninja 而不是 Make? Ninja 的构建图是预编译的——CMake 先生成 .ninja 文件,Ninja 只负责”按图施工”(执行编译命令)。这比 Make 的递归解析 Makefile 快得多。在 ESP-IDF 的数百个源文件的编译中,每次增量编译 Ninja 都能比分出”哪些文件真的需要重编”——因为它的依赖分析在构建图生成阶段就完成了,不是运行时解析。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

ESP32-S3 Hello World 学习笔记
https://mjzy.tech/posts/embedded/esp32-s3/00-hello-world/
作者
ENKIDU
发布于
2026-08-05
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
ENKIDU
深度学习 & 计算机底层原理 | 用代码理解世界
低语
天之锁永系天与地,而这里系着文字与记忆。欢迎来到乌鲁克的数字荒原。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
49
分类
6
标签
45
总字数
180,771
运行时长
0
最后活动
0 天前
站点信息
构建平台
Vercel
文章许可
CC BY-NC-SA 4.0

文章目录