Skip to content

Latest commit

 

History

History
 
 

nostdlib_baremetal

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 

Жизнь без стандартной библиотеки

Основной reference по набору команд преобразованный в HTML.

Инструменты для сборки без стандартной библиотеки

Инструменты GNU

При компоновке с опцией -nostdlib линковщик не включает функцию main, и не связывает программу со стандартной библиотекой языка Си.

Получаемый на выходе файл - обычный выполняемый файл в формате ELF, который можно выполнить в операционной системе.

Размещение различных секций файла при компоновке можно указать в специальном ld-файле (подробнее см. LD: Scripts), который указывается опцией -T имя_файла.

Для того, чтобы при компоновке не включалась лишняя информация о том, каким компилятором собрана программа, исползуется опция линковщика --build-id=none.

Для выделения кода самой программы из ELF-файла можно использовать утилиту objcopy.

Ассемблер NASM

Ассемблер nasm использует хоть и похожий на Intel, но всё же немного отличающийся по синтаксису язык. Этот ассемблер, в отличии от GNU, поддерживает много выходных форматов, в том числе flat-файлы, предназначенные для непосредственной заливки программатором или загрузки в память.

Взаимодействие с внешним миром в Linux

Общие сведения о системных вызовах

Системные вызовы - это функции, реализованные в ядре операционной системы, и поэтому обычные процессы могут вызывать их только используя специальные команды, которые переключают процессор в режим ядра. Для доступа к системным вызовам используются нестандартные способы вызова: либо механизм прерываний (команда int), либо специализированная команда архитектуры x86-64 syscall.

Для большинства (но не для всех) системных вызовов реализованы Си-сигнатуры, которые описаны во 2-м разделе man-страниц. Поскольку соглашения о вызовах обычных Си-функций отличаются от соглашений о системных вызовах, стандартная библиотека языка Си содержит короткие функции-оболочки, единственная задача которых - это переложить аргументы в соотвествии с требуемым соглашением, после чего выполнить системный вызов, и вернуть результат.

Примеры некоторых системных вызовов в Linux:

  • exit (_exit в Си-нотации) = 1 - выход из программы;
  • read = 3 - чтение из файлового дескриптора;
  • write = 4 - запись в файловый дескриптор;
  • brk (sbrk в Си-нотации) = 45 - перемещение границы сегмента данных программы.

Для обращения к произвольному системному вызову по его номеру, например, если для него не реализована функция-оболочка в стандартной Си-библиотеке, используется функция syscall:

#include <unistd.h>
#include <sys/syscall.h>

int main()
{
    const char Hello[] = "Hello!\n";
    
    // эквивалентно вызову
    // write(1, Hello, sizeof(Hello)-1);
    syscall(SYS_write, 1, Hello, sizeof(Hello)-1);
}

32-разрядные системы x86

Операционная система Linux реализует системные вызовы через программное прерывание с номером 0x80, которое можно инициировать командой int. В регистре eax хранится номер системного вызова, в регистрах ebx, ecx, edx, esi, edi передаются аргументы, а возвращаемое значение передается через eax.

Номера системных вызовов на x86 перечислены в файле /usr/include/asm/unistd_32.h.

Пример для x86 (вывод строки Hello с использованием системного вызова write):

    .text
    ......
    mov   eax, 4  // 4 - номер write
    mov   ebx, 1  // 1 - файловый дескриптор stdout
    mov   ecx, hello_ptr // указатель на hello
    mov   edx, 5  // количество байт в выводе
    int   0x80    // системный вызов Linux
    ......
    .data
hello:
    .string "Hello"
hello_ptr:
    .long   hello

64-разрядные системы x86-64

В 64-битных системах возможно использовать соглашения о системных вызовах для 32-битных платформ x86, но этот механизм используется исключительно для обеспечения работоспособности старых 32-битных программ. При использовании инструкции int 0x80 аргументы, передаваемые через регистры, усекаюстся до 32-битных значений, что может приводить к неопределенному поведению, например, если передаются указатели.

// переменная хранится на стеке, поэтому ее адрес
// имеет достаточно большое значение в виртуальном
// 64-разрядном адресном пространстве процесса 
char buffer[1024];

// если использовать int 0x80, значение указателя buffer
// будет записано в 32-битный регистр ecx, что приведет к
// ошибке Segmentation Fault
ssize_t bytes_read = read(0, buffer, sizeof(buffer));

Родным для архитектуры x86-64 соглашением в Linux является использование команды процессора syscall, где номер системного вызова передается через rax, а аргументы передаются через регистры: rdi, rsi, rdx, r10, r8 и r9. Обратите внимание, что не все используемые регистры совпадают со стандартным соглашением о вызовах в x86-64, например, вместо регистра rcx используется регистр r10. Кроме того, использование команды syscall может испортить содержимое регистров rcx и r11.

Номера системных вызовов для использования их командой syscall, хранятся в заголовочном файле /usr/include/sys/syscall.h, и большинство из них совпадают (хотя это ничем не гарантируется) с номерами системных вызовов для 32-битных системных вызовов архитектуры x86.

Пример для x86-64 (вывод строки Hello с использованием системного вызова write):

    .text
    ......
    mov   rax, 4  // 4 - номер write
    mov   rdi, 1  // 1 - файловый дескриптор stdout
    mov   rsi, hello_ptr // указатель на hello
    mov   rdx, 5  // количество байт в выводе
    syscall       // системный вызов Linux
    ......
    .data
hello:
    .string "Hello"
hello_ptr:
    .quad   hello

Взаимодействие с внешним миром через BIOS или в DOS (историческая справка)

До момента загрузки операционной системы, обработка ввода-вывода осуществляется с помощью подпрограмм, предоставляемых BIOS (Basic Input Output System).

Разные подсистемам ("сервисам") соответствуют различные номера прерываний. Например, прерывание 0x10 предназначено для вывода на экран, а прерывание 0x09 - за чтение с клавиатуры.

Некоторые операционные системы, например DOS, не запрещают использование прерываний BIOS, а дополняют их своими механизмами.

Подробное описание функций BIOS и DOS - здесь.

Отдельно стоит рассмотреть взаимодействие с выводом на экран. Поскольку вывод через прерывание является хоть и универсальным, но все же медленным способом, то лучше использовать прямую запись в видеопамять VGA.

Видеопамять VGA в архитектуре x86 располагается в диапазоне 0xA000...0xDFFFF (256Кб начиная с 640Кб), и делится на "окна", - области, назначение которых зависит от используемого режима работы.

В стандартном текстовом видеорежиме, вывод символа в позицию (X, Y) осуществляется записью двух байт по адресу 0xB8000+Y*80*2+X*2, где младший байт означает код символа, а старший - цвет символа и фона.

Стадии загрузки системы

Включение компьютера

Сразу после запуска компьютера, управление передаётся программе из ROM-памяти (часто именуемую BIOS, хотя это не совсем корректно), задача которой - выполнить диагностику системы, определить конфигурацию оборудования, и загрузить программу-загрузчик с определенного диска, чтобы передать ей управление.

Программа-загрузчик может располагаться:

  • в классической PC-системе - в первых 512 байтах диска;
  • в современных системах с EFI/UEFI - выделяется определенная область в Flash-памяти на системной плате, куда установщик операционной системы записывает свой загрузчик.

Загрузка системы через MBR

Master Boot Record имеет размер 512 байт, и состоит из двух частей: программы-загрузчика и первичной таблицы разделов диска. Признаком того, что MBR имеет загрузчик, является значение 0x55AA в последних двух байтах. Размер первичной таблицы разделов для PC - 64 байта, таким образом, для загрузчика остается всего 446 байт (512-2-64).

Если загрузчик является достаточно сложным (например, GRUB в графическом режиме со всякими красивостями и умной командной строкой), то его делят на две части: в MBR и частично - на разделе диска.

Первые 446 байт загружаются с диска в память по адресу 0x7C00, а область памяти от 0x0000 до 0x7C00 считается зарезервированной под стек. При этом, процессор x86 работает в 16-битном реальном режиме, со старинной сегментной адресацией памяти. Пример программирования MBR - здесь.

Задача загрузчика - это найти на диске файл с ядром системы, загрузить его в память, и передать ему управление. Примеры файлов ядра:

  • C:\msdos.sys - для DOS;
  • C:\Windows\System32\ntoskrnl.exe - для Windows;
  • /boot/vmlinuz - символическая ссылка на zlib-сжатый образ ядра в Linux.

Формат файла ядра - как правило, соответствует обычному исполняемому файлу (PE для Windows или ELF для Linux), но на него накладываются некоторые ограничения о размещении данных внутри файла, и кроме того, этот файл не может иметь зависимости от каких-либо библиотек.

Загрузка ядра Linux, формат multiboot.

Загрузчик GRUB загружает ELF-файл с ядром, распаковывает его при необходимости, и размещает по адресу, начиная с 1Мб.

Далее загрузчик ищет Magic-метку заголовка multiboot в первых 32К загруженного файла ядра, сразу после которой идет набор флагов и контрольная сумма заголовка. После этого заголовка, в самом файле следует 16К памяти под стек, а сразу после него - начало программы, которую нужно выполнять.

Таким образом, при компиляции ядер, необходимо строго указывать очерёдность различных секций, чтобы GRUB смог запустить ядро.

Подробнее - здесь.

Запуск ядра

Ядро регистрирует вектор прерываний, выполняет дальнейшую инициализацию оборудования, загружая при необходимости различные драйверы устройств. Когда ядро полностью загружено, то выполняется загрузка первой программы, которая выполняется в режиме пользователя:

  • C:\command.com - для DOS;
  • C:\Windows\System32\smms.exe - для Windows;
  • /boot/initrd - для Linux.

Дальнейшие стадии запуска

Процесс initrd, в зависимости от дистрибутива:

  • классический Unix-way: запускает набор shell-скриптов в одном из подкаталогов /etc/init.d/rcX.d, где X - уровень запуска по умолчанию, прописанный в файле /etc/inittab;
  • SystemD-way: запускает программу systemd, которая имеет свой набор конфигурационных файлов, по которым строит дерево зависимостей различных служб, и запускает их.