Использование системных библиотек
В этой Лекции приводятся некоторые примеры того, как писать программы под ОС 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