본문 바로가기
Today I learned

iamroot 커널스터디 5월 25일 1주차

by soheemon 2019. 5. 25.

운영체제는 일반적으로 자원관리자 라고 정의된다.
CPU, 메모리, 디스크등의 자원을 관리하여 일반 사용자들이 컴퓨터를 사용할 수 있도록 지원.

+ 운영체제가 사용자가 작성한 프로그램을 저장하는 과정
1. 운영체제는 우선 디스크의 일부 공간을 할당받는다.(일반적으로 4KB 크기의 공간을 할당받으며 이것을 디스크 블록이라 한다.)
2. 파일의 속성 정보(만들어진 시간, 만든 사람, 접근제어 정보등)을 저장하기 위한 공간을 디스크에 할당한다. 이는 inode라고 할 수 있다.
3. 마지막으로 파일 이름인 test.c와 inode간에 연결을 만들고 inode와 파일의 내용이 들어있는 디스크 블록을 연결한다. 따라서 파일 이름만 알면 inode연결을 통하여 그 파일의 내용, 파일을 만든 시간 정보 등을 찾아 갈 수 있다.

+ 컴파일 이후
1. 사용자가 만든 test.c를 컴파일하면 a.out(리눅스의 기본 바이너리 이름)이라는 이름을 갖는 파일이 생성된다.
따라서 운영체제는 디스크에서 디스크 블록을 할당받아 마찬가지로 파일 내용을 기록하고, inode를 할당받아 여기에 파일의 속성정보를 기록하고, 마찬가지로 파일이름과 inode 디스크 블록을 연결한다.

+ 컴파일 이후 만들어진 a.out이라는 바이너리를 수행하는 과정
1. 바이너리 파일이 태스크로 수행되기 위해서는 파일을 구성하고 있는 내용들이 메모리로 적재 되어야 한다. 따라서 바이너리를 수행하면 그 결과 태스크라는 새로운 객체가 생성된다. 이 객체는 수행중인 프로그램으로 정의되고, 활동하는 객체이다. 이 태스크는 기존에 존재하는 태스크들과 CPU스케쥴링에 따라 서로 CPU할당권을 경쟁하게 된다.

2.새로운 태스크는 우선 메모리의 일부 공간을 할당받는다. 그리고 여기에 바이너리 파일의 수행이미지를 적재한다. 각 태스크는 세그먼트 테이블과 페이지 테이블을 이용해 자신에게 할당된 페이지 프레임을 관리한다.

3장
+ 프로세스와 쓰레드 그리고 태스크
태스크= 자원소유권의 단위 
쓰레드= 수행의 단위
프로세스= 동작중인 프로그램
프로그램= 디스크에 저장되어 있는 실행 가능한 형태의 파일

+사용자 입장에서 프로세스 구조
32bit CPU의 경우 운영체제는 각 프로세스에게 총 4GB 크기의 가상공간을 할당한다.
이중 3GB를 사용자 공간으로 사용하고 나머지 1GB를 커널공간으로 사용한다.

+예시로 보는 프로세스 구조
각 영역을 세그먼트 또는 가상메모리 객체(vm_area_struct)라고도 부른다.
텍스트 영역: CPU에서 직접 수행되는 명령어, 함수등으로 구성됨.
데이터 영역: 텍스트영역 다음으로 존재하며 전역변수등으로 구성
스택 영역: 함수의 지역변수등으로 구성되는 영역이며 사용자 공간과 커널 공간의 경계 위치부터 아래 방향으로 공간을 차지한다. 스택은 프로그램이 수행됨에 따라 동적으로 변한다. 함수를 호출 할 때 인자나 호출된 함수의 지역변수를 저장하기 위함이다. 또한 함수가 종료되면(return시) 스택의 크기는 줄어드게 된다.
힙영역: 프로세스가 수행중에 malloc()/free()등의 함수를 사용하여 동적으로 메모리 공간을 할당 받을 수 있는 영역. 데이터 영역의 다음부분을 차지하며 힙은 아래에서 위쪽 방향으로 자라난다.

+ 리눅스의 태스크 모델
프로세스 생성/쓰레드 생성시 task_struct라는 자료구조를 생성하여 관리한다.
리눅스에서는 task_struct 자료구조 중에서 수행 이미지를 공유하는가, 같은 쓰레드 그룹에 속해있는가 등의 여부에 따라 프로세스, 또는 쓰레드로 사용자에게 해석되는 차이가 있을 뿐이다.
- 이는 기존의 운영체제와의 다른 리눅스 특유의 태스크 개념이며 기존 운영체제 연구자들은 자원을 태스크로 제어흐름을 쓰레드로 정의하였다. 반면 리눅스에서는 프로세스던 쓰레드던 커널 내부에서는 태스크라는 객체로 관리한다.
- 태스크가 관리하는 자원을 어떻게 공유하고 접근 제어하느냐에 따라 프로세스로 해석될 수도 있고 쓰레드로 해석될 수도 있는것이다.

+ 태스크 생성과 관련된 함수의 흐름
fork()를 호출하던 clone()을 호출하던 커널 내부에서 최종적으로 호출하는 함수는 do_fort()로써 동일하다.(함수의 인자로 부모 태스크와 얼마나 공유할지를 다르게 하게 되며 이를 통해 사용자 입장에서는 프로세스와 쓰레드가 구분된다.) 리눅스 입장에서 본다면 모두 '태스크'를 생성하기 때문이다.

+ do_fork()가 수행하는 일
1. 새로 생성되는 태스크를 위해 task_struct구조체를 준비하여 태스크의 정보를 기록한다. 
2. 태스크가 수행되기 위해 필요한 자원 등을 할당한 뒤 수행 가능한 상태로 만들어준다.


+ 사용자입장에서 태스크를 프로세스와 쓰레드로 구분하는 방법.
커널 내부에서는 프로세스/쓰레드/커널 내부에서 생성하는 커널쓰레드 등 관계 없이 모두 task_struct 구조체로 표현한다. 
-> 하지만 사용자 입장에서는 이를 어떻게 구분할까?
시스템에 존재하는 모든 태스크는 유일하게 구분이 가능해야 한다. 이를위해 task_struct 구조체 내에 pid필드에 유일한 값을 기록한다.

POSIX표준에는 '한 프로세스 내의 쓰레드는 동일한 PID를 공유해야 한다'라고 명시되어 있다. 
리눅스에서는 이를위해 tgid(Thread Group ID)라는 개념을 도입했다.

+ 프로세스와 쓰레드의 tgid값 할당의 차이
pid는 태스크를 구분하는 유일한 값으로, 우선 태스크가 생성되면 이 태스크를 위한 유일한 번호를 pid로 할당해 준다. 
-사용자가 프로세스를 원하는 경우라면 생성된 태스크의 tgid 값을 새로 할당된 pid값과 동일하게 넣어준다.
=> 따라서 프로세스는 유일한 tgid값을 갖게된다.
-사용자가 쓰레드를 원하는 경우라면 태스크의 tgid값을 부모 쓰레드의 tgid값과 동일한 값으로 갖게되며 이는 동일한 프로세스에 속해있는 것으로 해석된다.

정리하자면 태스크 생성시에 유일한 pid값을 할당받고 
프로세스의 tgid값은 이 pid값과 동일한 값이된다. (유일하다) 
쓰레드는 부모의 tgid값과 동일한 값으로 설정된다. (이는 동일한 프로세스에 속해 있는 것으로 해석된다.)

+ 태스크 문맥
태스크와 관련된 모든 정보를 문맥(context)라고 부른다. 
태스크의 문맥은 크게 세 부분으로 구분 할 수 있다.
1. 시스템 문맥
태스크의 정보를 유지하기 위해 커널이 할당한 자료구조들이다.
대표적인 자료구조로는 task_struct, 파일 디스크립터, 파일 테이블, 세그먼트 테이블, 페이지 테이블 등이 있다.
2. 메모리 문맥
텍스트, 데이터, 스택, heap영역, 스왑공간등이 여기에 포함된다.
3. 하드웨어 문맥(쓰레드 구조 또는 하드웨어 레지스터 문맥이라고 불린다.)
context switching이 발생할 때 태스크의 현재 실행 위치에 대한 정보를 유지하며 실행중이던 태스크가 대기 상태나 준비 상태로 전이할 떄 이 태스크가 어디까지 실행했는지 기억해 두는 공간이다.
이후 태스크가 다시 실행될 때 기억해 두었던 곳부터 다시 시작하게 된다.

+ task_struct 자료구조 살펴보기.
가. task identification 
태스크를 인식하기 위한 변수들이다. 
- pid/tgid/
- audit_context 구조체를 통해 이 태스크에 대한 사용자 접근권한을 제어할때 이용되는 uid, euid, suid sfuid 등이 있으며 사용자 그룹에 대한 저근제어에 이용되는 gid, egid, sgid, fsgid 등의 변수들도 유지된다.

나. state
태스크는 생성에서 소멸까지 많은 상태를 거치는데, 이를 관리하기 위한 변수이다.
TASK_RUNNING(0), TASK_INTERRUPTIBLE(1), TASK_UNINTERRUPTIBLE(2), 등의 값이 들어간다..

다. task relationship
태스크는 생성되면서 가족관계를 갖는다.
- real_parent: 현재 태스크를 생성한 부모 태스크의 tast_struct 구조체를 가리킴.
- parent: 현재 부모 태스크의 task_struct 구조체를 가리킴
- children/sibiling필드: 자식과 형제를 리스트로 연결 한 뒤 그 리스트의 헤드를 각각 저장

리눅스 커널에 존재하는 모든 태스크 들은 이중 연결 리스트로 연결되어 있다.
연결의 시작은 init_task로 시작되며 각 task_struct 구조체의 tasks라는 리스트 헤드를 통해 연결된다.
또한 태스크 중에 TASK_RUNNING상태인 태스크들은 run_list 필드를 통해 따로 이중 연결리스트로 연결되어 있다.

라. scheduling information
task_struct내의 스케줄링관 관련된 변수는 prio, policy, cpus_allowed, time_slice, rt_priority등이다.

마. signal information
시그널은 태스크에게 비동기적인 사건의 발생을 알리는 매커니즘이다. task_struct에서 시그널과 관련된 변수는 signal, sighand, blocked, pending등이 있다.

바. memory information
태스크는 자신의 명령어와 데이터를 텍스트, 데이터, 스택 그리고 힙공간 등에 저장한다.
task_struct에는 이 공간에 대한 위치와 크기, 접근 제어 정보등을 관리하는 변수들이 존재한다.
+ mm_struct: 또한 가상 주소를 물리 주소로 변환하기 위한 페이지 디렉터리와 페이지 테이블 등의 주소 변환 정보

사. file information
files: 태스크가 오픈한 파일들은 file_struct 구조체 형태인 files라는 변수로 접근 할 수 있다.
fs: 루트 디렉터리의 inode와 현재 디렉터리의 inode는 fs_struct 구조체 형태인 fs라는 변수로 접근 할 수 있다.

아. thread structure
쓰레드 구조는 문맥 교환을 수행할 때 태스크가 현재 어디까지 실행되었는지 기억해놓는 공간이다.

자. time information
태스크가 시작된 시간을 가리키는 start_time, real_start_time등이 있으며, 사용한 CPU시간의 통제를 담는 필드도 있다.

카. resource limits
태스크가 사용할 수 있는 자원의 한계를 의미한다.

+ 상태 전이와 실행 수준 변화
태스크는 생성된 뒤 자신에게 주어진 일을 수행하며 이를 위해 디스크 I/O나 락등 CPU이외의 자원을 요청하기도 한다.
만약 태스크가 당장 제공해 줄 수 없는 자원을 요청한다면 커널은 이 태스크를 잠시 '대기'하도록 만든 뒤 다른 태스크를 먼저 수행시키며, 태스크가 요청했던 자원이 사용 가능해지면 다시 '수행'시켜줌으로써 보다 높은 시스템 활용률을 제공하려 한다.
따라서 태스크는 상태전이라는 특징을 가지게 된다.

exit_state 필드에 저장되는 값 : EXIT_ZOMBIE, EXIT_DEAD
state 필드: 그 외의 상태

준비상태(TASK_RUNNING): 준비상태는 두가지가 있는데, 먼저 태스크가 생성되면 준비(TASK_RUNNING(ready))상태가 되며 두번째는 실제 CPU를 배정받아 명령어들을 처리하게되면 실행(TASK_RUNNING(running))상태가 된다.
다수의 CPU를 가지고 있는 시스템이라면, 실제 실행상태에 있을 수 있는 태스크의 개수는 CPU개수만큼이 된다.

+ 실행 상태에 있는 태스크의 상태전이
1. 태스크가 자신에게 주어진 일을 끝내고 exit()를 호출하거나 kill되면 TASK_DEAD상태로 전이된다.
구체적으로는 task_struct 구조체 내에 존재하는 exit_statue값과 조합하여 TASK_DEAD(EXIT_ZOMBIE)상태로 전이된다.
- ZOMBIE상태는 말 그대로 죽어있는 상태로써, 태스크에게 할당되어 있던 자원을 대부분 커널에게 반납한 상태이다. 그러나 자신이 종료된 이유(error번호), 자신이 사용한 자원의 통계 정보 등을 부모 태스크에게 알려주기 위해 유지되고 있는 상태이다.
- 추후 부모 태스크가 wait()등의 함수를 호출하면 자식 태스크의 상태는 TASK_DEAD(EXIT_DEAD))상태로 바뀌게 되며, 부모는 자식의 종료 정보를 넘겨받게 된다.
그런 뒤 TASK_DEAD(EXIT_DEAD))상태의 자식 태스크는 자신이 유지하고 있던 자원을 모두 반환하고 최종 종료된다.

만약 부모태스크가 자식 태스크에게 wait()등의 함수를 호출하기 전에 먼저 종료되어 없어지면 어떻게 될까? 
부모가 없는 ZOMBIE상태의 자식 태스크 즉, 고아태스크가 시스템에 영원히 존재하게 될것같지만
커널은 고아태스크의 부모를 init태스크로 바꾸어주며(이때문에 task_struct구조체에 real_parent와 parent라는 부모관련 필드가 2개 존재하게 된다고 한다.) init태스크가 wait()등의 함수를 호출 할 때 고아 태스크는 최종 소멸된다.

2. 실행(TASK_RUNNING(running))상태에서 실제 수행되던 태스크가 자신에게 CPU시간을 모두 사용하였거나, 보다 높은 우선순위를 가지는 태스크로 인해 준비(TASK_RUNNING(ready))상태로 전환되는 경우이다.

3. SIGSTOP, SIGTSTP, SIGTTIN, SIGTTOU등의 시그널을 받은 태스크는 TASK_STOPPED 상태로 전이되며, 추후 SIGCONT시그널을 받아 다시 TASK_STOPPED상태로 전이되며, 추후 SIGCONT시그널을 받아 다시 TASK_RUNNING(ready)상태로 전환된다. 한편 디버거의 ptrace()호출에 의해 디버깅 되고있는 태스크는, 시그널을 받는 경우 TASK_TRACED상태로 전이될 수 있다.

4. 실행(TASK_RUNNING(running))상태에 있던 태스크가 특정한 사건을 기다려야 할 필요가 있으면 대기상태(TASK_INTERRUPTIBLE, TASK_UNINTERRUPTIBLE, TASK_KILLABLE)로 전이 한다. 태스크가 디스크 같은 주변 장치에 요청을 보내고 그 요청이 완료되기까지 기다리거나, 사용중인 시스템 자원 대기등이 대표적인 예이다. 
- TASK_INTERRUPTIBLE과 TASK_UNINTERRUPTIBLE상태는 모두 특정 사건을 기다린다는 면에서는 유사하나. TASK_UNINTERRUPTIBLE상태는 시그널에 반응하지 않는다는 점에서 TASK_INTERRUPTIBLE과 구분된다.
시그널에 반응하지 않는 문제점등으로 인해(kill -9 pid가 먹히지 않는 상황) SIGKILL과 같은 중요한 시그널에만 반응하는 TASK_KILLABLE상태가 도입되었다.
 
대기상태로 전이한 태스크는 기다리는 사건에 따라 특정 큐에 삽입되어 대기하게 된다.
실행중이던 태스크가 대기상태로 전환하게 되면 다시 스케줄러가 호출되며 
스케줄러는 준비상태에 있는 태스크중 하나를 선택하여 다시 실행 상태로 만든다.
(사건을 기다려야하는 태스크를 사건이 발생할때까지 CPU를 붙잡고 있게하지 않고 대기상태로 만들고 다른 준비상태에 있는 태스크를 실행시킴으로써 CPU의 가동률 최대화!)

태스크가 기다리고 있던 사건이 발생하면 대기상태에 있던 태스크가 다시 준비 상태로 전이하게 되며(이때 멀티코어 시스템에서 발생 가능한 데드락을 방지하기 위해 내부적으로 TASK_WAKING상태가 사용된다.) 다시 다른 태스크들과 함께 스케줄링 되기 위해 경쟁하게 된다.
태스크가 자신의 임무를 다하면 TASK_DEAD 상태가 된다.

실행 중인 상태의 태스크는 실행권한에 따라 사용자 수준 실행상태와 커널 수준 실행 상태로 구분 할 수 있다.
- 사용자 수준 실행 상태: CPU에서 사용자 수준 프로그램의 제작자가 만든 응용 프로그램이나 라이브러리 코드를 수행하고 있는 상태.
- 커널 수준 실행 상태: CPU에서 커널 코드의 일부분을 수행하고 있는 상태로, 사용자 수준 권한보다는 더 강력한 커널권한으로 동작 한다.
+ 커널 수준 권한은, 사용자 수준 권한에서는 접근이 금지된 커널 내부 자료 구조를 접근하거나 수행이 금지된 특권 명령어를 커널 수준 권한에서 수행 할 수 있다는 의미이다.

+사용자 수준 실행상태에서 커널 수준 실행 상태로 전이 할 수 있는 방법
1. 시스템 호출 사용: 태스크가 시스템 호출을 요청하면 리눅스 커널에 트랩이 걸리게 되고 그결과 태스크의 상태가 커널 수준 실행 상태로 전이 되며 커널의 시스템 호출 처리 루틴으로 제어가 넘어가게 된다.
2. 인터럽트 발생: 마찬가지로 인터럽트가 발생하면 커널의 인터럽트 처리루틴으로 제어가 넘어가게 된다.

+사용자 수준에서 프로그램이 수행될 떄는 3GB아래 부분에 스택을 배치하고 수행된다.(32bit 기준) 그럼 CPU가 커널 수준 코드를 수행할 때는 어떨까?


리눅스도 C와 어셈블리로 작성된 소프트웨어 이기 때문에 수행되기 위해서는 스택을 필요로 한다.
커널은 태스크가 생성될 떄 마다 태스크 별로 8KB 혹은 16KB의 스택을 할당해 준다.(설정에 따라 다름) 
A라는 태스크가 시스템 호출을 요청했다면 이를 처리하기 위해 리눅스 커널은 A에게 할당해 주었던 커널 스택을 사용하여 요청된 작업을 수행해 주고, B라는 태스크가 시스템 호출을 요청했다면 역시 이를 처리하기 위해 B에게 할당해 주었던 커널 스택을 사용하여 요청된 작업을 처리해준다.
-정리하자면 태스크가 생성되면 리눅스는 task_struct 구조체와 커널 스택을 할당하게 된다.

-태스크당 할당되는 커널 스택은 thread_union이라 불리며 thread_info 구조체를 포함하고 있다.(thread_info 구조체를 리눅스에선 프로세스 디스크립터라 부르긷 한다.) 이 구조체 안에는 해당 태스크의 task_struct를 가리키는 포인터와, 스케줄링의 필요성 여부를 나타내는 플래그, 태스크의 포맷을 나타내는 exec_domain 등의 필드가 존재한다.

-만약 태스크가 시스템 호출 등을 통해 커널 수준 실행 상태로 진입한 뒤, 수행해야 할 일 을 모두 마쳤다면, 다시 사용자 수준 실행상태로 복귀하여 수행하던 곳에서부터 다시 작업을 시작해야 할 것이다.
그러기 위해서는 커널과 사용자 수준간의 변화시에 현재까지의 작업상황을 어딘가에 저장해 놓아야 하는데 이는 커널로 진입되는 시점에 커널 스택 안에 현재 레지스터의 값들을 구조체를 이용하여 일목요연하게 저장함으로써 이뤄진다.= pt_regs라는 이름을 가진다.

+open()시스템콜을 사용자 태스크가 호출하는 상황을 통해 알아보자.
이떄 open()이라는 시스템 호출의 번호는 unistd.h라는 헤더파일에 정해져 있고, 인텔 CPU의 기준으로 본다면 eax레지스터에 저장된다.
open()이라는 시스템 콜의 실제 핸들러 함수는 세개의 인자를 받는데, 이 인자들은 각각 ebx, ecx, edx에 저장한다 이 의미는 시스템 호출 도중에 이 레지스터들의 내용이 바뀌게 되며 이를 pt_regs구조체에 담아서 커널 스택내에 저장한다는 것이다.

sys_open()함수가 수행을 끝내고 사용자 수준으로 다시 리턴될 때 pt_regs구조체에 저장했었던 ebx, ecx, edx 레지스터 값을 사용하여 이전 실행 상태를 복원할 수 있다.

커널 수준 실행 상태에서 사용자 수준 실행상태로 전이할 때 리눅스 커널이 처리하는 일들
1. 현재 실행중인 태스크가 시그널을 받았는지 확인 => 받았다면 시그널 처리 핸들러 호출
2. 다시 스케줄링 해야할 필요가 있다면(현재 태스크의 need_resched 플래그가 1로 set되어 있는경우) 스케줄러를 호출한다.
3. 커널 내 에서 연기된 루틴들이 존재하면 이들을 수행한다.

댓글