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 라이브 멘션)
"에뮬레이터 하나하나를 '독립된 작은 펌웨어'로 만들어 두고, 게임을 고르면 그 펌웨어로 갈아 끼워 재부팅한다."
보통 PC 에뮬레이터는 RAM이 수 GB라 모든 에뮬레이터를 한 프로그램에 담아도 됩니다. 그런데 ESP32는 내부 RAM이 약 520KB밖에 안 됩니다(외부 PSRAM을 붙여도 수 MB). NES·SNES·Genesis 에뮬레이터를 전부 한 바이너리에 넣으면 플래시도 RAM도 터집니다. retro-go의 해법은 "한 번에 하나만 돌린다"입니다 — 런처, NES 묶음, DOOM, Genesis, MSX를 각각 별도 펌웨어로 구워 플래시의 서로 다른 칸(OTA 파티션)에 넣고, 게임을 고르면 ESP-IDF의 OTA 메커니즘으로 "다음 부팅은 저 칸"이라고 적은 뒤 재부팅합니다. 켜질 때마다 통째로 다른 프로그램이 되는 셈이죠. 이 영리한 구조 덕에 8KB짜리 NVS에 "어떤 게임을 켤지"만 남겨 두면, 작은 칩이 마치 여러 게임기를 갈아 끼우듯 동작합니다.
좀 더 구체적으로, 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카드에 남기는 법.
"$10 만능 보드(CYD) + 신형 RISC-V 칩(ESP32-C5) + 버튼 없이 터치로 게임 — 따라 하기 쉬운 화제성의 3박자."
ESP32에서 에뮬레이터를 돌리려는 사람은 항상 같은 벽에 부딪힙니다 — 코드도 RAM도 부족하다는 것. SNES 에뮬레이터 하나만 해도 코드가 1MB에 육박하고, 에뮬레이션엔 프레임버퍼·작업 RAM이 또 필요합니다. 에뮬레이터를 여러 개 담으면 플래시·RAM이 모두 터집니다.
전부 한 바이너리에 링크하면 플래시 용량 초과로 빌드부터 실패하거나, 겨우 빌드해도 한 에뮬레이터의 전역 변수·버퍼가 다른 에뮬레이터의 RAM을 잠식해 불안정해집니다. ESP-IDF의 idf.py도 원래 "한 프로젝트 = 한 앱"만 다뤄, 여러 펌웨어를 한 이미지로 묶는 것 자체를 지원하지 않습니다.
retro-go는 각 에뮬레이터를 완전히 격리된 펌웨어로 빌드해 각자 OTA 파티션에 넣고, 게임을 고르면 esp_ota_set_boot_partition() + 재부팅으로 갈아탑니다. 한 번에 하나만 RAM에 올라오니 메모리 충돌이 원천 차단됩니다. idf.py가 멀티-앱을 못 하는 문제는 자체 빌드 도구 rg_tool.py가 앱들을 차례로 빌드하고 mkfw.py로 한 이미지에 패킹해 해결합니다. 게다가 7개의 가벼운 코어는 retro-core 하나로 묶어 — 따로 빌드하면 ~3MB일 것을 700KB로 줄입니다.
이 포크가 트렌딩에 오른 건 접근성 때문입니다. 세 가지가 겹쳤습니다.
| 화제 포인트 | 무엇 | 왜 끌리나 |
|---|---|---|
| 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-go | RetroArch(libretro) | 단일 ESP 에뮬레이터 |
|---|---|---|---|
| 타깃 | ESP32 계열 MCU | PC·폰·콘솔(고사양) | 특정 MCU 1종 |
| 여러 게임기 | 10여 종(앱 분할) | 수십 종(코어 동적 로드) | 대개 1종 |
| 멀티 코어 운용 | OTA 파티션 + 재부팅 | 런타임 .so 로드 | 해당 없음 |
| 하드웨어 추상화 | 18종 타겟 컴파일타임 분기 | 드라이버 계층 | 보드 고정 |
| 메모리 예산 | PSRAM 포함 수 MB | 수백 MB~GB | 수백 KB~MB |
정확히 하면, retro-go 본체는 ducalex(Alex Duchesne)의 작품이고 코드의 99%가 upstream 그대로입니다(공통 라이브러리 config.h의 프로젝트 URL도 여전히 ducalex를 가리킵니다). RockBase의 실질 기여는 NM-CYD-C5 타겟 하나이며, 문서 스스로 이를 "PoC(개념 검증)"로 표기합니다. 그 타겟은 오디오가 dummy(무음)이고, 터치가 단일 터치라 동시 입력(달리며 점프)이 안 되며, 싱글코어라 듀얼코어 ESP32보다 여유가 적어 "런처+가벼운 코어부터" 권장됩니다. 스타 수 등 수치는 이 문서 작성 시점에 확인되지 않았습니다.
"ESP-IDF + FreeRTOS 위에, C로 짠 '작은 OS'(공통 라이브러리)와 에뮬레이터 코어들, 그리고 Python 빌드 오케스트레이터."
retro-go는 외부 서비스·클라우드가 전혀 없는 순수 펌웨어입니다. 스택은 네 층으로 읽으면 깔끔합니다 — ① 토대(ESP-IDF/FreeRTOS), ② 공통 라이브러리(모든 앱이 공유하는 추상화), ③ 에뮬레이터 코어들, ④ 빌드·패킹 도구.
| 구성요소 | 역할 | 한 줄 설명 |
|---|---|---|
| ESP-IDF 4.4~5.5 | 공식 SDK | CMake 빌드 + 드라이버(SPI·I2S·GPIO) + 부트로더. ESP32-C5는 5.5+ 필수(C5 지원이 5.5부터). |
| FreeRTOS | 실시간 OS | 렌더·오디오·입력을 태스크/큐로 동시 처리. CONFIG_FREERTOS_HZ=100. |
| FAT (microSD) | 파일시스템 | 롬·세이브·커버아트를 SD카드에서 읽음(코드페이지 437 + UTF-8 긴 파일명). |
| NVS | 설정 저장소 | 키-값 영구 저장. "다음에 켤 게임"같은 부트 설정도 여기 남김. |
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.c | SD카드/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 |
retro-go는 코어를 두 형태로 배치합니다 — 가벼운 7개는 retro-core 하나로 번들(크기 절약), 덩치 큰 3개는 독립 앱.
| 코어 | 게임기 | 원본 / CPU |
|---|---|---|
| nofrendo | NES(패미컴) | nofrendo 포크 · 6502 · 매퍼 60+종을 개별 .c로 |
| gnuboy | Game Boy / Color | gnuboy · LR35902 |
| smsplus | SMS·Game Gear·Colecovision·SG-1000 | SMS Plus GX · Z80 + SN76489 |
| pce-go | PC Engine | HuExpress 포크 · H6280 |
| handy | Atari Lynx | libretro-handy · 유일한 C++ 코어 |
| gw-emulator | Game & Watch | bzhxx/lcd-game-emulator · SM510 계열 |
| snes9x | SNES(느림) | Snes9x 2005 · 65c816 + SPC700 |
| prboom (독립) | DOOM(모드 지원) | PrBoom 2.5.0 |
| gwenesis (독립) | Mega Drive/Genesis | bzhxx/gwenesis |
| fmsx (독립) | MSX/MSX2 | fMSX(비상업 라이선스) |
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용 파티션 크기 오버라이드
"핵심은 단 하나 — 앱 전환 = '부팅 파티션을 바꾸고 재부팅'. 그 위에 디스플레이 부분 갱신, 크래시 트레이스, SPI 버스 공유가 얹힌다."
retro-go에서 가장 중요한 아이디어입니다. 각 에뮬레이터는 플래시의 서로 다른 OTA 파티션에 들어간 독립 펌웨어입니다. 런처에서 게임을 고르면 — ① "다음 부팅은 이 게임, 이 롬 경로"를 NVS에 적고, ② 부팅 파티션을 그 에뮬레이터로 지정한 뒤, ③ 재부팅합니다. 켜지면 통째로 다른 프로그램이 됩니다.
// 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 배우가 되어 있죠. 한 번에 한 역만 하니 분장(메모리)이 섞이지 않습니다.
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로 240×320 화면을 매 프레임 통째로 보내면 대역폭이 모자랍니다. retro-go는 스캔라인마다 체크섬을 떠서, 직전 프레임과 같은 줄은 건너뛰고 바뀐 줄만 전송합니다(부분 갱신).
// 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; // 이 줄만 다시 전송 }
임베디드의 명장면입니다. 패닉(크래시)이 나면 보통 화면도 시리얼도 못 쓰는데, 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에 저장 + 런처 복귀
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},\
}
이 셋이 retro-go를 관통합니다 — 멀티-앱 OTA(메모리 충돌 0), 스캔라인 부분 갱신(SPI 대역폭 절약), RTC 패닉 트레이스(케이블 없는 디버깅). 거기에 컴파일타임 타겟 분기(18종 보드를 런타임 비용 0으로)와 SPI 버스 공유(핀 부족 극복)가 더해져, "손바닥만 한 칩이 여러 게임기를 갈아 끼우는" 그림이 완성됩니다.
"공통 라이브러리 1개 + 앱 여러 개 + 빌드 도구. 폴더 이름만 봐도 '추상화(components) / 화면(launcher) / 게임기(retro-core)'가 보인다."
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의 기여는 "에뮬레이터를 새로 짠 것"이 아니라 "여러 에뮬레이터를 작은 칩에서 굴리는 운영 계층을 만든 것"입니다.
"한 레포 안에 임베디드 펌웨어·OTA·SPI 디스플레이·DMA·메모리 관리·에뮬레이터 내부가 전부 실물로 들어 있다."
배울 것: 메모리가 부족한 MCU에서 큰 프로그램 여러 개를 운영하는 패턴 — 각 앱을 독립 펌웨어로 굽고 OTA 파티션 + 재부팅 점프로 전환, NVS로 상태(다음 게임)를 인계. ESP-IDF의 OTA가 원래는 무선 업데이트용 A/B 슬롯인데, 이를 앱 슬롯으로 전용하는 발상.
실습: ESP-IDF 예제로 "버튼을 누르면 otadata를 바꿔 다른 펌웨어로 재부팅"하는 최소 2-앱 프로젝트를 만들어, retro-go의 rg_system_switch_app을 축소 재현해 보기.
배울 것: 플래시를 nvs/otadata/phy_init/app으로 나누는 파티션 테이블, 칩별 부트로더/테이블 오프셋(C5는 0x2000/0x8000), 그리고 esptool로 굽는 이미지 포맷. mkfw.py가 gen_esp32part.py 대신 직접 파티션을 배치하는 이유.
실습: 같은 펌웨어를 partitions.csv만 바꿔(앱 1개 vs 2개) 빌드해, otadata가 어떻게 부팅 대상을 고르는지 esptool.py read_flash로 들여다보기.
배울 것: ST7789/ILI9341 같은 SPI TFT를 초기화·구동하는 법, DMA로 CPU를 안 거치고 픽셀을 보내는 법, 그리고 매 프레임 전체가 아닌 바뀐 줄만 보내는 체크섬 부분 갱신으로 대역폭을 아끼는 기법.
실습: ESP32+CYD로 ST7789에 컬러바를 띄운 뒤, "한 줄만 색을 바꾸고 그 줄만 다시 전송"하도록 만들어, 전체 전송 대비 프레임레이트가 얼마나 오르는지 측정.
배울 것: 핀이 부족할 때 여러 장치를 한 SPI 버스에 CS로 다중화하는 법, 그리고 floating CS가 SD 초기화를 깨뜨리는 실전 버그와 해법(부팅 시 모든 CS를 HIGH로). CYD류 보드를 다루면 반드시 만나는 함정.
실습: CYD에서 SD카드 마운트가 실패할 때, 터치 CS를 일부러 떠 있게 두면 재현되는지 확인하고, RG_CUSTOM_PLATFORM_INIT처럼 CS를 올려 고쳐 보기.
배울 것: 내부 RAM과 PSRAM을 용도별로 나눠 쓰는 법(MEM_FAST 등), 수천 개 파일명을 strdup할 때 내부 RAM을 채우지 않으려는 버킷 할당자, 커버아트 매칭을 빠르게 하는 CRC 캐시 같은 임베디드 메모리 절약 패턴.
실습: 큰 SD카드의 롬 목록을 스캔하며 "각 파일명을 그냥 strdup vs 풀에 모아 할당"했을 때 내부 RAM 사용량 차이를 heap_caps_get_free_size로 비교.
배울 것: 한 레포에서 6502(NES)·Z80(SMS)·65c816+SPC700(SNES)·H6280(PCE)·SM510(G&W) 구현체를 비교하며, CPU 에뮬레이션의 기본(명령 디코드·사이클 카운팅)과 카트리지 매퍼(뱅크 스위칭)를 플러그인 구조로 짜는 법(map000.c~map231.c).
실습: nofrendo의 매퍼 한 개(예 MMC1)를 읽고, "롬의 어느 부분을 어느 주소에 매핑하는가"를 그림으로 정리. 가장 단순한 NROM(map000)부터.
배울 것: 렌더·오디오·입력을 별도 태스크로 돌리고 큐로 잇는 법, 화면 깜빡임을 막는 더블버퍼(updates[2] + REDRAW 이벤트), 그리고 코어가 적은 칩(C5 싱글코어)에서 스케줄링 여유를 확보하는 고민.
실습: FreeRTOS로 "생산자(난수 생성) 태스크 + 소비자(화면 출력) 태스크"를 큐로 연결하고, 큐가 가득/비었을 때의 블로킹 동작을 관찰.
"클라우드·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 build → flash. |
| 외부 서비스 | 없음. 100% 로컬·오프라인. Wi-Fi는 선택(파일 매니저·NTP·넷플레이). |
에뮬레이터가 크래시해 부팅이 반복되면, retro-go는 RTC 트레이스로 이를 감지해 런처(또는 factory)로 자동 복귀합니다. README는 또 전원을 켤 때 DOWN을 누른 채로 두면 강제로 런처로 부팅된다고 안내합니다 — 잘못 구운 게임에 갇히지 않도록.
NM-CYD-C5 타겟은 문서 스스로 PoC라 밝힙니다 — ① ESP-IDF 5.5+가 있어야 빌드되고(CI는 4.4라 C5를 안 굽습니다), ② 오디오가 dummy(무음), ③ 저항막 단일 터치라 십자키+버튼 동시 입력이 안 되어 액션 게임엔 부적합(런처 내비·가벼운 게임 테스트용), ④ 싱글코어라 듀얼코어 ESP32/S3보다 여유가 적어 무거운 코어(SNES·Genesis)는 버겁습니다. "런처 + retro-core(가벼운 코어)"부터 검증하는 게 정석입니다.
"보드에 구워 보기 → SPI 화면 띄우기 → 2-앱 OTA 점프 → 터치 가상패드 → 새 타겟 포팅."
ESP-IDF를 설치하고 ./rg_tool.py --target odroid-go build → flash로 펌웨어를 구운 뒤, microSD에 롬을 넣고 런처에서 NES/GB 게임을 실행. 게임을 고를 때 기기가 재부팅되는 순간을 직접 관찰(=OTA 점프).
목표: "에뮬레이터 전환 = 재부팅"이라는 핵심 설계를 눈으로 확인. 런처 → 게임 → (전원 깜빡) → 게임 화면의 흐름을 체감.
CYD/devkit + ST7789로 esp_lcd 드라이버를 써서 컬러바·텍스트를 띄운다. retro-go의 ili9341.h처럼 SPI 트랜잭션을 큐로 보내 보고, DMA on/off로 전송 속도를 비교.
목표: SPI 디스플레이의 초기화 시퀀스와 DMA 전송을 손에 익히기. 이게 모든 retro-go 화면 출력의 토대.
앱 A(빨강 화면)와 앱 B(파랑 화면)를 각각 OTA 파티션에 굽고, 버튼을 누르면 esp_ota_set_boot_partition() + esp_restart()로 서로 전환. NVS에 "다음 색"을 적어 두는 것까지 하면 retro-go의 switch_app 축소판.
목표: partitions.csv·otadata·OTA API를 직접 다뤄 멀티-앱 구조를 완성. retro-go 전체의 뼈대를 손으로 재현.
XPT2046 터치 좌표를 읽어, 화면을 십자키·A·B 영역으로 나눈 표(RG_GAMEPAD_TOUCH_MAP 같은)로 버튼 입력을 만든다. 누른 영역을 화면에 하이라이트해 디버깅. 단일 터치의 한계(동시 입력 불가)를 직접 확인.
목표: "버튼 없는 보드를 게임기로" — RockBase 기여의 핵심을 재현. 영역 경계를 조정해 사용성 실험.
targets/에 새 폴더(config.h+env.py+sdkconfig)를 만들어, 손에 든 ESP32 보드(다른 핀맵/디스플레이)에 retro-go를 올린다. PORTING.md를 길잡이로 디스플레이·입력·오디오 드라이버를 연결.
목표: 컴파일타임 타겟 분기 구조를 실전에서 다뤄 보기. 최소 런처+retro-core가 뜨면 성공. 가능하면 upstream에 PR.
"ESP-IDF 기초에서 출발해 파티션·OTA·SPI 디스플레이·메모리·에뮬레이터까지, retro-go를 길잡이 삼아."
| 주차 | 주제 | 핵심 학습 + retro-go 연결점 |
|---|---|---|
| 1주 | ESP-IDF / FreeRTOS 기초 | CMake 빌드·컴포넌트·태스크/큐·GPIO → rg_tool.py·base.cmake |
| 2주 | 파티션 테이블 + OTA | nvs/otadata/app 구조, A/B 슬롯, esp_ota API → mkfw.py·rg_system.c |
| 3주 | SPI 디스플레이 + DMA | ST7789/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 코드로 펼쳐 보여 주기 때문에, 임베디드·하드웨어를 진지하게 파려는 사람에게 드물게 좋은 교본입니다.
"이 문서와 저장소에서 반복되는 임베디드·ESP32·에뮬레이터 용어를 한곳에 모았다."
| 용어 | 의미 |
|---|---|
| ESP-IDF | ESP32용 공식 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 | "다음에 부팅할 파티션"을 가리키는 작은 파티션. 앱 전환의 핵심 |
| NVS | ESP-IDF의 키-값 영구 저장소. 설정·부트 네임스페이스(다음 게임) 저장 |
| SPI 디스플레이 | SPI로 픽셀을 보내는 TFT. ILI9341/ST7789가 사실상 표준 |
| DMA | CPU 개입 없이 메모리↔주변장치 전송. 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 | 멀티-앱 빌드 오케스트레이터 / 부트로더+파티션+앱을 한 이미지로 패킹하는 도구 |