Display 실험실 #2: TTGO T-Display LVGL 사용해보기

기운찬곰·2025년 12월 16일

Display 실험실

목록 보기
2/2
post-thumbnail

저번 시간에는 TTGO T-Display 개발 보드에서 ESP LCD API를 사용해서 ST7789 디스플레이 컨트롤러/드라이버 칩을 제어하는 실습을 진행했습니다.

하지만 실무에서 디스플레이를 사용한다면 텍스트도 있어야 하고요. 버튼이나 다양한 위젯이 필요할 수도 있습니다. 과연 ESP LCD API 만을 이용해서 구현이 가능할까요?

ESP LCD API는 저수준 API로 픽셀 단위로 색상을 그리는 기본 기능만 제공합니다. (예: esp_lcd_panel_draw_bitmap()). 따라서 텍스트를 렌더링하려면 폰트 데이터를 직접 처리하고 각 문자의 비트맵을 픽셀로 변환하는 코드를 직접 작성해야 합니다. 버튼이나 위젯은 터치 입력 처리, 상태 관리, 그래픽 렌더링을 모두 직접 구현해야 해서 매우 복잡합니다.

그렇기 때문에 실무에서는 LVGL 같은 GUI 라이브러리를 ESP LCD API 위에 얹어서 사용하는 것이 일반적입니다. 이번 시간에는 LVGL 에 대해 알아보겠습니다.


LVGL 개요

Light and Versatile Graphics Library (가볍고 활용도 높은 그래픽 라이브러리)

💻 LVGL 메인 페이지: https://lvgl.io/

LVGL은 모든 MCU, MPU 및 디스플레이 유형에 맞는 아름다운 UI를 제작할 수 있는 가장 인기 있는 무료 오픈 소스 임베디드 그래픽 라이브러리입니다.

소비자 가전제품부터 산업 자동화에 이르기까지 모든 애플리케이션은 LVGL의 30개 이상의 내장 위젯, 100개 이상의 스타일 속성, 웹에서 영감을 받은 레이아웃, 그리고 다양한 언어를 지원하는 타이포그래피 시스템을 활용할 수 있습니다.

LVGL은 완전한 오픈 소스이며 외부 종속성이 없어 이식이 매우 간단합니다. 모든 최신 MCU 또는 MPU와 호환되며, 모든 (RT)OS 또는 베어메탈 환경에서 ePaper, 흑백, OLED, TFT 디스플레이는 물론 모니터까지 구동할 수 있습니다. 게다가 상업용 프로젝트에서도 무료로 사용할 수 있습니다.

주요 특징 및 활용

주요 특징은 다음과 같습니다. (더 자세한 내용은 소개 참고)

  • 크로스 플랫폼 지원, 다양한 MCU 지원: Arduino, ESP32, STM32, Raspberry Pi 등 여러 마이크로컨트롤러 및 운영체제에서 동작할 수 있도록 호환성을 갖추고 있습니다.
  • 유연한 아키텍처: 모듈식 구조로 설계되어 있어, 필요한 기능만 선택적으로 사용하여 메모리 및 성능 최적화가 용이합니다.
  • 다양한 위젯 제공: 버튼, 슬라이더, 차트, 텍스트, 이미지 등 다양한 기본 GUI 위젯을 제공하여 복잡한 UI도 비교적 쉽게 구현할 수 있습니다.
  • 고성능: 하드웨어 가속 지원 및 효율적인 메모리 관리로, 저사양의 임베디드 시스템에서도 부드럽고 빠른 그래픽 성능을 제공합니다.
  • 터치스크린 지원: 멀티 터치 및 제스처 인식 기능을 포함하여 다양한 터치스크린 디바이스와의 호환성이 뛰어납니다.
  • GUI 개발 도구: SquareLine Studio와 같은 GUI 디자인 툴이 존재하며, 이를 통해 디자인 작업 후 LVGL 코드를 생성하여 개발을 가속화할 수 있습니다. (라이브러리 자체는 무료이나, 디자인 툴은 라이센스가 있을 수 있습니다.)

LVGL의 활용

LVGL은 스마트 홈 기기, 산업용 HMI(Human-Machine Interface), 웨어러블 장치, 의료 장비 등 디스플레이가 필요하고 사용자 상호작용이 중요한 다양한 임베디드 제품에 사용됩니다. 개발자는 LVGL을 통해 낮은 사양의 마이크로컨트롤러에서도 세련되고 사용하기 쉬운 그래픽 인터페이스를 만들 수 있습니다.

관리 주체

혹시 중국에서 관리하는 라이브러리인가 싶어서 확인을 해봤는데요. 공식적으로는 LVGL LLC라는 법인이 프로젝트를 관리하고, 개발 도구 및 관련 서비스 제공을 담당하는 주체로 존재합니다.

2016년 사이드 프로젝트로 시작된 LVGL은 처음부터 임베디드 시스템에 최적화되어 가볍고 유연하며 효율적으로 설계되었습니다.

LVGL은 언제나 오픈소스로 유지되며, 커뮤니티 주도로 운영되고, 누구나 무료로 사용할 수 있습니다. 우리는 LVGL이 현재는 물론 미래에도 독립성을 유지하고 모든 사람이 접근할 수 있도록 법적 보장을 마련하기 위해 적극적으로 노력하고 있습니다.

VGL LLC는 임베디드 GUI 개발을 더욱 빠르고 쉽게 만들어주는 LVGL UI 에디터와 같은 전문 도구를 개발합니다. 이러한 도구는 폐쇄적 코드이며 상업적 라이선스를 통해 제공되어 오픈 소스 프로젝트를 유지하고 발전시키는 데 기여합니다. (먹고는 살아야지...ㅇㅇ)

개발자 중에는 중국인은 없는거 같네요. 근무지가 헝가리라고 합니다. 뭔가 안심이 되네요.

요구 사항

기본적으로 디스플레이를 구동할 수 있는 모든 최신 컨트롤러는 LVGL을 실행하는 데 적합합니다. 최소 요구 사항은 다음과 같습니다.

  • 16비트, 32비트 또는 64비트 마이크로컨트롤러 또는 프로세서
  • 16MHz 이상의 클럭 속도를 권장합니다.
  • Flash/ROM: 필수 구성 요소의 경우 64kB 이상 (180kB 이상 권장)
  • RAM:
    • 정적 RAM 사용량: 사용 가능 및 위젯 유형에 따라 약 2kB.
    • 스택 크기: > 2kB (> 8kB 권장).
    • 동적 데이터(힙): > 2KB (GUI 위젯을 많이 사용하는 경우 > 48KB를 권장합니다).
    • 디스플레이 버퍼: > "가로 해상도" 픽셀 (> "가로 해상도"의 10배 권장)
    • MCU 또는 외부 디스플레이 컨트롤러에 프레임 버퍼 하나
  • C99 또는 그 이후 버전의 컴파일러.
  • C(또는 C++) 기본 지식: 포인터, 구조체, 콜백

메모리 사용량은 아키텍처, 컴파일러 및 빌드 옵션에 따라 달라질 수 있습니다.

버전

LVGL 프로젝트의 모든 저장소는 GitHub 에 호스팅되어 있습니다. (참고)

참고로, 현재 버전은 9.3 이네요. v8부터는 모든 마이너 릴리스에 대해 1년간 지원이 제공됩니다.


Learn the Basics

💻 참고: https://docs.lvgl.io/master/getting_started/learn_the_basics.html

공식 문서, 가장 처음을 읽어보면서 LVGL 에 대해 감을 잡아보도록 하겠습니다. 아무리 AI가 좋고 정리, 설명을 잘해준다고 해도, 공식 문서가 가장 기본이긴 하더라고요.

LVGL은 어떤 프로젝트에든 추가하여 제품에 UI 기능을 구현할 수 있는 .c, .h 파일 모음이라고 생각하면 됩니다. 일관성 있고 배우기 쉬운 API 함수를 활용하면 위젯, 스타일, 이벤트, 레이아웃 또는 애니메이션을 추가할 수 있습니다.

LVGL은 내장 소프트웨어 렌더링 엔진이나 GPU를 사용하여 이미지를 렌더링하고, 렌더링된 이미지를 디스플레이에 표시하기 위해 콜백 함수를 호출합니다. 이 콜백 함수는 LVGL과 디스플레이 간의 주요 인터페이스 역할을 합니다. 포팅 관련 작업의 대부분은 이러한 콜백 함수를 효율적으로 작성하는 데 집중됩니다.

이 장에서는 LVGL의 작동 방식과 활용법에 대한 기본적인 내용을 소개합니다.

LVGL 통합 개요

다음은 LVGL을 프로젝트에 통합하는 방법에 대한 개요입니다.

  • 드라이버 초기화: 시계, 타이머, 주변기기 등을 설정하는 것은 사용자의 책임입니다.
  • lv_init()을 호출하세요: LVGL 자체를 초기화합니다.
  • 디스플레이 및 입력 장치를 생성합니다: 디스플레이(lv_display_t) 및 입력 장치(lv_indev_t)를 생성하고 해당 콜백을 설정합니다.
  • UI를 생성하세요: LVGL 함수를 호출하여 화면, 위젯, 스타일, 애니메이션, 이벤트 등을 생성합니다.
  • 반복문 내에서 lv_timer_handler() 함수를 호출하세요: 이것은 LVGL 관련 모든 작업을 처리합니다. 화면 새로 고침, 입력 장치 읽기, 사용자 입력(및 기타 요소)에 따라 이벤트를 발생, 애니메이션 실행, 사용자가 생성한 타이머를 실행 등

코드 예시

이는 새 프로젝트에 LVGL을 추가하는 간단한 예시입니다. 수도 코드라고 생각하시고 흐름을 살펴보는 게 중요합니다.

void main(void)
{
    your_driver_init();

	// lv_init()으로 LVGL을 시작합니다. 
    lv_init();

	// 밀리초 단위 시간을 LVGL에 제공하는 콜백을 등록합니다. (애니메이션/타이머용)
    lv_tick_set_cb(my_get_millis);

	// 320x240 디스플레이를 생성
    lv_display_t * display = lv_display_create(320, 240);

	// 화면의 1/10 크기(픽셀당 2바이트) 버퍼를 할당해 부분 렌더링 모드로 설정합니다.
    static uint8_t buf[320 * 240 / 10 * 2];
    lv_display_set_buffers(display, buf, NULL, LV_DISPLAY_RENDER_MODE_PARTIAL);

    /* 이 콜백 함수가 렌더링된 이미지를 디스플레이에 표시합니다 */
    lv_display_set_flush_cb(display, my_flush_cb);

    /* 위젯 생성 */
    // lv_label_create()로 라벨을 만들고 "Hello LVGL!" 텍스트를 표시합니다.
    lv_obj_t * label = lv_label_create(lv_screen_active());
    lv_label_set_text(label, "Hello LVGL!");

    /* LVGL이 주기적으로 작업을 실행하도록 만듭니다 */
    // UI 업데이트, 이벤트 처리, 렌더링을 계속 수행합니다. 
    while(1) {
        /* 현재 표시된 위젯에 대한 업데이트를 여기서 제공합니다. */
        lv_timer_handler();
        my_sleep(5);  /* LVGL 타이머를 다시 처리하기 전에 5밀리초 대기합니다 */
    }
}

/* 시작 이후 경과된 밀리초를 반환합니다. 사용자가 구현해야 합니다 */
uint32_t my_get_millis(void)
{
    return my_tick_ms;
}

/* 렌더링된 이미지를 화면에 복사합니다. 사용자가 구현해야 합니다. */
void my_flush_cb(lv_display_t * disp, const lv_area_t * area, uint8_t * px_buf)
{
    /* 디스플레이에 렌더링된 이미지를 표시합니다 */
    // 실제 전송: SPI/I2C 등으로 픽셀 데이터를 LCD 하드웨어에 실제로 전송 
    my_display_update(area, px_buf);

	// LVGL에게 "버퍼 전송 완료, 다음 영역 렌더링 가능"이라고 알립니다. 버퍼가 사용 가능함을 알림
    // DMA를 사용하는 경우, DMA 완료 인터럽트에서 호출하세요.
    lv_display_flush_ready();
}

이 코드 하나의 중요한 내용은 다 들어가 있군요. 핵심 구성요소에 대해 살펴보겠습니다.

#1. buffer (렌더링 버퍼)

LVGL이 위젯을 그릴 "임시 캔버스" 역할을 합니다. LCD 화면 전체를 한번에 그리면 메모리가 부족하므로, 작은 버퍼에 일부분씩 그립니다. (예: 화면의 1/10 크기). 이렇게 하면 RAM이 부족한 MCU에서도 큰 화면을 다룰 수 있게 해줍니다.

#2. timer_handler (타이머 핸들러)

LVGL의 "심장", 모든 작업을 주기적으로 처리합니다.

  • 위젯들의 상태 변경 확인 (텍스트가 바뀌었나? 버튼이 눌렸나?)
  • 변경된 부분을 buffer에 렌더링 (픽셀 데이터 생성)
  • 애니메이션 진행
  • 렌더링 완료되면 자동으로 flush_cb 호출

호출 주기는 보통 5~10ms마다 계속 호출해야 합니다. 그런면에서 브라우저 API의 requestAnimationFrame 이랑 비슷하네요. (ㅎㅎ)

#3. flush_cb (플러시 콜백)

배달부 역할을 합니다. 버퍼의 내용을 실제 LCD로 전송합니다. 동작 흐름은 다음과 같습니다.

  1. LVGL이 buffer에 그리기를 완성하면 flush_cb가 자동으로 호출됩니다.
  2. 어느 영역(area)을 어떤 픽셀(px_buf)로 그려야 하는지 알려줍니다.
  3. 사용자는 이 함수 안에서 SPI/I2C로 LCD에 데이터 전송하면 됩니다.

구현 예시: esp_lcd_panel_draw_bitmap() 같은 드라이버 함수 호출

#4. flush_ready (전송 완료 신호)

"끝났어요!" 신호 입니다. LVGL에게 LCD 전송이 완료됨을 알립니다. LVGL은 전송이 끝나야 다음 영역을 렌더링할 수 있기 때문에 필요합니다.

  • DMA 사용 시: DMA 완료 인터럽트에서 호출 (비동기)
  • DMA 미사용 시: flush_cb 끝에서 바로 호출 (동기)

전체 동작 흐름을 정리해보겠습니다.

  1. 준비: 사용자가 buffer를 만들고 lv_display_set_buffers()로 LVGL에 등록합니다. 그리고 flush_cb 함수도 등록해둡니다.
  2. 렌더링 시작: 메인 루프에서 lv_timer_handler()를 호출하면, LVGL이 "아, 라벨 텍스트가 변경됐네?" 감지하고 렌더링을 시작합니다.
  3. 버퍼에 그리기: LVGL이 등록된 buffer에 화면의 일부(예: 왼쪽 상단 1/10 영역)를 픽셀 단위로 그립니다. 문자 폰트, 색상, 배경 등을 계산해서 RGB 데이터로 변환합니다.
  4. flush_cb 자동 호출: 한 조각 렌더링이 끝나면 LVGL이 자동으로 my_flush_cb(area, px_buf)를 호출합니다. "여기(area) 이 데이터(px_buf)로 그려주세요"라고 알려주는 거죠.
  5. LCD 전송: flush_cb 안에서 사용자가 구현한 my_display_update() 함수가 SPI/I2C로 LCD 드라이버에 픽셀 데이터를 전송합니다.
  6. 완료 신호: 전송이 끝나면 lv_display_flush_ready()를 호출해서 LVGL에게 "전송 완료!"를 알립니다. LVGL은 이제 다음 조각(2/10, 3/10...)을 렌더링할 수 있습니다.
  7. 반복: 화면의 모든 조각(10개)이 전송될 때까지 4~6단계가 반복됩니다. 다 끝나면 timer_handler가 종료되고, 5ms 대기 후 다시 처음부터 반복됩니다.

핵심 요약: timer_handler가 buffer에 그림 → flush_cb가 LCD로 전송 → flush_ready가 완료 신호 → 다음 조각 반복

... (생략) ... 
   ↓
lv_display_set_buffers() - 렌더링 버퍼 설정
   ↓
lv_display_set_flush_cb(my_flush_cb) - 플러시 콜백 등록
   ↓
lv_label_create() - 위젯 생성 (화면에 추가)
   ↓
lv_label_set_text() - 위젯 속성 설정
   ↓
┌─────────────────────────────────┐
│       while(1) 무한 루프           │
│    ↓                            │
│  lv_timer_handler() ───────┐    │
│    - 위젯 상태 확인           │     │
│    - 변경사항 있으면 렌더링     │     │
│    - 버퍼에 픽셀 그리기        │     │
│    ↓                             │
│  my_flush_cb() 자동 호출           │
│    - 렌더링 완료된 버퍼 전달          │
│    ↓                             │
│  my_display_update()             │
│    - 실제 LCD로 데이터 전송          │
│    ↓                             │
│  lv_display_flush_ready()        │
│    - LVGL에 전송 완료 알림           │
│    ↓                             │
│  my_sleep(5) - 5ms 대기           │
│    ↓                             │
└─────────────────────────────────┘
       ↑                   │
       └───────────────────┘

디스플레이

디스플레이는 실제 하드웨어를 의미합니다. LVGL을 하드웨어에 연결하려면 lv_display_t 객체를 생성하고 초기화해야 합니다.

LVGL은 다양한 내장 드라이버를 기본적으로 지원합니다(LVGL 통합 참고). 하지만 위에서 설명한 것처럼 처음부터 디스플레이를 초기화하는 것도 쉽습니다.

LVGL은 여러 디스플레이를 동시에 처리할 수도 있습니다.

스크린

스크린은 디스플레이 에 생성되는 LVGL 위젯입니다. 다른 위젯을 담는 논리적 컨테이너 역할을 합니다. 디스플레이는 여러 개의 스크린을 가질 수 있지만, 항상 하나의 활성 스크린이 있으며, lv_screen_active() 함수를 사용하여 활성 스크린을 가져올 수 있습니다. 이 함수는 lv_obj_t * 포인터를 반환합니다.

화면을 만드는 가장 일반적인 방법은 부모 위젯을 NULL을 가진 기본 위젯을 만드는 것입니다.

lv_obj_t * my_screen = lv_obj_create(NULL);

화면은 lv_screen_load ( my_screen ) 으로 로드할 수 있습니다.

위젯

위젯은 UI의 기본 구성 요소입니다. 예를 들어 버튼(lv_button), 슬라이더(lv_slider) , 드롭다운 목록(lv_dropdown), 차트(lv_chart) 등이 있습니다.

위젯은 각각의 생성 함수를 호출하여 동적으로 생성할 수 있습니다. 생성 함수는 나중에 위젯을 구성하는 데 사용할 수 있는 lv_obj_t * 포인터를 반환합니다.

각 생성 함수는 새 위젯을 추가할 위젯을 정의하는 parent 단일 인수를 가집니다. 예를 들어:

// screen 부모 밑에 버튼 추가
lv_obj_t * my_button1 = lv_button_create(lv_screen_active());

// 버튼 밑에 라벨 추가
lv_obj_t * my_label1 = lv_label_create(my_button1);

위젯이나 화면이 더 이상 필요하지 않은 경우 lv_obj_delete ( my_button1 ) 함수를 호출하여 제거할 수 있습니다.

위젯의 속성을 변경하려면 두 가지 함수 세트를 사용할 수 있습니다.

  • lv_obj_...(), lv_obj_set_width ( ) , lv_obj_add_style ( ) 등과 같은 일반적인 속성에 대한 함수는 공통 위젯 기능에서 다룹니다.
  • lv_<widget_type>_...() 유형별 속성에 대한 함수(예: lv_label_set_text ( ), lv_slider_set_value ( ) 등)

다음은 크기에 대한 픽셀 이외의 단위를 보여주는 예입니다. 오... 대충 느낌 알겠쓰~

lv_obj_t * my_button1 = lv_button_create(lv_screen_active());

/* Set parent-sized width, and content-sized height */
lv_obj_set_size(my_button1, lv_pct(100), LV_SIZE_CONTENT);

/* Align to the right center with 20px offset horizontally */
lv_obj_align(my_button1, LV_ALIGN_RIGHT_MID, -20, 0);

lv_obj_t * my_label1 = lv_label_create(my_button1);
lv_label_set_text_fmt(my_label1, "Click me!");
lv_obj_set_style_text_color(my_label1, lv_color_hex(0xff0000), 0); /* Make the text red */

이벤트

이벤트는 위젯에 어떤 일이 발생했음을 애플리케이션에 알리는 데 사용됩니다. 위젯에 하나 이상의 콜백 함수를 할당할 수 있으며, 이 함수는 위젯을 클릭, 놓거나, 드래그하거나, 삭제하는 등의 상황에서 호출됩니다.

콜백은 다음과 같이 할당됩니다.

lv_obj_add_event_cb(btn, my_btn_event_cb, LV_EVENT_CLICKED, NULL);

...

void my_btn_event_cb(lv_event_t * e)
{
    printf("Clicked\n");
}

LV_EVENT_ALL 모든 이벤트에 대한 콜백을 호출하는 대신 LV_EVENT_CLICKED 를 대신 사용할 수 있습니다. 이벤트 콜백은 현재 이벤트 코드 및 기타 이벤트 관련 정보를 포함하는 lv_event_t * e 인수를 받습니다. 현재 이벤트 코드는 다음을 통해 가져올 수 있습니다.

lv_event_code_t code = lv_event_get_code(e);

이벤트를 발생시킨 위젯은 다음 명령어를 사용하여 가져올 수 있습니다.

lv_obj_t * widget = lv_event_get_target_obj(e);

지역 및 상태

위젯은 하나 이상의 부분 으로 구성됩니다. 예를 들어 버튼은 LV_PART_MAIN 라는 하나의 부분으로만 이루어져 있습니다. 하지만 슬라이더(lv_slider)는 LV_PART_MAIN, LV_PART_INDICATOR 및 LV_PART_KNOB 를 포함합니다.

파트를 사용하면 위젯의 각 부분에 서로 다른 스타일을 적용할 수 있습니다.

음...그니까 LV_PART_MAIN은 위젯의 메인, 가장 기본이라고 볼 수 있겠네요. 이걸로 스타일을 설정하면 배경색이나 텍스트 색상 등을 바꿀 수 있겠네요.

그 외에는 LV_PART_SCROLLBAR(스크롤바), LV_PART_INDICATOR(프로그레스바의 채워진 부분)... 에서는 해당 부분에 대해서만 스타일을 적용이 가능하고요. 혹은 LV_STATE_CHECKED, LV_STATE_DISABLED 등 특정 상태에 대해서만 해서 스타일을 적용할 수 있는 거 같습니다.

/*Add to checkbox's disabled state*/
lv_obj_add_style(my_checkbox1, &style1, LV_STATE_DISABLED); 

/*Add to the slider's knob pressed state*/
lv_obj_add_style(my_slider1, &style1, LV_PART_KNOB | LV_STATE_PRESSED); 

스타일

스타일은 lv_style_t 객체에 저장됩니다. 객체에는 배경색, 테두리 두께, 글꼴 등의 속성이 포함됩니다. 스타일은 위젯의 특정 파트와 상태 에 추가할 수 있습니다. 위젯에는 해당 포인터만 저장되므로 정적 변수(static) 또는 전역 변수로 정의해야 합니다.

스타일을 사용하기 전에 lv_style_init ( &style1 ) 함수를 사용하여 초기화해야 합니다. 초기화 후에는 속성을 추가하여 스타일을 구성할 수 있습니다. 예를 들면 다음과 같습니다.

static lv_style_t style1;
lv_style_init(&style1);
lv_style_set_bg_color(&style1, lv_color_hex(0xa03080));
lv_style_set_border_width(&style1, 2);

일부 속성(특히 텍스트 관련 속성)은 상속될 수 있습니다. 즉, 위젯에 속성이 설정되어 있지 않으면 부모 위젯에서 해당 속성을 찾습니다. 예를 들어, 화면 스타일에서 글꼴을 한 번 설정하면 위젯이나 부모 위젯 중 하나에서 글꼴이 지정되지 않은 한 해당 화면의 모든 텍스트는 기본적으로 해당 글꼴을 상속받습니다.

Subject와 Observer

Subject와 Observer는 데이터 바인딩을 쉽게 생성할 수 있는 강력한 도구입니다. UI 또는 애플리케이션은 Subject가 변경될 때 알림을 받는 Observer 콜백을 생성하여 이러한 Subject를 구독할 수 있습니다.

위젯은 특정 주제를 구독할 수도 있습니다. 이렇게 하면 위젯이 삭제될 때 구독도 자동으로 해제됩니다.

일반적으로, 주체와 관찰자를 사용하는 것은 UI의 다양한 부분을 연결하고 애플리케이션 데이터 변경에 동적으로 반응하게 하거나, 애플리케이션이 UI 변경에 반응하도록 하는 방법입니다.


LVGL 통합

LVGL은 GitHub에서 이용 가능합니다. (참고)

GitHub에서 해당 라이브러리를 복제하거나 최신 버전을 다운로드 할 수 있습니다. 그래픽 라이브러리 자체는 lvgl디렉토리입니다. 이 디렉토리 안에는 여러 하위 디렉토리가 있지만, LVGL을 사용하려면 src 디렉토리 안에 .c와 .h 파일, 그리고 lvgl/lvgl.h 와 lvgl/lv_version.h 가 필요할 뿐입니다.

데모 및 예시는 lvgl 디렉토리 안에 examples와 demos 디렉토리에 포함되어있습니다. 프로젝트에 예제 및/또는 데모가 필요한 경우 이러한 디렉터리를 프로젝트에 추가하십시오. 만약 make나 CMake가 예제 및 데모 디렉터리를 처리하는 경우 추가 조치는 필요하지 않습니다.

LVGL은 별도의 설정 없이 바로 make나 cMake 빌드 시스템을 지원합니다.


🤔 ESP-IDF는 CMake를 사용하나요?

네, ESP-IDF는 CMake를 공식 빌드 시스템으로 사용합니다. ESP-IDF v4.0 이후부터 CMake 기반으로 전환되었고, 프로젝트 루트에 CMakeLists.txt 파일이 필수입니다 (예전 버전은 GNU Make 사용).

idf.py build를 실행하면 내부적으로 CMake가 돌아가며, 각 컴포넌트(components 폴더)마다 CMakeLists.txt로 의존성과 소스 파일을 관리합니다.

크로스 플랫폼 빌드 지원, 컴포넌트 단위 모듈화가 쉽고, LVGL 같은 외부 라이브러리를 idf_component_register()로 간편하게 통합할 수 있습니다.


💻 참고: https://docs.lvgl.io/master/integration/building/cmake.html

CMake는 크로스 플랫폼 빌드 시스템 생성기입니다. 프로젝트/라이브러리를 다른 프로젝트에 쉽게 통합하는 데 사용됩니다. 또한 다양한 옵션을 사용하여 빌드를 구성하고, 구성 요소를 활성화 또는 비활성화하거나, 구성/빌드 단계에서 사용자 지정 스크립트를 실행할 수 있는 기능을 제공합니다.

"LVGL에는 CMake가 기본적으로 포함되어 있으므로 LVGL을 직접 구성하고 빌드하거나 더 높은 수준의 CMake 빌드에 통합할 수 있습니다."

방법 1. LVGL ESP Portation

💻 참고(공식문서): LVGL Docs > Add LVGL to an ESP32 IDF project

공식 문서를 찾아보면 ESP-IDF 프로젝트에 LVGL을 통합하는 가장 간단한 방법은 esp_lvgl_port 컴포넌트를 사용하는 것이라고 합니다. 깃허브를 찾아보면 espressif/esp-bsp 저장소에서 components/esp_lvgl_port 라는 것을 찾아볼 수 있네요. (깃허브 참고)

참고로, esp-bsp 저장소는 ESP32-S3-BOX, M5Stack 등 Espressif 개발 보드의 하드웨어를 쉽게 사용하도록 미리 설정된 드라이버 모음 (LCD, 터치, 오디오, 센서 등 올인원 패키지)이라고 보면 됩니다.


🤔 포팅(Porting) 이라라는 게 뭘까요?

포팅(Porting)은 특정 플랫폼에서 동작하도록 코드를 맞춰주는 작업입니다.

LVGL 같은 범용 라이브러리는 하드웨어와 독립적으로 설계되어, ESP32에서 돌리려면 "ESP32의 SPI, 타이머, 메모리"를 LVGL이 이해할 수 있게 연결 코드를 작성해야 합니다.

my_flush_cb()에 ESP LCD 드라이버 호출, my_get_millis()에 ESP 타이머 함수 연결, 터치 입력을 LVGL 이벤트로 변환하는 등의 하드웨어와 라이브러리(LVGL)간의 번역 작업을 해줍니다. 중간에서 통역사 역할을 해준다는 거죠.


🎯 esl_lvgl_port는 뭐하는 녀석일까요?

깃허브 Readme.md 설명해 따르면 이 컴포넌트는 Espressif의 LCD 및 터치 드라이버와 함께 LVGL을 사용하는 데 도움을 준다고 합니다. LCD 디스플레이가 있는 모든 프로젝트에서 사용할 수 있습니다.

특징으로는 다음과 같습니다.

  • LVGL 초기화: 작업 및 타이머 생성, 회전을 다룸, 절전
  • 디스플레이 추가/제거 (esp_lcd 사용)
  • 터치 입력 추가/제거 (esp_lcd_touch 사용)
  • 네비게이션 버튼 입력 추가/제거 (button 사용)
  • 인코더 입력 추가/제거 (knob 사용)
  • USB HID 마우스/키보드 입력 추가/제거 (usb_host_hid 사용)

이 컴포넌트는 LVGL8 및 LVGL9를 지원합니다. 기본적으로 최신 LVGL 버전을 선택합니다. 이 컴포넌트는 LVGL 9 버전과 완벽하게 호환됩니다.

// 초기화
const lvgl_port_cfg_t lvgl_cfg = ESP_LVGL_PORT_INIT_CONFIG();
esp_err_t err = lvgl_port_init(&lvgl_cfg);

LVGL에 LCD 화면을 추가합니다. 여러 개의 LCD 화면을 추가하려면 이 함수를 여러 번 호출할 수 있습니다.

static lv_disp_t * disp_handle;

/* LCD IO */
esp_lcd_panel_io_handle_t io_handle = NULL;
ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t) 1, &io_config, &io_handle));

/* LCD driver initialization */
esp_lcd_panel_handle_t lcd_panel_handle;
ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(io_handle, &panel_config, &lcd_panel_handle));

/* Add LCD screen */
const lvgl_port_display_cfg_t disp_cfg = {
    .io_handle = io_handle,
    .panel_handle = lcd_panel_handle,
    .buffer_size = DISP_WIDTH*DISP_HEIGHT,
    .double_buffer = true,
    .hres = DISP_WIDTH,
    .vres = DISP_HEIGHT,
    .monochrome = false,
    .mipi_dsi = false,
    .color_format = LV_COLOR_FORMAT_RGB565,
    .rounder_cb = my_rounder_cb,
    .rotation = {
        .swap_xy = false,
        .mirror_x = false,
        .mirror_y = false,
    },
    .flags = {
        .buff_dma = true,
        .swap_bytes = false,
    }
};
disp_handle = lvgl_port_add_disp(&disp_cfg);

/* ... the rest of the initialization ... */

/* If deinitializing LVGL port, remember to delete all displays: */
lvgl_port_remove_disp(disp_handle);

음... 그니까 "Display 실험실 #1: TTGO T-Display 사용해보기 (feat. ESP-LCD, ST7789)" 에서 봤던 I/O 핸들러 생성하고, ST7789 Panel 핸들러 생성을 한 다음에, 이걸 LVGL 에다가 연결만 해주면 된다는 거네요. 그걸 쉽게 해주는게 esl_lvgl_port 가 하는 일이고요.

그 외에도 터치 입력 추가, 버튼 입력 추가 등이 있습니다. 생략하도록 하겠습니다.

주의할 점은 esp_lvgl_port가 LCD 드라이버까지 대신해 주는 건 아닙니다. 아래와 같은 순서라고 보시면 되고요. LCD 초기화는 여전히 필요합니다.

결론적으로 esp_lvgl_port를 사용하면 LVGL 및 디스플레이 드라이버를 쉽게 설치할 수 있도록 도와주는 함수들을 제공합니다. 또한 터치, 로터리 엔코더, 버튼 또는 USB HID 입력 지원을 추가할 수 있습니다. 전력 절약, 화면 회전 및 기타 플랫폼별 특수 기능 구현도 간소화합니다.

방법 2. LVGL 추가

esp_lvgl_port를 사용하지 않으려면 다음 명령어를 사용하여 LVGL 컴포넌트를 프로젝트에 추가할 수 있습니다. 버전은 요구사항에 맞게 조정하십시오.

// add-dependency는 main/idf_component.yml 파일에 의존성만 기록함
idf.py add-dependency "lvgl/lvgl^9.*"

// 실제 다운로드는 idf.py reconfigure 또는 다음 빌드 시 CMake 재구성 단계에서 수행
idf.py reconfigure

고급 사용법. ESP-IDF 프로젝트에 LVGL 직접 추가하는 방법은 다음과 같습니다.

  1. 프로젝트 루트에 components 폴더 생성 - ESP-IDF가 자동으로 인식하는 컴포넌트 디렉토리 입니다.
  2. LVGL 라이브러리 다운로드 - cd components && git clone --recurse-submodules https://github.com/lvgl/lvgl.git
  3. lv_conf.h 설정 파일 생성 - components/lvgl/ 안에 lv_conf_template.h를 복사해서 lv_conf.h로 이름 변경
  4. lv_conf.h 편집 - 첫 줄의 #if 0을 #if 1로 변경하고, 색상 깊이(LV_COLOR_DEPTH), 메모리 크기 등 설정
  5. 메인 CMakeLists.txt에 의존성 추가 - idf_component_register(... REQUIRES lvgl) (선택사항, 자동 인식됨)

🤔 git clone --recurse-submodules 명령이 뭔가요?

이 명령은 Git 저장소를 서브모듈까지 포함해서 복제합니다. 서브모듈이란 LVGL 내부에서 참조하는 다른 Git 저장소들(예: 폰트, 드라이버 라이브러리 등)을 말하며, 일반 git clone은 이걸 빈 폴더로 남겨두지만 이 옵션은 자동으로 다 받아옵니다.

결과적으로 LVGL 본체 + 모든 의존성 라이브러리가 완전히 설치되어 바로 사용 가능한 상태가 됩니다.


🤔 idf.py add-dependency 를 사용하는거랑 git clone ... 하는 거랑 뭐가 다르나요?

  1. 관리 방식: idf.py add-dependency는 npm/pip처럼 패키지 매니저가 버전 관리하고 idf_component.yml에 기록 됩니다. 반면, git clone은 수동으로 components 폴더에 직접 복사합니다.
  2. 업데이트: 컴포넌트 매니저는 idf.py update-dependencies로 일괄 업데이트 가능 합니다. git clone은 cd components/lvgl && git pull 로 수동 업데이트가 필요합니다.
  3. 저장 위치: 컴포넌트 매니저는 전역 캐시(~/.espressif/) + 프로젝트 managed_components/ 에 설치됩니다. git clone은 프로젝트 내 components/ 폴더에만 존재합니다.
  4. 버전 제어: ^9.*처럼 시맨틱 버전 명시 가능하고 팀원이 idf.py reconfigure로 동일 버전 자동 설치 가능합니다. git clone은 특정 커밋을 직접 체크아웃해야 합니다.

결론적으로 협업 프로젝트나 안정성 중시는 컴포넌트 매니저 사용 추천. LVGL 소스 수정이나 최신 개발 버전 필요하면 git clone을 추천합니다.


🤔 패키지 매니저? 컴포넌트 매니저? 그런게 있나요?

네, ESP-IDF v4.4부터 도입된 공식 의존성 관리 도구입니다. IDF Component Manager 라고 해서 Espressif가 만든 패키지 관리 시스템으로 npm(Node.js), pip(Python)처럼 외부 라이브러리를 명령어로 설치/관리하는 도구입니다.

참고. The ESP Component Registry: https://components.espressif.com/

idf.py add-dependency로 라이브러리 추가하면 idf_component.yml 파일에 기록되고, 빌드 시 자동으로 다운로드 되면서 managed_components/ 폴더에 설치됩니다.

어떤 방식을 사용할까

esp_lvgl_port을 사용하면 ESP-IDF와 LVGL 사이에서 귀찮은 걸 전부 대신 처리하기 때문에 편해집니다. 만약 esp_lvgl_port를 사용하지 않고 lvgl 만을 사용해서 구현하겠다고 한다면 대부분을 직접 구현해야 합니다.

위에서 LVGL 추가하는 예시 보여드렸죠? lv_init 부터 시작해서 esp_timer (tick), lv_disp_drv_register, lv_timer_handler, flush_cb 까지 이런 로직들을 구현해야 합니다.

LVGL 만을 사용하면 완전 제어가 가능하다는 장점이 있지만, 코드가 길어지고, 실수 포인트가 많고, 프로젝트마다 다시 작성해야 됩니다. mutex / thread-safe 도 직접 해줘야 합니다.

근데 처음 배우는 입장에서 esp_lvgl_port를 바로 사용하면 마법 처럼 보일 수 있습니다. 따라서 한 번은 직접 포팅 해보는 게 좋을 거 같다는 게 제 생각입니다. 그래야 구조도 이해할 수 있으니까요.

// 기존 (직접 포팅 하는 경우) 
lv_init();
lvgl_tick_init();
lvgl_display_init();
xTaskCreate(lvgl_task, ...);
...

// esp_lvgl_port 사용 시  (진짜 쉽긴 하네... 딸깍~)
esp_lvgl_port_cfg_t cfg = ESP_LVGL_PORT_INIT_CONFIG();
esp_lvgl_port_init(&cfg);
// 이후 디스플레이 등록... 끝. 

결론. esp_lvgl_port 사용하지 않고 직접 lv_port.c 을 구현해보겠습니다.


실습. LVGL 사용해보기

LVGL 추가, Conf 설정

프로젝트 생성 후에 LVGL 을 추가해보겠습니다. 저는 git clone --recurse-submodules 방식으로 해서 직접 추가했습니다. 그리고 나서 LVGL Config 설정에 대한 부분이 있는데요.

💻 참고(공식문서): https://docs.lvgl.io/master/integration/overview/configuration.html

방법은 두 가지 입니다. lv_conf.h 파일을 생성해서 사용하거나, Kconfig를 통해 menuconfig 에서 설정을 변경하는 방법이 있습니다.

방법 #1. lv_conf.h 파일 생성

lvgl/lv_conf_template.h 파일을 복사해서 그 옆에다가 lv_conf.h 라고 생성하면 됩니다. 그리고 나서 첫번째 줄 #if 0 이라고 되어있는 부분을 1로 바꿔서 파일 내용을 활성해줍니다. 마지막으로 디스플레이 패널에서 사용하는 색상 깊이에 맞게 LV_COLOR_DEPTH 를 설정해주세요.

⚠️ 주의사항) 이 방식을 사용하려고 한다면, 혹시 menuconfig → "LVGL configuration" 에서 "Check this to not use custom lv_conf.h" 가 체크 되어있는지 보십시오. 이게 체크되어있으면 lv_conf.h 를 사용하지 않고 Kconfig 을 통해 설정한다는 의미니까요.

저는 여태까지 lv_conf.h 파일 사용하는 줄 알았습니다. 근데 이상하게 lv_conf.h 파일 설정을 바꿔봐도, 심지어 삭제해봤는데 에러가 안나더라고요. 아... 여태까지 사용안하고 있었구나... 깨닫게 되었죠. (ㅋㅋ)

⚠️ 주의사항) lv_conf.h 파일을 다른 곳에 위치시키고 싶다. 대표적으로 main 폴더에서 사용하고 싶다면, 추가 설정이 필요합니다. 그냥 사용하면 LVGL 내부에서 lv_conf.h 파일을 못 찾습니다.

그래서 main/CMakeLists.txt 에다가 LV_CONF_INCLUDE_SIMPLE 추가를 해봤습니다.

idf_component_register(
    SRCS "main.c"
    INCLUDE_DIRS "."
)

target_compile_definitions(${COMPONENT_LIB} PRIVATE
    LV_CONF_INCLUDE_SIMPLE
)

INCLUDE_DIRS "." 이렇게 하면 main/이 include path에 들어가고요. LV_CONF_INCLUDE_SIMPLE가 "lv_conf.h"로 include 시도한다고 합니다. 그래서 LVGL이 자동으로 main/lv_conf.h를 찾습니다.

해봤는데요. 안되더군요. PUBLIC 으로 해도 안됩니다. idf.py fullclean 후 재빌드 해봤지만 실패했습니다. 아무래도 LVGL 내부에서 main/ include path가 전파가 안되는거 같기도 합니다. LVGL에다가 Include path를 직접 추가해보면 될 거 같긴 하지만 서브모듈을 직접 수정하는 방식은 별로일 거 같아서 안하려고요.

그래서 뭐... 이 방식을 사용하려고 하시면 LVGL 내부에 lv_conf.h 생성해서 사용하던가, 두번째 방법을 사용하십시오.

방법 #2. Kconfig

LVGL은 Kconfig를 사용해서도 설정할 수 있습니다. 현재는 cmake를 통해서만 사용 가능합니다. idf.py menuconfig → "Component config" → "LVGL configuration"에서 색상 깊이, 메모리 크기, 폰트, 위젯 활성화 등 모든 설정을 GUI로 할 수 있습니다

위에서 말했다싶이, "Check this to not use custom lv_conf.h" 가 체크되어있으면 됩니다. 이렇게 사용하면 장점은 파일 관리 불필요, GUI로 편리하게 설정한다는 것이죠. sdkconfig로 버전 관리도 가능하고요.

간단한 프로젝트는 Kconfig만 사용해도 충분할 거 같습니다. 복잡한 LVGL 커스터마이징이나 여러 플랫폼 지원은 lv_conf.h 사용을 해야 될 거 같고요.

아래부터는 코드 관련 부분이라서 깃허브 코드 참고 해주시면 되겠습니다. (특정 커밋)

프로젝트 구조

이번 프로젝트는 "Audio 실험실 #3: MicroSD 카드 음원 파일 재생 (1)" - MP3 Player 만들기랑 이어져있기 때문에 SD 카드 내용도 있습니다.

SD 카드에서 음원 파일을 읽은 다음에, 파일 (이름) 리스트를 뽑아서 LCD에 출력하는 예시입니다. 다만 버튼 조작이라던가, 화면 이동은 하지 않았고요. 이번에는 LVGL을 사용해서 UI 만 어느정도 출력하는 것을 목표로 합니다.

프로젝트 구조는 다음과 같습니다.

  • main.c: 전체 초기화, 오케스트레이션 + LVGL 태스크 루프
  • fs/sdcard.c/.h: SPI3(VSPI)로 SD카드 마운트 (SPI3_HOST). 디렉토리 열어서 확장자 기반 필터링 후 파일명 배열 반환.
  • display
    • st7789.c/.h: 보드 핀 정의/해상도(135x240)/SPI 클럭 등 설정. esp_lcd 기반 ST7789 패널 초기화
    • lcd_adapter.c/.h: esp_lcd의 io와 panel 을 받아서, LVGL 쪽의 “flush 완료 처리” 콜백으로 브릿지하는 얇은 어댑터 계층. (LVGL과 ESP_LCD 사이 중간 다리 역할)
  • lvgl_port/lv_port.c/.h: LVGL 초기화, draw buffer 할당 및 flush 콜백 연결, tick 증가 태스크 운영
  • ui/ui_file_list.c/.h: 검은 배경 + 타이틀 + lv_list로 파일 목록을 버튼 형태로 추가. 버튼 클릭 이벤트는 현재 주석 처리. 추후 선택/재생으로 이어질 예정

데이터 흐름(파일 리스트가 화면에 뜨기까지)

  • SD에서 읽은 char **audio_file_list → create_file_list_ui()에서 그대로 lv_list_add_btn(..., file_list[i])로 텍스트로 사용
  • 화면 갱신이 필요해지면 LVGL이 lcd_flush_cb 호출 → esp_lcd_panel_draw_bitmap() 전송 시작 → 완료 이벤트 → lv_display_flush_ready() 호출 → 다음 렌더 사이클 진행

ESP LCD API (ST7889)

참고. Display 실험실 #1: TTGO T-Display 사용해보기 (feat. ESP-LCD, ST7789)

해당 글을 참고하면 될 거 같고요. SPI 버스 초기화, LCD 패널 I/O 핸들러 생성, LCD Panel(ST7789) 핸들러 생성, 패널에 대한 기본적인 설정 정의를 했습니다.

/**
 * @brief 백라이트 핀을 설정하는 함수
 */
static void backlight_init(void) 
{
    gpio_config_t bk_gpio_config = {
        .mode = GPIO_MODE_OUTPUT,
        .pin_bit_mask = 1ULL << PIN_NUM_BKLT,
    };
    ESP_ERROR_CHECK(gpio_config(&bk_gpio_config));
    ESP_ERROR_CHECK(gpio_set_level(PIN_NUM_BKLT, 1)); // 백라이트 켜기 (HIGH 레벨 가정)
    ESP_LOGI(TAG_ST7789, "Backlight turned ON.");
}

esp_lcd_panel_handle_t st7789_init(void)
{
    // SPI 버스 초기화
    spi_bus_config_t bus_config = {
        ...
    };
    ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &bus_config, SPI_DMA_CH_AUTO));

    // LCD 패널 I/O 핸들러 생성
    esp_lcd_panel_io_handle_t io_handle = NULL;
    esp_lcd_panel_io_spi_config_t io_config = {
        ...
    };
    ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi((esp_lcd_spi_bus_handle_t)SPI2_HOST, &io_config, &io_handle));

    // I/O 등록
    lcd_device_register_io(io_handle);

    // LCD Panel(ST7789) 핸들러 생성
    esp_lcd_panel_handle_t panel_handle = NULL;
    esp_lcd_panel_dev_config_t panel_config = {
        ...
    };
    ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(io_handle, &panel_config, &panel_handle));

    // 패널 등록 (가로: 135, 세로: 240)
    lcd_device_register_panel(panel_handle, LCD_H_RES, LCD_V_RES);

    ...

    // TTGO T-Display 보드에 맞는 해상도/오프셋 설정
    // ST7789 (240x240)에서 135x240을 사용하는 경우, 메모리 시작 주소 오프셋 설정 필요
    esp_lcd_panel_set_gap(panel_handle, 52, 40);

    // 백라이트 켜기
    backlight_init();

    return panel_handle;
}

I/O 핸들러랑 Panel 핸들러는 lcd_adapter 함수(lcd_device_register_io, lcd_device_register_panel) 를 통해 연결해줬습니다. LVGL에서 필요해서요.

LCD Adapter

st7789 → lcd_adapter → lvgl_port 구조로 해서 설계했습니다. 만약에 ST7789 패널이 교체 되더라도 쉽게 수정이 가능하도록 설계했습니다만 완전히 추상화 되진 않았다고 생각합니다.

#include "lcd_adapter.h"
#include "esp_log.h"

static lcd_device_t s_device;
static lcd_flush_done_cb_t s_flush_cb;
static void *s_flush_ctx;

static bool lcd_color_done_cb(
    esp_lcd_panel_io_handle_t io_handle,
    esp_lcd_panel_io_event_data_t *event_data,
    void *user_ctx)
{
    if (s_flush_cb) {
        s_flush_cb(s_flush_ctx);   // flush 완료 알림
    }
    return false;  // context switch 필요 없음
    // FreeRTOS에게 "이 인터럽트 처리 후 태스크 전환 불필요"를 알림 (true면 우선순위 높은 태스크로 즉시 전환)
}

void lcd_device_set_flush_done_cb(lcd_flush_done_cb_t cb, void *user_ctx)
{
    s_flush_cb = cb;
    s_flush_ctx = user_ctx;
}

lcd_device_t *lcd_device_get(void)
{
    return &s_device;
}

void lcd_device_register_io(esp_lcd_panel_io_handle_t io)
{
    esp_lcd_panel_io_callbacks_t cbs = {
        .on_color_trans_done = lcd_color_done_cb,
    };
    esp_lcd_panel_io_register_event_callbacks(io, &cbs, NULL);
}

void lcd_device_register_panel(esp_lcd_panel_handle_t panel,
                               uint16_t w, uint16_t h)
{
    s_device.panel = panel;
    s_device.hor_res = w;
    s_device.ver_res = h;
}

esp_lcd_panel_io_register_event_callbacks 이 부분을 주목할 필요가 있는데요.

esp_lcd_panel_draw_bitmap()으로 픽셀 데이터를 SPI/I2C로 전송할 때, DMA를 사용하면 비동기로 전송되는데, 전송이 끝나면 자동으로 lcd_color_done_cb() 함수가 호출되도록 설정합니다.

lcd_color_done_cb 라는 콜백 안에서 lv_display_flush_ready()를 호출해서 LVGL에게 "LCD 전송 끝났어, 다음 영역 렌더링해도 돼" 라고 알려주게 됩니다. s_flush_cb 가 바로 lv_display_flush_ready 함수 포인터 라고 볼 수 있습니다.

다시 말해서, lcd_color_done_cb는 SPI DMA 전송 완료 시 호출되는 인터럽트 핸들러라고 볼 수 있죠.

LVGL Porting

세 부분으로 나눠서 설명하겠습니다. 먼저 위에서 나왔던 lcd_flush_cb 랑 lvgl_flush_done 가 보이네요.

// flush 완료 콜백 함수
static void lvgl_flush_done(void *user_ctx)
{
    flush_done_cnt++;   // ✅ ISR-safe (테스트 용도)
    lv_display_flush_ready((lv_display_t *)user_ctx);
}

// flush 시작 함수, 전송 시작
static void lcd_flush_cb(lv_display_t *disp,
                         const lv_area_t *area,
                         uint8_t *px_buf)
{
	// 테스트 용도 입니다
    static uint32_t cnt;
    if ((cnt++ % 50) == 0) {
        ESP_LOGI(TAG_LVGL,
            "flush_cb called: (%d,%d)-(%d,%d)",
            area->x1, area->y1, area->x2, area->y2);
    }

    lcd_device_t *device = lv_display_get_user_data(disp);

    esp_lcd_panel_draw_bitmap(
        device->panel,
        area->x1, area->y1,
        area->x2 + 1, area->y2 + 1,
        px_buf
    );
}
  • lcd_flush_cb: LVGL이 버퍼에 렌더링 완료하면 자동 호출 됩니다. esp_lcd_panel_draw_bitmap()으로 SPI DMA 전송을 시작합니다. 함수는 즉시 리턴 됩니다. DMA는 백그라운드 동작 합니다.
  • 좌표 처리: area->x1, y1, x2+1, y2+1로 전달하는 이유는 ESP LCD API가 "끝 좌표+1" 방식(exclusive end)을 사용하기 때문입니다.
  • lvgl_flush_done: SPI DMA 전송 완료 시 하드웨어 인터럽트에서 자동 호출 됩니다. lv_display_flush_ready(disp)로 LVGL에게 완료 신호를 보냅니다.
  • user_ctx 전달: lvgl_flush_done의 user_ctx에 lv_display_t * 포인터가 전달되어, 어느 디스플레이의 flush가 끝났는지 알 수 있습니다.

LVGL 애니메이션/타이머를 위해 10ms마다 시간을 업데이트 하는 함수라고 보면 됩니다.

/**
 * @brief LVGL 내부 타이머 틱을 주기적으로 증가시키는 FreeRTOS 태스크
 */
static void lv_tick_task(void *arg) {
    ESP_LOGI(TAG_LVGL, "lv_tick_task started");

    const uint32_t LV_TICK_PERIOD_MS = 10;
    while (1) {
        lv_tick_inc(LV_TICK_PERIOD_MS);
        vTaskDelay(pdMS_TO_TICKS(LV_TICK_PERIOD_MS));
    }
}
  • 무한 루프에서 10ms마다 lv_tick_inc(10)을 호출해서 LVGL 내부 시간 카운터를 증가. LVGL이 애니메이션 진행, 타이머 만료 등을 계산할 수 있게 해줍니다.
  • 아래의 xTaskCreate 와 연동되는 FreeRTOS 태스크라고 보면 됩니다. 별도 태스크가 주기적으로 시간을 "밀어넣는(push)" 방식 입니다.
  • 우선순위는 1로 설정해서 FreeRTOS에서 낮은 우선순위로 설정 됩니다. 이는 LCD 전송이나 메인 로직에 방해 없이 백그라운드에서 시간만 관리합니다.
  • xTaskCreate 함수 호출 즉시 FreeRTOS 스케줄러가 lv_tick_task를 별도 스레드로 실행하며, 다른 태스크들과 병렬로 동작합니다 (멀티태스킹 시작)

// LVGL 라이브러리 초기화, 타이머 시작, 디스플레이 드라이버를 LVGL에 등록
esp_err_t lvgl_init_and_register_driver(esp_lcd_panel_handle_t panel_handle)
{
    ESP_LOGI(TAG_LVGL, "lvgl_init_and_register_driver() start");

    if (panel_handle == NULL) {
        ESP_LOGE(TAG_LVGL, "Panle handle is NULL. Cannot register driver.");
        return ESP_FAIL;
    }

    // 1. LVGL 핵심 라이브러리 초기화
    lv_init(); 

    // 2. LVGL Ticker 태스크 생성 - (실행_함수, "태스크명", 스택크기, 전달인자, 우선순위, 핸들_포인터)
    xTaskCreate(lv_tick_task, "lv_tick", 4096, NULL, 1, NULL);

	// 3. LCD 디바이스 정보 가져오기 (LCD Adapter 참고) 
    lcd_device_t *device = lcd_device_get();
    ESP_LOGI(TAG_LVGL, "lcd_device: %p, res=%dx%d",
             device, device->hor_res, device->ver_res);

	// 4. LVGL 디스플레이 객체 생성
    lv_display_t *disp = lv_display_create(device->hor_res, device->ver_res);
    ESP_LOGI(TAG_LVGL, "lv_display created: %p", disp);

	// 5. 렌더링 버퍼 할당
    size_t buf_size = device->hor_res * device->ver_res / 10 * 2;
    uint8_t *buf = heap_caps_malloc(buf_size, MALLOC_CAP_DMA);
    assert(buf);
    ESP_LOGI(TAG_LVGL, "LVGL draw buf alloc: %p (%d bytes)",
             buf, buf_size);

	// 6. 버퍼 등록
    lv_display_set_buffers(disp, buf, NULL, buf_size,
                        LV_DISPLAY_RENDER_MODE_PARTIAL);  
    
    // 7. Flush 콜백 등록
    lv_display_set_flush_cb(disp, lcd_flush_cb);
    
    // 8. 사용자 데이터 저장
    lv_display_set_user_data(disp, device);

	// 9. DMA 완료 콜백 연결
    lcd_device_set_flush_done_cb(lvgl_flush_done, disp);

    return ESP_OK;
}
  1. lv_init(): LVGL의 내부 메모리, 변수, 시스템 초기화 (모든 LVGL 사용 전 필수)
  2. 타이머 태스크 생성: 10ms마다 lv_tick_inc()를 호출하는 백그라운드 태스크 시작 (애니메이션 시간 추적용)
  3. LCD 디바이스 정보 가져오기: 이전에 초기화된 LCD 하드웨어 정보(해상도, 패널 핸들 등) 가져옵니다.
  4. LVGL 디스플레이 객체 생성: LVGL에게 "이 크기의 화면을 사용한다"고 알림 (예: 135x240)
  5. 렌더링 버퍼 할당: 화면의 1/10 크기만큼 버퍼 할당 (픽셀당 2바이트 = RGB565). MALLOC_CAP_DMA는 DMA가 접근 가능한 메모리 영역에 할당한다는 의미입니다.
  6. 버퍼 등록: LVGL에게 렌더링용 버퍼 전달, 부분 렌더링 모드(화면을 10조각으로 나눠 그림)
  7. Flush 콜백 등록: LVGL 렌더링 완료 시 호출될 함수를 등록합니다. lcd_flush_cb에서 SPI 전송 시작
  8. 사용자 데이터 저장: lv_display_t 객체에 lcd_device_t 포인터 저장. flush_cb에서 lv_display_get_user_data()로 꺼내 쓸 수 있습니다.
  9. DMA 완료 콜백 연결: LCD DMA 전송 완료 시 lvgl_flush_done(disp) 호출. 그 안에서 lv_display_flush_ready() 실행 됩니다.

UI 생성하기

사실 LVGL 설정하는 과정이 어려운 거지... 사용하는건 쉽습니다. 단순한거 같거든요. 특히 웹 사이트나 앱 프론트를 한번이라도 해봤다면 뭐... 쉽습니다. 재미있는 점은 여기서도 flex 레이아웃이 있다는 거네요.

/**
 * @brief LVGL을 사용하여 파일 리스트 UI를 생성합니다. 
 */
void create_file_list_ui(char **file_list, int file_count)
{
    ESP_LOGI(TAG_UI, "create_file_list_ui");

    lv_obj_t *scr = lv_scr_act(); // screen 활성화

    // 화면 전체를 검은색 배경으로 설정합니다. lv_color_make(0xFF, 0xFF, 0xFF)
    lv_obj_set_style_bg_color(scr, lv_color_make(0x00, 0X00, 0x00), LV_PART_MAIN);

    // flex 레이아웃 적용
    lv_obj_set_flex_flow(scr, LV_FLEX_FLOW_COLUMN);
    lv_obj_set_flex_align(scr,
        LV_FLEX_ALIGN_START,
        LV_FLEX_ALIGN_CENTER,
        LV_FLEX_ALIGN_START);

    if (file_count <= 0) {
        // 파일이 없을 경우 오류 메시지를 표시합니다
        lv_obj_t *label = lv_label_create(scr);
        lv_label_set_text(label, "SD Card or MP3 files not found!");
        lv_obj_set_style_text_color(label, lv_color_make(0xFF, 0x00, 0x00), LV_PART_MAIN);
        lv_obj_align(label, LV_ALIGN_CENTER, 0, 0);
        return; 
    }

    // 타이틀
    lv_obj_t *title = lv_label_create(scr);
    lv_label_set_text(title, LV_SYMBOL_AUDIO " Music List");
    lv_obj_set_style_text_color(title, lv_color_white(), 0);
    lv_obj_set_style_text_font(title, &lv_font_montserrat_18, 0); // 글자 크게 lv_font_montserrat_20
    lv_obj_set_style_text_align(title, LV_TEXT_ALIGN_CENTER, 0);  // 중앙 정렬
    // lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 4);

    // List (lv_list) 위젯 생성
    lv_obj_t *list = lv_list_create(scr);
    // lv_obj_set_size(list, 135, 220); // TTGO T-Display 해상도(240x135)에 맞춘 크기
    lv_obj_set_style_text_color(list, lv_color_make(0xFF, 0xFF, 0xFF), LV_PART_MAIN);
    // lv_obj_align(list, LV_ALIGN_BOTTOM_MID, 0, 0);
    // lv_obj_align(list, LV_ALIGN_CENTER, 0, 0);
    // lv_obj_set_style_pad_all(list, 0, LV_PART_ITEMS); - 효과 없음. 리스트 아이템 패딩이 아닌듯
    // lv_obj_set_style_pad_all(list, 0, LV_PART_MAIN); - 효과 있음. 리스트 자체 패딩인 듯

    // 파일 목록을 리스트에 추가합니다
    for (int i = 0; i < file_count; i++) {
        lv_obj_t *list_btn = lv_list_add_btn(list, LV_SYMBOL_PLAY, file_list[i]);

        // lv_obj_t *lbl = lv_obj_get_child(list_btn, 1);   // ← label은 index 1
        // lv_label_set_long_mode(lbl, LV_LABEL_LONG_CLIP); // 텍스트 애니메이션 끄기
        
        lv_obj_set_style_pad_left(list_btn, 0, LV_PART_MAIN);
        lv_obj_set_style_pad_right(list_btn, 0, LV_PART_MAIN);

        // 이벤트 핸들러 설정
        // lv_obj_set_event_cb(list_btn, list_event_handler);
    }

    // flex 레이아웃 배치
    lv_obj_set_width(title, 135);
    lv_obj_set_width(list, 135);
    lv_obj_set_flex_grow(list, 1); // 남은 공간 전부

    // lv_obj_invalidate(scr);
}

조금 헷갈리는 부분은... 기본 설정된 스타일이 있는거 같은데, 어디가 패딩이 적용되어있는거지? 이런 부분이 헷갈렸습니다. 아무튼 뭐... 웹 사이트 DOM 구조처럼 이것도 트리 구조라고 볼 수 있네요.

메인 함수

main.c 는 앞서 구현했던 모든 내용을 종합해서 순서대로 실행해주는 부분입니다.

// --- 전역 변수 ---
static char **audio_file_list = NULL;
static int audio_file_count = 0; 

static void lvgl_task(void *arg)
{
    ESP_LOGI("LVGL_TASK", "LVGL task started");
    uint32_t last = 0;

    while (1) {
        lv_timer_handler();

		// 테스트 용도
        if (flush_done_cnt != last) {
            last = flush_done_cnt;
            ESP_LOGI("LVGL_TASK", "flush_done_cnt = %lu", last);
        }

        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

void app_main(void)
{
    // SD 카드 초기화
    if (sdcard_init() == ESP_OK) {
        // 음원 파일 목록 읽기
        audio_file_count = sdcard_list_files("/sdcard/music", &audio_file_list);
        ESP_LOGI(TAG, "Tㅇotal found audio files: %d", audio_file_count);
    }

    // LVGL 라이브러리 및 드라이버 초기화
    esp_lcd_panel_handle_t panel = st7789_init();
    lvgl_init_and_register_driver(panel);
    
    // 파일 리스트 UI 그리기 (lvgl_init 이후에는 위치는 크게 상관없는듯)
    create_file_list_ui(audio_file_list, audio_file_count);

    /* ⭐ LVGL 태스크는 스택 크게 */
    BaseType_t ret = xTaskCreate(
        lvgl_task,
        "lvgl",
        8192,
        NULL,
        5,
        NULL
    );
    
    ESP_LOGI(TAG, "lvgl task create: %s", ret == pdPASS ? "OK" : "FAIL"); 
}
  • lv_timer_handler() 호출: LVGL의 핵심이라고 할 수 있습니다. 위젯 상태 확인, 렌더링 수행, 애니메이션 처리, 터치 입력 처리 등 모든 LVGL 작업을 실행합니다. 변경사항이 있으면 자동으로 렌더링 시킵니다. (lcd_flush_cb 호출 → DMA 전송 시작)
  • vTaskDelay(pdMS_TO_TICKS(10)): CPU를 놓아주고 다른 태스크 실행 기회 제공 (10ms = 100Hz 주기로 LVGL 처리)
  • xTaskCreate 호출: 스택 크기 8KB 설정 (tick 태스크보다 2배 큼 - LVGL 처리가 더 복잡). 우선순위는 5로 설정해줬습니다. tick 태스크의 1보다 높음 - UI 반응성 중요.

FreeRTOS 환경에서는 별도 태스크로 분리하여 다른 작업(Wi-Fi, 센서 등)과 멀티태스킹 가능하도록 합니다.

실습 결과

로그 결과를 보면 다음과 같습니다. flush_done_cnt가 계속 증가하고요. 50씩 될 때마다 flush_cb called 이 출력되는 걸 알 수 있습니다. 제대로 실행되고 있는거 맞네요.

I (527) LCD_ADAPTER: register io callbacks, io=0x3ffccd60
I (647) ST7789_DRIVER: Backlight turned ON.
I (647) LVGL_PORT: lvgl_init_and_register_driver() start
I (647) LVGL_PORT: lv_tick_task started
I (647) LVGL_PORT: lcd_device: 0x3ffb2d1c, res=135x240
I (657) LVGL_PORT: lv_display created: 0x3ffb3c98
I (657) LVGL_PORT: LVGL draw buf alloc: 0x3ffe09b0 (6480 bytes)
I (657) LVGL_TASK: LVGL task started
I (667) UI_MODULE: create_file_list_ui
I (707) LVGL_PORT: flush_cb called: (0,0)-(134,23)
I (777) LVGL_TASK: flush_done_cnt = 9
I (777) MP3_PLAYER: lvgl task create: OK
I (777) main_task: Returned from app_main()
I (807) LVGL_TASK: flush_done_cnt = 12
I (817) LVGL_TASK: flush_done_cnt = 13
I (847) LVGL_TASK: flush_done_cnt = 15
I (857) LVGL_TASK: flush_done_cnt = 16
...
I (1217) LVGL_TASK: flush_done_cnt = 43
I (1247) LVGL_TASK: flush_done_cnt = 45
I (1257) LVGL_TASK: flush_done_cnt = 46
I (1287) LVGL_TASK: flush_done_cnt = 48
I (1297) LVGL_TASK: flush_done_cnt = 49
I (1317) LVGL_PORT: flush_cb called: (31,68)-(119,91)
I (1327) LVGL_TASK: flush_done_cnt = 51
...

실제 화면 입니다. 기본적으로 텍스트에 애니메이션이 동작하더라고요. 길이가 길면 알아서 동작하나봅니다.

애니메이션이 동작하기 때문에 화면 변화가 생겨서 flush_done_cnt, flush_cb 이 계속 출력되는 거고요. 만약 이 애니메이션 기능을 끄면 한번 실행 되고 끝입니다. 변화가 없으니까요.

현재는 일본어 폰트가 깨져있는데요. 일본어에는 한자도 포함되어 있어서... 폰트를 다운 받고 c 파일로 변환해도 용량이 크더라고요. 음... 앞으로는 미국 노래 들어야 겠습니다.


참고 자료

profile
행동하는 바보가 돼라. 생각을 즉시 행동으로 옮기는 사람이 되어라

0개의 댓글