4. 第一个Enclave程序:编写EDL、实现内部逻辑、编写不可信部分、编译与运行

好,到了真正动手的时候了。前面几章我们把TEE的概念、SGX的架构、开发环境的搭建都过了一遍。现在,我们来写第一个Enclave程序。

说白了,一个Enclave程序由三部分组成:描述文件(EDL)、Enclave内部的可信代码、以及外部不可信代码。这三者缺一不可。我刚开始接触SGX时,总觉得EDL是个多余的麻烦,后来踩了坑才明白——EDL就是Enclave的“海关”,什么能进、什么能出,全靠它说了算。

核心流程:编写EDL → 实现Enclave内部函数 → 编写外部调用代码 → 编译链接 → 运行验证。

Enclave程序开发与运行流程 编写EDL文件 实现Enclave逻辑 编写不可信代码 编译与运行 关键:EDL定义可信边界,决定哪些函数可以进出Enclave edger8r工具根据EDL生成代理代码(可信侧+不可信侧) 可信侧(Enclave内部) • 处理敏感数据 • 调用可信库函数 • 通过OCALL输出结果 不可信侧(外部) • 创建Enclave • 通过ECALL调用 • 处理OCALL回调

4.1 编写EDL文件——定义可信边界

EDL(Enclave Description Language)是Intel SGX定义的一种接口描述语言。它的作用,就是告诉编译器:哪些函数可以从外部调用进入Enclave(ECALL),哪些函数可以从Enclave内部调用到外部(OCALL)。

我个人习惯把EDL看作一份“外交协议”。ECALL是“入境许可”,OCALL是“出境许可”。没有许可,数据就过不了边界。

来看一个最简单的EDL文件示例:

// Enclave.edl
enclave {
    trusted {
        /* ECALL:外部可以调用的Enclave函数 */
        public void ecall_encrypt([in, size=len] char* input, size_t len,
                                   [out, size=len] char* output);
        
        public int ecall_get_result(void);
    };
    
    untrusted {
        /* OCALL:Enclave内部调用的外部函数 */
        void ocall_print_string([in, string] const char* str);
    };
};

小提示:EDL中的[in]、[out]、[size]等注解不是摆设。它们告诉edger8r工具如何做内存拷贝和边界检查。我曾经见过有人漏写[size]导致缓冲区溢出——在Enclave里这可不是闹着玩的。

EDL文件写好后,需要用sgx_edger8r工具处理它:

sgx_edger8r Enclave.edl

这个命令会生成四个文件:

  • Enclave_t.h 和 Enclave_t.c:可信侧的代理代码(用于Enclave内部)
  • Enclave_u.h 和 Enclave_u.c:不可信侧的代理代码(用于外部程序)

你想想看,如果没有edger8r,你得手动处理参数编组、内存拷贝、边界检查——那工作量可就大了去了。

4.2 实现Enclave内部逻辑——可信代码

Enclave内部的代码,运行在受保护的内存区域(EPC)中。这里的数据,CPU之外的任何软件都读不到。但有个限制:你不能直接在Enclave里调用系统调用、文件I/O、网络操作。这些都得通过OCALL委托给外部。

下面是一个简单的Enclave内部实现:

// Enclave.cpp
#include "Enclave_t.h"  // 由edger8r生成

#include <string.h>
#include <stdlib.h>

/* 内部缓冲区,用于存储加密结果 */
static char g_result[256];
static int g_result_len = 0;

/* ECALL:加密输入数据(这里用简单的XOR演示) */
void ecall_encrypt(char* input, size_t len, char* output) {
    if (input == NULL || output == NULL || len == 0) {
        ocall_print_string("Error: invalid parameters");
        return;
    }
    
    // 模拟加密:每个字节与0xAA异或
    for (size_t i = 0; i < len; i++) {
        output[i] = input[i] ^ 0xAA;
    }
    
    // 保存结果到内部缓冲区
    memcpy(g_result, output, len);
    g_result_len = len;
    
    ocall_print_string("Encryption done inside Enclave");
}

/* ECALL:获取内部结果长度 */
int ecall_get_result(void) {
    return g_result_len;
}

注意:Enclave内部不要使用全局变量存储敏感数据?不对,恰恰相反——Enclave内部是唯一安全的地方。全局变量在这里是安全的,因为EPC内存天然被加密保护。但要注意,不要把敏感数据通过OCALL传出去,除非你确认外部环境安全。

我在项目中遇到过一个问题:Enclave内部不能直接使用printf,因为那是系统调用。所以我们需要通过OCALL把字符串打印出去。上面的代码里,ocall_print_string就是干这个的。

4.3 编写不可信部分代码——外部应用程序

不可信部分,就是运行在普通用户空间的代码。它负责创建Enclave、调用ECALL、处理OCALL回调。

// App.cpp
#include <stdio.h>
#include <string.h>
#include "sgx_urts.h"
#include "Enclave_u.h"  // 由edger8r生成

/* OCALL实现:Enclave内部调用此函数打印字符串 */
void ocall_print_string(const char* str) {
    printf("[Enclave] %s\n", str);
}

int main() {
    sgx_enclave_id_t eid = 0;
    sgx_status_t ret;
    
    // 1. 创建Enclave
    ret = sgx_create_enclave("enclave.signed.so", SGX_DEBUG_FLAG,
                             NULL, NULL, &eid, NULL);
    if (ret != SGX_SUCCESS) {
        printf("Failed to create enclave, error code: %d\n", ret);
        return -1;
    }
    printf("Enclave created successfully, ID: %ld\n", eid);
    
    // 2. 准备数据
    char input[] = "Hello, TEE World!";
    char output[256] = {0};
    
    // 3. 调用ECALL
    ret = ecall_encrypt(eid, input, strlen(input) + 1, output);
    if (ret != SGX_SUCCESS) {
        printf("ECALL failed: %d\n", ret);
        sgx_destroy_enclave(eid);
        return -1;
    }
    
    // 4. 获取结果
    int result_len = ecall_get_result(eid);
    printf("Encrypted output (%d bytes): ", result_len);
    for (int i = 0; i < result_len; i++) {
        printf("%02X ", (unsigned char)output[i]);
    }
    printf("\n");
    
    // 5. 销毁Enclave
    sgx_destroy_enclave(eid);
    return 0;
}

这里有个细节:ecall_encrypt和ecall_get_result的第一个参数都是eid(Enclave ID)。这是edger8r自动加上的,用来标识要调用哪个Enclave实例。你可以在一个进程中加载多个Enclave,每个都有自己的ID。

4.4 编译与运行——把一切串起来

编译Enclave程序比普通程序复杂一些,因为需要分别编译可信部分和不可信部分,还要签名。我整理了一个典型的Makefile结构:

步骤 命令 说明
1. 生成代理代码 sgx_edger8r Enclave.edl 生成_t.h、_t.c、_u.h、_u.c
2. 编译不可信部分 g++ -c App.cpp -I$(SGX_SDK)/include 生成App.o
3. 编译可信部分 g++ -c Enclave.cpp -I$(SGX_SDK)/include/tlibc -fPIC -fvisibility=hidden 生成Enclave.o
4. 链接可信部分 g++ -o enclave.so Enclave.o -lsgx_trts -shared 生成未签名的.so
5. 签名Enclave sgx_sign sign -key Enclave_private.pem -enclave enclave.so -out enclave.signed.so 生成签名后的.so
6. 链接不可信部分 g++ -o app App.o Enclave_u.o -lsgx_urts 生成可执行文件

避坑指南:我曾经在编译时忘记加-fvisibility=hidden,结果Enclave内部的符号全部暴露了。虽然EPC内存还是加密的,但符号表泄露了内部函数名——这给攻击者提供了信息。记住:Enclave的编译选项要尽量保守。

运行程序:

./app

如果一切顺利,你会看到类似这样的输出:

Enclave created successfully, ID: 1
[Enclave] Encryption done inside Enclave
Encrypted output (17 bytes): E2 2B 2C 2C 2F 20 54 45 45 20 57 6F 72 6C 64 21 00

嗯,这里要注意:输出中的[Enclave]前缀,就是通过OCALL从Enclave内部打印出来的。这说明数据确实进入了Enclave,加密操作在受保护的环境中完成,结果又安全地返回到了外部。

4.5 常见问题与排查

第一次跑Enclave程序,大概率会遇到问题。我列几个常见的:

  • Enclave创建失败(SGX_ERROR_ENCLAVE_FILE_ACCESS):检查enclave.signed.so路径是否正确,文件是否存在。
  • ECALL返回SGX_ERROR_UNEXPECTED:通常是EDL和实际函数签名不匹配。重新运行edger8r,检查参数类型和个数。
  • OCALL没有触发:确认在不可信代码中实现了对应的OCALL函数,并且函数名与EDL中定义的一致。
  • 编译时报错找不到头文件:检查-I路径是否正确,特别是可信代码需要包含tlibc的头文件路径。

我的经验:调试Enclave程序时,先用SGX_DEBUG_FLAG编译。这样可以看到更详细的错误日志。生产环境再换成SGX_PROD_FLAG。另外,sgx_sign工具可以用-dump选项查看签名后的Enclave元信息,排查签名问题很有用。

好了,到这里你已经完成了第一个Enclave程序的编写、编译和运行。虽然功能简单,但骨架已经搭起来了。后面我们会在这个基础上,加入真正的加密算法、安全通信、远程认证等高级特性。