我见过不少学C语言的人,循环、函数、结构体都写得很溜,但一问到“这个变量到底存在哪里”“int和float在内存里有什么区别”“为什么有时候文件读出来的数据是反的”,就开始含糊了。其实C语言的核心竞争力,恰恰在于你能控制数据在内存里怎么排、怎么存、怎么取。数据存储这件事,几乎贯穿了指针、数组、字符串、文件操作、内存管理所有重要知识点。
这篇博文就把C语言里的数据存储彻底拆开讲一遍。从最小的bit到GB/TB的换算逻辑,从变量在内存中的分区到整数、浮点数的二进制存储规则,从字符串的地址陷阱到文件读写时的字节序列,再到动态内存管理的常见坑。适合刚学完语法想往深处走的人,也适合准备面试想系统梳理C语言底层知识的人。
1. 从bit到TB:先把存储单位的来龙去脉理清楚
1.1 为什么计算机的最小存储单位是bit
很多人背过“1 byte = 8 bit”,但没想过为什么是bit,更没想过为什么偏偏是8个bit组成一个byte。
bit的全称是binary digit,也就是二进制数字,只有0和1两种状态。计算机底层是电路,电路里最本质的状态就是“有电压”和“没电压”,对应下来就是1和0。所以计算机无法直接存储十进制数字、英文字母或者汉字,它只能存一堆0和1。这是理解一切存储问题的基础。
你可以把bit想成一个开关,开关只有开和关两个状态。一个开关能表达2种情况,两个开关能表达4种,三个能表达8种。n个开关能表达2^n种情况。这就是存储空间的本质——开关的数量。
1.2 字节(byte)是怎么成为通用单位的
一个bit能表达的信息实在太少了。一个英文字母用7个bit就能编码(ASCII码),但为了冗余和扩展,计算机界定了8个bit为一组,称为一个字节(byte)。后来这成了事实标准,几乎所有的存储容量、文件大小、内存大小都是以字节为基本单位来计量的。
这里有个经典换算问题:1 KB到底是1000字节还是1024字节?
严格来说,在计算机内部,因为寻址机制是二进制的,所以1 KB定义为2^10 = 1024字节,1 MB = 1024 KB,1 GB = 1024 MB,1 TB = 1024 GB。
但硬盘厂商习惯用十进制来标注容量,1 KB = 1000字节,所以一块标称500 GB的硬盘,插到电脑上显示只有465 GB左右,这不是硬盘坏了,而是换算标准不同。做嵌入式开发时也容易碰到这个问题,Flash芯片容量标注和实际可用容量之间经常差一截,通常就是厂商用了十进制标注。
存储单位换算关系整理如下:
| 单位 | 缩写 | 二进制换算 | 大致规模 |
|---|---|---|---|
| bit | b | 最小单位 | 一个开关状态 |
| byte | B | 1 B = 8 bit | 一个英文字符 |
| kilobyte | KB | 1 KB = 1024 B | 一小段文本 |
| megabyte | MB | 1 MB = 1024 KB | 一张高清图片 |
| gigabyte | GB | 1 GB = 1024 MB | 一部电影 |
| terabyte | TB | 1 TB = 1024 GB | 一块大硬盘 |
写代码时还要注意区分bit和byte的缩写。网络带宽通常是Mbps(兆比特每秒),文件下载速度是MB/s(兆字节每秒),8 Mbps的宽带理论下载速度只有1 MB/s。这个换算在日常编程里经常碰到,比如说你做网络通信,缓冲区大小开错了,很容易出问题。
1.3 sizeof 在存储计算中的角色
C语言里计算存储大小,最常用的就是sizeof运算符。注意sizeof不是函数,它是运算符,在编译期就能计算出结果。
#include <stdio.h> int main(void) { printf("char: %zu\n", sizeof(char)); printf("short: %zu\n", sizeof(short)); printf("int: %zu\n", sizeof(int)); printf("long: %zu\n", sizeof(long)); printf("float: %zu\n", sizeof(float)); printf("double: %zu\n", sizeof(double)); printf("pointer: %zu\n", sizeof(void*)); return 0; }在常见的32位/64位平台上,char是1字节,short是2字节,int是4字节,float是4字节,double是8字节。指针在32位平台是4字节,在64位平台是8字节。
而且sizeof对数组和指针的处理非常容易让人踩坑。当数组作为函数参数传递时,会退化成指针,所以在函数内部sizeof(arr)得到的不是数组大小,而是指针大小。这是个几乎每次写代码都会遇到的经典坑,很多面试题也喜欢从这里出。
2. 变量存在哪里:C语言的内存分区与存储位置
2.1 四个关键区域
一个C程序跑起来之后,内存大致分为几个区域:栈区、堆区、全局区(静态区)、常量区,还有代码区。
栈区(stack)是编译器自动管理的内存区域,用来存放局部变量、函数参数、返回地址。它的特点是分配和释放都由系统自动完成,函数一调用就分配空间,函数一返回就释放。栈的大小有限制,一般Linux默认是8 MB左右,Windows下默认1 MB左右。如果你在函数里定义一个很大的局部数组,比如int arr[1000000],很可能直接栈溢出。
堆区(heap)是程序员手动管理的内存区域,通过malloc、calloc、realloc申请,通过free释放。堆的空间理论上受限于系统的可用内存,比栈大得多。但堆的分配和释放必须由程序员控制,用不好就会出现内存泄漏、悬空指针、double free这些问题。
全局区(静态区)存放全局变量和static修饰的变量。这些变量在程序启动时就分配好空间,直到程序结束才释放。全局区的生存周期是整个程序的生命周期。注意区分:static修饰的局部变量虽然作用域还是函数内,但存储位置在全局区,所以它不会因为函数调用结束而销毁。
常量区存放字符串字面量和const修饰的常量。这个区域的特性是只读,试图修改它会导致程序崩溃或未定义行为。
2.2 一个例子看清变量存储位置
#include <stdio.h> #include <stdlib.h> int global_var = 100; // 全局区 static int static_var = 200; // 全局区(静态区) int main(void) { int local_var = 300; // 栈区 static int local_static = 400; // 全局区 int *heap_var = malloc(sizeof(int)); // 堆区 *heap_var = 500; char *str = "hello"; // 字符串字面量在常量区,str本身在栈区 printf("global: %p\n", &global_var); printf("static: %p\n", &static_var); printf("local: %p\n", &local_var); printf("local_static: %p\n", &local_static); printf("heap: %p\n", heap_var); printf("literal: %p\n", str); free(heap_var); return 0; }运行后打印的地址就能看出规律:全局变量和static变量的地址挨得很近,局部变量在栈区,malloc的地址在堆区。地址的分布和系统、编译器有关,但区域的划分是固定的。
2.3 内存布局对调试的意义
理解内存分区对调试特别有用。
栈溢出时,程序一般会报Segmentation fault,或者出现奇怪的覆盖。遇到这种问题,首先检查函数里有没有定义过大的局部数组。
堆内存问题则更隐蔽。非法访问堆内存不一定立即崩溃,有时过一段时间才出错。因为malloc的内部实现会维护一些元数据,如果你越界写了,可能破坏了这些元数据,导致后续malloc或free崩溃。
还有一点,全局变量默认会被初始化为0,局部变量不会。局部变量的初始值是不确定的,可能是上一段代码留下的残值,也有可能是任意垃圾数据。所以为什么说局部变量一定要初始化,原因就在这里。你看到的“随机值”并不是随机,而是那块内存里恰好残留的数据。
3. 整数与浮点数的存储机制:最容易被误解的底层细节
3.1 整数的补码存储
C语言中,整数按照补码(two‘s complement)的形式存储。为什么用补码?最直接的原因是:补码可以把减法统一为加法,这样计算机只需要设计加法器,不需要单独的减法器,硬件设计大幅简化。
补码的规则是这样的:
- 正数的补码就是它的二进制原码。
- 负数的补码是原码按位取反再加1。
以char类型(8位)举例:
| 数值 | 原码 | 补码(实际存储) |
|---|---|---|
| 5 | 0000 0101 | 0000 0101 |
| -5 | 1000 0101 | 1111 1011 |
| 0 | 0000 0000 | 0000 0000 |
| -128 | 无法表示 | 1000 0000 |
注意-128的补码是1000 0000。这是因为补码的表示范围是不对称的:8位有符号char,范围是-128到127,负数比正数多一个。signed char能表示的最小负数就是-128。
有个很容易出错的点是有符号整数溢出。C标准里,有符号整数溢出是未定义行为(undefined behavior),编译器可以任意处理。你写int i = 2147483647; i++;,结果不一定是-2147483648,虽然很多编译器在调试模式下会这样,但你不能依赖这个行为。做数据校验时一定要提前判断是否溢出。
3.2 浮点数的IEEE 754存储
浮点数的存储比整数复杂得多,使用的是IEEE 754标准。
float占用32位,分布是:1位符号位,8位指数位,23位尾数位。double占用64位,分布是:1位符号位,11位指数位,52位尾数位。关键在于,指数位用偏移量(bias)来表示负数指数,float的偏移量是127,double的偏移量是1023。
具体来说,一个浮点数表示为:
(-1)^sign × 1.mantissa × 2^(exponent - bias)
这里有个隐含的规则:尾数部分的最高位始终是1,所以不用存储它,能多出一位精度。这就是为什么叫“隐藏位”或者“隐式前导位”。
举个例子,浮点数5.0存储过程:
先把5.0转成二进制:101.0,科学计数法表示为1.01 × 2^2。
- 符号位:0(正数)
- 指数:2 + 127 = 129,二进制是1000 0001
- 尾数:01,补齐23位是0100 0000 0000 0000 0000 000
拼起来就是:0 10000001 01000000000000000000000
这就是5.0f在内存中的二进制表示。
浮点数最大的坑在于精度。0.1在十进制里是有限小数,但在二进制里是无限循环小数,所以float和double都只能存一个近似值。这就解释了为什么0.1 + 0.2不等于0.3:
#include <stdio.h> int main(void) { float a = 0.1f; float b = 0.2f; if (a + b == 0.3f) { printf("equal\n"); } else { printf("not equal: %.10f\n", a + b); } return 0; }判断两个浮点数是否相等,不能用==,应该用绝对值差:
#include <math.h> #include <stdio.h> int float_equal(float a, float b, float epsilon) { return fabsf(a - b) < epsilon; }这里的epsilon一般取1e-6或1e-7,根据实际需求确定。
3.3 大小端:数据在内存中的字节序
同一个int变量,比如int x = 0x12345678,在内存里按地址从低到高排列,有两种排法:
- 大端(Big-Endian):高字节在前,低字节在后。存的是0x12 0x34 0x56 0x78。
- 小端(Little-Endian):低字节在前,高字节在后。存的是0x78 0x56 0x34 0x12。
x86架构和大多数ARM处理器默认是小端。网络传输协议里规定使用大端字节序,所以做网络编程时必须转换字节序。
判断当前系统是大端还是小端,可以这样写:
#include <stdio.h> int main(void) { int x = 1; char *p = (char*)&x; if (*p == 1) { printf("little-endian\n"); } else { printf("big-endian\n"); } return 0; }这段代码的原理是取int变量的首地址,然后看这个字节的内容是1还是0。小端下,低地址存的是最低字节,也就是0x01,所以* p == 1。
大小端问题在实际开发中主要出现在:解析二进制协议、读写二进制文件、网络字节序转换、跨平台数据通信。如果你把一个小端系统上写的二进制文件直接拿到大端系统上读,数据就会完全错乱。解决办法是约定统一的字节序,或者在读写时做转换。
4. 数组、指针与字符串的存储细节
4.1 数组在内存中是连续排布的
C语言的数组在内存中是连续存放的,这是数组最本质的存储特征。int arr[5]会在内存中占用20个字节,arr[0]在低地址,arr[4]在高地址,元素之间没有空隙(不考虑对齐的情况)。
这种连续存储带来两个特点:
第一,数组支持随机访问。计算arr[i]的地址就是首地址 + i × sizeof(int),时间复杂度O(1)。
第二,数组越界不检查。C语言不会在编译期帮你检查arr[5]是否越界,越界访问会导致未定义行为,可能读写到相邻变量的内存。这就是很多安全漏洞的根源。
数组名和指针的关系是另一个容易混淆的点。数组名在大多数表达式中会退化为指向首元素的指针,但有两个例外:sizeof(数组名)计算的是整个数组的大小,&数组名得到的是指向整个数组的指针。
#include <stdio.h> int main(void) { int arr[5] = {1, 2, 3, 4, 5}; printf("sizeof(arr) = %zu\n", sizeof(arr)); // 20 printf("arr = %p\n", arr); // 首元素地址 printf("&arr = %p\n", &arr); // 整个数组地址 printf("arr + 1 = %p\n", arr + 1); // 加4字节 printf("&arr + 1 = %p\n", &arr + 1); // 加20字节 return 0; }arr + 1指向第二个元素,而&arr + 1直接跳过了整个数组。这两个概念搞混了,在二维数组和指针运算里会出各种问题。
4.2 字符串字面量与字符数组存储位置不同
字符串在C语言中是以’\0‘结尾的字符序列。涉及的存储位置有两种:
- 字符串字面量,如"hello",存储在常量区(只读区)。
- 字符数组,如char s[] = "hello",存储在栈区。
修改字符串字面量的内容是未定义行为,通常会崩溃:
char *p = "hello"; p[0] = 'H'; // 错误!试图修改常量区 char s[] = "hello"; s[0] = 'H'; // 正确,s是栈上的数组这里还有一点需要注意。sizeof(“hello”)的结果是6,因为包含结尾的’\0‘。但用strlen(”hello“)计算是5。这两个函数经常被搞混,sizeof算的是占用空间,strlen算的是字符串长度,不含结尾符。
4.3 结构体对齐与填充
结构体的存储不是简单地把成员顺序排布。为了CPU访问效率,编译器会对齐成员的地址。
#include <stdio.h> struct A { char c; // 1字节 int i; // 4字节 }; int main(void) { printf("sizeof(struct A) = %zu\n", sizeof(struct A)); // 通常是8 return 0; }理论计算是1 + 4 = 5字节,但实际是8字节。因为int需要4字节对齐,char后面有3个填充字节。这个填充在结构体保存到文件或者网络传输时会造成问题,因为填充字节里的内容是未定义的。
一个经典面试题:结构体重新排列成员顺序,能不能省内存?答案是能。
struct A { char c; int i; }; // 8字节 struct B { int i; char c; }; // 8字节 struct C { char c; char c2; int i; }; // 8字节 struct D { int i; char c; char c2; }; // 8字节看起来好像C和D差不多。但如果是数组,struct C arr[2]是16字节,struct D arr[2]也是16字节。真正省内存的方法是把大类型放前面,小类型放一起。实际情况需要结合具体的对齐规则分析,不同编译器可能有差异。做嵌入式开发时要对结构体内存布局有精确把握,通常可以加#pragma pack或__attribute__((packed))来控制对齐,但代价是访问效率下降。
5. 文件读写:数据持久化的核心操作方法
5.1 文本读写与二进制写读的区别
C语言的文件操作核心函数是fopen、fclose、fread、fwrite、fprintf、fscanf、fseek、ftell。
首先要明确文本模式和二进制模式的区别。文本模式下,换行符会被转换:Windows下\r\n和\n互相转换,而Linux下没有转换。二进制模式不做任何转换,字节原样读写。
所以在Windows上,如果你以文本模式读取一个二进制文件,文件内容可能被破坏。跨平台处理二进制文件,一定要在fopen的模式中加b:
FILE *fp = fopen("data.bin", "rb"); // 读二进制 FILE *fp2 = fopen("data.bin", "wb"); // 写二进制5.2 fprintf和fscanf的使用要点
fprintf和fscanf是文本模式下的格式化读写。将结构体变量写入文件:
#include <stdio.h> typedef struct { char name[32]; int age; double score; } Student; int main(void) { Student s = {"Alice", 20, 95.5}; FILE *fp = fopen("student.txt", "w"); if (fp == NULL) { perror("fopen"); return 1; } fprintf(fp, "%s %d %lf\n", s.name, s.age, s.score); fclose(fp); return 0; }读回来:
#include <stdio.h> typedef struct { char name[32]; int age; double score; } Student; int main(void) { Student s; FILE *fp = fopen("student.txt", "r"); if (fp == NULL) { perror("fopen"); return 1; } fscanf(fp, "%s %d %lf", s.name, &s.age, &s.score); printf("name=%s, age=%d, score=%.2lf\n", s.name, s.age, s.score); fclose(fp); return 0; }fscanf有个坑:如果读取失败,它不会清空目标变量,可能导致使用未定义数据。所以一定要检查返回值,fscanf返回成功转换的参数个数,比如上面的例子如果完全读取成功,返回值是3。
5.3 fwrite和fread实现二进制存储
如果数据量很大,或者需要保存结构体数组,用二进制方式更高效。fwrite直接按内存中的字节序列写入文件,没有格式化转换的损耗。
#include <stdio.h> #define SIZE 3 typedef struct { int id; double value; } Item; int main(void) { Item items[SIZE] = {{1, 10.5}, {2, 20.5}, {3, 30.5}}; FILE *fp = fopen("items.bin", "wb"); if (fp == NULL) { perror("fopen"); return 1; } size_t written = fwrite(items, sizeof(Item), SIZE, fp); if (written != SIZE) { fprintf(stderr, "write error\n"); } fclose(fp); return 0; }fwrite的参数比较多:第一个是数据源地址,第二个是单个元素字节数,第三个是元素个数,第四个是文件流。返回实际写入的元素个数,不是字节数。
读回来:
#include <stdio.h> #define SIZE 3 typedef struct { int id; double value; } Item; int main(void) { Item items[SIZE]; FILE *fp = fopen("items.bin", "rb"); if (fp == NULL) { perror("fopen"); return 1; } size_t read_count = fread(items, sizeof(Item), SIZE, fp); printf("read %zu items\n", read_count); for (int i = 0; i < read_count; i++) { printf("id=%d, value=%.2lf\n", items[i].id, items[i].value); } fclose(fp); return 0; }这里注意一个问题:上面这段代码把head里存的指针值直接写入了文件。指针是地址,下次运行时地址可能完全不同,直接读回来用就出问题了。正确的做法是给文件内的实体分配空间,把内容复制进去,再把地址写入head节点。另外,把整个结构体直接fwrite到文件里,会把对齐填充字节也写进去,不同编译器的填充规则可能不同,导致文件格式不能跨编译器兼容。所以二进制文件格式尽量在协议层设计,不要依赖编译器的结构体内存布局。
5.4 fseek和ftell定位文件位置
fseek支持随机访问文件中的任意位置,原型是:
int fseek(FILE *stream, long offset, int whence);whence有三个取值:SEEK_SET表示从文件开头偏移,SEEK_CUR表示从当前位置偏移,SEEK_END表示从文件末尾偏移。
ftell返回当前文件位置相对开头的偏移量,常用来获取文件大小:
#include <stdio.h> long get_file_size(FILE *fp) { fseek(fp, 0, SEEK_END); long size = ftell(fp); fseek(fp, 0, SEEK_SET); return size; }fseek和ftell配合,可以只读文件中间的某一段,不必每次都从头开始。这对大文件尤其有价值,能减少I/O量。
做文件读写时,fclose之前最好检查是否写成功。fclose本身会尝试刷新缓冲区,如果有数据没写完,fclose返回EOF。严谨的代码应该判断fclose的返回值,虽然大多数情况下你不会刻意去检查。
6. 动态内存管理:堆上数据存储的实践与常见坑
6.1 malloc、calloc、realloc、free的正确用法
动态内存是C语言数据存储的重点和难点。
malloc分配指定字节数的内存,不初始化。calloc分配指定元素个数和每个元素大小的内存,并自动清零。realloc调整已分配内存的大小。free释放之前分配的内存。
#include <stdlib.h> #include <string.h> #include <stdio.h> int main(void) { // malloc int *p = malloc(10 * sizeof(int)); if (p == NULL) { // 处理分配失败 return 1; } // calloc int *q = calloc(10, sizeof(int)); if (q == NULL) { free(p); return 1; } // realloc int *r = realloc(p, 20 * sizeof(int)); if (r == NULL) { // realloc失败,原内存仍然有效 free(p); free(q); return 1; } p = r; free(p); free(q); return 0; }malloc返回的指针默认是void*,在C语言中不需要强制转换,C++需要。但很多代码风格里会显式转换,因为C++编译器要求这么做。
有一种常见错误:malloc之后没有立即检查返回值。在单片机和嵌入式开发中,内存较小,malloc失败的可能性不能忽略。即使是PC程序,大量申请内存也可能失败。正确做法是每个malloc都要检查是否为NULL。
6.2 内存泄漏、悬空指针、double free
内存泄漏是最常见的内存问题,指的是程序申请了内存但不再使用后没有free。长跑的服务如果不断泄漏内存,最终会耗尽系统内存导致程序崩溃。
排查内存泄漏,Linux下可以用Valgrind,Windows下可以用Visual Studio的调试工具,或者CRT库自带的内存检测函数。这些工具都很有用,但更根本的还是培养良好的编码习惯:谁申请谁释放,在函数入口就明确释放逻辑。
悬空指针是指指针指向的内存已经释放,但指针本身还存在。释放后访问该指针属于未定义行为,可能崩溃也可能得到垃圾数据。常见场景:
int *p = malloc(sizeof(int)); *p = 42; free(p); *p = 99; // 悬空指针,危险安全的做法是free后把指针置为NULL:
free(p); p = NULL;虽然这不能完全防止问题(比如多个指针指向同一块内存),但至少能避免直接误用。
double free就是重复释放同一块内存,同样会破坏堆管理器的元数据,导致崩溃或安全漏洞。free之后,指针的值实际上没有改变,但内存已经被释放,第二次free会触发未定义行为。
一个综合的坑是realloc失败后的指针丢失:
int *p = malloc(sizeof(int) * 10); p = realloc(p, sizeof(int) * 1000000); // 如果realloc失败,p被设置为NULL,原内存泄漏了正确写法是先用临时指针接收realloc返回值,判断成功后再赋值给原指针。
6.3 在嵌入式场景下的存储注意
很多嵌入式设备内存有限,使用动态内存要谨慎。实时系统通常要求分配时间可控,malloc的分配时间是不确定的,这在某些场景下不可接受。有些嵌入式项目干脆禁止使用动态内存,全部用静态数组或栈上分配。
如果你在写单片机程序,要注意数据对齐和字节序问题。有些架构要求对齐访问,不对齐会触发硬件异常。跨芯片传输数据时,如果双方字节序不同,也需要统一处理。
7. 数据存储相关的面试高频题梳理
7.1 必背考点
数据存储相关的面试题,本质上是考你到底有没有理解内存和二进制。常见的考点集中在几类:整数的取值范围与溢出、浮点数的精度、大小端判断、数组与指针的区别、结构体对齐、动态内存的正确使用。
| 考点 | 常见问法 | 关键答案 |
|---|---|---|
| sizeof与strlen | sizeof(“hello”)是多少 | 6,包含’\0‘ |
| 整型溢出 | int最大值加1会怎样 | 有符号溢出是未定义行为 |
| 浮点比较 | 0.1 + 0.2 == 0.3成立吗 | 不成立,要使用误差比较 |
| 数组退化 | 函数参数里sizeof(arr)是多少 | 指针大小,不是数组大小 |
| 内存释放 | free后要置空吗 | 建议置空,防止悬空指针 |
| 结构体大小 | 包含char和int的结构体多大 | 受对齐影响,通常8字节 |
7.2 典型题目拆解
“字符串逆序”是PTA和许多考试里的高频题。思路是交换首尾字符:
#include <stdio.h> #include <string.h> void reverse(char *s) { int len = strlen(s); for (int i = 0; i < len / 2; i++) { char tmp = s[i]; s[i] = s[len - 1 - i]; s[len - 1 - i] = tmp; } } int main(void) { char s[] = "hello"; reverse(s); printf("%s\n", s); return 0; }这道题的核心考点是:你在main里定义的是字符数组而不是字符串字面量,才能原地修改。如果写成char *s = "hello",reverse函数会去修改常量区,程序崩溃。
另一个高频题是统计字符串中数字出现的次数,看起来简单,但用到了数组做计数、字符和数字的转换,是C语言数据存储的综合考察。
还有一题是“输入一个日期的年月日,计算这是该年的第几天”,考察的核心是数组存储每月的天数,然后做累加。这类题目的关键是把数据用合适的方式存起来,然后按逻辑遍历。
面试官还经常问“数组和指针有什么区别”。理解这个问题,其实已经掌握了C语言存储的本质。数组是一块固定大小的连续内存,大小在定义时确定;指针是一个保存地址的变量,可以指向任何地方。数组名在大多数表达式中退化为指针,但sizeof和&运算符的结果不同。把这两者彻底区分清楚,数据存储的核心也就理解透了。
回到最开始说的,数据存储不是简单的“变量放到内存里”这么一句话。它决定了你写的代码在底层如何工作。理解了存储单位,你就知道为什么有些文件那么小;理解了内存分区,你就知道局部变量和全局变量为什么行为不同;理解了补码和IEEE 754,你就知道整数溢出和浮点精度问题从哪来;理解了大小端,你就知道网络协议为什么要做字节序转换;理解了文件读写,你才能把数据安全地保存下来;理解了动态内存管理,你才能写出长时间运行不崩溃的程序。
我自己的体会是,C语言学到后期,很多看起来“玄学”的问题,最后都能归结到数据存储的某个细节上。遇到bug的时候,不要急着上调试器到处打日志,先想想这个数据存在哪、以什么形式存的、生命周期是多久,往往一下就能定位到问题。这也是C语言比起其他高级语言,更值得花时间深挖的地方。