本篇对应书籍第六章的内容
本篇介绍了汇编语言与C语言的混合编程,并实现了打印字符的内核函数,以及简单介绍了使用AT&T语法进行内联汇编。
本篇难点:
- 内联汇编的用法(AT&T)
本篇坑点:
- 书上print.S代码中出现了错误代码导致死循环(已解决)
函数调用约定
函数调用约定,是调用函数的一套约定,体现在:
- 参数的传递方式
- 参数的传递顺序
- 寄存器环境由谁来保存
调用约定是为了解决汇编语言的问题提出的,调用约定有很多:
Windows API用的是stdcall
,这里用的是cdecl
这两者都是参数从右往左入栈,区别在于stdcall是被调用者清理栈空间,cdecl是调用者清理
关于入栈和栈空间的清理,我之前写过一篇笔记:栈帧的工作原理,有兴趣的同学可以来看看
汇编语言和 C 语言混合编程
混合编程可以分为两种:
- 单独的汇编代码文件和 C 文件,在分别编译成目标文件之后一起链接成可执行程序。
- C 语言中嵌入汇编代码,直接编译成可执行程序,也就是内联汇编。
本节的重点是第一种。
系统调用
本节以 Linux 系统的系统调用为例介绍;
Linux 系统调用的入口只有一个,即 0x80 号中断,子功能号在寄存器 eax 中单独指定。
调用“系统调用”有两种方式:
- 将系统调用指令封装为 C 库函数,通过库函数进行系统调用
- 不依赖任何库函数,直接通过汇编指令 int 与操作系统通信
当输入的参数小于等于 5 个时,Linux 用寄存器传递参数;当参数个数大于 5 个时,把参数按照顺序放入连续的内存区域,并将该区域的首地址放到 ebx 寄存器。
系统调用的过程类似函数调用的过程,不过是系统提供的函数,同样遵守函数调用约定
汇编与 C 共同协作
在汇编语言中导出符号名用 global 关键字,引用外部符号时用 extern 声明。
在 C 代码中只要将符号定义为全局便可被外部引用,引用外部符号时用 extern 声明即可。
实现自己的打印函数
这里是通过直接写显存来完成一个打印函数
显卡的端口控制
本节重点:我们主要要用到哪个端口?那个端口的地址是什么?
显卡寄存器:
显卡端口非常多,但是计算机系统提供的寄存器寻址范围很少,只有0~65535个
所以显卡硬件也使用数据结构的方式来提供寄存器的访问,如图,上面4个寄存器被分为了2组,Address 寄存器和 Data 寄存器,前者存储寄存器组的索引,后者是该索引对应的寄存器
这里主要用到的寄存器组是CRT Controller Register,这里的端口地址取决于 Miscellaneous Output Register 寄存器中的 Input/Output Address Select 字段。
默认情况下, Miscellaneous Output Register寄存器的值为0x67, 其他字段不管, 咱们只关注这最重要的I/OAS位, 其值为1。 也就是说:
- CRT controller寄存器组的Address Register的端口地址为0x3D4,Data Register的端口地址0x3D5。
- Input Status # l Register寄存器的端口地址被设置为0x3DA。
- Feature Control register寄存器的写端口是0x3DA。
由于这里涉及到的显卡操作只用到了CRT Controller Registers分组中的寄存器,其他的就不管了:
这里的 0x0e
和0x0f
存储的是光标位置的高、低8位。
实现单个字符打印
为了开发方便,需要定义一些数据类型
lib/kernel/stdint.h:
#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/kernel/print.S
打印相关的函数都在这个文件里实现,打印的处理流程:
- 备份寄存器现场。
- 获取光标坐标值,光标坐标值是下一个可打印字符的位置。
- 获取待打印的字符。
- 判断字符是否为控制字符, 若是回车符、换行符、 退格符三种控制字符之一,则进入相应的处理流程。否则, 其余字符都被粗暴地认为是可见字符, 进入输出流程处理。
- 判断是否需要滚屏。
- 更新光标坐标值, 使其指向下一个打印字符的位置。
- 恢复寄存器现场, 退出。
代码文件:
TI_GDT equ 0
RPL0 equ 0
SELECTOR_VIDEO equ (0x0003 << 3) + TI_GDT + RPL0
[bits 32]
section .text
;-------------------------------------------
; Function: put_char
;
; Description: 把栈中的1个字符写入光标所在处
;------------------------------------------
global put_char
put_char:
pushad ;备份32位寄存器环境
;保险起见,每次打印都给 gs 寄存器赋值,因为要操作硬件,这里初始化gs的时候手动把DPL置零来执行内核代码
mov ax, SELECTOR_VIDEO
mov gs, ax
; 获取当前光标位置
;--------------------
;先设置索引寄存器的值选中要用的数据寄存器,然后从相应的数据寄存器进行读写操作
;高8位
mov dx, 0x03d4 ;索引寄存器
mov al, 0x0e ;提供索引光标位置的高8位
out dx, al
mov dx, 0x03d5 ;通过读写数据端口获取/设置光标位置
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 字节,所以 +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
;-------------------
; 退格键处理
.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:
.is_carriage_return:
; \r \n 都按 \n处理,光标切换到下一行的行首
; 这里的处理是:将光标移动到当前行首
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
;滚屏处理
.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
其中打印输出的控制字符,比如回车、换行、退格等,都是需要我们手动添加处理程序的,电脑并不认识这些控制字符
lib/kernel/print.h
print.S 中的 put_char 函数对于其他程序来说,属于外部函数,都写在一个头文件里会比较方便:
#ifndef __LIB_KERNEL_PRINT_H
#define __LIB_KERNEL_PRINT_H
#include "stdint.h"
void put_char(uint8_t char_asci);
#endif
kernel/source/main.c
接下来可以来使用我们的内核文件来测试一下这个函数的功能了:
#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('3');
put_char('2');
put_char('\n');
put_char('v');
put_char('0');
put_char('\b');
put_char('\n');
put_char('1');
put_char('\n');
while(1);
return 0;
}
运行 Bochs
接下来,编译,运行,测试一下效果看看:
nasm -f elf -o lib/kernel/print.o lib/kernel/print.S && \
gcc -m32 -I lib/kernel/ -c -o kernel/main.o kernel/source/main.c && \
ld -m elf_i386 -Ttext=0xc0001500 -e main -o kernel/kernel.bin kernel/main.o lib/kernel/print.o && \
strip --remove-section=.note.gnu.property ./kernel/kernel.bin && \
dd if=kernel/kernel.bin of=hd60M.img bs=512 count=200 seek=9 conv=notrunc
为了避免链接符号出现问题导致起始虚拟地址不准确,链接参数的顺序最好是调用在前,实现在后
运行:
实现字符串打印
lib/kernel/print.S
;-------------------------------------------
; Function: put_str
;
; Description: 通过put_char来打印以0结尾的字符串
; Param:待打印的字符串
;------------------------------------------
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
lib/kernel/print.h
#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
kernel/source/main.c
#include "print.h"
int main(){
put_str("\nhello kernel\n");
while(1);
return 0;
}
运行 Bochs
还是刚才的编译、链接、写入硬盘的操作,参数不变,运行:
实现整数打印
准备一个 8 字节的缓冲区,然后将要打印的整数从右往左依次取出 4 个字节,转换成 Ascii 码,然后填充到
lib/kernel/print.S
;-------------------------------------------
; 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 位数字从高位到低位逐个处理
.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;.skip_prefix_0 ;等于0就跳转,判断下一位是否是字符0
;dec edi ;书上说:反之就恢复前面指向的下一个字符,就是转到非 0 字符的第一个
;实际上这么跳转是死循环,书上写的有问题,跳转也有问题!
jmp .put_each_num
.full0:
mov cl, '0' ;当全 0 ,只打印一个 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
lib/kernel/print.h
#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);
void put_int(uint32_t num);
#endif
kernel/source/main.c
include "print.h"
int main(){
put_str("\nhello kernel\n");
put_int(0);
put_char('\n');
put_int(0x00001500);
put_char('\n');
put_int(0x11112345);
put_char('\n');
put_int(9);
put_char('\n');
put_int(0x0);
put_char('\n');
while(1);
return 0;
}
运行 Bochs
还是之前的编译链接命令,运行:
内联汇编
另一种汇编和 C 语言混合编程的方式便是在 C 语言里面写汇编语言。
gcc 默认支持的是 AT&T 语法风格的汇编语言
AT&T 汇编语法简介
AT&T 语法风格与 Intel 对比:
AT&T 中数字被优先认为是内存地址。
AT&T 内存寻址:
segreg(段基址): base_address(offset_address, index, size)
base_address 是基地址,可以为整数、变量名,可正可负。
offset_address 是偏移地址,index 是索引值,这两个必须是 8 个通用寄存器之一。
size 是个长度,只能是 1、2、4、8。
基本内联汇编
基本内联汇编是最简单的内联形式,其格式为:
asm [volatile] ("assembly code")
asm 是必须的,表示是内联汇编;
volatile 表示让编译器不要修改我的代码 ;
assembly code 的原则:
- 指令必须用双引号引起来,无论双引号中是一条指令或多条指令。
- 一对双引号不能跨行,如果跨行需要在结尾用反斜杠 ‘\’ 转移。
- 指令之间用分号’;’、换行符’\n’或换行符加制表符’\n’’\t’分隔。
在基本内联汇编中,若要引用 C 变量,只能将它定义为全局变量。如果定义为局部变量,链接时会找不到这两个符号。
扩展内联汇编
asm [volatile] ("assembly code": output : input : clobber/modify)
-
assembly code: 用户写入得汇编指令。
-
output: 用来指定汇编代码得数据如何输出给 C 代码使用。
-
input: 用来指定 C 中数据如何输入给汇编使用。
-
clobber/modify: 汇编代码执行后会破坏一些内存或寄存器资源,通过此项通知编译器,可能造成寄存器或内存数据得破坏,这样 gcc 就知道哪些寄存器或内存需要提前保护起来。
在扩展汇编指令中,%
被用作占位符,寄存器前面要用%%
这些要求在扩展内联汇编中称为“约束”,作用就是把 C 代码中的操作数映射为汇编中使用的操作数,约束分 4 大类:
-
寄存器约束:要求 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一样外,还可以让gee安排在内存中 A:把eax和edx组合成64位整数 f:表示浮点寄存器 t:表示第1个浮点寄存器 u:表示第1个浮点寄存器
使用举例:
#include<stdio.h> int main(){ int in_a = 1, in_b = 2, out_sum; asm("addl %%ebx, %%eax":"=a"(out_sum) :"a"(in_a),"b"(in_b)); //input的“a”(in_a):表示eax = in_a //output的“=a”(out_sum):表示eax = out_sum printf("sum is %d\n",out_sumJ; }
-
内存约束:直接将 input 和 output 中的 C 变量的内存地址作为内联汇编代码的操作数,直接进行内存读写
m:表示操作数可以使用任意一种内存形式。 o:操作数为内存变量,但访问它是通过偏移量的形式访问,即包含 offset—address 的格式。
使用举例:
#include<stdio.h> int 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)); //%1:序号占位符,就是 in_b 的地址 //%b0:b表示低8位,0表示 in_a 的地址 printf("in_b now is %d\n", in_b); }
-
立即数约束:要求 gcc 直接传递立即数给代码,不通过寄存器或内存,只能作为右值,只能放在 input 中
i:表示操作数为整数立即数 F:表示操作数为浮点数立即数 I:表示操作数为0~31之间的立即数 J:表示操作数为0~63之间的立即数 N:表示操作数为0~255之间的立即数 0:表示操作数为0~32之间的立即数 X:表示操作数为任何类型立即数
-
通用约束:0~9:此约束只用在input部分, 但表示可与output和input中第n个操作数用相同的寄存器或内存。
占位符:
-
序号占位符是对在 output 和 input 中的操作数,按照它们从左到右出现的次序从 0 开始编号,一直到 9。
-
名称占位符与序号占位符不同,需要在 output 和 input 中把操作数显式地起个名字:
[名称] "约束名" (C 变量)
-
操作数类型修饰符用来修饰所约束的操作数:内存、寄存器:
- output
- =,表示操作数是只写
- +,表示操作数可读写
- &,表示此output中的操作数要独占所约束的寄存器,任何 input 中所分配的寄存器不能与之相同
- input
- %,该操作数可以和下一个操作数互换
- output
机器模式简介
操作码就是指定操作数为寄存器中的哪个部分,初步了解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。
参考资料
- 《操作系统 真象还原》
- 操作系统真相还原第六章:https://blog.csdn.net/zhwenx3/article/details/109440289