【说在前面的话】
对嵌入式产品开发来说,成本是最基本的考量——同一个问题哪怕结论类似,在不同的背景下往往有着截然不同的新路历程。就拿“Cortex-M 平台下进行 JPEG 软件解码是否有意义”这个问题来说,这里的思考就颇为典型:
首先,Cortex-M 平台从 Cortex-M0 到 Cortex-M85 已经是一个跨度非常大的处理器品类了。一般来说,我们把大部分 Cortex-M0/M0+以及部分 Cortex-M3 芯片归类为“深度嵌入式系统(Deep Embedded System)”——这种说法其实是对“资源受限”或者“便宜货”的高情商说法,它们共有的特点是:
- 频率低于 72MHz(比如常见的 Cortex-M0+大多工作于 48MHz 或者 64MHz 下);
- Flash 捉襟见肘 (128K 都算大户人家了,64K 是家常便饭,32K 也司空见惯);
- SRAM 聊胜于无(6K 起步、8K 可玩,12K 标配,倘若有 16K 工程师便弹冠相庆了)
- 在性能上,由于片内 Flash Cache 那“纯属点缀”的作用,基本都是“残血版”。比如,Arm 官方标注 Cortex-M0+的最大 Coremark 是 1.46,实际上这类芯片你在 Flash 上一跑,十有八九只能跑出 2.6 ~ 1.2 左右的分数。
说到这里,可能有的朋友会满心不服:官方标注的是理论最大值,是 FPGA 在理想状况下跑出的 Coremark 极限值。实际产品都是在 Flash 上跑的,我们怎么可能跑出这样的好成绩?
有的,宝子,有的。比如 RP2040,人家在 XIP 连接的外部 SPI Flash 上跑 Coremark 都能跑到官方极限值的 90%——2.32。
工程在这里:https://github.com/GorgonMedu...
对跑 Coremark 有疑问的小伙伴可以参考这篇文章《【喂到嘴边了的模块】不服?跑个分看看!——Coremark 篇》,这里就不再赘述。
另外一类以 Cortex-M3/Cortex-M4 以及这几年逐渐流行起来的 Cortex-M33(或 STAR-MC1)为代表,被称为“主流嵌入式(Main-stream Embedded )”,它们的特点是:
- 频率通常在 72MHz 以上,常见的频率有 96MHz、144MHz 和 216MHz;
- Flash 大小适中:128K 起步,256K 常见,不少甚至可以触碰到 512K 的门槛;
- SRAM 主打“经济适用”:32K 是起步、64K 及格,128K 是工程师的心头好。
- 在性能方面,由于普遍配备了 8~16K 的 Flash Cache,因此即便在系统主频远高于 Flash 主频(通常在 28MHz 到 32MHz 以内),依然 可以达到 Cortex-M4F 官方性能的 6 到 9 成功力。
正因为此类芯片价格适中、资源较为丰富、性能恰到好处,被广泛用于各类嵌入式产品开发,对很多驱动彩屏 LCD 的产品来说甚至是“
选型的起点”。
最后一类产品被称之为“旗舰嵌入式(Rich-Embedded)”,以 Cortex-M7、Cortex-M85 以及最近逐渐流行起来的 Cortex-M55 为代表。因为资源丰富、性能卓越——身为 MCU 却摸到了 MPU 的下限——因此又被加以“跨界处理器(Cross-boundary Processor)”的名头高调宣传。它们的特点是:
- 频率极高:216MHz 起步、480MHz 常见、1G 甚至 1.5GHz 都不算新闻了
- Flash 豪横:1MByte 起步,2MByte 标配,4MByte 习以为常
- RAM 组合丰富:1M SRAM、32M SDRAM、还有各种打辅助的 PSRAM 等等
- 性能方面主打“力大砖飞”——不开优化,软件照样跑的飞起。
在你觉得我“下笔千言离题万里”之前,我们回头来看文章一开始提出的问题“Cortex-M 平台下进行 JPEG 软件解码是否有意义?”
- 对大多数 GUI 应用来说,系统选型的起点就是以 Cortex-M4F 为代表的“主流嵌入式系统”,比如 STM32F4 系列(及其兼容芯片)。造成这一原因的罪魁祸首是 LVGL——作为目前事实上“白嫖界”的通解,它对系统的最小要求正好落在了“主流嵌入式”单片机的范围内。
- 运行时刻进行 JPG 解码对系统的性能是有一定要求的——以 10K 大小 320*240 图片的解码为例,要想达到 3 FPS 的帧率,系统的基本要求至少是 Cortex-M3 72MHz
- 众所周知,根据《Arm-2D 教程》的描述:如果你有足够的资源,开发 GUI 请务必选择普通的 GUI 协议栈,比如 LVGL;对于少数资源受限的环境,(无论是芯片本身资源受限,还是除去其它应用后留给 GUI 开发的资源受限),如果 GUI 界面较为简单,则可以通过 Arm-2D 来降低产品成本。
- 主流嵌入式系统和旗舰嵌入式系统往往就自带了 JPEG 硬件解码器。就算没有解码器,在高性能处理器的驱动下,软件解码的效果也还说得过去。
结论似乎已经呼之欲出了:
- 如果你选了主流嵌入式系统(或者旗舰系统),那么无论是选择硬件解码,还是 TJpgDec 软件解码,在 GUI 中使用 JPEG 来节省存储器资源都是一个不错的选择;
- 如果你选择了深度嵌入式系统( 几乎罕有此类别下的芯片会提供 JPEG 硬件解码器),原本就不太适合做 GUI 开发(基本告别 LVGL 了),哪怕用了 Arm-2D 能够实现简单的图形应用,CPU 所能提供的算力做 JPEG 解码也只能提供“卡成 PPT”一般的效果。 这样的条件下使用 JPEG 还有什么实际意义呢?
别着急下结论,如果硬要在深度嵌入式系统下做 JPEG 解码,我们可以提取下面特点:
- 消耗的 Flash 小(320*240 的图片在 75%~80%的品质下,jpg 图片的典型大小在 10K 左右),应用所需的少数背景图可以直接存在内部 Flash 中(无需外扩)
- 解码速度慢,基本以 1 ~ 0.5FPS 为参照
- 如果使用 TJpgDec 进行解码,还具有代码尺寸小,消耗 RAM 小(3200Byte 以内),无需缓存整帧图片(边解码边显示)的优点
那么有没有应用产品对帧率几乎没有要求,但对存储器消耗却极其敏感(成本敏感),并且有一定的图形界面要求呢?其实很多:
- 带较复杂背景的仪表盘
- 偶尔需要显示公司 Logo 的简单 GUI 产品
- 电子标签
- 卡成 PPT 也无所谓的各类小产品,比如电子标签(墨水屏)、能显示图片就万事大吉的文创、玩具、小家电……
实际上,还有另外一类情况往往容易被忽略:当我们使用主流嵌入式芯片进行开发,留给 GUI 的 Flash 和 SRAM 资源都已近见底,哪怕没有硬件 JPEG 解码器的帮助,通过存储 JPEG 资源图片的方式来节省 Flash 依然是个不错的选择(避免了外扩 Flash)。考虑到 CPU 频率较高,此时,我们甚至可以获得较为流畅的动画效果。
总结来说:当我们被迫使用 Arm-2D 在资源受限的环境下进行嵌入式应用开发时,“搞点 JPEG 软解码很有必要!”。
【如何在 Arm-2D 中部署 TJpgDec 解码器】
从 v1.2.2 开始,Arm-2D 集成了 TJpgDec——一个横跨 8bit、16bit 和 32bit 平台的小资源 JPEG 解码库。
原生的 TJpgDec 只要消耗 3200Bytes 的 RAM 作为“工作缓冲(Working Memory)”,就可以支持任意分辨率的 JPEG 图片解码,并且允许使用 8bit、16bit(RGB565)和 24bit(RGB888)的格式进行输出。
当然,TJpgDec 也存在一些限制,比如不支持 32bit(Arm-2D 的 CCCN888)格式,不支持仅对我们“感兴趣的区域(Region of Interest,ROI)”进行解码,不支持对红色和蓝色通道进行交换等等。Arm-2D 在集成 TJpgDec 的基础上对其进行了魔改,实现了以下功能:
- 自动根据 Arm-2D 的颜色深度(GLCD_CFG_COLOUR_DEPTH)设置输出格式(且加入对 32bit 输出格式的支持);
- 追加 ROI 功能——支持 PFB,支持脏矩阵;
- 允许用户以追加参考点的形式来提升 ROI 解码性能;
- 按需解码,无需事先将整幅图片完全解码后保存到 RAM 中
- 在 copy-only 模式下,无需(工作缓冲以外的)像素缓冲;
- JPEG 图片以 tile 形式参与普通 Arm-2D 的 API 运算中
Arm-2D 是以 Loader 的形式引入 TJpgDec 的,因此部署时,我们要在 RTE 窗口中勾选 Acceleration::Arm-2D Extras::Loaders,如下图所示:
单击“确定”后,RTE 会在工程管理器的 Acceleration 列表中自动加入三个文件:
- tjpgd.c:魔改后的 TJpgDec 的源文件
- tjpgd_loader.c:Arm-2D 引入的 TJpgDec Loader 源文件
- arm_tjpgd_cfg.h:Arm-2D 引入的配置头文件(一般来说不需要做任何修改)
至此,我们就完成了 TJpgDec Loader 的部署。
【如何获取满足要求的 JPEG 图片】
JPEG 算法在压缩图片时有两种模式,一种是需要严格“顺序”解码的“基本(baseline)”模式,和支持“缩略图”的“高级模式(Advanced)”。由于自身定位的需要,着眼于“小巧”的 TJpgDec 只支持基本模式,因此目前市面上所能获得的大部分 JPEG 图片都必须经过模式转化后才能使用。
实现转码的方法非常多,比如我就是通过 MacOS 下的命令行:
convertinput.jpg-quality95-interlacenoneoutput.jpg
实现的,这里-interlance none 参数确保 JPEG 图片使用的是 baseline 编码方式。
其它转码方式建议询问 Kimi 或者 DeepSeek:
如何把一个图片转换成采用 baseline 编码方式的JPEG图片?
这里就不再赘述。
当我们获得了一个 baseline 编码的 jpg 图片后,下一步就要着手于把它转化为 C 语言数组——以便作为资源集成到项目中。网上有很多这类二进制数据(文件)转 C 语言数组的工具(Binary to C),比如:
https://notisrac.github.io/Fi...
打开后可以看到类似这样的界面:
按照箭头所指,单击“选择文件”,打开目标 jpg 文件,可以看到下面的内容:
这里,我们需要按照图片中标注的方式进行勾选,最后单击 Convert,获得所需的 C 语言数组:
此时,你既可以单击“Copy to Clipboard”,以便将数组复制到某个已有的 C 文件中,也可以单击“Save as file”将数组保存。完成上述步骤后,我们需要对生成的数组做一点小小的修改——将作为注释而存在的文件大小填入到数组的声明中,比如:
// array size is 23656
const uint8_t Helium[23656] = {
...
}
如果你是一个完美主义者,还可以按照自己所使用的编码规范修改名称,同时加入数组的原形声明,方便使用:
extern const uint8_t c_jpgHelium[23656];
// array size is 23656
const uint8_t c_jpgHelium[23656] = {
...
}
【如何在场景中使用 TJpgDec Loader】
首先,找到某一个场景的类,添加 arm_tjpgd_loader_t 对象,例如:
/*!
* \brief a user class for scene tjpgd
*/
typedef struct user_scene_tjpgd_t user_scene_tjpgd_t;
struct user_scene_tjpgd_t {
implement(arm_2d_scene_t); //! derived from class: arm_2d_scene_t
ARM_PRIVATE(
...
arm_tjpgd_loader_t tJPGBackground;
...
)
/* place your public member here */
};
接下来,我们要在场景的初始化函数中加入对 arm_tjpgd_loader_t 对象的初始化代码:
ARM_NONNULL(1)
user_scene_tjpgd_t *__arm_2d_scene_tjpgd_init(
arm_2d_scene_player_t *ptDispAdapter,
user_scene_tjpgd_t *ptThis)
{
...
*ptThis = (user_scene_tjpgd_t){
...
};
/* ------------ initialize members of user_scene_tjpgd_t begin ---------------*/
/* initialize TJpgDec loader */
do {
...
arm_tjpgd_loader_cfg_t tCFG = {
.bUseHeapForVRES = true,
.ptScene = (arm_2d_scene_t *)ptThis,
.u2WorkMode = ARM_TJPGD_MODE_PARTIAL_DECODED,
...
};
arm_tjpgd_loader_init(&this.tJPGBackground, &tCFG);
} while(0);
/* ------------ initialize members of user_scene_tjpgd_t end ---------------*/
arm_2d_scene_player_append_scenes( ptDispAdapter,
&this.use_as__arm_2d_scene_t,
1);
return ptThis;
}
这里初始化函数arm_tjpgd_loader_init()接受一个arm_tjpgd_loader_cfg_t 类型的结构体来传递初始化信息。它的定义如下:
typedef struct arm_tjpgd_loader_cfg_t {
//arm_2d_size_t tSize;
uint8_t bUseHeapForVRES : 1;
uint8_t u2ScratchMemType : 2;
uint8_t u2WorkMode : 2;
uint8_t : 5;
struct {
const arm_tjpgd_loader_io_t *ptIO;
uintptr_t pTarget;
} ImageIO;
arm_2d_scene_t *ptScene;
} arm_tjpgd_loader_cfg_t;
其中:
- u2WorkMode 用于指定解码器的工作模式,默认值是:
ARM_TJPGD_MODE_PARTIAL_DECODED,表示按需解码,这也是最常用的模式; - TJpgDec Loader 本质是一个虚拟资源,因此除了“配合 Copy-Only 工作的背景图片载入模式模式”外,工作时,都需要一个尺寸不大于 PFB 的像素缓冲。在前述的“按需解码模式”下,当 bUseHeapForVRES 为 “false(0)” 时,TJpDec Loader 将申请 PFB 作为像素缓冲。当 bUseHeapForVRES 为 true(1)时,TJpgDec Loader 将通过__arm_2d_allocate_scratch_memory()从 Heap 中申请缓冲。
- 当 TJpgDec Loader 从__arm_2d_allocate_scratch_memory()分配资源作为像素缓冲时,我们可以通过 u2ScrachMemType 来指定所需存储空间的速度属性:
typedef enum {
ARM_2D_MEM_TYPE_UNSPECIFIED, //!< 速度随意,有啥用啥
ARM_2D_MEM_TYPE_SLOW, //!< 速度较慢的 RAM //!< for slow memories, such as SDRAM, DDRAM, external memory etc
ARM_2D_MEM_TYPE_FAST, //!< 高速 RAM //!< for fast memories, such as TCM, SRAM etc.
} arm_2d_mem_type_t;
一般来说,该属性保持默认值即可。
- ptScene:指向 TJpgDec Loader 所在的场景类。
每个 TJpgDec Loader 都需要一个 IO 接口对象来访问 JPG 数据流。由于我们是使用 C 数组来保存 jpg 图片,因此需要在场景模板类中添加一个专门的读写 IO 对象 arm_tjpgd_io_binary_loader_t:
/*!
* \brief a user class for scene tjpgd
*/
typedef struct user_scene_tjpgd_t user_scene_tjpgd_t;
struct user_scene_tjpgd_t {
implement(arm_2d_scene_t); //! derived from class: arm_2d_scene_t
ARM_PRIVATE(
...
arm_tjpgd_loader_t tJPGBackground;
union {
arm_tjpgd_io_binary_loader_t tBinary;
} LoaderIO;
...
)
/* place your public member here */
};
在使用该 IO 接口前,需要对其进行初始化:
extern const uint8_t c_chHelium[23656];
arm_tjpgd_io_binary_loader_init(
&this.LoaderIO.tBinary,
c_chHelium75JPG,
sizeof(c_chHelium75JPG));
然后作为
arm_tjgpd_loader_cfg_t 对象的一部分传递给
arm_tjpgd_loader_init():
/* initialize TJpgDec loader */
do {
extern const uint8_t c_chHelium[23656];
arm_tjpgd_io_binary_loader_init(
&this.LoaderIO.tBinary,
c_chHelium75JPG,
sizeof(c_chHelium75JPG));
arm_tjpgd_loader_cfg_t tCFG = {
...
.ImageIO = {
.ptIO = &ARM_TJPGD_IO_BINARY_LOADER,
.pTarget = (uintptr_t)&this.LoaderIO.tBinary,
},
...
};
arm_tjpgd_loader_init(&this.tJPGBackground, &tCFG);
} while(0);
至此,我们就完成了对 TJpgDec Loader 的初始化工作。
接下来,我们要把下面几个函数插入到场景模板对应的时间处理程序中:
例如:
static void __on_scene_tjpgd_load(arm_2d_scene_t *ptScene)
{
user_scene_tjpgd_t *ptThis = (user_scene_tjpgd_t *)ptScene;
ARM_2D_UNUSED(ptThis);
arm_tjpgd_loader_on_load(&this.tJPGBackground);
}
...
static void __on_scene_tjpgd_depose(arm_2d_scene_t *ptScene)
{
user_scene_tjpgd_t *ptThis = (user_scene_tjpgd_t *)ptScene;
ARM_2D_UNUSED(ptThis);
arm_tjpgd_loader_depose(&this.tJPGBackground);
...
}
...
static void __on_scene_tjpgd_frame_start(arm_2d_scene_t *ptScene)
{
user_scene_tjpgd_t *ptThis = (user_scene_tjpgd_t *)ptScene;
ARM_2D_UNUSED(ptThis);
arm_tjpgd_loader_on_frame_start(&this.tJPGBackground);
}
...
static void __on_scene_tjpgd_frame_complete(arm_2d_scene_t *ptScene)
{
user_scene_tjpgd_t *ptThis = (user_scene_tjpgd_t *)ptScene;
ARM_2D_UNUSED(ptThis);
arm_tjpgd_loader_on_frame_complete(&this.tJPGBackground);
}
至此,我们就完成了所有的准备工作。
【“一切都是 Tile”】
正如 Arm-2D 的设计哲学那样,几乎所有 API 的操作对象都是“贴图(Tile)”——由 TJpgDec Loader 按需载入的 jpg 图片也是如此。下面我们就以“在某一场景中央显示图片”为例,展示 TJpgDec Loader 的使用方法:
static
IMPL_PFB_ON_DRAW(__pfb_draw_scene_tjpgd_handler)
{
ARM_2D_PARAM(pTarget);
ARM_2D_PARAM(ptTile);
ARM_2D_PARAM(bIsNewFrame);
user_scene_tjpgd_t *ptThis = (user_scene_tjpgd_t *)pTarget;
arm_2d_canvas(ptTile, __top_canvas) {
/*-----------------------draw the foreground begin-----------------------*/
/* following code is just a demo, you can remove them */
arm_2d_align_centre(
__top_canvas,
this.tJPGBackground.vres.tTile.tRegion.tSize) {
arm_2d_tile_copy_only(
&this.tJPGBackground.vres.tTile,
ptTile,
&__centre_region);
}
/*-----------------------draw the foreground end -----------------------*/
}
ARM_2D_OP_WAIT_ASYNC();
return arm_fsm_rt_cpl;
}
这里,由于 TJpgDec Loader 的本质是一个虚拟资源——从基类 arm_2d_tile_t 派生而来,因此,我们可以直接将其当做 tile 来使用:
- 通过 this.tJPGBackground.vres.tTile.tRegion.tSize 来获取图片的尺寸,并借助 arm_2d_align_centre()将图片居中;
- 通过 arm_2d_tile_copy_only()以背景图片载入模式(不需要额外的像素缓冲)将 jpg 图片显示在屏幕上。
是不是非常简单呢?
下图是 TJpgDec Loader 在 PC 例子工程中跑出的效果:
如果你按照上述方法无法显示图片,则可以顺着以下两个方向检查:
- 确保系统 Heap 的尺寸不小于 4K——确保 3200 字节的 Working Memory 可以分配成功
- 暂时只使用 arm_2d_tile_copy_only() 进行测试——避免额外申请像素缓冲
- 确保你所使用的图片使用 baseline 进行编码
保险起见,arm-2d 提供了几个用于验证的 jpg 图片数组:
externconstuint8_tc_chHeliumJPG[23656]; //! 95% 品质
externconstuint8_tc_chHelium75JPG[10685];//! 75% 品质
externconstuint8_tc_chHelium30JPG[5411];//! 30% 品质
【两个场景模板、两个 Demo】
如果上述方法无法解决问题,或者你对自己添加的代码心里没有底,又或者你只是想快速评估下 JPG 解码的效果,Arm-2D 为我们准备了两个勾选即可使用的 Demo:
- Decoding JPG with TJpgDec
该 Demo 以全屏刷新的方式居中显示一张大小为 320 * 256 的 jpg 图片。
下图是 TJpgDec Loader 在 PC 例子工程中跑出的效果:
你可以使用该场景来评估当前硬件系统在较为极限的全屏幕刷新模式下所能达到的 JPEG 解码性能。一般来说这一性能是当前芯片的最差情况——因为通常一个我们会配合脏矩阵——只刷新屏幕上变化的区域来优化帧率。关于脏矩阵的使用,请参考Arm-2D 列表
中的文章《【玩转 Arm-2D】如何使用脏矩阵优化帧率(基础篇)》,这里就不再赘述。
- Showing Animation with TJpgDec
该 Demo 以解码 JPEG 的方式在屏幕中央显示类似 GIF 的动画效果。由于使用了脏矩阵,因此场景的帧率与屏幕分辨率无关,可以用来评估当前平台以 JPG 为载体显示动画的能力。
下面的视频是 RP2040 (Cortex-M0+)在 240 * 240 以 SPI 为接口显示著名的《电信诈骗》GIF 的效果。
除了勾选即用的 Demo 以外,从实用性角度出发,cmsis-pack还为我们在MDK中提供了 TJpgDec Loader 相关的工程模板——简单修改几下(比如替换下素材)就可以完成一类应用场景的设计。
它们分别是:
- Meter:使用 jpg 作为表盘背景的仪表类应用
- Histogram:使用 jpg 作为背景的动态柱状图显示类应用
下面,我们就以 Meter 为例,展示场景模板的添加过程:
1、在工程管理器中选中你想添加代码模板的Group,单击右键,弹出菜单:
2、选择Add New Item to Group。
3、在窗口中找到User Code Template。展开Acceleration,选中Arm-2D Helper:PFB的Scene Template Meter,并在 Location 后面的"..." 按钮中将例子模板添加到工程目录中。
4、在 MDK 工程配置中,添加针对头文件的搜索路径,使其指向arm_2d_scene_meter.h所在的目录。
5、在main.c中添加头文件:
#include"arm_2d_scene_meter.h"
这样,我们就可以通过对应的初始化代码将 arm_2d_scene_meter 添加到场景播放器中了,例如:
...
arm_2d_scene_meter_init(&DISP_ADAPTER0);
arm_2d_scene_player_switch_to_next_scene(&DISP0_ADAPTER);
...
6、打开 arm_2d_scene_meter.h,找到宏,将其设置为“1”,从而选择使用 jpg 作为背景图片:
#ifndef ARM_2D_SCENE_METER_USE_JPG
# define ARM_2D_SCENE_METER_USE_JPG 1
#endif
效果如下:
类似的方法,我们也可以在模板中选择 Histogram 加入到工程中:
注意,arm_2d_scene_histogram.h 中用于打开 jpg 解码的宏如下:
#ifndef ARM_2D_SCENE_HISTOGRAM_USE_JPG
# define ARM_2D_SCENE_HISTOGRAM_USE_JPG 1
#endif
运行效果如下:
值得特别说明的是 Histogram 本身对性能要求非常低,但当你使用 jpg 作为背景图片时,对性能的要求就陡然升高,如果你看到类似下面的效果(甚至更差),则说明你的硬件平台的性能可能偏低了:
作为判断依据:如果系统的性能越高,则我们在 Histogram 中看到的正弦波波长就越长;反之性能越低,波长则越短。
【如何对接文件系统】
当我们可以在图形应用中使用 jpg 图片时,很多聪明的朋友可能立即会想到“将应用涉及的资源文件以 jpg 的形式保存到 SD 卡或者 SPI Flash 模拟的 U 盘里”,这样,更新资源就无需修改代码了(配合 Layout Assistant 可以做到对 jpg 图片尺寸的自适应)。
在前面的例子中,TJpgDec Loader 是通过 arm_tjpgd_io_binary_loader_t 类型的接口对象来访问数组中的 jpg 图片。其实,Arm-2D 还提供了一个名为 arm_tjpgd_io_file_loader_t
的接口类,方便我们透过<stdio.h>中的 API 函数(fopen、fread、fseek 和 fclose)来访问文件系统中的 jpg 图片。它的使用方法非常简单,比如:
1、在场景类中添加 arm_tjpgd_io_file_loader_t 成员:
struct user_scene_tjpgd_t {
implement(arm_2d_scene_t); //! derived from class: arm_2d_scene_t
ARM_PRIVATE(
...
union {
arm_tjpgd_io_file_loader_t tFile;
} LoaderIO;
...
)
/* place your public member here */
};
2、在场景初始化函数中添加对 arm_tjpgd_io_file_loader_t 对象的初始化:
arm_tjpgd_io_file_loader_init(
&this.LoaderIO.tFile,
"../common/asset/Helium.jpg");
这里,我们需要指定 jpg 文件的详细路径。
3、在 TJpgDec Loader 初始化时,将 对象绑定到 ImageIO 中:
/* initialize TJpgDec loader */
do {
arm_tjpgd_loader_cfg_t tCFG = {
...
.ImageIO = {
.ptIO = &ARM_TJPGD_IO_FILE_LOADER,
.pTarget = (uintptr_t)&this.LoaderIO.tFile,
},
...
};
arm_tjpgd_loader_init(&this.tJPGBackground, &tCFG);
} while(0);
4、确保你嵌入式系统环境中由 stdio.h 定义的文件访问函数可以正常工作,即,通过 fopen()、fread()、fseek()和 fclose()我们可以正确的访问 SD 卡或者 SPI Flash 上的文件。
除了透过 stdio.h 提供的标准函数访问文件系统,很多小伙伴还会使用诸如 FatFS、littleFS 之类的第三方库,此时,arm_tjpgd_io_file_loader_t 就无法满足要求了。
为了对接这些第三方库,我们实际上要实现一个名为 arm_tjpgd_loader_io_t 的接口:
typedef struct arm_tjpgd_loader_io_t {
bool (*fnOpen)(uintptr_t pTarget,
arm_tjpgd_loader_t *ptLoader);
void (*fnClose)(uintptr_t pTarget,
arm_tjpgd_loader_t *ptLoader);
bool (*fnSeek)(uintptr_t pTarget,
arm_tjpgd_loader_t *ptLoader,
int32_t offset,
int32_t whence);
size_t (*fnRead)(uintptr_t pTarget,
arm_tjpgd_loader_t *ptLoader,
uint8_t *pchBuffer,
size_t tSize);
} arm_tjpgd_loader_io_t;
实际上,从面向对象的角度考虑,arm_tjpgd_loader_io_t 是一个虚函数表的定义,是 IO 类的方法集合。我们除了要实现这些类的方法以外,还需要针对我们要对接的文件系统,设计一个 IO 类,比如,前文提到过的 IO 类 arm_tjpgd_loader_file_t,其定义如下:
typedef struct arm_tjpgd_io_file_loader_t {
ARM_PRIVATE(
const char *pchFilePath;
FILE *phFile;
)
} arm_tjpgd_io_file_loader_t;
我们可以看到,该类只是根据需要保存了目标文件的路径(pchFilePath)和打开文件后的句柄(phFile)——这是因为文件操作所需的大部分功能都已经通过 fopen、fseek、fread 和 fclose 完成了。
但在某些情况下,诸如 arm_tjpgd_loader_io_t 中 fnSeek 对应的读写指针定位功能,在第三方库(比如 fatfs)中没有实现,则需要我们来维护一个 size_t 类型的读写指针,例如 arm_tjpgd_io_binary_loader_t 中的 tPosition:
typedef struct arm_tjpgd_io_binary_loader_t {
ARM_PRIVATE(
size_t tPostion;
uint8_t *pchBinary;
size_t tSize;
)
} arm_tjpgd_io_binary_loader_t;
并实现对应的功能:
static
bool __arm_tjpgd_io_binary_seek(uintptr_t pTarget, arm_tjpgd_loader_t *ptLoader, int32_t offset, int32_t whence)
{
arm_tjpgd_io_binary_loader_t *ptThis = (arm_tjpgd_io_binary_loader_t *)pTarget;
ARM_2D_UNUSED(ptLoader);
assert(NULL != ptThis);
assert(NULL != this.pchBinary);
assert(this.tSize > 0);
switch (whence) {
case SEEK_SET:
if (offset < 0 || offset >= (int32_t)this.tSize) {
return false;
}
this.tPostion = offset;
break;
...
case SEEK_CUR:
if (offset > 0) {
if ((this.tPostion + offset) >= this.tSize) {
return false;
}
this.tPostion += offset;
} else if (offset < 0) {
size_t tABSOffset = -offset;
if ((this.tPostion < tABSOffset)) {
return false;
}
this.tPostion -= tABSOffset;
}
break;
default:
return false;
}
return true;
}
...
const arm_tjpgd_loader_io_t ARM_TJPGD_IO_BINARY_LOADER = {
.fnOpen = &__arm_tjpgd_io_binary_open,
.fnClose = &__arm_tjpgd_io_binary_close,
.fnSeek = &__arm_tjpgd_io_binary_seek,
.fnRead = &__arm_tjpgd_io_binary_read,
};
这里需要强调的是,当我们自己维护读写位置(tPosition)时:
- 在 fnSeek 对应的实现中,SEEK_SET 和 SEEK_CUR 必须处理;
- fnOpen 要将读写位置(tPosition)复位成“0”
- fnRead 要根据实际读取数据的多少更新读写指针(tPosition)
- tPosition 是 size_t 类型的变量,无符号,因此在处理负数 offset 时要对 offset 的符号进行处理。
更多细节,请参考 arm_tjpgd_io_binary_loader_t 和 arm_tjpgd_io_file_loader_t 的源代码(tjpgd_loader.c 和 tjpgd_loader.h),这里就不再赘述。
【性能优化小贴士】
JPEG 编解码的最小单位是一个长和宽都是 8 的整倍数的小矩形——常见的尺寸有 88 像素、1616 像素。编码后的 JPG 图片实际上就是由这样的小矩形从左到右、从上到下逐行排列的。
在这样的结构下,如果每个小矩形对应的数据长度是一样的,我们就可以根据感兴趣的区域快速的在数据流中定位到目标小矩形——然而,事与愿违,由于 JPEG 采用的是哈夫曼树排序后的编码方式,每个小矩形对应的编码长度是变化的,这就意味着,如果想找到图片中间某个坐标所对应的小矩形,我们就必须依次把之前所有的小矩形都解码一遍,如下图所示:
此外,对于下图的情况,在完成了蓝色部分第一行的解码后,哪怕我们想快进到蓝色第二行所在的位置,也必须老老实实把后续用不到的部分都依次解码后才有可能:
上述两种情况就造成了 ROI 模式下(只解码感兴趣的区域时)几乎无法避免的无效解码现象——换句话说,就是做了无用功。
为了解决这一问题,尤其是配合脏矩阵的使用,Arm-2D 创造性的引入了“解码上下文”的概念,即:
- 在首次解码的过程中记录下目标区域起点对应的上下文
- 在后续解码过程中,就可以直接从上下文处开始直接解码,从而跳过前面的“无用功”
为了降低用户的负担,Arm-2D 会自动添加一些上下文。此外,用户自己也可以通过函数 arm_tjpgd_loader_add_reference_point()来为指定的参考坐标点添加上下文。该 API 具有三种原型:
/*!
* \brief add reference point for a given TJpgDec Loader.
*
* \note 原型 1:
* arm_tjpgd_loader_add_reference_point(
* <TJpgDec Loader对象的指针>,
* <参考点在JPG图片内的坐标>);
*
* \note 原型 2:
* arm_tjpgd_loader_add_reference_point(
* <TJpgDec Loader对象的指针>,
* <JPG图片在某一具体画布内的坐标>
* <参考点在同一个画布内的坐标>);
*
* \note 原型 3:
* arm_tjpgd_loader_add_reference_point(
* <TJpgDec Loader对象的指针>,
* <目标tile的指针>
* <JPG图片在目标tile内的坐标>
* <参考点在屏幕上的绝对坐标>);
*/
#define arm_tjpgd_loader_add_reference_point(__TJPGD_LD_PTR, ...)
一般来说:
- 如果你明确想在 JPG 图片内指定一个参考点,使用“原型 1”即可;
- 如果你不确定参考点是否在 JPG 图片内,但明确知道 JPG 图片和参考点都位于某一个画布(canvas)内,那么只要使用“原型 2”——同时提供图片在画布内的位置和参考点在画布内的坐标即可;
- 如果你只想为屏幕上的某个绝对坐标添加参考点,而并不在乎 JPG 图片的具体位置,则使用“原型 3”就能满足这一需求。
此外,根据前面的结论,容易发现:如果一幅图片较宽(width 较大),则有可能导致换行时,所要做的无用功较多——这意味着降低行宽对提升 ROI 模式下解码性能非常有利。此时,在系统 RAM 资源允许的情况下(比如能提供 8K 的 HEAP——负担得起两份 TJpgDec Loader 同时工作时对 Working Memory 的需求),就可以考虑将原本的图片在水平方向上一分为二——降低行宽、提升 ROI 模式下的解码性能。
【尺寸优化小贴士】
TJpgDec Loader 的是在虚拟资源(Virtual Resource)基础上进行扩展而来。
对虚拟资源感到陌生的小伙伴,可以参考Arm-2D 文章列表中的《【玩转 Arm-2D】片内 Flash 不够怎么办?》,这里就不再赘述。
当你在初始化 TJpgDec Loader 时,将 bUseHeapForVRES 设置为 true,则“在需要时”,TJpgDec Loader 会通过 HEAP 来分配像素缓冲;反之,当其值为 false(或默认未设置该选项时),TJpgDec Loader 会向当前场景所在的场景播放器(一般是 DISP0_ADAPTER)申请 PFB Block 作为像素缓冲。
由于虚拟资源本质上是通过先将外部存储器中的图片资源载入到芯片内部的缓冲区(RAM)中,然后再完成后续的贴图操作,因此:
- 如果一个 API 用到了一个虚拟素材,就需要一块缓冲;
- 如果一个 API “同时用到”了两个虚拟素材,就需要两块缓冲,比如:
a. RGB565 的图片的像素数组(Source)
b. 图片对应的蒙版(Source Mask) - 如果一个 API “同时用到”了三个虚拟素材,就需要三块缓冲 ,比如:
a. RGB565 的图片的像素数组(Source)
b. 图片对应的蒙版(Source Mask)
c. 目标缓冲对应的蒙版(Target Mask)
当然,目前arm-2d的 API 也就只有涉及最多三个虚拟素材的情况。这就需要我们在 Display Adapter 的配置头文件(比如 arm_2d_disp_adapter_0.h 中)正确的配置 "Maximum number of Virtual Resource used per API":
如果我们的应用可以通过“只使用”arm_2d_tile_copy_only()就完成全部的虚拟资源访问,那么,选择“背景载入模式(Background Loading Mode)”就可以彻底避免申请额外的像素缓冲。
【说在后面的话】
总的来说,JPEG 解码有两个极端场景:
- 系统完全不在意帧率,只想节省点(内部)Flash 空间
- 系统频率在 100MHz 以上,在节省(内部)Flash 空间的基础上,仍然想通过脏矩阵来获得较为不错的帧率。
无论哪种情况,你都要掂量掂量,你是否开销得起 4K(甚至更多) 的 HEAP。
没有最好,只有最合适。在资源紧张的情况下,无论是将 JPEG 存储在内部 Flash,还是将资源展开后通过虚拟资源的形式保存在外部 Flash 中,都需要在性能和成本之间进行必要的权衡。
“又想马儿跑,又要马儿不吃草”是不太可能的——也许是时候换个芯片了。
![204[中级]: 网络用语“牛马”的流行与背后的社会心态- by Andrew Methven](https://cdn-image.aijishu.com...)
END
作者:GorgonMeducer傻孩子
原文:裸机思维
专栏推荐文章
- 【嵌入式解耦很难么】在“程序世界”做甲方爸爸是一种怎样的感觉?
- 【嵌入式解耦很难么】“面向对象”还是“面向过程”?接口说“我全要”
- 【嵌入式解耦很难么】霸总:你们都要变成我的形状!
- 超级嵌入式系统“性能/时间”工具箱
- 当 DeepSeek 接管操作系统:智能体(Agent)真能让程序员提前退休?
如果你喜欢我的思维,欢迎订阅裸机思维欢迎添加极术小姐姐微信(id:aijishu20)加入技术交流群,请备注研究方向。