10个颜色块 —— 一步一步用DMA发送像素数据
我们从头梳理,把每一步都讲清楚。
第一步:理解"为什么要用DMA"
你现在的 lcd_fill_color_cpu 是这样发数据的:
CPU → 每次发1字节 → SPI硬件 → 屏幕240×320 = 76800个像素,每个像素2字节,共 153600次 CPU介入,CPU累死了,速度慢。
用DMA之后:
CPU只需要准备好buffer,告诉DMA"去发这块内存" → DMA自己搬数据 → SPI硬件 → 屏幕
CPU可以去做别的事第二步:DMA发送像素,有哪些API可以选?
ESP-IDF SPI master 驱动提供这几个发送函数:
| 函数 | 阻塞? | 走DMA? | 适合场景 |
|---|---|---|---|
spi_device_polling_transmit() | 是,CPU一直等 | 小数据不走DMA | 命令/参数(1~4字节) |
spi_device_transmit() | 是,等完成才返回 | 会走DMA | 简单场景,大数据 |
spi_device_queue_trans() | 否,放入队列就返回 | 会走DMA | 大数据,后台传输 |
spi_device_get_trans_result() | 是,等队列里的事务完成 | — | 配合queue_trans使用 |
对于像素数据(大块数据),最常用的组合是:
spi_device_queue_trans() ← 把任务交给DMA,立刻返回
spi_device_get_trans_result() ← 等DMA传完或者更简单地直接用:
spi_device_transmit() ← 内部自动用DMA,等完成我们这里用 spi_device_transmit() 就够了,最简单。 [SPI master example]
第三步:DMA对内存有要求!
这是最重要的一点,很多小白踩坑的地方:
DMA只能访问特定的内存区域,普通的局部变量(栈上的数组)DMA访问不到!
所以像素buffer必须这样分配:
c
// ❌ 错误:栈上的数组,DMA无法访问
uint16_t buf[240 * 64];
// ✅ 正确:从DMA可访问的堆内存分配
uint16_t *buf = heap_caps_malloc(240 * 64 * sizeof(uint16_t),
MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);MALLOC_CAP_DMA 表示"我要DMA能访问的内存",MALLOC_CAP_INTERNAL 表示"从芯片内部RAM分配"(速度快)。[SPI master example]
第四步:ST7789的字节序问题(图片转成C数组时注意)
ST7789 接收 RGB565 颜色时,高字节在前(大端)。
比如红色 0xF800:
- 高字节
0xF8要先发 - 低字节
0x00后发
但ESP32内存里存的是小端,所以你填buffer时要手动交换高低字节:
c
uint16_t color = 0xF800; // 红色
// 交换字节:高字节和低字节互换
uint16_t color_swapped = (color >> 8) | (color << 8);
// color_swapped = 0x00F8,发出去后屏幕收到的顺序就是 F8 00,正确!第五步:spi_transaction_t 结构体说明
发送数据时需要填这个结构体,告诉SPI驱动"发什么、发多少":
c
spi_transaction_t t = {
.length = 像素字节数 * 8, // 注意:length单位是【bit】,不是字节!
.tx_buffer = buf, // 指向你的像素buffer
};关键字段:
| 字段 | 含义 |
|---|---|
.length | 要发送的数据长度,单位是bit,所以字节数要×8 |
.tx_buffer | 指向发送数据的指针,必须是DMA可访问内存 |
.tx_data | 当数据≤4字节时可以直接填在这里,不用单独分配buffer |
.flags | 特殊标志,一般填0即可 |
代码:DMA模式传送像素数据代码
C
#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "driver/spi_master.h"
#include "esp_log.h"
// 引脚定义
#define lcd_spi_cs GPIO_NUM_10 // CS 片选引脚,当CS拉低时表示SPI从机设备被选中,可以进行数据传输
#define lcd_spi_dc GPIO_NUM_14 // DC 数据/命令引脚,拉低表示数据,拉高表示命令
#define lcd_spi_rst GPIO_NUM_13 // RST 复位引脚
#define lcd_spi_bl GPIO_NUM_9 // BL LED 背光引脚
#define lcd_spi_mosi GPIO_NUM_11 // MOSI (也是SDA)主输出引脚,串行数据线用于发送数据到屏幕
#define lcd_spi_clk GPIO_NUM_12 // CLK 时钟引脚
#define spi_host SPI2_HOST // ESP32-S3 SPI 主机控制器选择
spi_device_handle_t spi_device_handle;
// 函数声明
void lcd_gpio_init(void);
void st7789_init(spi_device_handle_t spi_device_handle);
void st7789_write_cmd(uint8_t cmd, spi_device_handle_t spi_device_handle);
void st7789_write_data(uint8_t data, spi_device_handle_t spi_device_handle);
void st7789_set_window(uint16_t x0, uint16_t y0,
uint16_t x1, uint16_t y1,
spi_device_handle_t spi_device_handle);
void lcd_fill_color_cpu(uint16_t color, spi_device_handle_t spi_handle);
void lcd_fill_color_dma(uint16_t color, spi_device_handle_t spi_handle);
/**
* @brief 主函数
*/
void app_main(void)
{
lcd_gpio_init();
// SPI bus initialization
spi_bus_config_t spi_buscfg = {
.miso_io_num = -1,
.mosi_io_num = lcd_spi_mosi,
.sclk_io_num = lcd_spi_clk,
.quadwp_io_num = -1,
.quadhd_io_num = -1,
.max_transfer_sz = 240 * 320 * 2 + 8,
};
esp_err_t ret = spi_bus_initialize(SPI2_HOST, &spi_buscfg, SPI_DMA_CH_AUTO);
if (ret != ESP_OK) {
ESP_LOGE("ST7789", "Failed to initialize SPI bus");
return;
};
// 配置SPI设备参数结构体
spi_device_interface_config_t spi_st7789_dev_cfg = {
.clock_source = SPI_CLK_SRC_DEFAULT, // 使用默认时钟源
.clock_speed_hz = 40 * 1000 * 1000, // SPI时钟频率,根据屏幕规格书,最高可达62.5MHz,但实际使用中可能需要调整以确保稳定性
.spics_io_num = lcd_spi_cs, // SPI片选引脚
.mode = 0, // SPI模式,CLK polarity: 0, CLK phase: 0,时钟极性说的是空闲状态时的CLK信号电平
.queue_size = 7,
};
// 将SPI设备添加到SPI总线上
ret = spi_bus_add_device(SPI2_HOST, &spi_st7789_dev_cfg, &spi_device_handle);
if (ret != ESP_OK) {
ESP_LOGE("ST7789", "Failed to add SPI device");
return;
}
// 初始化屏幕
st7789_init(spi_device_handle);
// 填充屏幕(0x801F 紫色, 0x07E0 绿色, 0xF800 红色, 0xFFFF 白色)
// lcd_fill_color_cpu(0x07E0, spi_device_handle);
// 定义颜色数组(顺序:红黄蓝黑白绿橙粉棕紫)
uint16_t colors[] = {
0xF800, // 红
0xFFE0, // 黄
0x001F, // 蓝
0x0000, // 黑
0xFFFF, // 白
0x07E0, // 绿
0xFC00, // 橙
0xF81F, // 粉
0x9C60, // 棕
0x801F // 紫
};
int color_count = sizeof(colors) / sizeof(colors[0]); // 获取颜色数组的长度
int index = 0; // 颜色索引
// 循环填充颜色(10种颜色)
while (1) {
lcd_fill_color_dma(colors[index], spi_device_handle); // 调用填充颜色函数,发送颜色数据给屏幕)
index = (index + 1) % color_count; // 发送完一种颜色后,将索引加1,并取余数,确保索引在颜色数组范围内
vTaskDelay(pdMS_TO_TICKS(500)); // 延时0.5秒
}
}
/**
* @brief GPIO初始化
*/
void lcd_gpio_init(void){
// GPIO配置结构体
gpio_config_t lcd_gpio_config = {
.pin_bit_mask = (1ULL << lcd_spi_dc) | (1ULL << lcd_spi_rst) | (1ULL << lcd_spi_bl),
.mode = GPIO_MODE_OUTPUT,
.pull_up_en = GPIO_PULLUP_DISABLE,
.pull_down_en = GPIO_PULLDOWN_DISABLE,
.intr_type = GPIO_INTR_DISABLE,
};
// GPIO初始化
gpio_config(&lcd_gpio_config);
// 初始化完成后,默认将DC、RST、BL引脚设置为高电平,确保屏幕处于非复位状态并且背光打开
gpio_set_level(lcd_spi_dc, 1); // 默认数据模式
gpio_set_level(lcd_spi_rst, 1); // 默认复位状态
gpio_set_level(lcd_spi_bl, 1); // 默认背光开启
}
/**
* @brief 发送命令
*
* @param cmd 命令
* @param spi_handle SPI句柄
*/
void st7789_write_cmd(uint8_t cmd, spi_device_handle_t spi_device_handle)
{
// 引脚拉低,表示command,Page48 D/CX indicates whether the byte is command (D/CX=’0’)
// or parameter/RAM data (D/CX=’1’).
gpio_set_level(lcd_spi_dc, 0);
// 发送命令
spi_transaction_t transmit_cmd_config = {
.tx_buffer = &cmd,
.length = 8,
};
spi_device_polling_transmit(spi_device_handle, &transmit_cmd_config);
}
/**
* @brief 发送数据
*
* @param data 数据
* @param spi_handle SPI句柄
*/
void st7789_write_data(uint8_t data, spi_device_handle_t spi_device_handle)
{
// 引脚拉高,表示数据,Page48 D/CX indicates whether the byte is command (D/CX=’0’)
// or parameter/RAM data (D/CX=’1’).
gpio_set_level(lcd_spi_dc, 1);
// 发送数据
spi_transaction_t transmit_data_config = {
.tx_buffer = &data,
.length = 8,
};
spi_device_polling_transmit(spi_device_handle, &transmit_data_config);
}
/**
* @brief SPI初始化,配置SPI总线参数并添加SPI设备
*
*/
void st7789_init(spi_device_handle_t spi_device_handle){
// 1, 硬件复位,拉低RST引脚
// 屏幕初始化代码
gpio_set_level(lcd_spi_rst, 0); // 硬件复位,拉低RST引脚,默认高电平
// 延时10ms,Page42 ↓
// 原文 7. It is necessary to wait 5msec after releasing RESX before sending commands. Also Sleep Out command cannot be sent for 120msec.
vTaskDelay(pdMS_TO_TICKS(10));
gpio_set_level(lcd_spi_rst, 1); // 硬件复位,拉高,恢复高电平
vTaskDelay(pdMS_TO_TICKS(120));
// 延时120ms,Page42 ↓
// 原文 3. During the Resetting period, the display will be blanked
// (The display is entering blanking sequence, which maximum time is 120 ms, when Reset Starts in Sleep Out –mode.
// 2, 软件复位,发送0x01命令
// 发送软件复位命令0x01,Page124
st7789_write_cmd(0x01, spi_device_handle);
vTaskDelay(pdMS_TO_TICKS(120));
// 3, 退出睡眠模式,发送0x11命令
st7789_write_cmd(0x11, spi_device_handle);
vTaskDelay(pdMS_TO_TICKS(10)); // 从睡眠模式退出后并立即再次进入睡眠模式要等120毫秒,而退出后发送其他新命令要等待至少5毫秒
// 4, 设置像素格式,发送0x3A命令,参数0x55表示16位颜色以及262K,Page185,
st7789_write_cmd(0x3A, spi_device_handle);
st7789_write_data(0x55, spi_device_handle);
// 5,设置显示方向,发送0x36命令,参数0x00表示竖屏、RGB模式,Page176
st7789_write_cmd(0x36, spi_device_handle);
st7789_write_data(0x00, spi_device_handle);
// 6, 设置颜色反转开启 0x21
st7789_write_cmd(0x21, spi_device_handle);
// 7,设置display on,发送0x29命令,Page124
st7789_write_cmd(0x29, spi_device_handle);
vTaskDelay(pdMS_TO_TICKS(100)); // 等待100ms,确保屏幕稳定显示
ESP_LOGI("ST7789", "ST7789 init success!");
}
/**
* @brief 设置 ST7789 的显示窗口(列/行地址范围)
* @param x0 起始列(0~239)
* @param y0 起始行(0~319)
* @param x1 结束列(0~239)
* @param y1 结束行(0~319)
* @param spi_handle SPI 设备句柄
*/
void st7789_set_window(uint16_t x0, uint16_t y0, uint16_t x1, uint16_t y1, spi_device_handle_t spi_handle)
{
// ---- 设置列地址(X 方向) ----
st7789_write_cmd(0x2A, spi_handle); // 告诉屏幕:接下来我要发列地址参数
// 发送起始列(高字节,低字节)
st7789_write_data((x0 >> 8) & 0xFF, spi_handle); // 起始列的高 8 位
st7789_write_data(x0 & 0xFF, spi_handle); // 起始列的低 8 位
// 发送结束列(高字节,低字节)
st7789_write_data((x1 >> 8) & 0xFF, spi_handle); // 结束列的高 8 位
st7789_write_data(x1 & 0xFF, spi_handle); // 结束列的低 8 位
// ---- 设置行地址(Y 方向) ----
st7789_write_cmd(0x2B, spi_handle); // 告诉屏幕:接下来我要发行地址参数
// 发送起始行(高字节,低字节)
st7789_write_data((y0 >> 8) & 0xFF, spi_handle);
st7789_write_data(y0 & 0xFF, spi_handle);
// 发送结束行(高字节,低字节)
st7789_write_data((y1 >> 8) & 0xFF, spi_handle);
st7789_write_data(y1 & 0xFF, spi_handle);
}
/**
* @brief 填充整个屏幕为指定颜色(RGB565)
* @param color 16位颜色值
* @param spi_device_handle SPI设备句柄
*/
void lcd_fill_color_cpu(uint16_t color, spi_device_handle_t spi_device_handle)
{
// 1. 设置全屏窗口 (0,0) ~ (239,319)
st7789_set_window(0, 0, 239, 319, spi_device_handle);
// 2. 发送写内存命令 (0x2C)
st7789_write_cmd(0x2C, spi_device_handle);
// 3. 计算像素总数
uint32_t pixel_count = 240 * 320; // 76800 个像素
// 4. 逐个字节发送颜色数据(每个像素2字节)
uint8_t color_high = (color >> 8) & 0xFF;
uint8_t color_low = color & 0xFF;
for (uint32_t i = 0; i < pixel_count; i++) {
st7789_write_data(color_high, spi_device_handle);
st7789_write_data(color_low, spi_device_handle);
}
}
/**
* @brief 填充整个屏幕为指定颜色(DMA)
* @param color 16位颜色值
* @param spi_device_handle SPI设备句柄
*/
void lcd_fill_color_dma(uint16_t color, spi_device_handle_t spi_handle)
{
// 第1步:设置全屏窗口
st7789_set_window(0, 0, 239, 319, spi_handle);
// 第2步:发送写内存命令
st7789_write_cmd(0x2C, spi_handle);
// 第3步:DC 引脚拉高(数据模式)
gpio_set_level(lcd_spi_dc, 1);
// 第4步:申请一块 DMA 内存作为"快递车"
// 不能一次发整屏,硬件有单次传输上限,所以分块发送
int chunk_size = 4096; // 每次发 4096 字节(约 2048 个像素)
uint8_t *buf = heap_caps_malloc(chunk_size, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
if (buf == NULL) {
ESP_LOGE("ST7789", "DMA内存申请失败!");
return;
}
// 第5步:把颜色数据填满缓冲区
uint8_t hi = (color >> 8) & 0xFF;
uint8_t lo = color & 0xFF;
for (int i = 0; i < chunk_size; i += 2) {
buf[i] = hi;
buf[i + 1] = lo;
}
// 第6步:循环分块发送,直到整屏填满
int total = 240 * 320 * 2; // 153600 字节
int sent = 0;
while (sent < total) {
// 计算本次发多少字节(最后一块可能不足 4096)
int this_time = total - sent;
if (this_time > chunk_size) this_time = chunk_size;
spi_transaction_t t = {
.tx_buffer = buf,
.length = this_time * 8, // 单位是 bit,所以 ×8
};
spi_device_transmit(spi_handle, &t);
sent += this_time;
}
// 第7步:释放内存
heap_caps_free(buf);
}
// void lcd_fill_color_dma(uint16_t color, spi_device_handle_t spi_handle)
// {
// st7789_set_window(0, 0, 239, 319, spi_handle);
// st7789_write_cmd(0x2C, spi_handle);
// gpio_set_level(lcd_spi_dc, 1);
// // chunk_size 最大设为 32768(ESP32-S3 硬件单次 DMA 上限)
// int chunk_size = 32768;
// uint8_t *buf = heap_caps_malloc(chunk_size, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);
// if (buf == NULL) {
// ESP_LOGE("ST7789", "DMA内存申请失败!");
// return;
// }
// uint8_t hi = (color >> 8) & 0xFF;
// uint8_t lo = color & 0xFF;
// for (int i = 0; i < chunk_size; i += 2) {
// buf[i] = hi;
// buf[i + 1] = lo;
// }
// int total = 240 * 320 * 2; // 153600 字节
// int sent = 0;
// while (sent < total) {
// int this_time = total - sent;
// if (this_time > chunk_size) this_time = chunk_size;
// spi_transaction_t t = {
// .tx_buffer = buf,
// .length = this_time * 8,
// };
// spi_device_transmit(spi_handle, &t);
// sent += this_time;
// }
// heap_caps_free(buf);
// }代码注释详细版:第四步到第六步
好的!我们把这段代码拆成每一个小部分,逐一详细解释。
第4步:申请 DMA 内存
c
int chunk_size = 4096;
uint8_t *buf = heap_caps_malloc(chunk_size, MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL);uint8_t *buf 是什么?
uint8_t → 每个格子存 1 个字节(8位)的数据类型
* → 这是一个指针,buf 存的是内存地址(指向仓库的门牌号)
buf → 变量名,代表这块内存的起始地址
形象理解:
buf 就像一个"仓库地址",
heap_caps_malloc 帮你建好仓库后,
把仓库的门牌号告诉 bufheap_caps_malloc 申请到的内存长什么样?
内存地址(buf指向这里)
↓
┌────┬────┬────┬────┬────┬────┬──────┬────┐
│ │ │ │ │ │ │ .... │ │
└────┴────┴────┴────┴────┴────┴──────┴────┘
[0] [1] [2] [3] [4] [5] .... [4095]
←────────────── 4096 个格子 ──────────────→
每个格子存 1 字节if (buf == NULL) 是什么?
heap_caps_malloc 申请成功 → 返回内存地址 → buf 不是 NULL
heap_caps_malloc 申请失败 → 返回 NULL → buf 是 NULL
NULL 就是"空",表示申请失败,内存不够用
所以这里做一个检查,失败了就打印错误并退出第5步:填充颜色数据
c
uint8_t hi = (color >> 8) & 0xFF;
uint8_t lo = color & 0xFF;为什么要拆成 hi 和 lo?
颜色是 RGB565 格式,16位,比如红色 0xF800:
0xF800 用二进制表示:
1111 1000 0000 0000
←── 高8位 ──→←── 低8位 ──→
hi=0xF8 lo=0x00
SPI 每次只能发 1 个字节,
所以必须拆成两个字节分开发>> 8 和 & 0xFF 是什么意思?
color = 0xF800
color >> 8 → 把所有位向右移动8位
0xF800 → 0x00F8
取到了高8位
& 0xFF → 只保留最低8位,其余清零
0x00F8 & 0xFF = 0xF8 ← 确保只有8位
所以:
hi = (0xF800 >> 8) & 0xFF = 0xF8 (高字节)
lo = 0xF800 & 0xFF = 0x00 (低字节)for 循环填充
c
for (int i = 0; i < chunk_size; i += 2) {
buf[i] = hi;
buf[i + 1] = lo;
}i 每次跳 2 格(因为每个像素占 2 字节):
buf[0]=0xF8 buf[1]=0x00 → 第1个像素(红色)
buf[2]=0xF8 buf[3]=0x00 → 第2个像素(红色)
buf[4]=0xF8 buf[5]=0x00 → 第3个像素(红色)
...
buf[4094]=0xF8 buf[4095]=0x00 → 第2048个像素
整个仓库(4096字节)全部填满了同一种颜色第6步:循环分块发送
c
int total = 240 * 320 * 2; // 153600 字节
int sent = 0;total = 整个屏幕需要发的总字节数
sent = 已经发出去了多少字节(初始为0)while 循环逻辑
第1趟:
sent = 0
total - sent = 153600 - 0 = 153600
153600 > 4096,所以 this_time = 4096
发送 4096 字节
sent = 0 + 4096 = 4096
第2趟:
sent = 4096
total - sent = 153600 - 4096 = 149504
149504 > 4096,所以 this_time = 4096
发送 4096 字节
sent = 4096 + 4096 = 8192
... 重复 ...
最后一趟(第38趟):
sent = 151552
total - sent = 153600 - 151552 = 2048
2048 < 4096,所以 this_time = 2048 ← 不足一整块,发剩余的
发送 2048 字节
sent = 151552 + 2048 = 153600
sent == total,循环结束,整屏填满!spi_transaction_t t 是什么?
c
spi_transaction_t t = {
.tx_buffer = buf,
.length = this_time * 8,
};这是一张"快递单",告诉 SPI 硬件:
tx_buffer = buf → 货物在哪里(DMA仓库的地址)
length = this_time * 8 → 发多少(单位是bit,所以字节数×8)
比如 this_time = 4096 字节:
length = 4096 × 8 = 32768 bitspi_device_transmit 做了什么?
spi_device_transmit(spi_handle, &t)
↓
把"快递单 t"交给 SPI 驱动
↓
SPI 驱动通知 DMA:"去 buf 地址,搬 this_time 字节的货"
↓
DMA 自动搬运数据 → SPI 硬件 → 屏幕
↓
搬完后函数返回,继续下一趟 while 循环总结:第四步到第六步
申请4096字节仓库(buf)
↓
把颜色数据填满仓库(2048个像素)
↓
┌─────────────────────────────┐
│ while 循环(约38趟) │
│ │
│ 计算本趟发多少字节 │
│ ↓ │
│ 填好快递单(spi_transaction)│
│ ↓ │
│ DMA搬运 → SPI → 屏幕 │
│ ↓ │
│ sent += this_time │
└─────────────────────────────┘
↓
sent == 153600,整屏填满!
↓
heap_caps_free(buf) 释放仓库整体流程总结
1. heap_caps_malloc() → 分配DMA可访问的buffer
2. 填充颜色值(注意字节交换)
3. st7789_set_window() → 告诉屏幕"写哪个区域"
4. st7789_write_cmd(0x2C) → 发"写内存"命令
5. spi_device_transmit() → DMA把buffer数据发给屏幕
6. 重复3~5,共10次
7. heap_caps_free() → 释放buffer理解了这个流程之后,第二个需求(循环刷屏)只是在外面套一个 while(1) 循环,逻辑完全一样,随时可以继续。
