길민호(ethan.mino)
코딩수첩
길민호(ethan.mino)
전체 방문자
오늘
어제
  • 분류 전체보기 (215)
    • Computer Science (0)
    • Web (6)
      • CSS (0)
      • HTML (0)
    • Node.js (0)
    • Javascript (2)
    • Java (46)
      • Spring (27)
      • Jsp (0)
    • C\C++ (2)
    • Programming (0)
    • AI (0)
    • Database (7)
    • Git (5)
    • Algorithm (119)
      • Stack (0)
      • Queue (0)
      • Linked List (0)
      • Sort (0)
      • Simulation (27)
      • Recursion (0)
      • Backtracking (4)
      • Two Pointer (3)
      • Dynamic Programming (19)
      • Greedy (10)
      • Graph (3)
      • Dijkstra (1)
      • BFS\DFS (8)
      • Floyd (1)
      • MST (4)
      • Tree (4)
      • Binary Search (8)
      • Binary Search Tree (4)
    • IntelliJ (4)
    • Vscode (0)
    • Operating System (0)
    • 후기 (3)
    • 성장일지 (13)
    • 스터디 (7)
    • 설치 (1)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • ㅡ

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
길민호(ethan.mino)

코딩수첩

22.08.18 SSAFY 스터디 CS 발표 - Java 어셈블리어 분석
스터디

22.08.18 SSAFY 스터디 CS 발표 - Java 어셈블리어 분석

2022. 8. 16. 02:14

C언어 포인터는 왜 타입별로 구분되어 있는가?


내용

저번에 준배님이 제네릭 설명하실 때, 제가 주소의 크기는 같은데 왜 Int형 포인터와 double형 포인터를 구분하는지 질문한 적이 있습니다.

그래서 제가 C언어를 예시로 가져왔는데, 이게 어느정도 해답이 될 거 같습니다.

#include<stdio.h>

  int main() {

      int a = 300000000; // int형 변수에 300000000를 저장
      double b = 3.0; // double형 변수에 3.0 저장

      int * c = &a; // c는 a의 주소를 저장
      double * d = &b; // d는 b의 주소를 저장

      int e = a + *c; // a + (c가 가리키는 주소에 저장된 값)
      double f = b + *d; // b + (d가 가리키는 주소에 저장된 값)
  }

위 코드에서는 int형 변수a에 3_0000_0000을 저장하고, double형 변수 b에는 3.0을 저장합니다.

그리고, int  포인터형 변수 c에는 a의 주소를 저장하고, double 포인터형 변수 d에는 b의 주소를 저장합니다. 

마지막으로, int형 변수 e에는 a와 c가 가리키는 주소에 저장된 값을 더한 값,

double 형 변수 f에는 b와 d가 가리키는 주소에 저장된 값을 더해서 넣어줍니다.

 

한 바이트에 할당된 주소가 32비트일 때, 메모리를 도식화하면 아래와 같은 그림이 됩니다.

한 칸이 한 바이트를 의미합니다. 

따라서 int형 변수인 a는 4개의 칸으로, double 형 변수인 b는 8개의 칸으로, 포인터 변수인 d는 4칸으로 표현하였습니다. 

한 바이트씩 한 줄로 나열해야되지만, 편의상 아래와 같이 도식화하였습니다.

위 코드의 메모리를 도식화한 이미지

아 그래서 포인터가 왜 타입별로 구분되어 있는건데~!

 

만약 int형 포인터가 가리키는 주소에서 값을 가져온다고 생각해봅시다.

그럼, int형은 4 바이트이기 때문에, int형 포인터가 가리키는 주소에서 4바이트 가져오면 될 것입니다.

반면, double형 포인터가 가리키는 주소에서는 4 바이트가 아닌 8바이트를 가져와야합니다.

 

즉, int형 포인터든, double 형 포인터든 주소를 저장하기 때문에 크기는 동일합니다만, 

해당 주소에서 몇 바이트를 가져올 것이냐가 전혀 다릅니다.

따라서 컴파일 되고, 기계어로 번역될 때 다른 명령어로 번역이 되는거죠.

 

어셈블리어(Assembly language) 분석


내용

아래는 위 코드를 디어셈블러로 기계어를 어셈블리어로 변환한 코드입니다.

int main() {
      /*
          main() 함수의 실행을 준비하는 prologue
      */
          int a = 300000000; // int형 변수에 300000000를 저장
      00711718  mov         dword ptr [a],11E1A300h  
          double b = 3.0; // double형 변수에 3.0 저장
      0071171F  movsd       xmm0,mmword ptr [__real@4008000000000000 (0717B30h)]  
      00711727  movsd       mmword ptr [b],xmm0  

          int * c = &a; // c는 a의 주소를 저장
      0071172C  lea         eax,[a]  
      0071172F  mov         dword ptr [c],eax  
          double * d = &b; // d는 b의 주소를 저장
      00711732  lea         eax,[b]  
      00711735  mov         dword ptr [d],eax  

      // 핵심!
          int e = a + *c; // a + (c가 가리키는 주소에 저장된 값)
      00711738  mov         eax,dword ptr [c]  
      0071173B  mov         ecx,dword ptr [a]  
      0071173E  add         ecx,dword ptr [eax]  
      00711740  mov         dword ptr [e],ecx  
          double f = b + *d; // b + (d가 가리키는 주소에 저장된 값)
      00711743  mov         eax,dword ptr [d]  
      00711746  movsd       xmm0,mmword ptr [b]  
      0071174B  addsd       xmm0,mmword ptr [eax]  
      0071174F  movsd       mmword ptr [f],xmm0  

      /*
          main() 함수의 실행을 종료하기 위한 epilogue
      */
  }

 

핵심 주석 아래에 있는 코드만 보시면 됩니다. 

 

a + * c를 할 때는, c의 주소에서 4 바이트를 가져와서 eax 레지스터에 저장하고(a의 주소), 

a의 주소에서 4바이트를 가져와서 ecx에 저장합니다(a의 값).

그리고, eax 레지스터에 있는 a의 주소에서 4바이트를 가져와서(a의 값) ecx에 있는 값과 더한 다음 e의 주소에 저장해줍니다.

 

설명이 어렵게 느껴지실 수 있습니다.

간단하게만 보시면, int형 포인터 변수인 c와 double형 포인터 변수에 저장된 주소에서 값을 가져올 때, 명령어가 전혀 다른 것에만 주목하시면 됩니다.

포인터 변수에 저장된 주소로부터 몇 바이트 불러올 건지가 다르기 때문에, 서로 다른 명령어로 변역되는 것입니다.

 

따라서, 포인터가 동일한 크기의 주소를 저장함에도 타입별로 구분되어 있는 것이죠.

C++  (Call By Value, Call By Reference)


C++
#include <iostream>

int func1(int a) {
    a += 1;
    return 20;
}

int func2(int * a) {
    *a += 2;
    return 32;
}

int func3(int & a) {
    a += 3;
    return 100;
}

int main() {
    int t = 5;
    t += 3;

    printf("Before : %d\n", t);
    func1(t);
    printf("After func1() : %d\n", t);
    func2(&t);
    printf("After func2() : %d\n", t);
    func3(t);
    printf("After func3() : %d\n", t);
}
Before : 8
After func1() : 8
After func2() : 10
After func3() : 13

보시면, main 함수에서는 t에 5를 넣어주고, 3을 더해줍니다. 

그리고, func1() 함수를 호출해줍니다.

func1()을 보시면, 파라미터의 타입이 int 타입이고, a에 1을 더해줍니다. 

func1()가 반환한 이후에 main()에서 t 값을 출력해보면, 값이 바뀌지 않고 8이 그대로 출력되는 것을 확인할 수 있습니다.

func1() 호출부의 아규먼트 값이 파라미터에 단순히 복사되었기 때문입니다.

 

다음으로, func2() 함수를 호출해줍니다. 

func2()를 보시면, 파라미터의 타입이 int 포인터 형이고, 포인터 변수를 이용하여 값을 2 증가시켜주었습니다.

func1()과는 다르게, a가 t의 주소를 가리키고 있기 때문에, func2()가 반환했을 때, t의 값이 바뀝니다.

 

다음으로, func3() 함수를 호출해줍니다.

func3()의 파라미터는 int형의 참조자인데요, 참조자를 사용하면, t의 메모리 공간에 a라는 또 다른 이름을 붙인 것처럼 사용할 수 있습니다.

func3() 함수에서는 a에 3을 더해주고, func3()가 반환한 이후 t의 값은 변경되어서 13이 출력됩니다.

 


C++ 어셈블리 코드
     3: int func1(int a) {
      /*
          func1() 함수의 실행을 준비하는 prologue
      */  
     4: 	a += 1;
00A51808  mov         eax,dword ptr [ebp+8]  // eax 레지스터에 8이 들어감
00A5180B  add         eax,1  
00A5180E  mov         dword ptr [ebp+8],eax  
     5: 	return 20;
00A51811  mov         eax,14h  
     6: }
     
  	8: int func2(int * a) {
      /*
          func2() 함수의 실행을 준비하는 prologue
      */ 
     9: 	*a += 2;	// func3()와 동일
00A51868  mov         eax,dword ptr [ebp+8]  
00A5186B  mov         ecx,dword ptr [eax]  
00A5186D  add         ecx,2  
00A51870  mov         edx,dword ptr [ebp+8]  
00A51873  mov         dword ptr [edx],ecx  
    10: 	return 32;
00A51875  mov         eax,20h  
    11: }
    
        13: int func3(int & a) {
      /*
          func3() 함수의 실행을 준비하는 prologue
      */
    14: 	a += 3;	// // func2()와 동일
00A518D8  mov         eax,dword ptr [ebp+8]  
00A518DB  mov         ecx,dword ptr [eax]  
00A518DD  add         ecx,3  
00A518E0  mov         edx,dword ptr [ebp+8]  
00A518E3  mov         dword ptr [edx],ecx  
    15: 	return 100;
00A518E5  mov         eax,64h  
    16: }
    
int main() {
      /*
          main() 함수의 실행을 준비하는 prologue
      */
    19: 	int t = 5;
00A51A48  mov         dword ptr [ebp-8],5  
    20: 	t += 3;
00A51A4F  mov         eax,dword ptr [ebp-8]  
00A51A52  add         eax,3  
00A51A55  mov         dword ptr [ebp-8],eax  
    21: 
    22: 	printf("Before : %d\n", t);
00A51A58  mov         eax,dword ptr [ebp-8]  
00A51A5B  push        eax  
00A51A5C  push        0A57B30h  
00A51A61  call        00A5104B  
00A51A66  add         esp,8  
    23: 	func1(t);
00A51A69  mov         eax,dword ptr [ebp-8] // eax에 8이 들어감 
00A51A6C  push        eax  
00A51A6D  call        00A51190  
00A51A72  add         esp,4  
    24: 	printf("After func1() : %d\n", t);
00A51A75  mov         eax,dword ptr [ebp-8]  
00A51A78  push        eax  
00A51A79  push        0A57B40h  
00A51A7E  call        00A5104B  
00A51A83  add         esp,8  
    25: 	func2(&t);
00A51A86  lea         eax,[ebp-8]  // eax에 t의 주소값이 들어감
00A51A89  push        eax  
00A51A8A  call        00A510A0  
00A51A8F  add         esp,4  
    26: 	printf("After func2() : %d\n", t);
00A51A92  mov         eax,dword ptr [ebp-8]   
00A51A95  push        eax  
00A51A96  push        0A57B58h  
00A51A9B  call        00A5104B  
00A51AA0  add         esp,8  
    27: 	func3(t);
00A51AA3  lea         eax,[ebp-8]  // eax에 t의 주소값이 들어감
00A51AA6  push        eax  
00A51AA7  call        00A511BD  
00A51AAC  add         esp,4  
    28: 	printf("After func3() : %d\n", t);
00A51AAF  mov         eax,dword ptr [ebp-8]  
00A51AB2  push        eax  
00A51AB3  push        0A57B70h  
00A51AB8  call        00A5104B  
00A51ABD  add         esp,8  
    29: }

위 어셈블리 코드를 보면,

func1()을 호출할 때, ebp-8 주소에 저장된 값을 eax 레지스터에 넣어주는데요, ebp-8에 저장된 값이 바로 t의 주소값입니다.

따라서 eax 레지스터에는 8이라는 값이 들어가고, eax 레지스터를 스택에 push 해줍니다.

그리고 func1()을 호출해줍니다. 그럼 func1()에서는 복사된 값을 사용하는 것이죠.

 

다음으로, func2()를 호출할 때에는, ebp-8에 저장된 값을 eax 레지스터에 저장하고, eax를 스택에 push 해줍니다.

그리고 func2()를 호출해줍니다. 즉, eax에는 t의 주소값이 들어가는 것이죠.

func2()에서는 a에 저장된 t의 주소값을 eax에 저장한 후, eax에 저장된 주소로부터 4바이트를 불러와서 ecx에 저장한 다음, 2를 더해주고, t의 주소에 저장해줍니다. 

따라서, 2를 더한 값을 t의 주소에 저장해주었기 때문에 func2()가 반환한 이후에도 값이 변경된 것입니다.

 

그렇다면, 참조자는 어떻게 동작하는 걸까요?

func2()와 비교해보자면 func3()은 func2()와 완벽히 동일합니다. 

호출부도 동일하고, 함수 내부의 동작도 동일합니다. 

따라서, 포인터든 참조자든 호출 시 주소를 전달하고, 참조자는 컴파일 되었을 때, 포인터와 동일한 방식으로 사용되는 것을 확인할 수 있습니다.

 

정리하자면, 어셈블리어를 보니 C의 포인터와 C++의 참조자 모두 주소를 전달하는 방식이었습니다.

 

저의 경우 C/Java는 Call by Value, C++의 참조자는 Call by Reference라고 배웠는데요,

"C는 Call by Reference다", "아니다 주소 '값'을 전달하기 때문에 Call by Value다",

"자바의 Primitive 타입은 Call by Value고 Reference는 Call by Reference다", "아니다 일종의 포인터 역할을 하기 때문에 Call by Value다." 등 책마다, 교수님마다, 사람마다 서로 다른 의견을 갖고 있기 때문에, Call by Value, Call by Reference를 명확히 정의하는 것은 어렵겠습니다만,  

제가 생각했을 때는, 주소의 전달 여부가 아닌, 함수 실행부에서 전달 받은 파라미터를 마치 아규먼트의 Aliasing처럼 사용할 수 있느냐로 Call by Reference냐 Call by Value냐를 구분할 수 있을 것 같습니다.

다형성은 어떻게 동작하는가?


다형성은 도대체 어떻게 동작하는 것일까요?

자식 객체를 부모 타입의 레퍼런스 변수에 대입하면, 어떻게 자식의 오버라이딩된 메서드가 호출되는 걸까요?

 

자바의 다형성을 설명하기 앞서 정적 바인딩에 대해 간략히 설명 드리겠습니다.

정적 바인딩 (Static Binding)

정적 바인딩

위 예시를 Java가 아니라, 정적 바인딩을 지원하는 가상의 언어라고 생각해봅시다.

정적 바인딩의 경우, 호출하는 함수의 주소가 컴파일 타임에 결정되기 때문에, 컴파일러가 함수의 호출문을 함수의 주소로 점프하는 기계어로 번역하게 됩니다. (어셈블리 코드 참조)

따라서 각 객체들은 함수의 주소를 저장할 필요 없이, 변수들만 저장하면 됩니다. 

또한, 이미 결정된 주소로 가서 함수를 단순히 실행시키기만 하면 되기 때문에 오버헤드가 없고, 효율성이 높아집니다.

 

 

동적 바인딩 (Dynamic Binding)

다음으로, 동적 바인딩입니다. 

동적 바인딩은 정적 바인딩과 달리 컴파일 타임에 바인딩이 이루어지는 것이 아니라, 런타임에 바인딩이 이루어지는 것을 말합니다.

동적 바인딩

위 예시에서는 Parent 클래스와 Child 클래스가 있습니다.

그리고, Child는 Parent를 상속받았습니다.

아래 1번 코드에서  Parent 타입의 레퍼런스 변수 p에는 Parent 객체가 들어가고,

2번에서 getName()을 호출하면 부모의 getName() 메서드가 호출됩니다.

 

반면, 3번에서는 Parent 타입의 레퍼런스 변수 c에 Child 객체를 대입합니다.

레퍼런스 변수 c는 이미지에서 빨간색으로 강조 표시해놓은 부분만, 즉, parent에 해당하는 부분만 접근할 수 있습니다.

하지만, 각 객체들은 함수의 위치를 나타내는 테이블을 가지고 있으며, 부모 클래스의 메서드를 오버라이딩한 자식 객체가 부모 타입의 레퍼런스 변수에 대입될 때, 테이블에서의 부모 객체의 메서드 주소가 자식 객체의 메서드 주소로 업데이트 됩니다. 

 

이러한 메서드 호출과 실제 메서드의 바인딩이 런타임에 발생하고, 테이블을 업데이트하는 과정이 실행중에 발생하기 때문에 오버헤드가 발생합니다. 하지만, 다형성을 이용하여 더 유연한 프로그래밍이 가능하죠.

 

하지만, Java에서도 final, private, static 키워드를 사용하게 되면, 마찬가지로, 메서드의 주소를 컴파일 타임에 알 수 있기 때문에 정적 바인딩이 가능합니다.

 

참조


  1. PROGRAMMING LAGUAGE PRAGMATICS, 4th edition
  2. 🕷lldb 디버거 사용법, 명령어 정리

 

 

 

 

Java의 배열은 연속적? 그렇다면 왜 Heap에?


심볼릭 레퍼런스 (Symbolic Reference)


제네릭은 어떻게 동작하는가?


'스터디' 카테고리의 다른 글

데코레이터 패턴(Decorator Pattern)이란?  (0) 2023.04.04
22.11.15 SSAFY 스터디 CS 발표 - 디자인 패턴 (전략 패턴)  (0) 2022.12.01
22.11.8 SSAFY 스터디 CS 발표 - 디자인 패턴 (템플릿 메서드 패턴)  (0) 2022.11.07
22.10.11 SSAFY 스터디 CS 발표 - 컴퓨터 구조, 기억장치  (0) 2022.10.11
22.09.22 SSAFY 스터디 CS 발표 - 컴퓨터 구조  (0) 2022.09.22
    '스터디' 카테고리의 다른 글
    • 22.11.15 SSAFY 스터디 CS 발표 - 디자인 패턴 (전략 패턴)
    • 22.11.8 SSAFY 스터디 CS 발표 - 디자인 패턴 (템플릿 메서드 패턴)
    • 22.10.11 SSAFY 스터디 CS 발표 - 컴퓨터 구조, 기억장치
    • 22.09.22 SSAFY 스터디 CS 발표 - 컴퓨터 구조
    길민호(ethan.mino)
    길민호(ethan.mino)
    💻 호기심 많은 서버 개발자 길민호입니다.

    티스토리툴바