C/C++C语言runtime调用技术

Posted 与光同程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C/C++C语言runtime调用技术相关的知识,希望对你有一定的参考价值。

概述

C语言编译后,在可执行文件中会有 函数名信息。如果想要动态调用一个C函数,首先需要 根据函数名找到这个函数地址 ,然后根据函数地址进行调用。

动态链接器已经提供一个 API:dlsym(),可以通过函数名字拿到函数地址:

void test() 
  printf("testFunc");


int main() 
  void (*funcPointer)() = dlsym(RTLD_DEFAULT, "test");
  funcPointer();
  return 0;

从上面代码中可以看出,test方法是没有返回值和参数的。所以funcPointer只能在指向参数和返回值都是空的函数时才能正确调用到。
对于有返回值和有参数的C函数,需要指明参数和返回值类型才能使用。

int testFunc(int n, int m) 
  printf("testFunc");
  return 1;


int main() 
  // ① 表示正确定义了函数参数/返回值类型的函数指针
  int (*funcPointer)(int, int) = dlsym(RTLD_DEFAULT, "testFunc");
  funcPointer(1, 2);

  // ② 表示没有正确定义参数/返回值类型的函数指针
  void (*funcPointer)() = dlsym(RTLD_DEFAULT, "testFunc");
  funcPointer(1, 2); //error

  return 0;

不同的函数都有不同的参数和返回值类型,也就没办法通过一个万能的函数指针去支持所有函数的动态调用,必须要让函数的参数/返回值类型都对应上才能调用。因为函数的调用方和被调用方会遵循一种约定:调用惯例(Calling Convention)。

当然我们也不是没有应对方法,我们可以将所有参数设置为void* 指针,然后强行
将所有类型参数转化为void* 调用,就像这样:

typedef uint64_t (*easycall_func_t)(void); 
typedef uint64_t (*easycall_func_null_t)(void); 
typedef uint64_t (*easycall_func_args_t)(void*,...); 
int easycall_call_function(easycall_func_info_t easycall_func_info)

    int i=0 ;
    uint64_t fun_ret;
    easycall_func_t func=NULL,temp;
    void *arg[10];
    easycall_func_null_t temp_func0;
    easycall_func_args_t temp_func1;

    for(i = 0 ; i< g_easycall_lib_info_table.lib_index ; i++ )
    
        temp=easycall_find_symbols(g_easycall_lib_info_table.lib_table[i],easycall_func_info.name);
        if(temp!=NULL)func = temp;
    
    if(func==NULL)
        ECALL_PRINT_LN("don't find such func");
        return -1;
    
    for(i=0 ; i<easycall_func_info.pera_nums;i++)
    
        if(easycall_func_info.pera_type[i] == EASYCALL_PERA_INTER)
            arg[i] = (void*)easycall_func_info.pera[i].arg_inter;
        else
            arg[i] = (void*)easycall_func_info.pera[i].arg_str;
        
    
    switch (easycall_func_info.pera_nums)
    
    case 0:
        temp_func0=(easycall_func_null_t)func;
        fun_ret=temp_func0();
        break;
    case 1:
        temp_func1=(easycall_func_args_t)func;
        fun_ret=temp_func1(arg[0]);
        break;   
    case 2:
        temp_func1=(easycall_func_args_t)func;
        fun_ret=temp_func1(arg[0],arg[1]);
        break;  
    case 3:
        temp_func1=(easycall_func_args_t)func;
        fun_ret=temp_func1(arg[0],arg[1],arg[2]);
        break;  
    case 4:
        temp_func1=(easycall_func_args_t)func;
        fun_ret=temp_func1(arg[0],arg[1],arg[2],arg[3]);
        break;  
    case 5:
        temp_func1=(easycall_func_args_t)func;
        fun_ret=temp_func1(arg[0],arg[1],arg[2],arg[3],arg[4]);
        break;  
    case 6:
        temp_func1=(easycall_func_args_t)func;
        fun_ret=temp_func1(arg[0],arg[1],arg[2],arg[3],arg[4],arg[5]);
        break;  
    case 7:
        temp_func1=(easycall_func_args_t)func;
        fun_ret=temp_func1(arg[0],arg[1],arg[2],arg[3],arg[4],arg[5],arg[6]);
        break;  
    case 8:
        temp_func1=(easycall_func_args_t)func;
        fun_ret=temp_func1(arg[0],arg[1],arg[2],arg[3],arg[4],arg[5],arg[6],arg[7]);
        break;  
    case 9:
        temp_func1=(easycall_func_args_t)func;
        fun_ret=temp_func1(arg[0],arg[1],arg[2],arg[3],arg[4],arg[5],arg[6],arg[7],arg[8]);
        break;  
    case 10:
        temp_func1=(easycall_func_args_t)func;
        fun_ret=temp_func1(arg[0],arg[1],arg[2],arg[3],arg[4],arg[5],arg[6],arg[7],arg[8],arg[9]);
        break; 
    default:
        ECALL_PRINT_LN("too much perameteas");
        return -1;
    
    ECALL_PRINT_LN("%s return: 0x%lx(%ld)",easycall_func_info.name,(uint64_t)fun_ret,(uint64_t)fun_ret);

    return 0;


但是检测的调用也是会出现很多问题的。

调用惯例

高级编程语言的函数在调用时,需要约定好参数的传递顺序、传递方式,栈维护的方式,名字修饰。这种函数调用者和被调用者对函数如何调用的约定,就叫作调用惯例(Calling Convention)。高级语言编译时,会生成遵循调用惯例的汇编代码。

参数传递方式
调用函数时,参数可以选择使用栈或者使用寄存器进行传递
参数传递顺序
参数压栈的顺序可以从左到右也可以从右到左
栈维护方式
函数调用后参数从栈弹出可以由调用方完成,也可以由被调用方完成

在日常工作中,比较少接触到这个概念。因为编译器已经帮我们完成了这一工作,我们只需要遵循正确的语法规则即可,编译器会根据不同的架构生成对应的汇编代码,从而确保函数调用约定的正确性。

函数调用者和被调用者需要遵循这同一套约定,上述②,就是函数本身遵循了这个约定,而调用者没有遵守,导致调用出错。

以上面例子简单分析下,如果按①那样正确的定义方式定义funcPointer,然后调用它,这里编译成汇编后,在调用处会有相应指令把参数 n,m 的值 1 和 2 入栈(这里是举例),然后跳过去 testFunc()函数实体执行,这个函数执行时,按约定它知道n,m两个参数值已经在栈上,就可以取出来使用了:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-82a7oLXS-1670083113664)(image/C语言runtime调用技术/1667789573132.png)]

而如果按②那样定义,编译后这里不会把参数 n,m 的值 1 和 2 入栈,因为这里编译器把它当成了没有参数和没有返回值的函数,也就不需要进行参数入栈的操作,然后在 testFunc()函数实体里按约定去栈上取参数时就会发现栈上本来应该存参数 n 和 m 的地方并没有数据,或者是其他错误的数据,导致调用出错:

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-GxsQRlIO-1670083113664)(image/C语言runtime调用技术/1667789630625.png)]

也就是说如果需要动态调用任意 C 函数,有一种笨方案就是事先准备好任意参数类型/参数个数/返回值类型 排列组合的 C 函数指针,让最终的汇编把所有情况都准备好,最后调用时通过 switch 去找到正确的那个去执行就可以了😭。

这个时候我们就需要做两件事情,我们需要可以从我们的ELF文件中获取我们需要调用程序的完整信息。包括函数名,函数入参,函数返回值。然后通过汇编级的设置调用,调用出我们需要使用的函数。

ELF文件格式分析

ELF 基本数据类型

名称大小说明
Elf32_Addr4无符号程序地址
Elf32_Half2无符号中等整数
Elf32_Off4无符号文件偏移
Elf32_SWord4有符号大整数
Elf32_Word4无符号大整数
unsigned char1无符号笑整数

整体结构

概述

  • ELF头部(ELF_Header) : 每个ELF文件都必须存在一个ELF_Header,这里存放了很多重要的信息用来描述整个文件的组织,如: 版本信息,入口信息,偏移信息等。程序执行也必须依靠其提供的信息。
  • 程序头部表(Program_Header_Table) : 可选的一个表,用于告诉系统如何在内存中创建映像,在图中也可以看出来,有程序头部表才有段,有段就必须有程序头部表。其中存放各个段的基本信息(包括地址指针)。
  • 节区头部表(Section_Header_Table) : 类似与Program_Header_Table,但与其相对应的是节区(Section)。
  • 节区(Section) : 将文件分成一个个节区,每个节区都有其对应的功能,如符号表,哈希表等。
  • 段(Segment) : 嗯…就是将文件分成一段一段映射到内存中。段中通常包括一个或多个节区

注:每个节区都应该是前后相连的,且不可有重叠。即在一个地址上的字节只能属于一个节区

ELF_Header

#define EI_NIDENT 16

typedef struct

  unsigned char  e_ident[EI_NIDENT];

  Elf32_Half e_type;

  Elf32_Half e_machine;

  Elf32_Word e_version;

  Elf32_Addr e_entry;

  Elf32_Off e_phoff;

  Elf32_Off e_shoff;

  Elf32_Word e_flags;

  Elf32_Half e_ehsize;

  Elf32_Half e_phentsize;

  Elf32_Half e_phnum;

  Elf32_Half e_shentsize;

  Elf32_Half e_shnum;

  Elf32_Half e_shstrndx;

Elf32_Ehdr;

下面分别介绍各个字段含义:

  • e_ident 这是一个数组,其每个字节又都有所代表的含义: [外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-TLSf4LUC-1670083113665)(http://www.hoimk.com/usr/uploads/2017/03/3262651834.png)]

    EI_MAG0 - EI_MAG3 文件标识就是平时所说的ELF头,即 7F 45 4C 46(ELF)

    EI_CLASS 文件类,其实代表的是32位/64位程序

    取值代表含义
    01ELFCLASS3232位程序
    02ELFCLASS6464位程序

    EI_DATA 数据编码,一般都是01[td]

    取值代表含义
    01ELFDATA2LSB高位在前
    02ELFDATA2MSB低位在前

    EI_VERSION 文件版本,固定值01 EV_CURRENT

    EI_PAD 呃…就是一堆全是00的用来补全大小的数组

    EI_NIDENT 说是e_ident数组的大小,但我看了好几个so都是00

  • e_type 标识文件类型

    取值代表含义
    00ET_NONE未知文件类型格式
    01ET_REL可重定位文件
    02ET_EXEC可执行文件
    03ET_DYN共享目标文件(SO)
    04
  • e_machine 声明ABI

    取值代表含义
    01
    03EM_386X86
    04
    28hEM_ARMarm
    29h
  • e_version 跟ident[]里的EI_VERSION一样,为01

  • e_entry 可执行程序入口点地址。

  • e_phoff Program Header Offset,程序头部表索引地址,没有则为0。

  • e.shoff Section Header Offset,节区表索引地址,没有则为0。

  • e_flags 保存与文件相关的,特定于处理器的标志。(不知道有什么用,看了几个arm都是00 00 00 05,x86都是0)。

  • e_ehsize ELF_Header Size,嗯…ELF头部的大小

  • e_phentsize 程序头部表的单个表项的大小

  • e_phnum 程序头部表的表项数

  • e_shentsize 节区表的单个表项的大小

  • e_shnum 节区表的表项数

  • e_shstrndx String Table Index,在节区表中有一个存储各节区名称的节区(通常是最后一个),这里表示名称表在第几个节区。

Program Header

在ELF_Header中,我们可以得到Program Header的 索引地址(e_phoff)段数量(e_phnum)表项大小(e_phentsize)

typedef struct
 
    Elf32_Word p_type;
 
    Elf32_Off p_offset;
 
    Elf32_Addr p_vaddr;
 
    Elf32_Addr p_paddr;
 
    Elf32_Word p_filesz;
 
    Elf32_Word p_memsz;
 
    Elf32_Word p_flage;
 
    Elf32_Word p_align;
 
 Elf32_phdr;
  • p_type 声明此段的作用类型

    取值代表含义
    00PT_NULL此数组元素未用。结构中其他成员都是未定义的。
    01PT_LOAD此数组元素给出一个可加载的段,段的大小由 p_filesz 和 p_memsz 描述。文件中的字节被映射到内存段开始处。如果 p_memsz 大于 p_filesz,“剩余”的字节要清零。p_filesz 不能大于 p_memsz。可加载的段在程序头部表格中根据 p_vaddr 成员按升序排列。
    02PT_DYNAMIC数组元素给出动态链接信息。
    03PT_INTERP数组元素给出一个 NULL 结尾的字符串的位置和长度,该字符串将被当作解释器调用。这种段类型仅对与可执行文件有意义(尽管也可能在共享目标文件上发生)。在一个文件中不能出现一次以上。如果存在这种类型的段,它必须在所有可加载段项目的前面。
    04PT_NOTE此数组元素给出附加信息的位置和大小。
    05PT_SHLIB此段类型被保留,不过语义未指定。包含这种类型的段的程序与 ABI不符。
    06PT_PHDR此类型的数组元素如果存在,则给出了程序头部表自身的大小和位置,既包括在文件中也包括在内存中的信息。此类型的段在文件中不能出现一次以上。并且只有程序头部表是程序的内存映像的一部分时才起作用。如果存在此类型段,则必须在所有可加载段项目的前面。
    0x70000000PT_LOPROC此范围的类型保留给处理器专用语义。
    0x7fffffffPT_HIPROC此范围的类型保留给处理器专用语义。

    还有一些编译器或者处理器标识的段类型,有待补充。

  • p_offset 段相对于文件的索引地址

  • p_vaddr 段在内存中的虚拟地址

  • p_paddr 段的物理地址

  • p_filesz 段在文件中所占的长度

  • p_memsz 段在内存中所占的长度

  • p_flage 段相关标志(read、write、exec)

  • p_align 字节对其,p_vaddr 和 p_offset 对 p_align 取模后应该等于0。

Section Header Table

与Progarm Header类似,我们同样可以从ELF Header中得到 索引地址(e_shoff)节区数量(e_shnum)表项大小(e_shentsize) ,还可以由 名称节区索引(e_shstrndx) 得到各节区的名称。

Section Header Table 表项结构定义:

typedef struct
 
    Elf32_Word sh_name;
 
    Elf32_Word sh_type;
 
    Elf32_Word sh_flags;
 
    Elf32_Addr sh_addr;
 
    Elf32_Off sh_offset;
 
    Elf32_Word sh_size;
 
    Elf32_Word sh_link;
 
    Elf32_Word sh_info;
 
    Elf32_Word sh_addralign;
 
    Elf32_Word sh_entsize;
 
Elf32_Shdr;
 

sh_name 节区名称,此处是一个在名称节区的索引。

sh_type 节区类型

名称取值说明
SHT_NULL0此值标志节区头部是非活动的,没有对应的节区。此节区头部中的其他成员取值无意义。
SHT_PROGBITS1此节区包含程序定义的信息,其格式和含义都由程序来解释。
SHT_SYMTAB2此节区包含一个符号表。目前目标文件对每种类型的节区都只能包含一个,不过这个限制将来可能发生变化。一般,SHT_SYMTAB 节区提供用于链接编辑(指 ld 而言)的符号,尽管也可用来实现动态链接。
SHT_STRTAB3此节区包含字符串表。目标文件可能包含多个字符串表节区。
SHT_RELA4此节区包含重定位表项,其中可能会有补齐内容(addend),例如 32 位目标文件中的 Elf32_Rela 类型。目标文件可能拥有多个重定位节区。
SHT_HASH5此节区包含符号哈希表。所有参与动态链接的目标都必须包含一个符号哈希表。目前,一个目标文件只能包含一个哈希表,不过此限制将来可能会解除。
SHT_DYNAMIC6此节区包含动态链接的信息。目前一个目标文件中只能包含一个动态节区,将来可能会取消这一限制。
SHT_NOTE7此节区包含以某种方式来标记文件的信息。
SHT_NOBITS8这种类型的节区不占用文件中的空间,其他方面和 SHT_PROGBITS 相似。尽管此节区不包含任何字节,成员sh_offset 中还是会包含概念性的文件偏移
SHT_REL9此节区包含重定位表项,其中没有补齐(addends),例如 32 位目标文件中的 Elf32_rel 类型。目标文件中可以拥有多个重定位节区。
SHT_SHLIB10此节区被保留,不过其语义是未规定的。包含此类型节区的程序与 ABI 不兼容。
SHT_DYNSYM11作为一个完整的符号表,它可能包含很多对动态链接而言不必要的符号。因此,目标文件也可以包含一个 SHT_DYNSYM 节区,其中保存动态链接符号的一个最小集合,以节省空间。
SHT_LOPROC0X70000000这一段(包括两个边界),是保留给处理器专用语义的。
SHT_HIPROC0X7FFFFFFF这一段(包括两个边界),是保留给处理器专用语义的。
SHT_LOUSER0X80000000此值给出保留给应用程序的索引下界。
SHT_HIUSER0X8FFFFFFF此值给出保留给应用程序的索引上界。

sh_flags 同Program Header的p_flags

sh_addr 节区索引地址

sh_offset 节区相对于文件的偏移地址

sh_size 节区的大小

sh_link 此成员给出节区头部表索引链接。

sh_info 此成员给出附加信息。

sh_typesh_linksh_info
SHT_DYNAMIC此节区中条目所用到的字符串表格的节区头部索引0
SHT_HASH此哈希表所适用的符号表的节区头部索引0
SHT_REL、SHT_RELA相关符号表的节区头部索引重定位所适用的节区的节区头部索引
SHT_SYMTAB、SHT_DYNSYM相关联的字符串表的节区头部索引最后一个局部符号(绑定 STB_LOCAL)的符号表索引值加一
其它SHN_UNDEF0

部分系统节区作用详解

字符串表

在一个ELF文件中通常拥有一个或以上的字符串表,即类型为 SHT_STRTAB 的节区,如: ELF Header 中 e_shstrndx 索引的 节区名称表(.shstrtab)、符号名称表(.dynstr) 等。

对于字符串的定义,是以NULL(\\0)开头,以NULL结尾。

以一个.shstrtab表的内容为例:

00 2E 73 68 73 74 72 74 61 62 00 2E 69 6E 74 65 72 70 00 2E 64 79 6E 73 79 6D 00 …

从这里可以得到3个字符串即:

  • 2E 73 68 73 74 72 74 61 62 (.shstrtab);
  • 2E 69 6E 74 65 72 70 (.interp);
  • 2E 64 79 6E 73 79 6D (.dynsym);

假如索引为0,那么字符串的内容就是 2E 73 68 73 74 72 74 61 62 (.shstrtab)

符号表

符号: 指函数或者数据对象等。
既然叫做表,那么也分为一个一个表项,其表项也有自己的结构定义:


typedef struct 
 
    Elf32_Word st_name;
 
    Elf32_Addr st_value;
 
    Elf32_Word st_size;
 
    unsigned char st_info;
 
    unsigned char st_other;
 
    Elf32_Half st_shndx;
 
 Elf32_sym;

代码段

代码段就是 存放指令的节区(.text) ,符号表中的 st_value 指向代码段中具体的函数地址,以其地址的指令为函数开头。

全局偏移表

指.got节区,.got内的值均为 Elf32_Addr。其为全局符号提供偏移地址(指向过程链接表)。

过程链接表

.plt节区,其每个表项都是一段代码,作用是跳转至真实的函数地址

哈希表

指.hash节区。

数据段

.data、.bss、.rodata都属于数据段。其中,

  • .data 存放已初始化的全局变量、常量。
  • .bss 存放未初始化的全局变量,所以此段数据均为0,仅作占位。
  • .rodata 是只读数据段,此段的数据不可修改,存放常量。

.init_array .fini_array

程序运行时,执行.init_array中的指令。
程序退出时,执行.fini_array中的指令。

获取ELF文件格式的符号表

基于以上的知识,我们就可以通过将elf文件映射成内存,然后通过结构体指针,解析出ELF文件的符号表

/**
 * @function: elf64_load_symbols
 * @description: 64位 elf格式解析
 * @param char * elf_path
 * @return *
 * @author: yuri
 */
static int easycall_elf64_load_symbols(easycall_lib_info_t * share_lib_info)

    int i=0,j=0,ret=0;
    int elf_fd,length;
    const Elf64_Ehdr *header;       //ELF整体结构
    const Elf64_Shdr *sTable;       //节表
    const Elf64_Sym *symbolTable;   //符号表
    char *symbolSTable;             //符号字符串表
    Elf64_Xword sectionNumber;
    int symbolENumber;
    char* programMMap;
    struct stat st;
    elf_fd = open(share_lib_info->path , O_RDONLY);
    fstat(elf_fd, &st);
    length=st.st_size;
    programMMap =  (char*)mmap(NULL, length, PROT_READ, MAP_PRIVATE, elf_fd, 0);
    header = (Elf64_Ehdr *)programMMap;
    sTable = (Elf64_Shdr *)(programMMap + header->e_shoff);
    sectionNumber = sTable->sh_size;
    if (sectionNumber == 0)
    
        sectionNumber = header->e_shnum;
    
    if(g_ecall_debug_enable)
        ret=easycall_elf64_show_libinfo(header);
        if(ret < 0)
        
            ret = -1;
            goto easycall_elf64_load_symbols_err;  
        
    
    share_lib_info->symbol_table.symtab_nums=0;
    share_lib_info->symbol_table.dynsym_nums=0;
    for ( i = 0; i < sectionNumber; ++i)
    
        if (sTable[i].sh_type == SHT_SYMTAB)
        
            symbolTable = (Elf64_Sym *)(&programMMap[sTable[i].sh_offset]);
            symbolENumber = sTable[i].sh_size / sTable[i].sh_entsize;
            symbolSTable = (char *)(&programMMap[sTable[i + 1].sh_offset]);
            share_lib_info->symbol_table.symtab_nums=symbolENumber;
            share_lib_info->symbol_table.symtab=(easycall_symbol_info_t*)malloc(sizeof(easycall_symbol_info_t)*symbolENumber );
            if(share_lib_info->symbol_table.symtab==NULL)
                ECALL_DEBUG("malloc error");
                ret = -1;
                goto easycall_elf64_load_symbols_err;
            
            for ( j = 0; j < symbolENumber; ++j)
            
                share_lib_info->symbol_table.symtab[j].index=j+1;
                share_lib_info->symbol_table.symtab[j].value=symbolTable[j].st_value;
                share_lib_info->symbol_table.symtab[以上是关于C/C++C语言runtime调用技术的主要内容,如果未能解决你的问题,请参考以下文章

JNI

ios开发新手浅谈强大的runtime机制

Android高级NDK/JNI编程技术基础介绍

C Runtime Library的来历

c# 调用c dll void 指针类型转化问题

iOS Runtime理解