SD 卡在上电后的完整加载流程:从中断信号到文件系统挂载
一篇讲清 SD 卡驱动在上电阶段的完整工作链路——从 CPU 如何访问硬件寄存器,到 CD 引脚电平检测、中断向量跳转、识别线程发送的每一条命令,最终到文件系统挂载。全文面向具备 RT-Thread 基础、但尚未深入外设中断与 MMIO 机制的嵌入式开发者。
引言
很多人调试 SD 卡时,只会在 rt_device_find("sd0") 成功后开始读写,却说不清"卡到底是怎么被挂载上去的"。这篇文章不讨论读写性能,聚焦上电时刻驱动从无到有、最终让 /sd/ 可用的完整链路。
整条链路可以拆成四段:
- 地基:软件如何"够到"硬件寄存器(MMIO 与寄存器写入)
- 搭建:上电时驱动如何被内核自动调用,如何建立 host 与事件对象
- 检测:SD 卡插入状态如何被判定(CD 引脚电平、去抖、判位)
- 挂载:中断/事件如何驱动识别线程逐条发命令、最终把卡挂载为文件系统
先交代一个容易忽略的前提:卡插入状态与"有没有发生中断"是两回事,这决定了上电与热插拔走的是两条不同的触发路径,后面会专门对比。
开篇先用一段判定流程回答读者最关心的两个问题——“这个中断是什么"以及"已插卡的上电是否会触发中断”:
核心结论:"中断"指的是 SDIO 控制器的卡检测中断(
SDIO_INT_CARD_DET);已插卡的上电不走中断,走主动查询,中断留给真正产生边沿的热插拔。
一、地基:软件如何访问硬件寄存器
1.1 内存映射输入输出(MMIO)
CPU 执行读写指令时,它只负责把目标地址放到地址总线上,并通过控制信号声明本次是读还是写。CPU 自身并不区分地址背后是内存、是 CLIC 中断控制器、还是 UART——这个裁决由总线上的一段译码电路完成。
这段电路是地址译码器(Address Decoder):它比较地址总线的高位,判断本次地址落入哪台设备的地址区间,并只对该设备发出有效的片选信号。片选无效的设备,其数据输出处于高阻态,不参与总线响应。
于是就有了内存映射输入/输出(MMIO)的定义:外设寄存器以内存地址的形式暴露给 CPU,软件用普通的读地址、写地址指令即可操作外设。 例如某设备的中断控制器寄存器组被映射在地址空间的某一段,软件通过指针访问它:
#define XXX_BASE 0x????0000UL /* 中断控制器基地址——具体值由芯片决定 */
#define XXX ((XXX_Type *)XXX_BASE)
此后 XXX->REG = value 就等价于向那片寄存器写入一个值。
1.2 寄存器与"写寄存器"
寄存器是一组能保存 0/1 的存储单元,硬件上由多个 D 触发器(Flip-Flop) 构成——每个 D 触发器保存 1 bit,8 个即组成一个 8 位寄存器。D 触发器的行为是:在时钟有效沿到来时,把数据输入端的 0/1 抄入并保持;时钟不来则维持原值。
由此,"写寄存器"的定义清晰起来:
写寄存器 = 触发目标 D 触发器的时钟,把数据总线上的值锁存进该存储单元,并让其持续保持。
它是一次状态设定而非一次性的数据传递——写入的 0/1 会一直保持,直到下次被改写。这也解释了为何软件写一次中断使能位后,那个中断会持续可用。
一次完整的"写寄存器"在硬件上按以下顺序发生:
- CPU 指令给出地址、数据与写使能标记
- 地址译码器按地址选中目标外设并置其片选有效
- 目标寄存器的 D 触发器在(片选 ∧ 写使能)共同产生的时钟沿上,锁存数据总线上的值
- 锁存值经内部组合逻辑持续控制对应功能(如接通/断开某条中断请求通道)
理解这一点,后面所有"打开中断""清除标志"的代码才不是黑盒。
二、搭建:驱动如何被内核自动加载
2.1 自动初始化机制
RT-Thread 的驱动不是在上电后手动调用的,而是靠自动初始化。内核按固定等级顺序执行 INIT_*_EXPORT 宏注册的所有函数:
| 初始化等级 | 宏 | 就绪内容 |
|---|---|---|
| 板级硬件 | INIT_BOARD_EXPORT | GPIO、时钟 |
| 组件 | INIT_COMPONENT_EXPORT | 内核线程工具 |
| 环境 | INIT_ENV_EXPORT | 文件系统等组件 |
| 应用 | INIT_APP_EXPORT | SD 卡驱动在此执行 |
SD 卡驱动通过 INIT_APP_EXPORT(rt_hw_sdio_init) 挂入链表,上电到应用阶段时内核自动调用它。
2.2 host 与事件对象
rt_hw_sdio_init 对每个 SDIO 控制器执行 sdio_host_create,其核心工作有两项,分别对应两个关键数据结构:
- host(主机控制器):即
rt_mmcsd_host结构体,描述该 SDIO 控制器与 SD 卡的关联。创建过程会分配内存、绑定一组操作回调(发命令、配总线、读卡检测、开关中断),并把 host 与底层硬件句柄hw_sdio关联。 - 事件对象(event):由
rt_event_init(&hw_sdio->event, ...)创建的内核 IPC 对象,用于跨线程通知。检测线程阻塞等待该事件,ISR 或初始化代码向其发送标志。
它们在源码中的作用可以对应到如下关键行:
/* 创建并启动检测线程,它随后阻塞等待卡检测事件 */
hw_sdio->detect_thread =
rt_thread_create(name, rthw_mmcsd_detect, host,
RT_MMCSD_DETECT_STACK_SIZE, 25, 10);
rt_thread_startup(hw_sdio->detect_thread);
/* 初始化事件对象 */
rt_event_init(&hw_sdio->event, name, RT_IPC_FLAG_PRIO);
/* host 绑定能力:操作回调表 + 基础配置 */
host->ops = &ops; /* {发命令, 配总线, 读卡检测, 开关中断} */
host->freq_min = 400000; /* SD 协议规定的启动时钟 */
host->valid_ocr = ...; /* 支持的电压范围 */
需要强调的是:本阶段只完成资源与能力登记,尚未读写卡片。 真正的检测发生在下一段的 rthw_sd_detect()。
上电后从 INIT_APP_EXPORT 到挂载前的执行步骤可概括为下列流程:
三、检测:SD 卡插入状态如何被判定
3.1 CD 引脚与机械开关
SD 卡插座上有一根专门的引脚 CD(Card Detect),它连接插座内的机械行程开关,随插卡动作改变通断:
- 未插卡:开关断开(开路),由上拉电阻把 CD 引脚保持在高电平。
- 完全插入:卡片边缘压合开关(短路到地),CD 引脚被拉到低电平。
- 半插入:触点反复接通/断开,引脚电平在高、低之间抖动——这正是需要"去抖"的原因。
因此,"卡是否插好"的本质判定,是检测 CD 引脚的电平是否为低。
CD 引脚的电平状态由机械开关与上拉电阻共同决定,其判定关系如下:
3.2 去抖
机械开关的抖动会导致电平在插入瞬间不稳定。驱动通过写 SDIO 控制器的去抖寄存器来过滤:
HAL_SDIO_DEBNCE_SET(&hsdio, Hal_Sys_AhbClkGet() / 50u); /* 约 20ms */
DEBNCE 寄存器设置了一个约 20ms 的窗口:只有当 CD 引脚电平连续稳定 20ms,才认定该电平有效。 半插入状态下触点持续抖动,无法通过这一判决,从而避免误判。
3.3 判位:读 CDETECT 寄存器
驱动层的 rthw_sd_detect 通过 HAL 函数读取控制器内部的卡检测状态:
/* rthw_sd_detect */
if (Hal_Sdio_CardIsPresent(&hsdio)) {
ret = 0x01; /* 卡在位 */
} else {
ret = 0x00; /* 无卡 */
}
HAL 层的实现读取的是 CDETECT 寄存器(而非直接读某个通用 GPIO):
rs_uint8_t Hal_Sdio_CardIsPresent(SDIO_HandleTypeDef *hsdio)
{
rs_uint8_t isPresent = FALSE;
if ((hsdio != NULL) && (hsdio->Index < HAL_SDIO_INDEX_MAX)) {
if (0 == Hal_RegRead(SDIO_REG(hsdio->Index, CDETECT), REG_MASK_ALL, 0u)) {
isPresent = TRUE; /* CDETECT bit0 = 低 → 卡在位 */
}
}
return isPresent;
}
因此判位逻辑非常简单:CDETECT.bit0 == 0(引脚被拉低)即判定卡在位。 整个判断收敛在 HAL 那一个条件里。
关于"是哪个引脚"的澄清:在该 SoC 上,卡检测走的是 SDIO 控制器自带的 CD 检测通道(CDETECT 寄存器),而非普通 GPIO 的可编程引脚。SD 的 CLK/CMD/DAT 引脚复用交由板级/FPGA 实现,软件层并不配置它们的 PIN。
从插卡到中断的完整信号链——五个环节依次传导,如图:
四、中断机制:从信号到 ISR
4.1 谁负责中断:RISC-V 的 CLIC
该 SoC 的 CPU 是 RISC-V 内核(T-Head E906),自带 CLIC(Core-Local Interrupt Controller)。所有外设(SDIO、UART、GPIO、DMA、ISP…)的中断请求汇入 CLIC 仲裁后进入 CPU。
CLIC 初始化时,为所有中断源启用向量中断模式:
for (i = 0; i < MAX_IRQn; i++) {
CLIC->CLICINT[i].IP = 0; /* 清除挂起位 */
CLIC->CLICINT[i].ATTR |= 1; /* SHV=1 → 向量跳转 */
}
SHV(Selectable Hardware Vector)位是关键:它让 CLIC 在中断被判定有效后,依据中断源编号直接向 CPU 提供处理函数地址,CPU 据此跳转——不经过软件查表。
4.2 中断请求信号的本质
"外设向 CLIC 发送中断请求"在物理上不是发送数据,而是驱动一根专用中断请求线的电平。通常有两种形式:
- 电平触发:线被持续驱动到有效电平,直到被清除才恢复。
- 边沿触发:线上产生一次电平跳变(上升沿或下降沿)。
SD 卡检测属于电平锁存型——SD 卡中断标志由软件主动清除(__HAL_SDIO_CLEAR_INT),中断请求线在清除前持续有效。这印证了该信号是电平状态,而非一次性脉冲。
中断信号只承载"有事件发生"这一事实,不携带事件内容。 具体是哪类事件(卡插入、数据传输完成、超时…),CPU 在进入 ISR 后读取中断状态寄存器,逐位判断得出。
4.3 向量跳转:谁"调用"了 ISR
以 SDIO0 为例,SDIO0_IRQHandler 通过 ATTRIBUTE_ISR(即 __attribute__((interrupt("machine"))))声明,让编译器自动生成机器模式中断的现场保存/恢复与 mret 返回:
ATTRIBUTE_ISR void SDIO0_IRQHandler(void)
{
rt_interrupt_enter();
if (host[HAL_SDIO_INDEX_0] != NULL)
rthw_sdio_irq_process(host[HAL_SDIO_INDEX_0]);
rt_interrupt_leave();
}
SDIO0_IRQHandler 并非由软件函数调用,而是由 CLIC 硬件向量跳转进入。 中断到来时,CLIC 依据 SDIO0 的中断源编号直接让 CPU 跳到该函数地址,CPU 硬件完成现场切换。ISR 内部 rt_interrupt_enter() / rt_interrupt_leave() 只是通知内核当前处于中断上下文。
ISR 内 rthw_sdio_irq_process 的处理极短:读中断状态寄存器 → 清除标志 → 逐位判断事件 → 若是卡检测位则通过 rt_event_send 发送事件标志。ISR 不执行耗时操作,仅负责发信号。
4.4 中断源的规模与函数对应关系
该 SoC 的中断源枚举覆盖了从内核定时器中断到十余类外设中断,数量上百。要澄清的一点是:每个中断源是否拥有专属处理函数,取决于驱动是否实现了同名的强符号。
- 启动代码(
vectors.S)用宏为每个中断源预置.weak弱符号,默认指向兜底的Default_Handler。 - 有驱动实现的(如
SDIO0_IRQHandler、UART0_IRQHandler)以强符号覆盖 weak,成为真正的处理函数。 - 未实现的中断停在
Default_Handler(检查是否非屏蔽中断,否则进入异常流程)。 - GPIO 采用分组中断:每 16 根引脚共用一个中断源编号,共 8 个中断处理函数管理全部 GPIO 引脚。进 ISR 后仍需读取引脚状态做进一步细分。
因此"每个中断都有专属函数"并不绝对——架构上每个中断源占一个向量位,真正有专属函数的只有驱动写过的那些。
SDIO 外设中断从发生到进入 ISR 的消息时序如下(注意全程由 CLIC 硬件仲裁与向量跳转,非软件分发):
五、关键区分:上电插入 与 热插拔
这是整条链路最容易混淆的地方。两者最终都触发加载,但触发路径完全不同:
| 维度 | 上电时已插卡 | 使用中热插拔 |
|---|---|---|
| 触发源 | 软件主动查询 | 硬件中断 |
| 是否走中断 | 否 | 是 |
| 触发机制 | 创建 host 时主动读 CDETECT | CD 引脚边沿 → CLIC 向量跳转 |
| 时序前提 | 发生在中断使能之前 | 发生在中断使能之后 |
| 后续路径 | 殊途同归:均触发检测线程 → 识别 → 挂载 | 同左 |
为什么上电时已插卡不触发中断? 因为卡早在上电前就在位,CD 引脚电平是稳定态,没有边沿跳变,CLIC 不会据此产生中断。此时驱动选择在创建 host 的末尾主动调用一次检测:
if (rthw_sd_detect(host) == 0x01) {
rt_event_send(&hw_sdio->event, SDIO_INT_CARD_DET); /* 卡在位 → 直接发事件 */
}
于是上电走了"主动查询 + 直接发事件"的路径;而 SDIO_INT_CARD_DET 向量中断留给"使用中突然插入/拔出"这类真正的边沿事件。两者最终汇合于同一条加载流程。
六、挂载:从事件到文件系统
加载过程实际由多条线程协作完成——启动线程建立资源、检测线程等待并分发事件、识别线程下发命令。三者通过事件与邮箱传递信号,时序如下:
6.1 检测线程
事件标志 SDIO_INT_CARD_DET 唤醒的就是检测线程 rthw_mmcsd_detect。它一直阻塞在 rt_event_recv 上:
while (1) {
/* 阻塞等待卡检测事件,无事件时不占用 CPU */
rt_event_recv(&hw_sdio->event, SDIO_INT_CARD_DET,
RT_EVENT_FLAG_AND | RT_EVENT_FLAG_CLEAR,
RT_WAITING_FOREVER, NULL);
hw_sdio->sdio_dev->use_hold_reg = 1;
mmcsd_change(host); /* 通知识别线程 */
ret = mmcsd_wait_cd_changed(300); /* 等待识别结果 */
if ((ret == MMCSD_HOST_PLUGED) && (rthw_sd_detect(host) == 0x01)) {
dev = rt_device_find("sd0");
if (dev && (dfs_mount("sd0", "/sd/", "elm", 0, 0) == 0)) {
LOG_I("sd0 mount to /sd/ !");
mmcsd_mount_over();
}
} else if ((ret == MMCSD_HOST_UNPLUGED) && (rthw_sd_detect(host) == 0x00)) {
Hal_Sdio_DeInit(&hsdio); /* 拔卡 → 反初始化 */
}
}
这里 mmcsd_change 本身不处理识别,它只是向接收邮箱 mmcsd_detect_mb 投递一条消息,真正的识别在识别线程。
6.2 识别线程与命令序列
识别线程 mmcsd_detect 收到邮箱消息后,若当前 host->card == NULL(尚未初始化卡片),则依次下发识别命令。一套识别按 SDIO → SD → MMC 的顺序尝试:
while (1) {
rt_mb_recv(&mmcsd_detect_mb, &host, RT_WAITING_FOREVER);
if (host->card == NULL) {
mmcsd_power_up(host); /* 上电 + 延时稳定 */
mmcsd_go_idle(host); /* CMD0 复位,至少 74 个时钟 */
err = sdio_io_send_op_cond(host, 0, &ocr); /* 先试 SDIO 卡 */
if (!err) { if (init_sdio(host, ocr)) ...; continue; }
mmcsd_go_idle(host);
err = mmcsd_send_app_op_cond(host, 0, &ocr); /* 再试 SD 卡:CMD55 + ACMD41 */
if (!err) {
if (init_sd(host, ocr)) ... /* 是 SD 卡 → 读 CID/CSD 建档 */
rt_mb_send(&mmcsd_hotpluge_mb, &host); /* 通知"卡已插好" */
continue;
}
err = mmc_send_op_cond(host, 0, &ocr); /* 最后试 MMC 卡 */
...
}
}
识别原理:一张卡无法凭外观判断类型,只能靠下发命令"试探"。SD 卡的关键命令是 ACMD41,其响应携带该卡支持的工作电压范围,据此确认是 SD 卡。init_sd 读取 CID/CSD 并建立块设备 sd0。
6.3 挂载
识别完成后,检测线程等待在 mmcsd_wait_cd_changed(300),确认卡在位后执行 dfs_mount("sd0", "/sd/", "elm", 0, 0),把块设备 sd0 以 FAT(elm = Elm-Chan FatFs)文件系统挂载到路径 /sd/。至此,上层代码可通过 open("sd0/...") 读写 SD 卡文件。
七、完整链路与分层设计
将以上四段串起来,整条链路为:
上电复位
→ INIT_APP_EXPORT 自动初始化
→ sdio_host_create:host + 事件对象 + 检测线程
→ 主动读 CD 引脚(CDETECT):卡在位 → 直接发事件
→ 检测线程收到事件 → 发邮箱
→ 识别线程:上电 → CMD0 → 试探 SDIO/SD/MMC → 建档 sd0
→ dfs_mount("sd0", "/sd/", "elm")
→ /sd/ 可读写
热插拔分支(另一张卡):
→ CD 引脚边沿 → CLIC 向量跳转 SDIO0_IRQHandler → 发事件
→ 汇入同上加载流程
值得强调的分层原则是:中断上下文只负责最小操作(读状态、清标志、发事件),真正的耗时工作(上电、逐条命令、读卡识别、文件系统挂载)都在普通线程上下文完成。 这是嵌入式设计中普遍遵循的"中断做最小、耗时进线程"原则,它保证了中断响应及时、系统不被长 ISR 阻塞。
完整流程的"一图流"汇总如下(涵盖上电与热插拔两条路径):
总结
从上电到 SD 卡可读写,驱动实际经历了四层递进:
- 寄存器访问地基——MMIO 与 D 触发器,是一切外设操作的前提;
- 资源搭建——host、事件对象、检测线程的建立,只登记能力不动卡片;
- 插入检测——CD 引脚电平经去抖后形成 CDETECT 位,是判定卡在位的依据;
- 中断与加载——上电走主动查询,热插拔走 CLIC 向量中断,最终由检测线程与识别线程逐条发命令、建档、挂载文件系统。
理解这条链路的关键,在于分清**“电平状态"与"边沿事件”、“中断只发信号"与"线程做工作”**这两组对照。搞清它们,再回头阅读 drv_sdio 驱动代码时,就只剩直接对应了。
本文依据实际嵌入式工程中的 SD 卡驱动源码整理,去除了项目代号与平台实现细节,保留通用原理。

4523

被折叠的 条评论
为什么被折叠?



