2. 硬件解码器原理与调用:JPEG硬件解码器的工作流程、寄存器配置、DMA传输优化
讲软件解码讲了这么多年,说实话,到了真正的量产项目上,我越来越倾向于用硬件解码器。为什么?因为省电、省CPU、省时间。你想想看,一个800万像素的JPEG图片,用CPU软解可能要几十毫秒,硬件解码器几毫秒就搞定了。这差距,在嵌入式设备上就是能不能流畅播放幻灯片的关键。
今天我们就来聊聊JPEG硬件解码器到底怎么用。我会结合我实际调过的几个平台,把那些文档里没写清楚的坑,一个一个给你指出来。
2.1 JPEG硬件解码器的工作流程
硬件解码器说白了就是一个专用状态机。它不像CPU那样一条指令一条指令地跑,而是把JPEG解码的各个阶段——熵解码、反量化、IDCT、颜色转换——全部用硬件电路实现。你给它数据,它给你像素,中间不需要CPU干预。
核心流程: JPEG硬件解码器通常分为以下5个阶段,每个阶段都有独立的硬件模块并行工作。
这个流程看起来简单,但实际用起来有不少门道。我记得第一次调硬件解码器的时候,以为把数据塞进去就能出图,结果折腾了两天才发现是DMA配置错了。
2.2 寄存器配置详解
硬件解码器的寄存器配置,说白了就是告诉硬件:你要解码什么格式的图片、数据放在哪里、解码完输出到哪里。不同厂家的寄存器布局不一样,但核心概念是相通的。
2.2.1 核心寄存器组
| 寄存器名称 | 偏移地址 | 位宽 | 功能描述 |
|---|---|---|---|
| JPEG_CTRL | 0x00 | 32 | 控制寄存器:启动/停止解码、软复位、中断使能 |
| JPEG_STATUS | 0x04 | 32 | 状态寄存器:解码完成标志、错误标志、忙标志 |
| JPEG_IN_ADDR | 0x08 | 32 | 输入数据缓冲区地址(物理地址) |
| JPEG_IN_SIZE | 0x0C | 32 | 输入数据长度(字节数) |
| JPEG_OUT_ADDR | 0x10 | 32 | 输出像素缓冲区地址(物理地址) |
| JPEG_OUT_STRIDE | 0x14 | 32 | 输出行跨度(字节数,通常为width*3对齐到16字节) |
| JPEG_IMAGE_SIZE | 0x18 | 32 | 图像尺寸:高16位为宽度,低16位为高度 |
| JPEG_DMA_CTRL | 0x1C | 32 | DMA控制:传输模式、突发长度、优先级 |
我的经验: 配置寄存器时,一定要先读后写。有些寄存器有保留位,直接写0可能会触发硬件bug。我习惯的做法是:先读回默认值,修改需要的位,再写回去。这个习惯救过我至少两次。
2.2.2 典型配置流程
下面是一段实际项目中用过的初始化代码。注意,我故意保留了注释,因为有些坑不写注释下次自己都忘了。
/* JPEG硬件解码器初始化 - 基于某Cortex-M7平台 */
void jpeg_hw_decoder_init(jpeg_config_t *cfg)
{
uint32_t reg_val;
/* 1. 软复位解码器 */
JPEG_REG(JPEG_CTRL) |= (1 << 31); /* 复位位 */
while (JPEG_REG(JPEG_CTRL) & (1 << 31)); /* 等待复位完成 */
/* 2. 配置输入缓冲区 - 注意:必须是32字节对齐的物理地址 */
JPEG_REG(JPEG_IN_ADDR) = (uint32_t)cfg->input_buf;
JPEG_REG(JPEG_IN_SIZE) = cfg->input_size;
/* 3. 配置输出缓冲区 - 行跨度要对齐到16字节 */
JPEG_REG(JPEG_OUT_ADDR) = (uint32_t)cfg->output_buf;
JPEG_REG(JPEG_OUT_STRIDE) = ALIGN_UP(cfg->width * 3, 16);
/* 4. 配置图像尺寸 */
JPEG_REG(JPEG_IMAGE_SIZE) = (cfg->width << 16) | cfg->height;
/* 5. 配置DMA传输参数 */
reg_val = JPEG_REG(JPEG_DMA_CTRL);
reg_val &= ~0x7; /* 清除burst长度位 */
reg_val |= (0x3 << 0); /* 设置burst长度为8 */
reg_val |= (1 << 4); /* 使能DMA自动传输 */
JPEG_REG(JPEG_DMA_CTRL) = reg_val;
/* 6. 使能解码完成中断 */
JPEG_REG(JPEG_CTRL) |= (1 << 1);
}
⚠️ 我曾经踩过的坑: 输出缓冲区的地址必须是物理地址,不是虚拟地址。在有MMU的系统上,如果你传了虚拟地址,DMA会直接访问错误的内存区域,轻则花屏,重则系统崩溃。我有个同事因为这个bug查了整整一周。
2.3 DMA传输优化
DMA是硬件解码器的灵魂。没有DMA,CPU就得自己搬数据,那硬件解码的优势就废了一半。但DMA也不是随便配一下就能跑满带宽的,这里面的优化空间很大。
2.3.1 传输模式选择
| 传输模式 | 带宽利用率 | CPU占用 | 适用场景 |
|---|---|---|---|
| 单次传输 | 低(约40%) | 高(每次传输都要中断) | 小图片(< 32KB) |
| 突发传输 | 中(约65%) | 中(每突发一次中断) | 中等图片(32KB ~ 512KB) |
| 链表传输 | 高(约85%) | 低(全部传输完才中断) | 大图片(> 512KB) |
| 双缓冲传输 | 极高(约95%) | 极低(乒乓切换无等待) | 连续解码(幻灯片/视频) |
我个人最常用的是链表传输模式。为什么?因为JPEG图片解码时,输入数据是连续的,但输出数据可能需要分块写入显存的不同区域。链表传输可以一次性配置好多个传输描述符,DMA自己按顺序执行,CPU只需要在最后收一个完成中断。
2.3.2 双缓冲乒乓机制
如果你要做连续解码——比如图片幻灯片或者视频播放——双缓冲是必须的。原理很简单:两个缓冲区轮流用,一个给DMA写数据,一个给显示控制器读数据。
/* 双缓冲乒乓切换示例 */
typedef struct {
uint8_t *buf[2]; /* 两个缓冲区 */
uint32_t size; /* 每个缓冲区大小 */
volatile int active; /* 当前活跃缓冲区索引 */
} double_buffer_t;
void dma_callback(double_buffer_t *db)
{
/* DMA传输完成中断处理 */
int done_idx = db->active;
/* 切换缓冲区:让DMA写另一个缓冲区 */
db->active ^= 1;
JPEG_REG(JPEG_OUT_ADDR) = (uint32_t)db->buf[db->active];
/* 通知显示控制器:已完成的缓冲区可以刷新了 */
display_swap_buffer(db->buf[done_idx]);
}
性能数据: 在我之前的一个项目中,使用单缓冲解码1080P JPEG,帧率只有12fps。换成双缓冲乒乓机制后,帧率直接跳到28fps。瓶颈从DMA传输变成了解码器本身的吞吐量。
2.3.3 缓存一致性处理
嗯,这里要特别说一下。很多嵌入式工程师在DMA上栽跟头,都是因为缓存一致性问题。CPU有Cache,DMA直接访问内存,两边看到的数据可能不一样。
解决办法通常有三种:
- 关闭Cache:简单粗暴,但性能损失大,不推荐。
- 手动Clean/Invalidate:在DMA传输前后手动维护缓存一致性。
- 使用非缓存内存区域:把DMA缓冲区放在非缓存的内存区域。
我个人的建议是第三种。大多数嵌入式芯片都支持配置内存区域的属性,把DMA缓冲区所在的地址段配置为非缓存、非推测的。这样既不影响其他代码的Cache性能,又避免了手动维护的麻烦。
/* 以某Cortex-M7为例,配置MPU使DMA缓冲区非缓存 */
void mpu_config_dma_buffer(void *addr, uint32_t size)
{
MPU->RNR = 0; /* 使用region 0 */
MPU->RBAR = (uint32_t)addr; /* 基地址 */
MPU->RASR = (0x3 << 1) | /* 全权限 */
(0x1 << 4) | /* 可执行 */
(0x1 << 16) | /* 使能region */
(0x1 << 18) | /* 非缓存 */
(0x1 << 19) | /* 非缓冲 */
(size_code << 1); /* 区域大小编码 */
}
⚠️ 我曾经踩过的坑: 有一次我配置了非缓存区域,但忘记把DMA描述符也放在非缓存区域。结果DMA从描述符里读到的地址是错的,因为描述符本身被Cache住了。这个bug让我debug了整整两天。记住:DMA能访问的所有数据,包括描述符、缓冲区、状态标志,都必须放在一致性内存中。
2.4 实战:一个完整的解码调用示例
最后,我把上面讲的东西串起来,给一个完整的解码函数。这个函数我在实际项目里用过,可以直接抄作业。
/**
* @brief 使用硬件解码器解码一张JPEG图片
* @param jpeg_data JPEG文件数据指针
* @param jpeg_size JPEG文件大小
* @param rgb_buf 输出RGB缓冲区
* @param width 输出图像宽度
* @param height 输出图像高度
* @return 0成功,-1失败
*/
int jpeg_hw_decode(uint8_t *jpeg_data, uint32_t jpeg_size,
uint8_t *rgb_buf, int width, int height)
{
jpeg_config_t cfg;
int retry = 1000;
/* 配置解码参数 */
cfg.input_buf = (uint8_t *)UNCACHED_ADDR(jpeg_data);
cfg.input_size = jpeg_size;
cfg.output_buf = (uint8_t *)UNCACHED_ADDR(rgb_buf);
cfg.width = width;
cfg.height = height;
/* 初始化解码器 */
jpeg_hw_decoder_init(&cfg);
/* 启动解码 */
JPEG_REG(JPEG_CTRL) |= 0x1; /* 启动位 */
/* 等待解码完成或超时 */
while (retry--) {
if (JPEG_REG(JPEG_STATUS) & (1 << 0)) { /* 完成标志 */
JPEG_REG(JPEG_STATUS) |= (1 << 0); /* 清除标志 */
return 0;
}
if (JPEG_REG(JPEG_STATUS) & (1 << 1)) { /* 错误标志 */
return -1;
}
delay_us(10);
}
return -1; /* 超时 */
}
小技巧: 上面的代码里我用了UNCACHED_ADDR宏,这个宏的作用是把虚拟地址转换成非缓存区域的物理地址。不同芯片的实现不一样,但思路是一样的:确保DMA看到的数据和CPU看到的数据是一致的。