MMU(Memory Management Unit)란 무엇일까요? 가상 주소를 물리 주소로 변환하는 일을 하는 주인공이 MMU입니다. MMU는 하드웨어 블록으로 Arm 프로세서 내부에 존재합니다. MMU를 눈으로 직접 확인하고 싶지만 Arm 코어와 함께 MMU가 내부에 실장돼 있어 확인할 수는 없습니다.

최근에 개발되는 대부분 시스템에서 프로세스는 MMU를 활성화한 상태에서 실행됩니다. 그래서 MMU를 활성화하면 운영체제에서 실행되는 프로세스가 바라보는 주소는 가상 주소입니다. 여기서 ‘가상’이라는 용어는 말 그대로 세상에 존재하지 않는 논리적인 개념으로 볼 수 있습니다. 그런데 가상 주소가 존재하려면 MMU가 있어야 하는데, MMU의 역할은 가상 주소를 물리 주소로 변환하는 것입니다.

그렇다면 MMU는 가상 주소를 물리 주소로 어떻게 바꿀까요? MMU 내에는 TLB(Translation Lookaside Buffer)가 있는데, 이 버퍼는 페이지 테이블 레코드 정보를 담고 있습니다. TLB 에는 최근에 변환된 가상 주소에 대한 페이지 테이블 정보가 들어있고, 이 정보를 참고해 가상 주소를 물리 주소로 변환합니다.

Arm에서 제공하는 메모리 아키텍처에서 MMU는 가장 중요한 기능이므로 잘 익혀둡시다. 좀 더 자세한 내용은 19.2절 'MMU의 세부 동작'에서 설명합니다.


[정보] MMU와 운영체제
대부분 CPU 코어 위에서 운영체제가 실행되는데 CPU의 특징을 잘 알아야 운영체제의 세부 동작 원리도 잘 파악할 수 있습니다. 이를 메모리 관점에서 "운영체제에서 메모리를 관리하는 주요 기능은 CPU에서 지원하는 MMU를 활용한다"라고 설명할 수 있습니다.


 

 

Arm 아키텍처에서 메모리 매니지먼트란 무엇일까요? Arm 아키텍처에서 메모리와 관련된 내용을 다룰 때 '메모리 매니지먼트'와 MMU를 함께 설명하는 경우가 많아 메모리 매니지먼트에 대해 한 문장으로 정의하기 어렵습니다. Arm 아키텍처에서 메모리 매니지먼트는 다음과 같은 기능을 활용해 운영체제 커널에서 메모리 관련 시스템을 구축할 수 있는 환경을 제공하는 것입니다.

 MMU: 가상 주소를 물리 주소로 변환
 메모리 컨트롤 레지스터: MMU와 메모리 시스템 관련 속성 설정 

그렇다면 MMU는 무엇일까요? 가상 주소를 물리 주소로 변환하는 일을 하는 하드웨어 블록을 MMU라고 합니다.

메모리 매니지먼트의 전체 구조

다음 그림을 보면서 메모리 매니지먼트를 구성하는 주요 기능에 대해 알아봅시다.

 

그림 19.1 가상 주소가 물리 메모리에 접근하는 과정

그림에서 먼저 ①로 표시된 부분을 봅시다. ①은 CPU 코어 입장에서 바라보는 주소를 나타내며, 이는 가상 주소입니다. ②로 표시된 박스는 MMU이며, CPU 코어에서 접근하는 가상 주소를 물리 주소로 변환하는 역할을 맡습니다. MMU가 가상 주소를 물리 주소로 변환할 때 주소 변환 테이블 정보가 있는 페이지 테이블을 참고합니다. ③에 표시된 박스는 주소 변환 정보를 지닌 페이지 테이블을 나타냅니다. 

MMU는 CPU 코어가 접근하는 가상 주소인 ①에 해당되는 주소 변환 정보가 ③으로 표시된 페이지 테이블에 있는지 체크합니다. 이 정보가 페이지 테이블에 있으면 ④와 같이 물리 주소가 존재하는 메인 메모리에 접근합니다. 메인 메모리에 존재하는 데이터나 명령어를 로딩하는데, 이는 그림에서 ⑤에 해당됩니다.

만약 CPU 코어가 접근하는 가상 주소의 변환 정보가 페이지 테이블에 없으면 폴트(fault)를 유발하는데, 이는 ⑥에서 표시된 부분에 해당합니다. 익셉션 관점에서 Synchronous 익셉션이 유발됩니다.

소프트웨어 관점에서는 어셈블리 명령어로 구성된 루틴에서 메모리에 접근해 데이터를 읽는 코드를 보면 명령어가 한 줄씩 실행되는 것처럼 보입니다. 하지만 그림 19.1에서 표시된 전체 구조 내에서 주소가 처리됩니다.

[정보] 그림 19.1의 전체 구조에 대해

그림 19.1의 전체 구조는 메모리 시스템이 큰 그림에서 어떤 방식으로 동작하는지를 간단한 구조로 표현한 것입니다. 실제 메모리 시스템은 이보다 더 복잡한 구조로 구성돼 있습니다.


실전 개발에서 메모리 매니지먼트 

'메모리 매니지먼트'와 관련된 기능은 실전 개발에 어떻게 활용될까요? 다음과 같은 업무를 진행할 때 알아야 하는 기반 지식이 메모리 매니지먼트입니다.

 메모리 컨트롤 레지스터 설정
 MMU 관련 레지스터 설정
 캐시나 메모리를 설정하는 명령어 실행
 가상 주소 맵 변경

특히 브링업할 때 메모리 시스템을 구성하는 MMU와 캐시를 설정합니다. 이때 메모리 매니지먼트와 관련된 내용을 알아야 시스템을 제대로 브링업할 수 있습니다.

또한 크래시와 같은 신뢰성 이슈를 잘 해결하기 위한 기반 지식이 메모리 매니지먼트입니다. Arm 아키텍처 관점에서 크래시는 메모리 어보트로 볼 수 있는데, 메모리 어보트로 발생하는 원인의 절반 이상이 메모리 매니지먼트와 관련돼 있습니다. 몇 가지 예를 들면 다음과 같습니다.

 PC 얼라이먼트, SP 얼라이먼트
 메모리 권한 속성 오류로 인한 페이지 폴트
 가상 주소 변환 오류

메모리 매니지먼트를 이루는 지식은 메모리 어보트로 유발된 다양한 문제를 잘 해결할 수 있는 기반 지식입니다.

마지막으로 성능을 최적화하기 위한 기반 지식도 메모리 매니지먼트입니다. L1, L2 캐시 설정, 캐시 정책, 변환 테이블 처리 방식과 같이 메모리 시스템을 설정하는 방식이 성능에 많은 영향을 끼칠 수 있습니다.  

이어서 Arm 아키텍처에서 정의된 동작 모드가 운영체제나 RTOS에서는 어떻게 활용되는지 알아봅시다.

유저 모드는 유저 애플리케이션이 실행되는 모드이고, 슈퍼바이저 모드는 운영제체의 커널이나 커널 드라이버가 실행되는 모드입니다. 

그런데 동작 모드에 대한 내용을 처음 접하면 다음과 같은 다양한 의문이 생깁니다.

동작 모드는 왜 존재하며, 각각 어떻게 활용될까?
진행 중인 프로젝트에서 혹시 운영체제를 사용하지 않는 경우에는 유저 모드만 사용하면 될까?

이 질문에 대해 "프로젝트의 스펙에 맞게 동작 모드를 선택하면 된다"라고 말씀드리고 싶습니다. 

동작 모드를 선택하는 예시

한 가지 예를 들까요? 디스플레이 디바이스에 'Hello, world'만을 출력하는 동작만 수행하는 제품을 만든다고 가정해 봅시다. 키보드나 마우스와 같은 외부 입출력 디바이스에서 인터럽트가 유발되지도 않고 메모리를 설정할 필요도 없습니다. 이런 기능은 슈퍼바이저 모드에서 모두 프로그래밍해도 됩니다. 혹은 유저 모드에서 코드가 실행돼도 상관없습니다.

이번에는 음악만 재생하는 소형 MP3 플레이어를 예로 듭시다. 그런데 소형 MP3 플레이어에는 애플리케이션을 설치할 수 없는 조건입니다. 이처럼 매우 심플한 RTOS가 구동되는 상황이면 이번에도 모든 프로그램을 슈퍼바이저 모드에서 실행되도록 시스템을 설계할 수 있을까요?

그런데 한 가지 중요한 스펙이 제안됐습니다. '소형 MP3 플레이어에 블루투스 기능이 있어야 하는데, 블루투스를 연결할 때 인터럽트가 발생한다'라는 요구사항입니다. 이럴 때는 IRQ 모드에서도 프로그램이 반응해 동작하도록 시스템을 구성해야 합니다.

마지막으로 다양한 유저 애플리케이션을 설치해 실행되는 범용 운영체제를 예로 듭시다. 이 시스템에서는 어떤 모드에서 어떤 소프트웨어 스택이 실행되도록 설계할까요? 

유저 모드에서는 유저 애플리케이션이 구동되고 슈퍼바이저 모드에서는 리눅스 커널과 같은 운영체제의 커널이 실행되도록 시스템을 구성하면 좋을 것입니다. 물론 인터럽트가 발생하면 IRQ 모드로 진입하니 IRQ 모드에서도 인터럽트를 처리하도록 시스템을 설계할 수 있습니다.

그런데 실전 프로젝트에서 개발자가 실수로 NULL 포인터 익셉션을 유발하는 코드를 작성할 수 있습니다. 이러한 코드(명령어)가 실행되면 Arm 코어는 'Data Abort'나 'Prefetch Abort' 익셉션을 유발하면서 Abort 모드에 진입합니다. 이런 조건에서는 시스템의 오류 정보를 출력하고 시스템을 리셋하거나 프로세스를 종료시키는 코드가 구현되면 좋을 것입니다.

이처럼 시스템을 설계하는 개발자는 동작 모드의 특징을 잘 살려서 적절한 모드에 소프트웨어를 구현하면 됩니다. 

[중요] 동작 모드와 특권 레벨과의 관계

시스템을 설계할 때 동작 모드의 특성을 잘 파악하는 것이 중요합니다. 그래서 각 동작 모드가 어떤 PL에서 실행되는지 염두에 둬야 합니다. 유저 모드는 PL0이고 나머지 모드는 PL1으로 실행됩니다.


동작 모드를 선택할 때의 기본 원칙 

이어서 동작 모드를 선택할 때 고려해야 할 세 가지 기본 원칙을 설명하겠습니다. 

첫 번째, PL0에서 PL1으로 자유롭게 진입할 수 없습니다. PL0에서 실행되는 유저 모드에서는 SVC 명령어를 실행해 익셉션(트랩)이 유발돼야 PL1으로 진입할 수 있습니다.

두 번째, PL0에서는 MMU나 IRQ나 FIQ 인터럽트를 직접 설정할 수 없습니다. 하드웨어적으로 시스템을 설정하는 동작은 PL1에서 수행돼야 합니다. 

세 번째, PL1으로 정의된 동작 모드에서는 SPSR 레지스터의 모드 필드에 변경하려는 모드 비트를 설정한 후, 'SUBS PC LR' 혹은 'MOVS PC LR'과 같은 명령어를 실행하면 PL1으로 정의된 동작 모드로 바로 스위칭됩니다. PL1에서 실행되는 동작 모드끼리는 익셉션 없이 스위칭될 수 있습니다.






















이번 절에서는 동작 모드를 소개하고 각 동작 모드에 진입하는 방법을 알아봤습니다. 이어지는 절에서 동작 모드와 관련된 레지스터에 대해 알아봅시다. 

Armv8 아키텍처에서 익셉션 레벨은 어떻게 변경될까요? 크게 3가지 방식으로 익셉션 레벨이 변경됩니다. 

 SVC, HVC, SMC 명령어 실행
 IRQ/FIQ 인터럽트 발생
 ERET 명령어 실행

다음 표를 보면 익셉션 레벨로 진입하는 방법을 확인할 수 있습니다. 

 

표 6.3 Armv8 아키텍처에서 익셉션 레벨로 진입하는 방법


먼저 유저 애플리케이션이 실행되는 EL0에서 진입되는 익셉션 레벨을 알아봅시다.

 

EL0에서 진입하는 익셉션 레벨


EL0에서 EL1으로 진입하는 방법은 크게 3가지로 분류할 수 있습니다. 

첫 번째로는 소프트웨어적으로 EL1에 진입하는 방법입니다. EL0에서 SVC 명령어를 실행하면 EL0 Synchronous 익셉션이 유발되면서 EL1으로 진입합니다. 이런 동작을 운영체제에서는 시스템 콜이라고 합니다. 

또한 EL0는 비특권 레벨(Unprivileged Level)로 실행되므로 시스템 메모리 공간에 직접 접근하거나 인터럽트와 같은 하드웨어를 설정하는 데 제약이 있어 시스템 콜을 통해 하드웨어 리소스에 접근할 수 있는 EL1으로 스위칭됩니다. 운영체제의 커널을 통해 시스템 리소스를 설정하는 방식입니다.

두 번째로는 메모리 어보트를 유발하는 명령어를 EL0에서 실행하면 EL0 Synchronous 익셉션이 유발되면서 EL1으로 진입합니다. 일반적으로 EL0에서 실행되는 유저 애플리케이션이 메모리 어보트를 유발하는 명령어를 실행하면 EL1으로 익셉션 레벨을 변경한 다음, 운영체제 커널은 해당 프로세스를 종료하는 동작을 수행합니다. 다른 관점으로 EL1으로 진입하는 두 번째 방식은 소프트웨어적으로 논리적인 오류가 있는 어셈블리 명령어를 실행하는 것입니다.

[정보] 메모리 어보트는 언제 유발될까?

NULL 포인터 익셉션을 유발하는 코드를 작성하면 메모리 어보트 타입 익셉션이 유발됩니다.


세 번째로는, EL0에서 소프트웨어 루틴이 실행되다가 'IRQ 인터럽트' 익셉션이 유발되면 EL1으로 진입합니다. 달리 말하면 EL0에서 유저 애플리케이션이 실행되다가 인터럽트가 발생하면 EL1으로 진입합니다. 세부 동작은 다음과 같이 분류할 수 있습니다.

1. EL0에서 유저 애플리케이션 실행
2. IRQ 인터럽트 익셉션이 유발됨
3. EL1으로 스위칭
4. EL1에 존재하는 익셉션 벡터 주소로 프로그램 카운터를 분기

여기까지 EL0에서 EL1으로 진입하는 3가지 방식을 알아봤습니다. 

 


[중요] EL0에서 EL1으로 진입하는 동작 방식과 디버깅
사실 실전 프로젝트에서 EL0에서 EL1으로 진입하는 동작 방식을 알면 유저 애플리케이션의 성능 이슈를 디버깅할 때 도움이 됩니다. 유저 애플리케이션을 구성하는 코드를 아무리 열심히  리뷰해도 그 원인을 파악하기가 어려운 상황에 종종 직면합니다. 이럴 때 다음과 같이 시스템 소프트웨어 관점으로 문제를 분석할 수 있습니다.

 

 

 시스템 콜이 발생한 횟수
 유저 애플리케이션이 실행되다가 인터럽트를 처리하는 횟수

또한 전체 소프트웨어 스택의 실행 흐름을 파악하려면 익셉션 레벨로 진입하는 방식을 잘 알면 좋습니다.

EL1에서 진입하는 익셉션 레벨

 

이번에는 EL1에서 EL2로 진입하는 방법을 알아봅시다. EL1에서 'HVC' 명령어를 실행하면 EL1에서 EL2로 진입합니다. EL1에서는 게스트 OS(운영체제 커널)가 구동되는데, 게스트 OS가 여러 게스트 OS의 리소스를 관리하는 하이퍼바이저에게 서비스를 요청할 때 EL2로 진입합니다.

이어서 EL1에서 시큐어 모니터 콜을 유발하는 'SMC' 명령어를 실행하면 EL3로 진입해 모니터 모드로 진입합니다. EL3 모니터 모드는 논 시큐어 상태에서 시큐어 상태로 이동할 때 문지기(게이트키퍼)와 같은 역할을 수행합니다.  

이번 절에서는 Armv8 아키텍처의 익셉션 레벨을 살펴봤습니다. 이어지는 절에서 익셉션 레벨과 관련된 레지스터에 대해 알아봅시다. 

 

< '시스템 소프트웨어 개발을 위한 Arm 아키텍처의 구조와 원리' 저자>
 
 

 

 

Armv8 아키텍처는 PL0 ~ PL3까지 4개의 privilege level(접근 권한)을 정의합니다.  privilege levels은 Armv7 아키텍처에서 다룬 내용과 거의 유사합니다. 

[중요] Privilege level과 유저 애플리케이션

privilege levels은 Arm 아키텍처에서만 지원할까요? 그렇지는 않습니다.

x86을 포함한 대부분의 CPU 아키텍처는 privilege levels과 같은 기능을 지원합니다. 그 이유는 무엇일까요? 주로 리눅스와 같은 범용 운영체제를 설계할 때 시스템에 결함을 일으킬 수 있는 유저 애플리케이션으로부터 시스템을 보호하기 위해서입니다. 리눅스 커널과 같은 운영체제 커널은 privilege level에서 실행되며 privilege level에서만 주요 시스템 설정(시스템 레지스터, 인터럽트, 캐시 접근)을 할 수 있게 권한을 부여합니다. 유저 애플리케이션이 실행될 때는 privilege level 레벨이 아닌 unprivileged level로 실행해, 시스템의 세부 설정을 할 수 없게 설계합니다.

이처럼 privilege level 레벨로 권한을 부여하는 이유는 무엇일까요? 운영체제 커널의 세부 동작을 잘 모르는 유저 애플리케이션 개발자가 privilege level이 없는 환경에서 애플리케이션을 개발한다고 가정해봅시다. 유저 애플리케이션에서 메모리를 제대로 관리하면 괜찮겠지만 실수로 코드를 잘못 작성해서 커널 자료구조나 함수가 위치한 메모리 공간을 오염시키면 어떻게 될까요? 혹은 해커들이 고의로 시스템을 오동작시키기 위한 명령어를 실행하는 애플리케이션을 제작하면 어떤 결과를 초래할까요? 커널 패닉으로 시스템이 리부팅되거나 예상치 못한 시스템 오동작이 일어날 가능성이 있습니다.

따라서 유저 애플리케이션은 unprivileged level로 실행되도록 시스템을 설계합니다. 즉, 유저 애플리케이션에서 시스템 메모리 공간에 직접 접근하지 못하게 제약을 둡니다. 만약 유저 애플리케이션에서 시스템 메모리에 접근을 해야 하는 상황이라면 어떤 방법을 사용하면 될까요? 시스템 콜을 통해 privilege level로 실행되는 운영체제 커널로 이동해 실행 흐름을 스위칭한 다음 메모리에 접근하도록 시스템을 설계할 수 있습니다. 

  

익셉션 레벨과 Privilege Level


EL0에서는 유저 애플리케이션이 unprivileged 레벨 혹은 PL0로 실행됩니다. EL0에서 소프트웨어(유저 애플리케이션)가 실행되면 PL0 권한으로 실행되는데 다음과 같은 특징을 지닙니다.

 EL0에서는 인터럽트, MMU, 캐시 기능을 직접 설정할 수 없다.
 EL0에서는 메모리 접근 권한에 제약이 있다.
 IRQ나 MMU를 설정할 수 없다.
 EL0에서는 시스템 레지스터를 직접 설정할 수 없다. 

만약 EL0에서 시스템 레지스터나 메모리 주소와 하드웨어 리소스를 직접 설정하면 익셉션이 유발됩니다. 

이어서 운영체제의 커널이 구동되는 EL1은 PL1 권한으로 실행되는 익셉션 레벨입니다. EL1에서 구동되는 커널에서는 인터럽트, MMU, 캐시와 같은 하드웨어를 직접 설정할 수 있습니다. EL1은 EL0보다 privilege level이 높으므로 EL0에서 실행되는 코드나 데이터에 접근할 수 있습니다.

PL2는 EL2에게 부여되는 권한 레벨로, EL1으로 실행되는 게스트 OS의 시스템 리소스에 접근할 수 있습니다. 일반적으로 EL2에서 하이퍼바이저가 실행되며, 하이퍼바이저는 PL2 권한 레벨로 실행됩니다. EL2는 EL0 혹은 EL1보다 privilege level이 높아 EL0 혹은 EL1에서 실행되는 코드나 데이터에 직접 접근할 수 있습니다. 

PL2 권한이 있는 EL2는 자신보다 낮은 EL1(PL1)에서 실행되는 각각의 게스트 OS에서 설정된 레지스터에 접근할 수 있습니다. 이를 통해 EL2에서 실행되는 하이퍼바이저는 2개 이상의 게스트 OS의 리소스를 관리할 수 있습니다.


[용어] 하이퍼바이저란?

익셉션 레벨에 대한 설명을 하이퍼바이저 같은 낯선 용어와 함께 읽으면 무슨 내용인지 이해하기 어려울 수 있습니다. 익셉션 레벨에서 EL2를 설명하면 하이퍼바이저라는 용어가 등장합니다. 하이퍼바이저는 데스크톱 PC에서 윈도우와 리눅스를 사용하듯이, 2개 이상의 운영체제를 동시에 실행할 수 있는 아키텍처 혹은 플랫폼입니다. 

다음 그림을 보면 EL2에서 하이퍼바이저가 실행됩니다. 

 


그림 6.1 EL2에서 실행되는 하이퍼바이저 

하이퍼바이저에 대한 자세한 설명은 ‘16장. 가상화’를 참고하세요.

익셉션 레벨별로 접근할 수 있는 레지스터와 Privilege Level


이번에는 익셉션 레벨별로 접근할 수 있는 레지스터에 대해 알아봅시다. 이번 절에서 알아봤듯이 익셉션 레벨은 privilege level과 비례합니다. EL0는 PL0, EL1은 PL1 실행 권한을 지닙니다.

그런데 각 익셉션 레벨에서 접근할 수 있는 레지스터 범위가 한정돼 있습니다. EL1에서는 EL0에서 설정된 레지스터에 접근할 수 있고, EL2에서는 EL1, EL0에서 사용되는 레지스터에 모두 접근할 수 있습니다. 반대로 EL1에서는 EL2에서만 사용되는 레지스터에 접근할 수 없고 EL0에서는 EL1의 레지스터에 접근할 수 없습니다. 자신보다 높은 익셉션 레벨에서 정의된 레지스터에 접근하면 엑세스 권한 위반(Access Violation)으로 감지해 폴트를 유발합니다. 

이처럼 각 익셉션 레벨에 부여된 privilege level 권한 레벨만큼 레지스터에 접근할 수 있는 권한이 있습니다. 이런 특징을 활용해 보안 관점에서 시스템의 리소스를 체계적으로 관리할 수 있습니다.

다음은 EL2에서 실행되는 XEN 하이퍼바이저에서 EL1 레지스터에 직접 접근하는 어셈블리 명령어입니다. 

출처: https://github.com/xen-project/xen/blob/stable-4.15/xen/arch/arm/arm64/entry.S
01 0000000000269ba4 <guest_sync_slowpath>:
02  0x269ba4:    0xd10083ff     sub    sp, sp, #0x20
03  0x269ba8:    0xa9bf77fc     stp    x28, x29, [sp, #-16]!
...
04  0x269c00:    0xd53c4116     mrs    x22, sp_el1
05  0x269c04:    0xd5384037     mrs    x23, elr_el1
06  0x269c08:    0xa9005eb6     stp    x22, x23, [x21]
...
07  0x269c68:    0xa9405eb6     ldp    x22, x23, [x21]
08  0x269c6c:    0xd51c4116     msr    sp_el1, x22
09  0x269c70:    0xd5184037     msr    elr_el1, x23
10  0x269c74:    0x14000174     b    26a244 <return_from_trap>

[정보] guest_sync_slowpath 함수 소개

guest_sync_slowpath 함수는 EL2에서 실행되는 XEN 하이퍼바이저에서 게스트 OS를 관리하는 목적으로 구현된 함수입니다. guest_sync_slowpath 함수는 게스트 OS에서 하이퍼바이저에게 어떤 서비스를 요청할 때 서브루틴으로 호출됩니다. guest_sync 레이블을 통해 guest_sync_slowpath 함수가 호출되는데, XEN 하이퍼바이저 관점의 더 자세한 코드 분석은 15.5 절 'XEN 하이퍼바이저 코드 리뷰'를 참고하세요.


앞에서 소개한 코드는 어느 익셉션 레벨에서 실행될까요? 하이퍼바이저가 실행되는 EL2입니다. 이 점을 염두에 둡시다.

04 ~ 05번째 줄은 sp_el1과 elr_el1 레지스터를 읽는 명령어이고, 08 ~ 09번째 줄은 sp_el1과 elr_el1 레지스터를 설정하는 명령어입니다. 여기서 sp_el1은 EL1에서 접근하는 스택 포인터 레지스터, elr_el1은 EL1에서 접근하는 익셉션 링크 레지스터입니다. 그런데 PL2 권한으로 실행되는 EL2에서는 EL1에서 설정된 sp_el1 혹은 elr_el1 레지스터에 접근할 수 있습니다.

 

PL3 권한으로 실행되는 EL3

마지막으로 PL3는 EL3에 부여되는 권한 레벨로, 가장 높은 특권 레벨(highest privilege level)이라고도 합니다. 시스템 리소스를 모두 설정할 수 있고, 익셉션 레벨에 존재하는 모든 레지스터에 엑세스할 수 있습니다. 

[정보] 시스템이 부팅할 때 가장 먼저 어떤 익셉션 레벨을 설정할까?

시스템에 전원을 인가하면 가장 먼저 부트로더가 실행됩니다. 시스템이 부팅하면 가장 먼저 실행되는 소프트웨어인 부트로더는 EL3로 익셉션 레벨을 설정합니다. 실행 권한이 가장 높은 EL3에서 시스템을 구성하는 주요 기능(MMU, 시스템 레지스터)을 설정합니다. 

부트로더에서 익셉션 레벨을 EL3로 설정하는 다른 이유는 EL2에서 EL3로 진입하려면 익셉션을 유발하면서 EL2에서 실행한 정보를 담고 있는 레지스터 세트를 어딘가에 백업하는 추가 루틴이 실행되어야 하기 때문입니다. 결국 소프트웨어 복잡도가 늘어납니다. 그래서 EL3에서 시스템 리소스를 충분히 설정한 다음 EL3에서 EL2로 익셉션 레벨을 변경합니다. 이어서 EL2에서 EL1으로 익셉션 레벨을 변경하면서 부팅이 진행됩니다.  

시스템이 부팅하는 과정에서 실행되는 소프트웨어 스택은 SoC 칩마다 다르므로 구체적인 내용은 SoC에서 배포한 데이터 시트 문서를 참고하세요.   

익셉션 레벨과 Privilege Level 정리

지금까지 설명한 익셉션 레벨별 특권 레벨은 다음 표로 정리할 수 있습니다.

 

표 6.2 Armv8 아키텍처의 익셉션 레벨과 특권 레벨
 


익셉션 레벨과 권한 레벨은 비례하고 EL0는 가장 낮은 특권 레벨, EL3는 가장 높은 특권 레벨인 익셉션 레벨입니다.

다음 그림에서 익셉션 레벨과 특권 레벨의 관계를 확인할 수 있습니다. 

 


그림 6.2 익셉션 레벨과 특권 레벨

그림을 보면 익셉션 레벨과 Privilege level이 비례한다는 사실을 알 수 있습니다.  

 

 

익셉션 레벨의 실행 흐름과 소프트웨어 스택

 

이번에는 다음 그림을 보면서 각 익셉션 레벨에서 실행되는 소프트웨어 스택과 실행 흐름을 알아봅시다.  


 
그림 6.3 Armv8 익셉션 레벨과 주요 실행 흐름

그림 6.3은 멀티 운영체제가 동시다발적으로 실행되는 하이퍼바이저와 트러스트존 기능이 구현된 아키텍처입니다. 각 익셉션 레벨에서 어떤 소프트웨어가 실행되는지 살펴봅시다.

먼저 그림의 가장 윗부분에 보이는 EL0부터 분석합시다. EL0는 EL0를 나타내며 유저 애플리케이션이 실행됩니다. 이어서 EL0의 아랫부분에 있는 EL1을 보겠습니다. EL1에는 운영체제의 커널이 구동되며, EL1은 PL1 권한으로 실행됩니다. EL1의 아래에는 EL2가 보입니다. 

EL2에는 2개 이상 게스트 OS를 제어하는 하이퍼바이저가 구동되며, EL2는 PL2 권한으로 실행됩니다. 그림에서는 게스트 OS1과 게스트 OS2가 존재하는데, 하이퍼바이저는 2개 이상의 게스트 OS가 동시다발적으로 실행되도록 게스트 OS의 리소스(예: VCPU, 가상 인터럽트)를 관리하는 역할을 수행합니다. 

마지막으로 EL2 아래에 있는 EL3를 봅시다. EL3는 가장 높은 특권 레벨(highest privilege) 권한, 즉 시스템의 모든 기능(레지스터, 메모리, 캐시)을 설정할 수 있는 가장 높은 권한으로 실행됩니다. 보통 부팅 과정에서 실행되는 부트로더에서 EL3로 설정한 다음에 시스템의 여러 리소스(메모리, 시스템 레지스터)를 설정합니다. 
 
여기까지 Armv8 아키텍처의 근간인 익셉션 레벨을 살펴봤습니다. 이번 절에서 소개한 익셉션 레벨은 Armv8 아키텍처에서 가장 중요한 내용이니 잘 기억해 둡시다. 이어지는 절에서는 익셉션 레벨로 어떻게 진입하는지 살펴보겠습니다.

 

< '시스템 소프트웨어 개발을 위한 Arm 아키텍처의 구조와 원리' 저자>
 
 

 

 

익셉션 레벨은 ‘익셉션’과 ‘레벨’의 합성어로 익셉션의 레벨로 해석할 수 있습니다. 그런데 ‘익셉션 레벨’은 ‘익셉션의 레벨’과는 조금 다른 의미를 지니는 용어입니다. 먼저 익셉션 레벨을 소개합니다.

다음과 같은 Arm 스펙 문서의 내용을 보면서 익셉션 레벨에 대해 알아봅시다.

출처: Arm Architecture Reference Manual Armv8, for A-profile architecture https://developer.arm.com/documentation/ddi0487/gb 
D1.1 Exception levels

The ARMv8-A architecture defines a set of Exception levels, EL0 to EL3, where:
• If ELn is the Exception level, increased values of n indicate increased software
   execution privilege.
  • Execution at EL0 is called unprivileged execution.
  • EL2 provides support for virtualization of Non-secure operation.
• EL3 provides support for switching between two Security states, Secure state 
  and Non-secure state.

An implementation might not include all of the Exception levels. All implementations must include EL0 and EL1. EL2 and EL3 are optional.

스펙 문서의 내용은 다음과 같이 정리할 수 있습니다.

 

   
ARMv8-A 아키텍처는 EL0에서 EL3까지 익셉션 레벨을 다음과 같이 정의한다.

 ELn을 익셉션 레벨이라 하며, n은 PL(Privilege Level)을 뜻한다.
 EL0는 Unprivileged Execution이다.
 EL2는 논 시큐어 동작에서 가상화를 지원하기 위한 익셉션 레벨이다.
 EL3는 논 시큐어 상태와 시큐어 상태를 전환하는 시큐어 모니터가 실행되는 익셉션 레벨이다.
 (운영체제나 RTOS를 구현할 때) 모든 익셉션 레벨을 구현해야 할 필요는 없다. EL0와 EL1은 필수 구현 사항이고, EL2, EL3는 옵션으로 반드시 구현할 필요는 없다.

 



앞에서 소개한 스펙 문서의 내용은 명료하게 익셉션 레벨을 설명합니다. 하지만 입문자는 위 내용을 읽고 익셉션 레벨을 바로 이해하기 어렵습니다. 이제부터 익셉션 레벨에 대해 더 자세히 알아봅시다.

 

익셉션 레벨 추가 분석


앞에서도 다룬 바 있지만 복습하는 차원에서 설명하자면 먼저 EL은 익셉션 레벨(Exception Level)의 약자입니다. 사실 익셉션 레벨은 ELn 혹은 ELx로 명시하며, 'EL'의 접미사로 있는 n이나 x는 익셉션 레벨의 단계를 뜻합니다. 여기서 n이나 x 값의 범위는 0 ~ 3입니다.

EL0는 익셉션 레벨 0, EL1은 익셉션 레벨 1이라고 부르며 익셉션 레벨은 EL0, EL1, EL2, 그리고 EL3까지 존재합니다.   

n이나 x 값의 범위는 0과 3 사이인데, 이 정숫값은 PL(Privilege Level)과 비례합니다. EL0는 PL0이고 EL1은 PL1입니다. EL0는 비특권 레벨(Unprivileged Level) 혹은 PL0로 실행되는 익셉션 레벨입니다. PL0 혹은 비특권 레벨에서는 시스템 메모리에 직접 접근할 수 없고, MMU나 인터럽트와 같은 시스템 설정은 할 수 없는 권한을 지닌 익셉션 레벨입니다. 

또한 스펙 문서에서 "EL0와 EL1은 필수 구현 사항이고, EL2, EL3는 반드시 구현할 필요는 없다"라고 명시한 부분이 있습니다. 일반적으로 하나의 운영체제만 실행되는 시스템에서는 유저 애플리케이션이 구동되는 EL0와 운영체제 커널이 구동되는 EL1만 구현하면 됩니다.

게스트 OS 혹은 가상 머신을 실행하는 하이퍼바이저에서는 EL2는 구현해야 하고, 트러스트존을 활용해 시큐어 운영체제(Secure OS)를 사용하는 경우에만 EL3를 구현해야 합니다.

 


[정보] 트러스트존과 시큐어 운영체제
트러스트존은 CPU 내에 신뢰할 수 있는 실행 영역을 확보하기 위한 보안 아키텍처입니다. 시큐어 운영체제는 트러스트존에서 정의된 시큐어 월드에서 실행되는 보안 소프트웨어입니다. 자세한 내용은 15장 ‘트러스트존’을 참고하세요.
달리 보면 EL2와 EL3는 추가로 구현되는 옵션으로 볼 수 있습니다. 그런데 "EL2, EL3는 반드시 구현할 필요는 없다"라는 내용을 읽으면 다음과 같은 의문이 생깁니다.

 


여기서 EL2와 EL3를 구현해야 한다는 것은 무엇을 의미할까?

먼저 익셉션 레벨을 운영체제나 RTOS에서 사용하려면 ELx(익셉션 레벨)별로 시스템 설정을 해야 합니다. 이를 위해 기본적으로 다음과 같은 기능을 구현해야 합니다.

 각 익셉션 레벨별로 익셉션 핸들러를 구현
 각 익셉션 레벨별로 사용되는 스택을 설정
 익셉션 핸들러의 베이스 주소를 VBAR_ELx 레지스터를 통해 설정
 관련 시스템 레지스터 및 MMU 설정

이 같은 기본 기능을 구현해야 EL1에서 EL2나 EL3로 실행 흐름이 이어집니다.

운영체제에서는 익셉션 레벨이 어떻게 활용될까?

이어서 익셉션 레벨이 운영체제에서 어떻게 활용되는지 다음 Arm 스펙 문서를 보면서 알아 봅시다.

 



출처: Arm Architecture Reference Manual Armv8, for A-profile architecture https://developer.arm.com/documentation/ddi0487/gb 
D1.1.1 Typical Exception level usage model

The architecture does not specify what software uses which Exception level. Such choices are outside the scope of the architecture. However, the following is a common usage model for the Exception levels:

 EL0: Applications.
 EL1: OS kernel and associated functions that are typically described as privileged.
 EL2: Hypervisor.
 EL3: Secure monitor.

 



스펙 문서의 내용은 다음과 같이 요약할 수 있습니다.


Arm 아키텍처는 SW(운영체제)에서 어떤 익셉션 레벨을 사용할지 명시하지는 않는다. 이런 결정은 아키텍처의 범위를 범어선다. 하지만 대부분의 소프트웨어는 다음과 같은 익셉션 레벨에서 실행된다. 

 EL0: 유저 애플리케이션 
 EL1: 운영체제 커널(특권 권한으로 실행되는 함수) 
 EL2: 하이퍼바이저
 EL3: 시큐어 모니터 

 



두 개 이상의 게스트 OS를 실행하는 하이퍼바이저를 구현하지 않는 경우 EL2는 필수로 구현할 필요가 없습니다. 여기서 시큐어 모니터는 Armv8 아키텍처에서 도입된 실행 영역으로 EL3에서 실행됩니다. 시큐어 모니터는 논 시큐어 월드와 시큐어 월드 사이를 이동하기 위한 용도(Gatekeeping)로 도입된 실행 영역으로, 트러스트존 기능을 활성화해 보안 환경을 구축하려는 시스템에서만 사용됩니다.

EL2와 EL3는 필수 구현 항목은 아니므로 일단 ‘EL0에서는 유저 애플리케이션이 실행되고, EL1에서는 운영체제 커널이 실행된다’라는 정도로 기억해 둡시다. 

 

[중요] 익셉션 레벨와 Armv7의 동작 모드

익셉션 레벨에 대한 이해를 돕기 위해 Armv7 아키텍처의 동작 모드를 어떤 방식으로 활용하는지 함께 알아봅시다.

Armv7 아키텍처에서 유저 애플리케이션은 유저 모드에서 실행됩니다. 마찬가지로 Armv8 아키텍처에서는 EL0에서 유저 애플리케이션이 구동됩니다. 또한 Armv7 아키텍처에서 운영체제 커널은 슈퍼바이저 모드, Armv8 아키텍처에서는 EL1에서 운영체제 커널이 실행됩니다.

 

 


익셉션 레벨과 기능 정리

다음 표를 보면서 익셉션 레벨에 대해 배운 내용을 정리합시다.

 


 
어셈블리 명령어를 보면 ELR_EL1 혹은 SP_EL2와 같이 레지스터의 접미사가 많이 보입니다. 익셉션 레벨과 같이 정의된 이 같은 레지스터를 보면 위 표에서 소개한 익셉션 레벨이 떠올랐으면 좋겠습니다.

 

 

< '시스템 소프트웨어 개발을 위한 Arm 아키텍처의 구조와 원리' 저자>
 
 

이번에는 Armv7 아키텍처 관점에서 AAPCS를 SP 레지스터와 LR 레지스터가 어떻게 바뀌는지 기준으로 설명합니다. 하지만 대부분의 시스템 소프트웨어 개발자는 SP와 LR 레지스터를 설정하는 어셈블리 명령어보다 C 언어로 프로그래밍합니다. 그래서 SP와 LR 레지스터를 보면 대부분 낯설게 느낍니다. 
 
그래서 이번 절에서는 함수를 호출하는 예제 코드를 분석하면서 함수를 호출했을 때 SP와 LR 레지스터가 어떻게 바뀌는지 알아보겠습니다.

함수가 호출될 때의 세부 동작 원리

다음과 같은 함수를 작성했다고 가정하겠습니다.

01 int add_func(int x, int y)
02 {
03    int result = x + y;
04    printf("x:%d, y:%d \n", x, y);
05   
06    return result;
07 }

add_func 함수를 작성하면 다음과 같이 동작할 것이라 예상합니다.

 다른 함수에서 add_func 함수를 호출할 수 있다.
 어떤 함수가 add_func 함수를 호출해도 add_func 함수를 호출한 코드로 복귀한다.

즉, add_func 함수는 다양한 상황에서 다른 함수로부터 호출될 수 있습니다. 예를 들면, for 문 내에서 반복적으로 호출되거나 다른 함수에서 여러 번에 걸쳐 호출될 수 있습니다. 또한 C 언어로 작성된 코드뿐만 아니라 어셈블리 명령어를 통해 함수가 호출될 수 있습니다. add_func 함수가 자신의 기능을 독립적으로 실행하려면 다음과 같은 요구사항을 만족해야 합니다.

 먼저 add_func 함수를 호출한 주소를 어딘가에 저장해 놓는다.
 add_func 함수를 호출한 주소로 복귀한다.

모듈 단위로 독립적인 기능을 수행하는 함수가 호출되려면 Arm 아키텍처의 도움이 필요합니다. 함수를 호출할 때 Arm 아키텍처는 다음과 같이 동작합니다.

함수를 호출하면 함수를 호출한 후 복귀할 주소를 R14 레지스터에 저장한다.

그렇다면 함수를 호출하면 복귀할 주소가 R14(링크 레지스터)에 어떤 방식으로 업데이트될까요? 서브루틴(함수, 레이블)이 호출될 때 'BL [함수 주소]'와 같은 명령어가 실행되는데, Arm 코어는 서브루틴을 호출한 후 복귀할 주소를 링크 레지스터인 LR 레지스터에 업데이트합니다. 그래서 서브루틴이 호출되면 가장 먼저 실행되는 명령어는 다음과 같습니다.

R14 레지스터(복귀할 주소가 저장됨)의 값을 프로세스의 스택 공간에 푸시(저장)

서브루틴에서 자신의 기능을 마무리한 후 프로세스의 스택에 저장된 링크 레지스터의 값을 다시 PC에 넣으면 서브루틴을 호출한 주소로 복귀합니다.

R14 레지스터의 동작 원리

이어서 다음 예제 코드를 보면서 R14 레지스터의 동작 원리를 알아봅시다.

01  0x1004 SOME_ROUTINE:  PUSH    {FP, LR}
02  0x1008                 ; // 명령어
03  0x100c                 BL SUB_ROUTINE
04  0x1010                 ; // 명령어
05  0x1014                 POP     {FP, PC}

01번째 줄에 있는 SOME_ROUTINE은 호출되는 함수나 레이블을 나타내는 심벌 이름입니다. 01번째 줄의 'PUSH {FP, LR}' 명령어가 실행되면 FP(R11)와 LR(R14) 레지스터의 값을 프로세스의 스택에 백업(푸시)합니다.

이어서 03번째 줄에서는 'BL SUB_ROUTINE' 명령어를 실행합니다. 여기서 03번째 줄의 주소는 0x100c입니다. 이 명령어를 실행하면 SUB_ROUTINE으로 분기한 다음에 복귀할 주소인 0x1010가 LR(R14) 레지스터에 업데이트됩니다. 0x1010은 3번째 줄 다음에 있는 주소이며, SUB_ROUTINE의 실행이 마무리된 다음에 바뀌는 프로그램 카운터입니다.

[정보] “//명령어” 주석의 의미

02 ~ 04번째 줄의 주석인 “//명령어”는 주소에 있는 명령어를 표기한 것입니다.


05번째 줄은 01번째 줄에서 프로세스의 스택에 백업한 FP(R11)와 LR(R14) 레지스터의 값을 다시 FP(R11)와 LR(R14) 레지스터에 로딩하는 동작입니다.

앞에서 든 예제와 같이 함수를 어느 코드(함수, 레이블)에서 호출하든 함수가 실행한 다음에 복귀할 주소를 LR(R14) 레지스터가 알고 있기 때문에 함수가 독립적인 하나의 기능으로 실행될 수 있습니다.

함수에 전달되는 인자는 어떻게 처리될까?

대부분의 함수는 인자를 받아서 이를 처리하는 구조입니다. 그럼 함수에 전달되는 인자는 어떤 방식으로 처리될까요? 바로 R0 ~ R3 레지스터에 실려서 전달됩니다. 다음 그림을 보면서 더 자세히 알아 봅시다.

 


그림 12.2 함수가 호출될 때 사용되는 R0 ~ R3와 R0 레지스터

그림 12.2의 왼쪽 부분을 보면 a, b, c, d 인자를 적용해 sub_rountine 함수를 호출합니다. 이처럼 sub_rountine 함수에 전달되는 인자는 R0 ~ R3 레지스터에 저장됩니다.

이번에는 그림 12.2의 오른쪽에 있는 sub_rountine 함수의 가장 아랫부분을 봅시다. 'return result;' 구문이 보이는데, 이는 result 값을 반환하는 동작입니다. 이때 R0 레지스터에 result 값이 실려서 sub_rountine 함수를 호출한 코드의 res 지역 변수에 전달됩니다.

[주의] 8바이트 단위 리턴 처리 방식

함수의 리턴 값 타입이 4바이트 단위면 R0 레지스터에 리턴 값을 저장합니다. 만약 리턴 값을 반환하는 변수의 타입이 8바이트 단위면 R0 레지스터로는 부족합니다. R0 레지스터는 4바이트 단위의 데이터를 저장하기 때문입니다. 그래서 64바이트 크기의 리턴 값은 R0 ~ R1 레지스터에 저장됩니다. sub_routine 함수의 아랫부분에 보이는 R0 ~ R1이 바로 이 부분을 나타냅니다.


함수에 전달되는 인자는 R0 ~ R3 레지스터에 저장되는데, 여기서 R0 ~ R3는 R0, R1, R2, R3 레지스터이니 레지스터의 개수가 4개입니다. 이 내용을 읽으면 "함수에 전달되는 인자는 무조건 R0 ~ R3 레지스터에 저장된다"라고 오해할 수 있습니다. 다음 표를 보면서 조금 더 자세히 설명하겠습니다.

인자의 개수 인자를 저장하는 레지스터

 

표 12.2 함수에 전달되는 인자의 개수별로 사용되는 레지스터

표 12.2를 보면 함수에 전달되는 인자의 개수에 따라 사용되는 레지스터가 다릅니다. 함수에 전달되는 인자의 개수가 1개이면 R0 레지스터에 인자의 값이 저장됩니다. 만약 인자의 개수가 2개면 R0와 R1 레지스터에 각각 인자의 값이 저장됩니다. 하지만 함수에 전달되는 인자가 언제나 R0 ~ R3 레지스터에 저장되지는 않습니다. 함수에 전달되는 인자의 개수가 5개 이상이면 1~4번째 인자까지는 R0 ~ R3 레지스터에 저장되고, 5번째 인자부터 프로세스의 스택 공간에 저장됩니다.  

예제 코드 분석: 함수에 전달되는 인자의 처리 방식

이번에는 다음 예제 코드를 보면서 함수에 전달되는 인자가 어떻게 처리되는지 알아봅시다. 

01 int add_func(int x, int y)
02 {
03    int result = x + y;
04    printf("x:%d, y:%d \n", x, y);
05   
06    return result;
07 }
    
add_func 함수는 2개의 인자를 받아서 두 인자의 값을 더한 결과를 반환합니다. 이때 add_func 함수의 인자인 x와 y는 각각 R0와 R1 레지스터에 실려서 전달됩니다. 또한 add_func 함수는 'return result;' 구문과 함께 result를 반환합니다. 함수가 반환하는 값인 result는 R0 레지스터에 저장됩니다.

이번에는 다음 그림을 보면서 함수에 전달되는 인자와 함수가 반환하는 값이 어느 레지스터에 전달되는지 정리해 봅시다.

 


그림 12.3 add_func 함수가 호출될 때 사용되는 R0 ~ R1과 R0 레지스터

그림 12.3은 main 함수에서 add_func 함수를 호출하는 동작을 나타냅니다. 그림의 왼쪽 부분을 보면 a와 b라는 인자를 사용해 add_func 함수를 호출합니다. 이때 함수에 전달되는 인자는 add_func 함수에서 이 인자를 사용할 수 있게 R0와 R1 레지스터에 저장됩니다.

이번에는 그림 오른쪽에 있는 add_func 함수의 가장 아랫부분을 봅시다. 'return result;' 구문이 실행되면 R0 레지스터에 result 값이 실려서 add_func 함수를 호출한 코드의 res 지역 변수에 전달됩니다.

정리

이제 AAPCS 관점에서 정리하면 함수가 호출될 때의 세부 동작은 다음과 같이 분류할 수 있습니다.

 


표 12.3 AAPCS 관점에서 본 함수가 호출될 때의 세부 동작
함수 호출 동작
 AAPCS 관점 동작
함수 호출과 관련된 세부 동작 Armv7 - AAPCS 동작
함수에 인자를 전달
R0 ~ R3 레지스터에 저장
함수가 기능을 수행한 결과를 반환 R0 레지스터에 저장
함수를 호출한 다음에 복귀할 주소 링크 레지스터(R14)에 업데이트


세부 구현 방식을 단계별로 살펴보니 함수 호출은 Armv7 아키텍처의 도움을 받아 실행된다는 사실을 알 수 있었습니다.

Armv7 아키텍처에서 정의된 범용 레지스터 중 SP(R13) 레지스터와 LR(R14) 레지스터는 AAPCS와 연관된 핵심 레지스터입니다. 먼저 범용 레지스터 가운데 SP, LR 레지스터의 역할을 알아봅시다.

SP와 LR 레지스터의 역할

먼저 다음 그림을 보면서 Armv7 아키텍처에서 정의된 레지스터 중 AAPCS와 연관된 레지스터를 알아봅시다.

 
그림 12.1 Armv7 아키텍처의 레지스터 중 AAPCS와 연관된 레지스터 

위 그림은 Armv7 아키텍처에서 동작 모드별로 정의된 레지스터 목록입니다. 그림의 아랫부분에 빗금으로 표시된 부분을 봅시다.

해당 부분의 윗부분에 있는 SP 레지스터를 보겠습니다. 그림의 가장 왼쪽에 SP가 있는데, 같은 행에 각 동작 모드별로 SP_usr, SP_svc, SP_abt 레지스터가 있습니다. 그중 SP_svc는 슈퍼바이저 모드에서만 사용되는 SP 레지스터, SP_irq는 IRQ 모드에서만 접근하는 SP 레지스터입니다.

SP의 아랫부분을 보면 LR이 있는데, 이는 링크 레지스터(Link Register)를 나타냅니다. 보다시피 LR 레지스터 또한 각 동작 모드별로 존재합니다. 예를 들어, LR_svc는 슈퍼바이저 모드에서만 사용되는 LR 레지스터, LR_irq는 IRQ 모드에서만 접근되는 LR 레지스터입니다. 

[중요] 동작 모드별 뱅크드 레지스터

Armv7 아키텍처에서 각 동작 모드별로 뱅크드 레지스터가 존재합니다. 뱅크드 레지스터는 특정 동작 모드에서 전용으로 사용되는 레지스터입니다. 예를 들어 슈퍼바이저 모드에서 R13 레지스터가 바뀌면 R13_svc 레지스터가 함께 변경됩니다. 이와 마찬가지로 슈퍼바이저 모드에서 R14 레지스터가 바뀌면 R14_svc 레지스터가 함께 변경됩니다.

뱅크드 레지스터가 동작 모드별로 존재하면 동작 모드가 변경됐을 때 레지스터를 백업할 필요가 없어 간결하게 소프트웨어 구조를 설계할 수 있습니다. 


Arm 스펙 문서에서 본 SP 레지스터

이번에는 Arm 스펙 문서를 보면서 SP와 LR 레지스터에 대해 알아봅시다. 먼저 SP 레지스터에 대한 내용을 소개합니다.   

출처: ARM® Cortex™-A Series Programmer's Guide https://developer.arm.com/documentation/den0013/latest 
3.1 Registers
R13 (in all modes) is the OS stack pointer, but it can be used as a general purpose register when not required for stack operations.

위 내용은 다음과 같이 해석할 수 있습니다.

 R13은 운영체제의 스택 포인터(모든 모드에서 사용)다.
 R13은 스택 연산을 수행하지 않을 때 범용 레지스터처럼 사용될 수 있다.

그런데 Arm 스펙 문서의 내용 중 'OS stack pointer'로 적힌 부분이 있는데, 조금 포괄적인 의미를 지니고 있어 의도를 정확히 파악하기 어렵습니다. 여기서 'OS stack pointer'는 "프로세스의 스택 주소를 가리킨다"라고 해석하면 됩니다. 대부분의 운영체제는 프로세스 중심으로 실행되고, 어떤 함수나 레이블을 실행하거나 호출하던 프로세스의 스택 공간 내에서 실행되기 때문입니다. 

이어서 SP 레지스터에 대해 추가로 설명된 내용을 보겠습니다.

출처: ARM® Cortex™-A Series Programmer's Guide https://developer.arm.com/documentation/den0013/latest 
3.1 Registers
For example, a program executing in User mode which specifies R13 will access R13_usr. A program executing in SVC mode which specifies R13 will access R13_svc.

위 내용은 다음과 같이 요약할 수 있습니다.

유저 모드에서 실행되는 프로그램에서 R13을 지정하면 R13_usr가, 슈퍼바이저 모드에서 실행되는 프로그램에서 R13을 지정하면 R13_svc가 함께 업데이트된다.

Armv7 아키텍처에서 어떤 어셈블리 코드를 실행하든 동작 모드(슈퍼바이저, 유저 등등)에서 실행됩니다. 위 내용은 각 동작 모드에서 R13에 접근하면 해당 모드별로 정의된 R13 레지스터도 함께 액세스된다는 의미입니다. 이해를 돕기 위해 다음과 같은 예를 들겠습니다.

 유저 모드인 경우: R13 레지스터에 접근하면 R13_usr 레지스터에도 액세스
 슈퍼바이저 모드인 경우: R13 레지스터에 접근하면 R13_svc 레지스터에도 액세스

[정보] AAPCS의 핵심 원리와 디버깅

사실 위 내용을 읽으면 잘 와 닿지 않는데, TRACE32 같은 하드웨어 디버깅 장비를 사용해 확인하면 바로 이해할 수 있습니다. 다음 동영상 링크를 보면 TRACE32로 각 모드별로 R13 레지스터에 접근하는 내용을 확인할 수 있습니다. 
- TRACE32로 프로세스의 cpu_context(컨텍스트) 확인해보기: - https://youtu.be/pObn1fb-1M0


Arm 스펙 문서에서 본 LR 레지스터

이어서 스펙 문서 분석을 통해 LR 레지스터에 대해 알아봅시다.

출처: ARM® Cortex™-A Series Programmer's Guide https://developer.arm.com/documentation/den0013/latest 
3.1 Registers 
R14 (the Link Register) holds the return address from a subroutine entered when you use the branch with link (BL) instruction. It too can be used as a general purpose register when it is not supporting returns from subroutines. 

LR 레지스터를 명쾌하게 설명했는데, 내용을 요약하면 다음과 같습니다.

 LR은 BL 명령어와 함께 서브루틴(함수, 레이블) 주소로 분기했을 때 서브루틴에서 복귀할 주소를 저장하는 레지스터다.
 서브루틴으로 복귀하는 상황이 아니면 범용 레지스터로 사용될 수 있다.


[정보] 서브루틴이란?

Arm 스펙 문서를 보면 서브루틴(subroutine)이란 용어가 보입니다.

출처: https://developer.arm.com/documentation/dui0473/c/writing-arm-assembly-language/subroutines-calls
Subroutines calls
A subroutine is a block of code that performs a task based on some arguments and optionally returns a result. By convention, registers R0 to R3 are used to pass arguments to subroutines, and R0 is used to pass a result back to the callers. A subroutine that needs more than 4 inputs uses the stack for the additional inputs.

위 스펙은 Armv7 기반으로 서브루틴 호출(subroutines call)을 설명합니다. 여기서 서브루틴은 함수 호출이나 레이블로 분기하는 동작을 포함하며, 일반적으로 함수 호출을 서브루틴으로 표현할 때가 많습니다. 


SP 레지스터와 마찬가지로 LR 레지스터도 각 모드별로 LR_<mode> 레지스터가 존재합니다. 예를 들면 다음과 같습니다.

 IRQ 모드인 경우: R14 레지스터에 접근하면 R14_irq 레지스터에도 액세스
 슈퍼바이저 모드인 경우: R14 레지스터에 접근하면 R14_svc 레지스터에도 액세스

[중요] 동작 모드별로 SP 레지스터와 LR 레지스터가 존재하는 이유

Arm 프로세서에 대해 깊게 분석하면 동작 모드별로 SP와 LR 레지스터가 존재하는 이유를 알게 됩니다. 그 이유는 다음과 같이 설명할 수 있습니다. 

SP 레지스터와 LR 레지스터는 동작 모드별로 실행되는 프로세스의 실행 흐름을 저장하는 중요한 역할을 수행한다.

동작 모드를 변경하면 이전 동작 모드에서 실행됐던 레지스터를 어딘가에 백업해야 합니다. 그런데 각 동작 모드별로 SP 레지스터나 LR 레지스터가 없으면 동작 모드별로 실행된 레지스터를 스택과 같은 메모리 공간에 백업하고 로딩하는 루틴이 실행돼야 합니다. 그 결과, 시스템의 복잡도가 늘어납니다. 그래서 프로세스의 실행 흐름 정보를 저장하는 SP와 LR 레지스터가 각 동작 모드별로 존재합니다.


이처럼 SP와 LR 레지스터는 Armv7 아키텍처에서 정의된 다른 범용 레지스터와는 달리 서브루틴을 호출하기 위해 사용됩니다. 단순히 어셈블리 명령어를 실행한 결과를 저장하는 임시 저장용 메모리 공간은 아닙니다.

SP와 LR 레지스터의 표기 방식과 주요 동작 정리

이번에는 SP와 LR 레지스터의 표기 방식과 주요 동작을 표로 정리해 보겠습니다.

표 12.1 SP와 LR 레지스터의 특징
레지스터 특징
SP(Stack Pointer)  - R13으로도 표기
- 사용 중인 (프로세스의) 스택 위치를 저장 
- 동작 모드별로 SP 레지스터가 존재

LR(Link Register) - R14로도 표기
- 서브루틴을 수행(함수 호출)한 후 복귀할 주소를 저장 
- 동작 모드별로 LR 레지스터가 존재


어셈블리 명령어 중에서 SP(R13)와 LR(R14) 레지스터를 보면 위 표의 내용이 머릿속에 떠올랐으면 좋겠습니다.

이번에는 다음과 같은 예시 명령어를 보면서 메모리 리오더링에 대해 자세히 알아봅시다.

 

그림 17.4 메모리 리오더링의

 

01 ~ 04번째 줄은 모두 스토어 명령어입니다. 01 ~ 04번째 명령어가 실행될 , X1 레지스터의 값이 0x10000이라고 가정합시다. 또한 명령어가 실행될 워드의 단위는 8바이트입니다.

 

먼저 01번째 줄을 분석하겠습니다. X1 레지스터가 0x10000이니 0x10000 주소에 X0 레지스터의 (8바이트 크기) 저장합니다. 01번째 줄이 실행되면 바이트는 '1' 업데이트됩니다. 이는 '1' 표기된 박스에 해당합니다. 이어서 02번째 줄을 봅시다. 같은 0x10000 주소에 2바이트 사이즈의 데이터를 저장합니다. 여기서 01번째 줄에서 실행된 0x10000 주소의 데이터가 업데이트 됩니다. 부분은 2 표기된 박스에서 확인할 있습니다. 여기서 STRH 명령어는 2바이트만큼 데이터를 스토어하는 동작이므로 0x10000 0x10001 주소에 데이터(‘2’ 표시된 박스) 저장합니다.

 

이어서 03번째 줄을 봅시다. 0x10002 주소에 4바이트 사이즈의 데이터를 저장합니다. 이는 가운데 3으로 표기된 박스에 해당됩니다. 03번째 줄의 명령어가 STR이고 저장하는 W3 레지스터의 사이즈가 4바이트입니다. 따라서 0x10002 ~ 0x10005 주소에 W3 레지스터의 값이 저장됩니다. 여기서 다음과 같은 질문을 던지면서 예제 명령어에 대해 조금 생각해 봅시다.

 

    "02번째와 03번째 명령어 사이에 의존성이 있을까"?

 

질문에 답하려면 02번째와 03번째 명령어가 연달아 실행되면 어느 주소 공간에 데이터가 저장되는지 확인해야 합니다. 02번째 줄은 0x10000 ~ 0x10001 영역 주소에 2바이트 크기만큼 데이터(2) 저장하고, 03번째 줄은 0x10002 ~ 0x10005 구간 주소에 4바이트 사이즈 데이터(3) 저장합니다.

 

02번째와 03번째 줄을 실행한 결과 업데이트되는 주소 공간이 중복되지 않습니다. 따라서 "02 ~ 03번째 명령어 간에는 의존성이 없다"라고 답할 있습니다. 만약 Arm 프로세서 내부에서 03번째 명령어를 먼저 실행하고 02번째 명령어를 실행하면 결과가 달라지지 않습니다. 그래서 명령어의 순서를 바꿔서 실행할 있습니다. 이를 메모리 리오더링이라고 합니다.

 

여기서 걸음 나갈까요? Arm 프로세서는 내부에서 02 ~ 03번째 명령어를 번에 실행할 수도 있습니다. 이를 Arm 아키텍처에서는 머지 액세스라고 하는데 이번에 예시 명령어를 통해 머지 액세스가 무엇인지 자세히 알아봅시다.

 

02번째 줄은 0x10000 ~ 0x10001 범위의 주소에 접근합니다. 이를 메모리 액세스를 한번 수행했다고 말할 있습니다. 03번째 줄은 0x10002 ~ 0x10005 범위의 주소에 접근하며 역시 메모리 액세스를 처리했다고 있습니다. 02 ~ 03번째 줄을 순서대로 실행하면 2 메모리 액세스를 수행합니다.

 

만약 02번째 줄과 03번째 줄을 번에 실행하면 어떻게 될까요? 02 ~ 03번째 명령어 사이에 의존성이 없으니 번에 0x10000 ~ 0x10005 메모리 영역에 접근해 데이터를 스토어할 있습니다. 이를 머지 액세스라고 합니다.

 

 

[정보] 메모리 리오더링의 예시

 

명령어가 순차적으로 실행해도 프로세서 내부에서 메모리에 접근하는 순서는 순차적이지 않을 수도 있습니다.

 

이해를 돕기 위해 가지 예를 들겠습니다. 은행에 먼저 고객은 1 번호표를 받고 은행 직원과 상담을 시작했습니다. 그런데 다음에 2 번호표를 받은 고객이 창구에 왔는데, 은행 직원이 봤을 1번보다 2 번호표의 고객을 은행 직원이 먼저 처리할 있다고 판단합니다. 1 번호표의 고객의 요청 사항을 처리하려면 은행의 인프라 서버에 10 간격으로 접근할 있는 상황이기 때문입니다. 무엇보다 1 고객의 요청이 2 고객과 의존성이 없는 상황이니 고민할 필요가 없습니다. 이럴 2 번호표의 고객을 은행 직원이 먼저 처리할 있습니다.

 

1 고객과 2 고객의 요청을 모아 은행의 인프라 서버에서 처리하면 업무 시간을 줄일 수도 있습니다. 은행 직원도 메모리 리오더링과 유사한 방식을 적용해 업무를 최적화합니다.

 

 

Arm 프로세서 내부에서 성능을 최적화하기 위해 종종 어셈블리 명령어의 순서를 바꿔 실행한다고 설명했습니다. 이런 동작을 수행하기 전에 먼저 각 명령어 간에 의존성이 있는지 체크합니다. 명령어 간 의존성이 있다면 명령어의 순서를 지켜 실행합니다.

다음 어셈블리 명령어를 보면서 어드레스 의존성에 대해 배워 봅시다.
 

그림 17.2 의존성이 있는 로드(LDR) 명령어의 예시

위 코드는 2개의 로드 명령어로 구성돼 있습니다. 

먼저 01번째 줄을 봅시다. X1 레지스터가 0x30000을 담고 있고 0x30000 주소에 0x10000이 있다고 가정합시다. 01번째 줄은 X1 레지스터가 담고 있는 주소에 있는 데이터를 X0 레지스터에 로딩하는 동작입니다. 이어서 02번째 줄은 X0 레지스터의 값에서 8을 더한 결과(주소)에 있는 값을 X2 레지스터에 로딩합니다. 

01번째 줄 명령어를 실행한 결과, X0이 0x10000이면 02번째 줄은 0x10008 주소에 있는 데이터를 X2 레지스터에 로딩합니다. 02번째 줄은 다음과 같이 실행된다고 볼 수 있습니다.

X2 = *(0x10000 + 0x8);

이번에는 다른 예를 들까요? 01번째 줄 명령어의 실행 결과 X0이 0x20000이면 02 번째 줄은 어떻게 실행될까요? 02번째 줄은 0x20008 주소에 있는 데이터를 X2 레지스터에 로딩합니다. 이를 다음과 같은 수식으로 나타낼 수 있습니다. 

X2 = *(0x20000 + 0x8);

앞의 두 가지 예시에서 든 명령어의 공통점은 다음과 같은 의사 코드로 표현할 수 있습니다. 

X2 = *(0x0 + 0x8);

“X2 레지스터의 값은 X0 레지스터에 따라 결정된다”라고 해석할 수 있습니다. 

앞에서 소개한 두 가지 패턴의 명령어에서 한 가지 기억할 점은 "01번째 줄 명령어의 실행 결과가 02번째 줄에 영향을 미친다."라는 것입니다. 여기서 X0 레지스터의 값에 따라 02번째 줄의 실행 결과에 영향을 미치니 “01번째와 02번째 줄 명령어 사이에 의존성이 있다”라고 볼 수 있습니다. 이를 어드레스 의존성(address dependence) 혹은 레지스터 의존성(register dependence)이라고 하며, 다음 그림과 같이 표현할 수 있습니다.

 

그림 17.3 어드레스 의존성

만약 의존성이 있는 01번째 줄과 02번째 줄 명령어의 실행 순서를 바꾸면 어떻게 동작할까요? 명령어가 실행되면 예측할 수 없는 이상한 값이 레지스터에 저장될 가능성이 높아 오동작을 유발할 것입니다. 따라서 명령어 사이에 의존성이 있으면 Arm 프로세서 내부에서 메모리에 액세스하는 순서를 바꾸지 않습니다.

+ Recent posts