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. 完整项目介绍

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

3.1 以简易自行瞄准装置为例

本项目是 2025 年全国大学生电子设计竞赛的控制类题目,要求队伍在四天三夜内设计一台自行瞄准装置,整体分为两部分:循迹部分和云台瞄准部分。

循迹部分要求使用 TI 系列的 MSPM0G3507 MCU 控制小车沿正方形区域循迹,有速度要求,且不允许与瞄准装置主控通信(循迹部分与云台瞄准部分的电源需要相互独立)。

循迹方案采用七路循迹模块,使用纯 GPIO 读取(因为题目不允许使用带 MCU 的循迹模块)。

通过调节模块上的电位器来更改基准电压,用电压比较器对比基准电压与检测电压(接收管根据光照灰度等输出),得到固定的数字量 IO 值,STM32 读取后作为循迹算法的输入。

电机采用 JGB-520 减速电机,用 PWM + TB6612 控制,电机自带 AB 相编码器,可以闭环控制。

软件模拟判断编码值:两个外部中断线分别接 A 相和 B 相,都配置为双边沿触发(上升沿 + 下降沿,单边沿也可以)。根据 A/B 相边沿触发的先后顺序判断电机转向,根据触发频率判断电机转速。

电源采用 MP2225:12V 电池直流供电,经 TB6612 降压为 5V 后输入给 MCU 及其它外设。

云台瞄准部分没有限制,要求小车行进过程中云台用激光笔对目标靶心进行瞄准。我们队伍的方案是:用 STM32F103C8T6 做主控,K230 做视觉模块,云台采用舵机 + 步进电机组合,激光笔用继电器控制。

云台瞄准还引入 JY901(串口/IIC 通信)辅助,主要在小车转弯或抖动时对云台旋转提供补偿,期望采用串级 PID:

外环(角度环):输入目标角度与实际角度的偏差,输出期望角速度。
内环(角速度环):输入期望角速度与实际角速度的偏差,输出电机控制量。
这样内环能快速感知并抑制角速度的突变,外环负责最终把角度拉回目标值。转弯时小车车身在快速旋转,内环能第一时间”顶住”这个旋转趋势,云台方向稳定性明显提升。
可延伸引入无人机项目。

相关问题:

Q:项目的人员分工和准备?
A:我作为队长,在题目出来后的第一时间查看题单,选择适合我们队伍的题目,定下初始方案,并安排每名队员的工作内容。

我主要负责前期所有非视觉模块的驱动封装和模块化的 PCB 设计,写好注释方便队友阅读;
队友搭建硬件遇到连接问题时,我负责在 SolidWorks 上建模,再用 3D 打印机快速制作需要的结构;
方案初步确定后,我快速编写题目需要的代码逻辑、搭建调试信息,并辅助硬件和视觉队友。
队友 A 负责硬件搭建与整理,同时和我联调代码逻辑,以及后期的 PID 参数调试。
队友 B 负责视觉相关的所有代码、报告撰写和器材购置;我主要负责 OpenMV 部分,他负责 K230 部分。

赛前准备:我们主要练过两个题——送药小车(练习简单的数字识别和小车循迹逻辑)和激光笔追踪(练习云台的 PID 控制)。

Q: 项目的软件架构是怎么样的?
A:赛前我们封装了一个简单的二级菜单,构建了「阻塞式状态机 + 回调机制 + 分层设计」的嵌入式菜单架构:用 MENU_ITEM 命令模式统一管理菜单项,通过按键(确认/上移/下移)驱动状态迁移,菜单层与业务层通过 Work_Init/Work_Ret 和标志位解耦,适合比赛快速开发。
得益于菜单的便捷性,开发每个题目时,只需在业务层 work.c 里对应的 work1()、work2() 中实现对应题目即可。
对于 K230 视觉模块,我们在 Python 中读取数据后直接预处理,把摄像头中心与识别到的靶心的距离作为目标值,返回给 STM32 进行 PID 控制。

Q: 这样设计硬件的优势以及选型原因?
A:我们本次所有电赛模块与选型都以模块化为主。比如电源模块部分,若 MP2225 出现损坏,可以直接替换对应零件,而不用解焊或更换整个电路板;主控 STM32、MSPM0 主要考虑功能性,我们用 PCB 引出 MCU 所有可能用到的接口,代码也提前编写好,题目出来后只需考虑硬件连接。

Q:为什么云台部分使用舵机 + 步进电机,而不是双步进或双舵机?
A:舵机的优势是初始值固定、用 PWM 控制更简单;步进电机的控制更精细。我们用的是两个 GPIO 信号:
DIR:方向电平(0/1 对应正反转)
STP:脉冲信号(一个上升沿走一步)
本次是打靶题,目标靶在水平方向基本固定:用舵机时初始值固定,更容易控制 Y 轴的高度来瞄准;而 X 轴方向小车运动较快,需要更快的响应和更精细的控制(直线插补运动算法)。

Q:本次项目中遇到的难点有哪些?
A:

  1. MSPM0G3507 的板子定时器有限,仅有一路正交编码器接口(QEI),而前后轮是两路,所以我们用定时器 + 外部中断、通过软件模拟来读取编码器。
  2. 陀螺仪补偿部分当时没有做串级 PID,只读取了角度值,缺少了加速度的补充,导致拐角处抖动非常明显,附加题在多次测试中偶尔会丢分。
  3. 赛前准备不够充分:针对舵机瞄准的题目,我们没有提前购置导电滑环;附加题循迹两圈时线路过长,可能影响运行状态。

Q:对于视觉识别,之前你说到你负责openmv,队友C负责K230,二者的区别在哪?
A:OpenMV 更像“MCU 级视觉模块”,主打 MicroPython、传统图像算法,开发快但算力有限,适合色块、循迹、AprilTag 等简单任务;K230 是带 AI 加速的视觉 SoC,算力更强,能跑轻量神经网络,适合复杂目标检测和靶心识别。项目通过深度学习来识别靶心,把靶心偏差传给 STM32 做云台控制。

Q:这个项目中循迹部分和瞄准部分是如何协调的?
A:

Q:你们或者比赛官方是如何来评判自己设计的这个小车的性能指标的,是如何不断优化调试的?
A:

延伸深挖问答

Q1:题目指标和评分点是什么?你们方案怎么拆解?为什么选 MSPM0 + STM32 + K230?

A:题目有两个硬约束:循迹车必须用 TI 的 MSPM0G3507,且循迹部分与瞄准部分电源相互独立、两者不允许通信。
评分点分两块:循迹车看能否又快又稳地跑完正方形区域;瞄准装置看小车行进中打靶心的命中率和稳定速度。
拆解思路就是按限制分块、每块单独能跑:循迹车只做纯控制(七路循迹 + 编码器闭环 + TB6612),瞄准部分用 STM32F103C8T6 做主控、K230 做视觉。
选型理由:MSPM0 是题目指定;STM32F103C8T6 生态好、资料多、我们最熟,四天三夜能快速出活;K230 带 AI 加速,能跑轻量神经网络识别靶心,比传统图像算法稳。

Q2:编码器软件模拟时,中断丢步怎么办?测速周期取多少?

A:MSPM0G3507 只有一路 QEI,前后轮是两路编码器,所以第二路用”定时器 + 外部中断”模拟(A/B 两相都配双边沿,一个周期数 4 个边沿,等于四倍频)。
丢步:中断太密就会漏边沿。①中断函数里只做计数,不打印、不浮点、不调用任何耗时函数;②对其中的一相只取单边沿,把中断次数减半;③最根本的办法是能用定时器硬件计数就别用软件中断,硬件计数不会丢。
测速周期:速度环一般取 10ms(100Hz)。太慢响应跟不上,太快则一个周期内脉冲太少、算出的速度噪声大。做法是每 10ms 定时读一次计数、算出速度后清零重新累计。

Q3:串级 PID 内外环频率怎么定?参数怎么整定?JY901 数据怎么融合?为什么最后没做串级?

A:频率:内环(角速度环)快,一般 100~200Hz;外环(角度环)20~50Hz,内外环频率比通常取 5~10 倍。
整定顺序:先内后外。先断开外环,只给内环一个阶跃目标,调 P 到响应快且不振荡,再加一点 I 消静差、加一点 D 压超调;内环调好再闭合外环,用同样方法调外环。
JY901 融合:它内部自带姿态解算,直接输出角度、角速度、加速度。我们主要用它做云台补偿——小车转弯时车身在旋转,就把这个角速度作为前馈叠加到云台控制量上,让云台”提前顶住”旋转趋势。
为什么没做串级:时间不够。四天三夜里串级要整定两套参数,内环还依赖陀螺仪角速度,调试量和风险都大。最后只做了角度单环 + 前馈补偿,能用但转弯时抖动明显,附加题偶尔丢分。

Q4:电源独立要求怎么满足?MP2225、TB6612 电路怎么设计?共地问题怎么处理?

A:电源独立:题目要求循迹和瞄准两部分电源相互独立,所以做了两套独立电源,12V 电池分别给两条支路,各自经自己的降压/稳压电路,两块板之间不共电源也不通信,避免一边电机堵转把另一边 MCU 拉掉电。
MP2225(同步降压 DC-DC)负责把 12V 降到 5V 给 MCU 和外设:输入加电解 + 陶瓷电容去耦;输出按开关频率和电流算电感(典型 4.710µH)和输出电容;反馈电阻按目标电压分压计算、选 1% 精度;SW 开关节点走线要短要粗、远离模拟信号。
TB6612(双 H 桥电机驱动)负责电机功率输出:VM 接 12V 并加去耦,VCC 接 5V 逻辑电源,STBY 必须拉高使能;PWM 频率取 **10
20kHz**(高于人耳可听范围,避免电机啸叫);输出加续流保护。
共地:只要两块板之间有信号线(传感器、调试串口),就必须共地,否则电平没有共同参考、通信一定乱。做法是两块板的 GND 用粗线单点连接,不形成地环路;电机的大电流回流路径单独走,不和模拟/信号地混在一起,否则地线上的压降会直接干扰 MCU。

Q5:K230 识别靶心的模型、帧率、延迟、坐标映射和串口协议分别是什么?

A:模型:用轻量神经网络训练靶心识别,由 K230 的 KPU 做推理加速。帧率/延迟:轻量模型一般 15~30fps,单帧推理几十毫秒,叠加串口传输和 STM32 控制周期后端到端约 50~100ms,所以云台必须靠 PID 提前补偿,不能”看到偏差再慢慢追”。坐标映射:K230 输出图像像素坐标,取画面中心与靶心的偏差 (dx, dy),按标定好的比例换算成云台角度偏差,作为 PID 目标值。串口协议:自定义帧 帧头 + 数据长度 + dx + dy + 校验 + 帧尾,固定波特率,STM32 用串口 + 空闲中断/DMA 收帧,用校验和或 CRC 防误帧。

Q6:国赛综合测评没过,具体哪部分失败?如果重来怎么改?

A:失败在哪:综合测评考的是现场从零搭电路、写代码的能力,我们准备不够——平时习惯用现成模块和封装好的库,一旦要求不看例程手写就慢;上场也偏紧张,没把题目要求读透就动手,方向对但细节翻车。
如果重来:①赛前把”裸写”能力练起来,不用 HAL 也能点灯、配定时器、写串口;②提前搭熟常见电路(运放、比较器、ADC 采样、电机驱动);③上场先花几分钟读完题目、把评分点列出来再动手,避免做无用功。

Q7:云台抖动时,你怎么从机械、控制、视觉三方面排查?

A:按”先机械、再视觉、最后控制“的顺序排,因为机械和视觉是”输入有问题”,控制是”输出有问题”。
机械:先看有没有回程间隙和虚位——舵机摇臂松没松、步进联轴器打不打滑、结构刚度够不够、重心是否偏心;再看电机本身有没有共振。靠拧紧、加支撑、减负载、避开共振点解决。
视觉:靶心坐标一抖云台立刻就跟着抖。把 dx/dy 打出来看曲线,如果它在闪,就加滤波/限幅/死区(滑动平均、设最小变化阈值),或提高识别帧率、改善光照。
控制:最后看 PID——P 或 D 太大会高频自激,采样周期不稳也会抖。录下角度曲线,区分是”高频自激”(参数问题)还是”低频摆动”(响应慢);再检查前馈方向和实际转向是否一致、有没有相位滞后。


3.2 以ZYNQ 测试仪为例

本项目是的一个测试仪项目,需求是制作一台能测试相关产品的仪器(主要检测电压、电流、电阻,提供不同的通信接口和便捷的外设接口,可供二次开发)。第一代版本仅能检测对应型号,我负责第二代版本的软件和上位机开发*:上位机主动配置 JSON 串下发给下位机,下位机解析后按对应流程进行检测并上报。

前期我主要负责所有外设的驱动编写(ZYNQ7020 的 PS 部分)和测试,并移植 MicroPython、cJSON、Elog、LetterShell 等组件。

中期引入 FreeRTOS 实时操作系统,规范并整理之前的驱动代码,按顺序执行;同时为串口通信、网口通信等创建相应任务,防止冲突。
针对 MicroPython,我用 Claude Code 写了一个简单的 VS Code 插件,支持直接把 Python 脚本发送给下位机执行。下位机的 MicroPython 移植网上没有相关教程,我选择把源码直接放进 SDK 中编译,并在 Ubuntu 环境下生成 MicroPython 需要的 Qstr 字符串;随后我编写 Python 的 machine 模块,在接口中添加对应 GPIO 等外设的初始化和调用,让用户可以用 Python 脚本直接驱动外设,而无需了解 ZYNQ 的相关知识(这是第二个需求)。

machine 模块是 MicroPython 面向硬件外设的标准抽象层。我在 ZYNQ 平台上从零实现,把底层 SDK 的 GPIO / UART / IIC / SPI 等驱动封装成 Python 侧统一、易用的接口,让用户只写 Python 就能操作外设。

后期我主要负责电源模块相关驱动的编写和联调,针对不同的 ADC 芯片,根据手册或 demo 编写、移植代码,主要涉及 AXI-IIC 和 AXI-SPI,同时测试对应电源模块电路是否有问题、读取值是否稳定。

这里驱动过的芯片如下:

RTC(PCF8563) AXI-IIC
KEY(CH455) AXI-IIC
AD7606 ADC — AXI-IIC
ADS8681 ADC — AXI-SPI
TCA9548APWR IIC 多路开关
MCP23017 IIC → 多路 IO
WK2132 IIC → 串口

Q:本次项目中遇到的难点有哪些?
A:

  1. 第一次接触实际工程项目,代码的流程和规范性要求更高,同时也接触到更多驱动和电路。编写代码时要阅读手册、查看原理、搜索相关教程,是一次很实在的学习过程。
  2. 让我印象最深的是内存管理上的问题。板端用 cJSON 解析上位机下发的 JSON 测试计划串,执行完再用 cJSON_Delete() 释放。结果上位机”一键执行”连续下发测试步骤时,板子跑到某一步就卡死了:串口不再输出调试信息、不回 ACK,再发任何指令都没反应。

现象。 不是”死机”,不是 HardFault 也没有复位,看门狗也没触发,初步判定是某个代码执行卡死,我又顺着打印日志一层层缩小范围,发现它是在连续执行到某一次串口收发动作之后出问题的。

排查。 我用 JTAG + SDK 的调试工具挂上去,最后发现 PC 停在 cJSON_Delete() 这个释放函数内部出不来——里面的指针一直指向 next,再指向 next,怎么都走不到链表结尾,就是死循环。一开始我以为是传入的缓存指针有问题,最后找到真正原因:这其实是内存管理方案的问题,而且有两层。

第一层根因:cJSON 用错了堆。 这块板子上其实有好几个”内存池”:cJSON 默认用的是 C 标准库(newlib)的 malloc/free,而 newlib 那个堆在链接脚本里写死只有 40KB;FreeRTOS 自己有另一个 259KB 的堆。cJSON 偏偏用了那个 40KB 的小堆。而我们一次执行要同时装下解析出来的整棵计划树(上百个节点,动作执行是在持有这棵树的循环里跑的,所以整步都活着)、每个动作还要再生成一份结果文档,再加上渲染出来的字符串和帧缓冲,峰值加起来超过 40KB,分配就失败了,malloc 返回 NULL。

关键坑在旧版 cJSON 对分配失败完全不检查,拿到 NULL 照样往下用。而且在这颗芯片上,NULL 还不是个安全值:地址 0 恰好是被映射了的 OCM 内存,读写它都不报错。于是节点被静默地挂到垃圾地址上,等到 cJSON_Delete 去释放时,这些”假指针”被当成真块插进分配器的空闲链表,把链表写成了环——下一次 free() 在链表里找位置,永远走不到终点,无限自旋,而且全程不触发任何异常。这就是它表现为”CPU 活着但永远不返回”的真正原因。

第二层根因:并发踩踏。 newlib 的 malloc/free 本身不可重入,而这套 BSP 给的 __malloc_lock 是空函数,等于没锁。我的帧构造函数会被两个任务同时调用(UART 解析任务 + JSON 执行器任务),两个任务同时 malloc/free,就把空闲链表撞坏了,症状跟上面完全一样。这也解释了为什么只有上位机自动连续下发才复现、我手动隔几秒发一条就没事:上位机是”收到上一步结果立刻发下一步”,指令正好落进板子的忙窗口;手动操作永远碰不上这个时间点。时序相关、只在自动化下复现,这本身就是并发问题的典型特征。

修复。 ① 把 cJSON 的分配器从 40KB 的 C 库堆整体换到 259KB 的 FreeRTOS 堆(heap_4 靠挂起调度器天然互斥,顺带消掉一条竞争);② 把分配失败全部检查起来,让”内存不够”退化成”少一个字段 + 一个 nomem 标记”,而不是把内存写坏;③ 给 cJSON_Delete 加节点预算(上限 8192),它是执行器每步的收尾路径,宁可”有界泄漏 + 打一行诊断日志”也绝不允许无界死循环把设备搞成砖;④ 给 C 库分配器补上 __malloc_lock/__malloc_unlock,而且必须用递归互斥锁(newlib 格式化浮点数时会在持锁状态下再 malloc,普通锁会把自己锁死);⑤ 释放端统一走 cJSON_Free(),绝不用 C 库的 free 混着释放——两个堆的块头格式不一样,混用等于毁堆。

堆算法:FreeRTOS heap_1 只分配、heap_2 无合并、heap_3 包 malloc、heap_4 首次适应+合并、heap_5 多区域;也可用 TLSF、伙伴、slab 等。

最大的收获:裸机上写 malloc 之前,一定要先想清楚”这是哪个堆、多大、有没有锁、和谁共享”;分配失败必须检查,在”地址 0 可访问”的平台上,一个不检查的 NULL 等于静默的内存破坏;实时系统里”永远不返回”比”返回一个错误”可怕得多。

  1. 上位机方面,后期我主要是负责上位机部分的开发,逻辑方面很好实现,但是在理解用户以及设计适合用户操作的部分就有所欠缺,比如我在写一些操作按键按钮,或者说提示的时候,会本能的选择比较专业的词汇,没有站在客户的角度上考虑他们是否理解,会不会引起歧义,他们实际想怎么样,同样的问题还有配色以及布局
  1. 实际的测试过程还会和硬件工程师联调,所以有时候ADC读取的值有问题,无法读取寄存器或者读取的值有问题,我也要通过示波器查看信号波形是否完整,查看ADC的芯片是否有虚焊的情况(因为我测试使用的肯定是打样板),同时电压表测试运放电路是否正确,检测点电压是否正确,有问题再反馈给硬件工程师,再一起找问题,最后修复
延伸深挖问答

Q1:你写的 AXI 总线驱动,用的是 Xilinx 官方 IP 还是自定义 IP?PS-PL 地址映射怎么做的?地址和中断号从哪来?

A:用的是 Xilinx 官方 IP——AXI-IIC、AXI-SPI(还有 AXI-GPIO)。驱动没有自己写 PL 侧的 AXI 逻辑,而是在官方 SDK 例程(demo)的基础上,对着芯片手册改写、移植和封装。
地址映射:下位机是 ZYNQ 裸机 + FreeRTOS(跑在 PS 的 ARM 上,不是 Linux),所以既不用 mmap、也不用 devmem,更不需要写 Linux 驱动,而是直接在 BSP 里拿资源访问寄存器。

  • 地址从哪来:在 Vivado 里给每个 AXI IP 分配地址后,导出的硬件平台会自动在 xparameters.h 里生成 XPAR_XXX_BASEADDR 之类的宏,代码里引用宏即可,不用自己写魔数。
  • 中断号从哪来:同样由 xparameters.h 生成 XPAR_XXX_INTR,走 PS 的 GIC 注册——先把中断向量表接到 XScuGic_InterruptHandler,再用 XScuGic_Connect() 把中断号和处理函数绑定,最后 XScuGic_Enable() 使能。中断服务函数里先清标志、再取数据。

一句话:IP 用官方的,地址和中断号由 Vivado 导出、BSP 自动生成,代码里全是宏引用——这也是 ZYNQ 裸机开发和 Linux 驱动最大的区别:链路是硬件生成的,不需要设备树。

Q2:GPIO、IIC、SPI、UART 分别走什么控制器?中断怎么注册?有没有用 DMA?

A:

外设 控制器 典型挂载设备
GPIO AXI-GPIO 继电器、使能脚等
IIC AXI-IIC RTC(PCF8563)、KEY(CH455)、多路开关(TCA9548A)、多路 IO(MCP23017)、IIC 转串口(WK2132)、ADC(AD7606)
SPI AXI-SPI ADC(ADS8681)
UART WK2132 扩展 IIC → 多路串口

中断注册统一走 GIC:XScuGic_Connect(中断号, 处理函数, 参数) 绑定,再 XScuGic_Enable() 使能;ISR 里先清中断标志再处理数据。
DMA:项目里没有引入。原因是数据量小、速率也不高(ADC 采样、寄存器读写、串口收发都是小包),中断方式完全够用、时序也更好掌控;如果后面要做高速 ADC 连续采集,再考虑 AXI-DMA / AXI-Stream 这条路。

Q3:lwIP 用的是 TCP 还是 UDP?做服务器还是客户端?断线重连、心跳、粘包怎么处理?

A:这是”上位机下发指令、下位机回结果”的命令-应答场景,不能丢也不能错序,所以用 TCP;下位机做服务端(监听固定端口),上位机做客户端连进来。(如果是周期性上报状态、丢一帧无所谓,才用 UDP,更省资源、没有重传延迟。)

粘包/半包:TCP 是字节流,本身没有边界。办法就是自定义帧格式 + 长度字段:帧头 + 长度 + 载荷 + 校验,接收端先收够帧头,再按长度字段收满整帧,多出来的字节留在环形缓冲区里等下一帧——思路和串口收包完全一样。
断线重连:服务端发现 recv 返回 0 或出错,就关掉这条连接、重新回到 accept 等下一次连接;客户端侧定时重连。
心跳:定期互发一个小包确认链路还在,超时没收到就主动断开重连。

Q4:MicroPython 移植时,GC 和堆大小是怎么配的?

A:MicroPython 用的是自己的 GC(标记-清除),不走 C 库的 malloc。堆的大小和位置由移植层给:通过 mpconfigport.h 里的宏(MICROPY_GC_HEAP_SIZE 这一类)或初始化时传入的入参来指定,本质就是切一块静态内存专门给 MicroPython 用。

为什么不直接用 C 库堆:①要可控——嵌入式上必须清楚 Python 侧到底能吃多少内存;②要可回收——gc.collect() 能主动触发回收,C 库堆做不到;③要隔离——Python 脚本把堆撑爆也不能影响到 FreeRTOS 的堆。

常见坑:堆给小了稍大的脚本就报 MemoryError,给大了又挤占 C 侧内存;另外 gc.collect() 是全暂停的,脚本里频繁创建大对象会拖慢响应,所以我在 machine 模块里对收发缓冲区做了复用而不是每次新建。

Q5:QT 上位机里,串口和 QTcpSocket 怎么选?UI 线程和通信线程怎么分离?

A:选型看连接方式——调试仪器、现场连线走串口(QSerialPort),远程或多机管理走网络(QTcpSocket/QTcpServer)。两者可以抽象成同一层”通道”接口,上层业务代码不关心底下是串口还是网络,换通道只改底层。

线程分离:QT 的控件不是线程安全的,所以所有 UI 操作只能在主线程做;通信放到独立的 QThread 里(QSerialPort、QTcpSocket 依赖事件循环,必须在它们所属的线程里创建和使用)。
两个线程之间用信号槽跨线程通信,Qt 会自动用 QueuedConnection 把数据排队投递到目标线程的事件循环里,天然不需要自己加锁;要传自定义结构体就先用 qRegisterMetaType 注册。

关键原则:谁创建的对象谁用,串口/TCP 对象在通信线程里 new、在通信线程里 delete;不要把 UI 指针传进通信线程直接调用。

Q6:和硬件联调时,最难的通信问题是什么?怎么用示波器 / ILA 定位?

A:最难的是**”软件看起来没问题,但数据就是不对”**——比如 ADC 读出来一直跳、或者恒为 0,或者寄存器读回来永远是 0xFF。这种时候软件调试器帮不上忙,必须往下看物理层。

示波器:看信号本身的时序和完整性——SCK 有没有出来、频率对不对、CS 有没有按时拉低、MISO/MOSI 上有没有数据、有没有过冲和振铃、电平幅度够不够。就靠它发现过虚焊导致信号时有时无,以及采样点对不上的问题。
ILA(Integrated Logic Analyzer):Xilinx 平台最有力的手段——把 ILA IP 挂到 AXI 总线上,抓 AXI 读写的实际时序,能在板子全速运行时看到”CPU 到底发了什么、IP 回了什么”,把软件行为和硬件波形对上。比示波器强的地方是触发条件可以设得很精细(比如”某个地址被写、且数据等于 0x00 时触发”),抓偶发问题特别好用。

排查顺序:先用示波器确认物理信号没问题 → 再用 ILA 看协议层时序 → 最后才回头查软件。

Q:本次项中使用了FreeRTOS,具体表现在哪些地方,对比裸机,在项目上有什么优势?
A:主要是下位机(ZYNQ 的 PS 侧)使用了 FreeRTOS。

具体表现:① 把串口通信、网口通信、JSON 测试计划执行分别做成独立任务,各跑各的循环,互不阻塞;② 任务之间用队列/信号量传递数据,避免共用缓冲区打架;③ 驱动代码按任务重新整理,初始化顺序固定下来。

对比裸机:裸机只有一个大循环加中断,任何一步阻塞(等串口收完、等 ADC 稳定)都会把整条流程拖住;FreeRTOS 把”等”变成”阻塞让出 CPU”,别的任务照常跑,实时性和响应性都更好,而且能用优先级保证关键路径优先响应。

优先级划分:通信收发类任务最高(丢包代价最大),解析/执行任务居中,状态显示、日志这类后台任务最低;所有任务都挂在同一个抢占优先级分组下,配合互斥锁避免优先级反转。

3.3 以Linux智能车载控制系统为例

本身项目作为一个学习嵌入式linux驱动的东西,项目本身是分为几个部分:

  1. LED,继电器相关外设,这种主要以GPIO的驱动为主
  2. LCD,也就是最重要的显示界面,主要是以GT911屏幕驱动为主,涉及IIC,INPUT子系统,中断等
  3. 环境感知部分,比如温湿度传感器,光照传感器和陀螺仪
  4. 最后是嵌入式QT,我是在Ubuntu上进行编写代码,然后编译成imx6ull板子可以使用的文件,然后串口从传输过去运行的,这里主要设计的就是显示,以及操作交互,显示界面类似于手机APP一样,主界面有简单的灯光控制,温度控制(这里的输出都是简单进行模拟),天气以及RTC的时间显示/更新(这里是使用网上常见的API接口调用)_、,也可以专门进入一个音乐或者视频的界面(倒车辅助以及音乐播放),但是我当时并没有继续实现
  5. 对于物联网部分,使用的是TCP通信,然后通过python简单写了一个客户端,和一个中间的服务端,这样也可以通过网页远程检测状态,远程进行控制
  6. 可拓展添加的部分,就是摄像头和音频的接口()

//待补充

延伸深挖问答

Q1:Input 子系统上报多点触摸时,slot、tracking id 和 sync 分别怎么处理?坐标怎么校准?

A:这三样是 MT(多点触摸)协议的三个关键点,分开说:

  • slot(槽位):MT 协议用”槽”来表示触点,每个触点占一个 slot。上报前要先 input_mt_slot() 选中当前操作的槽,再用 input_mt_report_slot_state() 声明这个槽是”按下”还是”抬起”。先选槽、再报状态和坐标,顺序不能错。
  • tracking id:给每个触点一个在整个按下期间保持不变的编号,手指从 A 点滑到 B 点、坐标变了但 id 不变,上层才能知道”这是同一根手指在移动”而不是”松开又按下一根”。硬件不给 id 时(比如 GT911 的常规模式)就退化成”按 slot 序号当 id”上报。
  • sync(同步):所有触点都报完之后,调 input_mt_sync_frame() / input_sync() 提交这一帧。sync 之前的数据都是散的,只有 sync 才让内核把这一帧整体交给上层——所以一帧只 sync 一次,漏了上层收不到事件,多了会被拆成好几帧。
  • 坐标校准:硬件原始值到屏幕像素要做线性映射(x_screen = x_raw * scale + offset),同时处理 X/Y 反向。我们直接用 tslib 做,它内置 ts_calibrate 生成校准文件,还能做去抖和滤波。

Q2:字符设备驱动框架里,file_operations、cdev、class、udev 各自是什么角色?

A:四个东西是从”应用调用”到”设备节点出现”的一条链:

  • file_operations:函数指针表,把”应用层的 read/write/ioctl/open“映射到”我驱动里的哪个函数”。这是驱动最核心的部分。
  • cdev:内核里代表”这是一个字符设备”的结构,用 cdev_init() 把 file_operations 挂上去,cdev_add() 注册到内核。cdev 负责把设备号和应用的操作关联起来。
  • class:用 class_create() 建一个”类”(比如 led、misc),再由 device_create() 在这个类下创建设备。类的意义是让 udev 自动建节点。
  • udev:用户空间的守护进程。内核发出 uevent 后,udev 根据规则在 /dev 下自动创建/删除设备节点,不用再手动 mknod,还能设权限、做软链接。

一句话:file_operations 定”怎么操作”,cdev 把它注册成字符设备,class + device_create 通知用户空间,udev 负责自动把 /dev/xxx 建出来。

Q3:GPIO、IIC、ADC 分别用了什么子系统?pinctrl 和 platform 怎么配合?

A:

功能 子系统 / 框架
LED、继电器等 GPIO 子系统(gpiod_get / gpiod_set_value),或直接 gpio-leds
GT911 触摸屏 I2C 子系统 + Input 子系统 + 中断
温湿度、光照、陀螺仪 I2C 客户端驱动;传感器的模拟量采集用 IIO 子系统
内部 ADC IIO 子系统(工业 I/O),统一提供 /sys/bus/iio/... 接口

platform:设备树里那些没有物理总线可枚举的设备(GPIO 控制器、ADC 控制器等)挂在 platform 总线上,驱动用 platform_driver + of_match_table 匹配设备树节点,在 probe 里拿到资源。I2C 设备则挂 I2C 总线,用 i2c_driver + of_device_id 匹配。

pinctrl:引脚复用和电气属性全部交给 pinctrl 管。设备树里把引脚定义成 pinctrl-0 状态的节点(设成 I2C 功能、上拉、驱动能力等),驱动在 probe 里让内核自动 apply,代码里不再直接改寄存器。同一组引脚被两个节点占用是常见坑,要在设备树里屏蔽掉不需要的那个。

Q4:QT 怎么交叉编译到 imx6ull?tslib 和 framebuffer 怎么配?

A:交叉编译:先编译 QT 的 qmake 时指定交叉工具链(-xplatform 用板子对应的 mkspec,如 linux-arm-gnueabi-g++),用 -sysroot 指向目标板的根文件系统,产出的 qmake 就是”给板子用的”。之后写代码的 PC 上就用这个 qmake 生成 Makefile 再 make,编出来的可执行文件可直接在板子上跑。注意编译 C++ 库时用的 gcc 版本要和板子上的 libstdc++ 兼容,否则一跑就报 GLIBCXX 版本错误。

tslib:板子上的触摸屏在 Qt 里要经过 tslib 才准,所以 QT 编译时要加 -tslib,把 tslib 的库和头文件(Tslib 路径)指对。板子上设好环境变量 TSLIB_TSDEVICE=/dev/input/eventX(触摸屏设备节点),先用 ts_calibrate 做一次五点校准生成 pointercal。

framebuffer:QT 的显示平台选 linuxfb(-platform linuxfb),指定 QT_QPA_PLATFORM=linuxfb:fb=/dev/fb0,屏幕参数(分辨率、色深)从 fb0 读或手动指定。LCD 驱动本身要是好的(/dev/fb0 存在、能 cat 出图像),否则 Qt 上层配得再对也不显示。

Q5:TCP 获取天气时,用 HTTP 还是 HTTPS?JSON 怎么解析?断网和超时怎么处理?

A:协议:公共天气 API 基本都要求 HTTPS。用 HTTP 直连要么被拒绝、要么被重定向,而且明文也不安全。裸机/嵌入式上跑 HTTPS 一般用 mbedTLS / OpenSSL 做 TLS 层,再用类似 libcurl 的库发请求——比纯 socket 省事很多。

JSON 解析:下位机上用 cJSON 这类轻量库解析,按字段名逐层取(cJSON_GetObjectItem),取到值后要判空,别假设字段一定存在(接口改版或缺字段是常态)。
断网/超时:①socket 设 连接超时和读写超时,不能默认阻塞等下去;②请求失败就重试并退避,连续多次失败后拉长间隔,避免疯狂重连把 CPU 和网络打满;③缓存上一次成功的天气数据,联网失败时 UI 继续显示缓存值而不是空白,并标一个”数据过期”提示;④把网络请求放到独立线程,绝不能阻塞 UI 主线程,否则界面直接卡死。

Q6:驱动调试时常用的手段有哪些?分别什么时候用?

A:按”从软件内到硬件外“的顺序用:

  • printk + dmesg:最基础也最常用,看驱动加载有没有成功、probe 走到哪一步、返回值是什么。级别上用 KERN_ERR/KERN_INFO 区分。注意不能在中断里频繁打印。
  • devmem:直接在用户空间读写寄存器物理地址(devmem 0x0209C000 32),用来验证”寄存器到底有没有被正确配置”,排查底层时序问题特别快。
  • i2c-tools:i2cdetect 扫总线上有哪些设备(看设备树地址填错没有)、i2cget/i2cset 直接读寄存器值,确认芯片通不通。
  • 示波器:看信号时序和完整性——SCL/SDA/SCK 有没有出来、电平对不对、有没有过冲和虚焊。
  • 逻辑分析仪:同时抓多路信号并解码协议(I2C/SPI 直接解出地址、数据、ACK),一次就能看出是”主机发错了”还是”从机没回 ACK”,比示波器更适合查协议层问题。

排查顺序:先 printk 确认软件流程,再用 i2c-tools/devmem 验证寄存器,最后上示波器或逻辑分析仪看物理层。

Q7:这个项目里最难调的 bug 是什么?怎么定位的?根因是什么?

A:最难的是触摸屏坐标漂移和偶发不响应。

  • 现象:手指点在屏幕左边,光标跑到右边;有时候连续点几下完全没反应,但过一会儿又自己好了。
  • 定位:先用 evtest 直接读 Input 事件,发现原始上报的坐标就已经偏了——说明问题不在 QT 层,而在驱动或设备树。接着 i2cdetect 确认设备在线、i2cget 读到的数据合理,排除通信问题;再看设备树,发现 X/Y 轴方向配置和实际屏幕装反了,而且中断引脚被另一个节点占用了一部分,导致偶发丢中断。
  • 根因:①坐标方向/映射不对(配置问题,不是硬件坏);②设备树引脚冲突导致中断偶发丢失。
  • 修复:设备树里改对 X/Y 方向和映射,屏蔽掉冲突节点;软件侧再用 tslib 做一次校准 + 滤波。之后坐标就稳了。

收获:排查一定要从最靠近硬件的那一层开始验证(先 evtest 看原始数据,而不是先怀疑 QT),能少走很多冤枉路。

Q8:如果让你支持倒车摄像头,你会怎么设计驱动和 QT 显示?

A:分三层设计:

  • 驱动层:摄像头是 imx6ull 的 CSI 接口 + 外接摄像头模组。内核层面走 V4L2 框架——注册 v4l2_device 和 v4l2_subdev,摄像头输出 YUV/MIPI 数据,通过 CSI 控制器 + DMA 直接写入内存缓冲区,驱动实现 VIDIOC_REQBUFS / VIDIOC_QBUF / VIDIOC_DQBUF 这套 buf 队列接口。关键是用 DMA + 双缓冲/多缓冲,否则拷贝一次就掉帧。
  • 中间层:QT 侧不要把 V4L2 直接塞进 UI 线程。单独开一个采集线程做 DQBUF → 处理 → QBUF 循环,把每帧数据通过信号槽(用 QImage 跨线程传递)交给 UI 线程。
  • 显示层:UI 线程把 QImage 转成 QPixmap 贴到 QLabel/自定义控件上。为了流畅,显示用定时器控制帧率(如 25~30fps),而不是”来一帧刷一帧”;如果要和已有界面叠加(倒车辅助线、距离标尺),可以用 QPainter 在 QImage 上先画好再显示。

还要注意:①倒车场景要求低延迟,缓冲别开太深(2~3 帧即可),否则画面滞后影响判断;②摄像头和 LCD 抢内存带宽,必要时要降分辨率或色深;③挂 R 挡触发启动采集、摘挡关闭,能省功耗和带宽。


4. 针对特定公司的面试准备


4.1 XXXX

4.1.1 针对一个多电源输入设备(太阳能/低谷充电),储能设备,多电源使用设备(电车充电,家里的设备供能),如何协调整屋的电源系统,使得利益最大化?

4.1.2 实习中写代码的时候有没有规定的代码规范,比如命名,头文件,版权信息?

答: 命名:类名大驼峰,函数/变量小驼峰或下划线,成员变量常加 m_ 前缀,常量全大写。
头文件:用 #pragma once 或 include guard,include 按标准库、第三方库、项目内头文件排序。
版权信息:每个源文件头部通常有注释块,包含 Copyright、公司/团队、作者、创建日期、功能简述。
工具与流程:提交前用 clang-format 格式化,跑 lint/静态检查,CI 和 Code Review 也会检查规范。
如果团队没有明文规范,我也会主动对齐现有代码风格,保持一致性

4.1.3 过往经历中最有成就感的事情?

复刻一个开源的小键盘(开源的QMK生态)?或者是电赛拿到了省一(小车)?或者是第一次点灯emm

4.1.4 过往经历中比较挫败的事情,以及反思?

– 电赛的国赛(综合测评)没有通过,反思就是不够自信,没有做好准备
– 没有一个很好的实习经历吧

4.1.5 过往经历与队友生成分歧的地方,最后你是如何处理的?

答:有。
– 2025 电赛做“简易自行瞄准装置”时,我和队友在云台方案上产生过分歧:队友倾向用双步进,觉得控制更精细、一致性好;我倾向“舵机 + 步进”。因为题目靶心水平方向基本固定,Y 轴用舵机初始值固定、PWM 控制更简单,X 轴小车运动快,用步进做细分和直线插补响应更合适,四天三夜也更稳。

– 我没有直接争,而是先听完他的考虑,然后一起列评估维度:控制精度、响应速度、调试难度、现有模块和时间风险。接着用现有模块搭最小验证,分别测静态瞄准和小车转弯抖动下的表现。实测舵机 + 步进方案更稳、调试更快,双步进虽然精细但整定时间不够。最后我们统一用舵机 + 步进,把双步进作为备选,并写进方案文档。后续云台部分按时完成,项目也拿到省一。

– 类似地,编码器读取上也有分歧:MSPM0G3507 只有一路 QEI,队友想用硬件接口,我建议定时器 + 外部中断软件模拟。对比引脚、CPU 占用和实时性后,确认软件模拟能满足要求,最终采用。

– 我的处理原则是:对事不对人,首先不能情绪化,先对齐比赛目标;用维度和原型验证代替争论;最后尊重团队决策并复盘记录。

4.1.6 对当前这个岗位的理解?

巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉巴拉阿巴阿巴阿巴阿巴阿巴阿巴阿巴阿巴……

4.1.7 你认为你的哪些条件能够胜任这个岗位?

– 技术匹配:熟悉 C/C++、FreeRTOS、嵌入式 Linux,做过 I2C/SPI/GPIO/Input 子系统等驱动,也用过 TCP 做远程通信。

– 项目经验:ZYNQ 测试仪中用 FreeRTOS 搭多任务架构,负责外设驱动和电源模块联调;Linux 车载项目中完成 GT911 触摸屏、LCD 和 QT 应用开发。

问– 题攻关:处理过 cJSON 内存管理导致的系统卡死,定位到堆混用和并发无锁问题,通过换堆、加锁、加预算上限解决,符合安克对难点攻关的要求。

– 团队协作:电赛担任队长,负责方案、分工和核心驱动开发,也常和硬件工程师联调排查,沟通协作没问题。

– 学习与责任心:4 天 3 夜电赛、从零移植 MicroPython、复刻 QMK 键盘,都能快速上手并落地,责任心强,能主动补位。

– 总结:技术基础、项目经验、问题定位和团队协作都匹配,能较快胜任安克嵌入式岗位。

4.1.8 简历写 RT-Thread 实战,但项目用 FreeRTOS,两者区别是什么?为什么选 FreeRTOS?

答:两者都是抢占式实时内核,任务调度、信号量、队列、互斥锁这些概念基本一一对应,学会一个另一个很快就能上手。区别主要在生态和定位:

维度 FreeRTOS RT-Thread
定位 极简内核,只做”调度 + 同步 + 通信” 完整的 RTOS 生态,带设备框架和组件
体量 内核小(可裁剪到几 KB),源码短好读 内核 + 组件,体量大一些
关键差异 对外设无统一抽象,驱动自己封装 自带设备驱动框架(rt_device),还带 FinSH 控制台、网络协议栈、文件系统等组件
移植成本 只要实现 port.c 那几个接口 官方已经适配了大量国产 MCU,移植更快
适用场景 资源紧张、需要完全掌控的场合 快速做产品、需要现成组件(GUI、网络、文件系统)的场合

为什么选 FreeRTOS:①合项目本身——ZYNQ 的 BSP(Xilinx SDK)官方就提供 FreeRTOS 的移植,开箱即用,换 RT-Thread 要自己适配 PS 侧的 port;②内核小、源码清爽,跑在 PS 侧不需要额外占内存;③我们只需要”多任务 + 队列 + 互斥锁”这点能力,不需要 RT-Thread 那套完整组件,用 FreeRTOS 反而更轻、更好掌控。

我也实际用过 RT-Thread(做题和练习时),所以不是只会 FreeRTOS——选哪个是看项目需求,不是看我会哪个。

4.1.9 广播电视工程为什么转嵌入式?你的优势是什么?

答:为什么转:广播电视工程本身就偏电子和信号处理,课程里要学电路、信号系统、数字电路这些内容,和嵌入式是同一片土壤。真正让我定方向的是大一——一进实验室先学 C 语言和数据结构,然后发现”写代码直接让硬件动起来”这件事给我的正反馈最强,之后就沿着 STM32 -> RTOS -> 嵌入式 Linux 这条路一直走下来了。所以这不是”成绩不好换条路”,而是主动选了更感兴趣的方向。

我的优势:

  • 跨界视角:懂信号和硬件(广电的背景)、又懂软件和驱动,和硬件工程师沟通时更容易对齐,不会只从软件角度提需求——实习时和硬件工程师一起看示波器、查运放电路,这一点帮了很大忙。
  • 底层到上层的完整链路:从裸机寄存器 -> RTOS -> 驱动 -> QT 上位机 -> 网络通信都做过,遇到问题能自己判断该往下查还是往上查,而不是只会改自己那一层。
  • 软硬结合的项目经验:电赛(硬件搭建 + 控制算法 + 视觉)、ZYNQ 测试仪(驱动 + RTOS + 上位机)、Linux 车载(驱动 + QT + 物联网),三条线覆盖了嵌入式的典型场景。
  • 快速上手和落地的能力:从零移植 MicroPython、四天三夜做完整项目、复刻 QMK 键盘,都是”没做过但要快速做出来”的经历,学习速度和交付能力是我最自信的一点。

4.1.10 为什么投安克创新?你了解安克哪些产品?用过吗?

答:为什么投:①产品方向契合——安克做的是消费电子和智能硬件,产品线里充电、储能、音频、智能家居这些都需要嵌入式软件(电源管理、通信协议、驱动、固件升级),和我做过的 ZYNQ 测试仪(电源模块、驱动、通信)、Linux 车载(驱动 + QT + 物联网)方向对口;②重视研发——安克在研发上的投入和产品的工程化水平在行业里口碑不错,我更希望去一个”产品要真的被千万用户使用、对质量有要求”的地方,而不是只做demo;③对自己是挑战也是成长——消费电子的迭代节奏和质量要求比学校项目严得多,这正是我想补的经验。

了解的产品:

  • 充电类:Anker 的充电器、充电宝、数据线,氮化镓(GaN)充电器是它的招牌,还有 MagGo 磁吸系列。
  • 储能与户外:Anker SOLIX 系列的户外电源、太阳能板,这类产品里 BMS(电池管理)、逆变控制、通信都是嵌入式软件的核心。
  • 智能家居与安防:eufy 的摄像头、扫地机器人、门锁。
  • 音频:Soundcore 的耳机和音箱。
  • 投影/其他:Nebula 投影仪。

用过:用过 Anker 的充电宝和充电器,也了解过 Soundcore 的耳机——这些都是嵌入式 + 电源 + 通信的典型载体,所以我看安克的产品不只是看参数,也会想”如果是我做,协议怎么定、固件怎么升级、电池怎么管”。这也是我对这个岗位感兴趣的原因。

4.1.11 加班和薪资怎么看?

答:加班:我的态度是把活干完干好是第一位的。做电赛的时候是四天三夜连轴转,项目在收尾阶段赶进度我也能顶上,该出力的时候不会含糊。但我也相信”忙要有价值”——我倾向于用效率换时间:先把问题想清楚再动手、把能自动化的地方自动化(比如我给自己写了个 VS Code 插件,直接给板子发 Python 脚本执行,省掉反复手动操作的步骤)。所以我能接受项目冲刺期加班,也希望日常工作里是靠效率和结果说话,而不是单纯拼时长。

薪资:我相信薪水和能力、贡献是匹配的,作为应届生,我更看重的是能不能拿到成长快、有真实产品练手的岗位,以及团队的技术氛围。具体薪资我相信公司有成熟的体系,我更希望先了解岗位内容和培养路径,薪资按公司的标准来谈就可以——我更在意长期的发展空间,而不是入职时的那一两个数字。

4.1.12 你觉得自己最需要补的技术短板是什么?

答:诚实讲有三个:

  1. Linux 内核的深度——我现在的驱动能力更多是”能用框架把驱动写出来“,但内核内部的机制(调度器实现、内存管理、VFS、中断子系统源码)还是停留在概念层面。面试统信那次就很明显,全是内核源码细节,我只能说清原理、说不出代码层面怎么实现的。这块计划是靠读源码 + 写实际驱动来补。
  2. 工程化和代码规范——学校项目更看重”能不能跑通”,对代码结构、静态检查、单元测试、版本管理这些工程实践要求不高。开始实习后才意识到规范的重要性,现在会刻意对齐团队的代码风格、用 clang-format 和静态检查,但还很不够。
  3. C++ 的实战能力——QT 上位机用的还是”C with class”的写法,对现代 C++(RAII、智能指针、模板、STL 泛型编程)的理解和使用都比较浅,这是我想重点补的方向。

另外我也在补英语和文档能力——看芯片手册和开源项目文档是日常,读得慢会直接拖效率。承认短板并说清怎么补,比假装没有短板更重要。


5. 自我介绍

面试官您好,我是来自西安邮电大学广播电视工程专业的 2027 届应届生。下面我按学习经历来做自我介绍:

大一时,我一入校便通过考核进入了学校计算机学院的计科卓越班实验室。上半学期主要学习 C 语言、数据结构与算法,同时参加了学校的新生程序设计大赛,拿到第七名。

大一下学期分方向,我选择了嵌入式软件,开始学习 STM32 相关知识,动手制作了最简单的智能小车和小狗;还根据网上的教程搭建了自己的博客。

大二学年,我进一步深入嵌入式软件相关内容:用 ESP8266/ESP32 开发了解了物联网,用 Qt 学习了上位机开发,通过平衡车、一维板球掌握了运动控制中的 PID,并用 STM32 学习了 FreeRTOS 的基本原理。

同时,我和实验室认识的同学组队参加了不少比赛,包括嵌赛、电赛、数模、信息创新大赛等。尤其是大二下的电赛,从备赛到多轮比赛的完整过程让我收获最多。

大三时,实验室大部分同学都开始根据自己的方向选择实习或考研。我考虑到通院不允许大三实习,便在这段时间集中学习 Linux:通过 imx6ull 学习嵌入式 Linux,简单翻阅了《操作系统真象还原》这本书;大三末还参加了四邮四电的 ICT 比赛,拿到硬件创新赛道一等奖。

大三下,我认识到实践的重要性,找了一份时间相对自由的嵌入式软件实习——也就是 ZYNQ 测试仪这个项目。公司把开发板给了我,让我在校期间就能在实验室远程开发,不必离校去公司。

今年暑假,我转为到公司线下实习:和硬件工程师一起跟进了解项目整体,编写相关驱动,联调整个代码流程,一直持续到现在。这个项目预计在国庆收尾,届时我的实习也就结束了。

最后,我希望找一份嵌入式软件相关的工作岗位。以上就是我的自我介绍。


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

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

八股题目列表

1. C 语言

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


空指针与野指针的定义,野指针的产生方式有哪些

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

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

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

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

1
2
3
4
5
6
7
//完整malloc示例
int *pointer = malloc(sizeof(int));
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,只需要记录大小,启动时统一清零。分开可以减小固件体积,也方便启动代码批量初始化。

堆栈溢出和内存泄漏,怎么排查,怎么避免?

堆栈溢出:栈空间用超(深递归、超大局部数组、中断嵌套过深),越界写到相邻内存,轻则数据被改,重则跑飞进 HardFault。
内存泄漏:malloc 之后没有 free,堆越用越少,最终分配失败。

排查(栈溢出):用魔数填充看栈高水位(RTOS 用 uxTaskGetStackHighWaterMark());用 MPU 给栈设保护区,越界立即触发异常;看 map 文件确认栈大小;HardFault 发生时判断故障地址是否落在栈附近。
排查(内存泄漏):静态分析 + 动态工具(Valgrind、ASan/LeakSanitizer);统计 malloc/free 是否配对;跑长稳测试看堆剩余量趋势(xPortGetFreeHeapSize()),只降不升就是泄漏。

避免(栈):减少深递归、避免在栈上放大数组(改 static 或堆)、适当增大栈、限制中断嵌套层数。
避免(堆):能静态就静态、运行中多用内存池;用 RAII/智能指针自动释放;申请必查返回值、成对释放、封装统一的分配器。

面试回答:栈溢出看高水位和魔数、用 MPU 保护;泄漏靠工具和 malloc/free 配对统计、长稳看堆趋势。根治思路是”少用动态内存、用内存池和 RAII、边界和返回值都检查”。

malloc、free的作用?如何保证内存安全?

malloc :在堆(Heap)区动态申请指定字节数的内存空间。成功时返回指向该内存首地址的void*指针,失败时返回NULL。它只负责分配,不会初始化内存(内容是随机的垃圾值)

free:将之前通过malloc(或 calloc、realloc)申请的堆内存归还给系统,使这块内存可以被后续的内存申请复用

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); // 指针大小
strcpy和strncpy的区别?以及相关的类似对的区别?

strcpy 不检查长度,把源串连 \0 一起拷到目标,目标不够大就缓冲区溢出;strncpy 多一个长度 n 做限制。注意 strncpy 两个坑:源串比 n 长时不会补 \0(拷完只是截断,目标不是合法字符串);源串短于 n 时会用 \0 把目标补满(性能浪费)。

函数特点风险
strcpy全串拷贝含 \0无长度检查,溢出
strncpy限制 n 字节不保证补 \0
strcat / strncat追加到末尾strcat 同样无检查
sprintf / snprintf格式化输出sprintf 无检查,用 snprintf
memcpy / memmove定长拷贝,不管 \0重叠内存要用 memmove
strlen / sizeof到 \0 的长度 / 数组总大小不是一回事

实战建议:目标缓冲区已知大小时优先 snprintf(dst, sizeof(dst), "%s", src);拷贝后手动补 dst[n-1] = '\0' 最保险;能不用裸拷贝就不用。

函数之间共享内存的方法

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

面试回答:优先通过参数传递指针,明确数据的所有权和生命周期;全局变量简单但耦合较高,多线程共享时要配合锁或原子操作,多进程可以使用mmap共享内存,速度最快,同样需要锁或原子操作

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

简述寄存器操作常用的位运算

核心是读-改-写:只动目标位、不影响其他位。常见组合:置位 |= (1<<n)、清零 &= ~(1<<n)、取反 ^= (1<<n)、取某位 (reg>>n)&1。

1
2
3
4
5
6
7
8
#define BIT(n)           (1UL << (n))
#define SET_BIT(r, n) ((r) |= BIT(n))
#define CLEAR_BIT(r, n) ((r) &= ~BIT(n))
#define GET_BIT(r, n) (((r) >> (n)) & 1U)
// 读改写一个连续位段(如某寄存器的 [6:4] 设为 v)
#define WRITE_FIELD(r, off, w, v) \
((r) = ((r) & ~(((1UL << (w)) - 1) << (off))) \
| (((v) & ((1UL << (w)) - 1)) << (off)))

常用技巧:x & (x-1) 消掉最低位的 1(常用于数位里 1 的个数);x & -x 取出最低位的 1;~0U 表示全 1,比 0xFFFFFFFF 更安全。

三个坑:1 默认是 int,用到第 31 位以上必须写 1UL/1U;volatile 寄存器要拆成”读进变量 → 改 → 写回”;|= / &=~ 是读-改-写、非原子,中断和主循环同时操作会丢修改,带 W1C(写 1 清零) 的标志位寄存器更要小心,应先用 = 写入完整值。

简述static const volatile typedef register sizefof union struct关键字的作用

static 修饰局部变量时只初始化一次并保留状态;修饰文件作用域的变量或函数时限制为当前源文件可见。未显式初始化的 static 变量默认值为 0。


const 限制当前访问路径不能修改对象,具有类型信息,比简单的宏替换更容易进行类型检查。

  • const int *:常量指针,不能通过指针修改指向的变量,但指针可以改变指向。
  • int * const:指针常量,指针本身不能改变指向,但可以通过指针修改变量。
  • const int * const:指针和指向的变量都不能通过当前指针修改。

volatile 用于说明变量可能被硬件、中断或其他执行流修改,避免编译器省略必要的内存访问。它不保证原子性,也不能代替锁或其他同步机制。一般在嵌入式中访问寄存器使用


typedef 用于给已有类型起别名,不会创建新的类型,常用于简化结构体、函数指针和复杂类型的声明。它只是编译阶段的类型别名,不能像变量一样存储数据。

1
2
3
4
5
6
7
// 1. 简化结构体(省去每次都写 struct)
typedef struct { int x, y; } Point;
Point p = {1, 2}; // 直接使用别名

// 2. 简化函数指针(原本极难阅读的声明)
typedef int (*FuncPtr)(int, int);
FuncPtr f; // 清爽的变量声明

Q: 为什么要用 typedef 简化函数指针?
A: 原生语法极其难读,使用 typedef 可大幅提升声明和使用的可读性。

Q: typedef 和 #define 的区别是什么?
A: 处理阶段不同:typedef 在编译阶段处理(有类型检查),#define 在预处理阶段处理(纯文本替换,无类型检查)。
指针声明安全性不同:typedef int* P; P a, b;(a和b均为指针)
而#define P int*会导致P a, b;(a为指针,b为int,容易出错)。
作用域不同:typedef 遵循作用域规则,#define 从定义处一直生效到文件末尾(除非 #undef)。


register 用于建议编译器将变量优先放入寄存器,以提高访问速度,但最终是否使用寄存器由编译器决定。使用 register 修饰的变量不能使用取地址运算符 &。
它没有内存地址,通过CPU指令直接访问,离运算器(ALU)最近,速度极快

不过在现代C++中register已被弃用,编译器会自动进行寄存器分配优化


sizeof 用于计算类型或对象占用的字节数,结果类型是 size_t。对普通表达式通常不会求值,但变长数组除外;计算数组时得到整个数组大小,计算指针时得到的是指针本身的大小。

Q:sizeof和strlen的区别?
A:strlen是库函数,计算的是字符串开始到’\0’的长度大小(不包括’\0’),而sizeof是运算符,计算整个数组的大小


union 是共用体/联合体,所有成员共享同一块内存,同一时刻通常只使用其中一个成员,大小由最大成员和内存对齐决定。修改一个成员可能影响其他成员的值,常用于节省内存或进行数据解析。

Q:union 是如何进行内存对齐的?
A:大小由最大成员和内存对齐决定。

1
2
3
4
5
6
7
8
union U {
char a[5]; // 5字节,对齐要求 1
int b; // 4字节,对齐要求 4(通常)
};

// sizeof(union U) 通常为 8
// 解释:最大成员是 char[5](5字节),但最严格对齐要求是 int(4字节)。
// 5 必须向上补齐到 4 的整数倍,即 8。

struct 是结构体,每个成员都有独立的存储空间,可以同时保存多个成员的值。结构体大小会受到内存对齐影响,因此不一定等于所有成员大小之和。

Q:struct 是如何进行内存对齐的?
A:成员各自对齐并占用独立空间,总大小为最大对齐数的整数倍,优化空间可以使用从大到小排

1
2
3
4
5
6
7
8
9
10
struct S {
char a; // 偏移 0,占 1 字节,后面填充 3 字节
int b; // 偏移 4,占 4 字节
char c; // 偏移 8,占 1 字节,尾部填充 3 字节
};

// sizeof(struct S) 通常为 12(而不是 1+4+1=6)
// 解释:
// 成员起点对齐:b 必须在 4 的倍数偏移处(填充了 3 字节)。
// 尾部补齐:整体大小必须是最大成员(int)大小 4 的倍数,9 补齐到 12。
标识符、关键字与保留字

标识符是变量、函数和类型等对象的名称;
关键字具有语言规定的特殊含义;
保留字可能暂未使用但不能作为普通标识符。

头文件双引号与尖括号

#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;
}
讨论一下#pragma once和传统的头文件保护宏的区别及其优缺点

核心目的相同(防止头文件重复包含),但机制不同。传统宏基于宏名,#pragma once基于物理文件路径。
传统宏优点是标准支持、可移植性强,缺点是有宏名冲突风险;
#pragma once优点是简洁无冲突、编译更快,缺点是非标准扩展,软链接或网络文件系统下可能失效。

简述一下条件编译和使用场景

定义:预处理阶段根据宏定义决定是否编译某段代码。

常用指令:
#if、#ifdef、#ifndef、#else、#elif、#endif

核心使用场景:

  1. 头文件保护:防止头文件重复包含(#ifndef ... #endif)。
  2. 跨平台兼容:根据不同操作系统或编译器编译不同代码(如 #ifdef _WIN32)。
  3. 调试开关:区分 Debug 和 Release 模式,控制日志和断言输出。
  4. 功能裁剪:根据版本(如免费版/专业版)编译不同模块。

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
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);

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

模式CPOLCPHA
000
101
210
311

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

UART 与 DMA 联合使用

UART 负责串行通信,DMA 负责在外设和内存之间搬运数据,CPU 不需要逐字节参与,能够降低中断频率和 CPU 占用。

面试回答:发送时可以使用 DMA 一次搬运一帧数据(配置为 Normal 模式,发送完成触发中断);接收时通常结合 DMA 和空闲线中断(配置为 Circular 循环模式),收到一帧后再由 CPU 解析。

Q:为什么 UART 收发要用 DMA?不用行不行?

传统中断:每收到 1 个字节就进一次中断。如果波特率是 115200,一秒钟可能进上万次中断,CPU 频繁现场保护/恢复,极大浪费算力

DMA 优势:DMA 是硬件搬运工,CPU 只需配置好“源地址(串口数据寄存器)、目的地址(内存数组)、长度”,就可以去干别的事。只有搬完一整帧,才通知 CPU 一次。本质是空间换时间,用硬件并发换 CPU 效率。

什么是 DMA,它是如何减少 CPU 占用的?

DMA(Direct Memory Access,直接存储器访问)是一条不经过 CPU 的数据通道,能在外设和内存之间自动搬运数据。CPU 只要配置好”源地址、目的地址、长度、方向”,剩下的搬运由 DMA 控制器硬件完成。

传统方式(CPU 搬运):每个字节都要 CPU 亲自读寄存器、写内存,占用总线周期还要进中断做现场保护,数据量一大就把算力吃光。
DMA 方式:CPU 只配置一次,之后每次传输都不需要 CPU 干预,只在搬完(或半满)时收到一次中断。本质是用硬件并发换 CPU 效率——CPU 被从”逐字节搬运”里解放出来去执行别的任务。

关键点:DMA 不替代总线,它和 CPU 会争用总线(有优先级和仲裁);CPU 与 DMA 若访问同一块内存要注意缓存一致性和并发(必要时用环形缓冲区或双缓冲)。

一句话:DMA 把”复制数据”这件体力活交给硬件,CPU 只在开始配一次、结束收一次中断,所以中断次数和 CPU 占用大幅下降。

UART 和 USART 的区别,同步异步的区别?

UART 是通用异步收发器,USART 是通用同步/异步收发器——USART 比 UART 多一个同步(带时钟)模式,其余的数据帧格式、波特率、收发流程基本一致。STM32 里同一个外设既能当 UART(异步)用,也能当 USART(同步)用。

异步 UART:没有时钟线,收发双方各自按约定的波特率计时,靠起始位对齐、停止位收尾。所以两边波特率必须匹配,误差一般要控制在 ±2~3% 以内,否则采样点漂移、出现帧错误或噪声错误。

同步 USART:多一根 CK 时钟线,由主机提供时钟,数据在时钟边沿采样,不靠各自计时。因此可以跑更高波特率、更可靠,典型用于 SPI 模式、智能卡等带时钟的同步串口。

同步是怎么进行的:主机发每一位的同时翻转 CK,从机用这个时钟去采样数据线(上升沿/下降沿可选)。收发共用一个时钟源,就不存在异步那种”两个时钟各走各的、慢慢漂移错位”的问题。

一句话:UART 只有异步(无时钟,靠波特率约定);USART 兼容异步、额外支持同步(多一根时钟线)。日常串口通信绝大多数用的是异步模式。

串口和 DMA 结合使用时,DMA 该如何配置?

核心是让 DMA 当”搬运工”,在 USART->DR 和内存缓冲区之间自动搬字节,外设端地址固定不递增、内存端地址递增,具体配置项如下:

配置项接收(外设→内存)发送(内存→外设)
方向Peripheral → MemoryMemory → Peripheral
外设地址&USARTx->DR,不递增&USARTx->DR,不递增
内存地址接收缓冲区,递增发送缓冲区,递增
数据宽度外设/内存都 8 位8 位
传输长度缓冲区大小待发字节数
模式通常 Circular(配 IDLE 收不定长)通常 Normal(发完即停)

还要配:通道要与该 USART 的 DMA 请求对应(通道错了不会触发);DMA 优先级;使能 TC(传输完成)/HT(半传输)/TE(错误) 中断;最后打开 USART 的 DMA 使能位(USART_CR3 的 DMAT / DMAR),漏了这一步 DMA 根本不会动。

要点:接收用 Circular + 空闲中断收不定长,发送用 Normal + 传输完成中断;接收缓冲区要注意 CPU 与 DMA 的并发(必要时加环形缓冲区),改 DMA 配置前先关通道。

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库底层驱动已经自动处理好了这个清除动作,不需用我们操作

Modbus-RTU 是什么,和串口自定义的帧头帧尾数据包有什么区别?

Modbus 是一套主从式的应用层协议,RTU 是它在串口上的传输形式:一主多从,主机发起请求(带从机地址),被寻址的从机才应答,从机之间不通信。

帧格式:从机地址(1) + 功能码(1) + 数据(N) + CRC16(2),没有帧头帧尾的魔数字节。因为是二进制紧凑格式,所以不能用”数据里出现某个字节”来界定边界。

怎么判断一帧的开始和结束:靠总线静默时间——帧之间至少隔 3.5 个字符时间,帧内字符间隔不能超过 1.5 个字符时间。也就是用”空闲”当分隔符,而不是靠帧头帧尾。另外地址字段本身就是天然的边界:接收方等 3.5 字符静默后,把第一个字节当从机地址。

和自定义”帧头+帧尾”协议的区别:

  • 自定义协议常用 0xAA 0x55 之类的固定帧头加帧尾,靠字节模式找边界,实现简单、易调试,但数据里一旦出现同样的字节就要转义(否则误判)。
  • Modbus-RTU 没有帧头帧尾,靠时间静默 + CRC 校验定界和验错,效率高、标准统一,但对定时精度要求高(波特率误差、系统 tick 不准都会把一帧切断)。

一句话:自定义协议是”看字节找边界”,Modbus-RTU 是”看时间找边界 + CRC 兜底”。

环形缓冲区的作用,简述 C 语言的实现方法?

环形缓冲区(FIFO 循环队列)用一个首尾相接的固定数组做队列:写入移动 head、读出移动 tail,不用搬移数据、不用申请内存,读写互不阻塞。主要用于串口收发的缓冲,解决”生产者快、消费者慢”的速度不匹配。

作用:① 解耦——中断里只管往缓冲区写,主循环慢慢取出来解析;② 不丢数据——来不及处理时数据先存着;③ O(1) 读写——不涉及 memmove;④ 天然适合 DMA + 空闲中断收不定长数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#define RING_SIZE 256          // 建议取 2 的幂,可用 & (SIZE-1) 代替取模
typedef struct {
uint8_t buf[RING_SIZE];
volatile uint32_t head, tail; // volatile:中断和主循环都会改
} ring_t;

// 写入:缓冲区满返回 -1
int ring_put(ring_t *r, uint8_t data) {
uint32_t next = (r->head + 1) % RING_SIZE;
if (next == r->tail) return -1; // 留一个空位区分“空”和“满”
r->buf[r->head] = data;
r->head = next;
return 0;
}

// 读出:缓冲区空返回 -1
int ring_get(ring_t *r, uint8_t *data) {
if (r->tail == r->head) return -1;
*data = r->buf[r->tail];
r->tail = (r->tail + 1) % RING_SIZE;
return 0;
}

要点:head/tail 必须加 volatile(中断与主循环共享);单生产者单消费者场景无需加锁,只需保证指针更新是原子的;容量取 2 的幂时把 % RING_SIZE 换成 & (RING_SIZE-1) 更快;故意留一个空位用来区分”空”和”满”。

ADC 的主要性能参数

ADC 是把模拟电压转换成数字量,常见性能参数包括分辨率、采样率、转换时间、输入范围、精度、信噪比和有效位数 ENOB。

分辨率为 N 位时,理想量化步进约为 Vref / 2^N;采样率决定单位时间能采集多少次,输入信号频率过高会产生混叠。

1
voltage = adc_value * VREF / ((1 << ADC_BITS) - 1);

Q:ADC 如何提升精度?

A:分硬件和软件两条路。
硬件:用外部高精度基准(如 REF3033,而不是拿 VDDA 当基准);基准和模拟电源加去耦、滤波并做好模拟地分离;输入加RC 抗混叠滤波、必要时用运放缓冲(降低源阻抗);采用差分输入抑制共模干扰;走线远离数字高频信号。
软件:多次采样求平均 / 滑动滤波(随机噪声可提升约 √N 倍有效分辨率);中值滤波去脉冲;过采样 + 抽取提高有效位数(每 4 倍过采样约多 1 bit);做校准(消零偏和增益误差);只有在低速高阻信号下才需要加采样保持延时,否则会被前一级”吃掉”精度。
一句话:基准要稳、电源要干净、前端要滤波,再用平均/过采样榨出有效位数。

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% 占空比
STM32 有哪些时钟源,时钟树是如何分配的?

时钟源(4 个):HSI(内部高速 RC,约 8~16MHz,精度差但零启动时间)、HSE(外部晶振,通常 8MHz,准,是 PLL 首选)、LSI(内部低速,约 32kHz,喂 IWDG)、LSE(外部 32.768kHz 晶振,RTC 专用)。另外还有个 48MHz 的 HSI48 专供 USB。

时钟树的分配:HSE/HSI → PLL(×N 倍频、÷M 分频)得到 SYSCLK,再经 AHB Prescaler 得到 HCLK(内核、DMA、SRAM);HCLK 经 APB1/APB2 Prescaler 给外设。APB 分频系数不为 1 时,定时器时钟会再乘 2。

节点典型值作用
HSE8 MHz外部晶振,PLL 输入
PLL×9 → 72 MHz倍频产生 SYSCLK
SYSCLK / HCLK72 MHz内核、总线、内存
APB136 MHz低速外设(UART2~5、I2C)
APB272 MHz高速外设(GPIO、SPI1、USART1)
SysTickHCLK 或 /8RTOS 节拍

面试回答:STM32 以 HSE + PLL 为主时钟源,HSI 作应急备份;PLL 输出经 AHB/APB 逐级分频分给内核和外设;改主频就是在时钟树里改 PLL 的 M/N/P/Q 参数,改完必须等 PLLRDY 稳定再切换 SW 位。

I2C 和 SPI 的区别

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

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

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

I2C 时序

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

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

I2C 的寻址有七位和十位,具体是如何传输的?

起始条件之后紧跟的第 1 个字节是”地址字节”,里面既放设备地址、又放读写方向位(高位先发)。

7 位寻址(1 字节):[7 位地址][R/W]——高 7 位是地址,最低位是方向(0=写、1=读)。所以写地址 = addr<<1,读地址 = addr<<1 | 1。例如 7 位地址 0x50,实际发出的是写 0xA0 / 读 0xA1。

10 位寻址(2 字节):第 1 字节是固定前缀 1111 0 + 地址的高 2 位(bit9:8)+ R/W;第 2 字节是地址的低 8 位。也就是把 10 位地址劈成”高 2 位塞进第 1 字节、低 8 位单独发第 2 字节”。之后用重复起始切换方向时,前缀 + 地址还要再重发一遍(只改 R/W 位)。

地址空间里有保留段不能用:0000 000 是广播(general call)、0000 001~`0000 011、1111 0xx(10 位寻址前缀)、1111 111` 保留。

常见坑:驱动里填的多半是”7 位地址”,但硬件/库要求的可能是”左移一位后的 8 位值”(HAL 里是 addr<<1),搞混就会一直 NACK、通信不通。

I2C 和 SPI 是如何处理多主机和多从机情况的?

I2C 靠从机地址区分设备、靠总线仲裁支持多主机;SPI 靠片选 CS 区分从机,本身不支持多主机。

多从机:I2C 每个从设备有唯一地址(7 位或 10 位),主机在起始条件后先发”地址 + 读写位”,只有地址匹配的从机回 ACK,其余保持静默;遇到地址冲突(多个同型号器件)可以用 TCA9548A 这类 I2C 多路开关把总线分开。SPI 没有地址,靠各自独立的 CS 片选选中从机,n 个从机需要 n 根 CS,也可以用译码器或菊花链串联来省引脚。

多主机:I2C 的 SDA/SCL 是开漏输出 + 上拉电阻,构成线与逻辑,天然支持多主机。多个主机同时发起时,边发边回读总线:谁输出高电平却读到低电平,谁就仲裁失败,立刻退出并转为从机收听,等总线空闲再重试;仲裁只发生在地址和数据位,不会破坏已发送的数据。SCL 的线与还顺带实现了时钟同步,低电平最长的主机决定实际时钟周期。

SPI 为什么不能多主机:SPI 的 MOSI/SCK 是推挽输出,没有线与特性,无法像 I2C 那样无损仲裁;两个主机同时驱动会出现电平冲突,甚至损坏器件。所以标准 SPI 只允许单主机,确实需要时只能外加仲裁电路或握手信号。

面试总结:I2C 是”地址寻址 + 开漏仲裁”,天生支持多主机多从机;SPI 是”片选寻址 + 推挽输出”,只支持多从机,多主机要靠外部硬件。

什么是中断优先级,STM32 中具体怎么使用?

Cortex-M 的中断优先级由 NVIC 管理,数值越小、优先级越高(和直觉相反)。STM32 用 4 位表示优先级(共 16 个等级),并把这 4 位拆成抢占优先级和响应优先级(子优先级)两部分,怎么拆由优先级分组决定。

两种优先级的区别:

  • 抢占优先级:数值小的可以打断正在执行的低抢占优先级中断,实现中断嵌套。
  • 响应优先级(子优先级):不能抢占;只在多个中断同时挂起、且抢占优先级相同时,决定谁先被响应。

优先级分组(NVIC_PriorityGroupConfig)决定 4 位里几位给抢占、几位给子优先级。常用的是 Group_2:2 位抢占 + 2 位子,即 4 级抢占、4 级子优先级。分组是全局的,整个工程只能用一种。

分组抢占位子优先级位抢占级数
Group_004不抢占
Group_2224
Group_44016

使用步骤(HAL):

  1. HAL_NVIC_SetPriorityGrouping(NVIC_PRIORITYGROUP_2) —— 选分组;
  2. HAL_NVIC_SetPriority(IRQn, preempt, sub) —— 设某个中断的抢占/子优先级;
  3. HAL_NVIC_EnableIRQ(IRQn) —— 使能该中断。

⚠️ 和 FreeRTOS 配合的坑:configMAX_SYSCALL_INTERRUPT_PRIORITY 是一条阈值线,优先级高于它(数值更小)的中断不能调用任何 FreeRTOS API,否则会破坏内核临界区。这类中断要么把优先级设得足够低,要么只做裸寄存器操作、不碰调度。

常见误区:Cortex-M 里不是”数字大优先级高”;同一抢占优先级的中断不能互相打断,会按子优先级顺序依次执行完。

中断、临界区与任务调度

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

Q:中断中能不能调用 printf 或 delay?

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();
}
HardFault 是什么,具体项目中如何使用?

HardFault 是 Cortex-M 的兜底异常:访问非法地址、除零、非对齐访问、取指错误、从错误状态返回等,凡是没被其他异常处理的问题,最终都掉进 HardFault_Handler。它本身不是”错误原因”,而是**”出事了,快看现场”的入口**。

项目中的用法:在 HardFault_Handler 里先保存现场再分析:

  1. 判断是哪个栈:读 LR 的 bit2,为 0 用 MSP、为 1 用 PSP(裸机一般用 MSP)。
  2. 取出异常压栈的 8 个寄存器:出错时的 PC(出错指令地址)最关键,用它反查源码;LR 是返回地址;还有 R0~R3、R12、xPSR。
  3. 读故障状态寄存器定位类型:CFSR(可分成 MMFSR/BFSR/UFSR)、HFSR、以及 MMFAR/BFAR(只有对应 valid 位置位时地址才有效)。
  4. 反查符号:把 PC 用 addr2line / arm-none-eabi-addr2line 或 map 文件转成”文件:行号”。
  5. 落日志/复位:把现场存到一块不掉电的 RAM 或 Flash,然后软复位重启,别死等;写看门狗保证能自恢复。

实践建议:HardFault_Handler 要写成简单的 C/汇编,别在里面调 printf(可能阻塞或再次触发 fault);用 .noinit 段保存现场;开发阶段把 CFSR/PC 打进日志,能省掉大量排查时间。

什么是 FreeRTOS 的低功耗模式,如何与 STM32 低功耗结合?

FreeRTOS 本身提供一个空闲钩子 vApplicationIdleHook(),在空闲任务里被调用——系统发现”现在没有任何任务就绪”就进这里。低功耗就是在这个钩子里让 MCU 进睡眠,等中断唤醒。

STM32 三种低功耗模式与唤醒:

  • Sleep(WFI/WFE–睡眠模式):只关 CPU 时钟,外设照常,任意中断唤醒,唤醒最快、消耗最低。
  • Stop(__WFI–停止模式):关主时钟和大部分外设,保留 SRAM 和寄存器,EXTI / RTC / 看门狗唤醒,功耗 µA 级。
  • Standby(__WFI + 低功耗位–待机模式):只留后备域,基本等同关机,WKUP 引脚 / RTC 唤醒,唤醒后从头开始(相当于复位)。

如何结合:在 vApplicationIdleHook() 里调用 __WFI() 即可实现基础的 Sleep 低功耗——有中断(比如 SysTick 或串口)就来干活,没有就睡。想进 Stop 模式,要注意 SysTick 会一直是唤醒源把系统反复叫醒,通常的做法是:进入 Stop 前把 SysTick 停掉、并重新配置唤醒源(EXTI),唤醒后恢复时钟和 SysTick。RTOS 的 tickless idle 就是把这个过程自动化:算出”下一个任务唤醒时间”来设置定时器,让 MCU 睡满这段时间。

要点:tickless 模式是空闲钩子的进阶版(configUSE_TICKLESS_IDLE),需要在 vPortSuppressTicksAndSleep 里重新校准 xTickCount,否则时间会漂;唤醒源必须在醒来后清中断标志,否则会一直醒着。

RTOS 中如何进行内存管理

RTOS 内存管理主要围绕任务栈、内核对象、堆/内存池,目标是确定、低碎片、失败可处理。

  • 静态分配:编译期或链接脚本划数组/段,创建任务、队列、信号量时传入缓冲区。大小固定、无碎片、最确定,适合安全关键和启动阶段;缺点是不灵活。
  • 动态堆:malloc/free 或 pvPortMalloc/vPortFree。灵活,但时间不确定、可能失败、易碎片,不适合高频路径和中断。
  • 固定内存池/块:启动时把内存切成等大或多级块,分配释放快、无外部碎片,适合消息、网络包、DMA 缓冲;很多 RTOS 提供 ISR 安全接口。
  • 栈管理:任务独立栈,用高水位/填充模式测余量,留足最深调用和中断余量;中断栈可独立或借用任务栈。
  • 堆算法:FreeRTOS heap_1 只分配、heap_2 无合并、heap_3 包 malloc、heap_4 首次适应+合并、heap_5 多区域;也可用 TLSF、伙伴、slab 等。
  • 保护与检查:MPU/MMU 可隔离越界;关注栈高水位、堆剩余、最大可分配块、申请失败次数和碎片趋势,失败要有钩子或降级策略。
  • 使用规则:能静态就静态,运行中多用内存池;高频任务和 ISR 不反复申请释放;ISR 只用 ...FromISR 或非阻塞内存池;DMA 注意对齐和 cache。
RTOS 的五种内存分配方式

通常说的”五种内存分配方式”,就是 FreeRTOS 里 portable/MemMang/ 下的 heap_1.c ~ heap_5.c 这五种堆管理实现。它们对外都提供统一的 pvPortMalloc() / vPortFree() 接口,差别只在底层的分配释放策略。

方式支持释放核心策略特点与适用场景
heap_1❌ 只分配不释放把一个大数组按顺序切分,分配只移动指针, vPortFree 是空实现最简单、时间确定、无碎片;适合启动阶段一次性创建完任务和内核对象、之后不再删除的系统,尤其安全关键场景
heap_2✅首次适应算法,但不合并相邻空闲块会产生外部碎片,已废弃,新工程不建议使用
heap_3✅直接包装 C 标准库的 malloc/free,外面加挂起调度器的临界区线程安全,但时间不确定、依赖工具链,堆大小由链接脚本决定,不受 configTOTAL_HEAP_SIZE 控制
heap_4✅首次适应 + 相邻空闲块合并,按地址顺序插入空闲链表碎片明显小于 heap_2,最常用,适合运行中反复创建/删除
heap_5✅算法同 heap_4,额外支持多块不连续内存区(vPortDefineHeapRegions())适合内存分散在片内 SRAM + 外部 SDRAM 等场景

统一接口:不管选哪种,上层都只用 pvPortMalloc() / vPortFree()。换实现只需替换对应的 heap_x.c,应用代码不用改。

内存来源:heap_1/2/4/5 用的都是 static uint8_t ucHeap[configTOTAL_HEAP_SIZE],本质是一块放在 .bss 的静态数组,不经过链接器的堆;只有 heap_3 才是真正使用 C 库的堆。

面试回答:heap_1 只分配不释放、最简单;heap_2 能释放但不合并、已废弃;heap_3 是 malloc/free 的加锁包装;heap_4 首次适应 + 合并、最常用;heap_5 在 heap_4 基础上支持多块不连续内存。实际工程一般选 heap_4(或 heap_5),高可靠场景再配合静态分配和内存池(见上一题)。

线程切换和调度

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

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

OTA 升级是什么,STM32 如何实现,好处是什么?

OTA(Over-The-Air)就是通过通信接口远程给设备更新固件,不用拆机、不用现场接调试器。核心思路是把 Flash 分区:Bootloader 负责搬运和跳转,App 负责业务。

Flash 分区(双区方案):

  • Bootloader 区:上电先跑,检查升级标志,决定留在 Bootloader 刷固件、还是跳到 App。
  • App 区(运行区 + 备份区):新固件先写到备份区,校验通过后再回写/切换到运行区;也可以做成 A/B 两份 App 轮流启动。

实现流程:① 设备在 App 里通过 UART/4G/WiFi/蓝牙接收新固件,分片写入 Flash 的备份区;② 每片累加 CRC 校验,全部收完做一次整包校验(MD5/CRC32,重要场合验数字签名);③ 校验通过就写升级标志,然后软复位;④ Bootloader 看到标志,把备份区搬到运行区(或直接跳备份区),清标志并跳转新 App。

关键点:写 Flash 必须按页/扇区对齐、先擦后写、且擦写会阻塞 CPU(注意中断响应);升级过程中掉电必须能恢复,所以要有”标志 + 备份区 + 校验”三重保护;跳转 App 前要关中断、重设向量表偏移(SCB->VTOR)、设好 MSP。

好处:不用返场、可远程批量升级、能快速修 bug 和迭代功能,降低维护成本;配合双备份/回滚还能把”刷砖”风险降到最低。

一句话:OTA = 分好 Flash 区,Bootloader 管跳转和搬运,App 负责收包写备份区 + 校验 + 打标志,重启后由 Bootloader 完成切换,全程要防掉电、能回滚。

详细实现(以 STM32F103ZET6 / 512KB Flash 为例)

① 先规划 Flash 分区(0x80000 = 512KB,注意必须按页对齐,F103 的页是 2KB):

区域起始地址大小说明
Bootloader0x0800000032 KB上电先跑,负责搬运和跳转,永不擦除
App 运行区0x08008000224 KB正常业务固件
App 备份区0x08040000224 KBOTA 新固件先放这里
参数/标志区0x0807800032 KB升级标志、版本、CRC

App 的链接脚本(.ld / Keil 的 IROM1)必须把起始地址改成 0x08008000,否则编译出来的固件默认从 0x08000000 跑,会覆盖 Bootloader。

② 定义升级标志(放在参数区,掉电不丢):

1
2
3
4
5
6
7
8
9
10
11
#define OTA_MAGIC  0xA5A5A5A5

typedef struct {
uint32_t magic; // == OTA_MAGIC 才认为有有效升级请求
uint32_t version; // 新固件版本号
uint32_t size; // 固件字节数
uint32_t crc32; // 整包校验值
} ota_flag_t;

#define OTA_FLAG_ADDR 0x08078000
#define OTA_FLAG ((ota_flag_t *)OTA_FLAG_ADDR)

③ Bootloader 启动流程:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
int main(void) {
HAL_Init();
// 判断是否有升级请求
if (OTA_FLAG->magic == OTA_MAGIC) {
// 1) 校验备份区固件是否完整
if (crc32_check(APP_BACKUP_ADDR, OTA_FLAG->size) == OTA_FLAG->crc32) {
// 2) 把备份区搬到运行区(按页擦、按字写)
flash_copy(APP_RUN_ADDR, APP_BACKUP_ADDR, OTA_FLAG->size);
}
// 3) 无论成功失败都清标志,避免反复搬运
flash_erase_page(OTA_FLAG_ADDR);
}
// 4) 跳转到 App
jump_to_app(APP_RUN_ADDR);
}

④ 跳转代码(最关键的一段,向量表偏移一定要设):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
typedef void (*app_entry_t)(void);

void jump_to_app(uint32_t app_addr) {
uint32_t stack_top = *(volatile uint32_t *)app_addr; // 前 4 字节 = 栈顶
uint32_t reset_vec = *(volatile uint32_t *)(app_addr + 4); // 接着 4 字节 = 复位向量
app_entry_t app_entry = (app_entry_t)reset_vec;

__disable_irq(); // 1) 关总中断,防止跳转中被打断
SysTick->CTRL = 0; // 2) 关 SysTick(否则中断会指向旧向量表)
SysTick->LOAD = 0;
SysTick->VAL = 0;
HAL_DeInit(); // 3) 复位外设(可选,视情况)
for (int i = 0; i < 8; i++) { // 4) 清 NVIC,避免 App 一上电就触发残留中断
NVIC->ICER[i] = 0xFFFFFFFF;
NVIC->ICPR[i] = 0xFFFFFFFF;
}
SCB->VTOR = app_addr; // 5) 重定向向量表到 App(漏了会跑到 Bootloader 的中断)
__set_MSP(stack_top); // 6) 设主栈指针为 App 的栈顶
app_entry(); // 7) 跳过去,开始跑 App
}

⑤ App 侧收包与写入(在运行区里通过串口/4G 收新固件):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 每收一片(比如 1KB)就擦一页、写一页到备份区
void ota_write_chunk(uint32_t offset, uint8_t *data, uint32_t len) {
uint32_t page_addr = APP_BACKUP_ADDR + offset;

HAL_FLASH_Unlock();

FLASH_EraseInitTypeDef erase = {
.TypeErase = FLASH_TYPEERASE_PAGES,
.PageAddress = page_addr,
.NbPages = 1,
};
uint32_t err;
HAL_FLASHEx_Erase(&erase, &err);

for (uint32_t i = 0; i < len; i += 4) {
uint32_t word = *(uint32_t *)(data + i); // F103 按字(4字节)编程
HAL_FLASH_Program(FLASH_TYPEPROGRAM_WORD, page_addr + i, word);
}
HAL_FLASH_Lock();
}

收完所有分片后,算整包 CRC32,写 OTA_FLAG(magic/version/size/crc32),再 NVIC_SystemReset() 软复位。重启后 Bootloader 看到 magic 有效,就走搬运流程。

⑥ App 自己的向量表也要改:App 的 system_stm32f1xx.c 里把 VECT_TAB_OFFSET 设成 0x8000(即 0x08008000 - 0x08000000),或者在自己的 main 开头写 SCB->VTOR = 0x08008000;。Bootloader 和 App 两边都要设 VTOR,只设一边必崩。

几个实战坑:① 写 Flash 时 CPU 会停等,若此时有高速数据进来会丢包,接收要用 DMA + 环形缓冲,把”收”和”写”解耦;② Flash 擦写期间不能在同一块 Flash 上取指执行——Bootloader 搬 App 时,搬运代码必须在 RAM 里跑(或搬到不同 Flash 区),否则会取到”正在擦除的半成品指令”;③ 升级到一半掉电,备份区是不完整的,靠 magic + CRC 判定为无效、直接跳过,绝不能盲目搬运;④ 记得留出回滚能力:搬运前把旧 App 也保存在另一区,新 App 启动失败时由 Bootloader 切回旧版。

STM32 的启动模式有哪些?OTA 应该用什么启动模式?

STM32 复位时采样 BOOT0/BOOT1 引脚电平(在复位上升沿锁存),据此把不同的存储区映射到 0x00000000,CPU 就从那里取向量表、开始启动。

BOOT0BOOT1启动区域地址用途
0×主闪存(Main Flash)0x08000000正常运行,跑用户固件
10系统存储器(System memory)出厂固化的 BootloaderISP 串口/USB DFU 下载
11内置 SRAM0x20000000调试用,掉电即失

引脚差异:F1 系列用 BOOT0 + BOOT1(BOOT1 就是 PB2);F4/F7/H7 等只有 BOOT0,BOOT1 被移到**选项字节(Option Bytes)**里的 nBOOT1。所以新系列不用为 BOOT1 单独接引脚。

注意:BOOT 引脚只在复位瞬间被采样,运行中改电平不生效;要配合上电复位或手动复位。系统存储器的 Bootloader 是 ST 出厂烧好的,用户不能修改,协议固定(F1 是 UART1 的 AN3155),所以它只适合产线首次烧录,不适合量产 OTA。

OTA 应该用主闪存启动(BOOT0 = 0)。因为自定义 Bootloader 就放在主闪存的 0x08000000,OTA 的整个流程(收包 → 写备份区 → 校验 → 打标志 → 复位 → 搬运 → 跳转)全在主闪存内部完成,不涉及切换启动模式。相当于”自己管自己”。

如果想同时保留 ST 官方 ISP 通道(比如售后救砖):让自定义 Bootloader 在检测到某个标志或超时时,手动跳转到系统存储器(0x1FFFF000 附近),用完 ISP 再复位回主闪存。这样既保住了量产 OTA,又留了一条兜底的刷机路径。

一句话:三种启动模式靠 BOOT 引脚复位时选择;OTA 固定用主闪存启动,因为 Bootloader 和分区都在主闪存里,不依赖启动模式切换。

U-Boot 引导流程

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

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

常用文件系统, Flash和RAM的区别

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
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++ 优先使用对象自动管理资源,减少手动释放。

Q:在函数里 new 一个对象,函数退出会自动释放吗?不 delete、用 free 行不行?

A:不会自动释放,也不能用 free。 new 出来的是堆对象,函数结束时销毁的只是栈上那个指针变量,它指向的堆对象既不会自动调用析构函数,也不会归还内存,必须手动 delete。而 free 只归还内存、不调用析构函数,所以 new 配 free(或 malloc 配 delete)都是未定义行为。

一句话讲清区别:栈对象由编译器管——离开作用域自动析构、内存随栈帧回收;堆对象由程序员管——不 delete 就既不调用析构函数也不释放内存。

根本原因是 C++ 没有垃圾回收,new / delete 必须自己配对,没人替你回收。

创建方式存储位置释放方式自动释放
MyClass obj;栈离开作用域自动析构✅
new MyClass堆手动 delete❌
new MyClass[10]堆手动 delete[]❌
std::make_unique<MyClass>()堆unique_ptr 析构时 delete✅
malloc(sizeof(MyClass))堆手动 free❌

不 delete 的后果:每次调用都漏一块,长期运行的程序内存越用越少,最终 new 抛 std::bad_alloc;嵌入式 RAM 小,漏几 KB 就可能死机。如果对象还持有文件句柄、socket、锁等资源,不调析构函数比内存泄漏更严重。

补充:为什么不能对 NULL 指针调 free / delete? —— 其实可以:free(NULL) 和 delete nullptr 标准都规定为安全空操作,所以”delete 前判空”不是必需的。真正要防的是野指针(未初始化、已释放未置空),它们不指向 NULL,判空也拦不住。好习惯是申请后立刻判 NULL,delete 后立刻 p = nullptr。

现代 C++ 推荐用智能指针(RAII):unique_ptr 独占所有权、shared_ptr 引用计数归零时释放,本质都是”栈对象析构时自动 delete 堆对象”,基本可以告别裸 new/delete。

两个易错点:delete p 后 p 不会自动置空,是悬空指针,好习惯是紧接着 p = nullptr;同一块内存 delete 两次是未定义行为,通常直接崩溃。

简述 Qt(C++)的常用机制,比如信号槽、常用扩展库?

Qt 的核心是元对象系统(Meta-Object System),信号槽、属性系统、反射都建立在它上面。

信号槽(signal / slot):对象状态变化时 emit 一个信号,连接到该信号的槽(普通成员函数)会被自动调用。用 connect(sender, &Sender::sig, receiver, &Receiver::slot) 建立连接,收发双方互不知道对方,实现解耦。连接方式有 Auto(同线程直连、跨线程自动排队)、Direct(立即调用)、Queued(投递到接收者线程的事件循环);跨线程默认 Queued,所以槽函数天然跑在接收者线程,这是 Qt 线程安全的基石。

前提:类里要有 Q_OBJECT 宏,由 moc(元对象编译器) 预编译生成元信息,所以信号槽类必须经过 moc 处理(qmake/CMake 会自动做)。

其他常用机制:QObject 的父子对象树——父对象析构时自动 delete 所有子对象,解决内存管理;事件循环 QEventLoop 是所有 UI 和 Queued 调用的基础;Q_PROPERTY + QMetaObject 支持属性系统和反射。

常用扩展库:QString/QList/QMap 等容器与类型;QFile/QDir/QTextStream 文件与 IO;QThread/QMutex/QThreadPool 线程与并发;QNetworkAccessManager 网络;QSqlDatabase 数据库;QChart 图表;QSerialPort 串口(嵌入式上位机常用)。

一句话:Qt 用 moc + 元对象系统支撑信号槽,实现对象间解耦通信;再靠父子对象树自动管理内存,配合一大堆模块化库覆盖 GUI、网络、串口、并发。

虚函数是怎么实现的

常见 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 创建进程常用 fork、vfork、clone,执行新程序常用 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,子进程得到 0;pthread_create 创建线程并执行指定入口函数。

什么是僵尸进程/线程

僵尸进程/线程指已经结束运行、但资源还没被回收的进程/线程:退出后只留下一条记录(退出状态、PID/TID、栈等),不再参与调度,资源也无法释放。

僵尸进程:子进程先于父进程退出,父进程没调 wait/waitpid 回收。危害是占用进程表项和 PID,堆积后无法再 fork;父进程退出后由 init 接管回收。

僵尸线程:线程默认为 joinable,退出后要由别的线程 pthread_join;既不 join 也不 detach,栈和 TCB 就一直无法释放,累积后 pthread_create 返回 EAGAIN。

解决:进程——父进程 wait/waitpid,或忽略 SIGCHLD 交给 init;线程——pthread_join 回收,或用 pthread_detach(创建时设 detached)让内核自动回收。

本质相同:生命周期已结束,但资源没有回收。

任务共享资源和同步方法

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

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

互斥锁和同步锁的区别

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

面试回答: 互斥锁用于资源保护,条件变量/信号量用于执行顺序和事件通知。两者都属于同步机制,只是解决的问题不同。

例子:访问共享链表使用互斥锁;线程 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 提供 open、read、write、ioctl 等接口。内核和用户空间之间不能直接解引用对方地址,通常使用 copy_to_user 和 copy_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 通常结合 select、poll 或 epoll 监听多个文件描述符。

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+1,6=4+2,5=4+1,4=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(实际占用物理内存)和 %MEM;VIRT 是虚拟地址空间,不等于实际物理内存。

查找某个线程
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、符号表和调试信息。

常用命令有 file、readelf、objdump 和 nm。嵌入式工程通常先生成 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. 邮箱留言