调用约定, calling conventions,从字面上理解,它是调用函数时的一套约定,是被调用代码的接口,它体现在
-
参数的传递方式,是放在寄存器中?栈中?还是两者混合?
-
参数的传递顺序,是从左到右传递?还是从右到左?
-
是调用者保存寄存器环境,还是被调用者保存?保存哪些寄存器呢?
cdecl 调用约定由于起源于 C 语言,所以又称为 C 调用约定,是 C 语言默认的调用约定。 cdecl 的调
用约定意味着 :
- 调用者将所有参数从右向左入枝。
- 调用者清理参数所占的枝空间。
cdecl 调用约定最大的亮点是它允许函数中参数的数量不固定,我们熟识的 printf 函数,它能够支持变长参数,就是利用此 cdecl 调用约定的性质设计出来的
汇编语言和C语言混合编程可分为两大类。
-
单独的汇编代码文件与单独的C语言文件分别编译成目标文件后, 一起链接成可执行程序。
-
在C 语言中嵌入汇编代码,直接编译生成可执行程序。
本节所说的“汇编语言和 C语言混合编程”属于第 1 种,第 2种的内嵌汇编又称为内联汇编
系统调用是 Linux 内核提供的一套子程序,它和 Windows 的动态链接库 DLL 文件的功能一样,用来实现一系列在用户态不能或不易实现的功能,比如最常见的读写硬盘文件, 系统调用很像 BIOS 中断调用(在很久很久以前咱们有说过 BIOS 中断、 DOS中断等内容),只不过 系统调用的入口只有一个,即第 0x80 号中断,
为什么系统调用只有一个入口呢?中断的实现是要用到中断描述符表的,表中很多中断项(号)是被预留的,不能强占,所以 Linux 就选了一个可用的中断号作为所有系统调用的统一入口,具体的子功能在寄存器 eax 中单独指定。
调用“系统调用”有两种方式。
- 将系统调用指令封装为 c 库函数,通过库函数进行系统调用,操作简单。
- 不依赖任何库函数,直接通过汇编指令 int 与操作系统通信。
演示Linux系统调用
//代码 C_with_S_c.c
extern void asm_print(char*,int);
void c_print(char* str) (
int len=O;
while(str[len++]);
asm_print(str, len);
}
;代码 C_with_S_S.S
section .data
str: db "asm_print says hello world !", 0xa, 0
;0xa 是换行符, o 是手工加上的字符串结束符\ 0 的 ASCII 码
str_len equ $-str
section .text
extern c_print
global _start
_start:
;;;;;;;;;调用 c 代码中的函数 c_print;;;;;;;;;;;
push str ; 传入参数
call c_print ;调用 c 函数
add esp,4 ;回收钱空间
;;;;;;;;;;退出程序 ;;;;;;;;;;;;;;;;;;;
mov eax,1 ;第 1 号子功能是 exit 系统调用
int 0x80 ; 发起中断,通知 Linux 完成请求的功能
;;;;;;;;;asm_print的实现;;;;;;;;;;;;;;
global asm_print
asm_print: ;相当于 asm_print ( str, size)
push ebp; 备份 ebp
mov ebp,esp
mov eax,4;第 4 号子功能是 write 系统调用
mov ebx, 1;此项固定为文件描述符 1 ,标准输出( stdout )指向屏幕
mov ecx, [ebp+8] ;第 1 个参数
mov edx, [ebp+12];第 2 个参数
int 0x80;发起中断,通知 Linux 完成请求的功能
pop ebp;恢复 ebp
ret
总结一下
- 在汇编代码中导出符号供外部引用是用的关键字 global ,引用外部文件的符号是用的关键字extern
- 在C代码中只要将符号定义为全局便可以被外部引用(一般情况下无需用额外关键宇修饰,具体请参考C语言手册),引用外部符号时用 extern 声明即可。
端口实际上就是 IO 接口电路上的寄存器,为了能访问到这些 CPU 外部的寄存器,计算机系统为这些寄存器统一编址, 一个寄存器被赋予一个地址,这些地址可不是我们所说的内存地址,内存地址是用来访问内存用的,其范围取决于地址总线的宽度,而寄存器的地址范围是 0~65535 (Intel 系统)。这些地址就是我们所说的端口号,用专门的 IO 指令 in 和 out 来读写这些寄存器。
给寄存器分组的原因是显卡(显示器的 IO 接口电路)上的寄存器太多了,如果一个寄存器就要占用一个系统端口的话,这得多浪费硬件资源,万一别的硬件也这么干,这 65536 个地址可就捉襟见肘了。
把每一个寄存器分组视为一个寄存器数组,提供一个寄存器用于指定数组下标,再提供一个寄存器用于对索引所指向的数组元素(也就是寄存器)进行输入输出操作。这样用这两个寄存器就能够定位寄存器数组中的任何寄存器啦。
这两个寄存器就是各组中的 Address Register 和 Data Register o Address Register 作为数组的索引 (下标) , Data Register 作为寄存器数组中该索引对应的寄存器,它相当于所对应的寄存器的窗口,往此窗口读写的数据都作用在索引所对应的寄存器上。
所以,对这类分组的寄存器操作方法是先在 Address Register 中指定寄存器的索引值,用来确定所操作的寄存器是哪个,然后在 Data Register 寄存器中对所索引的寄存器进行读写操作。
nasm -I include/ -o mbr.bin mbr.S
dd if=./mbr.bin of=/code/hd60M.img bs=512 count=1 conv=notrunc
nasm -I include/ -o loader.bin loader.S
dd if=./loader.bin of=/code/hd60M.img bs=512 count=1 seek=2 conv=notrunc
nasm -f elf -o lib/kernel/print.o lib/kernel/print.S
gcc -I lib/kernel/ -m32 -c -o kernel/main.o kernel/main.c
ld -Ttext 0xc0001500 -e main -m elf_i386 -s -o kernel.bin kernel/main.o lib/kernel/print.o
dd if=kernel.bin of=/code/hd60M.img bs=512 count=200 seek=9 conv=notrunc
在上面的链接阶段,目标文件链接顺序是 main.o 在前, print.o 在后。大家知道, main.c 文件中用到了 print.o 中的 put_char 函数,在链接顺序上,属于“调用在前,实现在后”的顺序。如果将 print.o 放在前面, main.o 放在后面,也就是实现在前,调用在后,此时生成的可执行文件起始虚拟地址并不准确,会有向后顺延的现象,并且 segment 的数量也不一样。原因是链接器对符号表的处理细节造成的,链接器主要工作就是整合目标文件中的符号,为其分配地址,让使用该符号的文件可以正确定位到它。由于我能力有限,只能大概说一下我的理解,链接器最先处理的目标文件是参数中从左边数第一个(咱们这里是 main.o ),对于里面找不到的符号(这里是 put_char),链接器会将它记录下来,以备在后面的目标文件中查找。如果将其顺序颠倒,势必导致在后面的目标文件中才出现找不到符号的情况,而该符号的实现所在的目标文件早己经过去了,这可能使链接器内部采用另外的处理流程,导致出来的可执行程序不太一样。
;print.S
TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0
section .data
put_int_buffer dq 0 ; 定义8字节缓冲区用于数字到字符的转换
[bits 32]
section .text
;--------------------------------------------
;put_str 通过put_char来打印以0字符结尾的字符串
;--------------------------------------------
;输入:栈中参数为打印的字符串
;输出:无
global put_str
put_str:
;由于本函数中只用到了ebx和ecx,只备份这两个寄存器
push ebx
push ecx
xor ecx, ecx ; 准备用ecx存储参数,清空
mov ebx, [esp + 12] ; 从栈中得到待打印的字符串地址
.goon:
mov cl, [ebx]
cmp cl, 0 ; 如果处理到了字符串尾,跳到结束处返回
jz .str_over
push ecx ; 为put_char函数传递参数
call put_char
add esp, 4 ; 回收参数所占的栈空间
inc ebx ; 使ebx指向下一个字符
jmp .goon
.str_over:
pop ecx
pop ebx
ret
;------------------------ put_char -----------------------------
;功能描述:把栈中的1个字符写入光标所在处
;-------------------------------------------------------------------
global put_char
put_char:
pushad ;备份32位寄存器环境
;需要保证gs中为正确的视频段选择子,为保险起见,每次打印时都为gs赋值
mov ax, SELECTOR_VIDEO ; 不能直接把立即数送入段寄存器
mov gs, ax
;;;;;;;;; 获取当前光标位置 ;;;;;;;;;
;先获得高8位
mov dx, 0x03d4 ;索引寄存器
mov al, 0x0e ;用于提供光标位置的高8位
out dx, al
mov dx, 0x03d5 ;通过读写数据端口0x3d5来获得或设置光标位置
in al, dx ;得到了光标位置的高8位
mov ah, al
;再获取低8位
mov dx, 0x03d4
mov al, 0x0f
out dx, al
mov dx, 0x03d5
in al, dx
;将光标存入bx
mov bx, ax
;下面这行是在栈中获取待打印的字符
mov ecx, [esp + 36] ;pushad压入4×8=32字节,加上主调函数的返回地址4字节,故esp+36字节
cmp cl, 0xd ;CR是0x0d,LF是0x0a
jz .is_carriage_return
cmp cl, 0xa
jz .is_line_feed
cmp cl, 0x8 ;BS(backspace)的asc码是8
jz .is_backspace
jmp .put_other
;;;;;;;;;;;;;;;;;;
.is_backspace:
;;;;;;;;;;;; backspace的一点说明 ;;;;;;;;;;
; 当为backspace时,本质上只要将光标移向前一个显存位置即可.后面再输入的字符自然会覆盖此处的字符
; 但有可能在键入backspace后并不再键入新的字符,这时在光标已经向前移动到待删除的字符位置,但字符还在原处,
; 这就显得好怪异,所以此处添加了空格或空字符0
dec bx
shl bx,1
mov byte [gs:bx], 0x20 ;将待删除的字节补为0或空格皆可
inc bx
mov byte [gs:bx], 0x07
shr bx,1
jmp .set_cursor
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
.put_other:
shl bx, 1 ; 光标位置是用2字节表示,将光标值乘2,表示对应显存中的偏移字节
mov [gs:bx], cl ; ascii字符本身
inc bx
mov byte [gs:bx],0x07 ; 字符属性
shr bx, 1 ; 恢复老的光标值
inc bx ; 下一个光标值
cmp bx, 2000
jl .set_cursor ; 若光标值小于2000,表示未写到显存的最后,则去设置新的光标值
; 若超出屏幕字符数大小(2000)则换行处理
.is_line_feed: ; 是换行符LF(\n)
.is_carriage_return: ; 是回车符CR(\r)
; 如果是CR(\r),只要把光标移到行首就行了。
xor dx, dx ; dx是被除数的高16位,清0.
mov ax, bx ; ax是被除数的低16位.
mov si, 80 ; 由于是效仿linux,linux中\n便表示下一行的行首,所以本系统中,
div si ; 把\n和\r都处理为linux中\n的意思,也就是下一行的行首。
sub bx, dx ; 光标值减去除80的余数便是取整
; 以上4行处理\r的代码
.is_carriage_return_end: ; 回车符CR处理结束
add bx, 80
cmp bx, 2000
.is_line_feed_end: ; 若是LF(\n),将光标移+80便可。
jl .set_cursor
;屏幕行范围是0~24,滚屏的原理是将屏幕的1~24行搬运到0~23行,再将第24行用空格填充
.roll_screen: ; 若超出屏幕大小,开始滚屏
cld
mov ecx, 960 ; 一共有2000-80=1920个字符要搬运,共1920*2=3840字节.一次搬4字节,共3840/4=960次
mov esi, 0xb80a0 ; 第1行行首
mov edi, 0xb8000 ; 第0行行首
rep movsd
;;;;;;;将最后一行填充为空白
mov ebx, 3840 ; 最后一行首字符的第一个字节偏移= 1920 * 2
mov ecx, 80 ;一行是80字符(160字节),每次清空1字符(2字节),一行需要移动80次
.cls:
mov word [gs:ebx], 0x0720 ;0x0720是黑底白字的空格键
add ebx, 2
loop .cls
mov bx,1920 ;将光标值重置为1920,最后一行的首字符.
.set_cursor:
;将光标设为bx值
;;;;;;; 1 先设置高8位 ;;;;;;;;
mov dx, 0x03d4 ;索引寄存器
mov al, 0x0e ;用于提供光标位置的高8位
out dx, al
mov dx, 0x03d5 ;通过读写数据端口0x3d5来获得或设置光标位置
mov al, bh
out dx, al
;;;;;;; 2 再设置低8位 ;;;;;;;;;
mov dx, 0x03d4
mov al, 0x0f
out dx, al
mov dx, 0x03d5
mov al, bl
out dx, al
.put_char_done:
popad
ret
;-------------------- 将小端字节序的数字变成对应的ascii后,倒置 -----------------------
;输入:栈中参数为待打印的数字
;输出:在屏幕上打印16进制数字,并不会打印前缀0x,如打印10进制15时,只会直接打印f,不会是0xf
;------------------------------------------------------------------------------------------
global put_int
put_int:
pushad
mov ebp, esp
mov eax, [ebp+4*9] ; call的返回地址占4字节+pushad的8个4字节
mov edx, eax
mov edi, 7 ; 指定在put_int_buffer中初始的偏移量
mov ecx, 8 ; 32位数字中,16进制数字的位数是8个
mov ebx, put_int_buffer
;将32位数字按照16进制的形式从低位到高位逐个处理,共处理8个16进制数字
.16based_4bits: ; 每4位二进制是16进制数字的1位,遍历每一位16进制数字
and edx, 0x0000000F ; 解析16进制数字的每一位。and与操作后,edx只有低4位有效
cmp edx, 9 ; 数字0~9和a~f需要分别处理成对应的字符
jg .is_A2F
add edx, '0' ; ascii码是8位大小。add求和操作后,edx低8位有效。
jmp .store
.is_A2F:
sub edx, 10 ; A~F 减去10 所得到的差,再加上字符A的ascii码,便是A~F对应的ascii码
add edx, 'A'
;将每一位数字转换成对应的字符后,按照类似“大端”的顺序存储到缓冲区put_int_buffer
;高位字符放在低地址,低位字符要放在高地址,这样和大端字节序类似,只不过咱们这里是字符序.
.store:
; 此时dl中应该是数字对应的字符的ascii码
mov [ebx+edi], dl
dec edi
shr eax, 4
mov edx, eax
loop .16based_4bits
;现在put_int_buffer中已全是字符,打印之前,
;把高位连续的字符去掉,比如把字符000123变成123
.ready_to_print:
inc edi ; 此时edi退减为-1(0xffffffff),加1使其为0
.skip_prefix_0:
cmp edi,8 ; 若已经比较第9个字符了,表示待打印的字符串为全0
je .full0
;找出连续的0字符, edi做为非0的最高位字符的偏移
.go_on_skip:
mov cl, [put_int_buffer+edi]
inc edi
cmp cl, '0'
je .skip_prefix_0 ; 继续判断下一位字符是否为字符0(不是数字0)
dec edi ;edi在上面的inc操作中指向了下一个字符,若当前字符不为'0',要恢复edi指向当前字符
jmp .put_each_num
.full0:
mov cl,'0' ; 输入的数字为全0时,则只打印0
.put_each_num:
push ecx ; 此时cl中为可打印的字符
call put_char
add esp, 4
inc edi ; 使edi指向下一个字符
mov cl, [put_int_buffer+edi] ; 获取下一个字符到cl寄存器
cmp edi,8
jl .put_each_num
popad
ret
内联汇编称为 inline assembly, GCC 支持在 C代码中直接嵌入汇编代码,所以称为 GCC inline assembly.
大家知道,C语言不支持寄存器操作,汇编语言可以,所以自然就想到了在 C 语言中嵌入内联汇编提升“战斗力”的方式,通过内联汇编,程序员可以实现 C 语言无法表达的功能,这样使开发能力大为提升。
内联汇编按格式分为两大类, 一类是最简单的基本内联汇编,另一类是复杂一些的扩展内联汇编,内联汇编中所用的汇编语言,其语法是 AT&T,并不是咱们熟悉的 Intel 汇编语法, GCC 只支持它,所以咱们还得了解下 AT&T。
在 Intel 语法中,立即数就是普通的数字,如果让立即数成为内存地址,需要将它用中括号括起来,“[立即数]”这样才表示以“立即数”为地址的内存。
而 AT&T 认为,内存地址既然是数字,那数字也应该被当作内存地址,所以,数字被优先认为是内存地址,也就是说,操作数若为数字,则统统按以该数字为地址的内存来访问 。 这样,立即数的地位比较次要了,如果想表示成单纯的立即数,需要额外在前面加个前缀$。
在 AT&T 中的内存寻址还是挺独特的,它的内存寻址有固定的格式。
segreg (段基址): base_address(offset_address,index,size)
该格式对应的表达式为:segreg: base_address + offset_address + index * size.
此表达式的格式和 Intel 32 位内存寻址中的基址变址寻址类似, Intel 的格式:segreg:[base+index * size+offset]
不过与 Intel 不同的是 AT&T 地址表达式的值是内存地址,直接被当作内存来读写,而不是普通数字。
注意,格式中没有的部分也要保留逗号来占位 。 一共有 4 种变址寻址组合,下面各举个例子。
- 直接寻址:无 base_address,无 offset_address
movl %eax, (,%esi,2)
功能是将 eax 的值写入 esi * 2 所指向的内存。 - 寄存器间接寻址:无 base_address,有 offset_address
movl %eax,(%ebx, %esi, 2)
功能是将 eax 的值写入 ebx + esi * 2 所指向的内存。 - 寄存器相对寻址:有 base_address,无 offset_address
movl %eax, base value(, %esi, 2)
功能是将 eax 的值写入 base_value + esi * 2吃所指向的内存 。 - 变址寻址:有 base_address,有 offset_address
movl %eax, base_value(%ebx, %esi, 2)
功能是将 eax 的值写入 base_value + ebx + esi * 2所指向的内存。
基本内联汇编是最简单的内联形式,其格式为:asm [volatile] (“assembly code”)
各关键字之间可以用空格或制表符分隔,也可以紧凑挨在一起不分隔,各部分意义如下:
关键字 asm 用于声明内联汇编表达式,这是内联汇编固定的部分,不可少。关键宇 volatile 是可选项,它告诉 gcc:“不要修改我写的汇编代码,请原样保留”。 asm和volatile都是GCC的宏定义
“ assembly code”是咱们所写的汇编代码,它必须位于圆括号中,而且必须用双引号引起来。这是格
式要求,只要满足了这个格式 asm [volatile] (“”), assembly code 甚至可以为空。
下面说下 assembly code 的规则。
- 指令必须用双引号引起来,无论双引号中是一条指令或多条指令。
- 一对双引号不能跨行,如果跨行需要在结尾用反斜杠’'转义。
- 指令之间用分号’; ‘、换行符’\n’或换行符加制表符’\n’'\t’分隔。
char* str="hello,world\n";
int count = 0;
void main() {
asm("pusha; \
movl $4, %eax ; \
movl $1, %ebx; \
movl str, %ecx; \
movl $12, %edx ;\
int $0x80; \
mov %eax, count;\
popa \
");
}
在基本内联汇编中,若要引用 变量,只能将它定义为全局变量。如果定义为局部变量,链接时会找不到这两个符号,这就是基本内联汇编的局限性
扩展内联汇编 asm[ volatile ] ( “assemly code”:output : input : clobber/modify )
和前面的基本内联汇编相比,扩展内联汇编在圆括号中变成了4 部分,多了 output 、 input 和clobber/modify 三项。其中的每一部分都可以省略,甚至包括 assembly code 。省略的部分要保留冒号分隔符来占位,如果省略的是后面的一个或多个连续的部分,分隔符也不用保留,比如省略了 clobber/modify,不需要保留 input 后面的冒号。
assembly code:还是用户写入的汇编指令,和基本内联汇编一样。
汇编代码的运行是需要输入参数的,其运行之后也可产出结果。 input 和 output 正是 C 为汇编提供输入参数和存储其输出的部分,这是汇编与 C 交互的关键,我们之前的讨论就通过这两项解决。
output: 用来指定汇编代码的数据如何输出给 C 代码使用。内嵌的汇编指令运行结束后,如果想将运行结果存储到 C 变量中,就用此项指定输出的位置。 output 中每个操作数的格式为:"操作数修饰符约束名"(C变量名)
其中的引号和圆括号不能少,操作数修饰符通常为等号’=’‘。多个操作数之间用逗号’,'分隔。
input: 用来指定中数据如何输入给汇编使用。要想让汇编使用 C 中的变量作为参数,就要在此指定 。 input 中每个操作数的格式为:"[操作数修饰符]约束名"(C变量名)
其中的引号和圆括号不能少,操作数修饰符为可选项。多个操作数之间用逗号’,’分隔。
单独强调一下,以上的 output() 和 input() 括号中的是C代码中的变量, output(C变量) 和 input(C变量)就像C语言中的函数,将变量(值或变量地址)转换成汇编代码的操作数。
clobber/modify :汇编代码执行后会破坏一些内存或寄存器资源,通过此项通知编译器,可能造成寄存器或内存数据的破坏,这样 gcc 就知道哪些寄存器或内存需要提前保护起来,后面会展开细说。
assembly eode 中引用的所有操作数其实是经过 gcc 转换后的复本,“原件”都是在 output 和 input 括号中的 c 变量,
约束的作用域是 input 和 output 部分,咱们看看这些约束是怎么体现的,约束分为四大类。
-
寄存器约束
寄存器约束就是要求 gee 使用哪个寄存器,将 input 或 output 中变量约束在某个寄存器中。常见的寄
存器约束有:
a:表示寄存器 eax/ax/al
b:表示寄存器 ebx/bx/bl
c:表示寄存器 eex/ex/cl
d:表示寄存器 edx/dx/dl
D :表示寄存器 edi/di
S :表示寄存器 esi/si
q :表示任意这 4 个通用寄存器之-:eax/ebx/ecx/edx
r :表示任意这 6 个通用寄存器之一: eax/ebx/ecx/edx/esi/edi
g :表示可以存放到任意地点(寄存器和内存)。相当于除了同q一样外,还可以让 gcc 安排在内存中
A :把 eax和 edx 组合成 64 位整数
e :表示浮点寄存器
t :表示第 1 个浮点寄存器
u:表示第 2 个浮点寄存器 -
内存约束
内存约束是要求 gcc 直接将位于 input 和 output 中的C变量的内存地址作为内联汇编代码的操作数,不需要寄存器做中转,直接进行内存读写,也就是汇编代码的操作数是 C 变量的指针。
m :表示操作数可以使用任意一种内存形式。
o :操作数为内存变量,但访问它是通过偏移量的形式访问,即包含 offset address 的格式。 -
立即数约束
立即数即常数,此约束要求 gcc 在传值的时候不通过内存和寄存器,直接作为立即数传给汇编代码 。由于立即数不是变量,只能作为右值,所以只能放在 input 中。
i:表示操作数为整数立即数
F:表示操作数为浮点数立即数
I :表示操作数为 0~ 31 之间的立即数
J:表示操作数为 0~ 63 之间的立即数
N:表示操作数为 0~255 之间的立即数
O:表示操作数为 0~32 之间的立即数
X:表示操作数为任何类型立即数 -
通用约束
0~ 9:此约束只用在 input 部分,但表示可与 output 和 input 中第 n 个操作数用相同的寄存器或内存 。
约束的作用是让 C 代码的操作数变成汇编代码能使用的操作数,所有的约束形式其实都是给汇编用的。故,约束是 C 语言中的操作数(变量或立即数〉与汇编代码中的操作数之间的映射,它告诉 gcc,同一个操作数在两种环境下如何变换身份,如何对接沟通。编译过程中 C 代码是要先变成汇编代码的,内联汇编中的约束就相当于 gcc 让咱们指定 C 中数据的编译形式。
在内联汇编中assembly eode 中用到的操作数,都是位于 output 和 input 中 C 操作数的副本,多数通过赋值的方式传给汇编代码,或者顶多是通过指针的形式,当操作数的副本在汇编中处理完成后,又重新赋值给 C 操作数。
对比基本内联汇编和扩展内联汇编
// 基本内联汇编
#include<stdio.h>
int in_a = 1, in_b = 2, out_sum;
void main() {
asm (" pusha; \
movl in_a, %eax; \
movl in_b, %ebx; \
add! %ebx, %eax; \
movl %eax, out _sum; \
popa");
printf("sum is %d\n", out_sum);
}
// 扩展内联汇编
#include<stdio.h>
void main() {
int in_a = 1, in b = 2, out_sum;
asm ("addl %%ebx, %%eax":"=a"(out_sum):"a"(in_a), "b"(in_b)); // "=a"意思是 out_sum=eax
// 扩展内联汇编中寄存器前缀是两个%
printf ("sum is %d\n", out_sum);
}
output 和 input 中用的约束都是同一个,这是否会存在顺序混乱、数据覆盖?其实只要清楚操作数(约束对应的寄存器或内存)被赋值的顺序就明白了,肯定永远是输入(input)中的汇编操作数优先被赋值,汇编代码经过运行,最后才是为输出(output)中的汇编操作数赋值。
#include<stdio.h>
void main() {
int in_a = 1, in_b = 2;
printf("in_b is %d\n", in_b);
asm ("movb %b0, %1;" :: "a"(in_a), "m"(in_b)); // in_b中的立即数数值当作汇编指令的操作数
printf ("in_b now is %d\n", in_b);
}
序号占位符是对在 output 和 input 中的操作数,按照它们从左到右出现的次序从 0 开始编号,一直到9,也就是说最多支持 10 个序号占位符。操作数用在 assembly code 中,引用它的格式是%0~9 。在操作数自身的序号前面加 1 个百分号’%’便是对相应操作数的引用。一定要切记,占位符指代约束所对应的操作数,也就是在汇编中的操作数,并不是圆括号中的 C 变量。
第 5 行对寄存器础的引用:%b0,这是用的 32 位数据的低 8 位,在这里就是指 al 寄存器。如果不显式加字符’b’,编译器也会按照低 8 位来处理,但它会发出警告。我们可以在%和序号之间插入字符h来表示操作数为由ah(第 8~ 15 位〉,或者插入字符b来表示操作数为 al(第 0~7 位〉。
asm ("addl %%ebx, %%eax":"=a"(out_sum): "a"(in_a), "b"(in_b));
//等价于
asm("addl %2, %1":"=a"(out_sum): "a"(in_a), "b"(in_b));
/*其中:
"=a"(out_sum)序号为0, %0 对应的是 eax。
"a"(in_a)序号为1, %1 对应的是 eax 。
"b"(in_b)序号为2, %2 对应的是 ebx 。*/
名称占位符与序号占位符不同,序号占位符靠本身出现在 output 和 input 中的位置就能被编译器辨识出来。而名称占位序需要在 output 和 input 中把操作数显式地起个名字,它用这样的格式来标识操作数:[名称]"约束名"(C 变量)
这样,该约束对应的汇编操作数便有了名字,在 assembly code 中引用操作数时,采用%[名称]的形式就可以了。
#include<stdio.h>
void main() {
int in a = 18, in b = 3, out = 0;
asm ("divb %[divisor];movb %%al, %[result]"\
:[result]"=m"(out) \
:"a"(in_a), [divisor]"m"(in_b) \
);
printf ("result is %d\n", out);
}
在约束中还有操作数类型修饰符,用来修饰所约束的操作数:内存、寄存器,分别在 output和 input
中有以下几种。
在 output 中有以下 3 种。
- =:表示操作数是只写,相当于为 output 括号中的 C 变量赋值,如
=a(c_var)
,此修饰符相当于 c_var=eax 。 - +:表示操作数是可读写的,告诉 gcc 所约束的寄存器或内存先被读入,再被写入。
- &:表示此 output 中的操作数要独占所约束(分配)的寄存器,只供 output 使用,任何 input 中所分配的寄存器不能与此相同。注意,当表达式中有多个修饰符时,&要与约束名挨着,不能分隔。
在 input 中:
%:该操作数可以和下一个输入操作数互换。
一般情况下, input 中的 C 变量是只读的, output 中的 C 变量是只写的。
修饰符’=‘只用在 output 中,表示 C 变量是只写的,功能相当于 output 中的 C 变量=约束的汇编操作数,如"=a"(c_var),相当于 c_var=eax 的值。
修饰符’+'也只用在 output 中,但它具备读、写的属性,也就是它既可作为输入,同时也可以作为输出,所以省去了在 input 中声明约束。
#include <stdio.h>
void main(){
int in_a = 1, in_b = 2 ;
asm ("addl %%ebx, %%eax;": "+a"(in_a): "b"(in_b));
printf ("in_a is %d\n", in_a);
}
修饰符’&'用来表示此寄存器只能分配给 output 中的某个 C 变量使用,不能再分给 input 中某变量了。函数在执行完成时,返回值会存储在寄存器 eax 中。通常我们会将返回值获取到 C 变量中再处理。如果是让 gcc 自由分配寄存器, gcc 有可能把 eax 分配出去另做他用,有可能的一种情况是先调用函数,函数返回后又执行了其他操作,这个操作中用到了 gcc 分配的 eax 寄存器,于是函数的返回值便被破坏
#include <stdio .h>
void main () {
int ret_cnt = 0, test = 0;
char* fmt = "hello,world\n"; //共 12 个字符
asm ("pushl %1; \
call printf; \ // 执行后eax值为12
addl $4, %%esp; \
movl $6, %2"\ // 这里实际gcc分配eax给%2,覆盖了原本要输出的12
: "=a"(ret_cnt) \ // 要通过eax做输出
: "m"(fmt), "r"(test) \
) ;
printf ("the number of bytes written is %d\n", ret_cnt);
}
使用’&'可以让gcc不去破坏要作为输出的寄存器
#include <stdio .h>
void main () {
int ret_cnt = 0, test = 0;
char* fmt = "hello,world\n"; //共 12 个字符
asm ("pushl %1; \
call printf; \ // 执行后eax值为12
addl $4, %%esp; \
movl $6, %2"\ // 这里实际gcc分配eax给%2,覆盖了原本要输出的12
: "=&a"(ret_cnt) \ // 要通过eax做输出
: "m"(fmt), "r"(test) \
) ;
printf ("the number of bytes written is %d\n", ret_cnt);
}
clobber/modify 部分,这部分用于通知 gcc ,我们修改了哪些寄存器或内存。
由于我们在 C 程序中嵌入了汇编代码,这必然会造成一些资源的破坏,本来嘛,人家 C 代码翻译后也要用到寄存器,突然来了一堆抢寄存器用的汇编指令,这肯定会使 gcc 重新为 C 代码安排寄存器等资源。为了解决资源冲突,我们得让 gcc 知道,我们改变了哪些寄存器或内存,这样 gcc 才能合理安排。
如果在 output 和 input 中通过寄存器约束指定了寄存器, gcc 必然会知道这些寄存器会被修改,所以,需要在 clobber/modify 中通知的寄存器肯定不是在 output 和 input 中出现过的 。
明显的改变 gcc 当然能扫描出来。可“暗处”的指令就无法保证了,比如在汇编中调用了一个函数,该函数内部会修改一些资源,或者该函数中又调用了其他函数,这保不准在哪一层调用有修改资源的代码,简直无法跟踪。所以必须要人为显式地告诉 gcc 我们动了哪些资源,这个资源就是寄存器和内存。
asm ("movl %%eax, %0; movl %%eax,%%ebx": "=m"(ret_value) :: "bx"); // 即使寄存器只变动一部分,它的整体也会全跟着受影响,所以只写bx就可以
如果我们的内联汇编代码修改了标志寄存器 eflag 中的标志位,同样需要在 clobber/modfyi命中用"cc"声明。
如果我们在 output 中使用了内存约束, gcc 自然会得到哪块内存被修改。但如果被修改的内容井未在output 中,我们就需要用” memory ” 告诉 gcc 啦 。
另外一个用” memory”声明的原因就是清除寄存器缓存。
内存相对寄存器来说还是比较慢的, gcc 为了提速,编译中有时会把内存中的数据缓存到寄存器,之后的处理都是直接读取寄存器。编译过程中编译器无法检测到内存的变化,只有编译出来的程序在实际运行中才会出现变量的值被改变,也就是出现了内存变化的情况。 编译器编译程序时,将变量的值缓存到寄存器。程序在运行时,当变量所在的内存有变化,寄存器中的缓存还是旧数据,运行结果肯定就错了。
我们就可以在内联汇编中的 clobber/modify 部分用” memory”声明,通知编译器变量所在的内存数据变啦,这样它就会从内存再读取一次新数据啦。
机器模式用来在机器层面上指定数据的大小及格式。
h -输出寄存器高位部分中的那一字节对应的寄存器名称,如 ah 、 bh 、 ch、dh。
b -输出寄存器中低部分 1 字节对应的名称,如 al 、 bl 、 cl 、 dl 。
w -输出寄存器中大小为 2 个宇节对应的部分,如 ax 、 bx 、 ex 、 dx 。
k -输出寄存器的四字节部分,如 eax 、 ebx 、 ecx, edx 。