6.1 函数调用约定简介
咱们实验使用cdecl
。这里提一下stdcall
,cdecl
与stdcall
的区别在于由谁来回收栈空间。
stdcall
是被调用者清理参数所占的栈空间。
举例来说:
1 | int subtract(int a, int b); |
上面的 c 代码编译后的汇编语句是:
1 | # 主调用 |
值得关注的代码是ret 8
,这里由于既要将栈顶回退到参数之前,又要保证当前栈顶是返回地址,故 ret 指令有了这样的变体:ret 16位随机数
,从而允许在返回时顺便修改 esp 的值。
也就是对于ret 8
来说,由于之前先执行了pop ebp
,故当前 esp 指向是上图的[esp +4]位置,即当前栈顶为主调函数的返回地址,ret 指令将栈顶数据 pop 到 EIP 寄存器后,esp 自动+4,又由于有个参数 8,esp 又被+8,从而跳过参数 a 和 b,完成被调用者清理参数栈的任务。
回到cdecl
,由于 C 语言遵循的是cdecl
,故我们实验就遵守该规定了,这意味着:
- 调用者将所有参数从右向左入栈。
- 调用者清理参数所占的栈空间。
还是用 subtract 函数举例:
1 | # 主调用 |
和stdcall
相比,区别就在于栈的回收在主调函数中完成。
6.2 汇编语言和 C 语言混合编程
6.2.1 浅析 C 库函数与系统调用
BIOS 中断走的是中断向量表,所以有很多中断号给它用,而系统调用走的是中断描述符表中的一项而己,所以只用了第 0x80 项中断。
在 Linux 系统中,系统调用是定义在/usr/include/asm/unistd.h
文件中,该文件只是个统一的入口,指向了 32 位和 64 位两种版本。在 asm 目录下提供了这两个版本,这里以 32 位 x86 平台下的 unistd_32.h 为例:在/usr/include/asm/unistd_32.h
中共定义了 436 个系统调用(Ubuntu 20.04)。
不知道系统调用的用法,可以用 man 命令来查看。
例如:man 2 write
调用“系统调用”有两种方式。
- 将系统调用指令封装为 c 库函数,通过库函数进行系统调用,操作简单。
- 不依赖任何库函数,直接通过汇编指令 int 与操作系统通信。
系统调用输入参数的传递方式
当输入的参数小于等于 5 个时,Linux 用寄存器传递参数,依次为 ebx、ecx、edx、esi、edi 中,eax 寄存器用来存储子功能号。当参数个数大于 5 时,把参数按照顺序放入连续的内存区域,并将该区域的首地址放到 ebx 寄存器。
系统调用两个方式的代码演示:
1 | section .data |
编译链接:
1 | nasm -f elf -o syscall_write.o syscall_write.S |
执行结果:
6.2.2 汇编语言和 C 语言共同协作
|
| 导出符号 | 导入符号 |
| — | — | — |
| 汇编 | 关键字global
| 关键字extern
|
| C 语言 | 将符号定义为全局 | 关键字extern
|
举个例子:
调用过程:
6.3 实现自己的打印函数
之前我们往屏幕上输出文本时,要么利用 BIOS 中断,要么利用系统调用,这都是依赖别人的方法。本节我们要通过直接写显存来完成一个打印函数。
6.3.1 显卡的端口控制
显卡寄存器:
其中 CRT Controller Registers 寄存器组有点特殊,它的端口地址并不固定,具体值取决于Miscellaneous Output Register
寄存器中的Input/Output Address Select
字段。
I/OAS(Input/Output Address Select)
此位用来选择 CRT controller 寄存器组的地址和 Feature Control register 寄存器的写端口:
- 当此位为 0 时:
- CRT controller 寄存器组的端口地址被设置为 0x3B4h-0x3B5h。并且为了兼容 monochrome 适配器(显卡),Input Status #1 Register 寄存器的端口地址被设置为 0x3BA
- Feature Control register 寄存器的写端口是 0x3BA。
- 当此位为 1 时:
- CRT controller 寄存器组的端口地址被设置为 0x3D4h-0x3D5h。并且为了兼容 color/graphics 适配器(显卡),Input Status #1 Register 寄存器的端口地址被设置为 0x3DA。
- Feature Control register 寄存器的写端口是 0x3DA。
默认情况下,Miscellaneous Output Register 寄存器的值为 0x67,即 IOAS 位为 1。
其他分组寄存器:
6.3.2 实现单个字符打印
功能类似 C 语言的 putchar 函数,暂且命名为put_char
。
首先参考 Linux 的/usr/include/stdint.h
文件,定义一些数据类型:
1 |
|
处理流程:
- 备份寄存器现场。
- 获取光标坐标值,光标坐标值是下一个可打印字符的位置。
- 获取待打印的字符。
- 判断字符是否为控制字符,若是回车符、换行符退格符三种控制字符之一,则进入相应的处理流程。否则,其余字符都被粗暴地认为是可见字符,进入输出流程处理。
- 判断是否需要滚屏。
- 更新光标坐标值,使其指向下一个打印字符的位置。
- 恢复寄存器现场,退出。
代码 6-1-1
1 | TI_GDT equ 0 |
代码解析:
- 第 12 行
pushad
是备份 32 位寄存器的环境,pushad 是 push all double,该指令压入所有双字长的寄存器,这里的“所有”共 8 个,他们的入栈顺序是:EAX->ECX->EDX->EBX->ESP->ESI->EDI。 - 第 37 行是将光标值从 ax 寄存器中复制到 bx,这么做的原因是习惯使用 bx 寄存器做基址寻址。
- 第 42~50 行是判断参数是什么字符。
代码 6-1-2
1 | ; 退格键处理 |
代码解析:
- 第 3 行
dec bx
,由于 bx 是光标位置,即代表着字符的下一个位置,故 bx-1 指向当前字符; - 第 9 行
shr bx, 1
,由于一个字符在显存中占 2 字节,前面已经将bx*2
,故此时 bx 的值为字符在显存的相对地址,右移一位从而恢复 bx 的值为光标的坐标; - 第 22 行判断光标的坐标是否超过了 2000(80*25 模式下可显示字符的最大值),未超过 2000 就可以更新坐标,若超过 2000 则表示需要滚屏了。
- 第 29~33 行是回车操作,也就是将光标回撤到当前行的行首。方法是将光标坐标值对 80 求模,用 bx 减去余数就是行首字符的位置。
滚屏的两种情况:
- 新的光标值超出了屏幕右下角最后一个字符的位置。
- 最后一行中任意位置有回车或换行符。
实现滚屏的方法:
- 使用
Start Address High Register
和Start Address Low Register
寄存器,他们分别设置屏幕上显示的字符的起始位置地址的高 8 位和低 8 位。屏幕从该地址起向后显示 2000 个字符。由于显存有 32KB,在 8025 模式下一屏可以显示 2000 字符,故显存中可以存放 32KB/20002B ≈ 8 屏的字符,故可以使用这种方法保存大概 8 屏左右的内容。 - 默认情况下上述两个寄存器的值都是 0,一直到以该地址向上偏移 3999 字节的地方。可以把屏幕固定在此,只显示当前 2000 个字符。实现这种方案的步骤:
- 将第 1~24 行的内容整块搬到第 0~23 行,也就是把第 0 行的数据覆盖;
- 再将第 24 行,也就是最后一行的字符用空格覆盖,这样它看上去是一个新的空行;
- 把光标移动到第 24 行行首。
代码 6-1-3
1 | ;滚屏处理 |
代码解析:
- 5~8 行是把第 1-24 行的字符整体往上提一行。
- 11~17 行是循环将最后一行填充为空格,即清空最后一行.
- 18 行是将光标设到第 24 行行首。
为什么在函数里重新给 gs 寄存器赋值
已知:
在 loader 中, gs 被赋予成正确的选择子,程序进入保护模式之后就继承了 0 特权级,内核也在 0 特权级下工作。可是当有了用户进程之后问题就来了。用户进程是需要用 iretd 返回指令上 CPU 运行,CPU 执行 iretd 指令时会做特权检查(要是有任何一个数据段寄存器所指的段描述符 DPL 高于从 iretd 命令返回后的 CPL,CPU 会将该段寄存器赋值为 0)。
问题:
故将来为用户进程初始化寄存器时,CS.RPL 为 3,而用于访问显存的 gs 寄存器其选择子指向的段描述符 DPL 必须为 3 才能正常使用,否则 CPU 会将其置为 0。但目前我们使用的显存段描述符是 GDT 中的第 3 个段描述符,其 DPL 为 0。
解决方案:
- 为用户进程创建一个显存段描述符,其 DPL 为 3,专门给特权 3 级的用户进程使用。
- 在打印函数中动动手脚,将 GS 的值改为指向目前 DPL 为 0 的显存段描述符。
分析:
打印字符这种和硬件相关的功能属于内核范畴,用户需要打印输出时应该请求内核服务,由内核帮助完成。故应该选用第 2 种方法,用户进程将来在打印输出时,是需要通过系统调用陷入内核来完成的,到时用户进程的 CPL 会由 3 变成 0,执行的是内核的挨骂,那时再将 GS 赋值为内核使用的显存段选择子即可。
测试打印函数
1 |
|
1 |
|
编译链接写硬盘:
1 | nasm -f elf -o lib/kernel/print.o lib/kernel/print.S |
为了避免链接符号出现问题导致起始虚拟地址不准确,链接参数的顺序最好是调用在前,实现在后。
运行效果:
6.3.3 实现字符串打印
1 | [bits 32] |
代码就是循环调用 putchar 函数,遇到“\0”停止循环。
这里请注意,C 编译器会为字符串常量分配一块内存,在这块内存中存储字符串中各字符的 ASCII 吗,并且会在结尾后自动补充结束符’\0’,它的 ASCII 码是 0.编译器将该字符串作为参数时,传递的是字符串所在的内存起始地址,也就是说压入栈中的是存储该字符串的内存首地址,不是各个字符。
1 |
|
1 |
|
编译链接输出同上一小节,测试结果:
6.3.4 实现整数打印
只是整数,不支持浮点数。
1 | ;--------------------------------------------------------------------- |
函数的功能是将 32 位整形数字转换为字符后输出。实现原理是按十六进制来处理 32 位数字,每 4 位二进制表示 1 位十六进制,将各十六进制转换成对应字符,共 8 个十六进制数要处理。
代码解析:
- 首先在文件头定义了一个数据区,里面用伪指令 dq 申请了 8 字节的内存 put_int_buffer,他作为转换过程中用的缓冲区,实际上它的用途就是用于存储转换后的字符。
- 第 16 行不是必须的,只是直接通过 esp 获取参数不是个好习惯,难免有压栈操作改变 esp 的时候
- 第 19 行为 edi 赋值 7,表示在缓冲区中的偏移量,偏移 7 表示指向缓冲区最后一个 1 字节。
- 第 24~32 行表示若数字是
0~9
,则字符 0~9 的 ASCII 码是’0’的 ASCII 码+偏移量,该偏移量为当前的数字;同理字符 A~F 的 ASCII 吗是’A’的 ASCII 码+数字。 - 第 35~40 行是将转换好的字符保存到缓冲区 put_int_buffer 中,这里要注意大小端问题,这里采用的是大端模式。
1 |
|
1 |
|
运行结果:
6.4 内联汇编
之前用的汇编方法是 C 代码和汇编代码分别编译,最后通过链接的方式结合在一起形成可执行文件,本节将介绍在 C 代码中直接嵌入汇编语言。
其实还有一种方法,是将 C 代码编译为汇编代码后,再修改汇编代码 -。-|
6.4.1 什么是内联汇编
GCC 支持在 C 代码中直接嵌入汇编代码,所以称为 GCC inline assembly。
GCC 只支持 AT&T 语法。
6.4.2 汇编语言 AT&T 语法简介
无论语法怎么变,汇编语言中指令关键字肯定不能有太大出入,只是在指令名字最后加上了操作数大小后缀,b 表示 1 字节,w 表示 2 字节,l 表示 4 字节。语法风格的差异见表 6-9。
在 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 地址表达式的值是内存地址,而不是普通数字。
解析:
base_address
是基地址,可以为整数、变量名,可正可负。offset_address
是偏移地址,必须是 8 个通用寄存器之一。index
是索引值,必须是 8 个通用寄存器之一。- size 是长度,只能是 1、2、4、8(同 Intel 语法)。
寻址语法举例:
- 直接寻址,例
mov $6, 0xc00008F0
- 寄存器间接寻址,例
mov ($eax), %ebx
- 寄存器相对寻址,例
movb -4(%ebx),%al
,指将(ebx-4)所指向的内存复制 1 字节到 al。 - 变址寻址,例
movl %eax,base_value(%ebx,%esi,2)
6.4.3 基本内联汇编
基本内联汇编格式:asm [valatile] ("assembly code")
assembly code 规则:
- 指令必须用双引号引起来,无论双引号中是一条指令或多条指令。
- 一对双引号不能跨行,如果跨行需要在结尾用反斜杠’'转义。
- 指令之间用分号’;‘、换行符’\n’或换行符加制表符’\n"\t’分隔。
例如:
1 | char* str="hello,world"\n"; |
在 AT&T 中立即数的地位比较低,要加$前缀才表示数字为立即数。第 10 行是获取 write 的返回值,返回值都是存储在 eax 寄存器中,所以将其复制到变量 count 中。
在基本内联汇编中,若要引用 C 变量,只能将它定义为全局变量。如果定义为局部变量,链接时会找不到这两个符号,这就是基本内联汇编的局限性,简单的东西往往功能不够强大,所以咱们还得学下扩展内联汇编形式。
6.4.4 扩展内联汇编
扩展内联汇编格式:
1 | asm [volatile] ("assembly code":output : input : clobber/modify) |
扩展内联汇编在括号中变成了 4 部分,这 4 部分的每一部分都可以省略。
- assembly code:汇编指令
- output:执行汇编代码的数据如何输出给 C 代码使用。每个操作数的格式为
"操作数修饰符约束名"(C变量名)
- input:指定 C 中数据如何输入给汇编使用。每个操作数的格式:
"[操作数修饰符] 约束名"(C变量名)
- clobber/modify:汇编代码执行后会破坏一些内存或寄存器资源,通过此项通知编译器,可能造成寄存器或内存数据的破坏,这样 gcc 就知道哪些寄存器或内存需要提前保护起来。
寄存器约束:
- a 表示 eax/ax/al
- b 表示 ebx/bx/bl
- c 表示 ecx/cx/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 位整数
- f 表示浮点寄存器
- t 表示第 1 个浮点寄存器
- u 表示第 2 个浮点寄存器
基本内联汇编和扩展内联汇编的区别
1 |
|
在基本内联汇编中
- 寄存器用单个%做前缀
- 操作数是手动 movl 到寄存器中,寄存器的数据也是手动 movl 到变量中。
1 |
|
在扩展内联汇编中
- 寄存器前缀是两个%
- 操作数用约束名直接从变量赋值到寄存器中,返回值也通过约束名从寄存器中赋值到变量中。
内存约束:
内存约束是要求 gcc 直接将位于 input 和 output 中的 C 变量的呢村地址作为内联汇编代码的操作数,不需要寄存器做中转,直接进行内存读写,也就是汇编代码的操作数是 C 变量的指针。
- m 表示操作数可以使用任意一种内存形式
- o 操作数为内存变量,但访问它是铜鼓哦偏移量的形式访问,即包含
offset_address
的格式
举例说明:
1 |
|
第 5 行是内联汇编,把 in_a 施加寄存器约束 a,告诉 gcc 把变量 in_a 放到寄存器 eax 中,对 in_b 施加内存约束 m,告诉 gcc 把变量 in b 的指针作为内联代码的操作数。
%b0
这是用的 32 位数据的低 8 位,这里指 ax 寄存器的低 8 位,即 al 寄存器。
%1
序号占位符,这里指 in_b 的内存地址。
立即数约束
由于立即数不是变量,只能作为右值,所以只能放在 input 中。
- i:表示操作数为整数立即数
- F:表示操作数为浮点数立即数
- I:表示操作数为 0~31 之间的立即数
- J:表示操作数为 0~63 之间的立即数
- N:表示操作数为 0~255 之间的立即数
- O:表示操作数为 0~32 之间的立即数
- X:表示操作数为任何类型立即数
通用约束
0~9 :此约束只用在 input 部分,但表示可与 output 和 input 中第 n 个操作数用相同的寄存器或内存。
占位符
序号占位符是对在 output 和 input 中的操作数,按照它们从左到右出现的次序从 0 开始编号,一直到 9。引用的格式是%0~9。
以 32 位 eax 寄存器为例,常用的部分是 eax、ax、al(高 16 位无法直接用)。%b0
是指用寄存器低 8 位,即 al;%h0
指用寄存器第 8~15 位。
gcc 还提供了一种不受个数限制的占位符,名称占位符。
使用格式:[名称]"约束名"(C变量)
,举例说明:
1 | void main() { |
操作数类型修饰符用来修饰所约束的操作数:内存、寄存器:
在 output 中:
- "="表示操作数是只写,相当于为 output 括号中的 C 变量赋值
- "+"表示操作数是可读写的,高速 gcc 所约束的寄存器或内存先被读入,再被写入。
- "&"表示此 output 中的操作数要独占所约束(分配)的寄存器,只供 output 使用,任何 input 中所分配的寄存器不能使用。
在 input 中:
- "%"表示该操作数可以和下一个输入操作数互换。
clobber/modify 使用
用双引号把寄存器名称引进来即可,多个寄存器之间用逗号分隔。
如果修改了标志寄存器 eflags 中的标志位,需要在 clobber/modify 中用“cc”声明,如果修改了内存,需要用“memory”声明。用“memory”声明还可以清除寄存器缓存。
6.4.5 扩展内联汇编之机器模式简介
机器模式用来在机器层面上指定数据的大小及格式。
机器模式名称的结构大致是:数据大小+数据类型+mode,比如 QImode,表示 QuarterInteger,即四分之一整型。例如:
我们初步了解 h、b、w、k 这几个操作码就够了
b
-输出寄存器中低部分 1 字节对应的名称,如 al 、bl、cl 、dih
-输出寄存器高位部分中的那一字节对应的寄存器名称,如 ah、 bh、 ch、dhw
-输出寄存器中大小为 2 个宇节对应的部分,如 ax 、 bx、 ex 、 dxk
-输出寄存器的四字节部分,如 eax 、ebx、ecx、edx 。