OpenEdv-开源电子网

 找回密码
 立即注册
正点原子全套STM32/Linux/FPGA开发资料,上千讲STM32视频教程免费下载...
查看: 39|回复: 0

《ESP32-P4开发指南— V1.0》第四十一章 图片显示实验

[复制链接]

1245

主题

1259

帖子

2

精华

超级版主

Rank: 8Rank: 8

积分
5350
金钱
5350
注册时间
2019-5-8
在线时间
1376 小时
发表于 11 小时前 | 显示全部楼层 |阅读模式
本帖最后由 正点原子运营 于 2026-1-23 10:54 编辑

第四十一章 图片显示实验

1)实验平台:正点原子DNESP32P4开发板

2)章节摘自【正点原子】ESP32-P4开发指南— V1.0

3)购买链接:https://detail.tmall.com/item.htm?id=873309579825

4)全套实验源码+手册+视频下载地址:http://www.openedv.com/docs/boards/esp32/ATK-DNESP32P4.html

5)正点原子官方B站:https://space.bilibili.com/394620890

6)正点原子DNESP32S3开发板技术交流群:132780729


2.jpg

3.png

在嵌入式产品开发中,图片显示功能已经成为许多应用的基础需求。无论是在智能家居、便捷显示设备,还是其他消费类电子产品中,图像的显示和处理往往是用户界面的重要组成部分。本章将向大家介绍如何使用ESP32-P4来解码常见的图片格式,如BMP、JPG、JPEG、PNG和GIF,并通过LCD屏幕实现图像的显示。我们将从图片格式的解码原理开始,逐步讲解如何使用ESP32-P4的JPEG硬件解码和软件解码库,完成图像的加载、解码和显示工作,帮助大家掌握图像显示的基本技巧和应用。
本章分为如下几个小节:
41.1 图片格式及JPEG硬件编解码概述
41.2 硬件设计
41.3 程序设计
41.4 下载验证


41.1 图片格式及JPEG硬件编解码概述
本章将详细介绍常见图片格式(如BMP、JPG、JPEG、PNG、GIF)的文件结构,帮助读者对这些图像文件格式有更清晰的认识。我们还将深入探讨如何利用ESP32-P4的硬件JPEG编解码器进行JPEG图像的高效处理。通过本章内容的学习,读者将能够全面理解图像格式及其在ESP32-P4上的实现,从而在实际开发中充分利用这一强大功能,提升图像处理的效率。

41.1.1 BMP格式简介
BMP(全称Bitmap)是Window操作系统中的标准图像文件格式,文件后缀名为“.bmp”,使用非常广。它采用位映射存储格式,除了图像深度可选以外,不采用其他任何压缩,因此,BMP文件所占用的空间很大,但是没有失真。BMP文件的图像深度可选lbit、4bit、8bit、16bit、24bit及32bit。BMP文件存储数据时,图像的扫描方式是按从左到右、从下到上的顺序。
典型的BMP图像文件由五部分组成,如下图所示。
1)Bitmap File Header(位图头文件数据结构BITMAPFILEHEADER)
这个部分包含了文件头信息,如文件签名(表示这是一个BMP文件)、文件大小、保留字段,以及文件偏移到像素数据(PixelArray)的地址。
2)DIB Header(位图信息数据结构BITMAPV5HEADER)
该部分包含了详细的图像信息。它指定了DIB头的大小、图像宽度、高度、平面数(通常为1)、每个像素的位数(表示颜色深度),以及压缩方法。还包括图像尺寸、每米的水平和垂直像素密度、颜色表中的颜色数量、重要颜色数量等。在BMPV5版本中,还包含了通道的位掩码(红、绿、蓝、透明通道的掩码)、颜色空间类型、颜色空间端点、各颜色通道的Gamma值、ICC颜色配置文件的数据和大小等信息。
3)Color Table(调色板,半可选)
对于一些低色深图像(如8位或以下),该部分包含颜色表,每个颜色表项定义了一个颜色。颜色表对于颜色索引格式的BMP文件是必需的,但对于24位或更高位深度的文件可以省略。
4)Pixel Array(位图数据)
这是BMP文件中最重要的部分,包含实际的图像数据。像素数据从文件的底部开始按行存储(从左到右,从底到顶),每一行的末尾可能会有填充字节,以确保每行的数据大小是4字节的倍数。PixelArray的数据排列方式取决于图像的位深度和压缩方式。不同的位深度会影响每个像素的字节数,例如24位图像的每个像素由3个字节表示(红、绿、蓝通道),而32位图像则包含额外的透明通道。
5)可选的GAP区域和ICC颜色配置文件
①:GAP1和GAP2:这些是两个可选的填充区域,用于在某些场景下提供数据对齐或其它应用特定的需求。
②:ICC颜色配置文件:在一些BMP文件中,嵌入了ICC颜色配置文件,以确保在不同设备上显示一致的色彩。这个部分仅在使用BITMAPV5HEADER时出现。

第四十一章 图片显示实验1547.png
图42.1.1.1 BMP文件结构的层次和各部分之间的关系

从上图可以看出,无论是位图文件头还是其他数据结构,某些字段都包含指示方向的箭头。通过观察这些箭头的指向,可以直观地理解各个字段的作用及其在文件结构中的位置关系。箭头将各个字段串联起来,清晰地展示了BMP文件从文件头到像素数据的组织方式,帮助我们更好地理解各部分数据的布局及其功能。
在接下来的部分,笔者将以24位颜色深度的BMP图片为例,逐步拆解其文件结构,以帮助大家更直观地理解BMP格式。这里,我们使用了Hex Editor Neo十六进制编辑器来查看BMP文件的十六进制数据。大家可以根据需要下载并安装这个工具,用它来观察和分析BMP文件的内部结构。如下图是我们准备拆解的BMP图片。


第四十一章 图片显示实验1875.png
图41.1.1.2 24位颜色深度的BMP图片

首先我们把这一张BMP图片使用Hex Editor Neo十六进制编辑器打开,打开之后,我们就可以看到以下数据,如下图所示。

第四十一章 图片显示实验1964.png
图41.1.1.3 BMP图片数据(部分截图)

注意:bmp图片文件头中数据是以小端序存储的(低位在前,高位在后),因此我们阅读时,需要做一下调整。接下来我们对位图文件头、DIB 头、调色板和图像数据进行分析。
1,位图文件头
从图41.1.1.1中可以看到,BITMAPFILEHEADER结构体用于描述位图文件的头部信息,包含5个字段,总占用14个字节。下面,我们将逐一解析该结构体中的各个字段,如下图所示。


第四十一章 图片显示实验2173.png
图41.1.1.4 位图头文件数据结构各个字段描述信息

在上图中,我们已经获得了位图头文件数据结构的基本信息。接下来,我们将以表格形式详细讲解这些字段的信息,如下表所示。

1.png
表41.1.1.1 位图头文件数据结构各个字段描述信息

从上表中的信息可以看出,该文件是一个BMP格式的图像,文件大小为230454字节。大家可以参考图41.1.1.2中图像的属性(见下图所示),从中也可以确认该图像的文件大小。根据BMP文件的格式,图像数据从头部偏移量54字节开始,因此我们可以从该偏移位置获取图像数据的首地址。

第四十一章 图片显示实验2619.png
图42.1.1.5 BMP图片的文件大小

2,位图信息数据结构
从图41.1.1.1中可以看到,BITMAPV5HEADER结构体用于描述位图信息数据结构信息,包含11个字段(到Important Color Count截至,若是BMPV5版,则才用通道的位掩码等字段 ),总占用40个字节。下面,我们将逐一解析该结构体中的各个字段,如下图所示。


第四十一章 图片显示实验2795.png
图41.1.1.6 位图信息数据结构各个字段描述信息

在上图中,我们已经获得了位图头文件数据结构的基本信息。接下来,我们将以表格形式详细讲解这些字段的信息,如下表所示。

2.png
表41.1.1.2 位图信息数据结构各个字段描述信息

3,图像数据(Image Data)
这张图像的位深度为24位,表示每个像素占用3字节,由三个8位的RGB分量组成,其颜色分量的排列顺序是BGR(蓝、绿、红)。因此,每个像素的颜色数据按蓝色、绿色、红色的顺序存储。图像的尺寸为240x320,总共有240 * 320 = 76800个像素。因此,图像数据的大小为76800 * 3 = 230400字节。图像数据的起始位置位于偏移量为54字节的位置。下图显示了图像数据的起始部分。


第四十一章 图片显示实验3556.png
图41.1.1.7 BMP图像数据部分截图

在前面我们已经了解了BMP图像的扫描顺序是“从左到右、从下到上”。因此,在上图中0x36A000代表图像的左下角像素点(见下图所示),而0x47b00e代表的是该像素点右边第一个像素,即左下角的第二个像素点。依此类推,每行的像素点按顺序排列,直到绘制完当前行。
当一行绘制完成后,接下来会继续绘制到左下角的第二行,这一行的起始像素位置紧接在第一行的结束位置之后。整个图像按这种顺序逐行绘制,从底部到顶部逐步填充每一行的数据。


第四十一章 图片显示实验3793.png
图41.1.1.8 左下角的像素颜色值

大家可以对比一下这个颜色值与图42.1.1.2的图片左下角的第一个像素点是否一致。
注意:为保证文件的兼容性和CPU读写效率,BMP文件格式通常要求像素数据的每一行字节数必须是4的倍数。在这种情况下,每个像素只需要占用一个字节,因此不需要填充字节来调整行的大小。若不是4的倍数,则需要在后面填充0数据来补充。

41.1.2 PNG格式简介
PNG是20世纪90年代中期开始开发的图像文件存储格式,其目的是企图替代GIF和TIFF文件格式,同时增加一些GIF文件格式所不具备的特性。流式网络图形格式(Portable Network Graphic Format,PNG)名称来源于非官方的“PNG's Not GIF”,是一种位图文件(bitmap file)存储格式,读成“ping”。PNG用来存储灰度图像时,灰度图像的深度可多到16位,存储彩色图像时,彩色图像的深度可多到48位,并且还可存储多到16位的α通道数据。PNG使用从LZ77派生的无损数据压缩算法。
1,PNG的主要特点:
1)无损压缩:PNG使用无损压缩算法,能够保留图像的所有数据,适合需要精确图像的应用,如徽标、图表和文字密集型图像。
2)支持多种图像类型:PNG可以处理以下几种类型的图像:
基于调色板的图像:使用24位RGB或32位RGBA色彩。
灰度图像:可以包含透明度(Alpha通道)。
全彩图像:不使用调色板的RGB或RGBA图像。
3)透明度:PNG支持Alpha通道的透明度,使其成为网页图像的理想选择,能够平滑图像的边缘或进行图像叠加。
2,PNG图像组成
典型的PNG图像文件由以下三部分组成:
1)文件署名域(File Signature)
这是文件的开头部分,由8个字节组成,用于标识该文件是一个PNG文件。其值固定为:89 50 4E 47 0D 0A 1A 0A,这部分用于识别文件格式,确保该文件为PNG格式。
2)关键数据块(Critical Chunk)
这是PNG文件必须包含的数据块,包括:
IHDR块:该块包含图像的基本信息,如图像的宽度、高度、位深度、颜色类型、压缩方法、滤波器方法和隔行扫描方法等。
IDAT块:该块包含了实际的图像数据,经过压缩处理。PNG文件可能包含多个IDAT块,这些块一起表示完整的图像数据。
IEND块:该块标志着PNG文件的结束,表示文件内容已经完整,解码器应停止读取。
3)可选辅助数据块(Ancillary Chunk)
这些是可选的数据块,用于存储与图像相关的附加信息,如文本注释、透明度信息、背景色等。这些块对于图像的解码并非必需,但如果存在,解码器应能够解析它们。常见的辅助数据块包括:
&#61548LTE块:调色板图像使用的调色板数据块,定义图像的颜色索引。
tEXt块、zTXt块、iTXt块:用于存储文本信息,可以包含图像的元数据、作者信息或版权声明等。
bKGD块:定义图像的背景色,适用于调色板图像。
tRNS块:用于存储透明度信息,可以指定调色板图像的透明像素或RGB图像的透明度数据。
3,PNG图像文件解析
在接下来的部分,笔者将以32位颜色深度的PNG图片为例,逐步拆解其文件结构,以帮助大家更直观地理解PNG格式。这里,我们使用了Hex Editor Neo十六进制编辑器来查看PNG文件的十六进制数据。如下图是我们准备拆解的PNG图片。

第四十一章 图片显示实验5218.png
图41.1.2.1 32位颜色深度的PNG图片

首先我们把这一张PNG图片使用Hex Editor Neo十六进制编辑器打开,打开之后,我们就可以看到以下数据,如下图所示。

第四十一章 图片显示实验5307.png
图41.1.2.2 PNG图片数据(部分截图)

接下来,笔者根据PNG十六进制数据对PNG文件的文件署名域、关键数据块和辅助数据块进行分析。
1)文件署名域
在PNG文件前8字节的PNG文件署名域用来识别该文件是不是PNG文件,如下所示。


第四十一章 图片显示实验5429.png
图41.1.2.3 PNG文件署名数值

从上图可以看到,PNG首地址的前八字节与我们之前讲解的PNG文件署名是一致,所以该文件为PNG文件。
2)PNG数据块
PNG文件中的每个数据块都由下表所示的的4个域组成。


3.png
表41.1.2.1 PNG文件数据块的结构

PNG文件中的关键数据块的4个标准数据块分别是文件头数据块IHDR、调色板数据块PLTE、图像数据块IDAT、图像结束数据块IEND。下面我们逐个讲解这些块的信息。
①:文件头数据块IHDR
文件头数据块的数据块类型码为IHDR,它包含有PNG文件中存储的图像数据的基本信息(见下图所示),并作为第一个数据块出现在PNG数据流中,而且一个PNG数据流中只能有一个文件头数据块。


第四十一章 图片显示实验5894.png
图41.1.2.4文件头数据块IHDR的信息

之前笔者讲解过,每一个数据块都包含了表41.1.2.1结构,来描述文件头数据块IHDR的相关信息,下面笔者根据上图的消息,来填写文件头数据块IHDR数据结构体的相关字段,如下表所示。

4.png
表41.1.1.2 文件头数据块IHDR各个字段描述信息

②:调色板数据块PLTE
调色板数据块的数据块类型码为PLTE,它包含有与索引彩色图像相关的彩色变换数据,它仅与索引彩色图像有关,而且要放在图像数据块(image data chunk)之前。真彩色的PNG数据流也可以有调色板数据块,目的是便于非真彩色显示程序用它来量化图像数据,从而显示该图像。调色板数据块结构如下表。


5.png
表41.1.1.3 调色板数据块PLTE各个字段描述信息

注意:调色板数据块并非一定有的,样例图片中就没有包含调色板数据块。
③:图像数据块IDAT
图像数据块的数据块类型码是IDAT,存储实际的数据,在数据流中可包含多个连续顺序的图像数据块。下图为图像数据块IDAT相关信息。


第四十一章 图片显示实验6991.png

第四十一章 图片显示实验6993.png
图41.1.2.5 图像数据块IDAT信息

这里的数据都是经过压缩算法输出的数据。图像数据块IDAT的数据如下所示。

6.png
表41.1.1.4 图像数据块IDAT各个字段描述信息

从上表可以看出,我们的PNG图像数据为5455字节。
④:图像结束数据块IEND
图像结束数据的数据块类型码是IEND,它用来标记PNG文件或者数据流的结束,并且必须放在文件的尾部。IEND 数据块通常是PNG文件的最后一个数据块,指示文件的内容已完全解析,解码器可以停止处理,如下图所示。


第四十一章 图片显示实验7478.png
图41.1.1.6 图像结束数据块IEND信息

从上图可以看到,0x00000000表示结束块的长度为0,而49 45 4e 44表示IEND数据块的类型字段,后面的四个字节 ae 42 60 80 则是IEND类型字段的CRC校验码。
至于可选辅助数据块,大家可自行查阅相关的资料。

41.1.3 JPEG格式简介
JPEG是Joint Photographic Experts Group(联合图像专家组)的缩写,文件后辍名为“.jpg”或“.jpeg”,是最常用的图像文件格式,由一个软件开发联合会组织制定,同BMP格式不同,JPEG是一种有损压缩格式,能够将图像压缩在很小的储存空间,图像中重复或不重要的资料会被丢失,因此容易造成图像数据的损伤(BMP不会,但是BMP占用空间大)。尤其是使用过高的压缩比例,将使最终解压缩后恢复的图像质量明显降低,如果追求高品质图像,不宜采用过高压缩比例。但是JPEG压缩技术十分先进,它用有损压缩方式去除冗余的图像数据,在获得极高的压缩率的同时能展现十分丰富生动的图像,换句话说,就是可以用最少的磁盘空间得到较好的图像品质。而且JPEG是一种很灵活的格式,具有调节图像质量的功能,允许用不同的压缩比例对文件进行压缩,支持多种压缩级别,压缩比率通常在10:1到40:1之间,压缩比越大,品质就越低;相反地,压缩比越小,品质就越好。比如可以把1.37Mb的BMP位图文件压缩至20.3KB。当然也可以在图像质量和文件尺寸之间找到平衡点。JPEG格式压缩的主要是高频信息,对色彩的信息保留较好,适合应用于互联网,可减少图像的传输时间,可以支持24bit真彩色,也普遍应用于需要连续色调的图像。
JPEG文件由多个部分组成,各部分共同定义图像的编码方式和存储内容。具体如下:
1)SOI(Start of Image):文件头标识,标志着图像文件的开始,通常为固定值0xFFD8。
2)APP0(Application Segment 0):图像识别信息,含JFIF标识以及图像的分辨率等信息。
3)DQT(Define Quantization Table):量化表的定义。JPEG使用量化表来压缩图像数据,降低文件大小。
4)SOF0(Start of Frame 0):图像的基本信息,包括图像的宽度、高度和颜色通道数等。
5)DHT(Define Huffman Table):定义 Huffman 编码表,用于压缩图像数据。
6)DRI(Define Restart Interval):定义重新开始间隔,是一个可选字段,用于图像解码时的重新同步。
7)SOS(Start of Scan):扫描行的开始,标志着图像数据的开始。
8)Image data:经过压缩的图像内容数据,包含实际的图像像素信息。
9)EOI(End of Image):文件尾标识,标志着图像文件的结束,通常为0xFFD9。
其中,DRI(Define Restart Interval)为可选部分,其余部分是JPEG文件必不可少的组成数据。下图展示了一张 JPEG 图片的完整数据结构,有助于理解文件的整体组成。

第四十一章 图片显示实验8825.png
图41.1.3.1 JPEG文件数据结构

接下来,将以以下JPEG图片为例,使用十六进制编辑器打开,揭示该图片的真实内容。

第四十一章 图片显示实验8890.png
图41.1.3.2 本章节需要解析的JPEG图片

上图的JPEG文件大小为18546字节,分辨率为240*320,水平和垂直分辨率均为96dpi,颜色深度为24位。接下来,我们将根据这些图片信息,逐一讲解图42.1.3.1中的各个组成部分。
1,SOI(Start of Image)
JPEG文件的开始2个字节都是FF D8,这是JPEG协议规定的,如下图所示。


第四十一章 图片显示实验9075.png
图41.1.3.3 文件头标识

从上图可以看出,该文件的前两个字节分别为FF和D8,标志着该文件为JPEG格式。
2,APP0(Application Segment 0)
在JPEG文件格式中,APP0(Application Segment 0)是紧跟在SOI(Start of Image)标记后的一个段类型。接下来,我们将详细解析APP0段的信息。


第四十一章 图片显示实验9256.png
图41.1.3.4 图像识别信息

如上图所示,18个字节用于描述该JPEG图像的分辨率等信息,具体内容如下表所示。

7.png
表41.1.3.1 APP0(Application Segment 0)图像识别信息描述

上表中,水平方向和垂直方向的密度均为96dpi,恰好与我们前面讲解的图42.1.3.2中JPEG图片的水平方向和垂直方向密度一致。
3,DQT(Define Quantization Table
JPEG使用量化表来压缩图像数据,以减小文件大小。接下来,我们将详细解析量化表的信息,如下所示。


第四十一章 图片显示实验9800.png
图41.1.3.5 DQT量化表数据

从上图可以看出,这张JPEG图片包含两个量化表:第一张用于设置亮度,第二张用于设置色度。接下来,我们将解析这两张量化表的详细信息,如下表所示。

8.png
表41.1.3.2 DQT量化表信息

从上表可以看出,无论是量化表1还是量化表2,它们的量化数据都是64个字节的。
4,SOF0(Start of Frame 0)
该段类型主要描述图像的基本信息,包括图像的宽度、高度和颜色通道数等。下面我们来看一下SOF0段类型的信息,如下图所示。


9.png
表41.1.3.3 SOF0信息

通过上表解析,我们得到该图像的水平分辨率和垂直分辨率为240*320,与JPEG图片的分辨率一致。
5,DHT(Define Huffman Table)
该段类型用于定义 Huffman 编码表,用于压缩图像数据。下面我们来看一下DHT段类型的信息,如下图所示。


第四十一章 图片显示实验10865.png
图41.1.3.7 DHT数据

从上图可以看出,我们这张JPEG图片定义了4张Haffman 表,它们分别为亮度DC Haffman 表、亮度AC Haffman 表、色度DC Haffman 表和色度AC Haffman 表。下面,笔者以第一张亮度DC Haffman 表为例进行讲解,其他Haffman 表就不细讲了。

10.png
表41.1.3.4 DHT信息

6,SOS(Start of Scan)
该段类型用于标识扫描行的开始,标志着图像数据的起始。接下来,我们将解析DHT段类型的信息,如下图所示。


第四十一章 图片显示实验11330.png
图41.1.3.8 SOS数据

下面,我们来看一下SOS数据如何标识扫描行的开始,如下表所示。

11.png
表41.1.3.5 SOS信息

SOS段之后是压缩后的图像数据(按扫描行排列)。数据存放顺序是从左到右、从上到下。由于这些数据本质上是图像数据,因此这里不再详细讲解。
7,EOI(End of Image)
如果JPEG文件的最后两个字节为FF D9,则表示图像文件的结尾,如下图所示。


第四十一章 图片显示实验11829.png
图41.1.3.9 EOI数据

至此,JPEG文件的核心知识讲解完毕。如需了解更多段类型,敬请参考专业的JPEG文档。

41.1.4 GIF格式简介
GIF(Graphics Interchange Format)是CompuServe公司开发的图像文件存储格式,1987年开发的GIF文件格式版本号是GIF87a,1989年进行了扩充,扩充后的版本号定义为GIF89a。GIF图像文件以数据块(block)为单位来存储图像的相关信息。一个GIF文件由表示图形/图像的数据块、数据子块以及显示图形/图像的控制信息块组成,称为GIF数据流(DataStream)。数据流中的所有控制信息块和数据块都必须在文件头(Header)和文件结束块(Trailer)之间。下面我们来看一下GIF文件格式组成部分,如下图所示。

第四十一章 图片显示实验12188.png
图41.1.4.1 GIF文件格式组成

上图展示了GIF文件格式的结构,包括各个部分的组成和顺序。图中的不同颜色和样式的边框区分了必选部分和可选部分。以下是各个部分的解析:
1)Header(标头): 这是文件的开头部分,是必需的,用于标识文件类型和版本信息。
2)Logical Screen Descriptor(逻辑屏幕描述符):紧接着 Header,是必需的部分。它定义了 GIF 图像的宽度、高度、颜色深度等基本信息。
3)Global Color Table(全局颜色表):可选部分,包含 GIF 使用的颜色表。如果图像具有全局颜色表,则在文件中加载一次,以供图像中的每一帧使用。
4)Graphic Control Extension(图形控制扩展):可选部分,定义了与动画有关的信息,比如每一帧的延迟时间和透明度设置。
5)Image Descriptor(图像描述符):必需的部分,用于定义图像帧的大小和位置。每个图像(或帧)都需要一个Image Descriptor。
6)Local Color Table(局部颜色表):可选部分,某些帧可能使用局部颜色表,这样每一帧可以有不同的颜色表,增强了灵活性。
7)Image Data(图像数据):必需的部分,包含图像像素数据的压缩信息。
8)Plain Text Extension(纯文本扩展):必需部分,用于在图像中添加文本信息(不常见)。
9)Application Extension(应用扩展):必需部分,通常用于定义特定应用程序的自定义信息,例如循环控制(循环播放 GIF 动画)。
10)Comment Extension(注释扩展):可选部分,允许在 GIF 文件中添加注释或元数据。
11)Trailer(结尾): 必需的部分,用于标识 GIF 文件的结束。
图中框框使用实线和虚线区分了必需和可选部分。必需的部分(如 Header、Logical Screen Descriptor、Image Descriptor、Image Data 、Plain Text Extension、Application Extension和 Trailer)确保 GIF 文件的基本结构,而可选部分允许 GIF 增强功能,如动画、透明度和注释。
接下来。笔者使用十六进制编辑器打开下图gif,并使用该gif来解析GIF文件格式。


第四十一章 图片显示实验13193.png
图41.1.4.2 待解析的GIF文件

该GIF文件的大小为22988字节,分辨率为700*700像素,颜色深度为8位。通过这些信息,我们可以分析 GIF 文件结构的各部分数据是否对应正确。
1,Header(标头),共6个字节
通过前3个字节判断文件是否为GIF格式,后3个字节判断GIF格式的版本。Header(标头)信息如下图所示。


第四十一章 图片显示实验13365.png
图41.1.4.3 Header(标头)数据

从上图可以看到,该6个字节经过解码得到GIF89a,其中GIF表示该文件是GIF格式,而89a表示GIF格式版本。
2,逻辑屏幕标识符(Logical Screen Descriptor),共7个字节
逻辑屏幕标识符配置了 GIF 一些全局属性,我们通过读取解析它,获取GIF全局的一些配置。逻辑屏幕标识符信息如下图所示。


第四十一章 图片显示实验13552.png
图41.1.4.4 逻辑屏幕标识符数据

为了理解上图中的7个字节的具体含义,我们首先需要了解逻辑屏幕标识符的数据结构。下表展示了每个字节在该数据结构中的作用。

12.png
表41.1.4.1 逻辑屏幕标识符数据结构各个字段描述

逻辑屏幕标识符的7个字节用于定义GIF图像的基本显示属性,包括屏幕的宽高、全局颜色表的存在与大小、颜色深度、背景颜色等信息。具体来说,屏幕尺寸被设置为700*700像素,并且启用了全局颜色表(m位为1)。颜色深度通过cr位字段设定为8(即8种颜色)。pixel 字段定义了全局颜色表的大小,这里为2。即 8 种颜色,计算公式如下所示。

第四十一章 图片显示实验13979.png

最后就是像素宽高比,如果像素宽高比字段的值为 0,则表示图像的像素是正方形,即宽高比为 1:1,设备无需进行调整。
3,全局颜色表(Global Color Table),可变长度
在GIF文件格式中,全局颜色表(Global Color Table)是一个紧随逻辑屏幕描述符(Logical Screen Descriptor)之后的可选数据块,用于存储图像的调色板信息。每种颜色由3个字节表示,分别代表红、绿、蓝(RGB)分量的值。全局颜色表的大小可以通过逻辑屏幕描述符中的pixel字段计算得到,大小为8。例如,当pixel值为2时,全局颜色表的大小为8种颜色,每种颜色占3个字节,总计24个字节,如下图所示。


第四十一章 图片显示实验14290.png
图41.1.4.5 全局颜色表的颜色值

从上图可知,不同的颜色索引对应了不同的颜色值,下面笔者以表格形式描述,如下。

13.png
表41.1.4.2 全局颜色表的颜色值与索引关系

全局颜色表定义了图像可以使用的颜色范围(上表中的颜色值与图42.1.4.2的GIF所用颜色一致)。它通过索引映射的方式,将这些预定义的颜色应用于像素数据,从而减少文件的存储需求。在GIF文件中,每个像素的数据并不包含具体的RGB颜色值,而是引用全局颜色表中的颜色索引。这种索引机制允许图像使用一个有限的颜色集合来渲染,而不需要为每个像素重复存储完整的颜色信息,从而大大压缩了文件的大小和存储需求。
4,应用扩展(Application Extension),共19个字节
在GIF文件中,Application Extension块用于包含特定应用程序的信息,通常不具有通用用途。最常见的应用扩展是用于循环动画GIF的Netscape 2.0循环块扩展。它允许GIF文件设置动画的循环次数,从而实现持续播放或限定播放次数的动画效果。下面我们来看一下Netscape 2.0 循环块扩展的结构,它必须紧跟在逻辑屏幕描述符的全局颜色表之后,总共 19 个字节,具体信息,如下图所示。


第四十一章 图片显示实验14933.png
图41.1.4.6 应用扩展相关数据

前面我们了解到,Application Extension具有19个字节,那么这些字节代表怎么样的信息呢?下面,我们来看一下Application Extension的数据结构,如下所示。

14.png
表41.1.4.3 应用扩展信息描述

5,图形控制扩展(Graphic Control Extension),共8个字节
在 GIF 89a 版本中,图形控制扩展块(Graphic Control Extension)用于在每个图像块(图像标识符)之前控制当前帧的显示方式。它的长度固定为8个字节,提供了对图像块的透明度、延迟时间等的控制,使动画GIF的每一帧可以拥有更丰富的显示效果。具体信息如下。


第四十一章 图片显示实验15623.png
图41.1.4.7 图形控制扩展数据

下面我们以表的形式来讲解图形控制扩展信息,如下表所示。

16.png
表41.1.4.4 图形控制扩展信息描述

从上表可以看到,这张GIF图片的图形控制扩展块定义了其每帧的显示特性。通过不指定特定的处置方法,当前帧将直接覆盖之前的帧显示内容,不保留上一帧的图像信息。此外,该GIF未启用透明色,这意味着所有像素均会按照全局颜色表的颜色被完整渲染,不会有任何透明背景效果。延迟时间被设置为50个1/100秒(即0.5秒),使得每帧在动画播放时有一定的停留时间,从而实现平滑的视觉过渡。这些显示控制设置使得GIF在不同帧之间过渡顺畅,且无透明效果。
6,注释扩展(Comment Extension),可变长度
Comment Extension是GIF文件中一个可选的扩展块,通过标识符21 FE开始,用于将ASCII文本嵌入到GIF文件中。这一部分信息通常用于添加描述性或注释性内容,例如图像描述、作者信息,或其他人类可读的元数据。下图是本章节使用到的GIF文件提供的注释扩展信息。


第四十一章 图片显示实验16294.png
图41.1.4.8 注释扩展数据

图42.1.4.8中展示了当前 GIF 文件的注释扩展信息。关于数据的读取逻辑,或许会有人有疑问:已知21 FE是注释扩展的标识,而该扩展块的长度是可变的,那么我们是如何确定这一部分的截取内容的?实际上,从21 FE开始读取数据时,只要遇到0x00,表示当前注释扩展的结束。因此,读取到0x00前的所有字节均属于该注释扩展的数据内容。如果读取到的不是0x00则继续读取,直到遇到0x00为止。
7,图像标识符(Image Descriptor),共10个字节
Image Descriptor描述了每一帧图像的详细信息,包括图像的位置、尺寸以及是否包含局部颜色表。在 GIF 文件中,为了优化文件大小,每一帧可能仅存储与上一帧或第一帧的差异部分,因此宽高参数表示的是当前帧覆盖区域在屏幕中的位置和尺寸。每个 Image Descriptor 块以字节0x2C(对应逗号,ASCII码)作为标识符。一个GIF文件可以包含多个图像块,每个图像块都有一个图像标识符,描述当前帧的属性。接下来,我们将详细介绍图像标识符中的关键信息。


第四十一章 图片显示实验16776.png
图41.1.4.9 图像标识符数据

上图的字节数据具体描述如下表所示。

16.png
表41.1.4.5 图像标识符信息描述

上表第十个字节的各标志意义如下:
1)m为局部颜色表标志(Local Color Table Flag):当该标志置位时,表示紧接在图像标识符之后会有一个局部颜色表供紧随其后的图像使用;如果该标志未置位,则表示使用全局颜色表,并忽略局部颜色表的设置。
2)i为交织标志(Interlace Flag):当该标志置位时,表示图像数据使用交织方式排列;如果未置位,则使用顺序排列方式。
3)s为分类标志(Sort Flag):如果该标志置位,表示紧随其后的局部颜色表是分类排列的,通常意味着该颜色表按某种规则进行了排序,以优化存储或显示效果。
4)r为保留位:该位必须初始化为 0,用于将来的扩展或兼容性考虑。
5)pixel为局部颜色表大小(Size of Local Color Table):该值表示局部颜色表的大小,实际大小为pixel + 1,即局部颜色表包含pixel + 1个颜色条目。
从上述的内容可知,本章节讲解的GIF文件并没有局部颜色表,且图像数据使用顺序排列。
8,局部颜色表(Local Color Table),可变长度
如果GIF文件包含局部颜色表,则其数据结构与全局颜色表的数据结构相同。局部颜色表定义了特定帧图像的颜色信息,并按相同的格式存储颜色条目。然而,正如我们刚刚了解到的,本章节所讨论的GIF文件并不包含局部颜色表,因此本小节将不涉及这一部分内容。
9,基于颜色表的图像数据(Image Data),
GIF图像数据是基于颜色表索引的,这意味着每个像素的数据实际上是指向颜色表的一个索引值,而不是直接存储的颜色值。这样设计有助于更高效地压缩数据。GIF图像数据通过LZW(Lempel-Ziv-Welch)算法进行压缩,这种压缩方式在GIF 87a和89a版本中保持一致。数据域中每个数据块的结构如下所示:


第四十一章 图片显示实验17806.png
图41.1.4.6 颜色表的图像数据结构

接下来,我们来看一下颜色表的图像数据信息,如下图所示。

第四十一章 图片显示实验17858.png
图41.1.4.7 颜色表的图像数据数据

从图中可看出,标记为①的位置指示了LZW编码的初始表大小为3位,数据块的大小为255字节。根据GIF格式的编码规则,图像数据是分块存储的,每块的数据大小可达255字节。
以下是具体的处理规则:
1)数据块大小:每个数据块的第一个字节指示该块包含的字节数量(通常为255,标记为 ①),接下来的数据字节存储LZW压缩后的图像数据。
2)多块数据处理:当255字节的数据块填满后,若图像数据尚未结束,后续的数据块(标记为 ②)会继续包含255字节。
3)块结束标志:当上一个数据块填满后,读取到一个0x00的数据时,表示当前颜色表的图像数据已经结束LZW压缩的图像数据部分到此结束。
10,纯文本扩展(Plain Text Extension)
Plain Text Extension是GIF文件格式中一个不常用的扩展块,专门用于在图像上显示文本内容。它的起始标识符为0x21 0x01,但由于这个特性没有普遍支持,主流的浏览器和图像处理软件(如Photoshop)通常会忽略它。即使在一些支持GIF格式的库中,比如GIFLIB,也不会对它进行解析。
11,文件结尾(Trailer),共1个字节
标识GIF文件结束,固定值0x3B,如下图所示。


第四十一章 图片显示实验18404.png
图41.1.4.8文件结尾数据

41.1.5 硬件JPEG编解码概述
JPEG(Joint Photographic Experts Group)是一种广泛应用于数字图像的有损压缩算法,尤其是在数码摄影中。其压缩比通常能达到10:1,同时保持肉眼几乎不可察觉的图像质量损失。ESP32-P4的JPEG编解码器是一种基于JPEG基线标准的图像编解码器,用于压缩(编码)和解压(解码)图像,旨在降低传输图像所需的带宽或存储图像所需的空间,使得处理高分辨率图像成为可能。JPEG图像编码过程主要包括离散余弦变换(DCT)、锯齿扫描、量化和哈夫曼编码。图像解码过程是编码过程的逆操作,主要包括哈夫曼解码、逆量化、逆锯齿扫描和逆离散余弦变换(IDCT)。
ESP32-P4的JPEG硬件编解码特性
1,作为编码器时:

1)集成离散余弦变换(DCT)和哈夫曼编码算法。
2)支持输入格式:YUV444、YUV422、YUV420、灰度(GRAY),RGB888/RGB565(转换为YUV格式后支持)。
3)4 个可配置量化系数表(8位或16位精度)。
4)支持静态图像压缩(最高4K分辨率)和动态图像压缩(1080P@40fps,720P@70fps)。
5)自动填充零字节并添加结束标记(EOI)。
2,作为解码器时:
1)集成逆离散余弦变换(IDCT)和哈夫曼解码。
2)支持解码的图像格式:YUV444、YUV422、YUV420、灰度(GRAY)。
3)支持量化系数表和哈夫曼表(DC和AC各2个)。
4)输出分辨率:根据输入图像格式调整(水平和垂直分辨率会是 8 或 16 的倍数)。
5)性能支持静态图像解码(最高4K分辨率)和动态图像解码(1080P@40fps,720P@70fps)。
注意:JPEG 编解码器的一个重要特性是,它的编解码引擎不能同时作为编码器和解码器工作,用户需要根据实际需求选择解码或编码功能。

41.1.5.1 JPEG硬件架构与工作原理
1,JPEG 编解码系统
在前面的内容中,我们了解了ESP32-P4支持JPEG硬件编解码两种模式:编码和解码。但需要注意的是,这两种模式无法同时执行。接下来,我们将介绍ESP32-P4的JPEG编解码器的内部连接和数据流,如下图所示。

第四十一章 图片显示实验19360.png
图41.1.5.1 JPEG编解码系统连接框图

上图中,它描述了JPEG硬件编解码的选择与数据流向,具体流程如下:
1)JPEG模式选择
可以将编解码器配置为编码器或解码器,这决定了系统中的数据流方向。
2)数据传输
①:编码模式:在编码模式下,编解码器通过2D DMA TX(使用通道CH0/1/2发送)接收原始图像数据(picture),并将图像数据传输至JPEG ENCODER编码器模块。
②:解码模式:在解码模式下,编解码器通过 2D DMA TX(使用通道 CH0/1/2 接收)获取压缩的比特流,并将比特流传输至JPEG DECODER解码器模块。
3)JPEG 编码与解码
①:JPEG编码器:负责将原始图像数据压缩成JPEG比特流。
②:JPEG解码器:负责将JPEG比特流解压缩为图像数据。
4)编解码输出
①:编码,生成JPEG压缩位流,并通过 2D DMA RX(CH0/1)将其输出,形成最终的JPEG文件。
②:解码,将压缩的 JPEG 数据流通过2D DMA RX(CH0/1)将其输出至目标区域,并转换为可显示的图像数据。
根据上文所述的整体流程,下面我们将详细讲解ESP32-P4中JPEG编码器 (JPEG ENCODER)和JPEG解码器(JPEG DECODER))的工作原理,重点介绍它们的功能和如何与硬件资源进行交互。
1,JPEG硬件编码
JPEG编码的核心目的是将图像数据转换为一种紧凑的、易于存储和传输的数据流。这一过程将图像的像素数据压缩为更小的数据量,同时尽可能保留图像质量。下面将详细介绍从图片输入到数据流的转化过程,如下图所示。


第四十一章 图片显示实验20056.png
图41.1.5.2 JPEG硬件编码流程

上图展示了JPEG编码器的架构,JPEG硬件编码流程大致分为以下几个阶段,每个模块的功能和解码步骤如下:
1)数据传输:
通过2D DMA将内存中将图像数据传输至JPEG编码器,以实现高效的数据传输。这一步确保图像数据可以快速、连续地传输至编码器模块进行处理。
2)颜色空间转换:
rgb2ycrcb模块将RGB格式的图像转换为YCrCb格式。YCrCb颜色空间分离了亮度信息(Y)和色度信息(Cr 和 Cb),有助于压缩处理,因为色度信息的分辨率可以降低而不会明显影响图像质量,从而减少数据量。
3)二维离散余弦变换(DCT):
dct_idct模块对图像的8x8像素块执行二维离散余弦变换(DCT),将图像数据从空间域转换为频率域。DCT将大部分图像信息集中在低频分量上,这有助于压缩,因为高频信息可以在一定程度上被舍弃而对图像质量影响较小。
4)之字形扫描:
Zigzag模块对DCT转换后的数据进行“之字形”扫描,将低频分量排列在前。这种排列方式提高了编码效率,因为低频信息更重要且更常出现,便于后续量化和压缩。
5)量化表:
quant_table模块的量化表用于存储由软件提供的量化系数。这些系数用于控制不同频率分量的精度,量化表的值越高,对应频率分量的信息保留越少,从而达到更高的压缩率,但可能降低图像质量。
6)量化:
Quantizer模块的量化器根据量化表中的系数对经过之字形扫描的数据进行量化处理。通过量化,减少数据精度并丢弃一些不太重要的信息,以实现压缩目的。
7)霍夫曼编码:
huffman_encoder模块使用霍夫曼编码对量化后的数据进行无损压缩。霍夫曼编码将常见的符号用较短的编码表示,从而减少数据量并提高压缩率。
8)数据打包:
Packer模块将霍夫曼编码后的数据按32位分组打包,生成最终的JPEG压缩位流,并通过 2D DMA将其输出,形成最终的JPEG文件。
整个JPEG编码流程可以概括为:数据传输(2D DMA)→颜色空间转换(rgb2ycrcb)→DCT 变换(dct_idct)→之字形扫描(zigzag)→量化表(quant_table)量化(quantizer)→霍夫曼编码(huffman_encoder)→数据打包(packer)。通过这些步骤,JPEG 编码器将原始图像数据压缩成体积更小的JPEG格式比特流,便于存储和传输
JPEG比特流格式包含了图像开始标记(SOI)、图像结束标记(EOI)、帧头和扫描信息等结构性标记,便于解码器正确解码和重建图像。下图为JPEG比特流格式。


第四十一章 图片显示实验21153.png
图41.1.5.3 JPEG比特流格式

上图的JPEG比特流的结构对图像的解码和重建至关重要,它从图像开始标记(SOI)到图像结束标记(EOI)之间,包含多个结构性数据段和标记。JPEG比特流的基本结构包括SOI和EOI标记,以及介于二者之间的多个逻辑段。主要部分有:表/杂项(如量化表DQT、霍夫曼表DHT、重启间隔定义DRI等)、帧头(SOF0标记)、以及SOS扫描段(包括扫描头、编码的最小编码单元MCU和重启标记RSTm)。这些标记和数据段共同指示图像的编码方式及其各个组成部分。JPEG格式定义了一系列标记代码,每个代码由两字节组成,第一个字节固定为 0xFF,第二个字节表示具体的标记类型。以下是JPEG中常用的标记代码及其作用:

17.png
表41.1.5.1 数据段的标志码

对于上述内容,大家可能并不陌生,这实际上与41.1.3小节中讲解的JPEG文件格式解析内容相对应。我们在前面的章节中已经提到过这些结构,只是笔者在此未涉及COM和DRI等可选段,感兴趣的读者可以参考专业的JPEG解码文献或资料,深入了解这些部分的详细内容。
2,JPEG硬件解码
JPEG解码是将压缩后的JPEG数据流还原为原始图像的过程。这个过程是JPEG编码的逆过程,涉及从压缩的二进制数据流中提取图像数据,并通过逆变换、逆量化、解码等步骤重建图像内容。解码过程中,数据从压缩格式恢复到图像的像素信息,最终输出一个可以显示或处理的图像。下图为ESP32-P4的JPEG解码流程,如下图所示。


第四十一章 图片显示实验22014.png
图41.1.5.4 JPEG硬件解码流程

上图展示了JPEG解码器的架构,JPEG硬件解码流程大致分为以下几个阶段,每个模块的功能和解码步骤如下:
1)数据传输
通过2D DMA接收压缩的JPEG位流(bitstream),并将其传递给解码器的入口header_dec 模块。当然,最终的输出也是需要2D DMA传输,它将解码出的图像数据传输至目标存储区域。
2)位流解析
header_dec模块首先对位流进行初步解析,提取JPEG文件的标记,识别起始扫描(SOS)和图像结束(EOI)标记,该模块还负责控制整个解码过程的顺序和数据流向,为后续的解码过程做好准备。
3)Huffman 解码
huffman_decoder模块使用由软件传递的Huffman表,对header_dec模块输出的压缩位流数据执行Huffman解码,解码后的数据是经过压缩编码的亮度(Y)和色度(U、V)分量,并为后续解量化准备好。
4)量化系数反量化
解码数据通过quant_table中的量化系数表进行反量化,其中quant_table存储量化系数,由软件预先设置;dequantizer根据这些系数将Huffma解码后的数据还原为量化前的值,从而恢复图像的频率信息。
5)反之字形扫描
在量化解码后的数据中,图像块的频率数据是按照“之字形”方式排列的,dezigzag模块对这些数据执行反之字形扫描,将数据单元重新排序,还原到矩阵结构,以便进行下一步的离散余弦变换(DCT)。
6)二维逆离散余弦变换(IDCT)
dct_idct模块执行二维逆离散余弦变换(IDCT),将频率域的数据转换为空间域的像素值。经过IDCT处理后,数据单元变为图像的实际像素值,完成解码过程的核心步骤。
7)解码图像输出
解码得到的像素数据通过2D DMA传输到指定的存储位置,形成最终的图像数据输出。
整个解码流程可以归纳为:数据传输(2D DMA)→位流解析(header_dec)→Huffman 解码(huffman_decoder)→反量化(quant_table & dequantizer)→反之字形扫描(dezigzag)→逆DCT转换(dct_idct)→图像输出(2D DMA)。通过这一系列步骤,JPEG 硬件解码器将压缩的 JPEG 数据流转换为可显示的图像数据。
注意:由于硬件资源的限制,JPEG解码器和编码器共用quant_table和dct_idct模块。当编码器和解码器切换使用时,这两个模块不会同时进行工作,以节省硬件资源。

41.1.5.2 JPEG 驱动程序对硬件资源使用流程
关于JPEG硬件编解码 流程以及JPEG比特流格式,笔者已在前一小节中详细讲解。至于如何配置相关寄存器并启动JPEG硬件编解码,读者可以参考《ESP32-P4技术参考手册》中的相关内容,这里不再赘述。接下来,笔者将介绍应用程序中JPEG驱动程序对硬件资源的使用流程,具体流程如下。

第四十一章 图片显示实验23248.png
图41.1.5.2.1 JPEG 硬件编解码有限状态机流程图

上图展示了ESP32-P4中JPEG硬件的有限状态机,描述了编码和解码的具体流程。图中包含不同的状态、用户接口和状态之间的转换关系,具体解释如下
1,初始状态
系统的初始START状态。在此状态下,可以选择调用jpeg_new_decoder_engine 或 jpeg_new_encoder_engine来初始化解码或编码引擎。
2,就绪状态
初始化完成并获得编码或解码器句柄(jpeg_acquire_codec_handle)后,系统进入READY状态。在该状态下,可以进行调用jpeg_decoder_get_info获取解码器信息(可选)。如果需要解码操作,则调用jpeg_decoder_process;反之,编码就调用jpeg_encoder_process。
3,进程状态
在进入PROCESS状态后,硬件开始具体的编码或解码操作,流程如下:
1)获取锁(lock acquire):确保硬件资源独占,不会被其他任务使用。
2)头处理(header process):解析JPEG文件头信息,为硬件配置做好准备。
3)寄存器配置(configure registers):根据JPEG头信息配置硬件寄存器。
4)启动 2D DMA 传输(start 2DDMA transaction):启动2D DMA,负责将数据传输到硬件中进行编码或解码。
5)传输结果:若传输成功,来自2D DMA的回调(2DDMA CBS)将返回SUCCESS,并释放锁(lock_release)。若传输失败,JPEG中断将发出FAIL,并同样释放锁(lock_release)
4,完成状态
传输结束后,系统进入FINISH状态,标志着编码或解码任务完成。
5,停止状态
STOP:任务完成后,可以调用jpeg_del_decoder_engine或jpeg_del_encoder_engine删除编码或解码引擎,最后释放句柄(jpeg_release_codec_handle),系统进入 STOP 状态。
通过这个有限状态机图,开发者可以理解ESP32-P4的JPEG硬件在编码与解码过程中的状态流转,有助于高效利用JPEG硬件加速功能来实现图像压缩和解压。
注意:图中的粉色框表示硬件的私有操作,例如锁的获取与释放、头处理、寄存器配置以及DMA传输等步骤。蓝色框表示用户可以调用的公共接口,例如jpeg_new_decoder_engine和jpeg_acquire_codec_handle。箭头方向和线型的不同表示了状态的转移类型,其中实线表示必须的步骤,虚线表示可选步骤。

41.1.5.3 JPEG驱动文件结构
在ESP32-P4开发中,JPEG图像处理驱动程序通常涉及多个文件模块,以确保系统高效地执行图像编码和解码操作。JPEG驱动程序的文件结构设计合理,模块化清晰,有助于开发者了解各模块在驱动程序中的具体作用和它们之间的相互依赖关系。下图展示了JPEG驱动程序的文件结构,主要包括硬件抽象层(HAL)、JPEG驱动、软件处理过程以及硬件资源的依赖情况,如DMA、缓存(Cache)和电源管理(PM)。每个模块由不同的头文件和源文件构成,具有特定的功能和职责,进一步细化为公共头文件、私有头文件和源文件。下面,我们将深入分析各模块的功能和实现细节,以帮助读者更好地理解JPEG驱动程序的内部结构和开发流程。


第四十一章 图片显示实验24695.png
图41.1.5.3.1 JPEG 驱动程序文件结构

这张图展示了JPEG驱动程序文件的结构,以及各个文件之间的依赖关系和使用硬件资源的情况。图中的各个模块通过不同颜色框架标识,明确划分了JPEG驱动的组成部分,包括应用层、JPEG驱动层、基于组件的硬件资源部分,以及用于HAL(硬件抽象层)和纯软件处理的文件。JPEG驱动程序的结构和文件组织方式。主要涉及以下几个部分。
1,JPEG Application(应用层):最上层是应用程序,它与JPEG编码和解码功能交互,包含头文件 jpeg_decode.h 和 jpeg_encode.h,并通过实现文件 jpeg_decode.c 和 jpeg_encode.c 来处理具体的JPEG数据。
2,JPEG Driver(JPEG驱动):该部分包含用于与硬件交互的驱动代码。它通过文件 jpeg_common.c 进行通用操作,使用了 jpeg_private.h 文件来包含私有的内部实现。
3,HAL(硬件抽象层):这部分抽象了硬件平台的操作。文件 jpeg_hal.h、jpeg_hal.c、jpeg_ll.h 提供了硬件相关的接口和低层实现。通过这些文件,应用层和JPEG驱动可以与硬件平台上的JPEG加速器进行交互。
4,Based On Component(组件支持):这一部分描述了JPEG驱动依赖的其他硬件组件,如DMA2D、Cache和PM(电源管理)等。这些组件支持JPEG操作的硬件加速和性能优化。
5,Pure Software Header Process(纯软件头文件处理):这一部分通过文件如 jpeg_emit_marker.h 和 jpeg_parse_marker.h 处理JPEG标记的编码和解析。
注意:上图中的公共头文件:用蓝色表示,包含了JPEG应用程序和驱动程序使用的公共接口。私有头文件:用红色表示,包含了仅供JPEG驱动和硬件抽象层使用的内部实现。源文件:用白色表示,实际实现JPEG功能的源代码文件。

41.2 硬件设计

41.2.1 程序功能
开机的时候先检测字库,然后检测SD卡是否存在,如果SD卡存在,则开始查找SD卡根目录下的PICTURE文件夹,如果找到则显示该文件夹下面的图片文件(支持bmp、jpg、jpeg、PNG和gif格式),循环显示,通过按KEY0和KEY1可以快速浏览下一张和上一张。如果未找到PICTURE文件夹/任何图片文件,则提示错误。LED0闪烁,提示程序运行。

41.2.2 硬件资源
1)LED灯
        LED        0  - IO51
2)XL9555
        IIC_INT        - IO36
        IIC_SDA        - IO33
        IIC_SCL        - IO32
        EXIO_8        - KEY0
        EXIO_9        - KEY1
3)RGBLCD/MIPILCD(引脚太多,不罗列出来)
4)SPIFFS
5)SD卡
        CMD  -  IO44
        CLK   -  IO43
        D0      -  IO39
        D1      -  IO40
        D2      -  IO41
        D3      -  IO42

41.2.3 原理图
本章实验使用的硬件JPEG为ESP32-P4的片上资源,因此并没有相应的连接原理图。

41.3 程序设计

41.3.1 程序流程图

第四十一章 图片显示实验26130.png
图41.3.1.1 图片显示实验程序流程图

41.3.2 程序解析
在32_picture_display例程中,作者在32_picture_display\components\Middlewares路径下新建PICTURE和MYFATFS文件夹,并在这两个文件夹下新建了CMakeLists.txt文件。其中,PICTURE和MYFATFS文件夹用于存放PICTURE和MYFATFS组件驱动,而CMakeLists.txt文件则用于将PICTURE和MYFATFS组件添加至构建系统,以便项目工程能够使用PICTURE和MYFATFS组件功能。
1,MYFATFS驱动代码
这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码。MYFATFS组件源码包括两个文件:exfuns.c和exfuns.h。
下面先解析exfuns.h的程序。定义了文件的类型识别宏定义。
  1. <font size="3">/* exfuns_file_type返回的类型定义</font>
  2. <font size="3"> * 根据表FILE_TYPE_TBL获得.在exfuns.c里面定义</font>
  3. <font size="3"> */</font>
  4. <font size="3">#define T_BIN   0x00    /* BIN文件 */</font>
  5. <font size="3">#define T_LRC   0x10    /* LRC文件 */</font>
  6. <font size="3">#define T_NES   0x20    /* NES文件 */</font>
  7. <font size="3">#define T_SMS   0x21    /* SMS文件 */</font>
  8. <font size="3">#define T_TEXT  0x30    /* TXT文件 */</font>
  9. <font size="3">#define T_C     0x31    /* C文件 */</font>
  10. <font size="3">#define T_H     0x32    /* H文件 */</font>
  11. <font size="3">#define T_WAV   0x40    /* WAV文件 */</font>
  12. <font size="3">#define T_MP3   0x41    /* MP3文件 */</font>
  13. <font size="3">#define T_APE   0x42    /* APE文件 */</font>
  14. <font size="3">#define T_FLAC  0x43    /* FLAC文件 */</font>
  15. <font size="3">#define T_BMP   0x51    /* BMP文件 */</font>
  16. <font size="3">#define T_JPG   0x52    /* JPG文件 */</font>
  17. <font size="3">#define T_JPEG  0x53    /* JPEG文件 */</font>
  18. <font size="3">#define T_GIF   0x54    /* GIF文件 */</font>
  19. <font size="3">#define T_PNG   0x55    /* GIF文件 */</font>
  20. <font size="3">#define T_AVI   0x60    /* AVI文件 */</font>
复制代码
下面我们再解析exfuns.c的文件,该文件包含了多个文件系统扩展功能接口,这些功能实现如下所示。
1,文件类型定义
FILE_TYPE_TBL是一个二维数组,用来定义不同类型的文件及其子类型。例如,音频文件(如WAV、MP3)、图片文件(如BMP、JPG)等。该表格便于后续代码识别文件类型,如下代码所示:
  1. <font size="3">#define FILE_MAX_TYPE_NUM       7       /* 最多FILE_MAX_TYPE_NUM个大类 */</font>
  2. <font size="3">#define FILE_MAX_SUBT_NUM       7       /* 最多FILE_MAX_SUBT_NUM个小类 */</font>

  3. <font size="3">/* 文件类型定义 */</font>
  4. <font size="3">static const char *FILE_TYPE_TBL[FILE_MAX_TYPE_NUM][FILE_MAX_SUBT_NUM] = {</font>
  5. <font size="3">    {"BIN"," "," "," "," "," "," "},                        /* BIN文件 */</font>
  6. <font size="3">    {"LRC"," "," "," "," "," "," "},                        /* LRC文件 */</font>
  7. <font size="3">    {"NES", "SMS"," "," "," "," "," "},                     /* NES/SMS文件 */</font>
  8. <font size="3">    {"TXT", "C", "H"," "," "," "," "},                      /* 文本文件 */</font>
  9. <font size="3">    {"WAV", "MP3", "OGG", "FLAC", "AAC", "WMA", "MID"},     /* 音乐文件 */</font>
  10. <font size="3">    {"DB","BMP", "JPG", "JPEG", "GIF","PNG"," "},           /* 图片文件 */</font>
  11. <font size="3">    {"AVI"," "," "," "," "," "," "},                        /* 视频文件 */</font>
  12. <font size="3">};</font>
复制代码
2,字符转大写
exfuns_char_upper函数将传入的字符转换为大写字母。如果字符是数字或已经是大写字母,则保持不变,如下代码:
  1. <font size="3">/**</font>
  2. <font size="3"> * @brief             将小写字母转为大写字母,如果是数字,则保持不变.</font>
  3. <font size="3"> * [url=home.php?mod=space&uid=271674]@param[/url]             c : 要转换的字母</font>
  4. <font size="3"> * @retval            转换后的字母,大写</font>
  5. <font size="3"> */</font>
  6. <font size="3">uint8_t exfuns_char_upper(uint8_t c)</font>
  7. <font size="3">{</font>
  8. <font size="3">    if (c < 'A') return c;  /* 数字,保持不变. */</font>

  9. <font size="3">    if (c >= 'a')</font>
  10. <font size="3">    {</font>
  11. <font size="3">        return c - 0x20;    /* 变为大写. */</font>
  12. <font size="3">    }</font>
  13. <font size="3">    else</font>
  14. <font size="3">    {</font>
  15. <font size="3">        return c;           /* 大写,保持不变 */</font>
  16. <font size="3">    }</font>
  17. <font size="3">}</font>
复制代码
3,文件类型判断
exfuns_file_type根据文件名后缀判断文件的类型,并返回文件所属的大类和子类。若无法识别,则返回0xFF。如下代码:
  1. <font size="3">/**</font>
  2. <font size="3"> * @brief             报告文件的类型</font>
  3. <font size="3"> * @param            fname : 文件名</font>
  4. <font size="3"> * @retval          文件类型</font>
  5. <font size="3"> *   @arg            0XFF , 表示无法识别的文件类型编号.</font>
  6. <font size="3"> *   @arg             其他 , 高四位表示所属大类, 低四位表示所属小类.</font>
  7. <font size="3"> */</font>
  8. <font size="3">uint8_t exfuns_file_type(char *fname)</font>
  9. <font size="3">{</font>
  10. <font size="3">    uint8_t tbuf[5];</font>
  11. <font size="3">    char *attr = 0;   /* 后缀名 */</font>
  12. <font size="3">    uint8_t i = 0, j;</font>

  13. <font size="3">    while (i < 250)</font>
  14. <font size="3">    {</font>
  15. <font size="3">        i++;</font>

  16. <font size="3">        if (*fname == '\0')break;        /* 偏移到了最后了. */</font>

  17. <font size="3">        fname++;</font>
  18. <font size="3">    }</font>

  19. <font size="3">    if (i == 250)return 0XFF;           /* 错误的字符串. */</font>

  20. <font size="3">    for (i = 0; i < 5; i++)             /* 得到后缀名 */</font>
  21. <font size="3">    {</font>
  22. <font size="3">        fname--;</font>

  23. <font size="3">        if (*fname == '.')</font>
  24. <font size="3">        {</font>
  25. <font size="3">            fname++;</font>
  26. <font size="3">            attr = fname;</font>
  27. <font size="3">            break;</font>
  28. <font size="3">        }</font>
  29. <font size="3">    }</font>

  30. <font size="3">    if (attr == 0) return 0XFF;</font>

  31. <font size="3">    strcpy((char *)tbuf, (const char *)attr);       /* copy */</font>

  32. <font size="3">    for (i = 0; i < 4; i++) tbuf</font><span style="font-size: medium;"><span style="font-style: italic;"><span style="font-style: normal;"> = exfuns_char_upper(tbuf);/* 变为大写 */

  33.     for (i = 0; i < FILE_MAX_TYPE_NUM; i++)         /* 大类对比 */
  34.     {
  35.         for (j = 0; j < FILE_MAX_SUBT_NUM; j++)     /* 子类对比 */
  36.         {
  37.             if (*FILE_TYPE_TBL</span><span style="font-style: normal;">[j] == 0) break;   /* 此组已经没有可对比的成员了. */
  38.             if (strcmp(FILE_TYPE_TBL</span><span style="font-style: normal;">[j],(const char *)tbuf)== 0)
  39.             {
  40.                 return (i << 4) | j;
  41.             }
  42.         }
  43.     }

  44.     return 0XFF;    /* 没找到 */
  45. }</span></span></span>
复制代码
4,文件复制功能
exfuns_file_copy实现文件的复制功能,支持文件的拷贝进度显示。复制时可以选择覆盖原文件或不覆盖原文件。通过传入的回调函数fcpymsg显示文件复制进度(包括文件名、百分比等信息),如下代码:
  1. /**
  2. * @brief             文件复制
  3. *   [url=home.php?mod=space&uid=60778]@note[/url]            将psrc文件,copy到pdst.
  4. *                    注意: 文件大小不要超过4GB.
  5. *
  6. * @param            fcpymsg : 函数指针, 用于实现拷贝时的信息显示
  7. *                  pname:文件/文件夹名
  8. *                  pct:百分比
  9. *                  mode:
  10. *                      bit0 : 更新文件名
  11. *                      bit1 : 更新百分比pct
  12. *                      bit2 : 更新文件夹
  13. *                      其他 : 保留
  14. *                  返回值: 0, 正常; 1, 强制退出;
  15. *
  16. * @param             psrc    : 源文件
  17. * @param             pdst    : 目标文件
  18. * @param             totsize : 总大小(当totsize为0的时候,表示仅仅为单个文件拷贝)
  19. * @param             cpdsize : 已复制了的大小.
  20. * @param             fwmode  : 文件写入模式
  21. *   @arg            0: 不覆盖原有的文件
  22. *   @arg             1: 覆盖原有的文件
  23. *
  24. * @retval             执行结果
  25. *   @arg             0   , 正常
  26. *   @arg              0XFF, 强制退出
  27. *   @arg             其他, 错误代码
  28. */
  29. uint8_t exfuns_file_copy(uint8_t(*fcpymsg)(uint8_t *pname, uint8_t pct,
  30. uint8_t mode), uint8_t *psrc, uint8_t *pdst,
  31.                          uint32_t totsize, uint32_t cpdsize, uint8_t fwmode)
  32. {
  33.     uint8_t res;
  34.     uint16_t br = 0;
  35.     uint16_t bw = 0;
  36.     FIL *fsrc = 0;
  37.     FIL *fdst = 0;
  38.     uint8_t *fbuf = 0;
  39.     uint8_t curpct = 0;
  40.     unsigned long long lcpdsize = cpdsize;
  41.    
  42.     fsrc = (FIL *)malloc(sizeof(FIL));    /* 申请内存 */
  43.     fdst = (FIL *)malloc(sizeof(FIL));
  44.     fbuf = (uint8_t *)malloc(8192);

  45.     if (fsrc == NULL || fdst == NULL || fbuf == NULL)
  46.     {
  47.         res = 100;  /* 前面的值留给fatfs */
  48.     }
  49.     else
  50.     {
  51.         if (fwmode == 0)
  52.         {
  53.             fwmode = FA_CREATE_NEW;     /* 不覆盖 */
  54.         }
  55.         else
  56.         {
  57.             fwmode = FA_CREATE_ALWAYS;  /* 覆盖存在的文件 */
  58.         }
  59.         /* 打开只读文件 */
  60.         res = f_open(fsrc, (const TCHAR *)psrc, FA_READ | FA_OPEN_EXISTING);        
  61. /* 第一个打开成功,才开始打开第二个 */
  62.         if (res == 0)res = f_open(fdst, (const TCHAR *)pdst, FA_WRITE | fwmode);   

  63.         if (res == 0)           /* 两个都打开成功了 */
  64.         {
  65.             if (totsize == 0)   /* 仅仅是单个文件复制 */
  66.             {
  67.                 totsize = fsrc->obj.objsize;
  68.                 lcpdsize = 0;
  69.                 curpct = 0;
  70.             }
  71.             else
  72.             {
  73.                 curpct = (lcpdsize * 100) / totsize;            /* 得到新百分比 */
  74.             }
  75.             
  76.             fcpymsg(psrc, curpct, 0X02);                        /* 更新百分比 */

  77.             while (res == 0)    /* 开始复制 */
  78.             {
  79.                 res = f_read(fsrc, fbuf, 8192, (UINT *)&br);/* 源头读出512字节 */

  80.                 if (res || br == 0)break;

  81.                 res = f_write(fdst, fbuf,(UINT)br, (UINT *)&bw);/* 写入目的文件 */
  82.                 lcpdsize += bw;

  83.                 if (curpct != (lcpdsize * 100) / totsize) /* 是否需要更新百分比 */
  84.                 {
  85.                     curpct = (lcpdsize * 100) / totsize;

  86.                     if (fcpymsg(psrc, curpct, 0X02))            /* 更新百分比 */
  87.                     {
  88.                         res = 0XFF;                             /* 强制退出 */
  89.                         break;
  90.                     }
  91.                 }

  92.                 if (res || bw < br)break;
  93.             }

  94.             f_close(fsrc);
  95.             f_close(fdst);
  96.         }
  97.     }

  98.     free(fsrc); /* 释放内存 */
  99.     free(fdst);
  100.     free(fbuf);
  101.     return res;
  102. }
复制代码
5,获取文件夹名称
exfuns_get_src_dname从完整路径中提取文件夹的名称,如下代码:
  1. /**
  2. * @brief              得到路径下的文件夹
  3. *   @note            即把路径全部去掉, 只留下文件夹名字.
  4. * @param              pname : 详细路径
  5. * @retval            0   , 路径就是个卷标号.
  6. *                    其他, 文件夹名字首地址
  7. */
  8. uint8_t *exfuns_get_src_dname(uint8_t *pname)
  9. {
  10.     uint16_t temp = 0;

  11.     while (*pname != 0)
  12.     {
  13.         pname++;
  14.         temp++;
  15.     }

  16.     if (temp < 4) return 0;
  17. /* 追述到倒数第一个""或者"/"处 */
  18.     while ((*pname != 0x5c) && (*pname != 0x2f)) pname--;

  19.     return ++pname;
  20. }
复制代码
6,获取文件夹大小
exfuns_get_folder_size计算指定文件夹的总大小,递归计算文件夹内所有文件的大小,返回文件夹总大小,如下代码:
  1. /**
  2. * @brief             得到文件夹大小
  3. *   @note           注意: 文件夹大小不要超过4GB.
  4. * @param            pname : 详细路径
  5. * @retval            0   , 文件夹大小为0, 或者读取过程中发生了错误.
  6. *                    其他, 文件夹大小
  7. */
  8. uint32_t exfuns_get_folder_size(uint8_t *fdname)
  9. {
  10. #define MAX_PATHNAME_DEPTH  512 + 1     /* 最大目标文件路径+文件名深度 */
  11.     uint8_t res = 0;
  12.     FF_DIR *fddir = 0;          /* 目录 */
  13.     FILINFO *finfo = 0;         /* 文件信息 */
  14.     uint8_t *pathname = 0;      /* 目标文件夹路径+文件名 */
  15.     uint16_t pathlen = 0;       /* 目标路径长度 */
  16.     uint32_t fdsize = 0;

  17.     fddir = (FF_DIR *)malloc(sizeof(FF_DIR));   /* 申请内存 */
  18.     finfo = (FILINFO *)malloc(sizeof(FILINFO));

  19.     if (fddir == NULL || finfo == NULL)res = 100;

  20.     if (res == 0)
  21.     {
  22.         pathname = malloc(MAX_PATHNAME_DEPTH);

  23.         if (pathname == NULL)res = 101;

  24.         if (res == 0)
  25.         {
  26.             pathname[0] = 0;
  27.             strcat((char *)pathname, (const char *)fdname);     /* 复制路径 */
  28.             res = f_opendir(fddir, (const TCHAR *)fdname);      /* 打开源目录 */

  29.             if (res == 0)           /* 打开目录成功 */
  30.             {
  31.                 while (res == 0)    /* 开始复制文件夹里面的东东 */
  32.                 {
  33.                     res = f_readdir(fddir, finfo);        /* 读取目录下的一个文件 */

  34.                     if (res != FR_OK || finfo->fname[0] == 0) break;   

  35.                     if (finfo->fname[0] == '.')continue;               

  36.                     if (finfo->fattrib & 0X10)   
  37.                     {
  38.                         pathlen = strlen((const char *)pathname);      
  39.                         strcat((char *)pathname, (const char *)"/");   
  40.                         strcat((char *)pathname, (const char *)finfo->fname);   
  41.                         fdsize += exfuns_get_folder_size(pathname);     
  42.                         pathname[pathlen] = 0;
  43.                     }
  44.                     else
  45.                     {
  46.                         fdsize += finfo->fsize; /* 非目录,直接加上文件的大小 */
  47.                     }
  48.                 }
  49.             }

  50.             free(pathname);
  51.         }
  52.     }

  53.     free(fddir);
  54.     free(finfo);

  55.     if (res)
  56.     {
  57.         return 0;
  58.     }
  59.     else
  60.     {
  61.         return fdsize;
  62.     }
  63. }
复制代码
7,文件夹复制功能
exfuns_folder_copy类似于文件复制功能,但它支持文件夹的复制。该函数能够递归地复制文件夹中的所有文件和子文件夹。它同样支持进度显示,并允许选择覆盖文件或不覆盖文件,如下代码:
  1. /**
  2. * @brief            文件夹复制
  3. *   @note            将psrc文件夹, 拷贝到pdst文件夹.
  4. *                    注意: 文件大小不要超过4GB.
  5. *
  6. * @param             fcpymsg : 函数指针, 用于实现拷贝时的信息显示
  7. *                  pname:文件/文件夹名
  8. *                  pct:百分比
  9. *                  mode:
  10. *                      bit0 : 更新文件名
  11. *                      bit1 : 更新百分比pct
  12. *                      bit2 : 更新文件夹
  13. *                      其他 : 保留
  14. *                  返回值: 0, 正常; 1, 强制退出;
  15. *
  16. * @param              psrc    : 源文件夹
  17. * @param             pdst    : 目标文件夹
  18. *   @note             必须形如"X:"/"X:XX"/"X:XX/XX"之类的. 且要确认上一级文件夹存在
  19. *
  20. * @param             totsize : 总大小(当totsize为0的时候,表示仅仅为单个文件拷贝)
  21. * @param             cpdsize : 已复制了的大小.
  22. * @param            fwmode  : 文件写入模式
  23. *   @arg             0: 不覆盖原有的文件
  24. *   @arg             1: 覆盖原有的文件
  25. *
  26. * @retval            执行结果
  27. *   @arg             0   , 正常
  28. *   @arg            0XFF, 强制退出
  29. *   @arg            其他, 错误代码
  30. */
  31. uint8_t exfuns_folder_copy(uint8_t(*fcpymsg)(uint8_t *pname, uint8_t pct,
  32. uint8_t mode), uint8_t *psrc, uint8_t *pdst,
  33.                            uint32_t *totsize, uint32_t *cpdsize, uint8_t fwmode)
  34. {
  35. #define MAX_PATHNAME_DEPTH 512 + 1  /* 最大目标文件路径+文件名深度 */
  36.     uint8_t res = 0;
  37.     FF_DIR *srcdir = 0;     /* 源目录 */
  38.     FF_DIR *dstdir = 0;     /* 源目录 */
  39.     FILINFO *finfo = 0;     /* 文件信息 */
  40.     uint8_t *fn = 0;        /* 长文件名 */

  41.     uint8_t *dstpathname = 0;   /* 目标文件夹路径+文件名 */
  42.     uint8_t *srcpathname = 0;   /* 源文件夹路径+文件名 */

  43.     uint16_t dstpathlen = 0;    /* 目标路径长度 */
  44.     uint16_t srcpathlen = 0;    /* 源路径长度 */


  45.     srcdir = (FF_DIR *)malloc(sizeof(FF_DIR));  /* 申请内存 */
  46.     dstdir = (FF_DIR *)malloc(sizeof(FF_DIR));
  47.     finfo = (FILINFO *)malloc(sizeof(FILINFO));

  48.     if (srcdir == NULL || dstdir == NULL || finfo == NULL)res = 100;

  49.     if (res == 0)
  50.     {
  51.         dstpathname = malloc(MAX_PATHNAME_DEPTH);
  52.         srcpathname = malloc(MAX_PATHNAME_DEPTH);

  53.         if (dstpathname == NULL || srcpathname == NULL)res = 101;

  54.         if (res == 0)
  55.         {
  56.             dstpathname[0] = 0;
  57.             srcpathname[0] = 0;
  58.             strcat((char *)srcpathname,(const char *)psrc);/* 复制原始源文件路径 */
  59.             strcat((char *)dstpathname,(const char *)pdst);
  60.             res = f_opendir(srcdir, (const TCHAR *)psrc);       /* 打开源目录 */

  61.             if (res == 0)       /* 打开目录成功 */
  62.             {
  63.                 strcat((char *)dstpathname, (const char *)"/"); /* 加入斜杠 */
  64.                 fn = exfuns_get_src_dname(psrc);

  65.                 if (fn == 0)    /* 卷标拷贝 */
  66.                 {
  67.                     dstpathlen = strlen((const char *)dstpathname);
  68.                     dstpathname[dstpathlen] = psrc[0];          /* 记录卷标 */
  69.                     dstpathname[dstpathlen + 1] = 0;            /* 结束符 */
  70.                 }
  71.                 else strcat((char *)dstpathname, (const char *)fn); /* 加文件名 */

  72.                 fcpymsg(fn, 0, 0X04);                           /* 更新文件夹名 */
  73.                 res = f_mkdir((const TCHAR *)dstpathname);      

  74.                 if (res == FR_EXIST) res = 0;

  75.                 while (res == 0)          /* 开始复制文件夹里面的东东 */
  76.                 {
  77.                     res = f_readdir(srcdir, finfo);        /* 读取目录下的一个文件 */

  78.                     if (res != FR_OK || finfo->fname[0] == 0)break;     

  79.                     if (finfo->fname[0] == '.')continue;        
  80.                     fn = (uint8_t *)finfo->fname;               /* 得到文件名 */
  81.                     dstpathlen = strlen((const char *)dstpathname);     
  82.                     srcpathlen = strlen((const char *)srcpathname);     
  83.                     strcat((char *)srcpathname, (const char *)"/");     

  84.                     if (finfo->fattrib & 0X10)
  85.                     {
  86.                         strcat((char *)srcpathname, (const char *)fn);  
  87.                         res = exfuns_folder_copy(fcpymsg, srcpathname,
  88. dstpathname, totsize, cpdsize, fwmode);
  89.                     }
  90.                     else     /* 非目录 */
  91.                     {
  92.                         strcat((char *)dstpathname, (const char *)"/");
  93.                         strcat((char *)dstpathname, (const char *)fn);  
  94.                         strcat((char *)srcpathname, (const char *)fn);  
  95.                         fcpymsg(fn, 0, 0X01);       /* 更新文件名 */
  96.                         res = exfuns_file_copy(fcpymsg, srcpathname,
  97. dstpathname, *totsize, *cpdsize, fwmode);  
  98.                         *cpdsize += finfo->fsize;   /* 增加一个文件大小 */
  99.                     }

  100.                     srcpathname[srcpathlen] = 0;    /* 加入结束符 */
  101.                     dstpathname[dstpathlen] = 0;    /* 加入结束符 */
  102.                 }
  103.             }

  104.             free(dstpathname);
  105.             free(srcpathname);
  106.         }
  107.     }

  108.     free(srcdir);
  109.     free(dstdir);
  110.     free(finfo);
  111.     return res;
  112. }
复制代码
MYFATFS组件实现了一些FATFS文件系统的扩展功能,包含了文件的类型识别、文件和文件夹的复制、磁盘容量查询等基本文件操作。这些功能对于嵌入式系统中管理文件和存储资源非常有用。
2,PICTURE驱动代码
这里我们只讲解核心代码,详细的源码请大家参考光盘本实验对应源码。PICTURE驱动源码包括十二个文件:bmp.c、bmp.h、jpeg.c、jpeg.h、gif.c、gif.h、png.c、png.h、pngle.c、pngle.h、piclib.c和piclib.h。
其中:
bmp.c和bmp.h用于实现对bmp文件的解码;
jpeg.c和jpeg.h用于实现对jpeg/jpg文件的硬件解码;
gif.c和gif.h用于实现对gif文件的解码;
png.c和png.h用于实现对png文件的解码;
pngle.c/.h用于png解码及系统配置;
这些文件的代码太长了,而且也有规定的标准,需要结合各个图片编码的格式来编写,所以我们在这里不贴出来,大家查看光盘中的源码的实现过程即可。下面我们重点讲解这几个解码库对应到我们的LCD的显示部分。
1,解码库的控制句柄_pic_phy和_pic_info
我们通过定义_pic_phy和_pic_info两个结构体,实现了将解码后的图片数据与LCD显示操作的有效连接。_pic_phy用于存储和管理与LCD显示相关的操作接口,而_pic_info则包含了解码后的图片尺寸、颜色等基本信息,确保图片能够正确显示在屏幕上。这种设计不仅使图片解码和显示的流程更加简洁清晰,还能方便地在LCD上显示不同格式的图像数据。
  1. /* 图片显示物理层接口 */
  2. /* 在移植的时候,必须由用户自己实现这几个函数 */
  3. typedef struct
  4. {
  5.     /* 画点函数 */
  6.     void(*draw_point)(uint16_t, uint16_t, uint16_t);
  7.     /* 填充函数 */
  8.     void(*fill)(uint16_t, uint16_t, uint16_t, uint16_t, uint16_t);
  9.    
  10.     /* 画水平线函数 */
  11.     void(*draw_hline)(uint16_t, uint16_t, uint16_t, uint16_t);
  12.    
  13.     /*多点填充 */
  14.     void(*multicolor)(uint16_t, uint16_t, uint16_t, uint16_t,uint16_t *);
  15. } _pic_phy;

  16. /* 图像信息 */
  17. typedef struct
  18. {
  19.     uint16_t lcdwidth;      /* LCD的宽度 */
  20.     uint16_t lcdheight;     /* LCD的高度 */
  21. } _pic_info;
复制代码
在piclib.c文件中,我们用上述类型定义了两个结构体,具体如下:
  1. _pic_info picinfo;      /* 图片信息 */
  2. _pic_phy pic_phy;       /* 图片显示物理接口 */
复制代码
2,piclib_init函数
piclib_init函数,该函数用于初始化图片解码的相关信息,用于定义解码后的LCD操作。具体定义如下:
  1. /**
  2. * @brief            多点填充
  3. * @param            x, y          : 起始坐标
  4. * @param             width, height : 宽度和高度
  5. * @param             color         : 颜色数组
  6. * @retval            无
  7. */
  8. static void piclib_multi_color(uint16_t x, uint16_t y, uint16_t ex,
  9. uint16_t ey,uint16_t *color)
  10. {
  11.     esp_lcd_panel_draw_bitmap(lcddev.lcd_panel_handle, x, y, ex, ey + 1, color);
  12. }


  13. /**
  14. * @brief             画图初始化
  15. *   @note            在画图之前,必须先调用此函数, 指定相关函数
  16. * @param            无
  17. * @retval            无
  18. */
  19. void piclib_init(void)
  20. {
  21.     pic_phy.draw_point = lcd_draw_point;            /* 画点函数实现,仅GIF需要 */
  22.     pic_phy.fill = lcd_fill;                        /* 填充函数实现,仅GIF需要 */
  23.     pic_phy.draw_hline = lcd_draw_hline;            /* 画线函数实现,仅GIF需要 */
  24.     pic_phy.multicolor = piclib_multi_color;/* 填充函数实现,JPEG、BMP、PNG需要 */

  25.     picinfo.lcdwidth = lcddev.width;                /* 得到LCD的宽度像素 */
  26.     picinfo.lcdheight = lcddev.height;              /* 得到LCD的高度像素 */
  27. }
复制代码
初始化图片解码的相关信息,这些函数必须由用户在外部实现。我们使用之前LCD的操作函数对这个结构体中的绘制操作:画点、画线、画圆等定义与我们的LCD操作对应起来。具体这些操作可以查看LCD这一章节的描述。
3,piclib_ai_load_picfile函数
piclib_ai_load_picfile帮助我们得到需要显示的图片信息并助于下一步的绘制。本函数需要结合文件系统来操作,图片根据后缀区分并且在文件夹在保存是我们在PC下的细分类,也是我们处理和分类图片的最方便的方式:
  1. /**
  2. * @brief             智能画图
  3. *   @note           图片仅在x,y和width, height限定的区域内显示.
  4. *
  5. * @param             filename      : 包含路径的文件名(.bmp/.jpg/.jpeg/.gif等)
  6. * @param             x, y          : 起始坐标
  7. * @param            width, height : 显示区域
  8. *   @note                      图片尺寸小于等于液晶分辨率,才支持快速解码
  9. * @retval            无
  10. */
  11. uint8_t piclib_ai_load_picfile(char *filename, uint16_t x, uint16_t y,
  12. uint16_t width, uint16_t height)
  13. {
  14.     uint8_t res = 0;/* 返回值 */
  15.     uint8_t temp;

  16.     if ((x + width) > picinfo.lcdwidth) return PIC_WINDOW_ERR; /* x坐标超范围了 */

  17.     if ((y + height) > picinfo.lcdheight)return PIC_WINDOW_ERR;/* y坐标超范围了 */

  18.     /* 得到显示方框大小 */
  19.     if (width == 0 || height == 0)return PIC_WINDOW_ERR;         /* 窗口设定错误 */

  20.     /* 文件名传递 */
  21.     temp = exfuns_file_type(filename);   /* 得到文件的类型 */
  22.     ESP_LOGI("here","temp:%#x ", temp);
  23.     switch (temp)
  24.     {
  25.         case T_BMP:
  26.             ESP_LOGI("here","enter");
  27.             res = bmp_decode(filename, width, height);           /* 解码BMP */
  28.             break;

  29.         case T_JPG:
  30.         case T_JPEG:
  31.             res = jpeg_decode(filename, width, height);        /* 解码JPG/JPEG */
  32.             break;

  33.         case T_GIF:
  34.             res = gif_decode(filename, x, y, width, height);    /* 解码gif */
  35.             break;

  36.         case T_PNG:
  37.             res = png_decode(filename, width, height);          /* 解码PNG */
  38.             break;

  39.         default:
  40.             res = PIC_FORMAT_ERR;                               /* 非图片格式!!! */
  41.             break;
  42.     }

  43.     return res;
  44. }
复制代码
piclib_ai_load_picfile函数,整个图片显示的对外接口,外部程序,通过调用该函数,可以实现bmp、jpg/jpeg、png和gif的显示,该函数根据输入文件的后缀名,判断文件格式,然后交给相应的解码程序(bmp解码/jpeg解码/gif解码/png解码),执行解码,完成图片显示。
3,CMakeLists.txt文件
本例程的功能实现主要依靠MYFATFS和PICTURE组件。要在main函数中,成功调用MYFATFS和PICTURE文件中的内容,就得需要修改Middlewares/MYFATFS和Middlewares/PICTURE文件夹下的CMakeLists.txt文件,修改如下:
  1. #Middlewares/MYFATFS文件夹下的CMakeLists.txt文件内容
  2. list(APPEND srcs    exfuns.c)

  3. idf_component_register(SRCS "${srcs}"
  4.                     INCLUDE_DIRS "."
  5.                     REQUIRES fatfs
  6.                     )
  7. #Middlewares/PICTURE文件夹下的CMakeLists.txt文件内容
  8. list(APPEND srcs    bmp.c
  9.                     gif.c
  10.                     jpeg.c
  11.                     piclib.c
  12.                     png.c
  13.                     pngle.c
  14.                     )

  15. idf_component_register(SRCS "jpeg.c" "bmp.c" "${srcs}"
  16.                             INCLUDE_DIRS "."
  17.                             REQUIRES         BSP
  18.                                              MYFATFS
  19.                                              esp_driver_jpeg
  20.                                              fatfs
  21.                                              )
复制代码
4,main.c驱动代码
在main.c里面编写如下代码。
  1. /**
  2. * @brief             得到path路径下,目标文件的总个数
  3. * @param             path : 路径
  4. * @retval            总有效文件数
  5. */
  6. uint16_t pic_get_tnum(char *path)
  7. {
  8.     uint8_t res;
  9.     uint16_t rval = 0;
  10.     FF_DIR tdir;                                    /* 临时目录 */
  11.     FILINFO *tfileinfo;                             /* 临时文件信息 */
  12.     tfileinfo = (FILINFO *)malloc(sizeof(FILINFO)); /* 申请内存 */
  13.     res = f_opendir(&tdir, (const TCHAR *)path);    /* 打开目录 */

  14.     if (res == FR_OK && tfileinfo)
  15.     {
  16.         while (1)                                   /* 查询总的有效文件数 */
  17.         {
  18.             res = f_readdir(&tdir, tfileinfo);      /* 读取目录下的一个文件 */

  19.             if (res != FR_OK || tfileinfo->fname[0] == 0)break;
  20.             res = exfuns_file_type(tfileinfo->fname);

  21.             if ((res & 0X0F) != 0X00)               /* 取低四位,看看是不是图片文件 */
  22.             {
  23.                 rval++;                             /* 有效文件数增加1 */
  24.             }
  25.         }
  26.     }

  27.     free(tfileinfo);                                /* 释放内存 */
  28.     return rval;
  29. }

  30. /**
  31. * @brief            转换
  32. * @param            fs:文件系统对象
  33. * @param             clst:转换
  34. * @retval            =0:扇区号,0:失败
  35. */
  36. static LBA_t atk_clst2sect(FATFS *fs, DWORD clst)
  37. {
  38.     clst -= 2;  /* Cluster number is origin from 2 */

  39.     if (clst >= fs->n_fatent - 2)
  40.     {
  41.         return 0;   /* Is it invalid cluster number? */
  42.     }
  43. /* Start sector number of the cluster */
  44.     return fs->database + (LBA_t)fs->csize * clst;  
  45. }

  46. /**
  47. * @brief              偏移
  48. * @param            dp:指向目录对象
  49. * @param             Offset:目录表的偏移量
  50. * @retval            FR_OK(0):成功,!=0:错误
  51. */
  52. FRESULT atk_dir_sdi(FF_DIR *dp, DWORD ofs)
  53. {
  54.     DWORD clst;
  55.     FATFS *fs = dp->obj.fs;

  56. if (ofs >= (DWORD)((FF_FS_EXFAT && fs->fs_type == FS_EXFAT)
  57. ? 0x10000000 : 0x200000) || ofs % 32)
  58.     {
  59.         /* Check range of offset and alignment */
  60.         return FR_INT_ERR;
  61.     }

  62.     dp->dptr = ofs;         /* Set current offset */
  63.     clst = dp->obj.sclust;  /* Table start cluster (0:root) */

  64.     if (clst == 0 && fs->fs_type >= FS_FAT32)
  65.     {   /* Replace cluster# 0 with root cluster# */
  66.         clst = (DWORD)fs->dirbase;

  67.         if (FF_FS_EXFAT)
  68.         {
  69.             dp->obj.stat = 0;
  70.         }
  71.         /* exFAT: Root dir has an FAT chain */
  72.     }

  73.     if (clst == 0)
  74.     {   /* Static table (root-directory on the FAT volume) */
  75.         if (ofs / 32 >= fs->n_rootdir)
  76.         {
  77.             return FR_INT_ERR;  /* Is index out of range? */
  78.         }

  79.         dp->sect = fs->dirbase;

  80.     }
  81.     else
  82.     {   
  83.         dp->sect = atk_clst2sect(fs, clst);
  84.     }

  85.     dp->clust = clst;   /* Current cluster# */

  86.     if (dp->sect == 0)
  87.     {
  88.         return FR_INT_ERR;
  89.     }

  90.     dp->sect += ofs / fs->ssize;         /* Sector# of the directory entry */
  91.     dp->dir = fs->win +(ofs % fs->ssize);/* Pointer to the entry in the win[] */

  92.     return FR_OK;
  93. }

  94. void app_main(void)
  95. {
  96.     esp_err_t ret;
  97.     FF_DIR picdir;                    /* 图片目录 */
  98.     FILINFO *picfileinfo;             /* 文件信息 */
  99.     char *pname;                      /* 带路径的文件名 */
  100.     uint16_t totpicnum;                /* 图片文件总数 */
  101.     uint16_t curindex = 0;             /* 图片当前索引 */
  102.     uint8_t key = 0;                   /* 键值 */
  103.     uint8_t t;
  104.     uint16_t temp;
  105.     uint32_t *picoffsettbl;          /* 图片文件offset索引表 */

  106.     ret = nvs_flash_init();         /* 初始化NVS */
  107.     if(ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND)
  108.     {
  109.         ESP_ERROR_CHECK(nvs_flash_erase());
  110.         ESP_ERROR_CHECK(nvs_flash_init());
  111.     }

  112.     led_init();                         /* LED初始化 */
  113.     key_init();                         /* KEY初始化 */
  114.     lcd_init();                         /* LCD屏初始化 */
  115.     myiic_init();                       /* MYIIC初始化 */
  116.     xl9555_init();                      /* XL9555初始化 */

  117.     while (sdmmc_init())            /* 检测不到SD卡 */
  118.     {
  119.         lcd_show_string(30, 110, 200, 16, 16, "SD Card Error!", RED);
  120.         vTaskDelay(pdMS_TO_TICKS(200));
  121.         lcd_fill(30, 110, 239, 126, WHITE);
  122.         vTaskDelay(pdMS_TO_TICKS(200));
  123.     }

  124.     ret = exfuns_init();            /* 为fatfs相关变量申请内存 */

  125.     while (fonts_init())            /* 检查字库 */
  126.     {
  127.         lcd_clear(WHITE);
  128.         lcd_show_string(30, 30, 200, 16, 16, "ESP32-P4", RED);
  129.         
  130.         key = fonts_update_font(30, 50, 16, (uint8_t *)"0:", RED);  /* 更新字库 */

  131.         while (key)                 /* 更新失败 */
  132.         {
  133.             lcd_show_string(30, 50, 200, 16, 16, "Font Update Failed!", RED);
  134.             vTaskDelay(pdMS_TO_TICKS(200));
  135.             lcd_fill(20, 50, 200 + 20, 90 + 16, WHITE);
  136.             vTaskDelay(pdMS_TO_TICKS(200));
  137.         }

  138.         lcd_show_string(30, 50, 200, 16, 16, "Font Update Success!   ", RED);
  139.         vTaskDelay(pdMS_TO_TICKS(1000));
  140.         lcd_clear(WHITE);
  141.     }

  142.     text_show_string(30, 50,  200, 16, "正点原子ESP32-P4开发板",16,0, RED);
  143.     text_show_string(30, 70,  200, 16, "图片显示 实验", 16, 0, RED);
  144.     text_show_string(30, 90,  200, 16, "正点原子@ALIENTEK", 16, 0, RED);
  145.     text_show_string(30, 110, 200, 16, "KEY0:NEXT KEY1<img src="static/image/smiley/default/tongue.gif" border="0" smilieid="7" alt=":P">REV", 16, 0, RED);

  146.     while (f_opendir(&picdir, "0:/PICTURE"))    /* 打开图片文件夹 */
  147.     {
  148.         text_show_string(30, 150, 240, 16, "PICTURE文件夹错误!", 16, 0, RED);
  149.         vTaskDelay(pdMS_TO_TICKS(200));
  150.         lcd_fill(30, 150, 240, 186, WHITE);
  151.         vTaskDelay(pdMS_TO_TICKS(200));
  152.     }

  153.     totpicnum = pic_get_tnum("0:/PICTURE");     /* 得到总有效文件数 */
  154.     while (totpicnum == 0)      /* 图片文件为0 */
  155.     {
  156.         text_show_string(30, 150, 240, 16, "没有图片文件!", 16, 0, RED);
  157.         vTaskDelay(pdMS_TO_TICKS(200));
  158.         lcd_fill(30, 150, 240, 186, WHITE);
  159.         vTaskDelay(pdMS_TO_TICKS(200));
  160.     }

  161.     picfileinfo = (FILINFO *)malloc(sizeof(FILINFO));   /* 申请内存 */
  162. pname = malloc(255 * 2 + 1);                       /* 为带路径的文件名分配内存 */
  163. /* 申请4*totpicnum个字节的内存,用于存放图片索引 */
  164.     picoffsettbl = malloc(4 * totpicnum);        

  165.     while (!picfileinfo || !pname || !picoffsettbl)     /* 内存分配出错 */
  166.     {
  167.         text_show_string(30, 150, 240, 16, "内存分配失败!", 16, 0, RED);
  168.         vTaskDelay(pdMS_TO_TICKS(200));
  169.         lcd_fill(30, 150, 240, 186, WHITE);  /* 清除显示 */
  170.         vTaskDelay(pdMS_TO_TICKS(200));
  171.     }

  172.     /* 记录索引 */
  173.     ret = f_opendir(&picdir, "0:/PICTURE");             /* 打开目录 */

  174.     if (ret == FR_OK)
  175.     {
  176.         curindex = 0;                                   /* 当前索引为0 */

  177.         while (1)                                       /* 全部查询一遍 */
  178.         {
  179.             temp = picdir.dptr;                         /* 记录当前dptr偏移 */
  180.             ret = f_readdir(&picdir, picfileinfo);      /* 读取目录下的一个文件 */
  181.             if (ret != FR_OK || picfileinfo->fname[0] == 0) break;  

  182.             ret = exfuns_file_type(picfileinfo->fname);

  183.             if ((ret & 0X0F) != 0X00)                   /* 取高四位,看看是不是图片文件 */
  184.             {
  185.                 picoffsettbl[curindex] = temp;        /* 记录索引 */
  186.                 curindex++;
  187.             }
  188.         }

  189.         ESP_LOGI("main", "0:/PICTURE pic_num:%d", curindex);
  190.     }

  191.     text_show_string(30, 150, 240, 16, "开始显示...", 16, 0, RED);
  192.     vTaskDelay(pdMS_TO_TICKS(1000));
  193.     piclib_init();                  /* 初始化画图 */
  194.     curindex = 0;                          /* 从0开始显示 */
  195.     ret = f_opendir(&picdir, (const TCHAR *)"0:/PICTURE");    /* 打开目录 */

  196.     while (ret == FR_OK)                                                     /* 打开成功 */
  197.     {
  198.         atk_dir_sdi(&picdir, picoffsettbl[curindex]);
  199.         ret = f_readdir(&picdir, picfileinfo);                /* 读取目录下的一个文件 */

  200.         if (ret != FR_OK || picfileinfo->fname[0] == 0)
  201.         {
  202.             picdir.dptr = picoffsettbl[curindex];
  203.             ret = f_readdir(&picdir, picfileinfo);          /* 读取目录下的一个文件 */
  204.             
  205.             if (ret != FR_OK || picfileinfo->fname[0] == 0)
  206.             {
  207.                 ESP_LOGE(__FUNCTION__, "Read Failed");
  208.                 break;  /* 错误了/到末尾了,退出 */
  209.             }
  210.         }

  211.         strcpy((char *)pname, "0:/PICTURE/");              /* 复制路径(目录) */
  212. /* 将文件名接在后面 */
  213.         strcat((char *)pname, (const char *)picfileinfo->fname);               
  214.         lcd_clear(BLACK);
  215.         /* 显示图片 */
  216.         piclib_ai_load_picfile(pname, 0, 0, lcddev.width, lcddev.height);
  217.         /* 显示图片名字 */
  218.         text_show_string(2, 2, lcddev.width, 16, (char *)pname, 16, 0, RED);   
  219.         t = 0;

  220.         while (1)
  221.         {
  222.             t ++;
  223.             key = xl9555_key_scan(0);       /* 扫描按键 */

  224.             if (t > 250) key = KEY0_PRES;   /* 模拟一次按下KEY0 */

  225.             if ((t % 20) == 0)
  226.             {
  227.                 LED0_TOGGLE();
  228.             }

  229.             if (key == KEY1_PRES)            /* 上一张 */
  230.             {
  231.                 if (curindex)
  232.                 {
  233.                     curindex--;
  234.                 }
  235.                 else
  236.                 {
  237.                     curindex = totpicnum - 1;
  238.                 }
  239.                
  240.                 break;
  241.             }
  242.             else if (key == KEY0_PRES)      /* 下一张 */
  243.             {
  244.                 curindex++;

  245.                 if (curindex >= totpicnum)
  246.                 {
  247.                     curindex = 0;           /* 到末尾的时候,自动从头开始 */
  248.                 }

  249.                 break;
  250.             }

  251.             vTaskDelay(pdMS_TO_TICKS(10));
  252.         }

  253.         ret = 0;
  254.     }

  255.     free(picfileinfo);    /* 释放内存 */
  256.     free(pname);          /* 释放内存 */
  257.     free(picoffsettbl);   /* 释放内存 */
  258. }
复制代码
上述代码的实际功能是从SD卡中读取图片文件,并在LCD屏幕上显示这些图片。用户可以通过按键操作浏览图片,支持上一张和下一张的切换。具体流程如下:
1)初始化硬件:首先初始化了NVS(非易失性存储器)、LED、按键、LCD显示、I2C接口以及外部扩展模块(如XL9555)。之后,检查SD卡是否正常连接,如果未连接,则显示错误信息并继续检测。
2)加载字库:在SD卡成功连接后,初始化了SPIFFS文件系统,并准备好对SD卡中的字库文件进行读取。最后,初始化了显示所需的字体库。
3)读取图片文件:程序打开0:/PICTURE文件夹,统计文件夹中有效的图片文件数量。如果没有图片文件,程序会显示相应的提示。然后,程序会遍历目录,记录图片文件的索引,并加载图片文件的路径。
4)显示图片:读取图片文件后,程序会通过LCD显示屏显示图片内容,并在屏幕上显示图片的文件名。图片的显示是通过调用图像加载函数piclib_ai_load_picfile来实现的。
5)按键控制:用户可以通过按下KEY0或KEY1键来切换图片。KEY0切换到下一张图片,KEY1切换到上一张图片。程序支持在图片末尾后自动返回到开头。
6)内存管理和错误处理:代码在内存分配时考虑了内存不足的情况,并进行了有效的错误处理和提示。每次使用完毕后,会释放申请的内存,以避免内存泄漏。
通过这样的流程,代码不仅实现了图片浏览功能,还考虑了设备的稳定性和用户交互的便利性,使得用户能够方便地查看存储在SD卡中的图片。

41.4 下载验证
将程序下载到开发板后,可以看到LCD开始显示图片(假设SD卡及文件都准备好了,即:在SD卡根目录新建:PICTURE文件夹,并存放一些图片文件(.bmp/.jpg/.gif/png)在该文件夹内),如下图所示:

第四十一章 图片显示实验55196.png
图41.4.1 图片显示实验显示效果
回复

使用道具 举报

您需要登录后才可以回帖 登录 | 立即注册

本版积分规则


关闭

原子哥极力推荐上一条 /1 下一条

正点原子公众号

QQ|手机版|OpenEdv-开源电子网 ( 粤ICP备12000418号-1 )

GMT+8, 2026-1-23 22:40

Powered by OpenEdv-开源电子网

© 2001-2030 OpenEdv-开源电子网

快速回复 返回顶部 返回列表