Основы программирования на языке Assembler под архитектуру RISC-V

Программирование на языке Assembler

Разбить на страницы
Показывать лекцию целиком

Набор инструкций архитектуры (ISA) RISC-V, который обсуждался ранее, является основой для программирования на языке Assembler. Однако не очень удобно реализовывать программу исключительно на уровне ISA. По этой причине компилятор языка Assembler для RISC-V предоставляет различные псевдо-инструкции и директивы, которые облегчают разработку программ.

К концу этой лекции вы будете:

  • понимать и уметь использовать метки и директивы;
  • понимать и уметь использовать псевдо-инструкции;
  • знать, почему и как использовать бинарный интерфейс приложений (application binary interface, ABI);
  • уметь реализовывать поток выполнения программ.
  • Метки в языке Assembler

    Первое, что облегчает разработку кода на языке 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.

    Однако работа с функциями переразмещения вручную - работа громоздкая, поэтому чаще всего вместо них используют псевдоинструкции.

    Директивы языка Assembler

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

    В таблице 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. Тем не менее, скрипты компоновщика очень важны, когда идёт разработка для различных окружений или встраиваемых систем, поскольку такие скрипты могут определять расположение программы в памяти устройств.

    Также используется такая конструкция, как точка (.), которая подменяет текущий адрес.

    Пример на директивы языка Assemler

    Рассмотрим пример. Символ решётка '#' задаёт комментарий:

    # 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

    Бинарный интерфейс для приложений (ABI) и пользовательский режим

    До сих пор обращение к регистрам происходило по их именам. Однако, существует бинарный интерфейс для приложений (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
    

    Контрольные вопросы

  • Что такое метки?
  • Что происходит с метками при ассемблировании?
  • Для чего используются функции переразмещения?
  • Для чего предназначена секция .bss?
  • Как определить точку входа программы?
  • Что такое псевдо-инструкция?
  • Какие из перечисленных инструкций являются псевдо-инструкциями? sub, nop, subw, ecall, mv, sw, slt.
  • В чём преимущество позиционно-независимого кода?
  • Что специфицирует соглашение о вызовах?
  • В чём отличие пользовательского уровня, аппаратного и уровня супервизора?
  • Для чего используется стек?
  • Как реализовать if-then конструкцию на языке ассемблера?
  • Есть ли специальные инструкции для организации циклов?
  • Можно ли в коде функции менять значение регистра x1?
  • Чем принципиально отличается условный и безусловный переход?
  • Страницы:

    Набор инструкций архитектуры (ISA) RISC-V, который обсуждался ранее, является основой для программирования на языке Assembler. Однако не очень удобно реализовывать программу исключительно на уровне ISA. По этой причине компилятор языка Assembler для RISC-V предоставляет различные псевдо-инструкции и директивы, которые облегчают разработку программ.

    К концу этой лекции вы будете:

  • понимать и уметь использовать метки и директивы;
  • понимать и уметь использовать псевдо-инструкции;
  • знать, почему и как использовать бинарный интерфейс приложений (application binary interface, ABI);
  • уметь реализовывать поток выполнения программ.
  • Метки в языке Assembler

    Первое, что облегчает разработку кода на языке 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.

    Однако работа с функциями переразмещения вручную - работа громоздкая, поэтому чаще всего вместо них используют псевдоинструкции.

    Директивы языка Assembler

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

    В таблице 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. Тем не менее, скрипты компоновщика очень важны, когда идёт разработка для различных окружений или встраиваемых систем, поскольку такие скрипты могут определять расположение программы в памяти устройств.

    Также используется такая конструкция, как точка (.), которая подменяет текущий адрес.

    Пример на директивы языка Assemler

    Рассмотрим пример. Символ решётка '#' задаёт комментарий:

    # 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

    Бинарный интерфейс для приложений (ABI) и пользовательский режим

    До сих пор обращение к регистрам происходило по их именам. Однако, существует бинарный интерфейс для приложений (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
    

    Контрольные вопросы

  • Что такое метки?
  • Что происходит с метками при ассемблировании?
  • Для чего используются функции переразмещения?
  • Для чего предназначена секция .bss?
  • Как определить точку входа программы?
  • Что такое псевдо-инструкция?
  • Какие из перечисленных инструкций являются псевдо-инструкциями? sub, nop, subw, ecall, mv, sw, slt.
  • В чём преимущество позиционно-независимого кода?
  • Что специфицирует соглашение о вызовах?
  • В чём отличие пользовательского уровня, аппаратного и уровня супервизора?
  • Для чего используется стек?
  • Как реализовать if-then конструкцию на языке ассемблера?
  • Есть ли специальные инструкции для организации циклов?
  • Можно ли в коде функции менять значение регистра x1?
  • Чем принципиально отличается условный и безусловный переход?
  • Вернуться к учебному плану