提到Android系统的UI,我们最先接触到的便是系统在启动过程中所出现的画面了。Android系统在启动的过程中,最多可以出现三个画面,每一个画面都用来描述一个不同的启动阶段。本文将详细分析这三个开机画面的显示过程,以便可以开启我们对Android系统UI实现的分析之路。

第一个开机画面是在内核启动的过程中出现的,它是一个静态的画面。第二个开机画面是在init进程启动的过程中出现的,它也是一个静态的画面。第三个开机画面是在系统服务启动的过程中出现的,它是一个动态的画面。无论是哪一个画面,它们都是在一个称为帧缓冲区(frame buffer,简称fb)的硬件设备上进行渲染的。接下来,我们就分别分析这三个画面是如何在fb上显示的。

---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

由于篇幅过长,将内核启动的过程init进程启动的过程放在此篇,下篇单独解析系统服务启动过程的画面流程。

----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------

1. 第一个开机画面的显示过程

Android系统的第一个开机画面其实是Linux内核的启动画面。在默认情况下,这个画面是不会出现的,除非我们在编译内核的时候,启用以下两个编译选项:

CONFIG_FRAMEBUFFER_CONSOLE

CONFIG_LOGO

第一个编译选项表示内核支持帧缓冲区控制台,它对应的配置菜单项为:Device Drivers ---> Graphics support ---> Console display driver support ---> Framebuffer Console support

第二个编译选项表示内核在启动的过程中,需要显示LOGO,它对应的配置菜单项为:Device Drivers ---> Graphics support ---> Bootup logo

帧缓冲区硬件设备在内核中有一个对应的驱动程序模块fbmem,它实现在文件kernel/goldfish/drivers/video/fbmem.c中,它的初始化函数如下所示:

/***      fbmem_init - init frame buffer subsystem**      Initialize the frame buffer subsystem.**      NOTE: This function is _only_ to be called by drivers/char/mem.c.**/static int __init
fbmem_init(void)
{proc_create("fb", 0, NULL, &fb_proc_fops);if (register_chrdev(FB_MAJOR,"fb",&fb_fops))printk("unable to get major %d for fb devs\n", FB_MAJOR);fb_class = class_create(THIS_MODULE, "graphics");if (IS_ERR(fb_class)) {printk(KERN_WARNING "Unable to create fb class; errno = %ld\n", PTR_ERR(fb_class));fb_class = NULL;}return 0;
}

这个函数首先调用函数proc_create在/proc目录下创建了一个fb文件,接着又调用函数register_chrdev来注册了一个名称为fb的字符设备,最后调用函数class_create在/sys/class目录下创建了一个graphics目录,用来描述内核的图形系统。模块fbmem除了会执行上述初始化工作之外,还会导出一个函数register_framebuffer:

EXPORT_SYMBOL(register_framebuffer);

这个函数在内核的启动过程会被调用,以便用来执行注册帧缓冲区硬件设备的操作,它的实现如下所示:

/***      register_framebuffer - registers a frame buffer device*      @fb_info: frame buffer info structure**      Registers a frame buffer device @fb_info.**      Returns negative errno on error, or zero for success.**/int
register_framebuffer(struct fb_info *fb_info)
{int i;struct fb_event event;......if (num_registered_fb == FB_MAX)return -ENXIO;......num_registered_fb++;for (i = 0 ; i < FB_MAX; i++)if (!registered_fb[i])break;fb_info->node = i;mutex_init(&fb_info->lock);fb_info->dev = device_create(fb_class, fb_info->device,MKDEV(FB_MAJOR, i), NULL, "fb%d", i);if (IS_ERR(fb_info->dev)) {/* Not fatal */printk(KERN_WARNING "Unable to create device for framebuffer %d; errno = %ld\n", i, PTR_ERR(fb_info->dev));fb_info->dev = NULL;} elsefb_init_device(fb_info);......registered_fb[i] = fb_info;event.info = fb_info;fb_notifier_call_chain(FB_EVENT_FB_REGISTERED, &event);return 0;
}

由于系统中可能会存在多个帧缓冲区硬件设备,因此,fbmem模块使用一个数组registered_fb保存所有已经注册了的帧缓冲区硬件设备,其中,每一个帧缓冲区硬件都是使用一个结构体fb_info来描述的。

我们知道,在Linux内核中,每一个硬件设备都有一个主设备号和一个从设备号,它们用来唯一地标识一个硬件设备。对于帧缓冲区硬件设备来说,它们的主设备号定义为FB_MAJOR(29),而从设备号则与注册的顺序有关,它们的值依次等于0,1,2等。

每一个被注册的帧缓冲区硬件设备在/dev/graphics目录下都有一个对应的设备文件fb<minor>,其中,<minor>表示一个从设备号。例如,第一个被注册的帧缓冲区硬件设备在/dev/graphics目录下都有一个对应的设备文件fb0。用户空间的应用程序通过这个设备文件就可以操作帧缓冲区硬件设备了,即将要显示的画面渲染到帧缓冲区硬件设备上去。

这个函数最后会通过调用函数fb_notifier_call_chain来通知帧缓冲区控制台,有一个新的帧缓冲区设备被注册到内核中来了。

帧缓冲区控制台在内核中对应的驱动程序模块为fbcon,它实现在文件kernel/goldfish/drivers/video/console/fbcon.c中,它的初始化函数如下所示:

static struct notifier_block fbcon_event_notifier = {.notifier_call  = fbcon_event_notify,
};......static int __init fb_console_init(void)
{int i;acquire_console_sem();fb_register_client(&fbcon_event_notifier);fbcon_device = device_create(fb_class, NULL, MKDEV(0, 0), NULL,"fbcon");if (IS_ERR(fbcon_device)) {printk(KERN_WARNING "Unable to create device ""for fbcon; errno = %ld\n",PTR_ERR(fbcon_device));fbcon_device = NULL;} elsefbcon_init_device();for (i = 0; i < MAX_NR_CONSOLES; i++)con2fb_map[i] = -1;release_console_sem();fbcon_start();return 0;
}

这个函数除了会调用函数device_create来创建一个类别为graphics的设备fbcon之外,还会调用函数fb_register_client来监听帧缓冲区硬件设备的注册事件,这是由函数fbcon_event_notify来实现的,如下所示:

static int fbcon_event_notify(struct notifier_block *self,unsigned long action, void *data)
{struct fb_event *event = data;struct fb_info *info = event->info;......int ret = 0;......switch(action) {......case FB_EVENT_FB_REGISTERED:ret = fbcon_fb_registered(info);break;......}done:return ret;
}

帧缓冲区硬件设备的注册事件最终是由函数fbcon_fb_registered来处理的,它的实现如下所示:

static int fbcon_fb_registered(struct fb_info *info)
{int ret = 0, i, idx = info->node;fbcon_select_primary(info);if (info_idx == -1) {for (i = first_fb_vc; i <= last_fb_vc; i++) {if (con2fb_map_boot[i] == idx) {info_idx = idx;break;}}if (info_idx != -1)ret = fbcon_takeover(1);} else {for (i = first_fb_vc; i <= last_fb_vc; i++) {if (con2fb_map_boot[i] == idx)set_con2fb_map(i, idx, 0);}}return ret;
}

函数fbcon_select_primary用来检查当前注册的帧缓冲区硬件设备是否是一个主帧缓冲区硬件设备。如果是的话,那么就将它的信息记录下来。这个函数只有当指定了CONFIG_FRAMEBUFFER_CONSOLE_DETECT_PRIMARY编译选项时才有效,否则的话,它是一个空函数。

在Linux内核中,每一个控制台和每一个帧缓冲区硬件设备都有一个从0开始的编号,它们的初始对应关系保存在全局数组con2fb_map_boot中。控制台和帧缓冲区硬件设备的初始对应关系是可以通过设置内核启动参数来初始化的。在模块fbcon中,还有另外一个全局数组con2fb_map,也是用来映射控制台和帧缓冲区硬件设备的对应关系,不过它映射的是控制台和帧缓冲区硬件设备的实际对应关系。

全局变量first_fb_vc和last_fb_vc是全局数组con2fb_map_boot和con2fb_map的索引值,用来指定系统当前可用的控制台编号范围,它们也是可以通过设置内核启动参数来初始化的。全局变量first_fb_vc的默认值等于0,而全局变量last_fb_vc的默认值等于MAX_NR_CONSOLES - 1。

全局变量info_idx表示系统当前所使用的帧缓冲区硬件的编号。如果它的值等于-1,那么就说明系统当前还没有设置好当前所使用的帧缓冲区硬件设备。在这种情况下,函数fbcon_fb_registered就会在全局数组con2fb_map_boot中检查是否存在一个控制台编号与当前所注册的帧缓冲区硬件设备的编号idx对应。如果存在的话,那么就会将当前所注册的帧缓冲区硬件设备编号idx保存在全局变量info_idx中。接下来还会调用函数fbcon_takeover来初始化系统所使用的控制台。在调用函数fbcon_takeover的时候,传进去的参数为1,表示要显示第一个开机画面。

如果全局变量info_idx的值不等于-1,那么函数fbcon_fb_registered同样会在全局数组con2fb_map_boot中检查是否存在一个控制台编号与当前所注册的帧缓冲区硬件设备的编号idx对应。如果存在的话,那么就会调用函数set_con2fb_map来调整当前所注册的帧缓冲区硬件设备与控制台的映射关系,即调整数组con2fb_map_boot和con2fb_map的值。

为了简单起见,我们假设系统只有一个帧缓冲区硬件设备,这样当它被注册的时候,全局变量info_idx的值就会等于-1。当函数fbcon_fb_registered在全局数组con2fb_map_boot中发现有一个控制台的编号与这个帧缓冲区硬件设备的编号idx对应时,接下来就会调用函数fbcon_takeover来设置系统所使用的控制台。

函数fbcon_takeover的实现如下所示:

static int fbcon_takeover(int show_logo)
{int err, i;if (!num_registered_fb)return -ENODEV;if (!show_logo)logo_shown = FBCON_LOGO_DONTSHOW;for (i = first_fb_vc; i <= last_fb_vc; i++)con2fb_map[i] = info_idx;err = take_over_console(&fb_con, first_fb_vc, last_fb_vc,fbcon_is_default);if (err) {for (i = first_fb_vc; i <= last_fb_vc; i++) {con2fb_map[i] = -1;}info_idx = -1;}return err;
}

全局变量logo_shown的初始值为FBCON_LOGO_CANSHOW,表示可以显示第一个开机画面。但是当参数show_logo的值等于0的时候,全局变量logo_shown的值会被重新设置为FBCON_LOGO_DONTSHOW,表示不可以显示第一个开机画面。

中间的for循环将当前可用的控制台的编号都映射到当前正在注册的帧缓冲区硬件设备的编号info_idx中去,表示当前可用的控制台与缓冲区硬件设备的实际映射关系。

函数take_over_console用来初始化系统当前所使用的控制台。如果它的返回值不等于0,那么就表示初始化失败。在这种情况下,最后的for循环就会将全局数组con2fb_map的各个元素的值设置为-1,表示系统当前可用的控制台还没有映射到实际的帧缓冲区硬件设备中去。这时候全局变量info_idx的值也会被重新设置为-1。

调用函数take_over_console来初始化系统当前所使用的控制台,实际上就是向系统注册一系列回调函数,以便系统可以通过这些回调函数来操作当前所使用的控制台。这些回调函数使用结构体consw来描述。这里所注册的结构体consw是由全局变量fb_con来指定的,它的定义如下所示:

/**  The console `switch' structure for the frame buffer based console*/static const struct consw fb_con = {.owner                  = THIS_MODULE,.con_startup            = fbcon_startup,.con_init               = fbcon_init,.con_deinit             = fbcon_deinit,.con_clear              = fbcon_clear,.con_putc               = fbcon_putc,.con_putcs              = fbcon_putcs,.con_cursor             = fbcon_cursor,.con_scroll             = fbcon_scroll,.con_bmove              = fbcon_bmove,.con_switch             = fbcon_switch,.con_blank              = fbcon_blank,.con_font_set           = fbcon_set_font,.con_font_get           = fbcon_get_font,.con_font_default       = fbcon_set_def_font,.con_font_copy          = fbcon_copy_font,.con_set_palette        = fbcon_set_palette,.con_scrolldelta        = fbcon_scrolldelta,.con_set_origin         = fbcon_set_origin,.con_invert_region      = fbcon_invert_region,.con_screen_pos         = fbcon_screen_pos,.con_getxy              = fbcon_getxy,.con_resize             = fbcon_resize,
};

接下来我们主要关注函数fbcon_init和fbcon_switch的实现,系统就是通过它来初始化和切换控制台的。在初始化的过程中,会决定是否需要准备第一个开机画面的内容,而在切换控制台的过程中,会决定是否需要显示第一个开机画面的内容。函数fbcon_init的实现如下所示:

static void fbcon_init(struct vc_data *vc, int init)
{struct fb_info *info = registered_fb[con2fb_map[vc->vc_num]];struct fbcon_ops *ops;struct vc_data **default_mode = vc->vc_display_fg;struct vc_data *svc = *default_mode;struct display *t, *p = &fb_display[vc->vc_num];int logo = 1, new_rows, new_cols, rows, cols, charcnt = 256;int cap;if (info_idx == -1 || info == NULL)return;......if (vc != svc || logo_shown == FBCON_LOGO_DONTSHOW ||(info->fix.type == FB_TYPE_TEXT))logo = 0;......if (logo)fbcon_prepare_logo(vc, info, cols, rows, new_cols, new_rows);......
}

当前正在初始化的控制台使用参数vc来描述,而它的成员变量vc_num用来描述当前正在初始化的控制台的编号。通过这个编号之后,就可以在全局数组con2fb_map中找到对应的帧缓冲区硬件设备编号。有了帧缓冲区硬件设备编号之后,就可以在另外一个全局数组中registered_fb中找到一个fb_info结构体info,用来描述与当前正在初始化的控制台所对应的帧缓冲区硬件设备。

参数vc的成员变量vc_display_fg用来描述系统当前可见的控制台,它是一个类型为vc_data**的指针。从这里就可以看出,最终得到的vc_data结构体svc就是用来描述系统当前可见的控制台的。

变量logo开始的时候被设置为1,表示需要显示第一个开机画面,但是在以下三种情况下,它的值会被设置为0,表示不需要显示开机画面:

A. 参数vc和变量svc指向的不是同一个vc_data结构体,即当前正在初始化的控制台不是系统当前可见的控制台。

B. 全局变量logo_shown的值等于FBCON_LOGO_DONTSHOW,即系统不需要显示第一个开机画面。

C. 与当前正在初始化的控制台所对应的帧缓冲区硬件设备的显示方式被设置为文本方式,即info->fix.type的值等于FB_TYPE_TEXT。

当最终得到的变量logo的值等于1的时候,接下来就会调用函数fbcon_prepare_logo来准备要显示的第一个开机画面的内容。

在函数fbcon_prepare_logo中,第一个开机画面的内容是通过调用函数fb_prepare_logo来准备的,如下所示:

static void fbcon_prepare_logo(struct vc_data *vc, struct fb_info *info,int cols, int rows, int new_cols, int new_rows)
{......int logo_height;......logo_height = fb_prepare_logo(info, ops->rotate);......if (logo_lines > vc->vc_bottom) {......} else if (logo_shown != FBCON_LOGO_DONTSHOW) {logo_shown = FBCON_LOGO_DRAW;......}
}

从函数fb_prepare_logo返回来之后,如果要显示的第一个开机画面所占用的控制台行数小于等于参数vc所描述的控制台的最大行数,并且全局变量logo_show的值不等于FBCON_LOGO_DONTSHOW,那么就说明前面所提到的第一个开机画面可以显示在控制台中。这时候全局变量logo_show的值就会被设置为FBCON_LOGO_DRAW,表示第一个开机画面处于等待渲染的状态。

函数fb_prepare_logo实现在文件kernel/goldfish/drivers/video/fbmem.c中,如下所示:

int fb_prepare_logo(struct fb_info *info, int rotate)
{int depth = fb_get_color_depth(&info->var, &info->fix);unsigned int yres;memset(&fb_logo, 0, sizeof(struct logo_data));......if (info->fix.visual == FB_VISUAL_DIRECTCOLOR) {depth = info->var.blue.length;if (info->var.red.length < depth)depth = info->var.red.length;if (info->var.green.length < depth)depth = info->var.green.length;}if (info->fix.visual == FB_VISUAL_STATIC_PSEUDOCOLOR && depth > 4) {/* assume console colormap */depth = 4;}/* Return if no suitable logo was found */fb_logo.logo = fb_find_logo(depth);......return fb_prepare_extra_logos(info, fb_logo.logo->height, yres);
}

这个函数首先得到参数info所描述的帧缓冲区硬件设备的颜色深度depth,接着再调用函数fb_find_logo来获得要显示的第一个开机画面的内容,并且保存在全局变量fb_logo的成员变量logo中。函数fb_find_logo实现在文件kernel/goldfish/drivers/video/logo/logo.c文件中,如下所示:

extern const struct linux_logo logo_linux_mono;
extern const struct linux_logo logo_linux_vga16;
extern const struct linux_logo logo_linux_clut224;
extern const struct linux_logo logo_blackfin_vga16;
extern const struct linux_logo logo_blackfin_clut224;
extern const struct linux_logo logo_dec_clut224;
extern const struct linux_logo logo_mac_clut224;
extern const struct linux_logo logo_parisc_clut224;
extern const struct linux_logo logo_sgi_clut224;
extern const struct linux_logo logo_sun_clut224;
extern const struct linux_logo logo_superh_mono;
extern const struct linux_logo logo_superh_vga16;
extern const struct linux_logo logo_superh_clut224;
extern const struct linux_logo logo_m32r_clut224;static int nologo;
module_param(nologo, bool, 0);
MODULE_PARM_DESC(nologo, "Disables startup logo");/* logo's are marked __initdata. Use __init_refok to tell* modpost that it is intended that this function uses data* marked __initdata.*/
const struct linux_logo * __init_refok fb_find_logo(int depth)
{const struct linux_logo *logo = NULL;if (nologo)return NULL;if (depth >= 1) {
#ifdef CONFIG_LOGO_LINUX_MONO/* Generic Linux logo */logo = &logo_linux_mono;
#endif
#ifdef CONFIG_LOGO_SUPERH_MONO/* SuperH Linux logo */logo = &logo_superh_mono;
#endif}if (depth >= 4) {
#ifdef CONFIG_LOGO_LINUX_VGA16/* Generic Linux logo */logo = &logo_linux_vga16;
#endif
#ifdef CONFIG_LOGO_BLACKFIN_VGA16/* Blackfin processor logo */logo = &logo_blackfin_vga16;
#endif
#ifdef CONFIG_LOGO_SUPERH_VGA16/* SuperH Linux logo */logo = &logo_superh_vga16;
#endif}if (depth >= 8) {
#ifdef CONFIG_LOGO_LINUX_CLUT224/* Generic Linux logo */logo = &logo_linux_clut224;
#endif
#ifdef CONFIG_LOGO_BLACKFIN_CLUT224/* Blackfin Linux logo */logo = &logo_blackfin_clut224;
#endif
#ifdef CONFIG_LOGO_DEC_CLUT224/* DEC Linux logo on MIPS/MIPS64 or ALPHA */logo = &logo_dec_clut224;
#endif
#ifdef CONFIG_LOGO_MAC_CLUT224/* Macintosh Linux logo on m68k */if (MACH_IS_MAC)logo = &logo_mac_clut224;
#endif
#ifdef CONFIG_LOGO_PARISC_CLUT224/* PA-RISC Linux logo */logo = &logo_parisc_clut224;
#endif
#ifdef CONFIG_LOGO_SGI_CLUT224/* SGI Linux logo on MIPS/MIPS64 and VISWS */logo = &logo_sgi_clut224;
#endif
#ifdef CONFIG_LOGO_SUN_CLUT224/* Sun Linux logo */logo = &logo_sun_clut224;
#endif
#ifdef CONFIG_LOGO_SUPERH_CLUT224/* SuperH Linux logo */logo = &logo_superh_clut224;
#endif
#ifdef CONFIG_LOGO_M32R_CLUT224/* M32R Linux logo */logo = &logo_m32r_clut224;
#endif}return logo;
}
EXPORT_SYMBOL_GPL(fb_find_logo);

文件开始声明的一系列linux_logo结构体变量分别用来保存kernel/goldfish/drivers/video/logo目录下的一系列ppm或者pbm文件的内容的。这些ppm或者pbm文件都是用来描述第一个开机画面的。

全局变量nologo是一个类型为布尔变量的模块参数,它的默认值等于0,表示要显示第一个开机画面。在这种情况下,函数fb_find_logo就会根据参数depth的值以及不同的编译选项来选择第一个开机画面的内容,并且保存在变量logo中返回给调用者。

这一步执行完成之后,第一个开机画面的内容就保存在模块fbmem的全局变量fb_logo的成员变量logo中了。这时候控制台的初始化过程也结束了,接下来系统就会执行切换控制台的操作。前面提到,当系统执行切换控制台的操作的时候,模块fbcon中的函数fbcon_switch就会被调用。在调用的过程中,就会执行显示第一个开机画面的操作。

函数fbcon_switch实现在文件kernel/goldfish/drivers/video/console/fbcon.c中,显示第一个开机画面的过程如下所示:

static int fbcon_switch(struct vc_data *vc)
{struct fb_info *info, *old_info = NULL;struct fbcon_ops *ops;struct display *p = &fb_display[vc->vc_num];struct fb_var_screeninfo var;int i, prev_console, charcnt = 256;......if (logo_shown == FBCON_LOGO_DRAW) {logo_shown = fg_console;/* This is protected above by initmem_freed */fb_show_logo(info, ops->rotate);......return 0;}return 1;
}

由于前面在准备第一个开机画面的内容的时候,全局变量logo_show的值被设置为FBCON_LOGO_DRAW,因此,接下来就会调用函数fb_show_logo来显示第一个开机画面。在显示之前,这个函数会将全局变量logo_shown的值设置为fg_console,后者表示系统当前可见的控制台的编号。函数fb_show_logo实现在文件kernel/goldfish/drivers/video/fbmem.c中,如下所示:

int fb_show_logo(struct fb_info *info, int rotate)
{int y;y = fb_show_logo_line(info, rotate, fb_logo.logo, 0,num_online_cpus());......return y;
}

这个函数调用另外一个函数fb_show_logo_line来进一步执行渲染第一个开机画面的操作。函数fb_show_logo_line也是实现在文件kernel/goldfish/drivers/video/fbmem.c中,如下所示:

static int fb_show_logo_line(struct fb_info *info, int rotate,const struct linux_logo *logo, int y,unsigned int n)
{u32 *palette = NULL, *saved_pseudo_palette = NULL;unsigned char *logo_new = NULL, *logo_rotate = NULL;struct fb_image image;/* Return if the frame buffer is not mapped or suspended */if (logo == NULL || info->state != FBINFO_STATE_RUNNING ||info->flags & FBINFO_MODULE)return 0;image.depth = 8;image.data = logo->data;if (fb_logo.needs_cmapreset)fb_set_logocmap(info, logo);if (fb_logo.needs_truepalette ||fb_logo.needs_directpalette) {palette = kmalloc(256 * 4, GFP_KERNEL);if (palette == NULL)return 0;if (fb_logo.needs_truepalette)fb_set_logo_truepalette(info, logo, palette);elsefb_set_logo_directpalette(info, logo, palette);saved_pseudo_palette = info->pseudo_palette;info->pseudo_palette = palette;}if (fb_logo.depth <= 4) {logo_new = kmalloc(logo->width * logo->height, GFP_KERNEL);if (logo_new == NULL) {kfree(palette);if (saved_pseudo_palette)info->pseudo_palette = saved_pseudo_palette;return 0;}image.data = logo_new;fb_set_logo(info, logo, logo_new, fb_logo.depth);}image.dx = 0;image.dy = y;image.width = logo->width;image.height = logo->height;if (rotate) {logo_rotate = kmalloc(logo->width *logo->height, GFP_KERNEL);if (logo_rotate)fb_rotate_logo(info, logo_rotate, &image, rotate);}fb_do_show_logo(info, &image, rotate, n);kfree(palette);if (saved_pseudo_palette != NULL)info->pseudo_palette = saved_pseudo_palette;kfree(logo_new);kfree(logo_rotate);return logo->height;
}

参数logo指向了前面所准备的第一个开机画面的内容。这个函数首先根据参数logo的内容来构造一个fb_image结构体image,用来描述最终要显示的第一个开机画面。最后就调用函数fb_do_show_logo来真正执行渲染第一个开机画面的操作。函数fb_do_show_logo也是实现在文件kernel/goldfish/drivers/video/fbmem.c中,如下所示:

static void fb_do_show_logo(struct fb_info *info, struct fb_image *image,int rotate, unsigned int num)
{unsigned int x;if (rotate == FB_ROTATE_UR) {for (x = 0;x < num && image->dx + image->width <= info->var.xres;x++) {info->fbops->fb_imageblit(info, image);image->dx += image->width + 8;}} else if (rotate == FB_ROTATE_UD) {for (x = 0; x < num && image->dx >= 0; x++) {info->fbops->fb_imageblit(info, image);image->dx -= image->width + 8;}} else if (rotate == FB_ROTATE_CW) {for (x = 0;x < num && image->dy + image->height <= info->var.yres;x++) {info->fbops->fb_imageblit(info, image);image->dy += image->height + 8;}} else if (rotate == FB_ROTATE_CCW) {for (x = 0; x < num && image->dy >= 0; x++) {info->fbops->fb_imageblit(info, image);image->dy -= image->height + 8;}}
}

参数rotate用来描述屏幕的当前旋转方向。屏幕旋转方向不同,第一个开机画面的渲染方式也有所不同。例如,当屏幕上下颠倒时(FB_ROTATE_UD),第一个开机画面的左右顺序就刚好调换过来,这时候就需要从右到左来渲染。其它三个方向FB_ROTATE_UR、FB_ROTATE_CW和FB_ROTATE_CCW分别表示没有旋转、顺时针旋转90度和逆时针旋转90度。参数info用来描述要渲染的帧缓冲区硬件设备,它的成员变量fbops指向了一系列回调函数,用来操作帧缓冲区硬件设备,其中,回调函数fb_imageblit就是用来在指定的帧缓冲区硬件设备渲染指定的图像的。

至此,第一个开机画面的显示过程就分析完成了。

2. 第二个开机画面的显示过程

由于第二个开机画面是在init进程启动的过程中显示的,因此,我们就从init进程的入口函数main开始分析第二个开机画面的显示过程。init进程的入口函数main实现在文件system/core/init/init.c中,如下所示:

int main(int argc, char **argv)
{int fd_count = 0;struct pollfd ufds[4];......int property_set_fd_init = 0;int signal_fd_init = 0;int keychord_fd_init = 0;if (!strcmp(basename(argv[0]), "ueventd"))return ueventd_main(argc, argv);......queue_builtin_action(console_init_action, "console_init");......for(;;) {int nr, i, timeout = -1;execute_one_command();restart_processes();if (!property_set_fd_init && get_property_set_fd() > 0) {ufds[fd_count].fd = get_property_set_fd();ufds[fd_count].events = POLLIN;ufds[fd_count].revents = 0;fd_count++;property_set_fd_init = 1;}if (!signal_fd_init && get_signal_fd() > 0) {ufds[fd_count].fd = get_signal_fd();ufds[fd_count].events = POLLIN;ufds[fd_count].revents = 0;fd_count++;signal_fd_init = 1;}if (!keychord_fd_init && get_keychord_fd() > 0) {ufds[fd_count].fd = get_keychord_fd();ufds[fd_count].events = POLLIN;ufds[fd_count].revents = 0;fd_count++;keychord_fd_init = 1;}if (process_needs_restart) {timeout = (process_needs_restart - gettime()) * 1000;if (timeout < 0)timeout = 0;}if (!action_queue_empty() || cur_action)timeout = 0;......nr = poll(ufds, fd_count, timeout);if (nr <= 0)continue;for (i = 0; i < fd_count; i++) {if (ufds[i].revents == POLLIN) {if (ufds[i].fd == get_property_set_fd())handle_property_set_fd();else if (ufds[i].fd == get_keychord_fd())handle_keychord();else if (ufds[i].fd == get_signal_fd())handle_signal();}}}return 0;
}

函数一开始就首先判断参数argv[0]的值是否等于“ueventd”,即当前正在启动的进程名称是否等于“ueventd”。如果是的话,那么就以ueventd_main函数来作入口函数。这是怎么回事呢?当前正在启动的进程不是init吗?它的名称怎么可能会等于“ueventd”?原来,在目标设备上,可执行文件/sbin/ueventd是可执行文件/init的一个符号链接文件,即应用程序ueventd和init运行的是同一个可执行文件。内核启动完成之后,可执行文件/init首先会被执行,即init进程会首先被启动。init进程在启动的过程中,会对启动脚本/init.rc进行解析。在启动脚本/init.rc中,配置了一个ueventd进程,它对应的可执行文件为/sbin/ueventd,即ueventd进程加载的可执行文件也为/init。因此,通过判断参数argv[0]的值,就可以知道当前正在启动的是init进程还是ueventd进程。

ueventd进程是作什么用的呢?它是用来处理uevent事件的,即用来管理系统设备的。从前面的描述可以知道,它真正的入口函数为ueventd_main,实现在system/core/init/ueventd.c中。ueventd进程会通过一个socket接口来和内核通信,以便可以监控系统设备事件。 接下来调用另外一个函数queue_builtin_action来向init进程中的一个待执行action队列增加了一个名称等于“console_init”的action。这个action对应的执行函数为console_init_action,它就是用来显示第二个开机画面的。函数queue_builtin_action实现在文件system/core/init/init_parser.c文件中,如下所示:

static list_declare(action_list);
static list_declare(action_queue);void queue_builtin_action(int (*func)(int nargs, char **args), char *name)
{struct action *act;struct command *cmd;act = calloc(1, sizeof(*act));act->name = name;list_init(&act->commands);cmd = calloc(1, sizeof(*cmd));cmd->func = func;cmd->args[0] = name;list_add_tail(&act->commands, &cmd->clist);list_add_tail(&action_list, &act->alist);action_add_queue_tail(act);
}void action_add_queue_tail(struct action *act)
{list_add_tail(&action_queue, &act->qlist);
}

action_list列表用来保存从启动脚本/init.rc解析得到的一系列action,以及一系列内建的action。当这些action需要执行的时候,它们就会被添加到action_queue列表中去,以便init进程可以执行它们。

回到init进程的入口函数main中,最后init进程会进入到一个无限循环中去。在这个无限循环中,init进程会做以下五个事情:

A. 调用函数execute_one_command来检查action_queue列表是否为空。如果不为空的话,那么init进程就会将保存在列表头中的action移除,并且执行这个被移除的action。由于前面我们将一个名称为“console_init”的action添加到了action_queue列表中,因此,在这个无限循环中,这个action就会被执行,即函数console_init_action会被调用。

B. 调用函数restart_processes来检查系统中是否有进程需要重启。在启动脚本/init.rc中,我们可以指定一个进程在退出之后会自动重新启动。在这种情况下,函数restart_processes就会检查是否存在需要重新启动的进程,如果存在的话,那么就会将它重新启动起来。

C. 处理系统属性变化事件。当我们调用函数property_set来改变一个系统属性值时,系统就会通过一个socket(通过调用函数get_property_set_fd可以获得它的文件描述符)来向init进程发送一个属性值改变事件通知。init进程接收到这个属性值改变事件之后,就会调用函数handle_property_set_fd来进行相应的处理。后面在分析第三个开机画面的显示过程时,我们就会看到,SurfaceFlinger服务就是通过修改“ctl.start”和“ctl.stop”属性值来启动和停止第三个开机画面的。

D. 处理一种称为“chorded keyboard”的键盘输入事件。这种类型为chorded keyboard”的键盘设备通过不同的铵键组合来描述不同的命令或者操作,它对应的设备文件为/dev/keychord。我们可以通过调用函数get_keychord_fd来获得这个设备的文件描述符,以便可以监控它的输入事件,并且调用函数handle_keychord来对这些输入事件进行处理。

E. 回收僵尸进程。我们知道,在Linux内核中,如果父进程不等待子进程结束就退出,那么当子进程结束的时候,就会变成一个僵尸进程,从而占用系统的资源。为了回收这些僵尸进程,init进程会安装一个SIGCHLD信号接收器。当那些父进程已经退出了的子进程退出的时候,内核就会发出一个SIGCHLD信号给init进程。init进程可以通过一个socket(通过调用函数get_signal_fd可以获得它的文件描述符)来将接收到的SIGCHLD信号读取回来,并且调用函数handle_signal来对接收到的SIGCHLD信号进行处理,即回收那些已经变成了僵尸的子进程。

注意,由于后面三个事件都是可以通过文件描述符来描述的,因此,init进程的入口函数main使用poll机制来同时轮询它们,以便可以提高效率。

接下来我们就重点分析函数console_init_action的实现,以便可以了解第二个开机画面的显示过程:

static int console_init_action(int nargs, char **args)
{int fd;char tmp[PROP_VALUE_MAX];if (console[0]) {snprintf(tmp, sizeof(tmp), "/dev/%s", console);console_name = strdup(tmp);}fd = open(console_name, O_RDWR);if (fd >= 0)have_console = 1;close(fd);if( load_565rle_image(INIT_IMAGE_FILE) ) {fd = open("/dev/tty0", O_WRONLY);if (fd >= 0) {const char *msg;msg = "\n""\n""\n""\n""\n""\n""\n"  // console is 40 cols x 30 lines"\n""\n""\n""\n""\n""\n""\n""             A N D R O I D ";write(fd, msg, strlen(msg));close(fd);}}return 0;
}

这个函数主要做了两件事件:

A. 初始化控制台。init进程在启动的时候,会解析内核的启动参数(保存在文件/proc/cmdline中)。如果发现内核的启动参数中包含有了一个名称为“androidboot.console”的属性,那么就会将这个属性的值保存在字符数组console中。这样我们就可以通过设备文件/dev/<console>来访问系统的控制台。如果内核的启动参数没有包含名称为“androidboot.console”的属性,那么默认就通过设备文件/dev/console来访问系统的控制台。如果能够成功地打开设备文件/dev/<console>或者/dev/console,那么就说明系统支持访问控制台,因此,全局变量have_console的就会被设置为1。

B. 显示第二个开机画面。显示第二个开机画面是通过调用函数load_565rle_image来实现的。在调用函数load_565rle_image的时候,指定的开机画面文件为INIT_IMAGE_FILE。INIT_IMAGE_FILE是一个宏,定义在system/core/init/init.h文件中,如下所示:

#define INIT_IMAGE_FILE "/initlogo.rle"

即第二个开机画面的内容是由文件/initlogo.rle来指定的。如果文件/initlogo.rle不存在,或者在显示它的过程中出现异常,那么函数load_565rle_image的返回值就会等于-1,这时候函数console_init_action就以文本的方式来显示第二个开机画面,即向编号为0的控制台(/dev/tty0)输出“ANDROID”这7个字符。函数load_565rle_image实现在文件system/core/init/logo.c中,如下所示:

/* 565RLE image format: [count(2 bytes), rle(2 bytes)] */int load_565rle_image(char *fn)
{struct FB fb;struct stat s;unsigned short *data, *bits, *ptr;unsigned count, max;int fd;if (vt_set_mode(1))return -1;fd = open(fn, O_RDONLY);if (fd < 0) {ERROR("cannot open '%s'\n", fn);goto fail_restore_text;}if (fstat(fd, &s) < 0) {goto fail_close_file;}data = mmap(0, s.st_size, PROT_READ, MAP_SHARED, fd, 0);if (data == MAP_FAILED)goto fail_close_file;if (fb_open(&fb))goto fail_unmap_data;max = fb_width(&fb) * fb_height(&fb);ptr = data;count = s.st_size;bits = fb.bits;while (count > 3) {unsigned n = ptr[0];if (n > max)break;android_memset16(bits, ptr[1], n << 1);bits += n;max -= n;ptr += 2;count -= 4;}munmap(data, s.st_size);fb_update(&fb);fb_close(&fb);close(fd);unlink(fn);return 0;fail_unmap_data:munmap(data, s.st_size);
fail_close_file:close(fd);
fail_restore_text:vt_set_mode(0);return -1;
}

函数首先将控制台的显示方式设置为图形方式,这是通过调用函数vt_set_mode来实现的,如下所示:

static int vt_set_mode(int graphics)
{int fd, r;fd = open("/dev/tty0", O_RDWR | O_SYNC);if (fd < 0)return -1;r = ioctl(fd, KDSETMODE, (void*) (graphics ? KD_GRAPHICS : KD_TEXT));close(fd);return r;
}

函数vt_set_mode首先打开控制台设备文件/dev/tty0,接着再通过IO控制命令KDSETMODE来将控制台的显示方式设置为文本方式或者图形方式,取决于参数graphics的值。从前面的调用过程可以知道,参数graphics的值等于1,因此,这里是将控制台的显示方式设备为图形方式。

回到函数load_565rle_image中,从前面的调用过程可以知道,参数fn的值等于“/initlogo.rle”,即指向目标设备上的initlogo.rle文件。函数load_565rle_image首先调用函数open打开这个文件,并且将获得的文件描述符保存在变量fd中,接着再调用函数fstat来获得这个文件的大小。有了这些信息之后,函数load_565rle_image就可以调用函数mmap来把文件/initlogo.rle映射到init进程的地址空间来了,以便可以读取它的内容。

将文件/initlogo.rle映射到init进程的地址空间之后,接下来再调用函数fb_open来打开设备文件/dev/graphics/fb0。前面在介绍第一个开机画面的显示过程中提到,设备文件/dev/graphics/fb0是用来访问系统的帧缓冲区硬件设备的,因此,打开了设备文件/dev/graphics/fb0之后,我们就可以将文件/initlogo.rle的内容输出到帧缓冲区硬件设备中去了。

函数fb_open的实现如下所示:

static int fb_open(struct FB *fb)
{fb->fd = open("/dev/graphics/fb0", O_RDWR);if (fb->fd < 0)return -1;if (ioctl(fb->fd, FBIOGET_FSCREENINFO, &fb->fi) < 0)goto fail;if (ioctl(fb->fd, FBIOGET_VSCREENINFO, &fb->vi) < 0)goto fail;fb->bits = mmap(0, fb_size(fb), PROT_READ | PROT_WRITE,MAP_SHARED, fb->fd, 0);if (fb->bits == MAP_FAILED)goto fail;return 0;fail:close(fb->fd);return -1;
}

打开了设备文件/dev/graphics/fb0之后,接着再分别通过IO控制命令FBIOGET_FSCREENINFO和FBIOGET_VSCREENINFO来获得帧缓冲硬件设备的固定信息和可变信息。固定信息使用一个fb_fix_screeninfo结构体来描述,它保存的是帧缓冲区硬件设备固有的特性,这些特性在帧缓冲区硬件设备被初始化了之后,就不会发生改变,例如屏幕大小以及物理地址等信息。可变信息使用一个fb_var_screeninfo结构体来描述,它保存的是帧缓冲区硬件设备可变的特性,这些特性在系统运行的期间是可以改变的,例如屏幕所使用的分辨率、颜色深度以及颜色格式等。除了获得帧缓冲区硬件设备的固定信息和可变信息之外,函数fb_open还会将设备文件/dev/graphics/fb0的内容映射到init进程的地址空间来,这样init进程就可以通过映射得到的虚拟地址来访问帧缓冲区硬件设备的内容了。回到函数load_565rle_image中,接下来分别使用宏fb_width和fb_height来获得屏幕所使用的的分辨率,即屏幕的宽度和高度。宏fb_width和fb_height的定义如下所示:

#define fb_width(fb) ((fb)->vi.xres)
#define fb_height(fb) ((fb)->vi.yres)

屏幕的所使用的分辨率使用结构体fb_var_screeninfo的成员变量xres和yres来描述,其中,成员变量xres用来描述屏幕的宽度,而成员变量成员变量yres用来描述屏幕的高度。得到了屏幕的分辨率之后,就可以知道最多可以向帧缓冲区硬件设备写入的字节数的大小了,这个大小就等于屏幕的宽度乘以高度,保存在变量max中。

现在我们分别得到了文件initlogo.rle和帧缓冲区硬件设备在init进程中的虚拟访问地址以及大小,这样我们就可以将文件initlogo.rle的内容写入到帧缓冲区硬件设备中去,以便可以将第二个开机画面显示出来,这是通过函数load_565rle_image中的while循环来实现的。

文件initlogo.rle保存的第二个开机画面的图像格式是565rle的。rle的全称是run-length encoding,翻译为游程编码或者行程长度编码,它可以使用4个字节来描述一个连续的具有相同颜色值的序列。在rle565格式,前面2个字节中用来描述序列的个数,而后面2个字节用来描述一个具体的颜色,其中,颜色的RGB值分别占5位、6位和5位。理解了565rle图像格式之后,我们就可以理解函数load_565rle_image中的while循环的实现逻辑了。在每一次循环中,都会依次从文件initlogo.rle中读出4个字节,其中,前两个字节的内容保存在变量n中,而后面2个字节的内容用来写入到帧缓冲区硬件设备中去。由于2个字节刚好就可以使用一个无符号短整数来描述,因此,函数load_565rle_image通过调用函数android_memset16来将从文件initlogo.rle中读取出来的颜色值写入到帧缓冲区硬件设备中去,

函数android_memset16的实现如下所示:

void android_memset16(void *_ptr, unsigned short val, unsigned count)
{unsigned short *ptr = _ptr;count >>= 1;while(count--)*ptr++ = val;
}

参数ptr指向被写入的地址,在我们这个场景中,这个地址即为帧缓冲区硬件设备映射到init进程中的虚拟地址值。

参数val用来描述被写入的值,在我们这个场景中,这个值即为从文件initlogo.rle中读取出来的颜色值。

参数count用来描述被写入的地址的长度,它是以字节为单位的。由于在将参数val的值写入到参数ptr所描述的地址中去时,是以无符号短整数为单位的,即是以2个字节为单位的,因此,函数android_memset16在将参数val写入到地址ptr中去之前,首先会将参数count的值除以2。相应的地,在函数load_565rle_image中,需要将具有相同颜色值的序列的个数乘以2之后,再调用函数android_memset16。

回到函数load_565rle_image中,将文件/initlogo.rle的内容写入到帧缓冲区硬件设备去之后,第二个开机画面就可以显示出来了。接下来函数load_565rle_image就会调用函数munmap来注销文件/initlogo.rle在init进程中的映射,并且调用函数close来关闭文件/initlogo.rle。关闭了文件/initlogo.rle之后,还会调用函数unlink来删除目标设备上的/initlogo.rle文件。注意,这只是删除了目标设备上的/initlogo.rle文件,而不是删除ramdisk映像中的initlogo.rle文件,因此,每次关机启动之后,系统都会重新将ramdisk映像中的initlogo.rle文件安装到目标设备上的根目录来,这样就可以在每次开机的时候都能将它显示出来。

除了需要注销文件/initlogo.rle在init进程中的映射和关闭文件/initlogo.rle之外,还需要注销文件/dev/graphics/fb0在init进程中的映射以及关闭文件/dev/graphics/fb0,这是通过调用fb_close函数来实现的,如下所示:

static void fb_close(struct FB *fb)
{munmap(fb->bits, fb_size(fb));close(fb->fd);
}

在调用fb_close函数之前,函数load_565rle_image还会调用另外一个函数fb_update来更新屏幕上的第二个开机画面,它的实现如下所示:

static void fb_update(struct FB *fb)
{fb->vi.yoffset = 1;ioctl(fb->fd, FBIOPUT_VSCREENINFO, &fb->vi);fb->vi.yoffset = 0;ioctl(fb->fd, FBIOPUT_VSCREENINFO, &fb->vi);
}

在结构体fb_var_screeninfo中,除了使用成员变量xres和yres来描述屏幕所使用的分辨率之外,还使用成员变量xres_virtual和yres_virtual来描述屏幕所使用的虚拟分辨率。成员变量xres和yres所描述屏幕的分辨率称为可视分辨率。可视分辨率和虚拟分辨率有什么关系呢?可视分辨率是屏幕实际上使用的分辨率,即用户所看到的分辨率,而虚拟分辨率是在系统内部使用的,它是不可见的,并且可以大于可视分辨率。例如,假设可视分辨率是800 x 600,那么虚拟分辨率可以设置为1600 x 600。由于屏幕最多只可以显示800 x 600个像素,因此,在系统内部,就需要决定从1600 x 600中取出800 x 600个像素来显示,这是通过结构体fb_var_screeninfo的成员变量xoffset和yoffset的值来描述的。成员变量xoffset和yoffset的默认值等于0,即默认从虚拟分辨率的左上角取出与可视分辨率大小相等的像素出来显示,否则的话,就会根据成员变量xoffset和yoffset的值来从虚拟分辨率的中间位置取出与可视分辨率大小相等的像素出来显示。

帧缓冲区的大小是由虚拟分辨率决定的,因此,我们就可以在帧缓冲中写入比屏幕大小还要多的像素值,多出来的这个部分像素值就可以用作双缓冲。我们仍然假设可视分辨率和虚拟分辨率分别是800 x 600和1600 x 600,那么我们就可以先将前一个图像的内容写入到帧缓冲区的前面800 x 600个像素中去,接着再将后一个图像的内容写入到帧缓冲区的后面800 x 600个像素中。通过分别将用来描述帧缓冲区硬件设备的fb_var_screeninfo结构体的成员变量yoffset的值设置为0和800,就可以平滑地显示两个图像。理解了帧缓冲区硬件设备的可视分辨性和虚拟分辨性之后,函数fb_update的实现逻辑就可以很好地理解了。

至此,第二个开机画面的显示过程就分析完成了。

Android系统手机开机画面各个阶段代码执行流程分析(Part1)相关推荐

  1. Android系统手机开机画面各个阶段代码执行流程分析(Part2)

    3. 第三个开机画面的显示过程 第三个开机画面是由应用程序bootanimation来负责显示的.应用程序bootanimation在启动脚本init.rc中被配置成了一个服务,如下所示: servi ...

  2. 「Vue 学习笔记 1」Vue 项目快速搭建,初始项目各个文件夹作用介绍和启动代码执行流程分析

    「Vue 学习笔记 1」Vue 项目快速搭建,初始项目各个文件夹作用介绍和启动代码执行流程分析 前言 一.我的开发环境 二.使用 Vue CLI (Vue 脚手架)快速搭建项目 三.初始项目的目录结构 ...

  3. Vue项目启动代码执行流程分析

    相信来看看这篇文章的童鞋,都对Vue已经有了大致的了解.所以,话不多说,直接进入正题. 首先看下图: 一般一个初步的Vue项目创建好之后都会有这三个文件:index.html .main.js .Ap ...

  4. Android系统的开机画面显示过程分析(13)

          WindowManagerService类的成员函数performEnableScreen的实现如下所示: public class WindowManagerService extend ...

  5. android怎么开机画面,安卓手机开机画面怎么修改?

    安卓手机开机画面怎么修改?相信很多人都不知道,安卓手机的开机画面竟然是可以修改的,如果你也想要修改自己手机的开机画面的话,就来看看安卓手机开机画面修改的教程吧. 简易教程: 1.手机必须ROOT了的 ...

  6. 手机学习android,对于Android系统手机学习探讨

    Android系统手机最让人欣赏的是能为玩家提供良好的上网体验,确保你已经安装谷歌手机地图.Gmail 以及其他一些专为你的手机开发的精彩应用.谷歌将继续努力,让这些服务变得更好,同时也将添加更有吸引 ...

  7. 鸿蒙os2.0开机动画,前沿讯息:鸿蒙OS 2.0手机开机画面演示

    科技.数码.互联网新闻如今都成为了大众所关注的热点了,眼下互联网.科技.数码已经与我们的生活息息相关,密不可分了.我们应该多为自己充电,多掌握些知识才能让自己见多识广,才能不断地提高自己的个人修为.. ...

  8. 获取android系统手机的铃声和音量

    获取android系统手机的铃声和音量 通过程序 获取android系统手机的铃声和音量.设置音量的方法也很简单,AudioManager提供了方法: publicvoidsetStreamVolum ...

  9. Android系统手机通讯录

    基于Android系统手机通讯录软件的设计和开发 自google于2005年收买Android公司,Android商场有了很大的遍及,加上无线网络的敏捷开展,手机网速有了很大的提高,为智能手机的开展供 ...

最新文章

  1. 如何给Lombok Builder提供默认值
  2. ssh_config sshd_config 详解
  3. 8086指令(II)
  4. Android]Android字体高度的研究
  5. 从堆里找回“丢失”的代码
  6. easyui select ajax,easyui的combobox根据后台数据实现自动输入提示功能
  7. 从零开始的全栈工程师——html篇1.2
  8. python内置的集成开发工具是什么_python内置的集成开发工具是什么_后端开发
  9. ssm 项目cannot resolve package_前端工程化之创建项目
  10. 怎么用计算机弹c哩c哩,计算器音乐c哩c哩乐谱 | 手游网游页游攻略大全
  11. 多线程和多进程的区别
  12. CvtColor(转)
  13. 超市系统服务器,超市收银系统 服务器 配置
  14. [对于蓝桥杯要说的一二体会]
  15. wps ppt, 版式与母版
  16. CoinCola研究院 | 从技术的角度解读BCH分叉升级
  17. 惠普服务器优盘安装系统蓝屏,惠普u盘装系统出现蓝屏现象怎么解决
  18. VS2019 关闭安全检查
  19. java导航网站_java导航网站
  20. 桌面图标背景色解决方案

热门文章

  1. ldslite是什么启动项_ldslite是什么开机启动项
  2. a4988 脉宽要求_A4988步进电机驱动模块谁用过?
  3. 你想知道你的计算机一秒能做多少次运算吗?
  4. 如何看待4亿蓝领职业教育的切入点?| 职业教育系列报告(二)
  5. NBIOT模块基于电信IOT平台的南向对接流程
  6. (附源码)ssm养老院信息管理系统 毕业设计211141
  7. 【AI案例】(二)搭建大数据Python生态知识体系
  8. SBM模型分析全流程
  9. 使用st-link+keil下载和调试华大单片机的教程
  10. 基于MSP430G2553和MATLAB Appdesigner的心电信号复现和心率显示