2025년 9월 9일 화요일

메모리 한계에 부딪친 Nano Ardule 컨트롤러

아두이노 나노. 직접 촬영하였음.

아두이노 나노를 이용한 MIDI 컨트롤러 'Nano Ardule'을 만들다가 매우 중대한 난관에 봉착하였다. 하드웨어 제작을 마친 뒤, 지금까지 많은 공을 들여서 키보드 입력을 두 채널로 복제하여 서로 다른 음색이 동시에 나오도록 레이어링하거나 키보드 영역을 위와 아래로 나누는 스플리팅하기, 그리고 음색 편집 등의 기본 기능이 가능하도록 프로그래밍을 해 왔다. 여기에다가 마이크로SD카드에 담긴 MIDI 파일을 재생하는 기능을 추가하려고 하니 드디어 메모리가 부족하여 로드를 하지 못하는 단계에 이르렀다. 에러 없이 컴파일은 되는데 저장장치에 업로드를 하다가 중단되는 모습을 보니 허탈함을 금할 수 없다.

아두이노 나노(ATmega328P)는 작은 마이크로컨트롤러라서 다음과 같은 3종류의 주요 메모리를 갖는다. SRAM은 휘발성이라 전원이 꺼지면 내용이 사라진다.

  • 플래시(32 KB): 코드와 상수 저장, PROGMEM 활용.
  • SRAM(2 KB): 런타임 변수/스택, 제일 부족하므로 절약 최우선.
  • EEPROM(1 KB): 전원 꺼져도 남는 데이터 저장.

실시간 데이터는 SRAM, 큰 테이블이나 문자열은 PROGMEM(플래시), 사용자 설정은 EEPROM에 두는 것이 바람직하다. 아직 이러한 원칙에 입각하여 코드를 모두 최적화하지는 않았다. 얄궂게도 EEPROM는 수명이 있어서 데이터를 약 10만회까지 쓸 수 있다. 읽는 것은 상관이 없다.데이터를 쓰는 회수에는 약 10만회라는 제한이 있다. 여기에는 사용자 설정이나 영구 데이터를 넣으면 적당하다.

아두이노 IDE에서 업로드한 코드는 플래시에 저장된다. const 변수나 문자열 리터럴도 기본은 플래시에 저장되지만 실행 시 SRAM으로 복사되는 경우가 많다. 2KB에 불과한 협소한 SRAM을 작업대로 여기고 많은 일거리를 펼쳐 놓으려니 아두이노 나노가 한계에 이르는 것은 당연하다. 그래서 PROGMEM이나 F() 매크로를 써서 복사하지 않고 플래시에서 직접 읽게 하면 SRAM을 절약할 수 있다. 마이크로SD카드에 담긴 모든 파일은 PC에서 미리 인덱스 파일을 만들어서 한 줄씩을 읽으면서 실제 파일에 접근하게 하는 방법도 고려해야 한다. MIDI 파일은 미리 type 0으로 바꾸어 놓아야 한다.

아두이노 나노 에브리(ATmega4890)로 업그레이드하면 코드를 거의 바꾸지 않고 원활한 작업이 가능할 것이다. 플래시는 48KB, SRAM은 6KB, EEPROM은 약간 적어서 256B(플래시에서 에뮬레이션)이다.

아두이노 나노 에브리. 사진 출처: 쿠팡


2만원 조금 넘는 비용을 들여서 아두이노 나노 에브리를 살 것인가? 또는 최적화 기법을 총동원하여 아두이노 나노에 모든 기능을 욱여넣을 것인가? 9월 한달 동안은 아두이노 나노를 최대한 쥐어짜서 하는 데까지 해 보려고 한다. 다음은 챗GPT가 제안한 최적화 방안이다.


아두이노 나노(ATmega328P)로 프로그램/콤비 저장MIDI 파일 재생, 기본 UI를 동시에 구현하기 위한 최적화 방법을 우선순위대로 정리했습니다.

1) SRAM(2 KB) 먼저 살리기 — 최우선

  • 모든 상수 문자열은 플래시에서 직접 출력: F() 매크로 사용
    lcd.print(F("Init SD..."));
  • 테이블/이름 전부 PROGMEM (GM/GS/드럼 이름, UI 라벨)
    const char sPlay[] PROGMEM = "Play";
    char buf[17];
    strcpy_P(buf, sPlay);
    lcd.print(buf);
  • String 금지char[] 고정 버퍼(예: 32~64B) 공용 사용
  • 큰 로컬 배열 금지 (스택 폭발 방지) → 전역의 작은 공용 버퍼 재사용
  • 시리얼 버퍼 축소/끄기, 디버그는 필요한 구간만
  • SD 브라우저 최소화
    • 8.3 파일명 only (LFN 비활성)
    • /SONGS/INDEX.TXT, /DRUMS/INDEX.TXT 한 줄 한 파일 순차 읽기
    • 현재 선택 항목만 char line[32]로 보관

2) 코드/플래시(32 KB) 절약

  • 기능 스위치: #define FEATURE_BROWSER 0/1, #define FEATURE_EDIT 0/1
  • 중복 문자열 제거 (공통 접두/접미어 조합), inline 남발 금지
  • 가벼운 LCD 라이브러리/필요 함수만 사용, 미사용 라이브러리 제거
  • 컴파일 옵션: LTO, -Os(기본) 유지

3) MIDI 재생부 설계(작고 단단하게)

  • Type-0 전용 스트리밍 (PC에서 Type-1 → 0 변환)
  • 템포(Meta 0x51)만 처리, 나머지 Meta는 스킵
  • Running Status 유지, 이벤트 즉시 전송 (대형 큐 금지)
  • 버퍼 1개(≤512B)read → parse → send 파이프라인

4) 파일시스템 선택

  • SdFat (설정 최소화, LFN off)로 충분하면 그대로
  • 더 빡빡하면 Petit FatFs (읽기 전용, 초저메모리) 검토

5) EEPROM(1 KB) 사용 요령

  • 콤비/설정은 고정 구조체(≈32B)로 저장 → 20~30개 가능
  • 저장은 버튼으로만 (수시 자동 저장 금지)
  • 간단 wear-leveling: 다음 슬롯에 순환 저장 + CRC로 무결성 체크

6) 빌드 프로파일 두 가지 운용(권장)

  • A안: 편집/브라우저 중심 (재생 최소)
  • B안: 재생 중심 (브라우저 축소, INDEX.TXT 의존)
  • → 스위치 1–2개로 코드 크기·RAM 밸런스 즉시 전환
* 여기서 말하는 '스위치'란 컴파일 타임 스위치(전처리 매크로 #define)를 말합니다. 즉, 빌드할 때 켜고 끄는 플래그로 코드/데이터(특히 PROGMEM 테이블, 브라우저 로직, 플레이어 로직)를 아예 포함하거나 제외해서 플래시·SRAM 점유를 즉시 바꾸는 방식이에요.

7) 진단/관찰 루틴(문제 예방)

  • 부팅 시 freeRAM() (남은 SRAM 추정) 한 번 출력
  • SD 읽기/파싱 오류는 짧은 메시지로만 표시
  • LCD 갱신 주기 완만하게 (예: 150–200 ms) → MIDI 막힘 예방

8) “정 안 되면” 최후의 수단

  • UI 라벨 더 줄이기, GM/GS 풀네임 대신 축약명
  • 드럼 브라우저는 숫자+간단명
  • 그래도 모자라면 Nano Every(6 KB SRAM) 고려
    (단, Every는 EEPROM 256B → 콤비는 SD 저장 권장)

바로 적용 체크리스트

  • [ ] F()/PROGMEM 전면 적용, String 전면 금지
  • [ ] 공용 워크버퍼 1개(64–128B)로 파서/표시 공유
  • [ ] 8.3 이름 + INDEX.TXT 순차 브라우즈
  • [ ] Type-0 스트리밍 + 템포만 처리
  • [ ] EEPROM: 버튼 저장 + CRC + 간단 wear-leveling
  • [ ] 기능 스위치로 “편집↔재생” 빌드 분리

참고 코드 조각

(1) PROGMEM 문자열 출력

const char sLoading[] PROGMEM = "Loading";
char line[17];

void printP(const char* p) {
  strcpy_P(line, p);
  lcd.print(line);
}

(2) 간단한 Combi 저장(EEPROM, CRC 포함 예시 골격)

#include <EEPROM.h>

struct Combi {
  uint8_t ver, mode;
  uint8_t chA, chB, progA, progB;
  uint8_t bankA_msb, bankA_lsb, bankB_msb, bankB_lsb;
  uint8_t volA, volB, panA, panB;
  uint8_t splitPoint, octaveA, octaveB;
  uint16_t crc;
};

uint16_t crc16(const uint8_t* d, size_t n); // 구현 필요

bool loadCombi(uint8_t idx, Combi& c) {
  int addr = idx * sizeof(Combi);
  EEPROM.get(addr, c);
  uint16_t got = c.crc;
  c.crc = 0;
  return (crc16((uint8_t*)&c, sizeof(Combi)) == got);
}

bool saveCombi(uint8_t idx, Combi& c) {
  c.ver = 1; c.crc = 0;
  c.crc = crc16((uint8_t*)&c, sizeof(Combi));
  int addr = idx * sizeof(Combi);
  EEPROM.put(addr, c);
  return true;
}

(3) INDEX.TXT 한 줄씩 읽기(개념)

// 사용 중인 FS 라이브러리에 맞게 File 타입/호출 교체
bool readIndexLine(File& f, char* out, size_t n) {
  size_t i = 0; int ch;
  while (i < n - 1 && (ch = f.read()) >= 0) {
    if (ch == '\n' || ch == '\r') { if (i == 0) continue; break; }
    out[i++] = (char)ch;
  }
  out[i] = 0;
  return (i > 0);
}

댓글 없음: