Статьи

Golang Internals (часть 3): компоновщик, объектные файлы и перемещения

[Это сообщение в блоге написано Сергеем Матюкевичем.]

Предыдущие записи: Часть 1 | Часть 2 | Часть 3

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

 

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

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

package main

func main() {
	print(1)
}

Действительно просто, не правда ли? Теперь нам нужно скомпилировать это:

go tool 6g test.go

Эта команда создает объектный файл test.6 . Чтобы исследовать его внутреннюю структуру, мы будем использовать библиотеку goobj . Он используется внутри исходного кода Go, в основном для реализации набора модульных тестов, которые проверяют, правильно ли генерируются объектные файлы в различных ситуациях. Для этого поста в блоге я написал очень простую программу, которая печатает вывод, сгенерированный из библиотеки googj, на консоль. Вы можете посмотреть на источники этой программы здесь .

Прежде всего, вам необходимо скачать и установить мою программу:

go get github.com/s-matyukevich/goobj_explorer

Затем выполните следующую команду:

goobj_explorer -o test.6

Теперь вы должны увидеть структуру goob.Package в своей консоли.

 

Исследование объектного файла

Наиболее интересной частью нашего объектного файла является массив Syms . Это на самом деле таблица символов. Все, что вы определяете в своей программе — функции, глобальные переменные, типы, константы и т. Д. — записывается в эту таблицу. Давайте посмотрим на запись, которая соответствует основной функции. (Обратите внимание, что я вырезал поля Reloc и Func из выходных данных. Мы обсудим их позже.)

&goobj.Sym{
            SymID: goobj.SymID{Name:"main.main", Version:0},
            Kind:  1,
            DupOK: false,
            Size:  48,
            Type:  goobj.SymID{},
            Data:  goobj.Data{Offset:137, Size:44},
            Reloc: ...,
            Func:  ...,
}

Имена полей в структуре goobj.Sum довольно понятны :

поле Описание
SumID Уникальный идентификатор символа, который состоит из имени и версии символа. Версии помогают различать символы с одинаковыми именами.
вид Указывает, к какому виду принадлежит символ (более подробно позже).
DupOK Это поле указывает, разрешены ли дубликаты (символы с одинаковыми именами).
Размер Размер символьных данных.
Тип Ссылка на другой символ, который представляет тип символа, если таковой имеется.
Данные Содержит двоичные данные. Это поле имеет разные значения для символов различного типа, например, ассемблерный код для функций, необработанное строковое содержимое для строковых символов и т. Д.
RELOC Список переездов (более подробная информация будет предоставлена ​​позже)
Func Содержит специальные метаданные функции для функциональных символов (подробнее см. Ниже).

Теперь давайте посмотрим на различные виды символов. Все возможные виды символов определены как константы в пакете goobj (вы можете найти их здесь ). Ниже я скопировал первую часть этих констант:

const (
	_ SymKind = iota

	// readonly, executable
	STEXT
	SELFRXSECT

	// readonly, non-executable
	STYPE
	SSTRING
	SGOSTRING
	SGOFUNC
	SRODATA
	SFUNCTAB
	STYPELINK
	SSYMTAB // TODO: move to unmapped section
	SPCLNTAB
	SELFROSECT
	...

Как мы видим, символ main.main принадлежит виду 1, который соответствует константе STEXT . STEXT — это символ, который содержит исполняемый код. Теперь давайте посмотрим на массив Reloc . Он состоит из следующих структур:

type Reloc struct {
	Offset int
	Size   int
	Sym    SymID
	Add    int
	Type int
}

Каждое перемещение подразумевает, что байты, расположенные в интервале [Смещение, Смещение + Размер], должны быть заменены указанным адресом. Этот адрес рассчитывается путем суммирования местоположения символа Sym с добавлением количества байтов.

 

Понимание переездов

Теперь давайте рассмотрим пример и посмотрим, как работают перемещения. Для этого нам нужно скомпилировать нашу программу с помощью ключа -S, который выведет сгенерированный код сборки:

go tool 6g -S test.go

Давайте посмотрим через ассемблер и попробуем найти основную функцию.

"".main t=1 size=48 value=0 args=0x0 locals=0x8
	0x0000 00000 (test.go:3)	TEXT	"".main+0(SB),$8-0
	0x0000 00000 (test.go:3)	MOVQ	(TLS),CX
	0x0009 00009 (test.go:3)	CMPQ	SP,16(CX)
	0x000d 00013 (test.go:3)	JHI	,22
	0x000f 00015 (test.go:3)	CALL	,runtime.morestack_noctxt(SB)
	0x0014 00020 (test.go:3)	JMP	,0
	0x0016 00022 (test.go:3)	SUBQ	$8,SP
	0x001a 00026 (test.go:3)	FUNCDATA	$0,gclocals·3280bececceccd33cb74587feedb1f9f+0(SB)
	0x001a 00026 (test.go:3)	FUNCDATA	$1,gclocals·3280bececceccd33cb74587feedb1f9f+0(SB)
	0x001a 00026 (test.go:4)	MOVQ	$1,(SP)
	0x0022 00034 (test.go:4)	PCDATA	$0,$0
	0x0022 00034 (test.go:4)	CALL	,runtime.printint(SB)
	0x0027 00039 (test.go:5)	ADDQ	$8,SP
	0x002b 00043 (test.go:5)	RET	,

В будущих статьях мы рассмотрим этот код и попытаемся понять, как работает среда выполнения Go. На данный момент нас интересует следующая строка:

0x0022 00034 (test.go:4)	CALL	,runtime.printint(SB)

Эта команда расположена со смещением 0x0022 (в шестнадцатеричном формате) или 00034 (десятичное число) в данных функции. Эта строка фактически отвечает за вызов функции runtime.printint . Проблема в том, что компилятор не знает точный адрес функции runtime.printint во время компиляции. Эта функция находится в другом объектном файле, о котором ничего не знает компилятор. В таких случаях он использует перемещения. Ниже приведено точное перемещение, соответствующее этому вызову метода (я скопировал его из первого вывода утилиты goobj_explorer ):

{
                    Offset: 35,
                    Size:   4,
                    Sym:    goobj.SymID{Name:"runtime.printint", Version:0},
                    Add:    0,
                    Type:   3,
                },

Это перемещение сообщает компоновщику, что, начиная со смещения в 35 байтов, ему необходимо заменить 4 байта данных адресом начальной точки символа runtime.printint . Но смещение в 35 байт от данных основной функции фактически является аргументом инструкции вызова, которую мы видели ранее. (Инструкция начинается со смещения в 34 байта. Один байт соответствует коду инструкции вызова, а четыре байта — адресу этой инструкции.)

 

Как работает компоновщик

Теперь, когда мы это понимаем, мы можем понять, как работает компоновщик. Следующая схема очень упрощена, но она отражает основную идею:

  • Компоновщик собирает все символы из всех пакетов, на которые есть ссылки из основного пакета, и загружает их в один большой байтовый массив (или двоичное изображение).
  • Для каждого символа компоновщик вычисляет адрес на этом изображении.
  • Затем он применяет перемещения, определенные для каждого символа. Теперь это легко, так как компоновщик знает точные адреса всех других символов, на которые ссылаются эти перемещения.
  • Компоновщик подготавливает все заголовки, необходимые для формата Executable and Linkable (ELF) (в Linux) или формата Portable Executable (PE) (в Windows). Затем он генерирует исполняемый файл с результатами.

 

Понимание TLS

Прилежный читатель заметит странное перемещение в выводе утилиты goobj_explorer для основного метода. Он не соответствует ни одному вызову метода и даже указывает на пустой символ:

{
                    Offset: 5,
                    Size:   4,
                    Sym:    goobj.SymID{},
                    Add:    0,
                    Type:   9,
                },

Итак, что делает это перемещение? Мы видим, что он имеет смещение 5 байтов, а его размер составляет 4 байта. В этом смещении есть команда:

0x0000 00000 (test.go:3)	MOVQ	(TLS),CX

Он начинается со смещением 0 и занимает 9 байтов (поскольку следующая команда начинается со смещением 9 байтов). Мы можем догадаться, что это перемещение заменяет странный (TLS) оператор некоторым адресом, но что такое TLS и какой адрес он использует?

TLS — это сокращение от Thread Local Storage. Эта технология используется во многих языках программирования (более подробно здесь ). Короче говоря, это позволяет нам иметь переменную, которая указывает на разные области памяти при использовании разными потоками.

В Go TLS используется для хранения указателя на структуру G, которая содержит внутренние сведения о конкретной подпрограмме Go (более подробно об этом в последующих публикациях в блоге). Таким образом, существует переменная, которая — при обращении из разных подпрограмм Go — всегда указывает на структуру с внутренними деталями этой подпрограммы Go. Расположение этой переменной известно компоновщику, и эта переменная является именно тем, что было перемещено в регистр CX в предыдущей команде. TLS может быть реализован по-разному для разных архитектур. Для AMD64 TLS реализован через регистр FS , поэтому наша предыдущая команда переводится в MOVQ FS , CX .

Чтобы закончить наше обсуждение перемещений, я собираюсь показать вам перечислимый тип (enum), который содержит все различные типы перемещений:

// Reloc.type
enum
{
	R_ADDR = 1,
	R_SIZE,
	R_CALL, // relocation for direct PC-relative call
	R_CALLARM, // relocation for ARM direct call
	R_CALLIND, // marker for indirect call (no actual relocating necessary)
	R_CONST,
	R_PCREL,
	R_TLS,
	R_TLS_LE, // TLS local exec offset from TLS segment register
	R_TLS_IE, // TLS initial exec offset from TLS base pointer
	R_GOTOFF,
	R_PLT0,
	R_PLT1,
	R_PLT2,
	R_USEFIELD,
};

Как видно из этого перечисления, тип перемещения 3 — R_CALL, а тип перемещения 9 — R_TLS . Эти имена перечислений прекрасно объясняют поведение, которое мы обсуждали ранее.

 

Подробнее об объектных файлах Go

В следующем посте мы продолжим наше обсуждение объектных файлов. Я также предоставлю дополнительную информацию, необходимую для продвижения вперед и понимания работы среды выполнения Go. Если у вас есть какие-либо вопросы, не стесняйтесь задавать их в комментариях.

Прочитайте предыдущие части серии: Часть 1 | Часть 2 | Часть 3

Об авторе: Сергей Матюкевич — облачный инженер и разработчик Go в Altoros. С более чем 6-летним опытом в разработке программного обеспечения, он является экспертом в области облачной автоматизации и проектирования архитектур для сложных облачных систем. Активный участник сообщества Go, Сергей часто участвует в проектах с открытым исходным кодом, таких как Ubuntu и Juju Charms.