F1 FreeBSD
F2 BSD
F5 Disk 2
Глава 1. Начальная загрузка и инициализация ядра
Этот перевод может быть устаревшим. Для того, чтобы помочь с переводом, пожалуйста, обратитесь к Сервер переводов FreeBSD.
Содержание
1.1. Обзор
Эта глава представляет собой обзор процессов загрузки и инициализации системы, начиная с POST в BIOS (микропрограмме) и заканчивая созданием первого пользовательского процесса. Поскольку начальные этапы загрузки системы сильно зависят от архитектуры, в качестве примера используется архитектура IA-32. Однако архитектуры AMD64 и ARM64 гораздо важнее и интереснее, и их следует рассмотреть в ближайшем будущем в соответствии с темой этого документа.
Процесс загрузки FreeBSD может быть удивительно сложным. После передачи управления от BIOS необходимо выполнить значительный объем низкоуровневой настройки перед загрузкой и выполнением ядра. Эта настройка должна быть выполнена простым и гибким способом, предоставляя пользователю широкие возможности для настройки и адаптации.
1.2. Обзор
Процесс загрузки — это операция, крайне зависимая от оборудования. Не только для каждой архитектуры компьютера должен быть написан код, но также могут существовать различные типы загрузки в рамках одной архитектуры. Например, список файлов в каталоге stand показывает большое количество кода, зависящего от архитектуры. Для каждой из поддерживаемых архитектур существует отдельный каталог. FreeBSD поддерживает стандарт загрузки CSM (Compatibility Support Module). Таким образом, CSM поддерживается (как с GPT, так и с MBR разметкой), а также загрузка через UEFI (GPT полностью поддерживается, MBR — в основном). Также поддерживается загрузка файлов с ext2fs, MSDOS, UFS и ZFS. FreeBSD поддерживает функцию загрузочного окружения ZFS, которая позволяет основной ОС передавать детали о том, что загружать, выходящие за рамки простого раздела, как это было возможно ранее. Однако в наши дни UEFI более актуален, чем CSM. В следующем примере показана загрузка компьютера x86 с жёсткого диска с MBR-разметкой, где используется мультизагрузчик FreeBSD boot0, сохранённый в самом первом секторе. Этот загрузочный код запускает трёхэтапный процесс загрузки FreeBSD.
Ключ к пониманию этого процесса заключается в том, что он состоит из последовательных стадий возрастающей сложности. Эти стадии — boot1, boot2 и loader (подробнее см. boot(8)). Система загрузки выполняет каждую стадию последовательно. Последняя стадия, loader, отвечает за загрузку ядра FreeBSD. Каждая стадия рассматривается в следующих разделах.
Вот пример вывода, сгенерированного на различных этапах загрузки. Фактический вывод может отличаться в зависимости от машины:
Компонент FreeBSD | Вывод (может отличаться) |
| |
|
|
loader |
|
ядро системы |
|
1.3. BIOS
При включении компьютера регистры процессора устанавливаются в некоторые предопределённые значения. Один из регистров — это регистр указателя команд, и его значение после включения питания чётко определено: это 32-битное значение 0xfffffff0
. Регистр указателя команд (также известный как Счётчик Команд) указывает на код, который должен быть выполнен процессором. Ещё один важный регистр — это 32-битный управляющий регистр cr0
, и его значение сразу после перезагрузки равно 0
. Один из битов cr0
, бит PE (Protection Enabled, Защита Включена), указывает, работает ли процессор в 32-битном защищённом режиме или 16-битном реальном режиме. Поскольку этот бит сброшен при загрузке, процессор запускается в 16-битном реальном режиме. Реальный режим означает, среди прочего, что линейные и физические адреса идентичны. Причина, по которой процессор не запускается сразу в 32-битном защищённом режиме, — это обратная совместимость. В частности, процесс загрузки зависит от услуг, предоставляемых BIOS, а сам BIOS работает в устаревшем 16-битном коде.
Значение 0xfffffff0
немного меньше 4 ГБ, поэтому, если в машине нет 4 ГБ физической памяти, оно не может указывать на действительный адрес памяти. Аппаратное обеспечение компьютера преобразует этот адрес так, чтобы он указывал на блок памяти BIOS.
BIOS (Basic Input Output System) — это микросхема на материнской плате, которая содержит относительно небольшой объем памяти только для чтения (ROM). Эта память включает различные низкоуровневые процедуры, специфичные для оборудования, поставляемого с материнской платой. Процессор сначала переходит по адресу 0xfffffff0, который фактически находится в памяти BIOS. Обычно по этому адресу содержится инструкция перехода к процедурам POST BIOS.
POST (Power On Self Test) — это набор процедур, включающих проверку памяти, проверку системной шины и другую низкоуровневую инициализацию, чтобы процессор мог правильно настроить компьютер. Важным этапом на этой стадии является определение загрузочного устройства. Современные реализации BIOS позволяют выбирать загрузочное устройство, обеспечивая загрузку с дискеты, CD-ROM, жесткого диска или других устройств.
Самым последним действием в POST является инструкция INT 0x19
. Обработчик INT 0x19
считывает 512 байт из первого сектора загрузочного устройства в память по адресу 0x7c00
. Термин первый сектор происходит из архитектуры жёстких дисков, где магнитная пластина разделена на множество цилиндрических дорожек. Дорожки нумеруются, и каждая дорожка разделена на несколько (обычно 64) секторов. Нумерация дорожек начинается с 0, но нумерация секторов начинается с 1. Дорожка 0 находится на внешней стороне магнитной пластины, а сектор 1, первый сектор, имеет особое назначение. Он также называется MBR (Master Boot Record) или Главная Загрузочная Запись. Остальные секторы на первой дорожке не используются.
Этот сектор является нашей точкой входа в последовательность загрузки. Как мы увидим, этот сектор содержит копию нашей программы boot0. BIOS выполняет переход по адресу 0x7c00
, и она начинает выполняться.
1.4. Главная загрузочная запись (boot0
)
После получения управления от BIOS по адресу памяти 0x7c00
начинает выполняться boot0. Это первый код, который управляется FreeBSD. Задача boot0 довольно проста: просканировать таблицу разделов и позволить пользователю выбрать, с какого раздела загружаться. Таблица разделов — это специальная стандартная структура данных, встроенная в MBR (а значит, и в boot0), которая описывает четыре стандартных PC-раздела. boot0 находится в файловой системе как /boot/boot0. Это небольшой файл размером 512 байт, и именно его процедура установки FreeBSD записывает в MBR жёсткого диска, если во время установки была выбрана опция "bootmanager". Действительно, boot0 и есть MBR.
Как упоминалось ранее, мы вызываем прерывание BIOS INT 0x19
для загрузки MBR (boot0) в память по адресу 0x7c00
. Исходный файл для boot0 можно найти в stand/i386/boot0/boot0.S — это впечатляющий фрагмент кода, написанный Робертом Нордье.
Особая структура, начинающаяся со смещения 0x1be
в MBR, называется таблицей разделов. Она содержит четыре записи по 16 байт каждая, называемые записями разделов, которые определяют, как разделён жёсткий диск, или, в терминологии FreeBSD, нарезан. Один из этих 16 байт указывает, является ли раздел (срез) загрузочным или нет. Ровно одна запись должна быть с этом установленным флагом, иначе код boot0 откажется продолжать работу.
Запись о разделе содержит следующие поля:
1-байтовый тип файловой системы
1-байтовый флаг загрузки (
bootable
)6-байтовый дескриптор в формате CHS
8-байтовый дескриптор в формате LBA
Дескриптор записи раздела содержит информацию о том, где именно раздел расположен на диске. Оба дескриптора, LBA и CHS, описывают одну и ту же информацию, но разными способами: LBA (Logical Block Addressing) содержит начальный сектор раздела и его длину, тогда как CHS (Cylinder Head Sector) содержит координаты первого и последнего секторов раздела. Таблица разделов завершается специальной сигнатурой 0xaa55
.
MBR должен помещаться в 512 байт, один сектор диска. Эта программа использует низкоуровневые «трюки», такие как использование побочных эффектов определённых инструкций и повторное использование значений регистров из предыдущих операций, чтобы максимально эффективно использовать минимально возможное количество инструкций. Также необходимо соблюдать осторожность при работе с таблицей разделов, которая встроена в сам MBR. По этим причинам будьте очень внимательны при изменении boot0.S.
Обратите внимание, что исходный файл boot0.S ассемблируется "как есть": инструкции переводятся одна за одной в бинарный код без дополнительной информации (например, без формата файла ELF). Такой низкоуровневый контроль достигается на этапе компоновки с помощью специальных флагов, передаваемых компоновщику. Например, текстовая секция программы располагается по адресу 0x600
. На практике это означает, что boot0 должен быть загружен в память по адресу 0x600
для корректной работы.
Стоит взглянуть на Makefile для boot0 (stand/i386/boot0/Makefile), так как он определяет некоторые аспекты поведения boot0 во время выполнения. Например, если для ввода-вывода используется терминал, подключённый к последовательному порту (COM1), необходимо определить макрос SIO
(-DSIO
). -DPXE
включает загрузку через PXE при нажатии F6. Кроме того, программа определяет набор флагов, которые позволяют дополнительно настроить её поведение. Всё это проиллюстрировано в Makefile. Например, обратите внимание на директивы компоновщика, которые предписывают ему начинать секцию текста с адреса 0x600
и создавать выходной файл "как есть" (удаляя любое форматирование файла):
BOOT_BOOT0_ORG?=0x600 ORG=${BOOT_BOOT0_ORG}
В некоторые инструкции были внесены изменения для лучшего изложения. Например, некоторые макросы раскрыты, а некоторые проверки макросов опущены, когда результат проверки известен. Это относится ко всем приведённым примерам кода. |
start: cld # String ops inc xorw %ax,%ax # Zero movw %ax,%es # Address movw %ax,%ds # data movw %ax,%ss # Set up movw $LOAD,%sp # stack
Этот первый блок кода является точкой входа программы. Именно сюда BIOS передаёт управление. Сначала он гарантирует, что строковые операции автоматически увеличивают указатели операндов (инструкция cld
) [2]. Затем, не делая предположений о состоянии сегментных регистров, он их инициализирует. Наконец, он устанавливает регистр указателя стека (%sp
) в ($LOAD = адрес 0x7c00
), чтобы обеспечить работоспособный стек.
Следующий блок отвечает за перемещение и последующий переход к перемещенному коду.
movw %sp,%si # Source movw $start,%di # Destination movw $0x100,%cx # Word count rep # Relocate movsw # code movw %di,%bp # Address variables movb $0x8,%cl # Words to clear rep # Zero stosw # them incb -0xe(%di) # Set the S field to 1 jmp main-LOAD+ORIGIN # Jump to relocated code
Так как boot0 загружается BIOS по адресу 0x7C00
, он копирует себя по адресу 0x600
и передаёт управление туда (напомним, что он был слинкован для выполнения по адресу 0x600
). Исходный адрес, 0x7c00
, копируется в регистр %si
. Конечный адрес, 0x600
, — в регистр %di
. Количество слов для копирования, 256
(размер программы = 512 байт), копируется в регистр %cx
. Далее инструкция rep
повторяет следующую за ней инструкцию, то есть movsw
, количество раз, указанное в регистре %cx
. Инструкция movsw
копирует слово, на которое указывает %si
, по адресу, на который указывает %di
. Это повторяется ещё 255 раз. При каждом повторении оба регистра, исходный и конечный, %si
и %di
, увеличиваются на единицу. Таким образом, по завершении копирования 256 слов (512 байт), %di
имеет значение 0x600
`512`= `0x800`, а `%si` — значение `0x7c00`512
= 0x7e00
; таким образом, мы завершили перемещение кода. С момента последнего обновления этого документа инструкции копирования в коде изменились, поэтому вместо movsb и stosb были введены movsw и stosw, которые копируют 2 байта (1 слово) за одну итерацию.
Затем регистр назначения %di
копируется в %bp
. %bp
получает значение 0x800
. Значение 8
копируется в %cl
для подготовки новой строковой операции (как в предыдущей movsw
). Теперь stosw
выполняется 8 раз. Эта инструкция копирует значение 0
по адресу, на который указывает регистр назначения (%di
, то есть 0x800
), и увеличивает его. Это повторяется ещё 7 раз, так что %di
в итоге получает значение 0x810
. Фактически это очищает диапазон адресов 0x800
-0x80f
. Этот диапазон используется как (фиктивная) таблица разделов для записи MBR обратно на диск. Наконец, полю сектора для CHS-адресации этого фиктивного раздела присваивается значение 1, и выполняется переход к основной функции из перемещённого кода. Обратите внимание, что до этого перехода к перемещённому коду любые ссылки на абсолютные адреса избегались.
Следующий блок кода проверяет, следует ли использовать номер диска, предоставленный BIOS, или тот, что хранится в boot0.
main: testb $SETDRV,_FLAGS(%bp) # Set drive number? #ifndef CHECK_DRIVE /* disable drive checks */ jz save_curdrive # no, use the default #else jnz disable_update # Yes testb %dl,%dl # Drive number valid? js save_curdrive # Possibly (0x80 set) #endif
Этот код проверяет бит SETDRV
(0x20
) в переменной flags. Напомним, что регистр %bp
указывает на адрес 0x800
, поэтому проверка выполняется для переменной flags по адресу 0x800
-69
= 0x7bb
. Это пример типа изменений, которые можно внести в boot0. Флаг SETDRV
не установлен по умолчанию, но его можно задать в Makefile. Если он установлен, используется номер диска, сохранённый в MBR, вместо предоставленного BIOS. Мы предполагаем значения по умолчанию и то, что BIOS предоставил корректный номер диска, поэтому переходим к save_curdrive
.
Следующий блок сохраняет номер диска, предоставленный BIOS, и вызывает putn
для вывода новой строки на экран.
save_curdrive: movb %dl, (%bp) # Save drive number pushw %dx # Also in the stack #ifdef TEST /* test code, print internal bios drive */ rolb $1, %dl movw $drive, %si call putkey #endif callw putn # Print a newline
Обратите внимание, что мы предполагаем, что TEST
не определён, поэтому условный код в нём не собирается и не появится в нашем исполняемом файле boot0.
Следующий блок реализует фактическое сканирование таблицы разделов. Он выводит на экран тип раздела для каждой из четырёх записей в таблице разделов. Каждый тип сравнивается со списком известных файловых систем операционных систем. Примерами распознаваемых типов разделов являются NTFS (Windows®, ID 0x7), ext2fs
(Linux®, ID 0x83) и, конечно же, ffs
/ufs2
(FreeBSD, ID 0xa5). Реализация довольно проста.
movw $(partbl+0x4),%bx # Partition table (+4) xorw %dx,%dx # Item number read_entry: movb %ch,-0x4(%bx) # Zero active flag (ch == 0) btw %dx,_FLAGS(%bp) # Entry enabled? jnc next_entry # No movb (%bx),%al # Load type test %al, %al # skip empty partition jz next_entry movw $bootable_ids,%di # Lookup tables movb $(TLEN+1),%cl # Number of entries repne # Locate scasb # type addw $(TLEN-1), %di # Adjust movb (%di),%cl # Partition addw %cx,%di # description callw putx # Display it next_entry: incw %dx # Next item addb $0x10,%bl # Next entry jnc read_entry # Till done
Важно отметить, что флаг активности для каждой записи сбрасывается, поэтому после сканирования ни одна запись о разделе не активна в нашей копии boot0 в памяти. Позже флаг активности будет установлен для выбранного раздела. Это гарантирует, что только один активный раздел существует, если пользователь решит записать изменения обратно на диск.
Следующий блок проверяет наличие других дисков. При запуске BIOS записывает количество дисков, присутствующих в компьютере, по адресу 0x475
. Если есть другие диски, boot0 выводит текущий диск на экран. Пользователь может позже дать команду boot0 просканировать разделы на другом диске.
popw %ax # Drive number subb $0x80-0x1,%al # Does next cmpb NHRDRV,%al # drive exist? (from BIOS?) jb print_drive # Yes decw %ax # Already drive 0? jz print_prompt # Yes
Мы предполагаем, что присутствует только один диск, поэтому переход к print_drive
не выполняется. Также мы предполагаем, что ничего необычного не произошло, поэтому переходим к print_prompt
.
Следующий блок просто выводит приглашение с последующим вариантом по умолчанию:
print_prompt: movw $prompt,%si # Display callw putstr # prompt movb _OPT(%bp),%dl # Display decw %si # default callw putkey # key jmp start_input # Skip beep
Наконец, выполняется переход к start_input
, где используются сервисы BIOS для запуска таймера и чтения пользовательского ввода с клавиатуры; если таймер истекает, будет выбран вариант по умолчанию:
start_input: xorb %ah,%ah # BIOS: Get int $0x1a # system time movw %dx,%di # Ticks when addw _TICKS(%bp),%di # timeout read_key: movb $0x1,%ah # BIOS: Check int $0x16 # for keypress jnz got_key # Have input xorb %ah,%ah # BIOS: int 0x1a, 00 int $0x1a # get system time cmpw %di,%dx # Timeout? jb read_key # No
Прерывание запрашивается с номером 0x1a
и аргументом 0
в регистре %ah
. BIOS имеет предопределённый набор сервисов, запрашиваемых приложениями как программно-генерируемые прерывания через инструкцию int
, с получением аргументов в регистрах (в данном случае, %ah
). Здесь, в частности, запрашивается количество тиков часов с момента последней полуночи; это значение вычисляется BIOS через RTC (Real Time Clock). Эти часы могут быть настроены на работу с частотой от 2 Гц до 8192 Гц. BIOS устанавливает их на 18,2 Гц при запуске. Когда запрос выполнен, 32-битный результат возвращается BIOS в регистрах %cx
и %dx
(младшие байты в %dx
). Этот результат (часть %dx
) копируется в регистр %di
, и к %di
добавляется значение переменной TICKS
. Эта переменная находится в boot0 по смещению _TICKS
(отрицательное значение) от регистра %bp
(который, напомним, указывает на 0x800
). Значение этой переменной по умолчанию — 0xb6
(182 в десятичной системе). Идея заключается в том, что boot0 постоянно запрашивает время у BIOS, и когда значение, возвращённое в регистре %dx
, становится больше значения, хранящегося в %di
, время истекает и будет сделан выбор по умолчанию. Поскольку RTC тикает 18,2 раза в секунду, это условие выполнится через 10 секунд (это поведение по умолчанию можно изменить в Makefile). До истечения этого времени boot0 непрерывно опрашивает BIOS на предмет ввода пользователя; это делается через int 0x16
, аргумент 1
в %ah
.
Была нажата клавиша или истекло время, последующий код проверяет выбор. В зависимости от выбора, регистр %si
устанавливается так, чтобы указывать на соответствующую запись раздела в таблице разделов. Этот новый выбор переопределяет предыдущий выбор по умолчанию. Действительно, он становится новым значением по умолчанию. Наконец, устанавливается флаг ACTIVE выбранного раздела. Если это было разрешено при компиляции, версия boot0 в памяти с этими изменёнными значениями записывается обратно в MBR на диске. Мы оставляем детали этой реализации читателю.
Мы завершаем наше изучение последним блоком кода из программы boot0:
movw $LOAD,%bx # Address for read movb $0x2,%ah # Read sector callw intx13 # from disk jc beep # If error cmpw $MAGIC,0x1fe(%bx) # Bootable? jne beep # No pushw %si # Save ptr to selected part. callw putn # Leave some space popw %si # Restore, next stage uses it jmp *%bx # Invoke bootstrap
Вспомним, что %si
указывает на выбранную запись раздела. Эта запись сообщает нам, где начинается раздел на диске. Мы предполагаем, конечно, что выбранный раздел действительно является срезом FreeBSD.
Отныне мы будем отдавать предпочтение использованию технически более точного термина "слайс" вместо "раздел". |
Буфер передачи установлен в 0x7c00
(регистр %bx
), и запрос на чтение первого сектора слайса FreeBSD выполняется вызовом intx13
. Мы предполагаем, что всё прошло успешно, поэтому переход к beep
не выполняется. В частности, новый прочитанный сектор должен заканчиваться магической последовательностью 0xaa55
. Наконец, значение в %si
(указатель на выбранную таблицу разделов) сохраняется для использования на следующем этапе, и выполняется переход по адресу 0x7c00
, где начинается выполнение нашего следующего этапа (только что прочитанного блока).
1.5. Этап boot1
До сих пор мы прошли следующую последовательность:
BIOS выполнил первоначальную инициализацию оборудования, включая POST. MBR (boot0) был загружен по адресу
0x7c00
из абсолютного сектора один с диска. Управление выполнением было передано по этому адресу.boot0 переместил себя по адресу, по которому он был скомпонован для выполнения (
0x600
), после чего выполнил переход для продолжения выполнения в соответствующем месте. В завершение, boot0 загрузил первый сектор диска из раздела FreeBSD по адресу0x7c00
. Управление выполнением было передано по этому адресу.
boot1 — это следующий шаг в последовательности загрузки. Это первая из трех стадий загрузки. Обратите внимание, что до сих пор мы работали исключительно с секторами диска. Действительно, BIOS загружает самый первый сектор, а boot0 загружает первый сектор раздела FreeBSD. Обе загрузки происходят по адресу 0x7c00
. Мы можем концептуально представлять эти секторы диска как содержащие файлы boot0 и boot1, соответственно, но на самом деле это не совсем верно для boot1. Строго говоря, в отличие от boot0, boot1 не является частью загрузочных блоков [3]. Вместо этого, единый полноценный файл boot (/boot/boot) в итоге записывается на диск. Этот файл представляет собой комбинацию boot1, boot2 и Boot Extender
(или BTX). Этот единый файл превышает размер одного сектора (больше 512 байт). К счастью, boot1 занимает ровно первые 512 байт этого файла, поэтому, когда boot0 загружает первый сектор раздела FreeBSD (512 байт), он фактически загружает boot1 и передает ему управление.
Основная задача boot1 — загрузить следующий этап загрузки. Этот следующий этап несколько сложнее. Он состоит из сервера под названием "Boot Extender" (BTX) и клиента под названием boot2. Как мы увидим, последний этап загрузки, loader, также является клиентом сервера BTX.
Давайте теперь подробно рассмотрим, что именно делает boot1, начиная, как мы это делали для boot0, с точки входа:
start: jmp main
Точка входа start
просто переходит через специальную область данных к метке main
, которая, в свою очередь, выглядит следующим образом:
main: cld # String ops inc xor %cx,%cx # Zero mov %cx,%es # Address mov %cx,%ds # data mov %cx,%ss # Set up mov $start,%sp # stack mov %sp,%si # Source mov $MEM_REL,%di # Destination incb %ch # Word count rep # Copy movsw # code
Как и boot0, этот код перемещает boot1, на этот раз по адресу 0x700
. Однако, в отличие от boot0, он не переходит туда. boot1 скомпонован для выполнения по адресу 0x7c00
, фактически там, куда он был изначально загружен. Причина этого перемещения будет рассмотрена далее.
Далее идет цикл, который ищет слайс FreeBSD. Хотя boot0 загрузил boot1 из слайса FreeBSD, ему не была передана информация об этом [4], поэтому boot1 должен повторно просканировать таблицу разделов, чтобы найти начало слайса FreeBSD. Для этого он перечитывает MBR:
mov $part4,%si # Partition cmpb $0x80,%dl # Hard drive? jb main.4 # No movb $0x1,%dh # Block count callw nread # Read MBR
В приведённом выше коде регистр %dl
содержит информацию о загрузочном устройстве. Эти данные передаются BIOS и сохраняются MBR. Числа 0x80
и выше указывают на то, что мы имеем дело с жёстким диском, поэтому вызывается nread
, где считывается MBR. Аргументы для nread
передаются через %si
и %dh
. Адрес памяти по метке part4
копируется в %si
. Этот адрес памяти содержит "фальшивый раздел", который будет использован nread
. Ниже приведены данные фальшивого раздела:
part4: .byte 0x80, 0x00, 0x01, 0x00 .byte 0xa5, 0xfe, 0xff, 0xff .byte 0x00, 0x00, 0x00, 0x00 .byte 0x50, 0xc3, 0x00, 0x00
В частности, LBA для этой фиктивной раздела жестко закодирован как ноль. Это используется как аргумент для BIOS при чтении абсолютного сектора один с жесткого диска. Альтернативно, может использоваться адресация CHS. В этом случае, фиктивный раздел содержит цилиндр 0, головку 0 и сектор 1, что эквивалентно абсолютному сектору один.
Продолжим, рассмотрев nread
:
nread: mov $MEM_BUF,%bx # Transfer buffer mov 0x8(%si),%ax # Get mov 0xa(%si),%cx # LBA push %cs # Read from callw xread.1 # disk jnc return # If success, return
Напомним, что %si
указывает на поддельный раздел. Слово [5] по смещению 0x8
копируется в регистр %ax
, а слово по смещению 0xa
— в %cx
. BIOS интерпретирует их как младшее 4-байтовое значение, обозначающее LBA для чтения (старшие четыре байта предполагаются нулевыми). Регистр %bx
содержит адрес памяти, куда будет загружен MBR. Инструкция, помещающая %cs
в стек, очень интересна. В данном контексте она ничего не делает. Однако, как мы скоро увидим, boot2 в сочетании с сервером BTX также использует xread.1
. Этот механизм будет рассмотрен в следующем разделе.
Код в xread.1
далее вызывает функцию read
, которая фактически обращается к BIOS с запросом на чтение сектора диска:
xread.1: pushl $0x0 # absolute push %cx # block push %ax # number push %es # Address of push %bx # transfer buffer xor %ax,%ax # Number of movb %dh,%al # blocks to push %ax # transfer push $0x10 # Size of packet mov %sp,%bp # Packet pointer callw read # Read from disk lea 0x10(%bp),%sp # Clear stack lret # To far caller
Обратите внимание на длинную инструкцию возврата в конце этого блока. Эта инструкция извлекает регистр %cs
, помещённый в стек nread
, и возвращает управление. В конце nread
также возвращает управление.
С загрузкой MBR в память начинается фактический цикл поиска слайса FreeBSD:
mov $0x1,%cx # Two passes main.1: mov $MEM_BUF+PRT_OFF,%si # Partition table movb $0x1,%dh # Partition main.2: cmpb $PRT_BSD,0x4(%si) # Our partition type? jne main.3 # No jcxz main.5 # If second pass testb $0x80,(%si) # Active? jnz main.5 # Yes main.3: add $0x10,%si # Next entry incb %dh # Partition cmpb $0x1+PRT_NUM,%dh # In table? jb main.2 # Yes dec %cx # Do two jcxz main.1 # passes
Если обнаружен слайс FreeBSD, выполнение продолжается на метке main.5
. Обратите внимание, что при обнаружении слайса FreeBSD %si
указывает на соответствующую запись в таблице разделов, а %dh
содержит номер раздела. Мы предполагаем, что слайс FreeBSD найден, поэтому продолжаем выполнение на метке main.5
:
main.5: mov %dx,MEM_ARG # Save args movb $NSECT,%dh # Sector count callw nread # Read disk mov $MEM_BTX,%bx # BTX mov 0xa(%bx),%si # Get BTX length and set add %bx,%si # %si to start of boot2.bin mov $MEM_USR+SIZ_PAG*2,%di # Client page 2 mov $MEM_BTX+(NSECT-1)*SIZ_SEC,%cx # Byte sub %si,%cx # count rep # Relocate movsb # client
Напомним, что в данный момент регистр %si
указывает на запись среза FreeBSD в таблице разделов MBR, поэтому вызов nread
фактически прочитает секторы в начале этого раздела. Аргумент, переданный в регистре %dh
, указывает nread
прочитать 16 секторов диска. Напомним, что первые 512 байт, или первый сектор слайса FreeBSD, совпадает с программой boot1. Также напомним, что файл, записанный в начало слайса FreeBSD, это не /boot/boot1, а /boot/boot. Давайте посмотрим на размер этих файлов в файловой системе:
-r--r--r-- 1 root wheel 512B Jan 8 00:15 /boot/boot0
-r--r--r-- 1 root wheel 512B Jan 8 00:15 /boot/boot1
-r--r--r-- 1 root wheel 7.5K Jan 8 00:15 /boot/boot2
-r--r--r-- 1 root wheel 8.0K Jan 8 00:15 /boot/boot
Оба файла boot0 и boot1 имеют размер 512 байт каждый, поэтому они занимают ровно один сектор диска. boot2 значительно больше, так как содержит как сервер BTX, так и клиент boot2. Наконец, файл под названием просто boot на 512 байт больше, чем boot2. Этот файл представляет собой объединение boot1 и boot2. Как уже отмечалось, boot0 записывается в самый первый сектор диска (MBR), а boot записывается в первый сектор раздела FreeBSD; boot1 и boot2 не записываются на диск. Команда, используемая для объединения boot1 и boot2 в единый файл boot, выглядит просто как cat boot1 boot2 > boot
.
Итак, boot1 занимает ровно первые 512 байт boot, и, поскольку boot записывается в первый сектор слайса FreeBSD, boot1 полностью помещается в этот первый сектор. Когда nread
читает первые 16 секторов слайса FreeBSD, он фактически читает весь файл boot [6]. Более подробно о том, как boot формируется из boot1 и boot2, мы увидим в следующем разделе.
Напомним, что nread
использует адрес памяти 0x8c00
в качестве буфера передачи для хранения прочитанных секторов. Этот адрес выбран не случайно. Действительно, поскольку boot1 принадлежит первым 512 байтам, он оказывается в диапазоне адресов 0x8c00
-0x8dff
. Следующие 512 байт (диапазон 0x8e00
-0x8fff
) используются для хранения bsdlabel [7].
Начиная с адреса 0x9000
находится начало сервера BTX, и сразу за ним следует клиент boot2. Сервер BTX действует как ядро и выполняется в защищённом режиме с наивысшим уровнем привилегий. В отличие от этого, клиенты BTX (например, boot2) выполняются в пользовательском режиме. Мы увидим, как это реализовано, в следующем разделе. Код после вызова nread
находит начало boot2 в буфере памяти и копирует его по адресу 0xc000
. Это связано с тем, что сервер BTX размещает boot2 для выполнения в сегменте, начинающемся с 0xa000
. Мы подробно рассмотрим это в следующем разделе.
Последний блок кода в boot1 разрешает доступ к памяти выше 1MB [8] и завершается переходом к начальной точке сервера BTX:
seta20: cli # Disable interrupts seta20.1: dec %cx # Timeout? jz seta20.3 # Yes inb $0x64,%al # Get status testb $0x2,%al # Busy? jnz seta20.1 # Yes movb $0xd1,%al # Command: Write outb %al,$0x64 # output port seta20.2: inb $0x64,%al # Get status testb $0x2,%al # Busy? jnz seta20.2 # Yes movb $0xdf,%al # Enable outb %al,$0x60 # A20 seta20.3: sti # Enable interrupts jmp 0x9010 # Start BTX
1.6. Сервер BTX
Далее в нашей последовательности загрузки идёт сервер BTX. Давайте быстро вспомним, как мы сюда попали:
BIOS загружает абсолютный сектор один (MBR или boot0) по адресу
0x7c00
и переходит туда.boot0 перемещает себя по адресу
0x600
, по которому он был слинкован для выполнения, и переходит туда. Затем он читает первый сектор среза FreeBSD (который содержит boot1) в адрес0x7c00
и переходит туда.boot1 загружает первые 16 секторов среза FreeBSD по адресу
0x8c00
. Эти 16 секторов, или 8192 байта, представляют собой весь файл boot. Файл является объединением boot1 и boot2. boot2, в свою очередь, содержит сервер BTX и клиент boot2. Наконец, выполняется переход по адресу0x9010
, точке входа сервера BTX.
Прежде чем изучать сервер BTX подробно, давайте рассмотрим, как создается единый, всеобъемлющий файл boot. Способ сборки boot определен в его Makefile (stand/i386/boot2/Makefile). Рассмотрим правило, которое создает файл boot:
boot: boot1 boot2 cat boot1 boot2 > boot
Это говорит нам, что boot1 и boot2 необходимы, и правило просто объединяет их для создания одного файла с именем boot. Правила для создания boot1 также довольно просты:
boot1: boot1.out ${OBJCOPY} -S -O binary boot1.out ${.TARGET} boot1.out: boot1.o ${LD} ${LD_FLAGS} -e start --defsym ORG=${ORG1} -T ${LDSCRIPT} -o ${.TARGET} boot1.o
Для применения правила создания boot1 необходимо собрать boot1.out. Это, в свою очередь, зависит от наличия boot1.o. Последний файл является результатом ассемблирования нашего знакомого boot1.S без компоновки. Теперь применяется правило создания boot1.out. Оно указывает, что boot1.o должен быть скомпонован с точкой входа start
и начальным адресом 0x7c00
. Наконец, boot1 создается из boot1.out применением соответствующего правила. Это команда objcopy, применяемая к boot1.out. Обратите внимание на флаги, передаваемые objcopy: -S
указывает на удаление всей информации о перемещении и символов; -O binary
указывает формат вывода, то есть простой, неформатированный двоичный файл.
Имея boot1, давайте посмотрим, как устроен boot2:
boot2: boot2.ld @set -- `ls -l ${.ALLSRC}`; x=$$((${BOOT2SIZE}-$$5)); \ echo "$$x bytes available"; test $$x -ge 0 ${DD} if=${.ALLSRC} of=${.TARGET} bs=${BOOT2SIZE} conv=sync boot2.ld: boot2.ldr boot2.bin ${BTXKERN} btxld -v -E ${ORG2} -f bin -b ${BTXKERN} -l boot2.ldr \ -o ${.TARGET} -P 1 boot2.bin boot2.ldr: ${DD} if=/dev/zero of=${.TARGET} bs=512 count=1 boot2.bin: boot2.out ${OBJCOPY} -S -O binary boot2.out ${.TARGET} boot2.out: ${BTXCRT} boot2.o sio.o ashldi3.o ${LD} ${LD_FLAGS} --defsym ORG=${ORG2} -T ${LDSCRIPT} -o ${.TARGET} ${.ALLSRC} boot2.h: boot1.out ${NM} -t d ${.ALLSRC} | awk '/([0-9])+ T xread/ \ { x = $$1 - ORG1; \ printf("#define XREADORG %#x\n", REL1 + x) }' \ ORG1=`printf "%d" ${ORG1}` \ REL1=`printf "%d" ${REL1}` > ${.TARGET}
Механизм сборки boot2 гораздо сложнее. Отметим наиболее важные моменты. Список зависимостей выглядит следующим образом:
boot2: boot2.ld boot2.ld: boot2.ldr boot2.bin ${BTXDIR} boot2.bin: boot2.out boot2.out: ${BTXDIR} boot2.o sio.o ashldi3.o boot2.h: boot1.out
Отметим, что изначально файл заголовка boot2.h отсутствует, но его создание зависит от boot1.out, который у нас уже есть. Правило его создания немного лаконично, но важно то, что результат, boot2.h, выглядит примерно так:
#define XREADORG 0x725
Напомним, что boot1 был перемещён (т.е. скопирован из 0x7c00
в 0x700
). Это перемещение теперь обретает смысл, потому что, как мы увидим, сервер BTX освобождает часть памяти, включая область, куда boot1 был изначально загружен. Однако серверу BTX необходим доступ к функции xread
из boot1; согласно выводу boot2.h, эта функция находится по адресу 0x725
. Действительно, сервер BTX использует функцию xread
из перемещённого кода boot1. Теперь эта функция доступна из клиента boot2.
Следующее правило указывает компоновщику на необходимость связать различные файлы (ashldi3.o, boot2.o и sio.o). Обратите внимание, что выходной файл boot2.out компонуется для выполнения по адресу 0x2000
(${ORG2}). Напомним, что boot2 будет выполняться в пользовательском режиме внутри специального пользовательского сегмента, созданного сервером BTX. Этот сегмент начинается с адреса 0xa000
. Также помните, что часть boot2 в boot была скопирована по адресу 0xc000
, то есть со смещением 0x2000
от начала пользовательского сегмента, поэтому boot2 будет работать корректно при передаче управления на него. Далее, boot2.bin создается из boot2.out путем удаления символов и информации о формате; boot2.bin представляет собой сырой бинарный файл. Теперь обратите внимание, что файл boot2.ldr создается как 512-байтный файл, заполненный нулями. Это пространство зарезервировано для bsdlabel.
Теперь, когда у нас есть файлы boot1, boot2.bin и boot2.ldr, осталось только добавить сервер BTX перед созданием универсального файла boot. Сервер BTX находится в stand/i386/btx/btx; у него есть собственный Makefile со своим набором правил для сборки. Важно отметить, что он также компилируется как сырой бинарный файл и линкуется для выполнения по адресу 0x9000
. Подробности можно найти в stand/i386/btx/btx/Makefile.
Имея файлы, составляющие программу boot, последним шагом является их объединение. Это выполняется специальной программой под названием btxld (исходный код расположен в /usr/src/usr.sbin/btxld). Некоторые аргументы этой программы включают имя выходного файла (boot), его точку входа (0x2000
) и формат файла (бинарный). Различные файлы окончательно объединяются этой утилитой в файл boot, который состоит из boot1, boot2, bsdlabel
и сервера BTX. Этот файл, занимающий ровно 16 секторов или 8192 байта, записывается в начало раздела FreeBSD во время установки. Теперь перейдем к изучению программы сервера BTX.
Сервер BTX подготавливает простое окружение и переключается из 16-битного реального режима в 32-битный защищённый режим, непосредственно перед передачей управления клиенту. Это включает инициализацию и обновление следующих структур данных:
Изменяет
Таблицу Векторов Прерываний (IVT)
. IVT предоставляет обработчики исключений и прерываний для кода в Реальном Режиме.Создается
Таблица дескрипторов прерываний (IDT)
. В ней предусмотрены записи для исключений процессора, аппаратных прерываний, двух системных вызовов и интерфейса V86. IDT предоставляет обработчики исключений и прерываний для кода в защищенном режиме.Создается
Сегмент состояния задачи (TSS)
. Это необходимо, потому что процессор работает на наименее привилегированном уровне при выполнении клиента (boot2), но на наиболее привилегированном уровне при выполнении сервера BTX.Устанавливается GDT (Глобальная Таблица Дескрипторов). Создаются записи (дескрипторы) для кода и данных супервизора, кода и данных пользователя, а также кода и данных реального режима. [9]
Приступим к изучению фактической реализации. Напомним, что boot1 выполнил переход на адрес 0x9010
— точку входа сервера BTX. Прежде чем изучать выполнение программы там, обратите внимание, что сервер BTX имеет специальный заголовок в диапазоне адресов 0x9000-0x900f
, непосредственно перед точкой входа. Этот заголовок определён следующим образом:
start: # Start of code /* * BTX header. */ btx_hdr: .byte 0xeb # Machine ID .byte 0xe # Header size .ascii "BTX" # Magic .byte 0x1 # Major version .byte 0x2 # Minor version .byte BTX_FLAGS # Flags .word PAG_CNT-MEM_ORG>>0xc # Paging control .word break-start # Text size .long 0x0 # Entry address
Обратите внимание, что первые два байта — это 0xeb
и 0xe
. В архитектуре IA-32 эти два байта интерпретируются как относительный переход за заголовок к точке входа, поэтому теоретически boot1 мог бы перейти сюда (адрес 0x9000
) вместо адреса 0x9010
. Обратите внимание, что последнее поле в заголовке BTX — это указатель на точку входа клиента (boot2)b2. Это поле исправляется во время компоновки.
Сразу после заголовка следует точка входа сервера BTX:
/* * Initialization routine. */ init: cli # Disable interrupts xor %ax,%ax # Zero/segment mov %ax,%ss # Set up mov $MEM_ESP0,%sp # stack mov %ax,%es # Address mov %ax,%ds # data pushl $0x2 # Clear popfl # flags
Этот код отключает прерывания, устанавливает рабочий стек (начиная с адреса 0x1800
) и очищает флаги в регистре EFLAGS. Обратите внимание, что инструкция popfl
извлекает двойное слово (4 байта) из стека и помещает его в регистр EFLAGS. Поскольку извлекаемое значение фактически равно 2
, регистр EFLAGS эффективно очищается (IA-32 требует, чтобы бит 2 регистра EFLAGS всегда был равен 1).
Следующий блок кода очищает (устанавливает в 0
) диапазон памяти 0x5e00-0x8fff
. В этом диапазоне будут созданы различные структуры данных:
/* * Initialize memory. */ mov $MEM_IDT,%di # Memory to initialize mov $(MEM_ORG-MEM_IDT)/2,%cx # Words to zero rep # Zero-fill stosw # memory
Напомним, что boot1 изначально загружался по адресу 0x7c00
, поэтому при такой инициализации памяти эта копия фактически исчезла. Однако также напомним, что boot1 был перемещён на адрес 0x700
, поэтому эта копия всё ещё находится в памяти, и сервер BTX будет её использовать.
Далее обновляется таблица векторов прерываний (IVT) в реальном режиме. IVT представляет собой массив пар сегмент/смещение для обработчиков исключений и прерываний. BIOS обычно сопоставляет аппаратные прерывания с векторами прерываний 0x8
–0xf
и 0x70
–0x77
, но, как будет показано, программируемый контроллер прерываний 8259A, микросхема, управляющая фактическим сопоставлением аппаратных прерываний с векторами прерываний, программируется для переназначения этих векторов прерываний с 0x8
–0xf
на 0x20
–0x27
и с 0x70
–0x77
на 0x28
–0x2f
. Таким образом, обработчики прерываний предоставляются для векторов прерываний 0x20
–0x2f
. Причина, по которой обработчики, предоставляемые BIOS, не используются напрямую, заключается в том, что они работают в 16-битном реальном режиме, но не в 32-битном защищённом режиме. Вскоре будет выполнен переход в 32-битный защищённый режим. Однако сервер BTX настраивает механизм для эффективного использования обработчиков, предоставляемых BIOS:
/* * Update real mode IDT for reflecting hardware interrupts. */ mov $intr20,%bx # Address first handler mov $0x10,%cx # Number of handlers mov $0x20*4,%di # First real mode IDT entry init.0: mov %bx,(%di) # Store IP inc %di # Address next inc %di # entry stosw # Store CS add $4,%bx # Next handler loop init.0 # Next IRQ
Следующий блок создает IDT (таблицу дескрипторов прерываний). IDT в защищенном режиме аналогична IVT в реальном режиме. То есть, IDT описывает различные обработчики исключений и прерываний, используемые, когда процессор работает в защищенном режиме. По сути, она также состоит из массива пар сегмент/смещение, хотя структура несколько сложнее, поскольку сегменты в защищенном режиме отличаются от реального режима, и применяются различные механизмы защиты:
/* * Create IDT. */ mov $MEM_IDT,%di # IDT's address mov $idtctl,%si # Control string init.1: lodsb # Get entry cbw # count xchg %ax,%cx # as word jcxz init.4 # If done lodsb # Get segment xchg %ax,%dx # P:DPL:type lodsw # Get control xchg %ax,%bx # set lodsw # Get handler offset mov $SEL_SCODE,%dh # Segment selector init.2: shr %bx # Handle this int? jnc init.3 # No mov %ax,(%di) # Set handler offset mov %dh,0x2(%di) # and selector mov %dl,0x5(%di) # Set P:DPL:type add $0x4,%ax # Next handler init.3: lea 0x8(%di),%di # Next entry loop init.2 # Till set done jmp init.1 # Continue
Каждая запись в IDT
имеет длину 8 байт. Помимо информации о сегменте/смещении, они также описывают тип сегмента, уровень привилегий и присутствует ли сегмент в памяти. Структура организована так, что векторы прерываний от 0
до 0xf
(исключения) обрабатываются функцией intx00
; вектор 0x10
(также исключение) обрабатывается intx10
; аппаратные прерывания, которые позже настраиваются начиная с вектора 0x20
и до вектора 0x2f
, обрабатываются функцией intx20
. Наконец, вектор прерывания 0x30
, используемый для системных вызовов, обрабатывается intx30
, а векторы 0x31
и 0x32
обрабатываются intx31
. Необходимо отметить, что только дескрипторы для векторов прерываний 0x30
, 0x31
и 0x32
имеют уровень привилегий 3, такой же, как у клиента boot2, что означает, что клиент может выполнить программно-генерируемое прерывание к этим векторам через инструкцию int
без ошибки (это способ, которым boot2 использует сервисы, предоставляемые сервером BTX). Также обратите внимание, что только программно-генерируемые прерывания защищены от кода, выполняющегося на более низких уровнях привилегий. Аппаратно-генерируемые прерывания и исключения, генерируемые процессором, всегда обрабатываются корректно, независимо от фактических привилегий.
Следующий шаг — инициализация TSS (сегмента состояния задачи). TSS — это аппаратная функция, которая помогает операционной системе или исполнительному ПО реализовать многозадачность через абстракцию процессов. Архитектура IA-32 требует создания и использования как минимум одного TSS, если используются механизмы многозадачности или определены различные уровни привилегий. Поскольку клиент boot2 выполняется на уровне привилегий 3, а сервер BTX работает на уровне привилегий 0, необходимо определить TSS:
/* * Initialize TSS. */ init.4: movb $_ESP0H,TSS_ESP0+1(%di) # Set ESP0 movb $SEL_SDATA,TSS_SS0(%di) # Set SS0 movb $_TSSIO,TSS_MAP(%di) # Set I/O bit map base
Обратите внимание, что в TSS указано значение для указателя стека и сегмента стека уровня привилегий 0. Это необходимо, потому что если прерывание или исключение получено во время выполнения boot2 на уровне привилегий 3, процессор автоматически переключается на уровень привилегий 0, поэтому требуется новый рабочий стек. Наконец, полю базового адреса карты ввода-вывода TSS присваивается значение, которое представляет собой 16-битное смещение от начала TSS до битовой карты разрешений ввода-вывода и битовой карты перенаправления прерываний.
После создания IDT и TSS процессор готов к переходу в защищённый режим. Это выполняется в следующем блоке:
/* * Bring up the system. */ mov $0x2820,%bx # Set protected mode callw setpic # IRQ offsets lidt idtdesc # Set IDT lgdt gdtdesc # Set GDT mov %cr0,%eax # Switch to protected inc %ax # mode mov %eax,%cr0 # ljmp $SEL_SCODE,$init.8 # To 32-bit code .code32 init.8: xorl %ecx,%ecx # Zero movb $SEL_SDATA,%cl # To 32-bit movw %cx,%ss # stack
Сначала вызывается setpic
для программирования 8259A PIC (программируемого контроллера прерываний). Этот чип подключен к нескольким источникам аппаратных прерываний. При получении прерывания от устройства он сигнализирует процессору соответствующим вектором прерывания. Это можно настроить так, чтобы определенные прерывания были связаны с конкретными векторами прерываний, как объяснялось ранее. Затем регистры IDTR (Interrupt Descriptor Table Register) и GDTR (Global Descriptor Table Register) загружаются инструкциями lidt
и lgdt
соответственно. Эти регистры загружаются базовым адресом и предельным адресом для IDT и GDT. Следующие три инструкции устанавливают бит Protection Enable (PE) в регистре %cr0
. Это фактически переключает процессор в 32-битный защищенный режим. Затем выполняется дальний переход на init.8
с использованием селектора сегмента SEL_SCODE, который выбирает сегмент кода супервизора (Supervisor Code Segment). После этого перехода процессор фактически работает на уровне CPL 0 — наиболее привилегированном уровне. Наконец, для стека выбирается сегмент данных супервизора (Supervisor Data Segment) путем присвоения селектора сегмента SEL_SDATA регистру %ss
. Этот сегмент данных также имеет уровень привилегий 0
.
Наш последний блок кода отвечает за загрузку TR (Регистра Задач) с селектором сегмента для TSS, который мы создали ранее, и настройку окружения пользовательского режима перед передачей управления исполнения клиенту boot2.
/* * Launch user task. */ movb $SEL_TSS,%cl # Set task ltr %cx # register movl $MEM_USR,%edx # User base address movzwl %ss:BDA_MEM,%eax # Get free memory shll $0xa,%eax # To bytes subl $ARGSPACE,%eax # Less arg space subl %edx,%eax # Less base movb $SEL_UDATA,%cl # User data selector pushl %ecx # Set SS pushl %eax # Set ESP push $0x202 # Set flags (IF set) push $SEL_UCODE # Set CS pushl btx_hdr+0xc # Set EIP pushl %ecx # Set GS pushl %ecx # Set FS pushl %ecx # Set DS pushl %ecx # Set ES pushl %edx # Set EAX movb $0x7,%cl # Set remaining init.9: push $0x0 # general loop init.9 # registers #ifdef BTX_SERIAL call sio_init # setup the serial console #endif popa # and initialize popl %es # Initialize popl %ds # user popl %fs # segment popl %gs # registers iret # To user mode
Обратите внимание, что среда клиента включает селектор сегмента стека и указатель стека (регистры %ss
и %esp
). Действительно, как только TR загружается соответствующим селектором сегмента стека (инструкция ltr
), указатель стека вычисляется и помещается в стек вместе с селектором сегмента стека. Затем значение 0x202
помещается в стек; это значение, которое EFLAGS получит при передаче управления клиенту. Также в стек помещаются селектор сегмента кода пользовательского режима и точка входа клиента. Напомним, что эта точка входа прописывается в заголовке BTX во время компоновки. Наконец, селекторы сегментов (хранящиеся в регистре %ecx
) для регистров сегментов %gs, %fs, %ds и %es
помещаются в стек вместе со значением из %edx
(0xa000
). Примите во внимание эти значения, помещенные в стек (они скоро будут извлечены). Затем значения для оставшихся регистров общего назначения также помещаются в стек (обратите внимание на цикл loop
, который помещает значение 0
семь раз). Теперь начнётся извлечение значений из стека. Сначала инструкция popa
извлекает из стека последние семь помещённых значений. Они сохраняются в регистрах общего назначения в порядке %edi, %esi, %ebp, %ebx, %edx, %ecx, %eax
. Затем различные селекторы сегментов, помещённые в стек, извлекаются в соответствующие регистры сегментов. В стеке остаются ещё пять значений. Они извлекаются при выполнении инструкции iret
. Эта инструкция сначала извлекает значение, которое было помещено из заголовка BTX. Это значение является указателем на точку входа boot2. Оно помещается в регистр %eip
— регистр указателя инструкций. Затем селектор сегмента кода пользователя извлекается и копируется в регистр %cs
. Помните, что уровень привилегий этого сегмента — 3, наименее привилегированный уровень. Это означает, что мы должны предоставить значения для стека этого уровня привилегий. Именно поэтому процессор, помимо дальнейшего извлечения значения для регистра EFLAGS, выполняет ещё два извлечения из стека. Эти значения попадают в указатель стека (%esp
) и сегмент стека (%ss
). Теперь выполнение продолжается с точки входа boot0
.
Важно отметить, как определяется сегмент пользовательского кода. Базовый адрес этого сегмента установлен на 0xa000
. Это означает, что адреса памяти кода являются относительными к адресу 0xa000; если код, который выполняется, извлекается из адреса 0x2000
, фактический адрес в памяти будет 0xa000+0x2000=0xc000
.
1.7. Этап загрузки boot2
boot2
определяет важную структуру, struct bootinfo
. Эта структура инициализируется boot2
и передается загрузчику, а затем ядру. Некоторые узлы этой структуры устанавливаются boot2
, остальные — загрузчиком. Эта структура, среди прочей информации, содержит имя файла ядра, геометрию жесткого диска в BIOS, номер диска в BIOS для загрузочного устройства, доступную физическую память, указатель envp
и т.д. Ее определение выглядит так:
/usr/include/machine/bootinfo.h: struct bootinfo { u_int32_t bi_version; u_int32_t bi_kernelname; /* represents a char * */ u_int32_t bi_nfs_diskless; /* struct nfs_diskless * */ /* End of fields that are always present. */ #define bi_endcommon bi_n_bios_used u_int32_t bi_n_bios_used; u_int32_t bi_bios_geom[N_BIOS_GEOM]; u_int32_t bi_size; u_int8_t bi_memsizes_valid; u_int8_t bi_bios_dev; /* bootdev BIOS unit number */ u_int8_t bi_pad[2]; u_int32_t bi_basemem; u_int32_t bi_extmem; u_int32_t bi_symtab; /* struct symtab * */ u_int32_t bi_esymtab; /* struct symtab * */ /* Items below only from advanced bootloader */ u_int32_t bi_kernend; /* end of kernel space */ u_int32_t bi_envp; /* environment */ u_int32_t bi_modulep; /* preloaded modules */ };
boot2
входит в бесконечный цикл, ожидая ввода пользователя, затем вызывает load()
. Если пользователь ничего не нажимает, цикл прерывается по таймауту, и load()
загружает файл по умолчанию (/boot/loader). Функции ino_t lookup(char *filename)
и int xfsread(ino_t inode, void *buf, size_t nbyte)
используются для чтения содержимого файла в память. /boot/loader — это ELF-бинарный файл, но с заголовком ELF, перед которым добавлена структура struct exec
из a.out. load()
анализирует ELF-заголовок загрузчика, загружает содержимое /boot/loader в память и передаёт управление на точку входа загрузчика:
stand/i386/boot2/boot2.c: __exec((caddr_t)addr, RB_BOOTINFO | (opts & RBX_MASK), MAKEBOOTDEV(dev_maj[dsk.type], dsk.slice, dsk.unit, dsk.part), 0, 0, 0, VTOP(&bootinfo));
1.8. Этап загрузчика (loader)
Загрузчик также является клиентом BTX. Я не буду подробно описывать его здесь, существует исчерпывающая man-страница, написанная Майком Смитом: loader(8). Основные механизмы и BTX были рассмотрены выше.
Основная задача загрузчика — загрузить ядро. Когда ядро загружено в память, загрузчик вызывает его:
stand/common/boot.c: /* Call the exec handler from the loader matching the kernel */ file_formats[fp->f_loader]->l_exec(fp);
1.9. Инициализация ядра
Давайте рассмотрим команду, которая компонует ядро. Это поможет определить точное местоположение, где загрузчик передает выполнение ядру. Это местоположение является фактической точкой входа ядра. Данная команда теперь исключена из sys/conf/Makefile.i386. Интересующее нас содержимое можно найти в /usr/obj/usr/src/i386.i386/sys/GENERIC/.
/usr/obj/usr/src/i386.i386/sys/GENERIC/kernel.meta: ld -m elf_i386_fbsd -Bdynamic -T /usr/src/sys/conf/ldscript.i386 --build-id=sha1 --no-warn-mismatch \ --warn-common --export-dynamic --dynamic-linker /red/herring -X -o kernel locore.o <lots of kernel .o files>
Вот несколько интересных наблюдений. Во-первых, ядро представляет собой динамически связанный бинарный файл ELF, но динамический компоновщик для ядра — это /red/herring, что явно является фиктивным файлом. Во-вторых, взглянув на файл sys/conf/ldscript.i386, можно понять, какие параметры ld используются при компиляции ядра. Читая первые несколько строк, видим, что строка
sys/conf/ldscript.i386: ENTRY(btext)
говорит, что точка входа ядра — это символ btext
. Этот символ определён в locore.s:
sys/i386/i386/locore.s: .text /********************************************************************** * * This is where the bootblocks start us, set the ball rolling... * */ NON_GPROF_ENTRY(btext)
Сначала регистр EFLAGS устанавливается в предопределённое значение 0x00000002. Затем инициализируются все сегментные регистры:
sys/i386/i386/locore.s: /* Don't trust what the BIOS gives for eflags. */ pushl $PSL_KERNEL popfl /* * Don't trust what the BIOS gives for %fs and %gs. Trust the bootstrap * to set %cs, %ds, %es and %ss. */ mov %ds, %ax mov %ax, %fs mov %ax, %gs
btext вызывает подпрограммы recover_bootinfo()
и identify_cpu()
, которые также определены в locore.s. Вот описание их функций:
| Эта процедура разбирает параметры, переданные ядру при загрузке.
Ядро могло быть загружено тремя способами: загрузчиком (как описано выше), старыми загрузочными блоками диска или по старой процедуре загрузки без диска.
Эта функция определяет метод загрузки и сохраняет структуру |
| Эта функция пытается определить, на каком процессоре она выполняется, сохраняя найденное значение в переменной |
Следующие шаги включают активацию VME, если процессор поддерживает эту функцию:
sys/i386/i386/mpboot.s: testl $CPUID_VME,%edx jz 3f orl $CR4_VME,%eax 3: movl %eax,%cr4
Затем, включение подкачки:
sys/i386/i386/mpboot.s: /* Now enable paging */ movl IdlePTD_nopae, %eax movl %eax,%cr3 /* load ptd addr into mmu */ movl %cr0,%eax /* get control word */ orl $CR0_PE|CR0_PG,%eax /* enable paging */ movl %eax,%cr0 /* and let's page NOW! */
Следующие три строки кода необходимы, потому что была установлена подкачка, поэтому требуется переход для продолжения выполнения в виртуализированном адресном пространстве:
sys/i386/i386/mpboot.s: pushl $mp_begin /* jump to high mem */ ret /* now running relocated at KERNBASE where the system is linked to run */ mp_begin: /* now running relocated at KERNBASE */
Функция init386()
вызывается с указателем на первую свободную физическую страницу, после чего следует вызов mi_startup()
. init386
— это архитектурно-зависимая функция инициализации, а mi_startup()
— архитектурно-независимая (префикс 'mi_' означает Machine Independent, то есть «независимая от машины»). Ядро никогда не возвращается из mi_startup()
, и, вызывая её, завершает загрузку:
sys/i386/i386/locore.s: pushl physfree /* value of first for init386(first) */ call init386 /* wire 386 chip for unix operation */ addl $4,%esp movl %eax,%esp /* Switch to true top of stack. */ call mi_startup /* autoconfiguration, mountroot etc */ /* NOTREACHED */
1.9.1. init386()
init386()
определена в sys/i386/i386/machdep.c и выполняет низкоуровневую инициализацию, специфичную для чипа i386. Переход в защищённый режим был выполнен загрузчиком. Загрузчик создал самую первую задачу, в которой ядро продолжает работать. Прежде чем рассматривать код, рассмотрим задачи, которые процессор должен выполнить для инициализации выполнения в защищённом режиме:
Инициализировать настраиваемые параметры ядра, переданные из загрузочной программы.
Подготовить GDT.
Подготовить IDT.
Инициализировать системную консоль.
Инициализировать DDB, если он скомпилирован в ядро.
Инициализировать TSS.
Подготовить LDT.
Настройка pcb для thread0.
init386()
инициализирует настраиваемые параметры, переданные из bootstrap, устанавливая указатель окружения (envp) и вызывая init_param1()
. Указатель envp был передан из loader в структуре bootinfo
:
sys/i386/i386/machdep.c: /* Init basic tunables, hz etc */ init_param1();
init_param1()
определена в sys/kern/subr_param.c. Этот файл содержит ряд sysctl, а также две функции, init_param1()
и init_param2()
, которые вызываются из init386()
:
sys/kern/subr_param.c: hz = -1; TUNABLE_INT_FETCH("kern.hz", &hz); if (hz == -1) hz = vm_guest > VM_GUEST_NO ? HZ_VM : HZ;
TUNABLE_<typename>_FETCH
используется для получения значения из окружения:
/usr/src/sys/sys/kernel.h: #define TUNABLE_INT_FETCH(path, var) getenv_int((path), (var))
Sysctl kern.hz
представляет собой такт системных часов. Кроме того, эти параметры sysctl устанавливаются функцией init_param1()
: kern.maxswzone, kern.maxbcache, kern.maxtsiz, kern.dfldsiz, kern.maxdsiz, kern.dflssiz, kern.maxssiz, kern.sgrowsiz
.
Затем init386()
подготавливает Глобальную Таблицу Дескрипторов
(GDT). Каждая задача на x86 выполняется в своем собственном виртуальном
адресном пространстве, и это пространство адресуется парой
сегмент:смещение. Например, если текущая инструкция, которую должен
выполнить процессор, находится по адресу CS:EIP, то линейный виртуальный
адрес этой инструкции будет "виртуальный адрес кодового сегмента CS"
EIP. Для удобства сегменты начинаются с виртуального адреса 0 и
заканчиваются на границе 4 ГБ. Таким образом, линейный виртуальный адрес
инструкции в данном примере будет просто значением EIP. Сегментные регистры,
такие как CS, DS и другие, являются селекторами, то есть индексами в GDT
(если быть более точным, индекс — это не сам селектор, а поле INDEX в
селекторе). GDT в FreeBSD содержит дескрипторы для 15 селекторов на каждый
CPU:
sys/i386/i386/machdep.c: union descriptor gdt0[NGDT]; /* initial global descriptor table */ union descriptor *gdt = gdt0; /* global descriptor table */ sys/x86/include/segments.h: /* * Entries in the Global Descriptor Table (GDT) */ #define GNULL_SEL 0 /* Null Descriptor */ #define GPRIV_SEL 1 /* SMP Per-Processor Private Data */ #define GUFS_SEL 2 /* User %fs Descriptor (order critical: 1) */ #define GUGS_SEL 3 /* User %gs Descriptor (order critical: 2) */ #define GCODE_SEL 4 /* Kernel Code Descriptor (order critical: 1) */ #define GDATA_SEL 5 /* Kernel Data Descriptor (order critical: 2) */ #define GUCODE_SEL 6 /* User Code Descriptor (order critical: 3) */ #define GUDATA_SEL 7 /* User Data Descriptor (order critical: 4) */ #define GBIOSLOWMEM_SEL 8 /* BIOS low memory access (must be entry 8) */ #define GPROC0_SEL 9 /* Task state process slot zero and up */ #define GLDT_SEL 10 /* Default User LDT */ #define GUSERLDT_SEL 11 /* User LDT */ #define GPANIC_SEL 12 /* Task state to consider panic from */ #define GBIOSCODE32_SEL 13 /* BIOS interface (32bit Code) */ #define GBIOSCODE16_SEL 14 /* BIOS interface (16bit Code) */ #define GBIOSDATA_SEL 15 /* BIOS interface (Data) */ #define GBIOSUTIL_SEL 16 /* BIOS interface (Utility) */ #define GBIOSARGS_SEL 17 /* BIOS interface (Arguments) */ #define GNDIS_SEL 18 /* For the NDIS layer */ #define NGDT 19
Обратите внимание, что эти #defines
не являются самими селекторами, а лишь полем INDEX
селектора, поэтому они точно соответствуют индексам GDT. Например, реальный селектор для кода ядра (GCODE_SEL
) имеет значение 0x20
.
Следующий шаг — инициализация таблицы дескрипторов прерываний (IDT). Эта таблица используется процессором при возникновении программного или аппаратного прерывания. Например, чтобы выполнить системный вызов, пользовательское приложение использует инструкцию INT 0x80
. Это программное прерывание, поэтому аппаратное обеспечение процессора ищет запись с индексом 0x80 в IDT. Эта запись указывает на процедуру обработки данного прерывания, в данном конкретном случае это будет шлюз системных вызовов ядра. IDT может содержать максимум 256 (0x100) записей. Ядро выделяет NIDT записей для IDT, где NIDT — это максимум (256):
sys/i386/i386/machdep.c: static struct gate_descriptor idt0[NIDT]; struct gate_descriptor *idt = &idt0[0]; /* interrupt descriptor table */
Для каждого прерывания устанавливается соответствующий обработчик. Также настраивается шлюз системного вызова для INT 0x80
:
sys/i386/i386/machdep.c: setidt(IDT_SYSCALL, &IDTVEC(int0x80_syscall), SDT_SYS386IGT, SEL_UPL, GSEL(GCODE_SEL, SEL_KPL));
Итак, когда пользовательское приложение выполняет инструкцию INT 0x80
, управление передаётся функции _Xint0x80_syscall
, которая находится в сегменте кода ядра и будет выполнена с привилегиями супервизора.
Консоль и DDB инициализируются:
sys/i386/i386/machdep.c: cninit(); /* skipped */ kdb_init(); #ifdef KDB if (boothowto & RB_KDB) kdb_enter(KDB_WHY_BOOTFLAGS, "Boot flags requested debugger"); #endif
Сегмент состояния задачи (TSS) — это еще одна структура защищенного режима x86, используемая оборудованием для хранения информации о задаче при переключении задач.
Локальная таблица дескрипторов (LDT) используется для ссылки на код и данные пользовательского пространства. Определено несколько селекторов, указывающих на LDT, включая шлюзы системных вызовов, а также селекторы кода и данных пользователя:
sys/x86/include/segments.h: #define LSYS5CALLS_SEL 0 /* forced by intel BCS */ #define LSYS5SIGR_SEL 1 #define LUCODE_SEL 3 #define LUDATA_SEL 5 #define NLDT (LUDATA_SEL + 1)
Далее инициализируется структура Блока Управления Процессом (struct pcb
) для proc0. proc0 — это структура struct proc
, описывающая процесс ядра. Она всегда присутствует во время работы ядра, поэтому связана с thread0:
sys/i386/i386/machdep.c: register_t init386(int first) { /* ... skipped ... */ proc_linkup0(&proc0, &thread0); /* ... skipped ... */ }
Структура struct pcb
является частью структуры proc. Она определена в /usr/include/machine/pcb.h и содержит информацию процесса, специфичную для архитектуры i386, такую как значения регистров.
1.9.2. mi_startup()
Эта функция выполняет сортировку пузырьком всех объектов инициализации системы, а затем вызывает вход каждого объекта по очереди:
sys/kern/init_main.c: for (sipp = sysinit; sipp < sysinit_end; sipp++) { /* ... skipped ... */ /* Call function */ (*((*sipp)->func))((*sipp)->udata); /* ... skipped ... */ }
Хотя фреймворк sysinit описан в Руководстве разработчика, я рассмотрю его внутреннее устройство.
Каждый объект инициализации системы (объект sysinit) создается путем вызова макроса SYSINIT(). Возьмем, к примеру, объект sysinit announce
. Этот объект выводит сообщение об авторских правах:
sys/kern/init_main.c: static void print_caddr_t(void *data __unused) { printf("%s", (char *)data); } /* ... skipped ... */ SYSINIT(announce, SI_SUB_COPYRIGHT, SI_ORDER_FIRST, print_caddr_t, copyright);
Идентификатор подсистемы для этого объекта — SI_SUB_COPYRIGHT (0x0800001). Таким образом, сообщение об авторских правах будет выведено первым, сразу после инициализации консоли.
Давайте рассмотрим, что именно делает макрос SYSINIT()
. Он раскрывается в макрос C_SYSINIT()
. Макрос C_SYSINIT()
затем раскрывается в статическое объявление структуры struct sysinit
с вызовом другого макроса DATA_SET
:
/usr/include/sys/kernel.h: #define C_SYSINIT(uniquifier, subsystem, order, func, ident) \ static struct sysinit uniquifier ## _sys_init = { \ subsystem, \ order, \ func, \ (ident) \ }; \ DATA_WSET(sysinit_set,uniquifier ## _sys_init); #define SYSINIT(uniquifier, subsystem, order, func, ident) \ C_SYSINIT(uniquifier, subsystem, order, \ (sysinit_cfunc_t)(sysinit_nfunc_t)func, (void *)(ident))
Макрос DATA_SET()
раскрывается в _MAKE_SET()
, и именно в этом макросе скрыта вся магия инициализации системы:
/usr/include/linker_set.h: #define TEXT_SET(set, sym) _MAKE_SET(set, sym) #define DATA_SET(set, sym) _MAKE_SET(set, sym)
После выполнения этих макросов в ядре были созданы различные разделы, включая set.sysinit_set
. Запустив objdump для бинарного файла ядра, можно заметить наличие таких небольших разделов:
% llvm-objdump -h /kernel
Sections:
Idx Name Size VMA Type
10 set_sysctl_set 000021d4 01827078 DATA
16 set_kbddriver_set 00000010 0182a4d0 DATA
20 set_scterm_set 0000000c 0182c75c DATA
21 set_cons_set 00000014 0182c768 DATA
33 set_scrndr_set 00000024 0182c828 DATA
41 set_sysinit_set 000014d8 018fabb0 DATA
Это содержимое экрана показывает, что размер раздела set.sysinit_set составляет 0x14d8 байт, поэтому 0x14d8/sizeof(void *)
объектов sysinit скомпилировано в ядро. Другие разделы, такие как set.sysctl_set
, представляют другие наборы компоновщика.
Определяя переменную типа struct sysinit
, содержимое раздела set.sysinit_set
будет "собрано" в эту переменную:
sys/kern/init_main.c: SET_DECLARE(sysinit_set, struct sysinit);
struct sysinit
определена следующим образом:
sys/sys/kernel.h: struct sysinit { enum sysinit_sub_id subsystem; /* subsystem identifier*/ enum sysinit_elem_order order; /* init order within subsystem*/ sysinit_cfunc_t func; /* function */ const void *udata; /* multiplexer/argument */ };
Возвращаясь к обсуждению mi_startup()
, теперь должно быть понятно, как организованы объекты sysinit. Функция mi_startup()
сортирует их и вызывает каждый. Самый последний объект — это системный планировщик:
/usr/include/sys/kernel.h: enum sysinit_sub_id { SI_SUB_DUMMY = 0x0000000, /* not executed; for linker*/ SI_SUB_DONE = 0x0000001, /* processed*/ SI_SUB_TUNABLES = 0x0700000, /* establish tunable values */ SI_SUB_COPYRIGHT = 0x0800001, /* first use of console*/ ... SI_SUB_LAST = 0xfffffff /* final initialization */ };
Системный планировщик sysinit определен в файле sys/vm/vm_glue.c, а точка входа для этого объекта — scheduler()
. Эта функция фактически представляет собой бесконечный цикл и описывает процесс с PID 0, известный как процесс swapper. Структура thread0, упомянутая ранее, используется для его описания.
Первый пользовательский процесс, называемый init, создаётся объектом sysinit init
:
sys/kern/init_main.c: static void create_init(const void *udata __unused) { struct fork_req fr; struct ucred *newcred, *oldcred; struct thread *td; int error; bzero(&fr, sizeof(fr)); fr.fr_flags = RFFDG | RFPROC | RFSTOPPED; fr.fr_procp = &initproc; error = fork1(&thread0, &fr); if (error) panic("cannot fork init: %d\n", error); KASSERT(initproc->p_pid == 1, ("create_init: initproc->p_pid != 1")); /* divorce init's credentials from the kernel's */ newcred = crget(); sx_xlock(&proctree_lock); PROC_LOCK(initproc); initproc->p_flag |= P_SYSTEM | P_INMEM; initproc->p_treeflag |= P_TREE_REAPER; oldcred = initproc->p_ucred; crcopy(newcred, oldcred); #ifdef MAC mac_cred_create_init(newcred); #endif #ifdef AUDIT audit_cred_proc1(newcred); #endif proc_set_cred(initproc, newcred); td = FIRST_THREAD_IN_PROC(initproc); crcowfree(td); td->td_realucred = crcowget(initproc->p_ucred); td->td_ucred = td->td_realucred; PROC_UNLOCK(initproc); sx_xunlock(&proctree_lock); crfree(oldcred); cpu_fork_kthread_handler(FIRST_THREAD_IN_PROC(initproc), start_init, NULL); } SYSINIT(init, SI_SUB_CREATE_INIT, SI_ORDER_FIRST, create_init, NULL);
Функция create_init()
выделяет новый процесс, вызывая fork1()
, но не помечает его как готовый к выполнению. Когда этот новый процесс будет запланирован для выполнения планировщиком, будет вызвана функция start_init()
. Эта функция определена в init_main.c. Она пытается загрузить и выполнить бинарный файл init, сначала проверяя /sbin/init, затем /sbin/oinit, /sbin/init.bak и, наконец, /rescue/init:
sys/kern/init_main.c: static char init_path[MAXPATHLEN] = #ifdef INIT_PATH __XSTRING(INIT_PATH); #else "/sbin/init:/sbin/oinit:/sbin/init.bak:/rescue/init"; #endif
Изменено: 14 октября 2025 г. by Vladlen Popolitov