编写一个最小的 64 位 Hello World
原作者:CJ(cj1128)。原载 CJ 博客,页面标示日期为 2020-12-09。本文按原文主线作审校整理,保留入口、ELF、shebang、Tiny Script、Step 0–7、主要代码与最终 ELF 布局;省略部分工具输出和旁支细节,并非逐字全文。本文经作者授权转载。源站声明 © 2026 CJ’s blog, All rights reserved;未见源站或配套仓库声明开放许可,转载授权不构成开放许可。

经典 C 入门程序虽短,却涉及预处理、标准库、启动代码、链接器与内核装载。本文以文件尺寸为线索,观察从 C 源码到 Linux ELF 可执行文件的过程。原文说明实验基于 64 位 x86-64 CentOS 7;配套仓库 README 记载 Linux 3.10.0-862.el7.x86_64 与 Homebrew GCC 5.5.0_7,原文展示 GNU ld(Binutils)2.34,未注明 NASM 版本。尺寸数字和命令结果均为作者配套仓库对原始源码的历史记录;本稿对部分内联汇编作了编辑修正,修订代码未重新构建,因此这些数值不是本稿代码的实测结果,也不能推断在现代工具链或其他架构上相同。
入口、ELF 与 shebang
#include <stdio.h>
int main(void) {
printf("hello, world\n");
return 0;
}
这引出一些构建工具隐藏的问题:谁调用 main 并处理返回值?谁实现 printf?不用它能否输出?ELF(Executable and Linkable Format)既存放可重定位目标文件,也存放可执行文件。ELF 头和程序头描述入口与装载段;Linux 的 /usr/include/elf.h 定义相应结构。原文把原生 ELF 与脚本作对照,但常见 GCC ELF 仍可能依赖动态链接器和共享库;“原生”不等于完全不依赖外部运行时。
脚本也可直接执行,但依赖外部解释器。第一行 #!/bin/bash 是 shebang,Linux 内核识别该格式并按行中解释器路径处理脚本。原文以“读取前 128 字节”说明加载过程;这不应被当作程序可依赖的精确读取承诺。使用 #!/usr/bin/env node 可让 env 在 PATH 查找 Node,免于写死本机安装路径,但仍依赖 /usr/bin/env 存在且 PATH 可找到 Node。
原文记录 Node v11 曾在 Node 代码中移除 shebang;Node v12 所用 V8 支持 Hashbang grammar 后转由引擎解析。这是历史版本说明,不代表当前运行时内部实现。
Linux 的 binfmt_misc 还可注册其他加载规则;原文以包装器运行 Go 源文件为例,将规则写入 /proc/sys/fs/binfmt_misc/register。此操作修改系统配置,本稿未执行。原文创建包装器时使用未引用的 here-document 分隔符,Shell 会在写文件时先展开其中的 $1,因此生成脚本时参数可能已经丢失;若要保留运行时参数,应使用 <<'EOF',并写成 go run "$1",避免拆词和通配符展开。
脚本也可以很短,但仍依赖解释器
原文比较 Ruby 的 puts "hello, world" 与 PHP 短输出语句,并把解释器路径用符号链接缩短。在作者环境中,PHP 脚本记录为 18 字节。它只计算脚本文件本身,不含 PHP 解释器,因而不是独立的原生程序。
缩小 C 生成的 ELF 文件
0. 基准与 1. 符号表
用 gcc -s hello.c -o hello.out 剥除可移除的符号信息后,作者记录为 14,512 字节,减少约 2 KiB。具体保留哪些动态链接所需信息取决于工具链;发布是否保留调试符号仍应按调试需求决定。
2. 优化与 3. 启动文件
尝试 -O3 后,作者记录的尺寸没有变化;优化不能保证每个程序都更小。
通常 GCC 会链接 C 运行时启动文件(crt)。crt1.o 的 _start 准备参数、调用 main 并处理其返回值,另有 crti.o、crtbegin.o、crtend.o、crtn.o。原文把入口改成 nomain 并直接退出:
#include <stdio.h>
#include <unistd.h>
int nomain(void) {
printf("hello, world\n");
_exit(0);
}
作者仓库 Step 3 的实际构建命令是 gcc hello.c -s -O3 -e nomain -nostartfiles -o hello.out,记录为 13,664 字节;原文正文简写为 gcc -e nomain -nostartfiles,没有列出前述 -s -O3。_exit 不执行 C 库通常的完整清理过程,这是去掉运行时开销的学习示例;若 printf 的输出仍在 stdio 缓冲区(例如重定向到文件或管道),直接调用 _exit 可能丢弃它。这里把普通 C 函数直接指定为 ELF 入口;内核跳到 e_entry 时不会像正常 C 函数调用那样压入返回地址,初始栈与函数调用约定也不同。因此该例不是通用、可移植的 C 启动方式;尺寸和行为仅按作者历史环境记录,可靠入口应由符合 ABI 的启动桩处理。
4. 直接调用 Linux 系统调用
Linux x86-64 通过寄存器传入系统调用号和参数,再执行 syscall。输出需要 write(编号 1,文件描述符 1,13 字节),退出使用 exit(编号 60)。以下整理了原文 GCC 内联汇编示例:
const char *str = "hello, world\n";
void myprint(void) {
asm volatile(
"movq $1, %%rax\n"
"movq $1, %%rdi\n"
"movq %0, %%rsi\n"
"movq $13, %%rdx\n"
"syscall\n"
: : "r"(str) : "rax", "rdi", "rsi", "rdx", "rcx", "r11", "memory");
}
__attribute__((noreturn)) void myexit(void) {
asm volatile(
"movq $60, %%rax\n"
"xor %%rdi, %%rdi\n"
"syscall\n" : : : "rax", "rdi", "rcx", "r11", "memory");
__builtin_unreachable();
}
__attribute__((noreturn)) void nomain(void) { myprint(); myexit(); }
作者仓库 Step 4 的构建命令是 gcc hello.c -s -O3 -e nomain -nostartfiles -nostdlib -o hello.out,README 将原始源码与该命令对应为 12,912 字节;原文正文只写了 gcc -nostdlib。此数值不代表本稿补充 clobber 后的代码尺寸。GCC 扩展汇编中的寄存器百分号按 %%rax 形式转义;本稿修正了原文未转义处,并补充 syscall 会破坏的 rcx/r11 与 memory clobber。这是编辑修正,不是原文代码。示例仍不检查 write 的返回值或短写,只适合说明寄存器约定,不是完整系统调用封装。
5. 自定义链接脚本
readelf -S -W 显示节内容虽小,文件偏移却很大。作者展示的 GNU ld 默认脚本按当时 4 KiB MAXPAGESIZE 对齐,造成文件间隙;这是该工具链记录,不是所有 x86-64 链接器或版本的固定值。作者用脚本指定入口和节:
ENTRY(nomain)
SECTIONS {
. = 0x8048000 + SIZEOF_HEADERS;
tiny : { *(.text) *(.data) *(.rodata*) }
/DISCARD/ : { *(*) }
}
作者仓库 Step 5 的命令为 gcc hello.c -s -O3 -e nomain -nostartfiles -nostdlib -T link.lds -o hello.out,README 记录 584 字节;原文正文简写为 gcc -T link.lds。丢弃其他所有节会破坏很多项目所需的初始化、重定位与运行时信息;地址和对齐也都是具体实验条件,不能照搬到 PIE、共享库或任意程序。
6. NASM 汇编
section .data
message: db "hello, world", 0xa
section .text
global nomain
nomain:
mov rax, 1
mov rdi, 1
mov rsi, message
mov rdx, 13
syscall
mov rax, 60
xor rdi, rdi
syscall
作者仓库 Step 6 使用 nasm -f elf64 hello.asm、ld hello.o -T link.lds -o hello.out、strip hello.out,README 记录 440 字节。原文正文只概述汇编和链接步骤。这些指令依赖 Linux x86-64 ABI,不适用于 Windows 或 ARM64。
7. 手工 ELF
最后由 NASM 输出平面二进制,自己填写 ELF 头、程序头、机器码和字符串:
BITS 64
org 0x400000
ehdr:
db 0x7f, "ELF", 2, 1, 1, 0
times 8 db 0
dw 2
dw 0x3e
dd 1
dq _start
dq phdr - $$
dq 0
dd 0
dw ehdrsize
dw phdrsize
dw 1
dw 0
dw 0
dw 0
ehdrsize equ $ - ehdr
phdr:
dd 1
dd 5
dq 0
dq $$
dq $$
dq filesize
dq filesize
dq 0x1000
phdrsize equ $ - phdr
_start:
mov rax, 1
mov rdi, 1
mov rsi, message
mov rdx, 13
syscall
mov rax, 60
xor rdi, rdi
syscall
message: db "hello, world", 0xa
filesize equ $ - $$
作者仓库 Step 7 使用 nasm -f bin hello.asm -o hello.out 并运行 chmod +x hello.out,README 记录 170 字节。ELF 头为 64 字节、程序头 56 字节、代码 37 字节、字符串 13 字节。机器字段 0x3e 表示 x86-64;程序头 flags 为 5(读、执行),对齐 0x1000。字段偏移:入口字段位于 0x18,程序段起始于 0x40,入口地址为 0x400078,代码位于 0x78,字符串位于 0x9d,文件结束地址为 0xaa。任何偏移错误都可能导致内核拒绝加载或执行异常。这个方案依赖 ELF ABI、内核装载器与系统调用约定,不能宣称适用于任意 64 位机器;程序也不检查 write 是否成功。
尺寸变化一览
| 阶段 | 原文尺寸 |
|---|---|
| GCC 基准 | 16,712 字节 |
| 剥符号 | 14,512 字节 |
-O3 |
无变化 |
| 去启动文件 | 13,664 字节 |
| 去标准库 | 12,912 字节 |
| 自定义链接脚本 | 584 字节 |
| 汇编 | 440 字节 |
| 手工 ELF | 170 字节 |
尺寸变小不表示所有这些方式都适合生产。一般程序通常需要可维护的启动代码、重定位、调试信息、运行库和安全加固。手工 ELF 适合学习文件格式与系统调用。原作者说明还有更激进的缩减技巧,但没有在文中展开,并以 Intel 架构手册及其分步代码仓库作为进阶阅读线索。











暂无评论内容