
1.4 汇编语言
在进行内核代码开发的过程中,有很多地方需要直接操作CPU寄存器和I/O端口,用C/C++无法完成相应的功能。使用汇编的场景主要有两种:一是操作系统引导和初始化阶段需要大量地直接操作I/O端口,使用汇编语言会很高效;二是在内核代码中需要操作硬件,例如修改某些状态寄存器,改变CPU的工作模式等,这种情况就是以内嵌汇编为主。
每一种C编译器的内嵌汇编都不尽相同,下面详细介绍GCC内嵌汇编的语法。
1.4.1 内嵌汇编
GCC提供的基本汇编语法形式如下:
__asm__(AssemblerTemplate);
其中,__asm__是内嵌汇编命令的关键字,用来声明内嵌汇编表达式。AssemblerTemplate则是一组插入到C/C++代码中的汇编指令。
例如,下面的代码用于在C语言中插入一条mov寄存器的指令:
__asm__("mov %edx, %eax");
内嵌汇编指令的书写方式与直接在汇编文件中写汇编指令没有区别。基本内嵌汇编支持汇编器的所有指令形式,包括汇编中的伪指令。
在基本内嵌汇编中,我们可以插入一段汇编指令,但是无法让汇编指令与我们原本的C/C++程序代码产生关联。例如,修改或读取C/C++中的变量等。因此,除了支持基本内嵌汇编指令,GCC还支持通过扩展内嵌汇编的方式让汇编指令与C/C++代码进行互操作。
GCC的内嵌汇编语法形式如代码清单1-2所示。
代码清单1-2 内嵌汇编语法
1 __asm__asm-qualifiers(
2 AssemblerTemplate
3 :OutputOperands /* 可选 */
4 :InputOperands /* 可选 */
5 :Clobbers) /* 可选 */
从以上语法形式可以看出,GCC的内嵌汇编主要分为6个部分,下面依次进行解释。
1)__asm__:同基本内嵌汇编一样,扩展内嵌汇编同样使用__asm__作为关键字,GCC可以识别__asm__或者asm关键字。该标识符标识了内嵌汇编表达式的开始。
2)asm-qualifiers:该位置可选,一般常用的修饰符是volatile。GCC在优化过程中可能会对内嵌汇编进行修改或者消除。例如,当优化器发现内嵌汇编中某些指令对最后的输出没有影响时,优化器会消除掉这些指令,又或者优化器会对循环中的一些不变量进行外提操作。在某些情况下,编译器的这些优化并不是程序员所期望的行为,因此可以通过volatile关键字来禁止编译器对内嵌汇编的类似优化。
3)AssemblerTemplate:这个位置是内嵌汇编的主体部分,由一组包含汇编指令的字符串组成。GCC编译器识别其中的占位符,替换为对应的输出操作数、输入操作数等内容,最后将替换好的汇编指令作为汇编器的输入。每条指令最好以\n\t结尾,这样GCC产生的汇编文件的格式比较好看。例如下面的例子:
__asm__ __volatile__("mov %%edx,% %eax":);
该例子同基本内嵌汇编中的例子的内容是一样的,但这里采用的是扩展内嵌汇编的方式,因此有两个不同的地方:一是因为该例子不涉及任何与C/C++交互的地方,所以例子中输出操作数、输入操作数以及破坏描述部分都为空,需要在最后以一个冒号结尾;二是在扩展内嵌汇编中,引用寄存器时,需要在寄存器名称前添加“%%”,这是为了与操作数占位符的“%”进行区分。
4)OutputOperands:输出操作数,由逗号分隔,可以为空。每个内嵌汇编表达式都可以有0个或多个输出操作数,用来标识在汇编中被修改的C/C++程序变量。
输出操作数的形式如下:
[[asmSymbolicName]]constraint(cvariablename)
要理解asmSymbolicName的含义,需要先理解扩展内嵌汇编中操作数占位符的作用。在扩展内嵌汇编指令中,汇编指令的操作数可以由占位符进行引用,占位符代表了输出操作数以及输入操作数的位置。例如总共有5个操作数(2个输出操作数,3个输入操作数),则占位符%0~%4分别代表了这5个操作数,具体的实现如代码清单1-3所示。
代码清单1-3 输入/输出参数
1 int out1,out2;
2 int in1=1,in2=2,in3=3;
3 __asm__ __volatile__(
4 "add %3,%4\n\t"
5 "add %2,%3\n\t"
6 "mov %4,%1\n\t"
7 "mov %3,%0\n\t"
8 :"=r"(out1),"=r"(out2)
9 :"r"(in1),"r"(in2),"r"(in3)
10 :
11);
例子中占位符%0~%4分别指向C代码中out1、out2、in1、in2、in3这5个变量。
虽然数字类型的占位符比较方便,但是如果输出/输入操作数太多,则容易使得数字类型占位符过于混乱。因此,asmSymbolicName提供了一种别名的方式,允许在扩展内嵌汇编中使用别名来操作占位符。上面例子也可以修改为别名的形式,具体实现如代码清单1-4所示。
代码清单1-4 别名形式的参数
1 int out1,out2;
2 int in1=1,in2=2,in3=3;
3 __asm__ __volatile__(
4 "add %[in2],%[in3]\n\t"
5 "add %[in1],%[in2]\n\t"
6 "mov %[in3],%[out2]\n\t"
7 "mov %[in2],%[out1]\n\t"
8 :[out1]"=r"(out1),[out2]"=r"(out2)
9 :[in1]"r"(in1),[in2]"r"(in2),[in3]"r"(in3)
10:
11);
constraint表明操作数的约束,即上面例子中out1和out2的“=r”。对输出操作数而言,约束必须以“=”(意思是对当前变量进行写操作)或“+”(意思是对当前变量进行读和写操作)开头。在前缀之后,必须有一个或多个附加约束来描述值所在的位置。常见的约束包括代表寄存器的“r”和代表内存的“m”。上述例子中“=r(out1)”的约束含义是:内嵌汇编指令将会对out1变量进行写操作,并且会将out1与一个寄存器进行关联。GCC内嵌汇编中的约束符还有很多,详细列表可以查看GCC官方手册,此处不再赘述。
(cvariablename)表示该输出操作符所绑定的C/C++程序的变量,这个比较好理解。
最后再看一下来自Linux 0.11中的具体例子,如代码清单1-5所示。
代码清单1-5 Linux 0.11中的真实示例
1 inline unsigned long get_fs(){
2 unsigned short_v;
3 __asm__("mov %%fs,%%ax":"=a"(_v):);
4 return_v;
5 }
这个函数的功能是获取当前fs寄存器的值并返回。在函数get_fs()中,输出操作数为变量_v,其形式为“=a(_v)”。这里约束“=a”表明输出操作符与寄存器%ax绑定,因此内嵌汇编的作用就是将寄存器%fs的值存储到变量_v中。
5)InputOperands:输入操作数,由逗号分隔,可以为空。输入操作数集合标识了哪些C/C++变量是需要在汇编代码中读取使用的。
输入操作数的形式如下:
[[asmSymbolicName]]constraint(cvariablename)
同输出操作数语法形式一致。这里需要单独对输入操作数的constraint进行说明,与输出操作数不同,输入约束字符串不能以“=”或“+”开头,另外,输入约束也可以是数字。这表明指定的输入变量必须与输出约束列表中(从零开始的)索引处的输出变量指向同一个变量。
例如以下例子:
1 __asm__ __volatile__(
2 "add %2,%0"
3 :"=r"(a)
4 :"0"(a),"r"(b)
5 :
6);
在这个例子中,变量a对应的寄存器既要作为输入变量,也要作为输出变量。这里通过“0”约束将输入操作数与输出操作数绑定。
我们最后再看一下Linux 0.11中的具体例子:
1 inline void set_fs(unsigned long val){
2 __asm__("mov %0,%%fs"::"a"((unsigned short)val));
3 }
该函数的作用是将变量val的值存到%fs寄存器中。在对应的内嵌汇编中,输出操作数为空,而输入操作数则为val变量,在汇编指令里通过%0占位符来表示。
6)Clobbers:破坏描述部分。该位置需要列出除了输出操作数列表中会被修改的值之外,其他会被内嵌汇编修改的寄存器值。破坏描述部分的列表内容是寄存器的名称,要通过引号引起来,如果需要多个寄存器的话,则需要使用逗号进行分隔。这里的作用是通知编译器,说明在内嵌汇编中有哪些寄存器的值会被修改,使得编译器在内嵌汇编语句之前保存对应的寄存器值。
例如以下例子:
1 __asm__ __volatile__(
2 "mov %0, %eax"
3 ::"a"(a):" %eax"
4);
例子中将变量a的值写到 %eax寄存器中,这里 %eax寄存器既非输出操作数,又非输入操作数,因此需要在破坏描述部分进行声明。
除了通用寄存器,clobbers list还有两个特殊的参数有着不同的含义:一个是“cc”,它用来表示内嵌汇编修改了标志寄存器(flags register);另一个是“memory”,它用于通知编译器汇编代码对列表中的项目执行内存读取或写入(例如,访问由输入参数指向的内存)。为了确保内存包含正确的值,GCC可能需要在执行内嵌汇编之前将特定的寄存器值保存到内存中。此外,编译器不会假设在内嵌汇编之前从内存读取的值保持不变,它会根据需要重新加载这些值。“memory clobber”的作用等同于为编译器添加了一个读写内存屏障。
1.4.2 链接器的工作原理
从GCC编译源码到得到可执行二进制文件的过程主要分为4步。
1)预编译:将源码中的预处理指令进行展开,如#include以及#define等指令。
2)编译:编译是将源码经过一系列的分析和优化,生成对应架构的汇编代码。其中包括编译器前端的词法分析、语法分析、语义分析,编译器中端的IR(Intermediate Representation,中间表示)、IR之间的分析/变换/优化,以及编译器后端的指令调度、指令选择、寄存器分配以及代码生成等。
3)汇编:第2步生成的.s汇编文件此时还是人类可读的ASCII格式的文件,但是CPU执行的机器码需要的是二进制指令。因此第3步需要将.s中人类可读的指令与数据一一翻译成CPU可读的二进制文件。这个过程比较简单,只需要查表翻译即可。
4)链接:前边3步的预处理、编译、汇编的过程都是对单一的编译单元来进行的,也就是只有一个源文件。因此,编译器在执行完前面3个步骤后,会得到多个编译单元后缀为.o的目标文件,此时就需要链接器来将这些目标文件链接到一起生成最终的可执行文件。
由此可以看到,链接器做的事情主要是对编译器生成的多个.o文件进行合并,一般采取的策略是把各个目标文件中相同的段进行合并,例如多个.text段合并成可执行文件中的一个.text段。在这个阶段中,链接器对输入的各个目标文件进行扫描,获取各个段的大小,同时会收集所有的符号定义以及引用信息,构建一个全局的符号表。此时,链接器已经构造好了最终的文件布局以及虚拟内存布局,再根据符号表就能确定每个符号的虚拟地址。然后链接器会对整个文件进行第二遍扫描,这一阶段会利用第一遍扫描得到的符号表信息,依次对文件中每个符号引用的地方进行地址替换。这个阶段也就是对符号的解析以及重定位的过程。
以上4个过程是GCC编译链接的全过程。其中,预处理、编译以及汇编的过程,不管在哪个平台(Windows、Linux、macOS)都是通用的。因为虽然操作系统平台不一样,但是CPU的指令集是一样的,有差异的地方主要在于链接的过程。
不同的操作系统平台有着自己的二进制文件格式,例如Windows下的PE格式、Linux下的ELF格式,以及macOS上的MachO格式。二进制文件格式中定义了文件类型的魔数(Magic Number),代码段、数据段的存储位置以及一些其他程序相关的元数据等,因此当你运行对应系统的可执行文件时,需要对应系统的加载器(Loader)识别并加载对应格式的可执行文件,否则应用程序就无法运行,比如,ELF格式的可执行文件就无法运行在Windows系统中。
链接的过程就是生成对应系统加载器可识别格式的文件,组织不同段的位置,设置魔数,设置程序运行起始地址等。由此可见,链接器与加载器的工作关系类似镜像,链接器负责根据二进制文件格式标准生成对应格式的磁盘文件,而加载器则根据二进制文件格式标准将对应的磁盘文件读取到内存当中并执行。
运行在操作系统上的应用程序,是由系统的加载器进行加载并运行的。而操作系统内核在开机上电的时候并没有加载器来负责加载系统内核,因此操作系统的引导程序就需要由自己负责加载。例如,Linux 0.11代码中的bootsetct.S和setup.S这两个汇编文件做的事情就是加载系统内核。
同样,对系统内核的链接器而言,也不能生成ELF格式或者PE格式等,这里需要按照bootsetct.S和setup.S对初始引导过程中的内存布局进行设置,将对应的代码段生成在0x0位置,我们可以在内核的构建系统中看到ld的构建选项中有“Ttext 0x0”这个选项,表示将对应的代码段的虚拟内存地址设置到0x0位置。
如果想查看经过ld链接后的镜像文件中的符号与虚拟内存地址的对应关系,可以通过“-M”选项将对应关系输出到文件中。在调试内核代码的时候可以方便查看内存地址与符号的映射关系。
1.4.3 初识makefile
通常在对单文件或者比较少量的文件进行编译的时候,只需要通过GCC命令直接编译就可以了。因为在文件数目比较少的情况下,其编译过程中的文件依赖关系还是很简单的,可以通过人工控制命令的顺序来解决文件的依赖关系。
然而在大型项目的开发过程中,编译过程面对的往往是成千上万个源码文件,而源码的相互依赖关系又非常复杂,想通过人力来维护这种编译顺序几乎是一件不可能的事情。当然,可以通过维护一个shell脚本(用于构建)文件来控制整个系统中所有文件的编译过程。但是通过shell脚本来控制项目文件的编译顺序有3个问题。
1)shell脚本语言无法原生支持依赖关系的表达,需要通过复杂的逻辑代码来表达源码的依赖关系。
2)shell脚本语言无法原生支持增量依赖编译(即如果只修改项目中的一个文件,只需要重新编译对该文件依赖的模块即可),要想实现控制逻辑也非常复杂。
3)shell脚本的维护成本相对较高。
GNU make是一个收集文件依赖关系,并根据依赖关系自动进行项目构建的工具。依赖定义在makefile文件中,make工具依据makefile的规则来按顺序执行对应的命令。现在的大型项目都会使用更加智能的构建工具cmake,cmake可以自动分析文件中的依赖关系,从而生成对应的makefile文件,使得项目的构建更加简便。不过本书在实现Linux 0.11项目代码的过程中还是采用make工具,因为这个工程结构相对清晰、简单。
1.makefile基本规则
当我们输入make命令时,make会到当前目录下去查找makefile文件。makefile文件由一系列的规则组成,每条规则的形式如下:
1 target…:prerequisites…
2 recipe…
第一行规定了目标文件以及文件的依赖关系。在makefile里,target和冒号是必不可少的,prerequisites在这一行里边可以没有。第一行之后是一条或多条recipe命令,即要达成这个target需要执行的命令。这里需要注意的是,这些recipe命令必须使用Tab分隔符来进行缩进,相比第一行的target需要多缩进一个制表符。
target一般是指该条规则下最终生成的文件名,如可执行文件或者.o/.so文件等。一个target往往会依赖一个或多个文件,即规则中的prerequisites。多个依赖则用空格进行分隔。例如:
1 foo.o:foo.c
2 GCC foo.c-c-o foo.o
3 bar.o:bar.c
4 GCC bar.c-c-o bar.o
5 a.out:foo.o bar.o
6 GCC bar.o foo.o-o a.out
这里第一条规则的target是foo.o文件,foo.o的生成依赖foo.c。生成foo.o的命令要通过GCC编译。第二条规则表明了bar.o文件依赖bar.c。第三条规则给出a.out同时依赖foo.o以及bar.o。所以,在这个makefile文件里,我们可以通过执行make a.out生成最终的可执行文件。在构建a.out的过程中,根据其依赖关系可知它同时依赖foo.o以及bar.o,make会先构建出它的依赖文件foo.o和bar.o,也就是先执行make foo.o以及make bar.o命令。由此可见,make是根据makefile文件定义的规则,并按照依赖的顺序进行构建的。如果构建完a.out以后又对bar.c文件进行了修改,再次执行make a.out时,make只需要重新构建bar.o以及a.out即可,不需要重新构建foo.o,这样在大型项目中可以大大加快构建的速度。
一般情况下,make命令会将第一条规则作为默认执行的规则,这样直接运行make命令就等价于执行make a.out。
除此之外,target可以用来指代一组命令的名称,常用的target是clean。例如:
1 clean:
2 rm a.out *.o
这种情况下执行make clean会删除当前目录下的.o文件以及a.out文件。此时,clean是一个伪目标。如果当前文件夹下恰好有一个clean文件,会干扰到make的执行。因为make发现这个target并没有依赖文件,所以不需要重新构建,这个target对应的recipe也就不需要执行了。为了消除这种影响,我们最好在这种伪目标下做一些声明:
1 .PHONY:clean
2 clean:
3 rm a.out *.o
这样的话,make便不会认为clean是一个需要生成的文件目标了。
2.makefile的变量
在makefile中,还可以通过定义变量来避免在多处输入重复的命令。如在上面a.out的例子中,可以通过变量进行如下改写:
1 OBJS:=foo.o bar.o
2 CC:=GCC
3 CFLAGS:=-O2
4
5 a.out: $(OBJS)
6 $(CC) $(CFLAGS) $(OBJS)-o a.out
7 foo.o:foo.c
8 $(CC) $(CFLAGS)foo.c-c-o foo.o
9 bar.o:bar.c
10 $(CC) $(CFLAGS)bar.c-c-o bar.o
11
12 .PHONY:clean
13 clean:
14 rm a.out$(OBJS)
这里将目标文件的列表定义为变量OBJS,将编译命令定义为变量CC,将编译选项定义为CFLAGS。之后再修改目标文件或者编译器等只需要修改变量即可,不需要对每个规则下的命令进行修改。
除了用户定义的变量外,makefile中还有一系列自动变量,这些自动变量可以在规则执行时根据规则的target以及prerequisites进行刷新和计算。常用的自动变量主要有以下几个。
变量 $@:指代了当前规则里的target。如果一个规则的target有多个的话,则指代第一个target。例如上述例子可以改为:
1 a.out: $(OBJS)
2 $(CC) $(CFLAGS) $(OBJS)-o$@
3 foo.o:foo.c
4 $(CC) $(CFLAGS)foo.c-c-o$@
5 bar.o:bar.c
6 $(CC) $(CFLAGS)bar.c-c-o$@
变量$<:指代了当前规则里的第一个prerequisites。而$ˆ变量则表示当前规则中的所有prerequisites。由此,我们可以把上述例子继续改写为:
1 a.out: $(OBJS)
2 $(CC) $(CFLAGS)$^-o$@
3 foo.o:foo.c
4 $(CC) $(CFLAGS)$<-c-o$@
5 bar.o:bar.c
6 $(CC) $(CFLAGS)$<-c-o$@
变量$?:表示prerequisites列表中所有比target文件更新的文件。
至此,我们对makefile的规则就有了基本的了解。当然makefile还有很多高级的用法,本节只是简单介绍在开发Linux 0.11过程中用到的一些知识。如果读者对makefile的更多用法感兴趣,可以通过man make命令查看GNU make的官方手册。