GoSuda

Анализ на генерирането на AMD64 асемблерен код и на инструкциите за условно разклонение при условни оператори (if) в Go

By Lee Yunjin views ...

If оператор в езика Go

Първо, причината да изберем Go е, че сред модерните езици асемблерният код на Go е може би най-„красив“, а в сравнение с класическите езици ефективността на неговия синтаксис понякога е дори преобладаваща.

Сега, след като в предишния урок разбрахме как работи една проста програма на Go, нека директно сравним Go и Assembly ред по ред.

Изходен код

Първо, както при Go, така и при съвременните компилатори, включително GCC, автоматично се оптимизират разклоненията, които нямат смисъл. Компилаторите на C като GCC и Clang прилагат много агресивни оптимизации при индустриалния стандарт -O2, така че епохата, в която програмистът трудно можеше напълно да се довери на компилатора, всъщност приключи още в края на 20-ти век.

Затова, от гледна точка на компилатора, условията трябва да са такива, че да е трудно да бъдат предвидени и заменени с друг израз, за да имат поне някакъв смисъл.

 1package main
 2
 3import (
 4    "os"
 5    "strconv"
 6)
 7
 8func main() {
 9    // Ако тук използваме нещо предсказуемо, например x = 10, и разклонението може да бъде премахнато,
10    // компилаторът ще го оптимизира и ще премахне разклонението.
11    // Затова, ако искаме да видим такова нещо директно в асемблер, в C се използва например -O0,
12    // или се подава външна стойност, която компилаторът не може да предвиди.
13    // Тъй като този раздел разглежда модерното програмиране, няма да използваме
14    // метод за изключване на оптимизациите на бинарния файл на 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}

В този случай, тъй като компилаторът не може да предвиди входа, разклонението се превежда в машинен код без промяна.

Асемблерен код

 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)

Инструментът go tool показва на всеки ред от кода кой асемблерен израз му съответства.

Тъй като в този урок разглеждаме сравнителните и условните оператори if, е достатъчно да обърнем внимание на няколко реда.

Инструкциите CMPQ и JL

Инструкцията CMPQ служи за сравнение на операнди с размер 4 байта (4 думи). Името произлиза от CoMPareQuadword, съкратено до CMPQ.

На адрес 0x47a84e се вижда израз CMPQ os.Args+8(SB), $0x2. В този случай програмата сравнява броя на подадените аргументи с шестнадесетичното число 0x2 (тоест просто 2).

След това, ако аргументите са по-малко от 2 (тоест програмата е единственият аргумент), се извършва условен скок чрез JL. Това е съкращение от Jump, Less than (скок, ако е по-малко). Ако при предишното сравнение аргументите са по-малко от 2, се скача към адрес 0x47a8b0, където се намира JGE. Тук обаче се използва регистърът AX, затова трябва да знаем какво е съхранено в него.

Инструкцията MOVQ

След това трябва да разберем как регистърът CX се използва за съхранение на началния адрес на данните и как след четенето на адреса се извличат самите данни.

Диапазонът 0x47858-0x47863 показва тези операции стъпка по стъпка.

Първо, началният адрес на масива с аргументи се поставя в регистъра CX с инструкцията MOVQ os.Args(SB), CX. Тук трябва да разберем типа string в Go.

string в Go е структура, която се състои от 2 елемента по 8 байта, общо 16 байта.

struct8 byte8 byte
stringmem addressstring length

Визуално е представено така: първите 8 байта съдържат началния адрес на низа, а следващите 8 байта – дължината на низа.

Затова адресът на низа се записва в регистъра AX, а дължината му – в регистъра BX.

CALL

И в предишната публикация, когато разглеждахме функции от runtime, се появяваше инструкцията CALL. Тя се използва при функциите на Go и означава, че се извиква определена функция, буквално CALL. След това с помощта на функцията CALL низът се преобразува в цяло число, като при това не се вижда абстрактно в функцията къде се съхранява числото. Това е скрито в абстракцията на функцията.

Инструкциите CMPQ и JGE

Връщайки се към адрес 0x47a86c, инструкцията сравнява низа с числото 0xa (десетично 10)!

Това означава, че в програмата вече не се използва този аргумент, затова стойността е презаписана на мястото на низа, за да се създаде мястото на целочисленото число x.

Това е същността на агресивната оптимизация, извършвана от езици като Go.

След това се появява инструкцията JGE, която е съкращение от Jump, Greater or Equals (скок, ако е по-голямо или равно). Тази конструкция проверява дали стойността е по-голяма или равна на сравняваната.

Така, вместо точно x < 10, изразът е обърнат по посока на сравнението! В машинния код е по-интуитивно и спестява една инструкция да се пропусне блокът предварително, когато условието не е изпълнено, вместо първо да се изпълни сравнението при изпълнено условие и след това отново да се провери дали не е изпълнено.

Тази оптимизация е много класическа и затова е полезно да се знае, тъй като често се среща дори в компилатори с доста ниско ниво на оптимизация, за разлика от примера с strconv.Atoi по-горе.

Следователно, ако използваме това наблюдение, можем да получим изходни кодове, които се различават, но на ниво асемблер съвпадат на 100%.

Пример с огледални кодове

Със скрипта по-долу bash скриптът създава два изходни кода, които са 100% огледални, и след това проверява, че без метаданните, които се променят всеки път, само main дава точно същия асемблерен код.

 1#!/usr/bin/env bash
 2
 3# 1. Пълно почистване на старите остатъчни файлове и директории
 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. Създаване на изходния код на оригиналната версия (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. Създаване на огледалната версия на изходния код (main_from_asm.go)
36# Структурата на операторите е напълно симетрично синхронизирана, така че компилаторът да използва същия шаблон за оптимизация (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        // Ако запазим структурата x < 10 с 10 като референтна стойност, компилаторът
56        // избира точно същия JGE механизъм и същото разположение на блоковете като в main.go.
57        if x < 10 {
58                println(s1)
59        } else {
60                println(s2)
61        }
62}
63EOF
64
65# 4. Компилиране на двата източника в една и съща директория и с едно и също име на файла
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. Извличане на асемблерните секции на main.main с помощта на 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# Премахване на виртуалните адреси, отместванията и текстовите данни за машинните байтове
80# и филтриране само на чистия набор от инструкции (Opcode & Operands), който CPU изпълнява
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. Проверка на структурната цялост на машинните инструкции чрез 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 "------------------------------------------------------------"

При действително изпълнение на скрипта се получава следната информация.

 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------------------------------------------------------------

Заключение

Езиците за програмиране предоставят много абстракции, но зад тях се крият интересни и агресивни оптимизации. Също така, като използвахме това в наша полза, успяхме да създадем огледални кодове, които имат различен изходен код, но идентичен асемблер. Ако се интересувате от ниско ниво и се сблъскате със софтуер с затворен код на Go, вероятно е възможно да се анализира асемблерният код и да се възстанови изходният код, макар и не винаги лесно.

Следващ урок

В следващия урок ще разгледаме оператора select-case, който е също толкова интересен като If оператора.