跳转至

MPU 用户空间隔离与系统调用

1. 嵌入式系统的“非特权革命”:CONFIG_USERSPACE

传统 RTOS(包括标准 FreeRTOS 与早期 Zephyr)的所有线程都运行在 CPU 的最高特权级(Privileged Mode)。这意味着: * 任何一个第三方库(如未经验证的 JSON 解析器或蓝牙库)内部只要发生空指针解引用或越界写入,就能直接改写内核 TCB、关停全局中断甚至破坏底层寄存器。

Zephyr 提供了原生的 CONFIG_USERSPACE 用户空间特权隔离

flowchart TD
    subgraph UserSpace["非特权用户空间 (Unprivileged Mode)"]
        AppThread["用户业务线程 (User Thread)"]
        ThirdParty["第三方不可信协议栈 / 脚本引擎"]
    end

    subgraph KernelSpace["特权内核空间 (Privileged Supervisor Mode)"]
        SyscallStub["系统调用分发层 (Syscall Stub & Verification)"]
        KernelCore["Zephyr 内核对象 / 驱动核心 / 调度器"]
        HW["硬件寄存器 (NVIC / RCC / DMA)"]
    end

    UserThread -->|"直接访问硬件/内核数据"| Blocked["❌ MPU 硬件拦截: MemManage Fault"]
    UserThread -->|"执行 SVC 软中断"| SyscallStub
    SyscallStub -->|"校验入参/指针有效性"| KernelCore
    KernelCore -->|"特权级合法读写"| HW

与传统“MPU 只防栈溢出”的用法不同,Zephyr 把 MPU 从调试辅助升级为安全边界:MPU 区域在线程切换时随 k_mem_domain 动态重编程,形成“每个线程一个视图”的类进程隔离——这是无 MMU MCU 上最接近 Linux 进程模型的工业实现。


2. 系统调用机制(Syscall Stubs & Handlers)

当用户态代码调用内核 API(如 k_sem_give(&my_sem))时,编译系统不会直接链接内核符号,而是通过代码生成器生成系统调用桩函数(Syscall Stub)

sequenceDiagram
    autonumber
    participant User as 用户态线程 (Unprivileged)
    participant SVC as SVC/ syscall 指令 (Privileged Entry)
    participant Handler as z_vrfy_k_sem_give (权限验证)
    participant Core as z_impl_k_sem_give (真实内核实现)

    User->>SVC: 执行 syscall 指令,携带系统调用 ID 与寄存器参数
    Note over SVC: CPU 切入特权级, 栈切换到该线程的内核栈
    SVC->>Handler: z_mrsh 分发表按 ID 解包调用
    Note over Handler: 1. 检查指针是否在合法 RAM 范围<br/>2. 检查该线程是否被授予该 k_sem 句柄访问权限
    alt 校验失败 (权限非法)
        Handler-->>User: 触发系统致命错误 / 终止该线程
    else 校验通过
        Handler->>Core: 调用真正的内核实现函数
        Core->>Core: 修改信号量计数值与等待链表
        Core-->>User: 恢复非特权级,返回用户态继续执行
    end

2.1 编译期自动生成的三个孪生函数

在 Zephyr 源码中,声明一个系统调用宏 __syscall void k_sem_give(struct k_sem *sem);,接口分为实现、验证和生成的存根/分发三层;实现与验证函数通常由开发者编写:

函数 职责 运行上下文
实现 z_impl_k_sem_give() 最纯粹的原子操作逻辑 特权内核
验证 z_vrfy_k_sem_give() K_OOPS(Z_SYSCALL_OBJ(sem, K_OBJ_SEM)) 校验对象类型/权限/内存 特权内核(syscall 入口)
桩/分发 用户侧存根 + z_mrsh_k_sem_give() 打包参数进寄存器触发 syscall;内核侧解包恢复 用户栈 ↔ 内核栈

__syscall 宏同时生成用户侧存根:特权路径可直接调用 z_impl_*(零开销),仅用户线程走 SVC 路径——同一份源码按调用者特权自动选择快慢路径

2.2 内核对象表:句柄即安全凭证

  • 构建工具扫描 ELF/DWARF 信息发现静态内核对象,生成对象查找与权限元数据;启用动态对象配置时,k_object_alloc() 也可创建可授权对象。
  • 用户态拿到的“指针”在 syscall 边界被还原成表项校验:类型对不对(魔数)、允不允许(该线程的授权位),二者任一失败即 k_oops
  • 这解释了一条铁律:普通栈上临时构造或 memcpy 伪造的对象不能代替合法对象分配;应使用可被构建工具发现的静态对象或受支持的动态分配 API。

3. 动态内存域(Memory Domains)

为了防止用户线程间相互踩踏,Zephyr 引入了 struct k_mem_domain。 * 一个内存域聚合若干 MPU 分区(struct k_mem_partition,典型上限 8 个,架构相关)。 * 多个协同运行的线程可以加入同一个内存域,共享其中的一段共享缓冲区;而处于该域之外的其他用户线程无论如何也无法读写该物理内存,实现了类似 Linux 进程间内存隔离的高度安全性

3.1 域的编程模型与共享内存分区

K_APPMEM_PARTITION_DEFINE(app_a_part);        /* 应用 A 专属分区 */
K_APP_BMEM(app_a_part) uint8_t codec_buf[4096]; /* 把变量放入该分区 */

struct k_mem_partition *app_a_parts[] = { &app_a_part };
struct k_mem_domain app_a_domain;
k_mem_domain_init(&app_a_domain, ARRAY_SIZE(app_a_parts), app_a_parts);
k_mem_domain_add_thread(&app_a_domain, thread_a_tid); /* 线程入域 */

K_APP_BMEM(.bss 域)/K_APP_DMEM(.data 域)在链接期把变量聚拢进分区段;CONFIG_APP_SHARED_MEM=y 开启整个机制。线程切换时内核重编程 MPU 活动区域——这就是“每线程内存视图”的物理实现,切换成本约等于若干条 MPU 寄存器写。

3.2 ARMv8-M MPU 的硬约束(移植期必读)

约束 后果
区域数量有限(8/16 视实现) 分区过多时编译失败;需合并或调 CONFIG_MAX_DOMAIN_PARTITIONS
ARMv8-M 的区域边界按 32 字节粒度;不要套用 ARMv7-M 的幂次大小要求 奇数尺寸缓冲自动 padding,RAM 规划要留余量
特权侧存在“背景区域” 内核态不受用户分区限制;但侧信道上内核可读一切(非威胁模型范围)
无 MMU 的重映射 隔离是“权限级”而非“地址级”:物理地址恒定,仅访问位随线程切换

4. 用户线程的完整生命周期

flowchart LR
    Create["k_thread_create(... K_USER ...)"] --> Grant["k_thread_access_grant(tid,<br/>&sem, &msgq, &dev)"]
    Grant --> First["首次调度: 经 arch 用户态入口<br/>压栈初始上下文后跌入非特权态"]
    First --> Run["运行于用户态: 只能触碰<br/>自身栈 + 已授权域/分区"]
    Run --> Exit["线程退出: 内核栈/对象权限回收"]
    Create -.可选.-> Inherit["K_INHERIT_PERMS:<br/>继承创建者的全部授权"]
  • 授权即能力(capability 模型)k_thread_access_grant() 显式把内核对象的使用权授予用户线程——未授权对象即使地址可猜,syscall 边界也会拒绝。
  • 双栈设计:每个用户线程既有用户栈(日常运行)也有内核栈(syscall 期间),两栈物理分离,杜绝“用户栈溢出冲垮内核现场”。
  • 驱动设备句柄同样走授权制:k_thread_access_grant(tid, dev) 之后用户态才能对该设备发起 syscall 级操作。

5. 用户态限制清单(从特权代码迁移的审计项)

限制 原因 迁移改法
禁直访内核静态数据(z_impl_* 全家、内核结构) MPU 用户视图不含内核区 全部改为 __syscall API
禁裸指针跨边界传参 内核无法验证用户内存有效性 用 syscall 校验宏声明的缓冲 API;或 mem_domain 显式共享
禁 ISR/回调上下文调用需阻塞语义的 API 用户态不拥有中断上下文 用 workqueue 把活搬回线程
禁直接操作外设寄存器(MMIO) 外设区不在用户 MPU 视图 走统一驱动模型 syscall
全局变量默认不可见 全局区属内核域 K_APP_*MEM 分区显式声明

6. 典型 oops 现象与取证

用户态违规的第一现场几乎总是一份异常转储(CONFIG_EXCEPTION_DEBUG):

>>> MPU FAULT (instruction access) @ 0x0800abcd    ← 出错类型与地址
 r0-a3 / r12 / lr / pc / xpsr 寄存器组             ← 现场寄存器
 Faulting instruction address (LR): 0x0800ab99     ← 用 addr2line 对回符号
 Thread: 0x20001ff0 (unpriv_worker)                ← 肇事线程

Tip

取证三步:① 转储中 pc/lr 交给 addr2line -e zephyr.elf 定位源码行;② 看 Thread 名确认是哪个用户线程;③ 按 §5 清单对照——十有八九是“未授权对象访问”或“用户指针未过校验宏”。相比特权态的“随机 HardFault 三天后死机”,userspace 把故障当场定格成可定位事件,这正是它最大的工程价值。


7. 现场排查:Userspace 与 MPU

症状 疑似根因 验证手段
用户线程调内核 API 即 oops(-EACCES/对象拒绝) 对象未 k_thread_access_grant 授权 对照 grant 列表;补授权或加 K_INHERIT_PERMS
MPU fault 但代码“看起来没越界” 链接期变量未进分区(散在内核域) .elf 的 map:变量是否落在 K_APP_* 分区段
编译期 "no space for MPU region"/分区溢出 域分区数超架构上限 / 对齐 padding 膨胀 合并分区;调整 CONFIG_MAX_DOMAIN_PARTITIONS
syscall 后数据“没写进去” 传的是用户指针,缓冲区权限、长度或 API 返回值不符合预期 检查 API 返回值与 z_vrfy 校验;非法用户内存通常触发 oops,并非静默忽略
开 USERSPACE 后 RAM 暴涨 每线程双栈 + 对象表元数据开销 核对每线程内核栈尺寸;评估隔离收益是否匹配威胁模型
极难复现的偶发崩溃变成稳定 MPU fault 隔离起效:把原“潜伏越界”定格成现场 按 §6 三步取证(这是特性不是回归!)