跳转至

C 语言基础及Makefile

C 语言基础

如果你没有学习过 C 语言,我们 非常建议 你提前观看于老师的 C/C++ 课程 https://www.bilibili.com/video/BV1Vf4y1P7pq ,观看到第 6.5 章节有助于你了解基本的 C 语言语法。

RISC-V 汇编

我们期望你已经完成了《计算机组成原理》课程,并了解 RISC-V 汇编的基础知识。

此外,请常备 《The RISC-V Instruction Set Manual, Volume I: User-Level ISA, Version 2.1》(riscv-spec-v2.1.pdf) 与 《The RISC-V Instruction Set Manual, Volume II: Privileged Architecture, Document Version 20211203》(riscv-privileged-20211203.pdf) 作为参考 RISC-V 汇编的参考手册。

类型

在 C 语言中,整数类型有 long, int, short, char 等。 在绝大多数情况下,int 类型为 32 位长,而 long 类型的长度取决于 ABI(Application Binary Interface,在编译时由用户指定)。 为了避免编译目标架构的不同而导致 long、int 等类型实际长度与我们预想的不一致,在系统编程中,我们会使用定长的整形,如 uint64_t, int32_t 等。 在不同的ABI/编译器环境下,使用这一些类型保证了它们绝对是指定长度的。

例如,在 os/types.h 中:

os/types.h
typedef unsigned int uint;
typedef unsigned short ushort;
typedef unsigned char uchar;
typedef unsigned char uint8;
typedef unsigned short uint16;
typedef unsigned int uint32;
typedef unsigned long uint64;

我们定义了 uint64, uint32 等类型分别为 unsigned longunsigned int。 由于我们面向 riscv64 架构进行编程,我们可以确保在我们的 XV6 中,它们是 64 / 32 位的。

unsigned

注意在C语言中值在int类型取值范围内的整数字面量的默认类型是int。

当unsigned int与有符号整数(如int)比较时,有符号整数会被提升为unsigned int。如果常数为负数,提升后可能变成一个非常大的无符号值,导致比较结果与预期不符。

可以尝试执行以下代码,观察结果:

image

指针

指针是编程语言中一种变量类型,它存储了另一个变量的内存地址。通过指针,可以间接访问和操作其他变量的值。指针通常用于动态内存分配、函数参数传递、数据结构(如链表、树等)的实现等场景。

  1. 指针的"内容":它存储的是内存地址,而不是直接存储数据值。例如,指针 p 可以存储一个变量 x 的内存地址,而不是 x 的值。

  2. 指针的类型:指针有一个类型,表示它指向的是哪种类型的变量。例如,int* p 表示 p 是一个指向 int 类型的指针。

  3. 解引用:通过指针访问其指向的变量的过程叫做解引用,在 C 中,可以通过 * 操作符来解引用一个指针,获取指针所指向的值。

  4. 取地址:所有保存在内存上的变量可以被取地址,我们使用 & 来表示取一个变量的地址。

例如:

int a = 10;     // 定义一个变量 a,初始化为 10。
int *p = &a;    // 取变量 a 的内存地址,放入指针 p 中
*p = 20;        // 将 p 指向的内存地址修改为 20

int b = 30;
int **pp = &p;  // 取变量 p 的地址,放入指针 pp 中
*pp = &b;       // 将指针 p 的内容(其指向的地址),改为变量 b 的地址。
**pp = 50;      // 两次解引用。

printf("a: %d, b:%d\n", a, b);  // 输出: a:20, b:50.

一张图理解指针:

alt text

结构体

我们使用 struct 关键字表明该类型是一个结构体。 结构体是一堆打包在一起的数据

例如,我们声明结构体 struct proc,它的字段如下:

struct proc {
    int pid;
    char* name;
    void* chan;
    uint64 krkr;
    uint8 dkdk;
};

我们使用 . 操作符访问一个结构体变量的字段,使用 -> 操作符解引用一个结构体指针的字段。

struct proc p1;
p1.pid = 1234;

struct proc *pProc = &p1;
pProc->name = "proc 1234";

编译系统

在计算机组成原理课程中,我们简要的介绍了 C 语言的编译系统。通常来说,编译一个程序分为以下几步:

image

  1. 源代码 .c 文件经过 Pre-processor 预处理 cpp 得到 .i 文件

.i 文件是 GCC 预处理阶段生成的中间文件,包含了展开的头文件、宏定义和条件编译后的代码。使用 gcc -E 可以生成 .i 文件。

  1. .i 文件通过编译器 cc1 编译器得到汇编文件 .s

编译器对.i文件进行语法检查,检查无误后将.i文件转换成机器可以理解的汇编代码(人类可阅读形式的机器代码),在此过程中优化器可以对代码进行优化。

  1. .s 文件通过汇编器 as 得到 Relocatable objects (可重定位文件) .o

在此过程中,汇编器将汇编代码转换为目标代码(机器代码-直接在机器上执行的代码,人类不可读)。

  1. 链接器 ld 链接所有 .o 文件得到最终的可执行文件

在 Linux 系统上,目标文件及可执行文件通常以 ELF (Executable and Linkable Format) 文件格式存储。 ELF 文件分为不同的段 Section,用于存储特定类型的数据,如代码(.text)、数据(.data)和符号表(.symtab),每个段都有其专门的用途和属性。

通常来说,我们会用"编译器"来指代整个编译与链接过程中用到的所有工具,尽管编译器和链接器是两个不同的程序。特别的,当我们讨论编译器和链接器时,我们会将进行 预处理、汇编、编译 等步骤的工具集合统称为编译器;将最后的链接步骤所用的工具称为链接器。

实验步骤1:观察C语言编译过程

下面是一个简单的C语言代码示例,适合用于观察GCC编译过程中的 .i.s.o 文件:

// main.c
#include <stdio.h>

int main() {
    int a = 10;
    int b = 20;
    int sum = a + b;
    printf("Sum: %d\n", sum);
    return 0;
}

观察编译过程

  1. 预处理(Preprocessing):生成 .i 文件

    gcc -E main.c -o main.i
    
    这会生成 main.i 文件,其中包含了预处理后的代码(宏展开、头文件包含等),可以通过 cat main.i 查看其内容。

  2. 编译(Compilation):生成 .s 文件

    gcc -S main.i -o main.s
    
    这会生成 main.s 文件,其中包含了汇编代码,可以通过 cat main.s 查看其内容

  3. 汇编(Assembly):生成 .o 文件

    gcc -c main.s -o main.o
    
    这会生成 main.o 文件,其中包含了目标代码(机器代码),可以通过 objdump 工具来分析 main.o 的内容。例如使用 objdump -d hello.o 可以查看机器码及其对应的汇编指令。

  4. 链接(Linking):生成可执行文件

    gcc main.o -o main
    
    这会生成可执行文件 main,可以通过 file main 来查看 main 的文件类型为ELF。可以通过GNU Binutils工具集中的 readelf 工具,你可以查看 ELF 文件的文件头、段信息、符号表、动态段信息等。例如使用 readelf -h main 可以查看 main 的文件头。

Definition 和 Declaration

Definition (定义) 和 Declaration (声明) 是 C 语言中非常容易混淆的两个概念。

Declaration 声明了一个符号(变量、函数等),和它的的一些基础信息(如变量类型、函数参数类型、函数返回类型等)。这使得编译器 在编译阶段 能使用这些类型信息进行代码生成 (Code Generation)。

而 Definition 实际上会为该符号分配内存地址。链接器会 在链接阶段 为这些符号 分配地址(如函数地址、全局变量地址)。

Symbol (符号)

在 C 语言中,符号(Symbol)是编译器用来表示程序中各种实体(如变量、函数、宏、类型名等)的名称。每个符号在编译过程中被关联到特定的内存地址或其他资源。当程序被编译时,编译器会为这些符号创建符号表 (Symbol Table),记录它们的名称、类型、作用域以及对应的内存地址或值。

简而言之,符号是程序中代表实体的名字,编译器通过符号表来管理和解析这些名字。

编译器在编译某个 .c 文件时,它会一行一行的处理源代码,并维护一个符号表,表示当前文件中,到 目前为止 所有见过的符号。当编译器遇到对一个符号的引用(变量引用、函数调用)时,它会查找这个符号表;当编译器遇到一个符号的声明或定义时,它会向符号表中保存这个符号的信息。

所以,我们要保证,在引用一个符号时,它起码被声明过,即被编译器看到过。同时,在同一个文件中,定义也是一种声明。

对于编译器而言,如果该 .c 文件需要引用其它 .c 文件中的函数或者变量,则需要提前 声明 它。当编译器遇到了声明 (Declaration) 过但是没有在当前文件中被定义 (Definition) 过的符号时 (如 printf),编译器会假定该符号会在其他 object 文件中被定义,留下一些信息后交给链接器在链接阶段寻找这个符号。

例如,a.c 定义 了变量 int a。如果 main.c 想要引用它,则需要使用 extern int a声明 它。

// a.c
int a;

// main.c
extern int a;
int main() {
    printf("%d\n", a);
}

编译时,我们先分别编译 a.cmain.ca.omain.ogcc -c a.c -o a.ogcc -c main.c -o a.o,然后链接两个 .o 文件:gcc main.o a.o 生成可执行文件 a.out。

编译 a.c 时,编译器生成的 a.o 会表示它有一个全局可见的符号,叫 a。 编译 main.c 时,编译器是不知道任何关于其他 .c 文件的信息的。但是我们在第一行声明了变量 a,所以它知道最终链接的时候会有一个符号叫 a。编译器产生的 main.o 中会表示它需要一个符号,叫 a

链接器会查找所有 .o 文件的符号表,并根据名字和可见性匹配符号。

为了组织大型项目,我们不会在每个 .c 文件中手动导入其它 .c 文件中的符号,而是会使用头文件来声明这些会在 .c 中共享的符号。

头文件

头文件(Header File)的作用是声明函数、变量、宏定义、常量、类型等信息, 以便在多个源文件中共享。我们会在 .c 的开头使用 #include 宏导入头文件,它的语义是将文件内容直接复制到当前文件中,这一步是由 preprocessor 完成的。

如果某个 .c 文件中有些类型、函数、变量需要被其他 .c 文件引用,我们会创建一个对应的头文件。在给头文件取名上,我们一般使用同样的文件名,但是使用 .h 后缀;例如,对于 a.c 里面需要共享的信息,我们会创建一个它的头文件 a.h

对于需要共享的函数、变量,我们通常会在 a.h声明,在 a.c定义

// a.h
extern int a;
int add(int x, int y);

// a.c
int a;
// or int a = 10;
int add (int x, int y) {
    return x + y;
}

假如 main.c 需要引用 a.c 中提供的 a 变量或者 add 函数,则可以在其开头引入 a.h 头文件:

// main.c
#include "a.h"

int main() {
    a = 666;
    int b = 123;
    int c = add(a, b);
}

Note

  • 在一个 .c 文件中声明且定义的全局变量其他 .c 文件是无法 直接 使用的。例如你在一个 .c 文件中 int a; ,则在另一个文件中需要 extern int a; ,那么两个文件才是共享同一个 a

  • 在多个 .c 文件中定义全局变量时,我们要确保变量名是唯一的。否则会导致多重定义。

  • 如果我们希望定义一些仅当前 .c 可见的全局变量,我们可以使用 static 关键字。

  • .h 文件中仅能声明变量,如果 .h 定义了一个变量并且存在两个以上的 .c 文件 #include 了这个 .h 文件,则也会出现多重定义,因为预处理器会将被 include 的内容直接复制到当前文件中,这最终会导致两个 .c 都会对这个变量进行定义。

  • 如果你希望一个变量由多个 .c 共享使用,可以在 .h 文件中声明这个变量并且使用 extern 关键字进行修饰,并在任何一个 .c 中定义它。

readelf 读取 Symbol Table

我们再次解释一下 Declaration 和 Definition 的区别:

  • Definition 是向链接器表示,这个 .o 文件里面有一个符号,链接器需要为它分配内存地址。如果其他 .o 需要引用这个符号,则要判断这个符号是否允许被外部访问,即声明时是否使用了 static。

  • Declaration 是向编译器保证,这个符号会在链接时被找到,不论是当前 .c 或其他 .c 文件中定义的。编译器只要根据声明的变量类型或函数原型进行代码生成(如变量访存时的宽度 (lb, lw, ld),函数的参数个数),链接器会负责去找到这些符号。

我们可以通过 llvm-readelf-19 --symbol <file> 查看一个 ELF 文件的符号表,里面字段的意义可以参照 https://docs.oracle.com/cd/E19455-01/816-0559/chapter6-79797/index.html

$ llvm-readelf-19 --symbols build/os/proc.o | grep -E "FUNC|OBJECT|GLOBAL"
Symbol table '.symtab' contains 1240 entries:
   Num:    Value          Size Type    Bind   Vis       Ndx Name
     5: 0000000000000000    72 FUNC    LOCAL  DEFAULT     1 curr_proc
    23: 0000000000000048   304 FUNC    LOCAL  DEFAULT     1 freeproc
    97: 0000000000000178    92 FUNC    LOCAL  DEFAULT     1 first_sched_ret
   128: 0000000000000000     4 OBJECT  LOCAL  DEFAULT     6 proc_inited.1
   237: 0000000000000000     4 OBJECT  LOCAL  DEFAULT     7 PID.0
   663: 0000000000000000    32 OBJECT  LOCAL  DEFAULT     4 pid_lock
   664: 0000000000000020    32 OBJECT  LOCAL  DEFAULT     4 wait_lock
   665: 0000000000000040   104 OBJECT  LOCAL  DEFAULT     4 proc_allocator
  1201: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT   UND push_off
  1202: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT   UND mycpu
  1203: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT   UND pop_off
  1210: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT   UND usertrapret
  1211: 00000000000001d4   544 FUNC    GLOBAL DEFAULT     1 proc_init
  1212: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT   UND spinlock_init
  1213: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT   UND allocator_init
  1214: 00000000000000a8  4096 OBJECT  GLOBAL DEFAULT     4 pool
  1215: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT   UND kernel_pagetable
  1216: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT   UND kallocpage
  1217: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT   UND kalloc
  1218: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT   UND memset
  1219: 0000000000000000     0 NOTYPE  GLOBAL DEFAULT   UND kvmmap

Type 列表示该符号是函数 (FUNC) 还是变量 (OBJECT), Bind 表示这个符号是否允许其他 .o 找到 (LOCAL/GLOBAL)。Ndx 表示这个符号是否定义在这个 .o 里面,UND 表示它是外部的 .o,即需要从其他 .o 导入的符号,所以它的 Type 和 Size 都是未知的。

现在,你是否理解了链接中常出现的两种错误:multiple definition 和 undefined reference 的原因?

  • riscv64-unknown-elf-ld: build/os/proc.o:os/proc.c:14: multiple definition of 'idle'; build/os/main.o:os/main.c:7: first defined here
    • 在不同的 .c 文件中定义了多次 idle 变量。
  • riscv64-unknown-elf-ld: build/os/proc.o: in function 'proc_init': os/proc.c:38:(.text+0xd0): undefined reference to 'idle'
    • 在头文件中声明了 idle 变量,但是没有定义它。

Make 和 Makefile介绍

考虑一下,如果我们的工程稍微大一点(比如包含多个C语言文件),每次运行一次我们都要执行很多次gcc命令,是否有一种编译工具可以简化这个过程呢?接下来我们介绍自动化编译工具make。

Makefile 是一个用于自动化构建(编译、链接等)程序的配置文件,通常用于管理包含多个源文件的项目。它定义了如何从源代码生成目标文件(如可执行文件、库文件等),并确保只重新编译那些需要更新的部分,从而提高构建效率。

Makefilemake 工具的输入文件,make 是一个经典的构建工具,广泛用于 Unix/Linux 系统。

实验步骤2:使用makefile进行自动化构建

首先我们创建三个文件

//print.h 头文件
#include <stdio.h>
void print(void);

//print.c
#include "print.h"
void print(){
    printf("Hello, World!\n");
}

//main.c
#include "print.h"
int main(){
    print();
    return 0;
}

因为文件中的依赖关系,如果我们想要运行上面的代码,我们需要为每个.c文件生成.o目标文件,然后把两个.o文件生成可执行文件:

gcc -c main.c
gcc -c print.c
gcc -o main main.o print.o

./main

image-20220212090901375

image-20220212090954211

由此可见,如果我们的文件数量很多,每次运行就会变得十分的复杂。为了使整个编译过程更加容易,可以使用Makefile。

接着,我们创建一个文本文件并命名为Makefile。

Makefile文件内容:

main : main.o print.o
    gcc -o main main.o print.o
main.o : main.c print.h
    gcc -c main.c
print.o : print.c print.h
    gcc -c print.c
clean:
    rm main main.o print.o

Warning

Makefile中的缩进只能是tab,不能是若干空格,否则无法执行。

最后,我们只需要执行一句make命令,就可以完成整个编译过程:

image-20220212091943782

Makefile的基本结构

target: dependencies
[tab] system command

Makefile工作原理

在默认的方式下,也就是我们只输入 make 命令。那么,

  1. make会在当前目录下找名字叫“Makefile”或“makefile”的文件。
  2. 如果找到,它会找文件中的第一个目标文件(target),在上面的例子中,他会找到“main”这个文件,并把这个文件作为最终的目标文件。
  3. 如果main文件不存在,或是main所依赖的后面的 .o 文件的文件修改时间要比 main 这个文件新,那么,他就会执行后面所定义的命令来生成 main 这个文件。
  4. 如果 main 所依赖的 .o 文件也不存在,那么make会在当前文件中找目标为 .o 文件的依赖性,如果找到则再根据那一个规则生成 .o 文件。(这有点像一个堆栈的过程)
  5. 当然,你的C文件和H文件是存在的啦,于是make会生成 .o 文件,然后再用 .o 文件生成make的终极任务,也就是执行文件 main 了。

make clean

通过上述分析,我们知道,像clean这种,没有被第一个目标文件直接或间接关联,那么它后面所定义的命令将不会被自动执行,不过,我们可以显式要make执行。即命令—— make clean ,以此来清除所有的目标文件,以便重新编译。

参考及更多关于Makefile的知识请查看:(跟我一起写Makefile 1.0 文档