ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

从ARM MPU到龙芯Linux:驱动移植中的内存保护与DMA一致性实践

从ARM MPU到龙芯Linux:驱动移植中的内存保护与DMA一致性实践 开头先说说背景。我们走马观碑组接到的任务是把一套原本跑在 ARM 平台上的驱动方案原封不动地搬到龙芯 2K 系列平台上。老实讲刚拿到任务清单时我心里是有预感的这种跨架构移植最怕的不是业务逻辑复杂而是驱动里那些跟架构强绑定的“隐形依赖”。这套方案的核心依赖就是 MPUARM 的 Memory Protection Unit。代码里几十个宏、几百行配置全是区域号、属性位、Cache 策略它们在芒果味很浓的 H750 上跑得飞起可一旦换成龙芯这些代码一行都留不住。这篇文章就是记录我们组从拆解 MPU 依赖、梳理替代方案到在模拟环境里验证、最终在真机上跑稳定、顺带把固件更新和内存布局变化一起啃下来的全过程。如果你也在做国产化替代、跨架构驱动移植或者只是想搞明白“H750 的 MPU 怎么设置、到了 Linux 下对应的写法又是什么”这份记录应该能帮你少走不少弯路。1. 原方案里H750的MPU到底在保护哪几样东西1.1 三个典型区域的实际配置先看原方案的核心。目标芯片是 STM32H750Cortex-M7 内核内置 MPU 最多支持 16 个 region。原驱动里用到的其实只有 3 个区域但每一个都对应着一类完全不同的内存访问需求。第一个区域是外部 SDRAM。H750 上跑的是一个大分辨率 UI 工程帧缓冲、DMA 描述符、中间数据缓冲区全挂在外部 SDRAM 里。为了性能这个区域必须走 Cache而且要配置成 write-back write-allocate也就是典型的“回写 写分配”策略读多写多都不亏。第二个区域是片内 RAM 里给 RTOS 任务用的栈空间。这部分要的是隔离性任务栈越界访问不能“静默路过”必须立刻触发 MemManage Fault方便定位是谁越的权。第三个区域是外设寄存器区。所有外设寄存器访问都要求强顺序硬件上得是 device 内存或者 strong-ordered不能被 CPU 乱序执行整出幺蛾子。原代码大致长这样/* 配置SDRAM区域: 0xC0000000, 大小16MB, Write-Back Write-Allocate */ MPU-RNR 0; MPU-RBAR 0xC0000000 | MPU_REGION_VALID; MPU-RASR MPU_CONFIG(MPU_REGION_SIZE_16MB, MPU_REGION_FULL_ACCESS, AP_RW_PRIV_USER, TEX_1_CACHEABLE_WB_WA); /* 配置任务栈区域: 0x20020000, 大小64KB, 不允许非特权访问 */ MPU-RNR 1; MPU-RBAR 0x20020000 | MPU_REGION_VALID; MPU-RASR MPU_CONFIG(MPU_REGION_SIZE_64KB, MPU_REGION_PRIV_RW, AP_RW_PRIV_ONLY, TEX_0_NON_CACHEABLE);这两段配置看着简单但往下追就会发现几乎每个驱动模块都跟它们有直接或间接的关系。1.2 为什么说这套配置“非常挑平台”ARM 的 MPU 是架构定义的寄存器布局、区域数量、属性语义全是 ARM 自己定的。你在 ARM 平台上写的MPU-RBAR、MPU-RASR换到任何非 ARM 架构上都不存在了。这还不算什么真正有挑战的是区域属性背后的“行为契约”。比如 SDRAM 区域的 Cache 策略影响的不只是读写速度它直接决定了 DMA 和 CPU 之间能不能看到一致的数据。如果 Cache 策略是 write-backCPU 写了数据但还没回写DMA 去搬运的时候就可能搬走旧数据。原工程之所以没有这个问题正是因为 MPU 给 SDRAM 配了 write-back 且整个驱动都遵守了相应的同步规矩。这种约定被写进了驱动代码的每个角落不是简简单单删掉 MPU 配置就能完事的。我用一个类比跟组里新人解释这件事MPU 配置相当于房子的水电管线是按户型架构专门排的。换一套房墙纸撕掉不算管线也得重排。你得把房子里每个用电用水的地方都找出来再在新房的管线路由里一个一个接上。这就是移植的本质工作量。2. 龙芯2K的内存保护家底没有MPU寄存器但有别的打法2.1 Linux下的MMU和DMA一致性API到了龙芯这边情况完全不一样了。如果目标系统跑的是 Linux那内存保护的大旗是 MMU 在扛。每个进程都有独立的虚拟地址空间用户态程序想碰内核地址、想碰别的进程的地址直接就被页表权限挡回去了不需要你手动配 region。但这不等于“什么都不用做”。恰恰相反原来靠 MPU 区域属性实现的语义在 Linux 里需要换成一组风格完全不同的 API。原 MPU 的 SDRAM 区域 Cache 策略对应到 Linux 驱动里就是 DMA 缓冲区的一致性管理。内核提供了一整套标准接口dma_alloc_coherent()分配一致性 DMA 缓冲区保证 CPU 和 DMA 看到的数据一致不需要手动同步。dma_map_single()/dma_unmap_single()为一次 DMA 传输做映射可选择同步方向。dma_sync_single_for_cpu()/dma_sync_single_for_device()在 DMA 传输间隙做缓存同步。ioremap()/memremap()把物理地址映射到内核虚拟地址空间同时具备类似 device 内存的顺序访问属性。原方案中外设寄存器的“强顺序访问”需求在 Linux 里就是ioremap的默认行为这层属性并不需要手动去配。我把两类机制的对应关系整理成了表格后面移植时基本上是照这个表逐项翻译原H750 MPU区域原MPU属性作用龙芯Linux下的替代方案外部SDRAMWrite-Back Cache提升帧缓冲和DMA性能dma_alloc_coherent/dma_map_single 同步API外设寄存器区Device/Strong-Ordered保证访问顺序ioremap映射后的内核虚拟地址片内任务栈区特权/非特权隔离越界触发Fault进程页表权限 mprotect或内核态直接由MMU保护2.2 如果目标是RTOS或裸机呢这个项目组里其实还有另一拨人他们尝试在龙芯上跑 RTOS。这就头疼了。因为龙芯平台上没有像 ARM MPU 那样“配几个寄存器就能划保护区”的现成外设。LoongArch 架构的内存保护靠的是页表和 TLB你要在 RTOS 里做出类似 MPU 的效果得自己去维护页表项为每个任务分配不同的地址窗口然后处理 TLB 缺失异常。代码量比原来多了个数量级而且对内存布局的规划要求极高。聊到这里就有意思了。网上很多人搜“h750 mpu怎么设置”是因为在 ARM 上 MPU 配置属于基本功照着参考手册就可以上手。可一旦换到龙芯问题变成了“这个 SoC 的内存保护机制到底是什么”如果不把体系结构手册啃明白很容易在 RTOS 裸机环境下写出一个“看起来能跑但毫无隔离性”的系统。我们最终的取舍是产品主路线跑 Linux把需要强隔离的业务放进内核驱动模块RTOS 方案只保留在启动早期和工厂测试阶段不做完整的内存保护。3. 盘底代码动手前先把每处MPU依赖揪出来3.1 静态扫描与运行依赖分类移植不能上来就写代码第一步是盘底。我把整个驱动仓库拉下来先做了一轮静态扫描grep -rn MPU_\|RBAR\|RASR\|region\|write-back\|write-through\|device --include*.c --include*.h .扫出来的结果不少但很多是噪音。真正要关注的是那些“因 MPU 区域属性而成立”的代码包括直接操作 MPU 寄存器的代码这部分直接删掉。依赖 SDRAM Cache 策略的 DMA 描述符初始化逻辑。依赖寄存器强顺序访问的等待循环、轮询标志位。依赖任务栈隔离的异常处理函数、栈溢出检测代码。我建了一个清单把每个依赖拆出来跟驱动模块做映射驱动模块依赖的MPU区域依赖的具体行为迁移后的替代者LCD/GPU驱动SDRAM写回区域帧缓冲可缓存、DMA可访问设备树reserved-memory dma_alloc_coherent网卡驱动SDRAM写回区域TX/RX描述符一致同步dma_map_single sync APIGPIO/I2C驱动外设寄存器区寄存器读写强顺序ioremap默认属性RTOS任务管理任务栈区域越界访问触发异常移除改由LinuxMMU进程隔离这个步骤没有太多技术含量但很容易漏。最容易被遗漏的是那种“因为外部行为恰好正确所以没人意识到依赖存在”的代码。比如某个轮询外设标志位的循环以前因为 MPU 配了 device 属性所以不会被乱序执行干扰代码里根本没写任何内存屏障。换到新平台后如果不补屏障等 bug 出现时定位起来会非常痛苦。3.2 一个值得参考的分层思路盘完代码之后我做了一件事后来证明这是整个移植过程中性价比最高的事重构驱动分层。原驱动是直接操作寄存器的“平铺式”写法MPU 配置和业务逻辑缠在一起。我把每个驱动模块拆成三层平台无关的纯逻辑层只处理状态机、数据计算、协议解析。寄存器操作层把对寄存器读写的动作封装成函数内部可以用readl/writel或 RTOS 下的地址映射。内存策略层专门管缓冲区分配、Cache 同步、内存布局约束。这样一来MPU 相关的依赖被压缩到了内存策略层一个很小的范围内后面重新实现时只需要改这一层。纯逻辑层和大部分寄存器操作层甚至可以做到跨平台复用。移植完成后回头看如果没有这一步分层后面替换 DMA 策略、调整设备树的时候大概率会改一处崩一片。4. 一次翻译三个MPU区域从ARM属性到Linux内核API4.1 SDRAM一致性迁移dma_alloc_coherent接棒原来在 H750 上SDRAM 的 Cache 策略靠 MPU 配置解决驱动开发者的主要精力在管 DMA 描述符链不需要关心底层同步细节。到了龙芯 Linux 下这套东西要用内核 DMA API 重写。一个典型的 DMA 缓冲区分配改成这样struct my_dev { struct device *dev; void *rx_buf; dma_addr_t rx_dma; }; static int my_driver_probe(struct platform_device *pdev) { struct my_dev *mydata; mydata-dev pdev-dev; /* 分配一致性DMA缓冲区等价于原MPU区域里SDRAM的“可缓存且一致”语义 */ mydata-rx_buf dma_alloc_coherent(mydata-dev, BUF_SIZE, mydata-rx_dma, GFP_KERNEL); if (!mydata-rx_buf) { dev_err(mydata-dev, dma_alloc_coherent failed\n); return -ENOMEM; } return 0; }如果缓冲区不是每次都分配而是一块固定内存反复用作收发则改用dma_map_single配合同步 APIdma_addr_t addr dma_map_single(dev, buffer, len, DMA_FROM_DEVICE); if (dma_mapping_error(dev, addr)) { /* 处理映射失败 */ } /* 在DMA完成、CPU读取数据之前必须同步 */ dma_sync_single_for_cpu(dev, addr, len, DMA_FROM_DEVICE); /* 下次提交DMA之前再切回设备域 */ dma_sync_single_for_device(dev, addr, len, DMA_FROM_DEVICE); dma_unmap_single(dev, addr, len, DMA_FROM_DEVICE);这里有一个初学者特别容易搞混的点dma_alloc_coherent和dma_map_single的区别。前者分配的内存本身就不是 Cache 管理的CPU 和 DMA 访问它都会直接落到物理内存性能上可能不如后者的高性能路径后者是让一块普通内存进入 DMA 映射靠 API 在适当的时候同步 Cache。原 H750 工程里那种“SDRAM 开 Cache、DMA 权衡性能”的做法迁到 Linux 下更接近dma_map_single加显式同步的组合。如果你对性能不太敏感直接用dma_alloc_coherent最简单但如果你在跑网络或图像这种高吞吐场景建议换成 map 加 sync 的方式吞吐差别还是明显的。4.2 寄存器访问ioremap接替device属性外设寄存器区域的迁移相对简单。Linux 内核里ioremap出来的虚拟地址本身就带上了“不可缓存、访问顺序严格”的属性这正是原 MPU device 区域想干的事。static void __iomem *base; base devm_platform_ioremap_resource(pdev, 0); if (IS_ERR(base)) return PTR_ERR(base); /* 之后的读写全部走readl/writel */ writel(value, base REG_CTRL); val readl(base REG_STATUS);需要留意的是readl/writel本身在大多数架构上会生成带内存屏障的访问指令但这不代表可以完全省略dma_wmb()之类的显式屏障。特别是当你想保证“先写描述符再触发 DMA 启动”这种顺序时仍然需要依赖驱动里已有的内存屏障否则编译器乱序和 CPU 乱序还是会给你挖坑。在原 H750 工程里MPU 的 device 属性把这块管住了换了平台后这层隐含保护没了得靠编码规范主动维护。4.3 任务栈隔离从MPU region到mprotect或者干脆交给MMU如果原项目里“任务栈保护区”是为了在 RTOS 下抓越界那么在 Linux 下这个需求绝大多数场景已经不存在了因为 MMU 本身就给你做了完整的进程隔离。内核线程或驱动内部的越界访问会直接触发内核报错不需要提前划分什么 region。但有一种情况需要手动处理如果你要在用户态实现一个类似“监控缓冲区越界”的调试机制可以给某段虚拟内存设置页保护属性用mprotect配合 SIGSEGV 信号来捕获非法访问。#include sys/mman.h #include signal.h #include stdio.h #include unistd.h static char *guard_page; static void segv_handler(int sig, siginfo_t *info, void *ctx) { if (info-si_addr (void *)guard_page info-si_addr (void *)(guard_page pagesize)) { printf(catch access to guard page at %p\n, info-si_addr); } } int main(void) { pagesize sysconf(_SC_PAGESIZE); /* 分配一个页并设置为不可读写任何访问都会触发SIGSEGV */ guard_page mmap(NULL, pagesize, PROT_NONE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); struct sigaction sa { 0 }; sa.sa_sigaction segv_handler; sa.sa_flags SA_SIGINFO; sigaction(SIGSEGV, sa, NULL); /* 故意越界访问触发信号处理 */ char *p guard_page; char x *p; return 0; }不过话说回来如果你正在维护一个整体迁移到 Linux 的产品我不建议把太多精力花在“用用户态机制模拟原 RTOS 栈检查”上。Linux 自己的保护已经够用团队要做的是把原来“栈越界检测策略”中的业务层逻辑比如任务边界、优先级约束翻译成 Linux 下的线程/进程模型。硬要照搬原设计反而会把系统搞复杂。5. 先用模拟环境跑逻辑再对着固件查内存布局5.1 龙芯模拟环境怎么搭、怎么用接到龙芯平台硬件之前我们先用模拟环境把驱动的主逻辑跑通了。现在 QEMU 对 LoongArch 的支持已经比较成熟可以在 x86 开发机上模拟出一台龙芯虚拟机跑 Loongnix 或基于 LoongArch 的发行版。搭建流程大致是# 安装QEMU(需要带loongarch支持) sudo apt install qemu-system-misc # 下载LoongArch虚拟机内核和rootfs wget https://example.org/loongarch/vmlinux wget https://example.org/loongarch/loongnix-rootfs.ext4 # 启动模拟环境 qemu-system-loongarch64 \ -machine virt \ -cpu loongarch64 \ -m 2G \ -kernel vmlinux \ -drive fileloongnix-rootfs.ext4,formatraw \ -append root/dev/vda consolettyS0 \ -nographic把驱动编译成内核模块后放进虚拟机先验证注册流程、DMA 分配逻辑、中断处理是否正常。这套环境对排查“纯软件逻辑错误”非常高效比如指针用错、寄存器地址写错、数据结构没初始化。但它有一个天然的局限模拟环境里的内存访问时序、Cache 行为跟真机差异很大所以“数据一致性”类的问题在模拟环境里通常复现不出来别指望靠它抓 Cache bug。我在这个阶段的一个经验是把驱动跑通后先不要急着做功能验证而是先把每个函数的入口出口打上 trace 点把“预期会在真机上发生的资源分配、DMA 映射、中断触发”事件全部打印出来留作此刻的基线。后面真机上出现诡异问题时能拿这份基线做对比快速确认是不是新平台改变了行为。5.2 固件更新和内存布局这个问题非常容易忽略热搜里有个词叫“龙芯 bios更新固件”这个跟驱动移植看起来不搭界实际上关系极大。龙芯平台的固件通常是 PMON它负责启动和初始化内存控制器、建立最初的地址映射。驱动里的很多地址比如某段保留内存、DMA 专用的内存窗口都需要跟固件最终 report 出来的布局一致。我们踩过一次很痛的坑移植完成后在某台机器上工作正常换一台更新过固件的机器后驱动注册直接失败。排查到最后发现固件更新改变了内存控制器的初始化顺序导致系统在某个地址段预留的 DMA 内存窗口变了而设备树和驱动里的地址还是按旧固件写的。从那以后我养成了一个习惯每次拿到一台新设备先进 PMON 把内存映射 dump 出来跟设备树里memory节点和驱动里的保留内存地址做一次对照。以 PMON 环境为例PMON p # 打印当前内存布局、PCI资源等信息然后对照设备树reserved-memory { #address-cells 2; #size-cells 2; ranges; dma_pool: dma_pool40000000 { compatible shared-dma-pool; reg 0x0 0x40000000 0x0 0x1000000; no-map; }; };如果固件更新的版本说明里提到了内存、PCI 枚举或内存控制器相关内容就必须重新核对这份映射。这一步在开发文档里很少有人写但恰恰是国产平台移植最容易中招的地方。6. 真机连调时踩过的三个坑6.1 Cache一致性MPU配过了不代表Linux里就安全第一个坑也是最经典的坑。在真机上跑起来后发现网卡驱动收包偶发性出现“帧头对了但数据是旧的”。用逻辑分析仪和打印结合最后确认是 Cache 一致性问题。原 H750 工程里MPU 把 SDRAM 配成了 write-backDMA 描述符的同步靠驱动里的手工 flush。代码里写满了SCB_CleanDCache、SCB_InvalidateDCache这类操作。迁移到龙芯 Linux 后这些裸的 flush 操作被换成了dma_map_single加dma_sync_single_for_cpu的组合但在一次对 DMA 环形缓冲区的修改中我在 CPU 读取数据之前少调用了一次dma_sync_single_for_cpu导致 CPU 从 Cache 里读到旧数据。这种 bug 的特点是非常难定位因为它不是必现而是跟 Cache 行失效时机强相关。解决思路也很简单把 DMA 收发缓冲区所有 CPU 访问点全部检查一遍确保“CPU访问前同步、CPU修改后同步”并且全程使用同一套 DMA API不要混用裸 Cache 操作。我在移植后的代码里加了强制 rule驱动中不允许直接调用架构相关的 cache flush/invalidate 函数一律走 DMA API把架构差异封死。6.2 设备树里memory节点和固件对不上第二个坑就是前面提到的固件内存布局变化。具体表现是驱动在模拟环境里全通在一台旧设备上全通换了一台新设备直接 panic。最终定位是固件更新的内存窗口变化导致设备树 驱动共同引用的物理地址区域出了问题。这个东西的排查链路很长但规律很清晰。如果你发现同一个内核镜像在不同龙芯设备上表现不一致第一个动作永远是去 PMON 里 dump 内存映射而不是抓驱动的日志。因为大多数时候驱动不知道自己正跑在一个“错误地址已被占用”的系统里它只能拿到一片被误解的物理内存然后各种奇妙的行为就出现了。6.3 “MPU能解决的”不等于“MMU能解决的”最后一个坑带有认知性质。工程里一位同事在移植时把原 MPU 配置代码删掉后觉得“Linux 有 MMU安全就够了”于是一段时间没加任何权限控制。结果用户态程序通过mmap直接映射了/dev/mem把整个外设寄存器区域暴露了出去。这种事情在产品里是不能接受的因为你无法确定应用层到底会往里写什么。MPU 做的是“静态区域隔离”MMU 做的是“虚拟地址空间隔离”两者都不等于“驱动访问控制”。要守住外设资源真正可靠的办法是给驱动补一层完整的用户态访问接口比如用miscdevice加ioctl控制读写权限而不是让用户态绕过驱动直接碰物理地址。static long mydev_ioctl(struct file *file, unsigned int cmd, unsigned long arg) { switch (cmd) { case MYDEV_READ_REG: /* 只允许读取指定寄存器地址 */ if (!access_ok(reg_addr, size)) return -EACCES; return read_reg_copy_to_user(reg_addr, size); case MYDEV_WRITE_REG: /* 写操作必须经过权限校验 */ if (!capable(CAP_SYS_ADMIN)) return -EPERM; return write_reg_from_user(reg_addr, size); default: return -ENOTTY; } }移植不是“把代码从 ARM 搬到龙芯”这么简单而是把一套平台上的“保护假设”翻译成另一套平台上的“保护实现”。原工程里 MPU 做了很多幕后工作而 Linux 下的这些幕布要靠驱动开发者一点点重新拉起来。最后再分享一个收尾技巧。移植完成后我们整理了一份“MPU 属性回归测试清单”把原来每个区域对应的行为都转成了可执行的测试用例故意越界访问要能触发预期的错误、DMA 环形缓冲搬运连续 100 万次无数据错乱、外设寄存器读写顺序在压力测试下不出现异常。这份清单在模拟环境、真机、固件更新后各跑一遍比几轮 code review 都管用。
返回列表