嵌入式秋招面经八股(三)
经纬恒润
1. 宏定义
宏定义在编译过程的预处理阶段生效。处理以#开头的指令,如#include、#define等,宏定义在此阶段展开,替换为相应的代码和值,生成一个预处理的源文件,供后续编译使用。
2. 指针和数组的异同
- 数组:连续内存块的直接表示,大小固定,数组名是常量指针
- 指针:存储地址的变量,可以指向任意内存位置,灵活性更高
3. 结构体描述
结构体(struct)是C/C++中一种用户自定义的数据类型,用于将不同类型的数据组合在一起,形成一个复合数据类型。结构体可以包含多个成员变量,每个成员变量可以是不同的数据类型(如 int、float、char 等)。
优点:
- 数据封装:将相关的数据组合在一起,便于管理和使用
- 代码可读性:通过结构体可以更清晰地表示复杂的数据结构
- 灵活性:结构体可以包含不同类型的成员,适应多种需求
4. 结构体内存对齐规则
结构体中的内存对齐是为了提高内存访问效率,确保数据在内存中的存储位置符合硬件的要求。
- 对齐值:每个数据类型都有一个对齐值,通常是其大小(以字节为单位)。例如,int 的对齐值通常是 4 字节,double 的对齐值通常是 8 字节
- 结构体成员的对齐:结构体中的每个成员都按照其对齐值进行对齐。成员的存储位置必须是其对齐值的整数倍
- 结构体整体的对齐:结构体的整体大小必须是其最大成员对齐值的整数倍。结构体的对齐值通常等于其最大成员的对齐值
5. 结构体和联合体的关系与区别
结构体(struct)和联合体(union)都是C/C++中用于定义自定义数据类型的方式。二者语法相似,并且可以嵌套使用。
区别:
- 存储方式 :
- 结构体中每个成员各自占据内存,结构体的大小通常为所有成员大小的和
- 联合体中所有成员共享一块内存区域,联合体的大小等于其最大成员的大小
系统编程
6. I/O多路复用:epoll、poll、select
三者都是Linux下用于I/O多路复用的系统调用,用于同时监视多个文件描述符(fd)的状态,从而避免阻塞式I/O造成的性能损失。
- select:最早的I/O复用机制,受到文件描述符数量的限制,并且每次调用都要复制fd的集合,性能较低
- poll:改进了select,去除了fd的数量限制,但仍然是线性遍历所有的fd,性能较差
- epoll:由Linux引入,使用事件通知机制,无需遍历所有的fd,大幅提高性能
7. C++虚函数
在 C++ 中,虚函数是一种支持多态的机制,使得基类的指针或引用可以调用派生类的重写(override)方法。定义方式是在基类的成员函数前加上 virtual 关键字。
实现机制 :
C++中通过虚函数表和虚表指针来实现虚函数的动态绑定机制:
- 每个包含虚函数的类都有一张虚表,表中存储的是该类的虚函数的地址,虚表是静态存储的,在编译时就已经生成了
- 每个对象都有一个虚表指针(编译阶段产生),指向该类的虚表
- 当通过基类指针调用虚函数时,程序会根据虚表指针查找对应的虚函数表,然后调用其中存储的函数
8. C/C++编译流程及产生文件的后缀
- 预处理(.i):展开头文件、处理宏定义和条件编译,删除注释
- 编译(.s):将高级语言转换为平台相关的汇编代码
- 汇编(.o):将汇编代码转换为机器码,生成未链接的可重定位的目标文件
- 链接:合并所有的目标文件和库,生成最终的可执行文件
9. 可执行文件分段
- 代码段:可执行的机器指令
- 数据段:包含已经初始化的全局变量和静态变量
- BSS段:未初始化的全局变量和静态变量
- 堆段:动态分配的内存
- 栈段:局部变量和函数调用信息
10. 未初始化的变量存储
- 全局变量和静态变量:存储在BSS段,不占用可执行文件大小,程序运行的时候自动初始化为0
- 局部变量:存储在栈段,占用的内存大小和相应的数据类型相同,值是随机的
11. U-Boot启动流程
在 ARM 架构下运行 Linux 操作系统时,系统启动阶段需要将内核加载到内存并启动执行,这一过程通常由 Bootloader 完成,常用的 Bootloader 是 U-Boot。
- BootROM:系统上电后,SoC 内部固化的 BootROM 代码会首先运行,它负责检测启动引导设备(如 SD 卡、eMMC、SPI Flash 等),并从中加载下一阶段的启动程序(通常是 SPL:Secondary Program Loader)到片内 SRAM 执行
- SPL执行:SPL 的任务是对关键硬件(尤其是 DDR 内存)进行初始化
- U-Boot加载:一旦 DDR 初始化完成,SPL 会将完整的 U-Boot 镜像从存储介质中加载到 DDR 中并跳转过去运行
- 系统移交:完整的 U-Boot 随后会初始化更多外设(如串口、网络、USB 等),完成环境设置,最终负责将 Linux 内核、设备树(Device Tree)和其他的部分加载到内存中,并跳转到内核入口,交由 Linux 接管系统控制权
启动流程至此结束,而后linux内核开始启动:解压自身->解析设备树【创建设备节点】->初始化内核子系统->匹配并加载驱动->启动第一个用户空间进程(init)
12. sizeof运算时机
sizeof的运算结果大多数情况下在编译时就能确定(基本类型,固定大小的数组,指针,结构体/类),少数情况当操作数为可变长度数组时,发生在运行阶段(没用过,只在C中使用)。
示例:
c
int a = 0;
int i = sizeof(a++);
结果:i = 4,a = 0
解释:sizeof是一个编译阶段指令,所以sizeof实际就是sizeof(int)为4字节。a++在运行时才会生效,但在实际运行的时候由于sizeof是在编译阶段,所以会被忽略,所以a仍然是0。
并发编程
13. 死锁
死锁发生的条件:
- 互斥
- 占有并等待
- 不可抢占
- 循环等待
死锁发生的情况:
如果线程A持有资源R1并等待资源R2,而线程B持有资源R2并等待资源R1,就会形成一个循环等待的状态,导致死锁。
14. 互斥锁、读写锁
互斥锁:
- 保证在任何时刻,只有一个线程可以访问某一共享资源
- 互斥锁允许线程在访问临界区时锁定资源,其他线程必须等待当前线程释放锁才能继续访问该资源
- 特点:独占性、阻塞性
读写锁:
- 允许多个线程同时读共享资源,但在写资源时必须独占访问
- 读时共享、写时独占
- 读锁:共享锁;写锁:独占锁
15. 线程间同步方式
- 锁机制
- 条件变量
- 信号量
16. 线程的状态
- 新建状态
- 就绪状态
- 运行状态
- 阻塞状态
- 死亡状态
高频考点
17. volatile关键字
volatile 关键字在 C 和 C++ 中用于修饰变量,指示编译器该变量的值可能会在程序的任何时刻发生变化,即使程序的其他部分没有显式地修改它。
使用场景:
- 与硬件相关的编程
- 并发编程或者多线程编程
作用:
通常编译器会将一个局部变量的值保存在寄存器中,每次读数据都从寄存器中进行读取(速度快),当加了volatile关键字后,编译器就会知道该变量可能会在其他地方改变(如硬件),编译器每次都会从内存中读取这个值。
18. const关键字
const的主要作用是表示某个变量值在程序中不可被修改,根据修饰的对象不同,const的用法不同。
(1) 修饰普通变量:
表明该值在定义后不可以再次修改。
(2) 修饰指针:
根据const的位置不同,有不同的含义:
a、指向常量的指针:
c
const int *ptr = &a; // ptr 是指向常量的指针
*ptr = 20; // 编译错误:不能修改 ptr 指向的值
ptr = &b; // 合法:指针可以指向其他地址
b、常量指针:
c
int *const ptr = &a; // ptr 是常量指针
*ptr = 20; // 合法:可以修改 ptr 指向的值
ptr = &b; // 编译错误:不能修改指针 ptr 的地址
c、常量指针指向常量:
c
const int *const ptr = &a; // const 指针指向 const 对象
*ptr = 20; // 编译错误:不能修改 ptr 指向的值
ptr = &b; // 编译错误:不能修改 ptr 的地址
(3) 修饰函数参数:
确保参数在函数内不能被修改,通常用于引用或指针类型的参数。
(4) 修饰返回值:
表示返回的值不能被修改。
(5) 修饰成员函数:
const放在函数声明的尾部,表示函数不会修改类的成员变量,也不能调用其他非const的成员函数。可以访问成员变量,不能修改,可以调用const成员函数。
19. 空指针和野指针
空指针:
指不指向任何有效内存位置的指针,通常是NULL(宏:值通常为0)或nullptr(C++11中表示一个类型安全的空指针)。
野指针:
指一个指针仍然指向原本的有效位置,但该内存位置已经被释放或者不再有效。可能引发程序崩坏、数据损坏。
悬空指针:
是野指针的一种情况,即释放指针后没有将其置空。
野指针的场景:
- 释放内存后指针未被置空:
c
int* ptr = new int(10); // 动态分配内存
delete ptr; // 释放内存
// 此时 ptr 成为野指针,指向已释放的内存区域
- 指向局部变量:
指向已出作用域的局部变量的指针也会成为野指针。
避免野指针的方法:
- 及时置空指针
- 使用智能指针
- 避免指向局部变量的指针
- 使用nullptr初始化指针
20. 位域
位域是一种允许我们精确控制每个数据成员占用内存位数的技术。它通常用于存储标志位或小范围的整数,可以节省内存。尽管如此,位域在内存对齐和跨平台行为方面可能会存在一些不确定性,因此在使用时需要小心。
21. 指针函数和函数指针
指针函数:
是指返回值为指针的函数。
函数指针:
是指向函数的指针,它保存一个函数的地址,通过函数指针可以间接地调用一个函数。
例如:
c
int (*fptr)(int, int); // 定义一个函数指针 fptr,指向一个返回 int 类型并且参数为两个 int 类型的函数
函数指针也可以作为参数。
22. 指针数组和数组指针
指针数组:
是指每个元素都是指针的数组。
数组指针:
是指向数组的指针,指向整个数组的起始位置。
23. 函数传参的方式
值传递:
将实参复制到形参中,函数内部操作不会影响到实参。
指针传递:
将参数的地址传递给函数,函数可以直接访问和修改实参的值。
引用传递:
将实参的引用(别名)传递给函数,函数内部操作的就是实参本身,可以直接修改实参。可以使用常量引用传递来避免修改实参。
24. 位运算操作
如何取出一个32位数的高16位和低16位?
- 取低16位:与操作0x0000FFFF
- 取高16位:将高16位右移16位至低16位上,与操作0x0000FFFF
25. 构造函数和析构函数调用时机
构造函数:
在对象创建的时候被调用,包括:
- 对象的局部作用域内
- 动态分配内存时
- 对象数组创建时
- 函数返回对象时等
析构函数:
在对象被销毁的时候调用,包括:
- 对象的局部作用域结束时
- 动态内存释放时
- 对象数组销毁时
- 临时对象销毁时等
26. 内存分区
内存分区(Memory Partitioning)是操作系统管理内存的一种方式,它将内存分成不同的区域或"分区",以便更有效地分配和管理系统资源。通常会将其分为代码段、数据段、堆、栈、内核空间、用户空间。
内存分区技术:
- 静态分区
- 动态分区(堆、栈实现)
- 分页
- 分段
- 虚拟内存
优点:
- 提高内存利用率:合理的内存分区可以避免浪费,使得不同的任务可以共享内存
- 提高程序的稳定性:通过保护每个分区,操作系统能够有效避免程序之间的干扰和数据冲突
- 更好的资源管理:操作系统可以根据需要动态调整内存分配,从而有效利用内存资源
27. 头文件中定义变量和函数
实际上是可以的,但是并不建议这样操作。可以在头文件声明变量(用关键字extern,告知编译器该变量在其他地方定义)和函数,而在源文件中定义变量和函数。
28. 多态的实现方法
- 虚函数
- 函数指针
- 运算符重载
- 模板(静态多态)
29. 大端和小端
大端和小端是数据在内存中两种不同的存储方式。
大端:
数据的高位字节在低地址,低位字节在高地址。
小端:
数据的低位字节在低地址,高位字节在高地址。
应用:
主机字节序通常是小端,而网络字节序是大端。所以在进行socket通信的时候,通常需要大小端的转换。
检测方法:
使用C语言实现大端和小端可以定义一个无符号整型变量,在内存中以4个字节(32位)进行存储,将x的地址强制转换为unsigned char*类型,此时这个指针就指向内存中的第一个字节,就可以根据结果来得到大端和小端。
网络编程
30. TCP的滑动窗口机制
滑动窗口是TCP实现流量控制的关键机制,允许发送方在等待确认前连续发送多个数据包,而不是每发一个就等一次确认,从而提高传输效率。
简单点说就是接收方有一个缓冲区,通常是以字节为单位,只要这个缓冲区没有满,发送方就可以一直发送数据,当接收方回复时,TCP报文中的window窗口里面会有相关的信息,告诉发送方我还可以接收多少字节的数据。
31. TCP的拥塞控制
拥塞控制的目的就是避免因为网络过载导致丢包、延迟增加、带宽浪费。就是说发送方在发送数据的时候,不能一下发送的数据太多,直接"塞爆"网络,而是通过动态控制发送速率,使得发送速率和网络恰好合适。
32. 拥塞控制的四个经典阶段
-
慢启动:
- 初始时,发送速率要慢一点,通常为1或较小值
- 每收到一个ACK,拥塞窗口就增长(指数增长)
- 当达到慢启动阈值(ssthresh),进入下一阶段
-
拥塞避免:
- 当拥塞窗口大于等于慢启动阈值后,不再按照指数增长,而是按照线性增长
-
快速重传:
- 如果收到连续3个重复的ACK,不用等待超时,立刻重传那个丢失的数据包
- 如果收到2个ACK,可能是网络抖动,乱序导致的
-
快速恢复:
- 重传数据丢失后,将拥塞窗口设置为ssthresh / 2,以缓解网络的压力,然后重新慢启动
注意:
发送方可发送的数据量是滑动窗口和拥塞窗口中的较小值。
33. IP协议
IP协议用于在不同网络之间传输数据包。它提供了无连接、不可靠的传输服务。
特点:
- **无连接:**发送数据前不建立连接
- **不可靠:**不保证数据一定送达,也不保证顺序和完整性
版本:
- **IPV4:**使用32位地址,地址格式是点分十进制
- **IPV6:**使用128位地址,采用冒号十六进制
相关概念:
- 子网掩码是IPV4的概念,主要用来确认划分网络位和主机位
- 每个计算机或者路由器内部都会有一个路由表,在进行数据传输的时候是按照一跳一跳来实现的
过渡技术:
为了从IPV4平稳的过渡到IPV6,设计了多种技术:
- 双协议栈(同时运行IPV4和IPV6协议栈)
- 隧道机制(IPV6数据包封装在IPV4中进行传输)等
Linux内核与驱动
34. 内核模块
内核模块是编译好的内核代码片段(.ko文件),可以在内核运行时动态的加载和卸载,从而扩展内核功能,它和静态编译进内核不同,它是需要"拔插的",就像USB插件一样。有些不常用的功能(比如某些硬件驱动)不想在启动的时候全部加载,就可以使用内核模块,而且不用重启内核就可以调试模块代码。
35. 内核模块加载的流程
当用户使用insmod加载模块的时候,实际上完成了从用户空间到内核空间的一系列动作:
- Insmod是用户空间的命令,它最终会调用Linux内核暴露出来的系统调用sys_init_module()
- .ko是一个ELF格式的可链接目标文件,类似与普通的.o文件,内核会检查ELF文件格式,分配内核空间内存,解析重定位和符号依赖
- 执行初始化函数module_init
- 注册符号/添加模块表项
硬件通信协议
36. I2C协议
硬件组成:
SDA和SCL以及一个上拉电阻组成。在空闲状态下,二者都是高电平,通过改变两根线的高低电平来进行数据传输。
特点:
同步、串行、半双工的通信方式。不管多少设备,都是两根线,挂载的设备数量有限。
信号定义:
- 起始信号:SCL为高电平,SDA由高至低
- 终止信号:SCL为高电平,SDA由低至高
- 每次发送8个字节,收到一个ACK
速率:
- 低速:100kbps
- 快速:400kbps
- 最快可达3.4Mbps
适用场景:
设备多,通信速率低,系统简单的应用场景。
37. SPI协议
硬件组成:
有四根线,分别是SS、MISO、MOSI、SCLK。
特点:
一主多从的通信方式,串行,同步,全双工的通信方式,主从通信实际上就是二者相互交换自己的数据,通过移位寄存器实现。
优势:
速度快,可达几十Mbps,无ACK。
38. 串口通信
特点:
串行、异步、全双工的通信方式。
硬件组成:
由三根线组成,分别是地线、发送线、接收线。
数据格式:
一帧数据由11位组成,其中1位起始位,8位数据位,1位奇偶校验位,一位停止位。
- 起始位:拉低开始传输数据
- 停止位:拉高停止传输数据
电平标准:
- TTL电平
- RS232协议: -5-15为逻辑1,+5+15为逻辑0
- **RS485:**使用差分信号进行传输,两线之间的电压差为+2到+6V表示逻辑1,两线之间的电压差为-2到-6V表示逻辑0
39. CAN协议
特点:
一种串行通信协议,目标是高可靠性、实时性和多主通信。所有节点地位平等,都可以发起通信,使用差分信号传输,抗干扰能力强。
帧类型:
数据帧、遥控帧、错误帧、过载帧、间隔帧。
帧结构:
每帧主要由以下几个部分组成:起始位、标识符、控制字段、数据字段、CRC校验、应答位、帧结束位。
驱动开发
40. 字符设备驱动开发流程
主要包含四个部分:
- 分配主次设备号
- 文件操作函数fops设置
- 字符设备结构的分配和初始化
- 创建设备节点
音视频编码
41. H264编码
H264是一种高效的视频压缩标准,压缩的主要步骤包含:
- 使用当前帧使用周围像素预测【只保存残差,不保存完整的数据】,压缩空间冗余
- 使用前后帧数据,压缩时间冗余
- 变换、量化、熵编码等一些手段
H265 vs H264:
H265相比于H264具有更高的帧率,并且更加节省空间,但是H265编码速度慢(H264比H265快5-10倍),所以在要求实时性的场景下,优先考虑H264。
内存操作函数
42. memcpy、memset和memmove
memcpy:
c
memcpy(void *dest, const void *src, size_t n)
从 src 拷贝 n 个字节到 dest,常用于结构体或数组的复制。底层通常是使用char*逐字节拷贝。
memset:
c
memset(void *s, int c, size_t n)
将 s 指向的内存区域的前 n 个字节设置为值 c,常用于清零或初始化内存。
memmove:
c
memmove(void *dest, const void *src, size_t n)
从 src 拷贝 n 个字节到 dest,与memcpy相比,它的源地址和目标地址可以重叠。
重叠处理:
比如源目标地址是0,目标地址是2,需要拷贝的字节为5,则此时发生重叠,必须使用memmove,使用memcpy会发生未定义的错误,因为memcpy是逐字节拷贝的,在拷贝到第三个字节的时候会用已经覆盖的字节继续拷贝。使用memmove可以避免的原因是会自动判断是否重叠,如果重叠的话就会从后向前拷贝来避免。