PCI BARの値とACPIの_CRS関数

この投稿は、Kernel/VM探検隊@北陸 Part 6で発表を行った「AArch64 ThinHypervisor開発記録」の「PCI Base Address Registerは絶対アドレスじゃ無かった話」を詳しく説明したものです。


PCIの各デバイスには、PCI Configuration Spaceというものがあって、そこにはBase Address Register(BAR)が0~5まで6つあります。

PCIデバイスを操作するためには、BARに記載されているアドレスにアクセスする必要があり、最近のデバイスは殆どMMIO(Memory Mapped I/O)となっていて、メモリアドレスが記載されています。
最近では、64bit AddressがよほどでBAR 0/1を用いてBAR0に下位32bit、BAR1に上位32bitが記述されていると思います。

基本的にBARに書かれているアドレスがそのままMMIOのアドレスになると思うのですが、まれにこれが相対アドレスで記載されている場合があります。
今回は、そのケースについて追っていきます。

背景

ハイパーバイザで実際のPCI Bridgeの下に寄生する形でVirtIO Network Deviceを生やす実装をしていました。
具体的には、PCI Configuration Spaceを走査していきデバイスがないBus:Device.Functionを取得し、対応するPCI Configuration SpaceへのアクセスをトラップするようにしてハイパーバイザでVirtIO Network用のPCI Configurationを提供する、という実装をしていました。
VirtIO Networkが実際に動作するためには、BARに記載されるMMIOでVirtQueueの処理などをしないといけないのですが、Linuxは起動時にBase Addressを変更して整理します。このためBARに新たな値が書き込まれた場合にはこの値をもとにトラップするメモリアドレスを変更しないといけません。
今回は64bitアドレスなのでBAR0/BAR1を使用しているのですが、おかしな挙動に悩まされました。
以下は、Linuxとハイパーバイザの起動時のシリアル出力なのですが、Linuxが割り当てたと主張するアドレスと実際にPCI Configuration SpaceのBARに書き込まれる値が異なるのです。
(Hypervisor: と書かれている行はハイパーバイザが出力した行です。)

[    1.454562] pci 0000:00:01.0: BAR 0: assigned [mem 0xe000200000-0xe000200fff 64bit]
Hypervisor: BAR0: 0x200004
Hypervisor: BAR1: 0x0
Hypervisor: New Base Address: 0x200000

Linuxは 0xe000200000 をアサインしたと表示しているのに、実際にBARに書き込まれるアドレスは、 0x200000 となっています。(BAR0の下位3bitはフラグで0x04は64bit BARであることを示しています。)
このため、ハイパーバイザは 0x200000 をMMIO領域と設定してアクセスをトラップするのに対し、Linuxは 0xe000200000 にアクセスしに行くのでデバイスの制御ができず、起動に失敗します。
どうしてこのようなことになるのか、挙動を追います。

なお今回使用するLinuxはGNU/Linux 5.4です。

PCI Base Addressの割り当て

PCI Base Address RegisterにMMIOのアドレスを書き込んでいるのは、drivers/pci/setup-res.cpci_std_update_resource で行っています。pci_std_update_resourceの今回関係ある部分を概要を抽出すると以下のようになります。

static void pci_std_update_resource(struct pci_dev *dev, int resno)
{
	struct pci_bus_region region;
	u32 new;
	int reg;
	struct resource *res = dev->resource + resno;

	pcibios_resource_to_bus(dev->bus, &region, res);
	new = region.start;
	new |= res->flags & ~PCI_BASE_ADDRESS_MEM_MASK;

	reg = PCI_BASE_ADDRESS_0 + 4 * resno;

	pci_write_config_dword(dev, reg, new);

	if (res->flags & IORESOURCE_MEM_64) {
		new = region.start >> 16 >> 16;
		pci_write_config_dword(dev, reg + 4, new);
	}
}

この関数では、 配列となっている dev->resourceresno 番目のエントリを取り出し、これを pcibios_resource_to_bus に与えて、返ってきた region.start をBAR0/1に書き込んでいます。

次に pcibios_resource_to_bus を見てみます。

void pcibios_resource_to_bus(struct pci_bus *bus, struct pci_bus_region *region,
			     struct resource *res)
{
	struct pci_host_bridge *bridge = pci_find_host_bridge(bus);
	struct resource_entry *window;
	resource_size_t offset = 0;

	resource_list_for_each_entry(window, &bridge->windows) {
		if (resource_contains(window->res, res)) {
			offset = window->offset;
			break;
		}
	}

	region->start = res->start - offset;
	region->end = res->end - offset;
}

region->start = res->start - offset; が今回注目すべき点なのですが、 offset というのが気になります。この値は、 bridge->windows を順番に調べていき、 resource_contains(window->res, res) が真を返した際に、 window->offset の値がセットされます。 resource_contains は、 window->res->start <= res->start && window->res->end >= res->end の際に true を返します。

さて、 window->offset はどこでセットされるのでしょうか。
調べたところ、 acpi_dev_new_resource_entry で設定されるようです。

static acpi_status acpi_dev_new_resource_entry(struct resource_win *win,
					       struct res_proc_context *c)
{
	struct resource_entry *rentry;

	rentry = resource_list_create_entry(NULL, 0);
	if (!rentry) {
		c->error = -ENOMEM;
		return AE_NO_MEMORY;
	}
	*rentry->res = win->res;
	rentry->offset = win->offset;
	resource_list_add_tail(rentry, c->list);
	c->count++;
	return AE_OK;
}

rentry->offset = win->offset; がそれです。この関数は、すぐ下の acpi_dev_process_resource で呼ばれています。 acpi_dev_process_resource の処理概要は以下の通りです。

static acpi_status acpi_dev_process_resource(struct acpi_resource *ares,
					     void *context)
{
	struct res_proc_context *c = context;
	struct resource_win win;

	memset(&win, 0, sizeof(win));

	if (acpi_dev_resource_memory(ares, res)
	    || acpi_dev_resource_io(ares, res)
	    || acpi_dev_resource_address_space(ares, &win)
	    || acpi_dev_resource_ext_address_space(ares, &win))
		return acpi_dev_new_resource_entry(&win, c);

	return AE_OK;
}

ares を各関数に渡してパースが成功したら acpi_dev_new_resource_entry を呼び出すようです。
ここまでの予想で、 win->offset に値が入っているだろうと予想しているので、 acpi_dev_resource_address_spaceacpi_dev_resource_ext_address_space のどちらかが true を返すだろうと予想します。とりあえず、 acpi_dev_resource_address_space を見てみます。

bool acpi_dev_resource_address_space(struct acpi_resource *ares,
				     struct resource_win *win)
{
	struct acpi_resource_address64 addr;

	win->res.flags = 0;
	if (ACPI_FAILURE(acpi_resource_to_address64(ares, &addr)))
		return false;

	return acpi_decode_space(win, (struct acpi_resource_address *)&addr,
				 &addr.address);
}

win への値の格納は acpi_decode_space でやっているようです。 acpi_dev_resource_ext_address_spaceacpi_decode_space を呼び出しています。

acpi_decode_space のうち win->offset に関わる部分を抜粋します。

static bool acpi_decode_space(struct resource_win *win,
			      struct acpi_resource_address *addr,
			      struct acpi_address64_attribute *attr)
{
	// ...
	/*
	 * For bridges that translate addresses across the bridge,
	 * translation_offset is the offset that must be added to the
	 * address on the secondary side to obtain the address on the
	 * primary side. Non-bridge devices must list 0 for all Address
	 * Translation offset bits.
	 */
	if (addr->producer_consumer == ACPI_PRODUCER)
		offset = attr->translation_offset;
	win->offset = offset;
	// ...
}

コメントで、translate addressesについて書いており、PCIブリッジを跨ぐ際にアドレス変換を行うブリッジは attr->translation_offset がセットされているようです。
ここから、VirtIO Network Deviceが寄生していたPCI Host Bridgeはこの translation_offset がセットされていると予想できます。

さて acpi_dev_process_resource はどこから呼ばれているのでしょうか。
呼び出し一覧を見ると、 __acpi_dev_get_resources から呼び出されている気がします。
この関数は、acpi_dev_get_resources の内部関数です。 __acpi_dev_get_resources の抜粋は以下の通りです。

static int __acpi_dev_get_resources(struct acpi_device *adev,
				    struct list_head *list,
				    int (*preproc)(struct acpi_resource *, void *),
				    void *preproc_data, char *method)
{
	struct res_proc_context c;
	acpi_status status;

	c.list = list;
	c.preproc = preproc;
	c.preproc_data = preproc_data;
	c.count = 0;
	c.error = 0;
	status = acpi_walk_resources(adev->handle, method,
				     acpi_dev_process_resource, &c);

	return c.count;
}

acpi_dev_process_resourceacpi_walk_resources のコールバック関数として渡されています。 acpi_walk_resources は ACPICAの関数で、リソースのリストから一つずつエントリを取り出して、コールバック関数を呼び出します。

acpi_dev_get_resources のコメントには、 “Evaluate the _CRS method for the given device node and process its output”とあり、渡されたデバイスの_CRS関数を評価するようです。

この関数を呼び出していそうなのは、 acpi_pci_probe_root_resources だと予想します。
これに関してのドキュメントが、 https://docs.kernel.org/PCI/acpi-info.html に存在します。
ここから必要な部分を読み取ります。

まず、”For example, there’s no standard hardware mechanism for enumerating PCI host bridges, so the ACPI namespace must describe each host bridge, the method for accessing PCI config space below it, the address space windows the host bridge forwards to PCI (using _CRS), and the routing of legacy INTx interrupts (using _PRT).”という記述に注目します。要約すると、「PCI host bridgeを検出する標準的なハードウェアメカニズムは存在せず、ACPI名前空間において_CRSを評価することでアドレス空間の情報を取得できる。」という感じでしょうか。
PCI Configuration Spaceにアクセスする方法として、ECAMがあり、これはMCFGテーブルを読み取ることでアクセス可能だと理解してました。これについては、”Static tables like MCFG, HPET, ECDT, etc., are not mechanisms for reserving address space. The static tables are for things the OS needs to know early in boot, before it can parse the ACPI namespace. “とあり、MCFGなどの静的テーブルはあくまで起動初期に暫定的に使用する事を目的としているようです。
PCI host bridgeについて、”PCI host bridges are PNP0A03 or PNP0A08 devices.”とあり、_HIDがこれであるデバイスを見つけてくれば良いということになります。

さて、今回使用しているデバイスのDSDT/SSDTをiaslでディスアセンブルし、”PNP0A08″を探してみた結果、以下のようなデバイスが見つかりました。(必要な部分のみ抜粋)

    Scope (_SB)
    {
        Device (PCI0)
        {
            Name (_HID, EisaId ("PNP0A08") /* PCI Express Bus */)  // _HID: Hardware ID
            Name (_CID, EisaId ("PNP0A03") /* PCI Bus */)  // _CID: Compatible ID
            Name (_SEG, Zero)  // _SEG: PCI Segment
            Name (_BBN, Zero)  // _BBN: BIOS Bus Number

            Method (_CRS, 0, Serialized)  // _CRS: Current Resource Settings
            {
                Name (RBUF, ResourceTemplate ()
                {
                    WordBusNumber (ResourceProducer, MinFixed, MaxFixed, PosDecode,
                        0x0000,             // Granularity
                        0x0000,             // Range Minimum
                        0x00FD,             // Range Maximum
                        0x0000,             // Translation Offset
                        0x00FE,             // Length
                        ,, )
                    QWordMemory (ResourceProducer, PosDecode, MinFixed, MaxFixed, Cacheable, ReadWrite,
                        0x0000000000000000, // Granularity
                        0x0000000000000000, // Range Minimum
                        0x000000007FFFFFFF, // Range Maximum
                        0x000000E000000000, // Translation Offset
                        0x0000000080000000, // Length
                        ,, , AddressRangeMemory, TypeStatic)
                    QWordMemory (ResourceProducer, PosDecode, MinFixed, MaxFixed, Prefetchable, ReadWrite,
                        0x0000000000000000, // Granularity
                        0x000000E200000000, // Range Minimum
                        0x000000FFFFFFFFFF, // Range Maximum
                        0x0000000000000000, // Translation Offset
                        0x0000001E00000000, // Length
                        ,, , AddressRangeMemory, TypeStatic)
                })
                Return (RBUF) /* \_SB_.PCI0._CRS.RBUF */
            }
}

_CRS関数では、3つのリソースが記述されています。
_CRS関数の返す値についての説明は、Device Configuration — ACPI Specification 6.4 documentation にあります。
QWordMemory の項を見ると、”Address Translation offset”というものがあり、_CRSの返すエントリの2番目を見ると、”0x000000E000000000, // Translation Offset”とあり、これが巡り巡って、 offset の値になっていると考えると不可解な挙動も納得できます。

検証

今までのコードリーディングで立てた仮説を検証するためにLinux Kernelに pr_info を加えて値の確認をしてみます。

まずは、 pci_std_update_resource を以下のように書き換えます。

        pr_info("%s: res->start = 0x%llx\n", __func__, res->start);
        pcibios_resource_to_bus(dev->bus, &region, res);
        pr_info("%s: region.start = 0x%llx\n", __func__, region.start);
        new = region.start;

次に、 pcibios_resource_to_bus を以下のように書き換えます。

        resource_list_for_each_entry(window, &bridge->windows) {
                if (resource_contains(window->res, res)) {
                        pr_info("%s: window->offset: 0x%llx\n", __func__, window->offset);
                        offset = window->offset;
                        break;
                }
        }

更に、 acpi_dev_new_resource_entry にも pr_info を追加します。

        *rentry->res = win->res;
        pr_info("%s: win->offset: 0x%llx\n", __func__, win->offset);
        rentry->offset = win->offset;

acpi_dev_resource_address_spaceacpi_dev_resource_ext_address_space にも呼ばれた痕跡が残るようにします。

bool acpi_dev_resource_address_space(struct acpi_resource *ares,
				     struct resource_win *win)
{
	struct acpi_resource_address64 addr;

	win->res.flags = 0;
	if (ACPI_FAILURE(acpi_resource_to_address64(ares, &addr)))
		return false;
        pr_info("%s: Called\n", __func__);
	return acpi_decode_space(win, (struct acpi_resource_address *)&addr,
}
bool acpi_dev_resource_ext_address_space(struct acpi_resource *ares,
                                         struct resource_win *win)
{
        struct acpi_resource_extended_address64 *ext_addr;

        win->res.flags = 0;
        if (ares->type != ACPI_RESOURCE_TYPE_EXTENDED_ADDRESS64)
                return false;

        ext_addr = &ares->data.ext_address64;
        pr_info("%s: Called\n", __func__);
        return acpi_decode_space(win, (struct acpi_resource_address *)ext_addr,
                                 &ext_addr->address);
}

最後に、 acpi_pci_probe_root_resources も予想したデバイスが評価されているか表示するようにします。

int acpi_pci_probe_root_resources(struct acpi_pci_root_info *info)
{
        int ret;
        struct list_head *list = &info->resources;
        struct acpi_device *device = info->bridge;
        struct resource_entry *entry, *tmp;
        unsigned long flags;

        flags = IORESOURCE_IO | IORESOURCE_MEM | IORESOURCE_MEM_8AND16BIT;
        pr_info("%s: probe %s\n", __func__, info->name);
        // ...
}

これらの変更を適用したLinux Kernelをビルドして差し替えて起動してみます。
まずは、PCI Root Busを認識している付近のBoot Messageを見てみます。

[   24.084991] acpi PNP0A08:00: ECAM area [mem 0xe100000000-0xe10fdfffff] reserved by PNP0C02:00
[   24.093530] acpi PNP0A08:00: ECAM at [mem 0xe100000000-0xe10fdfffff] for [bus 00-fd]
[   24.101266] acpi_pci_probe_root_resources: probe PCI Bus 0000:00
[   24.201701] acpi_dev_resource_address_space: Called
[   24.206568] acpi_dev_new_resource_entry: win->offset: 0xe000000000
[   24.212736] acpi_dev_resource_address_space: Called
[   24.217603] acpi_dev_new_resource_entry: win->offset: 0x0
[   24.223076] PCI host bridge to bus 0000:00
[   24.227166] pci_bus 0000:00: root bus resource [mem 0xe000000000-0xe07fffffff window] (bus address [0x00000000-0x7fffffff])
[   24.238283] pci_bus 0000:00: root bus resource [mem 0xe200000000-0xffffffffff pref window]
[   24.246535] pci_bus 0000:00: root bus resource [bus 00-fd]

acpi_pci_probe_root_resourcesacpi_dev_resource_address_spaceacpi_dev_new_resource_entry の順番で呼ばれているようですね。Translation Offsetの値が取り出され、resourceが作成されているようです。
次に、実際にVirtIO Network DeviceのBARに値が書き込まれている所を見てみます。

[   24.462843] pci 0000:00:01.0: BAR 0: assigned [mem 0xe000200000-0xe000200fff 64bit]
[   24.470487] pci_std_update_resource: res->start = 0xe000200000
[   24.476308] pcibios_resource_to_bus: window->offset: 0xe000000000
[   24.482389] pci_std_update_resource: region.start = 0x200000

予想通り、Translation Offsetの値がここに伝播し、 region.start の値が変更されています。
検証の結果、予想どおりの挙動を示している事を確認できました。

結論

PCIのBase Address Registerの値を読み書きする際はDSDT/SSDTのAMLをよく読んで、Translation Offsetの値に注意しましょう。

追伸

ACPI被害者の会は随時会員を募集しております。
ACPIのあんな挙動やこんな挙動で悩まされた方は是非、ブログ記事などで「ACPI被害者の会」と言うワードと共に投稿してみてください。

投稿者: PG_MANA

支離滅裂な自称プログラマー。 C,C++,Rust,JavaScript,PHP,HTML,CSS,OS自作,openSUSE,Arch,旅行 なんか色々してる人 #seccamp 17 19 20 23 #OtakuAssembly