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,编译器优化直接把寄存器读取给优化掉了,结果中断标志位永远读不到,系统死在那。排查了两天才找到原因。
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,上层代码一行没改。
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系统里,我一般只允许两级嵌套。再多,栈空间就不好估算了。
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里设置)
嗯,说到内存映射,还有一个场景是FPGA和ARM之间的数据交换。FPGA把采集到的原始数据直接写到DDR的某个地址,ARM通过内存映射直接读取。这种方式比通过PCIe或者SPI快得多,延迟可以做到微秒级。CT的探测器数据采集,基本都是这么干的。
好了,这一章的内容就这些。寄存器映射是地基,外设驱动封装是框架,DMA和中断管理是效率,内存映射文件是数据通道。四者配合好了,HAL层才算真正立住了。下一章咱们聊聊任务调度,那可是CT和MRI系统的「大脑」。