STM32单片机学习(9)

1. 读写内部FLASH

1.1 关于内部FLASH

几乎所有的MCU内部都会有一块Flash,用于保存要运行的代码。在电脑上交叉编译得到二进制文件后,使用烧写器将该文件烧写到内部Flash上。MCU上电后,自动读取Flash上的内容并加载到内存,CPU再从内存执行代码内容。由于Flash掉电数据不丢失的特性,每次上电后,MCU都能执行同一个程序,直至Flash上的内容被重新烧写。

通常习惯上,使用内部Flash存储运行代码,外部Flash存储(前面“21.1 关于SPI”介绍的外部SPI接口Flash)存储需要掉电保存的数据。但这也不是绝对的,如果没有外部Flash,需要掉电保存的数据也可以放在内部Flash未使用的区域。

STM32内部也有一块Flash,不同的型号其Flash的容量大小也不一样,从“图 2.2.4 STM32芯片命名规则(仅适用于MCU)”可知,STM32的Flash(闪存)容量从1KB至2048KB不等。如下表 35.1.1 所示,为STM32闪存容量密度分类,本开发板使用的STM32F103ZET6的容量为512KB,属于高密度(HD:High-Density)系列。

  • 表 1.1.1 STM32 闪存容量密度等级
    在这里插入图片描述
    主要由三部分组成:主存储器、信息块、闪存存储器接口寄存器。

①主存储器
用于存储数据的区域,地址范围为0x08000000~0x0807FFFF,分为256页,每页2KB(中、低密度的系列为1KB),总计512KB。当启动引脚BOOT0为低电平时,系统将从0x08000000地址开始读取代码,也就是从主存储器(Main Flash memory)启动。

②信息块
由两部分组成:系统存储(System memory)和选项字节(Option Bytes)。
系统存储区域大小为2KB,存放有ST出厂固化的程序代码,该代码可实现从UART接收数据并烧写到主存储器区。当启动引脚BOOT0为高电平,BOOT1为低电平时,系统将从0x1FFFF000地址开始读取代码,该代码将从UART收到的数据写到主存储器。也就是通过UART实现程序的下载。
选项字节区域大小为16 Bytes,用于设置内存读写保护、硬件看门狗软硬模式等功能,可通过ST-Link等调试工具查看该部分寄存器状态。

③闪存存储器接口寄存器
用于控制闪存的读写、管理等,后面对闪存操作时,再介绍具体功能。

  • 图 1.1.1 Flash 模块组织结构(高密度)
    在这里插入图片描述
    对Flash核心的操作就是读写,下面详细介绍如何对Flash进行读写。
    ①读Flash
    从STM32总线结构框图”可知,内核使用ICode指令总线访问Flash的指令,使用DCode
    数据总线访问Flash的数据。
    Flash存储器有两个64位缓存器组成的预取缓冲器,使得CPU可以工作在更高频率,同时需要根据不同的系统时钟(SYSCLK)频率设置对应的等待周期(LATENCY)。系统时钟与等待周期关系如表 1.1.2 所示。通常系统时钟为72MHz,则需要设置2个等待周期(LATENCY),否则读写Flash可能出错,导致死机等情况。
  • 表 1.1.2 系统时钟与等待周期关系
    在这里插入图片描述
    正确设置求好等待周期后,可以利用指针进行读取数据。例如,从地址addr读取一字节数据。可将addr强制转换为uint32_t指针,然后取该指针所指向地址的值,即得到addr地址的数据:
    在这里插入图片描述
    ②写Flash
    内部Flash的写操作要繁琐一些,一共需要分为四个步骤:解锁->擦除->写数据->上锁。
  • 解锁
    MCU复位后,闪存编程和擦除控制器(FPEC)处于锁定保护状态,此时闪存控制寄存器(FLASH_CR)不可操作。需要将两个特定的解锁序列号(Key1=0x45670123 Key2=0xCDE89AB)写入闪存键值寄存器(FLASH_KEYR)以解锁FPEC模块。
  • 擦除
    由于Flash物理特性(只能写0,不能写1),在写Flash前需要进行擦除操作,将Flash全变为0xFFFF。FPEC支持页擦除(Page Erase)和批量擦除(Mass Erase),设置步骤如图 1.1.2 所示。
  • 图 1.1.2 擦除内部 FLASH 流程
    在这里插入图片描述

a) 检查FLASH_CR[LOCK]得知是否解锁,如果没解锁则先解锁;
b) 检查FLASH_SR[BSY]得知是否处于忙状态,如果忙则等待其变为空闲;
c) 如果是页擦除,设置FLASH_CR[PER]为1,即页擦除模式,再设置FLASH_AR指定擦除地址;如果是批量擦除,设置FLASH_CR[MER]为1,即主存储器所有页(批量)擦除模式;
d) 设置FLASH_CR[STRT]为1,启动擦除;
e) 检查FLASH_SR[BSY]直至擦除完成;
f) 如果是页擦除,读取被擦除的页并检查;如果是批量擦除,读取所有的页并检查;

  • 写数据
    擦除完后,便可以向FLASH写数据,设置步骤如图 1.1.3 所示。
  • 图 1.1.3 写内部 FLASH 流程
    在这里插入图片描述

a) 检查FLASH_CR[LOCK]得知是否解锁,如果没解锁则先解锁;
b) 检查FLASH_SR[BSY]得知是否处于忙状态,如果忙则等待其变为空闲;
c) 设置FLASH_CR[PG]为1,即Flash编程模式;
d) 向主存储器的指定地址写入数据,每次写入数据的长度为半字(16位);
e) 检查FLASH_SR[BSY]直至写完成;
f) 读取被写地址并检查写入的数据;

  • 上锁
    写Flash操作结束后,需要设置FLASH_CR[LOCK]为1,使FPEC模块重新上锁,以防止数据被不小心修改。

1.2 硬件设计

内部Flash的读写不涉及硬件设计。

1.3 软件设计

1.3.1 软件设计思路

实验目的:本实验对内部Flash进行读写。

  1. 实现Flash读写函数;
  2. 主函数编写控制逻辑:产生若干随机数,按键按下,将数据写入内部Flash,再读出对比;

1.3.2 软件设计讲解

  1. 设置等待周期

前面提到,不同的系统时钟,需要设置对应的等待周期,以确保能正确读取内部Flash。当系统时钟为72MHz,则需要设置2个等待周期(LATENCY)。

在系统时钟配置函数“SystemClock_Config()”,其实一直都做了这个操作,如代码段 1.3.1 所示。

代码段 1.3.1 系统时钟配置(stm32f1xx_clk.c)

/*
 * @brief System Clock Configuration
 * 	      The system Clock is configured as follow :
 * 		  System Clock source = PLL (HSI)
 * 		  SYSCLK(Hz) = 72000000
 * 	      HCLK(Hz) = 72000000
 * 	      AHB Prescaler = 1
 * 		  APB1 Prescaler = 2
 * 		  APB2 Prescaler = 1
 * 		  PLLMUL = 9
 * 		  Flash Latency(WS) = 2
 * @param None
 * @retval None
*/
void SystemClock_Config(void)
{
	RCC_OscInitTypeDef RCC_OscInitStruct = {0};
	RCC_ClkInitTypeDef RCC_ClkInitStruct = {0};
	
	RCC_OscInitStruct.OscillatorType = RCC_OSCILLATORTYPE_HSE;
	RCC_OscInitStruct.HSEState = RCC_HSE_ON;
	RCC_OscInitStruct.HSEPredivValue = RCC_HSE_PREDIV_DIV1;
	RCC_OscInitStruct.HSIState = RCC_HSI_ON;
	RCC_OscInitStruct.PLL.PLLState = RCC_PLL_ON;
	RCC_OscInitStruct.PLL.PLLSource = RCC_PLLSOURCE_HSE;
	RCC_OscInitStruct.PLL.PLLMUL = RCC_PLL_MUL9;
	if (HAL_RCC_OscConfig(&RCC_OscInitStruct) != HAL_OK)
	{
	while(1);
	}
	
	/** Initializes the CPU, AHB and APB busses clocks
	*/
	RCC_ClkInitStruct.ClockType      = RCC_CLOCKTYPE_HCLK|RCC_CLOCKTYPE_SYSCLK
								       |RCC_CLOCKTYPE_PCLK1|RCC_CLOCKTYPE_PCLK2;
	RCC_ClkInitStruct.SYSCLKSource   = RCC_SYSCLKSOURCE_PLLCLK;
	RCC_ClkInitStruct.AHBCLKDivider  = RCC_SYSCLK_DIV1;
	RCC_ClkInitStruct.APB1CLKDivider = RCC_HCLK_DIV2;
	RCC_ClkInitStruct.APB2CLKDivider = RCC_HCLK_DIV1;
	
	if (HAL_RCC_ClockConfig(&RCC_ClkInitStruct, FLASH_LATENCY_2) != HAL_OK)
	{
		while(1);
	}
}

在42行,使用“HAL_RCC_ClockConfig()”配置RCC结构体“RCC_ClkInitStruct”时,另一个参数就是Flash延时,传入“FLASH_LATENCY_2”表示设置2个等待周期。

  1. 读内部Flash

读内部Flash比较简单,直接通过指针获取或者使用C库提供的内存拷贝函数获取,如代码段 1.3.2 所示。
代码段 1.3.2 读内部 Flash(driver_flash.c)

/*
 * 函数名:void ReadFlash(uint32_t startAddr, uint32_t *pdata, uint32_t length)
 * 输入参数:startAddr-> 读首地址; pdata -> 读出的数据指针; length -> 读的字节个数
 * 输出参数:无
 * 返回值:无
 * 函数作用:读内部 flash
*/
void ReadFlash(uint32_t startAddr, uint32_t *pdata, uint32_t length)
{
#if 1
	// 方式一:使用指针获取数据
	uint16_t i = 0;
	for(i=0; i<length; i++) // 读取 length 个数据
	{
		pdata[i]=*(uint32_t*)startAddr; // 将指定地址数据保存到 pata
		startAddr = startAddr + 4; // 地址加 4,即下一个 32 位数据位置
	}
#else
	// 方式二:使用 C 库的内存拷贝函数获取数据
	memcpy((uint32_t*)pdata, (uint32_t*)startAddr, sizeof(uint32_t)*length);
#endif
}
  • 11~17行:使用指针方式获取数据;
  • 19~20行:使用C库的内存拷贝函数获取数据;
  1. 写内部Flash

写内部Flash,按照前面介绍的解锁->擦除->写数据->上锁顺序,依次操作即可,如代码段 1.3.3 所示。
代码段 1.3.3 写内部 Flash(driver_flash.c)

/*
 * 函数名:void WriteFlash(uint32_t startAddr, uint32_t *pdata, uint32_t length)
 * 输入参数:startAddr-> 写首地址; pdata -> 写的数据指针; length -> 写的字节个数
 * 输出参数:无
 * 返回值:无
 * 函数作用:写内部 flash
*/
void WriteFlash(uint32_t startAddr, uint32_t *pdata, uint32_t length)
{
	uint32_t i = 0;
	uint32_t address = startAddr;
	
	HAL_FLASH_Unlock(); // 解锁
	
	EraseFlash(startAddr, length); // 擦除
	
	for(i=0; i<length;i++) // 开始写数据
	{
		if (HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, address, pdata[i]) == HAL_OK)
		{
			address = address + 4;
		}
	}
	HAL_FLASH_Lock(); // 上锁
}
  • 13行:使用HAL库提供的“HAL_FLASH_Unlock()”函数解锁Flash;
  • 15行:擦除Flash,该函数的实现,后面讲解;
  • 17~23行:使用HAL库提供的“HAL_FLASH_Program()”函数写数据,这里写入的数据类型支持半字节(half-word,16位)、一字节(word,32位)、双字节(double word,64位),但实际最后都是以半字节16位写入;这里传入的数据是uint32_t类型,因此每次写一字节;
  • 24行:使用HAL库提供的“HAL_FLASH_Lock()”函数上锁Flash;

在写Flash之前,需要进行擦除操作。擦除有两种方式,页擦除(Page Erase)和批量擦除(Mass Erase),这里不能使用批量擦除,会导致下载测试程序也被擦除,无法执行代码,因此只能使用页擦除。

页擦除的处理方式也有两个思路,一个是根据要写入的大小,计算要擦除页数,对应擦除;另一个则是擦除整个测试区域的Flash。

在擦除之前,先理解如代码段 1.3.4 所示的几个宏定义。

代码段 1.3.4 测试相关宏定义(driver_flash.h)

#define TEST_CODE_SIZE (1024*8) // 本程序代码约为 7.89KB
#define TEST_PAGE_ADDR (0x08000000 + TEST_CODE_SIZE) // 写起始地址需要在 FLASH 上代码之后
#define TEST_PAGE_SIZE (1024*10) // 10KB
#define TEST_PAGE_END_ADDR (TEST_PAGE_ADDR + 4*TEST_PAGE_SIZE)// 用于测试的 Flash 空间结束地址
  • 1行:定义本程序占用Flash的大小。可通过生成的Bin文件大小得知,如图 1.3.1 所示,“InternalFlash.bin”的大小为8088字节。也可通过Keil编译完成的提示,计算得到占用Flash大小,如图 1.3.2 所示,Code表示代码占用空间,存放在Flash;RO-data表示只读常量占用空间,存放在Flash;RW-data表示初始化后的变量占用空间,存放在Flash;ZI-data表示未初始化的变量占用空间,它存放在BSS段,不占用Flash空间。因此,7640+420+28=8088字节,与bin文件信息一致;
  • 2行:定义用户写Flash的起始地址;0x0800000为内部Flash的起始地址,从该地址之后,存放本程序,我们只能操作本程序占用空间之后的Flash空间,否则将破坏本程序,无法执行;
  • 3行:定义可用于操作的Flash大小,理论上余下的512KB-8KB=504KB空间都可以使用;
  • 4行:定义可用于操作的Flash的结束地址;
    图 1.3.1 bin 文件信息
    在这里插入图片描述
  • 图 1.3.2 Keil 编译完成提示
    在这里插入图片描述
    了解前面几个宏定义后,再来看如代码段 1.3.5 所示的擦除函数。

代码段 1.3.5 擦除内部 Flash(driver_flash.c)

/*
 * 函数名:void EraseFlash(uint32_t addr, uint32_t length)
 * 输入参数:addr -> 擦除地址; length -> 擦除的字节个数
 * 输出参数:无
 * 返回值:无
 * 函数作用:擦除内部 flash
*/
void EraseFlash(uint32_t addr, uint32_t length)
{
	uint32_t nPageError = 0;
	
	EraseInitStruct.TypeErase = FLASH_TYPEERASE_PAGES; // 页擦除
	EraseInitStruct.PageAddress = addr; // 擦除的起始地址
	
#if 1
	// 方式一:只擦除用到的页
	EraseInitStruct.NbPages = (addr + length*4)/FLASH_PAGE_SIZE - addr/FLASH_PAGE_SIZE + \
							  (((addr + length*4)%FLASH_PAGE_SIZE)?1:0);
#else
	// 方式二:擦除整个测试页
	EraseInitStruct.NbPages = (TEST_PAGE_END_ADDR - addr) / FLASH_PAGE_SIZE;
#endif

	if (HAL_FLASHEx_Erase(&EraseInitStruct, &nPageError) != HAL_OK) // 擦除 Flash
	{
		Error_Handler();
	}
}
  • 12行:设置页擦除方式,这里只能为页擦除;
  • 13行:设置擦除的起始地址,仅用于页擦除模式;
  • 16~18行:设置需要擦除的页数;计算要擦除的整页数,加上不足一页时擦除一页,即为需要使用/擦除的页;
  • 20~21行:设置需要擦除的页数;擦除整个定义用于操作的Flash;
  • 24~27页:调用HAL提供的“HAL_FLASHEx_Erase()”函数进行擦除;
  1. 主函数控制逻辑

在主函数里,初始化系统时钟、打印串口、按键后,循环检测按键是否按下。当按键按下后,产生随机数据,然后写入内部Flash,再读出比较,如代码段 1.3.6 所示。

代码段 1.3.6 主函数控制逻辑(main.c)

while(1)
{
		if(rw_flag)
		{
			rw_flag = 0;
			
		// 准备随机生成的写入数据
		for(i=0;i< TEST_NUM;i++)
		{
			write_data[i] = rand();
		}
		
		// 读写 FLASH
		if (TEST_NUM > TEST_PAGE_SIZE)
		{
			printf("ERROR: 超出测试 FLASH 空间\n\r");
			break;
		}
		WriteFlash(TEST_PAGE_ADDR, write_data, TEST_NUM); // 写内部 FLASH
		ReadFlash(TEST_PAGE_ADDR, read_data, TEST_NUM); // 读内部 FLASH
		
		// 判断读写的数据是否一致
		for(i=0;i< TEST_NUM;i++)
		{
			if(read_data[i] != write_data[i]) // 如果出现不一致
			{
				printf("Error! \n\r");
				printf("ReadData[%03d]=0x%08x WriteData[%03d]=0x%08x \n\r", i, read_data[i], i, write_data[i]);
				break;
			}
			else
			{
				printf("ReadData[%03d]=0x%08x WriteData[%03d]=0x%08x \n\r", i, read_data[i], i, write_data[i]);
			}
		}
		if(i == TEST_NUM) // 每次校验都通过
		{
			printf("----内部 Flash 读写校验通过----\n\r");
		}
	}
}

2. 串口IAP更新

2.1 关于IAP

2.1.1 烧录方式介绍

在实际项目开发过程中,通常使用仿真器通过JTAG等接口进行调试和程序下载。在产品发布后,可能需要软件更新升级,此时无法要求用户使用仿真器烧录,这就需要实现通过程序实现应用程序的更新升级。

首先介绍常见的几种烧录方式。

  • 在电路编程(In Circuit Programing,ICP):不要求MCU内部有任何程序,直接对MCU的Flash写入程序。例如平时使用仿真器通过JTAG、SWD等方式烧录;
  • 在系统编程(In System Programing,ISP):利用半导体厂商在MCU内部固化的一段程序(通常称之为引导加载程序,BootLoader),通过串口等接口,实现向Flash写入程序。例如STM32通过调整BOOT引脚,从内部系统存储器启动,启动后接收串口数据,写入到Flash,实现程序的烧录;
  • 在应用编程(In Applicating Programing,IAP):开发者自行设计BootLoader程序,从串口、CAN、以太网等获取程序后,写入到Flash,实现程序的烧录;

2.1.2 IAP 原理介绍

本实验通过IAP实现应用程序的更新,这里重点介绍一下IAP的实现原理。

通常MCU内部只会有一个程序,而IAP方案则有两个程序,一个是引导加载程序BootLoader,一个是用户程序APP。MCU上电后,首先运行BootLoader,在BootLoader里选择是运行APP程序还是更新APP程序。

STM32F103ZET6的Flash的地址为0x0800 000~0x0807 FFFF,当STM32从Flash启动,即从0x0800 0000处读取代码数据。由分析STM32的启动文件“startup_stm32f103xe.s”,可得知STM32代码在Flash的分布如图2.1.1 所示。首先存放的是栈顶指针对应的地址,然后是各中断向量地址,再是各中断服务函数,最后是主函数等。当Flash内部只有一个程序时,0x0800 0000存放堆栈栈顶的地址,0x0800 0004存放复位中断向量的地址,即复位中断程序“Reset_Handler(void)”,该函数又调用__main,切换到C语言主函数执行。在主函数产生中断时,再跳到对应中断向量表,执行中断服务程序。

  • 图2.1.1 STM32 代码在 Flash 的分布
    在这里插入图片描述
    当Flash内部有两个程序时,MCU上电后,先执行到Bootloader的主函数。Bootloader的代码可实现跳到0x0800 C000处(APP代码存放地址,大于Bootloader代码即可),从而运行APP主函数。同时Bootloader的代码可实现从其它通信接口(比如串口)获取APP程序,然后利用前面实验学到读写内部Flash操作,将APP程序写入Flash的0x0800 C000中,再重新跳转到APP程序,实现APP程序的更新。

  • 图 2.1.2 IAP 方案代码在 Flash 的分布
    在这里插入图片描述

2.1.3 Ymodem 协议

前面提到,Bootloader的代码可实现从其它通信接口获取APP程序。这里的其它通信接口可以是串口、以太网口、I 2 C、SPI、CAN等,理论上只要是能传输数据的通信接口,都可以用于从外部获取APP程序。

其中通过串口获取数据最为常见,因此这里以串口为例,介绍STM32通过串口接收APP程序。当电脑与STM32通过串口连接后,需要Windows将APP程序通过串口发送出去,这里可以自己Windows编程制定传输协议实现上位机软件,也可以直接使用串口工具(比如MobaXterm、SecureCRT)的文件传输协议,这里直接使用串口工具的文件传输协议。

串口文件传输协议通常有如下几种:

  • Xmodem:早期最广泛的文件传输协议之一,将128个字节作为一个数据块进行传输,并且对每个数据块都进行循环冗余校验(Cyclic Redundancy Check, CRC),每检查一个数据包正确后返回一个ACK响应;
  • Ymodem:Xmodem的改进版,在一些地方也被称为Xmodem-1K,将1024个字节作为一个数据块进行传输,并对每个数据块也都进行CRC校验,每检查一个数据包正确后返回一个ACK响应;
  • Zmodem:采用流式传输,不需要每个数据包传输后发送ACK确认,当校验出错时,只发送出错数据包,从而提高效率;

Ymodem涉及的控制字符如表 2.1.1 所示,传输协议如图 2.1.3所示。整个传输过程可分为四个阶段:

  • 阶段一:接收 端 等待数据接收此时接收端不断发出字符C。
  • 阶段 二 : 首个数据 包的发送及确认发送端发送首个数据包,该数据包里面有文件名信息。数据包格式为:类型+序列号+序列号反码+数据内容+CRC高8位+CRC低8位。
  • 类型:用于表示数据包大小。当类型为SOH时,表示本数据包携带的数据长度为128字节;当类型为STX时,表示本数据包携带的数据长度为1024字节;
  • 序列号:表示数据包的序号,从0开始,依次递增;
  • 序列号反码:表示数据包的序号反码,值为0xFF-序列号;
  • 数据内容:首个数据包包含传输的文件名和文件大小,以ASCII字符串存放(空字符结尾),余下部分用空字符填充;
  • CRC高8位:表示CRC的高8位,用于CRC-16校验;
  • CRC低8位:表示CRC的低8位,用于CRC-16校验;

发送端发送完数据包后,接收端返回ACK信号,并发送字符C。

  • 阶段三 : 文件内容的传输 及确认
    随后发送端将不断发送数据包,接收端收到数据包后CRC校验无误,返回ACK信号。

  • 阶段四 : 文件传输结束发送端的数据包发送完后,发送EOT信号,接收端先返回NAK信号,随后发送端再发送EOT信号,接收端返回ACK信号,再不断发出字符C,进入下一次接收准备。
    以上就是标准Ymodem协议流程,实际开发中,可能因为串口工具的不同,导致协议略有差异,根据实际情况对应修改即可。

  • 表 2.1.1 Ymodem 协议相关控制字符
    在这里插入图片描述
    在这里插入图片描述

  • 图 2.1.3 Ymodem 协议传输示意图

2.2 硬件设计

串口IAP升级不涉及新硬件设计。

2.3 软件 设计

2.3.1 软件设计思路

实验目的:本实验通过串口,使用Ymodem协议下载程序,然后将程序烧写到Flash,实现对应用程序的更新/升级。

  1. 实现操作菜单,根据用户选择,对应执行启动应用程序或下载升级程序;
  2. 实现跳转到应用程序地址;
  3. 实现Ymodem协议接收数据,并实现写Flash;

2.3.2 软件设计讲解

  1. 实现操作菜单
    Bootloader启动后,首先应显示操作菜单供用户操作,如果用户无操作,将倒计时自动选择,如代码段2.3.1 所示。

代码段 2.3.1 操作菜单(menu.c)

	Serial_PutString((uint8_t *)"\n\r");
	Serial_PutString((uint8_t *)"===================== 主菜单 ========================\n\r");
	Serial_PutString((uint8_t *)"输入 1:加载内部 FLASH 程序 (5 秒后默认选择)\n\r");
	Serial_PutString((uint8_t *)"输入 2:通过串口 Ymodem 协议下载程序到内部 FLASH\n\r");
	Serial_PutString((uint8_t *)"输入 3:通过串口 Ymodem 协议下载程序到内部 FLASH 并启动\n\r");
	Serial_PutString((uint8_t *)"=====================================================\n\r");
	
	// 清除串口数据寄存器内容
	__HAL_UART_FLUSH_DRREGISTER(&husart);
	
	// 接收用户输入的选择或倒计时自动选择 2
	for (;time_out >= 0; time_out--)
	{
		HAL_UART_Receive(&husart, &key, 1, RX_TIMEOUT_1S);
		
		Serial_PutString((uint8_t *)"倒计时:");
		Serial_PutByte(time_out+48); // 数字转化为对应的 ASCII 字符
		Serial_PutString((uint8_t *)"\r");
		if (key > 0)
		{
			time_out = 5;
			break;
		}
		else if (time_out == 0)
			key = '1';
	}
  • 1~6行:串口打印操作提示信息:输入1则从内部Flash启动;输入2则通过串口下载程序;输入3则先下载程序再启动;
  • 9行:清除串口数据寄存器内容,准备接收串口收到的数据;
  • 12~26行:接收用户输入的选择或倒计时自动选择2;
    • 12行:由time_out限制循环次数;
    • 14行:串口接收数据保存到变量key,超时时间为1秒钟;
    • 16~18行:打印倒计时;
    • 19~23行:如果收到串口数据,跳出循环;
    • 24~25行:如果time_out秒后,依旧没有输入,则默认输入了‘1’;
  1. 实现跳转到应用程序地址

当无任何输入超时或用户输入‘1’,则尝试跳到应用程序地址,执行应用程序,如代码段 36.3.2 所示。代码段 2.3.2 跳转到应用程序(menu.c)

	case '1' :
	Serial_PutString((uint8_t *)"\n\r......开始加载内部 FLASH 程序......\n\r\n\r");
	
	// 根据程序加载地址保存的栈地址内容,判断加载地址是否有程序,并检查大小合法性
	if (((*(__IO uint32_t*)APPLICATION_ADDRESS) & 0x2FFF0000 ) == 0x20000000)
	{
		__set_MSP(*(__IO uint32_t*) APPLICATION_ADDRESS); // 设置用户程序栈指针
		JumpAddress = *(__IO uint32_t*) (APPLICATION_ADDRESS + 4); // 得到用户程序地址(复位中断向量地址)
		JumpToApplication = (pFunction) JumpAddress;
		JumpToApplication(); // 跳至用户程序地址执行
	}
	else
	{
		Serial_PutString((uint8_t *)"---> 错误:内部 FLASH 程序无程序或程序损坏,请先重新下载程序......\n\r");
	}
	break;
  • 5行:根据程序加载地址(自定义的0x0800 C000)保存的栈地址内容(等于APP的RW-data+ZI-data+0x20000000,即APP能用到栈最高地址)。将该值和0x2FFF 0000与运算,判断该值是否在SRAM(0x2000 0000~0x2000 FFFF)范围内,在范围内说明加载地址保存的APP程序栈地址正确,即APP程序存在,且没超出SRAM范围;
  • 6行:设置用户程序栈指针;
  • 7行:参考前面代码在Flash的分布知识,0x0800 C004存放复位中断向量,即真正用户程序地址;
  • 9~10行:跳至用户程序地址执行;
  1. 实现Ymodem 协议接收数据

当用户输入‘2’,则尝试通过串口下载程序到内部Flash,如代码段 2.3.3 所示。

代码段 2.3.3 调用串口下载(menu.c)

	case '2' :
	Serial_PutString((uint8_t *)"\n\r");
	RLED(ON); // 准备下载,点亮红灯
	SerialDownload(); // 从串口下载数据到内部 FLASH
	RLED(OFF); // 下载完成,点亮绿灯
	GLED(ON);
	key = 0;
	break;
  • 3~6行:下载前先三色LED亮红色,下载完后后亮绿色,串口下载如代码段 2.3.4 所示;

代码段 2.3.4 串口下载(menu.c)

/*
 * 函数名:void SerialDownload(void)
 * 输入参数:无
 * 输出参数:无
 * 返回值:无
 * 函数作用:串口通过 Ymodem 协议下载程序
*/
void SerialDownload(void)
{
	uint8_t number[11] = {0};
	uint32_t size = 0;
	COM_StatusTypeDef result;
	
	Serial_PutString((uint8_t *)"等待文件发送……(输入字母'A 或 a'退出)\n\r");
	result = Ymodem_Receive(&size); // 串口通过 Ymodem 协议接收数据
	HAL_Delay(10);
	if (result == COM_OK) // 接收正常
	{
		Serial_PutString((uint8_t *)"3 下载成功!\n\r");
		Serial_PutString((uint8_t *)"下载文件名: ");
		Serial_PutString(aFileName);
		
		Int2Str(number, size); // int 类型转 str 字符串
		Serial_PutString((uint8_t *)"\n\r 大小: ");
		Serial_PutString(number);
		Serial_PutString((uint8_t *)" Bytes\n\r");
	}
	else if (result == COM_LIMIT)
	{
		Serial_PutString((uint8_t *)"\n\r 下载程序大小超过 FLASH 空间!\n\r");
	}
	else if (result == COM_DATA)
	{
		Serial_PutString((uint8_t *)"\n\r 数据校验错误!\n\r");
	}
	else if (result == COM_ABORT)
	{
		Serial_PutString((uint8_t *)"\n\r 用户中断操作!\n\r");
	}
	else
	{
		Serial_PutString((uint8_t *)"\n\r 接收文件失败!\n\r");
	}
}
  • 15行:使用Ymodem接收数据,然后根据返回结果,打印对应提示信息;

Ymodem协议中,发送端发过来的数据都是数据包的形式,获取数据包如代码段 2.3.5 所示。

代码段 2.3.5 获取数据包(ymodem.c)

/*
 * 函数名:static HAL_StatusTypeDef ReceivePacket(uint8_t *p_data, uint32_t *p_length, uint32_t timeout)
 * 输入参数:timeout->重新接收超时时间
 * 输出参数:p_data->指向接收的数据 p_length->指向接收数据长度
 * 返回值:HAL_OK->正常返回 HAL_BUSY->被用户中止
 * 函数作用:接收来自发送者的数据包
*/
static HAL_StatusTypeDef ReceivePacket(uint8_t *p_data, uint32_t *p_length, uint32_t timeout)
{
		uint32_t crc;
		uint32_t packet_size = 0;
		HAL_StatusTypeDef status;
		uint8_t char1;
	
		*p_length = 0;
		status = HAL_UART_Receive(&husart, &char1, 1, timeout); // 接收一个字节数据
	
		if (status == HAL_OK)
		{
			switch (char1) // 判断接收的内容
			{
				case SOH: // SOH 表示本数据包大小为 128 字节
					packet_size = PACKET_SIZE; // 定义包大小 128 字节
					break;
				case STX: // STX 表示本数据包大小为 1K 字节
					packet_size = PACKET_1K_SIZE; // 定义包大小 1K
					break;
				case EOT: // EOT 表示传输完成
					break;
				case CA: // 连续收到两个 CA(Cancel)表示取消传输
				if ((HAL_UART_Receive(&husart, &char1, 1, timeout) == HAL_OK) && (char1 == CA)) //再接收到一个 CA
						packet_size = 2; // 定义包大小 2 字节
					else
						status = HAL_ERROR;
					break;
				case ABORT1: // ABORT 表示中止(收到 A/a),返回 HAL_BUSY
				case ABORT2:
					status = HAL_BUSY;
					break;
				default:
					status = HAL_ERROR;
				break;
		}
		*p_data = char1; // 保存接收的数据
		
		if (packet_size >= PACKET_SIZE ) // 如果数据包>=128 字节,即接收数据包
		{
		// 串口接收数据(保存位置:p_data[2]开始 保存长度:数据长度+数据包其它信息-数据包起始信号
			status = HAL_UART_Receive(&husart, &p_data[PACKET_NUMBER_INDEX], packet_size + PACKET_OVERHEAD_SIZE - 1, timeout);
		
			if (status == HAL_OK) // 简单的检查数据包完整性
			{
			if (p_data[PACKET_NUMBER_INDEX] != ((p_data[PACKET_CNUMBER_INDEX]) ^ NEGATIVE_BYTE)) // 反码校验
				{
				packet_size = 0;
				status = HAL_ERROR;
			}
			else // CRC 校验
			{
				crc = p_data[ packet_size + PACKET_DATA_INDEX ] << 8; // CRC 高 8 位
				crc += p_data[ packet_size + PACKET_DATA_INDEX + 1 ]; // CRC 低 8 位
				if (Cal_CRC16(&p_data[PACKET_DATA_INDEX], packet_size) != crc ) // CRC16 校验
				{
					packet_size = 0;
					status = HAL_ERROR;
				}
			}
		}
		else
		{
			packet_size = 0;
		}
	 }
	}
	*p_length = packet_size; // 返回包大小
	return status;
}
  • 16行:首先获取数据包第一个字节数据;
  • 18~43行:根据第一个字节数据内容,可得知数据包大小,以及修改不同状态标志;
  • 46~70行:如果发过来的是数据包,需要对数据进行校验;
  • 49~50行:接收余下的数据包,长度为:数据长度+数据包其它信息-数据包起始信号;
  • 54~58行:利用反码校验数据;
  • 59~68行:利用CRC-16校验数据;

收到数据包,校验无误后,再对数据包的内容进行解析。该部分代码比较长,这里分段讲解,如代码段2.3.6 所示。

代码段 2.3.6 数据包解析 1(ymodem.c)

switch (ReceivePacket(aPacketData, &packet_length, DOWNLOAD_TIMEOUT)) // 接收数据包
{
	case HAL_OK: //正常接收
	……
	case HAL_BUSY: // 中止传输
		Serial_PutByte(CA);
		Serial_PutByte(CA);
		result = COM_ABORT;
		break;
	default:
		if (session_begin > 0)
		{
		errors ++;
		}
		
		if (errors > MAX_ERRORS) // 中止通信
		{
		Serial_PutByte(CA);
		Serial_PutByte(CA);
		}
		else
		{
		Serial_PutByte(CRC16); // 发送字符 C,请求数据包
		}
		break;
  • 1行:接前面接收数据包,根据返回状态;
  • 3~4行:正常数据包的处理,放在后面讲解;
  • 5~9行:如果想中止传输,输入A/a后,将返回HAL_BUSY,发送“CA“信号,取消传输;
  • 10~25行:其它情况。根据错误次数决定中止传输,或者发送“C”信号,等待下一次传输;

正常数据包的解析如代码段 2.3.7 所示。

代码段 2.3.7 数据包解析 2(ymodem.c)

switch (packet_length) // 根据数据长度对应处理
{
	case 2: // 发送方中止
		Serial_PutByte(ACK); // 发送 ACK
		result = COM_ABORT;
		break;
	case 0: // 正常传输结束
		Serial_PutByte(ACK); // 发送 ACK
		file_done = 1;
		break;
	default: // 正常数据包
		if (aPacketData[PACKET_NUMBER_INDEX] != packets_received) // 数据包编号和接收数量序号不一致
		{
			Serial_PutByte(NAK); // 发送 NAK 信号
		}
	else
	{
		if (packets_received == 0) // 第一个数据包,为起始帧,包含文件信息
		{
		
			if (aPacketData[PACKET_DATA_INDEX] != 0) // 数据包数据不为空
			{
				// 提取文件名
				i = 0;
				file_ptr = aPacketData + PACKET_DATA_INDEX;
				while ( (*file_ptr != 0) && (i < FILE_NAME_LENGTH))
				{
					aFileName[i++] = *file_ptr++;
				}
				// 提取文件大小
				aFileName[i++] = '\0';
				i = 0;
				file_ptr ++;
				while ( (*file_ptr != ' ') && (i < FILE_SIZE_LENGTH))
				{
					file_size[i++] = *file_ptr++;
				}
				file_size[i++] = '\0';
				Str2Int(file_size, &filesize); // 字符串转 int 类型
				
// 测试要发送的 image 是否大于 FLASH 大小
				if (*p_size > (USER_FLASH_SIZE + 1))
				{
					// 超出限制,结束会话
					tmp = CA;
					HAL_UART_Transmit(&husart, &tmp, 1, NAK_TIMEOUT);
					HAL_UART_Transmit(&husart, &tmp, 1, NAK_TIMEOUT);
					result = COM_LIMIT;
				}
				
				//先擦除用户程序区域
				FLASH_If_Erase(APPLICATION_ADDRESS);
				*p_size = filesize;
				
				Serial_PutByte(ACK); // 数据帧正常,返回 ACK 信号
				Serial_PutByte(CRC16); // 返回 C 信号
			}
			else // 文件头数据包为空,结束会话
			{
				Serial_PutByte(ACK);
				file_done = 1;
				session_done = 1;
				break;
			}
		}
		else // 数据包
		{
			ramsource = (uint32_t) & aPacketData[PACKET_DATA_INDEX]; // 得到数据包数据
			
			// 将接收的数据写入 FLASH
			if (FLASH_If_Write(flashdestination, (uint32_t*) ramsource, packet_length/4) == FLASHIF_OK
)
			{
				flashdestination += packet_length;
				Serial_PutByte(ACK);
			}
			else // 写入 FLASH 出错
			{
				// 数据错误,结束会话
				Serial_PutByte(CA);
				Serial_PutByte(CA);
				result = COM_DATA;
			}
		}
		packets_received ++; // 接收数据包数量加 1
		session_begin = 1; // 会话开始标记
	}
	break;
}
break;
  • 1行:根据数据包长度,进行对应处理;
  • 3~6行:数据长度为2,即收到两个CA(Cancel),表示取消传输,返回ACK结束;
  • 7~10行:数据长度为0,正常传输完成,返回ACK结束;
  • 11~91行:其它数据长度;
    • 12~15行:数据包编号和接收数量序号不一致,返回NAK信号;
    • 18行:如果是第一个数据包,则包含文件信息;
    • 21~40行:提取文件名信息、文件大小信息;
    • 42~50行:判断文件大小是否超过定义的Flash可用大小,如果超过返回“CA”信号,结束会话;
    • 53~54行:擦除Flash,为后面写Flash做准备;
    • 56~57行:首个数据包正常,返回ACK和字符C信号;
    • 67行:首个数据包后,后面传的数据包都是文件内容;
    • 69行:获取数据包数据;
    • 72~84行:将接收的数据写入Flash,关于擦除、写Flash这块的函数操作,与前面读写Flash讲解的一致,这里就不再赘述;
  1. 主函数控制逻辑

在主函数里,初始化串口、Flash、LED灯,显示主菜单,如代码段 2.3.8 所示。

代码段 2.3.8 主函数控制逻辑(main.c)

	// 初始化内部 Flash
	FLASH_If_Init();
	
	// 初始化 LED 灯
	LedGpioInit();
	
	// 显示主菜单
	Main_Menu();
	
	while(1{
	
	}

3. 读写外部SRAM

3.1 关于外部SRAM

3.1 FSMC 接口介绍

STM32F103ZET6的内部有64KB的SRAM,可以满足大部分的应用场景,但在一些采集数据比较多的项目、应用算法或GUI(Graphical User Interface,图形用户界面)等应用场景,内部SRAM可能就不够了,此时就需要外部扩展SRAM。

STM32F103系列中,100脚及以上的MCU,都有一个FSMC(Flexible Static Memory Controller,灵活的静态存储器控制器),该外设是STM32设计的一种存储器控制技术,仅适用STM32系列的MCU。它可以驱动SRAM、NOR Flash、NAND Flash等存储器,但不能驱动SDRAM等动态存储器。

FSMC内部框图如图 3.1.1 所示,可以看作四部分组成。

① 时钟源
结合时钟树” 可知,FSMC挂在AHB总线下。因此,在使用FSMC时,需要先使能AHB总线时钟。

②AHB 总线 接口
AHB总线接口是CPU、DMA等AHB总线主设备访问FSMC的通道,它负责将AHB总线信号转换成外设通信协议。配置寄存器则描述了扩展外设的具体形式、通信协议和接口形式。

③NOR/PSRAM 存储器控制器
配置寄存器描述外设的特征和时序后,控制器将自动生成对应的驱动时序,以驱动8位、16位、32位的异步SRAM、异步PSRAM或NOR闪存。

④NAND/PC卡 卡 存储器控制器
配置寄存器描述外设的特征和时序后,控制器将自动生成对应的驱动时序,以驱动8位、16位的NAND存储器或16位的PC卡兼容设备。

FSMC所接外设共用一组地址总线(A[25:0])、数据总线(D[15:0])、输出使能(NOE)、写使能(NWE)和输入等待(NWAIT),如图 3.1.1 中的“共享信号”。地址总线用于寻址,数据总线用于传输数据,读写使能控制数据传输的方向,输入等待用于同步数据。这几个信号,是最基础的,基本所有外设都需要。

FSMC为所接外设提供一个片选信号,如NOR/PSRAM中的NE信号、NAND/PC卡中的NCE信号。通过片选信号,可以实现总线的分时复用。

剩下的其它外设接口信号,与所接外设有关,不同的外设需要不同的控制信号,比如NL用于NOR Flash的锁存使能、NBL[1:0]用于PSARM的高低字节输出使能。如表 37.1.1 所示,为NOR/PSRAM存储器控制器接口信号总结。

  • 图 3.1.1 FSMC 内部框图
    在这里插入图片描述
  • 表 3.1.1 NOR/PSRAM 存储器控制器接口信号
    在这里插入图片描述

由STM32存储器映射结构”可知,0x6000 0000-0x9FFF FFFF共计1GB空间为FSMC的存储器映射范围。FSMC把这1GB空间,分为了4个固定大小的存储区域(Bank1~4),每个大小为256MB,每个Bank都由一组独立的配置寄存器控制,相互之间不受干扰,如图 3.1.2 所示。

  • Bank1(0x6000 000~0x6FFF FFFF):用于NOR/PSRAM设备,该区域又被分为4块,每块64MB,可连接4个NOR Flash设备或PSRAM设备,每个区域通过片选引脚NE[4:1]进行选择;
  • Bank2(0x7000 000~0x7FFF FFFF):用于NAND Flash设备;
  • Bank3(0x8000 000~0x8FFF FFFF):用于NAND Flash设备;
  • Bank4(0x9000 000~0x9FFF FFFF):用于PC卡设备;
  • 图 3.1.2 FSMC 存储器地址映射
    在这里插入图片描述

本章实验重点介绍的如何驱动SRAM,因此重点分析NOR/PSRAM控制器,即Bank1。
CPU需要28根AHB地址总线,才能寻址完Bank1的256MB空间(2 28 =256MB),这里用HADDR[27:0]表示需要的地址总线。HADDR[25:0]对应连接外部存储器的FSMC_A[25:0],HADDR[26:27]对应片选信号引脚NE[4:1],如表 3.1.2 所示。

  • 表 3.1.2 Bank1 存储区选择
    在这里插入图片描述
    CPU访问一个地址,只能读取一字节(8位)数据,因此当外部存储器数据宽度不同时,实际向存储器发送的地址也将有所不同,如表 3.1.3 所示。
  • 表 3.1.3 不同数据宽度发送的地址
    在这里插入图片描述
    假设某8位数据宽度的外部存储器接在Bank1的第一区,则FSMC_NE1连接该外部存储器片选,此时HADDR[26:27]值为0x00;FSMC_A[25:0]连接该外部存储器地址引脚。此时一个地址对应一字节(8位)数据,通过HADDR[25:0]寻址该外部存储器,得到8位数据。

假设某16位数据宽度的外部存储器接在Bank1的第二区,则FSMC_NE2连接该外部存储器片选,此时HADDR[26:27]值为0x01,FSMC_A[25:0]连接该外部存储器地址引脚。此时一个地址对应两字节(16位)数据,通过HADDR[25:1]>>1寻址该外部存储器,得到16位数据。

这里解释下为什么对于16位数据宽度的外部存储器,要使用HADDR[25:1]>>1寻址。假设CPU想访问存储器0b0000地址数据,得到16位数据,此时只能获取此16位的低8位。接着想通过0b0001地址访问剩下的8位,却访问的是下一个16位数据。但如果将访问地址右移1位,即无论是0b0000还是0b0001,都是0b0000,再通过NBLx切换高低8位,即可完整的获取存储器0b0000处的16位数据,如图 3.1.3 所示。

  • 图 3.1.3 访问不同数据宽度的存储器
    在这里插入图片描述

NOR/PSRAM控制器支持同步访问和异步访问,这里的同步、异步概念,与前面“15.3 同步/异步通信”的概念类似。

同步访问需要一个时钟信号作为收发双方的参考信号,FSMC使用HCLK分频后的时钟,作为与外部存储器的时钟同步信号FSMC_CLK。

异步访问无需时钟信号,通过提前制定规则保证数据传输的准确,因此FSMC需要设置多个时间参数,比如数据建立时间(Data-Phase Duration,DATAST)、地址保持时间(Address-hold Phase Duration,ADDHLD)和地址建立时间(Address Setup Phase Duration ,ADDSET)。

NOR/PSRAM控制器支持的器件类型众多,不同的器件读写操作时序会有差异,因此控制器通过切换不同的模式,以支持不同的器件,如表 3.1.4 所示。

  • 表 37.1.4 NOR/PSRAM 控制器的异步访问模式
    在这里插入图片描述
    在非扩展模式下(FSMC_BCRx寄存器的EXTMOD位置0),如果连接的是SRAM/PSRAM,则默认访模式为模式1,如果连接的是NOR Flash,则默认访问模式为模式2。

在扩展模式下(FSMC_BCRx寄存器的EXTMOD位置1),支持四种扩展模式:模式A、模式B、模式C和模式D。也可以混合使用模式A、B、C、D的读写操作,比如可以使用模式A进行读,模式B进行写。

本章实验关于SRAM,因此这里介绍模式1和模式A的时序。模式1读写访问外部存储器的时序如图
3.1.4 所示。

FSMC读外部存储器时,首先Bank1区域内片选信号引脚NEx拉低,选中外部存储器所在Bank;同时读使能信号引脚NOE拉低,写使能信号引脚NWE保持高电平;接着地址总线A[25:0]在高低字节选择信号NBL[1:0]的配合下寻址,经历(ADDSET+1)个HCLK周期完成寻址;在随后的(DATASET+1)个HCLK周期里,外部存储器控制数据总线D[15:0]发送数据;最后,再经历2个HCLK周期后,Bank区域内片选信号引脚NEx和读使能信号引脚NOE都恢复为高电平。

FSMC写外部存储器时,首先Bank区域内片选信号引脚NEx拉低,选中外部存储器所在Bank;同时读使能信号引脚NOE拉高,写使能信号引脚NEW先暂时保持高电平;接着地址总线A[25:0]在高低字节选择信号NBL[1:0]的配合下寻址,经历(ADDSET+1)个HCLK周期完成寻址;然后写使能信号引脚NWE拉低,在随后的(DATASET+1)个HCLK周期里,FSMC控制数据总线D[15:0]发送数据,使能信号引脚NEW提前1个HCLK周期拉高;最后,Bank区域内片选信号引脚NEx恢复为高电平,读使能信号引脚NOE都恢复为低电平。

  • 图 3.1.4 FSMC 模式1读写时序
    在这里插入图片描述

模式A读写访问外部存储器的时序和模式1基本类似,如图 37.1.5所示。主要区别在于FSMC读外部存储器时,读使能信号引脚NOE不是一开始就拉高,而是等寻址完后才拉低。

其它几种模式的时序,这里不再过多赘述,读者在需要用到的时候,参考《参考手册》分析即可。
图 3.1.5 FSMC 模式 A 读写时序SRAM 芯片介绍100ASK_STM32F103开发板,板载一颗ISSI公司(Integrated Silicon Solution, Inc)生产的SRAM芯片IS62WV51216BLL。它是一颗16位数据宽度的512K(512K*16/8=1MB)大小的CMOS静态内存芯片。

如图3.1.6 所示为该芯片的框图和引脚描述,整个框图可以看作两部分组成。

  • 图 3.1.6 IS62WV51216BLL 框图和引脚描述
    在这里插入图片描述

①地址 、数据部分

  • A0-A18:为地址输入,一共19根,寻址范围为219=524288/1024=512K,每个地址存放16Bit数据,总容量1MB;
  • I/O0-I/O15:为数据输入/输出,一共16根,传输16Bit数据;

②控制部分

  • CS1,CS2:为片选信号,其中有上划线的CS1为低电平有效,CS2为高电平有效;CS2仅存在于48脚的mini BGA封装,开发板上使用的44脚的TSOP封装没有该脚;
  • OE :输出(读)使能信号,低电平有效;
  • WE :写使能信号,低电平有效;
  • UB :高字节控制信号(I/O8-I/O15),低电平有效;
  • LE :低字节控制信号(I/O0-I/O17),低电平有效;

最后,再看一下IS62WV51216BLL的真值表,如图 37.1.7 所示,重点关注后面的读、写模式。
在读模式下,写使能 WE 拉高,片选CS1 拉低,读使能OE 拉低, 低字节控制信号LE 拉低则I/O0-I/O7输出数据, 高字节控制信号 UB拉低则I/O8-I/O15输出数据。

在写模式下,写使能 WE拉低,片选CS1拉低,读使能OE拉高, 低字节控制信号LE拉低则I/O0-I/O7输入数据, 高字节控制信号 UB拉低则I/O8-I/O15输入数据。

  • 图 37.1.7 真值表
    在这里插入图片描述

3.2 硬件设计

如图 3.2.1 为开发板SRAM部分的原理图,U8为SRAM芯片IS62WV51216BLL。FSMC的A0-A18接在SRAM的A0-A18,FSMC的D0-D15接在SRAM的I/O0~I/O15,FSMC_NE3接在SRAM的片选上(即选择的Bank1的第三区域),FSMC_NWE接在SRAM的 WE ,FSMC_NOE接在SRAM的 OE, FSMC_NBL0和NBL1接在SRAM的 LE和UB 。

涉及的GPIO端口包含D、E、F、G,后面初始化引脚时,需要使能这些引脚所在端口的时钟。

  • 图 37.2.1 SRAM 原理图
    在这里插入图片描述

3.3 软件设计

3.3.1 软件设计思路

实验目的:本实验使用FSMC接口对外部SRAM进行读写。

  1. 初始化FSMC相关参数:配置时间参数、模式选择等;
  2. 初始化FSMC涉及的硬件相关参数:初始化时钟、配置涉及的GPIO;
  3. 封装外部SRAM的读写函数;
  4. 主函数编写控制逻辑:产生若干随机数,按键按下,将数据写入外部SRAM,再读出对比;

3.3.2 软件设计讲解

  1. GPIO端口定义

由前面原理图可知,IS62WV51216BLL需要用到40个GPIO,其中地址线19个,数据线16个,控制信号线5个,涉及GPIO端口D、E、F、G,这里先定义涉及的GPIO端口,方便后面初始化使用,如代码段 3.3.1所示。

代码段 37.3.1 相关宏定义(driver_fsmc.h)

/****************** BANK1 Address *****************/
#define BANK1_AREA1_ADDR ((uint32_t)(0x60000000))
#define BANK1_AREA2_ADDR ((uint32_t)(0x64000000))
#define BANK1_AREA3_ADDR ((uint32_t)(0x68000000))
#define BANK1_AREA4_ADDR ((uint32_t)(0x6C000000))

// PD0 PD1 PD4 PD5 PD8 PD9 PD10 PD11 PD12 PD13 PD14 PD15
#define FSMC_GPIOD_PIN ( GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_8 | GPIO_PIN_9 \
                       | GPIO_PIN_10 | GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15)
                       
// PE0 PE1 PE7 PE8 PE9 PE10 PE11 PE12 PE13 PE14 PE15
#define FSMC_GPIOE_PIN ( GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_7 | GPIO_PIN_8 | GPIO_PIN_9 | GPIO_PIN_10 \
					   | GPIO_PIN_11 | GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15)
					   
// PF0 PF1 PF2 PF3 PF4 PF5 PF12 PF13 PF14 PF15
#define FSMC_GPIOF_PIN ( GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4 | GPIO_PIN_5 \
					   | GPIO_PIN_12 | GPIO_PIN_13 | GPIO_PIN_14 | GPIO_PIN_15)
					   
// PG0 PG1 PG2 PG3 PG4 PG5 PG10
#define FSMC_GPIOG_PIN ( GPIO_PIN_0 | GPIO_PIN_1 | GPIO_PIN_2 | GPIO_PIN_3 | GPIO_PIN_4 | GPIO_PIN_5 | GPIO_PIN_10)
  • 1~5行:定义Bank1四个区域的起始地址,这里使用FSMC_NE3片选,即外部SRAM的起始地址为0x68000000;
  • 7~21行:定义FSMC连接外部SRAM用到的GPIO端口;
  1. FSMC 初始化

FSMC的初始化,包含两部分:协议部分和硬件部分。

协议部分初始化如代码段 3.3.2 所示。

代码段 3.3.2 FSMC 协议初始化(driver_fsmc.c)

static FSMC_NORSRAM_TimingTypeDef hfsmc_timing;
static SRAM_HandleTypeDef hsram;
static uint8_t fsmc_rw_flag = 0;

/*
 * 函数名:void FSMC_SRAM_Init(void)
 * 输入参数:无
 * 输出参数:无
 * 返回值:无
 * 函数作用:初始化 FSMC
*/
void FSMC_SRAM_Init(void)
{
	hfsmc_timing.AddressSetupTime = 0x00; 									 // 地址建立时间 ADDSET 范围:0~15 (模式 A 需要设置)
	// hfsmc_timing.AddressHoldTime = 0x01; 					   			 // 地址保持时间 ADDHLD 范围:1~15
	hfsmc_timing.DataSetupTime   = 0x02; 						  			 // 数据建立时间 DATAST 范围:1~255 (模式 A 需要设置)
	// hfsmc_timing.BusTurnAroundDuration = 0x00; 				 			 // 总线恢复时间 BUSTURN 范围:0~15
	// hfsmc_timing.CLKDivision = 0x02;                                      // 时钟分频因子 CLKDIV 范围:2~16
	// hfsmc_timing.DataLatency = 0x02;                        			     // 数据产生时间 ACCMOD 范围:2~17
	hfsmc_timing.AccessMode    = FSMC_ACCESS_MODE_A; // 模式 A
	
	hsram.Instance             = FSMC_NORSRAM_DEVICE;         				 // 实例类型:NOR/SRAM 设备
	hsram.Extended             = FSMC_NORSRAM_EXTENDED_DEVICE;
	hsram.Init.NSBank          = FSMC_NORSRAM_BANK3;                         // 使用 NE3,对应 BANK1(NORSRAM)的区域 3
	hsram.Init.DataAddressMux  = FSMC_DATA_ADDRESS_MUX_DISABLE;              // 地址/数据线不复用(nonmultiplexed)
	hsram.Init.MemoryType      = FSMC_MEMORY_TYPE_SRAM;                      // 存储器类型:SRAM
	hsram.Init.MemoryDataWidth = FSMC_NORSRAM_MEM_BUS_WIDTH_16;              // 选择 16 位数据宽度(所接 SRAM 为 16 位数据宽度)
	// hsram.Init.BurstAccessMode = FSMC_BURST_ACCESS_MODE_DISABLE;          // 是否使能突发访问,仅对同步突发存储器有效,此处未用到
	// hsram.Init.WaitSignalPolarity = FSMC_WAIT_SIGNAL_POLARITY_LOW;        // 等待信号的极性,适用于突发模式访问,此处未用到
	// hsram.Init.WaitSignalActive = FSMC_WAIT_TIMING_BEFORE_WS;             // 存储器是在等待周期之前的一个时钟周期还是等待周期期间使能 NWAIT,适用于突发模式访问,此处未用到
	hsram.Init.WriteOperation = FSMC_WRITE_OPERATION_ENABLE;                 // 存储器写使能
	// hsram.Init.WaitSignal = FSMC_WAIT_SIGNAL_DISABLE;                     // 等待使能位,适用于突发模式访问,此处未用到
	hsram.Init.ExtendedMode = FSMC_EXTENDED_MODE_ENABLE;                     // 启用扩展模式,使用模式 A
	// hsram.Init.AsynchronousWait = FSMC_ASYNCHRONOUS_WAIT_DISABLE;         // 是否使能异步传输模式下的等待信号,此处未用到 
	// hsram.Init.WriteBurst = FSMC_WRITE_BURST_DISABLE;                     // 禁止突发写,适用于突发模式访问,此处未用到
	
	if(HAL_SRAM_Init(&hsram, &hfsmc_timing, &hfsmc_timing) != HAL_OK)
	{
		Error_Handler();
	}
}
  • 14~20行:定义NOR/SRAM时序参数;由于外接设备为SRAM,只能使用模式1或模式A,在后面使能了扩展模式,因此这里只能使用模式A;使用模式A需要设置地址建立时间(ADDSET)和数据建立时间(DATAST)两个时序参数;

    • 14行:设置地址建立时间,取值范围为0~15个T HCLK ,由IS62WV51216BLL芯片手册可知,如图 3.3.1所示,地址建立时间t SA 大于0即可,因此这里设置AddressSetupTime值为0;
    • 16行:设置数据建立时间,取值范围为1~255个T HCLK ,由IS62WV51216BLL芯片手册可知,如图 3.3.1所 示 , 数 据 建 立 时 间 t SD 需 要 大 于 20ns , 当 系 统 时 钟 为 72MHz , HCLK 也 为 72MHz ,T HCLK =1/72us=13.8ns,至少需要2个T HCLK 才能大于t SD ,因此这里设置DataSetupTime值为2;
  • 图 37.3.1 IS62WV51216BLL 写周期时间特性
    在这里插入图片描述

    • 20行:设置FSMC的访问模式为模式A;
  • 22~37行:定义SRAM属性;

    • 22~23行:设置实例类型为NOR/SRAM设备;
    • 24行:硬件上使用的FSMC_NE3连接的SRAM,因此对应BANK1(NORSRAM)的区域3;
    • 25行:地址/数据线不复用(nonmultiplexed);
    • 26行:设置存储器类型为SRAM;
    • 27行:设置16位数据宽度(所接SRAM为16为数据宽度);
    • 33行:存储器写使能;
    • 35行:启用扩展模式,这样才能使用前面设置的模式A,否则为模式1;
  • 39~42行:使用HAL库提供的“HAL_SRAM_Init()”函数,将前面设置的NOR/SRAM时序参数和定义的SRAM属性初始化;

“HAL_SRAM_Init()”初始化函数会调用“HAL_SRAM_MspInit()”函数初始化硬件,FSMC相关的硬
件初始化如代码段 3.3.3 所示。

代码段 3.3.3 FSMC 硬件相关初始化(driver_fsmc.c)

void HAL_SRAM_MspInit(SRAM_HandleTypeDef *hsram)
{
	GPIO_InitTypeDef GPIO_InitStruct = {0};
	
	__HAL_RCC_FSMC_CLK_ENABLE(); 						// 使能 FSMC 时钟
	__HAL_RCC_GPIOD_CLK_ENABLE(); 						// 使能 GPIO 端口 D 时钟
	__HAL_RCC_GPIOE_CLK_ENABLE(); 						// 使能 GPIO 端口 E 时钟
	__HAL_RCC_GPIOF_CLK_ENABLE(); 						// 使能 GPIO 端口 F 时钟
	__HAL_RCC_GPIOG_CLK_ENABLE(); 						// 使能 GPIO 端口 G 时钟
	
	GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; 			// 配置为复用推挽功能
	GPIO_InitStruct.Pull = GPIO_PULLUP; 				// 上拉
	GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;		// 引脚翻转速率快
	
	// PDx
	GPIO_InitStruct.Pin = FSMC_GPIOD_PIN; 				// 按上面参数配置 D 组所有 GPIO
	HAL_GPIO_Init(GPIOD, &GPIO_InitStruct);
	
	// PEx
	GPIO_InitStruct.Pin = FSMC_GPIOE_PIN; 				// 按上面参数配置 E 组所有 GPIO
	HAL_GPIO_Init(GPIOE, &GPIO_InitStruct);
	
	// PFx
	GPIO_InitStruct.Pin = FSMC_GPIOF_PIN; 				// 按上面参数配置 F 组所有 GPIO
	HAL_GPIO_Init(GPIOF, &GPIO_InitStruct);
	
	// PGx
	GPIO_InitStruct.Pin = FSMC_GPIOG_PIN; 				// 按上面参数配置 G 组所有 GPIO
	HAL_GPIO_Init(GPIOG, &GPIO_InitStruct);
}
  • 5~9行:使能FSMC和涉及GPIO所在端口的时钟;
  • 11~13行:设置GPIO为复用推挽上拉模式,翻转速率快;
  • 15~29行:依次将涉及的GPIO按上述属性设置;
  1. 实现SRAM 读写函数

尽管开发板接的外部SRAM是16位数据位,理论上只能一次读、写16位数据。但CPU通过AHB总线访问接在FSMC上的设备,可以按字、半字、字节的方式访问,AHB将自动转换控制FSMC访问。这里以按字读、写为例讲解,如代码段 3.3.4 所示。

代码段 3.3.4 外部 SRAM 按字读写

/*
 * 函数名:void SRAM_WriteBufferWord(uint32_t *pdata, uint32_t addr, uint32_t sz)
 * 输入参数:pdata->要写入数据的首地址
 * 			addr ->要写到 RAM 的初始地址
 * 			sz ->要写入的数据个数
 * 输出参数:无
 * 返回值:无
 * 函数作用:往片外 RAM 地址 addr 开始写入 sz 个字
*/
void SRAM_WriteBufferWord(uint32_t *pdata, uint32_t addr, uint32_t sz)
{
	uint32_t i = 0;
	__IO uint32_t offset = (uint32_t)addr;
	
	for(i=0; i<sz; i++)
	{
		*(__IO uint32_t *)(IS62WV51216_BASE_ADDR + offset) = pdata[i];
		offset += 4; // 字 word,32 位,偏移 32/8=4 个地址
	}
}

/*
 * 函数名:void SRAM_ReadBufferWord(uint32_t *pdata, uint32_t addr, uint32_t sz)
 * 输入参数:pdata->要读出数据的首地址
 *			 addr ->要读出的 RAM 的初始地址
 *			 sz ->要写读出的数据个数
 * 输出参数:无
 * 返回值:无
 * 函数作用:往片外 RAM 地址 addr 开始读出 sz 个字
*/
void SRAM_ReadBufferWord(uint32_t *pdata, uint32_t addr, uint32_t sz)
{
	uint16_t i = 0;
	__IO uint32_t offset = (uint32_t)addr;
	
	for(i=0; i<sz; i++)
	{
		pdata[i] = *(__IO uint32_t *)(IS62WV51216_BASE_ADDR + offset);
		offset += 4; // 字 word,32 位,偏移 32/8=4 个地址
	}
}
  • 15~19行:循环将每个字写入SRAM;
    • 17行:将数据写入SRAM所在的Bank1的区域3地址0x6800 0000;
    • 39行:偏移4个地址,准备写下一个字;
  • 36~40行:循环从SRAM读出字;
    • 17行:从SRAM所在的Bank1的区域3地址0x6800 0000读出数据;
    • 39行:偏移4个地址,准备读下一个字;
  1. 主函数控制逻辑

在主函数里,初始化系统时钟、打印串口、按键后,循环检测按键是否按下。当按键按下后,产生随机数据,然后写入外部SRAM,再读出比较,如代码段 3.3.5 所示。

代码段 37.3.5 主函数控制逻辑(main.c)

// 初始化按键
KeyInit();

while(1)
{
	if(FSMC_RW_GetFlag())
	{
		FSMC_RW_SetFlag(0);
		// 准备随机生成的写入数据
		for(i=0;i< TEST_NUM;i++)
		{
			WriteDataBytes[i] = rand();
			WriteDataHalfWord[i] = rand();
			WriteDataWord[i] = rand();
		}
		
		// 读写 N 个字节并判断是否一致
		SRAM_WriteBufferBytes(WriteDataBytes, 0, TEST_NUM);
		SRAM_ReadBufferBytes(ReadDataBytes, 0, TEST_NUM);
		printf("**********************************************\n\r");
		for(i=0;i<TEST_NUM;i++)
		{
		if(WriteDataBytes[i] != ReadDataBytes[i])
		printf("Error! \n\r");
		printf("WriteBytes[%03d]=0x%02x, ReadBytes[%03d]=0x%02x\n\r", i, WriteDataBytes[i], i, 	ReadDataBytes[i]);
		}
		
		// 读写 N 个半字并判断是否一致
		SRAM_WriteBufferHalfWord(WriteDataHalfWord, 1000, TEST_NUM);
		SRAM_ReadBufferHalfWord(ReadDataHalfWord, 1000, TEST_NUM);
		printf("**********************************************\n\r");
		for(i=0;i<TEST_NUM;i++)
		{
			if(WriteDataHalfWord[i] != ReadDataHalfWord[i])
				printf("Error! \n\r");
			printf("WriteHalfWord[%03d]=0x%04x, ReadHalfWord[%03d]=0x%04x\n\r", i, WriteDataHalfWord[i], i, ReadDa
			taHalfWord[i]);
		}
		
		// 读写 N 个字并判断是否一致
		SRAM_WriteBufferWord(WriteDataWord, 2000, TEST_NUM);
		SRAM_ReadBufferWord(ReadDataWord, 2000, TEST_NUM);
		printf("**********************************************\n\r");
		for(i=0;i<TEST_NUM;i++)
		{
			if(WriteDataWord[i] != ReadDataWord[i])
				printf("Error! \n\r");
			printf("WriteWord[%03d]=0x%08x, ReadWord[%03d]=0x%08x\n\r", i, WriteDataWord[i], i, ReadDataWord[i]);
		}
	}
}

4. LCD 屏显示

4.1 关于LCD

4.1.1 显示屏基础知识

从第一个显示装置发明至今,已经快有百年历史。在显示屏的发展中,依次出现了三大显示技术:CRT、LCD、OLED,如图 4.1.1 所示。

  • 图 4.1.1 屏幕发展史
    在这里插入图片描述

  • CRT 显示屏

1897年,德国电气工程师、发明家、物理学家和诺贝尔物理学奖获得者卡尔·布劳恩(Karl FerdinandBraun),发明了CRT(Cathode Ray Tube,阴极射线管)用于验证粒子、电子等。直到1925年,约翰·洛吉·贝尔德(John Logie Baird)基于CRT技术创造了世界上最早的电视。在之后的几十年里,无论是电视还是电脑,都使用CRT显示器作为显示屏。如图 4.1.2 所示,CRT显示器有一个很显著的特征,就是受限阴极射线管工作原理,需要纵深很长的内部空间,因此俗称“大屁股电视”,其内部工作原理如图 4.1.3 所示。

  • 图 4.1.2 CRT 电脑显示器(左边)和 CRT 电视显示器(右)
    在这里插入图片描述

在真空玻壳(Glass vacuum envelope,标号①)里,阴极(Cathode,标号②)在电路控制下发出三束由电子组成的电子束(Electron beam)。电路上控制偏转线圈(Deflection yoke,标号④)产生磁场使电子束偏转到荧光屏(Fluorescent screen,标号⑤)指定位置(也就是像素点),荫罩(Shadow mask,标号⑥)用于过滤电子束,防止“溢出”到相邻像素点。荧光屏上布满荧光粉(Phosphor,标号⑧),每个荫罩对应的像素点位置都有三个荧光点(Phosphor dots,标号⑦),分别对应红色、绿色、蓝色,电子打在上面会产生对应颜色的光。通过控制阴极三束电子的发射强度,就能控制三原色各自的亮度,从而组合形成任意颜色。最后,电子枪依次激发屏幕上的所有荧光点,达到一定速度后,由于人的视觉暂留效应(Persistence of vision),就会看到整个屏幕显示图像。

  • 图 4.1.3 CRT 显示器工作原理
    在这里插入图片描述
  • LCD 显示屏
    1888年,奥地利植物学家和化学家斐德烈‧莱尼泽(Friedrich Reinitzer)从胡萝卜中提炼出一种化合物,该化合物在特定条件下,具备液体的流动性和类似晶体的某种排列特性,因此命名为液晶(Liquid Crystal)。

到20世纪60年代,RCA实验室的研究人员发现液晶在电场的作用下,液晶分子的排列会产生变化,继而造成光线的扭曲或折射,这种现象被称为电光效应。随后在1964年,RCA实验室的研究人员利用液晶的电光效应发明了首个液晶显示器(Liquid Crystal Display,LCD)。

LCD的工作原理如图 4.1.4 所示。首先由背光源产生一个非偏振光源,当它经过后偏光片(假设为垂直偏光片)时,光线将变为垂直偏振,随后该光进入液晶。如果此时电路未通电,相邻液晶分子之间方向略有不同,将垂直偏振光逐渐变为水平偏振光,最后前偏光片(假设为垂直偏光片)将阻挡水平偏振光,观察者只能看到一片黑色。如果此时电路通电,相邻液晶分子在电场作用下统一水平排列,垂直偏振光不发生任何变化,最后通过前偏光片(假设为垂直偏光片)照在彩色滤光片上,观察者将看到一片红色。

这里可以简单的把偏光片、控制电路、液晶看作一个不透光的挡板。可以通过电路实现整个单板的开闭的多少,实现背光源照在彩色滤波片的多少,从而控制红色的亮度。再类似的实现绿色、蓝色,便可实现彩色显示。

  • 图 4.1.4 LCD 显示原理
    在这里插入图片描述

根据液晶排列方式的不同,可分为扭转式向列型(Twisted Nematic, TN)、超扭转式向列型(SuperTwisted Nematic, STN)、横向电场效应型(In Panel Switch,IPS)、垂直排列型 (Vertical Alignment,VA)。

根据驱动形式的不同,可分为无源矩阵液晶显示屏(Passive Matrix LCD,PMLCD),主要用于TN、STN;有源矩阵液晶显示屏(Active Matrix LCD,AMLCD),主要用于TFT。

  • 表 4.1.1 常见 LCD 对比
    在这里插入图片描述
  • OLED 显示屏
    1979年的一天晚上,华裔科学家邓青云(Dr. C. W. Tang)博士在回家路上忽然想起了有东西忘记在实验室。晚上回到实验室,在黑暗中看到机蓄电池在闪闪发光,于是开始了对有机发光二极管的研究。随着有机发光二极管技术难题的逐渐突破,柯达公司生产出有机发光二极管显示器(Organic Light-Emitting Diode,OLED)。

LCD的工作原理如图 4.1.5 所示。OLED的工作原理比较简单,使用有机材料实现了类似半导体PN结的功能效果,通电后有机发光二极管就发光,通的电越多,亮度越高,通过红、绿、蓝不同配比,实现组成各种颜色。

  • 图 4.1.5 OLED 显示原理
    在这里插入图片描述

  • LED 显示屏和 和Micro LED 显示屏

前面OLED使用的有机发光二极管组成显示器,有没有直接使用生活中常见的半导体发光二极管,作为显示屏呢?这个当然有,在很多大型户外广告牌、店招滚动广告上,都能看到LED显示屏的身影,如图 4.1.6所示。

LED显示屏由众多LED灯组成,由于LED灯结构、工艺、散热、成本等限制,无法做得很小,因此画质清晰度比较差,通常用于远距离观看。

  • 图 4.1.6 LED 户外显示屏
    在这里插入图片描述

而Micro LED技术,将LED长度缩小到100μm以下,是常见LED的1%,比一粒沙子还要小。因为MicroLED单元过于微小,加大了制造的复杂性和更多的潜在问题,目前各企业正在积极研发中。

  • 图 4.1.7 三星 Micro LED
    在这里插入图片描述

显示屏Micro LED没有LCD的液晶层,可以像OLED一样独立控制每个像素的开关和亮度,继承了几乎所有LCD和OLED的优点,如果后面Micro LED技术成熟,解决生产上的技术难题,那么Micro LED可能将会是下一代主流显示技术。如表4.1.2 所示,为LCD、OLED、Micro LED的对比。

  • 表 4.1.2 LCD、OLED、Micro LED 技术对比
    在这里插入图片描述
  • 最后再介绍几个显示屏的基本参数。
  • 像素
    像素(Pixel)由图像(Picture)和元素(Element)这两个单词的字母所组成。指组成图像的最小单位,也就是前面屏幕中的每一个显示单元。
  • 分辨率
    由显示像素的数量定义,表示为水平方向的像素数量x垂直方向的像素数量。比如分辨率320x240表示水平方向有320个像素点,垂直方向有240个像素点。
  • 色深
    由可以绘制像素的颜色数量定义,以每像素位数(bpp)来表示。比如24bpp的色深(也可以使用RGB888表示),即组成每个像素的红、绿、蓝,每个都有2 8 个亮度等级,组合起来就有2^8 *2^8 *2^8 =16777216,即每个像素颜色有16777216种。
  • 刷新率
    每秒图像刷新的次数,单位Hz。刷新率越高,图像画面看起来过渡更流畅。

4.1.2 显示屏接口介绍

前面介绍了几种常见显示屏工作原理和内部结构,在嵌入式领域,由于成本、使用寿命等限制,通常使用LCD作为显示屏。

通常的嵌入式图形系统如图 4.1.8 所示,可以看作由四部分组成:微控制器、帧缓冲器、显示控制器、显示屏。

  • 图 4.1.8 嵌入式图形系统组成示意图
    在这里插入图片描述

MCU根据代码内容计算需要显示的图像数据,然后将这些图像数据放入帧缓冲器。帧缓冲器本质是一块内存,因此也被称为GRAM(Graphic RAM)。帧缓冲器再将数据传给显示控制器,显示控制器将图像数据解析,控制显示屏对应显示。

帧缓冲器和显示控制器,可以集成在MCU内部,也可以和显示屏做在一起。对于大部分中、低端MCU,不含显示控制器,内部SRAM也比较小,因此采用如图 4.1.9 所示的显示方案,将帧缓冲器、显示控制器和显示屏制作在一起,这样的屏幕习惯上称为“MCU屏”。

  • 图 4.1.9 MCU 屏
    在这里插入图片描述

对于部分高端MCU或MPU,本身含有显示控制器,使用内部SRAM或外部SRAM,如图4.1.10 所示,通过并行的RGB信号和控制信号直接控制显示屏,这样的屏幕习惯上称为“RGB屏”。

  • 图 4.1.10 RGB 屏
    在这里插入图片描述

MIPI(Mobile Industry Processor Interface,移动行业处理器接口)是ARM、ST、TI等公司成立的一个联盟,致力于定义和推广移动设备接口的规范标准化,从而减小移动设备的设计复杂度。
MIPI-DBI(Display Bus Interface,显示总线接口)是MIPI联盟发布的第一个显示标准,用来规定显示接口,MIPI-DBI中定义了三类接口:

  • A类:基于Motorola 6800总线
  • B类:基于Intel 8080总线
  • C类:基于SPI协议

MIPI-DBI用于与MCU屏(带有显示控制器和帧缓冲器)进行连接,如图 4.1.11 所示。

  • 图 4.1.11 MIPI-DBI 的 A 类、B 类(上)和 C 类接口(下)
    在这里插入图片描述

MIPI-DPI(Display Pixel Interface,显示像素接口),从名称就可以看出它是直接对屏幕的各像素点进行操作,利用H-SYNC(行同步信号)和V-SYNC(场同步信号)对各像素点进行颜色填充,类似CRT中电子枪那样扫描显示。MIPI-DPI用于与RGB屏(不含显示控制器和帧缓冲器)进行连接,如图 4.1.12 所示,像素数据需要实时流式传输到显示屏,对MCU性能有一定要求。

  • 图 4.1.12 MIPI-DPI 接口

在这里插入图片描述

MIPI-DSI(Display Serial Interface,显示串行接口),从名称可以看出它是串行传输,不过传输信号是差分信号,实现了低噪声和低功耗。包含MIPI-DSI接口的MCU与显示屏的某连接如图 4.1.13 所示。DSI封装了DBI或DPI信号,并通过PPI协议将它其发送到D-PHY,通过差分信号传输到显示屏模块的DSI控制器解析。

  • 图 4.1.13 MIPI-DSI 接口
    在这里插入图片描述

对于STM32系列的MCU,不同型号对MIPI联盟显示接口的支持有所不同,总结如下,如表 4.1.3 所示。

  • 所有STM32 MCU均支持MIPI-DBI C类接口(基于SPI协议);

  • 带FSMC的所有STM32 MCU均支持MIPI-DBI A类和B类接口;

  • 带LTDC的STM32 MCU支持MIPI-DPI接口;

  • 带DSI Host的STM32 MCU支持MIPI-DSI接口;

  • 表 38.1.3 STM32 MCU 支持的显示接口
    在这里插入图片描述

4.1.3 显示屏控制器

STM32F103ZET6只有FSMC接口,没有LTDC或DSI Host,因此只能接MCU屏。

如图 4.1.14 所示,为图 4.1.14 本开发板配套 MCU 屏由屏幕供应商提供的《LCD_3.5寸_320x480_ILI9488_液晶显示模块规格书.pdf》可知,该液晶显示模块为TFT LCD,分辨率为320480,色彩深度为262K(RGB666),接口类型为16 Bit的MCU接口,驱动IC为ILI9488。由《ILI9488驱动芯片数据手册.pdf》可知,该芯片可以驱动320480分辨率,16.7M色彩深度的TFTLCD,同时该芯片内部自带GRAM。

由百问科技提供的《100ASK_LCD_3A5_V11原理图.pdf》可知,底板的主要功能是将LCD屏幕排线转接成所需排针,以便和开发板连接。同时提供背光控制电路和触摸电路,这些在后面用到再讲解。这里重点介绍下显示屏控制器ILI9488,它的框图如图 4.1.15 所示。

  • 图 38.1.15 显示屏控制器 ILI9488 框图
    在这里插入图片描述

控制引脚IM[2:0]用于设置控制器的接口模式,如表 4.1.4 所示。显示屏支持的接口为16 Bit的MIPI-DBIB类,因此IM[2:0]值为010,该值由屏幕供应商生产时硬件设置,此时用到的数据引脚为DB[15:0]。

  • 表 4.1.4 接口模式
    在这里插入图片描述
    Intel 8080接口16位连接示意图,如图 4.1.16 所示。MCU和控制器之间,有4根控制信号,16个数据信号。CSX为片选信号,低电平有效(X表示低电平有效);D/CX为数据/命令切换信号,低电平时DB传输的为命令,高电平时DB传输的为数据;WRX为写信号,低电平有效;RDX为读信号,低电平有效;DB[15:0]为数据传输信号。

  • 图 4.1.16 DBI-B 类接口 16 位连接示意图
    在这里插入图片描述

Intel 8080接口16位数据传输示意图,如图 4.1.17 所示。首先片选信号CS拉低,复位信号RES保持为高。数据/命令切换信号D/C拉低,写信号引脚拉低,此时DB[15:0]发送的就是指令(黄色部分);数据/命令切换信号D/C拉高,写信号引脚拉低,此时DB[15:0]发送的就是一个像素点的颜色数据(红、绿、蓝部分),其中低5位为蓝色数据、中6位为绿色数据、高5位为红色数据。

  • 图 38.1.17 DBI-B 类接口 16 位数据传输时序图
    在这里插入图片描述

再来分析一下FSMC接口,如何对应DBI-B类(Intel 8080)接口。对比前面“图 37.1.5 FSMC模式A读写时序”的时序和引脚,可以发现两者几乎吻合,如表 4.1.5 所示。

在这里插入图片描述

片选、读/写使能、数据总线都能对应,FSMC的地址总线和8080信号的数据/命令切换信号对应不上,如何让两者对应上呢?假设NEx为NE4,即片选的Bank1的第四区,此时FSMC的寻址范围为0x6C000000~0x6FFF FFFF。假设A23与D/C连接,此时CPU访问(0x6C000000 + (1<<24) - 2))地址时,A23为低电平,对应D/C为命令信号;当CPU访问(0x6C000000 + (1<<24))地址时,A23为高电平,对应D/C为数据信号。通过这个方式,即可实现地址总线与D/C的对应。

4.2 硬件设计

LCD的硬件包含两部分,一部分是根据LCD屏幕设计的底板,一部分是开发板的LCD接口。

  • 如图 4.2.1 所示,整个原理图由四部分组成。①是连接LCD屏幕的FPC排座,它的设计主要参考屏幕供应商提供的《LCD_3.5寸_320x480_ILI9488_液晶显示模块规格书.pdf》,里面详细介绍了这45个引脚的功能;②是触摸芯片电路,用于实现触摸功能,放在后面一章单独讲解;③是背光驱动电路,通过控制三极管的通断,实现背光的开关,需要注意的是,控制引脚支持PWM功能可以实现亮度的调节;④是连接开发板的排针接口,该接口与开发板的LCD接口一致。

  • 图 38.2.1 LCD 底板原理图
    在这里插入图片描述

开发板LCD接口电路如图4.2.2 所示,RNx是排阻,可以等效看成四个独立并联的电阻。J9是与LCD底板连接的排母接口。

  • 图 4.2.2 开发板 LCD 电路
    在这里插入图片描述

STM32使用FSMC接口实现LCD的Intel 8080总线接口,以及使用GPIO控制LCD其它功能,两者引脚的对应关系,如表 4.2.1 所示。

  • 表 38.2.1 LCD 引脚对应
    在这里插入图片描述

4.3 软件 设计

4.3.1 软件设计思路

实验目的:本实验通过STM32的FSMC接口操作LCD,在LCD上显示字符和简单图形。

  1. 初始化定时器、PWM输出控制背光;
  2. 初始化FSMC接口;
  3. 初始化LCD控制器,LCD设置;
  4. 实现LCD像素点显示、字符显示、数字显示、画线等功能;
  5. 主函数编写控制逻辑:清屏,使用LCD提供的函数,在指定位置显示字符、字符串、数字、图形等;

4.3.2 软件设计讲解

  1. 初始化定时器

这里定时器主要有两个作用,第一个是实现us延时用于LCD控制延时,第二个是实现PWM输出用于LCD背光亮度。us延时使用的TIM2,实现原理参考前面“第25章 定时器—us延时”,如代码段 4.3.1 所示。
代码段 38.3.1 定时器实现 us 延时(driver_timer.c)

/*
 * 函数名:void TimerInit(void)
 * 输入参数:
 * 输出参数:无
 * 返回值:无
 * 函数作用:初始化定时器
*/
void TimerInit(void)
{
	TIM_ClockConfigTypeDef sClockSourceConfig = {0};
	
	// 定时器基本功能配置
	htim.Instance = TIM2; 							  // 使用定时器 2
	htim.Init.Prescaler = 72-1; 					  // 预分频系数 PSC=72-1(范围:0~0xFFFF)
	// 72MHz 经过 72 分频后,定时器时钟为 1MHz,即定时器计数 1 次的时间,刚好为 1us
	htim.Init.CounterMode = TIM_COUNTERMODE_UP; 	  // 向上计数
	htim.Init.Period = 0; 							  // 自动装载器 ARR 的值(范围:0~0xFFFF)
	htim.Init.ClockDivision = TIM_CLOCKDIVISION_DIV1; // 时钟分频(与输入采样相关)
	//htim.Init.RepetitionCounter = 0; 				  // 重复计数器值,仅存在于高级定时器
	htim.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE; // 不自动重新装载
	
	if (HAL_TIM_Base_Init(&htim) != HAL_OK)
	{
		Error_Handler();
	}
	
	// 定时器时钟源选择
	sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL; // 选用内部时钟作为定时器时钟源
	if (HAL_TIM_ConfigClockSource(&htim, &sClockSourceConfig) != HAL_OK)
	{
		Error_Handler();
	}
}
/*
 * 函数名:void HAL_TIM_Base_MspInit(TIM_HandleTypeDef *htim)
 * 输入参数:htim-TIM 句柄
 * 输出参数:无
 * 返回值:无
 * 函数作用:使能 TIM 时钟
*/
void HAL_TIM_Base_MspInit(TIM_HandleTypeDef *htim)
{
	if(htim->Instance==TIM2)
	{
		__HAL_RCC_TIM2_CLK_ENABLE(); // 使能 TIM2 的时钟
	}
}
/*
 * 函数名:void us_timer_delay(uint16_t t)
 * 输入参数:t-延时时间 us 范围-0~65535us
 * 输出参数:无
 * 返回值:无
 * 函数作用:定时器实现的延时函数,延时时间为 t us,为了缩短时间,函数体使用寄存器操作
*/
void us_timer_delay(uint16_t t)
{
	uint16_t counter = 0;
	__HAL_TIM_SET_AUTORELOAD(&htim, t); // 设置定时器自动加载值
	__HAL_TIM_SET_COUNTER(&htim, counter); // 设置定时器初始值
	HAL_TIM_Base_Start(&htim); // 启动定时器
	while(counter != t) // 直到定时器计数从 0 计数到 t 结束循环,刚好 t us
	{
		counter = __HAL_TIM_GET_COUNTER(&htim); // 获取定时器当前计数
	}
	HAL_TIM_Base_Stop(&htim); // 停止定时器
}

由原理图可知,使用的PC6连接LCD的背光控制电路,PC6可重映射为TIM3_CH1,如代码段 4.3.2 所示。

代码段 38.3.2 定时器实现 PWM 输出(driver_timer.c)

/*
 * 函数名:void TimerPWMInit(void)
 * 输入参数:无
 * 输出参数:无
 * 返回值:无
 * 函数作用:初始化定时器,输出频率 1Hz,占空比 50%的 PWMN 波
*/
void TimerPWMInit(void)
{
	TIM_ClockConfigTypeDef sClockSourceConfig;
	TIM_OC_InitTypeDef sConfig;
	
	// 定时器基本功能配置
	hpwm.Instance 			 = TIMx; 								 // 指定定时器 TIM3
	hpwm.Init.Prescaler		 = TIM_PRESCALER; 					     // 预分频系数 PSC=360-1
	hpwm.Init.CounterMode 	 = TIM_COUNTERMODE_UP;				     // 向上计数
	hpwm.Init.Period 		 = TIM_PERIOD; 							 // 自动装载器 ARR 的值,ARR=2000-1
	// 72MHz 经过 360 分频后,定时器时钟为 200KHz,即计数器每间隔 5us 计数一次,从 0 计数到 ARR,经历 10ms
	
	hpwm.Init.ClockDivision  = TIM_CLOCKDIVISION_DIV1; 				// 定时器时钟不从 HCLK 分频
	//hpwm.Init.RepetitionCounter = 0; 							    // 重复计数器值,仅存在于高级定时器
	hpwm.Init.AutoReloadPreload = TIM_AUTORELOAD_PRELOAD_DISABLE;   // 不自动重新装载
	
	// 将 TIM3 按 PWM 模式初始化
	if (HAL_TIM_PWM_Init(&hpwm) != HAL_OK)
	{
		Error_Handler();
	}
	
	// 定时器时钟源选择
	sClockSourceConfig.ClockSource = TIM_CLOCKSOURCE_INTERNAL;    // 选用内部时钟作为定时器时钟源
	HAL_TIM_ConfigClockSource(&hpwm, &sClockSourceConfig);
	
	// 配置 PWM 的输出通道参数
	sConfig.OCMode     = TIM_OCMODE_PWM1; 							 // PWM 输出的两种模式:PWM1 当极性为低,CCR<CNT,输出低电平,反之高
	电平
	sConfig.OCPolarity = TIM_OCPOLARITY_LOW; 					// 设置极性为低
	sConfig.OCFastMode = TIM_OCFAST_DISABLE; 					// 输出比较快速使能禁止(仅在 PWM1 和 PWM2 可设置)
	sConfig.Pulse = LCD_BRT; 									// 在 PWM1 模式下,通道 1(LCD_PWM)占空比
	if (HAL_TIM_PWM_ConfigChannel(&hpwm, &sConfig, TIM_LCD_PWM_CHANNEL) != HAL_OK)
	{
		Error_Handler();
	}
}
/*
 * 函数名:void HAL_TIM_PWM_MspInit(TIM_HandleTypeDef *htim)
 * 输入参数:htim-TIM 句柄
 * 输出参数:无
 * 返回值:无
 * 函数作用:HAL_TIM_PWM_Init 回调硬件初始化
*/
void HAL_TIM_PWM_MspInit(TIM_HandleTypeDef *htim)
{
	GPIO_InitTypeDef GPIO_InitStruct;
	
	TIM_PWM_CLK_EN(); 										// PWM 所涉及的 TIM3 时钟使能
	TIM_PWM_GPIO_CLK_EN(); 									// PWM 所涉及的 GPIOC 时钟使能
	__HAL_RCC_AFIO_CLK_ENABLE(); 							// 重映射涉及时钟使能
	//__HAL_AFIO_REMAP_TIM3_PARTIAL(); 						// 启用 TIM3 重映射
	__HAL_AFIO_REMAP_TIM3_ENABLE();
	
	GPIO_InitStruct.Mode = GPIO_MODE_AF_PP; 				// 复用推挽输出
	GPIO_InitStruct.Speed = GPIO_SPEED_FREQ_HIGH;
	
	GPIO_InitStruct.Pin = TIM_LCD_PWM_PIN;
	HAL_GPIO_Init(TIM_LCD_PWM_PORT, &GPIO_InitStruct); 		// 初始化 LCD_PWM 引脚
	
	HAL_NVIC_SetPriority(TIMx_IRQn, 0, 0); 					// 配置定时器中断优先级
	HAL_NVIC_EnableIRQ(TIMx_IRQn); 							// 使能 TIM3 中断
}
/*
* 函数名:void TIM3_IRQHandler(void)
* 输入参数:无
* 输出参数:无
* 返回值:无
* 函数作用:TIM3 中断的中断处理函数
*/
void TIM3_IRQHandler(void)
{
HAL_TIM_IRQHandler(&hpwm);
}

/*
 * 函数名:void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim)
 * 输入参数:htim-TIM 句柄
 * 输出参数:无
 * 返回值:无
 * 函数作用:TIM 中断回调周期更新函数
*/
void HAL_TIM_PWM_PulseFinishedCallback(TIM_HandleTypeDef *htim)
{
	if(htim->Instance == TIMx)
	{
		__HAL_TIM_SET_COMPARE(&hpwm, TIM_LCD_PWM_CHANNEL, lcd_brt *20);
	}
}

需要注意的61行使能重映射,只有在全部重映射时,PC6才能重映射为TIM3_CH1功能,因此需要使用“__HAL_AFIO_REMAP_TIM3_ENABLE()”函数使能。

TIM3的计数器CNT从0开始计数到CCR,输出低电平,LCD背光亮,然后计数器CNT从CCR到ARR,输出高电平,LCD灭。即,CCR值越小,占空比越大,背光越暗,CCR值越大,占空比小,背光越亮,CCR值与亮度成正比。CCR的计数范围为0~2000,因此在TIM3中断回调函数里,修改CCR值,即可改变PWM占空比,实现背光不同亮度。

  1. 初始化FSMC
    FSMC的初始化,包含两部分:协议部分和硬件部分。

协议部分初始化如代码段4.3.3 所示。

代码段 4.3.3 FSMC 协议部分初始化(driver_fsmc.c)

static FSMC_NORSRAM_TimingTypeDef hfsmc_rw;
static SRAM_HandleTypeDef hsram;

void FSMC_LCD_Init(void)
{
	hfsmc_rw.AddressSetupTime 			= 0x00; 										// 地址建立时间 ADDSET 范围:0~15 (模式 A 需要设置)
	// hfsmc_rw.AddressHoldTime 		= 0x01; 									// 地址保持时间 ADDHLD 范围:1~15
	hfsmc_rw.DataSetupTime 				= 0x02; 											// 数据建立时间 DATAST 范围:1~255 (模式 A 需要设置)
	// hfsmc_rw.BusTurnAroundDuration   = 0x00; 								// 总线恢复时间 BUSTURN 范围:0~15
	// hfsmc_rw.CLKDivision 			= 0x00; 										// 时钟分频因子 CLKDIV 范围:2~16
	// hfsmc_rw.DataLatency				= 0x00; 										// 数据产生时间 ACCMOD 范围:2~17
	hfsmc_rw.AccessMode 				= FSMC_ACCESS_MODE_A;								// 模式 A
	
	hsram.Instance 						= FSMC_NORSRAM_DEVICE; 									// 实例类型:NOR/SRAM 设备
	hsram.Extended 						= FSMC_NORSRAM_EXTENDED_DEVICE;
	hsram.Init.NSBank 					= FSMC_NORSRAM_BANK4; 								// 使用 NE4,对应 BANK1(NORSRAM)的区域 4
	hsram.Init.DataAddressMux		    = FSMC_DATA_ADDRESS_MUX_DISABLE; 				// 地址/数据线不复用(nonmultiplexed)
	hsram.Init.MemoryType 				= FSMC_MEMORY_TYPE_SRAM; 							// 存储器类型:SRAM
	hsram.Init.MemoryDataWidth			= FSMC_NORSRAM_MEM_BUS_WIDTH_16; 			// 选择 16 位数据宽度(所接 LCD 为 16 位数据宽度)
	// hsram.Init.BurstAccessMode 		= FSMC_BURST_ACCESS_MODE_DISABLE; 		// 是否使能突发访问,仅对同步突发存储器有效,此处未用到
	// hsram.Init.WaitSignalPolarity 	= FSMC_WAIT_SIGNAL_POLARITY_LOW; 		// 等待信号的极性,适用于突发模式访问,此处未用到
	// hsram.Init.WaitSignalActive 		= FSMC_WAIT_TIMING_BEFORE_WS; 			// 存储器是在等待周期之前的一个时钟周期还是等待周期期间使能 NWAIT,适用于突发模式访问,此处未用到
	hsram.Init.WriteOperation 			= FSMC_WRITE_OPERATION_ENABLE; 				// 写使能
	// hsram.Init.WaitSignal 			= FSMC_WAIT_SIGNAL_DISABLE; 					// 等待使能位,适用于突发模式访问,此处未用到
	hsram.Init.ExtendedMode 			= FSMC_EXTENDED_MODE_ENABLE; 					// 启用扩展模式,使用模式 A
	// hsram.Init.AsynchronousWait 		= FSMC_ASYNCHRONOUS_WAIT_DISABLE; 		// 是否使能异步传输模式下的等待信号,此处未用到
	// hsram.Init.WriteBurst 			= FSMC_WRITE_BURST_DISABLE; 					// 禁止突发写,适用于突发模式访问,此处未用到
	
	if(HAL_SRAM_Init(&hsram, &hfsmc_rw, &hfsmc_rw) != HAL_OK)
	{
	Error_Handler();
	}
}
  • 6~12行:定义NOR/SRAM时序参数;外接设备为Intel 8080接口的LCD,可以看作SRAM设备,可以使用模式1或模式A,在后面使能了扩展模式,因此这里只能使用模式A;使用模式A需要设置地址建立时间(ADDSET)和数据建立时间(DATAST)两个时序参数;

  • 6行:设置地址建立时间,取值范围为0~15个T HCLK 。由ILI9488驱动芯片手册可知,DBI B类的地址建立时间如图 4.3.1 所示,地址建立时间t ast 大于0即可,因此这里设置AddressSetupTime值为0;

  • 8行:设置数据建立时间,取值范围为1~255个T HCLK ,由ILI9488驱动芯片手册可知,如图 4.3.1 所示,数据建立时间t dst 需要大于10ns,当系统时钟为72MHz,HCLK也为72MHz,T HCLK =1/72us=13.8ns,只要1个T HCLK 就能大于t dst ,这里设置DataSetupTime值为2;

  • 图 38.3.1 时序特性
    在这里插入图片描述

  • 12行:设置FSMC的访问模式为模式A;

  • 14~30行:定义LCD(类似SRAM)属性;

    • 14~15行:设置实例类型为NOR/SRAM设备;
    • 16行:硬件上使用的FSMC_NE4连接的SRAM,因此对应BANK1(NORSRAM)的区域4;
    • 17行:地址/数据线不复用(nonmultiplexed);
    • 18行:设置存储器类型为SRAM;
    • 19行:设置16位数据宽度(所接LCD为16为数据宽度);
    • 26行:写使能;
    • 28行:启用扩展模式,这样才能使用前面设置的模式A;
  • 32~35行:使用HAL库提供的“HAL_SRAM_Init()”函数,将前面设置的NOR/SRAM时序参数和定义的属性初始化;

“HAL_SRAM_Init()”初始化函数会调用“HAL_SRAM_MspInit()”函数初始化硬件,FSMC相关的硬
件初始化如代码段 4.3.4 所示。

代码段 4.3.4 FSMC 硬件部分初始化(driver_fsmc.c)

void HAL_SRAM_MspInit(SRAM_HandleTypeDef *hsram)
{
	GPIO_InitTypeDef GPIO_InitStruct = {0};
	
	__HAL_RCC_FSMC_CLK_ENABLE(); 								// 使能 FSMC 时钟
	__HAL_RCC_GPIOC_CLK_ENABLE(); 								// 使能 GPIO 端口 C 时钟
	__HAL_RCC_GPIOD_CLK_ENABLE(); 								// 使能 GPIO 端口 D 时钟
	__HAL_RCC_GPIOE_CLK_ENABLE(); 								// 使能 GPIO 端口 E 时钟
	__HAL_RCC_GPIOF_CLK_ENABLE(); 								// 使能 GPIO 端口 F 时钟
	__HAL_RCC_GPIOG_CLK_ENABLE(); 								// 使能 GPIO 端口 G 时钟
	
	GPIO_InitStruct.Mode 				= GPIO_MODE_OUTPUT_PP; 				// 配置为推挽输出功能
	GPIO_InitStruct.Pull 				= GPIO_PULLUP; 						// 上拉
	GPIO_InitStruct.Speed 				= GPIO_SPEED_FREQ_HIGH;				// 引脚翻转速率快
	
	GPIO_InitStruct.Pin 				= LCD_RST_PIN;
	HAL_GPIO_Init(GPIOF, &GPIO_InitStruct); 					// 按上面参数配置 LCD_RST 引脚
	(PF11)
	
	GPIO_InitStruct.Pin 				= LCD_CS_PIN;
	HAL_GPIO_Init(GPIOG, &GPIO_InitStruct); 					// 按上面参数配置 LCD_CS 引脚(PG12)
	
	GPIO_InitStruct.Mode 				= GPIO_MODE_AF_PP; 					// 配置为复用推挽功能
	GPIO_InitStruct.Pull 				= GPIO_NOPULL; 						// 不上拉
	GPIO_InitStruct.Speed 				= GPIO_SPEED_FREQ_HIGH;				// 引脚翻转速率快
	
	// PDx
	GPIO_InitStruct.Pin 				= LCD_GPIOD_PIN;
	HAL_GPIO_Init(GPIOD, &GPIO_InitStruct); 					// 按上面参数配置 D 组涉及的 GPIO
	
	// PEx
	GPIO_InitStruct.Pin 				= LCD_GPIOE_PIN;
	HAL_GPIO_Init(GPIOE, &GPIO_InitStruct); 					// 按上面参数配置 E 组涉及的 GPIO
}
  1. 初始化LCD 控制器

接着是使用初始化后的FSMC接口,访问LCD控制器ILI9488。前面提到可以使用FSMC的任意地址线模拟Intel 8080接口的数据/命令切换信号。硬件上使用的FSMC_A23与D/C引脚相连,因此在访问“0x6D000000-2”地址时,FSMC_A23为低,对应命令信号,在访问“0x6D000000”地址时,FSMC_A23为高,对应数据信号。

如代码段 4.3.5 所示,定义“_lcd”结构体,里面有两个成员,每个成员为无符号16位数据类型。

代码段 4.3.5 LCD 结构体定义(driver_lcd.h)

/* LCD 指令结构体,用于读写 LCD 数据和寄存器 */
typedef struct
{
	__IO uint16_t reg; // 寄存器值
	__IO uint16_t data; // 数据值
}_lcd;

随后定义结构体“_lcd”的地址为“0x6D000000-2”,则成员“reg”地址为“0x6D000000-2”,成员“data”
的地址为“0x6D000000”,以后访问这两个地址,就可以分别发送命令、数据内容,如代码段 4.3.6 所示。

代码段 4.3.6 访问 LCD 控制器(driver_lcd.c)

#define LCD ((__IO _lcd*)(0x6C000000 + (1<<24) - 2))

/********** LCD 读写函数定义 **********/
/* 函数名: void LCD_Write_Cmd(uint16_t _cmd)
 * 输入参数:_cmd->要写入的指令
 * 输出参数:无
 * 返回值: 无
 * 函数作用:往 LCD 写入指令
*/
void LCD_Write_Cmd(uint16_t _cmd)
{
	LCD->reg = _cmd;
}
/* 函数名: void LCD_Write_Data(uint16_t _data)
 * 输入参数:_data->要写入的数据
 * 输出参数:无
 * 函数作用:往 LCD 写入数据
*/
void LCD_Write_Data(uint16_t _data)
{
	LCD->data = _data;
}

现在可以读写LCD的控制器ILI9488的寄存器了,需要根据ILI9488手册介绍的寄存器,依次进行配置。但ILI9488寄存器众多,依次配置效率不高,通常开发中,LCD屏幕厂商会提供初始化参考代码,用户直接使用即可。这里我们将厂商提供的初始化代码精简,提炼出几个重要的寄存器简单介绍一下,如代码段4.3.7 所示。

代码段 4.3.7 LCD 控制器初始化(driver_ili9488.c)

/* 函数名: static void ILI9488_RegConfig(void)
 * 输入参数:无
 * 输出参数:无
 * 返回值: 无
 * 函数作用:ILI9488 寄存器初始化
*/
static void ILI9488_RegConfig(void)
{
	LCD_Write_Cmd(0x3A); // Interface Pixel Format
	LCD_Write_Data(0x05); // DBI-16bit
	LCD_Write_Cmd(0xB6); // Display Function Control
	LCD_Write_Data(0x02); // RM:System interface
	LCD_Write_Data(0x22); // SS:S960->S1 GS:G1->G480
	LCD_Write_Data(0x3B); // NL
	
	LCD_Write_Cmd(0x11); // Sleep OUT
	LCD_Write_Data(0x00); // No parameter
	
	LCD_Write_Cmd(0x29); // Display ON
	LCD_Write_Data(0x00); // No parameter	
}
  • 9~10行:设置LCD控制器的接口像素格式;如图 38.3.2 所示,使用DBI 16位接口时,D[2:0]需要设置为0x5;

  • 图 38.3.2 LCD 控制器(Interface Pixel Format 寄存器)
    在这里插入图片描述

  • 12~15行:设置通过何种接口访问GRAM,LCD源极的驱动方向等;鉴于篇幅,这里就不展开挨个介绍,读者可以自行参考手册里该寄存器的每一位;

  • 17~18行:退出休眠,发送指令后,发送一个空数据占位;

  • 20~21行:启动显示,发送指令后,发送一个空数据占位;

正看本开发板的屏幕时,长小于宽,属于竖排显示,通过设置Memory Access Control(0x36)控制器,实现LCD的显示方向,如代码段 4.3.8 所示。

代码段 4.3.8 设置 LCD 显示方向(driver_lcd.c)

/* 函数名: void LCD_Scan_Dir(uint8_t _dir)
 * 输入参数:_dir:方向选项 0~7
 * 输出参数:无
 * 返回值: 无
 * 函数作用:设置 LCD 的方向
*/
void LCD_Scan_Dir(uint8_t _dir)
{
	// bit[7] MY:Row Address Order
	// bit[6] MX:Column Address Order
	// bit[5] MV:Row/Column Exchange
	// bit[3] BGR:RGB-BGR Order
	_dir = (_dir<<5) | 0x08; 			// 获取高 3 位值,RGB to BGR
	LCD_Write_Cmd(0x36); 				// Memory Access Control
	LCD_Write_Data(_dir); 				//根据 scan_mode 的值设置 LCD 方向,共 0-7 种模式
}

/* 函数名: void LCD_GRAM_Scan(uint8_t _opt)
 * 输入参数:_opt:扫描方式 0~7
 * 输出参数:无
 * 返回值: 无
 * 函数作用:设置 LCD 的扫描方式
*/
void LCD_GRAM_Scan(uint8_t _opt)
{
	uint8_t tmp = _opt%2;
	if(_opt > 7)
		_opt = 0;
	lcddev.setx_cmd  = 0x2A; 			// Column Address Set
	lcddev.sety_cmd  = 0x2B; 			// Page Address Set
	lcddev.gram_cmd  = 0x2C; 			// Memory Write
	lcddev.scan_mode = _opt;			// 记录扫描模式以便之后判断屏幕方向
	
	if(tmp == 0) // 0 2 4 6 竖屏
	{
		if(lcddev.dev_id == 0x9488)
		{
			lcddev.hor_res = 320;
			lcddev.ver_res = 480;
		}
	}
	else if(tmp == 1) // 1 3 5 7 横屏
	{
		if(lcddev.dev_id == 0x9488)
		{
			lcddev.hor_res = 480;
			lcddev.ver_res = 320;
		}
	}
	LCD_Scan_Dir(lcddev.scan_mode);
}

Memory Access Control寄存器的Bit[7:5]三位一起控制内存的读写方向。MY(Bit[7])控制行地址顺序,MX(Bit[6])控制列地址顺序,MV(Bit[5])控制行列交换,如图 4.3.3 所示。

  • 图 38.3.3 LCD 方向控制
    在这里插入图片描述
  1. LCD 显示功能函数

LCD正常工作后,接下来需要实现一些常用的功能函数,比如清屏、显示字符、字符串、数字变量、画线、填充等功能。

清屏功能的实现比较简单,先设置要填充的区域,然后向GRAM写入要填充的颜色即可,如代码段
4.3.9 所示。

代码段 4.3.9 LCD 清屏函数实现(driver_lcd.c)

/* 函数名: void LCD_SetCursor(uint16_t x, uint16_t y)
 * 输入参数:x:光标 x 坐标 y:光标 y 坐标
 * 输出参数:无
 * 返回值: 无
 * 函数作用:设置光标位置
*/
void LCD_SetCursor(uint16_t x, uint16_t y)
{
	LCD_Write_Cmd(lcddev.setx_cmd); 						// 设置 X 坐标指令
	LCD_Write_Data(x>>8); 									// 开始地址高 8 位
	LCD_Write_Data(x&0xFF); 								// 开始地址低 8 位
	LCD_Write_Data((lcddev.hor_res-1)>>8); 					// 结束地址高 8 位
	LCD_Write_Data((lcddev.hor_res-1)&0xFF); 				// 结束地址低 8 位
	
	LCD_Write_Cmd(lcddev.sety_cmd); 						// 设置 Y 坐标指令
	LCD_Write_Data(y>>8); 									// 开始地址高 8 位
	LCD_Write_Data(y&0xFF); 								// 开始地址低 8 位
	LCD_Write_Data((lcddev.ver_res-1)>>8); 					// 结束地址高 8 位
	LCD_Write_Data((lcddev.ver_res-1)&0xFF); 				// 结束地址低 8 位
}

/* 函数名: void LCD_Clear(uint16_t _color)
 * 输入参数:_color:颜色
 * 输出参数:无
 * 返回值: 无
 * 函数作用:LCD 清屏
*/
void LCD_Clear(uint16_t _color)
{
	uint32_t i = 0;
	uint32_t totalpoint = lcddev.hor_res * lcddev.ver_res; 	// 像素点总数
	
	lcd_color.backcolor = _color; 							// 设置背景颜色
	LCD_SetCursor(0, 0); 									// 设置显示位置
	
	LCD_Write_Cmd(lcddev.gram_cmd);							// 写入 GRAM 的指令
	for(i=0;i<totalpoint;i++) 								// 循环写像素颜色
	{
		LCD_Write_Data(lcd_color.backcolor);
	}
}
  • 9-13行:设置列地址;先设置列开始地址的高低8位,这里假设为0,然后设置列结束地址的高低8位,这里为320,也就是列地址范围为0~320;
  • 15-19行:设置行地址;先设置行开始地址的高低8位,这里假设为0,然后设置行结束地址的高低8位,这里为480,也就是列地址范围为0~480;这样设置之后,显示窗口即为整个屏幕空间;
  • 36~40行:向GRAM写入颜色数据,颜色数据将在前面设置的显示窗口显示;

接着实现在LCD上显示字符,这个稍微复杂一点,需要先实现在LCD上指定位置显示像素点,如代码段4.3.10 所示。

代码段 4.3.10 LCD 显示像素点(driver_lcd.c)

/* 函数名: void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color)
 * 输入参数:x,y:坐标 color:颜色
 * 输出参数:无
 * 返回值: 无
 * 函数作用:LCD 描点
*/
void LCD_DrawPoint(uint16_t x, uint16_t y, uint16_t color)
{
	LCD_Write_Cmd(lcddev.setx_cmd); 						// 设置 X 坐标指令
	LCD_Write_Data(x>>8); 									// 开始地址高 8 位
	LCD_Write_Data(x&0xFF); 								// 开始地址低 8 位
	LCD_Write_Data((lcddev.hor_res-1)>>8); 					// 结束地址高 8 位
	LCD_Write_Data((lcddev.hor_res-1)&0xFF); 				// 结束地址低 8 位
	
	LCD_Write_Cmd(lcddev.sety_cmd); 						// 设置 Y 坐标指令
	LCD_Write_Data(y>>8); 									// 开始地址高 8 位
	LCD_Write_Data(y&0xFF); 								// 开始地址低 8 位
	LCD_Write_Data((lcddev.ver_res-1)>>8); 					// 结束地址高 8 位
	LCD_Write_Data((lcddev.ver_res-1)&0xFF); 				// 结束地址低 8 位
	
	LCD_Write_Cmd(lcddev.gram_cmd); 						// 写入 GRAM 的指令
	LCD_Write_Data(color); 									// 写入颜色数据
}
  • 9~19行:指定显示窗口位置;
  • 21~22行:向GRAM写入一个颜色数据,因此只会在显示窗口的起始位置,显示指定的颜色;

接着实现在LCD上显示字符,这个稍微复杂一点,需要先准备ASCII字符表二维数组,每个字符由若干数据组成,定义在“font.h”里,字符显示的实现如代码段 4.3.11 所示。

代码段 4.3.11 LCD 显示字符(driver_lcd.c)

/* 函数名: void LCD_ShowChar(uint16_t x, uint16_t y, uint8_t num, uint8_t size, uint8_t mode)
* 输入参数:x,y:起始坐标 num:要显示的字符(" "--->"~") size:字体大小 12/16/24 mode: 是否叠加(1 叠加)
* 输出参数:无
* 返回值: 无
* 函数作用:在指定位置显示一个字符
*/
void LCD_ShowChar(uint16_t x, uint16_t y, uint8_t num, uint8_t size, uint8_t mode)
{
	uint8_t data, t, t1;
	uint16_t y0 = y;
	uint8_t csize = (size/8+((size%8)?1:0))*(size/2); // 得到字体一个字符对应点阵集所占的字节数
	num=num-' '; // 得到偏移后的值(ASCII 字库是从空格开始取模,所以-' '就是对应字符的字库)
	
	for(t=0; t<csize; t++)
	{
		if(size==12)
			data = asc2_1206[num][t]; //调用 1206 字体
		else if(size==16)
			data = asc2_1608[num][t]; //调用 1608 字体
		else if(size==24)
			data = asc2_2412[num][t]; //调用 2412 字体
		else
			return;
			
		for(t1=0;t1<8;t1++)
		{
			if(data&0x80) // 依次画最高位像素点
				LCD_DrawPoint(x, y, lcd_color.textcolor);
			else if(mode==0)
				LCD_DrawPoint(x, y, lcd_color.backcolor);
			
			data <<= 1;
			y++;
		
			if(y>lcddev.ver_res) // 超出垂直显示区域
				return;
			
			if((y-y0)==size) // 一列画完
			{
				y = y0;
				x++;
				if(x>lcddev.hor_res) // 超出水平显示区域
					return;
				break;
			}
		}
	}
}
  • 11行:计算字体一个字符对应点阵集所占的字节数;
  • 12行:得到字符在数组中的位置;
  • 14行:循环把该字符所有的字节挨个处理;
  • 16~23行:得到对应字库的对应字节;
  • 25行:循环处理每个字节;
  • 27~30行:取该字节最高位的数据,进行描点;
  • 32行:更新最高字节内容;
  • 33行:行数记录加1;
  • 35~36行:当显示行数大于屏幕边界,退出;
  • 38~45行:描完一列后,准备描下一列,更新相关标记;

接着实现字符串的显示,调用前面实现字符显示函数即可,注意显示边界,如代码段 4.3.12 所示。

代码段 4.3.12 LCD 显示字符串(driver_lcd.c)

/* 函数名: void LCD_ShowString(uint16_t x, uint16_t y, uint8_t size, uint8_t *p)
 * 输入参数:x,y:起点坐标 size:字体大小 *p:字符串起始地址
 * 输出参数:无
 * 返回值: 无
 * 函数作用:显示字符串
*/
void LCD_ShowString(uint16_t x, uint16_t y, uint8_t size, char *p)
{
	while((*p<='~')&&(*p>=' ')) 						// 判断是不是非法字符
	{
		LCD_ShowChar(x, y, *p, size, 0); 				// 显示该字符
		x+=size/2; // 字符位置水平偏移
		p++; // 指向下一个字符
		if(x>(lcddev.hor_res-size/2)) 					// 超出水平显示区域
		{
			x = 0;
			y+=size;
		}
		if(y>(lcddev.ver_res-size)) 					// 超出垂直显示区域
			break;
	}
}

其它的显示函数就不一一列举了,读者可直接看源码分析实现过程。

  1. 主函数控制逻辑

在主函数里,依次初始化定时器、PWM、FSMC接口和LCD控制器。然后清屏,显示字符、字符串、数字、画线、填充矩形来验证LCD显示,如代码段 4.3.13 所示。

代码段 4.3.13 主函数逻辑控制(main.c)

	printf("**********************************************\n\r");
	printf("-->嘻嘻嘻:www.xxx.net\n\r");
	printf("-->LCD 显示实验\n\r");
	printf("**********************************************\n\r");
	
	TimerInit(); // 定时器初始化
	TimerPWMInit(); // PWM 初始化
	FSMC_LCD_Init(); // FSMC 接口初始化
	LCD_Init(); // LCD 初始化
	
	LCD_Clear(WHITE); // 清屏为白色
	
	LCD_ShowChar(0, 0, 'A', 24, 0); // 显示字符
	
	LCD_SetColor(BLACK, WHITE); // 设置字符串颜色和背景色,显示字符串
	LCD_ShowString(0, 24, 24, "www.100ask.net");
	
	LCD_SetColor(WHITE, RED); // 设置字符串颜色和背景色,显示数字
	LCD_ShowNum(0, 48, 123456789, 24);
	
	LCD_DrawLine(160, 120, 70, 310, BLUE); // 画线显示三角形
	LCD_DrawLine(70, 310, 250, 310, BLUE);
	LCD_DrawLine(250, 310, 160, 120, BLUE);
	
	LCD_Color_Fill(110,311, 210,411, GREEN); // 填充矩形
	
	while(1);
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
### 回答1: 如果要学习STM32单片机,有很多可用的学习资料和资源供你参考和学习。首先,STMicroelectronics官方网站是最好的信息来源之一。在官方网站上,你可以找到Data Briefs、Technical Notes、Application Notes和User Manuals等多种文档,这些文档可以帮助你了解不同型号的STM32单片机,并提供详细的技术细节和应用示例。 此外,STMicroelectronics还提供了免费的配套开发工具和软件,如STM32CubeIDE、STM32CubeMX和HAL库等。这些工具可以帮助你开发、调试和烧写STM32单片机的代码,并提供丰富的代码库和实例,方便你快速入门。 除了官方资料外,网络上还有大量的STM32单片机学习资料和教程。你可以通过搜索引擎找到许多相关博客、论坛和视频教程,其中包括了解STM32单片机的基础知识、使用各种开发环境和编程语言进行开发,以及实际项目的应用示例等。这些资源可以帮助你深入学习STM32单片机的各个方面,并解决你在学习和项目中遇到的问题。 同时,还有一些出版的教材和参考书籍,如《精通STM32单片机》、《STM32权威指南》等,这些书籍以系统化的方式解释了STM32单片机的原理和应用,可以作为深入学习的参考资料。 总之,STM32单片机学习资料是丰富多样的,从官方资料到网络资源、教程和书籍都是很好的学习参考。结合多种源的学习材料和实践经验,你可以更好地掌握STM32单片机的开发和应用。 ### 回答2: STM32是一种广泛应用于嵌入式系统开发的32位单片机系列,具有高性能、低功耗和丰富的外设资源。学习STM32单片机需要掌握其基本原理、应用开发和编程技术等方面的知识。 首先,可以通过阅读官方提供的STM32单片机资料来进行学习。STMicroelectronics公司为STM32系列提供了官方的技术手册、应用笔记、教程和参考设计等资料,其中包含了单片机的内部结构、外设使用方法以及开发工具的介绍,有助于初学者对单片机的基本概念和应用进行了解。 其次,可以参考一些经典的STM32单片机编程教程和实例进行学习。在互联网上有很多相关的学习资源,包括视频教程、电子书和在线课程等,这些资源可以帮助初学者快速掌握STM32单片机的编程技巧和开发流程,了解如何使用STM32 HAL库和CubeMX软件进行开发。 此外,参加STM32单片机的实践项目和实验也是非常重要的学习方式。可以利用开发板或者仿真软件进行实验,从简单的LED闪烁开始,逐步深入学习各种外设的使用方法,例如串口通信、PWM输出和ADC采集等,通过实际操作来加深对STM32单片机的理解和应用。 最后,与其他STM32单片机学习者进行交流和探讨也是学习的重要途径。可以加入相关的技术社区、论坛或者参加线下的技术交流活动,与其他爱好者一起交流心得、解决问题和分享经验,共同进步。 综上所述,学习STM32单片机需要结合官方资料、编程教程、实践项目和交流讨论等多种方式,通过理论学习和实践操作相结合的方式来提高自己的技能和能力。只有不断学习和实践,才能逐步掌握STM32单片机的应用开发技术,发挥出其强大的功能。 ### 回答3: STM32单片机是一款由意法半导体公司推出的32位ARM Cortex-M系列微控制器。学习STM32单片机需要掌握一定的电子基础知识和C语言编程能力。以下是一些可供学习STM32单片机的资料推荐: 1. 官方资料:意法半导体官方网站提供了丰富的STM32单片机系列产品的技术文档、数据手册、应用笔记以及示例代码等,这些资料对于初学者和进阶者都非常有帮助。 2. 教材和教程:市面上有很多针对STM32单片机的教材和教程,其中一些是由专业人士撰写的,具有系统性和深度,适合系统学习。另外,也有一些网上的教程、博客和视频教程,可以提供实际操作示例和案例分析。 3. 社区论坛和博客:STM32单片机学习过程中,遇到问题时可以向社区论坛提问和交流。ST社区、电子爱好者论坛、知乎等地都有相关的技术讨论区,可以从其他人的经验中获得帮助。此外,还有一些博客是由学习STM32的爱好者写的,分享各种学习心得和项目经验。 4. 实验平台和开发板:购买一块能够容易上手的STM32开发板,如ST-Link V3 Mini开发板等,这样可以借助官方提供的开发环境和示例程序,快速上手进行实验和开发。 5. 项目实战:在学习的过程中,可以选择一些具体的项目进行实战。可以从简单的LED闪烁开始,逐步扩展到涉及串口通信、蓝牙、传感器和外设等更复杂的项目。 总之,学习STM32单片机需要结合官方资料、教材和教程、社区讨论和项目实战等多种资源,根据自己的兴趣和基础情况选择合适的学习路径,坚持实践,不断积累经验,就能够逐渐掌握STM32单片机的原理和应用。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值