실행시간 측정

실행시간 측정

2025년 4월 21일

Local APIC Timer #

사용법 #

LAPIC는 MSI 방식의 인터럽트를 핸들링할 때 살펴봤었다.

시간을 재는 회로 자체는 코어에도 있지만, 타임아웃이 됐을때 인터럽트를 발생시켜주는 타이머 역할을 하는게 LAPIC의 타이머이다.

initial_count 값을 세팅하면 current_count 가 이 값으로 세팅되고 자동으로 주기마다 1씩 줄어들며 0이 되면 인터럽트가 발생한다.

주기는 device_config로 설정해서 클럭 몇번당 한번 값이 줄어들지 정할 수 있다.
lvt_timer 로 속성을 세팅할 수 있는데, 코드에서는 인터럽트가 발생하지 않는 타이머를 사용하겠다는 의미이다.

 1namespace {
 2  const uint32_t kCountMax = 0xffffffffu;
 3  volatile uint32_t& lvt_timer = *reinterpret_cast<uint32_t*>(0xfee00320);     // 인터럽트 발생 방법 설정 
 4  volatile uint32_t& initial_count = *reinterpret_cast<uint32_t*>(0xfee00380); // 카운터의 초기 값
 5  volatile uint32_t& current_count = *reinterpret_cast<uint32_t*>(0xfee00390); // 카운터의 현재 값
 6  volatile uint32_t& divide_config = *reinterpret_cast<uint32_t*>(0xfee003e0); // 카운터의 감소 스피드 설정
 7}
 8
 9void InitializeLAPICTimer() {
10  // 클럭을 몇으로 나눌지? (0b1001 = APIC_CLK/64를 의미)
11  divide_config = 0b1001;
12  // InterruptMask(타이머 인터럽트 비활성화) | 하위 8bit는 인터럽트 벡터 번호(32)
13  lvt_timer = (0b001 << 16) | 32;  
14
15  // lvt_timer 설정 비트
16  // 0:7   Vector - 인터럽트 벡터 번호 (0~255)
17  // 12    Delivery Status - 인터럽트 전달 상태 0=idle, 1=send pending
18  // 16    Interrupt Mask - 1=인터럽트 비허용
19  // 17:18 Timer Mode - 0=단발, 1=주기적으로
20}
21
22void StartLAPICTimer() {
23  // 초기 값은 최댓값. 이 값부터 주기마다 current_count가 1씩 줄어들음
24  initial_count = kCountMax;
25}
26
27uint32_t LAPICTimerElapsed() {
28  // 기준 값 - 현재카운트. 기준값 대비 얼마나 줄어들었는지 확인할 수 있다.
29  return kCountMax - current_count;
30}
31
32void StopLAPICTimer() {
33  // 초기 값을 0으로 세팅하면 타이머는 정지된다.
34  initial_count = 0;
35}

LAPIC 타이머 인터럽트 방식 #

CPU 코어 하나에 LAPIC 타이머는 하나이기 때문에 위의 방식대로 하면 싱글코어에서 동시에 하나의 시간만 Start에서 End까지 잴 수 있다

여러곳에서 타이머를 사용하려면 커널 시작할때 타이머를 실행하고 안끄고 Elapsed와 Elapsed 사이 값만 비교하면 될것이다.

하지만 이건 count값이 0이 되면 종료되는 수명이 정해진 타이머이고, 인터럽트 방식을 사용해서 인터럽트마다 tick 카운트를 1씩 증가시키는 방식을 사용해볼 것이다.

코드 자체는 별게 없다. 타이머를 위한 새로운 IDT 인덱스를 추가해주고, 핸들러를 등록해주면 끝이다.

 1void InitializeLAPICTimer() {
 2  divide_config = 0b1011;
 3  // 16    Interrupt Mask - 1=인터럽트 비허용
 4  // 17:18 Timer Mode - 0=단발, 1=주기적으로
 5  // 0b010 << 16 (주기적 + 인터럽트 허용) | 인터럽트벡터 번호
 6  lvt_timer = (0b010 << 16) | InterruptVector::kLAPICTimer;
 7  initial_count = 0x1000000u;
 8}
 9
10// 인터럽트 안에서 호출되기 때문에 __attribute__((no_caller_saved_registers)) 경고가 발생한다
11// attribute를 붙이면 LAPICTimerOnInterrupt 에서는 caller-saved를 사용하지 않는다고 생각하고 컴파일되는데,
12// 이 함수가 호출하는 Tick에서 내부적으로 caller-saved 레지스터를 건드리기 때문에
13// 저장되지 않은 caller-saved 레지스터가 변경되어 ISR의 의무가 지켜지지 않게된다. 
14void LAPICTimerOnInterrupt() {
15  timer_manager->Tick();
16}
17
18__attribute__((interrupt))
19void IntHandlerLAPICTimer(InterruptFrame* frame) {
20  // msg_queue->push_back(Message{Message::kInterruptLAPICTimer});
21  LAPICTimerOnInterrupt();
22  NotifyEndOfInterrupt();
23}
24
25SetIDTEntry(idt[InterruptVector::kLAPICTimer],
26            MakeIDTAttr(DescriptorType::kInterruptGate, 0),
27            reinterpret_cast<uint64_t>(IntHandlerLAPICTimer),
28            kKernelCS);

TimerManager 클래스를 만들어서 특정 주기 인터럽트마다 카운트 값을 증가시키고, getter로 가져올 수 있게 하면 언제든 시간을 잴 수 있다.


인터럽트 방식의 성능상 장점 #

기존 인터럽트가 없는 방식에서는 시간을 재야하기 때문에 메인루프에서 hlt를 걸어둘 수 없고 이벤트가 없어도 CPU를 소모하는 busy-waiting 상태가 된다.

인터럽트 방식은 주기마다 hlt 상태의 CPU를 깨울 수 있기 때문에 hlt를 걸어둔다 하더라도 정확한 주기에 시간을 카운트할 수 있게된다.

 1  while (true) {
 2    __asm__("cli");
 3    // timer_manager 의 tick_ 도 인터럽트 안에서 수정하는 값이기 때문에
 4    // 크리티컬 섹션 안에서 가져와야 한다. 
 5    const auto tick = timer_manager->CurrentTick();
 6    if (main_queue->size() == 0) {
 7      __asm__("sti\n\thlt");      // 이전에는 hlt상태가 되면 시간을 잴 수 없었다.
 8      continue;
 9    }
10    Message msg = main_queue->front();
11    main_queue->pop_front();
12    __asm__("sti");

TimerManager에서 tick_ 멤버는 volatile 선언이 되어있다.

컴파일러는 getter만 호출하는 코드의 경우 상수값으로 변경해버리는 최적화를 하기도 하는데,
tick_은 getter만 호출하고 있더라도 인터럽트에 의해 값이 변경될 수 있다.

이 때 컴파일러의 변수 최적화를 방지하기 위해 volatile을 넣어 언제든 값이 변할 수 있다는 것을 컴파일러에게 알려준다.

 1class TimerManager {
 2 public:
 3  TimerManager(std::deque<Message>& msg_queue);
 4  void AddTimer(const Timer& timer);
 5  void Tick();
 6  unsigned long CurrentTick() const { return tick_; }
 7
 8 private:
 9  volatile unsigned long tick_{0};
10  std::priority_queue<Timer> timers_{};
11  std::deque<Message>& msg_queue_;
12};

Timeout #

AddTiemr로 timers_ 큐에 타이머들을 넣어주고, Tick이 호출될 때마다 내부적으로 관리하는 tick_ 값을 증가시키고 timers 큐를 확인한다.

맨 위에있는 값만 확인하고 현재 틱이 안됐으면 그대로 종료하는데,
timers 큐는 priority_queue 로 push 할때 < 연산자로 우선순위 비교를 해서 timeout 값이 작을수록 우선순위가 높아 top쪽에 배치된다.

그래서 timeout 값이 낮은 타이머부터 높은 우선순위로 처리되고 처리된 타이머는 제거된다.

 1inline bool operator<(const Timer& lhs, const Timer& rhs) {
 2  return lhs.Timeout() > rhs.Timeout();
 3}
 4
 5void TimerManager::AddTimer(const Timer& timer) {
 6  timers_.push(timer);
 7}
 8
 9void TimerManager::Tick() {
10  ++tick_;
11  while (true) {
12    const auto& t = timers_.top();
13    if (t.Timeout() > tick_) {
14      break;
15    }
16
17    Message m{Message::kTimerTimeout};
18    m.arg.timer.timeout = t.Timeout();
19    m.arg.timer.value = t.Value();
20    msg_queue_.push_back(m);
21
22    timers_.pop();
23  }
24}

타이머를 처리할땐 타임아웃 값과 value 값을 메시지큐의 메시지에 담는데, 이 메시지큐는 인터럽트만 관리하는게 아니라 모든 이벤트를 관리하고 데이터도 전달해야 하기 때문에 union 구조체 형태로 메시지에 맞는 데이터까지 추가할 수 있도록 한다.

 1struct Message {
 2  enum Type {       // 타입부분
 3    kInterruptXHCI,
 4    kTimerTimeout,
 5  } type;
 6
 7  union {           // 데이터부분
 8    struct {
 9      unsigned long timeout;
10      int value;
11    } timer;
12  } arg;
13};

ACPI PM Timer #

LAPIC 타이머의 한계 #

지금까지 만든 LAPIC Timer 카운터는 현실시간을 잴 수 없고, 가변 주파수 타이머라서 펌웨어가 CPU를 몇 Hz 로 실행시켰는지 알 수 없어서 기기마다 다른 속도로 틱이 동작할 것이다.

ACPI(Advanced Configuration and Power Interface) PM(Power Management) Timer 는 동작 주파수가 3.58 MHz로 항상 고정인 타이머 중 하나라서 이 기능으로 LAPIC Timer의 주기를 측정하고 결과적으론 LAPIC를 통해 시간을 잴 것이다.

ACPI PM Timer는 읽기만 가능하고 인터럽트같은 기능이 없어서 대체할수는 없다.


ACPI 란? #

ACPI는 이름이 전원관리용 처럼 보이지만, PCI 스캔으로 찾을 수 없는 하드웨어, 플랫폼레벨에 대한 메타 데이터를 OS에 테이블 형태로 넘겨 펌웨어의 개입 없이 플랫폼(PC 하드웨어)을 완전히 파악하는 것을 목표로 한다.

UEFI의 DXE 시점에 ACPI 드라이버가 하드웨어의 정보를 읽어와서 RSDP(Root System Description Pointer), XSDT(Extended System Descriptor Table), FADT(Fixed ACPI) 등의 테이블을 초기화하고, OS에 넘겨줘서 테이블을 읽으며 원하는 작업을 처리할 수 있게 된다.

305256d8-eac4-409d-81a0-6752cd575bf5


ACPI PM 타이머의 레지스터 정보 획득 #

타이머를 사용하기 위해서는 다른 하드웨어 작업들과 마찬가지로 IO 레지스터 포트를 알아야 한다.
RSDP -> XSDT -> FADT -> IO 포트번호 순서로 획득할 수 있는데 가장먼저 ACPI Table 을 UEFI 부트로더에서 얻어야 한다.

ACPI Table 획득 #

부트로더에서 gEfiAcpiTableGuid 를 통해 ACPI Table을 얻어오고, 커널의 인자로 전달해주면 된다.

 1// Loader.inf
 2[Guids]
 3  // 외부 모듈의 ACPI Table 의 GUID 심볼을 사용하겠다고 선언
 4  // 이 GUID 값은 UEFI 스펙상 이미 정의되어 있고, inf는 그 값을 링크해주는 역할을 함
 5  // 이걸 선언하지 않으면 그냥 gEfiAcpiTableGuid 심볼을 사용할 수 없게되는 것일 뿐이다.
 6  gEfiAcpiTableGuid
 7
 8// Main.c
 9EFI_STATUS EFIAPI UefiMain(
10    EFI_HANDLE image_handle,
11    EFI_SYSTEM_TABLE* system_table) {  // 애초에 system_table을 전달받음
12  // ...
13  VOID* acpi_table = NULL;
14  for (UINTN i = 0; i < system_table->NumberOfTableEntries; ++i) {
15    // system_table 에서 VendorGuid 가 ACPI인 테이블을 찾는다. 
16    if (CompareGuid(&gEfiAcpiTableGuid,
17                    &system_table->ConfigurationTable[i].VendorGuid)) {
18      acpi_table = system_table->ConfigurationTable[i].VendorTable;
19      break;
20    }
21  }
22  typedef void EntryPointType(const struct FrameBufferConfig*,
23                              const struct MemoryMap*,
24                              const VOID*);
25  EntryPointType* entry_point = (EntryPointType*)entry_addr;
26  entry_point(&config, &memmap, acpi_table);

RSDP 획득 #

사실 entry_point에서 전달받은 acpi_table이 RSDP이다.

 1struct RSDP {
 2  char signature[8];        // "RSD PTR "
 3  uint8_t checksum;         // 상위 20byte의 체크섬
 4  char oem_id[6];           // oem 이름
 5  uint8_t revision;         // RSDP 구조체 버전 번호. ACPI 6.2에서 2
 6  uint32_t rsdt_address;    // RSDT 32bit 물리주소
 7  uint32_t length;          // RSDP 전체 바이트 수
 8  uint64_t xsdt_address;    // XSDT 64bit 물리주소 
 9  uint8_t extended_checksum;// 전체 36byte의 체크섬
10  char reserved[3];
11
12  bool IsValid() const;     // 매직 값과 체크섬을 검증하는 함수
13} __attribute__((packed));
14
15extern "C" void KernelMainNewStack(
16    const FrameBufferConfig& frame_buffer_config_ref,
17    const MemoryMap& memory_map_ref,
18    const acpi::RSDP& acpi_table) {
19}

XSDT → FADT #

38169909-c9c5-492c-bbdb-3e13dd7d0edf

대략 이렇게 생겼다. XSDT는 DescriptionHeader 이후에 Entry가 나오는데, Entry도 DescriptionHeader 로 구조는 동일하다.

Entry를 순회하여 FADT의 헤더를 얻어올 수 있고, pm_tmr_blk, flags 외에 멤버가 더 있지만 타이머에서는 필요 없기 때문에 표시하지 않았다.

 1struct DescriptionHeader {
 2  char signature[4];
 3  uint32_t length;
 4  uint8_t revision;
 5  uint8_t checksum;
 6  char oem_id[6];
 7  char oem_table_id[8];
 8  uint32_t oem_revision;
 9  uint32_t creator_id;
10  uint32_t creator_revision;
11
12  bool IsValid(const char* expected_signature) const;
13} __attribute__((packed));
14
15struct XSDT {
16  DescriptionHeader header;
17
18  // XSDT의 Entry 순회용으로 연산자 오버로딩
19  const DescriptionHeader& operator[](size_t i) const;
20  size_t Count() const;
21} __attribute__((packed));
22
23struct FADT {
24  DescriptionHeader header;
25
26  // 나머지 멤버도 다 들어있지만 필요 없기 때문에 reserved로 채워넣었다. 
27  char reserved1[76 - sizeof(header)];
28  uint32_t pm_tmr_blk;
29  char reserved2[112 - 80];
30  uint32_t flags;
31  char reserved3[276 - 116];
32} __attribute__((packed));
33
34extern const FADT* fadt;
35// rsdp 에서 fadt를 찾아 위의 전역변수에 주소를 세팅해준다.
36void Initialize(const RSDP& rsdp);

XSDT의 [] 연산자를 보면 전달되는 인덱스 i로 header +1 위치부터 참조하는 것을 볼 수 있는데, &this->header + 1 연산에서는 header 크기만큼 1로 보고 더하기 때문에 header 이후 주소를 entries가 가리키게 된다.

 1// 리턴 타입은 DescriptionHeader 인데, Entry가 모두 같은 타입의 헤더를 갖기 때문이다. 
 2const DescriptionHeader& XSDT::operator[](size_t i) const {
 3  // entries = (char*)(&this->header) + sizeof(this->header)
 4  auto entries = reinterpret_cast<const uint64_t*>(&this->header + 1);
 5  return *reinterpret_cast<const DescriptionHeader*>(entries[i]);
 6}
 7
 8void Initialize(const RSDP& rsdp) {
 9  if (!rsdp.IsValid()) {
10    Log(kError, "RSDP is not valid\n");
11    exit(1);
12  }
13
14  const XSDT& xsdt = *reinterpret_cast<const XSDT*>(rsdp.xsdt_address);
15  if (!xsdt.header.IsValid("XSDT")) {
16    Log(kError, "XSDT is not valid\n");
17    exit(1);
18  }
19
20  fadt = nullptr;
21  for (int i = 0; i < xsdt.Count(); ++i) {
22    // XSDT의 Entry를 하나씩 접근해서 FACP 시그니쳐를 찾는다. 
23    const auto& entry = xsdt[i];
24    if (entry.IsValid("FACP")) {
25      fadt = reinterpret_cast<const FADT*>(&entry);
26      break;
27    }
28  }
29
30  if (fadt == nullptr) {
31    Log(kError, "FADT is not found\n");
32    exit(1);
33  }
34}

LAPIC 타이머 캘리브레이션 #

Calibration 은 교정이라는 뜻으로 표준 값으로 측정 장치를 조절하는 행위를 말한다.
LAPIC 타이머의 시간이 현실시간과 맞지 않아서 고정 주파수를 가진 ACPI PM 타이머로 캘리브레이션 과정을 거쳐야 한다.

일단 먼저 할 일은 PM Timer로 현실시간을 측정하는 것이다.
이 함수는 특정 현실시간동안 sleep 하는 함수이다.

 1// PM Timer의 주파수 (1초에 3579545 가 카운트된다)
 2const int kPMTimerFreq = 3579545;
 3
 4void WaitMilliseconds(unsigned long msec) {
 5  // pm_timer 가 32bit 크기인지 여부(또는 24bit)를 나타내는 플래그이다.  
 6  const bool pm_timer_32 = (fadt->flags >> 8) & 1;
 7  // 시작 count 값
 8  const uint32_t start = IoIn32(fadt->pm_tmr_blk);
 9  // 끝 count 값. 시작에서 msec 만큼 지났을때의 주파수 값을 계산한다.
10  // kPMTimerFreq는 초당 진동수니까 msec 시간으로 곱한다. 
11  uint32_t end = start + kPMTimerFreq * msec/1000;
12  if (!pm_timer_32) {
13    // 24bit 인 경우 
14    end &= 0x00ffffffu;
15  }
16
17  // end 가 더 작다면 한바퀴 돈 것이기 때문에 start 값이 될 때 까지임
18  if (end < start) { // overflow
19    while (IoIn32(fadt->pm_tmr_blk) >= start);
20  }
21  while (IoIn32(fadt->pm_tmr_blk) < end);
22}

LAPIC Timer 초기화 하는 코드를 수정해야한다.
initial_count 값을 10ms 마다 인터럽트를 발생시키는 값으로 세팅하면 된다.

 1extern unsigned long lapic_timer_freq;
 2const int kTimerFreq = 100;
 3
 4void InitializeLAPICTimer(std::deque<Message>& msg_queue) {
 5  timer_manager = new TimerManager{msg_queue};
 6
 7  // LAPIC Timer의 1 값을 1:1 비율로 증가시키는 것인데, 
 8  // 어차피 시간에 맞게 값만 잘 나오면 되니까 큰 상관은 없다. (정밀도는 오를듯)
 9  divide_config = 0b1011;   // devide 1:1
10  lvt_timer = 0b001 << 16;  // one shot 방식
11  
12  // LAPIC Timer 시작 -> 현실 100ms 진행 -> LAPIC Timer 끝
13  // 여기에서 측정하는 시간을 적당히 늘리면 더 정확한 시간이 될 것이다.
14  StartLAPICTimer();
15  acpi::WaitMilliseconds(100);
16  const auto elapsed = LAPICTimerElapsed();
17  StopLAPICTimer();
18
19  // 100ms 측정했기 때문에 10을 곱하면 1초에 맞는 lapic_timer_freq 값이 나온다. 
20  lapic_timer_freq = static_cast<unsigned long>(elapsed) * 10;
21
22  // devide 값은 측정했을때와 동일한 비율로 설정해야한다. 
23  divide_config = 0b1011;   // devide 1:1
24  // 인터럽트 방식으로 세팅
25  lvt_timer = (0b010 << 16) | InterruptVector::kLAPICTimer;
26  // 10ms 마다 인터럽트가 발생하도록 initial_count 값 지정
27  initial_count = lapic_timer_freq / kTimerFreq;
28}

1분 카운트를 해보니 대략 6000 정도의 카운트가 진행된 것을 확인할 수 있다. (밀리초까지는 정확하지 않으니 무시)

확인 중 이상한 점을 발견했다. 화면(실제 표시화면이 아니더라도)이 커지면 커질수록 시간이 느려졌다.
아마 내생각엔 qemu는 해상도에 따라 클럭이 실시간으로 변하는데, 화면이 커질수록 이 클럭이 낮아지는 것 같다.
b86b86e9-9673-46b8-a2d0-e41d6cf081a2

comments powered by Disqus