개략적인 컴퓨터 구조와 프로그램 동작 과정

Posted by yunki kim on March 17, 2023

  아래와 같이 "Hello world"를 출력하는 간단한 프로그램은 개발 공부를 처음 시작하는 사람이라면 누구나 한 번쯤은 작성해 봤을 겁니다. 

1
2
3
4
5
#include <stdio.h>
 
int main(void) {
    printf("Hello world\n");
}
cs

  이 프로그램을 실행해 보고는 더 많은 지식들을 얻기 위해 책을 다음 페이지로 넘깁니다. 하지만, 이 간단한 프로그램 동작과정에는 보이지 않는 지식들이 수도 없이 들어있습니다.

  이 글에서는 위 코드를 예시로 우선 소프트웨어 측면에서 위 프로그램이 실행되기 위한 소프트웨어적인 변환 과정을 설명합니다. 그 후, 하드웨어와 OS 관점에서 동작 과정을 설명하면서 개략적인 컴퓨터 구조를 설명합니다.

  이 글은 인텔 x86 아키텍처를 기반으로 작성되었습니다.

hello.c 텍스트 파일을 오브젝트 파일로 만들기까지

  프로그래머가 소스 파일을 작성하는 것이 프로그램에게 생명을 불어넣는 첫 단계입니다. 작성한 소스 파일은 hello.c라는 이름의 텍스트 파일로 저장됩니다. 소스 파일은 비트들의 연속이고 단위는 8비트(1byte)입니다. 또 한, 대부분의 컴퓨터 시스템은 문자를 나타내기 위해 ASCII 표준을 사용합니다. 따라서 "#include <stdio.h>"를 연속된 바이트로 바꾸면 "35 105 110 99 108 117 100 101 32 60 115 116 100 105 111 46 104 62"가 됩니다. hello.c처럼 ASCII 문자로만 이루어진 파일들을 텍스트 파일, 나머지 유형 파일들을 바이너리 파일이라 합니다. 

  hello.c 소스 파일 작성에 사용한 C 프로그램은 인간이 이해하고 읽을 수 있지만 기계는 이해하지 못합니다. 따라서 hello.c 내에 있는 코드들은 저급 기계어 인스트럭션(2진 비트들로 구성돼 있다)들로 번역돼야 합니다. 번역된 인스트럭션들은 실행가능 목적프로그램이라는 형태로 합쳐져서 바이너리 디스크 파일로 저장됩니다. 이 과정을 도식화하면 다음과 같습니다.

  이제 이 단계들을 하나씩 간단히 살펴보겠습니다.

Pre-prosessor(cpp)

  전처리기인 cpp는 C 프로그램을 # 문자로 시작하는 디렉티브(directive)에 따라 수정합니다. "#include"는 뒤에 따라오는 파일을 프로그램 문장에 삽입할 것을 지시합니다. 따라서 예제의 경우 "stdio.h" 헤더 파일이 삽입됩니다. hello.c 파일이 pre-processor 과정을 거쳐 hello.i 파일로 변환 결과를 보기 위해선 다음과 같은 명령어를 실행시키면 됩니다.

1
gcc -E hello.c -o hello.i
cs

  변환된 hello.i 파일은 내부는 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
0 "hello.c"
0 "<built-in>"
0 "<command-line>"
1 "/usr/include/stdc-predef.h" 1 3 4
0 "<command-line>" 2
1 "hello.c"
1 "/usr/include/stdio.h" 1 3 4
27 "/usr/include/stdio.h" 3 4
1 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 1 3 4
33 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 3 4
1 "/usr/include/features.h" 1 3 4
392 "/usr/include/features.h" 3 4
1 "/usr/include/features-time64.h" 1 3 4
20 "/usr/include/features-time64.h" 3 4
1 "/usr/include/x86_64-linux-gnu/bits/wordsize.h" 1 3 4
21 "/usr/include/features-time64.h" 2 3 4
1 "/usr/include/x86_64-linux-gnu/bits/timesize.h" 1 3 4
19 "/usr/include/x86_64-linux-gnu/bits/timesize.h" 3 4
1 "/usr/include/x86_64-linux-gnu/bits/wordsize.h" 1 3 4
20 "/usr/include/x86_64-linux-gnu/bits/timesize.h" 2 3 4
22 "/usr/include/features-time64.h" 2 3 4
393 "/usr/include/features.h" 2 3 4
486 "/usr/include/features.h" 3 4
1 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 1 3 4
559 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 3 4
1 "/usr/include/x86_64-linux-gnu/bits/wordsize.h" 1 3 4
560 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 2 3 4
1 "/usr/include/x86_64-linux-gnu/bits/long-double.h" 1 3 4
561 "/usr/include/x86_64-linux-gnu/sys/cdefs.h" 2 3 4
487 "/usr/include/features.h" 2 3 4
510 "/usr/include/features.h" 3 4
1 "/usr/include/x86_64-linux-gnu/gnu/stubs.h" 1 3 4
10 "/usr/include/x86_64-linux-gnu/gnu/stubs.h" 3 4
1 "/usr/include/x86_64-linux-gnu/gnu/stubs-64.h" 1 3 4
11 "/usr/include/x86_64-linux-gnu/gnu/stubs.h" 2 3 4
511 "/usr/include/features.h" 2 3 4
34 "/usr/include/x86_64-linux-gnu/bits/libc-header-start.h" 2 3 4
28 "/usr/include/stdio.h" 2 3 4
 
 
 
 
 
1 "/usr/lib/gcc/x86_64-linux-gnu/11/include/stddef.h" 1 3 4
209 "/usr/lib/gcc/x86_64-linux-gnu/11/include/stddef.h" 3 4
 
209 "/usr/lib/gcc/x86_64-linux-gnu/11/include/stddef.h" 3 4
typedef long unsigned int size_t;
34 "/usr/include/stdio.h" 2 3 4
 
 
1 "/usr/lib/gcc/x86_64-linux-gnu/11/include/stdarg.h" 1 3 4
40 "/usr/lib/gcc/x86_64-linux-gnu/11/include/stdarg.h" 3 4
typedef __builtin_va_list __gnuc_va_list;
37 "/usr/include/stdio.h" 2 3 4
 
1 "/usr/include/x86_64-linux-gnu/bits/types.h" 1 3 4
27 "/usr/include/x86_64-linux-gnu/bits/types.h" 3 4
1 "/usr/include/x86_64-linux-gnu/bits/wordsize.h" 1 3 4
28 "/usr/include/x86_64-linux-gnu/bits/types.h" 2 3 4
1 "/usr/include/x86_64-linux-gnu/bits/timesize.h" 1 3 4
19 "/usr/include/x86_64-linux-gnu/bits/timesize.h" 3 4
1 "/usr/include/x86_64-linux-gnu/bits/wordsize.h" 1 3 4
20 "/usr/include/x86_64-linux-gnu/bits/timesize.h" 2 3 4
29 "/usr/include/x86_64-linux-gnu/bits/types.h" 2 3 4
 
 
typedef unsigned char __u_char;
typedef unsigned short int __u_short;
typedef unsigned int __u_int;
typedef unsigned long int __u_long;
 
// 생략 ...
 
extern void funlockfile (FILE *__stream) __attribute__ ((__nothrow__ , __leaf__));
885 "/usr/include/stdio.h" 3 4
extern int __uflow (FILE *);
extern int __overflow (FILE *int);
902 "/usr/include/stdio.h" 3 4
 
2 "hello.c" 2
 
 
3 "hello.c"
int main(void) {
 printf("hello world");
}
 
cs

Compiler(cc1)

  cc1이라는 컴파일러가 텍스트 파일 hello.i를 hello.s로 변환합니다. '. s'는 어셈블리 파일입니다. 어셈블리어는 상위 수준 언어들의 컴파일러들을 위한 공통 출력 언어를 제공합니다. hello.c 파일은 다음과 같은 명령어를 통해 어셈블리어로 변환할 수 있습니다.

1
gcc -S helloworld.c
cs

  변환된 hello.s 파일은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
    .file    "hello.c"
    .text
    .section    .rodata
.LC0:
    .string    "hello world"
    .text
    .globl    main
    .type    main, @function
main:
.LFB0:
    .cfi_startproc
    endbr64
    pushq    %rbp
    .cfi_def_cfa_offset 16
    .cfi_offset 6-16
    movq    %rsp, %rbp
    .cfi_def_cfa_register 6
    leaq    .LC0(%rip), %rax
    movq    %rax, %rdi
    movl    $0, %eax
    call    printf@PLT
    movl    $0, %eax
    popq    %rbp
    .cfi_def_cfa 78
    ret
    .cfi_endproc
.LFE0:
    .size    main, .-main
    .ident    "GCC: (Ubuntu 11.3.0-1ubuntu1~22.04) 11.3.0"
    .section    .note.GNU-stack,"",@progbits
    .section    .note.gnu.property,"a"
    .align 8
    .long    1f - 0f
    .long    4f - 1f
    .long    5
0:
    .string    "GNU"
1:
    .align 8
    .long    0xc0000002
    .long    3f - 2f
2:
    .long    0x3
3:
    .align 8
4:
cs

Assembler(as)

  이 단계에서는 어셈블러(as)가 hello.s를 기계어 인스트럭션으로 번역하고, 이들을 재배치(realocation) 가능 목적프로그램(오브젝트 코드) 형태로 묶습니다. 여기서 재배치란 목적프로그램을 원래 정의되었던 주소와 다른 주소에 적재될 수 있게 목적 프로그램을 변경하는 것입니다. 재배치 목적프로그램은 다른 재배치 가능 목적 프로그램과 결합하게 됩니다. hello.o는 바이너리 파일로 다음과 같은 명령어로 생성할 수 있습니다.

1
gcc -c hello.c
cs

  변환된 hello.o 파일은 다음과 같습니다.

1
2
3
4
ELF>`@@
óúUH‰åHH‰Ç¸è¸]Ãhello worldGCC: (Ubuntu 11.3.0-1ubuntu1~22.0411.3.0GNUÀzRx #E†C
Z ñÿ    #hello.cmainprintf üÿÿÿÿÿÿÿüÿÿÿÿÿÿÿ .symtab.strtab.shstrtab.rela.text.data.bss.rodata.comment.note.GNU-stack.note.gnu.property.rela.eh_frame @#@ 0 &c,c1c 90o,B›R  jÀ8e@Ð     ø     ˆèt
 
cs

Linker(1d)

  hello.c에서 호출하는 printf 함수는 표준 C 라이브러리에 존재합니다. 위 과정에서 산출된 hello.o 파일은 hello.c 내부 코드만 바이너리로 변환했을 뿐, 표준 C 라이브러리가 제공하는 함수들이 포함돼 있지는 않습니다. 따라서 작성한 코드와 포함해야 하는 라이브러리들을 하나의 실행가능 목적 파일로 묶어줘야 하는데 링커가 이 일을 합니다. 

  'hello.c'에서 호출한 printf에 대한 목적 파일은 '/usr/bin' 디렉터리에 위치하는 것을 확인할 수 있습니다.

  링커는 printf 목적파일과 hello.o파일을 결합시킵니다. 이로 인해 hello 파일은 실행가능 목적파일로 메모리에 적재된 시스템에 의해 실행됩니다.

컴퓨터 구조

  위 과정을 통해 사용자가 작성한 소스 프로그램은 hello라는 실행가능한 목적 파일로 번역되어 디스크에 저장되었습니다. 이제 './hello'라는 명령어로 hello 목적파일을 실행시켰을 때 어떤 과정으로 프로그램이 동작하고 종료하는 지를 알아보기 위해 우선 현대 컴퓨터 구조부터 살펴보겠습니다.

폰 노이만 구조

  현대의 컴퓨터라고 하면 보통은 stored prgram computer를 의미합니다. Stored program computer는 애플리케이션이 컴퓨터 자체에 내장되어 있고 여러 태스크를 진행할 수 있도록 프로그래밍된 컴퓨터를 의미합니다. sotred computer program 컨셉을 다루었고, 현대에 모든 컴퓨터가 따르고 있는 구조가 폰 노이만 구조입니다. 폰 노이만 구조는 다음과 같습니다.

  위 그림을 3가지 구성 요소로 요약하면 memory, CPU(processor), I/O device가 됩니다. 폰 노이만 구조를 좀 더 현대 컴퓨터 구조에 맞게 상세히 그리면 다음과 같은 모습을 가집니다.

프로세서(processor)

  메인 메모리에 저장된 인스트럭션들을 해독하는 엔진입니다. PC(Program Counter)가 존재해 인스트럭션을 가리킵니다. 전원이 들어오면 전원이 꺼질 때까지 프로세서는 PC가 가리키는 인스트럭션을 반복적으로 실행하고 PC가 다음 인스트럭션을 가리키게 업데이트하기를 반복합니다. 레지스터 파일은 각각의 이름을 갖는 워드 크기의 레지스터 집합으로 구성돼 있습니다.  워드(word)는 고정 크기의 바이트 단위로 시스템마다 한 개의 워드를 구성하는 바이트 수가 다릅니다. 현재 대부분의 컴퓨터는 4바이트(32비트) 또는 8바이트(64비트)입니다.

  인스트럭션 요청이 오면 CPU는 다음과 같은 작업을 실행합니다.

- 적제(Load)

  메인 메모리에서 레지스터로 한 바이트 또는 워드를 이전 값에 덮어쓰는 방식으로 복사합니다.

- 저장(Store)

  레지스터에서 메인 메모리로 한 바이트 또는 워드를 이전 값을 덮어쓰는 방식으로 복사합니다.

- 작업(Operate)

  두 레지스터의 값을 ALU로 복사하고 두 개의 워드로 수식연산을 수행한 뒤, 결과를 덮어쓰기 방식으로 레지스터에 저장합니다.

- 점프(jump)

  인스트럭션 자신으로부터 한 개의 워드를 추출하고, 이것을 PC에 덮어쓰는 방식으로 복사합니다.

메인 메모리(Main Memory)

  CPU 근처에 위치해 있습니다. 프로세서가 프로그램을 실행하는 동안 데이터와 프로그램을 모두 저장하는 임시 저장장치입니다. 임시이기 때문에 휘발성 메모리입니다. 논리적으로 연속적인 바이트들의 배열로 이루어져 있고 0부터 시작하는 고유의 주소를 가지고 있습니다.

입출력 장치(I/O device)

  시스템과 외부세계를 연결하는 역할을 합니다. 각 입출력 장치는 입출력 버스와 컨트롤로 또는 어댑터를 통해 연결됩니다. 컨트롤러와 어댑터의 차이는 패키징(packaging)에 있습니다. 컨트롤러 디바이스는 자체가 하나의 칩셋이거나 시스템의 머더보드에 장착되어 있습니다. 반면 어댑터는 머더보드 슬롯에 장착되는 카드입니다.

버스(bus)

  Data control signal을 싣고 다니는, 시스템 내를 관통하는 전기적 배선군입니다.

hello 프로그램 실행

  이제부터 hello 프로그램이 실행되는 과정을 살펴보겠습니다. 아래의 설명에는 각 인터럽트 과정과 프로세스에 대한 설명은 생략되어 있습니다. 인터럽트에 대한 설명은 글 Interrupt를 참고하시면 됩니다. 프로세스에 대한 설명은 chapter3-1. 프로세스의 개요를 참고하시면 됩니다.

  사용자가 './hello'라고 터미널에 입력하면 쉘 프로그램은 다음과 같은 과정으로 각 문자를 레지스터에 읽어 들인 뒤 메모리에 저장합니다.

  './hello'를 입력한 뒤 엔터를 누르면 쉘은 명령어가 완료된 것을 알 수 있습니다. 이때, 만약 자주 사용하는 명령어라면 해당 명령어는 메인 메모리에 존재하게 됩니다. 'ls'와 같은 명령어가 그 예시입니다. 하지만, './hello'는 자주 사용하는 명령어가 아니므로 쉘은 파일 내의 코드와 데이터를 복사하는 인스트럭션을 실행해 실행 파일 hello를 디스크에서 메인 메모리로 로딩합니다.

  이 과정에서 주의할 점은 인터럽트 대신 DMA(Direct Memory Access)를 사용한다는 점입니다. DMA는 디바이스 컨트롤러가 CPU를 통해 메모리로 데이터를 보내지 않고 직접 데이터를 local buffer보다 큰 블록 단위로 메인 메모리로 보내는 방법입니다. 이 방법을 사용하는 이유는 local buffer의 크기가 작아, 디스트에 있는 파일을 일반적인 인터럽트 방식으로 보내면 너무 많은 인터럽트가 발생하기 때문입니다. DMA를 사용하면 오직 블록이 하나가 보내졌을 때, 모든 내용이 메모리에 완벽히 저장된 후에만 인터럽트가 발생합니다.

  이제 hello 목적 파일의 코드와 데이터가 메모리에 모두 적제 되었습니다. 프로세서는 hello 프로그램의 main 루틴 기계어 인스트럭션을 실행합니다. 이 인스트럭션들은 "hello world" 스트링을 메모리에서 레지스터 파일로 복사하고, 디스플레이 장치로 전송합니다. 그러면 "hello world"라는 스트링이 화면에 보이게 됩니다.