如何在STM32上实现硬件级串口屏的D/C切换
一、前言
对于目前市面上众多的液晶屏来讲,串口屏是个具有相当规模的存在,其中,采用QSPI/SPI的串口屏当中,又有相当数量具有D/C引脚,负责辅助屏幕控制器快速实施帧解析。传统手段是软件手动进行D/C切换,代码结构一般如下所示。
void ScreenFrameSend(void)
{
CS(0);
DC(0);
Spi_Send_cmd(xx);
DC(1);
Spi_Send_data(xx);
CS(1);
}
有些屏幕的控制器对于时序的要求可能会比较严格,而上述代码结构不可避免地引入时钟空闲,即Spi_Send_cmd 和 Spi_Send_data 是两次独立的发送,SPI总线时钟会在两次发送过程中出现空闲,这个间隙如果以可容纳的SPI时钟个数来衡量,在几个到几十个不等(空闲时钟数的多少,很大程度上取决于当时的SPI时钟速率,速率越高,空闲的时钟个数越大)。即便我们摒弃HAL库的冗长、效率低下的写法,直接用寄存器操作重写上述代码,还是不能从根本上消除这个间隙。本质上这是软件级别的响应,即使是将DC的切换放置于发送完成中断当中处理,也会由于入栈出栈而存在不可消除的时钟空闲。如果采用SPI的DMA发送,则会面临D/C切换的时机把控问题,切换时机不正确的话,屏幕端的帧解析就会失败,导致显示异常,而无论是软件手动切换还是中断响应切换,对于整帧DMA发送的场景都是极不可靠的。
基于此,我尝试探索实现SPI连续发送下的硬件级别的D/C切换的可靠方案。
MCU型号:STM32L496VGT6
开发环境:Keil5 MDK5.40 & AC5.06 update7 build 960
配置工具:STM32CubeMX v6.18
支持包版本:STM32CubeL4 Firmware Package V1.18.2 / 14-November-2025
二、方案论证
在STM32上,SPI接口均无配套的D/C切换引脚和状态机,要想实现硬件级别的响应,必须另寻出路。而定时器就是一个很好的硬件状态记录和翻转的工具,可以输入的同时,实施硬件输出响应。这里选用TIM2,让SPI时钟引入TIM2,作为其计数时钟,并根据计数产生比较输出,用于D/C输出。
三、实施细节
1. 定时器的配置
从完整的串口屏一般的SPI帧结构来看,首字节往往是指令字节,后续字节(如果存在的话)是参数字节或者是显存数据字节。D/C的切换要及时发生在首字节传输完成之后,次字节发送之前,如果以SPI时钟作为标尺,那么D/C切换时机就应该在8个SPI时钟周期之后,第9个时钟周期完成之前。
对于TIM2来说,它的时钟源可以是内部时钟,也可以是来自MCU片外时钟,它的TIM2_ETR引脚就被用来引入外部时钟,当然了,TIM2_CHx的TI1FP1/TI2FP2也可以引入外部时钟,且经常被用来测量PWM脉宽,但是经过实际测试发现,如果从TIM2_CHx引入SPI时钟,则会出现D/C翻转时机延迟现象,即D/C的实际翻转时机要延迟于理论时机,延迟的时钟周期数根据SPI时钟速率大小和编译器优化等级不同而不同,我经过小范围测试,得出了一个表格,如下所示。
|
TIM计数上限值 |
4(0~4) |
5(0~5) |
6(0~6) |
7(0~7) |
|
o-0 |
7.5周期 |
8.5周期 |
9.5周期 |
10.5周期 |
|
o-1 |
6.8周期 |
7.8周期 |
8.8周期 |
9.8周期 |
|
o-2 |
6.7周期 |
7.7周期 |
8.7周期 |
9.7周期 |
|
o-3 |
7周期 |
8周期 |
9周期 |
9.9周期 |
可以看出,通过TIMx_CH的TI1FP1/TI2FP2引入时钟计数是会存在较为严重的延迟,而且上述表格内的结果还是在80Mhz主频的64分频即1.25Mhz的SPI时钟下得到的,事实上如果将SPI主频拉高至32分频即2.5Mhz及以上时,延迟会增大至4个SPI时钟以上,这是完全不可接受的。至于延迟产生的原因,豆包给出的解答如下:
“STM32L4 TIM 外部时钟模式 1,TIxFP 信号进入从模式同步逻辑存在同步锁存延迟:计数到达 Pulse 匹配值不会立刻翻转 OC,要再等 1~3 个外部脉冲采样同步,不是代码 / 编译器问题,是 TIM 从模式硬件同步链固有流水线延迟。”
这里有限于我的水平,我给不出明确准确解答也无法对豆包的说法给出看法。大神们如果有不同见解,希望留言讨论,在此感谢先!
排除掉TIMx_CH的TI1FP1/TI2FP2输入时钟方案,对于定时器就还有一个途径 -- TIMx_ETR。这也是一个被设计用于定时器输入外部时钟或者触发信号的途径。TIM2的ETR有两种模式,分别为ETR模式1和ETR模式2。这两种模式的流程框图分别如下(原谅我偷懒,从RM0432中文版上截取,并非RM0351)


在这里选用ETR模式2来引入SPI时钟。由于是原理验证,滤波参数就不做设置了,在实际工程中,滤波参数的调教还需根据具体情况具体操作。分频系数我们也不设置,即不分频。以下是STM32CubeMX的配置界面:

这里有几个小问题要说明,首先是红框内,你一定看到的是0xFFFFFFFF对应的十进制数,但事实上这里的配置项能写入的值最大是0xFFFF。这个值是被写入TIMx_ARR寄存器的,而TIM2_ARR寄存器到底是高16位有效,还是全32位有效,这一点在HAL源码和芯片手册的表述不一致。为了安全起见,我这里填写的最大值选择0xFFFF,如果能够支持0xFFFFFFFF,那么还能简化一部分后续的操作。

第二个小问题是蓝框内的可选项没有PWM模式选择,可以在生成代码后,在代码里更改参数配置项。TIM2的配置代码如下:
void MX_TIM2_Init(void)
{
TIM_SlaveConfigTypeDef sSlaveConfig = {0};
TIM_MasterConfigTypeDef sMasterConfig = {0};
TIM_OC_InitTypeDef sConfigOC = {0};
htim2.Instance = TIM2;
htim2.Init.Prescaler = 0;
htim2.Init.CounterMode = TIM_COUNTERMODE_UP;
htim2.Init.Period = 0xFFFF;
htim2.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1;
htim2.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;
if (HAL_TIM_Base_Init(&htim2) != HAL_OK)
{
Error_Handler();
}
TIM_ClockConfigTypeDef sClockSourceConfig = {0};
sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_ETRMODE2; // 定时器时钟源:ETR2模式2
sClockSourceConfig.ClockPolarity = TIM_CLOCKPOLARITY_NONINVERTED; // 时钟不取反
sClockSourceConfig.ClockPrescaler = TIM_CLOCKPRESCALER_DIV1; // 不分频
sClockSourceConfig.ClockFilter = 0; // 不滤波
if (HAL_TIM_ConfigClockSource(&htim2, &sClockSourceConfig) != HAL_OK)
{
Error_Handler();
}
if (HAL_TIM_OC_Init(&htim2) != HAL_OK)
{
Error_Handler();
}
sMasterConfig.MasterOutputTrigger = TIM_TRGO_RESET;
sMasterConfig.MasterSlaveMode = TIM_MASTERSLAVEMODE_DISABLE;
if (HAL_TIMEx_MasterConfigSynchronization(&htim2, &sMasterConfig) != HAL_OK)
{
Error_Handler();
}
sConfigOC.OCMode = TIM_OCMODE_PWM1; // 比较输出用PWM模式1
sConfigOC.Pulse = 8; // 注意这里,不是7而是8,对应8次上升沿
sConfigOC.OCPolarity = TIM_OCPOLARITY_LOW; // 很重要,D/C时序总是从低电平开始。
sConfigOC.OCFastMode = TIM_OCFAST_DISABLE;
if (HAL_TIM_OC_ConfigChannel(&htim2, &sConfigOC, TIM_CHANNEL_1) != HAL_OK)
{
Error_Handler();
}
HAL_TIM_MspPostInit(&htim2);
HAL_NVIC_SetPriority(TIM2_IRQn, 0, 0);
HAL_NVIC_EnableIRQ(TIM2_IRQn);
}
这里需要注意的是,要开启TIM2的全局中断,并将其优先级设置高一些,至少要高于SPI的DMA中断优先级。
2. 比较中断的处理
在每一次的SPI时钟上升沿被输入TIM2_ETR引脚后,TIM2计数器都会加1,直至与预设比较值相等,触发比较中断/事件。与此同时,在硬件端比较输出通道会在此时拉高电平,事实是PWM的占空比翻转(前8个上升沿保持预设低电平,达到预设比较值后反转为高电平,直至htim2.Init.Period的预设计数最大值)。 而在比较中断处理中,我们需要处理一些不那么紧迫但是必不可少的事务,代码如下:
/* 定时器比较事件回调 */
void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim)
{
if (htim->Instance == TIM2)
{
flg_FirstByteSended = 1; // 首字节8个时钟计数完成,置标志
//2.切换成普通输出模式,固定高电平,后面海量SCLK周期保持高电平
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_5;
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct);
HAL_GPIO_WritePin(GPIOA, GPIO_PIN_5, pinState);
//1.关闭CH1输出,TIM放弃对引脚控制
HAL_TIM_OC_Stop_IT(htim, TIM_CHANNEL_1);
}
}
上述代码的意义在于:D/C虽然在正确的时机被从低电平切换为高电平,但是D/C还必须保持高电平直至完整帧传输完毕,即CS拉高释放总线后,D/C才可以回归低电平。但是由于htim2.Init.Period的最大值只能被配置为0xFFFF,而对于一款320*820的RGB565屏幕来说,DMA发送一屏完整帧的时钟数量至少是320*820*2*8 + 8。这远大于0xFFFF,为了确保D/C引脚能够无视这个最大值上限,在这里需要进行接力 -- 重新配置PA5(我这里用了PA5)为普通输出模式,断开与TIM2的复用映射,然后将其手动拉高。注意:这里必须先断开与TIM2的复用映射,否则直接操作BSRR寄存器是无效的。
3. SPI的初始化配置
SPI可以配置为DMA发送模式,这样比较节省CPU资源,且时序比较紧凑。初始化代码如下
void MX_SPI2_Init(void)
{
SPI_Handle_Screen.Instance = SPI2;
SPI_Handle_Screen.Init.Mode = SPI_MODE_MASTER;
SPI_Handle_Screen.Init.Direction = SPI_DIRECTION_2LINES;
SPI_Handle_Screen.Init.DataSize = SPI_DATASIZE_8BIT;
SPI_Handle_Screen.Init.CLKPolarity = SPI_POLARITY_LOW;
SPI_Handle_Screen.Init.CLKPhase = SPI_PHASE_1EDGE;
SPI_Handle_Screen.Init.NSS = SPI_NSS_SOFT;
SPI_Handle_Screen.Init.BaudRatePrescaler = SPI_BAUD_PCLK_PRESCALER; //时钟分频大家自行设定
SPI_Handle_Screen.Init.FirstBit = SPI_FIRSTBIT_MSB;
SPI_Handle_Screen.Init.TIMode = SPI_TIMODE_DISABLE;
SPI_Handle_Screen.Init.CRCCalculation = SPI_CRCCALCULATION_DISABLE;
SPI_Handle_Screen.Init.CRCPolynomial = 7;
SPI_Handle_Screen.Init.CRCLength = SPI_CRC_LENGTH_DATASIZE;
SPI_Handle_Screen.Init.NSSPMode = SPI_NSS_PULSE_DISABLE;
if (HAL_SPI_Init(&SPI_Handle_Screen) != HAL_OK)
{
Error_Handler();
}
}
void HAL_SPI_MspInit(SPI_HandleTypeDef* spiHandle)
{
GPIO_InitTypeDef GPIO_InitStruct = {0};
if(spiHandle->Instance==SPI_SCREEN)
{
/* SPI2 clock enable */
__HAL_RCC_SPI2_CLK_ENABLE();
__HAL_RCC_GPIOB_CLK_ENABLE();
/**SPI2 GPIO Configuration
PB13 ------> SPI2_SCK
PB15 ------> SPI2_MOSI
*/
GPIO_InitStruct.Pin = GPIO_PIN_SPI_SCLK|GPIO_PIN_SPI_MOSI; // MOSI & SCLK
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Pull = GPIO_PULLUP;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
GPIO_InitStruct.Alternate = GPIO_AF_SPI2;
HAL_GPIO_Init(GPIO_PORT_SPI_SCLK, &GPIO_InitStruct);
GPIO_InitStruct.Pin = GPIO_PIN_SPI_CS; //CS
GPIO_InitStruct.Mode = GPIO_MODE_OUTPUT_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_LOW;
HAL_GPIO_Init(GPIO_PORT_SPI_CS, &GPIO_InitStruct);
/* SPI2 DMA Init */
/* SPI2_TX Init */
DAM_Handle_SPI_MOSI.Instance = DMA_CHANNEL_SPI2_TX;
DAM_Handle_SPI_MOSI.Init.Request = DMA_REQUEST_1;
DAM_Handle_SPI_MOSI.Init.Direction = DMA_MEMORY_TO_PERIPH;
DAM_Handle_SPI_MOSI.Init.PeriphInc = DMA_PINC_DISABLE;
DAM_Handle_SPI_MOSI.Init.MemInc = DMA_MINC_ENABLE;
DAM_Handle_SPI_MOSI.Init.PeriphDataAlignment = DMA_PDATAALIGN_BYTE;
DAM_Handle_SPI_MOSI.Init.MemDataAlignment = DMA_MDATAALIGN_BYTE;
DAM_Handle_SPI_MOSI.Init.Mode = DMA_NORMAL;
DAM_Handle_SPI_MOSI.Init.Priority = DMA_PRIORITY_LOW;
if (HAL_DMA_Init(&DAM_Handle_SPI_MOSI) != HAL_OK)
{
Error_Handler();
}
__HAL_LINKDMA(spiHandle,hdmatx,DAM_Handle_SPI_MOSI);
HAL_NVIC_SetPriority(DMA1_Channel5_IRQn, 3, 0);
HAL_NVIC_EnableIRQ(DMA1_Channel5_IRQn);
}
}
4. SPI的DMA发送完成中断
当完整一帧通过SPI+DMA发送完后,触发发送完成中断。在中断处理中,我们需要完成一些事务,代码入下:
/* SPI发送完成中断服务
调用过程:
【用户APP代码使用DMA 从RAM将数据发送至SPIx_TX】: 调用=> 【 HAL_SPI_Transmit_DMA() 】: 调用=> 【SPI_DMATransmitCplt()】: 调用=> 【HAL_SPI_TxCpltCallback()】
在本中断服务中,意味着数据发送完毕,可以调用【HAL_SPI_DMAStop()】 停止 SPI的 DMA传输任务。
调用过程:
【HAL_SPI_DMAStop()】: 调用=> 【HAL_DMA_Abort()】: 清除寄存器中的相关中断标识位 + 关闭DMA中断 + 关闭DMA + 重置DMA状态为 HAL_DMA_STATE_READY 。
*/
void HAL_SPI_TxCpltCallback(SPI_HandleTypeDef *hspi)
{
if(hspi->Instance == SPI2)
{
// 释放片选引脚 CS 注意: 在调用 HAL_SPI_Transmit_DMA() 开始传输前,先操作片选引脚!!!
SPI_CS_OFF; // 拉高CS,释放SPI总线
flg_FirstByteSended = 0; // 首字节发送完毕标识归零,等待下一次帧发送
GPIO_InitTypeDef GPIO_InitStruct = {0};
__HAL_RCC_GPIOA_CLK_ENABLE();
GPIO_InitStruct.Pin = GPIO_PIN_5;
GPIO_InitStruct.Mode = GPIO_MODE_AF_PP;
GPIO_InitStruct.Pull = GPIO_NOPULL;
GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_VERY_HIGH;
GPIO_InitStruct.Alternate = GPIO_AF1_TIM2;
HAL_GPIO_Init(GPIOA, &GPIO_InitStruct); // 切换回复用模式,还给TIM控制
HAL_TIM_OC_Start_IT(&htim2, TIM_CHANNEL_1); // 开启OC输出,为下一帧SPI做好准备
}
}
5. SPI的DMA发送
有了上述的铺垫,这里就可以编写基于DMA的SPI发送函数,用于发送一个完整帧,兼容单字节无参指令、多参数指令以及显存更新帧。代码如下:
//每次发送一帧显存前调用
void SPI_Send_Frame(uint8_t *pData, uint16_t len)
{
SPI_CS_ON; // 拉低片选CS
__HAL_TIM_SET_COUNTER(&htim2, 0); //计数器清零,这步不可缺少。
HAL_SPI_Transmit_DMA(&SPI_Handle_Screen, pData, len); // 调用HAL库DMA发送
}
6. 时序的验证
以上内容组合起来后,需要运行来验证。在Main()函数中的前置内容和调用如下所示:
int main(void)
{
HAL_Init();
SystemClock_Config();
MX_GPIO_Init();
MX_DMA_Init();
MX_SPI2_Init();
MX_TIM2_Init();
HAL_TIM_OC_Start_IT(&htim2, TIM_CHANNEL_1);
__HAL_TIM_SET_COUNTER(&htim2,0);
HAL_TIM_Base_Start_IT(&htim2); //启动计数器
while (1)
{
if (Counter_1ms >= 1000)
{
Counter_1ms = 0;
SPI_Send_Frame(SPI_ByteArray, 2);
}
}
}
发送帧内容为{0xAA, 0x55}。运行后,我们可以在示波器上利用触发来观察时序是否满足我们的需求。SPI_SCLK与SPI_MOSI的时序关系如下图所示。其中SPI的时钟被设定为CPOL=0,CPHA=0
①. SPI_SCLK 与 D/C 的时序关系如下图所示:

可以看到D/C信号在第8个上升沿之后,第9个上升沿到来前拉高。PS:这里存在微弱的延迟,并且这个延迟也会随着SPI时钟速率的提高而增大,但是经过测试,当SPI时钟提升至10Mhz时,时钟延迟也就只有0.5T,属于可以接受范畴。
②. SPI_SCLK 与 CS 的时序关系如下图所示

可以看出,由于采用了软件CS,所以总线的启动和释放都存在一定的延迟。
③. CS 与 D/C 的时序关系如下图所示

其中,黄色波形为CS信号,绿色信号为D/C信号。可以看出,D/C信号在CS信号拉低后紧跟着拉低,在D/C完成切换后,直到CS释放,都一直保持为高电平,直至下一次发送的开始。
四、方案总结
以上的探索可以说达成了目的,目前这个机制,已经可以满足 SPI屏幕的时序流程:
1>. D/C 引脚在 CS 拉低后,紧接着拉低。
2>. 在 D/C 拉低后,SPI 时钟才会到来。
3>. D/C 在可配置的时钟数之后略有延迟地切换为高电平,且爬升过程非常短暂。
4>. D/C 引脚在拉高后,持续保持高电平,直至下一次通信流程开始后的步骤 “1>”。
以上完成了 D/C 引脚在整个通信帧从起始到结束的整个流程的测试,测试 SPI 时钟速率在 10Mhz也可以适配。TIMx_ETR方案稳定可靠,时机准确,没有异常跳变。
五、工程应用的讨论
上述探索还只是基于STM32L496VGT6核心板 + 飞线的简陋环境,面对较高的SPI时钟(在STM32L4家族上,SPI最大时钟可以被配置到20Mhz),SPI_SCLK和SPI_MOSI的信号畸变对于TIMx_ETR和屏幕端的信号采样会带来风险,我的建议是在总线上串入缓冲器,选用型号为德州仪器的 SN74LVC1G17DCKR ,其高速特性完全可以满足此处的用途,其延迟特性处于可接受范围。
六、结语
进行这个探索是为了尽可能地提高SPI串口屏的传输效率,减小软件手动切换D/C带来的空闲浪费。本人水平有限,还希望与大家多多交流,互通有无。
更多推荐



所有评论(0)