操作系统真象还原<第四部分>

(。・∀・)ノ゙嗨起来!!!

完善内核

调用约定

调用约定主要体现在以下三方面:

  1. 参数的传递方式,参数是存放在寄存器中还是栈中
  2. 参数的传递顺序,是从左到右传递还是从右到左传递
  3. 是调用者保存寄存器环境还是被调用者保存

有如下常见的调用约定,我们主要关注cdecl、stdcall、thiscall即可

image-20210122113800177

为 C 语言遵循的调用约定是 cdecl 。stdcall与之对比。

stdcall 调用约定

stdcall 的调用约定意味着

(1)调用者将所有参数从右向左入栈。

(2)被调用者清理参数所占的栈空间。

例子

1
2
3
4
5
subtract(int a, int b) { //被调用者
return a-b;
}
int sub = subtract(3,2); //主调用者
//函数 subtract 返回 a 减 b 的差,这里只要代入实参

在 stdcall 调用约定下,这个 c 代码被编译后的汇编语句是:

主调用者:

1
2
3
4
; 从右到左将参数入栈
push 2 ;压入参数 b
push 3 ;压入参数 a
call subtract ;调用函数 subtract

被调用者:

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

当执行流进入到 subtract 后,在它的内部为了用 ebp 作为基址引用栈中参数,先执行了 push ebp 来备份 ebp,再将栈指针赋给了 ebp。

image-20210123103020130

stdcall 是被调用者负责清理栈空间,这里的被调用者是函数 subtract。

也就是说,subtract 需要在返回前或返回时完成。在返回前清理栈相对困难一些,清理栈是指将栈顶回退到参数之前。因为返回地址在参数之下,ret 指令执行时必须保证当前栈顶是返回地址。所以通常在返回时“顺便”完成。于是 ret 指令便有了这样的变体。

其格式为:

1
ret 16 位立即数

stdcall 是调用者在栈中压入参数,由被调用者回收栈空间。貌似分工很明确,配合很默契。因为被调用者知道自己需要几个参数,所以知道要回收多少栈空间。

cdecl 调用约定

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

cdecl 的调用约定意味着

(1)调用者将所有参数从右向左入栈。

(2)调用者清理参数所占的栈空间。

很容易发现它和 stdcall 一样都是从右向左将参数入栈的,区别就是 cdecl 由调用者清理栈空间。

例子同上

1
2
3
4
5
subtract(int a, int b) { //被调用者
return a-b;
}
int sub = subtract(3,2); //主调用者
//函数 subtract 返回 a 减 b 的差,这里只要代入实参

主调用者:

1
2
3
4
5
; 从右到左将参数入栈
push 2 ;压入参数 b
push 3 ;压入参数 a
call subtract ;调用函数 subtract
add esp, 8 ;回收(清理)栈空间

被调用者:

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

和 stdcall 相比,在 cdecl 调用约定下生成的汇编代码,就是在被调用者中的回收栈空间操作挪到了主调用者中,

在主调用者代码中的第 4 行,通过将 esp 加上 8 字节的方式回收了参数 a 和参数 b,本例中的其他代码都和 stdcall 一样。

系统调用

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

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

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

为了更加理解系统调用,在后面会更频繁的结合C和汇编进行操作,下面做一个实验,分别用三种方式调用write函数,模拟下面C调用库函数的过程。

1
2
3
4
5
#include<unistd.h>
int main(){
write(1,"hello,world\n",4);
return 0;
}

为了使用 c 标准库中的 write 函数,文件开头包含了标准头文件 unistd.h,通过该函数可以使用系统的 write 系统调用,该文件在磁盘上的路径是/usr/include/unistd.h。

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

(1)将系统调用指令封装为 c 库函数,通过库函数进行系统调用,操作简单。

(2)不依赖任何库函数,直接通过汇编指令 int 与操作系统通信。

这里介绍第二种

eax 寄存器用来存储子功能号(寄存器 eip、ebp、esp 是不能使用的)。5 个参数存放在以下寄存器中,

传送参数的顺序如下。

(1)ebx 存储第 1 个参数。

(2)ecx 存储第 2 个参数。

(3)edx 存储第 3 个参数。

(4)esi 存储第 4 个参数。

(5)edi 存储第 5 个参数。

模拟代码syscall_write.S如下

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
section .data
str_c_lib: db "C library says: hello world!", 0xa ; 0xa为换行符
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:
; ssize_t write(int fd,const void *buf,size_t count);
; 方法一:模拟C语言中系统调用库函数write
push str_c_lib_len
push str_c_lib
push 1

call my_write
add esp, 12

; 方法二:系统调用
mov eax, 4 ; 系统调用号
mov ebx, 1 ; fd
mov ecx, str_syscall ; buf
mov edx, str_syscall_len ; count
int 0x80

; 退出程序
mov eax, 1 ; exit()
int 0x80

; 下面模拟write系统调用
my_write:
push ebp
mov ebp, esp
mov eax, 4
mov ebx, [ebp + 8] ; fd
mov ecx, [ebp + 0xc] ; buf
mov edx, [ebp + 0x10] ; count
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
./syscall_write.bin

运行结果如下

image-20210210184907607

开始第二个实验

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

调用关系如下图

image-20210207155422249

C_with_S_c.c代码

1
2
3
4
5
6
extern void asm_print(char*,int);
void c_print(char* str) {
int len=0;
while(str[len++]); // 循环求出长度len,以'\0'结尾
asm_print(str, len);
}

C_with_S_S.S代码

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
section .data
str: db "asm_print hello world!", 0xa, 0 ; 0xa为换行符,0为结束符
str_len equ $-str

section .text
extern c_print
global _start
_start:
push str
call c_print
add esp, 4

; 退出程序
mov eax, 1 ; exit()
int 0x80

; 下面模拟write系统调用
global asm_print
asm_print:
push ebp
mov ebp, esp
mov eax, 4
mov ebx, 1
mov ecx, [ebp + 8] ; str
mov edx, [ebp + 0xc] ; len
int 0x80
pop ebp
ret
1
2
3
4
nasm -f elf -o C_with_S_S.o C_with_S_S.S
gcc -m32 -c C_with_S_c.c -o C_with_S_c.o
ld -m elf_i386 C_with_S_S.o C_with_S_c.o -o a.out
./a.out

运行结果如下

image-20210207160240905

实现打印函数

显卡的端口控制

显卡的端口控制也是通过 in 和 out 指令加不同的端口号。

按照它们在图形管线(位于 CPU 和 video 之间)中的位置的顺序 ( 寄存器的目录

image-20210207161620334

前四组寄存器属于分组,它们被分成了两类寄存器,即 Address Register 和 Data Register

Address Register 作为数组的索引(下标),Data Register 作为寄存器数组中该索引对应的寄存器,它相当于所对应的寄存器的窗口,往此窗口读写的数据都作用在索引所对应的寄存器上。

CRT Controller Registers 寄存器组中的 Address Register 和 Data Register 的端口地址有些特殊,它的端口地址并不固定,具体值取决于 Miscellaneous Output Register 寄存器中的 Input/Output Address Select字段

image-20210207162912644

此寄存器各字段的英文描述

image-20210207162951870

这里 I/OAS(Input/Output Address Select)字段不仅影响 CRT Controller Registers 寄存器组的 Address Register和 Data Register的端口地址,而且还影响 Feature Control register 寄存器的写端口地址和 Input Status #1 Register 寄存器的端口地址(此寄存器只有读端口),也就是影响了表 6-2 中所有端口地址中包括 x 的寄存器。

I/OAS(Input/Output Address Select)

此位用来选择 CRT controller 寄存器组的地址,这里是指 Address Register 和 Data Register 的地址。

当此位为 0 时:

CRT controller 寄存器组的端口地址被设置为 0x3Bx,结合表 6-2,Address Register 和 Data Register 的端口地址实际值为 3B4h-3B5h。并且为了兼容 monochrome 适配器(显卡),Input Status #1 Register 寄存器的端口地址被设置为 0x3BA。

y 当此位为 1 时:

CRT controller 寄存器组的端口地址被设置为 0x3Dx,结合表 6-2,Address Register 和 Data Register 的端口地址实际值为 3D4h-3D5h。并且为了兼容 color/graphics 适配器(显卡),Input Status #1Register 寄存器的端口地址被设置为 0x3DA。

Feature Control register 寄存器的写端口也是 3xAh 的形式,该端口地址取值以同样的方式受 I/OAS 位的影响。

如果 I/OAS 位为 0,写端口地址为 3BAh。

如果 I/OAS 位为 1,写端口地址为 3DAh。

默认情况下,Miscellaneous Output Register 寄存器的值为 0x67,其他字段不管,咱们只关注这最重要的 I/OAS 位,其值为 1。也就是说:

CRT controller 寄存器组的 Address Register 的端口地址为 0x3D4,

Data Register 的端口地址 0x3D5。

Input Status #1Register 寄存器的端口地址被设置为 0x3DA。

Feature Control register 寄存器的写端口是 0x3DA。

其他分组寄存器( 目前不是很重要 )

image-20210207163822970

image-20210207163841020

image-20210207163917235

image-20210207163931628

实现单个字符打印

对于字符的打印主要是对显卡端口的操作,所以是用汇编实现,这里新键一个lib目录,里面添加一个头文件,主要申请一些数据结构信息,来自Linux源码。

stdint.h

1
2
3
4
5
6
7
8
9
10
11
12
13
#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 //!_LIB_STDINT_H_

再新建一个user目录和一个kernel目录,我们的print实现代码就在kernel目录下的print.S,这个函数比较复杂,处理流程如下

  1. 备份寄存器现场
  2. 获取光标坐标值,光标坐标值是下一个可打印字符的位置
  3. 获取待打印的字符
  4. 判断字符是否为控制字符,如回车、换行、退格符需要特殊处理
  5. 判断是否需要滚屏
  6. 更新光标坐标值,使其指向下一个打印字符的位置
  7. 恢复寄存器现场,退出

首先需要知道光标和字符的区别,它们之间没有任何关系,光标位置保存在光标寄存器中,可以手动维护,这就需要参考书中的显卡寄存器索引(P264),我们需要操作CRT控制数据寄存器中索引为0x0E的Cursor Location High Register和索引为0x0F的Cursor Location Low Register分别用来储存光标坐标的高8位和低8位。访问CRT寄存器,需要首先往端口地址为0x3D4寄存器写入索引,然后再从端口0x3D5的数据寄存器读写数据,另外一些特殊字符需要特殊处理,其中还会涉及到滚屏的处理,我们的屏幕是80*25大小的,步骤如下:

  1. 将第124行搬到023行,覆盖第0行
  2. 将24行也就是最后一行用空格覆盖,看起来像新的一行
  3. 光标移动到第24行行首

print.S

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003<<3) + TI_GDT + RPL0

[bits 32]
section .text
; ----------------- put_char -----------------
; 把栈中的一个字符写入光标所在处
; --------------------------------------------
global put_char ; 全局变量,外部可调用
put_char:
pushad ; 备份环境
; 保证gs中为正确的视频段选择子
; 为保险起见,每次打印时都为gs赋值
mov ax, SELECTOR_VIDEO ; 不能直接把立即数送入段寄存器
mov gs, ax

; 获取当前光标位置,25个字符一行,一共80行,从0行开始
; 先获得高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, 0x3d4
mov al, 0x0f
out dx, al
mov dx, 0x3d5
in al, dx
; 将16位完整的光标存入bx
mov bx, ax
; 下面这行是在栈中获取待打印的字符
mov ecx, [esp + 36] ; pushad压入4x8=32字节
; 加上主函数4字节返回地址
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 时,光标前移一位
; 末尾添加空格或空字符0
dec bx
shl bx, 1 ; 光标左移一位等于乘2
; 表示光标对应显存中的偏移字节
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),只要把光标移到行首就行了
xor dx, dx ; dx是被除数的高16位,清0
mov ax, bx ; ax是被除数的低16位
mov si, 80 ; 效访Linux中\n表示下一行的行首
div si ; 这里\n和\r都处理为下一行的行首
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=3820字节
; 一次搬4字节,共3840/4=960次
mov esi, 0xc00b80a0 ; 第一行行首
mov edi, 0xc00b8000 ; 第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, 0x3d4
mov al, 0x0f
out dx, al
mov dx, 0x03d5
mov al, bl
out dx, al
.put_char_done:
popad
ret

print.h

1
2
3
4
5
#ifndef __LIB_KERNEL_PRINT_H // 如果没有__LIB_KERNEL_PRINT_H宏则编译下面的代码
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(uint8_t char_asci); // 这里是8位无符号整型,为了和之前参数存放在cl寄存器长度吻合
#endif

main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#include "print.h"
void main(void){
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);
}

运行后的目录如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
.
├── boot
│ ├── include
│ │ └── boot.inc
│ ├── loader.bin
│ ├── loader.S
│ ├── mbr.bin
│ └── mbr.S
├── kernel
│ ├── kernel.bin
│ ├── main.c
│ └── main.o
└── lib
├── kernel
│ ├── print.h
│ └── print.S
├── stdint.h
└── user

操作

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

运行结果如下

image-20210216064159425

实现字符串的打印

put_str 函数是我们的字符串打印函数,它的原理是每次处理一个字符,循环调用 put_char 来完成字符串中全部字符的打印,

put_char函数封装起来,put_str通过put_char来打印以0字符结尾的字符串,思想就是循环打印直到0结。

print.S 增加的代码

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
; --------------------------------------------
; put_str通过put_char来打印以0字符结尾的字符串
; 输入:栈中参数为打印的字符串
; 输出:无
; --------------------------------------------
global put_str
put_str:
; 此函数用到ebx和ecx,先备份
push ebx
push ecx
xor ecx, ecx
mov ebx, [esp + 0xc] ; 栈中得到待打印字符串的地址
.goon:
mov cl, [ebx]
cmp cl, 0 ; 如果处理到了字符串尾,跳到结束处返回
jz .str_over
push ecx ; 为put_char函数传递参数
call put_char ; 循环调用put_char实现打印字符串
add esp, 4
inc ebx ; ebx指向下一个字符
jmp .goon

.str_over:
pop ecx
pop ebx
ret

print.h 需要一行声明

1
2
3
4
5
6
#ifndef __LIB_KERNEL_PRINT_H // 如果没有__LIB_KERNEL_PRINT_H宏则编译下面的代码
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(uint8_t char_asci); // 这里是8位无符号整型,为了和之前参数存放在cl寄存器长度吻合
void put_str(char* message);
#endif

main.c 对其进行调用测试

1
2
3
4
5
#include "print.h"
void main(void){
put_str("Welcome to kernel\n");
while(1);
}

操作

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

运行结果如下

image-20210216070742879

实现整数打印

封装 put_char 将数字转换成对应的字符,比如数字 9 变成字符‘9’

逐位处理,A~F 再单独处理,再增加对高位多余0的处理

print.S 增加的代码

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
64
65
66
67
68
69
;--------------------   将小端字节序的数字变成对应的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个四字节
mov edx, eax
mov edi, 7 ; 指定在put_int_buffer中初始的偏移量
mov ecx, 8 ; 32位数字中,十六进制数字的位数是8个
mov ebx, put_int_buffer

; 将32位数字按照十六进制的形式从低位到高位逐个处理
; 共处理8个十六进制数字
.16based_4bits: ; 每4位二进制是16进制数字的1位
; 遍历每一位十六进制数字
and edx, 0x0000000F ; 解析十六进制数字的每一位
; 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减1恢复指向当前字符
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

print.h需要添加 put_int 函数的定义

1
2
3
4
5
6
7
#ifndef __LIB_KERNEL_PRINT_H // 如果没有__LIB_KERNEL_PRINT_H宏则编译下面的代码
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(uint8_t char_asci); // 这里是8位无符号整型,为了和之前参数存放在cl寄存器长度吻合
void put_str(char* message); // 字符串打印,必须以\0结尾
void put_int(uint32_t num); // 以16进制的形式打印数字
#endif

main.c 对其进行调用测试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
#include "print.h" 
void main(void) {
put_str("I am kernel\n");
put_int(0);
put_char('\n');
put_int(9);
put_char('\n');
put_int(0x00021a3f);
put_char('\n');
put_int(0x12345678);
put_char('\n');
put_int(0x00000000);
while(1);
}

操作

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

运行结果如下

image-20210216084945620

综合以上 print.S

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
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
; 内核打印功能实现
TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0

section .data
put_int_buffer dd 0, 0

[bits 32]
section .text
; put_char,将栈中的一个字符写入光标所在处
global put_char
global put_str
global put_int

put_int:
pushad
mov ebp, esp
mov eax, [ebp + 4 * 9]
mov edx, eax
mov edi, 7
mov ecx, 8
mov ebx, put_int_buffer

.16based_4bits:
and edx, 0x0000000F
cmp edx, 9
jg .is_A2F
add edx, '0'
jmp .store

.is_A2F:
sub edx, 10
add edx, 'A'

.store:
mov [ebx + edi], dl
dec edi
shr eax, 4
mov edx, eax
loop .16based_4bits

.ready_print:
inc edi

.skip_prefix_0:
cmp edi, 8
je .full0

.go_on_skip:
mov cl, [put_int_buffer + edi]
inc edi
cmp cl, '0'
je .skip_prefix_0
dec edi
jmp .put_each_num

.full0:
mov cl, '0'
.put_each_num:
push ecx
call put_char
add esp, 4
inc edi
mov cl, [put_int_buffer + edi]
cmp edi, 8
jl .put_each_num
popad
ret

; 字符串打印函数,基于put_char封装
put_str:
push ebx
push ecx
xor ecx, ecx
mov ebx, [esp + 12]

.go_on:
mov cl, [ebx]
cmp cl, 0
jz .str_over
push ecx
call put_char
add esp, 4
inc ebx
jmp .go_on

.str_over:
pop ecx
pop ebx
ret

put_char:
pushad
mov ax, SELECTOR_VIDEO
mov gs, ax

; 获取当前光标位置
mov dx, 0x03d4
mov al, 0x0e
out dx, al
mov dx, 0x03d5
in al, dx
mov ah, al

mov dx, 0x03d4
mov al, 0x0f
out dx, al
mov dx, 0x03d5
in al, dx

mov bx, ax
mov ecx, [esp + 36]

cmp cl, 0xd
jz .is_carriage_return
cmp cl, 0xa
jz .is_line_feed

cmp cl, 0x8
jz .is_backspace
jmp .put_other

.is_backspace:
dec bx
shl bx, 1

mov byte [gs:bx], 0x20
inc bx
mov byte [gs:bx], 0x07
shr bx, 1
jmp .set_cursor

.put_other:
shl bx, 1
mov [gs:bx], cl
inc bx
mov byte [gs:bx], 0x07
shr bx, 1
inc bx
cmp bx, 2000
jl .set_cursor

.is_line_feed:
.is_carriage_return:
xor dx, dx
mov ax, bx
mov si, 80

div si

sub bx, dx

.is_carriage_return_end:
add bx, 80
cmp bx, 2000
.is_line_feed_end:
jl .set_cursor

.roll_screeen:
cld
mov ecx, 960

mov esi, 0xc00b80a0
mov edi, 0xc00b8000
rep movsd

mov ebx, 3840
mov ecx, 80

.cls:
mov word [gs:ebx], 0x0720
add ebx, 2
loop .cls
mov bx, 1920

.set_cursor:
mov dx, 0x03d4
mov al, 0x0e
out dx, al
mov dx, 0x03d5
mov al, bh
out dx, al

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

.put_char_done:
popad
ret

内联汇编

另一种汇编和 C 语言混合编程的方式便是在 C 语言里面写汇编语言。

gcc 默认支持的是 AT&T 语法风格的汇编语言

AT&T 汇编

AT&T 语法风格与 Intel 的对比

image-20210216091411891

AT&T 中数字被优先认为是内存地址。

AT&T 内存寻址:

1
2
segreg(段基址): base_address(offset_address, index, size)
#segreg(段基址):base_address + offset_address + index*size

base_address 是基地址,可以为整数、变量名,可正可负。

offset_address 是偏移地址,index 是索引值,这两个必须是 8 个通用寄存器之一。

size 是个长度,只能是 1、2、4、8。

Intel 的内存寻址:

1
segreg:[base+index*size+offset]

基本内联汇编

基本内联汇编是最简单的内联形式,其格式为:

1
asm [volatile] ("assembly code")

asm 是必须的,表示是内联汇编;

volatile 表示让编译器不要修改我的代码 ;

assembly code 的原则:

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

在基本内联汇编中,若要引用 C 变量,只能将它定义为全局变量。如果定义为局部变量,链接时会找不到这两个符号。

1
2
asm(“movl $9,%eax;””pushl %eax”) // 正确
asm(“movl $9,%eax””pushl %eax”) // 错误

在内联汇编中,要注意操作数的顺序是和 Intel 反着的。

inlineASM.c

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; \
mov %eax,count;\
popa \
");
}

操作

1
2
--gcc -m32 -o  inlineASM.bin inlineASM.c
--./inlineASM.bin

运行结果

image-20210216093451145

扩展内联汇编

扩展内联汇编要解决的是在同一个程序中C和汇编如何避免使用寄存器冲突

汇编执行前不知道那些寄存器C程序正在占用,所以用户在完成这步需要增加栈的压力,也会降低运行速度,因此这步由编译器来执行,汇编中提供要使用到的C程序中的变量和寄存器,编译器来提前进行保护

格式asm (volatile) ("assembly code" : output : input : clobber/modify)

  1. volatile等同于_ volatile _,和c中关键字volatile不一样:编译器不要优化代码,后面的指令保留原样 output:“操作数修饰符约束名” (C 变量名)
  2. input :“[操作数修饰符]约束名”( c 变量名)
  3. clobber/modify:输入汇编代码执行后可能破坏的寄存器或者内存,来通知编译器保护

上面对output和input的要求称为“约束”,它用来把C代码中的操作数(变量、立即数)映射为汇编中所使用的操作数(寄存器,内存地址)

约束

  1. 寄存器约束 : 要求 gcc 使用哪个寄存器
  2. 内存约束:把c变量的内存地址当作内联汇编代码的操作数, 不需要寄存器做中转,直接进行内存读写,也就是汇编代码的操作数是变量的指针
  3. 立即数约束:传值的时候不通过内存和寄存器,直接作为立即数传给汇编代码,只能作为右值,放在 input 中。
  4. 通用约束
寄存器约束
1
2
3
4
5
6
7
8
9
10
11
12
13
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一样外,还可以让gee安排在内存中
A:把eax和edx组合成64位整数
f:表示浮点寄存器
t:表示第1个浮点寄存器
u:表示第1个浮点寄存器

使用举例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//基本内联汇编
#include<stdio.h>
int in_a = 1, in_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);
}
//扩展内联汇编
#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 的格式。

下面的文件 mem.c 用约束 m 为例

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);
}

image-20210216101010581

立即数约束

要求 gcc 直接传递立即数给代码,不通过寄存器或内存,只能作为右值,只能放在 input 中

i:表示操作数为整数立即数
F:表示操作数为浮点数立即数
I:表示操作数为 0~31 之间的立即数
J:表示操作数为 0~63 之间的立即数
N:表示操作数为 0~255 之间的立即数
O:表示操作数为 0~32 之间的立即数
X:表示操作数为任何类型立即数

通用约束

0~9:此约束只用在input部分, 但表示可与 output 和 input 中第n个操作数用相同的寄存器或内存。

占位符

占位符分为序号占位符和名称占位符

产生原因:

  1. 肯定是为了方便代码编写,也容易让编译器识别
  2. 寄存器约束中有一种r约束,即让编译器自主选择寄存器来映射C代码中的变量,但编写者不知道用哪个寄存器,所以引入占位符

扩展内联汇编中的占位符要有前缀%,所以描述寄存器要用两个%(%%ebx

1
2
3
4
5
6
7
//序号占位符 支持10个操作数
asm("movb %h1, %0;"\
:"=m"(in_b)\
:"a"(in_a));
//%0指output %1指input,有多输入则记为%2,%3,%4...
//操作数默认是32位,根据指令对操作数的要求再取8位、16位等
//%和序号之间添加h表示取寄存器的低16位,添加b表示取低8位
1
2
3
4
//名称占位符	数量不受限制  规则 [名称]"约束名"(C变量)
asm("movb %[xx],%[yy];"\
:[yy]"=m"(in_b)\
:[xx]"a"(in_a));

序号占位符 是对在 output 和 input 中的操作数,按照它们从左到右出现的次序从 0 开始编号,一直到 9。

1
2
3
4
5
6
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));
//上面2行代码等价
//"=a"(out_sum)序号为 0,%0 对应的是 eax。
//"a"(in_a)序号为 1,%1 对应的是 eax。
//"b"(in_b)序号为 2,%2 对应的是 ebx。

名称占位符 需要在 output 和 input 中把操作数显式地起个名字:

1
[名称] "约束名" (C 变量)
  • 操作数类型修饰符用来修饰所约束的操作数:内存、寄存器:
    • output
      • =,表示操作数是只写
      • +,表示操作数可读写
      • &,表示此output中的操作数要独占所约束的寄存器,任何 input 中所分配的寄存器不能与之相同
    • input
      • %,该操作数可以和下一个操作数互换

reg5.c 测试名称

1
2
3
4
5
6
7
8
9
#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);
}

我们的目的是用 18 除以 3,最后打印结果是 6。

image-20210216104402368

reg6.c 测试 output 为 “+”

1
2
3
4
5
6
#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);
}

image-20210216104440975

reg7.c gcc将

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h> 
void main() {
int ret_cnt = 0, test = 0;
char* fmt = "hello,world\n"; // 共 12 个字符
asm(" pushl %1; \
call printf; \
addl $4, %%esp; \
movl $6, %2" \
:"=a"(ret_cnt) \
:"m"(fmt),"r"(test) \
);
printf("the number of bytes written is %d\n", ret_cnt);
}

image-20210216110603813

按照猜测 call 完 返回eax 为 12 然后输出12

然而在文件 reg7.c 的第 8 行,%2 被 gcc 分配为寄存器 eax 了。

导致输出结果为 6 。

reg8.c 测试 output 为“&”

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h> 
void main() {
int ret_cnt = 0, test = 0;
char* fmt = "hello,world\n"; // 共 12 个字符
asm(" pushl %1; \
call printf; \
addl $4,%%esp; \
movl $6, %2" \
:"=&a"(ret_cnt) \
:"m"(fmt),"r"(test) \
);
printf("the number of bytes written is %d\n", ret_cnt);
}

image-20210216112519086

这时输出 12 。由于 & 表示 output中的操作数要独占所约束的寄存器

reg9.c 测试 output 为 “%”

1
2
3
4
5
6
#include<stdio.h>
void main() {
int in_a = 1, sum = 0;
asm("addl %1, %0;":"=a"(sum):"%I"(2),"0"(in_a));
printf("sum is %d\n ", sum);
}

在这个输入中,还用到了修饰符’%’,这表示约束 I 对应的操作数可以和下一个输入所约束的操作数对换位置。

下一个输入是”0”(in_a),前面用了通用约束’0’,这表示,要求 gcc 把分配给 C 变量 in_a 的操作数(寄存器或内存)同序号 0 对应的汇编操作数一样,in_a 与 sum 所用的寄存器是一样的,都是 eax。( 类似于 “+ ”

c语言中的volatile作用(同扩展内联汇编中的memory)

内存约束的内存地址,编译器可以知道,但如果有内存在汇编执行过程中被修改,就需要用在clobber/modify中加入“memory”来告诉gcc了。

memeory声明的另个作用是清除寄存器缓存:
内存的访问速度比cpu中的寄存器来说是比较慢的,所以gcc为了提速,把可能常用到的变量存入寄存器中,但这就带来一个问题,编译器编译程序时不知道变量的内存是否会发生变化,也就是说在程序运行过程中变量所在的内存可能会变化,改变的时间可以在CPU的线程调度过程,地点是其他线程的代码运行中。这就导致寄存器的值是“过时”的值。

main.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(void)
{
int i;
i = 1;
i = 2;
return i;
}
//没优化情况下
mov dword ptr[ebp-4],1
mov dword ptr[ebp-4],2
mov eax,dword ptr[ebp-4] //访问内存的值
ret
//优化情况下
mov eax,2 //直接把变量的值放入寄存器中,[ebp-4]地址处的值如果变化,结果就会错误!
ret

因此volatile修饰变量,编译器就会放弃寄存器缓存的方法,采用标准寻址
memory声明告诉编译器变量所在的内存数据会改变,这样就可以从内存再读取一次新数据

机器模式简介

image-20210216125542268

操作码就是指定操作数为寄存器中的哪个部分,初步了解h、b、W、K这几个操作码就够了。

寄存器按是否可单独使用,可分成几个部分,拿eax举例:

  • 低部分的一字节:al
  • 高部分的一字节:ah
  • 两字节部分:ax
  • 四字节部分:eax

h:输出寄存器高位部分中的那一字节对应的寄存器名称,如ah、bh、ch、dh。

b:输出寄存器中低部分1字节对应的名称,如al、bl、cl、d1。

w:输出寄存器中大小为2个字节对应的部分,如ax、bx、ex、dx。

k:输出寄存器的四字节部分,如eax、ebx、ecx、edx。

Reward
  • Copyright: Copyright is owned by the author. For commercial reprints, please contact the author for authorization. For non-commercial reprints, please indicate the source.
  • © 2015-2021 John Doe
  • Powered by Hexo Theme Ayer
  • PV: UV:

请我喝杯咖啡吧~

支付宝
微信