AHCI 这套接口规范摊开来看,真正让驱动开发者反复返工的,往往不是命令码怎么拼,而是**系统内存结构(System Memory Structures)**那几张表——命令列表、命令表、接收 FIS 区。命令码写错,顶多报个错误位;内存结构摆错,控制器会一声不吭地把数据写到别的地方去,或者干脆卡在PxCI置位后永远不动。我在第一次给一块自制的 SATA 转接板写裸机驱动时,就栽在这上面:PxCI写下去了,PxTFD一直显示 BSY,逻辑分析仪上能看到 DMA 请求拉起来,数据却全塞进了一段早就被栈覆盖的内存里。
这篇内容围绕 AHCI 的系统内存结构展开,讲清楚 ABAR 寄存器区、Command List、Command Table、Received FIS 这四块内存各自长什么样、偏移怎么算、对齐有什么讲究、谁写谁读、什么顺序写。我会把每个字段为什么这么设计讲透,也会把规范里一笔带过、实际调试中却会要命的地方单独拎出来。适合已经能看懂 SATA 基本命令、正准备自己撸一遍 AHCI 初始化的读者,也适合已经写过一半、卡在某个端口起不来的朋友对照排查。
1. 一次 DMA 数据错位,把我拽回了 AHCI 的内存结构图
那块转接板的问题最后定位到PxCLBU上。我当时只写了PxCLB,把命令列表基地址的低 32 位填了进去,高 32 位忘了写,而那片内存恰好落在 4GB 以上。控制器按 64 位地址去取 Command Header,取到的是一段全零,CFL、PRDTL全是 0,于是它认为这是一条零长度的命令,行为完全不可预测。这种错误靠读代码看不出来,只能靠把内存结构当成一张"地图"来核对。
1.1 三种视角:PCI 配置空间、ABAR 寄存器区、系统内存结构
理解 AHCI 之前,得先把三个地址空间分清楚,很多人一开始就混在一起。
PCI 配置空间是用来"找到"控制器的:通过 Class Code0x010601(Mass Storage / SATA / AHCI 1.0)匹配设备,然后在 BAR5 里拿到 ABAR 的物理基地址。这一步只做一次,做完之后基本不再碰。
ABAR 寄存器区是控制器暴露给软件的一组寄存器,通过 MMIO 访问。它本身不存数据,只存"指针"和"状态"。比如PxCLB里放的是命令列表的地址,PxFB里放的是接收 FIS 区的地址。你可以把它理解成一张索引卡片,卡片上写的全是仓库门牌号。
系统内存结构才是真正的仓库:Command List、Command Table、Received FIS 这三块区域都分配在系统主存里,由软件初始化、由控制器通过 DMA 读写。软件写命令、控制器读命令;控制器写完成状态、软件读状态。所有的"业务数据"都在这里流转。
这三者的关系是单向的:配置空间告诉你 ABAR 在哪,ABAR 里的寄存器告诉你内存结构在哪,内存结构里才是真正的命令和数据。任何一环的地址写错,整条链路就断了,而且断点很难从上层现象反推——PxCI不清零可能是指针错,也可能是 PRDT 错,还可能是 FIS 区没对齐。
1.2 为什么"结构"比"命令"更值得先搞清楚
新手常有的思路是:先抄一段发 IDENTIFY 命令的代码,跑通了再慢慢理解。这个思路在别的外设上也许行得通,在 AHCI 上却容易吃大亏,因为 AHCI 的结构是一次性配置、长期复用的。
Command List 的地址一旦写进PxCLB,在整个端口生命周期里都不应该改;Command Table 是每条命令独立的,但它的地址是从 Command Header 里取的,而 Header 本身又在 Command List 里;Received FIS 区更是全程只分配一次,控制器会持续往里写。这意味着:结构摆错,后面几百条命令都会错;结构摆对,命令码就算一时写错,报错信息也是干净的、可定位的。
我自己后来的习惯是:拿到一块新控制器,先不急着发命令,而是把内存结构全部搭好,然后用读寄存器的方式验证——PxCLB读回来是不是我写进去的值,PxFB是不是 256 字节对齐,Command Header 的第一个 DWORD 里CFL是不是我期望的 5。这几步验证做完,再发命令,出问题的概率会低一个数量级。
2. ABAR 指向的那块寄存器区,其实是整张内存地图的索引
ABAR 通常映射到一段 8KB 的空间(规范要求至少 2KB,实际实现多为 8KB,因为端口数最多 32 个)。这段空间分成两大块:前面是全局控制区(Generic Host Control),从偏移0x00到0x2B;后面从0x100开始是每个端口各自的寄存器组,每组固定0x80字节。
2.1 Generic Host Control:0x00 到 0x2B 里藏着布局参数
全局区的字段不多,但每一个都会影响内存结构的形状。我列几个关键的:
| 偏移 | 名称 | 对内存结构的影响 |
|---|---|---|
0x00 | CAP | NCS决定 Command List 有多少个槽位,S64A决定地址是 32 位还是 64 位 |
0x04 | GHC | AE位必须置 1,否则整个 AHCI 模式不生效 |
0x08 | IS | 全局中断状态,各端口中断的汇总 |
0x0C | PI | 位图,标记哪些端口已实现,决定你要为哪些端口分配结构 |
0x10 | VS | 版本号,排查兼容性问题时有用 |
0x24 | CAP2 | 一些扩展能力,比如是否支持自动部分到 slumber |
CAP里的NCS(Number of Command Slots)在 bits 12:8,是从 0 开始计数的。读出来是 31,表示有 32 个槽位;读出来是 0,表示只有 1 个槽位。这个值直接决定 Command List 的大小上限:每个槽位对应一个 32 字节的 Command Header,32 个槽位正好 1024 字节,这也正是规范要求 Command List 必须 1KB 对齐的原因——地址低位被硬件忽略,1KB 对齐刚好能让低 10 位全部为 0。
CAP.S64A在 bit 31,为 1 说明支持 64 位寻址。这一位决定了PxCLBU、PxFBU以及 Command Header 里CTBAU是否需要填写。很多人在 32 位系统上写驱动,习惯性地只写低 32 位,换到 64 位环境就立刻翻车,原因就在这里。
CAP.SSS(bit 27)表示支持错峰上电,CAP.SPM(bit 26)表示支持端口倍增器,CAP.SAM(bit 18)表示只支持 AHCI 模式。这几个位不直接改内存布局,但会影响初始化流程,比如SAM为 1 时不需要去尝试 IDE 兼容模式。
2.2 Port Register Set 的 0x80 步长与 0x100 起点
端口寄存器的基址算法是:ABAR + 0x100 + port_index * 0x80。这个0x80是固定的,不随实现变化。每个端口组内部偏移如下:
struct hba_port { volatile uint32_t clb; /* 0x00 Command List Base Address */ volatile uint32_t clbu; /* 0x04 Command List Base Address Upper */ volatile uint32_t fb; /* 0x08 FIS Base Address */ volatile uint32_t fbu; /* 0x0C FIS Base Address Upper */ volatile uint32_t is; /* 0x10 Interrupt Status */ volatile uint32_t ie; /* 0x14 Interrupt Enable */ volatile uint32_t cmd; /* 0x18 Command and Status */ volatile uint32_t rsv0; /* 0x1C Reserved */ volatile uint32_t tfd; /* 0x20 Task File Data */ volatile uint32_t sig; /* 0x24 Signature */ volatile uint32_t ssts; /* 0x28 SATA Status (SCR0: SStatus) */ volatile uint32_t sctl; /* 0x2C SATA Control (SCR2: SControl) */ volatile uint32_t serr; /* 0x30 SATA Error (SCR1: SError) */ volatile uint32_t sact; /* 0x34 SATA Active (SCR3: SActive) */ volatile uint32_t ci; /* 0x38 Command Issue */ volatile uint32_t sntf; /* 0x3C SNotification */ volatile uint32_t fbs; /* 0x40 FIS-based Switching Control */ volatile uint32_t devslp; /* 0x44 Device Sleep */ uint32_t rsv1[10]; /* 0x48 - 0x6F */ volatile uint32_t vs[4]; /* 0x70 Vendor Specific */ };这段结构体在我自己的代码里几乎没变过,唯一的改动是把uint32_t全部换成了volatile——寄存器会被硬件异步修改,不加volatile编译器优化一下就可能把轮询循环优化成死循环或者直接跳过。
0x1C那个保留位置很容易被忽略。我在一次 review 别人的代码时看到他把tfd放在了0x1C,整个后续偏移全错位,结果是PxIS读到了PxCMD的值,中断永远清不掉。
2.3 从 CAP/CAP2 反推你该分配多大的内存
结构大小不是拍脑袋定的,要从能力寄存器反推。整个端口的系统内存结构由三部分组成:
- Command List:固定
32 * 32 = 1024字节(只有实际用到的槽位数是NCS + 1,但分配时按最大 1KB 分配最省事),必须1KB 对齐。 - Received FIS:固定 256 字节,必须256 字节对齐。
- Command Table:每个槽位一份,大小 =
0x80 + PRDTL * 16字节,必须128 字节对齐。如果没有用满 32 个槽位,可以只分配NCS + 1份。
如果按 32 个槽位、每个 Command Table 带 8 个 PRDT 条目来算,单端口大约需要1024 + 256 + 32 * (128 + 8 * 16) = 9472字节,不到 10KB。32 个端口全开也就 300KB 左右,对现代系统来说微不足道,但对嵌入式环境要掂量一下——如果只用到 2 个端口,就按PI寄存器里实际置位的端口来分配,不要盲目全开。
提示:
PI寄存器读出来为 0 的情况确实存在,某些实现里端口全部被 OEM 屏蔽了。这时候不要继续初始化,直接判定控制器不可用。
3. Command List:1KB 对齐的 32 槽命令槽位,错一个字节就全乱
Command List 是一张定长数组,每个元素是一个 32 字节的 Command Header。控制器通过PxCI里的位来索引这张表:PxCI的 bit 0 置位,控制器就去读 Header[0];bit 5 置位,就去读 Header[5]。这里有个隐含约束:PxCI置位的位置不能超过NCS,否则行为未定义。
3.1 Command Header 的八个 DWORD 逐字段拆解
每个 Header 是 32 字节,也就是 8 个 DWORD。这个结构我在不同平台上写过至少五遍,每次都要重新核对一遍位域,因为漏掉一个字段的后果都很严重。
| DWORD | 字段 | 说明 |
|---|---|---|
| DW0 | CFLbits 4:0 | Command FIS 长度,以 DWORD 为单位,H2D Register FIS 固定为 5 |
| DW0 | Abit 5 | ATAPI 命令标志,发 PACKET 命令时置 1 |
| DW0 | Wbit 6 | 方向,1 表示从设备读(写内存),0 表示写设备(读内存) |
| DW0 | Pbit 7 | Prefetchable,一般置 0 |
| DW0 | Rbit 8 | Reset,置 1 时控制器会先发一个复位再执行本条命令 |
| DW0 | Bbit 9 | BIST,一般置 0 |
| DW0 | Cbit 10 | Clear Busy upon R_OK,一般置 0 |
| DW0 | PMPbits 15:12 | 端口倍增器端口号,不用时置 0 |
| DW0 | PRDTLbits 31:16 | PRDT 条目数量 |
| DW1 | PRDBC | 实际传输字节数,由控制器回写,软件初始化时写 0 |
| DW2 | CTBA | Command Table 基地址低 32 位 |
| DW3 | CTBAU | Command Table 基地址高 32 位 |
| DW4-7 | Reserved | 保留,必须清零 |
CFL这个字段最容易写错。H2D Register FIS 是 20 字节,正好 5 个 DWORD,所以 ATA 命令的CFL就是 5。但如果用的是 ATAPI PACKET 命令,CFL仍然是 5,因为 CFIS 里放的还是一条 H2D Register FIS,真正的 SCSI 命令在 ACMD 区,靠A位置 1 告诉控制器去那里取。
W位是方向位,很多人以为它只影响数据流向,实际上它还会影响PRDTL为 0 时控制器的行为。对于 IDENTIFY 这种纯读命令,W必须置 1;对于 WRITE DMA 系列,W必须置 0。如果方向和实际数据流不匹配,控制器可能直接报PxIS.HBFS(Host Bus Fatal Error)或者干脆挂住。
DW4-7的保留部分必须清零,这一点规范里写得很明确,但我在实际项目里见过有人用memset分配后没清,残留的栈数据被当成保留位,某些控制器会因此拒绝执行命令。稳妥做法是每次填 Header 前先把 32 字节整体清零。
3.2 PRDTL 决定 Command Table 的实际大小
PRDTL是 PRDT 条目的数量,为 0 表示这条命令不传输数据(比如 FLUSH CACHE)。它决定了 Command Table 需要多大:0x80 + PRDTL * 16。
这里有个容易踩的坑:Command Table 的地址是 128 字节对齐的,但大小并不要求是 128 的整数倍。如果你的 PRDTL 是 1,Command Table 只需要128 + 16 = 144字节。但为了让每个槽位的 Command Table 地址计算简单,实践中通常按固定大小分配,比如每个槽位固定分配 256 字节(支持 8 个 PRDT 条目),地址直接按base + slot * 256算。
代价是浪费一点内存,好处是槽位之间的地址计算极其干净,不需要维护一张"每个槽位实际大小"的表。我在一个资源紧张的嵌入式项目里试过按实际大小紧凑分配,结果地址计算逻辑复杂了一倍,调试时间远超省下的几 KB 内存,后来还是改回了固定大小。
如果确实要支持大块传输,PRDTL 可以开得很大,上限是 65535(16 位字段)。按每个条目最多 4MB 算,理论上一条命令能传 256GB,当然实际中受协议和内存限制远达不到。我自己的经验值是单条命令的 PRDTL 控制在 32 以内,超过就拆成多条命令,这样中断响应和错误恢复都更可控。
3.3 PxCI 与 PxSACT:什么时候该写哪个寄存器
这两个寄存器经常被搞混,因为它们都和"发命令"有关,但语义完全不同。
PxCI(Command Issue)是命令槽位的占用位图。软件填好 Header 和 Table 后,把对应位置 1,控制器就开始执行。命令完成(成功或失败)后,控制器会把这个位清零。所以软件可以通过轮询PxCI判断命令是否完成——位还在,说明还在跑;位没了,说明结束了。
PxSACT(SATA Active)是NCQ 的活跃位图。只有在启用 NCQ(发送 READ FPDMA QUEUED 等命令)时才需要关心它。发 NCQ 命令时,软件要先在PxSACT里置位,再在PxCI里置位;控制器完成某个 tag 后,会清PxSACT对应的位,但不一定清PxCI——因为PxCI对应的是槽位,NCQ 下槽位和 tag 是绑定的,但完成顺序由设备决定。
不会用 NCQ 的时候,PxSACT完全可以不碰。我见过有代码在非 NCQ 命令里也往PxSACT写值,结果控制器认为这是一条带 tag 的命令,行为完全跑偏。判断标准很简单:命令码是0x60/0x61/0x64/0x65这几个 FPDMA 系列才需要动PxSACT,其他一律不碰。
还有一个顺序问题:置位PxCI之前,必须确保 Command Header、Command Table、PRDT 全部写完,并且对内存做一次写屏障。在 x86 上因为强内存序一般不会出问题,但在 ARM 这类弱内存序平台上,如果不加屏障,控制器可能读到还没写完的 Header。这个坑我在一块 ARM 板子上真实踩过,现象是命令时好时坏,加了几条dmb之后彻底稳定。
4. Command Table 内部:CFIS、ACMD 与 PRDT 的三段式布局
Command Table 是每条命令独立的一份结构,由 Header 里的CTBA指向。它的布局是固定的三段式:前 64 字节放 CFIS,接着 32 字节放 ACMD,然后 32 字节保留,从偏移0x80开始才是 PRDT 条目。
4.1 前 0x80 字节的两块命令缓冲
CFIS 区(偏移0x00-0x3F)存放 H2D Register FIS,这是真正告诉设备"要干什么"的地方。它的前 20 字节被使用,后面是填充。字段排布如下:
struct h2d_register_fis { uint8_t fis_type; /* 0x27 */ uint8_t pmport_c; /* bit7 = C, bits6:4 保留, bits3:0 = PM Port */ uint8_t command; /* ATA 命令码,如 0xEC = IDENTIFY */ uint8_t featurel; uint8_t lba0; uint8_t lba1; uint8_t lba2; uint8_t device; uint8_t lba3; uint8_t lba4; uint8_t lba5; uint8_t featureh; uint8_t countl; uint8_t counth; uint8_t icc; uint8_t control; uint8_t aux[4]; };fis_type必须是0x27,这是 H2D Register FIS 的类型码。写错了控制器会直接把这条 Header 标记为错误,PxIS里报PxIS.TFES(Task File Error)。
command字段对 ATA 设备是命令码,对 ATAPI 设备固定是0xA0(PACKET)。这一点很关键:发 ATAPI 命令时,CFIS 里的 command 是0xA0,真正的 SCSI CDB 放在 ACMD 区,同时 Header 的A位必须置 1。如果忘记置A位,控制器会去解析 ACMD 区的内容当成 FIS,结果是垃圾命令。
ACMD 区(偏移0x40-0x5F)只在 ATAPI 命令时有效,存放 12 字节或 16 字节的 SCSI CDB。ATA 命令时这块区域应该清零,不清零虽然通常不会出问题,但在某些严格实现的控制器上可能触发意外。
LBA 字段的填法也有讲究。对于小于 28 位的地址,用lba0到lba2,device字段的低 4 位填 LBA 的高 4 位(即lba27:24),device的 bit 6 置 1 表示 LBA 模式。对于 48 位地址,用lba0到lba5,device的 bit 6 置 1。这个位如果忘了置,设备会按 CHS 模式解释地址,读到的地方完全是随机的。
4.2 PRDT 条目的 16 字节与 4MB 上限
PRDT 从 Command Table 的0x80开始,每个条目 16 字节,结构如下:
struct prdt_entry { volatile uint32_t dba; /* Data Base Address,低两位忽略 */ volatile uint32_t dbau; /* 高 32 位,32 位寻址时置 0 */ volatile uint32_t rsv0; /* 保留 */ volatile uint32_t dbc; /* bit31 = I(完成时中断),bits21:0 = 字节数 - 1 */ };dbc的低 22 位是"字节数减 1",所以单个条目最多描述0x400000 = 4MB的数据。这个设计的原因是 22 位刚好够表达 4MB 范围,再大就溢出了。如果需要传输超过 4MB 的数据,必须拆成多个 PRDT 条目。
dbc的 bit 31 是中断位,置 1 表示这个条目传输完成后触发中断。这个位是给大块传输做进度控制用的,一般来说只需要在最后一个条目上置 1,中间条目置 0,这样可以避免每个条目都触发一次中断。我自己在实现大文件读取时,早期是每个条目都置 1,结果中断风暴把 CPU 占满了,后来改成只在末尾置 1,性能立刻正常。
dba的低两位被硬件忽略,意味着数据缓冲区至少要 4 字节对齐。实践中建议按 512 字节甚至 4KB 对齐,因为有些控制器(尤其是早期实现)对非对齐缓冲区处理得不好,可能出现数据错位。我在一块老控制器上遇到过 4 字节对齐缓冲区导致最后一个扇区数据被截断 2 字节的问题,改成 4KB 对齐后消失。
4.3 64 位地址下 DBAU 与 PxCLBU 的配对规则
S64A为 1 时,所有地址都是 64 位的,涉及三个地方:PxCLBU、PxFBU、CTBAU和DBAU。这四组高低位必须成对写,而且要保证写入顺序正确。
写入顺序的坑在于:如果先写低位再写高位,中间可能有一个短暂的时间窗口,控制器读到了"新的低位 + 旧的高位"这种组合,得到一个完全错误的地址。规范建议的写法是:先把基地址对齐的最低有效字节保持不动,先写高位,再写低位。因为低位里有一部分是对齐位,硬件本身会忽略,所以改了也不影响;等高位写完再写低位,就避免了中间态。
以PxCLB/PxCLBU为例,安全的写法是:
port->clbu = (uint32_t)(clb_phys >> 32); port->clb = (uint32_t)(clb_phys & 0xFFFFFC00); /* 低 10 位硬件忽略 */先写高 32 位,再写低 32 位,且低位直接按 1KB 对齐掩码处理。这个顺序不是迷信,是规范里明确推荐的,我在一块支持 64 位寻址的控制器上验证过:反过来写,初始化成功率大概只有七成,按规范顺序写,基本 100%。
还有一个细节:DBAU在 32 位寻址下是否必须写 0。规范说如果S64A为 0,DBAU被忽略。但有些实现会检查这个字段,非 0 就报错。稳妥做法是:无论S64A是什么,只要不用 64 位地址,就把DBAU和CTBAU都清 0。
5. Received FIS:设备把状态写回内存的五个槽位
Received FIS 区是控制器主动写、软件被动读的一块内存,固定 256 字节,必须 256 字节对齐。它的作用是把设备发回的 FIS 缓存下来,软件通过读这块内存获取设备的详细状态,比只读PxTFD能拿到更多信息。
5.1 256 字节对齐与五个 FIS 区的固定偏移
这 256 字节被切成五个固定大小的槽位,每个 32 字节(最后一块是 32 字节但只用前 16 字节):
| 偏移 | 名称 | 全称 |
|---|---|---|
0x00-0x1F | DSFIS | DMA Setup FIS |
0x20-0x3F | PSFIS | PIO Setup FIS |
0x40-0x5F | RFIS | Register Device-to-Host FIS |
0x60-0x7F | SDBFIS | Set Device Bits FIS |
0x80-0x9F | UFIS | Unknown FIS |
0xA0-0xFF | Reserved | 保留 |
这个布局是固定的,不会因为实现不同而变。但槽位内部的字段解析需要按 FIS 类型来,不能一概而论。
判据是每个槽位第一个字节的fis_type:DSFIS 是0x41,PSFIS 是0x5F,RFIS 是0x34,SDBFIS 是0xA1。UFIS 比较特殊,它的内容完全取决于收到什么,所以第一个字节可能是任意值,软件需要靠PxIS.UFS位来判断。
有一个容易忽略的点:这些槽位不是每次都有效的。比如你发了一条 DMA 读命令,完成时通常只有 DSFIS 被填充(如果设备用了 DMA Setup FIS),RFIS 可能没被填。软件必须先看PxIS里哪一位被置起,再去对应槽位读内容,不能盲目读。
5.2 每个槽位分别在什么场景下被填充
DSFIS(DMA Setup FIS)在设备使用 DMA Setup FIS 时被填充,主要出现在 NCQ 场景下,设备用它来告诉主机本次 DMA 传输的方向和偏移。非 NCQ 的 DMA 命令一般不产生 DSFIS,槽位里保留着上一次的内容,所以不能靠读 DSFIS 判断 DMA 是否完成。
PSFIS(PIO Setup FIS)在 PIO 传输时被填充,设备用它告诉主机"我准备好了一部分数据,你可以来读了"。PIO 命令(比如 IDENTIFY 的 PIO 版本)会触发多次 PSFIS,每次对应一个数据块。软件要循环处理,直到PxTFD的DRQ位清零。
RFIS(Register D2H FIS)是最常用的一块,几乎所有命令完成时都会填充它,里面包含完整的 Task File 寄存器镜像:status、error、lba、count、device。命令失败时,错误码就在 RFIS 的error字段里,比只读PxTFD能拿到更多细节。
SDBFIS(Set Device Bits FIS)只在 NCQ 下有效,设备用它来通知某些命令已完成,并把完成状态写进去。它的第一个 DWORD 里有一个 32 位的位图,对应 32 个 tag 的完成状态。
UFIS(Unknown FIS)是兜底的,收到不认识的 FIS 类型时控制器会把它原样放到这里,同时置PxIS.UFS。这个位被置起通常意味着设备或链路有问题,需要检查。
5.3 PxIS 与 PxSERR 的读取与清除顺序
PxIS是端口中断状态,PxSERR是 SATA 错误状态,两者都是写 1 清零。这里的顺序极其重要:先读状态,再处理,最后清状态。如果先清再处理,处理过程中新产生的中断会丢失;如果清的时候把没处理的位也清了,错误就永远查不到原因。
PxIS里几个高频位的对应关系:
| 位 | 名称 | 含义 | 对应结构 |
|---|---|---|---|
| 0 | DHRS | Register D2H FIS 收到 | RFIS |
| 1 | PSS | PIO Setup FIS 收到 | PSFIS |
| 2 | DSS | DMA Setup FIS 收到 | DSFIS |
| 3 | SDBS | Set Device Bits 收到 | SDBFIS |
| 4 | UFS | 未知 FIS 收到 | UFIS |
| 5 | DPS | PRDT 处理完成 | — |
| 22 | PRCS | PHY Ready 状态变化 | — |
| 24 | OFS | FIFO 溢出 | — |
| 27 | IFS | 接口致命错误 | — |
| 28 | HBDS | 主机总线数据错误 | PRDT |
| 29 | HBFS | 主机总线致命错误 | PRDT |
| 30 | TFES | Task File 错误 | RFIS |
| 31 | CPDS | 冷插拔检测 | — |
清除的顺序建议是:先清PxSERR(写 1 到需要清的位),再清PxIS(写 1 到需要清的位),最后重新读一次确认都清零了。这个顺序的原因是:PxSERR里有些位会导致PxIS里的对应位重新置起,如果先清PxIS再清PxSERR,可能出现PxIS刚清完又被PxSERR触发的中间态,看起来像"清不掉"。
PxSERR的 bit 15 是只读的汇总位,写 1 不会清除它,必须把底下的具体错误位清掉,它才会自动归零。这个细节很多人第一次遇到会困惑:明明写了 1,读回来还是 1。
6. 对齐、缓存与屏障:规范里写了但容易被跳过的三件事
规范里关于对齐的要求写得很短,就一句话:"Command List 必须 1KB 对齐,Received FIS 必须 256 字节对齐,Command Table 必须 128 字节对齐。"但真正做起来,对齐只是第一步,后面还有缓存一致性和内存屏障两关。
6.1 1KB / 256 字节 / 128 字节对齐的实际做法
对齐分配一定要在物理地址层面做。很多系统上虚拟地址和物理地址的对齐并不一致,如果按虚拟地址对齐分配,映射到物理地址后可能就不对齐了。
正确的做法是先分配足够大的内存(比需要的大小多出对齐粒度),然后在物理地址层面向上取整,建立映射时把这个偏移考虑进去。伪代码如下:
void *alloc_aligned(size_t size, size_t align) { void *raw = dma_alloc(size + align + sizeof(void *)); uintptr_t phys = virt_to_phys(raw); uintptr_t aligned = (phys + align - 1) & ~(align - 1); /* 记录原始指针用于释放,返回对齐后的虚拟地址 */ return phys_to_virt(aligned); }如果 Dma 分配器本身支持对齐参数,直接用会更省事。但要注意:有些平台的 DMA 分配器只保证 4KB 对齐,对于 1KB 和 256 字节的需求是够的,但如果你的需求超过 4KB(比如某些实现要求 Command Table 也按 1KB 对齐),就要显式指定。
一个我踩过的坑:早期我图省事,用静态数组做 Command List,加了__attribute__((aligned(1024)))。在裸机上跑没问题,但换到有 MMU 的环境,物理地址取决于链接脚本,实际物理地址并不保证 1KB 对齐。后来改成运行时分配 + 物理地址校验,问题消失。
6.2 写回缓存下的可见性问题
这个问题在带缓存的 CPU 上比在裸机上严重得多。软件写 Command Header 的时候,数据可能只到了 CPU 的缓存,没进主存;控制器通过 DMA 读内存,读到的是旧数据。
解决办法有两个方向:一是把这块内存映射成非缓存(uncached)的,读写直达内存,缺点是性能差;二是保持缓存,但每次写完后手动做一次 flush。
我自己的选择是混合策略:Command List 和 Command Table 用可缓存内存 + 写后 flush,Received FIS 用非缓存内存。原因是 Command List 写入频率不高(每条命令一次),flush 的开销可以接受;Received FIS 需要频繁读,而且读的时候不能有缓存陈旧的顾虑,非缓存更省心。
flush 的粒度要按缓存行来,不是按结构体大小来。比如 Command Header 是 32 字节,但一个缓存行通常是 64 字节,flush 的时候要把整个缓存行刷出去。如果为了省事只刷 32 字节,可能刷了个寂寞。
注意:在多核环境下,flush 之后还要确保其他核的缓存里没有旧副本。这个靠
clean+invalidate组合来做,单纯的clean只写回不清除,别的核仍然可能读到旧值。
6.3 一份常见故障与对应结构位置的对照表
这张表是我这几年攒下来的,几乎每次调试 AHCI 都能对上号:
| 现象 | 最可能的结构位置 | 原因 |
|---|---|---|
PxCI置位后一直不清 | Command Header 的CTBA | Command Table 地址错或未对齐 |
PxTFD一直 BSY | Command Header 的CFL | CFIS 长度字段写成 0 或其他值 |
| IDE 设备识别成 ATAPI | Command Header 的A位 | 该置 1 没置,或反之 |
收到PxIS.HBFS | PRDT 条目 | dbc字节数超出实际缓冲区 |
收到PxIS.TFES,错误码0x84 | CFIS 的command字段 | 命令码设备不支持 |
| 数据只传了前半段 | PRDT 的dbc | 只写了字节数,忘了减 1 |
| 64 位系统上完全不工作 | PxCLBU/CTBAU | 高 32 位没写 |
| 中断偶发丢失 | PxIS清除顺序 | 先清了PxIS导致重新置起的位丢失 |
| NCQ 命令完成顺序乱 | PxSACT | 忘记置PxSACT直接置PxCI |
这张表不能替代调试,但能让排查方向收敛得很快。我一般的顺序是:先看PxIS里哪一位被置起,再对照这张表看结构位置,然后用调试器 dump 出那块内存,人工核对字段值。
7. 把这些结构真正落到代码里:结构体定义与最小初始化序列
前面讲的都是"应该是什么样",这一节讲"怎么真正写出来"。我会给一套可以直接编译验证的结构体定义和初始化序列,也会说清楚每一步为什么这么排。
7.1 用定长类型和 volatile 把寄存器区描述出来
寄存器结构体必须用uint32_t而不是unsigned int,因为后者在不同平台上宽度不一样。所有寄存器字段都要加volatile,避免编译器把轮询优化掉。
#define HBA_PxCMD_ST (1u << 0) #define HBA_PxCMD_SUD (1u << 1) #define HBA_PxCMD_POD (1u << 2) #define HBA_PxCMD_CLO (1u << 3) #define HBA_PxCMD_FRE (1u << 4) #define HBA_PxCMD_FR (1u << 14) #define HBA_PxCMD_CR (1u << 15) struct hba_mem { volatile uint32_t cap; /* 0x00 */ volatile uint32_t ghc; /* 0x04 */ volatile uint32_t is; /* 0x08 */ volatile uint32_t pi; /* 0x0C */ volatile uint32_t vs; /* 0x10 */ volatile uint32_t ccc_ctl; /* 0x14 */ volatile uint32_t ccc_pts; /* 0x18 */ volatile uint32_t em_loc; /* 0x1C */ volatile uint32_t em_ctl; /* 0x20 */ volatile uint32_t cap2; /* 0x24 */ volatile uint32_t bohc; /* 0x28 */ uint8_t rsv[0x100 - 0x2C]; struct hba_port ports[32]; /* 0x100 起,每组 0x80 */ };注意rsv数组的作用:它把端口区的起始位置精确地推到0x100。如果这个填充算错了,所有端口寄存器的偏移都会错。我一般会加一句静态断言:
_Static_assert(offsetof(struct hba_mem, ports) == 0x100, "port base mismatch");结构体定义里最容易出错的是保留字段。很多实现里0x1C那个位置是保留的,如果不写进去,后续字段全部前移 4 字节。上面那份定义里rsv0就是干这个的。
7.2 一次能跑通的最小初始化顺序
初始化序列的顺序是有讲究的,不是随便排的。我按依赖关系整理成下面这几步:
使能 PCI 的 Memory Space 和 Bus Master。这两位置 1 之前,ABAR 访问会返回全 1,DMA 也不会工作。很多人卡在这一步,读出来的
CAP全是0xFFFFFFFF,以为是控制器坏了。读
CAP确定参数。NCS决定 Command List 要用几个槽位,S64A决定地址宽度。这一步必须在分配内存之前完成。置
GHC.AE = 1。这一位是 AHCI 模式的开关,不置 1 的话后续所有端口寄存器行为都没有保证。对
PI里每个置位的端口,先关命令和 FIS 接收。顺序是:清PxCMD.ST,等PxCMD.CR变 0;清PxCMD.FRE,等PxCMD.FR变 0。这个顺序不能反,因为ST没清就去清FRE,控制器可能还在用 FIS 区。写
PxCLB/PxCLBU和PxFB/PxFBU。按先高后低的顺序写,写完各自读回来校验一遍。清
PxSERR和PxIS。上电时这两个寄存器可能是脏的,不清的话后面一开中断就会误触发。置
PxCMD.FRE = 1。FIS 接收先于命令列表启动,因为命令执行过程中设备会回 FIS,FIS 区必须提前准备好。置
PxCMD.ST = 1。命令列表启动,端口进入可发命令状态。
写完这八步,端口就处于空闲等待命令的状态。可以用PxSSTS.DET判断设备是否在位:值为 3 表示设备存在且 PHY 通信已建立,值为 0 表示没有设备,值为 1 表示设备存在但通信未建立。
static int port_init(struct hba_port *p, uint64_t clb, uint64_t fb) { /* 1. 关命令引擎 */ p->cmd &= ~HBA_PxCMD_ST; while (p->cmd & HBA_PxCMD_CR) ; /* 2. 关 FIS 接收 */ p->cmd &= ~HBA_PxCMD_FRE; while (p->cmd & HBA_PxCMD_FR) ; /* 3. 写基地址,先高后低 */ p->clbu = (uint32_t)(clb >> 32); p->clb = (uint32_t)(clb & 0xFFFFFC00u); p->fbu = (uint32_t)(fb >> 32); p->fb = (uint32_t)(fb & 0xFFFFFF00u); /* 4. 清状态 */ p->serr = 0xFFFFFFFFu; p->is = 0xFFFFFFFFu; /* 5. 先开 FIS 接收,再开命令引擎 */ p->cmd |= HBA_PxCMD_FRE; p->cmd |= HBA_PxCMD_ST; return 0; }这段代码里while循环没有超时保护,实际产品代码里必须加。规范里说CR和FR在正常情况下会在很短时间内清 0,如果超过几毫秒还没清,说明控制器已经卡死了,加超时能避免驱动挂在这里。
7.3 上手自检:几个能立刻验证布局对不对的办法
结构搭好之后,别急着发命令,先做几项自检,能提前发现大部分布局问题。
第一项:寄存器回读。把PxCLB写进去之后立刻读回来,检查除了硬件忽略的低 10 位之外,其余位是不是和你写的一致。同样检查PxFB的低 8 位。这一步能发现地址计算错误和 64 位高位遗漏。
第二项:Command Header 自检。手工构造一个 Header,把CFL、PRDTL、CTBA填好,然后把整块 1KB 的 Command List 按 32 位 dump 出来,逐个核对偏移。我一般会在代码里加一个dump_cmdlist()函数,只在调试版本编译,出错时打印前几个 Header 的原始内容。
第三项:Received FIS 的初始状态。刚初始化完,PxFB指向的那 256 字节应该全是 0(或者是你上次残留的值)。如果里面有莫名其妙的数据,说明地址写错了,控制器可能正在往别的地方写。
第四项:发一条无数据传输的命令。比如FLUSH CACHE(命令码0xE7),PRDTL设 0,看PxCI是否能正常置位再清零。这条命令最简单,不涉及 PRDT,能验证 Header 和 CFIS 的基本正确性。
第五项:发 IDENTIFY DEVICE(0xEC),PRDTL设 1,缓冲区 512 字节,W位置 1。成功的话缓冲区里会有设备信息,前两个字节是0x0040(Word 0 的配置字)。这一步能验证 PRDT 的正确性。
这五项检查全部通过,基本可以确定内存结构没问题了,后面出问题就是命令参数或者协议理解的层面,排查范围大幅缩小。
最后分享一个我自己吃了亏才记住的点:Command Table 不要和 Command List 放在同一块连续内存里图省事。早期我把它们放在一起,Command List 用完 1KB 紧跟着放 Table,结果有一次计算 Table 地址时少加了一个偏移,写到了 Command List 的保留区里,控制器读到了一条半截的 Header,行为完全随机。后来改成两块独立分配,各自带对齐和校验,虽然多几行代码,但问题好定位得多——至少出问题时能立刻排除"两块内存互相踩"这种可能。
另一个体会是关于调试输出的:当PxCI卡住不清时,最有价值的动作不是去改代码,而是把 Command Header 的 8 个 DWORD 和 Command Table 的前 0x90 字节完整 dump 出来,一个字节一个字节地对规范。我自己的经验是,九成以上的卡死都能在这份 dump 里找到原因,而且通常是某个字段的偏移算错了一位,或者在 32 位和 64 位之间忘了成对写。