Опубликован: 19.01.2025 | Доступ: свободный | Студентов: 1 / 0 | Длительность: 02:34:00
Лекция 6:

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

< Лекция 5 || Лекция 6: 12 || Лекция 7 >

В этой Лекции приводятся некоторые примеры того, как писать программы под ОС 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
< Лекция 5 || Лекция 6: 12 || Лекция 7 >