C51 实现Arduino 式的IO 引脚编号映射和统一的IO 操作 - C语言宏魔法的简单实践

也就是整一个类似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 引脚有四种模式,分别是:

  1. 推挽输出,具有较强的电流输入/输出能力;
  2. 高阻输入,高阻态,不输出电平;
  3. 51模式,经典51 单片机的弱上拉准双向IO;
  4. 开漏模式,就是51 模式去掉了内部上拉,只能输出低电平;

每个IO 口对应两个寄存器用来设置每一个引脚的模式,比如P0 口的模式寄存器是P0M0 和P0M1,这两个寄存器都是不可位寻址的,只能使用位运算来置位或者置零。要设置P0.0 的模式,需要同时操作P0M0.0 和P0M1.0,通过这两位组合出四种模式,如下表:

模式M0M1
5100
推挽10
高阻01
开漏11

所以,要提供统一的设置方法,可以使用上一节定义的映射表,先找到对应的模式寄存器,再根据引脚位置设置对应的寄存器位。先用类似上一节的方法,定义一个宏,用来查表并生成寄存器名称:

#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. 宏魔法 C Pre-Processor Magic

本文关于宏的内容基本完全参照了上面这篇,加了点自己的实践总结和臆测。

附件 - 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();
	}
}
  • 2
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值