마이크로컨트롤러에서의 MicroPython¶
MicroPython은 마이크로컨트롤러에서 실행될 수 있도록 설계되었습니다. 마이크로컨트롤러는 일반적인 컴퓨터에 더 익숙한 프로그래머에게는 낯설 수 있는 하드웨어 제약을 가지고 있습니다. 특히 RAM과 비휘발성 “디스크”(플래시 메모리) 저장 공간의 양이 제한적입니다. 이 튜토리얼은 제한된 리소스를 최대한 활용하는 방법을 제시합니다. MicroPython은 다양한 아키텍처 기반의 컨트롤러에서 실행되므로 여기서 제시하는 방법은 일반적인 것입니다. 경우에 따라 플랫폼별 문서에서 자세한 정보를 얻어야 할 수도 있습니다.
플래시 메모리¶
OpenMV Cam에서 제한된 용량을 다루는 간단한 방법은 마이크로 SD 카드를 장착하는 것입니다. 장치에 SD 카드 슬롯이 없거나, 비용 또는 전력 소비 등의 이유로 이것이 실용적이지 않은 경우도 있습니다. 이런 경우에는 온칩 플래시를 사용해야 합니다. MicroPython 하위 시스템을 포함한 펌웨어는 온보드 플래시에 저장됩니다. 나머지 용량은 사용 가능합니다. 플래시 메모리의 물리적 아키텍처와 관련된 이유로 이 용량의 일부는 파일시스템으로 접근할 수 없을 수도 있습니다. 이런 경우 사용자 모듈을 펌웨어 빌드에 포함시킨 후 장치에 플래시함으로써 이 공간을 활용할 수 있습니다.
이를 달성하는 두 가지 방법이 있습니다. 프로즌 모듈과 프로즌 바이트코드입니다. 프로즌 모듈은 Python 소스를 펌웨어와 함께 저장합니다. 프로즌 바이트코드는 크로스 컴파일러를 사용하여 소스를 바이트코드로 변환한 다음 펌웨어와 함께 저장합니다. 어느 경우든 모듈은 import 문으로 접근할 수 있습니다:
import mymodule
프로즌 모듈과 바이트코드를 생성하는 절차는 플랫폼에 따라 다릅니다. 펌웨어 빌드 방법은 소스 트리의 관련 부분에 있는 README 파일에서 찾을 수 있습니다.
일반적으로 단계는 다음과 같습니다:
MicroPython 저장소 를 클론합니다.
펌웨어를 빌드하기 위한 (플랫폼별) 툴체인을 확보합니다.
크로스 컴파일러를 빌드합니다.
프로즌으로 만들 모듈을 지정된 디렉터리에 배치합니다(모듈을 소스로 프로즌할지 바이트코드로 프로즌할지에 따라 달라집니다).
펌웨어를 빌드합니다. 두 유형 중 하나의 프로즌 코드를 빌드하려면 특정 명령이 필요할 수 있습니다. 플랫폼 문서를 참조하세요.
펌웨어를 장치에 플래시합니다.
RAM¶
RAM 사용량을 줄일 때는 두 가지 단계를 고려해야 합니다. 컴파일과 실행입니다. 메모리 소비 외에도 힙 단편화(heap fragmentation)라고 알려진 문제도 있습니다. 일반적으로 객체의 반복적인 생성과 소멸을 최소화하는 것이 가장 좋습니다. 그 이유는 heap 을 다루는 절에서 설명합니다.
컴파일 단계¶
모듈을 임포트하면 MicroPython은 코드를 바이트코드로 컴파일하고, 이 바이트코드는 MicroPython 가상 머신(VM)에 의해 실행됩니다. 바이트코드는 RAM에 저장됩니다. 컴파일러 자체도 RAM을 필요로 하지만, 컴파일이 완료되면 이 메모리는 다시 사용 가능해집니다.
여러 모듈이 이미 임포트된 경우 컴파일러를 실행할 RAM이 부족한 상황이 발생할 수 있습니다. 이 경우 import 문은 메모리 예외를 발생시킵니다.
모듈이 임포트 시 전역 객체를 인스턴스화하면 임포트 시점에 RAM을 소비하게 되며, 이 RAM은 이후 임포트에서 컴파일러가 사용할 수 없게 됩니다. 일반적으로 임포트 시 실행되는 코드는 피하는 것이 가장 좋습니다. 더 나은 방법은 모든 모듈이 임포트된 후 애플리케이션이 실행하는 초기화 코드를 두는 것입니다. 이렇게 하면 컴파일러가 사용할 수 있는 RAM을 최대화할 수 있습니다.
그래도 모든 모듈을 컴파일하기에 RAM이 여전히 부족하다면, 한 가지 해결책은 모듈을 사전 컴파일하는 것입니다. MicroPython에는 Python 모듈을 바이트코드로 컴파일할 수 있는 크로스 컴파일러가 있습니다(mpy-cross 디렉터리의 README 참조). 결과로 생성되는 바이트코드 파일은 .mpy 확장자를 가지며, 파일시스템에 복사하여 일반적인 방식으로 임포트할 수 있습니다. 또는 일부 또는 모든 모듈을 프로즌 바이트코드로 구현할 수도 있습니다. 대부분의 플랫폼에서 바이트코드가 RAM에 저장되는 대신 플래시에서 직접 실행되므로 이렇게 하면 RAM을 더욱 절약할 수 있습니다.
실행 단계¶
RAM 사용량을 줄이기 위한 여러 코딩 기법이 있습니다.
상수
MicroPython은 다음과 같이 사용할 수 있는 const 키워드를 제공합니다:
from micropython import const
ROWS = const(33)
_COLS = const(0x10)
a = ROWS
b = _COLS
상수가 변수에 할당되는 두 경우 모두에서, 컴파일러는 상수 이름에 대한 조회를 코딩하는 대신 그 리터럴 값을 대입하여 이를 피합니다. 이렇게 하면 바이트코드가 절약되고 따라서 RAM이 절약됩니다. 그러나 ROWS 값은 전역 딕셔너리의 키와 값에 각각 하나씩, 최소한 두 개의 머신 워드를 차지하게 됩니다. 다른 모듈이 이를 임포트하거나 사용할 수 있기 때문에 딕셔너리에 존재해야 합니다. 이 RAM은 _COLS 처럼 이름 앞에 밑줄을 붙임으로써 절약할 수 있습니다. 이 심볼은 모듈 외부에서 보이지 않으므로 RAM을 차지하지 않습니다.
const() 의 인수는 컴파일 시점에 상수로 평가되는 것이라면 무엇이든 될 수 있습니다. 예를 들어 0x100, 1 << 8 또는 (True, "string", b"bytes") 등입니다(자세한 내용은 아래 절 참조). 심지어 이미 정의된 다른 const 심볼을 포함할 수도 있습니다. 예를 들어 1 << BIT 와 같습니다.
상수 데이터 구조
상당한 양의 상수 데이터가 있고 플랫폼이 플래시에서의 실행을 지원하는 경우, 다음과 같이 RAM을 절약할 수 있습니다. 데이터는 Python 모듈에 위치시키고 바이트코드로 프로즌해야 합니다. 데이터는 bytes 객체로 정의해야 합니다. 컴파일러는 bytes 객체가 불변임을 ‘알고’ 있으며, 객체가 RAM으로 복사되는 대신 플래시 메모리에 남도록 보장합니다. struct 모듈은 bytes 타입과 다른 Python 내장 타입 간의 변환을 도울 수 있습니다.
프로즌 바이트코드의 함의를 고려할 때, Python에서 문자열, float, bytes, 정수, 복소수, 튜플은 불변임을 유의하세요. 따라서 이들은 플래시로 프로즌됩니다(튜플의 경우 모든 요소가 불변일 때만 해당). 따라서 다음 줄에서
mystring = "The quick brown fox"
실제 문자열 “The quick brown fox”는 플래시에 위치합니다. 런타임에는 그 문자열에 대한 참조가 변수 mystring 에 할당됩니다. 참조는 단일 머신 워드를 차지합니다. 원칙적으로 긴 정수를 사용하여 상수 데이터를 저장할 수 있습니다:
bar = 0xDEADBEEF0000DEADBEEF
문자열 예시에서처럼, 런타임에는 임의로 큰 정수에 대한 참조가 변수 bar 에 할당됩니다. 그 참조는 단일 머신 워드를 차지합니다.
상수 객체로 이루어진 튜플은 그 자체가 상수입니다. 이러한 상수 튜플은 컴파일러에 의해 최적화되어 사용될 때마다 런타임에 생성될 필요가 없습니다. 예를 들어:
foo = (1, 2, 3, 4, 5, 6, 100000, ("string", b"bytes", False, True))
이 전체 튜플은 단일 객체로 존재하며(코드가 프로즌된 경우 플래시에 있을 수도 있음) 필요할 때마다 참조됩니다.
불필요한 객체 생성
객체가 자기도 모르게 생성되고 소멸될 수 있는 여러 상황이 있습니다. 이는 단편화를 통해 RAM의 사용성을 저하시킬 수 있습니다. 다음 절들에서는 이러한 사례를 논의합니다.
문자열 연결
상수 문자열을 생성하는 것을 목표로 하는 다음 코드 조각을 살펴봅시다:
var = "foo" + "bar"
var1 = "foo" "bar"
var2 = """\
foo\
bar"""
각각은 동일한 결과를 만들어내지만, 첫 번째는 런타임에 불필요하게 두 개의 문자열 객체를 생성하고 세 번째를 만들기 전에 연결을 위해 더 많은 RAM을 할당합니다. 나머지는 컴파일 시점에 연결을 수행하므로 더 효율적이고 단편화를 줄입니다.
문자열을 파일과 같은 스트림에 공급하기 전에 동적으로 생성해야 하는 경우, 이를 조금씩 나누어 수행하면 RAM을 절약할 수 있습니다. 큰 문자열 객체를 생성하는 대신, 부분 문자열을 생성하여 다음 부분을 다루기 전에 스트림에 공급하세요.
동적 문자열을 생성하는 가장 좋은 방법은 문자열 format() 메서드를 사용하는 것입니다:
var = "Temperature {:5.2f} Pressure {:06d}\n".format(temp, press)
버퍼
UART, I2C, SPI 인터페이스의 인스턴스와 같은 장치에 접근할 때, 미리 할당된 버퍼를 사용하면 불필요한 객체의 생성을 피할 수 있습니다. 다음 두 루프를 살펴봅시다:
while True:
var = spi.read(100)
# process data
buf = bytearray(100)
while True:
spi.readinto(buf)
# process data in buf
첫 번째는 매 반복마다 버퍼를 생성하는 반면 두 번째는 미리 할당된 버퍼를 재사용합니다. 이는 더 빠르고 메모리 단편화 측면에서도 더 효율적입니다.
Bytes는 int보다 작다
대부분의 플랫폼에서 정수는 4바이트를 소비합니다. 함수 foo() 에 대한 세 가지 호출을 살펴봅시다:
def foo(bar):
for x in bar:
print(x)
foo([1, 2, 0xff])
foo((1, 2, 0xff))
foo(b'\1\2\xff')
첫 번째 호출에서는 코드가 실행될 때마다 정수 list 가 RAM에 생성됩니다. 두 번째 호출은 컴파일 단계의 일부로 상수 tuple 객체(상수 객체만 포함하는 tuple)를 생성하므로, 한 번만 생성되며 list 보다 더 효율적입니다. 세 번째 호출은 최소한의 RAM을 소비하는 bytes 객체를 효율적으로 생성합니다. 모듈이 바이트코드로 프로즌되면 tuple 과 bytes 객체 모두 플래시에 위치하게 됩니다.
문자열 대 Bytes
Python3은 유니코드 지원을 도입했습니다. 이로 인해 문자열과 바이트 배열 사이의 구분이 생겼습니다. MicroPython은 문자열의 모든 문자가 ASCII인 경우(즉, 값이 128 미만인 경우) 유니코드 문자열이 추가 공간을 차지하지 않도록 보장합니다. 전체 8비트 범위의 값이 필요한 경우 bytes 와 bytearray 객체를 사용하여 추가 공간이 필요하지 않도록 할 수 있습니다. 대부분의 문자열 메서드(예: str.strip())는 bytes 인스턴스에도 적용되므로 유니코드를 제거하는 과정이 어렵지 않을 수 있다는 점에 유의하세요.
s = 'the quick brown fox' # A string instance
b = b'the quick brown fox' # A bytes instance
문자열과 바이트 간 변환이 필요한 경우 str.encode() 와 bytes.decode() 메서드를 사용할 수 있습니다. 문자열과 바이트는 모두 불변임에 유의하세요. 이러한 객체를 입력으로 받아 다른 객체를 생성하는 모든 연산은 결과를 생성하기 위해 최소 한 번의 RAM 할당을 의미합니다. 아래 두 번째 줄에서는 새로운 bytes 객체가 할당됩니다. foo 가 문자열이었더라도 마찬가지로 발생합니다.
foo = b' empty whitespace'
foo = foo.lstrip()
런타임 컴파일러 실행
Python 함수 eval 과 exec 는 런타임에 컴파일러를 호출하며, 이는 상당한 양의 RAM을 필요로 합니다. micropython-lib 의 pickle 라이브러리는 exec 를 사용한다는 점에 유의하세요. 객체 직렬화에는 json 라이브러리를 사용하는 것이 RAM 측면에서 더 효율적일 수 있습니다.
문자열을 플래시에 저장하기
Python 문자열은 불변이므로 읽기 전용 메모리에 저장될 가능성이 있습니다. 컴파일러는 Python 코드에 정의된 문자열을 플래시에 배치할 수 있습니다. 프로즌 모듈과 마찬가지로 PC에 소스 트리의 사본과 펌웨어를 빌드할 툴체인이 있어야 합니다. 이 절차는 모듈이 완전히 디버그되지 않았더라도, 임포트되고 실행될 수만 있다면 작동합니다.
모듈을 임포트한 후 다음을 실행합니다:
micropython.qstr_info(1)
그런 다음 모든 Q(xxx) 줄을 복사하여 텍스트 편집기에 붙여넣습니다. 명백히 유효하지 않은 줄이 있는지 확인하고 제거하세요. ports/stm32(또는 사용 중인 아키텍처에 해당하는 디렉터리)에 있는 qstrdefsport.h 파일을 엽니다. 수정한 줄을 파일 끝에 복사하여 붙여넣습니다. 파일을 저장하고 펌웨어를 다시 빌드하여 플래시합니다. 결과는 모듈을 임포트하고 다시 다음을 실행하여 확인할 수 있습니다:
micropython.qstr_info(1)
Q(xxx) 줄이 사라져 있어야 합니다.
힙(heap)¶
실행 중인 프로그램이 객체를 인스턴스화하면 필요한 RAM이 힙(heap)이라고 알려진 고정 크기 풀에서 할당됩니다. 객체가 스코프를 벗어나면(다시 말해 코드가 접근할 수 없게 되면) 그 불필요해진 객체는 “가비지(garbage)”라고 합니다. “가비지 컬렉션(GC)”이라고 알려진 프로세스가 그 메모리를 회수하여 빈 힙으로 반환합니다. 이 프로세스는 자동으로 실행되지만, gc.collect() 를 실행하여 직접 호출할 수도 있습니다.
이에 대한 설명은 다소 복잡합니다. ‘빠른 해결책’으로는 다음을 주기적으로 실행하세요:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
자세한 내용은 아래 내용과 내장 모듈 gc 의 문서를 참조하세요.
MicroPython 내부/개발자 관점의 세부 내용은 메모리 관리 도 참조하세요.
단편화¶
프로그램이 객체 foo 를 생성한 다음 객체 bar 를 생성한다고 가정해 봅시다. 이후 foo 는 스코프를 벗어나지만 bar 는 남아 있습니다. foo 가 사용하던 RAM은 GC에 의해 회수됩니다. 그러나 bar 가 더 높은 주소에 할당되었다면, foo 에서 회수된 RAM은 foo 보다 크지 않은 객체에만 사용될 수 있습니다. 복잡하거나 오래 실행되는 프로그램에서는 힙이 단편화될 수 있습니다. 사용 가능한 RAM이 상당히 많음에도 불구하고 특정 객체를 할당할 연속적인 공간이 부족하여 프로그램이 메모리 오류로 실패하는 것입니다.
위에서 설명한 기법은 이를 최소화하는 것을 목표로 합니다. 큰 영구 버퍼나 다른 객체가 필요한 경우, 단편화가 발생하기 전에 프로그램 실행 초기에 이를 인스턴스화하는 것이 가장 좋습니다. 힙의 상태를 모니터링하고 GC를 제어함으로써 추가적인 개선이 이루어질 수 있습니다. 이는 아래에서 설명합니다.
보고¶
메모리 할당을 보고하고 GC를 제어하기 위한 여러 라이브러리 함수가 제공됩니다. 이들은 gc 와 micropython 모듈에서 찾을 수 있습니다. 다음 예제는 REPL에서 붙여넣을 수 있습니다(Ctrl-E 로 붙여넣기 모드 진입, Ctrl-D 로 실행).
import gc
import micropython
gc.collect()
micropython.mem_info()
print('-----------------------------')
print('Initial free: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
def func():
a = bytearray(10000)
gc.collect()
print('Func definition: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
func()
print('Func run free: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
gc.collect()
print('Garbage collect free: {} allocated: {}'.format(gc.mem_free(), gc.mem_alloc()))
print('-----------------------------')
micropython.mem_info(1)
위에서 사용된 메서드:
gc.collect()가비지 컬렉션을 강제로 수행합니다. 각주를 참조하세요.micropython.mem_info()RAM 사용량 요약을 출력합니다.gc.mem_free()빈 힙 크기를 바이트 단위로 반환합니다.gc.mem_alloc()현재 할당된 바이트 수를 반환합니다.micropython.mem_info(1)힙 사용량 표를 출력합니다(아래에 자세히 설명).
생성되는 숫자는 플랫폼에 따라 다르지만, 함수를 선언하면 컴파일러가 내보낸 바이트코드 형태로 소량의 RAM을 사용함을 알 수 있습니다(컴파일러가 사용한 RAM은 회수되었습니다). 함수를 실행하면 10KiB 이상을 사용하지만, 반환 시 a 는 스코프를 벗어나 참조될 수 없으므로 가비지가 됩니다. 마지막 gc.collect() 는 그 메모리를 복구합니다.
micropython.mem_info(1) 이 생성하는 최종 출력은 세부 사항이 다양하지만 다음과 같이 해석할 수 있습니다:
심볼 |
의미 |
|---|---|
. |
빈 블록 |
h |
헤드 블록 |
= |
테일 블록 |
m |
마킹된 헤드 블록 |
T |
튜플 |
L |
리스트 |
D |
딕셔너리 |
F |
float |
B |
바이트 코드 |
M |
모듈 |
S |
문자열 또는 bytes |
A |
bytearray |
각 문자는 단일 메모리 블록을 나타내며, 한 블록은 16바이트입니다. 따라서 힙 덤프의 각 줄은 0x400바이트 또는 1KiB의 RAM을 나타냅니다.
가비지 컬렉션 제어¶
gc.collect() 를 실행하여 언제든지 GC를 요구할 수 있습니다. 이를 일정 간격으로 수행하는 것이 유리한데, 첫째는 단편화를 미리 방지하기 위해서이고 둘째는 성능을 위해서입니다. GC는 수 밀리초가 걸릴 수 있지만 할 일이 적을 때는 더 빠릅니다(OpenMV Cam에서 약 1ms). 명시적 호출은 이 지연을 최소화하면서 프로그램에서 허용 가능한 시점에 GC가 발생하도록 보장할 수 있습니다.
자동 GC는 다음 상황에서 유발됩니다. 할당 시도가 실패하면 GC가 수행되고 할당이 재시도됩니다. 이것이 실패할 경우에만 예외가 발생합니다. 둘째로, 빈 RAM의 양이 임계값 아래로 떨어지면 자동 GC가 트리거됩니다. 이 임계값은 실행이 진행됨에 따라 조정할 수 있습니다:
gc.collect()
gc.threshold(gc.mem_free() // 4 + gc.mem_alloc())
이는 현재 빈 힙의 25% 이상이 점유되면 GC를 유발합니다.
일반적으로 모듈은 생성자나 다른 초기화 함수를 사용하여 런타임에 데이터 객체를 인스턴스화해야 합니다. 그 이유는 이것이 초기화 시 발생하면 이후 모듈이 임포트될 때 컴파일러가 RAM 부족에 시달릴 수 있기 때문입니다. 모듈이 임포트 시 데이터를 인스턴스화하는 경우, 임포트 후 gc.collect() 를 실행하면 문제가 완화됩니다.
문자열 연산¶
MicroPython은 문자열을 효율적인 방식으로 처리하며, 이를 이해하면 마이크로컨트롤러에서 실행할 애플리케이션을 설계하는 데 도움이 될 수 있습니다. 모듈이 컴파일될 때, 여러 번 나타나는 문자열은 한 번만 저장되는데, 이를 문자열 인터닝(string interning)이라고 합니다. MicroPython에서 인터닝된 문자열은 qstr 이라고 합니다. 일반적으로 임포트된 모듈에서는 그 단일 인스턴스가 RAM에 위치하지만, 위에서 설명한 것처럼 바이트코드로 프로즌된 모듈에서는 플래시에 위치합니다.
문자열 비교 역시 문자 단위가 아닌 해싱을 사용하여 효율적으로 수행됩니다. 따라서 정수 대신 문자열을 사용하는 데 따른 손해는 성능과 RAM 사용량 측면에서 모두 작을 수 있는데, 이는 C 프로그래머에게는 놀라운 사실일 수 있습니다.
후기¶
MicroPython은 객체를 참조로 전달하고, 반환하고, (기본적으로) 복사합니다. 참조는 단일 머신 워드를 차지하므로 이러한 프로세스는 RAM 사용량과 속도 면에서 효율적입니다.
크기가 바이트도 머신 워드도 아닌 변수가 필요한 경우, 이를 효율적으로 저장하고 변환을 수행하는 데 도움이 되는 표준 라이브러리가 있습니다. array, struct, uctypes 모듈을 참조하세요.
각주: gc.collect() 반환값¶
Unix 및 Windows 플랫폼에서 gc.collect() 메서드는 컬렉션에서 회수된 별개의 메모리 영역 수(더 정확히는 free로 전환된 head의 수)를 나타내는 정수를 반환합니다. 효율성을 이유로 베어메탈 포트는 이 값을 반환하지 않습니다.