4、硬件抽象层(HAL)设计:寄存器映射、外设驱动封装、DMA与中断管理、内存映射文件

好,咱们进入第四章。这一章我打算聊聊硬件抽象层,也就是HAL。说实话,在CT和MRI这种设备里,HAL设计得好不好,直接决定了你后续开发是「如鱼得水」还是「如履薄冰」。我见过太多项目,因为HAL层没做好,最后软件改得面目全非。

说白了,HAL就是给上层软件一个「干净」的接口。底层是TI的DSP还是Xilinx的FPGA,上层不用管。你想想看,CT的探测器控制板和MRI的梯度控制板,用的芯片可能完全不同,但上层调度软件看它们,应该长得差不多。

4.1 寄存器映射:别让地址满天飞

寄存器映射,这是HAL最基础的一层。我个人的习惯是,所有硬件寄存器地址,必须集中定义,绝不允许在驱动代码里出现「0x40021000」这种魔法数字。

在C语言里,我通常这样干:

/* 寄存器基地址定义 */
#define UART1_BASE_ADDR     0x40011000
#define UART2_BASE_ADDR     0x40012000

/* 寄存器偏移定义 */
#define UART_SR_OFFSET      0x00    /* 状态寄存器 */
#define UART_DR_OFFSET      0x04    /* 数据寄存器 */
#define UART_CR1_OFFSET     0x0C    /* 控制寄存器1 */

/* 寄存器访问宏 */
#define REG32(addr)         (*(volatile uint32_t *)(addr))
#define UART_REG(base, off) REG32((base) + (off))

/* 使用示例 */
#define UART1_SR            UART_REG(UART1_BASE_ADDR, UART_SR_OFFSET)
#define UART1_DR            UART_REG(UART1_BASE_ADDR, UART_DR_OFFSET)

嗯,这里要注意一点:volatile关键字绝对不能少。我在项目中遇到过,有人忘了加volatile,编译器优化直接把寄存器读取给优化掉了,结果中断标志位永远读不到,系统死在那。排查了两天才找到原因。

警告:寄存器映射必须使用 volatile 修饰,防止编译器优化。另外,多核系统中要考虑内存屏障,否则一个核改了寄存器,另一个核读到的可能是旧值。

4.2 外设驱动封装:接口要稳定

外设驱动封装,说白了就是把寄存器的操作「包起来」。上层调用的不是UART1_DR = data,而是uart_send_byte(handle, data)

我建议每个外设驱动都提供一个结构体,把所有操作函数指针放进去:

/* UART驱动接口结构体 */
typedef struct {
    void (*init)(void *handle, uint32_t baudrate);
    void (*send_byte)(void *handle, uint8_t data);
    uint8_t (*recv_byte)(void *handle);
    void (*send_dma)(void *handle, uint8_t *buf, uint32_t len);
    void (*enable_interrupt)(void *handle, uint32_t irq_type);
} uart_drv_t;

/* 实例化一个UART1驱动 */
uart_drv_t uart1_drv = {
    .init = uart1_init,
    .send_byte = uart1_send_byte,
    .recv_byte = uart1_recv_byte,
    .send_dma = uart1_send_dma,
    .enable_interrupt = uart1_enable_irq
};

这样做的好处是,上层代码只需要拿到这个结构体指针,就能操作任何UART。换芯片?重新实现一套函数,结构体赋值换一下就行。我在做MRI射频接收板时,就靠这个设计,把底层从STM32F4无缝迁移到了国产GD32,上层代码一行没改。

技巧:驱动接口设计要「最小化」。只暴露必要的功能,不要什么都往外抛。比如UART驱动,init、send、recv就够了,什么FIFO阈值、奇偶校验配置,能藏就藏。

4.3 DMA与中断管理:别让CPU累死

CT和MRI的数据量有多大?我举个例子,一个64排CT,每圈扫描产生上千兆字节的数据。如果全靠CPU搬运,CPU直接废掉。所以DMA是必须的。

DMA管理,我习惯设计一个DMA控制器抽象层:

/* DMA传输描述符 */
typedef struct {
    uint32_t src_addr;      /* 源地址 */
    uint32_t dst_addr;      /* 目的地址 */
    uint32_t transfer_size; /* 传输字节数 */
    uint8_t  src_inc;       /* 源地址递增模式 */
    uint8_t  dst_inc;       /* 目的地址递增模式 */
    void (*callback)(void); /* 传输完成回调 */
} dma_desc_t;

/* DMA通道操作接口 */
void dma_channel_init(uint8_t ch);
void dma_start_transfer(uint8_t ch, dma_desc_t *desc);
uint8_t dma_is_busy(uint8_t ch);
void dma_abort(uint8_t ch);

中断管理这块,我踩过不少坑。曾经有一次,CT扫描过程中突然死机,查了三天,发现是中断嵌套导致栈溢出。MRI的梯度控制中断优先级设得太高,把数据采集中断给堵死了。

我的建议是:

  • 中断优先级要分层:实时性要求高的(比如梯度控制、探测器触发)放最高优先级;数据搬运(DMA完成中断)放中等;调试接口(UART、USB)放最低。
  • 中断服务程序要短:ISR里只做标志位设置、数据入队,具体处理放到任务级。我曾经见过有人把FFT计算放在中断里,结果系统响应延迟直接爆炸。
  • 中断嵌套要控制:MRI系统里,我一般只允许两级嵌套。再多,栈空间就不好估算了。
核心原则:DMA负责数据搬运,CPU负责数据处理。中断只做「通知」,不做「处理」。这个原则在医学影像设备里尤其重要,因为数据量太大了。

4.4 内存映射文件:让数据共享更高效

内存映射文件,在嵌入式里通常指「共享内存」或者「内存映射IO」。CT和MRI系统里,多个处理器(DSP、ARM、FPGA)之间需要高速共享数据,比如重建图像、原始数据、控制参数。

我常用的做法是,在物理内存中划出一块区域,定义成结构体,然后所有处理器都能访问:

/* 共享内存区域定义,放在DDR的固定地址 */
#define SHARED_MEM_BASE     0xC0000000
#define SHARED_MEM_SIZE     0x00100000  /* 1MB */

/* 共享数据结构 */
typedef struct __attribute__((packed)) {
    uint32_t magic;             /* 魔数,用于校验 */
    uint32_t version;           /* 数据版本号 */
    uint32_t scan_state;        /* 扫描状态 */
    float    patient_pos;       /* 病人位置 */
    uint32_t raw_data_ready;    /* 原始数据就绪标志 */
    uint8_t  raw_data_buf[512 * 1024]; /* 原始数据缓冲区 */
    uint32_t image_ready;       /* 图像就绪标志 */
    uint8_t  image_buf[256 * 1024];    /* 图像缓冲区 */
} shared_mem_t;

/* 访问宏 */
#define SHARED_MEM  ((shared_mem_t *)SHARED_MEM_BASE)

这里有个关键点:缓存一致性问题。ARM核有L1 Cache,DSP也有自己的Cache。如果一个核写了共享内存,另一个核读到的可能是Cache里的旧数据。

我在项目中遇到过,ARM核把图像数据写到了共享内存,DSP去读,读到的全是0。折腾了半天,发现是ARM的Cache没刷。解决方案是:

  • 写共享内存后,主动调用Cache刷新函数(如ARM的SCB_CleanDCache_by_Addr
  • 读共享内存前,主动调用Cache无效化函数(如SCB_InvalidateDCache_by_Addr
  • 或者,干脆把共享内存区域配置为「非缓存」属性(在MMU里设置)
注意:多核访问共享内存时,除了缓存一致性问题,还要考虑原子操作。比如标志位的设置和清除,最好用LDREX/STREX指令,或者用硬件信号量。否则两个核同时写一个变量,数据就乱了。

嗯,说到内存映射,还有一个场景是FPGA和ARM之间的数据交换。FPGA把采集到的原始数据直接写到DDR的某个地址,ARM通过内存映射直接读取。这种方式比通过PCIe或者SPI快得多,延迟可以做到微秒级。CT的探测器数据采集,基本都是这么干的。

好了,这一章的内容就这些。寄存器映射是地基,外设驱动封装是框架,DMA和中断管理是效率,内存映射文件是数据通道。四者配合好了,HAL层才算真正立住了。下一章咱们聊聊任务调度,那可是CT和MRI系统的「大脑」。