Go: как циклы преобразуются в ассемблерную программу?

ℹ️ В статье речь идёт о Go 1.13.

Циклы в программировании — понятие мощное и довольно простое. Тем не менее возникает необходимость преобразовывать их в основные команды, которые может понять компьютер. Способ компиляции может повлиять и на другие компоненты стандартной библиотеки. Начнём с анализа цикла диапазона.

Ассемблерный код цикла

Цикл диапазона выполняет перебор массива, среза или канала. Вот пример функции, которая считает сумму чисел, используя цикличное выполнение на срезе:

func main() {
   l := []int{9, 45, 23, 67, 78}
   t := 0

   for _, v := range l {
      t += v
   }

   println(t)
}

Команда go tool compile -S main.go выводит сгенерированный код на ассемблере, и вот какой дамп цикла диапазона мы имеем:

0x0041 00065 (main.go:4)   XORL   AX, AX
0x0043 00067 (main.go:4)   XORL   CX, CX

0x0045 00069 (main.go:7)   JMP    82
0x0047 00071 (main.go:7)   MOVQ   ""..autotmp_5+16(SP)(AX*8), DX
0x004c 00076 (main.go:7)   INCQ   AX
0x004f 00079 (main.go:8)   ADDQ   DX, CX
0x0052 00082 (main.go:7)   CMPQ   AX, $5
0x0056 00086 (main.go:7)   JLT    710x0058 00088 (main.go:11)  MOVQ   CX, "".t+8(SP)

Я разделил инструкции на две части: инициализация и сам цикл. Первые две инструкции инициализируют два регистра значением 0:

0x0041 00065 (main.go:4)   XORL   AX, AX
0x0043 00067 (main.go:4)   XORL   CX, CX

Регистр AX содержит текущую позицию в цикле, а в CX находится значение переменной t. Вот визуальное представление с инструкциями и регистрами общего назначения:

Цикл начинается с инструкции JMP 82, которая расшифровывается как “Jump to instruction 82” (то есть «перейти к инструкции 82»). Номер инструкции указан во втором столбце:

Следующая инструкция CMPQ AX, $5 расшифровывается как “Compare register AX and the value 5”сравнить регистр AX и значение 5»). Она вычитает значения регистра DX из AX и сохраняет результат в другом регистре. Это значение можно использовать в следующей инструкции JLT 71, расшифровывающейся как “Jump to instruction 71 if less than 0”перейти к инструкции 71, если меньше 0»). Табличка теперь выглядит вот так:

Если условие не выполняется, перехода не происходит и программа продолжается на следующей инструкции после цикла.

Теперь у нас есть скелет цикла. А вот и цикл, преобразованный обратно в Go:

goto end
start:
   ?
end:
   if i < 5 {
      goto start
   }

println(t)

Тело цикла отсутствует. Зато есть инструкции:

0x0047 00071 (main.go:7)   MOVQ   ""..autotmp_5+16(SP)(AX*8), DX
0x004c 00076 (main.go:7)   INCQ   AX
0x004f 00079 (main.go:8)   ADDQ   DX, CX

Первая инструкция MOVQ ""..autotmp_5+16(SP)(AX*8), DX расшифровывается как “Move memory from source to destination” («переместить память от источника к месту назначения»). Она состоит из:

  • среза ""..autotmp_5+16(SP), где SP — это указатель стека (наш текущий фрейм памяти), а autotmp_* — это автоматически генерируемое имя переменной;
  • смещения 8 (8 байтов в 64-разрядной архитектуре), помноженного на значение регистра AX и показывающего текущую позицию в цикле;
  • назначения (здесь это регистр DX, в котором содержится текущее значение цикла).

Вторая инструкция INCQ расшифровывается как “Increment” («инкремент») и добавляет текущую позицию цикла:

Последняя инструкция тела цикла ADDQ DX, CX расшифровывается как “Add DX into CX” («добавить DX в CX»). Напомним, DX содержит текущее значение цикла, а CX — значение переменной t:

Цикл остановится, когда счётчик цикла дойдёт до пяти. Затем инструкция, сразу после того как цикл укажет на регистр CX, передаст его значение в t:

0x0058 00088 (main.go:11)   MOVQ   CX, "".t+8(SP)

Вот как выглядит конечное состояние цикла:

А так выглядит в конечной форме преобразование цикла в Go:

func main() {
   l := []int{9, 45, 23, 67, 78}
   t := 0
   i := 0

   var tmp int

   goto end
start:
   tmp = l[i]
   i++
   t += tmp
end:
   if i < 5 {
      goto start
   }

   println(t)
}

Генерирование ассемблерного кода для этой новой программы даст тот же результат.

Улучшения

То, как происходит преобразование цикла, может повлиять на другие характеристики Go, например на планировщик. В Go до версии 1.10 цикл компилировался так же, как этот код:

func main() {
   l := []int{9, 45, 23, 67, 78}
   t := 0
   i := 0

   var tmp int
   p := uintptr(unsafe.Pointer(&l[0]))

   if i >= 5 {
      goto end
   }
body:
   tmp = *(*int)(unsafe.Pointer(p))
   p += unsafe.Sizeof(l[0])
   i++
   t += tmp
   if i < 5 {
      goto body
   }
end:
   println(t)
}

Проблема с этой реализацией заключается в том, что указатель p проходит конец распределения, когда i доходит до пяти. Из-за этого цикл не так легко прерывается, так как его тело не защищено. Оптимизация компиляции цикла призвана обеспечить то, чтобы не создавалось указателя на элемент, следующий за последним элементом. Это улучшение сделано на случай автономного или пассивного вытеснения в планировщике Go.

Читайте также:


Перевод статьи Vincent Blanchon: Go: How Are Loops Translated to Assembly?