부분 프레임 버퍼를 통한 메모리 사용 절감
이 섹션에서는 클록 애플리케이션을 예로 들어 약간의 성능 저하를 감수하고 메모리 요구 사항을 낮추기 위해 부분 프레임 버퍼를 구성 및 사용하는 방법에 대해 설명합니다.
STM32L4R9Discovery 평가 키트에서 실행되는 애플리케이션에 대한 동영상은 아래에서 확인할 수 있습니다
풀 사이즈 프레임 버퍼 메모리
일반적으로 프레임 버퍼는 디스플레이에서 사용 가능한 모든 픽셀을 수용할 만큼 충분한 용량을 갖춘 대형 메모리 배열입니다. 해상도가 480 x 272인 24비트 디스플레이에서 실행 중인 경우, 풀 사이즈 프레임 버퍼의 용량은 480 x 272 x 3바이트 = 391.680바이트입니다.
일부 애플리케이션에는 프레임 버퍼가 두 개("이중 버퍼링") 내지 세 개까지 있을 수 있습니다. 이 경우 총 메모리 요구 사항은 783.360바이트 및 1.175.040바이트가 됩니다.
TouchGFX는 UI의 어떤 부분을 그릴 때 프레임 버퍼에 픽셀 값을 기록합니다. 모든 그리기 작업이 완료된 후에는 프레임 버퍼가 디스플레이로 전송됩니다. 보통 UI의 일부만 업데이트되는 경우에도 전체 프레임 버퍼가 디스플레이로 전송됩니다. 일반적으로 프레임 버퍼를 다수의 소형 블록에서 업데이트한 후 전송할 수 있습니다.
업데이트 1, 업데이트 2, 업데이트 3, ..., 업데이트 N, 디스플레이에 전송
경우에 따라 특히 외부 RAM이 없는 저가 솔루션에서는 애플리케이션의 나머지 부분이 프레임 버퍼와 함께 내부 RAM에 들어갈 수 있을 만큼 프레임 버퍼가 충분히 작아야 합니다. 이런 점에서는 부분 프레임 버퍼가 유용합니다.
부분 프레임 버퍼 메모리
부분 프레임 버퍼를 사용하면 TouchGFX 애플리케이션을 풀 사이즈보다 작은 몇 개의 프레임 버퍼로 실행할 수 있습니다. 프레임 버퍼의 수와 크기는 구성이 가능합니다. 이 기법은 애플리케이션의 메모리 요구 사항을 상당 부분 낮출 수 있지만, 다음과 같은 몇 가지 제약이 있습니다.
- 부분 프레임 버퍼는 내장 메모리가 있는 디스플레이에서만 작동합니다. 보통은 DSI 디스플레이나, 병렬 버스 연결(DBI 유형 A/B, 8080/6800) 또는 SPI 버스 연결이 있는 디스플레이에서 작동합니다.
- 복잡한 애플리케이션에서는 깨짐 현상이 발생할 수 있습니다.
부분 프레임 버퍼는 프레임 버퍼를 사용해 디스플레이에 모든 픽셀을 표시하기 보다는 일반적으로 더 작은 부분을 표시합니다. 이 문서에서 사용된 클록 예제에서는 각기 11.700바이트의 프레임 버퍼 세 개가 사용됩니다. 따라서 프레임 버퍼의 메모리 점유 공간은 35.100바이트가 됩니다.
애플리케이션에서 UI의 일부를 업데이트해야 할 때마다 TouchGFX는 구성된 부분 프레임 버퍼 중 하나를 선택하고, 부분 프레임 버퍼에서 그리기 작업을 완료한 다음, 해당 부분을 디스플레이에 전송합니다. 렌더링이 필요한 UI의 모든 영역에서 이 작업이 반복됩니다. 이렇게 하면 데이터 업데이트 및 전송 공식이 다음과 같이 변경됩니다.
Update1, Transfer1, Update2, Transfer2, Update3, Transfer3, ..., UpdateN, TransferN
어떤 경우에는 다음 버퍼의 업데이트가 실행되는 동안 한 부분 프레임 버퍼의 전송이 실행될 수 있습니다.
디스플레이 깨짐 현상
풀 사이즈 프레임 버퍼를 사용하는 경우에 반해 부분 프레임을 사용하는 경우에는 TouchGFX가 업데이트 되는 즉시 UI의 일부를 전송하게 됩니다. 디스플레이가 주기적으로 새로 고침이 되어야 하기 때문에 최대 16ms(60fps 디스플레이의 경우) 후에 디스플레이의 표면에 수신된 업데이트가 표시됩니다. 이로 인해 모든 업데이트가 전송되기 전에 디스플레이에 대한 첫 번째 업데이트가 사용자에게 표시될 수 있습니다.
그리기 작업 및 전송의 전체 시퀀스를 완료하는 데 시간이 오래 걸리는 경우(> 16ms) 사용자에게 이전 프레임과 일부 새로운 업데이트의 조합이 표시될 가능성이 높습니다. 이를 디스플레이 깨짐 현상이라고 하며, 이러한 현상이 발생하는 것은 바람직하지 않습니다. 이러한 이유로 부분 프레임 버퍼는 렌더링에 오랜 시간이 걸리는 복잡한 애니메이션을 사용하는 애플리케이션에는 적합하지 않습니다.
디스플레이 업데이트 예제
애플리케이션에서 부분 프레임 버퍼를 구성하는 방법을 알아보기 전에 원호가 초 단위로 움직이는 디지털 시계를 예로 들어 살펴보겠습니다. 녹색 원호는 초당 6도씩 움직이며, 1분 후에 완전히 회전합니다. 아래 이미지에서와 같이 UI는 4개의 위젯으로 구성되어 있습니다.
다음은 디지털 시계와 원호를 업데이트하는 코드입니다.
MainView.cpp
void MainView::handleTickEvent()
{
ticks++;
if (ticks == 10)
{
ticks = 0;
secs += 1;
if (secs == 60) //increment minutes
{
secs = 0;
min += 1;
if (min == 60) //increment hours
{
min = 0;
hour += 1;
if (hour == 24)
{
hour = 0;
}
}
//Only update digital clock when minutes or hours change
digitalClock.setTime24Hour(hour, min, secs);
}
//Always update seconds
circleSeconds.updateArc(secs*6 - 20, secs*6);
}
}
다음 이미지는 원호가 상단에 접근하고 디지털 시계가 업데이트될 때 처음 몇 초 동안 업데이트되는 영역을 보여줍니다(회색 직사각형). 처음 두 프레임에서는 초만 변경됩니다(58초 및 59초). 세 번째 프레임에서 60초에 도달하면 시와 분 텍스트가 업데이트됩니다.
위의 세 번째 이미지에서 업데이트된 사각형은 154 x 60 픽셀, 20 x 12 픽셀 및 33 x 8 픽셀입니다. 표준 프레임 버퍼를 사용할 때 이러한 세 개의 직사각형이 전체 프레임 버퍼에 그려지고(이전 픽셀 덮어쓰기), 나중에 디스플레이로 전송됩니다. 부분 프레임 버퍼를 사용할 때 이들 세 개의 직사각형은 자체의 작은 프레임 버퍼에 그려진 다음, 즉시 디스플레이로 전송되어 표시됩니다.
부분 프레임 버퍼 구성
TouchGFX에서 부분 프레임 버퍼를 사용하려면 다음과 같은 단계를 거쳐야 합니다.
- 메모리 버퍼를 이용해 프레임 버퍼 할당자 객체 생성
- 해당 할당자를 사용하도록 TouchGFX HAL 클래스를 구성
- 버퍼를 디스플레이로 전송하는 코드를 작성
1단계와 2단계는 STM32CubeMX를 통해 TouchGFX Generator에서 자동으로 생성되는 반면, 3단계는 픽셀을 디스플레이로 전송하는 독점 드라이버입니다.
위의 구성은 다음과 같은 코드를 생성합니다.
TouchGFXGeneratedHAL.cpp
// Block Allocator for Partial Framebuffer strategy
static ManyBlockAllocator<1920, /* block size */
3, /* number of blocks */
2 /* bytes per pixel */
> blockAllocator;
이 프레임 버퍼 할당자는 세 개의 블록(각각 1920바이트)을 할당합니다. TouchGFX HAL은 부분 프레임 버퍼 전략을 사용하며 블록 할당자를 사용하도록 자동 구성되어 있습니다.
TouchGFXGeneratedHAL.cpp
void TouchGFXGeneratedHAL::initialize()
{
HAL::initialize();
registerEventListener(*(Application::getInstance()));
enableLCDControllerInterrupt();
enableInterrupts();
// Partial framebuffer strategy
setFrameBufferAllocator(&blockAllocator);
setFrameRefreshStrategy(HAL::REFRESH_STRATEGY_PARTIAL_FRAMEBUFFER);
}
이 구성에서는 TouchGFX가 작은 프레임 버퍼를 할당하고 그 안에 UI를 그립니다. 이제 남은 작업은 작은 프레임 버퍼를 디스플레이로 전송하는 것입니다.
먼저, 작은 원 업데이트를 그리기 위해 할당된 두 프레임 버퍼의 위치와 크기를 확인합니다(위의 두 번째 이미지).
직사각형 | x | y | 너비 | 높이 | 픽셀 |
---|---|---|---|---|---|
직사각형 1 | 112 | 56 | 22 | 14 | 308 픽셀 = 924 바이트 |
직사각형 2 | 153 | 42 | 29 | 11 | 319 픽셀 = 957 바이트 |
두 직사각형 모두 크기가 작기 때문에 프레임 버퍼 할당자가 할당한 블록에 들어갈 수 있습니다.
위의 세 번째 이미지에는 원에 대한 작은 업데이트와 텍스트를 덮는 더 큰 직사각형 등 세 개의 업데이트된 직사각형이 나와 있습니다.
직사각형 | x | y | 너비 | 높이 | 픽셀 |
---|---|---|---|---|---|
직사각형 1 | 126 | 51 | 20 | 12 | 240 픽셀 = 720 바이트 |
직사각형 2 | 165 | 42 | 33 | 8 | 264 픽셀 = 792 바이트 |
직사각형 3 | 118 | 165 | 154 | 60 | 9.240 픽셀 = 27.720 바이트 |
다시 말해, 직사각형 1과 2는 너무 작아서 프레임 버퍼 할당자가 할당한 블록에 들어갈 수 있지만 프레임 버퍼 3은 크기가 너무 큽니다. 이 직사각형은 크기가 너무 커서 프레임 버퍼(11,700바이트)에 들어갈 수 있는 여러 개의 직사각형으로 분할됩니다.
여기서 우리는 세 개의 직사각형을 업데이트하지만, 할당자는 두 개의 블록만 갖게 됩니다. 이 상황에서 TouchGFX는 첫 번째 블록들이 전송될 때까지 기다렸다가 블록을 재사용합니다.
프레임 버퍼를 화면으로 전송
TouchGFX는 직사각형을 다시 그려야 할 때 FrameBufferAllocator에서 프레임 버퍼를 할당합니다. 버퍼에 그리기를 한 후 TouchGFX는 다음과 같은 메서드를 호출합니다.
void HAL::flushFrameBuffer(const Rect& rect);
이 함수는 화면에 프레임 버퍼를 전송하도록 HAL 하위 클래스에서 재정의가 가능합니다. 부분 프레임 버퍼가 작동하려면 이러한 특별한 구현이 필요합니다. 다음 섹션에서는 SPI 디스플레이가 포함된 STM32G071 평가 키트 및 DSI 디스플레이가 포함된 STM32L4R9Discovery 평가 키트에서 이를 구성하는 방법을 알아보겠습니다.
X-NUCLEO-GFX01M1 SPI 디스플레이에 프레임 버퍼 전송
이 섹션에서는 X-Nucleo-GFX01M1 확장 보드가 포함된 STM32G071 nucleo 보드에서 TouchGFX 보드를 설정하는 방법을 알아보겠습니다. 이 확장 보드 MB1642B에는 2.2인치 240 x 320 SPI 디스플레이와 64MB의 SPI NOR 플래시가 포함되어 있습니다.
이 TouchGFX 보드 설정에서 프레임워크의 C++ 클래스를 사용하면 부분 프레임 버퍼 블록을 관리하는 데 도움이 됩니다. 이렇게 하면 TouchGFX 보드 설정의 코드가 약간 더 짧아집니다.
TouchGFX 보드 설정은 TouchGFX Generator를 사용해 이루어집니다. Read more about that here
가장 중요한 부분은 flushFrameBuffer 함수입니다.
TouchGFXGeneratedHAL.cpp
void TouchGFXGeneratedHAL::flushFrameBuffer(const touchgfx::Rect& rect)
{
HAL::flushFrameBuffer(rect);
// Try transmitting a block
PartialFrameBufferManager::tryTransmitBlock();
}
여기서는 PartialFrameBufferManager 프레임워크 클래스를 호출하여 블록을 전송해보겠습니다.
TouchGFXGeneratedHAL::endFrame 함수에서 PartialFrameBufferManager를 호출하면 나머지 모든 프레임 버퍼 블록들도 전송됩니다.
TouchGFXGeneratedHAL.cpp
void TouchGFXGeneratedHAL::endFrame()
{
// We must guard the next frame until we're done transferring all blocks over our display interface
// through either a semaphore if user is running an OS or a simple variable if not
PartialFrameBufferManager::transmitRemainingBlocks();
HAL::endFrame();
touchgfx::OSWrappers::signalRenderingDone();
}
PartialFrameBufferManager는 세 개의 함수를 사용해 디스플레이 드라이버 코드와 인터랙션을 합니다. 이러한 함수들은 TouchGFX 보드 설정에 구현되어 있어야 합니다.
TouchGFXGeneratedHAL.cpp
/**
* Check if a Frame Buffer Block is beeing transmitted.
*/
__weak int transmitActive()
{
return touchgfxDisplayDriverTransmitActive();
}
/**
* Check if a Frame Buffer Block ending at bottom may be sent.
*/
__weak int shouldTransferBlock(uint16_t bottom)
{
return touchgfxDisplayDriverShouldTransferBlock(bottom);
}
/**
* Transmit a Frame Buffer Block.
*/
__weak void transmitBlock(const uint8_t* pixels, uint16_t x, uint16_t y, uint16_t w, uint16_t h)
{
touchgfxDisplayDriverTransmitBlock(pixels, x, y, w, h);
}
위의 코드는 단순히 MB1642B 드라이버 코드에서 C함수에 호출을 전달합니다.
MB1642BDisplayDriver.c
int touchgfxDisplayDriverTransmitActive(void)
{
return IsTransmittingBlock_;
}
void touchgfxDisplayDriverTransmitBlock(const uint8_t* pixels, uint16_t x, uint16_t y, uint16_t w, uint16_t h)
{
Display_Bitmap((uint16_t*)pixels, x, y, w, h);
}
이러한 드라이버 코드의 구현은 사용하는 디스플레이에 따라 천차만별입니다. MB1642B 모듈의 경우, SPI 칩 선택 및 데이터/명령 모드를 제어하려면 두 개의 GPIO가 필요합니다.
전송 상태는 휘발성 uint8t 변수인 *IsTransmittingBlock*을 사용하여 구현됩니다. 전송이 시작되고 DMA 콜백에서 0으로 설정될 경우, 이 변수는 1로 설정됩니다.
MB1642BDisplayDriver.c
void MB1642BDisplayDriver_DMACallback(void)
{
/* Transfer Complete Interrupt management ***********************************/
if ((0U != (DMA1->ISR & (DMA_FLAG_TC1))) && (0U != (hdma_spi1_tx.Instance->CCR & DMA_IT_TC)))
{
/* Disable the transfer complete and error interrupt */
__HAL_DMA_DISABLE_IT(&hdma_spi1_tx, DMA_IT_TE | DMA_IT_TC);
/* Clear the transfer complete flag */
__HAL_DMA_CLEAR_FLAG(&hdma_spi1_tx, DMA_FLAG_TC1);
IsTransmittingBlock_ = 0;
...
// Signal Transfer Complete to TouchGFX
DisplayDriver_TransferCompleteCallback();
위에서 알 수 있듯이, DMA 콜백은 전송 완료 콜백을 호출하기도 합니다. 이 함수는 생성된 HAL에서 구현됩니다.
TouchGFXGeneratedHAL.cpp
extern "C"
void DisplayDriver_TransferCompleteCallback()
{
// After completed transmission start new transfer if blocks are ready.
PartialFrameBufferManager::tryTransmitBlockFromIRQ();
}
여기서 PartialFrameBufferManager를 호출하면 가능한 경우 새로운 전송이 시작됩니다.
DSI 디스플레이에서 프레임 버퍼 전송
STM32L4R9Discovery 평가 키트는 DSI 디스플레이를 사용합니다. 일반 HAL 클래스는 STM32HAL_DSI(STM32HAL_DSI.cpp에 위치)라고 불립니다.
FrameBufferAllocator에 블록이 그려졌음을 알리기 위해 HAL::flushFrameBuffer 메서드를 재정의합니다.
TouchGFXHAL.hpp
void TouchGFXHAL::flushFrameBuffer(const Rect& rect)
{
frameBufferAllocator->markBlockReadyForTransfer();
HAL::flushFrameBuffer(rect); //call normal implementation
}
FrameBufferAllocator 하위 클래스 ManyBlockAllocator는 블록이 전송 준비가 되었을 때 전역 함수 FrameBufferAllocatorSignalBlockDrawn()을 호출하게 됩니다. 이 메소드는 반드시 BSP 계층에 구현되어야 합니다.
TouchGFXGeneratedHAL.cpp
void FrameBufferAllocatorSignalBlockDrawn()
{
if (!dsiIsTransferring)
{
sendBlock();
}
}
DSI에서 이미 전송이 진행되고 있지 않는 한, 이 함수는 sendBlock 함수를 호출합니다. TouchGFX에서 그린 첫 번째 블록에서는 이러한 경우가 절대 발생하지 않기 때문에 전송이 시작됩니다. DSI 전송이 여전히 진행 중인 상태에서 다른 블록 그리기가 완료되면 해당 블록은 "전송 준비 상태"로 유지되고, 또 다른 자유 블록(사용 가능한 경우)에서 그리기가 계속됩니다.
DSI 전송이 완료되면 다른 직사각형에서 재사용이 가능하도록 먼저 전송된 블록의 사용을 해제하고, 다음 블록이 전송할 준비가 되었는지 확인해야 합니다. 이것은 모두 ER 인터럽트에서 수행됩니다.
TouchGFXHAL.cpp
__irq void DSI_IRQHandler(void) {
if (__HAL_DSI_GET_FLAG(&hdsi, DSI_IT_ER))
{
// End-of-refresh interrupt. Meaning last DSI transfer is complete
__HAL_DSI_CLEAR_FLAG(&hdsi, DSI_IT_ER);
if (dsiIsTransferring)
{
HAL::getInstance()->getFrameBufferAllocator()->freeBlockAfterTransfer();
dsiIsTransferring = 0;
}
sendBlock(); //transfer next block if availble
}
sendBlock 함수는 더 복잡합니다. 여기서는 프레임 버퍼를 전송하도록 LTDC 및 DSI 주변 장치를 구성합니다. 또한 전송된 데이터를 디스플레이 메모리의 올바른 위치에 저장하도록 디스플레이를 구성합니다. 코드의 이 부분은 특정 디스플레이에 따라 다릅니다. 명령 사양은 디스플레이 데이터 시트를 확인하십시오.
TouchGFXHAL.cpp
static void sendBlock()
{
FrameBufferAllocator* fbAllocator = HAL::getInstance()->getFrameBufferAllocator();
//Is a block ready for transfer?
if (fbAllocator->hasBlockReadyForTransfer())
{
Rect transfer_rect;
const uint8_t* src = fbAllocator->getBlockForTransfer(transfer_rect);
dsiIsTransferring = 1;
//1. Setup LTDC and layer address and dimension
//2. Configure display active area
//3. Start DSI
__HAL_DSI_WRAPPER_DISABLE(&hdsi);
//1: Setup LTDC
LTDC_Layer1->CFBAR = (uint32_t)src;
const uint32_t width = transfer_rect.width;
const uint32_t height = transfer_rect.height;
LTDC->AWCR = ((width + 1) << 16) | (height + 1);
LTDC->TWCR = ((width + 1 + 1) << 16) | (height + 1 + 1);
const uint16_t layer_x0 = 2 + 0;
const uint16_t layer_x1 = 2 + width - 1;
LTDC_Layer1->WHPCR = (layer_x1 << 16) | layer_x0;
const uint16_t layer_y0 = 2 + 0;
const uint16_t layer_y1 = 2 + height - 1;
LTDC_Layer1->WVPCR = (layer_y1 << 16) | layer_y0;
LTDC_Layer1->CFBLR = ((width * 3) << 16) | (width * 3 + 3);
LTDC_Layer1->CFBLNR = height;
LTDC->SRCR = (uint32_t)LTDC_SRCR_IMR;
//2: Configure display
const int16_t x = transfer_rect.x + 4;
const int16_t x2 = transfer_rect.x + 4 + width - 1;
uint8_t InitParam1[4] = { (uint8_t)(x >> 8), (uint8_t)(x & 0xFF), (uint8_t)(x2 >> 8), (uint8_t)(x2 & 0xFF)};
HAL_DSI_LongWrite(&hdsi, 0, DSI_DCS_LONG_PKT_WRITE, 4, DSI_SET_COLUMN_ADDRESS, InitParam1);
const int16_t y = transfer_rect.y;
const int16_t y2 = transfer_rect.y + height - 1;
uint8_t InitParam2[4] = { (uint8_t)(y >> 8), (uint8_t)(y & 0xFF), (uint8_t)(y2 >> 8), (uint8_t)(y2 & 0xFF) };
HAL_DSI_LongWrite(&hdsi, 0, DSI_DCS_LONG_PKT_WRITE, 4, DSI_SET_PAGE_ADDRESS, InitParam2);
//3: Start DSI transfer
__HAL_DSI_WRAPPER_ENABLE(&hdsi);
HAL_DSI_Refresh(&hdsi);
}
}
SPI 디스플레이에서 프레임 버퍼 전송
직사각형을 디스플레이로 전송하는 원리는 DSI와 동일하지만, 몇 가지 세부 사항이 다릅니다.
먼저, 직사각형이 그려질 때 진행 중인 전송이 아직 없으면 전송이 시작됩니다.
TouchGFXHAL.cpp
void TouchGFXHAL::flushFrameBuffer(const touchgfx::Rect& rect)
{
HAL::flushFrameBuffer(rect);
frameBufferAllocator->markBlockReadyForTransfer();
//start transfer if not running already!
if (!LCDManager_IsTransmittingData())
{
touchgfx::Rect r;
const uint8_t* pixels = frameBufferAllocator->getBlockForTransfer(r);
LCDManager_SendFrameBufferBlockWithPosition((uint8_t*)pixels, r.x, r.y, r.width, r.height);
}
}
LCDManager_SendFrameBufferBlockWithPosition 함수는 DMA를 사용해 디스플레이에 대한 SPI 전송을 시작합니다.
전송이 완료되면 SPI 전송 완료 핸들러가 함수를 호출합니다.
TouchGFXHAL.cpp
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
{
UNUSED(hspi);
LCD_CS_HIGH();
isTransmittingData = 0;
//Change to SPI datasize to 8 bit from 16 bit
heval_Spi.Instance->CR2 &= ~(SPI_DATASIZE_16BIT - SPI_DATASIZE_8BIT);
//signal transfer complete
LCDManager_TransferComplete();
}
LCDManager_TransferComplete 함수가 새로운 전송을 시작합니다.
TouchGFXHAL.cpp
void LCDManager_TransferComplete()
{
touchgfx::startNewTransfer();
}
void startNewTransfer()
{
FrameBufferAllocator* fba = HAL::getInstance()->getFrameBufferAllocator();
fba->freeBlockAfterTransfer();
blockIsTransferred = true;
if (fba->hasBlockReadyForTransfer())
{
touchgfx::Rect r;
const uint8_t* pixels = fba->getBlockForTransfer(r);
LCDManager_SendFrameBufferBlockWithPosition((uint8_t*)pixels, r.x, r.y, r.width, r.height);
}
}
결론
이 문서에서는 부분 프레임 버퍼 전략이 프레임 버퍼 메모리가 통합된 디스플레이를 갖춘 플랫폼의 메모리 요구 사항을 낮추는 데 어떤 도움이 되는지 살펴봤습니다.
부분 프레임 버퍼를 구성 및 설정하는 메서드는 모든 플랫폼에서 동일하지만, 블록의 내용을 디스플레이에 전송하는 메서드는 각기 다릅니다. 이렇게 LTDC/DSI 기반 플랫폼(STM32L4R9-DISCO)에서는 DSI에서 전송할 준비가 된 다음 블록에 맞게 LTDC 계층을 재구성할 수 있었던 반면, LCD 컨트롤러가 없는 플랫폼(STM32G071)에서는 SPI를 사용해 디스플레에 블록을 전송할 수 있었는지 그 방식을 살펴봤습니다.