21.6.3.1.1. `hrt_absolute_time()` 기반 마이크로초(us) 단위 절대 시간 스케줄링 큐 삽입

21.6.3.1.1. hrt_absolute_time() 기반 마이크로초(us) 단위 절대 시간 스케줄링 큐 삽입

프로그래밍 초보자들이 타이머를 짤 때 흔히 저지르는 실수가 하나 있다. 바로 ’현재 시간부터 +10ms 뒤’라는 상대 시간(Relative Time) 개념으로 스케줄링을 거는 것이다.
“지금부터 10ms 뒤에 깨워줘, 일어나서 일 좀 하고, 또 지금부터 10ms 뒤에 깨워줘…”
이렇게 스케줄링을 걸면, 내가 ’일 좀 하는 시간(Execution Time)’만큼 목표 시간이 점점 뒤로 밀리면서 결국 100Hz로 돌아야 할 루프가 90Hz, 80Hz로 심각하게 흔들리게 된다.

이 현상을 막기 위해 PX4의 ScheduleOnInterval() 함수는 철저하게 절대 시간(Absolute Time) 만을 사용하여 커널 타이머 큐(Timer Queue)에 화물을 찔러넣는다.

1. hrt_absolute_time(): 픽스호크의 절대 시계

픽스호크의 전원이 켜지는 그 순간, hrt_absolute_time() 이라는 거대한 하드웨어 스톱워치가 0부터 시작하여 마이크로초(us) 단위로 쉼 없이 증가하기 시작한다. 이 시계는 시스템이 꺼지기 전까지 절대로 멈추거나 뒤로 가지 않는 불멸의 기준점이다.

ScheduleOnInterval(10_ms) 내부에서 호출되는 스케줄링 로직의 배를 갈라보면 대략 이렇다.

// px4::ScheduledWorkItem 내부 주기 계산 로직 (단순화)
void ScheduledWorkItem::ScheduleOnInterval(uint32_t interval_us)
{
    // 1. 앞으로 내가 돌 영구적인 주기(Interval)를 기억해 둔다.
    _call_interval_us = interval_us;
    
    // 2. 현재 시스템의 "절대 시간"을 확인한다. (예: 시스템 부팅 후 딱 1,000,000us가 지남)
    hrt_abstime now = hrt_absolute_time();
    
    // 3. 내가 당장 깨어나야 할 다음 "절대 타임스탬프"를 찍는다. (예: 1,010,000)
    //    이 값만이 유일하게 커널 타이머 큐로 넘어간다.
    ScheduleAt(now + _call_interval_us); 
}

ScheduleAt(목표 절대 시간) 이라는 저수준 함수가 호출되면, 커널은 모든 모듈이 제출한 ’절대 시간’들을 오름차순으로 쫙 정렬하여 타이머 큐(Timer Queue)를 만든다. 그리고 제일 앞에 있는 놈의 시간이 될 때마다 정확히 인터럽트를 때려준다.

2. 절대 시간 스케줄링의 부작용: 잃어버린 시간(Missed Deadline)

하지만 아무리 절대 시간으로 큐에 화물을 꽂아 넣더라도 피할 수 없는 현실적인 문제가 있다.

만약 내가 1,010,000us에 깨어나기로 예약했는데, 하필 그 시간에 나보다 우선순위가 높은 놈(rate_ctrl 스레드의 다른 덩치 큰 모듈들)이 CPU를 꽉 잡고 놓아주지 않는다면?
내 모듈은 인터럽트는 맞았지만 버스에 자리가 없어서 1,012,000us 가 되어서야(즉, 2ms 지각하여) 간신히 Run() 함수 안으로 진입하게 된다.

이 지각(Latency) 현상이 벌어졌을 때, 만약 Run() 함수 마지막에 과거의 상대 시간 모델처럼 단순하게 ScheduleAt(현재 시간 + 10ms) 를 또 예약한다면 스케줄이 완전히 밀려버릴 것이다.

그렇다면 이 지각된 편차 시간(Jitter)을 어떻게 아름답게 흡수하고 보정해서 다시 원래의 칼 같은 100Hz(10ms) 주기로 루프를 되돌려 놓을 수 있을까?
PX4 소스 코드에 숨어있는 소름 돋는 동적 보정(Dynamic Jitter Compensation) 알고리즘을 다음 장(21.6.3.1.2)에서 확인해 보자.