[CS:APP] Chap 3.10 - 11

3.10 기계수준 프로그램에서 제어와 데이터의 결합

지금까지 머신코드가 프로그램 제어를 구현한 방법, 서로 다른 자료구조가 구현되는 방법을 보았다.

이곳에서, 가장 C 프로그래밍 에서 심오한 데이터와 자료가 상호작용하는 방식을 볼 것이다.

3.10.1 포인터 이해하기

포인터는 C의 핵심 특징이다. 이들은 다른 자료구조 내 원소들에 대한 참조를 생성하는 통일된 방법으로서의 역할을 수행한다.

  • 포인터는 초보들에게 혼란의 원인이지만, 그 아래에 깔려 있는 개념은 매우 간단하다.
  • [continue ..]
  • 포인터는 연관된 자료형을 갖는다.
    • 이 자료형은 어떤 종류의 객체를 이 포인터가 가리키는가를 의미한다. 다음과 같은 포인터 선언을 예제로 사용하겠다.
int *ip;
char **cpp;
  • 변수 ip는 int형 객체의 포인터이지만, cpp는 자기 자신이 char형 객체의 포인터인 객체를 가리키는 포인터이다.
    • 일반적으로 만일 객체가 자료형 T를 갖고 있다면, 포인터는 자료형 T를 갖는다. 특수한 void형은 범용 포인터를 표시한다.
      • 예를 들어, malloc 함수는 범용 포인터를 리턴하며, 이것은 할당 연산의 명시적 또는 암묵적 형변환을 통해 자료형을 갖는 포인터로 변환된다.
      • 포인터 자료형은 기계어 코드에 속한 개념은 아니다. 이들은 C 언어가 프로그래머들로 하여금 오류를 피하도록 돕기 위한 추상화 개념이다.
  • 모든 포인터는 특정 값을 가진다.
    • 이 값은 특정 자료형을 갖는 어떤 객체의 주소이다. 특수 값인 NULL 값은 포인터가 아무 곳도 가리키지 않는 것을 의미한다.
  • 포인터는 & 연산자를 사용해서 만든다.
    • 이 연산자는 lvalue로 구분되는 C 언어의 모든 수식에 적용 될 수 있으며, lvalue는 할당문의 좌측에 올 수 있는 식을 의미한다.
    • 여기에는 변수들, 구조체, 공용체, 배열의 원소들이 해당된다.
    • 연산자 ‘&’의 기계어 코드 구현은 leap을 사용해서 식의 값을 계산한다는 것을 이미 안다. 이 leap은 메모리 참조의 주소를 계산하기 위해 설계되었다.
  • 포인터는 * 연산자를 사용해서 역참조한다.
    • 그 결과는 포인터와 연관된 자료형을 갖는 값을 가져온다.
      • 역참조는 정해진 주소에 저장하거나 주소로부터 값을 가져오는 등의 메모리 참조를 사용해서 구현한다.
  • 배열과 포인터는 밀접한 관련이 있다.
    • 배열의 이름은 마치 포인터 변수처럼 참조 될 수 있다. (변경은 안된다!) 배열의 참조는 포인터 연산이나 역참조와 완벽하게 동일한 효과를 갖는다. a[3] == *(a+3) 배열 참조와 포인터 연산은 객체의 크기에 따라 오프셋을 조절해야 한다. 포인터 p에 대한 식 p+i를 값 p를 사용해서 작성 할 때, 최종 주소는 p + L * i로 계산된다. 여기서 L은 p와 관계된 자료형의 크기이다.
  • 한 종류의 포인터에서 다른 종류로의 자료형 변환은 그 종류만 바뀔 뿐, 값은 변화가 없다.
    • 형 변환의 한 가지 효과는 포인터 연산의 크기변환을 변경하는 것이다. 예를 들어 :
      • 만일 p가 값 p를 갖는 자료형 char의 포인터라면, 식 (int)p+7은 p + 28을 계산하며, (int*)(p+7)은 p + 7 을 계산한다.
        • (형변환이 덧셈보다 우선순위를 갖는 것을 상기하자.)
  • 포인터는 함수를 가리킬 수도 있다.
    • 이것은 프로그램의 다른 부분에서 호출 할 수 있는 코드에 대한 참조를 저장하거나 넘겨 줄 수 있는 강력한 기능을 제공한다.
      • 예를 들어 int fun(int x, int *p); 라고 정의된 함수가 있다고 하자.
      • 그러면, int (*fp)(int, int *); | fp = fun; 이런 식으로 포인터 fp를 선언하고 함수에 할당 할 수 있다.
      • 그리고 int y = 1; | int result= fp(3, &y); 이 포인터로 함수를 호출 할 수 있다.
      • 함수 포인터의 값은 함수의 기계어 표현에서 첫 인스트럭션의 주소이다.

3.10.2 실제 적용하기: GDB 디버거 사용하기

GNU 디버거인 GDB는 기계어 프로그램의 평가 및 분석에 유용한 기능을 제공한다. GDB를 이용하면 정교한 제어뿐만 아니라 동작 분석이 가능하다.

  • 책 267페이지에서 Commands List를 확인 할 수 있다.

일반적인 방법은 breakpoint를 프로그램에서 관심이 있는 부분 근처에 설정하는 것이다.

  • 함수의 시작 직후나 프로그램의 특정 주소에 설정 할 수 있다.
  • 프로그램 실행 중에 브레이크 포인트를 만나게 되면, 프로그램은 실행을 중단하고 제어를 사용자에게 넘긴다.
    • 브레이크 포인트로부터 레지스터나 메모리 위치의 값을 다양한 형식으로 조사 할 수 있다.
    • 또한, 한 번에 몇 개의 인스트럭션만 실행하여 프로그램을 단일 단계로 실행 할 수 있다.
      • 다음 브레이크 포인트 위치까지 프로그램을 진행 할 수도 있다.
  • 번외로, 많은 프로그래머들은 GDB의 GUI 버전인 DDD를 선호한다.

3.10.3 범위를 벗어난 메모리 참조와 버퍼 오버플로우

C에서는 배열 참조시 범위를 체크하지 않으며, 지역변수들에 스택에 보존용 레지스터들과 리턴 주소 같은 상태정보와 함께 스택에 저장된다는 것을 배웠다.

  • 이러한 조합때문에 심각한 에러가 발생 할 수 있는데, 스택에 저장된 상태정보가 범위를 벗어난 배열의 원소에 대한 쓰기 작업에 의해 변경되는 것이다.
    • 그러고 나서, 프로그램이 레지스터의 값을 재적재하거나 이렇게 변경된 상태정보를 사용해서 ret을 실행 할 때, 심각한 결과를 초래한다.
  • 특히 일반적인 상태 손실의 원인은 버퍼 오버플로우라고 알려져있다. 일반적으로 어떤 문자배열이 스택에 스트링을 가지고 할당 되어 있지만, 스트링의 크기는 배열에 할당된 공간을 초과한다. 이것은 다음과 같은 프로그램 예제를 통해 실행 해 볼 수 있다.
/* Implementation of library function gets() */
char *gets(char *s)
{
	int c;
	char *dest = s;
	while ((c = getchar()) != '\\n' && c != EOF)
		*dest++ = c;
	if (c == EOF && dest == s)
		/* No characters read */
		return NULL;
	*dest++ = '\\0'; /* Terminate string */
	return s;
}

/* Read input line and write it back */
void echo()
{
	char buf[8];
	gets(buf);
	puts(buf);
}

위 코드는 gets 라이브러리 함수의 심각한 문제를 나타내는 이 함수의 구현을 보여준다.

  • 표준 입력으로부터 한 줄을 읽어들이고 엔터 키나 오류상황이 발생했을 때 멈추는 하수이다.
  • 이 프로그램은 이 스트링을 인자 s라는 위치에 복사하고, 스트링의 마지막에 널 문자를 추가한다.
  • gets의 사용을 보여주기 위해 표준 입력에서 한 줄을 읽어들여서 다시 그것을 화면에 출력해 주는 echo 함수를 구현하였다.

gets 함수의 문제는 전체 스트링을 저장 할 수 있는 공간이 충분히 할당되었는지 결정 할 방법이 없다는 것이다. 위 예제에서, 의도적으로 버퍼의 크기를 매우 작게 설정하였다.

  • 즉 이 코드에서 길이가 7바이트보다 더 긴 스트링들은 모두 범위초과 쓰기를 발생시킨다.
  • echo에 대한 GCC의 어셈블리 코드 번역은 스택이 어떻게 구성되는지 유추 할 수 있다 :
echo:
	subq $24, %rsp
	movq %rsp, %rdi
	call gets
	movq %rsp, %rdi
	call puts
	addq $24, %rsp
	ret

아래 그림은 echo를 실행하는 동안에 스택의 구성을 보여준다.

  • 프로그램은 스택 포인터에서 24를 빼서 스택에 24바이트를 할당한다. (Line 2)
  • gets, puts로의 호출 시에 인자로 사용하기 위해, %rsp 가 %rdi 로 복사된다는 사실로 알 수 있는 것처럼 문자 buf는 스택의 탑에 위치한다.
    • buf와 저장된 리턴 포인터 사이의 16바이트는 사용되지 않는다.
    • 사용자가 최대 7문자를 입력하는 한, gets가 리턴하는 문자열은 buf에 할당된 공간 내에 알맞게 저장된다.
    • 보다 더 긴 스트링을 입력한다면, gets는 스택에 저장된 정보의 일부를 덮어쓰게 된다.

Characters typed Additional corrupted state

0-7 None
9-23 Unused stack space
24-31 Return address
32+ Saved state in caller

23개의 문자 스트링까지는 심각한 결과가 발생하지 않지만, 이 이상의 경우 리턴 포인터의 값과 추가적으로 저장된 상태까지도 손상 될 것이다.

  • 만일 리턴주소의 저장된 값이 손상되면, ret (Line 8)은 프로그램을 전혀 예상하지 못한 곳으로 점프하게 할 것이다.
    • 이러한 동작들은 모두 C 코드에서 불가능 할 것처럼 보였다. gets 같은 함수들에 의한 범위초과형 메모리 쓰기의 충격은 기계어 수준에서 공부해야 알 수 있는 것이다.
     

스크린샷 2025-04-08 오후 3.17.00.png

위에서 제시한 echo 함수는 간단한 코드인 만큼 허술하다. 보다 개선된 fgets를 이용하면 개선된 버전을 만들 수 있다.

  • 여기서는 읽어들일 최대 바이트 수를 인자로 사용한다.
    • 일반적으로 gets나 저장공간을 오버플로우 하게 되는 함수를 사용하는 것은 나쁜 프로그래밍 습관으로 평가한다.
    • 하지만 strcpy, strcat, sprintf 와 같이 자주 사용되는 많은 라이브러리 함수들은 버퍼 크기를 나타낼 아무 수단도 없이 일련의 바이트를 생성한다.
    • 그래서 이런 함수들이 버퍼 오버플로우 시 취약성이 될 수 있다.

버퍼 오버플로우의 보다 치명적인 사용은 일반적으로 프로그램이 하지 않을 기능들을 실행하도록 하는 것이다. (스타크래프트 EUD)

더보기

스타크래프트 버그 중 유명한 버그인 EUD가 바로 떠오른다.

 

게임 중 일어나는 버그는 아닌데, 그냥 아는거 나와서 주저리 떠든다면..
스타크래프트는 원래 유닛 단위의 킬 카운트를 세지 않았지만 어느 시점에서 업데이트로 추가되었다.
근데 이 킬 카운트를 세는 기능이 버퍼 오버플로우와 연관된 오류 발생으로 인해, 게임을 진행하는 전체적인 시스템에 접근이 가능해졌다. 그래서 기존의 룰에서 더 많이 확장되고, 심지어는 게임 단위에서 파일제어까지 시도 할 수 있는 기능까지 생겼다.

이렇기에 어떻게 보면 악성코드가 될 여지도 있었던 기능이었고 현재는 부분적 허용으로만 이용이 가능한 버그라고 알려져 있다.

  • 이것은 컴퓨터 네트워크 상의 시스템 보안성을 공격하는 가장 일반적인 방법 중의 하나이다.
  • 일반적으로 탐색코드라고 하는 실행코드를 바이트 인코딩한 탐색코드를 가리키는 포인터로 리턴 주소를 덮어쓰는 약간의 추가적인 바이트들을 포함하는 스트링을 입력한다.
  • ret을 실행하면 탐색코드로 점프하게 된다.

공격의 한 가지 유형으로, 탐색코드는 시스템 콜을 이용해서 쉘 프로그램을 시작하고, 공격자에게 여러 가지 운영체제 기능을 제공한다.

  • 다른 형태로 탐색코드는 허용되지 않은 기능을 실행하고, 손상된 스택을 복구하며, ret를 한 번 더 실행해서 외형적으로는 호출자에게 정상적인 리턴이 발생한 척도 할 수 있다.

여러 예시가 있는데, 외부 환경으로의 인터페이스는 “철통검증” 하여 외부의 에이전트 프로그램이 시스템에 오동작을 일으키지 못하도록 해야 한다.

3.10.4 버퍼 오버플로우 공격 대응 기법

버퍼 오버플로우 공격은 은밀하여 컴퓨터 시스템에 많은 문제를 야기해왔다.

  • 최신 컴파일러와 운영체제는 이들 공격이 실행되기 어렵게 하는 방법, 또 침입자가 버퍼 오버플로우 공격을 통해 시스템의 제어권을 획득 할 수 있는 방법을 제한하는 방법을 구현하였다.

스택 랜덤화

공격자는 탐색코드를 시스템에 삽입하려면 공격 스트링 내에 코드와 코드를 가리키는 포인터를 집어넣어야 한다.

  • 이 포인터를 만들기 위해서는 스트링이 위치 할 스택의 주소를 알아야한다. 역사적으로 스택 주소의 예측은 쉬웠다.
    • 동일한 프로그램과 운영체제 버전을 실행하는 모든 시스템에서의 스택 위치는 컴퓨터 간에 매우 안정적인 값을 가졌다.
    • 예를 들어, 공격자가 일반적인 웹 서버가 사용하는 스택 주소를 결정하기 위해서는 많은 컴퓨터에서 동작하는 공격을 만들 수 있어야 했다.
    • 보안 단일문화라고 부르는 현상인 완전히 동일한 바이러스 변종에 취약하였다. 전염성 질병이 실제로 퍼지듯이..
  • 스택 랜덤화의 아이디어는 프로그램의 매 실행마다 스택의 위치를 다르게 해주는데 있다. 그래서 컴퓨터들이 동일한 코드를 실행 할지라도, 이들은 완전히 다른 스택 주소를 사용하고 있게 된다.
    • 예를 들어, 프로그램 시작 시 스택에 alloca 함수를 사용하여 0 - n 바이트 사이의 랜덤 크기 공간을 할당해서 스택에 정해진 크기의 공간을 할당한다.
    • 이렇게 할당된 공간은 프로그램에서 사용하지 않지만, 이렇게 함으로써 매 실행마다 모든 이후의 스택 위치가 변경되도록 해준다.
    • 할당의 범위 n은 너무 커서 공간을 낭비하지 말아야 하며, 스택 주소에 충분한 변화를 줄 정도로 큰 값을 유지해야 한다.
  • 이 코드는 “전형적인” 스택 주소를 결정하는 간단한 방법이다.
    • 단순히 main 함수의 지역변수 주소를 출력하고 있다.
    • 32비트 모드의 리눅스 컴퓨터에서 이 코드를 만 번 실행하면 주소는 0xff7fc59c - 0xffffd09c까지, 약 2^23 범위를 갖는다.
    • 64비트 모드에서 실행하면, 0x7fff0001b698 - 0x7ffffffaa4a8까지 약 2^32 범위를 갖는다.
    int main()
    {
    	long local;
    	printf("local at %p\\n", &local);
    	return 0;
    }
    
  • 스택 랜덤화는 리눅스 시스템에서 표준 기법이 되었다. 이것은, ASLR이라고 부르는 주소공간 배치 랜덤화라고 하는 넓은 종류의 기술에 속한다.
    • ASLR을 사용하면 프로그램 코드, 라이브러리 코드, 스택, 전역변수, 힙 데이터를 포함하는 여러 프로그램의 부분들이 매번 실행때마다 메모리의 다른 지역에 로딩된다.
    • 이것은 하나의 컴퓨터에서 실행하는 프로그램은 다른 컴퓨터의 동일한 프로그램과 서로 다른 주소 매핑을 갖는 것을 의미한다. 이 방법은 여러 종류의 공격을 방지한다.
  • 그렇지만, 집요한 공격자가 반복적으로 무지막지하게 공격하면 랜덤화를 극복 할 수는 있다. 일반적으로 nop을 실제 탐색코드 앞에 삽입하는 것이다.
    • 이 인스트럭션은 프로그램 카운터를 다음 인스트럭션으로 이동하는 것말곤 없다.
      • 하지만 공격자가 이 인스트럭션들 중간에 주소를 추측 할 수 있다면, 프로그램은 이 코드 블록을 실행하고 탐색코드를 찾을 수 있다.
      • 이러한 코드 블록을 부르는 일반 용어는 “nop sled”로, 프로그램이 코드 내에서 “slides” 한다는 개념을 표현한다.
      • 만일 256바이트의 nop sled를 설정하면, n = 2^23에 걸친 랜덤화는 2^15회 시도로 깰 수 있다.
      • ASLR은 공격에 대한 노력을 증가시키니 악성 프로그램이 퍼지는 속도를 줄일 수 있지만 완벽한 대책이 되진 못한다.

 

스택 손상 검출

스택 손상을 감지하는 방법도 방어 방법이다.
echo 함수를 앞에서 보았는데, 손상은 대개 프로그램이 지역 버퍼의 경계를 벗어날 때 발생한다는 것을 알 수 있다.

C에서 배열의 경계를 넘는 쓰기 작업을 방지하는 안정적인 방법은 존재하지 않는다.

  • 그 대신, 프로그램은 해로운 효과가 발생하기 전에 이러한 쓰기 작업이 발생하는 것을 감지하는 시도를 할 수 있다.

스크린샷 2025-04-08 오후 3.50.39.png

최신 GCC에서는 스택 보호 코드를 버퍼 오버플로우를 감지하기 위해 생성된 코드에 추가하는 기법을 구현하고 있다.

  • 이 아이디어는 지역 버퍼와 나머지 스택 상태 값 사이에 위 그림과 같이 특별한 카나리 값을 저장하는 것이다.
    • 카나리 값은 보호값이라고도 불리며, 프로그램의 매 실행마다 랜덤으로 생성되므로 공격자가 값을 쉽게 추정하기 어렵다.
      • 레지스터 상태를 복원하고 함수로부터 리턴하기 전에 프로그램은 카나리 값이 함수나 함수 내부 동작에 의해 변경되었는지를 체크한다. 만일 변경되었다면, 프로그램은 에러를 발생시키면서 종료한다.
  • 카나리 값을 특별한 세그먼트에 저장해서 “read only”로 표시 할 수 있으며, 따라서 공격자가 저장된 카나리 값을 변경 할 수 없게 된다. 레지스터 상태를 복원하고 리턴하기 전에 함수는 스택에 저장된 값을 카나리 값과 비교한다.
    • xorq를 통해 비교해서 일치하면 0이고, 0이 아니면 카나리 값이 변경됨으로 판단하여 에러 처리 루틴을 호출하게 된다.

스택 보호는 버퍼 오버플로우 공격에 의해 프로그램 스택에 저장된 상태의 손상을 방지하는 데 유용하다.

  • 이 방식은 매우 작은 성능 저하를 가져올 뿐이며, 그것은 특히 GCC가 함수 내에 char 타입의 지역 버퍼가 있는 경우에만 이 코드를 삽입하기 때문이다.
  • 물론 실행하는 프로그램의 상태를 손상시키는 다른 방법들이 있지만, 스택의 취약성을 줄이는 것은 많은 일반적인 공격들을 약화 시킬 수 있다.

실행코드 영역 제한하기

마지막 방법은 공격자가 코드를 추가할 가능성을 제거하는 것이다.

  • 한 가지 방법은 어느 메모리 영역이 실행코드를 저장할지를 제한하는 것이다.
    • 일반적인 프로그램에서 컴파일러가 만든 코드를 저장하는 메모리 부분만이 실행 가능하면 된다.
    • 다른 부분들은 읽기와 쓰기만 허용하도록 제한 할 수 있다.
      • 가상 메모리 공간은 대게 2KB 나 4KB를 갖는 페이지들로 논리적으로 나누어진다.
  • 하드웨어는 사용자 프로그램과 운영체제 커널에게 허용된 접근 형태를 나타내는 서로 다른 메모리 보호의 형태를 지원한다.
    • 많은 컴퓨터는 세 개의 접근 형태에 걸친 제어를 허용한다.
      • 읽기 : 메모리에서 데이터 읽기
      • 쓰기 : 메모리에 데이터 저장하기
      • 실행하기 : 메모리 내용을 기계어 수준 코드로 취급하기
    • 역사적으로 x86 아키텍처는 읽기와 실행 접근 제어를 1비트 플래그에 통합하였으며, 따라서 읽기 허용으로 표시된 페이지는 모두 실행 가능 하였다.
      • 스택은 읽기 가능과 쓰기 가능으로 유지되어야 했으며, 따라서 스택의 바이트들도 실행 가능하였다.
        • 일부 페이지들을 읽기 가능하면서 실행 불가능하게 하기 위한 여러가지 방법들이 구현되었지만, 대개는 상당한 비효율성을 발생시켰다.
    • 보다 최근에 AMD에서 NX 비트를 64비트 프로세서를 위해 메모리 보호 내에 추가하였고, 이 방법으로 읽기/쓰기 접근이 분리 되었으며, 인텔도 채택하였다.
      • 이 기능으로 스택은 읽기/쓰기는 가능하지만 실행 할 수 없도록 표시될 수 있으며, 페이지가 실행 가능하니 체크하는 것도 하드웨어에서 효율성 저하 없이 실행된다.
  • 일부 프로그램에서는 동적으로 코드를 생성하고 실행하는 기능을 요구한다.
    • just-in-time 컴파일 기술은 실행 성능 개선을 위해 자바와 같은 인터프리터 언어로 작성된 프로그램을 위한 동적 코드 생성이 가능하다.
      • 최초의 프로그램을 작성 할 때 런타임 시스템이 실행 코드를 컴파일러가 생성한 부분에만 제한 할 수 있을지 여부는 언어와 운영체제에 따라 달라진다.

이들 각각은 취약성을 별도로 줄여주며, 함께 사용하면 보다 효과적이다.
불행히도 여전히 컴퓨터를 공격하는 방법은 존재하며, 많은 컴퓨터의 무결성을 지속적으로 훼손하고 있다.

3.10.5 가변크기 스택 프레임 지원하기

지금까지 다양한 함수에 대한 머신코드를 살펴보았지만, 이들은 할당되어야 하는 스택 프레임의 크기를 컴파일러가 미리 결정 할 수 있다는 공통적인 특징이 있었다.

  • 일부는 가변적인 크기가 필요하다. alloca 를 호출 할 때 일어난다. 또한 이것은 이 코드가 가변크기의 지역배열을 선언 할 때 일어날 수 있다.

아래 보여주는 예시는 가변크기 배열을 포함하는 함수의 예시이다.

long vframe(long n, long idx, long *q)
{
	long i;
	long *p[n];
	p[0] = &i;
	for (i = 1; i < n; i++)
		p[i] = q;
	return *p[idx];
}
vframe:
	pushq %rbp
	movq %rsp, %rbp
	subq $16, %rsp
	leaq 22(,%rdi,8), %rax
	andq $-16, %rax
	subq %rax, %rsp
	leaq 7(%rsp), %rax
	shrq $3, %rax
	leaq 0(,%rax,8), %r8
	movq %r8, %rcx
	
	.L3:
		movq %rdx, (%rdx, %rax, 8)
		addq $1, %rax
		movq %rax, -8(%rbp)
	.L2:
		movq -8(%rbp), %rax
		cmpq %rdi, %rax
		jl .L3
  • 이 함수는 n개의 포인터들의 지역배열 p를 선언하며, n은 첫 번째 인자로 주어진다.
    • 이것은 스택에 8n바이트를 할당을 요구하게 되며, 여기서 n의 값은 함수의 호출 때마다 달라진다.
    • 그래서 컴파일러는 얼마만큼의 공간이 함수의 스택 프레임을 위해 할당되어야 하는지 결정 할 수 없다.
    • 뿐만 아니라, 이 프로그램은 지역변수 i의 주소에 대한 참조를 생성하고, 그래서 이 변수는 또한 스택에 저장되어야 한다.
      • 실행되는 동안에 이 프로그램은 지역변수 i와 배열 p의 원소들에 모두 접근 할 수 있어야 한다.
        • 리턴 할 때에 이 함수는 스택 프레임을 반환하고 스택 포인터를 저장되었던 리턴주소의 위치로 설정한다.

 

가변 크기 스택 프레임을 처리하기 위해 x86-64 코드는 레지스터 %rbp 를 이용해서 프레임포인터(또는 베이스포인터라고 불린다)로 사용한다. 이 포인터를 사용 할 때, 스택 프레임은 아래 나올 시각자료의 vframe 함수의 경우를 보여주는 것과 같이 구성된다.

스크린샷 2025-04-08 오후 5.13.01.png

  • %rbp 는 피호출자 저장 레지스터이기 때문에, 이 코드는 스택에 이전 버전의 %rbp 를 보관한다.
    • 그 후 이 함수가 실행되는 동안 계속 %rbp 를 현 위치를 가리키도록 유지하며, 이것은 고정 길이 지역변수들을 %rbp 에 대한 상대적인 오프셋 값으로 참조한다.
  • 위 코드의 어셈블리 코드는 시작 부분에서 스택 프레임을 설정하고, 배열 p를 위한 공간을 할당하는 코드를 볼 수 있다.
    • 이 코드는 현재의 %rbp 값을 스택에 푸시하고, %rbp 를 이 스택위치로 가리키도록 설정하는 것으로 시작한다. (2-3번 줄)
    • 다음으로, 16바이트를 스택에 할당하는데, 첫 8바이트는 지역변수 i를 저장하는데 사용하며, 두 번째 8바이트는 사용하지 않는다.
    • 그러고 나서, 배열 p를 위한 공간을 할당한다. (5-11번 줄) 얼마나 큰 공간을 할당하는지, p를 배치하는지에 관한 상세 설명은 나중에 조사한다.
    • 이 프로그램이 11번 줄에 도달하는 순간에는
      1. 스택에 최소 8n 바이트를 할당하였다.
      2. 이 할당된 영역 내에 배열 p를 배치하였으며, 따라서 최소 8n 바이트가 사용 가능하다는 것만 설명하는 것으로 충분하다.초기화 루프를 위한 코드는 어떻게 지역변수들 i와 p가 참조되는지의 예를 보여준다.
        • 13번 줄 : movq %rdx, (%rcx, %rax, 8) | 이 내용은 배열 원소 p[i]가 q로 설정되는 것을 보여준다.
        • 이 인스트럭션은 p의 시작주소로 레지스터 %rcx 에 있는 값을 사용한다.
          • 지역변수 i가 갱신되고(15 Line), 읽히는(17 Line) 경우들을 볼 수 있다.
          • i의 주소는 -8(%rbp)를 참조해서 (즉 프레임 포인터에 상대적으로 오프셋 -8 떨어진 상태로) 주어진다.
        프레임 포인터는 이 함수의 끝에서 leave를 이용해서 자신의 이전 값으로 복원된다. (20번 줄) 이 인스트럭션은 인자를 하나도 갖지 않는다.movq %rbp, %rsp즉 스택 포인터는 먼저 저장된 %rbp 값의 위치로 설정되며, 그 후 이 값은 스택에서 팝되어 %rbp 에 저장된다.
        • 초기의 x86 버전에서는 프레임 포인터가 매 함수 호출 시에 사용되었다. x86-64 코드에서는 vframe에서와 같이 스택 프레임이 가변크기일 가능성이 있는 경우에만 사용된다.
        역사적으로 대부분의 컴파일러들은 IA32 코드를 생성 할 때 프레임 포인터를 사용하였다. 하지만 최근 GCC 버전들은 이러한 관습을 포기하였다.
        • 모든 함수들이 %rbp 를 피호출자-저장 레지스터로 취급하는 한, 프레임코드를 사용하는 코드와 그렇지 않은 코드를 혼합해서 사용하는 것이 허용된다는 점에 주목하자.
      3. 이 인스트럭션의 조합은 전체 스택 프레임을 반환하는 효과를 낸다.
      4. popq %rbp
      5. 이것은 다음의 두 인스트럭션을 실행하는 것과 동일하다:
    • movq %rbp, %rsp
    • popq %rbp

즉 스택 포인터는 먼저 저장된 %rbp 값의 위치로 설정되며, 그 후 이 값은 스택에서 팝되어 %rbp 에 저장된다.

이 인스트럭션의 조합은 전체 스택 프레임을 반환하는 효과를 낸다.

  • 초기의 x86 버전에서는 프레임 포인터가 매 함수 호출 시에 사용되었다. x86-64 코드에서는 vframe에서와 같이 스택 프레임이 가변크기일 가능성이 있는 경우에만 사용된다.

역사적으로 대부분의 컴파일러들은 IA32 코드를 생성 할 때 프레임 포인터를 사용하였다. 하지만 최근 GCC 버전들은 이러한 관습을 포기하였다.

  • 모든 함수들이 %rbp 를 피호출자-저장 레지스터로 취급하는 한, 프레임코드를 사용하는 코드와 그렇지 않은 코드를 혼합해서 사용하는 것이 허용된다는 점에 주목하자.

 

3.11 부동소수점 코드

부동소수점 아키텍처는 데이터로 연사하는 방법이 기계에 매핑되는 방법에 영향을 주는 여러 가지 개념으로 구성된다.

  • 부동소수점 값들이 저장되고 접근되는 방법이 포함되어 있다.
  • 부동소수점 데이터로 연산하는 인스트럭션들
  • 함수들의 인자와 리턴 값으로 부동소수점 값들을 전달하기 위해 이용되는 관례들
  • 함수를 호출하는 동안에 레지스터들을 보존하는 관례들. 예를 들어, 일부 레지스터들은 호출자 보관으로 지정하고, 나머지는 피호출자 저장으로 지정한다.

간단한 역사를 짚고 넘어가자.

  • 97년, 펜티엄/MMX 발표 이후, 그래픽과 영상처리를 위한 다양한 명령어를 포함하려고 했다.
    • 이들은 원래 SIMD라고 하는 단일 인스트럭션, 다중 데이터 방식이라고 알려진 병렬 모드로 곱셈 연산을 수행하는 방식에 집중해왔다.
    • 이 모드에서 동일 연산이 다수의 서로 다른 데이터 값들에 대해 병렬로 수행된다.
  • 지난 수년 간 이들에 대한 확장은 계속되어 왔다.
    • MMX → SSE → AVX으로 이름이 바뀌면서 일련의 주요 개선 작업이 진행되었다.
      • 각 세대마다 여러 개의 버전이 존재하였다.
      • MMX에는 MM 레지스터, SSE 때는 XMM, AVX에 대해서는 YMM이라 부르는 레지스터들에 저장된 데이터를 관리했다.
      • 각각 64, 128, 256비트 레지스터들이다. 그래서 예를 들어 YMM은 8개의 32비트를 저장 할 수있고 이 값들은 정수 또는 부동소수점이다.
    • x86-64 코드를 실행 할 수 있는 모든 프로세서들은 SSE2 또는 그 이상을 지원한다.
      • 따라서 x86-64 부동소수점은 프로시저 인자의 전달과 리턴 값의 전달 관습을 포함해서 SSE나 AVX에 기초한다.
      • 아래 그림에 나타낸 것 처럼 AVX 부동소수점 아키텍처는 %ymm0 - %ymm15 로 이름 붙인 16개의 YMM 레지스터들에 저장된다.
        • 각 레지스터는 256비트 길이를 갖는다. 스칼라 데이터로 연산할 때, 이 레지스터들은 부동소수점 데이터만을 보관하며, 하위 32비트, 64비트만이 사용된다.
          • 그래서 하위 32비트(float 인경우), 64비트 (double 인경우)만이 사용된다.
          • 어셈블리 코드는 이들의 SSE, XMM 레지스터 이름인 %xmm0 - %xmm15 로 레지스터를 참조하며, 각 XMM 레지스터는 대응되는 YMM의 하위 16바이트이다.
           

스크린샷 2025-04-08 오후 5.43.54.png

 

3.11.1 부동소수점 이동 및 변환 연산

스크린샷 2025-04-08 오후 5.44.20.png

이 그림은 변환 없이 하나의 XMM 레지스터에서 다른 레지스터로 이동하는 것뿐만 아니라 메모리와 XMM 레지스터들 간의 부동소수점 데이터를 이동하는 인스트럭션들을 보여준다.

  • 메모리를 참조하는 인스트럭션들은 스칼라 인스트럭션들이며, 이것은 이들이 묶인 데이터 값들이 아닌 개별 값들에 대해 연산한다는 것을 의미한다.
  • 데이터는 메모리나 XMM 레지스터들에 저장된다. 코드 최적화 지침이 32비트 메모리 데이터는 4바이트 정렬을 만족해야 하고, 64비트도 8바이트 정렬을 추천하고 있다.
    • 하지만, 이 인스트럭션들은 데이터의 정렬과 관계없이 정확히 동작한다.
      • 메모리 참조는 모든 다양한 변위들의 조합, 베이스 레지스터, 인덱스 레지스터, 배율 값을 포함해서 정수 MOV 인스트럭션들과 완벽히 동일한 방식으로 표기한다.
    • GCC는 데이터를 메모리에서 XMM 레지스터로, 또는 XMM 레지스터에서 메모리로 이동하기 위해서만 스칼라 이동연산을 이용한다.
      • 두 개의 XMM 레지스터들 간의 데이터 이동을 위해서는 한 개의 XMM 레지스터의 내용 전체를 다른 레지스터로 복사하기 위해 두 개의 인스트럭션 중 하나를 이용한다.
      • 즉, 단일 정밀도에 대해서는 vmovaps , 이중 정밀도 값에 대해서는 vmovapd 를 이용한다.
        • 이들 경우에 대해서 프로그램이 레지스터 전체를 복사하는지, 하위 값들만 복사하는지 여부는 프로그램의 기능이나 실행속도에는 아무 영향을 주지 않는다.
          • 따라서 스칼라 데이터에 국한된 인스트럭션 대신 이 인스트럭션들을 이용하더라도 실질적인 차이가 없다.
          • 이들 인스트럭션의 문자 ‘a’는 aligned 를 의미한다.
            • 메모리에 읽고 쓰기 위해 사용 될 때, 만일 주소가 16바이트 정렬 요건을 만족하지 못하면 이들은 예외를 발생시킨다.
            • 두 개의 레지스터 간에 이동에 대해서는 부정확한 정렬의 가능성은 없다.
  • 서로 다른 부동소수점 이동 연산의 예제를 보자.
float float_mov(float v1, float *src, float *dst)
{
	float v2 = *src;
	*dst = v1;
	return v2;
}
v1 in %xmm0, src in %rdi, dst in %rsi
float_mov:
	vmovaps %xmm0, %xmm1
	vmovss (%rdi), %xmm0
	vmovss %xmm1, (%rsi)
	ret
  • 해당 x86-64 어셈블리 코드에 대해 생각해보자.
    • 이 예제에서 vmovaps를 사용해서 데이터를 레지스터에서 레지스터로 복사하는 경우와 vmovss 를 사용해서 데이터를 메모리에서 XMM 레지스터로 복사하고, XMM 레지스터에서 메모리로 복사하는 예제를 볼 수 있다.
     

스크린샷 2025-04-08 오후 7.23.58.png

이 두 표에서 부동소수점과 정수 자료형 사이의 변환과 서로 다른 부동소수점 형식들 간에 변환하는 인스트럭션을 보여준다.

  • 이들은 모두 개별 데이터 값을 처리하는 스칼라 인스트럭션들이다.
    • 윗 표의 인스트럭션들은 XMM 레지스터나 메모리에서 읽은 부동소수점 값을 범용 레지스터로 변환한다. (%rax , %ebx )
    • 부동소수점 값들을 정수로 변환 할 때, 이들은 0의 방향으로 값을 근사하는 절삭을 수행하며, 이것은 C와 대부분의 다른 프로그래밍 언어에서 요구된 것이다.
  • 아랫 표의 인스트럭션들은 정수에서 부동소수점으로 변환한다. 이들은 특이하게도 오퍼랜드를 세 개 사용한다. 두 개의 소스와 한 개의 목적지.
    • 첫 번째 오퍼랜드는 메모리나 범용 레지스터에서 읽어온다. 우리는 의도적으로 두 번째 오퍼랜드를 무시 할 수 있는데, 이들의 값이 결과에 상위 바이트에만 영향을 주기 때문이다.
    • 목적지는 XMM 레지스터여야 한다. 보통 두 번째 소스와 목적지 오퍼랜드들은 다음의 인스트럭션과 동일하다. vcvtsi2sdq %rax, %xmm1, %xmm1
    • 이 인스트럭션은 한 개의 long integer를 레지스터 %rax 에서 읽어서 자료형 double로 반환하고, 결과를 XMM 레지스터 %xmm1 의 하위 바이트들에 저장한다.
  • 마지막으로 두 개의 서로 다른 부동소수점 형식들 간의 변환에서 현재 GCC 버전은 별도의 설명을 필요로 하는 코드를 생성한다.
    • %xmm0 의 하위 4바이트가 단일 정밀도 값을 가지고 있다고 가정해보자.
      • 그러면 이것을 이중 정밀도 값으로 변환하고, 그 결과를 %xmm0 의 하위 8바이트에 저장하기 위해서 다음의 인스트럭션을 사용해야하는 것이 당연 할 것 같다.
      • vcvtss2sd %xmm0, %xmm0, %xmm0 그 대신에, GCC가 다음과 같은 코드를 생성한 것을 발견한다:
        • vunpcklps %xmm0, %xmm0, %xmm0 | Replicate first vector element , vcvtps2pd %xmm0, %xmm0 | Convert two vector elements to double
          • vunpcklps은 보통은 두 XMM 레지스터들의 값을 엮어서 세 번째 레지스터에 저장하기 위해 사용된다.
          • 즉, 만일 소스 레지스터가 [s_3, s_2, s_1, s_0]을 워드로 갖고, 다른 소스는 워드 [d_3, d_2, d_1, d_0]을 갖는다면, 목적지 레지스터의 값은 [s_1, d_1, s_0, d_0] 일 것이다.
          • 위 코드에서, 세 오퍼랜드 모두 동일한 레지스터가 사용되는 것을 보았으며, 그래서 만일 처음의 레지스터가 값 [x_3, x_2, x_1, x_0]을 가지고 있다면..
            • 이 인스트럭션을 [x_1, x_1, x_0, x_0] 을 저장하도록 레지스터를 업데이트 할 것이다.
              • vcvtps2pd 는 소스 XMM 레지스터의 두 개의 하위 단일 정밀도 값들을 목적지 XMM 레지스터에 두 개의 이중 정밀도 값들로 확장한다.
              • 이 것을 이전의 vunpcklps 의 결과에 적용하면 [dx_0, dx_0] 값이 될 것이며, dx_0은 x를 이중 정밀도로 변환한 결과 값이다.
                • 두 인스트럭션의 최종 효과는 %xmm0 의 하위 4바이트에 있는 최초의 단일 정밀도 값을 이중 정밀도로 변환하고, 이 결과를 두 개 복사해서 %xmm0 에 저장하기 위한 것이다.
      왜 GCC가 이런 코드를 생성하는지는 불분명하다. XMM 레지스터에 동일한 값을 중복해야 하는 이득이나 필요성은 없다.
    • GCC는 이중 정밀도에서 단일 정밀도로 변환하기 위해 유사한 코드를 생성한다.
    • vmovddup %xmm0, %xmm0 | Replicate first vector element
    • vcvtpd2psx %xmm0, %xmm0 | Convert two vector elements to single
    • 이 인스트럭션들이 두 개의 이중 정밀도 값 [x_1, x_0]을 가지는 레지스터 %xmm0 로 시작한다고 해보자.
      • 그러면, vmovddup 은 이것을 [x_0, x_0]으로 설정할 것이다.
      • vcvtpd2psx 은 이 값들을 단일 정밀도로 변환하고, 이들을 레지스터의 하위 절반에 기록하고 상위 값을 0으로 설정하여 결과 값을 [0.0, 0.0, x_0, x_0]로 만든다.
        • 부동 소수점 0.0은 이진수로 모든 비트가 0인 값이라는 것을 기억하자
      • 역시 하나의 정밀도에서 다른 정밀도로 이런 방식으로 변환 값을 계산 할 때, 다음과 같은 하나의 인스트럭션을 사용하는 방법 외에는 분명한 값은 없다.
        • vcvtsd2ss %xmm0, %xmm0, %xmm0
        • 여러가지 부동 소수점 변환 연산의 예로 다음의 C 함수와 이 함수의 어셈블리 코드를 보자.
double fcvt(int i, float *fp, double *dp, long *lp)
{
	float f = *fp; double d = *dp; long l = *lp;
	*lp = (long) d;
	*fp = (float) i;
	*dp = (double) l;
	return (double) f;
}

i in %edi, fp in %rsi, dp in %rdx, lp in %rcx
fcvt:
	vmovss (%rsi), %xmm0
	movq (%rcx), %rax
	vcvttsd2siq (%rdx), %r8
	movq %r8, (%rcx)
	vcvtsi2ss %edi, %xmm1, %xmm1
	vmovss %xmm1, (%rsi)
	vcvtsi2sdq %rax, %xmm1, %xmm1
	vmovsd %xmm1, (%rdx)
	# The following two instructions convert f to double
	vunpcklps %xmm0, %xmm0, %xmm0
	vcvtps2pd %xmm0, %xmm0
	ret

fcvt로의 모든 인자들은 이들이 정수 또는 포인터이기 때문에 범용 레지스터를 통해 전달 된다. 결과 값은 %xmm0 으로 리턴된다.
앞에서 봤던 미디어 레지스터 목록처럼 이것은 float이나 double 리턴 값에 대한 공식지정 리턴 레지스터이다.
이 코드에서 GCC가 단일 정밀도에서 이중 정밀도로 변환하는 선호 방식 뿐만 아니라, 다수의 이동과 변환 인스트럭션들을 볼 수 있다.

3.11.2 프로시저에서 부동소수점 코드

x86-64에서 XMM 레지스터들이 함수로 부동소수점 인자를 전송하고 부동소수점 값들을 리턴 할 때 사용한다. 아래와 같이 설명되는 관습이 준수되어야 한다.

  • 최대 여덟 개의 부동소수점 인자들이 %xmm0 - %xmm7 XMM 레지스터들로 전달 될 수 있다.
  • 한 개의 부동소수점 값을 리턴하는 함수는 레지스터 %xmm0 을 이용한다.
  • 모든 XMM 레지스터들은 호출자 저장방식이다. 피호출자는 이들 레지스터를 저장하지 않은 채로 변경 할 수 있다.

어떤 함수가 포인터, 정수, 부동소수점 인자들의 조합을 가지고 있을 때, 포인터와 정수는 범용 레지스터로 전달되지만, 부동소수점 값들은 XMM 레지스터들로 전달된다.

  • 이것은 인자들의 레지스터로의 매핑이 이들의 타입과 순서에 의존한다는 것을 의미한다. 다음은 몇가지 예제들이다.
    • double f1(int x, double y, long z); : 이 함수는 x를 %edi에, y는 %xmm0, z는 %rsi에 저장한다.
    • double f2(double y, int x, long z); : 이 함수는 함수 f1과 동일한 레지스터 할당을 가지게 된다.
    • double f1(float x, double *y, long *z); : 이 함수는 x를 %xmm0, y는 %rdi, z는 %rsi에 저장한다.

 

3.11.3 부동소수점 산술 연산

아래 보이는 표는 산술연산을 수행하는 스칼라 AVX2 부동소수점 인스트럭션들을 문서화 한 것이다.

스크린샷 2025-04-08 오후 8.05.02.png

각각은 하나(S_1) 또는 두 개(S_1, S_2)의 소스 오퍼랜드와 한 개의 목적지 오퍼랜드 D를 갖는다.

  • 첫 번째 소스 오퍼랜드 S_1은 XMM 레지스터나 메모리 위치일 수 있다.
  • 두 번째 소스 오퍼랜드와 목적지 오퍼랜드는 XMM 레지스터만 가능하다.
  • 각 연산은 단일 정밀도와 이중 정밀도에 대한 인스트럭션을 갖는다. 결과 값은 목적지 레지스터에 저장된다.

예제로 부동소수점 함수에 대해 C와 어셈블리 코드로 하나씩 확인해보자.

double funct(double a, float x, double b, int i)
{
	return a*x - b/i;
}
a in %xmm0, x in %xmm1, b in %xmm2, i in %edi
funct:
	vunpcklps %xmm1, %xmm1, %xmm1
	vcvtps2pd %xmm1, %xmm1
	vmulsd %xmm0, %xmm1, %xmm0
	vcvtsi2sd %edi, %xmm1, %xmm1
	vdivsd %xmm1, %xmm2, %xmm2
	vsubsd %xmm2, %xmm0, %xmm0
	ret

세 개의 부동소수점 인자 a, x, b는 XMM 레지스터 %xmm0 - %xmm2 로 전달되지만, 정수인자 i는 레지스터 %edi 로 전달 된다.

  • 표준 2-인스트럭션 유형이 인자 x를 double로 변환하기 위해 사용되었다. (2-3 Line)
  • 또 다른 변환 인스트럭션이 인자 i를 double로 변환하기 위해 필요하다. (5 Line) 이 함수 값은 레지스터 %xmm0 에 리턴 된다.

 

3.11.4 부동소수점 상수의 정의 및 이용

정수 산술연산들과 달리, AVX 부동소수점 연산은 즉시 값을 오퍼랜드로 가질 수 없다.

  • 그 대신, 컴파일러는 상수 값을 위해 저장공간을 할당하고 초기화 해야한다.
    • 그러고 나서 코드는 메모리에서 값들을 읽어들인다. 이것은 다음의 Celsius를 Fahrenheit 변환 함수로 보여 줄 수 있다.
double cel2fahr(double temp)
{
	return 1.8 * temp + 32.0;
}
cel2fahr:
	vmulsd .LC2(%rip), %xmm0, %xmm0
	vaddsd .LC3(%rip), %xmm0, %xmm0
	ret
.LC2:
	.long 3435973837
	.long 1073532108
.LC3:
	.long 0
	.long 10779362128

이 함수가 값 1.8을 .LC2 로 명명된 메모리 위치에서 읽어들이고, 32.0을 메모리 위치 .LC3 에서 읽어들이는 것을 알 수 있다.

  • 이들 레이블들과 연계된 값들을 살펴보면 이들 각각의 값들은 십진수로 나타낸 두 개의 .long 선언들로 표시된 것을 알 수 있다.

이들이 어떻게 부동소수점 값들로 해석되어야 하는가?

  • 레이블 .LC2 를 보면 두 개의 값이 3435973837(0xcccccccd) 와 1073532108(0x3ffccccc) 이라는 것을 알 수 있다.

이 머신은 리틀 엔디안 바이트 순서방식을 따르므로 하위 4바이트는 첫 번째 값이 되며, 상위 4 바이트가 두 번째 값이 된다.

상위 바이트들에서 지수부 0x3ff(1023)을 추출 할 수 있으며, 여기에서 바이어스 1023을 빼면 지수 값 0을 얻는다.

  • 두 값이 소수부 비트들을 연결하면 소수 필드 0xccccccccccccd 를 얻게 되며, 이것은 0.8의 이진소수로 표시하며 여기에 묵시적인 1을 앞에 붙여 1.8을 얻음을 알 수 있다.

 

3.11.5 부동소수점 코드에서 비트연산 사용하기

때때로 우리는 GCC 생성코드가 XMM 레지스터에 비트 연산을 하여 유용한 부동소수점 결과를 구현하는 것을 발견한다.

  • 아래 있는 표는 범용 레지스터에 대한 연산에 대응되는 일부 관련 인스트럭션들을 보여준다. 이 연산들은 모두 통합된 데이터에 적용되된다.
    • 또, 이들이 두 소스 레지스터의 모든 데이터에 비트연산을 적용하여 목적지 XMM 레지스터 전체를 갱신한다는 것을 의미한다.
    • 스칼라 데이터에 대한 우리의 유일한 관심은 이 인스트럭션들이 목적지의 하위 4바이트 또는 8바이트에만 영향을 준다는 점이다.
    • 이 연산들은 종종 다음의 문제에서 살펴보는 것과 같이 부동소수점 값들을 다루는 간단하고 편리한 방법들이다.

 

Single Double Effect Description
vxorps xorpd D ← S_2 ^ S_1 Bitwise Exclusive-OR
vandps andpd D ← S_2 & S_1 Bitwise AND

3.11.6 부동소수점 비교 연산

AVX2는 부동 소수점 값들을 비교하기 위해 두 개의 인스트럭션을 제공한다.

 

Instruction Based On Description
ucomiss S_1, S_2 S_2 - S_1 Compare single precision
ucomised S_1, S_2 S_2 - S_1 Compare double precision

이 인스트럭션들은 오퍼랜드 두 개를 비교해서 이들의 상대 값을 나타내기 위해 상태코드를 설정한다는 면에서 CMP 인스트럭션과 유사하다.

  • cmpq에서 처럼 이 들은 오퍼랜드를 역순으로 나열하는 ATT 형식의 관습을 따른다.
  • 인자 S_2는 XMM 레지스터가 되어야 하며, S_1은 XMM 레지스터나 메모리가 될 수 있다.

부동소수점 비교 인스트럭션은 세 개의 조건코드를 설정한다. zero flag ZF, Carry Flag CF, Parity Flag PF

  • 패러티 플래그를 이제 설명하게 되었는데, 이 플래그는 가장 최근 산술 또는 논리연산이 최소 중요바이트를 짝수 패리티를 갖는 값을 생성 했을 때 설정된다. (즉 바이트 내에 짝수 개의 1이 존재)
    • 그렇지만 부동소수점 비교에서 이 플래그는 어느 하나의 오퍼랜드가 NaN일 때 설정된다.
      • 관습에 따라 C에서의 모든 비교는 하나라도 NaN이면 실패로 간주되기에, 이 플래그는 이런 조건 감지를 위함이다. 예를 들어 x가 NaN이면 x==x도 False이다.
  • 조건코드는 다음과 같은 규칙을 따라 설정된다.

 

Ordering S2:S1 CF ZF PF
Unordered 1 1 1
S2 < S1 1 0 0
S2 = S1 0 1 0
S2 > S1 0 0 0

무순서 경우는 오퍼랜드 중 하나가 NaN 일 때 발생한다. 이것은 패리티 플래그로 검출 될 수 있다.

  • 부동소수점 비교가 무순서 결과를 만들 때 조건부 점프를 실행하기 위해 보통 jp (jump on parity) 인스트럭션이 사용된다.
    • 이 경우를 제외하고, 캐리와 zero flag의 값들은 비부호형 비교에 대한 값들과 동일하다.
      • ZF는 두 오퍼랜드가 같을 때 설정되고, CF는 S_2 < S_1 일 때 설정된다.
      • ja, jb 같은 인스트럭션들은 이 플래그들의 여러 조합에 대해 조건부 점프로 사용된다.

부동소수점 비교의 예제로 아래 함수의 C 함수는 인자 x를 0.0과의 관계에 따라 분류해서 결과로 순서형 타입을 리턴한다.

  • C에서 순서형 타입은 정수로 인코딩 되며, 그래서 가능한 함수 값은 0 : NEG , 1 : ZERO , 2 : POS , 3 : OTHER 이다. 이 마지막 결과는 x값이 NaN일 때 발생한다.
// C code
typedef enum {NEG, ZERO, POS, OTHER} range_t;

range_t find_range(float x)
{
	int result;
	if (x < 0)
		result = NEG;
	else if (x == 0)
		result = ZERO;
	else if (x > 0)
		result = POS;
	else
		result = OTHER;
	return result;
}
x in %xmm0
find_range:
	vxorps %xmm1, %xmm1, %xmm1
	vucomiss %xmm0, %xmm1
	ja .L5
	vucomiss %xmm1, %xmm0
	jp .L8
	movl $1, %eax
	je .L3
.L8:
	vucomiss .LCO(%rip), %xmm0
	setbe %al
	movzbl %al, %eax
	addl $2, %eax
	ret
.L5:
	movl $0, %eax
.L3:
	rep; ret

GCC는 find_range 에 대해 오른쪽에 있는 어셈블리 코드를 생성한다. 이 코드는 그렇게 효율적이진 않다.

  • 요구된 정보가 한 번의 비교만으로 얻어질 수 있음에도 불구하고 x를 0.0과 세 번 비교한다. 또한 부동소수점 상수 0.0을 두 번 생성한다.
    • 한번은 vxorps 를 이용해서, 나머지 한 번은 메모리에서 값을 읽어서.. 네 개의 가능한 비교 결과에 대해 함수의 흐름을 추적해보자.
      • [x < 0.0] : 4번 줄의 ja 분기가 실행되어 리턴 값 0으로 마지막으로 점프한다.
      • [x = 0.0] : ja(4L)과 jp(6L) 분기들은 성립되지 않지만, je(8L)는 성립되며, %eax 에 리턴 값 1을 갖는다.
      • [x > 0.0] : 세 개의 분기 모두 성립하지 않는다. setbe(11L)는 0을 생성하며, 이것은 addl에 의해(13L) 증가되어 리턴 값 2가 된다.
      • [x = NaN] : jp(6L)이 성립하기 된다. 세 번째 vucomiss (10L)은 캐리와 zero flag 모두를 설정하게 된다.
        • 그래서 setbe 이랑 다음에 오는 인스트럭션은 %eax 를 1로 설정한다.
        • 이것은 addl (13L)으로 증가되어 리턴 값 3을 만든다.

3.11.7 부동소수점 코드에 대한 관찰

우리는 AVX2로 부동소수점 데이터에 대해 연산하기 위해 생성된 머신코드의 일반적인 스타일이 정수데이터에 연산하는 경우에 대해 살펴본 것과 유사하다는 사실을 알았다.

  • 이 둘은 모두 값을 보관하고 연산하기 위해 레지스터를 사용하며, 이들은 이 레지스터를 사용해서 함수의 인자를 전달한다.

물론 여러 가지 자료형을 다루는 것과 혼합된 자료형을 포함하는 수식을 계산하는 규칙들은 매우 복잡하다.

  • AVX2 코드는 정수 산술연산만을 수행하는 함수들에서 대개 나타나는 것보다 더 많은 인스트럭션들과 형식이 연관된다.

AVX2는 또한 합체된 데이터들에 대해 병렬 연산을 수행해서 보다 더 빨리 실행되는 계산들을 만들 수 있는 가능성을 가지고 있다.

  • 컴파일러 개발자들은 자동으로 스칼라 코드를 병렬코드로 변환하기 위해 연구하고 있지만, 현재 병렬화를 통해 고성능을 얻을 수 있는 가장 안정적인 방법은..
    • 데이터의 백터를 다루기 위해 GCC가 지원하는 C언어의 확장을 사용하는 것이다.

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

[CS:APP] Chap 8 : Exceptional Control Flow  (0) 2025.04.23
[CS:APP] 링커 7.1 - 7.14  (0) 2025.04.18
[CS:APP] Chap 3.7 - 9  (1) 2025.04.09
[CS:APP] Chap 3.4 - 6  (0) 2025.04.09
[CS:APP] Chap 3.1 - 3  (0) 2025.04.07