驱动工程师面试题 - C语言进阶1
本文档采用口语化的面试回答风格,模拟真实面试场景中的思路和表达方式
1.1 内存管理
1. 什么是内存对齐?为什么需要内存对齐?如何实现内存对齐?
回答思路:
什么是内存对齐:
内存对齐就是数据在内存中的起始地址必须是某个值的整数倍。比如4字节对齐,地址必须是4的倍数,像0x1000、0x1004这样,不能是0x1001、0x1002。
为什么需要对齐:
第一是硬件限制。有些CPU架构,比如早期的ARM,只能从对齐的地址读取数据,不对齐会直接触发硬件异常。
第二是性能考虑。CPU一次读取是按照总线宽度来的,比如32位系统一次读4字节。如果一个int从对齐地址开始,一次就读完了;如果不对齐,可能跨越了两个读取单元,要读两次再拼接,效率就低了。
第三是DMA操作和外设访问要求。DMA控制器和很多外设在访问内存时,要求数据必须对齐。比如DMA传输要求缓冲区地址对齐到cache line大小(通常是64字节或128字节),否则可能导致数据传输错误或缓存一致性问题。网卡、磁盘控制器等外设访问的内存区域也通常有对齐要求。
如何实现对齐:
使用posix_memalign、aligned_alloc、memalign等接口或者手动对齐,在Linux内核里,分配DMA缓冲区要用dma_alloc_coherent,它会自动对齐,而且保证缓存一致性。或者用kmalloc加GFP_DMA标志。
2. 什么是内存泄露?如何检测和预防?
回答思路:
内存泄漏就是分配的内存没释放,时间长了可用内存越来越少,最终程序崩溃。
检测方法我一般用这几种:
首先是代码审查,这是最基本的。每次写malloc就要想着对应的free在哪里,特别要注意异常路径,比如中间return了,前面分配的内存有没有释放。我一般养成习惯,malloc完马上写free,然后再填中间的逻辑。
然后是工具检测。Linux下最常用的是Valgrind,运行程序时加上--leak-check=full参数,它会告诉你哪里泄漏了,哪一行分配的没释放。还有AddressSanitizer,编译时加-fsanitize=address,运行时就能检测出来,而且性能开销比Valgrind小。
如果是嵌入式环境,这些工具可能用不了,我会自己写个简单的内存跟踪。就是把malloc和free包装一下,记录每次分配的地址、大小、文件名和行号,释放时就从记录里删除。程序结束时看看还有哪些没释放的,就知道哪里泄漏了。
还有静态分析工具,像Coverity、Cppcheck,提交代码前跑一遍,能发现很多潜在问题。
3. 栈和堆有什么区别?什么时候用栈,什么时候用堆?
回答思路:
这个问题比较基础,但在驱动开发中特别重要。
区别主要有这几点:
栈是编译器自动管理的,函数调用时分配,返回时释放,不用我们操心。堆是手动管理的,malloc分配,free释放,忘了释放就泄漏了。
速度上,栈快得多,就是移动一下栈指针,堆要在空闲链表里找合适的块,慢很多。
大小上,栈比较小,一般就几MB,堆大得多,受系统内存限制。所以大数组不能放栈上,会栈溢出。
还有就是生命周期,栈上的变量函数返回就没了,堆上的可以一直保留到手动释放。
什么时候用栈:小对象、临时变量、生命周期短的,都用栈。比如循环计数器、临时缓冲区、小结构体。栈的局部性好,对Cache友好,性能高。
什么时候用堆:大对象、生命周期长的、运行时才知道大小的,用堆。比如要返回给调用者的数据,或者根据用户输入动态分配的数组。
我举个实际例子,网卡驱动,接收缓冲区要几KB,肯定不能放栈上,用的是堆。但是处理数据包时的临时变量,像计数器、标志位这些,都放栈上。
4. malloc是如何实现的?free做了什么?
回答思路:
这个问题比较深入,我简单说一下原理。
malloc的实现,最基本的思路是维护一个空闲链表。每个内存块前面有个头部,记录这块的大小和是否空闲。malloc的时候就在链表里找一个足够大的空闲块,找到了就标记为已使用,返回给用户。如果块太大,还会把它分割成两块,一块给用户,剩下的继续放在空闲链表里。
查找策略有好几种:首次适配就是找到第一个够大的就用;最佳适配是找最小的够大的块,减少浪费;快速适配是维护多个不同大小的链表,分配更快。
free做的事情:首先标记这块内存为空闲,然后很重要的一步是合并相邻的空闲块。如果不合并,时间长了就会产生很多小碎片,虽然总空闲内存够,但找不到连续的大块,这就是内存碎片问题。
有几个常见错误要避免:一是重复释放,会导致链表结构破坏,程序崩溃;二是释放后继续使用,这是未定义行为,应该及时将指针赋值NULL;
5. 大小端是什么意思,如何判断系统是大端还是小端?如何进行大小端转换?
回答思路:
大小端是多字节数据在内存中的存储顺序。
大端就是高位字节放在低地址,就像我们平时写数字一样,从左到右从大到小。小端反过来,低位字节放在低地址。
举个例子,0x12345678这个数,大端存储就是12 34 56 78,小端就是78 56 34 12,完全反过来了。
常见的架构,x86、ARM默认都是小端,网络传输用的是大端,所以叫网络字节序。
判断方法最简单的是用联合体。定义一个联合体,里面有个int和一个char数组,给int赋值1,然后看char数组第一个元素是不是1。如果是1,说明低位在低地址,就是小端;如果是0,就是大端。
也可以直接用指针,把int的地址转成char指针,看第一个字节是什么。
转换方法有好几种:
最直接的是手动移位,16位的就把高8位和低8位交换,32位的把4个字节都交换一下。
更方便的是用系统函数,Linux下有htons、htonl这些,就是把主机字节序转成网络字节序,也就是转成大端。
在驱动开发中,这个特别常用。比如读硬件寄存器,硬件可能是大端的,CPU是小端的,读出来要转换一下。网络驱动处理数据包,IP头、TCP头这些都是大端的,要转成主机字节序才能用。
7. 什么是内存溢出?如何检测和预防?
回答思路:
内存溢出是指程序访问了非法范围的内存区域。
内存溢出的类型:
第一是栈溢出。函数调用层次太深,或者在栈上分配了特别大的数组,超过了栈的大小限制。栈溢出通常会导致程序立即崩溃,出现段错误。
第二是缓冲区溢出。写入数据超过了缓冲区的边界,覆盖了相邻的内存。这是最危险的,可能被利用来执行恶意代码。比如strcpy没有检查长度,memset、memcopy等内存操作函数传入长度超过缓冲区边界。
如何检测:
边界检查。对数组访问、缓冲区操作,字符串操作,都要检查边界。用strncpy代替strcpy,用snprintf代替sprintf,这些带n的函数都有长度限制,更安全。
静态分析工具。像Coverity、Cppcheck,在编译阶段就能发现潜在的溢出问题。
如何预防:
第一,合理估算内存需求。设计时就要考虑最大栈空间使用量,为任务分配足够的任务栈大小。
第二,避免大数组放栈上。栈空间有限,大数组要用堆分配。一般超过几KB的数组就不要放栈上了。
第三,限制递归深度。递归容易导致栈溢出,要么改成循环,要么限制递归深度。
第四,使用安全的字符串函数。用strncpy、snprintf、strlcpy这些,不要用strcpy、sprintf。
第五,数组访问前检查索引。确保索引在有效范围内,不要越界。
1.2 指针与数组
1. 什么是回调函数?回调函数的一般使用场景?
回答思路:
回调函数就是把函数指针作为参数传递,让被调用的函数在合适的时候调用它。
使用场景:
第一是中断处理。驱动里最常见的就是中断回调,硬件产生中断后,调用注册的中断处理函数。比如网卡收到数据包、定时器超时、GPIO电平变化,DMA传输完成都是通过回调通知驱动。
第二是框架层的接口抽象。驱动框架定义一组回调函数指针,不同的驱动实现自己的版本。比如Linux的file_operations结构体,里面有open、read、write、ioctl这些函数指针,应用层调用系统调用时,内核就调用对应的回调。这样框架和具体实现就解耦了。
第四是通用算法。算法本身不关心具体的数据类型,通过回调函数让调用者提供具体的操作。比如qsort的比较函数,遍历链表时的处理函数,都是用回调实现的。
2. 指针的大小是多少?在不同平台上有什么区别?
回答思路:
指针的大小取决于CPU的寻址能力,跟指针指向的类型无关。
32位系统,地址总线是32位,所以指针是4字节。不管是char指针、int指针还是函数指针,都是4字节。
64位系统,地址总线是64位,指针是8字节。
移植问题:32位程序移植到64位时,最容易出问题的就是指针和int混用。32位系统上,指针和int都是4字节,可以互相转换。但64位系统上,指针是8字节,int还是4字节,转换就会截断高位,导致地址错误,还有个要注意的,printf打印指针要用%p,不要用%x或%d,因为不同平台大小不一样。
3. 什么是野指针?如何避免和排查?
回答思路:
野指针就是指向非法地址的指针,使用它会导致不可预测的行为。
野指针的两种情况:
第一种是未初始化的指针。声明了一个指针变量,但没有赋值,它的值是不确定的,可能指向任何地方。如果对它解引用,可能访问到不该访问的内存,导致程序崩溃或数据被破坏。
第二种是悬空指针。指针指向的内存已经被释放了,但指针还保留着原来的地址。比如free了一块内存,指针没有置NULL,继续使用就是访问已经释放的内存,这是未定义行为。那块内存可能被重新分配给别人了,访问就会破坏别人的数据。
如何避免:
第一,声明时就初始化。如果暂时不知道指向哪里,就初始化为NULL。使用前检查是否为NULL,不是NULL才解引用。
第二,释放后立即置NULL。这样即使误用了,也只是访问NULL,会立即崩溃,容易发现问题。如果不置NULL,指针还指向原来的地址,这种bug特别难查。
第三,使用前检查有效性。特别是全局指针或者跨线程使用的指针,要确保它还有效。
如何排查:
首先是使用工具。AddressSanitizer和Valgrind都能检测出野指针访问,会告诉你哪一行访问了无效地址。
其次是看崩溃信息。段错误通常就是野指针导致的,看崩溃时的地址,如果是0x0或者很小的地址,一般是NULL指针;如果是很大的随机地址,可能是未初始化指针;如果是之前分配过的地址,可能是悬空指针。
还有代码审查。重点检查指针的生命周期,确保使用时指针还有效。
1.3 编译与链接
1. C程序编译的四个阶段是什么?每个阶段做什么?
回答思路:
C程序从源代码到可执行文件,要经过四个阶段:预处理、编译、汇编、链接。
第一阶段:预处理(Preprocessing)
这个阶段处理所有以#开头的指令。主要做三件事:一是展开宏定义,把所有的宏替换成实际内容;二是处理条件编译,根据#ifdef这些决定哪些代码保留;三是包含头文件,把#include的文件内容插入进来。
输入是.c文件,输出是.i文件,可以用gcc -E看到预处理结果。这个文件特别大,因为头文件都展开了。
第二阶段:编译(Compilation)
这个阶段把C代码翻译成汇编代码。编译器会做词法分析、语法分析、语义分析,然后生成中间代码,最后优化生成汇编代码。
输入是.i文件,输出是.s文件,可以用gcc -S生成。打开.s文件可以看到汇编指令。
第三阶段:汇编(Assembly)
这个阶段把汇编代码翻译成机器码,生成目标文件。目标文件包含机器指令、数据、符号表、重定位信息等。
输入是.s文件,输出是.o文件,可以用gcc -c生成。这个文件是二进制的,不能直接看,要用objdump或readelf查看。
第四阶段:链接(Linking)
这个阶段把多个目标文件和库文件链接成一个可执行文件。链接器要做两件事:一是符号解析,把函数调用和函数定义对应起来;二是重定位,确定每个符号的最终地址。
2. 静态库和动态库有什么区别?各有什么优缺点?
回答思路:
静态库和动态库都是代码复用的方式,但链接方式不同。
静态库(.a文件或.lib文件):
链接时,静态库的代码会被完整地拷贝到可执行文件里。就像把库的代码直接复制粘贴到你的程序里一样。
优点是:程序独立,不依赖外部文件,部署简单;运行时不需要加载库,启动快;不会有库版本冲突问题。
缺点是:可执行文件大,因为每个程序都包含一份库代码;如果库更新了,所有程序都要重新编译链接;浪费内存,多个程序运行时,库代码在内存里有多份。
动态库(.so文件或.dll文件):
链接时,只是记录一下需要哪些库,不拷贝代码。运行时才加载库,多个程序可以共享同一份库代码。
优点是:可执行文件小;库更新了,不用重新编译程序,只要接口不变就行;节省内存,多个程序共享一份库代码。
缺点是:程序依赖外部库文件,部署时要带上库;运行时要加载库,启动稍慢;可能有库版本冲突。
使用场景:
静态库适合:嵌入式系统,资源受限,不想依赖外部文件;对性能要求高,不想有加载开销;库不经常更新。
动态库适合:桌面应用,多个程序共享库,节省空间;库经常更新,不想重新编译所有程序;插件系统,运行时动态加载。
3. inline函数有什么用?什么场景下会使用?有什么好处?
回答思路:
inline是建议编译器把函数调用展开成函数体,避免函数调用的开销。
为什么需要inline:
函数调用是有开销的,要保存寄存器、传递参数、跳转、返回,这些都需要时间。对于特别小的函数,比如只有一两行代码,调用开销可能比函数本身还大。这时候用inline,编译器会把函数体直接插入到调用的地方,就像宏一样,但比宏安全,因为有类型检查。
使用场景:
第一,小函数,几行代码,调用开销不值得。
第二,频繁调用的函数,比如循环里调用的,inline可以显著提升性能。
第三,性能关键路径,比如中断处理函数里调用的,每一点开销都要优化。
好处:
一是性能提升,省去了函数调用开销。二是编译器可以做更多优化,因为看到了函数体,可以和周围代码一起优化。
4. 什么是符号表?如何查看?
回答思路:
符号表是目标文件或可执行文件里的一个表,记录了所有的符号信息,包括函数名、变量名、它们的地址、类型等。
符号表的作用:
链接时,链接器要把函数调用和函数定义对应起来,就是通过符号表。比如main.c里调用了func(),func.o的符号表里有func的定义,链接器就把它们连起来。
调试时,调试器要把地址转换成函数名和行号,也是通过符号表。如果没有符号表,调试器只能显示地址,看不到函数名。
如何查看符号表:
最常用的是nm命令,可以查看目标文件或可执行文件的符号表:
nm file.o- 显示所有符号nm -D file.so- 只显示动态符号nm -u file.o- 只显示未定义的符号
输出格式是:地址 类型 符号名。类型常见的有:T表示代码段的函数,D表示已初始化数据,B表示未初始化数据,U表示未定义的符号,小写字母表示局部符号(static)。
或者用objdump命令:
objdump -t file.o- 显示符号表objdump -T file.so- 显示动态符号表
5. 程序崩溃了,如何使用反汇编调试定位问题?
回答思路:
获取崩溃信息:
首先要知道崩溃在哪里。Linux下看core dump文件,用dmesg看内核日志,或者用gdb attach到进程。崩溃信息会告诉你出错的地址。
使用objdump反汇编:
最常用的是objdump命令:
objdump -d program- 反汇编整个程序objdump -d program | grep -A 20 <address>- 查看特定地址附近的代码
分析崩溃原因:
常见的崩溃模式:
第一,访问空指针。崩溃地址是0x0或者很小的地址。
第二,栈溢出。崩溃在函数调用或返回时,栈指针sp的值不正常,可能指向了不该访问的地址。
第三,野指针。崩溃地址是个随机值,汇编代码里访问了一个无效的内存地址。
第四,缓冲区溢出。崩溃在函数返回时,返回地址被覆盖了,跳转到了错误的地方。
6. 链接时出现undefined reference错误是什么原因?如何解决?
回答思路:
undefined reference是链接阶段的错误,表示链接器找不到某个符号的定义。
常见原因:
第一,忘了编译某个源文件。比如func()定义在func.c里,但编译时只编译了main.c,没编译func.c,链接时就找不到func的定义。
第二,忘了链接某个库。比如用了数学库的函数,但没有链接libm。
第三,函数名拼错了。声明和定义的函数名不一致,或者大小写错了。
第四,C和C++混用。C++编译器会对函数名进行修饰(name mangling),C编译器不会。如果C++代码调用C函数,或者C代码调用C++函数,链接时会找不到。
解决办法:用extern "C"包装,在C++里调用C函数时,头文件里用#ifdef __cplusplus包一下,里面声明extern "C"。
第五,静态库顺序错误。如果liba依赖libb,链接时要先写liba再写libb。
第六,符号是static的。函数定义时加了static,只在当前文件可见,其他文件链接不到。
1.5 并发编程基础
1. volatile关键字的作用是什么?什么时候需要用?
回答思路:
volatile告诉编译器,这个变量可能被意外改变,不要对它做优化。
为什么需要:
编译器为了提高性能,会做很多优化。比如一个变量读了多次,编译器可能只读一次,把值缓存在寄存器里,后面直接用寄存器的值。但如果这个变量会被外部改变,比如硬件寄存器、中断处理函数、其他线程,那缓存的值就是错的。
volatile就是告诉编译器,每次都要从内存读取,不要缓存。
什么时候需要用:
第一,硬件寄存器。寄存器的值可能随时被硬件改变,必须每次都读取。
第二,中断处理函数修改的变量。主程序和中断共享的变量,必须是volatile的,否则主程序可能读到旧值。
第三,多线程共享的变量。虽然volatile不能保证线程安全,但至少保证每次都从内存读取。
2. 什么是原子操作?为什么需要原子操作?
回答思路:
原子操作就是不可分割的操作,要么全部完成,要么全部不做,中间不会被打断。
为什么需要:
多线程或中断环境下,普通操作可能被打断。比如count++,实际上是三步:读取count,加1,写回count。如果两个线程同时执行,可能都读到同一个值,加1后写回,结果count只加了1,不是2。这就是竞态。
原子操作保证这三步是一个整体,不会被打断,就不会出现这个问题。
3. 什么是内存屏障?为什么需要内存屏障?
回答思路:
内存屏障是一种同步原语,保证内存操作的顺序。
为什么需要:
现代CPU为了提高性能,会乱序执行指令。编译器也会重排指令。这在单线程里没问题,但多线程或驱动开发中就可能出问题。
举个例子,一个线程先写data再写flag,另一个线程检查flag是1就读data。我们期望第二个线程看到data是新值。但CPU可能把两条写指令重排,先写flag再写data,第二个线程就可能看到flag是1但data还是旧值。
内存屏障就是告诉CPU,屏障前的内存操作必须在屏障后的操作之前完成,不能重排。
类型:
编译器屏障:防止编译器重排,不影响CPU。
asm volatile("" ::: "memory"); // GCC
CPU屏障:防止CPU重排。
- 读屏障(rmb):保证屏障前的读操作在屏障后的读操作之前完成
- 写屏障(wmb):保证屏障前的写操作在屏障后的写操作之前完成
- 全屏障(mb):保证屏障前的所有内存操作在屏障后的操作之前完成
使用场景:
第一,驱动开发。操作硬件寄存器时,必须保证顺序。比如先写配置寄存器,再写启动寄存器,不能重排。
第二,无锁编程。实现无锁数据结构时,要用内存屏障保证可见性和顺序性。
第三,DMA操作。启动DMA前,要确保数据已经写入内存,不能还在CPU缓存里。比如先把数据拷贝到DMA缓冲区,加个写屏障,再启动DMA。
注意事项:
内存屏障有性能开销,不要滥用。只在必要的地方用。
不同架构的内存模型不同,x86是强顺序的,ARM是弱顺序的。x86上可能不需要屏障的地方,ARM上可能需要。
4. volatile能保证线程安全吗?为什么?
回答思路:
不能。volatile只是告诉编译器不要优化,完全不能像锁那样保护临界区。
什么是线程安全和临界区:
线程安全的核心是保护临界区数据,也就是多个线程同时访问的共享数据。比如一个全局计数器,多个线程都要读写它,这就是临界区数据。
锁的作用是保护临界区,确保同一时间只有一个线程能进入临界区,其他线程必须等待。这样就不会有多个线程同时修改数据,导致数据不一致。
volatile完全不能保护临界区:
这是最关键的一点:volatile不能阻止多个线程同时进入临界区,它完全没有互斥的功能。
举个例子,两个线程同时执行volatile int count; count++;,volatile不会让第二个线程等待,两个线程会同时读count,同时加1,同时写回,结果count只加了1,不是2。临界区数据被破坏了。
如果用锁,第一个线程加锁进入临界区,第二个线程就必须等待,直到第一个线程解锁。这样就不会有数据竞争。
正确保护临界区的方法:
第一,用锁。比如pthread_mutex,进入临界区前加锁,出来后解锁。锁能保证互斥,同一时间只有一个线程能访问临界区数据。
第二,用原子操作。比如atomic_int配合atomic_fetch_add,保证操作的原子性。适合简单的临界区数据,像计数器、标志位。
第三,用内存屏障。配合原子操作使用,保证操作的顺序。