返回

C运行时如何获取函数参数?ARM64 DWARF深度解析

Linux

C 语言运行时如何拿到调用函数的参数?(ARM64 & DWARF 篇)

写 C 代码时,有时我们想在运行时知道,“我是被谁调用的?调用我的时候传了哪些参数?”。这就像 gdb 里的 backtraceinfo args 命令一样,能看到调用栈,还能看到每一层调用时传入的参数名和值。

比如下面这段代码:

// test.c
#include <stdio.h>

// 假设这是我们想实现的回溯+参数打印函数
void my_backtrace_func() {
    printf("Inside my_backtrace_func, let's pretend we are getting caller args...\n");
    // 这里是魔法发生的地方 (还没实现!)
}

void func2(int a, double b, const char* c) {
    printf("Inside func2: a=%d, b=%f, c=\"%s\"\n", a, b, c);
    my_backtrace_func(); // 想在这里获取 func1 调用 func2 时的参数 (5, 1.2, "hello")
                         // 以及 main 调用 func1 时的参数 (1)
}

void func1(int x) {
    printf("Inside func1: x=%d\n", x);
    func2(5, 1.2, "hello");
}

int main() {
    printf("Inside main\n");
    func1(1);
    return 0;
}

我们的目标是在 my_backtrace_func 执行时,能打印出类似这样的信息:

Call stack info:
Frame #1: func2 (a=5, b=1.2, c=0x..... "hello") called from test.c line 15
Frame #2: func1 (x=1) called from test.c line 21
Frame #3: main called from ???

听起来挺酷,但实际做起来,这事儿并不简单,特别是在需要精确获取参数值的时候。假设我们用 gcc -g 在 ARM64 架构上编译,虽然有了调试信息,但路还是有点绕。

这事儿为啥这么难?

主要有几个拦路虎:

  1. 调用约定 (Calling Convention): 不同的架构、不同的操作系统,甚至不同的编译器选项,都会影响函数参数如何传递。在 ARM64 的标准调用约定 (AAPCS64) 下,通常:

    • 前 8 个整型或指针参数会依次放入 x0x7 寄存器。
    • 前 8 个浮点或向量参数会依次放入 v0v7 寄存器。
    • 如果参数更多,或者参数类型复杂(比如大的结构体),就会通过栈来传递。
    • 返回值通常放在 x0 (整型/指针) 或 v0 (浮点)。
  2. 编译器优化: 如果开了优化(比如 -O1, -O2),事情就更复杂了:

    • 参数可能“消失”: 如果函数内没用到某个参数,编译器可能直接优化掉,根本不分配存储空间。
    • 值可能不在“标准”位置: 编译器可能觉得把参数挪个地方(比如从寄存器存到栈上,或者反过来)效率更高。甚至参数的值可能只在某个非常短的生命周期里存在于某个寄存器中。
    • 函数内联 (Inlining): 如果 func2 被内联到 func1 里,那 func2 的参数实际上成了 func1 的局部变量,它们的位置和生命周期完全变了。
  3. 获取参数值的时间点: 我们是在 my_backtrace_func 内部 执行的,此时 func2 已经开始执行,func1 调用 func2 时放在 x0-x7, v0-v7 里的原始参数值,可能已经被 func2 的代码覆盖掉了! 我们需要的是 func1 调用 func2 的那一刻,或者说 func2 刚开始 执行时的参数状态。

  4. DWARF 信息很复杂: 虽然 -g 提供了 DWARF 调试信息,里面确实包含了参数名、类型以及它们“应该”在哪里的 (DWARF Location Descriptions)。但这些本身是一种需要解释执行的小语言(基于栈的虚拟机),它告诉你参数值是存储在某个寄存器里,还是在栈上某个相对于栈指针 (SP) 或帧指针 (FP) 的偏移量处,甚至可能是多个计算步骤的结果。在运行时正确解释这些描述,并结合当前的机器状态(寄存器值、内存值)来算出参数值,是个精细活。

可行的解决方案思路

虽然没有一个能完美覆盖所有情况的“银弹”,但结合调试信息和一些库,我们还是有路可走的。核心思路就是:获取调用栈 -> 找到调用者的信息 -> 利用 DWARF 找到参数位置描述 -> 根据调用当时的机器状态计算参数值

下面介绍几种尝试方向,难度和精确度各不相同。

方案一:利用 libbacktrace (折中方案,主要用于符号化)

GCC 提供了一个 libbacktrace 库,它可以帮助我们获取 C++ 异常处理风格的回溯信息,并且能解析符号名和源码行号(如果编译时带 -g)。

  • 原理和作用: libbacktrace 主要设计用来做符号化的堆栈跟踪。它能遍历调用栈,拿到每一帧的程序计数器 (PC) 值,然后尝试通过读取调试信息(如 DWARF 的 .debug_line section)将其转换为函数名、文件名和行号。

  • 局限: libbacktrace 的强项在于符号解析,对于获取 运行时参数的具体值,它本身提供的直接支持很有限。它可能能通过某些回调给你访问原始 PC 或 Frame Pointer 的机会,但并不能直接告诉你:“参数 a 的值是 5”。你需要自己结合其他方法。

  • 示例 (仅展示基础回溯):

    #include <backtrace.h>
    #include <stdio.h>
    #include <stdlib.h>
    
    struct bt_ctx {
        struct backtrace_state *state;
        int error;
    };
    
    static void error_callback(void *data, const char *msg, int errnum) {
        struct bt_ctx *ctx = (struct bt_ctx *)data;
        fprintf(stderr, "libbacktrace error: %s (%d)\n", msg, errnum);
        ctx->error = 1;
    }
    
    static int simple_callback(void *data, uintptr_t pc) {
        struct bt_ctx *ctx = (struct bt_ctx *)data;
        // 这里只拿到了 PC,可以用下面的 full_callback 获取更多信息
        // 但获取参数值仍然困难
        printf("Frame PC: 0x%lx\n", (unsigned long)pc);
        return 0; // 返回 0 继续回溯
    }
    
    // 更详细的回调,可以获取符号信息
    static int full_callback(void *data, uintptr_t pc,
                             const char *filename, int lineno, const char *function) {
        printf("  at %s (%s:%d) [PC=0x%lx]\n",
               function ? function : "???",
               filename ? filename : "???",
               lineno, (unsigned long)pc);
    
        // **关键点:**  libbacktrace 在这里并没有直接提供访问参数值的机制。
        // 你可能需要在这里结合 libunwind 或其他方式获取当前帧的寄存器状态,
        // 然后再去解析 DWARF。
    
        return 0; // 继续
    }
    
    
    void my_backtrace_func() {
        struct backtrace_state *state = backtrace_create_state(NULL, 1, error_callback, NULL);
        if (!state) {
            fprintf(stderr, "Failed to create backtrace state\n");
            return;
        }
    
        struct bt_ctx ctx = { state, 0 };
        // 使用 full callback 获取符号信息
        backtrace_full(state, 0, full_callback, error_callback, &ctx); 
    
        // 或者用 simple callback 只获取 PC
        // backtrace_simple(state, 0, simple_callback, error_callback, &ctx);
    
        if (ctx.error) {
             fprintf(stderr, "Backtrace encountered an error.\n");
        }
    }
    
    // ... (其他 func2, func1, main 代码照旧) ...
    
    // 编译链接需要: gcc test.c -o test -g -lbacktrace -ldl
    
  • 评价: libbacktrace 对于打印基本的调用栈(函数名、行号)很有用,相对简单。但它不是为提取运行时参数值设计的。可以作为基础,但离目标还差关键一步。

方案二:硬核解析 DWARF + libunwind (最接近目标,但复杂)

这是最有希望实现目标的方法,但也最复杂。它结合了两个强大的工具:

  1. libunwind: 一个用于程序运行时栈回溯(unwinding)的库。它的强项在于,能够精确地获取每一栈帧的上下文信息,包括 程序计数器 (PC)、栈指针 (SP)、帧指针 (FP)(如果可用)以及其他通用寄存器的值 。它通常利用 .eh_frame.debug_frame DWARF section 中的信息来做到这一点,这些信息描述了函数如何设置和拆除其栈帧,以及寄存器是如何保存和恢复的。这对于处理优化后的代码至关重要。
  2. libdwarf (或类似的 DWARF 解析库): 用于读取和解析编译时产生的 DWARF 调试信息(在 .debug_info, .debug_abbrev, .debug_loc, .debug_ranges 等 section 中)。我们可以用它来:
    • 根据 libunwind 得到的 PC 值,在 .debug_info 中找到对应的编译单元 (CU) 和函数定义 (Subprogram DIE - Debugging Information Entry)。
    • 遍历函数定义下的参数 DIE (DW_TAG_formal_parameter)。
    • 获取每个参数的名字 (DW_AT_name) 和类型 (DW_AT_type)。
    • 最关键的是获取参数的位置描述 (DW_AT_location)。
  • 原理和作用:

    • libunwind 负责“倒带”调用栈,并在每一层“暂停”,提供当时的机器状态快照(寄存器值等)。
    • libdwarf 负责查阅“说明书”(DWARF 信息),告诉你在这个状态下,参数 a, b, c 分别记录在哪里(比如 a 在寄存器 x1b 在栈上相对于 Canonical Frame Address (CFA) 偏移 -16 的地方,cx2)。
    • 我们将两者结合:用 libunwind 获取状态,用 libdwarf 获取位置描述,然后在当前函数中 ,模拟执行 DWARF 的位置描述(它是一个基于栈的操作序列),从 libunwind 提供的寄存器快照和内存中读取数据,最终得到参数的值。
  • 实现步骤概要:

    1. 初始化 libunwind: 使用 unw_getcontext() 获取当前上下文,unw_init_local() 初始化栈回溯游标 (cursor)。
    2. 遍历调用栈: 使用 unw_step() 向上一层栈帧移动。
    3. 获取当前帧信息: 对每个栈帧(从调用者开始,所以第一次 unw_step 后是 func2 的调用者 func1),用 unw_get_reg() 获取指令指针 (IP/PC) 和其他可能需要的寄存器(如 x0-x7, SP, FP)。用 unw_get_proc_name() 等可以获取函数名(类似 libbacktrace)。
    4. 初始化 libdwarf: 加载包含调试信息的目标文件(通常是程序自身)。
    5. 查找 DWARF 信息:
      • 根据 libunwind 给出的 PC 值,使用 libdwarf 的功能找到对应的编译单元 (CU) Header。
      • 在 CU 中查找包含该 PC 的函数定义 (Subprogram DIE)。
      • 遍历该函数下的 DW_TAG_formal_parameter 子 DIE。
    6. 获取并解释 DW_AT_location:
      • 对每个参数 DIE,读取 DW_AT_location 属性。这个属性的值可能是一个指向 .debug_loc section 的偏移量(表示位置随 PC 变化),或者直接就是一个 DWARF 表达式。
      • 这个 DWARF 表达式定义了如何计算参数的地址或值。它可能涉及:
        • 寄存器操作:DW_OP_regX(n) 表示值在寄存器 n (ARM64 下 n=0x0, ..., n=30x30, n=64v0 等等,具体编号看 DWARF 标准和实现)。
        • CFA 相对寻址:DW_OP_fbreg(offset) 表示地址是 CFA 加上 offset。CFA (Canonical Frame Address) 通常是调用该函数时的栈指针,libunwind 可以帮你算出当前帧的 CFA。
        • 帧指针相对寻址:如果用了帧指针 (FP/x29),可能有类似的操作。
        • 内存读取:DW_OP_deref 从计算出的地址读取值。
        • 还有更复杂的栈操作、常量、计算等。
    7. 求值: 这是最难的部分。你需要一个 DWARF 表达式求值器。这个求值器需要知道当前栈帧的寄存器值(从 libunwind 获取)和 CFA,然后模拟执行 DWARF 字节码,最终得到参数的地址或直接值。对于指针,还需要额外一步解引用来获取指向的数据(比如字符串)。对于浮点数,需要读取对应的 v 寄存器或内存位置。
    8. 获取类型信息: 读取参数的 DW_AT_type 属性,它可以指向一个描述类型的 DIE (如 DW_TAG_base_type, DW_TAG_pointer_type),让你知道参数是 int, double, char * 还是其他,以便正确解释读取到的字节。
  • 代码示例 (概念性,高度简化):

    #include <libunwind.h>
    #include <libdwarf.h> // 假设我们用了 libdwarf
    #include <fcntl.h>
    #include <stdio.h>
    // ... 其他头文件
    
    void evaluate_dwarf_location(unw_cursor_t *cursor, Dwarf_Attribute *loc_attr, Dwarf_Debug dbg) {
        // 这是最复杂的部分:解析 loc_attr 中的 DWARF 表达式
        // 需要 DWARF 表达式求值器,并能从 cursor 获取寄存器/CFA
    
        // 伪代码:
        // 1. 获取 DWARF 表达式 (可能是 loclist 或直接表达式)
        // DWARF_expr_op *ops; size_t op_count;
        // dwarf_get_location_op_list(loc_attr, &ops, &op_count); 
    
        // 2. 初始化求值栈
        // Stack evaluation_stack;
    
        // 3. 遍历 ops 执行操作
        // for (op : ops) {
        //    switch(op.atom) {
        //       case DW_OP_regX(n): 
        //          unw_word_t reg_val; 
        //          unw_get_reg(cursor, DWARF_ARM64_REG_MAP(n), &reg_val); // 需要映射
        //          push(evaluation_stack, reg_val); 
        //          break;
        //       case DW_OP_fbreg(offset):
        //          unw_word_t cfa; 
        //          unw_get_reg(cursor, UNW_REG_SP, &cfa); // 简化,实际CFA获取可能更复杂
        //          // 或 unw_get_cfa(cursor, &cfa); - libunwind 1.3+ 才比较好用
        //          push(evaluation_stack, cfa + offset); // 计算地址
        //          break;
        //       case DW_OP_deref:
        //          addr = pop(evaluation_stack);
        //          // 需要知道参数类型大小来读取内存
        //          value = read_memory(addr, size_from_type_info); 
        //          push(evaluation_stack, value);
        //          break;
        //       // ... 处理其他几十种 DW_OP_xxx 操作 ...
        //    }
        // }
    
        // 4. 栈顶就是结果 (地址或值)
        // result = top(evaluation_stack);
    
        // 5. 根据参数类型打印结果
        // print_value(result, param_type_info);
    
        printf("  [DWARF evaluation needed, complex!]\n"); 
    }
    
    
    void my_backtrace_func() {
        unw_cursor_t cursor;
        unw_context_t context;
    
        unw_getcontext(&context);
        unw_init_local(&cursor, &context);
    
        // 打开包含 DWARF 信息的文件 (通常是自身)
        int fd = open("/proc/self/exe", O_RDONLY);
        Dwarf_Debug dbg = 0;
        Dwarf_Error err;
        if (dwarf_init(fd, DW_DLC_READ, NULL, NULL, &dbg, &err) != DW_DLV_OK) {
             fprintf(stderr, "Failed to init libdwarf\n");
             close(fd);
             return;
        }
    
        printf("Call stack info:\n");
        int frame_num = 0;
        while (unw_step(&cursor) > 0) {
            frame_num++;
            unw_word_t pc;
            unw_get_reg(&cursor, UNW_REG_IP, &pc);
    
            char func_name[256];
            unw_word_t offset;
            if (unw_get_proc_name(&cursor, func_name, sizeof(func_name), &offset) == 0) {
                printf("Frame #%d: %s (+0x%lx) [PC=0x%lx]\n", frame_num, func_name, offset, pc);
            } else {
                printf("Frame #%d: ??? [PC=0x%lx]\n", frame_num, pc);
            }
    
            // --- 尝试获取参数 ---
            // 这里需要大量 libdwarf 代码:
            // 1. 根据 PC (注意: 应该是调用点 PC,即当前 PC 减去指令长度,或者上一帧的 PC) 找到 CU 和 Subprogram DIE
            // Dwarf_Die func_die = find_function_die_by_pc(dbg, pc); // 伪函数
            // if (func_die) {
            //    Dwarf_Die child_die;
            //    if (dwarf_child(func_die, &child_die, &err) == DW_DLV_OK) {
            //        do {
            //            Dwarf_Half tag;
            //            if (dwarf_tag(child_die, &tag, &err) == DW_DLV_OK && tag == DW_TAG_formal_parameter) {
            //                char *param_name;
            //                if (dwarf_diename(child_die, &param_name, &err) == DW_DLV_OK) {
            //                     printf("  Param: %s = ", param_name);
            //                     dwarf_dealloc(dbg, param_name, DW_DLA_STRING);
            //                     
            //                     Dwarf_Attribute loc_attr;
            //                     if (dwarf_attr(child_die, DW_AT_location, &loc_attr, &err) == DW_DLV_OK) {
            //                          evaluate_dwarf_location(&cursor, &loc_attr, dbg); // 关键求值步骤
            //                          dwarf_dealloc(dbg, loc_attr, DW_DLA_ATTR);
            //                     } else {
            //                          printf("[No location info?]\n");
            //                     }
            //                }
            //                // 获取类型信息 DW_AT_type ...
            //            }
            //            // 遍历兄弟节点...
            //        } while (/* ... more siblings ... */); 
            //    }
            //} else { printf("  Could not find DWARF info for this frame.\n"); }
    
            printf("  (Parameter extraction requires full DWARF parsing and evaluation)\n"); // 实际需要实现上面的逻辑
        }
    
        dwarf_finish(dbg, &err);
        close(fd);
    }
    
    // 编译链接可能需要: gcc test.c -o test -g -lunwind -ldwarf -Wl,--eh-frame-hdr
    
  • 安全建议: 读取 DWARF 信息计算出的地址时要格外小心。错误的 DWARF 信息、栈损坏、或者优化导致的复杂情况可能让你读到无效内存,导致程序崩溃。务必做好边界检查和错误处理。

  • 进阶技巧:

    • 处理 Location Lists: DW_AT_location 可能指向 .debug_loc section 的一个列表,表示参数的位置随 PC 的变化而变化。你需要根据当前帧的 PC 查找正确的 DWARF 表达式。
    • 优化代码: 优化开启时 (-O2),变量的位置更可能在寄存器和栈之间跳动,或者只在很短的时间内有效。DWARF Location List 就是用来描述这种情况的。libunwind 能帮助恢复寄存器状态,但准确性仍受编译器生成信息的质量影响。
    • CFA 计算: libunwind 通常能通过 .eh_frame 计算出 CFA。理解 CFA 对于解释 DW_OP_fbreg 等操作至关重要。
    • 异步信号安全: 如果你在信号处理器中做这个,所有使用的函数(包括 libunwindlibdwarf 的某些部分)需要是异步信号安全的,这通常很难满足。libunwind 有一些针对本地、同步使用的函数 (unw_init_local),比跨进程的 (unwind_init_remote) 简单些。
  • 评价: 这是最接近 gdb info args 功能的方法,理论上可行,精度最高。但实现极其复杂,需要深入理解 DWARF 格式、ARM64 调用约定以及 libunwind 的工作原理。调试这种代码本身就像在写一个小型的调试器。

方案三:利用 Ptrace (外部进程方式,类似 GDB 自身)

如果你不排斥创建一个单独的“监控”进程,或者你的 my_backtrace_func 本身就是在一个类似调试器的环境里运行,那么可以考虑使用 ptrace 系统调用。

  • 原理和作用: ptrace 允许一个进程(tracer)控制另一个进程(tracee)。Tracer 可以暂停 tracee,检查其寄存器 (PTRACE_GETREGS / PTRACE_GETREGSET),读取其内存 (PTRACE_PEEKDATA),然后结合 DWARF 信息(在 tracer 进程中解析)来找出参数值。这基本上就是 GDB 的工作方式。
  • 局限:
    • 不是内部解决方案: 它要求有一个外部进程来执行监控和分析。
    • 性能开销大: 每次 ptrace 调用都会涉及上下文切换,暂停目标进程,开销远大于内部调用。
    • 复杂性: 仍然需要 DWARF 解析和位置表达式求值逻辑,只是执行环境换到了 tracer 进程。
  • 评价: 如果你真的在写一个调试工具,这是标准做法。但如果只是想在普通程序内部加一个功能,这个方法就有点“重”了,而且改变了程序的运行模式。

总结一下

在 C 语言运行时(尤其是在 ARM64 上),想要精确获取调用栈上任意一层函数的参数值,是一个相当棘手的问题。主要挑战在于参数传递位置的多变性(调用约定、编译器优化)以及 DWARF 信息的复杂性。

  • 简单方法 (如 libbacktrace) 只能提供基本的符号信息(函数名、行号),无法直接获取参数值。
  • 最可靠的内部方法 是结合 libunwind (用于获取准确的运行时栈帧状态和寄存器值)和 DWARF 解析库 (如 libdwarf) (用于查找参数的位置描述)。但这条路需要深厚的技术功底,特别是 DWARF 位置表达式的求值部分。
  • 外部方法 (如 ptrace) 功能强大,类似 GDB,但改变了程序运行模型且性能开销大。

对于最初的问题,考虑到要求和复杂度,最直接但艰难的路径就是 libunwind + libdwarf 。如果你只是想打印调用栈,libbacktrace 可能是个不错的起点。如果你需要 100% 的精确度和鲁棒性,并且愿意接受其复杂性,那么投入时间去研究和实现 DWARF 解析与求值是必经之路。

这活儿确实不是给新手准备的,但搞定它绝对能让你对程序运行时、编译器优化和调试信息的理解提升一大截!