Skip to content

2.3 通信总线:UART / I2C / SPI ​

这节要搞懂:芯片之间怎么"说话",三种方式各用在哪。 需要基础:2.2

这一节怎么读

这节讲三种总线,每一种都是"概念 → 波形 → 代码 → 动手做一遍"一条龙:

小节内容
一 ~ 三共同的基础概念(三种总线都要用)
四UART —— 讲完直接动手做回环测试
五I2C —— 讲完直接动手扫描设备
六SPI —— 实战放在 2.4 点亮屏幕
七多任务共用总线要注意什么

想先学哪个就直接跳到哪一节,不用从头读到尾。

一、什么是通信总线 ​

通信总线(Bus)是芯片之间传输数据的公共通道,包含物理连线和一套双方都遵守的通信协议(谁先说、什么时候说、一次说几位)。

打个比方:总线就是两个人之间的沟通方式 —— 打电话、开会、还是发专线电报,各有各的规矩。

二、三种总线速查表 ​

UART(串口)I2CSPI
打个比方打电话,一对一开会,主持人点名专线,一人一条
用几根线2 根2 根4 根
快不快慢中很快
能接几个设备1 个一堆(靠地址区分)每个要单独一根片选线
能同时收发吗✅ 全双工❌ 半双工✅ 全双工
主要用来看日志、调试接传感器接屏幕、SD 卡

一句话记住

调试用串口,传感器用 I2C,屏幕用 SPI。

三、先分清三组概念 ​

后面讲 UART 时会说它是"异步串行、全双工"通信。这几个词各是一组对立概念,先搞清楚。

串行 vs 并行 —— 数据怎么排队走 ​

串行(Serial):数据一位一位地按先后顺序,在一根线上传输。

并行(Parallel):多位数据同时通过多根线传输。

打个比方:

  • 串行 = 单车道,车只能一辆接一辆过
  • 并行 = 八车道,八辆车并排一起过
串行:1 根线,8 个时间片依次发完10110010TX◄──────────── 花 8 个时间片 ────────────►并行:8 根线,1 个时间片全部发完 D71D60D51D41D30D20D11D00◄─ 1 个时间片 ─► 快 8 倍,但要多 7 根线

并行看起来快 8 倍,那为什么现在几乎都用串行?

问题说明
占引脚传 8 位就要 8 根线,芯片引脚和电路板走线都是成本
跑不快速度一高,8 根线上的信号到达时间对不齐(有的线长一点就慢一点),接收方不知道该什么时候一起采样
互相干扰挨得近的线之间会串扰

所以现代的高速接口 —— USB、SATA、PCIe、以太网,全都是串行的。宁可一根线跑得飞快,也不要八根线互相拖后腿。

嵌入式里的例子

  • 串行:UART、I2C、SPI、I2S —— 我们学的全是串行
  • 并行:LCD 的 8080 并口(一次推 8 或 16 位像素)、老式打印机口

并行在短距离、要高带宽的场合还有一席之地,比如某些高分辨率屏幕。

同步 vs 异步 —— 靠什么对齐节拍 ​

数据一位一位过来,接收方怎么知道什么时候该读一次?两种办法:

同步(Synchronous):有一根独立的时钟线,发送方一边发数据一边打拍子,接收方跟着拍子读。

异步(Asynchronous):没有时钟线,双方事先约定好速率,各自按这个速率数拍子。

打个比方:

  • 同步 = 有指挥的合唱团 —— 指挥挥一下大家唱一个字,绝不会乱
  • 异步 = 没有指挥,事先说好"每秒 120 拍" —— 各自在心里数,全靠约定
同步异步
要不要时钟线✅ 要❌ 不要
线数多一根少一根
速率要不要事先约定不用,跟着时钟走必须一模一样
能跑多快快(几十 MHz)慢(通常几百 kbps)
代表I2C、SPI、I2SUART

异步的两个代价

① 速率必须精确一致。 双方波特率差超过 2~3% 就会采样错位 —— 这就是串口乱码最常见的原因。

② 传输效率低。 因为没有时钟线,每个字节都要额外加起始位和停止位来帮接收方对齐:

传 8 位数据,实际要发 10 位  →  效率只有 80%

同步通信不需要这些额外的位。

单工 / 半双工 / 全双工 —— 能不能同时说和听 ​

前两组讲的是"怎么传",这一组讲的是"能往哪个方向传、能不能同时"。

单工(Simplex):数据只能朝一个方向流,永远是一方发、一方收。

半双工(Half-duplex):双向都能传,但同一时刻只能一个方向 —— 要么我说你听,要么你说我听。

全双工(Full-duplex):双向可以同时进行 —— 我说话的同时也能听见你说。

打个比方:

相当于说明
单工收音机你只能听,没法回话
半双工对讲机按住键说话时听不见对方,松开才能听
全双工打电话两个人可以同时开口

判断方法很简单:看有几根数据线。

  • 两根独立的数据线(一根发、一根收)→ 能同时 → 全双工
  • 共用一根数据线 → 只能轮流 → 半双工

三种总线各是哪种 ​

总线类型为什么
UART全双工TX 和 RX 是两根独立的线,可以同时收发
I2C半双工只有 SDA 一根数据线,同一时刻只能一个方向
SPI全双工MOSI 和 MISO 两根独立的线,收发同时进行

这解释了两个你会遇到的现象

① I2C 读传感器为什么要分两步

因为它是半双工,不能边写边读,只能"先写、再读":

c
// 先写:告诉传感器"我要读哪个寄存器"
// 再读:把数据收回来
i2c_master_transmit_receive(dev, &reg, 1, data, 6, 1000);
//                          └─ 写 ─┘  └── 读 ──┘  两个阶段,有先后

② SPI 为什么"只读也得发点东西"

因为它是全双工,收和发是同一批时钟脉冲驱动的。不发就没有时钟,从机也就不会输出。

所以只想读的时候,主机会发一堆无意义的 0xFF 纯粹为了产生时钟(第六节还会细讲)。

对号入座

把三组概念放在一起,我们学的总线是这样分布的:

总线串行/并行同步/异步双工方式
UART串行异步全双工
I2C串行同步半双工
SPI串行同步全双工
I2S串行同步单向为主

所以 UART = 异步 + 串行 + 全双工:没有时钟线,靠约定的波特率,TX/RX 两根线各走各的。

四、UART:你天天在用 ​

UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)是一种异步串行通信接口。双方不共用时钟线,而是事先约定好速率(波特率),按位依次传输数据。

其实你已经在用它了:printf 打印出来的东西,就是通过串口传到电脑上的。

两条规矩:

  1. 波特率必须一致(双方约定的速度),最常用 115200
  2. 交叉接线:我的 TX(发) 接对方的 RX(收)

ESP32-S3 有三个串口,别搞混 ​

串口引脚干什么用
UART0GPIO43 / 44⭐ 系统占用 —— printf 和烧录都走它,别去动
UART1任意引脚本节实验用它,接 GPIO17(TX)/ GPIO18(RX)
UART2任意引脚备用

为什么 UART1 的引脚可以随便选

ESP32 内部有个 GPIO 交叉开关(GPIO Matrix),能把外设信号"路由"到几乎任意引脚上。

所以代码里 uart_set_pin() 想指定哪个脚就哪个脚 —— 这是 ESP32 比大多数单片机灵活的地方(很多芯片的串口引脚是焊死的)。

千万别把 UART0 的引脚拿去用

GPIO43/44 被系统的日志和烧录占着。占了它你就看不到 printf,也可能烧不进去。

UART 的波形长什么样 ​

传一个字节 0x41(字母 A,二进制 0100 0001)时,线上是这样的:

空闲(高)起始位D0=1D1=0D2=0D3=0D4=0D5=0D6=1D7=0停止位(高)TX

看这张图要抓住三点:

  1. 空闲时是高电平 —— 没数据时线一直拉高,所以拔掉线(悬空)容易被误判成一直在传
  2. 起始位是一个低电平 —— 接收方就靠这个下降沿知道"要开始了",然后按约定的波特率去数节拍
  3. 先传低位(D0) —— 0x41 的二进制是 0100 0001,但线上先出来的是最右边的 1

波特率就是"每一格多宽"

115200 波特率 = 每秒 115200 位 = 每一格约 8.68 微秒。

双方波特率不一致时,接收方数节拍的间隔就错了,采样点会飘到隔壁位上 —— 这就是乱码的根本原因。

串口乱码怎么办

按顺序查:

  1. 波特率对不对(99% 是这个,ESP32 默认 115200)
  2. 两块板子共地了吗(GND 必须连一起)
  3. TX 和 RX 有没有接反
c
#include "driver/uart.h"

#define UART_PORT        UART_NUM_1
#define UART_TX_GPIO     17
#define UART_RX_GPIO     18
#define UART_BAUD_RATE   115200

static void uart_basic_init(void)
{
    const uart_config_t uart_config = {
        .baud_rate  = UART_BAUD_RATE,
        .data_bits  = UART_DATA_8_BITS,
        .parity     = UART_PARITY_DISABLE,
        .stop_bits  = UART_STOP_BITS_1,
        .flow_ctrl  = UART_HW_FLOWCTRL_DISABLE,
        .source_clk = UART_SCLK_DEFAULT,
    };

    // ① 装驱动(同时申请收发缓冲区)
    ESP_ERROR_CHECK(uart_driver_install(UART_PORT, 1024, 1024, 0, NULL, 0));
    // ② 配参数
    ESP_ERROR_CHECK(uart_param_config(UART_PORT, &uart_config));
    // ③ 指定引脚
    ESP_ERROR_CHECK(uart_set_pin(UART_PORT, UART_TX_GPIO, UART_RX_GPIO,
                                 UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE));
}

// 发送
uart_write_bytes(UART_PORT, "hello", 5);

// 接收(最多等 100 毫秒)
uint8_t buf[128];
int len = uart_read_bytes(UART_PORT, buf, sizeof(buf), pdMS_TO_TICKS(100));

三个函数的顺序不能乱

uart_driver_install → uart_param_config → uart_set_pin

先装驱动(申请缓冲区),再配参数,最后接引脚。顺序反了会报错。

那两个 1024 是什么

c
uart_driver_install(UART_PORT, 1024, 1024, 0, NULL, 0);
//                              ↑收    ↑发   ↑事件队列长度

收发缓冲区各 1024 字节。有了缓冲区,数据来了驱动会先存着,不用你时刻盯着读 —— 隔一会儿去取一次就行。

事件队列填 0 表示不用事件通知机制(本节用不到)。

uart_read_bytes 的超时参数很关键

c
uart_read_bytes(UART_PORT, buf, sizeof(buf), pdMS_TO_TICKS(100));

最多等 100 毫秒,没数据就返回 0,不会一直卡着。

填 portMAX_DELAY 的话没数据就永远阻塞 —— 这在专门收数据的任务里是合理的,但要想清楚。

动手做一遍:UART 回环测试 ​

目标:用串口助手往板子发一句话,板子原样发回来,同时在日志里打印出收到了什么。

第 1 步:接一个 USB 转 TTL 模块 ​

这是本课程唯一要自己接线的地方

板子上的器件都焊好了,但 UART 需要一个"对端"才能演示收发 —— 板子自己跟自己说话说明不了问题。

所以这一节要外接一个 USB 转 TTL 模块(CH340 / CP2102 都行),几块钱一个,插在电脑上就是一个串口。

模块和板子之间要交叉接:

USB-TTL 模块ESP32-S3说明
TXGPIO18模块发 → 板子收(RX)
RXGPIO17板子发(TX) → 模块收
GNDGND⭐ 必须共地

两个最常见的错

① 没接 GND —— 只接了 TX/RX,双方没有共同的参考电平,收到的全是乱码。

② TX 接了 TX —— 必须交叉:我的发接你的收。接成 TX-TX、RX-RX 是一点反应都没有。

板子的 USB 线也要插着

那根 USB 线走的是 UART0,用来看 printf 日志和烧录。

所以板子上同时连着两条串口线:USB 线(日志)+ USB-TTL 模块(实验用的 UART1)。电脑上会多出一个 COM 口。

第 2 步:建工程 ​

建工程流程见 2.2 第 2 步

Project Name 填 uart_demo,Target 选 esp32s3。

第 3 步:写代码 ​

c
#include <stdio.h>
#include "driver/uart.h"
#include "esp_err.h"
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"

#define UART_PORT          UART_NUM_1
#define UART_TX_GPIO       17
#define UART_RX_GPIO       18
#define UART_BAUD_RATE     115200
#define UART_RX_BUF_SIZE   1024
#define UART_TX_BUF_SIZE   1024
#define UART_READ_BUF_SIZE 128
#define UART_TASK_STACK    4096
#define UART_TASK_PRIORITY 10

static void uart_basic_init(void)
{
    const uart_config_t uart_config = {
        .baud_rate  = UART_BAUD_RATE,
        .data_bits  = UART_DATA_8_BITS,
        .parity     = UART_PARITY_DISABLE,
        .stop_bits  = UART_STOP_BITS_1,
        .flow_ctrl  = UART_HW_FLOWCTRL_DISABLE,
        .source_clk = UART_SCLK_DEFAULT,
    };

    ESP_ERROR_CHECK(uart_driver_install(UART_PORT,
                                        UART_RX_BUF_SIZE,
                                        UART_TX_BUF_SIZE,
                                        0, NULL, 0));
    ESP_ERROR_CHECK(uart_param_config(UART_PORT, &uart_config));
    ESP_ERROR_CHECK(uart_set_pin(UART_PORT,
                                 UART_TX_GPIO,
                                 UART_RX_GPIO,
                                 UART_PIN_NO_CHANGE,
                                 UART_PIN_NO_CHANGE));
}

static void uart_echo_task(void *arg)
{
    uint8_t rx_buf[UART_READ_BUF_SIZE];
    (void)arg;

    while (1) {
        int len = uart_read_bytes(UART_PORT,
                                  rx_buf,
                                  UART_READ_BUF_SIZE,
                                  pdMS_TO_TICKS(100));

        if (len > 0) {
            printf("UART1 RX: %.*s\r\n", len, (char *)rx_buf);   // 打到日志口
            uart_write_bytes(UART_PORT, (const char *)rx_buf, len); // 原样发回去
        }
    }
}

void app_main(void)
{
    uart_basic_init();
    xTaskCreate(uart_echo_task, "uart_echo_task",
                UART_TASK_STACK, NULL, UART_TASK_PRIORITY, NULL);
}

这个任务为什么可以不写 vTaskDelay

第一章讲过"每个任务循环里必须有会睡的语句",这里看起来没有 —— 但 uart_read_bytes 本身就是阻塞的。

没数据时它会睡最多 100 毫秒,同样让出了 CPU。

会阻塞的 API 本身就起到了"睡"的作用,不用再额外加延时。

%.*s 是什么写法

c
printf("UART1 RX: %.*s\r\n", len, (char *)rx_buf);

%.*s 表示"打印字符串,长度由后面的参数指定" —— 这里就是只打印收到的 len 个字节。

为什么不用普通的 %s:uart_read_bytes 读回来的是裸字节,末尾没有 \0 结束符。用 %s 会一直往后读到不知道哪里,打出一堆乱码。

第 4 步:编译烧录 ​

操作见 2.2 第 4 步

注意烧录用的串口要选 板子那个(USB 线那个 COM),别选成 USB-TTL 模块的。

第 5 步:配置串口助手 ​

打开串口调试助手,按图设置:

选 USB-TTL 模块对应的 COM 口,波特率设 115200,然后点「打开」
选 USB-TTL 模块对应的 COM 口,波特率设 115200,然后点「打开」
项设成
端口名USB-TTL 模块那个 COM(不是板子的)
波特率115200
数据位8
校验位None
停止位1

和代码里 uart_config 的四项完全对应 —— 任何一项不一致都会乱码。

电脑上可能有不止一个 COM 口,别选错

板子的 USB 线是一个 COM(看日志用),USB-TTL 模块是另一个 COM(这里要选的)。如果还插着别的串口设备,列表里会更多。

分不清就把模块拔掉看列表少了哪个。

第 6 步:发一句话 ​

在下方输入框打 abc,点发送按钮。

发送 abc,上面接收区立刻出现回传的 abc(» 是发出去的,« 是收回来的)
发送 abc,上面接收区立刻出现回传的 abc(» 是发出去的,« 是收回来的)

接收区出现两行:

标记含义
» abc你发出去的
« abc板子回传的 ⭐

底部计数也能看出来:发送 5,接收 5-5(abc 加上回车换行共 5 字节)。

第 7 步:同时看日志窗口 ​

日志窗口(UART0)打印出板子收到了什么
日志窗口(UART0)打印出板子收到了什么

终端里出现:

UART1 RX: abc

两边都有反应,才说明收发都通了。

五、I2C:接传感器就用它 ​

I2C(Inter-Integrated Circuit)是一种同步串行总线,只需两根线:SDA(数据)和 SCL(时钟)。总线上可以挂多个设备,每个设备有唯一的地址,主机通过地址指定跟谁通信。

打个比方:I2C 就像开会 —— 主持人喊"3 号发言",只有 3 号会应答,其他人保持安静。

本课程板子上的 AHT20 走这两个脚:

AHT20 温湿度传感器ESP32-S3 引脚
SDAGPIO14
SCLGPIO13

I2C 需要上拉电阻

SDA 和 SCL 两根线需要接 4.7kΩ 上拉电阻到 3.3V。

本课程的板子上已经画好了这两个上拉电阻,不用另外接。代码里也可以打开芯片内部的上拉。

c
#include "driver/i2c.h"

#define I2C_PORT         I2C_NUM_1
#define AHT20_ADDR       0x38            // 传感器的 7 位地址,查手册
#define AHT20_SCL_GPIO   GPIO_NUM_13
#define AHT20_SDA_GPIO   GPIO_NUM_14

static esp_err_t i2c_init(void)
{
    i2c_config_t conf = {
        .mode             = I2C_MODE_MASTER,      // 单片机当主机
        .sda_io_num       = AHT20_SDA_GPIO,
        .scl_io_num       = AHT20_SCL_GPIO,
        .sda_pullup_en    = GPIO_PULLUP_ENABLE,   // 打开芯片内部上拉
        .scl_pullup_en    = GPIO_PULLUP_ENABLE,
        .master.clk_speed = 400000,               // 时钟频率 400kHz
    };

    esp_err_t ret = i2c_param_config(I2C_PORT, &conf);   // 填参数
    if (ret != ESP_OK) {
        return ret;
    }

    return i2c_driver_install(I2C_PORT, I2C_MODE_MASTER, 0, 0, 0);   // 装驱动
}

// 写:把 3 个字节发给地址 0x38 的设备
uint8_t cmd[] = {0xAC, 0x33, 0x00};
i2c_master_write_to_device(I2C_PORT, AHT20_ADDR, cmd, sizeof(cmd), pdMS_TO_TICKS(100));

// 读:从地址 0x38 的设备读回 6 个字节
uint8_t data[6];
i2c_master_read_from_device(I2C_PORT, AHT20_ADDR, data, sizeof(data), pdMS_TO_TICKS(100));

日常就记住两个函数

初学阶段,I2C 的操作基本就这两句:

函数干什么
i2c_master_write_to_device()往某个地址写几个字节
i2c_master_read_from_device()从某个地址读几个字节

START、地址、ACK、STOP 这些细节驱动都替你做了 —— 但知道它背后干了什么,出问题时才查得动。这一节后面就用逻辑分析仪把它扒开看。

I2C 的波形长什么样 ⭐ ​

I2C 是两根线配合工作的,必须一起看:

I2C 官方手册里的一次完整传输:START → 地址 → 读写位 → 应答 → 数据 → 应答 → STOP
I2C 官方手册里的一次完整传输:START → 地址 → 读写位 → 应答 → 数据 → 应答 → STOP

这张图画的是一次完整的传输:主机先叫某个设备的名字,对方答应了,然后开始一个字节一个字节地传。

从左到右依次是:START → 7 位地址(图中 1~7)→ 读/写位(图中 8)→ 从机应答 ACK(图中 9)→ 8 位数据 → 应答 → 8 位数据 → 应答 → STOP。

四个关键点,看懂了 I2C 就懂了一半:

① START 和 STOP 是靠"违反规矩"造出来的

正常传数据时,SDA 只能在 SCL 是低电平的时候变化。而:

  • START:SCL 还是高电平时,SDA 从高掉到低
  • STOP:SCL 还是高电平时,SDA 从低升到高

所以才能被明确地识别成"开始"和"结束",不会和数据混淆。

② 数据在 SCL 低电平时变化,在 SCL 高电平时被读走

SCL 低 → 发送方把 SDA 改成要发的值(允许变)
SCL 高 → 接收方读取 SDA(此时必须稳定)

所以图里 SDA 的每次跳变,都发生在 SCL 的低电平区间。

③ 地址 + 读写位 = 第一个字节

START 之后的头 8 位不是数据,而是**"我要找谁 + 我要干什么"**:

  • 前 7 位是设备地址(图中标 1~7 的那几个时钟)—— 总线上每个设备地址不同,只有对上号的那个才会应答。本节用的温湿度传感器 AHT20 地址是 0x38
  • 第 8 位是读写位(图中标 8 的那个时钟,写作 R/W):0 = 主机要写,1 = 主机要读

手册上的地址为什么有两个版本

有的手册写 0x38,有的写 0x70 —— 其实是同一个设备。

因为 0x70 = 0x38 << 1,那份手册把地址和读写位合起来当成一个字节写了。ESP-IDF 里填的是 7 位地址(0x38),别填错。

④ 每个字节后面跟一个 ACK(图中标 9 的那个时钟)

发完 8 位,发送方松开 SDA,由接收方把 SDA 拉低表示"我收到了"。

注意这两格和别的格子不一样 —— 它们是从机(温湿度传感器)在驱动,其余格子都是主机(单片机)在驱动。

如果接收方不存在,没人拉低 SDA,它就被上拉电阻拉成高电平 —— 主机看到的是没人应答。

这解释了为什么 I2C 一定要接上拉电阻

因为总线上所有设备都只能"把线拉低",谁都不能主动把线拉高。

松开的时候,全靠上拉电阻把线拉回高电平。没有上拉,线就一直飘在低电平,START、ACK 这些约定全都失效。

动手做一遍:读 AHT20 温湿度,并把它的波形抓出来 ​

前面那张波形图是手册上画的理想图。这一段我们让板子真的去读一次温湿度,再把逻辑分析仪夹上去,把自己代码跑出来的真实波形抓下来,和手册对照着看。

做完这个实验,I2C 对你就不再是"传感器库里的魔法"了 —— 从屏幕上打印的那个 28.03℃,能一路倒推回总线上那几十个高低电平。

什么是逻辑分析仪

逻辑分析仪(Logic Analyzer)是一种只关心高低电平的采样工具。

把它的探针并联到 SCL 和 SDA 上,它会飞快地记录这两根线每一刻是高还是低,再把记录下来的一串 0/1 翻译成 START、地址、ACK、数据给你看。

  • 示波器看的是"电压曲线长什么样"(有没有毛刺、上升沿够不够陡)
  • 逻辑分析仪看的是"传了什么内容"(地址对不对、有没有 ACK)

看总线内容用后者。本节用的是正点原子 DL16 Plus,配套软件叫 ATK-Logic。

建工程的流程每节都一样,见 2.2 第 2 步。本节工程名填 i2c_demo,Target 选 esp32s3。

第 1 步:把逻辑分析仪夹上去 ​

AHT20 已经焊在板子上了,走的是 GPIO13(SCL) 和 GPIO14(SDA),不用接线。要动手的只有逻辑分析仪。

它是并联在总线上"偷听"的,不会打断原来的通信,板子照常跑自己的程序:

逻辑分析仪接到哪里说明
D0ESP32-S3 的 GPIO13(SCL)也可以直接夹在 AHT20 的 SCL 脚上
D1ESP32-S3 的 GPIO14(SDA)同上
GND板子的 GND⭐ 一定要接,不然抓到的全是乱码

共地是第一位的

最常见的翻车就是只夹了信号线、忘了接地线。

高低电平是相对于 GND 来判断的,两边地不共,分析仪根本不知道多少伏算高。抓出来要么全是高,要么一片噪声。

第 2 步:写代码 ​

光扫地址还看不出什么,我们让板子真的去读一次温湿度,再去抓它。

AHT20 的读取流程手册上写得很死,就三步:

  1. 给它写 3 个字节 0xAC 0x33 0x00(意思是"开始测量")
  2. 等 80ms,让它测完
  3. 再读回 6 个字节,自己换算成温度和湿度

完整代码(可以直接替换掉 main.c):

c
#include <stdio.h>
#include <stdint.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/i2c.h"
#include "esp_err.h"

#define I2C_PORT        I2C_NUM_1
#define AHT20_ADDR      0x38             // 7 位地址,就是前面扫出来的那个
#define AHT20_SCL_GPIO  GPIO_NUM_13
#define AHT20_SDA_GPIO  GPIO_NUM_14
#define I2C_TIMEOUT_MS  100

// 配置 I2C 引脚和速度,然后装上驱动
static esp_err_t aht20_i2c_init(void)
{
    i2c_config_t conf = {
        .mode = I2C_MODE_MASTER,
        .sda_io_num = AHT20_SDA_GPIO,
        .scl_io_num = AHT20_SCL_GPIO,
        .sda_pullup_en = GPIO_PULLUP_ENABLE,
        .scl_pullup_en = GPIO_PULLUP_ENABLE,
        .master.clk_speed = 400000,
    };

    esp_err_t ret = i2c_param_config(I2C_PORT, &conf);
    if (ret != ESP_OK) {
        return ret;
    }

    return i2c_driver_install(I2C_PORT, I2C_MODE_MASTER, 0, 0, 0);
}

// 传感器上电后的初始化:发一次 0xBE 校准命令
static esp_err_t aht20_init(void)
{
    uint8_t cmd[] = {0xBE, 0x08, 0x00};
    esp_err_t ret = aht20_i2c_init();
    if (ret != ESP_OK) {
        return ret;
    }

    vTaskDelay(pdMS_TO_TICKS(40));       // 等传感器自己上电稳定

    ret = i2c_master_write_to_device(I2C_PORT, AHT20_ADDR, cmd, sizeof(cmd),
                                     pdMS_TO_TICKS(I2C_TIMEOUT_MS));
    if (ret != ESP_OK) {
        return ret;
    }

    vTaskDelay(pdMS_TO_TICKS(10));
    return ESP_OK;
}

static esp_err_t aht20_read(float *temperature, float *humidity)
{
    uint8_t cmd[] = {0xAC, 0x33, 0x00};  // 触发测量命令
    uint8_t data[6];

    // ① 写 3 个字节命令
    esp_err_t ret = i2c_master_write_to_device(I2C_PORT, AHT20_ADDR, cmd, sizeof(cmd),
                                               pdMS_TO_TICKS(I2C_TIMEOUT_MS));
    if (ret != ESP_OK) {
        return ret;
    }

    // ② 等它测完(手册要求至少 75ms,这里给 80ms)
    vTaskDelay(pdMS_TO_TICKS(80));

    // ③ 读回 6 个字节
    ret = i2c_master_read_from_device(I2C_PORT, AHT20_ADDR, data, sizeof(data),
                                      pdMS_TO_TICKS(I2C_TIMEOUT_MS));
    if (ret != ESP_OK) {
        return ret;
    }

    // data[0] 的最高位是"忙标志",还是 1 说明没测完
    if (data[0] & 0x80) {
        return ESP_ERR_INVALID_STATE;
    }

    // 湿度占 20 位,温度占 20 位,挤在 data[1]~data[5] 里
    uint32_t humidity_raw = ((uint32_t)data[1] << 12) | ((uint32_t)data[2] << 4) | (data[3] >> 4);
    uint32_t temperature_raw = (((uint32_t)data[3] & 0x0F) << 16) | ((uint32_t)data[4] << 8) | data[5];

    *humidity = humidity_raw * 100.0f / 1048576.0f;
    *temperature = temperature_raw * 200.0f / 1048576.0f - 50.0f;

    return ESP_OK;
}

static void aht20_task(void *arg)
{
    (void)arg;

    if (aht20_init() != ESP_OK) {
        printf("AHT20 init failed\n");
        vTaskDelete(NULL);
    }

    while (1) {
        float temperature = 0.0f;
        float humidity = 0.0f;
        esp_err_t ret = aht20_read(&temperature, &humidity);

        if (ret == ESP_OK) {
            printf("temperature: %.2f C, humidity: %.2f %%RH\n", temperature, humidity);
        } else {
            printf("AHT20 read failed: %s\n", esp_err_to_name(ret));
        }

        vTaskDelay(pdMS_TO_TICKS(1000));   // 每秒读一次
    }
}

void app_main(void)
{
    xTaskCreate(aht20_task, "aht20_task", 4096, NULL, 5, NULL);
}

第 3 步:编译烧录 ​

操作流程见 2.2 第 4 步

在 VS Code 底部状态栏依次点:编译 → 烧录(或者直接点"编译、烧录并监视")。

烧录方式、串口、芯片型号第一次设好就不用再动了。

第 4 步:串口应该每秒打印一行温湿度 ​

AHT20 读取代码和它打印出来的温湿度(红框里每秒一行)
AHT20 读取代码和它打印出来的温湿度(红框里每秒一行)

第 5 步:打开 ATK-Logic,进设备配置 ​

插上 USB,软件左下角会显示已连接的设备名(DL16 Plus),然后点右侧工具栏的**齿轮(设备配置)**图标。

左下角认出 DL16 Plus 说明设备连上了;点右边红框里的齿轮进设备配置
左下角认出 DL16 Plus 说明设备连上了;点右边红框里的齿轮进设备配置

第 6 步:选通道、改名字、设采样参数 ​

只勾 0、1 两个通道,把 D0 改名 SCL、D1 改名 SDA,阈值选 3.3V CMOS
只勾 0、1 两个通道,把 D0 改名 SCL、D1 改名 SDA,阈值选 3.3V CMOS

这一页有四个地方要改:

项目设成为什么
通道选择只勾 0 和 1只用两根线,通道开越多、能抓的时间越短
通道改名D0 → SCL,D1 → SDA后面解码要按名字对号入座,现在改好省事
触发阈值3.3V CMOSESP32-S3 是 3.3V 逻辑,选错了会把高电平认成低
时间 / 频率1m / 1MHz见下面的说明

采样率该设多高

采样率至少要是被测信号频率的 10 倍,不然一个电平还没看清就过去了。

我们的 I2C 跑 100kHz,1MHz 采样意味着每一位能采到 10 个点,足够还原波形了。

如果你把 I2C 提到 400kHz,采样率就要往上调到 4MHz 以上。

采集深度 60MSa/ch、运行模式 Stream 保持默认即可。**Stream(流模式)**是指数据一边采一边传给电脑,不受设备内存限制,适合抓时间比较长的过程。

第 7 步:加上 I2C 协议解码 ​

到这一步,软件只会给你看两条方波,还得手动一位一位数。协议解码就是让软件替你数。

点右侧工具栏的协议解码图标 → 在搜索框里输入 I2C → 选中列表里的 I²C。

右侧打开协议解码,搜索 I2C,选第一项 I²C(下面那些 I²C-Fast、demux 等是特殊变种,用不上)
右侧打开协议解码,搜索 I2C,选第一项 I²C(下面那些 I²C-Fast、demux 等是特殊变种,用不上)

第 8 步:告诉解码器哪根线是 SCL、哪根是 SDA ​

SCL 选 D0、SDA 选 D1,其余保持默认
SCL 选 D0、SDA 选 D1,其余保持默认

这一页只要改两处:

选项设成说明
SCLD0: SCL上一步改过名字,这里直接认得出来
SDAD1: SDA接反了会解不出任何东西

其余保持默认即可。 其中"从机地址格式"默认的 shifted 值得说一句 —— 它正好对应前面那个问题:

地址格式 shifted / unshifted 就是前面说的那两个版本

还记得前面"手册上的地址为什么有两个版本"吗?这里正好对应上:

  • shifted(默认):显示 7 位地址,AHT20 会显示成 0x38(和代码里填的一致)
  • unshifted:把地址和读写位合起来显示,同一个设备会变成 0x70 / 0x71

默认的 shifted 正好让解码结果和你代码里写的地址一模一样,省得对不上号还以为接错了。

点确定,配置就完成了。接下来点右上角的**播放(开始采集)**按钮,让板子跑起来,就能抓到波形了。

第 9 步:先看全景 —— 一次读取其实是两次通信 ​

点开始采集,抓一段,然后把视图缩到最小,会看到总线上是一簇一簇的活动,中间大片空白。

两个红框就是相邻的两簇通信,中间隔了 80ms —— 正好是代码里的 vTaskDelay(80)
两个红框就是相邻的两簇通信,中间隔了 80ms —— 正好是代码里的 vTaskDelay(80)

这张图就把一件事讲明白了:你代码里那一句 aht20_read(),在总线上其实是分开的两次通信。

红框时刻干了什么
左边那簇约 848ms主机写 0xAC 0x33 0x00,叫传感器开始测
中间空白80msvTaskDelay(80) —— 总线上什么都没有,传感器在自己测
右边那簇约 928ms主机读回 6 个字节

928 − 848 = 80ms,和代码里写的一分不差。

空白不是"卡住了"

新手看到总线大部分时间是空的容易慌。I2C 本来就是要用的时候才动,不用的时候两根线被上拉电阻拉在高电平上,静悄悄的,这是正常的。

顺带说,中间这 80ms 里 CPU 也没闲着 —— vTaskDelay() 会把这个任务挂起,让别的任务去跑。这就是 1.5 节 讲的阻塞态。

第 10 步:放大第一簇 —— 看主机怎么写命令 ​

把左边那簇放大,解码器已经替你把每一位翻译好了:

写命令:Address write 38 → ACK → AC → ACK → 33 → ACK → 00 → ACK → STOP
写命令:Address write 38 → ACK → AC → ACK → 33 → ACK → 00 → ACK → STOP

最上面三行就是解码结果,从上到下越来越"高层":

行显示的是
I²C:Bits每一位是 0 还是 1(最原始)
I²C:Address/data翻译成 地址 / 读写 / ACK / 数据字节
I²C:Packets整包总结:0x38 WR: AC 33 00

对照着看,和前面手册图上的顺序一模一样:

波形上显示含义对应代码里的
S(红色小圆点)START——
Address write: 38找 0x38 这个设备AHT20_ADDR
Writ(第 8 位)读写位 = 0,主机要写i2c_master_write_to_device
ACK传感器答应了 ⭐——
Data write: AC第 1 个字节cmd[0] = 0xAC
ACK收到——
Data write: 33第 2 个字节cmd[1] = 0x33
ACK收到——
Data write: 00第 3 个字节cmd[2] = 0x00
ACK收到——
PSTOP函数返回

再看地址那一格下面 I²C:Bits 行里的八位:0 1 1 1 0 0 0 然后是 0。

前 7 位 0111000 就是二进制的 0x38,第 8 位 0 就是写。这和前面手册图上讲的"第一个字节 = 地址 + 读写位"完全对得上 —— 只不过现在是你自己板子上跑出来的。

第 11 步:放大第二簇 —— 看主机怎么读数据 ​

读数据:Address read 38 → ACK → 连着读回 18 6E CD 96 3D F9 → STOP
读数据:Address read 38 → ACK → 连着读回 18 6E CD 96 3D F9 → STOP

I²C:Packets 行的总结是:0x38 RD: 18 6E CD 96 3D F9 —— 6 个字节一次全读回来了。

和上一步比,有两个地方不一样:

① 地址字节的最后一位变成了 1

看 I²C:Bits 行:0 1 1 1 0 0 0 然后是 1。

前 7 位还是 0111000(0x38 没变),但第 8 位从 0 变成了 1,意思是"这次我要读"。

这也解释了前面那个"手册上的地址为什么有两个版本":

写:0111000 + 0  = 0x70   ← 有的手册管这个叫"写地址"
读:0111000 + 1  = 0x71   ← 有的手册管这个叫"读地址"
代码里填的:0111000 = 0x38(7 位,不含读写位)

② ACK 是主机发的,不是传感器发的

读的时候方向反过来了:数据是传感器送出来的,所以每收到一个字节,要由主机回 ACK 说"我收到了,继续给"。

而最后一个字节后面主机故意不回 ACK(回 NACK),意思是"够了,别给了",然后发 STOP。

这就是为什么图里前 5 个字节后面都有黄色的 ACK,最后一个 F9 后面没有。

第 12 步:拿波形上的数据手算一遍 ​

最能说明问题的一步:波形上读到的 6 个字节,就是代码里 data[0] 到 data[5]。

data[0] = 0x18
data[1] = 0x6E
data[2] = 0xCD
data[3] = 0x96
data[4] = 0x3D
data[5] = 0xF9

按代码里的公式算:

先看忙标志

data[0] = 0x18 = 0b0001_1000,最高位是 0  →  不忙,数据有效

再算湿度

humidity_raw = (0x6E << 12) | (0xCD << 4) | (0x96 >> 4)
             = 0x6E000 | 0xCD0 | 0x9
             = 0x6ECD9 = 453849

humidity = 453849 * 100 / 1048576 = 43.28 %RH

再算温度

temperature_raw = ((0x96 & 0x0F) << 16) | (0x3D << 8) | 0xF9
                = 0x60000 | 0x3D00 | 0xF9
                = 0x63DF9 = 409081

temperature = 409081 * 200 / 1048576 - 50 = 28.03 C

回头看第 4 步那张日志图 —— 里面就有这么一行:

temperature: 28.03 C, humidity: 43.28 %RH

从波形一路算到串口打印,中间没有任何黑箱。

屏幕上那个 28.03 C,就是总线上那 6 个字节;那 6 个字节,就是 SDA 线上那几十次高低变化。抽象的"I2C 协议"到这里变成了你亲眼看到的东西。

那个 1048576 是哪来的

1048576 = 2^20。AHT20 的湿度和温度各占 20 位,所以原始值的范围是 0 ~ 2^20−1。

  • 湿度量程 0~100%RH:raw / 2^20 * 100
  • 温度量程 −50~150℃(跨度 200℃):raw / 2^20 * 200 - 50

手册上的公式就是这么来的,不用背。

六、SPI:接屏幕和 SD 卡 ​

SPI(Serial Peripheral Interface,串行外设接口)是一种高速同步串行总线,采用主从架构。它最多使用四根线,速度可达几十 MHz,适合传输大量数据。

四根线的作用:

线全称作用
SCLKSerial Clock时钟
MOSIMaster Out Slave In主机发给从机的数据
MISOMaster In Slave Out从机发给主机的数据
CSChip Select片选,"我现在跟你说话"

接屏幕的时候用不到 MISO ⭐

本课程的 TFT LCD 屏幕是只写不读的 —— 单片机只管往屏上画,从来不问屏幕要数据。

所以 MISO 那根线根本不接。

屏幕那边倒是多出两根不属于 SPI 总线的控制线:

线作用
DC区分接下来传的是命令还是数据(见 2.4)
RST复位

所以接屏幕实际是 SCL + SDA(MOSI) + CS + DC + RST 五根线。SD 卡才是真正四根线都用上的 —— 要从卡里读数据,MISO 必须接。

那代码里怎么发一个字节?本课程直接翻转普通 GPIO 来实现 SPI,叫"软件模拟 SPI"(也叫 bit-bang,一位一位地敲):

c
#define LCD_SCLK_GPIO   12          // 时钟
#define LCD_MOSI_GPIO   11          // 数据
#define LCD_CS_GPIO     10          // 片选

// 发一个字节:从最高位开始,一位一位地送出去
void SPI_Write_Byte(u8 dat)
{
    LCD_CS_Clr();                        // 片选拉低:我要跟你说话了

    for (int i = 0; i < 8; i++) {
        LCD_SCLK_Clr();                  // ① 时钟拉低
        if (dat & 0x80) {                // ② 取出最高位,放到数据线上
            LCD_MOSI_Set();
        } else {
            LCD_MOSI_Clr();
        }
        LCD_SCLK_Set();                  // ③ 时钟拉高 ← 对方在这一刻读走这一位
        dat <<= 1;                       // ④ 左移一位,准备发下一位
    }

    LCD_CS_Set();                        // 片选拉高:说完了
}

这个循环就是 SPI 的全部:时钟低电平时把数据准备好,时钟上升沿让对方读走,重复八次发完一个字节。下面讲的波形,画的就是这段代码跑起来的样子。

为什么用软件模拟

ESP32-S3 是有硬件 SPI 外设的,配好之后速度快得多,还能让 CPU 腾出手干别的。本课程用软件模拟,原因有三:

  1. 看得见:协议怎么一位一位拼出来的,就是上面这十几行,点进去就懂
  2. 引脚随便接:硬件 SPI 走特定引脚才能跑满速,软件模拟哪个 GPIO 都行
  3. 够用:时钟、天气这类画面变化不频繁的项目,软件模拟完全跑得动

完整的屏幕驱动怎么写,见 2.4。

先搞懂 mode:决定在哪个边沿采样 ​

看波形之前必须先弄清这个概念,否则图看不明白。

SPI 有四种工作模式,由两个参数组合而成:

  • CPOL:时钟空闲的时候是高电平还是低电平
  • CPHA:在时钟的第一个边沿还是第二个边沿采样数据
Mode时钟空闲时在哪个边沿采样常见程度
0低上升沿⭐ 最常用,大多数屏幕、传感器
1低下降沿少
2高下降沿少
3高上升沿部分 Flash、SD 卡

主机和从机的 mode 必须一致,代码里就是这一行:

c
.mode = 0,          // ⭐ 查从机手册,它写几你就填几

mode 填错会怎样

假设主机在上升沿发数据、从机在下降沿采样 —— 采样点正好落在数据跳变的瞬间,读到的是随机值。

表现就是:读出来全是 0x00 或全是 0xFF,而且换根线、降速度都没用。

遇到这种现象,第一个就该怀疑 mode。

波形长什么样(以最常用的 Mode 0 为例) ​

CSSCLKMOSIMISO1010110001010011拉低 = 我要跟你说话拉高 = 说完了▲ 每个上升沿,双方同时采样对方的数据线主机发 0xAC从机回 0x53

图中主机发出 0xAC(MOSI),从机同时回了 0x53(MISO)。

三个关键点:

① CS 拉低才开始,拉高就结束 —— 一条 SPI 总线上挂多个设备时,靠各自独立的 CS 线决定"现在跟谁说话"。没被选中的设备会把自己的输出脚变成高阻态,装作不存在。

② MOSI 和 MISO 是同时进行的 ⭐

看图里的两条数据线 —— 它们在同一个时钟节拍下一起动。

这叫全双工:每个时钟脉冲,主机往 MOSI 推出一位的同时,也从 MISO 收进一位。发 8 位的过程中,必然也收了 8 位。

这解释了一个新手困惑

"我只想读数据,为什么还要发东西?"

因为不发就没有时钟,没有时钟从机就不会输出。所以只读的时候,主机会发一堆无意义的字节(通常是 0xFF)纯粹为了产生时钟,把从机的数据"抽"出来。

反过来,只写的时候 MISO 上收到的数据直接丢弃就行。

③ 有时钟线,所以不需要约定速率 —— 这是它和 UART 最大的区别。UART 双方要事先说好波特率;SPI 由主机的 SCLK 直接指挥节拍,从机跟着走就行。这也是 SPI 能轻松跑到几十 MHz 的原因。

SPI 两个必查项

  1. mode 必须和设备手册一致(0/1/2/3),填错了读出来全是 0 或全是 255
  2. 速度别一上来就拉满。先用 10MHz 跑通,稳定了再往上加

动手做一遍:在 2.4 点亮屏幕 ​

SPI 这一节没有单独的小实验 —— 因为 2.4 点亮 TFT 屏幕 整节就是一次完整的 SPI 实战:

这里学的2.4 里怎么用
四根线 SCLK / MOSI / MISO / CS屏幕走的就是这几根
mode 要和从设备一致填错就是白屏或花屏
速度从慢往快调先 20MHz 跑通,再往上提
全双工,收发同时屏幕只收不发,MISO 干脆不接

把这一节的概念记住,直接去 2.4 动手。

七、多个任务共用一条总线 ​

如果两个任务同时用 I2C,会互相打断,数据就乱了。

解决办法(第一章 1.7):加一把互斥锁,或者干脆规定只让一个任务碰这条总线。

本节要点 ​

  • 总线 = 芯片之间的通信通道 + 一套双方遵守的协议
  • 串行 = 一根线一位位发(省线、能跑快);并行 = 多根线同时发(线多、高速对不齐)
  • 同步 = 有时钟线打拍子(I2C/SPI/I2S);异步 = 靠事先约定的速率(UART)
  • 全双工 = 能同时收发(UART、SPI,两根数据线);半双工 = 只能轮流(I2C,共用一根 SDA)
  • 调试用串口、传感器用 I2C、屏幕用 SPI
  • UART 异步,靠波特率约定,乱码先查波特率和共地
  • ESP32-S3 的 UART0(GPIO43/44)被系统占着,实验要用 UART1,引脚可以随便选
  • I2C 靠地址区分设备,需要上拉,读不到先扫地址
  • SPI 最快,mode 必须匹配,速度从慢往快调
  • 多任务共用总线要加锁
  • SPI 的实战在 2.4 点亮屏幕,这一节先把概念记住

动手试试 ​

前面每种总线都已经做过一次完整实验了,这几题是串起来的进阶练习:

  1. 对比两种总线的速度:分别用 UART(115200)和 I2C(400kHz)传 100 字节,用 esp_timer_get_time() 量一下各花多久,想想差在哪。
  2. 让 UART 变成一个命令行:收到 led on 就点灯、led off 就灭灯(用 2.2 的 LED)。
  3. 同时跑 UART 和 I2C:一个任务每秒读一次温湿度,另一个任务把结果通过 UART1 发出去 —— 注意共享数据要加锁(第一章 1.7)。
  4. 把 I2C 速度从 100kHz 提到 400kHz,看看还能不能稳定扫到设备;线接长一点再试一次。
  5. 想一想:如果要接两个相同型号的 I2C 传感器(地址一样),有什么办法?