3. TEE与REE的通信机制:SMC指令、共享内存与消息传递

好,我们进入第三章。这一章讲的是TEE和REE怎么“说话”。

你想想看,一个安全世界,一个普通世界,两者完全隔离。但隔离不等于老死不相往来。TEE需要从REE获取数据,REE也需要TEE帮忙做安全运算。那这个门是怎么开的?

我个人习惯把TEE-REE通信拆成三层来看:触发层(怎么叫醒对方)、数据层(数据放哪)、协议层(消息怎么组织)。咱们一层层剥开。

3.1 SMC指令:安全监控模式调用的“敲门砖”

先说最底层的东西——SMC指令。全称是Secure Monitor Call,安全监控调用。

ARM架构里,CPU有四种异常等级(EL0到EL3)。REE跑在EL1(非安全侧),TEE也跑在EL1(安全侧)。两者之间隔着一道“安全监控器”,也就是EL3的Monitor模式。

SMC指令的作用,就是让CPU从REE侧陷入EL3,由Monitor决定要不要切换到TEE侧。说白了,这就是一个硬件的系统调用。

核心要点:

  • SMC指令只能在EL1及以上执行,用户态(EL0)不能直接发。
  • 执行SMC后,CPU进入EL3,Monitor保存当前世界状态,恢复目标世界状态。
  • Monitor通过SCR(安全配置寄存器)的NS位判断当前是安全还是非安全世界。

我在项目中遇到过一个问题:某个驱动在中断上下文里发了SMC,结果导致系统卡死。为什么?因为SMC是同步指令,必须等Monitor处理完才返回。中断上下文里等不起。嗯,这里要注意——SMC不能在原子上下文或中断处理中直接调用,除非你很清楚自己在做什么。

3.2 共享内存机制:数据交换的“高速公路”

SMC只是敲门,真正传数据靠的是共享内存。

你想想,如果每次传几百字节的数据都靠SMC带过去,那效率太低了。SMC的传参寄存器就那么几个(x0-x7),传个指针还行,传大数据就抓瞎了。

所以业界通用的做法是:在非安全世界分配一块内存,通过SMC把物理地址告诉安全世界,然后双方都能访问这块内存。

3.2.1 共享内存的生命周期

我建议你记住这四个阶段:

  1. 分配:REE侧分配连续物理内存(通常用DMA或预留内存)。
  2. 注册:REE通过SMC把内存的物理地址、大小告诉TEE。
  3. 映射:TEE侧将这块内存映射到自己的页表,并设置访问权限。
  4. 释放:通信结束后,TEE解除映射,REE释放内存。

避坑指南:

我曾经在项目里踩过一个坑:REE侧分配的内存是虚拟地址连续的,但物理地址不连续。TEE侧拿到虚拟地址后直接当物理地址用,结果访问到错误的数据。记住,共享内存必须是物理连续的,或者TEE侧要有IOMMU/SMMU支持。

3.2.2 内存权限隔离

共享内存不是“裸奔”的。TEE侧会对这块内存做严格的权限控制:

权限类型 REE侧 TEE侧
读 允许 允许
写 允许(仅写入自己分配的区域) 允许(但受TEE内部策略控制)
执行 禁止 禁止
缓存策略 通常使用非缓存或写通 与REE侧一致

这里有个关键点:TEE侧永远不会执行共享内存里的代码。这是安全底线。如果有人往共享内存里塞恶意代码,TEE一旦执行就完蛋了。所以TEE的页表对共享内存区域会强制设置XN(不可执行)位。

3.3 消息传递协议:数据怎么“打包”和“拆包”

有了SMC做触发,有了共享内存做载体,接下来就是协议的事了。

GP(GlobalPlatform)标准定义了一套TEE通信协议,我挑重点讲。

3.3.1 消息结构

每次通信,双方交换的是一个固定格式的消息头+可变长度的负载。消息头里包含:

  • 命令ID:告诉TEE要做什么(比如:打开会话、调用命令、关闭会话)。
  • 会话句柄:标识当前是哪个会话。
  • 参数类型:说明负载里是值、引用、还是共享内存。
  • 返回值:TEE处理完后的状态码。
// 简化的消息头结构(伪代码)
struct tee_message_header {
    uint32_t cmd_id;        // 命令ID
    uint32_t session_id;    // 会话ID
    uint32_t param_types;   // 参数类型编码
    uint32_t return_code;   // 返回值
    uint32_t payload_size;  // 负载长度
    uint8_t  payload[0];    // 变长负载
};

3.3.2 参数传递方式

GP定义了四种参数类型,我列个表对比一下:

类型 说明 适用场景
值传递 直接传32位整数值 小数据、控制参数
临时引用 传一块临时内存的指针和长度 中等大小数据(几KB)
共享内存引用 传预先注册的共享内存的句柄 大数据(几MB以上)
无参数 啥也不传 纯控制命令

我个人习惯:能用值传递就别用引用,能用临时引用就别用共享内存。为什么?因为共享内存涉及分配、注册、映射、释放一套流程,开销不小。只有传大块数据时才值得用。

3.3.3 通信流程

一次典型的TEE通信流程是这样的:

  1. REE应用调用TEE驱动,发起SMC。
  2. Monitor切换到TEE侧,TEE内核解析消息头。
  3. TEE内核把消息路由到对应的TA(可信应用)。
  4. TA处理请求,把结果写回共享内存。
  5. TEE内核发起SMC返回,Monitor切回REE侧。
  6. REE驱动拿到结果,返回给应用。

小技巧:

调试TEE通信时,我经常在REE侧和TEE侧各打一个时间戳。如果发现SMC返回时间异常,八成是共享内存的缓存一致性问题——REE侧写完了,TEE侧读到的还是旧数据。解决办法是加内存屏障(DSB/ISB指令),或者用非缓存内存区域。

3.4 整体通信架构图

下面这张图把上面讲的三个层次串起来了。你可以看到SMC、共享内存、消息协议在整个通信链路中的位置。

TEE与REE通信架构图 REE(普通世界) REE应用(CA) TEE驱动(发送SMC) TEE(安全世界) TEE内核(解析消息) 可信应用(TA) EL3 Monitor(安全监控器) 共享内存(数据通道) SMC指令 SMC返回 消息协议 写数据 读数据 说明: ① REE通过SMC指令触发通信,Monitor负责世界切换。 ② 数据通过共享内存传递,消息协议定义数据格式和流程。

3.5 性能与安全权衡

最后聊点实际的。TEE-REE通信不是免费的午餐。每次SMC切换都有开销——上下文保存恢复、TLB刷新、缓存失效。我实测过,一次完整的SMC往返大概要几微秒到几十微秒,具体取决于硬件平台。

所以我的建议是:

  • 减少SMC次数:能批量处理就别一次一次调。比如一次传100条数据,比调100次SMC每次传1条快得多。
  • 共享内存复用:不要每次通信都分配释放共享内存。预先分配好,重复使用。
  • 消息合并:小消息合并成大消息一起传,减少协议头开销。

安全提醒:

共享内存是双刃剑。REE侧可以随时修改共享内存里的数据,TEE侧不能假设数据在两次读取之间不变。我见过一个漏洞:TEE先检查了共享内存里的长度字段,然后直接用这个长度去拷贝数据。攻击者在检查后、拷贝前修改了长度,导致缓冲区溢出。正确的做法是先拷贝到TEE内部缓冲区,再校验。