3、SPI控制器驱动实现:DMA传输模式配置、PIO模式与中断处理、时钟极性与相位配置、片选信号管理策略

各位好,欢迎来到第三章。这一章我们直接切入SPI驱动的核心——控制器端的实现。说实话,很多工程师写SPI驱动,能调通PIO模式就觉得完事了。但做安全元件驱动,光通是不够的,你得考虑效率、可靠性、还有各种边界情况。我个人习惯把SPI控制器的驱动拆成四个模块来搞:时钟极性相位、片选管理、PIO中断、DMA传输。咱们一个一个来啃。

核心要点:SPI驱动不是简单的读写函数堆砌。时钟极性和相位错了,数据全乱;片选没管好,从机可能死锁;中断处理慢了,CPU被拖死;DMA配置错了,内存可能被踩。每一个点都是坑。

3.1 时钟极性与相位配置

SPI的时钟极性和相位,说白了就是决定数据在时钟的哪个边沿采样、空闲时时钟线是高还是低。这个配置必须和从机完全一致,否则通信就是鸡同鸭讲。

我遇到过最典型的案例:某款安全芯片的datasheet写着“Mode 0”,结果实际测试发现它工作在Mode 3。后来查勘误表才发现是文档写错了。所以啊,不要完全相信文档,要用示波器去抓波形确认。

时钟极性(CPOL)和时钟相位(CPHA)的组合,对应四种模式:

SPI模式 CPOL CPHA 空闲时钟电平 数据采样边沿
Mode 0 0 0 低电平 第一个跳变沿(上升沿)
Mode 1 0 1 低电平 第二个跳变沿(下降沿)
Mode 2 1 0 高电平 第一个跳变沿(下降沿)
Mode 3 1 1 高电平 第二个跳变沿(上升沿)

配置代码其实很简单,就是操作控制寄存器里的几个位。以常见的STM32为例:

// SPI配置结构体
SPI_HandleTypeDef hspi1;

void HAL_SPI_MspInit(SPI_HandleTypeDef* hspi)
{
    // ... GPIO和时钟使能代码省略 ...
    
    hspi1.Instance = SPI1;
    hspi1.Init.Mode = SPI_MODE_MASTER;
    hspi1.Init.Direction = SPI_DIRECTION_2LINES;
    hspi1.Init.DataSize = SPI_DATASIZE_8BIT;
    hspi1.Init.CLKPolarity = SPI_POLARITY_LOW;   // CPOL = 0
    hspi1.Init.CLKPhase = SPI_PHASE_1EDGE;       // CPHA = 0
    hspi1.Init.NSS = SPI_NSS_SOFT;               // 软件片选
    hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_8;
    hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;
    hspi1.Init.TIMode = SPI_TIMODE_DISABLE;
    hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
    hspi1.Init.CRCPolynomial = 10;
    
    HAL_SPI_Init(&hspi1);
}

我的经验:配置时钟极性时,建议先配成Mode 0或Mode 3。这两种模式在空闲时时钟电平稳定,不容易产生毛刺。Mode 1和Mode 2在切换时可能会有额外的跳变,对安全元件这种敏感设备不太友好。

3.2 片选信号管理策略

片选信号,也就是CS(Chip Select),是SPI通信的“门禁”。拉低CS,从机开始监听;拉高CS,通信结束。听起来简单,但这里面的门道不少。

安全元件对片选信号特别敏感。我曾经调试过一个案子,安全芯片时不时就返回错误数据。查了两天才发现,是片选信号在两次通信之间没有完全释放,导致芯片状态机混乱。

片选管理有几个关键点:

  • 片选释放时间:两次通信之间,CS必须保持高电平至少几个微秒,让从机完成内部状态复位。这个时间在安全芯片的datasheet里通常叫“CS de-assertion time”或“CS high time”。
  • 软件片选 vs 硬件片选:我强烈建议用软件片选(NSS soft mode)。硬件自动片选虽然省事,但它在多字节传输时的行为可能不符合安全元件的要求。软件片选让你完全控制CS的拉低和拉高时机。
  • 片选与时钟的时序:CS拉低后,要等一小段时间再开始发时钟,这叫“CS setup time”。同样,最后一个时钟结束后,也要等一小段时间再拉高CS,这叫“CS hold time”。
// 软件片选控制示例
#define CS_PORT   GPIOA
#define CS_PIN    GPIO_PIN_4

void spi_cs_low(void)
{
    HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_RESET);
    // 等待CS setup time,通常1-5us
    delay_us(2);
}

void spi_cs_high(void)
{
    // 等待最后一个字节传输完成
    while(!(SPI1->SR & SPI_SR_TXE));
    while(SPI1->SR & SPI_SR_BSY);
    
    // 等待CS hold time
    delay_us(1);
    HAL_GPIO_WritePin(CS_PORT, CS_PIN, GPIO_PIN_SET);
    
    // 等待CS de-assertion time
    delay_us(5);
}

注意:不要在中断服务函数里直接操作片选。中断上下文可能打断正在进行的SPI传输,导致片选时序错乱。片选操作应该放在任务级代码中,或者用信号量保护起来。

3.3 PIO模式与中断处理

PIO模式,就是CPU亲自下场搬数据。每发一个字节,CPU都要等发送寄存器空,再写下一个字节;每收一个字节,CPU都要等接收寄存器非空,再读出来。这种方式简单直接,但效率低。

不过,对于安全元件驱动来说,PIO模式有它不可替代的优势:确定性。DMA传输时,CPU和DMA控制器是并行的,你很难精确知道某个字节是在哪个时刻被发送出去的。而PIO模式下,每一条指令的执行时间都是确定的,这对于需要精确时序控制的场景非常有用。

中断处理是PIO模式的进阶。用中断代替轮询,CPU就不用傻等了。但要注意,中断频率不能太高。SPI时钟如果是10MHz,每1微秒就产生一次中断,CPU会被活活拖死。

我一般这样设计中断处理:

  • 发送中断:当发送缓冲区空时触发。在中断里从发送队列取下一个字节写入DR寄存器。
  • 接收中断:当接收缓冲区非空时触发。在中断里读取DR寄存器,存入接收队列。
  • 错误中断:溢出、模式错误、CRC错误等。这个必须开,安全元件通信不能容忍静默错误。
// SPI中断服务函数示例
void SPI1_IRQHandler(void)
{
    uint32_t sr = SPI1->SR;
    
    // 处理错误
    if(sr & (SPI_SR_OVR | SPI_SR_MODF | SPI_SR_CRCERR))
    {
        spi_error_handler(sr);
        SPI1->SR &= ~(SPI_SR_OVR | SPI_SR_MODF | SPI_SR_CRCERR);
        return;
    }
    
    // 发送中断
    if((sr & SPI_SR_TXE) && (spi_tx_count < spi_tx_total))
    {
        SPI1->DR = spi_tx_buffer[spi_tx_count++];
    }
    
    // 接收中断
    if(sr & SPI_SR_RXNE)
    {
        spi_rx_buffer[spi_rx_count++] = SPI1->DR;
    }
    
    // 传输完成判断
    if(spi_rx_count == spi_tx_total)
    {
        spi_transfer_complete = 1;
        // 关闭中断,避免频繁触发
        SPI1->CR2 &= ~(SPI_CR2_TXEIE | SPI_CR2_RXNEIE);
    }
}

避坑指南:我曾经在中断里忘记清除中断标志位,结果程序一直卡在中断里出不来。后来养成习惯,每次进中断先读状态寄存器,处理完再写回去清除标志。另外,中断服务函数要尽量短,复杂的逻辑放到任务里去做。

3.4 DMA传输模式配置

DMA传输是SPI驱动的终极形态。CPU只需要告诉DMA控制器:“从内存地址A搬N个字节到SPI的DR寄存器”,然后就可以去干别的事了。传输完成后,DMA会发一个中断通知CPU。

配置DMA传输时,有几个关键参数必须搞对:

参数 说明 推荐配置
传输方向 外设到内存、内存到外设、内存到内存 SPI发送:内存到外设;SPI接收:外设到内存
数据宽度 字节、半字、字 与SPI数据位宽一致,通常为字节
地址增量 每次传输后地址是否自增 内存地址增量,外设地址固定
循环模式 是否自动循环传输 安全元件通信一般不用循环模式
优先级 DMA通道优先级 建议设为高,避免被其他DMA打断

DMA传输的代码配置:

// DMA发送配置
void spi_dma_tx_config(uint8_t *data, uint16_t len)
{
    // 禁用DMA通道
    __HAL_DMA_DISABLE(&hdma_spi1_tx);
    
    // 配置DMA
    hdma_spi1_tx.Init.Direction = DMA_MEMORY_TO_PERIPH;
    hdma_spi1_tx.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_spi1_tx.Init.MemInc = DMA_MINC_ENABLE;
    hdma_spi1_tx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
    hdma_spi1_tx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
    hdma_spi1_tx.Init.Mode = DMA_NORMAL;
    hdma_spi1_tx.Init.Priority = DMA_PRIORITY_HIGH;
    
    HAL_DMA_Init(&hdma_spi1_tx);
    
    // 启动DMA传输
    HAL_DMA_Start(&hdma_spi1_tx, (uint32_t)data, (uint32_t)&SPI1->DR, len);
    
    // 使能SPI的DMA发送请求
    SPI1->CR2 |= SPI_CR2_TXDMAEN;
}

// DMA接收配置
void spi_dma_rx_config(uint8_t *buffer, uint16_t len)
{
    __HAL_DMA_DISABLE(&hdma_spi1_rx);
    
    hdma_spi1_rx.Init.Direction = DMA_PERIPH_TO_MEMORY;
    hdma_spi1_rx.Init.PeriphInc = DMA_PINC_DISABLE;
    hdma_spi1_rx.Init.MemInc = DMA_MINC_ENABLE;
    hdma_spi1_rx.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
    hdma_spi1_rx.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
    hdma_spi1_rx.Init.Mode = DMA_NORMAL;
    hdma_spi1_rx.Init.Priority = DMA_PRIORITY_HIGH;
    
    HAL_DMA_Init(&hdma_spi1_rx);
    
    HAL_DMA_Start(&hdma_spi1_rx, (uint32_t)&SPI1->DR, (uint32_t)buffer, len);
    
    SPI1->CR2 |= SPI_CR2_RXDMAEN;
}

重要提醒:DMA传输完成后,一定要检查传输状态。我遇到过DMA因为总线仲裁问题少传了一个字节,结果安全芯片返回的数据全部错位。建议在DMA完成中断里检查剩余数据量寄存器(CNDTR),确认传输长度正确。

3.5 四种模式的对比与选择

说了这么多,到底什么时候用PIO,什么时候用DMA?我根据自己的经验整理了一个对比表:

特性 PIO轮询 PIO中断 DMA
CPU占用率 高(100%占用) 中(每次传输约1-2us) 低(仅启动和完成时)
传输速度 受CPU频率限制 受中断响应时间限制 接近SPI总线极限
代码复杂度 低 中 高
时序确定性 高 中 低
适用场景 调试、小数据量 中等数据量、实时性要求高 大数据量、低CPU占用

我个人建议:安全元件驱动以PIO中断模式为主,DMA为辅。安全元件的通信数据量通常不大(几百字节),PIO中断完全够用,而且时序可控。只有在需要批量传输固件或大块数据时,才切换到DMA模式。

下面这张图展示了SPI控制器驱动的整体架构和四种模式的切换逻辑:

SPI控制器驱动架构与传输模式选择 应用层(安全元件协议栈) SPI驱动接口层(spi_transfer/spi_write/spi_read) PIO轮询模式 PIO中断模式 DMA模式 轮询SR寄存器 读写DR寄存器 配置中断使能 ISR中操作DR 配置DMA通道 启动传输 SPI外设硬件(SCK/MOSI/MISO/CS) 选择依据: 数据量 < 16字节 → PIO轮询 16~256字节 → PIO中断 > 256字节 → DMA

嗯,这张图把整个驱动架构串起来了。从应用层到底层硬件,中间经过接口层、模式选择、寄存器操作。你写驱动的时候,脑子里要有这张图,知道当前代码处于哪个层级。

最后说一句,不要为了用DMA而用DMA。我见过有人给一个8字节的APDU命令配了DMA,结果DMA初始化代码比传输本身还耗时。合适的才是最好的。