讲解嵌入式C语言的编程技巧,并进行深入解析。

一、隐藏的死循环

有时候,我们会发现for循环变成了一个死循环:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18


unsigned char i;





for(i = 4; i >= 0; i--)


{


....


}

我们本希望循环5次,然后结束,但实际情况是陷入了死循环。这种错误在实际开发中,还比较难发现。其原因在于i的类型,无符号整型是永远不小于0的。

我们需要将i的类型改为有符号型。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18


signed char i;





for(i = 4; i >= 0; i--)


{


....


}

OK,这样就对了。细节虽小,但对实际开发的影响还是蛮大的,请大家引以为戒。

下面的两个例子中for循环也是死循环,请自行分析:

例1:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18


unsigned char i;





for(i=0;i<256;i++)


{


...


}

提示:i 的数据类型。

例2:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15


char str[20];


char \*p;


unsigned char n=0;





for(p = strcpy(str,"         abcd"); ((*p)=' '); p++,n++);

提示:这个例子,不光会死循环,而且还可能会让程序直接崩溃。判等的 == 你会不会经常直接写错成 =(赋值表达式)。

二、看似多余的空循环

有时我们会看到这样的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12


do


{


...... //do something


}while(0);

代码本身实际只运行了一次,为什么要在它外面加一层do while呢?这看似是多余的。其实不然,我们来看下面例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21


#define DO_SOMETHING fun1();fun2();





void main(void)


{


while(1)


DO_SOMETHING;


}

while(1) DO_SOMETHING; 本意应该是不断调用 fun1 和 fun2,但实际上只有 fun1 得到运行。其中原因大家应该明白。

所以,我们可以这样来写:

1
2
3


#define DO_SOMETHING do{ fun1();fun2();}while(0);

do while就如同一个框架把要运行的代码框起来,成为一个整体。

三、独立执行体

我在C语言编程的过程中,经常乐于使用一种“局部独立化”的方式,我称之为“独立执行体”,如下例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84


void fun(int a,int b,int c)


{


Int tmp=0;





//**主体计算





{ //**独立执行体,解决临时性问题





int c=0;





c=(a>b)?a:b;





printf("max:%d\r\n",c);





}


 


{ //**独立执行体*





  int c=0,d=0,.....,res=0.;





  //**数据处理算法





  printf("result:%d\r\n",res);


}





//**进一步计算





}

在编程时,我们经常需要解决一些小问题,比如想对一些数据进行临时性的处理,查看中间结果;或是临时性的突发奇想,试探性的作一些小算法。

这过程中可能需要独立的变量,以及独立于主体程序的执行逻辑,但又觉得不至于去专门定义一个函数,只是想一带而过。比如上例,函数fun主要对a、b、c这3个参数进行计算(使用某种算法),过程中想临时看一下a和b谁比较大,由第一个“独立执行体”来完成,其中的代码由自己的{}扩起来。

其实我们可以更深层的去理解C语言中的{},它为我们开辟了一个可自由编程的独立空间。在{}里,可以定义变量,可以调用函数以及访问外层代码中的变量,可以作宏定义等等。平时我们使用的函数,它的{}部分其实就是一个“独立执行体”。

“独立执行体”的思想,也许可以让我们编程更加灵活方便,可以随时让我们直接得到一块自由编程的静土。

上一节中的do while(0),其实完全可以把do while(0)去掉,只用{}即可:

1
2
3


#define DO_SOMETHING {fun1();fun2();}

另外,它还有一个好处,那就是当你不需要这段代码的时候,你可以直接在{}前面加上if(0)即可。一个“独立执行体”的外层是可以受if、do while、while、for等这些条件控制的。

四、多用()无坏处

!0+1,它的值等于多少?其实连我这样的老手也不能马上给出答案,2还是0?按C语言规定的运算符优先级来说,应该是!大于+,所以结果应该是2。

但如果把它放在宏里,有时候就开始坑人了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15


#define VALUE !0+1





int a;





a=VALUE&0;

踩过此类坑的人无需多说,自能领会。a=2&0呢,还是a=!0+1&0呢?它们的值截然不同。

这里出现了一些运算优先级和结合律的差错。为了让我们的语义和意图正确的得以表达,所以建议多用一些()。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15


#define VALUE ((!0)+1)





int a;





a=VALUE&0;

这样,a的值就一定是0了。

另外,有时候优先级还与 C 语言编译器有关,同一个表达式在不同的平台上,可能表达的意义是不同的。所以,为了代码的可植移性、正确性以及可读性,强烈建议多用一些()。

五、==的反向测试

C语言中的 = 与 ==,有时候是一个大坑。主要体现在条件判断时的值比较,如下例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18


int a=0;





If(a=1)


{


//**代码


}

也许我们的原意是判断a若为1,则执行。但实际上if根本不起作用,因为错把==写成了=。

C语言中的赋值操作也是一种表达式,称为赋值表达式,它的值即为赋值后变量的值。而C语言中条件判断又是一种宽泛的判断,即非0为真,为0则假。所以if(a=1)这样的代码编译是不会报错的。

这种错误通常是很难排查出来的,尤其是在复杂的算法中,只能一行行代码的跟踪。所以对于变量值的比较判断,振南建议使用“==的反向测试”,并养成习惯。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18


int a=0;





if(1==a)


{


//**代码


}

如果把 == 错写成了 =,因为常量无法被赋值,所以编译时会报错。

六、赋值操作的实质

原来一位哈工程理学院教授(搞数学的)讲述了自己的一个困惑,一直以来都被我们当成一个笑话在说。他学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 的问题,我们先来看一个例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18


signed short a = -1;





if(-1==a)


{


//....


}

这个if条件成立吗?似乎这是一句废话。其实不然,它不一定成立。

我们要知道C语言中的判等 == 运算是一种强匹配,也就是比较的双方必须每一个位都匹配才被认为相等。在上例中,a在内存中的表示是 0XFFFF(补码),但 -1 这个常量在内存中的表示在不同的硬件平台上却不尽相同,在16位CPU平台上是0XFFFF,它们是相等的。而在32位CPU平台上则是0XFFFFFFFF,它们就不相等。

所以,稳妥的办法是:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24


signed short a=-1;





if(((signed short)-1) == a)


{





//....





}

我们看到 -1 的补码是全 F,而且位数与 CPU 平台相关。所以-1经常还有另一个妙用,即可以用于判断硬件平台的 CPU 位数,便于提高代码的可移植性(32位平台的int(-1)为0XFFFFFFFF,而16位平台则是0XFFFF)。

九、字节快速位逆序

一道有意思的题目:如何快速得到一个字节的位逆序字节。比如 0X33 的位逆序字节是 0XCC。

有人给了我这样一段代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45


unsigned char reverse_byte(unsigned char byte)


{


unsigned char i=0;


unsigned char temp=0;





for(i=0;i<8;i++)


{


if(byte&(0x01<<i))


{


temp|=(0x80>>i);


}


}





return temp;


}

这段代码很简洁,也很巧妙。但它却不是最快的。后来作了改进:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45


unsigned char reverse_byte(unsigned char byte)


{


 unsigned char temp=0;





 if(byte&0x01) temp|=0x80;


 if(byte&0x02) temp|=0x40;


 if(byte&0x04) temp|=0x20;


 if(byte&0x08) temp|=0x10;


 if(byte&0x10) temp|=0x08;


 if(byte&0x20) temp|=0x04;


 if(byte&0x40) temp|=0x02;


 if(byte&0x80) temp|=0x01;





return temp;


}

这样把循环打开,确实会提速不少。但它仍不是最快的实现方案。请看如下代码:

1
2
3
4
5
6
7
8
9


unsigned char rbyte[256]={0x00,0x80,0x40,0xc0,0x20,........};





#define REVERSE_BYTE(x) rbyte[x]

恍然大悟了没有?使用字节数组事先准备好位逆序字节,然后直接以字节的值为下标索引,直接取数据即可。这种方法被称为“空间换时间”。

这个问题我问过很多人,多数人并不能直接给出最佳方案。倒是有不少人问我这个问题有什么实际意义,为什么要去计算位逆序字节?请大家想想,如果我们把电路上的数据总线焊反或插反了该怎么解决。

十、关于volatile

现在的编译器越来越智能,它们会对我们的代码进行不同程度的优化。请看下例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12


unsigned char a;


a=1;


a=2;


a=3;

这样一段代码,有些编译器会认为 a=1 与 a=2 根本就是毫无意义,会把它们优化掉,只剩下 a=3。但有些时候,这段代码是有特殊用途的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12


unsigned char xdata a _at_ 0X1111;


a=1;


a=2;


a=3;

a不单单是一个变量,而是一个外部总线的端口(51平台)。向它赋值会产生相应的外部总线上的时序输出,从而对外部器件实现控制。

这种时候,a=1和a=2不能被优化掉。举个例子:a所指向的外部总线端口,是一个电机控制器的接口,向它写入1是加速,写入2是减速,写入3是反向。那么上面的代码就是加速->减速->反向,这样一个控制过程。如果被优化的话,那最后就只有反向了。

为了防止这种被“意外”伦的情况发生,我们可以在变量的定义上加一个修饰词volatile。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15


volatile unsigned char xdata a _at_ 0X1111;





a=1;


a=2;


a=3;

这样,编译器就会对它单独对待,不再优化了。

volatile最常出现的地方,就是对芯片中寄存器的定义,比如 STM32 固件库中有这样的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63


#define __IO volatile





typedef struct





{





__IO uint32_t CRL;





__IO uint32_t CRH;





__IO uint32_t IDR;


 


__IO uint32_t ODR;





__IO uint32_t BSRR;





__IO uint32_t BRR;





__IO uint32_t LCKR;





} GPIO_TypeDef;

这是对 STM32 的 GPIO 寄存器组的定义,每一项都是一个__IO类型,其实就是 volatile。这样是为了对片内外设的物理寄存器的访问一定是真正落实的,而不是经过编译器优化,而变成去访问缓存之类的东西。

十一、关于变量互换

初学C语言的时候,有一个小编程题我们应该都记得,就是变量互换。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27


int a,b;





int temp;





temp=a;





a=b;





b=temp;

变量a与b的值互换,在这过程中一定需要一个中间变量temp作为中转。不用这个中间变量能不能实现?请看下面的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15


int a,b;





a=a+b;


b=a-b;


a=a-b;

可以说上面代码有点小巧妙,那么下面的代码就真正是巧妙了:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15


int a,b;





a=a^b;


b=a^b;


a=a^b;

异或运算有一个性质叫自反性,这个可以实现很多巧妙的操作,大家可以深入研究一下(异或位运算比上面的加减法更严谨,因为加减法是可能会溢出的)。

十二、关于sizeof

C语言中的sizeof我们应该是非常熟悉的,它的作用就是用来计算一个变量或类型所占用的字节数。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18


sizeof(int) //**如果是32位CPU平台,值为4,即4个字节





int a;


sizeof(a) //**同上





sizeof(struct ...) //**计算某结构体的大小

这个很简单,我们再来看下面的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15


char *pc="abc";





sizeof(pc) //指针的sizeof等于多少?





sizeof(*pc) //指针指向的单元的sizeof等于多少?

pc 用来指向 char 类型的变量。

pc 本身是一个指针类型,在32位平台上sizeof(pc)的值为4,即指针类型占用4个字节(与CPU平台有关)。*pc是pc所指向的变量,所以sizeof(*pc)的值为1。

好,还能理解吧,那我们再来看:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24


char a1[]="abcd";





sizeof(a1) //数组的sizeof等于多少?





void fun(char a1[]) //形参a1的sizeof等于多少?


{


//....


}

第一个sizeof(a1)等于5,因为它是一个数组(最后还有一个字符串结束符’\0’)。第二个sizeof(a1)等于4,形参中的a1不再是一个数组,而是一个指针。

好,下面的实例估计很多人没见到过:

1
2
3
4
5
6
7
8
9


struct {} a,b,c;





sizeof(a) //空结构体的sizeof等于多少?

空结构体类型变量的大小是多少?这个问题似乎有些奇葩,没什么实用性。空结构体有什么用?

这个问题可以揭示一些比较深层的问题,我们平时注意不到。空结构体的大小是1,即占用1个字节。

当我们的程序还仅仅是一个框架的时候,一些结构体还只是一个空壳,只是拿一个struct的定义在那占位置而已,此时就涉及到空结构体问题了。通常编译器会给空结构体分配1个字节的内存空间。为什么?如果不分配空间,那程序中的多个同类型结构体变量如何区分呢?比如a、b、c这三个变量,它们必须要被分配到不同的地址上去,各占1个字节的空间。

另外,因为sizeof有一个(),所以很多人想当然的把它当成一个函数。但其实它表达的是一个常数(运算符),它的值在程序编译期间就确定了。比如sizeof(i++),其中i为int类型,那么它的值就是4(32位平台)。

十三、memcpy的效率

memcpy函数的功能是用来作内存搬运,就是将数据从一个数组赋值到另一个数组。它的实现很简单:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21


void memcpy(unsigned char *pd,const unsigned char *ps,unsigned int len)


{


unsigned int i=0;





for(i=0;i<len;i++) pd[i]=ps[i];





}

但这种实现方式,其实是比较肤浅而低效的。作为嵌入式或硬件工程师,如果对上面的代码看不出什么问题的话,那可能要好好找找自身的原因。

上面的代码,对CPU数据总线带宽的利用率不高,我们把它改成这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42


void memcpy(unsigned char *pd,const unsigned char *ps,unsigned int len)


{





unsigned int i=0;





unsigned int temp=len/sizeof(unsigned int);








for(i=0;i<temp;i++) ((unsigned int *)pd)[i]=((unsigned int *)ps)[i];





i*=sizeof(unsigned int);





for(;i<len;i++) pd[i]=ps[i];


}

改进后的代码最大限度的利用了CPU数据总线带宽,每次传输多个字节(如32位平台为4字节)。这一实例告诉我们:C语言,尤其是嵌入式C语言很多时候需要考虑硬件层面的因素,如CPU总线、内存结构等。

十四、[]的本质

当我们想取出一个数组中的某个元素时,我们会用到[],采用下标的方式。如下例:

1
2
3
4
5
6
7
8
9


int a[3]={1,2,3};





a[1]; //数组a的第2个元素

其实,我们可以用其它方式取出这个元素,即*(a+1)。可以看到[]与*,在功能上有相似之处。其实[]并不限于与数组搭配访问数组元素,它的实质是:访问以指针所指向的地址为开始地址,以其下标为偏移量的存储单元中的数据,如下图所示。

图片

上图可能颠覆了一些人对[]的认识,下标还能是负数?[]可以在一个开始地址后面去取数据,为什么不能在它前面取数据呢?我们可以理解[]是对指针加减和取值操作的综合。

认清了[]的实质,再加上对C语言的精髓–指针深刻的理解,我们编程将会非常灵活,肆意挥洒。

十五、#与##(串化与连接)

C语言中的#与##可能很多人都不了解,更没有用过,因为在一般的教材上都没有对它们的介绍。但把它们用好了,也能使我们的代码别有一番格调。来看下面的例子:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12


#define STR(s) #s





printf("%s", STR(www.znmcu.com);


printf("%s", “www.znmcu.com”); //**宏展开之后的效果

这就是串化,在宏定义中 # 可以将宏参数转换为字符串,即在宏参数的开头和末尾添加引号。似乎有些鸡肋,但如果看到别人的代码有用到串化的时候,我们需要能够看懂。

再来看一下连接符 ##,它用来将参数和其它的内容连接起来,如下例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21


#define CON1(a, b) a##e##b         #define CON2(a, b) a##b##00





printf("%f\n", CON1(8.5, 2)); printf("%d\n", CON2(12, 34));





printf("%f\n", 8.5e2); //**展开后的效果





printf("%d\n", 123400); //**展开后的效果

我在很多ARM官方评估板的配套代码中看到过大量串化与连接的应用,当时我并不知道C语言还有串化连接这些东西(虽然我已经用C语言有十几年了),所以有些看不明白。通过百度学习了一下#与##,这才懂了。

所以,C语言学得多精都不为过,很多知识我们可能一辈子都不会用到,但不代表我们可以不知道,因为别人在用。

OK,关于语言方面的一些常见问题、非常规操作以及认知误区振南就讲这么多。

语言其实是博大精深,还是那句话:“学得多精都不为过!”我一直把嵌入式工程师比喻成“能与硬件对话的灵媒”,我们所使用的语言就是语言。我们自认为对语言已经足够了解了,足够精通了,但我们又会发现在实际开发过程中,会遇到很多新的问题,很多问题是与语言本身相关的。

【续】C语言的一些“骚操作”及其深层理解 (qq.com)