Blog
책 리뷰
컴퓨터 밑바닥의 비밀
프로그래밍 언어부터 프로그램 실행까지, 이렇게 진행된다.

컴퓨터 밑바닥의 비밀 Chapter 1 : 프로그래밍 언어부터 프로그램 실행까지, 이렇게 진행된다

  • 프로그래밍 언어는 컴퓨터 과학의 매우 작은 부분이다.
  • 이 장을 통해서 알 수 있는 내용은 프로그래밍 언어는 어떻게 만들었을지? 실행 파일은 왜 실행될 수 있는지? 실행 파일은 어떤 형태로 어떻게 실행 되는지? 실행되고 나면 어떤 형태를 갖추게 되는지? 어떻게 하면 더 효율적으로 실행할 수 있을지? 등이다.

1.1 여러분이 프로그래밍 언어를 발명한다면?

  • 초기에는 CPU 의지에 따라 직접 0과 1로 구성된 명령어를 작성했다.

  • CPU는 가산 명령어, 점프 명령어 등 겨우 몇 가지 명령어만 실행할 수 있다는 점을 발견 👉 이후 어셈블리어가 등장했다.

  • 하지만 어셈블리어도 가장 작은 단위의 명령으로만 구성되어 있었기 때문에 이를 좀 더 추상화할 방법이 필요하다.

    • "저에게 물 한 잔 주세요." 라는 명령어를 어셈블리어로 표현 👇
  • 여러가지 명령어에 문(statement)라고 명칭하고 조건에 따라 다른 명령어를 실행하는 규칙도 정의한다.

  • 재귀부분은 무슨 소린지 이해 안갔다. 그냥 대충 blahblah가 statement면 이는 무한히 재귀하게 된다는 뜻이다.

  • 재귀는 tree 구조로 만들어낼 수 있는데, 이를 구문 트리라고 표현하기로 한다.

  • 우리가 추상화해놓은 코드를 컴퓨터가 이해할 수 있는 언어로 변환(구문 트리 생성, 구문 변환) 등을 해주는 것이 컴파일러이다.

  • 근데 CPU마다 명령어가 다르기 때문에 우리는 여기에 통용과 표준을 만들기로 했고 가상 머신(virtual machine)을 만들어서 모든 컴퓨터에서 실행할 수 있게 했다.

    • 👉 이 가상 머신을 인터프리터(interpreter)라고 부르기로 한다.

1.2 컴파일러는 어떻게 작동하는 것일까?

  • 컴파일러라는 것은 크게 보면 번역기고 작게 보면 텍스트 처리 프로그램(text processor)이라는 것을 알 수 있다.
  • 컴파일러는 먼저 소스 코드를 돌아다니면서 모든 토큰(각 항목에 추가로 정보를 결합한 것 ex: int)을 찾아내는데, 이를 어휘 분석(lexical analysis)라고 한다.
  • 다음 토큰이 while 키워드에 필요한 토큰인지 등을 분석하는데 이런 과정을 해석(parsing)이라고 한다.
  • 해석된 토큰을 트리로 표현하는 것을 앞서 살펴본대로 구문 분석(syntax analysis)이라고 한다.
  • 생성된 구문에 오류가 없는지 확인하는 과정을 의미 분석(syntax analysis)라고 한다.
    • 예를 들어서 비교 기호의 좌우에 있는 값 형식이 다른지, 문자에 숫자를 더하는건 아닌지 등을 검사한다.
  • 이후 컴파일러는 구문 트리를 탐색한 결과를 바탕으로 좀 더 다듬어진 형태인 중간 코드(intermediate representation code, IR Code)를 생성한다.
    • 순환 구문 내 순환 상태와 관계없이 계산 가능한 값이 있다면, 이런 계산은 순환 구문 외부에서 먼저 진행되기도 하는 등 최적화가 진행된다.
  • 중간 코드 생성이 완료되면 앞의 중간 코드를 어셈블리어 코드로 변환하고 이 어셈블리어를 기계 명령어로 변환한다.
    • 이 때 기계 명령어를 담고 있는 파일을 목적 파일(object file)이라고 한다.
    • 위에서 설명한 목적 파일을 하나의 파일로 합치는 과정을 링크(linking)이라고 한며 링커(linker)가 이를 수행한다.

1.3 링커의 말할 수 없는 비밀

  • 다른 사람이 작성한 코드를 사용하기 위해서 라이브러리를 사용한다.
  • 라이브러리리는 정적 라이브러리(static library)와 동적 라이브러리(dynamic library)로 나뉜다.

1.3.1 링커는 이렇게 일한다.

  • 심벌 해석(symbol resolution)
    • exe 형식의 실행 파일이나 리눅스의 elf 파일 같은 실행 파일은 링커가 필요한 대상 파일을 한데 모아 구성한다.
    • 예를 들어 우리가 list.c에서 일종의 연결 리스트를 구현하고 다른 모듈에서 그 연결 리스트를 사용해야 한다면, 이 두 모듈 사이에 종속성이 있다고 표현한다.
    • 링커가 하는 일 중 하나는 종속성이 올바르게 설정되어 있는지 확인하는 것이다.
  • 각 저자가 자신의 분량을 모두 집필하고 나면 이를 한데 모아 하나로 합쳐야 책 한 권이 완성될 수 있다.
    • 이렇게 완성된 책을 최종적으로 생성된 실행 파일에 비유할 수 있다.
  • 책이 완성되기 전에는 CPU에 대한 내용이 어디에 들어갈지 알 수 없는데 이를 N으로 표시해놓고 나중에 재배치(relocation) 하는 과정을 거친다.

1.3.2 심벌 해석 :수요와 공급

  • 심벌은 전역 변수(global variable)나 함수(function)의 이름을 포함하는 모든 변수 이름을 의미한다.
    • 지역 변수(local variable)는 모듈 내에서만 사용되어 외부 모듈에서 참조할 수 없기 때문에 링커의 관심 대상이 아닙니다.
    • 소스 파일에 다른 모듈에서 참조할 수 있는 심벌이 두 개(전역 변수 g_a, 함수 func_b)가 있다.
    • 다른 모듈에서 정의한 심벌 두 개(외부 변수 g_e, 외부 함수 func_a)를 참조한다.
    • 링커가 위와 같은 심벌을 참조해야 한다는 것은 컴파일러가 알려준다.
  • 컴파일러가 외부 심벌 정보를 기록하는 표를 심벌 테이블(symbol table)이라고 한다.
    • 심벌 테이블에는 아래 두 가지 정보가 들어간다.
    • 내가 정의한 심벌, 즉 다른 모듈에서 사용할 수 있는 심벌
    • 내가 사용하는 외부 심벌

1.3.3 정적 라이브러리, 동적 라이브러리, 실행 파일

  • 정적 라이브러리
    • 실행 파일을 생성할 때 자신의 코드만 컴파일하며, 미리 컴파일이 완료된 정적 라이브러리는 다시 컴파일할 필요 없이 링크 과정에서 그대로 실행 파일에 복제한다. - 정적 링크 (static linking)
    • 정적 라이브러리는 모든 실행 파일에 복사가 된다.
    • 정적 라이브러리 크기가 2MB고 해당 라이브러리를 사용하는 실행 파일이 500개라면 1GB 크기의 데이터가 중복도니 데이터로 구성된다.
  • 동적 라이브러리
    • 윈도우에선 ddl 파일, 리눅스에선 so 파일(접두사는 lib)로 표현한다.
    • 정적 라이브러리와 달리 참조된 동적 라이브러리 이름, 심벌 테이블, 재배치 정보 등 필수 정보만 실행 파일에 포함한다.
      • 정적 라이브러리 👇
      • 동적 라이브러리 👇
    • 위에서 말한 필수 정보는 동적 링크(dynamic linking)가 일어날 때 사용한다.
      • 동적 링크는 실제 프로그램의 실행 시점까지 미루다가 두 가지 방식으로 메모리에 동적 라이브러리를 로드한다.
      • 첫 번째 방식은 프로그램이 메모리에 적재될 때 동적 링크가 진행된다.
        • 이 때 적재 도구(loader)라는 전용 프로세스가 실행된다.
          • 적재도구는 동적 링커라는 별도의 프로세스를 사용해서 참조하는 동적 라이브러리의 존재 여부와 위치, 심벌의 메모리 위치 등을 확인하여 링크 과정을 마무리한다.
          • 동적 링크를 사용하려면 실행 파일이 어떤 동적 라이브러리를 참조하는지 컴파일러에 명시적으로 알려주어야 한다.
          • gcc -o test test.c /path/to/libtest.so
      • 두 번째 방식인 실행 시간 동적 링크(runtime dynamic linking)은 프로그램의 실행 시간(runtime) 동안 코드가 직접 동적 링크를 실행할 수 있다.
        • 실행 시간 동적 링크는 실행 파일이 실행될 때까지 어떤 동적 라이브러리에 의존하는지 알 필요가 없기 때문에 좀 더 동적인 링크 방식이다.
        • 리눅스에서는 dlopen, dlsym, dlclose 등의 함수들로 런타임 동적 링크를 사용할 수 있다.
        • 주로 플러그인 개발시에 사용한다.
    • 동적 라이브러리의 장점
      • 더 높은 성능이 요구되는 코드의 핵심 부분은 동적 라이브러리를 통해 C/C++로 작성된 함수를 직접 호출할 수 있다.
      • 정적 라이브러리와 다르게 모든 실행 파일에 포함되지 않기 때문에 실행 파일의 크기가 줄어든다.
    • 동적 라이브러리의 단점
      • 동적 라이브러리는 프로그램이 적재되는 시간 또는 실행 시간에 링크되기 때문에 동적 링크를 사용하는 프로그램은 정적 링크를 사용할 때보다 성능이 약간 떨어질 수 있다.

1.3.5 재배치: 심벌의 실행 시 주소 결정하기

  • call 0x4004d6
  • 컴파일러는 .relo.text에는 해당 명령어를 저장하고 .relo.data에 해당 명령어와 관련된 데이터를 저장한다.
  • 컴파일러가 offset 주소를 대략적으로 잡아주고 링커는 변수의 실행 시간 주소를 가상 메모리(Virtual Memory)에 결정한다.

1.3.6 가상 메모리와 프로그램 메모리 구조

  • 64 bit 운영체제는 가상 코드 영역이 예외 없이 메모리 주소 0x04000000부터 시작한다.
  • 모든 프로세스의 가상 메모리는 표준화되어 있고 크기가 동일하다.
  • 실제 물리 메모리의 크기는 가상 메모리의 크기와는 무관하며 물리 메모리에는 힙 영역, 스택 영역 등 영역 구분조차 존재하지 않는다.
  • 모든 프로세스는 자신만의 페이지 테이블을 가지고 있으며, 같은 가상 메모리 주소라도 페이지 테이블을 확인하며 서로 다른 물리 메모리 주소를 획득한다.✨

1.4 컴퓨터 과학에서 추상화가 중요한 이유

  • 추상화는 표현력을 크게 향상시키고 의사소통의 효율을 올려 줄 뿐만 아니라 세부 사항을 노출할 필요가 없으므로 보호할 수도 있다.