随着单片机的使用日益频繁,用其作前置机进行采集和通信也常见于各种应用,一般是利用前置机采集各种终端数据后进行处理、存储,再主动或被动上报给管理站。这种情况下下,采集会需要一个串口,上报又需要另一个串口,这就要求单片机具有双串口的功能,但我们知道一般的单片机只提供一个串口,那么另一个串口只能靠程序模拟。
1、串口传输协议
首先,必须要知道串口通讯时数据是怎样传输的?这里以异步传输字符为例子,如下图所示:
一般字符传输都采用:1位起始位,8位数据位,1位停止位,没有校验位 的形式传输,其他形式的这里不讲。串口异步传输在空闲状态时都必须是高电平。第一位传输的是起始位,起始位会将原来空闲时的高电平拉成低电平,起始位用来来标识数据开始传输,提示接收方准备开始接收数据;当接收方第一次检测到一个下降沿时,就表示接收到了起始位。起始位后就是8位的数据位,接收方在接收每一位数据的时候会采集几十次,如果结果都是低电平,则接收到的数据位0,如果结果都是高电平,则棘手到的数据位是1。1位停止位会将电平拉成高电平,以为接收下一个数据做准备。
2、IO模拟串口发送程序
IO口模拟串口发送数据,必须严格按照上面的异步传输协议。我们用伪代码实现这一过程:
void VirtualCOM_ByteSend(u8 val)
{
u8 i;
IO_LOW(); //起始位,拉低电平
Delay(someTIme);
for(i = 0; i 《 8; i++) //8位数据位
{
if(val & 0x01)
IO_HIGH();
else
IO_LOW();
Dealy(someTIme);
val 》》= 1;
}
IO_HIGH(); //停止位,拉高电平
Delay(someTIme);
}
代码很简单,思路也很清晰,完全是按照异步传输的过程写的。这里最重要的是Delay(someTIme),sometime的延时时间就决定了传输的速度,sometime去取某些值才可以设置程序标准的串口波特率(1200、2400、9600、38400、115200等等)。
下面,我采用STM32开发板实现IO模拟串口发送。
(1)选择IO引脚设置为虚拟串口TX引脚
我选择PA4引脚来模拟串口的TX引脚,所以需要配置下PA4这个引脚为推挽输出:
#define COM_TX_PORT GPIOA
#define COM_TX_PIN GPIO_Pin_4
void VirtualCOM_TX_GPIOConfig(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
/* PA4最为数据输出口,模拟TX */
GPIO_InitStructure.GPIO_Pin = COM_TX_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_Out_PP;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(COM_TX_PORT, &GPIO_InitStructure);
GPIO_SetBits(COM_TX_PORT, COM_TX_PIN);}
这里需要说明的是,在配置完引脚后,需要将PA4引脚拉高,这样做是为了防止在发送数据起始位时,由于原来引脚是低电平而导致没有产生一个下降沿信号。
(2)IO模拟串口发送一个字节
遵循串口异步传输协议,编写了STM32上面的相应代码:
#define COM_TX_PORT GPIOA
#define COM_TX_PIN GPIO_Pin_4
#define COM_DATA_HIGH() GPIO_SetBits(COM_TX_PORT, COM_TX_PIN) //高电平
#define COM_DATA_LOW() GPIO_ResetBits(COM_TX_PORT, COM_TX_PIN) //低电平
u32 delayTime;
void VirtualCOM_ByteSend(u8 val)
{
u8 i = 0;
COM_DATA_LOW(); //起始位
Delay_us(delayTime);
for(i = 0; i 《 8; i++) //8位数据位
{
if(val & 0x01) COM_DATA_HIGH();
else
COM_DATA_LOW();
Delay_us(delayTime);
val 》》= 1;
}
COM_DATA_HIGH(); //停止位
Delay_us(delayTime);
}
(3)IO模拟串口发送字符串
既然发送一个字节的函数已将实现了,那么发送字符串函数就简单了:
void VirtualCOM_StringSend(u8 *str)
{
while(*str != 0)
{
VirtualCOM_ByteSend(*str);
str++;
}
}
3、IO模拟接收程序
接收的代码比发送的代码复杂些。先讲讲怎么IO口接收数据的思路。为了接收数据,IO引脚必须可以检测到传输数据的起始位,检测起始位其实相当于与要检测一个下降沿信号,那么引脚只要配置成外部中断模式就可以检测到这个起始信号。然后根据传输速率配置一个相应时间定时的定时器。当检测到起始信号后,打开该定时器,每隔一定时间就会进入定时器中断,检测当前的IO引脚高低电平,从而决定接收到的数据是‘1’还是‘0’。当第九次进入定时器中断服务程序时,说明已经收到了一个字节的数据,此时关闭定时器。
下面在讲讲怎么在STM32开发板上实现这一过程。
(1)选择IO引脚模拟串口接收引脚RX
我选择PA5来模拟串口的接收引脚RX,所以需要配置PA5为输入模式,同时打开它的外部中断。
#define COM_RX_PORT GPIOA
#define COM_RX_PIN GPIO_Pin_5v
oid VirtualCOM_RX_GPIOConfig(void)
{
GPIO_InitTypeDef GPIO_InitStructure;
EXTI_InitTypeDef EXTI_InitStruct;
NVIC_InitTypeDef NVIC_InitStructure;
/* PA5最为数据输入,模拟RX */
GPIO_InitStructure.GPIO_Pin = COM_RX_PIN;
GPIO_InitStructure.GPIO_Mode = GPIO_Mode_IPU;
GPIO_InitStructure.GPIO_Speed = GPIO_Speed_50MHz;
GPIO_Init(COM_RX_PORT, &GPIO_InitStructure);
EXTI_InitStruct.EXTI_Line=EXTI_Line5;
EXTI_InitStruct.EXTI_Mode=EXTI_Mode_Interrupt;
EXTI_InitStruct.EXTI_Trigger=EXTI_Trigger_Falling;//下降沿都中断
EXTI_InitStruct.EXTI_LineCmd=ENABLE;
EXTI_Init(&EXTI_InitStruct);
NVIC_InitStructure.NVIC_IRQChannel=EXTI9_5_IRQn; //外部中断,边沿触发
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 2;
NVIC_InitStructure.NVIC_IRQChannelCmd=ENABLE;
NVIC_Init(&NVIC_InitStructure);
}
(2)配置一个定时器用来定时接收数据
我配置TIM2定时器为一定的定时周期,在它的中断服务程序中读取串口发送过来数据。定时器配置代码如下:
void TIM2_Configuration(u16 period)
{
TIM_TimeBaseInitTypeDef TIM_TimeBaseStructure;
NVIC_InitTypeDef NVIC_InitStructure;
RCC_APB1PeriphClockCmd(RCC_APB1Periph_TIM2, ENABLE);//使能TIM2的时钟
TIM_DeInit(TIM2); //复位TIM2定时器
TIM_InternalClockConfig(TIM2); //采用内部时钟给TIM2提供时钟源
TIM_TimeBaseStructure.TIM_Prescaler = 72 - 1; //预分频系数为72,这样计数器时钟为72MHz/72 = 1MHz
TIM_TimeBaseStructure.TIM_ClockDivision = 0; //设置时钟分频
TIM_TimeBaseStructure.TIM_CounterMode = TIM_CounterMode_Up;//设置计数器模式为向上计数模式
TIM_TimeBaseStructure.TIM_Period = period - 1; //设置计数溢出大小,每计period个数就产生一个更新事件
TIM_TimeBaseInit(TIM2,&TIM_TimeBaseStructure); //将配置应用到TIM2中
TIM_ClearFlag(TIM2, TIM_FLAG_Update); //清除溢出中断标志
TIM_ITConfig(TIM2,TIM_IT_Update,ENABLE); //开启TIM2的中断
TIM_Cmd(TIM2,DISABLE); //关闭定时器
TIM2 NVIC_InitStructure.NVIC_IRQChannel = TIM2_IRQn; //通道设置为TIM2中断
NVIC_InitStructure.NVIC_IRQChannelSubPriority = 1;//响应式中断优先级0
NVIC_InitStructure.NVIC_IRQChannelCmd = ENABLE; //打开中断
NVIC_Init(&NVIC_InitStructure);
}
(3)IO口模拟串口接收功能的实现
IO口接收串口数据的功能是通过PA5引脚的外部中断服务程序与定时器的中断服务程序相互配合实现的。首先需要为数据定一些状态机,方便标识接收到数据的状态:
enum{
COM_START_BIT, //停止位
COM_D0_BIT, //bit0
COM_D1_BIT, //bit1
COM_D2_BIT, //bit2
COM_D3_BIT, //bit3
COM_D4_BIT, //bit4
COM_D5_BIT, //bit5
COM_D6_BIT, //bit6
COM_D7_BIT, //bit7
COM_STOP_BIT, //bit8};
定义好了状态机,还需要一个变量,来保存这些状态机的变化,并定义它的初始状态为COM_STOP_BIT:
u8 recvStat = COM_STOP_BIT; //定义状态机
下面是PA5的外部中断服务程序,它的主要任务是检测起始位,当他第一次检测到下降沿,则说明数据即将到来,这时只要打开定时器就可以了:
#define COM_RX_STAT GPIO_ReadInputDataBit(COM_RX_PORT, COM_RX_PIN)void EXTI9_5_IRQHandler(void)
{
if(EXTI_GetITStatus(EXTI_Line5)!=RESET)
{
if(!COM_RX_STAT) //检测引脚高低电平,如果是低电平,则说明检测到下升沿
{
if(recvStat == COM_STOP_BIT) //状态为停止位
{
recvStat = COM_START_BIT; //接收到开始位
Delay(1000); //延时一定时间
TIM_Cmd(TIM2, ENABLE); //打开定时器,接收数据
}
}
EXTI_ClearITPendingBit(EXTI_Line5); //清除EXTI_Line1中断挂起标志位
}
}
上面代码中,检测到下降沿并设置了状态之后,延时了一定的时候,才打开定时器,这样做的原因是让定时器每次在信号的中间检测,而不要在信号边沿检测。正如下面图所示:
下面就是定时器的中断服务程序,它主要是接收串口发送过来的数据,在它之前我们需要线定义一个变量用来保存接收到的数据:
u8 recvData;
然后,定时器中断中,每收到1位数据就改变下状态机并同时写入这个recvData对应的数据位中,当收到8为数据后,然后关闭定时器定时,以等待新的数据到来:
void TIM2_IRQHandler(void)
{
if(TIM_GetITStatus(TIM2, TIM_IT_Update) != RESET) //检测是否发生溢出更新事件
{
TIM_ClearITPendingBit(TIM2 , TIM_FLAG_Update);//清除中断标志
recvStat++; //改变状态机
if(recvStat == COM_STOP_BIT) //收到停止位
{
TIM_Cmd(TIM2, DISABLE); //关闭定时器 return; //并返回
}
if(COM_RX_STAT) //‘1’
{
recvData |= (1 《《 (recvStat - 1));
}
else //‘0’ { recvData &= ~(1 《《(recvStat - 1));
}
}
}
4、一些精确延时与不精确延时的实现
上面代码中,需要定义一个不精确定时与两个精确定时,分别用在检测到下降沿后延时一段时间在打开定时器和控制传输速率中:
void Delay(u32 t){ while(t--);
}
void Delay_us(u32 nus)
{
SysTick-》LOAD=nus*9; //时间加载
SysTick-》CTRL|=0x01; //开始倒数
while(!(SysTick-》CTRL&(1《《16)));//等待时间到达
SysTick-》CTRL=0X00000000; //关闭计数器
SysTick-》VAL=0X00000000; //清空计数器
}
void Delay_ms(u16 nms)
{
SysTick-》LOAD=(u32)nms*9000; //给重装载寄存器赋值,9000时,将产生1ms的时基
SysTick-》CTRL|=0x01; //开始倒数 while(!(SysTick-》CTRL&(1《《16))); //等待时间到达
SysTick-》CTRL=0X00000000; //关闭计数器 SysTick-》VAL=0X00000000; //清空计数器
}
5、编写一个配置波特率的函数
这里能配置的只有300、600、1200三种波特率,其他的波特率我不想弄,也没有必要弄。下面编写一个初始化IO模拟的串口,包括引脚配置、波特率设置、定时时间设置等:
#define _300BuadRate 3150
#define _600BuadRate 1700
#define _1200BuadRate 800void
VirtualCOM_Config(u16 baudRate)
{
u32 period; VirtualCOM_TX_GPIOConfig();
VirtualCOM_RX_GPIOConfig();
if(baudRate == _300BuadRate) //波特率300 period = _300BuadRate + 250;
else if (baudRate == _600BuadRate) //波特率600 period = _600BuadRate + 50;
else if (baudRate == _1200BuadRate) //波特率1200 period = _1200BuadRate + 50;
TIM2_Configuration(period); //设置对应模特率的定时器的定时时间
delayTime = baudRate; //设置IO串口发送的速率
}
要问上面的那些数字是怎么得来的,实话说:我是试出来,但是是有根据地试出来的。我以波特率为1200为例:IO串口发送函数VirtualCOM_ByteSend()中,我们用Delay_us(delayTime)来控制传输的速率,如果波特率设为1200,则1/1200=830us相当于没830us传输1bit数据,所以在delayTime理论上应该设为830才能保证以波特率1200的速率发送数据,但是由于发送是由代码实现,有一定的延时,而不像真正串口通过移位寄存器发送那样快速,所以需要将delayTime的值在830附近调整,最后我试出来delayTime=800时,正好实现了波特率为1200的速率发送。
同样的,在IO串口接收时,需要设定定时周期,这个周期也是试出来的,但是也是有依据的。还是以1200波特率接收为例:理论上应该设置定时时间为1/1200=830us,则需要的定时值为72000000/(1/830us)=72*830,这里设置定时器的预分频为72,则周期值应该为830,所以上面代码中period的理论上应该等于830,但是接收是由代码写成的,有一定的延时,而不像真正串口一样全部有硬件完成那样快速,所以需要将period的值在830附近调整,最后试出来period=850时,可以正常接收串口发送过来的数据。
6、其他函数的编写
首先需要编写的BSP_Init()函数,来初始化板子的其他一些外设的的初始化:
void BSP_Init(void)
{
static volatile ErrorStatus HSEStartUpStatus = SUCCESS;
RCC_DeInit(); //默认配置SYSCLK, HCLK, PCLK2, PCLK1, 复位后就是该配置
RCC_HSEConfig(RCC_HSE_ON); //使能外部高速晶振
HSEStartUpStatus = RCC_WaitForHSEStartUp();//等待外部高速稳定
if(HSEStartUpStatus == SUCCESS)
{
FLASH_PrefetchBufferCmd(FLASH_PrefetchBuffer_Enable);//使能flash预读取缓冲区
FLASH_SetLatency(FLASH_Latency_2); //令Flash处于等待状态,2是针对高频时钟的
RCC_HCLKConfig(RCC_SYSCLK_Div1); //HCLK = SYSCLK 设置高速总线时钟=系统时钟
RCC_PCLK2Config(RCC_HCLK_Div1); //PCLK2 = HCLK 设置低速总线2时钟=高速总线时钟
RCC_PCLK1Config(RCC_HCLK_Div2); //PCLK1 = HCLK/2 设置低速总线1的时钟=高速时钟的二分频
RCC_PLLConfig(RCC_PLLSource_HSE_Div1,
RCC_PLLMul_9); //PLLCLK = 8MHz * 9 = 72 MHz 利用锁相环讲外部8Mhz晶振9倍频到72Mhz
RCC_PLLCmd(ENABLE); //使能PLL锁相环 while(RCC_GetFlagStatus(RCC_FLAG_PLLRDY) == RESET){} //等待锁相环输出稳定 RCC_SYSCLKConfig(RCC_SYSCLKSource_PLLCLK);//将锁相环输出设置为系统时钟 while(RCC_GetSYSCLKSource() != 0x08){} //等待校验成功 } //使能GPIO口所使用的时钟 RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOA|RCC_APB2Periph_GPIOB|RCC_APB2Periph_GPIOC|RCC_APB2Periph_GPIOD|RCC_APB2Periph_GPIOE|RCC_APB2Periph_GPIOF|RCC_APB2Periph_GPIOG, ENABLE);
最后是main函数,main函数很简单,只要调用配置IO串口的配置函数就可以了:
extern u8 recvData;int main(void){ BSP_Init(); VirtualCOM_Config(_600BuadRate); //配置IO模拟串口的波特率为600 VirtualCOM_StringSend(“HelloWorld!\r\n”); //发送“HelloWorld!”字符串 while(1) { VirtualCOM_ByteSend(recvData); Delay(5000000); }}
7、现象
首先。我们需要用TTL转USB的串口线,连接到电脑,打开串口调试工具,设置波特率为600,1位停止位,然后就可以收到IO模拟串口发过来的“HelloWorld”,然后,我们发送一个字符‘a’过去,然后就会每间隔一段时间打印出该字符。如下图所示: