2. 外设驱动基础:寄存器映射、内存映射I/O与端口I/O、轮询与中断基础

各位同学,咱们今天聊点实在的。做Bootloader,说白了就是跟硬件打交道。你写的每一行代码,最终都要落到某个寄存器的某个位上。我见过不少新手,上来就对着数据手册抄代码,结果板子跑起来完全不是那么回事。为什么?因为没搞懂外设是怎么“接入”CPU的。

这一章,咱们就把外设驱动最底层的三件事说清楚:寄存器怎么映射到你的代码里、CPU怎么跟外设“对话”、以及怎么知道外设活干完了。嗯,这三件事搞明白了,后面写驱动就是搭积木。

2.1 外设寄存器映射:从硬件手册到C代码

每个外设都有一组寄存器。控制寄存器、状态寄存器、数据寄存器……它们都有固定的地址。你的任务,就是把这些地址变成C语言里能直接读写的变量。

我个人习惯用结构体来映射。举个例子,一个典型的UART外设,寄存器布局可能是这样的:

/* UART寄存器映射结构体 */
typedef struct {
    volatile uint32_t DR;      /* 数据寄存器,地址偏移0x00 */
    volatile uint32_t RSR;     /* 接收状态寄存器,偏移0x04 */
    uint32_t reserved[2];      /* 保留空间,对齐用 */
    volatile uint32_t FR;      /* 标志寄存器,偏移0x10 */
    volatile uint32_t ILPR;    /* 红外低功耗寄存器,偏移0x14 */
    volatile uint32_t IBRD;    /* 整数波特率除数,偏移0x18 */
    volatile uint32_t FBRD;    /* 小数波特率除数,偏移0x1C */
    volatile uint32_t LCR_H;   /* 线控寄存器,偏移0x20 */
    volatile uint32_t CR;      /* 控制寄存器,偏移0x24 */
    volatile uint32_t IFLS;    /* 中断FIFO级别选择,偏移0x28 */
    volatile uint32_t IMSC;    /* 中断屏蔽设置/清除,偏移0x2C */
    volatile uint32_t RIS;     /* 原始中断状态,偏移0x30 */
    volatile uint32_t MIS;     /* 屏蔽后中断状态,偏移0x34 */
    volatile uint32_t ICR;     /* 中断清除寄存器,偏移0x38 */
} UART_TypeDef;

/* 将外设基地址强制转换为结构体指针 */
#define UART0_BASE   0x4000C000
#define UART0        ((UART_TypeDef *)UART0_BASE)

这里有个关键点——volatile关键字。我刚开始做嵌入式时,就吃过这个亏。编译器优化会把看似“没变化”的寄存器读取给优化掉,结果你读状态寄存器永远读到同一个值。加了volatile,就是告诉编译器:“别动我的寄存器,每次都要老老实实去内存读。”

注意:结构体成员顺序必须严格对齐硬件手册的寄存器偏移。如果中间有保留空间,一定要用reserved占位。否则,你写的FR可能实际操作的是ILPR——板子不干活还算好的,烧了外设就麻烦了。

2.2 内存映射I/O vs 端口I/O

CPU访问外设,主要有两种方式。咱们先看个对比:

特性 内存映射I/O (MMIO) 端口I/O (PIO)
访问方式 用ldr/str等内存指令 用专用的in/out指令
地址空间 与内存共用地址空间 独立的I/O地址空间
典型架构 ARM、RISC-V、MIPS x86
保护机制 依赖MMU设置 依赖I/O权限位图
编程便利性 直接用指针,方便 需要特殊指令,稍麻烦

现在绝大多数嵌入式处理器都用内存映射I/O。说白了,外设寄存器就在内存地址里。你定义一个指针指向0x4000C000,然后读写它,就是在操作UART。

但这里有个坑——内存屏障。我在项目里遇到过,CPU是乱序执行的,你代码里先写控制寄存器再写数据寄存器,硬件可能先看到数据再看到控制。这在Bootloader里尤其要命,因为Bootloader通常不开MMU和缓存,但CPU内部的重排序依然存在。

我的习惯:在每次外设寄存器操作前后,加__DSB()和__ISB()屏障指令。虽然会损失一点性能,但在Bootloader阶段,稳定比速度重要得多。

2.3 轮询方式:简单但占CPU

轮询,就是CPU不停地问外设:“你好了没?你好了没?”

代码写起来很简单:

/* UART轮询发送一个字节 */
void uart_send_char(char c) {
    /* 等待发送FIFO非满 */
    while (UART0->FR & (1 << 5)) {
        /* 空转,啥也不干 */
    }
    UART0->DR = c;
}

/* UART轮询接收一个字节 */
char uart_recv_char(void) {
    /* 等待接收FIFO非空 */
    while (UART0->FR & (1 << 4)) {
        /* 空转 */
    }
    return (char)(UART0->DR & 0xFF);
}

你看,就是两个while循环。好处是逻辑清晰,没有竞态问题。坏处呢?CPU被死死占住了。在Bootloader里,如果你只是打印个“Hello”,轮询没问题。但如果你要等某个外设完成一个耗时操作(比如eMMC擦除,可能要几百毫秒),CPU就一直在那空转。

我曾经在一个项目里,用轮询方式等待Flash写完成。结果Bootloader启动时间长了300毫秒,客户直接投诉。嗯,从那以后,我对轮询就多留了个心眼。

适用场景:Bootloader早期阶段(时钟、DDR初始化时),中断还没配置好,只能用轮询。另外,调试串口用轮询也最可靠——中断没配好时,你总得有个地方打印信息吧?

2.4 中断方式:高效但复杂

中断的思路是:CPU告诉外设“你干完了叫我”,然后CPU该干嘛干嘛去。外设干完了,拉一根中断线,CPU停下当前工作,跳去执行中断服务程序(ISR)。

中断的流程大致是这样的:

  1. 配置中断控制器(如ARM GIC、NVIC)——使能对应的中断源。
  2. 配置外设——使能外设的中断产生条件(比如UART收到数据就产生中断)。
  3. 编写ISR——在中断处理函数里读取数据或状态。
  4. 中断返回——清中断标志,恢复现场。

一个简单的UART中断接收示例:

/* 中断服务程序 */
void UART0_IRQHandler(void) {
    uint32_t status = UART0->MIS;  /* 读屏蔽后中断状态 */
    
    if (status & (1 << 4)) {       /* 接收中断 */
        char c = (char)(UART0->DR & 0xFF);
        /* 把c放到环形缓冲区,或者直接处理 */
        rx_buffer[rx_head++] = c;
        /* 清中断 */
        UART0->ICR = (1 << 4);
    }
    
    if (status & (1 << 5)) {       /* 发送中断 */
        /* 发送完成,可以继续发下一个字节 */
        UART0->ICR = (1 << 5);
    }
}

/* 主函数里使能中断 */
void uart_init_interrupt(void) {
    /* 配置UART... */
    UART0->IMSC |= (1 << 4);      /* 使能接收中断 */
    UART0->IMSC |= (1 << 5);      /* 使能发送中断 */
    
    /* 配置NVIC */
    NVIC_EnableIRQ(UART0_IRQn);
    NVIC_SetPriority(UART0_IRQn, 1);
}

中断的好处很明显——CPU利用率高。但坏处也一堆:

  • 竞态条件:中断可能打断正在执行的代码,共享数据要加保护。
  • 中断嵌套:高优先级中断打断低优先级中断,栈开销大。
  • 延迟不确定:最坏情况下的中断响应时间很难算。
Bootloader里的中断陷阱:很多Bootloader在初始化阶段会关闭所有中断。如果你在某个阶段开了中断,但忘了配中断向量表或者中断控制器,一触发中断就直接跑飞。我见过有人把中断向量表放在DDR里,结果DDR还没初始化完,一个定时器中断过来……嗯,板子直接变砖。

2.5 轮询 vs 中断:怎么选?

在Bootloader里,我的选择原则很简单:

场景 推荐方式 原因
时钟、PLL、DDR初始化 轮询 中断环境还没建立,只能用轮询
调试串口打印 轮询 简单可靠,中断反而增加复杂度
慢速外设(eMMC、NAND) 中断或DMA 等待时间长,轮询浪费CPU
快速外设(SPI Flash) 轮询 操作快,中断开销占比大
多任务Bootloader 中断 需要并发处理多个外设事件

你想想看,Bootloader的本质是什么?是把内核从存储介质搬到内存,然后跳过去。这个过程里,大部分时间都在等存储介质。如果每个等待都用轮询,CPU利用率极低。但如果你一上来就搞复杂的中断系统,又容易出bug。

我个人建议:前期用轮询保证功能正确,后期再优化成中断。先跑通,再跑快。

2.6 本章知识体系

下面这张图,把咱们这一章的核心逻辑串起来了:

外设驱动基础:寄存器映射、I/O方式、事件处理 外设驱动基础核心逻辑 外设寄存器映射 CPU访问外设的方式 内存映射I/O (MMIO) 端口I/O (PIO) 轮询 (Polling) 中断 (Interrupt) 简单 占CPU 无竞态 适合Bootloader早期 高效 复杂 有竞态 适合慢速外设 先轮询跑通 → 再中断优化

这张图把咱们这一章的内容串成了流水线:从寄存器映射开始,到CPU怎么访问外设,再到怎么处理外设事件。你写驱动的时候,脑子里就要有这根线。

2.7 避坑总结

最后,我把自己这些年踩过的坑,列成清单,你写代码时对照着看:

  • 寄存器地址别算错——结构体成员顺序必须和手册一致,保留位要占位。
  • volatile不能省——编译器优化会坑你,尤其是状态寄存器。
  • 内存屏障要加——乱序执行在Bootloader里一样存在。
  • 中断向量表要放对地方——DDR初始化前,中断向量表必须在SRAM里。
  • 中断标志要清干净——不清标志,ISR会反复触发,看起来像死循环。
  • 轮询超时要处理——万一外设挂了,轮询不能死等,加个超时退出。

嗯,这一章的内容就到这。寄存器映射、MMIO与PIO、轮询与中断,这三块是外设驱动的基石。你把这些搞透了,后面写具体外设的驱动,就是套模板的事。