ARM C语言可变参数函数实现原理

注:本文参考韦东山新一期裸机视频《从零实现用于裸机调试的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,有符号数是无符号数的补码。

猜你喜欢

转载自blog.csdn.net/qq_35031421/article/details/109193237