C: 常见陷阱与安全编码
C语言的陷阱就像路上的坑——看着不起眼,踩上去就是程序崩溃。认识这些坑,才能绕着走。
1. 运算符优先级陷阱
C语言运算符优先级有 15 级,有些组合容易出错:
(1) 最常见的优先级错误
int *p = malloc(10 * sizeof(int));
if (p == NULL)
这不算是错误,但下面这个是:
int a = 1, b = 2, c = 3;
int result = a & b == 0;
== 优先级高于 &,实际是 a & (b == 0),即 1 & 0 = 0,而不是 (a & b) == 0。
(2) 优先级速查(易错部分)
| 陷阱写法 | 实际含义 | 想要的含义 | 正确写法 |
|---|---|---|---|
a & b == 0 |
a & (b == 0) |
(a & b) == 0 |
加括号 |
a << 2 + 1 |
a << (2+1) |
(a<<2)+1 |
加括号 |
*p++ |
*(p++) |
(*p)++ |
加括号 |
a | b + c |
a | (b+c) |
(a|b)+c |
加括号 |
2. 数组越界
C语言不检查数组下标,越界访问是未定义行为——可能读到垃圾值,可能崩溃,也可能"碰巧"正常工作(这最危险)。
int arr[5] = {1, 2, 3, 4, 5};
arr[5] = 100;
arr[-1] = 99;
这两行都是越界写入,编译器不会报错,但可能破坏其他变量的值或导致崩溃。
(1) 经典越界:循环多走一步
int arr[5];
for (int i = 0; i <= 5; i++) {
arr[i] = 0;
}
i <= 5 应该是 i < 5,这个 off-by-one 错误会导致 arr[5] 被写入。
(2) 字符串越界
char buf[5];
strcpy(buf, "Hello, World!");
buf 只有 5 字节空间,字符串却要 14 字节(含 '\0'),典型的缓冲区溢出。
3. 悬空指针
指针指向的内存已经被释放,但指针还在使用:
int *create_value(void) {
int x = 42;
return &x;
}
x 是局部变量,函数返回后栈帧释放,&x 变成悬空指针。访问它可能读到垃圾值。
int *p = malloc(sizeof(int));
*p = 42;
free(p);
printf("%d\n", *p);
free(p) 之后,p 指向的内存已归还。再通过 p 访问是未定义行为。
free(p); p = NULL;。这样如果后续误用,至少会段错误而不是产生难以追踪的随机错误。
4. 内存泄漏
分配了内存却忘记释放:
void leak_example(void) {
int *p = malloc(100 * sizeof(int));
if (p == NULL) return;
if (some_error) return;
free(p);
}
some_error 为真时直接返回,free(p) 被跳过,100 个 int 的内存泄漏了。
(1) 正确写法
void no_leak(void) {
int *p = malloc(100 * sizeof(int));
if (p == NULL) return;
if (some_error) {
free(p);
return;
}
free(p);
}
5. 未定义行为
未定义行为(Undefined Behavior, UB)是 C语言中最危险的概念——标准说"行为未定义",意味着什么都有可能发生。
(1) 常见未定义行为
| 行为 | 说明 |
|---|---|
| 数组越界 | 下标超出数组范围 |
| 解引用空指针 | *NULL |
| 使用悬空指针 | 访问已释放的内存 |
| 有符号整数溢出 | INT_MAX + 1 |
| 移位超过位数 | 1 << 100 |
| 同一表达式多次修改同一变量 | i = i++ + 1 |
| 除以零 | int x = 1 / 0 |
| 读取未初始化的变量 | int x; printf("%d", x); |
▶ 示例
i = i++ + 1 的陷阱:
#include <stdio.h>
int main(void) {
int i = 3;
i = i++ + 1;
printf("%d\n", i);
return 0;
}
不同编译器可能输出 4、5 或其他值。因为 i++ 修改了 i,赋值也修改了 i,同一条语句中对同一变量做两次修改是未定义行为。
6. 缓冲区溢出防范
缓冲区溢出是 C语言最著名的安全漏洞。1988 年的 Morris 蠕虫就是利用缓冲区溢出攻击的。
(1) 危险函数 vs 安全替代
| 危险函数 | 问题 | 安全替代 |
|---|---|---|
gets(buf) |
不限制长度 | fgets(buf, size, stdin) |
strcpy(dst, src) |
不检查目标大小 | strncpy(dst, src, size-1) |
sprintf(buf, fmt, ...) |
不检查目标大小 | snprintf(buf, size, fmt, ...) |
strcat(dst, src) |
不检查目标剩余空间 | strncat(dst, src, size-strlen(dst)-1) |
scanf("%s", buf) |
不限制输入长度 | scanf("%99s", buf) 或 scanf_s |
(2) 安全函数详解
strncpy
char *strncpy(char *dest, const char *src, size_t n);
最多复制 n 个字符。如果 src 少于 n 个字符,dest 剩余部分填 '\0'。但如果 src 正好 n 个字符,dest 不会自动添加 '\0'!
#include <stdio.h>
#include <string.h>
int main(void) {
char buf[6];
strncpy(buf, "Hello World", sizeof(buf) - 1);
buf[sizeof(buf) - 1] = '\0';
printf("%s\n", buf);
return 0;
}
Hello
strncpy 不会自动添加 '\0'!必须在复制后手动添加,否则后续字符串操作可能越界。
snprintf
int snprintf(char *str, size_t size, const char *format, ...);
最多写入 size-1 个字符,自动添加 '\0'。
#include <stdio.h>
int main(void) {
char buf[10];
int n = snprintf(buf, sizeof(buf), "值是 %d", 12345);
printf("输出: \"%s\"\n", buf);
printf("需要长度: %d, 实际空间: %d\n", n, (int)sizeof(buf));
return 0;
}
输出: "值是 1234"
需要长度: 9, 实际空间: 10
▶ 示例
安全的输入处理模式:
#include <stdio.h>
#include <string.h>
int main(void) {
char name[32];
printf("请输入姓名: ");
if (fgets(name, sizeof(name), stdin) == NULL) {
fprintf(stderr, "输入失败\n");
return 1;
}
name[strcspn(name, "\n")] = '\0';
if (strlen(name) == 0) {
fprintf(stderr, "姓名不能为空\n");
return 1;
}
printf("你好, %s!\n", name);
return 0;
}
fgets + strcspn 去换行符是处理用户输入的标准安全模式。永远不要用 gets。
▶ 示例
安全的字符串拼接:
#include <stdio.h>
#include <string.h>
int main(void) {
char path[256] = "/home/user";
const char *subdir = "/documents/work/projects/2024";
size_t current_len = strlen(path);
size_t remaining = sizeof(path) - current_len - 1;
if (strlen(subdir) < remaining) {
strncat(path, subdir, remaining);
} else {
fprintf(stderr, "路径过长,无法拼接\n");
return 1;
}
printf("路径: %s\n", path);
return 0;
}
路径过长,无法拼接
7. scanf 安全用法
scanf 是新手最常用的输入函数,也是最危险的:
char buf[10];
scanf("%s", buf);
用户输入超过 9 个字符就溢出了。安全写法:
char buf[10];
scanf("%9s", buf);
或在支持 C11 的编译器上使用 scanf_s:
char buf[10];
scanf_s("%9s", buf, (unsigned)sizeof(buf));
scanf_s 是 C11 可选附件 K 的函数,MSVC 支持但 gcc/clang 不一定支持。跨平台代码建议用 fgets + sscanf 组合。
8. 整数溢出
有符号整数溢出是未定义行为:
#include <stdio.h>
#include <limits.h>
int main(void) {
int a = INT_MAX;
int b = a + 1;
printf("%d + 1 = %d\n", a, b);
return 0;
}
输出不一定是 INT_MIN,编译器可能优化掉这个加法。
安全检查方法:
#include <limits.h>
int safe_add(int a, int b) {
if (a > 0 && b > INT_MAX - a) return 0;
if (a < 0 && b < INT_MIN - a) return 0;
return a + b;
}
9. 其他常见陷阱
(1) sizeof 与数组参数
void func(int arr[]) {
printf("%zu\n", sizeof(arr));
}
数组作为函数参数时退化为指针,sizeof(arr) 是指针大小(4 或 8),不是数组大小。必须额外传入长度参数。
(2) 宏定义陷阱
#define SQUARE(x) x * x
SQUARE(2 + 3)
展开为 2 + 3 * 2 + 3 = 11,不是 25。正确写法:
#define SQUARE(x) ((x) * (x))
但 SQUARE(i++) 仍然有问题(i 自增两次),更安全的是用内联函数。
(3) == 和 =
if (x = 5) {
}
这是赋值,不是比较!赋值表达式值为 5(非零),条件永远为真。部分编译器会警告,但不是所有编译器都开。
❓ 常见问题
valgrind --leak-check=full ./program。Windows 下可以用 Visual Studio 的调试堆或 AddressSanitizer。-fsanitize=address 即可启用,能检测越界、use-after-free、内存泄漏等问题。📖 小节
- 拿不准运算符优先级就加括号,这是最简单的防线
- 数组越界和缓冲区溢出是 C语言最常见的安全问题
- 用
fgets替代gets,用snprintf替代sprintf,用strncpy替代strcpy - 未定义行为可能"碰巧"正常工作,在不同编译器或优化级别下表现不同
- free 之后置 NULL,检查 malloc 返回值,有符号运算注意溢出
📝 作业
- 编写安全的字符串拷贝函数
safe_strcpy(char *dst, size_t dst_size, const char *src),确保不会溢出且以'\0'结尾 - 编写程序,故意制造数组越界和整数溢出,用 AddressSanitizer 编译运行,观察输出
- 审查以下代码,找出所有安全隐患并修正:
char buf[8]; gets(buf); sprintf(buf, "Result: %d", value);