GitHub 트렌딩 딥다이브 · 2026-06-24 · TrendShift 라이브 멘션

retro-go 딥다이브
— ESP32 한 칩에 NES·게임보이·SNES·DOOM을 욱여넣고, '앱마다 펌웨어 하나 + OTA 파티션 점프'로 굴리는 레트로 에뮬레이터 펌웨어

RockBase-iot/retro-go. 손바닥만 한 ESP32 보드에서 런처 1개 + 에뮬레이터 여러 개를 각각 독립 펌웨어로 굽고, 게임을 고를 때마다 부팅 파티션을 바꿔 재부팅하는 방식으로 NES·Game Boy·SNES·Master System·PC Engine·Game & Watch·Lynx·Genesis·MSX·DOOM을 돌리는 임베디드 펌웨어다. 사실 이 레포는 유명한 ducalex/retro-go의 포크이고, RockBase가 새로 더한 건 딱 하나 — NM-CYD-C5라는 신형 ESP32-C5(RISC-V) + Cheap Yellow Display + 저항막 터치 하드웨어 타겟이다. 그래서 이 문서는 두 겹으로 읽는다 — ① retro-go 본체가 메모리 부족한 MCU에서 어떻게 거대한 에뮬레이터들을 굴리는가(멀티-앱 OTA·SPI 디스플레이 부분갱신·크래시 트레이스), ② RockBase의 기여가 어떻게 물리 버튼 없는 $10 보드를 게임기로 만드는가(SPI 버스 공유·터치 가상패드). (저장소: RockBase-iot/retro-go · 언어 C(대부분)·C++·Python · 약 874파일 · ESP-IDF 4.4~5.5 · 라이선스 GPLv2 · upstream = ducalex/retro-go · 신생 포크 — TrendShift 라이브 멘션)

목차
  1. 프로젝트 한줄 요약
  2. 왜 주목받는가
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 분석 (게임 하나가 부팅되기까지)
  5. 디렉토리 구조 해부
  6. 학습 포인트 (기술별)
  7. 하드웨어 / 시스템 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 학습 로드맵
  10. 핵심 키워드 사전
  11. 참고 링크

1프로젝트 한줄 요약

"에뮬레이터 하나하나를 '독립된 작은 펌웨어'로 만들어 두고, 게임을 고르면 그 펌웨어로 갈아 끼워 재부팅한다."

한 줄로

retro-go는 메모리가 손바닥만 한 ESP32에서 거대한 에뮬레이터 여러 개를 돌리기 위해, 각 에뮬레이터를 따로 구운 펌웨어 + 전용 플래시 칸(OTA 파티션)으로 쪼개 두고, 런처가 '부팅할 칸'을 바꿔 재부팅시키는 멀티-앱 레트로 게임 펌웨어다.

보통 PC 에뮬레이터는 RAM이 수 GB라 모든 에뮬레이터를 한 프로그램에 담아도 됩니다. 그런데 ESP32는 내부 RAM이 약 520KB밖에 안 됩니다(외부 PSRAM을 붙여도 수 MB). NES·SNES·Genesis 에뮬레이터를 전부 한 바이너리에 넣으면 플래시도 RAM도 터집니다. retro-go의 해법은 "한 번에 하나만 돌린다"입니다 — 런처, NES 묶음, DOOM, Genesis, MSX를 각각 별도 펌웨어로 구워 플래시의 서로 다른 칸(OTA 파티션)에 넣고, 게임을 고르면 ESP-IDF의 OTA 메커니즘으로 "다음 부팅은 저 칸"이라고 적은 뒤 재부팅합니다. 켜질 때마다 통째로 다른 프로그램이 되는 셈이죠. 이 영리한 구조 덕에 8KB짜리 NVS에 "어떤 게임을 켤지"만 남겨 두면, 작은 칩이 마치 여러 게임기를 갈아 끼우듯 동작합니다.

용어
펌웨어(firmware) / ESP32 / 에뮬레이터
펌웨어는 PC의 운영체제+앱 역할을 한 덩어리로 하는, 기기에 직접 굽는 소프트웨어입니다. ESP32는 Espressif가 만든 초저가($2~5) Wi-Fi 내장 마이크로컨트롤러(MCU)로, 메이커·IoT 씬의 사실상 표준입니다. 에뮬레이터는 옛 게임기(예: 패미컴)의 CPU·그래픽·사운드 칩을 소프트웨어로 흉내 내, 원래 롬(ROM) 파일을 그대로 돌리는 프로그램입니다. retro-go는 이 셋을 합쳐 "작은 칩 위에서 옛 게임기들을 흉내 내는 펌웨어"를 만든 것입니다.

좀 더 구체적으로, retro-go는 ESP-IDF(Espressif 공식 SDK, FreeRTOS 기반)로 짠 C 프로젝트입니다. 핵심은 두 부분 — 공통 라이브러리(components/retro-go/, 약 7,800줄)가 디스플레이·오디오·입력·저장·설정·네트워크를 모든 앱이 공유하는 "작은 OS"처럼 추상화하고, 그 위에 런처(파일 브라우저·즐겨찾기·Wi-Fi 파일 매니저)와 에뮬레이터 앱들(7개를 한 펌웨어에 묶은 retro-core + 덩치 큰 prboom-go/gwenesis/fmsx)이 얹힙니다.

그리고 이 레포가 트렌딩에 오른 직접적 이유는 RockBase-iot가 더한 NM-CYD-C5 타겟입니다 — 메이커 씬에서 유명한 CYD(Cheap Yellow Display)의 신형 ESP32-C5 버전 보드에 retro-go를 올리고, 물리 버튼이 하나도 없는 보드에서 저항막 터치스크린을 9개 가상 버튼으로 매핑해 게임기를 만든 "기술 검증(PoC)"입니다. 이 문서는 사용법을 넘어 그 안의 임베디드 시스템 설계를 파고듭니다 — 멀티-앱 OTA, SPI 버스 하나에 화면·터치·SD카드를 다는 법, 매 프레임 전체가 아닌 바뀐 줄만 보내는 부분 갱신, 재부팅해도 살아남는 RTC 메모리로 크래시를 SD카드에 남기는 법.

2왜 주목받는가

"$10 만능 보드(CYD) + 신형 RISC-V 칩(ESP32-C5) + 버튼 없이 터치로 게임 — 따라 하기 쉬운 화제성의 3박자."

먼저, retro-go 본체가 푸는 문제 — '메모리가 손바닥만 한 칩에서 큰 에뮬레이터 돌리기'

ESP32에서 에뮬레이터를 돌리려는 사람은 항상 같은 벽에 부딪힙니다 — 코드도 RAM도 부족하다는 것. SNES 에뮬레이터 하나만 해도 코드가 1MB에 육박하고, 에뮬레이션엔 프레임버퍼·작업 RAM이 또 필요합니다. 에뮬레이터를 여러 개 담으면 플래시·RAM이 모두 터집니다.

함정
"에뮬레이터를 다 한 프로그램에 넣으면 되지"라는 접근

전부 한 바이너리에 링크하면 플래시 용량 초과로 빌드부터 실패하거나, 겨우 빌드해도 한 에뮬레이터의 전역 변수·버퍼가 다른 에뮬레이터의 RAM을 잠식해 불안정해집니다. ESP-IDF의 idf.py도 원래 "한 프로젝트 = 한 앱"만 다뤄, 여러 펌웨어를 한 이미지로 묶는 것 자체를 지원하지 않습니다.

해결
앱마다 독립 펌웨어 + OTA 파티션, 그리고 자체 빌드 오케스트레이터

retro-go는 각 에뮬레이터를 완전히 격리된 펌웨어로 빌드해 각자 OTA 파티션에 넣고, 게임을 고르면 esp_ota_set_boot_partition() + 재부팅으로 갈아탑니다. 한 번에 하나만 RAM에 올라오니 메모리 충돌이 원천 차단됩니다. idf.py가 멀티-앱을 못 하는 문제는 자체 빌드 도구 rg_tool.py가 앱들을 차례로 빌드하고 mkfw.py로 한 이미지에 패킹해 해결합니다. 게다가 7개의 가벼운 코어는 retro-core 하나로 묶어 — 따로 빌드하면 ~3MB일 것을 700KB로 줄입니다.

그리고 RockBase가 더한 화제성 — '버튼 없는 $10 보드를 게임기로'

이 포크가 트렌딩에 오른 건 접근성 때문입니다. 세 가지가 겹쳤습니다.

화제 포인트무엇왜 끌리나
ESP32-C5비교적 신형 싱글코어 RISC-V 칩(240MHz, Wi-Fi 6)"신형 C5에서 에뮬레이터가 돈다"는 기술 신선도. C5 지원은 ESP-IDF 5.5+ 필요
CYD (Cheap Yellow Display)ESP32+TFT+터치를 합친 $5~10 올인원 보드의 통칭납땜·배선 없이 누구나 사서 바로 굽는다 — 진입장벽 최저
터치 가상 패드물리 버튼 없이 저항막 터치(XPT2046)를 9개 버튼 영역으로 매핑추가 하드웨어 0개로 게임기 완성 — 따라 하기 쉬움
비유

retro-go 본체 = 여러 게임기 카트리지를 꽂는 본체. 카트리지(에뮬레이터 펌웨어)를 갈아 끼우면 그 게임기가 됩니다. 단, 카트리지를 갈 때마다 전원을 껐다 켜야 한다는 게 다르죠(=재부팅 점프).

RockBase의 NM-CYD-C5 = 그 본체를 '터치스크린 하나짜리 막대 사탕'에 이식한 것. 버튼이 없으니 화면 위 9칸을 손가락으로 누르면 십자키·A·B가 됩니다. 화려하진 않지만 "누구나 $10로 따라 할 수 있다"가 핵심 매력입니다.

경쟁/유사 프로젝트와 비교

관점retro-goRetroArch(libretro)단일 ESP 에뮬레이터
타깃ESP32 계열 MCUPC·폰·콘솔(고사양)특정 MCU 1종
여러 게임기10여 종(앱 분할)수십 종(코어 동적 로드)대개 1종
멀티 코어 운용OTA 파티션 + 재부팅런타임 .so 로드해당 없음
하드웨어 추상화18종 타겟 컴파일타임 분기드라이버 계층보드 고정
메모리 예산PSRAM 포함 수 MB수백 MB~GB수백 KB~MB
함정 (솔직하게)
"RockBase가 retro-go를 만들었다"는 오해 — 그리고 PoC라는 한계

정확히 하면, retro-go 본체는 ducalex(Alex Duchesne)의 작품이고 코드의 99%가 upstream 그대로입니다(공통 라이브러리 config.h의 프로젝트 URL도 여전히 ducalex를 가리킵니다). RockBase의 실질 기여는 NM-CYD-C5 타겟 하나이며, 문서 스스로 이를 "PoC(개념 검증)"로 표기합니다. 그 타겟은 오디오가 dummy(무음)이고, 터치가 단일 터치라 동시 입력(달리며 점프)이 안 되며, 싱글코어라 듀얼코어 ESP32보다 여유가 적어 "런처+가벼운 코어부터" 권장됩니다. 스타 수 등 수치는 이 문서 작성 시점에 확인되지 않았습니다.

3기술 스택 전체 지도

"ESP-IDF + FreeRTOS 위에, C로 짠 '작은 OS'(공통 라이브러리)와 에뮬레이터 코어들, 그리고 Python 빌드 오케스트레이터."

retro-go는 외부 서비스·클라우드가 전혀 없는 순수 펌웨어입니다. 스택은 네 층으로 읽으면 깔끔합니다 — ① 토대(ESP-IDF/FreeRTOS), ② 공통 라이브러리(모든 앱이 공유하는 추상화), ③ 에뮬레이터 코어들, ④ 빌드·패킹 도구.

① 토대 — ESP-IDF + FreeRTOS

구성요소역할한 줄 설명
ESP-IDF 4.4~5.5공식 SDKCMake 빌드 + 드라이버(SPI·I2S·GPIO) + 부트로더. ESP32-C5는 5.5+ 필수(C5 지원이 5.5부터).
FreeRTOS실시간 OS렌더·오디오·입력을 태스크/큐로 동시 처리. CONFIG_FREERTOS_HZ=100.
FAT (microSD)파일시스템롬·세이브·커버아트를 SD카드에서 읽음(코드페이지 437 + UTF-8 긴 파일명).
NVS설정 저장소키-값 영구 저장. "다음에 켤 게임"같은 부트 설정도 여기 남김.
용어
ESP-IDF / FreeRTOS / PSRAM
ESP-IDF는 ESP32용 공식 개발 프레임워크로, CMake 빌드 시스템과 Wi-Fi·SPI·I2S 같은 드라이버, 부트로더, 그리고 FreeRTOS를 묶어 줍니다. FreeRTOS는 작은 기기용 실시간 운영체제로, 여러 "태스크"(스레드 비슷한 것)를 우선순위로 번갈아 돌립니다. PSRAM(또는 SPIRAM)은 칩 내부 RAM(~520KB)이 부족할 때 외부에 붙이는 SPI 인터페이스의 의사 SRAM으로, retro-go는 프레임버퍼·작업 메모리에 이걸 반드시 요구합니다(NM-CYD-C5는 8MB).

② 공통 라이브러리(components/retro-go/) — 모든 앱의 '작은 OS'

이 폴더가 retro-go의 심장입니다. 런처든 에뮬레이터든 모두 이 라이브러리를 링크해, 하드웨어를 직접 만지지 않고 추상화된 API만 부릅니다.

모듈역할
rg_system.c (47KB)부팅·앱 전환·크래시 복구·패닉 트레이스·태스크 관리 — 가장 핵심
rg_display.c디스플레이 추상화 — 스케일링·부분 갱신·필터
rg_audio.c · rg_input.c오디오(I2S/부저/무음) · 입력(ADC·GPIO·I2C·터치) 추상화
rg_storage.c · rg_settings.cSD카드/VFS/파티션 · NVS 기반 설정(부트 네임스페이스 포함)
rg_gui.c (84KB) · rg_network.c메뉴/위젯 렌더링 · Wi-Fi/NTP/HTTP
config.h + targets/타겟 디스패처RG_TARGET_* 매크로로 18종 보드 정의 중 하나를 골라 include
libs/cJSON · lodepng(PNG) · miniz(ZIP) · netplay

③ 에뮬레이터 코어 — 한 레포에 8/16비트 CPU가 줄줄이

retro-go는 코어를 두 형태로 배치합니다 — 가벼운 7개는 retro-core 하나로 번들(크기 절약), 덩치 큰 3개는 독립 앱.

코어게임기원본 / CPU
nofrendoNES(패미컴)nofrendo 포크 · 6502 · 매퍼 60+종을 개별 .c로
gnuboyGame Boy / Colorgnuboy · LR35902
smsplusSMS·Game Gear·Colecovision·SG-1000SMS Plus GX · Z80 + SN76489
pce-goPC EngineHuExpress 포크 · H6280
handyAtari Lynxlibretro-handy · 유일한 C++ 코어
gw-emulatorGame & Watchbzhxx/lcd-game-emulator · SM510 계열
snes9xSNES(느림)Snes9x 2005 · 65c816 + SPC700
prboom (독립)DOOM(모드 지원)PrBoom 2.5.0
gwenesis (독립)Mega Drive/Genesisbzhxx/gwenesis
fmsx (독립)MSX/MSX2fMSX(비상업 라이선스)

④ 빌드·패킹 도구(Python) — '여러 펌웨어를 한 이미지로'

rg_tool.py가 핵심입니다. idf.py가 멀티-앱을 못 하므로, 이 도구가 ① 타겟 환경(targets/{target}/env.py)을 읽어 IDF_TARGET·기본 앱 목록을 정하고, ② 각 앱을 idf.py app으로 빌드하며 타겟 매크로를 주입하고, ③ mkfw.py로 부트로더+파티션테이블+앱들을 단일 이미지(.img/.fw)로 묶습니다.

# rg_tool.py — 앱마다 OTA 파티션을 부여하는 정의
PROJECT_APPS = {
  # 이름          Type SubType  Size(bytes)
  'launcher':     [0, 16, 1048576],   # 1MB
  'retro-core':   [0, 16, 1048576],   # 1MB (NES+GB+SNES+PCE+SMS+G&W+Lynx 번들)
  'prboom-go':    [0, 16, 786432],    # 768KB (DOOM)
  'gwenesis':     [0, 16, 1048576],   # 1MB (Genesis)
  'fmsx':         [0, 16, 589824],    # 576KB (MSX)
}
# SubType 16 = APP_OTA_0. 빌드 시 16,17,18… 순으로 OTA 인덱스를 재기록

타겟별 환경은 env.py 한 장이 exec()로 주입됩니다. NM-CYD-C5의 예 — PoC라 가벼운 앱만 기본 포함합니다:

# targets/nm-cyd-c5/env.py
IDF_TARGET = "esp32c5"
FW_FORMAT  = "none"
DEFAULT_APPS = "launcher retro-core"          # PoC: 가벼운 것만
PROJECT_APPS["retro-core"][2] = 0x110000      # C5용 파티션 크기 오버라이드

4아키텍처 심화 분석 (게임 하나가 부팅되기까지)

"핵심은 단 하나 — 앱 전환 = '부팅 파티션을 바꾸고 재부팅'. 그 위에 디스플레이 부분 갱신, 크래시 트레이스, SPI 버스 공유가 얹힌다."

설계 ① — 멀티-앱 OTA: 앱 전환이 곧 '재부팅 점프'

retro-go에서 가장 중요한 아이디어입니다. 각 에뮬레이터는 플래시의 서로 다른 OTA 파티션에 들어간 독립 펌웨어입니다. 런처에서 게임을 고르면 — ① "다음 부팅은 이 게임, 이 롬 경로"를 NVS에 적고, ② 부팅 파티션을 그 에뮬레이터로 지정한 뒤, ③ 재부팅합니다. 켜지면 통째로 다른 프로그램이 됩니다.

[SPI Flash 16MB — NM-CYD-C5] 0x0000 ┌───────────────────────────┐ 0x2000 │ Bootloader (C5: 0x2000) │ 0x8000 ├───────────────────────────┤ 0x9000 │ nvs (16KB) │ ← rg_settings 저장 (다음에 켤 게임 기록) 0xD000 │ otadata (8KB) │ ← '다음 부팅 파티션'을 가리킴 0xF000 │ phy_init (4KB) │ 0x10000 ├───────────────────────────┤ │ launcher (ota_0, 1MB) │ ◄─┐ │ retro-core (ota_1, 1MB) │ │ esp_ota_set_boot_partition() │ prboom-go (ota_2, 768K) │ │ + esp_restart() │ gwenesis (ota_3, 1MB) │ │ 로 서로 점프 │ fmsx (ota_4, 576K) │ ◄─┘ └───────────────────────────┘
// components/retro-go/rg_system.c — 앱 전환은 noreturn(돌아오지 않음)
void rg_system_switch_app(const char *partition, const char *name,
                          const char *args, uint32_t flags)
{
    if (update_boot_config(partition, name, args, flags))  // 1) 부트 설정 기록
        rg_system_restart();                                // 2) esp_restart()
    RG_PANIC("Failed to switch app!");
}
// update_boot_config 내부:
rg_settings_set_string(NS_BOOT, SETTING_BOOT_NAME, name);   // 어떤 게임
rg_settings_set_string(NS_BOOT, SETTING_BOOT_ARGS, args);   // 롬 경로
esp_ota_set_boot_partition(...);                            // 다음 부팅 파티션
비유

한 명의 배우(ESP32 CPU)가 무대 뒤 분장실 여러 개(OTA 파티션)를 가지고 있다고 보세요. NES 역, DOOM 역, 제네시스 역 분장이 각 방에 준비돼 있습니다. 관객이 "DOOM!"을 외치면(게임 선택), 배우는 쪽지에 'DOOM 방'이라 적어 두고(otadata) 무대를 끄고 다시 켭니다(재부팅). 다시 등장할 땐 완전히 DOOM 배우가 되어 있죠. 한 번에 한 역만 하니 분장(메모리)이 섞이지 않습니다.

설계 ② — 한 펌웨어 안의 7개 코어: 부트 시 네임스페이스로 분기

retro-core는 7개 에뮬레이터를 한 펌웨어에 묶었습니다. 부팅하면 런처가 NVS에 적어 둔 configNs(네임스페이스 문자열, 예 "nes")를 읽어 해당 코어의 진입점으로 분기합니다.

// retro-core/main/main.c — 부트 시 한 코어로 분기
void app_main(void) {
    rg_app_t *app = rg_system_init(AUDIO_SAMPLE_RATE, NULL, NULL);
    if      (!strcmp(app->configNs, "gb")  || !strcmp(app->configNs,"gbc")) gbc_main();
    else if (!strcmp(app->configNs, "nes"))  nes_main();
    else if (!strcmp(app->configNs, "pce"))  pce_main();
    else if (!strcmp(app->configNs, "sms"))  sms_main();   // gg, col도 sms_main
    else if (!strcmp(app->configNs, "gw"))   gw_main();
    else if (!strcmp(app->configNs, "snes")) snes_main();
    else if (!strcmp(app->configNs, "lnx"))  lynx_main();
    else launcher_main();
    RG_PANIC("Never reached");
}

각 코어의 ESP32 종속 코드는 main_nes.c·main_gbc.c·main_snes.c 같은 main 파일에만 격리됩니다. 덕분에 에뮬레이터 본체(CPU 에뮬레이션)는 거의 그대로 두고, 디스플레이·오디오·입력만 retro-go API에 연결하면 이식이 끝납니다.

설계 ③ — 디스플레이 파이프라인: SPI + DMA + '바뀐 줄만' 전송

SPI로 240×320 화면을 매 프레임 통째로 보내면 대역폭이 모자랍니다. retro-go는 스캔라인마다 체크섬을 떠서, 직전 프레임과 같은 줄은 건너뛰고 바뀐 줄만 전송합니다(부분 갱신).

에뮬레이터 코어 └─ rg_surface (프레임버퍼, PSRAM) └─ rg_display_submit() └─ [렌더 태스크] rg_display.c ├─ 스케일링: 좌표 룩업테이블(viewport→source) ├─ 픽셀 변환: PAL / 565_LE↔565_BE 런타임 변환 ├─ 부분 갱신: 줄별 rg_hash() 체크섬 비교 │ → 바뀐 줄만 전송 └─ ili9341.h 드라이버 (ST7789/ILI9341) ├─ SPI 버퍼/트랜잭션을 FreeRTOS 큐로 관리 └─ DMA 전송 (CPU 안 거치고 메모리→SPI)
// rg_display.c — 줄 단위 체크섬으로 바뀐 줄만 그림
if (partial_update)
    checksum = rg_hash(line_buffer_ptr - draw_width, draw_width * 2);
if (screen_line_checksum[draw_top + y] != checksum) {
    screen_line_checksum[draw_top + y] = checksum;
    need_update = true;   // 이 줄만 다시 전송
}

설계 ④ — 크래시 복구: 재부팅해도 살아남는 RTC 메모리

임베디드의 명장면입니다. 패닉(크래시)이 나면 보통 화면도 시리얼도 못 쓰는데, retro-go는 재부팅해도 지워지지 않는 RTC RAM에 백트레이스를 쌓아 두었다가, 다음 부팅 때 SD카드에 crash.log로 저장합니다. 사용자가 케이블 없이 버그를 신고할 수 있죠.

// rg_system.c — 패닉 시 putchar를 가로채 RTC RAM에 누적
static RTC_NOINIT_ATTR panic_trace_t panicTrace;   // 재부팅해도 보존
IRAM_ATTR void esp_panic_putchar_hook(char c) {
    if (panicTrace.magicWord != RG_STRUCT_MAGIC)
        begin_panic_trace("esp_panic", NULL);
    logbuf_putc(&panicTrace, c);
}
// 부팅 시 매직워드가 살아 있으면 = 직전이 크래시 → SD에 저장 + 런처 복귀

설계 ⑤ — SPI 버스 공유: 화면·터치·SD카드가 한 줄을 나눠 쓴다 (NM-CYD-C5)

CYD 보드는 핀이 적어 디스플레이·터치·SD카드가 같은 SPI 버스를 쓰고 CS(칩 선택) 핀만 다릅니다. 문제는, 부팅 초기에 어떤 장치의 CS가 떠 있으면(floating) SD카드 초기화가 깨진다는 것. 해법은 부팅하자마자 모든 CS를 HIGH로 올려 버스 충돌을 막는 것입니다.

// targets/nm-cyd-c5/config.h — SPI 공유 버스 핀맵 (발췌)
#define RG_GPIO_LCD_MISO   GPIO_NUM_2
#define RG_GPIO_LCD_MOSI   GPIO_NUM_7
#define RG_GPIO_LCD_CLK    GPIO_NUM_6
#define RG_GPIO_LCD_CS     GPIO_NUM_23
#define RG_GPIO_SDSPI_CS   GPIO_NUM_10   // SD는 같은 MISO/MOSI/CLK, CS만 다름
#define NM_CYD_C5_TOUCH_CS GPIO_NUM_1

// 부팅 시 모든 CS를 HIGH로 (버스 충돌 방지)
#define RG_CUSTOM_PLATFORM_INIT()                             \
    gpio_set_direction(NM_CYD_C5_TOUCH_CS, GPIO_MODE_OUTPUT); \
    gpio_set_level(NM_CYD_C5_TOUCH_CS, 1);                    \
    gpio_set_pull_mode(RG_GPIO_SDSPI_MISO, GPIO_PULLUP_ONLY);

그리고 버튼이 없으니 터치 좌표를 9개 가상 버튼으로 매핑합니다 — 화면을 십자키·A·B·START 영역으로 나눈 표 하나로:

// targets/nm-cyd-c5/config.h — 저항막 터치 → 가상 게임패드
#define RG_GAMEPAD_TOUCH_MAP {\
  {RG_KEY_UP,     0,   0, 120,  70}, {RG_KEY_LEFT,   0,  50,  70, 170},\
  {RG_KEY_RIGHT, 70,  50, 140, 170}, {RG_KEY_DOWN,   0, 170, 120, 239},\
  {RG_KEY_B,    170,  90, 239, 179}, {RG_KEY_A,    240,  90, 319, 179},\
  {RG_KEY_MENU, 170,   0, 319,  59}, {RG_KEY_SELECT,140,180,229, 239},\
  {RG_KEY_START,230, 180, 319, 239},\
}
설계 철학 한 줄

"한 번에 하나만 메모리에 올려라(OTA 점프). 전부 보내지 말고 바뀐 것만 보내라(부분 갱신). 죽더라도 흔적은 남겨라(RTC 트레이스)."

이 셋이 retro-go를 관통합니다 — 멀티-앱 OTA(메모리 충돌 0), 스캔라인 부분 갱신(SPI 대역폭 절약), RTC 패닉 트레이스(케이블 없는 디버깅). 거기에 컴파일타임 타겟 분기(18종 보드를 런타임 비용 0으로)와 SPI 버스 공유(핀 부족 극복)가 더해져, "손바닥만 한 칩이 여러 게임기를 갈아 끼우는" 그림이 완성됩니다.

5디렉토리 구조 해부

"공통 라이브러리 1개 + 앱 여러 개 + 빌드 도구. 폴더 이름만 봐도 '추상화(components) / 화면(launcher) / 게임기(retro-core)'가 보인다."

retro-go/ ├── rg_tool.py ★ 멀티-앱 빌드/플래시 오케스트레이터 (idf.py 래퍼) ├── base.cmake 모든 앱이 include — 공통 컴파일 옵션(PSRAM 패치 등) ├── Dockerfile CI 빌드(espressif/idf:release-v4.4 — odroid-go+mrgc-g32만) ├── BUILDING / PORTING / THEMING.md 개발 문서 ├── COPYING GPLv2 │ ├── components/retro-go/ ★★ 공통 라이브러리('작은 OS') = 모든 앱의 심장 │ ├── rg_system.c (47KB) 부팅/앱전환/크래시복구/패닉트레이스/태스크 │ ├── rg_gui.c (84KB) 메뉴/위젯/렌더 UI │ ├── rg_display.c 디스플레이 추상화(스케일링·부분갱신·필터) │ ├── rg_audio.c · rg_input.c 오디오 · 입력(ADC/GPIO/I2C/터치) │ ├── rg_storage.c · rg_settings.c · rg_network.c │ ├── config.h ★ 타겟 디스패처(RG_TARGET_* → targets/*/config.h) │ ├── drivers/ │ │ ├── display/ili9341.h, dummy.h, sdl2.h │ │ └── audio/i2s.c, buzzer.c, dummy.c, sdl2.c │ ├── libs/ cJSON · lodepng(PNG) · miniz(ZIP) · netplay │ └── targets/ ★ 하드웨어 정의 18종(각 폴더=config.h+env.py+sdkconfig) │ ├── odroid-go/ (공식) │ ├── mrgc-g32/ (공식) │ ├── nm-cyd-c5/ ★★ RockBase 추가(ESP32-C5 + CYD + 터치, PoC) │ └── … (esp32-s3-devkit, t-deck-plus, fri3d-2024, vmu 등 15종) │ ├── launcher/main/ ★ 런처 앱(메인 메뉴) │ ├── applications.c 롬 스캔(CRC 캐시)·앱 목록·게임 실행 트리거 │ ├── browser.c · bookmarks.c · updater.c(OTA 자체 업데이트) │ └── webui.c + webui.html.h ★ Wi-Fi 파일 매니저(브라우저로 SD 접근) │ ├── retro-core/ ★ 7개 에뮬레이터 번들 앱 │ ├── main/ main.c(디스패처) + main_{nes,gbc,pce,sms,gw,snes,lynx} │ └── components/ nofrendo · gnuboy · smsplus · pce-go · handy · gw-emulator · snes9x │ ├── prboom-go/ (DOOM) ├── gwenesis/ (Genesis) ├── fmsx/ (MSX) ← 독립 앱 ├── themes/ 런처 테마(이미지) └── tools/ mkfw.py(이미지 빌더) · gen_images.py · font_converter.py · patches/
용어
컴파일타임 타겟 분기 (config.h 디스패치)
retro-go는 18종의 서로 다른 보드를 지원하는데, 런타임에 "지금 보드가 뭐지?"를 따지지 않습니다. 대신 빌드할 때 RG_BUILD_TARGET=RG_TARGET_NM_CYD_C5 같은 매크로를 박으면, config.h가 그 보드의 targets/nm-cyd-c5/config.h(핀맵·해상도·오디오 설정)만 골라 include합니다. 결과적으로 그 보드용 코드만 바이너리에 들어가고 런타임 분기 비용은 0입니다 — 메모리가 귀한 MCU에서 흔히 쓰는 기법입니다.

가장 큰 파일이 말해 주는 것

rg_system.c(47KB)·rg_gui.c(84KB)가 가장 큰 건 "이 프로젝트의 진짜 핵심은 에뮬레이터가 아니라, 모든 앱이 공유하는 시스템 계층"이라는 뜻입니다. 부팅 조율·앱 전환·크래시 복구(rg_system)와 메뉴/위젯(rg_gui)이 무게중심이고, 에뮬레이터 코어들은 대부분 외부 프로젝트를 가져와 main 파일만 얹은 형태입니다. 즉 retro-go의 기여는 "에뮬레이터를 새로 짠 것"이 아니라 "여러 에뮬레이터를 작은 칩에서 굴리는 운영 계층을 만든 것"입니다.

6학습 포인트 (기술별 — 배울 것 + 실습 아이디어)

"한 레포 안에 임베디드 펌웨어·OTA·SPI 디스플레이·DMA·메모리 관리·에뮬레이터 내부가 전부 실물로 들어 있다."

① 멀티-앱 OTA 아키텍처

배울 것: 메모리가 부족한 MCU에서 큰 프로그램 여러 개를 운영하는 패턴 — 각 앱을 독립 펌웨어로 굽고 OTA 파티션 + 재부팅 점프로 전환, NVS로 상태(다음 게임)를 인계. ESP-IDF의 OTA가 원래는 무선 업데이트용 A/B 슬롯인데, 이를 앱 슬롯으로 전용하는 발상.

실습: ESP-IDF 예제로 "버튼을 누르면 otadata를 바꿔 다른 펌웨어로 재부팅"하는 최소 2-앱 프로젝트를 만들어, retro-go의 rg_system_switch_app을 축소 재현해 보기.

② 파티션 테이블 + 부트로더 + mkfw 패킹

배울 것: 플래시를 nvs/otadata/phy_init/app으로 나누는 파티션 테이블, 칩별 부트로더/테이블 오프셋(C5는 0x2000/0x8000), 그리고 esptool로 굽는 이미지 포맷. mkfw.pygen_esp32part.py 대신 직접 파티션을 배치하는 이유.

실습: 같은 펌웨어를 partitions.csv만 바꿔(앱 1개 vs 2개) 빌드해, otadata가 어떻게 부팅 대상을 고르는지 esptool.py read_flash로 들여다보기.

③ SPI 디스플레이 + DMA + 부분 갱신

배울 것: ST7789/ILI9341 같은 SPI TFT를 초기화·구동하는 법, DMA로 CPU를 안 거치고 픽셀을 보내는 법, 그리고 매 프레임 전체가 아닌 바뀐 줄만 보내는 체크섬 부분 갱신으로 대역폭을 아끼는 기법.

실습: ESP32+CYD로 ST7789에 컬러바를 띄운 뒤, "한 줄만 색을 바꾸고 그 줄만 다시 전송"하도록 만들어, 전체 전송 대비 프레임레이트가 얼마나 오르는지 측정.

④ SPI 버스 공유 (디스플레이·터치·SD)

배울 것: 핀이 부족할 때 여러 장치를 한 SPI 버스에 CS로 다중화하는 법, 그리고 floating CS가 SD 초기화를 깨뜨리는 실전 버그와 해법(부팅 시 모든 CS를 HIGH로). CYD류 보드를 다루면 반드시 만나는 함정.

실습: CYD에서 SD카드 마운트가 실패할 때, 터치 CS를 일부러 떠 있게 두면 재현되는지 확인하고, RG_CUSTOM_PLATFORM_INIT처럼 CS를 올려 고쳐 보기.

⑤ 메모리 관리 — PSRAM·버킷 할당자·CRC 캐시

배울 것: 내부 RAM과 PSRAM을 용도별로 나눠 쓰는 법(MEM_FAST 등), 수천 개 파일명을 strdup할 때 내부 RAM을 채우지 않으려는 버킷 할당자, 커버아트 매칭을 빠르게 하는 CRC 캐시 같은 임베디드 메모리 절약 패턴.

실습: 큰 SD카드의 롬 목록을 스캔하며 "각 파일명을 그냥 strdup vs 풀에 모아 할당"했을 때 내부 RAM 사용량 차이를 heap_caps_get_free_size로 비교.

⑥ 에뮬레이터 내부 — 여러 8/16비트 CPU

배울 것: 한 레포에서 6502(NES)·Z80(SMS)·65c816+SPC700(SNES)·H6280(PCE)·SM510(G&W) 구현체를 비교하며, CPU 에뮬레이션의 기본(명령 디코드·사이클 카운팅)과 카트리지 매퍼(뱅크 스위칭)를 플러그인 구조로 짜는 법(map000.c~map231.c).

실습: nofrendo의 매퍼 한 개(예 MMC1)를 읽고, "롬의 어느 부분을 어느 주소에 매핑하는가"를 그림으로 정리. 가장 단순한 NROM(map000)부터.

⑦ FreeRTOS 태스크 분리 + 더블버퍼

배울 것: 렌더·오디오·입력을 별도 태스크로 돌리고 큐로 잇는 법, 화면 깜빡임을 막는 더블버퍼(updates[2] + REDRAW 이벤트), 그리고 코어가 적은 칩(C5 싱글코어)에서 스케줄링 여유를 확보하는 고민.

실습: FreeRTOS로 "생산자(난수 생성) 태스크 + 소비자(화면 출력) 태스크"를 큐로 연결하고, 큐가 가득/비었을 때의 블로킹 동작을 관찰.

7하드웨어 / 시스템 요구사항

"클라우드·DB·GPU 전부 불필요. 대신 'PSRAM 달린 ESP32 보드'와 'microSD', 그리고 빌드용 ESP-IDF가 필수다."

항목요구사항 / 메모
CPU/보드ESP32 계열 + PSRAM 필수(최소 ~2MB). 200MHz 32비트. odroid-go·mrgc-g32(공식) 등 18종 타겟. NM-CYD-C5는 ESP32-C5 싱글코어 RISC-V 240MHz.
메모리NM-CYD-C5 기준 16MB Flash + 8MB PSRAM(QUAD 80MHz). PSRAM이 프레임버퍼·작업 RAM을 감당.
디스플레이SPI TFT(ST7789/ILI9341). NM-CYD-C5는 2.8인치 240×320 ST7789.
입력보드별 — ADC 아날로그 패드 / GPIO 버튼 / I2C 익스팬더 / XPT2046 저항막 터치(C5는 터치를 가상 패드로).
오디오내장 DAC / 외장 I2S DAC / PWM 부저 / dummy(무음). ESP32-C5는 내장 DAC가 없어 C5 PoC는 무음.
저장소microSD(FAT) — 롬·세이브·커버아트. 보드에 따라 SD가 디스플레이와 SPI 버스 공유.
빌드ESP-IDF 4.4~5.5. C5는 5.5+ 필수. ./rg_tool.py --target nm-cyd-c5 buildflash.
외부 서비스없음. 100% 로컬·오프라인. Wi-Fi는 선택(파일 매니저·NTP·넷플레이).
조작 / 운용 팁
"부트루프에 빠져도 런처로 돌아올 수 있다"

에뮬레이터가 크래시해 부팅이 반복되면, retro-go는 RTC 트레이스로 이를 감지해 런처(또는 factory)로 자동 복귀합니다. README는 또 전원을 켤 때 DOWN을 누른 채로 두면 강제로 런처로 부팅된다고 안내합니다 — 잘못 구운 게임에 갇히지 않도록.

함정 — C5 PoC를 시도하기 전에
싱글코어 + 무음 + 단일 터치라는 제약

NM-CYD-C5 타겟은 문서 스스로 PoC라 밝힙니다 — ① ESP-IDF 5.5+가 있어야 빌드되고(CI는 4.4라 C5를 안 굽습니다), ② 오디오가 dummy(무음), ③ 저항막 단일 터치라 십자키+버튼 동시 입력이 안 되어 액션 게임엔 부적합(런처 내비·가벼운 게임 테스트용), ④ 싱글코어라 듀얼코어 ESP32/S3보다 여유가 적어 무거운 코어(SNES·Genesis)는 버겁습니다. "런처 + retro-core(가벼운 코어)"부터 검증하는 게 정석입니다.

8직접 해볼 수 있는 실습 과제

"보드에 구워 보기 → SPI 화면 띄우기 → 2-앱 OTA 점프 → 터치 가상패드 → 새 타겟 포팅."

과제 1난이도 ★☆☆☆☆ · 입문

공식 타겟(odroid-go 등)에 구워서 게임 돌려 보기

ESP-IDF를 설치하고 ./rg_tool.py --target odroid-go buildflash로 펌웨어를 구운 뒤, microSD에 롬을 넣고 런처에서 NES/GB 게임을 실행. 게임을 고를 때 기기가 재부팅되는 순간을 직접 관찰(=OTA 점프).

목표: "에뮬레이터 전환 = 재부팅"이라는 핵심 설계를 눈으로 확인. 런처 → 게임 → (전원 깜빡) → 게임 화면의 흐름을 체감.

과제 2난이도 ★★☆☆☆ · 초급

ESP-IDF로 SPI TFT에 화면 직접 그려 보기

CYD/devkit + ST7789로 esp_lcd 드라이버를 써서 컬러바·텍스트를 띄운다. retro-go의 ili9341.h처럼 SPI 트랜잭션을 큐로 보내 보고, DMA on/off로 전송 속도를 비교.

목표: SPI 디스플레이의 초기화 시퀀스와 DMA 전송을 손에 익히기. 이게 모든 retro-go 화면 출력의 토대.

과제 3난이도 ★★★☆☆ · 중급

최소 '2-앱 OTA 점프' 펌웨어 만들기

앱 A(빨강 화면)와 앱 B(파랑 화면)를 각각 OTA 파티션에 굽고, 버튼을 누르면 esp_ota_set_boot_partition() + esp_restart()로 서로 전환. NVS에 "다음 색"을 적어 두는 것까지 하면 retro-go의 switch_app 축소판.

목표: partitions.csv·otadata·OTA API를 직접 다뤄 멀티-앱 구조를 완성. retro-go 전체의 뼈대를 손으로 재현.

과제 4난이도 ★★★★☆ · 중상

저항막 터치를 가상 게임패드로 매핑하기

XPT2046 터치 좌표를 읽어, 화면을 십자키·A·B 영역으로 나눈 표(RG_GAMEPAD_TOUCH_MAP 같은)로 버튼 입력을 만든다. 누른 영역을 화면에 하이라이트해 디버깅. 단일 터치의 한계(동시 입력 불가)를 직접 확인.

목표: "버튼 없는 보드를 게임기로" — RockBase 기여의 핵심을 재현. 영역 경계를 조정해 사용성 실험.

과제 5난이도 ★★★★★ · 고급

내 보드용 새 타겟 포팅하기

targets/에 새 폴더(config.h+env.py+sdkconfig)를 만들어, 손에 든 ESP32 보드(다른 핀맵/디스플레이)에 retro-go를 올린다. PORTING.md를 길잡이로 디스플레이·입력·오디오 드라이버를 연결.

목표: 컴파일타임 타겟 분기 구조를 실전에서 다뤄 보기. 최소 런처+retro-core가 뜨면 성공. 가능하면 upstream에 PR.

9관련 기술 심화 학습 로드맵 (6주 플랜)

"ESP-IDF 기초에서 출발해 파티션·OTA·SPI 디스플레이·메모리·에뮬레이터까지, retro-go를 길잡이 삼아."

주차주제핵심 학습 + retro-go 연결점
1주ESP-IDF / FreeRTOS 기초CMake 빌드·컴포넌트·태스크/큐·GPIO → rg_tool.py·base.cmake
2주파티션 테이블 + OTAnvs/otadata/app 구조, A/B 슬롯, esp_ota API → mkfw.py·rg_system.c
3주SPI 디스플레이 + DMAST7789/ILI9341 구동·DMA·부분 갱신 → rg_display.c·drivers/display/
4주입력·오디오·버스 공유ADC/GPIO/I2C/터치, I2S 오디오, SPI 버스 다중화 → rg_input.c·targets/nm-cyd-c5/
5주임베디드 메모리 관리PSRAM 배치·버킷 할당자·CRC 캐시·heap_caps → applications.c·rg_surface
6주에뮬레이터 내부 + 포팅CPU 에뮬레이션·매퍼·더블버퍼, 새 타겟 포팅 → retro-core/·PORTING.md
비유

이 로드맵은 "ESP32 임베디드 펌웨어 + 그래픽/오디오 드라이버 + 에뮬레이터 내부"를 한 줄로 꿴 저수준 하드웨어 코스입니다. 웹·앱 개발이 가려 주던 "플래시 파티션·SPI 버스·DMA·PSRAM·CPU 명령 에뮬레이션"의 실제를 retro-go가 읽을 만한 C 코드로 펼쳐 보여 주기 때문에, 임베디드·하드웨어를 진지하게 파려는 사람에게 드물게 좋은 교본입니다.

10핵심 키워드 사전

"이 문서와 저장소에서 반복되는 임베디드·ESP32·에뮬레이터 용어를 한곳에 모았다."

용어의미
ESP-IDFESP32용 공식 SDK(CMake + FreeRTOS + 드라이버 + 부트로더). retro-go의 빌드 토대
ESP32-C5신형 싱글코어 RISC-V ESP32(240MHz, Wi-Fi 6). 지원은 ESP-IDF 5.5+ 필요
CYD (Cheap Yellow Display)ESP32+TFT(ST7789/ILI9341)+터치를 합친 $5~10 올인원 보드의 통칭
PSRAM / SPIRAM외부 SPI 의사 SRAM. 내부 RAM(~520KB) 부족분 보완. retro-go 필수(C5는 8MB)
파티션 테이블플래시를 nvs/otadata/phy_init/app 영역으로 나눈 표. 오프셋 보통 0x8000
OTA (Over-The-Air)원래 무선 펌웨어 업데이트용 A/B 슬롯. retro-go는 이를 앱 슬롯으로 전용
otadata"다음에 부팅할 파티션"을 가리키는 작은 파티션. 앱 전환의 핵심
NVSESP-IDF의 키-값 영구 저장소. 설정·부트 네임스페이스(다음 게임) 저장
SPI 디스플레이SPI로 픽셀을 보내는 TFT. ILI9341/ST7789가 사실상 표준
DMACPU 개입 없이 메모리↔주변장치 전송. SPI 화면·I2S 오디오에 사용
부분 갱신 (partial update)매 프레임 전체가 아닌, 체크섬으로 바뀐 스캔라인만 전송해 대역폭 절약
XPT2046저항막 터치스크린 컨트롤러 IC(SPI). retro-go가 좌표를 가상 버튼으로 매핑
SPI 버스 공유화면·터치·SD카드가 한 SPI 버스를 CS로 다중화. floating CS가 SD를 깨뜨리는 함정 주의
I2S DAC디지털 오디오 출력 인터페이스/칩. ESP32 내장 DAC 또는 외장 PCM5102A 등
IRAM_ATTR / RTC_NOINIT_ATTR내부 명령RAM 배치(빠름·인터럽트 안전) / 재부팅 후에도 보존되는 RTC RAM(크래시 트레이스)
FreeRTOS 태스크작은 기기용 RTOS의 스레드. 렌더·오디오·입력을 우선순위로 동시 처리
매퍼 (mapper)카트리지의 뱅크 스위칭 회로. NES 매퍼 60+종을 map000~map231 .c로 구현
nofrendo / gnuboy / Snes9x / PrBoom / fMSX각각 NES/GB/SNES/DOOM/MSX 에뮬레이터의 원본 프로젝트명
rg_tool.py / mkfw.py멀티-앱 빌드 오케스트레이터 / 부트로더+파티션+앱을 한 이미지로 패킹하는 도구

11참고 링크