Songqian Li's Blog

去历史上留点故事

6.1 函数调用约定简介

image.png
咱们实验使用cdecl。这里提一下stdcallcdeclstdcall的区别在于由谁来回收栈空间。
stdcall是被调用者清理参数所占的栈空间。
举例来说:

1
2
int subtract(int a, int b);
int sub = subtract(3,2);

上面的 c 代码编译后的汇编语句是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 主调用
push 2
push 3
call subtract

# 被调用者
push ebp ;压入 ebp 备份
mov ebp,esp ;将 esp 赋值给 ebp
;用 ebp 作为基址来访问校中参数
mov eax, [ebp+0x8] ;偏移 8 字节处为第 1 个参数 a
add eax, [ebp+0xc] ;偏移 Oxc 字节处是第 2 个参数 b
;参数 a 和 b 相加后存入 eax
mov esp,ebp ;为防止中间有入栈操作,用 ebp 恢复 esp
;本句在此例子中可有可无,属于通用代码
pop ebp ;将 ebp 恢复
ret 8 ;数字 8 表示返回后使 esp+8
;函数返回时由被调函数清理了栈中参数

值得关注的代码是ret 8,这里由于既要将栈顶回退到参数之前,又要保证当前栈顶是返回地址,故 ret 指令有了这样的变体:ret 16位随机数,从而允许在返回时顺便修改 esp 的值。
image.png
也就是对于ret 8来说,由于之前先执行了pop ebp,故当前 esp 指向是上图的[esp +4]位置,即当前栈顶为主调函数的返回地址,ret 指令将栈顶数据 pop 到 EIP 寄存器后,esp 自动+4,又由于有个参数 8,esp 又被+8,从而跳过参数 a 和 b,完成被调用者清理参数栈的任务。
回到cdecl,由于 C 语言遵循的是cdecl,故我们实验就遵守该规定了,这意味着:

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

还是用 subtract 函数举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 主调用
push 2
push 3
call subtract
add esp, 8 ; 回收栈空间

# 被调用者
push ebp ;压入 ebp 备份
mov ebp,esp ;将 esp 赋值给 ebp
;用 ebp 作为基址来访问校中参数
mov eax, [ebp+0x8] ;偏移 8 字节处为第 1 个参数 a
add eax, [ebp+0xc] ;偏移 Oxc 字节处是第 2 个参数 b
;参数 a 和 b 相加后存入 eax
mov esp,ebp ;为防止中间有入栈操作,用 ebp 恢复 esp
;本句在此例子中可有可无,属于通用代码
pop ebp ;将 ebp 恢复
ret

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)。
image.png
不知道系统调用的用法,可以用 man 命令来查看。
例如:man 2 write
image.png
调用“系统调用”有两种方式。

  1. 将系统调用指令封装为 c 库函数,通过库函数进行系统调用,操作简单。
  2. 不依赖任何库函数,直接通过汇编指令 int 与操作系统通信。
系统调用输入参数的传递方式

当输入的参数小于等于 5 个时,Linux 用寄存器传递参数,依次为 ebx、ecx、edx、esi、edi 中,eax 寄存器用来存储子功能号。当参数个数大于 5 时,把参数按照顺序放入连续的内存区域,并将该区域的首地址放到 ebx 寄存器。
系统调用两个方式的代码演示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
section .data
str_c_lib: db "c library says: hello world!", 0xa ;0xa为LF ASCII 码
str_c_lib_len equ $-str_c_lib
str_syscall: db "syscall says: hello world!", 0xa
str_syscall_len equ $-str_syscall

section .text
global _start
_start:
;;;;;;;;;;;;; 方式1:模拟 c 语言中系统调用库函数 write ;;;;;;;;;;;;;
push str_c_lib_len ; 按照C调用约定压入参数
push str_c_lib
push 1

call simu_write ; 调用下面定义的simu_write
add esp, 12

;;;;;;;;;;;;; 方式2:跨过库函数,直接进行系统调用 ;;;;;;;;;;;;;
mov eax, 4 ;4号子功能是write系统调用
mov ebx, 1
mov ecx, str_syscall
mov edx, str_syscall_len
int 0x80 ; 发起中断,通知Linux完成请求的功能

;;;;;;;;;;;;; 退出程序 ;;;;;;;;;;;;;
mov eax, 1 ;第1号子功能是exit
int 0x80

;;;;;; 下面自定义的simu_write用来模拟C苦衷系统调用函数write
simu_write:
push ebp
mov ebp, esp

mov eax ,4 ;4号子功能为write
mov ebx, [ebp+8] ;第1个参数
mov ecx, [ebp+12] ;第2个参数
mov edx, [ebp+16] ;第3个参数
int 0x80
pop ebp
ret

编译链接:

1
2
3
nasm -f elf -o syscall_write.o syscall_write.S
ld -m elf_i386 -o syscall_write.bin syscall_write.o
chmod +x syscall_write.bin

执行结果:
image.png

6.2.2 汇编语言和 C 语言共同协作

|
| 导出符号 | 导入符号 |
| — | — | — |
| 汇编 | 关键字global | 关键字extern |
| C 语言 | 将符号定义为全局 | 关键字extern |

举个例子:
image.png
调用过程:
image.png

6.3 实现自己的打印函数

之前我们往屏幕上输出文本时,要么利用 BIOS 中断,要么利用系统调用,这都是依赖别人的方法。本节我们要通过直接写显存来完成一个打印函数。

6.3.1 显卡的端口控制

显卡寄存器:
image.png
其中 CRT Controller Registers 寄存器组有点特殊,它的端口地址并不固定,具体值取决于Miscellaneous Output Register寄存器中的Input/Output Address Select字段。
image.png
image.png
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。
其他分组寄存器:
image.png
image.png
image.png
image.png

6.3.2 实现单个字符打印

功能类似 C 语言的 putchar 函数,暂且命名为put_char
首先参考 Linux 的/usr/include/stdint.h文件,定义一些数据类型:

1
2
3
4
5
6
7
8
9
10
11
#ifndef LIB_STDINT_H
#define LIB_STDINT_H
typedef signed char int8_t;
typedef signed short int int16_t;
typedef signed int int32_t;
typedef signed long long int int64_t;
typedef unsigned char uint8_t;
typedef unsigned short int uint16_t;
typedef unsigned int uint32_t;
typedef unsigned long long int uint64_t;
#endif

处理流程:

  1. 备份寄存器现场。
  2. 获取光标坐标值,光标坐标值是下一个可打印字符的位置。
  3. 获取待打印的字符。
  4. 判断字符是否为控制字符,若是回车符、换行符退格符三种控制字符之一,则进入相应的处理流程。否则,其余字符都被粗暴地认为是可见字符,进入输出流程处理。
  5. 判断是否需要滚屏。
  6. 更新光标坐标值,使其指向下一个打印字符的位置。
  7. 恢复寄存器现场,退出。
代码 6-1-1
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
TI_GDT  equ     0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0

[bits 32]
section .text
;---------------- put_char -------------------
; 功能描述: 把栈中的1个字符写入光标所在处
;------------------------------------------
global put_char
put_char:
pushad ;备份32位寄存器环境
;需要保证gs中为正确的视频段选择子
;保险起见,每次打印都给 gs 寄存器赋值,因为要操作硬件,这里初始化gs的时候手动把DPL置零来执行内核代码
mov ax, SELECTOR_VIDEO
mov gs, ax
;-------------------
; 获取当前光标位置
;先设置索引寄存器的值选中要用的数据寄存器,然后从相应的数据寄存器进行读写操作
;-------------------
;高8位
mov dx, 0x03d4 ;索引寄存器
mov al, 0x0e ;提供索引光标位置的高8位
out dx, al ;将al的值写入索引寄存器里
mov dx, 0x03d5 ;通过读写数据端口获取/设置光标位置
in al, dx ;将数据寄存器的值读取到al
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 字节,所以 +36

;判断是不是控制字符
cmp cl, 0x0d ;CR 是 0x0d 回车符
jz .is_carriage_return
cmp cl, 0x0a ;LF 是 0x0a 换行符
jz .is_line_feed
cmp cl, 0x8 ;BS 是 0x08 退格符
jz .is_backspace

jmp .put_other
;-------------------

代码解析:

  • 第 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
; 退格键处理
.is_backspace:
dec bx ;bx 是下一个可字符的位置,减 1 则指向当前字符
shl bx, 1 ;光标值 *2 就是光标在显存中的相对地址

mov byte [gs:bx], 0x20 ;将待删除的字节补为0,低字节是 ascii 码
inc bx
mov byte [gs:bx], 0x07 ;将待删除的字节属性设置为0x07,高字节是属性
shr bx, 1 ;还原光标值,删除掉的字符本身就是下一个可打印字符的光标位置
jmp .set_cursor

; 输入字符处理
.put_other:
shl bx, 1

mov [gs:bx], cl ;cl里存放的是待打印的 ascii 码
inc bx
mov byte [gs:bx], 0x07 ;字符属性
shr bx, 1
inc bx

cmp bx, 2000
jl .set_cursor ;若光标值小于2000,则没有写满,则设置新的光标值,反之则换行

; 换行/回车处理
.is_line_feed: ;是换行符\n
.is_carriage_return: ;是回车符\r
; 这里的处理是:将光标移动到当前行首
xor dx, dx ;dx是被除数的高 16 位
mov ax, bx ;ax是被除数的低 16 位
mov si, 80
div si ;光标位置除 80 的余数便是取整
sub bx, dx ;dx里存放的是余数

.is_carriage_return_end:
; 将光标移动到下一行的同位置
add bx, 80
cmp bx, 2000

.is_line_feed_end:
jl .set_cursor

代码解析:

  • 第 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 减去余数就是行首字符的位置。

滚屏的两种情况:

  1. 新的光标值超出了屏幕右下角最后一个字符的位置。
  2. 最后一行中任意位置有回车或换行符。

实现滚屏的方法:

  1. 使用Start Address High RegisterStart Address Low Register寄存器,他们分别设置屏幕上显示的字符的起始位置地址的高 8 位和低 8 位。屏幕从该地址起向后显示 2000 个字符。由于显存有 32KB,在 8025 模式下一屏可以显示 2000 字符,故显存中可以存放 32KB/20002B ≈ 8 屏的字符,故可以使用这种方法保存大概 8 屏左右的内容。
  2. 默认情况下上述两个寄存器的值都是 0,一直到以该地址向上偏移 3999 字节的地方。可以把屏幕固定在此,只显示当前 2000 个字符。实现这种方案的步骤:
    1. 将第 1~24 行的内容整块搬到第 0~23 行,也就是把第 0 行的数据覆盖;
    2. 再将第 24 行,也就是最后一行的字符用空格覆盖,这样它看上去是一个新的空行;
    3. 把光标移动到第 24 行行首。
代码 6-1-3
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
;滚屏处理
.roll_screen:
;先将1-24行搬运到0-23行里
cld
mov ecx, 960 ;2000-80=1920,1920*2=3840 个字节要搬运,一次搬运4字节,搬运 3840/4=960 次
mov esi, 0xc00b80a0 ;第1行行首
mov edi, 0xc00b8000 ;第0行行首
rep movsd

;将第24行填充为空白
mov ebx, 3840
mov ecx, 80

.cls:
mov word [gs:ebx], 0x0720;0x0720是黑底白字的空格
add ebx, 2
loop .cls
mov bx, 1920 ;将光标重置到24行行首

;设置光标
.set_cursor:
;先设置高8位
mov dx, 0x03d4 ;索引寄存器
mov al, 0x0e ;光标高8位
out dx, al

mov dx, 0x03d5
mov al, bh
out dx, al

;再设置低8位
mov dx, 0x03d4
mov al, 0x0f
out dx, al

mov dx, 0x03d5
mov al, bl
out dx, al

.put_char_done:
popad
ret

代码解析:

  • 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。
解决方案:

  1. 为用户进程创建一个显存段描述符,其 DPL 为 3,专门给特权 3 级的用户进程使用。
  2. 在打印函数中动动手脚,将 GS 的值改为指向目前 DPL 为 0 的显存段描述符。

分析:
打印字符这种和硬件相关的功能属于内核范畴,用户需要打印输出时应该请求内核服务,由内核帮助完成。故应该选用第 2 种方法,用户进程将来在打印输出时,是需要通过系统调用陷入内核来完成的,到时用户进程的 CPL 会由 3 变成 0,执行的是内核的挨骂,那时再将 GS 赋值为内核使用的显存段选择子即可。

测试打印函数
1
2
3
4
5
#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(uint8_t char_asci);
#endif //__LIB_KERNEL_PRINT_H
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "print.h"
int main(){
put_char('\n');
put_char('k');
put_char('e');
put_char('r');
put_char('n');
put_char('e');
put_char('l');
put_char('\n');
put_char('1');
put_char('2');
put_char('\b'); // 退格符,输出时会删除前一个字符
put_char('3');
while(1);
return 0;
}

编译链接写硬盘:

1
2
3
4
nasm -f elf -o lib/kernel/print.o lib/kernel/print.S
gcc -m32 -I lib/kernel/ -c -o kernel/main.o kernel/main.c
ld -m elf_i386 -Ttext 0xc0001500 -e main -o kernel/kernel.bin kernel/main.o lib/kernel/print.o
dd if=kernel/kernel.bin of=hd60M.img bs=512 count=200 seek=9 conv=notrunc

为了避免链接符号出现问题导致起始虚拟地址不准确,链接参数的顺序最好是调用在前,实现在后
运行效果:
image.png

6.3.3 实现字符串打印

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[bits 32]
section .text
;---------------------------------------------------------------------
; Function: put_str
; Description: 通过put_char来打印以0结尾的字符串
; In:待打印的字符串
; Out: 无
;--------------------------------------------------------------------
global put_str
put_str:
;本函数只用到了 ebx 和 ecx,所以仅备份这两个寄存器
push ebx
push ecx
xor ecx, ecx
mov ebx, [esp + 12] ;获取待打印的地址
.goon:
mov cl, [ebx] ;cl是8位,1 个字节
cmp cl, 0
jz .str_over
push ecx ;put_char 参数
call put_char
add esp, 4
inc ebx
jmp .goon
.str_over:
pop ecx
pop ebx
ret

代码就是循环调用 putchar 函数,遇到“\0”停止循环。

这里请注意,C 编译器会为字符串常量分配一块内存,在这块内存中存储字符串中各字符的 ASCII 吗,并且会在结尾后自动补充结束符’\0’,它的 ASCII 码是 0.编译器将该字符串作为参数时,传递的是字符串所在的内存起始地址,也就是说压入栈中的是存储该字符串的内存首地址,不是各个字符。

1
2
3
4
5
6
#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(uint8_t char_asci);
void put_str(char* message);
#endif //__LIB_KERNEL_PRINT_H
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include "print.h"

int main() {
put_char('\n');
put_char('k');
put_char('e');
put_char('r');
put_char('n');
put_char('e');
put_char('l');
put_char('\n');
put_char('1');
put_char('2');
put_char('\b');
put_char('3');
put_str("\nhello kernel\n");
while (1);
return 0;
}

编译链接输出同上一小节,测试结果:
image.png

6.3.4 实现整数打印

只是整数,不支持浮点数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
;---------------------------------------------------------------------
; Function: put_int
; Description: 将小端序数字变成对应的ascii后,倒置
; Param_Input:待打印的数字
; Output:打印十六进制数字到屏幕
;---------------------------------------------------------------------

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位数字中,十六进制数字的位数是8个
mov ebx, put_int_buffer

;将32位数字按照16进制的形式从低位到高位逐个处理
.based16_4bits:
and edx, 0x0000000F ;取出最后一个16进制数
cmp edx, 9 ;判断是否大于9
jg .is_A2F
add edx, '0' ;转换成ascii
jmp .store
.is_A2F:
sub edx, 10
add edx, 'A'

;数字转换成 ascii 之后,按大端顺序存储到 buffer 中
.store:
mov [ebx + edi] , dl ;加上7个字节存储,也就是存储在最前面的位置上
dec edi
shr eax, 4 ;将参数数字最后一个字符去掉
mov edx, eax
loop .based16_4bits

;此时 buffer 中是 ascii 了,打印之前把高位连续数字去掉
.ready_to_print:
inc edi ;让 edi+1 变成 0
.skip_prefix_0:
cmp edi, 8 ;若已经是第九个字符了
je .full0 ;表示全是 0

;找出连续的 0 字符,edi 作为非 0 最高位偏移
.go_on_skip:
mov cl, [put_int_buffer + edi]
cmp cl, '0'
je .ready_to_print ;等于0就跳转,判断下一位是否是字符0
jmp .put_each_num

.full0:
mov cl, '0' ;全 0 ,只打印一个 0

.put_each_num:
push ecx ; 此时cl中为可打印字符
call put_char
add esp, 4
inc edi
mov cl, [put_int_buffer + edi]
cmp edi, 8
jl .put_each_num
popad
ret

函数的功能是将 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
2
3
4
5
6
7
#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(unsigned char char_asci);
void put_str(char* message);
void put_int(unsigned int num);
#endif //__LIB_KERNEL_PRINT_H
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#include "print.h"

int main() {
put_char('\n');
put_char('k');
put_char('e');
put_char('r');
put_char('n');
put_char('e');
put_char('l');
put_char('\n');
put_char('1');
put_char('2');
put_char('\b');
put_char('3');
put_str("\nhello kernel\n");
put_int(0x00000000);
put_str("\n");
put_int(0x0000023f);
while (1);
return 0;
}

运行结果:
image.png

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。
image.png
在 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 语法)。

寻址语法举例:

  1. 直接寻址,例mov $6, 0xc00008F0
  2. 寄存器间接寻址,例mov ($eax), %ebx
  3. 寄存器相对寻址,例movb -4(%ebx),%al,指将(ebx-4)所指向的内存复制 1 字节到 al。
  4. 变址寻址,例movl %eax,base_value(%ebx,%esi,2)

6.4.3 基本内联汇编

基本内联汇编格式:asm [valatile] ("assembly code")
assembly code 规则:

  1. 指令必须用双引号引起来,无论双引号中是一条指令或多条指令。
  2. 一对双引号不能跨行,如果跨行需要在结尾用反斜杠’'转义。
  3. 指令之间用分号’;‘、换行符’\n’或换行符加制表符’\n"\t’分隔。

例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
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; \
movl %eax,count;\
popa \
");
}

在 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
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
int in_a = 1, int_b = 2, out_sum;
void main() {
asm(" pusha; \
movl in_a, %eax; \
movl in_b, %ebx; \
addl %ebx, %eax; \
movl %eax, out_sum; \
popa");
printf("sum is %d\n", out_sum);
}

在基本内联汇编中

  • 寄存器用单个%做前缀
  • 操作数是手动 movl 到寄存器中,寄存器的数据也是手动 movl 到变量中。
1
2
3
4
5
6
#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));
printf("sum is %d\n", out_sum);
}

在扩展内联汇编中

  • 寄存器前缀是两个%
  • 操作数用约束名直接从变量赋值到寄存器中,返回值也通过约束名从寄存器中赋值到变量中。
内存约束:

内存约束是要求 gcc 直接将位于 input 和 output 中的 C 变量的呢村地址作为内联汇编代码的操作数,不需要寄存器做中转,直接进行内存读写,也就是汇编代码的操作数是 C 变量的指针。

  • m 表示操作数可以使用任意一种内存形式
  • o 操作数为内存变量,但访问它是铜鼓哦偏移量的形式访问,即包含offset_address的格式

举例说明:

1
2
3
4
5
6
7
#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));
printf("in_b now is %d\n", in_b);
}

第 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
2
3
4
5
6
7
8
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 中:

  • "="表示操作数是只写,相当于为 output 括号中的 C 变量赋值
  • "+"表示操作数是可读写的,高速 gcc 所约束的寄存器或内存先被读入,再被写入。
  • "&"表示此 output 中的操作数要独占所约束(分配)的寄存器,只供 output 使用,任何 input 中所分配的寄存器不能使用。

在 input 中:

  • "%"表示该操作数可以和下一个输入操作数互换。
clobber/modify 使用

用双引号把寄存器名称引进来即可,多个寄存器之间用逗号分隔。
如果修改了标志寄存器 eflags 中的标志位,需要在 clobber/modify 中用“cc”声明,如果修改了内存,需要用“memory”声明。用“memory”声明还可以清除寄存器缓存。

6.4.5 扩展内联汇编之机器模式简介

机器模式用来在机器层面上指定数据的大小及格式。
机器模式名称的结构大致是:数据大小+数据类型+mode,比如 QImode,表示 QuarterInteger,即四分之一整型。例如:
image.png
我们初步了解 h、b、w、k 这几个操作码就够了

  • b-输出寄存器中低部分 1 字节对应的名称,如 al 、bl、cl 、di
  • h-输出寄存器高位部分中的那一字节对应的寄存器名称,如 ah、 bh、 ch、dh
  • w-输出寄存器中大小为 2 个宇节对应的部分,如 ax 、 bx、 ex 、 dx
  • k-输出寄存器的四字节部分,如 eax 、ebx、ecx、edx 。
相关文章
评论
分享
  • 《操作系统真象还原》:第八章 内存管理系统

    8.1 makefile 简介 这部分可参考阮一峰的讲解:https://www.ruanyifeng.com/blog/2015/02/make.html 8.1.1 makefile 是什么 makefile 是 Linu...

    《操作系统真象还原》:第八章 内存管理系统
  • 《操作系统真象还原》:第七章 中断

    7.1 中断是什么,为什么要有中断 运用中断能够显著提升并发,从而大幅提升效率。 7.2 操作系统是中断驱动的 略 7.3 中断分类 把中断按事件来源分类,来自 CPU 外部的中断就称为外部中断,来自 CPU 内部的中断称为内部...

    《操作系统真象还原》:第七章 中断
  • 《操作系统真象还原》:第五章 保护模式进阶——加载内核

    5.3 加载内核 5.3.1 用 C 语言写内核 第一个 C 语言代码: 1234int main(void) { while(1); return 0;} 这个内核文件什么都没做,通过while(1)这个死循...

    《操作系统真象还原》:第五章 保护模式进阶——加载内核
  • 《操作系统真象还原》:第五章 保护模式进阶——内存分页机制

    从这一刻起,我们才算开始了真正的操作系统学习之旅 5.1 获取物理内存容量 5.1.1 Linux 获取内存的方法 在 Linux 2.6 内核总是用detect_memory函数来获取内存容量的。其函数本质上是通过调用 BI...

    《操作系统真象还原》:第五章 保护模式进阶——内存分页机制
  • 《操作系统真象还原》:第四章 保护模式入门

    4.1 保护模式概述 在本章大家会见到全局描述符表、中断描述符表、各种门结构,这是 CPU 提供给应用的,咱们用好就行。 保护模式强调的是“保护”,它是在 Intel 80286 CPU 中首次出现,这是继 8086 之后,Inte...

    《操作系统真象还原》:第四章 保护模式入门
  • 《操作系统真象还原》:第三章 完善MBR——I/O接口

    3.3 让我们对显示器说点什么吧 3.3.1 CPU 如何与外设通信——IO 接口 IO 接口功能: 设置数据缓冲,解决 CPU 与外设的速度不匹配 设置信号电平转换电路 设置数据格式转换 设置时序控制电路来同步 CPU 和外部...

    《操作系统真象还原》:第三章 完善MBR——I/O接口
  • 《操作系统真象还原》:第三章 完善MBR——CPU的实模式

    针对汇编 几个知识点: 第 1 行和第 4 行的 mov 操作,机器码第 1 个宇节都是B8,而另外第 2、3 行同样是 mov 指令,机器码却有天壤之别,似乎找不到共性。原因是机器码是由很多部分组成的,比如指令前缀、主操作码字...

    《操作系统真象还原》:第三章 完善MBR——CPU的实模式
  • 《操作系统真象还原》:第二章 编写 MBR

    先了解 CPU 的两种工作模式:实模式和保护模式 实模式(英语:Real mode)是 Intel 80286 和之后的 x86 兼容 CPU 的操作模式。实模式的特性是一个 20 比特的区段存储器地址空间(意思为只有 1MB 的存...

    《操作系统真象还原》:第二章 编写 MBR
  • 《操作系统真象还原》:第一章 环境配置

    第 0 章:一些你可能正感到迷惑的问题 摘记 0.28 MBR、EBR、DBR 和 OBR 各是什么 MBR 位于整个硬盘最开始的块, EBR 位于每个子扩展分区,各子扩展分区中只有一个逻辑分区。 MBR 和 EBR 位于分...

    《操作系统真象还原》:第一章 环境配置
  • 6月阅读总结

    “零拷贝”技术 Sogou C++ Workflow:搜狗公司的 C++服务器引擎,支持 500k QPS Reducing CPU scheduler latency in Linux:CPU 调度算法 BMQ 和 CFS 的对比...

    6月阅读总结