13 분 소요


Go Internals Primer를 번역한 글입니다.

이 글은 go 1.10 버전에서 작성되었습니다.


Go의 런타임 및 표준 라이브러리 구현을 시작하기 전에 반드시 추상 어셈블리 언어에 대한
이해가 필요합니다.
이 가이드의 목적은 추상 어셈블리를 빠르게 이해하는 것입니다.

  • 이 문서는 어셈블러에 대한 기본 지식이 필요합니다.
  • 아키텍처 관련 문제가 발생하는 경우 항상 linux/amd64 로 가정합니다.
  • 항상 컴파일러 최적화가 활성화 된 상태로 작업할 것 입니다.
  • 인용된 텍스트와 주석은 달리 명시되지 않는 한 항상 공식 문서와 코드베이스에서 제공됩니다.




R1 또는 와 같은 일부 기호 LR 는 사전 정의되어 있으며 레지스터를 참조합니다.
정확한 집합은 아키텍처에 따라 다릅니다.

유사 레지스터를 참조하는 4개의 미리 선언된 기호가 있습니다.
이들은 실제 레지스터가 아니라 툴체인에서 유지 관리하는 가상 레지스터입니다.
유사 레지스터 세트는 모든 아키텍처에서 동일합니다.

  • FP: 프레임 포인터: 인수 및 지역변수.
  • PC: 프로그램 카운터: 점프 및 분기.
  • SB: 정적 기본 포인터: 전역 기호.
  • SP: 스택 포인터: 로컬 스택 프레임 내에서 가장 높은 주소.

모든 사용자 정의 기호는 유사 레지스터 FP(인수 및 지역) 및 SB(전역) 에 대한 오프셋으로 작성됩니다.

A Quick Guide to Go’s Assembler 를 참고했습니다.




Pseudo-assembly(유사-어셈블리)

Go 컴파일러는 하드웨어에 매핑되지 않는 추상적이고 이식 가능한(Portable) 형식의
어셈블리를 생성합니다.
그런 다음 Go 어셈블러는 이 유사-어셈블리 Output을 사용하여 대상 하드웨어에 대한
구체적인 기계-별(machine-spedific) 명령어를 생성합니다.

이런 추가 레이어는 많은 장점이 있으며, 큰 장점 중 하나는 Go를 새로운 아키텍처로
쉽게 이식할 수 있다는 것입니다.
자세한 내용은 마지막에 첨부된 Rob PikeThe Design of the Go Assembler 를 참고하세요.


Go의 어셈블러는 내부 시스템(Underlying machine)을 직접 표현 하지 않습니다.
세부 정보가 머신(명령)과 정확하게 매핑될 수도 있지만, 그렇지 않을 수도 있습니다.

이는 컴파일러 스위트(Compiler Suite)가 일반적인 파이프 라인에서
어셈블러 단계(assembler pass)를 필요로 하지 않기 때문입니다.

대신 컴파일러는 일종의 반-추상 명령어 세트(semi-abstract instruction set)로 작동하며,
명령어 선택은 부분적으로 코드 생성 후에 발생합니다.

어셈블러가 반-추상 형식으로 작동하므로 MOV와 같은 명령어에 대해 툴체인(toolchain)이 실제로
생성하는 것은 이동 명령이 아니라 지우기(clear) 또는 로드(load)일 수 있습니다.
물론 해당 이름의 기계 명령어와 정확히 일치할 수도 있습니다.
일반적으로 기계-별 작업(machine-specific operation)들은 그 자체로 나타나는 반면,
메모리 이동, 서브 루틴 호출, 리턴과 같은 일반적인 개념은 더 추상화됩니다.

세부 사항은 아키텍처에 따라 다르며 상황이 자세히 정의되지 않아 정확하지 않습니다.


어셈블러 프로그램은 반-추상 명령어 세트를 분석하여 링커에 입력할 명령어로 변환합니다.




간단한 프로그램 분해해보기

다음 Go 코드(direct_topfunc_call.go)를 확인해보겠습니다.

// go : noinline 
func  add(a , b  int32) (int32 , bool) {
  return  a  +  b , true
}

func  main () {
  add(10 , 32)
}

(이 //go:noinline 컴파일러 지시문을 참고 하세요…)

이 코드를 어셈블리로 컴파일하겠습니다.

$ GOOS=linux GOARCH=amd64 go tool compile -S direct_topfunc_call.go
0x0000 TEXT		"".add(SB), NOSPLIT, $0-16
  0x0000 FUNCDATA	$0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
  0x0000 FUNCDATA	$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
  0x0000 MOVL		"".b+12(SP), AX
  0x0004 MOVL		"".a+8(SP), CX
  0x0008 ADDL		CX, AX
  0x000a MOVL		AX, "".~r2+16(SP)
  0x000e MOVB		$1, "".~r3+20(SP)
  0x0013 RET

0x0000 TEXT		"".main(SB), $24-0
  ;; ...omitted stack-split prologue...
  0x000f SUBQ		$24, SP
  0x0013 MOVQ		BP, 16(SP)
  0x0018 LEAQ		16(SP), BP
  0x001d FUNCDATA	$0, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
  0x001d FUNCDATA	$1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)
  0x001d MOVQ		$137438953482, AX
  0x0027 MOVQ		AX, (SP)
  0x002b PCDATA		$0, $0
  0x002b CALL		"".add(SB)
  0x0030 MOVQ		16(SP), BP
  0x0035 ADDQ		$24, SP
  0x0039 RET
  ;; ...omitted stack-split epilogue...


컴파일러가 수행하는 작업을 이해하기 위해,
이 두 함수를 한 줄씩 분석해 보겠습니다.




add 분석하기

0x0000 TEXT "".add(SB), NOSPLIT, $0-16
  • 0x0000: 함수 시작을 기준으로 현재 명령어의 오프셋입니다.

  • TEXT "".add: TEXT 지시문은 "".add 기호를 .text 섹션(실행 가능한 코드)의 일부로
    선언하고, 이어지는 명령들은 함수 본문 입니다. 빈 문자열 "" 은 링크타임 때 현재 패키지
    이름으로 대체됩니다: 즉, "".add 는 최종 바이너리에 링크될 때 main.add 가 됩니다.

  • (SB): SB “static-base” 포인터를 저장하는 가상 레지스터입니다.
    즉, 프로그램 주소-공간의 시작 주소입니다.
    "".add(SB) 우리의 기호가 주소 공간의 시작에서 (링커가 계산한) 일정한 오프셋에 위치한다고
    선언합니다. 다르게 말하면, 절대적이고 직접적인 주소를 가지고 있는 전역 함수 기호입니다.
    objdump 로 모든 것을 확인할 수 있습니다.

$ objdump -j .text -t direct_topfunc_call | grep 'main.add'
000000000044d980 g     F .text	000000000000000f main.add

모든 사용자 정의 기호는 유사-레지스터 FP(인자 및 로컬 변수) 및 SB(전역 변수)에 대한
오프셋으로 기록됩니다.
SB 유사-레지스터는 메모리의 위치로 생각할 수 있으므로 기호 foo(SB)foo라는 이름이
메모리에 있는 주소입니다.

  • NOSPLIT: 컴파일러가 현재 스택의 확장 여부를 확인하는 스택-분할 프리앰블(Preamble)을
    삽입하지 않도록 표시합니다.
    add 함수의 경우, 컴파일러는 직접 플래그를 설정했습니다: 로컬 변수와 자체 스택-프레임이
    없기 때문에 현재 스택을 확장할 필요가 없다는 것을 알만큼 똑똑합니다.
    따라서 각 호출 위치마다 이런 검사를 실행하는 것은 CPU 타임을 낭비하는 것입니다.

NOSPLIT: 스택을 분할해야하는지 확인하기 위해 프리앰블을 삽입하지 마십시오.
루틴을 위한 프레임과 루틴이 호출하는 모든 것은 스택 세그먼트의 맨 위에 있는 여유 공간에
맞아야 합니다.
스택 분할(splitting) 코드는 루틴을 보호하는 데 사용됩니다.
이 글의 마지막에서 고루틴과 스택 분할에 대해 간단히 설명하겠습니다.

  • $0-16: $0 은 할당될 스택 프레임의 바이트 크기입니다; 그리고 $16 은 호출자(caller)가
    전달한 인자의 크기입니다.

일반적인 경우 프레임 크기 뒤에 마이너스 기호(-)로 구분된 인수 크기가 옵니다.
(뺄셈이 아니라 단지 특이한 구문입니다.)
프레임 크기 $24-8 은 함수에 24 바이트 프레임이 있고 호출자의 프레임에 있는
8 바이트의 인자로 호출되는 것을 나타냅니다.
TEXT에 대해 NOSPLIT가 지정되지 않은 경우 인자 크기를 제공해야 합니다.
Go 프로토타입이 있는 어셈블리 함수의 경우 go vet은 인자 크기가 올바른지 확인합니다.

0x0000 FUNCDATA $0, gclocals·f207267fbf96a0178e8758c6e3e0ce28(SB)
0x0000 FUNCDATA $1, gclocals·33cdeccccebe80329f1fdbee7f5874cb(SB)

FUNCDATA 는 함수 메타데이터 리스트의 인덱스 0, 1 을 선언합니다.
인덱스의 유일한 목적은 충돌 없이 여러 종류의 FUNCDATA 를 정의하는 것입니다.
src/pkg/runtime/funcdata.h 에서 유지 관리되는 중앙 인덱스 레지스트리가 필요합니다.

이와 유사하게,

0x001e 00030 PCDATA  $1, $0

위 코드는 현재 프로그램 카운터와 연결된 인덱스 1의 값이 0이라고 선언합니다.
각 pcdata 인덱스는 별도의 pc-value 테이블로 인코딩됩니다.
funcdata와 마찬가지로 인덱스는 주어진 함수에 대해 여러 종류의 pcdata를 정의할 수 있도록 하며
해당 인덱스에 대한 레지스트리도 있습니다.

FUNCDATAPCDATA 지시문에는 가비지 컬렉터가 사용하기 위한 정보가 포함되어 있으며,
이는 컴파일러가 진행합니다.

이 내용은 지금 다루지 않고. 다음에 가비지 컬렉션을 볼 때 살펴 보겠습니다.

0x0000 MOVL "".b+12(SP), AX
0x0004 MOVL "".a+8(SP), CX

Go 호출 규칙은 호출자의 스택 프레임에 미리 예약된 공간을 사용하여 모든 인자들을
스택에 전달해야 합니다.
인자가 피호출자(callee)에게 전달되고 잠재적 인 리턴 값이 호출자에게 다시
전달될 수 있도록 스택을 적절히 확장(및 축소)하는 것은 호출자(caller)의 책임입니다.

Go 컴파일러는 PUSH/POP 제품군에서 명령어를 생성하지 않습니다:
하드웨어 스택 포인터 SP 를 각각 감소시키거나 증가시킴으로써 스택이 증가하거나 축소됩니다.
[UPDATE: 이슈 #21: SP 레지스터에 대하여.]

SP 유사-레지스터는 프레임(로컬 변수와 함수 호출을 위해 준비되는 인자)을 참조하는 데 사용되는
가상 스택 포인터입니다.
이 레지스터는 로컬 스택 프레임의 맨 위를 가리키므로 참조는 [-framesize, 0): x-8(SP), y-4(SP)
등의 범위에서 마이너스 오프셋을 사용해야 합니다.

공식 문서에는 “모든 사용자 정의 기호가 유사-레지스터 FP(인자 및 지역)에 대한 오프셋으로 기록됩니다”
라고 명시되어 있지만 이는 수작업으로 작성한 코드에만 해당됩니다.
최신 컴파일러와 마찬가지로 Go 툴 세트(suite)는 항상 생성하는 코드에서 직접 스택 포인터의
오프셋을 사용하여 인수 및 로컬을 참조합니다. 이를 통해 레지스터 수가 적은(예: x86)
플랫폼에서 프레임 포인터를 추가 범용(extra general-purpose) 레지스터로 사용할 수 있습니다.
이런 종류의 핵심적인 세부 사항을 즐기고 있다면 이 장의 끝에 있는
링크 시 x86-64의 스택 프레임 레이아웃 을 살펴보십시오.
[업데이트: 이슈 #2: 프레임 포인터.]

"".b + 12(SP)"".a + 8(SP)는 각각 스택 맨 아래에 있는 12 바이트 및 8 바이트
주소를 나타냅니다.(스택은 아래로 자랍니다!).
.a.b는 참조된 위치에 지정된 임의의 별칭입니다.
특별한 의미는 없지만 가상 레지스터에서 상대 주소 지정을 사용할 때 꼭 필요합니다.
가상 프레임 포인터에 대한 문서에는 이에 대해 다음과 같은 내용이 있습니다.

FP 유사-레지스터는 함수 인자를 참조하는 데 사용되는 가상 프레임 포인터입니다.
컴파일러는 가상 프레임 포인터를 유지하고 스택의 인자를 해당 유사-레지스터의
오프셋으로 참조합니다.
따라서 0(FP)는 함수에 대한 첫 번째 인자이고 8(FP)은 두 번째
(64 비트 시스템에서) 입니다.
그러나 이런 식으로 함수 인자를 참조할 때는 first_arg + 0(FP)
second_arg + 8(FP) 에서처럼 처음에 이름을 배치해야 합니다.
(오프셋-프레임 포인터로부터 오프셋-은 SB에서 사용하는 것과는 다릅니다.
여기서는 기호로부터의 오프셋입니다.)
어셈블러는 이 규칙을 적용하여, 일반 0(FP)8(FP)을 거부합니다.
실제 이름은 의미 상 관련이 없지만 인수 이름을 문서화하는 데 사용해야 합니다.

마지막으로 여기서 주목해야 할 두 가지 중요한 사항이 있습니다.

  1. 첫 번째 인수 a0(SP) 이 아니라 8(SP) 에 있습니다.
    이는 호출자가 CALL 유사-명령어(pseudo-instruction)로 리턴 주소를 0(SP)
    저장하기 때문입니다.
  2. 인자는 역순으로 전달됩니다. 즉, 첫 번째 인자는 스택의 맨 위에 가장 가깝습니다.
0x0008 ADDL CX, AX
0x000a MOVL AX, "".~r2+16(SP)
0x000e MOVB $1, "".~r3+20(SP)

ADDLAXCX에 저장된 두 개의 Long-words(즉, 4 바이트 값)를 더한 다음
최종 결과를 AX에 저장합니다.
그런 다음 그 결과는 "".~r2 + 16(SP)로 이동합니다. 여기서 호출자는 이전에
일부 스택 공간을 예약했고 리턴 값을 찾을 것입니다.
다시 한번, "". ~r2는 여기서 의미 론적 의미가 없습니다.

Go 가 여러 리턴 값을 처리하는 방법을 보여주기 위해 상수 true boolean 값도 반환합니다.
작동 방식은 첫 번째 리턴 값과 정확히 동일합니다. SP와 관련된 오프셋만 변경됩니다.

0x0013 RET

마지막 RET 유사-명령어는 Go 어셈블러에게 서브루틴 호출에서 올바르게 리턴하기 위해
대상 플랫폼의 호출 규칙에 필요한 모든 명령어를 삽입하도록 지시합니다.
이로 인해 코드가 0(SP)에 저장된 리턴 주소를 얻은 다음 점프합니다.

TEXT 블록의 마지막 명령어는 일종의 점프 여야하며 일반적으로 RET (유사-)명령어입니다.
(그렇지 않은 경우 링커는 자체 점프 명령어를 추가합니다. TEXT에는 폴스루가 없습니다.)

한 번에 모두 수집하기에는 너무 많은 구문과 의미들입니다.
다음은 우리가 방금 다룬 내용에 대한 간략한 요약입니다.

;; Declare global function symbol "".add (actually main.add once linked)
;; Do not insert stack-split preamble
;; 0 bytes of stack-frame, 16 bytes of arguments passed in
;; func add(a, b int32) (int32, bool)
0x0000 TEXT	"".add(SB), NOSPLIT, $0-16
  ;; ...omitted FUNCDATA stuff...
  0x0000 MOVL	"".b+12(SP), AX	    ;; move second Long-word (4B) argument from caller's stack-frame into AX
  0x0004 MOVL	"".a+8(SP), CX	    ;; move first Long-word (4B) argument from caller's stack-frame into CX
  0x0008 ADDL	CX, AX		    ;; compute AX=CX+AX
  0x000a MOVL	AX, "".~r2+16(SP)   ;; move addition result (AX) into caller's stack-frame
  0x000e MOVB	$1, "".~r3+20(SP)   ;; move `true` boolean (constant) into caller's stack-frame
  0x0013 RET			    ;; jump to return address stored at 0(SP)

전체적으로 다음은 main.add가 실행을 마쳤을 때 스택이 어떻게 보이는지
시각적으로 표현한 것입니다:

   |   -+-------------------------+ <-- 32(SP)              
   |    |                         |                         
 G |    |                         |                         
 R |    |                         |                         
 O |    | main.main's saved       |                         
 W |    |     frame-pointer (BP)  |                         
 S |   -|-------------------------| <-- 24(SP)              
   |    |      [alignment]        |                         
 D |    | "".~r3 (bool) = 1/true  | <-- 21(SP)              
 O |    |-------------------------| <-- 20(SP)              
 W |    |                         |                         
 N |    | "".~r2 (int32) = 42     |                         
 W |   -|-------------------------| <-- 16(SP)              
 A |    |                         |                         
 R |    | "".b (int32) = 32       |                         
 D |    |-------------------------| <-- 12(SP)              
 S |    |                         |                         
   |    | "".a (int32) = 10       |                         
   |   -|-------------------------| <-- 8(SP)               
   |    |                         |                         
   |    |                         |                         
   |    |                         |                         
 \ | /  | return address to       |                         
  \|/   |     main.main + 0x30    |                         
   -   -+-------------------------+ <-- 0(SP) (TOP OF STACK)

(diagram made with https://textik.com)




main 분석하기

main 함수를 설명하겠습니다.

0x0000 TEXT		"".main(SB), $24-0
  ;; ...omitted stack-split prologue...
  0x000f SUBQ		$24, SP
  0x0013 MOVQ		BP, 16(SP)
  0x0018 LEAQ		16(SP), BP
  ;; ...omitted FUNCDATA stuff...
  0x001d MOVQ		$137438953482, AX
  0x0027 MOVQ		AX, (SP)
  ;; ...omitted PCDATA stuff...
  0x002b CALL		"".add(SB)
  0x0030 MOVQ		16(SP), BP
  0x0035 ADDQ		$24, SP
  0x0039 RET
  ;; ...omitted stack-split epilogue...
0x0000 TEXT "".main(SB), $24-0

새로운 것은 없습니다:

  • "".main (연결되면 main.main)은 .text 섹션의 전역 함수 기호이며,
    주소는 주소 공간의 시작 부분에서 일정한 오프셋입니다.
  • 24 바이트 스택 프레임을 할당하고 인자를 받지 않으며 값을 리턴하지도 않습니다.
0x000f SUBQ     $24, SP
0x0013 MOVQ     BP, 16(SP)
0x0018 LEAQ     16(SP), BP

위에서 언급했듯이 Go 호출 규칙에서는 모든 인자가 스택에 전달되어야 합니다.

호출자인 main은 가상 스택 포인터를 감소시켜 스택 프레임을 24 바이트 씩 늘립니다
(스택이 아래쪽으로 커지므로 여기서 SUBQ는 실제로 스택 프레임을 더 크게 만듭니다).
24 바이트 중에서:

  • 8 바이트 (16(SP) - 24(SP))는 스택 해제를 허용하고 디버깅을 쉽게 할 수 있도록
    프레임 포인터 BP (실제!)의 현재 값을 저장하는 데 사용됩니다.
  • 1+3 바이트 (12(SP) - 16(SP))는 두 번째 리턴 값(bool)과 amd64에서 정렬에 필요한
    3 바이트를 위해 예약되어 있습니다.
  • 4 바이트 (8(SP) - 12(SP))는 첫 번째 리턴 값(int32)으로 예약되어 있습니다.
  • 4 바이트 (4(SP) - 8(SP))는 인수 b (int32)의 값으로 예약되어 있습니다.
  • 4 바이트 (0(SP) - 4(SP))는 인수 a (int32)의 값으로 예약되어 있습니다.
    마지막으로 스택의 성장에 따라 LEAQ는 프레임 포인터의 새 주소를 계산하여
    BP에 저장합니다.


그림 1. main.main 내부 스택.


0x001d MOVQ     $137438953482, AX
0x0027 MOVQ     AX, (SP)

호출자는 피호출자에 대한 인자를 방금 증가한 스택의 맨 위에 쿼드 Word(즉, 8 바이트 값)로
푸시합니다.
처음에는 임의의 쓰레기처럼 보일 수 있지만, 실제로 137438953482는 하나의 8 바이트
값으로 연결된 10 과 32 인 4 바이트 값에 해당합니다.

$ echo 'obase=2;137438953482' | bc
10000000000000000000000000000000001010
\____/\______________________________/
   32                              10
54321076543210765432107654321076543210
     4       3       2       1       0
0x002b CALL     "".add(SB)

우리는 정적-기반 포인터에 상대적인 오프셋으로 add 함수를 호출합니다.
즉, 이것은 직접(direct) 주소로의 간단한 점프입니다.

CALL은 스택의 맨 위에 있는 리턴 주소(8 바이트 값)도 푸시합니다.
따라서 add 함수 내에서 만들어진 SP에 대한 모든 참조는 결국 8 바이트로 오프셋됩니다!
예: "".a는 더 이상 0(SP)이 아니라 8(SP)에 있습니다.

0x0030 MOVQ     16(SP), BP
0x0035 ADDQ     $24, SP
0x0039 RET

마지막으로 우리는:

  1. 프레임 포인터를 한 스택-프레임만큼 해제합니다(즉, 한 단계 “아래로 이동”).
  2. 스택을 24 바이트만큼 축소하여 이전에 할당한 스택 공간을 회수합니다.
  3. Go 어셈블러에게 서브 루틴 반환 관련 항목을 삽입하도록 요청


그림 2. main.add 내부 스택.




고루틴과 스택 분할

지금은 고루틴의 내부를 설명하진 않지만, 어셈블리 덤프를 점점 더 많이 보면서
스택 관리와 관련된 지침이 매우 익숙해질 것입니다.
우리는 이러한 패턴을 빠르게 인식할 수 있어야 하며, 그 동안에 그들이 하는 일에
대한 일반적인 아이디어를 이해하고 왜 그렇게 하는지 알아야 합니다.


스택

Go 프로그램의 수많은 고루틴은 비결정적이며 실제로는 수 백만 개까지 생성될 수 있으므로
런타임은 고루틴이 스택 공간을 할당할 때 사용 가능한 메모리를 모두 차지하지 않도록
보수적인 방법을 취해야 합니다.
따라서 모든 새로운 고루틴에는 런타임이 2KB의 작은 스택을 제공합니다.

고 루틴이 작업을 수행함에 따라 인위적인 초기 스택 공간(예: 스택 오버플로)을
초과할 수 있습니다.
이를 방지하기 위해 런타임은 고루틴에 스택이 부족할 때 이전 스택의 두 배 크기를 가진
새롭고 더 큰 스택을 할당하고 원래 스택의 내용을 복사합니다.
이 프로세스를 스택 분할이라고 하며 고루틴 스택의 크기를 동적으로 효과적으로 조정합니다.


분할

스택 분할이 작동하도록 컴파일러는 잠재적으로 스택을 오버플로할 수 있는
모든 함수의 시작과 끝에 몇 가지 명령어를 삽입합니다.
이 장의 앞부분에서 살펴본 것처럼 불필요한 오버 헤드를 피하기 위해 스택을
초과할 수 없는 함수는 컴파일러가 이런 검사를 삽입하지 않도록 NOSPLIT 힌트를
표시합니다.

이번에는 스택 분할 프리앰블을 생략하지 않고 이전의 주요 기능을 살펴 보겠습니다.

0x0000 TEXT	"".main(SB), $24-0
  ;; stack-split prologue
  0x0000 MOVQ	(TLS), CX
  0x0009 CMPQ	SP, 16(CX)
  0x000d JLS	58

  0x000f SUBQ	$24, SP
  0x0013 MOVQ	BP, 16(SP)
  0x0018 LEAQ	16(SP), BP
  ;; ...omitted FUNCDATA stuff...
  0x001d MOVQ	$137438953482, AX
  0x0027 MOVQ	AX, (SP)
  ;; ...omitted PCDATA stuff...
  0x002b CALL	"".add(SB)
  0x0030 MOVQ	16(SP), BP
  0x0035 ADDQ	$24, SP
  0x0039 RET

  ;; stack-split epilogue
  0x003a NOP
  ;; ...omitted PCDATA stuff...
  0x003a CALL	runtime.morestack_noctxt(SB)
  0x003f JMP	0

보시다시피 스택 분할 프리앰블은 프롤로그에필로그 로 나뉩니다.

  • 프롤로그 는 고루틴의 스택 공간이 부족한지 확인하고 만약 그렇다면 에필로그 로 이동합니다.
  • 에필로그 는 스택 성장 로직을 트리거 한 다음 프롤로그 로 다시 이동합니다.

이것은 우리의 고루틴이 충분히 큰 스택이 할당되지 않은 한 계속되는 루프를 생성합니다.

프롤로그

0x0000 MOVQ	(TLS), CX   ;; store current *g in CX
0x0009 CMPQ	SP, 16(CX)  ;; compare SP and g.stackguard0
0x000d JLS	58	    ;; jumps to 0x3a if SP <= g.stackguard0

TLS 는 현재 g(즉, 고 루틴)의 모든 상태를 기록하는 데이터 구조를 보유한
런타임이 유지하는 가상 레지스터 입니다.

런타임의 소스 코드에서 g 정의를 살펴보면:

type g struct {
	stack       stack   // 16 bytes
	// stackguard0 is the stack pointer compared in the Go stack growth prologue.
	// It is stack.lo+StackGuard normally, but can be StackPreempt to trigger a preemption.
	stackguard0 uintptr
	stackguard1 uintptr

	// ...omitted dozens of fields...
}

16(CX)g.stackguard0 에 해당하는데, 이 값은 런타임에 의해 유지되는
임계값(threshold)으로, 스택 포인터와 비교하여 고루틴의 공간 부족을 확인할 수 있습니다.
따라서 프롤로그 는 현재 SP 값이 stackguard0 임계값(즉, 더 큰 값)보다 작거나 같은지 확인한 후, 공간이 부족한 경우 경우 에필로그 로 점프합니다.


에필로그

0x003a NOP
0x003a CALL	runtime.morestack_noctxt(SB)
0x003f JMP	0

에필로그의 본문은 아주 간단합니다.
즉, 런타임을 호출하여 스택을 키우는 실제 작업을 한 다음, 함수의의 첫 번째 명령
(즉, 프롤로그)으로 되돌아간다.

호출이 존재하기 직전에 NOP 명령으로 프롤로그 가 직접 호출 명령으로
이동하지 않습니다. 일부 플랫폼에서, 그렇게 하는 것은 문제가 될 수 있습니다;
실제 호출 직전에 noop 명령을 설정하고 이 NOP에 도착하는 것은 흔한 일이다.




Go Assembler 관련 명령

  • Command compile
    go tool compile [flags] file...
    
    sgo tool compile -S {go file}
    
  • Command objdump
    go tool objdump [-s symregexp] binary
    
    $ go tool objdump {binary} >> {output file}
    




참고자료

댓글남기기