Análisis de la generación de código ensamblador AMD64 para la sentencia condicional (if) de Go y de sus instrucciones de salto

Sentencias If en el lenguaje Go
En primer lugar, la razón por la que elegimos Go es que, entre los lenguajes modernos, Go es el que tiene el ensamblador más "bello", y en comparación con los lenguajes clásicos, la eficiencia de su sintaxis resulta en ocasiones abrumadora.
Ahora que en la clase anterior comprendimos el funcionamiento de un programa sencillo en Go, comparemos Go y Assembly línea por línea.
Código fuente
Ante todo, al igual que ocurre con Go, incluso los compiladores modernos, incluido GCC, optimizan automáticamente las bifurcaciones que no tienen ningún sentido de ser ejecutadas. Los compiladores de C como GCC y Clang aplican optimizaciones muy agresivas con -O2, el estándar de la industria, de modo que la era en que resultaba difícil que el programador confiara plenamente en el compilador ya quedó, finalmente, superada desde finales del siglo XX.
Por lo tanto, desde la perspectiva del compilador, las condiciones que este no pueda prever y reemplazar fácilmente por otras sentencias son las únicas que tienen sentido.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Si esto se sustituye por algo predecible y que permite eliminar la bifurcación, como x = 10,
10 // el compilador lo optimiza y elimina la bifurcación.
11 // Por ello, si se quiere observar directamente el ensamblador de esto, en C se usan opciones como -O0
12 // o bien se hace que el compilador utilice un valor externo impredecible,
13 // pero como esta sección trata la programación moderna, no se utilizará
14 // el método de desactivar las optimizaciones binarias de Go.
15 if len(os.Args) < 2 {
16 return
17 }
18 x, _ := strconv.Atoi(os.Args[1])
19
20 if x < 10 {
21 println("X is smaller than 10")
22 } else {
23 println("X is larger or same as 10")
24 }
25}
En este caso, como el compilador no puede predecir la entrada, la sentencia de bifurcación se traduce tal cual a código máquina.
Ensamblador
1TEXT main.main(SB) /home/yjlee/introduction-to-golang/learn-golang/if-and-switch/golang-if/main.go
2 main.go:8 0x47a840 493b6610 CMPQ SP, 0x10(R14)
3 main.go:8 0x47a844 7670 JBE 0x47a8b6
4 main.go:8 0x47a846 55 PUSHQ BP
5 main.go:8 0x47a847 4889e5 MOVQ SP, BP
6 main.go:8 0x47a84a 4883ec10 SUBQ $0x10, SP
7 main.go:15 0x47a84e 48833d12fb0a0002 CMPQ os.Args+8(SB), $0x2
8 main.go:15 0x47a856 7c58 JL 0x47a8b0
9 main.go:15 0x47a858 488b0d01fb0a00 MOVQ os.Args(SB), CX
10 main.go:18 0x47a85f 488b4110 MOVQ 0x10(CX), AX
11 main.go:18 0x47a863 488b5918 MOVQ 0x18(CX), BX
12 main.go:18 0x47a867 e834e8ffff CALL strconv.Atoi(SB)
13 main.go:20 0x47a86c 4883f80a CMPQ AX, $0xa
14 main.go:20 0x47a870 7d1d JGE 0x47a88f
15 main.go:21 0x47a872 e809befbff CALL runtime.printlock(SB)
16 main.go:21 0x47a877 488d0519f50100 LEAQ 0x1f519(IP), AX
17 main.go:21 0x47a87e bb15000000 MOVL $0x15, BX
18 main.go:21 0x47a883 e878c6fbff CALL runtime.printstring(SB)
19 main.go:21 0x47a888 e853befbff CALL runtime.printunlock(SB)
20 main.go:21 0x47a88d eb1b JMP 0x47a8aa
21 main.go:23 0x47a88f e8ecbdfbff CALL runtime.printlock(SB)
22 main.go:23 0x47a894 488d05c8040200 LEAQ 0x204c8(IP), AX
23 main.go:23 0x47a89b bb1a000000 MOVL $0x1a, BX
24 main.go:23 0x47a8a0 e85bc6fbff CALL runtime.printstring(SB)
25 main.go:23 0x47a8a5 e836befbff CALL runtime.printunlock(SB)
26 main.go:25 0x47a8aa 4883c410 ADDQ $0x10, SP
27 main.go:25 0x47a8ae 5d POPQ BP
28 main.go:25 0x47a8af c3 RET
29 main.go:16 0x47a8b0 4883c410 ADDQ $0x10, SP
30 main.go:16 0x47a8b4 5d POPQ BP
31 main.go:16 0x47a8b5 c3 RET
32 main.go:8 0x47a8b6 e845f0feff CALL runtime.morestack_noctxt.abi0(SB)
33 main.go:8 0x47a8bb eb83 JMP main.main(SB)
Si se utiliza la herramienta go tool, esta indica amablemente qué sentencia corresponde a qué instrucción ensamblador, una por una.
Lo que vamos a aprender en esta ocasión son las sentencias de comparación y las bifurcaciones if, por lo que basta con fijarse en algunas líneas.
Las instrucciones CMPQ y JL
La instrucción CMPQ es la que se utiliza para comparar tipos de datos de 4 bytes (4 palabras), y su origen es CoMPareQuadword, abreviado como CMPQ.
Si observamos la dirección de memoria 0x47a84e, encontramos la sentencia CMPQ os.Args+8(SB), $0x2.
En este caso, el programa compara el número de argumentos recibidos con el valor hexadecimal 0x2 (es decir, simplemente 2).
Después, si los argumentos son menores que 2 (es decir, si solo está el propio programa como argumento), se realiza un salto condicional mediante JL. Es decir, Jump, Less than, abreviado como JL.
Si tras la comparación anterior los argumentos eran menores que 2, se salta a la dirección 0x47a8b0, donde aparece JGE.
Sin embargo, esta sentencia utiliza el registro AX, por lo que es necesario conocer qué valor está almacenado en él.
La instrucción MOVQ
A continuación, es necesario comprender cómo se utiliza realmente el registro CX para almacenar la dirección inicial de los datos, y cómo se extraen los datos reales una vez leída esa dirección.
En el rango 0x47858-0x47863 se realiza esta operación paso a paso.
En primer lugar, la dirección inicial del arreglo de argumentos se carga en el registro CX mediante la instrucción MOVQ os.Args(SB), CX. En este punto, hay que entender el tipo string de Go.
El string de Go es una estructura, y está compuesta por 2 datos de 8 bytes, es decir, 16 bytes en total.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Visualmente, se representa como se muestra arriba: los primeros 8 bytes almacenan la dirección de inicio de la cadena, y los 8 bytes siguientes almacenan la longitud de la cadena.
Por lo tanto, la dirección de la cadena se guarda en el registro AX, y su longitud en el registro BX.
CALL
En la publicación anterior, al observar las funciones del grupo runtime, también aparecía la instrucción CALL.
Esta se antepone a las funciones que se usan en Go, y significa llamar a una función; de ahí que se llame, literalmente, CALL. Después, mediante la función CALL, se convierte la cadena en un entero, y en este proceso no se ve de forma visible dónde se almacena el entero, porque está abstraído dentro de la función. No es visible en la función.
Las instrucciones CMPQ y JGE
Volviendo a la dirección 0x47a86c, la instrucción compara la dirección de la cadena con el número 0xa (en decimal, 10)!
Esto significa que, como el programa ya no utiliza ese argumento, se sobrescribió la posición de la cadena para crear el espacio de la variable entera x.
Esta es la realidad de la optimización agresiva que se realiza en lenguajes como Go.
Después aparece la instrucción JGE, que es la abreviatura de Jump, Greater or Equals. Por lo tanto, esta sentencia verifica si el valor es mayor o igual que el valor de comparación.
Así pues, no es exactamente la sentencia x < 10 tal cual, sino que la dirección de comparación se invierte respecto a la sentencia x < 10!
Esto se debe a que, en código máquina, saltar de antemano cuando la condición no se cumple es más directo y ahorra una instrucción, en comparación con ejecutar la comparación una vez cuando la condición se cumple y luego volver a comprobar si no se cumple.
Esta optimización es muy clásica, por lo que conviene conocerla, ya que aparece con frecuencia incluso en compiladores con un nivel de optimización bastante bajo, a diferencia del ejemplo strconv.Atoi que vimos antes.
Por tanto, aplicando este principio, se puede obtener un código fuente diferente que, a nivel de ensamblador, coincide al 100 %.
Ejemplo de código espejo
Mediante el siguiente script, el script de bash genera dos fuentes que son espejo al 100 % y puede verificar que, excluyendo los metadatos que varían en cada caso y mirando solo main, se obtiene exactamente el mismo ensamblador.
1#!/usr/bin/env bash
2
3# 1. Limpieza completa de archivos y directorios residuales previos
4echo "[1/6] Cleaning up old artifacts..."
5rm -rf test_dir main_orig main_asm orig.asm asm.asm orig_pure.asm asm_pure.asm
6mkdir -p test_dir
7
8# 2. Escritura del código fuente de la versión original (main.go)
9echo "[2/6] Generating main.go..."
10cat << 'EOF' > main.go
11package main
12
13import (
14 "os"
15 "strconv"
16)
17
18func main() {
19 if len(os.Args) < 2 {
20 return
21 }
22 x, _ := strconv.Atoi(os.Args[1])
23
24 s1 := "X is smaller than 10"
25 s2 := "X is larger or same as 10"
26
27 if x < 10 {
28 println(s1)
29 } else {
30 println(s2)
31 }
32}
33EOF
34
35# 3. Escritura del código fuente de la versión espejo (main_from_asm.go)
36# Se sincroniza simétricamente la estructura de operadores para que el compilador use la plantilla de optimización (JGE)
37echo "[3/6] Generating main_from_asm.go..."
38cat << 'EOF' > main_from_asm.go
39package main
40
41import (
42 "os"
43 "strconv"
44)
45
46func main() {
47 if len(os.Args) < 2 {
48 return
49 }
50 x, _ := strconv.Atoi(os.Args[1])
51
52 s1 := "X is smaller than 10"
53 s2 := "X is larger or same as 10"
54
55 // Si se toma 10 como referencia y se mantiene la estructura x < 10, el compilador
56 // adopta exactamente el mismo mecanismo JGE y la misma disposición de bloques que main.go.
57 if x < 10 {
58 println(s1)
59 } else {
60 println(s2)
61 }
62}
63EOF
64
65# 4. Compilación de ambas fuentes en el mismo directorio y con el mismo nombre de archivo
66echo "[4/6] Compiling both sources inside 'test_dir'..."
67cp main.go test_dir/main.go
68cd test_dir && go build -o ../main_orig main.go && cd ..
69
70rm test_dir/main.go
71cp main_from_asm.go test_dir/main.go
72cd test_dir && go build -o ../main_asm main.go && cd ..
73
74# 5. Extracción de las secciones de ensamblador de main.main con go tool objdump
75echo "[5/6] Extracting main.main assembly sections..."
76go tool objdump -s "main\.main" main_orig > orig.asm
77go tool objdump -s "main\.main" main_asm > asm.asm
78
79# Se eliminan las direcciones virtuales, los desplazamientos y el texto de los bytes de máquina,
80# filtrando únicamente el conjunto de instrucciones que ejecuta la CPU (Opcode y operandos)
81awk '{print $4, $5, $6, $7}' orig.asm > orig_pure.asm
82awk '{print $4, $5, $6, $7}' asm.asm > asm_pure.asm
83
84# 6. Verificación de la integridad estructural de las instrucciones mediante diff
85echo "[6/6] Verifying assembly structural integrity via diff..."
86echo "------------------------------------------------------------"
87
88if diff orig_pure.asm asm_pure.asm > /dev/null; then
89 echo "===> [성공] 두 바이너리의 main.main 기계어 로직이 100% 일치합니다! <==="
90 echo "컴파일러의 최적화 파이프라인 가이드라인을 완벽하게 동기화하여 동일한 어셈블리를 얻었습니다."
91else
92 echo "===> [실패] 어셈블리 명령어 구조에 차이점이 발견되었습니다. <==="
93 diff -u orig_pure.asm asm_pure.asm
94fi
95echo "------------------------------------------------------------"
Al ejecutar el código realmente, se obtiene la siguiente información.
1[1/6] Cleaning up old artifacts...
2[2/6] Generating main.go...
3[3/6] Generating main_from_asm.go...
4[4/6] Compiling both sources inside 'test_dir'...
5[5/6] Extracting main.main assembly sections...
6[6/6] Verifying assembly structural integrity via diff...
7------------------------------------------------------------
8===> [성공] 두 바이너리의 main.main 기계어 로직이 100% 일치합니다! <===
9컴파일러의 최적화 파이프라인 가이드라인을 완벽하게 동기화하여 동일한 어셈블리를 얻었습니다.
10------------------------------------------------------------
Conclusión
Los lenguajes de programación ofrecen muchas abstracciones, pero detrás de ellas se esconden optimizaciones muy interesantes y agresivas. Además, aprovechando esto en sentido inverso, pudimos crear código espejo con fuentes diferentes pero ensamblador idéntico. Si se tiene interés por el nivel bajo y se encuentra software propietario escrito en Go, parece que no es imposible desensamblarlo y analizar el ensamblador para recuperar el código fuente.
Próxima clase
En la próxima clase veremos la sentencia select-case, que es otra sentencia interesante junto con if.