C语言面试题手册

梳理高频技术问题,帮助你按主题复习和查漏补缺。

服务端阅读 06月4日 13:44

C语言指针和数组有什么区别?sizeof、退化和 &arr 一次讲清

"数组名就是指针"——这句话对了一半,但在关键地方是错的。面试官最爱问的就是那一半:sizeof(arr) 和 sizeof(ptr) 差多少?&arr 和 arr 类型一样吗?数组传进函数后还能 sizeof 吗?搞不清这些,写代码时遇到诡异的 bug 也排查不了。本质区别:存储的是什么数组是一块连续内存,里面存的是数据本身。指针是一个变量,里面存的是地址。int arr[5] = {1, 2, 3, 4, 5}; // 20 字节连续内存,存了 5 个 intint *ptr = arr; // 8 字节(64 位系统),存的是 arr[0] 的地址arr 和 ptr 都能通过下标访问元素,arr[2] 和 ptr[2] 效果一样——这是"对了一半"的来源。但底层机制不同:数组直接通过基地址 + 偏移计算,指针先从指针变量里读出地址,再算偏移。sizeof:最直观的区别int arr[10];int *ptr = arr;sizeof(arr); // 40 = 10 * 4,整个数组的大小sizeof(ptr); // 8,指针变量本身的大小(64 位系统)sizeof 对数组名返回整个数组占用的字节数,对指针返回指针变量的大小。这是两者最可靠的区分方式——如果你在函数里对一个参数用 sizeof 试图获取数组大小,拿到的是指针大小而不是数组大小,因为数组已经退化了。赋值:数组名不能被赋值int arr[10];int *ptr;ptr = arr; // 合法:指针指向数组首元素arr = ptr; // 非法:数组名是常量地址,不能赋值arr++; // 非法:同上ptr++; // 合法:指针可以移动数组名在大多数表达式中代表数组首元素的地址,但它本身不是指针变量——没有独立的存储空间存放这个地址,它只是一个编译期常量。你不能修改一个常量。数组退化:传参时数组变成指针数组作为函数参数传递时,自动退化为指向首元素的指针:void func(int arr[]) { // 这里的 arr 是指针,不是数组 sizeof(arr); // 8,不是原数组的大小}int main(void) { int data[100]; func(data); // data 退化为 int*}函数签名里的 int arr[] 和 int *arr 完全等价,编译器看到的都是 int*。所以函数内部无法通过 sizeof 获取数组长度——必须额外传一个长度参数。这就是为什么 C 标准库的 qsort、memset、memcpy 都需要你传大小。&arr 和 arr:值相同,类型不同这是一个经典面试题:int arr[10];arr; // 类型:int*,指向首元素&arr; // 类型:int (*)[10],指向整个数组的指针两者的数值相同(都是数组起始地址),但类型不同,指针运算的步长不同:arr + 1; // 偏移 sizeof(int) = 4 字节,指向 arr[1]&arr + 1; // 偏移 sizeof(int[10]) = 40 字节,指向整个数组之后&arr 是"数组指针",指向的对象是整个数组;arr 退化为"元素指针",指向的对象是单个 int。类型不同导致指针算术的行为完全不同。指针数组 vs 数组指针这两个名字容易搞混,拆开读就清晰了:int *arr[5]; // 指针数组:5 个元素的数组,每个元素是 int*int (*ptr)[5]; // 数组指针:一个指针,指向 int[5] 类型的数组读法技巧:找核心名词——arr[5] 说明 arr 是数组(指针数组),(*ptr) 说明 ptr 是指针(数组指针)。指针数组的典型用途:字符串数组、函数指针表、不规则多维数组(每行长度不同)。数组指针的典型用途:二维数组传参 void func(int (*matrix)[5], int rows)。下标运算的本质arr[i] 和 *(arr + i) 完全等价——C 语言的下标运算就是指针算术的语法糖。甚至 i[arr] 和 arr[i] 也是等价的,因为 *(i + arr) 和 *(arr + i) 一样。当然,写 i[arr] 只是炫技,别在项目里这么写。对于多维数组,arr[i][j] 等价于 *(*(arr + i) + j)——先偏移到第 i 行,再偏移到第 j 列。什么时候用指针,什么时候用数组大小固定、生命周期明确:用数组。栈上分配,无需手动管理大小运行时确定、需要动态分配:用 malloc + 指针函数参数传数组:不可避免退化,额外传长度字符串字面量:char str[] = "hello" 是拷贝到栈上的数组,可修改;char *str = "hello" 指向只读段,修改行为未定义
服务端阅读 06月4日 13:42

C语言内存泄漏怎么排查?五种泄漏模式和三个检测工具

C 语言没有垃圾回收,内存管理全靠程序员手动 malloc/free。这种自由度换来的代价就是内存泄漏——分配了内存却没释放,进程的内存占用只增不减。短命程序无所谓,长期运行的服务端进程泄漏几十字节,跑几天就可能吃掉几个 G。五种常见泄漏模式忘了 free最直接的原因,代码写着写着就漏了:void process(void) { char *buf = malloc(1024); do_something(buf); // 函数结束,buf 没释放,1KB 泄漏}更隐蔽的情况是提前 return:函数中间某个错误检查直接 return -1,跳过了末尾的 free。每个 return 路径都必须有对应的释放逻辑,漏一条就是泄漏。指针覆盖把唯一指向已分配内存的指针覆盖掉了,想 free 都找不到:char *name = malloc(100);strcpy(name, "original");name = malloc(200); // 原来的 100 字节再也找不回来realloc 也有这个坑——如果 realloc 返回 NULL(分配失败),原指针仍然有效,但很多人写成 ptr = realloc(ptr, new_size),失败时原指针丢失,既泄漏又悬空。正确写法:void *tmp = realloc(ptr, new_size);if (tmp) { ptr = tmp;} else { // ptr 仍然有效,可以继续使用或 free}双重释放同一块内存 free 两次,堆管理器的内部数据结构被破坏,后续的 malloc/free 行为不可预测——可能立刻崩溃,也可能跑很久才出问题:free(ptr);// ... 一大段代码 ...free(ptr); // double free,堆被破坏根本原因通常是两个模块都认为自己"拥有"这块内存的所有权。free 之后把指针置 NULL 是防御手段——free(NULL) 是安全的,不会出错:free(ptr);ptr = NULL;错误路径泄漏函数有多个 return 点,某个错误分支忘了释放:int load_config(void) { char *buf = malloc(4096); if (read_file("config", buf) < 0) { return -1; // 泄漏:buf 没 free } if (parse(buf) < 0) { free(buf); return -1; // 这里正确释放了 } free(buf); return 0;}goto-free 模式是 C 语言中处理这种多退出点的惯用法:所有资源在函数末尾统一释放,错误路径用 goto cleanup 跳过去。不止是内存"内存泄漏"这个说法容易让人只盯着 malloc/free,但文件描述符(open 忘 close)、socket、临时文件、子进程(fork 忘 waitpid 导致僵尸进程)本质上都是资源泄漏,后果一样严重。文件描述符泄漏比内存泄漏更阴险——进程默认只能打开 1024 个 fd(ulimit -n),泄漏几十个就打不开新文件了。检测工具Valgrind最全面的内存问题检测工具,不用改代码、不用重新编译,直接运行:valgrind --leak-check=full --show-leak-kinds=all ./my_program输出会告诉你:哪一行 malloc 的内存没释放(definitely lost),哪些可能泄漏(indirectly lost),哪些还在用(still reachable)。重点关注 definitely lost。缺点:慢。Valgrind 通过 JIT 模拟每条指令,程序跑起来慢 10-50 倍。不适合长时间运行的程序,通常用短测试用例跑。AddressSanitizer (ASan)编译器内置的检测,GCC 和 Clang 都支持:gcc -fsanitize=address -g -O1 program.c -o program./programASan 运行时开销比 Valgrind 小得多(约 2x),但只能检测当前编译的代码,不能检测动态库。它会捕获越界访问和 use-after-free,但内存泄漏检测靠 LeakSanitizer(LSan),需要加 -fsanitize=leak 或 ASan 默认包含。mtraceglibc 提供的轻量级追踪,在代码里加两行:#include <mcheck.h>int main(void) { mtrace(); // 开始追踪 // ... 你的代码 ... muntrace(); // 结束追踪 return 0;}运行时设置环境变量 MALLOC_TRACE=out.txt,程序结束后用 mtrace 命令分析输出文件。比 Valgrind 轻量,但只报告未配对的 malloc/free,不能检测越界。手动审计清单没有工具的时候,代码审查是最后的防线:每个 malloc/calloc/realloc 是否都有对应的 free?每个 return 路径是否都释放了已分配的资源?realloc 的返回值是否用了临时变量接收?free 之后指针是否置 NULL?错误处理分支是否遗漏了清理逻辑?文件描述符和 socket 是否都关闭了?
服务端阅读 06月4日 13:41

C语言结构体内存对齐怎么算?三条规则和逐字节推演

结构体内存对齐是 C 语言面试的经典问题,也是实际项目中踩坑率很高的话题——sizeof 打出来的结果比预想的大,序列化/反序列化时数据错位,跨平台通信时结构体大小不一致,根源都在对齐和填充。为什么需要对齐CPU 访问内存不是逐字节的,而是按字长(32 位系统 4 字节,64 位系统 8 字节)一次读取。如果一个 int 跨了两次读取的边界,CPU 要读两次再拼接,性能下降。某些架构(如 ARM、SPARC)直接抛硬件异常。所以编译器在结构体成员之间插入填充字节,让每个成员的起始地址落在自己"自然边界"上——这就是对齐。三条对齐规则成员对齐:每个成员的偏移量必须是该成员大小的整数倍。不够就补填充字节结构体总大小:必须是最大成员大小的整数倍。不够就在末尾补填充字节嵌套结构体:嵌套的结构体对齐到其自身最大成员大小的整数倍逐字节推演struct A { char c; // 偏移 0,占 1 字节 // 偏移 1-3:3 字节填充(因为 int 要对齐到 4 的倍数) int i; // 偏移 4,占 4 字节};// 总大小:8 字节(1 + 3填充 + 4,已是 4 的倍数,末尾不需补)换一个成员顺序,浪费更多:struct B { char c1; // 偏移 0,占 1 字节 // 偏移 1:1 字节填充(short 要对齐到 2 的倍数) short s; // 偏移 2,占 2 字节 char c2; // 偏移 4,占 1 字节 // 偏移 5-7:3 字节填充(总大小必须是最大成员 short 的 2 字节的倍数?不对——最大成员是 short 大小 2,所以总大小要是 2 的倍数。5+1=6,但 int 没出现,最大对齐数是 2,6 是 2 的倍数,所以总大小 6?不,实际要看最大成员。这里最大成员 short 是 2 字节,所以结构体对齐到 2。6 已是 2 的倍数,总大小 6。)};// 实际总大小:6 字节再来看一个浪费严重的例子:struct Bad { char c1; // 偏移 0,1 字节 // 偏移 1-7:7 字节填充 double d; // 偏移 8,8 字节 char c2; // 偏移 16,1 字节 // 偏移 17-23:7 字节填充(总大小须为 8 的倍数)};// 总大小:24 字节——3 个成员实际只用了 10 字节,浪费了 14 字节调整成员顺序,把大的放前面:struct Good { double d; // 偏移 0,8 字节 char c1; // 偏移 8,1 字节 char c2; // 偏移 9,1 字节 // 偏移 10-15:6 字节填充(总大小须为 8 的倍数)};// 总大小:16 字节——比 Bad 少了 8 字节优化方法成员按大小降序排列最简单有效的手段:把 double 放前面,int 次之,short 再次,char 最后。填充字节最少。大型项目(如 Linux 内核)有专门的脚本检查结构体填充浪费。#pragma pack 紧凑对齐网络协议头、文件格式头等场景需要精确控制布局,可以用编译器指令取消对齐:#pragma pack(push, 1) // 保存当前对齐设置,设为 1 字节对齐struct PacketHeader { char type; int length; short flags;};#pragma pack(pop) // 恢复之前的对齐设置1 字节对齐下没有填充,sizeof(struct PacketHeader) = 7。但访问 length 可能不对齐,某些架构上性能下降甚至崩溃。所以 #pragma pack 只用于和外部协议对接的场合,不要在内部数据结构上滥用。位域用位域把多个小字段压缩到一个基本类型里:struct Flags { unsigned int ready : 1; unsigned int error : 1; unsigned int mode : 6; // 总共 8 位,1 字节就够};注意:位域的内存布局依赖编译器实现,跨编译器/跨平台不保证一致。不同编译器分配位域的方向可能不同,用于网络通信时要格外小心。跨平台注意事项对齐数不同:32 位和 64 位系统上,double 的对齐要求可能不同(4 vs 8),同一个结构体大小可能不一样指针大小不同:32 位指针 4 字节,64 位 8 字节,含指针的结构体大小不同字节序不同:大端/小端影响多字节成员的存储顺序,和填充无关但和序列化相关网络传输:不要直接 send/recv 结构体——填充、对齐、字节序都可能不一致。正确做法是逐字段序列化,或用 #pragma pack(1) 的专用协议结构体
服务端阅读 06月4日 13:39

C语言volatile关键字有什么用?四个场景和三个常见误区

volatile 这个关键字,很多人知道它是"防止编译器优化",但具体优化了什么、为什么需要防、什么场景该用,一深问就答不上来。更关键的是,C 语言的 volatile 和 Java 的 volatile 完全不是一回事——前者只管编译器,不管内存屏障;后者有 happens-before 语义。把 Java 那套理解搬到 C 里,会写出并发 bug。volatile 到底防了什么优化编译器看到一段代码反复读同一个变量,且中间没有写入,就会把值缓存到寄存器里,省去内存访问。这对普通变量是好事,但如果变量的值可能被外部因素改变——硬件、中断、另一个线程——缓存就会导致读到旧值。看一个典型的优化问题:int flag = 0;// 线程 1while (!flag) { // 编译器可能优化为:先读 flag,如果为 0 则死循环, // 不再重复读取内存中的 flag}加 volatile 之后,编译器每次都从内存重新读取:volatile int flag = 0;while (!flag) { // 每次循环都从内存读取 flag,能感知到外部修改}核心规则只有一条:对 volatile 变量的读/写操作,不会被编译器优化掉或重排,每次都必须真正访问内存。四个典型使用场景硬件寄存器访问嵌入式开发里,外设的状态寄存器映射到内存地址。读这个地址,拿到的不是上次写的值,而是硬件当前的状态——编译器不知道这回事,如果不加 volatile,可能直接复用寄存器缓存里的旧值:volatile uint32_t *status = (volatile uint32_t*)0x40000000;while (*status & 0x01) { // 等待硬件清零,每次都从地址重新读}中断服务程序(ISR)主循环在等一个标志位,中断里设置这个标志位。不加 volatile,编译器可能认为主循环里没人修改这个变量,直接优化成死循环:volatile int interrupt_flag = 0;void ISR_Handler(void) { interrupt_flag = 1; // 中断里置位}int main(void) { while (!interrupt_flag) { // 不加 volatile,编译器可能优化为 while(1) }}信号处理POSIX 信号处理函数里修改的变量,必须用 volatile sig_atomic_t 类型。这是标准要求的,不是建议:#include <signal.h>volatile sig_atomic_t signal_received = 0;void handler(int sig) { signal_received = 1;}sig_atomic_t 保证信号处理函数中的读写是原子的,volatile 保证编译器不会优化掉对它的访问。多线程共享变量(有限作用)多线程场景下 volatile 能保证每次读到最新值,但不能保证原子性,也不提供内存屏障。volatile int counter; 执行 counter++ 在多线程下仍然是竞态条件——++ 不是原子操作(读-改-写三步)。C 语言中多线程共享变量应该用 C11 的 _Atomic 或配合互斥锁。volatile 在这里只解决"可见性"问题,不解决"竞态"问题。volatile 不做什么这一点值得单独强调,因为误解最多:不是原子操作:volatile int x; x++; 不是线程安全的不是内存屏障:C 语言的 volatile 不阻止 CPU 或编译器对其他非 volatile 变量的重排。Java 的 volatile 有 happens-before 语义,C 的没有——两者名字一样,语义不同不能替代锁:需要互斥访问的场景,必须用 mutex 或 _Atomic不影响对齐和存储:volatile 只影响访问方式,不改变变量的布局常见误区"多线程共享变量加 volatile 就安全了"——这是最危险的误解。volatile 只保证每次从内存读,不保证读-改-写是原子的。两个线程同时 counter++,即使 counter 是 volatile,仍然会丢更新。"volatile 和 const 不能一起用"——可以。volatile const uint32_t *reg 表示"这个寄存器的值会自己变(volatile),但我的代码不能写它(const)",在嵌入式开发里很常见——只读状态寄存器就是这种类型。"编译器 -O0 就不需要 volatile"——碰巧在 -O0 下编译器不太做优化,所以没 volatile 也可能正常。但这不是正确做法,换 -O2 立刻出 bug,而且 debug 和 release 行为不一致反而更难排查。
服务端阅读 06月4日 13:36

C语言函数指针和回调函数怎么用?原理与常见坑一次讲清

C语言里的函数指针,是不少人学了多年 C 仍然含糊的概念。倒不是因为它多复杂——本质上就是"把函数的入口地址存到变量里"——但声明语法看着劝退,项目里该用的时候又想不起来。回调函数更甚:知道 qsort 要传比较函数,但让自己设计一个事件系统,就不知道从哪下手了。这篇文章从函数指针的声明和调用讲起,再到回调机制的原理和工程实践,最后说清楚容易踩的坑。函数指针:存的是函数入口地址函数编译后加载到内存,函数名就是入口地址。把这个地址赋给一个变量,这个变量就是函数指针。声明方式看着别扭,但拆开看规律很清晰:int (*fp)(int, int); // 指向「两个int参数、返回int」的函数核心语法:返回类型 (*指针名)(参数列表)。(*指针名) 外面的括号不能省——省了就变成声明一个返回 int* 的函数,即指针函数。这两者经常被搞混:| | 函数指针 | 指针函数 ||---|---|---|| 本质 | 指向函数的指针 | 返回指针的函数 || 声明 | int (*p)(int) | int* f(int) || * 归属 | 跟指针变量名结合 | 跟返回类型结合 |用 typedef 简化声明实际项目里函数指针的声明几乎都用 typedef 包一层,否则可读性极差:typedef int (*CompareFunc)(const void*, const void*);CompareFunc cmp = my_compare; // 之后直接当类型名用C 标准库 qsort 的第四个参数,不用 typedef 的话长这样:int (*)(const void*, const void*)——每次手写都是折磨。函数指针数组多个同类型函数指针放进数组,用下标切换——这是状态机和命令分发的基础写法:void state_idle(void) { /* 空闲状态处理 */ }void state_running(void) { /* 运行状态处理 */ }void (*states[])(void) = { state_idle, state_running };states[current_state](); // O(1) 跳转,比 switch-case 更干净新增状态只需加一个函数和数组元素,不用改动分发逻辑。嵌入式开发和网络协议解析里特别常见。回调函数:把函数当参数传给别人回调的本质:你定义一个函数,但不自己调用,而是把函数指针传给另一个函数,让对方在合适的时候反过来调用你。最经典的例子:qsortint compare_asc(const void* a, const void* b) { return *(int*)a - *(int*)b;}int arr[] = {5, 2, 8, 1, 9};qsort(arr, 5, sizeof(int), compare_asc);qsort 不关心升序还是降序,它只通过你传的比较函数来决定顺序。想降序?把 a - b 换成 b - a 就行。回调怎么传递上下文数据C 语言没有闭包,回调函数拿不到外部变量。标准做法是多传一个 void* 参数:typedef void (*Callback)(int result, void* ctx);void async_read(Callback cb, void* ctx) { int r = do_read(); cb(r, ctx); // 原样把上下文传回去}调用方把结构体指针转成 void* 传进去,回调里再转回来。GLib、libevent、libuv 都采用这个模式。qsort 没有设计这个参数是个遗憾,实际项目里只好用全局变量绕过,既不优雅也不线程安全。事件驱动模型回调是事件驱动的基础设施:GUI 框架注册按钮点击回调,网络库注册连接/断开回调,操作系统注册信号处理回调——本质上都是"你告诉我事件发生时该调谁"。libuv 的事件循环就是典型的回调驱动架构。容易踩的坑类型不匹配:函数指针类型必须严格匹配返回值和参数列表。强制类型转换后调用,栈帧错乱,调试极难定位——有时能跑有时崩,症状不稳定。空指针调用:回调没注册就被触发,函数指针是 NULL。调用前必须检查 if (fp != NULL)。过期指针:dlopen 加载动态库拿到函数指针,dlclose 之后还调用——段错误。JIT 编译的代码被回收后继续调用也一样。qsort 比较函数语义搞反:返回值是正/零/负,不是 true/false。搞反了排序结果全错但不报错,排查半天找不到原因。多线程竞态:一个线程注册回调,另一个线程触发回调,没有同步保护。轻则数据错乱,重则崩溃。回调的注册和触发必须加锁或用原子操作。
服务端阅读 06月4日 10:43

C语言 typedef 和 #define 有什么区别?指针声明陷阱详解

typedef 是编译器层面的类型别名,#define 是预处理器层面的文本替换——一个在编译时做类型检查,一个在编译前做字符串替换,这是最根本的区别。最经典的面试陷阱:连续声明指针时结果不同。typedef char* PCHAR; PCHAR a, b; 中 a 和 b 都是指针;而 #define PCHAR char*; PCHAR a, b; 展开后变成 char* a, b;,只有 a 是指针,b 是普通 char——这个 bug 编译器不会报错,能让你查半天。另一个容易踩的坑:宏函数的副作用。#define SQUARE(x) ((x) * (x)) 传入 SQUARE(i++) 会导致 i 自增两次,而 typedef 不存在这个问题,因为它只管类型不管值。// 连续声明:typedef 安全,#define 不安全typedef char* PCHAR;PCHAR a, b; // a 和 b 都是 char*#define PCHAR2 char*PCHAR2 c, d; // c 是 char*,d 是 char!一条原则:需要类型别名的用 typedef,需要文本替换的用 #define。函数指针、结构体、跨平台类型定义必须用 typedef;常量定义、条件编译、简单宏函数用 #define。追问typedef 和 #define 在函数指针定义上有什么区别?typedef 写出来可读性强得多:typedef int (*Callback)(int, int); 之后直接用 Callback cb = my_func;。用 #define 的话 #define Callback int (*)(int, int) 看着就像函数声明,而且 Callback cb, cb2; 同样只有第一个是指针。函数指针定义是 typedef 最不可替代的场景。#define 定义的常量为什么没有类型安全?因为 #define 在预处理阶段就被替换掉了,编译器根本看不到宏名。#define MAX 100 之后代码里出现的是裸数字 100,编译器不知道你原来写了 MAX,也不知道你想让它是什么类型——int、long 还是 unsigned 全靠上下文推断。而 const int MAX = 100; 或 typedef enum { MAX = 100 } 都有明确的类型。typedef 能不能和存储类说明符一起用?不能。typedef static int MyInt; 是非法的——typedef 是存储类说明符的一种,和 static、extern 互斥。这是 C 标准明确规定的,编译器会直接报错。同理 typedef extern int X; 也不行。实际项目里 typedef 最常见的用法是什么?三个场景:(1) 给 unsigned long long 之类的长类型起短名,比如 typedef uint64_t u64;(2) 定义回调/函数指针类型,让函数签名可读;(3) 跨平台抽象——typedef long intptr_t 在 64 位 Linux 上,typedef __int64 intptr_t 在 Windows 上,业务代码统一用 intptr_t 不用关心底层。
服务端阅读 06月4日 10:34

C语言 const 关键字怎么用?指针组合和实战场景详解

const 告诉编译器"这个值不许改"——但只限于编译期检查,运行时通过指针强转照样能绕过,所以 const 更像是"程序员之间的约定"而非硬件级的不可变。最核心的考点是 const 和指针的组合,读法从右往左:const int *p → p is a pointer to int const(指向的值不能改);int *const p → p is a const pointer to int(指针本身不能改)。一个快速判断法:const 在 * 左边修饰数据,在 * 右边修饰指针。实际项目中最有价值的用法是函数参数加 const:void process(const char *input, const int *data, size_t len),既防止函数内部意外修改输入数据,又让调用者一看签名就知道"这个函数不会动我的数据"。// 最实用的 const 模式:只读参数size_t str_len(const char *s) { size_t n = 0; while (s[n]) n++; return n;}// 调用者知道 s 不会被修改,编译器也敢做优化追问const 变量和 #define 宏有什么区别?const 变量有类型、有作用域、调试器能看到名字和值。#define 是预处理器的文本替换,没有类型、没有作用域、调试时只看到魔法数字。但 const 变量在 C 里不是真正的编译期常量——不能用来定义数组大小(VLA 除外),也不能用在 case 标签里,这是 C 和 C++ 的重大区别。const 指针到底能不能绕过?能。const int x = 10; int *p = (int*)&x; *p = 20; 编译通过,但修改 const 对象是未定义行为——编译器可能把 x 的值缓存在寄存器里,修改内存后读到的还是旧值。所以能绕过不代表该绕过,UB 的 bug 比 const 违规难查一百倍。函数返回 const 指针有什么用?防止调用者通过返回的指针修改内部数据。典型场景:const char* get_error_msg(int code),返回指向静态字符串的指针,加 const 让调用者知道不该写这块内存。如果不加 const,调用者 ((char*)get_error_msg(0))[0] = X 就能改掉内部字符串,出问题时很难定位。const 和 volatile 能同时用吗?能,而且嵌入式开发中很常见:const volatile uint32_t *hw_reg = (void*)0x4000;——const 表示程序不该写这个寄存器,volatile 表示每次读都必须从内存取(不能缓存),因为硬件可能随时改变它的值。两者修饰的是不同层面:const 约束程序员,volatile 约束编译器。
服务端阅读 06月4日 10:30

C语言递归函数怎么优化?尾递归和记忆化实战详解

递归就是函数调用自身,每次调用在栈上压入新的栈帧(保存局部变量、返回地址),直到命中终止条件再逐层返回。三要素:终止条件、递归调用、向终止条件逼近——缺一个都会出问题。递归最大的性能杀手是重复计算。经典的斐波那契 fib(n-1) + fib(n-2) 时间复杂度 O(2ⁿ),因为同一值被反复算。两个主流解法:记忆化(用数组缓存已算过的值,降到 O(n))和尾递归(把中间结果通过参数传递,编译器可复用当前栈帧而不压新帧)。// 尾递归:累加器参数携带中间结果int fib_tail(int n, int a, int b) { if (n == 0) return a; return fib_tail(n - 1, b, a + b);}// 调用:fib_tail(10, 0, 1)关键细节:GCC 开 -O2 会把尾递归优化成跳转(相当于循环),不开优化就老老实实压栈——这意味着同样的尾递归代码在 Debug 和 Release 下行为可能完全不同。追问尾递归和普通递归在栈上的区别?普通递归返回后还有事做(比如 n * factorial(n-1) 要等乘法),必须保留栈帧。尾递归的递归调用是函数最后一步,返回值直接向上传递,当前栈帧没用了,编译器可以复用它(tail call optimization)。效果上等价于 while 循环,栈深度始终是 O(1)。C 编译器一定做尾递归优化吗?不保证。C 标准没有强制要求 TCO,GCC/Clang 在 -O2 以上会做,MSVC 不做。而且只要递归调用不是严格最后一步(比如后面还有运算、或者有可观察的副作用),编译器就无法优化。所以生产代码别依赖 TCO,手写迭代更可靠。递归转迭代有什么通用方法?用显式栈模拟调用栈:把递归参数压栈,while 循环里弹栈处理,遇到需要"递归"的场景就压新参数。树的遍历是最典型的例子——前序遍历递归改迭代,就是把递归调用替换成压栈操作。什么时候该用递归,什么时候该用迭代?数据结构本身就是递归定义的(树、图、分治)用递归更自然。线性问题(求和、遍历数组)用迭代更直观也更高效。一个判断标准:如果递归深度可能超过几千层,就必须改迭代或用记忆化限制深度——Linux 默认栈大小 8MB,每帧几十字节的话,几万层就会栈溢出。
服务端阅读 06月4日 10:28

C语言联合体 union 怎么用?内存布局和实战场景详解

联合体(union)的所有成员共享同一块内存,大小等于最大成员的大小(再按最严格对齐要求补齐)。和 struct 每个成员各占各的不同,union 同一时刻只有最后写入的成员是有效的——读其他成员是未定义行为(C99 附录 J 明确标注)。三个核心使用场景:类型双关(不经过指针强转,用 union 做浮点数的二进制级操作)、变体类型(配合 enum 标记当前存的是哪种数据,俗称 tagged union)、协议解析(同一段内存既能当原始字节流读,也能按字段结构体读)。// 最常见的实用写法:tagged unionenum Tag { TAG_INT, TAG_FLOAT, TAG_STR };struct Value { enum Tag tag; union { int i; float f; char *s; } data;};一个冷知识:union 常用来检测字节序。写入 int x = 1,然后读 char 成员,如果是 1 就是小端序——很多 libc 的 endian 检测就是这么实现的。追问union 和 struct 的内存布局有什么区别?struct 的成员顺序排列,总大小是各成员大小之和(加上对齐填充)。union 的成员重叠排列,所有成员起始地址相同,总大小取最大成员再按最大对齐补齐。所以 struct 是"加法",union 是"取最大值"。读非最后写入的成员为什么是 UB?C 标准只保证读最后写入的成员是有定义的。因为不同成员可能有不同的位表示(trap representation),编译器有权假设你不会这么干,从而做激进优化。不过实践中,用 union 做 type punning(比如写 float 读 unsigned int 看位模式)在 GCC/Clang 下是扩展支持的行为,C99 TC3 之后的标准草案也倾向允许,但严格来说仍不算完全可移植。union 的对齐规则是什么?union 的对齐要求等于其最严格成员的对齐要求。比如包含 double 的 union 在 64 位系统上按 8 字节对齐,即使最大成员只占 4 字节,最终 sizeof 可能是 8 而不是 4。可以用 _Alignof(C11)或 __alignof__ 查实际对齐。实际项目里怎么安全地用 union?永远搭配 enum 标记使用(tagged union 模式)。写入某个成员前先设好 tag,读取前先检查 tag——任何跳过 tag 检查直接访问 union 成员的代码都是定时炸弹。C 语言没有内置的 sum type,tagged union 就是最接近的替代。
服务端阅读 06月4日 10:22

C语言枚举类型 enum 怎么用?有哪些实战技巧和常见坑?

枚举(enum)是 C 语言用名字代替整数的机制,让代码可读性远超裸数字。编译器把枚举常量替换成对应的 int 值,默认从 0 递增,也可手动指定。实际项目中最常见的三种用法:状态机(用枚举定义状态流转)、错误码(比 #define 更集中、调试器能显示名字)、位标志(每个值占一个 bit,按位或组合权限)。配合 typedef 省掉每次写 enum 的前缀,配合 switch 编译器会警告漏掉的 case——这是枚举最被低估的价值。// 位标志:一个变量表示多个开关enum Perm { READ = 1 << 0, // 0x01 WRITE = 1 << 1, // 0x02 EXECUTE = 1 << 2 // 0x04};unsigned int perm = READ | WRITE; // 读写权限if (perm & READ) { /* 可读 */ }一个容易忽略的技巧:在枚举末尾放一个 COUNT 成员,既标记了有效值范围,又能直接用来定义数组大小或做循环边界——for (int i = 0; i < COLOR_COUNT; i++)。追问enum 和 #define 定义常量有什么区别?enum 有类型信息(尽管 C 的检查很弱),调试时能显示枚举名而非裸数字,作用域遵循普通变量规则。#define 是预处理器文本替换,没有类型、没有作用域、调试时只看到数字。多个相关常量用 enum 聚在一起比散落的 #define 好维护得多。枚举值不连续有什么影响?编译没问题,但不能当数组下标遍历。需要遍历就保持值连续,并在末尾加 COUNT。如果业务要求不连续(比如错误码分段),那就老老实实用查表法映射。sizeof(enum) 到底多大?C 标准让编译器自行选择能容纳所有值的整数类型,通常是 int(4 字节)。C23 新增了指定底层类型的语法 enum Color : uint8_t,GCC/Clang 也支持 __attribute__((packed)) 选最小类型,但后者不可移植。实际踩过什么坑?同一编译单元内不同 enum 的成员名不能重复,大型项目里很容易撞名。解法是加前缀:CONN_STATE_IDLE、TASK_STATE_IDLE。另一个坑:C 不阻止把任意整数强转成枚举,enum Color c = 999; 编译器最多警告不报错,运行时行为未定义。
服务端阅读 06月1日 14:53

C语言预处理器指令有哪些?#define 宏有什么坑?

C 预处理器在编译前处理源代码,处理 #include、#define、#ifdef 等指令。它做的是文本替换,不是代码编译——这意味着宏没有类型检查、没有作用域,出错了很难调试。追问#define 宏有什么坑?经典陷阱:#define SQUARE(x) x * x,调用 SQUARE(1+2) 展开成 1+2 * 1+2,结果是 5 不是 9。正确写法要给参数和整体都加括号:#define SQUARE(x) ((x) * (x))。但即使这样,SQUARE(i++) 还是会让 i 自增两次——宏是文本替换,不是函数调用。能用 inline 函数就别用宏。#include 尖括号和双引号有什么区别?<header.h> 在系统目录和编译器指定路径中搜索,"header.h" 先在当前文件所在目录搜索,找不到再到系统目录。所以系统头文件用 <>,自己的头文件用 ""。这只是搜索顺序的区别,不是语法限制——用 #include <myheader.h> 也行,只是可能找不到。条件编译有哪些常见用法?三种最常用的模式:1) 跨平台适配(#ifdef _WIN32 ... #else ... #endif);2) 调试开关(#ifdef DEBUG ... #endif,release 编译时整块代码不进去);3) 头文件防重复包含(#ifndef MYHEADER_H ... #define MYHEADER_H ... #endif,或者 #pragma once)。条件编译的好处是编译器直接跳过不需要的代码,零运行时开销。#pragma once 和 include guard 有什么区别?#ifndef MYHEADER_H / #define MYHEADER_H / #endif 是标准 C 写法,所有编译器都支持,但要自己起宏名,可能撞名。#pragma once 更简洁,编译器用文件路径去重,不用起宏名。但 #pragma once 不是标准,理论上编译器可以不支持——实际上所有主流编译器都支持。项目里选一种统一用就行,别混着来。宏的 # 和 ## 是干什么用的?# 把宏参数转成字符串(字符串化),## 把两个 token 拼接成一个(token 粘合):#define STR(x) #x // STR(hello) → "hello"#define CONCAT(a, b) a##b // CONCAT(var, 1) → var1在生成枚举名称、注册函数表这些元编程场景中偶尔用到,日常代码很少用。面试知道就行,别主动写这种代码。预处理和编译是什么关系?编译分四个阶段:预处理 → 编译 → 汇编 → 链接。预处理阶段只做文本操作(替换宏、插入头文件、删除条件编译排除的代码),不检查语法。所以宏写错了,报错信息可能非常难懂——因为编译器看到的是展开后的代码,不是你写的源码。gcc -E file.c 可以查看预处理后的结果,调试宏时很有用。
服务端阅读 06月1日 14:52

C语言位运算符怎么用?设置、清除、翻转一位怎么做?

C 语言有 6 个位运算符:&(与)、|(或)、^(异或)、~(取反)、<<(左移)、>>(右移)。位运算直接操作二进制位,在标志位管理、数据压缩、硬件寄存器操作中不可替代。追问位运算怎么设置、清除、翻转、检查某个位?这是位运算面试的必考题,记住四个套路:#define BIT(n) (1 << n)flags |= BIT(3); // 设置第 3 位flags &= ~BIT(3); // 清除第 3 位flags ^= BIT(3); // 翻转第 3 位if (flags & BIT(3)) {} // 检查第 3 位是否为 1原理:| 遇 1 则 1(设置),& ~ 把目标位变 0 其余不变(清除),^ 遇 1 翻转遇 0 不变(翻转),& 提取目标位(检查)。异或有什么特殊性质?三个重要性质:1) 任何数异或自己为 0(a ^ a == 0);2) 任何数异或 0 不变(a ^ 0 == a);3) 交换律和结合律。实际应用:不用临时变量交换两个数(a ^= b; b ^= a; a ^= b;)、找数组中只出现一次的数字(其他都出现两次时,全部异或一遍结果就是那个单独的数)、简单加密。左移和右移有什么坑?左移 << 低位补 0,相当于乘以 2 的幂,没歧义。右移 >> 对无符号数高位补 0(逻辑移位),对有符号数高位补什么取决于编译器——可能补符号位(算术移位),也可能补 0。所以对有符号数做右移是未定义行为(实现定义),面试时说清楚这个区别。另外移位量不能超过类型的位宽,int x; x << 32 在 32 位 int 上是未定义行为。位运算和乘除法哪个快?现代编译器会自动把乘除 2 的幂优化成移位,手写 x >> 2 和 x / 4 生成的汇编基本一样。所以别为了"性能"写移位代替除法——可读性更值钱。唯一需要手写位运算的场景是硬件寄存器操作、协议字段编解码,这时候用位运算是表达意图,不是优化性能。位运算优先级有什么坑?位运算优先级低于比较运算符。if (flags & 0x01 != 0) 实际解析为 if (flags & (0x01 != 0)),先算不等式再位与,结果完全不对。正确写法要加括号:if ((flags & 0x01) != 0)。这也是 C 语言最常见的一类 bug。写段代码// 打包两个 8 位值到 16 位uint16_t pack(uint8_t hi, uint8_t lo) { return ((uint16_t)hi << 8) | lo;}// 解包uint8_t hi = pack(0xCD, 0xAB) >> 8; // 0xCDuint8_t lo = pack(0xCD, 0xAB) & 0xFF; // 0xAB
服务端阅读 06月1日 14:51

C语言字符串函数有哪些坑?strcpy 和 strncpy 有什么区别?

C 语言字符串就是以 \0 结尾的字符数组,标准库 <string.h> 提供了一组操作函数。面试重点不在背函数签名,而在理解哪些函数安全、哪些有坑、为什么有坑。追问strcpy 和 strncpy 有什么区别?哪个更安全?strcpy 无条件拷贝直到遇到 \0,目标缓冲区不够大就溢出——这是 C 语言最经典的安全漏洞来源。strncpy 限定了最大拷贝长度,但有个坑:如果源字符串比指定长度长,拷贝后不会自动补 \0,目标字符串可能不是合法的 C 字符串。正确用法:strncpy(dest, src, sizeof(dest) - 1);dest[sizeof(dest) - 1] = "\0"; // 手动保底所以 strncpy 不是"更安全的 strcpy",而是"需要你手动补 \0 的 strcpy"。真正安全的选择是 snprintf:snprintf(dest, sizeof(dest), "%s", src); 自动截断并补 \0。strlen 和 sizeof 有什么区别?strlen 是运行时函数,从指针位置开始数到 \0 为止,返回字符串的实际长度(不含 \0)。sizeof 是编译期运算符,返回变量或类型占用的字节大小。对数组 char s[] = "hello" 来说,sizeof(s) 是 6(含 \0),strlen(s) 是 5。对指针 char *p = "hello" 来说,sizeof(p) 是 4 或 8(指针本身大小),不是字符串长度。这是面试超高频题。strtok 为什么不是线程安全的?strtok 内部用一个静态指针记住上次分割的位置,下次调用传 NULL 就从这里继续。两个线程同时用 strtok,静态指针被互相覆盖,结果就乱了。strtok_r 是可重入版本,由调用者自己维护状态指针:char *saveptr;char *token = strtok_r(str, ",", &saveptr);while (token) { // 处理 token token = strtok_r(NULL, ",", &saveptr);}strcat 有什么问题?怎么安全拼接字符串?strcat 从目标字符串末尾开始追加,不检查目标缓冲区剩余空间,溢出风险和 strcpy 一样。安全做法用 snprintf:char buf[128];snprintf(buf, sizeof(buf), "%s%s", prefix, suffix);或者用 strncat,但要自己算剩余空间:strncat(dest, src, sizeof(dest) - strlen(dest) - 1);C11 的 strcpy_s 值得用吗?理论上 strcpy_s 会检查目标缓冲区大小,溢出时返回错误而不是崩溃。但它是 C11 可选附件(Annex K),MSVC 支持但 glibc 不支持,跨平台代码用不了。实际项目中更普遍的做法是用 snprintf 或自己封装安全拷贝函数。
服务端阅读 06月1日 14:50

C语言文件操作怎么做?fopen/fread/fwrite 怎么用?

C 语言文件操作就四步:fopen 打开 → fread/fwrite(或 fgets/fprintf)读写 → fseek/ftell 定位 → fclose 关闭。核心原则是:打开必检查返回值,关闭前确保所有操作完成,错误用 ferror/feof 判断。追问文本模式和二进制模式有什么区别?文本模式("r"/"w")在 Windows 上会自动做换行转换——读时把 \r\n 变成 \n,写时把 \n 变成 \r\n。二进制模式("rb"/"wb")原样读写,不做任何转换。Linux/macOS 上没区别,因为换行符本身就是 \n。跨平台代码处理二进制数据(图片、结构体)时一定要用二进制模式,否则数据会被悄悄改掉。fread 和 fgets 怎么选?fgets 按行读取文本,遇到 \n 或 EOF 停止,适合逐行处理配置文件、日志。fread 按字节块读取,一次读指定大小的块,适合二进制数据(结构体、数组)或大文件批量读取。性能上 fread 更快,因为它跳过了行尾检测和字符串格式化。怎么获取文件大小?最常见的方法:跳到末尾看位置,再跳回来:fseek(fp, 0, SEEK_END);long size = ftell(fp);rewind(fp);注意这是对常规文件的做法,对管道、设备文件、标准输入不适用(fseek 可能失败)。更稳妥的方式是用 fstat(POSIX)。文件操作怎么处理错误?三个工具:ferror(fp) 检测读写错误,feof(fp) 检测是否到文件尾,perror() 把 errno 翻译成人话打印出来。常见模式:if (fread(buf, 1, size, fp) < size) { if (ferror(fp)) perror("Read error"); else if (feof(fp)) { /* 正常结束 */ }}文件指针忘记关闭会怎样?fclose 刷出缓冲区里还没写入磁盘的数据,然后释放 FILE 结构体。忘了关闭,最后一批数据可能丢失,文件描述符泄漏——进程打开文件数有上限(通常 1024),多了就 fopen 失败。长期运行的程序(服务器)里这个问题尤其严重。写段代码FILE *fp = fopen("data.bin", "rb");if (!fp) { perror("fopen"); return -1; }fseek(fp, 0, SEEK_END);long size = ftell(fp);rewind(fp);char *buf = malloc(size);fread(buf, 1, size, fp);fclose(fp);
服务端阅读 06月1日 14:49

C语言 malloc 和 free 怎么用?内存泄漏怎么排查?

C 语言用 malloc/calloc/realloc 在堆上分配内存,用 free 释放。和栈上变量不同,堆内存的生命周期由程序员手动控制——分配了不释放就是内存泄漏,释放了再访问就是悬空指针。追问malloc、calloc、realloc 有什么区别?malloc(size) 分配 size 字节,内容不确定(可能是垃圾值)。calloc(n, size) 分配 n×size 字节并全部清零。realloc(ptr, new_size) 在已分配内存基础上调整大小——如果新空间够大就在原地扩展,不够就另找一块新地方拷贝过去。realloc 返回新地址,旧指针可能失效,所以务必用返回值更新指针。int *arr = malloc(5 * sizeof(int)); // 5 个未初始化的 intint *arr2 = calloc(5, sizeof(int)); // 5 个全零 intint *arr3 = realloc(arr, 10 * sizeof(int)); // 扩到 10 个内存泄漏怎么排查?最直接的办法:每个 malloc 配一个 free,在代码审查时逐对核对。工具方面,Linux 下用 Valgrind(valgrind --leak-check=full ./a.out)能自动检测泄漏位置;macOS 用 Instruments 的 Leaks 模板。常见泄漏场景:函数提前 return 忘了 free、循环里分配但只保存了最后一个指针、结构体成员分配了但释放结构体时忘了先释放成员。悬空指针和野指针有什么区别?野指针是未初始化的指针,指向随机地址——声明后没赋值就用了。悬空指针是释放后没置 NULL 的指针,指向已回收的内存——free(p) 后 *p 就是悬空访问。两者都是未定义行为,但悬空指针更危险,因为那段内存可能已被重新分配,改写它会导致别的数据被无声破坏。free(p); p = NULL; 是最简单的防护。realloc 失败了怎么办?realloc 失败返回 NULL,但原来的内存块仍然有效。常见错误写法:p = realloc(p, new_size); 如果失败,p 被置 NULL,原来的内存块丢失了——既无法访问也无法释放,内存泄漏。正确做法是先用临时指针接返回值,成功再更新:int *tmp = realloc(p, new_size);if (!tmp) { free(p); return NULL; } // 失败也要善后p = tmp;实际项目里怎么管理内存?小项目靠纪律:谁分配谁释放,函数文档写清楚返回的指针要不要调用者 free。大项目用内存池——预先分配一大块,自定义分配器从中切分,统一释放时一次 free 整个池。另外 C11 引入了 _Atomic 和 aligned_alloc,但核心思路没变:malloc/free 配对,free 后置 NULL,每个分配都有明确的释放点。
服务端阅读 06月1日 14:44

C语言位域(bit-field)有什么用?和位运算比哪个好?

位域(bit-field)让你在结构体里按位定义成员,指定每个成员占几个二进制位。主要用途是压缩存储——当多个标志位只需要 0/1 时,用位域把多个 bool 塞进一个 int 里,省内存。在网络协议解析和硬件寄存器映射中非常常见。追问位域怎么定义?占多少内存?struct Flags { unsigned int read : 1; // 1 bit unsigned int write : 1; // 1 bit unsigned int execute : 1; // 1 bit unsigned int reserved: 29; // 填满 32 bit};// sizeof(struct Flags) = 4 字节冒号后面的数字是位宽。所有位域成员加起来不能超过底层类型的总位数(unsigned int 是 32 位)。如果超过,编译器会分配下一个存储单元。位域有什么限制?三个硬限制:1) 不能取地址——&flags.read 编译报错,因为位域没有独立的字节地址;2) 不能用指针指向位域成员;3) 赋值超出范围会截断——3 位位域最大值是 7,赋值 10 实际存的是 2(10 % 8)。位域跨平台有什么坑?大端序和小端序机器上,位域的排列方向可能相反——同样是 a:4, b:4,x86 上 a 在低 4 位,PowerPC 上 a 可能在高 4 位。另外不同编译器对位域的对齐和填充规则不同。所以位域不适合做跨平台的数据传输格式,只适合同一平台内部使用。网络协议解析用位域的话,要做字节序转换。无名位域是干什么用的?两个用途:unsigned :0 强制下一个位域从新的存储单元开始(对齐);unsigned :N(N>0)跳过 N 位不用(保留位)。协议头里经常用无名位域来对齐字段位置。struct IPHeader { unsigned int version : 4; unsigned int ihl : 4; unsigned int tos : 8; unsigned int tot_len : 16;};位域和位运算比哪个好?位运算(flags |= 1 << 3)完全可控、跨平台、可移植,但可读性差。位域可读性好(flags.read = 1),但牺牲了可控性和可移植性。在嵌入式开发中,寄存器映射用位域更直观;跨平台代码用位运算更安全。实际项目里两种都有人用,看对可移植性的要求。
服务端阅读 06月1日 14:43

C语言可变参数函数是怎么实现的?va_list 原理是什么?

可变参数函数就是参数个数不固定的函数,最典型的例子是 printf。C 语言通过 <stdarg.h> 里的四个宏来操作可变参数:va_list 声明参数指针,va_start 定位到第一个可变参数,va_arg 逐个取出参数,va_end 收尾清理。追问va_list 的底层原理是什么?函数参数从右往左压栈,所以最后一个固定参数的地址紧挨着第一个可变参数。va_list 本质上是 char* 指针,va_start 根据最后一个固定参数的地址加上它的偏移量,算出第一个可变参数的位置。va_arg 取出当前参数后,指针按类型大小前进到下一个。这就是为什么 va_start 需要传最后一个固定参数——它得知道从哪里开始找可变参数。为什么可变参数没有类型安全?C 语言的调用约定只负责把参数压栈,不传递类型信息。va_arg(args, type) 里的 type 完全由调用者自己指定——你写 va_arg(args, int) 就按 int 读,写 va_arg(args, double) 就按 double 读,编译器不做任何检查。printf 的 %d、%f 就是靠格式字符串来"约定"参数类型,格式串写错就会读到垃圾值。可变参数怎么知道自己有几个参数?没办法自动知道,必须靠约定。常见做法有三种:1) 像 printf 用格式字符串描述参数个数和类型;2) 第一个参数传数量,如 sum(3, 10, 20, 30);3) 用哨兵值结尾,如 concat("a", "b", NULL)。va_copy 是干什么用的?复制一个 va_list 的当前状态。场景是你需要遍历两遍可变参数——第一遍计算长度,第二遍真正处理。遍历一遍后 va_list 已经指到末尾了,用 va_copy 在遍历前备份一份就能重来。va_list args, args_copy;va_start(args, fmt);va_copy(args_copy, args); // 备份// 第一遍:计算长度vsnprintf(NULL, 0, fmt, args);// 第二遍:实际格式化vsnprintf(buf, size, fmt, args_copy);va_end(args);va_end(args_copy);可变参数有什么坑?最大的是类型提升:char 和 short 会自动提升为 int,float 提升为 double。所以 va_arg(args, char) 是错的,必须写 va_arg(args, int)。另外,可变参数不能是结构体或联合体,只能传基本类型和指针。写段代码int sum(int count, ...) { va_list args; va_start(args, count); int total = 0; for (int i = 0; i < count; i++) total += va_arg(args, int); va_end(args); return total;}// sum(3, 10, 20, 30) → 60
服务端阅读 06月1日 14:42

C语言 static 关键字有什么用?修饰变量和函数分别什么效果?

static 在 C 语言里有三个作用:修饰局部变量让它持久化、修饰全局变量限制作用域、修饰函数限制可见性。记住一个规律——static 总是在"收窄"什么:局部变量收窄不了作用域(已经最窄了),就延长生命周期;全局变量和函数收窄可见范围到当前文件。追问static 局部变量和全局变量有什么区别?存储位置一样,都在 .data 或 .bss 段,生命周期都是程序运行全程。区别在作用域:static 局部变量只在函数内可见,全局变量整个文件可见。换句话说,static 局部变量就是"只有这个函数能用的全局变量"。int* get_counter() { static int count = 0; // 只初始化一次,值持久化 return ++count; // 每次调用 +1}static 全局变量和普通全局变量有什么区别?普通全局变量是外部链接,其他文件通过 extern 能访问。static 全局变量是内部链接,只能在定义它的 .c 文件内使用。效果就是命名隔离——多个文件可以有同名的 static 全局变量,互不冲突。static 函数有什么用?和 static 全局变量一个道理:限制到文件内部。好处是:避免和其他文件的同名函数冲突,编译器可以做更激进的内联优化(因为确定不会被外部调用)。在大型项目里,内部辅助函数都应该加 static,这是基本的模块封装手段。static 变量是线程安全的吗?不是。多个线程同时访问同一个 static 局部变量,和访问全局变量一样需要同步。C11 引入了 _Thread_local,可以给每个线程一份独立的 static 变量副本,但这不是 C89/C99 的特性。实际项目中,static 局部变量配合 mutex 是常见的线程安全模式。static 和 const 有什么区别?static 管的是作用域和生命周期("谁能看到我""活多久"),const 管的是可修改性("能不能改我")。两者可以组合:static const int MAX = 100; 表示文件内可见、不可修改的常量,替代 #define 更类型安全。写段代码// counter.c — static 封装示例static int count = 0; // 文件私有,外部不可见int counter_next() { return ++count; }int counter_get() { return count; }void counter_reset() { count = 0; }// 外部只能通过这三个函数操作 count
服务端阅读 06月1日 14:39

C语言 extern 关键字有什么用?跨文件共享变量怎么做?

extern 告诉编译器"这个变量/函数在别的地方定义了,别报错,链接时再找"。核心作用就是跨文件共享变量和函数声明。追问extern 声明和定义有什么区别?定义分配内存,声明只是说"有这个东西"。一个变量在整个程序里只能定义一次,但可以声明多次:extern int count; // 声明:不分配内存,告诉编译器 count 在别处定义int count = 0; // 定义:分配内存常见坑:extern int x = 10; 虽然语法合法,但一旦在头文件里这么写,多个源文件 include 后就会重复定义,链接报错。extern 声明不要带初始化。extern 和 static 为什么不能一起用?矛盾的东西。extern 说"外面能找到我",static 说"只有我这个文件能用"。编译器不知道该听谁的,所以直接报错。不过 static 全局变量和 extern 声明可以出现在不同文件里——这时候链接器找不到 static 变量,报 undefined reference。extern "C" 是干嘛的?C++ 支持函数重载,编译器会给函数名加后缀(name mangling),比如 void foo(int) 变成 _Z3fooi。C 语言不做这个变换。当 C++ 代码要调用 C 编译的库时,得告诉 C++ 编译器"按 C 的方式链接这个函数名":#ifdef __cplusplusextern "C" {#endif void c_library_init(); void c_library_cleanup();#ifdef __cplusplus}#endif这个模式在所有 C/C++ 混编项目的头文件里都会出现。头文件里写 extern 有什么讲究?标准做法:头文件里只放 extern 声明,.c 文件里放定义。这样多个源文件 include 同一个头文件时,各自拿到声明,链接时汇指向唯一的定义。如果头文件里直接写定义(不加 extern),多个 .c 文件 include 就会产生重复定义错误。实际项目中 extern 用得多吗?直接用 extern 声明变量的情况越来越少了,现代 C 项目更倾向用"头文件 + 函数接口"来暴露模块功能,而不是暴露全局变量。但 extern "C" 在 C/C++ 混编项目中几乎必用,比如嵌入式开发里 C++ 调 C 驱动库。写段代码// config.h — 只放声明extern int max_connections;extern const char* get_version();// config.c — 放定义int max_connections = 100;const char* get_version() { return "1.0.0"; }
服务端阅读 06月1日 14:39

C语言 restrict 关键字有什么用?编译器如何利用它做优化?

restrict 是 C99 引入的类型限定符,告诉编译器"这个指针是访问该内存区域的唯一途径",编译器据此可以做更激进的优化。说白了,restrict 就是一份承诺:你向编译器保证通过这个指针访问的内存,不会通过其他指针再访问。编译器信了,就能省掉很多冗余的内存读取。追问restrict 和普通指针有什么区别?普通指针可能有"别名"(aliasing)——多个指针指向同一块内存:void add(int *a, int *b, int *c, int n) { for (int i = 0; i < n; i++) a[i] = b[i] + c[i];}编译器不敢把 b[i] 和 c[i] 的值缓存在寄存器里,因为 a 可能和 b 或 c 指向同一块内存,每次循环都得老老实实从内存重新读取。加上 restrict 后,编译器知道 a、b、c 三者不会重叠,可以把 b[i] 和 c[i] 优化成寄存器访问,甚至做向量化(SIMD)和循环展开。memcpy 和 memmove 的区别就是 restrict 吗?对,这是最经典的例子。memcpy 的参数声明了 restrict,假定源和目标不重叠,所以可以更快地拷贝。memmove 没有 restrict,必须处理重叠的情况,所以更安全但更慢。void *memcpy(void *restrict dest, const void *restrict src, size_t n);void *memmove(void *dest, const void *src, size_t n);违反 restrict 的承诺会怎样?未定义行为。编译器不会在运行时检查,一旦你传了重叠的指针,结果不可预测——可能正常、可能数据错乱、可能优化后逻辑全变了。这是 restrict 最坑的地方:debug 模式下可能没事,release 模式下爆炸。实际项目里 restrict 常见吗?不算常见,主要出现在高性能计算、图像处理、DSP 这类对性能极度敏感的场景。标准库函数(memcpy、strcpy 等)大量使用。普通业务代码很少主动加,因为维护成本高——你要确保整个调用链都不会传入重叠指针,这个保证很难持续。写段代码// 没有 restrict:编译器保守,每次循环都从内存读void add(int *a, int *b, int *c, int n) { for (int i = 0; i < n; i++) a[i] = b[i] + c[i]; // 可能重叠,不敢优化}// 有 restrict:编译器可以 SIMD/循环展开void add_fast(int *restrict a, const int *restrict b, const int *restrict c, int n) { for (int i = 0; i < n; i++) a[i] = b[i] + c[i];}