ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

VPU驱动开发核心:寄存器读写与内存管理实战解析

VPU驱动开发核心:寄存器读写与内存管理实战解析 这几年做嵌入式Linux平台VPU几乎快成了中高端SoC的标配。尤其像RK3588这类芯片内置的VPU直接帮你把H.265、H.264的硬编硬解拉到8K级别省下的CPU资源不是一点半点。很多搞驱动开发的朋友一听到VPU就头疼觉得这玩意比GPU还难啃。真上手之后你会发现VPU驱动开发的核心骨架其实非常清晰剥掉那层视频编码算法的外衣底下就三件事把寄存器控制住、把内存安排好、把用户空间的接口打通。这篇文章就围绕寄存器读写和内存管理这两条主线来拆把VPU驱动开发里最常打交道的核心接口过一遍顺便讲清楚为什么这些接口要这么设计以及我在RK3588板子上实际调试时踩过哪些坑。如果你是刚入坑驱动开发、或者准备接VPU这个模块这篇文章可以帮你少走不少弯路。1. VPU驱动到底是干什么的先搞懂硬件与软件的边界1.1 VPU不是解码器是一台“可编程的协处理器”很多新手会有个误区以为VPU驱动就是把H.264解码逻辑写一遍。实际上VPU是一块专用的硬件加速单元它内部有硬件解码器、编码器、缩放器、后处理模块等驱动要做的是把“用户想解码哪一段码流、输出到哪块内存”翻译成硬件能听懂的寄存器配置。真正做熵解码、反变换、运动补偿的是VPU内部那套硬逻辑电路不是CPU上的软件。这就好比你请了一个专业厨师团队到家里做饭驱动干的活是告诉他们“今天要做宫保鸡丁、口味偏辣、三个人吃”而不是自己去厨房颠勺。VPU内部的DSP或状态机拿到你写的寄存器命令后会自己调度内部的硬件模块把活干完然后通过中断告诉你结果。1.2 驱动在内核里的生态位在一颗典型的多媒体SoC里VPU驱动通常作为一个平台设备platform device注册进内核往上接两个方向一个是Linux标准的V4L2框架用于视频流采集和编解码另一个是厂商自研的用户态库比如Rockchip的mpp、NXP的vpulib往下直接就是读写寄存器、分配内存、注册中断、操作IOMMU。这个位置决定了VPU驱动开发具备明显的“两头通吃”特征你要懂一点V4L2、懂一点文件操作接口这是面向软件和用户态的部分同时你更要懂寄存器地址、内存物理连续性、Cache一致性、IOMMU映射这些是面向硬件和系统层面的部分。1.3 为什么寄存器读写和内存管理是两道绕不开的坎我面试过不少做驱动开发的候选人聊到VPU的时候大多数人张口能说出V4L2的ioctl流程但一追问寄存器到底怎么映射、DMA缓冲区怎么分配、mmap的物理页是怎么和用户进程对应起来的就含糊了。原因很简单V4L2框架是“长在”寄存器操作和内存管理之上的框架只是规定了一套对话协议真正让硬件动起来的还是那两条腿。寄存器没配好VPU根本不会启动或者解码一半卡死内存没安排明白VPU访问的地址你没映射对轻则花屏重则直接IOMMU fault把整个进程干掉。所以这篇文章的核心就放在这两块接口的部分更多是告诉你在内核里往哪个方向接。2. 寄存器读写基础从物理地址到内核虚拟地址的映射2.1 为什么不能直接通过物理地址读写寄存器在裸机或单片机时代读寄存器通常就是直接操作地址比如*(volatile uint32_t *)0xFF240000。到了Linux内核里这套玩法行不通了原因有三个第一Linux默认开启了MMUCPU访问的是虚拟地址你直接给一个物理地址CPU不认。第二就算你运气好访问到了内核也随时可能因为页表切换导致你操作的不是同一块地方这在多进程系统里是灾难。第三从安全角度讲内核也绝不允许应用层直接拿物理地址碰硬件那等于绕过了所有权限检查。所以Linux提供一个标准的地址映射接口ioremap。它会帮你在内核虚拟地址空间中分配一块区域建立页表把物理寄存器地址映射进来。映射完之后你操作返回的虚拟地址实际上就是操作物理寄存器。void __iomem *base; base ioremap(VPU_REG_BASE, VPU_REG_SIZE); if (!base) { dev_err(dev, ioremap failed\n); return -ENOMEM; } // 操作某个偏移的寄存器 u32 val readl(base 0x10); writel(val | BIT(1), base 0x10);2.2 readl/writel 背后的门道为什么要用这两个接口很多新手会图省事直接*(volatile u32 *)base去读其实这是不推荐的。readl/writel这两个接口除了帮你把指针转成__iomem类型之外还有一个隐含作用内存屏障。寄存器操作往往有严格的时序要求比如你先写一个控制位紧接着要读状态位确认硬件是否响应。如果CPU的写操作还在store buffer里没落到总线上你马上就读状态读回来的可能是旧值。readl/writel在ARM平台一般会展开成ldr/str加上内存屏障指令确保访问顺序不出问题。另外不同SoC平台的访问宽度可能有限制有的寄存器必须按32位访问有的是16位。readl/writel默认固定32位能覆盖绝大多数控制寄存器场景。如果遇到只能字16位访问的还有readw/writew可用。提示ioremap返回的地址使用前一定要判断是否为NULL。我在调试早期就吃过亏设备树里reg属性写错了ioremap直接失败返回NULL然后代码没判断就往下走内核直接Oops。2.3 寄存器位域操作手册怎么看代码怎么写寄存器手册的位域划分可以看作“同一单元内的一排开关和旋钮”。比如一个控制寄存器bit[3:0]是编码模式bit[5]是启动位bit[7]是中断使能。你既不想读改写把别的位搞乱又不想写一整个值覆盖掉之前的配置所以需要一套安全的位操作套路。经典的读-改-写流程是这样的#define VPU_CTRL 0x0000 #define VPU_CTRL_MODE_MASK 0x0F #define VPU_CTRL_START BIT(5) #define VPU_CTRL_IRQ_EN BIT(7) u32 val readl(base VPU_CTRL); val ~VPU_CTRL_MODE_MASK; val | (H264_DECODE_MODE VPU_CTRL_MODE_MASK); val | VPU_CTRL_IRQ_EN; writel(val, base VPU_CTRL);这里有个细节有些硬件寄存器你没有读权限或者读回来是保留值这时“读-改-写”就不适用了。最好的做法是驱动里维护一个寄存器影子值shadow register每次全量写之前在影子值上做修改再把影子值整包写进硬件。这个习惯在硬件文档标注“Write Only”时是救命稻草。2.4 从板子上验证寄存器devmem的大用处调试驱动的时候你不可能每次都编译一遍内核来验证。Linux提供devmem工具能在shell下直接查看和修改寄存器。RK3588板子一般都带这个工具使用前建议先看下/proc/iomem确认VPU的寄存器物理基地址没有被内核占用。cat /proc/iomem | grep -i vpu devmem 0xfdbb0000 32 devmem 0xfdbb0000 32 0x1注意devmem在部分内核配置下会受限如果提示无法访问检查下内核是否开启了CONFIG_STRICT_DEVMEM。真机上稳妥的做法是临时加载一个小的字符设备驱动在驱动里直接里readl/writel写死地址去操作配合printk输出验证调通了再删掉。2.5 进阶用regmap统一管理寄存器如果你觉得每次都是ioremap readl/writel 手动操作太琐碎Linux内核还提供了一个更优雅的框架——regmap。它帮你把寄存器操作抽象成注册表支持缓存、总线访问、debugfs导出还天然集成锁。VPU驱动里很多厂商代码也迁移到了regmap风格。static const struct regmap_config vpu_regmap_config { .reg_bits 32, .val_bits 32, .reg_stride 4, .max_register 0x2000, }; regmap devm_regmap_init_mmio(dev, base, vpu_regmap_config); if (IS_ERR(regmap)) { return PTR_ERR(regmap); } regmap_update_bits(regmap, VPU_CTRL, VPU_CTRL_IRQ_EN, VPU_CTRL_IRQ_EN); regmap_read(regmap, VPU_STATUS, status);regmap的好处很多比如它自带的debugfs接口能让/sys/kernel/debug/regmap下直接看到所有寄存器的读写情况排查问题特别高效。我自己的习惯是复杂寄存器多的设备优先上regmap简单控制型设备直接readl/writel凑合也行灵活选择。3. 内存管理核心VPU如何拿到一块“能用”的内存3.1 VPU为什么对内存这么挑剔视频编解码涉及的数据量非常大一帧1080p的YUV420图像裸数据就有大约3MB4K帧更是接近12MB。VPU内部引擎直接通过DMA访问DDR它所需要的输入码流缓冲、参考帧缓冲、输出帧缓冲都是大块内存。问题在于很多VPU的DMA引擎并不支持类似CPU那种页表漫游的散列访问它需要一个物理地址连续的缓冲区。或者即使有IOMMU支持你也得先把分散的页映射成连续IOVA再喂给VPU。所以内存分配这个环节核心目标就两个大块、物理连续或者IOMMU映射后的连续地址空间。从操作系统的角度看这只是普通内存但从硬件的角度看这块内存必须是实打实的“一块地”不能是到处打补丁的碎布头。所以内核提供了一套专门的分配接口不需要你自己去内存管理子系统底层漫游。3.2 首选路径dma_alloc_coherent 一把梭如果VPU没有独立的IOMMU或者你图省事让硬件直接访问物理地址最直接的方法是使用dma_alloc_coherent分配一片一致性DMA缓冲区。dma_addr_t dma_handle; void *vaddr; vaddr dma_alloc_coherent(dev, size, dma_handle, GFP_KERNEL); if (!vaddr) { dev_err(dev, failed to alloc coherent buffer\n); return -ENOMEM; } // vaddr 是CPU侧的虚拟地址dma_handle 是硬件侧可以用的DMA地址这个接口的背后内核会优先从CMA区域分配预留的连续内存池如果不行再尝试从伙伴系统拿高阶页实在不行就触发内存规整。最关键的是它在CPU侧和DMA侧之间自动维护了Cache一致性也就是说你不需要手动刷Cache硬件和CPU看到的数据始终一致。代价是dma_alloc_coherent分配的内存性能开销略高一点点但作为视频帧缓冲问题不大。3.3 使用DMA-BUF实现跨驱动共享编解码与显示/摄像的桥梁实际产品中VPU解出来的帧往往要直接送给显示控制器或者GPU做后期处理。如果每次都要通过CPU内存拷贝性能直接崩。解决方案就是用DMA-BUF机制让VPU驱动和显示驱动共享同一块物理内存用户态通过dma_buf_fd拿到文件描述符再传递出去。V4L2的VIDIOC_QUERYBUF、VIDIOC_QBUF返回的缓冲区本质上就是DMA-BUF的载体。你用mmap映射的是CPU侧的虚拟地址用VIDIOC_EXPBUF导出的才是能传给别人的DMA-BUF fd。struct v4l2_exportbuffer expbuf { .type V4L2_BUF_TYPE_VIDEO_CAPTURE, .index buf_index, .flags O_RDONLY, }; int ret ioctl(fd, VIDIOC_EXPBUF, expbuf); // expbuf.fd 就是DMA-BUF的文件描述符VPU驱动内部的v4l2_m2m框架会自动处理输入输出队列的DMA-BUF引用你基本不需要手动 return 引用计数只要把vb2_ops的buf_prepare、buf_queue、start_streaming这几个回调写好就行。3.4 mmap把内核态的内存取到用户空间光在内核里拿到缓冲区还不够用户态编码器库里总得能把数据填进去。这里就用到mmap接口。在内核驱动里mmap对应的是file_operations里的.mmap回调关键是把内核分配的DMA缓冲区对应的物理页映射到进程地址空间。static int vpu_mmap(struct file *filp, struct vm_area_struct *vma) { struct vpu_device *vpu filp-private_data; struct vpu_buffer *buf vpu-buffers[vma-vm_pgoff]; unsigned long size vma-vm_end - vma-vm_start; if (size buf-size) return -EINVAL; return dma_mmap_coherent(vpu-dev, vma, buf-vaddr, buf-dma_handle, buf-size); }这里有个容易踩的坑vma-vm_pgoff默认单位是页不是字节。如果用户态mmap传的 offset 是第几个缓冲区那内核里要乘以PAGE_SIZE再换算或者直接从 vm_pgoff 当索引用。不同驱动写法不一样但一定要保证用户态和内核态对 offset 的语义理解一致否则轻则映射错缓冲区重则崩内核。3.5 Cache一致性稍微一疏忽画面就花说到内存管理就不得不提Cache一致性这是VPU驱动调试中翻车率最高的地方。CPU和DMA硬件并不是直接读写同一个物理内存CPU侧有L1/L2 Cache写完的数据可能还留在Cache里DMA引擎去读DDR的时候自然拿不到最新值反过来DMA写入的数据也可能只在DDR里Cache里还是旧内容CPU去读就会看到陈旧数据。解决办法就两条路要么用前面说的dma_alloc_coherent分配内存它内部配置了不可Cache的映射硬件和CPU永远直达DDR要么用普通内存但操作前后显式调用Cache维护接口。dma_sync_single_for_device(dev, dma_handle, size, DMA_TO_DEVICE); // 填充准备数据提交给硬件前调用 dma_sync_single_for_cpu(dev, dma_handle, size, DMA_FROM_DEVICE); // 硬件写完后CPU读取前调用特别注意方向不要搞反数据从CPU到硬件去解码用DMA_TO_DEVICE数据从硬件到CPU解码完读帧用DMA_FROM_DEVICE。搞反了不仅Cache刷不掉还会造成莫名的数据错乱。我在项目里遇到过一次解码输出前几帧花屏排查了一整天最后发现大家把方向写反了。我画了一张我自己记忆的三件套关系概念作用使用时机虚拟地址 vaddrCPU操作数据的地址memcpy、CPU读写DMA地址 dma_handle硬件寄存器里填写的地址配置VPU寄存器、DMA描述符用户态映射 uaddr应用层访问的地址mmap之后用户态指针硬件只会认DMA地址CPU也只会直接访问虚拟地址你必须清楚这三者的对应关系。尤其是dma_alloc_coherent的vaddr和dma_handle在ARM32和大多数ARM64平台上前者经过ioremap后可能和后者数值上很接近但概念不同绝不能混用。3.6 IOMMU与SMMU再进一步是“虚拟化地址”新一点的高端SoC包括RK3588通常给VPU配了IOMMU/SMMU。IOMMU和GPU驱动开发里的GPU虚拟地址空间是一个思路——硬件访问的不再是物理地址而是IOVAIO虚拟地址。驱动负责用dma_map_sg或iommu_map把分散的物理页映射成连续的IOVA这样VPU甚至不再要求内存物理连续。dma_addr_t iova; iova dma_map_single(dev, cpu_addr, size, DMA_BIDIRECTIONAL); if (dma_mapping_error(dev, iova)) { dev_err(dev, dma map failed\n); return -ENOMEM; }有IOMMU之后dma_alloc_coherent返回的dma_handle就不再等于物理地址了它是指向IOMMU页表的IOVA。这时候千万不要想当然地把 dma_handle 当物理地址去和别的驱动交互一定要走DMA-BUF导出或者dma_map系列接口来传递。4. 驱动核心接口用户态和内核态之间的通信协议4.1 file_operations每个驱动都要有的“前台柜台”用户态是通过文件描述符访问驱动的所以不管VPU驱动内部多复杂最终都要实现struct file_operations。对VPU驱动来说最关键的三个回调是open、ioctl、mmap对应上面说的“建立连接、发指令、取内存”。static const struct file_operations vpu_fops { .owner THIS_MODULE, .open vpu_open, .release vpu_release, .unlocked_ioctl vpu_ioctl, .mmap vpu_mmap, };open里要初始化私有数据把filp-private_data指向你创建的驱动上下文结构体后续所有接口都能靠它拿设备信息。release则负责释放资源避免进程异常退出时留下残留状态。4.2 ioctl命令码设计里的“编码艺术”ioctl是驱动和用户态沟通的主要通道。命令码的宏定义在Linux里有一套规范主要用到_IO、_IOW、_IOR、_IOWR四个宏它们把驱动类型标识、命令序号、数据方向和数据大小编码成一个整数。#define VPU_IOC_MAGIC V #define VPU_GET_VERSION _IOR(VPU_IOC_MAGIC, 1, struct vpu_version) #define VPU_SET_BITRATE _IOW(VPU_IOC_MAGIC, 2, uint32_t) #define VPU_START_DECODE _IOW(VPU_IOC_MAGIC, 3, struct vpu_decode_cfg) #define VPU_GET_FRAME _IOR(VPU_IOC_MAGIC, 4, struct vpu_frame_info)这里要特别注意用户态传递的指针在内核里不能直接解引用必须用copy_from_user/copy_to_user拷贝数据。虽然access_ok会做检查但真正搬运数据还是这两个接口。很多新手图省事强行解引用指针轻则返回EFAULT重则让攻击者打着驱动旗号把内核搞崩。4.3 中断与等待队列怎样让驱动“睡一会儿”再醒来VPU解码是个异步操作你启动它硬件自己去干干完通过中断通知CPU。驱动代码不能傻傻在ioctl里死循环轮询状态寄存器那是CPU的灾难。标准做法是在中断处理函数里唤醒等待队列把用户态的阻塞调用解除。static irqreturn_t vpu_irq_handler(int irq, void *dev_id) { struct vpu_device *vpu dev_id; writel(0, vpu-base VPU_INT_CLR); complete(vpu-decode_done); return IRQ_HANDLED; } static int vpu_wait_for_completion(struct vpu_device *vpu) { return wait_for_completion_interruptible(vpu-decode_done); }VPU解码一帧是几十毫秒级的事情wait_for_completion_interruptible能让你在用户态ioctl里等结果同时还不耽误其他线程和中断响应。等MPP这类库调用你驱动时它们天然会按阻塞/非阻塞两种模式来走。4.4 V4L2/M2M框架什么时候用、凭什么用如果VPU驱动要面向标准视频应用GStreamer、FFmpeg最好接入V4L2的memory-to-memoryM2M框架。这个框架把编解码抽象成输入队列和输出队列用户态通过VIDIOC_STREAMON、VIDIOC_QBUF等标准api调用而驱动只需要实现v4l2_m2m_ops和vb2_ops的回调。其实V4L2 M2M框架在内部就是帮你管好了DMA-BUF缓冲区流转、帧队列、ioctl分发你主要的精力还是花在写device_run——它会在每次硬件空闲时被调用你要在这里把用户填入的帧参数转成寄存器值然后启动硬件。M2M框架是理解VPU驱动结构的重要参考但别被它吓到底层的寄存器和内存还是按前面那两条线走。5. 常见问题与排查技巧实录5.1 寄存器读写出错的几类典型症状我在调试VPU驱动时遇到的寄存器类问题主要有三种症状第一种是读什么都不变化总是返回0或0xFFFFFFFF。查的方向一般是ioremap的地址对不对、时钟有没有打开。我遇到过最离谱的一次是reset引脚被默认拉低了VPU整个模块处在复位状态寄存器当然纹丝不动。第二种是写进去的值读出来不对或者只有某几位生效。这种通常是位宽不匹配寄存器只支持16位宽你却用readl/writel往里面写32位丢掉的一半数据哪去了根本看不见。也会遇到没有按regmap配置设置 reg_stride导致偏移地址错位。第三种是寄存器操作导致系统死机或死锁。这类往往是你访问了不存在的地址空间访问越界或者驱动中操作寄存器的代码和硬件访问同一个地址但没加锁两个上下文互踩。建议优先确认/proc/iomem里VPU的reg范围再配合devmem在真实环境下看一眼物理地址到底有没有对应映射。5.2 解码花屏、帧错位的定位思路花屏这个现象新手第一反应是寄存器没配好或者码流不对。我自己的排查顺序是反过来的先查内存再查寄存器最后才怀疑第三方库。内存方面重点查Cache一致性和物理地址映射。通过在驱动里加一个“真值校验”方案解码一帧后CPU侧直接读内存数据打印前64字节和硬件DMA回读的前64字节对比就能快速定位是Cache没刷还是地址没给对。寄存器方面重点查格式配置分辨率、色彩空间、帧数是编码在控制寄存器的某个位域里的只要有一个位域错解码器就会一帧一帧地偏。建议每个关键寄存器操作后用设备手册核对一遍。5.3 内存分配失败率高的优化思路如果dma_alloc_coherent频繁失败特别是4K视频帧这种大块内存大概率是CMA吃紧了。检查/proc/cma可以看到CMA剩余空间和碎片率。如果系统里同时跑着多媒体和显示多个模块都在抢CMA需要评估是否给VPU单独预留CMA区域调整设备树里的linux,cma-default区域大小。如果走了DMA-BUF路径进程之间共享内存那么还要检查DMA-BUF引用计数和生命周期管理。比如VIDIOC_REQBUFS释放不干净会导致DMA-BUF一直在引用内存在VIDIOC_STREAMOFF之后还没真正释放。5.4 常见问题速查表现象优先排查项寄存器读回全0模块电源/时钟、ioremap结果、复位引脚状态寄存器读回全FF地址越界、总线配置错误、PHY未上电驱动加载即Oops设备树reg大小写错、ioremap返回NULL但没判断解出来全是乱码寄存器位域配错、码流不完整、DMA地址配置错误偶尔花一帧Cache方向配错、内存复用冲突4K分配失败CMA不足、IOMMU映射中断、碎片化进程退出后内存不释放DMA-BUF引用计数泄漏devmem访问不了CONFIG_STRICT_DEVMEM 限制了5.5 调试工具与心法驱动调试不像应用层可以随便打日志但内核里依然有几件好用的工具值得常备。第一是/sys/kernel/debug/regmap如果你的驱动用regmap这里面记录了所有寄存器的实时值是我调试寄存器问题时最先看的地方。第二是trace event用trace_printk可以轻量地在内核里打点配合trace-cmd分析时间线和硬件状态变化比加printk高效得多。第三是devmem命令用来验证硬件真身位置和驱动逻辑是否一致。调试心法上我个人的经验是“先物理后逻辑”先用devmem或者一个极简racer模块确认寄存器能不能读写、中断能不能触发、DMA能不能搬运确认硬件链路通了之后再上V4L2和MPP这种大框架。如果上来就堆整个驱动出了故障你根本分不清是框架问题还是寄存器问题。6. 前沿踩坑与内核社区的新玩法写到这里再补几句和架构演进相关的东西。VPU驱动开发早些年很多是厂商闭源魔改内核代码在一个4.x老内核里躺一辈子。现在情况变了Rockchip、NXP、Allwinner这些厂商都在往主线内核靠VPU驱动逐渐开始使用起media controller、v4l2-mem2mem、scatter-gather和DMA-BUF heaps。DMA-BUF heaps 是比老的ION更规范的内存堆框架。用户态可以通过/dev/dma_heap/system这类节点直接分配一块内存再通过dma_buf_fd传给驱动驱动拿到DMA-BUF之后走dma_buf_map_attachment获取SGT表然后再映射IOVA或者物理地址。如果你的野平台还没有支持这套恰好你又在写新驱动强烈建议优先适配DMA-BUF heaps而不是继续抱着ION实现不放。ION虽然在新内核里还没完全删除但已经明确进入淘汰流程新驱动再依赖它就是给自己挖坑。对VPU驱动而言架构的演进实际上是把“内存管理”这个最复杂的环节逐渐标准化。剩下的寄存器配置和编解码流程虽然还是有一定定制性但有了足够的参考实现移植的难度会越来越低。内核社区的快节奏也说明一个趋势FPGA验证SoC、短视频场景、AI边缘盒子如雨后春笋VPU作为一个标准外设驱动开发的门槛正在被一层一层拆掉。我自己这两年调的最多的一组板卡就是从RK3588上跑VPU编解码链路往回追经历过的那些坑最终都沉淀成了经验。驱动开发这个东西永远不是看会的是调会的。你只要把寄存器读写和内存管理这两根主心骨立住了剩下的事大多只是顺着内核的框架往里填代码而已。
返回列表