☰
AHCI驱动开发:系统内存结构与PRDT/DBC排错
2026/10/26 6:26:41 网站建设 项目流程

最近在调一块 SATA 盘的读取路径,被一个特别隐蔽的 bug 卡了整整两天:IDENTIFY DEVICE 命令正常返回,读 512 字节的 MBR 也没问题,但一旦读的长度超过一个 PRD 条目能描述的范围,最后几个字节就永远是垃圾数据,而且不是每次都复现。查到最后发现是 PRDT 里那个DBC字段填错了——规范里写的是“传输字节数减一”,我按“传输字节数”填了,多描述了一个字节,越界的那一字节刚好落在某次分配的内存边缘外,只有特定对齐组合才会踩到。这类问题在 AHCI(Advanced Host Controller Interface)驱动开发里非常典型:HBA(Host Bus Adapter,主机总线适配器)本身逻辑不复杂,真正难的是System Memory Structures这一层——也就是驱动必须按照严格的对齐、尺寸和字段语义,在系统内存里手工“摆”好命令列表、命令表、FIS 和 PRD,然后只靠几个寄存器把物理地址告诉硬件。摆错一个字节,症状可能延迟几千次 I/O 才出现。

这篇内容就是围绕 AHCI 规范里“系统内存结构”这一章展开的。它讲清楚四类内存结构的职责边界、每个字段为什么这么定义、对齐要求背后的硬件原因,以及从零把一条命令提交出去需要按什么顺序做。适合三类人看:正在写裸机存储驱动或者自制内核的;用模拟器做设备模型、需要还原 HBA 行为的;以及排查过 SATA 超时、数据错位但一直没找到根因的。下面全部是我自己实际调过的路径,附上可以照着抄的结构定义和初始化序列。

1. AHCI 内存结构全景:驱动到底要摆好哪几块内存

1.1 从 HBA 的视角重新理解“驱动”

很多人第一次看 AHCI 会有个错觉,以为它像 NVMe 那样是“提交队列 + 完成队列”的现代模型,写个队列门铃就完事。其实 AHCI 的思路要古老得多:HBA 自己几乎不管理队列,它只是按照驱动预先摆好的内存布局,去固定的位置读固定的字节。你可以把 HBA 想象成一个只会照着地址卡片取货的搬运工——它不知道什么叫“队列”,只知道“命令列表在 PxCLB 指向的地方,每个槽 32 字节,我要第 n 个就去偏移 n×32 处读 32 字节,然后按照里面的 CTBA 去命令表里拿 FIS,再按照 PRDT 去搬数据”。

这个设计带来两个直接后果。第一,所有地址必须是物理地址,HBA 不经过 MMU,虚拟地址对它毫无意义;第二,所有结构的尺寸和边界都是硬编码的,不像 NVMe 那样可以由驱动协商队列深度和条目大小。CAP.NCS 告诉你最多有几个命令槽(0 表示 1 个,31 表示 32 个),命令头固定 32 字节,命令列表固定 1KB,Received FIS 固定 256 字节,这些数字一个都不能改。

我吃过一次亏:早期为了省内存,把 Command List 只分配了NCS+1个命令头的大小(比如 4 个槽就只给 128 字节)。逻辑上没毛病,因为用不到的槽永远不会被访问。但实际调试时发现某些 HBA 在复位或者错误恢复流程里会去预取整个 1KB 区域,落在未映射页上就直接总线错误。后来统一按 1KB 分配,问题消失。这个教训值得记一下:规范里标的尺寸是 HBA 可能访问的范围,不是驱动实际使用的范围,按规范全量分配最省心。

1.2 四类结构的职责边界

把系统内存结构拆开看,其实只有四块东西需要驱动自己准备,另外还有一块是 HBA 寄存器(属于 MMIO 空间,不算系统内存,但驱动必须同时操作,所以一起列出来对照)。

结构大小对齐要求谁来写核心作用
Command List1024 字节(32 个命令头 × 32 字节)1024 字节驱动存放命令头数组,HBA 按槽号索引
Command Table128 字节 + PRDTL×16 字节128 字节驱动存放 CFIS、ACMD 和 PRDT
Received FIS256 字节256 字节HBA设备发回的 FIS 落地缓冲区
HBA 寄存器每个端口 0x80 字节MMIO BAR 对齐双方控制与状态交互

这里最容易被忽略的是“谁来写”这一列。Command List 和 Command Table 是只读区——HBA 只从里面读,驱动写;Received FIS 是只写区——HBA 往里写,驱动读;而 HBA 寄存器是双方都动的共享区,需要严格区分哪些位是 RW、哪些是 RO、哪些是 RW1C(写 1 清除)。我见过有人把 Received FIS 缓冲区当成命令表复用,想着“反正就几百字节”,结果设备一回 D2H Register FIS 就把命令表覆盖了,表现是下一条命令莫名其妙地执行了上一批参数。这种覆盖不会有任何报错,只有数据错乱。

另一个边界问题是命令表和命令列表的归属关系。命令列表是每个端口一份(1KB),命令表是每个命令槽一份(128 字节起)。也就是说一个端口最多需要 32 个命令表,而且每个命令表的大小取决于它自己的 PRDTL——PRDTL 不同的槽,命令表大小可以不一样。我在第一版驱动里为了简单,给所有槽都分配了固定 8 个 PRD 条目的空间(128 + 8×16 = 256 字节),虽然浪费了一点,但换来的是“改 PRDTL 不用重新分配内存”,调试期这个取舍非常划算。

1.3 一次读命令的完整数据流

把一次典型读操作的路径走一遍,能最快建立整体印象。假设要读 LBA 0 开始的 8 个扇区(4096 字节)到一个 DMA 缓冲区:

  1. 驱动在某个空闲命令槽 n 的命令头里填好CFL=5、W=0(读方向)、PRDTL=1、CTBA指向该槽的命令表。
  2. 在命令表的 CFIS 区(偏移 0x00,20 字节)填一个 Register H2D FIS,内容为0x27 | Command=0x25(READ DMA EXT) | Count=8 | LBA=0。
  3. 在命令表的 PRDT 区(偏移 0x80)填一个 PRD 条目,DBA 指向 4KB 缓冲区物理地址,DBC 填4096-1=0xFFF,IOC 位置 1。
  4. 驱动写PxCI的第 n 位置 1,把门铃敲响。
  5. HBA 从 PxCLB 指向的内存读第 n 个命令头,从 CTBA 读命令表,把 CFIS 通过 SATA 链路发给设备。
  6. 设备返回 DMA Setup FIS 和 Data FIS,HBA 按 PRD 把数据搬进缓冲区。
  7. 传输完成,设备返回 D2H Register FIS,HBA 把它写进 Received FIS 的 0x00 处,更新PxTFD,清掉PxCI第 n 位,置位PxIS.DHRS。
  8. 驱动看到PxCI位清零或PxIS.DHRS置位,读PxTFD.STS判断有没有 ERR 位,然后读缓冲区。

整条链路里,驱动干了三件事:摆内存、写寄存器、等完成。HBA 干了三件事:读内存、搬数据、写状态。所有“智能”都在驱动侧,HBA 侧只有机械执行。理解了这一点,后面所有字段的定义就都好理解了——它们存在的唯一目的,就是让 HBA 能用最少的逻辑把内存里的东西取出来。

2. 命令列表与命令头:任务队列的骨架

2.1 Command List Base Address 的对齐与尺寸约束

PxCLB(偏移 0x00,32 位)和PxCLBU(偏移 0x04,32 位)拼成命令列表的 64 位物理基地址。关键约束是:低 10 位必须为 0,也就是说必须 1024 字节对齐。原因很直接——HBA 做地址计算时用的是“基址 + 槽号×32”,如果基址本身没对齐,所有槽的地址都会跟着歪。硬件为了省逻辑,干脆把这 10 位当成保留位,写进去也不生效。

这里有个细节值得提醒:PxCLB的低 10 位是RO(读出来永远是 0),不是“写了被忽略”。如果你写了个非对齐地址,回读的时候发现低 10 位是 0,会误以为硬件悄悄帮你对齐了,其实没有——HBA 内部用的就是基址的高 22 位加上槽号偏移,你写进去的低位被直接丢弃,最终访问的地址和你以为的不一样。我头一次遇到时就被这个“静默截断”迷惑了半天,明明传的地址看着没问题,实际访问的是另一片内存。所以分配命令列表时,简单的做法是直接调用按 1024 字节对齐的分配接口,或者手工向上取整:

/* 手工对齐到 1024 字节,并保证物理连续 */ static inline uint64_t align_up_1k(uint64_t addr) { return (addr + 1023ULL) & ~1023ULL; }

PxCLBU是否需要填,取决于CAP.S64A(bit 31)。如果 S64A=0,说明这个 HBA 只支持 32 位地址,PxCLBU必须写 0,而且命令列表、命令表、Received FIS、数据缓冲区全部要落在 4GB 以下。这一点在 32 位系统上不是问题,但在 64 位内核里非常容易踩——物理内存分配器很自然会给你一块高地址的页。我的做法是在驱动初始化时就把 S64A 读出来存成全局标志,所有 DMA 分配都走一个带DMA_32BIT_MASK约束的封装。

命令列表的大小固定是 1KB,不随NCS变化。PxCLB一旦设置,在PxCMD.ST=1期间不应该修改,规范要求先停端口再改基址。我在调试时为了图快直接改过,结果 HBA 处于半工作状态,PxCI位一直不清,最后靠PxCMD.CLO(Command List Override)强清才救回来——这个位是用来强行清PxCI和PxSACT的,属于应急手段,正常流程里不该出现。

2.2 32 字节 Command Header 逐字段拆解

命令头是整个结构体系里信息密度最高的地方,32 字节要表达 7 类信息。按 DWORD 分开看会清晰很多。

DW0(偏移 0x00):

位域名称含义常见误用
4:0CFLCFIS 长度,单位 DWORD,有效范围 2-16按字节数填,导致 HBA 读多/读少
5AATAPI 命令标志对 SATA 盘置 1 会被判非法
6W方向,1=写设备(H2D),0=读设备(D2H)读写方向反了,数据全丢
7P可预取一般置 0,语义模糊
12:8RReset 相关一般填 0
15BBIST 相关一般填 0
31:16C成功完成后清 BSY一般填 0,让 HBA 自己收尾

CFL是最容易填错的一个。Register H2D FIS 长度是 20 字节,除以 4 等于 5,所以填 5。如果按字节填成 20,HBA 会认为这个 FIS 有 80 字节,把后面的 PRDT 区域也当成 FIS 发出去,设备那边直接报协议错。反过来填成 4(16 字节)会丢掉 FIS 最后 4 字节,其中包含 Control 字段,某些命令的行为会变得很诡异——比如 FLUSH CACHE 少了 Control 里的位,设备可能不认。记住 CFL 的单位是 DWORD,不是字节,最小值是 2,最大值是 16。

W位的语义也值得单独说一下。它不是“这次操作是写还是读”,而是“数据传输方向相对主机而言”。读盘时数据从设备流向主机,W=0;写盘时数据从主机流向设备,W=1。这个方向搞反了,HBA 会把 PRD 指向的缓冲区当成源地址往外发,读操作就变成了把内存内容写进磁盘——我第一次写这个的时候方向搞反,一块测试盘的分区表被内存垃圾覆盖,幸好是模拟盘。

DW1(偏移 0x04):低 16 位是PRDTL(PRDT Length),表示 PRDT 里有多少个条目;高 16 位保留。PRDTL=0表示这个命令不带数据(比如 FLUSH CACHE),这时 HBA 完全不会去碰命令表的 PRDT 区域,哪怕里面有垃圾数据。

DW2(偏移 0x08):PRDBC(PRD Byte Count),32 位。这个字段驱动在提交前不用填,HBA 传输完成后会把实际搬运的字节数写回来。它的用途是做校验——如果PRDBC和你预期的字节数不一致,说明有 PRD 提前终止或者数据没搬完。我在读大块数据时习惯性会检查这个值,它救过我很多次:有一次PRDBC比预期少了 512 字节,顺着查下去发现是最后一个 PRD 的DBC少填了一个扇区。

DW3/DW4(偏移 0x0C、0x10):CTBA的低 32 位和高 32 位,组成命令表的 64 位物理基地址。低 7 位必须为 0,也就是 128 字节对齐。剩下的字节(0x14-0x1F,共 12 字节)保留,必须写 0——有些实现会拿这几个字节做内部用途,写垃圾进去可能出怪问题。

2.3 PRDTL、PRDBC 与命令表大小的三角关系

这三个东西的关系经常被搞混,我用一句话概括:PRDTL 是“输入”,决定 HBA 去命令表里读多少个 PRD 条目;PRDBC 是“输出”,告诉驱动 HBA 实际搬了多少字节;命令表的大小由 PRDTL 决定,是 128 + PRDTL×16。

注意最后这句——命令表大小是驱动分配时就要算好的,不是 HBA 算的。如果你按 PRDTL=8 分配了 256 字节的命令表,实际用的时候临时改成 PRDTL=16,HBA 会去读 128+16×16=384 字节,超出的 128 字节落在相邻内存上,读到的 PRD 全是垃圾,DMA 就会写到随机物理地址。这个 bug 的可怕之处在于它不会立刻崩,只会偶尔覆盖一些不重要的内存,等你发现某个不相关的数据结构被改掉时,已经排查不到源头了。

我的做法是给每个槽固定分配128 + MAX_PRD * 16字节,MAX_PRD取一个够用的值(比如 8,对应最大 32MB 单条命令)。这样命令表大小固定,物理地址固定,PRDTL只在提交前动态填进命令头,永远不会超出分配范围。代价是多花几 KB 内存,换来的是“不会越界”这个保证。

还有一个细节:PRDTL的合法范围受CAP.NCS之外的因素限制吗?实际上PRDTL是 16 位字段,理论上能到 65535,但实际驱动不会用那么多,因为单个 PRD 条目最大描述 4MB,65535 个条目意味着 256GB 的单条命令,没有设备支持这个。实用范围一般在 1 到 32 之间,覆盖 4MB 到 128MB 的单次传输。

3. 命令表与 FIS:命令真正落地的位置

3.1 Command Table 的三段式布局

命令表是一块变长内存,但前 128 字节是固定的三段结构,后面才是可变的 PRDT。

偏移长度区域写者
0x00-0x3F64 字节CFIS(Command FIS)驱动
0x40-0x5F32 字节ACMD(ATAPI Command)驱动,仅 ATAPI 用
0x60-0x7F32 字节保留必须清零
0x80 起PRDTL×16PRDT驱动

CFIS 区虽然给了 64 字节,但实际用不到这么多——Register H2D FIS 只有 20 字节,剩下的 44 字节是给其他 FIS 类型留的余量。这里有个小陷阱:CFIS 区里超出CFL×4的部分不会被 HBA 发送,但也不要留下敏感数据。我见过有实现会把整个 64 字节拷贝到内部的发送缓冲,虽然链路上只发CFL×4字节,但调试抓包时看到后面跟着一堆残留内容,排错时容易误判。养成习惯,填完 FIS 后把 CFIS 区剩下的部分清零。

ACMD 区只有 ATAPI 设备用,而且必须配合PxCMD.ATAPI位和命令头的A位置 1。对普通 SATA 盘,这块区域完全不参与,但保留区(0x60-0x7F)在规范里明确要求“必须为 0”,我一般直接把整个命令表在分配时清零,省得每次单独处理。清 256 字节的成本远低于一个隐藏 bug 的排查成本。

3.2 Register H2D FIS 的字段拼装

Register H2D FIS(FIS Type = 0x27)是驱动用得最多的 FIS,20 字节装下了命令、特性、LBA、扇区数、控制等一堆信息。它的字段顺序不是按字节顺序排的,而是按 ATA 寄存器顺序排的,这点必须记住。

struct fis_reg_h2d { uint8_t fis_type; /* 0x27 */ uint8_t pmport_c; /* bit7 C=1, bit3:0 PM Port */ uint8_t command; /* ATA 命令码 */ uint8_t featurel; /* Feature 低 8 位 */ uint8_t lba0; /* LBA 7:0 */ uint8_t lba1; /* LBA 15:8 */ uint8_t lba2; /* LBA 23:16 */ uint8_t device; /* Device 寄存器 */ uint8_t lba3; /* LBA 31:24 */ uint8_t lba4; /* LBA 39:32 */ uint8_t lba5; /* LBA 47:40 */ uint8_t featureh; /* Feature 高 8 位 */ uint8_t countl; /* Count 7:0 */ uint8_t counth; /* Count 15:8 */ uint8_t icc; /* Isochronous Command Completion */ uint8_t control; /* Control 寄存器 */ uint8_t aux[4]; /* 保留 */ } __attribute__((packed));

拼装的时候有两处必须小心。

第一处是LBA 的 48 位拆成 6 个字节,但不是连续的 6 个字节。lba0到lba2是低 24 位,然后中间夹了一个device字节,接着lba3到lba5才是高 24 位。这个“夹心”结构来自 ATA 寄存器的历史布局,第一次写的时候很容易写成连续的 6 字节,结果 LBA 超过 24 位之后就全错位了。读小容量盘的前几个扇区不会暴露这个问题,一旦访问到 LBA 超过 0xFFFFFF 的区域(大约 8GB 之后),数据就全乱了。

第二处是device字节的内容。对 LBA48 命令,device的 bit6(LBA 模式位)要置 1,bit4(DEV 位,选择主从盘)一般填 0 表示主盘。有些老代码会把这个字节整个填 0,在模拟器上跑得挺好,真实盘上就偶尔失败——原因是部分设备会检查这个位来判断寻址模式。

icc字段平时填 0。只有在启用了等时传输特性(CAP.SSC相关)时才需要,普通读写用不到。

countl/counth表示要传输的扇区数(对 READ/WRITE DMA EXT),注意是扇区数,不是字节数。写到这里我又想起一个坑:某次调试时看到PRDBC只有 512,而预期是 4096,查了半天发现是countl填了4096,设备以为要传 4096 个扇区,实际只给了 4KB 缓冲区,HBA 搬完 PRD 描述的量就停了,PRDBC=512其实是对的——是我自己把字节数当扇区数填了。

3.3 PRD 条目:DBC 的减一语义与 4MB 边界

PRD 条目 16 字节,结构很简单,但有两个致命细节。

struct prd_entry { uint32_t dba_low; /* 数据缓冲区物理地址低 32 位 */ uint32_t dba_high; /* 数据缓冲区物理地址高 32 位 */ uint32_t rsvd; /* 保留,必须为 0 */ uint32_t dbc; /* bit31 IOC, bit21:0 字节数-1 */ } __attribute__((packed));

第一个细节:DBC 填的是“字节数减一”。要传 512 字节,填 511;要传 4096 字节,填 4095;要传 1 字节,填 0。这个设计的好处是 0 也能表达有效长度(1 字节),坏处是每个驱动作者都要在这里摔一跤。更麻烦的是它不会在边界上立刻报错——多描述一个字节,HBA 就多写一个字节到缓冲区后面,如果那块内存恰好是空闲的,什么都不会发生;如果恰好是另一个结构体的头部,就会在很久之后表现为一个毫不相关的 bug。这就是我开头说的那个问题的完整机理。

第二个细节:单个 PRD 条目不能跨越 4MB 物理地址边界。规范的原意是让 HBA 内部的地址计算只需要维护一个 4MB 窗口,跨越边界会导致内部计数器溢出。具体约束是:如果 DBA 的 bit 22 到 bit 31 在某一段上发生变化,这个条目就是非法的。实践中的做法是:分配 DMA 缓冲区时按 4MB 对齐,并且单次传输不超过 4MB,这样一个缓冲区天然就不跨边界。如果要传更大的数据,就拆成多个 PRD 条目,每个条目负责一个 4MB 以内的、不跨界的分段。

IOC位(Interrupt on Completion)控制这条 PRD 搬完后要不要触发中断。多条目场景下一般只在最后一个条目置 1,前面的置 0,否则一次传输会触发多次中断。不过要注意,AHCI 的中断实际是由PxIS的各个位报告的,IOC影响的是 HBA 对最后一个 PRD 完成时的处理方式,具体行为要看实现——有些 HBA 在非最后条目置 IOC 时会立刻发中断,有些会忽略。保守做法是只在最后一条置 1。

PRD 条目的 DBA 没有对齐要求,字节级任意地址都行。但实际用起来,我建议按至少 512 字节(扇区大小)对齐,某些 HBA 实现会对非对齐缓冲区做额外的拆分,性能反而下降。

4. Received FIS:设备回话的地方

4.1 256 字节 Received FIS 的六段布局

Received FIS 是 HBA 往系统内存里写的唯一一块区域,PxFB/PxFBU指向它的物理基址,必须 256 字节对齐(低 8 位为 0)。这块区域固定 256 字节,内部按 FIS 类型分成六段,位置是硬编码的。

偏移大小FIS 类型触发条件
0x00-0x1F32 字节D2H Register FIS (0x34)命令完成、错误上报
0x20-0x3F32 字节PIO Setup FIS (0x5F)PIO 传输的中间阶段
0x40-0x5F32 字节DMA Setup FIS (0x41)DMA 传输启动、NCQ 完成
0x60-0x7F32 字节Set Device Bits FIS (0xA1)NCQ 完成、错误位更新
0x80-0xBF64 字节Unknown FIS收到未定义 FIS 类型
0xC0-0xFF64 字节保留未使用

每段的实际 FIS 长度都小于分到的空间,比如 D2H Register FIS 只有 20 字节,后面 12 字节是保留。HBA 写的时候只写有效部分,剩下的保持原样——所以初始化时必须把整块 256 字节清零,否则第一次读到的“保留区”里可能是什么垃圾,误当成数据解析就麻烦了。

PxFB的对齐要求比PxCLB宽松(256 字节 vs 1024 字节),但同样有个静默截断问题:低 8 位是 RO,写进去的会被丢弃。我这里犯过一个低级错误,把 Received FIS 和 Command List 放在同一页里,想省一页内存,结果PxFB只能 256 对齐,而PxCLB要求 1024 对齐,两个地址冲突,最后 HBA 访问的命令列表和我想的完全不是一回事。这两块内存必须分开分配,不要试图挤在一起。

4.2 PxCI、PxIS、PxSACT 的联动关系

这三个寄存器是驱动和 HBA 之间的“完成信号系统”,理解它们的分工能省掉大量轮询逻辑。

PxCI(Command Issue,偏移 0x38)是 32 位,每一位对应一个命令槽。驱动写 1,HBA 开始执行该槽的命令;命令完成(或出错)后,HBA 清 0。注意PxCI的置位是“写 1 有效”,但清除是 HBA 干的,驱动不要去写 0 清它——写 0 没有效果。有些实现允许通过PxCMD.CLO强制清所有位,那是应急手段。

PxSACT(SActive,偏移 0x34)是给 NCQ 用的。NCQ 命令提交时驱动同时置PxSACT和PxCI的对应位,设备可能乱序完成,PxSACT位在收到 Set Device Bits FIS 时才清。非 NCQ 命令不需要碰PxSACT。很多简单驱动干脆完全不用 NCQ,把PxSACT永远保持 0,也能正常工作——只是性能差一些。

PxIS(Interrupt Status,偏移 0x10)是中断状态,每一位对应一类事件。最常用的是:

  • DHRS(bit 0):D2H Register FIS 收到,命令完成的主要信号
  • PSS(bit 1):PIO Setup FIS 收到
  • DSS(bit 2):DMA Setup FIS 收到
  • SDBS(bit 3):Set Device Bits FIS 收到
  • TFES(bit 30):Task File Error Status
  • HBF(bit 29):Host Bus Fatal

PxIS的清除方式是写 1 清除(RW1C),写 0 无效。这个语义和很多人直觉相反——直觉上“清标志”应该写 0,但 AHCI 用的是写 1。我见过有人写PxIS = 0想清中断,结果一个都没清掉,中断风暴直接把系统卡死。

PxIE(偏移 0x14)是中断使能,位定义和PxIS一样。轮询模式下把PxIE全部清零,只读PxIS判断状态;中断模式下按需使能DHRS、PSS、DSS、SDBS这几位,TFES和HBF也建议使能,方便及时感知错误。

4.3 PIO Setup 与 D2H Register FIS 的处理差异

这两种 FIS 都表示“设备有话要说”,但用途完全不同,混起来处理会出问题。

D2H Register FIS是“命令完成”的正式通知。收到它意味着这个命令槽的工作结束了,HBA 已经清了PxCI对应位,驱动可以去读数据缓冲区和PxTFD了。它的字段包含最终的 Status 和 Error 寄存器值,直接反映命令成功还是失败。

PIO Setup FIS是“PIO 传输的中间握手”。PIO 模式下数据不是 DMA 搬的,而是设备告诉主机“我要给你 N 字节,你准备接收”,主机从PxCMD旁边的数据端口(偏移 0x80 的PxDATA)读走。一个 PIO 命令可能产生多个 PIO Setup FIS,每个对应一段数据。如果驱动把 PIO Setup FIS 当成完成信号,会在数据传输到一半时就以为结束了,后面的数据全丢。

现在大部分驱动都走 DMA 路径(READ/WRITE DMA EXT),PIO 只用在 IDENTIFY、SET FEATURES 这类小命令上,而且这些命令的数据量小(512 字节),一次 PIO Setup 就够。但不能因此忽略 PIO Setup 的处理——即使你不主动用 PIO 命令,设备在某些错误恢复场景下也会发 PIO Setup FIS。

DMA Setup FIS在非 NCQ 场景下用得不多,但 NCQ 场景下是关键的。它包含 DMA 缓冲区偏移和传输计数,配合PxSACT实现乱序完成。

Set Device Bits FIS在 NCQ 里用来一次性报告多个命令的完成状态,它的 32 位 SActive 字段告诉驱动哪些槽完成了。非 NCQ 场景下基本不出现。

还有一个特殊类型:Unknown FIS。当设备发来一个 HBA 不认识的 FIS 类型时,HBA 会把它整个拷贝到偏移 0x80 处(最多 64 字节),置位PxIS.UFS(bit 4),然后主动把PxCMD.FRE清零停止 FIS 接收。这是规范规定的保护行为,防止未知 FIS 反复冲刷缓冲区。驱动看到UFS置位,必须处理完(一般是记日志、复位端口),然后重新置PxCMD.FRE=1,否则端口从此收不到任何 FIS,表现为“命令提交了但永远不完成”。

5. 物理地址、对齐与缓存一致性

5.1 虚拟地址到物理地址的转换时机

这是整个 AHCI 驱动里最容易出系统性错误的地方,而且错误表现五花八门。核心原则只有一条:凡是写进 HBA 寄存器的地址,必须是物理地址;凡是 HBA 会读写的内存,必须在提交前完成地址转换并锁定(不可换出、不可迁移)。

需要转成物理地址的地址一共有五类:

  1. PxCLB/PxCLBU—— Command List 基址
  2. PxFB/PxFBU—— Received FIS 基址
  3. Command Header 的CTBA/CTBAU—— Command Table 基址
  4. PRD 条目的DBA—— 数据缓冲区地址
  5. ATAPI 场景下 ACMD 相关的地址(如果有)

转换时机很重要。如果用的是页表管理的系统,虚拟地址到物理地址的映射随时可能变(页换出、迁移、写时复制),所以在提交命令前转换一次,并且在命令完成前保证这块内存不被释放或迁移。裸机环境简单,物理地址就是虚拟地址,virt_to_phys是个空操作;但在有 MMU 且开分页的环境里,这层必须做扎实。

我踩过的坑是:在中断处理里直接用了栈上的局部变量作为 DMA 缓冲区。栈的物理页在命令执行期间可能被换出(如果栈支持换页),HBA 往一个已经不映射的物理页写数据,结果要么是数据丢失,要么是覆盖了别人的页。DMA 缓冲区必须来自专门的、物理连续的、锁定不换出的内存池。

5.2 64 位寻址能力与 CAP.S64A

CAP寄存器的 bit 31 是S64A(Supports 64-bit Addressing)。这个位决定了两件事。

如果S64A=1,所有地址寄存器的高 32 位有效,可以用 64 位物理地址,驱动不用操心内存落在哪。

如果S64A=0,PxCLBU、PxFBU、CTBAU、PRD 的dba_high全部必须写 0,并且所有相关内存必须在 4GB 以下。这里有个隐蔽的陷阱:规范说这些高 32 位字段在 S64A=0 时是“保留”的,但保留字段的行为是未定义的——有些实现会忽略,有些会检查非零值并报错。所以最稳的做法是无条件写 0,而不是“反正硬件会忽略”。

在 64 位环境里,要拿到 4GB 以下的物理内存,需要分配器支持 DMA 掩码。如果没有现成的分配器,一个土办法是启动时预留一块低 4GB 的连续内存专门给 AHCI 用,驱动从里面切。这块内存大小要算够:每个端口 1KB 命令列表 + 256 字节 Received FIS + 32 个命令表(假设每个 256 字节就是 8KB)+ 若干数据缓冲区。实际预留 1MB 足够跑几个端口了。

还有一个和 S64A 相关的细节:即使 S64A=1,PxCLB和PxFB的低位对齐要求依然存在(分别是 1024 和 256 字节)。64 位寻址不改变对齐约束。

5.3 缓存刷新与内存序

如果系统开了 Cache,还有一层问题:HBA 读的内存必须是从主存读的最新值,HBA 写的内存必须能被驱动看到。

具体来说,命令列表和命令表是驱动写、HBA 读,所以驱动写完这些结构之后、写PxCI之前,必须把这些 cache line刷回主存(clean/flush),否则 HBA 可能读到旧的缓存内容——注意是“可能”,不是“一定”,取决于缓存策略和 HBA 是否 snoop 总线。x86 平台的 PCI 设备通常会 snoop,一般不用手工刷;但 ARM 等平台如果没有 IO 一致性,就必须手工维护。

Received FIS 和数据缓冲区是 HBA 写、驱动读,所以在读之前必须让对应的 cache line 失效(invalidate),否则驱动读到的是缓存里的旧值。

最麻烦的是写PxCI之前的屏障。现代 CPU 和编译器都可能重排指令,如果PxCI的写被重排到命令表写入之前,HBA 会看到一个还没填好的命令头。所以PxCI的写必须用一个 full barrier 包起来:

/* 1. 填好命令头、命令表、PRDT */ build_command(slot, ...); /* 2. 确保所有写入对设备可见(刷 cache + 编译屏障 + 内存屏障) */ dma_wmb(); flush_cache_range(cmd_table, cmd_table_len); /* 3. 最后才敲门铃 */ hba_write32(port_base + PXCI, 1u << slot);

这个顺序不能变。我在模拟器上从来没遇到重排问题(因为模拟器是顺序执行的),换到真机就偶发失败,查了两天才定位到是编译器把PxCI的写提前了。

6. 手写一个最小可跑的 AHCI 提交路径

6.1 端口初始化的正确顺序

初始化顺序错了会出现各种“寄存器写了没反应”的怪现象。下面这个顺序是我试出来的,每一步都有理由。

static int port_init(volatile uint8_t *abar, int p) { volatile uint8_t *px = abar + 0x100 + p * 0x80; uint32_t cap = mmio_read32(abar + 0x00); /* 步骤 1:确认这个端口上有设备且通信已建立 */ uint32_t ssts = mmio_read32(px + 0x28); if ((ssts & 0x0F) != 0x03) /* DET = 3: 设备存在且已建立通信 */ return -1; /* 步骤 2:清掉所有残留错误,避免带病启动 */ mmio_write32(px + 0x30, 0xFFFFFFFF); /* PxSERR 写 1 清 */ mmio_write32(px + 0x10, 0xFFFFFFFF); /* PxIS 写 1 清 */ /* 步骤 3:确保端口完全停止,先停命令再停 FIS 接收 */ uint32_t cmd = mmio_read32(px + 0x18); if (cmd & (1u << 0)) { /* ST */ cmd &= ~(1u << 0); mmio_write32(px + 0x18, cmd); while (mmio_read32(px + 0x18) & (1u << 15)) ; /* 等 CR 清 */ } cmd = mmio_read32(px + 0x18); if (cmd & (1u << 4)) { /* FRE */ cmd &= ~(1u << 4); mmio_write32(px + 0x18, cmd); while (mmio_read32(px + 0x18) & (1u << 14)) ; /* 等 FR 清 */ } /* 步骤 4:关中断,先用轮询把链路跑通 */ mmio_write32(px + 0x14, 0); /* 步骤 5:设置命令列表和 Received FIS 的物理基址 */ mmio_write32(px + 0x00, (uint32_t)(clb_phys & 0xFFFFFFFF)); if (cap & (1u << 31)) mmio_write32(px + 0x04, (uint32_t)(clb_phys >> 32)); else mmio_write32(px + 0x04, 0); mmio_write32(px + 0x08, (uint32_t)(fis_phys & 0xFFFFFFFF)); if (cap & (1u << 31)) mmio_write32(px + 0x0C, (uint32_t)(fis_phys >> 32)); else mmio_write32(px + 0x0C, 0); /* 步骤 6:先开 FRE,再开 ST,一次写入减少中间状态 */ cmd = mmio_read32(px + 0x18); cmd &= ~(1u << 28); /* 清 ALPE,避免上电管理干扰 */ cmd |= (1u << 4); /* FRE = 1 */ cmd |= (1u << 0); /* ST = 1 */ mmio_write32(px + 0x18, cmd); return 0; }

几个关键点值得展开。

为什么先停命令再停 FIS 接收?因为ST=1时 HBA 可能还在往 Received FIS 写,先关FRE会让它写到一半的文件描述符断掉。反过来先停ST再停FRE才是安全的顺序。规范里也建议这个顺序。

为什么最后才置 FRE 和 ST?基址寄存器必须在端口停止状态下设置,运行中修改是未定义行为。有些实现会在ST=1时忽略PxCLB的写入,你的设置看起来“成功”了(回读值正确),但内部用的还是旧值。

为什么用一次写同时置 FRE 和 ST?如果分两次写,中间会有一小段时间FRE=1而ST=0,这时 HBA 已经在监听 FIS 了。理论上没问题,但如果设备那边恰好有残留 FIS 在发,会被写进 Received FIS 并置位PxIS,可能导致驱动误判。一次写能避开这个窗口。

6.2 提交一条 IDENTIFY DEVICE 并取回 512 字节

IDENTIFY DEVICE(命令码 0xEC)是最适合入门的命令:不带 LBA,固定返回 512 字节,不需要考虑 48 位地址和分段。下面是完整的提交流程。

#define SLOT 0 static int submit_identify(volatile uint8_t *px, void *buf_phys, void *cmdtab_phys) { struct fis_reg_h2d *fis = (struct fis_reg_h2d *)cmdtab_phys; struct prd_entry *prd = (struct prd_entry *)((uint8_t *)cmdtab_phys + 0x80); struct cmd_header hdr; /* --- 1. 构造 Register H2D FIS --- */ memset(fis, 0, 64); fis->fis_type = 0x27; fis->pmport_c = 0x80; /* C = 1, PM Port = 0 */ fis->command = 0xEC; /* IDENTIFY DEVICE */ fis->device = 0x00; /* 非 LBA 命令,device 寄存器清 0 */ fis->countl = 0; fis->counth = 0; /* feature/lba/control 全部保持 0 */ /* --- 2. 构造 PRDT:一个条目,512 字节 --- */ prd->dba_low = (uint32_t)((uint64_t)buf_phys & 0xFFFFFFFF); prd->dba_high = (uint32_t)((uint64_t)buf_phys >> 32); prd->rsvd = 0; prd->dbc = (512 - 1) | (1u << 31); /* 511 | IOC */ /* --- 3. 构造命令头 --- */ hdr.dw0 = (5 << 0) /* CFL = 5 DWORD = 20 字节 */ | (0 << 6) /* W = 0,读设备 */ | (0 << 5); /* A = 0,非 ATAPI */ hdr.dw1 = 1; /* PRDTL = 1 */ hdr.dw2 = 0; /* PRDBC 由 HBA 回填 */ hdr.dw3 = (uint32_t)((uint64_t)cmdtab_phys & 0xFFFFFFFF); hdr.dw4 = (uint32_t)((uint64_t)cmdtab_phys >> 32); /* --- 4. 写进命令列表的第 SLOT 个槽 --- */ memcpy((uint8_t *)clb_virt + SLOT * 32, &hdr, 32); /* --- 5. 确保 HBA 看到的是最新内容 --- */ dma_wmb(); flush_cache_range(cmdtab_virt, 0x80 + 16); /* --- 6. 敲响门铃 --- */ mmio_write32(px + 0x38, 1u << SLOT); return 0; } static int wait

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询