*p vs &p)과 배열 감쇄(Array Decay)4주차는 고수준 인터프리터 언어(Python)의 안락함을 벗어나, 하드웨어 메모리를 직접 제어하는 C 언어의 포인터 체계와 컴퓨터 시스템의 바이블인 CS:APP 3장(x86-64 머신 레벨 프로그램 표현)을 본격적으로 학습한 주차였습니다. 컴파일러가 고급 언어를 어떤 어셈블리 명령어와 레지스터 연산으로 변환하는지 그 물리적 밑바닥을 마주했습니다.
[고급 언어에서 x86-64 머신 코드로의 하향 변환 계층]
고급 C 코드: int sum(int a, int b) { return a + b; }
│
▼ 컴파일러 (GCC) 번역
x86-64 어셈블리:
lea (%rdi, %rsi), %eax ; 첫 번째 인자(%rdi)와 두 번째 인자(%rsi)를 더해 %eax에 저장
ret ; 호출자(Caller)로 복귀
│
▼ 어셈블러 & 링커
기계어 바이너리: 8d 04 37 c3 (CPU 제어 유닛이 직접 실행하는 16진수 바이트)
"내가 싫어하던 발표와 어려운 CS의 대한 모든 것을 합친 한주, 두려움이 가장 컸고, 내 스스로도 준비를 안해서 많이 반성했다"
살면서 가장 기피해 왔던 두 가지가 한꺼번에 정면으로 닥쳤습니다. 바로 '여러 사람 앞에서의 공식 기술 발표'와 '악명 높은 저수준 컴퓨터 시스템(CS:APP)과 어셈블리'였습니다.
처음 접하는 어셈블리 명령어와 C 포인터의 복잡성에 압도당하자, 마음속 깊은 곳에서 막연한 공포가 피어올랐습니다. "이걸 내가 어떻게 남들 앞에서 설명하지?"라는 두려움에 사로잡혀 오히려 문제에 깊이 몰입하지 못하고 시간을 흘려보냈으며, 발표 준비를 철저히 끝내지 못했습니다. 준비 부족은 발표 당일의 자신감 상실과 불완전한 전달로 고스란히 드러났고, 팀원들과 동료들에게 큰 미안함을 남겼습니다. 제 자신의 나태함과 두려움에 도망치려 했던 태도를 뼈저리게 반성한 한 주였습니다.
*p vs &p)과 배열 감쇄(Array Decay)포인터(Pointer)는 복잡한 마법이 아닙니다. 컴퓨터 아키텍처 관점에서 포인터 변수는 '메모리 주소 값을 담고 있는 8바이트(x86-64 기준) 크기의 단순 정수형 변수'에 불과합니다.
int x = 42;
int *p = &x; // p는 x의 시작 메모리 주소를 저장
*p = 100; // p가 가리키는 주소로 직접 찾아가(역참조) 4바이트 정수 100을 덮어씀
&x)를 포인터로 전달해야 합니다.arr은 함수의 인자로 전달되는 순간 배열 전체가 복사되지 않고, 첫 번째 원소의 시작 주소를 가리키는 포인터(&arr[0])로 강제 퇴화(Decay)합니다. 따라서 함수 내부에서 sizeof(arr)를 호출하면 배열의 전체 크기가 아닌 포인터 크기(8바이트)만 반환됩니다.함수 호출 시 메모리 스택 대신 초고속 CPU 레지스터를 통해 인자를 전달하여 속도를 극대화합니다.
%rdi%rsi%rdx%rcx%r8%r9%raxCPU의 레지스터 개수는 한정되어 있으므로 함수 호출 시 레지스터 덮어쓰기 충돌이 발생합니다.
%rax, %rcx, %rdx, %rsi, %rdi, %r8~%r11): 함수를 부르는 쪽(Caller)에서 값이 필요하다면 호출 전에 스택에 직접 백업해야 합니다. 피호출 함수는 이 레지스터들을 마음대로 덮어쓸 수 있습니다.%rbx, %rbp, %r12~%r15): 호출당하는 쪽(Callee)에서 이 레지스터를 사용하려면 반드시 기존 값을 스택에 push해 두고, 함수를 반환(ret)하기 직전에 원래 값으로 pop하여 복원해 놓아야 합니다.4주차의 실패를 통해 인생의 가장 큰 교훈 중 하나를 얻었습니다. "두려움은 피한다고 사라지지 않으며, 준비되지 않은 두려움은 반드시 처참한 결과로 돌아온다"는 사실이었습니다.
어렵고 싫어하는 주제일수록 더 일찍 시작하고, 더 집요하게 준비했어야 했습니다. 나의 무지와 게으름을 포장하려 하지 않고 투명하게 인정하는 것, "내가 이 부분을 완벽히 이해하지 못했다"는 것을 정직하게 인정하는 것에서부터 진짜 엔지니어의 성장이 시작된다는 것을 깨달았습니다. 이 반성은 5주차 이후 저를 근본적으로 변화시키는 결정적인 분수령(Turning Point)이 되었습니다.
답변: 포인터 변수 자체는 단순히 8바이트 메모리 주소 값만 저장하지만, 컴파일러는 포인터 선언 시 지정된 기저 자료형(Base Type)의 메타데이터를 추적합니다. 예를 들어 char *p는 역참조 시 해당 주소로부터 1바이트를 읽는 기계어(movzbl)를 생성하고, int *p는 4바이트(movl), double *p나 구조체 포인터는 해당 자료형의 크기만큼 데이터를 읽거나 쓰는 명령어를 생성합니다.
답변: leaq(Load Effective Address) 명령어는 원래 메모리 피연산자의 주소 계산(Base + Index * Scale + Disp)을 위해 하드웨어 주소 생성 유닛(AGU)에서 실행되도록 설계되었습니다. 이 명령어는 메모리를 역참조하지 않고 주소 계산 결과만을 목적지 레지스터에 저장하므로, 단 한 클럭 사이클 내에 y = x * 4 + z + 8과 같은 복합 산술 연산을 플래그 레지스터(%rflags) 변경 없이 고속으로 처리할 수 있어 컴파일러가 범용 산술 연산 최적화에 적극 채택합니다.
답변: free()를 통해 힙 메모리를 운영체제에 반환한 이후에도 포인터 변수는 여전히 과거의 메모리 주소를 가리키고 있습니다. 이 상태에서 해당 포인터를 다시 역참조하거나 값을 쓰는 경우, 이미 다른 객체나 공격자가 제어하는 데이터가 적재된 메모리를 무단 덮어쓰게 되어 Use-After-Free(UAF) 취약점이 발생하며, 함수 포인터 변조를 통한 임의 코드 실행 공격으로 이어질 수 있습니다.
