printf在uCOS51上的移植和浮点数显示
asdjf@163.com 2003/10/20
printf函数是C语言里应用最为广泛的函数之一,我们初学C语言时实现的第一个程序《Hello the world》,就包含 printf语句。它的应用十分灵活,可以打印各种类型数据,可变数量的变量,表达式,是非常理想的输出函数,广泛用于结果输出,中间变量显示,调试等。然而,编译器将其作为标准库函数,不提供源代码,其本身代码量也偏大,无法实现嵌入式系统按需裁减的要求,并且有些printf库代码不支持重入。
解决方法是把Linux里的相关源码简化后移植到C51里。关键点在于理解变参函数、参数传递规则、浮点数存储格式。
C编译器一般将函数参数按从右至左的顺序依次压入堆栈(C51在使用reentrant关键字后也这么处理),函数内部处理参数变量时直接在堆栈上寻址,局部变量紧跟在参数后面存放,函数返回时出栈,参数和局部变量所占用空间自动释放。例如:
fun(char *fmt,char a,int b long c,float d) reentrant
的堆栈结构如图1所示:
------------------
|float d 4 bytes |
+10 ------------------
|long c 4 bytes |
+6 ------------------
|int b 2 bytes |
+4 ------------------
|char a 1 bytes |
+3 ------------------
|char *fmt 3bytes|
SP+0-->------------------
| 局部变量 |
------------------
图1.fun函数参数和局部变量在堆栈里的结构
C51编译器从右向左依次将float/long/int/char/char *压入仿真堆栈,各种数据类型所占空间大小如图1,例如 char占1字节,float占4字节等。值得一提的是,常数压栈的格式:0-255按1字节压栈,256-32767压成2字节,32768(8000H)或以上压成4字节,带有l/L结尾的常数占4字节。
上面的函数fun内部可以通过函数名称访问各个变量,C编译器自动把函数名转换成地址,如:访问long c转换成访问SP+6,访问char a转换成访问SP+3等。写成表达式为:
c=0x12345678;======>(SP+6)=0x12345678
a='y';=============>(SP+3)='y'
总之,上面的函数通过显式地指定函数名和数据类型完成参数的传递和访问,内部细节由C编译器完成,对用户透明。
这种方式的好处是表达清晰,结构严谨,屏蔽底层细节;坏处是不够灵活,参数必须在处理前显式确定并固定不变,这给我们用同一函数处理不同情况带来了困难,C的解决方案是引入“变参函数”(详见C语言大全),如下:
fun(char *fmt,...) reentrant
...表示有0到N个可变数量参数,C编译器此时不检查参数匹配,传递参数规律与一般函数相同。如果我们用这个函数取代前一个函数,但仍按前一函数的调用方式调用,那么,参数在堆栈里的位置仍如图1所示。此时,函数形参只有“...”没有具体变量名,如何引用形参变量呢?观察图1堆栈结构可知,如果知道堆栈内第一个参数的起址和每个参数的数据类型及他们的排列顺序,就可以通过指针访问指定的变量。例如:
知道堆栈内第一个参数的起址SP和每个参数的数据类型及排列顺序(char*/char/int/long/float),就可以通过SP,SP+3,SP+4,SP+6,SP+10访问原来必须通过参数名访问的fmt,a,b,c,d变量。写成C语言就是:
fun("yy",'y',(int)2,5L,-12.5);
fun(char *fmt,...) reentrant
{
void *p;
p=&fmt;
//此时*p指向字符串"yy"首址,**p是字符串第一个字符'y'。
p=(char **)p+1;
//此时*((char *)p)为字符'y'。
p=(char *)p+1;
//此时*((int *)p)为0x0002。
p=(int *)p+1;
//此时*((long *)p)为0xC1480000,即-12.5的IEEE-754标准格式。
p=(float *)p+1;
}
测试代码:
void fun(char *fmt,...) reentrant
{
void *p;
p=&fmt;
PrintChar(**((char **)p));
p=((char **)p) +1;
PrintChar(*((char *)p));
p=((char *)p) +1;
PrintLong(*((int *)p));
p=((int *)p) +1;
PrintLong(*((long *)p));
p=((long *)p) +1;
PrintLong(*((long *)p));
p=((float *)p) +1;
}
显示结果:yy0000000200000005C1480000
由上面知,在C里不用显式使用SP等堆栈指针,而是使用void指针指向各种类型数据。变参函数的参数传递和获取就是这样运做的,知道了它的原理,就不难理解printf的实现了。
我所移植的printf支持标准或长二进制/八进制/十进制/十六进制/无符号整数,支持字符、字符串、浮点数、百分号%显示。其中,浮点数在整个范围内被完全支持,统一采用科学记数法显示。对应的指示符如下:
c 字符 f 浮点数 s 字符串 % 百分号显示
d/ld 2字节/4字节有符号整数 u/lu 2字节/4字节无符号整数
x/lx 2字节/4字节十六进制数 o/lo 2字节/4字节八进制数
b/lb 2字节/4字节二进制数
printf的功能是字符串化数据,它的第一个参数是格式化字符串fmt,用其指示第一个参数在堆栈里的起址和其后各个参数的数据类型。知道了参数堆栈起址和各个参数的类型和排放次序,就可以依次取出各个参数并字符串化。详细过程参见yyprintf源代码。同时,注意到参数是依靠起址和数据长度信息依次读出来的,那么,yyprintf的参数必须与格式化参数的指示相同,否则参数数据会乱掉。对于不能肯定的转化数据类型建议加上强制类型定义,如(int) 2。特别是常数的转换类型容易搞错。
printf大部分代码与硬件无关,只有参数堆栈结构和打印一个字符putchar()函数是硬件相关的。移植printf时只要修改 putchar()函数和堆栈结构即可。putchar()函数的功能一般是向串口输出一个字符,也可以向其他显示设备输出一个字符,取决于你的驱动程序。我已经在uCOS51里实现了PrintChar函数,直接调用就可以了。其实,在X86、POWERPC、ARM等32位CPU上移植printf 更加有效和方便。
测试举例:
float r=1.9835671E-10,pi=3.1415926;
yyprintf("R=%f Circle area=%f\n",r,pi*r*r);
结果:
R=1.983567E-10 Circle area=1.236071E-19
源代码:
//============================================================================================
//
//============================================================================================
void yyprintf(char *fmt,...) reentrant //自编简单printf等效函数
{
void *p; //任意指针,可以指向任何类型,C语法不对其严格要求。
char ch;
unsigned char j;
p=&fmt;
p=(char **)p+1; //此处p是指向指针的指针,fmt是字符串指针,p是指向fmt的指针
while(1){
while((ch=*fmt++)!='%'){
if(ch=='\0') return;
else if(ch=='\n'){PrintChar(10);PrintChar(13);}
else if(ch=='\t'){
for(j=0;j<TABNum;j++)
PrintChar(' ');
}
else PrintChar(ch);
}
ch=*fmt++;
switch(ch){
case 'c':
PrintChar(*((char *)p));
p=(char *)p+1;
break;
case 'd':
PrintN(*((int *)p),10);
p=(int *)p+1;
break;
case 'x':
PrintN(*((int *)p),16);
p=(int *)p+1;
break;
case 'o':
PrintUN(*((int *)p),8);
p=(int *)p+1;
break;
case 'b':
PrintUN(*((int *)p),2);
p=(int *)p+1;
break;
case 'l':
ch=*fmt++;
switch(ch){
case 'd':
PrintLN(*((long *)p),10);
p=(long *)p+1;
break;
case 'o':
PrintLUN(*((long *)p),8);
p=(long *)p+1;
break;
case 'u':
PrintLUN(*((unsigned long *)p),10);
p=(unsigned long *)p+1;
break;
case 'b':
PrintLUN(*((long *)p),2);
p=(long *)p+1;
break;
case 'x':
PrintLN(*((long *)p),16);
p=(long *)p+1;
break;
default:
return;
}
break;
case 'f':
DispF(*((float *)p));
p=(float *)p+1;
break;
case 'u':
PrintUN(*((unsigned int *)p),10);
p=(unsigned int *)p+1;
break;
case 's':
PrintStr(*((char **)p));
p=(char **)p+1;
break;
case '%':
PrintChar('%');
p=(char *)p+1;
break;
default:
return;
}
}
}
void PrintN(int n,int b) reentrant //十进制显示整形数
{
if(b==16){PrintWord(n);return;}
if(n<0){PrintChar('-');n=-n;}
if(n/b)
PrintN(n/b,b);
PrintChar(n%b+'0');
}
void PrintUN(unsigned int n,unsigned int b) reentrant //十进制显示无符号整形数
{
if(b==16){PrintWord(n);return;}
if(n/b)
PrintUN(n/b,b);
PrintChar(n%b+'0');
}
void PrintLN(long n,long b) reentrant //十进制显示长整形数
{
if(b==16){PrintLong(n);return;}
if(n<0){PrintChar('-');n=-n;}
if(n/b)
PrintLN(n/b,b);
PrintChar(n%b+'0');
}
void PrintLUN(unsigned long n,unsigned long b) reentrant //十进制显示无符号长整形数
{
if(b==16){PrintLong(n);return;}
if(n/b)
PrintLUN(n/b,b);
PrintChar(n%b+'0');
}
参考文献:
1。《ROM版本下系统调试信息的一种显示方法》合肥工业大学 彭良清 《单片机与嵌入式系统应用》p22页2002(1-6)
TO BE CONTINUED...
浮点数显示
asdjf@163.com 2003/10/20
C51里用4字节存储一个浮点数,格式遵循IEEE-754标准(详见c51.pdf第179页说明)。一个浮点数用两个部分表示,尾数和2的幂,尾数代表浮点上的实际二进制数,2的幂代表指数,指数的保存形式是一个0到255的8位值,指数的实际值是保存值(0到255)减去127,一个范围在-127到+128之间的值,尾数是一个24位值(代表大约7个十进制数),最高位MSB通常是1,因此不保存。一个符号位表示浮点数是正或负。
浮点数保存的字节格式如下:
地址 +0 +1 +2 +3
内容 SEEE EEEE EMMM MMMM MMMM MMMM MMMM MMMM
这里
S 代表符号位,1是负,0是正
E 偏移127的幂,二进制阶码=(EEEEEEEE)-127。
M 24位的尾数保存在23位中,只存储23位,最高位固定为1。此方法用最较少的位数实现了较高的有效位数,提高了精度。
零是一个特定值,幂是0 尾数也是0。
浮点数-12.5作为一个十六进制数0xC1480000保存在存储区中,这个值如下:
地址 +0 +1 +2 +3
内容0xC1 0x48 0x00 0x00
浮点数和十六进制等效保存值之间的转换相当简单。下面的例子说明上面的值-12.5如何转换。
浮点保存值不是一个直接的格式,要转换为一个浮点数,位必须按上面的浮点数保存格式表所列的那样分开,例如:
地址 +0 +1 +2 +3
格式 SEEE EEEE EMMM MMMM MMMM MMMM MMMM MMMM
二进制 11000001 01001000 00000000 00000000
十六进制 C1 48 00 00
从这个例子可以得到下面的信息:
符号位是1 表示一个负数
幂是二进制10000010或十进制130,130减去127是3,就是实际的幂。
尾数是后面的二进制数10010000000000000000000
在尾数的左边有一个省略的小数点和1,这个1在浮点数的保存中经常省略,加上一个1和小数点到尾数的开头,得到尾数值如下:
1.10010000000000000000000
接着,根据指数调整尾数.一个负的指数向左移动小数点.一个正的指数向右移动小数点.因为指数是3,尾数调整如下:
1100.10000000000000000000
结果是一个二进制浮点数,小数点左边的二进制数代表所处位置的2的幂,例如:1100表示(1*2^3)+(1*2^2)+(0*2^1)+(0*2^0)=12。
小数点的右边也代表所处位置的2的幂,只是幂是负的。例如:.100...表示(1*2^(-1))+(0*2^(-2))+(0*2^(-2))...=0.5。
这些值的和是12.5。因为设置的符号位表示这数是负的,因此十六进制值0xC1480000表示-12.5。
浮点数错误信息
8051没有包含捕获浮点数错误的中断向量,因此,你的软件必须正确响应这些错误情况。
除了正常的浮点数值,还包含二进制错误值。这些值被定义为IEEE标准的一部分并用在正常浮点数操作过程中发生错误的时候。你的代码应该在每一次浮点操作完成后检查可能出现的错误。
名称 值 含义
NaN 0xFFFFFFF 不是一个数
+INF 0x7F80000 正无穷(正溢出)
-INF 0xFF80000 负无穷(负溢出)
你可以使用如下的联合体(union)存储浮点数。
union f {
float f; //浮点值
unsigned long ul; //无符号长整数
};
这个union包含一个float和一个unsigned long以便执行浮点数学_运算并响应IEEE错误状态。
以上是KEIL在线帮助的中译文,下面我们讨论如何显示浮点数。
尾数为24bit,最高可表达的整数值为2^24-1=16777215,也就是说,小于等于16777215的整数可以被精确显示。这决定了十进制浮点数的有效位数为7位,10^7<16777215<10^8,10的7次方以内的数小于16777215,可以精确表示。使用科学记数法时,整数部分占1位,所以小数部分最大占7-1=6位,即最大有6位十进制精度。
长整形数和浮点数都占4字节,但表示范围差别很大。浮点数的范围为+-1.175494E-38到+-3.402823E+38,无符号长整形数范围为0到4294967295。显示浮点数要用到长整形数保存数据,可他们范围差这么多,怎么办呢?
仔细观察十进制浮点数的显示,有一个尾数和一个阶码,由上面论证可知32位IEEE-754浮点数最大有效数字为7位十进制数,超出此范围的数字有截断误差,不必理会,因此,浮点数尾数能够放在长整形数里保存。阶码为-38到38,一个char型变量就可以保存。
综上所述,以10^7的最大跨度为窗口(小于10^7也可以,如:10,100...10000等,但决不能大于它,那样会超出精度范围),定位浮点数的量级,然后取出7位尾数的整数值存于长整形数里,再调整阶码,就可以精确显示此浮点数。
量级尺度如下:
(-38)-(-35)-(-28)-(-21)-(-14)-(-7)-(0)-(7)-(14)-(21)-(28)-(35)-(38)
请严格按照KEIL手册给出的浮点数范围显示,因为数值空间没有完全使用,有些值用于错误指示和表示正负无穷。小于1.175494E-38的数仍可以显示一些,但最好不用,以免出错。我采用直接判断的方法,剔除此种情况。
在计算机里结合律不成立,(a*b)*c!=a*(b*c),原则是先让计算结果值动态范围小的两个数运算,请注意程序里的写法。
注:(1E38/b)*1E6不要写成1E44/b,因为无法在32位浮点数里保存1E44,切记!
计算机使用二进制数计算,能有效利用电子器件高速开关的特性,而人习惯于十进制数表示,二进制和十进制没有方便的转换方法,只能通过大量计算实现,浮点数的十进制科学记数法显示尤其需要大量的运算,可见,显示一个浮点数要经过若干次浮点运算,没有必要就不要显示,否则,花在显示上的时间比计算的耗时都要多得多。 |