☰
AHCI系统内存结构详解:命令列表、命令表与接收FIS
2026/10/26 3:25:31 网站建设 项目流程

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 里藏着布局参数

全局区的字段不多,但每一个都会影响内存结构的形状。我列几个关键的:

偏移名称对内存结构的影响
0x00CAPNCS决定 Command List 有多少个槽位,S64A决定地址是 32 位还是 64 位
0x04GHCAE位必须置 1,否则整个 AHCI 模式不生效
0x08IS全局中断状态,各端口中断的汇总
0x0CPI位图,标记哪些端口已实现,决定你要为哪些端口分配结构
0x10VS版本号,排查兼容性问题时有用
0x24CAP2一些扩展能力,比如是否支持自动部分到 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字段说明
DW0CFLbits 4:0Command FIS 长度,以 DWORD 为单位,H2D Register FIS 固定为 5
DW0Abit 5ATAPI 命令标志,发 PACKET 命令时置 1
DW0Wbit 6方向,1 表示从设备读(写内存),0 表示写设备(读内存)
DW0Pbit 7Prefetchable,一般置 0
DW0Rbit 8Reset,置 1 时控制器会先发一个复位再执行本条命令
DW0Bbit 9BIST,一般置 0
DW0Cbit 10Clear Busy upon R_OK,一般置 0
DW0PMPbits 15:12端口倍增器端口号,不用时置 0
DW0PRDTLbits 31:16PRDT 条目数量
DW1PRDBC实际传输字节数,由控制器回写,软件初始化时写 0
DW2CTBACommand Table 基地址低 32 位
DW3CTBAUCommand Table 基地址高 32 位
DW4-7Reserved保留,必须清零

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-0x1FDSFISDMA Setup FIS
0x20-0x3FPSFISPIO Setup FIS
0x40-0x5FRFISRegister Device-to-Host FIS
0x60-0x7FSDBFISSet Device Bits FIS
0x80-0x9FUFISUnknown FIS
0xA0-0xFFReserved保留

这个布局是固定的,不会因为实现不同而变。但槽位内部的字段解析需要按 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里几个高频位的对应关系:

位名称含义对应结构
0DHRSRegister D2H FIS 收到RFIS
1PSSPIO Setup FIS 收到PSFIS
2DSSDMA Setup FIS 收到DSFIS
3SDBSSet Device Bits 收到SDBFIS
4UFS未知 FIS 收到UFIS
5DPSPRDT 处理完成—
22PRCSPHY Ready 状态变化—
24OFSFIFO 溢出—
27IFS接口致命错误—
28HBDS主机总线数据错误PRDT
29HBFS主机总线致命错误PRDT
30TFESTask File 错误RFIS
31CPDS冷插拔检测—

清除的顺序建议是:先清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 的CTBACommand Table 地址错或未对齐
PxTFD一直 BSYCommand Header 的CFLCFIS 长度字段写成 0 或其他值
IDE 设备识别成 ATAPICommand Header 的A位该置 1 没置,或反之
收到PxIS.HBFSPRDT 条目dbc字节数超出实际缓冲区
收到PxIS.TFES,错误码0x84CFIS 的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 一次能跑通的最小初始化顺序

初始化序列的顺序是有讲究的,不是随便排的。我按依赖关系整理成下面这几步:

  1. 使能 PCI 的 Memory Space 和 Bus Master。这两位置 1 之前,ABAR 访问会返回全 1,DMA 也不会工作。很多人卡在这一步,读出来的CAP全是0xFFFFFFFF,以为是控制器坏了。

  2. 读CAP确定参数。NCS决定 Command List 要用几个槽位,S64A决定地址宽度。这一步必须在分配内存之前完成。

  3. 置GHC.AE = 1。这一位是 AHCI 模式的开关,不置 1 的话后续所有端口寄存器行为都没有保证。

  4. 对PI里每个置位的端口,先关命令和 FIS 接收。顺序是:清PxCMD.ST,等PxCMD.CR变 0;清PxCMD.FRE,等PxCMD.FR变 0。这个顺序不能反,因为ST没清就去清FRE,控制器可能还在用 FIS 区。

  5. 写PxCLB/PxCLBU和PxFB/PxFBU。按先高后低的顺序写,写完各自读回来校验一遍。

  6. 清PxSERR和PxIS。上电时这两个寄存器可能是脏的,不清的话后面一开中断就会误触发。

  7. 置PxCMD.FRE = 1。FIS 接收先于命令列表启动,因为命令执行过程中设备会回 FIS,FIS 区必须提前准备好。

  8. 置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 位之间忘了成对写。

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

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

立即咨询