也就是整一个类似wiring 的东西,给所有引脚统一的编号,可以用类似digitalWrite(0, 1) 这种方式读写引脚。好处是容易实现平台无关的库函数,比方说只要稍微改一改映射方案就可以把给STC 设计的库用到别的C51 单片机,不用直接操作寄存器了也算是个方便,而且引脚编号就是个普通数字,使用上要比sbit 灵活。
原理
要实现这种效果,首先要设计一个映射机制,就是当程序里写了编号0 时,可以自动把0 映射到对应的引脚寄存器。比方说如果规定0 对应P0.0,那么这个映射就要根据0 去找到P0 和0 这两个信息。找到之后,后续的代码才能根据寄存器实现操作,比如置位。显然这些信息需要提前放进程序,但是要怎么放就有的讲究了。
最简单直接的就是考虑用switch case,比如写个这种函数:
//把引脚编号转化到寄存器地址,比如0 -> P0
int pin_num_to_port(int num) {
switch(num) {
case 0:
return P0;
//...
}
}
//把引脚编号转换到寄存器内的位
int pin_num_to_pin(int num) {
switch(num) {
case 0:
return 0;
}
}
然后如果要用这两个信息去访问寄存器,在keil c51 里面像这样写:
#define PIN 0
int port = pin_num_to_port(PIN);
int bit_num = pin_num_to_pin(PIN);
sbit PPIINN = port ^ bit_num;
PPIINN = 0
是不行的,sbit 只能用全局常量的形式定义,也就是字面量写死。所以如果一定要实现,就只能要么汇编直接SETB
操作寄存器位,要么用位运算赋值,比如这样:
// 把一个引脚置为高电平
void setpin(port, pin) {
//如果要置位P0.0,首先生成一个变量,值是0000_0001,然后把这个值通过指针赋值给P0 寄存器。
uint8_t val = 0x01 << pin;
*((uint8_t *)(port)) = val;
}
但是同样不一定能用,因为这个指针不一定能指向P0。比方说在STC15 单片机里,内部ram 有256 字节,低128 的部分和普通51 一样,高128 RAM 地址则和特殊功能寄存器的地址区域重叠了。经典的课本上的51 单片机的RAM 结构都知道实际只有低128 字节给用,高128 是特殊功能寄存器,而STC15 是这个样子:
因为地址相同,对特殊功能寄存器和高128 RAM 的操作用不同的寻址方式区分,就类似SBUF 用读或写来区分两个寄存器一样。对特殊功能寄存器的访问只能用直接寻址方式进行,而高128 RAM 则只能用寄存器间接寻址,也就是指针。所以要访问P0
,只能代码里写死了P0 = 0xff
这么搞,上面代码里用指针赋值实际上访问的是高128 RAM 里的地址。
Arduino 的实现方式
先不管最后要怎么操作P0,因为switch case 的映射显得太小儿科,用函数里面放switch case 的形式实现映射表会导致额外的程序空间和调用开销,起码有几次传参和返回。那么Arduino 的标准实现是怎样的?参考源码,可以看到它是用放在程序存储区的数组实现的,拿引脚编号当数组下标,取出对应的寄存器信息,类似这样:
//以下定义对应的编号映射是:
//0 -> P0.0
//1 -> P0.1
//2 -> P1.0
unsigned char code PIN_NUM_TO_PORT[]={
P0,
P0,
P1,
};
unsigned char code PIN_NUM_TO_PIN[]={
0,
1,
0,
};
#define PIN 2
int port = PIN_NUM_TO_PORT[PIN]; // -> P1
int bit_num = PIN_NUM_TO_PIN[PIN]; // -> 0
这样一来就省下了函数调用开销,存储空间应该也能省一些。稍微有点不好看的就是数组里要放一堆重复信息,不过这也是没办法。不过这个映射转化的过程仍然是在运行时完成的,映射表本身要占用一定的程序空间,查表的过程要占用时间,查完表操作寄存器又会有函数调用的开销。而实际上,对引脚的操作很少有必要放在运行时完成,大部分时候都是完全写死的。Arduino 这种实现固然在某些少见的场合能提供一些灵活性,但是多数时候只会造成性能浪费。再加上C51 对特殊功能寄存器的操作可能只有写死这么一条路走,用函数操作寄存器行不通,那么剩下能想的办法,没错,就只有大家又爱又恨的宏了。
用宏实现零开销的映射
简单实现 - 用参数连接实现映射
首先介绍两个基本工具:
#define CAT(a, b) a ## b //参数连接,用来代替直接写## 符号,提高可读性,同时处理一些宏的特殊规则
#define CAT3(a, b, c) a ## b ## c //一样的,只是连接三个参数
CAT
的功能简单而又重要,用法是:
//调用
CAT(AAA, BBB)
//宏展开后变成
AAABBB
也就是把两个参数连接到一起了,如果连接的结果里还有别的宏,那就会继续展开,比如:
#define OK_0 (0)
#define OK_1 (1)
#define OK(n) CAT(OK_, n)
//调用
OK(1)
//展开成
OK_1
//进一步展开变成
1
可以看到,用不同的参数调用OK()
宏函数会展开成不同的宏,并进一步展开变成不同的结果。可以说这就是魔法开始的地方。但是如果要连接三个参数呢?直觉的写法可能是:
CAT(CAT(AA, BB), CC)
//希望能变成:
AABBCC
但这是不可行的,宏函数不能嵌套展开。在宏展开的过程中,如果碰到了和它本身名字相同的宏,这个宏不会继续展开,就像一个宏展开的过程中,它本身的名字消失了,不会被识别成一个可以展开的宏。所以上面那个嵌套的例子实际上只会连接一次,变成:
CAT(AA, BB)CC
展开结果里的CAT
不会被展开,因此最后会导致编译错误。所以这时候就是CAT3
的用处所在了。
利用类似上面那个OK(n)
宏函数的机制就已经能实现简单的一对一映射功能,比如:
#define PIN_0 P00
#define PIN_1 P01
#define PIN_2 P10
#define PIN(n) CAT(PIN_, n)
// 设置引脚为高电平
#define setpin(n) do { PIN(n) = 1; } while(0)
//调用
setpin(2);
//展开成
do { PIN(2) = 1; } while(0);
//进一步展开变成
do { PIN_2 = 1; } while(0);
//然后就
do { P10 = 1; } while(0);
//do while 会被编译器优化掉,所以最后的结果是;
P10 = 1;
P10
在STC 单片机的头文件中有定义,类似sbit P10 = P1^0
,没有的话自己整一套也不难。这样就成功实现了零开销、纯静态的从映射到引脚操作的流程。编译后的程序里完全不会有上面那些宏代码。不过有一点要注意,如果要用#define
定义个常量当引脚编号用,不能像平常那样数字两边加上括号。比如:
#define LED 0 //可以正确执行
#define LED (0) //编译错误
//第二种形式会变成:
setpin((0))
//宏展开就会变成:
do { PIN_(2) = 1; } while(0);
//当然会编译错误。
setpin(LED)
想要递归把括号脱掉是不可行的,上面已经说过一遍。比如这种:
#define PIN0 P00
#define PIN(n) CAT(PIN, n)
//如果可以递归,就会类似这样:
PIN((0))
// PIN() 自己调用自己
PIN(0)
// ->
PIN0
// ->
P00
//但是上面已经提过,宏函数不会嵌套展开,所以结果就只会到这里停止:
PIN(0)
然后编译器会把PIN(0) 识别成一个函数,而不是宏函数,因为宏在送到编译器之前都会被预处理器处理然后清理掉,结果当然是找不过这个函数,或者不小心错误调用了别的函数。为了避免错误调用,最简单的方法当然是遵守命名规则,映射表的宏名字都用大写。
设置低电平和读取引脚值也是类似的实现:
// 设置引脚为低电平
#define clrpin(n) do { PIN(n) = 0; } while(0)
// 读引脚,就是个最简单的表达式
#define getpin(n) (PIN(n))
可以在keil 里生成一个程序,然后进入调试,在反汇编窗口查看生成的实际程序代码。应该会类似下面这样,setpin
只会被转换成一条SETB
指令,clrpin
则是CLR
,没有任何多余的东西:
演示代码见后面的附件 - 1
用宏实现查表 - 宏函数展开顺序和递归
上面的简单映射实现只能囫囵的把引脚编号映射到一个值,如果想要获取更多的寄存器信息,简单的方法当然是可以多重复几遍,比如整出类似这样的表:
#define PIN_1 P01 //引脚编号 -> 引脚
#define PIN_REG_1 P0 //引脚编号 -> 寄存器
#define PIN_BIT_1 1 //引脚编号 -> 第几位
然后用多种宏函数去做不同的映射,得出各种的信息。实际上Arduino 的实现就类似这样,不同的数组存储不同的映射,然后再相应的配上一堆函数。只是感觉比较笨,有没有可能玩儿的更花一点?那就要介绍另外两个工具:
#define FIRST(a, b) a //展开成第一个参数
#define SECOND(a, b) b //展开成第二个参数
很简单,两个宏函数都接受相同数量的多个参数,然后从里面选择一个展开,比如:
FIRST(11, 12)
//展开成第一个参数
11
SECOND(11, 12)
//展开成第二个参数
12
那么如果把映射表定义成这样:
//参数 寄存器Pn, 引脚位n
#define PIN_0 0, 0 //0, 0 表示P0, 0
#define PIN_1 0, 1
然后用上面两个工具应该就可以像这样查表了:
FIRST(PIN_0)
//展开成
FIRST(0, 0)
//->
0
可惜并不能,会编译出错。还可以再实验一下:
FIRST(PIN_0, 99)
//会展开成
PIN_0
//然后
0, 0
SECOND(PIN_0, 99)
//->
99
问题就在于,一个宏函数到底是怎么处理它的参数的。宏函数展开的第一步是参数匹配,要决定参数括号里面的哪个部分对应哪个形参符号。举个例子:
#define OK_0 0
#define OK(n) CAT(OK_, n)
#define NUM 0
OK(NUM)
首先NUM
匹配形式参数n,然后要把实际参数插入到宏函数体中。但是在插入之前,要被插入的参数首先会被展开,也就是:
OK(NUM)
//->
OK(0)
//->
OK_0
//->
0
而如果是把参数直接插入之后再继续展开,就会变成这样:
OK(NUM)
//->
OK_NUM
那到这就已经不能继续了。所以根据这个原理,对SECOND
的调用如果要实现查表的效果,就应该在SECOND
匹配它的参数之前就把PIN_0
展开成0, 0
。这样一来SECOND
实际看到的参数列表就是(0, 0)
。要达成这个效果,就需要一道二传手:
#define UNPACK_SECOND(list) SECOND(list)
看上去UNPACK_SECOND
只接受一个参数,然后也只给SECOND
传递这么一个参数,似乎要编译出错,但是由于参数插入前先展开的机制,实际中会变成这样:
UNPACK_SECOND(PIN_0)
// 参数PIN_0 首先被展开,再插入
SECOND(0, 0)
// ->
0
于是UNPACK_SECOND
发挥的作用类似于给参数先解包了,然后再传递给SECOND
,这样就能如期望般匹配参数。类似的,给FIRST
也要配一个UNPACK_FIRST
,才能实现查表的功能。
查表操作引脚 - CAT,## 操作符的特殊规则
为了方便的从表里提取信息,不妨再多定义两个宏函数包装一下。首先要有一个宏提取出引脚在寄存器里的位,这个很简单:
//将引脚编号转换成寄存器位
#define PIN_BIT(num) UNPACK_SECOND(PIN(num))
PIN_BIT(0)
//->
UNPACK_SECOND(PIN(0))
SECOND(0, 0)
//->
0
然后要提取出引脚对应的寄存器,这一步就要连接一下,要把表里的0
通过CAT
连接成P0
,好像也很简单:
#define PIN_PORT(num) CAT(P, UNPACK_FIRST(PIN(num)))
PIN_PORT(0)
//->
CAT(P, UNPACK_FIRST(PIN(num)))
//参数一层一层展开
CAT(P, FIRST(0, 0))
CAT(P, 0)
//->
P0
可惜并不会这么顺利,上面的展开结果实际上会是:
CAT(P, UNPACK_FIRST(PIN(num)))
//->
PUNPACK_FIRST(PIN(num))
然后找不到符号PUNPACK_FIRST
,就无法继续展开下去了,参数PIN(num)
也不会被处理。问题很显然,CAT
直接把它的两个参数沾到一起了,并没有像期望的那样一层一层进去展开再插入、连接。这个问题和## 操作符的一条特殊规则有关:当宏函数体里面的参数紧邻## 操作符时,宏函数就不会先展开参数,而是直接插入参数。而回顾一下`CAT`` 的定义:
#define CAT(a, b) a ## b
两个参数正好都在## 旁边,所以CAT
函数完全不会对它的参数先处理,只会直接拼接进去,CAT3
也是一样的。定义CAT
代替直接使用## 操作符也就是这个原因,只要一眼看过去没有##,参数展开就会正常进行,相对来说把这条规则造成的不便隔离了一下。也就是说,调用CAT
前,所有参数必须已经展开了。那么就可以参照上一小节的经验,这样做:
#define SUPER_CAT(a, b) CAT(a, b)
就可以让SUPER_CAT
代为CAT
执行参数展开的工作。上面的PIN_PORT
也就能改成这样:
#define PIN_PORT(num) SUPER_CAT(P, UNPACK_FIRST(PIN(num)))
PIN_PORT(0)
//->
SUPER_CAT(P, UNPACK_FIRST(PIN(num)))
//参数一层一层展开
SUPER_CAT(P, SECOND(0, 0))
SUPER_CAT(P, 0)
CAT(P, 0)
//->
P0
有了这两个宏函数用来查表,之前的引脚操作宏就写成了这样~ 吗?
// 设置引脚为高电平
#define setpin(n) do { SUPER_CAT(PIN_PORT(n), PIN_BIT(n)) = 1; } while(0)
// 设置引脚为低电平
#define clrpin(n) do { SUPER_CAT(PIN_PORT(n), PIN_BIT(n)) = 0; } while(0)
// 读引脚,就是个最简单的表达式
#define getpin(n) ( SUPER_CAT(PIN_PORT(n), PIN_BIT(n)) )
当然是不行的,因为PIN_PORT
里也调用了SUPER_CAT
,这样就嵌套了。必须再在中间加一层,让PIN_PORT
先展开成寄存器名之后再送进引脚操作宏里。也就是要这么写:
#define SET_PIN(port, pin) do{ CAT(port, pin) = 1; } while(0)
#define CLR_PIN(port, pin) do{ CAT(port, pin) = 0; } while(0)
#define GET_PIN(port, pin) (CAT(port, pin))
#define setpin(num) SET_PIN(PIN_PORT(num), PIN_BIT(num))
#define clrpin(num) CLR_PIN(PIN_PORT(num), PIN_BIT(num))
#define getpin(num) GET_PIN(PIN_PORT(num), PIN_BIT(num))
//调用
setpin(0)
//->
SET_PIN(PIN_PORT(0), PIN_BIT(0))
SET_PIN(SUPER_CAT(P, UNPACK_FIRST(PIN(0))), UNPACK_SECOND(PIN(0)))
// ... 省略
SET_PIN(CAT(P, 0), 0)
SET_PIN(P0, 0)
//->
do{ CAT(P0, 0) = 1; } while(0)
do{ P00 = 1; } while(0)
这样一来,因为PIN_PORT
作为参数展开的时候还没被插进宏函数体里,所以就没有CAT
的嵌套问题,CAT
开始展开的时候PIN_PORT
已经展开完成变成寄存器参数了。
虽然结果还是要用多种宏函数查表,但是很明显,经过一轮转手,看起来更加不明觉厉了。至于这些寄存器信息能拿来做什么,当然是更多花活儿。这部分演示代码参见附件 - 2。
操作引脚模式寄存器
STC15 单片机的IO 引脚有四种模式,分别是:
- 推挽输出,具有较强的电流输入/输出能力;
- 高阻输入,高阻态,不输出电平;
- 51模式,经典51 单片机的弱上拉准双向IO;
- 开漏模式,就是51 模式去掉了内部上拉,只能输出低电平;
每个IO 口对应两个寄存器用来设置每一个引脚的模式,比如P0 口的模式寄存器是P0M0 和P0M1,这两个寄存器都是不可位寻址的,只能使用位运算来置位或者置零。要设置P0.0 的模式,需要同时操作P0M0.0 和P0M1.0,通过这两位组合出四种模式,如下表:
模式 | M0 | M1 |
---|---|---|
51 | 0 | 0 |
推挽 | 1 | 0 |
高阻 | 0 | 1 |
开漏 | 1 | 1 |
所以,要提供统一的设置方法,可以使用上一节定义的映射表,先找到对应的模式寄存器,再根据引脚位置设置对应的寄存器位。先用类似上一节的方法,定义一个宏,用来查表并生成寄存器名称:
#define SUPER_CAT3(a, b, c)
//生成 PnM0
#defne PIN_MODE_REG0(num) SUPER_CAT3(P, UNPACK_FIRST(PIN(num)), M0)
//生成 PnM1
#defne PIN_MODE_REG1(num) SUPER_CAT3(P, UNPACK_FIRST(PIN(num)), M1)
要注意,两个PIN_MODE_REG
函数里面不能调用PIN_PORT
生成P0
然后再连接成 P0M0
或者P0M1
,因为PIN_PORT
里也调用了SUPER_CAT
,最终都调用了CAT
,这样就会出现宏函数嵌套。
然后还需要对寄存器置位和置零的宏函数:
//给寄存器bit_num 位上置1
#define set_reg_bit(reg, bit_num) do { reg |= 0x01 << bit_num; } while(0)
//给寄存器bit_num 位上置0
#define clr_reg_bit(reg, bit_num) do { reg &= ~ (0x01 << bit_num); } while(0)
//还可以有位翻转
#define flip_reg_bit(reg, bit_num) do { reg ^= 0x01 << bit_num; } while(0)
这几条语句赋值右边的表达式实际使用时都是常量表达式,可以被编译器直接计算优化成一个常量,不会在运行时引入移位和取反运算。最后再来设置不同模式的宏函数,每个模式一个,一共四个,这里只演示设置推挽输出模式的宏:
//推挽输出模式,M0 = 1, M1 = 0
#define set_pin_out(num) do { \
set_reg_bit(PIN_MODE_REG0(num), PIN_BIT(num)); \
clr_reg_bit(PIN_MODE_REG1(num), PIN_BIT(num)); \
} while(0)
缺陷和总结
首先就是之前提到的,定义常量当引脚编号用的时候不能加括号,可能比较反常识,容易导致失误。然后就是一旦编译出错,报错信息都会很难懂,必须有足够的了解才有可能判断出大概是哪儿的问题,也用不了调试器。而且就像上面提到过的,宏函数调用层次一多,最明显的问题就是可能不知道的时候就出现了嵌套,一旦出了问题又难定位,脑子里必须非常清楚每一个宏的效果和内容。所以一般还是不用查表的方法,用那个笨办法就行,相对来说宏的调用过程更清晰明确。
参考资料
本文关于宏的内容基本完全参照了上面这篇,加了点自己的实践总结和臆测。
附件 - 1
简单实现的演示代码,Keil C51 环境。
#include <reg51.h>
#include <intrins.h>
sbit P00 = P0^0;
sbit P01 = P0^1;
sbit P10 = P1^0;
void delay() //@12.000MHz
{
unsigned char i, j, k;
_nop_();
_nop_();
i = 10;
j = 153;
k = 245;
do
{
do
{
while (--k);
} while (--j);
} while (--i);
}
#define PIN_0 P00
#define PIN_1 P01
#define PIN_2 P10
#define CAT(a, b) a ## b
#define PIN(n) CAT(PIN_, n)
// 设置引脚为高电平
#define setpin(n) do { PIN(n) = 1; } while(0)
// 设置引脚为低电平
#define clrpin(n) do { PIN(n) = 0; } while(0)
// 读引脚,就是个最简单的表达式
#define getpin(n) (PIN(n))
#define LED 0
int main(void) {
clrpin(LED);
while(1) {
if(getpin(LED))
clrpin(LED);
else
setpin(LED);
delay();
}
}
附件 - 2
查表演示代码,KEIL C51 环境。
#include <reg51.h>
#include <intrins.h>
sbit P00 = P0^0;
sbit P01 = P0^1;
sbit P10 = P1^0;
void delay() //@12.000MHz
{
unsigned char i, j, k;
_nop_();
_nop_();
i = 10;
j = 153;
k = 245;
do
{
do
{
while (--k);
} while (--j);
} while (--i);
}
#define PIN_0 P00
#define PIN_1 P01
#define PIN_2 P10
#define CAT(a, b) a ## b
#define CAT3(a, b, c) a ## b ## c
#define SUPER_CAT(a, b) CAT(a, b)
#define FIRST(a, b) a
#define SECOND(a, b) b
#define PIN(num) CAT(PIN_, num)
#define PX(port, pin) CAT3(PX_, port, pin)
//参数 寄存器Pn, 引脚位n
#define PIN_0 0, 0 //0, 0 表示P0, 0
#define PIN_1 0, 1
#define PIN_2 1, 0
#define UNPACK_FIRST(list) FIRST(list)
#define UNPACK_SECOND(list) SECOND(list)
#define PIN_PORT(num) SUPER_CAT(P, UNPACK_FIRST(PIN(num)))
#define PIN_BIT(num) UNPACK_SECOND(PIN(num))
#define SET_PIN(port, pin) do{ CAT(port, pin) = 1; } while(0)
#define CLR_PIN(port, pin) do{ CAT(port, pin) = 0; } while(0)
#define GET_PIN(port, pin) (CAT(port, pin))
#define setpin(num) SET_PIN(PIN_PORT(num), PIN_BIT(num))
#define clrpin(num) CLR_PIN(PIN_PORT(num), PIN_BIT(num))
#define getpin(num) GET_PIN(PIN_PORT(num), PIN_BIT(num))
#define LED 0
int main(void) {
clrpin(LED);
while(1) {
if(getpin(LED))
clrpin(LED);
else
setpin(LED);
delay();
}
}