← 目录

打印会展开答案。浏览器打印对话框可选择“另存为 PDF”。

CHAPTER 00 / FIELD GUIDE

准备工作与采购

先把器材选对,再开始实验。

建议用时 30–45 分钟先预测,再实验,再解释

这套课程要带你做出什么

你以前学过硬件和嵌入式,现在从基础重新建立直觉。第一个成品是带屏幕的旋钮调光器:旋钮控制亮度,按键开关,屏幕显示百分比。第二个成品是按键启动、限时行驶的两轮机器人:能平缓启动、停止,并测量轮速。联网、闭环控制作为后续扩展。

主线用 ESP-IDF 和 C。每个实验先预测、再运行、再解释。单章可以拆成几个 20–30 分钟的小节。学习时间包含操作;安装、采购和排错可能额外花时间。

统一硬件与软件

主控固定为 ESP32-S3-DevKitC-1 N8R8:N8 指 8 MB Flash,R8 指 8 MB PSRAM。教程基线固定 ESP-IDF 5.5.x,避免混用旧 API。此处是教学版本选择,不声称它是最新版本。现有示例无需开启 PSRAM。

第一批数量购买规格与用途
开发板1ESP32-S3-DevKitC-1 N8R8,已焊排针,有原理图;不要买裸 WROOM 模块
USB 数据线1接口以实物为准;必须能传数据
面包板2830 孔、可拼接;宽板可跨两块安装
杜邦线各一组公公、公母、母母,约 20 cm
LED / 按键各 5 个普通红色 LED;适合面包板的轻触按键
直插电阻各 10 个330 Ω、1 kΩ、4.7 kΩ、10 kΩ;¼ W
电位器110 kΩ,带三根可接线引脚
OLED1SSD1306、128×64、四针 I²C、3.3 V、已焊排针、模块自带上电复位
万用表1直流电压、电阻、通断蜂鸣档;带表笔
可选测量夹线几根方便固定表笔,避免滑动短路

第一批预算先预留 200–350 元,属于估算而非实时商品报价。可以先不买传感器套装、彩屏、示波器、电烙铁。

第二批约束
两轮底盘与减速电机带安装件、轮子和万向轮;优先提供堵转电流和编码器规格的低功率电机
DRV8833 双路模块标明实际持续电流能力;必须覆盖所选电机启动/堵转条件,不能按峰值选型
电源与开关按电机电压选择;本课可采用适配 4 节 AA 电池的电机,电池盒带独立开关
电容与接线模块附近预留 100–470 μF 电解电容,额定电压高于供电;电机线走接线端子
编码器明确 3.3 V 兼容输出、电源要求、减速比和脉冲数定义;不要把 5 V 推挽信号接 GPIO

到货检查

  1. 记录板上模块型号、板卡版本、USB 接口名称。保存商品原理图和引脚图。
  2. 不接外设,检查焊点与引脚有无短接。
  3. 用万用表通断档确认面包板内部哪些孔连通;电源长条可能在中间断开。
  4. 按第 03 章运行 hello;成功之后再接外设。
停下来想一想

为什么采购时“ESP32 开发板”这几个字还不够?

查看推导与答案

ESP32 是一个系列。芯片、板卡版本、接口和引脚占用都可能不同。编译目标与接线必须对应同一实物。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 01 / FIELD GUIDE

电路基础:让电流走一圈

电压、电流、电阻、地与测量。

建议用时 45–60 分钟先预测,再实验,再解释

先恢复三个直觉

电压是两点之间的电势差,所以说“这个点 3.3 V”默认相对 GND。电流是电荷流动的速率,必须有闭合路径。电阻限制同样电压下的电流。GND 是电路参考点和回流路径,不必然等于建筑物的保护地。

对电阻,有 I = V / R。LED 不是普通电阻:正向导通后压降随电流变化,所以使用串联电阻限制电流。

第一个无代码实验

红色 LED 假设正向压降约 2.0 V,电阻承受约 1.3 V,电流约 1.3 / 330 = 3.9 mA。实际压降以元件与测量为准。LED 长脚通常是阳极,短脚和外壳平边通常指示阴极;剪过脚的元件不要仅靠脚长判断。

动手算:换电阻会发生什么?

模型固定电源 3.3 V、LED 压降 2.0 V。仅用于理解趋势,不是 LED 的精确模型。

  1. 断开 USB,按上面的路径接线。LED 两脚不能落在面包板同一个导通组。
  2. 确认电源正负没有短接,再插 USB,观察 LED。
  3. 万用表黑笔插 COM、红笔插 VΩ,选直流电压档;黑笔接 GND,红笔测 3V3。
  4. 测电阻两端电压,再用 I = V/R 推算电流。
  5. 断电后把电阻改为 1 kΩ,再比较亮度。不要带电换线。
现象先检查
完全不亮电源电压、LED 极性、面包板孔位、电阻值
异常发热立即断电;检查短路和遗漏电阻
万用表电压为负表笔参考方向可能相反,不代表电源坏了
停下来想一想

串联电阻放在 LED 前面或后面,限流效果有什么区别?

查看推导与答案

在同一个串联回路中电流相同,两种位置都能限流。关键是没有另一条路径绕过电阻。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 02 / FIELD GUIDE

单片机里面有什么

从电脑上的源代码走到引脚电平。

建议用时 30 分钟先预测,再实验,再解释

四层对象不要混淆

对象在本课中是什么作用
芯片 / SoCESP32-S3执行程序,带 GPIO、定时器等外设
模组ESP32-S3-WROOM-1-N8R8集成芯片及相关存储、射频电路
开发板DevKitC-1提供供电、USB、按键和排针
框架 / SDKESP-IDF驱动、系统服务和构建工具

程序在哪里运行

你的电脑运行编译器,把 C 代码变成适合 S3 的机器指令。烧录工具通过 USB 路径把固件写进 Flash。复位后,芯片的启动代码寻找并启动应用,最终调用 app_main。不是电脑每执行一行 C,就遥控芯片执行一行。

源代码 .c编译 / 链接固件 .binFlash启动 → app_main

Flash 断电后保留程序;RAM 保存运行中的变量、栈和缓冲区,断电不保留。PSRAM 是额外的外部 RAM,使用前需要对应配置;它不能替代掉电保存配置的 NVS。

外设为什么重要

CPU 负责决策,外设负责专门工作。GPIO 读写电平;PWM 外设按节奏切换引脚;ADC 把电压转成数值。使用 PWM 时,CPU 不必在每一个波形边沿都运行代码。稍后把屏幕刷新和电机控制分开,也是在分配责任。

纸上实验

  1. 写下“源文件保存”“编译”“烧录”“复位”四个动作。
  2. 假设只修改电脑里的文件但不烧录,预测板子会运行哪个版本。
  3. 假设重新插电,预测普通变量与 Flash 中程序各会怎样。
停下来想一想

代码里 count 从 0 加到 100,拔电后再上电,它会从 100 继续吗?

查看推导与答案

普通 RAM 变量会重新初始化。若要继续,需要主动把状态保存到非易失存储,并在启动时读取。不能每次加一就无节制写 Flash。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 03 / FIELD GUIDE

安装环境与第一次烧录

把编辑、构建、下载和日志连起来。

建议用时 60–90 分钟先预测,再实验,再解释

选择一条安装路径

主路径使用 VS Code 的 Espressif ESP-IDF 扩展,选择 ESP-IDF 5.5 系列及 ESP32-S3 目标。扩展界面可能随版本变化,以官方安装说明为准。安装完成后使用扩展提供的 ESP-IDF Terminal,避免系统 Python 和工具链环境混在一起。Windows 使用官方安装器或同一扩展,不要直接照抄 macOS shell 命令。

macOS/Linux 如果选择手动安装,可按下面顺序执行;先按官方页面准备 Git、Python、CMake 等前置依赖。不要同时混装多套然后猜哪套在生效。

mkdir -p ~/esp
cd ~/esp
git clone -b v5.5 --recursive https://github.com/espressif/esp-idf.git
cd esp-idf
./install.sh esp32s3
. ./export.sh
idf.py --version

上面的 v5.5 是固定教学起点;使用 5.5.x 补丁版时记录实际版本。每个新终端都需要激活环境。教程没有替你在电脑上安装工具链。

打开随教程附带的工程

下载 examples.zip 并完整解压。进入 examples/01_hello,而不是 components 目录。该工程的最小结构是:

examples/
  components/lab/       # 后续实验共用的驱动源码
  01_hello/
    CMakeLists.txt
    sdkconfig.defaults
    main/
      CMakeLists.txt
      main.c
# 在已激活的 ESP-IDF 终端中,先 cd 到 01_hello 目录
idf.py set-target esp32s3
idf.py build
# macOS 查端口;Windows 在设备管理器中查看 COM 编号
ls /dev/cu.*
# 把 YOUR_PORT 替换成实际端口,例如 /dev/cu.usbserial-xxxx
idf.py -p YOUR_PORT flash monitor

优先使用板上标识为 USB-to-UART 的接口。不要同时连接两个 USB 供电口。监视器里应每秒出现一次递增数字。退出监视器用 Ctrl+]。占用串口的监视器退出后,其他工具才能可靠打开同一端口。

实验工程 · 01_hello

最小结果是串口每秒输出一次计数。先把这条链路跑通,再接外设。

打开 main.c · 查看公共驱动 lab.c · 下载全部工程

#include "esp_log.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
void app_main(void) {
    for(int n=0;; n++) {
        ESP_LOGI("hello","boot is alive: %d",n);
        vTaskDelay(pdMS_TO_TICKS(1000));
    }
}
故障处理顺序
idf.py 找不到激活当前终端的 ESP-IDF 环境
没有串口换数据线和电脑接口 → 查板载 USB 转串口芯片 → 按该芯片官方说明装驱动
Connecting 一直等待核对端口;按住 BOOT,点按 RESET,开始连接后松开 BOOT
编译提示目标不符进入正确工程;执行 set-target esp32s3,它会重新配置构建
日志乱码确认监视器与固件串口波特率;默认示例使用 115200
停下来想一想

为什么“找不到串口”时应该先换数据线,而不是重装整个 ESP-IDF?

查看推导与答案

没有数据通路时软件重装不会改变结果。按供电、物理连接、端口枚举、下载协议的层次定位,能更快缩小问题。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 04 / FIELD GUIDE

够用的 C 语言复习

读懂驱动配置和状态变量。

建议用时 45 分钟先预测,再实验,再解释

先掌握会立即用到的部分

写法意义本课用途
int / bool / uint8_t整数、布尔值、8 位无符号数计数、开关、屏幕字节
struct把相关字段放在一起GPIO / PWM 配置
&x变量 x 的地址让函数读取配置或写回结果
*p通过指针访问对象理解 API 参数
static局部变量跨函数调用保留值按键上一次状态
const不能经此名字修改初始化命令表
1ULL << n把第 n 位设为 1选择 GPIO 位掩码
gpio_config_t cfg = {
    .pin_bit_mask = 1ULL << 4,
    .mode = GPIO_MODE_OUTPUT,
};
gpio_config(&cfg);

上面先创建一个配置对象,再把地址交给驱动函数。未显式给出的结构体字段按零初始化。1ULL 是足够宽的无符号整数,不能随意改成普通 1 后再移到很高的位。

整数除法是常见陷阱

int raw = 2048;
int wrong = raw / 4095 * 100;   // 先除后乘,结果为 0
int percent = raw * 100 / 4095; // 先乘后除,结果为 50
float ratio = raw / 4095.0f;    // 浮点比例

本例乘法不会溢出 int;更大计数值要先分析范围,必要时用 int64_t。数组下标从 0 开始,长度为 10 的数组最后一项是 [9]。局部数组不能在函数返回后继续通过旧指针访问。

练习:只改 hello 工程

  1. 添加 int count = 0,在循环里递增并打印。
  2. 增加 bool enabled,每次循环取反,打印它的状态。
  3. 先算出 raw 为 0、2048、4095 时 percent 的结果,再用日志验证。
  4. 把重复计算提取为函数 int to_percent(int raw)。
停下来想一想

一个函数里的 int count = 0 与 static int count = 0 有什么不同?

查看推导与答案

普通局部 count 每次进入时重新初始化;static 局部对象在程序运行期间保留状态。但 static 不提供线程同步,多任务同时写它仍可能出错。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 05 / FIELD GUIDE

GPIO 输出:程序点亮 LED

第一次让软件改变真实电路。

建议用时 35 分钟先预测,再实验,再解释

GPIO 是受控的电平端口

输出模式下,我们让引脚输出高或低电平。电流能力有限,因此这里仅驱动串联电阻的小 LED。电机、继电器、大功率灯需要驱动电路。

接线

GPIO4→330 Ω→LED 阳极 / 阴极→GND

与第 01 章相比,把电源端从 3V3 换成 GPIO4。这里的 4 是丝印 GPIO 编号,不是从排针顶端数第四个孔。板载 RGB 灯涉及不同协议和版本引脚,先不使用。

实验工程 · 02_blink

LED 亮 500 ms、灭 500 ms。一个完整周期是 1 秒。

打开 main.c · 查看公共驱动 lab.c · 下载全部工程

#include "lab.h"
void app_main(void) {
    led_init();
    for(;; ) {
        gpio_set_level(LED,1);
        vTaskDelay(pdMS_TO_TICKS(500));
        gpio_set_level(LED,0);
        vTaskDelay(pdMS_TO_TICKS(500));
    }
}

看懂公共驱动

打开 lab.c 中的 led_init:设置 GPIO4 为输出并初始化为低电平。gpio_set_level 修改输出;vTaskDelay 暂停当前任务并让其他任务有机会运行。

  1. 断电接线,再检查电阻和极性。
  2. 烧录 02_blink,观察至少 10 个周期。
  3. 分别改为亮 100 ms、灭 900 ms,预测周期后验证。
  4. 用电压档测 GPIO4 对 GND。数字万用表刷新慢,不一定显示每个瞬间。
停下来想一想

亮 100 ms、灭 900 ms,与亮 500 ms、灭 500 ms,闪烁频率相同吗?

查看推导与答案

都是 1 Hz,因为周期都是 1000 ms。占空比分别是 10% 和 50%,频率与占空比是两个量。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 06 / FIELD GUIDE

GPIO 输入:按键与消抖

从“读到电平”到“一次有效操作”。

建议用时 45 分钟先预测,再实验,再解释

没按时必须有确定电平

按键一端接 GPIO5,另一端接 GND,软件启用内部上拉。松开时输入为 1,按下时接地为 0。如果既没有上拉也没有下拉,开路输入会漂浮,读数可能不稳定。

四脚轻触按键有两组内部相连的引脚。断电时用通断档确定分组,选择按下后才互相连通的两端,不凭外形猜。

机械触点会抖动

一次按压可能在几毫秒内产生多次 0/1 变化。我们要求输入连续稳定 30 ms,才接受新状态;只有稳定状态从松开变成按下时,产生一次事件。

采样值改变 → 记录改变时间
保持同一采样值至少 30 ms → 更新稳定状态
稳定状态变成 0 → 发出一次“按下”事件

实验工程 · 03_button

每按一次翻转 LED;长按不会不停翻转。button_pressed 的完整实现位于 lab.c。

打开 main.c · 查看公共驱动 lab.c · 下载全部工程

#include "lab.h"
#include "esp_log.h"
void app_main(void) {
    led_init();
    button_init();
    bool on=false;
    for(;; ) {
        if(button_pressed()) {
            on=!on;
            gpio_set_level(LED,on);
            ESP_LOGI("button","on=%d",on);
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

按顺序检查行为

  1. 慢按 10 次,检查翻转次数是否一致。
  2. 持续按住 2 秒,确认只有一次翻转。
  3. 快速按两次,观察能否区分;如果间隔短于消抖窗口,可能被合并。
  4. 把窗口改成 5 ms、100 ms,比较误触发和延迟。每次只改一个参数。
停下来想一想

为什么只写 if(gpio_get_level(5)==0) on=!on 不够?

查看推导与答案

按住期间每次循环都会翻转,还叠加机械抖动。消抖解决信号不稳定,边沿事件解决一次按压只触发一次,两件事都需要。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 07 / FIELD GUIDE

时间与状态机

让灯闪烁,同时仍能响应按键。

建议用时 40 分钟先预测,再实验,再解释

一次循环做一点事

若在处理按键的同一个循环里延迟 2 秒,这 2 秒内就不会再次读按键。解决办法是记住截止时间,每轮检查“时间到了吗”,完成小步工作后马上继续。

int64_t changed = now_ms();
bool on = false;
for (;;) {
    if (now_ms() - changed >= 500) {
        changed = now_ms();
        on = !on;
        gpio_set_level(LED, on);
    }
    if (button_pressed()) {
        // 这里处理一次按下事件
    }
    vTaskDelay(pdMS_TO_TICKS(10));
}

这是循环片段,放进已初始化 LED 和按键的 app_main 中,不是单独工程。now_ms 使用单调计时;不要用网络校准后的日历时间计算控制周期。

状态机让行为可描述

当前状态事件动作下一状态
停止按下记录开始时间,打开输出运行
运行按下关闭输出停止
运行超时关闭输出停止
任意检测到故障关闭输出并记录故障故障

先写转移表再写 if,比堆多个相互覆盖的布尔变量容易检查。故障状态是否允许再次启动,应明确规定;真实机器人不应在故障解除后自动重新运动。

实验

  1. 从 03_button 复制一个新工程,保留公共组件目录。
  2. 把闪烁计时加入循环;按键只切换是否允许闪烁。
  3. 关闭闪烁时立即将 LED 置 0。
  4. 记录最慢按键响应:它至少受到采样周期、30 ms 消抖和循环中最长工作时间影响。
停下来想一想

循环里没有 delay,就一定响应快吗?

查看推导与答案

不一定。屏幕传输、网络等待、长计算一样会阻塞。还可能因为忙循环占满 CPU 而影响其他任务。需要分析每段工作的最长耗时。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 08 / FIELD GUIDE

ADC 与 PWM:旋钮调光

把模拟输入变成可控输出。

建议用时 60 分钟先预测,再实验,再解释

两个方向的转换

ADC 把输入电压转换成数字;PWM 则通过快速开关控制平均作用。PWM 不是让 GPIO 输出真正连续的中间电压。LED 的视觉亮度也不与占空比严格线性。

电位器接线

两端分别接 3V3 和 GND,中间滑动端接 GPIO1。不确定滑动端时,断电测阻值:两个固定端之间约 10 kΩ,中间端到任一固定端随旋转改变。GPIO1 在 S3 上对应 ADC1 通道 0。

理解频率、分辨率和占空比

这里选 5 kHz、10 位 PWM,使用 0–1023 的保守占空比计数范围。频率越高,能选择的分辨率越受时钟限制。ESP32-S3 的 LEDC 使用 low-speed 模式。

拖动占空比,观察一周期

实验工程 · 04_knob

先读取一次作为滤波初值,每 10 ms 采样;七份旧值加一份新值,降低读数抖动。

打开 main.c · 查看公共驱动 lab.c · 下载全部工程

#include "lab.h"
#include "esp_log.h"
void app_main(void) {
    adc_init();
    pwm_init();
    int filtered=adc_read_raw();
    int64_t log_at=0;
    for(;; ) {
        filtered=(filtered*7+adc_read_raw())/8;
        pwm_set(filtered*1023/4095);
        if(now_ms()-log_at>=500) {
            log_at=now_ms();
            ESP_LOGI("knob","raw=%d",filtered);
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

做一次小记录

记录旋钮左端、中点、右端的 raw 和亮度;再移除滤波比较波动。滤波越强,显示越稳,但变化越慢。中点不一定正好是 2048,这可以成为测量与校准的讨论起点。

停下来想一想

把 5 kHz 改成 10 kHz,同时保持 50% 占空比,LED 会一定亮一倍吗?

查看推导与答案

不会。理想情况下平均导通比例不变。改变频率改变的是每秒开关次数,改变占空比才直接改变导通时间占比。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 09 / FIELD GUIDE

总线与 I²C 扫描

先确认设备存在,再谈显示内容。

建议用时 40 分钟先预测,再实验,再解释

为什么不是每个像素接一根线

模块里的控制器负责内部细节,主控通过约定协议发数据。你需要同时核对电气层、协议和数据含义。

接口常见信号本课中的位置
UARTTX、RX、GND日志;连接两个设备时通常 TX 接对方 RX
I²CSDA、SCL、GND多个带地址的设备共享数据与时钟线
SPISCLK、MOSI、可选 MISO、CS后续彩屏的常见接口,设备常用独立片选

屏幕接线

OLED 丝印开发板
VCC3V3
GNDGND
SDAGPIO8
SCLGPIO9

按丝印接,不按排针位置接。I²C 需要上拉:模块若已有连到 3.3 V 的上拉电阻,就先用它;否则 SDA 和 SCL 分别通过 4.7 kΩ 上拉到 3V3。不要重复堆很多上拉,也不要把线拉到 5 V。

实验工程 · 05_scan

扫描普通 7 位地址范围,找到应答地址。SSD1306 模块常见 0x3C 或 0x3D。

打开 main.c · 查看公共驱动 lab.c · 下载全部工程

#include "lab.h"
#include "esp_log.h"
void app_main(void) {
    i2c_master_bus_handle_t b=bus_init();
    for(int a=0x08; a<=0x77; a++) {
        esp_err_t e=i2c_master_probe(b,a,50);
        if(e==ESP_OK)ESP_LOGI("scan","found 0x%02x",a);
        else if(e!=ESP_ERR_NOT_FOUND)ESP_LOGW("scan","0x%02x: %s",a,esp_err_to_name(e));
    }
    ESP_LOGI("scan","done");
}
结果解释与下一步
找到 0x3C / 0x3D电气连接和地址应答基本可用;不代表屏幕已初始化
没有找到测供电 → 查 SDA/SCL → 查上拉和共地 → 查模块型号
多处超时总线可能被持续拉低;先断电检查短路、模块与电压
资料写 0x78可能是含读写位的 8 位表示;本 API 使用未左移的 7 位地址
停下来想一想

两个相同地址的模块能直接挂在同一条 I²C 总线上并分别控制吗?

查看推导与答案

通常不能。需要改其中一个地址、使用另一条总线或 I²C 多路复用器。扫描看到一个地址不能证明只有一个设备。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 10 / FIELD GUIDE

OLED:从像素到数字

自己看懂最小屏幕驱动。

建议用时 60 分钟先预测,再实验,再解释

显示内容先存在内存里

128×64 的黑白屏每个像素一位,一帧需要 128×64÷8 = 1024 字节。本教程的 frame 数组存储这一帧,oled_rect 改变内存,oled_show 才把内容送到屏幕。

公共驱动采用 SSD1306 的页组织:每字节对应同一列连续 8 个像素。位置 (x,y) 的数组下标为 (y/8)×128+x,对应位为 y%8。理解这个映射,就能解释为什么字符最终也只是像素。

先画矩形,再画数字

教程提供自己实现的七段数字,避免安装第三方字体库。它支持 0–9 和矩形,不提供汉字或通用字体。先完成读数显示,彩屏与 LVGL 放在结课扩展。

实验工程 · 06_display

屏幕每 200 ms 后更新计数和条形图。传输本身也需要时间,因此这不是精确的 5 Hz 定时器。

打开 main.c · 查看公共驱动 lab.c · 下载全部工程

#include "lab.h"
void app_main(void) {
    oled_init();
    unsigned n=0;
    for(;; ) {
        oled_clear();
        oled_number(30,10,n%10000,4);
        oled_rect(4,48,n%120,6);
        oled_show();
        n++;
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}

阅读驱动的三个入口

  1. oled_init:查地址、配置设备、发送初始化命令。只在启动时做一次。
  2. oled_rect / oled_number:修改 frame。绘图阶段不访问总线。
  3. oled_show:设置显示窗口,分块发送数据。0x00 前缀表示命令流,0x40 表示显示数据。

实验

先将计数固定为 1234,确认每个数字;再单独画四角的小方块,验证坐标和方向;最后让条形长度随计数变化。不要一开始就把字体、动画、输入和网络同时加进来。

停下来想一想

如果调用 oled_number 后忘记 oled_show,会发生什么?

查看推导与答案

内存缓冲区变化,但屏幕仍显示上一次发送的画面。绘制与提交是两个独立步骤。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 11 / FIELD GUIDE

项目一:桌面调光器

把旋钮、按键、LED 和屏幕连成产品。

建议用时 60–90 分钟先预测,再实验,再解释

先定义用户看见的行为

旋钮决定亮度百分比;按键切换输出开关;OLED 显示 000–100 和亮度条,左上角实心方块表示输出已启用。关闭时保留旋钮读数,但 LED 熄灭。

模块引脚 / 协议更新节奏
LEDGPIO4,经 330 Ω 到 LED / GNDPWM 硬件持续运行
按键GPIO5 到 GND,内部上拉主循环采样 + 30 ms 消抖
旋钮中点 GPIO1,两端 3V3/GND约每 10 ms 加循环耗时
OLEDSDA8 / SCL9约 200 ms 触发刷新

实验工程 · 07_dashboard

这是第一个综合成品。所有外设接线与前面单独实验一致。

打开 main.c · 查看公共驱动 lab.c · 下载全部工程

#include "lab.h"
#include "esp_log.h"
void app_main(void) {
    button_init();
    adc_init();
    pwm_init();
    oled_init();
    bool enabled=true;
    int value=adc_read_raw();
    int64_t screen_at=0;
    for(;; ) {
        if(button_pressed()) {
            enabled=!enabled;
            ESP_LOGI("ui","enabled=%d",enabled);
        }
        value=(7*value+adc_read_raw())/8;
        int percent=value*100/4095;
        pwm_set(enabled?value*1023/4095:0);
        if(now_ms()-screen_at>=200) {
            screen_at=now_ms();
            oled_clear();
            oled_number(40,8,(unsigned)percent,3);
            if(enabled)oled_rect(4,4,6,6);
            oled_rect(4,46,percent*120/100,8);
            oled_show();
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

分析它的限制

OLED 100 kHz 传输一帧的理论位传输时间已经接近 100 ms,实际还有协议开销。这段时间主循环不读按键,极短的点击可能漏掉。此项目适合慢速人机交互;第 12 章会用任务和队列隔离采样与显示。

验收矩阵

操作预期
旋钮最小到最大数字和亮度条整体递增,LED 随之变化
输出开启时按键方块消失,LED 熄灭
关闭时旋转数字仍更新,LED 保持熄灭
再次按键恢复当前旋钮设定,而非旧的固定亮度
断电重启按当前代码默认开启;下一章扩展可设计为默认关闭

独立修改任务

把上电默认状态改为关闭;增加一个“锁定读数”的状态;为每个状态写出操作表,再实现。先保留原工程,给修改版单独目录,便于比较。

停下来想一想

为什么屏幕显示 50% 时,不能承诺人眼看到的亮度正好是 100% 的一半?

查看推导与答案

显示的是控制量比例,视觉感受、LED 特性和实际驱动电流都影响主观亮度。产品界面需要说明数值代表什么。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 12 / FIELD GUIDE

FreeRTOS:任务、队列与中断

让慢屏幕不拖住采样。

建议用时 60 分钟先预测,再实验,再解释

先给数据明确的主人

采样任务拥有 ADC,显示任务拥有 OLED。采样把数值放进队列,显示取出副本。这样避免两个任务同时操作同一个驱动,也不用让显示任务读一个不断被修改的全局对象。

一个长度为 1 的队列适合“只关心最新值”:新的样本覆盖旧样本。按键事件、交易记录这类不能丢的数据应使用事件队列,并明确队列满了如何处理。

实验工程 · 08_tasks

采样任务约 20 ms 一次;显示任务按较慢节奏读取最新值。vTaskDelayUntil 让周期基于计划时刻,而非上次工作结束时刻。

打开 main.c · 查看公共驱动 lab.c · 下载全部工程

#include "lab.h"
#include "freertos/queue.h"
#include "esp_log.h"
static QueueHandle_t samples;
static void sample_task(void *arg) {
    (void)arg;
    adc_init();
    TickType_t next=xTaskGetTickCount();
    for(;; ) {
        int raw=adc_read_raw();
        xQueueOverwrite(samples,&raw);
        vTaskDelayUntil(&next,pdMS_TO_TICKS(20));
    }
}
static void display_task(void *arg) {
    (void)arg;
    oled_init();
    int raw;
    for(;; ) {
        if(xQueueReceive(samples,&raw,portMAX_DELAY)==pdTRUE) {
            oled_clear();
            oled_number(28,16,(unsigned)raw,4);
            oled_show();
        }
        vTaskDelay(pdMS_TO_TICKS(200));
    }
}
void app_main(void) {
    samples=xQueueCreate(1,sizeof(int));
    assert(samples);
    BaseType_t a=xTaskCreate(sample_task,"sample",4096,NULL,5,NULL);
    assert(a==pdPASS);
    BaseType_t b=xTaskCreate(display_task,"display",4096,NULL,3,NULL);
    assert(b==pdPASS);
    ESP_LOGI("tasks","two tasks created");
}

三种机制的分工

机制适合不适合
轮询按键、低速状态检查高频窄脉冲计数
任务正常业务逻辑、可能等待的 I/O假设优先级高就永远不会超时
中断快速记录事件,通知任务刷屏、长日志、阻塞等待

中断打断正常执行,需要简短。需要把事件交给任务时,使用对应 FromISR API,而不是普通阻塞接口。编码器计数优先使用硬件 PCNT,不必为每个脉冲都运行复杂中断逻辑。

volatile 不能替代互斥锁或队列;它不使“读、修改、写”变成原子操作。多核下更不能依赖“应该不会刚好同时发生”。ESP-IDF 的任务栈大小参数以字节计。

动手验证

  1. 运行 08_tasks,旋转电位器确认显示更新。
  2. 把显示等待增大到 1 秒,采样任务保持 20 ms。
  3. 在采样任务每 50 次打印一次计数,确认没有随显示等待变成每秒一次采样。
  4. 不在每次采样中打印,避免日志本身改变测量对象。
停下来想一想

队列长度为 1,显示任务读取太慢时会看到每一个中间值吗?

查看推导与答案

不会,xQueueOverwrite 刻意保留最新值。这适合仪表读数,不适合必须逐条处理的按钮事件。数据语义决定队列策略。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 13 / FIELD GUIDE

联网与保存配置

先连通、再服务,最后考虑持久化。

建议用时 60–90 分钟先预测,再实验,再解释

联网先学分层定位

连接接入点、获得 IP、连接服务器、应用协议成功是不同阶段。S3 的 Wi-Fi 使用 2.4 GHz;确认路由器提供兼容网络。不要把“Wi-Fi 已连接”当成“HTTP 请求必然成功”。

使用固定版本的官方完整工程

联网涉及事件注册和多个初始化步骤,直接从安装目录中随版本提供的完整例程开始,避免复制不完整的回调片段。下面给出 macOS/Linux 操作;Windows 在文件管理器中复制同名目录,再在 ESP-IDF 终端中执行后续 idf.py 命令。

# 先激活 ESP-IDF;确认当前目录下没有要覆盖的同名文件夹
mkdir -p ~/esp-labs
cp -R "$IDF_PATH/examples/wifi/getting_started/station" ~/esp-labs/wifi_station
cd ~/esp-labs/wifi_station
idf.py set-target esp32s3
idf.py menuconfig
# Example Configuration 中配置 SSID 与密码
idf.py build
idf.py -p YOUR_PORT flash monitor

验收:日志中出现获得 IP 的事件。关闭路由器再打开,观察例程的重试行为;它可能有重试次数上限。把“断线后最终放弃”和“持续退避重试”区分开,按实际产品需要修改。

第二个实验:HTTP 服务

cp -R "$IDF_PATH/examples/protocols/http_server/simple" ~/esp-labs/http_simple
cd ~/esp-labs/http_simple
idf.py set-target esp32s3
idf.py menuconfig
# 按该工程 README 配置 Wi-Fi 连接
idf.py build
idf.py -p YOUR_PORT flash monitor

电脑或手机连同一局域网,从日志读取设备 IP,在浏览器访问 http://设备IP/hello。若路由器启用访客隔离,客户端可能互相无法访问。先确认官方示例响应,再把响应改成一个固定的设备状态字段;不要一开始就在 HTTP 回调里直接控制电机。

NVS:只在需要时写配置

用官方 examples/storage/nvs/nvs_rw_value 做掉电保存实验,复制到新目录后采用同样的构建流程。理解初始化、打开命名空间、读取、设置、提交和关闭的顺序。设置值后需要 commit;读取不到键时提供默认值。不要为每个 ADC 样本写 Flash。

// API 顺序示意,不是可独立编译的工程
nvs_flash_init();
nvs_open("settings", NVS_READWRITE, &handle);
nvs_get_i32(handle, "brightness", &value); // 不存在时走默认值
nvs_set_i32(handle, "brightness", value);
nvs_commit(handle);
nvs_close(handle);
停下来想一想

为什么不在旋钮每变化一次时立即保存配置?

查看推导与答案

旋钮与 ADC 会不断抖动,这会产生大量无意义写入。可以在用户确认保存或停止调整一段时间后,再比较新旧值并写入。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 14 / FIELD GUIDE

电机与供电

先控制一个轮子,再装整车。

建议用时 60 分钟先预测,再实验,再解释

GPIO 发命令,驱动器提供电流

电机启动和堵转时电流可能远大于正常运行值。DRV8833 用 H 桥切换电机端子极性,从而正反转;板子的持续电流能力由芯片封装、散热和布局共同决定。选购时用真实电机电压和堵转电流匹配。

接线基线

接点连接
电池正极经物理开关DRV8833 VM / VIN,按模块丝印
电池负极驱动 GND
ESP32 GND驱动 GND,共地
ESP32 供电单独 USB,不把电池正极连到开发板
AIN1 / AIN2GPIO10 / GPIO11
BIN1 / BIN2GPIO12 / GPIO13,单轮实验暂不接电机
AOUT1 / AOUT2左电机两根线
BOUT1 / BOUT2右电机两根线,后续使用
nSLEEP(若引出)核对模块电路,拉到有效高电平;可用 10 kΩ 上拉到 3V3
AIN1/2、BIN1/2各加 10 kΩ 下拉到 GND,使复位期间输入确定为低

电机电流不要经过面包板细小电源轨和开发板。电池、驱动、马达用可靠接线端子和适当线径;地线尽量让电机大电流回路直接返回电池。VM 附近按模块要求放去耦电容;加电解电容时核对极性和耐压。

IN1IN2结果(DRV8833)
00滑行停止,高阻输出
10一个方向
01相反方向
11制动

实验工程 · 09_motor

先把轮子架空。按一下按钮,仅左电机以约 29% 指令运行 500 ms 后停止。若低占空比无法克服静摩擦,不要把轮子卡住长时间通电。

打开 main.c · 查看公共驱动 lab.c · 下载全部工程

#include "lab.h"
#include "esp_log.h"
void app_main(void) {
    button_init();
    motor_init();
    motor_set(0,0);
    int64_t stop_at=0;
    bool running=false;
    for(;; ) {
        if(button_pressed()&&!running) {
            motor_set(300,0);
            running=true;
            stop_at=now_ms()+500;
        }
        if(running&&now_ms()>=stop_at) {
            motor_set(0,0);
            running=false;
            ESP_LOGI("motor","stopped");
        }
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}
停下来想一想

为什么电机一启动,ESP32 就重启,但单独运行屏幕正常?

查看推导与答案

优先检查供电压降、回流路径和电机噪声。先把电机电源与逻辑供电分开并共地,检查接线与去耦,再考虑软件;仅修改延迟通常不能修复电源问题。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 15 / FIELD GUIDE

项目二:两轮机器人

完成有限、可停止的运动。

建议用时 90 分钟先预测,再实验,再解释

先明确这个版本的边界

本章成品是桌面调试的两轮小车:按键启动、缓慢增加输出、最多前进 2 秒,再自动停;运行中再按一次立即撤销输出。不包含自动导航或无人值守能力。先架空,之后在地面空旷区域低速测试。

差速转向

两轮同向且速度接近时前进;左慢右快向左转,反之向右转。左右轮反向可原地转动。用有符号命令表达方向:正数前进、负数后退、0 停止。它是电机输出命令,不是实际速度。

实验工程 · 10_robot

使用第 14 章两路接线。上电默认停止;正向指令每 10 ms 增加 10,最高 350/1023。

打开 main.c · 查看公共驱动 lab.c · 下载全部工程

#include "lab.h"
#include "esp_log.h"
// Wheels raised for initial tests. Button starts/stops a bounded forward movement.
// Physical motor-power switch remains the independent emergency stop.
void app_main(void) {
    button_init();
    motor_init();
    motor_set(0,0);
    bool running=false;
    int duty=0;
    int64_t stop_at=0;
    TickType_t next=xTaskGetTickCount();
    for(;; ) {
        if(button_pressed()) {
            running=!running;
            stop_at=now_ms()+2000;
        }
        if(now_ms()>=stop_at)running=false;
        if(running) {
            duty+=10;
            if(duty>350)duty=350;
        }
        else duty=0;
        motor_set(duty,duty);
        vTaskDelayUntil(&next,pdMS_TO_TICKS(10));
    }
}

装车前校准方向

  1. 架空轮子,先用 09_motor 检查左侧的正向。
  2. 断电后核对右侧接线;在单轮工程中用 motor_set(0,300) 检查右侧。
  3. 由于左右镜像安装,同样电机接线不一定得到同样车体方向。断电后交换某侧两根电机线,或明确在软件中反号。
  4. 运行 10_robot,确认两侧都朝车体前方转动。
  5. 地面低速测试;如果偏航,记录偏向,下一章用编码器解释原因。

未来接遥控时的停车规则

网络命令必须经过控制任务。控制任务保存最后一次有效命令的时间,超过例如 300 ms 未收到心跳就把目标设为 0;拒绝过期、格式错误和越界命令。网络回调只更新消息,不直接写 PWM。超时值要按网络质量和停车距离验证。

// 设计示意:只有控制任务写电机,不能直接替代本章工程
if (fault || now_ms() - last_valid_command_ms > 300) {
    target_left = 0;
    target_right = 0;
}
// 然后限幅、限制变化率,再输出。方向切换前先停稳。

软件超时不是独立急停。如果控制任务卡死或系统崩溃,硬件 PWM 可能继续输出。因此物理电源开关必须保留;更高要求需要独立硬件看门狗 / 驱动使能设计。

停下来想一想

左右轮都给 350,车为什么仍然走不直?

查看推导与答案

同样 PWM 不等于同样轮速。电机差异、摩擦、负载、轮径和供电都会影响实际速度。要更稳定,需要测量轮速并反馈调节。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 16 / FIELD GUIDE

编码器与闭环入门

先把速度测准,再调控制器。

建议用时 60–90 分钟先预测,再实验,再解释

脉冲数的单位必须说清

编码器可能标注电机轴每转脉冲数,也可能给减速后输出轴的计数;还可能按单边沿或四倍频计算。把这些混在一起,速度会差几倍甚至几十倍。

本章只对 A 相上升沿计数,B 相不接,测量速度大小,不测方向。先确认编码器供电和输出可兼容 3.3 V:A 接 GPIO6、GND 共地。若输出是开集电极,按模块要求上拉到 3V3;5 V 推挽输出需要合适电平转换。

实验工程 · 11_encoder

先用手慢慢转动轮子,观察累计脉冲和每秒脉冲数。PCNT 硬件负责计数,软件每 200 ms 读取一次。

打开 main.c · 查看公共驱动 lab.c · 下载全部工程

#include "lab.h"
#include "driver/pulse_cnt.h"
#include "esp_log.h"
// Count rising edges on A only. B is not used: this example measures magnitude, not direction.
void app_main(void) {
    pcnt_unit_config_t u= {
        .low_limit=-30000,
        .high_limit=30000,
        .flags.accum_count=true
    };
    pcnt_unit_handle_t unit;
    ESP_ERROR_CHECK(pcnt_new_unit(&u,&unit));
    pcnt_chan_config_t c= {
        .edge_gpio_num=6,
        .level_gpio_num=-1
    };
    pcnt_channel_handle_t channel;
    ESP_ERROR_CHECK(pcnt_new_channel(unit,&c,&channel));
    ESP_ERROR_CHECK(pcnt_channel_set_edge_action(channel,PCNT_CHANNEL_EDGE_ACTION_INCREASE,PCNT_CHANNEL_EDGE_ACTION_HOLD));
    ESP_ERROR_CHECK(pcnt_unit_add_watch_point(unit,30000));
    ESP_ERROR_CHECK(pcnt_unit_add_watch_point(unit,-30000));
    pcnt_glitch_filter_config_t f= {
        .max_glitch_ns=1000
    };
    ESP_ERROR_CHECK(pcnt_unit_set_glitch_filter(unit,&f));
    ESP_ERROR_CHECK(pcnt_unit_enable(unit));
    ESP_ERROR_CHECK(pcnt_unit_clear_count(unit));
    ESP_ERROR_CHECK(pcnt_unit_start(unit));
    int last=0;
    int64_t last_us=esp_timer_get_time();
    for(;; ) {
        vTaskDelay(pdMS_TO_TICKS(200));
        int count=0;
        ESP_ERROR_CHECK(pcnt_unit_get_count(unit,&count));
        int64_t t=esp_timer_get_time();
        float pps=(count-last)*1000000.0f/(t-last_us);
        ESP_LOGI("encoder","count=%d pulses_per_second=%.2f",count,(double)pps);
        last=count;
        last_us=t;
    }
}

从计数变成速度

手动把输出轮转 10 圈,用累计计数变化除以 10,得到本接法下的每圈脉冲 P。采样周期为 Δt 秒、计数变化为 ΔN:

轮速 RPM = 60 × ΔN / (P × Δt)
线速度 m/s = (ΔN / P) × π × 轮径米 / Δt
例:P=600,Δt=0.2 s,ΔN=120 → 60 RPM

从开环到闭环

开环只输出 PWM,不知道轮子是否达到目标。闭环把测得速度与目标比较,依据误差调整输出。先用 P 控制,再学习 I 消除稳态偏差;低速脉冲稀疏时,D 容易放大噪声,不急着加入。

# 算法伪代码;不是固件工程,增益必须实测整定
error = target_rpm - measured_rpm
candidate_integral = integral + error * dt
candidate = feedforward + Kp * error + Ki * candidate_integral
output = clamp(candidate, 0, max_output)
# 仅在未饱和,或误差使输出离开饱和时,接受积分更新
# 停止、故障、方向切换时清空积分;反向先停稳

控制周期固定,测量使用真实时间差。先把 Ki 设为 0,用较小 Kp 观察。出现来回振荡就停止并减小增益、检查测量延迟。电机停住而控制器仍持续积分会产生积分饱和;解除阻碍后可能突然加速。

闭环扩展实验的交付要求

给左右轮分别建立采样与控制通道,先在单轮架空环境记录时间、目标 RPM、实际 RPM、输出;加入第二轮前先验证故障停车。教程提供测量工程和算法解释,整车闭环需要按所购电机实测参数实现,不能把一组通用 PID 系数直接用于所有车。

停下来想一想

手转一圈记录 600,但代码按 P=150 计算,显示速度会怎样?

查看推导与答案

显示速度会变成实际的 4 倍,因为分母少了 4 倍。这往往来自不同计数模式的定义混淆。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。

CHAPTER 17 / FIELD GUIDE

调试、复习与结课项目

把能跑一次变成能解释、能重复。

建议用时 60 分钟 + 项目实践先预测,再实验,再解释

统一排错顺序

层次问题验证方式
供电电压对吗?启动时掉压吗?测静态电压;若仅瞬态故障,万用表可能捕捉不到
连接共地、极性、针脚对吗?断电测通断;按网络逐根检查
外设单模块是否可用?回到 blink / scan / 单轮工程
软件配置与版本匹配吗?打印版本、状态、错误码,观察第一条错误
时序是否阻塞、超时、竞争?给阶段记时间;减少日志扰动
系统多个功能一起才坏?逐个恢复模块,记录触发条件

把“出错了”变成可求助的信息

硬件:板卡/模块准确型号、接线表、供电方式
软件:ESP-IDF 版本、工程编号、最近修改
期望:按键一次切换一次
实际:长按会连续切换
复现:上电后第几步开始,每次是否出现
证据:第一条错误起的完整日志、测得电压
已排除:数据线、最小工程、单模块验证

两份结课作品

作品必须完成加分扩展
带屏幕的桌面设备开关、读数、亮度条、上电默认安全状态、接线与说明保存设置;任务分离;局域网状态页
两轮机器人方向正确、渐进启动、按键停、限时停、物理断电双轮编码器;闭环直行;失联停车;测距避障

后续做彩屏时再学习 SPI、帧缓冲和 LVGL;做机器人时再加入测距传感器、姿态与运动规划。ESP32 可承担传感、控制和轻量界面;复杂视觉与大模型通常需要其他计算设备协作。采购扩展模块前重新核对供电、逻辑电平、接口与剩余引脚。

怎样复习才不容易忘

  1. 次日:不看教程画出昨天的电路与程序流程。
  2. 三天后:从空白 main.c 重写一个小实验,再对照。
  3. 一周后:故意改变一个条件,例如按键极性、PWM 频率或 I²C 地址,预测结果。
  4. 完成项目后:解释一个真实故障的根因和证据,记录到 learning-records。不要把“看懂了”自动算成“会了”。
停下来想一想

复现一个故障时,为什么最好一次只改一个因素?

查看推导与答案

同时改线、换库、调时序,即使问题消失也无法判断原因。保留基线,每次只改变一个可验证的假设,才会得到可复用的知识。

本章验收

本章一手资料

卡住时可以直接向我提问:附上章节号、工程名、接线和第一条错误日志。