最近因为工作关系,需要开始重新捡起单片机的技能。实验室负责人比较熟悉8051系列单片机,也勉强给LPC111x系列写过程序。 而当前的目标,则是完整地了解一款PIC18系列的芯片:PIC18F46Q10。

选用这一款芯片的理由,既和工作的内容相关,也来自实验室负责人的一点期望:学习一个比51性能好一些的8比特单片机。 8051系列的设计来自于1980年代,虽然至今备受青睐而且是很多新设计的基础,但作为爱好者,如果只是停留在这个型号上,就会错失很多新的芯片的丰富功能。 而Microchip不仅仅生产繁多的8/16/32比特单片机型号,还免费提供它的IDE和编译器(MPLAB和XC8等)给用户使用,所以实验室负责人决定成为Microchip的粉丝~

自学的小目标

  • 熟悉编程环境,学习语法
  • 熟悉操作IO口的办法
  • 学习PIC18的定时器和定时器中断两个模块

PIC18F46Q10 vs AT89S51/52

  • 电压范围:PIC18可以使用1.8V-5.5V电压,以前的AT89S51只好使用5V。
  • 时钟频率:PIC18可以上到64MHz,指令频率是Fosc/4=16MHz,而51单片机是Fosc/12,就算用32MHz的时钟,指令频率也不过2MHz多一点。
  • ……

使用的硬件和软件

软件:MPLAB X,可以直接从microchip的网站上下载,安装免费。

——需要单独安装编译器,至少安装XC8。如果希望以后学习其他的16/32位单片机,可以安装相应的软件。

硬件:买了一个PICKIT 4的调试器,大约50欧元,支持ICSP和JTAG,除了PIC系列,也可以给其他支持JTAG标准的单片机编程,颇为值得购买。

另外,为了减少硬件上的烦劳,我买了一个OLIMEX PIC-USB-455x 的开发板,如图:

PIC-USB-4550

板上有一个按钮,一个LED灯。其他位置留给我们自行开发。

用MPLAB和XC8给单片机编程

XC8是C语言的编译器,结合MPLAB的开发环境,就可以很方便地编写和编译程序。PICKIT 4在Ubuntu下是即插即用的,无须安装驱动,MPLAB可以自动识别。

编程本身,其实C语言技术上还是大同小异。而重点是要仔细阅读单片机的数据手册。 很多问题的答案都在这份700多页的大部头文件里。

1. 用 MPLAB+XC8 进行基本的项目设置

在MPLAB中新建项目之后,注意选择器件型号。这样才能对相应的单片机生成正确的机器码。 作为一个弯路,我曾经试图在Protel中给PIC18F4550的虚拟器件加载PIC18F46Q10的代码,并不可行,因为不同器件内部的地址分配是不同的。(以前8051单片机相对宽松,因为遵循的老标准已经成为了传统……)

需要包含的文件

新项目需要包含的代码,至少是有main()这个函数。这一点和C语言相同。

因为不可避免地需要操作单片机的各种寄存器,需要引用xc.h这个文件。

除此之外,就是需要设定必要的配置位,让单片机运行在符合其电路设计的状态下。

单片机的配置位

配置位(Configure Bits)用于设定单片机运行的初始状态。这里面最重要的是时钟源的设置。如果设置不当,单片机将无法正确启动。

除此之外,单片机运行监控,看门狗,程序存储器和代码保护的设定,也都在配置位里。PIC18有CONFIG1CONFIG5共5个配置位,参数繁多。为了简化各种型号单片机的配置位设定,MPLAB自带Set Configuration Bits这个工具(在Production菜单下),可以据此生成适当的代码,粘贴到程序文件中即可。

2. 控制一个IO口,点亮开发板上的LED,然后闪烁

查阅开发板的资料,我们知道,LED连接在RD3这个引脚上。当引脚为0,接受灌电流的时候,灯就会点亮。

由51单片机的经验,我想当然地认为:类似 RD3=0 这样的语法就可以了。换作XC8的语法就是:

PORTDbits.RD3 = 0;

但是,灯并没有点亮。查阅数据手册第5章,得知PIC18的IO口非常复杂,功能繁多。下图是这个单片机一个IO口的逻辑关系图:

IO

注意到IO口的读写有4条线路:

  1. 写入控制IO口的数据锁存器,LATx
  2. 写入IO口本身,PORTx
  3. 读取IO口PORTx
  4. 读取IO口对应的数据锁存器,LATx

其中,1和2是同一条线路,实际上效果是相同的。这就是数据手册里说的,写入IO口总是写入IO口对应的数据锁存器上。 但是3和4区别在于,读取PORTx会读到IO口上的实际逻辑电位,而读取LATx读到的是之前写入的IO口上应具有的逻辑电位。

同时注意到,在输出时,锁存器要经过TRISx这个逻辑门。后者在设定为0的时候才可以启用。所以,我们需要至少设定IO口的输出驱动,TRISx寄存器,才可以让数字写入IO口:TRISD.TRISD3 = 0

而为了获取IO口上的逻辑电位,我们注意到在Read PORTx之前,还有一个受ANSELx控制的与门。因此,如果想要这个功能,也要考虑ANSELx的设定。

那么,如何翻转一个LED的状态呢?

我们希望能让LED闪烁。如果没有经验,我们可能试图使用这样的代码:

while(1){
    PORTDbits.RD3 ^= 1;
    delay();
}

但这样的代码并不能成功。可以看到IO口维持在同一个状态不动。

根据前面的分析,应该可以看出这段代码的问题。我们希望让RD3的值每次与上次不同。这涉及到一个先读后写(read-and-write)的过程。 程序需要首先读出RD3的值,然后将其翻转,写回RD3中。

然而,读取RD3是有条件的,必须让ANSELx=0。否则,读取到的不是对应引脚的实际逻辑电平。但另一方面,我们也无需读取实际引脚的逻辑电平,只要翻转IO口上的锁存器,也可以达到一样的效果。 所以,我们可以用如下代码:

TRISDbits.TRISD3 = 0; // 无论哪种方案,都必须设定引脚的输出功能。

// 方案1
LATDbits.LATD3 ^= 1; // 读写IO口锁存器

// 方案2
ANSELDbits.ANSELD3 = 0; // RD3引脚关闭模拟输入。启用施密特触发器或者TTL电平输入。
PORTDbits.RD3 ^= 1;

3. 定时器和中断

在前面的热身练习完成后,我们尝试学习一点复杂而重要的东西:PIC18的中断系统。

作为一个小目标,我们希望利用定时器——而不是基于循环的延时功能——实现灯的闪烁。

阅读数据手册

认识中断系统最重要的“地图”,就是数据手册上的中断逻辑图,如下:

Interrupts

PIC18的中断系统有很多特点,比较重要的是:

PIC18的中断可以配置为具有高低优先级的工作模式,或者兼容低端单片机的模式。 在启用优先级的时候,高优先级的中断一旦出现,会屏蔽低优先级的中断。 这是因为在图的右侧,高优先级的中断输出,同时经过一个非门,输入给了低优先级的中断路径上的一个与门。 只有当高优先级的中断执行完毕,才有可能执行低优先级的中断。

所有的中断源,比如定时器、外部中断、各种片上设备产生的中断,都可以被分配到两个优先级。 这是因为在图的左边,各种中断源都有对应一个IPR控制位(Interrupt Priority Register),分配中断源的优先级归属。 一个中断源会否产生中断,由3个控制位共同决定:中断优先级,中断启用(PIE)和中断请求(PIR)。然而,产生中断的逻辑实际上分为两路,互为镜像,分别是(PIE and PIR) and IPR(PIE and PIR) and !IPR, 所以实际上一个中断的产生,并不受中断优先级控制,只和中断启用中断请求相关。

特别是注意到,在IPEN(中断优先级启用控制)位为0的时候,所有的中断源都能产生中断并向右发送。 中断优先级的作用只是控制中断信号的路径,但IPEN=0时所有路径都是开放的。只有IPEN=1时,路径才会根据优先级不同经过复杂的控制逻辑输出。

PIR0与其他中断不同。 我们注意到PIR0一路的中断,绕过了PEIE的控制,只受到GIE(中断全局开关)的控制。只要GIE=1PIR0上的中断源都可以产生中断。 PEIE可以理解成为内部中断,其不包括PIR0中的5个中断源:定时器0,引脚电平变化(Interrupt on Change, IOC)和3个外部输入中断。 相比之下,为了启用片上其他功能的中断,比如EUSART(串行口)、其他的定时器等, 不仅要设定对应中断源的PIE,还可能需要启用PEIE这个开关(内部中断启用)——但只是在IPEN=0,即没有启用中断优先级的时候,才需要做这一点。

方案:定时器0中断实现灯的闪烁

定时器0是一个8位或者16位的定时器/计数器,可以从多个输入源获得计数脉冲。可能的输入有:

  1. CLC1(可配置的逻辑模块)的输出
  2. 多种内部振荡器的脉冲
  3. 芯片时钟/4
  4. 根据T0CKIPPS的设定所决定的外部引脚

我们这里只考虑使用芯片时钟作为计数脉冲,并使用定时器的16位模式。在这种模式下,定时器0会不停的从0x0000计数到0xFFFF,然后重新开始。在溢出的时候可以产生一个中断。为此,配置

T0CON1bits.T0CS = 2; // Fosc/4
T0CON0bits.T016BIT = 1;

因为时钟的速度比较快,比如我们本例中使用32MHz的振荡器(内部64MHz振荡器经过分频2:1),脉冲频率=振荡器频率/4=8MHz,即使计数到0xFFFF=65535也只需要0.008秒。 为了让效果明显一点,我们使用时钟脉冲的预分频器

T0CON1bits.T0CKPS = 0xA; // Prescalar 1:1024

让计数器溢出的频率变为8秒。

最后,使用

T0CON0bits.T0EN = 1;

即可启用时钟。

中断:启用中断,编写服务程序

仅仅启用时钟,并不能实现我们的目标。为了让时钟产生中断,并在中断中更改LED的状态,首先要编写中断服务程序。

中断服务程序(Interrupt Service Routine)是一个特殊的函数,我们这里命名为isr,其声明的格式和内容如下:

void __interrupt(high_priority) isr (void){
    if(PIR0bits.TMR0IF){
        LATDbits.LATD3 ^= 1;
        PIR0bits.TMR0IF = 0;
    }   
}

在这个程序中,我们需要用if首先判断是谁引起了中断。和一些高档的单片机可能不同,PIC18的中断优先级只有2个。所以,每个级别上的中断都可能由多个中断源引发。 这样,我们的函数就必须是一个“多面手”,可以应对所有可能的情况。

其次,注意到在if判断的结尾,我们重新将PIR0bits.TMR0IF清零。 这一步是非常重要的!一个中断请求产生后,必须手动消除这个中断请求。否则,下一个中断将还是由它引发。

p.s. 我们知道GIE是全局中断开关。实际上这个开关在中断发生后也有变化:它会自动清零。 但是,我们需不需要在ISR中重新启用GIE呢?这一点数据手册上说得很明白:在退出ISR之后,GIE会重新置1。 这也提示我们有一些操作不能在ISR中完成:比如关闭中断全局开关!

最后一步,就是启用中断了。根据前面的分析,我们需要注意如下几个控制位:

PIE0bits.TMR0IE = 1;
INTCONbits.IPEN = 0;        // traditional mode, no priority
INTCONbits.GIE_GIEH = 1;