1-5. Array vs Pointer

hyunahn·2025년 12월 4일

Array vs Pointer: 태생부터 다른 그들

1. The Core Concept (본질 정의)

"배열은 '집(House)' 그 자체이고, 포인터는 '주소가 적힌 쪽지(Note)'입니다."

  • 배열(Array): 메모리에 연속된 공간을 확보하고 그 공간 자체에 이름을 붙인 것입니다. 그 이름은 메모리 주소 그 자체(상수)를 의미하며, 바꿀 수 없습니다.
  • 포인터(Pointer): 메모리 어딘가에 별도로 저장된 변수입니다. 이 변수 안에는 다른 곳의 주소가 들어있습니다. 언제든 지우고 다른 주소를 쓸 수 있습니다.

2. Under the Hood (하드웨어 투시)

가장 결정적인 차이는 CPU가 데이터를 가져오는 과정(Instruction Cycle)에 있습니다. int a[100];int *p;가 있다고 가정합시다.

A. 배열 접근 (x = a[0]) : Direct Addressing

배열의 이름 a는 컴파일러에게 "아, 그 주소 0x1000번지?"라고 이미 알려져 있습니다. 심볼 테이블(Symbol Table)에 고정되어 있죠.

[ CPU ] --(1. 야, 0x1000번지 내용 내놔)--> [ Memory (0x1000) ]
        <--(2. 여기 값 50 있다)----------
  • 하드웨어 동작: 메모리를 한 번만 참조합니다. 빠릅니다.
  • Assembly: MOV EAX, [0x1000]

B. 포인터 접근 (x = p[0]) : Indirect Addressing

포인터 p는 변수이므로, 메모리 어딘가(0x2000)에 따로 살고 있습니다.

[ CPU ] --(1. 야, p 변수(0x2000)에 뭐 적혀있냐?)--> [ Memory (0x2000) ]
        <--(2. 0x1000번지로 가라고 적혀있는데?)---- [ p에 저장된 값: 0x1000 ]
        
        ----(잠깐, 한번  가야해?)----
        
[ CPU ] --(3. 그럼 0x1000번지 내용 내놔)--------> [ Memory (0x1000) ]
        <--(4. 여기 값 50 있다)------------------
  • 하드웨어 동작: 메모리를 두 번 참조합니다. (포인터 값을 읽고 -> 그 주소로 이동). 배열보다 연산이 하나 더 듭니다.
  • Assembly:
    MOV EDX, [0x2000] ; 포인터 변수 값 로드
    MOV EAX, [EDX]    ; 그 주소의 값 로드

3. The "Why" & Real-world Analogy (이유와 비유)

Q: 왜 둘이 똑같다고 착각하게 만들었을까요?
A: "함수 파라미터로 넘길 때의 'Decay(퇴화)' 현상 때문입니다."

C언어 설계자는 효율성을 중시했습니다. 만약 int arr[1000000]이라는 거대한 배열을 함수에 전달할 때, 배열 전체를 복사(Call by Value)한다면 메모리가 터져버릴 겁니다.

그래서 C언어는 규칙을 정했습니다: "배열의 이름을 함수에게 줄 때는, 배열 전체가 아니라 '첫 번째 요소의 주소(포인터)'만 던져준다."

이것을 Array Decay(배열의 포인터 퇴화)라고 합니다. 이 순간부터 함수 내부에서는 배열이 아니라 포인터로 취급됩니다.

🏠 집주소 비유

  • main 함수 (배열 주인): "우리 집(배열)으로 와서 파티하자." (집 전체를 들고 갈 수 없음)
  • 파티 함수 (손님): "주소만 줘." (포인터로 받음) -> 주소를 보고 찾아옴.

4. Code & Best Practice

이 차이를 모르면 발생하는 치명적인 버그들을 보여드리겠습니다.

1) sizeof의 배신

void func(int param_arr[]) { // 말이 배열이지, 사실 int *param_arr와 똑같음 (Decay 발생)
    printf("%lu\n", sizeof(param_arr)); 
    // 결과: 8 (64bit 시스템에서 포인터 크기) -> 배열 크기(40)가 아님!
}

int main() {
    int my_arr[10] = {0};
    printf("%lu\n", sizeof(my_arr)); 
    // 결과: 40 (int 4바이트 * 10개) -> 여기선 진짜 배열 크기
    
    func(my_arr);
    return 0;
}

Best Practice: 배열을 함수로 넘길 때는 반드시 배열의 크기(length)도 인자로 같이 넘겨야 합니다. 함수 안에서는 sizeof로 원본 크기를 알 방법이 없습니다.

2) 대입 연산 가능 여부

int arr[10];
int *ptr;

ptr = arr; // 가능 (포인터라는 쪽지에 배열 주소를 적음)
arr = ptr; // 에러! (집을 들어서 다른 땅으로 옮길 수 없음)

배열 이름은 L-value(수정 가능한 왼쪽 값)가 아닙니다. 하드웨어적으로 고정된 주소(Label)이기 때문입니다.

3) 포인터 변수의 별도 공간

int a[5];
int *p = a;

// &a 와 &p는 다릅니다.
// &a : 배열의 시작 주소 그 자체 (값: 0x1000)
// &p : 포인터 변수가 저장된 주소 (값: 0x2000)

5. Advanced Topics (심화: 링커와 로더의 관점)

하드웨어 레벨을 넘어, 프로그램이 실행될 때 OS와 링커(Linker)가 이 둘을 어떻게 다루는지 조금 더 깊이 들어가 봅시다.

🏗️ Relocation (재배치) 과정에서의 차이

실행 파일(ELF/PE)이 메모리에 로드될 때, 주소 결정 방식이 다릅니다.

  1. 전역 배열 (int global_arr[100])

    • 컴파일 타임에 .bss 또는 .data 섹션에 자리를 잡습니다.
    • 링커는 이 배열의 주소를 상대 주소(Relative Address)로 미리 계산해둡니다.
    • 프로그램이 실행될 때(ASLR 적용 시), 로더는 단순히 Base Address + Offset으로 주소를 확정합니다. 런타임 오버헤드가 거의 없습니다.
  2. 전역 포인터 (int *global_ptr)

    • 포인터 변수 자체는 .data 섹션에 위치하지만, 그 안에 들어갈 주소 값은 런타임에 결정됩니다(예: malloc 호출 시).
    • 만약 포인터가 다른 전역 변수를 가리킨다면, 로더는 동적 재배치(Dynamic Relocation)를 수행해야 합니다. "변수 A의 주소가 확정되면, 그 주소를 포인터 P에 써넣어라"는 작업이 추가됩니다.

📜 GOT (Global Offset Table)와 PIC (Position Independent Code)

공유 라이브러리(.so/.dll)에서 전역 배열과 포인터를 다룰 때 차이가 극명해집니다.

  • 배열 접근: 현재 명령어 주소(PC)로부터의 상대적인 거리(PC-relative)로 계산 가능하므로, GOT를 거치지 않고 바로 접근할 수 있는 최적화가 가능합니다.
  • 포인터 접근: 포인터가 가리키는 대상이 외부 모듈에 있을 수 있으므로, 반드시 GOT를 경유(Indirect Jump)해야 합니다. 여기서도 메모리 참조가 한 번 더 발생합니다.

0개의 댓글