先让两块设备分别工作,再讨论共享
屏幕单独能画图、卡单独能存文件,并不能证明它们接到同一条SPI总线上仍正常。共用时钟与数据线以后,上电模式、片选、线负载,以及异步发送期间谁能重写内存,都成为新的条件。
本任务用PID3787板上的TFT和microSD作两块SPI设备,按T、C、B三种模式逐步排除变量。B每轮先写入一条有序记录、刷新并读回核对,再把当前轮次画到屏上,重复100次。没有开发板与卡的实际日志,就只能把这项硬件验收记为待测。
- 做到这里,你应该看到
- 你能说出:T只验证显示、C只验证卡,只有B的100轮记录对应共享场景。
参考:[1] ESP-IDF5.5.5:SD与其他SPI设备共享的初始化顺序与负载[2] ESP-IDF5.5.5:SDSPI完整例程[5] Adafruit PID3787:尺寸、控制器与EYESPI修订
一条线共享,两个片选各自空闲高
本例走排针接线,使用排针已可靠焊接的同修订PID3787。随附散排针不等于已焊好;未焊时先由具备工具和焊接条件的人完成并检查,不能将排针松插焊孔当作电气连接。EYESPI仍留空,不在本例切换接口。
使用2022年11月EYESPI修订的Adafruit PID3787:1.54英寸、240×240、ST7789、板载microSD。GPIO是ESP32芯片号,图内J1是官方v1.1排针序号;TE、EYESPI和3V稳压输出不用。3V3接VIN、GND共地,所有MCU信号3.3V。
TFT不回读,SO只承载SD卡的MISO。TC与CC各外加10kΩ上拉到3V3,并在SPI初始化前由GPIO主动置高;外部电阻只是覆盖复位空档,不替代软件控制。线尽量短;4MHz是示例工作配置,未测最高频。
- 做到这里,你应该看到
- 断电用排针表逐根核对,尤其TC5、CC13不能合在同一片选。
参考:[5] Adafruit PID3787:尺寸、控制器与EYESPI修订[6] Adafruit:显示及SD引脚、电源与背光[7] DevKitC-1 v1.1官方排针与模组限制
SD先进入SPI模式,再初始化屏幕
SD卡上电先处于SD模式,尚未转为SPI时,其他SPI通信也可能触发它响应。组合模式先初始化SPI总线、把TFT片选拉高,再调用esp_vfs_fat_sdspi_mount;成功后才建立esp_lcd的显示设备。官方共享指南明确了这一顺序。
SD驱动初始化时自行使用探测低速,host.max_freq_khz=4000限制后续工作时钟;TFT的pclk_hz=4000000。各设备驱动管理自己的SPI事务。本例在单个应用任务中顺序执行;若以后增加并发任务,还需为设备和应用状态另作同步。
参考:[1] ESP-IDF5.5.5:SD与其他SPI设备共享的初始化顺序与负载[2] ESP-IDF5.5.5:SDSPI完整例程[3] ESP-IDF5.5.5:SPI主机、DMA缓冲与事务
总线空闲以外,还要守住一块条带内存
240×240 RGB565整帧为115200字节,但本例按16行条带绘制,只申请7680字节内部DMA内存。esp_lcd_panel_draw_bitmap提交颜色传输后可能先返回;此时缓冲仍归DMA使用。
每条提交后等待on_color_trans_done回调给出的信号量,再重写条带;完整15条完成后才进入下一项卡操作。1秒没等到回调,程序关背光、停止新作业并保留内存/句柄,防止DMA仍访问时释放。这个超时不会硬终止底层事务。
参考:[3] ESP-IDF5.5.5:SPI主机、DMA缓冲与事务[4] ESP-IDF5.5.5:SPI LCD配置与异步传输实现
下载完整工程,在固定SDK中复建
下载中的components/hxdisplay包含原创像素渲染器,main包含模式选择、排他新建文件与读回校验;没有省略驱动适配。P08和D03的显示组件逐字一致,但两者是独立可构建工程。
按E01进入官方IDF5.5.5环境,在本示例目录用下方命令构建;替换为本机桥接串口。sdkconfig.defaults锁8MB/QIO、Octal PSRAM80MHz、UART0/115200。生成的bootloader写入参数可能显示DIO,这是启动头部流程,使用生成flash_args,不手改。完整入口与组件原样见下一节。
idf.py -B /tmp/p08-build -D SDKCONFIG=/tmp/p08-build/sdkconfig -D IDF_TARGET=esp32s3 build
idf.py -B /tmp/p08-build -p /dev/cu.YOUR_UART_PORT flash monitor- 做到这里,你应该看到
- 作者已实际构建Exit0;这一步只证明能生成目标固件,未证明接线与卡可用。
参考:[2] ESP-IDF5.5.5:SDSPI完整例程[3] ESP-IDF5.5.5:SPI主机、DMA缓冲与事务
完整入口、记录校验与显示组件
以下文件标签对应下载中的真实相对路径,逐段保存;其他CMake、锁文件和记录模板均在下载中。程序不自动格式化卡,也不覆盖已有P08_xxx.CSV;顺序寻找001至999中第一个空闲名称,卡满或名称耗尽会停止。
// ---- main/Record.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
#include <stdio.h>
#include <string.h>
inline uint32_t record_hash(const char* s){uint32_t h=2166136261u;while(*s){h^=uint8_t(*s++);h*=16777619u;}return h;}
inline bool make_record(unsigned n,char* out,size_t cap){
char plain[24];int k=snprintf(plain,sizeof plain,"P08,%03u",n);if(k<0||size_t(k)>=sizeof plain)return false;
int len=snprintf(out,cap,"%s,%08lX\n",plain,(unsigned long)record_hash(plain));return len>0&&size_t(len)<cap;
}
inline bool verify_record(unsigned n,const char* line){char expected[48];return make_record(n,expected,sizeof expected)&&strcmp(line,expected)==0;}
// ---- main/Console.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <unistd.h>
#include <fcntl.h>
#include "driver/uart.h"
#include "esp_vfs_dev.h"
inline void init_console(){
ESP_ERROR_CHECK(uart_driver_install(UART_NUM_0,256,0,0,nullptr,0));
esp_vfs_dev_uart_use_driver(UART_NUM_0);
int flags=fcntl(STDIN_FILENO,F_GETFL);fcntl(STDIN_FILENO,F_SETFL,flags|O_NONBLOCK);
}
inline char console_key(){char c=0;return read(STDIN_FILENO,&c,1)==1?c:0;}
// ---- components/hxdisplay/Graphics.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include <stdint.h>
#include <stdio.h>
#include <string.h>
namespace hx {
struct View { enum Page { TEST, SENSOR, PROGRESS } page=TEST; unsigned rotation=0,seq=0; bool valid=false; int temperature_mC=0,humidity_mPermille=0; };
// Original 5x7 uppercase/digit bitmap font, one row per byte (5 significant bits).
inline const uint8_t* glyph(char c) {
struct Entry { char c; uint8_t row[7]; };
static const Entry f[]={
{'0',{14,17,19,21,25,17,14}},{'1',{4,12,4,4,4,4,14}},{'2',{14,17,1,2,4,8,31}},
{'3',{30,1,1,14,1,1,30}},{'4',{2,6,10,18,31,2,2}},{'5',{31,16,16,30,1,1,30}},
{'6',{14,16,16,30,17,17,14}},{'7',{31,1,2,4,8,8,8}},{'8',{14,17,17,14,17,17,14}},{'9',{14,17,17,15,1,1,14}},
{'A',{14,17,17,31,17,17,17}},{'B',{30,17,17,30,17,17,30}},{'C',{14,17,16,16,16,17,14}},
{'D',{30,17,17,17,17,17,30}},{'E',{31,16,16,30,16,16,31}},{'F',{31,16,16,30,16,16,16}},
{'G',{14,17,16,23,17,17,14}},{'H',{17,17,17,31,17,17,17}},{'I',{14,4,4,4,4,4,14}},
{'J',{7,2,2,2,2,18,12}},{'K',{17,18,20,24,20,18,17}},{'L',{16,16,16,16,16,16,31}},
{'M',{17,27,21,21,17,17,17}},{'N',{17,25,21,19,17,17,17}},{'O',{14,17,17,17,17,17,14}},
{'P',{30,17,17,30,16,16,16}},{'Q',{14,17,17,17,21,18,13}},{'R',{30,17,17,30,20,18,17}},
{'S',{15,16,16,14,1,1,30}},{'T',{31,4,4,4,4,4,4}},{'U',{17,17,17,17,17,17,14}},
{'V',{17,17,17,17,17,10,4}},{'W',{17,17,17,21,21,27,17}},{'X',{17,17,10,4,10,17,17}},
{'Y',{17,17,10,4,4,4,4}},{'Z',{31,1,2,4,8,16,31}},{'.',{0,0,0,0,0,6,6}},
{'-',{0,0,0,31,0,0,0}},{'+',{0,4,4,31,4,4,0}},{'/',{1,1,2,4,8,16,16}},
{'%',{25,25,2,4,8,19,19}},{',',{0,0,0,0,6,4,8}},{' ',{0,0,0,0,0,0,0}}
};
for(const auto& e:f)if(e.c==c)return e.row;
static const uint8_t unknown[7]={31,17,1,2,4,0,4};return unknown;
}
inline bool ink(int x,int y,const char* text,int left,int top,int scale=2) {
x-=left;y-=top;if(x<0||y<0||y>=7*scale)return false;
unsigned i=unsigned(x/(6*scale));if(i>=strlen(text)||x%(6*scale)>=5*scale)return false;
return glyph(text[i])[y/scale] & (1<<(4-(x%(6*scale))/scale));
}
inline uint16_t pixel(const View& v,int x,int y) {
constexpr uint16_t W=0xffff,R=0xf800,G=0x07e0,B=0x001f,K=0;
if(x==0||y==0||x==239||y==239)return W;
char title[32],line[32];
snprintf(title,sizeof title,"R%u %s",v.rotation,v.page==View::TEST?"RGB TEST":v.page==View::SENSOR?"SHT40":"SPI SHARED");
if(ink(x,y,title,12,20))return W;
if(ink(x,y,"0",4,4,1)||ink(x,y,"1",229,4,1)||ink(x,y,"2",229,229,1)||ink(x,y,"3",4,229,1))return W;
if(v.page==View::TEST){
if(y>=50&&y<130)return x<80?R:x<160?G:B;
if(ink(x,y,"240X240",12,155)||ink(x,y,"RGB565 LE",12,182))return W;
const char* gap=v.rotation==0?"GAP 0,80":v.rotation==1?"GAP 80,0":"GAP 0,0";
return ink(x,y,gap,12,208)?W:K;
}
if(v.page==View::PROGRESS){
snprintf(line,sizeof line,"PASS %03u/100",v.seq);
if(ink(x,y,line,12,80)||ink(x,y,"WRITE READ",12,125)||ink(x,y,"SPI DONE",12,170))return W;
return K;
}
if(!v.valid)return (ink(x,y,"NO SAMPLE",12,80)||ink(x,y,"CHECK I2C",12,125))?W:K;
unsigned t=v.temperature_mC<0?unsigned(-v.temperature_mC):unsigned(v.temperature_mC);
snprintf(line,sizeof line,"T %c%u.%u C",v.temperature_mC<0?'-':'+',t/1000,(t%1000)/100);
if(ink(x,y,line,12,80))return W;
snprintf(line,sizeof line,"RH %u.%u %%",unsigned(v.humidity_mPermille)/1000,(unsigned(v.humidity_mPermille)%1000)/100);
return ink(x,y,line,12,125)?W:K;
}
struct Rotation { bool swap,mx,my; int gx,gy; };
inline Rotation rotation(unsigned r){switch(r&3){case 0:return {false,true,true,0,80};case 1:return {true,false,true,80,0};case 2:return {false,false,false,0,0};default:return {true,true,false,0,0};}}
}
// ---- components/hxdisplay/Tft.h ----
// SPDX-License-Identifier: MIT
#pragma once
#include "esp_err.h"
#include "driver/spi_master.h"
#include "esp_lcd_panel_io.h"
#include "esp_lcd_panel_ops.h"
#include "freertos/FreeRTOS.h"
#include "freertos/semphr.h"
#include "Graphics.h"
namespace hx {
constexpr int SCK=12,MOSI=11,MISO=10,TCS=5,CCS=13,DC=4,RST=6,BL=7;
constexpr spi_host_device_t HOST=SPI2_HOST;
esp_err_t init_bus();
class Tft {
public:
esp_err_t init();
esp_err_t set_rotation(unsigned r);
esp_err_t draw(const View& v);
void backlight(bool on);
private:
esp_lcd_panel_handle_t panel_=nullptr;
esp_lcd_panel_io_handle_t io_=nullptr;
SemaphoreHandle_t done_=nullptr;
uint16_t* strip_=nullptr;
};
}
// ---- components/hxdisplay/Tft.cpp ----
// SPDX-License-Identifier: MIT
#include "Tft.h"
#include <initializer_list>
#include "driver/gpio.h"
#include "esp_heap_caps.h"
#include "esp_lcd_panel_vendor.h"
namespace hx {
esp_err_t init_bus(){
for(int pin:{TCS,CCS}){ESP_ERROR_CHECK(gpio_set_level(gpio_num_t(pin),1));ESP_ERROR_CHECK(gpio_set_direction(gpio_num_t(pin),GPIO_MODE_OUTPUT));}
ESP_ERROR_CHECK(gpio_set_level(gpio_num_t(BL),0));ESP_ERROR_CHECK(gpio_set_direction(gpio_num_t(BL),GPIO_MODE_OUTPUT));
spi_bus_config_t cfg{};cfg.sclk_io_num=SCK;cfg.mosi_io_num=MOSI;cfg.miso_io_num=MISO;
cfg.quadwp_io_num=-1;cfg.quadhd_io_num=-1;cfg.max_transfer_sz=240*16*2;
return spi_bus_initialize(HOST,&cfg,SPI_DMA_CH_AUTO);
}
static bool done_cb(esp_lcd_panel_io_handle_t,esp_lcd_panel_io_event_data_t*,void* ctx){
BaseType_t wake=pdFALSE;xSemaphoreGiveFromISR((SemaphoreHandle_t)ctx,&wake);return wake==pdTRUE;
}
void Tft::backlight(bool on){gpio_set_level(gpio_num_t(BL),on);}
esp_err_t Tft::init(){
done_=xSemaphoreCreateBinary();strip_=(uint16_t*)heap_caps_malloc(240*16*2,MALLOC_CAP_DMA|MALLOC_CAP_INTERNAL);
if(!done_||!strip_)return ESP_ERR_NO_MEM;
esp_lcd_panel_io_spi_config_t io{};io.cs_gpio_num=TCS;io.dc_gpio_num=DC;io.spi_mode=0;io.pclk_hz=4000000;io.trans_queue_depth=1;io.lcd_cmd_bits=8;io.lcd_param_bits=8;io.on_color_trans_done=done_cb;io.user_ctx=done_;
esp_err_t e=esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)HOST,&io,&io_);if(e!=ESP_OK)return e;
esp_lcd_panel_dev_config_t dev{};dev.reset_gpio_num=RST;dev.rgb_ele_order=LCD_RGB_ELEMENT_ORDER_RGB;dev.data_endian=LCD_RGB_DATA_ENDIAN_LITTLE;dev.bits_per_pixel=16;
if((e=esp_lcd_new_panel_st7789(io_,&dev,&panel_))!=ESP_OK)return e;
if((e=esp_lcd_panel_reset(panel_))!=ESP_OK)return e;
if((e=esp_lcd_panel_init(panel_))!=ESP_OK)return e;
if((e=esp_lcd_panel_invert_color(panel_,true))!=ESP_OK)return e;
if((e=set_rotation(0))!=ESP_OK)return e;
return esp_lcd_panel_disp_on_off(panel_,true);
}
esp_err_t Tft::set_rotation(unsigned r){
auto q=rotation(r);esp_err_t e=esp_lcd_panel_swap_xy(panel_,q.swap);if(e!=ESP_OK)return e;
if((e=esp_lcd_panel_mirror(panel_,q.mx,q.my))!=ESP_OK)return e;
return esp_lcd_panel_set_gap(panel_,q.gx,q.gy);
}
esp_err_t Tft::draw(const View& v){
for(int y=0;y<240;y+=16){
for(int j=0;j<16;++j)for(int x=0;x<240;++x)strip_[j*240+x]=pixel(v,x,y+j);
esp_err_t e=esp_lcd_panel_draw_bitmap(panel_,0,y,240,y+16,strip_);if(e!=ESP_OK)return e;
if(xSemaphoreTake(done_,pdMS_TO_TICKS(1000))!=pdTRUE){backlight(false);return ESP_ERR_TIMEOUT;}
}
backlight(true);return ESP_OK;
}
}
// ---- main/main.cpp ----
// SPDX-License-Identifier: MIT
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/stat.h>
#include "esp_log.h"
#include "esp_timer.h"
#include "esp_vfs_fat.h"
#include "driver/sdspi_host.h"
#include "sdmmc_cmd.h"
#include "Tft.h"
#include "Record.h"
#include "Console.h"
static hx::Tft screen;
static void stop(const char* stage){screen.backlight(false);ESP_LOGE("P08","STOP %s; no more SPI jobs; inspect log then RESET",stage);}
static FILE* new_file(char* path,size_t cap){
for(unsigned n=1;n<=999;++n){
snprintf(path,cap,"/sdcard/P08_%03u.CSV",n);
int fd=open(path,O_CREAT|O_EXCL|O_RDWR,0600);
if(fd>=0){FILE* f=fdopen(fd,"w+");if(!f)close(fd);return f;}
if(errno!=EEXIST)return nullptr;
}
errno=EEXIST;return nullptr;
}
static bool store_and_verify(FILE* f,unsigned n){
char wanted[48],got[48];if(!make_record(n,wanted,sizeof wanted))return false;
long offset=ftell(f);if(offset<0)return false;
if(fwrite(wanted,1,strlen(wanted),f)!=strlen(wanted))return false;
if(fflush(f)!=0||fsync(fileno(f))!=0)return false;
if(fseek(f,offset,SEEK_SET)!=0||!fgets(got,sizeof got,f)||!verify_record(n,got))return false;
return fseek(f,0,SEEK_END)==0;
}
extern "C" void app_main(){
init_console();ESP_ERROR_CHECK(hx::init_bus());
ESP_LOGI("P08","READY SPI2 SCK12 MOSI11 MISO10 TFT_CS5 SD_CS13 MODE0 4MHz");
ESP_LOGI("P08","Select T=tft only (remove card first), C=card only, B=both; one run per RESET");
char mode=0;
while(!mode){char c=console_key();if(c=='T'||c=='t')mode='T';if(c=='C'||c=='c')mode='C';if(c=='B'||c=='b')mode='B';vTaskDelay(pdMS_TO_TICKS(10));}
bool use_card=mode!='T',use_tft=mode!='C';
sdmmc_card_t* card=nullptr;FILE* f=nullptr;char path[64]={};
if(use_card){
// Other CS is already HIGH; initialise the SD card into SPI mode first.
sdmmc_host_t host=SDSPI_HOST_DEFAULT();host.slot=hx::HOST;host.max_freq_khz=4000;
sdspi_device_config_t dev=SDSPI_DEVICE_CONFIG_DEFAULT();dev.host_id=hx::HOST;dev.gpio_cs=gpio_num_t(hx::CCS);
esp_vfs_fat_sdmmc_mount_config_t cfg{};cfg.format_if_mount_failed=false;cfg.max_files=4;cfg.allocation_unit_size=16384;
esp_err_t e=esp_vfs_fat_sdspi_mount("/sdcard",&host,&dev,&cfg,&card);
if(e!=ESP_OK){ESP_LOGE("P08","mount %s",esp_err_to_name(e));stop("card init; no format attempted");return;}
f=new_file(path,sizeof path);if(!f){stop("new file (full card/path/999 names)");return;}
ESP_LOGI("P08","new file %s; existing files kept",path);
}
if(use_tft){
esp_err_t e=screen.init();if(e!=ESP_OK){if(f)fclose(f);stop("TFT init");return;}
hx::View card_view;if(screen.draw(card_view)!=ESP_OK){if(f)fclose(f);stop("test-card SPI transfer");return;}
vTaskDelay(pdMS_TO_TICKS(2000));
}
hx::View v;v.page=hx::View::PROGRESS;
unsigned passed=0;
for(unsigned n=1;n<=100;++n){
int64_t start=esp_timer_get_time();
if(use_card&&!store_and_verify(f,n)){ESP_LOGE("P08","record %u failed",n);fclose(f);stop("write/flush/readback");return;}
int64_t stored=esp_timer_get_time();v.seq=n;
if(use_tft&&screen.draw(v)!=ESP_OK){if(f)fclose(f);stop("TFT transfer timeout; DMA buffer retained");return;}
++passed;
ESP_LOGI("P08","mode=%c pass=%u/100 card_us=%lld tft_us=%lld",mode,passed,(long long)(stored-start),(long long)(esp_timer_get_time()-stored));
vTaskDelay(pdMS_TO_TICKS(50));
}
if(f&&fclose(f)!=0){stop("close");return;}
if(use_card){esp_err_t e=esp_vfs_fat_sdcard_unmount("/sdcard",card);if(e!=ESP_OK){stop("unmount");return;}}
ESP_LOGI("P08","DONE mode=%c records/transfers=%u; logical readback/SPI completion only, inspect real screen separately",mode,passed);
}
T、C、B分别运行,留下100轮和时钟记录
每次上电/RESET后只选一种模式,保留完整日志。先备份卡,示例只接受事先在电脑准备好的FAT32,不会因挂载失败替你格式化。不要热插拔。
把实际卡型号、供电、线长、4MHz工作上限、每轮card_us/tft_us、最后DONE和照片写入空observations.csv;这些栏没有作者实测值。SD文件读回只证明当前软件路径返回相同数据,不能承诺突然断电后的文件完整性。
- 断电拔下SD,启动串口发T;应先有RGB卡再PASS001…100和DONE mode=T,肉眼查颜色/边框。
- 断电插入已备份FAT32卡,启动发C;TFT不初始化且背光低,卡应创建新CSV并有100条校验与DONE mode=C。
- 前两项实际通过后启动发B;SD先mount,每轮写入→fflush/fsync→读回→TFT完成,直到pass=100/100及DONE mode=B。
- 正常卸载后断电取卡,电脑独立读取CSV,核对001–100与100条;另看屏幕PASS100、完整边框,附照片和原日志。
- 做到这里,你应该看到
- 没有B模式实物100轮证据时,hardware维度保持pending。
参考:[1] ESP-IDF5.5.5:SD与其他SPI设备共享的初始化顺序与负载[2] ESP-IDF5.5.5:SDSPI完整例程
只在组合时失败,先查新增加的条件
T失败优先查VIN、BL、TC/DC/RST、固定窗口和RGB字节序;C失败先看mount错误、FAT32、CC13/SO10、卡插接与电源。开文件失败还可能是卡满、文件系统错误或999个名称已占用,不应清空卡来掩盖原因。
各自正常、B失败时,检查两路CS是否真的独立、启动有没有先mount卡、有没有其他库重新初始化SPI,再查线长和负载。降频可以作为另一次有记录的对照;重新构建与记录,不能把建议写成已解决。
主机fixture用同一Record.h执行100条临时文件记录、错序、损坏与长度边界,已经通过;它没有SPI、FAT卡扇区或TFT模型。超时先保留日志,再断电修改接线、RESET恢复。需要复刷时走F06的BOOT/RESET流程,不以关闭欠压检测修复。
前置回看P06的总线排错思路和E01的构建环境;下一篇D03细查同一屏幕的色卡、方向和偏移。
参考:[1] ESP-IDF5.5.5:SD与其他SPI设备共享的初始化顺序与负载[3] ESP-IDF5.5.5:SPI主机、DMA缓冲与事务
资料来源与版本说明
固定版本源与具体模块资料逐项核对;构建与主机逻辑检查通过,所有实板观察待测。资料核对日期:。资料核对、程序构建与实板测试分别记录,不互相替代。