某实验记录(?)
在 AI 引导下做的小实验罢了。
序
也想不太好怎么总结这个实验, 大概知道了 LibC 在操作系统中的地位, 同时也了解了一下用户空间程序大概需要实现些什么。
内容随记
用户程序入口点
- 用户程序的入口点不是 main 函数而是 _start()
- libC 包装了系统调用以及众多基础函数的实现
- 可以用 C 语言写一个完全不依赖 libC 的用户空间程序
- 此时可能需要内联汇编来执行系统调用
静态链接?ldd?
- ldd 的处理对象是“动态可执行文件”,一个”静态”的程序并不是这种程序。
readelf -h 某一行输出:
类型: DYN (Position-Independent Executable file)
类型: EXEC (可执行文件)
这里应该是指该可执行文件是否“位置无关”(PIE)
file 部分输出:
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
statically linked
对于一个非PIE可执行文件,需要指定的 interpreter 来加载。而这个 interpreter 是可以更改的。
用 ldd 查看这种文件,会得到
不是动态可执行文件
这样的报错。同时可以看到程序返回了 1.
还有一种情况,ldd 会输出
statically linked
此时该程序肯定为PIE程序,没有链接到任何动态库。
对这个文件使用 file 查看,仍会得到
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2
的结果。
一般直接用 gcc 的 -static 选项编译出来的文件直接就是非PIE的。
而一个用默认选项编译的、没有使用 libc 的程序(实际可能需要加某些选项才能编译通过),
仍然是 PIE 的,但是用 ldd 查看便会得到前述的 statically linked 的结果。
先放一下 AI 总结算了,之后再自己总结一下。
总之首先第一点就是让 AI 给你写自己完全不熟悉的领域的代码可靠性是极差的。
一次从文件系统到程序本质的命令行探索
摘要: 本文记录了一次技术对话的精华。我们从为 Linux 桌面用户选择文件系统这一实际问题出发,通过对 bcachefs、Ext4 和 Btrfs 的比较,逐步深入。一个关于安卓系统文件格式的疑问,将我们引向了对程序执行本质的探索,最终通过一系列“裸金属”编译实验,清晰地揭示了 C 标准库、系统调用、动态链接器与操作系统内核之间,那看不见却至关重要的协作关系。
第一站:现代文件系统的权衡 - Btrfs 与 Ext4 的抉择
旅程的起点,是为 GNU/Linux 桌面用户寻找最合适的文件系统。我们对比了老牌王者 Ext4 和现代挑战者 Btrfs:
- Ext4 以其无与伦比的稳定性和性能,成为对大多数用户和新手而言,最简单、最可靠的选择。它体现了 Unix “做好一件事”的哲学。
- Btrfs 则以其丰富的现代特性——尤其是写时复制 (Copy-on-Write) 带来的快照功能——为高级用户和开发者提供了强大的系统保护和回滚能力。Fedora 等发行版通过巧妙的自动化配置,将
Btrfs的复杂性隐藏起来,让用户在享受其优势的同时几乎无感。
核心洞见: 文件系统的选择,是在“稳定省心”与“功能强大”之间的一种权衡,反映了不同用户的需求和技术偏好。
第二站:深入移动端 - 安卓揭示的底层架构
一个关于手机文件系统的问题,将我们的视野转向了安卓。我们发现,安卓不仅使用了 Ext4/F2FS 等 Linux 文件系统,其架构本身就是一个绝佳的操作系统学习范例:
- 应用并非纯 Java 程序: 现代安卓应用是混合体。
Kotlin作为官方首选语言负责上层逻辑,而游戏引擎、图像处理等高性能模块则依赖C/C++(通过 NDK) 编写的原生代码。 - ART vs. JVM: 我们澄清了一个关键概念——安卓应用运行在为移动设备优化的 ART (Android Runtime) 之上,而非传统的 JVM。ART 通过 AOT (Ahead-of-Time) 编译,将应用代码预先转换为原生机器码,以提升性能和效率。
- Termux 的启示: Termux 并非模拟器,它内部的
git,python等都是为 ARM 架构原生编译的 ELF 可执行文件,由 Linux 内核直接执行,完全绕过了 ART。这证明了安卓底层就是一个真正的 Linux 环境。 - Bionic vs. glibc: 安卓与 GNU/Linux 最本质的区别,在于 C 标准库:安卓使用 Google 自研的轻量级 Bionic,而 GNU/Linux 使用功能完备的 glibc。这个差异导致了两者在用户空间的全方位不同。
核心洞见: 安卓是一个高度特化的 Linux 发行版。它通过 ART、Bionic 和 FUSE 存储沙箱等技术,在标准的 Linux 内核之上,构建了一个安全、高效、以应用为中心的移动计算环境。
第三站:触及核心 - 系统调用与 C 库的共舞
Termux 的原生程序引出了终极问题:用户程序是如何与操作系统内核交互的?
- 内核与 C 库的关系: 我们确立了“政府与办事大厅”模型。
- 内核是唯一的资源管理者,它通过一套严格、底层、不可移植的接口——系统调用 (System Calls)——提供服务。
- C 标准库 (libc) 则是面向程序员的“办事大厅”。它将复杂的系统调用封装成我们熟悉的、可移植的 POSIX 函数(如
printf,read)。
_start与main: 我们理解到,一个程序的真正入口点是 C 库提供的_start,它负责初始化环境,然后才调用我们编写的main函数。- 动态链接的“总指挥”: 我们揭示了
/lib64/ld-linux-x86-64.so.2这个特殊文件的角色。它是动态链接器,是内核在启动一个动态程序时,第一个唤醒的用户空间程序。它负责加载所有需要的.so库,进行地址重定位,最后才将控制权交给程序的_start函数。
核心洞见: C 库和动态链接器,是构建在内核系统调用之上的、至关重要的抽象层。它们将程序员与底层硬件和操作系统的复杂性隔离开来,创造了一个稳定、可移植、高效的编程“文明”环境。
最终的验证:“裸金属”实验的启示
为了验证理论,我们进行了一系列不链接 C 库的“裸金属”编译实验。这些实验虽然充满了波折,但其结果极具启发性:
- 我们成功地用内联汇编直接发起了系统调用。
- 我们遇到了现代编译器默认行为(如栈保护和 PIE)带来的链接问题,并学会了如何通过编译选项 (
-fno-stack-protector,-no-pie) 来控制它们。 file和ldd工具对同一个文件给出矛盾报告的现象,深刻地揭示了程序格式上的声明与实质上的依赖之间的区别。
最终结论:
这次从一个简单的文件系统选择问题开始的探索,最终带领我们完成了一次对现代操作系统程序执行模型的完整“解剖”。我们理解到,无论是桌面、服务器还是手机,其上运行的每一个程序,都是在内核、C 库、动态链接器这三大支柱的协同工作下,才得以从冰冷的二进制文件,变为鲜活的运行中进程。而理解这三者之间的关系,正是理解所有现代计算系统的关键。
一次对“裸金属”程序的终极解剖——通过编译实验揭示的底层事实
摘要: 在对操作系统原理的探索中,我们进行了一系列旨在创建“自引导 ELF 脚本”的底层编译实验。这个过程虽然充满了挑战,但每一次失败和成功都揭示了关于 Linux 二进制文件格式、链接器行为和程序启动协议的一个个基本事实。本文档只记录那些经过我们反复验证后,能够完全、绝对确认的结论。
事实一:动态链接的“意图”与“实质”可以分离
我们最初的实验,即用 -nostdlib -pie 编译一个程序,产生了一个核心发现:
file工具的判断依据是 ELF 元数据。 当一个 ELF 文件包含一个.interp段(程序解释器段)时,file就会报告它为dynamically linked。这反映了文件被设计时的“意图”。ldd工具的判断依据是实际依赖。ldd通过模拟动态链接器的行为,检查 ELF 文件的.dynamic段中实际声明需要哪些.so库。如果这个依赖列表为空,ldd就会报告statically linked。这反映了程序运行时的“实质”。
结论: 一个 ELF 可执行文件可以在格式上是动态的(因为它需要一个解释器来加载),但在依赖上是静态的(因为它不链接任何外部共享库)。这是现代编译器为了安全(ASLR/PIE)而产生的默认行为。
事实二:链接器脚本是定义 ELF 结构的唯一权威,但极易出错
我们与链接器脚本 (script.ld) 的反复“搏斗”,揭示了它强大能力和巨大风险并存的本质。
ld的默认行为是不可靠的。 在使用自定义链接器脚本时,不能想当然地依赖链接器ld的“智能”默认行为来创建正确的程序头 (Segments)。我们的实验证明,简单的SECTIONS定义会导致.note段污染.interp段,产生垃圾数据。PHDRS必须明确。 手动定义程序头(PHDRS)是保证 ELF 结构正确的唯一可靠方法。我们的实验最终确认,一个能够同时、正确地为.interp,.note和可加载段分配程序头的链接器脚本,是构建特殊 ELF 文件所必需的。KEEP()是强制保留段的唯一手段。 如果一个段(如我们从 C 代码中定义的.interp)没有被任何代码直接引用,链接器可能会将其作为“垃圾”丢弃。链接器脚本中的KEEP(*(.section_name))是强制链接器保留该段的唯一权威指令。
结论: 创建非标准 ELF 文件,必须使用一个明确、详尽的链接器脚本来精确控制每一个段的归宿和属性,任何对默认行为的依赖都可能导致难以预料的链接错误或运行时错误。
事实三:程序作为“解释器”启动时,其入口协议与普通程序完全不同
这是我们所有运行时段错误的最终根源。
- 普通程序入口: 当内核加载一个普通的 PIE 可执行文件时,它会在栈顶准备好
argc,argv,envp,这是一个稳定、可靠的协议。 - 解释器入口: 当内核加载一个被
.interp段指定为解释器的程序时(无论它是 PIE 还是 shared object),它采用的是一套完全不同的、为动态链接器设计的底层协议。 - 栈的不可信: 在解释器入口协议中,栈顶不是
argc。我们反复的段错误实验(从 C 代码的*stack到汇编代码的push失败)最终证明,在_start函数被调用的那一刻,不能对栈指针rsp指向的内存内容做任何“普通程序”的假设。直接尝试解引用rsp是导致崩溃的直接原因。
结论: 一个程序的启动协议,由它在 execve 调用链中的角色(是最终目标还是中间解释器)决定,而不是仅仅由它的文件格式决定。为解释器编写的程序,必须遵循专门的、更底层的入口协议,通常需要直接处理辅助向量(Auxiliary Vector),而不能假设存在 argc/argv。
最终的核心事实
我们所有实验的终点,都指向了一个统一的核心事实:程序执行的“上下文”是由一系列严格的、底层的协议(ABI)所定义的。 无论是链接器脚本的语法,还是程序入口的栈布局,任何对这些协议的偏离或错误假设,都会导致可预测的失败。我们这次漫长而艰难的旅程,本质上就是不断地用实验去碰撞这些看不见的协议边界,直到最终理解并遵循它们为止。