강의/system programming

[system programming] Linux 운영체제 Exception Control Flow

하기싫지만어떡해해야지 2025. 3. 30. 19:06

본 게시글은

서울대학교 데이터사이언스대학원 정형수 교수님의 

데이터사이언스 응용을 위한 시스템 프로그래밍 강의를

학습을 목적으로 재구성하였습니다


 

 

이번 시간에 배울 내용은

linux 운영체제 내에서의 exception control flow에 대한 내용이다

 

이번 수업의 내용은 굉장히 중요한 내용인데

이 내용을 이해를 하지 못하면

이 시스템 프로그래밍이라는 수업 자체를

따라가기가 매우 힘들어 진다고 한다

 

그래서 교수님께서 이번 수업은 강의 녹화본을 올릴테니

이해가 안가면 갈때까지 영상을 보며 이해하라고 하셨다..

 

지금까지는 단순히 assembly code와

user program 내에서 어떻게 메모리와 register가 이동하고

어떤 과정을 거치는지를 알아봤다면

오늘 배우는 내용은 지금까지 배우는 내용에서

더 확장된 내용이다

 

그리고 여기서 말하는 exception이란건

우리가 흔히 코딩할 때 쓰는

try, exception 같은 개념이 아니다

 

한 마디로 정리하자면,

컴퓨터가 어떤 프로그램을 수행하고 있는데

다른 작업을 어떻게 수행하는지에 대해 담은 내용이고

특정 프로그램이 실행 중인데 다른 작업에 대한 요청이 들어오는 것을

시스템 프로그래밍에서는 exception이라고 부른다

 

 

 

우리가 컴퓨터로 어떤 코드를 실행시켰다

그럼 CPU에서는 해당 instruction을 열심히 수행시키고 있을 것이다

그런데 분명히 우리의 컴퓨터는

코드를 수행시키면서 우리가 마우스로 어떤 것을 클릭하면

그것에도 반응하고

키보드로 뭔가를 타이핑하면 그것도 그대로 받아들인다

이것이 어떻게 가능한걸까?

 

우리가 만약 마우스를 움직이게 된다면

그 마우스에 대한 신호는 CPU까지 가게 된다

보통 전기전자 쪽에서는 "핀을 켠다"라고 얘기를 하는데

와이어에서 핀을 켜면서 마우스 전기 신호를 CPU에게 준다

 

CPU는 모든 Instruction을 수행하면서

마우스와 같은 핀이 켜져있는지 아닌지 체크를 한다

그렇게 체크하다가 핀에 신호가 들어오면

어느 핀인지를 체크한 다음 해당하는 미리 운영체제가 정의해놓은

핸들러를 찾아서 수행한다

그런데 이건 어떤 작업을 수행하는 중간에

갑자기 넘어가는 것과 동일하다

그래서 핀이 켜지게 되면 그 상태의 cpu를

register와 return address를 고대로 저장한다

그리고 주변 장치의 event에 맞게 작성된 handler로

가기 위한 준비 작업들을 수행하는데

save를 하고, 운영체제의 kernel stack에 접근하고..

등과 같은 작업들을 수행한다

 

이러한 일련의 과정을 exception control이라고 한다

 

 

 

exception의 정의이다

내가 지금까지 어떤 프로그램을 수행하고 있는데

외부에서 event가 들어올 때 처리하는 것을 말한다

 

이것을 처리하는 코드는 os에서 handler로 이미 구현되어있다

우리는 내부에서 우리가 실행시킨 user code를 작동시키다가

외부 event pin이 켜지면

코드를 멈추고 current, next instruction과 관련된

register를 모두 저장한다

그런 다음 exception으로 가서

exception processing by excpetion handler를 수행시킨다

그다음 결과를 return 해주고

다시 원래의 user code돌아가서 아무일도 없었다는 듯이

수행을 계속한다고한다..

 

이렇게 하는게 user 모드에서 os 모드로 왔다갔다 하는 것인데

이렇게 모드를 변경하는 것을 mode switch라고 한다

뒤에 context switch도 나오는데

mode switch와 context switch는 확연히 다른 내용이다

 

그리고 이러한 것을 exception이라고 부르는 이유는

우리의 컨트롤과 상관없이 넘어가는 것이기 때문이다

 

 

exception을 위해서 exception table이라는 것이 존재한다

interrupt description table(IDT)라고도 부르는데

이 테이블은 처음에 우리가 컴퓨터를 부팅하는순간 생성된다

 

번호에 따라서 이미 만들어진 함수의 주소를 담고있고

예를 들면

0번은 timer 관련 핸들러

1번은 마우스 관련 핸들러

2번은 SSD 관련 핸들러

3번은 network 관련 핸들러 등..

 

그걸 CPU 핀에 날라오는 와이어의 number를 보고

몇 번 Handler를 불러야할지 결정하는 것이다

 

이걸 하게해주는 하드웨어가 있는데

PIC(Programmable interupt controller)이다

주변장치에서 신호가 들어왔을 때

PIC에 의해서 CPU의 pin에 해당하는 number를 정확하게 가져올 수 있다

그런 다음 현재 동작하고 있는 context를 save 시킨다

그러고 exception table 안에 들어있는

function pointer에 있는 코드로 점프해서

코드를 수행하고 다시 돌아온다

 

모든 CPU는 IDT를 빠르게 찾아가기위해

IDT의 base pointer를 항상 가리키고있는

register를 갖고 있는데 이를 IDTR register라고 한다

 

user mode에서 IDTR을 핸들링하려고하면 프로그램이 바로 죽어버리고

운영체제가 처음에 부팅할 때만 세팅할 수 있는 레지스터이다

 

 

이번 수업의 가장 첫 OT 때부터 교수님이 강조해왔던 내용..

htop을 다운받아서 terminal에서 htop을 실행시켜보면

현재 프로세스가 굉장히 많다는 것을 알 수 있다

 

하지만 우리는 그냥 한개의 프로세스가

CPU를 혼자 점유하고 있는 것처럼 느껴진다

 

프로그램이 실행되려면 메모리 영역에는 stack, heap, data, code가 있어야하고

CPU에는 register가 있어야한다

하지만 CPU는 물리적으로 제한이 되어있는데

어떻게 여러 가지 프로세스를 실행시킬 수 있는걸까?

 

 

매 순간에 core가 1개밖에 없다면

내가 실행할 수 있는 프로그램은 한 개 뿐인 것이다

 

그럼 나머지 프로그램들은 다 멈춰있어야하는데

그래서 현재 동작하고 있는 프로그램에 대해 캡처하는

추상화(abstraction)이 필요해진다

 

이걸 process라고 부르는 것이다

 

다시 한 번 process의 정의에 대해서 설명하자면

현재 동작하고 있는 running program에 대한 추상화이다

 

현재 동작하고 있는 프로그램이 무엇인지를 알아야

잠깐 이 프로그램을 멈추고 다음 프로그램을 실행시킬 수 있다

그 프로그램이 뭔지 알아야하는게

memory와 CPU의 state이다

 

따라서 process는 결국 memory + CPU의 state가 되는 것이다

 

그래서 결국 core가 1개더라도

동작하고 있는 program을 추상화해서 process를 생성한다

그래서 이 process를 왔다갔다 돌아가면서 수행시키기 위해

전부 save시키고 restore하는 과정을 반복해서 동작시키기 때문에

각각의 프로그램을 돌아가면서 돌릴 수 있는 것이다

 

이런식으로 결국 컴퓨터는 한 번에 한개의 연산밖에 수행을 못하고

이걸 멈추고 다시하고 멈추고 다시하고의 과정을 통해서

여러 개의 프로그램을 실행시키고 있는 것인데

우리는 컴퓨터가 모든 프로그램을 동시에 실행시키고 있다고 착각을 한다

이게 바로 os에서 가장 중요한 개념인 illusion이다

 

그런데 프로세스와 프로세스 사이의 term을 얼마나 가져갈지는

아직까지도 딜레마라고한다

 

term을 짧게 가져가면 사람은 눈치재지 못하지만

save와 restore의 횟수가 많아지기 때문에

나중에는 프로세스 수행 시간보다

save와 restore에 사용하는 시간이 더 많아질수가 있다

 

그렇다고 term을 길게 가져가면?

사람에게 illusion을 가져다주지못한다

 

 

이건 multi core procerssor에 대한 설명이다

main memory와 cache memory를 공유하면서

여러 개의 별도의 process를 실행시킬 수 있다

 

 

 

macOS 기준 shell에 접속해서 명령어를 치는걸 생각해보자

우리가 가장 많이 사용하는 명령어인 ls를 예시로 생각해보자

 

shell에 ls를 치고 엔터를 치면

shell이 우리가 입력한 명령어로 process를 생성한다

 

원래라면 shell은 process를 생성하고

command가 끝날 때 까지 기다리게 되는데

명령어 끝에 &를 붙이면 background에서 실행시킬 수도 있다

 

이걸 타임라인 그래프로 표현해보면

A조금 수행하고, B조금 수행하고, C조금 수행하고..

이런 과정을 반복한다

 

이걸 time sharing이라고 하는데

1960년대에 unix timesharing system이라는 논문이 나왔고

이 timesharing system은 아직까지도

가장 대표적인 방법론으로 사용되고있다

 

timesharing을 당시에 개발자들이 assembly code로 개발하다가

너무 힘들어서 만들게 된 언어가 바로 c언어라고한다

한마디로 원래 운영체제를 개발하기 위해서

만들어진 언어인 것이다

 

 

운영체제의 핵심 동작이 아까 앞에서 말했던

illusion과 virtualization이고

이걸 가능하게 해주는 핵심 개념이 바로 time sharing이다

 

 

cpu virtualization의 핵심은 program을 process로 추상화시키는 것이다

그러고 이 process는 memory와 cpu로 구성되어있다

 

cpu register set을 운영체제가 들고 있다가

멈췄을 때 다 저장해버린다

그런 다음 다시 restore을 수행하는데 이건

우리가 앞에서 배웠던 move로 수행한다

 

 

 

이게 CPU의 상태인데

이걸 abstracting 해줘야한다

 

 

 

우리가 shell에 command를 치는 순간

내부에서 process를 만든다

 

프로그램을 올릴 때 라이브러리가 있는 경우

linker가 라이브러리들 모두 memory 영역에 올려준다

그리고 그 안에서 어떤 함수가 어디에 있는지

메모리 주소를 해당하는 function에다가 적어준다

이걸 Linker loader라는 친구가 수행한다

 

이 작업을 다 끝내고 마지막에 start main이라는 address를

%rip에 적어주면 program이 시작되게 된다

 

이걸 process start라고 한다

 

 

 

이제 구체적으로 shell이 어떻게 Process를 만드는지 살펴보자

(원래 학부생 수업에서는 shell을 직접 구현해서 만드는 것만

2주에 걸쳐서 수행한다고한다)

 

process를 수행할 때 첫 번째로 하는 일은 fork라고 한다

fork는 새로운 child process를 만드는 작업인데

fork를 수행하면 shell이 한 개가 더 생성된다

 

 

 

 

fork 명령어를 수행하면 shell이 한 개 더 생성되고

원래 있던 구조와 똑같이 복사한다

그래서 fork 수행 후에는 그냥 shell이 한 개 더 생기는 결과가 나온다

 

 

이 fork가 끝나면 execve라는 것을 수행하는데

fork를 실행하면 return value는 2개가 나온다

 

첫 번째는 fork를 실행했던 부모 process

그다음은 새롭게 생성된 child process이다

 

부모의 pid는 child의 pid이고

child의 pid는 0이다

이렇게 pid를 확인해서 어떤 것이 child process인지 확인한다

 

위 ppt에 linux에서 fork를 실행했을 때

parent와 child의 결과가 예시로 나와있다

parent와 child의 순서는 그냥 마음대로라고한다

 

 

 

 

위 코드를 통해 예시를 살펴보자

맨 처음에 L0을 출력하고 fork를 한다

그럼 shell이 한 개 더 생성되면서

부모도 L1, 자식도 L1을 출력하게 된다

거기서 또 각각 fork를 수행하게 되기 때문에

총 4개의 process가 만들어지고

Bye는 4번이 출력되게 된다

 

이 4개의 process는 fork 명령어를 통해

고대로 복사된 것이기 때문에

모두 똑같은 memory 상태를 가진다

 

 

 

fork를 했을 때 자식이 너무 많아지면 안되기때문에

wait system call이라는 것이 있다

 

fork를 통해서 생성됐던 자식 process가

수행이 완료될때까지 parent는 기다리겠다는 의미이다

 

 

 

위 ppt의 코드에서 else 부분이 부모의 파트이다

 

fork를 수행하면 parent와 child가 동시에 수행되지만

parent는 child가 마무리 되기까지 기다린다

 

fork() == 0으로 child임을 확인하면

hello from child가 출력되고

자식이 exit(0)해서 process를 멈춘다

 

그럼 마지막 child has terminated 부분이 수행되고

printf의 출력 순서가 정확하게 맞춰지게된다

 

우리가 쓰는 os의 shell은 이런 과정을 통해 컨트롤을 하고있다

 

 

 

아까 앞에서 잠깐 언급했는데

fork 명령어를 수행한 다음에 수행되는 것이

execve이다

 

execve의 argument로는 파일 이름이 들어가는데

그럼 현재 fork 되어있는 이 process를

memory와 cpu를 해당 filename에 있는 것으로 바꿔달라는 뜻이다

 

그래서 드디어 fork해서 새로 복사해서 생성한 shell이

execve를 통해서 내가 수행한 명령으로 바뀌게 되는 것이다

아예 새로 process를 init하는 것이다

 

fork를 하는 이유는 그냥 새로운 process를 만들

껍데기를 생성한 것이고

execve를 해야지 내가 정말 수행하고 싶은 프로세스로 바뀌는 것이다

 

 

 

결국 우리가 쳤던 이런 명령어들을 모두 담아서

execve가 수행을 시키는 것이다

 

위 노란색 박스의 코드에서

execve(myargv[0], myargv, environ) 이런 코드를 볼 수 있는데

myargv[0] 이 부분을 실행시켜달라는 뜻이다

 

그래서 execve가 성공적으로 수행되면 다음 코드는

실행이 될 수가 없다

왜냐면 execve가 성공했다는 소리는

기존에 있던 memory가 모두 변경되었다는 뜻이기 때문에

execve 아래의 코드는 절대로 수행될 수가 없다

 

execve가 메모리 셋업 다해주고 코드도 올리고

linker loader의 역할도 죄다 다 해준다음

그 다음에 다시 돌아오지 않도록 해준다

 

 

 

stack 메모리에 모든 것들이 다 저장된다

저 libc_start_main부터 만약 우리가 생성한 process가 ls라고 한다면

main function이 사용될 수 있는 코드들이 셋업된다

 

현재 current가 무엇인지 argument는 무엇을 받았는지 등을

모두 다 운영체제가 셋업을 해준다

그런다음 libc_start_main을 한 다음에 rip에

이 부분에 Pointer를 던지게 된다

 

그럼 이제 거기서부터 수행이 시작되는 것이다-!

 

 

 

가장 마지막이 program text 코드이고

위 ppt의 그림처럼 환경설정과 셋팅을 전부다 다 해준다음

program text의 시작 주소를 CPU의 rip에 넣어준다

 

그때부터 process가 run되는 것이다

 

 

 

CPU가 동작을 하고 있을 때는

현재 state를 save 시킬 수가 없다

 

그래서 CPU가 동작을 멈추게 한 다음 save를 시켜줘야한다

그래서 동작을 멈추게 하기 위한 작업이 필요하다

그러고 CPU를 멈추게하면 뭔가를 저장할 무언가가 필요하다

이걸 운영체제에서 PCB(Process Control Block)이라고 부른다

 

PCB 안에 메모리가 어디서부터 어디까지 사용하고있고

지난번에 멈췄을 때 CPU의 register 상태가 무엇이고

혹시나 지금 멈추면 register를 어떻게 저장해야할지

register name 등을 모두 기록해 놓는다

 

linux에서는 이걸 task structure라고 부른다

 

 

PCB 내부를 잠깐 살펴보자

PCB 내부에서 context를 담당하는 부분이다

이렇게 cpu의 context를 담아둘 수 있는

변수들이 다 선언되어있다

이미 이전 수업에서 배웠듯이

64비트이면 e가 아니라 r이 들어간다

 

그래서 이렇게 들고 있으면 running program을

항상 구현할 수 있다고 한다

 

운영체제 측면에서는 PCB를 그냥 process라고 인식하고

이렇게 save된 A process의 context를

B process의 context로 바꿔주는 일련의 작업을 바로

앞에서 잠깐 나왔던 context switching이라고 한다

당연히 느껴지겠지만 mode switching보다 훨씬 비싼 작업이다

 

 

위 코드를 한 번 잘 살펴보자

 

kstack은 커널 스택이다

exception handling을 할 때 kstack으로 stack을 change한다

이 과정은 mode switching이다

 

stack이 kernel stack으로 바뀌면

현재 user program이 동작하고 있던걸 저장한다음

mode switching을 해주는 것이다

 

CPU는 kernel stack을 별도의 register에 기억을 해둔다

user stack을 가리키는 rsp가 있고

kernel stack을 가리키는 register도 존재한다

 

그래서 프로그램 수행 중 pin이 잡혔을 때

딱 고상태에서 고대로 멈추고

stack change를 하고 변경된 stack에서 작업을 수행한다

 

 

 

user stack과 kernel stack을 비교한 것이다

user stack은 왼쪽, kernel stack은 오른쪽이다

user stack은 CPU에서 esp 혹은 rsp가 가리키고 있다

 

user mode에서 kernel mode로 모드를 변경해주려고 하면

현재 CPU의 register 값들을 esp부터 쭈우욱 저장한다

마지막에 현재 user가 동작하다가 멈춰진 그 지점의

다음 지점으로 돌아갈 수 있는 지점인 eip도 저장한다

그러고 나서 stack change를 한 다음

kernel mode로 들어가서 필요한 동작을 수행한다

다 끝나서 다시 return하게 되면 다시 mode를 변경해주는 것이다

 

실제 내부에서는 위와 같이 구현이 되어있다고 한다

 

 

 

앞에서 말했던 exception table이다

IDTR register는 컴퓨터 부팅부터 영원히 바뀌지 않고

계속 똑같은 곳만 가리키고 있는다고 한다

 

 

 

IDTR register의 모습이다

 

우리가 마우스를 움직이면 pin으로 number가 기록된다고 한다

그럼 PIC에 신호를 주고

CPU는 그 자리에서 지금까지 수행하던 것을 멈추고

number를 왼쪽의 PIC에서 받아온다

 

그걸 IDTR를 사용하여 해당 handler 주소를 찾아오는데

number는 보통 32비트 기준 4바이트 단위로 저장이 되는데

number * 4를 하면 해당 handler의 주소로 바로 찾아올 수 있으므로

그런 방식으로 계산된 주소로 이동하고

IDT에서 해당 핸들러를 실행시킨다

 

 

 

위 ppt에서 색이 진한 것들이 CPU의 중요한 register들이라고 한다

오른쪽에 있는 여러 handler function들은

운영체제 개발자들이 구현한 것이다

 

 

 

이 부분부터는 context switching이 구체적으로

어떻게 이뤄지는지에 대한 설명인데

시간이 부족해서 동영상 강의를 통해 따로 설명을 해서 올려주신다고한다

ㅋㅋㅋ

 

아직 영상이 안올라와서 영상이 올라오면

시청해서 공부를 하고 다시 정리한 내용을 올리려고 한다

 

마지막을 context switching이 비싼 작업인 이유를

설명하고 이번 수업 정리를 마무리하려고 한다

 

context switching은 A process에서 B process로

memory 영역을 바꿔주는 것인데

원래 동작하고 있던 memory 영역과

새로운 memory 영역은 다른 영역이다

 

그런데 이걸 바꿔주면 무슨 일이 발생하게 되냐?

원래 동작하고 있던 cache의 content가 단 하나도 안맞게 된다

그럼 cache 메모리에 저장되어있던 데이터를

전부 flush 시켜야 되는 것이다

 

그래서 context switching이 수행되면

cache memory는 전부 flush가 된다

cache는 core에 존재해서 속도를 빠르게 도와주는 메모리인데

이게 다 flush가 되어버리기 때문에

전부 메모리로 돌아가서 다시 접근해야한다

 

그래서 context switching을 빈번하게 유발하면

속도가 느려지게 되는 것이다

정확히 말하면 프로그램의 속도가 느려지는게 아니고

memory access가 증가하기 때문에

여기서 유발되는 속도가 느려지는 것이다

 

 

아무튼 그렇다고한다..

 

그렇다면 오늘 수업 내용 정리는 여기까지 -!

다음시간에는 CPU 아키텍처에 대해 배운다고한다