C语言笔试真题复盘:从sizeof到线程通信的核心考点解析
简介这是一份面向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)65数组大小含\0strlen 不含sizeof(str2)5未知无\0strlen 越界读取sizeof(str3)65显式写了\0结果同 str1sizeof(p1)4—指针大小32 位系统固定 4 字节sizeof(p2)8—两个指针的数组2×48sizeof(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 结构体指针偏移p0x200 到底加了多少字节这是资料里最容易算错的一道题struct BBB { long num; // 4 字节32 位系统 int index; // 4 字节 char *name; // 4 字节 } *p; p 0x1000000;sizeof(BBB) 12。三个表达式的计算结果表达式计算过程结果p 0x2000x1000000 0x200 × 120x1001800(unsigned long)p 0x2000x1000000 0x2000x1000200(char *)p 0x2000x1000000 0x200 × 10x1000200关键区别在于指针的步长。p是struct BBB *类型p n实际偏移n × sizeof(BBB)字节。强制转成unsigned long后加法变成纯数值运算加多少就是多少。转成char *后步长为 1 字节结果和数值加法一致。数组地址偏移也是同一套逻辑。资料里给了五条规则核心记住二维数组的展开方式int a[2][3] {1,2,3,4,5,6}; // *(*(a1)1) 5 // a1 指向第二行*(a1) 取第二行首地址1 移到第二个元素对于字符指针数组char *p[] {ABC,DEF}*(*(p1)1)取的是E——先定位到第二个字符串再取第二个字符。3.3 大小端存储的判定与代码验证0x12345678在内存中的存放顺序大端和小端完全相反地址偏移大端模式小端模式0x000012 (OP0)78 (OP3)0x000134 (OP1)56 (OP2)0x000256 (OP2)34 (OP1)0x000378 (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/memcpy4.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(p0x200 0x%lx\n, (unsigned long)(p 0x200)); // 0x100180032 位系统 return 0; }编译命令gcc -m32 -o test_endian test_endian.c ./test_endian-m32强制 32 位编译确保指针大小和结构体对齐与题目一致。如果系统没有 32 位库去掉-m32后指针大小变成 8 字节sizeof(p2)会变成 16 而不是 8结构体偏移也会变。第三遍把题目按知识点归类找出反复出错的概念。我的错题集中在三个地方数组退化的边界条件、结构体对齐对偏移量的影响、volatile在多线程场景下的正确用法。针对每个薄弱点我写了一个最小验证程序跑通后再回头看题目基本就不会再错了。从那以后我每次遇到指针偏移和sizeof相关的题都强制自己在纸上画出内存布局图再算——这个习惯帮我避开了至少三次笔试里的低级错误。希望帮到你。本文还有配套的精品资源点击获取