在 Linux 上用 x86-64 汇编语言写 Hello World 程序(上)

2023-01-31 x86-64 Linux

背景: 我为什么要写这篇文章?最主要的原因是:这件事我从没做过。本科时,通过单片机课程我了解过 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 节中:

.text 节中:

编译

使用 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(程序的执行从这里开始),但我们的汇编代码中没有这个符号(标签)。

解决方法有二:

  1. main 标签换成 _start 标签,并指定其为全局符号(否则它默认是局部符号,无法被其他模块访问。关于全局符号和局部符号,可以参考 《深入理解计算机系统(第三版)》 7.5 节);
  2. 保持 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

此时我们(在机器指令层面)处于这个位置(绿色是将要执行的指令):

GDB 截图

我们逐指令往后运行(stepi),最终会运行到 syscall 这条指令。运行完它,Hello, world 字符串被正常打印出来,但程序没有退出(当然,因为我们没有编写退出相关的指令),而是继续执行后面的 add %al,(%rax) 指令!

为啥后面都是这个指令?因为它恰好是全零内存所“对应”的机器指令。CPU 并不知道这里不是我们的代码,它仍然闷着头往前运行,把全零(0x00 0x00)认为是 add %al,(%rax) 指令,也就是会访问 rax 寄存器所指向的内存地址。此时,rax 寄存器里存储的是 0xd,因为这是 write 系统调用的返回值(成功写入 13 个字节),所以 CPU 会尝试访问 0xd 这个(虚拟)地址。

这个地址显然是非法的,因为通过查看程序头部表(Program Header Table)可知,该进程的第一个段始于地址 0x400000,从 0x00x400000 这段地址本身就不是该进程的合法地址空间。

$ 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 还是一个接收可变参数的函数,所以这里还涉及在机器码层面,可变参数函数是如何被实现的。由于篇幅所限,这部分内容将放到下篇来完成。

参考