🧠 메모리 교재 · 02_주소와포인터

주소, 비트/바이트, 포인터, 프로세스 레이아웃

목표 학습 시간: 105분 + 복습 15분 · 전공 수준 정독용

이 챕터를 마치면: (1) 주소·워드·정렬을 정확히 정의하고 구조체 패딩을 손으로 계산하며, (2) 엔디안을 코드로 판별하고, (3) 포인터 산술을 자유롭게 설명하고, (4) 프로세스 주소 공간의 각 구획과 그 보안 장치(ASLR·가드 페이지)를 설명할 수 있다.


0. 학습 지도 (105분)

구간 내용
1 15 메모리 = 바이트 배열, 주소 공간의 크기
2 30 정렬(alignment)과 구조체 패딩 — 깊게
3 15 엔디안
4 25 포인터의 모든 것
5 20 프로세스 레이아웃 + 실습
복습 15 인출 + 연습문제

1. 메모리 = 바이트들의 거대한 배열

1.1 주소는 인덱스다

메모리의 가장 정확한 멘탈 모델은 거대한 1차원 바이트 배열이다. 각 칸은 1바이트(8비트)이고, 0부터 시작하는 번호가 붙는다. 이 번호가 주소(address) 이고, "바이트 주소 지정(byte-addressable)"이란 주소 하나가 정확히 바이트 하나를 가리킨다는 뜻이다.

주소:  0x0000  0x0001  0x0002  0x0003  0x0004 ...
      ┌──────┬──────┬──────┬──────┬──────┐
값:   │ 0x4F │ 0x00 │ 0xA1 │ 0xFF │ 0x23 │ ...
      └──────┴──────┴──────┴──────┴──────┘

1.2 주소 공간의 크기와 64비트의 진실

"64비트 시스템"에서 포인터는 64비트지만, 실제로 64비트를 다 쓰지는 않는다. x86-64는 보통 하위 48비트만 의미 있는 주소로 쓰고(최신 확장은 57비트), 나머지 상위 비트는 48번째 비트를 그대로 복제한 형태여야 한다 — 이를 정규(canonical) 주소라 한다. 그래서 가상 주소 공간은 사실상 2⁴⁸ ≈ 256TB 규모다. 왜 전부 안 쓰냐면, 64비트 전체를 매핑할 페이지 테이블 하드웨어를 갖추는 건 과하기 때문이다(지금 필요 이상이라 잘라 쓴다).

1.3 워드(word)와 기본 데이터 크기

CPU가 한 번에 자연스럽게 다루는 단위가 워드다(64비트 머신이면 8바이트). C의 타입 크기(일반적 64비트 리눅스 기준)는: char 1, short 2, int 4, long/포인터 8, float 4, double 8. 이 크기들이 다음 절의 정렬 규칙을 결정한다.


2. 정렬(alignment) — 가장 자주 면접에 나오고 가장 자주 오해받는 주제

2.1 자연 정렬(natural alignment)이란

타입 T는 보통 자기 크기의 배수 주소에 놓이기를 원한다. 이것이 자연 정렬이다.

alignof(T)로 그 타입이 요구하는 정렬을 알 수 있다(C11 <stdalign.h>).

2.2 왜 하드웨어가 정렬을 원하나

CPU/메모리는 데이터를 정렬된 워드(또는 캐시 라인) 단위로 읽고 쓴다. 4바이트 int가 4의 배수 주소에 있으면 한 워드 안에 깔끔히 들어가 한 번에 읽힌다. 그런데 주소가 어긋나(misaligned) 두 워드(또는 두 캐시 라인)에 걸치면:

2.3 구조체 패딩 — 손으로 계산하기

컴파일러는 각 멤버를 자연 정렬에 맞추려고 사이사이에 패딩(빈 바이트) 을 넣는다. 또한 구조체 전체 크기는 가장 큰 멤버 정렬의 배수가 되도록 끝에도 패딩을 붙인다(배열로 늘어놓아도 각 원소가 정렬되도록).

struct Bad {
    char  a;   // offset 0   (1B)
               // offset 1~3 : int b를 4의 배수(offset 4)에 놓기 위한 패딩 3B
    int   b;   // offset 4   (4B)
    char  c;   // offset 8   (1B)
               // offset 9~11: 구조체 크기를 4의 배수로 맞추는 꼬리 패딩 3B
};              // sizeof == 12, alignof == 4
struct Good {
    int   b;   // offset 0   (4B)
    char  a;   // offset 4   (1B)
    char  c;   // offset 5   (1B)
               // offset 6~7: 꼬리 패딩 2B (크기를 4의 배수로)
};              // sizeof == 8, alignof == 4

같은 멤버인데 배치 순서만 바꿔 12 → 8바이트로 줄였다. 규칙: 큰 타입부터 내림차순으로 배치하면 패딩이 최소화된다. 대규모 배열·캐시에 민감한 코드에서 이 차이는 메모리 사용량과 캐시 효율(세션 4)에 직접 영향을 준다.

Bad 12 B a pad (3) b (int) c pad (3)

Good 8 B b (int) a c pad (2)

같은 멤버, 배치만 다름 — 큰 타입(int)을 앞에 두면 꼬리 패딩만 남아 12B → 8B.

2.4 정렬 제어 도구


3. 엔디안(endianness)

3.1 정의

여러 바이트짜리 값을 메모리에 어떤 순서로 늘어놓는가의 문제. 32비트 값 0x12345678을 주소 0x100에 저장하면:

리틀 엔디안 (x86, ARM 기본):  0x100: 78  56  34  12   ← 낮은 자릿값(LSB)이 낮은 주소
빅 엔디안 (네트워크 바이트 순서): 0x100: 12  34  56  78   ← 높은 자릿값(MSB)이 낮은 주소

3.2 코드로 판별하기

#include <stdio.h>
int main(void) {
    unsigned int x = 0x12345678;
    unsigned char *p = (unsigned char *)&x;  // x의 첫 바이트를 본다
    if (p[0] == 0x78) printf("little endian\n");
    else              printf("big endian\n");
}

x의 가장 낮은 주소 바이트가 0x78이면 리틀 엔디안이다. 이 "포인터로 다른 타입의 내부 바이트를 들여다보는" 기법은 메모리를 진짜로 이해했는지 보여주는 좋은 신호다.

3.3 왜 중요한가

대부분의 PC·모바일은 리틀 엔디안이지만, 네트워크 프로토콜은 빅 엔디안(네트워크 바이트 순서) 을 표준으로 쓴다. 그래서 정수를 네트워크로 보내거나 바이너리 파일로 저장할 때 htonl()/ntohl() 같은 변환이 필요하다. 엔디안을 무시하고 구조체를 그대로 write() 하면, 다른 엔디안 머신에서 읽을 때 값이 뒤집힌다. 직렬화(serialization)에서 늘 신경 써야 하는 함정이다.


4. 포인터의 모든 것

4.1 포인터 = 타입이 붙은 주소

포인터는 신비롭지 않다 — 주소를 값으로 담은 변수다. 단, 그냥 주소가 아니라 "무슨 타입을 가리키는 주소인지" 라는 타입 정보가 붙어 있다. 이 타입 정보가 (a) 역참조 시 몇 바이트를 어떻게 해석할지, (b) 포인터 산술의 스케일을 결정한다.

int x = 42;
int *p = &x;        // &x: x의 주소.  p는 그 주소를 담는다.
printf("%p\n", (void*)p);  // 주소 출력
printf("%d\n", *p);        // *p: 그 주소로 가서 int로 4바이트 해석 → 42
*p = 7;                    // 그 주소에 7을 쓴다 → 이제 x == 7

4.2 포인터 산술은 타입 크기로 스케일된다

p + 1은 주소를 **1 증가시키는 게 아니라 sizeof(*p) 증가**시킨다. int*면 +4, double*면 +8.

int a[5] = {10,20,30,40,50};
int *p = a;        // 배열 이름 a는 첫 원소의 주소로 "붕괴(decay)"한다
printf("%d\n", *(p + 2));   // a[2] == 30 (주소는 +8)

이 때문에 a[i]는 정의상 *(a + i) 와 같다. 놀랍게도 i[a]*(i + a)라 동작한다(덧셈 교환법칙). 이것이 배열 인덱싱의 진짜 정체다.

4.3 배열과 포인터는 같지 않다 (중요 구분)

자주 헷갈리지만 배열과 포인터는 다르다.

int  arr[10];
int *ptr = arr;

sizeof(arr);   // 40 (= 10 * 4). 배열 전체 크기
sizeof(ptr);   // 8        포인터 자체의 크기(주소)

배열 이름은 "주소로 붕괴"하지만 그 자체가 포인터 변수는 아니다(재할당 불가, sizeof가 다름). 함수 인자로 배열을 넘기면 포인터로 붕괴해 크기 정보를 잃는다 — 그래서 길이를 따로 넘긴다.

4.4 특수한 포인터들

4.5 댕글링/와일드 포인터 (세션 3·9의 예고)


5. 프로세스 메모리 레이아웃

5.1 구획들

실행 중인 프로그램의 가상 주소 공간은 정해진 영역으로 나뉜다.

높은 주소
   ┌───────────────────────────┐
   │  커널 영역 (사용자 접근 불가) │
   ├───────────────────────────┤
   │  스택 (stack)              │  지역변수·호출 프레임. 아래로 자란다 ↓
   │        ↓                  │
   │  (가드 페이지 / 빈 공간)     │
   │                           │
   │  ── mmap 영역 ──           │  공유 라이브러리, 큰 malloc, 파일 매핑
   │        ↑                  │
   │  힙 (heap)                 │  malloc 동적 메모리. 위로 자란다 ↑
   ├───────────────────────────┤
   │  BSS                      │  초기화 안 된 전역/정적 (0으로 채움)
   ├───────────────────────────┤
   │  Data                     │  초기화된 전역/정적
   ├───────────────────────────┤
   │  Text (code)              │  기계어. 읽기 전용 + 실행 가능
   └───────────────────────────┘
낮은 주소

5.2 보안·안정성 장치

5.3 실습 — 레이아웃을 눈으로 보기

#include <stdio.h>
#include <stdlib.h>
int g_init = 5;        // Data
int g_uninit;          // BSS
void func(void) {}

int main(void) {
    int local = 1;                 // Stack
    int *heap = malloc(sizeof(int)); // Heap (가리키는 곳)
    printf("text(func): %p\n", (void*)func);
    printf("data       : %p\n", (void*)&g_init);
    printf("bss        : %p\n", (void*)&g_uninit);
    printf("heap       : %p\n", (void*)heap);
    printf("stack      : %p\n", (void*)&local);
    free(heap);
}

출력해 보면 보통 text < data < bss < heap < stack 순으로 주소가 커지는 것을 확인할 수 있다(ASLR 때문에 실행마다 값은 달라져도 상대적 배치는 유지). 직접 돌려서 자기 머신의 레이아웃을 눈에 새겨라.


6. 흔한 오해 바로잡기


7. 한 장 정리


8. 복습 (15분) — 답을 가리고

Q1. 자연 정렬이 무엇이고, 어긋나면 무슨 일이 생기나(3가지)?

타입을 자기 크기의 배수 주소에 놓는 것. 어긋나면 ① 두 워드/라인에 걸쳐 두 번 읽어 느려지고, ② 엄격 정렬 아키텍처에서 SIGBUS로 죽고, ③ 원자적 연산의 원자성이 깨질 수 있다.

Q2. 다음 구조체의 sizeof와 각 멤버 오프셋을 계산하라.

struct S { char a; double b; int c; };

a: offset 0 (1B). b(8B)는 8의 배수 → offset 8 (17 패딩 7B). c: offset 16 (4B). 꼬리 패딩: alignof=8이므로 크기를 8의 배수로 → offset 2023 패딩 4B. sizeof == 24, alignof == 8. (멤버 순서를 double, int, char로 바꾸면 16으로 줄어든다.)

Q3. int a[5]; int *p = a;에서 *(p+3)은 무엇이며 주소는 얼마나 이동했나?

a[3]. 포인터 산술이 sizeof(int)=4로 스케일되므로 주소는 +12 이동.

Q4. 리틀 엔디안 머신에서 0xAABBCCDD를 주소 0x200에 저장하면 0x200~0x203의 바이트는?

0x200: DD, 0x201: CC, 0x202: BB, 0x203: AA (낮은 자릿값이 낮은 주소).

Q5. text/data/bss/heap/stack 중 (a)초기값 있는 전역, (b)초기값 없는 전역, (c)실행마다 0으로 채워지는 곳, (d)아래로 자라는 곳은?

(a) data, (b) bss, (c) bss, (d) stack.

Q6. sizeof(arr)이 40인데 같은 걸 함수에 넘기면 sizeof가 8이 되는 이유는?

배열이 함수 인자로 전달될 때 첫 원소의 포인터로 붕괴(decay)해, 함수 안에서는 배열이 아니라 포인터다. 그래서 크기 정보를 잃고 sizeof가 포인터 크기(8)가 된다.

Q7. 세션 1과의 연결: 정렬/패딩이 캐시 성능과 어떻게 엮이나?

패딩으로 구조체가 커지면 한 캐시 라인(64B)에 담기는 객체 수가 줄어 공간적 지역성이 나빠진다. 멤버를 정돈해 크기를 줄이면 라인당 더 많은 데이터가 올라와 미스가 준다(세션 4의 AoS/SoA와도 연결).


다음: 세션 3 — 스택 vs 힙 (별도 파일)