728x90

이진 탐색 트리, AVL 트리

 

이진탐색트리, AVL트리

이진탐색트리는 binary search와 linked list를 결합한 자료구조이다. 이진 탐색의 효율적인 탐색 능력을 유지하면서도, 빈번한 자료 입력과 삭제를 가능하게끔 고안되었다. 이진탐색의 경우 탐색에

the-ze.tistory.com

 

레드 블랙 트리 시뮬레이터

 

Red/Black Tree Visualization

 

www.cs.usfca.edu

 

레드 블랙 트리는 이진 탐색트리의 한 종류로써, 스스로 균형을 잡는 트리이다. BST의 worst case의 단점 O(N)을 개선했다. 이 레드 블랙 트리에는 몇 가지 규칙들이 있다.

 

1. 모든 노드는 red 혹은 black

2. 최상위 루트 노드는 black

3. 모든 nil(leaf) 노드는 black

4. red의 자녀들은 black (red가 연속적으로 존재할 수 없다.)

5. 임의의 노드에서 자손 nil 노드들까지 가는 경로들의 black 수는 같다. (자기 자신은 카운트에서 제외)

 

여기서 nil 노드란?

1. 존재하지 않음을 의미하는 노드

2. 자녀가 없을 때 자녀를 nil 노드로 표기 (nil이 있다면 자녀가 없는 것을 의미)

3. 값이 있는 노드와 동등하게 취급

4. RB트리에서 leaf노드는 nil 노드

5. 모든 nli 노드는 black

 

임의의 노드에서 자손 nil 노드들까지 가는 경로들의 black 수는 같다. (자기 자신은 카운트에서 제외)

 

RB 트리가 위 속성을 만족하고 있고 두 자녀가 같은 색을 가질 때 부모와 두 자녀의 색을 바꿔줘도 속성은 여전히 만족한다.

부모와 자식의 색을 바꿔도 규칙에 어긋나지 않는다.

RB트리는 주로 삽입/삭제할 때 균형을 맞춘다.

 

red의 자녀들은 black , red가 연속적으로 존재할 수 없다.

임의의 노드에서 자손 nil노드들까지 가는 경로들의 black의 수는 같다.

이 둘을 위반하며 이들을 해결하려고 구조들 바꾸다 보면 자연스럽게 트리의 균형이 잡히게 된다.

 

삽입 방식

1. 삽입 전 RB트리 속성 만족한 상태

2. 삽입 방식은 일반적인 BST와 동일

3. 삽입 후 RB트리 위반 여부 확인

4. RB트리 속성을 위반했다면 재조정

5. RB트리 속성을 다시 만족

 

삽입하는 노드의 색깔은 항상 red이다.

node_t *rbtree_insert(rbtree *t, const key_t key)
{
  // TODO: implement insert
  // 적절한 위치 찾는 코드
  // 값 삽입
  node_t *SENTINEL_NODE = t->nil;
  node_t *parent_node = t->nil;

  node_t *new_node = malloc(sizeof(node_t));
  new_node->key = key;
  new_node->right = SENTINEL_NODE;
  new_node->left = SENTINEL_NODE;
  new_node->color = RBTREE_RED;

  node_t *cur_node = t->root;
  while (cur_node != SENTINEL_NODE)
  {
    parent_node = cur_node;
    if (cur_node->key > key)
    {
      cur_node = cur_node->left;
    }
    else
    {
      cur_node = cur_node->right;
    }
  }
  new_node->parent = parent_node;

  if (parent_node == SENTINEL_NODE)
  {
    t->root = new_node;
  }
  else if (new_node->key < parent_node->key)
  {
    parent_node->left = new_node;
  }
  else
  {
    parent_node->right = new_node;
  }

  rbtree_insert_fixup(t, new_node);
  return new_node;
}

 

일반적인 삽입만 했다면, 맨 위의 5가지 규칙이 깨지지 때문에 이를 규칙에 맞게 다시 재조정할 필요가 있다.

 

위의 시뮬레이터로 계속 삽입을 하면 보이다 싶이, 결국 black 노드가 많아지는 것을 볼 수가 있다.

 

위 사진에서 insert(25)를 해보자.

 

그럼 30의 좌측 자식 red노드로 처음엔 삽입될 것 이다. 이는 5가지 규칙 중 노드가 red라면 자녀들은 black을 위반한 상태이다. 부모와 삼촌을 black으로, 할아버지는 red로 변경하고 할아버지인 35에서 확인해보자 50 > 35가 레드가 되니 노드가 red라면 자녀들은 black을 위반한 상태이다. 그러면 50을 기준으로 오른쪽으로 회전해보자

 

 

그러면 또 35와 50을 보면 문제가 생기는데, 20과 35의 색깔을 바꿔주고 20을 기준으로 왼쪽으로 회전하면 된다.

 

 

구현할 때, 이런 재조정에 신경을 쓰며 구현을 해야한다.

 

void rbtree_insert_fixup(rbtree *t, node_t *p)
{
  while (p->parent->color == RBTREE_RED)
  {
    if (p->parent == p->parent->parent->left)
    {
      node_t *y = p->parent->parent->right;
      if (y->color == RBTREE_RED)
      {
        p->parent->color = RBTREE_BLACK;
        y->color = RBTREE_BLACK;
        p->parent->parent->color = RBTREE_RED;
        p = p->parent->parent;
      }
      else
      {
        if (p == p->parent->right)
        {
          p = p->parent;
          left_rotate(t, p);
        }
        p->parent->color = RBTREE_BLACK;
        p->parent->parent->color = RBTREE_RED;
        right_rotate(t, p->parent->parent);
      }
    }
    else
    {
      node_t *y = p->parent->parent->left;
      if (y->color == RBTREE_RED)
      {
        p->parent->color = RBTREE_BLACK;
        y->color = RBTREE_BLACK;
        p->parent->parent->color = RBTREE_RED;
        p = p->parent->parent;
      }
      else
      {
        if (p == p->parent->left)
        {
          p = p->parent;
          right_rotate(t, p);
        }
        p->parent->color = RBTREE_BLACK;
        p->parent->parent->color = RBTREE_RED;
        left_rotate(t, p->parent->parent);
      }
    }
  }
  t->root->color = RBTREE_BLACK;
}

 

삭제하려는 노드의 *자녀가 없거나 하나라면 삭제되는 색 = 삭제되는 노드의 색

 

삭제하려는 노드의 자녀가 둘이라면 삭제되는 색 = 삭제되는 노드의 successor

 

삭제되는 색이 red라면 어떠한 속성도 위반하지 않는다.

 

int rbtree_erase(rbtree *t, node_t *p)
{
  node_t *target_node = p;
  node_t *x;
  color_t target_node_orginal_color = target_node->color;
  if (p->left == t->nil)
  {
    x = p->right;
    rbtree_erase_transplant(t, p, p->right);
  }
  else if (p->right == t->nil)
  {
    x = p->left;
    rbtree_erase_transplant(t, p, p->left);
  }
  else
  {
    target_node = node_min(p->right, t->nil);
    target_node_orginal_color = target_node->color;
    x = target_node->right;
    if (target_node->parent == p)
    {
      x->parent = target_node;
    }
    else
    {
      rbtree_erase_transplant(t, target_node, target_node->right);
      target_node->right = p->right;
      target_node->right->parent = target_node;
    }
    rbtree_erase_transplant(t, p, target_node);
    target_node->left = p->left;
    target_node->left->parent = target_node;
    target_node->color = p->color;
  }
  if (target_node_orginal_color == RBTREE_BLACK)
    rbtree_erase_fixup(t, x);
  free(p);
  return 0;
}

 

 

10을 삭제시키면 임의의 노드에서 그 노드의 자손 nil노드들까지 가는 경로들의 black 수는 같다를 위반한다.

 

 

 

삭제된 10에 extra black을 부여해야한다. 근데 삭제하고 넣을 때 그 사이에 nil노드가 생겨버리기 때문에, 블랙이 두개가 생겨버린다. 이를 doubly black이라고 한다.

 

 

rb tree의 삭제는 해당 노드를 삭제하고 extra black을 부여 후, doubly black만 잘 해결하면 된다. 이를 해결하는 방법에는 4가지 case가 있다.

 

1. doubly black의 오른쪽 형제가 black이고, 그 형제의 오른쪽 자녀가 red일 때

2. doubly black의 오른쪽 형제가 black이고, 그 형제의 왼쪽 자녀가 red일 때

3. doubly black의 오른쪽 형제가 black이고, 그 형제의 두 자녀가 모두 black 일 때

4. doubly black의 오른쪽 형제가 red일 때

(위 모든 케이스 오른쪽, 왼쪽을 바꾼 상황이여도 적합함)

 

이 케이스들에 유의하며 코드를 짜야한다.

void rbtree_erase_fixup(rbtree *t, node_t *fixup_start_node)
{
  node_t *cur_node = fixup_start_node;
  node_t *sibling;
  while (cur_node != t->root && cur_node->color == RBTREE_BLACK)
  {
    if (cur_node == cur_node->parent->left)
    {
      sibling = cur_node->parent->right;

      if (sibling->color == RBTREE_RED)
      {
        sibling->color = RBTREE_BLACK;
        cur_node->parent->color = RBTREE_RED;
        left_rotate(t, cur_node->parent);
        sibling = cur_node->parent->right;
      }

      if (sibling->left->color == RBTREE_BLACK && sibling->right->color == RBTREE_BLACK)
      {
        sibling->color = RBTREE_RED;
        cur_node = cur_node->parent;
      }

      else {
        if (sibling->right->color == RBTREE_BLACK)
        {
          sibling->left->color = RBTREE_BLACK;
          sibling->color = RBTREE_RED;
          right_rotate(t, sibling);
          sibling = cur_node->parent->right;
        }
        sibling->color = cur_node->parent->color;
        cur_node->parent->color = RBTREE_BLACK;
        sibling->right->color = RBTREE_BLACK;
        left_rotate(t, cur_node->parent);
        cur_node = t->root;
      }
    }
    else {
      sibling = cur_node->parent->left;
      if (sibling->color == RBTREE_RED)
      {
        sibling->color = RBTREE_BLACK;
        cur_node->parent->color = RBTREE_RED;
        right_rotate(t, cur_node->parent);
        sibling = cur_node->parent->left;
      }
      if (sibling->left->color == RBTREE_BLACK && sibling->right->color == RBTREE_BLACK)
      {
        sibling->color = RBTREE_RED;
        cur_node = cur_node->parent;
      }
      else
      {
        if (sibling->left->color == RBTREE_BLACK)
        {
          sibling->right->color = RBTREE_BLACK;
          sibling->color = RBTREE_RED;
          left_rotate(t, sibling);
          sibling = cur_node->parent->left;
        }
        sibling->color = cur_node->parent->color;
        cur_node->parent->color = RBTREE_BLACK;
        sibling->left->color = RBTREE_BLACK;
        right_rotate(t, cur_node->parent);
        cur_node = t->root;
      }
    }
  }
  cur_node->color = RBTREE_BLACK;
}

 

 

 

위 코드는 URL sejin branch에 있음

 

GitHub - RB-TREE-TEAM7/rbtree-lab

Contribute to RB-TREE-TEAM7/rbtree-lab development by creating an account on GitHub.

github.com

 

참고한 유튜브

728x90

'CS > 자료구조' 카테고리의 다른 글

B-tree, B+tree, Trie, Minimum Spanning Tree  (1) 2024.04.01
Graph, Adjacency Matrix, Adjacency List  (0) 2024.03.31
Hash Table (collision, chaining, rehashing)  (1) 2024.03.31
Array, Linked List  (2) 2024.03.28
이진탐색트리, AVL트리  (0) 2024.03.21
728x90

이진탐색트리는 binary search와 linked list를 결합한 자료구조이다. 이진 탐색의 효율적인 탐색 능력을 유지하면서도, 빈번한 자료 입력과 삭제를 가능하게끔 고안되었다.

 

이진탐색의 경우 탐색에 소요되는 계산 복잡성은 O(logN)으로 빠르지만 자료 입력, 삭제가 불가능하다. 연결 리스트의 경우에는 자료 입력, 삭제에 필요한 계산복잡성은 O(1)로 효율적이지만, 탐색하는 데에는 O(n)의 계산 복잡성이 발생한다.

 

  • 각 노드의 왼쪽 서브트리에는 해당 노드의 값보다 작은 값을 지닌 노드들로 이루어져 있다.
  • 각 노드의 오른쪽 서브트리에는 해당 노드의 값보다 큰 값을 지닌 노드들로 이루어져 있다.
  • 중복된 노드가 없어야 한다.
  • 왼쪽 서브트리, 오른쪽 서브트리 또한 이진탐색트리이다.

 

 

이진탐색트리를 순회할 때는 중위순회(inorder) 방식을 쓴다. (왼쪽 서브트리-노드-오른쪽 서브트리 순으로 순회) 중위순회 방식을 사용하면 이진탐색트리 내에 있는 모든 값들을 정렬된 순서대로 읽을 수 있다. (1,5,6,10,14,17,21)

이진탐색트리의 핵심 연산은 검색, 삽입, 삭제 세 가지이다. 이밖에 이진탐색트리 생성, 이진탐색트리 삭제, 해당 이진탐색트리가 비어 있는지 확인, 트리순회 등의 연산이 있다.

탐색(검색)

이진탐색트리의 탐색 연산에 소요되는 계산복잡성은 트리의 높이(루트노드-잎새노드에 이르는 엣지 수의 최대값)가 h일 때 O(h)가 된다.

삽입

트리가 매우 적으면 그냥 중간에 삽입하면 되는데, 매우 길어질 때를 방지하기 위하여, 새로운 삽입은 무조건 리프노드에서 이루어지게된다. 삽입 연산에 소요되는 계산복잡성 역시 트리의 높이가 h일 때 O(h)이다. 삽입할 위치의 리프노드까지 찾아 내려가는 데 h번 비교를 해야 하기 때문이다.

삭제할 때의 경우

  1. 자식노드가 없는 경우 - 그냥 삭제하면 된다.
  2. 자식노드가 하나 있는 경우 - 본인을 삭제하고 부모와 자식을 연결하면 된다.
  3. 자식노드가 두 개 있는 경우 - 아래 과정을 따른다.

자식노드가 두 개 있을 때 삭제 과정

  1. 삭제 대상 노드의 오른쪽 서브트리를 찾는다.
  2. successor 노드를 찾는다.
  3. 2에서 찾은 successor의 값을 삭제 대상 노드에 복사한다.
  4. successor 노드를 삭제한다.

successor란? 중위 순회를 나열한 배열에서 본인의 바로 다음에 있는 노드이다. 즉 본인보다 오른쪽에 있으면서, 가장 작은 노드이다. (맨위 사진에서 [1,5,6,10,14,17,21] 여기서 10의 successor는 14이다.)

 

17 우측에 있는 노드 중에 가장 작은 19가 successor이다.

 

트리의 높이가 h이고 삭제 대상 노드의 레벨이 n라고 가정할 때, 1번 과정에서는 n번의 비교 연산이 필요하다. 2번 successor 노드를 찾기 위해서는 삭제 대상 노드의 서브트리 높이(h-d)에 해당하는 비교 연산을 수행해야 한다. 3번 4번은 복사하고 삭제하는 과정으로 상수시간이 걸려 무시할 만 하다. 종합적으로 따지면 O(d+h-d), 즉 O(h)가 된다.

노드가 루트의 한쪽으로 쏠려버리면 균형이 안맞아서 비효율적이다. 그러면 O(n)이 되는데 이를 해결하기 위해 균형을 맞추는 AVL Tree가 고안되었다.

 

AVL 트리는 편향된 트리의 균형을 맞추기 위한 트리이다.

 

  • 이진 탐색 트리의 속성을 가진다.
  • 왼쪽, 오른쪽 서브 트리의 높이 차이가 최대 1이다.
  • 높이 차이가 1보다 커지면 회전(Rotation)을 통해 균형을 맞춰 높이 차이를 줄인다.
  • 삽입, 검색, 삭제의 시간 복잡도가 O(log N)이다. (N : 노드의 개수)

회전 과정에 대해 알아보기 전에 알아야 할 것이있다. AVL트리는 균형이 무너졌는지에 대해 판단할 때 Balance Factor라는 것을 활용한다. 이는 아래와 같은 공식으로 구한다.

BF(K) = K의 왼쪽 서브트리의 높이 - K의 오른쪽 서브트리의 높이

우회전

1. y노드의 오른쪽 자식 노드를 z노드로 변경한다.

2. z노드 왼쪽 자식 노드를 y노드의 오른쪽 서브트리(T2)로 변경한다.

좌회전

1. y노드의 왼쪽 자식 노드를 z노드로 변경한다.

2. z노드 오른쪽 자식노드를 y노드의 왼쪽 서브트리(T2)로 변경한다.

Case를 기준으로 트리의 균형을 맞춘다.

 

LL Case일 때 해당 노드 기준 우회전

RR Case일 때 해당 노드 기준 좌회전

LR Case왼쪽 자식노드 기준으로 좌회전 히고 해당 노드 기준 우회전

RL Case오른쪽 자식노드 기준으로 우회전 하고 해당 노드 기준 좌회전

 

AVL트리는 이진 탐색트리의 균형을 맞출 뿐, 삽입 삭제는 비효율적이기 때문에, Dictionary 같은 잘 변동이 없는 것을 구현할 때 주로 사용되고, 삽입 삭제를 효율적으로 하기 위해 고안된 트리가 바로 RB트리이다.

 

 

RB트리 : https://the-ze.tistory.com/entry/Red-Black-Tree-%EA%B5%AC%ED%98%84

 

Red-Black Tree 구현

이진 탐색 트리, AVL트리: https://the-ze.tistory.com/entry/%EC%9D%B4%EC%A7%84%ED%83%90%EC%83%89%ED%8A%B8%EB%A6%AC-AVL%ED%8A%B8%EB%A6%AC 이진탐색트리, AVL트리 이진탐색트리는 binary search와 linked list를 결합한 자료구조이다.

the-ze.tistory.com

 

Binary Search Tree 구현 (yunsejin 폴더에 있음)

 

GitHub - yunsejin/Data-Structure-C-Language-Exercises

Contribute to yunsejin/Data-Structure-C-Language-Exercises development by creating an account on GitHub.

github.com

 

728x90

'CS > 자료구조' 카테고리의 다른 글

B-tree, B+tree, Trie, Minimum Spanning Tree  (1) 2024.04.01
Graph, Adjacency Matrix, Adjacency List  (0) 2024.03.31
Hash Table (collision, chaining, rehashing)  (1) 2024.03.31
Array, Linked List  (2) 2024.03.28
Red-Black Tree 구현  (0) 2024.03.22
728x90

프로시저는 재사용 가능한 코드 블록이며, 함수와 유사하게 특정 작업을 수행한다. 프로시저 P에서 Q로의 호출 과정에는 몇 가지 필수 기능이 포함된다.

1. 제어권 전달: 프로그램 카운터(PC)는 진입할 때 Q에 대한 코드의 시작주소로 설정되고, 리턴할 때는 P에서 Q를 호출하는 인스트럭션 뒤로 설정되어야 할 것이다.

(Passing Control : PC는 Q의 코드의 시작 주소로 설정되고 Q를 호출한 후에 P의 명령어로 설정한다.)

2. 데이터 전달: P는 하나 이상의 매개변수를 Q에 제공할 수 있어야 하며, Q는 다시 Q로 하나 이상의 값을 리턴할 수 있어야 한다.

(Passing Data : P는 하나 이상의 파라미터를 Q에게 제공하고, Q는 P에게 값을 다시 반환한다.)

3. 메모리 할당과 반납: Q는 시작할 때 지역변수들을 위한 공간을 할당할 수 있고, 리턴할 때 이 저장소를 반납할 수 있다.

(Allocating and deallocating memory : Q는 지역 변수에 대한 공간을 할당할 필요가 있다.)

 

스택은 작은 주소 방향을 성장하며, 스택포인터 %rsp는 스택의 최상위 원소를 가리킨다.

 

x86-64 아키텍처에서 프로시저가 레지스터에 저장할 공간을 초과하면 스택에 데이터를 저장한다. 프로시저 호출은 스택의 LIFO 원칙을 따르며, 프로시저 Q가 실행 중일 때, 프로시저 P는 대기 상태에 들어간다. Q 실행 중에는 지역 변수를 위한 새로운 공간을 스택에 할당하고, 다른 프로시저 호출을 준비한다. Q가 끝나면 할당했던 로컬 저장소를 해제한다.

 

프로그램은 스택을 통해 프로시저가 요구하는 저장 공간을 관리한다. P가 Q를 호출하면, 제어와 데이터 정보가 스택에 추가되고, P로 돌아올 때 해당 정보는 스택에서 제거된다. P가 Q를 호출하면서 리턴 주소를 스택에 푸시한다. 이는 Q가 끝나고 P로 돌아가야 할 위치를 나타낸다. Q는 호출될 때 자신의 작업 공간을 스택에 할당하며, 이 공간 안에서 레지스터 값과 지역 변수를 위한 공간을 설정하고, 필요한 인자들을 준비한다

 

많은 함수는 스택 프레임을 요구하지 않고 주로 레지스터 내에서 처리한다. P는 최대 여섯 개의 정수 인자를 레지스터를 통해 전달할 수 있지만, Q가 더 많은 인자를 필요로 하면 P는 이들을 스택 프레임 안에 저장한다. 제어를 Q로 넘기는 것은 PC(Program Counter)를 Q의 시작 주소로 설정하는 것이다. Q가 끝나면, ret 명령어는 스택에서 리턴 주소를 팝하여 PC를 그 주소로 설정해 P로 돌아간다.

call 인스트럭션은 호출된 프로시저가 시작하는 주소를 목적지로 갖는다. jump 인스트럭션과 비슷하게 직접 호출할수도, *를 붙여 간접 호출할 수도 있다. (직접 호출은 라벨이 주어지는 반면에 간접 호출은 *로 주어진다.)

메인함수에서 call이 실행되면서 call 의 다음주소인 400568이 복귀주소로 스택에 푸시된다. 그런다음 쭉 내려와서 retq 가 실행되면, 다시 복귀주소를 팝하고 돌아온다.

제어를 함수 P에서 Q로 넘긴다는 것은 단순히 프로그램 카운터를 Q를 위한 코드 시작주소로 설정하는 것이면 된다. 그렇지만, 나중에 Q가 동작을 마치고 리턴해야 할 때가 오면 프로세서는 P의 실행을 다시 실행해야 하는 코드 위치의 일부 기록을 가지고 있어야 한다. 이를 담당하는 것이 call 인스트럭션이다.

함수 P에서 Q로 제어 전송을 하는 것은 PC에 Q 코드에 대한 시작 주소로 설정하면 된다.

나중에 Q가 반환할 시간이 오면, 프로세서는 P의 실행을 다시 재개해야 하는 위치를 기록해야 한다.

이 정보는 Q를 호출하는 명령어와 함께 프로시저 Q를 부르면서 x86-64 기계에 저장된다.

 

이 명령어는 스택에 주소 A를 푸시하고, PC를 Q의 시작 주소로 설정한다.

푸시된 주소는 반환 주소로 부르며 call 명령어로 명령어의 주소로써 계산된다.

아래와 같이 조금 더 복잡한 경우도 살펴볼 수 있다. main함수 안에 top함수 안에 leaf함수가 있는 경우이다.

main함수가 실행되고 top함수가 호출되면 먼저 스택에는 main복귀주소가 들어가고 rip(PC)는 top으로 세팅된다. 그리고, top함수가 실행되면 그 안에있는 leaf함수도 실행된다. 이떄 top함수의 복귀주소가 들어가고, rip 는 leap로 세팅된다.

leap가 리턴되면 스택에서 팝한 것을 주소로하여 top으로 돌아온다.

top이 리턴되면 스택에서 팝한 값을 주소로 하여 main으로 돌아온다.

함수의 호출에 따라 %rsp의 이동, %rip의 이동, %rsp에는 어떤값이 푸시되는지, return할때는 retq에 의해 어떤 값이 팝되는지의 흐름을 이해해야 한다.

ret은 프로시저 호출이 끝날 때 사용되는 인스트럭션으로, 프로시저가 호출될 때 스택에 저장해 놓았던 복귀 주소를 읽어들여, 스택 프레임을 해제하고 해당 주소로 복귀한다.

x86-64에서는 최대 여섯개의 정수형 인자가 레지스터로 전달될 수 있다. 이 레지스터들은 전달되는 데이터 형의 길이에따라 레지스터 이름을 이용하여 정해진 순서로 이용된다. 인자들은 아래 리스트에서 각자의 순서에 따라 이들 레지스터에 할당된다. (64비트보다 작은 인자들은 더 작은 크기의 레지스터들로 할당 가능)

함수의 인자와 지역/전역변수를 포함한 데이터들의 이동을 고려하지 않고서는, 프로시저의 제어를 전달해봐야 아무 의미가 없다.

프로시저 P가 Q를 호출할때, P에대한 코드는 먼저 인자들을 적절한 레지스터들에 복사해야한다.

함수가 여섯개 이상의 정수형 인자를 가지면 6개를 넘어서는 인자들은 스택으로 전달된다.

인자7 에서 n까지를 위한 충분한 크기의 저장공간을 스택 프레임에 할당해야 한다.

다시말해 인자1~6은 적절한 레지스터들에 복사되고, 인자7~n까지는 스택탑에 넙는다.

Q는 레지스터와 스택을 통해 자신의 인자들에 접근할 수 있다.

 

이후에 만약 Q가 여섯개가 넘는 인자를 갖는 어떤 함수를 호출하려면 앞에서 본 스택프레임에서 Argument build area라는 곳에 공간을 할당할 수 있다.

그렇다면 함수가 여섯 개 이상의 정수형 인자를 가진다면? 다른 인자들은 스택으로 전달된다. 인자 1~6은 적절한 레지스터에 복사하고, 인자 7에서 n까지는 인자 7을 스택 탑에 넣는 방법으로 저장한다.

호출된 프로시저에 제어를 넘겨주고 프로시저가 반환되면, 프로시저 호출은 인수 데이터를 넘기고, 프로시저로부터 반환되는 것은 값을 반환하는 것을 포함한다.

 

x86-64에서 6개의 인수가 레지스터를 통해 전달된다. 레지스터들은 전달된 데이터 타입의 크기에 맞는 레지스터를 위해 사용된 이름과 함께 구체적인 순서로 사용된다.

 

함수가 6개보다 많은 인수를 가지고 있으면, 다른 인수는 스택에 전달된다.

인수가 8개인 proc 함수
어셈블리 코드

6번째 인수까지는 레지스터에 저장되지만 마지막 두 개는 스택으로 전달된다.

로컬 데이터는 메모리에 저장되어야 한다.

 

- 로컬 데이터를 가지고 있기에 충분한 레지스터가 없다.

- 주소 연산자 &는 지역 변수에 적용되어서 주소를 생성할 수 있어야 한다.

- 어떤 지역 변수는 배열이나 구조체이므로 배열이나 구조체 레퍼런스로 접근되어야만 한다.

 

일반적으로 프로시저는 스택 포인터를 감소시키면서 스택 프레임에 공간을 할당한다.

 

이 예에서는 함수가 지역 변수를 위해 스택을 사용하는 방식을 볼 수 있다. 함수 실행을 위해 스택을 16만큼 확장하고, movq 연산을 사용하여 함수 인자들을 스택에 배치한다. 함수가 실행된 후, 스택에서 지역 변수들을 사용하여 필요한 계산을 수행하고, 마지막에 스택 포인터를 원래 위치로 돌려 스택을 정리한다.

 

프로그램 레지스터는 모든 함수가 공유하는 자원으로, 한 함수가 다른 함수를 호출할 때, 호출된 함수(callee)는 호출하는 함수(caller)가 나중에 사용할 레지스터 값을 보존해야 한다. 이를 위해 callee는 callee-saved 레지스터(%rbx, %rbp, %r12-%r15)의 원래 값을 스택에 저장하고, 함수가 끝나기 전에 이 값을 복원해야 한다. 이 방식으로 레지스터 값을 안전하게 보존하며, 함수 간의 데이터 손실 없이 호출과 복귀가 이루어진다.

saved registers 자리에 값들이 저장됨으로써 P는 위에 정리한 Callee-saved-register들 또한 자유롭게 쓸 수 있다. Caller-saved-register은 결국에 %rsp를 뺀 나머지 모든 레지스터에 해당된다.

 

Saved registers 자리에 값들이 저장됨으로써 P는 위에 정리한 Callee-saved-register들 또한 자유롭게 쓸 수 있다. Caller-saved-register은 결국에 %rsp를 뺀 나머지 모든 레지스터에 해당된다.

재귀 프로시저도 마찬가지로 위에서 Caller - Callee의 관계처럼 진행된다.

함수 실행 시작에 %rbx 레지스터를 스택에 저장하는 것으로 시작한다. 실행이 끝나면, %rbx를 복원한다. 여기서 %rbx에 처음에 무엇이 저장되어 있었는지는 명시되지 않는다.

 

x86-64 프로시저 규칙은 함수가 재귀적으로 호출될 수 있도록 지원한다. 이는 각 함수가 스택에서 자신만의 공간을 가지고 있기 때문에 가능하다. 이로 인해 서로 다른 호출에서의 지역 변수들이 상호 간섭하지 않는다.

 

함수가 호출되었다가 반환될 때 스택에서 이루어지는 할당과 해제 작업은 로컬 저장소 관리를 위한 적절한 방법을 제공한다. 재귀 호출은 일반 함수 호출과 동일하게 처리되며, 스택의 할당 및 해제 규칙은 함수 호출의 순서와 일치한다.

 

 

728x90

'CS > 어셈블리어' 카테고리의 다른 글

어셈블리어와 제어문  (1) 2024.03.21
728x90

1의 보수: 모든 비트를 반전시킨 값으로, 각 비트에 대해 0은 1로, 1은 0으로 변경한다. 1의 보수는 +0과 -0의 두 가지 표현을 갖는다는 단점이 있다.

 

2의 보수: 1의 보수에 1을 더한 값으로, 가장 널리 사용되는 음수 표현 방식이다. 2의 보수는 오버플로를 자연스럽게 처리할 수 있으며, +0과 -0을 단 하나의 0으로 표현한다는 장점이 있다. 이는 산술 연산을 더 간단하게 만들어 준다.

unsign연산 / sign연산

C/C++ 프로그래밍에서 unsigned는 부호가 없는 즉 음수를 표현하지 않겠다 라는 의미이고  signed는 부호가 있는, 즉 음수로 표현이 가능하다라는 의미 이다.

CF(Carry Flag) : 가장 최근의 연산에서 가장 중요한 비트로부터 올림이 발생한 것을 표시한다.

 

-> 비부호형 연산(unsigned)에서 오버플로우를 검출

1111 + 0001 = 0000

 

ZF(Zero Flag) : 가장 최근 연산의 결과가 0인것을 표시

0111 + 0001 = 1000

1000 - 0001 = 0001

 

SF(Sign Flag) : 가장 최근 연산이 음수를 생성한 것을 표시

0000 - 0001 = 0000 

 

OF(Overflow Flag) : 가장 최근 연산이 2의 보수 오버플로우를 발생시킨 것을 표시

-> 부호형 연산(signed)에서 오버플로우를 검출

0100 + 0100 = 1000

인스트럭션은 컴퓨터에게 일을 시키는 단위로서 컴퓨터가 알아들을 수 있는 기계어로 이루어져 있는 명령어이다.

 

기계코드는 명령이 순차적으로 실행되는데 조건부에 걸쳐 jump를 할 수 있다.

 

-점프 인스트럭션 : 시험 결과에 따라서 프로그램의 다른 일부분으로 제어를 넘긴다.

 

-명령부(OP) : 실제 컴퓨터가 수행해야 할 동작을 나타냄, 주로 산술 및 논리연산, 데이터 이동, 분기, 입출력, 그 외의 제어 명령 ex) 위 사진에서의 mov

 

-처리부(operand) : 동작의 대상이 되는 데이터를 지정, 피연산자를 직접 나타내거나 피연산자가 기억된 레지스터 또는 기억장치의 주소이다. ex) 위 사진에서의 eax

mov eax, ecx 라는 명령어 한 줄 중에 eax가 1오퍼랜드, ecx가 2오퍼랜드 mov가 op이다.

mov(move) : 좌변에 우변의 값을 복사 (우변에 연산자가 올 수 없음)

mov eax, ecx+10

>

add ecx, 10

mov eax, ecx

하지만 주소를 나타내는 mov eax,[ebo+4]같은 연산은 가능하다. 예를 들어 ebp 레지스터가 100번지 주소를 저장하고 있고 104번지에 20이라는 값이 있다면 eax엔 20이라는 값이 저장된다.

lea(load effective address) : 좌변에 우변의 주소값을 저장 (좌변에 레지스터만 올 수 있음)

leaq, movq // 둘이 연산식이 같음

임의의 레지스터 안에 있는 값 + 임의의 레지스터 안에 잇는 주솟값 * index에 곱해줄 2,4,8중 하나의 정수 + 8, 16, 32 bit 중 하나의 값

둘 다 메모리 참조 없이 주소를 계산할 수 있는 명령어지만, movq는 계산 후 메모리에 접근하여 메모리에 저장된 값을 가져온다는 의미, 반면에 leaq는 계산한 값을 취하기만 할 뿐 메모리에는 접근하지 않음

다른 레지스터들은 변경시키지 않으면서 조건 코드만 변경해 주는 두개의 인스트럭션 : CMP, TEST

CMP(compare) 명령어는 두 가지 피연산자의 차이에 따라 조건 코드를 설정한다. (두 피연산자를 비교)

두 피연산자의 값이 값이 같다면 결과는 0이 되고 ZF가 1으로 세트된다. 값이 다르면 ZF가 0으로 세트된다.

 

SUB 명령어와 같은 방식으로 작동하지만, 그들의 목적지를 업데이트하지 않고 조건 코드를 설정하는 것은 다르다. (SUB과 비슷하지만 값을 바꾸진 않음)

 

TEST 명령어는 AND 명령어와 같은 방식으로 작동하지만, 그들의 목적지를 변경하지 않고 조건 코드를 설정하는 것은 다르다.

조건 코드를 직접적으로 읽는 거 보다, 조건 코드를 사용하는 데 세 가지 흔한 방법이 있다.

1. 조건 코드의 어떤 조합에 의존하여 단일 바이트를 0또는 1로 설정한다.

2. 다른 프로그램으로 조건적으로 jump할 수 있다.

3. 데이터를 조건적으로 전송할 수 있다.

SET 인스트럭션은 목적지로 하나의 '단일바이트' 레지스터나, '단일바이트'메모리주소를 사용한다. 이 바이트를 0이나 1로 기록할 것이기 때문에 굳이 큰 바이트가 필요할까 싶다.

각 명령어들은 조건 코드의 조합을 기반으로 단일 바이트를 0 또는 1로 지정한다.

어떤 명령어는 "synonyms"를 가지고 있으며, 이는 같은 기계 명령어의 대안 이름이다.

L 동의어 (setl set less가 아닌 set long으로 정하는건 컴퓨터가 알아서 랜덤으로 판단)

jump 명령어는 프로그램에 완전히 새로운 위치로 바꾸어 실행한다.

jump 목적지는 label(라벨)로 어셈블리 코드 내를 가리킨다.

jump .L1 명령어는 밑에 movq 명령어를 skip하고popq 명령어로 재개한다. 오브젝트-코드 파일을 생성하여, 어셈블러는 모든 라벨된(labeled) 명령어의 주소를 결정하고 jump 타겟을 jump 명령어의 한 부분으로 인코딩한다.

jmp *%rax는 레지스터 내의 값인 %rax를 jump 타겟으로 사용하고, jmp *(%rax)는 메모리로부터 jump 타겟을 읽는다. 어떻게 jump 명령어의 타겟이 인코딩되는지를 이해하는 것은 링킹(linking)에서 중요한 부분이다. jump 타겟은 상징적 라벨을 사용해서 작성된다.

간접 점프의 예:

jmp *%rax -> 레지스터 rax의 값을 점프 목적지로 사용한다.

jmp *(%rax) -> 레지트터 rax에 저장된 값을 주소로 하는 메모리값을 점프목적지로 읽는다.

어셈블러와 링커는 jump 타겟의 적절한 인코딩을 생성한다.

많은 인코딩이 있지만, 가장 흔하게 사용하는 것은 PC relative(PC 상대적 방식)이다. 점프목적지는 심벌레이블을 사용해서 작성한다. 어셈블러와 링커는 점프 목적지를 적절히 인코딩한다. 대상 인스트럭션과 점프 인스트럭션 바로 다음에 나오는 인스트럭션 주소와의 차이를 인코딩한다.

타겟 명령어의 주소와 jump을 따라가는 명령어의 주소 간의 차이를 인코딩한다. 두 번째 인코딩 방법은 "절대적인" 주소를 주는 것으로 타겟을 바로 구체화하기 위해4 바이트를 사용한다. PC-relative 어드레싱을 수행할 때 Program Counter의 값은 jump 자체가 아닌 jump을 따르는 명령어의 주소이다.

링킹 이후에 프로그램에서 명령어는 다른 주소로 재배치 받지만 jump 타겟은 바뀌지 않는다. C에서 조건문과 명령어를 기계 코드로 번역하는 가장 일반적인 방법은 조건적인 jump와 무조건적인 jump의 조합을 사용하는 것이다.(PC상대 방식으로 점프 목적지를 인코딩하면, 인스트럭션들이 간결하게 인코딩되고 목적코드는 수정없이 메모리상의 다른 위치로 이동될수 있다고 한다. // 역추적도 가능)

조건 연산을 구현하기 위한 가장 관습적인 방법은 제어의 조건적 전송을 통한 구현이다. 이 방법은 매우 단순하고 일반적이지만, 모던 프로세서에서는 비효율적이다. 대안적인 전략은 데이터의 조건적 전송을 통해 구현하는 것이다. 이 접근은 조건 연산의 결과와 조건에 속하는 것을 하나 선택하여 계산한다. 이 전략은 매우 제한적인 경우지만, 간단한 조건적 이동 명령어에 의해 구현된다.

조건적 데이터 전송에 따른 코드가 조건적 제어 전송보다 성능이 좋은 지 이해하려면, 모던 프로세서 연산을 이해해야 한다. 프로세서는 파이프라이닝(pipelining)을 통해 높은 성능을 얻는다.

최신 프로세서들은 파이프라인을 통해 높은 성능을 얻는데, 프로세서가 조건부 점프를 만나게 되면, 프로세서는 분기 조건에 대한 계산이 완료될때까지 어느쪽으로 분기될지 결정을 할 수 없다. 즉 조건계산이 될때까지 기다려야한다. 만약에 조건식 계산을 빠르게 예측가능하다면, 조건제어가 더 빠를 수 있겠지만, 그걸 예측하는거는 힘들다고 한다. 조건부 이동명령을 이용하여 컴파일을 하면 적당한 사이클의 시간이 걸리기때문에 사용하기에 좋다. 이 방식은 프로세서가 파이프라인을 꽉 찬 상태로 유지하는것을 더 쉽게 해준다.

C는 do-while, while, for문과 같은 다양한 반복문을 제공한다. 기계 코드에는 대응하는 명령어가 없지만, test와 jump의 조합으로 반복문을 구현한다.

1. Do-While Loops

2. While Loops

3. For Loops

조건부 테스트와 점프를 적절히 사용해서 구현해야한다. 이 부분에서 do-while, while, for에 대한 각각의 어셈블리코드는 책에 예제 코드가 잘 나와있다. 결국에 이 모든걸 jump로 구현할 수 있다.

Switch문은 정수 인덱스 값에 따라 다중분기 기능을 제공한다. 테스트해야하는 경우의 수가 많은 경우에 특히 유용하다. 점프 테이블이라는 자료구조를 사용해서 효율적인 구현이 가능하다

점프테이블은 switch문의 인덱스가 i일때, 프로그램이 실행해야 하는 동작을 구현하는 코드 블록의 주소가 되는 배열을 말한다. if-else문을 사용하는 것보다 switch 문을 사용하는것이, case의 수에 관계없이 일정하다는 것이 장점이다.

a에서 주목해야할것은 case102와 104106이다.

b에서는 들어오는 n값에서 100을 뺀것을 인덱스로 취하는 점프테이블을 사용한다.

점프테이블에서, 비어있는 인덱스는 loc_def로 가게끔 해놓은 것이 인상깊다.

어셈블리 코드를 살펴보면 점프테이블로 가는 간접점프가 나온다. %rsi에 인덱스를 저장하여 사용된다.

점프테이블 또한 어셈블리 코드로 번역된다.

 

자세하게는 알것 없고, 맨위에 써있는 L4가 앞서 점프테이블 간접점프에서 쓰였는데, 이는 이 레이블의 시작주소로 활용된 것이다. 이렇게 점프테이블을 이용하면 다중분기를 매우 효율적인 방식으로 구현할 수 있게된다.

 

728x90

'CS > 어셈블리어' 카테고리의 다른 글

프로시저란 무엇인가?  (0) 2024.03.21

+ Recent posts