Go: объектный файл и релокация

Kate

Administrator
Команда форума
Эта статья оперирует версией Go 1.14.

Релокация — это этап процесса линковки, в рамках которого каждому внешнему символу присваиваются соответствующие адреса. Поскольку пакеты компилируются отдельно, они не имеют понятия, где функции или переменные из других пакетов находятся фактически. Начнем с тривиального примера, когда нам потребуется релокация.

Компиляция​

Следующая программа задействует два разных пакета: main и fmt.

55dd516665fa773b9fcc7829f4a00c38.png

При построении этой программы первым отработает компилятор, который скомпилирует каждый пакет отдельно:

ce1dd723fbeb51a02652579a72c234b9.png

В этих промежуточных файлах мы можем увидеть временные адреса инструкций (с помощью команды go tool compile -S -l main.go):

После того, как компиляция нашей программы будет завершена, мы можем посмотреть сгенерированный файл с помощью команды go tool compile -S -l main.go, которая отображает ассемблерный код.

У нас есть несколько вариантов, как посмотреть на сгенерированную компилятором инструкцию:

  • Представить результат компиляции в виде ассемблерного кода. Команда: go tool compile -S -l main.go:
"".main STEXT size=137 args=0x0 locals=0x58
0x0000 00000 (main.go:7) TEXT "".main(SB)
[...]
0x0058 00088 (main.go:8) CALL fmt.Println(SB)
Флаг -l используется для предотвращения инлайнинга, чтобы немного упростить нам задачу.

Сгенерированный ассемблерный код показывает, что инструкция для вызова Println расположена со смещением (offset) 88 байт от начала функции main. Это смещение нужно линкеру, чтобы правильно релоцировать вызов функции.

  • Дизассемблируйте сгенерированный main.o с помощью команды go tool objdump main.o:
TEXT %22%22.main(SB)
[...]
main.go:8 0x57e e800000000 CALL 0x583 [1:5]R_CALL:fmt.Println
Идентификатор R_CALL означает релокацию вызова.

Однако, поскольку функция принадлежит другому пакету, компилятор не знает, где на самом деле находится функция. Это можно подтвердить, проверив сгенерированный файл main.o и перечислив символы с помощью команды go tool nm main.o. Вот результат:

7a879d6921eaa72d4db9c8f463bec2e2.png

Вы могли заметить, что нужно использовать команду go tool nm вместо нативной команды nm. Это потому что объектный файл (.o), созданный Go, имеет собственный формат.

Символ U расшифровывается как undefined, что означает, что компилятор не знает, где находится этот символ. Этому символу необходима релокация, т. е. нужно найти адрес для успешного вызова Println, и именно здесь на сцену выходит линкер. Прежде чем переходить к линкеру, давайте проанализируем сгенерированный объектный файл main.o, чтобы понять, с какими данными приходится работать линкеру.

Объектный файл​

Документация по объектному файлу хорошо объясняет его содержимое и формат:

7da2f2d0d5d0faed0b9ba5d9239c4188.png

Объектный файл (объектный модуль, object file) состоит из зависимостей, отладочной информации (DWARF), списка проиндексированных символов, раздела данных и, наконец, списка символов, в котором можно найти релокации. Вот его формат:

ac7050541c1e3f20fc4edc9a05ec866b.png

Каждый символ начинается с байта fe в шестнадцатеричном формате. Итак, давайте откроем наш объектный файл main.o с помощью шестнадцатеричного редактора, например xxd на Mac. Вот часть содержимого с выделенными символами:

4076a0ad8b8436d9dc6465883a53d6d3.png

Символ main.main - это первый символ в списке:

5a22b7c2641bbe7c7fe8d5ab166864ce.png

Первые байты 0102 00dc 0100 dc01 0a представляют первые атрибуты, охарактеризованные в определении: тип (type), флаг (flag), размер (size), данные (data), и количество релокаций.

Байты хранятся в формате zigzag (формат переменной длины varint). zigzag кодирует беззнаковые целые числа, используя младший бит для знака, делая их меньше по размеру.

Таким образом, релокация Println представляет собой последовательность байтов b201 0810 0008:

  • b201 - это закодированное значение смещения (offset) - 89. Это смещение является int32, а благодаря формату varint оно может уместиться в двух байтах.
  • 08 - количество байтов для перезаписи. Декодированное значение 4.
  • 10 - это тип релокации, закодированное значение 8 представляет R_CALL, релокацию вызова функции.
  • 08 - это ссылка на индексированные символы.
Загрузчик теперь имеет всю информацию, необходимую для выполнения релокаций и создания исполняемого бинарника.

Релокация​

Это этап, на котором линкер назначает виртуальные адреса всем разделам и инструкциям. Адреса каждого раздела можно увидеть с помощью команды objdump -h my-binary. Вот вывод для предыдущего примера:

4434117a353d62240e0452d22de01187.png

Функция main находится в разделе __text. Его также можно найти с помощью команды objdump -d my-binary, которая отображает инструкцию с адресами:

7feb12d786d833236ca1dcc8642d51b0.png

Функции main назначен адрес 109cfa0. Функция fmt.Println получила адрес 1096a00. Как только виртуальные адреса назначены, совершить релокацию вызова fmt.Println становится легко. Линкер просто вычислит адрес fmt.Println из адреса main, смещения и размера инструкции, и мы получим глобальное смещение для вызова инструкции. В предыдущем примере мы получили бы следующую операцию: 1096a00 (fmt.Println) - 109cfa0 (main) - 84 (смещение внутри main) - 4 (размер) = -26109.

Теперь инструкция знает, что функция fmt.Println расположена по смещению -26109 от текущего адреса памяти, и вызов будет успешным.

 
Сверху