2011년 9월 2일 금요일

C언어 포인터 개념 I - 포인터 이해, 포인터 기초 & CPU 구조


C언어의 포인터 이야기
C언어 포인터 개념 I
C언어 포인터 개념 II - float,double,char, string 포인터
C언어 포인터 개념 III - struct 포인터 & void 포인터
C언어 포인터 개념 III - struct - C++ 포인터 Linked-List 
C언어 포인터 개념 III - struct 포인터 2


이 문서에서 CPU 및 언어의 동작은 CPU와 컴파일 마다 틀리고 단지 여러가지 CPU를 고려 할 때 평균적인 개념들을 추출하고 도식한 것이다. 따라서 직접 개발하고자 하는 CPU 및 컴파일러를 필요에 따라 분석하여 이해 하여야 한다. 개념적 CPU는 CISC을 기준으로 한것이기 때문에 기계어 코드의 길이는 다를 수 있다.
모든 CPU의 상황은 다르지만 공부의 기본 방법이 어떠한 CPU을 잡고 이것의 포인터를 전체를 파악하면 다른 CPU는 상황에 맞는 대처로 간단히 해결된다. 어느 CPU든 정확한 개념없이 간다면 서로 섞이면서 혼돈의 세계로 빠진다. 따라서 이 글의 대부분은 특정 CPU에 촛점을 맞추어, 블로그 글을 위해 생 시간을 낭비하면 예제를 만들었다. 만든 사람의 성의를 보아 자세히 공부를 했으면 한다. 그래야 기술의 전파속도를 높일수 있어 윈윈 전략이 될 것이다.
만약 글 내용 중 문제가 있다거나 다음에 이런글이 있으면 좋다는 분은 메일 주셈. dolicom@naver.com


C언어의 포인터

개요

  C의 포인터는 다른 언어와 비교하면 C의 가장 중요한 특징이다. c프로그램 하면서 역시 가장 난해하게 생각하는 것 역시 포인터 이다. 그런데 기본 개념 없이 포인터를 사용하면 여러가지 오류를 발생하고 공부 하기가 힘들다.

포인터는 기본적으로 데이터의 위치를 가리키도록 정의 되어 있는 변수이다. 이 말은 3가지 요소를 기본적으로 전제 하여야 한다.
  1. 데이터 : 처리해야 할 데이터       -------------------- 'h', 100, 3.14159265358, 34.4234e23, "홀길동"
  2. 데이터를 저장해야할 공간 ---------------------------- 데이터 변수 (char, init,float,double, 그리고 array)
  3. 해당 데이터를 지정할수 있는 포인터 값을 저장할 공간 --- 포인터 변수 (char*, int*, ...)

일상생활에서 빗데어 생각하면
전화 시스템을 생각해 보자.
친구, 아내, 아들, 학교, 회사 등의 전화등 다양한 전화가 있다. 예를 들어 친구에게 전화를 한다면
- 통화 내용 ----------- 통화 내용 --------- 100, 3.1415, ...
- 친구가 전화 --------- 실제 전화기 ------- char, int,float,double, ...   => char ch; int ivalue;
- 친구의 전화번호 ----- 친구전화번호 ------ &ch,  &ivalue
- 전화번호저장매체 ---  수첩, 셀폰전화번호부, 사람의 해머 ------ char *, int *  --- char *pch;  int *pivalue;

많은 전화기가 있다. 그리고 그것에는 각각의 주소가 매겨져 있다. 그러나 내가 모든 전화 번호를 암기해야 할 이유는 없다. 필요한 것만 암기하거나 수첩에 적거나 셀폰에 저장하면 된다.
바로 이 전화번호을 기억하는 장소가 포인터 변수이다. 어느 사람은 머리속에, 어느사람은 수첩에 전화번호를 적는다. 머리속의 헤마가, 수첩이, 셀폰의 전화번호목록 저장메모리가 포인터 이다.
A친구에게 전화를 걸려면 암기된 전화번호에서 번호를 하나를 결정 한다. 이 번호를 내 전화기에서 다이얼링 한다. 이 과정이 포인터 변수에 값을 저장하는 과정이다.

그렇다면 변수의 예와 전화 걸기의 비교

저장 할 데이터 100 -- 통화 내용 : '안 죽고 잘있지.', 오늘 10시 만나...

int idata;    -------- 어느 한 친구의 전화기
int *pdata;  -------- 머리속, 수첩, 셀폰의 전화번호 목록

pdata = &idata; ---- 머리속에 전화번호를 암기, 수첩에 전화번호 적기, 셀폰에 친구 등록하기
*pdata = 100;  ----- 전화번호를 찾아 전화걸면 연결, 그리고 수다

이 과정에서 하나라도 빠지면 의미가 없어진다.
'int idata;'가 없는 경우 - 친구의 전화기가 없어데도 전화를 걸다. 전화걸 대상이 없는 전화를 건 셈이다.

       int *pdata;    ---- 내가 전화번호를 기록할 방법(머리속, 수첩 셀폰,...)은 있다.
       *pdata = 100; --- 전화할 대상을 설정하지 않았다. 그리고 다이얼을 하지 않고, 그냥 전화기 수화기만 들고 수다.
                               점점 미쳐간다....

다른 경우가 우편물을 생각 하자.
우선 편지를 보내려면 해당 대상이 있어야 한다. 그리고 그 대상에 대한 주소가 필요하다.
친구에게 편지를 보내려면 편지 봉투가 필요하다. 편지를 쓰고 이것을 봉투에 넣고 주소를 적는다. 그리고는 우체국에...
친구는 반듯이 주소를 가지고 있어야 한다. 그래야 편지를 보낸다. 그리고 내가 그 친구의 주소를 반듯시 알아야 편지를 보낸다.
- 편지 내용 (편지지) ---------------------- 100
- 편지 내용 물이 도착할 대상(친구집) ------ int ival;
- 편지봉투의 주소란과 우편번호 ----------- int *pivalue;
- 편지봉투의 주소 및 우편번호 기입 -------- pivalue = &ival;
- 우체부 편지배달  ----------------------- *pivalue = 100;

int idata;    ------   편지가 도착할 대상 : 친구 집
int *pdata;  ------  편지 봉투의 주소란
pdata = &idata; --- 편지지에 주소를 적어 대상을 선택 하고, 우표를 사서 붙이면
*pdata = 100;  ---- 우체국에서 배달부가 전달 한다. 그리고 친구집에 도착..
                            여기서 100이 편지 내용이다. 편지지의 글.

그렇다면 컴퓨터 프로그램에서는 포인터를 뭔가?

우선 컴퓨터는 처리할 데이터가 있다. 그런데 모든 데이터는 우선 메모리로 들어간다. 처리가 완료되면 저장장치 전송할 수도 있고, 버릴 수도 있다. 어째든 CPU가 데이터를 처리 할 때, 메모리에 데이터를 넣고 이것을 CPU을 가져와 가공한 후 다시 메모리에 넣어야 한다. C에서 말하는 포인터는 데이터가 메모리로 들어 갈 때, 어느 위치에 데이터를 넣을 것인가를 결정하는 방식이다. 이 방식이 결정되어야 데이터를 빼오고 처리 한다. 처리할 때 포인터를 사용하지 않을 수도 있는데 어래이의 인덱스를 붙이는 방식은 포인터 방식이 아니다.
어째든 데이터의 위치를 지정하는 변수를 사용하면, 효율적인 데이터 처리가 가능하다. 함수의 구현이나 다이나믹 변수 공간 처리 등에 유리한 방식이다.
포인터는 데이터의 위치를 가리키는 변수이다. 가르킨다는 말은 마이크로프로세서의 입장에서는 메로리나 하드웨어의 레지스터를 가르키면 된다. 그리고 모든 위치는 메모리의 주소로 결정 된다. 왜, 데이터는 메모리에 있기 때문이다. 결국 메모리의 위치라는 말은 메모리의 주소를 말하는 것이다. 메모리의 주소체계를 완벽히 이해 하려면 우선은 CPU의 이해가 필요한 부분이긴 하다. 그렇다고 전공이 아닌 개발자가 이것을 알려면 힘든 문제이긴 하다.



이 그림 32비트 CPU와 메모리간의 데이터 엑세스를 위한 구조이다. 형태는 조금씩 다른지만 모든 컴퓨터는 CPU와 메모리가 있다. 반듯시... 포인터와 연관 되어 이해해야 할것은 CPU와 메모리간 데이터 엑세스 이다. 포인터라는 말은 메모리(RAM/ROM/FLASH)의 데이터를 엑세스하는 기계어 코드와 연관되어 있기 때문이다.
포인터를 이해하기 전에 메모리와 레지스터간의 데이터 엑세스를 이해 해야 한다. C는 기계어와 밀접한 관계를 가지고 코딩 된다.

잠시 쉬어 가기
CPU 엑세스 모드
- immediately address mode - 엑세스 대상이 코드 다음에 온다.
   mov R0, #100   --> R0는 100이 된다.

- direct address mode - 코드 다음에 오는 operand를 주소값으로 하여 대상 데이터를 엑세스 한다.
     : x86, 68K
   mov R0, 0x01002000   ---> 0x01002000의 주소에 있는 데이터를 R0에 읽기

- indirect address mode
     : 68K 지원, x86에서 지원 안함
   mov R0, (0x01002000) ---> operand 0x01002000가 가르키는 값이 데이터가 있는 주소값으로 하여 데이터를 엑세스
  
    예 : mov R0, (0x01002000)
    RAM의 현황
      주소          : 저장된 값
     0x01002000  : 0x01002010
     0x01002010  : 10

     코드에 있는 주소값을 0x01002000을 opreand 버퍼에 넣는다.
     operand에 있는 숫자를 주소값으로 하여 0x01002010을 읽어 operand2에 넣는다
     operand2에 있는 값 0x01002010를 주소값으로 한번 읽어 다시 값을 읽어 R0에 넣는다.이때 10일 읽혀 R0는 10이 된다

CPU 마다 어셈블리의 표현 방식은 약간 씩 다르다는 거.

C에서

int a;

라고 했다면 그것이 무엇이든 반듯이 메모리에 정수를 저장할 공간이 확보 된다는 의미이다.지역변수가 레지스터에 할당 된 경우는 예외다. 따라서 한개의 정수는 메모리에 존재하고 이것을 CPU에서 읽어 ALU을 통해 가공한 다음, 다시 메모리로 옮긴다.
포인터라는 말은 즉 메모리의 위치를 지정하는 것을 말한다. 메모리의 위치는 그림에서 처럼 Address 주소값으로 구분 된다.
예를 들어 a변수가 0x00100020에 할당(링커가 알아서 함) 되어 있다면,

  a = 10;

에서 10이라는 정수값을 a의 주소값 0x00100020에 저장한다는 의미이다.
여기에서 정수형이든 다른 형이든 변수의 값이 메모리에 존재한다는 기본 개념을 모르고서는 절대로 포인터를 제대로 이해하기 어렵다. 이해 한다고 해도 다양한 포인터 처리를 각각의 경우에 대해 처리 과정을 숙달하기 어렵다.
이 코드는 기계어

   mov R0, #0x00100020   ---> 0x00100020을 R0을 넣어라.
   mov (R0), #10            ---> R0가 가르키는 주소값(0x00100020)에 10을 넣어라.

처럼 기계어로 컴파일 된다. 물론 CPU마다 컴파일러 마다 기계어 코드는 다르지만 보통의 경우가 그렀다 라는 이야기 이다.
정리하면
- C에서 변수를 사용하면 링커에 의해 메모리에 배치 변수 영역을 확보하고 주소가 결정 된다.
- C에서 주소값 0x00100020과 숫자 10은 모두 기계어 코드 다음에 숫자가 존재 한다.
- 해당 주소에 엑세스 하려면 주소값을 레지스터로 옮기고 레지스터가 가르키는 주소에 억세스 함으로써 데이터를 옮긴다.

그런데 포인터 변수는 주소값을 취급하는 것이므로 주소값을 저장할 공간이 필요하다.

int *pidata;

라고 했다면, 위의 a변수처럼 RAM의 메모리에 링커가 할당 한다. a가 10처럼 정수가 저장 된다면, pidata는 주소값 0x00100020을 저장하는 변수이다.
따라서 CPU의 메모리를 지정하는 길이는 CPU의 구조에서 결정 된다. 32비트 CPU의 메모리는 32비트 길이이면 어느 곳이든 구별하여 엑세스 할 수 있다. 따라서 포인터 변수가 저장 해야 할 것은 주소값이므로 32비트로 고정 된다.
위의 코드
 a = 10;
은 포인터를 사용한 것이 아니다. 포인터를 사용한다는 의미는 데이터를 저장할 변수에 엑세스하기 위해 주소값 저장 처리 변수를 사용하는 것이다.

   pidata = &a;
   *pidata = 10;

여기서는 pidata 변수는 저장할 공간의 주소값을 이용한 엑세스이다.
즉 a가 배정된 메모리의 주소값 0x00100020을  pidata에 저장해 두고, 이 주소값을 이용하여 엑세스 하는 것이다.
주소값을 저장하기 위해 piata 변수가 또 다시 필요한 것이 있지만, pidata 역시 메모리의 어딘가에 있어야 한다. 링커가 0x00100024에 배정  했다면

pidata = &a;
   mov R0, #0x00100024        
   mov (R0),  #0x00100020
CPU에 따라
  mov   (0x00100024),  #0x00100020

*pidata = 10;
   mov R0, 0x00100024  -->  0x00100024번지에 저장된 주소값 0x00100020가 R0에 저장된다.
   mov (R0), #10            --->  R0에 저장된 주소 0x00100020에 10을 저장 한다.

처럼 데이터를 엑세스 할 때는 레지스터를 사용 한다.





8비트라면
변수의 위치 주소값이 16비트면 충분하다.
예를 들어 a 변수가 0x0100에 할당되었다면

   mov R0, #0x0100   ---> 0x0100을 R0을 넣어라.
   mov (R0), #10      ---> 10의 LSB 8비트 0X0A을 R0(0x0100)가 가르키는 주소값으로 해서 쓰기.
   inc  R0              ---> 10의 상위 8비트를 쓰기 위해 주소값을 1 증가 한다.
   mov (R0), #0        ---> 10의 상위 8비트 0X00을 R0(0x0101)가 가르키는 주소값으로 해서 쓰기.

이렇게 32비트와 다르고 속도에도 차이가 난다.

포인터 변수의 길이

포인터는 메모리를 가리키는 변수이므로, 메모리의 주소를 저장할 수 있는 공간 이면 된다. 그래서 모든 포인터는 길이가 같으며,  값은 메모리 번지 값이다. 그런데 이 번지는 cpu 마다 다르다.
     - Z80 (8bits)   - 16bits
     - 68000계열    - 32bits
     - 80x86(IA32)  - 32bits
          ...
따라서 내가 현재 어느 CPU에서 프로그램 하는냐에 따라 변수 공간은 위와 같다. 즉 Z80계열로 프로그램 한다면 어느 변수의 위치를 가르키려면 16bits 공간이 필요하고 이 내용은 메로리 번지 값이다.
이 길이를 확인하기 위해 sizeof을 사용 한다.
   struct Man {
    char name[40];
    int age;
   } ;

   struct Man man;
   int iv;
     int leng;
     leng = sizeof( int );
     leng = sizeof( int *);
     leng = sizeof( struct Man);
     leng = sizeof( struct Man *);
      ...

정리 :
포인터는 데이터가 있는 위치를 가르키는 변수. 이것을 위해 2가지를 생각해야 한다.
  - 포인터 변수가 데이터가 있는 위치를 가르켜야 하기 때문에 실제 데이터가 들어 갈 변수가 필요
     따라서 포인트변수 만을 가지고는 의미가 없고 데이터를 저장할 수 없다.
     데이터를 취급하는 변수나 하드웨어의 레지스터가 존재해야 하고, 이 주소값을 포인터 변수에 저장하여 위치를 가르키도록 설정.
     다른 변수 타입의 저장도 되고 처리가 가능 - typecast.
  - 포인터 변수의 처리 방식은 데이터의 위치를 CPU의 메모리 주소값으로 처리.
     변수나 기타 하드웨어의 레지스터는 CPU의 address로 표현 된다.
     따라서 포인터 변수는 모두 주소값을 취급하는 것으로 CPU의 메모리 공간에 따라 길이와 처리 방식이 결정된다.
     CPU가 결정 되면 CPU에 의존하여 길이가 결정되고 한개의 CPU내에서도 메모리 공간에 따라 길이가 달라 질수 있다.
     8비트 CPU(8080, Z80) - 16비트 어드레스 방식 - 포인터 길이 2바이트 sizeof(int*) = sizeof(char*) = sizeof(void*)=...= 2
     8비트 MCU 계열(8051, SAM8, ...)  - 8비트와 16비트 혼용 - 메로리 공간에 따라 1 또는 2바이트 - 컴파일러에 공간 할당 옵션 검토.
     32비트 CPU (x386, 68000, ARM,...) - 32비트 주소공간 사용 - 4바이트 sizeof(int*) = sizeof(char*) = sizeof(void*)=...= 4

다른 타입변수 처리 예 :
      char g_data[1024];

      char *fun(void *data)
      {
           char *pdata;
           pdata = (char*) data;
            ...
           return pdata;
      }
      int *procdata(void *data, int maxlength)
      {
           int *pdata;
           pdata = (int*) data;
            ... // integer 처리
           return (int*) data;
      }
      int main()
      {
            memset(g_data, 0, sizeof(g_data) );
            char *pdata = fun((void*)g_data);
            . . .
            int *pidata = procdata((void*)g_data, sizeof(g_data)/sizeof(int)  );
             . . .
      }

하드웨어의 레지스터 사용 예 : ARM (삼성)
   #define rULCON0     (*(volatile unsigned *)0x50000000) //UART 0 Line control
   #define rUCON0      (*(volatile unsigned *)0x50000004) //UART 0 Control

   void InitUART()
   {
        //UART0
        rULCON0 = 0x3;   //Line control register : Normal,No parity,1 stop,8 bits
         . . .
    }

8비트와 16비트 혼용 예 :
   8051 : C 컴파일러 마다 주소공간 표현 방법은 틀림. 그러나 같은 CPU라면 어느 메모리 공간이든 표현 방법이 다 있음.

  #define  L7SEG0 (*(unsigned char*)(0x2fc40))  - 16비트 주소 공간
  #define  L7SEG1 (*(unsigned char*)(0x2fc42))
  #define  L7SEG2 (*(unsigned char*)(0x2fc44))
  #define  L7SEG3 (*(unsigned char*)(0x2fc46))

  char g_ledData[4];  -- 8비트 주소 공간
  char g_key;  ---------> sizeof(g_key)=1

  void DspLED()
  {
      char *pledfont;  -----------------> 8비트 주소 공간 이므로 이것의 sizeof(pledfont) = 1

       pledfont = g_ledData;
    
       L7SEG0 = *pledfont++;  
       L7SEG1 = *pledfont++;  
       L7SEG2 = *pledfont++;  
       L7SEG3 = *pledfont;  
   }

   Keil Complier :
   #define LEDPORT0  *(unsigned char xdata*) 0xc0000 - 외부 메모리 설정
    xdata char gGrapLedData[1024];  -- 외부 메모리 설정 - 16비트 주소 공간
    xdata char *pxled;   --> sizeof(pxled)=2 : 외부 메모리 설정 - 16비트 주소 공간
    char g_ledData[4];                    -- 내부 메모리 설정 - 8비트 주소 공간
    char *pledfont;         --> sizeof(pledfont)=1 : 내부 메모리 사용 - 8비트 주소 공간

       LEDPORT0 = *pledfont++;  

32비트 포인터 개념화


32bits CPU을 기준으로 다음과  같은 프로그램 예를 생각해 보자.
    int a;
    int *pa;

   int main()
   {
     pa = &a;
     *pa = 10;
      . . .
   }

int a int *pa는 글로벌 변수인 경우 변수 영역에 보통 연속적으로 linker에 의해 할당된다. 이것은 링커가 알아서 하는 것인데, 배치 순서도 선언된 순서의 반대로 배치하는 경우도 있고, 그 반대도 있다.

지역변수 라면 CPU의 레지스터 또는 STACK에 할당 된다.

예를 들어 RAM 주소 0x30001000 부터 할당 되었다면 위와 같이 된다.
 


- pa = &a; 하면 다음과 같다.



- *pa = 10; 하면 다음과 같다.


그림을 메모리 위주가 아니라 개념위주로 바꾸면


포인터에 관해 자신이 없는 사람은 머리 속으로 박스와 화살표를 그리면서 프로그램 하면 좀더 효과적으로 익힐 수 있다.

그러면 CPU 입장에서 구체적으로 심화 학습하면

    int a;
    int *pa;

위의 2개 변수는 CPU 입장에서 기계어로 변환 돼면서 변수가 있는 메모리를 의미 한다. 변수 영역은 linker section 중에서 data 영역이다. 말이 어려운데 다음 그림을 보고 생각 하자. 여기서 보통 32비트 CPU라도 명령어의 길이는 보통 16비트이나 이것은 가상의 CPU 이므로 주소값 계산을 쉽게 하기 위해 명령어를 32비트로 가상으로 잡았다.(좀 헤갈리즘...)



Data setction에서 정수 변수가 정의 되는 것은 컴파일러가 주소의 크기를 결정하고 linker가 구체적인 주소 값을 결정 한다.
C 언어의 표현으로 보면 ‘int a;' 이지만 컴파일러에 의해 기계어가 되면 메모리 0x30001000번지에 정수 값이 들어 감을 나타낸다. int *pa’역시 0x30001004에 정수가 들어 있는 위치를 가르키도록 주소 값이 저장 됨을 의미 한다. 프로그램 실행 과정을 살펴 보자.
        ;  pa = &a;
MOV  R0, #30001000H
MOV  30001004H, R0

이 프로그램이 실행 되기 위해 2개의 명령으로 구성되어 실행 된다. 물론 CPU와 컴파일러 마다 조금 씩은 다르지만…

&a’의 의미는 정수가 저장 될 공간 0x30001000 주소를 의미 한다. 그래서 기계어로 바뀌면 모두 ‘int a’등의 사람이 인식하는 언어는 없어지고 메모리 주소 어디를 의미할 뿐이다. 명령이 2개 이므로 2번의  기계어 코드가 수행 된다.

 MOV  R0, #30001000H
-         0x30003004 주소의 기계어 코드 0x63745342를 읽어  해석 한다.
-         해석 결과 다음 코드가 주소 값임을 알고  0x30003008 주소의 0x30001000 값을 읽어 레지스터 R0에 저장 한다.

MOV  30001004H, R0   - direct address mode
-         0x3000300C 주소의 기계어 코드 0x63745669를 읽어  해석 한다.
-         다음 코드 값이 주소임을 알고 다음 주소 0x30003010에서 0x30001004값을 읽는다.
-         0x30001004 번지에 레지스터 R0 값을 보낸다.

따라서 ‘int a;’는 실제로 기계어 코드에 주소가 고정되어 실행 되고 이 주소값에 의해 모든 자료가 이동 저장 된다.

     ; *pa = 10;
      MOV R0, #30001004H 
      MOV R0, 0000000AH  - R0에 있는 값이 주소 값으로 하여 이 주소에 10을 넣는다.

프로그램에서 정수 값 10은 역시 컴파일러에 의해 0x0000000A값으로 변환 되어 프로그램 코드에 붙는다. 이상은 실제로 개념적인 것이고 기계어로 바꿔어 지는 양상은 실제로 차이가 있다. CPU 및 컴파일러에 따라 각각 다르다는 것이다.


Intel X86 CPU에서 포인터 개념 분석하기

위의 개념은 실제하지 않는 CPU을 개념화 하여 나타낸 것이다. 따라서 포인터의 개념을 생각하기 위해서 가상의 개념을 보았다. 이번에는 실제의 PCintel CPU에서 Visual C++6.0으로 컴파일 하여 생각해 보자.

이것은 실제로 CPU의 기계어 개념까지를 알면 쉽지만 CPU에 익숙하지 않은 개발자는 어려울 것이다. 그러나 CPU을 기반으로 하는 C언어는 통상 기계어와 고수준 언어의 중간이라는 이야기에 옛날 부터 내려온 이야기라는 거...

C 프로그램으로 다음과 같이 코딩 한다.
int a;
int *pa;

void fun1(void)
{
   pa = &a;
   *pa = 0x12345678;

}

그리고 Visual C++에서 소스 만들기 옵션을 선택하면 다음과 같은 어셈블리가 만들어 진다. 실제로는 변수의 표기법이 복잡하지만 약간은 수정하여 보기 좋겠 하였다.

PUBLIC   _a                                     ; a
PUBLIC   _pa                                   ; pa
_BSS      SEGMENT
_a          DD         01H DUP (?)          ; a
_pa      DD         01H DUP (?)          ; pa
_BSS      ENDS
PUBLIC   _fun1                                 ; fun1

;            COMDAT _fun1
_TEXT    SEGMENT
_fun1 PROC NEAR                           ; fun1, COMDAT

;   pa = &a;
  00000   c7 05 00 00 00 00 00 00 00 00   mov  DWORD PTR _pa, OFFSET FLAT: _a  ; pa, a

 ; *pa = 0x12345678;
  0000a    c7 05  00 00 00 00  78 56 34 12     mov     DWORD PTR _a, 305419896 ; a, 12345678H

; return
  00014   c3          ret          0

_fun1    ENDP     ; fun1
_TEXT    ENDS

여기서의 실제 실행 시의 주소를 표시하면

위의 C에 포인터 주소값을 얻기 위해 다음과 같은 구조로 함수를 추가 하여 출력하면.
void printvar()
{
   printf("&a = 0x%08X\n", &a);
   printf("&pa = 0x%08X\n", &pa);
   printf("pa = 0x%08X\n", pa);
   printf("fun1= 0x%08X\n", (int) fun1);
   return;
}
실행결과:
&a = 0x0040991C
&pa = 0x00409918
pa = 0x0040991C
fun1= 0x00401010

자 이제 기계어 코드를 분석하자.

다음의 기계어는

00000   c7 05 00 00 00 00  00 00 00 00    mov  DWORD PTR _pa, OFFSET FLAT: _a  ; pa, a
0000a   c7 05 00 00 00 00  78 56 34 12    mov     DWORD PTR _a, 305419896 ; a, 12345678H
00014   c3                                               ret          0
는 실제로 실행되기 위한 주소값은 없는 00 00 00 00으로 표기 되어 있다. 어셈블로 소스를 만들면 컴파일 과정에는 실제 주소값을 모르므로 00 00 00 00으로 표기 된 것이다. 이것은 실제로 링커에 의해 주소값이 결정된다. 따라서 위의 코드로 실행되는 것이 아니라 링커에 의한 주소가 결정된 다음과 같은 상태로 실행 된다.

실행결과를 고려하여 주소값을 표시하면

코드주소     명령어  operand1       operand2
00401010  c7 05   18 99 40 00  1C 99 40  00   mov  DWORD PTR _pa, OFFSET FLAT: _a  ; pa, a
0040101a  c7 05  1C 99 40 00  78 56 34 12     mov  DWORD PTR _a, 305419896 ; a, 12345678H
00401024  c3                                                  ret          0

와 같이 link되어 실행 된다. 다시 하면 말하면 실제 주소는 link 시 결정된다.

그런데 위의 소스 코드는 여기서 이야기 하려는 기계어로 이해하기 위한 포인터의 예로써는 일 부분은 최적화로 인해 적절한 코드가 생성되지 않았다. 어째든.  이것은 실제
 
  pa = &a;
00401010  c7 05   18 99 40 00  1C 99 40  00   mov  DWORD PTR _pa, OFFSET FLAT: _a  ; pa, a
로 바뀌었고,
mov 명령은 다음과 같은 과정으로 실행된다.
  - 기계어 코드인 0x05C7을 읽고 이것이 mov 명령임을 해석 한다.
  - 변수 a의 주소값 0x0040991C을 CPU의 operand 버퍼에 넣는다.
  - 다시 operand의 버퍼의 내용을 변수 pa의 주소값인 0x00409918 번지에 전송 한다.

여기서 포인터 개념 측면에서 주의 깊게 생각해야 할 문제가  있다.
우선 C에서의 포인터는
 - CPU의 코드에 의해 주소값으로 어느 위치를 가르킨다.
 - 포인터는 CPU의 주소값으로 코드 내에 존재한다.
    변수 a가 존재하는 위치는  0x0040991C 이고
    변수 pa는 0x00409918 번지에 존재 한다.
    이것은 모두 코드 내에 주소값이 존재한다는거 강조 한다.   

 *pa = 0x12345678;에 해당되는
0040101a  c7 05  1C 99 40 00  78 56 34 12     mov  DWORD PTR _a, 305419896 ; a, 12345678H
는 실제로
 a = 0x12345678;
와 같은 최적화 코드가 생성 되었다. 따라서 여기서의 변수 pa 포인터 개념은 없다.


그래서 다시 포인터를 제대로 이해 하기 위해 최적화를 고려하여
코딩을 바꾸면

volatile int a[10];
volatile int *pa;
void printvar()
{
   printf("&a[0] = 0x%08X\n", &a[0]);
   printf("&pa = 0x%08X\n", &pa);
   printf("pa = 0x%08X\n", pa);
   printf("fun= 0x%08X\n", (int) fun1);
   return;
}
void fun(int cnt)
{
   pa = a;

   while (cnt) {
      *pa = cnt;
       pa++;
       cnt--;
   }
}

======================================
실행
&a[0] = 0x0040DF04
&pa = 0x0040DF00
pa = 0x0040DF2C
fun= 0x00401060
======================================

PUBLIC    _a                    ; a
PUBLIC    _pa                    ; pa
_BSS    SEGMENT
_a DD    0aH DUP (?)                ; a
_pa DD    01H DUP (?)                ; pa
_BSS    ENDS
PUBLIC    _fun                      ; fun
; Function compile flags: /Ogtpy
; File d:\blog\vint\vint.cpp
;    COMDAT _fun 

_TEXT    SEGMENT
_cnt$ = 8                        ; size = 4

_fun         PROC                    ; fun, COMDAT

00401060  8b 44 24 04                   mov     eax, DWORD PTR _cnt$[esp-4] -> 함수를 호출할 때 인수를 스택에 넣어 넘긴다.

;        pa = a;
00401064  c7 05 00 DF 40 00  04 DF 40 00  mov  DWORD PTR _pa, OFFSET _a ; pa, a  -> a의 주소값을 pa 변수영역에 저장
;   :    while (cnt) {  --------------------------------------> while - 초기 조건 체크0040106e  85 c0                            test     eax, eax
00401070  74 22                            je     SHORT $LN1@fun

00401072  eb 0c 8d a4 24  00 00 00 00 eb 03 8d 49 00     npad     14

;        while (cnt)  --------------------------------------> while - loop start$LL2@fun:

;             *pa = cnt;
00401080  8b 0d 00 DF 40 00          mov     ecx, DWORD PTR _pa ; pa
00401086  89 01                              mov     DWORD PTR [ecx], eax 

;             pa++;
00401088  83 05 00 DF 40 00  04     add     DWORD PTR _pa, 4    ; pa

;             cnt--;                                    -----------------> cnt-- & while - check- 계산0040108f   83 e8 01                         sub     eax, 1

;      --------------------------------------------------> while - check-조건 jump00401092 75 ec                               jne     SHORT $LL2@fun

;        }
$LN1@fun:

;    }
00401094   c3         ret     0

_fun            ENDP                    ; fun
_TEXT    ENDS

포인트와 관련 된 코드는
;        pa = a;
00401064  c7 05 00 DF 40 00  04 DF 40 00  mov  DWORD PTR _pa, OFFSET _a ; pa, a  -> a의 주소값을 pa 변수영역에 저장
이것은 위에서 언급한 것이다.

실행 과정은 다음과 같다.


실행과정 : 위의 mov 명령 실행  (A : address bus D:Data Bus, PC: Program Counter)
- Machine Cycle : fetch : 메모리 명령어 16비트 읽기 - PC=0x00401064->A=0x00401064 => D=0x05C7 , PC= 0x00401066
- Machine Cycle : decode - mov 명령 해석
- Pass1 : operand1 32비트 읽기 - PC=0x00401066->A=0x00401066 => D=0x0040DF00, PC= 0x0040106A
- Pass2 : operand2 32비트 읽기 - PC=0x0040106A->A=0x0040106A => D=0x0040DF04, PC= 0x0040106E
- pass3 : 값 저장    32비트 쓰기 - operand1 -> A= 0x0040DF00, operand2 -> D=0x0040DF04 => 메모리 값 변경

잠시 쉬어가기
이 명령 하나 실행하는데 복잡한 과정을 거친다. 원래 X86 CPU는 CISC 기반 이므로 명령 하나가 실행되는데 복잡한 과정을 거치는 기계어 코드가 있다. ARM 같은 RISC 라면 이렇게 복잡한 명령은 없다. RISC는 명령어와 하드웨어가 단순화 되어있기 때문이다.그렇지만 CISC 명령하나는 때로 RISC 명령 2개나 3개가 필요할 때가 있다.
어째든지 간에 RISC든 CISC든 CPU에서 pa, a등의 주소값(0x0040DF00, 0x0040DF04)은 분명 명령 코드 내부에 있다는 것은 확실 하다. 32비트 operand가 2개의 명령으로 쪼개저서 존재하는 경우도 있지만...
MPU : Micro-Processor Unit
CPU : 중앙처리장치 - 영어 생각 안남.

실행 결과는 다음...



이때의 구조는 그림과 같다. 포인터는 우선 그림으로 보면 다른 위치를 지정하는 것으로 
mov 명령으로 pa에 주소값이 넣어 진것이다. 그림에서 빨간색 부분의 주소이다.
여기서 pa가 존재하는 위치 0x0040DF00과 a[0] ~ a[9]까지의 주소값 0x0040DF04~0x0040DF2C는 링커가 주소값을 결정한 것이고 처리는 mov 명령 처럼 코드 내에 주소값이 존재 한다.


다음 코드의 단계로 넘어가자.

;             *pa = cnt;
00401080  8b 0d 00 DF 40 00          mov     ecx, DWORD PTR _pa ; pa
00401086  89 01                              mov     DWORD PTR [ecx], eax
cnt 변수는 CPU의 레지스터 EAX에 할당되어 있다.

단계적으로 보면
  mov     ecx, DWORD PTR _pa
  - pa의 포인터값 0x0040DF00 주소값을 레지스터 operand1 임시레지스터에 저장한다.
  - operand1 0x0040DF00 주소값이 가르키는 메모리의 포인터 값을 읽어 ECX에 저장한다. ECX= 0x0040DF04로 변한다.
 mov     DWORD PTR [ecx], eax
  - ECX가 가르키는 주소 0x0040DF04에 cnt변수처리 레지스터 EAX의 값을 전송 한다.

만약 함수를 호출 할 때 cnt=10 일 경우, a[0] <= 10 처럼 넣어진다.

처리 과정을 그림으로 표시하면 다음 그림과 같다.





 

 결과를 포인터 위주로 그리면 다음 그림과 같다.







다음 단계

;             pa++;
00401088  83 05 00 DF 40 00  04     add     DWORD PTR _pa, 4    ; pa
- int 어래이의 다음에 값을 조작하기 포인터를 한 단계 증가 한다.

  이 때의 계산 방식은
   pa의 주소값 + sizeof (int) = 0x0040DF04 + 4 = 0x0040DF08 
  
보통 sizeof(int)는 8비트 CPU(8051,AVR, ...)에서는 2이다. 그러나 32비트의 CPU는 4이다.

결과는 다음 그림과 같다.




이제는 다음 단계를 처리 하기 위해 while을 계속 진행 한다.

포인터의 지역변수

그런데 이번에는 포인터 변수를 지역변수로 사용 한다면 어떻게 될것인가?
보통 포인터 뿐만 아니라 보통의 지역변수는 최적화를 위해 레지스터에 할당 한다. 그러나 함수내에 지역변수의 숫자가 많다면 어쩔수 없이 스택에 저장 될 것이다.

포인터 역시 마찬가지로 pa 변수를 지역변수로 선언하면 레지스터에 할당 될 것이다.

int a[10];

void fun(int cnt)
{
  int *pa;

   pa = a;
   while (cnt) {
      *pa = cnt;
       pa++;
       cnt--;
   }
}

PUBLIC    _a                    ; a
_BSS    SEGMENT
_a DD    0aH DUP (?)                ; a
_BSS    ENDS
PUBLIC    _fun                    ; fun
; Function compile flags: /Ogtpy
; File d:\blog\vint\vint.cpp
;    COMDAT _fun
_TEXT    SEGMENT
_cnt$ = 8                        ; size = 4
_fun PROC                    ; fun, COMDAT

; 12   :   int *pa;
; 13   :
; 14   :    pa = a;
; 15   :
; 16   :    while (cnt) {

  00000    8b 4c 24 04     mov     ecx, DWORD PTR _cnt$[esp-4]  -> 인수 cnt 가저오기
  00004    b8 00 00 00 00     mov     eax, OFFSET _a    ; a     ----->  pa변수가 레지스트 EAX활당하고 초기 a 변수 주소값 넣기
  00009    85 c9         test     ecx, ecx+                              -----> while의 처음 cnt가 조건 계산, cnt 레지스터 ECX에 할당
   -   
  0000b    74 0d         je     SHORT $LN1@fun              ---------> while의 처음 cnt가 참인지 체크
  0000d    8d 49 00     npad     3

$LL2@fun:                                                              ----------> while loop

; 17   :       *pa = cnt;
  00010    89 08         mov     DWORD PTR [eax], ecx   -----> pa변수가 EAX에 할당되어 있으므로 간접 엑세스 모드로 cnt 값 넣기-a[cnt]에 넣기

; 18   :        pa++;
  00012    83 c0 04     add     eax, 4        ----> pa변수가 EAX에 할당되어 있으므로 포인터 증가

; 19   :        cnt--;

  00015    83 e9 01     sub     ecx, 1                                     ----------------------> cnt++  동시에 while 조건 계산
  00018    75 f6         jne     SHORT $LL2@fun         ----------------------> 위에서 계산 된 while 조건을 가지고 jump,
$LN1@fun:

; 20   :    }
; 21   : }

  0001a    c3         ret     0

_fun ENDP                    ; fun
_TEXT    ENDS

int cnt;  - 레지스터 ECX에 할당
int *pa; - 레지스터 EAX에 할당
pa 변수가 전역변수라면 이 변수를 다른 함수에서 사용할 수 있다. 따라서 이것을 위의 코드 처럼 블럭 안에서 레지스터에 할당 사용한 다음 변수를 없애서 폐기 할 수 없다. 이 문제는 다음 코드에서 생각 할 수 있다.

int a[10];
int *pa;

void initfun(int cnt)
{
    ...
   pa = a;
  ....
}

int sum(int leng)
{
  int sum;
    for(cnt = 0;cnt < leng;cnt++)
      sum += pa[cnt];
    return sum;
}
int main()
{
    initfun();
    printf("a[all] sum = %d\n", sum(10));
    return 0;
}
위의 코드 처럼 pa가 여러 함수에 사용 할 수 있어 이런 경우는 당연히 pa의 포인터 값이 변할 때 마다 변수 영역에 넣어 주어야 한다.

참고로
    int sum;
    int *pa;
  
      sum += pa[cnt];
      sum += *(pa+cnt);
이 두 코드는 같은 것이다. 이것은 컴파일러가 같은 기계어 코드를 만들 가능성이 높다.

주소값 변환 과정 : pa의 현재 주소값 + cnt * sizeof(int) -> 이 이야기는 매번 디이터의 포인트 주소값을 계산 한다거...
따라서 *pa++와는 속도 차이가 많이 난다.

for (cnt = 0;cnt < 10;cnt++)
      sum += *(pa+cnt);     -> pa의 현재 주소값 + cnt * sizeof(int) ===> 이것은 sum += a[cnt]와 같다.
   <===>
for (cnt = 0;cnt < 10;cnt++)
      sum += *pa++;          -> pa의 현재 주소값 + sizeof(int)
는 같은 기능을 하지만 각 루프에 대한 주소계산 방식은 많은 차이가 난다.

리얼타임 프로그램에서는 주의 깊게 프로그램 해야 한다. 전자쟁이가 프로그램 할 때, 거의 가끔 critical 한 시간에 걸릴 때는 어셈블리를 사용할 수도 있지만, C을 잘 구현하면 해결될 때가 있으니... C의 구조를 완벽하게 안다면 이런 극한 상황을 제어 할 수 있을 것이다. 그래서 생각나는 명령 goto loop;...

포인터 고정된 주소값 사용하기

그러면 실제로 포인터 변수는 다른 변수의 위치만을 지정 할수 있을 뿐만 아니라 고정된 주소값도 가능 하다.
다음 프로그램을 생각해 본다.

void fun2(void)
{
   pa = (int*) 0x10203040;  ->  0x10203040 주소를 지정 한 경우
   *pa = 0x12345678;         -> 0x10203040 번지에 0x12345678값을 넣어라.
}

; Visual Studio 6.0 PC intel x86 32비트 코드
_TEXT    SEGMENT
?fun2  PROC NEAR                    ; fun2, COMDAT

;    pa = (int*) 0x10203040;
  00000    b8 40 30 20 10     mov     eax, 270544960        ; 10203040H
  00005    a3 00 00 00 00     mov     DWORD PTR ?pa, eax ; pa  -> pa 주소값 00 00 00 00 => 0x00409918 (18 99 40 00)로 링크

;   *pa = 0x12345678;
  0000a    c7 00 78 56 34 12         mov     DWORD PTR [eax], 305419896 ; 12345678H

; }
  00010    c3         ret     0

?fun2 ENDP                    ; fun2
_TEXT    ENDS

이렇게 실제로 특정 주소값을 지정 가능 하다. 그렇다고 아무데나 다 사용할 수 있을까?
보 통 OS에서는 이렇게 지정할 수 없다. 물론 DDk에서는 가능한 일이지만. 이런경우 CPU을 직접 다루는 시스템 프로그램에서 가능한다. ARM에서 보드를 만들고 특정 기능의 전자 장비를 만든다는가 하는 경우이다. PC의 윈도우나 리눅스의 경우의 OS에서 Device Drivers라는 프로그램에서도 역시 가능하다.

링커에 의해 결정된 주소값으로 바꾸어 실제 코드를 보면
00401030  b8 40 30 20 10        mov     eax, 270544960        ; 10203040H
00401035  a3 18 99 40 00        mov     DWORD PTR ?pa, eax ; pa
0040103a  c7 00 78 56 34 12   mov     DWORD PTR [eax], 305419896 ; 12345678H
00401040  c3                           ret     0

실행 순서는
1. 우선 주소값 0x10203040을 CPU의 EAX 레지스터에 넣는다..
2. CPU의 EAX 레지스터의 내용을 pa의 변수에 넣는다.
3. EAX가 가리키는 위치(pa 변수값과 같음)에 숫자 0x12345678 넣는다.
   -> 이 코드에서 과정은 실제로 *pa가 사용되는 대신 EAX의 주소값을 사용 하므로써 최적화 되었다.

포인터의 null

다음과 같은 프로그램을 생각해 보자.

   int *pa;

   void main()
{
      *pa = 10;
   }

위의 프로그램에서 ‘*pa = 10;’은 문제를 이르킨다. 우선 C에서는 pa가 가르키는 메모리 주소값이 없기 때문에 10을 어디에 넣을지가 문제가 된다. c에서는 변수 pa의 포인터 공간에 어떤 어떤값이 들어갈지 모른다. 그런데 RAM은 어떤 값이든 들어 있기 때문에 CPU가 어느 어드레스건 access 하려고 한다. 재수 좋게 문제가 없는 사용하지 않는 공간에 설정이 될수 있겠지만, 대부분은 문제의 영역에 access함으로써 CPU가 프로그램 동작을 예측하기 힘들게 된다.
만약 Windows나 리눅스 처럼 OS가 있는 컴퓨터 같으면 OS가 설정한 리소스내에서 access가 이르어 지면 문제가 없지만 자기가 access할 수 없는 영역에 10을 넣으려는 시도가 된다면 바로 현재의 프로세서가 멈추어 버린다. 이에 비해 CPU가 직접 설계하여 제작한 경우라면 CPU가 설정되어 있지 않은 메모리에 access하려고 시도하면 exception(trap)이 결려 (소프트웨어 인터럽트) CPU가 멈추거나 인터럽트 서비스 루틴이 실행 된다. 이 때의 인터럽트의 종류는 'Bus Access Error' 이다. 자바의 경우는 defaultnull값이 설정되어 있으나 C에서는 그렇지 않다는 이야기 이다.
이프로그램의 경우 10이 들어갈 메모리가 확보 돼지 않은 상태가 된다. 따라서 실제의 정수값을 넣기 위해서는 반드시 이 값이 들어갈 집이 필요한 셈이다.

자바처럼 변수 panull의 값을 넣기위해서는 어떻게 해야 할까?

   pa = NULL;
   pa = (int*) 0;

비교 역시 가능하다.

   If (pa == NULL)
      pa = &a;

위 와 같이 비교가 가능한데 정수 값의 비교와 포인터의 비교는 어떻게 다른가? 이것은 CPU의 주소체계와 관련이 있다. 보통 32비트 이상의 CPU는 주소값의 한계가 32비트 체계를 사용한다. 그런데 비교를 위해서는 CPU의 레지스터와 ALU(연산유닛)과 관계가 있다. 비교는 ALU 계산을 통한다는 것은 다른 문서 '정수형 변수 int' 언급했다. 이 과정이 포인터 역시 적용 된다. 

pa == NULL은 
1. pa의 주소값을 CPU의 레지스터로 옮긴다.
2. 위의 레지스터 값과 0을 비교한다. 이것은 레지스터와 기계어 코드 다음값이 NULL(0)의 값과 비교 한다. 이이 때 역시   CMP 기계어 명령을 사용하고 결과 값은 버리고 CPU의 FLAG에 연산결과 나타난 상태값을 기록 한다.

x386에서는

;   if (! pa) {
     cmp    DWORD PTR _pa, 0        ; pa
     jne    SHORT $LN1@fun
      . . .
;   }
$LN1@fun:
    . . .
이 예에서 보여 주는 것은 포인터를 다루는 주소값도 정수 int 처럼 ALU로 계산하여 사용 한다. 비교, 연산 등이 모두 가능 한 것이다.

#define SZ_DATA 100

 int idata[SZ_DATA];
 int *startdata;
 int *lastdata;

 void cleardata()
     int *pdata;
     pdata = startdata;
     while (pdata <= lastdata)
          *pdata++ = 0;
}

 int sum()
 {
     int sum;
     int *pdata;
     pdata = startdata;
     sum = 0;
     while (pdata <= lastdata)
          sum += *pdata++;
    
     resurn sum;
 }

 int main()
 {
     startdata = idata;
     lastdata =  startdata + SZ_DATA;  ---> startdata가 가지고 있는 주소값 + sizeof(int)*SZ_DATA, 32비트 CPU에서 sizeof(int)=4
                                                           만약 startdata가 0x00401000이라면(idata가 0x00401000부터 배치 되었있다면.
                                                           lastdata = 0x00401000 + 4*100 = 0x00401000 + 400 = 0x0040119
     . . .
     printf("sum = %d\n", sum());
     return 0;
 }
이 예에서
     while (pdata <= lastdata)
이것은 두 주소값을 비교한 것이다. 데이터인 int  정수를 비교하는 것 처럼, CPU ALU을 통해 주소 값도 계산을 하여 비교 한다.



포인터 이야기는 다음으로 넘어 갑니다...

 http://blog.naver.com/dolicom/10066545399

크리에이티브 커먼즈 라이선스

댓글 없음:

댓글 쓰기