http://www.voidcn.com/article/p-newuofyn-hp.html
从网上参考别的详细注释,自己增加了一点点, 用与自己解惑用
.code32 # 多任务内核程序 [32] 位的启动代码 # 包含32位模式下的初始化设置代码,时钟中断代码,系统调用中断代码和两个任务代码 LATCH = 11930 SCRN_SEL=0x18 # 屏幕显示内存段选择符。 # 问:以下这些选择符是怎么定的值?根据段选择符的定义:位bit[15-3]为段索引,bit2为0表示GDT,1表示LDT,bit[1:0]表示RPL。所以0x18二进制为[00011 0 00]表示GDT表中的第三个描述符。 TSS0_SEL=0X20 # 任务0的TSS段选择符。 0x20二进制为[00100 0 00],表示选择GDT表中的第四个描述符 LDT0_SEL=0X28 # 任务0的LDT段选择符。 0x28二进制为[00101 0 00],表示选择GDT表中的第五个描述符 TSS1_SEL=0X30 # 任务1的TSS段选择符。 0x30二进制为[00110 0 00],表示选择GDT表中的第六个描述符 LDT1_SEL=0X38 # 任务1的LDT段选择符。 0x38二进制为[00111 0 00],表示选择GDT表中的第七个描述符 .global startup_32 .text # 表示可执行代码段(问:实际在编译时有什么影响吗?.text,.data,.bss,用于分别定义当前代码段,数据段和未初始化数据段, # 在链接多个目标模块时,链接程序会根据它们的类别把各个目标模块中的相应段分别组合在一起。) startup_32: # 首先加载数据段寄存器DS、堆栈段寄存器SS和堆栈指针ESP。所有段的线性基地址都是0. movl $0x10, %eax # 段选择子(其实就是段寄存器里的16位那个值而已) 0x10 = 0001 0 000 (后三位表示 cpl和 T(0:gdt 1:ldt)) mov %ax, %ds # 0-1=RPL(Ring0-3的那个), 2:TL=0,在gdt里查找,1-在ldt里查找,所以这里ax应该=0010 = 2 ? 为啥是2 # 上面的赋值,感觉没啥卵用, 当然 0x10, 在下面的 gdt定义里面有, 是 可读写的数据段 lss init_stack, %esp # 理解起来就是,把init_stack地址,加载到ss:esp # lss mem, reg = mem的低字-reg, mem的高位-段,这里是 ss # 并且, 所以这里是加载了 16+32个字节一共, lss是把前4个字节加载到esp, 后2个字节加载到ss = 0x10 , 堆栈段必须可读写,所以这里用了内核空间的数据段0x10, # esp->init_stack的地址, 它的上面有 128 * 4 字节的零值,作为栈空间来使用. call setup_idt # 设置中断向量表 , 过程最后,lidt ***,会加载到 IDTR 基地址和 IDT限长的 (32 + 16) call setup_gdt # 设置全局描述符表 , 同上 movl $0x10, %eax # 重新设置GDT表后,虽然段选择符没变,但是实际的描述表位置已经改变 (感觉boot.s里的idt和gdt没啥用啊) # (setup_gdt子程序中用lgdt指令更新了GDTR寄存器中gdt表的位置和长度),所以要重新加载段寄存器 mov %ax, %ds mov %ax, %es mov %ax, %fs mov %ax, %gs lss init_stack, %esp # 重新加载ss esp # 上面所有的段寄存器, 存的都是 内核数据段选择子 0x10; (当然除了CS,这个不能直接设置的) # ################################## 设置 时钟芯片 10ms 一次中断信号 ##################################### # 设置8253定时芯片。把计数器通道0,设置成每隔10毫秒向中断控制器发送一个中断信息号 # 下面介绍一下8253定时芯片: # 计数器的工作原理是这样的:它有一个输入频率,在PC上是1193180HZ , 所以把 1193180 /100 赋值给计数器, 就相当于100HZ 其实是 11931.8,约等于了 11930 # 8253具有3个独立的计数通道,采用减1计数方式。在门控信号有效时,每输入1个计数脉冲,通道作1次计数操作。当计数脉冲是已知周期的时钟信号时,计数就成为定时。 # 方式3为:方波发生器,最适合计算机。 movb $0x36, %al # 控制字:设置通道0工作在方式3、计数器初值采用二进制。 movl $0x43, %edx # 8253芯片控制字寄存器写端口。 outb %al, %dx # 向I/O端口写入一个字节,这里是向端口0x43写入0x36 movl $LATCH, %eax # 初始计数值设置为LATCH(1193180/100),即频率100HZ movl $0x40, %edx # 通道0的端口。 outb %al, %dx # 分两次把初始计数值写入通道0. movb %ah, %al outb %al, %dx # 上面的这个, 了解下就可以,就是为了设置 8253的工作方式而已 # ####################################################################################################### # ####################################################################################### # ********** 时钟中断 # 在IDT表第8和第128(0x80)项处分别设置定时中断门描述符和系统调用陷阱门描述符。 # 这里先解释一下int $0x80: # int $0x80是一条AT&T语法的中断指令,用于Linux的系统调用。 # Linux系统下的汇编语言比较喜欢用AT&T的语法,如果翻译成Intel的语法就是int 80h,就像我们在Intel的语法下的DOS汇编中经常用的int 21h调用DOS中断,同样如果换成AT&T语法就是int $0x80。 # 不过无论使用那一种语法,int $0x80或者int 80h都是针对Linux的,在DOS或者Windows下不起相应作用。反之亦然。 movl $0x00080000, %eax # 中断程序属内核,即EAX高字是内核代码段选择符0x0008(即索引为1,TI=0,RPL=00) movw $timer_interrupt, %ax # 设置定时中断门描述符。取定时中断处理程序地址。eax低字(低16字节) eax 是 时钟中断处理程序的描述符 movw $0x8e00, %dx # 中断门类型是14(屏蔽中断),特权级0或硬件使用。 书上写的是 32为中断门 movl $0x08, %ecx # 开机时BIOS设置的时钟中断向量号8.这里也直接使用它。 (当然,非要用别的索引号,也不是不行,只要不冲突,能记得住, 是随意的) lea idt(, %ecx,8), %esi # 把IDT描述符0x08地址放入ESI中,idt+0+ecx*8 => esi,标号"idt"是IDT表的地址,ecx这里是0x08, 就是表示, 要在 索引 = 0x08 的地方, 设置 timer_interrupt # 所以此时esi指向idt表的第64字节处,每个描述符占8字节,即第8个中断门描述符处,然后设置该描述符 # segreg:base_address(offset_address, index, size) # 其效果为 segreg:base_address + offset_address + index * size # segreg为分段模式下段寄存器,base_address为基址,offset_address 为偏移,index * size决定了第几个元素, # 其中size为元素长度,只能为1,2,4,8等等,这些元素都是可选的,index默认为0,size默认为1 movl %eax, (%esi) # 将相应数值填入门描述符的低4字节,填段选择符和中断函数地址低16位 movl %edx, 4(%esi) # 填充门描述符的高4字节,描述符属性和中断函数地址的高16位 # 定时中断调用过程(为简便省去特权级检查等):定时中断的向量号为8,所以发生中断的时候 # ,CPU会根据IDTR寄存器(上面call setup_idt已经设置好了IDTR)中提供的IDT表的基地址 # ,找到第8个中断门描述符,也即这里设置的门描述符。根据门描述符中的段选择符找到相应段 # 描述符,这里是找到内核代码段,这里的内核代码段的基地址是0.该基地址加上 # timer_interrupt就是中断函数入口。所以最终可以调用timer_interrupt函数。 # ####################################################################################### # ####################################################################################### # ******************** 系统调用中断 , 类同的方式 movw $system_interrupt, %ax # 设置系统调用陷阱门描述符。取系统通调用处理程序地址。 movw $0xef00, %dx # 陷阱门类型是15,特权级3的程序可执行。 movl $0x80, %ecx # 系统调用向量号是0x80。 (即, 0x80偏移处, 从默认的 ignroe_int改成了 system_interrupt) lea idt(, %ecx, 8), %esi # 把IDT描述符项0x80地址放入ESI中,然后设置该描述符。 movl %eax, (%esi) # 将相应数值填入描述符的低4字节,填段选择符(没有改动还是0x08)和中断函数地址低16位 movl %edx, 4(%esi) # 填充门描述符的高4字节,描述符属性和中断函数地址的高16位 # ####################################################################################### # 好了,现在我们为移动到任务0(TaskA)中执行来操作堆栈内容,在堆栈中人工建立中断返回时的场景。 # 注: 由于处于特权级0的代码不能直接把控制权转移到特权级3的代码中执行,但中断返回操作是可以的,因此当初始化GDT、IDT和定时芯片结束后,我们就利用中断返回指令IRET来启动运行第1个任务。 # 具体实现方法是在初始堆栈init_stack中人工设置一个返回环境。即把任务0的TSS段选择符加载到任务寄存器LTR中、LDT段选择符加载到LDTR中以后, # 把任务0的用户栈指针(0x17:init_stack)和代码指针(0x0f:task0)以及标志寄存器压入栈中,然后执行中断返回指令IRET。 # 该指令会弹出堆栈上的堆栈指针作为任务0的用户栈指针,恢复假设的任务0的标志寄存器内容,并且弹出栈中代码指针放入CS:EIP寄存器中,从而开始执行任务0的代码, # 完成了从特权级0到特权级3的控制转移。 # ####################################################################################### # 解释一下EFLAGS寄存器中的NT标志: # 位14是嵌套任务标志(Nested Task)。它控制着被中断任务和调用任务之间的链接关系。在使用CALL指令、中断或异常执行任务调用时,处理器会设置该标志。在通过使用IRET指令从一个任务返回时,处理器会检查并修改这个NT标志。 # 使用POPF/POPFD指令也可以修改这个标志,但是在应用程序中改变这个标志的状态会产生不可意料的异常。 # 嵌套任务标志NT用来控制中断返回指令IRET的执行。具体规定如下: # (1) 当NT=0,用堆栈中保存的值恢复EFLAGS、CS和EIP,执行常规的中断返回操作; # (2) 当NT=1,通过任务转换实现中断返回。 pushfl # EFLAGS入栈 andl $0xffffbfff, (%esp) # 复位标志寄存器EFLAGS中的嵌套任务标志。 popfl # EFLAGS出栈 这里只是复位 EFLAGS寄存器 的嵌套任务标志 而已 # ####################################################################################### movl $TSS0_SEL, %eax # 把任务0的TSS段选择符加载到任务寄存器TR。 ltr %ax # load 到 TR寄存器 因为加载的是段选择符, 所以, 就16位 movl $LDT0_SEL, %eax # 把任务0的LDT段选择符加载到局部描述符表寄存器LDTR。 lldt %ax # load,并且 TR和LDTR只需人工加载一次,以后CPU会自动处理。 感觉像系统进入后的一个NB的进程或者任务(init进程类似的???) movl $0, current # 把当前任务号0保存在current变量中。 即 当前任务号 sti # 现在开启中断,并在栈中营造中断返回时的场景。 (boot.s里 ,有对应的cli,当然,也不是所有的中断都能屏蔽的好像) pushl $0x17 # 把任务0当前局部空间数据段(堆栈段)选择符入栈。 # 问:0x17是怎么来的? # 答:0x17是任务0的局部数据段选择符,0x17的二进制为[00010 111]故为选择Index=2, # TI=1(表示在LDT中),RPL=3的段描述符,根据该段描述符可知为数据段,但是我们又看 # 到下面有ldt0和ldt1二个局部描述符表中都有0x17这个段选择符,那这里怎么区分选择 # 的是哪个局部段描述符表里的数据段呢,很简单因为TI=1,直接根据LDTR中的内容 # 来寻找局部段描述符表。上面已经用lldt指令加载了ldt0段描述符在GDT表中的段选择符到 # LDTR中,所以这里的0x17段选择符选择的是LDT0的第二个段(数据段)。注:lgdt加载的 # 是6字节的操作数,表示GDT表的基地址和长度。而lldt加载的是相应LDT表段描述符在GDT # 表中的段选择符,根据该段选择符就能找到LDT表的基地址和长度。如根据LDT0_SEL段选择 # 符就能找到GDT表中ldt0的段描述符,然后根据LDT0段的描述符找到ldt0表的地址, # 最终找到ldt0的数据段。这就是为什么每个LDT都必须在GDT中有一个段描述符和段选择符。 # pushl $init_stack # 把堆栈指针入栈(也可以直接把ESP入栈),在任务切换到任务1时,该值被弹出堆栈做为任务0的用户栈在TSS中保存,详见任务状态段有关任务切换的描述。 pushfl # 把标志寄存器入栈。 pushl $0x0f # 把当前局部空间代码段选择符入栈。 pushl $task0 # 把代码指针入栈。注意!pushl和push也是有区别的! (pushl压栈 双字 4个字节) iret # 执行中断返回指令,从而切换到特权级3的任务0中执行。 # 出栈时的内容为:任务0的代码指针task0,局部空间代码段选择符,标志寄存器,堆栈指针, # 局部空间数据段(堆栈段)选择符,ldt0段选择符,tss0段选择符. # 非常注意: 为什么要用这个 iret , 就是为了 让 CS:EIP,能跳转到 task0, # 汇编指令IRET # 【指令格式】IRET # 【指令功能】IRET(interrupt return)中断返回,中断服务程序的最后一条指令。IRET指令将推入堆栈的段地址和偏移地址弹出,使程序返回到原来发生中断的地方。其作用是从中断中恢复中断前的状态,具体作用有如下三点: # 1.恢复IP(instruction pointer):(IP)←((SP)+1:(SP)),(SP)←(SP)+2 对应的是 pushl $task0 # 2.恢复CS(code segment):(CS)←((SP)+1:(SP)),(SP)←(SP)+2 , 对应的是 pushl 0x000f # 3.恢复中断前的PSW(program status word),即恢复中断前的标志寄存器的状态。 (FR)←((SP)+1:(SP)),(SP)←(SP)+2 ,对应的是 pushfl # 4.恢复ESP(返回权限发生变化) 对应的是 pushl $init_stack # 5.恢复SS(返回权限发生变化) 对应的 pushl $0x17 # 以上操作按顺序进行。所以跟 入栈正好反过来了 # 以下是设置GDT和IDT中描述符项的子程序。 setup_gdt: # 使用6字节操作数lgdt_opcode设置GDT表位置和长度。 lgdt lgdt_opcode # lgdt指令加载GDT的入口地址(这里由lgdt_opcode指出)到GDTR中 ret setup_idt: # 首先在256个门描述符中都设置中断处理函数为ignore_int,然后用lidt加载IDT表。,实际就是给 idt表每8个字节赋一遍值 # 细节参见 中断门描述符结构 低位0-15 入口偏移低位, 16-31 段选择符, 高位:15-0 P=1/DPL=00/0 1110 0000 0000 16-31:入口偏移高位 # 所以,默认的 中断门描述符应该是 # 低: 0008 ignore_int # 高: 8E00 0000 8E就是上面那个 p/dpl/0***计算的来的, = 1000 1110 lea ignore_int, %edx # lea 取有效地址 -> edx, 设置方法与设置定时中断门描述符的方法一样。, 把默认的中断处理程序入口地址 -> edx movl $0x00080000, %eax # 选择符 为 0x0008 , 但是为什么呢?说是 同 内核代码段 , gdt表中第二个描述符, 内核的代码段 movw %dx, %ax # 将 edx的低16位,复制给ax 0000, 其实就是设置中断函数地址 , (注:ax为eax的低16位),设置中断函数地址 所以 eax = 0008 ignore_int movw $0x8e00, %dx # 门描述符属性 1000 1110 0000 0000 , 所以 edx = 8e00 0000 # 最高位 1:P,表示存在,肯定存在,DPL两位,00,再一个0的位置,然后 4个Type ,根据查找, 1110表示 32位中断门 lea idt, %edi # 取 idt表地址 -> edi edi中,这里存放的是 idt表的首地址 mov $256, %ecx # 准备重复256次, 循环设置所有256个门描述符项。 rp_idt: # 填充 idt表 movl %eax, (%edi) # 这里的括号,表示mov eax的内容,到 edi里面的 内存处 ,寄存器间接寻址 # Gun汇编器不允许直接把寄存器和数字相加 ,所以用 4(%edi),表示, edi里的地址 + 4 如果是负号,就是- movl %edx, 4(%edi) # 低位的四个字节设置完了之后, +4 ,设置下4个字节的内容 ? 难道为了简单,都设置成一样的了? addl $8, %edi # +8, 即下一个门描述符地址, 准备重复设置, IDT表地址加8字节,即跳到下一门描述符 dec %ecx # ecx自减 jne rp_idt lidt lidt_opcode # 加载 LDTR ret # 显示字符子程序。取当前光标位置并把AL中的字符显示在屏幕上。整屏可显示80X25个字符。 (80*25 个字符) write_char: # 打印字符 push %gs # 首先保存要用到的寄存器,eax由调用者负责保存 pushl %ebx mov $SCRN_SEL, %ebx # 然后让GS指向显示内存段(0xb8000) mov %bx, %gs # gs指向 显示io映射的内存地址 0xb8000 ? 什么鬼 ; movl scr_loc, %ebx # 从 变量 scr_loc 中取得当前的字符显示位置 shl $1, %ebx # 因为屏幕上,每个字符还有一个属性字节,因此字符实际 # 显示位置的内存偏移地址要 *2 shl 左移指令, 移动1位,等同于 *2 movb %al, %gs:(%ebx) # 这里是把字符发送到显存 shr $1, %ebx # 所以下一个字符的位置要 除 2 再 +1 incl %ebx # 这里加 1 cmpl $2000, %ebx # 比较一下, 如果超过了 80*25, 就需要复位, 从0开始 jb 1f # 这里之所以有一个f,表示 forwards,还有一个是 b:backwards, 这是AT&T的语法,表示下一个定义的局部符号 movl $0, %ebx # 复位为0 1: movl %ebx, scr_loc # 记录下一个字符的位置 ,最后把这个位置值保存起来(scr_loc) popl %ebx # 恢复ebx ,并弹出保存的寄存器内容(恢复调用该子程序前ebx,gs的值),返回。 pop %gs # 恢复gs ret # 以下是3个中断处理程序:默认中断、定时中断和系统调用中断。 # ignore_int是默认的中断处理程序,若系统产生了其他中断,则会载屏幕显示一个字符‘C’。 .align 2 ignore_int: # 默认的中断处理程序, 打印一个 'C' push %ds pushl %eax movl $0x10, %eax # 首先让DS指向内核数据段,因为中断程序属于内核。 mov %ax, %ds movl $67, %eax # print 'C' C=67 , 在AL中存放字符'C'的代码,调用显示程序显示在屏幕上。 call write_char popl %eax pop %ds iret # 这是定时中断处理程序。其中主要执行任务切换操作。 .align 2 timer_interrupt: push %ds pushl %eax movl $0x10, %eax # 首先让DS指向内核数据段。 mov %ax, %ds movb $0x20, %al # 然后立刻允许其他硬件中断,则向8253发送EOI命令。 outb %al, $0x20 movl $1, %eax cmpl %eax, current je 1f movl %eax, current # 若当前任务是0,则把1存入current,并跳转到任务1 ljmp $TSS1_SEL, $0 # 去执行。对于造成任务切换的长跳转偏移值无用,但需要写上。 ljmp 选择符, 32位偏移, 跳转到任务的任务段选择符会造成CPU切换到该任务运行。此时偏移值无用,可以任意填写一个0。 jmp 2f 1: movl $0, current # 若当前任务是1,则把0存入current,并跳转到任务0 ljmp $TSS0_SEL, $0 2: popl %eax pop %ds iret # 系统调用中断int0x80处理程序。该示例只有一个显示字符功能。 # 说明:system_interrup这个中断处理程序将由两个任务来调用。 .align 2 system_interrupt: push %ds pushl %edx pushl %ecx pushl %ebx pushl %eax movl $0x10, %edx # 首先让DS指向内核数据段 mov %dx, %ds call write_char # 然后调用显示字符子程序write_char, 显示AL中的字符 popl %eax popl %ebx popl %ecx popl %edx pop %ds iret # ***************************************************************** current: .long 0 # 当前任务号 (0或者1) scr_loc: # 屏幕显示位置, 从左上角顺序到右下角 .long 0 .align 2 lidt_opcode: # 16位表界限, 32位基地址 .word 256*8-1 # 加载IDTR寄存器的6字节操作数:表长度和基地址。 .long idt lgdt_opcode: # 低16位:gdt表界限, 高32位: 基地址 .word (end_gdt-gdt)-1 # 这个16位数表示GDT的段限长 .long gdt # 这个32位数表示GDT的基地址 .align 8 idt: # 中断向量表地址,8个字节一组,共 256组, 初始化为0 .fill 256,8,0 # IDT表空间。每个门描述符8字节,共占用2KB字节。 gdt: .quad 0x0000000000000000 # GDT表。第1个描述符不用。 .quad 0x00c09a00000007ff # 第2个是内核代码段描述符。其选择符是0x08。 .quad 0x00c09200000007ff # 第3个是内核数据段描述符。其选择符是0x10。 .quad 0x00c0920b80000002 # 第4个是显示内存段描述符。其选择符是0x18。 .word 0x0068, tss0, 0xe900, 0x0 # 第5个是TSS0段的描述符。其选择符是0x20 .word 0x0040, ldt0, 0xe200, 0x0 # 第6个是LDT0段的描述符。其选择符是0x28 .word 0x0068, tss1, 0xe900, 0x0 # 第7个是TSS1段的描述符。其选择符是0x30 .word 0x0040, ldt1, 0xe200, 0x0 # 第8个是LDT1段的描述符。其选择符是0x38 end_gdt: .fill 128,4, 0 # 初始内核堆栈空间 , 因为栈是从高地址向低地址发展的, 所以,下面定义了init_stack偏移, 作为 esp的栈顶地址 init_stack: .long init_stack # 这里是esp值32位 堆栈段偏移位置。 .word 0x10 # 这里是ss的值 16位 堆栈段同内核数据段 # 下面是任务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 ,krn_stk0为任务0内核栈顶指针 .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的内核栈空间。 任务0的用户栈其实是init_stack,在IRET之前已经手工的把任务0的用户栈基址入栈 krn_stk0: # 下面是任务1的LDT表段内容和TSS段内容 .align 8 ldt1: .quad 0x0000000000000000 # 第1个描述符,不用 .quad 0x00c0fa00000003ff # 选择符是0x0f,基地址=0x00000。 .quad 0x00c0f200000003ff # 选择符是0x17,基地址=0x00000。 tss1: .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, fs, 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 # 因为任务没有使用局部数据,所以这两句可省略。 movb $65, %al # 把需要显示的字符'A'放入寄存器中。 A = 65 int $0x80 # 执行系统调用,显示字符。 movl $0xfff, %ecx # 执行循环,起延时作用。 1: loop 1b # loop指令:每次循环CX递减1。等到CX为0的时候就退出循环 jmp task0 # 跳转到任务代码开始处继续显示字符。 task1: movl $0x17, %eax movw %ax, %ds movb $66 ,%al # 把需要显示的字符'B'放入寄存器中。 int $0x80 # 执行系统调用,显示字符。 movl $0xfff, %ecx # 执行循环,起延时作用。 1: loop 1b jmp task1 # 跳转到任务代码开始处继续显示字符。 .fill 128,4, 0 # 这是任务1的用户栈空间。 usr_stk1: