注:本文参考韦东山新一期裸机视频《从零实现用于裸机调试的printf函数》,只用于学习记录。
1. ARM C语言可变参数实现原理
在我们写C语言程序时,经常使用到 printf 函数打印,而 printf 函数就是一个可变参数函数,它的函数原型如下:(在ubuntu终端输入 man 3 printf
命令即可查看)
int printf(const char *format, ...);
其中:1)formmat : 固定参数
2)… :表示可变参数
可变参数的实现最主要最靠的时C语言的指针操作。由于C语言函数参数的入栈顺序是于参数的顺序是相反的(即C语言函数最后一个参数最先入栈,第一个参数最后入栈),我们只要知道第一个参数的地址便可访问到剩余的其他参数。据说,在x86平台下,函数调用时参数传递是使用堆栈来实现的。在ARM平台下,函数参数的传递遵循ATPCS规则,其中可变参数函数参数传递规则如下:
1)当参数不超过4个时,可以使用寄存器R0~R3来传递参数;当参数超过4个时,可以使用数据栈来传递参数。
2)在参数传递时,将所有参数看作是存放在连续的内存字单元的字数据。然后,然后依次将各个字数据传送到寄存器R0、R1、R2、R3中,如果参数多于四个,将剩余的字数据传送到数据栈中,入栈顺序于参数顺序相反。
例如,在ARM平台下,我们可以编写如下代码,通过反汇编观察可变参数函数的参数在内存(栈)的存储情况。
#include "uart.h"
int printf_test(const char *fmt, ...)
{
return 0;
}
int main(int argc, char **argv)
{
printf_test("abc", 1, 2, 3, 4, 5, 6);
return 0;
}
从上面的代可知,我们往printf_test
函数传递了7个参数。接下通过 arm-linux-guneabihf-gcc 编译器编译以上代码,并反汇编,其中以上程序对应的反汇编代码如下:(注:只截取了一部分)
uart.elf: file format elf32-littlearm
Disassembly of section .text:
87800000 <_start>:
87800000: e3a0d482 mov sp, #-2113929216 ; 0x82000000 /*把栈顶地址设置为0x82000000*/
87800004: ea000008 b 8780002c <main> /*跳转到main函数执行*/
87800008 <printf_test>:
87800008: e92d000f push {
r0, r1, r2, r3} /*把r3、r2、r1、r0依次入栈*/
8780000c: e52db004 push {
fp} ; (str fp, [sp, #-4]!) /*fp 入栈,存的是main函数的fp*/
87800010: e28db000 add fp, sp, #0 /*更新fp*/
87800014: e3a03000 mov r3, #0
87800018: e1a00003 mov r0, r3 /*r0作为printf_test的返回值*/
8780001c: e24bd000 sub sp, fp, #0
87800020: e49db004 pop {
fp} ; (ldr fp, [sp], #4) /*main函数的fp出栈*/
87800024: e28dd010 add sp, sp, #16 /*sp回滚,消除printf_test所用的栈空间*/
87800028: e12fff1e bx lr /*返回*/
8780002c <main>:
8780002c: e92d4800 push {
fp, lr} /*把 lr、fp 寄存器依次压栈,fp寄存器就是R11寄存器被称为栈帧寄存器,与sp一同构成函数所用的栈区间*/
87800030: e28db004 add fp, sp, #4 /*更新fp寄存器,即此时的fp所指的地方就是main函数所用栈的起始地址*/
87800034: e24dd018 sub sp, sp, #24 /*开辟24字节的栈空间,4字节对齐*/
87800038: e50b0008 str r0, [fp, #-8] /*在fp-8的地方(即紧跟着前fp压栈的地方)存入r0*/
8780003c: e50b100c str r1, [fp, #-12] /*接着存入r1*/
87800040: e3a03006 mov r3, #6
87800044: e58d3008 str r3, [sp, #8] /*存入printf_test的最后一个参数*/
87800048: e3a03005 mov r3, #5
8780004c: e58d3004 str r3, [sp, #4] /*存入printf_test的倒数第二个参数*/
87800050: e3a03004 mov r3, #4
87800054: e58d3000 str r3, [sp] /*存入printf_test的倒数第三个参数*/
87800058: e3a03003 mov r3, #3 /*把printf_test的倒数第四个参数存入r3寄存器*/
8780005c: e3a02002 mov r2, #2 /*把printf_test的倒数第五个参数存入r2寄存器*/
87800060: e3a01001 mov r1, #1 /*把printf_test的倒数第六个参数存入r1寄存器*/
87800064: e3000080 movw r0, #128 ; 0x80
87800068: e3480780 movt r0, #34688 ; 0x8780 /*把printf_test的第一个参数存入R0,这里存入R0的是第一个参数的地址指针,执行movw、movt这两个指令后,r0 = 0x87800080,即第一天参数的内容存放在0x87800080这个地址,在该地址存放的是0x00636261,即abc的ascii值*/
8780006c: ebffffe5 bl 87800008 <printf_test> /*调用printf_test函数*/
87800070: e3a03000 mov r3, #0
87800074: e1a00003 mov r0, r3
87800078: e24bd004 sub sp, fp, #4
8780007c: e8bd8800 pop {
fp, pc}
注:① 上面汇编代码的链接地址是 0x87800000,代码从 _start 开始执行;② 栈顶的地址设置为0x82000000,设置栈顶地址后,跳转到C程序main函数执行。
另外上面的汇编代码涉及的两条稍微陌生的汇编指令:
movw : 把 16 位立即数放到寄存器的底16位,高16位清0;
movt : 把 16 位立即数放到寄存器的高16位,低 16位不影响。
经过对汇编代码的分析,代码从_start开始执行到main函数调用printf_test后返回的栈空间使用分布图如下:
从上图可知,传入printf_test函数的7个参数被依次从右到左存放到了内存连续的占空间,因此只要得到第一个参数的地址,便可根据连续的内存地址访问到其他剩余的参数。
注:这个7个参数入栈分成了两部分入栈:① 在main函数中把最右边的3个参数(4、5、6)存到栈中,把前面的三个参数分别存放到R0~R3;② printf_test函数然后依次把R3、R2、R1、R0入栈。
编写代码测试,填充printf_test的代码,把传入的参数打印出来。(实验平台为正点原子IMX6ULL开发板,通过UART1串口输入打印信息)
#include "uart.h"
int printf_test(const char *fmt, ...)
{
char *p = (char *)&fmt;
putstr("arg1:");putstr((char *)fmt);putstr("\r\n");
p = p + sizeof(char *); /*sizeof(char *) = 4 因为 imx6ull是32bit的CPU,所有存char数据类型的地址是32bit,即4字节*/
putstr("arg2:");putnum(*p, 10);putstr("\r\n"); /*putnum的第一个参数是要打印的数字,第一个参数是进制,例如putnum(5,10)表示以10进制的方式打印5*/
p = p + sizeof(char *);
putstr("arg3:");putnum(*p, 10);putstr("\r\n");
p = p + sizeof(char *);
putstr("arg4:");putnum(*p, 10);putstr("\r\n");
p = p + sizeof(char *);
putstr("arg5:");putnum(*p, 10);putstr("\r\n");
p = p + sizeof(char *);
putstr("arg6:");putnum(*p, 10);putstr("\r\n");
p = p + sizeof(char *);
putstr("arg7:");putnum(*p, 10);putstr("\r\n");
return 0;
}
int main(void)
{
uart_init();
printf_test("abc", 1, 2, 3, 4, 5, 6);
while(1);
return 0;
}
把编译好的程序拿到 imx6ull 开发板运行,串口输入的结果如下:
由此可见,这七个参数的栈空间是连续的,我们知道第一个参数的地址,便可根据连续的内存地址访问到其他剩余的参数。
2. 改进printf_test打印程序
在VC6.0 头文件stdarg.h中有如下代码:
typedef char * va_list; /*重命名char* 为va_list */
#define _INTSIZEOF(n) ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
#define va_start(ap,v) (ap = (va_list)&v + _INTSIZEOF(v))
#define va_arg(ap,t) (*(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
#define va_end(ap) ( ap = (va_list)0 )
(1) _INTSIZEOF(n) : 用于获取其中一个变参类型占用的空间长度,4字节对齐;
(2) va_start(ap,v) :令 ap 指向第一个变参的地址;
(3) va_arg(ap,t) :取出一个变参的内容,同时把指针指向下一个变参的地址;对于表达式
(*(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
上面表达是的运算顺序为:
① 先运算ap += _INTSIZEOF(t),即ap指向了下一个可变参数的首地址,改变了ap的值;
② 然后计算 [ ap=ap+_INTSIZEOF(t)] - _INTSIZEOF(t),还原当前变量的地址,此时ap的值没有发生改变(即此时ap的值为第①步运算的值,也就是下一个可变参数的地址)。
③ (t*)把当前变量的地址强制转换为t类型的指针,然后 *(t*)取该地址的内容;
④ 最后就实现了取出一个变参的内容,同时把指针指向下一个变参的地址。
(4) va_end(ap):将指针指向 NULL, 防止野指针。
有了上面stdarg.h代码,我们可以把上面的printf_test函数的代码改为:
#include "uart.h"
typedef char * va_list;
#define _INTSIZEOF(n) ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
#define va_start(ap,v) (ap = (va_list)&v + _INTSIZEOF(v))
#define va_arg(ap,t) (*(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
#define va_end(ap) ( ap = (va_list)0 )
int printf_test(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
putstr("arg1:");putstr((char *)fmt);putstr("\r\n");
putstr("arg2:");putnum(va_arg(ap,int), 10);putstr("\r\n");
putstr("arg3:");putnum(va_arg(ap,int), 10);putstr("\r\n");
putstr("arg4:");putnum(va_arg(ap,int), 10);putstr("\r\n");
putstr("arg5:");putnum(va_arg(ap,int), 10);putstr("\r\n");
putstr("arg6:");putnum(va_arg(ap,int), 10);putstr("\r\n");
putstr("arg7:");putnum(va_arg(ap,int), 10);putstr("\r\n");
return 0;
}
int main(void)
{
uart_init();
printf_test("abc", 1, 2, 3, 4, 5, 6);
while(1);
return 0;
}
代码运行结果于前面相同,如下图所示:
3. 根据可变参数函数实现的原理,编写用于裸机调试的printf函数
(1) 基于正点原子imx6ull开发板 uart1 的uart.c代码如下:
#include "uart.h"
#include "imx6ul.h"
void uart_init(void)
{
/*1.使能UART1时钟*/
CCM->CCGR5 |= CCM_CCGR5_CG12(0x3);
/*2.设置引脚复用为UART1功能*/
IOMUXC_SetPinMux(IOMUXC_UART1_TX_DATA_UART1_TX, 0);
IOMUXC_SetPinMux(IOMUXC_UART1_RX_DATA_UART1_RX, 0);
/*3.设置硬件参数,设置为默认值0x10B0*/
IOMUXC_SetPinConfig(IOMUXC_UART1_TX_DATA_UART1_TX, 0x10B0);
IOMUXC_SetPinConfig(IOMUXC_UART1_RX_DATA_UART1_RX, 0x10B0);
/*4.关闭当前串口*/
UART1->UCR1 |= (1 << 0);
/*5.设置UART1传输格式:
* UART1中的UCR2寄存器的关键bit如下:
* [14]: 1:忽略RTS引脚
* [8]: 0:关闭奇偶校验 默认为0;
* [6]: 0:停止位1位 默认为0;
* [5]: 1:数据长度8位
* [2]: 1:发送数据使能
* [1]: 1:接受数据使能
*/
UART1->UCR2 |= (1 << 14) | (1 << 5) | (1 << 2) | (1 << 1);
/*6.设置串口MUXED模型,bit2必须设置为1*/
UART1->UCR3 |= (1 << 2);
/*7.设置波特率
* 根据芯片手册得知波特率计算公式:
* Baud Rate = Ref Freq / (16 * (UBMR + 1)/(UBIR+1))
* 当我们需要设置 115200的波特率
* UART1_UFCR [9:7]=101,表示不分频,得到当前UART参考频率Ref Freq :80M ,
* 带入公式:115200 = 80000000 /(16*(UBMR + 1)/(UBIR+1))
*
* 选取一组满足上式的参数:UBMR、UBIR即可
*
* UART1_UBIR = 71
* UART1_UBMR = 3124
*/
UART1->UFCR = 5 << 7; /* Uart的时钟clk:80MHz */
UART1->UBIR = 71;
UART1->UBMR = 3124;
/*8.使能串口*/
UART1->UCR1 |= (1 << 0);
}
void putchar(unsigned char c)
{
while (!((UART1->USR2) & (1 << 3))); /*等等上一个字符发送完毕*/
UART1->UTXD = c & 0xff;
}
unsigned char getchar(void)
{
while(!((UART1-> USR2) & (1 << 0)));
return (unsigned char)UART1->URXD;
}
void puts(char *s)
{
while(*s)
{
putchar((unsigned char)*s);
s++;
}
}
char num_tab[] = {
'0', '1', '2', '3', '4', '5', '6', '7', '8', '9','a','b','c','d','e','f'};
/* 功能:按照base的进制值打印num对应的进制数
* 参数: num: 输入打印的数值
* base:进制值
* flag: 1: 把num 转换为有符号数打印; 0:num为无符号数打印
*/
void putnum(long num, int base, int flag)
{
unsigned long m;
char buf[30];
char *s = buf + sizeof(buf);
*--s = '\0';
if(num < 0 && flag == 1) m = -num;
else m = (unsigned long)num;
do{
*--s = num_tab[m % base];
m /= base;
}while(m != 0);
if(num < 0 && flag == 1) *--s = '-';
puts(s);
}
int raise(void)
{
return 0;
}
注:① putnum函数使用到除法运算和求模运算,需要提供除法库,否则编译时会产生如下错误:
一般的交叉编译工具链都有基本的数据运算,它位于libgcc.a,因此,为了支持除法运算,我们修改Makefile要把libgcc.a 链接到程序里,链接程序的Makefile命令修改如下所示:
built-in.o : $(curdir_objs) $(subdir_objs)
$(LD) -r -o $@ $^ -lgcc -L/tools/gcc-linaro-4.9.4-2017.01-x86_64_arm-linux-gnueabihf/lib/gcc/arm-linux-gnueabihf/4.9.4/
注:链接指令中,每个“-L”表示库在哪里,即它的目录;“-l” 表示哪个库,即库的名称, -lgcc 表示会链接“libgcc.a”库。本人am-linux-gnueabihf-gcc编译器的libgcc.a 的路径是 /tools/gcc-linaro-4.9.4-2017.01-x86_64_arm-linux-gnueabihf/lib/gcc/arm-linux-gnueabihf/4.9.4/。
② 添加完libgcc.a库后,重新编译会产生以下错误:
arm-linux-gnueabihf-ld -Timx6ull.lds -o uart.elf built-in.o
built-in.o: In function `__aeabi_idiv0':
/home/tcwg-buildslave/workspace/tcwg-make-release/label/docker-trusty-amd64-tcwg-build/target/arm-linux-gnueabihf/snapshots/gcc-linaro-4.9-2017.01/libgcc/config/arm/lib1funcs.S:1331: undefined reference to `raise'
make: *** [Makefile:39: all] Error 1
这个错误的解决方法:添加raise函数。
int raise(void)
{
return 0;
}
(2) 实现printf函数的printf.c文件代码如下:
#include "uart.h"
typedef char * va_list;
#define _INTSIZEOF(n) ((sizeof(n) + sizeof(int) - 1) & ~(sizeof(int) - 1))
#define va_start(ap,v) (ap = (va_list)&v + _INTSIZEOF(v))
#define va_arg(ap,t) (*(t *)((ap += _INTSIZEOF(t)) - _INTSIZEOF(t)))
#define va_end(ap) ( ap = (va_list)-1 )
static int vprintf(const char *fmt, va_list ap)
{
for(; *fmt != '\0'; fmt++)
{
if(*fmt != '%'){
putchar(*fmt);
continue; /*终止本次for循环,即本次循环执行到这里不再往下执行,开始下一次for循环*/
}
fmt++;
switch(*fmt){
case 'd': putnum(va_arg(ap, int), 10, 1);break;
case 'o': putnum(va_arg(ap, unsigned int), 8, 0); break;
case 'u': putnum(va_arg(ap, unsigned int), 10, 0); break;
case 'x': putnum(va_arg(ap, unsigned int), 16, 0); break;
case 'c': putchar(va_arg(ap, int)); break;
case 's': puts(va_arg(ap,char *)); break;
default:
putchar(*fmt);
break;
}
}
return 0;
}
int printf(const char *fmt, ...)
{
va_list ap;
va_start(ap, fmt);
vprintf(fmt, ap);
va_end(ap);
return 0;
}
注:由于printf函数的名称与C语言库的printf冲突(前面uart.c的putchar、puts同理),编译时会产生以下警告:
解决这类警告的方法:在arm-linux-gnueabihf-gcc添加 -fno-builtin 编译选项。
(3) 编写测试程序:
#include "uart.h"
int main(void)
{
uart_init();
printf("printf test\r\n");
printf("test char:%c, %c\r\n",'a', 'B');
printf("test decimal num:%d\r\n", 123456);
printf("test decimal num:%d\r\n", -123456);
printf("test hex num:0x%x\r\n", 0x55aa55aa);
printf("test oct num:0%o\r\n",012); /*C 语言八进制以0开头,注意是数字 0,不是字母 o*/
printf("test unsigned num:%u\r\n", -1);
printf("test string: %s\r\n", "Hello world!");
while(1);
return 0;
}
编译后,打印的结果如下图所示:
注:(1) 有符号数强制转为无符号数:① 有符号数为正数,强制转换为无符号数,转换前后不变;② 有符号数为负数,强制转换为无符号数是有符号数的补码(负数补码:符号位(即最高位)除外,剩余位取反,加1;正数补码:与原码相同)。
(2) 无符号数强制转换为有符号数:① 符号位(即最高位)为0,有符号数与无符号数一样;② 符号位(即最高位)为1,有符号数是无符号数的补码。