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 程序分两类:二进制与脚本 在 Linux 里,"程序"只有两种形态: 类型 | 本质 | 例子 | 怎么看 | 二进制可执行文件 | CPU 直接执行的机器码 | /bin/ls 、./hello | file 显示 ELF | 脚本文件 | 文本,需要解释器执行 | ./test.sh 、./run.py | file 显示 script |
用 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() 系统调用内部。 --- 三、二进制程序:内核直接加载的 ELF 3.1 ELF 文件长什么样 一个 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 的指示,把各段映射到进程的虚拟地址空间。 3.2 strace 跟踪:从命令行到 main 用 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) = ?关键步骤拆解: 步骤 | 系统调用 | 做什么 | 1 | execve | 内核加载 ELF,创建进程地址空间 | 2 | mmap × N | 把 ELF 各段 + libc.so 映射到内存 | 3 | brk | 设置堆(heap)起始地址 | 4 | 跳转到 _start | C 运行时初始化,最终调用 main | 5 | write | 你的 printf("Hello") 变成了 write(1, ...) | 6 | exit_group | 进程退出 |
核心认知:execve 一做,老进程的地址空间全被替换,新程序接管一切。fork 创建子进程,execve 装载新程序——这是 Linux 创建进程的标准组合。 本节要点:二进制程序的执行链路是 execve → ELF 解析 → mmap 映射 → 动态链接 → _start → main。内核只认 ELF 格式,不关心你用什么语言写的。 --- 四、脚本程序:解释器代劳的文本文件 4.1 脚本的结构 一个脚本文件就是普通文本,头顶必须有一行 shebang: #!/bin/bashecho "Hello from bash"#!/usr/bin/env python3print("Hello from Python")#! 后面跟的是解释器的绝对路径。/usr/bin/env 是变通方案——它会在 PATH 里找 python3,避免硬编码路径。 没有 shebang 会怎样?shell 会自己上阵,用默认 shell 解释它。但这是不可靠的行为,别依赖。 4.2 strace 跟踪:脚本怎么跑起来的 $ 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 只是告诉内核"去找谁读",真正的执行者是解释器。 --- 五、二进制 vs 脚本:一张表说清区别 维度 | 二进制程序 | 脚本程序 | 文件格式 | ELF(机器码) | 纯文本(ASCII/UTF-8) | 内核怎么识别 | ELF 魔数 \x7fELF | shebang #! | 谁执行 | CPU 直接执行机器码 | 解释器逐行解释 | | execve 行为 | 内核直接加载 ELF | 内核启动解释器,脚本当参数 | 依赖 | 动态链接库(.so) | 解释器必须已安装 | 启动速度 | 快(mmap + 跳转) | 慢(启动解释器 + 解析文本) | 跨平台 | 需重新编译 | 解释器能跑就能跑 | 可读性 | 二进制,不可读 | 文本,可直接编辑 | 典型后缀 | 无后缀 / .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 | 脚本没有 shebang | 用当前 shell 执行,行为不可控 | 永远加 #!/bin/bash 或 #!/usr/bin/env python3 | shebang 路径写错 | bad interpreter: No such file or directory | 确认解释器路径:which bash | Windows 换行符 | /bin/bash^M: bad interpreter | dos2unix 或 sed -i 's/\r$//' | 二进制架构不匹配 | cannot execute binary file: Exec format error | file 查架构,交叉编译或用 QEMU | 动态库缺失 | error while loading shared libraries: libxxx.so | ldd 查依赖,安装缺失的库 | 依赖库版本不匹配 | version GLIBC_2.34 not found | 升级 glibc 或静态链接编译 |
本节要点:脚本最大的坑是 shebang 和环境(换行符、权限);二进制最大的坑是依赖库和架构匹配。 --- 七、总结 Linux 下执行一个程序,无论你是双击图标还是敲命令行,最终都汇入 execve() 这一条系统调用。内核打开文件,读前几个字节: 看到 \x7fELF——二话不说,mmap 映射各段,加载动态链接库,跳到 _start,你的 main 就跑起来了。 看到 #!——提取解释器路径,启动解释器,把脚本塞给它当参数,解释器逐行读、逐行执行。 下次在终端里敲 ./program 时,不妨想想内核正在读文件头的那几个字节,然后决定走哪条路。Linux 程序执行的秘密,藏在前 4 个字节里。 延伸阅读 免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作! |