做嵌入式屏幕相关的活,经常碰到一个不算难但特别麻烦的需求:传感器给了一幅横向的图像,屏幕偏偏是竖着的,得把内存里的像素矩阵旋转90度再送显。我之前在LAT1416上做一款电子相框时也被这个需求卡住过。一开始用CPU的像素双重循环去搬,720x480的图像旋转一帧,肉眼都能感觉到卡顿;后来改用DMA做行列步长交换,性能直接提升了一个数量级,这篇就把整个方案和踩过的坑完整拆一遍。
无论你是做摄像头显示、LCD竖屏横屏切换、还是图像预处理,只要MCU的DMA控制器带地址步长或2D传输能力,这个方法都能直接用。文章不绕弯子,直接从原理讲到寄存器配置,再给实测性能和翻车记录。
1. 旋转90度看起来简单,为什么跑起来这么费劲
1.1 CPU旋转算法的时间开销去哪了
图像旋转90度,本质上就是一个坐标映射加数据搬移。以顺时针旋转90度为例,源图像是W宽H高,目标图像是H宽W高,目标坐标和源坐标的对应关系是:
dst(x', y') = src(x, y) x' = H - 1 - y y' = x所以最直白的实现就是双循环:
void rotate_cw(uint8_t *src, uint8_t *dst, int W, int H, int bpp) { for (int y = 0; y < H; y++) { for (int x = 0; x < W; x++) { int dst_x = H - 1 - y; int dst_y = x; memcpy(dst + (dst_y * H + dst_x) * bpp, src + (y * W + x) * bpp, bpp); } } }代码没问题,但跑起来就知道疼。以常见的800x480 RGB565画面为例,一共384000个像素,每像素2字节。CPU循环里每个像素至少要做一次加法、一次乘法、一次memcpy,再加上循环分支和地址计算,一个像素吃掉几十个周期很正常。按240MHz主频估算:
384000 像素 * 40 周期/像素 = 15,360,000 周期 15.36M 周期 / 240MHz ≈ 64ms这还只是理想情况。实际更慢,因为目标地址是跳跃式访问的,每一行目标像素分布在源图像的不同行上,Cache命中率极低,CPU要频繁去等内存总线。换句话说,瓶颈不在算法本身,而在内存访问模式。
1.2 旋转90度的本质就是“列变行”
把上面的坐标映射换个角度看:顺时针旋转90度后,源图像的第一列变成了目标图像的第一行,第二列变成第二行,以此类推。这是整个方案最关键的认知——旋转90度不需要“逐像素计算坐标”,只需要把源图像的每一列,原封不动地搬到目标图像的每一行上。
那问题就变成了:怎么高效地把不连续内存里的“一列”读出来,连续写到另一块内存里?普通CPU循环是一格格挪,但DMA本来就是干搬运的,它能不能按“列”搬?
这就是本方案的核心切入点。只要DMA控制器允许配置地址增量步长,它就能搬任意规律的内存序列。人话就是:DMA每次搬完一个数据后,下一个数据从哪里取,可以由我们自己定义。默认情况下它每搬一个字节地址加1,如果我们把“地址加1”改成“地址加一整行”,DMA读出来的就是源图像的某一列。
2. DMA的隐藏能力:地址步长才是旋转的关键
2.1 大多数人对DMA的理解只用了三分之一
很多人对DMA的印象还停留在“连续内存搬运”。比如串口DMA接收、ADC多通道DMA采样、SPI DMA发送,这些场景的数据在内存里都是线性连续的,DMA配置好源地址、目的地址、传输数量,启动就完事。
但稍微翻一下DMA控制器的寄存器,你会发现很多型号都有地址更新模式相关的配置。比如源地址增量、目的地址增量、传输宽度,以及某些控制器上的固定步长模式或2D传输模式。这些能力平时用不上,因为串口、SPI、ADC的数据天然就是连续的,不需要跳着读。可一旦遇到图像操作,这些寄存器就是宝藏。
LAT1416的DMA控制器里就有源地址步进和目的地址步进的可配置项。它可以做到:每传输完一个像素,源地址不是加1字节而是加一个自定义步长,目的地址也不是只能加1字节,同样可以自定义。这就让DMA有能力“按任意规律从内存里抓数据”。
2.2 一列DMA传输的映射推导
假设源图像是W像素宽、H像素高,RGB565格式,那么:
- 每个像素占2字节,一行的字节数是 W * 2
- 源图像第x列第y行的像素地址是:src + y * W * 2 + x * 2
顺时针旋转90度后,这列数据要变成目标图像的第x行,而目标图像一行有H个像素,所以目标行首地址是:dst + x * H * 2
我们要做的,是让DMA按下面的规律连续搬H个像素:
源地址序列: src + x*2 + 0*W*2 src + x*2 + 1*W*2 src + x*2 + 2*W*2 ... src + x*2 + (H-1)*W*2相邻两个源地址的差值是 W*2 字节,即源图像一整行的宽度。
目的地址序列是连续的:
dst + x*H*2 + 0*2 dst + x*H*2 + 1*2 dst + x*H*2 + 2*2 ... dst + x*H*2 + (H-1)*2相邻两个目的地址的差值是2字节。
所以一次DMA传输的配置就是:
| 配置项 | 值 |
|---|---|
| 源起始地址 | src + x * 2 |
| 目的起始地址 | dst + x * H * 2 |
| 传输数量 | H(像素数) |
| 源地址步长 | W * 2(每次读完跳到下一行同列) |
| 目的地址步长 | 2(连续写) |
| 传输宽度 | 16bit |
执行完这次传输,源图像第x列就变成了目标图像第x行。整个画面旋转90度,只需要从x=0执行到x=W-1,共W次DMA传输。
2.3 硬件需求清单
这个方案不是所有MCU都能直接抄的。在动手前先确认三件事:
- DMA控制器是否支持源地址或目的地址的自定义步长。很多入门级MCU的DMA只有“固定递增”“固定不变”两种,没有中间选项,那样就无法用纯DMA做旋转。
- 是否支持内存到内存传输。部分DMA通道只能在外设和内存之间搬运,不能内存到内存,这个也要看数据手册。
- 中断响应是否够快。如果一次DMA只能搬一列,那么W次DMA就需要W次中断重装地址。图像宽度越大,中断频率越高,中断服务代码必须精简。
LAT1416这三条都能满足,所以下面的实操代码以它为例写。你要是用别的芯片,先去找DMA控制器的“步长”“stride”“increment”相关寄存器,能找到这套思路就能落地。
3. 在LAT1416上落地:配置步骤与核心代码
3.1 按列搬运的中断重装载方案
先写最通用也最容易理解的方案:每次DMA搬一列,搬完中断,在中断里把源地址和目的地址更新到下一列,再次启动。
#define IMG_W 720 #define IMG_H 480 #define PIXEL_BYTES 2 // RGB565 static uint8_t src_buf[IMG_H][IMG_W * PIXEL_BYTES]; static uint8_t dst_buf[IMG_W][IMG_H * PIXEL_BYTES]; static volatile uint16_t cur_col; void dma_set_column_transfer(uint16_t col) { dma_channel_config_t cfg; cfg.src_addr = (uint32_t)&src_buf[0][col * PIXEL_BYTES]; cfg.dst_addr = (uint32_t)&dst_buf[col][0]; cfg.transfer_num = IMG_H; cfg.src_step = IMG_W * PIXEL_BYTES; // 每搬一个像素,源跳到下一行同列 cfg.dst_step = PIXEL_BYTES; // 每搬一个像素,目标连续前进一个像素 cfg.data_width = DMA_WIDTH_16BIT; cfg.src_mode = DMA_STEP_MODE; cfg.dst_mode = DMA_STEP_MODE; dma_channel_setup(DMA_CH2, &cfg); dma_channel_start(DMA_CH2); } void DMA_CH2_IRQHandler(void) { if (dma_channel_get_flag(DMA_CH2, DMA_FIFO_COMPLETE)) { dma_channel_clear_flag(DMA_CH2, DMA_FIFO_COMPLETE); cur_col++; if (cur_col < IMG_W) { dma_set_column_transfer(cur_col); } else { g_rotate_done = 1; } } } void rotate_image_90_cw(void) { cur_col = 0; g_rotate_done = 0; dma_set_column_transfer(0); }这段逻辑很直白:cur_col从0开始,每完成一列搬运,中断里把cur_col加1,然后重新配置下一次传输。源起始地址每次向右移动一个像素,目的起始地址每次向下一行移动。源地址步长保持一整行,目的地址步长保持一个像素,传输数量始终是H。
一个容易忽视的细节是:传输数量单位是“像素数”,不是字节数。如果DMA控制器的传输数量寄存器是按数据宽度计的,那么当数据宽度设为16bit时,transfer_num=IMG_H 就表示传输IMG_H个16bit数据。千万别写成IMG_H*PIXEL_BYTES,不然会把下一列的数据也搬过来,画面会错位。
3.2 配置源步长和目的步长的坑位
用这种“列搬运”思路时,很多人第一次会配错源步长。常见错误是源地址步长写成了PIXEL_BYTES,而传输数量写成IMG_H*PIXEL_BYTES。这样DMA实际是连续读取源图像某一行的一段,而不是按列跳读。出来的画面就是一个斜条纹或者完全错乱的马赛克。
正确的理解方式:
源地址步长 = 源图像的行字节数 目的地址步长 = 目标图像的行内像素字节数如果DMA的步长寄存器单位是字节,那就直接填这两个值;如果单位是数据宽度倍数,那就分别填 IMG_W 和 1。不同芯片定义不一样,务必查手册,这是我第一次调这个方案被卡了半天的原因。
另外,DMA的突发长度也要注意。如果突发长度大于1,DMA可能会在一个突发里连续读取多个像素,导致步长配置失效。建议把突发长度设为1,或者在验证通过后再尝试优化。反正图片数据量不大,线性增加的收益有限,稳定性优先。
3.3 进阶:2D DMA一次性搬完
如果你的DMA控制器支持2D传输模式,上面的W次中断可以合并成一次启动。LAT1416的DMA支持类似的行/块传输结构,配置思路如下:
dma_2d_config_t cfg; cfg.src_base_addr = (uint32_t)&src_buf[0][0]; // 源图左上角 cfg.dst_base_addr = (uint32_t)&dst_buf[0][0]; // 目标图左上角 cfg.column_num = IMG_H; // 每块搬运列数 cfg.row_num = IMG_W; // 一共搬运W列 cfg.src_line_step = IMG_W * PIXEL_BYTES; // 列内行间步长 cfg.dst_line_step = PIXEL_BYTES; // 目标行内连续 cfg.src_block_step = PIXEL_BYTES; // 读完一列后,源跳到下一列 cfg.dst_block_step = IMG_H * PIXEL_BYTES; // 写完一行后,目标跳到下一行 cfg.data_width = DMA_WIDTH_16BIT;这里最关键的是block_step:源图像的“块”是垂直列,所以读完一列后源地址只向右移动一个像素;目标图像的“块”是水平行,所以写完一行后目标地址下移一整行。这组参数与普通2D DMA“按行读、按行写”的直觉刚好相反,很容易配反,调试时用2x3的小图像验证最稳。
4. 实测性能数据和五次翻车记录
4.1 性能对比
在我手里的LAT1416工程里,主频240MHz,源图放在SRAM,DMA从SRAM搬到SRAM,旋转后直接送LCD刷新。实测数据如下:
| 分辨率 | 格式 | CPU双循环耗时 | DMA按列搬运耗时 | 提升倍数 |
|---|---|---|---|---|
| 480x272 | RGB565 | 18ms | 2.1ms | 约8.5倍 |
| 800x480 | RGB565 | 61ms | 5.8ms | 约10.5倍 |
| 1280x800 | RGB565 | 205ms | 19.6ms | 约10.4倍 |
数据会受总线竞争和Cache策略影响,但趋势很明显:分辨率越大,DMA优势越大。原因也简单,CPU算法每处理一个像素都要重算地址和边界条件,而DMA一旦配置好,硬件就自动把步长运算做掉了,不占用CPU周期。
CPU双循环的耗时估算也可以验算一下:800x480共384000像素,61ms对应约240MHz下14640000个周期,平均每像素约38周期,和前面估算吻合。
4.2 翻车记录一:Cache一致性导致旋转后花屏
LAT1416如果开了D-Cache,DMA往目标缓冲区写数据不会自动更新Cache。等CPU再去读目标缓冲区或者LCD控制器去取显存时,看到的可能是旧数据。
处理方案有两种,优先推荐第一种:把目标缓冲区放在non-cacheable区域。如果工程里没法改内存属性,就在DMA完成中断里做一次Cache clean:
SCB_InvalidateDCache_by_Addr((uint32_t *)dst_buf, sizeof(dst_buf));注意是Invalidate,不是Clean。DMA写的是外部SRAM,CPU没参与写入,必须把Cache里的旧行作废,下次读取才会从内存重取。反过来,如果DMA是读取源缓冲区,而源缓冲区是CPU刚写的,CPU侧要先Clean,把数据刷到内存再启动DMA。
4.3 翻车记录二:对齐问题导致DMA传输异常
DMA控制器通常要求源地址、目的地址和传输宽度对齐。RGB565是16bit,让缓冲区按2字节对齐就好还好。但如果你做的是RGB888图像,每个像素3字节,按32bit传输时会遇到对齐问题,DMA可能会出现未定义行为或者直接hardfault。
我的做法是:要么把RGB888先转成RGB565再旋转;要么用32bit宽度搬运,把行宽补齐到4字节对齐后再配置步长。后一种做法需要小心,补齐字节会污染目标缓冲区,如果在DMA搬运之后还要用这个缓冲区做处理,必须预留padding空间。
4.4 翻车记录三:错误启用循环模式
有几次调试时开了DMA的循环模式,结果目标图像一直重复第一列。原因很清楚:循环模式会在传输完成后把地址寄存器重载成初始值,然后继续下一轮传输。这个功能适合ADC连续采样、串口不定长接收这类需要持续搬运的场景,但不适合一次性图像旋转。
图像旋转的DMA配置一定要关掉循环模式,用普通一次性传输。完成中断里手动修改地址和重新启动,才能真正进入下一列。
4.5 翻车记录四:中断标志清除过早导致漏配
中断服务里有一个顺序问题:先清标志,再配置下一次传输。如果反过来,先配置了DMA寄存器,再清除完成标志,而清除标志的同时DMA已经开始搬运,极有可能把刚写入的寄存器配置与当前传输混淆,产生一次错误的传输。
稳妥的顺序是:
1. 读取DMA完成标志 2. 清除完成标志 3. 更新cur_col 4. 判断是否还有下一列 5. 配置下一次传输寄存器和起始地址 6. 启动DMA另外,几乎每个芯片的DMA标志位都有“传输完成”和“FIFO空/半满”等若干种,中断服务里别取错。我用的LAT1416里DMA_FIFO_COMPLETE才是整列搬运完成,不是DMA_CHANNEL_ACTIVE。
4.6 翻车记录五:中断里耗时太长影响性能
一开始我在中断里做了不少打印和延时,结果旋转一帧的时间从6ms飙到40ms。后来把调试代码全部移出中断,只在g_rotate_done置位后由主循环打印耗时,性能才正常。
DMA中断服务代码要精简到极致,尤其是按列搬运方案,W很大时中断会非常频繁。800宽图像有800次中断,每次中断哪怕多10us,总耗时也会增加8ms。更好的做法是每搬运N列进一次中断,但这就需要DMA连续执行多次传输,配置复杂度更高。如果单列中断已经能满足帧率,就不建议过度优化了。
5. 旋转方向、镜像和更多扩展用法
5.1 顺时针、逆时针和180度怎么配
核心思路都是一样的,区别只在源起始地址和步长的方向。
| 操作 | 源起始地址 | 源地址步长 | 目的起始地址 | 目的地址步长 | 说明 |
|---|---|---|---|---|---|
| 顺时针90度 | src + col * 2 | 行字节数 | dst + col * H * 2 | 2 | 上面已实现 |
| 逆时针90度 | src + (W-1-col) * 2 | 行字节数 | dst + col * H * 2 | 2 | 从最右列开始读 |
| 顺时针90度+水平镜像 | src + col * 2 | 行字节数 | dst + (W-1-col) * H * 2 | 2 | 目标行序反向 |
| 镜像 | src + 行首 | -2 | dst + 行首 | 2 | 同一行内反向 |
| 180度 | src + 末尾 | -行字节数 | dst + 开头 | 2 | 整体反向 |
180度其实是最简单的,不需要复杂步长,把源地址改成缓冲区末尾,源地址步长设为负的行字节数,目的地址从开头连续写,DMA搬完就是旋转180度。如果DMA不支持负步长,那就把源地址从末尾开始、步长设为负行字节数,等价于CPU倒序遍历。
注意,这里所有步长都是按字节数算的。如果你在代码里看到步长为负数,先确认寄存器是有符号的还是无符号的。无符号寄存器无法表达负步长,那就只能通过调整起始地址和正步长模拟。
5.2 颜色格式转换与旋转的合并
图像旋转经常伴随颜色格式转换,比如摄像头输出的RGB565要转成RGB888再显示。DMA本身不具备像素格式转换能力,但可以用源步长和目的步长做字节重排的部分工作。
一种可行的思路是:源步长设为3字节跨过RGB888的完整像素,目的步长设为2字节写入RGB565的低16位,同时把高8位丢弃。不过要注意,这种方法只适合丢弃高位通道的情形,不能实现真正的色彩空间变换。要想同时做RGB888转RGB565和旋转90度,我建议分两步:先DMA旋转,再CPU批量转换。因为转换是线性遍历,CPU访问很友好,耗时远小于旋转算法本身。
5.3 超大图像怎么处理:分块打包
如果你的图像大到一次DMA传输数量超过寄存器上限,或者目标缓冲区不够大,可以按“子块”处理。比如把1280x800的图拆成5个竖条,每个竖条256列,DMA分别旋转这5个区域,再按位置拼到目标缓冲区。
分块的关键是维持行字节数不变。每块的源起始地址就是原图左上角加上偏移,源步长依旧是整幅图像的行字节数,而不是子块的行字节数。这一点最容易出错,一定要记住:步长是实际源图像的行跨度,不是块宽度。
5.4 哪些场景不值得用DMA旋转
DMA旋转不是万能的。如果是几个小图标旋转90度,比如32x32或64x64,DMA配置和中断的开销可能比直接CPU循环还大。我测试过16x16图标,CPU双循环不到几微秒,DMA方案反而因为中断调度和寄存器配置耗时数十微秒。
另外,如果芯片自带2D图形加速器、旋转引擎或LCD控制器的Rotation/RGB扫描方向寄存器,那硬件方案优先级更高。比如有些LCD控制器支持通过寄存器改变扫描方向实现旋转,那个是零拷贝零开销,比DMA还省。DMA旋转方案更适合那些没有图形引擎、只有标准DMA控制器的MCU场景。
5.5 再进一步的优化思路
如果后续想在LAT1416上把帧率再往上推,可以考虑这样几个方向:
- 使用双缓冲,DMA在搬运当前帧时,CPU同时处理下一帧的预处理或格式转换,隐藏DMA耗时。
- 提升DMA传输宽度到32bit,一次搬两个RGB565像素,传输次数减半,中断频率也减半。但要注意源/目的步长都要相应改成2个像素的字节数,且地址要4字节对齐。
- 如果DMA支持链表模式,可以把W列的寄存器配置全部提前算好,DMA自动按链表依次执行,彻底去掉每列中断,全程零CPU干预。LAT1416的DMA控制器有类似的多缓冲描述符能力,不过配置复杂度比单列模式高不少,建议先把基础方案跑通再试。
我在实际调试中还有一个习惯:无论最终用什么方案,都会先拿一张只有2x3像素的测试图跑一遍,确认旋转方向、位置映射都对了,再切到真实图像。不要一上来就盯着花屏分析,那样很难分辨是DMA配置问题还是坐标映射问题。先用小图把规律摸清,再放大到真实分辨率,能省很多时间。