앞에서 인터럽트에 대해 소개했으니 리눅스 커널에서 인터럽트를 처리하는 방식을 이해하기 위해 알아야 할 주요 개념을 소개합니다.

 인터럽트 핸들러
 인터럽트 벡터 
 인터럽트 디스크립터 
 인터럽트 컨텍스트

인터럽트 핸들러란?

 

인터럽트가 발생하면 이를 핸들링하기 위한 함수가 호출되는데 이를 인터럽트 핸들러라고 합니다. 예를 들어, 키보드를 타이핑해서 인터럽트가 발생하면 키보드 인터럽트를 처리하는 키보드 인터럽트 핸들러가 호출됩니다. 마찬가지로 휴대폰에서 화면을 손으로 만지면 터치 인터럽트가 발생하고 터치 인터럽트를 처리하는 터치 인터럽트 핸들러가 호출됩니다.

다음 그림을 보면서 각 디바이스별로 인터럽트 핸들러가 처리되는 과정을 알아보겠습니다.
 


그림 5.2 디바이스별로 실행되는 인터럽트 핸들러

그림 5.2에서 볼 수 있듯이 인터럽트 종류별로 인터럽트 핸들러가 있습니다. 인터럽트 핸들러는 함수 형태로 존재하며, 커널 내 인터럽트 함수에서 호출합니다. 이처럼 인터럽트가 발생해 지정한 인터럽트 핸들러가 동작하려면 어떻게 해야 할까요? request_irq() 함수를 적절한 인자와 함께 호출해서 미리 인터럽트 핸들러를 등록해야 합니다.

이해를 돕기 위해 컴퓨터에서 마우스를 움직였을 때 인터럽트를 처리하는 코드를 예로 들겠습니다.

https://github.com/raspberrypi/linux/blob/rpi-4.19.y/drivers/input/mouse/amimouse.c
01 static int amimouse_open(struct input_dev *dev)
02 {
03 unsigned short joy0dat;
...
04 error = request_irq(IRQ_AMIGA_VERTB, amimouse_interrupt, 0, "amimouse",
05     dev);

04 번째 줄을 보면 request_irq() 함수의 두 번째 인자로 인터럽트 핸들러 함수인 amimouse_interrupt()를 등록합니다.

이후 마우스 인터럽트가 발생하면 request_irq() 함수에서 지정한 amimouse_interrupt() 함수가 호출됩니다.

https://github.com/raspberrypi/linux/blob/rpi-4.19.y/drivers/input/mouse/amimouse.c
01 static irqreturn_t amimouse_interrupt(int irq, void *data)
02 {
03 struct input_dev *dev = data;
04 unsigned short joy0dat, potgor;
05 int nx, ny, dx, dy;
...
06 input_report_key(dev, BTN_LEFT,   ciaa.pra & 0x40);
07 input_report_key(dev, BTN_MIDDLE, potgor & 0x0100);
08 input_report_key(dev, BTN_RIGHT,  potgor & 0x0400);

인터럽트 핸들러에서는 마우스에서 입력한 데이터 정보를 참고해 유저 공간에 알리는 동작을 수행합니다.

코드는 복잡해 보이지만 다음 그림을 보면 인터럽트의 처리 과정을 쉽게 이해할 수 있습니다.
 


그림 5.3 마우스를 움직였을 때 마우스 인터럽트 핸들러를 호출하는 과정

마우스를 움직이면 마우스가 움직였다는 인터럽트가 발생해 인터럽트 벡터가 실행됩니다. 이후 커널 인터럽트 내부 함수에서 해당 인터럽트에 맞는 인터럽트 핸들러를 찾아 호출합니다. 많은 하드웨어 디바이스가 이 같은 방식으로 인터럽트를 통해 하드웨어의 변화를 알립니다.

인터럽트 컨텍스트는 언제 활성화될까?

 

인터럽트 컨텍스트는 현재 코드가 인터럽트를 처리 중이라는 뜻입니다. 인터럽트 컨텍스트에 대한 이해를 돕기 위해 먼저 소프트웨어 관점에서 인터럽트의 실행 흐름을 단계별로 보겠습니다.

1. 프로세스 실행 중
2. 인터럽트 벡터 실행
3. 커널 인터럽트 내부 함수 호출
4. 인터럽트 종류별로 인터럽트 핸들러 호출
  4.1 인터럽트 컨텍스트 시작
5. 인터럽트 핸들러의 서브루틴 실행 시작
6. 인터럽트 핸들러의 서브루틴 실행 마무리
  6.1 인터럽트 컨텍스트 마무리

복잡한 단계로 인터럽트가 처리되는 것 같아도 처리 과정을 요약하면 다음과 같습니다.

 인터럽트가 발생하면 실행 중인 코드를 멈추고 인터럽트 벡터로 이동해 인터럽트에 대한 처리를 수행합니다.
 인터럽트 종류별로 지정한 인터럽트 핸들러가 실행됩니다.

앞의 목록에서 4.1~6.1 사이에 호출된 함수는 인터럽트 컨텍스트에서 실행됐다고 할 수 있습니다. 여기서 한 가지 의문이 생깁니다. 현재 실행 중인 코드가 인터럽트 컨텍스트인지 어떻게 알 수 있을까요?

in_interrupt() 함수를 호출하면 현재 인터럽트 컨텍스트인지 알려줍니다. 이 함수가 true를 반환하면 현재 실행 중인 코드가 4.1~6.1 구간에 있다는 뜻입니다. 

인터럽트 디스크립터란?


인터럽트 종류별로 다음과 같은 인터럽트의 세부 속성을 관리하는 자료구조를 인터럽트 디스크립터라고 합니다.

 인터럽트 핸들러
 인터럽트 핸들러 매개변수 
 논리적인 인터럽트 번호 
 인터럽트 실행 횟수 

프로세스의 세부 속성을 표현하는 자료구조가 태스크 디스크립터이듯이 인터럽트에 대한 속성 정보를 저장하는 자료구조가 인터럽트 디스크립터인 것입니다. 커널 인터럽트의 세부 함수에서는 인터럽트 디스크립터에 접근해 인터럽트 종류별로 세부적인 처리를 수행합니다. 그림 5.4는 인터럽트가 발생했을 때 인터럽트 핸들러를 호출하는 흐름입니다.
 


그림 5.4 인터럽트 디스크립터로 인터럽트 핸들러를 호출하는 과정

커널 내부의 인터럽트 함수에서 인터럽트 종류별로 지정된 인터럽트 핸들러를 호출하려면 먼저 인터럽트 디스크립터에 접근해야 합니다. 인터럽트 디스크립터는 인터럽트 핸들러의 주소 정보를 갖고 있는데, 커널에서는 이를 읽어서 인터럽트 핸들러를 호출합니다.

인터럽트 디스크립터는 irq_desc 구조체이며 선언부는 다음과 같습니다.

https://github.com/raspberrypi/linux/blob/rpi-4.19.y/include/linux/irqdesc.h
struct irq_desc {
struct irq_common_data irq_common_data;
struct irq_data irq_data;
unsigned int __percpu *kstat_irqs;
irq_flow_handler_t handle_irq;

참고로 이번 절에서 소개한 인터럽트의 주요 개념은 5.2절부터 상세히 살펴볼 예정입니다.

인터럽트란 무엇일까요? 인터럽트란 일반적인 상황에서 갑자기 발생하는 비동기적인 통지나 이벤트라고 볼 수 있습니다. 이번 절에서는 인터럽트라는 용어와 그것의 의미를 알아보겠습니다.

 

일상 생활에서의 인터럽트
인터럽트란 단어가 생소하신가요? 낯설게 들리는 분도 있고 귀에 익은 분도 있을 것입니다. 일상생활에서 인터럽트란 갑자기 생긴 일이나 하던 일을 멈춘다는 의미입니다. 일상적으로 하던 일을 멈추게 하는 무엇인가가 갑자기 발생한 상황을 뜻합니다. 예를 들면, 책을 읽다가 갑자기 전화가 와서 읽던 책을 덮어 놓고 전화를 받는 상황이 그러합니다.

 

하드웨어 관점에서 인터럽트란?
임베디드 시스템 관점에서 인터럽트는 무엇일까요? 먼저 하드웨어 관점에서 생각해 봅시다. 하드웨어 관점에서 인터럽트란 하드웨어의 변화를 감지해서 외부 입력으로 전달되는 전기 신호입니다.

 

한 가지 예를 들어보겠습니다. 손으로 키보드를 치면 하드웨어적으로 키보드 하드웨어의 변화를 감지하고 신호가 발생합니다. 그래서 보통 하드웨어 개발자들은 오실로스코프란 장비로 인터럽트 신호가 제대로 올라오는지 측정합니다.

오실로스코프로 인터럽트 신호를 측정하면 다음과 같은 파형을 볼 수 있습니다. 
 


그림 5.1 인터럽트 파형의 예

참고로 인터럽트 신호는 그림 5.1과 같이 인터럽트를 식별하는 구간에 일정하게 5V(Voltage)를 유지하거나 0V에서 5V로 바뀌는 두 가지 종류로 분류합니다.

CPU 입장에서 인터럽트란?

 


이번에는 소프트웨어 관점에서 인터럽트가 무엇인지 알아봅시다. 인터럽트가 발생하면 프로세스는 하던 일을 멈추고 '이미 정해진 코드'를 실행해서 하드웨어의 변화를 처리합니다. 여기서 '이미 정해진 코드'란 어떤 의미일까요? 인터럽트 벡터와 인터럽트 핸들러를 말합니다.  이처럼 인터럽트가 발생하면 소프트웨어적으로 처리하는 과정을 인터럽트 서비스 루틴(Interrupt Service Routine)이라고 합니다.

이번에는 CPU(ARM) 관점에서 인터럽트를 어떻게 처리하는지 알아봅시다. 인터럽트는 CPU 아키텍처별로 다르게 처리합니다. x86, ARMv7, ARMv8 아키텍처별로 인터럽트를 처리하는 방식이 다른 것입니다. 라즈베리 파이는 ARMv7 기반 아키텍처이므로 ARMv7 CPU에서 인터럽트를 처리하는 과정을 알면 됩니다. 그럼 ARMv7 아키텍처에서는 인터럽트를 어떻게 처리할까요? ARMv7 프로세서에서 인터럽트는 익셉션(Exception)의 한 종류로 처리하므로 익셉션 처리 방식에 대해 알 필요가 있습니다. 

ARMv7 아키텍처에서 익셉션의 동작 원리는 무엇일까요? ARMv7 프로세서는 외부 하드웨어 입력이나 오류 이벤트가 발생하면 익셉션 모드로 진입합니다. ARMv7 프로세스는 익셉션이 발생했다고 감지하면 익셉션 종류별로 이미 정해 놓은 주소로 브랜치합니다. 조금 어려운 개념인데 순간 이동과 비슷한 개념으로 생각해도 좋습니다. 이미 정해진 주소로 브랜치하는 동작은 조금만 생각해보면 그리 낯설지는 않습니다. 어떤 코드에서 함수를 호출할 때 어셈블리 코드로 분석하면 이와 유사한 동작을 합니다.

한 가지 예를 들겠습니다.

https://github.com/raspberrypi/linux/blob/rpi-4.19.y/kernel/sched/core.c
01 asmlinkage __visible void __sched schedule(void)
02 {
...
03 do {
04 preempt_disable();
05 __schedule(false);

05 번째 줄과 같이 __schedule(false) 함수를 호출할 때 어셈블리 코드 관점에서는 어떻게 동작할까요? ARM 코어 프로그램 카운터를 __schedule() 주소로 바꿉니다. 즉, 현재 실행 중인 레지스터 세트를 스택에 푸시합니다.

마찬가지로 ARM 이 익셉션 모드를 감지하면 익셉션 모드별로 정해진 주소로 ARM 코어 프로그램 카운터를 바꿉니다. 이후 실행 중인 코드의 레지스터 세트를 스택에 푸시합니다.

인터럽트나 소프트웨어적인 심각한 오류가 발생하면 ARMv7 프로세스는 ‘이미 정해진 주소’에 있는 코드를 실행합니다. 이미 정해진 주소 코드를 익셉션 벡터(Exception Vector)라 하며, 각 익셉션의 종류에 따라 주소의 위치가 다릅니다. 그런데 ARMv7 프로세서는 인터럽트를 익셉션 벡터 중 하나의 모드로 처리합니다(이 동작은 5.3절에서 상세히 다룹니다).

이제 인터럽트 소개를 마쳤으니 이번에는 인터럽트에 대해 조금 더 자세히 살펴보겠습니다. 임베디드 시스템이나 운영체제에서 '인터럽트를 처리하는 방식'을 논할 때 흔히 “인터럽트 핸들러는 빨리 실행해야 한다.”라는 이야기를 많이 듣습니다. 이는 리눅스 디바이스 드라이버에서도 마찬가지입니다. 그러면 리눅스 커널에서도 인터럽트 핸들러를 빨리 실행해야 하는 이유는 무엇일까요? 가장 큰 이유는 인터럽트가 발생하면 실행되는 코드가 멈추기 때문입니다.

앞으로 여러분이 리눅스 디바이스 드라이버나 커널 코드를 볼 때는 우리가 보고 있고 있거나 실행하는 어떤 커널 코드도 인터럽트가 발생하면 실행이 멈춰서 인터럽트 벡터로 실행 흐름을 이동할 수 있다는 사실을 머릿속으로 그리면서 분석하면 좋겠습니다.

그런데 인터럽트가 발생하면 실행 중인 코드를 멈추고 익셉션 벡터로 이동한다는 사실은 코드만 봐서 이해하기는 어렵습니다. 이를 위해 실습이 필요합니다. 라즈베리 파이 같은 리눅스 시스템에서는 ftrace로 인터럽트의 동작 방식(인터럽트 종류와 인터럽트 발생 빈도)을 확인할 필요가 있습니다. 

공간 지역성과 시간 지역성을 활용해 캐시 알고리즘을 구성할 수 있다고 소개했습니다. 그런데 데이터 구조나 알고리즘을 사용할 때 메모리에 접근하는 패턴을 관찰하면 시간 지역성이나 공간 지역성을 확인하기 어려운 경우가 있습니다. 예를 들어, 링크드 리스트로 데이터를 관리할 때 메모리에 접근하는 패턴을 관찰하면 특정 위치에 있는 데이터에 다시 접근할 확률이 높지 않아 시간 지역성과 같은 특징을 지니지 않습니다. 

 

또한 링크드 리스트는 배열과 달리 인접한 메모리 주소 공간에 접근하지 않아 공간 지역성에도 맞지 않습니다. 그런데 코드를 유심히 분석하면 프로그램은 함수의 호출과 자료구조로 구성돼 있다는 사실을 알 수 있습니다. 자주 사용하는 자료 구조나 알고리즘은 어느 정도 정해져 있습니다. 예를 들어, 링크드 리스트나 스택과 같은 데이터 구조나 알고리즘을 사용해 데이터를 관리하는 패턴을 볼 수 있습니다. 

 

알고리즘의 특징을 활용해 프로그램이 메모리에 접근하는 패턴을 예측할 수 있는데, 이를 알고리즘 지역성이라고 합니다.
이번에는 간단한 예시 코드를 보면서 알고리즘 지역성에 대해 알아봅시다.

struct node {
02 struct node *next; // 다음 노드 주소를 저장
03 int data; // 데이터 저장 필드
04 };
05
06 struct node *curr = head->next; // 링크드 리스트 포인터에 첫 번째 노드의 주소를 저장
07 while (curr != NULL) // 노드의 포인터가 NULL이 아닐 때까지 실행
08 {
09 curr = curr->next; // 포인터에 다음 노드의 주소를 저장
10 process_data(curr); // process_data 함수를 호출해 노드를 처리
11 }

 

위 코드는 링크드 리스트를 구성하는 노드에 접근해 데이터를 처리하는 루틴입니다. 07 ~ 11번째 줄은 노드를 순회하면서 노드의 포인터가 NULL이 아닐 때까지 while 루프를 실행합니다. 그런데 링크드 리스트에 존재하는 자료 구조는 연속적인 메모리 공간에 위치하지 않습니다. 만약 링크드 리스트라는 자료 구조의 패턴을 미리 파악한다면 다음과 같이 처리할 수 있습니다.


06 struct node *curr = head->next; // 링크드 리스트 포인터에 첫 번째 노드의 주소를 저장
07 // 링크드 리스트를 순회하면서 노드 주소를 미리 로딩
08 while (curr != NULL) // 노드의 포인터가 NULL이 아닐 때까지 실행
09 {
10 curr = curr->next; // 포인터에 다음 노드의 주소를 저장
11 process_data(curr); // process_data 함수를 호출해 노드를 처리
12 }


이전 코드와 비교했을 때 달라진 점은 07번째 줄입니다. 07번째 줄은 미리 링크드 리스트를 순회해 노드포인터를 임시로 로딩하는 동작을 표현한 주석입니다. 07번째 줄과 같이 링크드 리스트를 미리 순회해 노드 포인터를 로딩하면 링크드 리스트를 구성하는 노드에 접근하는 08 ~ 12번째 줄의 실행 시간이 더 빨라질 것입니다.


링크드 리스트로 구성된 데이터를 어떤 패턴으로 접근할지는 예측이 가능한데, 이를 위해서는 링크드 리스트라는 자료구조와 알고리즘의 패턴을 이해해야 합니다. 알고리즘의 특징을 활용해 프로그램이 메모리에 접근하는 패턴을 예측하는 과정을 알고리즘 지역성이라고 합니다.


누군가 평소에 자주 들르는 카페에 다시 갈 가능성이 있을까요? 한 번도 안 간 사람보다 다시 갈 확률이 높을 것입니다. 이와 유사하게 프로그램이 특정 명령어나 특정 변수를 반복적으로 사용하는 패턴을 시간 지역성(temporal locality)이라고 합니다. 프로그램이 실행될 때 특정 명령어나 변수에 자주 접근하면 가까운 미래에 사용할 것을 대비해 사용된 명령어나 데이터를 캐시에 미리 로딩합니다.

 

앞에서 예시로 든 예제 코드를 보면서 시간 지역성에 대해 알아봅시다.

01 static int arr[10];
02 int sum = 0;
03 int weight = 10;
04
05 for (i = 0; i < 10; i++) {
06 sum += arr[i] + weight;
07 }


06번째 줄은 arr[] 배열의 &a[0] ~ &a[9] 주소에 있는 배열의 데이터를 읽어서 sum에 더하는 루틴입니다. 데이터에 접근하는 패턴을 자세히 관찰하면 다음과 같은 사실을 확인할 수 있습니다.


weight 지역 변수가 위치한 주소에 반복적으로 접근할 확률이 높다.

 


이런 특징을 활용해 &a[1] ~ &a[9] 주소에 있는 데이터를 로딩할 때 &weight 주소에 있는 데이터를 미리 로딩하면 속도 개선 효과를 얻을 수 있습니다. 이처럼 다시 접근할 것으로 예상되는 데이터를 미리 로딩해 캐시를 설계할 수 있는데, 이를 시간 지역성이라고 합니다.


시간 지역성을 활용한 다양한 알고리즘이 제안됐는데, 그중 프리페칭이 가장 널리 알려진 알고리즘입니다. 프리페칭은 최근에 CPU 코어에서 접근한 주소 접근 이력을 참고해 더 많은 양의 데이터를 미리 페치하는 기법입니다.

 

 

자주 가는 카페가 있다면 카페 옆에 있는 다른 가게에 갈 가능성도 있습니다. 이처럼 프로그램이 어떤 데
이터를 사용하면 그 데이터와 인접한 (주소에 있는) 데이터에 접근할 확률이 높습니다. 이 같은 패턴을 공
간 지역성(spatial locality)이라고 합니다.
【정보】 섹션 정보를 활용한 공간 지역성
프로그램을 컴파일하면 컴파일러는 연관된 객체(자료구조)를 특정 메모리 공간(섹션)에 모아두는 경향이 있습니다.
이번에는 다음 예제 코드를 보면서 공간 지역성에 대해 더 자세히 알아봅시다.
01 static int arr[10];
02 int sum = 0;


03
04 for (i = 0; i < 10; i++) {
05 sum += arr[i];
06 }
05번째 줄은 arr[] 배열의 &a[0] 주소에서 &a[9] 주소에 있는 배열의 데이터를 읽어서 sum에 더하는 루틴입
니다. 데이터의 위치와 데이터에 접근하는 패턴을 자세히 관찰하면 다음과 같은 사실을 확인할 수 있습
니다.
■ &a[0] ~ &a[9] 주소 구간에 위치한 데이터는 연속적인 메모리 공간에 위치한다.
■ &a[0] 주소에 접근하면 &a[1] ~ &a[9] 주소에 접근할 확률이 높다.
이런 특징을 활용해 &a[0] ~ &a[9]가 존재하는 주소에 미리 접근해 데이터를 캐시에 올려 놓으면 for 루프
가 더 빨리 동작할 것입니다. 이처럼 이미 사용된 데이터 주소 근처에 있는 데이터를 미리 로딩하는 방식
을 활용해 캐시를 설계합니다.
이처럼 공간 지역성은 최근 접근한 데이터의 주변 공간에 다시 접근하는 소프트웨어의 패턴을 뜻합니다.
이런 특징을 잘 활용해 연속적인 공간에 존재하는 메모리 데이터를 미리 로딩하면 메모리에 접근하는 횟
수를 줄일 수 있습니다. 이 같은 구조로 설계된 시스템에서는 같은 코드를 실행해도 더 빨리 실행될 수 있
습니다.


【정보】 디맨드 페치란?
공간 지역성을 활용하는 기법 중 하나는 디맨드 페치(demand fetch)입니다. 디맨드 페치란 프로그램이 메인 메모리에
접근해 명령어나 데이터를 캐시에 로딩한 다음에 일정 시간 동안 유지하는 방식을 의미합니다.



이전 절에서는 한 개의 캐시를 기준으로 캐시의 기본 개념을 설명했습니다. Arm 프로세서를 포함한 대부분의 프로세서는 멀티 레벨로 캐시가 구성돼 있습니다. 다음 그림을 보면서 멀티 레벨 캐시에 대해 알아봅시다. 
 


그림 18.2 메모리 계층 구조에서 캐시의 역할 

그림의 왼쪽과 오른쪽에 있는 Core는 말 그대로 CPU 코어를 뜻합니다. Core의 아랫부분을 보면 L1I$와 L1D$ 캐시가 보입니다. 여기서 L1은 레벨 1 캐시(레벨 원으로 발음)라고 하며 L1I$는 L1 명령어(Instruction) 캐시, L1D$는 L1 데이터(Data) 캐시를 뜻합니다.

위와 같이 Arm 프로세서를 비롯한 대부분 프로세서는 캐시가 여러 계층으로 구성돼 있습니다. L1(Level 1: 레벨 1 캐시), L2(Level 2: 레벨 2 캐시)는 대부분 Cortex 프로세서에서 볼 수 있고, L3 캐시까지 있는 프로세서도 확인할 수 있습니다. 그림에서 L3$ 로 명시된 부분은 L3 캐시를 나타냅니다. 

 


[중요] LLC(Last Level Cache) 란?
보통 마지막 레벨의 칩 내에 위치한 캐시를 특별히 LLC(Last Level Cache)라고 부릅니다. LLC 이후는 시간이 매우 오래 걸리는 칩 밖의 메모리 계층으로 이동하므로 특별히 구분합니다.

만약 Arm 코어에서 접근하는 데이터나 명령어가 L1, L2 캐시, 그리고 LLC에 없으면 프로세서 밖에 존재하는 메모리에 접근해 데이터나 명령어를 로딩해야 합니다. 이 과정에서 버스와 메모리 컨트롤러와 같은 하드웨어의 도움을 받아야 하므로 실행 시간이 더 오래 걸립니다. 따라서 LLC에서 가장 중요한 캐시 성능 지표는 캐시 미스로 선정하는 경우가 많습니다.

 



L1 캐시, L2 캐시를 처음 봤을 때 "캐시를 L1으로만 잘 설계하면 되지, 여러 계층의 캐시를 설계하는 이유는 무엇일까?"라는 의문이 생깁니다. 여러 계층의 캐시를 구성하면 프로세서의 성능을 최대한 끌어올릴 수 있기 때문입니다. 캐시의 접근 속도와 용량 사이에 트레이드오프가 있기 마련인데, 멀티 레벨로 캐시를 구성하면 트레이드오프를 최대한 극복할 수 있습니다.

프로세서가 데이터를 요청하면 다음과 같은 순서로 처리합니다. 

1. 먼저 L1 캐시에서 찾는다.
2. 만약 L1 캐시에 데이터가 없으면 L2 캐시에서 찾는다.
3. 그래도 데이터가 없으면 LLC에서 찾는다.
4. 만약 LLC에 도달해도 데이터가 없으면 메인 메모리에 접근해 데이터를 가져 온다.

CPU 코어에 가장 인접한 L1 캐시 내부는 명령어 캐시와 데이터 캐시가 따로 존재합니다. 그다음 레벨인 L2 캐시 이상의 캐시는(L2 캐시, L3 캐시) 하나의 캐시에서 명령어와 데이터를 함께 처리하는 방식으로 설계합니다. 

대부분의 Arm 프로세서는 캐시를 멀티 레벨로 구성하며, 성능을 극대화하는 방향으로 시스템을 설계합니다. 멀티 캐시에 대한 더 자세한 내용은 18.3절 ‘멀티 레벨 캐시’를 참고합시다.

이렇게 해서 캐시를 구성하는 주요 개념을 설명했습니다. 다음 절에서는 캐시와 관련된 알고리즘을 소개합니다.

 

 

 

MMU에서 가상 주소를 물리 주소로 변환하는 구조를 알아봤으니 변환 테이블의 내부 구조와 가상 주소가 물리 주소로 변환되는 실행 흐름을 알아봅시다.


페이지 테이블 엔트리


변환 테이블을 통해 가상 주소를 물리 주소로 변환하려면 먼저 가상 주소의 형식을 알아야 합니다. 가상 주소 영역을 일정한 크기의 블록으로 나눌 수 있는데, 여기서 한 개의 테이블 엔트리는 한 개의 블록에 해당됩니다.

 


그림 19.7 가상 주소가 변환 테이블을 통해 물리 주소로 변환되는 흐름

각각의 엔트리는 물리 메모리에서 해당되는 블록 주소와 물리 주소에 접근할 때 사용할 속성으로 구성돼 있습니다.

변환 테이블은 일종의 엔트리 블록으로 구성돼 있습니다. 변환 테이블에 있는 엔트리 0은 블록 0, 엔트리 1는 블록 1에 대한 매핑 정보를 제공합니다. 또한 각각 엔트리는 물리 주소의 블록에 해당되는 주소와 속성 정보를 담고 있습니다.


페이지 테이블 룩업

테이블 룩업은 변환 작업이 진행될 때 실행됩니다. 운영체제의 프로세스가 주소에 접근하면 주소 변환이 실행됩니다. 이때 가상 주소는 다음 다이어그램과 같이 두 가지 부분으로 나뉩니다.

 


그림 19.8 가상 주소의 구조와 페이지 룩업이 진행되는 흐름

위 다이어그램은 싱글 페이지 룩업 동작을 나타냅니다. ①에서 '엔트리'로 명시된 상위 비트는 찾을 블록 엔트리를 알려주는데, 테이블에 접근하는 인덱스로 활용됩니다. 변환 테이블을 구성하는 각각 엔트리 블록은 가상 주소에 대한 물리 주소의 베이스 주소를 담고 있습니다. 

그림을 통해 전체 구조를 확인했으니 주소가 변환되는 과정을 알아봅시다. 위 그림에서 ①로 표시된 부분은 CPU가 가상 주소를 실행하는 동작을 나타냅니다. CPU에서 실행되는 프로세스 입장에서 바라보는 주소는 가상 주소입니다. 이번에는 화살표와 함께 ②로 표시된 부분은 가상 주소의 베이스 주소를 바탕으로 주소 변환 정보를 담고 있는 페이지 테이블에 접근하는 동작입니다. 페이지 테이블의 시작 주소를 나타내는 TTBR1_EL1 레지스터를 통해 페이지 테이블의 베이스 주소를 참고해 변환 정보가 담긴 페이지 테이블 엔트리의 주소를 찾습니다. 그림의 ③은 페이지 테이블 엔트리 레코드를 읽어서 물리 주소로 변환하는 동작을 나타냅니다. 물리 주소에서 보이는 베이스는 페이지 테이블 엔트리에 있는 주소 정보입니다. ①에서 보이는 '오프셋'으로 명시된 하위 비트는 블록 내부의 오프셋을 나타내며, 변환 과정에서 변경되지 않습니다.


멀티 레벨 페이지 변환


이제까지 한 개의 주소 변환 테이블을 통해 주소가 변환되는 과정을 알아봤습니다. 한 개의 변환 테이블을 통해 한 번에 주소가 변환되므로 이를 싱글 룩업이라고 합니다. 그런데 Aarch64에서는 다수의 변환 페이지 테이블을 사용해 주소 변환 작업이 수행될 수 있는데, 이를 멀티 레벨 페이지 룩업을 통해 가상 주소가 물리 주소로 변환된다고 할 수 있습니다. 

이어서 다음 그림을 보면서 멀티 레벨 페이지 룩업의 실행 흐름을 알아봅시다.
 
출처: Learn the architecture - AArch64 memory management https://developer.arm.com/documentation/101811/latest/

그림 19.9 가상 주소가 멀티 레벨 변환 페이지를 통해 변환되는 과정

ARMv8-A 아키텍처에서 지원하는 최대 페이지 변환 레벨은 4개이며, 실제 레벨의 범위는 0 ~ 3 사이입니다. 멀티 레벨로 구성된 페이지 테이블을 구성하면 큰 사이즈의 블록과 작은 사이즈의 블록을 모두 활용할 수 있습니다. 멀티 레벨 변환 테이블을 구성하는 블록의 특징은 다음과 같습니다.

 큰 블록은 작은 블록보다 페이지 룩업에 걸리는 연산이 더 적습니다.(추가로 큰 블록들은 TLB에 더 효율적으로 캐싱됩니다.)
 작은 블록들은 TLB에서 캐싱할 때 비효율적입니다. 캐싱이 비효율적인 이유는 작은 블록들이 주소를 변환하는 과정에서 메모리에 더 자주 액세스해 데이터 읽기를 시도하기 때문입니다.


[정보] 큰 크기 블록과 작은 크기 블록을 사용할 때의 트레이드오프
큰 사이즈의 블록과 작은 사이즈의 블록으로 구성된 멀티 변환 테이블을 사용해 가상 주소를 변환할 때 트레이드오프가 있습니다. 이는 멀티 레벨 캐시와 비슷한 개념으로 볼 수 있습니다. L1 캐시의 가장 중요한 성능 지표는 캐시 라인을 읽어 오는 속도이고 L2 캐시의 가장 중요한 성능 지표는 캐시 히트 비율입니다. 따라서 큰 사이즈 블록과 작은 사이즈의 블록을 적절히 활용해 멀티 레벨 테이블을 구성할 필요가 있습니다.

 

 



 

Documentation – Arm Developer

 

developer.arm.com

 

 

MMU의 주된 역할은 가상 주소를 물리 주소로 변환하는 일입니다. 그렇다면 MMU는 어떻게 구성돼 있고 어떤 과정으로 가상 주소를 물리 주소로 변환할까요? 다음 그림을 보면서 MMU의 실행 흐름에 대해 알아봅시다.

 
출처: Learn the architecture - AArch64 memory management https://developer.arm.com/documentation/101811/latest/

그림 19.6 MMU를 통해 주소가 변환되는 흐름

프로세스가 가상 주소에 접근하면 먼저 MMU에게 주소가 전달됩니다. MMU는 먼저 TLB(Translation Lookaside Buffer)에 해당 주소에 대한 변환 정보가 있는지 확인합니다. 여기서 TLB는 최근에 사용된 주소 변환 정보를 담고 있는 일종의 캐시입니다. 

만약 가상 주소에 대한 변환 정보가 TLB에 있으면 바로 물리 주소로 변환합니다. 만약 MMU가 TLB 내에 최근에 변환된 테이블 정보를 읽지 못하면 ‘Table Walk Unit’을 통해 메모리로부터 적절한 테이블 엔트리를 읽습니다.

[중요] 캐시에 접근하기 전에 물리 주소로 변환하는 이유

Arm 코어 옆에 캐시가 있는데, 캐시에 접근하려면 먼저 MMU를 통해 가상 주소를 물리 주소로 변환하는 과정이 완료돼야 합니다. 메인 메모리 주소에 접근하기 전에 캐시에 접근해 메모리 주소가 있는 지 체크하기 때문입니다. 이 같은 과정을 캐시 룩업이라고 합니다.


이번에는 다음 그림을 통해 가상 주소와 물리 주소 공간이 어떻게 구성됐는지 알아봅시다. 그리고 가상 주소를 물리 주소로 변환하는 변환 테이블에 대해서도 배워 봅시다.

 

 

 

그림 19.5 소프트웨어 관점에서 가상 주소 공간과 물리 주소 공간

 

그림의 왼쪽 부분부터 봅시다. 왼쪽 부분은 소프트웨어 관점에서 가상 주소 공간을 나타냅니다. 가상 주소에는 각각 영역별로 특정 속성을 지닌 코드와 데이터가 존재합니다. 소프트웨어를 실행하는 주인공인 프로세스는 가상 주소 공간에서 실행됩니다.

 

프로세스가 가상 주소 공간에서 어떤 명령어를 실행하면 메모리에 위치한 변환 테이블에 접근합니다. 이는 그림의 가운데 부분에 해당됩니다. 변환 테이블에 의해 가상 주소는 물리 주소 공간에 접근합니다.

 

여기서 가운데 존재하는 '변환 테이블' 동적으로 업데이트되며, 운영체제에서 실행되는 프로세스마다 각자의 '변환 테이블' 갖게 됩니다. 프로세스마다 가상 주소를 물리 주소로 변환할 있는 '변환 테이블' 있다는 것은 각각 프로세스마다 독립적인 가상 주소 공간에서 실행될 있다고 있습니다.

 

이렇게 해서 전체 그림을 보면서 운영체제와 메모리 구조 관점에서 메모리 매니지먼트가 무엇인지 알아봤습니다. 가상 주소와 물리 주소 공간이 존재할 있게 Arm 아키텍처에서는 MMU 제공하며, 이를 활용해 가상 메모리를 관리합니다. 이어지는 절에서 MMU 대해 자세히 알아봅시다.

리눅스를 비롯한 대부분의 운영체제에서는 가상 메모리를 메모리 관리 기법으로 활용합니다. 이번에는 가상 메모리와 물리 주소에 대해 알아보고 가상 메모리 기법이 적용된 이유를 알아봅시다.

대부분의 운영체제는 다양한 메모리(DRAM) 상에서 실행될 수 있는데, 다음 그림은 가상 메모리 기법을 적용하기 전의 시스템 구조도입니다.
 

그림 19.2 물리 메모리와 메모리 시스템의 관계

물리 메모리가 '물리 메모리 A' ~ '물리 메모리 D'까지 있습니다. 만약 소프트웨어 개발자가 물리 메모리 타입에 따라 주소 오프셋을 변경하는 설정을 하거나 추가로 물리 메모리와 관련된 설정을 하면 소프트웨어의 복잡도가 많이 늘어날 수 있습니다. 물리 메모리에 대한 예외 상황을 점검해야 하니 골치가 아플 것입니다.

하드웨어 측면에서는 다양한 메모리 공급사가 있으며, 대표적인 업체로 삼성전자, 하이닉스, 도시바를 예로 들 수 있습니다. 또한 SoC 업체(브로드컴, 인텔, 퀄컴)별로 서로 다른 물리 메모리 맵을 구성하며, 리눅스 커널와 같은 운영체제 커널이 실행하는 물리 메모리의 주소가 다릅니다. 다음 그림에서 Soc A는 0x8000_0000 물리 주소에 '리눅스 커널' 이미지를 실행하고, SoC B는 0x4000_0000 물리 주소에서 '리눅스 커널' 이미지를 실행합니다.   

 

그림 19.3 SoC 벤더별 메모리 맵

그런데 만약 프로세스 입장에서 물리 메모리별로 서로 다른 주소에 접근한다면 시스템 복잡도가 높아질 것입니다. 물리 메모리별로 무엇인가 따로 설정을 해야 하기 때문입니다. 그런데 가상 메모리 기법을 적용하면 시스템의 전체 구조를 다음과 같이 그릴 수 있습니다. 

 

그림 19.4 가상 메모리 기법을 적용한 메모리 시스템의 전체 구조

시스템 소프트웨어 개발자 입장에서는 가상 메모리 범위 내의 가상 주소 처리에만 신경 쓰면 됩니다. 시스템에 어떤 물리 메모리를 탑재했는지 걱정할 필요가 없습니다.

+ Recent posts