嵌入式C/C++面试笔记-大厂真实求职珍藏版
个人真实下场在多家大厂的嵌入式面试笔试的经验,编程部分从编程语言可以分为C语言和C++两种;从系统来说,可以分为基础编程和系统编程;从问题的特点可以分为基础问题和疑难情景问题;以及最后的算法题。
结合我个人真实下场在多家大厂(宇宙厂及多家头部大厂)的嵌入式面试笔试的经验,个人真实面试笔试自用笔记,无偿分享,也希望大家更加关注编程细节。
背景:自己也经历过很难找到一份比较齐全的资料,到处付费买的也是不够齐全,于是就根据自己个人面试笔记撰写,希望对你帮助,同时启发对编程细节的专注。
受众:针对算法集成软件开发,嵌入式驱动开发,嵌入式软件开发,影像软件开发都是相同的问题。
影像软件的面试,主要是围绕编程和影像两方面展开,本节重点是整理嵌入式编程共性部分,相机编程之后专门整理。
编程部分从编程语言可以分为C语言和C++两种;从系统来说,可以分为基础编程和系统编程;从问题的特点可以分为基础问题和疑难情景问题;以及最后的算法题。
考虑到整体篇幅会比较大,把文章分为两篇,本篇主要总结C和C++的编程基础;下一篇总结系统编程和基础算法题;
一、C语言编程题
基础题主要围绕着数组函数指针展开,我这里抛砖引玉,建议收藏。
常问的编程基础主要包括以下几类,各种常见关键字的使用;结构体和数据类型的使用细节;以及加减指针表达式运算;各类数组和指针是必问的重点;特殊函数比如回调和IO函数等的细节使用。
重点1:关键字
volatile 关键字
题目:解释volatile
关键字的含义和什么时候使用它。
答案:
volatile
关键字的作用是告诉编译器,被其修饰的变量可能会在程序控制之外被修改,因此编译器在每次访问该变量时都应该直接从内存中读取其值,而不是使用可能已经存储在寄存器中的缓存值。这确保了变量的可见性和一致性。
通常用于:访问硬件寄存器、中断服务例程中的变量以及跨多个线程或任务共享的变量。
例子:
//在嵌入式系统中访问一个硬件寄存器 #define STATUS_REGISTER *((volatile unsigned int*)0x40021018) void check_status() { if (STATUS_REGISTER & 0x01) { // 处理特定状态 } }
static 关键字
题目:解释static
关键字在函数和变量上的作用,以及局部和全局使用的特点。
答案:
(1)在函数前使用static
,该函数的作用域被限制在定义它的文件内,即该函数成为了一个静态函数,不能被其他文件调用。
(2)在变量前使用static
,该变量的生命周期贯穿整个程序运行期间,但其作用域仍限制在定义它的作用域内(如函数内部或全局作用域)。
(3)修饰局部变量
当static
用于修饰函数内部的局部变量时,它会改变该变量的存储期,使其存储期贯穿整个程序运行期间,但变量只在定义它的函数内部可见。此外,该变量只在程序首次执行该函数时初始化一次,之后即使函数被多次调用,该变量的值也会保持上一次函数调用结束时的值。
(4)修饰全局变量和函数
当static
用于修饰全局变量或函数时,它会限制这些变量或函数的可见性,使得它们只能在定义它们的文件(编译单元)内部可见和使用,即它们具有内部链接性。
例子:
const 关键字
题目:解释const
关键字的作用,并比较const
与#define
在定义常量时的区别。
答案:const
关键字用于声明一个变量为常量,即该变量的值在初始化之后不能被修改。
从区别来看,const
有数据类型,而#define
没有;const
提供了类型检查,而#define
只是简单的文本替换,没有类型检查;
从存储的角度俩看,const
定义的常量存储在内存中(静态存储区或栈上),而#define
定义的宏在预处理阶段就被替换,不占用内存空间(但会影响程序长度)。
例子:
#define MAX_SIZE 100 const int MaxSize = 100; void func() { int array[MAX_SIZE]; // 使用#define定义的常量 int constArray[MaxSize]; // 使用const定义
typedef 关键字
题目:解释typedef
关键字的作用,typedef与#define的区别。
答案:
typedef
关键字用于为现有的数据类型定义一个新的名称(别名)。它并不创建新的数据类型,只是为已有的类型提供一个更方便、更具描述性的名字。
typedef 仅限于为类型定义符号名称,#define 不仅可以为类型定义别名,也能为数值定义别名,比如您可以定义 999 为MAX。
typedef 是由编译器执行解释的,#define 语句是由预编译器进行处理的。
例子:
typedef unsigned int uint; typedef struct { int x; int y; } Point;
setjmp
题目:用过setjmp么?它和goto什么区别?
答案:
定义在#include setjmp.h中。
非本地跳转setjmp longjmp实现了类似goto的机制。但并不局限于一个函数的作用域内。
例子:
重点2:变量类型
数据结构堆栈和队列
题目:
用过堆栈和队列么?什么区别?
答案:
嵌入式系统中常用的数据结构包括链表、栈、队列、树等,在嵌入式系统中,需平衡算法效率与资源消耗,选择适合的算法和数据结构。
栈:先进后出 push pop,适用于需要保护数据顺序的场景。
队列:先进先出 ,适用于需要按顺序处理数据的场景,比如create queue insert添加新元素以及delete移除一个元素,
链表:适用于需要频繁插入和删除操作的场景。
树:包括二叉树、红黑树等,适用于需要快速查找、插入和删除操作的场景。
变量定义
题目:
会在头文件中定义变量么?
答案:
特殊情况,即在头文件中使用extern
关键字声明变量,而在某个.c文件中定义这些变量,这种方式实现在多个.c文件之间共享变量。
变量通常不在头文件中定义,而是在.c文件中定义,原因有以下几点:
(1)避免重复定义:头文件通常被多个.c文件包含(include)。如果在头文件中定义变量,那么每当该头文件被包含时,这些变量就会被重新定义一次。
(2)控制变量的作用域和生命周期:在.c文件中定义的变量具有文件作用域(file scope),这意味着它们只在该.c文件内部可见和可用。这有助于封装和模块化编程。
(3)内存管理:在嵌入式系统中,内存资源往往非常有限。在.c文件中定义变量可以更容易地控制和管理这些变量的内存分配。这些变量被错误地视为全局变量,可能会导致内存泄漏。
寄存器变量
题目:
用过register变量么?
答案:
register:用于定义存储在寄存器中而不是 RAM 中的局部变量。
特点是变量的访问速度更快,需要注意是此时不能直接取地址。
指针常量和常量指针
题目:
指针常量和常量指针的区别是什么?
答案:
指针常量是指一个指针,它指向一个常量数据。这意味着你不能通过这个指针来修改它所指向的数据的值,但是你可以改变指针本身的值(即它可以指向不同的地址)。
const int *ptr;
常量指针是指一个指针本身的值是常量,即你不能改变这个指针指向的地址,但是你可以通过这个指针来修改它所指向的数据的值(如果数据本身不是常量的话)。
int *const ptr;
强制类型转换
强制类型转换将一个数据类型的变量转换为另一种数据类型。
常见于处理硬件寄存器、位操作和不同数据格式转换时。
例子:
结构体字节对齐
题目:
结构体中定义,在32系统上,定义的成员分别是char a;int b; short c;则总共占多少个字节?
答案:
字节对齐,8个,还有以下其他的排列。
例子:
重点3:表达式运算
逗号表达式
题目:
逗号运算 c=a,b;
,则c的结果是谁?
答案:
逗号运算符的作用:用于顺序执行两个表达式,并返回最后一个表达式的值。
在 c=a,b;
实际上并没有正确使用逗号运算符。这会导致 c
被赋值为 a
的值),而 b
的值则没有被使用在这个表达式中。
d=(a,b);
这里正确地使用了逗号运算符。因此,d
会被赋值为 b
的值,因为 a
的值虽然被计算了,但结果被逗号运算符丢弃了。
变量自加自减
题目:
c=n++和c=--n的结果分别是多少?
答案:
实际的变量的自加(++
)和自减(--
)操作通常用于计数、状态机更新、硬件寄存器访问中的标志位翻转等场景。
一般常考这类问题,且一些笔试题确实出现各种各样的奇怪问题。
例子:
指针加法
题目:
怎么通过指针的方式操作数组元素?
比如,给定一个整数数组int arr[] = {1, 2, 3, 4, 5};
和指向该数组第一个元素的指针int *ptr = arr;
,请写出表达式以获取数组中第三个元素的值,并解释你的答案。
答案:
int thirdValue = *(ptr + 2);
指针加法:实际上是将指针的地址值增加了一个或多个所指向类型的大小。例如,如果有一个指向int
的指针int *ptr
,并且int
类型占用4个字节,那么ptr + 1
将会使ptr
的地址增加4个字节,指向下一个int
的位置。
指针减法
题目:
给定两个指向同一整数数组的指针int *start = arr;
和int *end = arr + 4;
(其中arr
同上),请计算并打印这两个指针之间有多少个元素。
答案:
int count = end - start; printf("%d\n", count);
指针减法:实际上基于所指向类型的大小来减少地址值。如果ptr2
和ptr1
是指向同一类型数据的指针,那么ptr2 - ptr1
的结果是两个指针之间所间隔的元素数量,而不是字节数。
例子:
数组指针加法
题目:
给定一个指向字符数组(字符串)的指针char *str = "hello";
,请写出表达式以获取字符串中第二个字符(即'e'
)的地址,并解释你的答案。
答案:
char *secondCharPtr = str + 1;
例子:
多维数组加法
题目:
二维数组和指针,如何使用指针方位二维数组中的元素。
答案:
二维数组实际上是一个数组的数组,即每个元素本身也是一个数组。
对于二维数组,数组名是指向其第一行(即第一个一维数组)的指针。
即数组名array
可以看作是指向包含4个整数的数组的指针的指针(即int (*)[4]
类型)。
然后要访问特定元素,你可以先将数组名转换为指向行的指针,然后加上行索引(乘以每行的元素数量以计算偏移),最后加上列索引:
int *ptr = (int *)array; // 将array转换为指向int的指针 //ptr转换实际上是不安全的,因为它丢失了关于数组列数的信息。正确的做法是使用int (*)[4]类型的指针. // 但实际上,我们应该将array视为指向行的指针: int (*rowPtr)[4] = array; int value = rowPtr[1][2]; // 正确的方式,但仍然是使用数组索引 // 或者,使用指针算术: int value2 = *((rowPtr + 1) + 2); // 访问第二行第三列,注意这里的+2是列偏移,每个元素是int大小 // 更清晰的方式是使用正确的类型: int value3 = *(*(rowPtr + 1) + 2); // 等同于value2,但分解了指针解引用的步骤 // 更常用的方式是直接通过行指针和列索引访问,无需转换为int*: int value4 = (*(rowPtr + 1))[2]; // 访问第二行第三列
例子:
void foo(int [][3] ); main() { int a [3][3]= { { 1,2,3} , { 4,5,6},{7,8,9}}; foo(a); printf("%d" , a[2][1]); } void foo( int b[][3]) { ++ b; b[1][1] =9; } 在 foo 函数内部,您试图通过增加指针(++b)来跳过数组的第一行; 当您修改 b[1][1] 时,由于 b 已经被增加,实际上您修改的是原始数组 a 的第三行第二列的元素(即 a[2][1])。
重点4:数组和指针
指针数组和数组指针
题目:
指针数组和数组指针的区别,分别在什么时候使用。
答案:
指针数组是指一个数组,其元素是指针。即每个数组元素都存储了一个地址,通常这些地址指向其他变量或数组。
int a = 1, b = 2, c = 3; int *ptrArray[3] = {&a, &b, &c};
数组指针是指一个指针,它指向一个数组。即指针存储了一个数组的首地址,并且通过指针的类型知道数组的大小。
int arr[5] = {1, 2, 3, 4, 5}; int (*arrayPtr)[5] = &arr;
例子:
结构体指针
题目:
如何使用结构体指针。
答案:
结构体作为函数参数:
void printBook( struct Books book );
指向结构的指针:
struct Books *struct_pointer;
例子:
struct Books { char title[50]; char author[50]; char subject[100]; int book_id; } book = {"C 语言", "RUNOOB", "编程语言", 123456};
函数指针
题目:
如何定义函数函数指针?函数直接主要解决什么问题
答案:
typedef int (test) ( float * , float)
test tmp;
则tmp 的类型是函数的指针。
当需要动态地调用不同的函数来处理不同的任务的时候,使用函数指针来存储和调用这些函数。
例子:
野指针
题目:
什么是野指针,为什么出现野指针?
答案:
野指针是一个指向未知内存地址的指针。
出现原因:没有被明确地初始化;越界访问数组;已经被释放(比如free()),但随后又被访问;指针运算错误。
导致结果:不可预测的行为,包括程序崩溃、数据损坏或安全漏洞等。
重点5:函数
函数返回值
题目:
如何正确的指针返回值,而不会出现异常。
答案:
函数返回局部变量的指针,可能引起指针方面的问题;
如果函数返回动态分配的内存的指针,则较少出现指针问题。
信号处理函数
题目:
你知道信号处理函数么?在什么时候会用到它。
答案:
信号处理函数用于处理系统在运行时遇到的各种信号Signal。
比如用户中断(如Ctrl+C)、硬件异常(如除零错误)、定时器超时都是信号,信号是异步通知机制,用于通知进程发生了某种事件。
主要作用:捕获和处理信号;处理特定的操作;是程序更加健壮。
例子:
signal.h
终止执行abort(void):异常终止一个正在执行的程序,该函数将引发SIGABRT信号,可以在程序中为这个信号设置一个信号处理函数。
断言(assert):表达式为假或者0,则向标准错误打印一条诊断信息并终止程序。
回调函数
题目:
你一般是怎么定义和使用回调函数?
答案:
回调函数就是函数指针作为某个函数的参数的使用方式。
回调函数经常用来来处理特定事件,如中断、定时器到期、外设数据就绪等,可以使得代码更加模块化和可重用。
例子:
文件操作IO函数
题目:
如何读取并解析文件内的字符?
答案:
使用io操作函数fopen和fget,获取文件句柄就可以读取文件内的字符内容。
文件打开和关闭函数fopen和fclose;
文件读写函数fputc/fputs/fgetc/fgets/,读写数据块fread/fwrite,文件指针操作fseek/ftell
例子:
可变参数
题目:
你用过可变参数么,如何通过可变参数来实现遍历求和?
答案:
可变参数指的是函数可以接受数量不定的参数。
这种特性通过使用标准库中的<stdarg.h>
头文件来实现,里面提供了一套宏来访问这些可变参数。
定义函数原型:使用省略号(...
)来表示函数接受可变数量的参数。
void print_numbers(int n, ...); //`n`是一个确定的参数,表示后续可变参数的数量
然后定义一个va_list
类型的变量,它将用于访问可变参数列表。va_list是一个类型,用于声明一个变量,该变量将用于在可变参数列表中遍历参数。你可以把它想象成一个指向参数列表的指针,va_start宏用于初始化va_list变量,以便它可以开始遍历可变参数列表。它通常与函数的最后一个固定参数一起使用,以确定可变参数列表的起始位置。va_arg宏用于从va_list变量指向的当前位置检索下一个参数,并将va_list变量更新为指向下一个参数的位置。
例子:
递归函数
题目:
递归用过吧,你了解递归的实现么?举例说明
答案:
递归函数是指一个在其函数体内直接或间接调用自身的函数。
递归通过将一个复杂问题分解小的子问题来解决,每个子问题就是递归调用的实例,最后直到达到递归终止条件为止。
所以递归需要关注两个条件:
首先是递归的终止条件,当满足这个条件时,递归调用将停止,从而避免无限循环;
然后就是这是函数调用自身的部分。
例子:
二、C++编程题
因为C++相比C语言在嵌入式领域略少,且一些场景经常把C++按照C语言的语法使用,所以一般是排在第二位的。C++常问的基础题目主要分布在面对对象语言的问题。
常见的问题主要包括不同语言之间的差异特点,然后是面向对象的语言特性,最后就是构造析构函数以及虚函数的使用技巧。
C++由三个重要部分组成:语言核心、标准库和标准额模板库。
- 核心语言,提供了所有构件块,包括变量、数据类型和常量等等。
- C++标准库,提供了大量的函数,用于操作文件、字符串等。
- 标准模板库(STL),提供了大量的方法,用于操作数据结构等。
C/C++/java语言的优略势
C、C++、Java三种编程语言之间的优缺点和特点的如下:
C语言的优势:高效性和底层控制和简洁性和可移植性;特点是过程式编程语言,可直接访问物理地址。
C++相对于C的优势;面向对象编程和标准库和模板支持;
C++相对于Java的优势:性能优势和直接内存访问和硬件操作;
Java的优势:跨平台性和丰富的库和框架;java特点是丰富的API。
因为以上特点,在嵌入式领域编程中,HAL层及driver会使用C语言为主;frwk层软件以C++语言为主;而APP层则都是java代码(这部分由应用开发人员负责)。
C++面向对象
C++完全支持面向对象的程序设计,包括面向对象开发的四大特性:
- 封装: 数据和方法组合在一起,对外部隐藏实现细节,只公开对外提供的接口。可提高安全性、可靠性和灵活性。
- 继承: 是从已有类中派生出新类,新类具有已有类的属性和方法,并且可以扩展或修改这些属性和方法。这样可以提高代码的复用性和可扩展性。继承允许我们依据另一个类来定义一个类。只需指定新建的类继承了一个已有的类的成员即可。这个已有的类称为基类,新建的类称为派生类。
class Dog : public Animal //派生类
多继承:一个子类可以有多个父类,它继承了多个父类的特性。
- 多态: 同一种操作作用于不同的对象,可以有不同的解释和实现。它可以通过接口或继承实现,可以提高代码的灵活性和可读性。
- 抽象: 是从具体的实例中提取共同的特征,形成抽象类或接口,以便于代码的复用和扩展。抽象类和接口可以让程序员专注于高层次的设计和业务逻辑,而不必关注底层的实现细节。
C++和C的差异
引用很容易与指针混淆
它们之间有三个主要的不同:
- 不存在空引用。引用必须连接到一块合法的内存。
- 一旦引用被初始化为一个对象,就不能被指向到另一个对象。指针可以在任何时候指向到另一个对象。
- 引用必须在创建时被初始化。指针可以在任何时间被初始化。
命名空间
命名空间可作为附加信息来区分不同库中相同名称的函数、类、变量等。
一个文件夹(目录)中可以包含多个文件夹,每个文件夹中不能有相同的文件名,但不同文件夹中的文件可以重名。
动态内存
在 C++ 中使用特殊的运算符为给定类型的变量在运行时分配堆内的内存,这会返回所分配的空间地址。这种运算符即 new 运算符。
如果你不再需要动态分配的内存空间,可以使用 delete 运算符,删除之前由 new 运算符分配的内存。
类和对象
类是 C++ 的核心特性,通常被称为用户定义的类型。类用于指定对象的形式,它包含了数据表示法和用于处理数据的方法。类中的数据和方法称为类的成员。
类成员函数:
成员函数可以定义在类定义内部,或者单独使用范围解析运算符 :: 来定义。
类访问修饰符:
关键字 public、private、protected 称为访问修饰符。
类构造函数与析构函数
类的构造函数是类的一种特殊的成员函数,它会在每次创建类的新对象时执行。
析构函数在销毁对象时执行。
带参数的构造函数
类的析构函数是类的一种特殊的成员函数,它会在每次删除所创建的对象时执行。
友元函数
类的友元函数是定义在类外部,但有权访问类的所有私有(private)成员和保护(protected)成员。尽管友元函数的原型有在类的定义中出现过,但是友元函数并不是成员函数。
友元可以是一个函数,该函数被称为友元函数;友元也可以是一个类,该类被称为友元类,在这种情况下,整个类及其所有成员都是友元。
如果要声明函数为一个类的友元,需要在类定义中该函数原型前使用关键字 friend。
this指针
this
指针主要用于在类的成员函数内部访问类的成员(包括成员变量和成员函数)
在类的成员函数内部,你可以直接使用类的成员变量和成员函数,而不需要显式地通过this
指针来访问它们。然而,在某些情况下,显式地使用this
指针可以使代码更清晰,或者解决一些特定的编程问题(如返回当前对象的引用或指针)。
类的指针
访问指向类的指针的成员,需要使用成员访问运算符 ->,就像访问指向结构的指针一样。
Box *ptrBox; // 申明一个指针指向类
// 保存第一个对象的地址
ptrBox = &Box1;
// 现在尝试使用成员访问运算符来访问成员
cout << "Volume of Box1: " << ptrBox->Volume() << endl;
类的静态成员
类的外部通过使用范围解析运算符 :: 来重新声明静态变量,静态函数只要使用类名加范围解析运算符 :: 就可以访问。
int Box::objectCount = 0; // 初始化类 Box 的静态成员
重载运算符和重载函数
重载声明是指一个与之前已经在该作用域内声明过的函数或方法具有相同名称的声明,但是它们的参数列表和定义(实现)不相同。
运算符重载
重载的运算符是带有特殊名称的函数,函数名是由关键字 operator 和其后要重载的运算符符号构成的。
Box operator+(const Box&);
模板
使用泛型来定义函数,其中的泛型可用具体的类型(如int或double)替换。通过将类型作为参数传递给模板,可使编译器自动生成该类型的函数。
多态使用举例
多态意味着调用成员函数时,会根据调用函数的对象的类型来执行不同的函数。
C++中的多态性是一种允许以统一的方式处理不同类型的对象的特性。多态性主要分为两种类型:编译时多态(通过函数重载和模板实现)和运行时多态(通过虚函数和继承实现)。运行时多态是C++中多态性的主要和最具代表性的形式,它允许通过基类指针或引用来调用派生类中的函数。
虚函数使用举例
在基类中使用关键字 virtual 声明的函数。在派生类中重新定义基类中定义的虚函数时,会告诉编译器不要静态链接到该函数。
虚函数有很多种,有虚函数、纯虚函数、抽象类、虚析构函数等
例子1:通过父类指针声明子类对象
basePtr
是一个指向Base
类的指针,但它实际上指向了一个Derived
类的对象。当调用basePtr->show()
时,尽管basePtr
的声明类型是Base*
,但由于show()
是一个虚函数,并且basePtr
实际上指向了一个Derived
类的对象,因此调用的是Derived
类中的show()
函数。
例子2:函数参数定义为父类,调用函数时传入的是子类对象
display
函数接受一个Base
类的引用作为参数。在main
函数中,我们创建了一个Derived
类的对象derivedObj
,并将其作为参数传递给display
函数。尽管display
函数的参数是Base&
类型,但由于show()
是一个虚函数,并且传入的对象derivedObj
的实际类型是Derived
,因此调用的是Derived
类中的show()
函数。
三、专业领域问题
比如我所在的影像嵌入式领域,除了需要嵌入式知识,还需要基本影像知识;如果从事音频嵌入式,还需要具备音频的基础知识;
如果面试算法集成软件,还需要了解常用的算法的基本概念;嵌入式AI中,还需要了解AI算法的基础和部署方法。
完整版带代码示例,关注VX公众号<森哥谈成像技术>,可得完整版。
本次整理发现,自己也还有一些细节没有挖掘完,每年都会重新梳理,整理成pdf,不断更新,欢迎持续关注,大家一起进步。
”金三银四“,祝你得偿所愿。
#面试之前应该如何准备?##嵌入式笔面经分享##C语言##面试题目##通信/硬件秋招总结#
#面试时最害怕被问到的问题##嵌入式笔面经分享##面试之前应该如何准备?##C语言##嵌入式Linux#