4、第一个TA程序:创建TA工程、TA入口函数实现、TA命令处理、TA与CA通信流程

好,到了真正动手写代码的时候了。前面几章我们聊了TEE的基本概念、接口分类、环境搭建,现在终于要跑起第一个TA(Trusted Application)。

说实话,我第一次写TA的时候也踩了不少坑。那时候文档不全,全靠看开源代码和反汇编猜流程。现在条件好多了,但核心的东西没变——你得搞清楚TA到底是怎么跑起来的,CA又是怎么跟它说话的。

这一章,我们就从零开始,把一个完整的TA工程搭起来,实现入口函数、处理命令,最后跟CA联调通过。嗯,跟着我走一遍,你会发现其实没那么玄乎。

4.1 TA工程结构:别急着写代码,先看骨架

一个标准的TA工程,说白了就几个文件。我习惯先把目录结构搭好,再往里填逻辑。你想想看,如果连文件放哪儿都不清楚,后面编译链接肯定要出幺蛾子。

下面是我常用的TA工程目录模板:

my_first_ta/
├── Makefile              # 编译脚本,指定TA UUID、签名密钥等
├── user_ta_header.c      # TA头文件,定义UUID、堆栈大小、flags
├── ta_entry.c            # TA入口函数实现(重点)
├── ta_commands.c         # 命令处理函数实现(重点)
├── ta_commands.h         # 命令ID宏定义、接口声明
├── include/
│   └── ta_protocol.h     # CA与TA共享的协议头文件
└── sub.mk                # 子目录编译文件,告诉构建系统要编译哪些.c
我的习惯:UUID一定要在user_ta_header.c里写死,不要用随机生成的。我在项目中遇到过两次因为UUID冲突导致TA加载失败的问题,后来统一用固定UUID+版本号管理,再没出过事。

每个文件的作用,我列个表给你看:

文件 作用 必须自己写?
Makefile 定义TA_UUID、TA_SIGN_KEY、交叉编译器路径 是,但可复用模板
user_ta_header.c 填充TA头结构体,告诉TEE内核这个TA的元信息 是
ta_entry.c 实现TA_CreateEntryPoint、TA_DestroyEntryPoint、TA_OpenSessionEntryPoint、TA_CloseSessionEntryPoint 是
ta_commands.c 实现TA_InvokeCommandEntryPoint,处理CA发来的具体命令 是
ta_protocol.h 定义命令ID枚举、参数结构体,CA和TA都包含这个头文件 是

4.2 TA入口函数实现:四个回调,一个都不能少

TA的入口函数,其实就是TEE内核在特定时机回调你的四个函数。我刚开始写的时候,以为只用实现命令处理就行了,结果TA加载完直接崩了——因为没写TA_CreateEntryPoint。

这四个函数分别是:

  1. TA_CreateEntryPoint:TA被加载时调用一次。做初始化,比如分配全局缓冲区、加载密钥。
  2. TA_DestroyEntryPoint:TA被卸载时调用。释放资源,擦除敏感数据。
  3. TA_OpenSessionEntryPoint:CA打开会话时调用。可以做会话级别的初始化。
  4. TA_CloseSessionEntryPoint:CA关闭会话时调用。清理会话上下文。

来看一个最简的实现:

// ta_entry.c
#include <tee_internal_api.h>
#include <tee_internal_api_extensions.h>

TEE_Result TA_CreateEntryPoint(void)
{
    // 我一般在这里打印一条日志,确认TA加载成功
    DMSG("TA CreateEntryPoint: hello from TEE!");

    // 如果有全局资源要分配,在这里做
    // 比如分配一个安全的临时缓冲区
    return TEE_SUCCESS;
}

void TA_DestroyEntryPoint(void)
{
    DMSG("TA DestroyEntryPoint: cleaning up...");
    // 擦除敏感数据,释放内存
    // 我曾经忘记在这里清空密钥缓存,导致安全审计没过
}

TEE_Result TA_OpenSessionEntryPoint(uint32_t param_types,
                                    TEE_Param params[4],
                                    void **session_ctx)
{
    DMSG("TA OpenSessionEntryPoint: session opened");

    // 可以在这里分配会话上下文
    // *session_ctx = malloc(sizeof(my_session_t));
    // 如果分配失败,返回 TEE_ERROR_OUT_OF_MEMORY

    return TEE_SUCCESS;
}

void TA_CloseSessionEntryPoint(void *session_ctx)
{
    DMSG("TA CloseSessionEntryPoint: session closed");
    // 释放会话上下文
    // free(session_ctx);
}
注意:TA_CreateEntryPoint返回TEE_ERROR_GENERIC会导致TA加载失败,CA那边会收到"TA not found"的错误。我曾经排查了一下午,最后发现是初始化时一个malloc返回了NULL,但我没检查返回值。

4.3 TA命令处理:核心逻辑在这里

CA调用TA,最终都会落到TA_InvokeCommandEntryPoint这个函数里。它就像一个路由器,根据命令ID分发到不同的处理函数。

命令ID的定义,我习惯放在共享头文件里:

// ta_protocol.h
#define TA_MY_FIRST_TA_UUID \
    { 0x12345678, 0x1234, 0x1234, \
      { 0x12, 0x34, 0x56, 0x78, 0x9a, 0xbc, 0xde, 0xf0 } }

#define CMD_PING         0  // 测试连通性
#define CMD_GET_RANDOM   1  // 获取随机数
#define CMD_ENCRYPT      2  // 加密数据
#define CMD_DECRYPT      3  // 解密数据

命令处理函数的实现:

// ta_commands.c
#include <tee_internal_api.h>
#include "ta_protocol.h"

static TEE_Result cmd_ping(uint32_t param_types,
                           TEE_Param params[4])
{
    // 最简单的命令,什么都不做,返回成功
    // 我经常用这个命令测试CA和TA的通信链路是否正常
    DMSG("cmd_ping: received ping from CA");
    return TEE_SUCCESS;
}

static TEE_Result cmd_get_random(uint32_t param_types,
                                 TEE_Param params[4])
{
    // 检查参数:CA传了一个缓冲区进来,我们往里面写随机数
    // param_types 编码了四个参数的类型和方向
    // 这里我们要求 params[0] 是 TEE_PARAM_TYPE_MEMREF_OUTPUT

    uint32_t exp_param_types = TEE_PARAM_TYPES(
        TEE_PARAM_TYPE_MEMREF_OUTPUT,
        TEE_PARAM_TYPE_NONE,
        TEE_PARAM_TYPE_NONE,
        TEE_PARAM_TYPE_NONE);

    if (param_types != exp_param_types) {
        EMSG("cmd_get_random: unexpected param types");
        return TEE_ERROR_BAD_PARAMETERS;
    }

    // params[0].memref.buffer 是CA传进来的输出缓冲区
    // params[0].memref.size 是缓冲区大小
    TEE_GenerateRandom(params[0].memref.buffer,
                       params[0].memref.size);

    DMSG("cmd_get_random: generated %d bytes",
         params[0].memref.size);
    return TEE_SUCCESS;
}

TEE_Result TA_InvokeCommandEntryPoint(void *session_ctx,
                                      uint32_t cmd_id,
                                      uint32_t param_types,
                                      TEE_Param params[4])
{
    DMSG("TA InvokeCommandEntryPoint: cmd_id=%d", cmd_id);

    switch (cmd_id) {
    case CMD_PING:
        return cmd_ping(param_types, params);
    case CMD_GET_RANDOM:
        return cmd_get_random(param_types, params);
    case CMD_ENCRYPT:
        // 加密逻辑,后面章节详细讲
        return TEE_ERROR_NOT_IMPLEMENTED;
    case CMD_DECRYPT:
        return TEE_ERROR_NOT_IMPLEMENTED;
    default:
        EMSG("unknown command id: %d", cmd_id);
        return TEE_ERROR_BAD_PARAMETERS;
    }
}

关键点:param_types编码了四个参数的类型和方向。CA传进来的参数是输入还是输出,必须跟TA这边严格匹配。我见过太多人在这里栽跟头——CA传了输入,TA当输出写,结果数据没传回去,还把自己缓冲区写崩了。

4.4 CA与TA通信流程:一张图说清楚

CA调用TA,不是直接函数调用。中间经过了TEE内核的调度、内存的映射、参数的序列化。我把整个流程画成了一张图,你一看就明白:

CA与TA通信流程 CA (Client Application) TEE 内核 (OP-TEE) TA (Trusted Application) ① TEEC_OpenSession() ② 加载TA,调用CreateEntryPoint ③ 返回TEE_SUCCESS ④ 返回会话句柄 ⑤ TEEC_InvokeCommand(CMD_PING) ⑥ 调用TA_InvokeCommandEntryPoint ⑦ 返回处理结果 ⑧ 返回结果给CA 说明: CA和TA不在同一个地址空间。所有参数传递都经过TEE内核做内存映射和权限检查。 TA不能直接访问CA的内存,CA也不能直接访问TA的内存。这是TEE的安全基础。

从图上你能看到,整个通信分两大阶段:

  • 会话建立阶段(步骤1-4):CA调用TEEC_OpenSession,TEE内核加载TA(如果还没加载),调用TA_CreateEntryPoint和TA_OpenSessionEntryPoint,最后返回一个会话句柄给CA。
  • 命令调用阶段(步骤5-8):CA调用TEEC_InvokeCommand,TEE内核把参数映射到TA的地址空间,调用TA_InvokeCommandEntryPoint,TA处理完返回结果,TEE内核再把结果拷贝回CA的缓冲区。
避坑指南:我曾经在步骤6这里卡了两天。CA传了一个大缓冲区(2MB),TA这边处理时直接访问了指针,结果触发了page fault。后来才发现,TEE内核默认只映射了4KB,大缓冲区需要调用TEE_CheckMemoryAccessRights确认后再用。记住:TA里不要假设传进来的指针一定可访问。

4.5 编译与运行:验证你的第一个TA

代码写完了,怎么跑起来?我一般按这个步骤来:

  1. 编译TA:在TA工程目录下执行make,生成.ta文件。
  2. 签名TA:用sign_ta.py或optee_sign工具给TA签名,生成.stripped.elf。
  3. 部署TA:把签名的TA文件放到文件系统的/lib/optee_armtz/目录下。
  4. 编写CA:在REE侧写一个CA程序,调用TEEC_OpenSession和TEEC_InvokeCommand。
  5. 运行测试:在REE侧执行CA程序,观察TA侧的日志输出。

一个最简的CA调用代码片段:

// ca_main.c (REE侧)
#include <tee_client_api.h>
#include "ta_protocol.h"

int main(void)
{
    TEEC_Context ctx;
    TEEC_Session sess;
    TEEC_Operation op;
    TEEC_UUID uuid = TA_MY_FIRST_TA_UUID;
    uint32_t err_origin;

    // 初始化上下文
    if (TEEC_InitializeContext(NULL, &ctx) != TEEC_SUCCESS) {
        printf("Failed to initialize context\n");
        return -1;
    }

    // 打开会话
    if (TEEC_OpenSession(&ctx, &sess, &uuid,
                         TEEC_LOGIN_PUBLIC, NULL,
                         NULL, &err_origin) != TEEC_SUCCESS) {
        printf("Failed to open session\n");
        TEEC_FinalizeContext(&ctx);
        return -1;
    }

    // 调用CMD_PING
    memset(&op, 0, sizeof(op));
    op.paramTypes = TEEC_PARAM_TYPES(TEEC_NONE, TEEC_NONE,
                                     TEEC_NONE, TEEC_NONE);

    if (TEEC_InvokeCommand(&sess, CMD_PING, &op,
                           &err_origin) == TEEC_SUCCESS) {
        printf("CMD_PING succeeded!\n");
    } else {
        printf("CMD_PING failed\n");
    }

    // 关闭会话
    TEEC_CloseSession(&sess);
    TEEC_FinalizeContext(&ctx);

    return 0;
}
常见错误:如果CA返回TEEC_ERROR_TARGET_DEAD,说明TA崩溃了。这时候去查TA侧的日志,大概率是TA_CreateEntryPoint里出了异常。我遇到过最离谱的一次,是TA里用了一个未初始化的全局变量,导致加载时直接panic。

4.6 小结:你其实已经掌握了核心

嗯,到这里,第一个TA程序就跑通了。你想想看,其实核心就三件事:

  • 实现四个入口函数,让TEE内核能管理TA的生命周期。
  • 实现命令分发,让CA能调用TA的功能。
  • 理解通信流程,知道数据是怎么在CA和TA之间流转的。

我在项目里带过不少新人,大部分人卡住的地方不是代码本身,而是不理解"CA和TA不在同一个世界"这个前提。一旦你接受了这个设定,后面的事情就顺理成章了。