2장의 내용은 전에 1장에서 간단하게 배운System Call에 대해 더 자세히 다룰 것 이다. System Call이란 멀티유저 시스템에서 한 프로세스가 다른 프로세스에 I/O로 함부로 접근해 데이터를 망치는 일을 사전방지(Prevent)하기 위해 나온 방법이다. 즉, 쉘이 I/O를 사용하려고 하는 순간 CPU를 빼았고I/O를 하고 싶으면 커널이 가지고 있는 function에 부탁을 하는 방식을 System Call 이라고 한다는 것을 우리는 1장에서 배웠다. 이번 2장을 통해 System Call의 구체적인 절차에 대해 알아보자.
그렇다면 이 System Call이라는 것은 정확히 언제 일어나는 것일까? 우리가 I/O관련 function을 하려고 하면 그때 바로 일어나는 것일까? 이것을 알아보기 위해 먼저 밑에 그림을 보자.
리눅스 명령어 옆에 붙은 숫자에 따라**커맨드(1), 시스템 콜(2), 라이브러리 함수(3)**이다.
그림 좌측을 보면 유저 영역 안에 내가(유저) 짠 코드가 있다. 이 코드에서 printf()를 콜 하는데 이 printf()에 관한 코드는 내가 짠게 아니라 library function 인 것은 알 것이라 생각한다. 그럼 이제 printf()가 내 코드에 들어오는데 printf()는 출력 즉, I/O를 해 줘야 한다. 멀티 유저 시스템에서 I/O는 커널만 가능하기 때문에I/O를 하는 모든 library function은 무조건 System Call을 불러야한다.
System Call을 하게 되면Wrapper Routine이라는 공간에 가게 되고 이 공간에는 왜 커널로 가게 되는지 알려주는 정보들을 담고 있는Prepare parameter과CPU의 모드 비트를 커널로 바꾸는chmodk가 들어있다.
그럼 이제 런타임에서 트렙에 걸려 커널 영역으로 가게 된다. 그럼 커널에서는 Prepare parameter에 담겨있는 내용을 보고 적절한System call function으로 처리를 해준다.
커널 안에 있는모든 System call function의 이름은 sys_ 로 시작을 한다.
이제 실제로 트랩에 타고 갈 내용들을 준비하고 트랩을 일으키는 공간인 Wrapper Routine에 대해 조금 더 알아보자
Wrapper Routine에서 (인텔의 경우)$0x80등 의미 없는 문자들을 이용해 Machine Instruction을 줘 트랩을 발동한다. 그런데 위에서 트랩을 일으키기 전에 Prepare parameter들을 준비해야 한다고 했었다. 그 중에 가장 중요한 것은 바로system call number라는 것이다. 이 system call number는 커널이 가지고 있는system call function의 시작 주소를 담고있는 Array(배열)의 Index 번호로 사용이 된다.
system call number의 예를 들면 file하면 file의 system call에는 open, close, read, write 등이 있는데 open은 0번, close는 2번, read는 3, write는 4번 등 call number을 이용해 Array의 Index 위치에 접근을 한다.
지금까지 과정을 순차적으로 정리해 보면 아래와 같다.
- 컴파일러(gcc)가 유저가 짠 코드를 보고 라이브러리(printf)를 호출한다.
- 라이브러리에서 System call(write(2))을 호출한다.
- Wrapper Routine에서 write에 대응하는 system call number가 나오고 트랩을 건다.
- 커널이 system call number을 가지고 system call function 테이블에 접근해 function 시작 주소에 접근한다.
여기서 하나 알아둬야 할 점은 이렇게 system call number를 지정한 컴파일러와 그 system call number를 받고 system call function 테이블에서 function을 찾는 운영체제의 번호가서로 일치해야 한다는 점이다. 이러한 번호들은 컴파일러를 쓰는회사에서 결정을 한다. 리눅스에서 소스 프로그램은 다른 회사의 플랫폼으로 옮기면 다시 컴파일을 해줘야 이러한 number을 다시 맞춰 런 할 수 있는 것이다.
마지막으로 그림에 나온 예시를 통해 System Call의 과정을 따라가보자.
- 유저 프로그램이 시스템 콜을 부른다.
- Instruction으로 인해 트랩이 생긴다.
- 하드웨어가 User에서 Kernel로 mode 비트를 바꾼다.
- 하드웨어가 sys_call() 이라는 커널안의 트랩 핸들러로 가게 된다.
- 이런 핸들러는 커널안의 assembly function을 한다.
- 지금까지 유저 프로그램에서 진행했던 단계를 저장을 한다. (커널 쪽 일이 다 끝나면 System Call 했던 곳으로 돌아가서 다시 진행을 해야하기 때문에)
- 시스템 콜 번호가 커널 안에 sys_call table에 있는 번호에 맞는 번호인지 확인한다.
- 맞다면 system call function의 주소를 가져온다.
- 그리고 system call function을 불러 작업한다.
- (만약 진행 과정이 디버깅이 필요하다면 디버거를 실행시킨다.)
- 다시 시스템 콜을 했던 유저의 영역으로 돌아가고 mode 비트를 유저로 전환한다.
만약 한 사람이 스마트폰 어플리케이션으로 사진을 찍은 파일을 볼 수 있는 겔러리 어플리케이션을 만들었다고 생각해보자. 유저의 코드는 읽어오는 기능을 library에서 불러올꺼고 library는 I/O를 하기위해 커널에게 System Call을 할 것이다. 그럼 커널에서는 유저가 원하는 사진 파일을 유저 영역에 넘겨줘야 할 것이다. 아니면 반대로 유저의 영역에서 커널이 데이터를 가져와야 하는 경우도 생길 것 이다. 이처럼 유저 프로그램과 커널 프로그램이라는 독립된 프로그램 간에도 데이터를 주고 받을 수 있어야 할 것이다.
그러한 기능들은 오직 커널만이 가지고 있다. 리눅스는 앞서 말 한 것처럼 멀티 유저 시스템 이기 때문에 보호 목적상 커널만이 모든 메모리에 접근이 가능하다. 즉, 커널이 유저에게 보내줄 수는 있어도 유저가 커널에서 읽어 올 수는 없고, 커널이 유저한테서 읽어올 수는 있어도 유저가 커널한테 보내 줄 수는 없다. 모든 I/O는 커널을 통해서만이 이루어 진다.
단지 유저가 요구하는 바이트의 수는 커널이 디스크에서 받아오는 것 처럼 일정한 바이트의 단위가 아닌 4바이트, 7바이트 등 여러가지 종류가 될 수 있기 때문에 커널에게는 유저가 원하는 바이트 만큼 넘겨주는 기능 등이 존재한다.
그럼 커널에 대해 더 자세히 알아보기에 앞서 트랩전에 정해지는 시스템 콜 번호에 대해 구체적으로 알아보고 가자
System call number는 커널의 system call table의 인덱스 번호로 사용되어 system call function의 주소의 시작값을 불러오는 용으로 사용 된 다는 것을 배웠다. 이러한 번호는 컴파일러와 OS를 제작한 회사에서 정하며 이렇게 정해진 번호는 변경 할 수 없다.
그렇다면 리눅스에 자신만의 System Call(sys_write()나 sys_read()처럼)을 만들 순 없을까. 물론 자신만의 새로운 System Call을 만들 수는 있다.
먼저 새로운 System Call을 만드는 것의 장점을 살펴보자. 먼저 새로 System Call을 실행 시키기 간단하고 성능 또한 좋게 만들 수 있다. 이러한 면만 보면은 굳이 새로 System Call을 만들어 쓰지 않을 이유가 없는 것 같다.
그러나 이러한 장점보다는 더 중요한 단점들이 있다. 먼저 새로운 System Call을 만들게 되면 그 System Call만의 새로운 system call number가 필요로 하게 된다. 이렇게 되면 이 새로운 System Call은 그것을 제작한 플렛폼에서 밖에 쓰지를 못한다. 즉 다른 플렛폼에서 본인이 만든 (예를 들어 99번째 System Call) System Call을 부를려고 하면 당연히 다른 플랫폼에선 그 번호가 없기 때문에하나의 플랫폼에 의존적이게 되어버린다.
또한 한번 만든 System Call은추가만 가능하고 변경을 할 수 없기 때문에 나중에 수정을 하려고 해도 불가능 하다.
그렇다면 새로운 System Call은 만들 수는 없는 것일까? 어떻게 해야 새로운 System Call을 만들 수 있는 것일까?
해서 나온 방법으로 기존에 있던 System Call들인 read나 write들에 있는File Descriptor을 활용하는 것이다. 파일 디스크립터는 추후에 나오기에 간단히 설명을 하자면운영체제가 만든 파일이나 소켓을 편하게 부르기 위해서 부여한 숫자이다 라고 정도만 이해하면 될 것이다.
이러한 파일 디스크립터는 보통 적은 숫자만이 활용이 되고 있어 보통은 잘 쓰지 않는 999번 등에 본인의 파일 디스크립터를 지정하고 사용하면 커널안에서 System Call에도 영향을 주지 않고 사용할 수 있다.
Robert M. Love의 책에서도 권장하는 방식이고 전 세계 모든 유닉스 사용자들이 이러한 방식을 사용하고 있다고 한다.
일단 시스템 콜에 대한 내용은 이쯤 하고 Process Management에 대한 내용으로 넘어가자. Process Management는 커널이 하는 아주 중요한 임무 중 하나로서 반드시 짚고 넘어가야 할 부분 중 하나다.
1강 첫 시작에서 우리는 운영체제가 어떤 역할을 해 주는지 배웠다. 운영체제는하드웨어 자원을 관리하고프로그램들을 지원해주는 역할을 한다는 점을 숙지 했을 것이다.
이와 마찬가지로**운영체제의 핵심(Kernel)**인 커널 또한 같은 역할을 한다. 커널이란 위로는 프로그램들을 지원하고 밑으로는 하드웨어(CPU, Memory, Disk, TTY)를 관리하는 데이터와 기능들을 가지고 있는 프로그램이다.
이런 하드웨어 관리와 프로그램 지원을 위해서 커널은Internal Data Structure을 가지고 있다.
먼저 하드웨어 관리를 위한Data Structure안에는각 하드웨어에 대한 정보가 담겨져 있다. 예를 들어 Memory 하드웨어에 관한 Data Structure에는 이 Memory의 크기가 어느정도이며 어디서부터 어디까지 메모리가 사용되고 있는지 등관리를 위한 내용들이 담겨있다.
또 하드웨어처럼 프로세스들을 관리하기 위한 Data Structure 또한 있어야 할 것이다. 우리는 이러한 Data Structure을**PCB(Process Control Block)**이라고 부른다. 즉, 프로세스를 지원하고 관리하기 위한 정보들이 담겨있는 데이터 구조체 이다.
우리는 이러한 프로세스와 하드웨어를 관리하기 위한 데이터가 담겨있는 데이터 구조체를metadata라고 부른다.
그렇다면 프로세스를 관리하기 위한 metadata 내용에는 어떠한 것이 있을까
metadata의 안에는 다음과 같은 내용등이 담겨있다.
- PID(프로세스 식별자)
- 우선순위
- 대기 현상
- 프로세스의 상태
- 이미지의 디스크 위치
- 이미지의 메모리상의 위치(코드의 위치)
- 오픈 파일(유닉스에서 파일은 바이트의 연속이고 각종 디바이스 또한 전부 파일로 취급한다. 참고로 제일 먼저 오픈하는 파일은 키보드와 스크린 파일이다.)
- 현재 프로세스 진행중인 환경
- 터미널
- 상태 백터 저장 공간
만약 프로세스 A가 CPU를 가지고 있다가 디스크에 볼 일이 있어서 디스크에게 갔는데 디스크가 일을 하고 있다면 기다림을 신청하고 디스크를 기다린다. 이 시간은 CPU 입장에서는 몇억년의 시간이기에 A가 기다리는 동안 다른 프로세스가 CPU를 가져다 쓰게 만드는데 이때A가 하고 있던 작업 내용을 프로세스A의 PCB에 저장을 한다.
이때이 저장 공간을 state vector save area라고 한다.State of vector는State of Flipflop(0과 1)이 32개가 모여Register을 이루고 이러한 Register들을 저장하는 공간을 말한다.
-
부모, 자식 프로세스
-
실행 시간
이처럼 metadata에는 프로세스와 하드웨어를 관리하는데 있어 필요한 모든 정보를 담고 있다.
이제 상태 백터 저장 공간을 설명하면서 잠깐 말한프로세서 A의 기다림 신청이라는 개념에 대해 간단히 알고 가보자.
우리는 은행에 가서 일을 처리하려 할 때 이미 창구에 다른 사람이 먼저 일을 보고 있으면번호표를 뽑고 기다리던 경험을 해 봤을 것이다. 이처럼 프로세스도 본인이 사용하고 싶은 하드웨어가 이미 다른 프로세스에 의해 사용되어 지고 있으면 대기표를 뽑고 기다려야 하는 일이 일어난다. 이 경우 해당 프로세스의 PCB는 본인이 사용하려 했던 하드웨어에 링크를 걸어놓고**대기 Queue(대기열)**에 들어가게 된다.만약 본인 앞에 다른 프로세스가 똑같은 하드웨어를 사용하려고 이미 대기 신청을 한 상황이라면 먼저 기다리고 있던 프로세스의 뒷 순서로 대기 Queue에 들어간다.
이런 대기 Queue 중 CPU에 링크를 걸어놓고 기다리는 것을ready queue라고 하고 디스크에 링크를 걸어놓고 기다리는 것을**Disk I/O queue(Disk wait queue)**라고 한다.
컴퓨터를 처음 부팅하면 제일 먼저커널 프로세스가 올라간다. 그리고 이 커널이 터미널이 켜질때 마다 그에 해당하는Shell, 즉 Child Process를 만든다. 또 이러한 Shell은 Mail이라고 입력하면Mail이라는Child Process가 생성 된다. 이처럼 프로세스가 진행이 되면서 자식 프로세스를 만드는 일들이 필요로 해진다.
잠시 여기서 프로그램과 프로세스의 차이에 대해 알아보자. 프로그램과 프로세스의 차이는 명확하다. 프로그램은 보조 기억 장치에서 실행이 되기만을 기다리는 정적인 데이터의 묶음이고 이러한 프로그램의 명령어와 데이터가 메모리에 적재 되면 프로세스가 되는 것 이다. 즉, 프로세스란 실행 중인 프로그램을 뜻한다.
우리는 1강에서 한 프로그램에는 User Stack과 Kernel Stack이 있다는 것을 배웠다. User Stack은 한 프로그램 안에서의 function을 사용할 때 사용되는 것이다. Kernel Stack은 유저에서 시스템 콜을 해 커널의 function들을 사용할 때 생겨나는 여러가지 프로세스들의 Local Variable들을 위한 공간으로 사용된다. 만약 이렇게 Stack으로 사용하지 않고 늘 공간을 비워 놓는다면 프로그램 크기가 엄청 커지고 기계 값만 비싸질 것이다.
자 그렇다면 Child Process를 하기위해서는 어떠한 것을 만들어 줘야 할까. 먼저 Process의 정보들이 담겨있는 PCB를 만들어야 하고 그 다음에 그 Process를 만들어 줘야 할 것이다.
이 순서는 아래와 같다.
- **PCB 공간을 만들어 준다. **초기값은 Parent의 PCB를 복사해온다. (Parent가 쓰던 Resource(터미널, 키보드, 스크립트)를 자식 프로세스도 사용, 부모의 실행 환경이 자식의 실행 환경이 된다.)
- Child의 Process가 들어갈 메모리 공간을 확보해 초기값을 지정해 준다. (그러기 위해 Memory의 Data Structure에 가서 빈 메모리 공간을 찾아 공간을 지정해 준다. 그리고 Child Process의 값들을 넣어야 하는데 그 전에 먼저 부모의 image를 똑같이 복사를 해준다. 이 이유는 나중에 배우게 될 것이니 지금은 처리가 간단해지기 때문이라고 기억해두자)
- 디스크에서 Child의 새로운 image를 넣는다
- 새로 생긴 Child PCB를 CPU의 ready queue에 대기시켜 CPU를 사용 할 수 있게 해준다. (아직까지 CPU는 Parent가 사용 중 이기 때문이다.)
이러한 4가지 과정을 System Call의두가지로 나눌 수 있다.
- 1번과 2번의 과정을 Fork라고 부른다. (Parent와 동일한 것을 만든다.)
- 3번과 4번의 과정을 Exec이라고 부른다. (디스크로 부터 새 이미지를 읽어온다.)
그럼 이 과정 중 Fork에 대해 알아보자.
일단 Fork에 대해 알아보기 전에 포크는**한 번 Call하면 두번 돌아온다(Return)**라는 걸 알아두자. 지금은 이해가지 않더라도 일단 그런가 보다 하고 넘어가자.
그런데 이 두번 돌아올때 첫번째는 Parent가 본인이랑 똑같은 Process을 복사해서 Child를 CPU의 ready queue에 달아놓고 Parent(본인)로 돌아온다.
그 후에 CPU가 Child로 넘어오게 되면 Parent와 똑같은 PCB 즉, State Vector 또한 똑같이 복사가 되어 넘어오기 때문에Fork를 하고 난 바로 그 진행 시점또한 같이 넘어오게 된다. 즉 Child는 부모가 가지고 있는 정보들 뿐만 아니라 진행 현황까지 완전히 똑같은 형태를 갖고 있게 되고 이런 현상 때문에Child 또한 Fork에서 돌아오는 진행을 하게 된다.
그렇기 때문에 한번 Fork를 해서 두번 돌아온다는 표현이 생긴 것 이다.
단 운영체제가 이런 두 가지의 return으로 일어나는 혼동을 막기위해 다른 값을 return하게 해준다.
이러한 점이 이해가 갔다면 밑에 그림을 보자.
위에 그림에서처럼 포크가 두번 돌아오는데 한번은 부모로, 한번은 자식에게로 돌아간다. 그리고 돌아올때pid 값을 반환하며 돌아온다. 이 pid 값에 의해 else로 가면 부모인 것이고 0으로 가면 자식 프로세스 인 것이 분류가 된다.
다음은 이러한 fork 과정의 예시 프로그램이다.
cat fork.c 라는 fork()를 하는 프로그램이다.
보다시피 main()이 있고 그 안에 fork()가 있다. 이 fork()를 하게 됨으로서 저 위와 똑같은 코드가 Child 용으로하나 더 생성이 되는 것이다. 위에서 설명했던 것 처럼 fork()의 결과에 따라 정해진 pid의 값에 의해 child process로 가거나 parent process로 가게 된다.
2장 강의노트를 마친다. 이번 강의에서는 System Call이 일어나는 절차에 대해 System Call Wrapper Routine, System Call Number 등을 배웠고 커널이 프로세스들과 하드웨어들을 관리하기 위해 정보를 모아둔 Data Structure의 metadata에 대해서 배웠다. 또 마지막으로 Child Process의 생성과정 중 Fork에 대해 간략히 알아보았다. 다음 3강에는 Fork의 설명을 이어서 할 것이고 Exec에 대해 설명을 나가실 계획이시다. 한마디 한마디 중요한 말씀으로 유익한 강의를 제공해주신 고건 교수님께 감사의 말씀 올린다.