0.briefly speaking
This is the fourth experiment of the MIT 6.S081 Fall 2021 course. It is a series of small questions about the trap mechanism. If you still have questions about the trap mechanism, you can refer to the other 3 blogs I wrote before. They areNicely explained some background:
User state trap (taking system call as an example)
kernel state trap
RISC-V trap mechanism detailed explanation
This experiment is divided into the following three small tasks, with increasing difficulty in order:
- RISC-V assembly (easy)
- Backtrace (moderate)
- Alarm (hard)
Let's take a look at one by one...
1.RISC-V assembly (easy)
This experiment is alearning experiment, the design of this experiment is essentially to let usReview and familiarize yourself with RISC-V assembly language and Calling Convention. This experiment is to read some assembly language programs and answer some questions, let's take a look at them one by one.
The object of our research is a function called call.c. Its content is very simple. There are only two functions named f and g and the main function. The entire code is as follows:
// g函数作用,给传入的值加3
int g(int x) {
return x+3;
}
// f函数就是g函数的直接封装
int f(int x) {
return g(x);
}
// 调用f函数,打印两个值
void main(void) {
printf("%d %d\n", f(8)+1, 13);
exit(0);
}
can be seen,The main function calls the f function, and the f function is a simple wrapper of the g function. According to the instructions in the experiment guide, we can use the make fs.img command to compile this C code file and generate a readable assembly language file. Based on this assembly code, answer the following questions:
- Q1: Which registers are used to store the parameters used by the function? For example, in which register is the parameter 13 passed in when printf is called in the main function?
- A1: Let's look at the assembly and comments corresponding to the printf code, as follows:
printf("%d %d\n", f(8)+1, 13);
24: 4635 li a2,13 # 将13放置到a2寄存器
26: 45b1 li a1,12 # 将12放置到a1寄存器
28: 00000517 auipc a0,0x0 # a0 = pc = 0x28
2c: 7a050513 addi a0,a0,1952 # a0 = a0 + 1952 = 0x28 + 0x7a0 = 0x7c8
30: 00000097 auipc ra,0x0 # ra = pc = 0x30
34: 5f8080e7 jalr 1528(ra) # pc = ra + 1528 = 0x30 + 0x5f8 = 0x628
# ra = pc + 4 = 0x38
We can see that13 is placed in the a2 register, and f(8) + 1 = 8 + 3 + 1 = 12 representing the calculation result isis placed in the a1 register, a0 points to an address 7c8, and it is conceivable that this address should point to the address of the first output format string in the printf function. The above parameter transfer is in line with the calling convention of RISC-V. In fact, when passing integer parameters, if the number of parameters is less than 8, they will be placed in a0-a7 for transfer .
-
Q2: Where is the assembly code for the f function call in the main function? Where is the call to the g function? (Hint: the compiler can inline functions)
-
A2: As can be seen from the above assembly code, the compiler directly hard-codes the call result of the f function into the code, which can greatly reduce the overhead of the function call process.
-
Q3: What is the address of the printf function?
-
A3: It can be seen from the above code that the address of the printf function is the value of the PC after the last line of assembly execution is completed, which is 0x628.
-
Q4: After executing the jalr instruction that jumps to the printf function in the main function, what is the value of the ra register?
-
A4: We have calculated above,The value of the ra register should be 0x38, we can take this opportunity to familiarize ourselves with the usage of GDB, debug the user program call.c, and see if our calculation results are correct. The debugging steps are as follows, which is also debugging in Xv6Basic method of user mode program, it is recommended to master:
Open a command line terminal, enter: make CPUS=1 qemu-gdb , open gdb-server and wait for the debugger to connect.
Open another command line terminal, enter the debugger command: gdb-multiarch (of course, it can also be other debuggers), as shown below:
Then at the debugger side, load the user mode file we want to debug into file user/_call , and at the jalr instruction (Virtual address is 0x34) hit a breakpoint, as shown below:
Next, we use the continue command to continue the startup process of the kernel, pay attentionBecause the debugger is a user-mode program call, the above breakpoint can be triggered only after the program is executed after entering the operating system.. We enter continue , switch to the GDB server side, you can seeThe kernel has printed out some information, and GDB shows that the breakpoint has been triggered:
Be careful, at this timeThe triggered breakpoint is not in the program call.c, but at a certain moment when the kernel is started, the PC happens to be equal to the address value of 0x34, and GDB triggers this breakpoint. GDB is stupid, it will onlyCheck if the current PC value is equal to the breakpoint position you set, but it doesn't matter which program this is in. We can use the disas instruction to look at the assembly code of the current context:
we can seeThe currently executed assembly code is completely inconsistent with the disassembly of printf in call.c, so the so-called breakpoint that is triggered now is not located in call.c, we directly use the continue instruction to skip this breakpoint, so the kernel is completely started successfully at this time, and we are at this timeExecute the call program in the terminal, you can see that it is blocked by a breakpoint :
back to the GDB side, you can seehits our breakpoint again, This time it really entered our program. Use disas to check the disassembly and find that it is exactly the same as the code in call.asm:
this time it is really blocked at the place we expected, so we enter the stepi command , let it execute an assembly forward, at this timeThe control flow will go to the printf function, and the setting of the ra register will be completed at the same time:
we can look at the value of the current ra register, use the info registers ra command,Its value is 0x38, which is consistent with the value we calculated, and the guess is correct.
Quite a big run, but I think it's worth it, lol, let's get back on track.
-
Q5<: What will be the output of executing the following code?
The output is based on the fact that RISC-V is little-endian, if RISC-V is big-endian then how should the value of i be modified to produce the same output? Do you need to modify 57616 to a different value? -
A5: First write this code into call.c, compile and run it, the result is as follows:
a "He110 World" is output, wheree110 is the hexadecimal representation of 57616, and rld is the corresponding character for each byte in the unsigned integer i. If the RISC-V storage is now changed to big endian, the value of i should be initialized to 0x00726c46, and 57616 does not need to be modified, because the big and small endian storage will not change the result after it is converted into a hexadecimal number . -
Q6: In the code below, what will be printed after y=? (this value is uncertain), why is this?
-
A6: We replace the main function in the call.c file with the above code, compile and execute, and the results are as follows:
First, the above behavior is undefined behavior (Undefined Behavior), because the number of parameters passed in printf is less than the format string I don't intend to introduce the cause and effect in detail here (involving va_list, etc., and va_list is actually a pointer, pointing to a continuous memory area ), and the length of writing will be very long. I debugged the above program using GDB and found that5309 is actually a piece of uninitialized memory data immediately after 3, which is the answer to the question.
2.Backtrace (moderate)
This experiment allows usImplement a backtrace function, you can print the function call stack, which is convenient for us to debug. This small task is actually not difficult. The most important prerequisite knowledge is to understand and be familiar withFunction call stack frame structure (stack frame) in Xv6, knowing the relative storage location of each register, this task is very simple.
First, according to the requirements of the experiment guide, aEmbedded assembly to read fp registerDefined in the riscv.h file:
/* read frame pointer*/
static inline uint64
r_fp()
{
uint64 x;
asm volatile("mv %0, s0" : "=r" (x) );
return x;
}
Let's simply drawStack frame structure in Xv6 kernel(stack frame), which is very important for the implementation of the following code. When we are using Frame Pointer, this register actually points to the last position of the upper stack frame , because the stack pointer (stack pointer) in RISC-V follows the principle of full decrement . Then FP-8 must storeThe return address ra of the upper level function, the address of FP-16 must store the upper level stack frame pointer Previous FP, which is an important condition for us to achieve backtracking.
After knowing the basic structure of the stack frame, we will find thatThe stack frame of a function called by multiple layers of nestingIn essence, a linked list is formed. The entry of the linked list is the value currently stored in the FP register, and the next pointer of the linked list is the Previous FP pointer (FP-16). The last is the end of the backtracking. We know that the end of the linked list is generally a null pointer (nullptr), and it is the same here. In RISC-V abiClearly stipulates that the value of the last Previous FP position of the FP linked list is 0(The end of the frame record chain is indicated by the address zero appearing as the next link in the chain). But we did not do this in the experiment, the entire stack in the Xv6 kernel implementation is only one page (4K) in size, so we canUse the address value to detect whether it has traced back to the last stack frame。
Finally, give the complete code implementation:
/* backtrace : print the call stack of function */
void backtrace()
{
// 读出当前FP指针的值,使用上面添加的嵌入式汇编函数
// 根据FP指针的值计算出栈底位置,这将作为循环结束的条件
uint64 FramePointer = r_fp();
uint64 KernelTop = PGROUNDDOWN(FramePointer) + PGSIZE;
printf("backtrace:\n");
// 如果没有到最后一级,则持续向前一级回溯
while(FramePointer < KernelTop)
{
// 从FP-8这个位置取出上一级函数返回地址,打印出来
uint64 ReturnAddr = *(uint64*)(FramePointer - 8);
printf("%p\n", ReturnAddr);
// 回溯到上一层函数栈帧
FramePointer = *(uint64*)(FramePointer - 16);
}
return;
}
We add this function toIn the implementation of the sys_sleep function,have a test:
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);
backtrace(); // test backtrace here
return 0;
}
Start the kernel, execute bttest, the result is as follows:
Then use the mapping addr2line from the virtual address to the number of code lines to convert, the result is as follows:
the result is correct, theyindeed point to the return address of the corresponding function, and can also pass the test program. Now, weOne more tool for debugging, we added it to the panic function according to the instructions in the guide, for future use in debugging :). Finally, we execute the test program to judge the correctness of the program, the results are as follows:
Finally, it is very dangerous to use so-called addresses to detect whether to backtrack to the top of the stack. IUse GDB to debug the above program, found that the FP pointer coincides with the address at the bottom of the stack when jumping out of the loop (so the address comparison in the code implementation must be the < sign, and the <= sign will cause a catastrophe ), and at this time Previous FP is a very small value : 0x2fe0, this value is when the user falls into the kernel mode and executes the system callThe value remaining in the FP register, which points to a small address, which essentially points to a virtual address in the original user mode. If you continue to follow this address, you will fall into an infinite loop, which is some attention points outside this experiment.
3.Alarm (hard)
This is the last small task of this experiment, and it is also somewhat difficult. The goal of this experiment is to allow us to implement two system calls sigalarm and sigreturn, to achieveA function that returns after a process is interrupted at regular intervals to execute other functions. There is a more detailed description in the experiment guide, which is divided into two parts, respectively realizing sigalarm and sigreturn. Since this is a whole story, I'm not going to break it down into two parts, becauseThis would destroy the integrity of the story. I'll try to describe the whole call consistently :)
Before starting the code implementation of this task, you may need to review how to add a system call, which has been summarized in our previous blog, see the Briefly Speaking section in 6.S081——Lab2——system calls for details. itSummarizes all the steps to add a system call, we will not describe these steps one by one below, but only focus on the logic of the problem itself. Ok, here we go!
I would like to first give a complete overview of the taskfull call trace, as shown below, in fact, this picture almost sums up what we have to do, nowYou only need to implement the details in code, in the following explanation, I will use the numbers in the picture to index the corresponding actions , so this picture is very meaningful:
The first small goal is to implement the system call sigalarm, which can be setThe interval (Inteval) and response function (handler) of a process interrupted by the clock, once set, the process isThe processing function handler will be called at intervals of Inteval, so as to enter the complete calling process in the above figure. becauseBoth the time interval and the call function handler are strongly related to the process state, so we save it in struct proc, and the modified proc structure is as follows (newly added fields are annotated in angle brackets):
struct proc {
struct spinlock lock;
// p->lock must be held when using these:
enum procstate state; // Process state
void *chan; // If non-zero, sleeping on chan
int killed; // If non-zero, have been killed
int xstate; // Exit status to be returned to parent's wait
int pid; // Process ID
// wait_lock must be held when using this:
struct proc *parent; // Parent process
// these are private to the process, so p->lock need not be held.
uint64 kstack; // Virtual address of kernel stack
uint64 sz; // Size of process memory (bytes)
pagetable_t pagetable; // User page table
struct trapframe *trapframe; // data page for trampoline.S
struct trapframe alarmframe; // <在发生sigalarm调用时,用于保存之前的trapframe>
struct context context; // swtch() here to run process
struct file *ofile[NOFILE]; // Open files
struct inode *cwd; // Current directory
char name[16]; // Process name (debugging)
int AlarmInteval; // <触发alarm handler的时间间隔>
int Counter; // <计时器,记录从上次触发handler到现在经过的ticks数量>
char InHandler; // <标志位,表示当前进程是否处于响应alarm的流程中>
uint64 Handler; // <handler在用户态的虚拟地址>
};
So in fact, the implementation of sigalarm in the kernel sys_sigalarm is very simple, justJust record the parameters passed in by the user into the process structure, so its implementation looks like this:
// sigalarm对应的内核代码实现,只需要将用户传入的参数
uint64
sys_sigalarm(void)
{
if(argint(0, &myproc()->AlarmInteval) < 0)
return -1;
if(argaddr(1, &myproc()->Handler) < 0)
return -1;
return 0;
}
After the sigalarm system call is implemented in the kernel, the user mayCall the sigalarm system call, so as to complete the registration of the interrupt interval time and the interrupt handler function in the kernel . Once registered, the kernel starts to count the time interval, and starts to call this interrupt handler periodically .
as① in the pictureAs shown, when a process is executing, the timer maywill trigger a clock interrupt at one of the indeterminate moments. At this time, it will enter the corresponding process of the system call , first enter the usertrap function through the trampoline, that is,② in the picture above, where weWill handle this clock interrupt, and carry out the alarm response process in a timely manner, first give the code and comments:
void
usertrap(void)
{
// 以上代码从略...
if(p->killed)
exit(-1);
// 如果是时钟中断,且当前开启了alarm功能并且没有处于其他响应过程中,则开始处理中断
// p->AlarmInteval == 0表示我们当前关闭了alarm,因此不要响应
// p->InHandler == 0表示当前进程没有在处理alarm流程中,可以进行下一次响应
if(which_dev == 2 && p->AlarmInteval != 0 && p->InHandler == 0)
{
// 记录当前已经经过的ticks总数,计数器加一
p->Counter ++;
// 如果到了应该触发handler的间隔时间
if(p->Counter == p->AlarmInteval)
{
// 首先将trapframe完整保存在proc的alarmframe中
// 这是为了以后可以不受影响地回到原有进程中执行
// 出于便利,我选择直接将trapframe中的所有信息保存下来
memmove(&p->alarmframe, p->trapframe,
sizeof(struct trapframe));
// 修改trapframe中的epc,使得陷阱将会返回到用户态下的handler函数中
p->trapframe->epc = p->Handler;
// 设置标志位,表明当前进程正处于alarm的处理流程中,不再响应其他alarm
p->InHandler = 1;
}
}
// 以下代码从略...
}
So this is my modification logic for usertrap. What I do here is to record the elapsed time. If the time reaches the interval we set, the alarm response process will be triggered . First save the trapframe completely, then set the value of the epc register in the trapframe to the virtual address of the handler, so that it canReturn to the handler function in user mode via usertrapret. Don't forget to also set the flag InHandler, which can avoid errors caused by re-entering the handler.
After our setup, now back to③ in the picture above, some user-defined actions will be executed here, and the sigreturn system call will be executed at the end, so the operating system will fall into the kernel state again, and will be forwarded by usertrap (④ in the above picture) After entering the sys_sigreturn system call of the kernel, the implementation of sys_sigreturn is very simple, justSimple restore scene and reset counter,like⑤ in the picture aboveAs shown, my code implementation and comments are given:
uint64
sys_sigreturn(void)
{
struct proc* p = myproc();
// 恢复现场并将计数器和标志重置
memmove(p->trapframe, &p->alarmframe, sizeof(struct trapframe));
p->Counter = 0;
p->InHandler = 0;
return 0;
}
here we areRestored the scene of the earliest program when executing the user mode program, and initialized all the timers and flags, so that the process can respond to the next alarm :), and then through the recovery of usertrapret, the process is now back to the original program, and the scene has not been affected in any way, such as⑥ in the figureshown.
This is the complete implementation idea and explanation of the entire alarm task, which actually doesn’t look complicated :)
I executed alarmtest to test the correctness of the implementation, and the results are as follows, indicating that there is no problem with the implementation:
usertests I will not show the results here, it testsDid you add code that compromised kernel integrity?, The test time is relatively long, and I have tested the program without any problems. Only the finaltest program report, no problem:
this experiment is over, hahaha!