荣耀嵌入式软件一面,底层问得很深,差点被问哑
投的是荣耀终端的嵌入式软件开发岗,方向是 RTOS 和底层驱动。一面是视频面试,面试官是个做底层系统的工程师,上来自我介绍完直接开始问技术,没有任何寒暄。
整体风格是问得不多但每道都很深,基本上你答完他会继续追问细节,答不上来他会换个角度再问,感觉是在摸你的知识边界。RTOS 相关的问题占了将近一半,MCU 底层和驱动也问了不少,C++ 只问了一道但追问了很久。
总时长大概五十分钟,底层基础不扎实的话会很难受。
1. RTOS 的任务调度器是怎么工作的?抢占式调度和时间片轮转的区别是什么,FreeRTOS 里是怎么实现的?
答:任务调度器的核心职责是决定在任意时刻哪个任务占用 CPU。调度器维护一个就绪列表,按优先级组织所有处于就绪状态的任务,每次调度时从就绪列表里选出优先级最高的任务运行。
抢占式调度是指高优先级任务一旦就绪,可以立即打断当前正在运行的低优先级任务,强制切换到高优先级任务执行。这保证了高优先级任务的实时响应性,是 RTOS 的核心特性。触发抢占的时机通常是中断返回时,调度器检查是否有更高优先级的任务就绪,如果有就触发任务切换。
时间片轮转是针对相同优先级任务的调度策略。同优先级的多个任务轮流执行,每个任务运行一个时间片(通常是一个 SysTick 周期),时间片用完后切换到同优先级的下一个任务。这保证了同优先级任务都能得到执行机会,不会互相饿死。
FreeRTOS 里两者结合使用。就绪列表是一个按优先级索引的链表数组,每个优先级对应一条链表,同优先级的任务挂在同一条链表上。SysTick 中断每个 tick 触发一次,调度器在 SysTick 处理函数里检查是否需要切换任务,同优先级任务在这里做时间片轮转。任何导致高优先级任务就绪的操作(比如信号量释放、队列写入)都会在操作完成后检查是否需要立即抢占,如果需要就通过 PendSV 中断触发任务切换。PendSV 被设置为最低优先级中断,保证所有其他中断处理完之后再做任务切换,避免在中断处理过程中切换任务。
2. 任务切换的底层过程是什么?上下文保存和恢复具体保存了哪些内容?
答:任务切换的本质是保存当前任务的 CPU 状态,然后恢复下一个任务之前保存的 CPU 状态,让 CPU 从下一个任务上次被打断的地方继续执行,就好像从来没有被打断过一样。
在 ARM Cortex-M 上,任务切换通过 PendSV 异常实现。当需要切换任务时,调度器触发 PendSV,CPU 进入异常处理。
上下文保存分两部分。第一部分是硬件自动保存的,CPU 进入异常时会自动把 xPSR、PC、LR、R12、R0-R3 这八个寄存器压入当前任务的栈,这是 ARM 架构规定的异常入栈行为,不需要软件干预。第二部分是软件手动保存的,PendSV 处理函数里需要把剩余的寄存器 R4-R11 手动压栈,如果使用了浮点单元还需要保存浮点寄存器。
保存完之后,把当前任务的栈指针(SP)保存到任务控制块(TCB)里,然后调用调度器选出下一个要运行的任务,从新任务的 TCB 里取出它的栈指针,恢复 R4-R11,最后执行异常返回,CPU 自动从新任务的栈里弹出之前硬件保存的那八个寄存器,PC 被恢复到新任务上次被打断的位置,切换完成。
整个过程对任务来说是透明的,任务感知不到自己被切换过,就像从来没有被打断一样。
3. RTOS 中常见的内存分配方式有哪些,各自的优缺点是什么?
答:FreeRTOS 提供了五种堆实现,从 heap_1 到 heap_5,对应不同的使用场景。
heap_1 是最简单的,只能分配不能释放,从一个静态数组里顺序分配内存。优点是实现极简、没有碎片、确定性强,缺点是内存不能回收,适合系统初始化时创建好所有任务和对象之后就不再动态分配的场景。
heap_2 支持释放,用最佳适配算法,但释放后的内存块不会合并,时间久了会产生大量碎片,分配相同大小的块没问题,但分配大小变化的块容易失败。现在基本被 heap_4 取代了。
heap_4 是最常用的,支持分配和释放,释放时会合并相邻的空闲块,减少碎片。内部维护一个空闲链表,分配时遍历链表找合适的块,释放时插回链表并尝试合并。优点是通用性强,缺点是分配时间不确定,最坏情况下需要遍历整个链表。
heap_5 在 heap_4 基础上支持多个不连续的内存区域,比如芯片有多块 RAM,可以都纳入堆管理。
heap_3 是对标准库 malloc/free 的简单封装,加了临界区保护,线程安全,但性能和确定性取决于底层 C 库实现。
对于实时性要求严格的场景,动态内存分配本身就是个问题,因为分配时间不确定。更好的做法是用内存池,预先分配固定大小的块,分配和释放都是 O(1),时间确定,也没有碎片问题。
4. MCU 从复位到进入 main 函数经历了哪些步骤?
答:复位之后 CPU 做的第一件事是从固定地址读取两个值:从地址 0x00000000(或者 Flash 起始地址,取决于 BOOT 引脚配置)读取初始栈顶值,写入 MSP 寄存器;从地址 0x00000004 读取复位向量,也就是复位处理函数的地址,然后跳转过去执行。
复位处理函数通常在启动文件里,是汇编写的,做以下几件事:
第一步,初始化栈指针,确保 MSP 指向有效的栈空间,虽然硬件已经从向量表读了初始值,但有些实现会在这里再设置一次。
第二步,调用 SystemInit,初始化时钟系统,把 CPU 从默认的低速时钟切换到目标频率,配置 PLL、总线分频等。这一步完成后 CPU 才以正常速度运行。
第三步,初始化 .data 段,把存放在 Flash 里的已初始化全局变量的初始值复制到 RAM 对应的地址。因为 RAM 掉电丢失,程序每次启动都需要从 Flash 把初始值搬过来。
第四步,初始化 .bss 段,把未初始化的全局变量和静态变量所在的 RAM 区域清零。C 标准规定未初始化的全局变量初始值为零,这一步保证这个约定成立。
第五步,如果是 C++ 程序,调用全局对象的构造函数,遍历 .init_array 段里的函数指针列表,依次调用。
最后跳转到 main 函数,用户代码开始执行。
5. 启动文件里的汇编代码主要做了什么,向量表是怎么组织的?
答:启动文件是整个程序最先执行的代码,用汇编写是因为这时候 C 运行环境还没建立好,栈还没初始化,不能直接运行 C 代码。
启动文件主要包含两部分内容。
第一部分是向量表,这是一个存放在 Flash 起始地址的函数指针数组。第一个元素不是函数指针,而是初始栈顶地址,CPU 复位时直接用它初始化 MSP。从第二个元素开始是各种异常和中断的处理函数地址,按固定顺序排列:复位向量、NMI、HardFault、MemManage、BusFault、UsageFault,然后是 SVC、PendSV、SysTick,最后是各个外设中断。向量表的顺序和偏移是 ARM 架构规定的,不能随意改变。
第二部分是复位处理函数,也就是 Reset_Handler,就是前面说的那些初始化步骤,用汇编实现 .data 段复制和 .bss 段清零,然后调用 SystemInit 和 main。
启动文件里还会定义一些弱符号(weak symbo
剩余60%内容,订阅专栏后可继续查看/也可单篇购买
这是一个全面的嵌入式面试专栏。主要内容将包括:操作系统(进程管理、内存管理、文件系统等)、嵌入式系统(启动流程、驱动开发、中断管理等)、网络通信(TCP/IP协议栈、Socket编程等)、开发工具(交叉编译、调试工具等)以及实际项目经验分享。专栏将采用理论结合实践的方式,每个知识点都会附带相关的面试真题和答案解析。

