嵌入式八股文总结(C语言篇)

本文章已经生成可运行项目,

         C语言是嵌入式开发的基础,在此记录一些面试常问的八股文,希望可以和大家一起进步。(持续更新中……)

目录

1. const关键字的作用

2. const关键字的用法

3. 关键字static的作用

4. 关键字volatile的作用

5. 关于volatile的面试题

6. typedef和#define的区别

7. 找出下面中断处理程序(ISR)的错误

8. C语言编译的四个步骤

9. 自己实现MyStrcpy函数和MyStrlen()函数

10. 无符号整型与有符号整型进行运算

11. C语言内存分配方式有几种

12. 堆和栈的区别

13. 栈的作用

14. 说说局部变量和全局变量的作用域和生命周期,是否可以同名

15. 为什么static变量只初始化一次

16. sizeof()和strlen()的区别

17. 不使用sizeof(),求变量占用的字节数

18. 短路求值

19. 结构体和联合体的区别

20. 什么是内存泄漏

21. 内存泄露的原因

22. 如何判断内存泄漏

23. 什么是大端模式和小端模式

24. 如何判断当前系统的大小端模式

25. 指针数组和数组指针的区别

26. 指针函数和函数指针的区别

27. 数组和指针的区别

28. 什么是野指针,如何避免野指针

29. 头文件中是否可以定义静态变量

30. C语言中函数是如何调用的

31. 调用函数时的入栈顺序

32. 程序分为几个段

33. 数组下标可以是负数么

34. strcpy与memcpy的区别

35. 如何使用两个栈实现一个队列

36. 什么是位域,有什么作用

37. 什么是内联函数,有什么用

38. const与#define的区别

39. 防止头文件被多次包含   

40. float  x 与“零值”比较的if语句

41. 给绝对地址 0x100000 赋值,并跳转执行

42. 数据在内存中的存储形式是什么

43. 一道转义字符的笔试题

44.什么是寄存器变量,有什么用

45.什么是标识符、关键字和预定义标识符?区别是什么

46.可以在结构体中包含指向自己的指针么

47.定义字符串的方式有几种?区别是什么

48.什么是空指针,空指针指向哪里

49.结构体的大小(内存对齐)

50.一道关于逗号表达式的笔试题

51.可变参列表


1. const关键字的作用

1)修饰常变量

        const意味着只读,用来修饰常变量,如果一个变量(局部变量或全局变量)被const修饰,那么它的值就不能再被改变。

2) 增强程序的健壮性    

        #define也可以用来定义常量(标识符常量),但#define只是对值进行简单的替换,并不会进行类型检查;而const可以保护被修饰的东西,防止意外修改,增强程序的健壮性。

3) 节省空间,提高编译效率  

        编译器通常不为普通const常量分配存储空间,而是将它们保存在符号表中,这使得它成为一个编译期间的常量,没有了存储与读内存的操作,使得它的效率也很高。

const int N = 100;               /* 定义一个常量N */
N = 50;                         /* 错误,不能修改const修饰的值 */
const int n;                     /* 错误,常量在定义的时候必须初始化 */

const char GetString()           /* 定义一个函数 */
char* str = GetString()          /* 错误,如果const修饰的函数返回值类型为指针,则指针指向的内容不能被修改,且只能被赋值给被const修饰的指针(我用VS2022使用char *str = GetString()没发现报错) */
const char* str = GetString()    /* 正确 */

#define A 9                      /* 定义一个常量 */
const int B = 10;                /* 此时并未给B分配内存 */ 
int i = A;                      /* 预处理期间进行宏替换,分配内存 */                      
int i = B;                       /* 为A分配内存,之后不再分配 */
int j = A;                       /* 预处理期间再次进行宏替换,分配内存 */
int j = B;                       /* 不再分配内存 */

2. const关键字的用法

1) 修饰局部变量

        比如 const int n=5 和 int const n=5 这两种写法一样,表示变量n的值不能被改变了;也可以修饰常量静态字符串,如const char* str="fdsafdsa",后续若对str进行了误修改,编译期间就会发现,进而避免程序的逻辑错误。

2)  修饰常量指针、指针常量、指向常量的常指针

        修饰常量指针:不能使用这个指针改变变量的值(可以通过其他指针修改此变量的值),但指针可以指向其他地址。

/* 修饰常量指针 */
int main(void)
{
    int a = 100;
    int b = 50;
    const int* n = &a;    /* 或int const* n = &a; */
    int *p = &a;

    //*n = 10;              /* 错误,不可以使用指针n修改其指向的值 */
    *p = 10;                /* 正确,可以用其他指针修改变量a的值 */
    n = &b;                 /* 正确,可以修改指针指向其他地址 */
    printf("%d\n",a);       /* 输出结果为10 */  

    return 0
}


void StringCopy(char *strDestination,  const char *strSource);    /* 不能使用*strSource改变指针strSource指向的内容 */

        修饰指针常量:指针指向的地址不能变,但地址中保存的数据是可以变的。

/* 修饰指针常量 */
int main(void)
{
    int a = 100;
    int b = 50;
    int* const c = &a;

    //c = &b;               /* 错误,不能修改指针指向其他地址 */
    *c = 10;                /* 正确,可以修改指针指向的值 */

    return 0;
}


void swap ( int * const p1 , int * const p2 )    /* 指针p1和p2指向的地址都不能修改,但是可以使用*p1和*p2对值进行修改 */

        修饰指向常量的常指针:指针指向的地址不能改变,也不能通过这个指针改变变量的值,但是依然可以通过其他指针改变这个变量的值。

/* 修饰指向常量的常指针 */
int main(void)
{
    int m = 100;
    int n = 50;
    const int* const p = &m;
    int* q = &m;                 /* q指针也指向变量m */

    //p = &n;                    /* 错误,指针指向的地址不能修改 */
    //*p = 10;                   /* 错误,不能使用该指针修改变量的值 */
    *q = 60;                     /* 正确,可以使用其他指针修改变量值 */

    return 0;
}

3) 修饰全局变量

        全局变量的作用域是整个工程,我们应该尽量避免使用全局变量,因为一旦有一个函数改变了全局变量的值,它也会影响到其他引用这个变量的函数,导致出了bug后很难发现,如果一定要用全局变量,我们应该尽量使用const进行修饰,以防止不必要的人为修改,使用的方法与局部变量是相同的。

3. 关键字static的作用

1)修饰局部变量:static修饰局部变量时,会改变其存储位置,将其从栈区转移到静态区,所以出了作用域不销毁。静态变量的生命周期和程序相同,在main 函数之前初始化,在程序退出时销毁。

2)修饰全局变量:static修饰全局变量时,会将其外部链接属性改变为内部链接属性,此变量只能在当前源文件内使用,其他源文件中无法使用。

3)修饰函数:static修饰函数时,会将其外部链接属性改变为内部链接属性,此函数只能在当前源文件内使用,其他源文件中无法使用。

4. 关键字volatile的作用

       volatile是一个类型修饰符,提醒编译器它后面所定义的变量随时都有可能改变,因此编译后的程序每次需要存储或读取这个变量的时候,编译器不会对该变量进行优化,而是直接从变量内存地址中读取数据,从而可以提供对特殊地址的稳定访问。在以下几种情况中会用到volatile:

1) 并行设备的硬件寄存器(如状态寄存器 

        存储器映射的硬件寄存器通常要加volatile,因为每次对他读写都可能有不同的意义。比如对一个设备进行初始化,此设备的某个寄存器为0xff800000,如下所示:

int* output = (unsigned int*)0xff800000       /* 定义一个IO端口 */

int init(void)
{
    int i;
    for(i = 0; i < 10; i++)
        *output = i;
}

        如果不使用volatile修饰寄存器地址,那么编译器优化后的程序只对该寄存器进行了一次配置操作,对此地址只做了一次读操作,如下所示:

int init(void)
{
    *output= 9;
}

2) 中断服务程序中修改的供其他程序检测的变量

        中断服务函数中对某变量进行修改时,若主程序中没有修改该变量,则编译器优化后可能只从内存中读取到寄存器中一次,之后每次只从寄存器中读取变量的副本,导致中断服务程序的操作短路,所以需要使用volatile对变量进行修饰,告诉编译器不对其优化。

3) 多任务环境下个任务间共享标志,应该加volatile      

        多个任务都可以修改共享标志的值,编译器优化后会把变量读取到寄存器中,之后再取变量值时都是从寄存器读取,当内存变量或寄存器变量因别的线程而改变了值时,该寄存器的值不会改变,若不使用volatile修饰,会导致应用程序读取的值与实际的变量值不一致。

5. 关于volatile的面试题

1) 一个参数既可以是const还可以是volatile吗?       

        可以,比如只读的状态寄存器。它是volatile因为它可能被意想不到地改变。它是const因为程序不应该试图去修改它。

2) 一个指针可以是volatile吗?

        可以,当一个中服务子程序修改一个指向buffer的指针时。

3) 下面的函数有什么错误?

int square(volatile int* ptr)
{
    return *ptr * *ptr;    
}

        程序的目的是返回指针ptr指向的值的平方,但ptr指向的是volatile型的变量,所以编译器将产生类似于如下效果:

int square(volatile int* ptr)
{
    int a, b;

    a = *ptr;
    b = *ptr;
    return a * b;
}

        由于*ptr的值可能会被意想不到的改变,所以a和b的值可能不同,导致结果与预期不一致,正确的代码如下所示:

long square(volatile int* ptr)
{
    int a;

    a = *ptr;
    return a * a;
}

6. typedef和#define的区别

1)  原理不同

        #define是预处理指令,预处理时只进行简单而机械的字符串替换,不做正确性检查;typedef是关键字,在编译处理时会做正确性检查,它在自己的作用域内给一个已经存在的类型一个别名,但不能在函数定义里面使用typedef。用typedef定义数组、指针、结构等类型会带来很大的方便,不仅使程序书写简单,也使意义明确,增强可读性。

2)功能不同

       typedef用来定义类型的别名,起到类型易于记忆的功能。另一个功能是定义与机器无关的类型,如定义目标机器上最高精度的浮点类型REAL;而#define不只可以为类型取别名,还可以定义常量、变量、编译开关等。

typedef long double REAL;   /* 在支持long double的机器上 */
typedef double REAL;        /* 在不支持long double的机器上 */
typedef float REAL;         /* 在不支持double的机器上 */

3)作用域不同 

        #define没有作用域的限制,只要是之前预定义过的宏,在以后的程序中都可以使用,而typedef有自己的作用域。

4)对指针的操作不同

#define INTPTR1 int*
typedef int* INTPTR2;
INTPTR1 p1, p2;            /* 声明一个指针变量p1和一个整型变量p2 */
INTPTR2 p3, p4;            /* 声明两个指针变量p3和p4 */

5)被const修饰时含义不同 

#define INTPTR1 int*
typedef int* INTPTR2;

int main(void)
{
    int a = 1;    
    int b = 2;
    int c = 3;
    const INTPTR1 p1 = &a;     /* 定义一个常量指针p1,不能通过p1改变其指向的内容,但p1可以指向其他位置 */  
    const INTPTR2 P2 = &b;     /* 定义一个指针常量p2,即p2不能再指向其他位置 */
    INTPTR2 const p3 = &c;     /* 定义一个指针常量p3 */

    return 0;
}

7. 找出下面中断处理程序(ISR)的错误

__interrupt double compute_area(double radius)
{
    double area PI * radius * radius;
    printf("Area = %f\n", area);
    return area;
}

1)  ISR不能有返回值,也不能传递参数;

2)ISR应该快进快出,所以不推荐进行浮点运算,并且在许多编译器中浮点一般都是不可重入的,不允许在ISR中进行浮点运算。

3) printf()是不可重入函数,不能在ISR中使用。printf()函数采用的缓冲机制,这个缓冲区是共享的,相当于一个 全局变量,第一层中断来时,它向缓冲里面写入一些部分内容,恰好这时来了个优先级更高的中断,它同样调用了printf()函数, 也向缓冲里面写入一些内容,这样缓冲区的内容就错乱了。

8. C语言编译的四个步骤

1)预处理:展开宏和包含头文件,生成.i文件;

2)编译:编译器对将预处理后的源代码进行词法分析、语法分析、语义分析和优化,最后翻译成汇编码,生成.s文件;

3)汇编:汇编器将汇编码转换为机器码,生成.o文件;

4)链接:链接器将多个目标和库文件等组合在一起,并解决外部引用,最终生成可执行文件。

9. 自己实现MyStrcpy函数和MyStrlen()函数

 MyStrcpy()函数

char* MyStrcpy(char* strDest, const char *strSrc)    /* 3分,const修饰带拷贝字符串,提高代码健壮性 */
{
    assert((strDest != NULL) && (strSrc != NULL));  /* 2分,检查指针的有效性,提高代码的健壮性 */
    char* address = strDest;                         /* 1分 */
    while((*strDest++ = *strSrc++) != 0);            /* 3分 */
    return address;                                  /* 1分,支持链式表达式,提高函数可用性 */
}

        该函数将address返回,是为了实现链式表达式,比如: int length = strlen( mystrcpy(str, “Hello World”) )。 

MyStrlen()函数

int MyStrlen(const char* strDest)
{
    assert(NULL != strDest);
    return ('\0' != *strDest) ? (1 + MyStrlen(strDest + 1)) : 0;
}

         上面的实现方式使用了函数递归,需要注意的是,当传入的字符串太长时会导致递归深度较大,甚至可能导致栈溢出,所以不到万不得已尽量不要使用递归。使用递归时要保证停止条件是正确的,避免死循环。

10. 无符号整型与有符号整型进行运算

void foo(void)
{
    unsigned int a = 6;
    int b = -20;
    (a + b > 6) ? puts(">6") : puts("<6");
}

        答案输出为:“>6”,当表达式中存在有符号类型和无符号类型时,所有的操作数都自动转换为无符号类型。因此-20变成了一个非常大的正整数,所以该表达式计算出的结果大于6。嵌入式系统中频繁用到无符号整形的数据,务必注意。

11. C语言内存分配方式有几种

        C语言有三种内存分配方式,分别为:  静态存储区分配栈上分配堆上分配

        静态存储分配:内存在编译程序时就已经分配好了,这块内存在程序的整个运行期间都存在,比如全局变量、静态变量等;

        栈上分配:在执行函数时,函数内局部变量的存储单元都可以在栈上创建,栈空间充足的话则正常分配,栈空间不足则提示栈溢出错误;函数执行结束时这些存储单元自动被释放;

        从堆上分配:也叫做动态内存分配,程序运行时使用malloc()或new()函数申请的内存就是从堆上分配的,程序员可以自定义申请内存的大小,并且要自己负责在何时调用free()或者delete()函数释放内存,要注意内存泄露的问题。

12. 堆和栈的区别

1) 申请方式不同:栈由系统分配和释放,存放函数的参数值、局部变量的值等;而堆需要程序员自己申请和释放,堆中的具体内容也由程序员自己安排;

2) 申请大小的限制:栈空间有限,栈是向下生长的一块连续的内存区域,栈顶的地址和栈的容量已经设定好了,若申请的空间大于栈的剩余空间,则会报栈溢出错误。堆是向上生长的不连续的内存区域,系统中使用链表来管理空闲的内存地址,堆的大小受限于计算机系统中有效的的虚拟内存(链表的遍历方向是由低地址向高地址,当系统收到程序的申请时,会遍历链表,寻找第一个空间大于所申请空间的堆节点,然后将节点从内存空闲节点链表中删除,并将该节点的空间分配给程序)。可见,堆的空间比较灵活,通常比栈的空间要大。

3) 申请效率:栈由系统分配,效率较高,但程序员无法控制;相比之下堆的申请效率就比较低,而且容易产生内存碎片,但是用起来比较方便。

13. 栈的作用

        栈的作用有:函数调用、中断处理、程序调试、操作系统中的任务调度,介绍如下:

1) 函数调用:当一个函数被调用时,程序会把该函数的参数、返回地址、局部变量等信息存入栈中,当函数执行完成后,栈会依次弹出这些信息,并恢复调用函数前的状态(栈先进后出);

2) 中断处理:系统发生硬中断或软中断时,操作系统会自动保存当前进程的执行现场(包括CPU状态、程序计数器、寄存器等信息)到栈中,然后将CPU控制权转交给中断处理函数,中断处理函数执行完成后,栈会弹出之前保存的执行现场信息,恢复到中断前的状态;

3) 程序调试:调试程序时,程序员可根据栈中的内容(如变量的值、函数调用栈、异常处理等)来查看当前程序的执行状态;

4) 线程调度:栈是多线程编程的基石,每个线程都必须有一个自己的栈,用来存放本线程运行时各个函数的临时变量,和维护线程中函数调用和函数返回时的函数调用关系以及保存函数运行现场。

14. 说说局部变量和全局变量的作用域和生命周期,是否可以同名

作用域:局部变量的作用域是其所在的局部范围;全局变量的作用域是整个工程。如果想在当前源文件中使用工程内其他文件定义的变量,需在当前源文件中使用extern关键字进行声明。

生命周期:局部变量的生命周期是进入作用域时生命周期开始,出作用域时生命周期结束;全局变量的生命周期是整个程序的生命周期。

 局部变量和全局变量可以同名(不建议),局部变量会屏蔽全局变量。

15. 为什么static变量只初始化一次

        静态局部变量、静态全局变量和全局变量都保存在静态存储区中,所以这些变量都具有“记忆”功能,初始化一次后不会被销毁,生命周期和程序相同,所以只需要初始化一次。

16. sizeof()和strlen()的区别

1) 数据类型不同sizeof是操作符(sizeof既是关键字,也是操作符),而strlen是库函数

2)  操作的参数类型不同sizeof 只关注占⽤内存空间的⼤⼩,不关注内存中存放什么数据,strlen()只能用char*类型变量做参数,计算以“ \0 ”结尾的字符串长度,而sizeof后可接变量名、数据类型、函数等,sizeof后接变量名时可以不加括号,示例如下:

int fun(void)
{
    return 10;
}

int main(void)
{
    int num = 100;

    printf("%d\n", sizeof(num));			/* 结果为:4 */
    printf("%d\n", sizeof(int));			/* 结果为:4 */
    printf("%d\n", sizeof num);				/* 结果为:4 */
    printf("%d\n", sizeof(fun()));          /* 结果为:4,结果取决于函数返回值的类型*/

    printf("%d\n", sizeof("abcdef"));       /* 结果为:7,包含字符串最后的'/0' */
    printf("%d\n", strlen("abcdef"));		/* 结果为:6,不包含字符串结尾的'/0' */

    printf("%d\n", sizeof("\0"));           /* 结果为:2,字符串"\0"还有一个字符串结束符'\0' */
    printf("%d\n",strlen("\0"));            /* 结果为:0,strlen用来计算以"\0"作为结束符的字符串长度 */
    printf("%d\n", sizeof('\0'));           /* 结果为:4,'\0'的ASCLL代码值为0,等价于数字0 */
    
    return 0;
}

3)  执行时间不同:sizeof()通常是在编译程序时进行计算的,而strlen()函数是在程序运行时进行计算的,示例如下,

int main(void)
{
    char str[20] = "0123456789";
    char* str2 = "abcdef";

    printf("strlen(str) = %d\n", strlen(str));          /* 结果为字符串的长度:       10 */
    printf("sizeof(str) = %d\n", sizeof(str));          /* 结果为数组的大小:         20 */
    printf("strlen(str2) = %d\n", strlen(str2));        /* 结果为字符串的长度:        6 */
    printf("sizeof(str2) = %d\n", sizeof(str2));        /* 结果为指针的大小(64为系统):8 */

    return 0;
}

17. 不使用sizeof(),求变量占用的字节数

        如下所示:(char*)&value是value的地址,(char*)(&value + 1)是value的下一个地址,二者之差就是value类型数据的大小。

#define MySizeof(value) (char*)(&value + 1) - (char*)&value

int main(void)
{
	int i;
	double f;
	double* q;
	
	printf("%d\n", MySizeof(i));	/* 结果为:4 */
	printf("%d\n", MySizeof(f));	/* 结果为:8 */
	printf("%d\n", MySizeof(q));	/* 结果为:8 */
	return 0;
}

18. 短路求值

#include <stdio.h>

int main(void)
{
    int i = 6;
    int j = 1;

    if((i > 0) || ((j++) > 0))
    printf("%d\n", j);        /* 结果为1,i>0条件成立后直接往下执行,右边的((j++) > 0)被短路,&&同理 */
    return 0;  
}

19. 结构体和联合体的区别

结构体:结构体中各成员拥有自己的内存,各自互不干涉,结构体的总长度为所有结构体成员长度之和,遵循内存对齐原则;

联合体:联合体中各成员共用一块内存,共用一个内存首地址,并且同时只有一个成员可以得到这块内存的使用权,联合体的大小至少能容纳最大的成员变量,并且为所有成员变量类型大小的整数倍。

区别:内存利用方式不同,结构体中各成员拥有自己的内存,互不干涉;联合体中各成员共用一块内存;对结构体的成员赋值时,各变量互不影响,对联合体的变量赋值时,其他成员的值也将被改写。   

20. 什么是内存泄漏

        内存泄漏是指程序中已经分配的内存未能成功释放,导致可用内存逐渐减少的现象。在程序运行过程中,如果反复发生内存泄漏,最终可能会导致系统可用内存耗尽,从而影响程序的性能或导致程序崩溃。  

21. 内存泄露的原因

1) 未释放动态分配的内存:如使用malloccallocrealloc 和 new 等函数分配内存后,未使用对应的 free 或 delete 来释放内存;

2) 资源占用:除了内存外,程序可能申请其他系统资源(如文件句柄、数据库连接等),未正确释放这些资源也会导致类似内存泄漏的问题;

3) 数据结构错误:链表、树等数据结构若未正确处理其元素的删除操作,可能导致部分节点成为不可达的,从而造成内存泄漏。

22. 如何判断内存泄漏

1) 检查程序:通过检查程序来寻找可能未释放内存的地方,特别关注那些有动态内存分配的函数或模块。所以我们要养成良好的编程习惯,定期检查代码,使用内存分配函数后,一定要记得使用相应的函数释放掉。如果程序运行后再反过头检查内存泄漏是很麻烦的,难度很大。也可以在代码中添加日志输出,特别是在分配和释放资源的地方,可以帮助追踪内存的使用和释放。

2) 使用第三方工具在Linux下,可使用Valgrind等编程工具帮助检查内存泄漏;在Windows下,Visual Studio提供了内置的内粗泄露检测工具。除此之外,还有一些常见的工具插件,如ccmalloc、Dmalloc、Leaky等。

3) 链表管理:将分配的内存指针以链表的形式进行管理,使用完毕之后从链表中将其删除,程序运行结束时可检查该链表,判断是否将申请的内存全部释放完成。 

一个内存泄漏的例子

void my_malloc(char *p, int size)
{
    p = (char*)malloc(size * sizeof(char));
}

int main(void)
{
    char *str = NULL;
    my_malloc(str, 10);
    strcpy(str, "abcdefg");    /* 发生错误 */
    free(str);                 /* 无效,内存泄漏 */

    reyurn 0;
}

        上面程序中虽然使用free()进行释放内存,但此条语句无效,仍出现了内存泄漏。这是因为函数mv_malloc()中分配的内存的起始地址赋值给了函数内的局部变量p,函数返回后p变量自动销毁,而指针str的值仍为NULL,所以free()并没有对函数my_malloc()中的内存进行释放,出现内存泄漏。此外,释放内存后,应该将指针变量str设置为NULL,避免野指针。

改进方法1:将函数mv_malloc()分配的内存的首地址返回

char* my_malloc(char *p, int size)
{
    p = (char*)malloc(size * sizeof(char));
    return p;
}

int main(void)
{
    char *str = NULL;
    str = my_malloc(str, 10);
    strcpy(str, "abcdefg");    
    free(str);  
    str = NULL;   /* 释放内存后建议立即设置为NULL,避免野指针 */            

    return 0;
}

改进方法2:使用二级指针

void my_malloc(char **p, int size)
{
    *p = (char*)malloc(size * sizeof(char));
}

int main(void)
{
    char *str = NULL;
    my_malloc(&str, 10);
    strcpy(str, "abcdefg");    
    free(str); 
    str = NULL;   /* 释放内存后建议立即设置为NULL,避免野指针 */             

    return 0;
}

23. 什么是大端模式和小端模式

        计算机系统中以字节(8bit)为存储单元,也就是每个地址单元对应一个字节,C语言中的int、short等类型数据大小大于一字节,所以就要考虑到多字节类型数据在内存中的字节排序问题,也就是大端模式和小端模式,介绍如下:

小端模式:数据的位存放在地址中,数据的位存放在地址中。如存储0x12 34 56 78,小端存储时情况如下:

                                                                        低地址 --------------------> 高地址
                                                                        0x78  |  0x56  |  0x34  |  0x12

大端模式:数据的位存放在地址中,数据的位存放在地址中如存储0x12 34 56 78,大端存储时情况如下:

                                                                        低地址 --------------------> 高地址
                                                                        0x12  |  0x34  |  0x56  |  0x78

24. 如何判断当前系统的大小端模式

        无论是大端存储还是小端存储,读取数据时都是从低地址开始读取的,所以下面示例程序中 &i i 的低位地址。

示例1

void judge_bigend_littleend()
{
    int i = 12;                        /* 16进制为:0x 00 00 00 0C */
    int* p = &i;                       /* p中存储i的地址,也就是i的低地址 */
    char c = 0;
    c = *((char*)p);                   /* c等于i低地址处保存的一字节数据 */

    if (c == 0XC)                      /* i的低位数据为0XC */
        printf("系统为小端模式\n");
    else
        printf("系统为大端模式\n");
}

 示例2

void judge_bigend_littleend()
{
    union judge{
        int i;
        char ch;
    }judge;

    judge.i = 1;

    if (judge.ch == 1)                      /* ch和i的起始地址相同 */
        printf("系统为小端模式\n");
    else
        printf("系统为大端模式\n");
}

25. 指针数组和数组指针的区别

指针数组它是一个数组,数组元素为指针类型。比如int *p[3]声明了一个指针数组([]的优先级高,先与p结合,所以p是一个数组),p+1时指向下一个数组元素。

int main(void)
{
    int i;
    int *p[4];                     /* 定义一个指针数组,该数组有4个元素,每个元素为int类型 */
    int a[4] = {1,2,3,4};
    
    p[0] = &a[0];
    p[1] = &a[1];
    p[2] = &a[2];
    p[3] = &a[3];
    for(i = 0; i < 4; i++)
        printf("%d",*p[i]);        /* 最终的输出结果为1234 */
    printf("\n");

    return 0;
}

数组指针它是一个指针,指向一个数组。比如 int (*p)[4]声明了一个数组指针(()的优先级高,先与p结合,所以p是一个指针),p+1时要跨越4个整型数据的长度。

int main(void)
{
   int b[12] = {1,2,3,4,5,6,7,8,9,10,11,12};
    int (*p)[4];                /* 定义一个数组指针,指向的数组大小为4个int类型数据的大小 */
    p = (int (*)[4])b;                      
    printf("%d\n", **(++p));    /* ++p跨越的长度为4个int类型数据的大小,结果为第5个元素的值 */
 
    return 0;
}

        将上面程序中,将数组b的地址赋值给p,因为p是一个指向包含有4个int类型数据的数组的指针,所以可以理解为p是指向二维数组的指针,该二维数组为:{1,2,3,4},{5,6,7,8},{9,10,11,12} ,p指向的是 {1,2,3,4}的地址,所以++p指向的是{5,6,7,8}的地址,*(++p)就是二维数组元素{5,6,7,8},**(++p) 则是该数组的第一个元素,所以结果为5。    

26. 指针函数和函数指针的区别

指针函数它是一个函数,函数的返回值是一个地址。比如 int *pfun(int, int) 声明了一个指针函数。

#include <stdio.h>

int* fun(int* x)              /* 传入指针  */ 
{
	int* tmp = x;	          /* 指针tmp指向x */
    return tmp;               /* 返回tmp指向的地址 */
}

int main()
{
    int b = 2;      
    int* p = &b;              /* p指向b的地址 */
    printf("%d",*fun(p));     /* 输出p指向的地址的值,输出结果为2,很简单,不解释了 */
    return 0; 
}

函数指针它是一个指针,指向函数的地址。程序中定义一个函数后,在编译程序时会为这个函数分配一段内存空间,内存空间的首地址就是函数的地址,函数名表示的就是函数的地址,函数指针指向的就是这个地址。比如 int(*p)(int, int) 声明了一个函数指针。

# include <stdio.h>

int Max(int,int);                         /* 函数声明 */

int main(void)
{
    int(*p)(int,int);                     /* 定义一个函数指针 */
    int a, b,c;
    p = Max;                               /* 把函数Max赋给指针变量p,使p指向Max函数 */
    printf("please enter a and b:");
    scanf("%d%d",&a,&b);
    c=(*p)(a,b);                          /* 通过函数指针调用Max函数 */
    printf("a=%d\nb =%d\nmax=%d\n",a,b,c);

    return 0; 
}

int Max(int x,int y)                      /* 定义Max函数 */
{
    int z;
    if(x> y)
        z = x;
    else
        z = y;
    return z;
}     


补充:函数指针数组:
int (*a[4])(int)                           /* 定义一个数组函数指针数组,数组中的元素为参数和返回值都为int类型的函数 */

        上面的例子中使用 int(*p)(int,int);的形式声明了一个函数指针变量,若想定义一个或多个函数指针,可使用下面这种方式。

int (*fp1)(int, int), (*fp2)(int, int);

        上面这种方式比较难以理解,可简化如下。 

typedef int (*funcptr)(int, int);
funcptr fp1, fp2;

27. 数组和指针的区别

1)数据保存:指针保存的是数据的地址,内存访问偏移量为4个字节(32位处理器),无论指向的数据类型如何,都以地址类型进行解析。数组保存的是数据,数组名表示第一个数组元素的地址,内存偏移量取决于数据类型。对数组名取地址(&数组名)时表示整个数组,此时内存偏移量为整个数组的大小(sizeof(数组名))。

int main(void)
{
    char str[5] = {'a','b','c','d','e'};
    char *p = (char *)(&str + 1);

    printf("%c,%c",*(str+1),*(p-1));    /* 输出结果为b,e */

    return 0;
}

2) 数据访问:指针对数据是间接访问,需要使用解引用符号 *,数组对数据是直接访问,可通过数组名[下标]的形式来访问。

3) 使用环境:指针多用于动态数据结构(如链表等)和动态内存开辟;数组多用于存储固定个数且数据类型统一的数据结构(如线性表等)和隐式分配。

28. 什么是野指针,如何避免野指针

        野指针是指向不可用内存的指针,当指针被free或delete释放掉时,此时只是释放掉了指针指向的内存,如果没将指针设置为NULL,则会产生野指针;当指针访问越界时也会产生野指针。当函数内定义了一个数组,通过return返回指向该数组的指针时,会产生野指针,因为函数返回后数组的内存就被释放了。避免野指针的方法如下:

1) 创建指针时,对其设置一个初始值或NULL

2) 指针用完后释放内存,并记得将其赋值为NULL

3)避免指针访问越界

4)避免返回函数中变量的指针

野指针示例1

struct student
{
	char *name;
	int score;
}stu, *pstu;

int main()
{
   strcpy(stu.name,"XiaoMing");
   stu.score = 99;
   return 0;
}

        上述代码中,stu是一个全局变量,name成员会被编译器自动初始化为NULL,但在main函数中并没有将name指针指向合法的地址,使其称为一个野指针。

野指针示例2

struct student
{
	char *name;
	int score;
}stu, *pstu;

int main()
{
    pstu = (struct student*)malloc(sizeof(struct student));
    strcpy(pstu->name,"XiaoMing");
    pstu->score = 99;
    free(pstu);
    pstu = NULL;
    return 0;
}

        上面的程序对示例1进行了改进,使用malloc分配了一块内存,但该块内存是为结构体分配的,并没有为结构体中的指针变量name分配内存,所以此时name仍为野指针。

示例修改

struct student {
    char *name;
    int score;
} stu, *pstu;

int main(void) 
{
    stu.name = (char*)malloc(strlen("XiaoMing") + 1);    /* 分配足够内存并初始化指针 */ 
    if (stu.name == NULL) {
        return 1;                                        /* 处理内存分配失败 */
    }
    strcpy(stu.name, "XiaoMing");  
    stu.score = 99;
    free(stu.name);  
    stu.name = NULL; 
    return 0;
}

        上面为name指针分配了一块内存并进行了初始化,避免了野指针。此外,为了进一步确保不会出现野指针,分配内存后最好对指针进行判断,若指针不等于NULL表明内存分配成功。使用free()释放内存后记得立即将其设置为NULL,避免野指针。还有一种修改方式就是不使用指针来存储name,将char* name修改为char name[10],避免动态内存管理,从根本上减少野指针的风险。 

29. 头文件中是否可以定义静态变量

        不可以,在头文件中定义静态变量会造成资源浪费,因为这样会导致每个包含该头文件的源文件都有一个独立的静态变量,甚至会导致程序出现错误,所以不推荐在头文件中定义任何变量,当然也包含静态变量。推荐在源文件中定义静态变量,然后在头文件中使用extern关键字进行声明。extern 关键字表示该变量在其他地方有定义,而在当前头文件中不进行实际定义,这样就只会在源文件中进行一次内存分配,使其成为全局唯一实例。

30. C语言中函数是如何调用的

        大多数CPU上的程序使用栈来实现函数调用,调用函数时,栈被用来传递函数的参数、存储返回值信息和存储局部变量等,函数调用所使用的栈叫做栈帧,每个函数调用都有自己的栈帧结构。栈帧结构由两个指针来指定,分别为:帧指针(指向起始位置)、栈指针(指向栈顶)。函数执行的过程中,栈指针esp会数据的入栈和出栈而移动,多以函数大多数时间都是基于帧指针来访问数据,示意图如下:

                                                           69afd888362f4e72b39c281b4ae42387.png

31. 调用函数时的入栈顺序

1) 第一个入栈的是主函数中调用函数处的下一条执行语句的地址,也就是函数的返回地址

2) 接着入栈的是函数的各个参数,入栈顺序为从右向左(为了适应可变参函数的参数个数不确定的情况)依次将参数入栈;

3) 然后是函数内部的局部变量(注意,static变量是不入栈的);

4) 函数执行完成后按照先进后出的顺序依次出栈。

32. 程序分为几个段

        程序通常被分为四个段,分别为:代码段、数据段、堆栈段、bss段

代码段:用于存储程序的可执行指令,通常是只读的,且位于地地址的区域;

数据段:存储程序中已经初始化的全局变量和静态变量,通常位于代码段的后面;

堆栈段:栈中保存局部变量、函数调用的参数及返回值等,堆中保存由malloc()等申请的动态内存;

bss段:保存已经定义,但还未被初始化的全局变量和静态变量。

33. 数组下标可以是负数么

        可以,数组下标只是与当前位置的偏移量,示例如下:

#include <stdio.h>

int main(void)
{
    int i;
    int a[5] = {0, 1, 2, 3, 4};
    int *p = &a[4];
    for(i = -4; i <= 0; i++)
        printf("%d %x\n", p[i], &p[i]);
    return 0;
}

/* 运行结果 */
0 bd5b5f00
1 bd5b5f04
2 bd5b5f08
3 bd5b5f0c
4 bd5b5f10

34. strcpy与memcpy的区别

        这两个都是标准C库函数,介绍如下:

strcpy:用于字符串拷贝,将源字符串中的内容复制到目标字符串中,直到遇到字符串结束符 ‘\0’,需要注意的是,目标字符串必须有足够的空间来存储被复制的内容,否则可能导致缓冲区溢出。

memcpy:用于字节级的内存拷贝,memcpy对拷贝的内容没有限制,不仅限于字符串,还可以拷贝结构体、数组等数据。需要注意的是,使用memcpy函数拷贝字符串时不会检查字符串结束符,就是根据指定的字节数来进行拷贝。

区别介绍如下

1) 复制的内容不同:strcpy只能复制字符串,而memcpy可以复制任意内容,例如字符数组、整型、结构体、类等;

2) 复制的方法不同:strcpy不需要指定长度,它遇到被复制字符的串结束符"\0"才结束,所以容易溢出。memcpy可通过第3个参数指定复制的长度,但要注意源区域和目标区域不要发生重叠,否则可能会导致数据损坏;

3) 应用场景不同:复制字符串时通常使用strcpy函数,复制其他类型数据时通常使用memcpy函数。

35. 如何使用两个栈实现一个队列

        栈先进后出,队列先进先出,所以可以使用一个栈实现入队操作,另一个栈实现出队操作。假如现在有两个栈:栈A和栈B,要对1、2、3、4、5、6这6个元素进行入队和出队操作,流程如下:

① 假如此时要将1、2、3、4这4个元素入队,可将这四个元素压入栈A中,此时栈A和栈B的内容如下:

cf219f8263964b16bb9c29f1aa051951.png

② 此时要将最先入队的1出队,此时就要现将栈A中的元素依次出栈,再压入栈B中,最后从栈B中弹出元素1,如下所示:

0a3d0fd774764de7bf2f89997b1937bc.png

③ 若此时将元素2和元素3出队,接着元素5和元素6入队,最后将所有元素出队,流程如下所示:

ec91c50c46d24915a8268d5af39c0932.png

340d081957424975b4156e902f3a65fd.png

99853c2780f84fd0a11b06382d94cddd.png

596408f608314f2c9da75904abc83713.png

        最后再梳理一下思路:使用两个栈模拟一个队列,栈A用来实现入队操作,栈B用来实现出队操作。入队时比较简单,就是将数据压入栈A中。出队时要先判断栈B是否为空:若栈B不为空,则将栈B中的元素依次出栈。若栈B为空,则检查栈A是否为空。若栈A不为空,则将栈A的元素依次出栈,并依次压入到栈B中,接着再从栈B中将元素依次出栈;若栈A为空,则提示错误信息,此时队列为空。

36. 什么是位域,有什么作用

        在存储一些信息时,有时不需要存储一个完整的字节,可能只需要使用一个或几个二进制位,比如定义一个开关变量,只有0和1两种状态,也就是只使用1个二进制位即可。这时就可以使用位域来进行处理,用来节省存储空间。位域是C语言提供的一种数据结构,也叫做位段,位域就是把一个字节中的二进制位划分为不同的区域,并指定每个区域的二进制位数和域名,在程序中就可通过域名来操作对应的区域。位域的定义方式与结构体类似,示例如下:

int main(void){
    struct bs{
        unsigned char a:1;      /* 域名为a,占用1个二进制位 */
        unsigned char b:3;
        unsigned char  :4;      /* 无名位域,不能使用,一般用来作填充或者调整成员位置 */
        unsigned char c:4;
    }bit;
    
    bit.a=1;           /* 位域赋值方式和结构体一样,注意赋值不能超过该位域的允许范围 */
    bit.b=5;
    bit.c=9;
    printf("%d\n", sizeof(struct bs));          /* 输出结果为:2 */
    printf("%d,%d,%d\n", bit.a,bit.b,bit.c);    /* 输出结果为:1,5,9 */
	return 0;
}

        位域有两个特征:位域不能跨字节存储,位域不能跨类型存储,示例如下:

/* 位域不能跨字节存储 */
int main(void)
{
    struct bs {
        unsigned char a : 3;        
        unsigned char b : 4;        
        unsigned char c : 2;       /* a和b已经占用了7个二进制位,本字节还剩1个二进制位,存不下成员c,所以成员c直接从下个字节开始存储,跳过当前字节的bit8 */
      //unsigned char d : 9;       /* 错误,位域的宽度不能大于当前数据类型的宽度,unsigned char为8位数据,所以d的位宽不能大于8 */

    return 0;
}
/* 位域不能跨类型存储 */
int main(void)
{
    struct bs {
        char a : 1;        /* 内存对齐,所以预留3个字节,即占1+3个字节 */
        int b : 1;         /* 占4个字节 */
    };

    printf("%d\n", sizeof(struct bs));    /* 输出结果为:8 */
    return 0;
}

       最后再看一个笔试题,如下所示:

int mian(void)
{
    unsigned char puc[4];
    struct tagPIM{
         unsigned char ucPim1; 
         unsigned char ucData0 : 1;
         unsigned char ucData1 : 2;
         unsigned char ucData2 : 3;
    }*pstPimData;
    
    pstPimData = (struct tagPIM*)puc;        
    memset(puc, 0, 4);                /* 将数组初始化为0 */
    
    pstPimData->ucPim1  = 2;          /* 占一个字节,所以puc[0]的值为2 */
    pstPimData->ucData0 = 3;          /* 3的二进制为0000 0011,而ucData0的位域宽度为1,所以ucData0得到值为1(二进制) */
    pstPimData->ucData1 = 4;          /* 4的二进制为0000 0100,而ucData1的位域宽度为2,所以ucData1得到值为00(二进制)  */
    pstPimData->ucData2 = 5;          /* 5的二进制为0000 0101,而ucData1的位域宽度为3,所以ucData1得到值为101(二进制)  */
    /* ucData0、ucData1、ucData2一共占7个二进制位,所以上面三条语句都是在给puc[1]进行赋值,赋值结果为0010 1001,对应的16进制就是0x29  */
    printf("%02x %02x %02x %02x\n", puc[0], puc[1], puc[2], puc[3]);    /* 输出结果为02 29 00 00 */
    return 0;
}

37. 什么是内联函数,有什么用

        在程序中,如果某些函数会被频繁的调用,会导致频繁的函数入栈出栈,造成栈空间的大量消耗,甚至导致栈空间枯竭。为了解决这个问题,引入了inline修饰符,用来修饰内联函数。inline 关键字可以提示编译器将被修饰的内联函数的代码直接复制到调用处,而不是使用正常的函数调用机制。从而减少函数调用的开销,并提高程序的执行效率。示例如下:

inline int add(int a, int b) {
    return a + b;
}

下面介绍几个内联函数的注意事项:

1) 程序中每一处内联函数的调用都会复制函数代码,所以如果函数代码量太大的话,将其修饰为内联函数会导致程序的总代码量增大,消耗更多的内存空间,所以推荐只有当函数的代码量在10行之内才将其修饰为内联函数;

2) inline只是一种请求,编译器有可能不允许这种请求,并且只在release版本起作用,在debug版本不起作用;

3)如果函数中出现while、switch等复杂的控制语句,或者该函数是递归函数时,就不适合将其修饰为内联函数。

38. const与#define的区别

1) const是一种编译器关键字,而#define是预处理器指令;

2) const在编译阶段进行处理,而#define在预处理阶段进行处理;

3) #define只是简单的字符串替换,没有类型检查,而const有对应的数据类型,编译器会进行类型检查;

4) 若有多个地方使用#define时, 会在内存中产生多个备份,而const定义的只读变量在程序运行过程中只有一份备份;

5) const常量可以进行调试的而#define不能进行调试,因为在预编译阶段就已经替换掉了。

39. 防止头文件被多次包含   

#ifndef  TEST_H
#define  TEST_H

/* 头文件内容 */

#endif

40. float  x 与“零值”比较的if语句

        计算机中的浮点数通常不能表示所有数,存储的通常是实际的的近似值,比如1.0可能被存储为0.99999。无论是float还是double类型的变量,都不能保证可以精确的存储一个小数,只是近似值,所以一定要避免将浮点变量用“==”或“!=”与数字比较,应该设法转化成“>=”或“<=”形式。

if (x == 0.0)                         /* 错误 */
if ((x>=-EPSINON) && (x<=EPSINON))    /* 正确,其中EPSINON是一个很小的值,也就是允许的误差(即精度),比如0.00001 */

41. 给绝对地址 0x100000 赋值,并跳转执行

(unsigned int*)0x100000 = 1234;    /* 给绝对地址0x100000赋值 */
*(void(*)()0x100000)();            /* 跳转到地址0x100000处执行 */       

        这里再解释下跳转的原理,其实就是将地址0x100000强制转换成函数指针类型,也就是(void(*)())0x100000,再调用该函数*((void(*)())0x100000)(),STM32进行IAP时就是使用这个方法跳转的,相关程序如下:(参考正点原子)

typedef void (*iapfun)(void);                   /* 定义一个函数类型的参数 */
iapfun jump2app;                                /* 定义一个函数指针变量 */

/**
 * @brief       跳转到应用程序段(执行APP)
 * @param       appxaddr : 应用程序的起始地址
 * @retval      无
 */
void iap_load_app(uint32_t appxaddr)
{
    if (((*(volatile  uint32_t *)appxaddr) & 0x2FFE0000) == 0x20000000)     /* 检查栈顶地址是否合法.可以放在内部SRAM共64KB(0x20000000) */
    {
        /* 用户代码区第二个字为程序开始地址(复位地址) */
        jump2app = (iapfun) * (volatile uint32_t *)(appxaddr + 4);
        
        /* 初始化APP堆栈指针(用户代码区的第一个字用于存放栈顶地址) */
        sys_msr_msp(*(volatile uint32_t *)appxaddr);
        
        /* 跳转到APP */
        jump2app();
    }
}

42. 数据在内存中的存储形式是什么

        数据在内存中以补码的形式存储。正数的原码、反码、补码相同;负数最高位表示符号位(1表示负数,0表示正数),其余位取反后加一即为该负数的绝对值,下面举个例子:

int a = 0;
int b = ~a;

printf("%d", b);    /* 输出结果为-1 */

a在内存中为:00000000/00000000/00000000/00000000

b在内存中为:11111111/11111111/11111111/11111111

printf()函数在打印时打印的是原码,b的最高位为1,表示负数,其余位取反加1得到原码:

b的原码:10000000/00000000/00000000/00000001(转换为十进制就是-1)

所以最终输出结果为-1,下面再举一个例子。

int main(void)
{
    signed char num[500];
    int i;

    for(i = 0; i < 500; i++)
    {
        num[i] = -1-i;
    }
    printf("%d",strlen(num));    /* 输出结果为255 */

    return 0;
}

       char类型数的8位二进制表示的数值范围为[-128,127],-128是该范围内的最小值,没有对应的源码和补码形式。在补码规则中,二进制数1000 0000被直接定义为-128(若按补码的规则逆推,补码1000 0000减一,得到反码0111 1111,再按位取反得到原码1000 0000,该原码的数值为128,超出了8位二进制源码的数值范围,所以直接规定补码1000 0000表示-128)。下面模拟运行:

  • num[0]等于-1,补码二进制为1111 1111
  • num[1]等于-2,补码二进制为1111 1110
  • ... ...
  • num[127]等于-128,补码二进制为1000 0000
  • num[128]等于127,补码二进制为0111 1111。num[128]不可能等于-129,-129的补码二进制为1 0111 1111,此时发生了溢出,最高位被丢弃,只保留低8位0111 1111(十进制为127)
  • num[129]等于126,补码二进制为0111 1110。同理,-130的补码二进制为1 0111 1110,只保留低8位0111 1110(十进制为126)
  • ... ... 
  • num[-255]等于0,补码二进制为0000 0000。-256的补码二进制为1 0000 0000,只保留低8位0000 0000(十进制为0)

        可见,num[0]~num[254]为非0值,num[255]等于0,‘\0’为字符串的结束标志,所以strlen(num)的值为255。

43. 一道转义字符的笔试题

int main()
{
    char arr[] = "\test\658\x03az";

    printf("%d\n",strlen(arr));		/* 输出结果为8 */

    return 0;
}

字符串"\test\128\x03az"中共包含8个字符,这里介绍下不常用的转义字符\ddd和\xddd:

\ddd:ddd表示1-3个八进制数字,最大值为:\177

\xdd:dd表示1-2个十六进制数字,最大值为:\x7f

转义字符对应ASCII编码,因此对应的十进制数值不能超过127,所以字符串中包含如下8个字符:

‘\t’、'e'、's'、't'、'\65'、'8'、'\x03a'、'z'

:‘\65’表示一个字符,等价于'\065'。‘\x03a’表示一个字符,等价于'\x3a',其中的0可以忽略,也可以加任意多个0,如'\x000003a'也表示1个字符,而'\065'中最多包含三个数字,如"\0065"中包含两个字符:‘\006’和'5'。

44.什么是寄存器变量,有什么用

        寄存器变量是关键字register修饰的变量(仅限局部变量),用于建议编译器将该变量存储在CPU寄存器中,而不是常规的内存中,以提高对变量的访问效率,进而提高程序的执行速度。常用于嵌入式系统和实时操作系统等对响应速度要求极高的场景中。然而,目前的编译器已经足够智能,能够自动优化变量的存储位置,因此通常无需使用关键字register手动指定寄存器变量。

注意

(1)寄存器数量有限,编译器不一定会把变量存到寄存器中,会根据寄存器的使用情况和变量的使用频率等决定。

(2)寄存器变量通常是占用空间小、使用频繁的基本数据类型,如int、char等,一般不将复杂的数据类型修饰为寄存器变量。

(3)由于寄存器变量可能不存在于内存中,所以不能对其取地址,如register int num; int *p = &num;是错误的。(面试题中可能会考察)

45.什么是标识符、关键字和预定义标识符?区别是什么

标识符:程序员为变量、函数、类、模块等程序元素自定义的名称,用于在程序中引用这些元素。标识符通常由字母、数字和下划线组成,区分大小写,不能以数字开头,且不能与关键字冲突。

关键字:编程语言中具有特定含义和用途的保留字,是语法的一部分,用于表达特定的操作、逻辑或结构,不能将其作为标识符使用。

预定义标识符:编程语言或标准库中预先定义好的具有特定用途的标识符,通常表示一些标准函数、常量或宏等。用户可对其重定义,但不建议,以免引起混淆,导致程序破坏。

区别:标识符是由程序员定义的,用于标识各种编程元素,关键字是编程语言自身规定的,程序员不能对其进行修改,预定义标识符是编程语言或标准库预先定义好的,用户可以对其进行修改(不建议)。

#include <stdio.h>

int main(void)
{
    int age = 20;                               /* age、p是自己定义的,是标识符 */
    int *p = NULL;                              

    p = &age;
    if(18 <= *p)                                /* if、else是关键字(补充:sizeof也是关键字) */
        printf("小明成年了,年龄是:%d",*p);
    else
        printf("小明未成年");                    /* printf、NULL是预定义标识符 */
    
    return 0;
}

46.可以在结构体中包含指向自己的指针么

        C语言的结构体中可以包含指向自己的指针,但需要定义结构体的类型名之后再进行使用,举例如下。

错误示例:

typedef struct{
    char* item;
    NODEPTR next;
}*NODEPTR;

        上面这种方式是错误的,因为声明next成员时还没有定义NODEPTR类型,修改方式如下。

正确示例:(下面列举三种常见的方式)

typedef struct node{
    char *item;
    struct node *next;
}*NODEPTR;
struct node;
typedef struct node *NODEPTE;

struct node{
    char *item;
    NODEPTE next;
};
struct node{
    char *item;
    struct node *next;
};
typedef struct node *NODEPTR;

47.定义字符串的方式有几种?区别是什么

        通常可以使用数组或指针两种方式来定义字符串,介绍如下。

char a[] = "hello world!";    /* 数组定义字符串 */
char *p = "hello world!";     /* 指针定义字符串 */

        在C语言中,支持字符串字面量拼接,下面两条语句与上面这两条是等价的。

char a[] = "hello" "world!";    /* 数组定义字符串 */
char *p = "hello" "world!";     /* 指针定义字符串 */

  区别

        数组a存储在栈或者全局数据区(取决于定义的位置),字符串"hello world!"会被从常量区复制到数组内存中,也就是说a数组是一个可修改的副本,数组的大小由初始化的字符串长度决定,包含隐式的"/0",如sizeof(a)为13。指针p存储在栈或全局数据区,但其指向的字符串"hello world!"存放在只读常量区,指针p是直接引用常量区字符串,并没有进行内存复制,所以无法修改字符串的内容。使用sizeof(p)返回的是指针变量的大小,而不是字符串的长度。此外,不能对数组名进行重新赋值,但可以对指针重新赋值,如下所示。

char a[] = "hello world!";    /* 数组定义字符串 */
char *p = "hello world!";     /* 指针定义字符串 */

a[0] = 'H';                  /* 合法,将字符串修改为“Hello world!” */ 
p[0] = 'H';                  /* 错误,指针p指向的字符串保存在常量区无法修改 */  

int size_a = sizeof(a);      /* 值为13,字符串的长度 */ 
int size_p = sizeof(p);      /* 值为4,指针的大小(32位系统) */ 

a = "happy";                 /* 错误,数组名a是一个地址常量,不能对其重新赋值 */
p = "happy";                 /* 合法,指针变量可以重新指向其他地址,p 指向新的常量区字符串 */      

        在进行函数参数传递时,二者是等价的,如下所示。

void func(char *arg);       /* 接受数组或指针 */
void func(char arg[]);      /* 与上一行等价 */ 

        需要注意的是,数组作为函数参数传递时会退化为指针,传递的并不是整个数组的副本,而是数组首元素的地址,所以在函数内sizeof(arg)的值为指针的大小,并不是数组的原始大小。

扩展

         通过上面的介绍,可知当字符串常量(比如上例中的"hello world!")出现在表达式中时,其实是该字符串的首地址,而数组名也是数组的首地址。所以我们同样可以将字符串常量看作是数组名,对其进行下标引用、间接访问以及指针运算,示例如下。

printf("%c\n", "hello world!"[4]);        /* 输出结果为字符'o' */
printf("%c\n", *"hello world!");          /* 输出结果为'h' */
printf("%c\n", *("hello world!" + 4));    /* 输出结果为'o' */
printf("%s\n", "hello world!" + 2);       /* 输出结果为"llo world!" */

        在对字符串进行操作时,就可以使用上面的技巧来简化操作。举个例子,将一个数值以16进制打印出来。

#include <stdio.h>

void value_to_ascill(unsigned int value)
{		
	if(value >= 16)
		value_to_ascill(value / 16);
	if((value % 16) < 10)
		putchar((value % 16) + '0');
	else
		putchar((value % 16) - 10 + 'A');
}

int main(void)
{
    value_to_ascill(2544);

    return 0;
}

        由于字符集中的数字字符和字母字符通常是分开存储的,所以要分两段进行判断。可将程序简化如下:定义一个字符串,根据余数从字符串中选择正确的字符。

#include <stdio.h>

void value_to_ascill(unsigned int value)
{		
	if(value >= 16)
		value_to_ascill(value / 16);
	putchar("0123456789ABCDEF"[value % 16]);
}

int main(void)
{
    value_to_ascill(2543);

    return 0;
}

48.什么是空指针,空指针指向哪里

        空指针是指被赋值为空指针常量的指针,它不指向任何有效的对象或函数。空指针的作用是标识指针的无效状态,常用于对指针变量进行初始化,或者作为函数返回值的错误标识。需要注意的是,未初始化的指针可能指向任何地方,它不是空指针,而是野指针。空指针常量通常有以下两种形式:

  • 值为0的整数表达式,如0,0L,6-6等
  • 将上述表达式强制转换为void*类型,如(void*)0

        在C标准库中,使用宏定义NULL来表示空指针常量,通常被实现为0或(void*)0,如int *p = 0等价于int *p = NULL,但NULL语义更加明确,推荐使用NULL,以提高代码的可读性。空指针的值为0,那么可以用空指针来操作0地址处的值么,这是不可以的,因为操作系统通常将0地址设置为了不可访问,用户程序直接解引用空指针会触发段错误,导致系统崩溃。

49.结构体的大小(内存对齐)

        为了提升缓存利用率和兼容硬件平台,C语言要求结构体的各成员进行内存对齐。内存对齐规则如下:

  • 成员对齐规则:每个成员的起始地址偏移量必须是该成员自身大小与编译器默认对齐数(通常为8)的较小值的整数倍。
  • 结构体整体对齐规则:结构体的总大小必须是所有成员中对齐最大值的整数倍。

示例如下

typedef struct{
    char c1;        /* c1成员占1字节 */
    short s;        /* 填充1字节,s成员占2字节 */
    char c2;        /* c2成员占1字节 */
    int i;          /* 填充3字节,i成员占4字节 */
}Struct1;

typedef struct{
    char c1;        /* c1成员占1字节 */
    char c2;        /* c2成员占1字节 */
    short s;        /* s成员占2字节 */
    int i;          /* i成员占4字节 */
}Struct2;

int main(void)
{
    int size1 = sizeof(Struct1);
    int size2 = sizeof(Struct2);

    printf("%d,%d",size1,size2);    /* 输出结果为12,8 */

    return 0;
}

        可见,两个结构体中存储的成员相同,但Struct1在进行内存对齐时进行了内存填充,使得占用的内存高于Struct2,分析如下:

  • c1成员占1字节,起始地址偏移量为0,不需要内存填充。
  • s成员占两个字节,起始地址偏移量必须为min(2,8)的整数倍,所以需要填充1字节内存,在偏移量2处开始存储。
  • c1成员占1字节,起始地址偏移量为4,不需要内存填充。
  • i成员占4字节,起始地址偏移量必须为min(4,8)的整数倍,所以需要填充3个字节,在偏移量8处开始存储。

        各成员的最大对齐数为max(1,2,1,4)=4,目前结构体的总大小为12,是4的倍数,不需要再内存填充,所以结构体Struct1的最终大小为12字节。

50.一道关于逗号表达式的笔试题

int main(void)
{
	int a[3][2] = {(0,1),(2,3),(4,5)};
	int *p;
	p = a[0];
	printf("%d",p[0]);    /* 输出结果为1,不是0 */
    return 0;
}

        部分人会认为输出结果为0,这是因为对二维数组的定义方式不熟悉,二维数组中各成员应该使用花括号而不是中括号,如int a[3][2] = {{0,1},{2,3},{4,5}},所以上面的数组成员为逗号表达式,等价于int a[3][2] = {1,3,5},所以输出结果为1。

51.可变参列表

        函数原型中通常只能设置固定个数的参数,如果不确定向函数传递的参数的个数,可使用可变参列表来处理,可变参列表使用的并不多,介绍如下。

#include <stdio.h>
#include <stdarg.h>

/* 查找最大值,num为可变参参数的个数 */
int max(int num, ...)
{
    int i = 0;
    int current = 0;
    va_list args;                    /* 定义可变参列表对象 */
    
    va_start(args,num);             /* 初始化 */
    int max = va_arg(args, int);    /* 读取可变参数中的第一个参数 */
    for(i = 0; i < num; i++)        /* 查找最大值 */
    {
        current = va_arg(args, int);
        if(current > max)
        {
            max = current;
        }
    }
    va_end(args);

    return max;                    /* 返回最大值 */
}

int main(void)
{
    printf("Max = %d\n", max(3, 20, 50, 30));    /* 输出为Max = 50 */

    return 0;
}

        可变参列表是通过头文件<stdarg.h>中宏来实现的,包括va_start、va_arg、va_end,此外还需要定义一个va_list类型的变量与这几个宏配合使用,介绍如下:

  • args是一个va_list类型的变量,用于访问参数列表中未定义的部分(可变参函数中,未定义的参数部分使用“...”来表示)
  • va_start用来对args进行初始化,它有两个参数,第一个参数是va_list变量的名字,第二个参数是“...”前最后一个有名字的参数,上例中为num
  • va_arg用来获取可变参部分的参数,va_start初始化时将va_arg设置为可变参部分的第一个参数,每次获取完成后,自动指向下一个可变参数。它有两个参数,第一个参数是va_list变量的名字,第二个的参数是参数列表中下一个参数的类型,上例中所有的可变参数都为int类型
  • 当最后一个可变参获取完成后,使用va_end清理资源,确保函数结束时释放与可变参数相关的内存

        需要注意的是,可变参数必须从头到尾按顺序逐个获取,可以获取几个参数后中途停止,但不可以开始就从可变参数列表中间开始获取。此外,可变参函数中必须至少有一个命名参数,如果一个命名参数都没有,就无法使用va_start了。在实际开发中,推荐始终使用固定参数设计可变参数函数,尽量考虑使用数组或者结构体来封装参数,以提高代码的可读性和安全性。

本文章已经生成可运行项目
### C语言面试常见问题与答案 C语言作为一门基础且强大的编程语言,在面试中常被用来考察候选人的基础知识、代码实现能力以及对底层原理的理解。以下是常见的C语言面试问题及其详细解答: --- #### 1. 条件编译的作用和常用指令 条件编译是一种根据特定条件来决定是否编译某段代码的技术,主要通过预处理指令完成。这些指令通常以 `#` 开头,最常用的包括 `#ifdef`、`#ifndef`、`#if`、`#else`、`#elif` 和 `#endif`[^1]。 ```c #ifdef DEBUG printf("Debug mode is enabled.\n"); #else printf("Debug mode is disabled.\n"); #endif ``` 通过条件编译,可以实现跨平台代码编写或调试功能的开关控制。 --- #### 2. 全局变量与局部变量的区别 全局变量在编译后的目标文件中会保存其地址信息,链接器在链接多个目标文件时,能够确保所有函数可以访问到全局变量[^3]。而局部变量仅在其定义的函数或代码块内有效。 ```c int globalVar = 10; // 全局变量 void function() { int localVar = 5; // 局部变量 printf("Local Variable: %d\n", localVar); printf("Global Variable: %d\n", globalVar); } int main() { function(); printf("Global Variable: %d\n", globalVar); // printf("Local Variable: %d\n", localVar); // 错误:局部变量无法在其他作用域访问 return 0; } ``` --- #### 3. 使用 `const` 提升函数参数的安全性 `const` 关键字用于修饰函数参数,防止函数内部修改传入的参数值,从而提升代码的安全性和可读性[^2]。 ```c void printArray(const int *arr, int size) { for (int i = 0; i < size; i++) { printf("%d ", arr[i]); // arr[i] = 0; // 错误:不能修改数组内容 } printf("\n"); } int main() { int array[] = {1, 2, 3, 4, 5}; printArray(array, 5); return 0; } ``` --- #### 4. 编译器优化机制 在某些情况下,编译器会对代码进行优化,减少不必要的指令执行。例如,当变量值已被加载到寄存器中时,后续对该变量的访问可能直接从寄存器获取,而无需再次从内存中读取[^4]。 ```c int a = 10; int b, c; b = a; // a 的值被加载到寄存器中 c = a; // 直接从寄存器获取 a 的值,避免重复访问内存 ``` --- #### 5. 指针的基本概念与使用 指针是C语言的重要特性之一,用于存储变量的内存地址。通过指针操作,可以实现动态内存分配、数组遍历等功能。 ```c int value = 42; int *ptr = &value; printf("Value: %d\n", *ptr); // 解引用指针,访问指针指向的值 printf("Address: %p\n", ptr); // 输出指针的值(即变量的地址) ``` --- #### 6. 动态内存管理 C语言提供了 `malloc`、`calloc`、`realloc` 和 `free` 等函数来管理动态内存。正确使用这些函数可以避免内存泄漏或非法访问等问题。 ```c #include <stdio.h> #include <stdlib.h> int main() { int *array = (int *)malloc(5 * sizeof(int)); // 分配内存 if (array == NULL) { printf("Memory allocation failed.\n"); return -1; } for (int i = 0; i < 5; i++) { array[i] = i + 1; } for (int i = 0; i < 5; i++) { printf("%d ", array[i]); } printf("\n"); free(array); // 释放内存 return 0; } ``` --- #### 7. 结构体与联合体的区别 结构体允许将不同类型的数据组合在一起,而联合体在同一内存区域存储不同的数据类型,但同一时间只能使用其中一种类型。 ```c struct Person { char name[50]; int age; }; union Data { int intValue; float floatValue; }; int main() { struct Person person; strcpy(person.name, "John"); person.age = 25; union Data data; data.intValue = 10; printf("Integer Value: %d\n", data.intValue); data.floatValue = 3.14; printf("Float Value: %.2f\n", data.floatValue); return 0; } ``` --- #### 8. 文件操作 C语言提供了标准库函数来处理文件的读写操作,如 `fopen`、`fclose`、`fprintf` 和 `fscanf` 等。 ```c #include <stdio.h> int main() { FILE *file = fopen("example.txt", "w"); if (file == NULL) { printf("Failed to open file.\n"); return -1; } fprintf(file, "Hello, World!\n"); fclose(file); file = fopen("example.txt", "r"); if (file == NULL) { printf("Failed to open file.\n"); return -1; } char buffer[100]; fgets(buffer, sizeof(buffer), file); printf("File Content: %s", buffer); fclose(file); return 0; } ``` --- #### 9. 递归函数的应用 递归函数是指在其定义中调用自身的函数,常用于解决分治问题或树形结构的遍历。 ```c int factorial(int n) { if (n <= 1) { return 1; } return n * factorial(n - 1); } int main() { int result = factorial(5); printf("Factorial of 5: %d\n", result); return 0; } ``` --- #### 10. 多线程编程 虽然C语言本身不直接支持多线程,但可以通过POSIX线程库(pthread)实现多线程编程。 ```c #include <stdio.h> #include <pthread.h> void *threadFunction(void *arg) { printf("Thread ID: %ld\n", pthread_self()); return NULL; } int main() { pthread_t thread1, thread2; pthread_create(&thread1, NULL, threadFunction, NULL); pthread_create(&thread2, NULL, threadFunction, NULL); pthread_join(thread1, NULL); pthread_join(thread2, NULL); return 0; } ``` --- ###
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值