跳转到主要内容

FMC显示接口

本场景描述在使用配备FMC并行接口和GRAM的显示屏时,如何配置STM32 FMC(可变存储控制器)和TouchGFX Generator

Note
本场景假设已在板启动阶段完成可用显示驱动程序的开发。 驱动程序必须能够将像素传输到显示屏,并可以控制显示屏的像素写入位置。 如需进一步的详细信息,请查看显示屏的数据手册。

配置

FMC配置

在STM32CubeMX分类列表的Connectivity组中启用FMC。 在此,需将其中一个可用FMC Bank配置为LCD接口。 具体方法是在STM32CubeMX中将存储器类型设置为LCD接口。 这里还可选择用于向显示屏传输像素的并行数据线数,即16位或8位并行接口。

FMC Bank配置

请务必根据所使用的显示屏正确配置时序。 请核对MCU的FMC基址及用于连接显示屏的存储区。

TouchGFX Generator

显示接口:并行RGB (FMC)

当采用16位并行接口时,TouchGFX Generator可生成HAL,该层提供具有默认实现的简化API,用于向LCD执行数据及寄存器的读写操作:

TouchGFXGeneratedHAL.cpp
...
/**
* @brief Initialize the LCD IO.
* @param 无
* @retval 无
*/
__weak void LCD_IO_Init(void)
{
// 已由MX_FMC_Init() 完成
}

/**
* @brief 在LCD数据寄存器上写入数据。
* @param 数据:要写入的数据
* @retval 无
*/
__weak void LCD_IO_WriteData(uint16_t RegValue)
{
/* 写入16位寄存器 */
FMC_BANK1_WriteData(RegValue);
}
...

有关完整的生成代码,请参见文件TouchGFXGeneratedHAL.cpp。 这些函数被定义为__weak,开发人员可根据需要在其自己的HAL实现中按需重写这些函数。

如果正确配置了FMC Bank,就可以在TouchGFX Generator中进行选择:

显示接口 - 并行RGB (FMC)

显示接口:自定义

如果使用8位FMC接口(或是SPI或DSI命令模式等其他串行接口),则必须在TouchGFX Generator中选择自定义显示接口。 这意味着无法自动生成完整的HAL,因此开发人员必须实现手动配置和将像素从帧缓存传输到显示屏的功能。 实现此功能所需的所有句柄均由TouchGFX Generator生成。 下面显示了如何选择自定义

显示接口 - 自定义

Tip
对于通过8位FMC、SPI或DSI命令模式连接的显示屏,必须选择自定义显示接口

DMA配置

为最大限度减轻CPU负载,可配置一个DMA通道,将像素数据从帧缓存传输到FMC。 如果使用16位并行接口,则可重写生成的LCD API,以改用DMA传输。 另外,还可在TouchGFXHAL.cpp中扩展API,使其包含DMA传输功能。 以下是FMC的GPDMA通道配置示例:

DMA配置

请求设置为软件,因为我们需要对HAL层中的请求进行编程,以便将帧缓存传输到显示屏。 我们启用源地址递增来移动帧缓存指针,并禁用目标地址递增以始终写入FMC Bank存储地址。

用户代码

对于配备嵌入式GRAM的显示屏,在TouchGFXHAL.cpp中实现生成的TouchGFX HAL句柄时,应执行以下步骤,以便将像素传输到显示屏并使显示屏与TouchGFX引擎同步:

  1. 等待“VSYNC”(有时也称为“撕裂效应 (TE) 信号”)向TouchGFX引擎发送信号。
  2. 根据要重绘的帧缓存,将“显示光标”和“活动窗口”(显示屏上正在更新的区域)移动到GRAM中与此区域对应的位置。
  3. 准备将传入的像素数据写入GRAM。 根据所使用的帧缓存策略和显示接口,这可能需要交换帧缓存指针、向TouchGFX Engine发送信号,或等待之前的传输完成。
  4. 发送像素数据。

根据所使用的显示屏和帧缓存策略,上述步骤的实现方式会有所不同。 以下部分将介绍在使用配备FMC并行接口的GRAM显示屏时,如何执行这些步骤。

支持的帧缓存策略

  • 单帧缓存
  • 双帧缓存
  • 部分缓存 - GRAM显示
Further reading
有关TouchGFX中帧缓存策略的概述,请参见文章帧缓存策略

单帧缓存

使用FMC并行接口将像素数据传输到显示屏通常能提供非常高的带宽。 因此,许多应用程序可能会以一种较为简单的方式更新帧缓存,即在上一帧传输到显示屏后才开始渲染下一 帧。 一种更为优化的方法是将上一帧分成较小的数据块进行传输,并向TouchGFX引擎发送信号,使其在已传输的帧缓存中开始渲染下一帧。 这模拟了从LTDC驱动的显示屏中遵循扫描行的概念,区别在于此处我们遵循的是传输行

为实现最佳显示传输和渲染效果,必须将TouchGFX引擎设置为使用正确的帧缓存策略,并提供延迟函数:

TouchGFXHAL.cpp
void TouchGFXHAL::initialize()
{
// 其他初始化代码

registerTaskDelayFunction(&OSWrappers::taskDelay);
setFrameRefreshStrategy(REFRESH_STRATEGY_OPTIM_SINGLE_BUFFER_TFT_CTRL);

TouchGFXGeneratedHAL::initialize();
}

要启动显示传输,需使用显示屏TE信号生成的外部中断来通知TouchGFX引擎开始渲染下一帧,并开始将之前渲染的帧传输到显示屏。 通常情况下,TE信号是一个脉冲,表示显示屏扫描行在上升沿离开有效区域,并在下降沿再次进入有效区域。 如果显示屏传输带宽高于显示屏像素扫描的带宽(通常在FMC情况下),我们可以安全地在TE信号的上升沿开始传输,而不会有撕裂的风险。 下面的代码展示了这种情况:

TouchGFXHAL.cpp
void HAL_GPIO_EXTI_Rising_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == LCD_TE_Pin)
{
// VSync发生,TouchGFX引擎的vsync计数器递增
HAL::getInstance()->vSync();
// VSync发生,向TouchGFX引擎发送信号
OSWrappers::signalVSync();
GPIO::set(GPIO::VSYNC_FREQ);

if (refreshRequested)
{
refreshRequested = false;
nextSendToDisplayLine = refreshMinLine;
maxSendToDisplayLine = refreshMaxLine;
sendNextFrameBufferBlockToDisplay();
}
}
}
Note
如果显示屏传输带宽快于显示屏扫描行,则可在TE信号上升沿安全地启动传输,而不会有撕裂的风险。

在上述代码中,辅助变量nextSendToDisplayLinemaxSendToDisplayLine定义了要传输到显示屏的上一帧区域。 这些变量的值由TouchGFX引擎在TouchGFXHAL::flushFrameBuffer中更新,每当更新了部分帧缓存时就会调用该函数。 帧缓存也会被标记为dirty,表示需要在下一个TE信号时传输数据。

TouchGFXHAL.cpp
void TouchGFXHAL::flushFrameBuffer(const touchgfx::Rect& rect)
{
// 调用父类的flushFrameBuffer(const touchgfx::Rect& rect)实现
TouchGFXGeneratedHAL::flushFrameBuffer(rect);

//在下面的代码中,refreshMinLine和refreshMaxLine变量被更新为覆盖
//帧缓存所有变化的最小可能范围
int rectMin = rect.y;
int rectMax = rect.bottom();
refreshMinLine = MIN(rectMin, refreshMinLine);
refreshMaxLine = MAX(rectMax, refreshMaxLine);

refreshRequested = true; //表示在该时钟周期内帧缓存已更新
}

必须使用FMC函数实现向显示屏发送帧缓存像素块的辅助函数:

TouchGFXHAL.cpp
void sendNextFrameBufferBlockToDisplay()
{
maxDrawLine = nextSendToDisplayLine; // 根据传输进度更新TouchGFX框架允许绘制的区域

const int32_t remainingLines = maxSendToDisplayLine - nextSendToDisplayLine;
if (remainingLines > 0)
{
// 显示传输尚未完成。 开始传输下一个数据块
const uint32_t sendBlockHeight = MIN(MAX_BLOCK_HEIGHT, remainingLines);
setDisplayWindow(0, nextSendToDisplayLine, DISPLAY_WIDTH, sendBlockHeight);
uint16_t* dataPtr = framebuffer + nextSendToDisplayLine * DISPLAY_WIDTH;
uint32_t dataSize = DISPLAY_WIDTH * sendBlockHeight * 2;
nextSendToDisplayLine += sendBlockHeight;
LCD_IO_SendDataDMA((uint8_t*)dataPtr, dataSize);
}
else
{
// 显示传输已完成。 允许绘制到整个帧缓存
maxDrawLine = DISPLAY_HEIGHT;
}
}

在上述代码中,remainingLines表示待传输至显示屏的剩余行数。 如果存在剩余行数,则将sendBlockHeight定义为当前传输的行数。 MAX_BLOCK_HEIGHT常量限制了传输的大小。 maxDrawLine变量是已传输区域的高水位标记,它决定了TouchGFX引擎在下一帧中允许绘制的当前行数。 该值随传输进度递增,在标记为dirty的帧缓区传输完成时被设为DISPLAY_HEIGHT,使得TouchGFX引擎能够完成下一帧的渲染。 TouchGFX引擎通过查询maxDrawLine来了解允许更新的帧缓存范围:

TouchGFXHAL.cpp
uint16_t TouchGFXHAL::getTFTCurrentLine()
{
return maxDrawLine;
}

当一个块传输完成后,传输完成回调函数将用于启动下一传输:

TouchGFXHAL.cpp
static void DMA_TxCpltCallback(DMA_HandleTypeDef* hdma)
{
if (hdma == DISPLAY_DMA)
{
sendNextFrameBufferBlockToDisplay();
}
}

该过程将重复执行,直到帧缓存的所有dirty区都传输至显示屏。

当接收到下一个TE信号时,上述循环将重复执行。

参考实现

TouchGFX板设置NUCLEO-U575ZI + RVA35HI包含一个使用FMC优化渲染/传输的单帧缓存策略参考实现:

双帧缓存

与单帧缓存相比,双缓冲允许在从另一个帧缓存传输上一帧时,TouchGFX引擎可以自由地将下一帧写入一个帧缓存。 这提供了最优的渲染时间,因为它不需要等待显示传输完成。

要启动显示传输,显示屏TE信号生成的外部中断将用于通知TouchGFX引擎交换帧缓存并开始渲染下一帧。 显示屏设置为将之前渲染的帧传输到显示屏:

TouchGFXHAL.cpp
void LCD_SignalTearingEffectEvent(void)
{
// VSync发生,TouchGFX引擎的vsync计数器递增
HAL::getInstance()->vSync();
// VSync发生,向TouchGFX引擎发送信号
OSWrappers::signalVSync();

if (refreshRequested)
{
// 立即交换帧缓冲区,而不是等待任务被调度。
//注意:任务在唤醒时也会交换,但该操作是受保护的,并且如果已经完成交换,
// 那么此操作将不会产生任何效果。

touchgfx::HAL::getInstance()->swapFrameBuffers();

// 设置显示窗口,启用GRAM显示读取功能,通过DMA传输数据缓存
setWindow(0, 0, 240, 240);
LCD_IO_WriteReg(ST7789H2_WRITE_RAM);
dmaCompleted = false;
LCD_IO_SendDataDMA((uint8_t*)TFTframebuffer, (240 * 240 * 2));
}
}

在上述代码中,显示屏的分辨率为240x240像素(16位RGB565帧缓存),因此窗口设置为整个显示区域。 如果带宽有限,窗口可缩小至仅包含dirty区。 TouchGFX HAL函数TouchGFXHAL::flushFrameBuffer可用于此目的。

TouchGFX HAL函数TouchGFXHAL::beginFrame()TouchGFXHAL::endFrame() 用于请求必须执行传输操作,即帧缓存标记为dirty区。

TouchGFXHAL.cpp
static void DMA_TxCpltCallback(DMA_HandleTypeDef* hdma)
{
if (hdma == DISPLAY_DMA)
{
sendNextFrameBufferBlockToDisplay();
}
}

TouchGFXHAL::setTFTFrameBuffer()TouchGFXHAL::getTFTFrameBuffer() 方法维护传输到显示器的帧缓存地址:

TouchGFXHAL.cpp
uint16_t* TouchGFXHAL::getTFTFrameBuffer() const
{
return TFTframebuffer;
}

void TouchGFXHAL::setTFTFrameBuffer(uint16_t* address)
{
TFTframebuffer = address;

TouchGFXGeneratedHAL::setTFTFrameBuffer(address);
}

在TE中断处理程序中,指针TFTframebuffer用于设置传输到显示屏的窗口源地址。

参考实现

TouchGFX板设置STM32H573-DK Int. Flash包含一个使用FMC的双帧缓存策略的参考实现:

部分缓存 - GRAM显示

在TouchGFX Generator中选择部分缓存 - GRAM显示帧缓存策略时,用户必须配置部分帧缓存块的数量及其大小。 TouchGFX Generator生成的代码将实例化FrameBufferAllocator来跟踪部分帧缓存块的状态并配置帧缓存策略:

TouchGFXGeneratedHAL.cpp
// 用于部分帧缓冲区策略的块分配器
static ManyBlockAllocator<2048, /* block size */
3, /* 块数量 */
2 /* 像素字节数 */
> blockAllocator;

void TouchGFXGeneratedHAL::initialize()
{
HAL::initialize();
registerEventListener(*(Application::getInstance()));
// 部分帧缓冲区策略
setFrameBufferAllocator(&blockAllocator);
setFrameRefreshStrategy(HAL::REFRESH_STRATEGY_PARTIAL_FRAMEBUFFER);
}

另一个类PartialFrameBufferManager负责处理部分帧缓存块之间的同步、块的传输以及显示屏刷新率的时序。 生成的代码利用PartialFrameBufferManager公开的API,在TouchGFXGeneratedHAL::flushFrameBuffer中传输帧缓存块:

TouchGFXGeneratedHAL.cpp
void TouchGFXGeneratedHAL::flushFrameBuffer(const touchgfx::Rect& rect)
{
HAL::flushFrameBuffer(rect);
// Try transmitting a block
PartialFrameBufferManager::tryTransmitBlock();
}

PartialFrameBufferManager使用三个函数与显示屏驱动程序代码进行交互。 这些必须在TouchGFX应用模板中实现:

TouchGFXGeneratedHAL.cpp
/**
* 检查是否正在传输帧缓存块。
*/
int transmitActive()
{
return touchgfxDisplayDriverTransmitActive();
}

/**
* 检查是否可以发送一个以底部结束的帧缓冲区块。
*/
int shouldTransferBlock(uint16_t bottom)
{
return touchgfxDisplayDriverShouldTransferBlock(bottom);
}

/**
* 传输一个帧缓冲区块。
*/
void transmitBlock(const uint8_t* pixels, uint16_t x, uint16_t y, uint16_t w, uint16_t h)
{
touchgfxDisplayDriverTransmitBlock(pixels, x, y, w, h);
}

上述代码将调用转发至C函数,这些函数可在TouchGFXHAL.cpp的显示驱动程序中实现。

在下方示例中,实现使用PartialFrameBufferManager中的参数调用FMC API函数:

TouchGFXHAL.cpp
extern "C" void touchgfxDisplayDriverTransmitBlock(const uint8_t* pixels, uint16_t x, uint16_t y, uint16_t w, uint16_t h)
{
isTransmittingBlock = true;

setDisplayWindow(x, y, w, h);

LCD_IO_SendDataDMA((uint8_t*)pixels, w * h * 2);
}

函数touchgfxDisplayDriverTransmitActive直接返回全局变量isTransmittingBlock的值:

TouchGFXHAL.cpp
extern "C" int touchgfxDisplayDriverTransmitActive()
{
return isTransmittingBlock ? 1 : 0;
}

该值会在传输完成回调中重置。

函数touchgfxDisplayDriverShouldTransferBlock检查显示屏是否准备好接收新数据块。 部分帧缓存块在显示屏扫描行之后进行传输。 例如,如果显示屏扫描行已扫描至第50行,而待传输数据块的底部位于第70行,则TouchGFX引擎必须等待显示屏扫描至第70行以下区域后方可开始传输数据块。 这是为了避免显示屏上的撕裂效应。

许多显示屏不支持这一功能,因此可以使用硬件定时器,通过逐行递增计数器来估算行数。 理想情况下,计数器的递增速率应为 “每帧持续时间 (ms)/显示屏高度(像素)”,例如16.67 ms/320像素 = 52.1 us/行。 下面展示了一个使用行定时器来实现touchgfxDisplayDriverShouldTransferBlock的示例:

TouchGFXHAL.cpp
extern "C" int touchgfxDisplayDriverShouldTransferBlock(uint16_t bottom)
{
// 仅当显示器绘制的最后一行超过了所请求块的底部(加上两行的边距)时,才允许块传输
// 通过一个定时器来估计已绘制的行数,通过设定定时器的预分频器使得定时器递增的速率与行绘制速率的tick相匹配
uint16_t lastLineDrawn = LL_TIM_GetCounter(LINE_TIMER);
return bottom + 2 < lastLineDrawn || tearingEffectCount > 0;
}

在上述代码中,如果底部坐标(附加边距)小于显示行计数器,或当tearingEffectCount大于0时,函数返回true。 如果tearingEffectCount大于0,表示显示屏已发出TE信号且当前处于与待传输块相同的帧(但在显示屏Y轴方向上处于更靠后的位置), 这就意味着必须传输该数据块。

Tip
钩子函数“void waitUntilTransmitEnd()”和“void waitUntilCanTransferBlock(uint16_t bottom)”可用于定义当显示屏正在进行传输操作,或者显示屏尚未准备好接收新数据块时会发生的情况。 默认行为是“OSWrappers::taskYield()”。

tearingEffectCount会在TE中断处理程序中递增,并向TouchGFX引擎发出开始渲染下一帧的信号:

TouchGFXHAL.cpp
void HAL_GPIO_EXTI_Rising_Callback(uint16_t GPIO_Pin)
{
if (GPIO_Pin == LCD_TE_Pin)
{
// 处理TE信号的代码。 可能是为了微调显示行定时器的频率

tearingEffectCount++

// VSync发生,TouchGFX引擎的vsync计数器递增
HAL::getInstance()->vSync()
// VSync发生,向TouchGFX引擎发送信号
OSWrappers::signalVSync()

GPIO::set(GPIO::VSYNC_FREQ)
startRenderingImmediately = true
}
}

开始下一帧时,我们将重置tearingEffectCount

TouchGFXHAL.cpp
bool TouchGFXHAL::beginFrame()
{
tearingEffectCount = 0;
return TouchGFXGeneratedHAL::beginFrame();
}

参考实现

TouchGFX板设置NUCLEO-H563ZI + RVA35HI包含一个使用FMC和自适应显示行定时器配置的部分帧缓存策略的参考实现: