学习顺序:
阅读顺序总结
第一步: 阅读启动和硬件初始化相关的代码(entry.S
, start.c
)。
第二步: 了解内核初始化、内存管理和进程管理(main.c
, kalloc.c
, vm.c
, proc.c
)。
第三步: 阅读与系统调用、进程调度、上下文切换相关的代码(syscall.c
, sysproc.c
, swtch.c
)。
第四步: 深入理解中断处理、硬件和设备管理(kernelvec.S
, trap.c
, plic.c
)。
第五步: 学习文件系统、磁盘和日志管理(fs.c
, file.c
, log.c
, virtio_disk.c
)。
第六步: 理解同步机制和锁的实现(spinlock.c
, sleeplock.c
)。
第七步: 研究输入输出管理和字符串、格式化输出等辅助功能(console.c
, uart.c
, string.c
, printf.c
)。
最后: 了解用户与内核之间的切换(trampoline.S
)。
🆗启动和硬件初始化相关的代码(entry.S
, start.c
entry.S
entry.S
# qemu -kernel loads the kernel at 0x80000000
# and causes each CPU to jump there.
# kernel.ld causes the following code to
# be placed at 0x80000000. QEMU 加载内核时,会将控制权转交到 _entry 所指的地址,即 0x80000000。
.section .text
_entry:
# set up a stack for C.
# stack0 is declared in start.c,
# with a 4096-byte stack per CPU.
# sp = stack0 + (hartid * 4096)
la sp, stack0
li a0, 1024*4
csrr a1, mhartid
addi a1, a1, 1
mul a0, a0, a1
add sp, sp, a0
# jump to start() in start.c
call start
spin:
j spin
复制代码
这段代码是用汇编语言编写的一个 RISC-V 的引导程序,作用是在 QEMU 模拟器中启动一个操作系统内核,为每个 CPU 设置初始栈指针,并将控制权转交给 C 语言实现的 start 函数。了解即可
start.c
void main();
void timerinit(); // 定时器初始化函数
// 为每个 CPU 分配一个栈空间, 16是确保栈对齐
__attribute__ ((aligned (16))) char stack0[4096 * NCPU];
// scratch area for timer interrupt, one per CPU. 为每个 CPU 分配一个定时器中断处理的临时区域,每个CPU有32字节的空间
uint64 mscratch0[NCPU * 32];
// assembly code in kernelvec.S for machine-mode timer interrupt. 外部声明定时器中断处理函数, 位于 kernelvec.S
extern void timervec();
// entry.S jumps here in machine mode on stack0. 入口函数,进入机器模式并跳转到主函数
void
start()
{
// set M Previous Privilege mode to Supervisor, for mret.
unsigned long x = r_mstatus(); // 读取当前的 mstatus 寄存器的值 r读
x &= ~MSTATUS_MPP_MASK; // 清除 mstatus 寄存器中与 MPP 相关的位。 MSTATUS_MPP_MASK是用于屏蔽 MPP 位的掩码。
x |= MSTATUS_MPP_S; // 将 MPP 位设置为 S,就是内核态,表示下一次使用 mret 返回时,会跳转到内核态
w_mstatus(x); // 将修改后的mstatus 寄存器值写回去。 w写
// set M Exception Program Counter to main, for mret. 将 M 异常程序计数器设置为 main,用于 mret。
// requires gcc -mcmodel=medany 需要 gcc -mcmodel=medany
w_mepc((uint64)main); // 设置mepc寄存器的值为 main 函数的地址
// disable paging for now. 禁用分页,就是不启动虚拟内存,因为页表没建好,直接访问物理内存。
w_satp(0);
// delegate all interrupts and exceptions to supervisor mode. 将所有中断和异常委托给主管模式。
w_medeleg(0xffff); // 委托异常
w_mideleg(0xffff); // 委托中断
w_sie(r_sie() | SIE_SEIE | SIE_STIE | SIE_SSIE); // 进行or操作,启用时钟中断、软件中断和外部中断位
// ask for clock interrupts.
timerinit(); // 请求时钟中断
// keep each CPU's hartid in its tp register, for cpuid().
int id = r_mhartid(); // 读取当前 CPU 核心的hart ID,就是硬件线程ID。
w_tp(id); // hart ID 写入 tp 寄存器。tp寄存器通常用于存储每个核的特定信息。
// switch to supervisor mode and jump to main().
asm volatile("mret");
}
// set up to receive timer interrupts in machine mode, 设置为在机器模式下接收定时器中断,
// which arrive at timervec in kernelvec.S, 到达 kernelvec.S 中的 timervec,
// which turns them into software interrupts for 将其转换为 trap.c 中 devintr() 的软件中断。
// devintr() in trap.c.
// 该函数配置定时器中断,并准备系统以在固定时间间隔内处理机器模式的定时器中断。
void
timerinit()
{
// each CPU has a separate source of timer interrupts.
int id = r_mhartid(); // 获取当前CPU的唯一ID, 这样可以针对特定 CPU 配置定时器中断设置
// ask the CLINT for a timer interrupt. 向 CLINT请求定时器中断
int interval = 1000000; // cycles; about 1/10th second in qemu. 设定中断的时间间隔为 1,000,000 个周期, 约等于QEMU中的 1/10 秒
*(uint64*)CLINT_MTIMECMP(id) = *(uint64*)CLINT_MTIME + interval; // 负责生成定时器中断
// 准备定时器中断处理向量(timervec)所需的相关信息到 scratch[] 中。
// scratch[0..3] 用于保存定时器中断发生时的寄存器状态。
// scratch[4] 存储 CLINT MTIMECMP 寄存器的地址。
// scratch[5] 存储期望的定时器中断周期(以周期为单位)。
uint64 *scratch = &mscratch0[32 * id]; // 获取当前CPU的scratch区域指针
scratch[4] = CLINT_MTIMECMP(id); // 将 CLINT MTIMECMP寄存器的地址存入scratch[4],表示下一个定时器中断的时间点
scratch[5] = interval; // 将定时器中断的时间间隔存入scratch[5]。
w_mscratch((uint64)scratch); // 将机器模式的 scratch 寄存器(mscratch)设置为scratch数组的地址,数组内有数据
// 设置机器模式的中断处理向量(mtvec),指向定时器中断处理函数(timervec)。
// 这样 CPU 在发生定时器中断时就会跳转到定时器中断处理函数。
w_mtvec((uint64)timervec);
// enable machine-mode interrupts. 启用机器模式中断。
w_mstatus(r_mstatus() | MSTATUS_MIE);
// enable machine-mode timer interrupts. 启用机器模式的定时器中断。
w_mie(r_mie() | MIE_MTIE);
}
复制代码
最主要的内容:
定时器中断的工作原理:
定时器硬件会在每个固定的时间间隔内触发一次中断,操作系统会在中断处理程序中检查是否需要执行某些任务(如任务调度或超时处理)。
在计算机系统中,定时器通常以周期性的方式计数,当计数达到预定值时,就会触发一个定时器中断。
定时器中断的作用:
任务调度:操作系统可以根据定时器中断的触发,按固定的时间片轮流执行多个进程或线程(即时间共享)。
时间管理:定时器中断可以用于记录系统时间或延时操作。
周期性操作:某些硬件或软件功能可能需要定期执行,如数据采集、网络请求等。
机器模式中断的特点:
最高优先级:机器模式中断是所有中断模式中优先级最高的,CPU 进入机器模式后,能够处理其他级别的中断。
访问权限:机器模式有完全的硬件访问权限,能够访问所有硬件资源。它通常用于处理系统级的操作,如硬件初始化、外设控制、异常处理等。
中断向量表:在机器模式下,CPU 有一张中断向量表(mtvec),用于指向不同类型的中断处理程序。当中断发生时,CPU 会根据中断的类型,跳转到相应的处理程序。
机器模式中断的工作原理:
中断触发:机器模式中断可以由硬件外部设备(如定时器、外部设备、错误等)触发。
中断向量表:当中断发生时,RISC-V CPU 会根据当前的中断类型,跳转到对应的中断向量地址(mtvec
)。这通常是中断处理函数的入口。
中断优先级和屏蔽:RISC-V 架构支持在机器模式中控制中断的启用和屏蔽。例如,通过设置 MIE
(机器中断使能位)和 MIP
(机器中断挂起位)来管理中断的优先级和是否响应。
机器模式中断与定时器中断的关系
要记住机器模式不是内核态:内核态是操作系统中介于用户态和机器模式之间的特权级别
机器模式中断 是 RISC-V 架构中的高特权中断模式,它负责处理最底层的硬件中断和系统管理任务。
内核态中断 是操作系统中的一个概念,它发生在操作系统的内核态中,主要处理操作系统的任务调度、进程管理、I/O 等。
内核初始化、内存管理和进程管理main.c
, kalloc.c
, vm.c
, proc.c
main.c
volatile static int started = 0; // 多核 CPU 的启动协调
// start() jumps here in supervisor mode on all CPUs.
void
main()
{
if(cpuid() == 0){ // 返回当前 CPU 核心的 ID
consoleinit(); // 初始化控制台
printfinit();
printf("\n");
printf("xv6 kernel is booting\n");
printf("\n");
kinit(); // 初始化物理内存分配器
kvminit(); // 初始化分页机制
kvminithart(); // 启动分页机制
procinit(); // 初始化进程表
trapinit(); // 初始化中断向量和陷阱机制
trapinithart();
plicinit(); // 初始化外部中断控制器
plicinithart();
binit(); // 初始化文件系统的缓存,inode,文件表
iinit(); // inode cache
fileinit(); // file table
virtio_disk_init(); // 初始化虚拟磁盘
userinit(); // 创建第一个用户进程
__sync_synchronize(); // 确保内存操作的顺序
started = 1; // 同步作用,标志主核心完成了初始化
} else {
while(started == 0)
;
__sync_synchronize(); // 内存屏障,作用是确保在多核处理器或多线程环境下,内存操作按照代码的顺序执行,并使得这些操作对其他核心或线程可见
printf("hart %d starting\n", cpuid());
kvminithart(); // turn on paging
trapinithart(); // install kernel trap vector
plicinithart(); // ask PLIC for device interrupts
}
scheduler(); // 启动调度器
}
复制代码
这是 xv6 操作系统的 main()函数,是所有 CPU 核心在进入内核态之后的起点,初始化内核,分配资源,设置中断机制,启动调度器。
主要的问题有:
1、started 变量的作用是什么?
在主核心完成初始化之后,通过设置 start = 1 来告诉其他核心主核心的初始化过程已经完成。用于确保副核心只有在主核心完成全局初始化后才会开始工作。
2、__sync_synchronize();是什么?
1、保证内存访问的顺序:编译器和 CPU 可能会优化代码顺序(乱序执行)以提高性能,但在多核或多线程环境中,这种优化可能会导致数据不一致。__sync_synchronize(); 是一个全内存屏障,确保在它之前的所有内存操作在它之后的内存操作执行之前完成。
2、内存可见性:通过屏障,确保一个核心对共享变量的修改对于其他核心可见。比如,主核心修改了 started = 1 并执行了屏障,副核心在屏障后读取到的 started 一定是最新值。
3、多核环境中的数据一致性:确保多个核心对共享内存数据的操作按照期望顺序发生,避免因为缓存一致性或硬件优化导致的不一致行为。
3、为什么屏障后要打开分页、陷入和外部中断?
屏障之前,主核心完成了系统的全局初始化,包括内存分配、页表配置、中断控制器配置。
屏障之后,各个副核心在本地完成以下操作:1、启用分页: 确保虚拟内存机制的正确性。2、设置陷阱向量: 准备好处理异常、系统调用和中断。3、启用设备中断: 接收设备的中断信号,支持设备操作。
这些操作依赖主核心的全局初始化。
kalloc.c
// Physical memory allocator, for user processes,
// kernel stacks, page-table pages,
// and pipe buffers. Allocates whole 4096-byte pages.
// 物理内存分配器,用于用户进程、内核栈、页表页和管道缓冲区。
// 分配完整的 4096 字节页面(即一页内存)。
#include "types.h"
#include "param.h"
#include "memlayout.h"
#include "spinlock.h"
#include "riscv.h"
#include "defs.h"
// 释放内存的函数
void freerange(void *pa_start, void *pa_end);
// extern 声明,end 是内核代码加载后的第一个地址,由 kernel.ld 定义。
// 它标志着内核空间的结束和用户空间的开始。
extern char end[]; // first address after kernel.
// defined by kernel.ld.
struct run { // run结构,用链表形式保存空闲的物理页面
struct run *next;
};
struct { // kmem结构包含锁和一个空闲页面链表
struct spinlock lock;
struct run *freelist;
} kmem;
// 初始化内存分配器。
// 初始化锁,调用 freerange 函数来释放从 end 到 PHYSTOP地址之间的所有内存页面。 PHYSTROP: RISC-V 系统中用来定义物理内存的上限地址
void
kinit()
{
initlock(&kmem.lock, "kmem");
freerange(end, (void*)PHYSTOP);
}
// 释放指定内存范围的所有页面。
// pa_start 是开始地址,pa_end 是结束地址。
// 会从 pa_start 开始,向上遍历每一个页面(页大小为 PGSIZE -> 4096 字节),并将其释放。
void
freerange(void *pa_start, void *pa_end)
{
char *p;
p = (char*)PGROUNDUP((uint64)pa_start); // 将pa_start对齐到页面边界
for(; p + PGSIZE <= (char*)pa_end; p += PGSIZE)
kfree(p);
}
复制代码
// Free the page of physical memory pointed at by v,
// which normally should have been returned by a
// call to kalloc(). (The exception is when
// initializing the allocator; see kinit above.)
// 释放一个页面,pa 是要释放的物理内存页面的地址。
// 通常该页面应通过 kalloc 分配,但在内存分配器初始化阶段,它可能会被提前释放。
// 如果释放地址不合法(如地址未对齐或不在允许的物理内存范围内),会触发 panic。
void
kfree(void *pa)
{
struct run *r;
// 检查 pa 是否是合法的页面地址:地址必须对齐,且不在内核空间或超出物理内存范围
if(((uint64)pa % PGSIZE) != 0 || (char*)pa < end || (uint64)pa >= PHYSTOP)
panic("kfree");
// Fill with junk to catch dangling refs. 将页面内容填充为 1,以便能够捕捉悬空指针或错误引用。
memset(pa, 1, PGSIZE);
r = (struct run*)pa;
acquire(&kmem.lock);
r->next = kmem.freelist; // 将当前页面添加到空闲页面链表的头部
kmem.freelist = r; // 更新链表头指针
release(&kmem.lock);
}
复制代码
这里设计的很巧妙的地方是 memset(pa, 1, PGSIZE);
为什么要将页面内容填充为 1
?(下面函数的memset((char*)r, 5, PGSIZE);
同理)
悬空指针和错误引用:
当内存被释放(例如通过 kfree()
)后,如果程序仍然试图访问该内存地址,就会出现 悬空指针。悬空指针指向的内存区域已经被释放或回收,但程序仍然试图使用它。悬空指针访问的后果是未定义的,可能会导致程序崩溃或数据损坏。
为了避免这种情况,kfree()
会将释放的内存页面填充为特定的值,使得任何试图访问该内存的行为都能快速暴露出来。例如:
提高调试效率:
这种填充是调试的一个策略,通过检查内存中的特殊值,开发者可以很容易地识别出程序是否错误地访问了已释放的内存区域。现代操作系统和编程环境通常会使用类似的策略来处理内存泄漏和悬空指针。
// 分配一个 4096 字节的物理页面。
// 如果分配成功,返回页面的地址;如果没有足够的内存,返回 0。
void *
kalloc(void)
{
struct run *r;
acquire(&kmem.lock);
r = kmem.freelist; // 从空闲页面链表中取出一个页面
if(r)
kmem.freelist = r->next; // 更新链表头,移除已分配的页面
release(&kmem.lock);
if(r)
memset((char*)r, 5, PGSIZE); // 将分配的页面填充为 5,以便捕捉错误的使用
return (void*)r; // 返回分配的页面地址
}
复制代码
vm.c
proc.c
🆗系统调用、进程调度、上下文切换相关的代码 syscall.c
, sysproc.c
, swtch.c
syscall.c 【应该看完所有的会有更深的理解】
主要负责处理用户态程序通过 ecall
指令发起的系统调用。
// Fetch the uint64 at addr from the current process. 从当前进程中获取一个uint64类型的数据,存储在 ip 指针指向的内存地址中。
int
fetchaddr(uint64 addr, uint64 *ip)
{
struct proc *p = myproc(); // 获取当前进程指针
if(addr >= p->sz || addr+sizeof(uint64) > p->sz) // 检查地址是否越界
return -1;
if(copyin(p->pagetable, (char *)ip, addr, sizeof(*ip)) != 0) // 从进程的页表中复制数据
return -1;
return 0;
}
// Fetch the nul-terminated string at addr from the current process. 从当前进程中获取addr 处以 null结尾的字符串。
// Returns length of string, not including nul, or -1 for error. 返回字符串的长度(不包括 nul),如果出错则返回 -1。
int
fetchstr(uint64 addr, char *buf, int max)
{
struct proc *p = myproc();
int err = copyinstr(p->pagetable, buf, addr, max); // 从进程的页表中复制字符串
if(err < 0)
return err;
return strlen(buf);
}
// 函数artint、artaddr和artfd从陷阱框架中检索第n个系统调用参数并以整数、指针或文件描述符的形式保存。他们都调用argraw来检索相应的保存的用户寄存器
static uint64
argraw(int n) // 从当前进程的陷阱框架中获取第n个系统调用参数,返回 raw 类型的参数。 n: 参数的索引
{
struct proc *p = myproc();
switch (n) {
case 0:
return p->trapframe->a0;
case 1:
return p->trapframe->a1;
case 2:
return p->trapframe->a2;
case 3:
return p->trapframe->a3;
case 4:
return p->trapframe->a4;
case 5:
return p->trapframe->a5;
}
panic("argraw");
return -1;
}
// Fetch the nth 32-bit system call argument. // 获取第n个32 位系统调用参数。
int
argint(int n, int *ip)
{
*ip = argraw(n); // 获取参数并赋值给 ip
return 0;
}
// Retrieve an argument as a pointer. 获取第n个系统调用参数作为地址,存储在ip 指向的变量中。
// Doesn't check for legality, since
// copyin/copyout will do that.
int
argaddr(int n, uint64 *ip)
{
*ip = argraw(n); // 获取参数并赋值给 ip
return 0;
}
// Fetch the nth word-sized system call argument as a null-terminated string. 将第 n 个字大小的系统调用参数作为以空字符结尾的字符串获取。
// Copies into buf, at most max. 复制到 buf 中,最多为最大值。
// Returns string length if OK (including nul), -1 if error. 如果成功则返回字符串长度(包括 nul),如果出错则返回 -1。
int
argstr(int n, char *buf, int max)
{
uint64 addr;
if(argaddr(n, &addr) < 0)
return -1;
return fetchstr(addr, buf, max); // 获取字符串并返回长度
}
复制代码
主要区别
extern uint64 sys_chdir(void);
extern uint64 sys_close(void);
extern uint64 sys_dup(void);
extern uint64 sys_exec(void);
extern uint64 sys_exit(void);
extern uint64 sys_fork(void);
extern uint64 sys_fstat(void);
extern uint64 sys_getpid(void);
extern uint64 sys_kill(void);
extern uint64 sys_link(void);
extern uint64 sys_mkdir(void);
extern uint64 sys_mknod(void);
extern uint64 sys_open(void);
extern uint64 sys_pipe(void);
extern uint64 sys_read(void);
extern uint64 sys_sbrk(void);
extern uint64 sys_sleep(void);
extern uint64 sys_unlink(void);
extern uint64 sys_wait(void);
extern uint64 sys_write(void);
extern uint64 sys_uptime(void);
// syscalls 数组:存储系统调用的函数指针,索引对应系统调用的编号。
static uint64 (*syscalls[])(void) = {
[SYS_fork] sys_fork,
[SYS_exit] sys_exit,
[SYS_wait] sys_wait,
[SYS_pipe] sys_pipe,
[SYS_read] sys_read,
[SYS_kill] sys_kill,
[SYS_exec] sys_exec,
[SYS_fstat] sys_fstat,
[SYS_chdir] sys_chdir,
[SYS_dup] sys_dup,
[SYS_getpid] sys_getpid,
[SYS_sbrk] sys_sbrk,
[SYS_sleep] sys_sleep,
[SYS_uptime] sys_uptime,
[SYS_open] sys_open,
[SYS_write] sys_write,
[SYS_mknod] sys_mknod,
[SYS_unlink] sys_unlink,
[SYS_link] sys_link,
[SYS_mkdir] sys_mkdir,
[SYS_close] sys_close,
};
复制代码
实现系统调用的具体功能函数和数组索引与系统调用编号的映射关系。
void
syscall(void) // 处理系统调用,根据进程的 trapframe 中的系统调用编号
{
int num;
struct proc *p = myproc();
num = p->trapframe->a7; // 获取系统调用编号 [寄存器 a7 的作用与系统调用的调用约定有关]
if(num > 0 && num < NELEM(syscalls) && syscalls[num]) { // 检查系统调用编号是否有效
p->trapframe->a0 = syscalls[num](); // 调用相应的系统调用函数
} else {
printf("%d %s: unknown sys call %d\n",
p->pid, p->name, num);
p->trapframe->a0 = -1;
}
}
复制代码
RISC-V 系统调用约定
在 RISC-V 的 ABI
(Application Binary Interface)中:
系统调用编号:
调用系统调用时,程序会将 系统调用编号 放入寄存器 a7
中。
这个编号用于告诉操作系统内核当前调用的是哪一个系统调用。
系统调用参数:
系统调用的前 6 个参数通过寄存器 a0
至 a5
传递。
如果有更多参数(超过 6 个),会通过栈传递。
系统调用返回值:
系统调用的返回值存储在寄存器 a0
中。
触发系统调用:
系统调用通常通过 ecall
指令触发,这会将控制权交给内核。
内核通过 a7
的值判断是哪一个系统调用,并执行相应的系统调用函数。
sysproc.c
uint64
sys_exit(void) // 结束当前进程,并返回指定的退出状态码。
{
int n;
if(argint(0, &n) < 0)
return -1;
exit(n);
return 0; // not reached
}
uint64
sys_getpid(void) // 获取当前进程的进程ID
{
return myproc()->pid;
}
uint64
sys_fork(void) // 创建当前进程的一个副本
{
return fork();
}
uint64
sys_wait(void) // 等待子进程结束,并回收其资源。
{
uint64 p;
if(argaddr(0, &p) < 0)
return -1;
return wait(p);
}
uint64
sys_sbrk(void) // 调整当前进程的数据段大小。
{
int addr;
int n;
if(argint(0, &n) < 0) // 获取第一个参数(调整的字节数)
return -1;
addr = myproc()->sz; // 当前进程的内存大小。
if(growproc(n) < 0) // 调整内存大小
return -1;
return addr;
}
uint64
sys_sleep(void) // 让当前进程进入睡眠状态,指定的时间后唤醒。
{
int n;
uint ticks0;
if(argint(0, &n) < 0) // 获取第一个参数(睡眠的时钟周期数)
return -1;
acquire(&tickslock); // 获取时钟锁。
ticks0 = ticks;
while(ticks - ticks0 < n){ // 等待指定的时钟周期数
if(myproc()->killed){ // 若进程被杀死,则退出
release(&tickslock);
return -1;
}
sleep(&ticks, &tickslock);
}
release(&tickslock);
return 0;
}
uint64
sys_kill(void) // 终止指定PID的进程
{
int pid;
if(argint(0, &pid) < 0)
return -1;
return kill(pid);
}
// return how many clock tick interrupts have occurred
// since start. 返回自启动以来发生了多少个时钟滴答中断。
uint64
sys_uptime(void)
{
uint xticks;
acquire(&tickslock);
xticks = ticks; // 读取时钟周期数。
release(&tickslock);
return xticks;
}
复制代码
这个没什么好看的,最主要的代码都在 proc.c 中。
swtch.s
保存当前任务的寄存器
# Context switch
#
# void swtch(struct context *old, struct context *new);
#
# Save current registers in old. Load from new.
.globl swtch
swtch:
sd ra, 0(a0)
sd sp, 8(a0)
sd s0, 16(a0)
sd s1, 24(a0)
sd s2, 32(a0)
sd s3, 40(a0)
sd s4, 48(a0)
sd s5, 56(a0)
sd s6, 64(a0)
sd s7, 72(a0)
sd s8, 80(a0)
sd s9, 88(a0)
sd s10, 96(a0)
sd s11, 104(a0)
复制代码
加载新任务的寄存器
ld ra, 0(a1)
ld sp, 8(a1)
ld s0, 16(a1)
ld s1, 24(a1)
ld s2, 32(a1)
ld s3, 40(a1)
ld s4, 48(a1)
ld s5, 56(a1)
ld s6, 64(a1)
ld s7, 72(a1)
ld s8, 80(a1)
ld s9, 88(a1)
ld s10, 96(a1)
ld s11, 104(a1)
ret
复制代码
保存当前任务的上下文(寄存器的值)并恢复新任务的上下文。通过上下文切换,操作系统可以让多个任务(不同的进程或线程)在同一时间段内轮流使用 CPU。每次上下文切换时,操作系统会保存当前任务的状态,然后恢复下一个任务的状态,确保任务能够在 CPU 上继续执行而不丢失信息。
中断处理、硬件和设备管理 kernelvec.S,
trap.c,
plic.c`
kernelvec.S
内核模式的中断与异常处理
kernelvec
是操作系统进入内核模式时处理中断或异常的入口。它保存当前上下文(寄存器值)、调用内核的中断处理函数 kerneltrap
,然后恢复上下文并返回。
// make room to save registers.
addi sp, sp, -256
// save the registers.
sd ra, 0(sp)
sd sp, 8(sp)
sd gp, 16(sp)
sd tp, 24(sp)
sd t0, 32(sp)
sd t1, 40(sp)
sd t2, 48(sp)
sd s0, 56(sp)
sd s1, 64(sp)
sd a0, 72(sp)
sd a1, 80(sp)
sd a2, 88(sp)
sd a3, 96(sp)
sd a4, 104(sp)
sd a5, 112(sp)
sd a6, 120(sp)
sd a7, 128(sp)
sd s2, 136(sp)
sd s3, 144(sp)
sd s4, 152(sp)
sd s5, 160(sp)
sd s6, 168(sp)
sd s7, 176(sp)
sd s8, 184(sp)
sd s9, 192(sp)
sd s10, 200(sp)
sd s11, 208(sp)
sd t3, 216(sp)
sd t4, 224(sp)
sd t5, 232(sp)
sd t6, 240(sp)
复制代码
保存寄存器上下文,保护当前进程的执行状态,所有寄存器都被保存到栈中。
// 调用 trap.c 中的 C 陷阱处理程序
call kerneltrap
复制代码
调用内核的 C 语言函数 kerneltrap
,具体的中断或异常处理逻辑由内核中的 trap.c
定义。
// restore registers.
ld ra, 0(sp)
ld sp, 8(sp)
ld gp, 16(sp)
// not this, in case we moved CPUs: ld tp, 24(sp)
ld t0, 32(sp)
ld t1, 40(sp)
ld t2, 48(sp)
ld s0, 56(sp)
ld s1, 64(sp)
ld a0, 72(sp)
ld a1, 80(sp)
ld a2, 88(sp)
ld a3, 96(sp)
ld a4, 104(sp)
ld a5, 112(sp)
ld a6, 120(sp)
ld a7, 128(sp)
ld s2, 136(sp)
ld s3, 144(sp)
ld s4, 152(sp)
ld s5, 160(sp)
ld s6, 168(sp)
ld s7, 176(sp)
ld s8, 184(sp)
ld s9, 192(sp)
ld s10, 200(sp)
ld s11, 208(sp)
ld t3, 216(sp)
ld t4, 224(sp)
ld t5, 232(sp)
ld t6, 240(sp)
addi sp, sp, 256
复制代码
恢复所有寄存器到中断前的状态,确保中断处理不会破坏进程原本的运行环境。
// return to whatever we were doing in the kernel.
sret
复制代码
返回到内核的上一个执行点,继续之前的操作。
timervec
: 机器模式定时器中断处理
timervec
是机器模式的定时器中断入口,用于处理定时器中断,并安排下一次中断。
csrrw a0, mscratch, a0
sd a1, 0(a0)
sd a2, 8(a0)
sd a3, 16(a0)
复制代码
使用机器模式的 mscratch
寄存器存储当前寄存器值(a1
, a2
, a3
)。
mscratch
是机器模式提供的一段临时存储空间,通常由操作系统在启动时初始化。
# schedule the next timer interrupt
# by adding interval to mtimecmp.
ld a1, 32(a0) # CLINT_MTIMECMP(hart)
ld a2, 40(a0) # interval
ld a3, 0(a1)
add a3, a3, a2
sd a3, 0(a1)
复制代码
从 mscratch
中读取定时器中断的间隔(interval
)和当前计时器值(mtimecmp
)。
通过向 mtimecmp
加上 interval
,设置下一次定时器中断触发的时间。
# raise a supervisor software interrupt.
li a1, 2
csrw sip, a1
复制代码
设置软件中断位(sip
),通知操作系统有新的中断需要处理
ld a3, 16(a0)
ld a2, 8(a0)
ld a1, 0(a0)
csrrw a0, mscratch, a0
复制代码
恢复中断处理前的寄存器状态
返回到中断前的机器模式状态。
小总结:
kernelvec 处理用户态到内核态的中断或异常
timervec 处理时钟中断:硬件定时器触发的中断,通常周期性发生,用于操作系统的时间管理。
区别总结
trap.c
// 用于锁定ticks变量的自旋锁
struct spinlock tickslock;
uint ticks; // 系统时间的计数变量
// 外部定义的汇编符号,指向中断和异常处理相关的代码
extern char trampoline[], uservec[], userret[];
// in kernelvec.S, calls kerneltrap(). 调用kerneltrap()。
void kernelvec();
// 外部函数,处理设备中断
extern int devintr();
// 初始化trap模块
void
trapinit(void)
{
initlock(&tickslock, "time"); // 初始化自旋锁,名为time
}
// set up to take exceptions and traps while in the kernel.
// 初始化hart(硬件线程)的中断,设置中断向量表地址
void
trapinithart(void)
{
w_stvec((uint64)kernelvec);
}
复制代码
主要就是初始化一些函数。
//
// 处理来自用户态的中断、异常或系统调用
// 从trampoline.S调用
//
void
usertrap(void)
{
int which_dev = 0;
if((r_sstatus() & SSTATUS_SPP) != 0) // 检查是否从用户模式进入
panic("usertrap: not from user mode");
// send interrupts and exceptions to kerneltrap(),
// since we're now in the kernel. 设置stvec寄存器指向kernelvec,处理后续中断
w_stvec((uint64)kernelvec);
struct proc *p = myproc();
// save user program counter. 保存用户程序计数器
p->trapframe->epc = r_sepc();
if(r_scause() == 8){ // 系统调用
// system call
if(p->killed)
exit(-1);
// sepc points to the ecall instruction,
// but we want to return to the next instruction. 跳过系统调用,返回到下一条指令
p->trapframe->epc += 4;
// an interrupt will change sstatus &c registers,
// so don't enable until done with those registers. 打开中断,但是要在修改寄存器之后
intr_on();
syscall(); // 处理系统调用
} else if((which_dev = devintr()) != 0){
// ok 处理设备中断
} else { // 未知的中断或异常,打印错误并终止进程
printf("usertrap(): unexpected scause %p pid=%d\n", r_scause(), p->pid);
printf(" sepc=%p stval=%p\n", r_sepc(), r_stval());
p->killed = 1;
}
if(p->killed) // 如果进程标记终止,则退出
exit(-1);
// give up the CPU if this is a timer interrupt. 如果是时钟中断,放弃cpu控制权
if(which_dev == 2)
yield();
usertrapret(); // 返回用户态
}
复制代码
处理用户态中断的顺序:
检查中断来源
设置中断向量表地址
保存用户态上下文
判断中断类型
系统调用 (r_scause() == 8
):
检查进程是否被标记为已杀死。如果是,直接调用 exit(-1)
终止进程。
跳过当前系统调用指令(通过将 trapframe->epc
加 4,指向下一条指令)。
打开中断(intr_on()
),然后调用 syscall()
处理具体的系统调用。
设备中断:
调用 devintr()
检查是否是设备中断。若是设备中断,which_dev
将被赋值为对应设备的标识。
未知中断或异常:
打印错误信息,包括中断原因(scause
)、程序计数器(sepc
)、异常地址(stval
)。
将当前进程标记为已杀死。
检查进程状态
时钟中断特殊处理
返回用户态
为什么时钟中断会放弃 CPU 控制权?
时钟中断的作用:
时钟中断(Timer Interrupt)是操作系统实现时间片轮转调度的重要机制。
它定期触发(通常由硬件计时器控制),让内核有机会检查当前进程的运行时间是否超出分配的时间片。
多任务的公平性:
在多任务操作系统中,时钟中断触发时,当前进程可能已经运行了一段时间。
为了实现公平性,内核会通过 yield()
放弃当前进程的 CPU 使用权,将 CPU 调度给其他进程。
提高系统响应性:
如果当前进程长时间占用 CPU,其他进程可能被饿死(得不到执行机会)。
通过响应时钟中断,系统可以确保每个进程都能定期获得运行时间,避免某些任务长期被延迟。
进程调度的实现:
yield()
会将当前进程放回调度队列,并调用调度器(scheduler
)选择另一个进程运行。
这种机制实现了基于时钟的抢占式多任务调度。
//
// return to user space 返回用户态
//
void
usertrapret(void)
{
struct proc *p = myproc();
// 我们即将把陷阱的目标从
// kerneltrap() 切换到 usertrap(),因此请关闭中断,直到
// 我们回到用户空间,此时 usertrap() 是正确的。
// 禁用中断,准备切换到用户态
intr_off();
// send syscalls, interrupts, and exceptions to trampoline.S 设置stvec寄存器指向用户态的中断向量表
w_stvec(TRAMPOLINE + (uservec - trampoline));
// set up trapframe values that uservec will need when
// the process next re-enters the kernel.
// 设置trapframe中的值供uservec使用
p->trapframe->kernel_satp = r_satp(); // 内核页表
p->trapframe->kernel_sp = p->kstack + PGSIZE; // 进程的内核栈 process's kernel stack
p->trapframe->kernel_trap = (uint64)usertrap; // 用户态trap函数地址
p->trapframe->kernel_hartid = r_tp(); // hart ID(cpu的唯一标识)。hartid for cpuid()
// set up the registers that trampoline.S's sret will use
// to get to user space.
// set S Previous Privilege mode to User.
// 配置用户态的寄存器
unsigned long x = r_sstatus();
x &= ~SSTATUS_SPP; // clear SPP to 0 for user mode 清除SPP位,设置为用户模式
x |= SSTATUS_SPIE; // enable interrupts in user mode 开启用户模式中断。
w_sstatus(x);
// set S Exception Program Counter to the saved user pc.
// 设置用户态程序计数器为保存的值
w_sepc(p->trapframe->epc);
// tell trampoline.S the user page table to switch to.
// 设置用户态页表
uint64 satp = MAKE_SATP(p->pagetable);
// jump to trampoline.S at the top of memory, which
// switches to the user page table, restores user registers,
// and switches to user mode with sret.
// 跳转到trampoline.S中的代码,恢复用户态寄存器并切换到用户模式
uint64 fn = TRAMPOLINE + (userret - trampoline);
((void (*)(uint64,uint64))fn)(TRAPFRAME, satp);
}
复制代码
返回用户态的流程:
1、关闭中断: 确保切换过程不会被中断。
2、设置用户态中断向量表: 确保用户态发生中断时能跳转到 uservec
。
3、准备 trapframe 数据(也可以说是保存上下文): 为用户态的中断处理提供内核上下文信息。
4、配置 sstatus
(开中断): 切换到用户模式并允许用户态中断。
5、设置用户程序计数器: 恢复用户代码的执行位置。
6、切换到用户态页表: 准备用户虚拟地址空间。
7、跳转到 trampoline.S
: 切换到用户模式,恢复用户寄存器并执行用户代码。
// interrupts and exceptions from kernel code go here via kernelvec,
// on whatever the current kernel stack is.
// 无论当前内核堆栈是什么,内核代码的中断和异常都会通过 kernelvec 传输到这里。
void
kerneltrap()
{
int which_dev = 0;
uint64 sepc = r_sepc();
uint64 sstatus = r_sstatus();
uint64 scause = r_scause();
if((sstatus & SSTATUS_SPP) == 0) // 确保当前处于内核态模式。
panic("kerneltrap: not from supervisor mode");
if(intr_get() != 0)
panic("kerneltrap: interrupts enabled");
if((which_dev = devintr()) == 0){ // 处理设备中断
printf("scause %p\n", scause);
printf("sepc=%p stval=%p\n", r_sepc(), r_stval());
panic("kerneltrap");
}
// give up the CPU if this is a timer interrupt. 如果是时钟中断则放弃cpu控制权
if(which_dev == 2 && myproc() != 0 && myproc()->state == RUNNING)
yield();
// the yield() may have caused some traps to occur,
// so restore trap registers for use by kernelvec.S's sepc instruction.
// Yield() 可能导致某些陷阱发生,因此恢复trap寄存器以供 kernelvec.S 的 sepc 指令使用。
w_sepc(sepc);
w_sstatus(sstatus);
}
复制代码
处理内核态中断,流程为:
1、保存上下文
2、确保中断已禁用
3、识别并处理设备中断
4、时钟中断处理
5、恢复上下文
进一步说明
内核态中断与用户态中断的区别:
用户态中断处理需要切换上下文到内核态。
内核态中断处理发生在内核中,只需要保存当前内核的上下文。
嵌套中断处理:
内核中通常会在特定时刻重新启用中断(例如,处理某些非关键部分的代码时),允许高优先级中断嵌套执行。
时钟中断的优先级:
时钟中断一般用于调度器,确保系统以公平和高效的方式分配 CPU 时间。
//
// 检查并处理设备中断
// 返回值:
// 2:时钟中断
// 1:其他设备中断
// 0:未识别
//
int
devintr()
{
uint64 scause = r_scause();
// 检查是否是外部中断
if((scause & 0x8000000000000000L) &&
(scause & 0xff) == 9){
// 来自PLIC的外部中断
// 获取中断设备编号
int irq = plic_claim();
if(irq == UART0_IRQ){ // UART中断
uartintr();
} else if(irq == VIRTIO0_IRQ){ // VIRTIO磁盘中断
virtio_disk_intr();
} else if(irq){
printf("unexpected interrupt irq=%d\n", irq);
}
// the PLIC allows each device to raise at most one
// interrupt at a time; tell the PLIC the device is
// now allowed to interrupt again.
//PLIC 允许每个设备一次最多发出一个中断;告诉 PLIC 该设备现在可以再次中断。
if(irq)
plic_complete(irq);
return 1;
} else if(scause == 0x8000000000000001L){
// software interrupt from a machine-mode timer interrupt,
// forwarded by timervec in kernelvec.S.
// 来自机器模式定时器中断的软件中断,由 kernelvec.S 中的 timervec 转发。
if(cpuid() == 0){ // 处理时钟中断
clockintr();
}
// acknowledge the software interrupt by clearing
// the SSIP bit in sip.
// 清除SSIP位,表示处理完成。
w_sip(r_sip() & ~2);
return 2;
} else {
return 0; // 未识别的中断
}
}
复制代码
第一个函数是处理时钟中断的。第二个函数处理外部设备中断。主要的问题在:
为什么要增加系统时间计数?
实现基于时间的进程调度,特别是时间片轮转调度。
管理进程的睡眠与唤醒机制,让操作系统能够控制延时和定时任务。
为系统提供精确的时间管理功能,帮助操作系统处理各种基于时间的操作
PLIC 是一个通用外部中断控制器,管理多设备中断。
UART 和 VirtIO 分别用于串行通信和虚拟设备的 I/O 操作。
为什么 cpuid == 0 的时候处理时钟中断?
通常,RISC-V 系统中的第一个硬件线程(CPU 0)被指定为处理系统级任务(如时钟管理)。
定时器中断需要更新全局时间或调度进程,由 cpuid == 0
的核心统一处理,避免其他核重复更新时间。
处理流程总结
外部中断 (PLIC)
检查 scause
是否为外部中断。
调用 plic_claim()
获取中断号。
根据中断号调用相应处理程序:
uartintr()
:处理 UART 通信。
virtio_disk_intr()
:处理 VirtIO 磁盘操作。
调用 plic_complete()
告知 PLIC 完成处理。
定时器中断
检查 scause
是否为定时器中断。
若 cpuid == 0
,调用 clockintr()
更新系统时间。
清除 SSIP
位,标记处理完成。
plic.c
//
// the riscv Platform Level Interrupt Controller (PLIC).
// riscv 平台级中断控制器 (PLIC)。
//
void
plicinit(void)
{
// set desired IRQ priorities non-zero (otherwise disabled).
// 设置UART0中断的优先级为1,启用中断。
*(uint32*)(PLIC + UART0_IRQ*4) = 1;
// 设置VIRTIO0中断的优先级为1,启用中断。
*(uint32*)(PLIC + VIRTIO0_IRQ*4) = 1;
}
void
plicinithart(void)
{
int hart = cpuid(); // 获取cpuid
// set uart's enable bit for this hart's S-mode.
// 设置S模式中启用的中断位,启用UART0和VIRTIO0的中断。
*(uint32*)PLIC_SENABLE(hart)= (1 << UART0_IRQ) | (1 << VIRTIO0_IRQ);
// set this hart's S-mode priority threshold to 0.
// 设置S模式中断优先级阈值为0,表示处理所有优先级为0及以上的中断。
*(uint32*)PLIC_SPRIORITY(hart) = 0;
}
// ask the PLIC what interrupt we should serve.
// 查询PLIC,确定哪个 中断需要被当前Hart服务
int
plic_claim(void)
{
int hart = cpuid();
int irq = *(uint32*)PLIC_SCLAIM(hart); // 查询需要处理的中断号
return irq;
}
// tell the PLIC we've served this IRQ. 告诉PLIC当前Hart已经处理完一个中断。
void
plic_complete(int irq)
{
int hart = cpuid();
// 告诉PLIC当前Hart已经处理完中断,并清除相应的中断。
*(uint32*)PLIC_SCLAIM(hart) = irq;
}
复制代码
这个函数上来确实没懂它是要做什么。
函数的具体作用:
plicinit:
功能:初始化 PLIC 中特定外设的中断优先级。
场景:当系统启动时,需要初始化 PLIC,告诉它哪些中断是有效的,并设置这些中断的优先级。优先级设置为非零(比如 1),表示这些中断是启用的。否则,中断会被禁用。
应用:通过设置 UART0 和 Virtio0 的优先级为 1,表示系统可以处理这两个外设的中断。
plicinithart:
功能:为每个核心或硬件线程(Hart)设置中断处理的启用状态,并指定中断的优先级阈值。
场景:当多个核心或线程在运行时,每个线程需要独立处理自己的中断。因此,每个硬件线程都需要初始化,告诉 PLIC 它支持哪些中断,并设置每个硬件线程的中断优先级阈值。
应用:这段代码会为当前硬件线程启用 UART0 和 Virtio0 中断,并将优先级阈值设置为 0,表示只要有中断请求,它就会处理。
plic_claim:
功能:查询 PLIC,获取当前需要处理的中断。
场景:当处理器空闲并且有中断请求时,需要询问 PLIC 以便获取哪个中断需要被处理。PLIC 会返回当前需要处理的中断号。
应用:当操作系统或中断服务程序需要处理中断时,通过调用这个函数查询 PLIC,返回当前需要服务的中断号。
plic_complete:
功能:告知 PLIC 当前处理的中断已经完成。
场景:一旦处理程序完成了对某个中断的处理,它需要告知 PLIC,告诉 PLIC 该中断已经处理完毕,这样 PLIC 可以清除该中断请求,准备下一次的中断。
应用:处理中断后,调用这个函数告诉 PLIC 中断已处理,确保 PLIC 正确地更新中断状态。
举例:
假设我们有一个简单的操作系统,系统中有一个串口设备(UART0)和一个网络设备(Virtio0)。这两个设备可以向 CPU 发起中断,要求 CPU 处理数据:
当串口设备有数据可读时,它会触发一个中断。
当网络设备收到数据时,它也会触发一个中断。
通过 plicinit 函数,操作系统将启用这两个设备的中断。
通过 plicinithart 函数,每个处理器核心会为自己启用这些中断。
当处理器空闲时,它会通过 plic_claim 函数查询哪个设备发出了中断。
处理器响应中断并处理数据后,通过 plic_complete 告诉 PLIC 中断已经被处理。
文件系统、磁盘和日志管理fs.c
, file.c
, log.c
, virtio_disk.c
fs.c
file.c
log.c
virtue_disk.c
同步机制和锁的实现spinlock.c
, sleeplock.c
输入输出管理和字符串、格式化输出等辅助功能console.c
, uart.c
, string.c
, printf.c
console.c
模块实现了一个简单的控制台输入/输出系统,支持通过 UART(通用异步收发传输器)设备进行数据传输。主要功能包括:
UART 通常是内核空间的一部分。这里对下面的理解很重要。
#define BACKSPACE 0x100 // 定义退格符的值
#define C(x) ((x)-'@') // Control-x 宏,用于计算控制字符 如 C('P') = ^P = 16
复制代码
这个宏 C(x) 用于计算控制字符(Ctrl+x)对应的 ASCII 值。具体来说,控制字符 Ctrl+x 的 ASCII 值是 x 字符的 ASCII 值减去 '@' 的 ASCII 值。 '@' 的 ASCII 值为 64。 例如: C('P') 计算为 (P - '@'),即 80 - 64 = 16,这是 Ctrl+P 对应的 ASCII 值。 C('U') 计算为 (U - '@'),即 85 - 64 = 21,这是 Ctrl+U 对应的 ASCII 值。 这种方式使得可以很方便地计算出控制字符的 ASCII 值,而不需要手动记住每个控制字符的值。
//
// 向 uart 发送一个字符。
// 由 printf 调用,并回显输入字符,
// 但不来自 write()。
//
void
consputc(int c)
{
if(c == BACKSPACE){ // 如果是退格符
// if the user typed backspace, overwrite with a space. 如果用户输入退格符,覆盖之前的字符为一个空格,并回退光标。
uartputc_sync('\b'); // 输出退格符 这个函数在后面会讲
uartputc_sync(' '); // 输出一个空格,覆盖原字符
uartputc_sync('\b'); // 再次输出退格符,恢复光标位置
} else {
uartputc_sync(c); // 否则,直接输出字符
}
}
复制代码
讲一下第一个函数,功能就是将一个字符发送到 UART 设备。如果是退格符(可以看 ASCII 表 ),用空格覆盖当前字符并回退光标。下面的 uartputc_sync 三行主要是模拟退格操作。
举个例子:
假设用户输入了字符串 "Hello",但用户想要删除最后一个字符(o)。那么如果用户按下退格键,这段代码会被执行,效果如下: 1、用户输入:"Hello" 2、光标位置:在 o 之后 3、按下退格键后,代码执行: uartputc_sync('\b'); 让光标回到 o 字符之前的位置(光标移到 o 之前的位置)。 uartputc_sync(' '); 输出一个空格,覆盖掉 o,现在显示为 "Hell "。 uartputc_sync('\b'); 将光标再回到空格后的位置,准备进行下一次输入。 最终,显示内容变成了"Hell ",看起来就像是 o 被删除了,实际上是用空格覆盖了它,并将光标位置恢复到原位。
struct {
struct spinlock lock; // 用于多线程环境下的同步锁
// input
#define INPUT_BUF 128 // 输入缓冲区大小为128字节
char buf[INPUT_BUF]; // 缓冲区,用于存储用户输入的字符
uint r; // Read index 读 、写 、编辑的索引
uint w; // Write index
uint e; // Edit index
} cons;
复制代码
这个结构体主要是输入缓冲区配置,看注释了解即可
//
// user write()s to the console go here. 用户写入控制台的内容到这里。
// user_src:用户空间地址来源标识符。
// src:要写入的数据源地址。
// n:需要写入的字节数。
//
int
consolewrite(int user_src, uint64 src, int n)
{
int i;
acquire(&cons.lock); // 获取锁,确保多线程安全
for(i = 0; i < n; i++){ // 循环处理要写入的每一个字符
char c;
if(either_copyin(&c, user_src, src+i, 1) == -1) // 从用户空间复制字符 这个函数后面会更新 在proc.c
break;
uartputc(c); // 将字符发送到UART,这个函数后面也会更新,在uart.c
}
release(&cons.lock); // 释放锁
return i; // 返回成功写入的字符数量
}
复制代码
这个函数处理用户写入控制台的数据。它会从用户空间复制数据到内核空间并通过 UART 发送出去。
操作过程:先获取控制台的锁,确保线程安全,循环处理每一个字符,把用户空间的数据复制到内核空间,并通过 uartputc 发送到 UART,如果成功复制发送 n 个字符 就返回 n。
本来这里我以为 uartputc 是系统调用的,然后这个进程是用户函数,通过这个 uartputc 系统调用把数据发送到内核。结果不是,这里是搜集到的资料:
uartputc 不是系统调用。它是一个函数,用于通过 UART(通用异步收发传输器)发送单个字符。通常,系统调用是用户空间与内核空间之间的接口,而 uartputc 是内核内部用来进行硬件通信的函数,它直接与硬件接口进行交互。
这里回顾一下:用户进程如何向内核进程发送数据:
系统调用,IO 控制,内存映射,信号,套接字,管道和命名管道
uartputc 的作用和位置:
功能:uartputc 用于向串口设备发送单个字符。在内核中,它通过直接操作硬件寄存器来实现与硬件的通信,通常用于输出数据到串口控制台或调试信息。 内核空间:它是内核实现的函数,不是用户进程可以直接调用的系统调用,而是内核内部实现的硬件控制函数。在内核中与硬件进行通信时,通常需要直接与硬件进行交互,这类操作是通过内核函数来完成的,而用户进程是无法直接操作硬件的。
//
// 用户从控制台读取()到这里。
// 将(最多)一整行输入复制到 dst。
// user_dist 表示 dst 是用户
// 还是内核地址。
//
int
consoleread(int user_dst, uint64 dst, int n)
{
uint target;
int c;
char cbuf;
target = n; // 记录要读取的字符数量
acquire(&cons.lock);
while(n > 0){ // 当还有字符要读取时
// 等待中断处理程序将一些
// 输入放入 cons.buffer 中。
while(cons.r == cons.w){ // 如果没有新的输入
if(myproc()->killed){ // 如果进程被杀死
release(&cons.lock); // 释放锁
return -1; // 返回失败
}
sleep(&cons.r, &cons.lock); // 睡眠,直到有输入
}
c = cons.buf[cons.r++ % INPUT_BUF]; // 从缓冲区读取一个字符
if(c == C('D')){ // end-of-file // 检测到EOF(^D)
if(n < target){ // 如果剩余读取的字节数少于目标字节数
// 保留^D符号,确保下一次读取时返回0字节
cons.r--;
}
break;
}
// 将字符复制到用户空间的目标缓冲区
cbuf = c;
if(either_copyout(user_dst, dst, &cbuf, 1) == -1)
break;
dst++; // 目标地址加1,准备读取下个字节
--n; // 减少剩余读取字节数
if(c == '\n'){ // 如果读取到换行符, 读取完毕!
// a whole line has arrived, return to
// the user-level read().
break;
}
}
release(&cons.lock);
return target - n; // 返回实际读取的字节数
}
复制代码
这个函数负责从控制台读取一行输入,并将数据复制到用户空间。它读取的字符最多为 n 个,直到遇到换行符、EOF 符号或者缓冲区满为止。
操作过程为:
获取控制台的锁,确保多线程安全。 如果缓冲区 cons.buf 中没有数据,进程会进入睡眠,直到有新的字符输入。 循环读取缓冲区中的字符,直到读取到换行符、EOF 符号或缓冲区满。 将读取到的字符复制到用户空间,并返回实际读取的字节数。
if(c == C('D')){ // end-of-file // 检测到EOF(^D)
if(n < target){ // 如果剩余读取的字节数少于目标字节数
// 保留^D符号,确保下一次读取时返回0字节
cons.r--;
}
break;
}
复制代码
这段代码的核心目的是确保在读取过程中,遇到 Ctrl+D(EOF)时,能够正确处理 EOF 字符,并返回适当的读取结果。cons.r-- 让程序“保存” Ctrl+D,以便下次读取时可以返回 0 字节,表示文件结束。
**if(c == C('D'))**:
C('D') 计算的是 Ctrl+D 的 ASCII 值,即 4(即 0x04)。因此,这一行的作用是检查输入的字符 c 是否等于 Ctrl+D(EOF)。
如果当前输入的是 Ctrl+D(EOF),即字符值为 4,就会进入此条件分支。
**if(n < target)**:
这是一个嵌套的 if 判断,它检查剩余的字节数 n 是否小于目标字节数 target。
target 是读取的总字节数,而 n 是当前剩余的字节数,表示还需要读取多少字节。因此,这个判断是用来确认当前读取操作是否已经完成,如果剩余字节数少于目标字节数,则说明并没有读取完所有的字节。
**cons.r--;**:
这行代码用于调整读取位置 cons.r,使其回退一个字符(减去 1)。
之所以要回退,是因为在读取过程中,遇到 Ctrl+D(EOF)时,通常应该结束读取,并且 EOF(Ctrl+D)字符本身不应该作为有效数据返回给用户。所以,这里通过减去 1 来保留 Ctrl+D,保证在下一次读取时,Ctrl+D 会被正确处理,并且返回给上层程序 0 字节。
为什么要回退呢?
假设你正在读取一行文本,而文本的最后一个字符是 Ctrl+D。此时,Ctrl+D 被读取到并认为是 EOF,程序会结束当前的读取操作。
然而,如果直接将 EOF 返回,调用者可能会误以为没有任何输入。因此,代码通过回退一个位置(cons.r--)来“保存” EOF 字符,以便下次读取时,返回 0 字节并明确表示文件结束。
举个例子:
假设你有以下情况:
用户正在通过控制台输入字符,直到按下 Ctrl+D。 假设 target 是 128,表示目标是读取 128 个字节,而当前已经读取了部分字符,只剩下少量字节。 如果用户按下了 Ctrl+D(EOF),程序会检查当前还剩余多少字节需要读取: 如果剩余的字节数少于目标值 target,程序会把 Ctrl+D 这个字符暂时“保留”并回退读取位置(cons.r--),以便确保当下次读取时会返回 0 字节,表示文件已经结束。 然后,break 会跳出读取循环,终止读取。
//
// 控制台输入中断处理程序。
// uartintr() 调用此处理程序来输入字符。
// 执行擦除/终止处理,附加到 cons.buf,
// 如果整行已到达,则唤醒 consoleread()。
//
void
consoleintr(int c)
{
acquire(&cons.lock); // 获取锁,确保多线程安全
switch(c){
case C('P'): // 打印进程列表(^P)
procdump(); // 调用procdump()打印当前进程信息 proc.c的内容,后面更新
break;
case C('U'): // 删除整行(^U)
while(cons.e != cons.w && // 当缓冲区还有未处理的字符
cons.buf[(cons.e-1) % INPUT_BUF] != '\n'){ // 找到当前行的末尾
cons.e--; // 减少编辑索引 ?
consputc(BACKSPACE); // 输出退格符
}
break;
case C('H'): // 处理退格符(^H)
case '\x7f': // 处理删除字符(ASCII值0x7f)
if(cons.e != cons.w){ // 如果缓冲区不是空的
cons.e--; // 减少编辑索引
consputc(BACKSPACE); // 输出退格符
}
break;
default: // 普通字符处理
if(c != 0 && cons.e-cons.r < INPUT_BUF){ // 如果字符有效,且缓冲区没有满
c = (c == '\r') ? '\n' : c; // 将回车符转换为换行符
// echo back to the user.
consputc(c); // 回显字符
// 存储字符到缓冲区供 cons.read() 使用
cons.buf[cons.e++ % INPUT_BUF] = c;
// 如果遇到换行符、EOF符号或缓冲区满,唤醒 consoleread()
if(c == '\n' || c == C('D') || cons.e == cons.r+INPUT_BUF){
// wake up consoleread() if a whole line (or end-of-file)
// has arrived.
cons.w = cons.e; // 更新写索引
wakeup(&cons.r); // 唤醒等待读取的进程
}
}
break;
}
release(&cons.lock);
}
复制代码
中断处理函数,负责处理从 UART 接收的字符并执行相应的操作。它主要处理特殊字符以及一些控制字符。
参数:c:从 UART 接收到的字符
Ctrl+P:打印进程列表(通过调用 procdump)。 Ctrl+U:清除当前行(删除缓冲区中的字符)。 Ctrl+H 或 Del:删除字符(通过退格符模拟)。 普通字符:将字符回显并存储到缓冲区。如果缓冲区满或遇到换行符/EOF 符号,会唤醒等待读取的进程。
结果:控制台缓冲区 cons.buf 更新并根据需要唤醒等待的读取进程
void
consoleinit(void)
{
initlock(&cons.lock, "cons"); // 初始化控制台的自旋锁
uartinit(); // 初始化UART设备
// 注册控制台的 read 和 write 系统调用处理函数
devsw[CONSOLE].read = consoleread; // 控制台的 read 系统调用使用 consoleread
devsw[CONSOLE].write = consolewrite; // 控制台的 write 系统调用使用 consolewrite
}
复制代码
这个的功能是:初始化控制台,包括设置控制台锁、初始化 UART 设备,以及注册控制台的读写处理函数。
后面两行:注册控制台的 read 和 write 系统调用处理函数,分别关联到 consoleread 和 consolewrite 函数,确保通过系统调用执行控制台的读写操作。
uart.c
string.c
printf.c
用户与内核之间的切换trampoline.S
trampoline.S
评论