Skip to content

AHCI:从 BAR5 映射到发出读命令

AHCI:把 BAR5 这块寄存器窗映射进来

拿到 BAR5 物理地址了,AHCI 的活从 init 开始。第一件事不是碰寄存器,而是先让 PCI 允许这块卡干两件事:当总线主设备(能发起 DMA)、暴露内存空间(允许 MMIO 访问)。这是往 PCI 的 COMMAND 寄存器写两位:

cpp
uint32_t cmd_reg = pci::PCI::pci_read(dev.bus, dev.slot, dev.func, pci::PciReg::COMMAND);
cmd_reg |= (1U << 1);   // Bus Master Enable —— 不开,DMA 发不出去
cmd_reg |= (1U << 2);   // Memory Space Enable —— 不开,MMIO 访问全 0/FF
pci::PCI::pci_write(dev.bus, dev.slot, dev.func, pci::PciReg::COMMAND, cmd_reg);

这两位漏一个,BAR5 要么读不到、要么命令发不动——调试现场里它排第一号坑。

然后映射 BAR5。AHCI 的所有寄存器(全局控制、端口寄存器)都铺在 BAR5 这块物理地址上,内核要通过虚拟地址访问,所以用 016 的 VMM 把它 map 进来:

cpp
HBAMem* AHCI::map_bar5(uint64_t bar5_phys) {
    constexpr uint32_t BAR5_PAGES = 2;   // 映 2 页,够盖到 8 个端口
    constexpr uint64_t mmio_flags = cinux::arch::FLAG_PRESENT
                                  | cinux::arch::FLAG_WRITABLE
                                  | cinux::arch::FLAG_PCD;   // ★ MMIO 必须 uncached
    for (uint32_t i = 0; i < BAR5_PAGES; ++i) {
        uint64_t phys = bar5_phys + i * cinux::arch::PAGE_SIZE;
        uint64_t virt = MMIO_VIRT_BASE + i * cinux::arch::PAGE_SIZE;  // 0xFFFF800000100000
        if (!cinux::mm::g_vmm.map(virt, phys, mmio_flags)) return nullptr;
    }
    return reinterpret_cast<HBAMem*>(MMIO_VIRT_BASE);
}

那个 FLAG_PCD 是这一章的命门。MMIO 寄存器是设备状态,不是普通内存,CPU 若把它缓存起来,你读 port->ci(命令完成位)读到的可能是缓存里的旧值、永远不清零;你写 ghc 可能只进了缓存、没到设备。FLAG_PCD 把这块映射标成「禁用缓存」,每次读写都直达设备。映射的虚拟地址 MMIO_VIRT_BASE = 0xFFFF800000100000 选在内核高半区(地址高位为全 1,是 64 位规范形地址),和内核自己的 KERNEL_VMA(0xFFFFFFFF80000000) 错开,避免和别的映射打架。

HBAMem 这块结构体(ahci_config.hpp)就是 BAR5 的布局:开头是全局寄存器(cap/ghc/is/pi/vs…),然后 ports[] 从 offset 0x100 开始,每个端口 0x80 字节。它和 HBAPort 都标了 [[gnu::packed]],并各有一条 static_assert 焊死大小——MMIO 布局差一个字节就全错,编译期必须卡住。

映射好之后是「复位 → 开 AHCI 模式 → 开中断」的固定仪式:

cpp
hba_mem_->ghc |= GhcBits::AE;     // AHCI Enable,先切到 AHCI 模式
reset_hba();                      // GHC.HR 复位,等它自己清零
hba_mem_->ghc |= GhcBits::AE;     // 复位会把 AE 清掉,得重新置
hba_mem_->ghc |= GhcBits::INT_ENABLE;

reset_hbaGHC.HR 后死循环轮询它被硬件清零(硬件复位完会自己清)。注意复位会把 AE 一起清掉,所以复位后必须重新置 AE——这是规范要求,顺序反了或漏了,AHCI 模式没真正生效,后面端口全不听话。

端口起步:停机 → 分配 Command List/FIS → 起机

init 最后按 pi(Port Implemented,端口实现位图)逐个端口探测。每个端口先看 SATA 状态寄存器的 DET 字段,0x03 才表示「物理连了设备、链路已通」:

cpp
uint32_t det = port->ssts & PxSsts::DET_MASK;   // 低 4 位
if (det != PxSsts::DET_ACTIVE) {                 // DET_ACTIVE = 0x03
    // 没设备,跳过
    continue;
}
setup_port(i);

有设备的端口,setup_port 给它搭命令队列。AHCI 的工作模型是:每个端口有一张命令列表(32 个命令头,每个 32 字节)和一个FIS 接收缓冲(256 字节,设备往这塞状态 FIS)。这俩得是物理连续、对齐的内存,内核要先从 PMM 要页、清零、把它们物理地址写进端口的 clb/clbu/fb/fbu 寄存器:

cpp
void AHCI::setup_port(uint8_t port_index) {
    auto* port = &hba_mem_->ports[port_index];
    stop_port(port);                                  // ★ 改 clb/fb 前必须先停引擎

    uint64_t cmd_list_phys = cinux::mm::g_pmm.alloc_pages(1);  // 命令列表,4KB 页
    // ... 清零(靠 phys + KERNEL_VMA 直访)...
    uint64_t fis_buf_phys = cinux::mm::g_pmm.alloc_page();     // FIS 缓冲,1 页
    // ... 清零 ...

    port->clb  = static_cast<uint32_t>(cmd_list_phys & 0xFFFFFFFF);
    port->clbu = static_cast<uint32_t>(cmd_list_phys >> 32);   // 64 位地址拆高低
    port->fb   = static_cast<uint32_t>(fis_buf_phys & 0xFFFFFFFF);
    port->fbu  = static_cast<uint32_t>(fis_buf_phys >> 32);

    // 把 slot 0 的命令头指向命令表(放在命令列表那页的后半段)
    headers[0].ctba  = static_cast<uint32_t>(cmd_tbl_phys & 0xFFFFFFFF);
    headers[0].ctbau = static_cast<uint32_t>(cmd_tbl_phys >> 32);

    start_port(port);
}

这里有两个「为什么」。第一,改 clb/fb 这些基地址寄存器前必须先停引擎(stop_port):命令引擎若还在跑,你换了它脚下的命令列表地址,等于在行驶中换轮子,后果是数据错乱甚至控制器锁死。第二,内核要往命令列表页里写命令头、清零,得能访问这块物理内存——代码用 cmd_list_phys + 0xFFFFFFFF80000000ULL(也就是 KERNEL_VMA)直接当虚拟地址访问,这靠的就是 016 说过的「整段物理内存做了高半区恒等映射」那个 boot 期约定。

stop_portstart_port 的顺序是规范的硬要求,一个字都不能乱:

cpp
void stop_port(HBAPort* port) {
    port->cmd &= ~PxCmd::ST;           // 先清 Start
    // 等 CR(Command Running)清零 —— 引擎真的停了
    port->cmd &= ~PxCmd::FRE;          // 再清 FIS Receive Enable
    // 等 FR(FIS Receive Running)清零
}
void start_port(HBAPort* port) {
    port->cmd |= PxCmd::FRE;           // 先开 FIS 接收
    port->cmd |= PxCmd::ST;            // 再开命令处理
}

停机要先停命令引擎(ST→等 CR)、再停 FIS 接收(FRE→等 FR);起机反过来,先开 FRE、再开 ST。顺序反了,控制器拒绝配合或直接卡住——这是 AHCI 规范里写死的端口控制顺序。

发一条读命令:CFIS + PRDT + 轮询 CI

端口搭好了,read/write 就是在 slot 0 上发一条命令。AHCI 命令分三块:命令头(在命令列表里,指向命令表)、命令表(放 FIS 和 PRDT)、FIS(告诉设备具体干嘛)。execute_command 把它们拼起来:

展开代码 (共 25 行)收起代码
cpp
// 命令表放在命令列表那页的 slot 0 之后(cmd_list + 32×32 字节)
cmd_tbl_phys = cmd_list_phys_[port_index] + CMD_SLOTS * sizeof(HBACommandHeader);
build_cfis(cmd_tbl, write_cmd, lba, count);   // 填 FIS

// PRDT:一个条目指向目标缓冲(物理连续)
cmd_tbl->prdt[0].dba  = static_cast<uint32_t>(buf_phys & 0xFFFFFFFF);
cmd_tbl->prdt[0].dbau = static_cast<uint32_t>(buf_phys >> 32);
cmd_tbl->prdt[0].dbc  = (count * SECTOR_SIZE - 1) & 0x3FFFFF;   // 字节数-1,22位
cmd_tbl->prdt[0].i    = 1;   // 完成时中断

// 命令头:CFIS 长度(以 dword 计)、PRDT 条数、方向
headers[slot].cfl   = sizeof(RegH2DFIS) / 4;   // 20 字节 = 5 dword
headers[slot].prdtl = 1;
headers[slot].write = write_cmd ? 1 : 0;

port->is = static_cast<uint32_t>(~0U);   // 清中断状态
port->ci = (1U << slot);                  // ★ 往 Command Issue 写位 = 发命令

for (uint32_t i = 0; i < POLL_TIMEOUT; ++i) {
    if ((port->ci & (1U << slot)) == 0) {  // 硬件完成会清掉这一位
        if ((port->tfd & 0x01) != 0) return false;   // TFD.ERR 有错
        return true;
    }
    __asm__ volatile("pause");
}

几个细节值得停一下。PRDT(Physical Region Descriptor Table)是 DMA 的散列-聚集表,一个条目描述一块物理连续缓冲:地址(dba/dbau)+ 长度(dbc)。注意 dbc 是「字节数减一」(count * SECTOR_SIZE - 1),这是硬件约定——0 表示传 1 字节。这里只用一个 PRD 条目,所以要求调用方给的缓冲必须物理连续(一整页天然连续,016 的 alloc_page 给的就是这个)。

FIS 本身是 build_cfis 填的 Register Host-to-Device FIS(0x27),里头是一条 ATA 命令:

cpp
void build_cfis(HBACommandTable* cmd_tbl, bool write_cmd, uint64_t lba, uint16_t count) {
    auto* fis = reinterpret_cast<RegH2DFIS*>(cmd_tbl->cfis);
    fis->fis_type = FisType::REG_H2D;       // 0x27
    fis->flags    = 0x80;                   // 标志字节,见下文说明
    fis->command  = write_cmd ? AtaCmd::WRITE_DMA_EXT   // 0x35
                              : AtaCmd::READ_DMA_EXT;   // 0x25
    // 48 位 LBA:低 24 位进 lba0-2,高 24 位进 lba3-5
    fis->lba0 = lba & 0xFF;          fis->lba1 = (lba >> 8) & 0xFF;
    fis->lba2 = (lba >> 16) & 0xFF;  fis->device = 0x40;   // LBA 模式位
    fis->lba3 = (lba >> 24) & 0xFF;  fis->lba4 = (lba >> 32) & 0xFF;
    fis->lba5 = (lba >> 40) & 0xFF;
    fis->count0 = count & 0xFF;      fis->count1 = (count >> 8) & 0xFF;
}

这里得停下来挑一个细节:flags = 0x80 这个标志字节。Register H2D FIS 的字节 1,按 AHCI/Serial ATA 规范,bit 6 是 C 位(command)——置 1 表示「这是一条命令」,bit 7 是保留位。也就是说,规范意义上的「命令」应当把这一字节写成 0x40。而这章的代码写的是 0x80(bit 7),源码注释自己也写「bit 6 set = command」——注释和值对不上。它能跑通,是因为 QEMU 的 AHCI/IDE 模拟不强校验这个 C 位、照着 command 字段就处理了。这是个「在 QEMU 里宽容通过、未必符合规范字节布局」的点:本教程照实写当前 tag 的值 0x80,但你要知道规范那里要的是 bit 6。真机上若碰到控制器较真、命令不执行,这里是头号嫌疑。

READ DMA EXT/WRITE DMA EXT(0x25/0x35)是 48 位 LBA 的非排队 DMA 命令。48 位 LBA 的编码是 ATA 的老传统:LBA 拆成低 24 位(进 lba0/1/2)和高 24 位(进 lba3/4/5),设备位寄存器置 0x40 表示「LBA 模式」。注意这一章用的是普通 DMA,不是 NCQ(Native Command Queuing)——不排队、不并发,count 也只是 16 位扇区计数。

最后那个轮询是验收的关键:ci(Command Issue)写位发命令,命令完成时硬件会自己清掉这一位。所以死循环轮询 port->ci 这一位变 0,就表示命令做完了——但做完不等于做对,还得看 tfd(Task File Data)的 bit 0(ERR)是不是 0。只看 CI 清零不看 ERR,会把「命令失败」误判成成功。

得说清这一章的边界:代码只用了 slot 0、单条命令串行(execute_commandcmd_tbl_phys 固定指向 slot 0 的命令表,slot 参数实际被忽略)。AHCI 的命令列表能塞 32 条、理论上能并发,但这一章没那么干——够验证「能读盘」就行,并发提交是以后的事。

035_multi_terminal-45-gf25de18 · f25de18 · 2026-08-04