Лабораторная работа №2. Компиляция и запуск ассемблерной программы
2.1 Цель и задачи
Целью работы является освоение способов компиляции программ на ассемблере RISC-V.
Для достижения поставленной цели требуется решить следующие задачи:
- Изучить примеры простейших ассемблерных программ.
- Ознакомиться с основными командами для компиляции ассемблерных программ.
- На примере HelloWorld приложений изучить основные структурные элементы ассемблерной программы.
Презентация к блоку "Введение и инструменты сборки"
2.2. Основные теоретические сведения
Язык ассемблера представляет максимально близкий к аппаратному обеспечению способ для создания прикладных и системных программ. В таких программах практически отсутствуют привычные абстракции и программист вынужден оперировать реальными аппаратными структурами процессора и ОЗУ, а также учитывать особенности аппаратной архитектуры конкретной системы. Это приводит к тому, что языки ассемблера для разных процессорных архитектур не совместимы между собой, однако, общая логика построения программ и манипуляций с данными сохраняется. Ниже мы поясним необходимые элементы простейшей ассемблерной программы, подробное введение в ассемблер для RISC-V дается в книге, синтаксис команд описан в мануале.
Ассемблерные программы представляют собой последовательность простейших команд для процессора, которые оперируют минимальными элементами данных и простейшими операциями с ними или другими устройствами. Для создания базового комфорта программисту, в большинстве языков ассемблера (в том числе и для RISC-V) предусмотрены такие структурные единицы как псевдооператоры и метки:
- Псевдооператоры позволяют разделить программу по секциям на статические данные (строковые и числовые константы) и исполняемый код, а также задать точку входа в программу (строку, с которой начинается выполнение программы). Для нашего примера нам потребуется две секции - .global (задает точку входа) и .data (задает секцию данных). Это не единственное назначение псевдооператоров, подробнее можно прочитать в мануале.
- Метки помогают разделить программу на элементы, которые в ближайшем приближении напоминают функции в языке Си. Метки представляют собой именованные позиции в рамках исходного кода, к которым можно выполнить различные виды переходов в программе. Самый лучший аналог меток из языка Си это оператор GOTO, однако в отличии от Си, в ассемблере можно выполнять и условные переходы по меткам.
2.2.1 Структура ассемблерных программ
В отличии от языков программирования высокого уровня, в ассемблере вместо переменных используются регистры - небольшие ячейки памяти, расположенные на микросхеме процессора. В каждой архитектуре процессора свой ограниченный набор регистров. Малый объем памяти, отводимый на регистры, компенсируется высокой скоростью работы с ними - она превосходит аналогичный показатель для ОЗУ и ПЗУ. Регистры могут иметь специальное назначение - так в примере простейшей программы ниже мы будем использовать четыре регистра (a0, a1, a2, a7), назначение которых подразумевает передачу аргументов для вызываемой функции системного вызова. В остальном работа с регистрами с некоторыми оговорками похожа на работу с переменными в языках программирования высокого уровня.
Управляющие инструкции в языке ассемблера выглядят очень просто по сравнению с командами и ключевыми словами языков высокого уровня. Как правило, команда представляет собой сокращение полного английского наименования совершаемого действия:
- la - Load Address (загрузить адрес в регистр),
- addi - Add an Immediate value to register (сложить константу со значением из регистра),
- ecall - Environment Call (произвести вызов системной процедуры). В случае ОС Linux (в данном случае RISC-V ОС) это будет означать вызов функции стандартной библиотеки.
Команды могут иметь от нуля до трех операндов (аргументов) для базового набора инструкций. В расширениях RISC-V может встречаться и большее число операндов.
- la - два операнда (регистр назначения и адрес области данных),
- addi - три операнда (регистр назначения для результата сложения, слагаемое №1, слагаемое №2),
- ecall - без операндов.
Простейший helloworld на ассемблере для RISC-V будет выглядеть так:
.global _start # Точка входа для программы # Настройка вызова функции write так, чтобы он вывел строчку в терминал # В регистры a0-a2 записываются аргументы write # В регистре a7 указывается номер write _start: addi a0, x0, 1 # 1 = Поток стандартного выовда (StdOut) la a1, helloworld # Загрузка адреса контсанты helloworld в a1 addi a2, x0, 13 # Запись длины строки в a2 addi a7, x0, 64 # Запись номера функции write 64 ecall # Выполнение функции write # Настройка вызова exit и вызов функции addi a0, x0, 0 # Указание кода возврата для exit addi a7, x0, 93 # Запись номера функции exit 93 ecall # Выполнение функции exit .data helloworld: .ascii "Hello World!\n"
Необходимо отдельно рассказать о разнице между системным вызовом exit и одноименной функцией стандартной библиотеки:
- Системный вызов только выходит из текущего потока управления, т.е. нити. За завершение процесса отвечает системный вызов exit_group.
- В стандартной библиотеке функции exit(), _Exit(), _exit() используют именно вызов exit_group.
2.2.2 Компиляция ассемблерных программ
Для компиляции и запуска нам потребуется выполнить следующие команды в терминале RISC-V ОС (предполагаем, что исходный код сохранен в файле hello.s):
$ as -o hello.o hello.s # Запуск компилятора ассемблерных программ $ ld -o hello hello.o # Запуск линковщика $ ./hello
В случае успеха, в терминал будет выведена строка "Hello World!".
Аналогичные операции можно проделать и из гостевой ОС, выполняя кросс-компиляцию, при этом потребуется использовать утилиты riscv64-linux-gnu-as и riscv64-linux-gnu-ld соответственно.
Для исследования скомпилированных ассемблерных программ удобно использовать утилиту objdump. Она позволяет выполнить обратное преобразование - из бинарного файла получить код на языке ассемблера:
$ objdump -d ./hello
Флаг -d в данном случае обозначает "дизассемблировать секции файла, в которых ожидается наличие кода". Результат выполнения:
./hello: file format elf64-littleriscv Disassembly of section .text: 00000000000100e8 <_start>: 100e8: 00100513 li a0,1 100ec: 00001597 auipc a1,0x1 100f0: 02058593 addi a1,a1,32 # 1110c <__DATA_BEGIN__> 100f4: 00d00613 li a2,13 100f8: 04000893 li a7,64 100fc: 00000073 ecall 10100: 00000513 li a0,0 10104: 05d00893 li a7,93 10108: 00000073 ecall
Вывод objdump имеет следующую структуру:
hex-код операции код операции ассемблерный код
В структуре восстановленного ассемблерного кода угадывается структура исходной программы, однако очевидно, что операции указанные нами изначально были преобразованы компилятором.
2.3. Задание к лабораторной работе
Подготовьте программу, выполняющую с помощью языка ассемблера следующую задачу:
- Вывести строку "Hello, I am <ваше ФИО> from group <номер вашей группы>".
- Вывести с помощью псевдографики изображение фигуры (размер фигуры по одной из сторон - 3 строки), составленное по следующему алгоритму:
- a. Если у вас четный номер в списке - вы выводите изображение квадрата, если нечетный - равносторонний прямоугольный треугольник.
- b. Символы, из которых необходимо составить фигуру, определяются остатком от деления последней цифры в номере вашей группы на 4:
- 0 - @
- 1 - #
- 2 - $
- 3 - %
- Завершить работу программы.
Для вывода изображения используйте несколько последовательных вызовов write.
Программу необходимо скомпилировать и убедится в ее работоспособности на RISC-V ОС. С помощью утилиты objdump (в RISC-V ОС) необходимо дизассемблировать бинарный файл программы и зафиксировать в отчете общее количество строк, а также hex-код последней команды.
2.3.1. Описание последовательности выполнения работы
Для выполнения поставленной задачи необходимо понять, как выглядит текст, выводимый вашей программой и фигура, которую вам необходимо нарисовать в вашей программе. Для определенности будем считать, что это квадрат из символов *, размером 2х2. Для наглядности лучше всего изобразить эту фигуру:
** **
Далее необходимо составить программу, которая будет выводить указанные строки. Учитывая пример фигуры выше, всего программа должна вывести три строки. Запишите эти строки в секцию .data. Соответственно, необходимо сделать три вызова write.
Далее необходимо скомпилировать и запустить программу в RISC-V ОС. Для этого можно использовать утилиты as и ld, либо применить кросс-компиляцию и запуск из гостевой ОС. После компиляции и запуска, программу необходимо проанализровать через objdump.
2.3.2. Пример выполнения задания на защиту
Напишите программу, которая выводит две строки "I am an example string\n" и "Another string example\n".
.global _start _start: addi a0, x0, 1 la a1, string1 addi a2, x0, 25 addi a7, x0, 64 ecall addi a0, x0, 1 la a1, string2 addi a2, x0, 25 addi a7, x0, 64 ecall addi a0, x0, 0 addi a7, x0, 93 ecall .data string1: .ascii "I am example string\n" string2: .ascii "Another string example\n"
Вопросы для контроля
- Какую задачу решает утилита ld из пакета binutils?
- Какие задачи решают команды ассемблера li и auipc?
- Для чего предназначена секция .global?
- Что такое метки?