驱动工程师面试题 - 操作系统1
本文档采用口语化的面试回答风格,模拟真实面试场景中的思路和表达方式
1 进程与线程
1.1 进程与线程有什么区别?
回答思路:
进程和线程都是操作系统的执行单元,但它们有本质区别。
资源分配:
进程是资源分配的基本单位,每个进程有独立的地址空间、代码段、数据段、堆栈。线程是CPU调度的基本单位,同一进程内的线程共享进程的地址空间和资源。
开销:
创建进程的开销大,因为要分配独立的地址空间,拷贝父进程的资源。创建线程的开销小,只需要分配栈空间和线程控制块。
进程切换开销大,要切换地址空间,刷新TLB和Cache。线程切换开销小,因为共享地址空间,只需要切换寄存器和栈。
通信方式:
进程间通信需要特殊机制,像管道、消息队列、共享内存,因为地址空间隔离。线程间通信简单,直接访问共享变量就行,因为共享地址空间。
独立性:
进程之间相互独立,一个进程崩溃不影响其他进程。线程之间不独立,一个线程崩溃可能导致整个进程崩溃。
1.2 进程有哪些状态?状态之间如何转换?
回答思路:
进程最基本的是三态模型:就绪、运行、阻塞。
就绪状态(Ready):
进程已经准备好运行,所有需要的资源都有了,就等CPU分配。多个就绪进程在就绪队列里排队。
运行状态(Running):
进程正在CPU上执行指令。单核系统同一时刻只有一个进程在运行状态。
阻塞状态(Blocked/Waiting):
进程等待某个事件,比如I/O完成、信号量释放、等待锁,暂时不能运行。即使给它CPU也没用,因为条件不满足。
三态转换:
就绪到运行:调度器选中这个进程,分配CPU。
运行到就绪:时间片用完,或者被更高优先级进程抢占。进程还想运行,但CPU被拿走了。
运行到阻塞:进程主动等待事件,比如读文件、等待锁。进程调用阻塞操作,主动放弃CPU。
阻塞到就绪:等待的事件发生了,比如I/O完成、锁被释放,进程重新进入就绪队列,等待CPU分配。
注意:阻塞状态不能直接到运行状态,必须先到就绪状态,再由调度器分配CPU。
扩展的五态模型:
实际操作系统还有新建状态(进程刚创建,还没加入就绪队列)和终止状态(进程执行完毕或被杀死)。
但核心还是三态模型,理解了三态转换,就理解了进程调度的本质。
1.3 进程间通信有哪些方式?各有什么特点?
回答思路:
进程间通信有很多种方式,常用的有这几种:
管道(Pipe):
分为匿名管道和命名管道。匿名管道只能用于有亲缘关系的进程,命名管道可以用于任意进程。
特点是简单,但只能单向通信,而且容量有限。适合简单的数据传递。
消息队列:
进程可以往队列里发消息,其他进程从队列里取消息。
特点是可以实现异步通信,消息有类型,可以选择性接收。但消息大小有限制,而且要拷贝数据。
共享内存:
多个进程映射同一块物理内存,直接读写。
特点是速度最快,因为不需要拷贝数据。但需要自己处理同步问题,要配合信号量或互斥锁使用。
信号量:
主要用于进程间同步,控制对共享资源的访问。
特点是轻量级,但只能传递简单的同步信号,不能传递数据。
信号(Signal):
用于通知进程某个事件发生了,比如SIGKILL、SIGTERM。
特点是异步,但只能传递简单的通知,不能传递复杂数据。
Socket:
可以用于本机进程间通信,也可以用于网络通信。
特点是通用性强,但开销比其他方式大。
1.4 线程同步与互斥有哪些机制?各适用什么场景?
回答思路:
线程同步和互斥是并发编程的两个核心概念。互斥是保护临界区,防止多个线程同时访问共享资源。同步是协调线程执行顺序,让线程按特定顺序执行。
互斥机制:
互斥锁(Mutex):
最基本的互斥机制,保证同一时间只有一个线程能进入临界区。
适用场景:保护共享数据,临界区执行时间较长的情况。如果拿不到锁,线程会睡眠,不占用CPU。
自旋锁(Spinlock):
拿不到锁时,线程会一直循环等待,不睡眠。
适用场景:临界区执行时间很短,或者在中断上下文中,因为中断上下文不能睡眠。
读写锁(RWLock):
允许多个线程同时读,但写的时候独占。
适用场景:读多写少的情况,可以提高并发性能。
同步机制:
条件变量(Condition Variable):
配合互斥锁使用,让线程等待某个条件满足。
适用场景:生产者-消费者模型,线程需要等待某个条件才能继续执行。
信号量(Semaphore):
可以控制多个线程同时访问资源,也可以用于线程间同步。
适用场景:限制同时访问资源的线程数量(资源计数),或者线程间的执行顺序控制(同步信号)。
1.5 什么是上下文切换?开销在哪里?
回答思路:
上下文切换就是CPU从一个进程或线程切换到另一个,需要保存当前的执行状态,恢复下一个的执行状态。
切换过程:
第一步,保存当前进程的上下文,包括寄存器、程序计数器、栈指针等,保存到进程控制块(PCB)。
第二步,选择下一个要运行的进程,这是调度器的工作。
第三步,恢复下一个进程的上下文,从PCB里加载寄存器、程序计数器、栈指针等。
第四步,如果是进程切换,还要切换地址空间,刷新TLB和Cache。
开销在哪里:
第一,保存和恢复寄存器的开销。
第二,切换地址空间的开销,要刷新TLB,导致后续的内存访问会有TLB miss。
第三,Cache失效的开销。新进程的数据不在Cache里,会有很多Cache miss,性能下降。
第四,调度器选择下一个进程的开销。
如何减少上下文切换:
第一,减少线程数量,避免过度并发。
第二,使用线程池,避免频繁创建销毁线程。
第三,减少锁的竞争,降低线程阻塞的概率。
第四,使用无锁数据结构,避免线程因为等锁而切换。
2 用户态与内核态
2.1 什么是用户态和内核态?为什么要区分?
回答思路:
用户态和内核态是CPU的两种运行模式,也叫特权级。
用户态:
CPU运行在低特权级,只能执行普通指令,不能直接访问硬件,不能执行特权指令。应用程序运行在用户态。
内核态:
CPU运行在高特权级,可以执行所有指令,可以直接访问硬件,可以访问所有内存。操作系统内核运行在内核态。
为什么要区分:
核心目的是保护。
通过特权级隔离,用户程序不能直接操作硬件、不能访问内核内存、不能执行特权指令。所有对关键资源的访问都必须通过内核,内核可以检查权限、验证参数。
这样一个应用程序出错或者有恶意行为,只会影响自己,不会破坏内核或其他程序,保证了系统的稳定和安全。
如果不区分特权级,任何程序都能直接操作硬件和内存,一个程序的bug就可能导致整个系统崩溃。
2.2 用户态如何切换到内核态?有哪些方式?
回答思路:
用户态切换到内核态有三种主要方式:
系统调用:
这是最常见的方式。应用程序需要内核服务时,比如读文件、创建进程,就调用系统调用。系统调用会触发一个软中断或者陷阱指令,CPU切换到内核态,执行内核代码。
比如应用调用read函数,最终会执行系统调用,进入内核,内核驱动读取数据,再返回用户态。
硬件中断:
外部硬件设备产生中断时,CPU会自动切换到内核态,执行中断处理函数。中断是异步的,随时可能发生。
比如网卡收到数据包,产生中断,CPU切换到内核态,执行网卡驱动的中断处理函数。
异常(Exception):
程序执行出错时,CPU内部产生异常,切换到内核态,执行异常处理函数。异常是同步的,由当前指令引起。
比如程序访问空指针,产生缺页异常或段错误,CPU切换到内核态,内核发送SIGSEGV信号给进程。
这三种方式都是硬件机制,保证了用户态不能随意进入内核态,只能通过这些受控的方式。
2.3 系统调用的过程是怎样的?
回答思路:
系统调用是用户程序请求内核服务的过程,大概分这几步:
第一步,应用程序调用库函数:
比如应用调用read函数,这个read是C库提供的封装函数。
第二步,库函数准备参数:
把系统调用号和参数放到特定的寄存器里。比如ARM上,系统调用号放在r7,参数放在r0-r6寄存器。
第三步,触发软中断指令:
执行svc指令(Supervisor Call,以前叫swi)。这会触发CPU从用户态切换到内核态。
第四步,CPU切换到内核态:
保存用户态的寄存器到栈上,切换到内核栈,跳转到系统调用处理函数。
第五步,内核执行系统调用:
根据系统调用号,找到对应的内核函数,执行。比如read系统调用,会调用内核的sys_read函数,再调用具体的驱动函数。
第六步,返回用户态:
内核函数执行完毕,把返回值放到r0寄存器,恢复用户态的寄存器,切换回用户态,继续执行应用程序。
整个过程涉及两次特权级切换,有一定开销,所以频繁的系统调用会影响性能。
2.4 用户态和内核态切换的开销是什么?
回答思路:
用户态和内核态切换的开销主要在这几个方面:
保存和恢复寄存器:
切换时要保存用户态的所有寄存器,切换回来时要恢复。寄存器很多,这个操作有开销。
切换栈:
用户态有用户栈,内核态有内核栈,切换时要切换栈指针。
TLB和Cache的影响:
切换到内核态,访问的是内核地址空间,用户态的TLB和Cache可能失效。切换回用户态,又要重新加载用户态的数据到Cache,会有Cache miss。
权限检查:
内核要检查系统调用的参数是否合法,比如指针是否指向用户空间,这也有开销。
上下文切换的时间:
整个切换过程需要几百到几千个CPU周期,虽然看起来不多,但如果频繁切换,累积起来开销就大了。
如何减少开销:
第一,减少系统调用次数。比如批量读写,一次读写多个数据,而不是每次读写一个。
第二,使用mmap,把文件映射到内存,直接访问内存,避免read/write系统调用。
第三,使用用户态驱动,比如DPDK,绕过内核,直接在用户态访问硬件,避免内核态切换。
3 并发控制
3.1 什么是临界区和临界资源?
回答思路:
临界区和临界资源是并发编程中的核心概念。
临界资源:
一次只能被一个进程或线程使用的资源,就叫临界资源。比如共享变量/缓冲区、硬件寄存器、链表节点。
如果多个线程同时访问临界资源,可能导致数据不一致。比如两个线程同时对一个变量加1,结果可能只加了1次,不是2次。
临界区:
访问临界资源的那段代码,就叫临界区。
比如对共享变量count加1的代码,就是临界区。临界区必须互斥执行,同一时间只能有一个进程在临界区里。
临界区的要求:
第一,互斥访问。同一时间只有一个进程能进入临界区。
第二,有限等待。进程请求进入临界区,不能无限等待,要在有限时间内进入。
第三,空闲让进。如果没有进程在临界区,想进入的进程应该能立即进入。
第四,让权等待。进程等待进入临界区时,应该释放CPU,不要忙等。
3.2 为什么会出现死锁?如何预防?
回答思路:
死锁就是多个线程互相等待对方持有的资源,谁也无法继续执行。
常见的死锁场景:
第一,交叉持有锁。线程A持有锁1等待锁2,线程B持有锁2等待锁1,两个线程互相等待,形成死锁。
比如驱动里,线程A先锁设备A再锁设备B,线程B先锁设备B再锁设备A,就可能死锁。
第二,持有锁等待事件。线程持有锁,然后等待某个事件,但这个事件需要其他线程获取同一个锁才能触发。
比如线程A持有锁,等待条件变量,但线程B要获取这个锁才能修改条件,结果死锁。
第三,资源申请顺序不一致。多个线程以不同顺序申请多个资源,可能形成循环等待。
如何预防死锁:
第一,统一加锁顺序。给所有锁定义一个全局顺序,所有线程都按这个顺序加锁。这样就不会形成循环等待。
比如规定先锁设备A再锁设备B,所有线程都遵守这个顺序,就不会死锁。
第二,使用trylock。尝试获取锁,如果失败就释放已持有的锁,避免持有并等待。
第三,避免嵌套锁。尽量一次只持有一个锁,减少死锁可能。
第四,设置超时。等待锁时设置超时,超时就放弃,避免无限等待。
3.3 互斥锁与自旋锁有什么区别?各适用什么场景?
回答思路:
互斥锁和自旋锁都是用来保护临界区的,但实现方式不同。
互斥锁(Mutex):
拿不到锁时,线程会睡眠,进入阻塞状态,释放CPU。等锁被释放时,线程被唤醒,重新竞争锁。
优点是不占用CPU,适合临界区执行时间较长的情况。
缺点是睡眠和唤醒有开销,涉及上下文切换。
自旋锁(Spinlock):
拿不到锁时,线程会一直循环检查锁是否被释放,不睡眠,一直占用CPU。
优点是没有上下文切换的开销,如果临界区很短,很快就能拿到锁。
缺点是一直占用CPU,如果临界区执行时间长,会浪费CPU资源。
使用场景:
互斥锁适用于:临界区执行时间较长,或者在用户态程序中。
自旋锁适用于:临界区执行时间很短(几十个指令),或者在中断上下文中(因为中断上下文不能睡眠)。
驱动开发中的选择:
内核驱动里,如果临界区很短,用自旋锁。如果临界区可能睡眠,或者执行时间长,用互斥锁。
中断处理函数里,只能用自旋锁,因为中断上下文不能睡眠。
3.4 信号量的P/V操作是什么?如何使用?
回答思路:
信号量是一个整数变量,用来控制对共享资源的访问。
P操作(wait操作):
信号量减1,如果结果小于0,进程阻塞等待。如果结果大于等于0,进程继续执行。
P操作表示申请资源,如果资源不够,就等待。
V操作(signal操作):
信号量加1,如果有进程在等待,唤醒一个进程。
V操作表示释放资源,如果有进程在等待,就唤醒它。
信号量的类型:
二值信号量:只有0和1两个值,相当于互斥锁。
计数信号量:可以有多个值,用来控制多个资源。
使用场景:
第一,互斥访问。用二值信号量保护临界区,初始值为1。进入临界区前P操作,出来后V操作。
第二,资源计数。比如驱动接收数据帧,用计数信号量记录待处理的帧数,初始值为0。驱动每收到一帧数据V操作,进程每处理一帧数据P操作。信号量的值就表示当前有多少帧数据待处理。
3.5 读写锁是什么?什么时候用?
回答思路:
读写锁是一种特殊的锁,允许多个线程同时读,但写的时候独占。
读锁(共享锁):
多个线程可以同时持有读锁,只要没有线程持有写锁。
写锁(排他锁):
只有一个线程可以持有写锁,而且持有写锁时,不能有其他线程持有读锁或写锁。
规则:
读-读不互斥,可以并发。
读-写互斥,不能并发。
写-写互斥,不能并发。
使用场景:
读多写少的情况。比如配置数据,大部分时间是读取,偶尔才修改。用读写锁可以提高并发性能,多个线程可以同时读取。
如果用普通互斥锁,即使都是读操作,也只能一个一个来,性能就差了。
注意事项:
第一,写操作可能饥饿。如果一直有读操作,写操作可能一直拿不到锁。有些实现会给写操作更高优先级。
第二,如果写操作很频繁,读写锁的开销可能比普通互斥锁还大,因为读写锁的实现更复杂。
4 中断处理
4.1 中断的基本概念是什么?中断处理流程是怎样的?
回答思路:
中断是硬件通知CPU有事件发生的机制,让CPU暂停当前工作,去处理这个事件。
中断的基本概念:
中断是异步的,CPU不知道什么时候会发生。比如网卡收到数据包,就产生中断,通知CPU处理。
中断有中断号,不同的硬件设备有不同的中断号。CPU根据中断号找到对应的中断处理函数。
中断处理流程:
第一步,硬件产生中断信号,发送给中断控制器。
第二步,中断控制器判断中断优先级,如果优先级够高,就通知CPU。
第三步,CPU保存当前的执行状态,包括程序计数器、寄存器、标志位等,保存到栈上。
第四步,CPU关闭中断(或者只关闭同级和低级中断),防止中断嵌套。
第五步,CPU根据中断号,查中断向量表,找到中断处理函数的地址。
第六步,跳转到中断处理函数,执行中断处理代码。
第七步,中断处理完毕,恢复之前保存的执行状态,开启中断。
第八步,CPU继续执行被中断的程序。
整个过程是硬件和软件配合完成的,硬件负责保存状态、查表、跳转,软件负责具体的中断处理逻辑。
4.2 中断上下文与进程上下文有什么区别?
回答思路:
中断上下文和进程上下文是内核的两种执行环境,核心区别是内核是否代表某个特定进程执行。
进程上下文:
内核代码代表某个特定进程执行,知道当前是哪个进程。比如进程调用系统调用,进入内核,内核知道是哪个进程发起的调用,这就是进程上下文。
进程上下文可以睡眠,可以被调度,可以访问用户空间(通过copy_from_user等函数)。
中断上下文:
内核代码响应中断,不代表任何特定进程。中断随时可能发生,内核不知道当前是哪个进程在运行。
中断上下文不能睡眠,不能被调度,不能访问用户空间。
主要区别:
第一,中断上下文不能睡眠。因为中断不属于任何进程,没有进程上下文可以切换。如果中断处理函数睡眠了,就没法唤醒,系统会卡死。
第二,中断上下文不能访问用户空间。因为中断发生时,不知道是哪个进程在运行,用户空间的地址可能无效。
第三,中断上下文要尽快执行完毕。因为中断期间,可能关闭了中断,其他中断无法响应,影响系统实时性。
编程限制:
中断处理函数里,不能调用可能睡眠的函数,比如mutex_lock、kmalloc(GFP_KERNEL)、copy_from_user。
只能用不会睡眠的函数,比如spin_lock、kmalloc(GFP_ATOMIC)。
4.3 什么是中断嵌套?会有什么问题?如何避免?
回答思路:
中断嵌套就是在处理一个中断时,又来了另一个中断,打断当前的中断处理。
中断嵌套的问题:
第一,增加复杂度。中断处理函数可能被自己打断,要考虑重入问题。
第二,共享数据风险。如果中断处理函数访问共享数据,嵌套可能导致数据不一致。
第三,栈溢出风险。每次中断嵌套都要保存上下文到栈上,嵌套太深可能栈溢出。
如何避免中断嵌套:
第一,关闭中断。进入中断处理函数时,关闭当前CPU的中断,这样就不会被其他中断打断。Linux默认就是这样做的。
第二,使用中断下半部。把耗时的工作放到下半部(tasklet、workqueue),上半部只做最紧急的事,快速返回。这样中断处理时间短,即使关中断也不影响系统。
第三,使用自旋锁保护。如果必须允许中断嵌套,访问共享数据时用spin_lock_irqsave保护,它会关闭中断并加锁,保证数据安全。
4.4 什么是中断的上半部和下半部?为什么要分开?
回答思路:
中断处理分为上半部和下半部,是为了平衡实时性和效率。
上半部(Top Half):
就是中断处理函数本身,响应中断时立即执行。
上半部要尽快执行完毕,只做最紧急的工作,比如读取硬件状态、清除中断标志、把数据放到缓冲区。
上半部运行在中断上下文,不能睡眠,要关闭中断或者禁止抢占。
下半部(Bottom Half):
把耗时的工作推迟到下半部执行,比如数据处理、协议栈处理、唤醒进程。
下半部运行在进程上下文或软中断上下文,可以睡眠(如果用workqueue),可以被调度。
为什么要分开:
第一,提高实时性。如果中断处理函数执行时间长,其他中断就无法及时响应,影响系统实时性。分开后,上半部很快执行完,其他中断可以及时响应。
第二,避免关中断时间过长。上半部执行时,通常要关闭中断,如果时间长,会影响其他中断。
第三,提高并发性。下半部可以在多个CPU上并发执行,提高效率。
Linux的下半部机制:
Softirq(软中断):优先级最高,但使用复杂,一般内核子系统用。
Tasklet:基于softirq实现,使用简单,驱动常用。
Workqueue(工作队列):运行在进程上下文,可以睡眠,适合耗时操作。
4.5 中断处理函数有什么限制?
回答思路:
中断处理函数运行在中断上下文,有很多限制。
不能睡眠:
这是最重要的限制。不能调用任何可能睡眠的函数,比如mutex_lock、semaphore、wait_event、kmalloc(GFP_KERNEL)、copy_from_user。
因为中断上下文不属于任何进程,睡眠了就没法唤醒。
不能访问用户空间:
不能调用copy_from_user、copy_to_user,因为中断发生时,不知道是哪个进程在运行,用户空间地址可能无效。
要尽快执行完毕:
中断处理函数要尽量短,只做必要的工作。耗时的工作要放到下半部。
因为中断处理期间,可能关闭了中断,影响其他中断响应。
不能被调度:
中断处理函数不能主动放弃CPU,不能调用schedule。
要注意重入问题:
如果允许中断嵌套,中断处理函数可能被自己打断,要保护共享数据。
5 内存管理
5.1 什么是虚拟内存?为什么需要虚拟内存?
回答思路:
虚拟内存是操作系统提供的一种内存管理机制,让每个进程都有独立的地址空间。
什么是虚拟内存:
虚拟内存是指CPU访问的地址都是虚拟地址,不是物理地址。这些虚拟地址经过MMU(内存管理单元)查页表转换后,才变成物理地址去访问实际的物理内存。
每个进程都有独立的虚拟地址空间和页表。比如进程A和进程B都可以有地址0x1000,但通过各自的页表映射到不同的物理地址,互不干扰。
为什么需要虚拟内存:
第一,进程隔离。每个进程有独立的页表,进程切换时也切换页表。这样每个进程只能访问自己页表映射的物理内存,无法访问其他进程的物理内存,实现了进程隔离。
第二,内存保护。可以给内存页设置权限,比如只读、可写、可执行,还可以设置用户态/内核态权限。用户态进程不能访问内核页,访问违反权限的内存会触发异常。
第三,支持更大的地址空间。32位系统可以有4GB的虚拟地址空间,即使物理内存只有1GB。通过页面交换,可以把不常用的页面换出到磁盘。
第四,内存共享。多个进程可以把虚拟地址映射到同一块物理内存,实现共享内存通信。
5.2 虚拟地址如何转换成物理地址?
回答思路:
虚拟地址到物理地址的转换是通过页表完成的,由MMU硬件自动完成。
地址结构:
虚拟地址分为两部分:页号和页内偏移。
比如32位系统,4KB页面,虚拟地址的高20位是页号,低12位是页内偏移。
转换过程:
第一步,CPU发出虚拟地址。
第二步,MMU提取虚拟地址的页号。
第三步,MMU查页表,根据页号找到对应的物理页框号。
第四步,MMU把物理页框号和页内偏移组合,得到物理地址。
第五步,MMU访问物理地址,读取或写入数据。
5.3 MMU的作用是什么?
回答思路:
MMU(Memory Management Unit)是CPU里的一个硬件单元,负责虚拟地址到物理地址的转换和内存保护。
主要功能:
第一,地址转换。把虚拟地址转换成物理地址,通过查页表完成。这是MMU最核心的功能。
第二,内存保护。检查内存访问权限,比如只读页不能写,用户态不能访问内核空间。如果违反权限,MMU产生异常,CPU跳转到异常处理函数。
工作过程:
CPU发出虚拟地址,MMU自动查TLB,如果命中,直接得到物理地址。如果TLB miss,MMU查页表,找到物理地址,同时更新TLB。
如果页表项无效,MMU产生缺页异常,操作系统处理。
5.4 什么是内存分页?页表是什么?
回答思路:
内存分页就是把内存分成一块一块固定大小的页面,一般是4KB。
为什么要分页:
虚拟内存需要建立虚拟地址到物理地址的映射。如果按字节映射,映射表会非常大。分页后,只需要记录每个页的映射关系,大大减少了映射表的大小。
页和页框:
虚拟地址空间分成的块叫"页"(Page),物理内存分成的块叫"页框"(Page Frame)。
比如虚拟地址的第0页可以映射到物理内存的第5页框,第1页映射到第3页框。虚拟地址连续,但物理地址可以不连续。
页表:
页表就是记录"虚拟页号→物理页框号"映射关系的表。
每个进程有自己的页表。MMU根据虚拟地址的页号查页表,找到对应的物理页框号,再加上页内偏移,就得到了物理地址。
页表项除了物理页框号,还有权限标志位,比如可读、可写、可执行、用户态/内核态等。
5.5 Cache是什么?为什么需要?工作原理是什么?
回答思路:
Cache是CPU和内存之间的高速缓存,用来加速内存访问。
为什么需要Cache:
CPU速度很快,内存速度慢,差距很大。CPU直接访问内存太慢,会浪费很多时间等待。
Cache速度接近CPU,虽然容量小,但因为程序有局部性(访问的数据集中在一小块区域),所以够用。
Cache怎么工作:
CPU要读取数据时,先看Cache里有没有。
如果有(Cache命中),直接从Cache读,很快。
如果没有(Cache miss)或Cache被标记为无效(比如被手动invalidate,或者被硬件标记),从内存读取,同时把数据放到Cache里,下次访问就快了。
CPU要写入数据时,也是先写到Cache。
至于什么时候写回内存,取决于Cache的写策略(Write-through立即写回,Write-back延迟写回),也可以手动flush立即写回。
Cache Line:
Cache不是按字节管理的,而是按Cache Line管理,一般是64字节。
读一个字节时,会把整个64字节的Cache Line都加载进来。这样相邻的数据下次访问就不用再去内存读了。
Cache属性:
内存区域可以设置不同的Cache属性:
Cacheable(可缓存):正常内存区域,可以使用Cache加速访问。
Non-cacheable(不可缓存):直接访问内存,不经过Cache。一般用于硬件寄存器、DMA缓冲区。
Write-through(写穿透):写操作同时更新Cache和内存。
Write-back(写回):写操作只更新Cache,稍后再写回内存。
刷Cache和无效Cache:
Flush(刷Cache):把Cache里修改过的数据写回内存。DMA发送数据前要flush,保证内存里是最新数据。
Invalidate(无效Cache):把Cache里的数据标记为无效,下次访问会重新从内存读。DMA接收数据后要invalidate,保证读到的是DMA写入的新数据,而不是Cache里的旧数据。
6 调度算法
6.1 什么是进程调度?调度的时机有哪些?
回答思路:
进程调度就是操作系统决定让哪个进程在CPU上运行。多个进程轮流在CPU上执行,每个进程运行一段时间,然后切换到下一个进程。
为什么需要调度:
CPU数量有限,但进程很多。通过调度,让多个进程轮流使用CPU,实现"并发"执行。
调度的时机:
第一,进程主动阻塞。进程等待I/O、等待锁、调用sleep等,主动放弃CPU,进入阻塞状态。调度器选择其他就绪进程运行。
第二,时间片用完。分时系统中,每个进程分配一个时间片。时间片用完后,即使进程还想继续运行,也要切换到其他进程,保证公平。
第三,进程被唤醒。阻塞的进程等待的事件发生了(I/O完成、锁被释放),进程被唤醒进入就绪状态。如果它的优先级更高,可能立即抢占当前进程。
第四,进程创建或终止。新进程创建后进入就绪状态,可能触发调度。进程执行完毕或被杀死,调度器选择下一个进程运行。
第五,中断返回。中断处理完毕返回时,调度器检查是否需要切换进程。比如定时器中断可能导致时间片用完。
6.2 常见的调度算法有哪些?各有什么特点?
回答思路:
常见的调度算法有这几种:
先来先服务(FCFS):
按照进程到达的顺序调度,先到先执行。
优点是简单公平,实现容易。
缺点是平均等待时间长,如果前面有个长进程,后面的短进程要等很久。不适合交互式系统。
最短作业优先(SJF):
选择执行时间最短的进程先执行。
优点是平均等待时间最短,理论上最优。
缺点是要预知进程执行时间,实际很难做到。而且长进程可能饿死。
时间片轮转(RR):
每个进程分配一个时间片,时间片用完就切换到下一个进程,循环执行。
优点是公平,响应时间好,适合分时系统。
缺点是上下文切换开销,时间片太小开销大,时间片太大响应慢。
优先级调度:
每个进程有优先级,选择优先级最高的进程执行。
优点是灵活,可以根据重要性分配CPU。
缺点是低优先级进程可能饿死。可以用优先级老化解决,等待时间长了就提高优先级。
多级反馈队列:
多个就绪队列,不同队列不同优先级和时间片。新进程进入高优先级队列,如果时间片用完还没执行完,降到下一级队列。
优点是综合了多种算法的优点,既照顾短进程,又不会饿死长进程。
缺点是实现复杂。
Linux用的是CFS(完全公平调度器),基于虚拟运行时间,保证每个进程获得公平的CPU时间。
6.3 什么是实时调度?与普通调度有什么区别?
回答思路:
实时调度是为实时系统设计的调度算法,保证任务在规定时间内完成。
实时系统的特点:
实时系统要求任务在截止时间(Deadline)前完成,否则就是失败。
比如汽车的刹车系统,必须在几毫秒内响应,晚了就出事故。
硬实时和软实时:
硬实时:任务必须在截止时间前完成,超时就是系统失败。比如飞机控制系统。
软实时:任务最好在截止时间前完成,偶尔超时可以接受。比如视频播放,偶尔丢帧可以接受。
实时调度算法:
最早截止时间优先(EDF):选择截止时间最早的任务执行。
速率单调调度(RMS):周期任务,周期越短优先级越高。
与普通调度的区别:
普通调度追求公平、吞吐量、响应时间,但不保证截止时间。
实时调度追求可预测性,保证任务在截止时间前完成。
实时调度要做可调度性分析,确保所有任务都能按时完成。如果不能,就拒绝接受新任务。
6.4 什么是抢占式调度和非抢占式调度?
回答思路:
抢占式和非抢占式是调度的两种方式。
非抢占式调度:
进程一旦获得CPU,就一直执行,直到主动放弃CPU(阻塞、终止)或时间片用完。
其他进程不能强制抢占CPU。
优点是实现简单,上下文切换少。
缺点是响应时间差,如果一个进程一直占用CPU,其他进程要等很久。
抢占式调度:
高优先级进程可以抢占低优先级进程的CPU。或者时间片用完,强制切换。
优点是响应时间好,高优先级任务能及时执行。
缺点是上下文切换多,实现复杂,要处理临界区保护。
现代操作系统的选择:
Linux、Windows都是抢占式调度,保证响应时间和公平性。
RTOS也是抢占式调度,保证高优先级任务及时执行。
但内核态代码有些地方不能抢占,比如持有自旋锁时,要禁止抢占。
6.5 优先级调度可能出现什么问题?如何解决?
回答思路:
优先级调度最大的问题是优先级反转和饥饿。
优先级反转:
高优先级任务等待低优先级任务持有的资源,而低优先级任务被中优先级任务抢占,导致高优先级任务长时间等待。
比如任务H(高优先级)等待锁,锁被任务L(低优先级)持有。任务M(中优先级)抢占了任务L,任务L无法释放锁,任务H就一直等待。
这就是优先级反转,高优先级任务反而比中优先级任务慢。
解决方法:
优先级继承:任务L持有锁时,如果任务H等待这个锁,就临时提高任务L的优先级到任务H的优先级。任务L释放锁后,恢复原来的优先级。
优先级天花板:给每个锁设置一个优先级天花板,等于所有可能持有这个锁的任务的最高优先级。任务持有锁时,优先级提升到天花板。
饥饿问题:
低优先级任务可能一直得不到执行,因为总有高优先级任务。
解决方法:
优先级老化:任务等待时间长了,就逐渐提高优先级,最终能得到执行。
公平调度:保证每个任务都能获得一定的CPU时间,不管优先级。