In the course of work, we occasionally have to deal with rather low-level interaction with the hardware. In this article we want to show how PCI devices are polled to identify them and load the appropriate device drivers.
As a minimum base for working with PCI devices, we will use a kernel that supports the Multiboot specification. This will avoid the need to write your own boot sector and loader. In addition, this issue is already well covered on the Internet. GRUB will be the loader. We will boot from the flash drive, since it is convenient to load both a virtual and a real machine from it. We will use QEMU as a virtual machine. The real machine should be a machine with a normal BIOS (not UEFI) that supports booting from USB-HDD (usually there is an option Legacy USB support). You will need Ubuntu Linux with the following programs: expect, qemu, grub (they can be easily installed using the command sudo apt-get install). The gcc used should compile a 32 bit code.
Consider the first step - creating a kernel that supports the Multiboot specification. In the case of using GRUB as a loader, the kernel will be created from 3 files:
Kernel.c is the main file with the code of our program and the main () procedure;
Loader.s - contains the multiboot header for GRUB;
Linker.ld is the ld linker script, which specifically indicates which address the kernel will be located at.
Content Linker.ld:
ENTRY (loader) SECTIONS { . = 0x00100000; .text ALIGN (0x1000) : { *(.text) } .rodata ALIGN (0x1000) : { *(.rodata*) } .data ALIGN (0x1000) : { *(.data) } .bss : { sbss = .; *(COMMON) *(.bss) ebss = .; } }
')
The linker script indicates how to link the already compiled object files. The first line indicates that the entry point in our kernel will be the address labeled “loader”. Further in the script it is indicated that starting from the address 0x00100000 (1Mb) the text section will be located. The rodata, data, and bss sections are aligned at 0x1000 (4Kb) and are located after the text section.
Loader.s content:
.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
After downloading the kernel image from the disk, GRUB searches for the first 8Kb of the downloaded image for the signature 0x1BADB002. The signature is the first multiboot header field. The title itself looks like this:
Offset
| Type
| Field name
| Note
|
0
| u32
| magic
| required
|
four
| u32
| flags
| required
|
eight
| u32
| checksum
| required
|
12
| u32
| header_addr
| if flags [16] is set
|
sixteen
| 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
|
The header must include at least 3 fields - magic, flag, checksum. The magic field is a signature and, as mentioned above, is always 0x1BADB002. The flag flag contains additional requirements for the state of the machine at the time of transferring control to the OS. Depending on the value of this field, the set of fields in the Multiboot Information structure may change. The pointer to the Multiboot Information structure contains the EBX register at the moment of passing control to the loaded kernel. In our case, the flag field is set to 0, and the multiboot header consists of only 3 fields.
At the time of transferring control to the kernel, the processor is operating in protected mode with paging address turned off. Device interrupt handling is disabled. GRUB does not form a stack for a bootable kernel, and this is the first thing the operating system should do. In our case, 16Kb is allocated for the stack. The last assembler instruction executed will be the call kmain instruction, which transfers control to the C code, namely the void kmain (void) function.
Contents of kernel.c:
#include "printf.h" #include "screen.h" void kmain(void) { clear_screen(); printf(" -- Kernel started! -- \n"); }
While there is nothing interesting. From the point of view of loading, it should not contain anything specific, only the entry point for the C code. To display the implementation of the printf function found on the Internet, and several functions for working with video memory, such as putchar, clear_screen, were added.
The following simple makefile will be used to build the kernel:
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 .so: as -o $@ $< .co: $(CC) $(CFLAGS) -o $@ -c $< kernel.bin: $(OBJFILES) $(LD) -T linker.ld -o $@ $^ clean: rm $(OBJFILES) kernel.bin
Now we have a kernel that can be downloaded. It's time to check that it really loads. Install GRUB on a USB flash drive and tell it to load our kernel at startup. To do this, follow these steps:
1. Create a partition on a flash drive, format it into a file system supported by GRUB (in our case it is FAT32 file system). We used Disk Utility from the Ubuntu suite, which allowed us to create a partition:

2. Mount the USB flash drive and create the directory / boot / grub /. Copy stage1, stage2, fat_stage1_5 files from / usr / lib into it. Create a menu.lst text file in the / boot / grub / directory and write to it
timeout 5 default 0 title start_kernel root (hd0,0) kernel /boot/grub/kernel.bin
To install GRUB on a USB flash drive, use the expect script in the grub_install.exp file. Its contents are:
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
In a particular case, other disk numbers and device names are possible. Ultimately, compiling and running the virtual machine should be done with the make start command. This makefile command will install GRUB on a flash drive using the grub_install.exp script, and then start the QEMU virtual machine with our program. Since everything is loaded from a real flash drive, it is possible to boot from it not only the QEMU virtual machine, but also the real computer.
A running QEMU virtual machine with our program looks like this:

Now we will deal with the main task - transfer of all PCI devices available on a computer. PCI is the main bus with devices on the computer. In addition to the usual devices that are inserted into the well-known slots on the motherboard, there are devices connected to the motherboard itself (the so-called On-board devices), as well as a number of controllers (for example, USB) and bridges to other buses ( for example, PCI-ISA bridge). Thus, PCI is the main bus on the computer from which to begin polling all its devices.
Associated with each PCI device is a 256-byte structure (PCI Configuration Space) in which its settings are located. The device configuration ultimately boils down to writing and reading data from this structure. For all PCI devices, reading and writing data occurs via 2 I / O ports:
0xcf8 is a configuration port where a PCI address is written;
0xcfc is the data port through which data is read and written to the PCI address specified in the configuration port.
When reading data from the PCI Configuration Space, you can get information about the device, and by writing data to the device you can configure it.
The PCI address is the following 32-bit structure:
Bit 31
| Bits 30 - 24
| Bits 23 - 16
| Bits 15 - 11
| Bits 10 - 8
| Bits 7 - 2
| Bits 1 - 0
|
Always 1
| Reserved
| Tire number
| Device number
| Function number
| Register number
| Always 0
|
The bus number along with the device number identify the physical device on the computer. A physical device may include several logical devices that are identified by a function number (for example, a video capture card with a Wi-Fi controller will have at least two functions).
PCI Configuration Space is conventionally divided into registers of 4 bytes. The register number that is accessed is stored from 2nd to 7th bits in a 32-bit PCI address. The fields of the PCI Configuration Space structure describing a PCI device depend on its type. But for all types of devices, the first 4 registers of the structure contain the following fields:
Register number
| Bits 31 - 24
| Bits 23 - 16
| Bits 15 - 8
| Bits 7 - 0
|
0
| Device ID
| Vendor id
|
one
| Status
| Command
|
2
| Class code
| Subclass
| Prog IF
| Revision ID
|
3
| Bist
| Header type
| Latency timer
| Cache Line Size
|
Class code - describes the type (class) of the device in terms of the functions that the device performs (network adapter, video card, etc.);
Vendor ID - identifier of the device manufacturer (each device manufacturer in the world has one or several such unique identifiers). These numbers are issued by the international organization PCI SIG;
Device ID - unique device identifier (unique for a given Vendor ID). Their numbering is determined by the manufacturer itself.
The DeviceID (abbreviated as DEV) and VendorID (abbreviated as VEN) fields determine the driver corresponding to this device. Sometimes this also uses the additional RevisionID (abbreviated as REV). In other words, Windows, when discovering a new device on a computer, uses VEN, DEV, and REV numbers to search for the corresponding drivers on their disk or on the Internet, using a Microsoft server. Also, these numbers can be found in the device manager:

Consider the code that implements the easiest way to get a list of PCI devices on a computer:
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); } } } }
In this code, a complete enumeration of bus numbers and device numbers in the address at which reading occurs occurs. If the Header type field contains the PCI_HEADERTYPE_MULTIFUNC flag, then this physical device implements several logical devices, and when searching for PCI devices in the address that is written to the configuration port, it is necessary to go through the function number. If VendorID has an incorrect value, then there is no device with that number on this bus. On Qemu, this code displays the following result:

0x8086 is Intel's VendorID hardware. A DeviceID of 0x7000 corresponds to the PIIX3 PCI-to-ISA Bridge device. Boot from the resulting flash drive in VmWare Workstation 9.0. The list of PCI devices is much longer and looks like this:

Here is the search for PCI devices in the system. This action is performed on all modern operating systems running on IBM PCs. The next step in the operation of the operating system is to search for drivers and configure the devices found, and this already happens in a unique way for each device individually.