Timer class
Timer class에 대하여
Timer class는 일정 시간이 지난 뒤 특정 함수를 callback 하도록 예약하는 클래스이다.
기본 구조는 다음과 같다.
from machine import Timer
timer = Timer()
Timer.init(
*,
mode=Timer.PERIODIC,
freq=-1,
period=-1,
callback=None,
hard=True
)
Timer() 생성자
우선 첫 번쨰로 의문이 드는 것은 timer = Timer()이다.
아무것도 넘기지 않고 그냥 timer라는 객체를 생성한다. 과연 아무것도 넣지 않아도 될까?
Timer 생성자는 실제로 아래와 같이 생겼다.
timer = Timer(
id=...,
mode=...,
freq=...,
callback=...,
)
우선 id말고 뒤에 있는 파라미터는 init() 이라는 메서드로 초기화할 수 있다. 그래서 따로 생성자에게 넘기지 않은 것이다. 하지만 하나 걸리는 것이 있다면 바로 id 파라미터이다.
id 파라미터란 어떤 타이머를 사용할 것인지 설정하는 것이다. RP2040 기준 하드웨어 타이머 1개, 알람 4개로 구성되어 있다. 마이크로파이썬에서는 프로그래머가 타이머의 id를 바꾸지 않도록 설계하였다. 프로그래머가 접근할 수 있는 id는 -1, 가상 타이머이다.
가상타이머
가상 타이머란 소프트웨어로 만든 존재하지 않는 타이머이다. 프로그래머가 만약 직접 id를 지정하여 타이머를 사용하게 되면 프로그래머가 그 자원을 해제하기 전까지는 다른 장치들이 해당 타이머를 사용할 수 없는 문제가 발생한다. 그래서 마이크로파이썬에서는 해당 타이머의 충돌 문제를 해결하기 위해 가상 타이머를 도입하였다.
타이머의 구조
타이머 구조는 아래와 같이 이루어져 있다.
하드웨어 타이머
└─ 현재 시간이 몇 μs인지 계속 증가시키는 64비트 카운터 1개
하드웨어 알람
│
├─ ALARM0 → 다른 용도로 사용할 수 있음
├─ ALARM1 → 다른 용도로 사용할 수 있음
├─ ALARM2 → 다른 용도로 사용할 수 있음
└─ ALARM3 → 기본 알람 풀
├─ Timer A
├─ Timer B
├─ Timer C
├─ Timer D
└─ Timer E
이제 실제로 어떻게 작동하는지 봐보자.
만약 프로그래머가 타이머를 A~E개 만들었다고 해보자. 그럼 저렇게 ALARM3에 있는 알람 풀에 A~E타이머가 담기게 된다. 하드웨어 알람의 기본 알람 풀은 빌드 설정에 따라 달라질 수 있다. 그렇게 점유한 하드웨어 알람을 가지고 알람 풀에 있는 가장 빠른 시각을 하드웨어 알람 풀에 배정한다.
ALARM3 목표값 → 100ms
이후 A가 실행되면 하드웨어 알람에서 Timer A를 제거하고 다음 빠른 시각을 가진 Timer을 하드웨어 알람에 배정한다. 이를 계속 반복하는 것이다.
만약 타이머들이 실행되는 간격이 같거나, 협소하면 또는 콜백이 너무 길면 의도했던 간격으로 타이머들이 실행되지 않고 지연이 계속 누적될 수 있다.
타이머들이 실행되는 간격이 같거나 협소한 경우를 먼저 봐보자.
Timer는 알람을 하드웨어 알람에 배정할 때 순차 배정을 사용한다. 즉, 들어온 차례대로 실행 가능한 콜백을 처리한다는 것이다. 제일 중요한 것은 주기를 같게 설정한 타이머들이 동시에 실행된다는 오해를 버리는 것이다.
콜백의 처리 속도가 매우 빠르다고 가정하고 보면 다음과 같이 실행된다고 볼 수 있다.
1000.000ms → Timer A
1000.001ms → Timer B
1000.002ms → Timer C
이제 콜백이 매우 긴 경우를 생각해보자. 이는 매우 좋지 않은 상황이다. 어떤 간격을 주던 간에 콜백이 너무 길게 되면 의도하지 않은 결과를 낳을 수 있다.
그래서 주기를 같게 하거나 콜백을 무겁게 설계하는 것은 옳지 않은 방식임을 알 수 있다.
뒤에 있는 파라미터들은 init() 메서드에 나오므로 생략하겠다.
init() 메서드
init 메서드의 구조는 다음과 같다.
Timer.init(
*,
mode=Timer.PERIODIC,
freq=-1,
period=-1,
callback=None,
hard=True
)
mode
mode는 타이머가 한 번 실행될지, 계속 반복되어 실행될지를 정하는 것이다.
Timer.ONE_SHOT은 지정된 시간이 되면 한 번만 실행하고 알람 풀에서 제거된다. 이후 다시 init 메서드를 사용하여 해당 타이머를 다시 사용할 수 있다.
즉, deinit이 되는 것이 아니다.
Timer.PERIODIC은 지정된 시간이 되면 콜백을 수행하고 계속 알람 풀에 남아 있는다. 그래서 주기적으로 하드웨어 알람에 배정되고 제거될 수 있다.
period
period는 콜백을 수행하는 간격을 ms로 정하는 것이다. 어렵지 않다.
freq
freq는 콜백을 1초에 몇 번 수행할지 정하는 파라미터이다. 주파수와 동일하게 보면 된다. 어렵지 않다.
이 때, 만약 period와 freq를 동시에 지정하면 어떻게 될까? 마이크로파이썬 공식 문서에 따르면 우선순위가 freq가 더 높기 때문에 period는 무시된다고 적혀져 있다.
callback
콜백을 수행할 함수를 지정하는 것이다. 아까도 말했듯이 너무 무거운 함수를 콜백으로 지정하면 지연이 발생할 수 있어 의도치 않은 결과물이 나올 수 있다.
만약, 콜백을 지정하지 않았을 경우 오류를 발생시킨다. 따라서 반드시 타이머 사용시 콜백에 함수를 지정해줘야 한다.
hard
hard는 타이머 시간이 되었을 때, 콜백을 언제 수행할지 그리고 어떤 환경에서 수행할지를 정하는 것이다.
타이머 시간이 되었을 때, CPU에게 IRQ가 전달된다. 이 때, hard의 설정에 따라 어떻게 수행될지가 달라진다.
만약 hard=False라면, 인터럽트가 발생했을 때 해당 타이머 콜백 함수를 예약 목록에 두고 다른 우선순위가 높은 작업을 먼저 수행한 뒤 콜백 작업을 수행한다. 이를 Soft IRQ라고 한다.
아래와 같이 코드가 작성되어 있다고 해보자.
def callback(timer):
print("타이머 발생")
timer.init(
period=1000,
callback=callback,
hard=False
)
이 때, 1초가 지났다고 해서 바로 콜백이 처리되는 것이 아니다. 아래와 같이 실행된다고 볼 수 있다.
① 타이머 시간이 됨
↓
② 하드웨어 인터럽트 발생
↓
③ "callback을 실행해야 함"을 예약 목록에 등록
↓
④ 현재 MicroPython 작업을 적절한 지점까지 처리
↓
⑤ 예약된 callback 실행
그래서 만약 마이크로파이썬이 해당 타이머 시간 때 무거운 작업을 수행하고 있었다면 약간의 지연이 발생할 수 있다.
오해하면 안 되는 개념은 지연(latency)와 지터를 헷갈리면 안 된다. 지연은 예정된 시간보다 어느 정도 시간이 추가되었는지 나타내는 것이고, 지터는 해당 지연 시간들이 일정하지 않고 불규칙하게 달라지는 현상을 묶어서 표현하는 것이다.
만약 hard=True라면, 인터럽트가 발생했을 때 그 즉시 바로 콜백을 처리하도록 하는 것이다. 이를 Hard IRQ라고 한다.
다음과 같이 수행된다고 볼 수 있다.
메인 코드 실행 중
↓
타이머 인터럽트 발생
↓
현재 메인 코드 잠시 중단
↓
callback 실행
↓
callback 종료
↓
중단했던 메인 코드 계속 실행
그래서 hard를 True로 설정하면 매우 정확하게 타이머를 동작시킬 수 있다. 하지만 다른 중요한 작업들이 밀릴 수 있으므로 1) 가벼운 콜백 함수인지? 2) 다른 작업보다도 중요한 콜백 함수인지? 를 보아야 한다.
여기서 중요한 개념이 등장한다. hard=True로 설정하고 콜백 함수에서의 메모리 할당은 제한되거나 매우 위험할 수 있다. 만약, 마이크로파이썬이 메모리를 관리하고 있었는데 그 순간 타이머에서 메모리 할당 작업을 하는 Hard IRQ가 발생하면서 이전에 참조하고 있던 메모리를 건들 수 있는 참사가 발생한다. 그래서 Hard IRQ일 때는 콜백 함수 내부에서 메모리 할당을 피해야 하는 것이다. 참고로 마이크로파이썬에서는 Hard IRQ일 때는 힙 할당을 막기도 한다. 아래와 같은 작업들이 제한된다.
new_list = [1, 2, 3]
message = f"value={value}"
values.append(value)
obj = SomeClass()
그래서 새로운 리스트를 생성한다거나, 문자열과 같은 객체를 생성한다거나, 자료의 구조를 바꾼다거나, 부동소수점 계산 등을 수행하지 않는 것이 좋다.
또, sleep함수를 hard IRQ에서 사용하게 되면, 전에 정말 중요한 작업을 하고 있던 것들이 강제적으로 지연될 수 있다.
그럼 hard IRQ에서는 어떤 것을 수행하는 것이 좋은가?
이벤트가 발생했다는 사실 기록
센서 레지스터의 최소 정보 읽기
핀 상태 즉시 변경
카운터 증가
미리 만들어 둔 버퍼에 값 저장
등이 있다. 필자는 주로 환경변수나, 플레그 값을 변경할 때 사용하곤 한다.
emergency exception buffer
emergency exception buffer 라는 개념도 있다. 이는 hard IRQ안에서 예외가 발생했을 때, 예외 메시지를 출력할 메모리 공간 조차 할당을 못 할 경우가 생길 수 있다. 아까 문자열 객체의 생성이 제한된다는 설명과 일치하는 맥락이다.
그래서 EEB를 미리 설정하여 Hard IRQ로 설정되기 전에 메모리를 미리 확보해놓는 것이다. 아래와 같이 사용할 수 있다.
import micropython
micropython.alloc_emergency_exception_buf(100)
오해하면 안 되는 것이, EEB는 예외가 발생했을 때만 접근하는 메모리다. 일반 메모리를 할당할 때는 절대로 접근하지 않도록 설계되어 있어, EEB를 크게 잡는다고 해서 hard IRQ가 사용할 수 있는 메모리가 증가하는 것이 아니다. 단지 예외 메시지를 출력하기 위한 버퍼를 미리 마련해놓는 것이다.