UART半双工通讯原理与设计实现 - 以航顺HK32F0301MxxxxC系列MCU为例
UART通讯协议简要说明
UART,即通用异步串行收发传输器(Universal Asynchronous Receiver/Transmitter),一般按照单片机的支持,可以配置为全双工或半双工通讯:
全双工会使用两条信号线,RX和TX,对于指定的一个全双工设备,TX为该设备的数据发送线,RX为数据接收线,一般需要交叉连接另一设备的TX和RX(也就是自己的发送线连接到对方的接收线,自己的接收线连接到对方的发送线)。
半双工只需要一条数据线,进行通讯的双方按照指定的一定标准,交替发送数据等,可以实现一条线上完成数据发送与接收的任务。
UART协议数据帧结构
一般UART的一帧数据包含以下几部分
- 启动位(Start Bit):一个低电平的比特,用于标识数据传输的开始;
- 数据位(Data Bits):包含要传输的数据的比特数,通常是5、6、7或8个比特;
- 奇偶校验位(Parity Bit):可选,用于验证数据的正确性。奇偶校验位的值可以是奇校验或偶校验,它们根据数据位中1的个数确定;
- 停止位(Stop Bit):一个时钟高电平的比特,用于标识数据传输的结束;
如下图展示了航顺MCU数据手册中的UART协议数据帧结构配图,注意这里面把奇偶校验位视为数据位的一部分。
UART半双工通讯的实现
这里简要展示一下UART通讯的几种情况,如下图所示:
(a)为单工通讯,规定好一方发送,另一方接收;
(b)为半双工通讯,双方都可以进行发送和接收,不过为了避免冲突,不能同步收发;
(c)为全双工通讯,双方通过两条信号线连接,可以同时收发。
如果希望使用UART进行半双工通讯,一般双方都使用TX引脚进行连接,此时一方需要使用上拉电阻置高电平(UART通讯默认情况下是高电平,拉低电平表示数据传输的开始)。
这时在软件中规定好双方的收发顺序等,正确配置通讯双方的发送、接收状态的切换,即可实现在一条数据线上完成双方的数据发送与接收。
半双工通讯设计
这里以航顺HK32F0301MxxxxC系列MCU为例进行半双工通讯的代码设计。
MCU硬件条件确认
首先我们需要确定MCU支持半双工通信,通过翻阅数据手册我们可以了解到航顺的该类型MCU支持UART的单线半双工通信。
另外,数据手册中有对UART单线半双工通信的进一步说明,需要仔细阅读。我们可以看到数据手册中明确说明TX需要配置为复用功能、开漏输出,并在外部接上拉电阻以拉高电平,这在最开始的硬件原理图设计中就要注意。
通讯设计思路
由于我们这里使用单线半双工通讯,双方的硬件条件都支持数据的接收与发送,那么如果软件上未做好设计,将可能出现双方同时准备发送数据的情况,也就是发生了抢占冲突的问题。
此时将会导致信号线电平不正确,通讯双方将无法接收到正确数据,我们有必要设计好双方的一个收发流程,保证数据传输的正确性。
这里简单提两种通讯设计思路:
- 主从设备:令一台设备作为主设备,另一台为从设备;从设备只有接收到主设备发送的数据后,才会开始传递数据给主设备,主设备在规定时间内收到后,可以按照需求继续发送数据或暂停等。此外主设备设计超时重发机制,在发送完数据后,若规定时间内未收到数据,判定为超时,重新进行发送以尝试建立通讯。
- 交替发送:通讯双方各自设定一个定时器,例如规定5ms进行一次定时器中断,在定时器中断触发时,进行数据发送,并在发送完毕之后切换回接收状态,这里要注意收发状态的修改,避免自发自收的情况出现。
此外,为了进一步保证收发数据的正确性,可以指定通讯协议帧头、帧尾,以及配合校验字节等确保完整接收。
代码实现
这里我们以主从设备模式为例,简要展示一下代码的实现。
首先是航顺UART的配置,我们需要使能UART外设的时钟,并设置好波特率、校验方式、停止位、数据位等,并且要注意我们这里需要打开UART的接收和发送,并使能半双工(UART_HalfDuplexCmd(UART1,ENABLE);
),最后使能接收中断。
这里还需要配置要复用为UART数据发送的引脚,并配置为开漏输出,这里不做展示。
void UART_Configurature(void){
UART_InitTypeDef m_usart;
RCC_APBPeriph2ClockCmd(RCC_APBPeriph2_UART1, ENABLE); // 使能UART1时钟
m_usart.UART_BaudRate = 9600; // 波特率9600
m_usart.UART_Mode = UART_Mode_Rx| UART_Mode_Tx; // 使能接收和发送
m_usart.UART_Parity = UART_Parity_Even; // 偶校验
m_usart.UART_StopBits = UART_StopBits_1; // 1位停止位
m_usart.UART_WordLength = UART_WordLength_9b; // 9位数据位
UART_Init(UART1, &m_usart);
UART_HalfDuplexCmd(UART1,ENABLE); // 半双工
UART_Cmd(UART1, ENABLE); // 使能UART1
UART_ITConfig(UART1, UART_IT_RXNE, ENABLE); // 配置接收中断
NVIC_SetPriority(UART1_IRQn,0); // 设置优先级
NVIC_EnableIRQ(UART1_IRQn); // 使能中断
}
这里我们简单来看一下UART_HalfDuplexCmd(UART1,ENABLE);
函数的含义,找到航顺官方提供的库函数源文件中该函数的实现,可以看到在使能半双工时,将会把UART对应寄存器CR3
的HDSEL
位置1,与前面数据手册中所提到的UART单线半双工通信一致。
/**
* @brief Enables or disables the UART's Half Duplex communication.
* @param UARTx: Select the UART or the UART peripheral.
* This parameter can be one of the following values:
* UART1, UART2.
* @param NewState: new state of the UART Communication.
* This parameter can be: ENABLE or DISABLE.
* @retval None
*/
void UART_HalfDuplexCmd(UART_TypeDef* UARTx, FunctionalState NewState)
{
/* Check the parameters */
assert_param(IS_UART_ALL_PERIPH(UARTx));
assert_param(IS_FUNCTIONAL_STATE(NewState));
if (NewState != DISABLE)
{
/* Enable the Half-Duplex mode by setting the HDSEL bit in the CR3 register */
UARTx->CR3 |= UART_CR3_HDSEL;
}
else
{
/* Disable the Half-Duplex mode by clearing the HDSEL bit in the CR3 register */
UARTx->CR3 &= (uint32_t)~((uint32_t)UART_CR3_HDSEL);
}
}
如果各位比较细心,或许可以看到该函数前航顺关于半双工配置的一个说明,而其中有提到CR2
的LINEN
和CLKEN
位与CR3
的SCEN
和IREN
位必须要清空。
不过我们阅读数据手册发现,CR2
和CR3
两个寄存器并没有这里所提到的控制位,而在实际中我们也不需要专门处理这里所提到的控制位,这里可能是库函数版本问题,有待验证。
接下来,我们来实现UART的中断接收处理,来进行UART收发状态的切换等。
代码中的COM_RX
为自定义的一个枚举类型的值,comm
为一个结构体,具体请见后续代码。
void UART1_IRQHandler(void)
{
uint8_t temp_recv;
// 接收中断
if((UART1->ISR & UART_ISR_RXNE) != 0)
{
// 读取接收数据
temp_recv = (UART1->RDR & 0xFF);
// comm处于接收状态
if(comm.current_comstat == COM_RX)
{
comm.RecvBuf[comm.pBuf] = temp_recv;
comm.pBuf++;
if(comm.pBuf == 2) // 接收完成
{
comm.pBuf = 0; // 重置接收指针
comm.UartRecvFlg = 1; // 设置接收完成标志
}
}
}
else if((UART1->ISR & UART_ISR_ORE) != 0) // 接收溢出
{
temp_recv = UART1->RDR;
UART1->ICR |= UART_ICR_ORECF;
}
}
这里为了更好的实现UART的收发状态切换,我们定义了一个枚举类型用来表示收发状态,并且编写了相关处理函数,用于在主函数while循环中不断进行数据收发处理。
// 当前代码所在运行设备是主设备还是从设备
// 若为主设备,会进行超时重发
#define MASTER 1 // 1:主设备 0:从设备
//***************************变量定义*************************
// 通信状态
typedef enum
{
COM_TX = 0, // 发送状态
COM_RX, // 接收状态
}COMSTATE;
// 串口控制
typedef struct
{
COMSTATE current_comstat; // 当前通信状态
uint32_t CommTimer; // 通信定时器
uint8_t SendBuf[2]; // 发送缓存
uint8_t RecvBuf[2]; // 接收缓存
uint8_t pBuf; // 接收指针
uint16_t UartOTcnt; // 超时计数
uint8_t UartRecvFlg; // 接收完成标志
}UARTCTL;
extern UARTCTL comm;
/********************************* 函数 ****************************************
* @brief 切换半双工模式
* @param
* @retval None
*/
void UART_SwitchMode(COMSTATE stat)
{
if(stat == COM_RX)
{
comm.current_comstat = COM_RX;
UART1->CR1 &= ~UART_CR1_TE; // 关闭发送
UART1->CR1 |= UART_CR1_RE; // 使能接收
}
else if(stat == COM_TX)
{
comm.current_comstat = COM_TX;
UART1->CR1 &= ~UART_CR1_RE; // 关闭接收
UART1->CR1 |= UART_CR1_TE; // 使能发送
}
}
/********************************* 函数 ****************************************
* @brief UART通讯处理
* @param None
* @retval None
*/
void UARTDisp(void)
{
if (((comm.CommTimer - SysTick->VAL) & 0x00ffffff) >= _dTickus(20000.0))
{
// 每20ms处理1次
comm.CommTimer = SysTick->VAL;
// 发送
if(comm.current_comstat == COM_TX)
{
UART_SendByte(comm.SendBuf[0]);
UART_SendByte(comm.SendBuf[1]);
// 检查是否发送完毕
while((UART1->ISR & UART_ISR_TC) == 0);
UART1->ICR |= UART_ICR_TCCF; // 清除发送完成标志
UART_SwitchMode(COM_RX);
}
// 接收完成,进行解析
if(comm.UartRecvFlg)
{
comm.UartRecvFlg = 0; // 接收完成标志清零
comm.UartOTcnt = 0; // 超时计数清零
// ... 指令处理
// disp(comm.RecvBuf[0]);
// disp(comm.RecvBuf[1]);
// 接收完成,切换到发送状态
UART_SwitchMode(COM_TX);
}
else // 在当前轮次未接收到数据,进行超时处理
{
comm.UartOTcnt++; // 超时计数
if(comm.UartOTcnt>=5) // 若超时5次则认为通信错误
{
// 重新发送,并清空超时计数
#if(MASTER == 1)
UART_SwitchMode(COM_TX);
#endif
comm.UartOTcnt = 0;
}
}
}
}
注意这里在发送数据后,应当等待发送完成再切换收发状态,避免因为状态切换使得数据发送不完整。此外这里使用Systick
定时器进行轮询处理,控制函数执行周期,具体用法请自行查找Systick
相关资料。
主函数中主要进行相关IO口、外设配置,并在最开始将主设备设置为发送模式。
int main(void)
{
SysTick->CTRL = SysTick_CTRL_ENABLE_Msk; // 使能系统定时器
GPIOInit(); // GPIO初始化
UART_Configurature(); // UART初始化
UART_SwitchMode(COM_TX); //初始化为发送模式
comm.SendBuf[0] = 0xAA;
comm.SendBuf[1] = 0xBB;
while (1)
{
UARTDisp(); // 串口处理
//...其他业务代码
}
}
参考此代码编写从设备代码,可以实现交替收发的半双工通讯。
对于定时器方法的交替发送实现,这里不做讲解,具体可参考下方链接中单线半双工教程与案例的实现。
注意事项
接收与发送不应同时使能
注意到前面所编写的void UART_SwitchMode(COMSTATE stat)
函数中,将根据传入的状态,更新comm
结构体的状态,并操作UART的CR1
寄存器中TE
和RE
位,用于控制接收器与发送器的使能与关闭。
void UART_SwitchMode(COMSTATE stat)
{
if(stat == COM_RX)
{
comm.current_comstat = COM_RX;
UART1->CR1 &= ~UART_CR1_TE; // 关闭发送
UART1->CR1 |= UART_CR1_RE; // 使能接收
}
else if(stat == COM_TX)
{
comm.current_comstat = COM_TX;
UART1->CR1 &= ~UART_CR1_RE; // 关闭接收
UART1->CR1 |= UART_CR1_TE; // 使能发送
}
}
对于TE
和RE
位的作用,翻阅用户手册可以看到如下描述;结合此前航顺用户手册对于UART半双工通讯的描述可知,配置半双工通讯时,TX和RX线路会从内部相连接。
此时如果我们同时使能了发送器和接收器,那么接收器会直接接收到发送器发送的数据,也就是收到了自己要发送的数据,对于一般半双工通讯的设计,我们应当不需要去处理自己发送的数据,因此需要控制发送器和接收器交替使能,避免设备自身发送的数据触发了自身的接收中断处理。
及时切换收发状态
UART由发送状态转为接收状态要及时,在确保发送完毕之后尽快切换为接收状态,避免数据接收不完整。上方的示例代码中,这一段while堵塞就是为了尽快完成状态切换,我们这里使用到了中断和状态寄存器UARTx_ISR
来实现及时转换。
while((UART1->ISR & UART_ISR_TC) == 0);
UART1->ICR |= UART_ICR_TCCF; // 清除发送完成标志
UART_SwitchMode(COM_RX);
翻阅航顺用户手册,我们可以了解到UARTx_ISR
寄存器的TC
位表示UART发送完成,因此这里我们在该位未置1的时候,通过while循环堵塞等待,之后清空该位并切换收发状态,以保证状态切换的及时性。
我们这里使用逻辑分析仪来对输出信号进行监测,可以看到在没有从设备与主设备进行半双工通讯情况下,主设备大概每隔100ms都会发送0xAA 0xBB
数据,符合我们此前所做的超时重发设计。这里我们还使用另一个引脚,每当发送完毕并进行收发状态的切换后,都会进行电平的翻转,用于观察收发状态切换的及时性(硬件电路设计等请自行实现,不做展示)。
我们使用逻辑分析仪的时间标尺功能,测量UART发送完毕数据位后,上拉电平进行停止位发送的时刻与我们所设定的引脚翻转为高电平的时刻的时间差,如图中所示为106.86μs。而我们所使用的9600波特率对应的一个数据位的发送时间接近图中右侧上方的脉宽103.92μs,所以粗略来说,可以认为我们的状态切换时间为3μs左右,正常情况下应当不会出现由于收发状态切换太慢而未能及时接收数据。
这里我们编写代码,来做一个收发状态切换不当的示范。
我们注释掉此前使用寄存器进行while循环堵塞等待的代码,使用一个简单的空操作软件延时;可以预料,这个延时数值若过大,将会导致接收状态切换太慢,可能丢失从设备返回的数据;若数值过小,可能在数据还未发送完毕时,发送器就被取消使能,导致数据发送不完整。
// 发送
if(comm.current_comstat == COM_TX)
{
UART_SendByte(comm.SendBuf[0]);
UART_SendByte(comm.SendBuf[1]);
// 进行接收状态切换不当示范
softWareDelay(200);
// // 检查是否发送完毕
// while((UART1->ISR & UART_ISR_TC) == 0);
// UART1->ICR |= UART_ICR_TCCF; // 清除发送完成标志
UART_SwitchMode(COM_RX);
}
我们继续使用逻辑分析仪,按照此前的方法观测电平变化。
可以看到原本代码中要发送的数据是0xAA 0xBB
,而此时解析得到的数据却是0xAA 0XFB
,观察波形可以发现,在第二个字节发送过程的后半段,就只有高电平了,且用于指示状态切换的IO口电平发生了翻转,表明这时发送器被取消使能,因此电平变为所设计的上拉电平。
这也表明对于半双工通讯状态的切换,我们最好结合寄存器相关标志位进行实现,避免因为发送数据长度不同等,需要设置不同延时时间。
参考
HK32F0301MxxxxC数据手册V1.5
HK32F0301MxxxxC用户手册V1.1
stm32 USART串口半双工功能测试
Uart Full Duplex vs Half Duplex
STM32 UART Half-Duplex (Single Wire) Tutorial & Examples