If изрази в езика Go
If-изрази в езика 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-байтови (quadword) типове данни, като етимологията ѝ произлиза от CoMPare Quadword.
На адрес 0x47a84e виждаме инструкцията CMPQ os.Args+8(SB), $0x2.
В този случай програмата сравнява броя на подадените аргументи с шестнадесетичната стойност 0x2 (т.е. просто числото 2).
След това, чрез JL се извършва скок, ако аргументите са по-малко от 2 (т.е. ако програмата е изпълнена без допълнителни параметри). Името произлиза от Jump, Less than. Ако при сравнението се окаже, че аргументите са по-малко от 2, програмата прескача към адрес 0x47a8b0, където се намира инструкцията JGE. Въпреки това, тъй като в тази конструкция се използва регистърът AX, трябва да разберем природата на стойността, съхранена в него.
Инструкция MOVQ
След това трябва да разберем как, използвайки регистъра CX, се съхранява началният адрес на данните и как се извличат реалните данни след четенето на този адрес.
Разглеждайки диапазона 0x47858-0x47863, виждаме стъпково изпълнение на тази операция.
Първо, началният адрес на масива от аргументи се зарежда в регистъра CX чрез командата MOVQ os.Args(SB), CX. В този момент е необходимо да се разбере как е дефиниран типът string в Go.
Типът string в Go е структура, съставена от два 8-байтови елемента, което общо прави 16 байта.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Визуално това изглежда по горния начин, където първите 8 байта съдържат началния адрес на низа, а последните 8 байта – неговата дължина.
Следователно, адресът на низа се записва в регистъра AX, а дължината му – в регистъра BX.
CALL
В предходни постове, разглеждайки функции от типа runtime, също срещнахме командата CALL.
Тя присъства пред функциите, използвани в Go, и буквално означава извикване на дадена функция. След извикването на функцията, тя преобразува низа в цяло число, като в самия код не се вижда абстрактно къде се съхранява това число.
Инструкциите CMPQ и JGE
Връщайки се обратно към адрес 0x47a86c, инструкцията сравнява адреса на низа с числото 0xa (10 в десетична бройна система)!
Това означава, че тъй като програмата не използва повече този аргумент, тя презаписва мястото на низа, за да създаде променлива от целочислен тип x.
Това е същността на агресивната оптимизация, която се извършва в езици като Go.
След това се появява инструкцията JGE, която е съкращение от Jump, Greater or Equals. Следователно този израз проверява дали сравняваният обект е по-голям или равен.
Така че, вместо конструкцията x < 10, тя е обърната в x < 10 (по посока на логическата проверка).
Това е така, защото в машинния код е по-интуитивно и спестява една инструкция, ако се прескача директно при несъответствие на условието, вместо да се извършва сравнение и след това отново да се проверява за съответствие.
Тази оптимизация е класическа и, за разлика от примера със strconv.Atoi, се среща често дори при компилатори с ниско ниво на оптимизация, поради което е полезно да бъде разпознавана.
Прилагайки тези знания, можем да създадем код, който, макар и различен като изходен текст, генерира 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)
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 изразите.