上一站:嵌入式转型的五个阶段(总览) 上一关:S0:一周把硬件点亮 这一篇:只做 S2 —— 下到 MCU,把操作系统拿掉。 写给谁:能写 C、能 SSH 登录、S0 的 LED 已经自己点亮过的人。 耗时:6–8 周,每天 1.5–2 小时。这是整条路线里最难的一关,也是最值钱的一关。
先说结论:这一关难在哪
从「有 OS 帮你管内存、管调度、管文件」到「什么都没有,你就是一个无限循环」——
这不是知识量的差距,是思维方式的断层。
前面几年你形成的所有直觉,在这里大半失效:
| 你已经习惯的 | MCU 上的现实 | 后果 |
|---|---|---|
malloc 随便用 | 堆只有几十 KB,而且不该用 | 跑两天内存碎片化,莫名其妙死机 |
| 线程 + 锁 | 没有线程,只有中断 | 用多线程的思路写 ISR,必然出竞态 |
printf 调试 | 没有屏幕,串口是唯一出口 | 不加串口日志,等于闭眼调试 |
| 程序跑完退出 | 永远不退出,main 里是死循环 | 忘记 return 反而没事,忘记喂狗就复位 |
| 内存错了 Segmentation Fault | 写错地址直接 HardFault 或静默乱跑 | 不崩,但是错的——最难查的一类 |
最后一行是这一关的核心难点:桌面程序出错会崩,你能立刻知道;MCU 出错往往继续跑,但结果是错的。所以这一关真正的目标是——
学会在没有操作系统保护的情况下,自己建立「可观测性」。
为什么先 ESP32,再 STM32
这是顺序问题,顺序反了大概率会放弃。
| ESP32 | STM32 | |
|---|---|---|
| 价格 | ¥20–40 | ¥15–50 |
| 上手 | 快(USB 直接烧,WiFi 内置) | 慢(要装工具链、烧录器、配时钟树) |
| 教学价值 | 中(库封装太厚,看不见底下) | 高(逼你看寄存器) |
| 出成果 | 一天能联网 | 一周能点亮 |
做法:
- 前 2 周 ESP32:先把「灯亮、按键、读传感器、联网」跑通,建立反馈
- 后 4–6 周 STM32:同一个功能再写一遍,这次不用库,直接怼寄存器
同一个功能写两遍,第二遍你才会真正理解第一遍的库在干什么。
一、动手之前:面包板的三条电气常识
这张图是本关所有接线的地基。三个要点:
- 电源轨(上下两条长线)整条连通,但上下的 + 和 − 不互通
- 中间每 5 个孔一组,组内相通,组间不通
- 中间那道缺口是断的——元件跨在缺口两侧,两边就不连通(这正是 DIP 芯片能插的原因)
⚠️ 每次都做这一步:接完线、通电之前,用万用表通断档,量一下你以为是连通的点。 90% 的「灯不亮」都在这一步被发现。花 10 秒,省半小时。
二、第一个程序:点亮一个 LED(然后关掉库,再来一次)
2.1 接线
| 从 | 到 | 说明 |
|---|---|---|
| GPIO4 | 220Ω 电阻一端 | 任意输出脚都行 |
| 220Ω 另一端 | LED 长脚(正极) | 长脚是阳极 |
| LED 短脚(负极) | GND | 必须共地 |
算一下电流:(3.3V − 2.0V) ÷ 220Ω ≈ 6mA。ESP32 单脚最大约 12mA,安全。
LED 的 2.0V 是压降,不是可选项。蓝光/白光 LED 压降约 3.0–3.2V,同样的 220Ω 会明显变暗,这是正常的,不是坏了。
2.2 第一遍:用库写(Arduino 框架)
// 第一遍:库函数。看得懂,但不知道它干了什么
#define LED_PIN 4
void setup() {
pinMode(LED_PIN, OUTPUT);
}
void loop() {
digitalWrite(LED_PIN, HIGH);
delay(1000);
digitalWrite(LED_PIN, LOW);
delay(1000);
}
烧进去,灯闪了。这一步的意义只有「确认硬件没错」,不是学习。
2.3 第二遍:不用库,直接写寄存器
这是本关第一个真正的关卡。ESP32 的 GPIO 输出靠三个寄存器配合:
// 第二遍:寄存器。慢,但每一行都能解释
#include <stdint.h>
#define GPIO_ENABLE_REG (*(volatile uint32_t *)0x3FF44020)
#define GPIO_OUT_REG (*(volatile uint32_t *)0x3FF44004)
#define GPIO_OUT_W1TS_REG (*(volatile uint32_t *)0x3FF44008) // Write 1 To Set
#define GPIO_OUT_W1TC_REG (*(volatile uint32_t *)0x3FF4400C) // Write 1 To Clear
#define LED_BIT (1u << 4) // GPIO4
static void busy_delay(volatile uint32_t n) {
while (n--) { __asm__ volatile("nop"); }
}
void app_main(void) {
GPIO_ENABLE_REG |= LED_BIT; // ① 把该脚设为输出
for (;;) {
GPIO_OUT_W1TS_REG = LED_BIT; // ② 置 1(点灯)
busy_delay(2000000);
GPIO_OUT_W1TC_REG = LED_BIT; // ③ 清 0(灭灯)
busy_delay(2000000);
}
}
三个寄存器各自在干什么:
| 寄存器 | 作用 | 为什么需要它 |
|---|---|---|
GPIO_ENABLE_REG | 决定这个脚是输入还是输出 | 默认全是输入,不设永远是读 |
GPIO_OUT_REG | 当前输出电平 | 直接读写全部 32 个脚 |
W1TS / W1TC | 只写 1 有效的置位/清零 | 见下面 |
W1TS / W1TC 是这一关最重要的一个概念。
普通写法 GPIO_OUT_REG |= LED_BIT 是「读 → 改 → 写」三步。如果这两步之间来了个中断也去改同一个寄存器,你的修改就被覆盖了——这叫读改写竞态。
W1TS(Write 1 To Set)的设计是:你写 1 的位被置位,写 0 的位完全不动。一步完成,没有中间态,天然免疫竞态。
这就是「硬件为并发设计」的第一个例子。 桌面编程里你靠锁解决,硬件里可以直接把操作做成原子的。
2.4 一个必须自己踩的坑:volatile
把上面代码里的 volatile 去掉,开 -O2 编译,你会看到灯不闪——或者编译器直接把整个循环优化没了。
原因:编译器看到「这个变量没人读」,就把写操作优化掉了。
volatile 的意思是「这块内存可能被程序之外的东西改(硬件、中断),每次都必须真的去读写」。
记这一条就够了:凡是映射到硬件的地址、凡是中断里会改的变量,一律
volatile。 忘了它,症状是「Debug 版本正常、Release 版本乱跑」——这类 bug 能查一整天。
2.5 过门标准
- 库版本和寄存器版本都能点亮
- 能口头解释:为什么用
W1TS而不是|= - 能解释去掉
volatile后为什么灯不闪(用-O2亲眼看到一次) - 用万用表量到 LED 两端电压(约 2V)和电阻两端电压(约 1.3V),加起来 ≈ 3.3V
三、中断:为什么主循环里不能 delay
3.1 先看一个错误写法
// 错误示范:轮询 + 阻塞延时
void loop() {
if (digitalRead(BTN_PIN) == LOW) {
do_something();
}
delay(500); // ← 这 500ms 里,按键完全被忽略
}
问题很直接:delay 期间程序什么都看不见。 你按了,它不知道。
桌面程序里这个问题被操作系统掩盖了(事件循环)。裸机上没人帮你,你必须自己解决「同时等两件事」。
3.2 正确做法:中断只置标志,主循环干活
接线(图 3a):
| 从 | 到 |
|---|---|
| GPIO25 | 按键一端 |
| 按键另一端 | GND |
不接外接电阻——GPIO25 用内部上拉。但有个前提:必须用 INPUT_PULLUP 模式,否则引脚悬空,读数乱跳。
⚠️ 选脚很重要:别用 GPIO0 / GPIO2 / GPIO12 / GPIO15 做按键,这些是启动模式脚,上电时电平不对会进不了正常启动。GPIO25/26/27 是安全选择。
// 正确做法:中断里只做一件事——置标志
#include <stdint.h>
#include <stdbool.h>
#define BTN_PIN 25
#define DEBOUNCE_MS 20
static volatile bool g_btn_flag = false;
static volatile uint32_t g_btn_tick = 0;
// 中断服务程序(ISR):越短越好,不要在这里做业务
static void IRAM_ATTR on_button_isr(void) {
uint32_t now = xTaskGetTickCountFromISR();
// 简单时间窗去抖:距上次触发不足 20ms 的直接丢掉
if (now - g_btn_tick < DEBOUNCE_MS) return;
g_btn_tick = now;
g_btn_flag = true; // ← 只做这一件事
}
void app_main(void) {
gpio_config_t io = {
.pin_bit_mask = 1ULL << BTN_PIN,
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE, // ← 必须开,否则悬空
.intr_type = GPIO_INTR_NEGEDGE, // 下降沿:按下时从 1 → 0
};
gpio_config(&io);
gpio_install_isr_service(0);
gpio_isr_handler_add(BTN_PIN, on_button_isr, NULL);
for (;;) {
if (g_btn_flag) {
g_btn_flag = false; // 先清标志,再做业务
do_something(); // ← 业务放这里,慢一点也没关系
}
vTaskDelay(pdMS_TO_TICKS(10));
}
}
3.3 为什么机械按键需要消抖(图 3b)
按下的一瞬间,金属片会弹跳几毫秒到几十毫秒,电平在 0/1 之间抖几十次。
如果中断不加去抖,按一下会触发几十次。 症状:按一次,计数加了 37。
两种消抖方式:
| 方式 | 做法 | 优缺点 |
|---|---|---|
| 软件时间窗(上面用的) | 距上次触发不足 20ms 就丢弃 | 简单,够用;极端抖动会漏 |
| 硬件 RC 滤波 | 并联 100nF 电容 | 彻底,但要多一个元件 |
ISR 三条铁律(违反任何一条都会出玄学问题):
- 不做耗时操作(不能
delay、不能printf、不能等 I2C)- 不调用不可重入函数(
malloc、大部分标准库)- 共享变量必须
volatile,多字节变量要考虑原子性
3.4 过门标准
- 按一次,计数正好加 1(不是 2 也不是 37)
- 能解释「为什么
delay(500)会漏按键」 - 能解释「为什么 ISR 里不能
printf」 - 故意把去抖去掉,亲眼看到一次按键触发多次,再改回来
四、读传感器:I²C 是两根线的总线
4.1 为什么是两根线
I²C 只需要 SCL(时钟)+ SDA(数据)。两根线都是开漏输出 + 上拉电阻,所以:
- 任何设备都可以把线拉低
- 没有人能强行拉高(靠上拉电阻回升)
- 结果:多个设备可以并联在同一对线上,不会互相烧毁
每个设备有自己的地址,主机喊地址,只有对得上的那个才应答。
| 线 | ESP32 默认脚 | 说明 |
|---|---|---|
| SCL | GPIO22 | 时钟,由主机产生 |
| SDA | GPIO21 | 数据,双向 |
4.2 接线(以 SHT30 温湿度为例)
| SHT30 模块 | ESP32 |
|---|---|
| VCC | 3V3 |
| GND | GND |
| SCL | GPIO22 |
| SDA | GPIO21 |
大多数模块板载已有 4.7kΩ 上拉,不用再加。但买之前要看清楚商品页写没写——没写的自己补两个 4.7kΩ 到 3V3。
4.3 先扫地址,再写代码
这是本关最实用的一个习惯:不确定地址时,先扫一遍总线。
#include <stdio.h>
#include "driver/i2c.h"
#define I2C_PORT I2C_NUM_0
#define SDA_PIN 21
#define SCL_PIN 22
void i2c_scan(void) {
i2c_config_t conf = {
.mode = I2C_MODE_MASTER,
.sda_io_num = SDA_PIN,
.scl_io_num = SCL_PIN,
.sda_pullup_en = GPIO_PULLUP_ENABLE,
.scl_pullup_en = GPIO_PULLUP_ENABLE,
.master.clk_speed = 100000, // 100kHz 标准模式
};
i2c_param_config(I2C_PORT, &conf);
i2c_driver_install(I2C_PORT, conf.mode, 0, 0, 0);
printf("scanning I2C bus...\n");
for (uint8_t addr = 1; addr < 127; addr++) {
i2c_cmd_handle_t cmd = i2c_cmd_link_create();
i2c_master_start(cmd);
i2c_master_write_byte(cmd, (addr << 1) | I2C_MASTER_WRITE, true);
i2c_master_stop(cmd);
esp_err_t ret = i2c_master_cmd_begin(I2C_PORT, cmd, pdMS_TO_TICKS(50));
i2c_cmd_link_delete(cmd);
if (ret == ESP_OK) {
printf(" found device at 0x%02X\n", addr);
}
}
printf("scan done\n");
}
扫出来是空的? 按这个顺序查(这是最有用的排查顺序):
- SDA/SCL 接反了 —— 最常见,占了大概一半
- 上拉没生效 —— 模块没板载,你也没加
- VCC 没接或接错电压 —— 有些模块只支持 5V,有些只支持 3.3V
- 没共地 —— 模块的 GND 和 ESP32 的 GND 必须通
- 地址被占/冲突 —— 两个同型号设备默认同地址
这五条查完还没结果,换一根杜邦线。 杜邦线断芯是真实的、高频的故障源,而且万用表量不出来(接触时才断)。
4.4 读一次温湿度
// SHT30 单次测量、高重复性、无时钟拉伸
static esp_err_t sht30_read(float *temp_c, float *rh_pct) {
const uint8_t addr = 0x44;
const uint8_t cmd[2] = {0x24, 0x00}; // 单次测量命令
uint8_t buf[6];
// ① 发命令
i2c_cmd_handle_t c = i2c_cmd_link_create();
i2c_master_start(c);
i2c_master_write_byte(c, (addr << 1) | I2C_MASTER_WRITE, true);
i2c_master_write(c, cmd, sizeof(cmd), true);
i2c_master_stop(c);
esp_err_t ret = i2c_master_cmd_begin(I2C_PORT, c, pdMS_TO_TICKS(100));
i2c_cmd_link_delete(c);
if (ret != ESP_OK) return ret;
vTaskDelay(pdMS_TO_TICKS(20)); // ② 等测量完成(手册要求 15ms)
// ③ 读 6 字节:温度(2)+CRC(1) 湿度(2)+CRC(1)
c = i2c_cmd_link_create();
i2c_master_start(c);
i2c_master_write_byte(c, (addr << 1) | I2C_MASTER_READ, true);
i2c_master_read(c, buf, 6, I2C_MASTER_LAST_NACK);
i2c_master_stop(c);
ret = i2c_master_cmd_begin(I2C_PORT, c, pdMS_TO_TICKS(100));
i2c_cmd_link_delete(c);
if (ret != ESP_OK) return ret;
// ④ 换算(公式来自数据手册)
uint16_t raw_t = (buf[0] << 8) | buf[1];
uint16_t raw_h = (buf[3] << 8) | buf[4];
*temp_c = -45.0f + 175.0f * (float)raw_t / 65535.0f;
*rh_pct = 100.0f * (float)raw_h / 65535.0f;
return ESP_OK;
}
注意第 ② 步那个 20ms:发完命令不能立刻读,传感器要时间转换。这就是为什么读传感器不能放在中断里——20ms 对中断来说已经太长了。
4.5 三个必须知道的细节
① CRC 校验不能省
SHT30 每两个数据字节后面跟一个 CRC8。I²C 在长线上是会出错的,读错一个字节你就得到一个看起来正常、但完全错误的温度。
static uint8_t crc8(const uint8_t *data, int len) {
uint8_t crc = 0xFF;
for (int i = 0; i < len; i++) {
crc ^= data[i];
for (int b = 0; b < 8; b++)
crc = (crc & 0x80) ? (crc << 1) ^ 0x31 : (crc << 1);
}
return crc;
}
// 用法:if (crc8(buf, 2) != buf[2]) return ESP_ERR_INVALID_CRC;
② 上拉电阻和线长成反比
| 线长 | 上拉 |
|---|---|
| < 30cm | 4.7kΩ |
| 30cm–1m | 2.2kΩ |
| > 1m | 换方案(用差分总线或降低速率) |
线越长,电容越大,上升沿越慢。上拉太小会让上升沿变缓,读到的就是乱码。
③ 读失败要重试,不能直接崩
for (int i = 0; i < 3; i++) {
if (sht30_read(&t, &h) == ESP_OK) break;
vTaskDelay(pdMS_TO_TICKS(50));
}
嵌入式设备要连续跑几个月,任何一次 I²C 失败都不该让设备死掉。
4.6 过门标准
- 用扫地址程序看到 0x44
- 读到室温,用手捂住传感器看到数值上升(这比看串口数字更有说服力)
- 拔掉 SDA 线,程序不崩,只报错
- 能解释「为什么读传感器不能放中断里」
五、低功耗:电池能撑多久,是这一关的硬指标
5.1 先算一笔账
一块 2000mAh 的 18650,3.3V 供电:
| 工作模式 | 电流 | 2000mAh 能撑多久 |
|---|---|---|
| 一直跑、WiFi 常开 | ~120mA | 约 16 小时 |
| 每 60 秒醒一次读传感器 | ~1.5mA(均值) | 约 55 天 |
| 深度睡眠 + 定时唤醒 | ~20µA | 理论 4 年(受电池自放电限制) |
同样的硬件、同样的功能,差了 60 倍。 这不是优化,这是「能不能用电池供电」的分界线。
5.2 三个降耗手段,按收益排序
① 睡眠(最大收益)
// 深睡 60 秒后自动醒来
esp_sleep_enable_timer_wakeup(60ULL * 1000000ULL);
// 或者:按键唤醒
// esp_sleep_enable_ext0_wakeup(GPIO_NUM_25, 0);
esp_deep_sleep_start(); // 这一行之后,程序重新从 app_main 开始
深睡醒来等于复位——所有变量清零,RAM 除了 RTC 域都丢。所以状态要么存 RTC 内存,要么存 flash。
② 关掉不用的外设
WiFi 是最耗电的。不用时一定要 esp_wifi_stop(),不是「不连」就行。
③ 降低主频
setCpuFrequencyMhz(80) 从 240MHz 降到 80MHz,功耗大约降一半,对大部分传感器采集够用。
5.3 一个反直觉的坑
「加了睡眠反而更耗电」 是新手最常见的现象。原因通常是:
- 睡眠前没关 WiFi / 没关外设的电源
- 用浅睡(light sleep)而不是深睡
- 调试串口一直开着(USB 转串口芯片自己就吃几十 mA)
测功耗必须拔掉 USB,用万用表串在电池回路里量。 插着 USB 测出来的数字没有意义。
5.4 过门标准
- 电池供电,连续跑 2 周以上(这是低功耗做对了的硬指标)
- 拔电重插,自动恢复,不需要按键
- 能用万用表量出「醒着」和「睡着」两种电流并说出数量级差
- 能解释「为什么深睡醒来后变量都没了」
六、从 ESP32 到 STM32:同一件事,不用库再写一遍
前五节做完,你已经能出东西了。但你还不知道库在帮你干什么。
这一段就是把库拆掉。选一个最小目标:用寄存器让一个 LED 闪。
6.1 要跨过的三道坎
| 坎 | ESP32(Arduino/IDF) | STM32 裸机 |
|---|---|---|
| 时钟 | 自动配好 | 必须手动开外设时钟,不开则写寄存器无效(且不报错) |
| 引脚复用 | 自动 | 要配 MODER / OTYPER / PUPDR 寄存器 |
| 烧录 | USB 直插 | ST-Link + 工具链 |
第一行是最坑的:STM32 上,你写了 GPIO 寄存器但灯不亮,九成是因为忘了开 RCC 时钟。而且它不报错,就是没反应。
// STM32F103 寄存器版点灯(最小示例)
#include <stdint.h>
#define RCC_APB2ENR (*(volatile uint32_t *)0x40021018)
#define GPIOC_CRL (*(volatile uint32_t *)0x40011000)
#define GPIOC_ODR (*(volatile uint32_t *)0x4001100C)
#define IOPCEN (1u << 4) // GPIOC 时钟使能位
void led_init(void) {
RCC_APB2ENR |= IOPCEN; // ① 第一步永远是开时钟
// ② PC13 配成推挽输出、50MHz
GPIOC_CRL &= ~(0xFu << 20); // 清掉 PC13 的 4 位配置
GPIOC_CRL |= (0x3u << 20); // 0011 = 输出模式 50MHz
}
void led_toggle(void) {
GPIOC_ODR ^= (1u << 13); // ③ 翻转 PC13
}
对照着看:ESP32 有 GPIO_ENABLE_REG 管方向,STM32 用 CRL/CRH 每 4 位管一个脚。概念完全一样,只是编排方式不同。
这一步做完,你回头再看
digitalWrite(),就知道它内部大概做了三件事: 开时钟 → 配模式 → 写数据寄存器。库不是魔法,是这三步的封装。
6.2 过门标准
- STM32 上用寄存器点亮 LED,全程不调用任何 HAL 库函数
- 故意注释掉开时钟那行,亲眼看到灯不亮,再恢复
- 能画出「从
main到灯亮」的调用链,标出每一步配置了什么
七、这一关的排查清单(粘贴到你的笔记本首页)
嵌入式最耗时间的不是写代码,是代码没错但板子不干活。下面这张表按「发生概率」排序:
| 症状 | 最可能的原因 | 怎么确认 |
|---|---|---|
| 灯完全不亮 | 共阴/共阳接反 | 万用表二极管档量 LED 两端 |
| 灯完全不亮 | 杜邦线断芯 | 换一根(量不出来) |
| 灯完全不亮 | 面包板跨缺口 | 看是不是插在中间缺口两侧 |
| 灯不亮但电压对 | 引脚编号用错(BCM vs 物理脚) | 查板子丝印,不用记忆 |
| 板子反复重启 | 电源电流不够 | 换官方电源,别用电脑 USB |
| 板子反复重启 | 看门狗没喂 | 检查循环里有没有长阻塞 |
| 读数乱跳 | 输入脚悬空 | 开内部上拉或加外部电阻 |
| 按一次加多次 | 没消抖 | 加 20ms 时间窗 |
| I²C 扫不到 | SDA/SCL 反了 | 对调试一次(30 秒的事) |
| I²C 扫不到 | 没共地 | 量两个 GND 之间是否导通 |
| 数值合理但错 | CRC 没校验 | 加上 CRC 比对 |
| Debug 正常 Release 乱跑 | 少了 volatile | 全局搜索硬件地址和 ISR 共享变量 |
| 跑几小时后死机 | 堆碎片 / 栈溢出 | 改用静态分配,增大栈 |
| 睡不醒 | 唤醒源没配 | 检查 esp_sleep_enable_* 返回值 |
八、8 周怎么排
| 周 | 目标 | 手上要出的东西 |
|---|---|---|
| 1 | 环境 + 点灯(库版) | 灯在闪 |
| 2 | 寄存器版点灯 + 理解 W1TS/volatile | 两版代码都跑通 |
| 3 | 中断 + 消抖 | 按键计数准确 |
| 4 | I²C 扫地址 + 读温湿度 | 串口打印室温 |
| 5 | 低功耗 + 睡眠唤醒 | 电池跑起来 |
| 6–8 | STM32 裸机重写 | 不调库点亮 + 串口输出 |
如果某一周卡住超过 3 天:停下来,回到「排查清单」,把现象写清楚再动手。瞎改代码是嵌入式里最浪费时间的做法。
九、这一关结束时你应该能回答
- 为什么
delay(500)会漏按键?中断怎么解决这个问题? volatile到底防的是什么?举个例子说明不加会怎样。- 读 I²C 传感器为什么不能放在中断里?
- 电池设备的续航是怎么算出来的?为什么深睡能差 60 倍?
- ESP32 的
GPIO_ENABLE_REG和 STM32 的CRL在概念上是什么关系?
答不出第 2 和第 5 题,说明还停在「会用库」,没到「懂硬件」。 这正是这一关要跨过去的东西。
下一站
S2 做完,你就有了「脑 + 手脚」两套能力。S3 把两者接起来:网关(树莓派)+ 多个节点(MCU),处理断线、重连、时间同步——分布式系统的雏形。
参考
本文的硬件事实(ESP32 寄存器地址、SHT30 命令与换算公式、STM32 寄存器位定义)来自各自芯片的数据手册与参考手册。代码、接线图、排查清单、8 周排期均为原创整理。