모두의 코드
내가 C 언어를 공부하기 전에 알았으면 좋았을 것들

작성일 : 2020-09-06 이 글은 69172 번 읽혔습니다.

씹어먹는 C 언어

고등학교를 졸업하고 공대에 들어가게 된다면, 어떤 경로로든 컴퓨터 언어 라는 것을 접하게 될 것입니다. 요즘에는 보통 첫 언어로 파이썬을 배우는 것이 추세지만, 일부 불쌍한 영혼들의 경우 첫 언어로 C 언어를 배우게 되실 겁니다. 하지만 C 언어를 아무런 컴퓨터에 대한 배경 지식 없이 무작정 배운다면 꽤나 힘듦니다. 그 이유는 여러가지 있겠지만, 몇 가지 꼽아보자면

  • C 언어는 무려 50 년 전에 (1972년)에 출시된 언어 입니다. 그 때 부터 지금 까지 컴퓨터의 비약적인 성장이 있었고 마찬가지로 기존 언어들의 단점을 보완하는 새로운 언어들이 수 없이 쏟아져 나왔습니다. 이 때문에 현재 많이 사용하는 언어들인 파이썬과 같은 언어들 보다는 조금 불편한 면이 있습니다.

지금은 내가 코드를 치면 바로 바로 내가 친 내용을 볼 수 있죠. 하지만 이 시절에는 코드를 치고나서 엔터를 누르면 프린터가 내가 친 내용을 출력해줬습니다.

  • 탄생 목적이 유닉스 운영체제를 작성하기 위해서 만들어졌습니다. 즉, 시스템 프로그래밍 이 주 목적 이였기에, C 와 컴퓨터 시스템과 밀접한 관계가 있습니다. 따라서 시스템 관련 배경 지식을 모른다면 C 언어를 처음 접할 때 까다로울 수 있습니다.

물론 C 언어가 지금까지 사용되는 데에는 이유가 있습니다. 가장 중요한 이유로 배울 내용이 적다 라는 점이 있습니다. 다른 말로 이야기 하면 C 언어 자체에서 제공하는 기능이 요즘에 나오는 컴퓨터 언어들에 비해서 적기 때문에, 쉽게 언어 전체 내용을 습득할 수 있습니다.

주의 사항

물론 배울 내용이 적다배우기 쉽다 는 절대 같지 않습니다.

하지만 언어의 크기가 작다고 해서 언어를 쉽게 습득할 수 있다는 것은 아닙니다. 아무래도 C 언어가 시스템 프로그래밍용 언어로 처음 출발한 만큼, 컴퓨터 시스템에 대한 이해가 있지 않은 이상 C 언어를 계속 공부하면서 아니 왜 이렇게 해야 하지?아니 이건 왜 안되지? 에 대한 의문을 끊임없이 품으시게 될 것입니다.

이 글에선 간단하게 컴퓨터 시스템에 관한 전반적인 내용들을 다루어보도록 하겠습니다. 이 글을 통해서 첫 프로그래밍 언어로 본인의 의지와 상관 없이 C 언어를 만나게될 분들에게 조금이나마 C 를 접하는데 도움이 되었으면 합니다.

그래서 컴퓨터란 도대체 뭘까

결론 부터 말하자면 컴퓨터는 일련의 연산을 수행하는 계산기 입니다.

우리가 컴퓨터로 게임도 하고 유튜브도 보고 문서도 작성하고 그러지만 결국 컴퓨터는 단순히 연산을 (빠르게) 수행하는 계산기 입니다. 즉 아래와 같은 단순한 쌀집 계산기나

쌀집 계산기

여러분이 지금 가지고 계실 데스크탑 컴퓨터나

여러분에 책상위에 있을 컴퓨터

계산을 한다는 점에서는 본질적으로는 큰 차이가 없습니다.

물론 여러분의 컴퓨터를 컴퓨터 답게 만드는 이유는 바로 명령어를 읽어들여서 주어진 명령어에 따라 연산 을 수행한다는 점 입니다. 예를 들어서 쌀집 계산기의 경우 1 부터 100 까지 더하고 싶다면 여러분이 직접 1 부터 100 까지 일일히 더해야 합니다. 반면에 컴퓨터의 경우 1 부터 100 까지 더하라는 명령을 작성한다면 컴퓨터가 해당 프로그램을 실행시켜서 동일한 작업을 할 수 있겠죠.

자, 그렇다면 바로 아래와 같은 의문들이 떠오를 것입니다.

  1. 누가 명령어를 읽는가?

  2. 어디서 명령어를 읽는가?

  3. 프로그램이란게 뭐지?

  4. 명령어는 어떻게 작성하는가?

지금 부터 하나씩 알아보도록 합시다.

누가 명령어를 읽는가?

컴퓨터의 모든 연산은 중앙 처리 장치, 흔히 CPU (Central Processing Unit) 이라 불리는 손바닥 보다도 작은 조그마한 반도체에 의해서 처리 됩니다.

요즘 핫한 AMD 에서 만든 라이젠 CPU

CPU 는 대충 1 초에 10억번 개의 연산을 처리하는 만큼 엄청난 양의 열을 발생합니다. 따라서 보통은 아래 그림 처럼 쿨러라는 냉각 장치 밑에 숨어있습니다.

제 본체 사진 입니다.

물론 요즘에 컴퓨터 그래픽 관련 연산이 늘어남에 따라 그래픽 관련 연산들만 전문적으로 처리하는 GPU 라는 장치가 따로 있기는 하지만 얘네들은 말 그래도 그래픽 전용이고 (정확히 말하자면 행렬 연산 전문), CPU 와 같이 다양한 종류의 명령어를 처리할 수 는 없습니다. 범용적인 명령어들의 경우는 모두 CPU 에서 처리 됩니다.

어디서 명령어를 읽는가?

자 이제 컴퓨터에 모든 명령어들은 CPU 에서 처리된다는 것을 배웠습니다. CPU 가 명령어를 실행하기 위해서는 두 가지 조건이 필요한데 첫 번째로 일단 실행할 명령어를 읽어야 하고, 두 번째로 연산된 결과를 어디엔가 저장해야 합니다.

이상적인 상황을 생각한다면 당연히 명령을 수행하는 곳이 CPU 이므로, 실행할 명령어들도 그냥 CPU 안에 저장해놓았다가 읽어들이고, 연산된 결과도 그냥 CPU 안에 저장하면 좋을 것 같습니다. 하지만 문제는 CPU 가 연산에 특화되어 있는 장치이기 때문에 데이터를 저장해놓을 공간이 매우 부족 하다라는 점입니다.

CPU 가 연산을 수행하기 위해서 데이터를 저장하는 공간을 레지스터 (register) 라고 부르는데, 여러분이 사용하는 64 비트 CPU 의 경우 일반적인 연산을 수행할 수 있는 레지스터는 총 16 개 밖에 없습니다.

그리고 각 레지스터에 64 비트의 데이터를 담을 수 있죠. 즉, CPU 에 최대한 쑤셔 박아도 들어갈 수 있는 데이터의 크기는 최대 128 바이트 밖에 안된다는 것입니다.

물론 레지스터는 CPU 안에 있는 메모리 이기 때문에 CPU 안에서 연산을 수행 시에 매우 빠르게 접근할 수 있습니다. 하지만 안타깝게도 기술의 한계상 레지스터의 개수를 늘리는 것은 어렵기 때문에 CPU 밖에 있는 저장공간이 필요할 수 밖에 없습니다.

이렇게 CPU 옆에 딱 붙어서 저장 공간 역할을 하는 장치를 바로 램(RAM) 이라고 부릅니다. RAM 이라는 영어 단어는 임의 접근 메모리 (Random Access Memory) 의 줄인 말인데, 왜 임의 접근 인지는 아래에서 후술하겠습니다.

RAM

램은 보통 위 그림 처럼 기다란 막대기 처럼 생겼습니다. 그리고 그 안에는 데이터를 보관하는 여러 칩들이 들어가 있죠. 여러분의 컴퓨터 본체 안을 열어 보면 CPU 옆에 일자로 박혀있는 램들을 확인하실 수 있을 것입니다.

위와 같이 CPU 옆에 RAM 이 딱 붙어있습니다.

램의 경우 이미 잘 아시는 분들은 아시겠지만, CPU 의 조그마한 레지스터들에 비해서 비교할 수 없을 만큼 큰 용량을 제공합니다. 보통 사용하는 램이 8GB 정도 되므로, CPU 의 레지스터들 크기 (128 바이트) 와 비교했을 때 대략 6 천만 배 이상 크죠.

따라서 CPU 는 램에 실행할 명령어들을 저장해놓고 있다가, 연산을 수행할 때 램에서 읽어들이게 됩니다. 그리고 필요한 데이터는 램에서 꺼내 쓰거나 저장해놓죠. 하지만 이런 램도 큰 단점이 하나 있는데 바로 휘발성 메모리 라는 점입니다. 휘발성 메모리란, 전기가 있을 경우만 유지되는 메모리를 말하며, 전원이 공급되지 않는다면 메모리에 저장되어 있는 데이터는 모두 날라가게 됩니다.

컴퓨터를 항상 켜놓을 수는 없을 노릇이기에, 컴퓨터가 꺼져도 데이터를 유지할 수 있는 저장장치가 필요하는데, 이 때 등장하는 것이 바로 하드 디스크와 SSD 가 되겠죠. 이들은 램과는 다르게 전기 공급 없이도 데이터를 안정적으로 보관할 수 있습니다. 물론 램과 비교했을 때 이들에서 데이터를 읽어들이는 작업은 훨씬 느립니다.

통상적으로 CPU 에서 램에 있는 데이터를 가져오는데 100 나노초가 걸린다고 보는데, SSD 의 경우 대략 50 ~ 150 마이크로초, 하드 디스크의 경우 무려 1 ~ 10 밀리초가 걸립니다. 참고로 1 밀리초는 1000 마이크로초이고, 1 마이크로초는 1000 나노초 입니다.

CPU 는 평균적으로 0.3 나노초 마다 1 번 연산을 수행하는데, 하드 디스크에서 데이터가 올 때 까지 기다리는 동안 대략 3천만 번의 연산을 수행할 수 있습니다. 만일 CPU 가 매번 하드 디스크에서 필요한 명령어를 읽어들인다면 엄청난 시간을 낭비하게 되겠죠! 따라서 보통 컴퓨터 프로그램을 실행을 한다면

  1. 하드 디스크에서 저장되어 있는 프로그램의 위치를 찾아서 램에 복사해놓는다.

  2. CPU 는 램에서 명령어를 읽어들여서 실행을 한다.

순으로 진행이 된다고 보시면 됩니다.

전체적인 개요

눈치가 빠르신 분들은 이미 아셨겠지만 사실 CPU 에서 RAM 에 접근하는 속도도 그리 빠른 편은 아닙니다. 바로 옆에 붙어 있기는 해도, 50 나노초는 CPU 에서 연산을 150번 수행할 수 있는 시간이죠. 생각보다 엄청 깁니다.

이러한 문제 때문에 CPU 에는 비록 직접적인 연산을 수행할 수는 없지만 빠르게 데이터를 레지스터에 불러올 수 있는 저장 공간으로 캐시(Cache)라는 것을 사용합니다. 캐시는 계층별로 L1, L2, L3 캐시로 이루어져 있는데, L1 캐시의 경우 크기가 제일 작지만 (보통 256KB) 레지스터와 가장 인접한 캐시로 L1 캐시에 저장되어 있는 데이터를 읽는 데에는 1 나노초 밖에 걸리지 않습니다.

따라서 데이터를 읽어들이는데 기다리는 시간을 최소화 할 수 있죠. L3 캐시의 경우 대충 28 나노초 정도 걸리지만 크기가 제일 큽니다 (~ 16MB). 따라서 보통 지금 가장 필요한 데이터 의 경우 L1 캐시에 들어가게 되고, 그 중요도에 따라 필요성이 낮으면 낮을 수록 L2, L3 캐시에 배치됩니다.

CPU 는 자기가 조만간 사용할 것만 같은 데이터들을 미리 캐시에 불러옵니다. 또한 램에 데이터를 저장할 때도, 바로 램에 쓰는 것이 아니라 캐시에만 잠깐 써놨다가 나중에 (여유가 좀 생기면) 램에 적는 방식을 사용하곤 합니다.

물론 CPU 가 미래에 실행할 명령어들을 미리 볼 수 있는 것이 아니기 때문에 램에서 어떠한 데이터가 필요로 할 지는 알 수 없는 노릇입니다. 따라서 CPU 는 여러가지 예측 알고리즘을 사용해서 캐시의 적중률을 높이려고 하는데 대개 램의 전체 데이터를 중구 난방으로 사용하는 것 보다는 특정 부분만 반복적으로 접근하는 경우에 캐시 적중률이 높아집니다.

물론 CPU 가 요청한 데이터가 캐시에 없을 수 도 있습니다. 이를 캐시 미스(Cache miss) 라고 하며, 이 경우 램에서 필요한 데이터를 불러 오느라 상당히 시간이 지체됩니다. 빠르게 동작하는 프로그램을 설계하기 위해서는 캐시 미스 확률을 낮게 하는 것이 중요하겠죠.

아래 테이블은 컴퓨터 시스템에서 데이터를 불러오는데 걸리는 시간을 나타낸 간단한 표 입니다. 정확한 수치를 기억할 필요는 없고 대충 이정도 되겠구나 정도로만 이해하시면 됩니다.

실제 접근 시간

현실 시간으로 환산했을 때

1 CPU 사이클 (가장 간단한 연산을 하는데 걸리는 시간)

0.4 나노초

1초

L1 캐시 접근

0.9 나노초

2초

L2 캐시 접근

2.8 나노초

7초

L3 캐시 접근

28 나노초

1분

RAM 접근

~100 나노초

4분

NVMe SSD 접근

~25 마이크로초

17시간

일반 SSD 접근

50~150 마이크로초

1.5일 ~ 4일

일반 하드디스크 접근

1~10 밀리초

1 ~ 9달

서울에서 미국 샌프란시스코 패킷 전송 시간

180ms

14년

CPU 가 부엌이라고 생각해본다면

  • 우리가 CPU 에서 연산하는 것은 마치 도마 위에 재료들을 써는 것과 같습니다.

  • 처음에 재료들은 냉장고 (램) 에 있으므로 꺼내는데 꽤나 시간이 걸립니다.

  • 하지만 재료를 도마 옆에 준비한 이상 (CPU 캐쉬) 금방금방 옆에 있는 재료들을 도마 위에 올려서 썰 수 있습니다.

  • 냉장고에 원하는 재료가 없다면 근처 슈퍼에 가서 가져와야 합니다. (SSD 와 HDD 접근) 시간 스케일을 정확히 하자면, SSD 에 접근하는 것은 대충 비행기 타고 미국에서 가서 재료들을 비행기로 공수하는데 걸리는 시간과 비슷하며, HDD 에 접근하는 것은 우리의 재료가 씨앗 부터 시작해서 추수할 때 까지 옆에서 기다리는 것과 같습니다 ㅎㅎ

명령어는 어떻게 작성하는가?

CPU 가 램에서 데이터를 읽어들이기 위해서는 램의 어디 에서 데이터를 읽어들일지 말해줘야 합니다. 램에 있는 모든 데이터는 1 바이트 단위로 0 번을 시작으로 고유의 주소(address) 가 부여되어 있습니다.

램의 모습

위 그림은 램의 모습을 간단하게 나마 그려본 것입니다. 위와 같이 램은 크기가 1 바이트 (2진수로 8 자리 수) 짜리 데이터를 보관할 수 있는 수 많은 방들로 구성되어 있습니다. CPU 는 램에게 어디에서 데이터를 읽을지 알려준다면 램은 해당 위치에 있는 데이터를 즉각 전달해줍니다.

마찬가지로 어디에다 데이터를 저장할지 알려준다면 램은 해당 위치에 있는 데이터를 CPU 가 전달한 데이터로 바꿔치기 합니다 (그 위치에 있던 데이터는 사라집니다.)

한 가지 중요한 점은 해당 주소값으로 부터 얼마 만큼 읽어야 할지도 말해줘야 한다는 것입니다. 램 상에서는 데이터의 경계가 없습니다. 예를 들어서 0x1234 라는 주소값에서 부터 단 한칸 (즉 1 바이트) 만 읽어야 할 수도 있고, 4 칸 (4 바이트) 를 읽어야 할 수 도 있습니다. 이렇게 얼마 만큼 읽어야 할 지는 명령어 단계에서 지정해줍니다.

예를 들어 우리가 CPU 에게 주소값 0x1234 에 1 바이트 만큼 3 이라는 데이터를 저장하고 싶다고 해봅시다. 그렇다면 다음과 같은 순서로 명령어를 내리면 됩니다.

  • 먼저 CPU 의 레지스터에 접근하고자 하는 주소값 0x1234 를 저장합니다. 편의상 a 라는 레지스터에 저장했다고 해봅시다.

  • 이제 a 에 저장된 주소값에서 부터 1 바이트 부분 까지 3 을 저장해 라는 명령을 내립니다.

그렇다면 CPU 는 레지스터 a 에 저장되어 있는 주소값인 0x1234 에, 3 이라는 데이터를 저장하라는 명령을 실행하게 됩니다. 따라서 램에는 3 이라는 데이터가 들어가겠죠.

0x1234 에 3 이라는 데이터가 들어갔습니다.

위 명령을 실제 사용되는 CPU 명령어로 재현해보자면 아래와 같습니다.

        mov     eax, 4660 # 4660 은 0x1234 를 십진수로 나타낸 것입니다.
        mov     BYTE PTR [rax], 3

mov대입 해라 라는 명령어 입니다. 예를 들어서

mov A, B

라고 했다면 A 에 B 를 대입해라 라는 의미로 생각해면 됩니다. (즉 왼쪽 인자에 오른쪽 인자의 값을 복사).

주의 사항

위 명령어를 보고 혹시라도 기겁을 하셨더라면 너무 걱정하지 마십쇼!. C 프로그래밍을 하면서 위와 같이 직접적으로 명령어를 쳐야하는 경우는 없습니다.

따라서 위 명령어들을 해석해본다면, 첫 번째 문장의 경우 eax 라는 이름의 레지스터에 0x1234 라는 값을 대입하라라는 명령이겠죠.

두 번째 문장의 경우 rax 라는 이름의 레지스터에 들어있는 값을 주소값 으로 생각해서 해당 위치에 3 을 대입해라 라는 문장입니다 ([] 가 그 역할을 의미합니다). 그렇다면 BYTE PTR 은 뭘까요? 이는 전달한 주소값으로 부터 1 바이트 만큼의 데이터를 우리가 저장할 값 (3) 으로 덮어 씌우라는 의미겠죠.

참고로 rax 와 eax 는 같은 레지스터를 의미 합니다 (아래 그림 참조) 앞서 64 비트 CPU 에는 64 비트 크기의 레지스터 16 개가 있다고 했는데, rax 는 그들 중 한 레지스터를 의미 합니다. 그리고 eax 의 경우 rax 레지스터의 마지막 32 비트를 의미하죠.

rax 와 eax 는 같은 레지스터지만, 참조하는 범위가 다릅니다.

0x1234 의 경우 크기가 $2^{32}$ 보다 작기 때문에 레지스터의 마지막 32 비트 안에 기록할 수 있습니다. 따라서 레지스터에서 eax 를 읽든 rax 를 읽든 똑같이 0x1234 가 읽히게 됩니다.

아무튼 여차저차해서 정확히 0x1234 한 칸에만 우리가 원하는 데이터 (3)가 들어갔습니다.

만약에 1 바이트 말고 4 바이트 영역에 3 을 쓰고 싶다면 어떨까요? 3 은 0x0003 이므로 0x1234 에는 3 이 들어가겠지만, 나머지 3 칸에는 0 이 들어가게됩니다.

        mov     eax, 4660
        mov     DWORD PTR [rax], 3

DWORD PTR 의 경우 4 바이트 만큼을 보라는 의미 입니다. 따라서 위 명령이 수행되었다면

0x1234 부터 0x1237 까지에 3 이라는 데이터가 들어갔습니다.

와 같이 0x1234 부터 0x1237 에 0x0003 이라는 데이터가 들어가게 됩니다.

한 가지 궁금한점! 왜 레지스터에 대입할 필요없이 그냥

        mov     DWORD PTR [4660], 3

을 하면 안될까요? 그 이유는 간단합니다. CPU 가 위와 같은 명령어를 허용하지 않기 때문입니다.

위와 같이 메모리의 주소값에 해당하는 데이터를 접근하기 위해선, 먼저 그 주소값을 레지스터에 집어 넣고 해당 레지스터를 참조해야 합니다.

또 한 가지 여기서 짚고 넘어갈 점으로 64 비트 시스템 CPU 의 레지스터의 크기가 바로 8 바이트라는 점입니다. 그 이유는 당연히도, 레지스터에는 주소값을 전달할 수 있어야 하는데, 64 비트 시스템에서 주소값은 8 바이트 이기에, 레지스터도 8 바이트가 되는 것이지요.

8 바이트로는 $2^{64} (= 18,446,744,073,709,551,616)$ 개의 수를 표현할 수 있는데, 꽤 큰 수기는 하지만 엄청나게 큰 수 (예를 들어서 $100!$ 처럼) 를 표현하기에는 무리가 있습니다. 따라서 CPU 로 매우 큰 정수의 연산을 다루기 위해서는 수를 8 바이트 단위로 쪼개서 따로따로 연산을 해야겠지요.

CPU 가 명령어를 읽어들이는 방법

자, 앞서 간단하게 CPU 가 어떠한 방식으로 램에 데이터를 읽고/쓰는지에 대해서 살펴보았습니다. 정리해보자면 CPU 는 주소값 을 통해서 램에 어디에 접근할지 명령하게 됩니다.

그런데, CPU 가 독자적으로 명령을 내릴 수 있는 것은 아닙니다. CPU 가 명령을 내리기 위해서는 해당 명령어를 어디선가 가져와야 합니다. 이와 같이 CPU 에 실행할 명령어를 제공하는 것을, 쉬운 말로 프로그램을 실행한다 라고 합니다.

프로그램은 단순히 생각해보면 실행할 명령어와 데이터들의 집합이라 보시면 됩니다. 우리가 프로그램을 실행하게 되면, 컴퓨터의 운영체제가 CPU 에게 램에 위치해 있는 프로그램의 시작점을 알려주게 되고, 그 이후로 CPU 는 해당 위치 부터 명령어를 쭉쭉 읽어나가며 실행하게 됩니다.

여기서 중요한 점은 CPU 가 현재 램의 어디에서 명령어를 읽어야 할지 계속 알아야 한다는 점입니다. 이는 CPU 안에 지금 읽어들일 명령어의 위치 (instruction pointer) 만을 보관하는 특별한 레지스터 덕분에 가능하며, 인텔 64 비트 CPU 의 경우 해당 레지스터의 이름은 RIP 입니다.

예를 들어서 위와 같이 간단한 프로그램이 메모리에 들어가 있다고 해봅시다. 만일 현재 RIP 가 0x40068 이라면, CPU 는 메모리에서 해당 위치에 쓰여 있는 명령어를 읽게 됩니다. 이 경우 mov rax, 0x20 이 되겠네요.

해당 명령어를 처리하고 나면 rax 에는 0x20 이 들어가겠고, RIP 는 그 다음 명령어를 가리키는 주소값인 0x4006b 로 자동으로 업데이트 됩니다.

CPU 는 현재 내가 어떠한 프로그램을 실행하고 있는지 모릅니다. 그저 현재 자신의 RIP 레지스터가 가리키는 위치에 있는 명령어를 실행하고 그 다음 명령어의 위치로 RIP 를 증가시키는데에만 관심이 있을 뿐 어떠한 프로그램에서 해당 명령어를 실행하는지 모릅니다.

그렇다면 예를 들어서 우리가 컴퓨터에서 스타크래프트를 실행하였다고 해봅시다. 그렇다면 누군가는 하드 디스크나 SSD 와 같은 저장 장치에 있는 스타크래프트 프로그램 파일을 메모리에 복사하는 작업을 해야 겠죠? 이와 같은 과정은 운영체제 단에서 해결합니다.

보통 메모리를 나타날 때 위 그림 처럼 세로로 긴 막대기로 나타내는 경우가 많습니다

한 가지 중요한 점은 메모리에 아 여기는 명령어들이 있는 곳이고 여기는 데이터들이 있는 곳이다 라고 써있는 것이 아닙니다. 따라서 CPU 의 관점에선 메모리의 특정 주소값을 읽으라고 했을 때 뭐가 데이터이고 뭐가 프로그램 명령어 인지 알 수 없습니다. 그냥 운영체제가 프로그램에서 처음으로 실행할 명령어의 위치를 CPU 에게 알려주고 난다면, 그냥 그려러니 하고, 거기서 부터 명령어들을 쭉쭉 읽어나가게 됩니다. CPU 는 이렇게 생각보다 단순합니다.

실제로 해킹 기법 중에서 사용자 (해커가) 입력한 데이터를 마치 명령어로 착각하게 해서 해커가 원하는 데이터를 실행시키는 공격 기법이 있습니다.

그런데 프로그램이 하나만 실행하는게 아니잖아?

앞선 그림에서는 우리가 딱 스타크래프트 하나만을 실행하는 것을 생각했지만, 실제로는 컴퓨터 상에서 한 번에 프로그램 하나만이 돌아가고 있는 것이 아니지요. 일단 이 글을 보시는 분들은 아마 인터넷 브라우저를 돌리고 있을 테고, 당연히 운영체제 자체도 프로그램으로써 메모리에 상주해있을 것입니다. 따라서 실제 RAM 을 살펴보자면 아래와 같이 여러가지 프로그램들이 각각에 구역에서 뒤영켜 있을 것입니다.

메모리에 여러 프로그램들이 올라와 있는 모습

위 그림은 실제로 여러 프로그램들이 같이 실행되고 있을 때 램의 모습을 나타낸 것입니다. 각 프로그램들이 램의 여러 부분을 나누어서 돌아가고 있음을 알 수 있습니다.

앞서 간단하게 어셈블리 명령어를 다루면서, CPU 에서 원하는 위치에 데이터를 쓰거나 가져오기 위해서는 메모리의 주소값 을 전달해야 한다고 하였습니다.

        mov     eax, 4660
        mov     DWORD PTR [rax], 3

예를 들어서 위 명령어는 메모리의 0x1234 (십진수로 4660) 에 위치해 있는 곳에서 4 바이트 만큼의 공간에 3 이라는 값을 쓰라는 명령이지요.

그런데 한 가지 문제점이 있습니다. 위와 같이 램에서 여러가지 프로그램들이 돌아가고 있는데 어떻게 0x1234 라는 공간을 사용할 수 있다고 보장할 수 있을까요?

물론, 만약에 지금 0x1234 라는 위치에 있는 공간을 어떤 프로그램도 사용하지 않고 있기에 3 이라는 값을 잘 써 넣을 수 있다고 해봅시다. 그런데 이전에 이미 실행되고 있던 프로그램이 0x1234 부분을 사용하고 있다고 하면 어떻게 될까요? 만일 위 코드가 그대로 실행되었더라면 다른 프로그램의 데이터를 손상시키게 되겠죠.

이를 해결하기 위해서는 다른 메모리 주소를 사용하도록 프로그램 명령어 자체를 다시 작성해야 하는데, 이를 프로그램을 실행할 때 마다 매번 하는 것은 불가능하다고 말할 수 있습니다.

따라서 CPU 에서는 메모리를 조금 더 효율적으로 관리하기 위해서 특별한 메커니즘을 제공합니다.

가상 메모리 vs 물리 메모리

앞서 CPU 가 0x1234 에 3 이라는 데이터를 쓰라는 명령을 내리면 메모리의 주소값 0x1234 의 위치에 3 을 쓰게된다고 하였는데 사실 거짓말 이였습니다. 왜냐하면 이렇게 된다면 앞서 이야기 했던 문제를 해결할 수 없기 때문이죠.

따라서 실제로 CPU 가 보는 0x1234 라는 주소값과 실제 메모리의 0x1234 주소값은 차이가 있습니다. CPU 가 참조하는 0x1234 라는 주소값은 특별한 1 대 1 변환 과정에 의해서 실제 메모리의 주소값을 변환하게 됩니다. 이렇게 변환된 주소는 0x1234 가 될 수 도 있고 아니면 아예 0x12345678 처럼 전혀 다른 곳에 있는 메모리 주소값이 될 수 도 있습니다.

아무튼 CPU 가 참조하는 메모리 주소값을 가상 메모리(virtual memory) 라고 하고, 일련의 변환 과정에 의해 참조하게될 실제 메모리의 주소값을 물리 메모리(physical memory) 라고 합니다.

CPU 가 보는 메모리는 사실 가상 메모리 이고, 실제로는 일정한 크기의 조각들로 쪼개어져서 메모리의 각기 다른 영역에 대응되어 있다.

이러한 변환 방식을 페이징(paging) 이라고 하고, 변환이 되는 최소의 메모리 단위를 페이지(page) 라고 합니다. 페이지의 크기는 여러가지로 설정할 수 있지만 대부분의 경우 1 페이지는 4 KB 정도 입니다.

어떻게 변환을 수행할 지 기록한 테이블을 페이지 테이블(page table) 이라고 하는데, 이 페이지 테이블은 각 프로그램 마다 하나씩 가지고 있습니다. 이 덕분에 구글 크롬에서의 0x1234 와 그림판의 0x1234 가 실제로는 다른 물리 메모리 주소를 참조할 수 있겠죠.

각 프로그램 별로 고유의 페이지 테이블을 가지고 있기 때문에, 같은 가상 메모리 주소를 사용해도 다른 물리 주소를 의미할 수 있습니다.

재미 있는 점은 64 비트 시스템의 경우 사용 가능한 가상 메모리 크기는 $2^{64}$ 바이트로, 대략 1844만 테라바이트 정도 됩니다. 아마 일반 사람이 이만한 크기의 메모리를 필요할 날은 한동안 오지 않을 것이라 생각합니다..

반면에 우리가 흔히 사용하는 메모리의 크기는 8 기가 혹은 16 기가 바이트를 주로 사용하죠. 하지만 페이징 덕분에 $2^{64}$ 바이트 가상 메모리 공간 그 어디에 기록을 해도 페이지 테이블을 통해 현재 내가 사용 가능한 크기 이내의 물리 주소로 변환을 하게 되면 문제 없이 사용 가능 합니다.

심지어 경우에 따라선, 메모리의 올라가 있는 전체 프로그램이 필요로 하는 메모리가 지금 내가 가지고 있는 물리 메모리의 용량 보다도 더 큰 경우도 해결 가능합니다. 아무래도 꽤 오래전부터 (메모리가 1기가 밑인 시절) 컴퓨터를 사용해왔던 분들은 기억 하실지 모르겠지만, 메모리가 제한된 환경에서는 이와 같은 상황이 빈번하게 일어났습니다. 만일 프로그램이 요구하는 메모리에 비해서 현재 사용 가능한 메모리가 적다면, 안쓰는 페이지 부터 하드 디스크에 복사해 놓고, 해당 페이지를 메모리를 필요로 하는 프로그램에 제공을 하는 것입니다.

만약에 하드 디스크에 복사해놓은 페이지를 다시 필요로 한다면, 그 때 운영체제가 해당 페이지를 다시 메모리에 복사해주면 됩니다. 옛날 컴퓨터에서 프로그램을 실행했을 때 종종 매우 느려졌던 이유가 바로, 물리 메모리가 부족해서 하드 디스크에 복사해 놓은 페이지를 읽어오느라 매우 느렸던 것입니다. (앞서 이야기 했듯이 메모리에 비해 하드 디스크는 매우 느리죠!)

이렇게 페이징 덕분에 각 프로그램들은 마치 자기 혼자서 메모리 전 공간을 사용하는 것 마냥 생각할 수 있습니다. 메모리에 같이 올라가 있는 다른 프로그램들을 전혀 고려할 필요 없이 말이지요! 덕분에 프로그램을 개발하는 사람들 입장에선 매우 편리하게 프로그램을 작성할 수 있게 되었습니다.

정리

자 그렇다면 이 짧은 글을 통해서 컴퓨터에 여러가지 중요한 요소들에 대해서 다루었습니다. 이 글을 통해서 다음과 같은 내용들을 꼭 기억해주셨으면 합니다.

  • 모든 연산은 CPU 에서 수행된다. 정확히 말하자면, CPU 의 자그마한 레지스터 상에서 수행된다. 64 비트 CPU 의 경우 레지스터의 크기는 8 바이트 이다.

  • CPU 는 무슨 연산을 할 지 알려주는 명령어와, 명령어를 실행하기 위해 필요로 하는 데이터를 메모리 (램) 에서 읽는다.

  • 우리가 프로그램을 실행한다는 것은 하드 디스크에 잠들어 있는 명령어들과 데이터를 메모리에 쓰는 것이라 생각하면 된다. 그리고 운영체제가 CPU 에 처음으로 실행해야 할 명령어의 주소값을 전달함으로써 프로그램이 시작된다.

  • CPU 에는 캐시가 있어서 메모리 접근 횟수를 줄일 수 있다.

  • 각 프로그램들은 마치 자신이 방대한 메모리 공간 전체를 사용하는 것 처럼 생각하며 작동한다.

  • CPU 에서 참조하는 주소값은 실제 물리 메모리 주소값이 아니라 가상 메모리 주소값이다.

  • 가상 메모리 주소값은 각 프로그램의 페이지 테이블을 통해서 실제 메모리 주소값으로 변환된다.

강좌를 보다가 조금이라도 궁금한 것이나 이상한 점이 있다면 꼭 댓글을 남겨주시기 바랍니다. 그 외에도 강좌에 관련된 것이라면 어떠한 것도 질문해 주셔도 상관 없습니다. 생각해 볼 문제도 정 모르겠다면 댓글을 달아주세요.

현재 여러분이 보신 강좌는 <내가 C 언어를 공부하기 전에 알았으면 좋았을 것들> 입니다. 이번 강좌의 모든 예제들의 코드를 보지 않고 짤 수준까지 강좌를 읽어 보시기 전까지 다음 강좌로 넘어가지 말아주세요
댓글이 73 개 있습니다!
프로필 사진 없음
강좌에 관련 없이 궁금한 내용은 여기를 사용해주세요

    댓글을 불러오는 중입니다..