Как найти PCI устройства без операционной системы

    В ходе работы нам периодически приходится сталкиваться с достаточно низкоуровневым взаимодействием с аппаратной частью. В данной статье мы хотим показать, каким образом происходит опрос 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 "quit\r"
    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. Следующим шагом в работе операционной системы является поиск драйверов и конфигурирование найденных устройств, а это уже происходит уникальным образом для каждого устройства в отдельности.
    • +31
    • 31.8k
    • 5
    НеоБИТ
    101.52
    Company
    Share post

    Comments 5

      +4
      Проверялось ли это на реальном железе?
      В частности интересно будет посмотреть на результаты систем без PCI-шины, к примеру на Z68 или H67 чипсетах.
      Конечно, PCIe имеет обратную программную совместимость с PCI, но всё же занятно было бы взглянуть на корректность и полноту полученного перечня.
        0
        PCIe будет работать абсолютно так же. С точки зрения настройки они в этом месте не отличаются — такое же точно конфигурационное пространство с теми же регистрами.
        Отличается оно в физическом уровне (последовательно-параллельная шина с 8/10 или более оптимальным кодированием) и в форме разъёма.
        +3
        CFLAGS = -Wall -nostdlib -fno-builtin -nostartfiles -nodefaultlibs

        -nostdlib, -nostartfiles и -nodefaultlibs тут не нужны, вы же линкуете не gcc, а вот -nostdinc не помешал бы, чтобы не получить прототипы из системных заголовков.

        qemu /dev/sdb

        QEMU, кстати, умеет загружать multiboot-ядра опцией -kernel: qemu -kernel kernel.bin

        Для установки GRUB-а на флешку используется expect-скрипт в файле grub_install.exp

        А можно было бы проще:
        cat <<EOF | grub --batch
        root (hd1,0)
        setup (hd1)
        quit
        EOF
        

          0
          Ещё момент: у вас есть секция bss, но код инициализации её нулями в loader отсутствует.
          0
          А чего это вы так Windows выделили? Все операционки находят драйвер по PCI IDs (VID/PID). В линуксе в каждом драйвере указан набор ID устройств, которые он может обслужить.
          Если хочется «принудительно» попытаться завести драйвер с новым устройством, можно его IDs отправить в файлик new_id соответствующего драйвера в sysfs, примерно так:

          echo «8086 0700» >> /sys/bus/pci/drivers/.../new_id

          После этого, если устройство не было ассоциировано с каким-то другим драйвером, подхватится этим — потому, что этому драйверу сказали «обслуживай устройства с такими-то ID».

          Был случай, когда я так заводил RAID-контроллер на какой-то матери Supermicro — они зачем-то для известного FusionMPT (драйвер mptsas) указали те ID, которых в старом ядре не было. (В новом уже были, кто-то позаботился.)

          Что интересно, rev в линуксе не используется для выбора драйвера, только самими драйверами.

          Only users with full accounts can post comments. Log in, please.