C语言的一些“骚操作”及其深层理解
内容目录
讲解嵌入式C语言的编程技巧,并进行深入解析。
一、隐藏的死循环
有时候,我们会发现for循环变成了一个死循环:
|
|
我们本希望循环5次,然后结束,但实际情况是陷入了死循环。这种错误在实际开发中,还比较难发现。其原因在于i的类型,无符号整型是永远不小于0的。
我们需要将i的类型改为有符号型。
|
|
OK,这样就对了。细节虽小,但对实际开发的影响还是蛮大的,请大家引以为戒。
下面的两个例子中for循环也是死循环,请自行分析:
例1:
|
|
提示:i 的数据类型。
例2:
|
|
提示:这个例子,不光会死循环,而且还可能会让程序直接崩溃。判等的 == 你会不会经常直接写错成 =(赋值表达式)。
二、看似多余的空循环
有时我们会看到这样的代码:
|
|
代码本身实际只运行了一次,为什么要在它外面加一层do while呢?这看似是多余的。其实不然,我们来看下面例子:
|
|
while(1) DO_SOMETHING; 本意应该是不断调用 fun1 和 fun2,但实际上只有 fun1 得到运行。其中原因大家应该明白。
所以,我们可以这样来写:
|
|
do while就如同一个框架把要运行的代码框起来,成为一个整体。
三、独立执行体
我在C语言编程的过程中,经常乐于使用一种“局部独立化”的方式,我称之为“独立执行体”,如下例:
|
|
在编程时,我们经常需要解决一些小问题,比如想对一些数据进行临时性的处理,查看中间结果;或是临时性的突发奇想,试探性的作一些小算法。
这过程中可能需要独立的变量,以及独立于主体程序的执行逻辑,但又觉得不至于去专门定义一个函数,只是想一带而过。比如上例,函数fun主要对a、b、c这3个参数进行计算(使用某种算法),过程中想临时看一下a和b谁比较大,由第一个“独立执行体”来完成,其中的代码由自己的{}扩起来。
其实我们可以更深层的去理解C语言中的{},它为我们开辟了一个可自由编程的独立空间。在{}里,可以定义变量,可以调用函数以及访问外层代码中的变量,可以作宏定义等等。平时我们使用的函数,它的{}部分其实就是一个“独立执行体”。
“独立执行体”的思想,也许可以让我们编程更加灵活方便,可以随时让我们直接得到一块自由编程的静土。
上一节中的do while(0),其实完全可以把do while(0)去掉,只用{}即可:
|
|
另外,它还有一个好处,那就是当你不需要这段代码的时候,你可以直接在{}前面加上if(0)即可。一个“独立执行体”的外层是可以受if、do while、while、for等这些条件控制的。
四、多用()无坏处
!0+1,它的值等于多少?其实连我这样的老手也不能马上给出答案,2还是0?按C语言规定的运算符优先级来说,应该是!大于+,所以结果应该是2。
但如果把它放在宏里,有时候就开始坑人了:
|
|
踩过此类坑的人无需多说,自能领会。a=2&0呢,还是a=!0+1&0呢?它们的值截然不同。
这里出现了一些运算优先级和结合律的差错。为了让我们的语义和意图正确的得以表达,所以建议多用一些()。
|
|
这样,a的值就一定是0了。
另外,有时候优先级还与 C 语言编译器有关,同一个表达式在不同的平台上,可能表达的意义是不同的。所以,为了代码的可植移性、正确性以及可读性,强烈建议多用一些()。
五、==的反向测试
C语言中的 = 与 ==,有时候是一个大坑。主要体现在条件判断时的值比较,如下例:
|
|
也许我们的原意是判断a若为1,则执行。但实际上if根本不起作用,因为错把==写成了=。
C语言中的赋值操作也是一种表达式,称为赋值表达式,它的值即为赋值后变量的值。而C语言中条件判断又是一种宽泛的判断,即非0为真,为0则假。所以if(a=1)这样的代码编译是不会报错的。
这种错误通常是很难排查出来的,尤其是在复杂的算法中,只能一行行代码的跟踪。所以对于变量值的比较判断,振南建议使用“==的反向测试”,并养成习惯。
|
|
如果把 == 错写成了 =,因为常量无法被赋值,所以编译时会报错。
六、赋值操作的实质
原来一位哈工程理学院教授(搞数学的)讲述了自己的一个困惑,一直以来都被我们当成一个笑话在说。他学C语言的时候,首先a=1,然后后面又来一个a=2,这让他非常不解,a 怎么可能同样等于 1 又等于 2 呢?
其实这是因为他对计算机运行机制不了解,这个a不是他数学稿纸上的代数变量,而是计算机中实实在在的“电”,或者说“信号”,如下图所示。
其实不限于C语言,所有编程语言的目的都是控制计算机硬件,实现电信号的传输、存储等操作,最终达成某一功能。
变量是存储器中的一小块空间,它源自于形如int a这样的代码编译后由编译器所作的存储器分配。对变量的赋值就是CPU内核通过三总线将数据传输到存储器特定地址单元上的过程。所以,a=1;a=2;只是两次数据传输过程而已。
这个教授当时算是个外行,其实对于我们也是一样的,想要真正掌握编程语言,只流于代码表面的意思是不行的,必须对它在硬件上产生的操作有明确的认识,对计算机内部的运行机理有深入理解才可以。
七、关于补码
补码是一个很基础的概念,但对于很多人来说,其实有些迷糊,这里对补码进行一些通俗而深刻的讲解。
C语言中的整型类型有两种,无符号与有符号。无符号比较好理解,如下图所示。
只需要将每一个位乘以它的权值,再求和即是其所表达的数值。它所有的位都用来表达数值,因此上图中类型能表达的范围为0~255(8个位)。但如何表达负数,比如-10,这个时候就涉及到补码了,如下图所示。
有符号整型的最高位被定义为符号位,0 为正数,1为负数。上图中前一行等于+76,后一行等于多少?-76?那就错了。
对于负数的数值要按其补码来计算,如下图所示。
为什么要引入补码的概念,符号位表示符号,其它位直接表示其绝对值,不是更好吗?这其实是一个数字游戏。我们要知道一个前提:CPU中只有加法器,而没有减法器。OK,我们看下面的例子。
可以看到,补码将符号位也统一到了计算过程中,并且巧妙地使用加法实现了减法操作。这对于简化CPU中的算术逻辑电路(ALU)具有重要意义。
八、关于-1
为了说明关于 -1 的问题,我们先来看一个例子:
|
|
这个if条件成立吗?似乎这是一句废话。其实不然,它不一定成立。
我们要知道C语言中的判等 == 运算是一种强匹配,也就是比较的双方必须每一个位都匹配才被认为相等。在上例中,a在内存中的表示是 0XFFFF(补码),但 -1 这个常量在内存中的表示在不同的硬件平台上却不尽相同,在16位CPU平台上是0XFFFF,它们是相等的。而在32位CPU平台上则是0XFFFFFFFF,它们就不相等。
所以,稳妥的办法是:
|
|
我们看到 -1 的补码是全 F,而且位数与 CPU 平台相关。所以-1经常还有另一个妙用,即可以用于判断硬件平台的 CPU 位数,便于提高代码的可移植性(32位平台的int(-1)为0XFFFFFFFF,而16位平台则是0XFFFF)。
九、字节快速位逆序
一道有意思的题目:如何快速得到一个字节的位逆序字节。比如 0X33 的位逆序字节是 0XCC。
有人给了我这样一段代码:
|
|
这段代码很简洁,也很巧妙。但它却不是最快的。后来作了改进:
|
|
这样把循环打开,确实会提速不少。但它仍不是最快的实现方案。请看如下代码:
|
|
恍然大悟了没有?使用字节数组事先准备好位逆序字节,然后直接以字节的值为下标索引,直接取数据即可。这种方法被称为“空间换时间”。
这个问题我问过很多人,多数人并不能直接给出最佳方案。倒是有不少人问我这个问题有什么实际意义,为什么要去计算位逆序字节?请大家想想,如果我们把电路上的数据总线焊反或插反了该怎么解决。
十、关于volatile
现在的编译器越来越智能,它们会对我们的代码进行不同程度的优化。请看下例:
|
|
这样一段代码,有些编译器会认为 a=1 与 a=2 根本就是毫无意义,会把它们优化掉,只剩下 a=3。但有些时候,这段代码是有特殊用途的:
|
|
a不单单是一个变量,而是一个外部总线的端口(51平台)。向它赋值会产生相应的外部总线上的时序输出,从而对外部器件实现控制。
这种时候,a=1和a=2不能被优化掉。举个例子:a所指向的外部总线端口,是一个电机控制器的接口,向它写入1是加速,写入2是减速,写入3是反向。那么上面的代码就是加速->减速->反向,这样一个控制过程。如果被优化的话,那最后就只有反向了。
为了防止这种被“意外”伦的情况发生,我们可以在变量的定义上加一个修饰词volatile。
|
|
这样,编译器就会对它单独对待,不再优化了。
volatile最常出现的地方,就是对芯片中寄存器的定义,比如 STM32 固件库中有这样的代码:
|
|
这是对 STM32 的 GPIO 寄存器组的定义,每一项都是一个__IO类型,其实就是 volatile。这样是为了对片内外设的物理寄存器的访问一定是真正落实的,而不是经过编译器优化,而变成去访问缓存之类的东西。
十一、关于变量互换
初学C语言的时候,有一个小编程题我们应该都记得,就是变量互换。
|
|
变量a与b的值互换,在这过程中一定需要一个中间变量temp作为中转。不用这个中间变量能不能实现?请看下面的代码:
|
|
可以说上面代码有点小巧妙,那么下面的代码就真正是巧妙了:
|
|
异或运算有一个性质叫自反性,这个可以实现很多巧妙的操作,大家可以深入研究一下(异或位运算比上面的加减法更严谨,因为加减法是可能会溢出的)。
十二、关于sizeof
C语言中的sizeof我们应该是非常熟悉的,它的作用就是用来计算一个变量或类型所占用的字节数。
|
|
这个很简单,我们再来看下面的代码:
|
|
pc 用来指向 char 类型的变量。
pc 本身是一个指针类型,在32位平台上sizeof(pc)的值为4,即指针类型占用4个字节(与CPU平台有关)。*pc是pc所指向的变量,所以sizeof(*pc)的值为1。
好,还能理解吧,那我们再来看:
|
|
第一个sizeof(a1)等于5,因为它是一个数组(最后还有一个字符串结束符’\0’)。第二个sizeof(a1)等于4,形参中的a1不再是一个数组,而是一个指针。
好,下面的实例估计很多人没见到过:
|
|
空结构体类型变量的大小是多少?这个问题似乎有些奇葩,没什么实用性。空结构体有什么用?
这个问题可以揭示一些比较深层的问题,我们平时注意不到。空结构体的大小是1,即占用1个字节。
当我们的程序还仅仅是一个框架的时候,一些结构体还只是一个空壳,只是拿一个struct的定义在那占位置而已,此时就涉及到空结构体问题了。通常编译器会给空结构体分配1个字节的内存空间。为什么?如果不分配空间,那程序中的多个同类型结构体变量如何区分呢?比如a、b、c这三个变量,它们必须要被分配到不同的地址上去,各占1个字节的空间。
另外,因为sizeof有一个(),所以很多人想当然的把它当成一个函数。但其实它表达的是一个常数(运算符),它的值在程序编译期间就确定了。比如sizeof(i++),其中i为int类型,那么它的值就是4(32位平台)。
十三、memcpy的效率
memcpy函数的功能是用来作内存搬运,就是将数据从一个数组赋值到另一个数组。它的实现很简单:
|
|
但这种实现方式,其实是比较肤浅而低效的。作为嵌入式或硬件工程师,如果对上面的代码看不出什么问题的话,那可能要好好找找自身的原因。
上面的代码,对CPU数据总线带宽的利用率不高,我们把它改成这样:
|
|
改进后的代码最大限度的利用了CPU数据总线带宽,每次传输多个字节(如32位平台为4字节)。这一实例告诉我们:C语言,尤其是嵌入式C语言很多时候需要考虑硬件层面的因素,如CPU总线、内存结构等。
十四、[]的本质
当我们想取出一个数组中的某个元素时,我们会用到[],采用下标的方式。如下例:
|
|
其实,我们可以用其它方式取出这个元素,即*(a+1)。可以看到[]与*,在功能上有相似之处。其实[]并不限于与数组搭配访问数组元素,它的实质是:访问以指针所指向的地址为开始地址,以其下标为偏移量的存储单元中的数据,如下图所示。
上图可能颠覆了一些人对[]的认识,下标还能是负数?[]可以在一个开始地址后面去取数据,为什么不能在它前面取数据呢?我们可以理解[]是对指针加减和取值操作的综合。
认清了[]的实质,再加上对C语言的精髓–指针深刻的理解,我们编程将会非常灵活,肆意挥洒。
十五、#与##(串化与连接)
C语言中的#与##可能很多人都不了解,更没有用过,因为在一般的教材上都没有对它们的介绍。但把它们用好了,也能使我们的代码别有一番格调。来看下面的例子:
|
|
这就是串化,在宏定义中 # 可以将宏参数转换为字符串,即在宏参数的开头和末尾添加引号。似乎有些鸡肋,但如果看到别人的代码有用到串化的时候,我们需要能够看懂。
再来看一下连接符 ##,它用来将参数和其它的内容连接起来,如下例:
|
|
我在很多ARM官方评估板的配套代码中看到过大量串化与连接的应用,当时我并不知道C语言还有串化连接这些东西(虽然我已经用C语言有十几年了),所以有些看不明白。通过百度学习了一下#与##,这才懂了。
所以,C语言学得多精都不为过,很多知识我们可能一辈子都不会用到,但不代表我们可以不知道,因为别人在用。
OK,关于语言方面的一些常见问题、非常规操作以及认知误区振南就讲这么多。
语言其实是博大精深,还是那句话:“学得多精都不为过!”我一直把嵌入式工程师比喻成“能与硬件对话的灵媒”,我们所使用的语言就是语言。我们自认为对语言已经足够了解了,足够精通了,但我们又会发现在实际开发过程中,会遇到很多新的问题,很多问题是与语言本身相关的。
Author grabbyte
LastMod 2023-07-04