C/C++面试必备知识

C/C++
210
0
0
2024-01-06

介绍

很多人学过C语言,但在面对嵌入式软件开发相关面试的时候依然不知所措,因为C/C++纯软件的常用开发技巧有些嵌入式并不常用,而嵌入式开发中使用到的C/C++知识与技巧有些也非常特别。本文就来介绍嵌入式开发中C/C++面试必备知识。

必备知识

include “filename.h”’和include <filename.h>有什么区别?

“filename.h”是从本项目里搜索filename.h,<filename.h> 是从标准库里搜索filename.h文件

静态/非静态、全局/局部 相关知识

问题:“静态全局变量”和“非静态全局变量”有什么区别?“静态局部变量”和“非静态局部变量”有什么区别?“静态函数”和“非静态函数”有什么区别? 静态全局变量只在本文件中定义,其他文件不能引用. 局部变量所在函数每次调用的时候都会被重新分配存储空间,函数结束后,就会回收该存储空间。静态局部变量不会,始终保持当前值。

calloc 和 malloc 有什么区别?

calloc在动态分配完内存后,将内存空间置为零。malloc不初始化,里边数据是随机的脏数据。

static

静态全局变量:在全局变量前,加上关键字static,该变量就被定义成为一个静态全局变量。静态变量在应用层面上主要是限定作用域。 静态全局变量有以下特点:

  1. 该变量在全局数据区分配内存
  2. 未经初始化的静态全局变量会被程序自动初始化为0(在函数体内声明的自动变量的值是随机的,除非它被显式初始化,而在函数体外被声明的自动变量也会被初始化为0)
  3. 静态全局变量在声明它的整个文件都是可见的,而在文件之外是不可见的 静态变量都在全局数据区分配内存,包括后面将要提到的静态局部变量。对于一个完整的程序,在内存中的分布情况:

代码区

low address

全局数据区堆区栈区

high address

一般程序把新产生的动态数据存放在堆区,函数内部的自动变量存放在栈区。自动变量一般会随着函数的退出而释放空间,静态数据(即使是函数内部的静态局部变量)也存放在全局数据区。全局数据区的数据并不会因为函数的退出而释放空间。 定义全局变量就可以实现变量在文件中的共享,但定义静态全局变量还有以下好处:

  1. 静态全局变量不能被其它文件所用
  2. 其它文件中可以定义相同名字的变量,不会发生冲突
  • static在函数中的用法 当函数中定义一个static变量,除了第一次调用这个函数会定义这个变量以外,其他情况下,均不会重新定义了。下面举个例子,对比静态变量和常规变量在函数调用中的区别。
void staticFun(void)
{
   static  uint8_t  data = 0;
   data++;
   printf("static function data = %d\r\n",data);
}
void NostaticFun(void)
{
   uint8_t  data = 0;
   data++;
   printf("no static function data = %d\r\n",data);
}
int main()
{

staticFun();
staticFun();
staticFun();

NostaticFun();
NostaticFun();
NostaticFun();


return 0;

}

执行此程序,主函数会先调用三次staticFun();函数,再调用三次NostaticFun();函数。最后的输出结果为:

1
2
3
1
1
1

因为每次NostaticFun中的data 都会被重新定义,而staticFun中的data不会重复定义。

const

修饰变量

用来修饰不可赋值的变量,如果一个变量在声明初始化之后不希望被修改,可以声明为const; const修饰的变量应该进行初始化; const修饰的变量有可能改变,部分编译器可用scanf修改; const常用来修饰函数的形参,保证该参数在函数内部不会被修改。

修饰指针

  1. const修饰指针——常量指针( const int *p = &a ),指针的指向可以修改,但是指针指向的值不可以修改。
  2. const修饰常量——指针常量( int * const p = &a ),指针的指向不可以修改,但是指针指向的值可以修改。
  3. const即修饰指针,又修饰常量(const int * const p = &a ),指针的指向不可以修改,指针指向的值也不可以修改.

extren

extern表明变量或者函数是定义在其他其他文件中的。用来修饰外部变量(全局),表示该变量在其他文件中定义。首先讲一下声明与定义, 声明不等于定义,声明只是指出了变量的名字,并没有为其分配存储空间;定义指出变量名字同时为变量分配存储空间,定义包含了声明。extern是用来声明全局变量的。注意:在程序中一个变量可以声明多次,但只能定义一次。

volatile

volatile提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,告诉编译器对该变量不做优化,都会直接从变量内存地址中读取数据,从而可以提供对特殊地址的稳定访问。如果没有volatile关键字,则编译器可能优化读取和存储,可能暂时使用寄存器中的值,如果这个变量由别的程序更新了的话,将出现不一致的现象。(简洁的说就是:volatile关键词影响编译器编译的结果,用volatile声明的变量表示该变量随时可能发生变化,与该变量有关的运算,不要进行编译优化,以免出错)。

c语言内存分配方式

  1. 从静态存储区域分配:由编译器自动分配和释放,在程序编译的时候就已经分配好内存,这块内存在程序的整个运行期间都存在,直到整个程序运行结束时才被释放,如全局变量与static变量。
  2. 在栈上分配 同样由编译器自动分配和释放,在函数执行时,函数内部的局部变量都可以在栈上创建,函数执行结束时,这些存储单元将被自动释放。 (需要注意的是,栈内存分配运算内置于处理器的指令集中,它的运行效率一般很高,但是分配的内存容量有限。)
  3. 从堆上分配 也称为动态分配内存,由程序员手动完成申请和释放。程序在运行的时,由程序员使用内存分配函数(如malloc函数)来申请内存,使用完之后再由程序员自己负责使用内存释放函数(如free函数)来释放内存。 (需要注意的是,如果在堆上分配了内存空间,就必须及时释放它,否则将会导致运行的程序出现内存泄漏等错误)

变量的作用域及生命周期

1.全局变量

从静态存储区域分配,其作用域是全局作用域,也就是整个程序的生命周期内都可以使用。如果程序是由多个源文件构成的,那么全局变量只要在一个文件中定义,就可以在其他所有的文件中使用,但必须在其他文件中通过使用extern关键字来声明该全局变量。

2.全局静态变量

从静态存储区域分配,其生命周期也是与整个程序同在的,从程序开始到结束一直起作用。与全局变量不同的是,全局静态变量作用域只在定义它的一个源文件内,其他源文件不能使用。

3.局部变量

从栈上分配,其作用域只是在局部函数内,在定义该变量的函数内,只要出了该函数,该局部变量就不再起作用,也即该变量的生命周期和该函数同在。

4.局部静态变量

从静态存储区域分配,其在第一次初始化后就一直存在直到程序结束。该变量的特点是其作用域只在定义它的函数内可见,出了该函数就不可见了。

内存对齐(结构体内存大小规则)

基础知识

在 C/C++ 中,结构体/类是一种复合数据类型,其构成元素既可以是基本数据类型(如int、long、float等)的变量,也可以是一些复合数据类型(如数组、结构、联合等)的数据单元。编译器为每个成员按其自然边界(alignment)分配空间。各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个结构的地址相同。

如果一个变量的内存地址正好位于它长度的整数倍,他就被称做自然对齐。如果在 32 位的机器下,一个int类型的地址为0x00000004,那么它就是自然对齐的。同理,short 类型的地址为0x00000002,那么它就是自然对齐的。char 类型就比较 “随意” 了,因为它本身长度就是 1 个字节。自然对其的前提下:

char   偏移量为sizeof(char)   即 1 的倍数 
short  偏移量为sizeof(short)  即 2 的倍数 
int    偏移量为sizeof(int)    即 4 的倍数 
float  偏移量为sizeof(float)  即 4 的倍数 
double 偏移量为sizeof(double) 即 8 的倍数

结构体的总大小为最大对齐数(每个成员变量都有一个对齐数)的整数倍

实例解析

  1. 在设置结构体或类时,不考虑内存对齐问题,会浪费一些空间,例如实验一:
struct asd1{
    char a;
    int b;
    short c;
};//12字节
 
struct asd2{
    char a;
    short b;
    int c;
};//8字节

上面两个结构体拥有相同的数据成员 char、short 和 int,但由于各个成员按照它们被声明的顺序在内存中顺序存储,所以不同的声明顺序导致了结构体所占空间的不同。具体如下图:

2. 看到上面的第二张图,有的人可能会有疑问,为什么 short 不是紧挨着 char 呢?其实这个原因在上面已经给出了答案——自然对齐。为此,我们可以创建结构体验证自然对齐的规则。实验很简单,在原本 short 类型变量前后添加 char 类型,看结果是怎样的。实验二:

struct asd3{
    char a;
    char b;
    short c;
    int d;
};//8字节
 
struct asd4{
    char a;
    short b;
    char c
    int d;
};//12字节

3. 当数据成员中有 double 和 long 时,情况又会有一点变化。还是以上面的结构体 asd1 和 asd2 为基础,都添加 double 型数据成员。来看看结果是什么,实验三:

struct asd1{
	char a;
	int b;
	short c;
	double d;
};//24个字节
 
struct asd2{
	char a;
	short b;
	int c;
	double d;
};//16个字节

只添加了一个 double,但 struct asd1 的大小从 12 变到了 24。而 struct asd2 的大小从 8 变到了 16。不需要迷惑,因为这和 double 的自然对其有关(需要注意)。原本的 asd1 占 12 个字节大小,但是 double 对齐需要是 8 的倍数,所以在 short 后面又填充了 4 个字节。此时,asd1 的占 16 个字节,再加上 double 的 8 个字节就成了 24 个字节。而 asd2 没有这个问题,它原本占 8 个字节。因为正好能对齐,所以添加 double 后占 16 个字节。具体情况如下图所示:

4. 指定对齐值 在缺省情况下,C 编译器为每一个变量或是数据单元按其自然对界条件分配空间。一般地,可以通过下面的方法来改变缺省的对界条件: 使用伪指令 #pragma pack (n),C 编译器将按照 n 个字节对齐。 使用伪指令 #pragma pack (),取消自定义字节对齐方式。

实验四:

#pragma pack(4)
struct asd5{
    char a;
    int b;
    short c;
    float d;
    char e;
};//20
#pragma pack()
 
#pragma pack(1)
struct asd6{
    char a;
    int b;
    short c;
    float d;
    char e;
};//12
#pragma pack()

使用 #pragma pack (value) 指令将结构体按相应的值进行对齐。两个结构体包含同样的成员,但是却相差 8 个字节。难道我们只需要通过简单的指令就能完成内存对齐的工作吗?其实不是的。上面的对齐结果如下:

以 32 位机器为例,CPU 取的字长是 32 位。所以上面的对齐结果会这样带来的问题是:访问未对齐的内存,处理器需要作两次内存访问。如果我要获取 int 和 float 的数据,处理器需要访问两次内存,一次获取 “前一部分” 的值,一次获取 “后一部分” 的值。这样做虽然减少了空间,但是增加访问时间的消耗。其实最理想的对齐结果应该是:

ps.使用 #pragma pack(4) 可以让前面的实验三中的 asd1 少占用 4 字节。

对其原则

对齐原则

  1. 数据类型自身的对齐值:对于 char 型数据,其自身对齐值为1,对于 short 型为2,对于 int,float,double 类型,其自身对齐值为 4,单位字节。
  2. 结构体或者类的自身对齐值:其成员中自身对齐值最大的那个值。
  3. 指定对齐值:#pragma pack (value) 时的指定对齐值 value。
  4. 数据成员、结构体和类的有效对齐值:自身对齐值和指定对齐值中小的那个值。

共用体

根据实际情况,有时需要把几种类型不同的数据,如一个整型变量、一个字符变量、一个实型变量存放在起始地址相同的同一段存储单元种。这三个变量在内存种所占的字节数不同,但都从同一个地址开始存放。这种几个类型不同的变量共同占用同一段内存的结构,称为“共用体”类型结构。共用体,也称为联合体。

union 共用体名
{
    成员表列
};

共用体变量的所有成员共享同一段存储空间,这个存储空间等于共用体变量种占据内存字节数最多的成员的字节数。

‘##’连接符

##用来连接前后两个参数,把它们变成一个字符串。 例子如下:

#define main(x,y) x##y
int xy=1;
cout < < main(x,y) < < endl; 

将会使编译器把 cout < < main(x,y) < < endl; 解释为 cout < < xy < < endl; 理所当然,将会在标准输出处显示’1’。 从此可以看出,x##y的效果就是将x和y连在一起了。 而#define main(x,y) x##y 则相当于把main(x,y)等价于x##y

宏定义与条件变量

#if…#else…#endif

我们在调试程序时,经常会遇到某段功能的实现,写了两种版本的程序,但调试时又不想来回切换。,这时候我们可以使用条件变量。 比如:想测试__set_FAULTMASK(1);和__disable_fault_irq();的区别,就可以使用如下方式,只需要更改#if后面是1还是0就可以选择是使用哪段程序。

   #if 1
    __set_FAULTMASK(1);
        NVIC_SystemReset();   
  #else
   __disable_irq();
   delay_ms(1000);
    __disable_fault_irq();
    NVIC_SystemReset();
    #endif

string.h 库函数(以memcpy函数为例)

C库函数是我们开发过程中必不可少的,其中面试中突出考察的大多为string.h中的库函数。 memcpy函数的用法,memcpy (void* _Dst,void const* _Src,size_t _Size) memcpy函数是将后面地址的内容一个数据一个数据放在前面的地址,注意,是先放低位。 _Size是字节数,也就是说如果是32位数组,两个数组值就应该是_Size就应该是4。 例子:

char a[8]={0x12,0x34,0x56,0x78,0x90,0x14,0x52,0x46 };
short b=0;
memcpy(&b,a+1,2);
printf("b=%x", b);
此段代码的作用是把0x340x56拼接起来送到b,输出的最终结果是:0x5634

void 指针

void 指针可以指向任意类型的数据,就是说可以用任意类型的指针对 void 指针对 void 指针赋值。如果要将 void 指针 p 赋给其他类型的指针,则需要强制类型转换,就本例而言:a=(int *)p。在内存的分配中我们可以见到 void 指针使用:内存分配函数 malloc 函数返回的指针就是 void * 型,用户在使用这个指针的时候,要进行强制类型转换,也就是显式说明该指针指向的内存中是存放的什么类型的数据 (int )malloc(1024) 表示强制规定 malloc 返回的 void 指针指向的内存中存放的是一个个的 int 型数据。

int *Q;
void *P;
P=Q;

我们可以看到void指针类型是可以指向int *指针类型的。

指针大小

在64位系统中,不管什么样的基类型,系统指针给指针变量分配的内存空间都是8字节,在C语言中,指针变量的“基类型”仅用来指定该指针变量可以指向的变量类型,并没有其他意思。不管基类型是什么类型的指针变量,他仍然是指针变量,所以仍然占用 8 字节。

%*c

%*c表示忽略一个字符

strstr()

此函数在嵌入式的日常开发中使用频繁,功能为:在字符串 A中查找第一次出现字符串B的位置。 C 标准库 - <string.h> C 库函数 char *strstr(const char *haystack, const char *needle) 在字符串 haystack 中查找第一次出现字符串 needle 的位置,不包含终止符 ‘\0’。

atoi()

C 标准库 - <stdlib.h 描述 C 库函数 int atoi(const char *str) 把参数 str 所指向的字符串转换为一个整数(类型为 int 型)。

rename()

描述 C 库函数 int rename(const char *old_filename, const char *new_filename) 把 old_filename 所指向的文件名改为 new_filename。 参数 old_filename – 这是 C 字符串,包含了要被重命名/移动的文件名称。 new_filename – 这是 C 字符串,包含了文件的新名称。

面试例题

叙述题

  1. 请按时间复杂度对以下排序方法进行分类 a) 快速排序;b) 冒泡排序;c) 归并排序;d) 堆排序;e) 插入排序

O(n^2):b、e O(n*logn):a、c、d

  1. #define sum(a, b) a + b 好吗?如果不好,如何纠正? 可以改为#define sum(a, b) ((a)+(b))
  2. 给出变量“a”的以下定义 a) An integer b) A pointer to an integer c) A pointer to a pointer to an integer d) An array of 10 integers e) An array of 10 pointers to integer f) A pointer to an array of 10 integers g) A pointer to a function that takes an integer as an argument and returns an integer h) An array of ten pointers to functions that take an integer argument and return an integer a) int a; b) int *a; c) int **a; d) int a[10]; e) int *a[10]; f) int (*a)[10]; g) int (*a)(int); h) int (*a[10])(int);
  3. 以下函数的输出是什么?为什么? void foo(void) { unsigned int a = 6; int b = -20; (a+b > 6) ? puts(“> 6”) : puts(“<= 6”); } 应该是“>6”,因为表达式中存在有符号类型和无符号类型,所有的数都自动转换为无符号类型。因此-20的代表的不再是负数,而是一个很大的数字,所以结果也变为很大的数。
  4. 读C程序,下面运行测试功能时的每个结果是什么。 (1)
void GetMemory(char *p)
{
p = (char *)malloc(100);
}
void Test(void)
{
char *str = NULL;
GetMemory(str);
strcpy(str, "hello world");
printf(str);
}

解答;会崩溃:因为GetMemory 并不能传递动态内存,Test 函数中的 str 一直都是 NULL。strcpy(str, “hello world”);将使程序崩溃;动态申请内存有可能失败,所以应该增加判断; 执行GetMemory之后,p得到新分配的空间地址,str依然为NULL; 没有对内存进行回收free(),局部变量存在栈区,malloc()在堆区; 局部变量在函数执行完毕之后回收栈空间; 调用时传递进去的参数是str的一份备份。既然是备份,那么无论函数内部如何操作,都只是操作它的备份,与原本的str值没有关系。所以Test里的str经过GetMemory之后仍然是原本定义时的NULL,使用strcpy字符串到NULL时自然就会发生段错误。另外,如果第7行不初始化为NULL,编译时不会报错,但是,它就成了野指针野指针野指针啊,操作野指针是很危险的; 一级指针传递的函数内部更改不影响实参一级指针的值,所以此处要么使用二级指针,要么使用引用。

(2)

char *GetMemory(void)
{
char p[] = "hello world";
return p;
}
void Test(void)
{
char *str = NULL;
str = GetMemory();
printf(str);
}

解答:乱码:char p[ ]是函数内部局部自动变量,存在于“栈内存(stack)”中,当GetMemory函数运行结束之后p的内存释放了,将它返回给调用者去操作就自然出错; 有些初学者可能会好奇,明明以前开发时,经常会用到函数返回值,没出现过乱码?造成这种不同的原因,其实是因为返回类型的不一样,函数返回的是返回值副本,如果你返回的是一个值那无所谓,这个值本身就是你需要的,但如果像上述程序一样返回的是一个指针地址,因为此地址的指向的栈内存已经释放了,那你读取的就是脏数据了。也就是乱码。 (3)

Void GetMemory2(char **p, int num)
{
*p = (char *)malloc(num);
}
void Test(void)
{
char *str = NULL;
GetMemory(&str, 100);
strcpy(str, "hello");
printf(str);
}

解答:正常输出hello (4)

void Test(void)
{
char *str = (char *) malloc(100);
strcpy(str, “hello”);
free(str);
if(str != NULL)
{
strcpy(str, “world”);
printf(str);
}
}

解答:str的动态内存已经被释放

代码题

  1. 实现一个函数“revstr()”,不允许使用任何C标准库函数,定义函数原型,返回输入字符串的反转。
char *revstr(char *str, size_t len)
{
    char    *start = str;
    char    *end = str + len - 1;
    char    ch;
    if (str != NULL)
    {
       while (start < end)
       {
        ch = start;
    end=ch;
    start=end;
         *start++;
    *end--;     
       }
   }
    return str;
}
  1. 字符串小写转大写
//将字符串中的小写字母转换为大写
//str:要转换的字符串
//len:字符串长度
void litterTobig(u8 *str,u8 len)
{
  u8 i;
  for(i=0;i<len;i++)
  {
    if((96<str[i])&&(str[i]<123))  //小写字母
    str[i]=str[i]-32;        //转换为大写
  }
}
  1. 字符串大写转小写
int8_t* CapToLow(int8_t* str)
{
    int i;
    for (i = 0; i < sizeof(str); i++)
    {
        if ((64 < str[i]) && (str[i] < 91)) //大写
            str[i] = str[i] + 32;           //小写
    }
    return str;
}
  1. 将某几位清0,并保留其他位的状态 使用”&= ~"进行清零。 我们以下面的程序为例:
uint32_t ultmp;
ultmp=0x12345678;
ultmp&= ~(0XFFFF0000);
printf("ultmp=0x%d\n",ultmp);

上述程序的作用就是将ultmp的高16位 置0,低16位保留. 最后输出的结果是0x00005678。

后续

这里知识帮大家总结一下,有什么错误的地方欢迎指正。

编写不易,感谢支持。