본문 바로가기
Programming

컴퓨터 밑바닥의 비밀 - 1장

by mingule 2024. 6. 24.

책을 읽으면서 간단하게 정리해본다.

중간중간 내 생각과 느낀점이 들어가 있을 수 있고, 잘못된 내용을 적어뒀을 수도 있다. ^^;; 

혹시 보이면 댓글 부탁드립니다,, 호홋,,

 

내가 작성한 코드를 컴퓨터가 어떻게 인식하지?

CPU (Central Processing Unit)

책에 따르면 인간은 스위치 on/off 를 조합해 boolean logic을 표현할 수 있다는 것을 발견하고 이를 기반으로 CPU를 만들었다고 한다. 

흠.. 뭔가 엄청난 비약이 있어보여서 뭐지? 했는데,

인간이 예전에는 봉화(불 → on/off)로 서로 통신했고, 그게 모스부호가 되고, 문자가 되어 이를 통해 CPU를 만들어냈다.. 라는 설명을 듣고는 박박대박 이해가 됐다. 

 

이렇게 만들어진 CPU는 이 두가지 작업만 할 수 있는 똑똑한 바보다.

1. 데이터를 한 곳에서 다른 곳으로 옮긴 후 간단히 연산

2. 다른 자리로 옮김

 

하지만 인간보다 훨~씬 빠르다.

이렇게 인간보다 훨~씬 빠른 CPU(갑)와 소통하기 위해 인간(을)은 CPU의 언어로 CPU와 대화해야했다. ㅠㅠ

CPU의 언어라 함은 0과 1로 대화를 한다는거다. 

막 종이에 구멍을 뚫어놓은 천공카드를 가지고 컴퓨터의 작업을 제어했다. 

휴 ~ 개고생 했을꺼다. 

 

이렇게 개고생 하다가 안되겠다! 싶었는지

우리가 그래도 인간인데! 기계어랑 소통하려면 이렇게까지 해야해? 패턴을 찾자!

CPU는 가산 명령어, 점프 명령어 등 몇가지 명령어만 실행가능해.

이런 010101 과 같은 기계어와 해당 특정 작업을 대응시켜서, 기계어를 우리 인간이 이해할 수 있는 언어로 대응시켜버리자! 라는 생각을 하게 된다. 

 

어셈블리어 (Low Level Language)

흠.. 그래도 어느정도 인간이 이해할 수 있는 단어와 대응은 시켜놨지만 아직 세부사항에 대해 신경은 써줘야 한다.

예를 들어, "물 한잔 줘~" 라는 것을 명령하려면, "오른쪽 다리를 들고 한발짝 앞으로 가고 다음은 왼쪽 다리를 들고 어쩌고.. 수도꼭지를 틀고.. 어쩌고.." 라는 엄청 길고 귀찮은 명령들의 집합으로 제어해야한다. 

 

하.. 듣기만해도 끔찍!

대충 추상적으로 표현해도 알잘딱깔센으로 잘 수행하는 그런 고수준 언어가 필요하다.

저런 세부적인 명령들을 보다보니, 규칙과 패턴, 그리고 단도직입적인 명령어(문 / statement) 들이 잔뜩 있다는 것을 깨달았다.

그리고 세부사항들(parameter)에 조금씩 차이가 있을 뿐, 비슷한 것들이 계속 반복되는 것을 알게됐다.(함수)

또, 문 안에서 실행되는 코드들이 계속 반복되는 것에서(if else 안에 if else 안에 if else ...) 재귀를 찾을 수 있었다. 

 

프로그래밍 언어를 컴퓨터가 인식할 수 있는 기계 명령어로 변환하려면 어떻게 해야할까?

컴퓨터가 "재귀"를 이해하게 만드려면, Syntax Tree, 즉 구문트리를 만들면 된다.

맨 끝 자식(leaf node) → 부모로 이어지도록 계속 기계어 번역을 하다보면, 전체 기계어를 번역할 수 있게 된다.

 

요런 작업을 해주는 친구가 바로! 컴파일러(Compiler) 이다. 

 

이렇게 고급 프로그래밍 언어(high level programming language)가 탄생했다.

이 시점부터 프로그래머는 인간이 인식할 수 있는 언어를 사용해 코드를 작성할 수 있게 되었고,

컴파일러는 이런 프로그래밍 언어를 CPU가 인식할 수 있는 기계명령어로 번역하는 역할을 담당하게 되었다.

다만, 이런 고급언어는 추상적인 표현이 뛰어나서 사용하기는 쉽지만, 저수준 계층에 대한 제어 능력은 조금 떨어지기 때문에, 만약 저수준 계층의 세세한 사항을 직접 제어해야한다면 그건 어셈블리어를 사용해야 할 수도 있다.

 

**Syntax (구문):
  프로그래밍 언어에서 문법적으로 올바른 프로그램을 작성하기 위한 규칙과 구조. 구문은 프로그램의 형태와 구성을 정의합니다.
**Statement (문):
  프로그래밍 언어에서 실행 가능한 단일 명령이나 명령 집합. 각 문은 특정 작업을 수행하며, 구문 규칙에 따라 작성됩니다.

 

해석형 언어 

세상에는 정말 다양한 하드웨어 사에서 만든 다양한 CPU가 존재하는데, 그럼 각각의 CPU들은 서로 실행할 수 없다.

그런데 만약 "표준어 명령 집합" 이란걸 정의하고 만들어서 사용한다면? ssapossible~

이건 약간 오늘날 세계에서 사용되는 국제 통용어인 영어를 쓰는 것과 같은 결이다.

 

각양각색의 CPU마다 상응하는 시뮬레이션 프로그램을 준비하면, 우리 코드를 직접 서로 다른 플랫폼에서 실행할 수 있다.

이 시뮬레이션 프로그램은 바로 가상머신(인터프리터/interpreter) 이다.

 

컴파일러 (번역기 / 텍스트 처리 프로그램)

그럼 컴파일러는 어떻게 작동할까?

아까 잠깐 나왔듯이, 컴파일러는 고수준 언어를 저수준 언어로 번역하는 프로그램이다.

개발자가 저장한 코드 Text File(source file)을 컴파일러에게 주면, 컴파일러는 그것을 실행 파일 형태로 만들어서 내보낸다.

이 실행 파일이 바로 CPU가 직접 실행할 수 있는 기계 명령어다.

 

그러면 실행 파일을 어떻게 만들어나갈까?

1. Lexical Analysis(어휘 분석): 소스 코드를 돌아다니면서 모든 토큰(항목 + 정보 결합)을 찾아내 추출한다. (소스코드 → 토큰)

    이 토큰 자체로는 아무 의미가 없다. 개발자가 전달하고자 하는 토큰의 의도를 알아야한다.

2. Parsing(구문 분석): 구문에 맞추어 토큰을 해석하는 과정, 해석 후 구문 트리를 생성하는 과정

3. Semantic Analysis(의미 분석): 구문 트리에 이상이 없는지 확인해야 함

4. 중간 코드 생성(Intermediate Representation Code 생성): 좀 더 다듬어진 형태의 코드를 생성(추가적인 최적화가 진행되기도 함)

5. 중간 코드를 어셈블리어 코드로 변환

6. 어셈블리어 코드를 기계 명령어로 변환

 

모든 소스 파일에는 각각의 대상 파일이 있는데, 하나의 실행파일로 만들고 싶다면, Link(링크)를 사용해 병합한다.

Link를 담당하는 프로그램은 Linker(링커)이다. 

 

링커

컴파일러가 생성한 대상 파일 여러개를 하나로 묶어 하나의 최종 실행 파일을 생성한다. (exe 파일 or elf 파일..)

ex) 저자 여러병이 각각 특정 부분을 맡아 chapter 별로 따로 집필하고, 개별 장을 묶어 한 권으로 출판하는 것!

 

링커가 하는 일!

1. 종속성이 올바르게 설정되어 있는지 확인 (인터페이스 구현이 종속된 모듈에서 사용가능한지)

  - 우리가 참조하고 있는 외부 심벌(external symbol)에 대한 구현이 단 하나만 있어야 함

  - 그러면 링커는 이를 찾아내 연결하는 작업(심벌 해석/symbol resolution)을 함

    - 대상 파일에서 참조하고 있는 모든 외부 심벌마다 대상 정의가 반드시 존재하는지, 단 하나만 존재하는지 확인

  - ex) 우리가 서로 참고한 내용이 실제로 그 책 안에 있는지 확인해야함

2. 재배치(relocation)

  - 특정한 소스 파일에서 다른 모듈에 정의되어있는 무언가를 참조할 때, 컴파일 시점에는 무언가가 어느 메모리 주소에 위치할지 정확히 알 수 없음

  - 그걸 N으로 표시해두고 넘어가면, 링커가 실제 메모리 주소로 대체해줌

  - Symbol(심벌): 전역 변수와 함수의 이름을 포함하는 모든 변수 이름을 의미
     → 지역변수는 아님. 모듈 내에서만 사용되어 외부 모듈에서 참조할 수 없기 때문 

 

링커가 관심을 갖는 것
    - 소스파일에 다른 모듈에서 참조할 수 있는 심벌이 있는가
    - 소스파일이 다른 모듈에서 정의한 심벌을 참조하는가
    - 이 정보들은 컴파일러가 링커에게 알려줌!
      - 컴파일러가 만든 대상파일에는 `명령어부분` + `데이터부분`이 모두 있음
      - 컴파일러가 외부 심벌 정보를 기록하는 표를 심벌 테이블(symbol table) 이라고 함

정적 라이브러리
    - 소스파일 여러개를 미리 개별적으로 컴파일하고 링크해 정적 라이브러리로 생성 가능
    - 소스파일마다 단독으로 컴파일함
    - 이후 실행파일을 생성할 때에는 자신의 코드만 컴파일, 미리 완료된 애는 그대로 실행파일에 직접 복제
       → 디스크와 메모리 낭비 가능! 🗑️ → 동적 라이브러리 사용해봐라
    - 실행 시점: 컴파일 단계에서 실행 파일에 복사됨 

동적 라이브러리 (공유 라이브러리 / 동적 링크 라이브러리)
    - 사용 방식과 실행 시점이 정적 라이브러리와 다름
    - 실행 파일에 라이브러리 내용을 모두 복사했던 정적 라이브러리와는 달리, 참조된 동적 라이브러리의 필수 정보만 실행 파일에 포함
      → 크기를 확실히 줄일 수 있음
      - 이 정보들은 동적 링크가 일어날 때에 사용됨
    - 실행 시점: 동적 링크는 실제 프로그램의 실행 시점까지 미룸

    - 1. 프로그램이 메모리에 적재될 때 동적 링크가 진행
    - 2. 프로그램이 먼저 실행된 후, 프로그램의 실행시간(runtime) 동안 코드가 직접 동적 링크를 실행

    장점
      - 프로그램의 업그레이드와 버그 수정을 매우 쉽게 만들어줌
      - 메모리 적재와 디스크 저장에 필요한 리소스 절약
      - 여러 언어를 혼합하여 개발할 때 유용 (빨라야 하는건 C++ 사용하고 나머지는 Python 사용)
      - 코드의 재사용 효율 올라감
      - ex) 플러그인
    단점
      - 프로그램의 적재되는 시간 또는 실행 시간에 링크되기 때문에 성능이 죄금 떨어짐 
      - 동적 라이브러리의 코드는 임의의 메모리 절대 주소로는 참조할 수 없음
      - 적재할 때 동적 링크를 수행하는 프로그램은 실행 파일로만은 실행이 불가능

 


링커는 어떻게 변수의 실행시간 메모리 주소를 미리 알 수 있을까?
     - 가상 메모리 (virtual memory)
       - 물리적으로 존재하지 않는 가짜 메모리 
       - 링커는 가상 메모리를 사용함으로써 링커는 프로세스 메모리 구조를 미리 알 수 있음
       - 실제 메모리 주소는 신경안씀 ㅋ
    
    - 가상 메모리의 기본 원리
       - 모든 프로세스의 가상 메모리는 표준화 되어있고 크기가 동일
       - 프로세스마다 영역의 크기가 다를 수는 있지만 배치 순서는 같음
       - 실제 물리 메모리 크기와 가상 메모리 크기는 관련 없음
       - 물리 메모리에는 힙 영역, 스택영역 등 영역 구분이 존재하지 않음 (가상 메모리에는 구분됨) / 운영체제마다 조금씩 다를 순 있음
       - 모든 프로세스는 자신만의 페이지 테이블을 가지고 있어서 동일한 가상 메모리 주소에서 서로 다른 내용을 가져올 수 있음
     

 

댓글