从glibc中backtrace实现看gcc内联汇编

一、glibc中powerpc的backtrace实现
这个函数是C库提供的堆栈回溯功能,实现原理也不复杂,就是从堆栈中寻找函数返回地址,只是powerpc里使用了内联汇编。
int
__backtrace (void **array, int size)
{
  struct layout *current;
  int count;

  /* Force gcc to spill LR.  */
  asm volatile ("" : "=l"(current));这个是首先遇到的一个内联汇编,具体是什么意思,等看了这些语句之后再回头解释。

  /* Get the address on top-of-stack.  */
  asm volatile ("lwz %0,0(1)" : "=r"(current));这里有一个简单的gcc扩展内联汇编指令,这个指令就是把栈低的地址放入current变量中,因为r1寄存器本身是指向栈顶的指针。这一点和386不同,386使用了ebp指向堆栈的栈底,而esp指向栈顶,powerpc中省掉了ebp,所以一上来就要进行一次间接寻址。
这里从powerpc引入的backtrace,然后引入内联汇编,但是我同样关注了一下386的实现,有不少宏,看起来更复杂一下,其中的_Unwind_Backtrace实现就让人找不到是在哪里。我就随便找了个函数链接了一下,通过可执行文件的map文件找到了这个函数实现的位置是位于gcc的一个库中libgcc_eh.a中,再详细的说就是位于库中的unwind-dw2.o文件内。这里做个备案,主要实现代码为gcc-4.1.0gccunwind.inc和unwind-gcc-4.1.0unwind-dw2.c文件中,具体代码不再分析,但是感觉是跟dwarf文件格式有关,其中的fde我在之前一篇说明该文件格式的日志中说过,不过我现在差不多忘了。
TIPS:使用gcc生成map文件的方法:
gcc -Wl,-Map,map.txt
二、内联汇编的格式
1、作为属性说明
对变量或者函数属性的说明,其结构为简单的asm(“string”)形式,它没有输出部分,输入部分和修改部分,只是孤零零的作为一个属性。这个说明的意思和下面即将说明的语句具有不同的应用场景,所谓属性不直接对应代码,而是依附于一个变量的存在而存在,而语句则为实实在在的指令序列。
这里的属性又可以分为符号重命名说明和特殊寄存器使用(绑定)说明,
①、符号名绑定说明
这个主要是为了避免使用gcc默认的符号生成规则,例如,对于gcc生成的符号,它在目标文件中的名字和在源文件中的名字是一样的。int main()在生成的目标文件中就叫做main,但是如果需要和其它的一些编译器生成的目标进行链接,而这些编译器偏偏使用的不是这种格式,比方说 main生成的目标中符号为_main,那此时可以用这个方法进行调整。下面是一个简单例子
[root@Harry lhsrhs]# cat asmname.c 
int foo()  asm("nonsense");
int main()
{
return foo();
}
[root@Harry lhsrhs]# gcc -S asmname.c 
[root@Harry lhsrhs]# cat asmname.s 
    .file    "asmname.c"
    .text
.globl main
    .type    main, @function
main:
    pushl    %ebp
    movl    %esp, %ebp
    andl    $-16, %esp
    call    nonsense  源代码中对于foo的调用转换为汇编中的对于asm("nonsense")声明的符号名称。
    movl    %ebp, %esp
    popl    %ebp
    ret
    .size    main, .-main
    .ident    "GCC: (GNU) 4.4.2 20091027 (Red Hat 4.4.2-7)"
    .section    .note.GNU-stack,"",@progbits
这个功能在C库的动态链接器中看到过一个对于变量转换的使用例子,那就是对动态节的绑定引用,由于其中的_DYNAMIC是一个链接器可识别的特殊字符,所以可以在源代码中提前使用这个变量(并且必须使用这个变量),下面代码摘抄自glibc-2.7sysdepsi386dl-machine.h
/* Return the run-time load address of the shared object.  */
static inline Elf32_Addr __attribute__ ((unused))
elf_machine_load_address (void)
{
  /* Compute the difference between the runtime address of _DYNAMIC as seen
     by a GOTOFF reference, and the link-time address found in the special
     unrelocated first GOT entry.  */
  extern Elf32_Dyn bygotoff[] asm ("_DYNAMIC") attribute_hidden;
  return (Elf32_Addr) &bygotoff - elf_machine_dynamic ();
}
下面是我测试的一个简单输出
[tsecer@Harry maporder]$ gcc maporder.c -o shared.exe
[tsecer@Harry maporder]$ readelf shared.exe -s | grep _DY
    46: 08049548     0 OBJECT  LOCAL  HIDDEN   21 _DYNAMIC
②、寄存器绑定说明
还有一种是要求使用特殊寄存器的说明,这个倒是真在代码中见过,那就是在内核中powerpc使用r2作为current来提高效率。
linux-2.6.21includeasm-powerpccurrent.h
register struct task_struct *current asm ("r2");
注意这里前面的register并不是一个可选修饰符,而是一个必选修饰符,否则会有告警,并且这个r2会被解析为一个符号重命名(由于这个重命名对于局部变量来说没有意义,所以这个声明会被给出警告然后忽略)。
gcc中对于该语法的解析位于
c_parser_simple_asm_expr
函数中,从调用关系上看,它就是直接和gcc的扩展属性__attribute__具有相同的用法,调用函数位于
c_parser_declaration_or_fndef()
{
……
      if (c_parser_next_token_is_keyword (parser, RID_ASM))
        asm_name = c_parser_simple_asm_expr (parser);
      if (c_parser_next_token_is_keyword (parser, RID_ATTRIBUTE))对于attribute的解析和asm的解析位于相同级别
        postfix_attrs = c_parser_attributes (parser);
      if (c_parser_next_token_is (parser, CPP_EQ))
……
}
对于这个语法的处理,gcc的代码位于gcc-4.1.0gccc-decl.c文件中的
finish_decl (tree decl, tree init, tree asmspec_tree)
函数,可以看到,其中的最后一个参数tree asmspec_tree就是之前解析出来的asm属性,其中用的一个宏C_DECL_REGISTER (decl)表示这个声明是否包含了register属性,如果没有register属性但是通过asm绑定了寄存器,那就会给出警告提示:
     if (asmspec) 
    {
      /* If this is not a static variable, issue a warning.
         It doesn't make any sense to give an ASMSPEC for an
         ordinary, non-register local variable.  Historically,
         GCC has accepted -- but ignored -- the ASMSPEC in
         this case.  */
      if (!DECL_FILE_SCOPE_P (decl)
          && TREE_CODE (decl) == VAR_DECL
          && !C_DECL_REGISTER (decl)
          && !TREE_STATIC (decl))
        warning (0, "ignoring asm-specifier for non-static local "
             "variable %q+D", decl);
      else
        set_user_assembler_name (decl, asmspec);
    }
这里都没有对gcc的代码做分析,只是备案,因为我也自信没有这个分析能力,只是看一下最为皮毛的东西,知道有这么回事。
2、作为语句说明
这个就是在backtrace中看到的那个指令,这种语法的解析并不是使用上面看到的c_parser_simple_asm_expr,而是由专门的
/* Parse an asm statement, a GNU extension.  This is a full-blown asm
   statement with inputs, outputs, clobbers, and volatile tag
   allowed.

   asm-statement:
     asm type-qualifier[opt] ( asm-argument ) ;

   asm-argument:
     asm-string-literal
     asm-string-literal : asm-operands[opt]
     asm-string-literal : asm-operands[opt] : asm-operands[opt]
     asm-string-literal : asm-operands[opt] : asm-operands[opt] : asm-clobbers

   Qualifiers other than volatile are accepted in the syntax but
   warned for.  */
static tree
c_parser_asm_statement (c_parser *parser)
函数来处理,这里可以看到可以有若干个部分,输出,输入及修改部分。这三个部分使用分号分开,而各个部分之内的声明使用逗号分开,这些都是语法的基本单位,记住即可。
三、这种格式的目的
1、为什么使用字符串
这里想讨论一下这个奇怪的语法为什么是这样的。这种格式描述的不是C语言通用的内容而是特定cpu相关的内容,所以对gcc前端来说,它必须隔离出自己能够掌握和需要掌握的信息,而体系结构相关的后端内容需要封装,什么样的封装最具有扩展性和通用性呢?信不信由你,就是字符串,大家想想现在看的这个网页使用的就是html语言,而这个语言就是字符串语言,没有涉及二进制及大小端,很多网络协议,例如ftp,pop3协议等都是使用的字符命令格式。其实脚本语言(包括perl,bash等)的优点就是跨平台,移植性强。
我们看一下gcc的内联汇编就可以看到,其中汇编指令就是封装在字符串中的,而各个部分中的后端限制也是放在字符串中的。想到了美国的一个总统说过:几千年人类最伟大的发明就是把权利关进了笼子(不是原话,所以不加引号)。编译器也需要把后端各种差异和可能的变化封装入字符串中(这里的变化主要来自内联汇编指令及表达式限制),让它们以字符串的形式出现,这样在语法分析上看,所有格式相同且便于扩展,而具体功能由各个后端来解释,这是一种封装变化的变通方法
2、gcc需要知道什么内容
虽然gcc不需要了解后端内容,但是优化这些是在前端就可以完成的,所以字符串指令也不能是一个黑匣子,而应该说明自己对gcc关心的哪些内容作了修改,当然,这些指令也可以通过输入部分向gcc要求提供一些表达式的值,这个表达式可以非常复杂,gcc擅长的就是表达式分析和求值。gcc的另一个重要工作就是优化,优化的一个重点是寄存器分配,就是让寄存器中尽可能放最频繁被操作的中间值,减少内存访问。习惯386体系结构的同学可能觉得每个寄存器都有特定用处,没有空闲寄存器来优化。根据386的函数调用规则,eax、ecx、edx是需要调用者保存的寄存器,但是考虑一下RISC体系结构,例如powerpc,它有32个通用寄存器,其中有接近一半是调用者保存,被调用者可以直接使用的,这对于大部分常用的函数来说应该有很大的优化空间,因为典型的函数并不长。
为了达到这个目的,gcc需要跟踪各个表达式当前在哪个寄存器中,根据指令之后的数据流/指令流来进行分析。而后端代码如果要操作寄存器的话,它不能就没有合作精神的单刀直入,而应该跟gcc打个招呼,告诉gcc自己的指令会将加工的结果放在哪个寄存器中,然后这个寄存器要放入哪个部分(输出部分),这个时候汇编指令是看不到变量和表达式的,所以它只能通过寄存器来告诉gcc自己的输入,然后再由gcc代劳把寄存器值放入变量中;内联汇编作为一个执行流,它同样需要输入部分,此时就需要使用输入部分,gcc同样代劳将一些表达式的值放入寄存器中;指令中可能使用一些寄存器作为临时变量,所以要在修改部分告诉gcc自己还会额外修改哪些指令(这就是clobber部分),从而让gcc知道,如果有必要的话,先保存该寄存器的值。
总起来说:gcc能够并且必须跟踪寄存器(及内存)的使用情况,但是无法理解字符串中指令的意义,汇编字符串指令对gcc来说就是一个黑匣子。对汇编指令来说,它识别的是寄存器(当然可以使用全局变量等,但是通常都是若干条CPU特有指令),它的基本语法单位是寄存器;对gcc来说,它的基本单位是表达式。所以对于每个说明部分,为这样一个形式 
"constrants string"(expression)
从而在内联汇编的基本操作单位 “寄存器/内存”和gcc识别的基本单位"表达式"之间搭建一个沟通的友谊桥梁,从而达到双赢效果
3、限制字符串的意义
1、汇编指令中字符串替换规则
汇编指令主要处理寄存器、立即数以及内存之类的CPU体系结构相关的东西,但是gcc更多的知道的是C语言中通过高级语言描述的表达式,所以gcc还要根据限制符中的描述对字符串中的操作符进行格式化,这一点和printf中的格式化说明是相同的,这里的m、i、r说明就相当于printf中声明的x、d之类的说明,而且从效果上看的确是影响了对汇编指令中字符串的格式化。下面是一个测试代码,可以看到不同的寻址方式使用的汇编指令格式也不相同:
[root@Harry lhsrhs]# cat cpInlineAsm.c 
int main()
{
int  * pint, varint;
char varchar;
__asm__ volatile (
"
movl %0,%%eax
movl %1,%%eax
movl %2,%%eax
movl %3,%%eax
"
:
:"i"(0x1234),"m"(pint),"r"(pint),"r"(varchar));

}

[root@Harry lhsrhs]# gcc cpInlineAsm.c -S 
[root@Harry lhsrhs]# cat cpInlineAsm.s 
    .file    "cpInlineAsm.c"
    .text
.globl main
    .type    main, @function
main:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $16, %esp
    movl    -12(%ebp), %eax
    movzbl    -1(%ebp), %edx
#APP
# 5 "cpInlineAsm.c" 1
    
movl $4660,%eax              立即数需要使用$引导
    movl -12(%ebp),%eax  内存类型则转换为内存类型变量寻址,即从内存中读入数据
    movl %eax,%eax          寄存器类型则需要使用%引导寄存器,当然之前有 -12(%ebp), %eax将内存值装入寄存器
    movl %dl,%eax             这里寄存器为%dl,也就是一个字节寄存器,而这里是一个字节还是一个整数是由C语言中声明决定的


# 0 "" 2
#NO_APP
    leave
    ret
    .size    main, .-main
    .ident    "GCC: (GNU) 4.4.2 20091027 (Red Hat 4.4.2-7)"
    .section    .note.GNU-stack,"",@progbits
2、寄存器选择约束
这个约束包括了某个特定符号的寄存器以及整个指令中所有寄存器之间的一个约束。对gcc来说,汇编语句可以认为是一个C语言的函数,它有输入值,输出值还有修改值(分别对于输出部分、输入部分和修改部分),只是这些内容对汇编语言来说更加的底层。
①、输入只读、输出只写
和C语言一样,gcc假设输入部分是只读的(这一点对C语言函数调用者同样成立,例如foo(myvar)调用之后,myvar的值不会变化,虽然foo中可以修改自己的备份),输出部分是只写的,会被汇编指令修改,这个修改不仅是对于C语言中变量的修改,也包括了汇编中使用的寄存器。
②、所有输入变量的使用在所有输出赋值执行之前
也就是说,对于一个输入
asm ("instrustions"
:"=r"(val1),"=r"(val2)
:"r"(var3),"r"(val4))
这意味着在汇编指令中,对于输入部分val1和val2使用的任何一个寄存器赋值之前,必须结束对var3和val4的使用。举例子说,对于上面的instruction序列,下面的指令是不按套路出牌的:
val1 = var3 +1 这里已经执行了 对输出部分val1的赋值,所以这条指令之后,所有的输入部分都可能不再是原始值。
var2 = var4 +1 所以这里使用var4是错误的,因为之前已经对输出部分执行了赋值操作
这好像是对最原始的C语言函数的模拟,但是那时候函数只有一个返回值,而且的确是在最后一条语句(return)修改返回值,这个可以把这个汇编指令想象成一个C语言函数经过gcc汇编之后生成的汇编指令序列。
根据这个概念,就可以知道earlyclobber的意思和作用了:这个修饰就是说,这个输出部分可能在一些输入部分还会被用到输入值的时候执行赋值动作。例如上面的例子中的val1就是一个earlyclobber,因为在对他复制之后,接下来的var2 = var4 +1 还将会使用输入值,所以val1是一个earlyclobber,需要加上&限制,从而让gcc不要使val1和任何一个输入变量使用相同寄存器。
看一个测试代码
[tsecer@Harry lhsrhs]$ cat earlyclobber.c 
int foo()
{
int bar,boo;
asm ("Something as delimiter"
:"=r"(boo)
:"r"(bar));
return boo+1234;
}
[tsecer@Harry lhsrhs]$ gcc -S earlyclobber.c 
[tsecer@Harry lhsrhs]$ cat earlyclobber.s 
    .file    "earlyclobber.c"
    .text
.globl foo
    .type    foo, @function
foo:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $16, %esp
    movl    -8(%ebp), %eax  输入寄存器使用的是eax
#APP
# 4 "earlyclobber.c" 1
    Something as delimiter
# 0 "" 2
#NO_APP
    movl    %eax, -4(%ebp)输出同样使用了eax寄存器
    movl    -4(%ebp), %eax
    addl    $1234, %eax
    leave
    ret
    .size    foo, .-foo
    .ident    "GCC: (GNU) 4.4.2 20091027 (Red Hat 4.4.2-7)"
    .section    .note.GNU-stack,"",@progbits
[tsecer@Harry lhsrhs]$ 
gcc为什么这么假设呢?一方面,这个有其合理性,就是假设这个指令和函数一样正规,另一个是可以减少寄存器数量使用。为了不让它们使用相同寄存器,就可以加上一个earlyclobber修饰符:
foo:
    pushl    %ebp
    movl    %esp, %ebp
    subl    $16, %esp
    movl    -8(%ebp), %edx可以看到输入使用的是edx
#APP
# 4 "earlyclobber.c" 1
    Something as delimiter
# 0 "" 2
#NO_APP
    movl    %eax, -4(%ebp)输出使用的是eax寄存器
    movl    -4(%ebp), %eax
    addl    $1234, %eax


3、修改内容
下面是内核中的一个例子,是386的拷贝动作
static __always_inline void * __memcpy(void * to, const void * from, size_t n)
{
int d0, d1, d2;
__asm__ __volatile__(
    "rep ; movsl "
    "movl %4,%%ecx "可以看到,这里使用了输入寄存器g,但是在这条指令之前,movsl使用并修改了esi、edi、ecx(即所有输入部分),所以不能让
    "andl $3,%%ecx "  %4和任何一个输入寄存器相同,这也是为什么输入中每个都加了earlyclobber标志的原因
#if 1    /* want to pay 2 byte penalty for a chance to skip microcoded rep? */
    "jz 1f "
#endif
    "rep ; movsb "
    "1:"
    : "=&c" (d0), "=&D" (d1), "=&S" (d2)
    : "0" (n/4), "g" (n), "1" ((long) to), "2" ((long) from)
    : "memory");这里的memory表示修改了内存值,具体是那个内存,那不能一一说明,所以当这个跳指令之后,如果gcc要访问一个内存值,那么它必须要重新从内存中读入,而不能使用寄存器中的内容
return (to);
}
4、回头看一下backtrace中汇编的意义
由于声明要使用lr寄存器保存输出值,所以强制gcc把这个寄存器保存入堆栈中,因为这个函数是一个叶子函数,所以正常情况下lr寄存器是不需要保存在堆栈中的,这个内联汇编就是对gcc虚晃一枪,让gcc把这个lr同样保存到寄存器中,从而形成一个和更上层一致的内存布局结构,为接下来的for循环准备。

原文地址:https://www.cnblogs.com/tsecer/p/10486145.html