从零开始学51单片机C语言
上QQ阅读APP看书,第一时间看更新

2.1 单片机C语言入门

51单片机的编程语言主要有两种,一种是汇编语言,另一种是C语言。汇编语言的机器代码执行效率很高,但可读性却不强,复杂一点的程序就更难读懂,而C语言虽然在机器代码生成效率上不如汇编语言,但可读性和可移植性却远远超过汇编语言,而且C语言还可以嵌入汇编来解决高时效性的代码编写问题。因此,在掌握一定汇编语言的基础上,就需要进一步学习C语言编程了。

2.1.1 C语言的特点

C语言是一种结构化语言。它层次清晰,便于按模块化方式组织程序,易于调试和维护。C语言的表现能力和处理能力极强,它不仅具有丰富的运算符和数据类型,便于实现各类复杂的数据结构。它还可以直接访问内存的物理地址,进行位(bit)一级的操作。由于C语言实现了对硬件的编程操作,因此C语言集高级语言和低级语言的功能于一体,效率高、可移植性强,特别适合单片机系统的编程与开发。

2.1.2 单片机采用C语言编程的好处

与汇编语言相比,C语言在功能性、结构性、可读性、可维护性上有明显的优势,因而易学易用。用过汇编语言后再使用C语言来开发,体会会更加深刻。下面简要说明单片机采用C语言编程的几点好处。

1. 语言简洁,使用方便、灵活

C语言是现有程序设计语言中规模最小的语言之一,C语言的关键字很少,ANSI C标准一共只有32个关键字,9种控制语句,压缩了一切不必要的成分。C语言的书写形式比较自由,表达方法简洁,使用一些简单的方法就可以构造出相当复杂的数据类型和程序结构。同时,当前几乎所有单片机都有相应的C语言级别的仿真调试系统,调试十分方便。

2. 代码编译效率较高

当前,较好的C语言编译系统编译出来的代码效率只比直接使用汇编语言低20%左右,如果使用优化编译选项甚至可以更低。况且,随着单片机技术的发展,ROM空间不断增加,51系列单片机中,片上ROM空间做到32KB、64KB的比比皆是,代码效率所差的20%已经不是一个重要问题。

3. 无须深入理解单片机内部结构

采用汇编语言进行编程时,编程者必须对单片机的内部结构及寄存器的使用方法十分清楚;在编程时,一般还要进行RAM分配,稍不小心,就会发生变量地址重复或冲突。

采用C语言进行设计时,则不必对单片机硬件结构有很深入的了解,编译器可以自动完成变量存储单元的分配,编程者可以专注于应用软件部分的设计,大大加快了软件的开发速度。

4. 可进行模块化开发

C语言是以函数作为程序设计的基本单位的,C语言程序中的函数相当于汇编语言中的子程序。各种C语言编译器都会提供一个函数库,此外,C语言还具有自定义函数的功能,用户可以根据自己的需要编写满足某种特殊需要的自定义函数(程序模块),这些程序模块可不经修改,直接被其他项目所用。因此采用C语言编程,可以最大限度地实现资源共享。

5. 可移植性好

用过汇编语言的读者都知道,即使是功能完全相同的一种程序,对于不同的单片机,必须采用不同的汇编语言来编写。这是因为汇编语言完全依赖于单片机硬件。C语言是通过编译来得到可执行代码的,本身不依赖机器硬件系统,用C语言编写的程序基本上不用修改或者进行简单的修改,即可方便地移植到另一种结构类型的单片机上。

6. 可以直接操作硬件

C语言具有直接访问单片机物理地址的能力,可以直接访问片内或片外存储器,还可以进行各种位操作。

介绍到这里,我想说一下我学习单片机编程的一个小插曲。在20世纪90年代中期,我最初接触单片机的时候,在我心中觉得51就是单片机,单片机就是51,根本不知道还有其他单片机的存在。那时,我学习的是汇编语言,根本不知道用C语言也可以进行单片机开发。幸运的是,我有一个同事,比较精通C语言,我们一起做一个项目的时候,我才真正发现C语言的威力,于是,在同事的影响下,我开始使用C语言进行单片机编程。其实我也很庆幸,学习和使用了两年多的汇编语言。由于这些锻炼,我对单片机底层结构和接口时序弄得很清楚。在使用C语言开发的时候,优化代码和处理中断就不会太费劲。

总之,用C语言进行单片机程序设计是单片机开发与应用的必然趋势,我们一旦学会使用C语言之后,就会对它爱不释手,尤其是进行大型单片机应用程序开发,C语言几乎是唯一的选择。

2.1.3 如何学习单片机C语言

C语言常用语法不多,尤其是单片机的C语言常用语法更少,初学者没有必要将C语言的所有内容都学习一遍,只要跟着本书学下去,当遇到难点时,停下来适当地查阅C语言基础教材里的相关部分,便会很容易掌握。有关C语言的基础教材较多,在这里,笔者向大家推荐谭浩强的《C程序设计》一书,该书语言通俗,实例丰富,十分适合初学者学习和查阅。

C语言仅仅是一个编程语言,其本身并不难,难的是如何灵活运用C语言编写出结构完善的单片机程序。要达到这一点,就必须花费大量的时间进行实践、实验,光看书不动手,等于是纸上谈兵,很难成功!因此,本书主要是通过不断地实践、实战,使读者在玩中学,在学中玩,步步为营,步步深入,使自己在不知不觉中,成为单片机的编程高手。

2.1.4 一个简单的流水灯程序

下面我们先来看一个实例,这个例子的功能十分简单,就是让单片机的P2口的LED灯按流水灯的形式进行闪烁,每个LED灯的闪烁时间为0.5s,硬件电路如图2-1所示。

图2-1 点亮P2口LED灯电路

图中采用STC89C52单片机,这种单片机属于80C51系列,其内部有8KB的Flash ROM和512B(字节)的RAM,并且可以通过串口进行ISP程序下载,不需要反复插拔芯片,非常适于做实验。STC89C52的P2引脚上接8个发光二极管,RP7、RP9为限流电阻排,以免LED被烧坏;P2口是准双向口,可以外接上拉电阻,也可以不接,图中外接了RP13上拉电阻排。

根据要求,用C语言编写的程序如下。

#include<reg52.h>
#define uint unsigned int
sbit  P20=P2^0;                 //定义位变量
sbit  P21=P2^1;
sbit  P22=P2^2;
sbit  P23=P2^3;
sbit  P24=P2^4;
sbit  P25=P2^5;
sbit  P26=P2^6;
sbit  P27=P2^7;
void Delay_ms(uint xms)         //延时程序,xms是形式参数
{
  uint i, j;
  for(i=xms;i>0;i--)            //i=xms,即延时x毫秒,xms由实际参数传入一个值
  {
     for(j=115;j>0;j--)
     {;}                        //此处分号不可少,表示是一个空语句
  }
}
void main()
{
  while(1)                      //循环显示
  {
     P20=0;                     //P20脚灯亮
     Delay_ms (500);            //将实际参数500传递给形式参数xms,延时0.5s
     P20=1;                     //P20脚灯灭
     P21=0;                     //P21脚灯亮
     Delay_ms (500);
     P21=1;                     //P21脚灯灭
     P22=0;                     //P22脚灯亮
     Delay_ms (500);
     P22=1;                     //P22脚灯灭
     P23=0;                     //P23脚灯亮
     Delay_ms (500);
     P23=1;                     //P23脚灯灭
     P24=0;                     //P24脚灯亮
     Delay_ms (500);
     P24=1;                     //P24脚灯灭
     P25=0;                     //P25脚灯亮
     Delay_ms (500);
     P25=1;                     //P25脚灯灭
     P26=0;                     //P26脚灯亮
     Delay_ms (500);
     P26=1;                     //P26脚灯灭
     P27=0;                     //P27脚灯亮
     Delay_ms (500);
     P27=1;                     //P27脚灯灭
  }
}

这里,采用单片机C语言编译器Keil软件作为开发环境,关于Keil软件的详细内容,将在后面进行介绍。

下面我们对这个程序进行简要的分析。

程序的第一行是“文件包含”,所谓“文件包含”是指一个文件将另外一个文件的内容全部包含进来。所以,这里的程序虽然只有几行,但C编译器(Keil软件)在处理的时候却要处理几十行或几百行。为加深理解,可以用任何一个文本编辑器打开Keil\c51\inc文件夹下面的reg52.h来看一看里面有什么内容,在C编译器处理这个程序时,这些内容也会被处理。这个程序包含reg.h的目的就是为了使用P2这个符号,即通知C编译器程序中所写的P2是指80C51单片机的P2端口,而不是其他变量,这是如何做到的呢?用写字板程序打开reg52.h显示如下。

#ifndef __REG52_H__
#define __REG52_H__
/ *  BYTE Register  * /
sfr P0   = 0x80;
sfr P1   = 0x90;
sfr P2   = 0xa0;
sfr P3   = 0xb0;
……
#endif

可以看到:“sfr P2= 0xa0;”,即定义符号P2与地址0xa0对应,熟悉80C51内部结构的读者不难看出,P2口的地址就是0xa0。

程序的第2行是一个宏定义语句,注意后面没有分号。#define命令用它后面的第一个字母组合代替该字母组合后面的所有内容,也就是相当于我们给“原内容”重新起一个比较简单的“新名称”,方便以后在程序中使用简短的新名称,而不必每次都写烦琐的原内容。该例中,我们使用宏定义的目的就是将unsigned int用uint代替,在上面的程序中可以看到,在我们需要定义unsigned int类型变量时,并没有写unsigned int,取而代之的是uint。

程序的第3~10行用符号P20~P27来表示P2口的P2.0~P2.7八只引脚。在C语言里,如果直接写P2.0、P2.1……P2.7,C编译器并不能识别,而且它们不是一个合法的C语言变量名,所以得给它另起一个名字,这里起的名为P20~P27,可是P20~P27是否就是P2.0~P2.7呢?你这么认为,C编译器可不这么认为,所以必须给它们建立联系,这里使用了Keil的保留字sbit来定义。

main称为“主函数”,每一个C语言程序有且只有一个主函数,函数后面一定有一对大括号“{}”、在大括号里面书写其他程序代码。

Delay_ms(500)的用途是延时。由于单片机执行指令的速度很快,如果不进行延时,灯亮之后马上就灭,灭了之后马上就亮,速度太快,人眼根本无法分辨,所以需要进行适当的延时,这里采用自定义函数Delay_ms(500),以延时0.5s的时间,函数前面的void表示该延时函数没有返回值。

Delay_ms(500)函数是一个自定义函数,它不是由Keil编译器提供的,即你不能在任何情况下写这样一行程序以实现延时,如果在编写其他程序时写上这么一行,会发现编译通不过。注意观察本程序会发现,在使用Delay_ms(500)之前,第11~16行已对Delay_ms(uint xms)函数进行了事先定义,因此,在主程序中才能采用Delay_ms(500)进行调用。

注意,在延时函数Delay_ms(uint xms)定义中,参数xms被称作“形式参数”(简称形参);而在调用延时函数Delay_ms(500)中,小括号里的数据“500”,这个“500”被称作“实际参数”(简称实参)。参数的传递是单向的,即只能把实参的值传给形参,而不能把形参的值传给实参。另外,实参可以在一定范围内调整,这里用“500”来要求延时时间为0.5s,如果是“1000”,则延时时间是1000ms,即1s。

在延时函数Delay_ms(uint xms)内部,采用了两层嵌套for语句,如下所示。

void Delay_ms(uint xms)         //延时程序,xms是形式参数
{
  uint i, j;
  for(i=xms;i>0;i--)            //i=x毫秒,即延时xms,xms由实际参数传入一个值
  {
     for(j=115;j>0;j--)
     {;}                        //此处分号不可少,表示是一个空语句
  }
}

在这个延时函数中,采用的是一种比较正规的形式,C语言规定,当循环语句后面的大括号只有一条语句或为空时,可省略大括号,因此,上面两个for循环语句中的大括号都可以省略,也就是说,可以采用以下简化的形式。

void Delay_ms(uint xms)         //延时程序,xms是形式参数
{
  uint i, j;
  for(i=xms;i>0;i--)            //i=xms,即延时x毫秒,xms由实际参数传入一个值
     for(j=115;j>0;j--);        //此处分号不可少
  }

第一个for后面没有分号,那么编译器就会认为第二个for语句就是第一个for语句的内部语句,而第二个for语句后面有分号,编译器就会认为第二个for语句内部语句为空。程序在执行时,第一个for语句中的i每减一次,第二个for语句便执行115次,因此上面这个例子便相当于共执行了xms×115次for语句。通过改变xms变量的值,可以改变延时时间。

2.1.5 利用C51库函数实现流水灯

上面介绍的程序虽然可以实现流水灯的功能,程序比较烦琐,下面采用C51自带的库函数_crol()_()来实现,具体源程序如下所示。

#include<reg52.h>
#include<intrins.h>
#define uint unsigned int
#define uchar unsigned char
void Delay_ms(uint xms)     //延时程序,xms是形式参数
{
  uint i, j;
  for(i=xms;i>0;i--)        //i=xms,即延时x毫秒,xms由实际参数传入一个值
     for(j=115;j>0;j--);    //此处分号不可少
  }
void main()
{
  uchar led_data=0xfe;     //给led_data赋初值0xfe,点亮第一个LED灯
  while(1)                 //大循环
  {
     P2= led_data;
     Delay_ms(500);
     led_data=_crol_(led_data,1);//将led_data循环左移1位再赋值给led_data
  }
}

该源程序在ch2/my_8LED文件夹中。

显然,这个流水灯程序比上面的流水灯程序要简捷许多,下面简要进行说明。

程序中,_crol_是一个库函数,其函数原形为:

unsigned char  _crol_(unsigned char  c, unsigned char  b);

这个函数是C51自带的库函数,包含在intrins.h头文件中,也就是说,如果在程序中要用到这个函数,那么必须在程序的开头处包含intrins.h这个头文件。函数实现的功能是,将字符c循环左移b位。

函数中,_crol_是函数名,不用多讲,函数前面没有void,取而代之的是unsigned char,表示这个函数返回值是一个无符号字符型数据;有返回值的意思是说,程序执行完这个函数后,通过函数内部的某些运算而得出一个新值,该函数最终将这个新值返回给调用它的语句。小括号里有两个形参,unsigned char c和unsigned char b,它们都是无符号字符型数据。

现在我们应该清楚led_data=_crol_(led_data,1)这条语句的含义了,其作用就是,将led_data中的数据向左循环移1位,再赋给变量led_data。

有左移位库函数,当然也有右移库函数,函数原形为:

unsigned char  _cror_(unsigned char  c, unsigned char  b);

右移位函数与左移位函数使用方法相同,这里不再重复。

2.1.6 小结

通过以上的几个简单的C语言程序,我们可以总结出以下几点。

(1)C程序是由函数构成的。一个C源程序至少包括一个函数,一个C源程序有且只有一个名为main()的函数,也可能包含其他函数,因此,函数是C程序的基本单位。主程序通过直接书写语句和调用其他函数来实现有关功能,这些其他函数可以是由C语言本身提供给我们的,这样的函数称之为库函数(流水灯程序中的_crol_(led_data,1)函数就是一个库函数),也可以是用户自己编写的,这样的函数称之为用户自定义函数(流水灯程序中的Delay_ms(uint xms)函数就是一个自定义函数)。那么,库函数和用户自定义函数有什么区别呢?简单地说,任何使用C语言的人,都可以直接调用C的库函数而不需要为这个函数写任何代码,只需要包含具有该函数说明的相应的头文件即可;而自定义函数则是完全个性化的,是用户根据自己的需要编写的。

(2)一个函数由两部分组成。

① 函数的首部,即函数的第一行。包括函数名、函数参数(形式参数)等,函数名后面必须跟一对圆括号,即便没有任何参数也是如此。

② 函数体,即函数首部下面的大括号“{}”内的部分。如果一个函数内有多个大括号,则最外层的一对“{}”为函数体的范围。

(3)一个C语言程序,总是从main函数开始执行的,而不管物理位置上这个main()放在什么地方。

(4)主程序中的Delay_ms(uint xms)如果写成delay_ms(uint xms)就会编译出错,即C语言区分大小写,这是很多初学者在编写程序时常犯的错误,书写时一定要注意。

(5)C语言书写的格式自由,可以在一行写多个语句,也可以把一个语句写在多行。没有行号(但可以有标号),书写的缩进没有要求。但是建议读者自己按一定的规范来写,可以给自己带来方便。

(6)每个语句定义的最后必须有一个分号,分号是C语句的必要组成部分。

(7)可以用/ *…..* /的形式为C程序的任何一部分作注释,在“/ *”开始后,一直到“* /”为止的中间的任何内容都被认为是注释;如果使用的是Keil开发软件,那么,该软件也支持C++风格的注释,就是用“//”引导的后面的语句是注释。这种风格的注释,书写比较方便,只对本行有效,在只需要一行注释的时候,我们往往采用这种格式。但要注意,只有Keil支持这种格式,其他的编译器不一定支持这种格式的注释。