系统软件启动过程

实验一:系统软件启动过程

参考

重要文件

调用顺序

  1. boot/bootasm.S  | bootasm.asm(修改了名字,以便于彩色显示)a. 开启A20   16位地址线 实现 20位地址访问  芯片版本兼容通过写 键盘控制器804264h端口 与 60h端口。b. 加载GDT全局描述符 lgdt gdtdescc. 使能和进入保护模式 置位 cr0寄存器的 PE位 (内存分段访问) PE+PG(分页机制)movl %cr0, %eax orl $CR0_PE_ON, %eax  或操作,置位 PE位 movl %eax, %cr0d. 调用载入系统的函数 call bootmain  # 转而调用 bootmain.c 2. boot/bootmain.c -> bootmain 函数a. 调用readseg函数从ELFHDR处读取8个扇区大小的 os 数据。b. 将输入读入 到 内存中以 进程(程序)块 proghdr 的方式存储c. 跳到ucore操作系统在内存中的入口位置(kern/init.c中的kern_init函数的起始地址)3. kern/init.ca. 初始化终端 cons_init(); init the console   kernel/driver/consore.c显示器初始化       cga_init();    串口初始化         serial_init(); keyboard键盘初始化 kbd_init();b. 打印内核信息 & 欢迎信息 print_kerninfo();          //  内核信息  kernel/debug/kdebug.ccprintf("%s\n\n", message);// 欢迎信息 const char *message = “qwert”c. 显示堆栈中的多层函数调用关系 切换到保护模式,启用分段机制grade_backtrace();d. 初始化物理内存管理pmm_init();        // init physical memory management   kernel/mm/ppm.c--->gdt_init();    // 初始化默认的全局描述符表e. 初始化中断控制器,pic_init();        // 初始化 8259A 中断控制器   kernel/driver/picirq.cf. 设置中断描述符表idt_init();        // kernel/trap/trap.c // __vectors[] 来对应中断描述符表中的256个中断符  tools/vector.c中g. 初始化时钟中断,使能整个系统的中断机制  8253定时器 clock_init();      // 10ms 时钟中断(1s中断100次)   kernel/driver/clock.c----> pic_enable(IRQ_TIMER);// 使能定时器中断 h. 使能整个系统的中断机制 enable irq interruptintr_enable();     // kernel/driver/intr.c// sti();          // set interrupt // x86.hi. lab1_switch_test();// 用户切换函数 会 触发中断用户切换中断4. kernel/trap/trap.c trap中断(陷阱)处理函数trap() ---> trap_dispatch()   // kernel/trap/trap.c a. 10ms 时钟中断处理 case IRQ_TIMER:if((ticks++)%100==0) print_ticks();//向终端打印时间信息(1s打印一次)b. 串口1 中断    case IRQ_COM1: 获取串口字符后打印c. 键盘中断      case IRQ_KBD: 获取键盘字符后打印d. 用户切换中断

实验目的:

操作系统是一个软件,也需要通过某种机制加载并运行它。
在这里我们将通过另外一个更加简单的软件-bootloader来完成这些工作。
为此,我们需要完成一个能够切换到x86的保护模式并显示字符的bootloader,为启动操作系统ucore做准备。
lab1提供了一个非常小的bootloader和ucore OS,
整个bootloader执行代码小于512个字节,这样才能放到硬盘的主引导扇区中。
通过分析和实现这个bootloader和ucore OS,读者可以了解到:

1. 计算机原理

    CPU的编址与寻址: 基于分段机制的内存管理CPU的中断机制外设:串口/并口/CGA,时钟,硬盘

2. Bootloader软件

    编译运行bootloader的过程调试bootloader的方法PC启动bootloader的过程ELF执行文件的格式和加载外设访问:读硬盘,在CGA上显示字符串

3. ucore OS软件

    编译运行ucore OS的过程ucore OS的启动过程调试ucore OS的方法函数调用关系:在汇编级了解函数调用栈的结构和处理过程中断管理:与软件相关的中断处理外设管理:时钟

实验内容:

lab1中包含一个bootloader和一个OS。
这个bootloader可以切换到X86保护模式,能够读磁盘并加载ELF执行文件格式,并显示字符。
而这lab1中的OS只是一个可以处理时钟中断和显示字符的幼儿园级别OS。

项目组成

lab1的整体目录结构如下所示:
>>> tree.├── bin  // =======编译后生成======================================│   ├── bootblock  // 是引导区│   ├── kernel     // 是操作系统内核│   ├── sign       // 用于生成一个符合规范的硬盘主引导扇区│   └── ucore.img // ucore.img 通过dd指令,将上面我们生成的 bootblock 和 kernel 的ELF文件拷贝到ucore.img├── boot // =======bootloader 代码=================================│   ├── asm.h      // 是bootasm.S汇编文件所需要的头文件, 是一些与X86保护模式的段访问方式相关的宏定义.│   ├── bootasm.S // 0. 定义了最先执行的函数start,部分初始化,从实模式切换到保护模式,调用bootmain.c中的bootmain函数│   └── bootmain.c // 1. 实现了bootmain函数, 通过屏幕、串口和并口显示字符串,加载ucore操作系统到内存,然后跳转到ucore的入口处执行.|                  // 生成 bootblock.out |                  // 由 sign.c 在最后添加 0x55AA之后生成 规范的 512字节的├── kern  // =======ucore系统部分===================================│   ├── debug// 内核调试部分 ==================================================│   │   ├── assert.h   // 保证宏 assert宏,在发现错误后调用 内核监视器kernel monitor│   │   ├── kdebug.c  // 提供源码和二进制对应关系的查询功能,用于显示调用栈关系。│   │   ├── kdebug.h   // 其中补全print_stackframe函数是需要完成的练习。其他实现部分不必深究。│   │   ├── kmonitor.c // 实现提供动态分析命令的kernel monitor,便于在ucore出现bug或问题后,│   │   ├── kmonitor.h // 能够进入kernel monitor中,查看当前调用关系。│   │   ├── panic.c    // 内核错误(Kernel panic)是指操作系统在监测到内部的致命错误,│   │   └── stab.h│   ├── driver //驱动==========================================================│   │   ├── clock.c    // 实现了对时钟控制器8253的初始化操作 系统时钟 │   │   ├── clock.h   │   │   ├── console.c  // 实现了对串口和键盘的中断方式的处理操作 串口命令行终端│   │   ├── console.h│   │   ├── intr.c     // 实现了通过设置CPU的eflags来屏蔽和使能中断的函数│   │   ├── intr.h│   │   ├── kbdreg.h   // │   │   ├── picirq.c   // 实现了对中断控制器8259A的初始化和使能操作   │   │   └── picirq.h│   ├── init // 系统初始化======================================================│   │   └── init.c       // ucore操作系统的初始化启动代码│   ├── libs│   │   ├── readline.c│   │   └── stdio.c│   ├── mm // 内存管理 Memory management========================================│   │   ├── memlayout.h  // 操作系统有关段管理(段描述符编号、段号等)的一些宏定义│   │   ├── mmu.h        // 内存管理单元硬件 Memory Management Unit 将线性地址映射为物理地址,包括EFLAGS寄存器等段定义│   │   ├── pmm.c     // 设定了ucore操作系统在段机制中要用到的全局变量│   │   └── pmm.h        // 任务状态段ts,全局描述符表 gdt[],加载gdt的函数lgdt, 初始化函数gdt_init│   └── trap // 陷阱trap 异常exception 中断interrupt 中断处理部分=================│       ├── trap.c       // 紧接着第二步初步处理后,继续完成具体的各种中断处理操作;│       ├── trapentry.S  // 紧接着第一步初步处理后,进一步完成第二步初步处理;|       |                // 并且有恢复中断上下文的处理,即中断处理完毕后的返回准备工作;│       ├── trap.h       // 紧接着第二步初步处理后,继续完成具体的各种中断处理操作;│       └── vectors.S    // 包括256个中断服务例程的入口地址和第一步初步处理实现。|                        // 此文件是由tools/vector.c在编译ucore期间动态生成的├── libs // 公共库部分===========================================================│   ├── defs.h           // 包含一些无符号整型的缩写定义│   ├── elf.h│   ├── error.h│   ├── printfmt.c│   ├── stdarg.h     // argument 参数│   ├── stdio.h          // 标志输入输出 io│   ├── string.c│   ├── string.h│   └── x86.h            // 一些用GNU C嵌入式汇编实现的C函数├── Makefile             // 指导make完成整个软件项目的编译,清除等工作。└── tools // 工具部分============================================================├── function.mk      // mk模块 指导make完成整个软件项目的编译,清除等工作。├── gdbinit       // gnu debugger 调试├── grade.sh├── kernel.ld├── sign.c           // 一个C语言小程序,是辅助工具,用于生成一个符合规范的硬盘主引导扇区。|                    // 规范的硬盘主引导扇区大小为512字节,结束符为0x55AA|                    // obj/bootblock.out( <= 500 )  +  0x55AA -> bootblock(512字节)└── vector.c         // 生成vectors.S 中断服务例程的入口地址和第一步初步处理实现

cpu mmu 内存 关系

页地址映射

中断异常陷阱

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

编译

cd lab1/
make # 变量定义 ( = or := )
# 其中 = 和 := 的区别在于, 
# := 只能使用前面定义好的变量, = 可以使用后面定义的变量
# +=变量追加值 SRCS += programD.c   
# +=变量追加值  
# 命令前缀 
# 前缀 @   :: 只输出命令执行的结果, 出错的话停止执行
# 前缀 -   :: 命令执行有错的话, 忽略错误, 继续执行

makefile文件见

直接在硬件模拟器上运行

硬件模拟器qemu的安装

make qemu

1. 从CPU加电后执行的第一条指令开始,单步跟踪BIOS的执行

单步调试和查看BIOS代码如果你是想看BIOS的汇编,可试试如下方法: 练习2可以单步跟踪,方法如下:1. 修改 lab1/tools/gdbinit,set architecture i8086target remote :12342. 在 lab1目录下,执行make debug这时gdb停在BIOS的第一条指令处:
0xffff0: ljmp $0xf000,$0xe05b3 在看到gdb的调试界面(gdb)后,执行如下命令,就可以看到BIOS在执行了
si
si
...
4 此时的CS=0xf000, EIP=0xfff0,如果想看BIOS的代码x/2i 0xffff0应该可以看到0xffff0: ljmp $0xf000,$0xe05b0xffff5: xor %dh,0x322f
进一步可以执行x/10i 0xfe05b
可以看到后续的BIOS代码。首先在CPU加电之后,CPU里面的ROM存储器会将其里面保存的初始值传给各个寄存器,
其中CS:IP = 0Xf000 : fff0(CS:代码段寄存器;IP:指令寄存器),
这个值决定了我们从内存中读数据的位置,PC = 16*CS + IP。

此时系统处于实模式,并且截止到目前为止系统的总线还不是我们平常的32位,
这时的地址总线只有20位,所以地址空间的总大小只有1M,
而我们的BIOS启动固件就在这个1M的空间里面。BIOS启动固件需要提供以下的一些功能:☆基本输入输出的程序☆系统设置信息☆开机后自检程序☆系统自启动程序在此我们需要找到CPU加电之后的第一条指令的位置,然后在这里break,单步跟踪BIOS的执行,
根据PC = 16*CS + IP,我们可以得到PC = 0xffff0,
所以BIOS的第一条指令的位置为0xffff0(在这里因为此时我们的地址空间只有20位,所以是0xffff0)。在这里我们利用 make debug来观察BIOS的单步执行,
所以我们首先通过Makefile文件来查看make debug的相关操作:#  Makefile 
# 利用make debug来观察BIOS的单步执行
# 首先是对qemu进行的操作
# sleep 2 等待一段时间
# 针对gdbinit文件进行的调试
debug: $(UCOREIMG)$(V)$(QEMU) -S -s -parallel stdio -hda $< -serial null &$(V)sleep 2$(V)$(TERMINAL) -e "gdb -q -tui -x tools/gdbinit"# tools/gdbinit   
file bin/kernel
target remote :1234
break kern_init
continue可以看到,电脑在运行到kern_init是会触发break,然后又紧接着在下一步continue,

执行调试

这里需要安装 cgdb:
sudo apt-get install cgdb

开始调试:

make debug会出现一个新的终端,分为上下两个窗口,上面的窗口显示运行到的源码,下面的窗口是gdb调试界面。
由上面的分析可知:BIOS的第一条指令的位置为0xffff0

**查看 pc指针地址 对应的指令 前强制反汇编当前的指令 **

x/i $pc    显示1行
x/10i $pc 显示10行

我们查看 0xffff0地址内的信息:

  x/i 0xffff0>>> 0xffff0:     ljmp   $0x3630,$0xf000e05b可以看到,BIOS的第一条指令是一条跳转指令 ljmp,然后程序会跳转到0xf000e05b,开始进行一系列的操作。在截图中我们看到pc:0xfff0,这是因为在x86的机器里面并没有pc这个寄存器,所谓的pc值是通过CS:IP而得到的,因此这里的PC所代表的是eip寄存器里面的值,低 16位的值。

设置断点:

 在gdb命令行中,使用b *[地址]便可以在指定内存地址设置断点,当qemu中的cpu执行到指定地址时,便会将控制权交给gdb。n/s都是C语言级的断点定位。 s会进入C函数内部,但是不会进入没有定位信息的函数(比如没有加-g编译的代码,因为其没有C代码的行数标记,没办法定位),n不会。   
ni/si都是汇编级别的断点定位。si会进入汇编和C函数内部,ni不会
归纳:当要进入没有调试信息的库函数调试的时候,用si是唯一的方法。      当进入有调试信息的函数,用si和s都可以,但是他们不同,si是定位到汇编级别的第一个语句,但是s是进入到C级别的第一个语句.

gdb的单步命令:

next 单步到程序源代码的下一行,不进入函数。
nexti 单步一条机器指令,不进入函数。
step 单步到下一个不同的源代码行(包括进入函数)。
stepi 单步一条机器指令。

2. bootloader进入保护模式的过程

我们最重要的是要理解三个问题:
1、为何要开启A20,以及如何开启A20;
2、如何初始化GDT表;
3、如何使能和进入保护模式。

1、为何要开启A20,以及如何开启A20

首先关于A20,我们通过查询资料以及说明文档可以知道早期的8086CPU所提供的地址线只有20位,
所以可寻址空间为0~2^20(1MB),但是8086的数据处理位宽16位,无法直接访问1M的地址空间,
所以8086提供了段地址加偏移地址的转换机制。
PC的寻址结构是segment:offset,segment和offset都是16位寄存器,
最大值是0ffffh,所以换算成物理地址的计算方法是把segment左移4位,再加上offset,
所以segment:offset所能表示的最大为10ffefh,而这个地址超过了1M,
但是超过1M会发生“回卷”的现象不会报错,
但是从下一代的80286开始,地址线成为了24位,所能访问的地址空间超过了1M,
此时寻址超过1M时会报错,出现了向下不兼容,所以为了解决这个问题采用了A20机制。A20 Gate,将A20地址线控制器 和 键盘控制器的一个输出进行AND操作,
这样来控制A20地址线的打开与关闭,所以在实模式下,需要确保A20开关处于关闭状态,这样可以防止访问大于1M的地址空间,但
是在保护模式下,我们需要访问更大的内存空间,所以需要将A20的开关打开,如果在保护模式下,A20的开关未打开的话,此时我们只能访问奇数兆的内存,
即只能访问0—1M,2—3M,4—5M……,所以如果我们要进入保护模式,首先就需要把A20开关给打开。

2、如何初始化GDT表

接下来我们需要了解下GDT表(全局描述符表),在整个操作系统中我们只有一张GDT表,
GDT可以放在内存的任意位置,但是CPU必须知道GDT的入口,
在Intel里面有一个专门的寄存器GDTR用来存放GDT的入口地址,
程序员将GDT设定在内存的某个位置之后,
可以通过LGDT指令将GDT的入口地址加载到该寄存器里面,
以后CPU就可以通过GDTR来访问GDT了。

3、如何使能和进入保护模式

最后我们需要了解如何 使能 和 进入 保护模式,
关于这一点我们需要了解一个寄存器CR0,首先我们来看下CR0寄存器的各个位代表什么:

在这里由于我们需要进入保护模式,所以暂时可以先不用管其他的位,只需关注最低位的PE即可,
PE是启用保护位(protection enable),当设置该位的时候即开启了保护模式,
系统上电复位的时候该位默认为0,于是便是实模式;
当PE置1的时候,进入保护模式,实质上是开启了段级保护,只是进行了分段,没有开启分页机制,
如果要开启分页机制的话我们需要同时置位PE和PG。有了初步了解之后我们便知道的开启保护模式的相关操作,
首先开启A20 Gate,其次加载全局描述符表GDT,最后只需要将CR0寄存器的最低位置为1即可。接下来我们通过观察代码来查看UCore具体是如何实现相应的操作的:
    # Enable A20:#  For backwards compatibility with the earliest PCs, physical#  address line 20 is tied low, so that addresses higher than#  1MB wrap around to zero by default. This code undoes this.
seta20.1:inb $0x64, %al    # Wait for not busy(8042 input buffer empty).testb $0x2, %al   # 等待键盘控制器8042 0x64 端口 空闲,64h端口中的状态寄存器的值为0x2jnz seta20.1      # 忙的话一直等待movb $0xd1, %al  # 等到64h空闲之后我们会写入0xd1 0xd1 -> port 0x64outb %al, $0x64  # 表明我们要向60h里面写入数据, 0xd1 means: write data to 8042's P2 portseta20.2:inb $0x64, %al   # 等待64h端口空闲 Wait for not busy(8042 input buffer empty).testb $0x2, %al  # 64h端口中的状态寄存器的值为0x2jnz seta20.2     # 忙的话一直等待movb $0xdf, %al  # 0xdf -> port 0x60 等到空闲之后,我们将0xdf写入60h端口,至此来打开A20开关。outb %al, $0x60  # 0xdf = 11011111, means set P2's A20 bit(the 1 bit) to 1 (第20位)
首先是开启A20,根据上文我们知道需要将第20位为1即可,
但是我们需要知道在UCore里是如何将A20置为1的。
根据说明书我们可以知道,A20地址线由键盘控制器8042进行控制,
我们的A20所对应的是8042里面的P21引脚,所以问题就变成了我们需要将P21引脚置1。对于8042芯片来说,有两个端口地址60h和64h。
对于这两个端口来说,
0x64用来发送一个键盘控制命令,
0x60用来传递参数,所以将P21引脚置1的操作就变成了,
我们首先利用0x64端口传递一个写入的指令,然后由0x60端口读进去相应的参数来将P21置1。
由以下的资料我们可以知道,
我们首先要先向 64h 发送 0xd1 的指令,写之前需要等待键盘控制器8042空闲,
可以通过判断 64h端口中的状态寄存器的值0x2,来判断是否空闲。
然后向 60h 发送 0xdf 的指令。

在这里可能有人会有疑问,既然我们只需要将P21置为1就可以了,
那么我们是不是可以传入多种不同的参数,只需要对应的位为1就好了,答案是不行的。我们传入的0xdf参数在这里也相当于一条指令,通过这条指令我们可以将A20的开关打开。
在这里我们还需要注意一个问题就是当前端口(60h或者64h)是否空闲,
只有当这两个端口空闲的时候我们才可以向其传入数据。

boot/bootasm.S

#include <asm.h># Start the CPU: switch to 32-bit protected mode, jump into C.
# The BIOS loads this code from the first sector of the hard disk into
# memory at physical address 0x7c00 and starts executing in real mode
# with %cs=0 %ip=7c00..set PROT_MODE_CSEG,        0x8                     # kernel code segment selector
.set PROT_MODE_DSEG,        0x10                    # kernel data segment selector
.set CR0_PE_ON,             0x1                     # protected mode enable flag# start address should be 0:7c00, in real mode, the beginning address of the running bootloader
.globl start
start:
.code16                                             # Assemble for 16-bit modecli                                             # Disable interruptscld                                             # String operations increment# 初始化 Set up the important data segment registers (DS, ES, SS).xorw %ax, %ax                                   # Segment number zeromovw %ax, %ds                                   # -> Data Segmentmovw %ax, %es                                   # -> Extra Segmentmovw %ax, %ss                                   # -> Stack Segment# 1 Enable A20:#  For backwards compatibility with the earliest PCs, physical#  address line 20 is tied low, so that addresses higher than#  1MB wrap around to zero by default. This code undoes this.
seta20.1:inb $0x64, %al  # Wait for not busy(8042 input buffer empty).testb $0x2, %al # 等待键盘控制器8042 0x64 端口 空闲,64h端口中的状态寄存器的值为0x2jnz seta20.1    # 忙的话一直等待movb $0xd1, %al # 等到64h空闲之后我们会写入0xd1, 0xd1 -> port 0x64outb %al, $0x64 # 表明我们要向60h里面写入数据,0xd1 means: write data to 8042's P2 portseta20.2:inb $0x64, %al  # 等待64h端口空闲, Wait for not busy(8042 input buffer empty).testb $0x2, %al # 64h端口中的状态寄存器的值为0x2jnz seta20.2    # 忙的话一直等待movb $0xdf, %al # 等到空闲之后,我们将0xdf写入60h端口,至此来打开A20开关,0xdf -> port 0x60outb %al, $0x60 # 0xdf = 11011111, means set P2's A20 bit(the 1 bit(第20位)) to 1# Switch from real to protected mode, using a bootstrap GDT# and segment translation that makes virtual addresses# identical to physical addresses, so that the# effective memory map does not change during the switch.# 2 load gdt 加载GDT全局描述符  在后面可以看到lgdt gdtdesc# 使能和进入保护模式 movl %cr0, %eax      # 首先将cr0寄存器里面的内容取出来orl $CR0_PE_ON, %eax # 进行一个或操作, PE是启用保护位(protection enable),当设置该位的时候即开启了保护模式movl %eax, %cr0      # 最后将得到的结果再写回 cr0中 # 只是进行了分段,没有开启分页机制,如果要开启分页机制的话我们需要同时置位PE和PG。# Jump to next instruction, but in 32-bit code segment.# Switches processor into 32-bit mode.# 3 最后通过一个长跳转指令正式进入保护模式。ljmp $PROT_MODE_CSEG, $protcseg.code32                                             # Assemble for 32-bit mode
protcseg:# Set up the protected-mode data segment registers  初始化保护模式的数据段寄存器movw $PROT_MODE_DSEG, %ax                       # Our data segment selectormovw %ax, %ds                                   # -> DS: Data Segmentmovw %ax, %es                                   # -> ES: Extra Segmentmovw %ax, %fs                                   # -> FSmovw %ax, %gs                                   # -> GSmovw %ax, %ss                                   # -> SS: Stack Segment# Set up the stack pointer and call into C. The stack region is from 0--start(0x7c00)movl $0x0, %ebp    # 栈底指针movl $start, %esp  # 栈顶指针call bootmain      # 调用bootmain函数在bootmain.c中,进行 加载ELF格式的操作系统OS# If bootmain returns (it shouldn't), loop.
spin:jmp spin# Bootstrap GDT
.p2align 2                                # force 4 byte alignment
gdt:                                      # GDT表的入口地址 GDT全局描述符表由三个全局描述符组成SEG_NULLASM                           # null seg 第一个均为空描述符SEG_ASM(STA_X|STA_R, 0x0, 0xffffffff) # code seg for bootloader and kernel 第二个为代码段描述符SEG_ASM(STA_W, 0x0, 0xffffffff)       # data seg for bootloader and kernel 第三个为数据段描述符# gdt全局描述符表  它里面有两个参数
gdtdesc:.word 0x17 # 表示的是GDT表的大小                 # sizeof(gdt) - 1.long gdt  # 表示的是GDT表的入口地址             # address gdt

3. 分析bootloader加载ELF格式的OS的过程。

上面 boot/bootasm.S 的末尾,切换到保护模式,初始化一些段寄存器后,
会调用bootmain函数在bootmain.c中 ,进行 加载ELF格式的操作系统OS。

ELF文件格式

读取elf

背景知识

对于硬盘来说,我们知道是分成许多扇区的其中每个扇区的大小为512字节。
读取扇区的流程我们通过查询指导书可以看到:1、等待磁盘准备好;2、发出读取扇区的命令;3、等待磁盘准备好;4、把磁盘扇区数据读到指定内存。   接下来我们需要了解下如何具体的从硬盘读取数据,
因为我们所要读取的操作系统文件是存在0号硬盘上的,
所以,我们来看一下关于0号硬盘的I/O端口:

在这里我们可以看到,对于0号硬盘的读取操作是通过一系列的寄存器完成的,
所以在读取硬盘时我们也是通过对这些硬盘进行操作从而得到相应的数据。
通过上面对硬盘知识的一些了解之后,
我们开始观察具体的实现过程:
#include <defs.h>
#include <x86.h>
#include <elf.h>// elf文件格式定义
/*
通过bootmain函数从硬盘中 加载elf格式的 操作系统os 到内存中使用程序块方式存储1. 将一些OS的ELF文件从硬盘中读到内存的ELFHDR里面 格式在elf.h中定义2. 在加载操作开始之前我们需要对ELFHDR进行判断,观察是否是一个合法的ELF头3. 通过循环读取每个段,并且将每个段读入相应的虚存p_va 程序块中4. 最后调用ELF header表头中的内核入口地址, 实现 内核链接地址 转化为 加载地址,无返回值。*/
#define SECTSIZE        512   // 一个扇区的大小
#define ELFHDR          ((struct elfhdr *)0x10000)// scratch space 虚拟地址va(virtual address)/* 等待磁盘准备好
waitdisk - wait for disk ready */
static void
waitdisk(void) {while ((inb(0x1F7) & 0xC0) != 0x40)//判断磁盘寄存器的状态为标志
// 0x1F7 0号硬盘 读时状态寄存器
// 0xC0 = 0x11000000 最高两位为1
// 0x40 = 0x01000000 01表示空闲
// 检查0x1F7的最高两位,如果是01,那么证明磁盘准备就绪,跳出循环,否则继续等待。/* do nothing */;
}/* 读取一整块扇区
readsect - read a single sector at @secno into @dst */
static void
readsect(void *dst, uint32_t secno) {// wait for disk to be readywaitdisk();// 1、等待磁盘准备好outb(0x1F2, 1);           // 设置需要读取得参数 扇区计数 count = 1outb(0x1F3, secno & 0xFF);// 即读取相应的内容到寄存器里面,outb(0x1F4, (secno >> 8) & 0xFF);outb(0x1F5, (secno >> 16) & 0xFF);outb(0x1F6, ((secno >> 24) & 0xF) | 0xE0);outb(0x1F7, 0x20); // 2、发出读取磁盘命令 cmd 0x20 - read sectors// wait for disk to be readywaitdisk();// 3、等待磁盘准备好; // read a sectorinsl(0x1F0, dst, SECTSIZE / 4);// 4、把磁盘扇区数据读到指定内存。 
}/* ** readseg - read @count bytes at @offset from kernel into virtual address @va,* might copy more than asked.* 从0号硬盘上读入os文件* 第一个参数是一个虚拟地址va (virtual address),起始地址* 第二个是count(我们所要读取的数据的大小 512*8),*            SECTSIZE的定义我们通过追踪可以看到是512,即一个扇区的大小* 第三个是offset(偏移量)* */
static void
readseg(uintptr_t va, uint32_t count, uint32_t offset) {uintptr_t end_va = va + count;//结束地址// round down to sector boundaryva -= offset % SECTSIZE;// SECTSIZE=512扇区单位长度 起始地址减去偏移 块首地址// translate from bytes to sectors; kernel starts at sector 1uint32_t secno = (offset / SECTSIZE) + 1;// 存储我们需要读取的磁盘的位置// 通过一个for循环一次从磁盘中读取一个整块for (; va < end_va; va += SECTSIZE, secno ++) {// 继续对虚存va和secno进行自加操作,直到读完所需读的东西为止。readsect((void *)va, secno);// 磁盘中读取一个整块 存到相应的虚存va中}
}/* bootmain - the entry of bootloader 
bootmain 函数 加载 elf格式的os
操作系统文件是存在0号硬盘上的
读取扇区的流程我们通过查询指导书可以看到:
1、等待磁盘准备好;
2、发出读取扇区的命令;
3、等待磁盘准备好;
4、把磁盘扇区数据读到指定内存。 
*/
void
bootmain(void) {// read the 1st page off disk// 从0号硬盘上读入os文件// 第一个参数是一个虚拟地址va(virtual address),// 第二个是count(我们所要读取的数据的大小 512*8),// 第三个是offset(偏移量)// SECTSIZE的定义我们通过追踪可以看到是512,即一个扇区的大小
// 1. 将一些OS的ELF文件从硬盘中读到内存的ELFHDR里面 格式在elf.h中定义readseg((uintptr_t)ELFHDR, SECTSIZE * 8, 0);// 调用readseg函数从ELFHDR处读取8个扇区的大小。// is this a valid ELF?
// 2. 在加载操作开始之前我们需要对ELFHDR进行判断,观察是否是一个合法的ELF头if (ELFHDR->e_magic != ELF_MAGIC) {goto bad;//加载到错误得操作系统, 跳转到}struct proghdr *ph, *eph;//内存中 进程(程序)块 的存储方式, 在elf.h中定义// ph表示ELF段表首地址 ,  eph表示ELF段表末地址// load each program segment (ignores ph flags)ph = (struct proghdr *)((uintptr_t)ELFHDR + ELFHDR->e_phoff);//首地址=基地址+偏移量eph = ph + ELFHDR->e_phnum;// 末地址 = 首地址+elf大小// 3. 通过循环读取每个段,并且将每个段读入相应的虚存p_va 程序块中for (; ph < eph; ph ++) {readseg(ph->p_va & 0xFFFFFF, ph->p_memsz, ph->p_offset);}// call the entry point from the ELF header
// 4. 最后调用ELF header表头中的内核入口地址, 实现 内核链接地址 转化为 加载地址,无返回值。((void (*)(void))(ELFHDR->e_entry & 0xFFFFFF))();bad:outw(0x8A00, 0x8A00);outw(0x8A00, 0x8E00);/* do nothing */while (1);
}

4. 实现函数调用堆栈跟踪函数

我们需要实现函数调用堆栈,因此我们需要首先针对函数堆栈的操作做一些相关的了解,对于函数堆栈来说可以分为以下三部分操作:1、首先保存原相关寄存器的状态,即将相关参数以及寄存器的当前状态压入栈;2、其次在栈中进行函数操作,即完成函数的相关功能;3、最后释放栈空间,回复原寄存器状态。要实现以上的相关操作我们就需要对函数栈的结构有相关的了解:
堆栈是地址逆生长.

在每次进行函数调用的时候,
首先会将函数的参数 自右向左 压入栈中,
所以从图中我们可以看到参数的顺序是从参数3到参数1,
然后将返回地址压入栈,即下条指令的地址压入栈,
接着把原ebp的值压入栈,便于稍后的恢复。
对于上一层ebp的压入 和 返回地址 和是通过两句汇编实现的:
    pushl %ebp      # 上一层的ebp的压入movl %esp, %ebp # %ebp 指向%esp栈顶(存储的是上一层的ebp,也就是返回地址)
当我们传完参数时,我们进行push操作,将原ebp的值压入栈,
此时我们的ebp寄存器所指的位置是上一层ebp的位置,
然后通过一个movl操作将返回地址压入对应的栈,便实现了对函数栈的搭建。所以一般而言,
ss:[ebp+4]处为返回地址,
ss:[ebp+8]处为第一个参数值(最后一个入栈的参数值,此处假设其占用4字节内存),
ss:[ebp-4]处为第一个局部变量,
ss:[ebp]处为上一层ebp值。
由于ebp中的地址处总是“上一层函数调用时的ebp值”,
而在每一层函数调用中,都能通过当时的ebp值“向上(栈底方向)”能获取返回地址、参数值,
“向下(栈顶方向)”能获取函数局部变量值。最后在函数调用结束后我们只需要将ebp还原,并且跳转到返回地址即可。
接下来我们来观察具体实现的代码:我们需要在lab1中完成kernel/kdebug.c中函数print_stackframe的实现,
可以通过函数print_stackframe来跟踪函数调用堆栈中记录的返回地址。
在如果能够正确实现此函数,
可在lab1中执行 “make qemu”后,
在qemu模拟器中得到类似如下的输出:
……
ebp:0x00007b08 eip:0x001009a6 args:0x00010094 0x00000000 0x00007b38 0x00100092 kern/debug/kdebug.c:308: print_stackframe+21

kernel/kdebug.c 最后的 print_stackframe() 函数 打印函数堆栈调用信息

void
print_stackframe(void) {/* LAB1 YOUR CODE : STEP 1 *//* (1) call read_ebp() to get the value of ebp. the type is (uint32_t);* (2) call read_eip() to get the value of eip. the type is (uint32_t);* (3) from 0 .. STACKFRAME_DEPTH*    (3.1) printf value of ebp, eip*    (3.2) (uint32_t)calling arguments [0..4] = the contents in address (uint32_t)ebp +2 [0..4]*    (3.3) cprintf("\n");*    (3.4) call print_debuginfo(eip-1) to print the C calling function name and line number, etc.*    (3.5) popup a calling stackframe*           NOTICE: the calling funciton's return addr eip  = ss:[ebp+4]*                   the calling funciton's ebp = ss:[ebp]*/
// 1.首先通过两个函数得到寄存器ebp和eip的值,并存到变量里。
uint32_t ebp = read_ebp();
// 2. eip的值 存储返回地址
uint32_t eip = read_eip();
// 3. 通过一个for循环来循环输出栈内的相关参数
//for(int i = 0; ebp !=0 && i < STACKFRAME_DEPTH; i++)
int i,j;
for(i = 0; ebp !=0 && i < STACKFRAME_DEPTH; i++)
// 这里 变量定义需要在最上面, 这里在中间定义,是 c99 才支持的{// 3.1打印ebp的值cprintf("ebp:0x%08x eip:0x%08x args:", ebp, eip);// cprintf()打印 格式%08x// 3.2 打印函数参数 第一个参数存在ebp+8的位置uint32_t* args = (uint32_t*)ebp + 2;//这里的2代表两个整形数地址范围也就是2*4=8// uint32_t* args =(uint32_t*)(ebp + 8);for( j =0; j<4; j++){cprintf("0x%08x ",args[j]);//打印函数的调用参数 参数1 参数2 参数3 参数4}// 3.3 打印换行符号cprintf("\n");// 打印换行符号// (3.4) print_debuginfo(eip-1);// 3.5 原ebp的值就存在ebp的位置,eip的值存在ebp+4的位置,所以在这里通过数组的操作实现具体功能。ebp = ((uint32_t*)ebp)[0];eip = ((uint32_t*)ebp)[1];}
}

5. 完善中断初始化和处理

a. 中断描述符表

中断描述符表(也可简称为保护模式下的中断向量表)中一个表项占多少字节?其中哪几位代表中断处理代码的入口?

中断描述符表一个表项占8字节。
其中0~15位和48~63位分别为offset的低16位和高16位。
16~31位为段选择子。
通过段选择子获得段基址,加上段内偏移量即可得到中断处理代码的入口。

b. 完善kern/trap/trap.c中对中断向量表进行初始化的函数idt_init。

在idt_init函数中,依次对所有中断入口进行初始化。
使用mmu.h中的SETGATE宏,填充中断描述符表(idt, interrupt description table)数组内容。
每个中断的入口由tools/vectors.c生成,
使用trap.c中声明的 vectors数组即可。我们需要对所有的中断入口进行初始化,在这里我们首先需要对中断有一个大概的了解.
在操作系统中,有三种特殊的中断事件, 中断 (interrupt) , 异常(exception), 陷入中断(trap interrupt)。1. 由CPU外部设备引起的外部事件如I/O中断、时钟中断、控制台中断等是异步产生的(即产生的时刻不确定),与CPU的执行无关,我们称之为异步中断(asynchronous interrupt)也称外部中断,简称中断 (interrupt)。
2. 把在CPU执行指令期间检测到不正常的或非法的条件(如除零错、地址访问越界)所引起的内部事件,称作同步中断(synchronous interrupt),也称内部中断,简称异常(exception)。
3. 把在程序中使用请求系统服务的系统调用而引发的事件, 称作陷入中断(trap interrupt),也称软中断(soft interrupt),系统调用(system call)简称trap。对于中断描述符表idt来说把每个 中断或异常的编号 和 一个指向中断服务例程 的 描述符联系起来。同GDT(全局描述符表,地址映射)一样,IDT(中断描述符表)是一个8字节的描述符数组,
但IDT的第一项可以包含一个描述符。
CPU把中断(异常)号乘以8做为 IDT的索引。
IDT可以位于内存的任意位置,CPU通过IDT寄存器(IDTR)的内容来寻址IDT的起始地址。
指令LIDT和SIDT用来操作IDTR。
两条指令都有一个显示的操作数:一个6字节表示的内存地址。 根据中断的分类我们可以了解到,我们在进行初始化时是需要对 中断进行分类处理的,
针对不同的权限进行不同的初始化,因此我们在编写代码时需要注意 内核权限 和 用户权限的区分,
通过指导书我们可以了解到,对于我们的UCore来说只有从 用户态 转化为 内核态 时权限是 用户权限,
所以我们在进行初始化时只需要将这一点拿出来单独初始化即可。

kern/trap/trap.c中对中断向量表进行初始化的函数idt_init

void
idt_init(void) {
// 1. 声明__vectors[] 来对应中断描述符表中的256个中断符  tools/vector.c中extern uintptr_t __vectors[];// 代码段偏移量
// 2. 通过for循环运用SETGATE宏定义函数(类似c++ inline内连函数)  进行 中断门idt[i] 的初始化// 在kernel/mm/mmu.h中 #define SETGATE(gate, istrap, sel, off, dpl) {}int i;for(i = 0; i<sizeof(idt)/sizeof(struct gatedesc); i++){// 0 中断门 1 陷阱门  G// D_KTEXT 内核 代码段起始地址 在kernel/mm/memlayout.h中// __vectors[i] 偏移地址// DPL_KERNEL 内核权限  DPL_USER 用户权限  在kernel/mm/memlayout.h中SETGATE(idt[i], 0, GD_KTEXT, __vectors[i], DPL_KERNEL);}
// 需要对 用户态 转 内核态 的中断表进行初始化了,
// 和上面的不同之处只是在于特权值的不同,所以我们的操作如下:
// T_SWITCH_TOK 121  trap.h 中SETGATE(idt[T_SWITCH_TOK], 0, GD_KTEXT, __vectors[T_SWITCH_TOK], DPL_USER);// 3. 最后加载idt中断描述符表  libs/x86.h
// 将 &idt_pd 首地址 加载到 中断描述符表寄存器 (IDTR)lidt(&idt_pd);
}

c. 完善trap.c中的中断处理函数trap_dispatch 中关于时钟中断得处理

使操作系统每遇到100次时钟中断后,调用print_ticks子程序,向屏幕上打印一行文字”100 ticks”。
在上面我们已经将idt中断向量符表完成了初始化的操作,所以我们在这里可以直接对其进行调用即可,
在这里我们需要了解下调用中断的一个大体流程:
中断描述符表 idt 是一个表项占用8字节,
其中2-3字节是段选择子,
0-1字节和6-7字节拼成位移,
两者联合便是中断处理程序的入口地址。

我们可以看到当出发了中断之后,
我们可以通过IDTR寄存器来查找到相应的中断号,
我们可以
通过 IDT.base + 8*n 可以找到相应中断的地址,然
后跳转到具体中断的执行程序中就可以完成中断处理。
所以在第三问我们需要调用时钟中断,并且完成对于时钟中断的相关操作。

trap_dispatch()

#define TICK_NUM 100 // 每隔多长时间打印时间信息// 打印 时钟信息
static void print_ticks() {cprintf("hello linux, times %d ticks\n",TICK_NUM);
#ifdef DEBUG_GRADEcprintf("End of Test.\n");panic("EOT: kernel seems ok.");
#endif
}// 各种中断响应处理
static void
trap_dispatch(struct trapframe *tf) {char c;switch (tf->tf_trapno) {case IRQ_OFFSET + IRQ_TIMER:/* LAB1 YOUR CODE : STEP 3 *//* handle the timer interrupt *//* (1) After a timer interrupt, you should record this event using a global variable (increase it), such as ticks in kern/driver/clock.c* (2) Every TICK_NUM cycle, you can print some info using a funciton, such as print_ticks().* (3) Too Simple? Yes, I think so!*/// 1.记录时钟中断ticks++; // 定义在 kern/driver/clock.c 中// 2. 判断 ticks的状态, 执行相应的操作if(ticks% TICK_NUM == 0)// TICK_NUM 本文件最上面 为100 {// 每当ticks计数达到100时,即出发了100次时钟中断后,时钟中断会print“100 ticks”。print_ticks();//向终端打印时间信息  }break;case IRQ_OFFSET + IRQ_COM1:// 串口1 中断c = cons_getc();cprintf("serial [%03d] %c\n", c, c);break;case IRQ_OFFSET + IRQ_KBD://键盘中断c = cons_getc();cprintf("kbd [%03d] %c\n", c, c);cprintf("keyboard interrupt\n");break;//LAB1 CHALLENGE 1 : YOUR CODE you should modify below codes.case T_SWITCH_TOU://用户切换 用户到内核panic("T_SWITCH_** ??\n");break;case T_SWITCH_TOK:panic("T_SWITCH_** ??\n");break;case IRQ_OFFSET + IRQ_IDE1:case IRQ_OFFSET + IRQ_IDE2:/* do nothing */break;default:// in kernel, it must be a mistakeif ((tf->tf_cs & 3) == 0) {print_trapframe(tf);panic("unexpected trap in kernel.\n");}}
}

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/75304.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

Selenium自动化测试框架常见异常分析及解决方法

01 pycharm中导入selenium报错 现象: pycharm中输入from selenium import webdriver, selenium标红 原因1: pycharm使用的虚拟环境中没有安装selenium, 解决方法: 在pycharm中通过设置或terminal面板重新安装selenium 原因2: 当前项目下有selenium.py,和系统包名冲突导致, …

Amazon Aurora MySQL 和 Amazon RDS for MySQL 集群故障转移和只读实例扩容时间测试

01 测试背景 Amazon Aurora MySQL 是与 MySQL 兼容的关系数据库&#xff0c;专为云而打造&#xff0c;性能和可用性与商用数据库相当&#xff0c;成本只有其 1/10。 Amazon RDS for MySQL 让您能够在云中更轻松设置、操作和扩展 MySQL 部署。借助 Amazon RDS&#xff0c;您可以…

SpringBoot环境MongoDB分页+去重+获取去重后的原始数据

最近有个比较复杂的MongoDB查询需求&#xff0c; 要求1&#xff1a;获取最近订单表中的请求参数信息&#xff0c;并需要按照请求参数中的账号进行去重 要求2&#xff1a;数据量可能比较大&#xff0c;因此需要做分页查询 研究了大半天&#xff0c;终于搞出了解决方案&#xff0…

MySQL触发器详解保证入土

文章目录 简介一、MySQL触发器基础触发器分类基础常用关键字1. 定义触发器2. 创建和删除触发器3. 执行时机和条件 二、MySQL触发器的使用场景1. 数据完整性约束插入触发器更新触发器删除触发器 2. 数据变更日志的记录与追踪3. 触发器与存储过程的对比与选择 三、触发器的性能和…

C++学习笔记(重载、类)

C 1、函数重载2、类2.1、类的方法和属性2.2、类的方法的定义2.3、构造器和析构器2.4、基类与子类2.5、类的public、protected、private继承2.6、类的方法的重载2.7、子类方法的覆盖2.8、继承中的构造函数和析构函数 1、函数重载 函数重载大概可以理解为&#xff0c;定义两个名…

C语言实现三字棋

实现以下&#xff1a; 1游戏不退出&#xff0c;继续玩下一把&#xff08;循环&#xff09; 2应用多文件的形式完成 test.c. --测试游戏 game.c -游戏函数的实现 game.h -游戏函数的声明 (2)游戏再走的过程中要进行数据的存储&#xff0c;可以使用3*3的二维数组 char bor…

idea VCS配置多个远程仓库

Idea VCS配置多个远程仓库 首先要有两个或多个不同远程仓库地址 idea 添加数据源 查看推送记录 添加数据源 ok之后填写账号密码 推送本地项目 选择不同远程地址 push 查看不同远程地址的 不同分支的 推送记录 不期而遇的温柔&#xff1a; 应用开源架构进行项目开发&#xff0…

Java版企业电子招标采购系统源码—企业战略布局下的采购寻源

功能模块&#xff1a; 待办消息&#xff0c;招标公告&#xff0c;中标公告&#xff0c;信息发布 描述&#xff1a; 全过程数字化采购管理&#xff0c;打造从供应商管理到采购招投标、采购合同、采购执行的全过程数字化管理。通供应商门户具备内外协同的能力&#xff0c;为外部供…

Qt串口基本设置与协议收发

前言 1.一直都想要做一个Qt上位机&#xff0c;趁着这个周末有时间&#xff0c;动手写一下 2.comboBox没有点击的信号&#xff0c;所以做了一个触发的功能 3.Qt的数据类型很奇怪&#xff0c;转来转去的我也搞得很迷糊 4.给自己挖个坑&#xff0c;下一期做一个查看波形的上位…

Android 9.0 网络之netd详解

一、DHCP流程 分析netd之前先了解一下网络自动获取IP流程&#xff0c;借鉴下图流程查看代码&#xff1a; &#xff08;1&#xff09;WIFI扫描到可用网络后进行连接&#xff0c;代码路径&#xff1a;\frameworks\opt\net\wifi\service\java\com\android\server\wifi\WifiStateMa…

OJ练习第167题——单词接龙

单词接龙 力扣链接&#xff1a;127. 单词接龙 题目描述 字典 wordList 中从单词 beginWord 和 endWord 的 转换序列 是一个按下述规格形成的序列 beginWord -> s1 -> s2 -> … -> sk&#xff1a; 每一对相邻的单词只差一个字母。 对于 1 < i < k 时&…

Vue2+Vue3笔记(尚硅谷张天禹老师)day03

声明:只是记录&#xff0c;初心是为了让页面更好看,会有错误,我并不是一个会记录的人&#xff0c;所以有点杂乱无章的感觉&#xff0c;我先花点时间把视频迅速过掉&#xff0c;再来整理这些杂乱无章的内容 计划是一天更新一点 待做的东西 完成博客记录把完成后的博客记录拆成单…

计算机网络的故事——简单的HTTP协议

简单的HTTP协议 文章目录 简单的HTTP协议一、简单的HTTP协议 一、简单的HTTP协议 HTTP是不保存状态的协议&#xff0c;为了实现保存状态功能于是引入了Cookie技术。 method: get:获取资源 post:传输实体主体 put:传输文件 head:获取报文首部&#xff0c;用于确认URI的有效性以…

把文件上传到Gitee的详细步骤

目录 第一步&#xff1a;创建一个空仓库 第二步&#xff1a;找到你想上传的文件所在的地址&#xff0c;打开命令窗口&#xff0c;git init 第三步&#xff1a;git add 想上传的文件 &#xff0c;git commit -m "给这次提交取个名字" 第四步&#xff1a;和咱们在第…

从0开始的ios自动化测试

最近由于工作内容调整&#xff0c;需要开始弄ios自动化了。网上信息有点杂乱&#xff0c;这边我就按我的实际情况&#xff0c;顺便记录下来&#xff0c;看是否能帮到有需要的人。 环境准备 安装tidevice pip3 install -U “tidevice[openssl]”它的作用是&#xff0c;帮你绕…

C++ 多态语法点

前置知识点 成员变量和成员函数分开存储&#xff0c;只有非静态成员变量才属于类的对象上。 静态成员变量和静态成员函数没有在类上存储。 非静态成员函数也不属于类的对象上 class Animal {public:virtual void speak(){cout<<"动物在说话"<<endl;}}v…

注解-宋红康

目录 一、注解&#xff08;Annotation&#xff09;概述二、常见的注解实例三、如何自定义注解四、JDK中的四个元注解五、Java8注解的新特性1、可重复注解2、类型注解 一、注解&#xff08;Annotation&#xff09;概述 二、常见的注解实例 三、如何自定义注解 自定义注解必须配…

虚拟化和容器

文章目录 1 介绍1.1 简介1.2 虚拟化工作原理1.3 两大核心组件&#xff1a;QEMU、KVMQEMUKVM 1.4 发展历史1.5 虚拟化类型1.6 云计算与虚拟化1.7 HypervisorHypervisor分为两大类 1.8 虚拟化 VS 容器 2 虚拟化应用dockerdocker 与虚拟机的区别 K8Swine 参考 1 介绍 1.1 简介 虚…

springBoot对接Apache POI 实现excel下载和上传

搭建springboot项目 此处可以参考 搭建最简单的SpringBoot项目_Steven-Russell的博客-CSDN博客 配置Apache POI 依赖 <dependency><groupId>org.apache.poi</groupId><artifactId>poi</artifactId><version>5.2.2</version> </…

FastChat工作原理解析

在了解FastChat如何完成大模型部署前&#xff0c;先了解下Huggingface提供的Transformer库。Hugggingface提供的Transformer库 Hugging Face 的 Transformers 库是一个用于自然语言处理&#xff08;NLP&#xff09;任务的 Python 库&#xff0c;旨在简化和加速使用预训练语言模…