C语言入门基本概念:变量、指针、数组与内存解析
1. 开始之前先把“学C语言到底在学什么”这个问题想清楚1.1 为什么几十门语言里老鸟总建议你先啃C每个刚开始接触编程的人都会遇到同一个困惑网上有Python、Java、Go、JavaScript这么多教程为什么身边的老鸟还是建议先学C语言我当年也很不服气觉得学一个“看起来像古董”的语言没什么用。直到自己踩了三四年坑之后回头再看才明白一个道理C语言不是为了让你直接写出最酷炫的网站而是为了逼你理解计算机是怎么工作的。C语言离底层很近。你写的每一个变量、每一次函数调用、每一个数组下标最终都要落回到内存地址和机器指令上。你学Python时不需要关心int到底占几个字节学Java时不需要关心指针这个东西但学C语言时“程序在内存里长什么样”这个问题会被赤裸裸地摊在你面前。搞懂这些再去看其他语言就像拿到了一本地图你知道变量、函数、数组、递归这些概念背后真正的物理含义不会被语法糖迷惑。C语言本身也很小。标准C只有几十个关键字语法规则简单但这恰恰是入门的好处。小意味着你可以真的学完小也意味着你写的每一行代码都没有框架帮你兜底所有细节都需要你自己负责。一个人只有自己握过方向盘、轧过路肩才知道车道是怎么画出来的。所以这篇《C语言入门一》不打算急着给你堆语法规则而是先把“基本概念”这条线捋清楚。适合真正零基础的读者也适合那些学过一点但总觉得概念稀里糊涂的人。这一篇讲透最核心的地基后续再聊具体进阶技巧。1.2 从代码到运行你写的C程序经历了什么很多初学者写第一段Hello World时只是照着敲了一遍然后运行成功但并不知道在这几秒里计算器帮我们干了什么。了解这个过程非常值得因为后面所有报错信息都从这里来。整个流程分四步预处理、编译、汇编、链接。预处理阶段处理#include、#define这些以井号开头的指令它会把引用的头文件内容“复制粘贴”到你的源码里编译阶段把C代码翻译成汇编代码汇编阶段再把汇编代码翻译成机器指令生成目标文件在Windows上是.obj在Linux上是.o链接阶段把你写的目标文件和系统库、其他目标文件合并最终生成可执行文件。这个过程用一行命令就能完成。在Linux或macOS上用gccgcc -o hello hello.c在Windows上用MinGW或Visual Studio也可以。如果你想看中间每一步的产物用-E看预处理结果用-S看汇编代码gcc -E hello.c -o hello.i gcc -S hello.c -o hello.s我强烈建议你试一次哪怕看不懂汇编也可以打开.s文件瞄一眼。你会看到自己写的printf变成了某种奇奇怪怪的文本那是人类和机器之间的中间语言。知道程序不是凭空运行的后面遇到“编译通过但运行崩溃”的情况就不会完全无从下手。注意现在还有不少教程喜欢直接让你用在线编译器比如OnlineGDB、菜鸟工具这类。我建议新手还是要在本机配置一次gcc环境原因很简单——调试、看错误信息、断点查看变量这些能力在线工具很难完整提供给你。环境配置步骤不复杂网上随便搜都能找到别在这件事上偷懒。2. 变量与数据类型内存视角下的第一课2.1 变量不是数学里的x而是内存格子我第一次学C语言最大的障碍是总把变量想象成数学方程里的未知数。但C语言里的变量完全是另一回事它代表一块内存区域这块区域有地址、有大小、有存储的内容。int a 10;这句话实际上是让系统帮你在内存里划出4个字节在绝大多数平台上把整数10按照特定的二进制格式放进去并且给这个起始位置取了个名字叫a。理解这一点很多问题就有了答案。为什么变量必须先声明后使用因为你要提前告诉编译器“我需要一块多大、什么类型的内存”。为什么不同类型占用的字节数不一样因为表达不同取值范围的数据需要不同数量的二进制位。为什么赋值操作可以改变变量的值因为你在修改那块内存格子里存放的内容。你可以用一个最简单的方法来“看见”变量背后的内存信息用printf打印地址和大小#include stdio.h int main() { int a 10; printf(a的值: %d\n, a); printf(a的地址: %p\n, (void*)a); printf(a占用的字节数: %zu\n, sizeof(a)); return 0; }是取地址运算符sizeof是运算符不是函数这两个概念现在先混个脸熟。跑一下这段代码你会看到地址是一个十六进制数字比如0x7ffd...每次运行可能还不一样。这足以说明变量背后确实有实实在在的内存位置。2.2 整数、浮点和字符怎么选才不踩坑C语言内置的几种基本类型新手只需要先掌握三族整数族、浮点族、字符型。整数包括int、short、long、long long以及带不带unsigned前缀的区别。浮点数主要是float和double。字符型就是char。选类型的原则很简单先看取值范围。int通常占4个字节能表示大约正负21亿long long占8个字节更大float约7位有效数字double约15位有效数字。如果做财务或者科学计算直接用double如果只存一个英文字母或ASCII字符用char如果计数器不可能为负考虑unsigned int。初学者最容易掉进一个坑把浮点数和整数混用。比如int a 3 / 2;你以为结果是1.5实际上这个表达式里两个操作数都是整数执行的是整数除法结果直接截断成1然后赋值给aa的值就是1。想要得到1.5至少要让其中一个操作数变成小数写成3.0 / 2或者3 / 2.0。另一个坑是float精度不足。float f 3.1415926535;然后打印出来可能变成3.1415927因为float只有约7位有效数字后面那些位被截断了。很多初学者在printf输出浮点时喜欢用%f其实%f对应double%lf也是对应double尤其在printf里两者等价但在scanf里%lf和%f就有严格区分。Printf时用%f没问题scanf读取float用%f读取double用%lf这个千万别记反。2.3 常量、宏和类型转换的边界常量在C里比较好理解就是不能在运行期改动的值。字面量常量比如10、3.14、A还有用const关键字修饰的变量声明之后就不能再赋值。另外一种是宏常量通过#define PRICE 100定义它其实是在预处理阶段做文本替换把所有PRICE替换成100。我见过很多新手把#define当成一种变量定义这是不对的。它没有类型也不占运行时内存本质只是文本替换。类型转换也是基本概念里绕不开的一环。转换分隐式和显式两种。隐式转换发生在不同数据类型一起运算时编译器会自动把低精度转成高精度。比如int double那个int会被转成double再运算。显式转换则是你手动写的强制类型转换比如(double)a / b代表先把a转换成double再参与除法。强制类型转换容易出两个问题。一是精度损失double转int会把小数部分丢弃不是四舍五入。二是溢出风险把一个很大的long long转成int高字节被截断结果变成什么谁都没法一眼算出来。所以能用数学方式避免转换就尽量避免比如先用1.0 *做乘法让式子变成浮点运算而不是硬着头皮去转类型。3. 运算符和控制流让程序学会判断和循环3.1 表达式求值顺序和运算符优先级如果说变量是程序里存放数据的抽屉那运算符就是抽屉之间的连接线。所有计算都发生在表达式里而表达式由运算符和操作数组成。算术运算符 - * / %、关系运算符 !、逻辑运算符 || !、位运算符 | ^ ~新手阶段先重点掌握前三种位运算可以等学到进阶再补。运算符优先级不需要死记但有几个原则必须形成肌肉记忆赋值号的优先级极低所以a 1 2 * 3是先算右边的表达式再赋值逻辑与和逻辑或||具有短路求值特性意思是如果左边的结果已经能决定整个表达式的真假右边的表达式根本不会执行。比如a ! 0 b / a 10当a为0时左边就是假右边b / a根本不会算就不会发生除零错误。这个特性很实用但新手容易忽略。优先级方面实际项目里最稳妥的做法不是背表而是依赖两层保障第一用括号表达你的本意(a b) * c和a b * c一眼可辨第二写完后自查一遍比较运算符不要和赋值运算符混写。比如判断a是否等于b要写a b而不是a b。后者是赋值会把b的值赋给a然后整个表达式作为判断条件几乎永远为真只要b不为0。3.2 if/switch与循环语句选择结构的分寸if语句是程序做判断的基本方式。格式上要特别注意即使只有一个语句也建议总是带花括号这样以后往里加代码时不容易出错。判断条件里C语言中任何非零值都为真0为假但为了可读性比较类判断建议直接写if (score 60)这种不要写if (score - 60 0)绕弯子。多分支除了else if还有switch。switch适合分支判断条件是一个离散整数或字符的情况比如根据菜单选项数字执行不同逻辑。switch里最容易漏掉的是每个case块末尾的break。如果不写break程序会继续穿过下一个case执行这叫“落空”行为。有些老手会故意利用落空特性做几个case共用一段逻辑但如果刚入门我的建议是规规矩矩写break避免产生微妙bug。循环主要有while、do while和for三种。while先判断条件再执行循环体条件初始为假时一次都不执行do while先执行一次循环体再判断条件至少执行一次for本质上是一种“计数循环”的简写由初始化语句、循环条件、更新表达式三部分组成。比如打印1到10for (int i 1; i 10; i) { printf(%d\n, i); }新手容易在for循环里改变循环变量的值比如在循环体里给i重新赋值。我尽量不在循环体里修改循环变量实在要改也会先用另一个变量保存临时值否则循环次数会变得难以预测。3.3 循环里的break、continue和死循环break用于跳出当前循环continue用于跳过本次循环剩余部分、直接进入下一轮。两者用途完全不同。比如你要从数组里找第一个大于100的数找到就跳出循环这就是break你要跳过负数值不处理但继续循环处理下一个数这就是continue。死循环在入门阶段经常被当成“程序卡死了”其实是循环条件永远成立。最常见的原因有三个循环控制变量没有更新比如while (i 10)里忘记写i更新变量写在了一些永远不会执行到的分支里把赋值写成了恒等比如while (i 1)循环条件直接变成常量真。遇到程序卡住可以按CtrlC终止然后用printf在循环体里打印当前变量值很快就知道它卡在哪一步。无限循环并不是完全没有用处。在事件循环、服务器监听这些场景里程序员反而会刻意写while (1)这也是C语言所谓“裸露”的风格之一。但作为刚入门的学习者先学会创建一个能在有限次数内结束的循环比追求技巧更重要。4. 函数与作用域拆解代码的基本组织方式4.1 函数签名和返回值子过程思维C语言里的函数是一段具有名字的、完成特定功能的代码块。你可以在某个地方调用它让它执行然后拿到结果。主函数main是所有程序的入口程序启动后自动从main函数第一行开始执行。函数的核心组成包括返回类型、函数名、参数列表和函数体。比如int add(int x, int y) { return x y; }int是返回类型add是函数名x和y是形式参数。定义函数时返回类型是void就表示不返回任何值。函数可以嵌套调用比如add里可以再调用另一个函数但C语言不允许在函数内部再定义另一个完整的函数这是语法层面规定好的别和Python等语言搞混。把代码拆成函数目的不是让代码变短而是让每个函数只承担一个清晰的任务这样调试时你只需要怀疑某一个函数内部的问题。一个函数最好控制在几十行内如果超过一百行就要考虑它是不是做了太多事。这个习惯越早养成越受益。4.2 参数传递与局部变量栈上的故事C语言函数参数默认按值传递。也就是说调用add(a, b)时把变量a和b当前的值取出来复制一份传给函数里的x和y。函数内部对x、y的任何修改都不会影响外面的a、b。初学者最容易在这儿犯迷糊为什么我在函数里明明给参数赋值了回到外面变量还是老样子因为你们操作的根本是两份内存。如果想让函数修改外面的变量你需要传地址。这就需要指针出场最简单的情形就是把变量的地址作为参数传进去然后在函数里通过解引用操作修改那个地址里存放的内容。比如交换两个数的经典函数void swap(int *p1, int *p2) { int tmp *p1; *p1 *p2; *p2 tmp; }调用时写swap(a, b)。这涉及到后面会详细展开的指针基本概念但在这里你只需要记住传值复制内容传地址才能改原内容。这个区分后续做链表、二叉树操作时天天要遇到。局部变量是在函数内部声明的变量它存储在栈上。栈是一种由系统自动管理的区域函数被调用时系统会为它的局部变量分配一段内存函数返回后这段区域会自动回收。所以返回一个局部变量的地址是非常危险的因为函数一结束那块内存就被系统收回了。很多隐蔽的bug就是这样来的。4.3 作用域和生命周期变量之间如何划清界限作用域指的是变量在代码中可以被访问的区域。C语言最基本的作用域是块作用域也就是由{}围起来的一段区域。在函数内部声明的变量函数外面访问不到这是“局部作用域”。在函数外面声明的变量叫全局变量理论上在定义之后的所有地方都能访问。生命周期则指变量在内存中存在的时间。局部变量生命周期从声明处开始到所在块执行结束就结束全局变量生命周期是整个程序运行期间。这里有个很常见的误解认为全局变量能简化代码建议多用。实际上全局变量越多函数之间的隐式依赖就越多你改一个函数时的副作用波及范围就越大。我见过好几个项目里因为全局变量被误改导致排查一个bug需要翻遍全部代码。入门阶段我的建议是能放在局部就用局部确确实实需要共享的数据再考虑定义成全局但一定要控制数量。静态局部变量static修饰的局部变量是个特例。它虽然写在函数内部但生命周期是整个程序第一次调用时初始化之后函数调用结束不会销毁下次再进入函数时仍然保留上次的值。这个特性常被用来实现计数器但新手阶段知道有这么一回事即可不用深究。5. 数组和指针的初次相遇5.1 数组就是一批同类型变量的连续内存数组在C语言里占据极其核心的位置。从概念上讲它是一组同类型变量在内存中连续存放的结构。比如int scores[5];声明了一个包含5个int的数组从scores[0]到scores[4]。需要注意的是数组下标从0开始这是C语言的传统也是新手最容易踩的头一个坑。所谓“连续存放”意味着数组在内存中是一块连续的区域。也就是说scores[1]的地址紧跟着scores[0]的地址。你可以打印出来看看#include stdio.h int main() { int scores[5] {88, 92, 77, 85, 90}; for (int i 0; i 5; i) { printf(scores[%d] %d, 地址 %p\n, i, scores[i], scores[i]); } return 0; }正常情况下你会发现地址间隔恰好是4个字节因为一个int占4字节。这个特性意味着数组天生适合做“一堆相同结构的数据”的统一处理比如批量输入成绩、遍历求和、冒泡排序。冒泡排序是C语言入门的经典题目网上能搜到大量代码但理解排序本身之前先得理解数组的连续内存布局否则你只是在机械敲代码。5.2 指针变量先记住“存地址”三个字指针是C语言入门的分水岭。很多人在这里放弃但我用一句大白话概括它指针变量就是一个用来存地址的变量。普通变量存的是数值指针变量存的内存地址就这一个区别。声明一个指针变量要带上星号int *p;意思是“p是一个指针变量它指向一个int类型的对象”。给它赋值需要取某个普通变量的地址int a 10; p a;。这时候,p里存的是a的地址。要访问“p指向的那个位置的内容”需要用解引用操作符*p。*p可以读写比如*p 20;相当于把a的值改成20。为什么需要指针因为函数调用传值不够用了因为数组的名字本身和地址纠缠在一起因为管理和操作系统内存需要一个“间接”的手段。最简单的实用价值就是前面提到的函数里修改外部变量。没有指针C语言的很多核心功能根本没法定做。我见过的初学者最常见的指针错误是把int *p a;和*p a;搞混。前者是声明一个指针变量并初始化后者是试图把地址存入*p所指向的位置。如果p还没指向合法位置这里就会崩溃或编译错误。记住声明时的星号是类型的一部分使用时的星号是解引用操作。5.3 新手必看的指针与数组关系图用文字描述数组名在大多数表达式中会“退化”为指向首元素的指针。也就是说scores可以像一个指针一样用它代表scores[0]。所以下面这些写法效果几乎是等价的printf(%p\n, scores); printf(%p\n, scores[0]);访问元素也有等价关系scores[i]等价于*(scores i)。后者的意思是从首地址开始向后移动i个int大小的字节然后解引用这个地址。注意不是直接加i个地址而是加i * sizeof(int)这是C语言指针算术的特点编译器会根据类型自动计算步长。有了这个基础后面理解“数组作为函数参数时实际上传的是指针”就顺理成章。比如用函数处理数组形参写int arr[]和int *arr是等价的因为编译器都会把它当成一个指向int的指针来处理。这就是为什么在函数内用sizeof(arr)得不到数组的总大小只能得到指针本身的大小——因为arr在函数参数里已经变成指针了。很多新手在这一点上卡了很久。万变不离其宗指针存地址数组是一段连续内存两者结合时一个名字代表首地址下标就是相对于首地址的偏移量。把这句话背下来后面学字符串、链表都会顺畅很多。6. 零基础最容易踩的五个坑和我的调试建议6.1 分号、花括号和中英文符号看起来最无聊的问题往往是新手报错率第一名。C语言的每个语句要以分号结尾函数体、循环体、结构体定义要用花括号匹配。如果用的是中文输入法打出来的分号和花括号是全角字符编译器根本不认报错信息还特别看不懂。我的建议很简单写代码期间把输入法切到英文模式等你养成习惯之后就会好很多。万一编译报错提示“expected ‘;’ before ...”先别急着怀疑逻辑把报错行和它上一行仔细看一遍绝大多数时候就是少了一个分号或者花括号没配对。用带括号高亮功能的编辑器或者用gcc -fsyntax-only做语法检查都能快速定位。6.2 scanf忘记取地址符这个坑几乎每个人都会踩scanf(%d, a);应该写成scanf(%d, a);。scanf需要把输入的数据写入变量对应的内存地址所以必须传地址。如果漏了程序可能编译时只是警告运行时却会尝试向一个无效或错误的位置写入数据导致崩溃或者更可怕的“莫名其妙被改写”。字符串读取是另一个容易踩的坑例如char name[20]; scanf(%s, name);这里不需要加因为数组名本身已经是首地址。哪个该加、哪个不该加本质上还是要回到“scanf需要地址”这个原理上来。不要死记规则理解了指针之后这些自然就通了。6.3 数组越界与运行时崩溃C语言不会帮你检查数组下标是否越界。你写scores[10]编译器甚至可能不报警告程序也会正常运行因为你访问的是数组后面那片未知内存可能是别的变量可能是系统数据。短期看不出来等某次数据被悄无声息地破坏时就会产生极其隐蔽的bug。这是C语言自由度高的代价。防御性写法是所有循环里涉及到数组下标的地方都要反复确认边界。比如遍历int scores[5]循环条件必须是i 5不是i 5。如果实在怕忘可以把下标和上限写成宏或者const常量让编译器帮你盯住。6.4 乱用全局变量前面提到过全局变量生命周期长、作用域大用起来是很方便但代价是代码的耦合度急剧上升。我入门那会儿也觉得全局变量真香两个函数直接读写同一个变量省得传参。直到有一次我在一个循环里无意间改了全局变量导致另一个函数的行为变的很奇怪整整一周都在排查。后来我给自己立了几条规矩尽量只用局部变量函数之间需要共享的少量数据通过参数传递确实特殊的数据再作为全局。这个原则让我后来的代码调试轻松了很多。全局变量不是毒品但必须像控制糖分一样谨慎。6.5 不会用printf调试导致原地转圈很多初学者遇到程序行为不符预期第一反应是盯着代码死想看半天也找不到问题。我强烈建议在所有关键节点加printf把变量当前值打出来。比如循环里打印i和变量的变化函数入口打印参数值。这样程序到底走到哪一步、数据变成什么样一览无余。用printf调试听起来土但它是入门阶段成本最低、效果最直接的手段。等后面熟悉了可以再用gdb这样的调试器打断点、单步执行、查看内存但初期不要觉得自己用printf不“高级”。工具是为你服务的不是摆设。另外专门提一下字符串格式化输出时最容易犯的一个错打印整数用%d指针用%p字符用%c字符串用%s浮点用%f。格式符写错时输出结果往往是一大串莫名数字或乱码这不是C语言本身有问题而是数据在内存里的二进制被按照错误的方式解读了。理解这一点你会宽心很多。C语言入门第一阶段的“基本概念”大致就这些。我没有堆一堆习题因为概念没建立起来就刷题效率极低。我建议你先把我上面提到的代码挨个敲一遍然后尝试修改几个地方看看变量和指针的地址变化。等你亲手验证了“变量是内存格子”“指针存地址”“数组是连续内存”再进入循环嵌套、字符串函数、结构体这些进阶内容会踏实很多。概念这东西光看是学不会的得和编译器进行足够的互动让错误和输出成为你最忠实的老师。