单片机软件常用设计分享(四)驱动设计之串口驱动设计


前言

   本人自从使用嵌入式实时操作系统后,就开始了串口的驱动设计,而这个驱动设计是具有严格意义的,因为它统一管理了MCU的所有串口,实现了非阻塞的自动处理发送与接收,同时设计并提供了的一些基本操作接口。
  在此之前,本人一直想努力设计一个与具体MCU耦合度较低的串口驱动,但发现比较难以完成。主要是每个厂家的MCU在串口设计上差别较大,同时软件驱动与硬件底层以及中断设计关联较深,如果非要把它分开,则需要传递的入口参数太多,这样就会变得比较复杂,并失去了原有的意义。因此,后面对于具体的串口驱动设计,则将依赖于一款具体的MCU。当然,我认为只要你能看懂并理解,将其移植到你使用的MCU还是很方便的。
  也很高兴有朋友能够对我之前写的部分文章表示赞许,甚至还期待着后续的写作。对此,本人深感荣幸,虽然此前对串口驱动的设计因为前述原因延期,但确实也有些拖拖拉拉的,变懒散了。今天,在2022春节即将到来前的最后一星期天,终于攒足了劲,完成了这篇文章,虽然可能有些差强人意,但还是先拿出来,后续如有需要再改进。无论如何,我给出的代码还是有积极意义的,希望对你有用。

《驱动设计–串口驱动》

  经过一段时间的思考,个人觉得串口驱动应该只适合操作系统,对于裸机,以下的说明可能不适用。
  以下将从3个角度去说明串口驱动设计,即为什么要设计串口驱动、设计串口驱动需要解决的问题、如何设计串口驱动。

一、为什么要设计串口驱动

1.CPU利用率低

  串口实际上是一个慢速硬件,其波特率与MCU的时钟相比,是不具备可比性的。因此,在应用层某个地方选择直接发送数据时,其它应用如果需要同时发送数据,则必然需要等待。此时,实际上有两个地方处于等待状态,一个是正在使用串口发送数据的应用,另一个是等待发送数据的应用,而实际上两个或更多的应用均被阻塞在那里。
  接收也存在相似的问题,如果当前串口接收到数据,应用层已经在解析数据,与此同时串口又收到数据,那么只能是等待应用层解析完数据后再处理;

2.应用与串口底层耦合性高

  目前大多数的人设计串口,一般是直接调用库函数的,其耦合性非常强。比如发送,基本是直接调用系统提供的库函数,使用poll/中断/DMA之一的模式。每个模块中涉及到串口输出的,就直接调用(DEBUG串口居多),这样既存在上面描述的问题,还有就是有些人不做互斥,也就是可能多个应用中同时对串口进行发送操作,想想这样会出现什么后果呢。
  另外,对于接收,很多就是接收到数据后,通知或调用解析函数(直接在中断中解析数据更加的不好)。当然,对于串口的操作不只是收发,还存在设置修改等,这些可能也存在于一些零零散散的代码里;

3.缺少串口统一管理的设计

  官方一般提供了一个库函数,但其中的串口操作是一些零散的最底层操作,其主要是实现对串口寄存器的组织的操作,并不完全适合于直接应用在客户的应用层软件中。如果你想将串口底层与应用层分开,则还需要设计一些接口,以隔离硬件与应用。当然,这些接口最好是标准的,比如提供打开串口、关闭串口,设置串口,串口写入,串口读取等等操作;

二、设计串口驱动需要解决的问题

1.MCU内所有串口的统一管理

  这个首先需要设计一个数据结构,其可以管理所有串口的初始化、去初始化、设置及参数等。这些结构可以适应每一个串口可以使用不同的操作方式(POLL/INTERRUPT/DMA)。另外,需要创建一个串口驱动任务(进程),其主要处理所有串口发送与接收操作;

2.MCU内所有串口的统一操作接口

  这个很重要,在创建串口驱动任务后,就应该获得一个在应用层使用的串口操作接口指针,其中至少应该包含:串口打开、串口关闭、串口控制、串口读取、串口写入等操作接口;这些操作接口仅凭一个入参(串口号)来区分不同的串口操作对象;

3.对串口数据发送的要求

  通过操作接口完成串口数据的发送,实现一个先入先出的功能,即以队列来暂存发送数据。在队列没有满的情况下,对应用层不进行阻塞,即调用完则离开,串口驱动任务会处理数据的发送(其依次取出队列的数据发送);

4.对串口数据接收的要求

  首先,串口接收不能丢失数据,也就是串口缓冲区需要一直接收数据,不能因为执行解析操作而暂停接收,接收缓冲区需要设计为环形结构。
  其次,每接收一帧数据,都能进行缓存并通知应用层进行解析,这个缓存也将采用队列的数据结构;

5.串口收发数据的压力测试要求

  串口收发数据的压力测试要求,其表现为连续发送或接收多帧数据的处理能力。这实际上是根据串口具体连接的硬件对象而定,其主要是修改或设置收发数据缓冲区尺寸及队列深度;

三、如何设计串口驱动

  本人在工作中,主要使用STM32及GD32系列MCU,并运行在FreeRTOS系统下。以下的介绍将以此为基础进行说明。

1.串口驱动任务的设计

  首先,串口驱动任务将作为一个基本任务或进程,在系统启动时首先执行,其在执行后将有利于调试串口输出调试信息。
  其次,串口驱动任务主要执行各种事务的处理,包括串口发送启动、串口发送结束、串口接收处理、串口读取完成。其中每一种事务的处理均是对所有串口进行遍历操作的,但执行时以非阻塞的方式完成(如果被阻塞了,会影响本串口或其它串口的其它事务处理)。为了便于以下描述,这里首先将给出串口驱动任务的主要代码。

//串口驱动任务
STATIC void Serial_Task(void *pvParameters)
{
   
	EventBits_t eventGroupValue;
	
	for (;;)
	{
   
		//阻塞等待事件组
		eventGroupValue=xEventGroupWaitBits(pSerialDrv->eventGroupHandle,
											COM_EVENTGROUP,
											pdTRUE,
											pdFAIL,
											portMAX_DELAY);
		osMutexWait(pSerialDrv->optMutex,portMAX_DELAY);
		//检查并处理串口接收事件
		if	(eventGroupValue&COM_EVENTGROUP_RX)
		Serial_RxProcess(eventGroupValue);
		//检查并处理串口读取完成事件
		if	(eventGroupValue&COM_EVENTGROUP_RD)
		Serial_RdFinishProcess(eventGroupValue);
		//检查并处理串口发送完成事件
		if (eventGroupValue&COM_EVENTGROUP_TX_FINISH)
		Serial_TxFinishProcess(eventGroupValue);
		//检查并处理串口发送启动事件
		if (eventGroupValue&COM_EVENTGROUP_TX_START)
		Serial_TxStartProcess(eventGroupValue);
		osMutexRelease(pSerialDrv->optMutex);
	}
}

1)串口发送启动

  这个事件是由串口设计的操作接口触发的,一旦进入这个事件,则意味着至少有一个串口发送队列中有数据需要发送。因此,这个事务的处理实际上是遍历每一个串口是否有数据需要发送,并在检查到时启动发送。
  另外,串口发送设计了一个队列,用以传递发送数据缓冲区指针,其被定义如下:
  osMessageQId msg;

//串口发送启动实现代码
STATIC void Serial_TxStartProcess(EventBits_t	eventGroupValue)
{
   
	tComPort	comX;
	//循环遍历每一个串口是否有串口发送启动事件
	for	(comX=COM0;comX<COM_MAX;comX++)
	{
   
		//串口打开,且有相应的发送启动事件时,进入发送启动处理
		if	(pSerialDrv->pObj[comX]!=NULL&&(eventGroupValue&COMX_EVENT_TX_START(comX)))
		{
   
			//当前串口没有执行发送时,启动发送
			if	(pSerialDrv->pObj[comX]->tx.busy==FALSE)
			Serial_GetTxDataFromMessageToStart(comX);// 不进行展开说明,将在后面的完整代码中呈现
		}
	}
}

2)串口发送完成

  这个事件是由串口任务启动串口发送后由中断执行触发的(可能是串口发送中断,也可能是DMA TX完成中断),进入这个事件将处理相应串口的内存释放,检查发送队列中是否还有数据需要发送,如有则立即启动发送,否则禁止发送中断。

STATIC void Serial_TxFinishProcess(EventBits_t	eventGroupValue)
{
   
	tComPort	comX;
	//循环遍历每一个串口是否有串口发送完成事件
	for (comX=COM0;comX<COM_MAX;comX++)
	{
   
		//串口打开,且有相应的发送完成事件时,进入发送完成处理
		if	(pSerialDrv->pObj[comX]&&(eventGroupValue&COMX_EVENT_TX_FINISH(comX)))
		{
   
			//释放发送内存
			vPortFree(pSerialDrv->pObj[comX]->tx.pSend); 
			pSerialDrv->pObj[comX]->tx.busy=FALSE;
			//检查等待发送队列(有则立即启动)
			if	(uxQueueMessagesWaiting(pSerialDrv->pObj[comX]->tx.msg)>0)
			Serial_GetTxDataFromMessageToStart(comX);
			if	(pSerialDrv->pObj[comX]->tx.busy==FALSE)
			{
   
				//清空发送缓冲区并禁止发送中断
				pSerialDrv->pObj[comX]->tx.pSend=NULL;
				if	(pSerialDrv->pObj[comX]->optMode.sendUseDma==TRUE)
				{
   
					usart_dma_transmit_config(usartHardConfig[comX].usart_periph,USART_DENT_DISABLE);
					dma_interrupt_disable(usartDmaConfig[comX][TX].dma_periph,usartDmaConfig[comX][TX].dma_channel,DMA_CHXCTL_FTFIE);
					nvic_irq_disable(usartDmaConfig[comX][TX].dma_nvic_irq);
				}
				else
				usart_interrupt_disable(usartHardConfig[comX].usart_periph, USART_INT_TC);
			}
		}
	}
}

3)串口接收处理

  这个事件是由串口接收空闲中断执行时触发的,这里也将遍历每一个串口,检查到有串口接收事件时,将读取串口数据,并发送到串口读取队列中。同时,检查执行串口打开时是否提供回调函数,并执行调用回调函数功能。
  另外,串口接收设计了2个队列,其作用说明如下:
  osMessageQId msgIsr;这个队列主要用于将串口接收缓冲区中的数据偏移地址与数据长度发送到串口驱动任务中;
  osMessageQId msgApp;这个队列主要用于将串口串口驱动中申请的数据缓冲区地址与数据长度发送到应用层读取;
  串口接收处理函数为Serial_RxProcess(eventGroupValue),不进行展开说明,将在后面的完整代码中呈现。

4)串口读取完成

  首先说明,这个模块是在后来设计的,原有的设计中不包含这个模块。因为在压力测试中发现解析处理过程中多次执行回调函数会导致接收处理异常,或某些接收数据没有执行回调处理。因此,在读取完成后,再执行下一次的回调处理会比较合理,并且不再出错。
  其实,这里也就是保证每一个接收数据帧能都被有序的调用回调函数执行解析处理。

STATIC void Serial_RdFinishProcess(EventBits_t	eventGroupValue)
{
   
	tComPort		comX;
	//循环遍历每一个串口是否有串口读取完成事件
	for	(comX=COM0;comX<COM_MAX;comX++)
	{
   
		//串口打开,且有相应的读取完成事件时,进入读取完成处理
		if	(pSerialDrv->pObj[comX]!=NULL&&(eventGroupValue&COMX_EVENT_RD_FINISH(comX)))
		{
   
			if	(pSerialDrv->pObj[comX]->rx.receiveFull==TRUE)
			pSerialDrv->pObj[comX]->rx.receiveFull=FALSE;
			//检查并执行下一次的回调函数处理(接收通知)
			if	(pSerialDrv->pObj[comX]->rx.RecNotice!=NULL&&uxQueueMessagesWaiting(pSerialDrv->pObj[comX]->rx.msgApp)>0)
			pSerialDrv->pObj[comX]->rx.RecNotice();
		}
	}
}

2.串口驱动操作接口函数的设计

  串口驱动任务仅仅执行了串口的收发操作的有序处理。但要使用串口驱动,则需要编写一些标准的操作接口,以便同串口驱动任务协同工作。这里的串口驱动操作接口函数,主要包括:串口打开、串口关闭、串口控制、串口读取、串口打印、串口格式化打印、串口ASCII打印。其中后面两个接口是增强设计,如没有必要可以不做设计(将在完整代码中呈现)。
  以下分别描述各个接口函数的设计。

1)串口打开函数

  这个接口函数主要执行串口驱动任务使用内存资源创建,串口硬件初始化,串口接收启动等操作。以下是具体的实现函数,只有执行过串口打开后,其它接口才可以使用。

STATIC BOOL Serial_Open(tComPort comX,tSerialUartParam* pParam,tSerialRecNoticeFunc RecNoticeFunc)
{
   
	if	(pSerialDrv!=NULL	&&pSerialDrv->pObj[comX]==NULL	&&inHandlerMode()==0)
	{
   
		//创建串口内存资源
		pSerialDrv->pObj[comX]=(tSerialObj*)pvPortMalloc(sizeof(tSerialObj)+usartBuffMax[comX][RX]);
		if	(pSerialDrv->pObj[comX]==NULL)
		return	FALSE;
		//复制串口参数(包括波特率、数据位、停止位、奇偶校验等)
		pSerialDrv->pObj[comX]->param=*pParam;
		//串口使用的操作系统资源创建
		Serial_ParamInial(comX,RecNoticeFunc);
		//串口硬件初始化
		Serial_UsartXInit(comX);
		//启动串口接收
		Serial_ReceiveStart(comX);
		//以下函数暂时没有处理
		Serial_Enable(comX);
		return	TRUE;
	}
	return	FALSE;
}

2)串口关闭函数

  这个函数接口实际上就是执行一个串口的整体去初始化操作,释放操作系统资源,释放内存资源、释放硬件等。当然,串口关闭函数其实大多时候是不会使用的。以下是具体的实现函数:

STATIC BOOL Serial_Close(tComPort comX)
{
   
	//禁止串口操作(暂时没有处理)
	Serial_Disable(comX);
	//串口硬件去初始化
	Serial_UsartXDeInit(comX);
	//串口收发队列及链表释放
	Serial_TRxListFree(comX);
	//串口使用的操作系统资源释放
	Serial_ParamDeInial(comX);
 	//串口内存资源释放
	vPortFree(pSerialDrv->pObj[comX]);
	pSerialDrv->pObj[comX]=NULL;
	return	TRUE;
}

3)串口控制函数

  这个函数接口主要实现串口参数修改,主要是波特率等参数修改。当然,也可以添加执行其它你希望的操作。这里实际上已经预留了其它操作的设计。以下是具体的实现函数:

STATIC BOOL Serial_Ctrl(tComPort comX,tSerialCtrl *pCtrl)
{
   
	switch	(pCtrl->cmd)
	{
   
		//串口参数修改
		case	SERIAL_CTRL_UARTPARAM:
				Serial_ParamSetup(comX);
				break;
		//串口放弃操作
		case	SERIAL_CTRL_ABORT:
				Serial_TRxListFree(comX);
				break;
	}
	return	TRUE;
}

4)串口读取函数

  串口读取操作,实际上是从队列中读取串口数据的缓冲区指针,调用者将通过这个返回的指针读取数据。另外,在读取完成后,将向串口驱动发送一个读取完成的回执,使得串口驱动可以执行下一次的回调函数功能。以下是具体的实现函数:

STATIC BOOL Serial_Read(tComPort comX,tSerialBuff **pSerialRx,tReadMode readMode,TickType_t xTicksToWait)
{
   
	//应用层读取到串口数据,在处理完成后需要释放pSerialRx指向的内存
	TickType_t	delayTime;
	uint32_t	address;
	
	if	(pSerialDrv!=NULL&&pSerialDrv->pObj[comX]!=NULL)
	{
   
		//执行读取时有三种操作模式,分别是仅读取一次/在指定时间内一直读取/不限时间的读取
		if	(readMode==READ_ONLYONCE)
		delayTime=1;
		else if	(readMode==READ_SETTIME)
		delayTime=xTicksToWait;
		else if	(readMode==READ_WAITING)
		delayTime=portMAX_DELAY;
		else
		return	FALSE;
		osMutexWait(pSerialDrv->pObj[comX]->rx.mutex, portMAX_DELAY);
		//这里将读取到缓冲区地址
		address=osQueueUint32DataTake(pSerialDrv->pObj[comX]->rx.msgApp,delayTime);
		if	(address!=NULL)
		{
   
			*pSerialRx=(tSerialBuff *)address;
			//向串口驱动发送读取完成的事件组	
			osEventGroupSet(pSerialDrv->eventGroupHandle,COMX_EVENT_RD_FINISH(comX));
			osMutexRelease(pSerialDrv->pObj[comX]->rx.mutex);
			return	TRUE;
		}
		else
		pSerialRx=NULL;
		osMutexRelease(pSerialDrv->pObj[comX]->rx.mutex);
	}
	return	FALSE;
}

5)串口打印函数

  串口打印函数,主要完成将发送数据拷贝出来,并添加到发送队列中,然后,向串口驱动任务发送启动事件组。以下是具体的实现函数:

STATIC BOOL Serial_Print(tComPort comX,uint8_t *pData,uint16_t length)
{
   
	tMessage	message;
	tSerialBuff	*pSerialTx;
	
	//串口已经打开,并且数据有效时执行
	if	(pSerialDrv!=NULL&&pSerialDrv->pObj[comX]!=NULL&&pData!=NULL&&length>0)
	{
   
	    //检查发送队列(至少需要保留一个空队列)
		if	(uxQueueSpacesAvailable(pSerialDrv->pObj[comX]->tx.msg)<=1)
		return	FALSE;
		//同一串口调用之间的互斥(其作用实际上是避免无限执行耗尽内存)
		osMutexWait(pSerialDrv->pObj[comX]->tx.mutex, portMAX_DELAY);
		if	(TRUE&&inHandlerMode()==0)
		{
   
			//非中断中执行,可以申请内存进行拷贝
			pSerialTx=(tSerialBuff*)pvPortMalloc(sizeof(tSerialBuff)+length);
			if	(pSerialTx==NULL)
			{
   
				osMutexRelease(pSerialDrv->pObj[comX]->tx.mutex);
				return FALSE;
			}
			//拷贝数据并设置消息
			pSerialTx->pBuff=((uint8_t*)pSerialTx)+sizeof(tSerialBuff);
			memcpy(pSerialTx->pBuff,pData,length);
			pSerialTx->length=length;
			message.event=0;
			message.address=(uint32_t)pSerialTx;
			//执行消息发送
			if	(osMessagePut(pSerialDrv->pObj[comX]->tx.msg,message,100)==FALSE)
			{
   
				vPortFree(pSerialTx);
				osMutexRelease(pSerialDrv->pObj[comX]->tx.mutex);
				return	FALSE;
			}
		}
		else
		{
   
			//在中断内,不能调用pvPortMalloc申请内存,因此设计了参数传递,同时借用message.event传递数据长度
			message.event=length;
			message.address=(uint32_t)pData;
			//执行消息发送
			if	(osMessagePut(pSerialDrv->pObj[comX]->tx.msg,message,100)==FALSE)
			{
   
				osMutexRelease(pSerialDrv->pObj[comX]->tx.mutex);
				return	FALSE;
			}
		}
		//向串口任务发送事件组		
		osEventGroupSet(pSerialDrv->eventGroupHandle,COMX_EVENT_TX_START(comX));
		osMutexRelease(pSerialDrv->pObj[comX]->tx.mutex);
		return	TRUE;
	}
	return	FALSE;
}

3.串口驱动结构设计

  感觉在这里再仔细描述串口驱动所使用的每一个数据结构,好像似乎有些多余了。因为我是打算给出完整的驱动代码,因此我将不再像以前一样重复,尽量在完整代码中将注释做多一点,这里就不再多讲了。

四、串口驱动源码

  说明:此代码基于FreeRTOS V10.2.1,MCU为GD32F409。代码中的某些部分基于GD32的库函数。另外,以下代码文件并非独立的存在,其必定依赖于一些其它文件,这里我将不会一一列出。

1.Serial.h文件代码(驱动.h文件)

#ifndef _SERIAL_H
#define	_SERIAL_H
#include "typedef.h"  //此头文件仅仅是一些类型定义,此处不再给出,但说明一下其中几个定义
/*
typedef.h中的部分定义
typedef SemaphoreHandle_t 	osMutexId;
typedef SemaphoreHandle_t 	osSemaphoreId;
typedef QueueHandle_t 		osMessageQId;
*/
//串口参数-停止位定义
#define	UART_STOPBIT_0P5				0
#define	UART_STOPBIT_1					1
#define	UART_STOPBIT_2					2
#define	UART_STOPBIT_1P5				3
//串口参数-奇偶校验定义
#define	UART_PARITY_NONE				0
#define	UART_PARITY_ODD					1
#define	UART_PARITY_OVEN				2

//串口参数类型定义
typedef struct
{
   
	uint32_t				baudRate; 				//波特率
	uint8_t					wordLength;				//字长(8/9)
	uint8_t					stopBits;				//停止位(0/1/2/3<0.5/1/2/1.5>)
	uint8_t					parity;					//奇偶校验位(0/1/2:无/奇/偶)
}tSerialUartParam;//串口参数

//com口定义,注这里是我一个项目中使用串口的具体定义
typedef enum
{
   
	COM0,
	COM1,
	COM2,
	COM5,
	COM_MAX,
}tComPort;//COM口

//串口数据读取方式定义
typedef enum
{
   
	READ_ONLYONCE=0,								//仅读一次
	READ_SETTIME=1,									//指定等待时间
	READ_WAITING=2									//一直等待
}tReadMode;//串口外部读取数据方式

//串口底层收发使用传输操作方式
typedef struct
{
   
	BOOL					sendUseDma;				//TURE使用DMA,FALSE使用中断
	BOOL					receiveUseDma;			//TURE使用DMA,FALSE使用中断
}tUsartOptMode;//串口底层收发使用传输操作方式

//串口收发缓存
typedef struct
{
   
	uint8_t					*pBuff;					//缓存区指针
	uint16_t				length;					//缓存区长度
}tSerialBuff;//串口收发缓存

//串口控制操作命令
typedef enum
{
   
	SERIAL_CTRL_UARTPARAM,							//控制修改串口参数
	SERIAL_CTRL_ABORT,								//控制串口放弃操作
}tSerialCtrlCmd;//串口控制操作命令

//串口控制操作
typedef struct
{
   
	tSerialCtrlCmd					cmd;				//设置命令
	tSerialUartParam				uartParam;			//串口参数
}tSerialCtrl;//串口控制操作

//串口操作接口功能类型定义
typedef BOOL(*tSerialRecNoticeFunc)(void);	//串口接收通知回调函数
typedef BOOL(*tSerialOpenFunc)(tComPort,tSerialUartParam*,tSerialRecNoticeFunc);
typedef BOOL(*tSerialCloseFunc)(tComPort);
typedef BOOL(*tSerialCtrlFunc)(tComPort,tSerialCtrl*);
typedef BOOL(*tSerialReadFunc)(tComPort,tSerialBuff**,tReadMode,TickType_t);
typedef BOOL(*tSerialPrintFunc)(tComPort,uint8_t*,uint16_t,BOOL);
typedef BOOL(*tSerialPrintAsciiFunc)(tComPort,char*,uint8_t*,uint16_t);
typedef BOOL(*tSerialPrintStrFunc)(tComPort,char *fmt,...);

//串口驱动操作接口类型定义
typedef struct
{
   
	/*******************************************************************************************************
	Open(tComPort comX,tSerialUartParam* pParam,tSerialRecNoticeFunc RecNoticeFunc)调用参数说明如下:
	comX:打开的COM口号
	pParam:串口参数
	RecNoticeFunc:串口接收到数据后的应用层回调函数
	实际使用举例如下ÿ
评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值