ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

C语言数据类型与变量详解:内存视角与常见坑

C语言数据类型与变量详解:内存视角与常见坑 很多人学C语言学到“数据类型和变量”这一节就觉得索然无味不就是int a 10;嘛声明、赋值、打印三分钟看完。但等到真正写代码的时候被坑得最惨的恰恰就是这些基础概念。我在整理C语言学习笔记时也经历过从“自我感觉良好”到“一脸懵”的过程尤其是在内存视角下去看变量、在边界条件下去测数据类型才发现原来很多东西只是“以为懂了”。这篇笔记就是围绕C语言数据类型和变量展开的把变量本质、整数/浮点数的存储细节、类型转换规则、作用域与生命周期这些核心内容用更容易理解的方式重新捋一遍。不管你是刚开始学C语言的小白还是学了一阵子但总觉得根基不稳的人这篇内容都值得你静下心来看完。1. 变量到底是什么地址、内存和解释方式1.1 变量名只是给程序员看的名字先说一个很多初学者容易忽略的事实C语言里的变量名纯粹是给人类看的。编译器拿到你的.c源文件之后第一步会做语法分析、符号收集把你定义的a、b、count这些名字记录在符号表里并给它们分配“对应的地址”。到了最终生成机器码的阶段变量名基本就消失了取而代之的是一串串内存地址和偏移量。所以你在代码里写int a 5;这句话真正干的事是什么是向编译器申请一块大小为sizeof(int)的内存空间然后把二进制形式的“5”写到这块内存里同时让符号表中的a指向这块内存的起始地址。后面你再写a 10;程序做的事情是找到a对应的地址把这块内存里的值改成10而不是“把一个叫a的盒子里的东西换掉”。理解这一点非常重要因为它直接决定了你后面学指针、学数组、学结构体时能不能建立起正确的心智模型。很多人都说指针难其实难就难在没想明白指针变量也是变量它存的值也是一个数字只不过这个数字被类型约定为“地址”。如果你清楚变量本身的底层是“地址内存解释方式”指针的神秘感立刻就少了一半。1.2 同一串二进制换一种类型解释就完全不一样“解释方式”是什么概念我举个例子你立刻就能明白#include stdio.h int main(void) { char c A; printf(c 按 %%c 打印: %c\n, c); printf(c 按 %%d 打印: %d\n, c); printf(c 的十六进制: 0x%02X\n, (unsigned char)c); return 0; }在常见的ASCII环境下A对应的数值是65。也就是说变量c的内存里存的二进制就是0100 0001也就是十进制65。你用%c打印它它显示成字符A你用%d打印它它显示成数字65。内存里的数据没有变变的是你怎么解释这一串二进制。这就是数据类型的核心作用类型决定了编译器如何解释一块内存以及允许你对这块内存做哪些操作。int告诉你这块内存按4字节有符号整数来读float告诉你这块内存按IEEE 754格式来读char *告诉你这块内存里存的是一个地址并且以这个地址为起点可以继续读字节。还有一个特别容易混淆的细节为什么同样是4字节int能表示的最大正整数大约是21亿而float却能表示到3.4×10^38因为它们的解释方式不同float把32位拆成了符号位、指数位和尾数位用“科学计数法”的套路来表示数值所以范围大但精度有限。这一点下一章讲浮点数的时候再细说这里先建立“同一块内存不同解释得到完全不同的数值”这个认知。1.3 用printf把变量“底裤”扒出来看一下学习变量最有效的方法就是别光在纸上推导直接上手打印。我写C这么多年遇到任何拿不准的类型问题第一反应永远是写个小程序去验证。比如你想搞清楚自己的平台上int到底占几个字节、变量地址长什么样就写这段#include stdio.h int main(void) { int a 5; char b A; double c 3.14; printf(sizeof(int) %zu\n, sizeof(int)); printf(sizeof(char) %zu\n, sizeof(char)); printf(sizeof(double) %zu\n, sizeof(double)); printf(a %p\n, (void *)a); printf(b %p\n, (void *)b); printf(c %p\n, (void *)c); return 0; }注意打印sizeof的结果时一定要用%zu因为sizeof运算符的返回值类型是size_t它在不同平台上可能是unsigned long或unsigned long long。用%d去打印严格来说属于格式说明符和参数类型不匹配在某些编译器上会报警告在64位平台上甚至可能打出错误的值。很多人说“我机器上sizeof(int)打印出来是8”多半就是犯了这种错误。先别急着怀疑标准先去查你的格式说明符对不对。2. 整数类型家族从char到long long的字节与范围2.1 一张表看懂常见整数类型的范围和大小C语言标准规定了5种标准有符号整数类型char、short int、int、long int、long long int以及它们对应的无符号版本。注意标准只规定了“最小范围”没有硬性规定每种类型必须占多少字节。说得直白一点int在16位平台、32位平台、64位平台上的大小可能是不同的但标准保证int至少能表示 -32767 到 32767 的范围。在我常用的64位Linux GCC环境下各类型实际大小如下类型典型大小64位Linux取值范围示例printf格式char1字节-128 ~ 127有符号时%c/%dunsigned char1字节0 ~ 255%u或%02Xshort2字节-32768 ~ 32767%hdunsigned short2字节0 ~ 65535%huint4字节-2147483648 ~ 2147483647%dunsigned int4字节0 ~ 4294967295%ulong8字节64位Linux-9223372036854775808 ~ 9223372036854775807%ldunsigned long8字节0 ~ 18446744073709551615%lulong long8字节同上%lldunsigned long long8字节同上%llu这张表看起来简单但有几个值得划重点的地方。第一long在不同平台上的大小不一致。在64位Linux上long是8字节但在64位Windows上long是4字节。所以跨平台写代码时如果你想确保一个整数是8字节直接写long long别写long。这也是C语言被人诟病“可移植性差”的一个典型体现。第二char类型到底有没有符号标准没规定由实现决定。大多数x86平台上有符号char的范围是 -128 到 127但有些嵌入式平台比如ARM默认char是无符号的。所以当你要用char存非负的小数比如字节数据、ASCII码时要么显式写unsigned char要么做好跨平台时行为可能不一致的心理准备。2.2 有符号和无符号最高位决定了一切整数在内存里是怎么存的这里必须提补码twos complement。简单说正数的补码就是它本身的二进制负数的补码是“取反加一”。比如8位有符号整数 -1内存里是1111 1111也就是0xFF。而unsigned char类型的255内存里也是1111 1111。同样的8个1按有符号char解释是 -1按无符号char解释是255。这就是前面说的“类型决定解释方式”的又一个活例子。为什么要用补码因为计算机做减法的时候不需要单独设计一套减法电路直接“加一个负数”就行。比如 5 - 3本质上就是 5 (-3)。补码让符号位也能参与正常的二进制加法运算而且进位自然消失结果依然正确。这是计算机组成原理里的基础内容但作为一个C语言开发者理解它能帮你解释很多“奇怪”的现象。关于有符号和无符号有一个几乎所有初学者都会踩的坑有符号整数和无符号整数混合运算时有符号整数会被隐式转换成无符号整数。看这段代码#include stdio.h int main(void) { int a -1; unsigned int b 1; if (a b) { printf(a b\n); } else { printf(a b\n); } return 0; }凭直觉-1 肯定小于 1对吧但运行结果输出的是a b。原因就是比较时a先从int被隐式转换为unsigned int-1 变成了UINT_MAX42949672954294967295 当然不小于 1。这也是为什么我写代码时for循环里的计数器、数组下标这类东西能不用无符号就不用无符号尤其要避免“无符号变量和负数比较”这种局面。2.3 数值溢出有符号无符号的区别溢出的行为有符号和无符号完全是两回事。无符号整数溢出是明确定义的按2^N取模。比如unsigned char x 255; x x 1;结果就是0标准保证这一点。如果你刻意需要“环绕”效果比如实现一个循环计数器用无符号整数就够了。有符号整数溢出是未定义行为undefined behavior。比如int a INT_MAX; printf(%d\n, a 1);这段代码在标准层面没有规定输出什么。在某些编译器上它可能输出-2147483648看起来像“环绕”但在开启优化的情况下编译器可能直接假设“有符号溢出永远不会发生”然后做各种你想象不到的优化导致结果非常离谱。所以千万不能依赖有符号溢出的“环绕”行为来写业务逻辑这是C语言初学者最容易养成的坏习惯之一。limits.h头文件里定义好了各种类型的边界值比如INT_MAX、INT_MIN、UINT_MAX、CHAR_BIT。写可移植代码时不要自己硬编码2147483647这种数字用标准宏来代替。顺便说一句CHAR_BIT表示一个字节有多少位绝大多数平台是8但在一些古老的DSP平台上可能是16用这个宏能让你的代码更稳健。3. 浮点数的真相为什么0.1加0.2不等于0.33.1 IEEE 754浮点数是怎么存成一串二进制的如果你第一次运行下面这段代码#include stdio.h int main(void) { float a 0.1f; float b 0.2f; float c a b; printf(a b %.20f\n, c); return 0; }看到输出结果约等于0.30000000447034835815你可能会怀疑自己写错了。其实没写错这就是浮点数存储的天然缺陷。原因很简单二进制无法精确表示 0.1 或 0.2就像十进制无法用有限小数表示 1/3 一样。0.1 转换成二进制是0.0001100110011001100...是一个无限循环小数。C语言里的float只有32位double只有64位存不下这个无限循环只能截断或舍入到某个精度。因此你在内存里存的0.1其实是一个非常接近但不完全等于0.1的二进制近似值。IEEE 754标准把浮点数拆成三部分符号位、指数位、尾数位。float是1位符号 8位指数 23位尾数double是1位符号 11位指数 52位尾数。用类似“科学计数法”的方式来表示一个数尾数存有效数字指数存小数点移动的位数。这种设计让浮点数能表示极大和极小的数值范围但也意味着它只能精确表示“2的整数次幂的有限组合”比如 0.5、0.25、0.125 都能精确而 0.1、0.2、0.3 都不能精确。3.2 float和double怎么选精度才是关键float的有效精度大约是6到7位十进制数字double大约是15到16位。什么叫有效精度就是说一个数从左边第一个非零数字开始能保证多少位是可信的。比如float存 123456789打印出来可能是 123456792后面几位就不准了存 0.123456789能信的也只有前7位左右。所以能用 double 的地方不要贪省内存用 float。很多初学者写科学计算题、物理模拟题时习惯声明float原因可能是教材里老用float举例。但现在的个人电脑内存动辄8GB、16GB普通程序里多占4个字节根本无感而精度损失却是实打实的。除非你在写单片机、嵌入式、GPU渲染这些对内存带宽极其敏感的场景否则优先double没毛病。还有一个细节printf打印浮点数时默认只显示6位小数。你看到0.300000不代表它真的就是精确的0.3只是默认精度只显示到6位。想看真实情况就用%.10f、%.20f这样的格式控制多打几位。反过来scanf读float用%f读double必须用%lf。很多人把printf和scanf的格式说明符搞混因为printf里%f和%lf效果差不多但在scanf里用错是会导致内存写坏的典型错误。3.3 千万别用 直接比较浮点数浮点数不能直接用比较相等这是无数人踩出来的教训。原因还是那个0.1 0.2 和 0.3 在二进制浮点表示里并不相等哪怕它们肉眼看起来应该相等。正确做法是比较两个数的差的绝对值是否小于某个允许的误差范围epsilon#include stdio.h #include math.h int main(void) { double x 0.1 0.2; double y 0.3; double eps 1e-9; if (fabs(x - y) eps) { printf(x 和 y 在误差范围内相等\n); } else { printf(x 和 y 不相等\n); } return 0; }eps取多少要看具体场景。如果是普通计算题1e-9基本够用如果涉及放大倍数很大的计算比如天文数字可能要动态调整误差。总之把“浮点数不能直接等值比较”这条刻在脑子里能帮你避开一大批看似莫名其妙的问题。更重要的实践建议涉及钱、金额、账户余额等场景绝对不要用浮点数。金融计算要求的是精确十进制舍入规则浮点数的二进制近似会让你在结账时多出0.00000001元。正确做法是用整数存“分”或者用专门的十进制高精度库。这个话我在很多技术群里重复过无数遍但仍然能看到有人用double存金额然后对着“为什么算出来差一分钱”发愁。4. 类型转换编译器替我们做的决定和我们替编译器背的锅4.1 隐式转换C语言悄悄替你做的数学题C语言有一个隐式类型转换规则几乎所有写C的人都被它“偷袭”过。核心规则可以总结成一条链当表达式里操作数类型不一致时编译器会把“较低等级”的类型自动提升为“较高等级”的类型。等级大致是intunsigned intlongunsigned longlong longunsigned long longfloatdoublelong double注意int和unsigned int混用时int会被转成unsigned intint和long混用时int会转成long。规则本身不复杂后果却很迷惑人。举一个面试高频题#include stdio.h int main(void) { int a -1; unsigned int b 1; if (a b) { printf(yes\n); } else { printf(no\n); } return 0; }前面已经讲过了输出是no。这里再从转换规则角度重复一遍比较的时候a被转换成unsigned int变成 4294967295所以是“大数”和1比。你以为在比大小实际上编译器帮你做了一次“无符号化”。更隐蔽的还有整数提升integer promotion。C语言规定char、short参加算术运算时会先被提升成int。这意味着char c1 127; char c2 1; int sum c1 c2;这个计算是手打int加法不会发生溢出因为提升后已经不再是char运算了。但对初学者来说最容易出问题的地方是sizeof(a)。在C语言里字符常量的类型是int不是char所以sizeof(a)的结果在32位/64位平台上通常是4而不是1。很多人乍一看会愣住但这就是标准。这一套隐式转换不是坏事它让C语言写起来更简洁。但问题是它太“安静”了经常在你没注意到的时候就改变了结果。我的建议是在关键计算里显式写出你认为应该参与运算的类型别把正确性寄托在隐式转换上。4.2 强制转换告诉编译器“我知道我在干什么”显式强制转换的语法是(type)expression比如(int)x、(double)count。它的作用是“我就要把这份数据按另一种类型来解释/转换”。但强制转换有一个很不直观的特点它不修改原变量而是生成一个新的值。比如double d 3.99; int n (int)d; printf(%f %d\n, d, n);输出3.990000 3。d还是3.99(int)d只是把小数部分截断了。这个截断是向零方向截断不是四舍五入。如果你需要四舍五入得自己写(int)(d 0.5)或者用round()函数。还有一个初学者必踩的经典坑和整数除法有关float f 1 / 3; printf(%f\n, f);输出是0.000000。原因是1和3都是int1 / 3先按整数除法计算结果是0然后才把0赋给浮点变量f。要写出浮点数至少要让其中一个操作数是浮点类型比如1.0 / 3、1 / 3.0或者(float)1 / 3。很多人问“为什么我算除法总得到0”十有八九就是这个问题。4.3 指针和数组相关的转换别把地址当数值用前面讲过的内容在这里会换个角度出现数组名在很多情况下会“退化”成指向首元素的指针。比如int arr[5] {1, 2, 3, 4, 5}; printf(%p\n, (void *)arr);arr的本质是“数组首元素的地址”它的类型是int *。如果你试图对数组名做数值转换比如(long)arr你得到的是那个地址的整数值而不是把数组里每个元素“变成”long。这与热搜里经常有人问的“c语言数组变量的类型转换”是同一个问题数组的“类型”和“元素类型”是两回事转换数组名不会转换数组内容只会转换地址的“身份”。指针类型之间的强制转换也存在类似的坑。比如int *p和char *q指向同一个地址但p 1移动4个字节q 1移动1个字节因为指针算术的步长取决于指针指向的类型。如果你把一个int *强转成char *后续所有指针运算的步长都变了这既可能是有意为之比如按字节分析内存也可能导致你读错数据。所以做指针强转前先问自己一句你到底想干嘛5. 作用域与生命周期变量不是一直存在的5.1 局部变量、全局变量和那块“栈”内存按位置划分变量大致可以分成局部变量、全局变量、静态变量、外部变量。它们的区别不仅在于“能不能访问”还在于“什么时候创建、什么时候销毁”。局部变量定义在函数内部或块内部花括号包裹的范围内它的生命周期从程序执行到定义处开始到离开所在的块为止。局部变量一般存放在栈区函数调用时在栈上分配函数返回时自动释放。典型的陷阱是返回局部变量的地址int *foo(void) { int x 10; return x; // 危险x在函数返回后就被销毁了 }这个代码编译时可能会给个警告运行后通过返回的指针去读x的值读到的可能是垃圾值也可能碰巧还是10但这是未定义行为随时会崩。很多人学指针时在这里吃过亏根源就是没理解局部变量的生命周期。全局变量定义在所有函数之外存放在数据段从程序启动到程序结束一直存在。它的特点是默认初始化为0静态存储期变量默认清零作用域是整个源文件甚至可以被extern扩展到其他文件。但全局变量也有代价多文件编程时如果命名不好容易互相冲突程序逻辑里隐藏的全局状态越多越难排查问题。还有一个细节如果你同时有全局变量和局部变量同名在函数内部局部变量会遮蔽shadow全局变量。想在这种情况下访问全局变量可以在局部变量名前加上作用域解析符但在纯C里其实没有这种语法只能换名字。我见过有人靠“全局变量叫count局部变量也叫count”来炫技结果代码review时被骂得狗血淋头所以给变量起名时务必注意避免遮蔽。5.2 static关键字一副面孔两种效果static是C语言里最容易被误解的关键字之一因为它有两个截然不同的作用。第一static修饰局部变量把变量的存储期从“自动”改为“静态”。函数里写int counter(void) { static int count 0; return count; }第一次调用counter()时count被初始化为0之后每次调用count的值都会保留上次的结果第二次调用返回2第三次返回3。它的生命周期变成了整个程序运行期间但作用域依然是函数内部外面访问不到。这个特性很适合做“调用次数统计”“全局唯一计数器”之类的功能但也正因为它的状态不会自动清零多线程环境下使用需要额外注意线程安全问题。第二static修饰全局变量把变量的作用域限制在当前源文件编译单元内外部文件即使写了extern也无法访问。这个用途在写多文件项目时很常用可以避免不同文件之间因同名全局变量发生链接冲突。相当于给全局变量加了一层“私密性”。5.3 extern把变量的世界扩大到多个文件一个常见的多文件代码结构是helper.h里写声明#ifndef HELPER_H #define HELPER_H extern int g_counter; #endifhelper.c里写定义#include helper.h int g_counter 0;main.c里使用#include stdio.h #include helper.h int main(void) { g_counter 100; printf(%d\n, g_counter); return 0; }这里的逻辑是extern int g_counter;是“声明”告诉编译器这个变量在别的文件里定义了你先让我用int g_counter 0;是“定义”编译器为它分配内存。声明和定义的区别很多初学者分不清但它恰恰是解决“头文件里到底该写什么”的关键。如果你把定义写进了头文件然后在多个.c文件里#include同一个头文件链接器会报“重复定义”的错误。另一个常在热搜里出现的相关问题“如何查看堆内变量”。用C语言的malloc在堆区分配内存时指针变量本身可能是栈上的局部变量但它指向的内存块在堆上需要手动用free释放。堆上的数据生命周期由程序员控制不像局部变量那样自动销毁。这跟变量的生命周期也有密切关系全局/static变量的生命周期是“程序级”局部变量是“函数级”堆内存的生命周期是“手动级”。搞清楚这三者的区分很多“内存崩溃”“内存泄漏”问题就能快速定位。结构体变量的定义也遵循同样的变量规则。比如struct Student stu;这里的stu就是一个占一块连续内存的变量它的总大小是各成员大小之和还会考虑对齐填充。你可以先把结构体变量理解成一个“打包好的变量”本质依然是“地址内存解释方式”只是C语言还额外告诉了你内部怎么分区。这块内容等讲结构体的时候再展开但底层的变量观念现在就可以建立。6. 常见编译错误与自查清单6.1 “未声明的标识符”其实在说什么我见过很多人刚学C语言时被编译器的报错吓住其实错误信息本身已经把问题说得很清楚了。最常见的错误之一error: x undeclared (first use in this function)翻译成大白话就是“编译器在处理你这行代码的时候没见过叫x的东西。”可能的原因有这么几个变量名拼写错误count写成了conut变量声明写在了使用它的代码之后变量声明在一个块{}内部而你在块外使用根本没有声明这个变量。排查方法很机械但很有效从上往下看找到第一次使用该变量的行然后往上找最近的一次声明。如果找不到补上声明如果声明处的作用域和你使用处不一致调整声明的位置。在IDE里这类错误一般还会给红色波浪线定位更快。用命令行编译的话配合gcc -Wall -Wextra能看到更多潜在问题。还有个很类似的报错是“xxx was not declared in this scope”。别紧张scope就是作用域这个报错本质上也是在说你在一个看不到xxx的区域内使用了它。6.2 格式说明符与参数类型不匹配int x 42; printf(%f\n, x); // 错误x是int%f期望double这个错误在printf中可能不报编译错误因为printf是可变参数函数编译器不一定能检查出每个参数的类型是否与格式说明符匹配。但运行结果经常非常离谱有时候打印出0.000000有时候打印出天文数字。原因在于函数调用时参数按二进制压栈而printf按%f的要求去解释那一串二进制解释方式错了数字自然就错了。用gcc编译时如果开启了-Wall强烈建议编译器能帮你检查出部分格式串和参数不匹配的问题类似warning: format %f expects argument of type double, but argument 2 has type int看到这类警告不要无视哪怕程序“碰巧”输出正常也说明你的代码存在隐性问题。而对于scanf格式串写错更严重因为它是往你传入的指针指向的内存里写数据。比如double d; scanf(%f, d);就是错误用法应该用scanf(%lf, d);。6.3 链接阶段的“未定义引用”和“重复定义”编译错误分两个阶段编译期和链接期。编译期错误通常在你写的源文件内部链接期错误则出现在把多个目标文件合并成可执行文件时。典型报错undefined reference to helper_func大白话是helper_func这个函数的声明在头文件里你已经看到了代码里也调用了但是在链接时链接器翻遍了所有参与链接的目标文件都没找到helper_func的实现。可能的原因是你忘了把helper.c编进去也可能helper_func根本没有定义。解决办法检查编译命令确保相关源文件都参与了编译比如gcc main.c helper.c -o program另一个典型报错是“重复定义”multiple definition of g_counter通常是因为同一个全局变量在多个源文件里各定义了一遍或者定义被写进了头文件并被多个源文件包含。解法请参考上一章extern的用法头文件里只放声明不放定义。6.4 给新手的一份自查清单最后给你一个我写C代码时经常过一遍的清单适合在提交代码或运行崩溃前快速自查检查项说明变量是否存在且拼写正确防止“未声明的标识符”变量声明是否在使用之前C89之前要求所有声明在语句前现代C虽然放开了但声明靠前仍更清晰变量类型是否和表达式一致防止隐式转换带来的意外结果格式说明符是否和参数类型匹配printf/scanf的经典坑数组下标是否越界下标从0开始边界用 长度有符号和无符号是否混用避免负数被解释成巨大正数指针指向的内存是否仍有效别返回局部变量地址强制转换是否真的需要别为了消除警告而乱强转这些自查项看起来基础但我在读别人代码、帮人排查bug的时候遇到的绝大多数“诡异问题”都能落回到这几点上。C语言的变量系统就是这么个东西规则不算多但每条绑定在内存模型上忘记哪一环都可能出大问题。我自己学C语言最深的体会是千万别把变量和数据类型当成“背知识点”的内容一定要动手。每学一个新类型就写个小程序去printf它的sizeof、它的地址、它的边界值看到一份不理解的代码先猜它内存长什么样再写代码验证。纸上得来终觉浅这句老话放在C语言身上比任何一门语言都合适。多踩几个坑多看几次打印结果类型和变量的那点事儿自然而然就通了。
返回列表