二、2.打印和内联汇编

调用约定, calling conventions,从字面上理解,它是调用函数时的一套约定,是被调用代码的接口,它体现在

  • 参数的传递方式,是放在寄存器中?栈中?还是两者混合?

  • 参数的传递顺序,是从左到右传递?还是从右到左?

  • 是调用者保存寄存器环境,还是被调用者保存?保存哪些寄存器呢?


cdecl 调用约定由于起源于 C 语言,所以又称为 C 调用约定,是 C 语言默认的调用约定。 cdecl 的调
用约定意味着 :

  1. 调用者将所有参数从右向左入枝。
  2. 调用者清理参数所占的枝空间。

cdecl 调用约定最大的亮点是它允许函数中参数的数量不固定,我们熟识的 printf 函数,它能够支持变长参数,就是利用此 cdecl 调用约定的性质设计出来的


汇编语言和C语言混合编程可分为两大类。

  1. 单独的汇编代码文件与单独的C语言文件分别编译成目标文件后, 一起链接成可执行程序。

  2. 在C 语言中嵌入汇编代码,直接编译生成可执行程序。

本节所说的“汇编语言和 C语言混合编程”属于第 1 种,第 2种的内嵌汇编又称为内联汇编


系统调用是 Linux 内核提供的一套子程序,它和 Windows 的动态链接库 DLL 文件的功能一样,用来实现一系列在用户态不能或不易实现的功能,比如最常见的读写硬盘文件, 系统调用很像 BIOS 中断调用(在很久很久以前咱们有说过 BIOS 中断、 DOS中断等内容),只不过 系统调用的入口只有一个,即第 0x80 号中断,

为什么系统调用只有一个入口呢?中断的实现是要用到中断描述符表的,表中很多中断项(号)是被预留的,不能强占,所以 Linux 就选了一个可用的中断号作为所有系统调用的统一入口,具体的子功能在寄存器 eax 中单独指定。


调用“系统调用”有两种方式。

  1. 将系统调用指令封装为 c 库函数,通过库函数进行系统调用,操作简单。
  2. 不依赖任何库函数,直接通过汇编指令 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。

image-20230810173738290

在 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 种变址寻址组合,下面各举个例子。

  1. 直接寻址:无 base_address,无 offset_address
    movl %eax, (,%esi,2)
    功能是将 eax 的值写入 esi * 2 所指向的内存。
  2. 寄存器间接寻址:无 base_address,有 offset_address
    movl %eax,(%ebx, %esi, 2)
    功能是将 eax 的值写入 ebx + esi * 2 所指向的内存。
  3. 寄存器相对寻址:有 base_address,无 offset_address
    movl %eax, base value(, %esi, 2)
    功能是将 eax 的值写入 base_value + esi * 2吃所指向的内存 。
  4. 变址寻址:有 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 的规则。

  1. 指令必须用双引号引起来,无论双引号中是一条指令或多条指令。
  2. 一对双引号不能跨行,如果跨行需要在结尾用反斜杠’'转义。
  3. 指令之间用分号’; ‘、换行符’\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 种。

  1. =:表示操作数是只写,相当于为 output 括号中的 C 变量赋值,如=a(c_var),此修饰符相当于 c_var=eax 。
  2. +:表示操作数是可读写的,告诉 gcc 所约束的寄存器或内存先被读入,再被写入。
  3. &:表示此 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 。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值