4. SPI驱动移植:配置SPI四线/三线模式,实现ToF模组高速数据流读取

好,咱们今天聊点硬核的——SPI驱动移植。说实话,很多做ToF的工程师,最后都卡在这一步。不是不会配SPI,而是不知道怎么把数据“又快又稳”地读出来。

我个人习惯把SPI配置分成两个层面:物理层数据流层。物理层管的是几根线、什么模式;数据流层管的是怎么把数据从模组搬到内存里。咱们一个一个说。

4.1 四线模式 vs 三线模式:到底选哪个?

先看个表格,一目了然:

特性 四线模式(标准SPI) 三线模式(半双工)
信号线 SCLK, MOSI, MISO, CS SCLK, SDIO(双向), CS
数据传输 全双工,同时收发 半双工,分时收发
典型场景 ToF帧数据读取(高速) 寄存器配置、直方图读取
引脚占用 4根 3根
速率上限 通常更高(可达50MHz+) 受限于方向切换开销

你可能会问:“那我直接用四线不就行了?”嗯,这里要注意——有些ToF模组(比如VL53L1系列)在读取直方图时,硬件上只支持三线模式。我刚开始做的时候没仔细看手册,硬用四线去读,结果数据全是乱的。后来才发现,模组的MISO引脚在直方图模式下根本不出数据。

避坑指南: 我曾经在某个项目中,因为三线模式的SDIO方向切换时序没处理好,导致每帧数据都丢一个字节。排查了整整两天,最后用逻辑分析仪才抓到问题——方向切换后的第一个SCLK时钟沿,SDIO还没稳定下来。解决方案是在切换方向后插入一个空时钟周期。

4.2 配置实战:以STM32为例

咱们直接上代码。这是我在实际项目中用过的配置,针对的是ToF模组的帧数据读取(四线模式,DMA传输):

// SPI配置结构体
SPI_HandleTypeDef hspi1;

void MX_SPI1_Init(void)
{
    hspi1.Instance = SPI1;
    hspi1.Init.Mode = SPI_MODE_MASTER;          // 主机模式
    hspi1.Init.Direction = SPI_DIRECTION_2LINES; // 四线全双工
    hspi1.Init.DataSize = SPI_DATASIZE_8BIT;     // 8位数据
    hspi1.Init.CLKPolarity = SPI_POLARITY_LOW;   // 时钟空闲低电平
    hspi1.Init.CLKPhase = SPI_PHASE_1EDGE;       // 第一个边沿采样
    hspi1.Init.NSS = SPI_NSS_SOFT;               // 软件控制CS
    hspi1.Init.BaudRatePrescaler = SPI_BAUDRATEPRESCALER_2; // 42MHz(主频84MHz)
    hspi1.Init.FirstBit = SPI_FIRSTBIT_MSB;      // 高位先行
    hspi1.Init.TIMode = SPI_TIMODE_DISABLE;
    hspi1.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
    hspi1.Init.CRCPolynomial = 10;
    
    if (HAL_SPI_Init(&hspi1) != HAL_OK)
    {
        Error_Handler();
    }
}

这里有个细节:时钟极性和相位。不同的ToF模组要求不一样。我见过最坑的是某款模组,手册上写的是Mode 0,但实际测试必须用Mode 3才能稳定通信。所以我的建议是——别完全信手册,用逻辑分析仪抓一下实际波形,确认数据在正确的时钟边沿被采样。

4.3 DMA双缓冲:让数据飞起来

为什么要用DMA双缓冲?说白了,就是不让CPU在搬数据这件事上浪费时间。ToF模组一帧数据可能有几万个字节,如果用CPU轮询去读,那其他任务基本就别想跑了。

双缓冲的核心思想是:一个缓冲区在给DMA填数据,另一个缓冲区在给应用程序处理数据。两个角色轮流切换,没有空档期。

关键配置: 在STM32上,DMA双缓冲需要用到DMA的循环模式(Circular Mode)和双缓冲区功能。注意,不是所有DMA通道都支持双缓冲,选型时要看仔细。
// DMA双缓冲配置示例
#define TOF_FRAME_SIZE    1024  // 每帧数据大小(字节)
#define TOF_BUFFER_NUM    2     // 双缓冲

uint8_t tof_rx_buffer[TOF_BUFFER_NUM][TOF_FRAME_SIZE];
volatile uint8_t active_buffer = 0;

void DMA_Init_DoubleBuffer(void)
{
    // 配置DMA通道,使用循环模式
    hdma_spi1_rx.Instance = DMA2_Stream0;
    hdma_spi1_rx.Init.Channel = DMA_CHANNEL_3;
    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_CIRCULAR;  // 循环模式,关键!
    hdma_spi1_rx.Init.Priority = DMA_PRIORITY_HIGH;
    hdma_spi1_rx.Init.FIFOMode = DMA_FIFOMODE_DISABLE;
    
    // 设置双缓冲区地址
    HAL_DMAEx_ChangeMemory(&hdma_spi1_rx, 
                           (uint32_t)tof_rx_buffer[0],  // 缓冲区0
                           (uint32_t)tof_rx_buffer[1],  // 缓冲区1
                           DMA_MEMORY_0);
    
    // 关联到SPI
    __HAL_LINKDMA(&hspi1, hdmarx, hdma_spi1_rx);
    
    // 启动DMA传输
    HAL_SPI_Receive_DMA(&hspi1, tof_rx_buffer[0], TOF_FRAME_SIZE);
}

你可能会问:“那怎么知道当前哪个缓冲区是满的?”答案是:利用DMA的中断回调。当DMA传输完成一半(半传输中断)和全部完成(传输完成中断)时,分别切换缓冲区索引。

// DMA中断回调
void HAL_SPI_RxHalfCpltCallback(SPI_HandleTypeDef *hspi)
{
    if (hspi->Instance == SPI1)
    {
        // 缓冲区0已满,可以处理
        active_buffer = 0;
        process_tof_frame(tof_rx_buffer[0]);
    }
}

void HAL_SPI_RxCpltCallback(SPI_HandleTypeDef *hspi)
{
    if (hspi->Instance == SPI1)
    {
        // 缓冲区1已满,可以处理
        active_buffer = 1;
        process_tof_frame(tof_rx_buffer[1]);
    }
}
个人经验: 我在做一款工业级ToF传感器时,发现DMA中断频率太高(帧率30fps,每帧1024字节),导致CPU有30%的时间都在处理中断。后来我把中断优先级调低,并在回调里只设置一个标志位,真正的数据处理放在主循环里做。这样CPU占用率降到了5%以下。

4.4 中断优先级:别让SPI被“饿死”

中断优先级这件事,说大不大,说小不小。但一旦出问题,就是那种“偶尔丢一帧数据”的玄学bug,特别难查。

我的原则是:SPI的DMA中断优先级,要高于普通外设中断,但低于系统滴答定时器。为什么?因为SPI数据流是实时的,如果被其他中断长时间阻塞,DMA的FIFO可能会溢出,导致数据丢失。

以NVIC配置为例:

// 中断优先级分组:抢占优先级2位,子优先级2位
HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2);

// SPI1 DMA中断:抢占优先级1,子优先级0
HAL_NVIC_SetPriority(DMA2_Stream0_IRQn, 1, 0);
HAL_NVIC_EnableIRQ(DMA2_Stream0_IRQn);

// 其他外设中断:抢占优先级2,子优先级0
HAL_NVIC_SetPriority(USART1_IRQn, 2, 0);
HAL_NVIC_EnableIRQ(USART1_IRQn);

这里有个容易忽略的点:同一个DMA Stream的不同中断标志位(半传输、传输完成、传输错误)共享同一个中断优先级。所以你在中断服务函数里要判断到底是哪个事件触发了中断,别搞混了。

我曾经踩过的坑: 有一次,我在SPI DMA中断里直接调用了printf打印调试信息。结果printf本身又依赖UART中断,而UART中断优先级比SPI DMA低。这就导致了死锁——SPI DMA中断等UART中断释放资源,UART中断又因为优先级低被SPI DMA中断阻塞。最后系统直接卡死。解决方案:中断服务函数里绝对不要做耗时操作,只设标志位。

4.5 知识体系总览

说了这么多,咱们用一张图把整个SPI驱动移植的知识体系串起来:

SPI驱动移植知识体系 物理层配置 四线模式(全双工) 三线模式(半双工) 时钟极性与相位 数据流层设计 DMA单缓冲 DMA双缓冲(推荐) 中断回调处理 系统集成与优化 中断优先级配置 帧数据/直方图解析 性能调优与调试

从这张图可以看出来,SPI驱动移植不是孤立的——它从物理层配置开始,到数据流层设计,最后落到系统集成与优化。每一层都依赖上一层做对,才能保证最终的数据流稳定可靠。

好了,这一章的内容就到这里。记住:SPI配置只是开始,真正考验功力的是怎么把数据“无损、实时、高效”地搬进你的系统里。下一章咱们聊聊直方图数据的解析算法,那个更有意思。


公众号:蓝海资料掘金营,微信deep3321