Набор инструкций архитектуры (ISA) RISC-V, который обсуждался ранее, является основой для программирования на языке Assembler. Однако не очень удобно реализовывать программу исключительно на уровне ISA. По этой причине компилятор языка Assembler для RISC-V предоставляет различные псевдо-инструкции и директивы, которые облегчают разработку программ.
К концу этой лекции вы будете:
Первое, что облегчает разработку кода на языке Assmebler, это метки. Метки служат для идентификации мест в программном коде, они используются в качестве смещений и непосредственных значений адресов.
Под меткой понимается символьная последовательность, оканчивающаяся двоеточием. Также существует особая разновидность меток - числовые метки, - это локальные метки, которые могут быть заданы с суффиксами 'f' для перехода вперёд и 'b' для перехода назад.
Пример показан ниже:
1:
j 1b
j 1f
1:
Первый переход выполнится на метку 1 выше кода, потому что указан суффикс 'b', второй переход выполнится на метку 1 ниже кода, потому что указан суффикс 'f'.
Компилятор языка Assembler преобразует метки в непосредственные значения адресов.
Рассмотрим следующий пример:
addi x1, x0, 1
beq x1,x0, there
addi x1, x1, 1
there:
addi x1, x1, 1
Компилятор переводит программу в машинные коды и вставляет смещения, ассоциируемые с метками:
0x0: 00100093 addi x1 x0 1 0x4: 00008463 beq x1 x0 8 <there> 0x8: 00108093 addi x1 x1 1 0000000c <there>: 0xc: 00108093 addi x1 x1 1
Если мы посмотрим на базовый набор RV32I ISA, можно увидеть, что адресация по всему адресному пространству может потребовать более одной инструкции: инструкции auipc и lui используются для загрузки старшей части непосредственного значения адреса в регистр, а для загрузки младшей части в регистр используется инструкция addi. Таким образом, требуется некий механизм разделения адресов меток на старшую и младшую составляющие.
Функции переразмещения %hi(symbol) и %lo(symbol) разделяют адрес метки на младшую и старшую части. Компоновщик (линковщик) размещает программу в памяти и назначает адреса соответствующим символам. Таким образом, код:
lui x1, %hi(there) #absolute higher 20 bits
addi x1, x1, %lo(there) #absolute lower 12 bits
there:
транслируется в код:
0x10078: lui x1,0x10 0x1007c: addi x1,x1,128 # 10080 <there>
Адрес метки there после размещения имеет адрес 0x10080. Функции %hi и %lo используются для задания корректных адресов в регистре x1.
Ещё одним примером функций переразмещения являются функции адресации, относительной значения программного счётчика. К ним относятся функции %pcrel_hi(symbol) и %pcrel_lo(label), которые работают вместе с инструкциями auipc и addi. Однако, поскольку адресация относительная, они используются отличным от глобальной адресации способом. Так, компилятор сгенерирует из такого кода:
1: auipc x1, %pcrel_hi(there)#relative higher 20 bits
addi x1, x1, %pcrel_lo(1b)#relative lower 12 bits label 1 backwards
there:
следующий код:
1: 0x10078: auipc x1,0x0 0x1007c: addi x1,x1,8 # 10080 <there>
В этом примере также адрес метки there равен 0x10080. Инструкция auipc должна добавить 0x0 к значению программного счётчика 0x10078 для получения старшей части адреса метки 'there'. Результат инструкции auipc - число 0x10078 в регистре x1. Инструкция addi добавляет число 8 к регистру x1, поскольку 8 - это младшая часть разницы адресов меток there и 1.
Однако работа с функциями переразмещения вручную - работа громоздкая, поэтому чаще всего вместо них используют псевдоинструкции.
Директивы языка предоставляют компилятору информацию о том, как должен интерпретироваться текст, следующий за директивой: это данные или код. Все директивы начинаются с точки.
В таблице 4.1 ниже приводится перечень наиболее часто употребляемых директив:
| Директива | Аргументы | Описание |
|---|---|---|
| .text | Переключиться на секцию .text | |
| .data | Переключиться на секцию .data | |
| .rodata | Переключиться на секцию .rodata | |
| .bss | Переключиться на секцию .bss | |
| .section | .text, .data, .rodata, .bss | Переключиться на секцию, задаваемую аргументом |
| .equ | name, value | определить имя для константного значения |
| .ascii | "string" | начало строки без терминального нуля |
| .asciz | "string" | начало строки с терминальным нулём |
| .string | "string" | то же самое, что и .asciz |
| .byte | expression [,expression]* | 8-битные значения, разделённые запятыми |
| .half | expression [,expression]* | 16-битные значения, разделённые запятыми |
| .word | expression [,expression]* | 32-битные значения, разделённые запятыми |
| .dword | expression [,expression]* | 64-битные значения, разделённые запятыми |
| .zero | integer | байты, заполненные нулями |
| .align | integer | значение, выровненное по заданной степени 2 |
| .globl | symbol_name | внести symbol_name в таблицу символов |
Эти директивы очень важны, когда пишется программа, которая собирается для исполнения внутри некоторого окружения, например, встроенной системы или операционной системы.
Директива .text предоставляет компилятору информацию, что следующий текст является набором машинных инструкций. Директива .data говорит о том, что начинается область с объявлением данных, которые должны быть проинициализированы и могут меняться в процессе выполнения программы. Директива .rodata предназначена для объявления инициализированных данных, доступных только для чтения (константные значения). Директива .bss служит для объявления модифицируемых, но не инициализированных данных. Все эти области памяти могут быть объявлены с помощью директивы .section с последующим указанием типа секции (.text, .data, .rodata и т.д.).
Имя для константного значения может быть задано с помощью директивы equ. Данные для ASCII-строк задаются директивами .ascii, .asciz, и .string. Разница между ними в том, что директивы .asciz и .string добавляют терминальный нулевой байт в конце строки, а директива .ascii - нет. Текст располагается следом за директивной.
Директивы .byte, .half, .word, и .dword служат для задания одного или более числовых значений, которые указываются следом за директивами. Указанные директивы отличаются размером (в байтах) описываемых значений.
Директива .zero объявляет массив байт, которые заполняются нулями, при этом первое число, идущее за директивой .zero задаёт количество таких элементов.
Директива .align выравнивает в памяти данные, следующие за числом, задаваемым как 2 в степени аргумента параметра. Выравнивание достигается путём вставки нулевых байтов.
Директива .globl symbol_name определяет идентификатор symbol_name, который обычно задаётся как значение, видимое компоновщика. Символ _start требуется для задания точки входа в программу. В данном руководстве мы не рассматриваем синтаксис скриптов компоновщика, просто используем скрипт по умолчанию, который поставляется вместе с набором инструментов сборки Linux Gnu toolchain. Тем не менее, скрипты компоновщика очень важны, когда идёт разработка для различных окружений или встраиваемых систем, поскольку такие скрипты могут определять расположение программы в памяти устройств.
Также используется такая конструкция, как точка (.), которая подменяет текущий адрес.
Рассмотрим пример. Символ решётка '#' задаёт комментарий:
# define exit as 93
.equ exit, 93
# program code
.section .text
# export _start for linker
.globl _start
_start:
li a7, exit
ecall
# data: init one word (16-bit value) with 1 and read/write
.section .data
counter:
.word 1
# rodata: constant text string
.section .rodata
text_begin:
.asciz "Text"
text_end:
# current address minus address of text_begin = length of text
.byte .-text_begin
# non initialized block with same size as the text
.section .bss
# start next part by address aligned to multiple of 2^2 = 4
.align 2
copy_begin:
.zero text_end-text_begin
Если этот пример сохранить в файле с названием example.s, то его можно будет собрать в исполняемый модуль в формате ELF (Executable and Linkable Format, ELF), и проанализировать результат сборки и компоновки с помощью утилиты objdump:
riscv64-linux-gnu-as -o example.o example.s riscv64-linux-gnu-ld -o example example.o riscv64-linux-gnu-objdump -f -d -Mno-aliases,numeric example example: file format elf64-littleriscv architecture: riscv:rv64, flags 0x00000112: EXEC_P, HAS_SYMS, D_PAGED start address 0x00000000000100e8 Disassembly of section .text: 00000000000100e8 <_start>: 100e8: 05d00893 addi x17,x0,93 100ec: 00000073 ecall
Команда objdump с ключами -f -d показывает дизассемблированный файл (-d) с заголовком файла (-f). Опции, указанные после ключа -Mno-aliases,numeric говорят, что утилита objdump должна печатать только базовые инструкции и не выводить псевдоинструкции и печатать номера регистров, а не их имена в синтаксисе ABI (что будет рассмотрено позже). В листинге показан только программный код, содержащийся в секции.text. После указания ключа -t , утилита objdump печатает все секции исполняемого файла:
riscv64-linux-gnu-objdump -t example example: file format elf64-littleriscv SYMBOL TABLE: 00000000000100e8 l d .text 0000000000000000 .text 00000000000100f0 l d .rodata 0000000000000000 .rodata 00000000000110f6 l d .data 0000000000000000 .data 00000000000110fc l d .bss 0000000000000000 .bss 0000000000000000 l d .riscv.attributes 0000000000000000 .riscv.attributes 0000000000000000 l df *ABS* 0000000000000000 example.o 000000000000005d l *ABS* 0000000000000000 exit 00000000000100f6 l .data 0000000000000000 counter 00000000000100f0 l .rodata 0000000000000000 text_begin 00000000000100f5 l .rodata 0000000000000000 text_end 00000000000110fc l .bss 0000000000000000 copy_begin 00000000000118f6 g *ABS* 0000000000000000 __global_pointer$ 00000000000110fa g .data 0000000000000000 __SDATA_BEGIN__ 00000000000100e8 g .text 0000000000000000 _start 0000000000011108 g .bss 0000000000000000 __BSS_END__ 00000000000110fa g .bss 0000000000000000 __bss_start 00000000000110f6 g .data 0000000000000000 __DATA_BEGIN__ 00000000000110fa g .data 0000000000000000 _edata 0000000000011108 g .bss 0000000000000000 _end
Секция .section .text начинается с адреса 0x100e8, секция .rodata - с адреса 0x100f0, секция .data - с адреса 0x110f6, и секция .bss - с адреса 0x110fc. Каждой метке присваиваются адреса, то есть метка copy_begin располагается в секции .bss по адресу 0x110fc.
В итоге можно просмотреть содержимое бинарного файла в шестнадцатеричном формате, задав утилите objdump ключи -F -s, где ключ -F нужен для вывода дополнительной информации о файле:
riscv64-linux-gnu-objdump -F -s example example: file format elf64-littleriscv Contents of section .text: (Starting at file offset: 0xe8) 100e8 9308d005 73000000 ....s... Contents of section .rodata: (Starting at file offset: 0xf0) 100f0 54657874 0005 Text.. Contents of section .data: (Starting at file offset: 0xf6) 110f6 01000000 .... Contents of section .riscv.attributes: (Starting at file offset: 0xfa) 0000 412d0000 00726973 63760001 23000000 A-...riscv..#... 0010 05727636 34693270 305f6d32 70305f61 .rv64i2p0_m2p0_a 0020 3270305f 66327030 5f643270 3000 2p0_f2p0_d2p0.
Первые два двойных слова в секции .text являются представлением машинного кода программы. Следует обратить внимание, что в выводе дизассемблера порядок байт инвертирован, то есть написано 9308d005 вместо 05d00893. Показанное содержимое отображает то, как данные хранятся в памяти (или в файле), а не то, как 32-битное значение или инструкция составлены в формате RISC-V little endian. В формате представления little endian 32-битная инструкция составляется таким образом, что младшие адреса занимают старшие байты, а старшие адреса - младшие байты. Таким образом, четыре байта со значениями 0x93 0x08 0xd0 0x05 , расположенные по адресу 0x100e8, берутся и размещаются в 32-битное представление как 0x05d00893. Напротив, формат Big endian предписывает иной порядок следования байт в памяти.
Секция .rodata начинается после двух 32-битных инструкций по адресу 0x100f0 (что соответствует 8-ми байтному смещению от начала программы: 0x100e8 + 0x8) и содержит байты 0x54657874 0x0005. Значения 0x54 0x65 0x78 0x74 являются побайтовым представлением в таблице ASCII-символов четырёх символов: 'T' 'e' 'x' 't'. Они заканчиваются нулевым байтом (0x00) и байтом длины, равным 0x05 - это длина текста включая терминальный ноль. Затем следует секция .data, которая содержит 32-битное представление числа 1, которое кодируется в формате Little Endian. Следующая секция описывает атрибуты RISC-V.
Секция .bss не содержит данных, поэтому её содержимое не отображается. Тем не менее в таблице символов было видно, что секция .bss начинается с адреса 0x110fc, который является следующим адресом за адресом секции .data (заканчивается по адресу 0x110fa) с добавлением двух байт для выравнивания в памяти до 4 байт.
В итоге мы получили представление о том, как компилятор и компоновщик располагают данные в формате ELF.
Компилятор языка Assembler транслирует программу в машинный код. В рамках курса в основном рассматривается GNU assembler. Компилятор понимает не только инструкции RISC-V, определяемые стандартом ISA, но также понимает псевдо-инструкции и псевдо-директивы.
Псевдо-инструкции являются частью языка Assembler и могут быть странслированы в машинный код. Однако, в отличие от инструкций из набора ISA, псевдо-инструкции гораздо проще использовать. Обычно псевдо-инструкция может быть странслирована в более чем одну базовую инструкцию набора ISA. Кроме того, псевдо-инструкции решают проблему использования функций ручного перемещения памяти. В следующих таблицах показаны псевдо-инструкции и то, в какие базовые инструкции они преобразуются.
| Псевдо-инструкция | Набор базовых инструкций | Описание |
|---|---|---|
| la rd, symbol | auipc rd, symbol[31:12] addi rd, symbol[11:0] | Загрузить адрес (не позиционно-независимый код - non-PIC) |
| la rd, symbol | auipc rd, symbol@GOT[31:12] l{w|d} rd, symbol[11:0](rd) | Загрузить адрес (позиционно-независимый код - PIC) |
| lla ra, symbol | auipc rd, symbol[31:12] addi rd, rd, symbol[11:0] | Загрузить локальный адрес |
| lga rd, symbol | auipc rd, symbol@GOT[31:12] l{w|d} rd, symbol@GOT[11:0](rd) | Загрузить глобальный адрес |
| l{b|h|w|d} rd, symbol | auipc rd, symbol[31:12] l{b|h|w|d} rd, symbol[11:0](rd) | Загрузить глобальное значение |
| s{b|h|w|d} rs, symbol, rd | auipc rd, symbol[31:12] s{b|h|w|d} rs, symbol[11:0](rd) | Сохранить глобальное значение |
| nop | addi x0, x0, 0 | Нет операции |
| li rd, imm | Различные варианты инструкций | Загрузить непосредственное значение |
| mv rd, rs | addi rd, rs, 0 | Скопировать значение регистра |
| not rd, rs | xori rd, rs, -1 | дополнение до 1 (инверсия битов) |
| neg rd, rs | sub rd, x0, rs | дополнение до 2 (инверсия битов с добавлением 1) |
| negw rd, rs | subw rd, x0, rs | дополнение до 2 (слово) |
Положение позиционно-независимого кода (Position Independent Code, PIC) в адресном пространстве зависит только от настроек компоновщика (а не от архитектуры) и полезно для генерации бинарного кода библиотек, которые могут использоваться другими программами. Такой код обычно используется совместно с глобальной таблицей смещений (Global Offset Table, GOT), которая хранится в исполняемом модуле и позволяет операционной системе загружать библиотеки при старте вызывающих программ по различным адресам памяти. Тема, связанная с этим вопросом, является достаточно сложной и не будет рассмотрена в рамках данного курса.
Для самостоятельно изучения вопросов, связанных с форматом ELF, можно посоветовать ознакомиться с документом RISC-V ELF Specification, доступным по адресу https://github.com/riscv-non-isa/riscv-elf-psabi-doc/blob/master/riscv-elf.adoc.
Отдельно стоит отметить, что псевдо-инструкция lga не поддерживается в наборе инструментов GNU toolchain версии 2.39 и ниже.
Рассмотрим на примере следующего кода, как он транслируется с точки зрения компилятора языка Assembler:
.text
.globl _start
_start:
la x1, counter #load address of counter
addi x1, x1, 4 #go to next word address += 4
lw x2, 0(x1) #load value from address, the 2
li x1, 2
lw x3, counter #load word from address counter, the 0
add x3, x2, x1 #add: x3 = x2 + x1
sw x3, counter, x2 #save x3, use x2 for address
.data
counter:
.word 0, 2
Disassembly of section .text:
00000000000100e8 <_start>:
100e8: 00001097 auipc x1,0x1
100ec: 02808093 addi x1,x1,40 # 11110 <__DATA_BEGIN__>
100f0: 00408093 addi x1,x1,4
100f4: 0000a103 lw x2,0(x1)
100f8: 00200093 addi x1,x0,2
100fc: 00001197 auipc x3,0x1
10100: 0141a183 lw x3,20(x3) # 11110 <__DATA_BEGIN__>
10104: 001101b3 add x3,x2,x1
10108: 00001117 auipc x2,0x1
1010c: 00312423 sw x3,8(x2) # 11110 <__DATA_BEGIN__>
Адрес метки counter в памяти равен 0b11110. Псевдо-инструкции транслируются в базовые инструкции следующим образом:
la x1, counter -> auipc x1,0x1; addi x1, x1, 40 li x1, 2 -> addi x1, x0, 2 lw x3, counter -> auipc x3, 0x1; lw x3, 20(x3) sw x3, counter, x2 -> auipc x2, 0x1; sw x3, 8(x2)
Таблица 4.3 ниже показывает псевдо-инструкции и базовые инструкции для расширения и условной установки битов:
| Псевдо-инструкция | Набор базовых инструкций | Описание |
|---|---|---|
| sext.{b|h|w} rd, rs | Может быть различный набор инструкций | sign extend |
| zext.{b|h|w} rd, rs | Может быть различный набор инструкций | zero extend |
| seqz rd, rs | sltiu rd, rs, 1 | rd = (rs == 0)? 1:0 |
| snez rd, rs | sltu rd, x0, rs | rd = (rs != 0)? 1:0 |
| sltz rd, rs | slt rd, rs, x0 | rd = (rs < 0)? 1:0 |
| sgtz rd, rs | slt rd, x0, rs | rd = (rs > 0)? 1:0 |
В таблице 4.4 приведены псевдо-инструкции для условного ветвления. Первая часть из них упрощает ветвление с использованием операций сравнения с нулём без использования регистра x0. Вторая часть предлагает удобные функции для сравнения за счёт переупорядочивания параметров базовых инструкций, например, вызов команды ble rs, rt, imm эквивалентен вызову bge rt, rs, imm.
| Псевдо-инструкция | Набор базовых инструкций | Описание |
|---|---|---|
| beqz rs, imm | beq rs, x0, imm | if (rs == 0) PC+=imm |
| bnez rs, imm | bne rs, x0, imm | if (rs != 0) PC+=imm |
| blez rs, imm | bge x0, rs, imm | if (rs <= 0) PC+=imm |
| bgez rs, imm | bge rs, x0, imm | if (rs >= 0) PC+=imm |
| bltz rs, imm | blt rs, x0, imm | if (rs < 0) PC+=imm |
| bgtz rs, imm | blt x0, rs, imm | if (rs > 0) PC+=imm |
| bgt rs, rt, imm | blt rt, rs, imm | if (rs > rt) PC+=imm |
| ble rs, rt, imm | bge rt, rs, imm | if (rs <= rt) PC+=imm |
| bgtu rs, rt, imm | bltu rt, rs, imm | if (rs > rt) PC+=imm, unsign. |
| bleu rs, rt, imm | bgeu rt, rs, imm | if (rs <= rt) PC+=imm, unsign. |
Наконец, псевдо-инструкции для безусловного перехода упрощают программирование за счёт того, что в их составе не нужно указывать имена регистров. А переход на дальние адреса реализуется командами call и tail.
| Псевдо-инструкция | Набор базовых инструкций | Описание |
|---|---|---|
| j imm | \jal x0, imm | PC += imm |
| jal imm | jal x1, imm | x1 = PC+4; PC += imm |
| jr rs | jalr x0, rs, 0 | PC = rs |
| jalr rs | jalr x1, rs, 0 | x1 = PC+4; PC = rs |
| ret | jalr x0, x1, 0 | PC = x1 |
| call imm | auipc x6, imm[31:12] jalr x1, x6, imm[11:0] | x1 = PC+4; PC = imm |
| tail imm | auipc x6, imm[31:12] jalr x0, x6, imm[11:0] | PC = imm |
До сих пор обращение к регистрам происходило по их именам. Однако, существует бинарный интерфейс для приложений (Application Binary Interface, ABI), подробнее про него можно почитать по ссылке: https://github.com/riscv-non-isa/riscv-elf-psabi-doc. Он представляет собой ряд правил, описывающих, как и какие регистры должны быть использованы для решения задач. Среди прочего, ABI предоставляет псевдонимы в том числе и для регистров.
Кроме того, одним из соглашений, регламентируемых ABI, является то, как устроен механизм сохранения значений регистров при переходе в подпрограмму: в момент вызова адрес возврата сохраняется в регистре, а программный счетчик устанавливается на начало функции. Функции состоит из инструкций, которые, собственно, и выполняются функцией; после выполнения функции управление передаётся инструкции, следующей следом за инструкцией вызова. Чтобы вернуться, функция использует сохранённый адрес возврата, а значит должно быть соглашение о том, какой из регистров используется для хранения адреса возврата в случае, если над программой работает большое число программистов. Кроме того, важно соглашение о том, какие регистры можно считать измененными или неизменяемыми при выходе из функции. В таблице ниже приведены псевдонимы и разъяснение соглашения о хранении. Например, регистр x1 имеет псевдоним ra, который обозначает адрес возврата, и вызывающая функция должна сохранить значение регистра x1 до вызова функции и восстановить его после возврата функции.
| Регистр | Псевдоним ABI | Описание | Кто сохраняет значение |
|---|---|---|---|
| x0 | zero | Константный ноль zero constant | - |
| x1 | ra | Адрес возврата return address | Вызывающий код |
| x2 | sp | Указатель на стек stack pointer | Вызываемая функция |
| x3 | gp | Глобальный указатель global pointer | Не должен использоваться в пользовательском режиме |
| x4 | tp | Указатель потока thread pointer | Не должен использоваться в пользовательском режиме |
| x5-x7 | t0-t2 | Временные значения temporaries | Вызывающий код |
| x8 | s0 / fp | Сохраняемый регистр / Указатель на фрейм saved / Frame pointer | Вызываемая функция |
| x9 | s1 | Сохраняемый регистр saved register | Вызываемая функция |
| x10-x11 | a0-a1 | Аргументы функций / Возвращаемые значения function args. / return values | Вызывающий код |
| x12-x17 | a2-a7 | Аргументы функций function arguments | Вызывающий код |
| x18-x27 | s2-s11 | Сохраняемые регистры saved registers | Вызываемая функция |
| x28-x31 | t3-t6 | Временные значения temporaries | Вызывающий код |
| pc | - | Программный счётчик program counter | - |
Указатель на стек, глобальный указатель и указатель потока используются на системном уровне. Глобальный указатель и указатель потока не должны быть использованы никем, кроме операционной системы, а указатель на стек может быть использован для сохранения и восстановления значения.
В данном курсе мы рассматриваются только непривилегированные инструкции ISA. Архитектура RISC-V предусматривает три уровня привилегий: пользовательский (User, U), супервизора (Supervisor, S) и аппаратный (Machine, M). Системные вызовы, такие как, например, функции API операционной системы, выполняются либо уровне супервизора, либо на аппаратном уровне. Для использования функций операционной системы в пользовательском режиме необходимо выполнить системные вызовы строго придерживаясь ABI.
Стек - это область памяти, к которой можно обращаться посредством указателя на стек (Stack Pointer, регистр sp). Указатель на стек обычно управляется системой, так что регистр sp на момент запуска программы уже является проинициализированным. Стек в памяти растёт от старших адресов к младшим и заполняется данными сверху вниз.
Для сохранения данных на стеке используется термин "затолкнуть в стек" (push), а для извлечения - "вытолкнуть из стека" (pop).
В примере ниже представим, что регистр sp хранит значение 0xff0:
li t0, 0xbeabdeaf # t0 = 0xbeabdeaf addi sp, sp, -4 # увеличить стек на 4 байта sw t0, 0(sp) # затолкнуть t0 (0xbeabdeaf) в стек
В результате выполнения этой операции указатель на стек хранит значение 0xfec, и значение 0xbeabdeaf сохраняется в памяти по адресу 0xfec. Значение можно извлечь и восстановить значение указателя на стек следующим кодом:
lw t0, 0(sp) # вытолкнуть 0xbeabdeaf из стека в t0 addi sp, sp, 4 # урезать значение стека обратно
Программа начинает выполнение с инструкции, определяемой программным счётчиком. Программный счётчик определяет процесс выполнения программы. Без использования инструкций ветвления и переходов, программа будет исполняться инструкция за инструкцией в том порядке, в котором они расположены в памяти. Использование инструкций ветвления и переходов позволяет контролировать процесс выполнения программ, они позволяют реализовывать операции проверки условий и циклы, которые являются основными действиями в императивном программировании. Использование безусловных переходов (совместно с механизмом сохранения адреса возврата согласно ABI) позволяет использовать функции (процедуры), которые являются основой процедурного программирования. Поскольку эти функциональности определяют то, как мы можем писать код, рассмотрим их реализацию с соответствующими инструкциями языка Assembler.
Цель условия - проверить, является ли проверяемое выражение истинным (true) или ложным (false), и, в зависимости от получаемого результата, выполнить переход по одной из веток кода. Такой механизм также известен как оператор if-then-else.
Реализация if-then-else в языке Assembler реализуется с помощью инструкций условного ветвления. Если условие (не) верно, управление передаётся на соответствующую ветку кода. Зачастую логика оригинального оператора if-then-else переворачивается таким образом, что код веки "else" выполняется до кода ветки "then", как показано в примере:
# если условие истинно, тогда выполнить 'code for then', иначе 'code for else' # если (t0 == t1), то переходим на ветку 'then' beq t0, t1, then # иначе ... # код для else # перепрыгнуть через then-часть j end then: ... # код для then end:
Простой вариант конструкции if-then может быть реализован с использованием одной инструкции ветвления:
# если t0=t1 - выполнить 'code for then', если t0!=t1 - игнорировать 'code for then' bne t0, t1, skip ... # код для then skip: ...
Более сложные условия реализуются вложенными конструкциями.
Циклы также реализуются условными инструкциями. Конструкция while-do повторяет do-часть зацикленно до тех пор, пока проверяемое условие истинно. Для конструкции while-do условие проверяется в начале инструкций, которые необходимо повторить. Если условие не выполняется, зацикливаемый код пропускается (не выполняется). Например:
# инициализация t0 и t1
# цикл должен выполниться 4 раза
li t0, 0
li t1, 4
while:
# если t0 == t1 перейти на end, выйти из цикла
beq t0, t1, end
... # код цикла
# инкремент: t0 = t0 + 1
addi t0, t0, 1
# повторять, пока условие выше (t0 == t1) - истиина
j while
end:
Любая функция реализуется путём сохранения адреса возврата, перехода на код функции, его выполнения и возврата из функции, при этом необходимо позаботиться о том, чтобы использовались корректные регистры для сохранения адреса возврата. Лучшее решение - слепо следовать ABI, которые регламентирует использование регистров как для передачи аргументов, так и для реализации механизма возврата:
# аргумент в a0
li a0, 0
# вызов функции func, адрес возврата в ra
jal ra, func
# код, выполняемый после возврата из func
# ...
# код функции
func:
... # в коде функции нельзя менять ra
# возращаемое значение в a0, в примере - число 1
li a0, 1
# вернуться по адресу из ra
ret
Инструкции, которые сохраняют значения регистров на стеке в начале кода функции называют "пролог"; инструкции, которые выталкивают значения из стека обратно в регистры в конце работы функции называют "эпилог". Пролог и эпилог служат для реализации возможности работы с регистрами внутри тела функции. Простой пример пролога и эпилога показан в следующем разделе в примере использования рекурсии.
Рекурсивная функция - функция, которая вызывает сама себя. Рекурсивные функции обычно используют локальные переменные, значения которых хранятся в регистрах и которые необходимо сохранять перед вызовом функции и восстанавливать после возврата из неё. Рекурсивные функции - источник большого числа ошибок, поскольку при вызове рекурсивных функций происходит постоянное заталкивание значений регистров в стек и выталкивание значений регистров из стека и при отсутствии контроля может произойти переполнение стека в случае, если размера выделенной под стек памяти окажется недостаточно.
Рассмотрим вычисление n-ного элемента последовательности a(n) = a(n-1) + 3 при начальном значении a(0) = 2. Члены последовательности: a(0) = 2, a(1) = a(0) + 3 = 5, a(2) = a(1) + 3 = (a(0)+3) + 3 = 8 и так далее. Вычисляющая функция вызывает сама себя до тех пор, пока не будет достигнуто начальное значение a(0). Ниже приводится пример программы с рекурсивным вызовом функции compute, вычисляющей a(n) до тех пор, при этом считается, что аргумент n должен храниться в регистре a0. Результат возвращается в регистре a0.
.globl _start
_start:
li a0, 5 # вычислить для n = 5
call compute
# выход
li a7, 93
ecall
compute:
# занять место в стеке под регистр ra
# в RV64 регистры 64 бита, следовательно 8 байт
addi sp, sp, -8
sd ra, 0(sp)
# проверка на окончание рекурсии; да? тогда выходим
beq a0, x0, compend
# иначе посчитать a(n-1)
addi a0, a0, -1
# рекурсивный вызов
call compute
# рекурсивно посчитать a(n) = a(n-1) + 3
addi a0, a0, 3
# выйти
j compret
compend:
# если (n ==0), вернуть a(0) = 2
li a0, 2
compret:
# восстановить ra
ld ra, 0(sp)
# осводобить стек
addi sp, sp, 8
ret
Набор инструкций архитектуры (ISA) RISC-V, который обсуждался ранее, является основой для программирования на языке Assembler. Однако не очень удобно реализовывать программу исключительно на уровне ISA. По этой причине компилятор языка Assembler для RISC-V предоставляет различные псевдо-инструкции и директивы, которые облегчают разработку программ.
К концу этой лекции вы будете:
Первое, что облегчает разработку кода на языке Assmebler, это метки. Метки служат для идентификации мест в программном коде, они используются в качестве смещений и непосредственных значений адресов.
Под меткой понимается символьная последовательность, оканчивающаяся двоеточием. Также существует особая разновидность меток - числовые метки, - это локальные метки, которые могут быть заданы с суффиксами 'f' для перехода вперёд и 'b' для перехода назад.
Пример показан ниже:
1:
j 1b
j 1f
1:
Первый переход выполнится на метку 1 выше кода, потому что указан суффикс 'b', второй переход выполнится на метку 1 ниже кода, потому что указан суффикс 'f'.
Компилятор языка Assembler преобразует метки в непосредственные значения адресов.
Рассмотрим следующий пример:
addi x1, x0, 1
beq x1,x0, there
addi x1, x1, 1
there:
addi x1, x1, 1
Компилятор переводит программу в машинные коды и вставляет смещения, ассоциируемые с метками:
0x0: 00100093 addi x1 x0 1 0x4: 00008463 beq x1 x0 8 <there> 0x8: 00108093 addi x1 x1 1 0000000c <there>: 0xc: 00108093 addi x1 x1 1
Если мы посмотрим на базовый набор RV32I ISA, можно увидеть, что адресация по всему адресному пространству может потребовать более одной инструкции: инструкции auipc и lui используются для загрузки старшей части непосредственного значения адреса в регистр, а для загрузки младшей части в регистр используется инструкция addi. Таким образом, требуется некий механизм разделения адресов меток на старшую и младшую составляющие.
Функции переразмещения %hi(symbol) и %lo(symbol) разделяют адрес метки на младшую и старшую части. Компоновщик (линковщик) размещает программу в памяти и назначает адреса соответствующим символам. Таким образом, код:
lui x1, %hi(there) #absolute higher 20 bits
addi x1, x1, %lo(there) #absolute lower 12 bits
there:
транслируется в код:
0x10078: lui x1,0x10 0x1007c: addi x1,x1,128 # 10080 <there>
Адрес метки there после размещения имеет адрес 0x10080. Функции %hi и %lo используются для задания корректных адресов в регистре x1.
Ещё одним примером функций переразмещения являются функции адресации, относительной значения программного счётчика. К ним относятся функции %pcrel_hi(symbol) и %pcrel_lo(label), которые работают вместе с инструкциями auipc и addi. Однако, поскольку адресация относительная, они используются отличным от глобальной адресации способом. Так, компилятор сгенерирует из такого кода:
1: auipc x1, %pcrel_hi(there)#relative higher 20 bits
addi x1, x1, %pcrel_lo(1b)#relative lower 12 bits label 1 backwards
there:
следующий код:
1: 0x10078: auipc x1,0x0 0x1007c: addi x1,x1,8 # 10080 <there>
В этом примере также адрес метки there равен 0x10080. Инструкция auipc должна добавить 0x0 к значению программного счётчика 0x10078 для получения старшей части адреса метки 'there'. Результат инструкции auipc - число 0x10078 в регистре x1. Инструкция addi добавляет число 8 к регистру x1, поскольку 8 - это младшая часть разницы адресов меток there и 1.
Однако работа с функциями переразмещения вручную - работа громоздкая, поэтому чаще всего вместо них используют псевдоинструкции.
Директивы языка предоставляют компилятору информацию о том, как должен интерпретироваться текст, следующий за директивой: это данные или код. Все директивы начинаются с точки.
В таблице 4.1 ниже приводится перечень наиболее часто употребляемых директив:
| Директива | Аргументы | Описание |
|---|---|---|
| .text | Переключиться на секцию .text | |
| .data | Переключиться на секцию .data | |
| .rodata | Переключиться на секцию .rodata | |
| .bss | Переключиться на секцию .bss | |
| .section | .text, .data, .rodata, .bss | Переключиться на секцию, задаваемую аргументом |
| .equ | name, value | определить имя для константного значения |
| .ascii | "string" | начало строки без терминального нуля |
| .asciz | "string" | начало строки с терминальным нулём |
| .string | "string" | то же самое, что и .asciz |
| .byte | expression [,expression]* | 8-битные значения, разделённые запятыми |
| .half | expression [,expression]* | 16-битные значения, разделённые запятыми |
| .word | expression [,expression]* | 32-битные значения, разделённые запятыми |
| .dword | expression [,expression]* | 64-битные значения, разделённые запятыми |
| .zero | integer | байты, заполненные нулями |
| .align | integer | значение, выровненное по заданной степени 2 |
| .globl | symbol_name | внести symbol_name в таблицу символов |
Эти директивы очень важны, когда пишется программа, которая собирается для исполнения внутри некоторого окружения, например, встроенной системы или операционной системы.
Директива .text предоставляет компилятору информацию, что следующий текст является набором машинных инструкций. Директива .data говорит о том, что начинается область с объявлением данных, которые должны быть проинициализированы и могут меняться в процессе выполнения программы. Директива .rodata предназначена для объявления инициализированных данных, доступных только для чтения (константные значения). Директива .bss служит для объявления модифицируемых, но не инициализированных данных. Все эти области памяти могут быть объявлены с помощью директивы .section с последующим указанием типа секции (.text, .data, .rodata и т.д.).
Имя для константного значения может быть задано с помощью директивы equ. Данные для ASCII-строк задаются директивами .ascii, .asciz, и .string. Разница между ними в том, что директивы .asciz и .string добавляют терминальный нулевой байт в конце строки, а директива .ascii - нет. Текст располагается следом за директивной.
Директивы .byte, .half, .word, и .dword служат для задания одного или более числовых значений, которые указываются следом за директивами. Указанные директивы отличаются размером (в байтах) описываемых значений.
Директива .zero объявляет массив байт, которые заполняются нулями, при этом первое число, идущее за директивой .zero задаёт количество таких элементов.
Директива .align выравнивает в памяти данные, следующие за числом, задаваемым как 2 в степени аргумента параметра. Выравнивание достигается путём вставки нулевых байтов.
Директива .globl symbol_name определяет идентификатор symbol_name, который обычно задаётся как значение, видимое компоновщика. Символ _start требуется для задания точки входа в программу. В данном руководстве мы не рассматриваем синтаксис скриптов компоновщика, просто используем скрипт по умолчанию, который поставляется вместе с набором инструментов сборки Linux Gnu toolchain. Тем не менее, скрипты компоновщика очень важны, когда идёт разработка для различных окружений или встраиваемых систем, поскольку такие скрипты могут определять расположение программы в памяти устройств.
Также используется такая конструкция, как точка (.), которая подменяет текущий адрес.
Рассмотрим пример. Символ решётка '#' задаёт комментарий:
# define exit as 93
.equ exit, 93
# program code
.section .text
# export _start for linker
.globl _start
_start:
li a7, exit
ecall
# data: init one word (16-bit value) with 1 and read/write
.section .data
counter:
.word 1
# rodata: constant text string
.section .rodata
text_begin:
.asciz "Text"
text_end:
# current address minus address of text_begin = length of text
.byte .-text_begin
# non initialized block with same size as the text
.section .bss
# start next part by address aligned to multiple of 2^2 = 4
.align 2
copy_begin:
.zero text_end-text_begin
Если этот пример сохранить в файле с названием example.s, то его можно будет собрать в исполняемый модуль в формате ELF (Executable and Linkable Format, ELF), и проанализировать результат сборки и компоновки с помощью утилиты objdump:
riscv64-linux-gnu-as -o example.o example.s riscv64-linux-gnu-ld -o example example.o riscv64-linux-gnu-objdump -f -d -Mno-aliases,numeric example example: file format elf64-littleriscv architecture: riscv:rv64, flags 0x00000112: EXEC_P, HAS_SYMS, D_PAGED start address 0x00000000000100e8 Disassembly of section .text: 00000000000100e8 <_start>: 100e8: 05d00893 addi x17,x0,93 100ec: 00000073 ecall
Команда objdump с ключами -f -d показывает дизассемблированный файл (-d) с заголовком файла (-f). Опции, указанные после ключа -Mno-aliases,numeric говорят, что утилита objdump должна печатать только базовые инструкции и не выводить псевдоинструкции и печатать номера регистров, а не их имена в синтаксисе ABI (что будет рассмотрено позже). В листинге показан только программный код, содержащийся в секции.text. После указания ключа -t , утилита objdump печатает все секции исполняемого файла:
riscv64-linux-gnu-objdump -t example example: file format elf64-littleriscv SYMBOL TABLE: 00000000000100e8 l d .text 0000000000000000 .text 00000000000100f0 l d .rodata 0000000000000000 .rodata 00000000000110f6 l d .data 0000000000000000 .data 00000000000110fc l d .bss 0000000000000000 .bss 0000000000000000 l d .riscv.attributes 0000000000000000 .riscv.attributes 0000000000000000 l df *ABS* 0000000000000000 example.o 000000000000005d l *ABS* 0000000000000000 exit 00000000000100f6 l .data 0000000000000000 counter 00000000000100f0 l .rodata 0000000000000000 text_begin 00000000000100f5 l .rodata 0000000000000000 text_end 00000000000110fc l .bss 0000000000000000 copy_begin 00000000000118f6 g *ABS* 0000000000000000 __global_pointer$ 00000000000110fa g .data 0000000000000000 __SDATA_BEGIN__ 00000000000100e8 g .text 0000000000000000 _start 0000000000011108 g .bss 0000000000000000 __BSS_END__ 00000000000110fa g .bss 0000000000000000 __bss_start 00000000000110f6 g .data 0000000000000000 __DATA_BEGIN__ 00000000000110fa g .data 0000000000000000 _edata 0000000000011108 g .bss 0000000000000000 _end
Секция .section .text начинается с адреса 0x100e8, секция .rodata - с адреса 0x100f0, секция .data - с адреса 0x110f6, и секция .bss - с адреса 0x110fc. Каждой метке присваиваются адреса, то есть метка copy_begin располагается в секции .bss по адресу 0x110fc.
В итоге можно просмотреть содержимое бинарного файла в шестнадцатеричном формате, задав утилите objdump ключи -F -s, где ключ -F нужен для вывода дополнительной информации о файле:
riscv64-linux-gnu-objdump -F -s example example: file format elf64-littleriscv Contents of section .text: (Starting at file offset: 0xe8) 100e8 9308d005 73000000 ....s... Contents of section .rodata: (Starting at file offset: 0xf0) 100f0 54657874 0005 Text.. Contents of section .data: (Starting at file offset: 0xf6) 110f6 01000000 .... Contents of section .riscv.attributes: (Starting at file offset: 0xfa) 0000 412d0000 00726973 63760001 23000000 A-...riscv..#... 0010 05727636 34693270 305f6d32 70305f61 .rv64i2p0_m2p0_a 0020 3270305f 66327030 5f643270 3000 2p0_f2p0_d2p0.
Первые два двойных слова в секции .text являются представлением машинного кода программы. Следует обратить внимание, что в выводе дизассемблера порядок байт инвертирован, то есть написано 9308d005 вместо 05d00893. Показанное содержимое отображает то, как данные хранятся в памяти (или в файле), а не то, как 32-битное значение или инструкция составлены в формате RISC-V little endian. В формате представления little endian 32-битная инструкция составляется таким образом, что младшие адреса занимают старшие байты, а старшие адреса - младшие байты. Таким образом, четыре байта со значениями 0x93 0x08 0xd0 0x05 , расположенные по адресу 0x100e8, берутся и размещаются в 32-битное представление как 0x05d00893. Напротив, формат Big endian предписывает иной порядок следования байт в памяти.
Секция .rodata начинается после двух 32-битных инструкций по адресу 0x100f0 (что соответствует 8-ми байтному смещению от начала программы: 0x100e8 + 0x8) и содержит байты 0x54657874 0x0005. Значения 0x54 0x65 0x78 0x74 являются побайтовым представлением в таблице ASCII-символов четырёх символов: 'T' 'e' 'x' 't'. Они заканчиваются нулевым байтом (0x00) и байтом длины, равным 0x05 - это длина текста включая терминальный ноль. Затем следует секция .data, которая содержит 32-битное представление числа 1, которое кодируется в формате Little Endian. Следующая секция описывает атрибуты RISC-V.
Секция .bss не содержит данных, поэтому её содержимое не отображается. Тем не менее в таблице символов было видно, что секция .bss начинается с адреса 0x110fc, который является следующим адресом за адресом секции .data (заканчивается по адресу 0x110fa) с добавлением двух байт для выравнивания в памяти до 4 байт.
В итоге мы получили представление о том, как компилятор и компоновщик располагают данные в формате ELF.
Компилятор языка Assembler транслирует программу в машинный код. В рамках курса в основном рассматривается GNU assembler. Компилятор понимает не только инструкции RISC-V, определяемые стандартом ISA, но также понимает псевдо-инструкции и псевдо-директивы.
Псевдо-инструкции являются частью языка Assembler и могут быть странслированы в машинный код. Однако, в отличие от инструкций из набора ISA, псевдо-инструкции гораздо проще использовать. Обычно псевдо-инструкция может быть странслирована в более чем одну базовую инструкцию набора ISA. Кроме того, псевдо-инструкции решают проблему использования функций ручного перемещения памяти. В следующих таблицах показаны псевдо-инструкции и то, в какие базовые инструкции они преобразуются.
| Псевдо-инструкция | Набор базовых инструкций | Описание |
|---|---|---|
| la rd, symbol | auipc rd, symbol[31:12] addi rd, symbol[11:0] | Загрузить адрес (не позиционно-независимый код - non-PIC) |
| la rd, symbol | auipc rd, symbol@GOT[31:12] l{w|d} rd, symbol[11:0](rd) | Загрузить адрес (позиционно-независимый код - PIC) |
| lla ra, symbol | auipc rd, symbol[31:12] addi rd, rd, symbol[11:0] | Загрузить локальный адрес |
| lga rd, symbol | auipc rd, symbol@GOT[31:12] l{w|d} rd, symbol@GOT[11:0](rd) | Загрузить глобальный адрес |
| l{b|h|w|d} rd, symbol | auipc rd, symbol[31:12] l{b|h|w|d} rd, symbol[11:0](rd) | Загрузить глобальное значение |
| s{b|h|w|d} rs, symbol, rd | auipc rd, symbol[31:12] s{b|h|w|d} rs, symbol[11:0](rd) | Сохранить глобальное значение |
| nop | addi x0, x0, 0 | Нет операции |
| li rd, imm | Различные варианты инструкций | Загрузить непосредственное значение |
| mv rd, rs | addi rd, rs, 0 | Скопировать значение регистра |
| not rd, rs | xori rd, rs, -1 | дополнение до 1 (инверсия битов) |
| neg rd, rs | sub rd, x0, rs | дополнение до 2 (инверсия битов с добавлением 1) |
| negw rd, rs | subw rd, x0, rs | дополнение до 2 (слово) |
Положение позиционно-независимого кода (Position Independent Code, PIC) в адресном пространстве зависит только от настроек компоновщика (а не от архитектуры) и полезно для генерации бинарного кода библиотек, которые могут использоваться другими программами. Такой код обычно используется совместно с глобальной таблицей смещений (Global Offset Table, GOT), которая хранится в исполняемом модуле и позволяет операционной системе загружать библиотеки при старте вызывающих программ по различным адресам памяти. Тема, связанная с этим вопросом, является достаточно сложной и не будет рассмотрена в рамках данного курса.
Для самостоятельно изучения вопросов, связанных с форматом ELF, можно посоветовать ознакомиться с документом RISC-V ELF Specification, доступным по адресу https://github.com/riscv-non-isa/riscv-elf-psabi-doc/blob/master/riscv-elf.adoc.
Отдельно стоит отметить, что псевдо-инструкция lga не поддерживается в наборе инструментов GNU toolchain версии 2.39 и ниже.
Рассмотрим на примере следующего кода, как он транслируется с точки зрения компилятора языка Assembler:
.text
.globl _start
_start:
la x1, counter #load address of counter
addi x1, x1, 4 #go to next word address += 4
lw x2, 0(x1) #load value from address, the 2
li x1, 2
lw x3, counter #load word from address counter, the 0
add x3, x2, x1 #add: x3 = x2 + x1
sw x3, counter, x2 #save x3, use x2 for address
.data
counter:
.word 0, 2
Disassembly of section .text:
00000000000100e8 <_start>:
100e8: 00001097 auipc x1,0x1
100ec: 02808093 addi x1,x1,40 # 11110 <__DATA_BEGIN__>
100f0: 00408093 addi x1,x1,4
100f4: 0000a103 lw x2,0(x1)
100f8: 00200093 addi x1,x0,2
100fc: 00001197 auipc x3,0x1
10100: 0141a183 lw x3,20(x3) # 11110 <__DATA_BEGIN__>
10104: 001101b3 add x3,x2,x1
10108: 00001117 auipc x2,0x1
1010c: 00312423 sw x3,8(x2) # 11110 <__DATA_BEGIN__>
Адрес метки counter в памяти равен 0b11110. Псевдо-инструкции транслируются в базовые инструкции следующим образом:
la x1, counter -> auipc x1,0x1; addi x1, x1, 40 li x1, 2 -> addi x1, x0, 2 lw x3, counter -> auipc x3, 0x1; lw x3, 20(x3) sw x3, counter, x2 -> auipc x2, 0x1; sw x3, 8(x2)
Таблица 4.3 ниже показывает псевдо-инструкции и базовые инструкции для расширения и условной установки битов:
| Псевдо-инструкция | Набор базовых инструкций | Описание |
|---|---|---|
| sext.{b|h|w} rd, rs | Может быть различный набор инструкций | sign extend |
| zext.{b|h|w} rd, rs | Может быть различный набор инструкций | zero extend |
| seqz rd, rs | sltiu rd, rs, 1 | rd = (rs == 0)? 1:0 |
| snez rd, rs | sltu rd, x0, rs | rd = (rs != 0)? 1:0 |
| sltz rd, rs | slt rd, rs, x0 | rd = (rs < 0)? 1:0 |
| sgtz rd, rs | slt rd, x0, rs | rd = (rs > 0)? 1:0 |
В таблице 4.4 приведены псевдо-инструкции для условного ветвления. Первая часть из них упрощает ветвление с использованием операций сравнения с нулём без использования регистра x0. Вторая часть предлагает удобные функции для сравнения за счёт переупорядочивания параметров базовых инструкций, например, вызов команды ble rs, rt, imm эквивалентен вызову bge rt, rs, imm.
| Псевдо-инструкция | Набор базовых инструкций | Описание |
|---|---|---|
| beqz rs, imm | beq rs, x0, imm | if (rs == 0) PC+=imm |
| bnez rs, imm | bne rs, x0, imm | if (rs != 0) PC+=imm |
| blez rs, imm | bge x0, rs, imm | if (rs <= 0) PC+=imm |
| bgez rs, imm | bge rs, x0, imm | if (rs >= 0) PC+=imm |
| bltz rs, imm | blt rs, x0, imm | if (rs < 0) PC+=imm |
| bgtz rs, imm | blt x0, rs, imm | if (rs > 0) PC+=imm |
| bgt rs, rt, imm | blt rt, rs, imm | if (rs > rt) PC+=imm |
| ble rs, rt, imm | bge rt, rs, imm | if (rs <= rt) PC+=imm |
| bgtu rs, rt, imm | bltu rt, rs, imm | if (rs > rt) PC+=imm, unsign. |
| bleu rs, rt, imm | bgeu rt, rs, imm | if (rs <= rt) PC+=imm, unsign. |
Наконец, псевдо-инструкции для безусловного перехода упрощают программирование за счёт того, что в их составе не нужно указывать имена регистров. А переход на дальние адреса реализуется командами call и tail.
| Псевдо-инструкция | Набор базовых инструкций | Описание |
|---|---|---|
| j imm | \jal x0, imm | PC += imm |
| jal imm | jal x1, imm | x1 = PC+4; PC += imm |
| jr rs | jalr x0, rs, 0 | PC = rs |
| jalr rs | jalr x1, rs, 0 | x1 = PC+4; PC = rs |
| ret | jalr x0, x1, 0 | PC = x1 |
| call imm | auipc x6, imm[31:12] jalr x1, x6, imm[11:0] | x1 = PC+4; PC = imm |
| tail imm | auipc x6, imm[31:12] jalr x0, x6, imm[11:0] | PC = imm |
До сих пор обращение к регистрам происходило по их именам. Однако, существует бинарный интерфейс для приложений (Application Binary Interface, ABI), подробнее про него можно почитать по ссылке: https://github.com/riscv-non-isa/riscv-elf-psabi-doc. Он представляет собой ряд правил, описывающих, как и какие регистры должны быть использованы для решения задач. Среди прочего, ABI предоставляет псевдонимы в том числе и для регистров.
Кроме того, одним из соглашений, регламентируемых ABI, является то, как устроен механизм сохранения значений регистров при переходе в подпрограмму: в момент вызова адрес возврата сохраняется в регистре, а программный счетчик устанавливается на начало функции. Функции состоит из инструкций, которые, собственно, и выполняются функцией; после выполнения функции управление передаётся инструкции, следующей следом за инструкцией вызова. Чтобы вернуться, функция использует сохранённый адрес возврата, а значит должно быть соглашение о том, какой из регистров используется для хранения адреса возврата в случае, если над программой работает большое число программистов. Кроме того, важно соглашение о том, какие регистры можно считать измененными или неизменяемыми при выходе из функции. В таблице ниже приведены псевдонимы и разъяснение соглашения о хранении. Например, регистр x1 имеет псевдоним ra, который обозначает адрес возврата, и вызывающая функция должна сохранить значение регистра x1 до вызова функции и восстановить его после возврата функции.
| Регистр | Псевдоним ABI | Описание | Кто сохраняет значение |
|---|---|---|---|
| x0 | zero | Константный ноль zero constant | - |
| x1 | ra | Адрес возврата return address | Вызывающий код |
| x2 | sp | Указатель на стек stack pointer | Вызываемая функция |
| x3 | gp | Глобальный указатель global pointer | Не должен использоваться в пользовательском режиме |
| x4 | tp | Указатель потока thread pointer | Не должен использоваться в пользовательском режиме |
| x5-x7 | t0-t2 | Временные значения temporaries | Вызывающий код |
| x8 | s0 / fp | Сохраняемый регистр / Указатель на фрейм saved / Frame pointer | Вызываемая функция |
| x9 | s1 | Сохраняемый регистр saved register | Вызываемая функция |
| x10-x11 | a0-a1 | Аргументы функций / Возвращаемые значения function args. / return values | Вызывающий код |
| x12-x17 | a2-a7 | Аргументы функций function arguments | Вызывающий код |
| x18-x27 | s2-s11 | Сохраняемые регистры saved registers | Вызываемая функция |
| x28-x31 | t3-t6 | Временные значения temporaries | Вызывающий код |
| pc | - | Программный счётчик program counter | - |
Указатель на стек, глобальный указатель и указатель потока используются на системном уровне. Глобальный указатель и указатель потока не должны быть использованы никем, кроме операционной системы, а указатель на стек может быть использован для сохранения и восстановления значения.
В данном курсе мы рассматриваются только непривилегированные инструкции ISA. Архитектура RISC-V предусматривает три уровня привилегий: пользовательский (User, U), супервизора (Supervisor, S) и аппаратный (Machine, M). Системные вызовы, такие как, например, функции API операционной системы, выполняются либо уровне супервизора, либо на аппаратном уровне. Для использования функций операционной системы в пользовательском режиме необходимо выполнить системные вызовы строго придерживаясь ABI.
Стек - это область памяти, к которой можно обращаться посредством указателя на стек (Stack Pointer, регистр sp). Указатель на стек обычно управляется системой, так что регистр sp на момент запуска программы уже является проинициализированным. Стек в памяти растёт от старших адресов к младшим и заполняется данными сверху вниз.
Для сохранения данных на стеке используется термин "затолкнуть в стек" (push), а для извлечения - "вытолкнуть из стека" (pop).
В примере ниже представим, что регистр sp хранит значение 0xff0:
li t0, 0xbeabdeaf # t0 = 0xbeabdeaf addi sp, sp, -4 # увеличить стек на 4 байта sw t0, 0(sp) # затолкнуть t0 (0xbeabdeaf) в стек
В результате выполнения этой операции указатель на стек хранит значение 0xfec, и значение 0xbeabdeaf сохраняется в памяти по адресу 0xfec. Значение можно извлечь и восстановить значение указателя на стек следующим кодом:
lw t0, 0(sp) # вытолкнуть 0xbeabdeaf из стека в t0 addi sp, sp, 4 # урезать значение стека обратно
Программа начинает выполнение с инструкции, определяемой программным счётчиком. Программный счётчик определяет процесс выполнения программы. Без использования инструкций ветвления и переходов, программа будет исполняться инструкция за инструкцией в том порядке, в котором они расположены в памяти. Использование инструкций ветвления и переходов позволяет контролировать процесс выполнения программ, они позволяют реализовывать операции проверки условий и циклы, которые являются основными действиями в императивном программировании. Использование безусловных переходов (совместно с механизмом сохранения адреса возврата согласно ABI) позволяет использовать функции (процедуры), которые являются основой процедурного программирования. Поскольку эти функциональности определяют то, как мы можем писать код, рассмотрим их реализацию с соответствующими инструкциями языка Assembler.
Цель условия - проверить, является ли проверяемое выражение истинным (true) или ложным (false), и, в зависимости от получаемого результата, выполнить переход по одной из веток кода. Такой механизм также известен как оператор if-then-else.
Реализация if-then-else в языке Assembler реализуется с помощью инструкций условного ветвления. Если условие (не) верно, управление передаётся на соответствующую ветку кода. Зачастую логика оригинального оператора if-then-else переворачивается таким образом, что код веки "else" выполняется до кода ветки "then", как показано в примере:
# если условие истинно, тогда выполнить 'code for then', иначе 'code for else' # если (t0 == t1), то переходим на ветку 'then' beq t0, t1, then # иначе ... # код для else # перепрыгнуть через then-часть j end then: ... # код для then end:
Простой вариант конструкции if-then может быть реализован с использованием одной инструкции ветвления:
# если t0=t1 - выполнить 'code for then', если t0!=t1 - игнорировать 'code for then' bne t0, t1, skip ... # код для then skip: ...
Более сложные условия реализуются вложенными конструкциями.
Циклы также реализуются условными инструкциями. Конструкция while-do повторяет do-часть зацикленно до тех пор, пока проверяемое условие истинно. Для конструкции while-do условие проверяется в начале инструкций, которые необходимо повторить. Если условие не выполняется, зацикливаемый код пропускается (не выполняется). Например:
# инициализация t0 и t1
# цикл должен выполниться 4 раза
li t0, 0
li t1, 4
while:
# если t0 == t1 перейти на end, выйти из цикла
beq t0, t1, end
... # код цикла
# инкремент: t0 = t0 + 1
addi t0, t0, 1
# повторять, пока условие выше (t0 == t1) - истиина
j while
end:
Любая функция реализуется путём сохранения адреса возврата, перехода на код функции, его выполнения и возврата из функции, при этом необходимо позаботиться о том, чтобы использовались корректные регистры для сохранения адреса возврата. Лучшее решение - слепо следовать ABI, которые регламентирует использование регистров как для передачи аргументов, так и для реализации механизма возврата:
# аргумент в a0
li a0, 0
# вызов функции func, адрес возврата в ra
jal ra, func
# код, выполняемый после возврата из func
# ...
# код функции
func:
... # в коде функции нельзя менять ra
# возращаемое значение в a0, в примере - число 1
li a0, 1
# вернуться по адресу из ra
ret
Инструкции, которые сохраняют значения регистров на стеке в начале кода функции называют "пролог"; инструкции, которые выталкивают значения из стека обратно в регистры в конце работы функции называют "эпилог". Пролог и эпилог служат для реализации возможности работы с регистрами внутри тела функции. Простой пример пролога и эпилога показан в следующем разделе в примере использования рекурсии.
Рекурсивная функция - функция, которая вызывает сама себя. Рекурсивные функции обычно используют локальные переменные, значения которых хранятся в регистрах и которые необходимо сохранять перед вызовом функции и восстанавливать после возврата из неё. Рекурсивные функции - источник большого числа ошибок, поскольку при вызове рекурсивных функций происходит постоянное заталкивание значений регистров в стек и выталкивание значений регистров из стека и при отсутствии контроля может произойти переполнение стека в случае, если размера выделенной под стек памяти окажется недостаточно.
Рассмотрим вычисление n-ного элемента последовательности a(n) = a(n-1) + 3 при начальном значении a(0) = 2. Члены последовательности: a(0) = 2, a(1) = a(0) + 3 = 5, a(2) = a(1) + 3 = (a(0)+3) + 3 = 8 и так далее. Вычисляющая функция вызывает сама себя до тех пор, пока не будет достигнуто начальное значение a(0). Ниже приводится пример программы с рекурсивным вызовом функции compute, вычисляющей a(n) до тех пор, при этом считается, что аргумент n должен храниться в регистре a0. Результат возвращается в регистре a0.
.globl _start
_start:
li a0, 5 # вычислить для n = 5
call compute
# выход
li a7, 93
ecall
compute:
# занять место в стеке под регистр ra
# в RV64 регистры 64 бита, следовательно 8 байт
addi sp, sp, -8
sd ra, 0(sp)
# проверка на окончание рекурсии; да? тогда выходим
beq a0, x0, compend
# иначе посчитать a(n-1)
addi a0, a0, -1
# рекурсивный вызов
call compute
# рекурсивно посчитать a(n) = a(n-1) + 3
addi a0, a0, 3
# выйти
j compret
compend:
# если (n ==0), вернуть a(0) = 2
li a0, 2
compret:
# восстановить ra
ld ra, 0(sp)
# осводобить стек
addi sp, sp, 8
ret
Для получения официальных документов о завершении программы дополнительного профессионального образования (удостоверения о повышении квалификации, дипломов о профессиональной переподготовке и MBA) необходимо предоставить:
Внимание! Вы можете не заказывать доставку бумажной версии официального документы, а скачать его в электронном виде и распечатать самостоятельно. Информация о выданном документе в течение 1 месяца загружается в Федеральную информационную систему «Федеральный реестр сведений о документах об образовании и (или) о квалификации, документах об обучении» - ФИС ФРДО.
Доступ на новый сайт осуществляется с использованием адреса электронной почты, который был указан вами при регистрации на "старом". Мы постарались перенести все ваши данные с прежнего ресурса, однако не исключена вероятность потери части информации.
При возникновении проблемы со входом, воспользуйтесь функцией сброса пароля
Если вы обнаружите несоответствия, пожалуйста, сообщите нам.