[Kernel of Linux] 2. System Call (1)
Kernel/Theory

[Kernel of Linux] 2. System Call (1)

반응형

지난 강의 요약 - Introduction

OS는 하나의 거대한 프로그램이 아니라 여러 프로그램으로 나뉘어 있다. 여러 프로그램 중에서도 커널은 memory resident 한 프로그램이다. 나머지는 모두 disk resident 프로그램이므로 필요할 때만 메모리에 올라갔다가 필요 없어지면 다시 내려간다. 커널을 제외한 나머지 프로그램들을 utility, job, command라고도 부른다.

하나의 user가 하드웨어의 모든 resource를 사용하는 윈도우 OS와는 달리, 리눅스는 multi-user system이므로 한 프로세스 혹은 user가 다른 프로세스나 user의 정보를 담고 있는 memory, disk에 접근하는 것을 예방(prevention) 해야 한다.

접근은 I/O operation을 통해 이루어지므로 모든 I/O는 반드시 커널을 통해서만 가능하게 만든다. 커널에게 I/O 요청을 하기 위해서는 system call을 통해 kernel의 function을 request 해야 한다.

cpu 내부에 존재하는 mode bit을 통해 user mode, kernel mode 별 다른 permission check를 수행한다. user mode에서 작성된 바이너리를 보면 I/O 관련 statement가 컴파일러에 의해 system call prepare + chmodk 명령어로 바뀐 것을 볼 수 있다. 실제 I/O statement는 바이너리 내부에 없다는 걸 명심해야 한다. chmodk 명령어는 privileged op-code이기 때문에 trap에 걸려 cpu를 뺏기게 되고 trap handler가 호출된다. permission check 이후 I/O 수행하고 다시 user mode로 복귀한다.


1. System Call

전체적인 system call이 invoking 되는 과정을 살펴보자.

내가 짠 코드 내부에 library function call이 존재하는데 printf() 함수같은 경우 I/O를 수행해야 하는데 스스로 못하니까 system call을 통해 I/O를 수행하려 할 것이다. 컴파일러는 해당 system call 호출 부분을 바꿔주게 되는데 이 루틴을 system call wrapper routine이라고 한다. 실행 도중에 chmodk 명령어를 만나 trap이 걸려 kernel로 달려가게 된다. prepare parameter에서 요청한 대로 syscall을 처리해주는데 sys_{요청한 syscall}() 형식으로 된 syscall을 부른다.

1.1 Inside Wrapper Routine

위에서 보여주는 wrapper routine 예제는 intel cpu architecture 기준으로 얘기하고 있다. 해당 system call에 필요한 arguments를 준비하고 eax에 5를 넣어준 뒤 interrupt number로 0x80을 주고 interrupt를 트리거해서 trap을 발동시키고 있다.

참고로 system call number는 회사(혹은 밴더)에 따라서 다르게 지정할 수 있다. 예를 들면 write system call의 number가 IBM에서는 3이라면 RedHat에서는 4로 정해놓았을 수도 있다는 것이다(임의로 적은 syscall number다). 컴파일러가 wrapper 내부에 system call number를 system call request마다 지정해놓는 것이므로 만약 회사마다 지원하는 컴파일러가 다르고 여기서 만든 바이너리를 다른 회사의 커널에서 실행시키려면 다시 컴파일해야 할 것이다.

1.2 System Call Process

이번에는 system call을 부르는 과정을 자세히 살펴보자.

user가 만든 프로그램에서 wrapper routine을 통해 system call을 부르기 위한 준비를 한다. wrapper 마지막에는 intel 기준으로 int 0x80 명령어를 통해 HW trap을 트리거한다. mode bit를 스위칭해서 user mode에서 kernel mode로 진입한다. 그리고 sys_call() 함수로 점프하게 된다. 이 함수는 trap handler이며 커널 안에 존재하는 어셈블리 함수다.

sys_call() 함수는 먼저 현재 프로세스의 context(register values)를 저장한 뒤 eax에 저장된 system call number가 valid한지 체크한다. valid라면 sys_call_table[]에서 요청된 system call을 처리해주는 함수의 시작 주소를 얻어 실행한다.

이후 다시 mode bit을 user mode로 세팅해준 뒤 user 프로그램으로 돌아가게 된다.

1.3 System Call Function

모든 system call handler 함수는 sys_{요청한 syscall}() 형식의 naming convention을 가지고 있다.

강의에서 교수님은 설명하시지 않았지만 asmlinkage에 대한 개념을 조금 짚고 넘어가보자. 이전에 포스팅한 글 중에서 커널에 custom syscall 추가한 것이 있다. 거기서 처음 설명한 건데 asmlinkage는 compiler directives 중 하나다. ARM이나 MIPS가 아닌 x86에서는 때에 따라 인자를 넘겨주는 방식이 레지스터일 수도, 스택일 수도, 특정 메모리 주소 일 수도 있다. 보통은 gcc 같은 컴파일러가 자동으로 알아서 해줄 테지만 직접 작성한 함수를 어셈블리에서 호출하는 경우에는 인자 전달 방식에 차이가 생겨버릴 수 있다. 이때 스택을 이용해서 인자를 넘겨줄 수 있도록 컴파일러에게 알려주는 지시어가 바로 asmlikage다.

그렇기 때문에 모든 system call이 asmlinkage를 붙여 어셈블리로 작성된 sys_call() 함수와 syscall handler 함수들 간에 인자 전달을 원활히 만들어주는 것이다.

system call 중에는 I/O를 요청한 경우가 있을 수 있다. 그렇다면 I/O를 수행한 결과를 user 쪽으로 반환해야 하는 경우가 있다는 뜻이다. 커널은 block 단위나 sector 단위로 데이터를 읽거나 쓰는데 user에서는 4Byte만을 요청할 수도 있다. 그럼 그 크기만큼만을 리턴해야 할 것이다. 

여기서 주목해야 할 점은 커널이라는 하나의 독립된 프로그램과 다른 독립된 프로그램 간의 경계를 넘어서 정보를 이동시켜야 할 필요가 있다. user 측에서는 memory나 disk에 대한 I/O가 제한되므로 결국 kernel이 그런 동작들을 수행해줘야 한다. 위 함수 리스트가 바로 그런 동작을 수행해주는 함수들이다.

* 더블 언더스코어(__)가 없는 함수들이 있는데 이건 요청된 주소의 validity를 체크하는 루틴이 존재해서 시간이 더 걸리는 함수들이다.

1.4 System Call Number

위에서도 한번 언급했지만 다시 정리하면, system call number라는 것은 결국 Architecture dependent하며 바꿀 수 없는 것이다. 밴더가 정한 컴파일러와 OS가 같이 있어야지만 호환이 된다는 것이고 해당 밴더 사의 규약을 따르는 바이너리들은 거기에 맞춰 사용하고 있기 때문에 임의로 바꿀 수 없다.

새로운 system call에 대한 찬반 의견이다. 찬성에 대한 의견은 이식하기에 간단하면서도 좋은 성능을 낼 수 있다는 것이고 반대 의견은 독자적으로 만든 새 system call이 platform dependent 하기 때문에 오직 자기 자신만이 쓸 수 있고 기존의 system call의 변경은 불가능하기 때문에 number를 추가할 수밖에 없다는 것이다.

결국 새로운 system call을 만드는 것에 대해 부정적인 시각을 가지고 있다. 그렇다면 어떻게 해야할까?

새 system call의 대체 방안이 존재한다는 것이다.

새로운 fd를 만드는데 여기까지 못 올 것 같은 숫자를 정한다. user가 아무리 많이 파일을 만들어도 3~100개 정도밖에 못 열 것이라 예상하면 999 같은 fd를 사용하는 것이다. 그리고 기존의 system call을 이용해서 접근한다. 그럼 내가 만들어준 새 fd를 인식했을 때에는 내가 정한 일련의 동작을 수행하도록 하는 것이다.

이렇게 하면 새로운 system call을 추가하는 일 없이 system call layer를 clean 하게 유지할 수 있다.

 

2. Process Management

리눅스 커널이 해주는 가장 중요한 임무 중 하나는 process management다.

kernel은 아래로는 hardware를 management 해야 하고 위로는 소프트웨어들을 support 해야 한다. 그렇게 하기 위해서는 hardware 하나마다 internal data structure를 하나씩 가지고 있어야 한다. 마찬가지로 프로세스마다 그 프로세스를 관리하기 위한 data structure가 있어야 한다. 그걸 PCB(Process Control Block)이라고 부른다. 이런 것들을 metadata라고 한다.

2.1 PCB

그렇다면 PCB에는 어떤 내용이 들어가 있어야 할까? 프로세스가 실행되는데 필요한 정보들이나 환경 정보 같은 것들이 있어야 한다.

위 리스트에서 볼 수 있는 것들이 PCB에서 볼 수 있는 것들이다. 그중에서도 open files를 살펴보자. 리눅스에서 file은 sequence of bytes라고 정의했었다. 그래서 키보드, 마우스, 그리고 디스플레이도 각각 하나의 file인데 그 중에서도 standard file이라고 했었다. 이 standard files는 리눅스에서 미리 시작할 때 만들어놓는다. 그것이 바로 fd 0: standard input file(키보드), fd 1: standard output file(모니터)다.

참고로 PCB(혹은 TCB) 정보는 task_struct라는 구조체에 의해 관리된다. 해당 구조체의 인스턴스는 프로세스 별로 할당된 user stack의 최상단(스택의 제일 낮은 주소)에 위치한다.

그리고 또 PCB에서 중요한 것 중 하나는 device에 접근하는 방식이다. 어떤 프로세스가 실행 도중에 I/O를 요청하면서 disk에 접근하려고 하는데 disk에 접근하는 프로세스가 항상 그 프로세스만 쳐다보고 있을 수가 없다. 다른 프로세스가 사용 중일 수도 있다. 그렇다면 그 프로세스는 이미 쓰고 있는 프로세스가 다 쓸 때까지 기다려야 한다.

그럼 그 기다리는 방식을 어떻게 할 거냐 하면 바로 queue 방식처럼 각 PCB가 사용할 순서대로 링크되면 된다. 쓰던 프로세스가 일을 다 마치면 링크된 다음 프로세스에게 넘길 것이다. 새로 접근하려는 프로세스가 생긴다면 제일 마지막 PCB에 링크되어 기다릴 것이다.

cpu 사용을 기다리는 PCB list를 ready queue, disk에 접근하려는 PCB list를 disk I/O queue라고 한다.

그런데 여기서 cpu에 접근하고 있는 프로세스가 일을 다 끝내지 못했는데 너무 오래 걸려서 뒤에 링크된 프로세스들이 계속 기다리고 있는 상황을 생각해보자. 그렇다면 스케줄러가 이를 통제해서 적당한 시간이 흐른 뒤에 잠시 멈추고 다음 프로세스에게 제어를 넘겨줄 텐데 아직 할 일이 남아있는 프로세는 다음 작업을 위해 사용 중이던 register 값들을 save 하고 넘겨주어야 한다. 해당 save data는 PCB 멤버 중에 state vector save area에 저장된다.

* state vector = filp-flop(1-bit)이 2개 모이면 register라고 하고 담긴 값은 state of register라고 한다. register의 state가 많아지면 state of vector라고 한다.

2.2 Child Process

처음에 부팅이 되고 kernel이 실행된다. 거기에 shell이 올라가고 command로 mail을 주면 mail 프로세스가 생기고 또 텍스트 편집을 하려고 하면 편집 프로세스가 생기고,... 이런 식으로 동작할 것이다. 즉 parent-child 관계가 생기게 된다. 그렇다면 child process는 어떻게 만들까?

프로세스마다 PCB와 kernel stack이 있다. kernel stack이 존재하는 이유를 다시 살펴보자면 kernel에 있는 기능을 이용할 때도 해당 kernel function이 하나만 있는 게 아니라 여러 system call을 불러 이용하게 될 것이다. 그럼 kernel을 위한 stack도 필요하니 프로세스마다 하나씩 있는 것이다.

<Child Process 생성 과정>

Step 1. PCB를 할당하고 parent의 PCB를 copy
    - 왜 copy? -> parent가 쓰던 터미널, working directory 등의 자원을 copy 함으로써 손쉽게 환경을 세팅할 수 있다.
    - 환경을 상속받고, 자원을 공유하니 효율적

Step 2. memory를 할당하고 parent의 image를 copy
    - 어떻게 memory 할당? -> memory도 memory의 metadata를 가지고 있다고 배웠다. 그 metadata를 보면 child가 들어올 공간을 찾는 게 가능하다.
    - 할당하고 초기화해야 하는데 그 정보는 parent의 image를 통해 해 준다.

Step 3. disk로부터 new image를 load

Step 4. child PCB를 ready list(ready queue)에 link
    - cpu 사용을 위해 줄 세우기
    - 아직까지는 parent가 사용 중이니 기다리라는 뜻이다.

 

위의 4단계는 2개의 system call이 필요하다.

fork() - Step 1 & 2 / exec() - Step 3 & 4

정리하자면 fork()는 parent와 아주 똑같은 프로세스를 생성해주는 것이고, exec()가 해주는 것은 disk로부터 새 image를 load 하는 것이다.

fork()는 한 번 call 하면 두 번 돌아온다(call once, return twice). 처음 return은 parent, 다음은 child다. 왜냐면 만들어놓은 child를 ready list에 넣어놓고 왔기 때문에 parent 이후에 child를 실행하는 것이다.

child는 parent의 모든 정보를 copy 해왔기 때문에 parent가 fork() 호출한 시점부터 똑같이 실행된다. 하지만 이렇게 하면 parent와 child가 혼동이 오기 때문에 OS가 2번 return 시 다르게 return value를 준다. 

 

Reference

http://egloos.zum.com/studyfoss/v/4951809

https://en.wikipedia.org/wiki/Flip-flop_(electronics)

반응형