简介:这是一份面向C语言求职者与在校学生的经典笔试试题汇总文档,聚焦面试高频考点与易错细节,适合准备技术笔试、巩固语言基础的中初级开发者使用。压缩包内共1个doc文件,约122KB,内容以问答形式组织,涵盖sizeof与strlen的区别、指针与数组长度计算、栈与堆的内存分配差异、大小端存储判断、结构体指针偏移运算、数组地址偏移算法,以及const、static、volatile等关键字的语义与线程间通信机制等核心主题。文档通过具体代码片段与逐条解析,帮助读者厘清编译期与运行期行为差异,理解内存布局与跨平台数据交换要点,并规避栈溢出、内存泄漏和指针误用等常见问题。目前已有319人学习,适合作为笔试冲刺前的查漏补缺材料,也可用于日常复习C语言底层知识。
1. 一份被低估的 C 语言笔试真题集:从 sizeof 到线程通信的完整复盘
翻招聘群里的聊天记录,十个吐槽 C 语言笔试的,八个卡在sizeof和strlen上。这份《C语言笔试试题汇集(超经典).doc》我前后拆过三遍,它不是那种按章节铺知识点的教材,而是一份按面试官出题逻辑组织的题库——从sizeof指针和数组的长度、stack和heap的区别,一路推到大小端存储、结构体指针偏移、const/static/volatile三个关键字的完整用法,最后落到进程与线程、线程间通信机制。适合两类人:一是正在准备嵌入式或系统方向笔试的求职者,二是工作两三年但发现基础概念有模糊地带、想系统补一遍的开发者。它最大的价值在于每道题都给了答案和推导过程,不是只丢一个结论让你背。
2. sizeof 与 strlen 的边界:数组退化、指针大小和编译期计算
2.1 为什么 sizeof(str1) 是 6 而 strlen(str1) 是 5
先看这份资料里最经典的一组对比。在 32 位系统下:
char str1[] = "Hello"; // 自动追加 '\0' char str2[5] = {'H','e','l','l','o'}; // 无 '\0' char str3[6] = {'H','e','l','l','o','\0'}; char *p1 = "Hello"; char *p2[] = {"hello", "world"}; int n = 10; int *q = &n;逐个拆解sizeof和strlen的结果:
| 表达式 | sizeof 结果 | strlen 结果 | 原因 |
|---|---|---|---|
sizeof(str1) | 6 | 5 | 数组大小含\0,strlen 不含 |
sizeof(str2) | 5 | 未知 | 无\0,strlen 越界读取 |
sizeof(str3) | 6 | 5 | 显式写了\0,结果同 str1 |
sizeof(p1) | 4 | — | 指针大小,32 位系统固定 4 字节 |
sizeof(p2) | 8 | — | 两个指针的数组,2×4=8 |
sizeof(n) | 4 | — | int 类型大小 |
sizeof(q) | 4 | — | 指针大小 |
这里最容易翻车的是str2。strlen(str2)的结果是未定义的——它从str2的首地址开始逐字节扫描,直到遇见第一个\0才停。如果内存里恰好有个 0,结果可能是个很小的数;如果没有,程序直接崩。我见过有人在笔试里写strlen(str2) = 5,面试官当场让他解释为什么,答不上来就挂了。
2.2 sizeof 是操作符,strlen 是函数——这个区别决定了什么
资料里列了九条区别,但真正影响写代码的只有三条:
第一条:sizeof 在编译期求值,strlen 在运行期求值。这意味着sizeof可以用来定义数组维度:
char str[20] = "0123456789"; int a = strlen(str); // a = 10,运行时计算 int b = sizeof(str); // b = 20,编译期确定第二条:数组做 sizeof 参数不退化,传给 strlen 就退化为指针。这是函数传参时最隐蔽的坑:
void func(char arr[]) { // 这里 arr 已经退化为 char*,sizeof(arr) = 4 // 不是数组的实际大小 int len = sizeof(arr); // 4,不是数组长度 }如果函数内部需要知道数组大小,必须额外传一个长度参数,或者用memcpy把数据拷出来再操作。资料里给的做法是fun(unsigned char *p1, int len),进入函数后用memcpy(buf, p1, len)拷贝,长度由调用方传入。
第三条:sizeof 可以用类型做参数,strlen 只能用char*。sizeof(int)合法,strlen(int)编译不过。sizeof后面跟类型必须加括号,跟变量名可以不加——因为它是操作符不是函数。
注意:
sizeof返回类型是size_t,在头文件中 typedef 为unsigned int。打印时用%zu而不是%d,否则在 64 位系统上可能输出异常。
3. 内存布局与地址运算:从 stack/heap 到结构体指针偏移
3.1 程序内存五区与 stack/heap 的真实差异
资料里把内存分成五个区:栈区、堆区、全局区(静态区)、文字常量区、程序代码区。用一个例子程序就能看清每个变量的归属:
int a = 0; // 全局初始化区 char *p1; // 全局未初始化区 int main() { int b; // 栈 char s[] = "abc"; // 栈 char *p2; // 栈 char *p3 = "123456"; // p3 在栈上,"123456\0" 在常量区 static int c = 0; // 全局(静态)初始化区 p1 = (char *)malloc(10); // 堆区,10 字节 p2 = (char *)malloc(20); // 堆区,20 字节 strcpy(p1, "123456"); // "123456\0" 放常量区 }stack 和 heap 的核心差异不在「一个自动一个手动」这种表面描述,而在分配效率和碎片特性:
- 栈:由编译器自动分配释放,存放函数参数值、局部变量。操作方式类似数据结构里的栈,后进先出。空间有限,通常几 MB,超出就是 stack overflow。
- 堆:由程序员手动分配释放(
malloc/free),不释放则程序结束时由 OS 回收。分配时系统遍历空闲内存链表,找第一个空间大于申请量的节点,删除后分配给程序。堆是向高地址扩展的不连续内存区域,大小受限于系统有效虚拟内存。
资料里特别提醒:堆分配和数据结构里的「堆」是两回事,只是分配方式类似链表操作。这个区分在面试里经常被追问。
3.2 结构体指针偏移:p+0x200 到底加了多少字节
这是资料里最容易算错的一道题:
struct BBB { long num; // 4 字节(32 位系统) int index; // 4 字节 char *name; // 4 字节 } *p; p = 0x1000000;sizeof(BBB) = 12。三个表达式的计算结果:
| 表达式 | 计算过程 | 结果 |
|---|---|---|
p + 0x200 | 0x1000000 + 0x200 × 12 | 0x1001800 |
(unsigned long)p + 0x200 | 0x1000000 + 0x200 | 0x1000200 |
(char *)p + 0x200 | 0x1000000 + 0x200 × 1 | 0x1000200 |
关键区别在于指针的步长。p是struct BBB *类型,p + n实际偏移n × sizeof(BBB)字节。强制转成unsigned long后,加法变成纯数值运算,加多少就是多少。转成char *后步长为 1 字节,结果和数值加法一致。
数组地址偏移也是同一套逻辑。资料里给了五条规则,核心记住二维数组的展开方式:
int a[2][3] = {1,2,3,4,5,6}; // *(*(a+1)+1) = 5 // a+1 指向第二行,*(a+1) 取第二行首地址,+1 移到第二个元素对于字符指针数组char *p[] = {"ABC","DEF"},*(*(p+1)+1)取的是'E'——先定位到第二个字符串,再取第二个字符。
3.3 大小端存储的判定与代码验证
0x12345678在内存中的存放顺序,大端和小端完全相反:
| 地址偏移 | 大端模式 | 小端模式 |
|---|---|---|
| 0x0000 | 12 (OP0) | 78 (OP3) |
| 0x0001 | 34 (OP1) | 56 (OP2) |
| 0x0002 | 56 (OP2) | 34 (OP1) |
| 0x0003 | 78 (OP3) | 12 (OP0) |
大端把高位字节放在低地址,小端把低位字节放在低地址。x86 系列是小端,部分嵌入式处理器用大端。判定方法用联合体最直接:
int check_endian() { union { int i; char c[4]; } u; u.i = 0x12345678; // 小端返回 1,大端返回 0 return (u.c[0] == 0x78); }联合体所有成员共享同一块内存,给i赋值后读c[0]就能看到最低地址上的字节。小端模式下c[0]是0x78,大端模式下是0x12。
4. 关键字与函数实现:const、static、volatile 和手写 strcpy/memcpy
4.1 const 的四种写法与 volatile 的三个陷阱
const的位置决定了谁不能被修改:
const int a; // a 是常整型数 int const a; // 同上 const int *a; // a 指向常整型数,值不可改,指针可改 int * const a; // a 是常指针,指针不可改,值可改 int const * a const; // 值和指针都不可改面试里常问「为什么要用 const」,资料给了三个理由:向读代码的人传达参数用途、给优化器附加信息产生更紧凑代码、让编译器保护不希望被改变的参数。我自己的习惯是,函数参数只要不在内部修改就加const,这是最省事的防御性写法。
volatile的核心是告诉编译器「这个变量可能被意想不到地改变,每次用都必须重新从内存读」。三个典型场景:并行设备的硬件寄存器、中断服务子程序访问的非自动变量、多线程共享变量。
资料里有个经典的volatile陷阱题:
int square(volatile int *ptr) { return *ptr * *ptr; }这段代码看起来是求平方,但编译器可能生成:
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 = *ptr; return a * a; }static的三个作用也值得记牢:函数体内静态变量在调用过程中维持值不变;模块内静态变量可被模块内函数访问但不能被模块外访问;模块内静态函数只能被本模块调用。第三点很多人答不上来,但它恰恰是 C 语言实现模块化封装的基础。
4.2 手写 strcpy 和 memcpy:边界处理与返回值设计
资料里给了两个函数的实现,我补上完整的边界检查:
#include <assert.h> char* strcpy(char* strDest, const char* strSrc) { assert(strDest != NULL && strSrc != NULL); char* strTmpD = strDest; // 保存首地址用于返回 while ((*strTmpD++ = *strSrc++) != '\0') { // 逐字符拷贝,遇到 '\0' 停止 } return strDest; // 返回首地址,支持链式调用 } void* memcpy(void* pDest, const void* pSrc, unsigned int size) { assert(pDest != NULL && pSrc != NULL); char* pTmpD = (char*)pDest; const char* pTmpS = (const char*)pSrc; while (size-- > 0) { *pTmpD++ = *pTmpS++; } return pDest; }两个函数的区别:strcpy只能拷贝字符串,遇到\0停止;memcpy是内存原样拷贝,不管内容是什么,指定多少字节就拷多少。strcpy返回char*是为了支持strlen(strcpy(dst, src))这种嵌套调用。
注意:
memcpy不处理源和目标内存重叠的情况。如果pDest和pSrc有重叠区域,需要用memmove或者从高地址向低地址拷贝。
4.3 宏定义中的括号陷阱与 do-while(0) 惯用法
资料里列了 20 条宏定义技巧,最容易出错的是括号问题:
// 错误写法 #define ADD(a, b) a + b // ADD(1, 2) * 3 展开为 1 + 2 * 3 = 7,不是 9 // 正确写法 #define ADD(a, b) ((a) + (b))多语句宏必须用do { } while(0)包裹:
#define DO(a, b) do { \ a + b; \ a++; \ } while(0)如果不用do-while(0),在if-else中使用会出问题:
if (cond) DO(a, b); // 展开后 else 会找不到匹配的 if else ...其他实用宏包括:ARR_SIZE(a)返回数组元素个数、MAX(x,y)和MIN(x,y)求极值、FPOS(type, field)获取结构体成员偏移量。FPOS的实现是((dword) &((type *)0)->field),把 0 地址强制转成结构体指针再取成员地址,偏移量就是成员地址本身。
5. 进程、线程与通信机制:笔试最后一道大题的拆解方式
5.1 进程和线程的本质区别
资料里对进程和线程的描述很长,但核心就四句话:
- 进程是资源分配的基本单位,线程是 CPU 调度的基本单位。
- 每个进程有独立地址空间,同一进程的线程共享地址空间。
- 进程崩溃不影响其他进程,线程崩溃导致整个进程挂掉。
- 创建和撤销进程的开销远大于线程。
一个程序至少有一个进程,一个进程至少有一个线程。线程自己不拥有系统资源,只有程序计数器、一组寄存器和栈,但可以访问隶属于进程的资源。
5.2 进程间通信的六种方式与线程间同步的三种机制
进程间通信方式:
| 方式 | 特点 | 适用场景 |
|---|---|---|
| 管道(pipe) | 亲缘关系进程间通信 | 父子进程简单数据传递 |
| 有名管道(named pipe) | 允许无亲缘关系进程通信 | 任意进程间通信 |
| 信号(signal) | 软件层模拟中断 | 事件通知 |
| 消息队列(message queue) | 消息链表,克服信号量有限缺点 | 结构化消息传递 |
| 共享内存(shared memory) | 多进程访问同一块内存 | 大数据量高效通信 |
| 信号量(semaphore) | 同步和互斥手段 | 配合共享内存使用 |
| 套接字(socket) | 网络间不同机器进程通信 | 分布式系统 |
线程间同步机制:
- 信号量:二进制信号量、互斥信号量、整数型信号量、记录型信号量。互斥信号量必须同一任务申请和释放,可递归申请;二进制信号量允许一个任务申请、另一个任务释放。
- 消息:消息队列、消息邮箱。
- 事件:event 机制。
资料里特别区分了互斥信号量和二进制信号量:互斥信号量是二进制信号量的子集,但互斥信号量有优先级继承机制,能防止优先级反转。这个细节在嵌入式笔试里经常考。
5.3 可重入函数与递归函数的判断标准
可重入函数是指多个任务可以同时调用而不破坏数据的函数。判断标准:不使用全局变量或静态变量、不调用不可重入函数、不返回指向静态数据的指针。递归函数要特别注意栈溢出风险,每次递归调用都会在栈上分配新的局部变量,递归深度受栈大小限制。
6. 用这套题集做笔试复盘:我的三遍刷题法和一个验证脚本
第一遍按顺序做,不看答案,把每道题的推导过程写在纸上。重点不是做对,而是暴露自己的思维盲区。我第一遍做sizeof(str2)时写了 5,翻答案才发现strlen结果是未定义的——这个错误让我重新理解了字符串结束符的底层含义。
第二遍只做错题,并且用编译器验证。比如大小端判定,我写了个小脚本在本地跑:
#include <stdio.h> int main() { // 验证大小端 union { int i; char c[4]; } u; u.i = 0x12345678; printf("c[0]=0x%02x, c[3]=0x%02x\n", u.c[0], u.c[3]); // 小端输出:c[0]=0x78, c[3]=0x12 // 验证 sizeof 和 strlen char str1[] = "Hello"; char str2[5] = {'H','e','l','l','o'}; printf("sizeof(str1)=%zu, strlen(str1)=%zu\n", sizeof(str1), strlen(str1)); // sizeof(str1)=6, strlen(str1)=5 // 验证结构体指针偏移 struct BBB { long num; int index; char *name; } *p = (struct BBB *)0x1000000; printf("p+0x200 = 0x%lx\n", (unsigned long)(p + 0x200)); // 0x1001800(32 位系统) return 0; }编译命令:
gcc -m32 -o test_endian test_endian.c ./test_endian-m32强制 32 位编译,确保指针大小和结构体对齐与题目一致。如果系统没有 32 位库,去掉-m32后指针大小变成 8 字节,sizeof(p2)会变成 16 而不是 8,结构体偏移也会变。
第三遍把题目按知识点归类,找出反复出错的概念。我的错题集中在三个地方:数组退化的边界条件、结构体对齐对偏移量的影响、volatile在多线程场景下的正确用法。针对每个薄弱点,我写了一个最小验证程序,跑通后再回头看题目,基本就不会再错了。
从那以后我每次遇到指针偏移和sizeof相关的题,都强制自己在纸上画出内存布局图再算——这个习惯帮我避开了至少三次笔试里的低级错误。希望帮到你。
本文还有配套的精品资源,点击获取