2、C语言基础安全:变量初始化、未定义行为、类型转换陷阱、sizeof与strlen的误用
各位同学,咱们今天聊点实在的。C语言的基础安全,说白了就是那些你天天写、天天碰,但稍不留神就翻车的地方。我做了十几年嵌入式,见过太多线上事故,追根溯源,往往就是这几个基础点没守住。今天我把它们掰开揉碎了讲,你听完能少踩一半的坑。
2.1 变量初始化:别信“默认值”这种鬼话
先问个问题:局部变量不初始化,它的值是多少?
很多人脱口而出“随机值”。嗯,对,但不全对。准确地说,是“不确定的值”。在栈上分配时,那块内存里残留的是上次函数调用留下的数据。我见过最离谱的一次,一个同事的代码里有个未初始化的指针,恰好指向了之前某个函数的返回地址,结果一解引用,直接跑飞了。
我个人习惯:定义变量的同时就初始化。哪怕你后面马上要赋值,也先给个0或NULL。这不是强迫症,这是保命。
安全编码规范:
- 所有局部变量在声明时立即初始化
- 指针变量初始化为 NULL
- 结构体变量使用 memset 或 = {0} 清零
- 静态变量和全局变量虽然默认初始化为0,但显式初始化更清晰
// 错误示例
int count;
char *buffer;
struct data info;
// 使用前忘记初始化,后果自负
// 正确示例
int count = 0;
char *buffer = NULL;
struct data info = {0};
避坑指南:我曾经在一个中断服务函数里定义了一个局部数组,没初始化就直接用了。结果每次中断触发,数组里都是上次中断留下的数据,导致数据处理逻辑完全错乱。查了整整两天才定位到问题。从那以后,我写任何局部变量都先初始化,成了肌肉记忆。
2.2 未定义行为:C语言的“幽灵陷阱”
未定义行为(Undefined Behavior,UB)是C语言里最让人头疼的东西。标准委员会说“未定义”,意思是“编译器想怎么干就怎么干”。你想想看,这多可怕?
常见的未定义行为包括:
- 有符号整数溢出
- 数组越界访问
- 使用未初始化的变量
- 除零操作
- 同一个变量在两个序列点之间被多次修改
// 经典未定义行为
int i = 0;
i = i++ + ++i; // 别问我结果是多少,编译器自己都不知道
// 有符号整数溢出
int a = INT_MAX;
a = a + 1; // 未定义行为,不是简单地变成负数
为什么说它危险?因为编译器在遇到UB时,可以优化出任何结果。我见过一个案例:一个整数溢出检查的代码,在开启-O2优化后,整个if判断被编译器直接删掉了,因为编译器认为“有符号整数不会溢出”是程序员的责任,它默认你的代码不会触发UB。
警告:不要依赖任何未定义行为的结果。即使你在某个编译器上测试通过了,换个编译器、换个优化等级,结果可能完全不同。这不是“特性”,这是“炸弹”。
2.3 类型转换陷阱:隐式转换是“温柔的杀手”
类型转换,尤其是隐式转换,是嵌入式C里最容易出问题的地方之一。我建议你养成一个习惯:所有类型转换都显式写出来,别让编译器替你猜。
常见的陷阱有:
- 有符号与无符号混用:当有符号数和无符号数比较时,有符号数会被隐式转换为无符号数。负数变成很大的正数,比较结果完全颠倒。
- 整数提升:char、short等类型在表达式中会被提升为int,可能导致意想不到的符号扩展。
- 截断:将大类型赋值给小类型时,高位被丢弃,数据丢失。
// 有符号与无符号混用的陷阱
int a = -1;
unsigned int b = 1;
if (a < b) {
// 你以为会执行?实际上不会!
// a被转换为unsigned int,变成0xFFFFFFFF,远大于1
}
// 正确的做法
if (a < (int)b) {
// 显式转换,避免陷阱
}
安全编码规范:
- 避免有符号和无符号类型直接比较或运算
- 必须混用时,显式进行类型转换
- 使用 -Wsign-compare 编译选项开启警告
- 位运算操作数尽量使用无符号类型
我记得有一次,一个同事写了个循环:for (int i = 0; i < strlen(str); i++)。strlen返回的是size_t(无符号),i是int。当strlen返回0时,i < 0 这个比较,i被提升为无符号,0 < 0 为假,循环直接跳过。但问题是,如果strlen返回的值大于INT_MAX呢?嗯,那就有意思了。
2.4 sizeof与strlen的误用:你以为你懂,其实你不懂
这两个东西,新手容易搞混,老手也偶尔翻车。我直接说结论:sizeof是运算符,strlen是函数。sizeof在编译时计算,strlen在运行时计算。
| 特性 | sizeof | strlen |
|---|---|---|
| 类型 | 运算符 | 函数 |
| 计算时机 | 编译时 | 运行时 |
| 计算对象 | 类型或变量 | 字符串 |
| 结果 | 变量/类型占用的字节数 | 字符串长度(不含'\0') |
| 对指针 | 返回指针本身的大小(4或8字节) | 遍历指针指向的内存直到'\0' |
// 经典误用
char str[] = "hello";
char *p = str;
printf("%zu\n", sizeof(str)); // 6(包含'\0')
printf("%zu\n", sizeof(p)); // 4或8(指针大小)
printf("%zu\n", strlen(str)); // 5
printf("%zu\n", strlen(p)); // 5
// 危险用法:用sizeof计算字符串长度
char buffer[64];
strcpy(buffer, "hello");
// 下面这个用法是错误的
// size_t len = sizeof(buffer); // 返回64,不是5
// 正确用法
size_t len = strlen(buffer); // 返回5
警告:当数组作为函数参数传递时,它会退化为指针。在函数内部使用sizeof,得到的是指针大小,不是数组大小。这是C语言里最容易踩的坑之一。
void process(char arr[64]) {
// 你以为sizeof(arr)是64?
// 实际上arr已经退化为指针,sizeof(arr)是4或8
size_t size = sizeof(arr); // 错误!
}
// 正确做法:同时传入数组大小
void process(char arr[], size_t size) {
// 使用传入的size
}
避坑指南:我曾经在写一个通信协议解析函数时,用sizeof计算了传入的缓冲区大小,结果因为数组退化为指针,sizeof始终返回4。导致缓冲区边界检查形同虚设,一个超长数据包直接写穿了栈。从那以后,我所有处理缓冲区的函数,都强制要求传入长度参数,绝不用sizeof在函数内部计算。
好了,这一章的内容就这些。变量初始化、未定义行为、类型转换、sizeof与strlen,这四个点看似基础,但每一个都能让你在深夜里对着屏幕怀疑人生。记住:写C语言,永远假设编译器比你聪明,也永远假设编译器会坑你。保持警惕,显式表达你的意图,别让编译器替你“猜”。