Linux命令行参数在栈中的分配

《Professional Assembly Language — Richard•Blum》一书第11章Using Command-Line Parameters一节讲到, 32位Linux环境下ELF 程序被加载之后,分配的虚拟内存起始字节地址为0x08048000,结束字节地址为0xbfffffff,如下所示:

Program Virtual Memory Area
0xbfffffffStack Data
……
0x08048000Program Code and Data

预期Stack Pointer(%esp)初始值也应该是0xbfffffff,但由于Linux会在程序初始化前,将一些诸如命令行参数及环境变量等信息放到栈上,所以(可能从下往上看更易读些):

Program Stack
0xc0000000:栈底
0xbffffffc:NULL(0x00000000)
程序名称字符串值 *
环境变量字符串值 *
命令行参数字符串值 *
ELF Auxiliary Vectors
NULL(结束envp[])
环境变量字符串地址列表(envp[])
NULL(结束argv[])
命令行参数字符串地址列表(argv[])
%esp:命令行参数个数(dword argc)

* 指多个asciz类型的字符串

ELF Auxiliary Vectors不是程序所要关心的,可以打开/usr/include/elf.h查看struct Elf32_auxv_t的定义。通过设置环境变量:LD_SHOW_AUXV=1,可以在执行程序前输出AUXV值。

按上图所示,下面的代码应该能够毫无悬念地输出命令行参数及环境变量:

# File:args.s
# Print command line arguments and environment variables
.section .data
  argcs:.asciz "argc=%d\n"
  argvs:.asciz "argv[%d]=%s\n"
  env_header:.asciz "Environment variables:\n"
  envs:.asciz "%s\n"
.section .text
.global _start
_start:
  movl %esp,%ebp
  pushl (%ebp)
  pushl $argcs
  call printf # Print argc
  addl $8,%esp
  # Print argv[]
  movl $0,%eax
  addl $4,%ebp
argvloop:
  movl (%ebp,%eax,4),%ecx
  jecxz argvloop_end # NULL ends argv[]
  pushl %ecx # String addr
  pushl %eax
  pushl $argvs
  call printf
  # printf ret value override eax,restore from stack
  movl 4(%esp),%eax
  addl $12,%esp
  inc %eax
jmp argvloop
argvloop_end:
  leal 4(%ebp,%eax,4),%ebp # skip argv[] and NULL
  pushl $env_header
  call printf
  addl $4,%esp
envloop:
  movl (%ebp),%ecx
  jecxz end # NULL ends envp[]
  pushl %ecx
  pushl $envs
  call printf
  addl $8,%esp
  addl $4,%ebp
jmp envloop
end:
  pushl $0
  call exit

但要注意编译方式,因为使用了C的printf函数,所以需要链接libc:

$ as --gstabs args.s -o args.o
$ ld args-libc.o -o args.bin  --dynamic-linker /lib/ld-linux.so.2 -lc
$ ./args.bin

很明显,用GAS编译很麻烦,用GCC则方便很多,只需要一条命令就可以了:gcc -o args.bin args.s,GCC会一步做好编译链接工作。不过GCC和GAS有一个区别,GAS将_start视作程序执行起点,而GCC则将main当作执行起点。如果要使用GCC编译,则需要将.global _start改成.global main

# hello.s
# 使用GCC的Hello,world汇编程序示例
.section .data
  msg:.asciz "Hello,world!\n"
.section .text
.global main
main:
  push $msg
  call printf
  push $0
  call exit

《Professional Assembly Language》第四章Creating a Simple Program - Assembling using a compiler一节中介绍了这种方式,不过书中并没有就这种差别再作深层解释,给读者留了一个大坑。为什么说是大坑呢?其实,GCC所指定的main,文中所说的Beginning of the program ,并不是真正的Entry Point。如果只是Entry Point名称的区别的话,你会立即查找到ld命令有一个-e参数可用于指定Entry Point名称:

-e entry
--entry=entry

Use entry as the explicit symbol for beginning execution of your program, rather than the default entry point. If there is no symbol named entry, the linker will try to parse entry as a number, and use that as the entry address (the number will be interpreted in base 10; you may use a leading 0x for base 16, or a leading 0 for base 8).

事实上main并不是Entry Point名称!使用GCC编译,GCC会自己添加上libc的_start作为entry point,然后再在执行时调用main。将上面的代码编译一下: gcc -o hello.bin hello.s -gstabs(参数-gstabs是为了生成可用于GDB调试的信息)。可以通过readelf命令查看ELF Header:

$ readelf -h hello.bin|grep Entry
  Entry point address:               0x8048360

地址0x8048360才是它的入口地址。通过GDB来验证一下:

$ gdb -q hello.bin
Reading symbols from ./hello.bin...done.
(gdb) disassemble _start
Dump of assembler code for function _start:
   0x08048360 <+0>:     xor    %ebp,%ebp
   0x08048362 <+2>:     pop    %esi
   0x08048363 <+3>:     mov    %esp,%ecx
   0x08048365 <+5>:     and    $0xfffffff0,%esp
   0x08048368 <+8>:     push   %eax
   0x08048369 <+9>:     push   %esp
   0x0804836a <+10>:    push   %edx
   0x0804836b <+11>:    push   $0x80484a0
   0x08048370 <+16>:    push   $0x8048430
   0x08048375 <+21>:    push   %ecx
   0x08048376 <+22>:    push   %esi
   0x08048377 <+23>:    push   $0x8048414
   0x0804837c <+28>:    call   0x8048350 <__libc_start_main@plt>
(gdb) disassemble main
Dump of assembler code for function main:
   $0x8048414 <+0>:     push   $0x804a018
   0x08048419 <+5>:     call   0x8048320 <printf@plt>
   0x0804841e <+10>:    push   $0x0
   0x08048420 <+12>:    call   0x8048340 <exit@plt>
(gdb)

可以看到,main部分确实是我们写的代码,但程序执行,却是从_start开始的。GCC加载的libc中的_start代码,会将main的地址作为参数(push $0x8048414)传给__libc_start_main__libc_start_main执行作了很多准备操作后再去调用main。这会带来什么问题呢?事实上,如果我们将上面的args.s的_start改成main,然后用GCC编译的话,就会取不到正确的命令行参数。看到main这个名称,我们一定会联想到C语言的main函数,事实上这两者几乎是等价的,可以通过查看main.c生成的汇编代码验证这点:

#include <stdio.h>
int main(int argc,char *argv[],char *envp[]) {
  printf("Hello,world!\n");
}

执行gcc -S main.c以生成main.s文件:

.file "main.c"
  .section  .rodata
.LC0:
  .string "Hello,world!"
  .text
  .globl  main
  .type main, @function
main:
.LFB0:
  .cfi_startproc
  pushl   %ebp
  .cfi_def_cfa_offset 8
  .cfi_offset 5, -8
  movl    %esp, %ebp
# 省略部分内容

可以看到,C中的main函数也是被转换成Assembly中的.global main,那么看一下C中main函数的声明:int main(int argc,char *argv[],char *envp[]);,就会明白,最终在调用main时,栈中已经变成这样了:

main执行时栈中的情况
%esp+12:环境变量字符串数组的指针(*envp[])
%esp+8:命令行参数字符串数组的指针(*argv[])
%esp+4:命令行参数个数(argc)
%esp:main返回地址
* 注意,栈顶第一个元素不是argc而是main返回地址。
因为main也是通过call指令调用,而call指令会将返回地址push进栈!

再通过GDB验证一下:
* 注意是从汇编代码编译,而不是直接从C代码编译。因为从C代码编译,生成的调试信息中main标签的地址,是main函数第一行代码的地址,通过gdb调试时,break main将在main的第一行代码处停止,而在这之前,已经运行过了push %ebpsub $0x64,%esp(在栈上分配局部变量空间)这些Function Prologue指令了,所以此时栈的结构会受main函数的局部变量大小及其它因素的影响。

$ gcc hello.s -o hello.bin -gstabs
$ gdb -q hello.bin
Reading symbols from ./hello.bin...done.
(gdb) break main
Breakpoint 1 at 0x8048414: file hello.s, line 8.
(gdb) run 1 2 3
Starting program: ./hello.bin 1 2 3

Breakpoint 1, main () at hello.s:8
8         push $hello
(gdb) x/4wx $esp # 依次为:返回地址,argc,*argv[],*envp[]
0xbffff17c:     0x001704d3      0x00000004      0xbffff214      0xbffff228
(gdb) x/4wx 0xbffff214  # View argv[],4个命令行参数
0xbffff214:     0xbffff435      0xbffff45a      0xbffff45c      0xbffff45e
(gdb) x/s 0xbffff45a    # 第二个命令行参数,第一个是程序名称
0xbffff45a:      "1"
(gdb) x/2wx 0xbffff228 # View envp[]
0xbffff228:     0xbffff460      0xbffff475
(gdb) x/s 0xbffff460  # 第一个环境变量值
0xbffff460:      "LC_PAPER=zh_CN.UTF-8"
查看一下main函数返回地址0x001704d3前后的代码
(gdb) disassemble 0x001704d3-16,+20
Dump of assembler code from 0x1704c3 to 0x1704d3:
   0x001704c3 <__libc_start_main+227>:  add    $0x89,%al
   0x001704c5 <__libc_start_main+229>:  inc    %esp
   0x001704c6 <__libc_start_main+230>:  and    $0x8,%al
   0x001704c8 <__libc_start_main+232>:  mov    0x74(%esp),%eax
   0x001704cc <__libc_start_main+236>:  mov    %eax,(%esp)
   这一行调用main,main的地址放在0x70(%esp)中
   0x001704cf <__libc_start_main+239>:  call   *0x70(%esp)
   0x001704d3 <__libc_start_main+243>:  mov    %eax,(%esp)
   0x001704d6 <__libc_start_main+246>:  call   0x189fb0 <__GI_exit>
查看 0x70(%esp) 单元的值
(gdb) x/1wx $esp+4+0x70    # call指令push了返回地址,所以到main执行时,这里要再加4
0xbffff1f0:     0x8048414  # 正是main的地址

所以上面获取命令行参数的汇编代码,若想用GCC编译还能正确运行,则需要改成这样:

.section .data
  argcs:.asciz "argc=%d\n"
  argvs:.asciz "argv[%d]=%s\n"
  env_header:.asciz "Current environment variables:\n"
  envs:.asciz "%s\n"
.section .text
.global main
main:
  movl %esp,%ebp
  pushl 4(%ebp) # Skip ret addr
  pushl $argcs
  call printf # Print argc
  addl $8,%esp
  # Print argv[]
  movl $0,%eax
  movl 8(%esp),%ebp # Skip argc and ret addr
argvloop:
  movl (%ebp,%eax,4),%ecx
  jecxz argvloop_end # NULL ends argv[]
  pushl %ecx # String addr
  pushl %eax
  pushl $argvs
  call printf
  # prinf ret value override eax,restore from stack
  movl 4(%esp),%eax
  addl $12,%esp
  inc %eax
jmp argvloop
argvloop_end:
  movl 12(%esp),%ebp # Skip argc,ret,argv
  pushl $env_header
  call printf
  addl $4,%esp
envloop:
  movl (%ebp),%ecx
  jecxz end # NULL ends envp[]
  pushl %ecx
  pushl $envs
  call printf
  addl $8,%esp
  addl $4,%ebp
jmp envloop
end:
  pushl $0
  call exit

故事还没有结束,事实上,GCC也可以通过参数,指定不加载libc的startfiles,这样就和GAS编译效果一样了:

# hello.s 中仍然声明 .global _start
$ gcc hello.s -o hello.bin -gstabs -nostartfiles
-nostartfiles

Do not use the standard system startup files when linking.
The standard system libraries are used normally,unless -nostdlib or -nodefaultlibs is used.

参考资料