Linux 구조 (OS와 하드웨어 기초)

Contents

  • Introduction
  • OS란?

    • OS가 생격난 배경
    • 선점과 비선점 방식
  • 컴퓨터 시스템의 개요
  • 사용자 모드로 구현되는 기능

    • 시스템 콜
    • 시스템 콜의 wrapper 함수
    • 표준 C 라이브러리
  • 프로세스 관리

    • fork() 함수
  • 프로세스 스케줄러
  • 메모리 관리

    • 단순한 메모리 할당
    • 가상 메모리
    • 가상메모리 응용
    • 파일 맵
    • 디맨드 페이징
    • Copy on Write
    • 스왑
    • 계층형 페이지 테이블
    • Huge Page
  • 메모리 계층
  • 파일 시스템
  • 저장 장치

Introduction

컴퓨터 시스템을 구성하는 OS(운영체제, Operating System)와 이와 연계된 하드웨어 기초에 대해서 학습해 보려고 합니다.

오늘날 컴퓨터 시스템은 계층화, 세분화가 잘 되어 있어서 개발자들이 OS나 하드웨어를 일일이 고려하며 프로그래밍하는 일이 적어졌습니다. 이렇게 계층화된 컴퓨터 시스템을 아래와 같이 도식화 할 수 있습니다. 보통 애플리케이션 개발자라면 바로 밑 라이브러리 정도만 알면 되고, OS 라이브러리 개발자라면 커널 정도만 알아도 된다고 합니다.

하지만 실제 시스템 상황은 위와 같이 단순하지 않고 복잡하기 때문에, 일부만 알아서는 해결할 수 없는 문제가 산재해 있습니다. 결론적으로, 모든 계층에 대한 이해가 있어야만 해결할 수 있는 문제가 존재함을 의미합니다. 최근 회사에서 작업을 하는 스마트 TV용 애플리케이션의 성능 문제가 있었는데, 이러한 문제 또한 시스템상 복잡하게 얽힌 문제의 일환으로 생각됩니다.

개발자가 OS와 하드웨어에 대한 이해도가 높으면, 다음과 같은 일을 할 수 있게 됩니다.

  • 하드웨어 특성을 고려하여 소프트웨어를 개발할 수 있습니다.
  • 시스템 설계 시 기준으로 삼을 지표가 무엇인지 알 수 있습니다.
  • OS나 하드웨어 관련 오류를 대처할 수 있습니다.

여기에 더해서 네트워크 관련 지식을 쌓으면 더 좋지만, 네트워크 자체만으로 분량이 방대해서 별도 포스팅으로 학습하고자 합니다.

OS란

OS가 없는 임베디드 시스템에서 프로그램을 만든다면 상관이 없지만, 아마 대부분 실제 대부분의 프로그램이 돌아가기 위해 OS가 꼭 필요합니다. 그렇다면 OS란 무엇일까요? 사실 OS를 한마디로 정의하는것은 쉽지 않습니다. 저는 윈도우즈나 과거 도스의 커맨드 창과 같은 것을 OS로 착각했습니다. 하지만, 이러한 것도 워드나 다른 프로그램과 같이 OS 위에서 돌아가는 하나의 프로그램입니다. 단지, 다른 프로그램을 쉽게 사용할 수 있도록 사용자에게 인터페이스를 제공해주는 프로그램입니다.

OS란 운영체제라고 하는 일종의 소프트웨어입니다. 말 그대로 PC와 같은 장치를 운영(Operating)해 주는 체제(System)입니다. PC 안에서 각 프로그램들이 충돌이 일어나지 않으며 원만하고 효율적으로 돌아가게 하기 위해서 기반 시설을 제공해주는 인프라 같은 존재입니다.

OS 위에서 Hello World 프로그램을 만드는 것은 매우 간단합니다. 하지만 OS가 없는 PC에서 Hello World 프로그램을 만드는 것은 많은 노고가 필요할 것입니다. 화면에 아무 점이라도 찍기 위해서 비디오 카드에 직접 엑세스하는 코드를 작성해야 할 것입니다. 그리고 Hello World라는 글자를 표현하기 위해 폰트 데이터도 있어야 합니다. 모니터에는 점 단위로 표현을 하기 때문에 글자를 표기하기 위한 점들의 위치 정보가 있어야 합니다. OS가 없기 때문에 당연히 이러한 폰트도 없으므로 직접 코드상에 그에 해당하는 데이터를 가지고 있어 합니다.

이러한 고난을 이겨내고 Hello World 프로그램을 만들었다고 해도 아직 다른 문제가 존재합니다. 프로그램은 실행은 어떻게 해야 할까요? OS가 없으니 컴파일러를 돌려 exe 파일을 만들고 탐색기를 띄워 이 exe 파일을 실행하는 과정을 거칠수 없습니다. OS가 없고 파일이라는 개념도 없기때문에, 해당하는 코드는 PC 전원이 들어오면 CPU가 가장 먼저 인스트럭션을 읽어오기 시작하는 주소에 들어가 있어야 합니다. 전원과 상관없이 내용이 지워지지 않는 ROM에 직접 프로그램을 구워서 CPU의 시작 주소에 프로그램이 위치할 수 있도록 주소를 계산해 CPU와 연결해야 합니다.

OS가 생겨난 배경

지금처럼 개인화된 컴퓨터가 없을때, 특정 기관의 전산실에 컴퓨터가 한 대 있으면 여러 사람들이 이를 함께 사용했습니다. 천공 카드에 프로그램을 짜서 관리자에게 전달하면, 관리자는 프로그램을 돌리고 결과물을 프린트해서 돌려주는 형태였습니다.

문제는 이러한 작업이 산재해 있을 때, 대부분의 시간을 프린터 출력에 사용해 CPU의 이용률이 매우 낮다는 것입니다. 따라서, 프린트 스풀링(Spooling)과 같은 개념을 도입해 여러 작업을 메모리에 적재해 놓고 한 작업이 끝나면 다음 작업으로 넘어가게 하는 것이 초기 OS의 목적이었습니다. 이러한 작업을 해주는 것을 일괄처리 시스템이라고 불렀습니다.

일괄 처리 시스템도 I/O 작업을 기다려야 하는 상황과 같이 대기 상태가 발생하면 결국은 CPU가 유휴상태로 있는 시간이 많았습니다. 따라서, 대기상태가 되면 다른 프로그램을 실행할 수 있도록 OS가 개선되었습니다. 이를 다중 프로그래밍(Multi-Programming) 혹은 멀티태스킹(Multi Tasking)이라고 부르는데, 여기서 바로 스케줄링의 필요성이 대두되었습니다.

컴퓨터 시스템의 개요

컴퓨터 시스템이 동작할 때 하드웨어에서는 다음 순서가 반복됩니다.

  1. 입력 장치 혹은 네트워크 어댑터를 통해서 컴퓨터에 무언가 초리 요청이 들어옵니다.
  2. 메모리에 있는 명령을 읽어 CPU에서 실행하고 그 결과값을 다시 메모리의 다른 영역에 기록합니다.
  3. 메모리의 데이터를 하드디스크(이하 HDD로 표기)나 SSD 등의 저장 장치에 기록 또는 네트워크를 통해 다른 컴퓨터에 전송하거나 디스플레이 등의 출력 장치를 통해 사람에게 결과값을 표기합니다.
  4. 1번부터 반복해서 실행합니다.

위 순서를 반복해 사용자에게 필요한 하나의 처리로 정리한 것을 프로그램이라 합니다. 그리고 프로그램은 크게 애플리케이션, 미들웨어, OS(운영체제)가 있습니다.

일반적으로 OS는 여러 프로그램을 프로세스라고 하는 단위로 실행합니다. 리눅스를 포함해 대부분의 OS는 여러 개의 프로세스를 동시에 실행할 수 있습니다.

리눅스의 중요한 역할은 외부 장치를 조작하는 일입니다. 앞서 언급했지만, 리눅스가 없으면 여러 개의 프로세스가 각각 디바이스를 조작하는 코드를 작성해야 합니다. 이러한 경우 애플리케이션의 개발자가 디바이스의 상세 스펙을 알아야 하고 개발 비용 또한 커집니다. 그리고, 멀티 프로세스가 동시에 디바이스를 조작할 경우 예상치 못한 문제가 발생할 수도 있습니다.

이러한 단점 때문에 리눅스는 디바이스 드라이버라고 하는 프로그램을 통해서만, 프로세스가 디바이스를 조작할 수 있도록 되어 있습니다. 세상에는 여러 종류의 디바이스가 있지만, 디바이스의 종류만 같으면 리눅스는 같은 인터페이스로 조작하도록 되어 있습니다.

개발자의 버그나 해킹 목적으로 특정 프로세스가 집접 디바이스를 조작하려고 시도하는 상황이 발생합니다. 리눅스는 이러한 문제를 피하고자 CPU에 있는 기능을 이용해 프로세스가 직접 하드웨어에 접근하는 것을 차단합니다. CPU에는 커널 모드와 사용자 모드가 있는데, 커널 모드로 동작할 때만 디바이스에 접근할 수 있으며, 프로세스는 사용자 모드에서 작동합니다.

디바이스 조작 외에도 프로세스 관리 시스템, 프로세스 스케줄링, 메모리 관리 시스템과 같은 처리도 커널이 담당합니다. 프로세스가 커널이 제공하는 기능을 사용하려 할 때는 시스템 콜이라고 하는 특수한 처리를 통해 커널에 요청합니다.

커널은 시스템에 탑재된 CPU나 메모리 등의 리소스를 관리하고 리소스의 일부를 시스템에 존재하는 각 프로세스에 적절히 분배합니다.

사용자 모드로 구현되는 기능

OS는 커널 이외에도 사용자 모드에서 동작하는 여러 프로그램으로 구성되어 있습니다. 작은 단위로 컴퓨터 시스템을 구성하는 각각의 프로세스와 OS의 관계를 나타낸 아래의 그림을 참조해 주세요.

시스템에는 애플리케이션이나 미들웨어뿐만 아니라 OS에서 제공하는 프로그램도 여러 가지 있습니다. 이번 장에는 시스템 콜, OS가 제공하는 라이브러리, OS가 제공하는 프로그램에 대해 살펴보겠습니다.

시스템 콜

프로세스가 프로세스의 생성이나 하드웨어의 조작 등 커널의 도움이 필요한 경우 시스템 콜을 통해 커널에 처리를 요청합니다. 시스템 콜의 종류에는 아래와 같은 것들이 있습니다.

  • 프로세스 생성, 삭제
  • 메모리 확보, 해제
  • 프로세스 간 통신(IPC)
  • 네트워크
  • 파일시스템 다루기
  • 파일 다루기(디바이스 접근)

CPU의 모드 변경

프로세스는 보통 사용자 모드에서 실행되며 커널에 요청을 위해 시스템 콜을 호출하면 CPU에서 인터럽트 이벤트가 발생합니다. 인터럽트 이벤트가 발생하면 CPU는 커널 모드로 변경 후 요청을 처리를 완료하고, 다시 사용자 모드로 전환합니다. 유저 프로세스에서 시스템 콜을 통하지 않고 직접 CPU 모드를 변경할 수는 없습니다.

시스템 콜의 동작 순서

strace 명령어를 통해 프로세스가 어떠한 시스템 콜을 호출했는지 확인할 수 있습니다. 우선 hello 프로그램을(hello.c) 작성해 보아요.

#include <stdio.h>

int main(void)
{
  puts("hello world");
  return 0;
}

작성한 hello 프로그램을 우선 컴파일한 후 실행해 봅니다.

$ cc -o hello hello.c
$ ./hello
hello world
$

strace의 출력과 프로그램의 자체 출력이 섞이지 않도록 '-o' 옵션을 사용해 strace 출력을 별도의 파일로 저장합니다.

$ strace -o hello.log ./hello
hello world
$

그리고 strace의 결과가 저장된 hello.log의 내용을 살펴볼 수 있습니다.

$ cat hello.log
execve("./hello", ["./hello"], [/* 22 vars */]) = 0
brk(0)                                  = 0xd86000
access("/etc/ld.so.nohwcap", F_OK)      = -1 ENOENT (No such file or directory)
access("/etc/ld.so.preload", R_OK)      = -1 ENOENT (No such file or directory)
# .....생략
munmap(0x7f4b4cbfe000, 30824)           = 0
fstat(1, {st_mode=S_IFCHR|0620, st_rdev=makedev(136, 1), ...}) = 0
mmap(NULL, 4096, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f4b4cc05000
write(1, "hello world\n", 12)           = 12
exit_group(0)                           = ?
+++ exited with 0 +++

strace 각각의 줄은 1개의 시스템 콜 호출을 의미합니다. 가장 아래서 세번째 줄을 보면 'write' 시스템 콜이 "hello world\n" 문자열을 화면에 출력하고 있습니다. 제가 실행한 환경에서 시스템 콜은 총 31번 호출되었습니다. hello 프로그램은 c 언어로 작성되었지만 작성한 언어와 상관없이 프로그램이 커널에 요청할 때에는 시스템 콜을 호출합니다.

실험

프로세스가 사용자 모드와 커널 모드 중 어느 쪽에서 실행되고 있는지의 비율은 sar 명령어로 확인할 수 있습니다. 단순히 부모 프로세스의 프로세스 ID를 얻는 'getppid()' 시스템 콜을 무한루프 하는 프로그램을 실행해서 CPU가 어떤 종류의 처리를 하는지 측정해 보도록 하겠습니다.

// ppidloop.c
#include <sys/types.h>
#include <unistd.h>

int main(void)
{
  for (;;)
      getppid();
}

위 프로그램을 컴파일 후 실행하면 다음과 같이 1초 동안 한 일을 확인할 수 있습니다.

$ cc -o ppidloop ppidloop.c
$ ./ppidloop &
[1] 7231
$ sar -P ALL 1 1
Linux 3.13.0-170-generic (vagrant-ubuntu-trusty-64) 	09/23/2019 	_x86_64_	(1 CPU)

04:00:52 PM     CPU     %user     %nice   %system   %iowait    %steal     %idle
04:00:53 PM     all     35.42      0.00     64.58      0.00      0.00      0.00
04:00:53 PM       0     35.42      0.00     64.58      0.00      0.00      0.00

Average:        CPU     %user     %nice   %system   %iowait    %steal     %idle
Average:        all     35.42      0.00     64.58      0.00      0.00      0.00
Average:          0     35.42      0.00     64.58      0.00      0.00      0.00
$

위 출력을 확인해 보면 CPU 0이 ppidlop 프로그램을 35.42%의 비율로 실행하고, 이 프로그램의 부모 프로세스를 얻는 커널의 처리를 64.58%의 비율로 실행하고 있습니다. %system이 100%가 아닌 이유는 main 함수 안에서 getppid()를 호출하기 위한 루프가 프로세스를 사용하고 있기 때문입니다.

측정이 끝났으면 프로그램을 종료합니다.

kill 7231

프로세스 관리

커널의 프로세스 생성 및 삭제에 대해 알아보겠습니다. 리눅수의 실제 프로세스 생성 및 삭제 동작 방식은 가상 기억장치에 대한 이해가 필요합니다. 여기서는 우선 가상 기억장치가 없는 단순한 경우만 살펴보도록 하겠습니다.

리눅스에서 프로세스를 생성하는 두 가지 목적이 있습니다.

  1. 같은 프로그램의 처리를 여러 개의 프로세스가 나눠서 처리함. (예, 웹 서버)
  2. 전혀 다른 프로그램을 생성함. (예, bash로 각종 프로그램을 새로 생성하는 경우)

위 생성 목적에 대해 fork()와 execve()함수를 사용하는데(시스템 내부에서는 clone(), execve() 시스템 콜을 호출함), 이 두가지 함수에 대해 알아보겠습니다.

fork() 함수

"같은 프로그램의 처리를 여러 개의 프로세스가 나눠 처리하기"라는 목적에는 fork() 함수만 사용합니다. 프로세스를 생성하는 순서는 다음과 같습니다.

  1. fork() 함수를 실행하면 실행한 프로세스와(부모 프로세스) 함께 새로운 프로세스가(자식 프로세스) 1개 생성됩니다.
  2. 자식 프로세스용 메모리 영역을 작성하고 거기에 부모 프로세스의 메모리를 복사합니다.
  3. fork() 함수의 리턴값이 각기 다른 것을 이용하여 부모 프로세스와 자식 프로세스가 서로 다른 코드를 실행하도록 분기합니다.

!!그림 여기에 삽입!!

그러면, fork() 함수를 사용해 프로세스가 생성되는 과정을 간단한 프로그램을 통해 알아보도록 하겠습니다.

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <err.h>

static void child()
{
  printf("I'm child! my pid is %d.\n", getpid());
  exit(EXIT_SUCCESS);
}

static void parent(pid_t pid_c)
{
  printf("I'm parent! my pid is %d and the pid of my child is %d.\n", getpid(), pid_c);
  exit(EXIT_SUCCESS);
}

int main(void)
{
  pid_t ret;
  ret = fork();
  if (ret == -1)
    err(EXIT_FAILURE, "fork() failed");
  if (ret == 0) {
    // fork() 함수가 리턴할 때 자식 프로세스는 0을 반환하기 때문에, 자식 프로세스는 이 곳으로 도달합니다.
    child();
  } else {
    // fork() 함수가 리턴할 때 부모 프로세스는 자식 프로세스의 프로세스 ID를 리턴하기 때문에, 부모 프로세스는 이 곳으로 도달합니다.
    parent(ret);
  }
  // shouldn't reach here
  err(EXIT_FAILURE, "shouldn't reach here");
}

컴파일해서 실행해 보면 아래와 같은 출력을 확인할 수 있습니다.

$ cc -o fork fork.c
$ ./fork
I'm parent! my pid is 43140 and the pid of my child is 43141.
I'm child! my pid is 43141.
$

execve() 함수

전혀 다른 프로그램을 생성할 때에는 execve() 함수를 사용합니다. 전혀 다른 프로그램을 생성하는 경우 프로세스의 수가 증가하는 것이 아니라 기존 프로세스를 변경하는 방식으로 수행됩니다.

프로세스 스케줄러

일반적으로 스케줄러는 아래와 같이 설명합니다.

  • 하나의 CPU는 동시에 하나의 프로세스만 처리할 수 있음.
  • 하나의 CPU에 여러 개의 프로세스를 실행해야 할 때는 각 프로세스를 적절한 시간으로 쪼개 번갈아 처리함.

메모리 관리

리눅스는 커널의 메모리관리 시스템으로 시스템에 탑재된 메모리를 관리합니다. 간단하게 "free" 명령어로 총 메모리 양과 사용중인 메모리의 양을 확인할 수 있습니다.

Screen Shot 2019-10-05 at 7 57 13 PM

free 명령어로 확인할 수 있는 것을 그림으로 나타냈습니다.

메모리 사용량이 증가하면 비어 있는 메모리가 점점 줄어들며, 이러한 상태가 되면 메모리 관리 시스템은 커널 내부의 해제 가능한 메모리 영역을 해제합니다. 그래도 메모리 사용량이 계속 증가하면 메모리 부족 상태(Out Of Memory, OOM)가 됩니다.

이러한 경우 프로세스를 선택해 강제 종료 시키는 OOM Killer라는 기능이 있습니다. 업무용 서버에서 특정 프로세스를 강제 종료하는 것은 큰 문제가 될 수 있습니다. 서버에서는 sysctl의 'vm.paniconoom' 파라미터의 기본값을 변경해 메모리가 부족하면 프로세스가 아닌 시스템을 강제종료하는 방법이 있기도 합니다.

단순한 메모리 할당

실제 메모리 할당 방식은 나중에 설명할 가상 메모리에 대한 이해가 필요합니다. 이에 앞서 우선 가상 메모리가 없는 단순한 경우와 가상 메모리가 없어서 생기는 문제점에 대해 살펴보겠습니다.

커널이 프로세스에 메모리를 할당하는 일은 크게 두 가지 경우에 발생합니다.

  1. 프로세스를 생성할 때
  2. 프로세스를 생성한 뒤 추가로 동적 메모리를 할당할 때

1의 경우 앞서 살펴보았으며, 2번의 경우 추가로 메모리가 더 필요하면 프로세스는 커널에 메모리 확보용 시스템 콜을 호출해 메모리 할당을 요청합니다. 커널은 필요한 사이즈를 빈 메모리 영역에서 잘라내 시작 주소값을 반환합니다. 이러한 메모리 할당 방법에는 다음과 같은 문제점이 있습니다.

  • 메모리 단편화(memory fragmentation)
  • 다른 용도의 메모리에 접근 가능
  • 여러 프로세스를 다루기 곤란함

메모리 단편화

프로세스가 메로리의 획득, 해제를 반복하면 아래와 같이 메모리 단편화가 발생합니다. 총 300 바이트가 비어 있지만 각각 100바이트씩 나누어져 있습니다.

3개의 단편화된 영역을 하나의 영역으로 간주할 수 있겠지만 아래와 같은 문제가 있어서 불가능합니다.

  • 프로그램이 메모리를 획득할 때마다 몇 개의 영억에 나누어져 있는지 확인해야 하므로 불편합니다.
  • 100바이트보다 큰 하나의 데이터를 만들 수 없습니다.

다른 용도의 메모리에 접근 가능

프로세스가 커널이나 다른 프로세스가 사용하고 있는 영역에 접근해 데이터를 파괴하면 시스템적으로 문제가 발생할 수 있습니다.

여러 프로세스를 다루기 어려움

같은 프로세스 또는 여러 개의 프로세스를 동시게 움직이는 경우 어려움이 있습니다. 각 프로그램이 각자 동작할 주소가 겹치지 않도록 프로그래밍할 때마다 주의해서 만들어야 하기 때문입니다.

가상 메모리

위에 언급된 여러 문제점을 해결하기 위해 CPU는 가상 메모리 기능을 가지고 있습니다. 가상 메모리는 프로세스가 시스템에 탑재된 메모리를 직접 접근하지 않고 가상 주소를 사용해 간접적으로 접근하는 방식입니다. 아래의 그림에서 프로세스가 주소 100번지에 접근하면 실제 메모리상의 주소 600번지에 있는 데이터에 접근합니다.

페이지 테이블

가상 주소를 물리 주소로 변경하는 과정은 커널 내부에 있는 페이지 테이블이라는 표를 이용합니다. 전체 메모리를 페이지라는 단위로 나눠 관리하고 가상 메모리의 변환은 이 페이지 단위로 이루어집니다. 가상 주소 공간의 크기는 고정이며 페이지 테이블 엔트리에는 각각의 페이지의 가상 주소에 대응하는 물리 메모리가 존재하는지를 나타내는 데이터가 들어 있습니다.

가상 주소 공간을 벗어난 주소에 접근하면 CPU에는 페이지 폴트(page fault)라는 인터럽트가 발생합니다. 페이지 폴트에 의해 현재 실행 중인 명령이 중단되고 커널 내 페이지 폴트 핸들러가 동작합니다. 커널은 프로세스의 메모리 접근이 잘못되었다는 것을 페이지 폴트 핸들러에 전달하고 'SIGSEGV' 시그널을 프로세스에 통지합니다. 이 시그널을 받은 프로세스는 강제 종료됩니다.

프로세스에 메모리를 할당할 때

커널이 프로세스를 생성할 때나 추가 메모리를 요청받을 때, 가상 메모리를 통해 어떻게 프로세스에 메모리를 할당하는지 살펴보겠습니다.

프로세스를 생성할 때

먼저 프로그램의 실행 파일을 읽어 여러 보조 정보를 읽어옵니다. 프로그램을 실행하는데 필요한 메모리 사이즈는 '코드 영역 사이즈 + 데이터 영역 사이즈'이며 이 영역을 물리 메모리에 할당해서 필요한 데이터를 복사합니다. (리눅스에서 실제 물리 메모리 할당은 '디맨드 페이징'이라는 방식을 사용하는데, 이 부분은 뒷부분에서 살펴보겠습니다.) 프로세스를 위한 페이지 테이블을 만들고, 가상 주소 공간을 물리 주소 공간에 매핑하고, 엔트리 포인트의 주소에서 실행을 시작합니다.

고수준 레벨에서의 메모리 할당

C 언어의 표준 라이브러리에 있는 'malloc()' 함수가 메모리 확보 함수이며, 리눅스는 내부적으로 malloc() 함수에서 mmap() 함수를 호출해 메모리 할당을 구현합니다.

mmap() 함수는 페이지 단위로 메모리를 확보하지만 malloc() 함수는 바이트 단위로 메모리를 확보합니다. glibc는 바이트 단위 메모리 확보를 위해 mmap() 시스템 콜을 이용해 메모리 영역을 확보합니다. 프로그램에서 malloc()이 호출되면 필요한 양을 바이트 단위로 반환하고, 더 이상 빈 공간이 없으면 mmap()을 다시 호출합니다.

C 언어와 같은 고급 프로그래밍 언어의 소스코드나 파이썬 같이 직접 메모리 관리를 하지 않는 스크립트 언어의 오브젝트 생성에도 내부에서 C 언어의 malloc() 함수를 사용하고 있습니다. 좀 더 살펴보고 싶으면 적절한 파이썬 스크립트를 strace로 추적해 볼 수 있습니다.

가상메모리를 이용한 문제 해결법

앞서 가상메모리에 대해 살펴보았으며, 가상메모리를 사용해 앞서 언급되었던 아래의 문제점을 어떻게 해결하는지에 대해 알아보겠습니다.

메모리 단편화 문제

물리 메모리의 단편화된 영역을 프로세스의 가상 주소 공간에서는 하나의 큰 영역처럼 보이게 할 수 있기때문에 단편화 문제를 해결합니다.

다른 용도의 메모리에 접근 가능한 문제

가상주소 공간과 페이지 테이블은 프로세스별로 만들어집며, 각 프로세스는 다른 프로세스의 메모리에 접근할 수 없습니다. 사실 구현상의 이유로 커널의 메모리에 대한 모든 프로세스가 가상 주소 공간에 매핑되어 있습니다. 하지만, 커널 자체가 사용하는 메모리에 대응하는 페이지 테이블 엔트리는 CPU가 너컬 모드로 실행할 떄만 접근이 가능한 '커널 모드 전용'이라는 정보가 추가되어 있습니다. 따라서, 이 부분도 사용자 모드로 작동하는 프로세스는 접근할 수 없습니다

여러 프로세스를 다루기 곤란한 문제

가상 주소 공간은 프로세스별로 존재하기 때문에, 다른 프로그램과 주소가 겹치는 것을 걱정할 필요가 없고 자신이 사용할 메모리가 물리 메모리의 어디에 놓이는가에 대해 신경 쓰지 않아도 됩니다.

가상 메모리의 응용

파일 맵

리눅스에는 파일의 영역을 가상 주소 공간에 메모리 매핑하는 기능이 있습니다. mmap() 함수를 틍정한 방법으로 호출하면 파일의 내용을 읽어 들여 그 영역을 가상 주소 공간에 매핑할 수 있습니다.

디맨드 페이징

앞서 커널이 프로세스에 메로리르 할당할 때 필요한 영역을 메모리에 확보하고, 페이지 테이블을 설정하여 가상 주소 공간을 물리 주소 공간에 매핑하는 것을 알아보았습니다.

하지만, 이와 같은 방법은 확보한 메모리 중 사용하지 않는 영역이 존재할 수 있기 때문에 메모리를 낭비하는 단점이 있습니다. 이 문제를 해결하기 위해 리눅스는 디맨드 페이지(demand paging) 방식을 사용해 메모리를 프로세스에 할당합니다.

디맨드 페이징을 사용하면 프로세스의 가상 주소 공간 내 각 페이지에 대응하는 주소는 페이지에 처음 접근할 때 할당됩니다. 프로세스가 생성되면 가상 주소 공간 안에 코드 영역이나 데이터 영역에 대응하는 페이지에 '프로세스가 메모리 영역을 얻었음'을 기록합니다. 그러나 물리 메모리에 아직 할당은 하지 않습니다. 프로그램이 엔트리 포인트로부터 실행을 시작할 때 엔트리 포인트에 대응하는 물리 메모리가 할당됩니다.

이때 처리의 흐름은 다음과 같습니다.

  1. 프로그램이 엔트리 포인트에 접근합니다.
  2. CPU가 페이지 테이블을 참조해서 엔트리 포인트가 속한 페이지에 대응하는 가상 주소가 물리 주소에 아직 매핑되지 않음을 검출합니다.
  3. CPU에 페이지 폴트가 발생합니다.
  4. 커널의 페이지 폴트 핸들러가 1에 의해 접근된 페이지에 물리 메모리를 할당하여 페이지 폴트를 지웁니다.
  5. 사용자 모드로 돌어와 프로세스가 실행을 계속합니다.

Copy On Write

시스템 콜을 호출했을 떄가 아니라 그 후 쓰기가 발생할 때 물리 메모리를 복사하는 방식을 Copy On Write이라고 합니다.

앞에서 살펴본 fork() 시스템 콜도 가상 메모리 방식을 사용해 고속화됩니다. fork() 시스템 콜을 수행할 때는 부모 프로세스의 메모리를 자식 프로세스에 전부 복사하지 않고 페이지 테이블만 복사합니다. 페이지 테이블 엔트리 안에 쓰기 권한을 나타내는 필드가 있지만, 이 경우 부모도 자식도 전체 페이지에 쓰기 권한을 무효화 합니다.

이후 페이지를 읽을 뿐이라면 어느 쪽의 프로세스도 공유된 물리 페이지에 접근할 수 있습니다. 그러나 부모 혹은 사직 프로세스의 어느 쪽이든 페이지를 변경하려 하면 아래의 흐름으로 공유를 해제합니다.

  1. 페이지 쓰기를 허용하지 않기 때문에 CPU에 페이지 폴트가 발생합니다.
  2. CPU가 커널 모드로 변경되고 페이지 폴트 핸들러가 작동합니다.
  3. 페이지 폴트 핸들러는 접근한 페이지를 다른 장소에 복사하고, 쓰려고 한 프로세스에 할당한 후 내용을 다시 작성합니다.
  4. 부모, 자식 프로세스 각각 공유가 해제된 페이지에 대응하는 페이지 테이블 엔트리를 업데이트 합니다.

스왑

물리 메모리가 부족하게 되면 메모리 부족(OOM) 상태가 됩니다. 스왑은 리눅스의 메모리 부족 대응 장치이며, 저장 장치 일부를 일시적으로 메모리 대신 사용하는 방식입니다. 시스템의 물리 메모리가 부족한 상태가 되어 물리 메모리를 획득할 떄, 기존에 사용하던 물리 메모리의 일부분을 저장 장치에 저장하여 빈 공간을 만듭니다. 이때 메모리의 내용이 저장된 영역을 스왑 영역이라고 부릅니다.

계층형 페이지 테이블

Huge Page

파일시스템

리눅스에서는 저장 장치의 데이터에 접근 시 편의를 위한 파일시스템을 제공합니다. 컴퓨터 시스템에 파일시스템을 당연하듯이 여길수 있지만, 파일시스템이 없는 환경에서의 데이터의 저장 작업 흐름에 대해서 상상해 봅시다. 데이터의 저장을 위해 저장 장치의 특정 주소와 저장할 사이즈를 쓰겠다는 명령을 직접 입력해야 합니다. 또한, 저장한 정보를 읽기 위해 이러한 정보를 잘 기록해 두어야 하며, 남은 공간을 알기 위해 빈 영역을 관리해야 합니다.

이러한 복잡한 처리를 피하고자 파일시스템이 있으며, 파일시스템은 사용자에게 의미가 있는 하나의 데이터를 이름, 위치, 사이즈 등의 보조 정보를 추가해 파일이라는 단위로 관리합니다. 각 파일의 이름을 기억해 놓으면 데이터의 위치나 사이즈 등의 복잡한 정보를 기억할 필요가 없습니다.

리눅스의 파일시스템

파일을 카테고리 별로 정리할 수 있도록 리눅스 파일시스템에는 Directory라고 부르는 특수한 파일이 있습니다. Directory 안에는 파일 또는 다른 Directory 보관이 가능하며, 다른 디렉토리 안에 존재한다면 여러 파일이 같은 이름을 가질 수 있습니다.

리눅스는 'ext4, 'XFS', 'Btrfs ' 등 여러 개의 파일시스템을 다룰 수 있습니다. 각각의 파일시스템은 데이터 구조, 다룰 수 있는 파일 사이즈, 파일시스템의 사이즈, 처리 속도 등이 다릅니다. 하지만, 어떠한 파일시스템이라도 아래의 시스템 콜을 호출한다면 통일된 인터페이스로 접근이 가능합니다.

  • 파일의 작성, 삭제: creat(), unlink()
  • 파일을 열고 닫음: open(), close()
  • 열린 파일로부터 데이터를 읽어 들임: read()
  • 열린 파일에 데이터를 씀: write()
  • 열린 파일의 특정 위치로 이동: lseek()
  • 위에 언급한 것 이외의 파일시스템에 의존적인 특수한 처리: ioctl()

이러한 시스템 콜이 호출되면 다음과 같은 순서로 파일의 데이터가 읽어집니다.

  1. 커널 내 모든 파일시스템 공통 초리가 작동하고 대상 파일의 파일시스템을 판별합니다.
  2. 각 파일시스템을 처리하는 프로세스를 호출해 시스템 콜에 대응하는 처리를 합니다.
  3. 데이터 읽기를 하는 경우에는 디바이스 드라이버에 처리를 의뢰합니다.
  4. 디바이스 드라이버가 데이터를 읽어 들입니다.

데이터와 메타데이터

파일시스템에는 데이터와 메타데이터라는 두 종류의 데이터가 있습니다.

  • 데이터: 사용자가 작성한 문서나 사진, 동영상, 프로그램 등의 내용
  • 메타데이터: 파일의 이름이나 저장 장치 내 위치, 사이즈 등의 보조 정보

메타데이터에는 아래와 같은 정보 또한 포함되어 있습니다.

  • 종류: 데이터를 보관하는 일반 파일인지 디렉터리인지 혹은 다른 종류인지를 판별하는 정보
  • 시간 정보: 작성한 시간, 최후에 접근한 시간, 최후에 내용이 반영된 시간
  • 권한 정보: 어느 사용자가 파일에 접근이 가능한가

참고로 'df' 명령어로 얻은 파일시스템의 스토리지 사용량은 파일의 합계 사이즈 뿐만 아니라 메타데이터의 사이즈도 더해지므로 주의가 필요합니다.

용량 제한

특정 용도가 파일시스템의 용량을 무제한으로 사용할 수 있다면 다른 용도로 사용할 용량이 부족할 수 있고, 특히 시스템 관리 처리를 위한 용량이 부족하게 된다면 시스템 전체가 동작할 수 없게 됩니다. 이러한 상황을 피하기 위해 파일시스템의 용량을 용도별로 제한할 수 있는 기능을 쿼터(quota)라고 합니다. 쿼터에는 사용자 쿼터, 디렉터리 쿼터(혹은 프로젝트 쿼터), 서브 볼륨 쿼터 등이 있습니다.

파일시스템이 깨진 경우

시스템을 운용하다보면 종종 파일시스템이 깨지는 경우가 발생합니다. 한 예로, 데이터를 스토리지에 쓰고 있는 도중에 시스템의 전원이 끊어진 경우에 발생합니다. 파일시스템이 깨지는 것을 막기 위한 기술은 여러 가지가 있으며, 널리 사용되는 것은 '저널링'과 'Copy on Write'입니다. ext4와 XFS는 저널링으로, Btrfs는 Copy on Write로 파일시스템이 깨지는 것을 막고 있습니다.

저널링

Copy on Write