[交流][微知识]模块的封装(四):头文件的疼
本帖最后由 Gorgon_Meducer 于 2019-7-25 17:57 编辑[交流][微知识]模块的封装(四):头文件的疼
认真说起来,头文件(Header File)是个短命的家伙——就整个编译过程来说,它的寿命是最短的。
为什么这么说呢?关于头文件的话题,讨论起来那可是“孩子没娘,说来话长了”,既然是闲聊、你也不
是等着这篇文章救命,那就不妨从头开始说起——先假设读者们都是不了解编译基本过程的初学者。
一个编译(Compilation)过程通常至少分为三个阶段:预编译(Precompiling)、编译(Make)和链接
(Linking)。他们就像一个流水线一环套一环——前一工序的输出是后一工序的输入。这本没有什么稀奇的,
但对于程序员来说,这个过程中有几个基本常识是需要记住的:
1. C语言编译的基本单位(Compilation Unit)是 C源文件 (而并没有头文件)
2. 同一个工程中,不同C源文件的编译是彼此独立的(毫不相干的)
3. 头文件在预编译阶段就已经合并到对应的C源文件中了,和所有的宏以及条件编译一样,到了编译阶段,
所有的头文件、宏都是不存在的,已经被替换为对应的内容和常量了。
理解这三点,基本上已经可以解决很多我们日常编码过程中存在的很多疑问,比如:
- Q1:为什么不能C语言头文件里面定义变量或者函数的实体?
- Q2:为什么有的时候宏的先后顺序并不那么重要?
- Q3:为什么可以在源代码的任意位置(另起一行后)定义宏,甚至是include别的头文件?
推荐大家基于前面的三个事实自己思考,答案在附录中介绍。
头文件里可以放什么呢?这是个值得讨论的问题:
-各类宏
-函数的声明(也就是 extern xxxxx)
-全局变量的声明(也就是 extern xxxx)
然而,值得说明的是,这里有一个编码规则值得你去遵守:头文件里坚决不要放全局变量有关的任何东西
(硬要加,也必须是const类型的,比如各类接口)。
-类型定义(typedef, struct, union 之类的)
-static 的变量实体和函数实体。
这个可以有,为啥呢?因为即便多个c源文件包含同一个头文件导致同样的函数和变量实体存在多份,但
static 的另外一个名字 "private" 可以保证每一份变量和函数实体都是彼此独立的,都是每个c源代码的
私人财产——你可以有,我也可以有。“哎?你也有啊,真巧哎,我也有……”
-inline 的函数
这个和static是一个道理。
头文件里面不能放函数的实体,想必原因大部分人都知道了,这里就不再赘述。但头文件里不放(非const)的全局变量的声明,
这怎么玩?这里需要说明一下,头文件里不是不能放(非const)的全局变量声明,而是我提供了一个人为的规定(规范),建议
不要放任何(非const)的全局变量到头文件里,具体原因和解决方案,我们在别的帖子里再讨论(其实有人讨论过,大约就是,
如何避免使用全局变量)——是的,避免使用(非const)的全局变量是可以做到的——这里也不再赘述。说了这么多废话,我们
真正要讨论的内容还没有开始:
- 如何建立头文件的使用规则,使其即灵活、使用方便,又灵活且便于扩展(模块化)——符合面向接口开发的要求,方便我们
建立黑盒子?
简而言之,
- 如何让头文件的使用不再头疼;永远告别循环包含;方便代码的移植?
首先,思考一个简单的问题?为什么我们要用头文件?答案其实很简单,因为每个.c文件都是独立编译的,因此需要在源代码
级别传递一些信息,类似一群人在唠嗑:
源代码A: 我定义了一个函数,你们哥几个要用么?
源代码B和源代码C: 我们要用啊,函数原型(prototype)什么样子啊?
源代码A: 你们不用费脑经记(抄下来),我都写好了,放在一个头文件里了,你们直接include就可以了。
源代码B和源代码C: 这个敢情方便。那你头文件放哪里了?
源代码A: 有两种方式,要么你直接到我这里来拿(指定路径);要么你找编译器问(编译器指定搜索路径)。
源代码D: 你们整这么麻烦做什么?你直接告诉我原型,我抄下来,不就不用问这个问那个,还包含文件什么的,真麻烦。
源代码A: D啊,你老想耍小聪明,万一我更新了你不知道怎么办?我有义务告诉你么?并没有。
源代码B和源代码C: 是啊,是啊,A以后估计要外包了,不在这里了,到时候有变化,都记录在头文件里,你本地放一个,没法
及时同步的。
源代码D: 我不听!我不听!我不听……
是不是很有画面感?抛开捂着耳朵的D,我们回到讨论的话题——既然头文件是用来交换信息的,那么如果把所有的信息都放在一起,大家
需要的时候各取所需,岂不美哉?——基于这种思想,几乎所有人都见过把所有变量、函数、宏、类型定义都放到一个叫做system.h的头文件
里的做法。你有这么做过么?不要不好意思,几乎所有人都这么做过——因为实在太方便了,世界大同,挺好,直到你尝试和别人一起合作开发
系统,并试图在不同项目间复用一些代码的时候:
“何首乌藤和木莲藤缠络着”……对于这种情况,我们叫做耦合。“是要找个时间来理一理了”,你对自己说,然后长叹了一口气,发现这句话其
实很早之前就说过了。想到还有更奇葩的循环包涵的问题,你不得不感叹,头文件真的是个头疼的东西——要不我们还是不用了吧?直接抄下来
貌似更简单啊——源程序D痴痴的笑了。
那么,如何解决这个问题呢?其实,从实践经验来看,头文件的用途分为两大类:
站在C源文件的视角上:
- 从 外部向C源文件内部 输入配置信息——我们把这类头文件叫做配置头文件(Configuration Header File)。
需要强调的是,信息的流动方向是 从外向内,所以又可以简单的理解为输入性的头文件(Header File for information input)。常见的app_cfg.h
就是典型的配置头文件。
- 从 C源文件内部向外 输出接口信息(全局函数、类型,宏定义等信息)——我们把这类头文件叫做接口头文件(Interface Header File)。
需要强调的是,信息的流动方向是 从内向外,所以又可以简单的理解为输出性的头文件(Header File for information output)。常见的, spi.h
usart.h, device.h, stdint.h 就是典型的接口头文件。
输入和输出两个不同的职能如果被放在同一个头文件里,就有极大的风险产生循环包含或者交叉引用(两个相反方向的箭头产生闭合的圆圈)。
system.h实际上就是一个混淆信息流动方向的例子。这就是本质上依赖system.h的工程 模块不好拆分的原因。一般来说,为了“降低”循环包含的风
险,同时又为了尊重常见模块封装的习惯,我们会人为的规定:
- 模块内部的各类文件“允许”包含模块的接口头文件;
- 模块内部的各类文件“应该”包含模块自己的配置头文件;
- 除极少数情况外,系统中所有的配置头文件都“应该避免”包含任何街口头文件。
简单的来说,这三条规则就是允许两个信息流单向的进行混合:也就是,配置头文件的信息可以单向的流向接口头文件;但反过来却绝对禁止,
这就从源头上极大的降低了发生“循环包含”的概率。但即便如此,还有另外一类问题单纯依靠拆分头文件是不能解决的,这就是头文件的“交叉引用”
问题。
To be continue...
相关文章
a. [交流][微知识]模块的封装(一):C语言类的封装
b. [交流][微知识]模块的封装(二):C语言类的继承和派生
c. [交流][微知识]模块的封装(三):无伤大雅的形式主义
本帖最后由 Gorgon_Meducer 于 2017-6-2 18:59 编辑
附录
- Q1:为什么不能C语言头文件里面定义变量或者函数的实体?
- Q2:为什么有的时候宏的先后顺序并不那么重要?
- Q3:为什么可以在源代码的任意位置(另起一行后)定义宏,甚至是include别的头文件?
深更半夜,自占地板,虚位以待,何日更新…… 深更半夜,自占地基,虚位以待,何日更新…… 大师好早啊 大师好早啊,经常这么早吗? zwhzwh_11 发表于 2017-6-1 07:12
大师好早啊,经常这么早吗?
时间差,不在国内你懂的 大师肉身翻墙了 又有新的内容更新了,期待 期待大师的精彩内容! 何日更新啊何日更新
何日更新啊何日更新 C++的才疼… 大师早上好~仰望一下~ 大师早上好 大师好早 前排占位 不错。。。
继续。。。 大师,你就凌晨发出这几句话,何日更新…… 懂了,日后更新! stdio 发表于 2017-6-1 09:34
C++的才疼…
C++的疼什么,头文件的地位终于可以赶上C文件了,可以拥有自己的变量和代码了,虽然基本没人去用。
STL的代码都是写在头文件中的而且还不要扩展名;ATL就更变态了,变量定义都搞到头文件中了,C文件成了摆设。 takashiki 发表于 2017-6-2 10:58
C++的疼什么,头文件的地位终于可以赶上C文件了,可以拥有自己的变量和代码了,虽然基本没人去用。
STL的 ...
我其实不太敢评论C++,咱们就讨论C的好了,毕竟规则简单直接。 听大师讲课 听大师讲课 期待新课程 mark, 等待大师更新 搬凳子听大师讲课。。。 期待大师更新...... 这种进价知识确实需要认学习真 谢谢,期待后续更新。 A1:在头文件定义,会造成多重定义;
A2:编译器会把宏展开,再编译,所以顺序无关吧;
A3:宏和include都是直接替换,故无所谓位置(只要编译过); 谢谢,期待大师后续更新 谢谢,期待后续更新... {:lol:} 新帖,顶一下。。学习了~ 大师级的营养,顶 gujiamao_love 发表于 2017-6-6 18:02
A1:在头文件定义,会造成多重定义;
A2:编译器会把宏展开,再编译,所以顺序无关吧;
A3:宏和include都是 ...
恭喜您,答对了! Gorgon_Meducer 发表于 2017-6-1 04:49
附录
- Q1:为什么不能C语言头文件里面定义变量或者函数的实体?
平常是这么用的,也不知道原因{:smile:} soga238 发表于 2017-6-14 08:43
平常是这么用的,也不知道原因
平常如何用的?把变量和函数的“实体”放在了头文件里? Gorgon_Meducer 发表于 2017-6-14 16:13
平常如何用的?把变量和函数的“实体”放在了头文件里?
偶尔函数里放 #include, 只知道可以放,不知道原因 soga238 发表于 2017-6-16 17:17
偶尔函数里放 #include, 只知道可以放,不知道原因
现在应该知道了吧?在预编译阶段就够已经干掉了。到实际编译的时候已经被替换成对应的内容了(文本) 今日缓慢更新…… 大师难得露一次脸 {:biggrin:} 落叶知秋 发表于 2017-6-29 19:53
大师难得露一次脸
因为懒了…… {:handshake:}{:handshake:}{:handshake:}{:handshake:} Gorgon_Meducer 发表于 2017-6-29 23:52
因为懒了……
你好,我在参考key_queue.c改了一个消息的队列(meg_queue.c),在主循环里面不断调用meg_dequeue获取消息时,串口接收的数据有丢失(串口接收是采用中断的方式)。我想应该是meg_dequeue里面的原子保护(SAFE_ATOM_CODE())导致了串口接收数据丢失。请问这种方式用什么好的解决方案吗? 本帖最后由 Gorgon_Meducer 于 2017-7-14 17:35 编辑
cgbabc 发表于 2017-7-14 17:26
你好,我在参考key_queue.c改了一个消息的队列(meg_queue.c),在主循环里面不断调用meg_dequeue获取消息 ...
在你猜想原子保护导致丢失的时候,你评估过你保护的时间有多长么?
可以用IO口配合逻辑分析仪或者示波器来测量。一般只是保护队列的出入队操作是不会有这个问题的。
因为UART是一个低速设备,假设是9600波特率,意味着平均1ms才会有一个字符,如果你是115200波特率,
意味着平均80us才有一个字符,80us意味着什么?意味着,如果你系统频率是12M,你可以跑80*12=960个
指令周期,你什么队列操作要960个指令周期(关闭中断响应),如果你对960个指令周期没概念,假设2个
指令周期一个指令(这已经是比较差的情况了,大部分操作1个指令周期一个指令),一个指令2个字节,
也就是说,在你接收指令的间隔,差不多能运行0.9K的代码?你关闭终端响应的时候执行了0.9KB的代码?
尽可能保证中断做的事情最小。尽可能保证原子操作只保护最小的范围——比如出入队的那个部分。
显然问题不在这里,你好好找一找吧。做事情不要想当然,要定量分析。 本帖最后由 cgbabc 于 2017-7-14 22:38 编辑
Gorgon_Meducer 发表于 2017-7-14 17:32
在你猜想原子保护导致丢失的时候,你评估过你保护的时间有多长么?
可以用IO口配合逻辑分析仪或者示波器 ...
谢谢你的回答,我会再去重新分析是那里出了问题。你说的尽可能的保证原子操作只保护最小的范围,我把代码改成以下,你看行吗?
bool KEY_DEQUEUE(key_queue_t* ptQueue, key_event_t* ptKey)
{
CLASS(key_queue_t) *ptQ = (CLASS(key_queue_t) *)ptQueue;
bool bReturn = false;
if ((NULL == ptQueue) && (NULL == ptKey)) {
return false;
}
if(0 != ptQ->hwLength)//!< the queue empty
{
SAFE_ATOM_CODE(
//! read date form queue head
*ptKey = ptQ->ptBuffer;
ptQ->hwLength--; //!< update length
if(ptQ->hwSize == ++ptQ->hwHead){ //!< update queue head pointer
ptQ->hwHead = 0;
}
bReturn = true; //!< update return value
);
}
return bReturn;
} 原来来的代码是这样的:
bool KEY_DEQUEUE(key_queue_t* ptQueue, key_event_t* ptKey)
{
CLASS(key_queue_t) *ptQ = (CLASS(key_queue_t) *)ptQueue;
bool bReturn = false;
if ((NULL == ptQueue) && (NULL == ptKey)) {
return false;
}
SAFE_ATOM_CODE(
if(0 != ptQ->hwLength){ //!< the queue empty
//! read date form queue head
*ptKey = ptQ->ptBuffer;
ptQ->hwLength--; //!< update length
if(ptQ->hwSize == ++ptQ->hwHead){ //!< update queue head pointer
ptQ->hwHead = 0;
}
bReturn = true; //!< update return value
}
);
return bReturn;
} 本帖最后由 Gorgon_Meducer 于 2017-7-15 06:48 编辑
cgbabc 发表于 2017-7-14 22:39
原来来的代码是这样的:
bool KEY_DEQUEUE(key_queue_t* ptQueue, key_event_t* ptKey)
{
看了你的代码,我觉得丢数据不是原子操作的原因。你先检查SAFE_ATOM_CODE宏的实现是否
正确。如果没有问题,那可能是别的原因。因为这个队列操作本身占用时间并不长。
另外,你一个串口消息……用key_event_t做什么?让我很怀疑你中断处理程序里面都在做什么?
不应该是做一个普通的字节队列就行了么?你究竟想实现什么功能? Gorgon_Meducer 发表于 2017-7-15 06:46
看了你的代码,我觉得丢数据不是原子操作的原因。你先检查SAFE_ATOM_CODE宏的实现是否
正确。如果没有问 ...
上面的代码只是示意而已,并没有在串口接收中断里面使用它。 cgbabc 发表于 2017-7-15 15:06
上面的代码只是示意而已,并没有在串口接收中断里面使用它。
你提供的信息太少,我不知道怎么帮你。 Gorgon_Meducer 发表于 2017-7-17 19:11
你提供的信息太少,我不知道怎么帮你。
谢谢,我自己再去找找问题吧(其实是我自己表达能力不够)。其实自己是一直不知道如何能构建一个好一点的程序架构,老是程序改来改去的,很烦。一直期望你能出一本这类的书籍,但也知道你忙,一直等待中。有时间的时候就看看你以前写的帖子,把它们理解透彻。有时间就读那本OOPC那本书,但我感觉那本书,没你写的那些结构好用。摸索中前进.... 自己一直在思考,如何把延时,按键,串口,SPI,TIMER这些功能都做一些比较好用的模块,有新的项目的时候,直接调用就可以了。但以我现在的水平,还是没有达到那个水准。自己努力学习中.... cgbabc 发表于 2017-7-17 20:36
谢谢,我自己再去找找问题吧(其实是我自己表达能力不够)。其实自己是一直不知道如何能构建一个好一点的 ...
那你需要持续关注这个帖子,这个帖子介绍的内容,并不直接解决你的问题,但是却是解决你的问题
所必须的工具。因为这个帖子介绍模块封装的最基本技术(不是技巧,是技术) Gorgon_Meducer 发表于 2017-7-17 21:10
那你需要持续关注这个帖子,这个帖子介绍的内容,并不直接解决你的问题,但是却是解决你的问题
所必须的 ...
是的,一直关注中.....{:smile:} {:smile:} 留个名,一直这么做的 Gorgon_Meducer 发表于 2017-7-17 21:10
那你需要持续关注这个帖子,这个帖子介绍的内容,并不直接解决你的问题,但是却是解决你的问题
所必须的 ...
持刀等待更新 不错,貌似我已经在遵从大师的这种规则了 不好意思,最近有点忙,随后将会回复更新。 大师,这个帖子有后文吗?是不是在别的地方更新了? LB342342 发表于 2018-11-12 10:00
大师,这个帖子有后文吗?是不是在别的地方更新了?
还在连载,最近比较忙…… 傻孩子的很多连载都受益匪浅,唯一不习惯的是学起来太费劲---封装得太深了,要一一展开才能窥视一二 似乎明白了一些,又似乎不大明白。
请教大师:
“头文件里坚决不要放全局变量有关的任何东西”
BSP中要使用的全局变量,我放在一个.c中,然后在对应的.h中放它们的extern ****;
需要使用的其他.c直接包含这个全局变量头文件。这种方式合理么?
“隶属于它自己的接口头文件(Output)和配置头文件(Input)永远不要同时包含(include)在当前的C源文件中。”
以串口为例,对于硬件的描述在HwInfo.h中,Uart.c中包含HwInfo.h,Uart.h包含对外接口函数。
但某些时候,比如定义某个串口的初始波特率为9600,此时如果不包含Uart.h,
那么这个define就必须放在c代码中,这样似乎也不妥当。
fengxin32 发表于 2018-11-12 22:38
似乎明白了一些,又似乎不大明白。
请教大师:
“头文件里坚决不要放全局变量有关的任何东西”
关于你说的第一个问题,从语法的角度来说是没有问题的;但是从我强调的规范:
“头文件里坚决不要放全局变量有关的任何东西”来说就是错的。我觉得是我没说
清楚,其实我想说的是,就根本不要用全局变量,而不是全局变量放哪里的问题。
所有要用全局变量的场合,都可以用static变量+对变量进行读或者写的api来替换。
对于第二个问题,我觉得你需要详细描述一下,为什么你觉得“这样不妥”。因为
我看不出任何不妥,原本波特率这种东西,就是应该通过配置头文件传入进来的,
属于输入信息——配置头文件是可以被接口头文件包含的,也可以被.c包含,所以
确定波特率的宏定义应该放在配置头文件里,然后覆盖usart.h和usart.c。 我原来就是把内部的外部的函数声明一股脑全放进.h文件,随着程序量的增大,越来越混乱。后来试着开始分开。看了大师的建议,一下子就明朗了。
全局变量使用一个对外的函数来调用获取,确实的就不需要全局变量了。
下面的程序就是将定时器作为电机编码器的输入,定义内部静态变量pulsH为电机绝对位置。外部可以通过函数ReadEncoder()来获取电机的绝对位置。
/* pulsH为编码器技术溢出后的累加值 */
staticvolatile int32_t pulsH;
……
/**
* @brief读取编码器的数据
* @param无
* @retval经过计算后的编码器的数据
*/
int32_t ReadEncoder(void)
{
return pulsH + (uint32_t)(__HAL_TIM_GET_COUNTER(&htim2));//获取定时器的值
}
……
/**
* @brief 定时器中断回调函数调用,处理数据溢出
* @param无
* @retval 中断后,根据计数器方向,更新高位数据
*/
void pulsH_exe(TIM_HandleTypeDef *htim)
{
/* 根据定时器的CR1的“DIR”判断计数器的方向,1为负向,0为正向。 */
if( __HAL_TIM_IS_TIM_COUNTING_DOWN(htim) )
{
/* 65536为定时器重装载值+1 */
pulsH =pulsH - 65536;//__HAL_TIM_GET_AUTORELOAD ( htim ) ;
}
else
{
pulsH =pulsH + 65536;//__HAL_TIM_GET_AUTORELOAD ( htim ) ;
}
}
本帖最后由 Gorgon_Meducer 于 2018-11-16 19:48 编辑
LB342342 发表于 2018-11-16 10:54
我原来就是把内部的外部的函数声明一股脑全放进.h文件,随着程序量的增大,越来越混乱。后来试着开始分开。 ...
没错,很高兴能帮助到你。
我觉得你已经注意到了,用静态变量+API的方式可以很好的控制外部对静态变量的访问权限。
相当于你可以控制别人对变量的访问是:只读,只写,还是读写都可以。另外,由于是通过
函数的,所以可以进行必要的有效性检测;也避免了过去那种,不知道哪里访问了全局变量
带来的移植和修改问题。
本质上,这就是面向接口开发。这是一个好的开始。
提个思考题:有人说,给静态变量提供API,还要专门写个函数,是不是增加了系统开销,
或者降低了效率呢?
我先给结论,剩下你自己去寻找答案:
结论:提供API在效率上并没有任何牺牲,甚至代码尺寸还更小了……如果用inline的话,
基本上效果跟过去直接用全局变量是一样的(尺寸和效率都不会变化)。 更新模块内头文件的包含原则。 等待更新! 留名仔细研究 向大神学习。
页:
[1]