Skip to content

Commit f02d7e6

Browse files
authored
Merge pull request #250 from gg582/main
Add a post about Golang and Go Assembly
2 parents bc9c5d3 + 8641dba commit f02d7e6

2 files changed

Lines changed: 310 additions & 0 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
author: Lee Yunjin
3+
title: C language And Go: What is similar
4+
---
5+
6+
##
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
---
2+
id: 260eb3cf473a1591de25a5906d102639
3+
author: Lee Yunjin
4+
title: '매니지드 언어와 Go 어셈블리 분석: 기초부터 If문까지'
5+
description: 매니지드 언어의 정의와 Go 언어의 바이너리 구조를 살펴보고, If문이 어셈블리로 어떻게 번역되는지 실습을 통해 심층 분석합니다.
6+
language: ko
7+
date: 2026-05-17T12:00:00Z
8+
path: /blog/posts/managed-language-and-go-assembly-b16003ea
9+
---
10+
11+
## 매니지드 언어란 무엇인가?
12+
13+
매니지드 언어는 언매니지드 언어, 즉 프로그래머가 짠 로직에서 크게 벗어나지 않고 수행만 하는 언어와 달리 GC, 런타임 최적화, 그린 쓰레드, 동시성 처리 등을 런타임에서 실행하여서 사용자가 위험한 저수준 관리를 할 필요 없게 만들어 주는 언어입니다.
14+
15+
이러한 언어의 경우, 비즈니스 로직에만 집중하여 개발에 몰입할 수 있다는 장점이 있지만, 반면 프로그래머의 직관과 실제 프로그램이 다르게 동작할 수 있어서 정교한 런타임 튜닝이 필요할 때도 있습니다.
16+
17+
먼저, 매니지드 언어 중에 가장 미니멀리스트 철학에 충실하고 어셈블리가 정직한 Go언어를 보도록 하겠습니다.
18+
19+
### Go 언어의 바이너리 구조
20+
21+
| .text | .data | .gopclntab, .typelink 등 |
22+
| :--- | :--- | :--- |
23+
| 실행될 기계어 코드 | 저장될 데이터 | 언어 런타임 섹션 |
24+
25+
Go언어는 사용자가 입력한대로 1:1로 기계어 번역하지 않기 때문에, `.text` 섹션의 로직은 언어 런타임 섹션과도 긴밀하게 연관되어 있습니다.
26+
27+
또한, 사용자가 따로 작성하지 않은 `runtime.printnl()` 같은 함수들이 `.text` 섹션 어셈블리에 추가됩니다. 이러한 자동적인 코드 삽입을 통해 Go 언어는 수동 관리로부터 개발자를 벗어나게 돕습니다.
28+
29+
---
30+
31+
## Go에서 main 함수 부분만 보기
32+
33+
우선, 간단한 예시 소스 `main.go`를 작성해서 main부터 **AMD64 머신에서** 보도록 하겠습니다.
34+
35+
```go
36+
package main
37+
38+
func sayHello(msg string) {
39+
println(msg)
40+
}
41+
42+
func main() {
43+
sayHello("Hello World")
44+
}
45+
```
46+
47+
이후 이렇게 빌드합니다.
48+
49+
```bash
50+
go build main.go
51+
```
52+
53+
Go는 쉬운 저수준 디버깅을 위해 `go tool`을 지원합니다. `go tool`에서 메인 패키지에서 메인 함수만큼의 어셈블리만 보기 위해서 이 구문을 입력합니다.
54+
55+
```bash
56+
go tool objdump -s "main\.main" ./main
57+
```
58+
59+
### 어셈블리 분석
60+
61+
```go
62+
TEXT main.main(SB) /home/yjlee/compare-assembly/go/main.go
63+
main.go:7 0x468f60 493b6610 CMPQ SP, 0x10(R14)
64+
main.go:7 0x468f64 762f JBE 0x468f95
65+
main.go:7 0x468f66 55 PUSHQ BP
66+
main.go:7 0x468f67 4889e5 MOVQ SP, BP
67+
main.go:7 0x468f6a 4883ec10 SUBQ $0x10, SP
68+
main.go:8 0x468f6e 90 NOPL
69+
main.go:4 0x468f6f e8cca3fcff CALL runtime.printlock(SB)
70+
main.go:4 0x468f74 488d05da290100 LEAQ 0x129da(IP), AX
71+
main.go:4 0x468f7b bb0b000000 MOVL $0xb, BX
72+
main.go:4 0x468f80 e83bacfcff CALL runtime.printstring(SB)
73+
main.go:4 0x468f85 e8f6a5fcff CALL runtime.printnl(SB)
74+
main.go:4 0x468f8a e811a4fcff CALL runtime.printunlock(SB)
75+
main.go:9 0x468f8f 4883c410 ADDQ $0x10, SP
76+
main.go:9 0x468f93 5d POPQ BP
77+
main.go:9 0x468f94 c3 RET
78+
main.go:7 0x468f95 e8e6afffff CALL runtime.morestack_noctxt.abi0(SB)
79+
main.go:7 0x468f9a ebc4 JMP main.main(SB)
80+
```
81+
82+
- 현재 쓰레드에 진입한지 `CMPQ`로 비교한 후, 맞다면 Entrypoint `0x468f95`로 점프합니다.
83+
- 진입점을 `PUSHQ BP`로 스택에 삽입합니다.
84+
- 가장 최근에 데이터가 적재된 레지스터 `SP`에 함수 시작 시 스택 시작 지점을 지정하여 지역 변수 참조 시의 진입점을 고정합니다.
85+
- 이후 16바이트만큼의 로컬 변수 스택을 예약하고 (`SUBQ $0x10, SP`), `NOPL`을 이용해서 여러 바이트를 채워 CPU 캐시 정렬을 합니다.
86+
- Go Runtime에서 스트링 버퍼의 출력 락을 `runtime.printlock(SB)`를 호출하여 겁니다.
87+
- `LEAQ` 명령을 이용해서 할당한 문자열의 시작 주소를 범용 레지스터 중 데이터 저장에 쓰는 누산기 주소인 `AX`에 저장합니다.
88+
- 이후 연산 보조 및 임시 데이터 저장에 쓰는 `BX` 레지스터에 문자열 길이 11을 저장합니다. (`MOVL $0Xb, BX`)
89+
- `runtime.printstring(SB)`로 SB 쪽으로 누산기 정보를 출력합니다.
90+
- 한 줄 공백도 `rumtime.printnl(SB)`로 SB쪽으로 씁니다.
91+
- 스트링 버퍼를 `runtime.printunlock(SB)`로 해제합니다.
92+
- `ADDQ $0x10, SP`로 빌린 16바이트 스택 메모리를 돌려줍니다.
93+
- 처음에 진입점을 스택에 넣어 알려 줬으니 이제 `POPQ BP`로 스택에서 진입점을 뺀 후 반환 시그널을 줍니다.
94+
- 이후 `runtime.morestack_noctxt.abi0(SB)`로 매니지드 언어답게 충분한 스택을 할당, GC 등의 런타임을 셋업합니다.
95+
- 관리된 `main.main(SB)` 주소로 이동합니다.
96+
97+
보기와 같이 비즈니스 로직의 어셈블리는 꽤 명확하고, 얇은 런타임 관리만 덧붙여진 형태입니다.
98+
99+
### 최적화가 없을 때
100+
101+
위의 형태는 Go 컴파일러에서 따로 떨어져 있는 두 함수를 자동으로 인라이닝해 최적화한 결과입니다. 그러나, 우리는 학습을 위해 이 경우에는 `sayHello`를 인라이닝하지 않게 할 것입니다.
102+
103+
이렇게 하기 위해 다음 플래그로 소스를 컴파일합니다.
104+
105+
```bash
106+
go build -gcflags="-l" main.go
107+
```
108+
109+
쉘에서 결과를 찍어보면 중복되는 어셈블리가 발견됩니다.
110+
111+
```bash
112+
yjlee@elegant:~/compare-assembly/go$ go build -gcflags="-l" main.go
113+
yjlee@elegant:~/compare-assembly/go$ go tool objdump -s "main\.sayHello" ./main
114+
TEXT main.sayHello(SB) /home/yjlee/compare-assembly/go/main.go
115+
main.go:3 0x468f60 493b6610 CMPQ SP, 0x10(R14)
116+
main.go:3 0x468f64 7636 JBE 0x468f9c
117+
main.go:3 0x468f66 55 PUSHQ BP
118+
main.go:3 0x468f67 4889e5 MOVQ SP, BP
119+
main.go:3 0x468f6a 4883ec10 SUBQ $0x10, SP
120+
main.go:5 0x468f6e 4889442420 MOVQ AX, 0x20(SP)
121+
main.go:5 0x468f73 48895c2428 MOVQ BX, 0x28(SP)
122+
main.go:4 0x468f78 e8c3a3fcff CALL runtime.printlock(SB)
123+
main.go:4 0x468f7d 488b442420 MOVQ 0x20(SP), AX
124+
main.go:4 0x468f82 488b5c2428 MOVQ 0x28(SP), BX
125+
main.go:4 0x468f87 e834acfcff CALL runtime.printstring(SB)
126+
main.go:4 0x468f8c e8efa5fcff CALL runtime.printnl(SB)
127+
main.go:4 0x468f91 e80aa4fcff CALL runtime.printunlock(SB)
128+
main.go:5 0x468f96 4883c410 ADDQ $0x10, SP
129+
main.go:5 0x468f9a 5d POPQ BP
130+
main.go:5 0x468f9b c3 RET
131+
main.go:3 0x468f9c 4889442408 MOVQ AX, 0x8(SP)
132+
main.go:3 0x468fa1 48895c2410 MOVQ BX, 0x10(SP)
133+
main.go:3 0x468fa6 e8d5afffff CALL runtime.morestack_noctxt.abi0(SB)
134+
main.go:3 0x468fab 488b442408 MOVQ 0x8(SP), AX
135+
main.go:3 0x468fb0 488b5c2410 MOVQ 0x10(SP), BX
136+
main.go:3 0x468fb5 eba9 JMP main.sayHello(SB)
137+
```
138+
139+
즉 컴파일러가 최적화하는 것은 이러한 중복 연산, 비효율적인 루프 언롤링 등의 대상임이 확인되었습니다.
140+
141+
---
142+
143+
## Go 언어에서의 If문
144+
145+
먼저, 우리가 Go를 선택한 것은 Go가 모던 언어 중에서는 가장 어셈블리가 '아름답고', 고전 언어들과 비교해도 그 구문의 효율성은 오히려 압도적일 때가 있기 때문입니다.
146+
147+
이제 간단한 Go 프로그램의 동작 방식에 대해 이해했으니 바로 Go와 Assembly를 줄 별로 비교해 보도록 하겠습니다.
148+
149+
### 소스 코드
150+
151+
우선, Go에서도 그러하지만 심지어는 GCC를 포함한 모던 컴파일러들은 사용하는 의의가 없는 분기문을 자동으로 최적화합니다. 따라서, 컴파일러 입장에서 예측하여 다른 구문으로 바꿔 버리기 힘든 조건을 주어야 적어도 의미를 갖습니다.
152+
153+
```go
154+
package main
155+
156+
import (
157+
"os"
158+
"strconv"
159+
)
160+
161+
func main() {
162+
// 입력을 컴파일러가 예측할 수 없게 os.Args를 사용합니다.
163+
if len(os.Args) < 2 {
164+
return
165+
}
166+
x, _ := strconv.Atoi(os.Args[1])
167+
168+
if x < 10 {
169+
println("X is smaller than 10")
170+
} else {
171+
println("X is larger or same as 10")
172+
}
173+
}
174+
```
175+
176+
이 경우, 입력을 컴파일러가 예측할 수 없기 때문에 분기문은 그대로 기계어 번역됩니다.
177+
178+
### 어셈블리어 분석
179+
180+
```asm
181+
TEXT main.main(SB) /home/yjlee/introduction-to-golang/learn-golang/if-and-switch/golang-if/main.go
182+
main.go:8 0x47a840 493b6610 CMPQ SP, 0x10(R14)
183+
main.go:8 0x47a844 7670 JBE 0x47a8b6
184+
main.go:8 0x47a846 55 PUSHQ BP
185+
main.go:8 0x47a847 4889e5 MOVQ SP, BP
186+
main.go:8 0x47a84a 4883ec10 SUBQ $0x10, SP
187+
main.go:15 0x47a84e 48833d12fb0a0002 CMPQ os.Args+8(SB), $0x2
188+
main.go:15 0x47a856 7c58 JL 0x47a8b0
189+
main.go:15 0x47a858 488b0d01fb0a00 MOVQ os.Args(SB), CX
190+
main.go:18 0x47a85f 488b4110 MOVQ 0x10(CX), AX
191+
main.go:18 0x47a863 488b5918 MOVQ 0x18(CX), BX
192+
main.go:18 0x47a867 e834e8ffff CALL strconv.Atoi(SB)
193+
main.go:20 0x47a86c 4883f80a CMPQ AX, $0xa
194+
main.go:20 0x47a870 7d1d JGE 0x47a88f
195+
main.go:21 0x47a872 e809befbff CALL runtime.printlock(SB)
196+
main.go:21 0x47a877 488d0519f50100 LEAQ 0x1f519(IP), AX
197+
main.go:21 0x47a87e bb15000000 MOVL $0x15, BX
198+
main.go:21 0x47a883 e878c6fbff CALL runtime.printstring(SB)
199+
main.go:21 0x47a888 e853befbff CALL runtime.printunlock(SB)
200+
main.go:21 0x47a88d eb1b JMP 0x47a8aa
201+
main.go:23 0x47a88f e8ecbdfbff CALL runtime.printlock(SB)
202+
main.go:23 0x47a894 488d05c8040200 LEAQ 0x204c8(IP), AX
203+
main.go:23 0x47a89b bb1a000000 MOVL $0x1a, BX
204+
main.go:23 0x47a8a0 e85bc6fbff CALL runtime.printstring(SB)
205+
main.go:23 0x47a8a5 e836befbff CALL runtime.printunlock(SB)
206+
main.go:25 0x47a8aa 4883c410 ADDQ $0x10, SP
207+
main.go:25 0x47a8ae 5d POPQ BP
208+
main.go:25 0x47a8af c3 RET
209+
main.go:16 0x47a8b0 4883c410 ADDQ $0x10, SP
210+
main.go:16 0x47a8b4 5d POPQ BP
211+
main.go:16 0x47a8b5 c3 RET
212+
main.go:8 0x47a8b6 e845f0feff CALL runtime.morestack_noctxt.abi0(SB)
213+
main.go:8 0x47a8bb eb83 JMP main.main(SB)
214+
```
215+
216+
### CMPQ 명령어 & JL 명령어
217+
218+
`CMPQ` 명령어는 4바이트(4워드) 자료형을 비교하기 위한 명령어이고, 어원은 **C**o**MP**are **Q**uadword입니다.
219+
220+
`0x47a84e`번 메모리 주소를 보면, `CMPQ os.Args+8(SB), $0x2` 구문이 들어가 있습니다. 이 경우, 프로그램이 입력받은 인자 수와 16진수 `0x2`(즉 2)를 비교합니다.
221+
222+
이후, 인자가 2보다 작은지 `JL` (**J**ump if **L**ess)을 통해 비교 후 점프를 수행합니다. 인자가 2보다 작았다면, `0x47a8b0` 주소로 점프하여 함수를 종료합니다.
223+
224+
### MOVQ 명령어와 Go의 String 구조
225+
226+
`0x47858`-`0x47863` 범위를 보면 `os.Args`에서 데이터를 가져오는 과정을 볼 수 있습니다. Go의 `string`은 구조체이며, 16바이트(8바이트 포인터 + 8바이트 길이)로 구성됩니다.
227+
228+
| struct | 8 byte | 8 byte |
229+
| :--- | :--- | :--- |
230+
| string | memory address | string length |
231+
232+
따라서 스트링의 주소를 `AX` 레지스터, 길이를 `BX` 레지스터에 저장하여 함수로 전달하거나 처리합니다.
233+
234+
### CMPQ 명령어 & JGE 명령어 (역조건 최적화)
235+
236+
주소 `0x47a86c`를 보면 `CMPQ AX, $0xa` (10과 비교) 후 `JGE` (**J**ump if **G**reater or **E**qual) 명령어가 등장합니다.
237+
238+
우리의 소스는 `if x < 10`이었지만, 어셈블리에서는 `x >= 10`이면 else 블록으로 점프하게 되어 있습니다. 이것은 **역조건을 이용해 점프 횟수를 줄이는 전형적인 컴파일러 최적화**입니다.
239+
240+
---
241+
242+
### 거울상 코드 검증 (Mirroring Code)
243+
244+
아래 스크립트를 이용하면 서로 다른 소스 코드가 어떻게 동일한 어셈블리 결과를 낼 수 있는지 확인할 수 있습니다.
245+
246+
```bash
247+
#!/usr/bin/env bash
248+
249+
# 1. 기존 잔여 파일 및 디렉터리 완전 초기화
250+
rm -rf test_dir main_orig main_asm orig.asm asm.asm orig_pure.asm asm_pure.asm
251+
mkdir -p test_dir
252+
253+
# 2. 원래 버전 소스 코드 작성 (main.go)
254+
cat << 'EOF' > main.go
255+
package main
256+
import ("os"; "strconv")
257+
func main() {
258+
if len(os.Args) < 2 { return }
259+
x, _ := strconv.Atoi(os.Args[1])
260+
s1 := "X is smaller than 10"
261+
s2 := "X is larger or same as 10"
262+
if x < 10 { println(s1) } else { println(s2) }
263+
}
264+
EOF
265+
266+
# 3. 거울상 버전 소스 코드 작성 (main_from_asm.go)
267+
cat << 'EOF' > main_from_asm.go
268+
package main
269+
import ("os"; "strconv")
270+
func main() {
271+
if len(os.Args) < 2 { return }
272+
x, _ := strconv.Atoi(os.Args[1])
273+
s1 := "X is smaller than 10"
274+
s2 := "X is larger or same as 10"
275+
// 구조적으로 동일하게 작성
276+
if x < 10 { println(s1) } else { println(s2) }
277+
}
278+
EOF
279+
280+
# 4. 빌드 수행
281+
cp main.go test_dir/main.go
282+
cd test_dir && go build -o ../main_orig main.go && cd ..
283+
rm test_dir/main.go
284+
cp main_from_asm.go test_dir/main.go
285+
cd test_dir && go build -o ../main_asm main.go && cd ..
286+
287+
# 5. 어셈블리 추출 및 비교
288+
go tool objdump -s "main\.main" main_orig > orig.asm
289+
go tool objdump -s "main\.main" main_asm > asm.asm
290+
awk '{print $4, $5, $6, $7}' orig.asm > orig_pure.asm
291+
awk '{print $4, $5, $6, $7}' asm.asm > asm_pure.asm
292+
293+
if diff orig_pure.asm asm_pure.asm > /dev/null; then
294+
echo "===> [성공] 두 바이너리의 main.main 기계어 로직이 100% 일치합니다!"
295+
else
296+
echo "===> [실패] 차이점이 발견되었습니다."
297+
fi
298+
```
299+
300+
### 결론
301+
302+
프로그래밍 언어는 많은 추상화를 제공하지만, 그 이면에는 공격적인 최적화들이 숨어 있습니다. 이러한 저수준 동작을 이해하면 더 효율적인 코드를 작성하는 데 도움이 될 뿐만 아니라, 바이너리 분석과 같은 보안 영역에서도 큰 자산이 됩니다.
303+
304+
다음 시간에는 If문만큼이나 재미있는 `select-case` 문과 Go 런타임의 심화 섹션들을 분석해 보도록 하겠습니다.

0 commit comments

Comments
 (0)