STM32单线串口通讯实战(二):链路层核心 —— DMA环形缓冲与收发切换时序

1. 单线通讯的"至暗时刻":收发切换 (Turnaround)

在全双工(两根线)模式下,发送和接收是独立的。但在单线模式下,你必须在一个循环中不断地切换身份:说 -> 听 -> 说

1.1 致命的"TC 标志位"陷阱

这是新手最容易犯的错误:使用 TXE (Transmit Data Register Empty) 标志来切换方向。

  • 错误做法

    1. 填入数据到 TDR。

    2. 等待 TXE 置位(表示数据从寄存器移到了移位寄存器)。

    3. 立刻切换为接收模式

    4. 后果:移位寄存器里还有 8-10 个 bit 没发完,你把 TX 关了,最后那个字节被"截断",总线上出现乱码。

  • 正确做法 : 必须等待 TC (Transmission Complete) 标志。它表示移位寄存器也空了,最后一个 Stop Bit 已经完整发到了物理线路上。

1.2 状态机控制代码 (STM32G0 寄存器操作)

为了极致的切换速度(HAL 库函数调用有几微秒开销),在中断或高频任务中,建议直接操作寄存器控制 TE (Transmitter Enable) 和 RE (Receiver Enable)。

/* 定义方向控制宏,提高代码可读性 */

#define UART_ENTER_RX_MODE() do { \

USART1->CR1 &= ~USART_CR1_TE; /* 关闭发送 */ \

USART1->CR1 |= USART_CR1_RE; /* 开启接收 */ \

} while(0)

#define UART_ENTER_TX_MODE() do { \

USART1->CR1 &= ~USART_CR1_RE; /* 关闭接收 (避免回显) */ \

USART1->CR1 |= USART_CR1_TE; /* 开启发送 */ \

} while(0)

/* 发送函数示例 */

void SingleWire_Send(uint8_t *pData, uint16_t Len)

{

// 1. 切为发送模式

UART_ENTER_TX_MODE();

// 2. 使用 HAL 库或 DMA 发送

if(HAL_UART_Transmit_DMA(&huart1, pData, Len) != HAL_OK)

{

Error_Handler();

}

// 注意:这里不能立刻切回 RX!

// 必须在 DMA 完成中断 (TC中断) 中切回 RX

}

1.3 怎么处理 Echo (自发自收)?

在单线模式(特别是 Open-Drain 物理连接)中,TX 引脚的电平变化会被 RX 引脚同步捕捉到。

  • 笨办法:软件层接收所有数据,发现是自己发的就丢弃。缺点是浪费 DMA 空间和 CPU 算力解析。

  • 聪明办法 :如上代码所示,在发送期间,硬件关闭 RE (Receiver Enable)

    • STM32G0 允许在初始化时只开启 TE,发送完后再开启 RE。这样物理层根本不会把数据送入接收 FIFO,彻底根除 Echo。

2. 接收端的"黄金标准":DMA Circular + IDLE

处理单线总线上的数据(通常是不定长的)时,"DMA 循环模式 + 空闲中断" 是目前最高效的架构,没有之一。

2.1 为什么是 Circular (环形)?

如果用 Normal 模式,每次收满 Buffer 都要重新调用 HAL_UART_Receive_DMA。在这个"重新调用"的微秒级空隙里,如果有数据进来,就会发生 Overrun Error (ORE),导致丢包。 Circular 模式下,DMA 指针指到 Buffer 尾部会自动回到头部,硬件永不停歇。

2.2 为什么是 IDLE (空闲中断)?

单线协议通常不知道下一包数据有多长。

  • IDLE 定义 :当总线上检测到一帧数据传输结束,且维持了一个字节时间的空闲(High Level),硬件置位 IDLE 标志。

  • 这相当于硬件告诉你:"刚才那波数据发完了,你可以处理了"。

2.3 实战代码:STM32G0 的 DMA 接收实现

步骤 1:初始化启动 DMAmain() 初始化部分调用一次即可:

#define RX_BUF_SIZE 256

uint8_t RxBuffer[RX_BUF_SIZE];

void Start_Listening(void)

{

// 开启空闲中断

__HAL_UART_ENABLE_IT(&huart1, UART_IT_IDLE);

// 启动 DMA 循环接收

// 注意:G0 的 DMA 初始化代码需确保配置为 DMA_CIRCULAR

HAL_UART_Receive_DMA(&huart1, RxBuffer, RX_BUF_SIZE);

}

步骤 2:编写中断服务函数 (ISR) 这是核心逻辑。我们需要计算"上次读到了哪"和"现在 DMA 写到了哪",中间的部分就是新收到的数据。

/* 全局变量记录处理进度 */

volatile uint16_t Last_Read_Index = 0;

void USART1_IRQHandler(void)

{

uint32_t isrflags = USART1->ISR;

uint32_t cr1its = USART1->CR1;

// 检测 IDLE 标志 (注意:G0 清除 IDLE 标志是写 ICR 寄存器)

if ((isrflags & USART_ISR_IDLE) && (cr1its & USART_CR1_IDLEIE))

{

// 1. 清除 IDLE 标志

__HAL_UART_CLEAR_IDLEFLAG(&huart1);

// 2. 获取当前 DMA 写入位置

// G0 的 DMA 计数器是递减的 (NDTR),也就是 "剩余传输量"

// 需换算为 "Buffer 中的索引"

uint16_t dma_remaining = __HAL_DMA_GET_COUNTER(huart1.hdmarx);

uint16_t current_write_index = RX_BUF_SIZE - dma_remaining;

// 3. 计算数据长度并处理

Process_New_Data(current_write_index);

}

// 处理 HAL 库的其他中断

HAL_UART_IRQHandler(&huart1);

}

步骤 3:环形数据提取 (Ring Buffer Logic)

void Process_New_Data(uint16_t current_index)

{

uint16_t len = 0;

if (current_index == Last_Read_Index) return; // 无新数据

if (current_index > Last_Read_Index)

{

// 情况 A: 线性数据 (未回卷)

// [ ... old ... | NEW DATA | ... empty ... ]

// ^ ^

// Last Curr

len = current_index - Last_Read_Index;

Parse_Protocol(&RxBuffer[Last_Read_Index], len);

}

else

{

// 情况 B: 数据回卷 (Wrap around)

// [ DATA_PART2 | ... old ... | DATA_PART1 ]

// ^ ^

// Curr Last

// 先处理尾部 (Part 1)

uint16_t tail_len = RX_BUF_SIZE - Last_Read_Index;

Parse_Protocol(&RxBuffer[Last_Read_Index], tail_len);

// 再处理头部 (Part 2)

if (current_index > 0)

{

Parse_Protocol(&RxBuffer[0], current_index);

}

}

// 更新指针

Last_Read_Index = current_index;

}

3. 进阶:如何调试"看不见"的方向?

调试单线串口最痛苦的是:示波器接上去,只有一根线在跳,你根本分不清哪段波形是 Master 发的,哪段是 Slave 回的。

推荐技巧:GPIO 辅助调试法 找一个空闲 GPIO(例如 PB5),作为调试探针。

  1. UART_ENTER_TX_MODE() 里,拉高 PB5。

  2. 在 DMA 发送完成中断(切回 RX 时),拉低 PB5。

示波器设置

  • CH1: 单线串口数据。

  • CH2: PB5。

效果 : 当 CH2 为高电平时,CH1 的波形是你发的;当 CH2 为低电平时,CH1 的波形是别人回的。通过测量 CH2 下降沿到 CH1 对方数据起始位的间隔,你可以精确测量 响应时间 (Response Time),这是优化总线效率的关键依据。


4. 本章小结

在这一章,我们完成了链路层的坚实地基:

  1. 收发切换 :明确了必须等待 TC 标志,并利用硬件寄存器快速切换 TE/RE 以屏蔽回显。

  2. 高效接收:实现了 DMA Circular + IDLE 机制,无论数据包多长,都能在总线空闲瞬间触发处理。

  3. 数据提取:解决了环形缓冲区的数据回卷(Wrap-around)处理逻辑。

/*******************************************
* Description:
* 本文为作者《嵌入式开发基础与工程实践》系列文之一。
* 关注我即可订阅后续内容更新,采用异步推送机制。
* 转发本文可视为广播分发,有助于信息传播至更多节点。
*******************************************/

相关推荐
萧曵 丶2 小时前
MQ 业务实际使用与问题处理详解
开发语言·kafka·消息队列·rabbitmq·rocketmq·mq
kylezhao20193 小时前
第三节、C# 上位机面向对象编程详解(工控硬件封装实战版)
开发语言·前端·c#
散峰而望3 小时前
【算法竞赛】C++入门(三)、C++输入输出初级 -- 习题篇
c语言·开发语言·数据结构·c++·算法·github
kingwebo'sZone3 小时前
c# 遍历 根据控件名获取控件实例
开发语言·c#
星空椰3 小时前
jvms Java 版本管理工具
java·开发语言
REDcker3 小时前
C++ 崩溃堆栈捕获库详解
linux·开发语言·c++·tcp/ip·架构·崩溃·堆栈
人工智能知识库3 小时前
HCIA-IoT H12-111题库(带详细解析)
物联网·hcia·hcia-iot·h12-111
qq_406176143 小时前
JavaScript闭包:从底层原理到实战
开发语言·前端·javascript
沐知全栈开发3 小时前
`.toggleClass()` 方法详解
开发语言