Начало работы с Go Language Assembly — начиная с вывода HelloWorld

Go

В JVM байт-код может помочь нам выяснить многие детали компиляции и выполнения, Чтобы понять лежащий в основе синтаксический сахар и принципы языка go, необходимо иметь глубокое понимание базовых знаний ассемблера. Сборка на самом деле не так сложна, как представлялось, на самом деле она похожа на байт-код Java в принципе, но информации очень мало, поскольку она находится ближе к основанию системы, ее сравнительно сложнее читать.

Зачем изучать ассемблер языка Go

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

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

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

В этой статье сначала будет представлен вывод «Hello, World!» с ассемблированием на платформе Linux, а также представлены некоторые основные концепции ассемблера на этом примере. Он закладывает основу для дальнейшего знакомства со сборкой Plan9 языка Go.

Напишите вывод Hello World на C

Раньше я читал много книг по компиляции, и у меня есть ощущение, что, как и в других книгах по программированию, почему бы не рассказать о том, как выводить «Hello, World!»? Почитав много, я постепенно пойму, что вывести "Hello, World!" в консоль со сборкой не так просто, это не три-две строчки, чтобы просто вызвать функцию.

Чтобы понять, как выводить строки в терминал, давайте сначала напишем реализацию на языке C:

#include <stdio.h>

int main() {
    char *str = "Hello, World!\n";
    printf("%s", str);
}

Более близкий способ написания уровня системных вызовов:

#include <unistd.h>

int main() {
    int stdout_fd = 1;
    char* str = "Hello, World!\n";
    int length =  14;
    write(stdout_fd, str, length);
}

Философия дизайна Unix заключается в том, что все является файлом.После запуска программа содержит как минимум три файловых дескриптора (file descriptors, fd для краткости):

  • Стандартный ввод stdin(0)
  • Стандартный вывод stdout(1)
  • Ошибка вывода stderr(2)

Выполнение программы на терминале для вывода строки фактически записывает данные в стандартный выходной файловый дескриптор stdout, а значение fd stdout равно 1.

write — это системный вызов, который записывает данные в файл, сигнатура его функции следующая:

ssize_t	 write(int fd, void * buffer, size_t count)

Первый параметр fd представляет дескриптор файла для записи, второй параметр buffer представляет адрес памяти данных, которые должны быть записаны в файл, а третий параметр представляет количество байтов данных, записываемых в файл из буфера. Таким образом, вывод «Hello, World!\n» в стандартный вывод на самом деле является вызовом системного вызова записи, который записывает 14-байтовую строку в файловый дескриптор с fd, равным 1.

Скомпилируйте и выполните приведенный выше код C, вы увидите вывод строки «Hello, World!»

gcc main.c -o main
./main 

Hello, World!

ЦП, память и регистры

Сборка в основном связана с ЦП и памятью.ЦП сам отвечает только за работу, а не за хранение.Хранение данных обычно размещается в памяти.Мы знаем, что скорость работы ЦП намного выше, чем скорость чтения и записи памяти. Для того, чтобы ЦП не читался памятью Запись мешает, и в ЦП вводится понятие кэша первого уровня, кэша второго уровня и регистров.Эти ресурсы очень ценны.Я до сих пор помню учителя, который сказал: «Кэш второго уровня дороже золота». Регистры можно рассматривать как сверхскоростные устройства хранения, которые могут хранить очень небольшие объемы данных внутри ЦП. Поскольку количество регистров ограничено и очень важно, каждый регистр имеет свое имя.Наиболее часто используемые из них следующие.Сначала они знакомы мне и будут подробно представлены в последующих статьях.

%EAX %EBX %ECX %EDX %EDI %ESI %EBP %ESP

Системный вызов: контракт между ядром и приложением.

Давайте представимсистемный вызовКонцепция, подумают многие, это не просто, я могу писать сотни системных вызовов в день.

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

Необходимо обратить внимание на эти знания:

  • Системный вызов переключает процессор из «пользовательского режима» в «режим ядра».
  • Приложения выполняют системные вызовы по «имени», такие как выход и запись.Каждому системному вызову на нижнем уровне соответствует число, например, выход соответствует 1, а запись соответствует 4. Эти числа необходимо хранить в регистрах.%eaxсередина
  • При вызове системного вызова значение параметра необходимо поместить в указанный регистр
  • int 0x80Инструкция используется для переключения процессора из пользовательского режима в режим ядра, int — это аббревиатура от interrupt (прерывание), а не int от integer. После того, как ядро ​​получит запрос на прерывание 0x80, оно вызовет соответствующий системный вызов в соответствии с содержимым заранее подготовленных регистров.

Последовательность выполнения вызова записи показана на следующем рисунке:

go assembly.001

Соберитесь, чтобы написать Hello World

С приведенным выше основанием, давайте посмотрим на скомпилированный код, я надеюсь не переубедить здесь большинство студентов. имя файлаhelloworld.s, ниже код для сборки

.section .data

msg:
    .ascii "Hello, World!\n"

.section .text
.globl _start

_start:
    # write 的第 3个参数 count: 14
    movl $14,  %edx
    # write 的第 2 个参数 buffer: "Hello, World!\n"
    movl $msg, %ecx
    # write 的第 1 个参数 fd: 1
    movl $1,   %ebx
    #  write 系统调用本身的数字标识:4
    movl $4,   %eax
    #  执行系统调用: write(fd, buffer, count)
    int $0x80

    # status: 0
    movl $0,   %ebx
    # 函数: exit
    movl $1,   %eax
    # system call: exit(status)
    int $0x80

В ассемблере все, что начинается с точки (.), не транслируется напрямую в машинные инструкции..sectionРазделите ассемблерный код на разделы,.section .dataЭто начало сегмента данных. Сегмент данных хранит данные, которые должны использоваться последующей программой, что эквивалентно глобальной переменной. В сегменте данных мы определяем сообщение, содержимое, представленное в кодировке ascii, — «Hello, World!\n»,

Следующее.section .textУказывает на начало сегмента текста, где хранятся программные инструкции.

Следующая команда.globl _start, здесь нет ни одной опечатки, не глобальной,_startявляется этикеткой. Далее идет настоящая инструкция по сборке.

Как упоминалось ранее, при выполнении системного вызова записи%eaxВ регистре хранится системный вызов номер 4 записи,%ebxfd для хранения стандартного вывода,%ecxСохраняет адрес выходного буфера.%edxСохраняет количество байтов. так что смотри_startПосле примечания следуют четыре инструкции movl Формат инструкции movl:

movl src dst

Напримерmovl $4, %eaxИнструкция состоит в том, чтобы сохранить константу 4 в регистр%eaxСреди них $ перед цифрой 4 означает "немедленная адресация". Другие режимы адресации сборки будут подробно описаны позже. Я не буду здесь их расширять. Просто знайте, что немедленная адресация сама по себе содержит данные, к которым осуществляется доступ. Инициализируйте данные на 4, не переходя ни на какой адрес, чтобы прочитать 4, дайте цифру 4 прямо в инструкции.

Следующая командаint $0x80, Как упоминалось ранее, это инструкция, запускаемая прерыванием, которая передает процесс выполнения ядру для продолжения обработки. Приложению не нужно заботиться о том, как ядро ​​​​обработает его. Ядро вернет процесс выполнения приложению после обработки , и установите его в зависимости от того, было ли выполнение успешным или нет.Значение глобальной переменной errno. Как правило, в Linux успешный системный вызов возвращает неотрицательное значение, а при отправке ошибки возвращается отрицательное значение.

Следующая инструкция фактически выполняет exit(0) для выхода из программы.Инструкции и логика такие же, как и предыдущие, поэтому я не буду их повторять.

Давайте скомпилируем и выполним приведенный выше ассемблерный код. В Linux вы можете собирать и компоновать программы, используя as и ld

as $helloworld.s -o helloworld.o
ld $helloworld.o -o helloworld

执行:
./helloworld

вы можете увидеть вывод

Hello, World!

Пример вывода ассемблера языка Go Hello World

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

Обратите внимание, что следующие эксперименты проводятся на платформе Mac, см. исходный код:GitHub.com/Артур-Станция…

Структура файла следующая:

.
├── helloworld
│   ├── helloworld.go
│   └── helloworld.s
├── main.go

main.go читается следующим образом, вызывая метод PrintMe helloworld.go в:

package main

import (
	"./helloworld"
)

func main() {
	helloworld.PrintMe()
}

Содержимое helloworld.go просто объявляет пустую функцию для PrintMe():

package helloworld

func PrintMe()

Конкретная реализация находится в сборочном файле helloworld.s, содержимое которого следующее:

#include "textflag.h"

DATA  msg<>+0x00(SB)/8, $"Hello, W"
DATA  msg<>+0x08(SB)/8, $"orld!\n"
GLOBL msg<>(SB),NOPTR,$16

TEXT ·PrintMe(SB), NOSPLIT, $0
	MOVL 	$(0x2000000+4), AX 	 // write 系统调用数字编号 4
	MOVQ 	$1, DI 			      // 第 1 个参数 fd
	LEAQ 	msg<>(SB), SI 		// 第 2 个参数 buffer 指针地址
	MOVL 	$16, DX 		        // 第 3 个参数 count
	SYSCALL
	RET

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

О регистрах

Использование регистров в plan9 не требует префикса с r или e, например rax, просто напишите AX.

eax->AX
ebx->BX
ecx->CX
...

Сборка Go представляет четыре очень важных псевдорегистра:

  • FP: указатель кадра, используемый для доступа к параметрам функции.
  • ПК: Счетчик программ: для ответвлений и переходов
  • SB: статический базовый указатель: обычно используется для объявления функций или глобальных переменных.
  • SP: указатель стека: указывает на начальную позицию локальной переменной текущего кадра стека, обычно используется для ссылки на локальную переменную функции.

объявление переменной

Команда DATA на языке ассемблера Go используется для инициализации переменных Синтаксис следующий:

DATA symbol+offset(SB)/width, value

Например, объявите переменную msg:

DATA  msg<>+0x00(SB)/8, $"Hello, W"

Давайте посмотрим на инструкцию GLOBL

GLOBL msg<>(SB),NOPTR,$16

Инструкция GLOBL объявляет переменную как глобальную, за которой следуют два параметра, флаг и размер переменной.Этот NOPTR не влияет на последующее чтение и здесь не будет представлен.

Обратите внимание, что после msg стоит<>, что означает, что эта глобальная переменная доступна только в текущем файле, аналогично статической переменной в языке C.

определение функции

Синтаксис определения функции следующий:

TEXT symbol(SB), [flags,] $framesize[-argsize]

Разделен на 5 компонентов: инструкция TEXT, имя функции, флаг необязательных флагов, размер кадра функции и размер необязательных параметров функции.

В качестве примера возьмем ассемблерный код в примере:

TEXT ·PrintMe(SB), NOSPLIT, $0
  • ТЕКСТ означает раздел .text в ассемблере,
  • Обратите внимание, что в дополнение к пробелу между TEXT и PrintMe есть античеловеческая «средняя точка».·», я не знаю, какое хобби было у человека, который это разработал, в первую очередь, 😁. Эта средняя точка заменяется после компиляции на., а также добавьте имя пакета, например здесьhelloworld.PrintMe
  • Флаг NOSPLIT здесь не будет.
  • $0 означает, что размер кадра стека равен 0

Далее идет содержимое тела конкретной функции.

Какого черта 0x2000000 в MOVL $(0x2000000+4)? Номер системного вызова под Mac нужно добавить с 0x2000000.Не спрашивайте почему, это системное соглашение. Номер системного вызова для Mac можно найти здесь:open source.apple.com/source/Hun/…

Немного отличаясь от сборки под Linux, представленной ранее, параметры системного вызова под Mac необходимо хранить в регистрах, таких как DI, SI, DX, а номер системного вызова хранится в AX.

Здесь сначала представлено введение в сборку Go HelloWorld. Надеюсь, это поможет вам

постскриптум

Эта статья является введением в сборку языка Go. Из-за ограниченного места она не раскрывает каждую деталь очень подробно. В следующей серии статей мы продолжим знакомить с ней с примерами.

Вы можете отсканировать QR-код ниже, чтобы подписаться на мой официальный аккаунт: