2025. 4. 9. 17:52ㆍ개발/Windows
이 글은 윤성우 님의 '뇌를 자극하는 윈도우즈 시스템 프로그래밍'을 참고하여 공부한 내용입니다.
최근 유튜브의 알고리즘에 의해 C/C++의 메모리 영역에 대한 영상을 봤습니다. 5분 안에 빠르고 간단하게 메모리 영역에 대해 설명해 주는데 굉장히 효율적인 영상이라 생각했습니다. 특히 이분법적으로 메모리 영역을 코드와 데이터로 나눈 것이 인상 깊었습니다. 코드는 프로세스가 실행될 흐름이고 나머지 데이터, 힙, 스택은 단순히 코드가 실행되며 사용될 데이터로 분류한 것이 참신했습니다.
지난 '레지스터와 명령어구조' 포스트에서 실행된 프로세스의 명령어를 IR에서 Decode 하여 실제 CPU가 실행하는 명령어의 구조를 살펴봤습니다. 이번 포스트에서는 프로세스가 실행되며 사용하는 메모리와 레지스터의 구조와 순서에 대해 알아보겠습니다.
Program Counter
실행된 프로세스는 PC(Program Counter) 레지스터를 통해 실행흐름이 관리됩니다. 우선 PC는 실행할 명령어의 주소를 가지게 됩니다. 그다음 Control Unit은 Control Bus를 통하여 실행할 명령어를 읽기 위한 요청을 메모리에게 보냅니다. 이때 MAR(Memory Address Register)에 PC의 주소값을 복사한 후 Address Bus를 통해 주소를 전달하게 됩니다. 이후 해당 주소에 있는 명령어는 메모리에서 MBR(Memory Buffer Register)에 Data Bus를 통해 임시 전달되며, 최종적으로 IR, Instruction Register에 명령어가 Fetch 됩니다.
위 과정이 끝나면 PC는 다음 명령어 주소를 읽기 위해 값을 더해 주소를 이동시킵니다. 만약 이때 분기 명령이 있는 경우(조건문, 함수호출) 값을 더하지 않고 대상 주소를 PC에 직접 설정합니다. 여기서 함수를 호출할 경우 추가적인 작업이 필요합니다.
Stack Frame
스택 프레임은 메모리 구조로써, 함수 실행에 필요한 데이터를 관리하고 실행 흐름을 유지하는데 사용됩니다. 스택 프레임의 생성은 호출자(Caller)와 피호출자(Callee)에 의해 진행되며, 각 함수 호출마다 독립적으로 생성됩니다.
우선 스택의 흐름을 관리하는 두 개의 레지스터를 알고 있어야 합니다. SP(Stack Pointer)와 FP(Frame Pointer)입니다. SP는 항상 사용하지 않은 메모리의 최상단 주소, 즉 스택의 가장 낮은 주소를 가지고 있습니다. 그리고 FP는 스택 프레임의 시작 주소를 가지고 있습니다. 이 두 레지스터를 이용하여 스택 프레임이 생성되는 순서를 알아보겠습니다. 우선 함수를 호출하기 전 준비가 필요합니다.
가장 먼저 호출자는 현재 사용 중인 레지스터를 스택에 저장합니다. 만약 호출자 코드에서 연산중이라면 현재 범용 레지스터(예: RAX)에 있는 값들을 스택 최상단에 저장합니다. 이제 호출한 피호출자가 사용할 인자를 전달합니다. 인자는 레지스터를 통해 전달됩니다. 이때 인자의 개수가 초과되면 남은 스택에 저장합니다. 이때 인자가 초과되는 기준은 운영체제마다 다릅니다.
그다음 피호출자가 종료 후 다시 호출자의 현재 실행 흐름으로 올 수 있게 하기 위해 호출자의 다음 명령어의 주소를 스택에 저장합니다. 함수를 호출할 준비가 모두 끝났습니다. 이제 함수를 호출하며 PC는 피호출자의 시작 주소로 이동됩니다.
피호출자는 현재 호출자 스택 프레임의 시작 주소를 가지고 있는 FP를 스택에 저장하여 피호출자가 종료하였을 때, 호출자의 FP를 다시 회수할 수 있게 합니다. 그다음 현재 SP 주소값을 FP에 저장하여 피호출자의 시작주소로 갱신합니다. 이제 피호출자에서 사용할 지역변수를 저장할 공간을 할당합니다. 이후 피호출자는 코드를 실행합니다. 이때 코드에서 지역변수를 사용할 때는 FP에 오프셋을 통해 접근합니다. 예를 들어 -4(%FP)는 현재 스택 프레임의 시작 주소로부터 -4 오프셋 된 주소에 있는 지역변수에 접근하겠다는 의미입니다.
실행이 모두 끝난 피호출자는 현재 FP의 주소값을 SP에 저장하여 지역변수 공간을 해제시킵니다. 그리고 기존에 저장해 둔 호출자의 FP 주소값을 읽어 현재 FP에 저장합니다. 마지막으로 피호출자는 호출자가 저장한 반환 주소를 읽어 PC를 해당 주소로 직접 설정합니다. 추가로 피호출자에게 전해줄 인자를 스택에 저장했다면, 이 인자들 또한 해제시킵니다. 이때 스택에 레지스터의 값을 임시로 저장했다면 다시 값을 복구하고 동일하게 해제합니다.
위와 같은 과정을 통해 함수 호출 시 메모리 누수 혹은 실행 흐름 오류 없이 코드가 안정적으로 동작됩니다. 참고로 위 방식은 스택 프레임에서 FP와 SP 동작방식의 이해를 돕기 위한 예시이며 각 운영체제에서 사용하는 호출규약은 위 과정과 다를 수 있습니다.
Calling Convertions
함수 호출규약은 호출자와 피호출자간 서로 데이터를 주고받으며 실행 흐름을 관리하기 위한 규칙입니다. 위에서 설명한 스택 프레임이 생성되는 과정에서 매개변수의 전달, 값 반환, 레지스터 보존 등이 규칙에 해당됩니다. 그래서 각 호출규칙마다 레지스터 사용여부, 매개변수의 전달 순서, 스택 해제를 진행하는 주체(호출자 또는 피호출자) 등이 달라질 수 있습니다.
우선 호출규약의 예시로 32비트 시스템에서 많이 사용된 __cdecl과 __stdcall 두 가지를 알아보겠습니다.
__cdecl, C Declaration은 C 및 C++에 대한 기본 호출규약이며 Linux x86의 기본 호출규약이기도 합니다. 위 과정과 다르게 매개변수를 레지스터를 사용해 전달하지 않고 스택에 저장하여 FP 레지스터 오프셋을 통해 접근합니다. 그리고 32비트 시스템이기 때문에 결과 값을 반환하는 레지스터를 EAX를 사용합니다. 주요 특징으로는 가변 인자 함수(예: printf)를 지원하는 것입니다. 가변 인자는 말 그대로 인자의 개수가 고정되어 있지 않기 때문에, 호출자만 피호출자의 매개변수가 몇 개인지 알고 있습니다. 그래서 스택 메모리 해제를 피호출자가 아닌 호출자가 진행합니다.
__stdcall, Standard Call은 Windows x86의 기본 호출규약입니다. __cdecl과 비슷하게 매개변수의 전달에 레지스터를 사용하지 않으며 결과 값을 EAX에 반환합니다. 차이점은 가변 인자를 지원하지 않는 고정 인자 방식이기 때문에 피호출자가 스택 메모리 해제를 진행합니다. 피호출자가 스택을 해제하기 때문에 호출자 코드가 더 간결해지며 호환성이 높아지는 장점이 있습니다.
Windows 64비트 시스템에서는 Microsoft x64 Calling Convention을 따릅니다. 이 호출규약은 32비트 시스템에 있는 호출규약들을 통합하여 단일 호출규약으로 사용하기 위해 설계되었습니다. 단일 호출규약을 사용하게 되면 효율성과 유연성 그리고 호환성을 높일 수 있기때문입니다. 그래서 이 호출규약은 가변인자를 사용할 수 있으며 호출자가 스택 메모리를 해제합니다. 또한 매개변수 전달을 위해 레지스터를 사용합니다. 사용되는 레지스터는 (정수: RCX, RDX, R8, R9) (실수: xmm0, xmm1, xmm2, xmm3) 4개이며 초과되는 매개변수는 스택에 저장됩니다. 마지막으로 이 호출규약에서 호출자는 매개변수를 처리하는 Shadow Space를 할당하는 주요 특징이 있습니다. Shadow Space는 가변인자를 처리할 공간으로 사용할 수도 있으며, 디버깅을 위해 매개변수 값을 복사해놓는 공간으로 활용할 수도 있습니다.
'개발 > Windows' 카테고리의 다른 글
IPC (0) | 2025.03.26 |
---|---|
커널 오브젝트와 오브젝트 핸들 (0) | 2025.03.17 |
Windows 프로세스 생성과 소멸 (0) | 2025.03.15 |
레지스터와 명령어 구조 (0) | 2025.03.14 |
Windows 32/64비트 시스템 (0) | 2025.02.26 |