Linux ARM 程序启动全链路解析:从 shell 到 main(含动态/静态链接)
Linux ARM 程序启动全链路解析:从 shell 到 main(含动态/静态链接)
系统框图总览
[Shell] --execve--> [内核 fs/exec.c:do_execveat_common]├─(脚本) fs/binfmt_script.c:load_script│ --bprm_change_interp--> [/bin/bash | /usr/bin/env bash]└─(ELF) fs/binfmt_elf.c:load_elf_binary├─ load_elf_phdrs(顺序读少量页)├─ PT_LOAD → 建 vma(按需映射)├─ PT_INTERP → 映射 ld.so└─ create_elf_tables → 栈+auxv
[ARM do_page_fault] arch/arm(/arm64)/mm/fault.c├─ 文件页:filemap_fault(页缓存命中快;冷启动块I/O慢)├─ 匿名页:do_anonymous_page(BSS/TLS/栈)└─ COW :do_wp_page(写时复制)
[解释器 ld.so]├─ 解析 DT_NEEDED → 打开 .so├─ REL/RELA 重定位 + GOT/PLT(懒绑定可延后)├─ TLS 初始化└─ __libc_start_main├─ 运行 PREINIT/INIT_ARRAY└─ 跳转 main()
- 热点路径:
filemap_fault
(文件页)、do_anonymous_page
(BSS/TLS/栈)、do_wp_page
(COW)、ld.so
重定位与懒绑定、__libc_start_main
进入main
前构造函数。
全局总览(一屏看懂)
- 输入:shell 触发
execve
(脚本或 ELF)。 - 内核:识别格式 → 若脚本经
binfmt_script
切到解释器;若 ELF 经binfmt_elf
映射段,存在PT_INTERP
则先映射ld.so
。 - 内存:建立
VMA
(PT_LOAD
按需映射),不立即把所有字节读入;首次访问由缺页(do_page_fault
)驱动完成。 - 动态链接:
ld.so
打开依赖.so
,解析/重定位(REL/RELA
、GOT/PLT
、TLS
),再调用__libc_start_main
。 - 进入 main 前:构建用户栈与
auxv
、执行构造函数(.init_array
),随后才进入main
。 - 耗时关键:冷启动的文件页缺页(块 I/O)、动态链接的重定位与符号查找、首次 PLT 懒绑定。
适配 Linux 4.4.94 内核,以 ARM32/ARM64 平台为例,聚焦:
- 时间消耗关键路径(I/O、动态链接、缺页、重定位、符号解析)
- 各段如何被加载(ELF 头、PT_LOAD 段、符号/重定位、BSS/TLS、解释器)
- 可执行文件大小与启动耗时关系(顺序读、按需缺页、缓存命中)
- page fault 行为(文本页/数据页、匿名零页、写时复制、文件映射)
参考关键源码:
fs/exec.c
:do_execveat_common
,bprm_change_interp
,create_arg_pages
,setup_arg_pages
fs/binfmt_elf.c
:load_elf_binary
,load_elf_phdrs
,elf_map
,create_elf_tables
,setup_arg_pages
fs/binfmt_script.c
:load_script
include/linux/binfmts.h
:linux_binprm
- ARM 缺页入口:
arch/arm/mm/fault.c
/arch/arm64/mm/fault.c
- 通用缺页处理:
mm/memory.c
:handle_mm_fault
,__handle_mm_fault
,handle_pte_fault
,do_fault*
,filemap_fault
(mm/filemap.c
)
1. 从 shell 到内核 exec 路径(含 shebang 与 ELF 解释器)
- shell 解析脚本行,命令解析 → 若目标为脚本且首行
#! /path/to/interpreter
:用户态调用execve()
进入内核。 fs/exec.c::do_execveat_common
:复制参数/环境,填充linux_binprm
,检测魔数/格式。- 若为脚本:
fs/binfmt_script.c::load_script
解析 shebang,移除旧argv[0]
(脚本路径),然后按顺序装入:脚本路径、可选的“单一整串参数”、解释器路径,并通过bprm_change_interp()
将执行目标切到解释器(如/bin/bash
、/usr/bin/env bash
)。最终用户态看到的argv
形如:- 无参数:
[解释器, 脚本路径, 原脚本其余参数...]
- 有参数(整串):
[解释器, 额外参数, 脚本路径, 原脚本其余参数...]
- 示例:
#!/bin/bash
→ 解释器/bin/bash
,argv
:[bash, 脚本, ...]
#!/usr/bin/env bash
→ 解释器/usr/bin/env
,额外参数作为“单一整串”"bash"
,argv
:[env, "bash", 脚本, ...]
#!/usr/bin/env -S bash -O extglob
→ 内核仍将"-S bash -O extglob"
作为单一参数推入argv
:[env, "-S bash -O extglob", 脚本, ...]
;后续由env
在用户态按-S
规则拆分为bash -O extglob
并转发。
- 无参数:
- 若为 ELF:
fs/binfmt_elf.c::load_elf_binary
解析 ELF 头、Program Headers,若存在PT_INTERP
,则设置解释器为动态链接器(ARM32:/lib/ld-linux.so.3
、ARM64:/lib/ld-linux-aarch64.so.1
)。随后内核先映射解释器,再映射主程序。
时间关注点:
- 脚本场景:额外打开脚本与解释器二进制,I/O 次数增加;解释器自身再
execve
/dlopen
等加载库。 - ELF 场景:有
PT_INTERP
时会先加载动态链接器(ld.so),再由其解析依赖库;无PT_INTERP
(静态链接)则直接进入程序入口_start
。
2. 段映射与内存镜像构建(按需映射,而非一次性读入)
ELF 映射核心(细化实现与对齐规则):
fs/binfmt_elf.c::load_elf_phdrs
顺序读取 Program Header 表(e_phoff
/e_phnum
/e_phentsize
),I/O 连续且很少,通常命中页缓存后几乎不耗时。fs/binfmt_elf.c::elf_map
最终调用do_mmap_pgoff
以MAP_FIXED|MAP_PRIVATE
建立vma
;p_vaddr
与p_offset
会按页大小向下对齐,p_align
由链接器保证,权限来自p_flags → PROT_{R,W,X}
,对应vm_flags
(如VM_EXEC
/VM_MAYWRITE
)。- 代码段(RX):文件私有映射,不可写;首次执行/读取触发缺页,经页缓存按需读取文本页。
- 数据段(RW):
p_filesz
部分为文件私有映射(已初始化数据);p_memsz - p_filesz
的部分为匿名零填充(BSS)。- 末页零填充:内核使用
padzero
把“已初始化数据的最后一页中未覆盖的尾部”清零,避免残留旧字节。 - BSS 区域扩展:通过
set_brk(elf_bss, elf_brk)
/vm_brk
创建匿名vma
;这些页在首次写入时由do_anonymous_page
分配物理页。
- 末页零填充:内核使用
PT_TLS
:动态链接器读取 TLS 模板(p_vaddr/p_filesz/p_memsz
),为每线程复制p_filesz
并零填p_memsz - p_filesz
;ARM32 的线程指针寄存器为tp
(实现相关),ARM64 使用TPIDR_EL0
记录线程本地基址。- 解释器(ld.so):若主程序包含
PT_INTERP
,内核先以同样方式映射解释器(得到AT_BASE
),再映射主程序;解释器进入用户态继续加载依赖库。
符号/重定位(ARM/ARM64 关键点):
- 静态链接:链接时地址已决议;运行时无符号查找,无
PLT/GOT
解析路径;首屏主要是缺页与 BSS 分配。 - 动态链接:解释器解析
DT_NEEDED
并按路径顺序查找依赖(LD_LIBRARY_PATH
→RPATH/RUNPATH
→ld.so.cache
→ 默认路径)。- 表项解析:读取
DT_SYMTAB
/DT_STRTAB
(符号/字符串表),DT_REL/DT_RELA
(非跳转重定位),DT_PLTREL/DT_JMPREL
(PLT 重定位)。 - 常见重定位:
- ARM32:
R_ARM_RELATIVE
(大量,相对地址修正)、R_ARM_GLOB_DAT
/R_ARM_JUMP_SLOT
(全局数据/PLT)、R_ARM_ABS32
、R_ARM_COPY
(拷贝符号)。 - ARM64:
R_AARCH64_RELATIVE
、R_AARCH64_GLOB_DAT
/R_AARCH64_JUMP_SLOT
、R_AARCH64_ABS64
等。
- ARM32:
- PLT 懒绑定:首次调用时通过解析器
dl_runtime_resolve
完成符号查找并写入 GOT;设置LD_BIND_NOW=1
或链接器选项-z now
可改为启动期一次性绑定(更快的首次调用,启动更久)。 - RELRO:若启用
-z relro
/-z now
,部分节在重定位后设为只读以提高安全性,但会增加启动写入阶段与页保护切换。
- 表项解析:读取
补充:TLS 与构造函数
- TLS 模型:
initial-exec
、global-dynamic
等模型影响是否在启动期或首次访问时开销更高(目录表查找、DTV
更新)。 - 构造函数:解释器在调用
__libc_start_main
前运行依赖库与主程序的.init_array
,这些代码与其引用的数据会触发相应文本/数据页缺页。
时间关注点(更细化):
- Program Header I/O:通常 1–2 页顺序读;冷启动下仍很快,非瓶颈。
- 文本/数据页缺页:首次执行/读取触发
filemap_fault
;页缓存命中为“次/微秒级”,冷启动需块 I/O(毫秒级),形成启动主耗时。 - 重定位:
RELATIVE
数量巨大时造成 CPU 密集访存并触发对符号/字符串表页的缺页;GLOB_DAT/JUMP_SLOT
受符号个数与查找成本影响。 - 懒绑定:把部分成本延后到第一次调用;如果首屏立刻调用大量外部符号,热点会集中在解析器与哈希查找(
GNU_HASH
)上。 - BSS/TLS/栈:匿名页分配走内存分配与 COW 路径,无磁盘 I/O,但在内存压力下可能受分配延迟影响。
[Shell] --execve--> [fs/exec.c:do_execveat_common]├─(脚本) [fs/binfmt_script.c:load_script]│ --bprm_change_interp--> [/bin/bash]│ (I/O: 读取脚本 + 解释器ELF)└─(ELF) [fs/binfmt_elf.c:load_elf_binary]├─ load_elf_phdrs (顺序读I/O,受缓存影响)├─ PT_LOAD → elf_map/vm_mmap (建 vma,不读入数据)├─ PT_INTERP → 映射 ld.so (I/O)└─ create_elf_tables → 用户栈+auxv (轻量)[ARM do_page_fault] arch/arm(/arm64)/mm/fault.c└─ handle_mm_fault → __handle_mm_fault → handle_pte_fault (mm/memory.c)├─ 文件页:filemap_fault (mm/filemap.c)│ → 页缓存命中(快) / 冷启动块I/O(慢)├─ 匿名零页:do_anonymous_page (分配物理页)└─ COW:do_wp_page (复制页,带宽敏感)[解释器 ld.so]├─ 解析 DT_NEEDED → 打开 .so (I/O)├─ REL/RELA 重定位 (CPU + 可能触发缺页)├─ GOT/PLT 构建;懒绑定首调开销 (可用 LD_BIND_NOW 改为启动期)├─ TLS 初始化 (每线程数据区)└─ __libc_start_main(main, ...)[程序]├─ 运行构造函数 .init_array (可能触发文件页缺页)└─ 进入 main() (首屏路径涉及代码/数据页触发)
7. 关键源码定位
fs/exec.c
:do_execveat_common
,bprm_change_interp
,setup_new_exec
,create_arg_pages
,setup_arg_pages
fs/binfmt_elf.c
:load_elf_binary
,load_elf_phdrs
,elf_map
,create_elf_tables
fs/binfmt_script.c
:load_script
mm/memory.c
:handle_mm_fault
,__handle_mm_fault
,handle_pte_fault
,do_fault*
mm/filemap.c
:filemap_fault
arch/arm/mm/fault.c
,arch/arm64/mm/fault.c
:do_page_fault
,__do_page_fault
include/linux/binfmts.h
:linux_binprm
8. 性能诊断建议(实操)
- 统计缺页与 I/O:
perf stat -e page-faults,major-faults,minor-faults -e task-clock
,perf record
/perf report
观察do_page_fault
/filemap_fault
热点。 - 观测动态链接耗时:设置
LD_DEBUG=libs,reloc,statistics
,查看库解析与重定位统计。 - 预热页缓存:在启动前
cat
/readahead
可执行文件与关键.so
,或运行一次以填充缓存。 - 减少文本段首次缺页:将热点初始化代码体积控制在少量页内,避免零散访问。
9. FAQ 速查
- “函数符号如何加载?”:符号表本身不映射为运行时必需数据;动态链接在解析时读取
DT_SYMTAB
/DT_STRTAB
所在页,完成后主要通过 GOT/PLT 间接访问函数入口;静态链接则已在链接期决议,无运行时符号解析。 - “静态变量在哪里?”:位于数据段(已初始化)或 BSS(未初始化,零页供给),在
PT_LOAD
的 RW 映射中。 - “文件大是否一定慢?”:决定启动耗时的是“首次实际访问到的页数”和“动态链接工作量”,不是总文件大小;热缓存命中时差异更小。
- “脚本触发与直接 ELF 执行差别?”:脚本会先切到解释器,再由解释器执行命令/再触发新的
execve
;相较直接 ELF,多一次解释器加载与其自身初始化开销。
6A. 实际案例:ARM 上从脚本触发一个动态链接程序
- 准备示例:
- 脚本
run_app.sh
:#!/usr/bin/env bash ./app-dyn
- 程序
app-dyn
:动态链接构建(示例,C 源码)- 源码
app.c
:#include <stdio.h> #include <math.h> extern void foo_init(void); // 来自自定义共享库,可选 int main(void) {foo_init(); // 若未提供 libfoo.so,此调用可注释double x = 0.5;printf("cos(%f)=%f\n", x, cos(x));return 0; }
- 编译:
arm-linux-gnueabihf-gcc -O2 app.c -o app-dyn -Wl,--as-needed -lm
(如需libfoo.so
:在同目录放置并-lfoo -L.
) - 依赖:
libc.so
,libm.so
,可选libfoo.so
- 源码
- 脚本
- 运行:
chmod +x run_app.sh && ./run_app.sh
- 观察:
strace -f -o trace.txt ./run_app.sh
perf stat -e page-faults,major-faults,minor-faults -e task-clock ./run_app.sh
- (可选)
LD_DEBUG=libs,reloc,statistics ./run_app.sh
查看动态链接统计
流程图(简化):
run_app.sh --execve--> do_execveat_common└─ load_script → bprm_change_interp → /usr/bin/env bash└─ bash execve ./app-dyn → load_elf_binary├─ load_elf_phdrs(顺序读)├─ PT_INTERP → 映射 ld.so├─ PT_LOAD → 建 vma(按需)└─ create_elf_tables → 栈+auxvARM do_page_fault → handle_mm_fault├─ filemap_fault:.text/.rodata/.data 的文件页按需载入(冷启动块I/O)├─ do_anonymous_page:BSS/TLS/栈分配(零页,无磁盘I/O)└─ do_wp_page:首次写 .data 触发 COW(页复制)ld.so:解析 DT_NEEDED,REL/RELA,GOT/PLT,TLS → __libc_start_main程序:.init_array → main
首屏耗时定位建议:
- 代码页首访:查看
perf report
中do_page_fault/filemap_fault
的比例;冷启动下占比高。 - 动态链接重定位:
LD_DEBUG=statistics
查看relocations
数量与time
;符号多、库多时显著。 - 懒绑定:首次调用某些函数时看到
ld.so
相关符号查找热点。
小节补充说明:文本/数据页缺页 vs 匿名页缺页
- 文本页缺页(
.text
):PT_LOAD
的 RX 文件私有映射,CPU取指首次命中未映射页触发;处理路径filemap_fault
,页缓存未命中需块 I/O(major fault)。 - 数据页缺页(
.rodata
/.data
的文件部分):PT_LOAD
的 R/RW 文件私有映射,首次读取触发;处理路径同filemap_fault
。对.data
的首次写入会走do_wp_page
将该页复制为匿名页(COW)。 - 匿名页缺页(
BSS
/TLS
/栈):不从文件读,首次访问经do_anonymous_page
分配零页;无磁盘 I/O,但存在内存分配与可能的COW成本。 - 末页与 BSS:
padzero
清理已初始化数据末页未覆盖尾部;set_brk
/vm_brk
扩展 BSS 的匿名vma
,首写分配。
小节补充说明:进入 main 前执行与耗时来源
- 执行顺序(动态链接):
- 解释器
ld.so
完成依赖映射与重定位后,先运行“共享库的.init_array/DT_INIT
”与主程序的.preinit_array
;随后调用__libc_start_main
。 __libc_start_main
在进入main
之前,运行主程序的.init_array/DT_INIT
(以及注册的构造函数),最后跳转main
。
- 解释器
- 执行顺序(静态链接):
- 由启动例程直接调用
__libc_start_main
,在进入main
之前运行主程序的.init_array
/构造函数。
- 由启动例程直接调用
- 耗时构成:
- 文件页缺页:构造函数代码首次执行引发文本页缺;访问只读常量或已初始化数据引发数据页缺。冷启动下,major fault(块I/O)为主耗时。
- 动态链接工作:
REL/RELA
重定位(尤其大量RELATIVE
)、符号查找(GLOB_DAT/JUMP_SLOT
),以及首次 PLT 懒绑定会在进入main
前或构造阶段发生。 - 匿名页分配:
BSS/TLS/栈
的首写分配与 COW 复制产生内存开销,非磁盘 I/O,但在内存压力下会变慢。
- 来自可执行/库的具体来源:
- 可执行文件:其
.init_array
/.text
/.rodata
/.data
;若PT_INTERP
存在,解释器先行映射与执行其自身初始化。 - 依赖库:各库的
.text
/.rodata
/.data
与.init_array
;库越多、构造函数越多,启动前的缺页与重定位开销越高。
- 可执行文件:其
- 观测建议:
perf stat -e page-faults,major-faults,minor-faults -e task-clock
量化缺页与CPU时间;perf report
查看filemap_fault/do_page_fault/ld.so
热点。LD_DEBUG=libs,reloc,statistics
统计库加载与重定位耗时;readelf -W -a
查看INIT/INIT_ARRAY/PREINIT_ARRAY
。