프로그래밍 · 보충

주소 모드로 본 포인터

포인터는 C가 새로 지어낸 것이 아닙니다.
기계가 원래 하던 일과 같은 생각을 문법으로 나타낸 것입니다.

학습 목표

  1. CPU가 실행하는 명령어 하나가 어떤 부분으로 이루어지는지 설명할 수 있다.
  2. 직접 주소 지정 방식과 간접 주소 지정 방식의 차이를 설명할 수 있다.
  3. 두 방식을 배열로 재현하여 결과를 확인할 수 있다.
  4. 변수와 포인터가 두 방식에 어떻게 대응하는지 설명할 수 있다.

명령어의 구성

기계는 우리가 쓴 C를 그대로 읽지 않습니다.

이 차시는 보충입니다

포인터를 컴퓨터 구조 쪽에서 다시 보는 내용입니다. 건너뛰어도 포인터를 쓰는 데 지장이 없습니다. 다만 「포인터가 왜 이렇게 생겼는지」가 마음에 걸린다면 여기서 답이 나옵니다.

우리가 쓴 C 코드는 컴파일러를 거쳐 CPU가 실행할 수 있는 기계어 명령어(instruction)로 번역된 뒤 실행됩니다. 명령어 하나는 사람의 문장보다 훨씬 단순해서, 단순하게 보면 세 부분으로 이루어집니다.

부분 나타내는 것
무엇을 더할지, 옮길지, 분기할지(다른 명령어로 건너뛸지) 같은 연산의 종류다. 연산 코드(opcode)라고 한다.
어떻게 뒤에 적힌 주소를 어떻게 해석할지 정한다.
주소 연산에 쓸 대상, 곧 피연산자(operand)를 나타내는 부분으로, 이 차시에서는 메모리 주소 하나다.
CPU가 실행하는 명령어 하나 무엇을 어떻게 주소 더할지 옮길지 주소를 어떻게 해석할지 메모리 주소 하나 이 부분이 주소의 뜻을 정한다 같은 주소 4가 「4번 칸을 열어라」일 수도, 「4번 칸을 열면 또 주소가 있으니 거기로 가라」일 수도 있다.

「어떻게」 부분이 주소의 뜻을 정합니다

적힌 주소가 같아도 뜻은 여러 가지일 수 있습니다. 「4번 칸을 열어라」일 수도 있고, 「4번 칸을 열면 또 주소가 있을 테니 그 주소로 다시 가라」일 수도 있습니다. 같은 4를 어떻게 해석할지를 「어떻게」 부분이 정합니다.

이 「어떻게」를 주소 지정 방식(addressing mode)이라고 부르고, 줄여서 주소 모드라고도 합니다. 기계가 주소를 다루는 방식이 한 가지가 아니라는 것이 이 차시의 출발점입니다.

주소 지정 방식에는 이 차시에서 볼 직접 · 간접 방식 말고도 여러 가지가 있습니다. 적힌 수를 주소가 아니라 값 그대로 쓰는 즉시 방식, CPU 안에서 값을 잠시 저장하는 작은 저장 공간인 레지스터(register)를 가리키는 레지스터 방식, 적힌 주소에 인덱스 값을 더해 찾아가는 인덱스 방식이 그 예입니다. 여기서는 포인터와 바로 이어지는 직접 방식과 간접 방식만 봅니다.

스스로 확인

명령어에 「어떻게」 부분이 없다면 어떤 불편이 생길까요? 주소의 뜻이 하나로 고정된다는 것이 무슨 뜻인지 생각해 보세요.

직접과 간접

한 번 가느냐, 두 번 가느냐의 차이입니다.

메모리가 아래처럼 채워져 있다고 해 봅시다. 수업에서 원리를 확인하려고 지어낸 값입니다. 실제 메모리는 바이트마다 주소가 있지만, 여기서는 값 하나가 저장되는 칸마다 주소를 매긴 단순한 메모리로 생각합니다. 두 방식의 차이를 보는 데는 이것으로 충분합니다.

주소 0 1 2 3 4 5 6 7
저장된 값 0 0 0 0 3190 7 0 2022

직접 — 적힌 주소가 4일 때

  1. 명령어에 적힌 주소는 4다.
  2. 4번 칸을 연다.
  3. 저장된 3190을 그대로 쓴다.

칸을 한 번 여는 것으로 끝납니다. 적힌 주소가 다룰 값이 있는 곳을 곧바로 가리킵니다.

간접 — 적힌 주소가 5일 때

  1. 명령어에 적힌 주소는 5다.
  2. 5번 칸을 여니 7이 있다.
  3. 이 7은 다룰 값이 아니라 또 다른 주소다.
  4. 7번 칸을 다시 열어 2022를 쓴다.

칸을 두 번 엽니다. 적힌 주소가 「값의 주소를 저장해 둔 곳」을 가리킵니다.

메모리에 저장된 값은 그대로입니다

두 경우 모두 메모리에 저장된 값은 하나도 달라지지 않았습니다. 「어떻게 해석할지」만 달랐고 그것이 결과를 정했습니다. 저장된 값은 그대로인데 읽는 방법이 결과를 정한다는 점에서, 포인터의 자료형이 읽는 방법을 정하는 것과 같은 이야기입니다.

간접 방식이 왜 있을까요? 프로그램이 실행되는 동안 가리킬 곳을 바꿀 수 있기 때문입니다. 명령어를 고치지 않고 5번 칸에 저장된 주소만 바꾸면 다른 곳의 값을 다루게 됩니다. 명령어는 그대로여도 다루는 대상은 달라질 수 있는 것입니다.

요즘 흔히 쓰는 CPU는 이 두 단계를 명령어 하나로 처리하지 않는 경우가 많습니다. 먼저 한 명령어가 메모리에 저장된 주소를 레지스터로 가져오고, 다음 명령어가 레지스터에 저장된 그 주소로 메모리에 접근합니다. 두 번째 명령어처럼 레지스터에 저장된 주소로 메모리에 접근하는 방식을 레지스터 간접 방식이라고 합니다. 명령어 하나로 하든 둘로 나누든 「저장된 주소로 한 번 더 간다」는 일은 같습니다.

스스로 확인

5번 칸의 값을 7에서 4로 바꾸면, 같은 간접 방식의 명령어가 이제 무엇을 가져올까요?

배열로 재현하기

인덱스가 주소 구실을 하는 배열로 두 방식을 재현해 봅니다.

「직접과 간접」 절에서 생각한 메모리는 주소가 매겨진 칸이 이어진 것이었습니다. 배열도 요소가 연속으로 이어져 있고, 인덱스가 곧 주소 구실을 합니다. 그러니 배열을 메모리로 삼아 두 방식을 그대로 재현할 수 있습니다.

직접 — 4번 칸을 한 번 열고 끝 0 0 1 0 2 0 3 0 4 3190 5 7 6 0 7 2022 ① 열어 보니 7 ② 다시 7번 간접 — 5번 칸을 열어 나온 주소로 다시 간다 저장된 값은 하나도 달라지지 않았다. 해석하는 방법만 달랐다.
int field = 4;                       // 명령어에 적힌 주소

printf("직접: %d\n", memory[field]); // 4번 칸을 열어 3190을 꺼낸다

memory[field] — 대괄호를 한 번 씁니다. field에 저장된 주소가 가리키는 칸을 열어 그 값을 그대로 씁니다.

field = 5;                           // 이번에도 명령어에 적힌 주소

int real = memory[field];            // 5번 칸을 열었더니 「또 다른 주소」(7)가 있다
printf("간접: %d\n", memory[real]);  // 그 주소로 다시 가서 2022를 꺼낸다

대괄호가 한 번 더 붙습니다

memory[memory[field]]로 한 줄에 쓸 수도 있습니다. 안쪽 대괄호가 「또 다른 주소」를 꺼내고, 바깥 대괄호가 그 주소로 다시 갑니다. 위 코드는 단계를 눈에 보이게 하려고 real에 한 번 저장해 두었습니다.

「저장된 주소로 한 번 더 간다」는 것이 전부입니다. C에서 p가 num을 가리킬 때 *p로 num의 값을 읽는 역참조가 바로 이 한 단계에 해당합니다. 거쳐 가는 단계가 하나 늘 뿐 새로운 종류의 일이 아닙니다.

스스로 확인

memory[memory[memory[field]]]처럼 저장된 주소를 두 번 거쳐 가는 명령어가 있다면 C의 무엇에 대응할까요?

나란히 놓기

기계의 방식과 C의 문법을 나란히 놓아 봅니다.

#include <stdio.h>

int main(void) {
    int num = 2022;
    int *p = &num;

    printf("변수로 읽기   %d\n", num);   // 변수 이름으로 값이 있는 곳에 바로 접근한다
    printf("포인터로 읽기 %d\n", *p);    // p에 저장된 주소로 한 번 더 간다(역참조)

    return 0;
}
방식 기계 쪽 C 쪽
직접 적힌 주소에 값이 있다 num — 변수 이름으로 값이 있는 곳에 바로 접근한다
간접 적힌 주소에 「값의 주소」가 있다 *p — p에 저장된 주소로 다시 간다
메모리에 접근하는 횟수 직접 1번, 간접 2번 개념상 이름으로 1번, 별표(역참조) 하나마다 1번 더

포인터의 역참조는 간접 방식과 같은 생각입니다

C가 없던 일을 새로 만들어 낸 것이 아닙니다. 기계에는 오래전부터 「값의 주소를 저장해 둔 곳」을 거쳐 가는 방법이 있었고, C의 포인터는 같은 생각을 사람이 읽고 쓸 수 있는 문법으로 나타낸 것입니다.

다만 이 대응은 이해를 돕기 위한 비유입니다. C 표준은 *p를 어떤 기계어 명령어로 번역할지 정하지 않습니다. 실제 컴파일러는 앞에서 본 레지스터 간접 방식을 흔히 쓰고, 최적화할 때는 메모리에 가지 않고 레지스터의 값만으로 처리하기도 합니다.

포인터가 어렵게 느껴지는 이유도 여기서 설명됩니다. 포인터가 유별난 것이 아니라, C가 기계 가까이에 있는 언어여서 기계가 하던 방식이 가려지지 않고 그대로 드러나 있는 것입니다. 다른 언어들은 이것을 감춰 두거나 아예 쓰지 못하게 하기도 합니다.

돌아보면 전부 같은 규칙이었습니다

  • 메모리의 모든 바이트에는 주소가 있다 — 그래서 &로 변수의 주소를 알아낼 수 있었다.
  • 주소도 값이라 저장해 둘 수 있다 — 주소를 저장하는 변수가 포인터였다.
  • 저장된 주소로 가면 그곳의 값을 읽고 바꿀 수 있다 — 그것이 역참조(*)였다.
  • 함수 안에서 바꾸고 싶은 것이 있으면 그것의 주소를 전달한다 — 일반 변수든 포인터든 같았다.

배열도 문자열도 함수 포인터도 동적 할당도 이 규칙들 위에 서 있습니다. 새 문법이 나올 때마다 「지금 주소를 몇 번 거쳐 가고 있나」를 물으면 대개 풀립니다.

스스로 확인

C 코드 가운데 메모리에 두 번 접근해 값을 얻는 것, 곧 저장된 주소를 한 번 거쳐 값에 접근하는 것들을 떠올려 보세요. 몇 가지나 됩니까?

핵심 정리

주소의 뜻은 하나가 아니다

명령어에는 「주소를 어떻게 해석할지」를 정하는 주소 지정 방식이 있다. 같은 주소도 방식에 따라 다른 뜻으로 읽힌다.

한 번이냐 두 번이냐

직접은 칸을 한 번 열어 값을 쓰고, 간접은 열어 나온 주소로 다시 가서 값을 쓴다.

인덱스가 주소 구실을 한다

배열을 메모리로 삼으면 두 방식을 그대로 재현할 수 있다. 대괄호를 한 번 더 씌우는 것이 간접이다.

포인터는 새 개념이 아니다

역참조는 기계의 간접 방식과 같은 생각을 문법으로 나타낸 것이다. 이 대응은 비유이고, 실제로 어떤 명령어로 번역할지는 컴파일러가 정한다. 어려운 것은 개념이 아니라 기계의 방식이 가려지지 않고 드러나 있다는 점이다.

확인 퀴즈

배운 내용을 확인해 봅시다.

1. 명령어에 적힌 주소가 「값의 주소를 저장해 둔 곳」을 가리키는 방식은?

2. 배열을 메모리로 삼았을 때 간접 방식에 해당하는 것은?

3. 간접 방식이 쓸모 있는 이유로 알맞은 것은?