Study|嵌入式面试与八股
一、面试记录
查漏补缺——————
龙晶石半导体|嵌入式软件|2025/09/28 02:30|LZ
- static 的用法
- const 的用法
- 介绍一下 STM32 的 .s 启动文件
- 介绍一下 STM32 的 .ld 文件
- 堆和栈的区别
- 获取内存数据类型字节数的方式
- 假设项目中 Flash 过大,有初始化值为 0 的变量,删掉可以减少体积吗?
- SPI(什么时候读取数据,怎么设置)
- 介绍串口和 DMA 联合使用
- 串口和 DMA 实现接收可变长数据(并非有规定的协议)
- Mutex 和优先级继承
- 设备树的作用
- 字符设备在用户空间实现数据交换怎么实现(0 拷贝)
- 反问
哈罗普惠|嵌入式测试|2025/10/21 14:00|LZ
其实不要本科生–
统信软件|Linux 内核 / 操作系统|2025/10/21 14:00|LZ
全是内核知识,需要阅读源码才能有机会
齐昕科技|MCU 底层固件实习 / 嵌入式|2025/10/30 14:45|CLH
面了 24 分钟,主要全问的是有关 25 年电赛的问题,从前期准备,到分工,再到具体实现逻辑和方案,以及遇到的问题。
全志科技|嵌入式软件实习 / 嵌入式|2025/06/09 17:00|CLH
问的项目和编译原理的八股?
CVTE|嵌入式软件技术支持 / 嵌入式|2026/08/06 16:00|CLH
C 语言基础八股、嵌入式基础八股、最拿得出手的项目介绍、对加班的看法和薪资要求等。
二、面试前准备
1. 简述一下 GT911 触摸屏驱动:过程与问题
首先 GT911 是 I2C 驱动的触摸屏,所以选择 I2C 框架进行编写;同时需要通过中断上报坐标信息,因此还要使用 Input 子系统并申请中断,对 I2C 引脚、中断引脚和复位引脚进行初始化。
第一步是根据屏幕数据手册和板级手册确认引脚、初始化时序、读取地址等信息,然后修改设备树:设置引脚模式和电气属性,配置 I2C 控制器、中断和复位 GPIO,同时屏蔽占用相同引脚的其他节点。
需要确认 I2C 总线编号、设备地址、中断引脚、复位引脚和 I2C 控制器节点;通过
compatible匹配驱动,通过reg设置 I2C 地址,并配置中断触发方式。
第二步是编写驱动:注册 I2C 驱动和匹配表,在 probe 中解析设备树,获取 I2C 客户端、中断号、复位 GPIO 等资源,执行复位时序,读取 GT911 ID 验证通信,然后创建 Input 设备、初始化多点触摸槽位并申请中断。
第三步是处理中断。由于 I2C 通信可能休眠,不能在软中断或 tasklet 中直接读取 I2C,应使用线程化中断或工作队列。中断线程读取状态寄存器和坐标数据,解析触点数量、ID 和坐标,按 MT 协议上报槽位状态、X/Y 坐标和同步事件,最后写入命令清除中断标志。
第四步是验证上报结果:可以使用 hexdump /dev/input/eventX 查看原始事件,使用 evtest 解析事件,也可以使用 tslib 做滤波、校准和坐标转换。
常见问题包括初始化时序或延时错误、读取数据后没有清除中断标志,以及设备树节点重复占用同一组引脚。
2. 简述一下 Linux 移植的整体流程与关键点
整体流程可以参考Linux 移植相关概念。
以 NXP 官方参考板的 U-Boot 源码为基础,先编译并运行官方版本,检查 SD 卡、eMMC、LCD 和网络,再创建新的板级配置并添加相关文件。
创建新的板级配置,然后添加相关文件
修改驱动,对于imx6ull板子主要修改的驱动如下: LCD屏幕和网络驱动
LCD: 分辨率,像素时钟,像素格式,LCD的panel环境变量(在uboot启动的配置模式中,通过setenv pannel TFT7016来设置)
网络驱动:因为6ull使用的网络芯片和官方不同,这里主要要适配网络芯片型号(PHY地址),以及正确的控制复位引脚
然后进行运行即可,同时通过set ipaddr/ethaddr/gatewayip/netmask/serverip…然后通过ping自己的虚拟机测试网络是否正确
这里强调bootcmd和bootargs环境变量
bootcmd是uboot倒计时结束之后执行的命令(比如后期的网络挂载就是通过在这里添加环境变量来实现的,注意,从网络中启动唯一目的是为了调试,为了防止频繁的往EMMC中烧录东西,这里使用的指令是tftp网络下载)
bootargs是uboot传递给linux内核的参数,比如控制的输出位置(我们默认的串口),根文件系统位置、文件系统的格式等等
内核编译成功之后会生成一个linux的镜像文件(uboot编译出来是一个bin文件,板子上电后通过固定偏移,开始运行uboot),我们将设备树文件和这个镜像文件保存到EMMC中,指定这个镜像文件和设备树的文件位置之后,uboot读取这两个文件进行启动,就是最终的从EMMC中启动
在移植的时候,我们是可以使用图形化进行配置的,指令为make menuconfig,然后可以在图形化配置界面进行修改
这里主要涉及三个:
- CPU主频修改
- 修改EMMC的驱动模式,从4线修改为8线,更快; 修改供电,将默认的1.8V修改成3.3V
- 修改底层的网络驱动,因为芯片不同,部分引脚不同,同时要屏蔽别的使用了相关引脚IO的驱动
(设备树中的PHY地址,网络驱动节点中的部分属性,复位和网络时钟引脚的驱动,这几个包括前两点都是设备树文件中修改,最后是在fec_main.c添加一个芯片复位之后的延时,要求复位之后要延时150ms,所以添加了一个延时)
然后重新编译之后,通过ifconfig进行测试网络
使用BusyBox 构建根文件系统,主要修改两部分 添加编译器 添加中文支持,选择动态编译后编译 busybox,会得到一个基础的根文件系统(BusyBox的工作也就到此为止)
然后是向根文件系统中添加向根文件系统添加 lib 库(动态库,如果是静态编译则不用,但是静态库很占内存,并且可能导致dns不能使用)和一些基础文件目录(如 dev、proc、mnt、sys、tmp 和 root 等),这里的lib库通过交叉编译器工具中获取,并且没有裁剪
3. 完整项目介绍
回答思路:项目背景 -> 硬件架构 -> 软件架构 -> 个人负责内容 -> 难点与结果。
以简易自行瞄准装置为例….待补充
以ZYNQ测试仪为例…待补充
三、八股板块 – 嵌入式软件
这一部分按知识领域整理,回答时优先说明概念、使用场景、实现原理和常见问题。
八股题目列表
用于整理指针、关键字、内存、编译链接和标准库等基础问题。
空指针与野指针
空指针不指向任何有效内存,通常使用 NULL 表示。声明了指针但暂时没有目标地址时,应初始化为空指针。
野指针指向无效或已经释放的内存
常见原因:
1.包括返回局部变量地址
2.使用未初始化指针
3.释放后继续使用
4.指针越界和重复释放
释放后将指针置为 NULL,可以降低再次误用的风险,但不能替代生命周期管理和边界检查。
1 | //完整malloc示例 |
栈、堆、静态区与内存布局
- 栈:函数内的普通局部变量,生命周期通常随函数调用结束。
- 堆:通过
malloc申请的内存,需要手动释放。 - 数据段:全局变量和
static变量,生命周期覆盖整个程序运行期。 .data:初始值非零的已初始化变量.bss:初始值为零或未初始化的变量,文件中通常只记录大小.rodata:常量字符串、const全局变量
Q: 为什么要将未初始化和初始化为0的值放在.bss段,有什么优势?
A: 减小可执行文件体积,仅记录了为0值的大小,零值由系统批量清零,效率高
Q:为什么
.bss不和.data放在一起?A:
.data需要在固件中保存初始值,启动时从 Flash 搬到 RAM;.bss的初始值全是 0,只需要记录大小,启动时统一清零。分开可以减小固件体积,也方便启动代码批量初始化。
malloc、free 与内存安全
malloc 申请的内存必须与 free 匹配。释放后继续解引用、重复释放和未检查申请结果都会导致未定义行为,参数野指针。
数组和指针的区别
数组是一段连续的内存空间,数组名在大多数表达式中会退化为指向首元素的指针;指针本身是一个变量,保存地址,可以改变指向。
注意:
sizeof(array)得到整个数组大小,sizeof(pointer)得到指针本身大小;数组作为函数参数时会退化为指针,函数中无法直接通过sizeof得到数组长度。
1 | int array[5] = {0}; |
函数之间共享内存的方法
常见方法有:使用全局变量、传递指针、使用 static 变量、动态内存,以及在多线程场景中使用共享内存。
面试回答:优先通过参数传递指针,明确数据的所有权和生命周期;全局变量简单但耦合较高,多线程共享时还要配合锁或原子操作。
1 | void update_value(int *value) |
static、const、volatile 等关键字
static 修饰局部变量时只初始化一次并保留状态;修饰文件作用域的变量或函数时限制为当前源文件可见。未显式初始化的 static 变量默认值为 0。
const 限制当前访问路径不能修改对象,具有类型信息,比简单的宏替换更容易进行类型检查。
const int *:常量指针,不能通过指针修改指向的变量,但指针可以改变指向。int * const:指针常量,指针本身不能改变指向,但可以通过指针修改变量。const int * const:指针和指向的变量都不能通过当前指针修改。
volatile 用于说明变量可能被硬件、中断或其他执行流修改,避免编译器省略必要的内存访问。它不保证原子性,也不能代替锁或其他同步机制。一般在嵌入式中访问寄存器使用
typedef 用于给已有类型起别名,不会创建新的类型,常用于简化结构体、函数指针和复杂类型的声明。它只是编译阶段的类型别名,不能像变量一样存储数据。
register 用于建议编译器将变量优先放入寄存器,以提高访问速度,但最终是否使用寄存器由编译器决定。使用 register 修饰的变量不能使用取地址运算符 &。
sizeof 用于计算类型或对象占用的字节数,结果类型是 size_t。对普通表达式通常不会求值,但变长数组除外;计算数组时得到整个数组大小,计算指针时得到的是指针本身的大小。
Q:sizeof和strlen的区别?
A:strlen是库函数,计算的是字符串开始到’\0’的长度大小(不包括’\0’),而sizeof是运算符,计算整个数组的大小
union 是共用体,所有成员共享同一块内存,同一时刻通常只使用其中一个成员,大小由最大成员和内存对齐决定。修改一个成员可能影响其他成员的值,常用于节省内存或进行数据解析。
struct 是结构体,每个成员都有独立的存储空间,可以同时保存多个成员的值。结构体大小会受到内存对齐影响,因此不一定等于所有成员大小之和。
标识符、关键字与保留字
标识符是变量、函数和类型等对象的名称;关键字具有语言规定的特殊含义;保留字可能暂未使用但不能作为普通标识符。
头文件双引号与尖括号
#include "file.h" 通常先查找项目相关目录,再查找系统目录;#include <file.h> 通常直接查找编译器配置的系统目录。
输出重定向与 printf 工作过程
嵌入式项目中常通过重定义 fputc,将 printf 的字符输出转发到串口。许多 C 运行库为 fputc 提供弱符号,工程中的强符号实现会覆盖它。
1 | //重定义printf输出为USART1 |
2. MCU
用于整理单片机、启动文件、链接脚本、外设、DMA、中断和 RTOS 等内容。
STM32 .s 启动文件
.s 文件是启动汇编文件,主要完成建立栈、初始化数据段、清零 .bss、执行系统初始化,最后跳转到 main。
面试回答:上电后 CPU 先从向量表获取栈顶地址和复位入口,然后执行
Reset_Handler,完成 C 运行环境初始化后进入main。
“MCU 上电后,硬件会首先从向量表的起始地址(通常是 Flash >的
0x00000000)取出初始主栈指针(MSP),接着从偏移0x04>处取出复位向量(Reset_Handler)的地址并跳转执行。
在Reset_Handler中,启动代码主要完成四件事:
调用SystemInit配置系统时钟和硬件基础;
初始化 C 运行环境:把 Flash 中的 .data 段拷贝到 RAM,把 .>bss 段清零;
初始化堆栈(如果用的是 RTOS,这一步还会初始化任务栈 PSP,>但主栈 MSP 是硬件初始化的);
跳转到 main 函数。
这里要区分一下工具链:
如果是 Keil MDK,Reset_Handler里会调用 C 库的 __main,由它来代劳.data、.bss的初始化和最终跳转到 main;
如果是 GCC,没有__main,通常要在汇编里手动编写搬运.>data和清零.bss的代码,最后直接bl main
1 | Reset_Handler |
STM32 .ld 链接脚本
.ld 文件用于告诉链接器:Flash 和 RAM 的起始地址、大小,以及代码和变量如何分配。
面试回答:链接脚本决定程序最终的内存布局,例如代码放在 Flash,运行时变量放在 RAM;如果程序过大,首先检查 Flash 区域和各段占用。
1 | MEMORY |
Flash、RAM、.data 与 .bss
初始化值非零的全局或静态变量放在 .data,初始值保存在 Flash,启动时复制到 RAM;初始值为零或未初始化的变量放在 .bss,启动时统一清零。
Q:初始化为 0 的全局变量会不会增加固件文件大小?
A:通常不会明显增加,因为
.bss只记录所需空间大小,程序启动时再由启动代码清零。
1 | int initialized = 1; // 通常位于 .data |
SPI 通信时序与配置
SPI 是同步、全双工通信,主要配置片选 CS、时钟 SCK、数据输入 MISO、数据输出 MOSI、时钟频率、数据位宽和传输模式。
Q:SPI 的 CPOL 和 CPHA 是什么?
A:CPOL 决定空闲时钟电平,CPHA 决定在第一个还是第二个时钟边沿采样。具体使用哪种模式,要以从设备手册为准。
1 | spi_config.mode = SPI_MODE_0; // CPOL=0, CPHA=0 |
UART 与 DMA 联合使用
UART 负责串行通信,DMA 负责在外设和内存之间搬运数据,CPU 不需要逐字节参与,能够降低中断频率和 CPU 占用。
面试回答:发送时可以使用 DMA 一次搬运一帧数据(配置为 Normal 模式,发送完成触发中断);接收时通常结合 DMA 和空闲线中断(配置为 Circular 循环模式),收到一帧后再由 CPU 解析。
Q:为什么 UART 收发要用 DMA?不用行不行?
传统中断:每收到 1 个字节就进一次中断。如果波特率是 115200,一秒钟可能进上万次中断,CPU 频繁现场保护/恢复,极大浪费算力
DMA 优势:DMA 是硬件搬运工,CPU 只需配置好“源地址(串口数据寄存器)、目的地址(内存数组)、长度”,就可以去干别的事。只有搬完一整帧,才通知 CPU 一次。本质是空间换时间,用硬件并发换 CPU 效率。
DMA 接收可变长数据
常用方案是 DMA 循环接收 + UART 空闲线中断。空闲中断表示一段时间没有新数据,此时通过 DMA 剩余计数计算本次接收长度,再交给协议层处理。
注意:空闲中断只能判断一段数据暂时结束,不能代替协议中的帧头、长度、校验和。还要处理缓存溢出、粘包和半包。
Q:DMA接收可变长数据后如何得到长度?
A:HAL库可以使用函数
__HAL_DMA_GET_COUNTER(&hdma_usart_rx)获取剩余缓存区大小,然后通过缓冲区总大小减去得到的值计算出长度;
标准库中可以选择读取DMA的计数器的值来获得长度;
Q:DMA有哪些模式? 普通模式和循环模式的区别和使用?
A:
普通模式(Normal):DMA 搬完设定长度后就停止,只搬运一次。处理完数据后,必须在代码里重新调用 HAL_UART_Receive_DMA() 重新开启接收(该函数内部会重置计数器并重启DMA),否则下一帧无法接收。适合单次定长接收,或者配合空闲中断(IDLE)接收不定长数据循环模式(Circular):DMA 搬完设定长度后自动回到起点继续搬运,无需 CPU 干预。虽然省事,但新数据会直接覆盖旧数据(发生回绕),所以必须保证 CPU 能在覆盖前把数据读走。适合连续高速采集(如 ADC 连续扫描)或配合空闲中断+环形缓冲区使用
Q:什么是空闲中断?
A:空闲中断(IDLE):当串口总线在接收完一个字节后,经过一个字节的传输时间(起始位+数据位+校验位+停止位)都没有再收到新的数据,此时串口硬件会认为“总线空闲了”,自动置位 IDLE 标志并触发中断
在 STM32 标准库中清除 IDLE 标志位,必须先读 SR 寄存器,再读 DR 寄存器,不能直接写 0 清除。 HAL库底层驱动已经自动处理好了这个清除动作,不需用我们操作
ADC 的主要性能参数
ADC 是把模拟电压转换成数字量,常见性能参数包括分辨率、采样率、转换时间、输入范围、精度、信噪比和有效位数 ENOB。
分辨率为
N位时,理想量化步进约为Vref / 2^N;采样率决定单位时间能采集多少次,输入信号频率过高会产生混叠。
1 | voltage = adc_value * VREF / ((1 << ADC_BITS) - 1); |
PWM 的基本概念与数模转换
PWM 主要参数是频率、周期、占空比和幅值。占空比是高电平时间占一个周期的比例,改变占空比可以改变平均电压或电机有效功率。
PWM 经过低通滤波器后可以得到近似直流电压:
Vout ≈ Vcc × Duty。例如Vcc = 3.3V、占空比为50%,滤波后平均电压约为1.65V。
1 | pwm_set_frequency(1000); |
I2C 的优势以及与 SPI 的区别
I2C 使用两根线(SCL、SDA),支持多主机和多从机,并且通过设备地址区分从设备,节省引脚和布线成本。
I2C:线少、支持地址寻址、速度相对较低,适合传感器、EEPROM 等低速设备;SPI:全双工、速度快、时序简单,但通常每个从设备需要独立片选线。
面试总结:设备数量多、引脚紧张时优先考虑 I2C;需要高速连续传输时通常选择 SPI。
中断、临界区与任务调度
中断处理函数应尽量短,只做清除标志、保存数据、发送通知等快速操作。耗时、复杂或可能阻塞的工作放到任务、工作队列或中断线程中。
Q:中断中能不能调用
printf或delay?A:一般不建议。它们可能耗时、阻塞或依赖锁,会增加中断延迟;中断中只记录必要数据,再交给任务处理。
Mutex 与优先级继承
互斥锁用于保护共享资源,同一时刻只能由一个任务持有。优先级继承可以让持有锁的低优先级任务临时提升到等待者的优先级,减少优先级反转。
注意:中断上下文不能获取会休眠的互斥锁;中断中通常使用自旋锁、关闭中断或发送信号,具体取决于上下文和临界区长度。
中断是否有返回值和输入参数
中断服务函数通常没有业务上的返回值,也没有由调用者传入的普通参数。硬件触发中断后,处理器根据中断号进入固定的中断入口,驱动通过寄存器、全局状态或 dev_id 获取上下文。
Linux 中断处理函数常见形式为
irqreturn_t handler(int irq, void *dev_id);返回值用于告诉内核是否处理了该中断,不是给硬件返回业务数据。
1 | irqreturn_t button_handler(int irq, void *dev_id) |
GPIO 的常见模式
GPIO 常见模式包括推挽输出、开漏输出、上拉输入、下拉输入、模拟输入、浮空输入和复用功能(复用推挽,复用开漏)。
推挽输出可以主动输出高低电平;开漏输出只能主动拉低,输出高电平需要上拉电阻,适合 I2C 等总线;输入模式要根据外部电路选择上拉、下拉或浮空。
1 | gpio_set_mode(LED_PIN, GPIO_OUTPUT_PUSH_PULL); |
probe 的作用与流程
probe 是 Linux 驱动匹配到设备后执行的初始化函数,通常完成解析设备树、申请 GPIO/中断/时钟、初始化硬件、注册子系统设备。
常见流程:匹配
compatible-> 获取资源 -> 硬件复位 -> 读取芯片 ID -> 初始化设备 -> 注册字符设备、Input 或其他子系统 -> 开始工作。
1 | static int demo_probe(struct platform_device *pdev) |
I2C 和 SPI 的区别
I2C 时序
SPI 时序和模式
RTOS 中如何进行内存管理
线程切换和调度
U-Boot 引导流程
常用文件系统和 Flash
FAT 适合 SD 卡和 U 盘,ext4 常用于 eMMC 等块设备,JFFS2 和 UBIFS 面向原始 NAND/NOR Flash,LittleFS 适合资源有限的 MCU 和 SPI NOR Flash。
Flash 不能像 RAM 一样任意改写,擦除通常以块或扇区为单位,擦写次数也有限。因此要考虑坏块、磨损均衡、掉电保护、写入对齐和缓存一致性。掉电敏感数据可以使用日志、双备份、版本号和 CRC。
RAM 是易失、可随机读写、可按字节覆盖、速度快、寿命近乎无限;Flash 是非易失、读可以随机但写不能任意覆盖、按页写按块擦、速度慢、寿命有限
3. C++
封装、继承与多态
封装是把数据和操作数据的函数放在类中,并通过 private、protected、public 控制访问;继承是子类复用和扩展父类;多态是同一个接口根据对象实际类型执行不同实现。
面试回答:封装提高安全性和可维护性,继承实现代码复用,多态提高扩展性。C++ 中动态多态通常通过虚函数和基类指针或引用实现。
1 | class Device { |
Lambda 表达式
Lambda 是可以直接定义和使用的匿名函数,常用于回调、算法和线程任务。基本形式为 [捕获列表](参数列表) -> 返回值 { 函数体 }。
捕获列表决定 Lambda 如何使用外部变量:
[x]按值捕获,[&x]按引用捕获,[=]全部按值捕获,[&]全部按引用捕获。
1 | int factor = 2; |
构造函数和析构函数
构造函数用于初始化对象,析构函数用于释放对象持有的资源,二者都没有返回值,不能写 void。构造函数可以重载,析构函数一个类只能有一个。
构造函数在对象创建时自动调用,析构函数在对象离开作用域或被
delete时自动调用。父类析构函数通常应声明为virtual,避免基类指针删除子类对象时资源释放不完整。
1 | class Buffer { |
面向对象与多态
多态分为静态多态和动态多态。函数重载、运算符重载和模板属于静态多态;虚函数属于动态多态,在运行时根据对象实际类型选择函数。
动态多态条件:存在继承关系,子类重写父类虚函数(父类为抽象类),并通过父类指针或引用调用。如果父类要被正确析构,通常应提供虚析构函数。
1 | class Device { |
malloc、new、free 与 delete
虚函数是怎么实现的
C++ 虚函数和纯虚函数的区别
虚函数使用 virtual 声明,父类可以提供默认实现,子类可以选择重写。纯虚函数在声明后加 = 0,表示父类只规定接口;含有纯虚函数的类是抽象类,不能直接创建对象,子类实现所有纯虚函数后才能实例化。
1 | class Device { |
4. Linux
进程和线程的区别
进程是资源分配的基本单位,拥有独立的地址空间;线程是 CPU 调度的基本单位,同一进程中的线程共享代码段、数据段和文件描述符,但拥有独立的栈和寄存器上下文。
进程隔离性好但创建和通信开销较大;线程创建和切换开销较小,但共享资源时需要同步,错误可能影响同一进程中的其他线程。
创建进程和线程的 API
Linux 创建进程常用 fork、vfork、clone,执行新程序常用 exec 系列函数;创建线程常用 POSIX 线程库 pthread_create。
1 | pid_t pid = fork(); |
fork复制当前进程,父进程得到子进程 PID,子进程得到0;pthread_create创建线程并执行指定入口函数。
任务共享资源和同步方法
常见同步方法包括互斥锁、读写锁、自旋锁、信号量、条件变量、原子变量、消息队列、管道和共享内存。
线程共享变量时使用互斥锁或原子变量;生产者和消费者可以使用信号量或条件变量;进程之间可以使用管道、消息队列或共享内存配合同步锁。
互斥锁和同步锁的区别
互斥锁主要解决同一时间只能有一个任务访问临界资源;同步机制主要解决任务之间的执行顺序和事件通知。互斥锁强调资源保护,同步强调协作。
例子:访问共享链表使用互斥锁;线程 A 处理完数据后通知线程 B,可以使用条件变量或信号量。一个程序中可以同时使用互斥和同步。
锁的原理和实现
锁的核心是让多个执行流对共享状态进行原子竞争。互斥锁通常使用原子指令实现“空闲 -> 持有”的状态转换,竞争失败的线程睡眠或自旋,释放锁后再唤醒等待者。
短临界区可以使用自旋锁,避免线程睡眠和唤醒开销;临界区较长或可能阻塞时使用互斥锁。锁的范围应尽量小,避免死锁和长时间占用。
1 | pthread_mutex_lock(&mutex); |
用户态与内核态切换
常见入口有系统调用、异常和设备中断。系统调用通常由用户程序主动发起,异常和设备中断由处理器或硬件事件触发。
1 | int file_descriptor = open("/dev/demo", O_RDWR); // 通过系统调用进入内核 |
面试回答:用户态不能直接访问硬件和内核空间,必须通过系统调用、设备文件或中断机制完成交互。
container_of 宏
内核回调有时只收到结构体成员地址,需要根据成员偏移量反推出外层结构体地址。核心就是成员地址减去成员偏移量:
1 |
pointer 是成员指针,type 是外层结构体类型,member 是成员名称。
1 | struct device_node { |
设备树的作用
设备树用于描述硬件资源和连接关系,例如 GPIO、时钟、中断、I2C 地址和设备兼容字符串。驱动通过 compatible 匹配节点,再解析其中的资源。
1 | touchscreen@5d { |
面试回答:设备树把硬件信息从驱动代码中分离出来,同一个驱动可以通过不同设备树适配不同硬件。
字符设备与用户空间数据交换
字符设备通过 file_operations 提供 open、read、write、ioctl 等接口。内核和用户空间之间不能直接解引用对方地址,通常使用 copy_to_user 和 copy_from_user。
1 | static ssize_t demo_read(struct file *file, char __user *user_buffer, |
注意:
copy_to_user返回未复制的字节数,返回值不为0时说明复制失败或没有完全成功。
零拷贝的实现思路
零拷贝的目标是减少数据在内核缓冲区和用户缓冲区之间的重复复制,常用 mmap、DMA、共享内存或 splice。实际实现还要考虑缓存一致性、生命周期、权限和同步。
例子:驱动申请一块 DMA 缓冲区,再通过
mmap映射给用户空间,用户程序直接读取这块缓冲区,减少一次数据复制。
阻塞 / 非阻塞 I/O
阻塞 I/O 在数据或资源未就绪时等待;非阻塞 I/O 立即返回。多路 I/O 通常结合 select、poll 或 epoll 监听多个文件描述符。
1 | struct pollfd event = { |
面试区别:阻塞 I/O 逻辑简单但可能占用线程;非阻塞 I/O 需要主动处理未就绪状态;
epoll更适合大量连接或设备事件。
工作队列、中断线程与 tasklet
工作队列和中断线程运行在可调度上下文,可以执行可能休眠的操作,例如 I2C 访问;tasklet 属于软中断上下文,不能休眠,只能使用自旋锁。
选择原则:需要休眠或执行较复杂任务时使用工作队列或中断线程;只需要快速延后处理且不能休眠时才考虑 tasklet。新代码通常优先考虑线程化中断或工作队列。
修改文件权限
使用 chmod 修改权限,权限分为文件所有者 u、所属组 g、其他用户 o,每类权限都有读 r=4、写 w=2、执行 x=1。
1 | chmod 755 script.sh |
数字权限是每一组权限值相加:
7=4+2+1,6=4+2,5=4+1,4=4。目录的x表示可以进入目录,文件的x表示可以执行。
Vim 基础操作
1 | i 进入插入模式 |
Linux 查找相关命令
1 | find . -name "*.c" |
find查文件,grep查文件内容,which查当前 PATH 中的可执行文件,whereis查命令相关文件。
Linux 内存查看命令
1 | free -h |
top中重点关注RES(实际占用物理内存)和%MEM;VIRT是虚拟地址空间,不等于实际物理内存。
查找某个线程
1 | ps -T -p PID |
PID是进程号,TID是线程号。定位高 CPU 线程后,可以结合strace -p TID分析。
Git 基础与冲突处理
1 | git clone URL |
发生冲突时:先执行
git status找到冲突文件,手动处理<<<<<<<、=======、>>>>>>>标记,确认内容后执行git add,最后继续提交或执行git rebase --continue。
1 | git status |
不想继续时,合并冲突使用
git merge --abort,变基冲突使用git rebase --abort。
ARP 是什么,属于哪一层
TCP、UDP 和常用协议
网络分层模型
TCP 三次握手和四次挥手
粘包与拆包如何处理
多线程之间如何通信
死锁的原因和解决方法
main 执行前发生了什么
ELF 文件是什么
程序出现段错误如何调试
常见原因有空指针、野指针、数组越界、释放后使用、栈溢出、错误函数指针和并发数据损坏。先保留 core 和日志,再使用带调试信息的程序执行:
1 | gdb ./app core |
还可以使用 AddressSanitizer 或 Valgrind 检查越界、释放后使用和内存泄漏。嵌入式设备则记录 HardFault 的 PC、LR、CFSR 和故障地址,再结合 ELF 反查源代码。
我会分场景。Linux 用户态先开 core dump,用 gdb ./app core 看 bt、frame、寄存器、指针,再用 ASan/Valgrind 查越界和 UAF。MCU 没有 core,就靠 HardFault 异常帧,读 PC、LR、CFSR、HFSR、MMFAR、BFAR,结合 ELF 用 addr2line 反查源码。
printf/日志可以作为辅助手段,用来缩小范围,比如在可疑函数前后打印指针、长度、状态。但它不能替代现场分析,而且在异常上下文、缓冲区未刷新、内存已损坏时不可靠。嵌入式里更常用 RTT、SWO、串口日志,HardFault 里先保存现场再分析。
5. 算法与项目测试
冒泡排序和快速排序的区别
冒泡排序通过相邻元素交换,把较大或较小元素逐步移动到一端;平均时间复杂度为 $O(n^2)$。快速排序选择基准进行分区,平均时间复杂度为 $O(n\log n)$,最坏为 $O(n^2)$。
冒泡排序稳定、实现简单;快速排序平均更快,但需要注意基准选择和递归栈,通常不是稳定排序。
GT911 触摸检测与乱点测试
检测触摸事件可以使用 evtest 查看解析后的输入事件,也可以使用 hexdump 查看 /dev/input/eventX 的原始数据;tslib 可以进行滤波、校准和坐标转换。
1 | evtest /dev/input/event2 |
乱点测试重点观察:无触摸时是否产生坐标、单指移动是否连续、多指触摸的槽位是否正确、松手后是否发送同步事件,以及快速点击时是否丢点或重复上报。
测试方法:进行单点静止、慢速滑动、快速滑动、多点触摸、边缘触摸和长时间空载测试,并记录
event数据、内核日志和异常坐标。
留言
有问题请指出,你可以选择以下方式:
- 在下方评论区留言
- 邮箱留言