Оператор If в языке Go
Оператор If в языке Go
Во-первых, мы выбрали Go, поскольку среди современных языков программирования его ассемблерный код является наиболее «эстетичным», а эффективность его синтаксиса зачастую превосходит даже классические языки.
Теперь, когда в предыдущей лекции мы разобрались с основами работы простых программ на Go, давайте приступим к построчному сравнению Go и Assembly.
Исходный код
Прежде всего, как и в Go, современные компиляторы, включая GCC, автоматически оптимизируют бесполезные условные операторы. Компиляторы языка C, такие как GCC и Clang, выполняют крайне агрессивную оптимизацию при использовании промышленного стандарта -O2, поэтому эпоха, когда программист мог полностью доверять компилятору, окончательно сформировалась еще в конце XX века.
Следовательно, смысл имеет лишь предоставление условий, которые компилятору трудно предсказать и заменить другими конструкциями.
1package main
2
3import (
4 "os"
5 "strconv"
6)
7
8func main() {
9 // Если заменить это на нечто предсказуемое, например x = 10,
10 // что позволяет исключить ветвление, компилятор оптимизирует код и удалит ветку.
11 // Поэтому, чтобы увидеть это непосредственно в ассемблере, в языке C
12 // используют флаги вроде -O0 или заставляют компилятор использовать внешние
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-слововых) типов данных; её название происходит от сокращения 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. В этот момент необходимо понимать структуру строкового типа в Go.
Тип string в Go является структурой, состоящей из двух 8-байтовых элементов, что в сумме дает 16 байт.
| struct | 8 byte | 8 byte |
|---|---|---|
| string | mem address | string length |
Визуально это выглядит так: первые 8 байт содержат начальный адрес строки, а вторые 8 байт — её длину.
Следовательно, адрес строки сохраняется в регистре AX, а длина строки — в регистре BX.
CALL
Как мы видели в предыдущих публикациях при работе с функциями runtime, используется инструкция CALL.
Она ставится перед функциями, используемыми в Go, и буквально означает вызов функции. Впоследствии для преобразования строки в целое число используется функция CALL, однако в самой функции не отображается, где именно сохраняется полученное целое число, так как это абстрагировано.
Инструкции CMPQ и JGE
Возвращаясь к адресу 0x47a86c, инструкция сравнивает адрес строки с числом 0xa (10 в десятичной системе)!
Это означает, что поскольку аргумент больше не используется в программе, компилятор перезаписал область памяти строки, создав переменную целого типа x.
Такова суть агрессивной оптимизации, применяемой в языке Go.
Затем появляется инструкция JGE, что является сокращением от Jump, Greater or Equals. Таким образом, эта конструкция проверяет, является ли значение больше или равным объекту сравнения.
Следовательно, это не просто выражение x < 10, а измененное направление сравнения для x < 10!
Это происходит потому, что в машинном коде предварительный пропуск (jump) в случае несовпадения условия является более интуитивным и экономит одну инструкцию по сравнению с выполнением сравнения и последующей проверкой на несовпадение.
Поскольку такая оптимизация является классической, этот шаблон часто встречается даже в компиляторах с довольно низким уровнем оптимизации, в отличие от примера с strconv.Atoi, поэтому его полезно знать.
Таким образом, применяя этот принцип, можно получить исходный код, который отличается по тексту, но на уровне ассемблера будет идентичен на 100%.
Пример зеркального кода
Используя приведенный ниже скрипт, можно убедиться, что Bash-скрипт создает два зеркально идентичных исходных файла, которые при анализе только функции 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. Проверка различий структур машинных команд
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.