Krafton Jungle/4. CSAPP

[Computer System] ① 컴퓨터 시스템으로의 여행 (1)

munsik22 2025. 3. 14. 17:07

 

컴퓨터 시스템은 HW와 시스템 SW로 구성된다.

1.1 정보는 비트와 컨텍스트로 이루어진다

소스파일 (또는 소스 프로그램)

  • 0 또는 1로 표시되는 비트(bit)들의 연속
  • 바이트(byte) = 8bit 단위로 구성됨
  •  바이트는 프로그램의 텍스트 문자를 나타냄
  • 대부분의 컴퓨터 시스템은 텍스트 문자를 아스키(ASCII) 표준을 사용하여 표시함
#include <stdio.h>

int main() {
	printf("hello, world\n");
	return 0;
}

hello.c 파일은 연속된 바이트들로 구성되어 있다. 각 바이트는 특정 문자에 대응되는 정수 값을 가진다. 예를 들어, 첫 번째 바이트 35는 ASCII 표준 상 #에 대응된다.

더보기

# 35
i 105
n 110
c 99
l 108
u 117
d 100
e 101
  32
< 60
s 115
t 116
d 100
i 105
o 111
. 46
h 104
> 62
\n 10
i 105
n 110
t 116
  32
m 109
a 97
i 105
n 110
( 40
) 41
{ 123
\n 10
p 112
r 114
i 105
n 110
t 116
f 102
( 40
" 34
h 104
e 101
l 108
l 108
o 111
, 44
  32
w 119
o 111
r 114
l 108
d 100
" 34
\n 10
) 41
; 59
\n 10
r 114
e 101
t 116
u 117
r 114
n 110
  32
0 48
; 59
\n 10
} 125

1바이트는 0과 1의 8비트로 이루어져있기 때문에, hello.c 파일을 비트로 표현하면 아래와 같다.

001000110110100101101110011000110110110001110101011001000110010100100000001111000111001101100100011010010110111100101110011010000111001000111110000010100110100101101110001000000110110101110000011000010110100100101110001010000010100100101001101111011011000110010000100100100100110011111101000010100110010101110010011101000111001001110010011011100100000000110000000011101000110111011111101

 

이렇게 우리가 작성한 코드를 컴퓨터가 읽을 수 있는 기계어(이진코드)로 번역하는 작업은 주로 컴파일러가 수행한다. 정확하게 이야기하자면, 컴파일러가 C 코드를 고급 언어에서 어셈블리어로 번역하고, 어셈블러가 어셈블리어에서 기계어, 즉 0과 1의 이진 코드로 번역한다.

 

hello.c와 같이 아스키 문자들로만 구성된 파일을 텍스트 파일이라고 하고, 그렇지 않은 나머지는 바이너리 파일이라고 한다. 두 파일을 간단하게 구분하자면, 우리 같은 사람이 쉽게 읽을 수 있으면 텍스트 파일, 아니면 바이너리 파일이다.

 

모든 시스템 내부의 정보[각주:1]는 비트들로 표시된다. 서로 다른 객체들을 구분하는 유일한 방법은 이들을 바라보는 컨텍스트(context, 문맥)에 의해서다.

 

예를 들어 이진수 01100101는 다양한 의미로 사용된다.

  • 아스키 코드 : e
  • 정수 : 10
  • 16진수 : 0x65

또 다른 예시로 이진수 00000000 10000000 00000000 00000000는 다음과 같은 의미로 사용된다.

  • 정수 : 2147483648
  • 16진수 : 0x80000000
  • 부동소수점 : -0.0 또는 +2147483648.0
  • ARM 아키텍처에서는 ADD 명령어의 opcode로 사용됨

1.2 프로그램은 다른 프로그램에 의해 다른 형태로 번역된다

hello.c는 사람이 쉽게 읽고 쓸 수 있는 언어인 고급 언어로 작성되었기 때문에, 컴퓨터가 시스템에서 실행시키기 위해서는 저급 언어로 번역하는 과정이 필요하다.

 

각 C 문장들은 번역 프로그램들에 의해 저급 기계어 인스트럭션들로 번역되고, 실행가능 목적 파일로 합쳐져서 바이너리 디스크 파일로 저장된다.

gcc -o hello hello.c

 

유닉스 시스템에서 사용되는 GCC 컴파일러 드라이버는 소스파일 hello.c를 읽어서 오브젝트 파일 (=실행파일)인 hello로 번역한다.

번역 과정은 4개의 단계를 거쳐 실행된다. 이 과정에서 사용되는 프로그램들인 전처리기, 컴파일러, 어셈블러, 링커를 합쳐서 컴파일 시스템이라고 부른다.

  • 전처리단계 hello.c (source program/text) → hello.i (modified source program/text)
    전처리기(Pre-processor, cpp)는 C 프로그램을 #로 시작하는 디렉티브에 따라 수정한다. 예를 들어 #include<stdio.h>는 프로그램 문장에 시스템 헤더파일인 stdio.h를 직접 삽입한다. 결과적으로 hello.i라는 새로운 C 프로그램이 생성된다.
  • 컴파일단계 hello.i (modified source program/text) → hello.s (assembly program/text)
    컴파일러(Compiler, cc1)은 hello.i를 hello.s라는 어셈블리어 프로그램으로 번역한다. 어셈블리어는 저수준 기계어 명령어를 텍스트 형태로 나타낸다.
    main:
        subq $8, %rsp
        movl $.LCO, %edi
        call puts
        movl $0, %eax
        addq $8, %rsp
        ret
    어셈블리어는 여러 상위 수준 언어의 컴파일러들을 위한 공통의 출력언어를 제공하기 때문에 유용하다.
  • 어셈블리 단계 hello.s (assembly program/text) → hello.o (relocatable object programs/binary)
    어셈블러(Assembler, as)가 hello.s를 기계어 명령(인스트럭션)으로 번역하고, 이들을 재배치가능 목적프로그램의 형태로 묶어서 hello.o라는 목적파일에 결과를 저장한다. 이 파일은 main 함수의 인스트럭션들을 인코딩 하기 위한 17바이트를 포함하는 바이너리 파일이다.
  • 링크 단계 hello.o (relocatable object programs/binary) + printf.o → hello (executable object program/binary)
    링커(Linker, ld)에 의해 hello.o와 printf 함수를 호출하는 printf.o와 통합되어 실행가능 목적파일 (= 실행파일)로 메모리에 적재되어 시스템에 의해 실행된다.

1.3 컴파일 시스템이 어떻게 동작하는지 이해하는 것은 중요하다

    • 프로그램 성능 최적화하기
      • 효율적인 코드 작성을 위해 컴파일러의 내부 동작을 알 필요는 없음
      • 하지만 C 코드 작성 시 올바른 판단을 하기 위해서는 기계어 수준 코드에 대한 기본적인 이해가 필요함
      • 컴파일러가 어떻게 C 문장들을 기계어 코드로  번역하는지 알 필요가 있음
        더보기
        1. switch 문은 if-else 문을 연속해서 사용하는 것보다 언제나 더 효율적일까?
          switch 문이 항상 더 효율적이지는 않다. switch 문은 특정 값에 대한 분기를 처리할 때 유용하며, 많은 경우 내부적으로 점프 테이블을 사용하여 효율성을 높인다. 그러나 분기 조건이 적고 연속적인 경우, if-else 문이 더 간단하고 효율적일 수 있다. 성능 차이는 컴파일러와 특정 상황에 따라 달라질 수 있다.
        2. 함수 호출 시 발생하는 오버헤드는 얼마나 되는가?
          함수 호출 시 발생하는 오버헤드는 여러 요소에 따라 달라지며, 일반적으로 스택 프레임을 생성하고 매개변수를 전달하는 과정에서 발생한다. 이 오버헤드는 몇 사이클에서 수십 사이클까지 다양할 수 있으며, 호출되는 함수의 복잡성, 매개변수의 수, 그리고 호출 방식(값 전달, 참조 전달 등)에 따라 달라진다.
        3. while 루프는 for 루프보다 더 효율적일까?
          while 루프와 for 루프의 효율성은 거의 비슷하다. 두 루프 모두 비슷한 구조로 작성될 수 있으며, 컴파일러는 이 둘을 최적화하여 동일한 기계어로 변환할 수 있다. 따라서 성능 차이는 코드의 구조와 구현에 따라 달라지며, 일반적으로 루프의 효율성은 루프의 내용과 반복 횟수에 더 크게 영향을 받는다.
        4. 포인터 참조가 배열 인덱스보다 더 효율적인가?
          포인터 참조와 배열 인덱스는 비슷한 성능을 가지지만, 포인터는 더 직접적으로 메모리 주소를 다루기 때문에 약간 더 효율적일 수 있다. 그러나 이 차이는 일반적으로 미미하며, 주로 코드의 가독성과 유지보수성이 더 중요하다. 최적화의 관점에서 보면, 컴파일러가 두 방식 모두 최적화할 수 있기 때문에 실질적인 성능 차이는 거의 없다.
        5. 합계를 지역 변수에 저장하면 참조형태로 넘겨받은 인자를 사용하는 것보다 왜 루프가 더 빨리 실행되는가?
          지역 변수를 사용하면 메모리 접근이 더 빠르다. 지역 변수는 스택에 저장되며, 참조형 인자를 사용할 경우마다 메모리 주소를 역참조하는 과정이 필요하다. 또한, 지역 변수는 최적화가 용이하여 컴파일러가 더 효과적으로 최적화할 수 있다.
        6. 수식 연산 시 괄호를 단순히 재배치하기만 해도 함수가 더 빨리 실행되는 이유는 무엇인가?
          괄호를 재배치하면 연산의 순서가 변경되어 컴파일러가 최적화할 수 있다. 괄호를 통해 특정 연산을 먼저 수행하게 하여 중간 결과를 재사용하거나, 불필요한 계산을 줄일 수 있다. 또한, 컴파일러는 연산 순서를 최적화하여 CPU의 파이프라인을 더 효율적으로 사용할 수 있게 된다.
  • 링크 에러 이해하기
    • 대규모 SW 시스템을 빌드하는 경우에 링커의 동작과 관려노딘 프로그래밍 에러가 발생 가능함
      더보기
      1. 링커가 어떤 참조를 풀어낼 수 없다고 할 때, 무엇을 의미하는지?
        링커가 참조를 풀어낼 수 없다는 것은 코드 내에서 사용된 변수나 함수가 정의되지 않았다는 의미다. 즉, 링커는 해당 기호를 찾을 수 없어서 연결할 수 없는 상태다. 주로 함수나 변수가 다른 파일에 정의되어 있지만, 링커가 그것을 찾지 못하거나, 링크할 수 있는 라이브러리에 포함되어 있지 않을 때 발생한다.
      2. 정적 변수와 전역 변수의 차이는 무엇인가?
        전역 변수는 프로그램의 모든 함수에서 접근할 수 있는 변수로, 프로그램의 시작부터 종료까지 메모리에 존재한다. 정적 변수는 특정 함수 내에서만 접근할 수 있는 변수로, 함수가 호출될 때마다 초기화되지 않고, 프로그램이 종료될 때까지 값이 유지된다. 이 두 변수의 주요 차이는 접근 범위와 초기화 시점이다.
      3. 각기 다른 파일에 동일한 이름이 두 개의 전역변수를 정의한다면 무슨 일이 일어나는가?
        동일한 이름의 전역 변수가 서로 다른 파일에 정의되면, 링크 시 충돌이 발생하게 된다. 이 경우 링커는 어떤 변수를 사용할지 결정할 수 없으므로 duplicate symbol 에러가 발생할 수 있다. 이를 방지하기 위해 보통 extern 키워드를 사용하여 변수를 선언하거나, 네임스페이스나 static 키워드를 사용하여 이름 충돌을 피한다.
      4. 정적 라이브러리와 동적 라이브러리의 차이는 무엇인가?
        정적 라이브러리는 컴파일 시 프로그램에 포함되어 실행 파일의 일부가 되는 라이브러리다. 이로 인해 실행 파일의 크기가 커지고, 라이브러리의 업데이트가 필요할 경우 재컴파일해야 한다. 동적 라이브러리는 실행 시에 로드되는 라이브러리로, 여러 프로그램에서 공유할 수 있다. 실행 파일의 크기가 작아지고, 라이브러리의 업데이트가 용이하지만, 실행 시 라이브러리가 존재하지 않으면 에러가 발생할 수 있다.
      5. 컴파일 명령을 쉘에서 입력할 때 명령어 라인의 라이브러리들의 순서는 무슨 의미가 있는가?
        라이브러리의 순서는 링크 순서를 결정한다. GCC와 같은 컴파일러는 명령어 라인에서 먼저 나오는 라이브러리부터 링크를 시도한다. 따라서 의존성이 있는 라이브러리는 그 의존성이 있는 라이브러리 뒤에 위치해야 한다. 라이브러리 A가 라이브러리 B를 참조한다면, B는 A보다 먼저 나와야 한다는 것이다.
      6. 왜 링커와 관련된 에러들은 실행하기 전까지는 나타나지 않는 걸까?
        링커 에러는 컴파일러가 소스 코드를 기계어로 변환한 후, 여러 오브젝트 파일과 라이브러리를 결합하는 과정에서 발생한다. 이 과정은 실행 파일을 생성하는 단계이기 때문에, 실행 전까지는 링커가 모든 참조와 정의를 확인하고 충돌을 검사하기 때문에 에러가 발생하는 것이다. 때문에 실행 시점에서는 이미 모든 문제가 해결된 상태여야 한다.
  • 보안 약점(Security hole) 피하기
    • 버퍼 오버플로우(buffer overflow) 취약성 : 프로그래머들이 신뢰할 수 없는 곳에서 획득한 데이터의 양과 형태를 주의 깊게 제한해야 할 필요를 거의 인식하지 못하기 때문에 발생함
    • 즉, 안전한 프로그래밍을 배우는 첫 단계는 프로그램 스택에 데이터와 제어 정보가 저장되는 방식 때문에 생겨나는 영향을 이해하는 것임

1.4 프로세스는 멤리에 저장된 인스트럭션을 읽고 해석한다

hello.c에서 부터 시작되어 실행가능한 목적파일로 번역되어 디스크에 저장된 hello 실행파일을 유닉스 시스템에서 실행하는 과정을 알아보자.

hello 실행파일을 유닉스 시스템에서 실행하기 위해서 쉘(Shell)이라는 응용프로그램에 이름을 입력한다.

linux> ./hello
hello, world
linux>

 

  • 쉘은 커맨드라인 인터프리터로, 프롬프트를 출력하고 명령어 라인을 입력 받아 그 명령을 실행함
  • 명령어 라인이 내장 쉘 명령어가 아니면, 쉘은 실행파일의 이름으로 판단하고 그 파일을 로딩해서 실행함
  • 쉘은 hello 프로그램을 로딩하고, 실행한 뒤에 종료를 기다림
  • hello 프로그램은 메시지를 화면에 출력하고 종료됨
  • 쉘은 프롬프트를 출력해주고 다음 입력 명령어 라인을 기다림

1.4.1 시스템의 하드웨어 조직

hello 프로그램을 실행할 때 무슨 일이 일어나는지 설명하기 위해서는, 아래 그림과 같은 전형적인 시스템에서의 하드웨어 조직을 이해해야 한다.

버스(Bus)

  • 시스템 내를 관통하는 전기적 배선
  • 컴포넌트들 간에 바이트 정보를 전송
  • 워드(word)라고 하는 고정 크기의 바이트 단위로 데이터를 전송
  • 대부분의 컴퓨터들은 4바이트(32bit) 또는 8바이트(64bit)의 워드 크기를 가짐

입출력장치(I/O)

  • 시스템과 외부와의 연결을 담당함
  • 위 그림에서는 키보드, 마우스, 디스플레이, 디스크가 해당함
  • 실행가능 파일 hello는 디스크에 저장되어 있음
  • 각 입출력 장치는 입출력 버스(I/O Bus)나 컨트롤러/어댑터를 통해 연결됨
    • I/O Bus와 I/O 디바이스 간의 정보 교환 목적
    • 컨트롤러 : 디바이스 자체가 칩셋이거나 시스템의 인쇄기판(Motherboard)에 장착됨
    • 어댑터 : 마더보드의 슬롯에 장착됨

메인 메모리(Main Memory)

  • 프로세서가 프로그램을 실행하는 동안 데이터와 프로그램을 모두 저장하는 임시 저장장치
  • 물리적으로는 DRAM 칩들로 구성됨
  • 논리적으로는 메모리는 연속적인 바이트들의 배열로, 각각 0부터 시작해서 고유의 주소(배열의 인덱스)를 가짐
  • 기계어 명령이나 C 프로그램 변수들은 각각 다양한 바이트 크기를 가짐
    • short 타입 : 2byte
    • int, float 타입 : 4byte
    • long, double 타입 : 8byte

프로세서(Processor)

  • 주처리장치(CPU) = 프로세서 : 메인 메모리에 저장된 명령(인스트럭션)들을 해독하는 엔진
  • 프로그램 카운더(PC) : 워드 크기의 저장장치 또는 레지스터
  • CPU는 PC가 가리키는 주소의 명령을 수행하고, PC 값이 다음 명령의 주소를 가리키도록 업데이트함
  • 인스트럭션 집합 구조(매우 단순한 인스트럭션 실행 모델)을 구현한 것처럼 보이나, 실제로는 훨씬 더 복잡함
  • 메인 메모리, 레지스터 파일, 수식/논리 처리기(ALU)를 순환함
    • 레지스터 파일 : 각각 고유의 이름을 가지는 워드 크기의 레지스터 집합
    • ALU : 새 데이터와 주소 값을 계산함
  • CPU의 작업 예시
    • 적재(Load) : 메인 메모리 → 레지스터로 1바이트/워드를 이전 값에 덮어쓰기 식으로 복사
    • 저장(Store) : 레지스터 → 메인 메모리로 1바이트/워드를 이전 값에 덮어쓰기 식으로 복사
    • 작업(Operate) : 두 레지스터 값을 ALU에 복사하고 두 개의 워드로 수식 연산을 수행해 결과를 덮어쓰기 방식으로 레지스터에 저장
    • 점프(Jump) : 인스트럭션 자신으로부터 한 개의 워드를 추출하고 PC에 덮어쓰기 방식으로 복사
프로그램 카운터(PC)는 다음에 시행할 명령어의 주소를 지정하는 역할을 하며, ALU는 산술 및 논리 연산 처리를 한다.

1.4.2 hello 프로그램의 실행

① 쉘 프로그램은 자신의 인스트럭션을 수행하면서 사용자의 입력을 기다림

② 쉘 프로그램에 ".\hello" 입력 : 각각의 문자를 레지스터에 읽어온 후 메모리에 저장함

hello 명령어를 키보드에서 읽어들이는 과정

 

③ 엔터 키를 누르면 쉘은 명령어 입력이 끝났다는 것을 인식함

  • 파일 내의 코드와 데이터를 복사하는 일련의 인스트럭션을 실행하여 실행파일 hello를 디스크에서 메인 메모리로 로딩함
  • 직접 메모리 접근(DMA) 기법을 사용하면 데이터는 프로세서를 거치지 않고 디스크에서 메인 메모리로 직접 이동함

실행파일을 디스크에서 메인 메모리로 로딩하는 과정

④ hello 목적파일의 코드와 데이터가 메모리에 적재된 후, 프로세서는 hello 프로그램의 main 루틴의 기계어 인스트럭션을 실행하기 시작함

  • 이 인스트럭션들은 hello, world\n 스트링을 메모리로부터 레지스터 파일로 복사함
  • 스트링이 레지스터로부터 디스플레이 장치로 전송되어 화면에 표시됨

출력 스트링을 메모리에서 화면으로 기록하는 과정

  1. 디스크 파일, 메모리상의 프로그램, 데이터, 네트워크를 통해 전송되는 데이터 등 [본문으로]