你好,我是编辑宇新。
春节将至,先给你拜个早年:愿你2022年工期变长,需求变少,技术水平更加硬核。
距离我们专栏更新结束已经过去了不少时间,给坚持学习的你点个赞。学习操作系统是一个长期投资,需要持之以恒,才能见效。无论你是二刷、三刷的朋友,还是刚买课的新同学,都建议你充分利用留言区,给自己的学习加个增益buff。这种学习讨论的氛围,也会激励你持续学习。
今天这期加餐,我们整理了课程里的思考题答案,一次性发布出来,供你对照参考,查漏补缺。
建议你一定要先自己学习理解,动脑思考、动手训练,有余力还可以看看其他小伙伴的解题思路,之后再来对答案。
Q:为了实现C语言中函数的调用和返回功能,CPU实现了函数调用和返回指令,即上图汇编代码中的“call”,“ret”指令,请你思考一下:call和ret指令在逻辑上执行的操作是怎样的呢?
A:一般函数调用的情况下call和ret指令在逻辑上执行的操作如下:
1.将call指令的下一条指令的地址压入栈中;
2.将call指令数据中的地址送入IP寄存器中(指令指针寄存器),该地址就是被调用函数的地址;
3.由于IP寄存器地址设置成为被调用函数的地址,CPU自然跳转到被调用函数处开始执行指令;
4.在被调用函数的最后都有一条ret指令,当CPU执行到ret指令时,就从栈中弹出一个数据到IP寄存器,而这个数据通常是先前执行call指令的下一条指令的地址,即实现了函数返回功能。
Q:以上printf函数定义,其中有个形式参数很奇怪,请你思考下:为什么是“…”形式参数,这个形式参数有什么作用?
A:在C语言中经常使用printf(“%s :%d”,“number is :”,20);printf(“%x :%d”,0x10,20);printf(“%x,%x :%d”,0xba,0xff,20);可以看出,这些printf函数参数个数都不同,因为C语言的特性支持变参函数。而“…”表示支持0个和多个参数,C语言是通过调用者传递参数的,刚好支持这种变参函数。
Q:其实我们的内核架构不是我们首创的,它是属于微内核、宏内核之外的第三种架构,请问这是什么架构?
A:我们的内核架构是混合内核架构,是介于微、宏架构之间的一种架构,这种架构保证了宏架构的高性能又兼顾了微架构的可移植、可扩展性。
Q:Windows NT内核属于哪种架构类型?
A:Windows NT内核架构其实既不属于传统的宏内核架构,也不是新的微内核架构,说NT是微内核架构是错误的,NT这种内核架构其实是宏内核的变种——混合内核。
Q:请问实模式下能寻址多大的内存空间?
A:由于实模式下访问内存的地址是这样产生的:16位段寄存器左移4位,加一个16位通用寄存器,最后形成了20位地址,所以只能访问1MB大的内存空间。
Q:分页模式下,操作系统是如何对应用程序的地址空间进行隔离的?
A:操作系统会给每个应用程序都配置独立的一套页表数据。应用程序运行时,就让CR3寄存器指向该应用程序的页表数据。运行下一个应用程序时,则会执行同样的操作。
Q:请你思考一下,如何写出让CPU跑得更快的代码?由于Cache比内存快几个数量级,所以这个问题也可以转换成:如何写出提高Cache命中率的代码?
A:第一,定义变量时,尽量让其地址与Cache行大小对齐。
int a __attribute__((aligned (64)));
int b __attribute__((aligned (64)));
第二,操作数据时的顺序,尽量和数据在内存中布局顺序保持一致。
int arr[M][M];
for(int i = 0; i < M; i++) {
for(int k = 0; k < M; k++) {
arr[i][k] = 0;
}
}
//而非这样
for(int i = 0; i < M; i++) {
for(int k = 0; k < M; k++) {
arr[k][i] = 0;
}
}
第三,尽量少用全局变量。
Q:请用代码展示一下自旋锁或者信号量,可能的使用形式是什么样的?
A:最常规的形式是在设计共享数据结构时,在其中包含自旋锁或者信号量。
如下所示:
typedef struct s_DATA
{
spinlock_t d_lock;
sem_t d_sem;
int a;
int b;
long state;
}data_t;
data_t da;
do_da_write(data_t* d)
{
x86_spin_lock(&d->d_lock);
d->a = 0;
d->b = 1;
d->state = 2;
x86_spin_unlock(&d->d_lock);
}
do_da_sem_write(data_t* d)
{
krlsem_down(&d->d_sem);
d->a = 20;
d->b = 10;
d->state = 4;
krlsem_up(&d->d_sem);
}
do_da_write(&da);
do_da_sem_write(&da);
Q:请试着回答:上述Linux的读写锁,支持多少个进程并发读取共享数据?这样的读写锁有什么不足?
A:第一个问题,根据上述描述,读写锁本质上就是一个计数器。锁变量的初始值为0x01000000,即表示最多可以有0x01000000个进程同时获取读锁。
第二个问题,读写锁的不足是,如果一直有很多读取数据的进程占有读锁,因而可能导致修改数据的进程饥饿的情况。操作系统会加以控制,让修改数据的进程优先得锁。
Q:请问,我们为什么要把虚拟硬盘格式化成ext4文件系统格式呢?
A:有两点原因。第一,GRUB在加载系统映像文件时,能够识别ext4文件系统格式;二,我们在Linux下生成系统映像文件时,要复制到虚拟硬盘中去,所以这个文件系统格式必须被Linux所识别,那么选ext4就最合适。
Q:请问GRUB头中为什么需要_entry标号和_start标号的地址?
A:这是GRUB规定的。GRUB正是通过_entry标号和_start标号的地址,控制内核文件被加载到什么内存地址,又应该从什么内存地址开始运行。
Q:请你想一下,init_bstartparm()函数中的init_mem820()函数,这个函数到底干了什么?
A:init_mem820()函数是把e820map_t结构数组复制到内核文件之后的内存空间,并且重新填写了机器信息结构。
它的代码如下:
void init_meme820(machbstart_t *mbsp)
{
//源e820map_t结构数组地址
e820map_t *semp = (e820map_t *)((u32_t)(mbsp->mb_e820padr));
//e820map_t结构数组元素个数
u64_t senr = mbsp->mb_e820nr;
//获取下一段空闲内存空间的首地址,即e820map_t结构数组的新地址
e820map_t *demp = (e820map_t *)((u32_t)(mbsp->mb_nextwtpadr));
//检查地址空间冲突
if (1 > move_krlimg(mbsp, (u64_t)((u32_t)demp), (senr * (sizeof(e820map_t)))))
{
kerror("move_krlimg err");
}
//复制
m2mcopy(semp, demp, (sint_t)(senr * (sizeof(e820map_t))));
//并重新填写了对应的机器信息结构字段
mbsp->mb_e820padr = (u64_t)((u32_t)(demp));
mbsp->mb_e820sz = senr * (sizeof(e820map_t));
mbsp->mb_nextwtpadr = P4K_ALIGN((u32_t)(demp) + (u32_t)(senr * (sizeof(e820map_t))));
mbsp->mb_kalldendpadr = mbsp->mb_e820padr + mbsp->mb_e820sz;
return;
}
Q:请你画出Cosmos硬件抽象层的函数调用关系图。
A:Cosmos硬件抽象层的函数调用关系图如下。
Q:为什么要用C代码mkpiggy程序生成piggy.S文件,并包含vmlinux.bin.gz文件呢?
A:因为mkpiggy程序在读取vmlinux.bin.gz文件,知道了其长度等信息,它就会把这些信息保存在piggy.S文件相关的字段中。在解压vmlinux.bin.gz文件时,解压的代码需要用到这些信息。
Q:你能指出上文中Linux初始化流程里,主要函数都被链接到哪些对应的二进制文件中了?
A:它们的链接结构如下。
1._start、main函数链接在setup.elf文件中,而setup.elf文件生成了setup.bin。
2.startup_32、startup_64、extract_kernel链接在linux/arch/x86/boot/compressed目录下的vmlinux文件中,而这个文件生成了vmlinux.bin。
3.Linux内核的startup_64、x86_64_start_kernel、start_kernel、arch_call_rest_init、rest_init、kernel_init、try_to_run_init_process、run_init_process函数链接在顶层linux目录下的vmlinux中。这是一个elf格式的文件,由objcoopy去除符号信息后用压缩工具压缩,包含到piggy.S中,从而形成了piggy.o,最终和其它文件一起生成了vmlinux.bin。
Q:我们为什么要以 2 的(0~52)次方为页面数来组织页面呢?
A:以2的(0~52)次方为页面数来组织页面,是为了每组连续的页面能对半分割。对半分割是为了保证连续的页面空间最大化,同时保证在下一次释放时,能最大可能地合并一个整体,这么做的目的只有一个:在满足最小、最大页面请求时,保证内存碎片的最小化。
Q:请问在 4GB 的物理内存的情况下,msadsc_t 结构实例变量本身占用多大的内存空间?
A:4GB有1M个页面,那就对应1M个msadsc_t结构,每个msadsc_t结构为40个字节,所以占用40MB的内存空间。
Q:在内存页面分配过程中,是怎样尽可能保证内存页面连续的呢?
A:因为分配内存页面一开始就是连续的,然后在分配时始终以2的幂次分隔,所以能保证内存页面的最大连续性。
Q:为什么我们在分配内存对象大小时,要按照Cache行大小的倍数分配呢?
A:因为这使得我们分配的内存对象的地址空间是和Cache行对齐的,那么这个内存对象中的数据就极有可能被Cache命中,从而大大提升程序的性能。
Q:请问内核虚拟地址空间为什么有一个 0xFFFF800000000000~0xFFFF800400000000 的线性映射区呢?
A:内核的线性映射区0xFFFF800000000000~0xFFFF800400000000,会映射到物理地址空间的0~~0x400000000。因为内核本身运行在虚拟地址空间,本身使用虚拟地址,但是它又必须访问物理内存,所以有了这个线性映射区,就可以把这个区域的物理地址转换成虚拟地址,也可以直接把虚拟地址转换成物理地址。
另外,因为它们之间就是一个常数:0xFFFF800000000000。所以,内核就可以很方便地操作自身数据结构和设备寄存器。这个设备寄存器是物理地址,内核很方便就能转换为虚拟地址,然后通过这个虚拟地址访问设备寄存器。
Q:请问,x86 CPU 的缺页异常,是第几号异常?缺页的地址保存在哪个寄存器中?
A:x86 CPU的缺页异常,是14号异常。缺页的地址保存在x86 CPU的CR2寄存器中。
Q:在默认配置下,Linux伙伴系统能分配多大的连续物理内存?
A:Linux伙伴系统能分配多大的连续物理内存,取决于MAX_ORDER。MAX_ORDER的值默认为11,因为是free_area数组的下标,所以要MAX_ORDER-1 = 10, 结果就是2 << 10 = 1024,而1024个连续的页面(一个页面4KB)是4MB,即Linux伙伴系统能分配多大的连续物理内存是4MB。
Q:Linux的SLAB,使用kmalloc函数能分配多大的内存对象呢?
A:kmalloc函数能分配32MB的内存对象。
Q:各个进程是如何共享同一份内核代码和数据的?
A:只需要将每个进程的上半部分虚拟地址空间(0xFFFF800000000000~0xFFFFFFFFFFFFFFFF)的MMU页表设为相同的映射关系就行了,这样每个进程都可以共享内核的代码的数据,但是又不能读取和修改这部分地址空间中的数据,因为权限不够。
Q:请问当调度器函数调度到一个新建进程时,为何要进入 retnfrom_first_sched 函数呢?
A:因为新建的进程内核中只有CPU默认的寄存器状态,没有从内核其它任何位置调用进入krlschedul函数。因此没有调用krlschedul函数的调用路径,所以无从返回,只能通过retnfrom_first_sched函数,强制初始化CPU寄存器状态,从而让进程开始运行。
Q:我们让进程进入等待状态后,进程会立马停止运行吗?
A:进程不会立马停止运行,因为在调用krlsched_wait函数后,进程的上下文并没有切换。需要在krlsched_wait函数的外层,通过调用krlschedul函数进行进程调度,才能让该进程停止运行,进入等待状态。
Q:想一想,Linux 进程的优先级和 Linux 调度类的优先级是一回事儿吗?
A:不是一回事儿。一个调度类管理着同一类的多个进程,而进程的优先级是该调度类下的各个进程间的优先级。
Q:请你写出一个用来访问设备的接口函数,或者想一下访问一个设备需要什么参数。
A:比如打开一个设备的接口函数,如下所示:
int open(devid_t *devid, uint_t flgs);
必须至少要有设备的devid参数。里面要包含设备的类型和设备号,这样才能找到一个具体的设备。
Q:请你写出帮驱动程序开发者自动分配设备ID接口函数。
A:很明显,这需要驱动程序提供一个设备类型,然后到设备表中搜索该设备类型还没有占用的设备ID,最后返回这个设备ID。代码如下所示:
drvstus_t krlnew_devid(devid_t *devid)
{
device_t *findevp;
drvstus_t rets = DFCERRSTUS;
cpuflg_t cpufg;
list_h_t *lstp;
devtable_t *dtbp = &osdevtable;//获取设备表
uint_t devmty = devid->dev_mtype;
uint_t devidnr = 0;
if (devmty >= DEVICE_MAX)
{
return DFCERRSTUS;
}
krlspinlock_cli(&dtbp->devt_lock, &cpufg);
if (devmty != dtbp->devt_devclsl[devmty].dtl_type)
{
rets = DFCERRSTUS;
goto return_step;
}
//检查这个设备类型链表是不是为空
if (list_is_empty(&dtbp->devt_devclsl[devmty].dtl_list) == TRUE)
{
rets = DFCOKSTUS;
devid->dev_nr = 0;
goto return_step;
}
//扫描该设备类型链表下的所有设备
list_for_each(lstp, &dtbp->devt_devclsl[devmty].dtl_list)
{
findevp = list_entry(lstp, device_t, dev_intbllst);
if (findevp->dev_id.dev_nr > devidnr)
{
//获取最大的设备号
devidnr = findevp->dev_id.dev_nr;
}
}
//新的设备号等于最大设备号加一
devid->dev_nr = devidnr++;
rets = DFCOKSTUS;
return_step:
krlspinunlock_sti(&dtbp->devt_lock, &cpufg);
return rets;
}
Q:请你想一想,为什么没有 systick 设备这样周期性的产生中断,进程就有可能霸占 CPU 呢?
A:如果一个应用程序,它不调用任何系统接口,也不退出系统,就在主函数中执行一个死循环,这样这个进程一旦运行,内核将再也没有办法从应用手中夺回CPU,其代码如下:
void main()
{
for(;;);
return;
}
Q:为什么无论是我们加载miscdrv.ko内核模块,还是运行App测试,都要在前面加上sudo呢?
A:Linux系统的安全是基于用户类型的,并且是多用户的系统,所以有些影响系统的操作,必须要root用户才能完成。比如你加载一个内核模块,这个内核模块是不是友好的?会不会干坏事?这需要管理员root用户评估;应用程序访问设备,同样是系统特权操作,也需要root用户,而sudo命令就是暂时让应用以root用户运行。
Q:请问,我们文件系统的储存单位为什么要自定义一个逻辑储存块?
A:有两点考量:一是储存设备都按块为单位储存;二是为了文件系统代码的可移植性和可扩展性。因为储存设备的储存块大小各不相同,有512B、1KB、2KB、4KB,我们自己定义一个逻辑储存块,就能很好地适应不同的储存设备。
Q:请问,建立文件系统的超级块、位图、根目录的三大函数的调用顺序可以随意调换吗,原因是什么?
A:不能随意调换,因为建立位图要依赖于超级块,而建立根目录时需要依赖于位图,所以必须是先调用建立超级块的函数,然后调用建立位图的函数,最后调用建立根目录的函数。
Q:请你想一想,我们这个简单的、小的,却五脏俱全的文件系统有哪些限制?
A:我们这个文件系统有如下限制:
Q:请说一说 super_block,dentry,inode 这三个数据结构 ,一定要在储存设备上对应存在吗?
A:不一定要在储存设备上对应存在,具体的文件系统可以有自己的实现,但是在运行时刻必须要能转换成内存中对应的super_block,dentry,inode这三大数据结构。转换方法由具体文件系统实现。
Q:我们这节课从宏观的角度分析了网络数据的运转,但是在内核中网络数据包怎么运转的呢?请你简单描述这个过程。
A:内核网络数据包处理流程如下:
Q:我们已经了解到了操作系统内核和网络协议栈的关系,可是网络协议栈真的一定只能放在内核态实现么?
A:我们发现传统的收发方式有一些弊端,比如:内核态用户态切换会引入Cache Miss、流水线失效、硬中断、锁、额外的拷贝等等额外开销。这些开销在C10K的并发规模可能无法体现出来,可是一旦到了C10M的规模,这些开销就不容小视了,于是DPDK这种用户态网络栈就应运而生了。
Q:请思考一下,我们目前的互联网架构属于中心化架构还是去中心化架构呢?你觉得未来的发展趋势又是如何?
A:早期的传统互联网架构下,我们如果要配置交换机,一般都是直接用配置线连接交换机,然后命令行配置的,但出现什么问题可能就要跑到机房了。而且那个年代,中小型运营商也比较多,且分布式技术不够成熟。所以,诞生了如OSPF、BGP、ISIS之类的分布式、自组织的动态路由协议。
而随着分布式技术成熟,以及电信、互联网巨头逐渐聚集,我们现在逐渐演进到了以Google B4为代表的中心化架构的SDN上了,这也就是为什么万维网之父Tim Berners-Lee爵士会表示对今天的中心化Web 非常不满,却还是搞出了开源的去中心化平台 Solid项目的原因。
至于未来,个人认为随着以大数据、区块链为代表的去中心化架构逐渐成熟,也许互联网的基础架构会回归去中心化。当然为了实现这个目标,就需要我们大家一起努力了。
Q:套接字也是一种进程间通信机制,它和其他通信机制有什么不同?
A:它可用于不同机器间的进程通信。
Q:我们了解的 TCP 三次握手,发生在 socket 的哪几个函数中呢?
A:第一次握手:客户端调用connect时,触发了连接请求,向服务器发送了SYN J包,这时connect进入阻塞状态;
第二次握手:服务器监听到连接请求,即收到SYN J包,就会调用accept函数接收请求,向客户端发送SYN K ,接着ACK J+1,这时accept进入阻塞状态;
第三次握手:客户端收到服务器的SYN K ,ACK J+1之后,这时connect返回,并对SYN K进行确认;服务器收到ACK K+1时,accept返回。至此三次握手完毕,连接建立。
Q:请问 int 指令后面的常数能不能大于255,为什么?
A:int 指令后面的常数不能大于255,因为int指令会经过中断门,后面的常数就是中断门的索引,而我们中断门最多256(0~255)个,所以不能大于255。
Q:请说说syscall指令和int指令的区别是什么?
A:syscall指令不需要经过中断门,执行syscall指令后的进入内核的入口地址,是内核在初始化时写入到特殊寄存器。这个寄存器应用程序不能访问,处理器在硬件层还对syscall指令执行逻辑做了一定的优化,而int要经过中断门进入到内核,做权限检查又还要读取内存,这会导致性能下降。
Q:有了KVM作为虚拟化的基石之后,如果让你从零开始,设计一款像各大云厂商IAAS平台一样的虚拟化平台,还需要考虑哪些问题呢?
A:如果只是在一台物理机上开启多个虚拟机,KVM确实已经做的很棒了,但是如果我们扩展到多个机架、多个机房,问题就变得更加复杂了。
我们除了要考虑之前讲过的网络问题,还需要考虑分布式环境下的计算、存储、消息传输、状态同步、动态迁移、扩缩容、镜像、身份认证、编排与调度、UI管理面板等很多问题。
当然,业界也有一些开源解决方案,比如大名鼎鼎的OpenStack,不过笔者觉得OpenStack由于设计实现得比较早,所以存在集群规模有限,部署、维护、二次开发复杂度高,历史包袱重等问题。和多位架构师沟通交流之后,我们正在尝试重新设计并实现一套更现代化的、轻量级的、IAAS云平台,感兴趣的同学可以加入课程群多多交流。课程交流群点这里,按加群提示操作后加入。
Q:在我们启动容器后,一旦容器退出,容器可写层的所有内容都会被删除。那么,如果用户需要持久化容器里的部分数据该怎么办呢?
A:可以通过实现volume(数据卷),在容器文件系统里创建挂载点,把宿主机文件目录挂载到容器挂载点,启动过程中读取数据卷。
Q:除了 ARM 指令集,如果想开发一款 CPU,我们还有更好的 RISC 指令集可选么?
A:RISC-V是2010年加州大学柏克莱分校创建的开源指令集架构。由于这个指令集是完全开放,允许任何人用于任何目的而设计,还不需要付高昂的专利费,所以开源之后IBM、高通、恩智浦、甲骨文、华为、阿里等知名公司也纷纷加入基金会,并且投入大量资源来进行研发与优化。由此可见,RISC-V是一个非常有潜力的项目,我们也会在后续课程结束后,发起Cosmos配套的开源芯片研发项目,感兴趣的同学可以加入课程群一起多多交流。
Q:请问,ARMv8有多少特权级?每个特权级有什么作用?
A:ARMv8,有4个特权级,E0~E3, E0运行APP,E1运行OS, E2运行虚拟机监控软件,E3运行安全监视软件。
到这里,思考题答案公布完毕,同学们学习加油呀!
评论