-
x86-64 Assembly Language 5 Hello world와 System callLinux Development/Kernel 2021. 7. 29. 00:43
시스템 콜은 애플리케이션과 OS가 대화하는 가장 중요한 방법이다.
앞선 두 hello world 코드를 보면 물론 여러 가지가 다르지만 그중 시스템 콜을 이용하는데 의미 있는 차이를 보인다.
- 32bit 환경에서는 int라는 opcode(오피 코드, 명령 부분)와 0x80이라는 operand(데이터 부분)가 포함된 인스트럭션이 보인다.
- 64bit 환경에서는 syscall이라는 opcode만 보인다.
이 두 가지 코드는 모두 System Call을 이용하기 위한 코드들로서 하나는 32bit에서 고전적으로 쓰이는 트랩 게이트를 이용한 시스템 콜, 다른 하나는 64bit 환경에서 권장되는 MSRs(Modes Specific Registers) 시스템 콜 전용 레지스터들을 이용한 콜이다. 아래는 이에 대한 설명이다.
1. 권한(Privilege Level)이 필요한 커널 진입
앞에서 우린 32bit 아키텍처의 애플리케이션이 가지게 되는 4GB 메모리 구조 속에 커널 영역과 유저 영역이 있다는 것을 알게 됐다. 보면 커널 역시 이 애플리케이션에게 할당된 4BG의 가상 주소 공간에 존재하는 것이다.
여기서 의문을 가져야 할 한 가지는, 가상 주소 공간을 관리하고 실제 물리 메모리를 할당해주는 프로그램이 커널인데 그 커널이 가상 주소 공간 안에 있다는 것이다. 그것도 각 애플리케이션들은 모두 4GB의 가상 주소 공간을 할당받기 때문에 그 모든 가상 메모리 공간 안에 커널 존재하고 있는 것이다. 이는 사실 다음과 같다.
- 같은 가상공간에 맵핑되어 있다고 해도 커널 주소 공간 속 메모리들은 다양한 방식으로 물리 메모리와 맵핑된다.
- 특히 매우 중요한 커널 영역은(kernel logical Address) 물리 메모리의 초반부 영역과 직접적으로, 그리고 통째로 맵핑된다.
- 반면 유저 공간의 메모리 세그먼트들(text, data, bss, 스택 등)은 오직 커널에 의해 할당되고 관리된다.
- 커널 공간의 커널 세그먼트들은 그 로컬 환경에 오직 하나 존재하고 모든 애플리케이션이 이를 공유한다.
커널은 각 애플리케이션들에게 메모리를 할당해주고 그것들을 적절하게 구동시켜주기 위해, 각 애플리케이션들에 해당하는 구체적인 정보를 가지고 있다, 이때 각 프로세스들의 구체적인 정보를 담는 task_struct 구조체는 각각의 프로세스들을 관리하기 위한 매우 중요한 관리 단위가 된다. 참고로 이전 설명에서 유저 영역의 스택 끝에 있다던 thread_struct 구조체의 가장 중요한 역할은 사실 관련된 task_struct 구조체를 찾기 위한 수단이 된다는 것이다.
task_struct 구조체에 담기는 일부 정보들
- 현재 프로세스가 어떤 메모리 주소 구간을 사용하며, 그 구간을 어떤 종류의 세그먼트로 사용하는지
- 그 세그먼트들과 맵핑된 물리 메모리 정보가 어디에 적혀 있는지.
- 프로세스의 컨텍스트 정보 1: 현재 코드의 어느 라인이 실행 중에 있는지(RIP, EIP)
- 프로세스의 컨텍스트 정보 2: 이 애플리케이션이 다시 돌기 시작할 때 당장 GPR 레지스터들과(general purpose register) 세그먼트 셀렉터(CS, SS, DS)에 어떤 데이터를 넣어줘야 하는지(스택의 위치정보, 세그먼트들 위치 정보)
- 지금 휴면 상태인지, 실행 대기 상태인지, 실행 대기 상태라면 CPU선점을 위한 우선순위가 어떻게 되는지
- 이 프로세스가 어떤 파일들을 열어서 사용하고 있었는지
이 프로세스들의 정보 묶음 역시, 모든 애플리케이션들의 4BG 메모리 영역 중 커널 부분에 존재할 것이다.
참고로 이런 구조 때문에 우린 컨텍스트 스위칭(context switching)을 하기 위해선 일단 커널 주소 영역으로 올라가서 커널 코드를 타야 한다. 이렇게 커널이 별도의 프로세스 형태로 되어있는 것이 아니라 필요시에 빠르게 접근하기 위해서 메모리 영역 안에 포함되어있는 형태를 모놀리식(monolithic) 커널이라고 하며 현대 대부분의 커널이 이와 같은 형태를 띤다.
이런 커널 주소 영역으로 애플리케이션 코드가 진입할 수 있을까? 할 수 없다
보통 어셈블러 명령어 중 FAR CALL 방식이나 FAR JMP 방식의 명령은 매우 넓은 주소 범위를 뛰어 넘어서 이동할 수 있도록 만든 명령이다. 다시 말해 세그먼트 간 이동을 위해 만들어진 명령이다. 하지만 현재 돌고 있는 CPU의 권한이 3이라면(애플리케이션 모드) 커널 세그먼트로 진입할 수 없다.
하드웨어가 직접 이런 권한에 따른 이용 제한에 기능을 구현하기 위해 많은 기반을 제공한다. 가장 기반이 되는 보호 기능은, 페이징(paging) 기능에 있다.
페이징(Paging)
- 물리 메모리 속에 데이터와 코드들은 가상 주소 공간처럼 내용들이 어디 한 곳에 일열로 주욱 저장되는 것이 아니라 (continuous) 여기저기에 파편같이 저장되고 지워지고 한다.
- 하나의 파편은 보통 4 kbyte인데 1바이트짜리 아스키 문자가 4096자 들어가는 크기이다.
- 이것을 일컬어 페이지(page)라고 한다.
- 이는 부족한 물리 메모리를 알뜰살뜰 잘 써보고자 하는 노력의 일환이다.
- 어떻게 재조립해야 하는지에 대한 맵핑 정보는 GDT, LDT라는 테이블들을 통해 구할 수 있다.
- 이 4 kbyte 페이지마다 PTE (page table entry) 구조체라는 것을 만들어서 OS가 관리한다.
- 해당 페이지 접근을 위해 권한 0을 요구하는지 3을 요구하는지가 이 PTE구조체에 쓰여있다.
권한이 낮으면 애초에 물리 메모리에서부터 코드나 데이터를 받을 수도 없는 것이다.
참고로 경우에 따라선 주소 값이 커널 영역 주소면 어셈블리 명령어 자체가 동작 안 할 수도 있다.
권한에 따른 세그먼트 접근 제한
앞 설명에서 CPU레지스터 중에 세그먼트들을 가리키는 CS, DS, SS 등의 16bit 레지스터를 봤다(32bit 환경에서) 이 16비트 중 하위 3비트는 사실 주소 값이 아니라 부가정보를 나타내기 위해 쓰인다. 이중 2비트를 이용해 해당 세그먼트를 사용할 수 있는 최소 권한을 RPL(Requested Privilege Level) 비트에 표시한다.
현재 권한이 RPL 권한보다 낮으면 세그먼트에 접근할 수 없다. 따라서 커널 세그먼트에 접근하려면 권한 확보가 선행돼야 한다.
권한(Privilege Level)을 올려서 커널 영역에 진입하기 위해서 4가지 방법이 있으며 실질적으로는 두 가지 방법만 사용한다.
2. 32bit 환경에서 커널 코드 진입을 위한 4가지 진입 게이트
앞에서 세그먼트의 가상 주소를 물리 주소와 맵핑하기 위해 GDT와 LDT를 사용한다고 말했다, 여기에 인터럽트 핸들러 루틴이 있는 곳을 가리키기 위해 각 인터럽트에 해당하는 맵핑 정보를 담은 IDT라는 테이블이 있고 이 테이블을 가리키는 IDTR 레지스터가 있다고 말했었다. 이런 테이블들에는 세그먼트와 인터럽트 관련 정보 말고 추가로 게이트 정보라는 것 역시 담긴다.
이는 요구 권한이 높은 세그먼트의 특정 영역으로 권한을 올려서 들어갈 수 있는 코드 진입 정보이다. 이 게이트 엔트리는 대략 4가지 종류가 있다
- Task gate - 프로세스들의 컨텍스트 정보인 레지스터 셋과 프로세스의 세그먼트 정보를 저장하고 읽는 작업을 지원해주기 위해 만들어진 게이트, GDT와 LDT에 TSS Descriptor라는 프로세스 정보를 넣어두고 IDT의 Task gate Descriptor 항목을 만들어 이 TSS Descriptor을 가리키면 하드웨어가 자동으로 컨택스트 스위칭을 진행한다. 기존에 OS가 전담하던 컨텍스트 스위칭 역할을 하드웨어 레벨에서 지원해주기 위해 만들어졌지만, 하드웨어 의존도가 너무 강하고 생각보다 퍼포먼스가 떨어져서 거의 안 쓰인다고 한다. 이 작업을 위해 권한 상승이 당연히 필요할 것이다.
- Trap gate - 0으로 나누는 예외가 발생했거나, 페이지 폴트(물리 메모리에서 뭘 더 가져와야 하는 상황을 알려주는 예외)가 발생했을 때 이를 처리해 주기 위한 예외처리 루트. 소프트웨어 인터럽트라고도 불림, 리눅스에선 트랩 중 0x80번 트랩을 시스템 콜 진입점으로 활용함.
- Interrupt gate - 하드웨어 인터럽트들을 처리해주기 위한 게이트, 키보드 입력, 네트워크 장비의 패킷 수신 등 주변장치의 모든 것들이 인터럽트로 처리된다. Interrupt Nesting이라고 주변 장치들의 인터럽트 우선순위를 스스로 분류하는 로직들이 생기는 등 그 기능이 확장되면서 System call 진입점으로는 오히려 쓰기 불편하게 되었다.
- Call gate - GDT와 LDT를 활용해 만들 수 있는 진입점, 낮은 권한 레벨에서도 단지 Far Call 형식이나 Far jmp형식의 어셈블리 명령어를 이용하면 권한도 상승시켜 주고 커널 세그먼트에 접근도 허용해 준다.(단 정해진 위치로만) 역시 퍼포먼스가 떨어져서 거의 안 쓰이다가, 차후 system call을 위한 전용 레지스터(MSRs)가 직접 세그먼트를 포인팅 해주는 방식으로 바뀌면서 안 쓰게 됐다. 이제 32bit 아키텍처에서는 sysenter명령을 이용하면 GDT, LDT대신 이 레지스터를 통해 시스템 콜로 접근한다 (64bit 아키텍처에서는 syscall명령을 통해 접근한다)
3. 컨텍스트 스위칭과 유저/커널 모드 전환의 차이점
컨텍스트 스위칭을 하기 위한 모든 코드는 커널 안에 있고 이는 커널 메모리 영역 안에 있다.
따라서 애플리케이션이 컨텍스트 스위칭을 하기 위해선 일단 커널 모드에 진입해야 한다.
애플리케이션 A->애플리케이션 A의 커널 모드-> schedule()->애플리캐이션 B의 커널 모드-> 애플리케이션 A
이 컨텍스트 스위칭 과정을 풀어서 해석해 보면 아래와 같다.
- 커널 모드 전환 애플리케이션 A의 컨텍스트(레지스터 세트)가 커널 모드 컨텍스트로 변경됨
- 컨텍스트 스위칭 리눅스 커널상에 현재 프로세스를 가리키는 task_struct *current; 포인터가 RUNNING 상태인 프로세스 중에 존재하는 B 애플리케이션의 task_struct를 포워딩하도록 변경, 컨텍스트(레지스터 세트)는 변경 없이 아직 커널 모드
- 유저 모드 전환 커널 모드의 컨텍스트(레지스터 세트)가 현재 프로세스인 애플리케이션 B의 컨텍스트로 변경됨, 참고로 이 컨텍스트(현재 상태의, 레지스터 세트) 값들은 task_struct 구조체 안에 보존되어있음.
따라서 리눅스 환경상에서 컨텍스트 스위칭 자체는 레지스터 세트의 교환이 없이 이루어진다고 봐야 할지 모르겠다. (두 번의 모드 전환이 있을 뿐)
이때 현재 프로세스들의 우선순위를 평가하고, 필요할 때 컨텍스트 스위칭을 진행하는 scheldule() 함수는 아래와 같은 상황에서 구동된다.
- 커널 타이머에 의해 주기적으로 Call 됨
- System call 처리를 위해 커널 모드에 들어왔다가 처리를 끝내고 돌아가기 직전에
- wait() 시스템 콜같이 오랜 기간 대기상태에 있어야 하는 시스템 콜을 구동할 때
4. 32bit Architecture에서의 System Call
고전적인 시스템 콜에서는 소프트웨어적으로 인터럽트를 발생시켜 이를 처리해주는 인터럽트 루틴을 타고 간다. 이때 시스템 콜을 위한 인터럽트 번호는 0x80(128) 번이며 해당 인터럽트 진입점에선 자연스럽게 필요한 권한(Priority)을 제공해 준다.
맨 위의 코드를 보면 int 0x80으로 인터럽트를 진행하는 코드 4번 라인에서 eax 레지스터에 0x04를 넣어주는 게 보인다. eax에는 int 0x80 명령을 처리하기 전에 내가 처리하고자 하는 시스템 콜 번호를 넣는다. 0x04번 시스템 콜은 write()로 화면 출력을 위한 시스템 콜이다.
5. 64bit Architecture에서의 System Call
시스템 콜 전용 레지스터인 MSR 레지스터 세트에 의해 시스템 콜 처리 루틴들이 담긴 세그먼트 정보와 offset 정보를 확보할 수 있다. 어셈블러의 syscall명령(32bit에선 sysenter)으로 MSR레지스터 셋을 통해서 커널 모드에 진입하면 별도의 절차 없이 권한 0(커널 모드 권한)을 획득할 수 있으며 커널 모드의 레지스터 컨텍스트 세트가(EIP, CS, SS 등) 바로 세팅된다. 커널이 세팅해준 MSRs레지스터를 통해서 하게 되는 진입은 비교적 안전하다고 판단하는 것이다. 덕분에 64bit 아키텍처부턴 권장되는 방식이다.
참고로 맨 위에 64bit Hello world의 코드를 보면 4라인에 rax에(eax의 64비트 레지스터) 숫자 1이 들어가는 것이 보인다.
syscall 명령을 사용할 때 시스템 콜 번호가 32bit 시스템 콜과 다르다는 것을 알 수 있다. 두 가지 방식의 진입점이 모두 존재하므로 두 개의 번호 체계가 모두 존재함을 알 수 있다. 하위 호환성을 위해 바꾸기보단 새로운 시스템 콜 번호를 추가한 것이다.