2、嵌入式系统基础:处理器架构、内存映射、外设寄存器、中断与DMA的基本概念
嵌入式设备驱动开发的本质,是让软件能够正确、高效地控制硬件资源。在编写任何一行驱动代码之前,开发者必须深刻理解处理器如何“看待”这个世界:CPU核心通过何种路径访问外设?内存地址空间如何布局?外设寄存器与普通内存有何区别?中断与DMA如何协同工作以提升系统实时性?本章将围绕这四个核心主题展开,为后续的驱动框架设计、设备树编写和底层操作函数实现奠定坚实的理论基础。
2.1 处理器架构与驱动开发的关联
核心原理:指令集与编程模型
嵌入式处理器架构决定了驱动代码的编译方式、内存访问规则和中断响应机制。目前主流架构包括 ARM Cortex-A(应用处理器)、Cortex-R(实时处理器)、Cortex-M(微控制器)以及 RISC-V。从驱动开发视角看,最关键的差异在于:内存管理单元(MMU)的有无、中断延迟的确定性、以及寄存器访问的指令开销。Cortex-A 系列通常运行 Linux,依赖 MMU 实现虚拟地址映射;Cortex-M 系列则直接访问物理地址,中断延迟可低至 12 个时钟周期。
驱动开发者的核心关注点:
- 字节序(Endianness):ARM 支持大小端切换,但外设寄存器通常固定为小端模式,驱动中需使用
readl/writel等访问宏保证一致性。 - 内存屏障(Memory Barrier):CPU 可能重排内存访问指令,驱动中操作 DMA 描述符或外设寄存器后必须插入
dmb()或dsb()指令。 - 缓存一致性:DMA 访问的内存区域需要标记为
DMA_ATTR_NON_CONSISTENT或使用dma_alloc_coherent分配一致性内存。
判断方法:选择正确的访问模型
在编写驱动前,需要判断目标外设属于哪种访问类型:
- PIO(Programmed I/O)模式:CPU 直接读写寄存器,适用于低速外设(如 GPIO、UART 配置寄存器)。
- MMIO(Memory-Mapped I/O)模式:外设寄存器被映射到内存地址空间,使用普通内存访问指令操作,这是绝大多数 SoC 采用的方式。
- Port I/O 模式:x86 架构特有,使用
in/out指令,在嵌入式 Linux 中已较少使用。
案例拆解:Cortex-A9 与 Cortex-M7 的驱动差异
假设需要操作同一个 UART IP 核,在 Cortex-A9(Linux 环境)下,驱动通过 ioremap 将物理地址映射为虚拟地址,再使用 writel 写入寄存器;而在 Cortex-M7(裸机或 RTOS 环境)下,直接使用 volatile 指针访问物理地址。两者的核心区别在于:A9 需要处理页表映射和缓存一致性,而 M7 则无需考虑 MMU,但需要手动管理编译器优化带来的乱序访问问题。
常见误区:初学者容易在 Cortex-M 上使用 volatile 关键字后便认为万事大吉,但 volatile 只能防止编译器优化,无法阻止 CPU 硬件乱序执行。在涉及 DMA 或双核通信时,必须使用内存屏障指令。
2.2 内存映射与地址空间布局
核心原理:物理地址与虚拟地址
现代嵌入式 SoC 内部集成了多种存储器和外设,它们被统一编址到一张物理地址映射表中。这张表由芯片厂商在参考手册中给出,是驱动开发最基础的参考资料。以典型的 32 位处理器为例,地址空间通常划分为:DDR 区间、片上 SRAM、外设寄存器区间、系统控制区间。驱动开发者必须清楚每个外设的基地址和寄存器偏移,才能正确构造访问地址。
| 地址区间 | 大小 | 用途 | 访问属性 |
|---|---|---|---|
| 0x0000_0000 - 0x0FFF_FFFF | 256MB | DDR 内存 | 可缓存、可写回 |
| 0x1000_0000 - 0x1FFF_FFFF | 256MB | 外设 MMIO | 不可缓存、设备内存 |
| 0x2000_0000 - 0x2FFF_FFFF | 256MB | 片上 SRAM | 可缓存、可写通 |
| 0xE000_0000 - 0xFFFF_FFFF | 512MB | 系统控制/调试 | 不可缓存、强序 |
操作流程:从物理地址到虚拟地址的映射
在 Linux 驱动中,访问外设寄存器必须遵循以下流程:
// 1. 获取外设物理基地址(来自设备树或平台数据)
#define UART_BASE 0x10000000
#define UART_DATA 0x00
#define UART_STATUS 0x04
// 2. 建立虚拟地址映射
void __iomem *base = ioremap(UART_BASE, SZ_4K);
if (!base) {
pr_err("ioremap failed\n");
return -ENOMEM;
}
// 3. 通过访问宏操作寄存器
u32 status = readl(base + UART_STATUS);
writel(0x01, base + UART_DATA);
// 4. 驱动卸载时释放映射
iounmap(base);
实战场景:设备树中的内存映射描述
设备树(Device Tree)是描述硬件资源的标准方式。以下是一个典型 UART 节点的内存映射描述:
uart0: serial@10000000 {
compatible = "vendor,uart-ip";
reg = <0x10000000 0x1000>; // 基地址 + 地址长度
interrupts = <0 33 4>; // 中断控制器类型、中断号、触发方式
clocks = <&uart_clk>;
status = "okay";
};
驱动通过 platform_get_resource 获取 reg 属性中的地址信息,再调用 devm_ioremap_resource 完成映射。这种方式避免了硬编码地址,提高了代码的可移植性。
经验提醒:在裸机开发中,建议将外设基地址定义为宏,并统一放在 memory_map.h 头文件中。同时,为每个外设建立独立的 struct 描述寄存器布局,使用 offsetof 宏计算偏移,避免硬编码偏移值。
2.3 外设寄存器访问机制
核心原理:寄存器位域与读写属性
外设寄存器是硬件状态机的“控制面板”,每个寄存器由若干位域(Bit Field)组成,分别控制不同的功能。寄存器按读写属性可分为:只读(RO)、只写(WO)、读写(RW)、写1清0(W1C)、写1置1(W1S)。其中 W1C 类型最易出错——向该位写 1 会清除中断标志,写 0 无效。驱动开发者必须严格遵循数据手册中的位定义,否则可能导致硬件状态异常。
判断方法:如何安全地修改寄存器位域
修改寄存器位域时,必须采用“读-改-写”序列,避免影响其他位:
// 错误示例:直接赋值会覆盖其他位
writel(0x01, base + UART_CTRL);
// 正确示例:读-改-写
u32 ctrl = readl(base + UART_CTRL);
ctrl |= (1 << 3); // 设置 bit3
ctrl &= ~(0x3 << 4); // 清除 bit4-5
writel(ctrl, base + UART_CTRL);
案例拆解:UART 状态寄存器轮询
以 16550 UART 的 LSR(Line Status Register)为例,bit5 为发送保持寄存器空标志(THRE),bit0 为接收数据就绪标志(DR)。驱动发送数据时,需要轮询 THRE 位;接收数据时,需要检查 DR 位。若使用中断方式,则需在 ISR 中读取 IIR(中断标识寄存器)判断中断源,并读取 LSR 清除错误状态。
风险提示:某些寄存器(如中断状态寄存器)在读取后会自动清除标志位。如果驱动在读取后未及时处理数据,可能导致中断丢失。务必参考数据手册中的“读副作用”说明。
2.4 中断与DMA:高效数据通路
核心原理:中断优先级与嵌套
中断机制允许外设在事件发生时主动通知 CPU,避免轮询浪费 CPU 周期。嵌入式系统中,中断控制器(如 GIC、NVIC)负责管理多个中断源的优先级、使能状态和挂起状态。驱动开发者需要理解:中断上下文与进程上下文的区别——中断处理函数不能睡眠,不能调用可能阻塞的函数(如 mutex_lock、kmalloc(GFP_KERNEL))。
操作流程:中断驱动的标准注册流程
// 1. 申请中断号(来自设备树)
int irq = platform_get_irq(pdev, 0);
if (irq < 0) return irq;
// 2. 注册中断处理函数
request_irq(irq, uart_isr,
IRQF_TRIGGER_RISING,
"my_uart", dev);
// 3. 中断处理函数(上半部)
static irqreturn_t uart_isr(int irq, void *data) {
struct uart_dev *dev = data;
u32 status = readl(dev->base + UART_STATUS);
if (status & RX_READY) {
// 读取数据到缓冲区
dev->rx_buf[dev->rx_cnt++] = readl(dev->base + UART_DATA);
}
// 清除中断标志(W1C)
writel(status & RX_READY, dev->base + UART_STATUS);
return IRQ_HANDLED;
}
DMA 工作原理与驱动集成
DMA(Direct Memory Access)控制器可以在无需 CPU 干预的情况下,在外设 FIFO 与内存之间搬运数据。驱动开发中,DMA 的使用分为三步:分配 DMA 缓冲区、配置 DMA 描述符、启动传输并等待完成中断。以 Linux 内核的 dmaengine 框架为例:
// 1. 获取 DMA 通道
struct dma_chan *chan = dma_request_chan(dev, "rx");
// 2. 准备传输描述符
struct dma_async_tx_descriptor *desc;
desc = dmaengine_prep_slave_single(chan, buf_phys,
len, DMA_DEV_TO_MEM,
DMA_PREP_INTERRUPT);
// 3. 注册回调并提交
desc->callback = dma_rx_callback;
dmaengine_submit(desc);
dma_async_issue_pending(chan);
案例拆解:高速 ADC 数据采集系统
某工业数据采集系统使用 ADC 以 1MSPS 采样率连续采集模拟信号,每次采样产生 16 位数据。若采用中断方式,CPU 每秒需处理 100 万次中断,几乎耗尽全部性能。改用 DMA 后,ADC 每次采样完成将数据直接写入内存环形缓冲区,每满 1024 个样本触发一次 DMA 完成中断,CPU 只需每秒处理约 1000 次中断,负载降低 99.9%。
| 指标 | 中断模式 | DMA 模式 |
|---|---|---|
| CPU 中断频率 | 1 MHz | ~1 kHz |
| CPU 占用率 | 85% | 5% |
| 数据丢失风险 | 高(中断延迟抖动) | 低(硬件搬运) |
| 实现复杂度 | 低 | 中 |
复盘经验:DMA 描述符链(Descriptor Chain)支持连续传输多个不连续的内存块,适用于网络包或音频帧的分散-聚集 I/O。但描述符本身必须存放在一致性内存中,且更新描述符后需要执行内存屏障,确保 DMA 控制器看到最新数据。
2.5 常见误区与设计复盘
误区一:忽视缓存一致性
当 DMA 写入内存后,CPU 读取该内存时可能命中 Cache 中的旧数据。解决方法是使用 dma_alloc_coherent 分配一致性内存,或在每次 DMA 传输前后调用 dma_map_single 配合 dma_sync_single_for_cpu 进行同步。
误区二:中断处理函数中执行耗时操作
中断处理函数应遵循“快进快出”原则,仅完成数据读取和标志清除,耗时操作(如协议解析、数据拷贝)应放到工作队列或 tasklet 中执行。否则会阻塞其他中断,导致系统实时性下降。
误区三:寄存器地址硬编码
在多平台复用或 SoC 版本升级时,硬编码地址会导致驱动无法移植。应始终通过设备树或平台数据获取地址信息,并使用 devm_ioremap_resource 进行资源管理。
设计复盘清单:
- 是否明确了外设寄存器的读写属性和位域含义?
- 是否考虑了 MMU 和 Cache 对 DMA 传输的影响?
- 中断处理函数是否足够短小?是否需要下半部机制?
- DMA 描述符是否存放在一致性内存中?是否执行了内存屏障?
- 地址信息是否来自设备树而非硬编码?
掌握处理器架构、内存映射、寄存器访问和中断/DMA 机制,是驱动开发的基本功。本章内容为后续的设备树解析、字符设备驱动编写、中断子系统集成和 DMA 引擎使用打下了坚实基础。在实际项目中,开发者应始终以芯片参考手册为最终依据,结合内核框架的抽象接口,编写出健壮、可移植的驱动程序。