外观
2.3 通信总线:UART / I2C / SPI
这节要搞懂:芯片之间怎么"说话",三种方式各用在哪。 需要基础:2.2
这一节怎么读
这节讲三种总线,每一种都是"概念 → 波形 → 代码 → 动手做一遍"一条龙:
| 小节 | 内容 |
|---|---|
| 一 ~ 三 | 共同的基础概念(三种总线都要用) |
| 四 | UART —— 讲完直接动手做回环测试 |
| 五 | I2C —— 讲完直接动手扫描设备 |
| 六 | SPI —— 实战放在 2.4 点亮屏幕 |
| 七 | 多任务共用总线要注意什么 |
想先学哪个就直接跳到哪一节,不用从头读到尾。
一、什么是通信总线
通信总线(Bus)是芯片之间传输数据的公共通道,包含物理连线和一套双方都遵守的通信协议(谁先说、什么时候说、一次说几位)。
打个比方:总线就是两个人之间的沟通方式 —— 打电话、开会、还是发专线电报,各有各的规矩。
二、三种总线速查表
| UART(串口) | I2C | SPI | |
|---|---|---|---|
| 打个比方 | 打电话,一对一 | 开会,主持人点名 | 专线,一人一条 |
| 用几根线 | 2 根 | 2 根 | 4 根 |
| 快不快 | 慢 | 中 | 很快 |
| 能接几个设备 | 1 个 | 一堆(靠地址区分) | 每个要单独一根片选线 |
| 能同时收发吗 | ✅ 全双工 | ❌ 半双工 | ✅ 全双工 |
| 主要用来 | 看日志、调试 | 接传感器 | 接屏幕、SD 卡 |
一句话记住
调试用串口,传感器用 I2C,屏幕用 SPI。
三、先分清三组概念
后面讲 UART 时会说它是"异步串行、全双工"通信。这几个词各是一组对立概念,先搞清楚。
串行 vs 并行 —— 数据怎么排队走
串行(Serial):数据一位一位地按先后顺序,在一根线上传输。
并行(Parallel):多位数据同时通过多根线传输。
打个比方:
- 串行 = 单车道,车只能一辆接一辆过
- 并行 = 八车道,八辆车并排一起过
并行看起来快 8 倍,那为什么现在几乎都用串行?
| 问题 | 说明 |
|---|---|
| 占引脚 | 传 8 位就要 8 根线,芯片引脚和电路板走线都是成本 |
| 跑不快 | 速度一高,8 根线上的信号到达时间对不齐(有的线长一点就慢一点),接收方不知道该什么时候一起采样 |
| 互相干扰 | 挨得近的线之间会串扰 |
所以现代的高速接口 —— USB、SATA、PCIe、以太网,全都是串行的。宁可一根线跑得飞快,也不要八根线互相拖后腿。
嵌入式里的例子
- 串行:UART、I2C、SPI、I2S —— 我们学的全是串行
- 并行:LCD 的 8080 并口(一次推 8 或 16 位像素)、老式打印机口
并行在短距离、要高带宽的场合还有一席之地,比如某些高分辨率屏幕。
同步 vs 异步 —— 靠什么对齐节拍
数据一位一位过来,接收方怎么知道什么时候该读一次?两种办法:
同步(Synchronous):有一根独立的时钟线,发送方一边发数据一边打拍子,接收方跟着拍子读。
异步(Asynchronous):没有时钟线,双方事先约定好速率,各自按这个速率数拍子。
打个比方:
- 同步 = 有指挥的合唱团 —— 指挥挥一下大家唱一个字,绝不会乱
- 异步 = 没有指挥,事先说好"每秒 120 拍" —— 各自在心里数,全靠约定
| 同步 | 异步 | |
|---|---|---|
| 要不要时钟线 | ✅ 要 | ❌ 不要 |
| 线数 | 多一根 | 少一根 |
| 速率要不要事先约定 | 不用,跟着时钟走 | 必须一模一样 |
| 能跑多快 | 快(几十 MHz) | 慢(通常几百 kbps) |
| 代表 | I2C、SPI、I2S | UART |
异步的两个代价
① 速率必须精确一致。 双方波特率差超过 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, ®, 1, data, 6, 1000);
// └─ 写 ─┘ └── 读 ──┘ 两个阶段,有先后② SPI 为什么"只读也得发点东西"
因为它是全双工,收和发是同一批时钟脉冲驱动的。不发就没有时钟,从机也就不会输出。
所以只想读的时候,主机会发一堆无意义的 0xFF 纯粹为了产生时钟(第六节还会细讲)。
对号入座
把三组概念放在一起,我们学的总线是这样分布的:
| 总线 | 串行/并行 | 同步/异步 | 双工方式 |
|---|---|---|---|
| UART | 串行 | 异步 | 全双工 |
| I2C | 串行 | 同步 | 半双工 |
| SPI | 串行 | 同步 | 全双工 |
| I2S | 串行 | 同步 | 单向为主 |
所以 UART = 异步 + 串行 + 全双工:没有时钟线,靠约定的波特率,TX/RX 两根线各走各的。
四、UART:你天天在用
UART(Universal Asynchronous Receiver/Transmitter,通用异步收发器)是一种异步串行通信接口。双方不共用时钟线,而是事先约定好速率(波特率),按位依次传输数据。
其实你已经在用它了:printf 打印出来的东西,就是通过串口传到电脑上的。
两条规矩:
- 波特率必须一致(双方约定的速度),最常用 115200
- 交叉接线:我的 TX(发) 接对方的 RX(收)
ESP32-S3 有三个串口,别搞混
| 串口 | 引脚 | 干什么用 |
|---|---|---|
| UART0 | GPIO43 / 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) ——
0x41的二进制是0100 0001,但线上先出来的是最右边的1
波特率就是"每一格多宽"
115200 波特率 = 每秒 115200 位 = 每一格约 8.68 微秒。
双方波特率不一致时,接收方数节拍的间隔就错了,采样点会飘到隔壁位上 —— 这就是乱码的根本原因。
串口乱码怎么办
按顺序查:
- 波特率对不对(99% 是这个,ESP32 默认 115200)
- 两块板子共地了吗(GND 必须连一起)
- 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 | 说明 |
|---|---|---|
| TX | GPIO18 | 模块发 → 板子收(RX) |
| RX | GPIO17 | 板子发(TX) → 模块收 |
| GND | GND | ⭐ 必须共地 |
两个最常见的错
① 没接 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 |
| 数据位 | 8 |
| 校验位 | None |
| 停止位 | 1 |
和代码里 uart_config 的四项完全对应 —— 任何一项不一致都会乱码。
电脑上可能有不止一个 COM 口,别选错
板子的 USB 线是一个 COM(看日志用),USB-TTL 模块是另一个 COM(这里要选的)。如果还插着别的串口设备,列表里会更多。
分不清就把模块拔掉看列表少了哪个。
第 6 步:发一句话
在下方输入框打 abc,点发送按钮。

接收区出现两行:
| 标记 | 含义 |
|---|---|
» abc | 你发出去的 |
« abc | 板子回传的 ⭐ |
底部计数也能看出来:发送 5,接收 5-5(abc 加上回车换行共 5 字节)。
第 7 步:同时看日志窗口

终端里出现:
UART1 RX: abc两边都有反应,才说明收发都通了。
五、I2C:接传感器就用它
I2C(Inter-Integrated Circuit)是一种同步串行总线,只需两根线:SDA(数据)和 SCL(时钟)。总线上可以挂多个设备,每个设备有唯一的地址,主机通过地址指定跟谁通信。
打个比方:I2C 就像开会 —— 主持人喊"3 号发言",只有 3 号会应答,其他人保持安静。
本课程板子上的 AHT20 走这两个脚:
| AHT20 温湿度传感器 | ESP32-S3 引脚 |
|---|---|
| SDA | GPIO14 |
| SCL | GPIO13 |
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 是两根线配合工作的,必须一起看:

这张图画的是一次完整的传输:主机先叫某个设备的名字,对方答应了,然后开始一个字节一个字节地传。
从左到右依次是: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),不用接线。要动手的只有逻辑分析仪。
它是并联在总线上"偷听"的,不会打断原来的通信,板子照常跑自己的程序:
| 逻辑分析仪 | 接到哪里 | 说明 |
|---|---|---|
| D0 | ESP32-S3 的 GPIO13(SCL) | 也可以直接夹在 AHT20 的 SCL 脚上 |
| D1 | ESP32-S3 的 GPIO14(SDA) | 同上 |
| GND | 板子的 GND | ⭐ 一定要接,不然抓到的全是乱码 |
共地是第一位的
最常见的翻车就是只夹了信号线、忘了接地线。
高低电平是相对于 GND 来判断的,两边地不共,分析仪根本不知道多少伏算高。抓出来要么全是高,要么一片噪声。
第 2 步:写代码
光扫地址还看不出什么,我们让板子真的去读一次温湿度,再去抓它。
AHT20 的读取流程手册上写得很死,就三步:
- 给它写 3 个字节
0xAC 0x33 0x00(意思是"开始测量") - 等 80ms,让它测完
- 再读回 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 步:编译烧录
第 4 步:串口应该每秒打印一行温湿度

第 5 步:打开 ATK-Logic,进设备配置
插上 USB,软件左下角会显示已连接的设备名(DL16 Plus),然后点右侧工具栏的**齿轮(设备配置)**图标。

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

这一页有四个地方要改:
| 项目 | 设成 | 为什么 |
|---|---|---|
| 通道选择 | 只勾 0 和 1 | 只用两根线,通道开越多、能抓的时间越短 |
| 通道改名 | D0 → SCL,D1 → SDA | 后面解码要按名字对号入座,现在改好省事 |
| 触发阈值 | 3.3V CMOS | ESP32-S3 是 3.3V 逻辑,选错了会把高电平认成低 |
| 时间 / 频率 | 1m / 1MHz | 见下面的说明 |
采样率该设多高
采样率至少要是被测信号频率的 10 倍,不然一个电平还没看清就过去了。
我们的 I2C 跑 100kHz,1MHz 采样意味着每一位能采到 10 个点,足够还原波形了。
如果你把 I2C 提到 400kHz,采样率就要往上调到 4MHz 以上。
采集深度 60MSa/ch、运行模式 Stream 保持默认即可。**Stream(流模式)**是指数据一边采一边传给电脑,不受设备内存限制,适合抓时间比较长的过程。
第 7 步:加上 I2C 协议解码
到这一步,软件只会给你看两条方波,还得手动一位一位数。协议解码就是让软件替你数。
点右侧工具栏的协议解码图标 → 在搜索框里输入 I2C → 选中列表里的 I²C。

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

这一页只要改两处:
| 选项 | 设成 | 说明 |
|---|---|---|
| SCL | D0: SCL | 上一步改过名字,这里直接认得出来 |
| SDA | D1: SDA | 接反了会解不出任何东西 |
其余保持默认即可。 其中"从机地址格式"默认的 shifted 值得说一句 —— 它正好对应前面那个问题:
地址格式 shifted / unshifted 就是前面说的那两个版本
还记得前面"手册上的地址为什么有两个版本"吗?这里正好对应上:
- shifted(默认):显示 7 位地址,AHT20 会显示成 0x38(和代码里填的一致)
- unshifted:把地址和读写位合起来显示,同一个设备会变成 0x70 / 0x71
默认的 shifted 正好让解码结果和你代码里写的地址一模一样,省得对不上号还以为接错了。
点确定,配置就完成了。接下来点右上角的**播放(开始采集)**按钮,让板子跑起来,就能抓到波形了。
第 9 步:先看全景 —— 一次读取其实是两次通信
点开始采集,抓一段,然后把视图缩到最小,会看到总线上是一簇一簇的活动,中间大片空白。

这张图就把一件事讲明白了:你代码里那一句 aht20_read(),在总线上其实是分开的两次通信。
| 红框 | 时刻 | 干了什么 |
|---|---|---|
| 左边那簇 | 约 848ms | 主机写 0xAC 0x33 0x00,叫传感器开始测 |
| 中间空白 | 80ms | vTaskDelay(80) —— 总线上什么都没有,传感器在自己测 |
| 右边那簇 | 约 928ms | 主机读回 6 个字节 |
928 − 848 = 80ms,和代码里写的一分不差。
空白不是"卡住了"
新手看到总线大部分时间是空的容易慌。I2C 本来就是要用的时候才动,不用的时候两根线被上拉电阻拉在高电平上,静悄悄的,这是正常的。
顺带说,中间这 80ms 里 CPU 也没闲着 —— vTaskDelay() 会把这个任务挂起,让别的任务去跑。这就是 1.5 节 讲的阻塞态。
第 10 步:放大第一簇 —— 看主机怎么写命令
把左边那簇放大,解码器已经替你把每一位翻译好了:

最上面三行就是解码结果,从上到下越来越"高层":
| 行 | 显示的是 |
|---|---|
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 | 收到 | —— |
| P | STOP | 函数返回 |
再看地址那一格下面 I²C:Bits 行里的八位:0 1 1 1 0 0 0 然后是 0。
前 7 位 0111000 就是二进制的 0x38,第 8 位 0 就是写。这和前面手册图上讲的"第一个字节 = 地址 + 读写位"完全对得上 —— 只不过现在是你自己板子上跑出来的。
第 11 步:放大第二簇 —— 看主机怎么读数据

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,适合传输大量数据。
四根线的作用:
| 线 | 全称 | 作用 |
|---|---|---|
| SCLK | Serial Clock | 时钟 |
| MOSI | Master Out Slave In | 主机发给从机的数据 |
| MISO | Master In Slave Out | 从机发给主机的数据 |
| CS | Chip 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 腾出手干别的。本课程用软件模拟,原因有三:
- 看得见:协议怎么一位一位拼出来的,就是上面这十几行,点进去就懂
- 引脚随便接:硬件 SPI 走特定引脚才能跑满速,软件模拟哪个 GPIO 都行
- 够用:时钟、天气这类画面变化不频繁的项目,软件模拟完全跑得动
完整的屏幕驱动怎么写,见 2.4。
先搞懂 mode:决定在哪个边沿采样
看波形之前必须先弄清这个概念,否则图看不明白。
SPI 有四种工作模式,由两个参数组合而成:
- CPOL:时钟空闲的时候是高电平还是低电平
- CPHA:在时钟的第一个边沿还是第二个边沿采样数据
| Mode | 时钟空闲时 | 在哪个边沿采样 | 常见程度 |
|---|---|---|---|
| 0 | 低 | 上升沿 | ⭐ 最常用,大多数屏幕、传感器 |
| 1 | 低 | 下降沿 | 少 |
| 2 | 高 | 下降沿 | 少 |
| 3 | 高 | 上升沿 | 部分 Flash、SD 卡 |
主机和从机的 mode 必须一致,代码里就是这一行:
c
.mode = 0, // ⭐ 查从机手册,它写几你就填几mode 填错会怎样
假设主机在上升沿发数据、从机在下降沿采样 —— 采样点正好落在数据跳变的瞬间,读到的是随机值。
表现就是:读出来全是 0x00 或全是 0xFF,而且换根线、降速度都没用。
遇到这种现象,第一个就该怀疑 mode。
波形长什么样(以最常用的 Mode 0 为例)
图中主机发出 0xAC(MOSI),从机同时回了 0x53(MISO)。
三个关键点:
① CS 拉低才开始,拉高就结束 —— 一条 SPI 总线上挂多个设备时,靠各自独立的 CS 线决定"现在跟谁说话"。没被选中的设备会把自己的输出脚变成高阻态,装作不存在。
② MOSI 和 MISO 是同时进行的 ⭐
看图里的两条数据线 —— 它们在同一个时钟节拍下一起动。
这叫全双工:每个时钟脉冲,主机往 MOSI 推出一位的同时,也从 MISO 收进一位。发 8 位的过程中,必然也收了 8 位。
这解释了一个新手困惑
"我只想读数据,为什么还要发东西?"
因为不发就没有时钟,没有时钟从机就不会输出。所以只读的时候,主机会发一堆无意义的字节(通常是 0xFF)纯粹为了产生时钟,把从机的数据"抽"出来。
反过来,只写的时候 MISO 上收到的数据直接丢弃就行。
③ 有时钟线,所以不需要约定速率 —— 这是它和 UART 最大的区别。UART 双方要事先说好波特率;SPI 由主机的 SCLK 直接指挥节拍,从机跟着走就行。这也是 SPI 能轻松跑到几十 MHz 的原因。
SPI 两个必查项
mode必须和设备手册一致(0/1/2/3),填错了读出来全是 0 或全是 255- 速度别一上来就拉满。先用 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 点亮屏幕,这一节先把概念记住
动手试试
前面每种总线都已经做过一次完整实验了,这几题是串起来的进阶练习:
- 对比两种总线的速度:分别用 UART(115200)和 I2C(400kHz)传 100 字节,用
esp_timer_get_time()量一下各花多久,想想差在哪。 - 让 UART 变成一个命令行:收到
led on就点灯、led off就灭灯(用 2.2 的 LED)。 - 同时跑 UART 和 I2C:一个任务每秒读一次温湿度,另一个任务把结果通过 UART1 发出去 —— 注意共享数据要加锁(第一章 1.7)。
- 把 I2C 速度从 100kHz 提到 400kHz,看看还能不能稳定扫到设备;线接长一点再试一次。
- 想一想:如果要接两个相同型号的 I2C 传感器(地址一样),有什么办法?