一个简单的多任务内核实例
多任务程序结构和工作原理
本文给出的内核文件由两个文件构成。一个使用as86语言编制而成的引导启动程序boot.s,用于在计算机上电时从启动盘上把内核代码加载到内存中;另一个是使用GNU as 汇编语言编制的内核程序head.s,其中实现两个运行在特权等级 3 上的任务在时钟中断控制下相互切换运行,并且还实现了在屏幕上显示字符的一个系统调用。
两个任务分别为任务 A 和任务 B,它们会调用这个显示系统调用在屏幕上显示字符 A 和 B,直到每 10 毫秒切换到另外一个任务。任务 A 则显示字符 A, 任务 B 则显示字符 B。若要终止这个内核程序,则需要重新启动机器,或关闭模拟 pc 运行的软件。
boot.s 编译出来的程序工 512 个字节,将被存放到软盘映像文件的第一个扇区中,如下图所示。pc 机在上电启动时,ROM BIOS中的程序会将启动盘上第一个扇区的数据加载到内存 0x7c00(31KB)位置开始处,并把执行权限转移到0x7c00处开始执行此 boot 程序。
boot 程序的主要功能是把软盘或映像文件中的head内核代码加载到内存的某个指定位置处,并设置好临时 GDT 表等信息后,把处理器到运行保护模式下,然后跳转到 head 代码处去运行内核代码。实际上,boot 程序首先利用 ROM BIOS 中断 int 0x13 把软盘中的 head 代码读入到内存 0x10000(64KB)位置开始处,然后再把这段 head代码移动到内存 0 开始处(此处为何不直接移动到内存 0 开始处? 因为 ROM BIOS 的中断描述表 IDT 在内存 0 开始处,且内存 1KB 开始处是 BIOS 程序使用的数据区,若直接拷贝到内存 0 开始处,则会覆盖 BIOS 的中断描述表,导致 BIOS 中断 int 0x13 无法调用,从而无法完成 head 代码从软盘到内存的拷贝。
)。最后设置控制寄存器 CR0 中的开启保护模式标志位,并跳转到内存 0 处开始执行 head 代码。boot 程序在内存中移动 head 代码的示意图如下:
把 head 内核代码移动到内存 0 开始处的主要原因是为了设置 GDT 表时可以简单一些,因而也能让 head 程序尽量简短一些(因为 GDT 表的设置是在 head 内完成。
)。
head 程序是运行在 32 位保护模式下,其中主要包括初始设置的代码、时钟中断 int 0x08 的过程代码、系统调用中断 int 0x80 的过程代码以及任务 A 和 任务 B 等的代码和数据。其中初始设置工作包括:1、重新设置 GDT 表;2、设置系统定时器芯片;3、设置 IDT 表并设置时钟和系统调用中断门;4、移动到任务 A 中执行。
在虚拟地址空间中 head 程序的内核代码和任务代码分布如下图所示。实际上,本内核示例中所有程序的代码段和数据段都对应到物理内存同一区域上,即从物理内存 0 开始的区域。GDT 中全局代码段和数据段描述符的内容都设置位:基地址 0x0000;段限长值为 0x07FF。因为颗粒度位 1,所以实际长度位 8MB。而全局显示数据段被设置成:基地址为0xb8000;段限长值为 0x0002,所以实际段长度为 8KB,对应到显示内存区域上。
两个任务在 LDT 中代码段和数据段描述符的内容也都设置为:基地址为 0x0000;段限长值为 0x03ff,实际长度为 4MB。因此在线性地址空间中这个“内核”的代码和数据段都从线性地址 0 开始并由于没有采用分页机制,所以它们都直接对应物理地址 0 开始处。在 head 程序编译出的目标文件中以及最终得到的软盘映像文件中,代码和数据的组织形式见下图所示。
由于处于特权级 0 的代码不能直接把控制权转移到特权等级 3 的代码中执行,但中断返回操作是可以的,因此当初始化 GDT、IDT 和定时芯片结束后,我们就利用中断返回指令 IRET 来启动运行第一个任务。具体实现方法是在初始堆栈 init_stack 中人工设置一个返回环境。即把任务 0 的 TSS 选择符加载到任务寄存器 LTR 中,LDT 段选择符加载到 LDTR 中以后,把任务 0 的用户栈指针(0x17:init_stack)和代码制作(0x0f:task0)以及标志寄存器值压入栈中,然后执行中断返回指令 IRET。该指令会弹出堆栈上的堆栈指针作为任务 0 的用户栈指针,恢复假设的任务 0 的标志寄存器的内容,并弹出栈中代码指针放入 CS:EIP 寄存器中,从而开始执行任务 0 的代码,完成了特权等级 0 到特权等级 3 代码的控制转移。
为了每隔 10ms 切换运行任务,head 程序中把定时芯片 8235 的通道 0 设置成每经过 10ms 就向中断控制芯片 82589A 发送一个时钟中断请求信号。PC 机的 ROM BIOS 开机时已经在8259A中把时钟中断请求信号设置为中断向量 8,因此我们需要在中断 8 处理过程中执行任务切换操作。实现任务切换。
引导程序 boot.s
其代码如下:
//首先利用bios中断把内核代码(head代码)加载到0x10000处,然后移动
//到内存0处。!最后进入保护模式,并跳转到内存0开始处继续执行
BOOTSEG = 0x07c0 !引导扇区被BIOS加载到内存的0x7c00处
SYSSEG = 0x1000 !内核(head)先加载到0x10000,然后移动到0x0处
SYSLEN = 17 !内核占用的最大磁盘扇区数
entry start
start:
jmpi go, #BOOTSEG !段间跳转至0x7c0:go处,当本程序刚刚运行时,所有的段寄存器值均为0
go1: mov ax, csmov ds, axmov es, axmov [msg + 17], ah mov cx, #20mov dx, #0x1004mov bx, #0x000cmov bp, #msgmov ax, #0x1301int 0x10
loop1: jmp loop1
msg: .ascii "loading system ...".byte 13, 10
`
go: mov ax, cs !该跳转语句会把cs寄存器加载为0x7c0(原来为1),让ds与ss都指向0x7co段mov ds, axmov ss, axmov sp, #0x400 !设置临时栈指针,其值大于程序末端有一定空间即可!加载内核代码到内存0x10000开始处
!利用bios中断int 0x13功能2从启动盘中读取head代码。 DH--磁头号, DL-驱动器号,
!CH-10位磁道号低8位, CL-位6,7是磁道号高两位, 位5-0起始扇区号(从1计)
!ES:BX-读入缓冲区位置(0x1000:0x0000),
!AH-读扇区功能号, AL-需都的扇区数(17)
load_system:mov dx,#0x0000 !磁头号 与 驱动器号 均为0mov cx,#0x0002 !磁道号为0 扇区号为2(1号扇区是该程序)mov ax,#SYSSEG mov es,axxor bx,bxmov ax,#0x200 + SYSLENint 0x13jnc ok_load !若没有发生错误,则跳转到ok_load处继续执行, 否则死循环
die: jmp go1!把内核代码移动到内存0开始处, 共移动8KB字节(内核长度不超过8KB)
ok_load:cli !关闭中断mov ax, #SYSSEGmov ds, ax !移动开始位置DS:SI = 0x1000:0 目前位置ES:DI=0:0xor ax, axmov es, axmov cx, #0x2000 !设置共移动4K,每次移动一个字(word)sub si, sisub di, direp movw !执行重复移动指令!加载IDT 和 GDT基地址寄存器 IDTR和GDTRmov ax, #BOOTSEGmov ds, ax !让DS重新指向0x7c0d段lidt idt_48 !加载IDTR, 6字节操作数, 2字节表示长度, 4字节表示线性基地址lgdt gdt_48 !加载GDTR,6字节操作数, 2字节表示长度, 4字节表示线性基地址!设置控制寄存器CR0(即机器状态字),进入保护模式。段选择符值8对应GDT表中第2个段描述符mov ax, #0x0001 !在CR0中设置保护模式位PE(位0)lmsw ax !然后跳转至选择符值指定的段中, 偏移0处jmpi 0, 8 !注意此时段值已是段选择符, 该段的线性基地址是0!下面是全局描述符表GDT的内容,其中包括3个段描述符, 第一个不可用,另外2个代码和数据描述符
gdt: .word 0, 0, 0, 0.word 0x07FF !段描述符1, 8Mb--段限长值2047().word 0x0000.word 0x9A00.word 0x00C0 .word 0x07FF.word 0x0000.word 0x9200.word 0x00C0
!下面分别是LIDT与LGDT指令的6字节操作数
idt_48: .word 0.word 0,0 !此处复用bios的中断处理程序
gdt_48: .word 0x7ff.word 0x7c00 + gdt, 0.org 510.word 0xAA55 !引导扇区有效标志,必须处于引导扇区的最后2个字节处
内核程序 head.s
#head.s 包含32位保护模式初始化设置代码,时钟中断代码,系统调用中断代码和两个任务的代码
#在初始化完成后程序移动到任务0开始执行,并在时钟中断控制下进行任务0和任务1之间切换操作
LATCH = 11930 #定时器初始计数值,即每个10ms发送一次中断请求
SCRN_SEL = 0x18 #屏幕显示内存段选择符
TSS0_SEL = 0x20 #任务0的TSS段选择符
LDT0_SEL = 0x28 #任务0的LDT段选择符
TSS1_SEL = 0x30 #任务1的TSS段选择符
LDT1_SEL = 0x38 #任务1的LDT段选择符.text
.globl idt,gdt
startup_32:
#首先加载数据段寄存器DS、堆栈段寄存器SS和堆栈指针ESP。所有段的线性基地址都是0movl $0x10, %eax #0x10是GDT中数据段选择符mov %ax, %dslss init_stack, %esp# setup base fields of descriptors.call setup_idtcall setup_gdtmovl $0x10,%eax # reload all the segment registersmov %ax,%ds # after changing gdt. mov %ax,%esmov %ax,%fsmov %ax,%gslss init_stack,%esp# setup up timer 8253 chip.movb $0x36, %almovl $0x43, %edxoutb %al, %dxmovl $11930, %eax # timer frequency 100 HZ movl $0x40, %edxoutb %al, %dxmovb %ah, %aloutb %al, %dx# setup timer & system call interrupt descriptors.movl $0x00080000, %eax movw $timer_interrupt, %axmovw $0x8E00, %dxmovl $0x08, %ecx # The PC default timer int.lea idt(,%ecx,8), %esimovl %eax,(%esi) movl %edx,4(%esi)movw $system_interrupt, %axmovw $0xef00, %dxmovl $0x80, %ecxlea idt(,%ecx,8), %esimovl %eax,(%esi) movl %edx,4(%esi)# unmask the timer interrupt.
# movl $0x21, %edx
# inb %dx, %al
# andb $0xfe, %al
# outb %al, %dx# Move to user mode (task 0)pushflandl $0xffffbfff, (%esp)popflmovl $TSS0_SEL, %eaxltr %axmovl $LDT0_SEL, %eaxlldt %ax movl $0, currentstipushl $0x17pushl $init_stackpushflpushl $0x0fpushl $task0iret/****************************************/
setup_gdt:lgdt lgdt_opcode #使用6字节操作数lgdt_opcode设置GDT表位置和长度ret#这段代码暂时设置IDT表中所有256个中断门描述符都为同一个默认值,均使用默认中断处理程序
#ignore_int。 设置的具体方法是:首先eax和edx寄存器对中分别设置好默认中断门描述符的0-3字节
#和4-7字节的内容,然后利用该寄存器循环往IDT表中填入默认中断门描述符内容
setup_idt: #把所有256个中断描述符设置为默认处理过程lea ignore_int, %edx #设置方法与设置定时中断门描述符的方法一样movl $0x00080000, %eax #选择符为0x0008movw %dx, %axmovw $0x8E00, %dx #中断门类型, 特权等级为0lea idt, %edimov $256, %ecx #循环设置所有256个门描述符项
rp_idt: movl %eax, (%edi)movl %edx, 4(%edi)addl $8, %edidec %ecxjne rp_idtlidt lidt_opcode ret#显示字符子程序,取当前光标位置并把 AL 中的字符显示在屏幕上,整屏可显示80 X 25 个字符
write_char:push %gs #首先保存要用到的寄存器, EAX 由调用者保存pushl %ebxmov $SCRN_SEL, %ebx #然后让GS指向显示内存段(0xb8000)mov %bx, %gsmovl scr_loc, %ebx #再从变量 scr_loc中去目前字符位置shl $1, %ebx #因为在屏幕上每个字符还有一个属性字节,因此字符movb %al, %gs:(%ebx) #实际显示位置对应的显示内存偏移地址要乘以2shr $1, %ebx #把字符放到显示内存后把位置除以 2 加 1,此时位置值对应incl %ebx #下一个显示位置,如果该位置大于2000 则复位成0cmpl $2000, %ebxjb 1fmovl $0, %ebx
1: movl %ebx, scr_loc #最后把这个值保存起来popl %ebxpop %gsret#以下是3个中断处理程序, 默认中断, 定时中断和系统调用中断
#ignore_int是默认中断处理程序, 托系统产生其他中断,则会在屏幕上显示一个字符C
.align 4
ignore_int:push %dspushl %eaxmovl $0x10, %eax #首先让DS指向内核数据段,因为中断程序属于内核mov %ax, %dsmovl $67, %eax #在AL中放入字符c的代码, 调用显示程序显示在屏幕call write_charpopl %eaxpop %dsiret#这是定时中断处理程序, 其中主要执行任务切换操作
.align 4
timer_interrupt:push %dspushl %eaxmovl $0x10, %eax #首先让Ds指向内核数据段mov %ax, %dsmovb $0x20, %al #然后立刻允许其他硬件中断,即向8259A发送E01命令outb %al, $0x20movl $1, %eax #接着去判断当前任务,若是任务1则去执行任务0,或反之cmpl %eax, currentje 1fmovl %eax, current #若当前任务是0,则把 1 存入current,并跳转到任务1ljmp $TSS1_SEL, $0 #去执行,注意跳转的偏移值无用,但需要写上jmp 2f
1: movl $0, current #若当前任务是1,则把0存入current,并跳转到任务0ljmp $TSS0_SEL, $0 #去执行
2: popl %eaxpop %dsiret#系统调用中断int 0x80 处理程序,该示例只有一个显示字符功能
.align 4
system_interrupt:push %dspushl %edxpushl %ecxpushl %ebxpushl %eaxmovl $0x10, %edx #首先让DS指向内核数据段mov %dx, %dscall write_char #然后调用显示字符子程序write_char,显示AL中的字符popl %eaxpopl %ebxpopl %ecxpopl %edxpop %dsiret//--------------------------------------------//
current:.long 0 #当前任务号(0或1)
scr_loc:.long 0 #屏幕当前显示位置, 按从左上角到右下角顺序显示.align 4
lidt_opcode:.word 256*8 - 1 #加载IDTR寄存器的6字节操作数, 表长度(0x7FF)和基地址.long idtlgdt_opcode:.word (end_gdt - gdt) - 1 #加载GDTR寄存器的6字节操作数, 表长度和基地址.long gdt.align 8
idt: .fill 256, 8, 0 #IDT空间。 共256个门描述符, 每个8字节, 共占用2KB。gdt: .quad 0x0000000000000000 #GDT表, 第1个描述符不可用.quad 0x00c09a00000007ff #第2个是内核代码段描述符。其选择符是0x08.quad 0x00c09200000007ff #第3个是内核数据段描述符。其选择符是0x10.quad 0x00c0920b80000002 #第4个是显示内存段描述符。 其选择符是0x18.word 0x68, tss0, 0xe900, 0x00 #第5个是TSS0段的描述符, 其选择符是0x20.word 0x40, ldt0, 0xe200, 0x00 #第6个是LDT0段的描述符, 其选择符是0x28.word 0x68, tss1, 0xe900, 0x00 #第7个是TSS1段的描述符, 其选择符是0x30.word 0x40, ldt1, 0xe200, 0x00 #第8个是LDT1段的描述符, 其选择符是0x38
end_gdt:.fill 128, 4, 0 #初始化内核栈空间init_stack: #刚进入保护模式时用于加载SS:ESP堆栈指针值.long init_stack #堆栈偏移地址.word 0x10 #堆栈段同内核数据段#下面是任务0的LDT表段中的局部段描述符
.align 8
ldt0: .quad 0x0000000000000000 #第1个描述符,不用.quad 0x00c0fa00000003ff #第2个局部代码段描述符, 对应选择符是0x0f.quad 0x00c0f200000003ff #第3个局部数据段描述符, 对应选择符是0x17#下面是任务0的TSS表段的内容。注意其中标号等字段在任务切换时不会改变
tss0: .long 0 #back link.long krn_stk0, 0x10 #esp0, ss0.long 0, 0, 0, 0, 0 #esp1, ss1, esp2, ss2, cr3.long 0, 0, 0, 0, 0 #eip, eflags, eax, ecx, edx.long 0, 0, 0, 0, 0 #ebx esp, ebp, esi, edi.long 0, 0, 0, 0, 0, 0 #es, cs, ss, ds, fs, gs.long LDT0_SEL, 0x8000000 #ldt, trace bitmap.fill 128, 4, 0 #这是任务0的内核栈空间
krn_stk0:#下面是任务1的LDT表段内容和TSS段内容
.align 8
ldt1: .quad 0x0000000000000000 #第1个描述符,不用.quad 0x00c0fa00000003ff #选择符0x0f 基地址=0x00000.quad 0x00c0f200000003ff #选择符是0x17,基地址为0x00000tss1: .long 0 #back link.long krn_stk1, 0x10 # esp0, ss0.long 0, 0, 0, 0, 0 # esp1, ss1, esp2, ss2, cr3.long task1, 0x200 # eip eflags.long 0, 0, 0, 0 #eax, ecx, edx, ebx.long usr_stk1, 0, 0, 0 #esp, ebp, esi, edi .long 0x17, 0x0f, 0x17, 0x17, 0x17, 0x17 #es, cs, ss, ds, gs.long LDT1_SEL, 0x8000000 #ldt, trace bitmap.fill 128, 4, 0 #这是任务1的内核栈空间,其用户栈直接用初始栈空间
krn_stk1:#下面是任务0和任务1的程序, 他们分别循环显示A和B
task0: movl $0x17, %eax #首先让DS指向任务的局部数据段movw %ax, %ds #因为任务没有使用局部数据, 所以这两句可以省略mov $65, %al #把需要显示的字符A放入AL寄存器中int $0x80 #执行系统调用,显示字符movl $0xfff, %ecx #执行循环起延时作用
1: loop 1bjmp task0 #跳转到任务代码开始处继续显示字符 .fill 128, 4, 0
usr_stk0:task1:mov $66, %al #把需要显示的字符B放入AL寄存器int $0x80 #执行系统调用,显示字符movl $0xfff, %ecx #延时一段时间, 并跳转到开始处继续循环显示
1: loop 1bjmp task1.fill 128, 4, 0 #这是任务1的用户栈空间
usr_stk1:
编译过程
makefile 文件:
# Makefile for the simple example kernel.
AS86 =as86 -0 -a
LD86 =ld86 -0
AS =as
LD =ld
LDFLAGS =-m elf_i386 -Ttext 0 -e startup_32 -s -x -M all: ImageImage: boot systemdd bs=32 if=boot of=Image skip=1objcopy -O binary system headcat head >> Imagedisk: Imagedd bs=8192 if=Image of=/dev/fd0sync;sync;synchead.o: head.ssystem: head.o $(LD) $(LDFLAGS) head.o -o system > System.mapboot: boot.s$(AS86) -o boot.o boot.s$(LD86) -s -o boot boot.oclean:rm -f Image System.map core boot head *.o system
bochs模拟效果
http://www.taodudu.cc/news/show-4768683.html
相关文章:
- 一、2440裸机点亮led
- 归并排序(常数空间复杂度的一个变体)
- 一站式linux0.11内核head.s代码段图表详解
- 计算机二级编程题特殊解法,2012年全国计算机二级VF笔试专家密押试卷一
- 进程管理(一)
- 内核该怎么学?Linux进程管理工作原理(代码演示)
- oracle 查找非中文,Oracle中如何判断一个字符串是否含有汉字
- linux内核进程状态,深入理解 Linux 内核学习笔记(一):进程
- 逆向基础(一)
- linux创建pc目录,在linux汇编语言中创建一个目录
- Linux内核学习笔记——内核页表隔离KPTI机制(源码分析)
- L1-050 倒数第N个字符串 (15 分)andL1-054 福到了 (15 分)
- L1-043 阅览室 (20 分)andL1-048 矩阵A乘以B (15 分)
- 一种基于计算机网络的流媒体,基于网络计算机的流媒体播放器的研究与实现.pdf...
- 内核第一阶段初始化
- 硬件知识andl linux发展历史
- C语言汇编查看笔记(一)
- Hotspot源码解析一
- LINUX内核第一霸
- 操作系统学习(一)
- andl $size-1,%ecx
- 同一个局域网下的两台电脑实现定时或者实时拷贝数据
- 学什么编程语言以后不会过时?
- 2019年就业前景最好的7大编程语言(内附python教程分享)
- 如今学什么编程语言最好?这5种招聘最多的岗位了解一下
- 开源的兰空图床LskyPro
- 微信小程序隐藏滚动条
- firefox打开不能上网怎么回事 firefox 不能上网
- 火狐浏览器模拟微信浏览器教程
- 修改Firefox浏览器 user-agent 微信浏览器UA
一个简单的多任务内核实例相关推荐
- 4.9一个简单的多任务内核实例
第四章第9节 本节描述了一个简单多任务内核的设计和实现方法,这个内核包括两个特权级3的用户任务和一个系统调用中断过程. 本节给出的内核实例由两个文件构成.一个是使用as86语言编制的引导启动程序boo ...
- 一个简单的EJB-Session Bean实例
分享一下我老师大神的人工智能教程!零基础,通俗易懂!http://blog.csdn.net/jiangjunshow 也欢迎大家转载本篇文章.分享知识,造福人民,实现我们中华民族伟大复兴! 一个简单 ...
- 一个简单的百度爬虫实例
一个简单的百度爬虫实例 最近在百度aistdio自学课程,看到一个基础课程的作业是爬取百度上<青春有你>选手信息,索性就跟着爬了一下,复习一下自己去年自学的已经忘得差不多的爬虫. 直接上代 ...
- 一个简单使用html的实例
一个简单使用html的实例 起始网页 点击上个网页的连接,跳转到 代码如下 网页1的代码 <html> <head> <title>一个实例</title&g ...
- 用javascript进行一个简单的机器学习小实例
虽然它可能不是机器学习传统选择的开发语言,但是JavaScript正在证明有能力完成这样的工作--即使它目前还不能与主要的机器学习语言Python竞争.在进一步学习之前,让我们做一下机器学习的介绍. ...
- swt包下载,swt包引入(一个简单的SWT程序实例及详解)
让我们从简单的 HelloWorld 应用程序开始. swt包下载可以在eclipse网站上下 进到下面这个地址里 http://www.eclipse.org/downloads/download. ...
- 一个简单的TTS文语转换实例
Text-to-Speech Tutorial 原文:来自SAPI5.1 文档 翻译:seasun / openpaper / google 论坛 日期:2007-10-21 本文介绍一个最基本的文语 ...
- src获取同级目录中的图片_一个简单的Python爬虫实例:百度贴吧页面下载图片
本文主要实现一个简单的爬虫,目的是从一个百度贴吧页面下载图片. 1. 概述 本文主要实现一个简单的爬虫,目的是从一个百度贴吧页面下载图片.下载图片的步骤如下: 获取网页html文本内容: 分析html ...
- 一步步学习操作系统(1)——参照ucos,在STM32上实现一个简单的多任务(“啰里啰嗦版”)...
该篇为"啰里啰嗦版",另有相应的"精简版"供参考 "不到长城非好汉:不做OS,枉为程序员" OS之于程序员,如同梵蒂冈之于天主教徒,那永远都 ...
最新文章
- os.path.dirname(path) 返回文件的绝对路径
- ps查看oracle进程数,通过ps -ef | grep oracle查出的进程,怎样对应数据库中跑的进程...
- 我就改了一行代码,为什么就全超时了?
- mybatis学习(25):分页3 多参数传递(使用map)
- CS144 lab0 笔记
- 基本数据类型____字典
- 数据库基础知识——参考数据库基本概念6版
- Scheduler:Event UID not valid(转)
- Vue生命周期和钩子函数的一些理解
- android studio 通知栏广播,Android消息推送,通知栏的显示和点击
- 姓潘取名:潘姓有气质的女孩名字
- 互联网金融的分类监管主体
- 基于stm32单片机的空气质量检测仿真(仿真+源码+全套资料)
- java.lang.ArithmeticException: Rounding necessary
- 连接Ubuntu 出现 Algorithm Negotiation failed 错误
- 2.2 数据管理 之 数据加权
- MIGO BAPI_GOODSMVT_CREATE创建及增强
- 心率检测仪的设计与实现
- 数学建模:微分方程模型—常微分方程数值解算法及 Python 实现
- Python转换PDF,Word/Excel/PPT都能转!