当前位置:首页 > C语言
  • 10张图让你彻底理解回调函数

    大家好,以前写过一篇关于回调函数的文章C语言函数指针之回调函数,今天又安排了一篇。 不知你是不是也有这样的疑惑,我们为什么需要回调函数这个概念呢?直接调用函数不就可以了?回调函数到底有什么作用?程序员到底该如何理解回调函数? 这篇文章就来为你解答这些问题,读完这篇文章后你的武器库将新增一件功能强大的利器。 一切要从这样的需求说起 假设你们公司要开发下一代国民App“明日油条”,一款主打解决国民早餐问题的App,为了加快开发进度,这款应用由A小组和B小组协同开发。 其中有一个核心模块由A小组开发然后供B小组调用,这个核心模块被封装成了一个函数,这个函数就叫make_youtiao()。 如果make_youtiao()这个函数执行的很快并可以立即返回,那么B小组的同学只需要: 调用make_youtiao() 等待该函数执行完成 该函数执行完后继续后续流程 从程序执行的角度看这个过程是这样的: 保存当前被执行函数的上下文 开始执行make_youtiao()这个函数 make_youtiao()执行完后,控制转回到调用函数中 如果世界上所有的函数都像make_youtiao()这么简单,那么程序员大概率就要失业了,还好程序的世界是复杂的,这样程序员才有了存在的价值。 现实并不容易 现实中make_youtiao()这个函数需要处理的数据非常庞大,假设有10000个,那么make_youtiao(10000)不会立刻返回,而是可能需要10分钟才执行完成并返回。 这时你该怎么办呢?想一想这个问题。 可能有的同学会问,和刚才一样直接调用不可以吗,这样多简单。 是的,这样做没有问题,但就像爱因斯坦说的那样“一切都应该尽可能简单,但是不能过于简单”。 想一想直接调用会有什么问题? 显然直接调用的话,那么调用线程会被阻塞暂停,在等待10分钟后才能继续运行。在这10分钟内该线程不会被操作系统分配CPU,也就是说该线程得不到任何推进。 这并不是一种高效的做法。 没有一个程序员想死盯着屏幕10分钟后才能得到结果。 那么有没有一种更加高效的做法呢? 想一想我们上一篇中那个一直盯着你写代码的老板(见《从小白到高手,你需要理解同步与异步》),我们已经知道了这种一直等待直到另一个任务完成的模式叫做同步。 如果你是老板的话你会什么都不干一直盯着员工写代码吗?因此一种更好的做法是程序员在代码的时候老板该干啥干啥,程序员写完后自然会通知老板,这样老板和程序员都不需要相互等待,这种模式被称为异步。 回到我们的主题,这里一种更好的方式是调用make_youtiao()这个函数后不再等待这个函数执行完成,而是直接返回继续后续流程,这样A小组的程序就可以和make_youtiao()这个函数同时进行了,就像这样: 在这种情况下,回调(callback)就必须出场了。 为什么我们需要回调callback 有的同学可能还没有明白为什么在这种情况下需要回调,别着急,我们慢慢讲。 假设我们“明日油条”App代码第一版是这样写的: make_youtiao(10000);sell(); 可以看到这是最简单的写法,意思很简单,制作好油条后卖出去。 我们已经知道了由于make_youtiao(10000)这个函数10分钟才能返回,你不想一直死盯着屏幕10分钟等待结果,那么一种更好的方法是让make_youtiao()这个函数知道制作完油条后该干什么,即,更好的调用make_youtiao的方式是这样的: “制作10000个油条, 炸好后卖出去 ”,因此调用make_youtiao就变出这样了: make_youtiao(10000, sell); 看到了吧,现在make_youtiao这个函数多了一个参数,除了指定制作油条的数量外 还可以指定制作好后该干什么 ,第二个被make_youtiao这个函数调用的函数就叫回调,callback。 现在你应该看出来了吧,虽然sell函数是你定义的,但是这个函数却是被其它模块调用执行的,就像这样: make_youtiao这个函数是怎么实现的呢,很简单: void make_youtiao(int num, func call_back) { // 制作油条 call_back(); //执行回调 } 这样你就不用死盯着屏幕了,因为你把make_youtiao这个函数执行完后该做的任务交代给make_youtiao这个函数了,该函数制作完油条后知道该干些什么,这样就解放了你的程序。 有的同学可能还是有疑问,为什么编写make_youtiao这个小组不直接定义sell函数然后调用呢? 不要忘了明日油条这个App是由A小组和B小组同时开发的,A小组在编写make_youtiao时怎么知道B小组要怎么用这个模块,假设A小组真的自己定义sell函数就会这样写: void make_youtiao(int num) { real_make_youtiao(num); sell(); //执行回调 } 同时A小组设计的模块非常好用,这时C小组也想用这个模块,然而C小组的需求是制作完油条后放到仓库而不是不是直接卖掉,要满足这一需求那么A小组该怎么写呢? void make_youtiao(int num) { real_make_youtiao(num); if (Team_B) { sell(); // 执行回调 } else if (Team_D) { store(); // 放到仓库 }} 故事还没完,假设这时D小组又想使用呢,难道还要接着添加if else吗? 这样的话A小组的同学只需要维护make_youtiao这个函数就能做到工作量饱满了,显然这是一种非常糟糕的设计。 所以你会看到,制作完油条后接下来该做什么不是实现make_youtiao的A小组该关心的事情,很明显只有调用make_youtiao这个函数的使用方才知道。 因此make_youtiao的A小组完全可以通过回调函数将接下来该干什么交给调用方实现,A小组的同学只需要针对回调函数这一抽象概念进行编程就好了,这样调用方在制作完油条后不管是卖掉、放到库存还是自己吃掉等等想做什么都可以,A小组的make_youtiao函数根本不用做任何改动,因为A小组是针对回调函数这一抽象概念来编程的。 以上就是回调函数的作用,当然这也是针对抽象而不是具体实现进行编程这一思想的威力所在。面向对象中的多态本质上就是让你用来针对抽象而不是针对实现来编程的。 异步回调 故事到这里还没有结束。 在上面的示例中,虽然我们使用了回调这一概念,也就是调用方实现回调函数然后再将该函数当做参数传递给其它模块调用。 但是,这里依然有一个问题,那就是make_youtiao函数的调用方式依然是同步的,关于同步异步请参考《从小白到高手,你需要理解同步与异步》,也就是说调用方是这样实现的: make_youtiao(10000, sell);// make_youtiao函数返回前什么都做不了 我们可以看到,调用方必须等待make_youtiao函数返回后才可以继续后续流程,我们再来看下make_youtiao函数的实现: void make_youtiao(int num, func call_back) { real_make_youtiao(num); call_back(); //执行回调 } 看到了吧,由于我们要制作10000个油条,make_youtiao函数执行完需要10分钟,也就是说即便我们使用了回调,调用方完全不需要关心制作完油条后的后续流程,但是调用方依然会被阻塞10分钟,这就是同步调用的问题所在。 如果你真的理解了上一节的话应该能想到一种更好的方法了。 没错,那就是异步调用。 反正制作完油条后的后续流程并不是调用方该关心的,也就是说调用方并不关心make_youtiao这一函数的返回值,那么一种更好的方式是:把制作油条的这一任务放到另一个线程(进程)、甚至另一台机器上。 如果用线程实现的话,那么make_youtiao就是这样实现了: void make_youtiao(int num, func call_back) { // 在新的线程中执行处理逻辑 create_thread(real_make_youtiao, num, call_back);} 看到了吧,这时当我们调用make_youtiao时就会 立刻返回 ,即使油条还没有真正开始制作,而调用方也完全无需等待制作油条的过程,可以立刻执行后流程: make_youtiao(10000, sell);// 立刻返回// 执行后续流程 这时调用方的后续流程可以和制作油条 同时 进行,这就是函数的 异步调用 ,当然这也是异步的高效之处。 新的编程思维模式 让我们再来仔细的看一下这个过程。 程序员最熟悉的思维模式是这样的: 调用某个函数,获取结果 处理获取到的结果 res = request();handle(res); 这就是函数的同步调用,只有request()函数返回拿到结果后,才能调用handle函数进行处理,request函数返回前我们必须 等待 ,这就是同步调用,其控制流是这样的: 但是如果我们想更加高效的话,那么就需要异步调用了,我们不去直接调用handle函数,而是作为参数传递给request: request(handle); 我们根本就不关心request什么时候真正的获取的结果,这是request该关心的事情,我们只需要把获取到结果后该怎么处理告诉request就可以了,因此request函数可以立刻返回,真的获取结果的处理可能是在另一个线程、进程、甚至另一台机器上完成。 这就是异步调用,其控制流是这样的: 从编程思维上看,异步调用和同步有很大的差别,如果我们把处理流程当做一个任务来的话,那么同步下整个任务都是我们来实现的,但是异步情况下任务的处理流程被分为了两部分: 第一部分是我们来处理的,也就是调用request之前的部分 第二部分不是我们处理的,而是在其它线程、进程、甚至另一个机器上处理的。 我们可以看到由于任务被分成了两部分,第二部分的调用不在我们的掌控范围内,同时只有调用方才知道该做什么,因此在这种情况下回调函数就是一种必要的机制了。 也就是说回调函数的本质就是“只有我们才知道做些什么,但是我们并不清楚什么时候去做这些,只有其它模块才知道,因此我们必须把我们知道的封装成回调函数告诉其它模块”。 现在你应该能看出异步回调这种编程思维模式和同步的差异了吧。 接下来我们给回调一个较为学术的定义 正式定义 在计算机科学中,回调函数是指一段以参数的形式传递给其它代码的可执行代码。 这就是回调函数的定义了。 回调函数就是一个函数,和其它函数没有任何区别。 注意,回调函数是一种软件设计上的概念,和某个编程语言没有关系,几乎所有的编程语言都能实现回调函数。 对于一般的函数来说,我们自己编写的函数会在自己的程序内部调用,也就是说函数的编写方是我们自己,调用方也是我们自己。 但回调函数不是这样的,虽然函数编写方是我们自己,但是函数调用方不是我们,而是我们引用的其它模块,也就是第三方库,我们调用第三方库中的函数,并把回调函数传递给第三方库,第三方库中的函数调用我们编写的回调函数,如图所示: 而之所以需要给第三方库指定回调函数,是因为第三方库的编写者并不清楚在某些特定节点,比如我们举的例子油条制作完成、接收到网络数据、文件读取完成等之后该做什么,这些只有库的使用方才知道,因此第三方库的编写者无法针对具体的实现来写代码,而只能对外提供一个回调函数,库的使用方来实现该函数,第三方库在特定的节点调用该回调函数就可以了。 另一点值得注意的是,从图中我们可以看出回调函数和我们的主程序位于同一层中,我们只负责编写该回调函数,但并不是我们来调用的。 最后值得注意的一点就是回调函数被调用的时间节点,回调函数只在某些特定的节点被调用,就像上面说的油条制作完成、接收到网络数据、文件读取完成等,这些都是事件,也就是event,本质上我们编写的回调函数就是用来处理event的,因此从这个角度看回调函数不过就是event handler,因此回调函数天然适用于事件驱动编程event-driven,我们将会在后续文章中再次回到这一主题。 回调的类型 我们已经知道有两种类型的回调,这两种类型的回调区别在于回调函数被调用的时机。 注意,接下来会用到同步和异步的概念,对这两个概念不熟悉的同学可以参考上一盘文章《从小白到高手,你需要理解同步和异步》。 同步回调 这种回调就是通常所说的同步回调synchronous callbacks、也有的将其称为阻塞式回调blocking callbacks,或者什么修饰都没有,就是回调,callback,这是我们最为熟悉的回调方式。 当我们调用某个函数A并以参数的形式传入回调函数后,在A返回之前回调函数会被执行,也就是说我们的主程序会等待回调函数执行完成,这就是所谓的同步回调。 有同步回调就有异步回调。 异步回调 不同于同步回调, 当我们调用某个函数A并以参数的形式传入回调函数后,A函数会立刻返回,也就是说函数A并不会阻塞我们的主程序,一段时间后回调函数开始被执行,此时我们的主程序可能在忙其它任务,回调函数的执行和我们主程序的运行同时进行。 既然我们的主程序和回调函数的执行可以同时发生,因此一般情况下,主程序和回调函数的执行位于不同的线程或者进程中。 这就是所谓的异步回调,asynchronous callbacks,也有的资料将其称为deferred callbacks ,名字很形象,延迟回调。 从上面这两张图中我们也可以看到,异步回调要比同步回调更能充分的利用机器资源,原因就在于在同步模式下主程序会“偷懒”,因为调用其它函数被阻塞而暂停运行,但是异步调用不存在这个问题,主程序会一直运行下去。 因此,异步回调更常见于I/O操作,天然适用于Web服务这种高并发场景。 回调对应的编程思维模式 让我们用简单的几句话来总结一下回调下与常规编程思维模式的不同。 假设我们想处理某项任务,这项任务需要依赖某项服务S,我们可以将任务的处理分为两部分,调用服务S前的部分PA,和调用服务S后的部分PB。 在常规模式下,PA和PB都是服务调用方来执行的,也就是我们自己来执行PA部分,等待服务S返回后再执行PB部分。 但在回调这种方式下就不一样了。 在这种情况下,我们自己来执行PA部分,然后告诉服务S:“等你完成服务后执行PB部分”。 因此我们可以看到,现在一项任务是由不同的模块来协作完成的。 即: 常规模式:调用完S服务后后我去执行X任务, 回调模式:调用完S服务后你接着再去执行X任务, 其中X是服务调用方制定的,区别在于谁来执行。 为什么异步回调越来越重要 在同步模式下,服务调用方会因服务执行而被阻塞暂停执行,这会导致整个线程被阻塞,因此这种编程方式天然不适用于高并发动辄几万几十万的并发连接场景, 针对高并发这一场景,异步其实是更加高效的,原因很简单,你不需要在原地等待,因此从而更好的利用机器资源,而回调函数又是异步下不可或缺的一种机制。 回调地狱,callback hell 有的同学可能认为有了异步回调这种机制应付起一切高并发场景就可以高枕无忧了。 实际上在计算机科学中还没有任何一种可以横扫一切包治百病的技术,现在没有,在可预见的将来也不会有,一切都是妥协的结果。 那么异步回调这种机制有什么问题呢? 实际上我们已经看到了,异步回调这种机制和程序员最熟悉的同步模式不一样,在可理解性上比不过同步,而如果业务逻辑相对复杂,比如我们处理某项任务时不止需要调用一项服务,而是几项甚至十几项,如果这些服务调用都采用异步回调的方式来处理的话,那么很有可能我们就陷入回调地狱中。 举个例子,假设处理某项任务我们需要调用四个服务,每一个服务都需要依赖上一个服务的结果,如果用同步方式来实现的话可能是这样的: a = GetServiceA();b = GetServiceB(a);c = GetServiceC(b);d = GetServiceD(c); 代码很清晰,很容易理解有没有。 我们知道异步回调的方式会更加高效,那么使用异步回调的方式来写将会是什么样的呢? GetServiceA(function(a){ GetServiceB(a, function(b){ GetServiceC(b, function(c){ GetServiceD(c, function(d) { .... }); }); });}); 我想不需要再强调什么了吧,你觉得这两种写法哪个更容易理解,代码更容易维护呢? 博主有幸曾经维护过这种类型的代码,不得不说每次增加新功能的时候恨不得自己化为两个分身,一个不得不去重读一边代码;另一个在一旁骂自己为什么当初选择维护这个项目。 异步回调代码稍不留意就会跌到回调陷阱中,那么有没有一种更好的办法既能结合异步回调的高效又能结合同步编码的简单易读呢? 幸运的是,答案是肯定的,我们会在后续文章中详细讲解这一技术。 总结 在这篇文章中,我们从一个实际的例子出发详细讲解了回调函数这种机制的来龙去脉,这是应对高并发、高性能场景的一种极其重要的编码机制,异步加回调可以充分利用机器资源,实际上异步回调最本质上就是事件驱动编程,这是我们接下来要重点讲解的内容。 免责声明:本文内容由21ic获得授权后发布,版权归原作者所有,本平台仅提供信息存储服务。文章仅代表作者个人观点,不代表本平台立场,如有问题,请联系我们,谢谢!

    时间:2021-02-25 关键词: 回调函数 C语言

  • C语言实现MD5加密,竟如此简单!

    本文详细讲解视频已经上传到B站: https://www.bilibili.com/video/BV1uy4y1p7on/ 公众号后台回复【md5】即可获得本文所有源码。 一、摘要算法 摘要算法又称哈希算法。 它表示输入任意长度的数据,输出固定长度的数据,它的主要特征是加密过程不需要密钥,并且经过加密的数据无法被解密。 目前可以被解密逆向的只有CRC32算法,只有输入相同的明文数据经过相同的消息摘要算法才能得到相同的密文。 消息摘要算法不存在密钥的管理与分发问题,适合于分布式网络上使用。由于其加密计算的工作量相当巨大,所以以前的这种算法通常只用于数据量有限的情况下的加密。 消息摘要算法分为三类: MD(Message Digest):消息摘要 SHA(Secure Hash Algorithm):安全散列 MAC(Message Authentication Code):消息认证码 这三类算法的主要作用:验证数据的完整性 二、MD5简介 MD5即Message-Digest Algorithm 5(信息-摘要算法)。 属于摘要算法,是一个不可逆过程,就是无论多大数据,经过算法运算后都是生成固定长度的数据,结果使用16进制进行显示的128bit的二进制串。通常表示为32个十六进制数连成的字符串。 MD5有什么用? 用于确保信息传输完整一致。是计算机广泛使用的杂凑算法之一(又译摘要算法、哈希算法),主流编程语言普遍已有MD5实现。更多用在文档校验上,用来生成密钥检测文档是否被篡改。 三、在线MD5加密 有很多在线进行MD5加密的网站,如下: http://www.metools.info/code/c26.html 举例: 给字符串 12334567 加密成。 如图结果为: 32135A337F8DC8E2BB9A9B80D86BDFD0 四、C语言实现MD5算法 源文件如下:md5.h #ifndef MD5_H#define MD5_H typedef struct{    unsigned int count[2];    unsigned int state[4];    unsigned char buffer[64];   }MD5_CTX;                          #define F(x,y,z) ((x & y) | (~x & z))#define G(x,y,z) ((x & z) | (y & ~z))#define H(x,y,z) (x^y^z)#define I(x,y,z) (y ^ (x | ~z))#define ROTATE_LEFT(x,n) ((x  (32-n)))#define FF(a,b,c,d,x,s,ac) \          { \          a += F(b,c,d) + x + ac; \          a = ROTATE_LEFT(a,s); \          a += b; \          }#define GG(a,b,c,d,x,s,ac) \          { \          a += G(b,c,d) + x + ac; \          a = ROTATE_LEFT(a,s); \          a += b; \          }#define HH(a,b,c,d,x,s,ac) \          { \          a += H(b,c,d) + x + ac; \          a = ROTATE_LEFT(a,s); \          a += b; \          }#define II(a,b,c,d,x,s,ac) \          { \          a += I(b,c,d) + x + ac; \          a = ROTATE_LEFT(a,s); \          a += b; \          }                                            void MD5Init(MD5_CTX *context);void MD5Update(MD5_CTX *context,unsigned char *input,unsigned int inputlen);void MD5Final(MD5_CTX *context,unsigned char digest[16]);void MD5Transform(unsigned int state[4],unsigned char block[64]);void MD5Encode(unsigned char *output,unsigned int *input,unsigned int len);void MD5Decode(unsigned int *output,unsigned char *input,unsigned int len); #endif md5.c #include #include "md5.h" unsigned char PADDING[]={0x80,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,                         0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,                         0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,                         0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,0};                         void MD5Init(MD5_CTX *context){     context->count[0] = 0;     context->count[1] = 0;     context->state[0] = 0x67452301;     context->state[1] = 0xEFCDAB89;     context->state[2] = 0x98BADCFE;     context->state[3] = 0x10325476;}void MD5Update(MD5_CTX *context,unsigned char *input,unsigned int inputlen){    unsigned int i = 0,index = 0,partlen = 0;    index = (context->count[0] >> 3) & 0x3F;    partlen = 64 - index;    context->count[0] += inputlen  29;        if(inputlen >= partlen)    {       memcpy(&context->buffer[index],input,partlen);       MD5Transform(context->state,context->buffer);       for(i = partlen;i+64 state,&input[i]);       index = 0;            }      else    {        i = 0;    }    memcpy(&context->buffer[index],&input[i],inputlen-i);}void MD5Final(MD5_CTX *context,unsigned char digest[16]){    unsigned int index = 0,padlen = 0;    unsigned char bits[8];    index = (context->count[0] >> 3) & 0x3F;    padlen = (index count,8);    MD5Update(context,PADDING,padlen);    MD5Update(context,bits,8);    MD5Encode(digest,context->state,16);}void MD5Encode(unsigned char *output,unsigned int *input,unsigned int len){    unsigned int i = 0,j = 0;    while(j > 8) & 0xFF;         output[j+2] = (input[i] >> 16) & 0xFF;         output[j+3] = (input[i] >> 24) & 0xFF;         i++;         j+=4;    }}void MD5Decode(unsigned int *output,unsigned char *input,unsigned int len){     unsigned int i = 0,j = 0;     while(j 

    时间:2021-02-25 关键词: MD5 摘要算法 C语言

  • C语言编程规范 clean code

    规则并不是完美的,通过禁止在特定情况下有用的特性,可能会对代码实现造成影响。但是我们制定规则的目的“为了大多数程序员可以得到更多的好处”, 如果在团队运作中认为某个规则无法遵循,希望可以共同改进该规则。参考该规范之前,希望您具有相应的C语言基础能力,而不是通过该文档来学习C语言。 总体原则 约定 无论是“规则”还是“建议”,都必须理解该条目这么规定的原因,并努力遵守。 在不违背总体原则,经过充分考虑,有充足的理由的前提下,可以适当违背规范中约定。 例外破坏了代码的一致性,请尽量避免。“规则”的例外应该是极少的。 1 命名 命名被认为是软件开发过程中最困难,也是最重要的事情。 标识符的命名要清晰、明了,有明确含义,符合阅读习惯,容易理解。 总体风格 规则1.1 标识符命名使用驼峰风格 类型 命名风格 函数,结构体类型,枚举类型,联合体类型 大驼峰 变量,函数参数,宏参数,结构体中字段,联合体中成员 小驼峰 宏,常量,枚举值,goto 标签 全大写,下划线分割 常量是指,全局作用域下,const 修饰的基本数据类型、枚举、字符串类型的变量,不包括数组、结构体和联合体。 上表中建议1.1 作用域越大,命名应越精确 例: int GetCount(void); // Bad: 描述不精确 int GetActiveConnectCount(void); // Good 文件命名 文件名命名只允许使用小写字母、数字以及下划线(_)。 文件名应尽量简短、准确、无二义性。 不大小写混用的原因是,不同系统对文件名大小写处理会不同(如 MicroSoft 的 DOS, Windows 系统不区分大小写,但是 Unix / Linux, Mac 系统则默认区分)。 dhcp_user_log.c 坏的命名举例: dhcpuserlog.c: 未分割单词,可读性差 函数命名 函数命名统一使用大驼峰风格。 建议1.3 函数的命名遵循阅读习惯 动作类函数名,可以使用动宾结构。如: 判断型函数,可以用形容词,或加 is: DataReady() // OK IsRunning() // OK JobDone() // OK 变量命名 规则1.2 全局变量应增加 'g_' 前缀,函数内静态变量命名不需要加特殊前缀 注意:常量本质也是全局变量,但如果命名风格是全大写,下划线连接的格式,则不适用当前规则。 函数局部变量的命名,在能够表达相关含义的前提下,应该简短。 更好的写法: int Func(...) { enum PowerBoardStatus status; // Good: 结合上下文,status 已经能明确表达意思 status = GetPowerBoardStatus(slot); if (status == POWER_OFF) { ... } ... } 或一些简单的数学计算函数中的变量: int Mul(int a, int b) { return a * b; } 类型命名采用大驼峰命名风格。 类型包括结构体、联合体、枚举类型名。 通过 typedef 对结构体、联合体、枚举起别名时,尽量使用匿名类型。 若需要指针自嵌套,可以增加 'tag' 前缀或下划线后缀。 typedef struct { // Good: 无须自嵌套,使用匿名结构体 int a; int b; } MyType; // 结构体别名用大驼峰风格 typedef struct tagNode { // Good: 使用 tag 前缀。这里也可以使用 'Node_'代替也可以。 struct tagNode *prev; struct tagNode *next; } Node; // 类型主体用大驼峰风格 宏、枚举值采用全大写,下划线连接的格式。 常量推荐采用全大写,下划线连接风格。作为全局变量,也可以保持与普通全局变量命名风格相同。 这里常量如前文定义,是指基本数据类型、枚举、字符串类型的全局 const 变量。 宏举例: #define PI 3.14 #define MAX(a, b) (((a) < (b)) ? (b) : (a)) #ifdef SOME_DEFINE void Bar(int); #define Foo(a) Bar(a) // 特殊场景,用大驼峰风格命名函数式宏 #else void Foo(int); #endif 非常量举例: // 结构体类型,不符合常量定义 const struct MyType g_myData = { ... }; // OK: 用小驼峰 // 数组类型,不符合常量定义 const int g_xxxBaseValue[4] = { 1, 2, 4, 8 }; // OK: 用小驼峰 int Foo(...) { // 局部作用域,不符合常量定义 const int bufSize = 100; // OK: 用小驼峰 ... } 建议1.5 避免函数式宏中的临时变量命名污染外部作用域 当函数式宏需要定义局部变量时,为了防止跟外部函数中的局部变量有命名冲突。 2 排版格式 建议2.1 行宽不超过 120 个字符 如下场景不宜换行,可以例外: 例: #ifndef XXX_YYY_ZZZ #error Header aaaa/bbbb/cccc/abc.h must only be included after xxxx/yyyy/zzzz/xyz.h #endif 规则2.1 使用空格进行缩进,每次缩进4个空格 大括号 K&R风格 换行时,函数左大括号另起一行放行首,并独占一行;其他左大括号跟随语句放行末。 右大括号独占一行,除非后面跟着同一语句的剩余部分,如 do 语句中的 while,或者 if 语句的 else/else if,或者逗号、分号。 函数声明和定义 在声明和定义函数的时候,函数的返回值类型应该和函数名在同一行。 换行举例: ReturnType FunctionName(ArgType paramName1, ArgType paramName2) // Good:全在同一行 { ... } ReturnType VeryVeryVeryLongFunctionName(ArgType paramName1, // 行宽不满足所有参数,进行换行 ArgType paramName2, // Good:和上一行参数对齐 ArgType paramName3) { ... } ReturnType LongFunctionName(ArgType paramName1, ArgType paramName2, // 行宽限制,进行换行 ArgType paramName3, ArgType paramName4, ArgType paramName5) // Good: 换行后 4 空格缩进 { ... } ReturnType ReallyReallyReallyReallyLongFunctionName( // 行宽不满足第1个参数,直接换行 ArgType paramName1, ArgType paramName2, ArgType paramName3) // Good: 换行后 4 空格缩进 { ... } 规则2.4 函数调用参数列表换行时保持参数进行合理对齐 换行举例: ReturnType result = FunctionName(paramName1, paramName2); // Good:函数参数放在一行 ReturnType result = FunctionName(paramName1, paramName2, // Good:保持与上方参数对齐 paramName3); ReturnType result = FunctionName(paramName1, paramName2, paramName3, paramName4, paramName5); // Good:参数换行,4 空格缩进 ReturnType result = VeryVeryVeryLongFunctionName( // 行宽不满足第1个参数,直接换行 paramName1, paramName2, paramName3); // 换行后,4 空格缩进 条件语句 我们要求条件语句都需要使用大括号,即便只有一条语句。 理由: 规则2.6 禁止 if/else/else if 写在同一行 如下是正确的写法: if (someConditions) { ... } else { // Good: else 与 if 在不同行 ... } 循环 和条件表达式类似,我们要求for/while循环语句必须加上大括号,即便循环体是空的,或循环语句只有一条。 for (int i = 0; i < someRange; i++) { // Good: 使用了大括号 DoSomething(); } while (condition) { } // Good:循环体是空,使用大括号 while (condition) { continue; // Good:continue 表示空逻辑,使用大括号 } switch语句 switch 语句的缩进风格如下: switch (var) { case 0: // Good: 缩进 DoSomething1(); // Good: 缩进 break; case 1: { // Good: 带大括号格式 DoSomething2(); break; } default: break; } switch (var) { case 0: // Bad: case 未缩进 DoSomething(); break; default: // Bad: default 未缩进 break; } 建议2.2 表达式换行要保持换行的一致性,操作符放行末 例: // 假设下面第一行已经不满足行宽要求 if ((currentValue > MIN) && // Good:换行后,布尔操作符放在行末 (currentValue < MAX)) { DoSomething(); ... } int result = reallyReallyLongVariableName1 + // Good: 加号留在行末 reallyReallyLongVariableName2; 变量赋值 每行最好只有一个变量初始化的语句,更容易阅读和理解。 int maxCount = 10; bool isCompleted = false; 例外情况: 对于多个相关性强的变量定义,且无需初始化时,可以定义在一行,减少重复信息,以便代码更加紧凑。 int i, j; // Good:多变量定义,未初始化,可以写在一行 for (i = 0; i < row; i++) { for (j = 0; j < col; j++) { ... } } 初始化包括结构体、联合体及数组的初始化 结构体或数组初始化时,如果换行应保持4空格缩进。 从可读性角度出发,选择换行点和对齐位置。 // Good: 满足行宽要求时不换行 int arr[4] = { 1, 2, 3, 4 }; // Good: 行宽较长时,换行让可读性更好 const int rank[] = { 16, 16, 16, 16, 32, 32, 32, 32, 64, 64, 64, 64, 32, 32, 32, 32 }; 注意: 规则2.11 结构体和联合体在按成员初始化时,每个成员初始化单独一行 指针 声明或定义指针变量或者返回指针类型函数时,"*" 靠左靠右都可以,但是不要两边都有或者都没有空格。 int *p1; // OK. int* p2; // OK. int*p3; // Bad: 两边都没空格 int * p4; // Bad: 两边都有空格 选择"*"跟随类型风格时,避免一行同时声明带指针的多个变量。 int* a, b; // Bad: 很容易将 b 误理解成指针 注意,任何时候 "*" 不要紧跟 const 或 restrict 关键字。 规则2.12 编译预处理的"#"默认放在行首,嵌套编译预处理语句时,"#"可以进行缩进 空格和空行 水平空格应该突出关键字和重要信息,每行代码尾部不要加空格。总体规则如下: 对于大括号内部两侧的空格,建议如下: 常规情况: int i = 0; // Good:变量初始化时,= 前后应该有空格,分号前面不要留空格 int buf[BUF_SIZE] = {0}; // Good:数组初始化时,大括号内空格可选 int arr[] = { 10, 20 }; // Good: 正常大括号内部两侧建议加空格 指针和取地址 x = *p; // Good:*操作符和指针p之间不加空格 p = &x; // Good:&操作符和变量x之间不加空格 x = r.y; // Good:通过.访问成员变量时不加空格 x = r->y; // Good:通过->访问成员变量时不加空格 循环和条件语句: if (condition) { // Good:if关键字和括号之间加空格,括号内条件语句前后不加空格 ... } else { // Good:else关键字和大括号之间加空格 ... } while (condition) {} // Good:while关键字和括号之间加空格,括号内条件语句前后不加空格 for (int i = 0; i < someRange; ++i) { // Good:for关键字和括号之间加空格,分号之后加空格 ... } switch (var) { // Good: switch 关键字后面有1空格 case 0: // Good:case语句条件和冒号之间不加空格 ... break; ... default: ... break; } 建议2.4 合理安排空行,保持代码紧凑 根据上下内容的相关程度,合理安排空行; 函数内部、类型定义内部、宏内部、初始化表达式内部,不使用连续空行 不使用连续 3 个空行,或更多 大括号内的代码块首行之前和末行之后不要加空行。 ret = DoSomething(); if (ret != OK) { // Bad: 返回值判断应该紧跟函数调用 return -1; } int Foo(void) { ... } int Bar(void) // Bad:最多使用连续2个空行。 { ... } int Foo(void) { DoSomething(); // Bad:大括号内部首尾,不需要空行 ... } 一般的,尽量通过清晰的架构逻辑,好的符号命名来提高代码可读性;需要的时候,才辅以注释说明。 注释是为了帮助阅读者快速读懂代码,所以要从读者的角度出发,按需注释。 注释跟代码一样重要。 写注释时要换位思考,用注释去表达此时读者真正需要的信息。在代码的功能、意图层次上进行注释,即注释解释代码难以表达的意图,不要重复代码信息。 修改代码时,也要保证其相关注释的一致性。只改代码,不改注释是一种不文明行为,破坏了代码与注释的一致性,让阅读者迷惑、费解,甚至误解。 注释风格 /*//都是可以的。 按注释的目的和位置,注释可分为不同的类型,如文件头注释、函数头注释、代码注释等等; 同一类型的注释应该保持统一的风格。 注意:本文示例代码中,大量使用 '//' 后置注释只是为了更精确的描述问题,并不代表这种注释风格更好。 文件头注释 规则3.1 文件头注释必须包含版权许可 /* Copyright (c) 2020 XXX you may not use this file except in compliance with the License. http://www.apache.org/licenses/LICENSE-2.0 distributed under the License is distributed on an "AS IS" BASIS, See the License for the specific language governing permissions and // 单行函数头 int Func1(void); // 多行函数头 // 第二行 int Func2(void); 函数尽量通过函数名自注释,按需写函数头注释。 不要写无用、信息冗余的函数头;不要写空有格式的函数头。 例: /* * 返回实际写入的字节数,-1表示写入失败 * 注意,内存 buf 由调用者负责释放 */ int WriteString(char *buf, int len); 上面例子中的问题: 代码注释 规则3.4 注释符与注释内容间要有1空格;右置注释与前面代码至少1空格 使用'/*' '*/' /* 这是单行注释 */ DoSomething(); /* * 这是单/多行注释 * 第二行 */ DoSomething(); 选择并统一使用如下风格之一: int foo = 100; // 放右边的注释 int bar = 200; /* 放右边的注释 */ 当右置的注释超过行宽时,请考虑将注释置于代码上方。 被注释掉的代码,无法被正常维护;当企图恢复使用这段代码时,极有可能引入易被忽略的缺陷。 正确的做法是,不需要的代码直接删除掉。若再需要时,考虑移植或重写这段代码。 建议3.1 case语句块结束时如果不加break/return,需要有注释说明(fall-through) 例,显式指明 fall-through: switch (var) { case 0: DoSomething(); /* fall-through */ case 1: DoSomeOtherThing(); ... break; default: DoNothing(); break; } 4 头文件 本章从编程规范的角度总结了一些方法,可用于帮助合理规划头文件。 头文件是模块或文件的对外接口。 头文件中适合放置接口的声明,不适合放置实现(内联函数除外)。 头文件应当职责单一。头文件过于复杂,依赖过于复杂还是导致编译时间过长的主要原因。 通常情况下,每个.c文件都有一个相应的.h(并不一定同名),用于放置对外提供的函数声明、宏定义、类型定义等。 如果一个.c文件不需要对外公布任何接口,则其就不应当存在。 示例: foo.h 内容 #ifndef FOO_H #define FOO_H int Foo(void); // Good:头文件中声明对外接口 #endif 内部使用的函数声明,宏、枚举、结构体等定义不应放在头文件中。 本规则反过来并不一定成立。比如: 有些特别简单的头文件,如命令 ID 定义头文件,不需要有对应的.c存在。 同一套接口协议下,有多个实例,由于接口相同且稳定,所以允许出现一个.h对应多个.c文件。 有些产品中使用了 .inc 作为头文件扩展名,这不符合C语言的习惯用法。在使用 .inc 作为头文件扩展名的产品,习惯上用于标识此头文件为私有头文件。但是从产品的实际代码来看,这一条并没有被遵守,一个 .inc 文件被多个 .c 包含。本规范不提倡将私有定义单独放在头文件中,具体见建议4.1。 头文件包含是一种依赖关系,头文件应向稳定的方向包含。 一般来说,应当让不稳定的模块依赖稳定的模块,从而当不稳定的模块发生变化时,不会影响(编译)稳定的模块。 除了不稳定的模块依赖于稳定的模块外,更好的方式是每个模块都依赖于接口,这样任何一个模块的内部实现更改都不需要重新编译另外一个模块。 在这里,假设接口本身是最稳定的。 头文件循环依赖,指 a.h 包含 b.h,b.h 包含 c.h,c.h 包含 a.h, 导致任何一个头文件修改,都导致所有包含了a.h/b.h/c.h的代码全部重新编译一遍。 而如果是单向依赖,如a.h包含b.h,b.h包含c.h,而c.h不包含任何头文件,则修改a.h不会导致包含了b.h/c.h的源代码重新编译。 规则4.2 头文件必须编写#define保护,防止重复包含 定义包含保护符时,应该遵守如下规则: 假定 timer 模块的 timer.h,其目录为 #ifndef TIMER_INCLUDE_TIMER_H #define TIMER_INCLUDE_TIMER_H ... #endif 只能通过包含头文件的方式使用其他模块或文件提供的接口。 通过 extern 声明的方式使用外部函数接口、变量,容易在外部接口改变时可能导致声明和定义不一致。 同时这种隐式依赖,容易导致架构腐化。 应该改为: a.c 内容 #include "b.h" // Good: 通过包含头文件的方式使用其他.c提供的接口 void Bar(void) { int i = Foo(); ... } b.c内容 int Foo(void) { // Do something } 规则4.4 禁止在 extern "C" 中包含头文件 extern "C" 通常出现在 C,C++ 混合编程的情况下,在 extern "C" 中包含头文件,可能会导致被包含头文件的原有意图遭到破坏,比如链接规范被不正确地更改。 b.h 内容 ... #ifdef __cplusplus extern "C" { #endif #include "a.h" void B(void); #ifdef __cplusplus } #endif 按照 a.h 作者的本意,函数 Foo 是一个 C++ 自由函数,其链接规范为 "C++"。但在 b.h 中,由于 extern "C"的内部,函数 Foo 的链接规范被不正确地更改了。 extern "C"修饰。非侵入式的做法是,在 卫语句可以有效的减少 if 相关的嵌套层次。例: 原代码嵌套层数是 3: 使用 int Foo(...) { if (!received) { // Good: 使用'卫语句' return -1; } type = GetMsgType(msg); if (type == UNKNOWN) { return -1; } return DealMsg(..); } 建议5.1 对函数的错误返回码要全面处理 示例: char fileHead[128]; ReadFileHead(fileName, fileHead, sizeof(fileHead)); // Bad: 未检查返回值 DealWithFileHead(fileHead, sizeof(fileHead)); // fileHead 可能无效 注意,当函数返回值被大量的显式(void)忽略掉时,应当考虑函数返回值的设计是否合理。 如果所有调用者都不关注函数返回值时,请将函数设计成建议5.2 设计函数时,优先使用返回值而不是输出参数 使用返回值而不是输出参数,可以提高可读性,并且通常提供相同或更好的性能。 函数名为 GetXxx、FindXxx 或直接名词作函数名的函数,直接返回对应对象,可读性更好。 建议5.3 使用强类型参数,避免使用void* 尽管不同的语言对待强类型和弱类型有自己的观点,但是一般认为c/c++是强类型语言,既然我们使用的语言是强类型的,就应该保持这样的风格。 好处是尽量让编译器在编译阶段就检查出类型不匹配的问题。 使用强类型便于编译器帮我们发现错误,如下代码中注意函数 上述问题有可能很隐晦,不易轻易暴露,从而破坏性更大。 如果明确 void *,则在编译阶段就能发现上述问题。 void FooListAddNode(FooNode *foo) { ListAppend(&g_fooList, &foo->link); } void *入参。 建议5.4 模块内部函数参数的合法性检查,由调用者负责 int SomeProc(...) { int data; bool dataOK = GetData(&data); // 获取数据 if (!dataOK) { // 检查上一步结果,其实也就保证了数据合法 return -1; } DealWithData(data); // 调用数据处理函数 ... } void DealWithData(int data) { if (data < MIN || data > MAX) { // Bad: 调用者已经保证了数据合法性 return; } ... } const 指针参数,将限制函数通过该指针修改所指向对象,使代码更牢固、安全。 注意:指针参数要不要加 const 取决于函数设计,而不是看函数实体内有没有发生“修改对象”的动作。 函数的参数过多,会使得该函数易于受外部(其他部分的代码)变化的影响,从而影响维护工作。函数的参数过多同时也会增大测试的工作量。 看能否拆分函数 看能否将相关参数合在一起,定义结构体 内联函数是C99引入的一种函数优化手段。函数内联能消除函数调用的开销;并得益于内联实现跟调用点代码的合并,编译器有更大的视角,从而完成更多的代码优化。内联函数跟函数式宏比较类似,两者的分析详见建议6.1。 将函数定义成内联一般希望提升性能,但是实际并不一定能提升性能。如果函数体短小,则函数内联可以有效的缩减目标代码的大小,并提升函数执行效率。 反之,函数体比较大,内联展开会导致目标代码的膨胀,特别是当调用点很多时,膨胀得更厉害,反而会降低执行效率。 内联函数规模建议控制在 10 行以内。 规则5.3 被多个源文件调用的内联函数要放在头文件中定义 SomeInlineFunc函数的声明而没有定义。other.c包含inline.h,调用inline.h inline int SomeInlineFunc(void); other.c #include "inline.h" int OtherFunc(void) { int ret = SomeInlineFunc(); } 6 宏 函数式宏是指形如函数的宏(示例代码如下所示),其包含若干条语句来实现某一特定功能。 #define ASSERT(x) do { \ if (!(x)) { \ printk(KERN_EMERG "assertion failed %s: %d: %s\n", \ __FILE__, __LINE__, #x); \ BUG(); \ } \ } while (0) 定义函数式宏前,应考虑能否用函数替代。对于可替代场景,建议用函数替代宏。 函数式宏的缺点如下: #的用法和无处不在的括号,影响可读性。 gcc的 宏在预编译阶段展开后,在其后编译、链接和调试时都不可见;而且包含多行的宏会展开为一行。函数式宏难以调试、难以打断点,不利于定位问题。 #define MAX(a, b) (((a) < (b)) ? (b) : (a)) int Max(int a, int b) { return (a < b) ? b : a; } int TestMacro(void) { unsigned int a = 1; int b = -1; (void)printf("MACRO: max of a(%u) and b(%d) is %d\n", a, b, MAX(a, b)); (void)printf("FUNC : max of a(%u) and b(%d) is %d\n", a, b, Max(a, b)); return 0; } MAX中的b的比较提升为无符号数的比较,结果是a < b。输出结果是: 函数没有宏的上述缺点。但是,函数相比宏,最大的劣势是执行效率不高(增加函数调用的开销和编译器优化的难度)。 为此,C99标准引入了内联函数(gcc在标准之前就引入了内联函数)。 内联函数/函数执行严格的类型检查 内联函数/函数的入参求值只会进行一次 内联函数就地展开,没有函数调用的开销 内联函数比函数优化得更好 规则6.1 定义宏时,宏参数要使用完备的括号 如下所示,是一种错误的写法: #define SUM(a, b) a + b // Bad. 100 / SUM(2, 8)将扩展成 100 / (2 + 8)。 这个问题可以通过将整个表示式加上括号来解决,如下所示: 但是这种改法在下面这种场景又有问题: 1 << (2 + 8)(因为+),跟预期结果 #define SUM(a, b) (a) + (b) // Bad. SUM(2, 8) * 10。扩展后的结果为 (2 + 8) * 10不符。 综上所述,正确的写法如下: 但是要避免滥用括号。如下所示,单独的数字或标识符加括号毫无意义。 #define SOME_CONST 100 // Good: 单独的数字无需括号 #define ANOTHER_CONST (-1) // Good: 负数需要使用括号 #define THE_CONST SOME_CONST // Good: 单独的标识符无需括号 宏参数参与 '#', '##' 操作时,不要加括号 宏参数参与字符串拼接时,不要加括号 宏参数作为独立部分,在赋值(包括+=, -=等)操作的某一边时,无需括号 宏参数作为独立部分,在逗号表达式,函数或宏调用列表中,无需括号 规则6.2 包含多条语句的函数式宏的实现语句必须放在 do-while(0) 中 如下所示的宏是错误的用法(为了说明问题,下面示例代码稍不符规范): // Not Good. #define FOO(x) \ (void)printf("arg is %d\n", (x)); \ DoSomething((x)); 用大括号将 #define FOO(x) { \ (void)printf("arg is %d\n", (x)); \ DoSomething((x)); \ } 正确的写法是用 do-while(0) 把执行体括起来,如下所示: // Good. #define FOO(x) do { \ (void)printf("arg is %d\n", (x)); \ DoSomething((x)); \ } while (0) 包含 break, continue 语句的宏可以例外。使用此类宏务必特别小心。 宏中包含不完整语句时,可以例外。比如用宏封装 for 循环的条件部分。 非多条语句,或单个 if/for/while/switch 语句,可以例外。 由于宏只是文本替换,对于内部多次使用同一个宏参数的函数式宏,将带副作用的表达式作为宏参数传入会导致非预期的结果。 如下所示,宏a++传入导致SQUARE执行后跟预期不符: ((a++) * (a++)),变量7,而不是预期的 b = SQUARE(a); a++; // 结果:a = 6,只自增了一次。 建议6.2 函数式宏定义中慎用 return、goto、continue、break 等改变程序流程的语句 首先,宏封装 return 容易导致过度封装和使用。 如下代码,RETURN_IF宏忽略掉了,从而导致对主干流程的理解有偏差。 #define LOG_AND_RETURN_IF_FAIL(ret, fmt, ...) do { \ if ((ret) != OK) { \ (void)ErrLog(fmt, ##__VA_ARGS__); \ return (ret); \ } \ } while (0) #define RETURN_IF(cond, ret) do { \ if (cond) { \ return (ret); \ } \ } while (0) ret = InitModuleA(a, b, &status); LOG_AND_RETURN_IF_FAIL(ret, "Init module A failed!"); // OK. RETURN_IF(status != READY, ERR_NOT_READY); // Bad: 重要逻辑不明显 ret = InitModuleB(c); LOG_AND_RETURN_IF_FAIL(ret, "Init module B failed!"); // OK. 如果 CHECK_PTR会直接返回,而没有释放 CHECK_PTR宏命名也不好,宏名只反映了检查动作,没有指明结果。只有看了宏实现才知道指针为空时返回失败。 综上所述:不推荐宏定义中封装 return、goto、continue、break 等改变程序流程的语句; 对于返回值判断等异常处理场景可以例外。 注意: 包含 return、goto、continue、break 等改变流程语句的宏命名,务必要体现对应关键字。 建议6.3 函数式宏不超过10行(非空非注释) 函数式宏本身的一大问题是比函数更难以调试和定位,特别是宏过长,调试和定位的难度更大。 而且宏扩展会导致目标代码的膨胀。建议函数式宏不要超过10行。 7 变量 在C语言编码中,除了函数,最重要的就是变量。 变量在使用时,应始终遵循“职责单一”原则。 按作用域区分,变量可分为全局变量和局部变量。 全局变量 尽量不用或少用全局变量。 在程序设计中,全局变量是在所有作用域都可访问的变量。通常,使用不必要的全局变量被认为是坏习惯。 使用全局变量的缺点: 破坏函数的独立性和可移植性,使函数对全局变量产生依赖,存在耦合; 在并发编程环境中,使用全局变量会破坏函数的可重入性,需要增加额外的同步保护处理才能确保数据安全。 如不可避免,对全局变量的读写应集中封装。 规则7.1 模块间,禁止使用全局变量作接口 全局变量是模块内部的具体实现,不推荐但允许跨文件使用,但禁止作为模块接口暴露出去。 对全局变量的使用应该尽量集中,如果本模块的数据需要对外部模块开放,应提供对应函数接口。 局部变量 规则7.2 严禁使用未经初始化的变量 这里的变量,指的是局部动态变量,并且还包括内存堆上申请的内存块。 因为他们的初始值都是不可预料的,所以禁止未经有效初始化就直接读取其值。 如果有不同分支,要确保所有分支都得到初始化后才能使用: void Foo(...) { int data; if (...) { data = 100; } Bar(data); // Bad: 部分分支该值未初始化 ... } Warning 530: Symbol 'data' (line ...) not initialized Warning 644: Variable 'data' (line ...) may not have been initialized 如果没有确定的初始值,而仍然进行初始化,不仅不简洁,反而不安全,可能会引入更难发现的问题。 对于后续有条件赋值的变量,可以在定义时初始化成默认值 char *buf = NULL; // Good: 这里用 NULL 代表默认值 if (condition) { buf = malloc(MEM_SIZE); } ... if (buf != NULL) { // 判断是否申请过内存 free(buf); } 无效初始化,隐藏更大问题的反例: void Foo(...) { int data = 0; // Bad: 习惯性的进行初始化 UseData(data); // 使用数据,本应该写在获取数据后面 data = GetData(...); // 获取数据 ... } 因此,应该写简洁的代码,对变量或内存块进行正确、必要的初始化。 例外: 遵从“安全规范”要求,指针变量、表示资源描述符的变量、BOOL变量不作要求。 所谓魔鬼数字即看不懂、难以理解的数字。 魔鬼数字并非一个非黑即白的概念,看不懂也有程度,需要结合代码上下文和业务相关知识来判断 type = 12;就看不懂,但 status = 0;并不能表达是什么状态。 解决途径: 对于单点使用的数字,可以增加注释说明 对于多处使用的数字,必须定义宏或const 变量,并通过符号命名自注释。 禁止出现下列情况: 没有通过符号来解释数字含义,如 #define XX_TIMER_INTERVAL_300MS 300 8 编程实践 表达式 建议8.1 表达式的比较,应当遵循左侧倾向于变化、右侧倾向于不变的原则 当变量与常量比较时,如果常量放左边,如 if (MAX > v)更是难于理解。 应当按人的正常阅读、表达习惯,将常量放右边。写成如下方式: 也有特殊情况,如:if (v = MAX)会有编译告警,其他静态检查工具也会报错。让工具去解决笔误问题,代码要符合可读性第一。 含有变量自增或自减运算的表达式中,如果再引用该变量,其结果在C标准中未明确定义。各个编译器或者同一个编译器不同版本实现可能会不一致。 为了更好的可移植性,不应该对标准未定义的运算次序做任何假设。 示例: x = b[i] + i++; // Bad: b[i]运算跟 i++,先后顺序并不明确。 函数参数: Func(i++, i); // Bad: 传递第2个参数时,不确定自增运算有没有发生 建议8.2 用括号明确表达式的操作顺序,避免过分依赖默认优先级 当表达式包含不常用,优先级易混淆的操作符时,推荐使用括号,比如位操作符: c = (a & 0xFF) + b; /* 涉及位操作符,需要括号 */ 规则8.2 switch语句要有default分支 特例: 如果switch条件变量是枚举类型,并且 case 分支覆盖了所有取值,则加上default分支处理有些多余。 现代编译器都具备检查是否在switch语句中遗漏了某些枚举值的case分支的能力,会有相应的warning提示。 enum Color { RED, BLUE }; // 因为switch条件变量是枚举值,这里可以不用加default处理分支 switch (color) { case RED: DoRedThing(); break; case BLUE: DoBlueThing(); ... break; } goto语句会破坏程序的结构性,所以除非确实需要,最好不使用goto语句。使用时,也只允许跳转到本函数goto语句之后的语句。 示例: // Good: 使用 goto 实现单点返回 int SomeInitFunc(void) { void *p1; void *p2 = NULL; void *p3 = NULL; p1 = malloc(MEM_LEN); if (p1 == NULL) { goto EXIT; } p2 = malloc(MEM_LEN); if (p2 == NULL) { goto EXIT; } p3 = malloc(MEM_LEN); if (p3 == NULL) { goto EXIT; } DoSomething(p1, p2, p3); return 0; // OK. EXIT: if (p3 != NULL) { free(p3); } if (p2 != NULL) { free(p2); } if (p1 != NULL) { free(p1); } return -1; // Failed! } 建议8.4 尽量减少没有必要的数据类型默认转换与强制转换 如下赋值,多数编译器不产生告警,但值的含义还是稍有变化。 char ch; unsigned short int exam; ch = -1; exam = ch; // Bad: 编译器不产生告警,此时exam为0xFFFF。

    时间:2021-02-24 关键词: 编程规范 C语言

  • C语言代码中extern

    编排 | strongerHuang 公众号 | strongerHuang 在你的代码中,不知能否看到类似下面的代 这好像没有什么问题,你应该还会想:“嗯⋯是啊,我们的代码都是这样写的,从来没有因此碰到过什么麻烦啊~”。 你说的没错,如果你的头文件从来没有被任何C++程序引用过的话。 这与C++有什么关系呢? 看看__cplusplus(注意前面是两个下划线) 的名字你就应该知道它与C++有很大关系。__cplusplus是一个C++规范规定的预定义宏。你可以信任的是:所有的现代C++编译器都预先定义了它;而所有C语言编译器则不会。另外,按照规范__cplusplus的值应该等于1 9 9 7 1 1 L ,然而不是所有的编译器都照此实现,比如g++编译器就将它的值定义为1。 所以,如果上述代码被C语言程序引用的话,它的内容就等价于下列代码。 在这种情况下,既然extern "C" { }经过预处理之后根本就不存在,那么,它和#include指令之间的关系问题自然也就是无中生有。 1、extern "C"的前世今生 在C++编译器里,有一位暗黑破坏神,专门从事一份称作“名字粉碎”(name mangling)的工作。当把一个C++的源文件投入编译的时候,它就开始工作,把每一个它在源文件里看到的外部可见的名字粉碎的面目全非,然后存储到二进制目标文件的符号表里。 之所以在C++的世界里存在这样一个怪物,是因为C++允许对一个名字给予不同的定义,只要在语义上没有二义性就好。比如,你可以让两个函数是同名的,只要它们的参数列表不同即可,这就是函数重载(function overloading);甚至,你可以让两个函数的原型声明是完全相同的,只要它们所处的名字空间(namespace)不一样即可。事实上,当处于不同的名字空间时,所有的名字都是可以重复的,无论是函数名,变量名,还是类型名。 另外,C++程序的构造方式仍然继承了C语言的传统:编译器把每一个通过命令行指定的源代码文件看做一个独立的编译单元,生成目标文件;然后,链接器通过查找这些目标文件的符号表将它们链接在一起生成可执行程序。 编译和链接是两个阶段的事情;事实上,编译器和链接器是两个完全独立的工具。编译器可以通过语义分析知道那些同名的符号之间的差别;而链接器却只能通过目标文件符号表中保存的名字来识别对象。 所以,编译器进行名字粉碎的目的是为了让链接器在工作的时候不陷入困惑,将所有名字重新编码,生成全局唯一,不重复的新名字,让链接器能够准确识别每个名字所对应的对象。 但 C语言却是一门单一名字空间的语言,也不允许函数重载,也就是说,在一个编译和链接的范围之内,C语言不允许存在同名对象。比如,在一个编译单元内部,不允许存在同名的函数,无论这个函数是否用static修饰;在一个可执行程序对应的所有目标文件里,不允许存在同名对象,无论它代表一个全局变量,还是一个函数。所以,C语言编译器不需要对任何名字进行复杂的处理(或者仅仅对名字进行简单一致的修饰(decoration),比如在名字前面统一的加上单下划线_)。 C++的缔造者Bjarne Stroustrup在最初就把——能够兼容C,能够复用大量已经存在的C库——列为C++语言的重要目标。但两种语言的编译器对待名字的处理方式是不一致的,这就给链接过程带来了麻烦。 例如,现有一个名为my_handle.h的头文件,内容如下: 然后使用C语言编译器编译my_handle.c,生成目标文件my_handle.o。由于C语言编译器不对名字进行粉碎,所以在my_handle.o的符号表里,这三个函数的名字和源代码文件中的声明是一致的。 随后,我们想让一个C++程序调用这些函数,所以,它也包含了头文件my_handle.h。假设这个C++源代码文件的名字叫my_handle_client.cpp,其内容如下: 其中,粗体的部分就是那三个函数的名字被粉碎后的样子。 然后,为了让程序可以工作,你必须将my_handle.o和my_handle_client.o放在一起链接。由于在两个目标文件对于同一对象的命名不一样,链接器将报告相关的“符号未定义”错误。 为了解决这一问题,C++引入了链接规范(linkage specification)的概念,表示法为extern"language string",C++编译器普遍支持的"language string"有"C"和"C++",分别对应C语言和C++语言。 链接规范的作用是告诉C++编译:对于所有使用了链接规范进行修饰的声明或定义,应该按照指定语言的方式来处理,比如名字,调用习惯(calling convention)等等。 2、链接规范的用法有两种 在我们清楚了 extern "C" 的来历和用途之后,回到我们本来的话题上,为什么不能把#include 指令放置在 extern "C" { ... } 里面? 我们先来看一个例子,现有a.h,b.h,c.h以及foo.cpp,其中foo.cpp包含c.h,c.h包含b.h,b.h包含a.h,如下: 正如你看到的,当你把#include指令放置在extern "C" { }里的时候,则会造成extern "C" { } 的嵌套。这种嵌套是被C++规范允许的。当嵌套发生时,以最内层的嵌套为准。比如在下面代码中,函数foo会使用C++的链接规范,而函数bar则会使用C的链接规范。 这样的结果肯定不会引起编译问题的结果——即便是使用MSVC。 把 #include 指令放置在extern "C" { }里面的另外一个重大风险是,你可能会无意中改变一个函数声明的链接规范。比如:有两个头文件a.h,b.h,其中b.h包含a.h,如下: Q: 难道任何#include指令都不能放在extern "C"里面吗? A: 正像这个世界的大多数规则一样,总会存在特殊情况。 有时候,你可能利用头文件机制“巧妙”的解决一些问题。比如,#pragma pack的问题。这些头文件和常规的头文件作用是不一样的,它们里面不会放置C的函数声明或者变量定义,链接规范不会对它们的内容产生影响。这种情况下,你可以不必遵守这些规则。 更加一般的原则是,在你明白了这所有的原理之后,只要你明白自己在干什么,那就去做吧。 Q: 你只说了不应该放入extern "C"声明怎么办? A: 如果你可以判断,这个头文件永远不可能让C++代码来使用,那么就不要管它。 但现实是,大多数情况下,你无法准确的推测未来。你在现在就加上这个extern "C",这花不了你多少成本,但如果你现在没有加,等到将来这个头文件无意中被别人的C++程序包含的时候,别人很可能需要更高的成本来定位错误和修复问题。 Q: 如果我的C+ +程序想包含一个C头文件a . h,它的内容包含了C的函数/变量声明,但它们却没有使用extern "C"的写法如下,这正确吗? 这应该可以工作,但在每个头文件中都写这么一大串,不仅有碍观瞻,还会造成一旦策略进行修改,就会到处修改的状况。违反了DRY(Don't Repeat Yourself)原则,你总要为之付出额外的代价。解决它的一个简单方案是,定义一个特定的头文件——比如clinkage.h,在其中增加这样的定义: 以下举例中c的函数声明和定义分别在cfun.h 和 cfun.c 中,函数打印字符串 “this is c fun call”,c++函数声明和定义分别在cppfun.h 和 cppfun.cpp中,函数打印字符串 "this is cpp fun call", 编译环境vc2010. 5、C++ 调用 C 的方法 c++ 调用 c 的方法,关键是要让c的函数按照c的方式编译,而不是c++的方式。 (1) cfun.h如下: #ifndef _C_FUN_H_#define _C_FUN_H_ void cfun(); #endif cppfun.cpp 如下: //#include "cfun.h" 不需要包含cfun.h#include "cppfun.h"#include using namespace std;extern "C" void cfun(); //声明为 extern void cfun(); 错误 void cppfun(){ cout<<"this is cpp fun call"<<endl;} int main(){ cfun(); return 0;} (2)cfun.h同上 cppfun.cpp 如下: extern "C"{ #include "cfun.h"//注意include语句一定要单独占一行;}#include "cppfun.h"#include using namespace std; void cppfun(){ cout<<"this is cpp fun call"<<endl;} int main(){ cfun(); return 0;} (3)cfun.h如下: #ifndef _C_FUN_H_#define _C_FUN_H_ #ifdef __cplusplusextern "C"{#endif void cfun(); #ifdef __cplusplus}#endif #endif cppfun.cpp如下: #include "cfun.h"#include "cppfun.h"#include using namespace std; void cppfun(){ cout<<"this is cpp fun call"<<endl;} int main(){ cfun(); return 0;} 6、C调用 C++ 的方法 c调用c++,关键是C++ 提供一个符合 C 调用惯例的函数。 在vs2010上测试时,没有声明什么extern等,只在在cfun.c中包含cppfun.h,然后调用cppfun()也可以编译运行,在gcc下就编译出错,按照c++/c的标准这种做法应该是错误的。以下方法两种编译器都可以运行 cppfun.h如下: #ifndef _CPP_FUN_H_#define _CPP_FUN_H_ extern "C" void cppfun(); #endif cfun.c如下: //#include "cppfun.h" //不要包含头文件,否则编译出错#include "cfun.h"#include void cfun(){ printf("this is c fun call\n");} extern void cppfun(); int main(){#ifdef __cplusplus cfun();#endif cppfun(); return 0;} 本文素材来源网络,版权归原作者所有。如涉及作品版权问题,请与我联系删除。 免责声明:本文内容由21ic获得授权后发布,版权归原作者所有,本平台仅提供信息存储服务。文章仅代表作者个人观点,不代表本平台立场,如有问题,请联系我们,谢谢!

    时间:2021-02-24 关键词: 代码 extern C语言

  • 嵌入式工程师超长经验分享:从单片机coder到嵌入式programer的简单历程

    C语言到底该怎么学,单片机coder怎么才能顺利转型成为嵌入式programer?21ic论坛有一“镇站之宝”的超长经验分享贴,特此分享给所有热爱coding的你。 之前和大家谈了一点UML在嵌入式开发中的使用,以及链表、哈希表等数据结构在实现对象之间的交互机制(设计模式)的一点简单实例。有很多朋友表示很感兴趣,21ic高手云集,有点班门弄斧的感觉,所以还望尽情拍砖。之前的帖子很乱,除了因为太随意没有准备外,更主要是因为本人也处于半瓶子阶段,所谈问题题目又太大。对此我只能凭借拙见,谈点个人的理解,由于本人是这方面的新手,凭借一己之热情,大放厥词,还请各位斧正。其实UML就是一个工具,提供了用例图、顺序图、活动图、类图、状态机图、部署图、包图等工具,辅助工程师完成:分析->设计->实施->测试的整个过程。每个过程都有细分,例如分析阶段:首先是需求分析,然后有系统分析,再次有对象结构分析等。需求分析阶段会使用到用例图、顺序图、状态机图、顺序图等,需求分析阶段的最重要的要素是软件的外部功能视图和使用场景等。其中前者使用用例图表述,它也提供了沟通用户和开发者的桥梁;后者用顺序图或状态机图等表述,提供了系统每个功能实现的可能路径。其他过程和需求分析阶段类似,这里篇幅所限就不再一一提及。UML就是这样同我们的设计过程关联起来的。将面向对象的方法用于MCU是有必要的,也是可能的,当然也是很有效的。这样的努力最起码可以拉近mcu开发者同其他领域的C开发者之间的距离,弥补那道似乎难以逾越的鸿沟,比如看到linux内核代码时,你会发现原来如此亲切。当然,随着对面向对象方法的深入理解,你会发现C++也不再那么让你不知道如何使用,或者把C++用得像面向过程的语言一样。当然本人C++菜鸟,还望高手指教。然而面向对象的方法也非一蹴而就,一朝搞定,它是一个循序渐进的过程,特别是应用与mcu这样的平台,好多东西是靠摸索出来的。如何开始,先从何处下手是个问题。21ic同仁liufb提议:“正如《重构与模式》所说:如果想成为一名更优秀的软件设计师,了解优秀软件设计的演变过程比学习优秀设计本身更有价值,因为设计的演变过程中蕴藏着大智慧。”我决定发掘一下我近十年以来的阶段性C代码,试图去发现一点什么,这个我之前还从未尝试过,能找到的一起的代码也寥寥无几。不过我觉得值得一试,那就从此开始吧。 努力发掘,搜索N年前的邮箱,居然找到了当时在一款AT89X52单片机上的处女作。就从它开始入手了。 时代背景:2006年,郑州某小公司,之前的工作是修手机,然后是在某气体传感器公司焊接维护生产设备,再后来在这家小公司画电路板,然而软件才是我的最爱。好不容易boss开恩,让我参与到写代码的行列。之前的进度让在郑州这种蜗牛般的工作节奏的大氛围里面的boss也觉得忍无可忍,于是我加入了。 代码太长,截取一部分吧。里面只有我写的一个子函数,大部分是同事写的。  由于做开始工作的同事不太会用多文件,所以这个项目的代码只有一个文件,连头文件都没有,整个文件有2600行代码。以下我将列举它的三大部分:           1.全局变量部分。           2.部分子函数。           3.main函数。 最后我将会用现在的眼光,结合大师(Grady Booch)的经典,分析一下这部分代码。 全局变量部分: bit FUN,Shift_on,Add_on,Sub_on,fun_flag;bit dspflagz1,dspflagz2,dspflagz3;unsigned char z1,z2,td3,working,DSP_m,DSP_n;unsigned char l1,l2,r,m;bitflagv,flagy,flags,flag0,flagx,beepflag1,beepflag2,flagt,flagw;bit working_on,function_on, AINTSYR_on,AINTSYW_on,BINTSYR_on,BINTSYW_on ;bitprogram_on,program_woking,up_flag,down_flag,up_on,down_on;unsigned char AINTSY_state, BINTSY_state, function_state;unsigned char tx1,tx2,tx3,tw,TX,t;unsigned char display_state ,x1,x2,w1,w2;unsigned char program_state,program_working;unsigned char clk_number;unsigned char code DS[]={0,33,63,86,100,86,63,33};unsigned chards_curtime[6]={0x05,0x03,0x02,0x01,0x07,0x06};unsigned char clk_data[6]={06,1,1,0,0,1};unsigned char set_time[6];sbit switch_work= 0xB0;sbit switch_function=0xB1;sbit switch_program=0xB2;sbit switch_up=0x90;sbit switch_down=0x91;sbit switch_AINTSYR=0x92;sbit switch_AINTSYW=0x93;sbit switch_BINTSYR=0x94;sbit switch_BINTSYW=0x95;sbit RS=0xA2;sbit RW=0xA1;sbit E=0xA0;sbit CS2=0xA3;sbit CS1=0xA4;sbit DACS1=0xA7;sbit DACS2=0xA6;sbit DACS3=0xA5;sbit ds_sclk=0xB3 ; /*初始化变量*/sbit ds_io=0xB4;sbit ds_rst=0xB5; 初评:除了最后是管脚定义外,前边都是全局的标志位或全局变量。 这个 void text(void)可是我的处女作啊 //////////////////////////////////////////////////////////////////////////////////////////////////////void text(void){ bit Flag_add; /*加一标志*/ bit Flag_sub; /*减一标志*/ unsigned char max_value; /*各时间单位的最大值*/ unsigned char min_value; /*各时间单位的最小值*/ /*if(FUN==1) { */ /*定义标志位*/ if(switch_work==0) /*移位键*/ { if(Shift_on==0) { Shift_on=1; buzzer(); clk_number++; if(clk_number>6)clk_number=1; } } else Shift_on=0; if(switch_up==0) /*加一键*/ { if(Add_on==0) { Add_on=1; buzzer(); Flag_add=1; } } else Add_on=0; if(switch_down==0) /*减一键*/ { if(Sub_on==0) { Sub_on=1; buzzer(); Flag_sub=1; } } else Sub_on=0; switch(clk_number) { case 1: max_value=99;min_value=0;break; case 2: max_value=12;min_value=1;break; case 3: if(clk_data[1]==1|| clk_data[1]==3|| clk_data[1]==5|| clk_data[1]==7|| clk_data[1]==8|| clk_data[1]==10|| clk_data[1]==12) max_value=31; /*1,3,5,7,8,10,12*/ else if( clk_data[1]==4|| clk_data[1]==6|| clk_data[1]==9|| clk_data[1]==11) max_value=30; /*4,6,9,11*/ else if((clk_data[0]%4==0)||clk_data[0]==0) max_value=29; /*闰年*/ else max_value=28; min_value=1; break; case 4: max_value=23;min_value=0;break; case 5: max_value=59;min_value=0;break; case 6: max_value=7;min_value=1;break; } if(Flag_add==1) { clk_data[clk_number-1]++; Flag_add=0; if(clk_data[clk_number-1]>max_value) clk_data[clk_number-1]=min_value; } else if(Flag_sub==1) { clk_data[clk_number-1]--; Flag_sub=0; if(clk_data[clk_number-1]

    时间:2021-02-23 关键词: 嵌入式 单片机 C语言

  • 适合具备C语言基础的C++教程(五)

    前言 在上述教程中,我们已经完成了 C++相对于 C语言来说独特的语法部分,在接下来的教程中,我们将叙述 C++中面向对象的语法特性。我们在学习面向对象的这种编程方法的时候,常常会听到这三个词,封装、继承、派生,这也是面向对象编程的三大特性,在本节我们将依次阐述封装、继承、派生的具体用法,在这里,我们先叙述的是封装这个属性的的相关内容。下图是关于 封装 这个特性所包含的一些内容。 封装 下图就是封装所具备的相关特性: image-20210209204824118 那么上图所示的抽象出数据成员以及成员函数具体的含义是什么呢,正如前面教程所述,在前面的教程里,我们选用一个 Person类来作为例子进行讲解,其中这个类里我们有 name以及age,这个也就是我们抽象出来的数据,那抽象出来的成员函数也就是前面教程讲到的setName()和setAge()函数,在设计这个类的时候,会把这个类的一些成员设置为私有的或者公有的,这也就是访问控制。具体的代码如下所示: /* 为了代码简便,省略相关构造函数以及析构函数,为的是展示封装的特性*/class Person {private:    char *name;    int age;public:    Person()    {        cout 

    时间:2021-02-20 关键词: 嵌入式 C C语言

  • 适合具备C语言基础的C++教程(四)

    前言 在上一则教程中,我们讲述了重载运算符中前 ++和后++的重载函数的实现,阐述了在 C++中可以将运算符进行重载的方法,这种方法大大地便利了程序员编写代码,在接下来地叙述中,我们将着重讲述运算符重载时地一些更为细致地内容,其中就包括当重载地运算符返回值为引用和非引用两种状态时,代码执行效率地高低以及采用在类内实现运算符重载函数的方法。 返回值为引用和非引用的区别 在上述所示的类当中,增加一部分代码,加入析构函数以及拷贝构造函数,代码如下所示: class Point{private:    int x;    int y;public:    Point()     {        cout

    时间:2021-02-20 关键词: 嵌入式 C C语言

  • 适合具备C语言基础的C++教程(三)

    前言 在上一则教程中,着重地阐述了构造函数以及析构函数的相关概念,这也是C++中非常重要的两个概念之一。在今天的教程中,笔者将继续叙述 C++相对于 C语言来说不同的点,将详细叙述命名空间,静态成员,友元函数以及运算符重载这几个知识点。 C++ 命名空间 命名空间的存在是为了区分不同库的相同的函数名,用一个简单的例子来说明这个问题就是在 windows的文件系统中,不同文件夹下可以有相同名字的文件,相同文件夹下因为这相同文件处在不同的范围内,用 C++ 说白了也就是处在不同的命名空间中。文件系统的一个结构图: 文件系统框图 定义命名空间 命名空间的定义使用的是关键字 namespace,后跟命名空间的名称,如下所示: namespace namespace_name{    // 代码声明} 为了调用带有命名空间的函数或者变量,需要在前面加上命名空间的名称,如下所示: name::code   // code 可以是变量或者是函数 例子 下面通过一个例子来说明命名空间的概念,首先,我们具有两个类,一个是 Dog ,一个是 Person,而这个时候,有两个函数具有相同的名字,都要输出不同的信息,这个时候,就有必要使用到命名空间的概念。首先,我们在 dog.h 里面定义一个 dog 类,代码如下所示: #ifndef __DOG_H__#define __DOG_H__namespace C{class Dog{private:    char *name;    int age;public:    void setName(char *name);    int setAge(int age);    void printInfo(void);};void printVersion(void);}#endif 然后,紧接着来看 dog.cpp 里面的内容。代码如下所示: #include "dog.h"namespace C{    void Dog::setName(char *name)    {        this->name = name;    }    int Dog::setAge(int age)    {        if (age  20)        {            this->age = 0;            return -1;        }        this->age = age;        return 0;    }    void Dog::printInfo(void)    {        printf("name = %s, age = %d\n",name,age);    }    void printersion(void)    {        printf("Dog v1");    }} OK ,看完了 Dog 的代码,我们紧接着来看 Person 的代码,代码如下所示: #ifndef __PERSON_H__#define __PERSON_H__namespace A{class Person{private:    char *name;    int age;    char *work;public:    void setName(char *name);    int setAge(int age);    void printInfo(void);    };    void printfVersion(void);}#endif 紧接着就是 Person.cpp 的代码,具体的代码如下所示: namespace A {void Person::setName(char *name){    this->name = name;}int Person::setAge(int age){    if (age  150)    {        this->age = 0;        return -1;    }    this->age = age;    return 0;}void Person::printInfo(void){    printf("name = %s, age = %d, work = %s\n", name, age, work); }void printVersion(void){    printf("Person v1\n");}} 上述就是 所定义的两个类,我们紧接着来看 main.cpp 的代码: int main(int argc, char **argv){    A::Person per;    per.setName("zhangsan");    per.setAge(16);    per.printInfo();    C::Dog dog;    dog.setName("wangcai");    dog.setAge(1);    dog.printInfo();    A::printVersion();    C::printVersion();    return 0} 在最后的倒数第二行和倒数第三行,我们可以看到如果这个时候,没有命名空间的存在,那么就完全不能够分辨 printVersion这个函数,加上了命名空间之后,就能够分辨出来了。 静态成员 在上述代码的基础上,我们在主函数定义了如何几个变量,代码如下所示: #include int main(int argc, char **argv){    Person per1;    Person per2;    Person per3;    Person per4;    Person *per5 = new Person[10];} 那我们要如何知道我们定义几个 Person 对象呢,可以这样去做,我们创建一个 cnt变量,然后在每个构造函数执行的过程中让 cnt加一,代码如下所示: #include #include #include class Person{private:    int cnt;    char *name;    int age;    char *work;public:    Person()    {        name = NULL;        work = NULL;        cnt++;    }    Person(char *name)    {        this->name = new char[strlen(name) + 1];        strcpy(this->name, name);        this->work = NULL;        cnt++;    }    Person(char *name, int age, char *work = "none")    {        this->name = new char[strlen(name) + 1];        strcpy(this->name, name);        this->work = new char[strlen(work) + 1];        strcpy(this->work, work);        cnt++;    }    ~Person()    {        if (this->name)        {            cout 

    时间:2021-02-20 关键词: 嵌入式 C C语言

  • 适合具备C语言基础的C++教程(二)

    前言 在上一则教程中,通过与 C 语言相比较引出了 C++ 的相关特性,其中就包括函数重载,引用,this 指针,以及在脱离 IDE 编写 C++ 程序时,所要用到的 Makefile的相关语法。本节所要叙述的是 C++的另外两个重要的特性,也就是构造函数和析构函数的相关内容,这两部分内容也是有别于 c语言而存在的,也是 c++的一个重要特性。 构造函数 类的构造函数是类的一种特殊的成员函数,它会在每次创建新的对象的时候执行,构造函数的名称和类的名称是完全相同的,并不会返回任何的类型,也不会返回 void。构造函数可以用于为某些成员变量设置初始值。 比方说,我们现在有如下所示的一段代码: #include using namespace std;class Person{private:    char *name;    int age;    char *work;public:    Person() {cout 

    时间:2021-02-20 关键词: 嵌入式 C C语言

  • C语言编程:.H文件与.C文件的关系!

    C语言编程:.H文件与.C文件的关系!

    一个.C文件对应一个.H文件是一种良好的编程习惯,那么.C和.H文件之间的关系是怎么样的呢? 一、.H文件与.C文件的关系: 迄今为止,写过的程序都是一些很简单的程序,从来没有想到要自己写.H文件,也不知道.H文件到底什么用,与.C文件什么关系。只是最近写键盘程序,参考别人的程序时,发现别人写的严格的程序都带有一个“KEY.H”,里面定义了.C文件里用到的自己写的函数,如Keyhit()、Keyscan()等。 经过查找资料得知,.H文件就是头文件,估计就是Head的意思吧,这是规范程序结构化设计的需要,既可以实现大型程序的模块化,又可以实现根各模块的连接调试。 1、.H文件介绍: 在单片机C程序设计中,项目一般按功能模块化进行结构化设计。将一个项目划分为多个功能,每个功能的相关程序放在一个C程序文档中,称之为一个模块,对应的文件名即为模块名。一个模块通常由两个文档组成,一个为头文件*.h,对模块中的数据结构和函数原型进行描述;另一个则为C文件*.c ,对数据实例或对象定义,以及函数算法具体实现。 2、.H文件的作用 作为项目设计,除了对项目总体功能进行详细描述外,就是对每个模块进行详细定义,也就是给出所有模块的头文件。通常H头文件要定义模块中各函数的功能,以及输入和输出参数的要求。模块的具体实现,由项目组成根据H文件进行设计、编程、调试完成。为了保密和安全,模块实现后以可连接文件OBJ、或库文件LIB的方式提供给项目其他成员使用。由于不用提供源程序文档,一方面可以公开发行,保证开发人员的所有权;另一方面可以防止别人有意或无意修改产生非一致性,造成版本混乱。所以H头文件是项目的详细设计和团队工作划分的依据,也是对模块进行测试的功能说明。要引用模块内的数据或算法,只要用包含include指定模块H头文件即可。 3、.H文件的基本组成 /*如下为键盘驱动的头文档*/#ifndef _KEY_H_ //防重复引用,如果没有定义过_KEY_H_,则编译下句#define _KEY_H_ //此符号唯一, 表示只要引用过一次,即#i nclude,则定义符号_KEY_H_/////////////////////////////////////////////////////////////////char keyhit( void ); //击键否unsigned char Keyscan( void ); //取键值////////////////////////////////////////////////////////////////#endif 二、尽量使用宏定义#define 开始看别人的程序时,发现程序开头,在文件包含后面有很多#define语句,当时就想,搞这么多标示符替换来替换去的,麻不麻烦啊,完全没有理解这种写法的好处。原来,用一个标示符表示常数,有利于以后的修改和维护,修改时只要在程序开头改一下,程序中所有用到的地方就全部修改,节省时间。 #define KEYNUM 65//按键数量,用于Keycode[KEYNUM]#define LINENUM 8//键盘行数#define ROWNUM 8//键盘列数 注意的地方: 1、宏名一般用大写 2、宏定义不是C语句,结尾不加分号 三、不要乱定义变量类型 以前写程序,当需要一个新的变量时,不管函数内还是函数外的,直接在程序开头定义,虽然不是原则上的错误,但是很不可取的作法。 下面说一下,C语言中变量类型的有关概念: 从变量的作用范围来分,分为局部变量和全局变量: 1、全局变量:是在函数外定义的变量,像我以前定义在程序开头的变量都是全局变量,这里我就犯了一个大忌,使用了过多的全局变量。 带来的问题有两个:一是,全局变量在程序全部执行过程中都占用资源;二是,全局变量过多使程序的通用性变差,因为全局变量是模块间耦合的原因之一。 2、局部变量:在函数内部定义的变量,只在函数内部有效。 从变量的变量值存在的时间分为两种: 1、静态存储变量:程序运行期间分配固定的存储空间。 2、动态存储变量:程序运行期间根据需要动态地分配存储空间。 具体又包括四种存储方式:auto static register extern 1、局部变量,不加说明默认为auto型,即动态存储,如果不赋初值,将是一个不确定的值。而将局部变量定义为static型的话,则它的值在函数内是不变的,且初值默认为0。 static unsigned char sts;//按键状态变量static unsigned char Nowkeycode;//此时的键码static unsigned char Prekeycode;//上一次的键码static unsigned char Keydowntime;//矩形键盘按下去抖时间变量static unsigned char Keyuptime;//矩形键盘释放去抖时间变量static unsigned char Onoffdowntime;//关机键按下去抖时间变量static unsigned char Onoffuptime;//关机键释放去抖时间变量static unsigned char onoff_10ms; //判断关机键中断次数变量,累计150次大约为3S,因为前后进了两个10ms中断 2、全局变量,编译时分配为静态存储区,可以被本文件中的各个函数引用。如果是多个文件的话,如果在一个文件中引用另外文件中的变量,在此文件中要用extern说明。不过如果一个全局变量定义为static的话,就只能在此一个文件中使用。 四、特殊关键字const volatile的使用 1、const const用于声明一个只读的变量 const unsigned char a=1;//定义a=1,编译器不允许修改a的值作用:保护不希望被修改的参数const unsigned char Key_code[KEYNUM]={0x01,0x02,0x03,0x04,0x05,0x06,0x07,0x08,0x09,0x0A,0x0B,0x0C,0x0D,0x0E,0x0F,0x10,0x11,0x12,0x13,0x14,0x15,0x16,0x17,0x18,0x19,0x1A,0x1B,0x1C,0x1D,0x1E,0x1F,0x20,0x21,0x22,0x23,0x24,0x25,0x26,0x27,0x28,0x29,0x2A,0x2B,0x2C,0x2D,0x2E,0x2F,0x30,0x31,0x32,0x33,0x34,0x35,0x36,0x37,0x38,0x39,0x3A,0x3B,0x3C,0x3D,0x3E,0x3F,0x40,0x41};//键码const unsigned char Line_out[LINENUM]={0xFE,0xFD,0xFB,0xf7,0xEF,0xDF,0xBF,0x7F};//行输出编码const unsigned char Row_in[ROWNUM]={0xFE,0xFD,0xFB,0xf7,0xEF,0xDF,0xBF,0x7F};//列输入编码 2、volatile 一个定义为volatile的变量是说这变量可能会被意想不到地改变,这样,编译器就不会去假设这个变量的值了。精确地说就是,优化器在用到这个变量时必须每次都小心地重新读取这个变量的值,而不是使用保存在寄存器里的备份。 static int i=0;int main(void){...while (1){if (i)dosomething();}}/* Interrupt service routine. */void ISR_2(void){i=1;} 程序的本意是希望ISR_2中断产生时,在main当中调用dosomething函数,但是,由于编译器判断在main函数里面没有修改过i,因此可能只执行一次对从i到某寄存器的读操作,然后每次if判断都只使用这个寄存器里面的“i副本”,导致dosomething永远也不会被调用。如果将将变量加上volatile修饰,则编译器保证对此变量的读写操作都不会被优化(肯定执行)。 一般说来,volatile用在如下的几个地方: 1、中断服务程序中修改的供其它程序检测的变量需要加volatile; 2、多任务环境下各任务间共享的标志应该加volatile; 3、存储器映射的硬件寄存器通常也要加volatile说明,因为每次对它的读写都可能由不同意义。 END 版权归原作者所有,如有侵权,请联系删除。 ▍ 推荐阅读 树莓派Pico:仅4美元的MCU 嵌入式Linux开发板裸机程序烧写方法总结 国产16位MCU的痛点,可以用这款物美价廉产品 →点关注,不迷路← 免责声明:本文内容由21ic获得授权后发布,版权归原作者所有,本平台仅提供信息存储服务。文章仅代表作者个人观点,不代表本平台立场,如有问题,请联系我们,谢谢!

    时间:2021-02-20 关键词: 编程 C语言

  • 70道C语言与C++常见问答题

    2 简述#ifdef、#else、#endif和#ifndef的作用 利用#ifdef、#endif将某程序功能模块包括进去,以向特定用户提供该功能。在不需要时用户可轻易将其屏蔽。 3 写出int 、bool、 float 、指针变量与 “零值”比较的if 语句 5 sizeof 和strlen 的区别 sizeof是一个操作符,strlen是库函数。 sizeof的参数可以是数据的类型,也可以是变量,而strlen只能以结尾为‘\0’的字符串作参数。 编译器在编译时就计算出了sizeof的结果,而strlen函数必须在运行时才能计算出来。并且sizeof计算的是数据类型占内存的大小,而strlen计算的是字符串实际的长度。 数组做sizeof的参数不退化,传递给strlen就退化为指针了 8 写一个 “标准”宏MIN #define min(a,b)((a)<=(b)?(a):(b)) 11 一个参数可以既是const又是volatile吗 可以,用const和volatile同时修饰变量,表示这个变量在程序内部是只读的,不能改变的,只在程序外部条件变化下改变,并且编译器不会优化这个变量。每次使用这个变量时,都要小心地去内存读取这个变量的值,而不是去寄存器读取它的备份。 注意:在此一定要注意const的意思,const只是不允许程序中的代码改变某一变量,其在编译期发挥作用,它并没有实际地禁止某段内存的读写特性。 while(1)  { } 「注意」:很多种途径都可实现同一种功能,但是不同的方法时间和空间占用度不同,特别是对于嵌入 式软件,处理器速度比较慢,存储空间较小,所以时间和空间优势是选择各种方法的首要考虑条件。 15 全局变量和局部变量有什么区别?实怎么实现的?操作系统和编译器是怎么知道的? 全局变量是整个程序都可访问的变量,谁都可以访问,生存期在整个程序从运行到结束(在程序结束时所占内存释放); 而局部变量存在于模块(子程序,函数)中,只有所在模块可以访问,其他模块不可直接访问,模块结束(函数调用完毕),局部变量消失,所占据的内存释放。 操作系统和编译器,可能是通过内存分配的位置来知道的,全局变量分配在全局数据段并且在程序开始运行的时候被加载.局部变量则分配在堆栈里面。 18 请解析((void ()( ) )0)( )的含义 void (*0)( ) :是一个返回值为void,参数为空的函数指针0。 (void (*)( ))0:把0转变成一个返回值为void,参数为空的函数指针。 (void ()( ))0:在上句的基础上加*表示整个是一个返回值为void,无参数,并且起始地址为0的函数的名字。 ( (void ()( ))0)( ):这就是上句的函数名所对应的函数的调用。 21 指针常量与常量指针区别 指针常量是指定义了一个指针,这个指针的值只能在定义时初始化,其他地方不能改变。常量指针 是指定义了一个指针,这个指针指向一个只读的对象,不能通过常量指针来改变这个对象的值。指针常量强调的是指针的不可改变性,而常量指针强调的是指针对其所指对象的不可改变性。 「注意」:无论是指针常量还是常量指针,其最大的用途就是作为函数的形式参数,保证实参在被调用 函数中的不可改变特性。 int *ptr;  ptr = (int *)0x67a9;  *ptr = 0xaa66; 「注意」:这道题就是强制类型转换的典型例子,无论在什么平台地址长度和整型数据的长度是一样的, 即一个整型数据可以强制转换成地址指针类型,只要有意义即可。 25 C语言的结构体和C++的有什么区别 C语言的结构体是不能有函数成员的,而C++的类可以有。 C语言的结构体中数据成员是没有private、public和protected访问限定的。而C++的类的成员有这些访问限定。 C语言的结构体是没有继承关系的,而C++的类却有丰富的继承关系。 「注意」:虽然C的结构体和C++的类有很大的相似度,但是类是实现面向对象的基础。而结构体只可以简单地理解为类的前身。 28 句柄和指针的区别和联系是什么? 句柄和指针其实是两个截然不同的概念。Windows系统用句柄标记系统资源,隐藏系统的信息。你只要知道有这个东西,然后去调用就行了,它是个32it的uint。指针则标记某个物理内存地址,两者是不同的概念。 30 说一说extern“C” extern "C"的主要作用就是为了能够正确实现C++代码调用其他C语言代码。加上extern "C"后,会指示编译器这部分代码按C语言(而不是C++)的方式进行编译。由于C++支持函数重载,因此编译器编译函数的过程中会将函数的参数类型也加到编译后的代码中,而不仅仅是函数名;而C语言并不支持函数重载,因此编译C语言代码的函数时不会带上函数的参数类型,一般只包括函数名。 这个功能十分有用处,因为在C++出现以前,很多代码都是C语言写的,而且很底层的库也是C语言写的,为了更好的支持原来的C代码和已经写好的C语言库,需要在C++中尽可能的支持C,而extern "C"就是其中的一个策略。 C++代码调用C语言代码 在C++的头文件中使用 在多个人协同开发时,可能有的人比较擅长C语言,而有的人擅长C++,这样的情况下也会有用到 33 C++中类成员的访问权限 C++通过 public、protected、private 三个关键字来控制成员变量和成员函数的访问权限,它们分别表示公有的、受保护的、私有的,被称为成员访问限定符。在类的内部(定义类的代码内部),无论成员被声明为 public、protected 还是 private,都是可以互相访问的,没有访问权限的限制。在类的外部(定义类的代码之外),只能通过对象访问成员,并且通过对象只能访问 public 属性的成员,不能访问 private、protected 属性的成员 36 说一说c++中四种cast转换 C++中四种类型转换是:static_cast, dynamic_cast, const_cast, reinterpret_cast 1、const_cast 用于将const变量转为非const 2、static_cast 用于各种隐式转换,比如非const转const,void*转指针等, static_cast能用于多态向上转化,如果向下转能成功但是不安全,结果未知; 3、dynamic_cast 用于动态类型转换。只能用于含有虚函数的类,用于类层次间的向上和向下转化。只能转指针或引用。向下转化时,如果是非法的***对于指针返回NULL,对于引用抛异常***。要深入了解内部转换的原理。 向上转换:指的是子类向基类的转换 向下转换:指的是基类向子类的转换 它通过判断在执行到该语句的时候变量的运行时类型和要转换的类型是否相同来判断是否能够进行向下转换。 4、reinterpret_cast 几乎什么都可以转,比如将int转指针,可能会出问题,尽量少用; 5、为什么不使用C的强制转换? C的强制转换表面上看起来功能强大什么都能转,但是转化不够明确,不能进行错误检查,容易出错。 auto_ptr< string> p1 (new string ("I reigned lonely as a cloud.”)); auto_ptrp2; p2 = p1; //auto_ptr不会报错. 此时不会报错,p2剥夺了p1的所有权,但是当程序运行时访问p1将会报错。所以auto_ptr的缺点是:存在潜在的内存崩溃问题! unique_ptr(替换auto_ptr) unique_ptr实现独占式拥有或严格拥有概念,保证同一时间内只有一个智能指针可以指向该对象。它对于避免资源泄露(例如“以new创建对象后因为发生异常而忘记调用delete”)特别有用。 采用所有权模式。 unique_ptrps1, ps2; ps1 = demo("hello"); ps2 = move(ps1); ps1 = demo("alexia"); cout << *ps2 << *ps1 << endl; shared_ptr shared_ptr实现共享式拥有概念。多个智能指针可以指向相同对象,该对象和其相关资源会在“最后一个引用被销毁”时候释放。从名字share就可以看出了资源可以被多个指针共享,它使用计数机制来表明资源被几个指针共享。可以通过成员函数use_count()来查看资源的所有者个数。除了可以通过new来构造,还可以通过传入auto_ptr, unique_ptr,weak_ptr来构造。当我们调用release()时,当前指针会释放资源所有权,计数减一。当计数等于0时,资源会被释放。 shared_ptr 是为了解决 auto_ptr 在对象所有权上的局限性(auto_ptr 是独占的), 在使用引用计数的机制上提供了可以共享所有权的智能指针。 成员函数: use_count 返回引用计数的个数 unique 返回是否是独占所有权( use_count 为 1) swap 交换两个 shared_ptr 对象(即交换所拥有的对象) reset 放弃内部对象的所有权或拥有对象的变更, 会引起原有对象的引用计数的减少 get 返回内部对象(指针), 由于已经重载了()方法, 因此和直接使用对象是一样的.如 shared_ptrsp(new int(1)); sp 与 sp.get()是等价的 weak_ptr weak_ptr 是一种不控制对象生命周期的智能指针, 它指向一个 shared_ptr 管理的对象. 进行该对象的内存管理的是那个强引用的 shared_ptr. weak_ptr只是提供了对管理对象的一个访问手段。weak_ptr 设计的目的是为配合 shared_ptr 而引入的一种智能指针来协助 shared_ptr 工作, 它只可以从一个 shared_ptr 或另一个 weak_ptr 对象构造, 它的构造和析构不会引起引用记数的增加或减少。weak_ptr是用来解决shared_ptr相互引用时的死锁问题,如果说两个shared_ptr相互引用,那么这两个指针的引用计数永远不可能下降为0,资源永远不会释放。它是对对象的一种弱引用,不会增加对象的引用计数,和shared_ptr之间可以相互转化,shared_ptr可以直接赋值给它,它可以通过调用lock函数来获得shared_ptr。 try {     Circle& ref_circle = dynamic_cast(ref_shape); } catch (bad_cast b) {     cout << "Caught: " << b.what(); } 42 用C++设计一个不能被继承的类 #include #include using namespace std;   class String{ public:     // 默认构造函数     String(const char *str = nullptr);     // 拷贝构造函数     String(const String &str);     // 析构函数     ~String();     // 字符串赋值函数     String& operator=(const String &str);   private:     char *m_data;     int m_size; };   // 构造函数 String::String(const char *str) { if(str == nullptr)  // 加分点:对m_data加NULL 判断     {         m_data = new char[1];   // 得分点:对空字符串自动申请存放结束标志'\0'的         m_data[0] = '\0';         m_size = 0;     } else {         m_size = strlen(str);         m_data = new char[m_size + 1];         strcpy(m_data, str);     } }   // 拷贝构造函数 String::String(const String &str)   // 得分点:输入参数为const型 {     m_size = str.m_size;     m_data = new char[m_size + 1];  //加分点:对m_data加NULL 判断     strcpy(m_data, str.m_data); }   // 析构函数 String::~String() {     delete[] m_data; }   // 字符串赋值函数 String& String::operator=(const String &str)  // 得分点:输入参数为const { if(this == &str)    //得分点:检查自赋值 return *this;       delete[] m_data;    //得分点:释放原有的内存资源     m_size = strlen(str.m_data);     m_data = new char[m_size + 1];  //加分点:对m_data加NULL 判断     strcpy(m_data, str.m_data); return *this;       //得分点:返回本对象的引用 } B::g  A::f  B::h 「注意」:考察了面试者对虚函数的理解程度。一个对虚函数不了解的人很难正确的做出本题。在学习面向对象的多态性时一定要深刻理解虚函数表的工作原理。 47 链表和数组有什么区别 存储形式:数组是一块连续的空间,声明时就要确定长度。链表是一块可不连续的动态空间, 长度可变,每个结点要保存相邻结点指针。 数据查找:数组的线性查找速度快,查找操作直接使用偏移地址。链表需要按顺序检索结点, 效率低。 数据插入或删除:链表可以快速插入和删除结点,而数组则可能需要大量数据移动。 越界问题:链表不存在越界问题,数组有越界问题。 「注意」:在选择数组或链表数据结构时,一定要根据实际需要进行选择。数组便于查询,链表便于插 入删除。数组节省空间但是长度固定,链表虽然变长但是占了更多的存储空间。 template//模板函数 int compare(const T &v1,const T &v2) { if(v1 > v2) return -1; if(v2 > v1) return 1; return 0; } //模板特例化,满足针对字符串特定的比较,要提供所有实参,这里只有一个T template<>  int compare(const char* const &v1,const char* const &v2) { return strcmp(p1,p2); } 「本质」特例化的本质是实例化一个模板,而非重载它。特例化不影响参数匹配。参数匹配都以最佳匹配为原则。例如,此处如果是compare(3,5),则调用普通的模板,若为compare(“hi”,”haha”)则调用特例化版本(因为这个cosnt char*相对于T,更匹配实参类型),注意二者函数体的语句不一样了,实现不同功能。 「注意」模板及其特例化版本应该声明在同一个头文件中,且所有同名模板的声明应该放在前面,后面放特例化版本。 (2)类模板特例化 原理类似函数模板,不过在类中,我们可以对模板进行特例化,也可以对类进行部分特例化。对类进行特例化时,仍然用template<>表示是一个特例化版本,例如: 50 为什么析构函数一般写成虚函数 由于类的多态性,基类指针可以指向派生类的对象,如果删除该基类的指针,就会调用该指针指向的派生类析构函数,而派生类的析构函数又自动调用基类的析构函数,这样整个派生类的对象完全被释放。如果析构函数不被声明成虚函数,则编译器实施静态绑定,在删除基类指针时,只会调用基类的析构函数而不调用派生类析构函数,这样就会造成派生类对象析构不完全,造成内存泄漏。所以将析构函数声明为虚函数是十分必要的。在实现多态时,当用基类操作派生类,在析构时防止只析构基类而不析构派生类的状况发生,要将基类的析构函数声明为虚函数。举个例子: 51 vector的底层原理 vector底层是一个动态数组,包含三个迭代器,start和finish之间是已经被使用的空间范围,end_of_storage是整块连续空间包括备用空间的尾部。 当空间不够装下数据(vec.push_back(val))时,会自动申请另一片更大的空间(1.5倍或者2倍),然后把原来的数据拷贝到新的内存空间,接着释放原来的那片空间[vector内存增长机制]。 当释放或者删除(vec.clear())里面的数据时,其存储空间不释放,仅仅是清空了里面的数据。因此,对vector的任何操作一旦引起了空间的重新配置,指向原vector的所有迭代器会都失效了。 54 vector中erase方法与algorithn中的remove方法区别 vector中erase方法真正删除了元素,迭代器不能访问了 remove只是简单地将元素移到了容器的最后面,迭代器还是可以访问到。因为algorithm通过迭代器进行操作,不知道容器的内部结构,所以无法进行真正的删除。 57 list的底层原理 ist的底层是一个双向链表,使用链表存储数据,并不会将它们存储到一整块连续的内存空间中。恰恰相反,各元素占用的存储空间(又称为节点)是独立的、分散的,它们之间的线性关系通过指针来维持,每次插入或删除一个元素,就配置或释放一个元素空间。 list不支持随机存取,如果需要大量的插入和删除,而不关心随即存取 60 map 、set、multiset、multimap的底层原理 map 、set、multiset、multimap的底层实现都是红黑树,epoll模型的底层数据结构也是红黑树,linux系统中CFS进程调度算法,也用到红黑树。 红黑树的特性: 每个结点或是红色或是黑色; 根结点是黑色; 每个叶结点是黑的; 如果一个结点是红的,则它的两个儿子均是黑色; 每个结点到其子孙结点的所有路径上包含相同数目的黑色结点。 63 当数据元素增多时(从10000到20000),map的set的查找速度会怎样变化? RB-TREE用二分查找法,时间复杂度为logn,所以从10000增到20000时,查找次数从log10000=14次到log20000=15次,多了1次而已。 66 为何map和set不能像vector一样有个reserve函数来预分配数据? 在map和set内部存储的已经不是元素本身了,而是包含元素的结点。也就是说map内部使用的Alloc并不是map声明的时候从参数中传入的Alloc。 69 迭代器失效的问题 插入操作: 对于vector和string,如果容器内存被重新分配,iterators,pointers,references失效;如果没有重新分配,那么插入点之前的iterator有效,插入点之后的iterator失效; 对于deque,如果插入点位于除front和back的其它位置,iterators,pointers,references失效;当我们插入元素到front和back时,deque的迭代器失效,但reference和pointers有效; 对于list和forward_list,所有的iterator,pointer和refercnce有效。删除操作: 对于vector和string,删除点之前的iterators,pointers,references有效;off-the-end迭代器总是失效的; 对于deque,如果删除点位于除front和back的其它位置,iterators,pointers,references失效;当我们插入元素到front和back时,off-the-end失效,其他的iterators,pointers,references有效; 对于list和forward_list,所有的iterator,pointer和refercnce有效。 对于关联容器map来说,如果某一个元素已经被删除,那么其对应的迭代器就失效了,不应该再被使用,否则会导致程序无定义的行为。

    时间:2021-02-19 关键词: 面试题 C 问答题 C语言

  • 适合具备C语言基础的C++教程(一)

    引言 C 语言通常被认为是一种面向过程的语言,因为其本身的特性更容易编写面向过程的代码,当然也不排除使用 C 语言编写面向过程的代码,比如 Linux 的源代码以及现在很火的国产物联网操作系统 RT-Thread,其内核的实现方式都是使用 C 语言实现的面向对象的代码。相比于 C 语言来说,C++ 更能够实现面向对象的程序设计,其具有的特性也要比 C 语言要多的多。下面假设有这样一个需求。 现要描述两个人的信息,姓名,职业,年龄,并输出。 我们首先先使用 C 语言的设计思路实现这个功能。 C语言描述 如果使用 C 语言来描述上面这个问题,大部分都会想到使用结构体来完成这个要求,写出的程序也就如下所示: #include struct person { char *name; int age; char *work; }; int main(int argc, char** aggv) { struct person persons[] = { {"wenzi",24,"programer"},         {"jiao", 22,"teacher"},     }; char i; for (i = 0; i < 2; i++)     { printf("name is:%s,age is:%d,work is:%s\n",persons[i].name,persons[i].age,persons[i].work);     } } 上述这是比较初级的写法,如果对 C 语言了解的更多一点的人在写这段程序的时候,会使用函数指针的方式将代码写的更加巧妙,代码如下所示: #include struct person { char *name; int age; char *work; void (*printInfo)(struct person *per); }; void printInfo(struct person *per) { printf("The people's name is:%s,age is:%d,work is:%s\n",per->name,per->age,per->work); } int main(int argc, char** argv) { struct person per[2]; per[0] = {"wenzi",18,"programer",printInfo};     per[1] = {"jiaojiao",18,"teacher",printInfo};     per[0].printInfo(&per[0]);     per[1].printInfo(&per[1]); } 使用了函数指针的方式来书写这个程序,程序也变得更加简介了,主函数里也少了for循环。 C++ 的引入 那除此之外,还有更好的书写方式么,这个时候就要引入 C++ 的特性了,上述代码中在执行函数时都传入了参数,那要如何做才能将上述中的参数也省略去呢,且看如下的代码: #include struct person { char *name; int age; char *work; void prinfInfo(void) { printf("The people's name is:%s,age is:%d,work is:%s\n",name,age,work);            } }; int main(int argc, char** argv) { struct person persons[] = { {"wenzi", 18,"program"},         {"jiao", 18, "teacher"},     };     persons[0].prinfInfo();     persons[1].prinfInfo(); return 0; } 上述代码中使用了 C++ 的特性,在结构体中定义了函数,然后也就可以直接调用函数了,跟上面 C 语言的代码相比较,它没了实参,而且代码看起来也比 C 语言更加简洁了。 实际在 C++ 中它具有自己独有的一套机制来实现上述的代码,也就是即将说明的class,有了 class 之后,我们就可以这样书写代码: #include class person { public: char * name; int age; char * work; void printInfo(void) { printf("The people's name is:%s,age is:%d,work is:%s\n",name,age,work);      } } int main(int argc, char** argv) {     person persons[] = {         {"wenzi", 18,"program"},         {"jiao", 18, "teacher"},     };     persons[0].prinfInfo();     persons[1].prinfInfo(); return 0; } 上述就是关于 C++ 的一个简单的引入过程。 C++ 数据访问控制 但是为了能够改变类里的数据,但是又要使得这个改变不要越界,避免胡乱地改变,我们可以这样来定义这个类: #include #include class Person { private: char *name; int age; char *work; public: void PrintInfo(void) { cout

    时间:2021-02-19 关键词: 嵌入式 面向过程 C C语言

  • C语言必须写main函数?最简单的 Hello world 你其实一点都不懂!

    作者 | 明哥 转自 | 程序员入门进阶 我 们在刚写程序的时候,第一个都是 hello world,而在这里,完整的代码就是: 我们打眼一看,其实很简单,就是引入头文件,写一个主函数,然后输出一句话,但是当我们编译出来ELF的时候,我们使用工具readelf,去查看下这里面的FUNC,会发现多了很多方法。(gcc相关工具链,我经常用的是objdump ) 如果你想知道这个过程都处理了什么,可以使用gcc -o hello hello.c -v,这里的-v,会输出过程信息,这里截一部分,大家看下 这块要学习,去GCC官方看下它的编译,链接参数。Makefile文件,可以使用 --just-print 进行调试。这里面的UND,代表的是未定义,未定义的这些方法,会在加载器加载的时候,补充进来。 我们这里使用 IDA 来解析下这个输出ELF,可以看到一个简单的信息。 这里的Interpreter,就是解析程序,crtstuff.c这个就是给我们的运行环境,做初始化。从这里我们就能看到,其实我们的一个简单的程序,也是五脏俱全的。 既然它们的流程是,系统加载进来,然后初始化,再到我们的main方法,那么这个main方法,肯定是可以变的。为什么这么说呢?做过嵌入式开发的应该熟悉,基本上都没有main函数一说,直接从跳转入口开始跑就可以的。可以给任意函数,指定成Enter,也就是入口函数,使用链接脚本就可以指定,这块感兴趣的可以搜索gcc链接器参数。 我们先简单做一个操作,这样子来处理下。gcc -o hello hello.c -nostdlib 我们来把这个库去掉,看看会报哪些错误,可以看到这里报了入口点找不到,也就是_start 。 https://my.oschina.net/saly/blog/130920 我们看下这里的参数介绍: 我们是用gcc -o hello hello.c -nostartfiles 把这个启动函数去掉,然后我们自己实现一个。然后我们把文件修改成 这里修改成exit ,同时加上对应的库文件,去掉return的原因是,这时候不能返回,需要清理,返回去没人接这个,系统中使用的是jmp,你返回就找不到路了。 然后这里已经没有main函数了,直接用的_start,这个属于覆盖的方式,那么我们自己定义一个名字,该怎么处理呢? 然后使用参数 gcc -o hello hello.c -nostartfiles -efuck_main ,-e这里就是 -enter的缩写,代表指定入口,通过这个操作,最终我们实现了没有main函数的一个程序,并且能够运行。 今天在这里分享一个比较有用的命令,在我们开发移植三方代码时候,会遇见很多未定义,包含错误,链接失败,这时候就需要定位我们的编译器参数,echo 'main(){}'|gcc -E -v - 这个可以看到详细的头文件,链接库的引用信息,当然我们可以使用--sysroot去指定,同时配合着 -I -l 参数。 到这里就完了吗?必然不是,我们看了如何修改入口函数,我们如果想要在main前后做一些动作呢?我们晓得的是动态库是有这个机制的,我们静态可执行库,也是有的,具体是: 这里运行结果: 我们可以清晰的看到,前后有了输出,那么我们看下这个最终的elf,这里找到after_main具体存放位置,而这个对应位置的方法,会在调用main之后进行遍历。所以这个是可以声明多个的。 而关于退出,还有个优雅的方式,就是int atexit(void (*)(void));,这个是一个设置退出方法,然后在main结束后,会进行执行,这里就是注册,很好理解。 为什么有main函数,主要是约定成俗,你让别人用你的东西,那必然要给他一个入口,也就是你的系统跟他关联的那个定义,main函数就是c语言开发,大家约定的入口。 但是在嵌入式开发当中,因为整个的系统,都是由我们处理,从启动,加载,运行,所以我们是可以不指定main函数,可以自己来约定。 好了第一讲就分享到这里,下一节我们来说下,c语言main函数的多种写法,其中一个标准的写法是带有:参数argv和argc,下一节说下这个是如何查找,定位的。 免责声明:本文内容由21ic获得授权后发布,版权归原作者所有,本平台仅提供信息存储服务。文章仅代表作者个人观点,不代表本平台立场,如有问题,请联系我们,谢谢!

    时间:2021-02-10 关键词: main函数 C语言

  • 大牛谈嵌入式C语言的高级用法

    内存管理 我们需要知道——变量,其实是内存地址的一个抽像名字罢了。在静态编译的程序中,所有的变量名都会在编译时被转成内存地址。机器是不知道我们取的名字的,只知道地址。 内存的使用时程序设计中需要考虑的重要因素之一,这不仅由于系统内存是有限的(尤其在嵌入式系统中),而且内存分配也会直接影响到程序的效率。因此,我们要对C语言中的内存管理,有个系统的了解。 在C语言中,定义了4个内存区间:代码区;全局变量和静态变量区;局部变量区即栈区;动态存储区,即堆区;具体如下: 1>栈区(stack)— 由编译器自动分配释放 ,存放函数的参数值,局部变量的值等。其操作方式类似于数据结构中的栈。 2>堆区(heap) — 一般由程序员分配释放, 若程序员不释放,程序结束时可能由OS回收 。注意它与数据结构中的堆是两回事,分配方式倒是类似于链表。 3>全局区(静态区)(static)—全局变量和静态变量的存储是放在一块的,初始化的全局变量和静态变量在一块区域, 未初始化的全局变量和未初始化的静态变量在相邻的 另一块区域。- 程序结束后由系统释放。 4>常量区 —常量字符串就是放在这里的。程序结束后由系统释放。 5>程序代码区—存放函数体的二进制代码。 我们来看张图: 图1 首先我们要知道,源代码编译成程序,程序是放在硬盘上的,而非内存里!只有执行时才会被调用到内存中!我们来看看程序结构,ELF是是Linux的主要可执行文件格式。ELF文件由4部分组成,分别是ELF头(ELF header)、程序头表(Program header table)、节(Section)和节头表(Section header table)。具体如下: 1>Program header描述的是一个段在文件中的位置、大小以及它被放进内存后所在的位置和大小。即要加载的信息; 2>Sections保存着object 文件的信息,从连接角度看:包括指令,数据,符号表,重定位信息等等。在图中,我们可以看到Sections中包括: text 文本结 存放指令; rodata 数据结 readonly; data 数据结 可读可写; 3>Section头表(section header table)包含了描述文件sections的信息。每个section在这个表中有一个入口;每个入口给出了该section的名字,大小,等等信息。相当于 索引! 而程序被加载到内存里面,又是如何分布的呢?我们看看上图中: 正文和初始化的数据和未初始化的数据就是我们所说的数据段,正文即代码段; 2>正文段上面是常量区,常量区上面是全局变量和静态变量区,二者占据的就是初始化的数据和未初始化的数据那部分; 3>再上面就是堆,动态存储区,这里是上增长; 4>堆上面是栈,存放的是局部变量,就是局部变量所在代码块执行完毕后,这块内存会被释放,这里栈区是下增长; 5>命令行参数就是001之类的,环境变量什么的前面的文章已经讲过,有兴趣的可以去看看。 我们知道,内存分为动态内存和静态内存,我们先讲静态内存。 静态内存 存储模型决定了一个变量的内存分配方式和访问特性,在C语言中主要有三个维度来决定:存储时期 、作用域 、链接。 1、存储时期 存储时期:变量在内存中的保留时间(生命周期) 存储时期分为两种情况,关键是看变量在程序执行过程中会不会被系统自动回收掉。 1) 静态存储时期 Static 在程序执行过程中一旦分配就不会被自动回收。 通常来说,任何不在函数级别代码块内定义的变量。 无论是否在代码块内,只要采用static关键字修饰的变量。 2) 自动存储时期 Automatic 除了静态存储以外的变量都是自动存储时期的,或者说只要是在代码块内定义的非static的变量,系统会肚脐自动非配和释放内存; 2、作用域 作用域:一个变量在定义该变量的自身文件中的可见性(访问或者引用) 在C语言中,一共有3中作用域: 1) 代码块作用域 在代码块中定义的变量都具有该代码的作用域。从这个变量定义地方开始,到这个代码块结束,该变量是可见的; 2) 函数原型作用域 出现在函数原型中的变量,都具有函数原型作用域,函数原型作用域从变量定义处一直到原型声明的末尾。 3) 文件作用域 一个在所有函数之外定义的变量具有文件作用域,具有文件作用域的变量从它的定义处到包含该定义的文件结尾处都是可见的; 3、链接 链接:一个变量在组成程序的所有文件中的可见性(访问或者引用); C语言中一共有三种不同的链接: 1) 外部链接 如果一个变量在组成一个程序的所有文件中的任何位置都可以被访问,则称该变量支持外部链接; 2) 内部链接 如果一个变量只可以在定义其自身的文件中的任何位置被访问,则称该变量支持内部链接。 3) 空链接 如果一个变量只是被定义其自身的当前代码块所私有,不能被程序的其他部分所访问,则成该变量支持空链接 我们来看一个代码示例: #include int a = 0;// 全局初始化区 char *p1; //全局未初始化区 int main() { int b; //b在栈区 char s[] = "abc"; //栈 char *p2; //p2在栈区 char *p3 = "123456"; //123456\0在常量区,p3在栈上。 static int c =0; //全局(静态)初始化区 p1 = (char *)malloc(10); p2 = (char *)malloc(20);  //分配得来得10和20字节的区域就在堆区。 strcpy(p1, "123456"); //123456\0放在常量区,编译器可能会将它与p3所指向的"123456"优化成一个地方。 } 1.2动态内存 当程序运行到需要一个动态分配的变量时,必须向系统申请取得堆中的一块所需大小的存储空间,用于存储该变量。当不在使用该变量时,也就是它的生命结束时,要显示释放它所占用的存储空间,这样系统就能对该空间 进行再次分配,做到重复使用有线的资源。下面介绍动态内存申请和释放的函数。 1.2.1 malloc 函数 malloc函数原型: size是需要动态申请的内存的字节数。若申请成功,函数返回申请到的内存的起始地址,若申请失败,返回NULL。我们看下面这个例子: 使用该函数时,有下面几点要注意: 1)只关心申请内存的大小; 2)申请的是一块连续的内存。记得一定要写出错判断; 3)显示初始化。即我们不知这块内存中有什么东西,要对其清零; 1.2.2 free函数 在堆上分配的额内存,需要用free函数显示释放,函数原型如下: 使用free(),也有下面几点要注意: 1)必须提供内存的起始地址; 调用该函数时,必须提供内存的起始地址,不能够提供部分地址,释放内存中的一部分是不允许的。 2)malloc和free配对使用; 编译器不负责动态内存的释放,需要程序员显示释放。因此,malloc与free是配对使用的,避免内存泄漏。 p = NULL是必须的,因为虽然这块内存被释放了,但是p仍指向这块内存,避免下次对p的误操作; 3)不允许重复释放 因为这块内存被释放后,可能已另分配,这块区域被别人占用,如果再次释放,会造成数据丢失; 1.2.3 其它相关函数 calloc函数分配内存需要考虑存储位置的类型。 realloc函数可以调整一段动态分配内存的大小 1.3堆和栈比较 1)申请方式 stack: 由系统自动分配。例如,声明在函数中一个局部变量 int b; 系统自动在栈中为b开辟空间 heap: 需要程序员自己申请,并指明大小,在c中malloc函数 ,如p1 = (char *)malloc(10); 2)申请后系统的响应 栈:只要栈的剩余空间大于所申请空间,系统将为程序提供内存,否则将报异常提示栈溢出。 堆:首先应该知道操作系统有一个记录空闲内存地址的链表,当系统收到程序的申请时,会遍历该链表,寻找第一个空间大于所申请空间的堆结点,然后将该结点从空闲结点链表中删除,并将该结点的空间分配给程序,另外,对于大多数系统,会在这块内存空间中的首地址处记录本次分配的大小,这样,代码中的delete语句才能正确的释放本内存空间。另外,由于找到的堆结点的大小不一定正好等于申请的大小,系统会自动的将多余的那部分重新放入空闲链表中。 3)申请大小的限制 栈:栈是向低地址扩展的数据结构,是一块连续的内存的区域。这句话的意思是栈顶的地址和栈的最大容量是系统预先规定好的,栈的大小是2M(也有的说是1M,总之是一个编译时就确定的常数),如果申请的空间超过栈的剩余空间时,将提示overflow。因此,能从栈获得的空间较小。 堆:堆是向高地址扩展的数据结构,是不连续的内存区域。这是由于系统是用链表来存储的空闲内存地址的,自然是不连续的,而链表的遍历方向是由低地址向高地址。堆的大小受限于计算机系统中有效的虚拟内存。由此可见,堆获得的空间比较灵活,也比较大。 4)申请效率的比较 栈由系统自动分配,速度较快。但程序员是无法控制的。 堆是由new分配的内存,一般速度比较慢,而且容易产生内存碎片,不过用起来最方便。 5)堆和栈中的存储内容 栈:在函数调用时,第一个进栈的是主函数中后的下一条指令(函数调用语句的下一条可执行语句)的地址,然后是函数的各个参数,在大多数的C编译器中,参数是由右往左入栈的,然后是函数中的局部变量。注意静态变量是不入栈的。当本次函数调用结束后,局部变量先出栈,然后是参数,最后栈顶指针指向最开始存的地址,也就是主函数中的下一条指令,程序由该点继续运行。 堆:一般是在堆的头部用一个字节存放堆的大小。堆中的具体内容由程序员安排。 6)存取效率的比较 char s1[] = "aaaaaaaaaaaaaaa"; char *s2 = "bbbbbbbbbbbbbbbbb"; aaaaaaaaaaa是在运行时刻赋值的; 而bbbbbbbbbbb是在编译时就确定的; 但是,在以后的存取中,在栈上的数组比指针所指向的字符串(例如堆)快。 比如: 对应的汇编代码 第一种在读取时直接就把字符串中的元素读到寄存器cl中,而第二种则要先把指针值读到edx中,再根据edx读取字符,显然慢了。 7)最后总结 堆和栈的区别可以用如下的比喻来看出: 栈就像我们去饭馆里吃饭,只管点菜(发出申请)、付钱、和吃(使用),吃饱了就走,不必理会切菜、洗菜等准备工作和洗碗、刷锅等扫尾工作,他的好处是快捷,但是自由度小。 堆就象是自己动手做喜欢吃的菜肴,比较麻烦,但是比较符合自己的口味,而且自由度大。 2.1 #pragma pack(n) 对齐用法详解 1.什么是对齐,以及为什么要对齐 现代计算机中内存空间都是按照byte划分的,从理论上讲似乎对任何类型的变量的访问可以从任何地址开始,但实际情况是在访问特定变量的时候经常在特定的内存地址访问,这就需要各类型数据按照一定的规则在空间上排列,而不是顺序的一个接一个的排放,这就是对齐。 对齐的作用和原因:各个硬件平台对存储空间的处理上有很大的不同。一些平台对某些特定类型的数据只能从某些特定地址开始存取。其他平台可能没有这种情况, 但是最常见的是如果不按照适合其平台要求对数据存放进行对齐,会在存取效率上带来损失。比如有些平台每次读都是从偶地址开始,如果一个int型(假设为 32位系统)如果存放在偶地址开始的地方,那么一个读周期就可以读出,而如果存放在奇地址开始的地方,就可能会需要2个读周期,并对两次读出的结果的高低字节进行拼凑才能得到该int数据。显然在读取效率上下降很多。这也是空间和时间的博弈。 2.对齐的实现 通常,我们写程序的时候,不需要考虑对齐问题。编译器会替我们选择时候目标平台的对齐策略。当然,我们也可以通知给编译器传递预编译指令而改变对指定数据的对齐方法。但是,正因为我们一般不需要关心这个问题,所以因为编辑器对数据存放做了对齐,而我们不了解的话,常常会对一些问题感到迷惑。最常见的就是struct数据结构的sizeof结果,出乎意料。为此,我们需要对对齐算法所了解。 作用: 指定结构体、联合以及类成员的packing alignment; 语法: #pragma pack( [show] | [push | pop] [, identifier], n ) 说明: 1>pack提供数据声明级别的控制,对定义不起作用; 2>调用pack时不指定参数,n将被设成默认值; 3>一旦改变数据类型的alignment,直接效果就是占用memory的减少,但是performance会下降; 3.语法具体分析 1>show:可选参数;显示当前packing aligment的字节数,以warning message的形式被显示; 2>push:可选参数;将当前指定的packing alignment数值进行压栈操作,这里的栈是the internal compiler stack,同时设置当前的packing alignment为n;如果n没有指定,则将当前的packing alignment数值压栈; 3>pop:可选参数;从internal compiler stack中删除最顶端的record;如果没有指定n,则当前栈顶record即为新的packing alignment数值;如果指定了n,则n将成为新的packing aligment数值;如果指定了identifier,则internal compiler stack中的record都将被pop直到identifier被找到,然后pop出identitier,同时设置packing alignment数值为当前栈顶的record;如果指定的identifier并不存在于internal compiler stack,则pop操作被忽略; 4>identifier:可选参数;当同push一起使用时,赋予当前被压入栈中的record一个名称;当同pop一起使用时,从internal compiler stack中pop出所有的record直到identifier被pop出,如果identifier没有被找到,则忽略pop操作; 5>n:可选参数;指定packing的数值,以字节为单位;缺省数值是8,合法的数值分别是1、2、4、8、16。 4.重要规则 1>复杂类型中各个成员按照它们被声明的顺序在内存中顺序存储,第一个成员的地址和整个类型的地址相同; 2>每个成员分别对齐,即每个成员按自己的方式对齐,并最小化长度;规则就是每个成员按其类型的对齐参数(通常是这个类型的大小)和指定对齐参数中较小的一个对齐; 3>结构、联合或者类的数据成员,第一个放在偏移为0的地方;以后每个数据成员的对齐,按照#pragma pack指定的数值和这个数据成员自身长度两个中比较小的那个进行;也就是说,当#pragma pack指定的值等于或者超过所有数据成员长度的时候,这个指定值的大小将不产生任何效果; 4>复杂类型(如结构)整体的对齐<注意是“整体”>是按照结构体中长度最大的数据成员和#pragma pack指定值之间较小的那个值进行;这样在成员是复杂类型时,可以最小化长度; 5>结构整体长度的计算必须取所用过的所有对齐参数的整数倍,不够补空字节;也就是取所用过的所有对齐参数中最大的那个值的整数倍,因为对齐参数都是2的n次方;这样在处理数组时可以保证每一项都边界对齐; 5.对齐的算法 由于各个平台和编译器的不同,现以本人使用的gcc version 3.2.2编译器(32位x86平台)为例子,来讨论编译器对struct数据结构中的各成员如何进行对齐的。 在相同的对齐方式下,结构体内部数据定义的顺序不同,结构体整体占据内存空间也不同,如下: 设结构体如下定义: 结构体A中包含了4字节长度的int一个,1字节长度的char一个和2字节长度的short型数据一个。所以A用到的空间应该是7字节。但是因为编译器要对数据成员在空间上进行对齐。所以使用sizeof(strcut A)值为8。 现在把该结构体调整成员变量的顺序。 这时候同样是总共7个字节的变量,但是sizeof(struct B)的值却是12。 下面我们使用预编译指令#progma pack (value)来告诉编译器,使用我们指定的对齐值来取代缺省的。 sizeof(struct C)值是8。 修改对齐值为1: sizeof(struct D)值为7。 对于char型数据,其自身对齐值为1,对于short型为2,对于int,float,double类型,其自身对齐值为4,单位字节。 6.四个概念值 1>数据类型自身的对齐值:就是上面交代的基本数据类型的自身对齐值。 2>指定对齐值:#progma pack (value)时的指定对齐值value。 3>结构体或者类的自身对齐值:其数据成员中自身对齐值最大的那个值。 4>数据成员、结构体和类的有效对齐值:自身对齐值和指定对齐值中小的那个值。有了这些值,我们就可以很方便的来讨论具体数据结构的成员和其自身的对齐方式。有效对齐值N是最终用来决定数据存放地址方式的值,最重要。有效对齐N,就是表示“对齐在N上”,也就是说该数据的”存放起始地址%N=0”. 而数据结构中的数据变量都是按定义的先后顺序来排放的。第一个数据变量的起始地址就是数据结构的起始地址。结构体的成员变量要对齐排放,结构体本身也要根 据自身的有效对齐值圆整(就是结构体成员变量占用总长度需要是对结构体有效对齐值的整数倍,结合下面例子理解)。这样就不能理解上面的几个例子的值了。 例子分析: 分析例子B; 假设B从地址空间0x0000开始排放。该例子中没有定义指定对齐值,在笔者环境下,该值默认为4。 第一个成员变量b的自身对齐值是1,比指定或者默认指定对齐值4小,所以其有效对齐值为1,所以其存放地址0x0000符合0x0000%1=0. 第二个成员变量a,其自身对齐值为4,所以有效对齐值也为4,所以只能存放在起始地址为0x0004到0x0007这四个连续的字节空间中,符合0x0004%4=0, 且紧靠第一个变量。 第三个变量c,自身对齐值为2,所以有效对齐值也是2,可以存放在0x0008到0x0009 这两个字节空间中,符合0x0008%2=0。所以从0x0000到0x0009存放的都是B内容。再看数据结构B的自身对齐值为其变量中最大对齐值(这里是b)所以就是4,所以结构体的有效对齐值也是4。根据结构体圆整的要求,0x0009到0x0000=10字节,(10+2)%4=0。所以0x0000A到0x000B也为结构体B所占用。故B从0x0000到0x000B共有12个字节,sizeof(struct B)=12; 同理,分析上面例子C: 第一个变量b的自身对齐值为1,指定对齐值为2,所以,其有效对齐值为1,假设C从0x0000开始,那么b存放在0x0000,符合0x0000%1=0; 第二个变量,自身对齐值为4,指定对齐值为2,所以有效对齐值为2,所以顺序存放在0x0002、0x0003、0x0004、0x0005四个连续字节中,符合0x0002%2=0。 第三个变量c的自身对齐值为2,所以有效对齐值为2,顺序存放在0x0006、0x0007中,符合0x0006%2=0。所以从0x0000到0x00007共八字节存放的是C的变量。 又C的自身对齐值为4,所以C的有效对齐值为2。又8%2=0,C只占用0x0000到0x0007的八个字节。所以sizeof(struct C)=8. 字节对齐对程序的影响 先让我们看几个例子吧(32bit,x86环境,gcc编译器): 设结构体如下定义: 现在已知32位机器上各种数据类型的长度如下: char:1(有符号无符号同) short:2(有符号无符号同) int:4(有符号无符号同) long:4(有符号无符号同) float:4 double:8 那么上面两个结构大小如何呢? 结果是: sizeof(strcut A)值为8 sizeof(struct B)的值却是12 结构体A中包含了4字节长度的int一个,1字节长度的char一个和2字节长度的short型数据一个,B也一样;按理说A,B大小应该都是7字节。之所以出现上面的结果是因为编译器要对数据成员在空间上进行对齐。上面是按照编译器的默认设置进行对齐的结果,那么我们是不是可以改变编译器的这种默认对齐设置呢,当然可以.例如: sizeof(struct C)值是8。 修改对齐值为1: sizeof(struct D)值为7。 后面我们再讲解#pragma pack()的作用. 2.3修改编译器的默认对齐值 1>在VC IDE中,可以这样修改:[Project]|[Settings],c/c++选项卡Category的Code Generation选项的Struct Member Alignment中修改,默认是8字节。 2>在编码时,可以这样动态修改:#pragma pack .注意:是pragma而不是progma. 如果在编程的时候要考虑节约空间的话,那么我们只需要假定结构的首地址是0,然后各个变量按照上面的原则进行排列即可,基本的原则就是把结构中的变量按照 类型大小从小到大声明,尽量减少中间的填补空间.还有一种就是为了以空间换取时间的效率,我们显示的进行填补空间进行对齐,比如:有一种使用空间换时间做 法是显式的插入reserved成员: reserved成员对我们的程序没有什么意义,它只是起到填补空间以达到字节对齐的目的,当然即使不加这个成员通常编译器也会给我们自动填补对齐,我们自己加上它只是起到显式的提醒作用. 2.4字节对齐可能带来的隐患 代码中关于对齐的隐患,很多是隐式的。比如在强制类型转换的时候。例如: 最后两句代码,从奇数边界去访问unsignedshort型变量,显然不符合对齐的规定。 在x86上,类似的操作只会影响效率,但是在MIPS或者sparc上,可能就是一个error,因为它们要求必须字节对齐. 如果出现对齐或者赋值问题首先查看 1). 编译器的big little端设置 2). 看这种体系本身是否支持非对齐访问 3). 如果支持看设置了对齐与否,如果没有则看访问时需要加某些特殊的修饰来标志其特殊访问操作。 ARM下的对齐处理 from DUI0067D_ADS1_2_CompLib type qulifiers 有部分摘自ARM编译器文档对齐部分对齐的使用: 1.__align(num) 这个用于修改最高级别对象的字节边界。在汇编中使用LDRD或者STRD时就要用到此命令__align(8)进行修饰限制。来保证数据对象是相应对齐。这个修饰对象的命令最大是8个字节限制,可以让2字节的对象进行4字节对齐,但是不能让4字节的对象2字节对齐。__align是存储类修改,他只修饰最高级类型对象不能用于结构或者函数对象。 2.__packed __packed是进行一字节对齐 l 不能对packed的对象进行对齐 l 所有对象的读写访问都进行非对齐访问 l float及包含float的结构联合及未用__packed的对象将不能字节对齐 l __packed对局部整形变量无影响 l 强制由unpacked对象向packed对象转化是未定义,整形指针可以合法定 义为packed。 __packed int* p; //__packed int 则没有意义 2.5对齐或非对齐读写访问带来问题 __packed struct STRUCT_TEST {char a;int b;char c; } ; //定义如下结构此时b的起始地址一定是不对齐的,在栈中访问b可能有问题,因为栈上数据肯定是对齐访问[from CL] //将下面变量定义成全局静态不在栈上 static char* p;static struct STRUCT_TEST a;void Main() { __packed int* q; //此时定义成__packed来修饰当前q指向为非对齐的数据地址下面的访问则可以 p = (char*)&a; q = (int*)(p+1); *q = 0x87654321; /* 得到赋值的汇编指令很清楚 ldr      r5,0x20001590 ; = #0x12345678 [0xe1a00005]   mov      r0,r5 [0xeb0000b0]   bl       __rt_uwrite4 //在此处调用一个写4byte的操作函数 [0xe5c10000]   strb     r0,[r1,#0]   //函数进行4次strb操作然后返回保证了数据正确的访问 [0xe1a02420]   mov      r2,r0,lsr #8 [0xe5c12001]   strb     r2,[r1,#1] [0xe1a02820]   mov      r2,r0,lsr #16 [0xe5c12002]   strb     r2,[r1,#2] [0xe1a02c20]   mov      r2,r0,lsr #24 [0xe5c12003]   strb     r2,[r1,#3] [0xe1a0f00e]   mov      pc,r14 */ /* 如果q没有加__packed修饰则汇编出来指令是这样直接会导致奇地址处访问失败 [0xe59f2018]   ldr      r2,0x20001594 ; = #0x87654321 [0xe5812000]   str      r2,[r1,#0] */ //这样可以很清楚的看到非对齐访问是如何产生错误的 //以及如何消除非对齐访问带来问题 //也可以看到非对齐访问和对齐访问的指令差异导致效率问题 } 来源:网络 免责声明:本文内容由21ic获得授权后发布,版权归原作者所有,本平台仅提供信息存储服务。文章仅代表作者个人观点,不代表本平台立场,如有问题,请联系我们,谢谢!

    时间:2021-02-10 关键词: 嵌入式 高级用法 C语言

  • C语言指针-从底层原理到花式技巧,用图文和代码帮你讲解透彻

    一、前言 二、变量与指针的本质 三、指针的几个相关概念 四、指向不同数据类型的指针 五、总结 一、前言 如果问C语言中最重要、威力最大的概念是什么,答案必将是指针!威力大,意味着使用方便、高效,同时也意味着语法复杂、容易出错。指针用的好,可以极大的提高代码执行效率、节约系统资源;如果用的不好,程序中将会充满陷阱、漏洞。 这篇文章,我们就来聊聊指针。从最底层的内存存储空间开始,一直到应用层的各种指针使用技巧,循序渐进、抽丝剥茧,以最直白的语言进行讲解,让你一次看过瘾。 说明:为了方便讲解和理解,文中配图的内存空间的地址是随便写的,在实际计算机中是要遵循地址对齐方式的。 二、变量与指针的本质 1. 内存地址 我们编写一个程序源文件之后,编译得到的二进制可执行文件存放在电脑的硬盘上,此时它是一个静态的文件,一般称之为程序。 当这个程序被启动的时候,操作系统将会做下面几件事情: 把程序的内容(代码段、数据段)从硬盘复制到内存中; 创建一个数据结构PCB(进程控制块),来描述这个程序的各种信息(例如:使用的资源,打开的文件描述符...); 在代码段中定位到入口函数的地址,让CPU从这个地址开始执行。 当程序开始被执行时,就变成一个动态的状态,一般称之为进程。 内存分为:物理内存和虚拟内存。操作系统对物理内存进行管理、包装,我们开发者面对的是操作系统提供的虚拟内存。 这2个概念不妨碍文章的理解,因此就统一称之为内存。 在我们的程序中,通过一个变量名来定义变量、使用变量。变量本身是一个确确实实存在的东西,变量名是一个抽象的概念,用来代表这个变量。就比如:我是一个实实在在的人,是客观存在与这个地球上的,道哥是我给自己起的一个名字,这个名字是任意取得,只要自己觉得好听就行,如果我愿意还可以起名叫:鸟哥、龙哥等等。 那么,我们定义一个变量之后,这个变量放在哪里呢?那就是内存的数据区。内存是一个很大的存储区域,被操作系统划分为一个一个的小空间,操作系统通过地址来管理内存。 内存中的最小存储单位是字节(8个bit),一个内存的完整空间就是由这一个一个的字节连续组成的。在上图中,每一个小格子代表一个字节,但是好像大家在书籍中没有这么来画内存模型的,更常见的是下面这样的画法: 也就是把连续的4个字节的空间画在一起,这样就便于表述和理解,特别是深入到代码对齐相关知识时更容易理解。(我认为根本原因应该是:大家都这么画,已经看顺眼了~~) 2. 32位与64位系统 我们平时所说的计算机是32位、64位,指的是计算机的CPU中寄存器的最大存储长度,如果寄存器中最大存储32bit的数据,就称之为32位系统。 在计算机中,数据一般都是在硬盘、内存和寄存器之间进行来回存取。CPU通过3种总线把各组成部分联系在一起:地址总线、数据总线和控制总线。地址总线的宽度决定了CPU的寻址能力,也就是CPU能达到的最大地址范围。 刚才说了,内存是通过地址来管理的,那么CPU想从内存中的某个地址空间上存取一个数据,那么CPU就需要在地址总线上输出这个存储单元的地址。假如地址总线的宽度是8位,能表示的最大地址空间就是256个字节,能找到内存中最大的存储单元是255这个格子(从0开始)。即使内存条的实际空间是2G字节,CPU也没法使用后面的内存地址空间。如果地址总线的宽度是32位,那么能表示的最大地址就是2的32次方,也就是4G字节的空间。 【注意】:这里只是描述地址总线的概念,实际的计算机中地址计算方式要复杂的多,比如:虚拟内存中采用分段、分页、偏移量来定位实际的物理内存,在分页中还有大页、小页之分,感兴趣的同学可以自己查一下相关资料。 3. 变量 我们在C程序中使用变量来“代表”一个数据,使用函数名来“代表”一个函数,变量名和函数名是程序员使用的助记符。变量和函数最终是要放到内存中才能被CPU使用的,而内存中所有的信息(代码和数据)都是以二进制的形式来存储的,计算机根据就不会从格式上来区分哪些是代码、哪些是数据。CPU在访问内存的时候需要的是地址,而不是变量名、函数名。 问题来了:在程序代码中使用变量名来指代变量,而变量在内存中是根据地址来存放的,这二者之间如何映射(关联)起来的? 答案是:编译器!编译器在编译文本格式的C程序文件时,会根据目标运行平台(就是编译出的二进制程序运行在哪里?是x86平台的电脑?还是ARM平台的开发板?)来安排程序中的各种地址,例如:加载到内存中的地址、代码段的入口地址等等,同时编译器也会把程序中的所有变量名,转成该变量在内存中的存储地址。 变量有2个重要属性:变量的类型和变量的值。 示例:代码中定义了一个变量 int a = 20; 类型是int型,值是20。这个变量在内存中的存储模型为: 我们在代码中使用变量名a,在程序执行的时候就表示使用0x11223344地址所对应的那个存储单元中的数据。因此,可以理解为变量名a就等价于这个地址0x11223344。换句话说,如果我们可以提前知道编译器把变量a安排在地址0x11223344这个单元格中,我们就可以在程序中直接用这个地址值来操作这个变量。 在上图中,变量a的值为20,在内存中占据了4个格子的空间,也就是4个字节。为什么是4个字节呢?在C标准中并没有规定每种数据类型的变量一定要占用几个字节,这是与具体的机器、编译器有关。 比如:32位的编译器中: char: 1个字节; short int: 2个字节; int: 4个字节; long: 4个字节。 比如:64位的编译器中: char: 1个字节; short int: 2个字节; int: 4个字节; long: 8个字节。 为了方便描述,下面都以32位为例,也就是int型变量在内存中占据4个字节。 另外,0x11223344,0x11223345,0x11223346,0x11223347这连续的、从低地址到高地址的4个字节用来存储变量a的数值20。在图示中,使用十六进制来表示,十进制数值20转成16进制就是:0x00000014,所以从开始地址依次存放0x00、0x00、0x00、0x14这4个字节(存储顺序涉及到大小端的问题,不影响文本理解)。 根据这个图示,如果在程序中想知道变量a存储在内存中的什么位置,可以使用取地址操作符&,如下: printf("&a = 0x%x \n", &a); 这句话将会打印出:&a = 0x11223344。 考虑一下,在32位系统中:指针变量占用几个字节? 4. 指针变量 指针变量可以分2个层次来理解: 指针变量首先是一个变量,所以它拥有变量的所有属性:类型和值。它的类型就是指针,它的值是其他变量的地址。 既然是一个变量,那么在内存中就需要为这个变量分配一个存储空间。在这个存储空间中,存放着其他变量的地址。 指针变量所指向的数据类型,这是在定义指针变量的时候就确定的。例如:int *p; 意味着指针指向的是一个int型的数据。 首先回答一下刚才那个问题,在32位系统中,一个指针变量在内存中占据4个字节的空间。因为CPU对内存空间寻址时,使用的是32位地址空间(4个字节),也就是用4个字节就能存储一个内存单元的地址。而指针变量中的值存储的就是地址,所以需要4个字节的空间来存储一个指针变量的值。 示例: int a = 20; int *pa; pa = &a; printf("value = %d \n", *pa); 在内存中的存储模型如下: 对于指针变量pa来说,首先它是一个变量,因此在内存中需要有一个空间来存储这个变量,这个空间的地址就是0x11223348; 其次,这个内存空间中存储的内容是变量a的地址,而a的地址为0x11223344,所以指针变量pa的地址空间中,就存储了0x11223344这个值。 这里对两个操作符&和*进行说明: &:取地址操作符,用来获取一个变量的地址。上面代码中&a就是用来获取变量a在内存中的存储地址,也就是0x11223344。 *:这个操作符用在2个场景中:定义一个指针的时候,获取一个指针所指向的变量值的时候。 int pa; 这个语句中的表示定义的变量pa是一个指针,前面的int表示pa这个指针指向的是一个int类型的变量。不过此时我们没有给pa进行赋值,也就是说此刻pa对应的存储单元中的4个字节里的值是没有初始化的,可能是0x00000000,也可能是其他任意的数字,不确定; printf语句中的*表示获取pa指向的那个int类型变量的值,学名叫解引用,我们只要记住是获取指向的变量的值就可以了。 5. 操作指针变量 对指针变量的操作包括3个方面: 操作指针变量自身的值; 获取指针变量所指向的数据; 以什么样数据类型来使用/解释指针变量所指向的内容。 5.1 指针变量自身的值 int a = 20;这个语句是定义变量a,在随后的代码中,只要写下a就表示要操作变量a中存储的值,操作有两种:读和写。 printf("a = %d \n", a);这个语句就是要读取变量a中的值,当然是20; a = 100;这个语句就是要把一个数值100写入到变量a中。 同样的道理,int *pa;语句是用来定义指针变量pa,在随后的代码中,只要写下pa就表示要操作变量pa中的值: printf("pa = %d \n", pa);这个语句就是要读取指针变量pa中的值,当然是0x11223344; pa = &a;这个语句就是要把新的值写入到指针变量pa中。再次强调一下,指针变量中存储的是地址,如果我们可以提前知道变量a的地址是 0x11223344,那么我们也可以这样来赋值:pa = 0x11223344; 思考一下,如果执行这个语句printf("&pa =0x%x \n", &pa);,打印结果会是什么? 上面已经说过,操作符&是用来取地址的,那么&pa就表示获取指针变量pa的地址,上面的内存模型中显示指针变量pa是存储在0x11223348这个地址中的,因此打印结果就是:&pa = 0x11223348。 5.2 获取指针变量所指向的数据 指针变量所指向的数据类型是在定义的时候就明确的,也就是说指针pa指向的数据类型就是int型,因此在执行printf("value = %d \n", *pa);语句时,首先知道pa是一个指针,其中存储了一个地址(0x11223344),然后通过操作符*来获取这个地址(0x11223344)对应的那个存储空间中的值;又因为在定义pa时,已经指定了它指向的值是一个int型,所以我们就知道了地址0x11223344中存储的就是一个int类型的数据。 5.3 以什么样的数据类型来使用/解释指针变量所指向的内容 如下代码: int a = 30000; int *pa = &a; printf("value = %d \n", *pa); 根据以上的描述,我们知道printf的打印结果会是value = 30000,十进制的30000转成十六进制是0x00007530,内存模型如下: 现在我们做这样一个测试: char *pc = 0x11223344; printf("value = %d \n", *pc); 指针变量pc在定义的时候指明:它指向的数据类型是char型,pc变量中存储的地址是0x11223344。当使用*pc获取指向的数据时,将会按照char型格式来读取0x11223344地址处的数据,因此将会打印value = 0(在计算机中,ASCII码是用等价的数字来存储的)。 这个例子中说明了一个重要的概念:在内存中一切都是数字,如何来操作(解释)一个内存地址中的数据,完全是由我们的代码来告诉编译器的。刚才这个例子中,虽然0x11223344这个地址开始的4个字节的空间中,存储的是整型变量a的值,但是我们让pc指针按照char型数据来使用/解释这个地址处的内容,这是完全合法的。 以上内容,就是指针最根本的心法了。把这个心法整明白了,剩下的就是多见识、多练习的问题了。 三、指针的几个相关概念 1. const属性 const标识符用来表示一个对象的不可变的性质,例如定义: const int b = 20; 在后面的代码中就不能改变变量b的值了,b中的值永远是20。同样的,如果用const来修饰一个指针变量: int a = 20; int b = 20; int * const p = &a; 内存模型如下: 这里的const用来修饰指针变量p,根据const的性质可以得出结论:p在定义为变量a的地址之后,就固定了,不能再被改变了,也就是说指针变量pa中就只能存储变量a的地址0x11223344。如果在后面的代码中写p = &b;,编译时就会报错,因为p是不可改变的,不能再被设置为变量b的地址。 但是,指针变量p所指向的那个变量a的值是可以改变的,即:*p = 21;这个语句是合法的,因为指针p的值没有改变(仍然是变量c的地址0x11223344),改变的是变量c中存储的值。 与下面的代码区分一下: int a = 20; int b = 20; const int *p = &a; p = &b; 这里的const没有放在p的旁边,而是放在了类型int的旁边,这就说明const符号不是用来修饰p的,而是用来修饰p所指向的那个变量的。所以,如果我们写p = &b;把变量b的地址赋值给指针p,就是合法的,因为p的值可以被改变。 但是这个语句*p = 21就是非法了,因为定义语句中的const就限制了通过指针p获取的数据,不能被改变,只能被用来读取。这个性质常常被用在函数参数上,例如下面的代码,用来计算一块数据的CRC校验,这个函数只需要读取原始数据,不需要(也不可以)改变原始数据,因此就需要在形参指针上使用const修饰符: short int getDataCRC(const char *pData, int len) { short int crc = 0x0000; // 计算CRC return crc; } 2. void型指针 关键字void并不是一个真正的数据类型,它体现的是一种抽象,指明不是任何一种类型,一般有2种使用场景: 函数的返回值和形参; 定义指针时不明确规定所指数据的类型,也就意味着可以指向任意类型。 指针变量也是一种变量,变量之间可以相互赋值,那么指针变量之间也可以相互赋值,例如: int a = 20; int b = a; int *p1 = &a; int *p2 = p1; 变量a赋值给变量b,指针p1赋值给指针p2,注意到它们的类型必须是相同的:a和b都是int型,p1和p2都是指向int型,所以可以相互赋值。那么如果数据类型不同呢?必须进行强制类型转换。例如: int a = 20; int *p1 = &a; char *p2 = (char *)p1; 内存模型如下: p1指针指向的是int型数据,现在想把它的值(0x11223344)赋值给p2,但是由于在定义p2指针时规定它指向的数据类型是char型,因此需要把指针p1进行强制类型转换,也就是把地址0x11223344处的数据按照char型数据来看待,然后才可以赋值给p2指针。 如果我们使用void *p2来定义p2指针,那么在赋值时就不需要进行强制类型转换了,例如: int a = 20; int *p1 = &a; void *p2 = p1; 指针p2是void*型,意味着可以把任意类型的指针赋值给p2,但是不能反过来操作,也就是不能把void*型指针直接赋值给其他确定类型的指针,而必须要强制转换成被赋值指针所指向的数据类型,如下代码,必须把p2指针强制转换成int*型之后,再赋值给p3指针: int a = 20; int *p1 = &a; void *p2 = p1; int *p3 = (int *)p2; 我们来看一个系统函数: void* memcpy(void* dest, const void* src, size_t len); 第一个参数类型是void*,这正体现了系统对内存操作的真正意义:它并不关心用户传来的指针具体指向什么数据类型,只是把数据挨个存储到这个地址对应的空间中。 第二个参数同样如此,此外还添加了const修饰符,这样就说明了memcpy函数只会从src指针处读取数据,而不会修改数据。 3. 空指针和野指针 一个指针必须指向一个有意义的地址之后,才可以对指针进行操作。如果指针中存储的地址值是一个随机值,或者是一个已经失效的值,此时操作指针就非常危险了,一般把这样的指针称作野指针,C代码中很多指针相关的bug就来源于此。 3.1 空指针:不指向任何东西的指针 在定义一个指针变量之后,如果没有赋值,那么这个指针变量中存储的就是一个随机值,有可能指向内存中的任何一个地址空间,此时万万不可以对这个指针进行写操作,因为它有可能指向内存中的代码段区域、也可能指向内存中操作系统所在的区域。 一般会将一个指针变量赋值为NULL来表示一个空指针,而C语言中,NULL实质是 ((void*)0) , 在C++中,NULL实质是0。在标准库头文件stdlib.h中,有如下定义: #ifdef __cplusplus #define NULL 0 #else #define NULL ((void *)0) #endif 3.2 野指针:地址已经失效的指针 我们都知道,函数中的局部变量存储在栈区,通过malloc申请的内存空间位于堆区,如下代码: int *p = (int *)malloc(4); *p = 20; 内存模型为: 在堆区申请了4个字节的空间,然后强制类型转换为int*型之后,赋值给指针变量p,然后通过*p设置这个地址中的值为14,这是合法的。如果在释放了p指针指向的空间之后,再使用*p来操作这段地址,那就是非常危险了,因为这个地址空间可能已经被操作系统分配给其他代码使用,如果对这个地址里的数据强行操作,程序立刻崩溃的话,将会是我们最大的幸运! int *p = (int *)malloc(4); *p = 20; free(p); // 在free之后就不可以再操作p指针中的数据了。 p = NULL; // 最好加上这一句。 四、指向不同数据类型的指针 1. 数值型指针 通过上面的介绍,指向数值型变量的指针已经很明白了,需要注意的就是指针所指向的数据类型。 2. 字符串指针 字符串在内存中的表示有2种: 用一个数组来表示,例如:char name1[8] = "zhangsan"; 用一个char *指针来表示,例如:char *name2 = "zhangsan"; name1在内存中占据8个字节,其中存储了8个字符的ASCII码值;name2在内存中占据9个字节,因为除了存储8个字符的ASCII码值,在最后一个字符'n'的后面还额外存储了一个'\0',用来标识字符串结束。 对于字符串来说,使用指针来操作是非常方便的,例如:变量字符串name2: char *name2 = "zhangsan"; char *p = name2; while (*p != '\0') { printf("%c ", *p); p = p + 1; } 在while的判断条件中,检查p指针指向的字符是否为结束符'\0'。在循环体重,打印出当前指向的字符之后,对指针比那里进行自增操作,因为指针p所指向的数据类型是char,每个char在内存中占据一个字节,因此指针p在自增1之后,就指向下一个存储空间。 也可以把循环体中的2条语句写成1条语句: printf("%c ", *p++); 假如一个指针指向的数据类型为int型,那么执行p = p + 1;之后,指针p中存储的地址值将会增加4,因为一个int型数据在内存中占据4个字节的空间,如下所示: 思考一个问题:void*型指针能够递增吗?如下测试代码: int a[3] = {1, 2, 3}; void *p = a; printf("1: p = 0x%x \n", p); p = p + 1; printf("2: p = 0x%x \n", p); 打印结果如下: 1: p = 0x733748c0 2: p = 0x733748c1 说明void*型指针在自增时,是按照一个字节的跨度来计算的。 3. 指针数组与数组指针 这2个说法经常会混淆,至少我是如此,先看下这2条语句: int *p1[3]; // 指针数组 int (*p2)[3]; // 数组指针 3.1 指针数组 第1条语句中:中括号[]的优先级高,因此与p1先结合,表示一个数组,这个数组中有3个元素,这3个元素都是指针,它们指向的是int型数据。可以这样来理解:如果有这个定义char p[3],很容易理解这是一个有3个char型元素的数组,那么把char换成int*,意味着数组里的元素类型是int*型(指向int型数据的指针)。内存模型如下(注意:三个指针指向的地址并不一定是连续的): 如果向指针数组中的元素赋值,需要逐个把变量的地址赋值给指针元素: int a = 1, b = 2, c = 3; char *p1[3]; p1[0] = &a; p1[1] = &b; p1[2] = &c; 3.2 数组指针 第2条语句中:小括号让p2与*结合,表示p2是一个指针,这个指针指向了一个数组,数组中有3个元素,每一个元素的类型是int型。可以这样来理解:如果有这个定义int p[3],很容易理解这是一个有3个char型元素的数组,那么把数组名p换成是*p2,也就是p2是一个指针,指向了这个数组。内存模型如下(注意:指针指向的地址是一个数组,其中的3个元素是连续放在内存中的): 在前面我们说到取地址操作符&,用来获得一个变量的地址。凡事都有特殊情况,对于获取地址来说,下面几种情况不需要使用&操作符: 字符串字面量作为右值时,就代表这个字符串在内存中的首地址; 数组名就代表这个数组的地址,也等于这个数组的第一个元素的地址; 函数名就代表这个函数的地址。 因此,对于一下代码,三个printf语句的打印结果是相同的: int a[3] = {1, 2, 3}; int (*p2)[3] = a; printf("0x%x \n", a); printf("0x%x \n", &a); printf("0x%x \n", p2); 思考一下,如果对这里的p2指针执行p2 = p2 + 1;操作,p2中的值将会增加多少? 答案是12个字节。因为p2指向的是一个数组,这个数组中包含3个元素,每个元素占据4个字节,那么这个数组在内存中一共占据12个字节,因此p2在加1之后,就跳过12个字节。 4. 二维数组和指针 一维数组在内存中是连续分布的多个内存单元组成的,而二维数组在内存中也是连续分布的多个内存单元组成的,从内存角度来看,一维数组和二维数组没有本质差别。 和一维数组类似,二维数组的数组名表示二维数组的第一维数组中首元素的首地址,用代码来说明: int a[3][3] = {{1,2,3}, {4,5,6}, {7,8,9}}; // 二维数组 int (*p0)[3] = NULL; // p0是一个指针,指向一个数组 int (*p1)[3] = NULL; // p1是一个指针,指向一个数组 int (*p2)[3] = NULL; // p2是一个指针,指向一个数组 p0 = a[0]; p1 = a[1]; p2 = a[2]; printf("0: %d %d %d \n", *(*p0 + 0), *(*p0 + 1), *(*p0 + 2)); printf("1: %d %d %d \n", *(*p1 + 0), *(*p1 + 1), *(*p1 + 2)); printf("2: %d %d %d \n", *(*p2 + 0), *(*p2 + 1), *(*p2 + 2)); 打印结果是: 0: 1 2 3 1: 4 5 6 2: 7 8 9 我们拿第一个printf语句来分析:p0是一个指针,指向一个数组,数组中包含3个元素,每个元素在内存中占据4个字节。现在我们想获取这个数组中的数据,如果直接对p0执行加1操作,那么p0将会跨过12个字节(就等于p1中的值了),因此需要使用解引用操作符*,把p0转为指向int型的指针,然后再执行加1操作,就可以得到数组中的int型数据了。 5. 结构体指针 C语言中的基本数据类型是预定义的,结构体是用户定义的,在指针的使用上可以进行类比,唯一有区别的就是在结构体指针中,需要使用->箭头操作符来获取结构体中的成员变量,例如: typedef struct { int age; char name[8]; } Student; Student s; s.age = 20; strcpy(s.name, "lisi"); Student *p = &s; printf("age = %d, name = %s \n", p->age, p->name); 看起来似乎没有什么技术含量,如果是结构体数组呢?例如: Student s[3]; Student *p = &s; printf("size of Student = %d \n", sizeof(Student)); printf("1: 0x%x, 0x%x \n", s, p); p++; printf("2: 0x%x \n", p); 打印结果是: size of Student = 12 1: 0x4c02ac00, 0x4c02ac00 2: 0x4c02ac0c 在执行p++操作后,p需要跨过的空间是一个结构体变量在内存中占据的大小(12个字节),所以此时p就指向了数组中第2个元素的首地址,内存模型如下: 6. 函数指针 每一个函数在经过编译之后,都变成一个包含多条指令的集合,在程序被加载到内存之后,这个指令集合被放在代码区,我们在程序中使用函数名就代表了这个指令集合的开始地址。 函数指针,本质上仍然是一个指针,只不过这个指针变量中存储的是一个函数的地址。函数最重要特性是什么?可以被调用!因此,当定义了一个函数指针并把一个函数地址赋值给这个指针时,就可以通过这个函数指针来调用函数。 如下示例代码: int add(int x,int y) { return x+y; } int main() { int a = 1, b = 2; int (*p)(int, int); p = add; printf("%d + %d = %d\n", a, b, p(a, b)); } 前文已经说过,函数的名字就代表函数的地址,所以函数名add就代表了这个加法函数在内存中的地址。int (*p)(int, int);这条语句就是用来定义一个函数指针,它指向一个函数,这个函数必须符合下面这2点(学名叫:函数签名): 有2个int型的参数; 有一个int型的返回值。 代码中的add函数正好满足这个要求,因此,可以把add赋值给函数指针p,此时p就指向了内存中这个函数存储的地址,后面就可以用函数指针p来调用这个函数了。 在示例代码中,函数指针p是直接定义的,那如果想定义2个函数指针,难道需要像下面这样定义吗? int (*p)(int, int); int (*p2)(int, int); 这里的参数比较简单,如果函数很复杂,这样的定义方式岂不是要烦死?可以用typedef关键字来定义一个函数指针类型: typedef int (*pFunc)(int, int); 然后用这样的方式pFunc p1, p2;来定义多个函数指针就方便多了。注意:只能把与函数指针类型具有相同签名的函数赋值给p1和p2,也就是参数的个数、类型要相同,返回值也要相同。 注意:这里有几个小细节稍微了解一下: 在赋值函数指针时,使用p = &a;也是可以的; 使用函数指针调用时,使用(*p)(a, b);也是可以的。 这里没有什么特殊的原理需要讲解,最终都是编译器帮我们处理了这里的细节,直接记住即可。 函数指针整明白之后,再和数组结合在一起:函数指针数组。示例代码如下: int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int mul(int a, int b) { return a * b; } int divide(int a, int b) { return a / b; } int main() { int a = 4, b = 2; int (*p[4])(int, int); p[0] = add; p[1] = sub; p[2] = mul; p[3] = divide; printf("%d + %d = %d \n", a, b, p[0](a, b)); printf("%d - %d = %d \n", a, b, p[1](a, b)); printf("%d * %d = %d \n", a, b, p[2](a, b)); printf("%d / %d = %d \n", a, b, p[3](a, b)); } 这条语句不太好理解:int (*p[4])(int, int);,先分析中间部分,标识符p与中括号[]结合(优先级高),所以p是一个数组,数组中有4个元素;然后剩下的内容表示一个函数指针,那么就说明数组中的元素类型是函数指针,也就是其他函数的地址,内存模型如下: 如果还是难以理解,那就回到指针的本质概念上:指针就是一个地址!这个地址中存储的内容是什么根本不重要,重要的是你告诉计算机这个内容是什么。如果你告诉它:这个地址里存放的内容是一个函数,那么计算机就去调用这个函数。那么你是如何告诉计算机的呢,就是在定义指针变量的时候,仅此而已! 五、总结 我已经把自己知道的所有指针相关的概念、语法、使用场景都作了讲解,就像一个小酒馆的掌柜,把自己的美酒佳肴都呈现给你,但愿你已经酒足饭饱! 如果以上的内容太多,一时无法消化,那么下面的这两句话就作为饭后甜点为您奉上,在以后的编程中,如果遇到指针相关的困惑,就想一想这两句话,也许能让你茅塞顿开。 指针就是地址,地址就是指针。 指针就是指向内存中的一块空间,至于如何来解释/操作这块空间,由这个指针的类型来决定。 另外还有一点嘱咐,那就是学习任何一门编程语言,一定要弄清楚内存模型,内存模型,内存模型! 免责声明:本文内容由21ic获得授权后发布,版权归原作者所有,本平台仅提供信息存储服务。文章仅代表作者个人观点,不代表本平台立场,如有问题,请联系我们,谢谢!

    时间:2021-02-05 关键词: 指针 底层原理 C语言

  • 分享一下我是如何学习RT-Thread的

    在分享我的学习过程之前,先交代一下背景。 我是从2020年11月初开始学习RT-Thread实时操作系统的,在学习RT-Thread之前,我接触过uCOS和FreeRTOS,但这两个在单片机上应用的实时操作系统,我都没有仔细并系统化地进行学习,都只是局限于开发板上运行一下demo例程。 我以前在开发板公司工作的时候,曾经系统化地学习过Linux驱动程序,当时是买韦东山的2440驱动教程,不过很遗憾,一直没有从事过Linux驱动开发相关的岗位,学过就丢到一边了。但正是因为这段经历,让我明白了建立抽象思维的重要性。 我曾经使用嵌入式Linux操作系统,开发过两个应用项目,以前为了协助生产,也用操作系统做过一些桌面工具,所以,对于使用操作系统开发,里面涉及到的一些概念,比如多线程,内存管理,线程间同步与通信,等等,理解起来也相对容易。 以上就是我学习RT-Thread之前的经历和背景。 经过差不多两个月业余时间的学习,我已经把RT-Thread内核应用相关的知识系统化地学习了一遍,并且同时写了一些学习总结文档,如下图所示,并且在公众号做成了 专辑 | RT-Thread学习笔记 ,公众号回复【技术文档】即可下载PDF。 先用思维导图总结概括一下,学习方法不一定适用于每个人,但希望可以给你一些参考。 明确利益目标 参加工作后的学习或自我提升,其目的已经不像在大学校园里面那样单纯,大学里面基本都是学院安排好的学习计划,而作为学生,只需要按计划执行就可以了。可以说,很多大学生受限于社会经历,也不知道为啥要学习这门课程,他们只是知道这是学院要求的。 参加工作后,个人的学习和提升,都是有目的的,人性都是趋利的。你基本不可能做到不带任何目的而投入大量的时间去学习一门技能,就算是因为兴趣爱好而学,那也是为了自己的内心满足感,“寻找内心满足感”也是一种目的。 而对于大多数职场打工者的自我学习提升,无非只有两个目的:名和利。所以,在学习RT-Thread操作系统之前(其实也不局限于RT-Thread,包括很多职场技能也是如此),要先搞清楚自己为什么要学这门技能,要有明确的利益目标。 如果想不明白这个问题,只是道听途说地觉得RT-Thread或某项技能很火,也想跟风学习一下,那还是不要贸然浪费时间,因为漫无目的的学习,会很难坚持下去。 那么,我学习RT-Thread的利益目标是什么?其实很简单,是为了公众号有素材可写,同时建立自己的技术形象,然后积极拥抱开源社区,认识不同行业的大佬。通过这种学习总结+持续输出的闭环,可以让自己技术得到提升的同时,也让自己链接更多优秀的人才,如果自己能帮助更多的人,那就更加perfect了。 这种持续的学习总结输出,也为我以后求职增加了筹码,呈现给对方公司的,不再是一纸枯燥的简历,可以是我的技术博客,公众号,GitHub,开源作品,等等,这也是我的利益目标之一。 如果你还不知道如何搞清楚自己学习的利益目标,那就要先花时间好好想清楚了。而对于大多数职场打工人来说,学习技能最直接的目标,就是为了跳槽好找工作。可以把跳槽当作利益目标之一,但是别忘了,最好还是要通过展示手段,把你所学到的技能呈现到对方公司面前,而不是把技能都写在一纸简历上面。 弄清基本概念 文章里面的每一段话,是由字词句三者组成的,就像我们初中高中学习阅读理解的时候,分析一段话的含义,都是从字或词开始进行分析(文言文翻译简直是一段痛苦的经历)。所以,要学习RT-Thread操作系统,就需要先弄清楚操作系统的基本概念。 这些基本概念,在操作系统里面基本上都是相通的,不管是实时操作系统还是多任务分时操作系统。(在你看这段话的时候,脑海是不是也会有疑问,什么是多任务分时操作系统?) 为什么你会看不懂一段话,无非就是组成这段话的词语概念不理解,不妨看看下面一段话,里面红框部分都涉及了各种概念,而这些概念如果你不懂的话,是看不懂这段话的。 短短几句话里面,就包含了很多操作系统的概念,什么是线程调度器?什么是抢占式?什么是最高优先级?什么是中断服务程序?等等。。。而我能很容易看懂这几句话,是因为我有了以前的学习经历作为铺垫。 那么,在我们平时的学习中,如何搞懂这些基本概念呢?教大家一个比较实用的方法,就是在网上搜索的时候,在前面加上“如何通俗地理解xxx”,比如,“如何通俗地理解多线程”,网上就会有不少结合生活事例的参考答案。 现在终于知道为啥语文数学外语这些科目可以贯穿整个学业生涯了,学习语文可以锻炼我们的阅读理解能力,而数学可以让我们建立逻辑推导能力,学习外语,是为了在经济全球化的背景下,让自己更好地和世界交流。 建立抽象思维 为什么要建立抽象思维?人为什么能通过阅读文字或观看图片和视频来获取知识?原因就是,一个人在接收外界信息的时候,会在自己的大脑里面建立一个具体的事物影像,这个具体的事物影像在构建的时候,会依赖于你自身的社会经历以及知识丰富程度。 回到如何学习RT-Thread这个话题上面,我在学习RT-Thread的时候,首先参考的是其官网提供的学习资料,而这一大堆学习资料里面,我首先关注的,肯定是RT-Thread这个操作系统的简介和框架。 千万不要一开始就去看系统源码,也不要马上去运行demo例程,在你的大脑建立RT-Thread这个影像框架之前,看这些源码,无疑是想自己劝退自己。 为什么要先看简介和框架?因为这个框架可以有助于我建立一个关于RT-Thread的大脑影像,往后不管我学习RT-Thread的哪一块内容,都是基于这个框架的,框架是用来告诉我们,里面的代码以及模块是如何进行组织的,所以,框架很重要! 细心的人就会发现,即使是这个框架介绍,里面也涉及了很多基本概念,这样又回到了“弄清基本概念”这个环节。所以,这是一个螺旋式的过程,不断弄清概念,建立大脑影像,如此往复循环。 当你要去看操作系统代码的时候,面向对象的思维,肯定是必不可少的,这又回到了“什么是面向对象?”这个问题上了。我相信,通过网络搜索,你一定会基本明白什么是面向对象,接下来就是C语言如何实现面向对象了,具体可以查看以下文章。 C语言面向对象编程 用C语言实现面向对象编程 如何建立抽象思维?这个问题实在太难回答了,抽象思维的建立,不是一朝一夕的事情,它需要周而复始地保持一种对世界好奇的学习心态,而且很大程度取决于你的人生经历和知识体量,我能给出的建议就是,多阅读经典书籍,多观察这个世界,多总结和思考。 持续总结输出 为什么我现在那么喜欢写文章?为什么我一直建议各位持续总结输出?这是因为,一个人的认知象限里面,有以下四种状态:不知道自己不知道、知道自己不知道、不知道自己知道、知道自己知道。 关于这四种认知状态的说明,在网络上面有很多解释,这里不做重复描述。 持续地通过技术文章进行输出,可以让一个人理清自己的学习过程与学习状态,这种方式可以让一个人搞清楚学习过程里面,哪些是自己知道的,哪些是自己不知道的。我相信,很多人在最初学习一个新知识点的时候,通常都是处于“不知道自己不知道”的状态,也就是不知道自己哪些地方不懂,自然就不会向别人提问。 “好记性不如烂笔头”这个道理,我相信很多人都懂。我在2015年学习Linux驱动的时候,就开始用记事本零散地记录自己的学习过程,那时候,还没学会系统化地进行技术文章组织和输出。 直到最近这两年决定写公众号和博客,才明白系统化知识的重要性,所以公众号和博客的写作风格,都是通过技术专辑的方式来呈现,一方面是为了让自己学习不会太片面,同时让网络上的知识碎片能尽可能集中一点,方便以后自己查阅,如果能给你带来一些帮助,那是对我莫大的鼓励。 这种持续的总结输出,会给我带来一些正反馈作用,让我认识了很多优秀的同行,同时也希望我的学习总结,能给其他嵌入式的初学者带来一定参考,希望后面可以通过更多的开源项目,来回馈开源社区,算是对嵌入式技术圈子一点微不足道的贡献。 以上就是我的学习方法论,关于我的从业经历和职场感悟,也可以在公众号获取我的个人微信,跟我一起互相交流,共同进步。 感谢阅读! 免责声明:本文内容由21ic获得授权后发布,版权归原作者所有,本平台仅提供信息存储服务。文章仅代表作者个人观点,不代表本平台立场,如有问题,请联系我们,谢谢!

    时间:2021-02-05 关键词: RT-Thread C语言

  • 学了这么多年,你知道C语言发展简史吗?

    免责声明:本文内容由21ic获得授权后发布,版权归原作者所有,本平台仅提供信息存储服务。文章仅代表作者个人观点,不代表本平台立场,如有问题,请联系我们,谢谢!

    时间:2021-02-03 关键词: C语言

  • 弄懂这些指针基础知识,再遇C指针咱就不慌了

    一、前言 二、变量与指针的本质 三、指针的几个相关概念 四、指向不同数据类型的指针 五、总结 一、前言 如果问C语言中最重要、威力最大的概念是什么,答案必将是指针!威力大,意味着使用方便、高效,同时也意味着语法复杂、容易出错。指针用的好,可以极大的提高代码执行效率、节约系统资源;如果用的不好,程序中将会充满陷阱、漏洞。 这篇文章,我们就来聊聊指针。从最底层的内存存储空间开始,一直到应用层的各种指针使用技巧,循序渐进、抽丝剥茧,以最直白的语言进行讲解,让你一次看过瘾。 说明:为了方便讲解和理解,文中配图的内存空间的地址是随便写的,在实际计算机中是要遵循地址对齐方式的。 二、变量与指针的本质 1. 内存地址 我们编写一个程序源文件之后,编译得到的二进制可执行文件存放在电脑的硬盘上,此时它是一个静态的文件,一般称之为程序。 当这个程序被启动的时候,操作系统将会做下面几件事情: 把程序的内容(代码段、数据段)从硬盘复制到内存中; 创建一个数据结构PCB(进程控制块),来描述这个程序的各种信息(例如:使用的资源,打开的文件描述符...); 在代码段中定位到入口函数的地址,让CPU从这个地址开始执行。 当程序开始被执行时,就变成一个动态的状态,一般称之为进程。 内存分为:物理内存和虚拟内存。操作系统对物理内存进行管理、包装,我们开发者面对的是操作系统提供的虚拟内存。 这2个概念不妨碍文章的理解,因此就统一称之为内存。 在我们的程序中,通过一个变量名来定义变量、使用变量。变量本身是一个确确实实存在的东西,变量名是一个抽象的概念,用来代表这个变量。就比如:我是一个实实在在的人,是客观存在与这个地球上的,道哥是我给自己起的一个名字,这个名字是任意取得,只要自己觉得好听就行,如果我愿意还可以起名叫:鸟哥、龙哥等等。 那么,我们定义一个变量之后,这个变量放在哪里呢?那就是内存的数据区。内存是一个很大的存储区域,被操作系统划分为一个一个的小空间,操作系统通过地址来管理内存。 内存中的最小存储单位是字节(8个bit),一个内存的完整空间就是由这一个一个的字节连续组成的。在上图中,每一个小格子代表一个字节,但是好像大家在书籍中没有这么来画内存模型的,更常见的是下面这样的画法: 也就是把连续的4个字节的空间画在一起,这样就便于表述和理解,特别是深入到代码对齐相关知识时更容易理解。(我认为根本原因应该是:大家都这么画,已经看顺眼了~~) 2. 32位与64位系统 我们平时所说的计算机是32位、64位,指的是计算机的CPU中寄存器的最大存储长度,如果寄存器中最大存储32bit的数据,就称之为32位系统。 在计算机中,数据一般都是在硬盘、内存和寄存器之间进行来回存取。CPU通过3种总线把各组成部分联系在一起:地址总线、数据总线和控制总线。地址总线的宽度决定了CPU的寻址能力,也就是CPU能达到的最大地址范围。 刚才说了,内存是通过地址来管理的,那么CPU想从内存中的某个地址空间上存取一个数据,那么CPU就需要在地址总线上输出这个存储单元的地址。假如地址总线的宽度是8位,能表示的最大地址空间就是256个字节,能找到内存中最大的存储单元是255这个格子(从0开始)。即使内存条的实际空间是2G字节,CPU也没法使用后面的内存地址空间。如果地址总线的宽度是32位,那么能表示的最大地址就是2的32次方,也就是4G字节的空间。 【注意】:这里只是描述地址总线的概念,实际的计算机中地址计算方式要复杂的多,比如:虚拟内存中采用分段、分页、偏移量来定位实际的物理内存,在分页中还有大页、小页之分,感兴趣的同学可以自己查一下相关资料。 3. 变量 我们在C程序中使用变量来“代表”一个数据,使用函数名来“代表”一个函数,变量名和函数名是程序员使用的助记符。变量和函数最终是要放到内存中才能被CPU使用的,而内存中所有的信息(代码和数据)都是以二进制的形式来存储的,计算机根据就不会从格式上来区分哪些是代码、哪些是数据。CPU在访问内存的时候需要的是地址,而不是变量名、函数名。 问题来了:在程序代码中使用变量名来指代变量,而变量在内存中是根据地址来存放的,这二者之间如何映射(关联)起来的? 答案是:编译器!编译器在编译文本格式的C程序文件时,会根据目标运行平台(就是编译出的二进制程序运行在哪里?是x86平台的电脑?还是ARM平台的开发板?)来安排程序中的各种地址,例如:加载到内存中的地址、代码段的入口地址等等,同时编译器也会把程序中的所有变量名,转成该变量在内存中的存储地址。 变量有2个重要属性:变量的类型和变量的值。 示例:代码中定义了一个变量 int a = 20; 类型是int型,值是20。这个变量在内存中的存储模型为: 我们在代码中使用变量名a,在程序执行的时候就表示使用0x11223344地址所对应的那个存储单元中的数据。因此,可以理解为变量名a就等价于这个地址0x11223344。换句话说,如果我们可以提前知道编译器把变量a安排在地址0x11223344这个单元格中,我们就可以在程序中直接用这个地址值来操作这个变量。 在上图中,变量a的值为20,在内存中占据了4个格子的空间,也就是4个字节。为什么是4个字节呢?在C标准中并没有规定每种数据类型的变量一定要占用几个字节,这是与具体的机器、编译器有关。 比如:32位的编译器中: char: 1个字节; short int: 2个字节; int: 4个字节; long: 4个字节。 比如:64位的编译器中: char: 1个字节; short int: 2个字节; int: 4个字节; long: 8个字节。 为了方便描述,下面都以32位为例,也就是int型变量在内存中占据4个字节。 另外,0x11223344,0x11223345,0x11223346,0x11223347这连续的、从低地址到高地址的4个字节用来存储变量a的数值20。在图示中,使用十六进制来表示,十进制数值20转成16进制就是:0x00000014,所以从开始地址依次存放0x00、0x00、0x00、0x14这4个字节(存储顺序涉及到大小端的问题,不影响文本理解)。 根据这个图示,如果在程序中想知道变量a存储在内存中的什么位置,可以使用取地址操作符&,如下: printf("&a = 0x%x \n", &a); 这句话将会打印出:&a = 0x11223344。 考虑一下,在32位系统中:指针变量占用几个字节? 4. 指针变量 指针变量可以分2个层次来理解: 指针变量首先是一个变量,所以它拥有变量的所有属性:类型和值。它的类型就是指针,它的值是其他变量的地址。 既然是一个变量,那么在内存中就需要为这个变量分配一个存储空间。在这个存储空间中,存放着其他变量的地址。 指针变量所指向的数据类型,这是在定义指针变量的时候就确定的。例如:int *p; 意味着指针指向的是一个int型的数据。 首先回答一下刚才那个问题,在32位系统中,一个指针变量在内存中占据4个字节的空间。因为CPU对内存空间寻址时,使用的是32位地址空间(4个字节),也就是用4个字节就能存储一个内存单元的地址。而指针变量中的值存储的就是地址,所以需要4个字节的空间来存储一个指针变量的值。 示例: int a = 20; int *pa; pa = &a; printf("value = %d \n", *pa); 在内存中的存储模型如下: 对于指针变量pa来说,首先它是一个变量,因此在内存中需要有一个空间来存储这个变量,这个空间的地址就是0x11223348; 其次,这个内存空间中存储的内容是变量a的地址,而a的地址为0x11223344,所以指针变量pa的地址空间中,就存储了0x11223344这个值。 这里对两个操作符&和*进行说明: &:取地址操作符,用来获取一个变量的地址。上面代码中&a就是用来获取变量a在内存中的存储地址,也就是0x11223344。 *:这个操作符用在2个场景中:定义一个指针的时候,获取一个指针所指向的变量值的时候。 int pa; 这个语句中的表示定义的变量pa是一个指针,前面的int表示pa这个指针指向的是一个int类型的变量。不过此时我们没有给pa进行赋值,也就是说此刻pa对应的存储单元中的4个字节里的值是没有初始化的,可能是0x00000000,也可能是其他任意的数字,不确定; printf语句中的*表示获取pa指向的那个int类型变量的值,学名叫解引用,我们只要记住是获取指向的变量的值就可以了。 5. 操作指针变量 对指针变量的操作包括3个方面: 操作指针变量自身的值; 获取指针变量所指向的数据; 以什么样数据类型来使用/解释指针变量所指向的内容。 5.1 指针变量自身的值 int a = 20;这个语句是定义变量a,在随后的代码中,只要写下a就表示要操作变量a中存储的值,操作有两种:读和写。 printf("a = %d \n", a);这个语句就是要读取变量a中的值,当然是20; a = 100;这个语句就是要把一个数值100写入到变量a中。 同样的道理,int *pa;语句是用来定义指针变量pa,在随后的代码中,只要写下pa就表示要操作变量pa中的值: printf("pa = %d \n", pa);这个语句就是要读取指针变量pa中的值,当然是0x11223344; pa = &a;这个语句就是要把新的值写入到指针变量pa中。再次强调一下,指针变量中存储的是地址,如果我们可以提前知道变量a的地址是 0x11223344,那么我们也可以这样来赋值:pa = 0x11223344; 思考一下,如果执行这个语句printf("&pa =0x%x \n", &pa);,打印结果会是什么? 上面已经说过,操作符&是用来取地址的,那么&pa就表示获取指针变量pa的地址,上面的内存模型中显示指针变量pa是存储在0x11223348这个地址中的,因此打印结果就是:&pa = 0x11223348。 5.2 获取指针变量所指向的数据 指针变量所指向的数据类型是在定义的时候就明确的,也就是说指针pa指向的数据类型就是int型,因此在执行printf("value = %d \n", *pa);语句时,首先知道pa是一个指针,其中存储了一个地址(0x11223344),然后通过操作符*来获取这个地址(0x11223344)对应的那个存储空间中的值;又因为在定义pa时,已经指定了它指向的值是一个int型,所以我们就知道了地址0x11223344中存储的就是一个int类型的数据。 5.3 以什么样的数据类型来使用/解释指针变量所指向的内容 如下代码: int a = 30000; int *pa = &a; printf("value = %d \n", *pa); 根据以上的描述,我们知道printf的打印结果会是value = 30000,十进制的30000转成十六进制是0x00007530,内存模型如下: 现在我们做这样一个测试: char *pc = 0x11223344; printf("value = %d \n", *pc); 指针变量pc在定义的时候指明:它指向的数据类型是char型,pc变量中存储的地址是0x11223344。当使用*pc获取指向的数据时,将会按照char型格式来读取0x11223344地址处的数据,因此将会打印value = 0(在计算机中,ASCII码是用等价的数字来存储的)。 这个例子中说明了一个重要的概念:在内存中一切都是数字,如何来操作(解释)一个内存地址中的数据,完全是由我们的代码来告诉编译器的。刚才这个例子中,虽然0x11223344这个地址开始的4个字节的空间中,存储的是整型变量a的值,但是我们让pc指针按照char型数据来使用/解释这个地址处的内容,这是完全合法的。 以上内容,就是指针最根本的心法了。把这个心法整明白了,剩下的就是多见识、多练习的问题了。 三、指针的几个相关概念 1. const属性 const标识符用来表示一个对象的不可变的性质,例如定义: const int b = 20; 在后面的代码中就不能改变变量b的值了,b中的值永远是20。同样的,如果用const来修饰一个指针变量: int a = 20; int b = 20; int * const p = &a; 内存模型如下: 这里的const用来修饰指针变量p,根据const的性质可以得出结论:p在定义为变量a的地址之后,就固定了,不能再被改变了,也就是说指针变量pa中就只能存储变量a的地址0x11223344。如果在后面的代码中写p = &b;,编译时就会报错,因为p是不可改变的,不能再被设置为变量b的地址。 但是,指针变量p所指向的那个变量a的值是可以改变的,即:*p = 21;这个语句是合法的,因为指针p的值没有改变(仍然是变量c的地址0x11223344),改变的是变量c中存储的值。 与下面的代码区分一下: int a = 20; int b = 20; const int *p = &a; p = &b; 这里的const没有放在p的旁边,而是放在了类型int的旁边,这就说明const符号不是用来修饰p的,而是用来修饰p所指向的那个变量的。所以,如果我们写p = &b;把变量b的地址赋值给指针p,就是合法的,因为p的值可以被改变。 但是这个语句*p = 21就是非法了,因为定义语句中的const就限制了通过指针p获取的数据,不能被改变,只能被用来读取。这个性质常常被用在函数参数上,例如下面的代码,用来计算一块数据的CRC校验,这个函数只需要读取原始数据,不需要(也不可以)改变原始数据,因此就需要在形参指针上使用const修饰符: short int getDataCRC(const char *pData, int len) { short int crc = 0x0000; // 计算CRC return crc; } 2. void型指针 关键字void并不是一个真正的数据类型,它体现的是一种抽象,指明不是任何一种类型,一般有2种使用场景: 函数的返回值和形参; 定义指针时不明确规定所指数据的类型,也就意味着可以指向任意类型。 指针变量也是一种变量,变量之间可以相互赋值,那么指针变量之间也可以相互赋值,例如: int a = 20; int b = a; int *p1 = &a; int *p2 = p1; 变量a赋值给变量b,指针p1赋值给指针p2,注意到它们的类型必须是相同的:a和b都是int型,p1和p2都是指向int型,所以可以相互赋值。那么如果数据类型不同呢?必须进行强制类型转换。例如: int a = 20; int *p1 = &a; char *p2 = (char *)p1; 内存模型如下: p1指针指向的是int型数据,现在想把它的值(0x11223344)赋值给p2,但是由于在定义p2指针时规定它指向的数据类型是char型,因此需要把指针p1进行强制类型转换,也就是把地址0x11223344处的数据按照char型数据来看待,然后才可以赋值给p2指针。 如果我们使用void *p2来定义p2指针,那么在赋值时就不需要进行强制类型转换了,例如: int a = 20; int *p1 = &a; void *p2 = p1; 指针p2是void*型,意味着可以把任意类型的指针赋值给p2,但是不能反过来操作,也就是不能把void*型指针直接赋值给其他确定类型的指针,而必须要强制转换成被赋值指针所指向的数据类型,如下代码,必须把p2指针强制转换成int*型之后,再赋值给p3指针: int a = 20; int *p1 = &a; void *p2 = p1; int *p3 = (int *)p2; 我们来看一个系统函数: void* memcpy(void* dest, const void* src, size_t len); 第一个参数类型是void*,这正体现了系统对内存操作的真正意义:它并不关心用户传来的指针具体指向什么数据类型,只是把数据挨个存储到这个地址对应的空间中。 第二个参数同样如此,此外还添加了const修饰符,这样就说明了memcpy函数只会从src指针处读取数据,而不会修改数据。 3. 空指针和野指针 一个指针必须指向一个有意义的地址之后,才可以对指针进行操作。如果指针中存储的地址值是一个随机值,或者是一个已经失效的值,此时操作指针就非常危险了,一般把这样的指针称作野指针,C代码中很多指针相关的bug就来源于此。 3.1 空指针:不指向任何东西的指针 在定义一个指针变量之后,如果没有赋值,那么这个指针变量中存储的就是一个随机值,有可能指向内存中的任何一个地址空间,此时万万不可以对这个指针进行写操作,因为它有可能指向内存中的代码段区域、也可能指向内存中操作系统所在的区域。 一般会将一个指针变量赋值为NULL来表示一个空指针,而C语言中,NULL实质是 ((void*)0) , 在C++中,NULL实质是0。在标准库头文件stdlib.h中,有如下定义: #ifdef __cplusplus #define NULL 0 #else #define NULL ((void *)0) #endif 3.2 野指针:地址已经失效的指针 我们都知道,函数中的局部变量存储在栈区,通过malloc申请的内存空间位于堆区,如下代码: int *p = (int *)malloc(4); *p = 20; 内存模型为: 在堆区申请了4个字节的空间,然后强制类型转换为int*型之后,赋值给指针变量p,然后通过*p设置这个地址中的值为14,这是合法的。如果在释放了p指针指向的空间之后,再使用*p来操作这段地址,那就是非常危险了,因为这个地址空间可能已经被操作系统分配给其他代码使用,如果对这个地址里的数据强行操作,程序立刻崩溃的话,将会是我们最大的幸运! int *p = (int *)malloc(4); *p = 20; free(p); // 在free之后就不可以再操作p指针中的数据了。 p = NULL; // 最好加上这一句。 四、指向不同数据类型的指针 1. 数值型指针 通过上面的介绍,指向数值型变量的指针已经很明白了,需要注意的就是指针所指向的数据类型。 2. 字符串指针 字符串在内存中的表示有2种: 用一个数组来表示,例如:char name1[8] = "zhangsan"; 用一个char *指针来表示,例如:char *name2 = "zhangsan"; name1在内存中占据8个字节,其中存储了8个字符的ASCII码值;name2在内存中占据9个字节,因为除了存储8个字符的ASCII码值,在最后一个字符'n'的后面还额外存储了一个'\0',用来标识字符串结束。 对于字符串来说,使用指针来操作是非常方便的,例如:变量字符串name2: char *name2 = "zhangsan"; char *p = name2; while (*p != '\0') { printf("%c ", *p); p = p + 1; } 在while的判断条件中,检查p指针指向的字符是否为结束符'\0'。在循环体重,打印出当前指向的字符之后,对指针比那里进行自增操作,因为指针p所指向的数据类型是char,每个char在内存中占据一个字节,因此指针p在自增1之后,就指向下一个存储空间。 也可以把循环体中的2条语句写成1条语句: printf("%c ", *p++); 假如一个指针指向的数据类型为int型,那么执行p = p + 1;之后,指针p中存储的地址值将会增加4,因为一个int型数据在内存中占据4个字节的空间,如下所示: 思考一个问题:void*型指针能够递增吗?如下测试代码: int a[3] = {1, 2, 3}; void *p = a; printf("1: p = 0x%x \n", p); p = p + 1; printf("2: p = 0x%x \n", p); 打印结果如下: 1: p = 0x733748c0 2: p = 0x733748c1 说明void*型指针在自增时,是按照一个字节的跨度来计算的。 3. 指针数组与数组指针 这2个说法经常会混淆,至少我是如此,先看下这2条语句: int *p1[3]; // 指针数组 int (*p2)[3]; // 数组指针 3.1 指针数组 第1条语句中:中括号[]的优先级高,因此与p1先结合,表示一个数组,这个数组中有3个元素,这3个元素都是指针,它们指向的是int型数据。可以这样来理解:如果有这个定义char p[3],很容易理解这是一个有3个char型元素的数组,那么把char换成int*,意味着数组里的元素类型是int*型(指向int型数据的指针)。内存模型如下(注意:三个指针指向的地址并不一定是连续的): 如果向指针数组中的元素赋值,需要逐个把变量的地址赋值给指针元素: int a = 1, b = 2, c = 3; char *p1[3]; p1[0] = &a; p1[1] = &b; p1[2] = &c; 3.2 数组指针 第2条语句中:小括号让p2与*结合,表示p2是一个指针,这个指针指向了一个数组,数组中有3个元素,每一个元素的类型是int型。可以这样来理解:如果有这个定义int p[3],很容易理解这是一个有3个char型元素的数组,那么把数组名p换成是*p2,也就是p2是一个指针,指向了这个数组。内存模型如下(注意:指针指向的地址是一个数组,其中的3个元素是连续放在内存中的): 在前面我们说到取地址操作符&,用来获得一个变量的地址。凡事都有特殊情况,对于获取地址来说,下面几种情况不需要使用&操作符: 字符串字面量作为右值时,就代表这个字符串在内存中的首地址; 数组名就代表这个数组的地址,也等于这个数组的第一个元素的地址; 函数名就代表这个函数的地址。 因此,对于一下代码,三个printf语句的打印结果是相同的: int a[3] = {1, 2, 3}; int (*p2)[3] = a; printf("0x%x \n", a); printf("0x%x \n", &a); printf("0x%x \n", p2); 思考一下,如果对这里的p2指针执行p2 = p2 + 1;操作,p2中的值将会增加多少? 答案是12个字节。因为p2指向的是一个数组,这个数组中包含3个元素,每个元素占据4个字节,那么这个数组在内存中一共占据12个字节,因此p2在加1之后,就跳过12个字节。 4. 二维数组和指针 一维数组在内存中是连续分布的多个内存单元组成的,而二维数组在内存中也是连续分布的多个内存单元组成的,从内存角度来看,一维数组和二维数组没有本质差别。 和一维数组类似,二维数组的数组名表示二维数组的第一维数组中首元素的首地址,用代码来说明: int a[3][3] = {{1,2,3}, {4,5,6}, {7,8,9}}; // 二维数组 int (*p0)[3] = NULL; // p0是一个指针,指向一个数组 int (*p1)[3] = NULL; // p1是一个指针,指向一个数组 int (*p2)[3] = NULL; // p2是一个指针,指向一个数组 p0 = a[0]; p1 = a[1]; p2 = a[2]; printf("0: %d %d %d \n", *(*p0 + 0), *(*p0 + 1), *(*p0 + 2)); printf("1: %d %d %d \n", *(*p1 + 0), *(*p1 + 1), *(*p1 + 2)); printf("2: %d %d %d \n", *(*p2 + 0), *(*p2 + 1), *(*p2 + 2)); 打印结果是: 0: 1 2 3 1: 4 5 6 2: 7 8 9 我们拿第一个printf语句来分析:p0是一个指针,指向一个数组,数组中包含3个元素,每个元素在内存中占据4个字节。现在我们想获取这个数组中的数据,如果直接对p0执行加1操作,那么p0将会跨过12个字节(就等于p1中的值了),因此需要使用解引用操作符*,把p0转为指向int型的指针,然后再执行加1操作,就可以得到数组中的int型数据了。 5. 结构体指针 C语言中的基本数据类型是预定义的,结构体是用户定义的,在指针的使用上可以进行类比,唯一有区别的就是在结构体指针中,需要使用->箭头操作符来获取结构体中的成员变量,例如: typedef struct { int age; char name[8]; } Student; Student s; s.age = 20; strcpy(s.name, "lisi"); Student *p = &s; printf("age = %d, name = %s \n", p->age, p->name); 看起来似乎没有什么技术含量,如果是结构体数组呢?例如: Student s[3]; Student *p = &s; printf("size of Student = %d \n", sizeof(Student)); printf("1: 0x%x, 0x%x \n", s, p); p++; printf("2: 0x%x \n", p); 打印结果是: size of Student = 12 1: 0x4c02ac00, 0x4c02ac00 2: 0x4c02ac0c 在执行p++操作后,p需要跨过的空间是一个结构体变量在内存中占据的大小(12个字节),所以此时p就指向了数组中第2个元素的首地址,内存模型如下: 6. 函数指针 每一个函数在经过编译之后,都变成一个包含多条指令的集合,在程序被加载到内存之后,这个指令集合被放在代码区,我们在程序中使用函数名就代表了这个指令集合的开始地址。 函数指针,本质上仍然是一个指针,只不过这个指针变量中存储的是一个函数的地址。函数最重要特性是什么?可以被调用!因此,当定义了一个函数指针并把一个函数地址赋值给这个指针时,就可以通过这个函数指针来调用函数。 如下示例代码: int add(int x,int y) { return x+y; } int main() { int a = 1, b = 2; int (*p)(int, int); p = add; printf("%d + %d = %d\n", a, b, p(a, b)); } 前文已经说过,函数的名字就代表函数的地址,所以函数名add就代表了这个加法函数在内存中的地址。int (*p)(int, int);这条语句就是用来定义一个函数指针,它指向一个函数,这个函数必须符合下面这2点(学名叫:函数签名): 有2个int型的参数; 有一个int型的返回值。 代码中的add函数正好满足这个要求,因此,可以把add赋值给函数指针p,此时p就指向了内存中这个函数存储的地址,后面就可以用函数指针p来调用这个函数了。 在示例代码中,函数指针p是直接定义的,那如果想定义2个函数指针,难道需要像下面这样定义吗? int (*p)(int, int); int (*p2)(int, int); 这里的参数比较简单,如果函数很复杂,这样的定义方式岂不是要烦死?可以用typedef关键字来定义一个函数指针类型: typedef int (*pFunc)(int, int); 然后用这样的方式pFunc p1, p2;来定义多个函数指针就方便多了。注意:只能把与函数指针类型具有相同签名的函数赋值给p1和p2,也就是参数的个数、类型要相同,返回值也要相同。 注意:这里有几个小细节稍微了解一下: 在赋值函数指针时,使用p = &a;也是可以的; 使用函数指针调用时,使用(*p)(a, b);也是可以的。 这里没有什么特殊的原理需要讲解,最终都是编译器帮我们处理了这里的细节,直接记住即可。 函数指针整明白之后,再和数组结合在一起:函数指针数组。示例代码如下: int add(int a, int b) { return a + b; } int sub(int a, int b) { return a - b; } int mul(int a, int b) { return a * b; } int divide(int a, int b) { return a / b; } int main() { int a = 4, b = 2; int (*p[4])(int, int); p[0] = add; p[1] = sub; p[2] = mul; p[3] = divide; printf("%d + %d = %d \n", a, b, p[0](a, b)); printf("%d - %d = %d \n", a, b, p[1](a, b)); printf("%d * %d = %d \n", a, b, p[2](a, b)); printf("%d / %d = %d \n", a, b, p[3](a, b)); } 这条语句不太好理解:int (*p[4])(int, int);,先分析中间部分,标识符p与中括号[]结合(优先级高),所以p是一个数组,数组中有4个元素;然后剩下的内容表示一个函数指针,那么就说明数组中的元素类型是函数指针,也就是其他函数的地址,内存模型如下: 如果还是难以理解,那就回到指针的本质概念上:指针就是一个地址!这个地址中存储的内容是什么根本不重要,重要的是你告诉计算机这个内容是什么。如果你告诉它:这个地址里存放的内容是一个函数,那么计算机就去调用这个函数。那么你是如何告诉计算机的呢,就是在定义指针变量的时候,仅此而已! 五、总结 我已经把自己知道的所有指针相关的概念、语法、使用场景都作了讲解,就像一个小酒馆的掌柜,把自己的美酒佳肴都呈现给你,但愿你已经酒足饭饱! 如果以上的内容太多,一时无法消化,那么下面的这两句话就作为饭后甜点为您奉上,在以后的编程中,如果遇到指针相关的困惑,就想一想这两句话,也许能让你茅塞顿开。 指针就是地址,地址就是指针。 指针就是指向内存中的一块空间,至于如何来解释/操作这块空间,由这个指针的类型来决定。 另外还有一点嘱咐,那就是学习任何一门编程语言,一定要弄清楚内存模型,内存模型,内存模型! 祝您好运! 免责声明:本文内容由21ic获得授权后发布,版权归原作者所有,本平台仅提供信息存储服务。文章仅代表作者个人观点,不代表本平台立场,如有问题,请联系我们,谢谢!

    时间:2021-01-30 关键词: 内存 指针 C语言

  • 常用的排序算法(C语言)

    01 前言 排序是数据处理中经常运用的一种重要运算,排序的功能是将一个数据元素(记录)的任意序列,重新排列成一个按照一个规则有序的序列。常用的排序算法我们要熟练掌握。 02 冒泡排序 冒泡排序(英语:Bubble Sort)是一种简单的排序算法。它重复地走访过要排序的数列,一次比较两个元素,如果他们的顺序(如从大到小、首字母从A到Z)错误就把他们交换过来。 示例: #include void bubble_sort(int arr[], int len) { int i, j, temp; for (i = 0; i < len - 1; i++) for (j = 0; j < len - 1 - i; j++) if (arr[j] > arr[j + 1]) { temp = arr[j]; arr[j] = arr[j + 1]; arr[j + 1] = temp; }} int main() { int arr[] = { 22, 34, 3, 32, 82, 55, 89, 50, 37, 5, 64, 35, 9, 70 }; int len = (int) sizeof(arr) / sizeof(*arr); bubble_sort(arr, len); int i; for (i = 0; i < len; i++) printf("%d ", arr[i]); return 0; } 03 选择排序 选择排序(Selection sort)是一种简单直观的排序算法。它的工作原理如下。首先在未排序序列中找到最小(大)元素,存放到排序序列的起始位置,然后,再从剩余未排序元素中继续寻找最小(大)元素,然后放到已排序序列的末尾。以此类推,直到所有元素均排序完毕。 示例: void swap(int *a,int *b) { int temp = *a; *a = *b; *b = temp;} void selection_sort(int arr[], int len) { int i,j; for (i = 0 ; i < len - 1 ; i++) { int min = i; for (j = i + 1; j < len; j++) //走访未排序列 if (arr[j] < arr[min]) //找到目前最小值 min = j; //记录最小值序号 swap(&arr[min], &arr[i]); //做交換 }} 04 插入排序 插入排序(英语:Insertion Sort)是一种简单直观的排序算法。它的工作原理是通过构建有序序列,对于未排序数据,在已排序序列中从后向前扫描,找到相应位置并插入。插入排序在实现上,通常采用in-place排序,因而在从后向前扫描过程中,需要反复把已排序元素逐步向后挪位,为最新元素提供插入空间。 示例: void insertion_sort(int arr[], int len){ int i,j,temp; for (i=1;i

    时间:2021-01-23 关键词: 排序算法 C语言

  • C语言回调函数详解

    1、什么是回调函数? 回调函数,光听名字就比普通函数要高大上一些,那到底什么是回调函数呢?恕我读得书少,没有在那本书上看到关于回调函数的定义。我在百度上搜了一下,发现众说纷纭,有很大一部分都是使用类似这么一个场景来说明:A君去B君店里买东西,恰好缺货,A君留下号码给B君,有货时通知A君。感觉这个让人更容易想到的是异步操作,而不是回调。另外还有两句英文让我印象深刻: 1) If you call me, I will call you back; 2) Don't call me, I will call you. 看起来好像很有道理,但是仔细一想,普通函数不也可以做到这两点吗?所以,我觉得这样的说法都不是很妥当,因为我觉得这些说法都没有把回调函数的特点表达出来,也就是都看不到和普通函数到底有什么差别。不过,百度百科的解析我觉得还算不错(虽然经常吐槽百度搜索...):回调函数就是一个通过函数指针调用的函数。如果你把函数的指针(地址)作为参数传递给另一个函数,当这个指针被用来调用其所指向的函数时,我们就说这是回调函数。 下面先说说我的看法。我们可以先在字面上先做个分解,对于"回调函数",中文其实可以理解为这么两种意思: 1) 被回调的函数; 2) 回头执行调用动作的函数。那这个回头调用又是什么鬼? 先来看看来自维基百科的对回调(Callback)的解析:In computer programming, a callback is any executable code that is passed as an argument to other code, which is expected to call back (execute) the argument at a given time. This execution may be immediate as in a synchronous callback, or it might happen at a later time as in an asynchronous callback. 也就是说,把一段可执行的代码像参数传递那样传给其他代码,而这段代码会在某个时刻被调用执行,这就叫做回调。如果代码立即被执行就称为同步回调,如果在之后晚点的某个时间再执行,则称之为异步回调。关于同步和异步,这里不作讨论,请查阅相关资料。 再来看看来自Stack Overflow某位大神简洁明了的表述:A "callback" is any function that is called by another function which takes the first function as a parameter。也就是说,函数 F1 调用函数 F2 的时候,函数 F1 通过参数给 函数 F2 传递了另外一个函数 F3 的指针,在函数 F2 执行的过程中,函数F2 调用了函数 F3,这个动作就叫做回调(Callback),而先被当做指针传入、后面又被回调的函数 F3 就是回调函数。到此应该明白回调函数的定义了吧? 2、为什么要使用回调函数? 很多朋友可能会想,为什么不像普通函数调用那样,在回调的地方直接写函数的名字呢?这样不也可以吗?为什么非得用回调函数呢?有这个想法很好,因为在网上看到解析回调函数的很多例子,其实完全可以用普通函数调用来实现的。要回答这个问题,我们先来了解一下回到函数的好处和作用,那就是解耦,对,就是这么简单的答案,就是因为这个特点,普通函数代替不了回调函数。所以,在我眼里,这才是回调函数最大的特点。来看看维基百科上面我觉得画得很好的一张图片。 下面以一段不完整的 C 语言代码来呈现上图的意思: #include#include // 包含Library Function所在读得Software library库的头文件 int Callback() // Callback Function{ // TODO return 0;}int main() // Main program{ // TODO Library(Callback); // TODO return 0;} 乍一看,回调似乎只是函数间的调用,和普通函数调用没啥区别,但仔细一看,可以发现两者之间的一个关键的不同:在回调中,主程序把回调函数像参数一样传入库函数。这样一来,只要我们改变传进库函数的参数,就可以实现不同的功能,这样有没有觉得很灵活?并且丝毫不需要修改库函数的实现,这就是解耦。再仔细看看,主函数和回调函数是在同一层的,而库函数在另外一层,想一想,如果库函数对我们不可见,我们修改不了库函数的实现,也就是说不能通过修改库函数让库函数调用普通函数那样实现,那我们就只能通过传入不同的回调函数了,这也就是在日常工作中常见的情况。现在再把main()、Library()和Callback()函数套回前面 F1、F2和F3函数里面,是不是就更明白了? 明白了回调函数的特点,是不是也可以大概知道它应该在什么情况下使用了?没错,你可以在很多地方使用回调函数来代替普通的函数调用,但是在我看来,如果需要降低耦合度的时候,更应该使用回调函数。 3、怎么使用回调函数? 知道了什么是回调函数,了解了回调函数的特点,那么应该怎么使用回调函数?下面来看一段简单的可以执行的同步回调函数代码: #include int Callback_1() // Callback Function 1{ printf("Hello, this is Callback_1 "); return 0;} int Callback_2() // Callback Function 2{ printf("Hello, this is Callback_2 "); return 0;} int Callback_3() // Callback Function 3{ printf("Hello, this is Callback_3 "); return 0;} int Handle(int (*Callback)()){ printf("Entering Handle Function. "); Callback(); printf("Leaving Handle Function. ");} int main(){ printf("Entering Main Function. "); Handle(Callback_1); Handle(Callback_2); Handle(Callback_3); printf("Leaving Main Function. "); return 0;} https://www.cnblogs.com/jiangzhaowei/p/9129105.html  免责声明:本文内容由21ic获得授权后发布,版权归原作者所有,本平台仅提供信息存储服务。文章仅代表作者个人观点,不代表本平台立场,如有问题,请联系我们,谢谢!

    时间:2021-01-22 关键词: 嵌入式 回调函数 C语言

首页  上一页  1 2 3 4 5 6 7 8 9 10 下一页 尾页
发布文章

技术子站

更多

项目外包