Study|嵌入式面试与八股

一、面试记录

查漏补缺——————

龙晶石半导体|嵌入式软件|2025/09/28 02:30|LZ

  1. static 的用法
  2. const 的用法
  3. 介绍一下 STM32 的 .s 启动文件
  4. 介绍一下 STM32 的 .ld 文件
  5. 堆和栈的区别
  6. 获取内存数据类型字节数的方式
  7. 假设项目中 Flash 过大,有初始化值为 0 的变量,删掉可以减少体积吗?
  8. SPI(什么时候读取数据,怎么设置)
  9. 介绍串口和 DMA 联合使用
  10. 串口和 DMA 实现接收可变长数据(并非有规定的协议)
  11. Mutex 和优先级继承
  12. 设备树的作用
  13. 字符设备在用户空间实现数据交换怎么实现(0 拷贝)
  14. 反问

哈罗普惠|嵌入式测试|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,然后可以在图形化配置界面进行修改

这里主要涉及三个:

  1. CPU主频修改
  2. 修改EMMC的驱动模式,从4线修改为8线,更快; 修改供电,将默认的1.8V修改成3.3V
  3. 修改底层的网络驱动,因为芯片不同,部分引脚不同,同时要屏蔽别的使用了相关引脚IO的驱动
    (设备树中的PHY地址,网络驱动节点中的部分属性,复位和网络时钟引脚的驱动,这几个包括前两点都是设备树文件中修改,最后是在fec_main.c添加一个芯片复位之后的延时,要求复位之后要延时150ms,所以添加了一个延时)

然后重新编译之后,通过ifconfig进行测试网络

使用BusyBox 构建根文件系统,主要修改两部分 添加编译器 添加中文支持,选择动态编译后编译 busybox,会得到一个基础的根文件系统(BusyBox的工作也就到此为止)
然后是向根文件系统中添加向根文件系统添加 lib 库(动态库,如果是静态编译则不用,但是静态库很占内存,并且可能导致dns不能使用)和一些基础文件目录(如 dev、proc、mnt、sys、tmp 和 root 等),这里的lib库通过交叉编译器工具中获取,并且没有裁剪

3. 完整项目介绍

回答思路:项目背景 -> 硬件架构 -> 软件架构 -> 个人负责内容 -> 难点与结果。

简易自行瞄准装置为例….待补充

ZYNQ测试仪为例…待补充


三、八股板块 – 嵌入式软件

这一部分按知识领域整理,回答时优先说明概念、使用场景、实现原理和常见问题。

八股题目列表

1. C 语言

用于整理指针、关键字、内存、编译链接和标准库等基础问题。


空指针与野指针

空指针不指向任何有效内存,通常使用 NULL 表示。声明了指针但暂时没有目标地址时,应初始化为空指针。

野指针指向无效或已经释放的内存
常见原因:

1.包括返回局部变量地址
2.使用未初始化指针
3.释放后继续使用
4.指针越界和重复释放

释放后将指针置为 NULL,可以降低再次误用的风险,但不能替代生命周期管理和边界检查。

1
2
3
4
5
6
7
//完整malloc示例
int *pointer = malloc(sizeof(*pointer));
if (pointer != NULL) {
*pointer = 42;
free(pointer);
pointer = NULL;
}
栈、堆、静态区与内存布局
  • 栈:函数内的普通局部变量,生命周期通常随函数调用结束。
  • 堆:通过 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
2
3
4
5
int array[5] = {0};
int *pointer = array;

sizeof(array); // 5 * sizeof(int)
sizeof(pointer); // 指针大小
函数之间共享内存的方法

常见方法有:使用全局变量、传递指针、使用 static 变量、动态内存,以及在多线程场景中使用共享内存。

面试回答:优先通过参数传递指针,明确数据的所有权和生命周期;全局变量简单但耦合较高,多线程共享时还要配合锁或原子操作。

1
2
3
4
void update_value(int *value)
{
*value = 10;
}

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
2
3
4
5
6
7
8
//重定义printf输出为USART1
int fputc(int character, FILE *file)
{
while ((USART1->SR & 0x40) == 0) {
}
USART1->DR = (unsigned char)character;
return character;
}

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 MDKReset_Handler里会调用 C 库的 __main,由它来代劳.data、.bss的初始化和最终跳转到 main;
如果是 GCC,没有__main,通常要在汇编里手动编写搬运.>data和清零.bss的代码,最后直接bl main

1
2
3
4
Reset_Handler
; 初始化栈和数据段
bl SystemInit
bl __main
STM32 .ld 链接脚本

.ld 文件用于告诉链接器:Flash 和 RAM 的起始地址、大小,以及代码和变量如何分配

面试回答:链接脚本决定程序最终的内存布局,例如代码放在 Flash,运行时变量放在 RAM;如果程序过大,首先检查 Flash 区域和各段占用。

1
2
3
4
5
MEMORY
{
FLASH (rx) : ORIGIN = 0x08000000, LENGTH = 512K
RAM (rwx) : ORIGIN = 0x20000000, LENGTH = 128K
}
Flash、RAM、.data 与 .bss

初始化值非零的全局或静态变量放在 .data,初始值保存在 Flash,启动时复制到 RAM;初始值为零或未初始化的变量放在 .bss,启动时统一清零。

Q:初始化为 0 的全局变量会不会增加固件文件大小?

A:通常不会明显增加,因为 .bss 只记录所需空间大小,程序启动时再由启动代码清零。

1
2
3
int initialized = 1;       // 通常位于 .data
int zero_initialized = 0; // 通常位于 .bss
int uninitialized; // 通常位于 .bss
SPI 通信时序与配置

SPI 是同步、全双工通信,主要配置片选 CS、时钟 SCK、数据输入 MISO、数据输出 MOSI、时钟频率、数据位宽和传输模式

Q:SPI 的 CPOL 和 CPHA 是什么?

A:CPOL 决定空闲时钟电平,CPHA 决定在第一个还是第二个时钟边沿采样。具体使用哪种模式,要以从设备手册为准。

1
2
3
spi_config.mode = SPI_MODE_0;  // CPOL=0, CPHA=0
spi_config.frequency = 1000000;
spi_transfer(tx_buffer, rx_buffer, length);
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
2
pwm_set_frequency(1000);
pwm_set_duty(50); // 1 kHz,50% 占空比
I2C 的优势以及与 SPI 的区别

I2C 使用两根线(SCL、SDA),支持多主机和多从机,并且通过设备地址区分从设备,节省引脚和布线成本。

I2C:线少、支持地址寻址、速度相对较低,适合传感器、EEPROM 等低速设备;SPI:全双工、速度快、时序简单,但通常每个从设备需要独立片选线。

面试总结:设备数量多、引脚紧张时优先考虑 I2C;需要高速连续传输时通常选择 SPI。

中断、临界区与任务调度

中断处理函数应尽量短,只做清除标志、保存数据、发送通知等快速操作。耗时、复杂或可能阻塞的工作放到任务、工作队列或中断线程中。

Q:中断中能不能调用 printfdelay

A:一般不建议它们可能耗时、阻塞或依赖锁,会增加中断延迟;中断中只记录必要数据,再交给任务处理。

Mutex 与优先级继承

互斥锁用于保护共享资源,同一时刻只能由一个任务持有。优先级继承可以让持有锁的低优先级任务临时提升到等待者的优先级,减少优先级反转。

注意:中断上下文不能获取会休眠的互斥锁;中断中通常使用自旋锁、关闭中断或发送信号,具体取决于上下文和临界区长度。

中断是否有返回值和输入参数

中断服务函数通常没有业务上的返回值,也没有由调用者传入的普通参数。硬件触发中断后,处理器根据中断号进入固定的中断入口,驱动通过寄存器、全局状态或 dev_id 获取上下文。

Linux 中断处理函数常见形式为 irqreturn_t handler(int irq, void *dev_id);返回值用于告诉内核是否处理了该中断,不是给硬件返回业务数据。

1
2
3
4
5
irqreturn_t button_handler(int irq, void *dev_id)
{
button_event = 1;
return IRQ_HANDLED;
}
GPIO 的常见模式

GPIO 常见模式包括推挽输出、开漏输出、上拉输入、下拉输入、模拟输入、浮空输入和复用功能(复用推挽,复用开漏)

推挽输出可以主动输出高低电平;开漏输出只能主动拉低,输出高电平需要上拉电阻,适合 I2C 等总线;输入模式要根据外部电路选择上拉、下拉或浮空。

1
2
gpio_set_mode(LED_PIN, GPIO_OUTPUT_PUSH_PULL);
gpio_set_mode(KEY_PIN, GPIO_INPUT_PULL_UP);
probe 的作用与流程

probe 是 Linux 驱动匹配到设备后执行的初始化函数,通常完成解析设备树、申请 GPIO/中断/时钟、初始化硬件、注册子系统设备

常见流程:匹配 compatible -> 获取资源 -> 硬件复位 -> 读取芯片 ID -> 初始化设备 -> 注册字符设备、Input 或其他子系统 -> 开始工作。

1
2
3
4
5
6
7
static int demo_probe(struct platform_device *pdev)
{
parse_device_tree(pdev);
request_resources(pdev);
hardware_init();
return register_device();
}

I2C 和 SPI 的区别

I2C 使用 SCLSDA 两根线,通过地址区分设备,支持多主机和多从机;SPI 通常使用 SCKMOSIMISOCS,速度更高、全双工,但每个从设备通常需要独立片选。

I2C 适合传感器、EEPROM 等设备,优点是省引脚;SPI 适合高速 ADC、Flash、显示屏等设备,优点是速度快、时序简单。

I2C 时序

I2C 的 SDASCL 通常使用开漏输出并配上拉电阻。总线空闲时两根线都是高电平。开始条件是 SCL 为高电平时 SDA 从高变低;停止条件是 SCL 为高电平时 SDA 从低变高。每个字节发送后,第 9 个时钟用于 ACK 或 NACK。

需要注意设备地址、读写位、重复起始、ACK、总线忙和超时。SDA 在 SCL 高电平期间不能随意变化,否则可能被识别为起始或停止条件。

SPI 时序和模式

SPI 开始时主机拉低 CS,再根据时钟边沿同时发送和采样数据,结束后释放 CSCPOL 决定空闲电平,CPHA 决定在哪个边沿采样,因此共有四种模式:

模式CPOLCPHA
000
101
210
311

具体模式要以从设备手册为准,同时确认最大时钟频率、位序、数据宽度和片选保持时间。

RTOS 中如何进行内存管理

RTOS 主要管理任务栈、内核对象和堆。静态分配大小稳定、不会产生碎片;动态分配灵活,但可能失败或产生碎片;固定内存块速度稳定,适合消息和网络包。

实际项目要检查任务栈高水位、堆剩余空间和申请失败次数。不要在高频任务中反复申请释放,也不要在中断中调用可能阻塞的分配接口。

线程切换和调度

线程因时间片用完、主动阻塞、等待资源或被更高优先级线程抢占而触发调度。调度器从就绪队列选择下一个线程,保存当前线程的寄存器和栈指针,再恢复下一个线程的寄存器和栈指针。

在 Cortex-M 中,RTOS 常用 SysTick 产生时钟节拍,使用 PendSV 执行上下文切换,使用 SVC 启动第一个任务。临界区过长或优先级设置不合理都会影响实时性。

U-Boot 引导流程

Boot ROM 上电后从固定介质加载初始代码,随后进入 U-Boot。U-Boot 初始化时钟、内存和串口,读取环境变量,再加载 Linux 内核、设备树和可选的 initramfs,最后启动内核。

bootcmd 是启动命令,bootargs 是传给内核的参数,例如控制台、根文件系统位置和文件系统类型。调试时可以先在命令行手动执行加载命令。

常用文件系统和 Flash

FAT 适合 SD 卡和 U 盘,ext4 常用于 eMMC 等块设备,JFFS2 和 UBIFS 面向原始 NAND/NOR Flash,LittleFS 适合资源有限的 MCU 和 SPI NOR Flash。

Flash 不能像 RAM 一样任意改写,擦除通常以块或扇区为单位,擦写次数也有限。因此要考虑坏块、磨损均衡、掉电保护、写入对齐和缓存一致性。掉电敏感数据可以使用日志、双备份、版本号和 CRC。

RAM 是易失、可随机读写、可按字节覆盖、速度快、寿命近乎无限;Flash 是非易失、读可以随机但写不能任意覆盖、按页写按块擦、速度慢、寿命有限

3. C++

封装、继承与多态

封装是把数据和操作数据的函数放在类中,并通过 privateprotectedpublic 控制访问;继承是子类复用和扩展父类;多态是同一个接口根据对象实际类型执行不同实现。

面试回答:封装提高安全性和可维护性,继承实现代码复用,多态提高扩展性。C++ 中动态多态通常通过虚函数和基类指针或引用实现。

1
2
3
4
5
6
7
8
9
10
11
12
13
class Device {
private:
int status = 0;

public:
virtual void start() = 0;
virtual ~Device() = default;
};

class Sensor : public Device {
public:
void start() override { /* 启动传感器 */ }
};
Lambda 表达式

Lambda 是可以直接定义和使用的匿名函数,常用于回调、算法和线程任务。基本形式为 [捕获列表](参数列表) -> 返回值 { 函数体 }

捕获列表决定 Lambda 如何使用外部变量:[x] 按值捕获,[&x] 按引用捕获,[=] 全部按值捕获,[&] 全部按引用捕获。

1
2
3
4
5
6
int factor = 2;
auto multiply = [factor](int value) {
return value * factor;
};

int result = multiply(3); // 6
构造函数和析构函数

构造函数用于初始化对象,析构函数用于释放对象持有的资源,二者都没有返回值,不能写 void。构造函数可以重载,析构函数一个类只能有一个。

构造函数在对象创建时自动调用,析构函数在对象离开作用域或被 delete 时自动调用。父类析构函数通常应声明为 virtual,避免基类指针删除子类对象时资源释放不完整。

1
2
3
4
5
class Buffer {
public:
Buffer() { /* 申请资源 */ }
~Buffer() { /* 释放资源 */ }
};
面向对象与多态

多态分为静态多态和动态多态。函数重载、运算符重载和模板属于静态多态;虚函数属于动态多态,在运行时根据对象实际类型选择函数。

动态多态条件:存在继承关系,子类重写父类虚函数(父类为抽象类),并通过父类指针或引用调用。如果父类要被正确析构,通常应提供虚析构函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class Device {
public:
virtual void start() = 0;
virtual ~Device() = default; //虚析构函数,因为delete device是直接释放父类指针,如果不是虚析构函数,则不会调用子类的析构函数
};

class Sensor : public Device {
public:
void start() override { /* 启动传感器 */ }
};

Device *device = new Sensor;
device->start(); // 调用 Sensor::start
delete device;
malloc、new、free 与 delete

malloc / free 是 C 的库函数,只分配和释放原始内存,不调用构造函数和析构函数;new / delete 是 C++ 运算符,会分别完成对象构造和析构。数组申请必须使用 new[]delete[] 配对。

1
2
3
4
5
int *data = static_cast<int *>(malloc(sizeof(int)));
free(data);

Sensor *sensor = new Sensor;
delete sensor;

面试易错点:malloc 不能和 delete 混用,new 不能和 free 混用;现代 C++ 优先使用对象自动管理资源,减少手动释放。

虚函数是怎么实现的

常见 C++ 编译器会为包含虚函数的类生成虚函数表,对象中保存一个指向虚函数表的指针。通过基类指针调用虚函数时,程序运行时查表找到真实函数,因此可以调用子类实现。

虚函数会增加对象空间和一次间接调用开销。构造函数中不要依赖子类虚函数,因为子类部分还没有完成构造。

C++ 虚函数和纯虚函数的区别

虚函数使用 virtual 声明,父类可以提供默认实现,子类可以选择重写。纯虚函数在声明后加 = 0,表示父类只规定接口;含有纯虚函数的类是抽象类,不能直接创建对象,子类实现所有纯虚函数后才能实例化。

1
2
3
4
5
6
class Device {
public:
virtual void start() { }
virtual void stop() = 0;
virtual ~Device() = default;
};

4. Linux

进程和线程的区别

进程是资源分配的基本单位,拥有独立的地址空间;线程是 CPU 调度的基本单位,同一进程中的线程共享代码段、数据段和文件描述符,但拥有独立的栈和寄存器上下文。

进程隔离性好但创建和通信开销较大;线程创建和切换开销较小,但共享资源时需要同步,错误可能影响同一进程中的其他线程。

创建进程和线程的 API

Linux 创建进程常用 forkvforkclone,执行新程序常用 exec 系列函数;创建线程常用 POSIX 线程库 pthread_create

1
2
3
4
5
6
7
8
pid_t pid = fork();
if (pid == 0) {
execl("/bin/ls", "ls", NULL);
}

pthread_t thread;
pthread_create(&thread, NULL, worker, NULL);
pthread_join(thread, NULL);

fork 复制当前进程,父进程得到子进程 PID,子进程得到 0pthread_create 创建线程并执行指定入口函数。

任务共享资源和同步方法

常见同步方法包括互斥锁、读写锁、自旋锁、信号量、条件变量、原子变量、消息队列、管道和共享内存

线程共享变量时使用互斥锁或原子变量;生产者和消费者可以使用信号量或条件变量;进程之间可以使用管道、消息队列或共享内存配合同步锁。

互斥锁和同步锁的区别

互斥锁主要解决同一时间只能有一个任务访问临界资源;同步机制主要解决任务之间的执行顺序和事件通知。互斥锁强调资源保护,同步强调协作。

例子:访问共享链表使用互斥锁;线程 A 处理完数据后通知线程 B,可以使用条件变量或信号量。一个程序中可以同时使用互斥和同步。

锁的原理和实现

锁的核心是让多个执行流对共享状态进行原子竞争。互斥锁通常使用原子指令实现“空闲 -> 持有”的状态转换,竞争失败的线程睡眠或自旋,释放锁后再唤醒等待者。

短临界区可以使用自旋锁,避免线程睡眠和唤醒开销;临界区较长或可能阻塞时使用互斥锁。锁的范围应尽量小,避免死锁和长时间占用。

1
2
3
pthread_mutex_lock(&mutex);
shared_value++;
pthread_mutex_unlock(&mutex);
用户态与内核态切换

常见入口有系统调用、异常和设备中断。系统调用通常由用户程序主动发起,异常和设备中断由处理器或硬件事件触发。

1
2
int file_descriptor = open("/dev/demo", O_RDWR); // 通过系统调用进入内核
read(file_descriptor, buffer, sizeof(buffer));

面试回答:用户态不能直接访问硬件和内核空间,必须通过系统调用、设备文件或中断机制完成交互。

container_of 宏

内核回调有时只收到结构体成员地址,需要根据成员偏移量反推出外层结构体地址。核心就是成员地址减去成员偏移量

1
2
#define container_of(pointer, type, member) \\
((type *)((char *)(pointer) - (char *)(&((type *)0)->member)))

pointer 是成员指针,type 是外层结构体类型,member 是成员名称。

1
2
3
4
5
6
7
struct device_node {
int id;
struct list_head list;
};

struct list_head *node_list = get_list_entry();
struct device_node *node = container_of(node_list, struct device_node, list);
设备树的作用

设备树用于描述硬件资源和连接关系,例如 GPIO、时钟、中断、I2C 地址和设备兼容字符串。驱动通过 compatible 匹配节点,再解析其中的资源。

1
2
3
4
5
6
touchscreen@5d {
compatible = "goodix,gt911";
reg = <0x5d>;
interrupt-parent = <&gpio1>;
interrupts = <5 IRQ_TYPE_EDGE_RISING>;
};

面试回答:设备树把硬件信息从驱动代码中分离出来,同一个驱动可以通过不同设备树适配不同硬件。

字符设备与用户空间数据交换

字符设备通过 file_operations 提供 openreadwriteioctl 等接口。内核和用户空间之间不能直接解引用对方地址,通常使用 copy_to_usercopy_from_user

1
2
3
4
5
6
7
8
static ssize_t demo_read(struct file *file, char __user *user_buffer,
size_t size, loff_t *offset)
{
if (copy_to_user(user_buffer, kernel_buffer, size) != 0) {
return -EFAULT;
}
return size;
}

注意:copy_to_user 返回未复制的字节数,返回值不为 0 时说明复制失败或没有完全成功。

零拷贝的实现思路

零拷贝的目标是减少数据在内核缓冲区和用户缓冲区之间的重复复制,常用 mmap、DMA、共享内存或 splice。实际实现还要考虑缓存一致性、生命周期、权限和同步。

例子:驱动申请一块 DMA 缓冲区,再通过 mmap 映射给用户空间,用户程序直接读取这块缓冲区,减少一次数据复制。

阻塞 / 非阻塞 I/O

阻塞 I/O 在数据或资源未就绪时等待;非阻塞 I/O 立即返回。多路 I/O 通常结合 selectpollepoll 监听多个文件描述符。

1
2
3
4
5
struct pollfd event = {
.fd = file_descriptor,
.events = POLLIN,
};
poll(&event, 1, 1000);

面试区别:阻塞 I/O 逻辑简单但可能占用线程;非阻塞 I/O 需要主动处理未就绪状态;epoll 更适合大量连接或设备事件。

工作队列、中断线程与 tasklet

工作队列和中断线程运行在可调度上下文,可以执行可能休眠的操作,例如 I2C 访问;tasklet 属于软中断上下文,不能休眠,只能使用自旋锁。

选择原则:需要休眠或执行较复杂任务时使用工作队列或中断线程;只需要快速延后处理且不能休眠时才考虑 tasklet。新代码通常优先考虑线程化中断或工作队列。

修改文件权限

使用 chmod 修改权限,权限分为文件所有者 u、所属组 g、其他用户 o,每类权限都有读 r=4、写 w=2、执行 x=1

1
2
chmod 755 script.sh
chmod 644 config.ini

数字权限是每一组权限值相加:7=4+2+16=4+25=4+14=4。目录的 x 表示可以进入目录,文件的 x 表示可以执行。

Vim 基础操作
1
2
3
4
5
6
7
8
9
10
11
12
13
i              进入插入模式
Esc 返回普通模式
:w 保存
:q 退出
:wq 保存并退出
:q! 不保存退出
/keyword 向下查找 keyword
n 查找下一个
N 查找上一个
dd 删除当前行
yy 复制当前行
p 粘贴
u 撤销
Linux 查找相关命令
1
2
3
4
find . -name "*.c"
grep -rn "keyword" .
which gcc
whereis gcc

find 查文件,grep 查文件内容,which 查当前 PATH 中的可执行文件,whereis 查命令相关文件。

Linux 内存查看命令
1
2
3
4
free -h
top
ps aux
cat /proc/meminfo

top 中重点关注 RES(实际占用物理内存)和 %MEMVIRT 是虚拟地址空间,不等于实际物理内存。

查找某个线程
1
2
3
ps -T -p PID
top -H -p PID
cat /proc/PID/task/TID/status

PID 是进程号,TID 是线程号。定位高 CPU 线程后,可以结合 strace -p TID 分析。

Git 基础与冲突处理
1
2
3
4
5
6
7
git clone URL
git status
git add file
git commit -m "message"
git pull --rebase
git push origin branch
git log --oneline

发生冲突时:先执行 git status 找到冲突文件,手动处理 <<<<<<<=======>>>>>>> 标记,确认内容后执行 git add,最后继续提交或执行 git rebase --continue

1
2
3
git status
git add conflicted_file
git commit -m "resolve merge conflict"

不想继续时,合并冲突使用 git merge --abort,变基冲突使用 git rebase --abort

ARP 是什么,属于哪一层

ARP 是地址解析协议,用于在局域网中根据 IPv4 地址查询目标设备的 MAC 地址。主机先广播查询,目标设备再回复自己的 MAC 地址,结果通常会缓存起来。

ARP 直接封装在以太网帧中,不使用 TCP 或 UDP 端口。按 TCP/IP 模型通常归入网络接口层;按 OSI 模型可理解为处于网络层和数据链路层之间。IPv6 不使用 ARP,而使用 NDP。

TCP、UDP 和常用协议

TCP 面向连接、可靠、有序,适合 HTTP、文件传输和登录服务;UDP 无连接、开销小、不保证到达和顺序,适合 DNS、音视频和设备广播。

常见相关协议还有:IP 负责寻址和路由,ICMP 用于 ping 等网络诊断,DNS 负责域名解析,ARP 负责 IPv4 地址到 MAC 地址的解析,HTTP 用于网页和接口,MQTT 适合物联网发布/订阅消息。

网络分层模型

OSI 七层模型从下到上是物理层、数据链路层、网络层、传输层、会话层、表示层和应用层。实际开发更常使用 TCP/IP 模型:网络接口层、网际层、传输层和应用层。

分层的好处是职责清晰。例如网线属于底层,IP 负责寻址,TCP/UDP 负责端到端传输,HTTP/MQTT 负责业务。

TCP 三次握手和四次挥手

三次握手:客户端发送 SYN,服务端回复 SYN + ACK,客户端再回复 ACK,双方建立连接。四次挥手:主动关闭方发送 FIN,对方回复 ACK;对方数据发送完后再发送 FIN,主动关闭方回复 ACK

握手是确认双方收发能力;挥手通常需要四次,是因为 TCP 的发送和接收方向可以分别关闭。TIME_WAIT 用于让旧报文过期并保证最后的 ACK 能够重传。

粘包与拆包如何处理

TCP 是字节流协议,没有消息边界。多次发送的数据可能一次接收,也可能一次数据分多次接收,这就是粘包和拆包。它不是 TCP 出错,而是应用层没有定义消息边界。

常用解决方法是固定长度、分隔符、长度字段或 TLV/Protobuf 等自描述格式。嵌入式串口也建议使用帧头、长度、数据和校验和,并配合环形缓冲区和状态机解析。

多线程之间如何通信

线程共享同一进程的地址空间,可以使用共享变量、消息队列、管道、条件变量、信号量、互斥锁、读写锁和原子变量。

共享数据要用锁或原子操作保护;生产者和消费者适合队列、信号量或条件变量;中断与任务之间通常使用环形缓冲区、队列或信号量,不在中断中获取互斥锁。

死锁的原因和解决方法

死锁通常同时具备互斥、占有且等待、不可剥夺、循环等待四个条件。解决方法包括按统一顺序申请多个锁、缩短持锁时间、使用超时锁、避免持锁时执行 I/O,并记录锁的持有者和等待者。

排查时查看各线程调用栈和锁状态,画出“线程 -> 等待的锁 -> 持有线程”的关系,通常可以找到循环等待。

main 执行前发生了什么

操作系统加载器先把 ELF 文件映射到进程地址空间,准备参数和环境变量,然后跳转到入口。C 运行库通常负责初始化 .data、清零 .bss、准备堆、执行初始化函数和 C++ 全局对象构造函数,最后调用 main

Linux 用户程序一般由 C 运行库的 _start 衔接;裸机程序则由启动文件和链接脚本完成大部分初始化。加载器负责装入文件、建立地址映射、完成动态链接和重定位,再把控制权交给入口地址。

ELF 文件是什么

ELF 是 Linux 常见的可执行文件、目标文件、共享库和 core dump 格式。ELF 头描述文件类型和入口地址,程序头表告诉加载器如何映射内存,节区头表描述 .text.data、符号表和调试信息。

常用命令有 filereadelfobjdumpnm。嵌入式工程通常先生成 ELF,再转换为 BIN 或 HEX 烧录。

程序出现段错误如何调试

常见原因有空指针、野指针、数组越界、释放后使用、栈溢出、错误函数指针和并发数据损坏。先保留 core 和日志,再使用带调试信息的程序执行:

1
2
3
4
gdb ./app core
(gdb) bt
(gdb) frame 0
(gdb) print pointer

还可以使用 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
2
3
evtest /dev/input/event2
hexdump -C /dev/input/event2
ts_test

乱点测试重点观察:无触摸时是否产生坐标、单指移动是否连续、多指触摸的槽位是否正确、松手后是否发送同步事件,以及快速点击时是否丢点或重复上报。

测试方法:进行单点静止、慢速滑动、快速滑动、多点触摸、边缘触摸和长时间空载测试,并记录 event 数据、内核日志和异常坐标。

留言

有问题请指出,你可以选择以下方式:

  1. 在下方评论区留言
  2. 邮箱留言