Что нужно знать, если вы хотите вызывать Go функции из ассемблера

в 12:04, , рубрики: amd64, asm, Go, golang, x86_64, Компиляторы, ненормальное программирование, системное программирование

You've run into a really hairy area of asm code.
My first suggestion is not try to call from assembler into Go. — Ian Lance Taylor

До тех пор, пока ваш ассемблерный код делает что-то простое, всё выглядит неплохо.

Как только у вас возникает задача вызвать из ассемблерного кода Go функцию, один из первых советов, который вам дадут: не делайте так.

Но что если вам это очень-очень нужно? В таком случае, прошу под кат.

Что нужно знать, если вы хотите вызывать Go функции из ассемблера - 1

Calling convention

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

Рекомендую ознакомиться с Go functions in assembly language, где наглядно описана большая часть необходимой нам информации.

Обычно, calling convention варьируется от платформы к платформе, поскольку может различаться набор доступных регистров. Мы будем рассматривать только GOARCH=amd64, но в случае Go конвенции отличаются не так значительно.

Вот некоторые особенности конвенции вызова функций в Go:

  • Все аргументы передаются через стек, кроме "контекста" в замыканиях, он доступен через регистр DX (%rdx).
  • Результаты возвращаются тоже через стек (в картинке ниже они включены в arguments).
  • Аргументы вызываемой функции размещаются на фрейме вызывающей стороны.
  • Выделение и уничтожение фрейма выполняется вызываемой функцией. Эти действия являются частью прологов и эпилогов, которые вставляются ассемблером автоматически.

При вызове функции может происходить ситуация, когда у стека горутины недостаточно места. В этой ситуации будет произведено расширение стека.

Что нужно знать, если вы хотите вызывать Go функции из ассемблера - 2

Эта картина мира может поменяться, если произойдёт переход на register-based calling convention. Мы ещё вернёмся к теме эволюции конвенций вызова функций в Go.

Итак, чтобы вызвать функцию:

  • Вам нужно, чтобы на фрейме текущей функции было место для аргументов вызываемой функции.
  • Первый аргумент идёт в 0(SP), второй аргумент идёт в 8(SP) (если размер первого аргумента равен 8 байтам), и так далее.
  • Результат функции доставать из n(SP), где n — это размер всех входных аргументов функции. Для функции с двумя аргументами int64, результат начинается с 16(SP).

Размер фрейма вы указываете при определении функции.

package main

func asmfunc(x int32) (int32, int32)

func gofunc(a1 int64, a2, a3 int32) (int32, int32) {
    return int32(a1) + a2, int32(a1) + a3
}

func main() {
    v1, v2 := asmfunc(10)
    println(v1, v2) // => 3, 11
}

// func asmfunc(x int32) (int32, int32)
TEXT ·asmfunc(SB), 0, $24-12
  MOVL x+0(FP), AX
  MOVQ $1, 0(SP)  // Первый аргумент (a1 int64)
  MOVL $2, 8(SP)  // Второй аргумент (a2 int32)
  MOVL AX, 12(SP) // Третий аргумент (a3 int32)
  CALL ·gofunc(SB)
  MOVL 16(SP), AX // Забираем первый результат
  MOVL 20(SP), CX // Забираем второй результат
  MOVL AX, ret+8(FP)  // Возвращаем первый результат
  MOVL CX, ret+12(FP) // Возвращаем второй результат
  RET

$24-16 (locals=24 bytes, args=16 bytes)

          0     8     12    16     20     SP
locals=24 [a1:8][a2:4][a3:4][ret:4][ret:4]

        0    4          8      12     FP
args=16 [x:4][padding:4][ret:4][ret:4]

Обратите внимание, между входными аргументами и результатами есть 4 байта для выравнивания. Это нужно для того, чтобы результаты функции начинались с адреса, который выравнен по ширине указателя (8 байт на amd64).

Некоторые ошибки, связанные с размером фрейма и использованием регистра FP может найти go vet.

Указатели и stackmap

Попробуем теперь вызвать функцию с аргументом-указателем.

package foo

import (
    "fmt"
    "testing"
)

func foo(ptr *object)

type object struct {
    x, y, z int64
}

func printPtr(ptr *object) {
    fmt.Println(*ptr)
}

func TestFoo(t *testing.T) {
    foo(&object{x: 11, y: 22, z: 33})
}

TEXT ·foo(SB), 0, $8-8
        MOVQ ptr+0(FP), AX
        MOVQ AX, 0(SP)
        CALL ·printPtr(SB)
        RET

Если мы запустим тест, то получим панику из-за stackmap:

=== RUN   TestFoo
runtime: frame <censored> untyped locals 0xc00008ff38+0x8
fatal error: missing stackmap

Для того, чтобы успешно найти указатели на стеке, GC нуждается в так называемых stackmaps. Для обычных Go функций их генерирует компилятор, но для ассемблерных функций этой информации у рантайма нет.

Для аргументов функции информацию можно передать через определение stub функции с корректными типами (функция без тела в Go файле). В документации указаны случаи, когда stackmap не обязателен, но наш случай не один из них.

Здесь либо делать так, чтобы функция не требовала stackmap (сложно), либо использовать NO_LOCAL_POINTERS и не подорваться на нюансах (ещё сложнее).

NO_LOCAL_POINTERS

С таким ассемблерным кодом TestFoo будет проходить:

#include "funcdata.h"

TEXT ·foo(SB), 0, $8-8
        NO_LOCAL_POINTERS
        MOVQ ptr+0(FP), AX
        MOVQ AX, 0(SP)
        CALL ·printPtr(SB)
        RET

Однако нужно понимать, чем достигнут этот прогресс.

Попробуем поразмышлять, зачем вообще сборщику мусора нужно знать про указатели на нашем стеке? Допустим, эти указатели пришли к нам извне, они "достижимы" из кода, который вызывал ассемблерную функцию, поэтому нам не страшно, если локальные указатели на нашем фрейме не будут считаться живыми, так?

Если мы вспомним, что указатели могут указывать не только на объекты в heap, то поймём, что это не всегда так. Если при вызове функции произойдёт увеличение стека, адреса стека изменятся, что инвалидирует все указатели объекты внутри него. В обычном режиме GC "чинит" все эти указатели и мы ничего не замечаем, но если у него нет информации об указателях на стеке, он этого сделать не сможет.

Здесь нам помогает то, что все указатели, передаваемые в ассемблерную функцию "утекают" (escapes to heap) в терминах escape analysis, так что для того, чтобы иметь внутри ассемблерного кода на стеке указатель на стековую память нужно постараться.

Сформулируем правило безопасного использования NO_LOCAL_POINTERS: данные, адресуемые указателями, лежащими внутри локальных слотов функции, должны удерживаться видимыми GC указателями. Запрещены указатели на стек.

В связи с появлением в Go non-cooperative preemption, важным дополнением будет то, что ассемблерные функции не прерываются.

Второе кейс безопасного использования можно найти внутри рантайма Go. Функции, отмеченные go:nosplit, не могут расширить стек, так что часть проблем, связанная с NO_LOCAL_POINTERS уходит сама по себе.

GO_ARGS

Для ассемблерных функций, которые имеют Go prototype, автоматически вставляется GO_ARGS.

GO_ARGS — это макрос из того же funcdata.h, что и NO_LOCAL_POINTERS. Он указывает, что для получения информации о stackmap аргументов нужно использовать Go декларацию.

Раньше это не работало в ситуации, когда stackmap для ассемблерной функции определялся в другом пакете. Сейчас проставлять args_stackmap вручную для экспортируемых символов не обязательно. Но как пример этот патч всё равно интересен: он показывает, как можно ручками добавить метаданных в stackmap.

GO_RESULTS_INITIALIZED

Если ассемблерная функция возвращает указатель и вызывает Go функции, то требуется начать тело этой функции с зануления стековых слотов под результат (так как там может находиться мусор) и вызвать макрос GO_RESULTS_INITIALIZED сразу после этого.

Например:

// func getg() interface{}
TEXT ·getg(SB), NOSPLIT, $32-16
  // Интерфейс состоит из двух указателей.
  // Оба из них нужно заполнить нулями.
  MOVQ $0, ret_type+0(FP)
  MOVQ $0, ret_data+8(FP)
  GO_RESULTS_INITIALIZED
  // Дальше код самой функции...
  RET

В целом, лучше избегать ассемблерных функций, которые возвращают типы-указатели.

Больше примеров использования можно найти на GitHub.

Go Internal ABI

Go Internal ABI — горячая тема в очень узких кругах.

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

Два ключевых ограничения:

  1. Существующий ассемблерный код будет продолжать работать.
  2. Эта поддержка обратной совместимости не будет прекращена в будущем.

Предыдущий calling convention теперь относится к ABI0, а экспериментальный новый к ABIInternal.

Если мы запустим компиляцию Go с флагом -S, то увидим, что ABIInternal уже существует, просто он не отличается на данный момент от ABI0:

Что нужно знать, если вы хотите вызывать Go функции из ассемблера - 3

Когда ABIInternal будет достаточно хорош, его переименуют в ABI1, сделав стабильным. ABIInternal же продолжит свой путь к идеальному calling convention и другим низкоуровневым радостям.

Хорошей новостью для нас является то, что в обозримом будущем существующий ассемблерный код продолжит работать корректно.

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

Полезные материалы

Что нужно знать, если вы хотите вызывать Go функции из ассемблера - 4

Hub-опрос

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

Автор: Искандер

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js