令您惊讶的大部分内存开销来自 C 库和动态链接器,即使您不经常使用它们,它们也会完全加载到内存中。 (您正在将它们用于在 main 之前运行的代码块,许多人没有意识到它存在,但它就在那里。它负责设置 stdio、运行 C++ 全局构造函数等事情,并安排它可以从main返回而不会崩溃。)
作为比较,这只是在没有hand-hacking the ELF file format 的情况下在 Linux/x86 上可以拥有的最小的忙等待程序:
$ cat tiny.s
.text
.globl _start
.type _start,@function
_start:
pause
jmp _start
.size _start, .-_start
.section .note.GNU-stack,"",@progbits
我已经用汇编语言编写了它,因此我可以省去与 C 库相关的所有开销。为了排除在main 之前运行的代码,我必须将程序的“主函数”命名为_start。这样编译:
$ gcc -nostdlib -nostartfiles -static -Wl,--build-id=none -o tiny tiny.s
-nostdlib 关闭大部分 C 库,-nostartfiles 关闭在 main 之前运行的代码,-static 这样它甚至不会拉入动态链接器,-Wl,--build-id=none 到抑制使可执行文件在磁盘上显着变大的注释。这就是我们得到的结果:
$ objdump -dr tiny
tiny: file format elf64-x86-64
Disassembly of section .text:
0000000000401000 <_start>:
401000: f3 90 pause
401002: eb fc jmp 401000 <_start>
$ size tiny
text data bss dec hex filename
4 0 0 4 4 a.out
$ ls -l tiny
-rwxr-xr-x 1 zack zack 4632 Nov 27 10:53 tiny
四个字节的实际机器指令。它们被放大到略多于 4k 的完整可执行文件,并带有更多的填充和注释。您可以使用objdump 和readelf 命令来查看文件并查看其中的所有内容。
这是它在top 中的显示方式:
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
12154 zack 20 0 156 4 0 R 100.0 0.0 0:16.51 a.out
这仍然分配了 156kB¹ 的地址空间和 4kB 的实际 RAM。要更详细地了解这个空间的用途,我们可以查看/proc/<pid>/maps 的过程:(注意,您系统上的输出可能会略有不同)
$ cat /proc/12514/maps
000000400000-000000401000 r--p 00000000 fd:01 26477890 /home/zack/tiny
000000401000-000000402000 r-xp 00001000 fd:01 26477890 /home/zack/tiny
7fff5c9b3000-7fff5c9d4000 rw-p 00000000 00:00 0 [stack]
7fff5c9fb000-7fff5c9fe000 r--p 00000000 00:00 0 [vvar]
7fff5c9fe000-7fff5c9ff000 r-xp 00000000 00:00 0 [vdso]
有五个虚拟内存分配,每行的前两个数字分别是它们的开始和结束地址。可执行文件的前 0x1000 字节 (4kB) 已被映射为只读,而可执行文件的第二个 4kB 已被映射为读取-执行。 (是的,这意味着文件比它的内存映射短。内核将用零填充空白。)然后我们有
0x7fff5c9d4000 - 0x7fff5c9b3000 = 132kB 分配给堆栈,12kB 分配给“vvar”,4kB 分配给“vdso”。 8 + 132 + 12 + 4 = 156kB。
这里有一个有趣的事实:top 的 RES 只计算已提交给当前进程的页面。在这种情况下,这是 stack 分配的一页。来自可执行文件的 8kB 映射不计算在内,因为它们是只读的、可共享的和可丢弃的——如果您有许多进程运行同一个程序,它们都将共享同一个副本程序代码在物理 RAM 中,如果内核需要将这些页面从 RAM 中踢出以腾出空间给其他东西,它不必将它们写入交换文件。 (top 手册页对 RES 有不同的说法,但据我所知,这是错误的。)
“vvar”和“vdso”映射分别是一小团数据和代码,由内核提供给 Linux 上的所有用户空间进程。它们用于低级技巧,例如可以在不实际将 CPU 切换到内核模式的情况下执行gettimeofday。这减少了数千个周期的开销,这对于准确计时很重要。据我所知,没有办法关闭这些。
您可以使用ulimit 命令减少堆栈分配的大小。例如,ulimit -s 4 将其缩减到绝对最小值 4kB。如果我这样运行我的程序
$ (unset $(printenv | cut -d= -f1); ulimit -s 4; exec ./tiny)
然后top 报告
PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND
14293 zack 20 0 28 4 0 R 100.0 0.0 0:06.02 tiny
/proc/14293/maps 中的 stack 行读取
7ffdf80e5000-7ffdf80e6000 rw-p 00000000 00:00 0 [stack]
但是如果没有最初的unset 命令(清除所有环境变量),程序会在启动时崩溃:
$ (ulimit -s 4; exec ./tiny)
Segmentation fault
这是因为内核在启动程序运行之前将一堆数据写入堆栈分配——命令行参数向量、所有环境变量和ELF auxiliary vector。如果我不清除环境变量,该数据会占用超过 4kB 的空间并且程序会崩溃。我打赌你不知道在execve 系统调用中可能会触发段错误。
¹ 正确的二进制千字节,即:1kB ≝ 1024 字节。不要听信任何人的不同意见,即使是国际计量局也不行。