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

Использование системных библиотек

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

В этой Лекции приводятся некоторые примеры того, как писать программы под ОС Linux и использованием взаимодействия с кодом на языке Assembler. Рассмотренные действия выполнятся на компьютере с Intel-архитектурой с установленной ОС Linux и установленным набором инструментов для кросс-компиляции. Исполняемые файлы запускаются под эмулятором Qemu в режиме пользователя. Несмотря на это, программы могут быть собраны и запущены на процессоре с архитектурой RISC-V как это было показано в Лекции 2.

После изучения этой лекции вы должны быть способны:

  • писать программы на языке Assembler с использованием системных вызовов;
  • писать программы на языке Assembler с использованием библиотек, написанных на C.
  • Использование системы и библиотек

    Взаимодействие с системой

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

    ОС Linux предоставляет интерфейс для системных вызовов. Стоит отметить, что несмотря на то, что приведённые примеры были выполнены и проверены в ОС Debian, они должны работать и в других версия ОС Linux.

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

    man syscall.2 
    

    И посмотрите на строки и столбцы с текстом "riscv":

    Arch/ABI    Instruction           System  Ret  Ret  Error    Notes
                                      call #  val  val2
    -------------------------------------------------------------------
    riscv       ecall                 a7      a0   a1   -
    
    ...
    
    Arch/ABI      arg1  arg2  arg3  arg4  arg5  arg6  arg7  Notes
    --------------------------------------------------------------
    riscv         a0    a1    a2    a3    a4    a5    -
    

    Видно, что регистр a7 используется для задания номера системного вызова, регистры a0 и a1 используются для хранения возвращаемых значений, а аргументы передаются через регистры с a0 по a5.

    Есть документация на различные системные вызовы:

    man syscalls.2 
    
    NAME
           syscalls - Linux system calls
    
    SYNOPSIS
           Linux system calls.
    
    DESCRIPTION
           The system call is the fundamental interface between an application and the Linux kernel.
    ...
    System call                 Kernel        Notes
    ------------------------------------------------------------------------
    write(2)                    1.0
    ...
    

    В качестве примера рассмотрим системный вызов для вывода значений write. Чтобы получить информацию о параметрах системного вызова write, необходимо ввести в консоли:

    man write.2
    
    NAME
           write - write to a file descriptor
    
    SYNOPSIS
           #include <unistd.h>
    
           ssize_t write(int fd, const void *buf, size_t count);
    
    DESCRIPTION
           write()  writes  up to count bytes from the buffer starting at buf to the file referred to by the file descriptor fd.
    ...
    

    Системный вызов write позволяет выводить информацию в поток стандартного вывода. Файловый дескриптор со значением 1 является стандартным выводом, что, во-первых, является общим соглашением, а во-вторых, описано в заголовочном файле /usr/include/unistd.h.

    Но эта информация - для программистов на языке C. Для программистов на языке Assembler необходимо знать численное значение номера системного вызова. В файле /usr/include/asm-generic/unistd.h содержится следующая строчка, задающая идентификатор для системного вызова:

    grep write /usr/include/asm-generic/unistd.h
    
    /* fs/read_write.c */
    #define __NR_write 64
    __SYSCALL(__NR_write, sys_write)
    …
    

    Кроме того, необходимо оформить правильный выход из программы. Это можно сделать с использованием системного вызова exit. Системный вызов exit имеет номер 93, что можно выяснить способом, аналогичным показанному выше.

    Обладая этой информацией, теперь есть возможность написать программу, которая выводит традиционное сообщение "Hello World!". Для этого нам нужно загрузить регистры необходимыми параметрами и выполнить корректные системные вызовы. Параметрами системного вызова write являются файловый дескриптор, который необходимо поместить в регистр a0, адрес начала выводимого сообщения, который необходимо поместить в регистр a1 и длина сообщения в байтах, которую нужно поместить в регистр a2. Номер системного вызова необходимо помещать в регистр a7, а сам системный вызов осуществляется с помощью инструкции ecall:

    # file hello.s
    .equ write, 64
    .equ exit, 93
    .section .text
    .globl  _start
    _start:
            li      a0, 1
            la      a1, msgbegin 
            lbu     a2, msgsize 
            li      a7, write
            ecall
            li      a0, 0
            li      a7, exit
            ecall
    
    .section .rodata
    msgbegin:
    .ascii  "Hello World!\n"
    msgsize:
    .byte   .-msgbegin
    

    Соберём и запустим программу:

    riscv64-linux-gnu-as hello.s -o hello.o
    riscv64-linux-gnu-ld hello.o -o hello
    ./hello
    Hello World!
    

    Взаимодействие с библиотеками

    Помимо прямых системных вызовов, операционная система предоставляет целый набор библиотек, которые могут быть выполнены в двух вариантах: для динамической или статической компоновки. Используя связывание (линковку) с библиотеками, можно в своих программах использовать предоставляемый ими набор функций. Интерфейс обычно представляет собой описание функции для языка C, доступ к нему можно получить, путём линковки в окружении компилятора C.

    Построение интерфейса с библиотеками на языке С требует соблюдения соглашений о вызовах RISC-V (RISC-V Calling Conventions, документ доступен по ссылке https://github.com/riscv-non-isa/riscv-elf-psabi-doc/blob/master/riscv-cc.adoc). Имеется возможность указать компилятору/компоновщику путём задания соответствующих аргументов, какой вариант соглашения ABI нужно использовать. Например, вариантом ABI по умолчанию для архитектуры RV64G является LP64D. ABI регламентирует размер типов данных, использованных компилятором, порядок передачи аргументов в функции, и т.д.

    Компоновщик в процессе работы добавляет специальный служебный настроечный код для работы с библиотеками С, который запускается до разработанной программы на языке Assembler и затем передаёт управление на точку входа программы. В предыдущих примерах точкой входа был участок кода с меткой _start, поскольку до сих пор сборка в окружении языка C не использовалась, однако при сборке в окружении языка C необходимо, чтобы имя метки было _main.

    В следующем примере программы на языке Assembler будут использованы функции printf и scanf стандартной библиотеки glibc, получить информацию по использованию которых можно в стандартное документации man printf.3 и man scanf.3. Демонстрируемая далее программа считает хэш по алгоритму djb2 (подробнее про алгоритм можно почитать по ссылке: http://www.cse.yorku.ca/~oz/hash.html) за авторством Д. Бернштейна. Хэш-функция вычисляет значение фиксированной длины для задаваемого в строковом виде набора данных. Алгоритм djb2 начинается с значения hash(0) = 5381 и вычисляет каждое следующее значение по формуле:

    hash(i+1) = 33 * hash(i) ^ character(i),
    

    где character(i) соответствует символу на i-той позиции во входной строке. Вычисления начинаются с самого первого символа и заканчиваются на последнем символе.

    Следует обратить внимание, что программа написана для набора инструкций RV64I, поэтому для компиляции для набора инструкций RV32 вызовы addiw и slliw должны быть заменены на вызовы addi и slli. Оригинальная хэш-функция оперирует 32-битными числами (как и в приведённом ниже примере), однако алгоритм применим и для 64-битных чисел.

    # djb2.s
    .section .text
    .globl main             # run in C 'environment' 
    main:
        addi sp, sp, -8    # store ra (return address) on stack
        sd ra, 0(sp)
    
        la a0, prompt       # printf the prompt string
        call printf
    
        la a0, scanfmt      # scanf from stdin (console)
        la a1, input        # into buffer input
        call scanf          # with format scanfmt
    
        la a0, input        # process input with djb2
        call djb2
        mv a1, a0
    
        la a0, result       # print result 
        call printf
    
        li a0, 0
    
        ld ra, 0(sp)        # restore ra
        addi sp, sp,8
        ret                 # return to caller
    
    djb2:                   # compute djb2 
        li t1, 5381         # init hash = 5381
    djb2_loop:
        lb t0, 0(a0)        # process every char of input
        beqz t0, dbj2_end   # until zero appears
    
        mv t2, t1           
        slliw t2, t2, 5     # t2 = hash << 5 = 32 * hash
        addw t1, t1, t2     # t1 = 32 * hash + hash = 33 * hash
        addw t1, t1, t0     # t1 = 33 * hash + char
    
        addi a0, a0, 1      # next iteration 
        j djb2_loop 
    dbj2_end:
        mv a0, t1           # return hash value
        ret
    
    .section .rodata
    prompt:
    .asciz "Enter text: "
    scanfmt:
    .asciz "%127[^\n]"          # scanf max 127 chars and end with return
    result:
    .asciz "Hash is %lu\n"      # write out the parameter as long unsigned
    .section .bss
    input:                      # storage for input
    .zero 128
    

    Файл djb2.s необходимо слинковать компилятором C, а собранный в результате бинарный исполняемый файл должна обладать информацией о том, где располагаются динамические библиотеки. Этого можно достичь, задействовав переменную окружения QEMU_LD_PREFIX как показано ниже:

    riscv64-linux-gnu-as djb2.s -o djb2.o
    riscv64-linux-gnu-gcc djb2.o -o djb2
    export QEMU_LD_PREFIX=/usr/riscv64-linux-gnu/
    qemu-riscv64-static djb2
    Enter text: hallo   
    Hash is 261095189
    

    Можно просмотреть исполняемый файл с помощью утилиты objdump и увидеть, что исполняемый файл отличается от предыдущих примеров за счёт настройки среды окружения C.

    Подсчёт символов

    Следующая программа подсчитывает количество символов, слов и строк, задаваемых в стандартном вводе. В ней используется функция getchar библиотеки C, которая передаёт ASCII-коды вводимых символов. При подсчете слов предполагается, что слово представляет собой по крайней мере букву алфавита или десятичную цифру. Код перевода строки в Linux имеет ASCII-код 0xa, ASCII-коды десятичных цифр лежат в диапазоне от 0x30 до 0x39 для цифр от 0 до 9, буквы латинского алфавита в верхнем регистре имеют коды от 0x41 до 0x5a для букв от A до Z, и коды от 0x61 до 0x7a для букв нижнего регистра от a до z. Для детального анализа некоторых инструкций в приведённом исходном коде заданы номера строк.

    00 # wordcount.s
    01 .section .text
    02 .globl main             # run in C 'environment' 
    03 main:
    04     addi sp, sp, -40    # store ra (return address) and saved regs on stack
    05     sd ra, 0(sp)
    06     sd s0, 8(sp)
    07     sd s1, 16(sp)
    08     sd s2, 24(sp)
    09     sd s3, 32(sp)
    10 
    11     li s0, 0            # counter chars
    12     li s1, 0            # counter line feeds
    13     li s2, 0            # counter words
    14     li s3, 0            # indicator if current input is in word
    15 
    16 loop:
    17     call getchar        # get input from stdin in a0
    18     bltz a0, end        # if end of file (eof is -1) goto end
    19 
    20     addi s0, s0, 1      # count characters
    21 
    22     li t0, 0xa          # is linefeed (ascii 0xa)?
    23     bne a0, t0, nolf    # no -> continue
    24     addi s1, s1, 1      # yes -> count
    25 nolf:
    26 
    27                         # is this a word: char digit or alphabet?
    28     addi t0, a0, -0x30  # digits go from 0x30 to 0x39
    29     li   t1, 0x9        # if (char-0x30) >= 0 and <= 0x9 then digit
    30     bleu t0, t1, aldi   # trick: treat negative value as unsigned
    31                         # value (or neg. as unsign.) > 0x9, continue
    32     andi t0, a0, ~0x20  # 0x60 to range > 0x40, lower to upper cast
    33     addi t0, t0, -0x41  # letter go from 0x41 to 0x5a
    34     li   t1, 0x19       # (char-0x41) >= 0 and <= 0x19, then alphabet
    35     bleu t0, t1, aldi   # trick again 
    36                         # reached here, then not in word (anymore)
    37     add s2, s2, s3      # count word, indicator is one if word else 0
    38     li s3, 0            # clear indicator
    39     j loop
    40 
    41 aldi:
    42     li s3, 1            # char is part of word, indicate for word counter
    43     j loop
    44 end:
    45     la a0, result       # print result 
    46     mv a1, s1
    47     mv a2, s2
    48     mv a3, s0
    49     call printf
    50 
    51     li a0, 0
    52 
    53     ld s3, 32(sp)       # restore saved regs.
    54     ld s2, 24(sp)
    55     ld s1, 16(sp)
    56     ld s0, 8(sp)
    57     ld ra, 0(sp)        # restore ra
    58     addi sp, sp,40
    59     ret                 # return to caller
    60 
    61 .section .rodata
    62 result:
    63 .asciz "Lines: %u  Words: %u  Chars: %u\n"      # write out result
    

    Инструкции на строках 04-09 и 53-58 сохраняют значения регистров ra и s0-s3 согласно ABI, регистры s0-s2 используются в роли счётчиков для подсчёта символов, слов и строк соответственно. Регистр s3 принимает значение 1, если входной символ представляет собой букву слова. Все эти регистры изначально инициализируются нулями в строках 11-14.

    В цикле со строки 16 вызывается функция getchar. Если в результате вызова getchar было получено значение -1 (признак конца файла или конца водимых данных), происходит выход из цикла (строка 18). Если getchar вернула ASCII-код символа, увеличивается значение счётчика символов (строка 20). Если getchar вернула символ перевода строки, увеличивается счётчик строк (строки 22-24). Считанный символ проверяется на то, что он является числом (строки 27-30) или буквой (строки 32-36) и в случае, если он попадает в один из этих диапазонов, значение регистра s3 выставляется равным 1 (наличие 1 в регистре s3 говорит о том, что обнаружено слово) и осуществляется переход на новую итерацию цикла считывания (строки 42-43). Если же прочитанный символ не входит в требуемые диапазоны, значение регистра r3 добавляется к счётчику слов, и его значение становится равным 0.

    Строки 28-30 и 32-35 определяют, находится ли символ в заданном диапазоне. Первая проверка - на попадание в диапазон от 0x30 до 0x39 (цифры), это достигается путём вычитания из кода символа значения 0x30 и проверки на то, что результат лежит в диапазоне от 0 до 9. При этом выполняется операция если-меньше-или-равно (bleu). Строки 33-35 ведут себя похожим образом для диапазона от 0x41 до 0x5a за исключением того, что в строке 32 очищается бит 5 рассматриваемого символа инструкцией andi с инверсией значения 0x20. Очищая этот бит, все значения, большие или равные 0x60, отображаются в диапазон от значения 0x40, а значения ниже 0x60 не затрагиваются. Это позволяет выполнить проверку в строках 33-35 также и для значений в диапазоне от 0x61 до 0x7a.

    riscv64-linux-gnu-as -o wordcount.o wordcount.s
    riscv64-linux-gnu-gcc -o wordcount wordcount.o 
    export QEMU_LD_PREFIX=/usr/riscv64-linux-gnu/
    qemu-riscv64-static wordcount
    word Word     .... hello 
    next line     should be two lines
    Lines: 2  Words: 9  Chars: 60
    

    Проверка чисел на простоту

    Следующий код запрашивает число и проверяет его на простоту, результат этой проверки выводится на экран. Программа реализует алгоритм "решето Эратосфена". C-функция scanf принимает аргумент - строку "%u", которая читает беззнаковое целое. Метки, начинающиеся с .L, являются локальными и не экспортируются в таблицу символов.

    00 # prime.s
    01 .equ maxnb, 0x100000
    02 .section .text
    03 .globl main             # run in C 'environment' 
    04 main:
    05     addi sp, sp, -8     # store ra (return address) on stack
    06     sd ra, 0(sp)
    07 
    08     la a0, prompt       # printf the prompt string
    09     call printf
    10 
    11     la a0, scanfmt      # scanf from stdin (console)
    12     la a1, input        # into buffer input
    13     call scanf          # with format scanfmt
    14 
    15     blez a0, .Lerr      # input error   
    16 
    17     la  a1, input       # check if input number n fits 
    18     lw  a1, 0(a1)
    19     li  t0, maxnb
    20     bge a1, t0, .Lerr
    21 
    22     la a0, input        # process input with sieve
    23     call sieve
    24 
    25     bnez a0, .Lp1
    26 .Lp0:
    27     la a0, outno 
    28     j  .Lpp
    29 .Lp1:
    30     la a0, outyes       # print result 
    31     j  .Lpp
    32 .Lerr: 
    33     la a0, error    
    34 .Lpp:
    35     call printf
    36 
    37     li a0, 0
    38 
    39     ld ra, 0(sp)        # restore ra
    40     addi sp, sp,8
    41     ret                 # return to caller
    42 
    43 sieve:
    44     # input: register a0 points to number n
    45     # that is checked if it is a prime.
    46     # output: if n is prime a0 is one else zero
    47     # sieve of Erastosthenes
    48     # init array with numbers
    49     lw t1, 0(a0)        # nb to check
    50     li t2, 2            # counter start with 2
    51     la t3, array        # pointer to array
    52 .Ls0:
    53     sw   t2, 8(t3)      # set item to index, 8() is begin with index 2
    54     addi t3, t3, 4      # increment by four for word size
    55     addi t2, t2, 1      # counter
    56     ble  t2, t1, .Ls0   # until counter == nb to check
    57 
    58     # array has now the values: 0 0 2 3 4 5 6 7 8 9 10...
    59 
    60     # non-primes are cancelled out
    61     # by setting their array items to zero
    62     li t2, 2            # start with 2, t2 is index i
    63     la t3, array        # t3 is pointer to array
    64 .Ls1:
    65     lw   t4, 8(t3)      # t4 is current array item (offset by 2) 
    66     beqz t4, .Ls3       # no prime, continue at .Ls3
    67 
    68     mul t4, t2, t2      # t4 = t2 * t2; t4 is index j
    69 .Ls2:
    70     slli t5, t4, 2      # t5 = t4 * 4 for offset (words) in array
    71     add  t5, t3, t5     # t5 = t3 + t5; t5 is address in array for j
    72     sw   x0, 0(t5)      # set entry to 0, no prime nb, array[j] = 0
    73     add  t4, t4, t2     # t4 = t4 + t2; j += i
    74     ble  t4, t1, .Ls2   # cancel out all multiples of i for i < n
    75 
    76 .Ls3:
    77     addi t2, t2, 1      # continue with next number
    78     mul  t0, t2, t2     # as long as n*n > index
    79     ble  t0, t1, .Ls1 
    80 
    81     slli t0, t1, 2      # use n as index
    82     add  t0, t3, t0     # compute address in array
    83     lw   t0, 0(t0)      # load its item
    84     snez a0, t0         # set a0 to 1 if array[n] != 0 
    85 
    86     ret
    87 
    88 
    89 .section .rodata
    90 prompt:
    91 .asciz "Enter number (<1048576): "
    92 scanfmt:
    93 .asciz "%u"                # scanf an unsigned int number
    94 outyes:
    95 .asciz "is a prime number.\n" 
    96 outno:
    97 .asciz "is not a prime number.\n" 
    98 error:
    99 .asciz "wrong input.\n"
    100 .section .bss
    101 input:                      # storage for numbers 
    102     .word 0
    103 array:
    104     .zero 4*maxnb           # max number storage
    

    Строки 01-41 реализуют с вводом числа и выводом результата. Решето Эратосфена реализовано в строках 43-86. Строки 89-104 описывают данные, с которыми происходит работа.

    Первая часть проверки чисел на простоту инициализирует массив (строки 49-56). Основная часть состоит в исключении всех кратных чисел, начиная с числа два (строки 62-79). Для этого обрабатывается каждый элемент массива и если он уже исключён (равен 0), берется следующий элемент (строки 64-66). В противном случае аннулируются все кратные числа, начиная с квадрата рассматриваемого числа (строки 68-74, внутренний цикл) и заканчивая самим введенным числом. Затем обрабатывается следующий элемент массива (строки 64-79, внешний цикл). Это продолжается до тех пор, пока рассматриваемое число меньше или равно введенному числу (строки 77-79). Элемент массива, соответствующий введённому числу, проверяется в конце на предмет того, равен он нулю (число не простое) или нет (число- простое) (строки 81-84).

    riscv64-linux-gnu-as -o prime.o prime.s 
    riscv64-linux-gnu-gcc -o prime prime.o 
    export QEMU_LD_PREFIX=/usr/riscv64-linux-gnu/
    qemu-riscv64-static prime
    Enter number (<1048576): 1034123
    is a prime number.
    

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

  • Где можно посмотреть, какие номера каким системным вызовам соответствуют?
  • С помощью какой инструкции осуществляется системный вызов?
  • Что регламентирует ABI, кроме соглашения о вызовах?
  • Как должна быть организована программа на языке ассемблера, использующая glibc?
  • Как передать Qemu информацию о расположении динамических библиотек?
  • Страницы:

    В этой Лекции приводятся некоторые примеры того, как писать программы под ОС Linux и использованием взаимодействия с кодом на языке Assembler. Рассмотренные действия выполнятся на компьютере с Intel-архитектурой с установленной ОС Linux и установленным набором инструментов для кросс-компиляции. Исполняемые файлы запускаются под эмулятором Qemu в режиме пользователя. Несмотря на это, программы могут быть собраны и запущены на процессоре с архитектурой RISC-V как это было показано в Лекции 2.

    После изучения этой лекции вы должны быть способны:

  • писать программы на языке Assembler с использованием системных вызовов;
  • писать программы на языке Assembler с использованием библиотек, написанных на C.
  • Использование системы и библиотек

    Взаимодействие с системой

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

    ОС Linux предоставляет интерфейс для системных вызовов. Стоит отметить, что несмотря на то, что приведённые примеры были выполнены и проверены в ОС Debian, они должны работать и в других версия ОС Linux.

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

    man syscall.2 
    

    И посмотрите на строки и столбцы с текстом "riscv":

    Arch/ABI    Instruction           System  Ret  Ret  Error    Notes
                                      call #  val  val2
    -------------------------------------------------------------------
    riscv       ecall                 a7      a0   a1   -
    
    ...
    
    Arch/ABI      arg1  arg2  arg3  arg4  arg5  arg6  arg7  Notes
    --------------------------------------------------------------
    riscv         a0    a1    a2    a3    a4    a5    -
    

    Видно, что регистр a7 используется для задания номера системного вызова, регистры a0 и a1 используются для хранения возвращаемых значений, а аргументы передаются через регистры с a0 по a5.

    Есть документация на различные системные вызовы:

    man syscalls.2 
    
    NAME
           syscalls - Linux system calls
    
    SYNOPSIS
           Linux system calls.
    
    DESCRIPTION
           The system call is the fundamental interface between an application and the Linux kernel.
    ...
    System call                 Kernel        Notes
    ------------------------------------------------------------------------
    write(2)                    1.0
    ...
    

    В качестве примера рассмотрим системный вызов для вывода значений write. Чтобы получить информацию о параметрах системного вызова write, необходимо ввести в консоли:

    man write.2
    
    NAME
           write - write to a file descriptor
    
    SYNOPSIS
           #include <unistd.h>
    
           ssize_t write(int fd, const void *buf, size_t count);
    
    DESCRIPTION
           write()  writes  up to count bytes from the buffer starting at buf to the file referred to by the file descriptor fd.
    ...
    

    Системный вызов write позволяет выводить информацию в поток стандартного вывода. Файловый дескриптор со значением 1 является стандартным выводом, что, во-первых, является общим соглашением, а во-вторых, описано в заголовочном файле /usr/include/unistd.h.

    Но эта информация - для программистов на языке C. Для программистов на языке Assembler необходимо знать численное значение номера системного вызова. В файле /usr/include/asm-generic/unistd.h содержится следующая строчка, задающая идентификатор для системного вызова:

    grep write /usr/include/asm-generic/unistd.h
    
    /* fs/read_write.c */
    #define __NR_write 64
    __SYSCALL(__NR_write, sys_write)
    …
    

    Кроме того, необходимо оформить правильный выход из программы. Это можно сделать с использованием системного вызова exit. Системный вызов exit имеет номер 93, что можно выяснить способом, аналогичным показанному выше.

    Обладая этой информацией, теперь есть возможность написать программу, которая выводит традиционное сообщение "Hello World!". Для этого нам нужно загрузить регистры необходимыми параметрами и выполнить корректные системные вызовы. Параметрами системного вызова write являются файловый дескриптор, который необходимо поместить в регистр a0, адрес начала выводимого сообщения, который необходимо поместить в регистр a1 и длина сообщения в байтах, которую нужно поместить в регистр a2. Номер системного вызова необходимо помещать в регистр a7, а сам системный вызов осуществляется с помощью инструкции ecall:

    # file hello.s
    .equ write, 64
    .equ exit, 93
    .section .text
    .globl  _start
    _start:
            li      a0, 1
            la      a1, msgbegin 
            lbu     a2, msgsize 
            li      a7, write
            ecall
            li      a0, 0
            li      a7, exit
            ecall
    
    .section .rodata
    msgbegin:
    .ascii  "Hello World!\n"
    msgsize:
    .byte   .-msgbegin
    

    Соберём и запустим программу:

    riscv64-linux-gnu-as hello.s -o hello.o
    riscv64-linux-gnu-ld hello.o -o hello
    ./hello
    Hello World!
    

    Взаимодействие с библиотеками

    Помимо прямых системных вызовов, операционная система предоставляет целый набор библиотек, которые могут быть выполнены в двух вариантах: для динамической или статической компоновки. Используя связывание (линковку) с библиотеками, можно в своих программах использовать предоставляемый ими набор функций. Интерфейс обычно представляет собой описание функции для языка C, доступ к нему можно получить, путём линковки в окружении компилятора C.

    Построение интерфейса с библиотеками на языке С требует соблюдения соглашений о вызовах RISC-V (RISC-V Calling Conventions, документ доступен по ссылке https://github.com/riscv-non-isa/riscv-elf-psabi-doc/blob/master/riscv-cc.adoc). Имеется возможность указать компилятору/компоновщику путём задания соответствующих аргументов, какой вариант соглашения ABI нужно использовать. Например, вариантом ABI по умолчанию для архитектуры RV64G является LP64D. ABI регламентирует размер типов данных, использованных компилятором, порядок передачи аргументов в функции, и т.д.

    Компоновщик в процессе работы добавляет специальный служебный настроечный код для работы с библиотеками С, который запускается до разработанной программы на языке Assembler и затем передаёт управление на точку входа программы. В предыдущих примерах точкой входа был участок кода с меткой _start, поскольку до сих пор сборка в окружении языка C не использовалась, однако при сборке в окружении языка C необходимо, чтобы имя метки было _main.

    В следующем примере программы на языке Assembler будут использованы функции printf и scanf стандартной библиотеки glibc, получить информацию по использованию которых можно в стандартное документации man printf.3 и man scanf.3. Демонстрируемая далее программа считает хэш по алгоритму djb2 (подробнее про алгоритм можно почитать по ссылке: http://www.cse.yorku.ca/~oz/hash.html) за авторством Д. Бернштейна. Хэш-функция вычисляет значение фиксированной длины для задаваемого в строковом виде набора данных. Алгоритм djb2 начинается с значения hash(0) = 5381 и вычисляет каждое следующее значение по формуле:

    hash(i+1) = 33 * hash(i) ^ character(i),
    

    где character(i) соответствует символу на i-той позиции во входной строке. Вычисления начинаются с самого первого символа и заканчиваются на последнем символе.

    Следует обратить внимание, что программа написана для набора инструкций RV64I, поэтому для компиляции для набора инструкций RV32 вызовы addiw и slliw должны быть заменены на вызовы addi и slli. Оригинальная хэш-функция оперирует 32-битными числами (как и в приведённом ниже примере), однако алгоритм применим и для 64-битных чисел.

    # djb2.s
    .section .text
    .globl main             # run in C 'environment' 
    main:
        addi sp, sp, -8    # store ra (return address) on stack
        sd ra, 0(sp)
    
        la a0, prompt       # printf the prompt string
        call printf
    
        la a0, scanfmt      # scanf from stdin (console)
        la a1, input        # into buffer input
        call scanf          # with format scanfmt
    
        la a0, input        # process input with djb2
        call djb2
        mv a1, a0
    
        la a0, result       # print result 
        call printf
    
        li a0, 0
    
        ld ra, 0(sp)        # restore ra
        addi sp, sp,8
        ret                 # return to caller
    
    djb2:                   # compute djb2 
        li t1, 5381         # init hash = 5381
    djb2_loop:
        lb t0, 0(a0)        # process every char of input
        beqz t0, dbj2_end   # until zero appears
    
        mv t2, t1           
        slliw t2, t2, 5     # t2 = hash << 5 = 32 * hash
        addw t1, t1, t2     # t1 = 32 * hash + hash = 33 * hash
        addw t1, t1, t0     # t1 = 33 * hash + char
    
        addi a0, a0, 1      # next iteration 
        j djb2_loop 
    dbj2_end:
        mv a0, t1           # return hash value
        ret
    
    .section .rodata
    prompt:
    .asciz "Enter text: "
    scanfmt:
    .asciz "%127[^\n]"          # scanf max 127 chars and end with return
    result:
    .asciz "Hash is %lu\n"      # write out the parameter as long unsigned
    .section .bss
    input:                      # storage for input
    .zero 128
    

    Файл djb2.s необходимо слинковать компилятором C, а собранный в результате бинарный исполняемый файл должна обладать информацией о том, где располагаются динамические библиотеки. Этого можно достичь, задействовав переменную окружения QEMU_LD_PREFIX как показано ниже:

    riscv64-linux-gnu-as djb2.s -o djb2.o
    riscv64-linux-gnu-gcc djb2.o -o djb2
    export QEMU_LD_PREFIX=/usr/riscv64-linux-gnu/
    qemu-riscv64-static djb2
    Enter text: hallo   
    Hash is 261095189
    

    Можно просмотреть исполняемый файл с помощью утилиты objdump и увидеть, что исполняемый файл отличается от предыдущих примеров за счёт настройки среды окружения C.

    Подсчёт символов

    Следующая программа подсчитывает количество символов, слов и строк, задаваемых в стандартном вводе. В ней используется функция getchar библиотеки C, которая передаёт ASCII-коды вводимых символов. При подсчете слов предполагается, что слово представляет собой по крайней мере букву алфавита или десятичную цифру. Код перевода строки в Linux имеет ASCII-код 0xa, ASCII-коды десятичных цифр лежат в диапазоне от 0x30 до 0x39 для цифр от 0 до 9, буквы латинского алфавита в верхнем регистре имеют коды от 0x41 до 0x5a для букв от A до Z, и коды от 0x61 до 0x7a для букв нижнего регистра от a до z. Для детального анализа некоторых инструкций в приведённом исходном коде заданы номера строк.

    00 # wordcount.s
    01 .section .text
    02 .globl main             # run in C 'environment' 
    03 main:
    04     addi sp, sp, -40    # store ra (return address) and saved regs on stack
    05     sd ra, 0(sp)
    06     sd s0, 8(sp)
    07     sd s1, 16(sp)
    08     sd s2, 24(sp)
    09     sd s3, 32(sp)
    10 
    11     li s0, 0            # counter chars
    12     li s1, 0            # counter line feeds
    13     li s2, 0            # counter words
    14     li s3, 0            # indicator if current input is in word
    15 
    16 loop:
    17     call getchar        # get input from stdin in a0
    18     bltz a0, end        # if end of file (eof is -1) goto end
    19 
    20     addi s0, s0, 1      # count characters
    21 
    22     li t0, 0xa          # is linefeed (ascii 0xa)?
    23     bne a0, t0, nolf    # no -> continue
    24     addi s1, s1, 1      # yes -> count
    25 nolf:
    26 
    27                         # is this a word: char digit or alphabet?
    28     addi t0, a0, -0x30  # digits go from 0x30 to 0x39
    29     li   t1, 0x9        # if (char-0x30) >= 0 and <= 0x9 then digit
    30     bleu t0, t1, aldi   # trick: treat negative value as unsigned
    31                         # value (or neg. as unsign.) > 0x9, continue
    32     andi t0, a0, ~0x20  # 0x60 to range > 0x40, lower to upper cast
    33     addi t0, t0, -0x41  # letter go from 0x41 to 0x5a
    34     li   t1, 0x19       # (char-0x41) >= 0 and <= 0x19, then alphabet
    35     bleu t0, t1, aldi   # trick again 
    36                         # reached here, then not in word (anymore)
    37     add s2, s2, s3      # count word, indicator is one if word else 0
    38     li s3, 0            # clear indicator
    39     j loop
    40 
    41 aldi:
    42     li s3, 1            # char is part of word, indicate for word counter
    43     j loop
    44 end:
    45     la a0, result       # print result 
    46     mv a1, s1
    47     mv a2, s2
    48     mv a3, s0
    49     call printf
    50 
    51     li a0, 0
    52 
    53     ld s3, 32(sp)       # restore saved regs.
    54     ld s2, 24(sp)
    55     ld s1, 16(sp)
    56     ld s0, 8(sp)
    57     ld ra, 0(sp)        # restore ra
    58     addi sp, sp,40
    59     ret                 # return to caller
    60 
    61 .section .rodata
    62 result:
    63 .asciz "Lines: %u  Words: %u  Chars: %u\n"      # write out result
    

    Инструкции на строках 04-09 и 53-58 сохраняют значения регистров ra и s0-s3 согласно ABI, регистры s0-s2 используются в роли счётчиков для подсчёта символов, слов и строк соответственно. Регистр s3 принимает значение 1, если входной символ представляет собой букву слова. Все эти регистры изначально инициализируются нулями в строках 11-14.

    В цикле со строки 16 вызывается функция getchar. Если в результате вызова getchar было получено значение -1 (признак конца файла или конца водимых данных), происходит выход из цикла (строка 18). Если getchar вернула ASCII-код символа, увеличивается значение счётчика символов (строка 20). Если getchar вернула символ перевода строки, увеличивается счётчик строк (строки 22-24). Считанный символ проверяется на то, что он является числом (строки 27-30) или буквой (строки 32-36) и в случае, если он попадает в один из этих диапазонов, значение регистра s3 выставляется равным 1 (наличие 1 в регистре s3 говорит о том, что обнаружено слово) и осуществляется переход на новую итерацию цикла считывания (строки 42-43). Если же прочитанный символ не входит в требуемые диапазоны, значение регистра r3 добавляется к счётчику слов, и его значение становится равным 0.

    Строки 28-30 и 32-35 определяют, находится ли символ в заданном диапазоне. Первая проверка - на попадание в диапазон от 0x30 до 0x39 (цифры), это достигается путём вычитания из кода символа значения 0x30 и проверки на то, что результат лежит в диапазоне от 0 до 9. При этом выполняется операция если-меньше-или-равно (bleu). Строки 33-35 ведут себя похожим образом для диапазона от 0x41 до 0x5a за исключением того, что в строке 32 очищается бит 5 рассматриваемого символа инструкцией andi с инверсией значения 0x20. Очищая этот бит, все значения, большие или равные 0x60, отображаются в диапазон от значения 0x40, а значения ниже 0x60 не затрагиваются. Это позволяет выполнить проверку в строках 33-35 также и для значений в диапазоне от 0x61 до 0x7a.

    riscv64-linux-gnu-as -o wordcount.o wordcount.s
    riscv64-linux-gnu-gcc -o wordcount wordcount.o 
    export QEMU_LD_PREFIX=/usr/riscv64-linux-gnu/
    qemu-riscv64-static wordcount
    word Word     .... hello 
    next line     should be two lines
    Lines: 2  Words: 9  Chars: 60
    

    Проверка чисел на простоту

    Следующий код запрашивает число и проверяет его на простоту, результат этой проверки выводится на экран. Программа реализует алгоритм "решето Эратосфена". C-функция scanf принимает аргумент - строку "%u", которая читает беззнаковое целое. Метки, начинающиеся с .L, являются локальными и не экспортируются в таблицу символов.

    00 # prime.s
    01 .equ maxnb, 0x100000
    02 .section .text
    03 .globl main             # run in C 'environment' 
    04 main:
    05     addi sp, sp, -8     # store ra (return address) on stack
    06     sd ra, 0(sp)
    07 
    08     la a0, prompt       # printf the prompt string
    09     call printf
    10 
    11     la a0, scanfmt      # scanf from stdin (console)
    12     la a1, input        # into buffer input
    13     call scanf          # with format scanfmt
    14 
    15     blez a0, .Lerr      # input error   
    16 
    17     la  a1, input       # check if input number n fits 
    18     lw  a1, 0(a1)
    19     li  t0, maxnb
    20     bge a1, t0, .Lerr
    21 
    22     la a0, input        # process input with sieve
    23     call sieve
    24 
    25     bnez a0, .Lp1
    26 .Lp0:
    27     la a0, outno 
    28     j  .Lpp
    29 .Lp1:
    30     la a0, outyes       # print result 
    31     j  .Lpp
    32 .Lerr: 
    33     la a0, error    
    34 .Lpp:
    35     call printf
    36 
    37     li a0, 0
    38 
    39     ld ra, 0(sp)        # restore ra
    40     addi sp, sp,8
    41     ret                 # return to caller
    42 
    43 sieve:
    44     # input: register a0 points to number n
    45     # that is checked if it is a prime.
    46     # output: if n is prime a0 is one else zero
    47     # sieve of Erastosthenes
    48     # init array with numbers
    49     lw t1, 0(a0)        # nb to check
    50     li t2, 2            # counter start with 2
    51     la t3, array        # pointer to array
    52 .Ls0:
    53     sw   t2, 8(t3)      # set item to index, 8() is begin with index 2
    54     addi t3, t3, 4      # increment by four for word size
    55     addi t2, t2, 1      # counter
    56     ble  t2, t1, .Ls0   # until counter == nb to check
    57 
    58     # array has now the values: 0 0 2 3 4 5 6 7 8 9 10...
    59 
    60     # non-primes are cancelled out
    61     # by setting their array items to zero
    62     li t2, 2            # start with 2, t2 is index i
    63     la t3, array        # t3 is pointer to array
    64 .Ls1:
    65     lw   t4, 8(t3)      # t4 is current array item (offset by 2) 
    66     beqz t4, .Ls3       # no prime, continue at .Ls3
    67 
    68     mul t4, t2, t2      # t4 = t2 * t2; t4 is index j
    69 .Ls2:
    70     slli t5, t4, 2      # t5 = t4 * 4 for offset (words) in array
    71     add  t5, t3, t5     # t5 = t3 + t5; t5 is address in array for j
    72     sw   x0, 0(t5)      # set entry to 0, no prime nb, array[j] = 0
    73     add  t4, t4, t2     # t4 = t4 + t2; j += i
    74     ble  t4, t1, .Ls2   # cancel out all multiples of i for i < n
    75 
    76 .Ls3:
    77     addi t2, t2, 1      # continue with next number
    78     mul  t0, t2, t2     # as long as n*n > index
    79     ble  t0, t1, .Ls1 
    80 
    81     slli t0, t1, 2      # use n as index
    82     add  t0, t3, t0     # compute address in array
    83     lw   t0, 0(t0)      # load its item
    84     snez a0, t0         # set a0 to 1 if array[n] != 0 
    85 
    86     ret
    87 
    88 
    89 .section .rodata
    90 prompt:
    91 .asciz "Enter number (<1048576): "
    92 scanfmt:
    93 .asciz "%u"                # scanf an unsigned int number
    94 outyes:
    95 .asciz "is a prime number.\n" 
    96 outno:
    97 .asciz "is not a prime number.\n" 
    98 error:
    99 .asciz "wrong input.\n"
    100 .section .bss
    101 input:                      # storage for numbers 
    102     .word 0
    103 array:
    104     .zero 4*maxnb           # max number storage
    

    Строки 01-41 реализуют с вводом числа и выводом результата. Решето Эратосфена реализовано в строках 43-86. Строки 89-104 описывают данные, с которыми происходит работа.

    Первая часть проверки чисел на простоту инициализирует массив (строки 49-56). Основная часть состоит в исключении всех кратных чисел, начиная с числа два (строки 62-79). Для этого обрабатывается каждый элемент массива и если он уже исключён (равен 0), берется следующий элемент (строки 64-66). В противном случае аннулируются все кратные числа, начиная с квадрата рассматриваемого числа (строки 68-74, внутренний цикл) и заканчивая самим введенным числом. Затем обрабатывается следующий элемент массива (строки 64-79, внешний цикл). Это продолжается до тех пор, пока рассматриваемое число меньше или равно введенному числу (строки 77-79). Элемент массива, соответствующий введённому числу, проверяется в конце на предмет того, равен он нулю (число не простое) или нет (число- простое) (строки 81-84).

    riscv64-linux-gnu-as -o prime.o prime.s 
    riscv64-linux-gnu-gcc -o prime prime.o 
    export QEMU_LD_PREFIX=/usr/riscv64-linux-gnu/
    qemu-riscv64-static prime
    Enter number (<1048576): 1034123
    is a prime number.
    

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

  • Где можно посмотреть, какие номера каким системным вызовам соответствуют?
  • С помощью какой инструкции осуществляется системный вызов?
  • Что регламентирует ABI, кроме соглашения о вызовах?
  • Как должна быть организована программа на языке ассемблера, использующая glibc?
  • Как передать Qemu информацию о расположении динамических библиотек?
  • Вернуться к учебному плану