背景: 我为什么要写这篇文章?最主要的原因是:这件事我从没做过。本科时,通过单片机课程我了解过 80C51,通过计算机组成原理课程学习了 MIPS,但唯独没有真正学习和使用过目前使用最广泛的 x86-64。终于,我通过《深入了解计算机系统》这本神书入门了 x86-64,能够看懂基本的 x86-64 汇编了。但看懂代码还远远不够,要动手写代码,不断犯错和纠正,才能真正了解其中的许多门道。
💡 本文所有操作均是在 Ubuntu 22.04 上进行的,其他 Linux 发行版请自行变通。汇编语法遵循 AT&T,汇编器是 GNU as。
第一个 Hello World 程序:不使用 libc
为了输出字符串 Hello, world
,且不使用 C 标准库中提供的输入输出函数(如最常用的 printf
),我们只能向内核发起 write
系统调用。
第一个版本的代码(hello.s
)如下:
.section .rodata
msg:
.ascii "Hello, world\n"
.text
main:
# 相当于执行 write(1, msg, 13)
mov $1, %rax # 在 x86-64 下,write 的系统调用号是 1
mov $1, %rdx # 第一个参数:1 是标准输出(stdout)的文件描述符
mov $msg, %rsi # 第二个参数:Hello, world 字符串的首地址(msg 标签处)
mov $13, %rdx # 第三个参数:要输出数据的字节数
syscall # 发起系统调用
代码解释:
首先,使用 .section
和 .text
汇编器指令(directives)将整个代码分成两个节(section):.rodata
和 .text
。这是为了符合 ELF 文件规范:只读数据(如这里要输出的字符串)存放在的 .rodata
节,可执行代码存放在 .text
节。
在 .rodata
节中:
- 使用
.ascii
汇编器指令直接定义字符串Hello, world\n
。
在 .text
节中:
- 在
main
标签下,按照惯例发起write
系统调用:寄存器rax
存放系统调用号,参数通过六个寄存器rdi
、rsi
、rdx
、r10
、r8
和r9
进行传递:第一个参数在rdi
中,第二个参数在rsi
中,以此类推。所以,在rax
中存放write
的系统调用号1
;在rdi
中存放1
,即标准输出的文件描述符;在rsi
中存放要输出数据的首地址;在rdx
中存放要输出数据的长度。 - 执行
syscall
指令,陷入内核态,由操作系统内核完成write
操作。 - 注:这里没有考虑
write
的返回值,只是为了方便。如果要考虑的话,系统调用的返回值会被写入rax
寄存器。
编译
使用 GNU 汇编器 as 来汇编上述代码,输出 ELF64 格式的目标文件 hello.o
:
$ as hello.s -o hello.o
也可以直接使用 gcc,gcc 会帮你调用 as:
$ gcc -c hello.s -o hello.o
链接
使用 GNU 链接器 ld 对 hello.o
进行链接,得到可执行文件 hello
:
$ ld -o hello hello.o
ld: warning: cannot find entry symbol _start; defaulting to 0000000000401000
情况不对!ld 说它找不到入口符号 _start
,并采取了默认入口地址 0000000000401000
。原来,GNU 链接器 ld 默认的程序入口符号是 _start
(程序的执行从这里开始),但我们的汇编代码中没有这个符号(标签)。
解决方法有二:
- 将
main
标签换成_start
标签,并指定其为全局符号(否则它默认是局部符号,无法被其他模块访问。关于全局符号和局部符号,可以参考 《深入理解计算机系统(第三版)》 7.5 节); - 保持
main
标签不变,将其指定为全局符号,并在执行 ld 的时候,通过-e
选项(--entry
)设置默认入口点为main
。
这里,我们采取第一种方法。修改后的汇编程序如下:
.section .rodata
msg:
.ascii "Hello, world\n"
.text
.global _start
_start:
# 相当于执行 write(1, msg, 13)
mov $1, %rax # 在 x86-64 下,write 的系统调用号是 1
mov $1, %rdx # 第一个参数:1 是标准输出(stdout)的文件描述符
mov $msg, %rsi # 第二个参数:Hello, world 字符串的首地址(msg 标签处)
mov $13, %rdx # 第三个参数:要输出数据的字节数
syscall # 发起系统调用
修改内容就是将 main
标签更改为 _start
标签,同时使用 .global
汇编器指令指定 _start
为全局符号,告诉链接器 ld。
再次尝试汇编和链接:
$ as hello.s -o hello.o
$ ld -o hello hello.o
链接过程没有错误了。
运行
尝试运行:
$ ./hello
Hello, world
[1] 50226 segmentation fault (core dumped) ./hello
虽然 Hello, world
被打印出来了,但后面又发生段错误!你能想到是哪里出问题了吗?
先思考一下为何这段代码会发生段错误。我们知道,当程序发生段错误(即内核向进程发送 SIGSEGV
信号)时,说明内核检测到这个进程发生了无效的内存引用。单纯看代码,似乎看不出哪里发生了非法内存访问。那我们用 GDB 调试一下,看看程序在机器码层面的运行是怎样的吧!
💡 关于 Linux 信号 的相关知识,可以参考《Linux/UNIX 系统编程手册》第 20 章以及《UNIX环境高级编程(第3版)》第 10 章。
首先,使用 GDB 加载前面的 hello
程序:
$ gdb ./hello
然后,在 _start
函数处添加一个断点,并开始运行:
(gdb) b _start
Breakpoint 1 at 0x401000
(gdb) r
此时我们(在机器指令层面)处于这个位置(绿色是将要执行的指令):
我们逐指令往后运行(stepi
),最终会运行到 syscall
这条指令。运行完它,Hello, world
字符串被正常打印出来,但程序没有退出(当然,因为我们没有编写退出相关的指令),而是继续执行后面的 add %al,(%rax)
指令!
为啥后面都是这个指令?因为它恰好是全零内存所“对应”的机器指令。CPU 并不知道这里不是我们的代码,它仍然闷着头往前运行,把全零(0x00 0x00
)认为是 add %al,(%rax)
指令,也就是会访问 rax
寄存器所指向的内存地址。此时,rax
寄存器里存储的是 0xd
,因为这是 write
系统调用的返回值(成功写入 13 个字节),所以 CPU 会尝试访问 0xd
这个(虚拟)地址。
这个地址显然是非法的,因为通过查看程序头部表(Program Header Table)可知,该进程的第一个段始于地址 0x400000
,从 0x0
到 0x400000
这段地址本身就不是该进程的合法地址空间。
$ readelf --segments ./hello
Elf file type is EXEC (Executable file)
Entry point 0x401000
There are 3 program headers, starting at offset 64
Program Headers:
Type Offset VirtAddr PhysAddr
FileSiz MemSiz Flags Align
LOAD 0x0000000000000000 0x0000000000400000 0x0000000000400000
0x00000000000000e8 0x00000000000000e8 R 0x1000
LOAD 0x0000000000001000 0x0000000000401000 0x0000000000401000
0x000000000000001b 0x000000000000001b R E 0x1000
LOAD 0x0000000000002000 0x0000000000402000 0x0000000000402000
0x000000000000000d 0x000000000000000d R 0x1000
Section to Segment mapping:
Segment Sections...
00
01 .text
02 .rodata
💡 关于 ELF 可执行文件中程序头部表的解释,可以参考 《深入理解计算机系统(第三版)》 7.8 节。
好了,原因找到了,就是我们没有考虑这段代码运行完(syscall
之后)的程序正常退出工作。我们如何让程序正常退出呢?还是调用内核提供的 exit
系统调用!
修改后的代码如下:
.section .rodata
msg:
.ascii "Hello, world\n"
.text
.global _start
_start:
# 相当于执行 write(1, msg, 13)
mov $1, %rax # 在 x86-64 下,write 的系统调用号是 1
mov $1, %rdx # 第一个参数:1 是标准输出(stdout)的文件描述符
mov $msg, %rsi # 第二个参数:Hello, world 字符串的首地址(msg 标签处)
mov $13, %rdx # 第三个参数:要输出数据的字节数
syscall # 发起系统调用
# 相当于调用 exit(0)
mov $60, %rax # 在 x86-64 下,exit 的系统调用号是 60
mov $0, %rdx # 唯一的一个参数:0
syscall # 发起系统调用
💡 不同指令集架构下,同一个系统调用,其系统调用号可能不同。要查阅某个架构下的系统调用号表,请直接查看 Linux 源码,或查阅 Chromium OS Docs - Linux System Call Table。
汇编、链接并运行上述代码:
$ as hello.s -o hello.o
$ ld -o hello hello.o
$ ./hello
Hello, world
正常运行!
使用 libc 的 Hello World 程序
前一小节演示了如何在操作系统提供的系统调用层之上完成 Hello World 程序。当你的程序复杂起来的时候,就有必要使用更高层的库了。C 标准库在系统调用层之上封装出了许多方便的函数,如 printf
,可以让我们完成更复杂的输出任务。
调用外部函数需遵循当前指令集架构的调用约定(Calling Conventions);同时 printf
还是一个接收可变参数的函数,所以这里还涉及在机器码层面,可变参数函数是如何被实现的。由于篇幅所限,这部分内容将放到下篇来完成。