跳过正文
  1. Posts/

hpppl基本研究

··2804 字·6 分钟·
cerry2022
作者
cerry2022
A little bit about you

最近学习使用ImHex并对hp39gii的.hpprgm程序进行了一些逆向研究,目前已经取得很大的进展,先来介绍ImHex,ImHex是一个基于ImGUI的十六进制编辑器,集成了很强大的 Pattern Language(模式语言) ,利用这个模式语言,可以优雅地编写程序逆向分析二进制文件结构。

话不多说,让我们来看看这个HPPPL语言的.hppram文件格式吧! 首先以一个程序作为示例,HP39GII程序-图像处理库 BLib,这是一个带简单压缩算法的图像保存和加载库,我们在计算器上编辑好后,按下SHIFT+1【Prgm】之后,可以看到大小10KB,而实际上源码本身只有3KB,那么多出来的那些数据是什么呢?

UTF16-LE打开,可以看到是一些乱码,但是依稀能看见几个字母,比如1BLOAD1BSAVE BREAK ,考虑到HPPPL语言如此高的执行效率,是否使用了某种类似编译的机制呢,为了深入分析,我们使用ImHex来查看:
不难发现,0x10以及0x20这两个位置都有一串UTF-16编码的字符串,"BLOAD""BSAVE",两者正好对应程序对应的两个导出全局函数。结合 Header 结构体分析,0x08的02 即为导出函数的数量,在每个导出函数名称前,存在固定的标识符 0x31,可以推断,这代表该条目为“导出函数”类型。由于这部分数据呈现线性排列,我们将其定义为导出表

接下来的分析可能比较难,通过对多个文件逆向对比分析发现,紧跟着导出函数列表后的4字节内容其实是导出列表到源代码区域的字节跨度,我称这一核心区域为BytecodeBlock

实际上,该区域是由源代码编译而成的RPN(逆波兰表示法)堆栈字节码序列。也就是说,HP Prime 编程语言(HPPPL)在底层执行机制上,其实是基于栈式虚拟机架构的。

这里我直接给出最后的结果,文件的整体结构如下

struct FileStructure {
    Header header; // 文件头
    ExportEntry export_table[header.export_fun_count + header.export_var_count];  // 导出函数列表
    ExportVar export_vars[header.export_var_count]; // 导出变量列表
    BytecodeBlock bytecode; // 核心字节码区
    SourceCodeBlock source_code; // 源码区域
};

FileStructure file_data @ 0x00; // 文件解析器开始位置,指向0x00

这是文件头的结构:

struct Header {
    le u32 header_end [[type("hex"), color("FF0000")]];
    le u32 export_var_count;
    le u32 export_fun_count;
    le u32 reserved; 
};

导出函数和变量的结构:

// 导出函数名是utf16编码,前16bit为type类型,最后有两字节的空余
struct ExportEntry {
    le u16 type;
    String16 name [[color("FFA500")]];
    padding[2];
};
// 导出变量较为复杂,是使用了`RPN堆栈字节码序列`的
struct ExportVar {
    le u32 var_size;
    u64 block_end_addr = $ + var_size; 
    u64 limit_addr = block_end_addr;
    BytecodeStreamElement stream[while($ < limit_addr)] [[inline]];
    if ($ < limit_addr) {
        padding[limit_addr - $] [[color("909090")]];
    }
};

面向对象的栈式虚拟机
#

最关键的区域来了,BytecodeBlock实际上都是一系列的 OpcodeHeader + Bytecode 在常规语言(如 C 或 Java)的字节码中,指令(Opcode)和数据(Data/Constant Pool)通常是分离的。但在 HPPPL 的 BytecodeBlock 里,你会发现一个极其深刻的特点:指令本身就是对象的构造器,而数据本身就是可执行的指令。

让我们顺着这个思路往底层挖:

不难发现,在 BytecodeStreamElement 的 match 逻辑里,0x010104 指向的是一个矩阵,0x01030B 指向的是一个函数。最关键的来了:在虚拟机眼里,它们并没有本质区别

  • 在标准架构中:你会看到一条指令叫 PUSH_MATRIX [index]。
  • 在 HPPPL 中:没有 PUSH 指令。当 BytecodeMatrix 结构出现在流中时,它本身就触发了“将自己实例化并压栈”的动作。

实际上,每一个 OpcodeHeader 并不是在描述“接下来是一个什么数据”,而是在定义一个 “自描述对象”。由于它是 RPN(逆波兰)架构,这种“万物即对象”的设计意味着:流里的每一个元素都是一个自包含的对象实例,它们被读取的那一刻,就完成了从二进制数据到运行时对象的转换。


// =====================================================================
// 迭代器核心:单条指令解析器
// =====================================================================

struct BytecodeStreamElement {
    u64 limit = parent.limit_addr;
    
    u32 peek_val = std::mem::read_unsigned($, 4);
    u32 peek_opcode = peek_val & 0x00FFFFFF;

    match (peek_opcode) {
        // --- 数字字面量 (实数) ---
        (0x000000 | 0x000100 | 0x010100 | 0x020100 | 0x030100): {
            BytecodeNone bytecode_header;
            u64 number [[color("FF0000"), comment("实数 (Double)")]];
        }
        // --- 字符串字面量 ---
        (0x010102 | 0x020102): {
            u32 byte_code [[color("FF00FF")]];
            u16 str_len;
            String16 str_val [[color("00FFFF")]];
        }
        // --- 复数/虚部字面量 ---
        // 和0x000100一起出现
        (0x010503): {
            BytecodeNone bytecode_header;
            u64 imag_number [[color("FF8000"), comment("虚部/复数数值")]];
        }
        // --- 矩阵 ---
        (0x010104): {
            BytecodeMatrix bitcode_matrix;
        }
        // --- 列表 / 向量 ---
        (0x010106): {
            BytecodeDirectory bytecode_dir;
        }
        // --- 变量 ---
        (0x010107 | 0x020107 | 0x030107 | 0x360107 | 0x370107 | 0x400107 | 0x540107 | 0xE90107 | 0x990107): {
            BytecodeNone bytecode_header;
            String16 str_val [[color("00FFFF")]];
        }
        // --- 复杂调用结构 ---
        (0x010108 | 0x010308): {
            BytecodeNone bytecode_header;
        }
        // --- 单位 ---
        (0x010109): {
            u32 byte_code [[color("FF00FF")]];
            u32 addr0;
            u16 magic0;
            String16 str_val [[color("00FFFF")]];
            u24 magic1;
        }
        // --- 目录 ---
        (0x01010A): {
            BytecodeDirectory bytecode_dir;
        }
        // --- 函数定义 ---
        (0x01030B | 0x01010B): {
            BytecodeFunction function_def;
        }
        // --- 默认/未知指令处理 ---
        (_): {
            if ($ + 2 <= limit) {
                le u16 unknown_instruction [[type("hex"), color("C0C0C0")]];
            } 
            else {
                u8 raw_byte [[color("505050")]]; 
            }
        }
    }
};

动态调试-深入分析
#

随着对 BytecodeBlock 的解析日益清晰,一个更深层次的问题浮出水面:这些“对象”在内存中是如何被管理的?既然是栈式虚拟机,那么栈在哪里?系统函数(如 SIN, PRINT)又是如何被索引的?

通过对模拟器内存的动态调试(使用 IDA Pro 配合 ImHex),我发现了一个统管全局的结构体,该指针通常位于全局地址 dword_DECA00。我将其命名为 “上帝对象” (God Object) 或 VM Context

实际上,HPPPL 虚拟机的运行状态并非离散的变量,而是封装在这个巨大的上下文结构中。我们可以将运行时的内存模型大致还原如下:

// 运行时核心结构:上帝对象
struct VM_Context {
    u32 state_flags;       // +0x00: 虚拟机状态标志
    padding[0x14];
    u32 user_env_ptr;      // +0x18: 用户环境指针(指向用户定义的全局变量、导出函数)
    u32 sys_env_ptr;       // +0x1C: 系统环境指针(这是最关键的字典)
    padding[0x354];
    u32 sys_func_count;    // +0x374: 内置系统函数数量
    // ... 栈顶指针、堆内存池等
};

// 系统环境指针指向的“符号表”条目
struct SystemSymbolEntry {
    u32 attributes;        // 参数个数、返回类型
    char* name_ptr;        // 指向 "SIN", "PRINT" 等字符串
    void* native_func_ptr; // 指向底层 C++ 实现代码 (.text段)
};

这就解释了为什么在 BytecodeStreamElement 中,0x08 (Complex Call) 类型的对象只需要存储一个字符串名称(如 “SIN”)。

执行流程的真相:按名绑定(Late Binding)

当虚拟机解析到 0x08 类型的对象时,它并不是直接跳转,而是执行了一次动态查找:

  1. 压栈:将函数描述符入栈。
  2. 查表:拿着对象中的字符串 name,去 sys_env_ptr 指向的系统符号表中遍历。
  3. 调用:找到对应的 native_func_ptr,然后执行底层的 C++ 代码。

为了更直观地展示这种“万物即对象”与“RPN栈机”的结合,我们来还原一个经典场景:PRINT(SIN(1))。 在我们的 BytecodeBlock 中,这段代码会被序列化为以下对象流:

  1. 实数对象 (Type 00): 00 01 00 00 …
    • 动作:直接入栈。此时栈顶:[1.0]。
  2. 函数调用对象 (Type 08): 08 01 00 03 “SIN”
    • 动作:查表找到 math_sin。
    • 执行:弹出 1.0,计算正弦值,结果 0.841 封装成新对象压栈。此时栈顶:[0.841]。
  3. 函数调用对象 (Type 08): 08 01 00 05 “PRINT”
    • 动作:查表找到 io_print。
    • 执行:弹出 0.841,调用图形接口绘制屏幕。
  4. 操作符对象 (Type 05): 05 00 00 0A (Statement End)
    • 动作:这是一个特殊的“无负载”对象,ID 0A 告诉虚拟机语句结束,清理当前栈帧。

结论

至此,HPPPL 的全貌已然清晰:
它是以对象为基本图元以 RPN 为执行逻辑以动态符号表为链接桥梁的栈式虚拟机。

我们在文件中看到的每一个 OpcodeHeader,本质上都是一个冻结的对象。而虚拟机的任务,就是将这些冻结的对象解冻,扔进 VM_Context 维护的那个巨大的栈中,让它们相互作用,最终涌现出复杂的计算逻辑。

这也印证了我最初的猜想:HPPPL 并不是在“运行代码”,而是在“演化对象”。

相关文章