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

 

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值