В ходе работы нам периодически приходится сталкиваться с достаточно низкоуровневым взаимодействием с аппаратной частью. В данной статье мы хотим показать, каким образом происходит опрос PCI-устройств для их идентификации и загрузки соответствующих драйверов устройств.
В качестве минимальной базы для работы с PCI-устройствами будем использовать ядро, поддерживающее спецификацию Multiboot. Так удастся избежать необходимости писать собственный загрузочный сектор и загрузчик (loader). Кроме того, этот вопрос и так отлично освещен в интернете. В качестве загрузчика будет выступать GRUB. Грузиться мы будем с флэшки, так как с нее удобно загружать и виртуальную, и реальную машину. В качестве виртуальной машины будем использовать QEMU. В качестве реальной машины должна выступать машина с обычным BIOS-ом (не UEFI), поддерживающим загрузку с USB-HDD (обычно присутствует опция Legacy USB support). Для работы понадобятся Ubuntu Linux со следующими программами: expect, qemu, grub (их можно легко установить при помощи команды sudo apt-get install). Используемый gcc должен компилировать 32х битный код.
Рассмотрим первый шаг – создание ядра, поддерживающего спецификацию Multiboot. В случае использования GRUB-а в качестве загрузчика ядро будет создаваться из 3-х файлов:
Kernel.c – основной файл с кодом нашей программы и процедурой main();
Loader.s – содержит заголовок мультизагрузчика для GRUB;
Linker.ld – скрипт компоновщика ld, в котором в частности указывается, по какому адресу будет располагаться ядро.
Содержимое Linker.ld:
ENTRY (loader)
SECTIONS
{
. = 0x00100000;
.text ALIGN (0x1000) :
{
*(.text)
}
.rodata ALIGN (0x1000) :
{
*(.rodata*)
}
.data ALIGN (0x1000) :
{
*(.data)
}
.bss :
{
sbss = .;
*(COMMON)
*(.bss)
ebss = .;
}
}
Скрипт компоновщика указывает, как слинковать уже скомпилированные объектные файлы. В первой строчке указано, что точкой входа в нашем ядре будет адрес с меткой «loader». Далее в скрипте указано, что начиная с адреса 0x00100000 (1Мб) будет располагаться секция text. Секции rodata, data и bss выровнены по 0x1000 (4Кб) и располагаются после секции text.
Содержимое Loader.s:
.global loader
.set FLAGS, 0x0
.set MAGIC, 0x1BADB002
.set CHECKSUM, -(MAGIC + FLAGS)
.align 4
.long MAGIC
.long FLAGS
.long CHECKSUM
# reserve initial kernel stack space
.set STACKSIZE, 0x4000
.lcomm stack, STACKSIZE
.comm mbd, 4
.comm magic, 4
loader:
movl $(stack + STACKSIZE), %esp
movl %eax, magic
movl %ebx, mbd
call kmain
cli
hang:
hlt
jmp hang
GRUB после загрузки образа ядра с диска ищет в первых 8Кб загруженного образа сигнатуру 0x1BADB002. Сигнатура является первым полем заголовка мультизагрузки. Сам заголовок выглядит следующим образом:
Offset | Type | Field Name | Note |
0 | u32 | magic | required |
4 | u32 | flags | required |
8 | u32 | checksum | required |
12 | u32 | header_addr | if flags[16] is set |
16 | u32 | load_addr | if flags[16] is set |
20 | u32 | load_end_addr | if flags[16] is set |
24 | u32 | bss_end_addr | if flags[16] is set |
28 | u32 | entry_addr | if flags[16] is set |
32 | u32 | mode_type | if flags[2] is set |
36 | u32 | width | if flags[2] is set |
40 | u32 | height | if flags[2] is set |
44 | u32 | depth | if flags[2] is set |
Заголовок должен включать в себя минимум 3 поля – magic, flag, checksum. Поле magic является сигнатурой и, как уже было сказано выше, всегда равно 0x1BADB002. Поле flag содержит дополнительные требования к состоянию машины на момент передачи управления ОС. В зависимости от значения этого поля может меняться набор полей в структуре Multiboot Information. Указатель на структуру Multiboot Information содержит регистр EBX в момент передачи управления загружаемому ядру. В нашем случае поле flag имеет значение 0, и заголовок мультизагрузки состоит только из 3-ех полей.
На момент передачи управления ядру процессор работает в защищенном режиме с выключенной страничной адресацией. Обработка прерываний от устройств отключена. GRUB не формирует стек для загружаемого ядра, и это первое что должна сделать операционная система. В нашем случае под стек выделяется 16Кб. Последней выполненной ассемблерной инструкцией будет инструкция call kmain, которая передает управление коду на C, а именно функции void kmain(void).
Содержимое kernel.c:
#include "printf.h"
#include "screen.h"
void kmain(void)
{
clear_screen();
printf(" -- Kernel started! -- n");
}
Пока здесь нет ничего интересного. С точки зрения загрузки в нем не должно присутствовать ничего специфичного, только точка входа для кода на С. Для вывода на экран была добавлена реализация функции printf, найденная на просторах Интернета, и несколько функций для работы с видеопамятью, таких как putchar, clear_screen.
Для сборки ядра будет использоваться следующий простой makefile:
CC = gcc
CFLAGS = -Wall -nostdlib -fno-builtin -nostartfiles -nodefaultlibs
LD = ld
OBJFILES =
loader.o
printf.o
screen.o
pci.o
kernel.o
start: all
cp ./kernel.bin ./flash/boot/grub/
expect ./grub_install.exp
qemu /dev/sdb
all: kernel.bin
.s.o:
as -o $@ $<
.c.o:
$(CC) $(CFLAGS) -o $@ -c $<
kernel.bin: $(OBJFILES)
$(LD) -T linker.ld -o $@ $^
clean:
rm $(OBJFILES) kernel.bin
Теперь у нас есть ядро, которое можно загрузить. Пора проверить, что оно действительно загружается. Установим GRUB на флешку и скажем ему загружать наше ядро при старте. Для этого нужно выполнить следующие шаги:
1. Создать раздел на флешке, отформатировать его в файловую систему, поддерживаемую GRUB-ом (в нашем случае это файловая система FAT32). Мы воспользовались утилитой Disk Utility из комплекта Ubuntu, которая позволила создать раздел:
2. Примонтировать флешку и создать каталог /boot/grub/. Скопировать в него из /usr/lib файлы stage1, stage2, fat_stage1_5. Создать текстовый файл menu.lst в директории /boot/grub/ и записать в него
timeout 5
default 0
title start_kernel
root (hd0,0)
kernel /boot/grub/kernel.bin
Для установки GRUB-а на флешку используется expect-скрипт в файле grub_install.exp. Его содержимое:
log_user 0
spawn grub
expect "grub> "
send "root (hd1,0)r"
expect "grub> "
send "setup (hd1)r"
expect "grub> "
send "quitr"
exit 0
В конкретном случае возможны другие номера дисков и названия устройств. В конечном итоге компиляция и запуск виртуальной машины должны выполняться командой make start. Эта команда из makefile выполнит установку GRUB на флэшку с использованием скрипта grub_install.exp, а затем запустит виртуальную машину QEMU с нашей программой. Поскольку все загружается с реальной флэшки, то с нее можно загрузить не только виртуальную машину QEMU, но и реальный компьютер.
Запущенная виртуальная машина QEMU с нашей программой выглядит следующим образом:
Теперь займемся основной задачей – перечисление всех имеющихся на компьютере PCI-устройств. PCI – это основная шина с устройствами на компьютере. В нее помимо обычных устройств, которые вставляются во всем известные слоты на материнской плате, также подключены устройства, вшитые в саму материнскую плату (так называемые On-board devices), а так же ряд контроллеров (например, USB) и мостов на другие шины (например, PCI-ISA bridge). Таким образом, PCI – это основная шина на компьютере, с которой начинается опрос всех его устройств.
С каждым PCI-устройством связана структура из 256-ти байт (PCI Configuration Space), в которой располагаются его настройки. Конфигурация устройства в конечном итоге сводится к записи и чтению данных из этой структуры. Для всех PCI-устройств чтение и запись данных происходит через 2 порта ввода-вывода:
0xcf8 — конфигурационный порт, в который записывается PCI-адрес;
0xcfc — порт данных, через который происходит чтение и запись данных по указанному в конфигурационном порту PCI-адресу.
При чтении данных из PCI Configuration Space можно получить информацию об устройстве, а записывая туда данные устройство можно настроить.
PCI-адрес представляет собой следующую 32-х битную структуру:
Бит 31 | Биты 30 – 24 | Биты 23 – 16 | Биты 15 – 11 | Биты 10 – 8 | Биты 7 – 2 | Биты 1 – 0 |
Всегда 1 | Зарезервировано | Номер шины | Номер устройства | Номер функции | Номер регистра | Всегда 0 |
Номер шины вместе с номером устройства идентифицируют физическое устройство на компьютере. Физическое устройство может включать в себя несколько логических, которые идентифицируются номером функции (например, плата видео захвата с контроллером Wi-Fi будет иметь, по крайней мере, две функции).
PCI Configuration Space условно разбита на регистры по 4 байта. Номер регистра, к которому происходит обращение, хранится с 2го по 7й биты в 32-х битном PCI-адресе. Поля структуры PCI Configuration Space, описывающей PCI-устройство, зависят от его типа. Но для всех типов устройств первые 4 регистра структуры содержат следующие поля:
Номер регистра | Биты 31 — 24 | Биты 23 – 16 | Биты 15 – 8 | Биты 7 – 0 |
0 | Device ID | Vendor ID | ||
1 | Status | Command | ||
2 | Class code | Subclass | Prog IF | Revision ID |
3 | BIST | Header type | Latency Timer | Cache Line Size |
Class code – описывает тип (класс) устройства с точки зрения функций, которые устройство выполняет (сетевой адаптер, видео карта и т.д.);
Vendor ID – идентификатор производителя устройства (у каждого производителя устройств в мире есть один или несколько таких уникальных идентификаторов). Эти номера выдаются международной организацией PCI SIG;
Device ID – уникальный идентификатор устройства (уникален для заданного Vendor ID). Их нумерацию определяет сам производитель.
По полям DeviceID (сокращенно DEV) и VendorID (сокращенно VEN) определяется драйвер, соответствующий этому устройству. Иногда для этого используется еще дополнительный идентификатор RevisionID (сокращенно REV). Другими словами, Windows, обнаруживая новое устройство в компьютере, использует числа VEN, DEV и REV для поиска соответствующих им драйверов у себя на диске или в Интернете, используя сервера Microsoft. Также эти номера можно встретить в диспетчере устройств:
Рассмотрим код, реализующий самый простой способ получения списка имеющихся на компьютере PCI-устройств:
int ReadPCIDevHeader(u32 bus, u32 dev, u32 func, PCIDevHeader *p_pciDevice)
{
int i;
if (p_pciDevice == 0)
return 1;
for (i = 0; i < sizeof(p_pciDevice->header)/sizeof(p_pciDevice->header[0]); i++)
ReadConfig32(bus, dev, func, i, &p_pciDevice->header[i]);
if (p_pciDevice->option.vendorID == 0x0000 ||
p_pciDevice->option.vendorID == 0xffff ||
p_pciDevice->option.deviceID == 0xffff)
return 1;
return 0;
}
void kmain(void)
{
int bus;
int dev;
clear_screen();
printf(" -- Kernel started! -- n");
for (bus = 0; bus < PCI_MAX_BUSES; bus++)
for (dev = 0; dev < PCI_MAX_DEVICES; dev++)
{
u32 func = 0;
PCIDevHeader pci_device;
if (ReadPCIDevHeader(bus, dev, func, &pci_device))
continue;
PrintPCIDevHeader(bus, dev, func, &pci_device);
if (pci_device.option.headerType & PCI_HEADERTYPE_MULTIFUNC)
{
for (func = 1; func < PCI_MAX_FUNCTIONS; func++)
{
if (ReadPCIDevHeader(bus, dev, func, &pci_device))
continue;
PrintPCIDevHeader(bus, dev, func, &pci_device);
}
}
}
}
В данном коде происходит полный перебор номеров шин и номеров устройств в адресе, по которому происходит чтение. Если поле Header type содержит флаг PCI_HEADERTYPE_MULTIFUNC, то данное физическое устройство реализует несколько логических устройств, и при поиске PCI-устройств в адресе, записываемом в конфигурационный порт, нужно перебирать номер функции. Если VendorID имеет некорректное значение, то устройства с таким номером на этой шине нет. На Qemu этот код выводит следующий результат:
0x8086 – это VendorID оборудования компании Intel. DeviceID, равный 0x7000, соответствует устройству PIIX3 PCI-to-ISA Bridge. Загрузимся с получившейся флешки в VmWare Workstation 9.0. Список PCI-устройств оказался значительно длиннее и выглядит следующим образом:
Вот так выглядит поиск PCI-устройств в системе. Это действие выполняется во всех современных операционных системах, работающих на компьютерах IBM PC. Следующим шагом в работе операционной системы является поиск драйверов и конфигурирование найденных устройств, а это уже происходит уникальным образом для каждого устройства в отдельности.
Автор: NWOcs