完善资料让更多小伙伴认识你,还能领取20积分哦, 立即完善>
1)实验平台:alientek 阿波罗 STM32F767 开发板
第五十章 视频播放器实验 STM32F4 的处理能力,不仅可以软解码音频,还可以用来播放视频!本章,我们将使用探索者 STM32F4 开发板来播放 AVI 视频,本章我们将实现一个简单的视频播放器,实现 AVI 视频播放。本章分为如下几个部: 50.1 AVI&libjpeg 简介 50.2 硬件设计 50.3 软件设计 50.4 下载验证 50.1 AVI&libjpeg 简介 本章,我们使用 libjpeg(由 IJG 提供),来实现 MJPG 编码的 AVI 格式视频播放,我们先 来简单介绍一下 AVI 和 libjpeg。 50.1.1 AVI 简介 AVI 是音频视频交错(Audio Video Interleaved)的英文缩写,它是微软开发的一种符合 RIFF 文件规范的数字音频与视频文件格式,原先用于 Microsoft Video for Windows (简称 VFW)环境, 现在已被多数操作系统直接支持。 AVI 格式允许视频和音频交错在一起同步播放,支持 256 色和 RLE 压缩,但 AVI 文件并 未限定压缩标准,AVI 仅仅是一个容器,用不同压缩算法生成的 AVI 文件,必须使用相应的解 压缩算法才能播放出来。比如本章,我们使用的 AVI,其音频数据采用 16 位线性 PCM 格式(未 压缩),而视频数据,则采用 MJPG 编码方式。 在介绍 AVI 文件前,我们要先来看看 RIFF 文件结构。AVI 文件采用的是 RIFF 文件结构 方式,RIFF(Resource Interchange File Format,资源互换文件格式)是微软定义的一种用于管 理 WINDOWS 环境中多媒体数据的文件格式,波形音频 WAVE,MIDI 和数字视频 AVI 都采用 这种格式存储。构造 RIFF 文件的基本单元叫做数据块(Chunk),每个数据块包含 3 个部分, 1、4 字节的数据块标记(或者叫做数据块的 ID) 2、数据块的大小 3、数据 整个 RIFF 文件可以看成一个数据块,其数据块 ID 为 RIFF,称为 RIFF 块。一个 RIFF 文 件中只允许存在一个 RIFF 块。RIFF 块中包含一系列的子块,其中有一种子块的 ID 为"LIST", 称为 LIST 块,LIST 块中可以再包含一系列的子块,但除了 LIST 块外的其他所有的子块都不 能再包含子块。 RIFF 和 LIST 块分别比普通的数据块多一个被称为形式类型(Form Type)和列表类型(List Type)的数据域,其组成如下: 1、4 字节的数据块标记(Chunk ID) 2、数据块的大小 3、4 字节的形式类型或者列表类型(ID) 4、数据 下面我们看看 AVI 文件的结构。AVI 文件是目前使用的最复杂的 RIFF 文件,它能同时存 储同步表现的音频视频数据。AVI 的 RIFF 块的形式类型(Form Type)是 AVI,它一般包含 3个子块,如下所述: 1、信息块,一个 ID 为"hdrl"的 LIST 块,定义 AVI 文件的数据格式。 2、数据块,一个 ID 为 "movi"的 LIST 块,包含 AVI 的音视频序列数据。 3、索引块,ID 为"idxl"的子块,定义"movi"LIST 块的索引数据,是可选块(不一定有)。 接下来,我们详细介绍下 AVI 文件的各子块构造,AVI 文件的结构如图 50.1.1.1 所示: 图 50.1.1.1 AVI 文件结构图 从上图可以看出(注意‘AVI ’,是带了一个空格的),AVI 文件,由:信息块(HeaderList)、 数据块(MovieList)和索引块(Index Chunk)等三部分组成,下面,我们分别介绍这几个部分。 1,信息块(HeaderList) 信息块,即 ID 为“hdrl”的 LIST 块,它包含文件的通用信息,定义数据格式,所用的压 缩算法等参数等。hdrl 块还包括了一系列的字块,首先是:avih 块,用于记录 AVI 的全局信息,比如数据流的数量,视频图像的宽度和高度等信息,avih 块(结构体都有把 BlockID 和 BlockSize 包含进来,下同)的定义如下: //avih 子块信息 typedef struct { u32 BlockID; //块标志:avih==0X61766968 u32 BlockSize; //块大小(不包含最初的 8 字节,即 BlockID 和 BlockSize 不算) u32 SecPerFrame; //视频帧间隔时间(单位为 us) u32 MaxByteSec; //最大数据传输率,字节/秒 u32 PaddingGranularity; //数据填充的粒度 u32 Flags; //AVI 文件的全局标记,比如是否含有索引块等 u32 TotalFrame; //文件总帧数 u32 InitFrames; //为交互格式指定初始帧数(非交互格式应该指定为 0) u32 Streams; //包含的数据流种类个数,通常为 2 u32 RefBufSize; //建议读取本文件的缓存大小(应能容纳最大的块) u32 Width; //图像宽 u32 Height; //图像高 u32 Reserved[4]; //保留 }AVIH_HEADER;这里有很多我们要用到的信息,比如 SecPerFrame,通过该参数,我们可以知道每秒钟的 帧率,也就知道了每秒钟需要解码多少帧图片,才能正常播放。TotalFrame 告诉我们整个视频 有多少帧,结合 SecPerFrame 参数,就可以很方便计算整个视频的时间了。Streams 告诉我们数 据流的种类数,一般是 2,即包含视频数据流和音频数据流。 在 avih 块之后,是一个或者多个 strl 子列表,文件中有多少种数据流(即前面的 Streams), 就有多少个 strl 子列表。每个 strl 子列表,至少包括一个 strh(Stream Header)块和一个 strf(Stream Format)块,还有一个可选的 strn(Stream Name)块(未列出)。注意:strl 子列表出现的顺 序与媒体流的编号(比如:00dc,前面的 00,即媒体流编号 00)是对应的,比如第一个 strl 子 列表说明的是第一个流(Stream 0),假设是视频流,则表征视频数据块的四字符码为“00dc”, 第二个 strl 子列表说明的是第二个流(Stream 1),假设是音频流,则表征音频数据块的四字符 码为“01dw”,以此类推。 先看 strh 子块,该块用于说明这个流的头信息,定义如下: //strh 流头子块信息(strh∈strl) typedef struct { u32 BlockID; //块标志:strh==0X73747268 u32 BlockSize; //块大小(不包含最初的 8 字节,即 BlockID 和 BlockSize 不算) u32 StreamType; //数据流种类,vids(0X73646976):视频;auds(0X73647561):音频 u32 Handler; //指定流的处理者,对于音视频来说即解码器,如 MJPG/H264 等. u32 Flags; //标记:是否允许这个流输出?调色板是否变化? u16 Priority; //流的优先级(当有多个同类型的流时优先级最高的为默认流) u16 Language; //音频的语言代号 u32 InitFrames; //为交互格式指定初始帧数 u32 Scale; //数据量, 视频每桢的大小或者音频的采样大小 u32 Rate; //Scale/Rate=每秒采样数 u32 Start; //数据流开始播放的位置,单位为 Scale u32 Length; //数据流的数据量,单位为 Scale u32 RefBufSize; //建议使用的缓冲区大小 u32 Quality; //解压缩质量参数,值越大,质量越好 u32 SampleSize; //音频的样本大小 struct //视频帧所占的矩形 { short Left; short Top; short Right; short Bottom; }Frame; }STRH_HEADER;这里面,对我们最有用的即 StreamType 和 Handler 这两个参数了,StreamType 用于告诉我 们此 strl 描述的是音频流(“auds”),还是视频流(“vids”)。而 Handler 则告诉我们所使用的解 码器,比如 MJPG/H264 等(实际以 strf 块为准)。 然后是 strf 子块,不过 strf 字块,需要根据 strh 字块的类型而定。 如果 strh 子块是视频数据流(StreamType=“vids”),则 strf 子块的内容定义如下: //BMP 结构体 typedef struct { u32 BmpSize; //bmp 结构体大小,包含(BmpSize 在内) long Width; //图像宽 long Height; //图像高 u16 Planes; //平面数,必须为 1 u16 BitCount; //像素位数,0X0018 表示 24 位 u32 Compression; //压缩类型,比如:MJPG/H264 等 u32 SizeImage; //图像大小 long XpixPerMeter; //水平分辨率 long YpixPerMeter; //垂直分辨率 u32 ClrUsed; //实际使用了调色板中的颜色数,压缩格式中不使用 u32 ClrImportant; //重要的颜色 }BMP_HEADER; //颜色表 typedef struct { u8 rgbBlue; //蓝色的亮度(值范围为 0-255) u8 rgbGreen; //绿色的亮度(值范围为 0-255) u8 rgbRed; //红色的亮度(值范围为 0-255) u8 rgbReserved; //保留,必须为 0 }AVIRGBQUAD; //对于 strh,如果是视频流,strf(流格式)使 STRH_BMPHEADER 块 typedef struct { u32 BlockID; //块标志,strf==0X73747266 u32 BlockSize; //块大小(不包含最初的 8 字节,即 BlockID 和 BlockSize 不算) BMP_HEADER bmiHeader; //位图信息头 AVIRGBQUAD bmColors[1]; //颜色表 }STRF_BMPHEADER;这里有 3 个结构体,strf 子块完整内容即:STRF_BMPHEADER 结构体,不过对我们有用 的信息,都存放在 BMP_HEADER 结构体里面,本结构体对视频数据的解码起决定性的作用, 它告诉我们视频的分辨率(Width 和 Height),以及视频所用的编码器(Compression),因此它 决定了视频的解码。本章例程仅支持解码视频分辨率小于屏幕分辨率,且编解码器必须是 MJPG 的视频格式。 如果 strh 子块是音频数据流(StreamType=“auds”),则 strf 子块的内容定义如下: //对于 strh,如果是音频流,strf(流格式)使 STRF_WAVHEADER 块 typedef struct { u32 BlockID; //块标志,strf==0X73747266 u32 BlockSize; //块大小(不包含最初的 8 字节,即 BlockID 和 BlockSize 不算) u16 FormatTag; //格式标志:0X0001=PCM,0X0055=MP3... u16 Channels; //声道数,一般为 2,表示立体声 u32 SampleRate; //音频采样率 u32 BaudRate; //波特率 u16 BlockAlign; //数据块对齐标志 u16 Size; //该结构大小 }STRF_WAVHEADER;本结构体对音频数据解码起决定性的作用,他告诉我们音频信号的编码方式(FormatTag)、 声道数(Channels)和采样率(SampleRate)等重要信息。本章例程仅支持 PCM 格式 (FormatTag=0X0001)的音频数据解码。 2,数据块(MovieList) 信息块,即 ID 为“movi”的 LIST 块,它包含 AVI 的音视频序列数据,是这个 AVI 文件的 主体部分。音视频数据块交错的嵌入在“movi”LIST 块里面,通过标准类型码进行区分,标准 类型码有如下 4 种: 1,“##db”(非压缩视频帧)、 2,“##dc”(压缩视频帧)、 3,“##pc”(改用新的调色板)、 4,“##wb”(音频帧)。 其中##是编号,得根据我们的数据流顺序来确定,也就是前面的 strl 块。比如,如果第一 个 strl 块是视频数据,那么对于压缩的视频帧,标准类型码就是:00dc。第二个 strl 块是音 频数据,那么对于音频帧,标准类型码就是:01wb。 紧跟着标准类型码的是 4 个字节的数据长度(不包含类型码和长度参数本身,也就是总长 度必须要加 8 才对),该长度必须是偶数,如果读到为奇数,则加 1 即可。我们读数据的时候, 一般一次性要读完一个标准类型码所表征的数据,方便解码。 3,索引块(Index Chunk) 最后,紧跟在‘hdrl’列表和‘movi’列表之后的,就是 AVI 文件可选的索引块。这个索 引块为 AVI 文件中每一个媒体数据块进行索引,并且记录它们在文件中的偏移(可能相对于 ‘movi’列表,也可能相对于 AVI 文件开头)。本章我们用不到索引块,这里就不详细介绍了。 关于 AVI 文件,我们就介绍到这,有兴趣的朋友,可以再看看光盘:6,软件资料→AVI 学 习资料 里面的相关文档。 50.1.2 libjpeg 简介 libjpeg 是一个完全用 C 语言编写的库,包含了被广泛使用的 JPEG 解码、JPEG 编码和其他 的 JPEG 功能的实现。这个库由 IJG 组织(Independent JPEG Group(独立 JPEG 小组))提供, 并维护。libjpeg,目前最新版本为 v9a,可以在:http://www.ijg.org 这个网站下载到。 libjpeg 具有稳定、兼容性强和解码速度较快等优点。 本章,我们使用 libjpeg v9a 来实现 MJPG 数据流的解码,MJPG 数据流,其实就是一张张 的 JPEG 图片拼起来的图片视频流,只要能快速解码 JPEG 图片,就可以实现视频播放。 前面的图片显示实验我们使用了 TJPGD 来做 JPEG 解码,大家可能会问,为什么不直接用 TJPGD 来解码呢?原因就是 TJPGD 的特点就是:占用资源少,但是解码速度慢。在 STM32F4 上,同样一张 320*240 的 JPG 图片,用 TJPGD 来解码,得 120 多 ms,而用 libjpeg,则只需要 50ms 左右即可完成解码,明显速度上 libjpeg 要快不少,使得解码视频成为可能。实际上,经 过我们优化后的 libjpeg,使用 STM32F4,在不超频的情况下,可以流畅播放 480*272@10 帧的 MJPG 视频(带音频)。 篇幅所限,关于 libjpeg 的移植,我们这里就不介绍了,请大家参考光盘源码。关于 libjpeg 的移植和使用,其实在下载的 libjpeg 源码里面,就有很多介绍,大家重点可以看:readme.txt、 filelist.txt、install.txt 和 libjpeg.txt 等。 本节我们主要讲解一下如何使用 libjpeg 来实现一个 jpeg 图片的解码,这个在 libjpeg 源码 里面:example.c,这个文件里面有简单的示范代码,在 libjpeg.txt 里面也有相关内容介绍。这 里我们简要的给大家介绍一下,example.c 里面的标准解码流程如下(示例代码): //错误结构体 struct my_error_mgr { struct jpeg_error_mgr pub; // jpeg_error_mgr 结构体,里面有很多错误处理函数 jmp_buf setjmp_buffer; //返回给函数调用者 }; typedef struct my_error_mgr * my_error_ptr; //JPEG 解码错误处理函数 METHODDEF(void) my_error_exit (j_common_ptr cinfo) { my_error_ptr myerr = (my_error_ptr) cinfo->err; //指向 cinfo->err (*cinfo->err->output_message) (cinfo); //显示错误信息 longjmp(myerr->setjmp_buffer, 1); //跳转到 setjmp 处 } //JPEG 解码函数 GLOBAL(int) read_JPEG_file (char * filename) { struct jpeg_decompress_struct cinfo; struct my_error_mgr jerr; //错误处理结构体 FILE * infile; //输入源文件 JSAMPARRAY buffer; //输出缓存 int row_stride; /* physical row width in output buffer */ if ((infile = fopen(filename, "rb")) == NULL)//尝试打开文件 { fprintf(stderr, "can't open %sn", filename); return 0; } //第一步,设置错误管理,初始化 JPEG 解码对象 cinfo.err = jpeg_std_error(&jerr.pub); //建立 JPEG 错误处理流程 jerr.pub.error_exit = my_error_exit; //处理函数指向 my_error_exit if (setjmp(jerr.setjmp_buffer)) //建立 my_error_exit 函数使用的返回上下文,当其他地方 //调用 longjmp 函数时,可以返回到这里进行错误处理 { jpeg_destroy_decompress(&cinfo);//释放解码对象资源 fclose(infile);//关闭文件 return 0; } jpeg_create_decompress(&cinfo);//初始化解码对象 cinfo //第二步,指定数据源(比如一个文件) jpeg_stdio_src(&cinfo, infile); //第三步,读取文件参数(通过 jpeg_read_header 函数) (void) jpeg_read_header(&cinfo, TRUE);//可以忽略此返回值 //第四步,设置解码参数(这里使用 jpeg_read_header 确定的默认参数),故无处理。 //第五步,开始解码 (void) jpeg_start_decompress(&cinfo);//还是忽略返回值 //在读取数据之前,可以做一些处理,比如设定 LCD 窗口,设定 LCD 起始坐标等 row_stride = cinfo.output_width * cinfo.output_components;//确定一样有多少个样本 //确保 buffer 至少可以保存一行的样本数据,为其申请内存 buffer = (*cinfo.mem->alloc_sarray) ((j_common_ptr) &cinfo, JPOOL_IMAGE, row_stride, 1); //第六步,循环读取数据 while (cinfo.output_scanline < cinfo.output_height)//每次读一样,直到读完整个文件 { (void) jpeg_read_scanlines(&cinfo, buffer, 1); //解码一行数据 put_scanline_someplace(buffer[0], row_stride); //将解码后的数据输出到某处 } //第七步,结束解码 (void) jpeg_finish_decompress(&cinfo);//结束解码,忽略返回值 //第八步,释放解码对象资源 jpeg_destroy_decompress(&cinfo);//释放解码时申请的资源(大把内存) fclose(infile); //关闭文件 return 1; //结束 }以上代码,将一个 jpeg 解码分成了 8 个步骤,我们结合本例程代码简单讲解下这几个步骤。 不过,我们先来看一下一个很重要的结构体数据类型:struct jpeg_decompress_struct,定义成 cinfo 变量,该变量保存着 jpeg 数据的详细信息,也保存着解码之后输出数据的详细信息。一般情况 下,每次调用 libjpeg 库 API 的时候都需要把这个变量作为第一个参数传入。另外用户也可以通 过修改该变量来修改 libjpeg 行为,比如输出数据格式,libjpeg 库可用的最大内存等等。 不过,在 STM32F4 里面使用,可不能按以示例代码这么来定义 cinfo 和 jerr 结构体,因为 单片机堆栈有限,cinfo 和 jerr 都比较大(均超过 400 字节),很容易出现堆栈溢出的情况。在 开发板源码,使用的是全局变量,而且用的是指针,通过内存管理分配。 接下来,开始看解码步骤,第一步是分配,并初始化解码对象结构体。这里做了两件事:1, 错误管理,2,初始化解码对象。首先,错误管理使用 setjmp 和 longjmp 机制(不懂请百度)来 实现类似 C++的异常处理功能,外部代码可以调用 longjmp 来跳转到 setjmp 位置,执行错误管 理(释放内存,关闭文件等)。这里注册了一个 my_error_exit 函数,来执行错误退出处理,在 本例程代码,还实现了一个函数:my_emit_message,输出警告信息,方便调试代码。然后,初 始化解码对象 cinfo,就是通过 jpeg_create_decompress 函数实现。 第二步,指定数据源。示例代码用的是 jpeg_stdio_src 函数。本章代码,我们用另外一个函 数实现: //初始化 jpeg 解码数据源 static void jpeg_filerw_src_init(j_decompress_ptr cinfo) { if (cinfo->src == NULL) /* first time for this JPEG object? */ { cinfo->src = (struct jpeg_source_mgr *) (*cinfo->mem->alloc_small)((j_common_ptr) cinfo, JPOOL_PERMANENT,sizeof(struct jpeg_source_mgr)); } cinfo->src->init_source = init_source; cinfo->src->fill_input_buffer = fill_input_buffer; cinfo->src->skip_input_data = skip_input_data; cinfo->src->resync_to_restart = jpeg_resync_to_restart; /* use default method */ cinfo->src->term_source = term_source; cinfo->src->bytes_in_buffer = 0; /* forces fill_input_buffer on first read */ cinfo->src->next_input_byte = NULL; /* until buffer loaded */ }该函数里面,设置了 cinfo->src 的各个函数指针,用于获取外部数据。这里面重点是两个 函数:fill_input_buffer 和 skip_input_data,前者用于填充数据给 libjpeg,后者用于跳过一定字 节的数据。这两个函数请看本例程源码(在 mjpeg.c 里面)。 第三步,读取文件参数。通过 jpeg_read_header 函数实现,该函数将读取 JPEG 的很多参数, 必须在解码前调用。 第四步,设置解码参数,示例代码没有做任何设置(使用默认值)。本章代码则做了设置, 如下: cinfo->dct_method = JDCT_IFAST; cinfo->do_fancy_upsampling = 0;这里,我们设置了使用快速整型 DCT 和 do_fancy_upsampling 的值为假(0),以提高解码 速度。 第五步,开始解码。示例代码首先调用 jpeg_start_decompress 函数,然后计算样本输出 buffer 大小,并为其申请内存,为后续读取解码后的数据做准备。不过本章例程,我们为了提高速度, 没有做这些处理了,我们直接修改底层函数:h2v1_merged_upsample 和 h2v2_merged_upsample (在 jdmerge.c 里面),将输出的 RGB 数据直接转换成 RGB565,送给 LCD。然后,为了正确 的输出到 LCD,我们在 jpeg_start_decompress 函数之后,加入如下代码: LCD_Set_Window(imgoffx,imgoffy,cinfo->output_width,cinfo->output_height); LCD_WriteRAM_Prepare(); //开始写入 GRAM这两个函数,先设置好开窗大小(即 jpeg 图片尺寸),然后就发送准备写入 GRAM 指令。 后续解码的时候,直接就在h2v1_merged_upsample 和 h2v2_merged_upsample 里面丢数据给 LCD, 实现 jpeg 解码输出到 LCD。 第六步,循环读取数据。通过 jpeg_read_scanlines 函数,循环解码并读取 jpeg 图片数据, 实现 jpeg 解码。示例代码通过 put_scanline_someplace 函数,输出到某个地方(如 lcd,文件等), 本章例程则直接解码的时候就输出到 LCD 了,所以仅剩 jpeg_read_scanlines 函数,循环调用即 可实现 jpeg→LCD 的操作。 第七步,解码结束。解码完成后,通过 jpeg_finish_decompress 函数,结束 jpeg 解码。 第八步,释放解码对象资源。在所有操作完成后,通过 jpeg_destroy_decompress,释放解 码过程中用到的资源(比如释放内存)。这样,我们就完成了一张 jpeg 图片的解码。上面,我们简要列出了本章例程与 example.c 的异同,详细的代码,请大家参考光盘本例程源码 mjpeg.c。libjepg 的使用,我们就介绍到这里。 最后,我们看看要实现 avi 视频文件的播放,主要有哪些步骤,如下: 1)初始化各外设 要解码视频,相关外设肯定要先初始化好,比如:SDIO(驱动 SD 卡用)、I2S、DMA、 WM8978、LCD 和按键等。这些具体初始化过程,在前面的例程都有介绍,大同小异,这里就 不再细说了。 2)读取 AVI 文件,并解析 要解码,得先读取 avi 文件,按 50.1.1 节的介绍,读取出音视频关键信息,音频参数:编 码方式、采样率、位数和音频流类型码(01wb/00wb)等;视频参数:编码方式、帧间隔、图片尺 寸和视频流类型码(00dc/01dc)等;共同的:数据流起始地址。有了这些参数,我们便可以初始 化音视频解码,为后续解码做好准备。 3)根据解析结果,设置相关参数 根据第 2 步解析的结果,设置 I2S 的音频采样率和位数,同时要让视频显示在 LCD 中间区 域,得根据图片尺寸,设置 LCD 开窗时 x,y 方向的偏移量。 4)读取数据流,开始解码 前面三步完成,就可以正式开始播放视频了。读取视频流数据(movi 块),根据类型码, 执行音频/视频解码。对于音频数据(01wb/00wb),本例程只支持未压缩的 PCM 数据,所以,直 接填充到DMA缓冲区即可,由DMA循环发送给WM8978,播放音频。对于视频数据(00dc/01dc), 本例程只支持 MJPG,通过 libjpeg 解码,所以将视频数据按前面所说的几个步骤解码即可。然 后,利用定时器来控制帧间隔,以正常速度播放视频,从而实现音视频解码。 5)解码完成,释放资源 最后在文件读取完后(或者出错了),需要释放申请的内存、恢复 LCD 窗口、关闭定时器、 停止 I2S 播放音乐和关闭文件等一系列操作,等待下一次解码。 50.2 硬件设计 本章实验功能简介:开机后,先初始化各外设,然后检测字库是否存在,如果检测无问题, 则开始播放 SD 卡 VIDEO 文件夹里面的视频(.avi 格式)。注意:1,在 SD 卡根目录必须建 立一个 VIDEO 文件夹,并存放 AVI 视频(仅支持 MJPG 视频,音频必须是 PCM,且视频分辨 率必须小于等于屏幕分辨率)在里面。2,我们所需要的视频,可以通过:狸窝全能视频转换 器,转换后得到,具体步骤后续会讲到(50.4 节)。 视频播放时,LCD 上还会显示视频名字、当前视频编号、总视频数、声道数、音频采样率、 帧率、播放时间和总时间等信息。KEY0 用于选择下一个视频,KEY2 用于选择上一个视频, KEY_UP 可以快进,KEY1 可以快退。DS0 还是用于指示程序运行状态(仅字库错误时)。 本实验用到的资源如下: 1) 指示灯 DS0 2) 4 个按键(KEY_UP/KEY0/KEY1/KEY2) 3) 串口 4) TFTLCD 模块 5) SD 卡 6) SPI FLASH 7) WM8978 8) I2S2 这些前面都已介绍过。本实验,大家需要准备 1 个 SD 卡和一个耳机(或喇叭),分别插入 SD 卡接口和耳机接口(喇叭接 P1 接口),然后下载本实验就可以看视频了! 50.3 软件设计 打开本章实验工程目录可以看到,我们在工程根目录新建 MJPEG 文件夹,在该文件夹里 面新建了 JPEG 文件夹,存放 libjpeg v9a 的相关代码,同时,在 MJPEG 文件夹里面新建了 avi.c、 avi.h、mjpeg.c 和 mjpeg.h 四个文件。然后,工程里面,新建了 MJPEG 分组,将需要用到的相 关.c 文件添加到该分组下面,并将 MJPEG 和 JPEG 两个文件夹加入头文件包含路径。 我们还在 APP 文件夹下面新建了 videoplayer.c 和 videoplayer.h 两个文件,然后将 videoplayer.c 加入到工程的 APP 组下。 整个工程代码有点多,我们看看本实验新添加进来的代码,有哪些,如图 50.3.1 所示: 图 50.3.1 本实验新增代码 可见,本工程新增的代码是比较多的,主要是 libjpeg 需要的文件挺多的。这里我们挑一部 分重要代码给大家讲解。 首先是 avi.c 里面的几个函数,代码如下: AVI_INFO avix; //avi 文件相关信息 u8*const AVI_VIDS_FLAG_TBL[2]={"00dc","01dc"};//视频编码标志字符串,00dc/01dc u8*const AVI_AUDS_FLAG_TBL[2]={"00wb","01wb"};//音频编码标志字符串,00wb/01wb //avi 解码初始化 //buf:输入缓冲区 //size:缓冲区大小 //返回值:AVI_OK,avi 文件解析成功 // 其他,错误代码 AVISTATUS avi_init(u8 *buf,u16 size) { u16 offset; u8 *tbuf; AVISTATUS res=AVI_OK; AVI_HEADER *aviheader; LIST_HEADER *listheader; AVIH_HEADER *avihheader;STRH_HEADER *strhheader; STRF_BMPHEADER *bmpheader;STRF_WAVHEADER *wavheader; tbuf=buf; aviheader=(AVI_HEADER*)buf; if(aviheader->RiffID!=AVI_RIFF_ID)return AVI_RIFF_ERR; //RIFF ID 错误 if(aviheader->AviID!=AVI_AVI_ID)return AVI_AVI_ERR; //AVI ID 错误 buf+=sizeof(AVI_HEADER); //偏移 listheader=(LIST_HEADER*)(buf); if(listheader->ListID!=AVI_LIST_ID)return AVI_LIST_ERR; //LIST ID 错误 if(listheader->ListType!=AVI_HDRL_ID)return AVI_HDRL_ERR; //HDRL ID 错误 buf+=sizeof(LIST_HEADER); //偏移 avihheader=(AVIH_HEADER*)(buf); if(avihheader->BlockID!=AVI_AVIH_ID)return AVI_AVIH_ERR; //AVIH ID 错误 avix.SecPerFrame=avihheader->SecPerFrame; //得到帧间隔时间 avix.TotalFrame=avihheader->TotalFrame; //得到总帧数 buf+=avihheader->BlockSize+8; //偏移 listheader=(LIST_HEADER*)(buf); if(listheader->ListID!=AVI_LIST_ID)return AVI_LIST_ERR; //LIST ID 错误 if(listheader->ListType!=AVI_STRL_ID)return AVI_STRL_ERR; //STRL ID 错误 strhheader=(STRH_HEADER*)(buf+12); if(strhheader->BlockID!=AVI_STRH_ID)return AVI_STRH_ERR; //STRH ID 错误 if(strhheader->StreamType==AVI_VIDS_STREAM) ///视频帧在前 { if(strhheader->Handler!=AVI_FORMAT_MJPG)return AVI_FORMAT_ERR;//不支持 avix.VideoFLAG=(u8*)AVI_VIDS_FLAG_TBL[0]; //视频流标记 "00dc" avix.AudioFLAG=(u8*)AVI_AUDS_FLAG_TBL[1]; //音频流标记 "01wb" bmpheader=(STRF_BMPHEADER*)(buf+12+strhheader->BlockSize+8);//strf if(bmpheader->BlockID!=AVI_STRF_ID)return AVI_STRF_ERR;//STRF ID 错误 avix.Width=bmpheader->bmiHeader.Width; avix.Height=bmpheader->bmiHeader.Height; buf+=listheader->BlockSize+8; //偏移 listheader=(LIST_HEADER*)(buf); if(listheader->ListID!=AVI_LIST_ID)//是不含有音频帧的视频文件 { avix.SampleRate=0; //音频采样率 avix.Channels=0; //音频通道数 avix.AudioType=0; //音频格式 }else { if(listheader->ListType!=AVI_STRL_ID)return AVI_STRL_ERR;//STRL ID 错误 strhheader=(STRH_HEADER*)(buf+12); if(strhheader->BlockID!=AVI_STRH_ID)return AVI_STRH_ERR;//STRH 错误 if(strhheader->StreamType!=AVI_AUDS_STREAM) return AVI_FORMAT_ERR;//格式错误 wavheader=(STRF_WAVHEADER*)(buf+12+strhheader->BlockSize+8);//strf if(wavheader->BlockID!=AVI_STRF_ID)return AVI_STRF_ERR;//STRF 错误 avix.SampleRate=wavheader->SampleRate; //音频采样率 avix.Channels=wavheader->Channels; //音频通道数 avix.AudioType=wavheader->FormatTag; //音频格式 } }else if(strhheader->StreamType==AVI_AUDS_STREAM) //音频帧在前 { avix.VideoFLAG=(u8*)AVI_VIDS_FLAG_TBL[1]; //视频流标记 "01dc" avix.AudioFLAG=(u8*)AVI_AUDS_FLAG_TBL[0]; //音频流标记 "00wb" wavheader=(STRF_WAVHEADER*)(buf+12+strhheader->BlockSize+8);//strf if(wavheader->BlockID!=AVI_STRF_ID)return AVI_STRF_ERR; //STRF ID 错误 avix.SampleRate=wavheader->SampleRate; //音频采样率 avix.Channels=wavheader->Channels; //音频通道数 avix.AudioType=wavheader->FormatTag; //音频格式 buf+=listheader->BlockSize+8; //偏移 listheader=(LIST_HEADER*)(buf); if(listheader->ListID!=AVI_LIST_ID)return AVI_LIST_ERR; //LIST ID 错误 if(listheader->ListType!=AVI_STRL_ID)return AVI_STRL_ERR; //STRL ID 错误 strhheader=(STRH_HEADER*)(buf+12); if(strhheader->BlockID!=AVI_STRH_ID)return AVI_STRH_ERR; //STRH ID 错误 if(strhheader->StreamType!=AVI_VIDS_STREAM)return AVI_FORMAT_ERR; bmpheader=(STRF_BMPHEADER*)(buf+12+strhheader->BlockSize+8);//strf if(bmpheader->BlockID!=AVI_STRF_ID)return AVI_STRF_ERR; //STRF ID 错误 if(bmpheader->bmiHeader.Compression!=AVI_FORMAT_MJPG) return AVI_FORMAT_ERR;//格式错误 avix.Width=bmpheader->bmiHeader.Width; avix.Height=bmpheader->bmiHeader.Height; } offset=avi_srarch_id(tbuf,size,"movi"); //查找 movi ID if(offset==0)return AVI_MOVI_ERR; //MOVI ID 错误 if(avix.SampleRate)//有音频流,才查找 { tbuf+=offset; offset=avi_srarch_id(tbuf,size,avix.AudioFLAG); //查找音频流标记 if(offset==0)return AVI_STREAM_ERR; //流错误 tbuf+=offset+4; avix.AudioBufSize=*((u16*)tbuf); //得到音频流 buf 大小. } return res; } //查找 ID //buf:待查缓存区 //size:缓存大小 //id:要查找的 id,必须是 4 字节长度 //返回值:0,查找失败,其他:movi ID 偏移量 u16 avi_srarch_id(u8* buf,u16 size,u8 *id) { u16 i; size-=4; for(i=0;i { if(buf==id[0]) if(buf[i+1]==id[1]) if(buf[i+2]==id[2]) if(buf[i+3]==id[3])return i;//找到"id"所在的位置 } return 0; } //得到 stream 流信息 //buf:流开始地址(必须是 01wb/00wb/01dc/00dc 开头) AVISTATUS avi_get_streaminfo(u8* buf) { avix.StreamID=MAKEWORD(buf+2); //得到流类型 avix.StreamSize=MAKEDWORD(buf+4); //得到流大小 if(avix.StreamSize%2)avix.StreamSize++; //奇数加 1(avix.StreamSize,必须是偶数) if(avix.StreamID==AVI_VIDS_FLAG||avix.StreamID==AVI_AUDS_FLAG) return AVI_OK; return AVI_STREAM_ERR; }这里三个函数,其中 avi_ini 用于解析 AVI 文件,获取音视频流数据的详细信息,为后续解 码做准备。而 avi_srarch_id 用于查找某个 ID,可以是 4 个字节长度的 ID,比如 00dc,01wb, movi 之类的,在解析数据以及快进快退的时候,有用到。avi_get_streaminfo 函数,则是用来获 取当前数据流信息,重点是取得流类型和流大小,方便解码和读取下一个数据流。 接下来,我们看 mjpeg.c 里面的几个函数,代码如下: //mjpeg 解码初始化 //offx,offy:x,y 方向的偏移 //返回值:0,成功; 1,失败 u8 mjpegdec_init(u16 offx,u16 offy) { cinfo=mymalloc(SRAMCCM,sizeof(struct jpeg_decompress_struct)); jerr=mymalloc(SRAMCCM,sizeof(struct my_error_mgr)); jmembuf=mymalloc(SRAMCCM,MJPEG_MAX_MALLOC_SIZE);//解码内存池申请 if(cinfo==0||jerr==0||jmembuf==0){ mjpegdec_free();return 1;} //保存图像在 x,y 方向的偏移量 imgoffx=offx; imgoffy=offy; return 0; } //mjpeg 结束,释放内存 void mjpegdec_free(void) { myfree(SRAMCCM,cinfo); myfree(SRAMCCM,jerr); myfree(SRAMCCM,jmembuf); } //解码一副 JPEG 图片 //buf:jpeg 数据流数组 bsize:数组大小 //返回值:0,成功 其他,错误 u8 mjpegdec_decode(u8* buf,u32 bsize) { JSAMPARRAY buffer; if(bsize==0)return 1; jpegbuf=buf; ***ufsize=bsize; jmempos=0;//MJEPG 解码,重新从 0 开始分配内存 cinfo->err=jpeg_std_error(&jerr->pub); jerr->pub.error_exit = my_error_exit; jerr->pub.emit_message = my_emit_message; //if(bsize>20*1024)printf("s:%drn",bsize); if (setjmp(jerr->setjmp_buffer)) //错误处理 { jpeg_abort_decompress(cinfo); jpeg_destroy_decompress(cinfo); return 2; } jpeg_create_decompress(cinfo); jpeg_filerw_src_init(cinfo); jpeg_read_header(cinfo, TRUE); cinfo->dct_method = JDCT_IFAST; cinfo->do_fancy_upsampling = 0; jpeg_start_decompress(cinfo); LCD_Set_Window(imgoffx,imgoffy,cinfo->output_width,cinfo->output_height); LCD_WriteRAM_Prepare(); //开始写入 GRAM while (cinfo->output_scanline < cinfo->output_height) { jpeg_read_scanlines(cinfo, buffer, 1); } LCD_Set_Window(0,0,lcddev.width,lcddev.height);//恢复窗口 jpeg_finish_decompress(cinfo); jpeg_destroy_decompress(cinfo); return 0; }其中,mjpegdec_init 函数,用于初始化 jpeg 解码 ,主要是申请内存,然后确定视频在液 晶上面的偏移(以让视频显示在 LCD 中央)。mjpegdec_free 函数,用于释放内存,解码结束后 调用。mjpegdec_decode 函数,是解码 jpeg 的主要函数,通过前面 50.1.2 节介绍的步骤进行解 码,该函数的参数 buf 指向内存里面的一帧 jpeg 数据,bsize 就是数据大小。 接下来,我们看 videoplayer.c 里面 video_play_mjpeg 函数,代码如下: //播放一个 mjpeg 文件 //pname:文件名 //返回值: KEY0_PRES:下一曲 KEY1_PRES:上一曲 //其他:错误 u8 video_play_mjpeg(u8 *pname) { u8* framebuf; //视频解码 buf u8* pbuf; //buf 指针 FIL *favi; u8 res=0; u16 offset=0; u32 nr;u8 key; u8 i2ssavebuf; i2***uf[0]=mymalloc(SRAMIN,AVI_AUDIO_BUF_SIZE); //申请音频内存 i2***uf[1]=mymalloc(SRAMIN,AVI_AUDIO_BUF_SIZE); //申请音频内存 i2***uf[2]=mymalloc(SRAMIN,AVI_AUDIO_BUF_SIZE); //申请音频内存 i2***uf[3]=mymalloc(SRAMIN,AVI_AUDIO_BUF_SIZE); //申请音频内存 framebuf=mymalloc(SRAMIN,AVI_VIDEO_BUF_SIZE); //申请视频 buf favi=(FIL*)mymalloc(SRAMIN,sizeof(FIL)); //申请 favi 内存 memset(i2***uf[0],0,AVI_AUDIO_BUF_SIZE); memset(i2***uf[1],0,AVI_AUDIO_BUF_SIZE); memset(i2***uf[2],0,AVI_AUDIO_BUF_SIZE); memset(i2***uf[3],0,AVI_AUDIO_BUF_SIZE); if(i2***uf[3]==NULL||framebuf==NULL||favi==NULL) res=0XFF; while(res==0) { res=f_open(favi,(char *)pname,FA_READ); if(res==0) { pbuf=framebuf; res=f_read(favi,pbuf,AVI_VIDEO_BUF_SIZE,&nr);//开始读取 if(res) {printf("fread error:%drn",res);break;} //开始 avi 解析 res=avi_init(pbuf,AVI_VIDEO_BUF_SIZE); //avi 解析 if(res){ printf("avi err:%drn",res); break;} video_info_show(&avix); TIM6_Int_Init(avix.SecPerFrame/100-1,8400-1);//10Khz 计数频率,加 1 是 100us offset=avi_srarch_id(pbuf,AVI_VIDEO_BUF_SIZE,"movi");//寻找 movi ID avi_get_streaminfo(pbuf+offset+4);//获取流信息 f_lseek(favi,offset+12); //跳过标志 ID,读地址偏移到流数据开始处 res=mjpegdec_init((lcddev.width-avix.Width)/2, 110+(lcddev.height-110- avix.Height)/2);//初始化 JPG 解码 //JPG 解码初始化 if(avix.SampleRate) //有音频信息,才初始化 { WM8978_I2S_Cfg(2,0);//飞利浦标准,16 位数据长度 I2S2_Init(I2S_Standard_Phillips,I2S_Mode_MasterTx,I2S_CPOL_Low, I2S_DataFormat_16bextended); //飞利浦标准,主机发送,时钟低有效,16 位扩展帧 I2S2_SampleRate_Set(avix.SampleRate); //设置采样率 I2S2_TX_DMA_Init(i2***uf[1],i2***uf[2],avix.AudioBufSize/2); //配置 DMA i2s_tx_callback=audio_i2s_dma_callback; //回调函数 I2S_DMA_Callback i2splaybuf=0; i2ssavebuf=0; I2S_Play_Start(); //开启 I2S 播放 } while(1)//播放循环 { if(avix.StreamID==AVI_VIDS_FLAG)//视频流 { pbuf=framebuf; f_read(favi,pbuf,avix.StreamSize+8,&nr);//读整帧+下个数据流 ID res=mjpegdec_decode(pbuf,avix.StreamSize); if(res) printf("decode error!rn"); while(frameup==0);//等待时间到达(在 TIM6 的中断里面设置为 1) frameup=0; //标志清零 frame++; }else //音频流 { video_time_show(favi,&avix); //显示当前播放时间 i2ssavebuf++; if(i2ssavebuf>3)i2ssavebuf=0; do { nr=i2splaybuf; if(nr)nr--; else nr=3; }while(i2ssavebuf==nr);//碰撞等待. f_read(favi,i2***uf[i2ssavebuf],avix.StreamSize+8,&nr);//填充 i2***uf pbuf=i2***uf[i2ssavebuf]; } key=KEY_Scan(0); if(key==KEY0_PRES||key==KEY2_PRES) { res=key; break;}//切换 else if(key==KEY1_PRES||key==WKUP_PRES) { I2S_Play_Stop(); //关闭音频 video_seek(favi,&avix,framebuf); pbuf=framebuf; I2S_Play_Start(); //开启 DMA 播放 } if(avi_get_streaminfo(pbuf+avix.StreamSize))//读取下一帧 流标志 { printf("frame error rn"); res=KEY0_PRES; break; } } I2S_Play_Stop(); //关闭音频 TIM6->CR1&=~(1<<0); //关闭定时器 6 LCD_Set_Window(0,0,lcddev.width,lcddev.height);//恢复窗口 mjpegdec_free(); //释放内存 f_close(favi); } } myfree(SRAMIN,i2***uf[0]); myfree(SRAMIN,i2***uf[1]); myfree(SRAMIN,i2***uf[2]); myfree(SRAMIN,i2***uf[3]); myfree(SRAMIN,framebuf); myfree(SRAMIN,favi); return res; }该函数用来播放一个 avi 视频文件(mjpg 编码),解码过程就是根据前面我们在 50.1.2 节 最后所介绍的步骤进行,不过在这里,我们的音频播放用了 4 个 buf,以提高解码的流畅度。 最后,我们看看主函数代码: int main(void) { HAL_Init(); //初始化 HAL 库 Stm32_Clock_Init(336,8,2,7); //设置时钟,168Mhz delay_init(168); //初始化延时函数 uart_init(115200); //初始化 USART usmart_dev.init(84); //初始化 USMART LED_Init(); //初始化 LED KEY_Init(); //初始化 KEY LCD_Init(); //初始化 LCD SRAM_Init(); //初始化外部 SRAM W25QXX_Init(); //初始化 W25Q128 WM8978_Init(); //初始化 WM8978 WM8978_ADDA_Cfg(1,0); //开启 DAC WM8978_Input_Cfg(0,0,0); //关闭输入通道 WM8978_Output_Cfg(1,0); //开启 DAC 输出 WM8978_HPvol_Set(40,40); WM8978_SPKvol_Set(60); TIM3_Init(10000-1,8400-1); //10Khz 计数,1 秒钟中断一次 my_mem_init(SRAMIN); //初始化内部内存池 my_mem_init(SRAMCCM); //初始化 CCM 内存池 exfuns_init(); //为 fatfs 相关变量申请内存 f_mount(fs[0],"0:",1); //挂载 SD 卡 POINT_COLOR=RED; while(font_init()) //检查字库 { LCD_ShowString(30,50,200,16,16,"Font Error!"); delay_ms(200); LCD_Fill(30,50,240,66,WHITE);//清除显示 delay_ms(200); LED0=!LED0; } POINT_COLOR=RED; Show_Str(60,50,200,16,"Explorer STM32 开发板",16,0); Show_Str(60,70,200,16,"视频播放器实验",16,0); Show_Str(60,90,200,16,"正点原子@ALIENTEK",16,0); Show_Str(60,110,200,16,"2017 年 5 月 5 日",16,0); Show_Str(60,130,200,16,"KEY0:NEXT KEY2:PREV",16,0); Show_Str(60,150,200,16,"KEY_UP:FF KEY1:REW",16,0); delay_ms(1500); while(1) { video_play(); } }该函数代码同上一章的 main 函数代码几乎一样,十分简单,我们就不再多说了。 最后,因为视频解码需要用到比较多的堆栈,所以需要修改 startup_stm32f40_41xxx.s 里面 的堆栈大小,将原来的 0x00000400 设置为 0x00000800,如下: Stack_Size EQU 0x00000800 同时,为了提高速度,我们对编译器进行设置,选择使用-O2 优化,从而优化代码,提高 速度(但调试效果不好,建议调试时设置为-O0),编译器设置如图 50.3.2 所示: 图 50.3.2 编译器优化设置 设置完后,重新编译即可。至此,本实验的软件设计部分结束。 50.4 下载验证 本章,我们例程仅支持 MJPG 编码的 avi 格式视频,且音频必须是 PCM 格式,另外视频分 辨率不能大于 LCD 分辨率。要满足这些要求,现成的 avi 文件是很难找到的,所以我们需要用 软件,将通用视频(任何视频都可以)转换为我们需要的格式,这里我们通过:狸窝全能视频 转换器,这款软件来实现(路径:光盘:6,软件资料→软件→视频转换软件→狸窝全能视频转 换器.exe)。安装完后,打开,然后进行相关设置,软件设置如图 50.4.1 和 50.4.2 所示: 图 50.4.1 软件启动界面和设置 图 50.4.2 高级设置 首先,如图 50.4.1 所示,点击 1 处,添加视频,找到你要转换的视频,添加进来。有的视 频可能有独立字幕,比如我们打开的这个视频就有,所以在 2 处选择下字幕(如果没有的,可 以忽略此步)。然后在3处,点击▼图标,选择预制方案:AVI-Audio-Video Interleaved(*.avi), 即生成.avi 文件,然后点击 4 处的高级设置按钮,进入 50.4.2 所示的界面,设置详细参数如 下: 视频编码器:选择 MJPEG。本例程仅支持 MJPG 视频解码,所以选择这个编码器。 视频尺寸:480x272。这里得根据所用 LCD 分辨率来选择,我们用 480*800 的 4.3 寸电容屏 模块,所以,这里最大可以设置:480x272。PS:如果是 2.8 屏,最大宽度只能是 240)。 比特率:1000。这里设置越大,视频质量越好,解码就越慢(可能会卡),我们设置为 1000, 可以得到比较好的视频质量,同时也不怎么会卡。 帧率:10。即每秒钟 10 帧,对于 480*272 的视频,本例程最高就只能播放 10 帧左右的视 频,如果要想提高帧率,有几个办法:1,降低分辨率;2,降低比特率;3,降低音频采样率。 音频编码器:PCMS16LE。本例程只支持 PCM 音频,所以选择音频编码器为这个。 采样率:这里设置为 11025,即 11.025Khz 的采样率。这里越高,声音质量越好,不过, 转换后的文件就越大,而且视频可能会卡。 其他设置,采用默认的即可。设置完以后,点击确定,即可完成设置。 点击图50.4.1的5处的文件夹图标,设置转换后视频的输出路径,这里我们设置到了桌面, 这样转换后的视频,会保存在桌面。最后,点击图中 6 处的按钮,即可开始转换了,如图 50.4.3 所示: 图 50.4.3 正在转换 等转换完成后,将转换后的.avi 文件,拷贝到 SD 卡→VIDEO 文件夹下,然后插入开发板 的 SD 卡接口,就可以开始测试本章例程了。 在代码编译成功之后,我们下载代码到 ALIENTEK 探索者 STM32F4 开发板上,程序先检 测字库,然后检测 SD 卡的 VIDEO 文件夹,并查找 avi 视频文件,在找到有效视频文件后,便 开始播放视频,如图 50.4.4 所示: 图 50.4.4 视频播放中 可以看到,屏幕显示了文件名、索引、声道数、采样率、帧率和播放时间等参数。然后, 我们按 KEY0/KEY2,可以切换到下一个/上一个视频,按 KEY_UP/KEY1,可以快进/快退。 至此,本例程介绍就结束了。本实验,我们在 ALIENTEK STM32F4 探索者开发板上实现 了视频播放,体现了 STM32F4 强大的处理能力。 附本实验测试结果(视频比特率:1000,音频均为:11025,立体声) 对 240*160/240*180 分辨率,可达 30 帧 对 320*240 分辨率,可达 20 帧 对 480*272 分辨率,可达 10 帧 最后提醒大家,转换的视频分辨率,一定要根据自己的 LCD 设置,不能超过 LCD 的尺寸!! 否则无法播放(可能只听到声音,看不到图像)。 |
|
相关推荐
|
|
1980 浏览 1 评论
AD7686芯片不传输数据给STM32,但是手按住就会有数据。
1836 浏览 3 评论
4416 浏览 0 评论
如何解决MPU-9250与STM32通讯时,出现HAL_ERROR = 0x01U
1985 浏览 1 评论
hal库中i2c卡死在HAL_I2C_Master_Transmit
2489 浏览 1 评论
小黑屋| 手机版| Archiver| 电子发烧友 ( 湘ICP备2023018690号 )
GMT+8, 2024-12-19 14:02 , Processed in 0.570212 second(s), Total 66, Slave 48 queries .
Powered by 电子发烧友网
© 2015 bbs.elecfans.com
关注我们的微信
下载发烧友APP
电子发烧友观察
版权所有 © 湖南华秋数字科技有限公司
电子发烧友 (电路图) 湘公网安备 43011202000918 号 电信与信息服务业务经营许可证:合字B2-20210191 工商网监 湘ICP备2023018690号