前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
圈层
工具
发布
首页
学习
活动
专区
圈层
工具
MCP广场
社区首页 >专栏 >只有170字节,最小的64位Hello World程序这样写成

只有170字节,最小的64位Hello World程序这样写成

作者头像
机器之心
发布于 2023-03-29 11:23:07
发布于 2023-03-29 11:23:07
54902
代码可运行
举报
文章被收录于专栏:机器之心机器之心
运行总次数:2
代码可运行

机器之心转载

作者:CJ Ting

最简单的 C 语言 Hello World 程序,底层到底发生了什么?如何编写出最小的 64 位 Hello World 程序?

Hello World 应该是每一位程序员的启蒙程序,出自于 Brian Kernighan 和 Dennis Ritchie 的一代经典著作 The C Programming Language。

代码语言:javascript
代码运行次数:0
运行
复制
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// hello.c#include <stdio.h>int main() {  printf("hello, world\n");  return 0;}
代码语言:javascript
代码运行次数:0
运行
复制

这段代码我想大家应该都太熟悉了,熟悉到可以默写出来。虽然是非常简单的代码,但是如果细究起来,里面却隐含着很多细节:

  • #include <stdio.h> 和 #include "stdio.h" 有什么区别?
  • stdio.h 文件在哪里?里面是什么内容?
  • 为什么入口是 main 函数?可以写一个程序入口不是 main 吗?
  • main 的 int 返回值有什么用?是谁在处理 main 的返回值?
  • printf 是谁实现的?如果不用 printf 可以做到在终端中打印字符吗?

上面这些问题其实涉及到程序的编译、链接和装载,日常工作中也许大家并不会在意。

现代 IDE 在方便我们开发的同时,也将很多底层的细节隐藏了起来。往往写完代码以后,点击「构建」就行了,至于构建在发生什么,具体是怎么构建的,很多人并不关心,甚至根本不知道从源代码到可执行程序这中间经历了什么。

编译、链接和装载是一个巨大的话题,不是一篇博客可以覆盖的。在这篇博客中,我想使用「文件尺寸」作为线索,来介绍从 C 源代码到可执行程序这个过程中,所经历的一系列过程。

Tip: 关于编译、链接和装载,这里想推荐一本书《程序员的自我修养》。不得不说,这个名字起得非常不好,很有哗众取宠的味道,但是书的内容是不错的,值得一看。

我们先来编译上面的程序:

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
$ gcc hello.c -o hello$ ./hellohello, world$ ll hello-rwxr-xr-x 1 root root 16712 Nov 24 10:45 hello
代码语言:javascript
代码运行次数:0
运行
复制

Tip: 后续所有的讨论都是基于 64 位 CentOS7 操作系统。

我们会发现这个简单的 hello 程序大小为 16K。在今天看来,16K 真的没什么,但是考虑到这个程序所做的事情,它真的需要 16K 吗?

在 C 诞生的上个世纪 70 年代,PDP-11 的内存为 144K,如果一个 hello world 就要占 16K,那显然是不合理的,一定有办法可以缩减体积。

Tip: 说起 C 语言,我想顺带提一下 UNIX。没有 C 就没有 UNIX 的成功,没有 UNIX 的成功也就没有 C 的今天。诞生于上个世纪 70 年代的 UNIX 不得不说是一项了不起的创造。 这里推荐两份关于 UNIX 的资料:

The UNIX Time-Sharing System 是1974 年由 Dennis Ritchie 和 Ken Thompson 联合发表的介绍 UNIX 的论文。不要被「论文」二字所吓到,实际上,这篇文章写得非常通俗易懂,由 UNIX 的作者们向你娓娓道来 UNIX 的核心设计理念。 The UNIX Operating System 是一段视频,看身着蓝色时尚毛衣的 Kernighan 演示 UNIX 的特性,不得不说,Kernighan 简直太帅了。

接下来我们来玩一个游戏,目标是:在 CentOS7 64 位操作系统上,编写一个体积最小的打印 hello world 的可执行程序。

Executable

我们先来看「可执行程序」这个概念。

什么是可执行程序?按照字面意思来理解,那就是:可以执行的程序。

ELF

上面用 C 编写的 hello 当然是可执行程序,毫无疑问。

实际上,我们可以说它是真正的「可执行」程序(区别于后文的脚本),或者说「原生」程序。

因为它里面包含了可以直接用于 CPU 执行的机器代码,它的执行无需借助外部。

hello 的存储格式叫做 ELF,全称为 Executable and Linkable Format,看名称可以知道,它既可以用于存储目标文件,又可以用于存储可执行文件。

ELF 本身并不难理解,/usr/include/elf.h 中含有 ELF 结构的详细信息。难理解的是由 ELF 所掀开的底层世界,目标文件是什么?和执行文件有什么区别?链接在干什么?目标文件怎样变成可执行文件等等等等。

Shebang

接下来我们来看另外一种形式的可执行程序——脚本。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
$ cat > hello.sh <<EOF#!/bin/bashecho "hello, world"EOF$ chmod +x hello.sh$ ./helo.shhello, world
代码语言:javascript
代码运行次数:0
运行
复制

按照定义,因为这个脚本可以直接从命令行执行,所以它是可执行程序。

那么 hello 和 hello.sh 的区别在哪里?

可以发现 hello.sh 的第一行比较奇怪,这是一个叫做 Shebang 的东西 #!/bin/bash,这个东西表明当前文件需要 /bin/bash 程序来执行。

所以,hello 和 hello.sh 的区别就在于:一个可以直接执行不依赖于外部程序,而另一个需要依赖外部程序。

我曾经有一个误解,认为 Shebang 是 Shell 在处理,当 Shell 执行脚本时,发现第一行是 Shebang,然后调用相应的程序来执行该脚本。

实际上并不是这样,对 Shebang 的处理是内核在进行。当内核加载一个文件时,会首先读取文件的前 128 个字节,根据这 128 个字节判断文件的类型,然后调用相应的加载器来加载。

比如说,内核发现当前是一个 ELF 文件(ELF 文件前四个字节为固定值,称为魔数),那么就调用 ELF 加载器。

而内核发现当前文件含有 Shebang,那么就会启动 Shebang 指定的程序,将当前路径作为第一个参数传入。所以当我们执行 ./hello.sh 时,在内核中会被变为 /bin/bash ./hello.sh。

这里其实有一个小问题,如果要脚本可以从命令行直接执行,那么第一行必须是 Shebang。Shebang 的形式固定为 #! 开头,对于使用 # 字符作为注释的语言比如 Python, Ruby, Elixir 来说,这自然不是问题。但是对于 # 字符不是注释字符的语言来说,这一行就是一个非法语句,必然带来解释错误。

比如 JavaScript,它就不使用 # 作为注释,我们来写一个带 Shebang 的 JS 脚本看看会怎么样。

代码语言:javascript
代码运行次数:0
运行
复制
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
$ cat <<EOF > test.js#!/usr/bin/env nodeconsole.log("hello world")EOF$ chmod +x test.js$ ./test.jshello world
代码语言:javascript
代码运行次数:0
运行
复制

并没有出错,所以这里是怎么回事?按道理来说第一行是非法的 JS 语句,解释器应该要报错才对。

如果把第一行的 Shebang 拷贝一份到第二行,会发现报了 SyntaxError,这才是符合预期的。所以必然是 Node 什么地方对第一行的 Shebang 做了特别处理,否则不可能不报错。

大家可以在 Node 的代码里面找一找,看看在什么地方 😉

答案是什么地方都没有,或者说在最新的 Node 中,已经没有地方在处理 Shebang 了。

在 Node v11 中,我们可以看到相应的代码(https://github.com/nodejs/node/blob/v11.15.0/lib/internal/main/check_syntax.js#L50)。

stripShebang 函数很明显,它的作用在于启动 JS 解释器的时候,将第一行的 Shebang 移除掉。

但是在 Node v12 以后,Node 更新了 JS 引擎 V8 到 7.4,V8 在这个版本中实现一个叫做 Hashbang grammar 的功能,也就是说,从此以后,V8 可以处理 Shebang 了,因此 Node 删除了相关代码。

因为 Shebang 是 V8 在处理了,所以我们在浏览器中也可以加载带有 Shebang 的 JS 文件,不会有任何问题~

我们可以得出结论,支持作为脚本使用的语言,如果不使用 # 作为注释字符,那么必然要特别处理 Shebang,否则使用起来就太不方便了。

/usr/bin/env

上面的 test.js 文件中,不知道大家是否注意到,解释器路径写的是 /usr/bin/env node。

这样的写法如果经常写脚本,应该不陌生,我之前一直这样用,但是没有仔细去想过为什么。

首先我们来看 /usr/bin/env 这个程序是什么。

根据 man env 返回的信息:env - run a program in a modified environment.

env 的主要作用是修改程序运行的环境变量,比如说

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
$ export name=shell$ node> process.env.name'shell'$ env name=env node> process.env.name'env'
代码语言:javascript
代码运行次数:0
运行
复制

通过 env 我们修改了 node 运行时的环境变量。但是这个功能和我们为什么要在 Shebang 中使用 env 有什么关系?

在 Shebang 中使用 env 其实是因为另外一个原因,那就是 env 会在 PATH 中搜索程序并执行。

当我们执行 env abc 时,env 会在 PATH 中搜索 abc 然后执行,就和 Shell 一样。

这就解释了为什么我们要在脚本中使用 /usr/bin/env node。对于想要给他人复用的脚本,我们并不清楚他人系统上 node 的路径在哪里,但是我们清楚的是,它一定在 PATH 中。

而同时,绝大部分系统上,env 程序的位置是固定的,那就是 /usr/bin/env。所以,通过使用 /usr/bin/env node,我们可以保证不管其他用户将 node 安装在何处,这个脚本都可以被执行。

binfmt_misc

前面我们提到过,内核对于文件的加载其实是有一套「多态」机制的,即根据不同的类型来选择不同的加载器。

那么这个过程我们可以自己定制吗?

当然可以,内核中有一个加载器叫做 binfmt_misc,看名字可以知道,这个加载器用于处理各种各样非标准的其他类型。

通过一套语法,我们可以告知 binfmt_misc 加载规则,实现自定义加载。

比如我们可以通过 binfmt_misc 实现直接运行 Go 文件。

代码语言:javascript
代码运行次数:0
运行
复制
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
# 运行 Go 文件的指令是 `go run`,不是一个独立的程序# 所以,我们先要写一个脚本包装一下$ cat <<EOF > /usr/local/bin/rungo#!/bin/bashgo run $1EOF# 接下来写入规则告诉 binfmt_misc 使用上面的程序来加载所有# 以 .go 结尾的文件$ echo ':golang:E::go::/usr/local/bin/rungo:' > /proc/sys/fs/binfmt_misc/register# 现在我们就可以直接运行 Go 文件了$ cat << EOF > test.gopackage mainimport "fmt"func main() {  fmt.Println("hello, world")}EOF$ chmod +x test.go$ ./test.gohello, world
代码语言:javascript
代码运行次数:0
运行
复制

Tiny Script

根据上面的知识,如果我们想要编写一个体积最小的打印 hello world 的脚本,我们要在这两方面着手:

  • 解释器路径要尽量短;
  • 脚本本身用于打印的代码要尽量短。

解释器的路径很好处理,我们可以使用链接。

脚本本身的代码要短,这就很考验知识了,我一开始想到的是 Ruby,puts "hello, world" 算是非常短的代码了,没有一句废话。但是后来 Google 才发现,还有更短的,那就是 PHP 😉

PHP 中 打印 hello world 的代码就是 hello, world,对的,你没看错,连引号都不用。

所以,最终我们的结果如下:

代码语言:javascript
代码运行次数:0
运行
复制
代码语言:javascript
代码运行次数:0
运行
复制
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
# 假设 php 在 /usr/local/bin/php$ cd /$ ln -s /usr/local/bin/php p$ cat <<EOF > final.php#!/phello, worldEOF$ chmod +x final.php$ ./final.phphello, world$ ll final.php-rwxr-xr-x 1 root root 18 Dec  2 22:32 final.php
代码语言:javascript
代码运行次数:0
运行
复制

在脚本模式下,我们的成绩是 18 个字节,使用的解释器是 PHP。

其实在脚本模式下编写最小的 hello world 没有太大意义,因为我们完全可以自己写一个输出 hello world 的程序作为解释器,然后脚本里面只要 #!/x 就行了。

Tiny Native

上面的脚本只是抛砖引玉,接下来我们进入正题,怎样编写一个体积最小的打印 hello world 的原生可执行程序?

网上有很多关于这个话题的讨论,但基本都是针对 x86 的。现如今 64 位机器早就普及了,所以我们这里针对的是 64 位的 x64。

Tip: 64 位机器可以执行 32 位的程序,比如我们可以使用 gcc -m32 来编译 32 位程序。但这只是一个后向兼容,并没有充分利用 64 位机器的能力。

Step0

首先,我们使用上文提到的 hello.c 作为基准程序。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
// hello.c#include <stdio.h>int main() {  printf("hello, world\n");  return 0;}

gcc hello.c -o hello.out 编译以后,它的大小是 16712 个字节。

Step1: Strip Symbols

第一步,也是最容易想到的一步,剔除符号表。

符号是链接器工作的的基本元素,源代码中的函数、变量等被编译以后,都变成了符号。

如果经常从事 C 开发,一定遇到过 ld: symbol not found 的错误,往往是忘记链接了某个库导致的。

使用 nm 我们可以查看一个二进制程序中含有哪些符号。

Tip: nm 是「窥探」二进制的一个有力工具。记得之前有一次苹果调整了 iOS 的审核策略,不再允许使用了 UIWebView 的 App 提交。我们的 IPA 里面不知道哪个依赖使用了 UIWebView,导致苹果一直审核不过,每次都要二分注释、打包、提交审核,然后等待苹果的自动检查邮件告知结果,非常痛苦。 后来我想到了一个办法,就是使用 nm 查看编译出来的可执行程序,看看里面是否有 UIWebView 相关的 symbol,这大大简化了调试流程,很快就定位到问题了。

对 step0 中的 hello.out 程序使用 nm,输出如下:

可以看到有一个符号叫做 main,这个对应的就是我们的 main 函数。但是很奇怪没有看到 printf,而是出现了一个叫做 puts@@GLIBC_2.2.5 的符号。

这里其实是 GCC 做的一个优化,如果没有使用格式字符串调用 printf,GCC 会将它换成 puts。

这些符号都存储在了 ELF 中,主要用于链接,对于可执行文件来说,符号并没有什么太大作用,所以我们首先可以通过剔除符号表来节省空间。

有两个方法,第一是通过 strip,第二是通过 GCC 参数。

这里我们使用第二个方法,gcc -s hello.c -o hello.out 得到新的不含符号表的可执行程序,它的大小是 14512 字节。

虽然结果还是很大,但是我们省了 2K 左右,不错,再接再厉。

Step2: Optimization

第二个比较容易想到的办法就是优化,开启优化以后编译器会生成更加高效的指令,从而减小文件体积。

使用 gcc -O3 编译我们的程序,然后会发现,结果没有任何变化😂。

其实也非常合理,因为这个程序太简单了,没什么好优化的。

看来要再想想别的办法。

Step3: Remove Startup Files

之前我们提到过一个问题,是谁在调用 main 函数?

实际上我们编写的程序都会被默认链接到 GCC 提供的 C 运行时库,叫做 crt。

通过 gcc --verbose 我们可以查看编译链接的详细日志。

可以发现我们的程序链接了 crt1.o, crti.o, crtbegin.o, crtend.o 以及 crtn.o。

其中 crt1.o 里面提供的 _start 函数是程序事实上的入口,这个函数负责准备 main 函数需要的参数,调用 main 函数以及处理 main 函数的返回值。

上面这些 crt 文件统称为 Start Files。所以,现在我们的思路是,可不可以不用这些启动文件?

_start 函数主要功能有两个,第一是准备参数,我们的 main 不使用任何参数,所以这一部分可以忽略。

第二是处理返回值,具体的处理方式是使用 main 函数的返回值调用 exit 系统调用进行退出。

所以如果我们不使用启动文件的话,只需要自己使用系统调用退出即可。

因为我们现在不使用 _start 了,自然我们的主函数也没必要一定要叫做 main,这里我们改个名字突出一下这个事实。

代码语言:javascript
代码运行次数:0
运行
复制
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
#include <stdio.h>#include <unistd.h>intnomain(){  printf("hello, world\n");  _exit(0);}
代码语言:javascript
代码运行次数:0
运行
复制

unistd.h 里面提供系统调用的相关函数,这里我们使用的是 _exit。为什么是 _exit 而不是 exit?可以参考这个回答「What is the difference between using _exit() & exit() in a conventional Linux fork-exec?」。

通过 gcc -e nomain -nostartfiles 编译我们的程序,其中 -e 指定入口,--nostartfiles 作用很明显,告诉 GCC 不必链接启动文件了。

我们得到的结果是 13664 个字节,不错,又向前迈进了一步。

Step4: Remove Standard Library

现在我们已经不使用启动文件了,但是我们还在使用标准库,printf 和 _exit 函数都是标准库提供的。

可不可以不使用标准库?

当然也可以。

这里就要说到系统调用,用户程序和操作系统的交互通过一系列称为「系统调用」的过程来完成。

比如 syscall_64 是 64 位 Linux 的系统调用表,里面列出了 Linux 提供的所有系统调用。

系统调用工作在最底层,通过约定的寄存器传递参数,然后使用一条特别的指令,比如 32 位 Linux 是 int 80h,64 位 Linux 是 syscall 进入系统调用,最后通过约定的寄存器获取结果。

C 标准库里面封装了相关函数帮助我们进行系统调用,一般我们不用关心调用细节。

现在如果我们不想使用标准库,那么就需要自己去完成系统调用,在 hello 程序中我们使用了两个系统调用:

  • write: 向终端打印字符实际上就是向终端对应的文件写入数据
  • exit: 退出程序

因为要访问寄存器,所以必须要使用内联汇编。

最终代码如下,在 C 中内联汇编的语法可以参考这篇文档(https://www.ibiblio.org/gferg/ldp/GCC-Inline-Assembly-HOWTO.html)。

代码语言:javascript
代码运行次数:0
运行
复制
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
char *str = "hello, world\n";
voidmyprint(){  asm("movq $1, %%rax \n"      "movq $1, %%rdi \n"      "movq %0, %%rsi \n"      "movq $13, %%rdx \n"      "syscall \n"      : // no output      : "r"(str)      : "rax", "rdi", "rsi", "rdx");}
voidmyexit(){  asm("movq $60, %rax \n"      "xor %rdi, %rdi \n"      "syscall \n");}
intnomain(){  myprint();  myexit();}
代码语言:javascript
代码运行次数:0
运行
复制

使用 gcc -nostdlib 编译我们的程序,结果是 12912 字节。

能去的我们都去掉了,为什么还是这么大???

Step5: Custom Linker Script

我们先来看上一步得到的结果。

代码语言:javascript
代码运行次数:0
运行
复制
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
$ readelf -S -W step4/hello.outSection Headers:  [Nr] Name              Type            Address          Off    Size   ES Flg Lk Inf Al  [ 0]                   NULL            0000000000000000 000000 000000 00      0   0  0  [ 1] .text             PROGBITS        0000000000401000 001000 00006e 00  AX  0   0 16  [ 2] .rodata           PROGBITS        0000000000402000 002000 00000e 01 AMS  0   0  1  [ 3] .eh_frame_hdr     PROGBITS        0000000000402010 002010 000024 00   A  0   0  4  [ 4] .eh_frame         PROGBITS        0000000000402038 002038 000054 00   A  0   0  8  [ 5] .data             PROGBITS        0000000000404000 003000 000008 00  WA  0   0  8  [ 6] .comment          PROGBITS        0000000000000000 003008 000022 01  MS  0   0  1  [ 7] .shstrtab         STRTAB          0000000000000000 00302a 000040 00      0   0  1

可以发现 Size 很小但是 Off 的值非常大,也就是说每个 Section 的体积很小,但是偏移量很大。

使用 xxd 查看文件内容,会发现里面有大量的 0。所以情况现在很明朗,有人在对齐。

这里其实是默认的 Linker Script 链接脚本在做对齐操作。

控制链接器行为的脚本叫做 Linker Script,链接器内置了一个默认脚本,正常情况下我们使用默认的就好。

我们先来看看默认的脚本是什么内容。

代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
$ ld --verboseGNU ld (GNU Binutils) 2.34...  . = ALIGN(CONSTANT (MAXPAGESIZE));...  . = ALIGN(CONSTANT (MAXPAGESIZE));...

可以看到里面有使用 ALIGN 来对齐某些 Section,使得他们的地址是 MAXPAGESIZE 的倍数,这里 MAXPAGESIZE 是 4K。

这就解释了为什么我们的程序那么大。

所以现在解决方案也就很清晰了,我们不使用默认的链接脚本,自行编写一个。

代码语言:javascript
代码运行次数:0
运行
复制
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
$ cat > link.lds <<EOFENTRY(nomain)SECTIONS{  . = 0x8048000 + SIZEOF_HEADERS;
  tiny : { *(.text) *(.data) *(.rodata*) }
  /DISCARD/ : { *(*) }}EOF

使用 gcc -T link.lds 编译程序以后,我们得到了 584 字节,巨大的进步!🚀

Step6: Assembly

还有什么办法能进一步压缩吗?

上面我们是在 C 中使用内联汇编,为什么不直接使用汇编,完全抛弃 C?

我们来试试看,其实上面的 C 代码转换成汇编非常直接。

代码语言:javascript
代码运行次数:0
运行
复制
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
section .datamessage: db "hello, world", 0xa
section .text
global nomainnomain:  mov rax, 1  mov rdi, 1  mov rsi, message  mov rdx, 13  syscall  mov rax, 60  xor rdi, rdi  syscall
代码语言:javascript
代码运行次数:0
运行
复制

这里我们使用 nasm 汇编器,我喜欢它的语法~

nasm -f elf64 汇编我们的程序,然后使用 ld 配合上面的自定义链接脚本链接以后得到可执行程序。

最后的结果是 440 字节,离终点又进了一步了✌~

Step7: Handmade Binary

还能再进一步吗?还有什么是我们没控制的?

所有的代码都已经由我们精确掌控了,但是最终的 ELF 文件依旧是由工具生成的。

所以,最后一步,我们来手动生成 ELF 文件,精确地控制可执行文件的每一个字节。

代码语言:javascript
代码运行次数:0
运行
复制
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
BITS 64  org 0x400000
ehdr:           ; Elf64_Ehdr  db 0x7f, "ELF", 2, 1, 1, 0 ; e_ident  times 8 db 0  dw  2         ; e_type  dw  0x3e      ; e_machine  dd  1         ; e_version  dq  _start    ; e_entry  dq  phdr - $$ ; e_phoff  dq  0         ; e_shoff  dd  0         ; e_flags  dw  ehdrsize  ; e_ehsize  dw  phdrsize  ; e_phentsize  dw  1         ; e_phnum  dw  0         ; e_shentsize  dw  0         ; e_shnum  dw  0         ; e_shstrndxehdrsize  equ  $ - ehdr
phdr:           ; Elf64_Phdr  dd  1         ; p_type  dd  5         ; p_flags  dq  0         ; p_offset  dq  $$        ; p_vaddr  dq  $$        ; p_paddr  dq  filesize  ; p_filesz  dq  filesize  ; p_memsz  dq  0x1000    ; p_alignphdrsize  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  $ - $$
代码语言:javascript
代码运行次数:0
运行
复制

还是使用 nasm,不过这一次,我们使用 nasm -f bin 直接得到二进制程序。

最终结果是 170 个字节,这 170 字节的程序发送给任意的 x64 架构的 64 位 Linux,都可以打印出 hello world。

结束了,尘埃落定。

Tip: 其实还可以继续,还有一些技巧可以进一步减小体积,因为非常的「Hack」,这里不打算说明了。有兴趣的朋友可以参考《A Whirlwind Tutorial on Creating Really Teensy ELF Executables for Linux》。

Final Binary Anatomy

最后我们来看一下这 170 字节中每一个字节是什么,在做什么,真正地做到对每一个字节都了然于胸。

代码语言:javascript
代码运行次数:0
运行
复制
代码语言:javascript
代码运行次数:0
运行
AI代码解释
复制
# ELF Header00:   7f 45 4c 46 02 01 01 00 # e_ident08:   00 00 00 00 00 00 00 00 # reserved10:   02 00 # e_type12:   3e 00 # e_machine14:   01 00 00 00 # e_version18:   78 00 40 00 00 00 00 00 # e_entry20:   40 00 00 00 00 00 00 00 # e_phoff28:   00 00 00 00 00 00 00 00 # e_shoff30:   00 00 00 00 # e_flags34:   40 00 # e_ehsize36:   38 00 # e_phentsize38:   01 00 # e_phnum3a:   00 00 # e_shentsize3c:   00 00 # e_shnum3e:   00 00 # e_shstrndx
# Program Header40:   01 00 00 00 # p_type44:   05 00 00 00 # p_flags48:   00 00 00 00 00 00 00 00 # p_offset50:   00 00 40 00 00 00 00 00 # p_vaddr58:   00 00 40 00 00 00 00 00 # p_paddr60:   aa 00 00 00 00 00 00 00 # p_filesz68:   aa 00 00 00 00 00 00 00 # p_memsz70:   00 10 00 00 00 00 00 00 # p_align
# Code78:   b8 01 00 00 00          # mov    $0x1,%eax7d:   bf 01 00 00 00          # mov    $0x1,%edi82:   48 be 9d 00 40 00 00 00 00 00    # movabs $0x40009d,%rsi8c:   ba 0d 00 00 00          # mov    $0xd,%edx91:   0f 05                   # syscall93:   b8 3c 00 00 00          # mov    $0x3c,%eax98:   48 31 ff                # xor    %rdi,%rdi9b:   0f 05                   # syscall9d:   68 65 6c 6c 6f 2c 20 77 6f 72 6c 64 0a # "hello, world\n"

可以发现 ELF Header 是 64 个字节,Program Header 是 56 字节,代码 37 个字节,最后 13 个字节是 hello, world\n 这个字符串数据。

从上面的反汇编中我们可以看出 x86-64 和 ARM 比起来一个显著的特点就是 x86-64 是变长指令集,每条指令的长度并不相等。长一点的 movabs 是 10 个字节,而短一点的 syscall 只有 2 个字节。

关于 x86-64,Intel 官方的手册 Intel® 64 and IA-32 Architectures Software Developer Manuals 十分十分详细,是每一个底层爱好者居家旅行的必备之物。

tiny-x64-helloworld 仓库中有上面每一步的代码和编译指令,供大家参考~

最后,编译、链接和装载互联网上有很多资料,这篇博客的目的并不是想要详细地去介绍这里面的知识,更多地是想作为一个楔子,帮助大家建立一个整体的认识,从而挑选自己感兴趣的部分去深入学习,祝大家 Happy Coding~

原文链接:https://cjting.me/2020/12/10/tiny-x64-helloworld/

本周六,WAIC开发者·2020上海临港人工智能开发者大会将震撼来袭。

全球顶尖AI专家齐聚主论坛,解读智能化转型的技术之道。4场技术工作坊分享智慧城市智能出行、华为开源生态与边缘智能引擎等话题,2场黑客松等你来战。

点击阅读原文,选择你感兴趣的环节,立即报名。

© THE END 

转载请联系本公众号获得授权

投稿或寻求报道:content@jiqizhixin.com

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2020-12-25,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 机器之心 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
暂无评论
推荐阅读
编辑精选文章
换一批
汇编学习(1),汇编之helloworld
makefile可以这样看,目标hello依赖hello.o, 而hello.o又依赖hello.asm, 如果hello.asm的修改时间大于hello.o,那么hello.o下一行的命令就需要执行。
一只小虾米
2022/11/28
8960
Linux下Shellcode编写
基本过程是首先使用汇编通过系统调用的方式实现程序功能,编译成可执行文件,然后使用 objdump 进行机器码提取
yichen
2022/01/06
2.3K0
实现一个输出自身MD5的最小的程序
selfmd5项目为参加公司一个内部比赛所写,要求输出自身md5的最小程序,必须是64位ELF文件, 不能使用socket系统调用。
esrrhs
2020/12/11
1.5K0
计算机编程语言的本质
开发后端有很多的编程语言,目前比较流行的就是python ,c,cpp,java,js,php,rust,golang ..
北风之神xk
2023/08/02
6752
Linux pwn入门学习到放弃
PWN是一个黑客语法的俚语词,自”own”这个字引申出来的,意为玩家在整个游戏对战中处在胜利的优势。本文记录菜鸟学习linux pwn入门的一些过程,详细介绍linux上的保护机制,分析一些常见漏洞如栈溢出,堆溢出,use after free等,以及一些常见工具介绍等。
FB客服
2020/09/22
3.9K0
Linux pwn入门学习到放弃
NX防护机制以及最基本shellcode
道理我们都懂,那么如果我们关闭了NX到底可以干什么呢,该如何利用呢?下面通过一个实验来说明。
FB客服
2023/02/10
9610
NX防护机制以及最基本shellcode
深入了解GOT,PLT和动态链接
之前几篇介绍exploit的文章, 有提到return-to-plt的技术. 当时只简单介绍了 GOT和PLT表的基本作用和他们之间的关系, 所以今天就来详细分析下其具体的工作过程.
evilpan
2023/02/12
1.6K0
深入了解GOT,PLT和动态链接
ROP的基本原理和实战教学,看这一篇就够了!
ROP的全称为Return-oriented programming(返回导向编程),这是一种高级的内存攻击技术可以用来绕过现代操作系统的各种通用防御(比如内存不可执行和代码签名等)。通过上一篇文章栈溢出漏洞原理详解与利用,我们可以发现栈溢出的控制点是ret处,那么ROP的核心思想就是利用以ret结尾的指令序列把栈中的应该返回EIP的地址更改成我们需要的值,从而控制程序的执行流程。
Java程序猿
2023/02/21
3.5K0
Linux系统下刨析hello world背后的秘密
linux系统上使用gcc生成可执行程序:gcc -g -W helloworld.c -o helloworld
Linux兵工厂
2023/02/28
6330
Linux系统下刨析hello world背后的秘密
腾讯极客挑战赛丨从“碰撞”到“爆破”,42次尝试终破纪录
各位爱挑战爱学习的coder们,大家千呼万唤的解题思路来啦!(原赛题传送门:腾讯极客挑战赛丨全世界最最最小的程序,等你来battle!)
腾讯极客挑战赛
2021/06/16
1.1K0
腾讯极客挑战赛丨从“碰撞”到“爆破”,42次尝试终破纪录
OpenHarmony 内核源码分析(编译过程篇) | 简单案例窥视编译全过程
编译过程要经过:源文件 --> 预处理 --> 编译(cc1) --> 汇编器(as) --> 链接器(ld) --> 可执行文件(PE/ELF)
小帅聊鸿蒙
2025/03/25
910
OpenHarmony 内核源码分析(编译过程篇) | 简单案例窥视编译全过程
万字图文 | 你写的代码是如何跑起来的?
我们在写完代码后,进行简单的编译,然后在 shell 命令行下就可以把它启动起来。
开发内功修炼
2022/12/07
6180
万字图文 | 你写的代码是如何跑起来的?
BUUCTF 刷题笔记——PWN 2
先验文件,本题文件为 32 为可执行文件,保护约等于没开。值得注意的是,该文件又是静态链接,因此又可以直接调用一些未被调用过的函数来解题,比如老朋友 mprotect() 函数。
h-t-m
2023/03/10
1.5K0
BUUCTF 刷题笔记——PWN 2
【参赛经验分享】实现一个世界上最小的程序来输出自身的MD5 388解法分享
这题整体思路其实大家应该都很明白了。这里主要是列举一些优化点。elf header相关的做的比较挫,求其他大神思路。
王沛文
2021/08/30
6460
高级语言的编译:链接及装载过程介绍
引言 随着越来越多功能强大的高级语言的出现,在服务器计算能力不是瓶颈的条件下,很多同学会选择开发效率高,功能强大的虚拟机支持的高级语言(Java),或者脚本语言(Python,Php)作为实现功能的首选,而不会选择开发效率低,而运行效率高的 C/C++ 作为开发语言。而这些语言一般情况下是运行在虚拟机或者解释器中,而不需要直接跟操作系统直接打交道。 虚拟机和解释器相当于为高级语言或者脚本语言提供了一个中间层,隔离了与操作系统之间进行交互的细节,这为工程师们减少了很多与系统底层打交道的麻烦,大大提高了工程师的
美团技术团队
2018/03/12
1.4K0
高级语言的编译:链接及装载过程介绍
32位程序和64位程序这些区别你知道吗?
我们在编写C/C++程序时,32位程序和64位程序的代码有何区别?如何编写既可以编译成32位程序又可以编译成64位程序的代码?
编程珠玑
2020/06/22
3K0
Linux下程序是怎样执行的
就会出现如下结果。ps 在此处,我们可以人为ls为可执行程序的名称,--version 是该程序需要的参数。
高性能架构探索
2021/04/13
5.1K0
Linux下程序是怎样执行的
重学计算机组成原理(七)- 程序无法同时在Linux和Windows下运行?
既然程序最终都被变成了一条条机器码去执行,那为什么同一个程序,在同一台计算机上,在Linux下可以运行,而在Windows下却不行呢?
JavaEdge
2019/08/16
1.4K0
重学计算机组成原理(七)- 程序无法同时在Linux和Windows下运行?
[操作系统] ELF文件从形成到加载轮廓
编译和链接这两个步骤,在Windows下被IDE封装的很完美,我们一般是使用一键编译并运行,但是当链接出错的话我们就束手无措了。在Linux下有gcc/g++编译器,可以直接展示出编译链接的过程。
DevKevin
2025/03/08
1950
[操作系统] ELF文件从形成到加载轮廓
Linux 程序编译过程的来龙去脉
大家肯定都知道计算机程序设计语言通常分为机器语言、汇编语言和高级语言三类。高级语言需要通过翻译成机器语言才能执行,而翻译的方式分为两种,一种是编译型,另一种是解释型,因此我们基本上将高级语言分为两大类,一种是编译型语言,例如C,C++,Java,另一种是解释型语言,例如Python、Ruby、MATLAB 、JavaScript。
刘盼
2018/09/25
3K0
Linux 程序编译过程的来龙去脉
推荐阅读
相关推荐
汇编学习(1),汇编之helloworld
更多 >
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档
本文部分代码块支持一键运行,欢迎体验
本文部分代码块支持一键运行,欢迎体验