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
每个文件的作用,我列个表给你看:
| 文件 | 作用 | 必须自己写? |
|---|---|---|
| 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。
这四个函数分别是:
- TA_CreateEntryPoint:TA被加载时调用一次。做初始化,比如分配全局缓冲区、加载密钥。
- TA_DestroyEntryPoint:TA被卸载时调用。释放资源,擦除敏感数据。
- TA_OpenSessionEntryPoint:CA打开会话时调用。可以做会话级别的初始化。
- 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);
}
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内核的调度、内存的映射、参数的序列化。我把整个流程画成了一张图,你一看就明白:
从图上你能看到,整个通信分两大阶段:
- 会话建立阶段(步骤1-4):CA调用
TEEC_OpenSession,TEE内核加载TA(如果还没加载),调用TA_CreateEntryPoint和TA_OpenSessionEntryPoint,最后返回一个会话句柄给CA。 - 命令调用阶段(步骤5-8):CA调用
TEEC_InvokeCommand,TEE内核把参数映射到TA的地址空间,调用TA_InvokeCommandEntryPoint,TA处理完返回结果,TEE内核再把结果拷贝回CA的缓冲区。
TEE_CheckMemoryAccessRights确认后再用。记住:TA里不要假设传进来的指针一定可访问。
4.5 编译与运行:验证你的第一个TA
代码写完了,怎么跑起来?我一般按这个步骤来:
- 编译TA:在TA工程目录下执行
make,生成.ta文件。 - 签名TA:用
sign_ta.py或optee_sign工具给TA签名,生成.stripped.elf。 - 部署TA:把签名的TA文件放到文件系统的
/lib/optee_armtz/目录下。 - 编写CA:在REE侧写一个CA程序,调用
TEEC_OpenSession和TEEC_InvokeCommand。 - 运行测试:在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;
}
TEEC_ERROR_TARGET_DEAD,说明TA崩溃了。这时候去查TA侧的日志,大概率是TA_CreateEntryPoint里出了异常。我遇到过最离谱的一次,是TA里用了一个未初始化的全局变量,导致加载时直接panic。
4.6 小结:你其实已经掌握了核心
嗯,到这里,第一个TA程序就跑通了。你想想看,其实核心就三件事:
- 实现四个入口函数,让TEE内核能管理TA的生命周期。
- 实现命令分发,让CA能调用TA的功能。
- 理解通信流程,知道数据是怎么在CA和TA之间流转的。
我在项目里带过不少新人,大部分人卡住的地方不是代码本身,而是不理解"CA和TA不在同一个世界"这个前提。一旦你接受了这个设定,后面的事情就顺理成章了。