大多数事物都可以分为外层和里层,外层呈现给使用者,里层呈现给设计者。例如,.h与lib,歌曲与工程文件、设备与cad图纸等。
一个代码开发者,编写代码,编译生成可执行文件,运行可执行文件,达成了预期效果。编写代码是代码开发者的所有工作,至于编译器怎么工作、程序是怎么执行的,代码开发者不是必须知道的。但有些场景,如果开发者知道这些里层原理会十分有用。这篇文章打算整理一下工作到现在学到的里层知识。
文章使用arm架构环境。
场景:
int add(int x, int y)
{
return (x+y);
}
int b()
{
return 233;
}
int c()
{
int p = add(1, 1);
return add(256, p);
}
int a()
{
int bb, cc;
bb = b();
cc = add(c(), 123);
return add(bb, cc);
}
int main()
{
return a();
}
假设编译器没有开启优化,那么它就不会对同文件中的函数调用进行激进地指令优化,严格按照代码逻辑编译。此时函数调用基于栈实现。
栈,是一块内存区域,用于保存代码块中使用的数据(局部变量/保存寄存器)。和数据结构的栈类似,当代码块运行结束时,会出栈,丢弃掉相关数据。通常内存布局都是栈底在高地址,栈顶在低地址,比较反直觉。画草图的时候老是犹豫,应该怎么画内存方向。统一一下,上高下低,和上表一样。sp,stack pointer,栈指针,用于保存栈正在使用的最顶端内存地址。
开栈是减
sub sp, sp, #16
退栈是加
add sp, sp, #16
当发生函数调用时,cpu会将当前pc保存到lr或ra,把pc指向目标函数text段的起始位置,压栈,然后pc就会依条执行跳转函数的命令了,执行完毕后推栈,将pc=lr,返回到函数调用前。跳转的函数会操作通用寄存器,破坏跳转前函数的上下文,所以需要在函数跳转前将相关寄存器保存,栈就负责做这件事。反汇编一下上面的代码。
00000550 <add>:
550: e52db004 push {fp} @ (str fp, [sp, #-4]!)
554: e28db000 add fp, sp, #0
558: e24dd00c sub sp, sp, #12
55c: e50b0008 str r0, [fp, #-8]
560: e50b100c str r1, [fp, #-12]
564: e51b2008 ldr r2, [fp, #-8]
568: e51b300c ldr r3, [fp, #-12]
56c: e0823003 add r3, r2, r3
570: e1a00003 mov r0, r3
574: e28bd000 add sp, fp, #0
578: e49db004 pop {fp} @ (ldr fp, [sp], #4)
57c: e12fff1e bx lr
00000580 <b>:
580: e52db004 push {fp} @ (str fp, [sp, #-4]!)
584: e28db000 add fp, sp, #0
588: e3a030e9 mov r3, #233 @ 0xe9
58c: e1a00003 mov r0, r3
590: e28bd000 add sp, fp, #0
594: e49db004 pop {fp} @ (ldr fp, [sp], #4)
598: e12fff1e bx lr
0000059c <c>:
59c: e92d4800 push {fp, lr}
5a0: e28db004 add fp, sp, #4
5a4: e24dd008 sub sp, sp, #8
5a8: e3a01001 mov r1, #1
5ac: e3a00001 mov r0, #1
5b0: ebffffe6 bl 550 <add>
5b4: e50b0008 str r0, [fp, #-8]
5b8: e51b1008 ldr r1, [fp, #-8]
5bc: e3a00c01 mov r0, #256 @ 0x100
5c0: ebffffe2 bl 550 <add>
5c4: e1a03000 mov r3, r0
5c8: e1a00003 mov r0, r3
5cc: e24bd004 sub sp, fp, #4
5d0: e8bd8800 pop {fp, pc}
000005d4 <a>:
5d4: e92d4800 push {fp, lr}
5d8: e28db004 add fp, sp, #4
5dc: e24dd008 sub sp, sp, #8
5e0: ebffffe6 bl 580 <b>
5e4: e50b000c str r0, [fp, #-12]
5e8: ebffffeb bl 59c <c>
5ec: e1a03000 mov r3, r0
5f0: e3a0107b mov r1, #123 @ 0x7b
5f4: e1a00003 mov r0, r3
5f8: ebffffd4 bl 550 <add>
5fc: e50b0008 str r0, [fp, #-8]
600: e51b1008 ldr r1, [fp, #-8]
604: e51b000c ldr r0, [fp, #-12]
608: ebffffd0 bl 550 <add>
60c: e1a03000 mov r3, r0
610: e1a00003 mov r0, r3
614: e24bd004 sub sp, fp, #4
618: e8bd8800 pop {fp, pc}
0000061c <main>:
61c: e92d4800 push {fp, lr}
620: e28db004 add fp, sp, #4
624: ebffffea bl 5d4 <a>
628: e1a03000 mov r3, r0
62c: e1a00003 mov r0, r3
630: e8bd8800 pop {fp, pc}
所有函数,开头和结尾几乎都长差不多,
push {fp, lr}
add fp, sp, #4
...
sub sp, fp, #4
pop {fp, pc}
分别对应了压栈和推栈的两个部分
lr是否保存取决于函数内部是否会发生函数调用,因为函数跳转必然会保存返回地址(覆盖lr),没了lr就无法找到回去的路了。fp,frame pointer,用来保存当前代码块的栈底。保存fp是可选的,因为编译器是可以计算出当前代码块用了多少栈的,保存fp,更多是为了维测功能(栈回溯),因为fp指向上一层的fp,沿着fp一路向前,就能推出整个函数调用链路。是否保存fp取决于编译器的优化策略。
除了这两个寄存器,其他通用寄存器也需要保存,但保存策略还是取决于架构的abi设计与编译器实现。保存策略主要分为两类,
一般写代码是不需要感知到这一层的,只有在手写汇编时需要注意,此时编译器已无权管控代码编写者的行为,如果编写者没有在函数入口保存callee-saved寄存器并使用了它,那就有可能破坏上一层函数的上下文,导致各种未知问题发生。
这种设计的初衷应该是为了优化程序运行效率,把易失的临时数据保存在caller-saved寄存器,需要持久存放的数据放在callee-saved寄存器上,这样理论能减少访存的次数。不知道如果每个函数就在入口保存自己需要使用的所有寄存器会有什么性能损失。
总之,函数调用就是在栈上不停的进入与退出,最后完成运算,不算复杂。
任务,是一个抽象的词语,一个进程的完整执行流程,就能算是一个任务。如果计算机运算只有函数调用,CPU就同时只能处理一个任务了,相当于多个任务一个个的排队等着执行,如果突然来了一个紧急事项,也只能等待当前执行的任务执行完毕。任务切换,就是指CPU可以不完成某个任务,直接切换到在不同的任务中去。
任务切换通常基于中断机制来完成。固定频率的时钟中断,可以不管cpu任务状态来打断它,打断后,cpu也许会把pc保存到某些特殊的寄存器上,然后跳到特定的代码位置上执行中断流程代码,当然这些行为要看具体的架构设计。
在被打断前,任务栈和所有的寄存器,其实就是被打断任务的现场。CPU很直接,只需要把寄存器恢复到被打断前的状态,就能从打断后再回到这个任务上。同理,如果不想再执行这个任务,只需要把寄存器保存起来就行了。之后再把寄存器恢复成另一个任务被打断前的状态,就相当于完成了任务切换。但实际上,情况可能会复杂点,不同的架构,需要保存的东西不一样,如异常等级、各种系统寄存器什么的,这个需要阅读中断恢复相关的章节。各个任务通常都有自己的栈,中断一般都是用一个特殊的栈,差不多就叫系统栈吧,从任务到中断,涉及一个切栈的操作,即把进入中断前的sp保存,进中断时直接把sp赋值为固定的系统栈地址。
在中断恢复的流程里,对各任务进行一次优先级排序,选择优先级最高的进行恢复,即完成了任务切换。
中断嵌套,指高优先级的中断可以打断低优先级中断。大多数架构都支持这种功能。这种场景下,因为被打断前是系统栈,无法进行无脑切栈,在后续处理前需要先判断当前是否使用的系统栈来识别是否需要切栈。
中断嵌套似乎没什么好说的。也许我得实现一个简单的RTOS放到单板上跑一跑,先学嵌入式吧。