关于Linux中程序的内存布局

5
我有关于Linux程序的内存布局的一些问题。我从各种来源了解到(我正在阅读《从零开始编程》),每个部分都加载到自己的内存区域中。文本部分首先在虚拟地址0x8048000处加载,其次是数据部分,接下来是bss部分,然后是堆和栈。
为了尝试这种布局,我用汇编语言编写了这个程序。首先,它打印一些标签的地址并计算系统断点。然后它进入一个无限循环。该循环递增指针,然后尝试访问该地址处的内存,某个时刻,一个分段错误将退出程序(我故意这样做)。
以下是程序:

.section .data

start_data:
str_mem_access:
.ascii "Accessing address: 0x%x\n\0"
str_data_start:
.ascii "Data section start at: 0x%x\n\0"
str_data_end:
.ascii "Data section ends at: 0x%x\n\0"
str_bss_start:
.ascii "bss section starts at: 0x%x\n\0"
str_bss_end:
.ascii "bss section ends at: 0x%x\n\0"
str_text_start:
.ascii "text section starts at: 0x%x\n\0"
str_text_end:
.ascii "text section ends at: 0x%x\n\0"
str_break:
.ascii "break at: 0x%x\n\0"
end_data:

.section .bss

start_bss:
.lcomm buffer, 500
.lcomm buffer2, 250
end_bss:

.section .text
start_text:

.globl _start
_start:

# print address of start_text label
pushl $start_text
pushl $str_text_start
call printf
addl $8, %esp
# print address of end_text label
pushl $end_text
pushl $str_text_end
call printf
addl $8, %esp
# print address of start_data label
pushl $start_data
pushl $str_data_start
call printf
addl $8, %esp
# print address of end_data label
pushl $end_data
pushl $str_data_end
call printf
addl $8, %esp
# print address of start_bss label
pushl $start_bss
pushl $str_bss_start
call printf
addl $8, %esp
# print address of end_bss label
pushl $end_bss
pushl $str_bss_end
call printf
addl $8, %esp
# get last usable virtual memory address
movl $45, %eax
movl $0, %ebx
int $0x80

incl %eax # system break address
# print system break
pushl %eax
pushl $str_break
call printf
addl $4, %esp

movl $start_text, %ebx

loop:
# print address
pushl %ebx
pushl $str_mem_access
call printf
addl $8, %esp

# access address
# segmentation fault here
movb (%ebx), %dl

incl %ebx

jmp loop

end_loop:
movl $1, %eax
movl $0, %ebx
int $0x80

end_text:

以下是输出结果的相关部分(这是Debian 32位系统):
text section starts at: 0x8048190
text section ends at: 0x804823b
Data section start at: 0x80492ec
Data section ends at: 0x80493c0
bss section starts at: 0x80493c0
bss section ends at: 0x80493c0
break at: 0x83b4001
Accessing address: 0x8048190
Accessing address: 0x8048191
Accessing address: 0x8048192
[...]
Accessing address: 0x8049fff
Accessing address: 0x804a000
Violación de segmento

我的问题是:

1) 为什么我的程序从地址0x8048190开始,而不是0x8048000?我猜测"_start"标签处的指令不是加载的第一件事情,那么在地址0x8048000和0x8048190之间有什么东西吗?

2) 为什么文本段结束和数据段开始之间有一个间隔?

3) bss的起始地址和结束地址相同。我假设这两个缓冲区存储在其他地方,这正确吗?

4) 如果系统断点在0x83b4001处,为什么我会在0x804a000处得到分段错误?


2
如果你从未阅读过这篇文章,请看一下它(http://www.muppetlabs.com/~breadbox/software/tiny/teensy.html)——它是一篇很棒的阅读材料,虽然与主题几乎没有关系。 - Lynn Crumbling
请注意,ELF加载器只关心可执行文件的。在许多情况下,存在1:1映射,例如.text部分(链接后)是文本段中唯一的内容。链接器将.rodata等节合并到.text中。此外,“堆”并不是真正存在的东西,更多的是一个概念(使用mmap(MAP_ANONYMOUS)进行的分配与brk不连续)。我不确定人们是否认为BSS和静态数据是堆的一部分。同样不确定Linux是否将初始的brk放在BSS之后。 - Peter Cordes
1个回答

2
我假设你是使用gcc -m32 -nostartfiles segment-bounds.S或类似命令来构建这个程序,因此你有一个32位的动态二进制文件。(如果你实际上使用的是32位系统,则不需要-m32,但大多数想要测试这个程序的人都会有64位系统。)
我的64位Ubuntu 15.10系统对于一些事情给出了与你的程序略微不同的数字,但总体行为模式是相同的。(不同的内核,或者只是ASLR,解释了这一点。例如,brk地址变化很大,像0x93540010x82a8001这样的值)
我的程序为什么从地址0x8048190开始,而不是0x8048000?
如果您构建静态二进制文件,您的_start将在0x8048000处。
我们可以看到从readelf -a a.out0x8048190是.text节的开头。但它不在映射到页面的文本段的开头(页面大小为4096B,Linux要求映射对文件位置的4096B边界对齐,因此以这种方式布局文件,execve无法将_start映射到页面的开头。我认为off栏是文件中的位置。)
可能,在.text部分之前的文本段中包含动态链接器所需的只读数据,因此将其映射到内存中相同的页面是有意义的。
## part of readelf -a output
Section Headers:
  [Nr] Name              Type            Addr     Off    Size   ES Flg Lk Inf Al
  [ 0]                   NULL            00000000 000000 000000 00      0   0  0
  [ 1] .interp           PROGBITS        08048114 000114 000013 00   A  0   0  1
  [ 2] .note.gnu.build-i NOTE            08048128 000128 000024 00   A  0   0  4
  [ 3] .gnu.hash         GNU_HASH        0804814c 00014c 000018 04   A  4   0  4
  [ 4] .dynsym           DYNSYM          08048164 000164 000020 10   A  5   1  4
  [ 5] .dynstr           STRTAB          08048184 000184 00001c 00   A  0   0  1
  [ 6] .gnu.version      VERSYM          080481a0 0001a0 000004 02   A  4   0  2
  [ 7] .gnu.version_r    VERNEED         080481a4 0001a4 000020 00   A  5   1  4
  [ 8] .rel.plt          REL             080481c4 0001c4 000008 08  AI  4   9  4
  [ 9] .plt              PROGBITS        080481d0 0001d0 000020 04  AX  0   0 16
  [10] .text             PROGBITS        080481f0 0001f0 0000ad 00  AX  0   0  1         ########## The .text section
  [11] .eh_frame         PROGBITS        080482a0 0002a0 000000 00   A  0   0  4
  [12] .dynamic          DYNAMIC         08049f60 000f60 0000a0 08  WA  5   0  4
  [13] .got.plt          PROGBITS        0804a000 001000 000010 04  WA  0   0  4
  [14] .data             PROGBITS        0804a010 001010 0000d4 00  WA  0   0  1
  [15] .bss              NOBITS          0804a0e8 0010e4 0002f4 00  WA  0   0  8
  [16] .shstrtab         STRTAB          00000000 0010e4 0000a2 00      0   0  1
  [17] .symtab           SYMTAB          00000000 001188 0002b0 10     18  38  4
  [18] .strtab           STRTAB          00000000 001438 000123 00      0   0  1
Key to Flags:
  W (write), A (alloc), X (execute), M (merge), S (strings)
  I (info), L (link order), G (group), T (TLS), E (exclude), x (unknown)
  O (extra OS processing required) o (OS specific), p (processor specific)

“为什么文本段的结尾和数据段的开头之间有一个间隙?”
“为什么不呢?它们必须位于可执行文件的不同段中,因此映射到不同的页面。(文本是只读和可执行的,并且可以被MAP_SHARED映射。数据是可读写的,并且必须是MAP_PRIVATE。顺便说一下,在Linux中,默认情况下数据也是可执行的。)”
“留出间隙为动态链接器提供了空间,将共享库的文本段映射到可执行文件的文本旁边。这也意味着对数据段的越界数组索引更容易导致段错误。(早期和更明显的故障总是更容易调试的。)”
3)BSS的起始地址和结束地址相同。我认为这两个缓冲区存储在其他地方,这是正确的吗?
很有趣。它们在BSS中,但我不知道为什么当前位置没有受到.lcomm标签的影响。可能它们在链接之前进入了不同的子部分,因为您使用了.lcomm而不是.comm。如果我使用.skip或.zero来保留空间,我会得到您期望的结果:
.section .bss
start_bss:
#.lcomm buffer, 500
#.lcomm buffer2, 250
buffer:  .skip 500
buffer2: .skip 250
end_bss:

.lcomm即使您没有切换到该部分,也会将内容放入BSS中。也就是说,它不关心当前的部分是什么,并且也可能不关心或影响当前在.bss部分的位置。简而言之,当您手动切换到.bss时,请使用.zero.skip,而不是.comm.lcomm


如果系统断点在0x83b4001处,为什么我会在0x804a000处先收到分段错误信号?
这告诉我们,在文本段和brk之间存在未映射的页面。(您的循环从ebx = $start_text开始,因此它在文本段后的第一页未映射时出错)。除了文本和数据之间的虚拟地址空间中的空洞之外,可能还存在其他空洞超出数据段。
内存保护具有页面粒度(4096B),因此第一个触发故障的地址将始终是页面的第一个字节。

我正在Debian 3.5 i386虚拟机中构建它,使用命令as break.S -o break.o && ld -dynamic-linker /lib/ld-linux.so.2 -o break break.o -lc(主机为Ubuntu 15.10 64位)。 - saga.x
@saga.x:是的,这相当于“gcc -m32 -nostartfiles”。你为什么要使用32位VM?只需在Ubuntu系统上运行“gcc -m32”,或者使用“as”和“ld”(如我在链接中解释的),并带有正确的参数。在64位内核上运行32位代码完全没有问题,并且Ubuntu的多库软件包包括所有必要的32位库。 - Peter Cordes
好的,我安装了gcc-multilib包并使用gcc -m32 -nostartfiles进行了构建,它可以工作。我还查找了一些关于ASLR的内容,如果我以root身份执行sysctl -w kernel.randomize_va_space=0,断点地址永远不会改变,它固定在0x804a001,这是我得到的段错误的相同地址。我应该阅读更多关于Linux如何工作和内存管理方面的内容,才能更好地理解这个主题,非常有趣,但我对此还很陌生。谢谢你的回答! - saga.x
@saga.x:是的,你可以禁用ASLR,但通常在使用gdb和/proc/pid/maps进行调试时不需要运行之间的重复性。有趣的是,它在没有ASLR的情况下使用与32位内核相同的brk。然而,32位和64位内核之间存在差异:如果我没记错,在64位内核下的32位进程可以使用整个4GiB的虚拟地址空间,但32位内核在每个进程的虚拟地址空间中保留了上1或2GiB以映射系统调用期间的内核内存。(因此,在32位内核上的32位进程中只能分配最多3GiB。) - Peter Cordes
是的,有很多东西需要理解!我使用Linux作为我的桌面操作系统已经将近20年了,所以我能够逐渐地学习到很多东西,而不是一下子就面对所有的复杂性。在我开始认真研究汇编语言之前,我已经知道了很多东西。无论如何,你的问题比通常的无聊的“我对汇编语言一无所知,但我写了这个程序,请帮我调试”之类的问题要好得多。继续提出有趣的问题 :) - Peter Cordes
@saga.x:另外,早些时候当计算机速度缓慢、磁盘/内存较小且值得这样做时(我想我的电脑是P233MMX,有64MB的内存),通过阅读各种选项的帮助来配置/编译自己的内核,我学到了很多关于Linux的知识(例如,有一个选项可以选择你想要2G:2G内核:用户分割还是1:3)。 - Peter Cordes

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接