아두이노 메모리 상세 분석
아두이노와 센서를 능숙하게 다루고 각종 통신 모듈과 디스플레이 모듈을 활용하기 시작하는 중급 유저라면 메모리 문제를 한번쯤 겪기 마련입니다. 불과 2KB의 SRAM을 가진 아두이노에서는 메모리 문제에서 자유로울 수가 없는데 반해 이를 디버깅할 수 있는 방법이 아두이노에서는 마땅히 없습니다. (PC 혹은 다른 기기, 플랫폼에서도 메모리 문제가 골치거리인 점은 같지만…)
유일한 디버깅 방법인 Serial 함수를 이용한 디버그 메시지 출력도 Serial 통신을 활성화 하는 과정에서 통신용 버퍼를 생성하기 때문에 메모리를 잡아먹어 버립니다. 따라서 아두이노 메모리 문제는 메모리에 대해 충분히 이해하고 미리 조심하는 것이 최선이라 하겠습니다.
그래서 아두이노 메모리 관련된 해외 자료들을 수집해서 정리해 봤습니다. 아래 링크가 원문입니다. 원문의 주요 내용을 정리했지만 제가 첨삭을 했습니다.
.
아두이노 메모리 분석
아두이노 스케치 사이즈가 너무 커지면 업로드 할 때 아래와 같은 에러 메시지가 발생하는 경우가 있습니다.
하지만 이런 에러는 업로드 할 때 발생하므로 오히려 다행인 경우라고 할까요. 많은 경우는 스케치가 문제없이 업로드되지만 동작할 때 이상한 현상을 보인다던지 죽어버리게 됩니다. 그리고 이런 문제가 발생했을 때는 어디가 문제인지 알기 힘든 경우가 많습니다.
먼저 메모리 문제는 어떻게 확인할 수 있는지 부터 보겠습니다. 스케치가 문제없이 업로드 되더라도 아래와 같은 작업을 한 뒤 문제가 발생한다면 메모리 문제일 가능성이 큽니다.
- 하나 이상의 라이브러리를 include 한 뒤
- 몇 개의 LED pixel을 추가한 뒤 (LED 디스플레이의 각 픽셀을 말함)
- SD 카드에서 파일을 연 뒤
- 디스플레이를 초기화 한 뒤
- 다른 스케치와 합친 뒤
- 새로운 함수를 추가한 뒤
이런 메모리 문제를 해결하기 위해서는 아두이노의 메모리 동작 방식부터 이해해야 합니다.
.
Harvard vs Princeton
컴퓨터 발전의 초기 시절에 두 개의 상반된 프로세서/메모리 아키텍처가 발표됩니다.
유명한 ENIAC을 위해 만들어진 폰 노이만(Von Neumann 또는 Princeton) 아키텍처는 (컴파일 된) 프로그램과 (프로그램 실행중 사용되는) 데이터 저장 공간을 위해 같은 메모리 공간을 사용합니다.
반면에 Harvard Mark 1에 사용된 하버드 아키텍처는 프로그램과 데이터 저장 공간을 위해 물리적으로 분리된 메모리를 사용합니다.
이 두 개의 상반된 아키텍처는 서로 장단점이 있습니다. 하버드 아키텍처가 성능에서 뛰어난 반면 폰 노이만 아키텍처는 더 유연합니다.
근래에 사용되는 일반적인 컴퓨터(PC, 맥 등등)는 두 아키텍처의 장점을 결합한 하이브리드 디자인을 채용하고 있습니다. PC의 CPU 내부를 깊이 파고들면 instruction, data를 위한 별도의 캐시(cache)를 운용하므로 하버드 모델에 기반함을 알 수 있습니다. 하지만 instruction, data 캐시는 자동으로 공통 메모리 공간에 올라갑니다. 프로그래밍 관점에서는 수 기가 바이트의 가상공간(virtual storage)을 가진 순수한 폰 노이만 장치처럼 보이게 됩니다.
.
마이크로 컨트롤러 (Microcontrollers)
아두이노를 동작시키는 것과 같은 마이크로 컨트롤러는 임베디드 시스템을 위해 설계되었습니다. 일반 목적의 컴퓨터와는 달리 임베디드 프로세서는 최소의 비용으로 효율적이고 안정적으로 동작하도록 요구됩니다. 따라서 multi-layer caching, disk-based virtual memory systems 과 같은 사치스러움을 배제하고 굉장히 빡빡하게 디자인됩니다. 이런 요구사항에는 하버드 아키텍처가 잘 어울립니다. 아두이노 UNO, Nano, Mini의 핵심 칩인 Atmega328 도 (상대적으로) 순수한 하버드 아키텍처를 사용합니다. 따라서 작성한 프로그램(스케치)은 플래시 메모리(32KB, 프로그램 메모리라고도 부름)에 저장되고 데이터는 SRAM(2KB)에 저장됩니다.
이런 작업들은 컴파일러와 run-time system에 의해 관리됩니다. 그런데 메모리의 여유가 거의 없는 상황이 되면 이런 부분들을 인위적으로 어떻게 관리할지 전략이 필요합니다. 특히나 아두이노 같은 작은 장치에서는 메모리가 꽉 차는 상황이 쉽게 오므로 한정된 자원을 아껴써야 합니다.
이제 아두이노가 가진 각 메모리의 특징들 부터 살펴보겠습니다.
.
아두이노 메모리 – 플래시 메모리(Flash Memory)
플래시 메모리는 프로그램 이미지와 초기화된 데이터가 저장되는 곳입니다. 아두이노가 실행되면 플래시 메모리에서 프로그램 코드가 실행되지만 실행중인 코드에서 플래시 메모리를 바꿀 수는 없습니다. 플래시에 저장된 데이터를 바꾸기 위해서는 먼저 SRAM으로 복사를 해야 합니다.
플래시 메모리는 SD 카드와 같은 기술입니다. 비휘발성(non-volatile)이므로 전원이 차단되도 지워지지 않습니다. 플래시 메모리는 1000,000 write cycle을 가지므로 영구적인 저장공간은 아닙니다. 하루에 10번 프로그램을 업로드 한다면 약 27년 후 한계에 다다릅니다.
.
아두이노 메모리 – 램(SRAM, Static Random Access Memory)
램은 실행중인 프로그램에서 읽고 쓸수 있는 메모리입니다. 아래와 같이 데이터 종류에 따라 구분되어 사용됩니다.
- Static data – 전역 변수(global variable), static 변수용으로 예약된 공간입니다. 아두이노 런타임 시스템은 프로그램이 시작할 때 플래시 메모리에서 이 값을 읽어 램으로 복사를 합니다.
- Heap – 힙은 동작으로 할당되는 데이터들을 위한 영역입니다. 힙은 데이터가 할당될 때 Static data 위에 차례로 저장하고 지웁니다.
- Stack – 스택은 지역 변수(local variable)를 위한 공간입니다. 예를 들어 함수(또는 인터럽트)가 호출되어 함수 내부 코드가 실행될 때, 함수 내부에서 선언된 변수가 있다면 스택에 저장됩니다. 스택은 (그림과 같이) 메모리 최 상위부터 힙 영역 방향으로 저장됩니다. 함수, 인터럽트 실행 또는 지역 변수 선언은 스택을 증가시킵니다. 그리고 함수, 인터럽트 실행이 끝나면 증가했던 메모리 만큼 다시 반환됩니다.
- Free memory – 위 3개의 영역으로 사용되지 않는 여유 공간.
대부분의 메모리 문제는 여유 공간이 부족해 힙과 스택이 충돌할 때 발생합니다. 그리고 이때 어떤 일이 발생할지는 예측하기 힘듭니다.
어떤 경우에는 문제점이 한참 뒤에 나타나기도 합니다.
.
EEPROM
EEPROM(1KB)은 프로그램에서 읽고 쓸 수 있는 비휘발성 메모리입니다. 마치 하드디스크처럼 사용이 가능합니다. EEPROM은 byte 단위로 읽고 쓸 수 있으며 SRAM보다 느린 특징이 있습니다. SRAM과 별도의 영역이므로 경우에 따라 유용하게 사용될 수 있습니다. 플래시 메모리와 마찬가지로 읽기-쓰기 100,000 회 정도가 가능합니다. 메모리 문제에서의 중요성은 좀 떨어집니다.
.
아두이노 메모리 비교
크게 3가지 (플래시, 램, EEPROM) 메모리로 구분하지만 아두이노 종류별로 메모리 크기는 틀립니다. 아래 표를 참고하세요.
.
메모리 사용량 측정 방법
메모리 문제를 해결하기 위한 첫 걸음은 메모리 사용량을 측정하는 것입니다.
.
플래시 메모리
플래시 메모리 사용량은 업로드 할 때 결정됩니다. 업로드 할 때 아두이노 개발환경의 메시지 창(하단 검은색 영역)을 보시면 컴파일 된 바이너리 사이즈가 나옵니다. 사이즈가 너무 클 경우 에러 메시지도 여기에 표시됩니다.
.
SRAM
SRAM은 동적으로 계속 변화하는 메모리이기 때문에 측정하기도 힘듭니다. 아래와 같이 free_ram() 함수를 작성해서 프로그램 곳곳에서 체크해야 합니다.
int freeRam () { extern int __heap_start, *__brkval; int v; return (int) &v - (__brkval == 0 ? (int) &__heap_start : (int) __brkval); }
실제 free_ram() 함수가 알려주는 것은 힙과 스택 사이의 여유 공간입니다.
힙 공간은 데이터로 꽉 찬게 아니라 파편화되어 채워지기 때문에 사용되지 않는 여유공간이 있을 수 있습니다. 하지만 이 공간은 프로그래머가 직접 사용할 수 없기 때문에 고려하지 않아도 좋습니다. free_ram()으로 체크되는 여유 공간을 체크해야 합니다.
.
EEPROM
EEPROM은 100% 프로그래머가 제어가 가능하므로 어디어 어떻게 쓰고, 언제 어디서 어떻게 읽을지 스스로 계획해야 합니다. 따라서 별도로 사용량 측정이 필요한게 아니라 프로그래머가 알아서 관리해야 합니다.
// ************************************************ // Write floating point values to EEPROM // ************************************************ void EEPROM_writeDouble(int address, double value) { byte* p = (byte*)(void*)&value; for (int i = 0; i < sizeof(value); i++) { EEPROM.write(address++, *p++); } } // ************************************************ // Read floating point values from EEPROM // ************************************************ double EEPROM_readDouble(int address) { double value = 0.0; byte* p = (byte*)(void*)&value; for (int i = 0; i < sizeof(value); i++) { *p++ = EEPROM.read(address++); } return value; }
.
메모리를 잡아먹는 괴물들
이제 메모리를 소비하는 주요 원인들을 살펴보겠습니다. 특정 장치나 드라이버, 라이브러리는 동작을 위해 많은 양의 메모리를 잡아 먹습니다. 아래 장치들은 메모리를 잡아먹는 괴물이라 불릴만 하므로 사용시 주의해야 합니다.
.
SD 카드
SD, Micro SD 카드 모듈은 입출력을 위해 512byte의 SRAM 버퍼가 필요합니다.
Pixel
RGB LED Strip 같은 장치의 경우 색 표현을 위해 각 픽셀(LED) 당 3 byte의 메모리가 필요합니다. 당연히 픽셀 수가 늘어날수록 메모리 요구량도 증가합니다. UNO 보드의 경우 SRAM 크기를 고려할 때 500개 정도의 픽셀을 제어할 수 있습니다. 각종 통신 모듈 등 다른 모듈이 추가될 경우 이 수는 줄어듭니다.
RGB Matrix Displays
RGB Matrix도 마찬가지의 이유로 RAM을 많이 잡아 먹습니다. 32×32 모델의 경우 1600 byte의 SRAM을 소모합니다. 이런 경우 아예 아두이노 메가 같은 (상대적으로)고성능 보드를 사용하는 것이 좋습니다.
Monochrome OLED Displays
128×64 해상도의 OLED 디스플레이는 자주 사용되는 모델 중 하나입니다. 단색 on/off 표현만 가능함에도 픽셀 수가 1024개 이므로 1KB의 램을 소비합니다. 같은 해상도의 컬러 OLED 모듈도 판매되고 있는데 우회적인 방법이 아니면 아두이노 UNO 보드에서 쓸 수 없습니다.
ST7565 LCD Displays
동작하는 방식이 틀릴 뿐 메모리 소비는 단색 OLED와 같습니다. 128×64 해상도의 경우 1KB의 램을 소비합니다.
e-Ink Displays
단색 e-Ink 디스플레이의 경우 8픽셀당 1byte가 필요한 점은 같습니다. 다만 디스플레이가 고해상도인 경우가 많아서 램 요구량도 높은 편입니다. 2.0” 버전의 경우 3KB의 램이 필요합니다.
보시면 대부분 디스플레이 모듈의 램 요구량이 높은 걸 알 수 있습니다. 이런 문제점을 해결하기 위해 더 적은 램으로도 디스플레이를 컨트롤 할 수 있는 u8glib 가 개발되어 있습니다. 디스플레이 외에도 많은 양의 텍스트 데이터를 필요로 하는 WiFi, Ethernet 모듈도 주의해야 합니다. (보통 300~500 byte 정도의 버퍼를 사용합니다. 사용자가 크기를 변경할 수는 있습니다.)
Serial, SoftwareSerial, I2C, SPI 와 같은 통신 인터페이스를 사용할 때도 통신용 버퍼가 램을 잡아먹습니다. (통신 라이브러리에 따라 사용량은 다름)
앞서 소개한 모듈들과 라이브러리를 사용할 때는 메모리 문제에 특히 주의해야 합니다. 그리고 메모리 문제가 발견되면 문제가 되는 상황별로 대처가 필요합니다.
.
플래시 메모리 문제 해결법
프로그램 바이너리 사이즈가 넘치는 경우 플래시 메모리를 확보해야 합니다.
.
Dead Code 없애기
온라인으로 공유된 코드들을 붙여 새로운 코드를 만든 경우 쓸모 없는 코드들이 있을 수 있습니다. 이것들을 제거해주는 것이 첫 걸음 입니다.
- 사용되지 않는 라이브러리 : #include 로 포함된 라이브러리들, 주석으로 제거해서 컴파일 해보고 문제 없으면 삭제해도 OK
- 사용되지 않는 함수 : 한 번도 호출되지 않는 함수들
- 사용되지 않는 변수 : 값은 종종 업데이트 되지만 실제 사용되지는 않는 변수들
- 실행되지 않는 코드 : 나중에 쓰일 것 같아 만들어둔 코드들, 에러를 대비해 디버깅 용도로 만들어 둔 코드들.
- 반복되는 코드 : 같은 코드가 소스코드 여러 곳에서 반복되는 경우 함수로 만들어서 호출하도록 처리
.
부트로더 제거
플래시 메모리 영역은 부트로더 코드 저장을 위해서도 사용됩니다.
그럴 일은 잘 없지만 플래시 메모리가 부족한 경우 부트로더 제거할 수도 있습니다.
제거할 경우 사용되는 부트로더 종류에 따라 2K~4K 정도의 플래시 영역을 확보할 수 있습니다.
부트로더를 제거하면 이후부터는 업로드 할 때마다 ISP programmer를 이용해 업로드 해야합니다.
.
램 문제 해결법
대부분의 메모리 문제는 램 문제입니다만 문제 분석하기도 제일 어려운 것이 램 문제입니다. 아래 방법들로 최대한 램 용량을 확보해야 합니다.
.
사용하지 않는 변수 제거
불필요한 변수는 최대한 제거합니다.
.
F() 매크로 사용
문자열은 메모리의 골치거리 중 하나입니다. 문자열은 플래시 메모리에 프로그램 이미지에 포함되어 탑재된 뒤, 다시 static 변수로 SRAM을 잡아습니다. 이에 Paul Stoffregen of PJRC and Teensyduino 이 만든 간단하고 훌륭한 솔루션이 F() 매크로입니다. 이 매크로는 문자열이 SRAM에 복사되지 않도록 해주며 아두이노가 실행되면서 문자열을 사용할 때 플래시 메모리에서 직접 읽어 사용하도록 해줍니다. 실제로는 PROGMEM 지시어를 사용해서 선언된 변수를 사용하는 것과 같은 원리입니다. 아래 예제처럼 문자열을 F() 로 감싸서 사용하면 됩니다.
Serial.println("Sram sram sram sram. Lovely sram! Wonderful sram! Sram sra-a-a-a-a-am sram sra-a-a-a-a-am sram. Lovely sram! Lovely sram! Lovely sram! Lovely sram! Lovely sram! Sram sram sram sram!"); Serial.println(F("Sram sram sram sram. Lovely sram! Wonderful sram! Sram sra-a-a-a-a-am sram sra-a-a-a-a-am sram. Lovely sram! Lovely sram! Lovely sram! Lovely sram! Lovely sram! Sram sram sram sram!"));
이렇게 사용하면 SRAM 180 byte를 아낄 수 있습니다. Ethernet, Wifi 모듈에서는 웹 페이지를 만들거나 HTTP Request/Response를 만들기 위해서는 이처럼 긴 문자열이 필요함을 떠올리시면 왜 이게 유용한지 감이 오실겁니다.
.
Reserve() 사용
아두이노의 String 라이브러리를 사용하는 경우 문자열이 동적으로 커지면서 힙(heap)을 파편화시키게 됩니다. 이때 reserve(num) 함수를 사용하면 동적으로 증가하는 문자열을 위한 버퍼를 할당해서 힙 파편화를 줄여줍니다. 문자열을 처리하면서 길이가 증가하고, 잠깐 사용하는 문자열이 아닌 경우는 단지 reserve() 함수만 호출해주면 됩니다. 이런 작업은 C string 을 이용해서 더 효율적으로 할 수 있지만, 간단한 가이드라인만 지킴으로써 효율성에서 큰 차이없이 편리한 String object를 사용할 수 있습니다.
.
읽기 전용 데이터에 PROGMEM 사용
앞선 F() 설명하면서 언급된 PROGMEM 지시어는 플래시 메모리 영역만 사용하고 RAM을 소모하지 않도록 해줍니다. 대신 읽기 전용(read only) 데이터만 사용이 가능합니다. 읽기 전용 데이터의 경우 가급적 PROGMEM 지시어를 사용하는 것이 램을 아끼는 지름길입니다. 상세 내용은 아래 링크를 참고하세요.
https://www.hardcopyworld.com/?p=1189
.
버퍼 사이즈 축소
- 버퍼 할당 : 버퍼, 배열 등을 사용한다면 필요한 만큼만 할당
- 라이브러리의 버퍼 : 버퍼 사이즈를 조절할 수 있도록 함수를 제공하는 라이브러리들이 있습니다.
- 시스템 버퍼 : 아두이노의 가장 기본인 Serial 통신에 사용되는 버퍼들도 있습니다.
아두이노에서 기본으로 사용되는 Serial 라이브러리는 Serial receive buffer 용도로 64 byte 를 할당합니다. 빠른 속도로 많은 양의 데이터가 오가지 않는다면 이걸 반 이하로 줄일 수도 있습니다. 아두이노 설치폴더의 HardwareSerial.cpp 파일을 열어서
….\Arduino-1.x.x\hardware\arduino\cores\arduino\HardwareSerial.cpp
아래 부분을 적당히 수정해주면 됩니다.
#define SERIAL_BUFFER_SIZE 64
.
불필요하게 크게 잡은 변수 수정
불필요하게 크게 잡은 변수도 수정하세요. (double->float, long->int 등등)
.
Global & Static Variables
Global, Static 변수로 선언된 경우 아두이노가 동작할 동안은 일정량의 메모리를 계속 차지하고 있습니다. Global, Static 변수를 남발하지 마세요.
.
동적 할당 (Dynamic Allocations)
Object, 데이터가 동적으로 할당되는 경우는 힙 사이즈가 스택 방향으로 증가합니다. 이렇게 할당된 메모리는 다시 해제가 가능하지만 그렇다고 반드시 힙이 줄어드는 것은 아닙니다!! 만양에 다른 데이터가 힙에 동적으로 할당되어 올라간다면 힙의 꼭지점은 줄어들지 않기 때문입니다. 이 경우는 구멍 송송 스위스 치즈처럼 힙이 파편화(fragmented) 되게 됩니다. 동적 할당은 꼭 필요할 때만 사용하세요. (특히 문자열)
.
지역 변수(Local Variables)
함수가 실행될 때 stack frame 을 생성하면서 스택이 증가합니다. 각각의 stack frame은 함수로 전달된 파라미터, 함수 내부에서 선언된 변수를 포함합니다. 하지만 이 메모리는 함수 사용이 끝나면 100% 반환됩니다. 지역 변수는 Global, Static 변수처럼 공간을 영구적으로 차지하지 않기 때문에 코드 일부에서만 사용되는 변수는 함수안에서 사용되도록 배려가 필요합니다.