4 嵌入式软件面试 — C语言
4.1 C语言该如何准备
C语言面试时,重点准备指针、数组、内存等知识,对重要的概念进行辨析。其次,简历中提到的关于C语言的内容应该烂熟于心,都是会重点会被问到的内容。下面的面试题都是关键知识点,不必背诵,做到理解和有条理的回答即可。
4.2 数据类型
问题1:不同数据类型占用内存大小()
数据类型 | 大小(字节) |
char | 1 |
short | 2 |
int | 4 |
long | 4 或 8(依赖于系统) |
long long | 8 |
float | 4 |
double | 8 |
long double | 8 或 16(依赖于系统) |
bool | 1 |
* | 4 或 8(依赖于系统) |
问题2:介绍一下枚举类型()
如果一个变量只有几种可能的值,可以定义为枚举类型。所谓"枚举"是指将变量的值一一列举出来,变量的值只能在列举出来的值的范围内。枚举类型的一般形式为:
enum 枚举名{ 标识符[=整型常数], 标识符[=整型常数], ... 标识符[=整型常数] } 枚举变量;
问题3:结构体与共用体的区别()
结构体:数组定义相同类型数据项的变量,但是结构体允许存储不同类型的数据项。如下面是声明一个结构体类型:
struct Books{ char title[50]; char author[50]; char subject[100]; int book_id; } book;
共用体:允许在相同的内存位置存储不同的数据类型。可以定义一个带有多成员的共用体,但是任何时候只能有一个成员带有值。共用体提供了一种使用相同的内存位置的有效方式。
union Data{ int i; float f; char str[20]; } data;
结构体和共用体最大的区别:结构体占用的内存是所有的成员各自占用的内存空间之和,共用体占用的内存则不同,等于占用内存空间最大的那个成员。
问题4:局部变量与全局变量的区别()
局部变量:在函数或一个代码块内部声明的变量,称为局部变量。它们只能被函数内部或者代码块内部的语句使用。
全局变量:在所有函数外部定义的变量,称为全局变量。全局变量的值在程序的整个生命周期内都是有效的。局部变量和全局变量的名称可以相同,但是在函数内,局部变量的值会覆盖全局变量的值。
局部变量与全局变量区别:当局部变量被定义时,系统不会对其初始化,必须自行对其初始化。定义全局变量时,系统会自动初始化为下列值:
数据类型 | 初始化默认值 |
int | 0 |
char | '\0' |
float | 0 |
double | 0 |
pointer | NULL |
问题5:原码、反码、补码()
数值在计算机的存储里,最左边的一位代表符号位,0代表正数,1代表负数。
原码:为二进制的数,如:10 原码为0000 1010。
反码:正数的反码与原码相同:如:10 原码为0000 1010,反码为0000 1010,负数为原码0变1,1变0,(符号位不变):如:-10 原码为1000 1010,反码为1111 0101。
补码:正数的补码与原码相同:如:10 原码为0000 1010,补码为0000 1010,负数的补码为反码加1:如:-10 反码为1111 0101,补码为1111 0110。
问题6:声明与定义的区别()
声明与定义C语言中的对象必须有且只有一个定义,但它可以有多个extern声明。两个术语含义如下:
定义 | 只能出现在一个地方 | 确定对象的类型并分配内存,用于创建新的对象。例如:int my_array[100]; |
声明 | 可以多次出现 | 描述对象的类型,用于指代其他地方定义的对象,由于并未在声明中为数组分配内存,所以并不需要提供关于数组长度的信息。例如:extern int my_.array; |
问题7:不同数据类型之间的赋值规则?()
整数与整数之间(char, short, int, long) | 长度相等:内存中的数据不变,只是按不同的编码格式来解析。 长赋值给短:截取低位,然后按短整数的数据类型解析。 短赋值给长:如果都是无符号数,短整数高位补0;如果都是有符号数,短整数高位补符号数;如果一个有符号一个无符号,那么先将短整数进行位数扩展,过程中保持数据不变,然后按照长整数的数据类型解析数据。 |
整数与浮点数之间 | 浮点数转整数:截取整数部分。 整数转浮点数:小数部分为0,整数部分与整数相等。 |
loat与double之间 | double转float会丢失精度。 float转double不会丢失精度。 |
4.3 关键字
问题8:介绍static关键字应用场景()
局部变量的静态存储:当static用于函数内部的局部变量时,它会改变该变量的存储期,使其从自动存储期变为静态存储期。这意味着变量在程序的整个运行期间都存在,而不是在函数调用结束时销毁。此外,静态局部变量只在第一次初始化时赋值,之后的函数调用中保持其值不变。
void function() { static int count = 0; // 静态局部变量,只初始化一次 count++; printf("Count: %d\n", count); }
全局变量的文件作用域:在文件作用域(即全局作用域)中使用static关键字声明变量,该变量只在定义它的文件内部可见,这有助于避免不同文件之间的命名冲突。
静态函数:在函数声明前使用static关键字,使该函数仅在定义它的文件内部可见。
static void staticFunction() { printf("This is a static function.\n"); }
问题9:const关键字作用()
const关键字用于声明一个变量为常量,即一旦初始化后,其值就不能被修改。下面是关键字const的使用示例,作用如下:
int const a; // a是一个整形常量 const int *a; // a是一个指向整型常量的指针变量 int * const a; // a是一个指向整型变量的指针常量 int const * const a = &b; // a是一个指向整型常量的指针常量 char *strcpy(char *strDest, const char *strSrc); // 参数在函数内部不会被修改 const int strcmp(char *source, char *dest); // 函数的返回值不能被修改
问题10:extern关键字作用()
extern关键字用于声明一个变量或函数是在另一个文件中定义的,用于声明全局变量或函数的全局可见性。其主要用途包括:
引用全局变量:在多个文件中共享全局变量时,通常在一个文件中定义变量(不加extern),而在其他需要使用该变量的文件中声明它(使用extern)。
// globals.h #ifndef GLOBALS_H #define GLOBALS_H extern int globalVar; // 声明全局变量,实际定义在另一个文件中 #endif
// globals.c #include "globals.h" int globalVar = 42; // 定义全局变量 // main.c #include <stdio.h> #include "globals.h" int main() { printf("Global variable: %d\n", globalVar); return 0; }
声明外部函数:类似地,当函数在另一个文件中定义时,可以使用extern在需要调用该函数的文件中声明它。不过,对于函数来说,extern是隐含的,即使不显式声明extern,编译器也会假定函数是在其他地方定义的,除非它同时被声明和定义在同一个文件中。
问题11:volatile关键字作用()
volatile关键字用于告诉编译器某个变量的值可能会在程序的控制之外被改变。这通常发生在以下几种情况中:
访问硬件寄存器:在嵌入式编程中,经常需要直接读写硬件寄存器,这些寄存器的值可能会由硬件或其他中断服务程序改变。
多线程编程:在多线程环境中,一个线程可能会修改另一个线程正在使用的变量。
与外部系统交互:程序通过某种接口与外部设备或系统交互时,这些外部系统可能会改变某些变量的值。使用volatile可以防止这些变量被改变,从而确保每次访问变量时都能获取其最新的值。
问题12:auto关键字作用()
用来定义自动局部变量,自动局部变量在进入声明该变量的语句块时被建立,退出语句块时被注销,仅在语句块内部使用。其实普通局部变量就是自动局部变量,只是省略了auto这一关键字。
问题13:register关键字作用()
作用:编译器会将register修饰的变量尽可能地放在CPU的寄存器中,以加快其存取速度,一般用于频繁使用的变量。
注意:register变量可能不存放在内存中,所以不能用&来获取该变量的地址;只有局部变量和形参可以作为register变量;寄存器数量有限,不能定义过多register变量。
问题14:typedef与#define的区别()
typedef用于为已存在的类型定义一个新的名字,它是一个编译时的指令,由编译器处理,typedef定义的类型别名在编译时会进行类型检查。#define是预处理指令,用于定义宏,它由预处理器处理,而不是编译器,#define只进行简单的文本替换,不进行类型检查。通过下面的示例理解两者的区别:
1)可以用其他类型说明符对#define类型名进行扩展,但对typedef所定义的类型名却不能这样做。如下所示:
#define peach int unsigned peach i; /*没问题*/ typedef int banana unsigned banana i: /*错误!非法*/
2)在连续几个变量的声明中,用typedef定义的类型能够保证声明中所有的变量为为同一种类型,而用#define定义的类型则无法保证。如下所示:
#define int_ptr int * int_ptr chalk,cheese;
经过宏扩展,第二行变为:
int * chalk, cheese;
这使得chalk和cheese成为不同的类型, chalk是一个指向int的指针,而cheese则是一个int。下面的代码中:
typedef char * char_ptr; char_ptr Bentley,Rolls_Royce;
Bentley和Rols_Royce的类型依然相同。虽然前面的类型名变了,但它们的类型相同,都是指向char的指针。
4.4 函数
问题15:main函数()
int main(int argc, char* argv)
第一个参数, int 型的 argc ,为整型,用来统计程序运行时发送给 main 函数的命令行参数的个数。
第二个参数, char* 型的 argv[] ,为字符串数组,用来存放指向字符串的指针元素,每一个指针元素指向一个字符串参数。各成员含义如下:
- argv[0] 指向程序运行的全路径名
- argv[1] 指向在DOS命令行中执行程序名后的第一个字符串
- argv[2] 指向执行程序名后的第二个字符串
- argv[argc-1] 指向执行程序名后的最后一个字符串
- argv[argc] 为 NULL
问题16:函数传参方式()
传参类型 | 描述 |
传值传参 | 函数接收的是实际参数的副本,修改函数内的形式参数对实际参数没有影响。 |
指针传参 | 函数接收的是参数的地址,该地址用于访问调用中要用到的实际参数。这意味着,修改形式参数会影响实际参数。 |
问题17:函数调用的过程和中断区别()
函数调用:是程序执行流程中的一个基本操作,它允许程序中的调用者请求程序的被调用的函数执行特定的任务。函数调用通常涉及到参数的传递、局部变量的创建、以及返回值的处理。这个过程是程序正常执行流程的一部分,不涉及硬件中断。
中断:则是计算机硬件和操作系统用来处理异步事件的一种机制。当外部设备需要与CPU通信,或者操作系统需要处理定时任务时,它们会发送一个中断信号给CPU。CPU接收到中断信号后,会暂停当前的程序执行,保存当前的执行状态,然后跳转到一个特定的中断处理程序去处理这个事件。处理完毕后,CPU会恢复之前保存的状态,继续执行被中断的程序。中断处理通常涉及到硬件层面的操作,是操作系统层面的事件响应机制。
问题18:sizeof和strlen的区别()
- sizeof是一个操作符,strlen是库函数。
- sizeof计算的是数据类型占内存的大小,而strlen计算的是字符串实际的长度。
- sizeof的参数可以是数据的类型,也可以是变量,而strlen只能以结尾为‘\0‘的字符串作参数。
- 编译器在编译时就计算出了sizeof的结果。而strlen函数必须在运行时才能计算出来。
问题19:内联函数的优缺点和适用场景是什么?()
优点:内联函数与宏定义一样会在原地展开,省去了函数调用开销,同时又能做类型检查。
缺点:它会使程序的代码量增大,消耗更多内存空间。
适用场景:函数体内没有循环(执行时间短)且代码简短(占用内存空间小)。
4.5 指针
问题20:数组指针与指针数组()
数组指针:数组指针是一个指向数组的指针,它通常用于指向一个多维数组的某一行或一维数组。
int (*arrayPtr)[10]; // 指向一个包含10个整数的数组的指针 int arr[5][10]; // 一个5行10列的二维数组 int (*arrayPtr)[10] = &arr[0]; // 指向二维数组第一行的指针 int i, j; for(i = 0; i < 5; i++) { for(j = 0; j < 10; j++) { arrayPtr[i][j] = i * j; // 通过数组指针访问和赋值 } }
指针数组:指针数组是一个数组,其元素都是指针,通常用于存储指针,这些指针可以指向不同的数据类型或对象。
int *ptrArray[10]; // 一个包含10个整数指针的数组 int a[10]; // 一个整数数组 int *ptrArray[10]; // 指针数组 int i; for(i = 0; i < 10; i++) { ptrArray[i] = &a[i]; // 将指针数组的每个元素指向整数数组的相应元素 } for(i = 0; i < 10; i++) { *ptrArray[i] = i * i; // 通过指针数组访问和赋值 }
问题21:函数指针与指针函数()
函数指针:函数指针是一种指针,它指向一个函数。函数指针通常用于回调函数、API接口、事件处理等。
int (*funcPtr)(int, int); // 指向一个接受两个int参数并返回int的函数的指针 int add(int a, int b) { return a + b; } int main() { int (*funcPtr)(int, int) = add; // 函数指针指向add函数 int result = funcPtr(3, 4); // 调用函数指针,相当于调用add函数 return 0; }
指针函数:指针函数是一个函数,它返回一个指针。指针函数通常用于动态内存分配、获取字符串的指针等场景。
int* func(); // 返回一个指向int的指针的函数 int* getArray() { static int arr[10]; // 返回静态数组的指针 return arr; } int main() { int* arrPtr = getArray(); // 调用指针函数 arrPtr[0] = 5; // 通过返回的指针访问和赋值 return 0; }
问题22:指针与结构体()
由于数组和结构体在内存中都是连续存储的,这种转换是合法的,但从数组指针到结构体指针只有在确保结构体大小与数组大小相匹配时才是安全的。例如:tregister *p = (tregister *)initArr; 将数组 initArr 的地址强制转换为 tregister 类型的指针。
在编程中,我们经常会遇到需要通过一个内存地址来访问和控制硬件的情况。例如代码#define GPIO1 ((GPIO_Type *) GPIO_Base)。这里,GPIO_Base是一个代表硬件基地址的变量,将其强制转换为 GPIO_Type ,就可以实现数组里的值一一赋给结构体的成员变量了。而GPIO1就是结构体的首地址。
问题23:野指针()
野指针是指不指向任何有效内存块的指针。野指针的存在通常是由于指针所指向的内存已经被释放、删除或者回收,但是指针本身未被正确地更新或置空。野指针产生有如下原因:
(1)指针变量的值未被初始化: 声明一个指针的时候,没有显示的对其进行初始化,那么该指针所指向的地址空间是不确定的。
#include <stdio.h> int main() { int *ptr; // 指针未初始化 printf("%d\n", *ptr); // 未定义行为,因为ptr指向的地址不确定 return 0; }
(2)指针所指向的地址空间已经被free或delete:在堆上malloc或者new出来的地址空间,如果已经free或delete,那么此时堆上的内存已经被释放,但是指向该内存的指针如果没有人为的修改过,那么指针还会继续指向这段堆上已经被释放的内存,这时还通过该指针去访问堆上的内存,就会造成不可预知的结果,给程序带来隐患。
#include <stdio.h> #include <stdlib.h> int main() { int *ptr = malloc(sizeof(int)); // 动态分配内存 if (ptr != NULL) { *ptr = 10; // 存储一个值 free(ptr); // 释放内存 } printf("%d\n", *ptr); // 未定义行为,因为ptr指向的内存已经被释放 return 0; }
(3)指针操作超越了作用域:指针指向的内存区域不在作用域内,但程序仍然尝试通过这个指针来访问或修改内存。
void func() { int localVar = 20; int *ptr = &localVar; } int main() { func(); // localVar 在func的作用域内 int *ptr = NULL; ptr = &localVar; // 错误:localVar的作用域在func内,而ptr在func外 printf("%d\n", *ptr); // 未定义行为,因为ptr指向的localVar已经不再有效 return 0; }
解决办法:
(1)初始化置NULL
(2)申请内存后判空:malloc申请内存后需要判空,而在现行C++标准中,如C++11,使用new申请内存后不用判空,因为发生错误将抛出异常。
(3)指针释放后置NULL
问题24:指针占用空间大小()
在C或C++等语言中,指针可以指向不同类型的数据,例如 int、float、char 等,但指针本身的大小与它指向的数据类型无关。例如,int*、float* 和 char* 在相同平台上通常占用相同的空间。
- 32位系统:在32位系统中,指针通常占用4字节(32位)的空间。
- 64位系统:在64位系统中,指针通常占用8字节(64位)的空间。
问题25:指针常量与常量指针()
指针常量:是指指针变量的值不能被改变,即你不能让这个指针指向另一个地址。但是,指针常量指向的值是可以被修改的。
int value = 10; int* const ptrConst = &value;
在这个例子中,ptrConst 是一个指针常量,它指向 value 的地址,并且不能再指向其他地址,但可以通过 ptrConst 修改 value 的值。
常量指针:是指指针指向的值是常量,即你不能通过这个指针修改它指向的值。但是,指针本身的值是可以改变的,即可以指向其他地址。
int value = 10; const int* ptrToConst = &value;
问题26:数组首元素地址和数组地址的异同?()
异:数组首元素地址和数组地址是两个不同的概念。例如int a[10],a的值是数组首元素地址,所以a+1就是第二个元素的地址,int类型占用4个字节,所以两者相差4。而&a是数组地址,所以&a+1就是向后移动(10*4)个单位,所以两者相差40。
同:数组首元素地址和数组地址的值是相等的。
4.6 内存
问题27:内存布局()
从低地址到高地址,一个程序由代码段、数据段、BSS段、堆栈段组成。
代码段:存储程序的执行代码,这部分内存是只读的,防止程序运行时被修改。
未初始化数据区(BSS):存储未初始化的全局变量和静态变量。在程序启动时,这部分内存通常被自动初始化为零。
已初始化数据段(Data):存放程序中已初始化的全局变量和静态变量的一块内存区域。
常量区(Rodata):存储常量值,如字符串字面量。
堆区(Heap):用于动态内存分配,程序在运行时可以通过malloc、calloc、realloc等函数动态申请和释放内存。
栈区(Stack):用于存储局部变量、函数参数和返回地址。栈是后进先出(LIFO)的数据结构,函数返回时会自动释放其占用的栈空间。
问题28:static修饰的变量在内存中位置()
static修饰的变量,无论是全局变量还是局部变量,都在程序的数据区分配内存。
问题29:内存泄露()
内存泄漏是指程序中已分配的内存空间在使用完毕后未被正确释放,导致随着时间的推移,可用内存逐渐减少,最终可能耗尽系统内存。
问题30:内存碎片()
内部碎片:是指分配给进程的内存块中未被使用的部分,这通常是因为分配单元的大小和进程所需内存大小不完全匹配造成的。内存碎片通常分为内部碎片和外部碎片:
内部碎片:由于采用固定大小的内存分区,当一个进程不能完全使用分给它的固定内存区域时就产生了内部碎片,通常内部碎片难以完全避免。
外部碎片:由于某些未分配的连续内存区域太小,以至于不能满足任意进程的内存分配请求,从而不能被进程利用的内存区域。再比如堆内存的频繁申请释放,也容易产生外部碎片。
问题31:内存池()
内存池主要用于优化程序中动态内存分配和释放的效率,减少内存碎片,提高程序运行速度。
内存池预先从操作系统申请一大块连续内存空间,并将其管理起来,当程序需要分配内存时,不再直接向操作系统请求,而是从内存池中快速分配一小块事先准备好的内存单元。当不再需要这些内存时,也不是直接归还给操作系统,而是归还给内存池,由内存池统一管理,适时或在程序结束时再归还给操作系统。
问题32:大端和小端()
大端:一个多字节整数,数字的高位部分存放在内存的低地址单元。低地址存高字节。
小端:一个多字节整数,数字的低位部分存放在内存的低地址单元。低地址存低字节。
大小端各自的优点是什么?
- 大端优点:符号位在低地址的第一个字节中,便于快速判数据的正负和大小。
- 小端优点:CPU做数值运算的时候是依次从内存的低位到高位取数据进行运算,这样运行效率更高。强制转换数据不需要调整字节内容,因为1、2、4字节数据的存储方式一样。
问题33:结构体内存对齐()
结构体内存对齐的原因:
- 满足硬件平台的访问要求:满足硬件平台的访问要求,某些硬件平台要求数据只能在对齐的内存地址上访问,否则会导致异常。
- 提高程序执行效率:内存对齐可以减少处理器访问内存的次数,访问未对齐的内存,处理器需要作两次内存访问,而对齐的内存访问仅需要一次访问。
内存对齐的规则:
- 结构体的第一个成员对齐到和结构体变量起始位置偏移量为0的地址处。
- 其他成员变量要对齐到对齐数的整数倍的地址处,结构体总大小为最大对齐数的整数倍。
- 如果嵌套了结构体的情况,嵌套的结构体的成员对齐到自己成员中最大对齐数的整数倍处,结构体的整体大小就是所有最大对齐数的整数倍。
嵌入式软件面试宝典包含简历制作、笔试准备、面试八股文、企业真题等。