最小的 C 程序

#include <sys/syscall.h>

.globl _start
_start:
  movq $SYS_write, %rax   // write(
  movq $1,         %rdi   //   fd=1,
  movq $st,        %rsi   //   buf=st,
  movq $(ed - st), %rdx   //   count=ed-st
  syscall                 // );

  movq $SYS_exit,  %rax   // exit(
  movq $1,         %rdi   //   status=1
  syscall                 // );

st:
  .ascii "\033[01;31mHello, OS World\033[0m\n"
ed:

:这段代码是什么意思?

ChatGPT

这段代码是一个使用x86-64汇编语言编写的程序,它通过Linux系统调用实现了向标准输出打印红色文本并退出的功能。让我们一行一行地解释代码的含义:

  1. #include <sys/syscall.h>:这是一个系统调用头文件,其中包含了系统调用的常量定义。

  2. .globl _start:这个指令表示标记_start是一个全局标号,它是程序的入口点。

  3. _start::这是程序的入口标号,也就是程序开始执行的地方。

  4. movq $SYS_write, %rax:将系统调用号SYS_write(代表write系统调用)加载到寄存器%rax中。在x86-64架构中,系统调用号是通过%rax寄存器传递的。

  5. movq $1, %rdi:将文件描述符1(标准输出)加载到寄存器%rdi中。在Linux中,标准输出的文件描述符是1。

  6. movq $st, %rsi:将字符串的起始地址st加载到寄存器%rsi中,作为write系统调用的第二个参数(即要打印的文本)。

  7. movq $(ed - st), %rdx:将字符串的长度(ed标号的地址 - st标号的地址)加载到寄存器%rdx中,作为write系统调用的第三个参数(即要打印的文本长度)。

  8. syscall:执行write系统调用,将指定的文本打印到标准输出。

  9. movq $SYS_exit, %rax:将系统调用号SYS_exit(代表exit系统调用)加载到寄存器%rax中。

  10. movq $1, %rdi:将返回状态码1加载到寄存器%rdi中,作为exit系统调用的参数。

  11. syscall:执行exit系统调用,程序终止执行。

  12. st::这是一个标号,标识字符串的起始地址。

  13. .ascii "\033[01;31mHello, OS World\033[0m\n":这是要打印的红色文本。在ASCII码中,\033表示转义字符,后面的[01;31m表示设置文本颜色为红色,Hello, OS World是要打印的文本,\033[0m表示恢复默认文本颜色,\n表示换行。

  14. ed::这是一个标号,标识字符串的结束地址。

当程序执行时,它会将红色的"Hello, OS World"文本打印到终端,并退出执行。需要注意的是,这段代码只能在Linux系统上运行,因为它使用了Linux特定的系统调用方式。在其他操作系统上,这段代码可能会产生未定义的行为或错误。

编译指令:

image-20230724214148762

值得注意的是,上述代码执行后,终端显示的是小红叉。

通过阅读 ChatGPT 的回答,将 movq $1, %rdi 修改为 movq $0, %rdi后,程序执行后,终端将会显示小圆点。也即返回 0 。

image-20230724214805507

:gcc -S 中的 S 选项是哪个单词的缩写,表示什么意思?

ChatGPT:

在GCC编译器中,选项-S表示"Assembly Output"的缩写,意思是生成汇编语言输出。当你使用gcc -S命令时,GCC将会把源代码编译成对应的汇编代码(以汇编语言的形式),而不进行进一步的汇编和链接,即不生成可执行文件。这个选项通常用于查看代码在汇编级别上的实现,方便进行代码优化、调试或者了解编译器的生成过程。

举例来说,如果你有一个C语言源文件example.c,你可以通过以下命令将其转换成汇编代码文件example.s

gcc -S example.c

然后你可以打开example.s文件,查看对应的汇编代码。

使用 objdump -d a.out 查看 ELF 文件。

image-20230724215343836

只有一个 .text 区,401000 地址 <_start> 标记是程序的入口, 40102e <st> 开始应该是存放的字符串吧。

🌶️ Demo: 非递归实现汉诺塔问题

#include <stdio.h>
#include <assert.h>

#include "hanoi-nr.c"

int main() {
  hanoi(3, 'A', 'B', 'C');
}
typedef struct {
  int pc, n;
  char from, to, via;
} Frame;

#define call(...) ({ *(++top) = (Frame) { .pc = 0, __VA_ARGS__ }; })
#define ret()     ({ top--; })
#define goto(loc) ({ f->pc = (loc) - 1; })

void hanoi(int n, char from, char to, char via) {
  Frame stk[64], *top = stk - 1;
  call(n, from, to, via);
  for (Frame *f; (f = top) >= stk; f->pc++) {
    n = f->n; from = f->from; to = f->to; via = f->via;
    switch (f->pc) {
      case 0: if (n == 1) { printf("%c -> %c\n", from, to); goto(4); } break;
      case 1: call(n - 1, from, via, to);   break;
      case 2: call(    1, from, to,  via);  break;
      case 3: call(n - 1, via,  to,  from); break;
      case 4: ret();                        break;
      default: assert(0);
    }
  }
}
void hanoi(int n, char from, char to, char via) {
  if (n == 1) {
    printf("%c -> %c\n", from, to);
  } else {
    hanoi(n - 1, from, via, to);
    hanoi(1,     from, to,  via);
    hanoi(n - 1, via,  to,  from);
  }
}
a.out: hanoi-main.c hanoi-nr.c
	gcc -ggdb -Wall $<

:详细解释这段代码(hanoi-nr.c)

ChatGPT:

这段代码实现了一个递归解法的汉诺塔问题。汉诺塔问题是经典的递归问题,目标是将一堆从小到大依次放置的圆盘从一个柱子移动到另一个柱子,移动过程中始终保持大圆盘在小圆盘之上。

让我们逐行解释代码的含义:

  1. typedef struct { ... } Frame;:定义了一个名为Frame的结构体类型,包含了整数pcn,以及字符fromtovia

  2. #define call(...) ({ *(++top) = (Frame) { .pc = 0, __VA_ARGS__ }; }):这是一个宏定义,用于模拟函数调用。call(...)会将参数打包成一个Frame结构体,并将其压入堆栈(用top指针指向堆栈顶部),然后将pc(程序计数器)设置为0,模拟函数的入口处。

  3. #define ret() ({ top--; }):这是另一个宏定义,用于模拟函数返回。ret()会将堆栈顶部的Frame结构体弹出,模拟函数返回。

  4. #define goto(loc) ({ f->pc = (loc) - 1; }):这是第三个宏定义,用于模拟函数内部的跳转。goto(loc)会将当前函数的pc设置为loc - 1,相当于将程序计数器设置到目标位置,模拟函数内部的跳转。

  5. void hanoi(int n, char from, char to, char via) { ... }:这是汉诺塔问题的函数实现,采用了非递归的方式。

  6. Frame stk[64], *top = stk - 1;:定义了一个名为stkFrame数组,用于模拟函数调用堆栈。top是一个指针,初始时指向堆栈的顶部,但是由于数组从0开始索引,所以初始时指向堆栈的下一个位置(stk-1)。

  7. call(n, from, to, via);:模拟了对hanoi函数的第一次调用。将初始的汉诺塔问题参数压入堆栈,然后进入非递归的汉诺塔求解过程。

  8. for (Frame *f; (f = top) >= stk; f->pc++) { ... }:进入一个循环,条件是堆栈不为空。每次循环都取出堆栈顶部的Frame结构体,然后根据pc的值执行不同的操作。

  9. n = f->n; from = f->from; to = f->to; via = f->via;:从当前帧结构体中取出参数值,准备执行对应的操作。

  10. switch (f->pc) { ... }:根据当前帧的pc值,进行不同的操作。在这个非递归实现中,相当于模拟递归函数中的不同递归调用。

  11. case 0: if (n == 1) { printf("%c -> %c\n", from, to); goto(4); } break;:当pc为0时,执行移动一个圆盘的操作,即将from柱子上的圆盘移动到to柱子上,并输出移动的路径。然后通过goto(4)跳转到case 4,模拟递归函数返回的操作。

  12. case 1: call(n - 1, from, via, to); break;:当pc为1时,执行递归调用,将规模减一的子问题从from柱子经过to柱子移动到via柱子。

  13. case 2: call(1, from, to, via); break;:当pc为2时,执行移动最后一个圆盘的操作,将from柱子上的最后一个圆盘移动到to柱子上。

  14. case 3: call(n - 1, via, to, from); break;:当pc为3时,执行递归调用,将规模减一的子问题从via柱子经过from柱子移动到to柱子。

  15. case 4: ret(); break;:当pc为4时,执行函数返回操作,相当于递归函数的返回。

  16. default: assert(0);:如果pc的值超出了预期的范围,这里使用了assert(0)语句来触发断言,表示出现了意料之外的错误。

总体上,这段代码通过使用一个自定义的堆栈(stk数组)和宏定义(call()ret()goto())的方式,实现了非递归解法的汉诺塔问题。这种方法模拟了函数调用和返回的过程,避免了递归调用时的函数调用栈溢出问题,是一种较为巧妙的非递归解法。

Demo: 在代码中控制编译优化

void spin_1() {
  int i;
  for (i = 0; i < 100; i++) {
    // Empty loop body
  }
}

void spin_2() {
  volatile int i;
  for (i = 0; i < 100; i++) {
    // Empty loop body
  }
}

int return_1() {
  int x;
  for (int i = 0; i < 100; i++) {
    // Compiler will assign [%0] an assembly operand
    asm("movl $1, %0" : "=g"(x));  // "x = 1;"
  }
  return x;
}

int return_1_volatile() {
  int x;
  for (int i = 0; i < 100; i++) {
    asm volatile ("movl $1, %0" : "=g"(x));
  }
  return x;
}

int foo(int *x) {
    *x = 1; // Must write at least once
    *x = 1; // because *x may be used later in external function
    return *x; // Must be 1
}

void external();
int foo_func_call(int *x) {
  *x = 1;
  external(); // May read or write x
  *x = 1;
  return *x; // Must be 1
}

int foo_volatile(int volatile *x) {
    *x = 1; // *x is volatile
    *x = 1;
    return *x;
}

int foo_volatile2(int * volatile x) {
    *x = 1; // x is volatile
    *x = 1;
    return *x;
}

int foo_barrier(int *x) {
    *x = 1;
    asm volatile("" : : : "memory"); // "Clobbers" memory
    *x = 1;
    return *x;
}

:针对以上这些函数,编译器有权作出哪些优化?

ChatGPT:

针对以上这些函数,编译器可能会进行多种优化,取决于编译器的实现和优化级别。以下是编译器可能进行的一些优化:

  1. spin_1()spin_2() 函数:
    由于这两个函数的循环体是空的,编译器可能会进行循环展开优化,将循环展开为直接执行100次空循环体的代码,以减少循环控制的开销。

  2. return_1()return_1_volatile() 函数:
    由于这两个函数的循环体中对 x 的赋值语句没有实际的数据依赖关系,编译器可能会进行循环优化,将循环展开或消除循环,以减少循环控制的开销。此外,编译器可能会将循环内的赋值语句优化成直接的常数赋值。

  3. foo() 函数:
    这个函数中的循环体对 x 的赋值语句在循环内执行了两次,但是由于循环没有实际的数据依赖关系,编译器可能会进行循环优化,将循环展开或消除循环,并将两次赋值合并成一次赋值。

  4. foo_func_call() 函数:
    由于函数 external() 可能读写 x,而在调用 external() 前后都对 x 进行了赋值操作,编译器可能会进行函数内联优化,将 external() 函数的内容直接嵌入到 foo_func_call() 中,以避免在函数调用时产生额外的函数调用开销。

  5. foo_volatile()foo_volatile2() 函数:
    这两个函数中的 x 声明为 volatile,意味着编译器不能对它们进行某些优化,以确保对 x 的读写操作不会被优化掉,因为 x 被标记为 volatile 表示它可能会在未知的时间被外部因素更改。编译器可能会确保对 x 的赋值和读取在最终生成的汇编代码中按照源代码的顺序执行。

  6. foo_barrier() 函数:
    在循环内部的 asm volatile("" : : : "memory") 语句使用了 "memory" clobber,这告诉编译器这个内联汇编代码可能会访问任何内存地址,从而避免编译器在该语句之前或之后的代码重排。因此,这个语句会产生一个内存屏障,确保编译器不会在这个语句附近进行优化,以保证内存访问的顺序性和可见性。

需要注意的是,编译器的优化行为可能受到编译器版本、编译器选项和目标平台的影响。不同的编译器可能会有不同的优化策略和行为。要了解具体的优化效果,可以使用特定编译器版本和选项进行实际测试和分析。

这个回答中关于 return_1() 和 return_1_volatile() 的说法正确吗…

操作系统上的应用程序

#include <stdio.h>

int main() {
  printf("Hello World\n");
}
#include <sys/syscall.h>

.globl _start
_start:
  movq $SYS_write, %rax   // write(
  movq $1,         %rdi   //   fd=1,
  movq $st,        %rsi   //   buf=st,
  movq $(ed - st), %rdx   //   count=ed-st
  syscall                 // );

  movq $SYS_exit,  %rax   // exit(
  movq $1,         %rdi   //   status=1
  syscall                 // );

st:
  .ascii "\033[01;31mHello, OS World\033[0m\n"
ed:
a.out: minimal.S
	gcc -S minimal.S > minimal.s
	as minimal.s -o minimal.o
	ld minimal.o

gcc-demo:
	strace -f gcc hello.c

xedit-demo:
	strace xedit

在我的电脑上使用 strace 命令查看 gcc 编译过程执行了哪些其他工具。

strace -f gcc hello.c |& grep -e execve

image-20230725214010695

可以看到:

  1. 首先使用了执行了gcc 程序。
  2. 然后执行了 cc1 程序,pid 是 29651, 过一会它正确退出了,返回了 0 。
  3. 然后尝试寻找 as ,失败了好多次。最后执行的是 /usr/bin/as ,并且也是过一会正确退出。
  4. 教科书上告诉我下一个环节就是 链接 了,但很明显,ld 之前还执行了个 collect2 程序。
  5. 最后执行 ld 程序进行链接。

那么,

: gcc 编译过程中 collect2 作了哪些工作?

ChatGPT:

在GCC(GNU Compiler Collection)编译过程中,collect2是一个重要的工具程序,它实际上是GNU链接器ld的一个驱动程序。collect2起着将目标文件(Object Files)转换为可执行文件的作用。以下是collect2在GCC编译过程中所做的主要工作:

  1. 链接目标文件(Object Files):GCC编译过程通常分为四个阶段:预处理、编译、汇编和链接。在前三个阶段之后,会生成一组目标文件(通常以.o结尾)。collect2的首要任务是将这些目标文件进行链接,即将它们组合成一个单一的可执行文件。

  2. 解析启动文件(Startup Files):可执行文件在执行时需要一些初始化工作,这些初始化工作包括设置堆栈、全局数据初始化等。这些初始化工作通常由启动文件完成。collect2会选择合适的启动文件,并将其链接到生成的可执行文件中。

  3. 解析共享库(Shared Libraries):如果程序依赖于共享库(动态链接库),collect2会查找并解析这些共享库,将它们的代码和数据链接到可执行文件中,以便在运行时动态加载这些共享库。

  4. 符号解析与重定位:在链接过程中,目标文件之间可能会存在相互引用的符号(比如函数、变量等)。collect2会负责解析这些符号引用,确保它们能正确地连接到对应的定义处。同时,collect2还会进行重定位操作,将各个目标文件中的地址信息调整到正确的位置,以便在内存中正确加载和执行。

  5. 生成可执行文件:经过上述步骤,collect2会将所有链接好的代码和数据组合成一个可执行文件,它可以直接在操作系统上运行。

总的来说,collect2是GCC编译过程中一个非常重要的环节,它将分散的目标文件与所需的其他资源进行组合和链接,最终产生可在操作系统上运行的可执行文件。

这… 真的不是在骗我吗?

关于 collect2, 详见 http://gcc.gnu.org/onlinedocs/gccint/Collect2.html