C/C++ 声明和定义

前言 编译和链接 声明和定义 为什么要声明 为什么要定义 总结 前言 很多编程语言的语法中都有关于声明和定义的概念,这种概念一般会应用于函数或变量的创建和使用中,但是为什么要这么做? 以C语言为例,一些书籍或教程会要求读者在程序文件开头写上函数和变量的声明,然后再在后面对其进行定义(对于变量也可以叫初始化)。这不免让人感到有一丝疑惑,为什么要这样做?我能不能先定义再声明?或者我能否不声明直接定义? 事实上这样做很多时候也是可以的,但是为什么呢? 为此我们需要明确一些基本的概念。 编译和链接 以C和C++为例,如果你正在使用某个IDE进行编程,那么你应该可以发现,在你写好一个程序后需要将它运行之前,往往需要先进行构建(有的IDE里面写的是生成或build),然后经过一段时间的等待后,你就会获得一个可执行程序,之后你就可以运行这个程序文件得到想要的效果。 这个过程中发生了什么呢?一般情况下这个过程分为了三步——预处理、编译、链接。预处理过程一般是C和C++的特色,你所见到的那些带#的语句一般被称为宏,而这些宏将会被预处理器进行预处理。通常而言,你只需要知道#define(将被定义的内容用定义的内容进行替换)和#include(将被引用的文件全部原样复制到此)两个宏就够了。 不过预处理并不是我们讨论的焦点。 编译一般发生于预处理之后,一般而言,当预处理结束后,所有有用的.h文件都已经通过#include被原样复制到了.c文件中了,所以整个工程现在就只剩下一堆.c文件。而编译器会检测每个.c文件然后将它们编译称为对应的汇编文件,再然后将汇编文件翻译成二进制文件。不过一般而言,翻译成为汇编文件这个过程都是被隐藏的,至少如果不单独设置你是无法看到生成汇编文件这个过程。不过没关系,这样也可以简化我们的思路,所以直接将编译过程理解为从.c文件到二进制文件(这个文件后缀根据编译器和系统的不同可能有所差异,一般是.o或.obj,当然具体是什么后缀都不重要)。 编译结束后,显然我们会得到一堆二进制文件,它们和之前写的.c文件一般是一一对应的。但是很显然,这些二进制文件还不是可执行程序,而且它们太分散了。这个时候就需要链接器进行工作,链接器会根据链接表将所有二进制文件进行重组和拼装,最后形成一个完整的可执行文件。 声明和定义 那么,声明和定义在这里有什么用呢? 为什么要声明 现在我们假设一个工程中有两个.c文件,一个是main.c,里面放了一个空的main函数,此外什么也没有。另一个是hello.c,里面放了一个void SayHello()函数并引用了stdio.h文件,这个函数的功能是输出一个Hello World。 显然,hello.c里面实现了对SayHello函数的定义,也就是说我们的工程中已经实现了SayHello这个函数,此时我们是否能够在main函数中调用它呢? 如果你尝试直接在main函数中调用SayHello,然后分别对main.c和hello.c进行编译(一般IDE会有单独的编译选项,右键单击某个c文件就可以选择,而且这个编译事实上还是包括了预处理过程的),你会发现对main.c进行编译的时候会出现报错。 编译器会提示你,没有对SayHello函数进行定义。 但是我们明明在hello.c中进行定义了啊?难道是我需要先编译一下hello.c然后再编译main.c?事实上你换一下顺序结果也是一样。 因为编译器是没有记忆的,当他编译完一个文件,就会马上忘记它在这个文件里都发现了什么东西,所以哪怕它刚刚才在hello.c里面编译了SayHello的定义,但是现在它在编译main.c,它就已经忘记了这回事了。 那么我们有什么办法可以解决这种情况呢? 第一种方法就是,在main.c的前面加上对SayHello的定义,注意尤其一定要在main函数的前面。有人会问,为什么一定要在前面,后面不行吗?事实上确实不行,因为当编译器发现一个它从来没见过的函数调用时,就会马上报错,不会那么智能地再在后面找一遍你是不是定义在后面了,所以唯有在调用之前就定义好,他才知道你有好好定义这个函数,然后它就不会闹了。 但是显然,如果我希望在多个文件里面调用SayHello函数,那么这个方法就不起效了。 所以我们就有了第二种方法,在main.c文件前面加上一句对SayHello的声明。声明只需要写上函数返回值、函数名和函数参数表就可以了,不需要写完整的实现。声明其实是程序员对编译器的保证,意思就是“我保证我在其它某个地方肯定会写这个函数的定义的”,然后编译器会相信程序员,虽然你并没有实际在这个文件中定义这个函数,但是当你调用这个函数的时候,编译器不会报错。注意,声明也需要写在调用的前面,逻辑和第一种方法一样。 现在你大概理解了声明的意思了,而一般情况下,声明会被写在一个.h文件中,要使用某个函数之前只要#include这个文件并且将它对应的.c文件包含在工程内即可。 注意,除了#include,你一定也要将它对应的.c文件包含在工程内,因为.c文件里包含了对这些函数的定义。 为什么要定义 没有定义显然是不行的,否则这个函数就是一个空有名字和输入输出的不明物,很显然这是程序员的疏忽(或像我们故意)造成的错误,是需要被发现检测出来的。 现在,我们做个简单的小实验,还是刚才的工程,但是我们将hello.c里的SayHello注释掉(如果你不怕麻烦想要更好的体验,可以直接删掉hello.c或将它移出工程),此时我们再次编译hello.c和main.c,你会发现,编译器仍然没有报错。 是不是有点疑惑,现在明显没有SayHello的定义,但是为什么编译器不报错呢? 很显然,是因为编译器非常相信你,你在main.c中声明了,你一定会定义SayHello的,所以编译器就理所当然地相信了你,而没有记忆的它显然是无法发现自己被骗了。而hello.c文件里什么都没有,那么自然也不会有什么错(如果你直接将其移除了工程就更加不可能报错了)。 但是这并不代表你能一直骗它,我们知道编译结束后还得链接才能得到可执行程序,不过IDE一般没有单独的链接按钮,所以我们得使用构建,当按下这个按钮后整个工程都将完整经历完前面说的三个过程——预处理、编译和链接。其中预处理和编译仍然是刚才我们的编译器进行的,它和刚才一样,没有发现任何问题。 但是你骗不了链接器,因为它会对程序进行重新组装,但是在组装的过程中它会发现,有一个本来应该出现的函数定义却在此刻不见了,然后它会发出一个L字母开头的报错,唯有你在工程中重新创建了这个函数的定义并重新构建才可以消除。 总结 这里用C语言的函数声明与定义解释了为什么要声明和定义以及它们有什么用,用相同的逻辑它也可以扩展到变量以及其它语言的声明与定义中。

February 11, 2023 · 云雾海

STM32CubeMX GCC工程Makefile内容详解

STM32CubeMX GCC工程Makefile内容详解 STM32CubeMX GCC工程Makefile内容详解 基础介绍 注意 Makefile TARGET DEBUG OPT BUILD_DIR C_SOURCES ASM_SOURCES PREFIX CC,AS,CP,SZ HEX,BIN CPU FPU FLOAT-ABI MCU AD_DEFS,C_DEFS AS_INCLUDES,C_INCLUDES ASFLAGS,CFLAGS ifeq ($(DEBUG), 1) CFLAGS LDSCRIPTS LIBS,LIBDIR LDFLAGS all OBJECTS BUILD clean flash dependencies 结语 基础介绍 因为项目原因,需要对编译系统进行一些比较复杂的使用,但是我对于编译系统这一块并不是非常精通了解,所以需要进行一下学习。正巧,众所周知STM32推出过非常实用的工具——STM32CubeMX,这个工具可以对STM32进行一些基础配置并设置一些配置代码,基本可以靠图形界面完成对STM32的所有初始化配置,然后它就会生成一个基础工程,编程者在这个基础上进行设置即可。 当然,本文主要关注点是在编译系统,所以该工具的配置什么的就不详细说了,基本上就是简单随便配置一下时钟就可以了(事实上应该时钟都不需要配置),重点在于我们生成工程的时候需要选择生成Makefile工程,这样才可以使用我们的开源工具链进行配置。 之后我们可以得到如下的工程目录(当然,众所周知CubeMX的一些设置会影响到工程根目录的具体结构,不过这都不是重点): 其中build目录是编译后生成的,先不管,另外Core文件夹下的Test文件夹及其中内容是我进行测试的时候添加的,暂时也先不管。 我们先来看看其它的东西,首先是Core中的内容,这部分的内容基本上就完全是STM32CubeMX生成的,Inc中放h文件而Src中放c文件,里面的文件都是在CubeMX中配置过的东西,这些东西是每次CubeMX在修改后都会重新生成的东西(也有可能是你修改了哪一部分,就会重新生成哪一部分的文件,这个是CubeMX自己的机制,不重要)。 其次是Drivers中的内容,这些内容实际上都是从 CubeMX的安装目录里的sdk中复制过来的,如果我们在配置的时候没有选择要将这些东西复制过来,那么CubeMX也不会生成这个目录下的内容(或者不能说生成,而就是直接复制过来),但是取而代之的就是你使用的所有sdk的库函数,都是直接调用的安装目录里的东西,那么为了安全性着想,就不要在编程的时候修改他们了,否则你的库函数就被你的代码污染了,当然这样做的好处就是你的代码复用性会提高而且工程体积会稍微小点。 然后是下面的.mxproject文件,CubeMX的工程文件,不管。 接着就是我们等会最重要的Makefile,这个是Make工具的脚本文件,如果你不知道Make工具是啥,建议先了解一下再看本文会好一点。 之后是MakeTest.ioc文件,CubeMX的配置文件,不管。 然后是后缀为s的start文件,这个文件是芯片的项目启动文件,一般不需要管,里面放了中断向量表之类的东西。 最后是后缀为ld的链接脚本文件,这个是用于链接的,等会会说。 注意 本文的解析结构并没有按照makefile的解析顺序来,而是采用从上到下的顺序来解说的(当然会根据实际情况进行跳跃),主要原因在于我学习这个东西的目的主要是直接把这个Makefile当成一个模板,然后未来直接在这个模板上进行修改就可以了,而到目前为止我暂时还不打算自己从头来写个Makefile(毕竟能用就行,平时哪需要那么麻烦)。 考虑到篇幅,就不单独把全部makefile列出来了,需要时你直接用CubeMX生成一个工程然后看它Makefile即可,只要STM32公司那边没有对这个规则进行大改,那么下面这些内容应该都大差不差。 此外,本文中很多东西其实可以在gcc的手册中找到答案,如果感兴趣可以去看看。 Makefile TARGET TARGET = MakeTest 设置目标文件名,实际上就是做了个变量,并且把Makefile这个作为参数传入。平时当成工程名即可,如果要换工程,就自己随便重新取个名字。 DEBUG DEBUG = 1 是否进行调试编译,这里表示是,release版关闭调试时将1改成其它数即可,具体原因后面会讲到。 OPT OPT = -Og 优化等级,这里使用Og的目的是因为开启了调试,所以优化时需要产生合理的优化而不和调试选项冲突。如果是release版本,可以使用-Os(提高速度并优化体积)或-O3(大幅提高速度但会增大体积)等命令,关于这部分的介绍可以参考知乎回答。 BUILD_DIR BUILD_DIR = build 编译目标地址,这也是前面提到为什么我会生成一个build文件夹,实际上就是这个变量进行的命名,如果你需要,可以把它修改成任何名字,然后编译出来的所有文件就都会放在这个你新命名的文件夹里面了。不过一般不建议修改,因为大家通用的都是build,不建议太特立独行。 ...

March 8, 2022 · 云雾海

深度底层剖析,你所不知道的printf

注意 视频 再观printf函数 print为什么要加个f 格式符是什么时候被解析的? 如何实现变参函数 来实现一个printf吧 printf是否有检测功能 printf只能输出在命令行吗 结语 注意 本文为针对C语言的基础技术向,更适合刚入门需要进阶的同学技术进阶使用,当然我会尽量用简单的语言让哪怕是初学者也可以看得懂,核心知识有如下部分: 变参函数的概念和使用 __attribute__(())的概念和使用 printf函数的实现 视频 本文可以结合视频一起食用,效果更佳:视频链接 再观printf函数 相信绝大部分同学写的第一个程序,应该就是大名鼎鼎的Hello World程序了,这个程序闻名于著名的《C语言程序设计(The C Programming Language)》(C语言之父丹尼斯·里奇(Dennis Ritchie)著)一书中,非常经典,以至于程序员们学习任何语言,都会先用这个语言运行个Hello World出来。 在C语言中,这个程序一般写出来是这个样子的: #include <stdio.h> int main() { printf("Hello World!\n"); return 0; } 如果在命令行运行这个程序的话,你将获得一行Hello World!输出。 如果你是一个不喜欢思考的初学者,可能会认为printf也许是C语言里面最基础最简单的函数了,毕竟这个程序这么简洁、格式这么规范、使用这么简单、甚至闭着眼睛都不会写错。 但如果我们往细究往深了想呢?printf它是如何实现的呢?我们是否可以实现一个自己的printf函数呢?为什么它会运行在命令行中而不是从屏幕某个规定的位置显示呢…… 这时你可能会发现,woc,printf似乎比我们想象的要复杂太多了,你以为的简单小函数,其实却是个超级大boss。不过不要担心,我们会从基础开始讲,而且我会保证尽量说得简单明了。 print为什么要加个f 现在,我们重新抱着认真学习的态度来观察printf这个函数吧。首先我们从名字来解析一下。 我们知道,在英语中,print有打印、印刷的意思,这个词语完全可以解释将字符串打印到电脑屏幕上这个过程,那么为什么这个函数的名字要加上f呢? 其实这个f是format的缩写,即格式化的意思,printf其实为格式化打印的意思。那么格式化这个是怎么来的? 现在想想,如果我们希望在屏幕上输出一个变量该怎么做呢?最简单的方法就是使用格式字符,就如下面这个程序一样: #include <stdio.h> int main() { int a = 25; printf("a = %d\n", a); return 0; } 这个函数将会在屏幕上输出一行a = 25,在这个过程中%d就是变量a的格式符,它在字符串中占位,然后字符串在输出的时候,遇到了这个占位符,就会解析a的具体值,并把它转换为字符串的形式输出出来。 现在从这个功能中我们其实可以发现很多问题,我们来逐一解释: 格式符是什么时候被解析的? 这个问题是我在学习理解printf的时候第一个思考的问题,即格式符是如何什么时候被解析的,答案有两种,对应了两种不同的结果。 首先是在编译阶段被解析,这种方式的话也有两种情况,一是编译器有针对printf这类函数的优化,二是编译器会主动把字符串中的格式符变成某种执行效果。而另一种方案是printf函数内部实现的解析格式符。 我们可以用如下的程序来测试(为了方便,后面我们生成程序的名称都为demo.exe): #include <stdio.h> int main(int argc, char* argv[]) { int a = 1, b = 2; char* str = "a=%d\n"; printf(str, a); printf(argv[1], b); putchar('\n'); return 0; } 然后我们把它们编译完成后,在命令行中使用./demo.exe b=%d来运行(如果使用的某种IDE,可以打开工程文件夹,找到输出exe文件,然后在文件夹中按住shift+右键,打开powershell,然后用./文件名.exe b=%d的方式运行即可)。 ...

August 19, 2021 · 云雾海

C宏定义连接符#和##及其应用

在学习LwIP的时候,发现源码中出现了#define LWIP_MEMPOOL(name, num, size, desc) MEMP_##name这样的语句,了解到了C编译器中##这个连接符。顺便查了一下资料,还发现了#这个字符转换符。 功能 ##可以作为宏定义中的变量替换,#则作为字符替换,一般他们都是用于带参宏定义中。 比如: #define func1(a) printf(#a"\n") #define func(num, a) func##num(a) int main() { func1(hello); func(1, hello); return 0; } 上述程序将会输出两个hello。 第一个hello源自于func1(hello);这句话因为宏定义将会变成printf(“hello”);其中hello之所以变成字符串,就是因为在宏定义的时候使用了#,另外相邻且未被任何字符隔开的两个字符串在C中会被合并在一起,故这里相当于还加上了一个回车。 第二个hello源自于func(1, hello);这句话因为宏定义首先会变成func1(hello);其中之所以参数1会出现在变量名中,就是因为在宏定义中使用了##,它相当于将宏定义中的参数num变成了真正Token中的内容。Token可以理解为我们在C语言中写的变量名、函数名等等。然后func1(hello);又变成printf("hello");。 应用 它们的应用不在于提高代码效率或缩减体积,而在于简化编程难度和提高易读性。 比如,我有一堆字符串口令,为了方便,我对它们进行了宏定义: #define TEST_HELLO "Hello" #define TEST_BYE "Bye" #define TEST_NAME1 "Tom" #define TEST_NAME2 "Jerry" 现在,我希望把这几个口令以一定的方式打印出来,最直接的方法就是如下: printf("%s %s %s %s", TEST_HELLO, TEST_NAME1, TEST_BYE, TEST_NAME2); 不过上述的方式太单一了,而且封装程度不好,如果我们未来想到一个新的问候词要替换呢?可能有人想到用这样的方法来替换: void SaySomething(char* sth1, char* sth2, char* name1, char* name2) { printf("%s %s %s %s", sth1, name1, sth2, name2); } 这样封装后我们使用: SaySomething(TEST_HELLO, TEST_NAME1, TEST_BYE, TEST_NAME2); 就可以完成上面的工作了,但是似乎还是太麻烦而且可扩展性以及封装度有点差,这时我们可以观察上面的所有问候语都是以TEST_开头的且后面的问候句与下划线后的文字直接相关,而人的名字则以TEST_NAME开头,然后接上一个数字编号,那么我们是否可以依靠这个来做点什么呢? #define SaySomething(sth1, sth2, name1, name2) printf(#sth1" %s "#sth2" %s", TEST_NAME##name1, TEST_NAME##name2) 通过这样一个封装,我们就可以实现前面的功能了,用法如下: ...

June 16, 2021 · 云雾海

C语言可以使用变量定义数组

这是在一次研讨会中发现的我知识上的纰漏,在此进行记录。 当时想给学弟学妹讲一下关于动态内存的问题,在讲述时我用了一个初始化变量时不能用变量作为下标。例如: int i = 5; char test[i]; 然而事实上,在这样写了之后我发现编译没有出错,这和我最开始学C语言时老师给我们讲的内容并不相同。我最初怀疑时因为此时i已经被赋值了而后在此期间没有进行计算,所以编译器可能将其直接作为一个宏定义来解析了。但是我们又尝试了如下两种写法。 int i = 5; i++; char test[i]; int i; scanf("%d", &i); char test[i]; 此时编译器仍然没有报错,而且生成的程序运行起来也是正常的。故而这样的写法其实是正确的,其运行逻辑应该是这种语句在被编译的时候成为了类似于malloc的语句,同时在后面的某个位置自动添加了free,不过因为我对汇编并不是特别熟,只能从理论层面大概分析一下,具体内容各位可以使用反汇编自行查看。 不过需要注意的是,在定义的时候不能直接赋值: int i = 5; char test[i] = {0, 1, 2, 3, 4}; 原因可能在于等号赋值是先右后左,虽然在普通直接使用常量定义数组的时候没有关系,但那应该是在编译状态就可以计算出的内容,只要赋值的长度不超就没事,而这种变量赋值则类似于一种动态分配,编译器并不能预测在运行时这个变量究竟是多少,哪怕你知道它只能是这么多,但是编译器并没有那么智能,不如就直接报错处理了。 考虑到最开始学到这个知识的时候老师讲课用的书是谭浩强的那本,限于时代因素那本书使用的C语言标准应该是在C89左右,而当时实验课我们使用DEV-C++进行编译也会遇到这种问题,故而我怀疑可能在C99或者C11的版本中发生了某种改变,毕竟我现在用的是C11的gcc了。不过具体是什么时候不重要,记住这个结论就好了。 另外我真认为各高校确实该换本教科书了……

April 19, 2021 · 云雾海