固件下printf函数分析
这篇文章主要来探索PMON固件下调试信息的输出过程。在固件的启动过程中,可以通过串口看到一些调试信息或日志信息的输出,在显示控制器初始化完毕后,同样可以看到日志信息的输出。那么就会有以下几个疑问:
- 串口的输出信息是如果过来的?
- 显示器的信息是如何输出的?
- 显示器的信息和串口信息是如何同步的?
针对第一个问题,有一个大概的猜想就是通过读写串口控制器的DATA寄存器来完成,具体到中间的部分,不是特别清楚了。而第二个问题,只知道在PMON固件下的显示采用的是内存,并没有使用显存,具体怎么显示的不得知。最后一个问题,一点思路也没有。
1. 初始化过程
这部分初始化过程分为三个步骤,首先是在代码编译阶段,通过定义构造函数__attribute__ ((constructor))关键词,gcc编译器会将init_fs代码保存在文本段,然后将init_fs函数指针放入特殊段init_array中,最后连接器将所有保存在init_array中的函数指针组合成ctors段。这部分过程对应下面图中紫色的框。
在代码初始化过程中会调用__init函数,该函数会执行之前在编译中注册到__CTOR_LIST__中的所有函数,其中就包括init_fs。通过这种方式,就可以确定init_fs是如何调用的,这是第二步。总结下来就是程序执行过程中调用保存在构造段的函数来实现初始化。最后是具体函数的执行过程,devinit函数负责初始化DevTable表,将ConfigTable表的数据写入DevTable表中。除此之外,通过调用DevTable表中的handler函数指针,对具体的显示终端进行初始化,在这里仅完成NS16550控制器的初始化,显示终端直接返回1。termfs结构体指针写入file结构体数组对应的变量中,调用term_open打开对应的显示终端。在PMON固件中,一共注册了6个终端,在这里只关注终端1和终端4,分别对应串口和显示,6个终端对应的fs函数是相同的,也就是说6个终端执行的读写都是调用termfs结构体中的读写函数。
下图为关键结构体的成员数值,在代码中DevTable结构体是提前定义的,定义的过程中指明了后续的处理函数,然后在init_fs函数初始化的过程中,将DevTable数值填充到了DevTable结构体中,这个结构体在后面很多函数中都有使用。_file结构体也是在init_fs初始化的过程中实现的。在DevTable结构体中可以找到对应fd的处理函数,在file结构体中可以找到对应fd的读写函数。
2. 数据流通过程
整个框架划分为三部分,由上至下分别为应用层、中间层和驱动层。其中应用层完成数据格式的封装,调用file结构体数据中对应的wirite函数;中间层主要是termfs相关的部分,中间层完成了根据显示终端创建对应的发送序列,找到之前注册的handler函数;驱动层是各显示终端的handler函数。
下面详细分析上述过程,在代码中调用printf函数会执行vfprintf函数,该函数的第一个参数表示具体的显示终端,对于写的过程,参数为stdout,该参数为一个全局变量,由iob[1]结构体数组指定,对应的fd为1,valid变量也为1。再往下是调用fput函数,申请buf空间保存要打印的内容,后面是执行write函数,直至在file结构体数组中找到fd对应的写函数,并执行该函数。在初始化的过程中file[1]对应的写函数为term_wirte。接下来进入中间层,在term_write函数中,首先是将buf数组中的内容传递至发送队列中,也就是构造发送队列,然后调用scandevs函数,根据传递的desl数值在DevTable中查找对应的handler函数并执行。注意在term_write函数中实现了串口终端和显示终端的同时输出,在该函数中通过dsel=DevTable[dsel+1].handler?dsel+1:0修改dsel的数值,在DevTable中第一项存储的是有关串口终端的信息,第二项存储的是有关显示终端的信息,通过上述的操作,scandevs()函数的调用执行了两次,也分别构造了两个终端对应的发送队列,两个终端发送队列的数据源是一致的,为term_write函数传入的buf。在这里output_to_both变量控制着显示终端和串口终端是否同时显示,如果是1,则开启同时显示,如果是0,则只在串口终端显示。
129 int term_write (int fd, const void *buf, size_t nchar)
131 {
132 DevEntry *p;
133 struct TermDev *devp;
134 char *buf2 = (char *)buf;
135 int i, n;
136 int dsel;
137 int count;
138
139 devp = (struct TermDev *)_file[fd].data;
140 p = &DevTable[devp->dev];
141 n = nchar;
142 dsel=devp->dev;
143
144 do
145 {
146 p = &DevTable[dsel];
147 buf2 = (char *)buf;
148 n = nchar;
150 while (n > 0) {
152 while(!tgt_smplock());
154 i = Qspace (p->txq);
155 while (i > 2 && n > 0) {
156 if ((p->t.c_oflag & ONLCR) && *buf2 == '\n') {
157 Qput(p->txq, '\r');
158 i--;
159 }
160 Qput(p->txq, *buf2++);
161 n--;
162 i--;
163 }
164 tgt_smpunlock();
167 while (Qused(p->txq)) {
168 scandevs();
169 }
170 }
171 dsel=DevTable[dsel+1].handler?dsel+1:0;
172 }while(dsel!=devp->dev && output_to_both);
174 return (nchar);
175 }
找到对应handler函数后,通过传递OP_TX参数,实现数据的发送功能。在驱动层的handler函数中实现了OP_TX时要调用的函数。对于串口终端,调用outb(&dp->data, data)将data数据写入到串口控制器的DATA寄存器中,从而实现数据的写入。对于显示终端,调用相对比较复杂,要先通过video_putc(data&0xff)函数,再执行video_putchar 函数,最后调用到video_drawchars函数,将要显示的字符根据设定的显示模式写入到对应的显示缓冲区中,从而实现字符在显示器上的显示。video_fb_address就是显示缓冲区的地址,通过向该地址写入数据就可以进行显示。
3. 字符显示底层软件原理
讨论到显示终端的输出,有必要分析下软件层面是如何将字符的信息写入显示缓冲区的,对于写入缓冲区后,字符是如何显示到显示器的过程不在此次讨论的范围中。查看7A1000芯片的用户手册,寄存器framebufferAddr存放的是显示缓冲区的地址,数值为0x07000000。
在缓冲区的初始化过程中,除了向framebufferAddr寄存器写入地址信息,还需要依据当前的显示模式,写入显示的行和列相关的数据。如下所示,设定当前的显示模式为640×480,在vgamode数组中定义了该显示模式下的时钟、前沿、同步以及后沿的大小,初始化的过程中会将这些数据写入7A1000对应的寄存器中。
对于显示器前沿、后沿这些概念可以看下面这张图片,对于640×480的显示模式,在行方面,前沿大小为16,后沿大小为48,行同步脉冲大小为96,实际的大小为640,列和行类似,不再阐述具体数值。
对于显示初始化完成之后,接下来分析下具体一个字符,例如A是如何写入缓冲区的。有关的核心代码如下:
521 void video_drawchars (int xx, int yy, unsigned char *s, int count)522 {523 unsigned char *cdat, *dest, *dest0;524 int rows, offset, c;525 int i;526 527 if(disableoutput)return;528 529 offset = yy * VIDEO_LINE_LEN + xx * VIDEO_PIXEL_SIZE;530 dest0 = video_fb_address + offset;531 switch (VIDEO_DATA_FORMAT) {
619 case GDF_16BIT_565RGB:620 while (count--) {621 c = *s;622 cdat = vide_fontdata + c * VIDEO_FONT_HEIGHT;623 for (rows = VIDEO_FONT_HEIGHT, dest = dest0;624 rows--;625 dest += VIDEO_LINE_LEN) {626 unsigned char bits = *cdat++;627 628 ((unsigned int *) dest)[0] = SHORTSWAP32 ((video_font_draw_table16 [bits >> 6] & eorx) ^ bgx);629 ((unsigned int *) dest)[1] = SHORTSWAP32 ((video_font_draw_table16 [bits >> 4 & 3] & eorx) ^ bgx);630 ((unsigned int *) dest)[2] = SHORTSWAP32 ((video_font_draw_table16 [bits >> 2 & 3] & eorx) ^ bgx);631 ((unsigned int *) dest)[3] = SHORTSWAP32 ((video_font_draw_table16 [bits & 3] & eorx) ^ bgx);632 }633 dest0 += VIDEO_FONT_WIDTH * VIDEO_PIXEL_SIZE;634 s++;635 }636 break;}}
具体下来有以下几步:
- 从vide_fontdata数组中定位到显示的字符数据;
- 每一次从vide_fontdata数组中取一项数据,大小为8位;
- 将vide_fontdata数组取出的数值两位一个组合作为参数在video_font_draw_table16数组内查找对应的结果;
- 将video_font_draw_table16找到的结果与eorx进行与操作,然后再与背景色进行异或就得到了要写入缓冲区的数值,大小为32位,代表两个像素点。
- 经过上述的操作,8位原始数据经过video_font_draw_table16数组扩充之后,得到了一行128位的数据。
- eorx是前景色和背景色进行异或后的数值,通常情况下,背景色数值为0,前景色数值设置为灰色。
将vide_fontdata数组的内的数据经过多次上述的操作,一个字符的绘制就会完成。在这里,一个字符由16×16个像素点组成。字符绘制的顺序为按照列以此推进,一列4个像素点。如下所示,为字符A绘制完成后的示意图。
其他字符类似,最终在写满整个屏幕的内容后,显示缓冲区与实际显示器的数据方面的关系如下所示。在内存中是一行一行的存储要显示的数据,一行的大小为0x640。如果取消某个位置的显示,就将对应的缓冲区数据写成背景色数值即可。
4. 总结
回到一开始的问题。串口的输出信息是如果过来的?串口的信息是通过操作数据写入串口控制器的DATA寄存器来完成的,具体过程参见第二部分。显示器的信息是如何输出的?显示字符通过写入到缓冲区来完成显示的,源数据是vide_fontdata数组,本质是将前景色的什么位写入到缓冲区,继而实现字符的显示,具体请看第三部分。显示器的信息和串口信息是如何同步的?通过设置output_to_both这个变量控制。
到这里分析基本结束,总结一下就是首先是对数据流相关的初始化做了分析,这是基础部分,初始化是在构造函数中完成。然后是分析数据从printf到底层的整个过程,划分为了三层,分别为应用层、中间层和驱动层。最后是详细研究了下显示字符的数据是如何写入到缓冲区的。