上一站:嵌入式转型的五个阶段(总览) 上一关: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

这是顺序问题,顺序反了大概率会放弃。

ESP32STM32
价格¥20–40¥15–50
上手快(USB 直接烧,WiFi 内置)慢(要装工具链、烧录器、配时钟树)
教学价值中(库封装太厚,看不见底下)高(逼你看寄存器)
出成果一天能联网一周能点亮

做法:

  • 前 2 周 ESP32:先把「灯亮、按键、读传感器、联网」跑通,建立反馈
  • 后 4–6 周 STM32:同一个功能再写一遍,这次不用库,直接怼寄存器

同一个功能写两遍,第二遍你才会真正理解第一遍的库在干什么。


一、动手之前:面包板的三条电气常识

面包板电气结构

这张图是本关所有接线的地基。三个要点:

  1. 电源轨(上下两条长线)整条连通,但上下的 + 和 − 不互通
  2. 中间每 5 个孔一组,组内相通,组间不通
  3. 中间那道缺口是断的——元件跨在缺口两侧,两边就不连通(这正是 DIP 芯片能插的原因)

⚠️ 每次都做这一步:接完线、通电之前,用万用表通断档,量一下你以为是连通的点。 90% 的「灯不亮」都在这一步被发现。花 10 秒,省半小时。


二、第一个程序:点亮一个 LED(然后关掉库,再来一次)

2.1 接线

ESP32 点亮 LED

从到说明
GPIO4220Ω 电阻一端任意输出脚都行
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 三条铁律(违反任何一条都会出玄学问题):

  1. 不做耗时操作(不能 delay、不能 printf、不能等 I2C)
  2. 不调用不可重入函数(malloc、大部分标准库)
  3. 共享变量必须 volatile,多字节变量要考虑原子性

3.4 过门标准

  • 按一次,计数正好加 1(不是 2 也不是 37)
  • 能解释「为什么 delay(500) 会漏按键」
  • 能解释「为什么 ISR 里不能 printf」
  • 故意把去抖去掉,亲眼看到一次按键触发多次,再改回来

四、读传感器:I²C 是两根线的总线

I2C 总线

4.1 为什么是两根线

I²C 只需要 SCL(时钟)+ SDA(数据)。两根线都是开漏输出 + 上拉电阻,所以:

  • 任何设备都可以把线拉低
  • 没有人能强行拉高(靠上拉电阻回升)
  • 结果:多个设备可以并联在同一对线上,不会互相烧毁

每个设备有自己的地址,主机喊地址,只有对得上的那个才应答。

线ESP32 默认脚说明
SCLGPIO22时钟,由主机产生
SDAGPIO21数据,双向

4.2 接线(以 SHT30 温湿度为例)

SHT30 模块ESP32
VCC3V3
GNDGND
SCLGPIO22
SDAGPIO21

大多数模块板载已有 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");
}

扫出来是空的? 按这个顺序查(这是最有用的排查顺序):

  1. SDA/SCL 接反了 —— 最常见,占了大概一半
  2. 上拉没生效 —— 模块没板载,你也没加
  3. VCC 没接或接错电压 —— 有些模块只支持 5V,有些只支持 3.3V
  4. 没共地 —— 模块的 GND 和 ESP32 的 GND 必须通
  5. 地址被占/冲突 —— 两个同型号设备默认同地址

这五条查完还没结果,换一根杜邦线。 杜邦线断芯是真实的、高频的故障源,而且万用表量不出来(接触时才断)。

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;

② 上拉电阻和线长成反比

线长上拉
< 30cm4.7kΩ
30cm–1m2.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中断 + 消抖按键计数准确
4I²C 扫地址 + 读温湿度串口打印室温
5低功耗 + 睡眠唤醒电池跑起来
6–8STM32 裸机重写不调库点亮 + 串口输出

如果某一周卡住超过 3 天:停下来,回到「排查清单」,把现象写清楚再动手。瞎改代码是嵌入式里最浪费时间的做法。


九、这一关结束时你应该能回答

  1. 为什么 delay(500) 会漏按键?中断怎么解决这个问题?
  2. volatile 到底防的是什么?举个例子说明不加会怎样。
  3. 读 I²C 传感器为什么不能放在中断里?
  4. 电池设备的续航是怎么算出来的?为什么深睡能差 60 倍?
  5. ESP32 的 GPIO_ENABLE_REG 和 STM32 的 CRL 在概念上是什么关系?

答不出第 2 和第 5 题,说明还停在「会用库」,没到「懂硬件」。 这正是这一关要跨过去的东西。


下一站

S2 做完,你就有了「脑 + 手脚」两套能力。S3 把两者接起来:网关(树莓派)+ 多个节点(MCU),处理断线、重连、时间同步——分布式系统的雏形。


参考

本文的硬件事实(ESP32 寄存器地址、SHT30 命令与换算公式、STM32 寄存器位定义)来自各自芯片的数据手册与参考手册。代码、接线图、排查清单、8 周排期均为原创整理。