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控制器驱动的整体架构和四种模式的切换逻辑:
嗯,这张图把整个驱动架构串起来了。从应用层到底层硬件,中间经过接口层、模式选择、寄存器操作。你写驱动的时候,脑子里要有这张图,知道当前代码处于哪个层级。
最后说一句,不要为了用DMA而用DMA。我见过有人给一个8字节的APDU命令配了DMA,结果DMA初始化代码比传输本身还耗时。合适的才是最好的。