GoSuda

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

By Lee Yunjin views ...

Оператор if в языке Go

Прежде всего, мы выбрали 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 используют -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, сокращённое до JL. Если по результату предыдущего сравнения аргументов было меньше 2, выполняется переход по адресу 0x47a8b0, где находится JGE. Однако в этой инструкции используется регистр AX, поэтому необходимо понимать, что за значение хранится в регистре.

Команда MOVQ

Далее нужно понять, как с помощью регистра 'CX' сохраняется начальный адрес данных и как затем извлекаются фактические данные после чтения по адресу.

Диапазон 0x47858-0x47863 поэтапно выполняет эту операцию.

Сначала начальный адрес массива аргументов помещается в регистр CX командой MOVQ os.Args(SB), CX. При этом необходимо понимать строковый тип Go.

Строка string в Go является структурой, которая состоит из двух 8-байтных значений, то есть из 16 байт.

struct8 byte8 byte
stringmem addressstring length

Визуально это выглядит так, как показано выше: первые 8 байт содержат адрес начала строки, а следующие 8 байт содержат длину строки.

Поэтому адрес строки сохраняется в регистр AX, а длина строки в регистр BX.

CALL

В предыдущей статье, при рассмотрении функций из пакета runtime, также встречалась команда CALL. Она стоит перед функциями, используемыми в Go, и означает, что функция вызывается; буквально это и есть CALL. Затем с помощью вызова функции строка преобразуется в целое число, однако то, куда именно сохраняется это число, скрыто внутри функции и не видно.

Команды CMPQ и JGE

Вернёмся к адресу 0x47a86c: инструкция сравнивает адрес строки с числом 0xa (десятичное 10)!

Это означает, что, поскольку программа больше не использует данный аргумент, значение было перезаписано поверх адреса строки, и тем самым было создано место для целочисленной переменной x.

Именно в этом и заключается суть агрессивной оптимизации, выполняемой в языках вроде Go.

Далее появляется команда JGE, которая является сокращением от Jump, Greater or Equals. Таким образом, эта инструкция проверяет, больше ли значение или равно ли оно сравниваемому.

Поэтому условие x < 10 преобразовано не в точности в таком виде, а направление сравнения в инструкции изменено на противоположное! Причина в том, что в машинном коде выгоднее заранее перепрыгивать, когда условие не выполняется, чем выполнять сравнение один раз при выполнении условия и затем снова проверять, не выполняется ли оно, так как это более прямолинейно и позволяет сэкономить одну инструкцию.

Такая оптимизация очень классическая, поэтому она часто встречается даже в компиляторах с относительно низким уровнем оптимизации, и полезно её знать.

Таким образом, применяя эту идею, можно получить исходные коды, которые отличаются по тексту, но на уровне ассемблера полностью совпадают на 100%.

Пример зеркального кода

С помощью приведённого ниже скрипта можно создать два исходных файла, полностью зеркальных по 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# оставляя только чистый набор инструкций, который выполняет CPU (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, то, вероятно, разобрать ассемблерный код и восстановить исходный текст тоже не будет невозможной задачей.

Следующая лекция

В следующий раз мы разберём оператор if и ещё один интересный оператор: select-case.