ESP32-S3 SPI 通信与 LCD 驱动

7535 字
38 分钟
ESP32-S3 SPI 通信与 LCD 驱动

ESP32-S3 SPI 通信与 LCD 驱动#

本章重要 API 函数#

ESP-IDF SPI 主机驱动 API#

API功能参数说明头文件
spi_bus_initialize()初始化 SPI 总线,配置 IO 复用和 DMAspi_host_device_t host_id:SPI 外设编号(SPI2_HOST/SPI3_HOST)
const spi_bus_config_t *bus:总线配置结构体(IO 引脚、DMA、标志位)
返回 esp_err_t:ESP_OK 成功,ESP_ERR_INVALID_ARG 参数错误
"driver/spi_master.h"
spi_bus_add_device()向总线添加 SPI 设备,返回设备句柄spi_host_device_t host_id:SPI 外设编号
const spi_device_interface_config_t *dev:设备配置(时钟、模式、队列深度)
spi_device_handle_t *handle:输出设备句柄
返回 esp_err_t
"driver/spi_master.h"
spi_device_polling_transmit()轮询方式发送 SPI 事务(阻塞)spi_device_handle_t handle:设备句柄
spi_transaction_t *trans:事务描述符(含 tx_buffer、length、rx_buffer)
返回 esp_err_t
"driver/spi_master.h"
spi_transaction_tSPI 事务描述符结构体.length:传输总长度(单位 bit,必须为 8 的倍数)
.tx_buffer:发送缓冲区指针(为 NULL 时填 0)
.rx_buffer:接收缓冲区指针(为 NULL 时丢弃)
.flags:SPI_TRANS_* 标志位
"driver/spi_master.h"
spi_bus_config_tSPI 总线配置结构体.mosi_io_num:MOSI 数据输出引脚
.miso_io_num:MISO 数据输入引脚(不用设 -1)
.sclk_io_num:SCLK 时钟引脚
.max_transfer_sz:DMA 单次最大传输字节数
.flags:SPICOMMON_BUSFLAG_MASTER 等
"driver/spi_master.h"
spi_device_interface_config_tSPI 设备接口配置结构体.clock_speed_hz:SPI 时钟频率(Hz),最高 80MHz
.mode:SPI 模式 0~3(CPOL+CPHA)
.queue_size:事务队列深度
.spics_io_num:硬件 CS 引脚(软件 CS 设 -1)
.clock_source:时钟源(默认 XTAL)
"driver/spi_master.h"

项目封装 SPI 驱动 API#

API功能参数说明头文件
spi2_init()初始化 SPI2 总线并注册 LCD 设备void:无参数,内部配置 MOSI=GPIO11、SCLK=GPIO12、MISO=GPIO13、CS=GPIO48、60MHz、Mode 0、开启 DMA"spi.h"
spi2_write_data()向 SPI 设备发送数据块(纯写)uint8_t *data:发送数据指针
uint32_t len:发送字节数(≤ 115200)
无返回值,内部调用 spi_device_polling_transmit
"spi.h"

项目封装 LCD 驱动 API#

API功能参数说明头文件
lcd_init()初始化 LCD:配置控制 GPIO → 硬件复位 → 寄存器序列 → 清屏白色void:无参数"lcd.h"
lcd_write_cmd()发送 LCD 命令字节uint8_t cmd:命令码(如 0x11=Sleep Out, 0x29=Display On, 0x2C=Memory Write)"lcd.h"(内部函数)
lcd_write_data()发送 LCD 单字节数据uint8_t data:数据字节"lcd.h"(内部函数)
lcd_write_data16()发送 LCD 双字节数据(高字节在前)uint16_t data:16 位数据值"lcd.h"(内部函数)
lcd_write_datan()批量发送 LCD 数据uint8_t *data:数据缓冲区指针
uint16_t length:发送字节数
"lcd.h"(内部函数)
lcd_set_window()设置 LCD 矩形绘图窗口uint16_t xstar:起始 X 坐标
uint16_t ystar:起始 Y 坐标
uint16_t xend:结束 X 坐标
uint16_t yend:结束 Y 坐标
"lcd.h"(内部函数)
lcd_show_dot()在固定位置画一个像素点uint16_t color:RGB565 颜色值"lcd.h"
lcd_show_line()画一条 100 像素的水平色块uint16_t color:RGB565 颜色值"lcd.h"
lcd_clear()全屏填充单色uint16_t color:RGB565 颜色值"lcd.h"
lcd_show_picture()显示 240×240 RGB565 位图uint8_t *img:图片数据指针(115200 字节)"lcd.h"

本章目录#

点击条目可跳转到对应小节。

章节导航#


学习前后依赖#

方向内容
前置基础GPIO 输出配置(01_LED)、GPIO 输入/开漏/推挽概念(04_Key_board)、I2C 通信协议与主从时序理解(14_IICBH1750)、轮询/中断/DMA 三种传输模式的区分(0-1_under_Loop_stu #10 章)
本章核心SPI 四线串行通信原理与 Mode 0~3 时钟极性和相位、ESP32-S3 SPI 主机驱动架构(总线→设备→事务→DMA)、GPIO 交换矩阵自动分配 MOSI/SCLK/CS、SPI LCD 的命令/数据双通道协议(DC 引脚切换)、ST7789-like 初始化序列寄存器分析、RGB565 像素编码与 240×240 帧数据组织、DMA 分段批量传输策略
后续承接ESP-IDF lvgl 图形库(LVGL 底层通过 SPI 驱动 LCD)、双缓冲同步渲染(避免撕裂)、SPI Flash/NOR 存储芯片驱动、SPI 从机模式(双 MCU 通信)、QSPI Octal 高速模式

一、SPI 通信原理#

1.1 SPI 总线物理结构#

SPI(Serial Peripheral Interface)是一种全双工、同步、主从架构的串行通信总线,由 Motorola 发明。典型的 4 线制 SPI 定义如下:

总览图:四线 SPI 拓扑#

主机(ESP32-S3) 从机(LCD 驱动芯片)
┌─────────────────┐ ┌──────────────────┐
│ │ SCLK ──────►│ │
│ SPI Master │ MOSI ──────►│ SPI Slave │
│ (SPI2_HOST) │ MISO ──────│◄────(LCD 不接) │
│ │ CS ──────►│ │
└─────────────────┘ └──────────────────┘
+ 额外控制线(非 SPI 标准,LCD 专用):
主机 从机
GPIO47 ─────── DC ─────────► 区分命令/数据
GPIO21 ─────── RST ─────────► 硬件复位
GPIO40 ─────── BLK ─────────► 背光使能

信号线说明#

信号方向作用本章引脚
SCLK (Serial Clock)主机→从机同步时钟,所有数据传输的节拍器GPIO12
MOSI (Master Out Slave In)主机→从机主机发送数据的主通道GPIO11
MISO (Master In Slave Out)从机→主机从机回复数据(LCD 不读故不接)GPIO13(预留)
CS (Chip Select)主机→从机低电平选中从机,多设备时区分目标GPIO48
DC (Data/Command)主机→从机LCD 特有:0=命令,1=数据GPIO47
RST (Reset)主机→从机硬件复位 LCD 驱动芯片GPIO21
BLK (Backlight)主机→从机背光开关控制GPIO40

局部图:MOSI 上的数据流#

MOSI 数据线 (GPIO11):
位顺序: MSB ──────────────► LSB
┌──┬──┬──┬──┬──┬──┬──┬──┐
│D7│D6│D5│D4│D3│D2│D1│D0│
└──┴──┴──┴──┴──┴──┴──┴──┘
主机在 SCLK 边沿送出每个 bit,从机在相反的边沿锁存。
一次事务可发送任意字节数(由 t.length 控制 bit 总数)。

CS 信号的作用: 主机拉低 CS → 开始与选中的从机通信 → 发送数据 → 拉高 CS 释放总线。多个 SPI 从机挂在同一组 SCLK/MOSI/MISO 上,依靠各自的 CS 线区分。

1.2 SPI 四种工作模式(CPOL + CPHA)#

SPI 定义了 CPOL(Clock Polarity,时钟极性)和 CPHA(Clock Phase,时钟相位)两个参数:

  • CPOL = 0:SCLK 空闲时为低电平
  • CPOL = 1:SCLK 空闲时为高电平
  • CPHA = 0:在第 1 个时钟边沿采样数据
  • CPHA = 1:在第 2 个时钟边沿采样数据

时序图:四种模式对比(以 1 字节为例)#

Mode 0 (CPOL=0, CPHA=0) — 本章使用:
SCLK 空闲低,上升沿采样
┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐
SCLK ─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └──
↑ ↑ ↑ ↑ ↑ ↑ ↑ ↑ ← 采样点(上升沿)
MOSI ──D7──D6──D5──D4──D3──D2──D1──D0─── 主机在下降沿换数据
← 主机在 SCLK 下降沿切换数据 →
Mode 2 (CPOL=1, CPHA=0):
┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐ ┌─┐
SCLK ┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘ └─┘──
↑ ← 下降沿采样

对照表#

SPI ModeCPOLCPHA空闲 SCLK采样边沿数据切换边沿
Mode 000上升沿下降沿
Mode 101下降沿上升沿
Mode 210下降沿上升沿
Mode 311上升沿下降沿

本章 LCD 使用 Mode 0.mode = 0,时钟空闲为低,在 SCLK 上升沿锁存数据。这是大多数 SPI 外设的默认选择。

1.3 SPI 数据帧与时序#

SPI 没有固定的帧格式——没有起始位、停止位、校验位。唯一的规则就是 SCLK:主机每产生一个时钟脉冲,总线上就有一位数据被传输。

状态图:一次完整 SPI 事务#

┌──────────┐
│ CS = HIGH│ ← 总线空闲,从机忽略 SCLK/MOSI
└────┬─────┘
│ 主机拉低 CS
┌──────────┐
│ CS = LOW │ ← 从机被激活,准备接收
└────┬─────┘
│ 主机产生 SCLK + 逐字节发送
┌──────────┐
│ 传输 N │ ← t.length 比特的数据在 MOSI 上传完
│ 字节数据 │
└────┬─────┘
│ 主机拉高 CS
┌──────────┐
│ CS = HIGH│ ← 从机锁存已收数据,释放总线
└──────────┘

关键认识: SPI 的”帧”由 CS 的下降沿和上升沿界定。一次 CS↓→CS↑ 就是一帧。帧内可以发送任意字节数——1 字节的命令、2 字节的坐标、11520 字节的像素数据,都由 t.lengtht.tx_buffer 控制。

1.4 ESP32-S3 SPI 硬件架构#

ESP32-S3 内部集成了 4 个 SPI 外设

SPI 外设用途本章使用
SPI0内部 Flash 访问(用户不可用)
SPI1内部 PSRAM 访问(用户不可用)
SPI2通用 SPI,又称 HSPI✓ 连接 LCD
SPI3通用 SPI,又称 VSPI预留扩展

总览图:ESP32-S3 SPI 驱动分层#

┌──────────────────────────────────────┐
│ 应用程序 (app_main) │
│ lcd_init() → lcd_show_picture() │
└──────────────────┬───────────────────┘
┌──────────────────▼───────────────────┐
│ LCD 封装层 (lcd.c) │
│ lcd_write_cmd / lcd_write_data16 │
│ lcd_set_window / lcd_clear │
│ ┌─ DC/RST/BLK GPIO 直接控制 │
│ └─ 寄存器序列 / 像素格式转换 │
└──────────────────┬───────────────────┘
┌──────────────────▼───────────────────┐
│ SPI 封装层 (spi.c) │
│ spi2_init() / spi2_write_data() │
└──────────────────┬───────────────────┘
┌──────────────────▼───────────────────┐
│ ESP-IDF SPI Master Driver │
│ spi_bus_initialize │
│ spi_bus_add_device │
│ spi_device_polling_transmit │
│ ┌─ GPIO 交换矩阵自动分配 IO │
│ ├─ DMA 引擎自动搬运大数据 │
│ └─ 事务队列 (queue_size=7) │
└──────────────────┬───────────────────┘
┌──────────────────▼───────────────────┐
│ ESP32-S3 SPI2 硬件控制器 │
│ 移位寄存器 + 时钟发生器 + CS 控制 │
│ ───────────────┬─────────────────── │
│ GPIO11(MOSI) GPIO12(SCLK) │
│ GPIO13(MISO) GPIO48(CS) │
└──────────────────┴───────────────────┘

局部图:事务描述符与 DMA 协作#

应用程序调用:
spi2_write_data(buf, len)
填充 spi_transaction_t:
┌──────────────────────┐
│ .tx_buffer = buf │ ← 指向数据源
│ .length = len * 8 │ ← 总比特数
│ .rx_buffer = NULL │ ← 不读回
└──────────────────────┘
spi_device_polling_transmit(spi2_handle, &t)
ESP-IDF 驱动自动决策:
┌─ len ≤ 64B → CPU 填充 FIFO(无 DMA)
└─ len > 64B → DMA 引擎自动搬运
DMA 描述符链:
┌─ desc[0]: 指向 buf[0..4095]
├─ desc[1]: 指向 buf[4096..8191]
└─ ... 分段描述,无需 CPU 干预

1.5 SPI 与 I2C 的对比#

维度SPII2C
信号线数4 线(SCLK + MOSI + MISO + CS)2 线(SCL + SDA)
通信模式全双工(同时收发)半双工(分时收发)
时钟速度60 MHz(本章)~ 80 MHz100 kHz ~ 400 kHz(标准)
从机寻址硬件 CS 线(每从机一根线)软件地址(7/10 位地址字节)
帧格式无固定帧,CS 界定START → 地址 → 数据 → STOP
ACK 机制无(一发到底,不确认)每字节后 1 位 ACK/NACK
多从机扩展每加一个从机,多拉一根 CS 线理论上 127 个(同总线)
距离极短(PCB 级,厘米)短距离(板间,数十厘米)

本章用 SPI 而不用 I2C 的原因: LCD 需要高速刷屏。240×240×2 = 115200 字节/帧,I2C 400kHz 需要约 2.3 秒一帧,SPI 60MHz 只需要约 15ms。差 150 倍。


二、SPI LCD 驱动芯片与命令#

2.1 LCD 引脚定义与控制信号#

本章 LCD 模块的控制引脚(在 lcd.h 中通过宏定义):

宏定义GPIO功能含义
LCD_RST(x)GPIO21Reset 复位0 = 复位,1 = 正常运行
LCD_DC(x)GPIO47Data/Command0 = 命令,1 = 数据
LCD_CS(x)GPIO48Chip Select0 = 选中(SPI 硬件自动控制)
LCD_BLK(x)GPIO40Backlight1 = 开背光,0 = 关背光

注意区分 CS 和 DC: CS 是 SPI 标准的片选信号(在 SPI 设备配置中通过 .spics_io_num = GPIO_NUM_48 设置,由 ESP-IDF 驱动自动控制);DC 是 LCD 特有的命令/数据选择线,由 LCD 驱动层代码手动控制(LCD_DC(0) 写命令,LCD_DC(1) 写数据)。两者不能混淆。

2.2 LCD 命令行协议#

SPI LCD 使用一种双通道协议——同一根 MOSI 线上,通过 DC 引脚的高低电平区分:

  • DC = 0(Command mode): MOSI 上的字节被解释为命令码
  • DC = 1(Data mode): MOSI 上的字节被解释为命令参数或像素数据

状态图:命令与数据的交替#

lcd_write_cmd(0x2A):
┌─ CS=0 ─► DC=0 ─► 发送 0x2A ─► CS=1 ─┐ ← 告诉 LCD: 我要设 X 坐标
└────────────────────────────────────┘
lcd_write_data16(0x0064): ← LCD 知道: 这是 X 坐标的参数
┌─ CS=0 ─► DC=1 ─► 发送 0x00,0x64 ─► CS=1 ─┐
└────────────────────────────────----──┘
一次完整的窗口设置 = 命令 + 参数:
┌──────┐ ┌────────────────┐
│DC=0 │ │DC=1 │
│0x2A │ │xstar(2B) xend(2B)│ ← Column Address Set
└──────┘ └────────────────┘
┌──────┐ ┌────────────────┐
│DC=0 │ │DC=1 │
│0x2B │ │ystar(2B) yend(2B)│ ← Row Address Set
└──────┘ └────────────────┘
┌──────┐
│DC=0 │
│0x2C │ ← Memory Write(无参数)
└──────┘
┌──── DC=1, 连续发送像素数据 ────┐ ← 像素流

2.3 初始化序列详解#

lcd_init() 中的寄存器初始化序列来自 LCD 驱动芯片的数据手册(ST7789 或兼容芯片)。按功能分类如下:

总览图:初始化序列分类#

硬件复位
├─ Sleep Out (0x11) ← 退出睡眠,延时 120ms
├─ Porch 时序 (0xB2) ← 前后肩参数
├─ VCOM 控制 (0xBB) ← VCOM 偏置电压
├─ 帧率 (0xC6) ← 帧频 = 60Hz
├─ 伽马校正 (0xE0, 0xE1) ← 正/负极伽马曲线
├─ 颜色格式 (0x3A) ← 16 位 (RGB565)
├─ 内存访问控制 (0x36) ← 扫描方向
├─ Display Inversion On (0x21)
├─ Display On (0x29)
└─ Memory Write (0x2C) ← 准备接收像素数据

关键寄存器详解#

0x3A(Interface Pixel Format)— 像素格式

lcd_write_cmd(0x3A);
lcd_write_data(0x05); // 0x05 = 16 bits/pixel(RGB565)
取值含义:
0x05 → RGB565 (16bpp): RRRRRGGGGGGBBBBB
0x06 → RGB666 (18bpp): 每通道 6 位
0x03 → 12bpp

0x36(Memory Data Access Control)— 扫描方向

lcd_write_cmd(0x36);
lcd_write_data(0x70);
0x70 = 0b_0111_0000:
┌─ MY = 1: 行地址自上而下递增
├─ MX = 1: 列地址自左向右递增
├─ MV = 1: 行列交换(横屏/竖屏切换)
└─ BGR = 0: RGB 顺序(非 BGR)

0xB2(Porch Control)— 前后肩时序

定义显示区域外围的空白像素行/列(类似 CRT 的消隐期)。
5 个参数: 0x0C,0x0C,0x00,0x33,0x33
分别控制: 前肩行数、后肩行数、前肩列数、后肩列数

0xE0 / 0xE1(Gamma Correction)— 伽马校正

0xE0: 正极性伽马曲线(14 字节)
0xE1: 负极性伽马曲线(14 字节)
LCD 用交流驱动防止液晶极化,正负极性各有一套调色曲线。
每条曲线 14 个可编程电压节点 (PRP1~PRP14, PRN1~PRN14),
控制从 0% 到 100% 亮度的非线性映射关系。

三、软件架构传递树#

main.c
├─ app_main()
│ ├─ lcd_init() // [lcd.c]
│ │ ├─ spi2_init() // [spi.c]
│ │ │ ├─ spi_bus_initialize(SPI2_HOST, &spibus, SPI_DMA_CH_AUTO)
│ │ │ │ └─ 配置: MOSI=11, MISO=13, SCLK=12
│ │ │ │ max_transfer_sz = 115200
│ │ │ │ DMA 自动分配通道
│ │ │ └─ spi_bus_add_device(SPI2_HOST, &spidev, &spi2_handle)
│ │ │ └─ 配置: 60MHz, Mode 0, CS=48, queue=7
│ │ │
│ │ ├─ gpio_config(&...) ×3 // GPIO21(RST), GPIO47(DC), GPIO40(BLK)
│ │ │
│ │ ├─ lcd_hard_reset() // RST 拉低 100ms → 拉高 100ms
│ │ │
│ │ ├─ lcd_on() // BLK=1, 开背光
│ │ │
│ │ ├─ 寄存器初始化序列:
│ │ │ ├─ lcd_write_cmd(0x11) → Sleep Out
│ │ │ ├─ lcd_write_cmd(0xB2) + 5×data → Porch
│ │ │ ├─ ... (共 26 组命令)
│ │ │ └─ lcd_write_cmd(0x2C) → Memory Write
│ │ │
│ │ └─ lcd_clear(WHITE) // 全屏刷白
│ │ └─ lcd_set_window(0,0,239,239)
│ │ └─ lcd_write_datan(lcd_buf, 11520) × 10 次
│ │ └─ spi2_write_data(buf, len) [spi.c]
│ │ └─ spi_device_polling_transmit(handle, &t)
│ │ └─ ESP-IDF → DMA 引擎 → SPI2 硬件
│ │
│ └─ lcd_show_picture(gImage_image)
│ └─ lcd_set_window(0,0,239,239)
│ └─ 数据重组: 将 uint8 数组按 RGB565 组装到 lcd_buf
│ └─ lcd_write_datan(lcd_buf, 11520) × 10 次
│ └─ spi2_write_data(...) → DMA → SPI2
├─ components/ (CMakeLists.txt)
│ ├─ SPI/ ← spi.c + spi.h
│ ├─ LCD/ ← lcd.c + lcd.h
│ └─ LED/ ← led.c + led.h(本章未实际使用 LED 功能)
└─ main/CMakeLists.txt → main.c

调用关系总结:

app_main() → lcd_init() → spi2_init() + 寄存器序列
→ lcd_show_picture() → lcd_set_window() + lcd_write_datan()
所有 LCD 写操作最终汇聚到:
lcd_write_cmd() --─┐
lcd_write_data() -─┤
lcd_write_data16()─┼──→ spi2_write_data() → spi_device_polling_transmit()
lcd_write_datan() ─┘

四、代码详细解析#

4.1 SPI 驱动层 — spi2_init()#

源码位置:components/SPI/spi.c

void spi2_init(void)
{
// 第一步: 配置 SPI 总线
spi_bus_config_t spibus_structure = {
.flags = SPICOMMON_BUSFLAG_MASTER, // 主机模式
.intr_flags = 0, // 不使用中断
.isr_cpu_id = INTR_CPU_ID_AUTO, // 自动分配 ISR CPU
.max_transfer_sz = 240*240*2, // DMA 最大传输 = 115200 字节
.miso_io_num = GPIO_NUM_13, // MISO(预留,LCD 不读)
.mosi_io_num = GPIO_NUM_11, // MOSI 数据输出
.quadhd_io_num = -1, // 不使用四线模式
.quadwp_io_num = -1, // 不使用四线模式
.sclk_io_num = GPIO_NUM_12, // SCLK 时钟
};
spi_bus_initialize(SPI2_HOST, &spibus_structure, SPI_DMA_CH_AUTO);
// 第二步: 注册 LCD 设备
spi_device_interface_config_t spidevice_structure = {
.clock_source = SPI_CLK_SRC_DEFAULT, // 默认时钟源(XTAL)
.clock_speed_hz = 60000000, // 60 MHz
.mode = 0, // SPI Mode 0 (CPOL=0, CPHA=0)
.queue_size = 7, // 事务队列深度
.spics_io_num = GPIO_NUM_48, // 硬件 CS 引脚
};
spi_bus_add_device(SPI2_HOST, &spidevice_structure, &spi2_handle);
}

设计要点:

  1. 为什么用 SPI2 而不是 SPI3? SPI0/1 被内部 Flash/PSRAM 占用。SPI2(HSPI) 和 SPI3(VSPI) 都可用,这里使用 SPI2。

  2. max_transfer_sz = 240*240*2 = 115200 的含义: 告诉 ESP-IDF 为 DMA 准备足够大的描述符内存。如果设为默认值 4096,一次只能 DMA 搬运 4KB,发送 115200 字节需要约 29 次 DMA 操作,效率低。

  3. queue_size = 7 事务队列可以预存 7 个 SPI 事务。在使用轮询模式时实际不会用到这个队列,但如果改为中断模式,队列就会发挥作用。

  4. .mode = 0 对应 CPOL=0, CPHA=0。SCLK 空闲为低,在上升沿锁存数据。这是 LCD 驱动芯片手册规定的模式。

  5. SPI_DMA_CH_AUTO 自动分配 GDMA 通道,不手动指定。

4.2 SPI 驱动层 — spi2_write_data()#

void spi2_write_data(uint8_t *data, uint32_t len)
{
spi_transaction_t t = {0};
t.length = len * 8; // bit 总数(例如 11520 × 8 = 92160)
t.tx_buffer = data; // 发送缓冲区的指针(不是拷贝!)
spi_device_polling_transmit(spi2_handle, &t);
}

关键细节:

  • t.length 的单位是 bit,不是 byte。发送 11520 字节需要 11520 * 8 = 92160 bit。
  • t.tx_buffer 是只读指针,ESP-IDF 驱动直接从该地址读数据并送入 DMA/移位寄存器,不会拷贝数据。因此调用者必须保证在 transmit 返回前缓冲区有效。
  • spi_device_polling_transmit 是阻塞调用——调用后 CPU 会等待 DMA 或 FIFO 传输完成才返回。对于 11520 字节 @60MHz,阻塞时间约 1.5ms。
  • LCD 只写不读,所以 t.rx_buffer 不设(保持 NULL)。

4.3 LCD 驱动层 — GPIO 配置与硬复位#

源码位置:components/LCD/lcd.c

lcd_init() 函数前 3 段分别配置 RST(GPIO21)、DC(GPIO47)、BLK(GPIO40) 为 GPIO 输出模式:

// RST 引脚: 上拉,初始状态高(不复位)
gpio_structure.pin_bit_mask = 1ull << GPIO_NUM_21;
gpio_structure.pull_up_en = GPIO_PULLUP_ENABLE;
// DC 引脚: 上拉,初始状态高(数据模式,虽然还没用)
gpio_structure.pin_bit_mask = 1ull << GPIO_NUM_47;
gpio_structure.pull_up_en = GPIO_PULLUP_ENABLE;
// BLK 引脚: 下拉,初始状态低(默认关背光)
gpio_structure.pin_bit_mask = 1ull << GPIO_NUM_40;
gpio_structure.pull_down_en = GPIO_PULLDOWN_ENABLE;

为什么 RST 上拉、DC 上拉、BLK 下拉?

  • RST 上拉:复位是低有效,上拉保证上电时不误复位
  • DC 上拉:默认数据模式,写命令时下拉
  • BLK 下拉:上电时关背光防止闪烁,初始化完成后再 lcd_on() 开启

硬件复位函数:

void lcd_hard_reset(void)
{
LCD_RST(0); // 拉低 RST(复位芯片)
vTaskDelay(100); // 保持 100ms
LCD_RST(1); // 拉高 RST(释放复位)
vTaskDelay(100); // 等待芯片内部 PLL 稳定,100ms
}

为什么使用 vTaskDelay(100) 而不是 delay(100) 前者是 FreeRTOS 的任务挂起(CPU 可调度其他任务),后者是死循环忙等。养成用 vTaskDelay 的习惯是正确的。

4.4 LCD 驱动层 — 写命令与写数据的区别#

这是 LCD 驱动中最核心的 4 个函数:

// 写命令: DC=0, 发送 1 字节
void lcd_write_cmd(uint8_t cmd)
{
LCD_DC(0); // DC 低 = 命令模式
spi2_write_data(&cmd, 1); // 发送 1 字节
}
// 写数据 (8 位): DC=1, 发送 1 字节
void lcd_write_data(uint8_t data)
{
LCD_DC(1); // DC 高 = 数据模式
spi2_write_data(&data, 1);
}
// 写数据 (16 位): DC=1, 发送 2 字节,高字节在前(大端序)
void lcd_write_data16(uint16_t data)
{
uint8_t databuf[2] = {0, 0};
databuf[0] = data >> 8; // 高字节在前(MSB first = 大端序)
databuf[1] = data & 0xFF; // 低字节在后
LCD_DC(1);
spi2_write_data(databuf, 2);
}
// 写数据块: DC=1, 发送 N 字节
void lcd_write_datan(uint8_t *data, uint16_t length)
{
LCD_DC(1);
spi2_write_data(data, length); // 一次 SPI 事务发送全部
}

为什么 write_data16 要做字节拆分? ST7789 的 16 位格式是 RGB565 大端序:先发高 8 位(R5 + G3),再发低 8 位(G3 + B5)。一个像素 = 2 字节。这个拆分对应硬件要求的字节顺序。

为什么 write_datan 直接传指针? 批量像素数据用 spi2_write_data 一次性发起一次 SPI 事务 + DMA 搬运,比循环调用 write_data16 快很多(后者每 2 字节就触发一次 CS↓→CS↑ 和一次函数调用)。

4.5 LCD 驱动层 — 窗口设置#

void lcd_set_window(uint16_t xstar, uint16_t ystar,
uint16_t xend, uint16_t yend)
{
lcd_write_cmd(0x2a); // CASET: Column Address Set
lcd_write_data16(xstar); // 起始列
lcd_write_data16(xend); // 结束列
lcd_write_cmd(0x2b); // RASET: Row Address Set
lcd_write_data16(ystar); // 起始行
lcd_write_data16(yend); // 结束行
lcd_write_cmd(0x2c); // RAMWR: Memory Write
// 之后所有发来的数据都按从左到右、从上到下的顺序
// 填充这个窗口区域
}

命令说明:

命令名称作用
0x2ACASET设定列的起止坐标(X 方向)
0x2BRASET设定行的起止坐标(Y 方向)
0x2CRAMWR开始写入显存(GRAM),自动指针递增

GRAM 写入顺序:

set_window(0, 0, 239, 239) 之后:
Y\X 0 1 2 ... 239
0 pixel[0] pixel[1] pixel[2] ... pixel[239]
1 pixel[240] ... ... pixel[479]
... ...
239 ... pixel[57599]
写入顺序: 逐行、每行从左到右。
每个像素 2 字节 (RGB565),一帧 = 57600 像素 × 2 = 115200 字节。

4.6 LCD 驱动层 — 清屏与图片显示#

清屏函数 lcd_clear(color)

void lcd_clear(uint16_t color)
{
uint8_t data[2] = {0, 0};
data[0] = color >> 8;
data[1] = color & 0xFF;
lcd_set_window(0, 0, 239, 239); // 全屏窗口
// 填充 lcd_buf 缓冲区(每 2 字节一个像素)
for(uint16_t i = 0; i < 240*240/10; i++) // 57600 / 10 = 5760 次迭代
{
lcd_buf[2 * i] = data[0]; // 每 2 字节填一个颜色
lcd_buf[2 * i + 1] = data[1];
}
// 10 次 DMA 传输,每次 11520 字节 = 全屏
for(uint16_t j = 0; j < 10; j++)
{
lcd_write_datan(lcd_buf, 11520);
}
}

关键设计分析:

  • 为什么分 10 次发送? lcd_buf 是 115200 字节的全局数组。虽然可以把 SPI 事务的 t.length 设为 115200*8 = 921600,但 DMA 一次传输的数据量受 max_transfer_sz 限制。分成 10 次×11520 字节复用同一个 lcd_buf 缓冲区,节省 RAM。

  • 为什么用全局 lcd_buf 而不是每次 malloc? 115200 字节的 malloc 可能从 PSRAM 分配,速度慢且碎片化。全局数组编译时就固定在 .bss 段,地址已知,没有运行时开销。

图片显示函数 lcd_show_picture(img)

void lcd_show_picture(uint8_t *img)
{
unsigned long i = 0, j = 0;
lcd_set_window(0, 0, 239, 239);
// 将 img(uint8数组,每 2 字节 = 1 像素)组装到 lcd_buf
for (j = 0; j < 240 * 240; j++)
{
lcd_buf[2 * j] = img[2 * i]; // 高字节
lcd_buf[2 * j + 1] = img[2 * i + 1]; // 低字节
i++;
}
// 分 10 次 DMA 发送
for(j = 0; j < (240*240*2 / 11520); j++)
{
lcd_write_datan(&lcd_buf[j * 11520], 11520);
}
}

如果 img 本身就是 RGB565 大端序,可以跳过从 img 拷贝到 lcd_buf 的第一步,直接把 img 指针传给 lcd_write_datan

4.7 主程序入口#

void app_main(void)
{
lcd_init(); // 初始化 SPI + LCD
lcd_show_picture(gImage_image); // 显示 240×240 图片
while (1)
{
vTaskDelay(500); // 空闲循环,500ms Tick
}
}
  • gImage_image 是一个 115200 字节的 unsigned char 数组,存于 Flash(.rodata 段),包含一张 240×240 RGB565 图片。
  • vTaskDelay(500) 保证空闲任务不空转,让 FreeRTOS Idle Task 能执行清理工作。

五、完整通信时序图#

5.1 LCD 初始化全程时序#

─── 硬件复位 ───
主机: RST=0 ─────(100ms)────── RST=1 ─────(100ms)──────
从机: (芯片内部复位) (PLL锁相环启动)
──── 寄存器初始化序列 ────
主机: 0x11 (DC=0) ────(120ms delay)────
从机: (退出 Sleep,启动内部振荡器)
主机: 0xB2 (DC=0) → 0x0C,0x0C,0x00,0x33,0x33 (DC=1)
从机: (配置 Porch 前后肩)
主机: 0x3A (DC=0) → 0x05 (DC=1)
从机: (设为 16bpp RGB565)
主机: 0x36 (DC=0) → 0x70 (DC=1)
从机: (设为 MY=MX=MV=1 / RGB 顺序)
... (共 26 组命令) ...
主机: 0x21 (DC=0) ← Display Inversion On
主机: 0x29 (DC=0) ← Display On
主机: 0x2C (DC=0) ← Memory Write
──── 清屏(全白)────
主机: 0x2A (DC=0) → 0x00,0x00, 0x00,0xEF (DC=1) ← X: 0~239
主机: 0x2B (DC=0) → 0x00,0x00, 0x00,0xEF (DC=1) ← Y: 0~239
主机: 0x2C (DC=0)
主机: (DC=1) 115200 字节 WHITE 色 ──► 屏幕变白

5.2 图片帧发送时序#

一次 lcd_show_picture(gImage_image) 的开销分析:
┌─ 软件拷贝 ───────────────────────────┐
│ for(j=0; j<57600; j++) │
│ lcd_buf[2j]=img[2i]; │ 约 2ms @240MHz
│ lcd_buf[2j+1]=img[2i+1]; │
└──────────────────────────────────────┘
┌─ 第 1 次 DMA ────────────────────────┐
│ lcd_write_datan(buf, 11520) │
│ → spi2_write_data → t.length=92160 │
│ → DMA 搬运 11520B → SPI2 移位寄存器│
│ 耗时: 11520×8 / 60MHz ≈ 1.54 ms │
└──────────────────────────────────────┘
... 重复 10 次 ...
┌─ 第 10 次 DMA ───────────────────────┐
│ 同上 │
└──────────────────────────────────────┘
总耗时: ~2ms (拷贝) + 10×1.54ms (DMA) ≈ 17.4 ms
帧率: 1000/17.4 ≈ 57 FPS(理论最大值)

实际帧率会低于 57FPS,因为还需要考虑 CS 切换、DMA 描述符准备、中断延时的开销。如需更高帧率,可以改用双缓冲 + 中断通知机制。


容易踩坑#

现象常见原因排查方向
LCD 白屏无显示复位时序异常:vTaskDelay(100) 不够、RST 引脚未正确拉低/拉高用示波器测 RST 引脚波形;检查 LCD_RST(0) / LCD_RST(1) 宏定义中的 GPIO 编号是否与板子实际接线一致
LCD 花屏/颜色错乱RGB565 字节序反了(先低后高 vs 先高后低)检查 lcd_write_data16()data >> 8data & 0xFF 的顺序
清屏只清了部分区域lcd_set_window() 的坐标参数超出范围、或者只调用了一次 lcd_write_datan确认 lcd_set_window(0,0,239,239);确认循环次数 j < 10(115200/11520=10)
LCD 显示偏移或镜像0x36 寄存器配置的 MY/MX/MV 位不对参考 LCD 手册调整 0x36
max_transfer_sz 太小导致显示截断DMA 描述符内存不够确保 max_transfer_sz >= 240*240*2 = 115200
图片颜色发红/发蓝图片取模时 RGB 顺序选错检查取模工具的 RGB565 输出顺序是否为大端序;0x36 的 BGR 位是否为 0

六、本章小结#

维度应掌握内容
SPI 原理四线制(SCLK/MOSI/MISO/CS)全双工同步通信;CPOL+CPHA 四种模式;Mode 0 是最常见的选择
ESP-IDF SPI 驱动总线初始化(spi_bus_initialize)→ 设备注册(spi_bus_add_device)→ 事务发送(spi_device_polling_transmit)三步走;事务描述符的 .length 是 bit 单位
LCD 驱动DC 线区分命令/数据;0x2A/0x2B/0x2C 窗口设置三步曲;RGB565 大端序 2 字节/像素
DMA 使用max_transfer_sz 决定 DMA 缓冲区大小;启用 SPI_DMA_CH_AUTO 让 ESP-IDF 自动管理
软件架构SPI 封装层(总线+设备句柄)→ LCD 封装层(命令/数据/寄存器序列)→ 应用层(初始化+刷新)
性能SPI 60MHz 的理论帧率约 57FPS;瓶颈在 CPU 准备像素数据而非 SPI 带宽

SPI 对比 I2C — 何时用哪个?#

场景推荐
高速刷屏 / 大量数据传输SPI
多个传感器挂在同一总线I2C(节省 IO 引脚)
PCB 板级短距离SPI
需要 ACK 确认的可靠通信I2C
主从双向频繁交互SPI(全双工)

进阶方向#

学完本章后应能自主实现:

  • 使用 spi_device_queue_trans + spi_device_get_trans_result 实现中断驱动异步 SPI
  • 添加字库+中文显示(结合 07_LCDDisplay 章节的字模算法)
  • 实现双缓冲帧切换,消除 DMA 写入期间的屏幕撕裂
  • 将 SPI LCD 驱动接入 LVGL 图形库,实现按钮、图表等高级 UI

Tips#

四线制的每一根线都在干什么#

SPI 的四根线分工非常清晰,理解各自角色就不会混淆:

SCLK(时钟线): 主机产生的方波信号。从机根据 SCLK 的边沿
判断"什么时候该读数据、什么时候该放数据"。
没有 SCLK,从机完全不知道该干什么。
MOSI(主机出): 主机向从机发送数据的单向通道。
本章 LCD 场景下几乎所有数据都走 MOSI。
MISO(主机入): 从机向主机发送数据的单向通道。
本章 LCD 不读回数据,MISO 闲置。
但如果接的是 SD 卡或 SPI Flash,MISO 用来读回数据。
CS(片选): 低电平有效。主机拉低某个 CS 引脚,
对应的从机就知道"轮到我了"。
多个从机共享 SCLK/MOSI/MISO,
靠各自的 CS 线区分。这是 SPI 的寻址机制。

一个常见误解:SPI 是全双工,所以”任何时候 MOSI 和 MISO 都在同时传输”。实际上——如果你只想写(LCD 场景),MISO 上的数据会被忽略。全双工是硬件能力,不是强制要求。

CPOL/CPHA 四种模式的设计哲学#

为什么 SPI 需要四种模式?难道不能统一用一种吗?

答案:历史兼容性。 SPI 没有统一的国际标准(不像 I2C 有 NXP 规范文档),不同芯片厂商设计了不同的时序习惯。SPI 四种模式就是为了兼容这些差异。

Mode 0 (CPOL=0, CPHA=0) — 最常用:
"时钟平常低,一变高就采样"
大多数 Flash、LCD、传感器用 Mode 0
Mode 3 (CPOL=1, CPHA=1) — 第二常用:
"时钟平常高,一变高就采样"(与 Mode 0 镜像)
某些 ADC 和 DAC 用 Mode 3
Mode 1 和 Mode 2 — 少数外设:
采样时机滞后半个周期
极少数老芯片用

如何确定用哪种模式? 看从机芯片的数据手册。找不到就用 Mode 0 试——90% 的设备兼容 Mode 0。如果通信失败(数据错乱或全 0/全 1),再依次试 Mode 1/2/3。

CS 和 CPOL/CPHA 的关系:CS 拉低后,从机才开始关注 SCLK。Mode 0 下,从机期望看到 SCLK 在 CS 拉低时刻是低电平,然后第一个上升沿开始采样。所以 CPOL 描述的是”无通信时 SCLK 的电平”,这个电平也应该是 CS 拉低瞬间的电平。

SPI vs I2C——并非谁更好、而是谁更适合#

不是在”SPI 和 I2C 之间选一个”,而是”根据约束条件选择”:

你的约束是什么选 SPI选 I2C
IO 引脚紧张(只剩 2 根)❌ 至少 4 根✅ 只要 2 根
需要高速传输(MHz 级)✅ 可达 80 MHz❌ 最高 400 kHz
挂很多设备(>5 个)❌ 每个设备独占一根 CS✅ 一条总线挂 127 个
需要可靠确认(ACK)❌ 无 ACK 机制✅ 每字节 ACK/NACK
收发同时进行✅ 全双工❌ 半双工
设备距离 > 10cm❌ 信号衰减严重✅ 差分 RS485 可达 1km

本章的设计智慧:LCD 需要 SPI(速度),BH1750 需要 I2C(引脚少 + 有 ACK)。两个协议在同一个项目中并存,是合理的工程决策。

踩坑实录#

  1. MISO 引脚悬空产生假数据:LCD 不接 MISO,但 SPI 总线上如果 MISO 引脚悬空,电磁干扰可能让主机误以为收到了数据。解决:MISO 接 GND,或在 spi_bus_config_t 中设 .miso_io_num = -1

  2. DMA 和 PSRAM 的缓存一致性问题:ESP32-S3 的 PSRAM 通过 Cache 访问。如果 DMA 直接写 PSRAM,CPU 读到的可能是 Cache 中的旧数据。使用 CAMERA_FB_IN_PSRAM 类型的缓冲区时,需要在 DMA 写入后调用 esp_cache_msync() 刷新缓存。

  3. max_transfer_sz 不是越大越好:这个值直接影响 SPI 驱动预分配的 DMA 描述符数量。max_transfer_sz = 115200 在内部 RAM 中分配约 1KB 的描述符内存。正常情况下无需调大。

知识衔接#

  • 上一章02_IIC——I2C 用地址字节寻址,SPI 用 CS 引脚寻址。两者的”寻址哲学”完全不同:I2C 是”软件地址”(所有人监听),SPI 是”硬件片选”(被选中才监听)。
  • 下一章04_SPITF_SD卡——SD 卡也通过 SPI 访问,但用 FATFS 文件系统把块设备抽象成文件。LCD 和 SD 卡可以共享同一组 SPI2 引脚,靠各自的 CS 分时切换。这是 SPI 总线最优雅的设计模式。

文章分享

如果这篇文章对你有帮助,欢迎分享给更多人!

ESP32-S3 SPI 通信与 LCD 驱动
https://mjzy.tech/posts/embedded/esp32-s3/03-spi-lcd/
作者
ENKIDU
发布于
2026-08-22
许可协议
CC BY-NC-SA 4.0
Profile Image of the Author
ENKIDU
深度学习 & 计算机底层原理 | 用代码理解世界
低语
天之锁永系天与地,而这里系着文字与记忆。欢迎来到乌鲁克的数字荒原。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
49
分类
6
标签
45
总字数
180,771
运行时长
0
最后活动
0 天前
站点信息
构建平台
Vercel
文章许可
CC BY-NC-SA 4.0

文章目录