OS는 할껀데 핵심만 합니다. 12편 Memory 개요, 메모리 관리, 정적 링킹과 동적 링킹, 절대 주소와 상대 주소

1

OS

목록 보기
12/17

메모리 관리의 개요

1. 메모리 관리의 복잡성

CPU는 메모리에 있는 내용을 가져오거나 작업 결과를 메모리에 저장하기 위해 메모리 주소 레지스터(MAR)을 사용한다. 메모리 주소 레지스터에 필요한 메모리 주소를 넣으면 데이터를 메모리에서 가져오거나 메모리에 데이터를 옮길 수 있다.

폰노이만 구조(메모리 - CPU를 사용한 구조)에서의 메모리는 유일한 작업 공간이며 모든 프로그램은 메모리에 올라와야 실행이 가능하다. 과거의 일괄 처리 시스템에서는 한 번에 한 가징 작업만 처리했기 때문에 메모리 관리가 어렵지 않았다. 그러나 오늘날의 시분할 시스템에서는 운영체제를 포함한 모든 응용 프로그램이 메모리에 올라와 실행되기 때문에 메모리 관리가 복잡하다.

또한, 운영체제 프로그램도 메모리에 올라와야 실행할 수 있다. 따라서 메모리에 사용자 프로세스 뿐만 아니라 운영체제 프로세스도 공존한다. 부팅과정이 발생할 때 컴퓨터 하드디스크에 저장된 운영체제 프로그램이 메모리에 올라간다. 부팅이 끝나면 응용 프로그램이 메모리에서 작업을 할 수 있게 된다.

이처럼 복잡한 메모리 관리 시스템을 배우는 것이 이번 장의 목표이다.

2. 메모리 관리의 이중성

하드디스크에 저장된 프로그램이 메모리에 올라가 실행될 준비가 완료되면 프로세스라고 한다. 프로세스는 메모리를 가능한 많이 차치하고 싶어한다. 더 나아가 독차지하고 싶어한다. 그러나, 메모리를 관리하는 입장은 어떨까?? 메모리를 관리하는 입장에서는 프로세스가 최대한 적은 메모리를 잡고 구동되기를 바란다. 즉, 메모리 공간의 효율을 위해 프로세스가 최소한의 선까지만 메모리를 잡았으면 좋겠다. 이러한 상황이 바로 메모리 관리의 이중성이다.

프로세스 입장에서 작업의 편리함과 관리자 입장에서 관리의 편리함이 충돌을 일으키는 것을 말한다. 이러한 이중성을 잘 처리해야 좋은 메모리 관리 시스템이라고 할 수 있다. 현대의 메모리 관리 시스템은 프로세스와 메모리 관리자의 상충되는 요구 사항을 완벽하게 처리한다. 앞으로 이에 대해서 살펴보도록 하자.

3. 소스코드의 번역과 실행

프로그램이 프로세스가 되기 위해서는 프로그램이라는 실행 파일이 메모리에 올라가야 한다. 따라서 우리는 프로그램이 어떻게 만들어지며, 이것이 소스코드와 어떤 관계에 있는 지 알아볼 필요가 있다.

3.1 컴파일러와 인터프리터의 동작

컴퓨터에서 작동하는 응용 프로그램들은 프로그래밍 언어로 만들며, 보통 컴파일러를 사용하여 작성한 프로그램을 실행 가능한 코드로 변경한다.

기계어와 어셈블리어는 컴퓨터 동작을 가장 직접적으로 표현한 언어로 저급(low-level language)라고 하고, 고급 언어(high-level language)는 사용자가 이해하기 쉽게 프로그래밍할 수 있는 언어로 C언어와 자바가 대표적인 예이다.

참고로 저급, 고급을 나누는 기준은 언어가 사람과 얼마나 가까운가, 시스템(컴퓨터)에 얼마나 가까운가 이다. low할수록 컴퓨터에 가까운 언어이고, high할수록 인간에 가깝다. 따라서, 상대적으로 분류할 수 있는데 C언어는 python과 같은 언어보다 low-level language가 된다.

언어 번역 프로그램은 소스코드를 컴퓨터가 실행할 수 있는 기계어로 번역하는 프로그램이다. 대표적인 언어 번역 프로그램으로는 컴파일러(compiler)와 인터프리터(interpretor)이다.

  • 컴파일러: 소스코드를 컴퓨터가 실행할 수 있는 기계어로 번역한 후 한꺼번에 실행한다. C언어 자바 등이 이 방식으로 프로그램을 실행한다.
  • 인터프리터: 소스코드를 한 행씩 번역하여 실행한다. 자바스크립트, python 등이 이 방식으로 프로그램을 실행한다.

컴파일러는 소스코드 전체를 한꺼번에 확인한다. 즉, 순차적으로 확인하는 것이 아닌 소스코드 뒷부분, 앞부분을 확인하여 최적화 할 수 있는 것은 최적화를 진행하고, 문제가 될만한 부분은 잡아주고, 필요없는 부분은 알아서 제거해준다.

인터프리터는 코드를 한 라인 한 라인 씩 순차적으로 분석한다. 즉, 전체를 보지 않기때문에 어떤 전체 문맥의 정보를 가지고 코드를 분석, 평가할 수 없다. 때문에 만약 뒷 부분인 아닌 앞 부분에서 에러가 발생하면 뒷 부분은 검사도 하지않고 앞 부분에서 에러가 발생했다고 종료된다.

3.2 컴파일렁의 목적

컴파일러의 목적은 다음과 같다.

  1. 오류발견: 소스코드에서 오류를 발견하여 실행 시 문제가 없도록 하는 것이다. 컴파일러는 오류를 찾기 위해 심벌 테이블(symbol table)을 사용한다. 심벌 테이블은 변수 선언부에 명시한 각 변수의 이름과 type를 모아놓은 테이블로 선언하지 않은 변수를 사용하지는 않았는지, 변수에 다른 type의 데이터를 저장하지 않았는 지를 확인할 수 있다.

  2. 코드 최적화: 소스코드를 기계어로 바꾸는 과정에서 불필요한 작업이나, 반복되는 작업이 있다면 이를 시스템 차원에 맞게 최적화하는 작업을 해준다.

이렇게 컴파일러에 의해 컴파일된 실행 가능한 프로그램이 만들어지면, .exe , .com파일이 만들어진다. 반면 유닉스 시스템에서는 .out파일이 만들어질 것이다.

3.3 컴파일러와 인터프리터의 차이

컴파일러를 사용하는 프로그래밍 언어는 사용할 변수를 먼저 선언한 후 코드를 작성한다. 변수 선언은 오류를 찾고 코드를 최적화하기 위해 반드시 필요한 작업이다.

컴파일러는 실행 전에 소스코드를 점검하여 오류를 수정하고 필요 없는 부분을 정리하여 최적화된 실행 파일을 만든다. 그러나, 인터프리터는 한 줄 씩 위애ㅔ서부터 아래로 실행되기 때문에 같은 일을 반복하거나 필요없는 변수를 확인하지 못한다. 따라서 크고 복잡한 프로그램에는 컴파일러를 사용하고 간단한 프로그램에는 인터프리터를 사용한다.

3.4 컴파일 과정

컴파일은 사용자가 작성한 소스코드를 object code로 변환한 후 라이브러리를 연결하고 최종 실행 파일을 만들어 실행하는 과정이다.

소스코드 -> 컴파일러 -> object code -> 링커 -> 실행

소스코드~링커까지가 바로 컴파일이다.

  1. 소스코드 작성 및 컴파일: 소스코드를 작성하고 컴파일하면 목적 코드가 만들어진다. 컴퓨터는 0과 1로만 이루어진 기계어를 인식할 수 있기 때문에 사용자가 작성한 소스코드를 컴파일러로 일차 번역을 하게 된다. 이 때 얻게되는 것이 바로 object code이다.
  2. object code와 라이브러리 연결: objcet code가 만들어지면 라이브러리에 있는 코드를 object code에 삽입하여 최종 실행 파일을 만든다. 즉, 내 파일에서 mylib.h라는 라이브러리를 사용한다면 이 라이브러리도 컴파일하여 object code로 만들어주고, 내 파일에 연결시켜줘야 한다. 이를 가능하게 해주는 것이 링커이다. 링커를 통해 object code들을 하나로 묶어 실행 파일로 만드는 것이다.

  1. 동적(dynamic) 라이브러리를 포함하여 최종 실행: 위의 과정을 거치는 것은 외부 함수를 포함해서 프로그램을 만드는 것을 정적 링크(static linking)이라고 부른다. 그러나, 정적 링크를 통해서 하나의 실행 파일을 만드는 것은 큰 문제점이 있는데, 프로그램 덩치가 커지고, 외부 라이브러리가 업그레이드 되었을 경우 이를 사용하는 프로그램을 다시 컴파일 해야하는 부담이 있다. 즉, 앞서 이야기한 mylib.h에서 사용하는 함수의 정의가 변경되었다고하자. 이 때 마다 모든 파일을 컴파일하고 링킹 작업을 거치는 것은 매우 비효율 적이다. 그래서 shared library, 다른 말로 dynamic library를 만들어 컴파일 시점에 사용할 라이브러리를 연결하는 방법을 사용한다. 즉, 정적 링킹처럼 처음부터 외부 라이브러리들을 object code로 만들고 링킹해놓아 실행 프로그램을 하나의 뭉탱이로 만드는 것이 아닌, dynamic libaray로 쓸 파일의 object code는 따로 두고, 나머지만 링킹한 프로그램을 만들고, dynamic libaray는 실행 시에 라이브러리를 연결하는 것이다. 이렇게 한다면, 우리는 특정 라이브러리의 소스가 변경되어도 전체 파일을 컴파일 할 필요없이 해당 파일만 dynamic libaray로 만들고 컴파일한 후에 경로만 잘 설정해주면, 사용할 수 있다.

이런 것이 가능한 이유는 메모리와 관련이 깊다. 컴파일 과정에서 모든 라이브러리를 다 링크하여 하나의 실행 파일로 만드는 정적 링크와는 달리, 동적 라이브러리 각각의 object code를 링크시키지않고 실행 파일을 만든 다음, 실행 과정에서 동적 라이브러리의 object code 위치를 알려주어 연결해주는 것은 각 object code를 따로 메모리에 올려 실행하기 때문이다. 즉, 공유 라이브러리와 연결된 프로그램을 실행하면 내부적으로 dynamic loader라는 프로그램이 먼저 동작하여 다음의 과정을 진행한다.

  1. dynamic link된 공유 라이브러리를 찾아서 메모리에 로딩
  2. entry function( main 함수 )를 찾아서 호출
  3. 프로그램 실행

이렇게 컴파일 과정은 정적 링킹동적 링킹 둘로 이루어져 있는 것이다. 동적 object code는 파일이름도 다른데 window는 .dll 리눅스에서는 .so파일을 말한다.

그러나 위의 그림에서 한 가지 걸리는 것은 마치 실행 중에 dynamic lib object code을 동적 링킹하는 것처럼 보인다. 즉, 런타임 중에 동적 링킹하는 것처럼 보이는데, 사실 아니다. 즉, 런타임 중이 아니라 프로그램이 메모리에 올라가는 과정에서 동적 링킹이 이루어지는 것이다. 런타임 중의 동적 링킹은 dlopen(), dlsym()과 같은 라이브러리르 사용할 때 가능하다.

이 과정은 사실 코드로 확인하는 것이 더 좋다. 필자의 블로그보다는 더 좋은 정리 글이 많아서 아래에 남겨둔다.

https://nomad-programmer.tistory.com/105
https://www.lesstif.com/software-architect/shared-library-linker-loader-12943542.html

4. 메모리 관리자의 역할

위에서 프로그램을 만드는 방법에 대해서 알게되었고 어떻게 실행되는 지 확인하였다. 이제 이 프로그램을 프로세스로 올리기위해 메모리를 어떻게 관리하는 지 확인해보자.

메모리 관리는 메모리 관리자가 담당한다. 메모리 관리자는 정확히 말해 메모리 관리 유닛(Memory Manage Unit, MMU)이라는 하드웨어인데, 일반적으로 메모리 관리자라고 일컫는다. 메모리 관리자의 작업은 가져오기(fetch), 배치(placement), 재배치(replacement)이다.

  1. 가져오기(fetch):
    프로그램과 데이터를 메모리로 가져오는 작업이다. 메모리 관리자는 사용자가 요청하면 프로그램와 데이터를 모두 메모리로 가져온다. 그러나 특정 상황에서는 일부만 가져와 실행하기도 한다. 또한, 사용자 요청이 없어도 앞으로 필요할 것이라고 예상되는 데이터를 미리 가져오기도 한다.

  2. 배치(placement):
    가져온 프로그램과 데이터를 메모리의 어떤 부분에 올려놓을 지 결정하는 작업이다. 배치 작업 전에 메모리를 어떤 크기로 자를 것인지가 매우 중요하다. 같은 크기로 자르냐, 실행되는 프로세스의 크기에 맞게 자르냐에 따라 메모리 관리의 복잡성이 달라진다.

  3. 재배치 작업(replacement):
    새로운 프로세스를 가져와야 하는데 메모리가 꽉 찼다면 메모리에 있는 프로세스를 하드디스크로 옮겨놓아야 새로운 프로세스를 메모리에 가져올 수 있다. 이처럼 꽉 차 있는 메모리에 새로운 프로세스를 가져오기 위해 오래된 프로세스를 내보내는 작업이 재배치 작업이다.

메모리 관리자는 fetch,m placement, replacement 등의 작업을 다음과 같은 정책을 수립하여 그 정책에 따라 메모리를 관리한다.

  1. fetch 정책: 프로세스가 필요로 하는 데이터를 가져올지 결정하는 정책이다. 프로세스가 요청할 떄 메모리로 가져오는 것이 일반적인 방법이지만, 필요하다고 예상되는 데이터를 미리 가져오는 방법인 prefectch도 있다.
  2. placement 정책: 가져온 프로세스를 메모리의 어떤 위치에 올려놓을 지 결정하는 정책이다. 메모리를 일정한 크기로 자르는 것을 페이징(paging)이라고 하며, 프로세스의 크기에 맞게 자르는 것을 세그먼트테이션(segmentation)이라고 한다. placement 정책은 paging과 segmentation의 장단점을 파악하여 메모리를 효율적으로 관리할 수 있도록 정책을 만드는 것이다.
  3. replacement 정책: 메모리가 꽉 찼을 때 메모리 내에 어떤 프로세스를 내보낼 지 결정하는 정책이다. 앞으로 사용하지 않을 프로세스를 내보내면 시스템의 성능이 올라가지만, 자주 사용할 프로세스를 내보내면 성능이 떨어진다. 때문에 앞으로 사용하지 않을 프로세스를 찾아내 내보내는 알고리즘을 replacement 알고리즘이라고 한다.

5. 메모리 주소

메모리에 접근할 때는 주소를 이용한다. 따라서 메모리와 주소는 매우 밀접한 관계이다.

5.1 32bit CPU와 64bit CPU의 차이

CPU의 비트는 한 번에 다룰 수 있는 데이터의 최대 크기를 의미한다. 즉, CPU에서 명령어를 처리하는데 32bit라는 것은 최대 크기 32bit를 메모리에서 가져와 사용할 수 있다는 것이다. CPU 내부 부품은 모두 이 비트를 기준으로 제작되는데, 32bit CPU 내의 레지스터 크기는 전부 32bit이고, 산술 논리 연산장치도 32bit를 처리할 수 있도록 설계된다. 또한 데이터를 전송하는 각종 버스의 크기, 즉 대역폭도 32bit이다. 32bit 대역폭의 버스를 통해 한 번에 옮겨지는 데이터의 크기는 당연히 32bit이다.

CPU의 비트는 메모리 주소 공간(memory space)의 크기와도 관련이 있다. 왜냐하면 CPU의 비트만큼 메모리 주소를 가져올 수 있는데, 32bit라면 2^32-1개의 주소 공간을 가져올 수 있다는 것이다. 따라서 이는 최대 메모리 4GB만 활용할 수 있다는 것을 의미한다.

오늘날의 CPU는 64bit을 가진다. 레지스터 크기, 버스의 대역폭, 한 번에 처리되는 데이터의 초디ㅐ 크기 등이 32bit CPU의 2배가 되는 것이다. 따라서 32bit CPU보다 처리 속도가 빠르고 사용할 수 있는 메모리도 크다. 32bit CPU는 0~2^32-1 번지의 주소 공간을 제공하지만 64bit CPU는 0~2^64-1번지의 주소 공간을 제공한다. 또한, 32bit CPU의 메모리는 4GB로 제한되지만 64bit의 CPU는 메모리 약 2^64 byte를 가져올 수 있는데 이는 거의 무한대에 가까운 메모리를 사용할 수 있다는 것이다.

32bit CPU든 64bit CPU든 컴퓨터에는 메모리가 설치되며 각 메모리 주소 공간이 있다. 이렇게 설치된 메모리의 주소 공간을 물리 주소 공간(physical address space)이라고 한다. 물리 주소 공간은 하드웨어 입장에서 바라본 주소 공간으로 컴퓨터마다 그 크기가 다르다. 이와 반대로 사용자 입장에서 바라본 주소 공간은 논리 주소 공간(logical address space)라고 한다.

5.2 절대 주소와 상대 주소

단순한 메모리 영역을 예시로 들면, 운영체제 영역과 사용자 영역이 있다. 운영체제 영역은 운영체제가 구동되는데 필요한 영역이므로 사용자가 함부로 침범하지 못하고, 다른 메모리 영역과 엄격히 구분되어야한다. 따라서, 이를 구분하기위해서는 하드웨어의 도움이 필요한데, 이는 CPU 내에 있는 경계 레지스터가 담당한다. 경계 레지스터는 운영체제 영역과 사용자 영역 경계 지점의 주소를 가진 레지스터이다. 메모리 관리자는 사용자가 작업을 요청할 때마다 경계 레지스터의 값을 벗어나는 지 검사하고, 만약 경계 레지스터를 벗어나는 작업을 요청하는 프로세스가 있다면 그 프로세스를 종료한다.

5.2 절대 주소와 상대 주소의 개념

사용자 프로세스가 메모리의 사용자 영역 400번지에 올라왔다고 하자, 컴파일 할 당시에 변수의 주소를 0번지부터 배정한다. 컴파일할 당시에는 변수가 메모리의 어느 위치에 올라가는 지 알 수 없기 때문에 0번지부터 배정하고 실제로 실행할 때 주소를 조정한다. 만약 사용자 프로세스가 메모리의 400번지에 올라간다면 프로세스 내 변수의 각 주소에 400을 더하는데, 이 때 400번지는 절대 주소(absolute address)이다. 실제 물리주소를 가리키는 절대 주소는 메모리 관리자 입장에서 바라본 주소이다. 즉 메모리 관리 주소 레스터가 사용하는 주소로 컴퓨터에 꽂힌 램 메모리의 실제 주소를 말한다.

상대 주소(relative address)는 사용자 프로세스 입장에서 운영체제 영역은 어차피 사용할 수 없는 공간이다. 또한 운영체제의 절대 주소(물리 주소)를 알 필요도 없다. 상대 주소는 사용자 영역이 시작되는 0번지로 변경하여 사용하는 주소 지정방식이다. 가령, 아래 그림처럼 운영체제 영역이 360까지라면, 절대주소 400번지는 상대주소로 40번지가 된다.

즉, 상대주소는 사용자 프로세스 입장에서 바라본 주소이며, 절대 주소와 관계없이 항상 0번지부터 시작한다. 상대 주소를 사용하면 프로세스 입장에서 상대 주소가 사용할 수 없는 영역의 위치를 알 필요가 없고, 주소가 항상 0번지부터 시작하기 때문에 편리하다.

상대 주소는 사용자 프로세스 입장에서 운영체제가 어디서 끝나는지, 자신의 데이터가 어디에 존재하는 지 알 필요없이 주소 공간이 항상 0번지부터 시작하는데, 이러한 주소 공간을 논리 주소 공간이라고 부른다. 앞에서 설명했듯이 논리 주소 공간은 물리 주소 공간의 상대적인 개념이다. 즉, 논리 주소 공간은 상대 주소를 사용하는 주소 공간이고, 물리 주소 공간은 절대 주소를 사용하는 주소 공간이다.

5.3 상대 주소를 절대 주소로 변환하는 과정

상대 주소를 사용하면 상대 주소를 실제 메모리 내의 물리 주소, 즉 절대 주소로 변환해야 한다. 이러한 변환 작업은 프로세스가 실행되는 동안 메모리 관리자가 매우 빠르게 처리한다. 메모리 관리자가 상대 주소를 절대 주소로 변환하는 과정은 다음과 같다.

  1. 사용자 프로세스가 상대주소 40번지에 있는 데이터를 요청한다.
  2. CPU는 메모리 관리자에게 40번지에 있는 내용을 가져오라고 명령한다.
  3. 메모리 관리자는 replacement 레지스터를 사용하여 상대 주소 40번지를 절대 주소 400번지로 변환하고 메모리 400번지에 저장된 데이터를 가져온다.

메모리 관리자는 사용자 프로세스가 상대 주소를 사용하여 메모리에 접근할 때마다 상대주소 값에 replacement 레지스터 값을 더하여 절대 주소를 구한다. replacement 레지스터는 주소 변환 기본이 되는 주소값을 가진 레지스터로 메모리에서 사용자 영역의 시작 주소값이 저장된다. 사용자 프로세스 입장에서는 메모리 관리자가 replacement 레지스터를 사용하여 상대 주소를 절대 주소로 변환하기 때문에 메모리가 항상 0번지부터 시작하는 연속된 작업 공간으로 보인다. 만약 운영체제 영역이 바뀌어 사용자 영역이 500번지부터 시작한다면 replacement 레지스터에 500을 넣으면 된다.

0개의 댓글