주소, 비트/바이트, 포인터, 프로세스 레이아웃
목표 학습 시간: 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는 보통 자기 크기의 배수 주소에 놓이기를 원한다. 이것이 자연 정렬이다.
char(1B): 아무 주소나 OK (1의 배수)int(4B): 4의 배수 주소 선호double(8B): 8의 배수 주소 선호
alignof(T)로 그 타입이 요구하는 정렬을 알 수 있다(C11 <stdalign.h>).
2.2 왜 하드웨어가 정렬을 원하나
CPU/메모리는 데이터를 정렬된 워드(또는 캐시 라인) 단위로 읽고 쓴다. 4바이트 int가 4의 배수 주소에 있으면 한 워드 안에 깔끔히 들어가 한 번에 읽힌다. 그런데 주소가 어긋나(misaligned) 두 워드(또는 두 캐시 라인)에 걸치면:
- 두 번 읽어서 합쳐야 한다 → 느림.
- 아키텍처에 따라 하드웨어 예외(SIGBUS) 로 죽는다(엄격 정렬 아키텍처: 일부 ARM 설정·SPARC 등). x86은 misaligned를 허용하지만 성능 손해를 본다.
- 원자적 연산(atomic) 은 정렬을 요구한다. misaligned면 원자성이 깨지거나 폴트가 난다(세션 10과 연결).
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)에 직접 영향을 준다.
2.4 정렬 제어 도구
_Alignas(N)/ C++alignas(N): 특정 변수·멤버를 N바이트 정렬 강제(예: 캐시 라인 64B 정렬로 false sharing 회피 — 세션 10).#pragma pack(1): 패딩을 없애 빽빽하게(파일/네트워크 포맷 매핑용). 대신 misaligned 접근 위험 → 신중히.alignof(T),offsetof(struct, member): 정렬·오프셋을 컴파일 타임에 확인.
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 특수한 포인터들
void *: 타입 없는 "그냥 주소". 어떤 객체 포인터와도 호환되지만 역참조 불가(크기를 모르니까).malloc이void*를 반환하는 이유.- NULL: "아무 데도 안 가리킴"을 뜻하는 약속된 값(보통 주소 0). 역참조하면 세그폴트(0번지는 매핑 안 됨).
- 이중 포인터
int **pp: "int를 가리키는 포인터를 가리키는 포인터". 포인터 자체를 함수에서 바꿔야 할 때(예: 리스트 head 갱신) 쓴다.
4.5 댕글링/와일드 포인터 (세션 3·9의 예고)
- 댕글링: 이미 수명이 끝난 메모리(반환된 스택 프레임, free된 힙)를 가리키는 포인터.
- 와일드: 초기화 안 한 포인터(쓰레기 주소). 둘 다 역참조 시 미정의 동작.
5. 프로세스 메모리 레이아웃
5.1 구획들
실행 중인 프로그램의 가상 주소 공간은 정해진 영역으로 나뉜다.
높은 주소
┌───────────────────────────┐
│ 커널 영역 (사용자 접근 불가) │
├───────────────────────────┤
│ 스택 (stack) │ 지역변수·호출 프레임. 아래로 자란다 ↓
│ ↓ │
│ (가드 페이지 / 빈 공간) │
│ │
│ ── mmap 영역 ── │ 공유 라이브러리, 큰 malloc, 파일 매핑
│ ↑ │
│ 힙 (heap) │ malloc 동적 메모리. 위로 자란다 ↑
├───────────────────────────┤
│ BSS │ 초기화 안 된 전역/정적 (0으로 채움)
├───────────────────────────┤
│ Data │ 초기화된 전역/정적
├───────────────────────────┤
│ Text (code) │ 기계어. 읽기 전용 + 실행 가능
└───────────────────────────┘
낮은 주소
- Text: 코드. 보통 읽기 전용·실행 가능으로 매핑(실수/공격으로 코드를 못 덮게).
- Data:
int g = 5;처럼 초기값 있는 전역. 초기값이 실행 파일에 저장된다. - BSS:
int g;처럼 초기값 없는 전역. 실행 시 0으로 채워진다. 파일에 0들을 저장할 필요가 없어 실행 파일 크기를 아낀다(BSS는 "크기"만 기록). - Heap:
malloc이 주는 영역. 위로(높은 주소로) 성장. - Stack: 함수 호출 프레임. 아래로(낮은 주소로) 성장. 힙과 마주 보며 가운데 빈 공간을 나눠 쓴다.
- mmap 영역: 공유 라이브러리(libc 등), 큰 메모리 할당, 파일 매핑이 들어가는 중간 지대(세션 6).
5.2 보안·안정성 장치
- ASLR(주소 공간 배치 무작위화): 실행할 때마다 스택·힙·라이브러리의 시작 주소를 무작위로 바꿔, 공격자가 주소를 예측하기 어렵게 한다. 그래서 같은 프로그램도 실행마다 주소가 달라진다.
- 가드 페이지(guard page): 스택 끝에 접근 금지 페이지를 둬서, 스택이 넘쳐 그 페이지를 건드리면 즉시 폴트가 나게 한다 → 조용한 메모리 오염 대신 명확한 크래시로 스택 오버플로를 잡는다.
- W^X (Write XOR Execute): "쓸 수 있으면 실행 불가, 실행 가능하면 쓰기 불가" 원칙으로 코드 주입 공격을 막는다.
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. 흔한 오해 바로잡기
- ❌ "스택이 아래로 자란다 = 스택 메모리 주소가 점점 커진다." → 반대다. 새 프레임이 쌓일수록 주소가 작아진다(낮은 주소 방향으로 성장).
- ❌ "
sizeof(배열)은 포인터 크기." → 배열은 전체 크기, 포인터는 8. 배열이 함수 인자로 넘어가며 포인터로 붕괴할 때만 크기 정보를 잃는다. - ❌ "패딩은 컴파일러 낭비." → 패딩은 하드웨어 정렬 요구를 만족시키는 필수 장치다. 멤버 순서를 바꾸면 줄일 수 있을 뿐, 없앨(
pack) 땐 misaligned 위험을 떠안는다. - ❌ "BSS는 메모리를 더 먹는다." → BSS는 실행 파일엔 크기만 기록(0 데이터를 저장 안 함). 실행 시 0으로 채워진다.
- ❌ "엔디안은 옛날 얘기." → 직렬화·네트워크·바이너리 포맷에서 지금도 매번 부딪힌다.
7. 한 장 정리
- 메모리는 바이트 배열, 주소는 인덱스. 64비트라도 실제론 48비트 정규 주소.
- 정렬은 하드웨어가 워드/라인 단위로 접근하기 때문이며, 어긋나면 두 번 읽기·SIGBUS·원자성 손실. 구조체 패딩은 그 결과이고, 멤버를 큰 타입부터 배치하면 줄어든다.
- 엔디안은 다중 바이트 값의 저장 순서. 직렬화·네트워크에서 변환 필요.
- 포인터는 타입 붙은 주소. 산술은 타입 크기로 스케일되고
a[i] == *(a+i). 배열 ≠ 포인터. - 프로세스 공간 = text/data/bss/heap/stack + mmap, 그리고 ASLR·가드 페이지·W^X 가 지킨다.
8. 복습 (15분) — 답을 가리고
Q1. 자연 정렬이 무엇이고, 어긋나면 무슨 일이 생기나(3가지)?
타입을 자기 크기의 배수 주소에 놓는 것. 어긋나면 ① 두 워드/라인에 걸쳐 두 번 읽어 느려지고, ② 엄격 정렬 아키텍처에서 SIGBUS로 죽고, ③ 원자적 연산의 원자성이 깨질 수 있다.
Q2. 다음 구조체의 sizeof와 각 멤버 오프셋을 계산하라.
struct S { char a; double b; int c; };
a: offset 0 (1B). b(8B)는 8의 배수 → offset 8 (1
7 패딩 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 힙 (별도 파일)