Windows 里双击
.exe,程序就开了。Linux 桌面也能双击图标启动,但终端里敲./hello和./test.sh背后发生的事完全不同——一个由内核直接加载,一个要解释器代劳。用strace和readelf扒开来看,能清楚地看到两者的分界线。同一主题此前还写过一篇《上电≠跑 main:MCU 和 Linux 启动前到底偷偷干了多少事?》,那篇讲的是"上电之后到 main 之前":硬件复位、向量表、启动文件、Bootloader 接力。这篇则接着讲"main 之后":内核怎么识别 ELF 和脚本、execve() 内部做了什么。两篇合起来,覆盖从芯片上电到程序跑起来的完整路径。
在 Windows 里,双击 notepad.exe,资源管理器调用 CreateProcess,系统加载 PE 文件,创建进程,程序就跑起来了。这套流程用户感知不到,只有一个沙漏转两圈。
Linux 桌面环境(GNOME / KDE)也能双击图标启动程序。桌面管理器读取 .desktop 文件里的 Exec= 字段,最终调用的还是同一个东西:
$ /usr/bin/firefox但如果你在终端里跑一个自己编译的程序,就得这样:
$ ./helloHello, World!注意那个 ./——它告诉 shell:"别去 PATH 里找,就执行当前目录下这个文件"。去掉试试:
$ hellobash: hello: command not foundWindows 会自动搜索当前目录,Linux 不会。这是安全设计,不是 bug。

本节要点:不管双击还是命令行,最终都汇入同一条路——
execve()系统调用。Linux 和 Windows 的关键区别不在 GUI,在内核怎么处理这个调用。
在 Linux 里,"程序"只有两种形态:
/bin/ls./hello | fileELF | ||
./test.sh./run.py | filescript |
用 file 命令一眼分辨:
$ file /bin/ls/bin/ls: ELF 64-bit LSB pie executable, x86-64, dynamically linked, stripped$ file hello.chello.c: C source, ASCII text$ file hellohello: ELF 64-bit LSB pie executable, x86-64, dynamically linked, not stripped$ file test.shtest.sh: Bourne-Again shell script, ASCII text executableELF 就是 Linux 下的可执行文件格式,相当于 Windows 的 PE(.exe)。脚本则是不带二进制头的纯文本。

本节要点:内核靠文件头几个字节判断类型。
\x7fELF走二进制路径,#!走脚本路径。这个判断发生在execve()系统调用内部。
一个 C 程序编译后变成 ELF 文件,结构如下:
$ readelf -h helloELF Header: Magic: 7f 45 4c 46 02 01 01 00 00 00 00 00 00 00 00 00 Class: ELF64 Data: 2's complement, little endian OS/ABI: UNIX - System V Type: DYN (Position-Independent Executable file) Machine: Advanced Micro Devices X86-64 Entry point address: 0x1060关键字段:Entry point address——内核加载完 ELF 后,就跳到这个地址开始执行。这个地址指向 _start,不是你的 main。
注意 Type 字段:现代 GCC 默认编译 PIE(Position-Independent Executable),Type 显示为
DYN而非EXEC。PIE 配合 ASLR(地址空间随机化)让程序每次加载到不同地址,是安全加固的一部分。如果用gcc -no-pie,就会回到传统的EXEC类型,入口地址也变成0x401000级别。

ELF 文件在磁盘上是一段一段的,内核按照 Program Header 的指示,把各段映射到进程的虚拟地址空间。
用 strace 跟踪 ./hello,看看内核到底做了什么:
$ strace ./helloexecve("./hello", ["./hello"], 0x7ffd12345678 /* 50 vars */) = 0brk(NULL) = 0x55555576a000mmap(NULL, 8192, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) = 0x7f1234567000access("/etc/ld.so.preload", R_OK) = -1 ENOENT (No such file or directory)openat(AT_FDCWD, "/etc/ld.so.cache", O_RDONLY|O_CLOEXEC) = 3fstat(3, {st_mode=S_IFREG|0644, st_size=123456, ...}) = 0mmap(NULL, 123456, PROT_READ, MAP_PRIVATE, 3, 0) = 0x7f1234548000close(3) = 0openat(AT_FDCWD, "/lib/x86_64-linux-gnu/libc.so.6", O_RDONLY|O_CLOEXEC) = 3read(3, "\177ELF\2\1\1\3\0\0\0\0\0\0\0\0\3\0>\0\1\0\0\0\220\223\2\0\0\0\0\0"..., 832) = 832mmap(NULL, 1990256, PROT_READ, MAP_PRIVATE|MAP_DENYWRITE, 3, 0) = 0x7f1234361000mmap(0x7f1234388000, 1449984, PROT_READ|PROT_EXEC, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x27000) = 0x7f1234388000mmap(0x7f12344ea000, 348160, PROT_READ, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x189000) = 0x7f12344ea000mmap(0x7f123453f000, 24576, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_FIXED|MAP_DENYWRITE, 3, 0x1dd000) = 0x7f123453f000close(3) = 0write(1, "Hello, World!\n", 14) = 14exit_group(0) = ?关键步骤拆解:
execve | ||
mmap | ||
brk | ||
_start | main | |
write | printf("Hello") 变成了 write(1, ...) | |
exit_group |
核心认知:
execve一做,老进程的地址空间全被替换,新程序接管一切。fork创建子进程,execve装载新程序——这是 Linux 创建进程的标准组合。

本节要点:二进制程序的执行链路是
execve → ELF 解析 → mmap 映射 → 动态链接 → _start → main。内核只认 ELF 格式,不关心你用什么语言写的。
一个脚本文件就是普通文本,头顶必须有一行 shebang:
#!/bin/bashecho "Hello from bash"#!/usr/bin/env python3print("Hello from Python")#! 后面跟的是解释器的绝对路径。/usr/bin/env 是变通方案——它会在 PATH 里找 python3,避免硬编码路径。
没有 shebang 会怎样?shell 会自己上阵,用默认 shell 解释它。但这是不可靠的行为,别依赖。
$ strace ./test.shexecve("./test.sh", ["./test.sh"], 0x7ffd12345678 /* 50 vars */) = 0看上去只有一个 execve,但实际上内核在 execve 内部做了这些事情:

strace 只看到一次 execve,因为内核在同一个系统调用内部完成了 shebang 解析和解释器加载。这个机制叫 binfmt_script,是 Linux 内核内置的二进制格式处理器之一,和 binfmt_elf(处理 ELF)平级。
# 内核注册的二进制格式处理器一览$ ls /proc/sys/fs/binfmt_misc/WSLInterop python3.12 register statusbinfmt_misc 目录下还能看到其他注册的格式处理器——比如 WSL 就注册了 WSLInterop 来支持在 Linux 里直接运行 Windows 的 .exe。register 和 status 是管理接口,可以向内核动态注册新的二进制格式。
用 strace -f 跟踪子进程,可以看到 bash 启动后的完整行为:
$ strace -f ./test.sh 2>&1 | head -20execve("./test.sh", ["./test.sh"], ...) = 0brk(NULL) = 0x55555576a000...(bash 自身的初始化)openat(AT_FDCWD, "./test.sh", O_RDONLY) = 3read(3, "#!/bin/bash\necho \"Hello from b"..., 80) = 42注意最后两行:bash 打开并读取了 test.sh 的内容。这说明解释器把脚本当数据读进去,然后逐行执行。
本节要点:脚本不是被内核直接执行的,而是被解释器读进去逐行处理的。shebang 只是告诉内核"去找谁读",真正的执行者是解释器。
\x7fELF | #! | |
execve | ||
.elf | .sh.py.pl |
一个更直观的对比:
# 二进制:查看需要反汇编$ objdump -d hello | head -50000000000001060 <_start>: 1060: f3 0f 1e fa endbr64 1064: 31 ed xor %ebp,%ebp# 脚本:直接就能看$ cat test.sh#!/bin/bashecho "Hello from bash"
本节要点:二进制是"给你指令,CPU 自己跑";脚本是"给你脚本,解释器帮你跑"。一条
execve,内核走的却是两条完全不同的分支。
bash: ./test.sh: Permission denied | chmod +x test.sh | |
#!/bin/bash 或 #!/usr/bin/env python3 | ||
bad interpreter: No such file or directory | which bash | |
/bin/bash^M: bad interpreter | dos2unixsed -i 's/\r$//' | |
cannot execute binary file: Exec format error | file | |
error while loading shared libraries: libxxx.so | ldd | |
version GLIBC_2.34 not found |
本节要点:脚本最大的坑是 shebang 和环境(换行符、权限);二进制最大的坑是依赖库和架构匹配。
Linux 下执行一个程序,无论你是双击图标还是敲命令行,最终都汇入 execve() 这一条系统调用。内核打开文件,读前几个字节:
\x7fELF——二话不说,mmap 映射各段,加载动态链接库,跳到 _start,你的 main 就跑起来了。#!——提取解释器路径,启动解释器,把脚本塞给它当参数,解释器逐行读、逐行执行。
下次在终端里敲 ./program 时,不妨想想内核正在读文件头的那几个字节,然后决定走哪条路。Linux 程序执行的秘密,藏在前 4 个字节里。