[CS:APP] Chap 3.4 - 6

 

3.4 정보 접근하기

일반적인 PC의 CPU는 64비트 값을 저장 할 수 있는 16개의 범용 레지스터를 가지고 있다.

  • 인텔의 첫번째 CPU 였던 8086 에선 8개, 32비트인 IA32에서도 8개. 64비트에서 와서야 이 레지스터 갯수가 늘어난 것이다.
  • 이들 레지스터는 정수 데이터와 포인터를 저장하는 데 사용한다. 아래 설명 할 그림은 이 레지스터들을 보여준다.
  • 이름은 모두 %r 로 시작하지만, 인스트럭션 집합의 역사로 인해 여러 개의 명명법이 존재한다.
    • 원조 CPU 8086에서 레지스터들은 %ax ~ %sp 까지, 16비트 레지스터를 가지고 있었다.
    • IA32 (32-bit)로 확장하면서 이 레지스터들은 %eax ~ %esp까지 이름을 붙였다.
    • 64bit로 확장하면서 본래 8개의 레지스터들은 %rax ~ %rsp까지 64비트로 확대되었다.
      • 여기에 8개의 새로운 레지스터들이 추가되었으며, 이들은 새로운 명명법에 따라 %r8 ~ %r15까지로 이름을 붙였다.
  • 그림에서 한 레지스터에 중첩된 상자를 볼 수 있다. 인스트럭션들은 16개의 레지스터 하위 바이트들에 저장된 다양한 크기의 데이터에 연산 할 수 있다. 바이트 수준 연산들은 가장 덜 중요한 바이트에 대해 연산이 가능하며, 16비트 연산은 2바이트, 32비트는 4바이트, 64비트 연산은 레지스터 전체에 접근 할 수 있다.
    • 이렇게 함으로써, 하나의 레지스터로 다양한 크기의 연산을 처리 하는 유연한 사용, 작은 단위 데이터를 빨리 처리 할 수 있다는 점에서의 성능 최적화를 꾀 할 수 있다.
  • 인스트럭션들이 레지스터들을 목적지로 할 때에는, 8바이트 단위로 데이터를 주고 받게 될텐데, 1, 2, 4바이트만 내용이면 남은 공간은 어쩌면 좋을까?
    • 1 또는 2 바이트를 생성하는 경우에는 나머지 바이트들은 변경없이 유지한다.
      • 즉, 덮은 부분만 변경이 이루어진다.
    • 4 바이트 길이의 값을 생성하는 경우는 상위 4바이트를 0으로 설정한다.
      • 이런 방식은 32비트에서 64비트로 넘어오면서 채택 된 방식이다.
    • 8바이트면 당연하게도 그냥 덮어쓰면 된다.
  • 그림의 우측에서 볼 수 있듯, 서로 다른 레지스터들은 일반적인 프로그램에서 서로 다른 목적으로 이용된다.
    • 좀 특이한 것은, 스택 포인터 %rsp로 런타임 스택의 끝 부분을 가리키기 위해 사용된다. 일부 인스트럭션들만 이 레지스터를 읽고 쓴다. 다른 15개는 사용이 비교적 자유롭다.

3.4.1 오퍼랜드 식별자

대부분의 인스트럭션은 하나 이상의 오퍼랜드를 가진다.

    • 소스 값은 상수로 주어지거나, 레지스터나 메모리로부터 읽을 수 있다. 결과 값도 레지스터, 메모리에 저장된다.
    • 이러한 오퍼랜드의 종류는 세 가지 타입으로 구분 할 수 있다.오퍼랜드는 연산을 수행 할 소스 값과, 그 결과를 저장 할 목적지의 위치를 명시한다. x86-64는 여러 가지 오퍼랜드의 형태를 지원한다.

  • Immediate : 상수 값 그 자체를 말한다. 어셈블러는 해당 값을 인코딩하는 가장 컴팩트 한 방법을 자동으로 선택한다. 상수는 $-577, $0x1F 같이 나타낸다.
  • Register : 레지스터의 내용을 가리킨다.
    • 레지스터는 사실 그냥 큰 통인데, 그것을 잘라서 쓰는 느낌이다.
      • %rax 라고 불리는 큰 통을 %eax , %ax , %al 로 잘라서 쓸 수 있다는게 된다.
        • 언급된 레지스터는 차례로 8, 4, 2, 1 바이트이다
    • Form에서 볼 수 있는 기호 r_a는 임의의 레지스터 a를 나타내며, 해당 값은 R[r_a]을 참조하여 지정되며, 레지스터 집합을 배열 R과 레지스터 식별자를 인덱스로 사용하는 형태로 나타낸다.
  • Memory는 메모리 접근은 사실 그냥 주소 계산이다. 유효주소라고 부르는 계산된 주소에 의해 메모리 위치에 접근하게 된다.
    • 유효 주소가 생긴 이유 : CPU는 하나의 레지스터 값, 오프셋, 인덱스, 배율등을 조합해서 메모리 주소를 계산한다. 이 것을 유효 주소라고 부른다. 메모리 접근은 항상 계산된 위치를 필요로 한다. 프로그램이 동적으로 데이터를 다룰 수 있게 하려면, 주소 계산은 필연적이다.
    • 메모리는 거대한 바이트의 배열로 생각 할 수 있으니, M_start[something]과 같이 표시하여 메모리 주소 something부터 start 만큼의 바이트를 참조하는 것이라고 암시 된다. 단순화를 위해 아래첨자는 생략한다.
    • 진짜 많은 주소 지정 방식이 존재한다. 가장 일반적인 형태는 표의 마지막 레코드인 Imm(r_b, r_i, s )이다. 네 개의 구성 요소가 존재한다.
상수 오프셋 Imm  
베이스 레지스터 r_b 64비트 레지스터
인덱스 레지스터 r_i 64비트 레지스터
배율 s s는 1, 2, 4, 8의 값을 갖는다.
유효주소의 계산 방법 위에꺼랑 동일 Imm + R[r_b] + R[r_i] * s

베이스 위치에서 오프셋 만큼 떨어진 곳, 인덱스 위치에 배율을 곱한만큼 더 간 거리 = 계산 방법

3.4.2 데이터 이동 인스트럭션

가장 많이 사용되는 인스트럭션은 데이터를 한 위치에서 다른 위치로 복사하는 명령이다.

  • 오퍼랜드 표시방법의 일반성 때문에 많은 컴퓨터에서 여러 개의 인스트럭션이 요구 될 때 데이터 이동 인스트럭션이 간단해졌다. 극단으로 가면, 완전 옛날에는 배열 인덱싱, 배율 연산, 이런거 아무것도 없었다.
    • 데이터 이동 인스트럭션의 가장 간단한 형태인 MOV는 :
      • 다른 크기의 데이터에 대해 작동한다는 점을 빼고 동일하다 : movb, movw, movl, movq
  • 소스 오퍼랜드는 상수, 레지스터 저장 값, 메모리 저장 값을 표시한다.
  • 목적 오퍼랜드는 레지스터 또는 메모리 주소의 위치를 지정한다.
  • x86-64는 데이터 이동 인스트럭션에서 메모리에서 메모리로 바로 이동 하는 상태는 불가능하다.
    • 왜냐하면 하드웨어 실행 효율성이 크다.
    • 대부분의 산술/논리/이동은 소스 오버랜드 로드 → 연산 → 결과 저장으로 구성된다.
      • 하지만 두 오퍼랜드가 모두 메모리 일경우, 메모리 접근이 두 번 필요하고, 메모리 간 직접 연산을 직접 처리 할 ALU 경로를 따로 구성해야한다. → 복잡해지고, 느려진다.
      • 그래서 x같지만 레지스터 중간 단계를 강제해서 패턴이 형성되도록 한 것이다.
  • mov [start mem], [middle register] 그리고, mov [middle register], [end mem] 이렇게 해야한다.
  • **하나의 메모리 위치에서 다른 위치로 어떤 값을 복사하기 위해서는 두 개의 인스트럭션이 필요하다.**
  • 앞에서 봤던 정수 레지스터 전신을 참조하면
    • 이 인스트럭션들의 레지스터 오퍼랜드는 16개 중에서 이름을 붙인 부분이 될 수 있다.
    • 여기서 레지스터의 크기는 인스트럭션의 마지막 문자가 나타내는 크기와 일치해야한다.
      • 인스트럭션이 감당 할 수 있는 레지스터의 크기가 정해져있다고 생각해야 한다.
    • 대부분의 경우 MOV는 특정 레지스터 바이트들이나 대상 오퍼랜드에 의해 지정된 메모리 위치만을 업데이트 한다.
    • 예외 : movl이 레지스터를 목적지로 갖는 경우. 이 경우에는 레지스터의 상위 4바이트도 0으로 설정한다.
      • 왜 이런 예외가 생겼습니까?
        • 어떤 레지스터를 위한 32비트 값을 생성하는 인스트럭션은 레지스터의 상위 바이트들을 또한 0으로 설정하도록 32비트 값을 생성하는 관습에 의해 생겼다. x86-64에서 채택되었다.
      • 다음의 MOV 인스트럭션 예제는 다섯 가지 가능한 조합의 소스와 목적지 유형을 보여준다. 소스 오퍼랜드가 먼저 나오고, 이후에 목적 오퍼랜드가 나온다는 점에 유의하자.
        • 유형이 3개면 도착지도 3개일테니 9개의 유형이 있겠지만..
          • Imm이 수령지인 3개의 경우는 아예 사용하는 의미가 없고, Mem-Mem도 앞에서 언급 했듯 사용이 불가하다.
1 movl $0x4050, %eax Immediate - Register 4Byte
2 movw %bp, %sp Register - Register 2Byte
3 movb (%rdi, %rcx), %al Memory - Register 1Byte
4 movb $-17, (%rsp) Immediate - Memory 1Byte
5 movq %rax, -12(%rbp) Register - Memory 8Byte

이 그림에서 설명이 있는 마지막 인스트럭션 (movabsq)는 64비트 상수를 다루기 위한 것이다.

  • 레지스터 movq 인스트럭션은 오직 32비트 2의 보수 숫자로 나타낼 수 있는 상수 소스 오퍼랜드들만을 갖는다. 이 값은 그 후 부호 확장되어, 목적지를 위해 64비트 값을 생산한다.
  • movabsq 인스트럭션은 임의의 64비트 상수 값을 소스 오퍼랜드로 가질 수 있으며, 목적지로는 레지스터만을 가질 수 있다.

  • 그림 3.5로 논하는 이 그림은 위에서 차례로 1 → 2, 1 → 4, 2 → 4, 1 → 8, 2 → 8을 논한다.
  • 자 근데 4 → 8이 여기 중에 없다. 왜 그럴까? : 아래에 설명이 있다.

이 두 장의 그림은 보다 작은 소스 값을 더 큰 목적지로 복사 할 때 사용하기 위한 두 종류의 데이터 이동 명령들을 설명하고 있다. 이 인스트럭션들은 모두 레지스터나 메모리에 저장되어 있는 소스로부터 레지스터 목적지로 복사한다.

  • MOVZ 클래스의 인스트럭션들은 목적지의 남은 바이트들을 모두 0으로 채워준다.
  • MOVS 클래스는 이들을 소스 오퍼랜드의 가장 중요한 비트를 반복해서 복사하는 부호 확장으로 채운다.
    • 이 명령들의 이름에는 마지막 두 개의 문자가 크기를 나타내는 지시자를 갖는다.
      • 첫 번째는 소스의 크기를 나타낸다. 두 번째는 목적지의 크기를 나타낸다.
      • 나타낸 것처럼 이들 클래스들에는 세 개의 인스트럭션이 각각 포함된다. 이들은 1, 2바이트 소스 크기와 2, 4바이트 목적지 크기를 지원하며, 물론 목적지가 소스보다 더 긴 경우들에 대해서만 다룬다.
      • 왜냐? 1→2, 1→4, 2→4로 모든 걸 다하기때문이다. MOVS 계열의 코드는 부호 확장 전용으로, 작은 값을 큰 공간에 넣으면서 부호를 유지하기 위한 것이었다.
        • 현실적으로, CPU 설계에서 인스트럭션 종류가 최소화 될수록 좋기 때문이다.

윗 그림에서 4바이트 소스 값을 8바이트 목적지로 0으로 확장하는 명확한 인스트럭션이 없다는 것을 유의하자.

  • 이러한 인스트럭션은 논리적으로는 movzlq가 되겠지만, 이런걸 다루는건 없다.
  • 대신, 더 쉽게, movl 쓰면 0이 저절로 빈 칸에 채워진다. movl이 그런 사용을 하고, 의도적 설계이다.
  • 반면에, 64비트 목적지의 경우, 부호 확장으로 이동하는 것은 세 종류의 소스에 대해 모두 지원된다.
    • 여기서 이야기하는 세 종류의 소스는 1, 2, 4 바이트의 크기 단위로 나눔을 의미한다.

아랫 그림은 cltq 인스트럭션에 대해서도 소개하고 있다.

  • 이 인스트럭션은 오퍼랜드가 없다. 언제나 레지스터 %eax를 소스로, %rax를 목적지로 사용해서 부호 확장 결과를 만든다.
  • 그래서 이것은 movslq %eax, %rax과 정확히 같지만, 좀 더 압축적인 인코딩을 갖는다.
    • 목적이 %eax → %rax 이라면 cltq를 고정으로 사용 해 볼 수도 있겠다.

3.4.3 데이터 이동 예제

데이터 이동 인스트럭션의 예제 코드로 C 코드와 어셈블리 코드를 살펴보자.

long exchange(long *xp, long y)
{
    long x = *xp;
    *xp = y;
    return x;
}
exchange:
    movq (%rdi), %rax
    movq %rsi, (%rdi)
    ret

함수 exchange는 단 세 개의 인스트럭션으로 구현되었다.

  • 두 개의 데이터 이동, 그리고 함수가 호출된 위치로 리턴하는 인스트럭션 한 개.
  • 리턴 값을 레지스터 %rax에 저장해서 함수가 값을 리턴 하거나, 이 레지스터의 하위 부분 중의 하나로 리턴한다.

프로시저가 실행을 시작하면, 프로시저 매개변수 xp, y는 레지스터 %rdi와 %rsi에 저장된다.

  • 인스트럭션 2는 x를 메모리에서 읽어서 레지스터 %rax에 저장한다.
    • 이것은 x = *xp로 구현된다.
    • 나중에 레지스터 %rax는 함수에서 값을 리턴 할 때 사용 될 것이며, 그래서 리턴 값은 x다.
  • 인스트럭션 3은 y를 레지스터 %rdi에 저장된 xp로 지정한 메모리 위치에 써주며, 이것은 연산 *xp = y를 직접 구현한 것이다.
    • 이 예제는 어떻게 MOV 인스트럭션을 이용해서 메모리에서 레지스터로 읽어들이는지(Line 2), 레지스터에서 메모리로 쓰는지(Line 3)을 보여준다.

이 어셈블리 코드들에서 두 가지 특징에 주목해 보자.

  • C언어의 포인터는 어셈블리어에서 단순히 주소이다.
    • 포인터를 역참조하는 것은 포인터를 레지스터에 복사하고, 이 레지스터를 메모리 참조에 사용하는 과정으로 이루어진다.
  • x 같은 지역변수들은 메모리에 저장되기 보다는 종종 레지스터에 저장된다. 레지스터의 접근은 메모리보다 속도가 훨씬 더 빠르다.

[연습 문제]

src_t *sp;
dest_t *dp;

라는 코드가 있다고 하자.

*dp = (dest_t) *sp;

를 제일 적절한 데이터 이동 인스트럭션을 사용해서 구현해보려고 한다. 아래 표를 채우자.

  • 하지만 src_t와 dest_t의 데이터 타입은 좀 많이 다를 수도 있다. 아래처럼!!
       
char, 1byte int, 4byte movsbq (%rdi), %rax movl %eax, (%rsi)
char, 1byte unsigned int movzbq (%rdi), %rax movl %eax, (%rsi)
unsigned char long movzbq (%rdi), %rax movq %rax,(%rsi)
int char movl (%rdi), %eax movb %al, (%rsi)
unsigned unsigned char movl (%rdi), %eax movb %al, (%rsi)
char short movsbw (%rdi), %ax movw %ax, %rsi
src_t dest_t Instruction ? 부가 설명
long, 8byte long, 8byte movq (%rdi), %rax
movq %rax, (%rsi)
특이사항 없음
char, 1byte int, 4byte movsbq (%rdi), %rax
movl %eax, (%rsi)
movsbq : extend byte to quad (1 → 8Byte)
movl : 레지스터 → 메모리 4Byte
char, 1byte unsigned int movzbq (%rdi), %rax
movl %eax, (%rsi)
movzbq : zero extend byte to quad.
unsigned char long movzbq (%rdi), %rax
movq %rax,(%rsi)
1Byte 내용을 가져와서 남은 7Byte 공간을 0으로 채운다.
int char movl (%rdi), %eax
movb %al, (%rsi)
char는 하위만 쓰기 때문에 부호 확장이 필요하지 않다.
unsigned unsigned char movl (%rdi), %eax
movb %al, (%rsi)
이 부분도 방금 다룬 케이스와 유사하다.
char short movsbw (%rdi), %ax
movw %ax, %rsi
 

 

[연습문제]

void decode(long *xp, long *yp, long *zp); 라는 내용이 어셈블리 코드로 컴파일하니 이렇게 되었다 :

xp는 %rdi, yp는 %rsi, zp는 %rdx에 배정되었다.
decode1:
            movq (%rdi), %r8
            movq (%rsi), %rcx
            movq (%rdx), %rax
            movq %r8, (%rsi)
            movq %rcx, (%rdx)
            movq %rax, (%rdi)
            ret

이와 같은 내용을 갖는 decode1의 C 코드 내용을 쓰기

// 나의 풀이 :
// (%rdi) -> %r8 -> (%rsi)로 경유 후 이동했다.
// (%rsi) -> %rcx -> (%rdx)로 경유 후 이동했다.
// (%rdx) -> %rax -> (%rdi)로 경유 후 이동했다.
void decode1(long *xp, long *yp, long *zp)
{
    long *tmp = *zp;
    *zp = *yp;
    *yp = *xp;
    *xp = *tmp;
}

3.4.4 스택 데이터의 저장과 추출, push/pop

pushq 와 popq 는 프로그램 스택에 데이터를 저장하거나 스택에서 데이터를 추출 하기 위해 사용한다.

  • 스택은 프로시저 호출을 처리하는데 중요한 역할을 한다. 배경지식으로 설명한다면,
    • 스택은 Last-in, First-out 형태로 추가/제거가 이루어지는 자료구조이다.
      • 제거하는 값은 가장 최근에 추가된 값이며, 그 값은 스택에 여전히 남아있어야 한다.
    • x86-64에서 프로그램 스택은 메모리의 특정 영역에 위치한다.
      • 스택은 높은 주소에서 낮은 주소로 향하게 되는데 있어 다른 메모리 요소와 충돌하지 않도록, 적당히 구성 요소와 거리를 둔 높은 주소에 올려준다.
    • 스택 포인터 %rsp 가 스택 맨 위 원소의 주소를 저장한다.
    • subq $8, %rsp movq %rbp, (%rsp)
    • 이 코드는 quad word 값을 스택에 추가하는 내용이다.
      • 먼저 스택 포인터를 8 감소시킨다. 그 값을 스택 주소의 새로운 탑에 기록하는 것으로 구현된다.
      • 저 두 줄을 스택 인스트럭션으로 표현하면 pushq %rbp 한 방에 가능하다.
        • 당연히 명령어가 짧아야한다. 수천만 번 할거고, 그 땐 유의미한 메모리 사용이 발생한다.
    movq (%rsp), %rax
    addq $8, %rsp
    • 이 코드는 quad word 값을 스택에서 제거 (pop)하는 내용이다.
      • 탑 위치에서 데이터를 읽는다. 스택 포인터를 8 증가시키는 것으로 구현이 가능하다.
      • 이 두 줄은 popq %rax 한 줄로 구현 될 수 있다.
  • 스택이 프로그램 코드와 다른 형태의 프로그램 데이터와 동일한 메모리에 저장된다.
    • 프로그램들은 표준 메모리 주소지정 방법을 사용해서 스택 내 임의의 위치에 접근 할 수 있다.
    • 간단한 예로, 스택 최상위 원소가 쿼드 워드라고 가정하면, movq 8(%rsp), %rdx 은 스택의 두 번째 쿼드워드를 레지스터 %rdx 에 복사 해준다.

 

3.5 산술연산과 논리연산

이 그림은 x86-64 정수와 논리연산의 리스트를 보여준다.
오퍼랜드의 길이에 따른 다양한 변형이 가능하기 때문에, 대부분의 연산을 인스트럭션 클래스에 따라 나열한 것이다.

  • leaq는 길이에 따른 변형이 없다.
  • 예를 들어, add는 네 개의 덧셈 인스트럭션으로 이루어져 있다 : addb, addw, addl, addq
    • 접두사에 따라 수행하는 데이터 단위에 따른 차이이다.
  • 연산들은 네 개의 그룹으로 나누어진다 :
    • 유효주소 적재, 단항, 이항, 쉬프트.
      • 이항 연산은 두 개의 오퍼랜드를 쓴다. 단항은 한 개만 사용한다.

3.5.1 유효주소 적재

유효주소 적재 인스트럭션 leaq는 실제로는 movq 의 변형이다.

  • 이것은 메모리에서 레지스터로 읽어들이는 인스트럭션의 형태를 갖긴 하지만, 메모리를 참조하진 않는다.
  • 이 인스트럭션의 첫 번째 오퍼랜드는 메모리 참조 같이 보인다.
    • 그런건 아니고, 가리키는 위치에서 읽기를 수행하는 대신 유효주소를 목적지에 복사한다. (&S)
      • 이 인스트럭션은 나중의 메모리 참조에 사용하게 되는 포인터를 생성하기 위함이다.
      • 또, 일반적인 산술연산을 간결하게 설명하기 위해 사용된다.
      • [Example] 레지스터 %rdx가 x를 가지고 있다면, 인스트럭션 leaq 7(%rdx, %rdx, 4) 는 레지스터 %rdx 에 5x + 7을 저장한다.
      • 목적 오퍼랜드는 반드시 레지스터만 올 수 있다.

 

3.5.2 단항 및 이항 연산

두 번째 그룹에서의 연산은 하나의 오퍼랜드가 소스와 목적지로 동시에 사용되는 단항 연산이다.

  • 이 오퍼랜드는 레지스터나 메모리 위치가 될 수 있다.
  • 예를 들어, 인스트럭션 incq (%rsp) 는 스택 탑의 8바이트 원소의 값을 증가시켜준다.
    • 이 문법은 C에서의 증가(++), 감소(—) 연산자를 연상시킨다.
  • 세 번째 그룹은 이항 연산들로 구성되며, 두 번째 오퍼랜드는 소스이면서 목적지로 사용된다.
    • 이 문법은 C에서의 할당 연산자인 x -= y 와 유사하다. 그렇지만, 소스가 먼저오고, 나중에 목적지가 나오는 점에 유의하자. 이것은 비교환성 연산 noncommutative 연산으로 특이하기 보인다.
      • 인스트럭션 subq %rax, %rdx 은 레지스터 %rdx에서 %rax 값 만큼 빼준다.
      • 첫 번째 오퍼랜드는 상수나 레지스터, 메모리 위치가 올 수 있다.
      • 두 번째는 레지스터나 메모리가 올 수 있다. MOV 인스트럭션에서 두 개의 오퍼랜드가 모두 메모리 위치가 될 수는 없다. 만일, 메모리 위치일 때 프로세서가 메모리에서 값을 읽고, 연산을 하고, 그 결과를 다시 메모리에 써야 한다는 점에 유의해야 한다.

3.5.3 쉬프트 연산

마지막 그룹은 쉬프트 연산으로 구성되며, 쉬프트하는 크기를 먼저 주고, 쉬프트 할 값을 두 번째로 준다.

  • 산술과 논리형 우측 쉬프트가 모두 가능하다.
  • 여러 쉬프트 인스트럭션들은 쉬프트 할 양을 즉시 값이나 단일 바이트 레지스터 %cl 로 명시 할 수 있다.
    • 특이한 것은 이 인스트럭션 들은 이 특정 레지스터만을 오퍼랜드로 허용한다는 점이다.
  • 원칙적으로, 1바이트 쉬프트 양은 쉬프트 양을 2^8 - 1 = 255까지 가능하도록 한다. 

x86-64에서는 w비트 길이의 데이터 값에 적용하는 쉬프트 연산은 레지스터 %cl 의 하위 m비트로 쉬프트 양을 결정한다.
2^m = w의 관계가 성립한다. 상위 비트들은 무시된다.

  • 예를 들어, 레지스터 %cl이 16진수 값 0xFF를 가질 때, 인스트럭션 salb는 7만큼 쉬프트하고, salw는 15, sall은 31, salq는 63만큼 쉬프트하게 된다.
  • 좌측 쉬프트 인스트럭션에는 두 가지 이름이 있다. SAL과 SHL. 이들은 모두 동일한 효과를 내며, 우측에서부터 0을 채운다.
    • 우측 쉬프트 인스트럭션은 SHR이 논리 쉬프트(0으로 채운다)를 수행하는 반면, SAR이 산술 쉬프트(부호 비트는 복사해서 채운다)를 수행한다는 점에서 차이가 있다. 쉬프트 연산의 목적 오퍼랜드는 레지스터나 메모리 위치가 될 수 있다.
  • 2의 보수 표현 상으로 1100은 -4이고, 그냥 하면 1100은 12이다.
    • 그래서 1100에 SAR을 수행하면 1010이 되어 -2가 된다.
    • 1100에 SHR을 수행하면 0110이 되어 6이 된다.
      • 이로써 1100은 사실 그냥 1100이고, 명령어를 어떻게 정하냐에 따라 인스트럭션을 부여한다고 보자.
        비트는 변하지 않지만 해석의 방식을 직접 정한다고 생각하자.

 

3.5.4 토의

앞 그림에서 언급했던 대부분의 인스트럭션들은 비부호형과 2의 보수 산술연산에 사용 될 수 있다.

  • 오직 우측 쉬프트만이 부호형과 비부호형 데이터를 구분하는 인스트럭션을 요구한다.
  • 이것이 부호형 정수 산술연산을 구현하는 방식으로, 2의 보수산술 연산을 선호하는 주요 특징이다.
// C code
long arith(long x, long y, long z)
{
	long t1 = x ^ y;
	long t2 = z * 48;
	long t3 = t1 & 0x0F0F0F0F;
	long t4 = t2 - t3;
	return t4;
}
// Assembly
artih:
	xorq %rsi, %rdi
	leaq (%rdx, %rdx, 2), %rax
	salq $4, %rax
	andl $252645135, %edi
	subq %rdi, %rax
	ret

이 두 코드는 산술연산을 수행하는 함수와 이것의 어셈블리 코드로 번역된 형태의 예를 보여준다.

  • 인자 x, y, z는 처음에는 각각 레지스터 %rdi, %rsi, %rdx에 저장된다.
  • 어셈블리 코드의 인스트럭션들은 C 소스코드의 줄 번호와 밀접하게 대응된다. 줄번호 2는 x^y를 계산한다.
  • 줄번호 3, 4는 수식 z*48을 leaq와 쉬프트 인스트럭션을 결합해서 계산한다.
  • 줄번호 5는 t1 과 0x0F0F0F0F의 AND를 계산한다. 마지막 뺄 셈은 줄번호 6에서 계산된다.
  • 뺄셈의 목적지는 레지스터 %rax 이기 때문에, 이것이 이 함수가 리턴하는 값이 될 것이다.

조금 더 자세히 보면,

  • 어셈블리 코드에서 %rax 레지스터에 저장되는 일련의 값들은 3*z, z*48, t4에 대응된다.
  • 일반적으로 컴파일러는 각각의 레지스터를 여러 가지 프로그램의 값을 저장하는 데 사용하고, 레지스터들 간에 프로그램 값을 이동하는데 사용한다.

 

3.5.5 특수 산술연산

두 개의 64비트 부호형 또는 비부호형 정수들 간의 곱셈은 결과 값을 표시하기 위해 128비트를 필요로 한다.

  • x86-64 인스트럭션 집합은 128비트(16바이트) 숫자와 관련된 연산에 대해서는 제한적인 지원을 제공한다.
  • 기존의 명명법인 워드, 더블워드, 쿼드워드 방식을 이어가면서, 인텔은 16바이트 워드를 옥트 워드라고 명명하였다.
  • 두 개의 64비트를 곱하여 완전 128비트 곱을 생성 할 수 있도록 지원하는 인스트럭션의 예제를 확인해보자.

imulq 인스트럭션은 두 가지 형식을 갖는다.

  • 한 가지는 IMUL 인스트럭션 클래스의 멤버인 형태이다.
    • 이 형식은 두 개의 64비트 오퍼랜드로부터 64비트 곱을 생성하는 “2 오퍼랜드” 곱셈 인스트럭션을 제공한다.
    • (곱을 64비트로 절삭 할 때는 비부호형에서와 2의 보수 곱셈에서 모두 동일한 비트 수준 동작을 갖는다는 점을 기억해야 한다.)
  • 추가적으로 x86-64는 두 개의 다른 “단일 오퍼랜드” 곱셈 인스트럭션을 제공하며, 두 64비트 값의 완전한 128비트 곱을 계산한다.
    • 하나는 비부호형(mulq)이고, 다른 하나는 2의 보수(imulq) 곱셈이다. 이들 모두 한 개의 인자는 레지스터 %rax에 보관해야 하고, 다른 하나는 인스트럭션 소스 오퍼랜드로 주어진다. 곱은 레지스터 %rdx (상위 64비트)와 %rax (하위 64비트)에 저장된다.
    • 비록 imulq 라는 이름은 두 종류의 다른 곱셈 연산에 사용되지만, 어셈블러는 오퍼랜드의 수에 따라 어느 경우에 해당하는지 알 수 있다.
#include <inttypes.h>

typedef unsigned __int128 uint128_t;

void store_uprod(uint128_t *dest, uint64_t x, uint64_t y)
{
	*dest = x * (uint128_t) y;
}

두 개의 비 부호형 수 x, y의 128비트 곱을 생성하는 코드를 보자.

  • 여기에서, 우리는 C 표준의 확장 형태인 파일 inttypes.h에 선언된 정의들을 이용해서 x, y를 64비트 숫자로 명시적으로 선언한다.
  • 불행히도 이 표준은 128비트 값들에 대해서는 제한하지 않는다.
    • 대신, 128비트를 위해 GCC가 제공하는 __int128을 이용해서 선언하는 방법에 의존한다.
  • 우리의 코드는 inttypes.h에 있는 다른 자료형들의 명명법을 본따서, typedef 함수를 이용해서 자료형 uint128_t를 정의한다.
    • 이 코드는 곱셈 결과 값이 포인터 dest로 지시된 16바이트에 저장되어야 한다는 것을 명시한다.
store_uprod:
	movq %rsi, %rax
	mulq %rdx
	movq %rax, (%rdi)
	movq %rdx, 8(%rdi)
	ret

이 함수를 GCC로 생성한 어셈블리 코드는 다음과 같다. 곱을 지정하기 위해서는 두 개의 movq 인스트럭션이 필요하다는 점에 유의하자.

  • 하나는 하위 8바이트 movq %rax, (%rdi) , 다른 하나는 상위 8바이트 movq %rdx, 8(%rdi)이다.
    • 이 코드는 리틀 엔디안 머신에서의 사용을 목적으로 하기에, 주소지시자가 8(%rdi)로 언급했듯, 상위 바이트는 높이 간다.
      • 리틀 엔디안 머신 = x86-64라는 뜻. 책 진짜 불친절하네

산술연산 표에서는 나눗셈이나 나머지 연산들이 빠져있었다.

  • 이들 연산들은 단일 오퍼랜드 곱셈 인스트럭션과 비슷한 단일 오퍼랜드 나눗셈 인스트럭션으로 제공된다.
  • 부호형 나눗셈 인스트럭션 idivq 은 피제수를 128비트로 레지스터 %rdx, %rax에 저장한다. 제수는 인스트럭션의 오퍼랜드로 주어진다.
  • 인스트럭션은 몫은 레지스터 %rax에, 나머지는 %rdx에 저장한다.

피제수는 나눠지는 수, 제수는 나누는 수이다.

64비트 나눗셈의 응용 대부분에서, 피제수는 64비트 값으로 주어진다. 이 값은 레지스터 %rax에 저장되어야 한다.

  • %rdx의 비트들은 모두 0(비부호형 산술연산)으로 또는 %rax의 부호비트로(부호형 산술연산) 설정되어야 한다.
  • 후자의 연산은 인스트럭션 cqto(= cqo)를 이용해서 실행 될 수 있다. 오퍼랜드는 없다.
    • %rax의 부호비트를 묵시적으로 읽어서 %rdx 전체에 복사한다.

x86-64로 나눗셈을 구현한 예제로, 다음의 C함수는 두 64비트 부호형 수의 몫과 나머지를 계산한다.

void remdiv(long x, long y, long *qp, long *rp)
{
	long q = x/y;
	long r = x%y;
	*qp = q;
	*rp = r;
}
remdiv:
	movq %rdx, %r8
	movq %rdi, %rax
	cqto
	idivq %rsi
	movq %rax, (%r8)
	movq %rdx, (%rcx)
	ret

2번 줄 : 이 코드에서 인자 qp는 인자 레지스터 %rdx가 나눗셈 연산을 위해 필요하므로, 다른 레지스터에 먼저 저장되어야 한다.
3-4번 줄 : 그 다음에 x를 부호 확장해서 복사하여 피제수를 준비한다.
6번 줄 : 나눗셈 후에 레지스터 %rax에 들어 있는 몫은 qp에 저장된다.
7번 줄 : 레지스터 %rdx의 나머지는 rp에 저장된다.

  • 비부호형 나눗셈은 divq 인스트럭션을 활용한다. 일반적으로 레지스터 %rdx는 계산에 앞서 0으로 설정된다.

 

3.6 조건문

지금까지 다룬 인스트럭션들이 하나씩 순차적으로 실행되는 직선적인 코드 동작만을 다루어왔다.

  • 반복문, 스위치문 등은 조건부 실행이 요구된다. 기계어 코드는 두 경우로 이러한 조건문을 표현해 낸다.
    • 데이터 값들을 시험해서, 이 시험 결과에 따라 데이터 흐름이나 제어 흐름을 변경한다.
  • 데이터 의존성 제어흐름이 조건부 동작을 구현하는 보다 일반적이고 공통된 방법이다. 한번 살펴 보겠다.
    • 보통 C, 기계어의 인스트럭션들은 모두 프로그램에 나타나는 순서대로 순차적 실행이 일어난다.
      • 하지만 순서를 점프 인스트럭션으로 변경 할 수 있다. 때에 따라서, 어떤 시험의 결과에 따라 프로그램의 다른 일부분으로 제어를 넘겨준다.
      • 컴파일러는 C의 제어 구문을 구현하는 데 이러한 낮은 수준 방법에 기초하여 인스트럭션 코드를 생성해야 한다.
    • 본문에서는 먼저 조건형 연간을 구현하는 두 가지 방법에 대해 다룬다.

 

3.6.1 조건 코드

정수 레지스터들과 함께 CPU는 가장 최근 산술 또는 논리연산의 특성을 설명하는 단일 비트 조건 코드로 구성된 레지스터들을 운영한다.

  • 이 레지스터들은 조건부 분기를 수행하기 위해서 시험 될 수 있다.이 조건 코드들이 가장 유용하다 :
    • CF : Carry Flag. 가장 최근의 연산에서 가장 중요한 비트로부터 받아 올림이 발생한 것을 표시한다. 비부호형 연산에서 오버플로우를 검출 할 때 사용한다.
    • ZF : Zero Flag. 가장 최근의 연산 결과가 0 인 것을 표시한다.
    • SF : Sign Flag. 가장 최근 연산이 음수를 생성 한 것을 표시한다.
    • OF : Overflow Flag. 가장 최근 연산이 양수/음수의 2의 보수 오버플로우를 발생시킨 것을 표시한다.

예를 들어, 정수 변수 a, b, t를 사용하는 C 할당문 t = a + b 를 수행하기 위해 ADD 인스트럭션을 사용했다고 가정하자.

  • 그러면, 조건 코드는 다음의 C 수식에 따라 설정된다 :
CF  (unsigned) t < (unsigned) a Unsigned overflow
ZF (t == 0) Zero
SF (t < 0) Negative
OF (a < 0 == b < 0) && (t < 0 ≠ a < 0) Signed overflow

leaq 인스트럭션은 주소 계산에 사용하기 위한 것이므로, 조건 코드를 변경하지 않는다.

  • 반면에, 우리가 앞에서 산술연산 목록으로 봤던 모든 인스트럭션들은 조건 코드 값을 바꾼다.
    • XOR 같은 논리연산에서는 캐리와 오버플로우 플래그가 0으로 세팅된다.
    • 쉬프트 연산에서는 캐리 플래그가 쉬프트되어 없어지는 마지막 비트로 설정되며, 오버플로우 플래그는 0으로 세팅 된다.
    • INC, DEC 인스트럭션들은 오버플로우와 Zero Flag을 세팅하지만, Carry Flag에는 영향을 주지 않는다.
  • 또 조건 코드 값이 변경될 뿐만 아니라, 레지스터들은 변경시키지 않으면서 조건 코드만 변경 해주는 두 개의 인스트럭션 클래스가 있다.

여기에 나열 되어 있다.

  • CMP 인스트럭션 들은 두 오퍼랜드의 차에 따라 조건 코드를 설정한다.
    • 이들은 목적지를 갱신하지 않고, 조건 코드를 설정한다는 점을 제외하고는 SUB 인스트럭션과 같은 방법으로 동작한다.
    • ATT 형식에서 오퍼랜드 들은 역순으로 나열되기 때문에, 코드를 읽기가 어렵다.
    • 이 인스트럭션들은 만일 두 오퍼랜드가 같으면 Zero Flag를 1로 설정한다.
  • 다른 플래그들은 두 오퍼랜드의 순서 관계를 결정하는데 사용 될 수 있다.
    • TEST 인스트럭션은 목적지 오퍼랜드를 변경하지 않으면서, 조건 코드를 설정하는 점만 제외하고는 AND 인스트럭션과 같은 방식으로 동작한다.
      • 일반적으로 같은 오퍼랜드가 반복되거나, 오퍼랜드 중의 하나는 시험 할 비트를 가리키는 마스크이다.
        • %rax 가 음수인지, 0인지, 양수인지, 알기위해서는 testq %rax, %rax 같이 사용한다.

 

3.6.2 조건 코드 사용하기

조건 코드를 직접 읽는 것 외에, 조건 코드를 이용하는 보편적인 세 가지 방법이 있다.

  1. 조건 코드의 조합에 따라 0 또는 1을 한 개의 바이트에 기록한다.
    1. 위 사진에 설명된 인스트럭션들은 조건 코드들의 일부 조합에 따라, 하나의 바이트를 0 또는 1로 기록한다.
    • 이러한 인스트럭션의 클래스를 SET 인스트럭션이라고 부른다.
    • 이들 인스트럭션은 서로 다른 접미어를 가지며, 이에 따라 다른 조건 코드의 조합을 사용하여 서로 다른 동작을 한다.
      • 여기서 주의할 점은 이 인스트럭션들은 접미어를 사용해서 오퍼랜드의 크기를 나타내는 것이 아니다.
        • 이것은 조건 코드의 어떤 조합을 사용 할 것인지를 나타낸다는 점이다. 예를 들어 setl은 set long이 아닌 set less이다. setb는 set byte가 아닌 set below이다.
      • SET 인스트럭션은 목적지 단위로 하위 단일 바이트 레지스터 가운데 한 개나 단일 바이트 메모리 주소를 사요한다.
        • 이 바이트를 0 또는 1로 기록한다. 32, 64비트 결과를 만드려면 상위 비트들은 0으로 만들어줘야 한다.
  2. 조건에 따라 프로그램의 다른 부분으로 이동하는 방법이 있다.
  3. 조건에 따라 데이터를 전송하는 방법이 있다.

C에서 자료형 long인 a와 b 사이에 수식 a < b를 계산하는 전형적인 인스트럭션들은 이렇게 작성 할 수 있다.

comp:
	cmpq %rsi, %rdi
	setl %al
	movzbl %al, %eax
	ret

cmpq 인스트럭션의 비교 순서에 유의하자. (2번 줄)

  • 비록 인자들이 %rsi(b) , %rdi(a) 순서로 나열되어 있지만, 비교는 실제로 a와 b 사이에서 이루어진다.
  • movzbl 인스트럭션은 %eax의 상위 3바이트만을 지우는 게 아니라 전체 레지스터인 %rax의 상위 4바이트도 0으로 지운다는 사실을 기억하자.

일부 기존의 기계어 인스트럭션들에는 “유사어”라고 나열하는 다수의 이름을 갖는 인스트럭션들이 있다.

  • 예를 들어, setg(더 큰 경우에 1을 저장한다.) 그리고 setnle(작거나 동일하지 않으면 1을 저장한다.)은 동일한 기계어 인스트럭션이다.
    • 컴파일러와 역어셈블러는 어떤 이름을 사용 할 지 랜덤으로 결정한다

비록 모든 산술 및 논리연산이 조건 코드를 설정할지라도 다른 SET 인스트럭션들에 대한 설명은 t = a - b 계산 후의 결과에 따라 조건 코드를 설정하는 비교 인스트럭션이 실행된 경우에 적용된다.

  • cmpq a,b : 내부적으로 b - a 계산해서 플래그를 설정한다.
    • 그 다음, sete , setl , setg 같은 명령어들이 이 플래그들을 보고 작동한다.
  • sete (= equal)
    • sete → Zero Flag가 1이면 a == b라는 뜻. a - b가 0이면 동일한거니까?
  • setl (= signed less 일 때, 즉 a < b)
    • Overflow 없을 때 (OF = 0)
      • 결과가 음수라면 → SF = 1 → a < b
      • 결과가 양수라면 → SF = 0 → a ≥ b
    • Overflow 있을 때 (OF = 1)
      • 플래그로 판단하는 방향이 반대가 된다.
      • SF = 0 → a < b
      • SF = 1 → a > b
    a < b 인지 확인하고 싶다 → (SF ^ OF) == 1
    • SF, OF가 다르면 a < b
    • 같으면 a ≥ b

기계여 코드가 어떻게 부호형과 비부호형 값을 구분하고, 구분하지 못하는지 이해하는 것은 중요하다.

  • C에서와 달리, 기계어 에서는 데이터의 타입과 프로그램에서의 값을 연관시키지 않는다.
  • 그 대신, 두 경우에 있어서 대부분 동일한 인스트럭션을 사용한다.
    • 왜냐하면 많은 산술연산들이 비부호형과 2의 보수 산술연산에서 동일한 비트수준 동작을 갖기 때문이다.
    • 다른 버전의 우측 쉬프트, 나눗셈, 곱셈을 사용하고,
    • 조건 코드의 다른 조합을 사용하는 일부 경우에는 부호형과 비부호형을 다루는 데 다른 인스트럭션을 사용하기도 한다.

 

3.6.3 점프 인스트럭션

일반적인 실행의 경우, 인스트럭션들은 나열된 순서에 따라 순차적으로 실행된다.
점프 인스트럭션은 프로그램이 완전히 새로운 위치로 실행을 전환하도록 한다.

  • 이들 점프의 목적지를 보통 어셈블리 코드에서 레이블 (label)로 표시한다. 다소 어거지지만 이걸 보자.
movq $0, %rax
	jmp .L1
	movq (%rax), %rdx
.L1:
	popq %rdx

인스트럭션 jmp .L1은 프로그램이 movq 인스트럭션을 건너뛰는 대신, popq로 실행을 다시 시작하게 한다.

  • 목적 코드 파일을 만들기 위해서 어셈블러는 모든 레이블이 붙은 인스트럭션들의 주소를 결정하고, “jump target”을 인코딩한다.

이 내용에서, 여러 가지 점프 인스트럭션을 확인 할 수 있다. jmp 인스트럭션은 무조건적으로 점프한다.

  • 점프 목적지가 인스트럭션의 일부로 인코딩 되는 경우는 직접 점프한다.
    • 직접 점프는 예제 어셈블리 코드에 보는 것 처럼 .L1과 같이 점프 대상을 레이블로 프로그램 내에 작성한다.
  • 점프 대상을 레지스터나 메모리 위치로부터 읽어들여야 하는 경우에는 간접 점프를 사용한다.
    • 간접 점프는 ‘*’ 처럼 메모리 오퍼랜드 중의 하나를 이용한 오퍼랜드 식별자를 합쳐서 작성한다.
      • 예를 들어 jmp *&%rax 이 인스트럭션은 레지스터 %rax의 값을 점프 목적지로 사용한다.
      • jmp *(%rax) 이 인스트럭션은 %rax에 저장된 값을 읽기 주소로 사용하여, 메모리에서 점프 목적지를 읽어들인다.
  • 따로 언급하지 않되 표에 있는 인스트럭션들은 모두 조건부 점프이다.
    • 조건 코드의 일부 조합에 의해 코드 순서의 다음 인스트럭션을 실행하거나 아니면 점프를 실행한다.
  • 이들 인스트럭션의 이름과 점프를 실행하기 위한 조건들은 SET 인스트럭션에서의 조건들과 일치한다.
    • 조건부 점프는 직접 점프만 가능하다.

 

3.6.4 점프 인스트럭션 인코딩

어셈블리 코드에서, 점프 목적지는 Symbol Label을 사용해서 작성한다. 어셈블러와 나중에 다루는 링커는 점프 목적지를 적절히 인코딩한다.

여러가지 방법이 있는데, 가장 일반적인 방법은 PC Relative (PC 상대적) 방법이다.

  • 대상 인스트럭션과 점프 인스트럭션 바로 다음에 오는 인스트럭션 주소와의 차이를 인코딩한다.
  • 이들 오프셋은 1,2, 4 바이트 정도로 인코딩이 가능하다.
    • 두 번째 인코딩으로, “절대” 주소를 제공하는 방법으로 대상을 직접 명시하기 위해 4바이트를 사용한다.
    • 어셈블러와 링커는 점프 목적지를 인코딩 하는 방법을 적절히 선택한다.

PC-상대 주소의 예로, 어떤 함수에 대한 다음의 어셈블리 코드는 branch.c 파일의 컴파일 과정에서 생성된 것이다.

이 프로그램은 두 개의 점프를 포함하고 있다.

  • 2번 줄의 jmp 인스트럭션은 더 높은 주소로 전진 이동하도록 한다.
  • 그 반면, 7번 줄의 jg 인스트럭션은 낮은 위치로 되돌아가는 점프가 실행된다.
movq %rdi, %rax
jmp .L2
.L3:
	sarq %rax
.L2:
	testq %rax, %rax
	jg .L3
	rep; ret

이 코드를 .o 형식의 역어셈블 버전으로 확인하면 아래와 같다.

우측에 있는 역 어셈블러가 생성한 주석을 보면, 점프의 목적지가 2번 줄의 점프 명령은 0x8로 계산, 5번 줄은 0x5로 계산되었다.

  • 그렇지만 바이트 인코딩을 보면 첫 번째 점프 인스트럭션의 목적지가 0x03으로 인코딩 된 것을 알 수 있다.
  • 이것을 다음 인스트럭션의 주소인 0x05에 더하면 점프 목적지 주소인 0x8을 얻을 수 있으며, 이것이 바로 4번줄에 있는 인스트럭션의 주소이다.

마찬가지로, 두 번째 점프 인스트럭션의 목적지는 0xf8로 단일 바이트 2의 보수 표시로 인코딩 된다.

  • 이것을 6번 줄 인스트럭션의 주소인 0xd에 더하면 0x5가 되고, 이것은 3번 줄 인스트럭션의 주소이다.

이 예제에서 얻을 수 있는 것은, PC-상대 주소 지정을 수행 할 때 프로그램 카운터의 값은 점프 인스트럭션 자신의 주소가 아니다.

  • 점프 다음에 나오는 인스트럭션 주소가 된다.
  • 이러한 관습은 그 기원이 프로세서가 아니라 인스트럭션을 실행 할 때 첫 번째 단계로 프로그램 카운터를 갱신하던 초기 구현 시절로 거슬러 올라간다.

바로 위에 있는 이 내용은 위에서 봤던 프로그램을 링크 단계 이후에 역어셈블한 결과를 보여준다.

  • 인스트럭션들은 다른 주소에 재배치되었지만, 2번과 5번 줄의 점프 목적지 값은 바뀌지 않은 채로 남아 있다.
  • PC-상대 방식으로 점프 목적지를 인코딩하면, 인스트럭션들이 간결하게 인코딩 될 수 있고, 목적코드는 수정 없이 메모리 상의 다른 위치로 이동 될 수 있다.

 

3.6.5 조건부 분기를 조건제어로 구현하기

C에서 조건부 수식과 문장을 기계어 코드로 번역하는 가장 일반적인 방법은 조건부 및 무조건 점프를 함께 사용하는 것이다.

// A. original C code
long lt_cnt = 0;
long ge_cnt = 0;

long absdiff_se(long x, long y)
{
	long result;
	if (x < y)
	{
		lt_cnt++;
		result = y - x;
	}
	else
	{
		ge_cnt++;
		result = x - y;
	}
	return result;
}
// B. Equivalent goto version.
long gotodiff_se(long x, long y)
{
	long result;
	if (x >= y)
	{
		goto x_ge_y;
	}
	lt_cnt++;
	result = y - x;
	return result;
	
	x_ge_y:
		ge_cnt++;
		result = x - y;
		return result;

	
}
C. Assembly
absdiff_se:
	cmpq %rsi, %rdi
	jge .L2
	addq $1, lt_cnt(%rip)
	movq %rsi, %rax
	subq %rdi, %rax
	ret
	.L2:
	addq $1, ge_cnt(%rip)
	movq %rdi, %rax
	subq %rsi, %rax
	ret

코드 A는 두 수의 차이를 절대값으로 계산하는 C 함수이다.
이 함수는 전역변수 lt_cnt와 ge_cnt로 인코딩 된 두 개의 카운터 중의 하나를 증가시키는 부수효과도 갖는다.

GCC는 C번 형태의 어셈블리 코드를 만들어내고, 이 코드를 바탕으로 다시 C언어로 만든 것이 B에 위치한 코드이다.

  • 이것은 어셈블리 코드에서의 모조건 점프와 유사한 goto문이 포함되어 있다. 이러한 프로그래밍 스타일을 “goto code”라고 한다.
  • 이 goto code에서 5번 줄의 goto x_ge_y 문장은 9번 줄의 레이블로 점프를 발생시킨다. 만약 점프 할 필요가 없다면 그 아래 내용을 마저 실행하고 리턴할 것이다.
  • 어셈블리 코드 구현에서, 2번 줄에서 먼저 두 개의 오퍼랜드를 비교하여 조건 코드를 기록한다.
  • 비교 결과 x이 같거나 큰 경우 ge_cnt를 증가시키는 8번 줄에서 시작하여, x - y를 리턴 값으로 계산하는 코드 블록으로 점프한다.
  • 그렇지 않은 경우 전역변수 lt_cnt를 증가시키고 y - x를 리턴 값으로 계산하고 리턴하는 코드를 4번 줄부터 진행한다.
    • 어셈블리 absdiff_se는 gotodiff_se와 밀접하게 따라가고 있음을 알 수 있다.

C언어의 조건문은 이런느낌이다 :

if (test-expr)
	then-statement
else
	else-statement

조건문은 어셈블리에서 이런 느낌이다. 다만 설명은 C로.. :

t = test-expr;
if (!t)
	goto false;
then-statement
goto done;
false:
	else-statement
done:

즉, 컴파일러는 else-문, then-문에 대해 별도의 코드 블록을 생성한다. 정확한 블록이 실행되도록 조건부와 무조건 분기를 삽입한다.

 

3.6.6 조건부 이동으로 조건부 분기 구현하기

조건부 동작을 구현하는 전형적인 방법.

  • 조건이 만족되면 프로그램의 한 가지 실행 경로를 따른다.
  • 아니면 다른 경로를 탄다.

이러한 제어의 조건부 전환을 통해 이루어진다. 이 방법은 간단하고, 일반적이지만, 최신 프로세서들에게는 매우 비효율적일 수 있다.

또 다른 전략은 데이터의 조건부 전송을 이용하는 것이다.

  • 조건부 동작의 산출물 모두를 계산하고, 조건에 따라 하나만 선택하는 방식이다. 제한적인 상황에서 의미를 갖지만, 최신 프로세서의 성능 특성과 잘 일치하는 간단한 조건 부 이동 인스트럭션으로 구현된다.

조건부 이동을 사용해서 컴파일 될 수 있는 예제코드이다.

// A. Original C Code
long absdiff(long x, long y)
{
	long result;
	if (x < y)
	{
		result = y - x;
	}
	else
	{
		result = x - y;
	}
	return result;
}
// B. copied routine From Assembly, but C code
long cmovdiff(long x, long y)
{
	long rval = y - x;
	long eval = x - y;
	long ntest = x >= y;
	/* line below requires
			single instruction: */
	if (ntext) rval = eval;
	return rval;
}
// C. Assembly Code
absdiff:
		movq %rsi, %rax
		subq %rdi, %rax
		movq %rdi, %rdx
		subq %rsi, %rdx
		cmpq %rsi, %rdi
		cmovge %rdx, %rax
		ret

핵심은 어셈블리 코드의 단일 cmovge 인스트럭션(7번 줄)이 cmovdiff의 조건부 할당(8번 줄)을 구현한다는 것이다.
이것은 6번 줄의 cmpq 인스트럭션이 한 개의 값이 다른 값보다 크거나 같은지를 나타내는 경우에만 데이터를 소스 레지스터에서 목적지로 전송하게 된다.

자, (3.6.5)에서 다루었던 조건부 제어 이동 기반 코드보다 조건부 데이터 이동 코드가 성능이 더 우수한 이유를 이해하기 위해, 최신 프로세서들이 어떻게 동작하는지 먼저 알아야한다.

  • 프로세서들은 각 인스트럭션을 일련의 단계로 처리하며, 이 단계들은 각각 요구된 동작의 작은 부분만을 실행하는 파이프라인을 통해 높은 성능을 얻는다.
    • (example) 메모리로부터 인스트럭션의 인출, 인스트럭션 타입의 결정, 메모리로부터의 읽기쓰기, 산술연산 수행.. 프로그램 카운터 갱신..
  • 이 방식은 예를 들면, 이전 인스트럭션의 연산을 수행하는 동안에 다른 인스트럭션을 인출하는 것처럼 연속되는 단계들을 중첩시켜 고성능을 얻는다.
    • 이를 위해서는 파이프라인을 실행할 인스트럭션들로 미리 채우기 위해 실행할 인스트럭션들의 순서를 훨씬 일찍 결정 할 수 있어야 한다.
      • 조건부 점프를 만나게되면, 분기 조건에 대한 계산이 끝나기 전까지는 분기가 어떻게 될지 결정 할 수 없다.
      • 프로세서는 각 점프 인스트럭션이 실행될지를 추측하기 위한 복잡한 분기예측 회로를 채택하고 있다.

이를 안정적으로 예측 할 수 있다면, 인스트럭션 파이프라인은 인스트럭션들로 가득 채워질 수 있다.

  • 반면에, 점프 하나를 잘못 예측하면, 미래의 인스트럭션을 위해 이미 실행한 작업결과들을 상당 부분 버려야한다. 정확한 위치에서 다시 인스트럭션들을 파이프라인에 채우는 작업을 수행해야 한다.
    • 이러한 예측 오류는 약 15-30 클럭 사이클의 손실을 발생시켜 상당한 성능 감소를 발생시킨다.
  • 인텔 하스웰 프로세서에서 바로 위의 코드에 대해 반복 시도를 해본 결과 예측은 매우 어려우며, 복잡한 분기예측 시스템조차 50%의 정확도를 나타냈다. 결과적으로, 분기의 예측오류 손실이 이 함수의 성능을 결정하게 된다.
  • 조건부 점프를 사용하는 64비트 코드에서, 분기 패턴을 쉽게 예측 할 수 있는 경우에는 함수가 호출 당 약 8클럭을, 랜덤인 경우에는 약 17.5 클럭 사이클을 소모함을 발견하였다.
    • 이런 상황으로부터 약 19 클럭 사이클의 예측 오류 손실을 유추 할 수 있다. 분기가 정확히 예측 되었는지에 따라 8 - 27 사이클의 범위를 갖게 된다.
      • 이것은 프로세서가 파이프라인을 꽉 찬 상태로 유지하는 것을 더욱 쉽게 해준다. 반면, 조건부 이동명령을 사용해서 컴파일 한 코드는 테스트 하는 데이터와 상관없이 8 클럭 사이클을 필요로 한다.

이 그림은 64비트로 가능한 조건부 이동 move 인스트럭션을 보여주고 있다.

  • 이들 인스트럭션은 두 개의 오퍼랜드를 갖는다. [소스 레지스터 또는 메모리 위치 S ], [목적지 레지스터 R ].
  • SET, JUMP 인스트럭션에서처럼 이 인스트럭션들의 결과는 조건 코드 값에 따라 달라진다.
  • 소스 값은 메모리나 소스 레지스터로부터 읽히지만, 목적지에는 명시된 조건이 만족 될 때만 복사한다.

소스와 목적지 값은 16, 32, 또는 64비트를 갖는다. 단일 바이트 조건부 이동은 지원되지 않는다.

오퍼랜드의 길이가 명시적으로 인스트럭션의 이름에 인코딩되는 movw, movl 무조건형 인스트럭션과 다르다.

  • 어셈블러는 목적지 레지스터의 이름으로부터 조건부 이동 인스트럭션의 오퍼랜드 길이를 추정한다. 그래서 동일한 인스트럭션 이름이 모든 오퍼랜드 길이에 대해서 사용 될 수 있다.
  • 조건부 점프와는 달리, 프로세서는 테스트의 결과를 예측하지 않고서도 조건부 이동 인스트럭션을 실행 할 수 있다. 프로세서는 간단히 소스 값을 읽고 조건 코드를 검사하고, 목적지 레지스터를 갱신하거나 그대로 유지한다.
  • 어떻게 조건부 동작이 조건부 데이터 이동을 통해서 구현되는지 이해하기 위해서 다음과 같은 일반적인 조건부 수식과 할당을 살펴보자.

v = test-expr ? then-expr : else-expr; 이 수식을 조건부 제어 전송을 이용해서 컴파일하는 표준 방식은 다음의 형태를 갖게 된다.

if (!test-expr)
	goto false;
	v = then-expr;
	goto done;
false:
	v = else-expr;
done:

이 코드는 두 개의 코드를 포함한다 : then-expr을 계산하는 코드와 else-expr을 계산하는 코드.

  • 조건부와 무조건부 점프의 조합을 사용해서 오직 하나의 코드만이 계산되도록 한다.
    • 조건부 이동 move 에서는 then-expr와 else-expr는 test-expr의 계산 결과에 따라 선택되는 최종 값의 의해 모두 계산된다.
    • 다음의 추상화 된 코드로 설명 할 수 있다.
v = then-expr;
ve = else-expr;
t = test-expr;
if (!t) v = ve;

이 코드의 마지막 문장은 조건부 move로 구현되었다. 값 ve는 테스트 조건이 만족하지 않는 경우에만 복사가 이루어질 것이다.

  • 모든 조건부 수식들이 조건부 이동으로 컴파일되는 것은 아니다.
    • 중요한 점은 우리가 보여준 추상화된 코드에서 테스트 결과에 상관없이 then-expr와 else-expr 모두가 실행된다는 점이다.
      • 만일 둘 중의 어느 한 곳에서라도 오류나 부수적 결과를 만든다면, 유효하지 않은 동작이 발생 할 것이다.
      • 3.6.5에서 봤던 예제가 이에 해당한다 (대충 보라)

두 번째 예시로, 다음의 C 함수를 한번 살펴보자.

long cread(long *xp)
{
	return (xp ? *xp : 0);
}

우선 이 프로그램은 포인터가 null일 때 그 결과를 0으로 설정하기 위해 조건형 이동을 이용해서 컴파일 하는 좋은 예이다.

cread:
	movq (%rdi), %rax
	testq %rdi, %rdi
	movl $0, %edx
	cmove %rdx, %rax
	ret

이건 사실 오류가 있다. movq를 사용하는 xp의 역참조(2번 줄)이 테스트가 실패해도 발생해서 널포인터 역참조 오류를 상반 할 수 있다.

  • 따라서 이 코드는 분기하는 코드를 이용해서 컴파일 해야한다.

조건부 이동을 사용한다고 해서 언제나 코드 효율성을 개선 할 수 있는 것은 아니다. 예를 들어,

  • then-expr이나 else-expr의 계산이 상당한 양의 계산을 요하는 경우에는 해당 조건이 만족되지 못한다면 이런 노력이 낭비되고 만다.

컴파일러는 낭비되는 계산량과 분기 예측오류에 의한 잠재적 성능 손실 사이의 상대적 성능을 고려해야 한다. 실제로는 컴파일러가 이러한 결정을 정확하게 하기 위한 정보가 충분하지 않다.

  • 예를 들어, 분기 명령이 예측 가능한 패턴을 얼마나 잘 따르게 될 지 알 수 없다.
    • GCC로 실험해 본 결과, 하나의 add와 같이 두 수식이 매우 간단히 계산될 수 있는 경우만 조건부 이동 move을 사용한다는 것이 드러난다.
    • GCC는 심지어 분기 예측 오류의 비용이 복잡한 계산의 비용보다 더 큰 경우에도, 조건부 제어 이동을 사용한다.

종합하면, 조건부 동작을 구현하기 위해서 조건부 제어의 전환 외에 추가적으로 조건부 데이터 이동을 사용 할 수 있다는 것을 알 수 있었다. 비록 매우 제한적인 경우에만 사용 될 수 있지만, 이들은 상당히 보편적이고, 최신 프로세서들의 연산들과도 보다 더 잘 동작한다.

3.6.7 반복문

C에서는 여러 가지 반복문 구문을 제공한다. do-while, while, for. 기계어에는 여기에 대응되는 인스트럭션이 없다.

  • 조건부 테스트와 점프를 함께 사용해서 반복문의 효과를 구현한다. GCC와 다른 컴파일러들은 반복 코드를 두 개의 기본 루프 패턴에 기초해서 반복문 코드를 생성한다. do-while 문을 통해 보다 복잡한 구현 사례를 점진적으로 살펴보자.

Do-While

do
	body-statement
	while (test-expr);

반복문은 body-statement를 반복적으로 실행하고, test-expr을 계산하여 그 결과가 0이 아니면 반복 수행을 계속한다.

body-statement가 적어도 한 번은 실행된다는 점에 주목하자.

이러한 일반형식을 조건문과 goto문으로 변환하면 다음과 같다.

loop:
	body-statement
	t = test-expr;
	if (t)
		goto loop;

즉, 매 실행마다 프로그램은 본체 문장과 테스트 수식을 계산한다. 예제로, 팩토리얼에 대한 내용을 do-while로 구현 한 것도 확인해보자. (actived n > 0)

// A. original c code
long fact_do(long n)
{
	long result = 1;
	do {
		result *= n;
		n = n-1;
	} while (n > 1);
	return result;
}
// b. c goto version, converted from assembly
long fact_do_goto(long n)
{
	long result = 1;
loop:
	result *= n;
	n = n-1;
	if (n > 1)
		goto loop;
	return result;
}
// c. assembly code
fact_do:
	movl $1, %eax
.L2:
	imulq %rdi, %rax
	subq $1, %rdi
	cmpq $1, %rdi
	jg .L2
	rep; ret

b 형태에서 확인 할 수 있는 goto형태의 코드는 do_while 루프 구조가 어떻게 하위 수준의 테스트와 조건부 점프의 조합들로 변환되는지를 보여준다.

  • result를 초기화 한 후에, 프로그램은 루프를 시작한다. 먼저 result와 n을 갱신하는 것으로 루프의 본체를 실행한다.
  • 그 후 n > 1 인지 테스트하고, 참인 경우에는 루프의 시작 부분으로 점프해 돌아간다. c의 어셈블리 코드는 goto 코드의 원본을 보여준다.
  • 조건부 점프 인스트럭션 jg(line 7)은 루프를 구현하는 주요 인스트럭션이다. 반복을 계속할지, 루프를 멈출지를 결정 할 수 있다.
  • 어셈블리 코드를 역 엔지니어링 하려면 프로그램 값을 위해 어떤 레지스터들이 이용되었는지 결정해야 한다. 이 경우, 매핑은 결정하기가 매우 쉽다.
    • n은 레지스터 %rdi로 함수에 전달되는 것을 알고 있다. 레지스터 %rax 가 1로 초기화 되는 것을 알 수 있다. (line 2)
      • 상기하기 : 이 인스트럭션이 %eax 를 목적지로 가지고 있지만, 이것도 %rax 의 상위 4바이트를 0으로 설정 할 것이다.
    • 이 레지스터도 4번 줄에서 곱셈으로 업데이트 되는 것을 알 수 있다.
      • 나아가서 %rax 가 함수 값을 리턴하기 위해 사용되기 때문에 이것은 리턴되는 값을 저장하는 데 종종 선택된다. 따라서 우리는 %rax가 프로그램 값 result에 대응된다고 결론 내린다.

[연습 문제] GCC는 다음과 같은 어셈블리 코드를 생성한다.

dw_loop:
	movq %rdi, %rbx
	movq %rdi, %rcx
	idivq $9, %rcx
	leaq (,%rdi,4), %rdx
.L2:
	leaq 5(%rbx,%rcx), %rcx
	subq $1, %rdx
	testq %rdx, %rdx
	jg .L2:
	rep; ret

While 루프

while문의 일반적인 형태는 이렇다.

while (test-expr)
	body-statement

while문은 test-expr을 먼저 계산해서, body-statement를 실행하기 전에 종료 될 수 있다는 점에서 do-while문과 차이가 있다.

기계어로 번역하는 방법에는 여러 가지 방법이 있으며, 이 가운데 두 가지 방법이 GCC가 생성하는 코드에서 이용 된다.

  • 이 둘은 do-while루프에서 우리가 본 것과 루프 구조가 동일하고, 초기 테스트의 구현 방법에서만 다르다.
  • 우리가 중간으로 점프라고 부르는 첫 번째 번역 방법은 루프의 맨 마지막에서 테스트로 무조건 점프를 수행하기 위한 초기 테스트를 한다.
    • 이것은 일반적인 while 루프에서 goto 코드로 번역하기 위한 다음과 같은 템플릿으로 표현 될 수 있다:
goto test;
loop:
	body-statement
test:
	t = test-expr;
	if (t)
		goto loop;
// A. original C code
long fact_while(long n)
{
	long result = 1;
	while (n > 1)
	{
			result *= n;
			n = n-1;
	}
	return result;
}
// B. goto code, converted from assembly
long fact_while_im_goto(long n)
{
	long result = 1;
	goto test;
loop:
	result *= nl
	n = n - 1;
test:
	if (n > 1)
		goto loop;
	return result;
}
// C. assembly code
fact_while:
	movl $1, %eax
	jmp .L5
.L6:
	imulq %rdi, %rax
	subq $1, %rdi
.L5:
	cmpq $1, %rdi
	jg .L6
rep; ret
  • 두번째 방법인 조건부-do는 먼저 이 코드를 초기 테스트가 실패하는 경우에 루프를 건너뛰도록 조건부 분기를 이용해서 do-while 루프로 번역한다.
    • GCC는 예를 들어 명령줄 옵션 -01을 이용해서 상위 수준의 최적화로 컴파일 할 때는 이 전략을 따른다.
    • 이 방법은 일반적인 while 루프 형태를 do-while 루프로 번역 할 때, 다음의 템플릿으로 표시 될 수 있다 :
t = test-expr;
if (!t)
	goto done;
do
	body-statement
	while (test-expr);
done:

위에 적힌 코드는 goto로 다음과 같이 변환 될 수 있다.

t = test-expr;
if (!t)
	goto done;
loop:
	body-statement
	t = test-expr;
	if (t)
		goto loop;
done:

이러한 구현 전략을 이용해서 컴파일러는 종종 테스트 조건이 항상 만족되는지 결정하는 것 같은 초기 테스트를 최적화 할 수 있다.

지금 볼 것은 중간으로 점프 부분으로 구현했던 코드의 동일한 C코드이지만, GCC를 명령 -01을 주었을 때 발생하는 컴파일 결과를 볼 수 있다.

// A. original C code
long fact_while(long n)
{
	long result = 1;
	while (n > 1)
	{
			result *= n;
			n = n-1;
	}
	return result;
}
// B. goto version
long fact_while_gd_goto(long n)
{
	long result = 1;
	if (n <= 1)
		goto done;
		
loop:
	result *= n;
	n = n - 1;
	if (n != 1)
			goto loop;
			
done:
	return result;
}
fact_while:
	cmpq $1, %rdi
	jle .L7
	movl $1, %eax
.L6:
	imulq %rdi, %rax
	subq $1, %rdi
	cmpq $1, %rdi
	jne .L6
	rep; ret
.L7:
	movl $1, %eax
	ret

For 루프문

for 반복문의 일반적인 유형은 다음과 같다.

for (init-expr; test-expr; update-expr)
	body-statement

C 언어 표준에는 위 반복문이 while 반복문을 사용하는 다음의 코드와 동일한 동작을 한다고 기록되어 있다.

init-expr;
while (test-expr)
{
	body-statement
	update-expr;
}

프로그램은 먼저 초기화 수식인 init-expr을 계산한다.

  • 테스트 조건은 test-expr를 계산하는 곳에서 루프에 들어가며, 테스트가 실패하면 루프를 빠져나오고, 반복문 body-statement를 실행하며, 마지막으로 update-expr을 계산한다.
  • for 루프에 대해 생성된 코드는 최적화 수준에 따라 while 루프를 do_while을 위한 두 개의 번역 전략 중 하나를 따른다.
    • 즉, 중간으로-점프 전략은 goto 코드를 생성한다.
// 중간으로 점프 형식의 for 루프
init-expr;
goto test;
loop:
	body-statement;
	update-expr;
test:
	t = test-expr;
	if (t)
		goto loop;
// 조건형 do 전략으로의 for 루프
init-expr;
t = test-expr;
if (!t)
	goto done;
loop:
	body-statement
	update-expr;
	t = test-expr;
	if (t)
		goto loop;
done:

일례로 for 반복문을 사용하는 factorial 함수를 살펴보자.

long fact_for(long n)
{
	long i;
	long result = 1;
	for (i = 2; i <= n; i++)
	{
		result *= i;
	}
	return result;
}

위에서 나타낸 것처럼 for 반복문을 사용해서 factorial 함수를 작성하는 자연스러운 방법은 인수를 2에서 n까지 곱해 나가는 것이며, 이 함수는 while이나 do-while 반복문을 사용하는 코드와는 다르다.

init-expr test-expr update-expr body-statement

i = 2 i ≤ n i++ result *= i;

이들 요소들을 for 루프를 while 루프로 변환하기 위해 이 템플릿에 대입해보면 다음과 같은 코드를 만들 수 있다.

long fact_for_while(long n)
{
	long i = 2;
	long result = 1;
	while (i <= n)
	{
			result *= i;
			i++;
	}
	return result;
}

중간으로-점프 변환을 이 while 루프에 적용하면, 다음과 같은 goto 버전의 코드를 얻을 수 있다.

long fact_for_jm_goto(long n)
{
	long i = 2;
	long result = 1;
	goto test;
loop:
	result *= i;
	i++;
test:
	if (i <= n)
		goto loop;
	return result;
}

실제로, GCC가 명령줄 옵션 -0g를 이용하여 생성한 어셈블리 코드는 다음의 형태이다.

n in %rdi

fact_for:
	movl $1, %eax
	movl $2, %edx
	jmp .L8
.L9:
	imulq %rdx, %rax
	addq $1, %rdx
.L8:
	cmpq %rdi, %rdx
	jle .L9
	rep; ret

이것을 통해 C에서의 세 개의 반복문 유형 모두 do-while, while, for 하나 이상의 조건부 분기를 갖는 코드를 생성하는 단순한 전력에 의해 번역된다는 것을 배웠다.

제어의 조건부 전환은 반복문을 기계어 코드로 번역하는 기본 메커니즘을 제공한다.

3.6.8 Switch문

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

점프 테이블은 그 원소 i가 switch문의 인덱스가 i일 때 프로그램이 실행해야 하는 동작을 구현하는 코드 블록의 주소가 되는 배열이다.

  • 이 코드는 점프 인스트럭션의 목적지를 찾아내기 위해서 switch문의 인덱스를 사용하여 점프 테이블을 배열처럼 참조한다.
  • 점프 테이블은 다단계의 조건문보다 switch문을 실행하는 데 걸리는 시간이 case 수에 관계 없다는 점이 장점이다.
  • GCC는 케이스 값의 밀집도와 case의 수를 고려해서 switch문의 번역 방법을 선택한다.

왼쪽에 있는 코드는 C Switch문의 예제를 보여준다. 이 예제는 여러 흥미로운 특징이 있는데, 연속적인 범위에 분포하지 않고, 다중 레이블인 경우도 있으며, 다른 case에 들어가있는 case도 있다.

switch_eg를 컴파일 할 때 생성된 어셈블리 코드이다.

  • 이 코드의 동적은 이전 코드의 오른쪽에 위치한 switch_eg_impl에 나타나 있다. 이 코드는 C 언어의 확장 형태로, GCC가 점프 테이블을 위해 제공하는 기능을 활용 하고 있다.

배열 jt는 일곱 개의 원소를 가지고 있으며, 이들 각각은 각 코드 블록의 주소이다.

이들의 위치는 코드에서 레이블로 정의되고, “&&”로 시작되는 레이블들로 이루어지며, 코드 포인터에 의해 jt의 원소로 표시된다. (연산자 &가 데이터 값에 대한 포인터를 생성한다는 점 상기하기)

원본 C 코드는 case 값으로 100, 102-104, 106을 갖지만, 스위치문의 변수인 n은 임의의 정수가 될 수 있다.

  • 컴파일러는 n에서 100을 빼서 범위를 0에서 6사이로 만든 후, C 버전 코드에서 index라고 하는 새로운 프로그램 변수를 생성하였다.
  • 2의 보수로 표현된 음수를 비부호형 표시로 대응시키면 큰 양수 값이 된다는 사실을 이용하여 index를 비부호형 값으로 취급해서 분기 가능성을 보다 더 단순화시켰다.

따라서 index 값이 0-6 범위를 벗어나는지 시험을 6보다 큰지 시험하는 방식으로 수행하였다. C와 어셈블리 코드에서 index 값에 따라 점프해가기 위한 다섯 개의 서로 다른 위치가 존재한다.

loc_A(.L3), loc_B(.L5), loc_C(.L6), loc_D(.L7), loc_def(.L8)이며, 여기서 마지막 레이블은 default case의 목적지이다. 이 레이블 각각은 case 분기를 구현하는 코드 블록을 구분한다.

  • C와 어셈블리 코드에서 프로그램은 index를 6과 비교하여 만일 6보다 크면, default case로 점프한다.

switch문을 실행하는 데 있어서 핵심 단계는 점프 테이블을 통해서 코드의 위치로 접근하는 것이다.

이 부분은 C 코드 16번 줄에서 점프 테이블을 통해서 코드의 위치로 접근하는 것이다.

  • 이 계산된 goto는 GCC에서 C 언어 확장의 형태로 지원된다. 위 어셈블리 코드 예제에서, 비슷한 동작이 6번 줄에서 일어난다.
  • jmp 인스트럭션의 오퍼랜드는 “*”로 시작하며, 간접 점프를 나타내고, 오퍼랜드는 index 값을 저장하는 레지스터 %rdi 가 메모리 위치를 지정하는 색인으로 이용된다.
  • 예제의 C 코드는 코드 블록의 위치에 해당하는 7개의 포인터를 원소로 갖는 점브 테이블을 선언하고 있다.
    • 이 원소들은 n 값 100-106에 대응하는 0-6 값을 갖는다.
    • 점프 테이블은 원소 4와 6의 경우에 동일한 코드 레이블인 loc_D를 사용해서 이중 case를 처리하며, 1, 5처럼 해당이 없는경우 default case 레이블을 사용하게 될 것이다.
  • 어셈블리 코드에서 점프 테이블은 다음과 같은 선언으로 지정 된다 :

이 선언들은 .rodata(Read Only 데이터)라고 불리는 목적코드 세그먼트 내에서 7개의 quad 워드가 존재하고,
이들은 각각 어셈블리 코드 레이블들에 연관된 인스트럭션 주소들이라는 점을 말해 준다.

레이블 .L4는 이 할당 부분의 시작을 표시한다. 이 레이블에 연계된 주소는 간접 점프(line 5)의 시작주소로 사용된다.

서로 다른 코드 블록은 switch문의 여러 가지 분기를 구현한다. 이들의 대부분은 간단히 val 결과 값을 계산하고 함수의 끝 부분으로 이동한다.

이와 유사하게, 어셈블리 코드 블록에서도 레지스터 %rdi를 위한 값을 계산하여 저장하고 함수 끝 부분의 레이블 .L2로 표기된 위치까지 점프한다.

  • case 102만이 유일하게 이러한 패턴을 따르지 않는데, 이것은 이 case에 대한 코드가 본래의 C 코드에서 case 103의 코드 블록에서 연속되어 진행 되기 때문이다.
  • 이것은 .L5 레이블로 시작하는 어셈블리 코드 블록의 마지막에 jmp 인스트럭션을 생략해서 처리되며, 따라서 이 코드는 다음 블록의 실행까지 계속 진행한다.
    • 유사하게, switch_eg_impl의 C버전은 loc_B를 갖는 이 블록의 마지막 부분에 goto문이 없다.

이 코드의 모든 부분을 분석하려면 세심한 연구가 필요하다.
그러나 중요한 점은 점프 테이블을 사용하면 다중분기를 매우 효율적인 방법으로 구현 할 수 있게 된다는 것이다.

'컴퓨터 이론 > CS:APP' 카테고리의 다른 글

[CS:APP] 링커 7.1 - 7.14  (0) 2025.04.18
[CS:APP] Chap 3.10 - 11  (1) 2025.04.09
[CS:APP] Chap 3.7 - 9  (1) 2025.04.09
[CS:APP] Chap 3.1 - 3  (0) 2025.04.07
[CS:APP] 1 : 컴퓨터 시스템으로의 여행 일부  (0) 2025.03.24