
1. 一个C程序是怎么从文本变成可执行文件的学习C程序的第一课往往不是写代码而是经历各种看不懂的报错。比如在命令行敲了个dsh回车屏幕上弹出一行dsh 不是内部或外部命令也不是可运行的程序或批处理文件。当年我还是个新手时对着这行字看了半天还以为自己安装的编程环境坏了甚至怀疑是电脑中毒。后来才明白这行字压根和代码语法没关系它只是在告诉你系统在默认路径里找不到你要执行的程序。这一个瞬间其实就是C程序初识最真实的写照——C程序不是双击图标就能跑的Word文档它需要编译、链接、执行一整个链条的配合。这篇文章我打算结合自己这些年从入门到实际做项目的经验把C程序的完整链路讲清楚从源文件变成可执行文件到程序内部的分支跳转再到中断、嵌入式和代码质量这些进阶话题。适合刚学C语言的同学也适合那些能跑通题目、却总感觉代码不受自己控制的初学者。1.1 四次翻译预处理、编译、汇编、链接C语言属于编译型语言这句话的意思是你写好的.c文件本质上只是给人类看的文本CPU根本不认。中间必须有一个翻译官把它变成CPU能执行的机器指令这个整体过程叫编译具体拆开又分成四步预处理、编译、汇编、链接。预处理处理所有以#开头的指令比如#include stdio.h就是把stdio头文件的内容原样贴进来#define则是做文本替换。这一步不检查语法只做文本层面的整理。编译把预处理后的文本翻译成汇编指令。这是最核心的一步语法检查、类型检查都在这里。汇编把汇编指令翻译成机器指令生成目标文件Windows下是.objLinux下是.o。此时的机器代码还不能直接运行因为调用的函数比如printf还缺着。链接把目标文件和函数库合并补上缺失的函数地址最终生成可执行文件.exe或者Linux下的无后缀可执行文件。一个形象的类比源文件是一份中文菜谱写着取蒜三瓣拍碎预处理是帮你把葱姜蒜的购买清单贴到菜谱末尾编译是翻译成英文菜谱汇编是再翻译成只认编号的帮厨手势链接则是把所有配菜、调料都放到同一个操作台。四步做完灶台才能开火。一个最小的C程序长这样#include stdio.h int main(void) { printf(Hello, C!\n); return 0; }#include stdio.h的意思是我要用标准输入输出库里的printf请把它的声明贴进来。int main(void)是程序的入口函数操作系统启动程序后第一件事就是找main。return 0表示正常退出返回给系统的退出码是0。很多新手以为printf会自动换行其实不会必须手动加\n这是许多人第一次输出时明明打印了却没有换行的根源。编译命令在不同平台上略有差异环境编译器编译命令生成的可执行文件WindowsMinGWgccgcc hello.c -o hello.exehello.exeWindowsMSVCclcl hello.chello.exeLinuxgccgcc hello.c -o hellohellomacOSclangcc hello.c -o hellohello1.2 不是内部或外部命令初学的第一道坎回到开头的报错。在Windows的命令行里输入任何命令系统都会去PATH环境变量列出的目录里寻找对应程序。找到了就执行找不到就报xxx 不是内部或外部命令也不是可运行的程序或批处理文件。这跟你写的C程序半毛钱关系都没有纯粹是系统没找到可执行文件。我见过很多新手栽在这里装完编译器之后在高高兴兴敲gcc的时候遇到这个报错然后开始怀疑人生。实际上解决办法很简单把gcc所在目录加到PATH里就行。Windows下可以临时设置set PATHC:\MinGW\bin;%PATH%如果希望永久生效用setx PATH C:\MinGW\bin;%PATH%注意setx有个坑它会覆盖原有变量如果你原本的PATH很长可能会被截断。所以建议先echo %PATH%备份一份再用系统设置里的环境变量编辑界面手工添加更保险。Linux/macOS则是export PATH$PATH:/usr/local/bin如果你敲gcc能正常显示版本号而敲dsh还报错那说明dsh这个程序本身要么没安装、要么不在PATH里。命令行报错不等于代码错误学会区分命令找不到和编译报错是C程序路上的第一项基本功。还有一个新手常踩的坑在Windows里编译成功后直接双击hello.exe窗口一闪而过什么都看不到。这是因为程序执行完就退出了。正确的做法是先打开命令行在命令行里运行hello.exe或者干脆在代码末尾加一句getchar()等用户按回车再退出不过getchar()会读走输入缓冲区里的残留字符想稳定地暂停一下可以配合循环清空缓冲区这个细节等你学到输入输出再回来体会。2. 亲手拆开一个C程序从词法分析到表达式陷阱2.1 先写一个mini词法分析器理解状态机学完基础语法之后有个特别适合练手的入门项目写一个小的词法分析程序。听起来高大上其实说白了就是给文本分词——把一串字符按单词、数字、运算符等分类拆开。正规的词法分析器是编译器前端的一部分但初学者完全可以写一个简化版统计一段英文文本里有多少个单词。#include stdio.h #include ctype.h int main(void) { int ch; int in_word 0; int count 0; while ((ch getchar()) ! EOF) { if (isalpha(ch)) { if (!in_word) { in_word 1; count; } } else { in_word 0; } } printf(word count: %d\n, count); return 0; }别看这段代码短里面藏着一个非常重要的思想状态机。变量in_word表示当前是否处于一个单词内部这个状态。读到字母且之前不在单词内说明发现了一个新单词的开始计数加一读到非字母说明单词结束把状态切回去。真正的词法分析核心骨架就是这种读一个字符、根据当前状态决定下一步动作的循环只不过还要加上识别关键字、运算符、数字字面量等分支。这里有个C语言细节我特意提醒一下为什么用int ch而不是char ch因为getchar()返回值是int它可能返回EOF而EOF通常是-1。如果用char保存在有的平台上char是无符号的-1会被截断成255永远不等于EOF循环就永远不会结束。这个坑哪怕写过几年程序的人也偶尔会踩。在命令行编译跑起来之后输入一段英文按CtrlZWindows或CtrlDLinux/macOS结束输入程序就会输出统计到的单词数。你可以试试用这句话验证状态机逻辑Hello, world! I love C.——标点符号前后的空格、逗号、感叹号都会正确触发单词状态的切换。2.2 运算符和表达式看程序写结果为什么总错几乎所有C语言教材的第二章都会讲运算符和表达式考试里也总有一类题叫看程序写运行结果。我接触过的初学者几乎都在这里翻过车而且翻得很不服气明明代码看着特别简单就是算不对。原因通常有三个。第一优先级背不熟。比如a || b c很多人从左往右算其实的优先级高于||所以真实含义是a || (b c)。再比如x 2 3 4是先算23还是先算3 4正确答案是先算加法再算比较因为算术运算符的优先级高于关系运算符。第二结合性搞混。赋值运算符是右结合a b c 3的意思是先算c 3再算b 那个结果最后才给a赋值。自增自减的前缀和后缀也容易混i是先使用后自增i是先自增后使用这个区别如果理解不透写循环都会出问题。第三写了未定义行为还不自知。比如int a 5; int b a a;这段代码在C标准里是未定义行为同一个表达式里两次修改同一个变量a中间没有序列点。有的编译器算出b12有的算出b11换一个优化选项结果还会变。考试里如果真出了这种题严谨的评判标准应该是代码本身有问题输出无法确定。平时写代码的原则就是不写需要猜测求值顺序的表达式a;单独写一行需要新值时再单独取。看程序写结果题的正确打开方式是给每个变量画一张小表逐行推演。比如int n 5; int sum 0; while (n 0) { sum n; n - 2; } printf(%d\n, sum);手推过程初始n5, sum0第一轮sum5, n3第二轮sum8, n1第三轮sum9, n-1循环结束。输出9。这个过程不需要任何编程技巧只需要耐心。我教新人的时候经常强调推不出来的时候就在纸上把变量列表写出来每执行一行更新一次比盯着屏幕发呆有用得多。关于运算符优先级不需要死记一大张表记住一条主线就够了先单目再算术再移位再关系再位运算再逻辑运算再条件运算最后赋值。其中最容易记反的是和||其实排在位运算后面而不是所有逻辑运算一锅端。写复杂判断时最稳妥的做法是加括号哪怕多写几层也不要让读代码的人去查表。3. 程序被打断之后中断流程与嵌入式C的实战差异3.1 顺序、分支、循环之后还有一个异步入口结构化程序设计的经典结论是任何程序都可以用顺序、分支、循环三种结构写出来。顺序就是从上到下一行行执行分支是if/else和switch循环是while/for/do-while。初学阶段你理解的程序世界就是这三板斧。但真实世界不是这样的。程序在执行过程中随时可能被外部事件打断。最典型的场景在单片机里一个按键按下了一个定时器计数到零了一串串口数据到达了CPU都可能放下手头的活先去处理这个事件处理完再回来接着干活。这种随时插队的机制就是中断。我自己的感受是初学者如果只接触PC上的纯计算程序很难理解中断到底意味着什么。因为PC上的程序默认独占CPU你写什么它就执行什么没有插队的概念。但只要一接触嵌入式开发中断立刻变成绕不开的核心机制。而中断流程的执行方式和普通函数调用有本质区别理解了它你对程序执行流程的认识会上一个台阶。3.2 进入中断流程从压栈、执行到恢复现场c程序进入中断流程这句话拆开看是这么回事CPU正在执行主程序的某条指令此时中断源触发了硬件会先把当前正在执行的指令地址和现场状态压入栈中然后跳转到中断向量表里对应的入口执行中断服务函数ISR。ISR执行完之后硬件恢复现场让CPU回到刚才被打断的位置继续执行。打个比方你正在写一篇博客打到一半电话响了。你记住自己刚写到哪个字保护现场起身去接电话执行中断服务函数接完挂掉回到座位上从那个字继续写恢复现场。如果接电话过程中又有人按门铃那就是更高优先级的中断嵌套。中断服务函数有几个约定俗成的规矩新手特别容易犯第一ISR里不要做耗时或不确定的事。不要调用printf、delay这类可能长时间阻塞或者不可重入的函数。中断服务函数应该短小精悍通常只做设置标志位、记录数据、清中断标志这几件事真正复杂的处理放到主循环里。第二ISR和主循环共享的变量要加volatile修饰。如果不加编译器可能认为主循环里那个变量从来没被改变过从而把读取操作优化掉导致主循环永远看不到中断里产生的变化。第三中断服务函数的名字要和芯片启动文件里的向量表对上。在STM32上EXTI0_IRQHandler、TIM2_IRQHandler这类名字都不能随意改否则中断写了也白写根本不会被执行到。看一个典型的中断流程示例volatile uint8_t flag 0; void EXTI0_IRQHandler(void) { if (EXTI_GetITStatus(EXTI_Line0) ! RESET) { flag 1; EXTI_ClearITPendingBit(EXTI_Line0); } } int main(void) { // 初始化按键、LED、NVIC... while (1) { if (flag) { flag 0; LED_On(); } } }中断里做的事情只是把flag置1并清掉中断标志位主循环检测到flag后再处理业务。这种生产者-消费者的松耦合写法比直接在中断里操作硬件要干净得多也是嵌入式C工程里的常见惯例。3.3 Keil STM32嵌入式C程序的样子很多人的第一块开发板是STM32用的IDE是Keil MDK。和前文PC上的纯C程序相比这里有几个肉眼可见的差别。第一程序入口不再是运行完就退出的形态。main函数结束意味着整个程序就要飞了。所以嵌入式C程序的main里几乎都有一个while(1)死循环系统服务全在这个循环里转。第二没有操作系统的话主循环就是你的调度器。中断负责及时响应主循环负责处理中断留下的任务。任务多了要分时处理不能在一个耗时函数里磨蹭太久。第三头文件和库函数风格完全不同。STM32标准外设库SPL的写法是类似RCC_APB2PeriphClockCmd、GPIO_Init这种用函数配置寄存器的方式。用Keil写STM32时最小点灯程序长这样#include stm32f10x.h int main(void) { GPIO_InitTypeDef GPIO_InitStructure; RCC_APB2PeriphClockCmd(RCC_APB2Periph_GPIOC, ENABLE); GPIO_InitStructure.GPIO_Pin GPIO_Pin_13; GPIO_InitStructure.GPIO_Mode GPIO_Mode_Out_PP; GPIO_InitStructure.GPIO_Speed GPIO_Speed_50MHz; GPIO_Init(GPIOC, GPIO_InitStructure); while (1) { GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_SET); volatile int i; for (i 0; i 500000; i); GPIO_WriteBit(GPIOC, GPIO_Pin_13, Bit_RESET); for (i 0; i 500000; i); } }注意延时循环里那个volatile int i。如果不加volatile编译器会发现整个循环没有改变任何外部可见的状态直接给你优化没了结果就是LED闪烁频率失控甚至不闪。这就是前面提到的volatile在嵌入式里的典型应用场景防止编译器把看似无效的循环删掉。很多人以为嵌入式C和PC上的C是两种语言其实标准部分是同一套只是嵌入式的资源更紧、和硬件的交互更直接。像SPWM正弦脉宽调制这种工业控制里常见的需求本质就是在定时器中断里不断改变某个比较寄存器的值让占空比按正弦规律变化。用C语言查一个正弦表、做几次乘加运算就能产生驱动电机或逆变器的波形。这种用数学波形驱动硬件的感觉是C程序最迷人的地方之一。4. 让C程序不翻车静态分析、防御性编程与手推练习4.1 让机器替你复读代码Coverity规则与同类工具程序写得多了一定会明白一个道理编译器只能帮你检查语法对不对检查不了你的逻辑会不会出事故。在工程界合入代码前跑一遍静态分析工具是很常见的门禁流程其中Coverity的C/C检测规则在业内使用非常广。它会把代码喂给一堆规则引擎找出数组下标是否可能越界malloc之后有没有可能直接对NULL解引用申请的资源是否所有路径都释放这类问题然后把可疑位置直接标出来。Coverity是商业工具个人学习不一定用得着但它的思路完全可以借鉴到日常开发里写完代码先用静态分析工具扫一遍再手写测试。免费替代方案里cppcheck和clang-tidy都很好用而且安装简单。比如cppcheck --enableall hello.c它会输出类似Array arr[10] index 10 out of bounds这样的信息。clang-tidy的检查规则更细甚至能提示变量命名风格和可疑的代码重复。能被静态分析抓出来的问题往往是运行时最容易让人崩溃的问题。举个例子char *p (char *)malloc(1024); strcpy(p, hello); free(p);编译器不会报错但Coverity会报告两件事第一malloc的返回值没有检查万一内存分配失败p是NULL下一行strcpy直接解引用空指针程序马上就崩第二free(p)之后没有把p置空虽然这里立即返回了但真实项目里如果后面的代码不小心又用它就变成野指针访问。改进一下char *p (char *)malloc(1024); if (p NULL) { return; } strcpy(p, hello); free(p); p NULL;这段代码看着啰嗦但每一个判断都是真金白银的防御。在汽车电子、医疗器械这类安全等级高的领域MISRA C这类编码规范还会要求得更严格比如禁止使用动态内存分配。与其等测试阶段发现崩溃不如在代码阶段就堵住漏洞。4.2 防御性编程三个习惯挡住大多数故障我在实际开发中总结出三个性价比最高的防御性编程习惯分享给初学者。习惯一任何返回值都要检查。malloc可能返回NULLfopen可能返回NULLscanf可能读不到内容连接数据库也可能失败。别假设每个调用都成功这是初学者最容易忽略的假设。一次fopen失败直接解引用结果轻则程序崩掉重则写出脏数据。习惯二涉及缓冲区操作必须带长度。下面这个写法是经典反面教材char buf[16]; strcpy(buf, user_input);用户输入超过15个字符strcpy就会把数据写到buf后面的内存里这叫缓冲区溢出。轻则覆盖相邻变量重则程序流程被恶意利用。改用带长度限制的版本char buf[16]; strncpy(buf, user_input, sizeof(buf) - 1); buf[sizeof(buf) - 1] \0;strncpy只拷贝目标容量范围内的内容末尾再手动补一个\0确保字符串一定以空字符结尾。注意strncpy如果源字符串比目标长它不会自动加\0所以这个手动的\0不能省。习惯三对象生命周期结束时记得清零和置空。free(p)之后立刻把p置为NULL可以避免悬空指针——也就是指向一块已经释放内存的指针。同理文件句柄用完要fclose并做好错误处理。下面这张表是我常用的自检清单故障模式典型原因预防手段数组越界循环条件写成i N统一用i N且明确数组大小空指针解引用malloc/fopen失败后未检查操作前判空并处理失败分支悬空指针free后继续使用free后立即赋NULL缓冲区溢出字符串拷贝无长度限制用strncpy并手动补\0空循环被优化延时循环无副作用加volatile变量共享变量不可见中断修改的变量被编译器缓存加volatile修饰这些习惯不需要背写代码时逼自己执行写多了就内化了。4.3 练习建议先手推再调试最后回到最初那个场景如果你正在学看程序写运行结果这一类题我的建议是不要急着开IDE跑先拿纸笔手推。推的过程逼迫你把每个变量的变化捋清楚捋着捋着很多灵光一闪就变成了肌肉记忆。推完之后再上机验证如果输出和你推的不一样说明你的某个假设有误这时候再调试收获最大。调试器的断点、单步、监视窗口都是用来确认你的心智模型和真实机器是否一致的而不是用来代替思考的。我带过不少实习生发现一个规律遇到看不懂的代码第一反应是复制到IDE里编译运行、试来试去的那种到后面学指针、学结构体时会非常吃力反而肯手推变量变化、再上机验证的抽象能力提升得很快。学C程序就是这样上手容易想让它完全受你控制必须把每一步都想清楚。如果你已经能熟练编译运行一个C程序了那我建议下一个项目可以试着写一个更完整的词法分析器或者拿Keil点亮一块STM32开发板写一个带中断的按键程序再进阶到用定时器输出一路SPWM波形。这些都是从初识C程序走向能独立做小项目的下一站祝你好运。