开头先说说背景。我们走马观碑组接到的任务是把一套原本跑在 ARM 平台上的驱动方案,原封不动地搬到龙芯 2K 系列平台上。老实讲,刚拿到任务清单时我心里是有预感的:这种跨架构移植,最怕的不是业务逻辑复杂,而是驱动里那些跟架构强绑定的“隐形依赖”。这套方案的核心依赖就是 MPU,ARM 的 Memory Protection Unit。代码里几十个宏、几百行配置,全是区域号、属性位、Cache 策略,它们在芒果味很浓的 H750 上跑得飞起,可一旦换成龙芯,这些代码一行都留不住。这篇文章就是记录我们组从拆解 MPU 依赖、梳理替代方案,到在模拟环境里验证、最终在真机上跑稳定、顺带把固件更新和内存布局变化一起啃下来的全过程。如果你也在做国产化替代、跨架构驱动移植,或者只是想搞明白“H750 的 MPU 怎么设置、到了 Linux 下对应的写法又是什么”,这份记录应该能帮你少走不少弯路。
1. 原方案里,H750的MPU到底在保护哪几样东西
1.1 三个典型区域的实际配置
先看原方案的核心。目标芯片是 STM32H750,Cortex-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-back,CPU 写了数据但还没回写,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下的替代方案 |
|---|---|---|
| 外部SDRAM | Write-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 API |
| GPIO/I2C驱动 | 外设寄存器区 | 寄存器读写强顺序 | ioremap默认属性 |
| RTOS任务管理 | 任务栈区域 | 越界访问触发异常 | 移除,改由LinuxMMU进程隔离 |
这个步骤没有太多技术含量,但很容易漏。最容易被遗漏的是那种“因为外部行为恰好正确,所以没人意识到依赖存在”的代码。比如某个轮询外设标志位的循环,以前因为 MPU 配了 device 属性所以不会被乱序执行干扰,代码里根本没写任何内存屏障。换到新平台后如果不补屏障,等 bug 出现时定位起来会非常痛苦。
3.2 一个值得参考的分层思路
盘完代码之后我做了一件事,后来证明这是整个移植过程中性价比最高的事:重构驱动分层。
原驱动是直接操作寄存器的“平铺式”写法,MPU 配置和业务逻辑缠在一起。我把每个驱动模块拆成三层:
- 平台无关的纯逻辑层:只处理状态机、数据计算、协议解析。
- 寄存器操作层:把对寄存器读写的动作封装成函数,内部可以用
readl/writel或 RTOS 下的地址映射。 - 内存策略层:专门管缓冲区分配、Cache 同步、内存布局约束。
这样一来,MPU 相关的依赖被压缩到了内存策略层一个很小的范围内,后面重新实现时只需要改这一层。纯逻辑层和大部分寄存器操作层甚至可以做到跨平台复用。移植完成后回头看,如果没有这一步分层,后面替换 DMA 策略、调整设备树的时候大概率会改一处崩一片。
4. 一次翻译三个MPU区域:从ARM属性到Linux内核API
4.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配合同步 API:
dma_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 file=loongnix-rootfs.ext4,format=raw \ -append "root=/dev/vda console=ttyS0" \ -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_pool@40000000 { compatible = "shared-dma-pool"; reg = <0x0 0x40000000 0x0 0x1000000>; no-map; }; };如果固件更新的版本说明里提到了内存、PCI 枚举或内存控制器相关内容,就必须重新核对这份映射。这一步在开发文档里很少有人写,但恰恰是国产平台移植最容易中招的地方。
6. 真机连调时踩过的三个坑
6.1 Cache一致性:MPU配过了不代表Linux里就安全
第一个坑,也是最经典的坑。在真机上跑起来后,发现网卡驱动收包偶发性出现“帧头对了但数据是旧的”。用逻辑分析仪和打印结合,最后确认是 Cache 一致性问题。
原 H750 工程里,MPU 把 SDRAM 配成了 write-back,DMA 描述符的同步靠驱动里的手工 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 都管用。