内嵌汇编
内嵌汇编
操作系统高级教程上面需要阅读Linux内核0.11的源码,其中在书《Linux内核设计的一书》第2.5节异常处理类中段服务程序挂接的部分,遇到了嵌入在C语言中的汇编代码,之前从来没有学习过汇编,因此记录下。
AT&T基础知识
内嵌汇编使用的是AT&T汇编,所以首先稍微讲解下AT&T的汇编指令的基础知识。
操作数前缀
1 | movl $8,%eax |
看到在AT%T汇编中诸如”%eax”、”%ebx”之类的寄存器名字前都要加上”%”;”$8”、”$0xffff”这样的立即数之前都要加上”$”。
源/目的操作数顺序
在Intel语法中,第一个操作数是目的操作数,第二个操作数源操作数。而在AT&T中,第一个数是源操作数,第二个数是目的操作数。
1 | // INTEL语法 |
标识长度的操作码后缀
在AT&T的操作码后面有时还会有一个后缀,其含义就是指出操作码的大小。“l”表示长整数(32位),“w”表示字(16位),“b”表示字节(8位)。
1 | movb %bl,%al |
GCC内嵌汇编
Linux操作系统内核代码绝大部分使用C语言编写,只有一小部分使用汇编语言编写,例如与特定体系结构相关的代码和对性能影响很大的代码。GCC提供了内嵌汇编的功能,可以在C代码中直接内嵌汇编语言语句,大大方便了程序设计。
基本行内汇编
基本行内汇编很容易理解,一般是按照下面的格式:
asm(“statements”);
在“asm”后面有时也会加上“volatile”表示编译器不要优化代码,后面的指令保留原样
__asm__ __volatile__("hlt");
如果有很多行汇编,则每一行后要加上“\n\t” :
1 | asm( "pushl %eax\n\t" |
或者我们也可以分成几行来写,如:
1 | asm("movl %eax,%ebx"); |
通常使用汇编语句最方便的方式是把它们放在一个宏内,而宏语句需要在一行上定义,因此使用反斜杠\
将这些语句连成一行,所以上述语句如果在宏中定义的话就是:
1 | asm( "pushl %eax; \ |
扩展的行内汇编
在扩展的行内汇编中,可以将C语言表达式(比如C语言中的变量)指定为汇编指令的操作数,而且不用去管如何将C语言表达式的值读入寄存器,以及如何将计算结果写回C变量,你只要告诉程序中C语言表达式与汇编指令操作数之间的对应关系即可, GCC会自动插入代码完成必要的操作。
使用内嵌汇编,要先编写汇编指令模板,然后将C语言表达式与指令的操作数相关联,并告诉GCC对这些操作有哪些限制条件。例如下面的内嵌汇编语句:
1 | int main(){ |
“movl %1,%0”
是指令模板;“%0”和“%1”代表指令的操作数,称为占位符,“=r”代表它之后是输入变量且需用到寄存器,指令模板后面用小括号括起来的是C语言表达式 ,其中input是输入变量,该指令会完成把input的值复制到result中的操作 。
扩展的行内汇编的语法
内嵌汇编语法如下:
1 | asm("汇编语句模块" |
即格式为asm ( "statements" : output_regs : input_regs : clobbered_regs)
汇编语句模块必不可少,其他三部分可选,如果使用了后面的部分,而前面部分为空,也需要用“:”格开,相应部分内容为空。
汇编语句模块
汇编语句模块由汇编语句序列组成,语句之间使用“;”、“\n”或“\n\t”分开。指令中的操作数可以使用占位符引用C语言变量,操作数占位符最多10个,名称如下:%0,%1…,%9。指令中使用占位符表示的操作数,总被视为long型(4,个字节),但对其施加的操作根据指令可以是字或者字节,当把操作数当作字或者字节使用时,默认为低字或者低字节。对字节操作可以显式的指明是低字节还是次字节。方法是在%和序号之间插入一个字母,“b”代表低字节,“h”代表高字节,例如:%h1。
占位符的理解:将汇编输出寄存器与输入寄存器从输出寄存器行开始左到右从上到下进行编号分别为:%0,%1…,%9。比如有代码:
1 | #define get_seg_byte(seg,addr) \ |
输出寄存器”=a”eax记为%0,输入寄存器””(依然是eax)记为%1,输入寄存器”m”为%2。
输出寄存器
描述输出操作数,不同的操作数描述符之间用逗号格开,每个操作数描述符由限定字符串和C语言变量组成。每个输出操作数的限定字符串必须包含“=”,表示它是一个输出操作数。例如:
__asm__ __volatile__ ("pushfl ; popl %0 ; cli":"=g" (x) )
在这里“x”便是最终存放输出结果的C程序变量,而“=g”则是限定字符串,限定字符串表示了对它之后的变量的限制条件 。
输入寄存器
描述输入操作数,不同的操作数描述符之间使用逗号格开,每个操作数描述符同样也由限定字符串和C语言表达式或者C语言变量组成。例:
1 | __asm__ volatile__ ("lidt %0" : : "m" (real_mode_idt)); |
其中%0是占位操作符,而输出寄存器为空,输入寄存器的值为C语言表达式real_mode_idt。
限定字符串
又叫做寄存器加载代码
限定字符 | 描述 | 限定字符 | 描述 |
---|---|---|---|
a | 使用寄存器eax | m | 使用内存地址 |
b | 使用寄存器ebx | o | 使用内存地址并可以加偏移值 |
m、o、V、p | 使用寄存器ecx | I | 使用常数0~31 立即数 |
g、X | 寄存器或内存 | J | 使用常数0~63 立即数 |
I、J、N、i、n | 立即数 | K | 使用常数0~255立即数 |
D | 使用edi | L | 使用常数0~65535 立即数 |
q | 使用动态分配字节可寻址寄存器(eax、ebx、ecx或edx) | M | 使用常数0~3 立即数 |
r | 使用任意动态分配的寄存器 | N | 使用1字节常数(0~255)立即数 |
g | 使用通用有效的地址即可(eax、ebx、ecx、edx或内存变量) | O | 使用常数0~31 立即数 |
A | 使用eax与edx联合(64位) | i | 立即数 |
例子
直接摘抄自《Linux内核完全注释》第5.2.2 traps.c程序的第82页
- 例子1:
1 | 01 #define get_seg_byte(seg,addr) \ |
第1 行定义了宏的名称,也即是宏函数名称 get_seg_byte(seg,addr)
。第 3 行定义了一个寄存器变量 res 。第 4 行上的 __asm__
表示嵌入汇编语句的开始。从第 4 行到第 7 行的 4 条语句是 AT&T 格式的汇编语句。
第 8 行即是输出寄存器,这句的含义是在这段代码运行结束后将 eax 所代表的寄存器的值放入__res
变量中,作为本函数的输出值, “=a” 中的 “a” 称为加载代码, “=” 表示这是输出寄存器。第 9 行表示在这段代码开始运行时将 seg 放到 eax 寄存器中, “” 表示使用与上面同个位置的输出相同的寄存器。而 ((addr))表示一个内存偏移地址值。为了在上面汇编语句中使用该地址值,嵌入汇编程序规定把输出和输入寄存器统一按顺序编号,顺序是从输出寄存器序列从左到右从上到下以 “%0” 开始,分别记为 %0 、 %1 、 …%9 。因此,输出寄存器的编号是 %0 (这里只有一个输出寄存器),输入寄存器前一部分 (“” (seg)) 的编号是 %1 ,而后部分的编号是 %2 。上面第 6 行上的 %2 即代表 ((addr)) 这个内存偏移量。现在我们来研究 4—7 行上的代码的作用。第一句将 fs 段寄存器的内容入栈;第二句将 eax 中的段值赋给 fs 段寄存器;第三句是把 fs:(*(addr)) 所指定的字节放入 al 寄存器中。当执行完汇编语句后,输出寄存器 eax 的值将被放入 __res
,作为该宏函数(块结构表达式)的返回值。
通过上面分析,我们知道,宏名称中的 seg 代表一指定的内存段值,而 addr 表示一内存偏移地址量。到现在为止,我们应该很清楚这段程序的功能了吧!该宏函数的功能是从指定段和偏移值的内存地址处取一个字节。
通过上面的例子说明,阅读这段代码时应该像CPU处理指令时的逻辑一样,先从输出输入寄存器语句开始知道输入输出是什么,然后再阅读汇编语句,处理完后再看最后的返回是什么。
- 例子2
再来看下Linux 内核中main()中对中断异常挂接的trap_init()中的设计到的一个GCC内嵌汇编。代码路径为;include\asm\system.h
1 | 1 #define _set_gate(gate_addr,type,dpl,addr) \ |
首先从第6行输出寄存器开始阅读:输出寄存器为空。然后是第7至10行的输入寄存器。第%0个输入寄存器使用“i”表示输入立即数,第%1个输入寄存器使用“o”表示使用内存地址并可以加偏移值,第%2个输入寄存器依然使用“o”代码,第%3个寄存器使用“d”表示使用寄存器edx,第%4个寄存器使用“a”表示使用寄存器eax。
之后再看下汇编语句:依次进行值的传递。
参考
《Linux内核设计的艺术》新设计团队 著
《Linux内核完全注释》赵炯 编著