2024年2月

一、目的

从2014年Android4.0开始接触机器人,开发过App应用软件,研究过Framework层框架结构、也梳理过Native层的系统流程,但是对于Hal层,以及底下的kernel方向,知之甚少。
本着一份对于底层知识的渴望,也为方便以后debug问题,故开始尝试分析下Android的开机流程。

二、环境

  1. 版本:Android 12
  2. 平台:展锐 SPRD8541E
  3. kernel:5.4

三、相关概念

3.1 Android平台架构

如下是Google官网提供的平台架构图,很直观地攘括了Android的层级,关于各个层级的结构,有想了解可以参考:
https://developer.android.google.cn/guide/platform?hl=zh-cn

ps:
Android系统为什么要有Hal层?

3.2 Android启动架构

如下是从某大佬的文章里摘取的图片,很详细地描述了Android系统启动过程:Boot Loader引导开机 -> Kernel -> Native -> Framework -> App

3.3 zImage

zImage是一般情况下默认的压缩内核映像文件,压缩vmlinux,加上一段解压启动代码得到,只能从0X0地址运行。

3.4 RAMDISK

RAMDISK(initrd) 是一个小的分区像,在引导时内核以只读方式挂载它。它只保护/int和一些置文件,它用于初始化和挂载其它的文件系统镜像。
ramdisk.img被包含Google android SDK中(SSDK ROOT/toolslibimages/ramdiskimg) , 也可以编生成(SSDK ROOT/outarget/productSPRODUT NAME/ramdisk.img) 。这是一个gzip压缩的CPIO文件。

3.5 RC文件

rc文件,是用Android Init Language编写的特殊文件。用这种语法编写的文件,统一用".rc"后缀。所有rc文件,不会被编译/链接。它是配置文件,不是程序,是一种用于android init的配置文件。真正加载rc文件,并进行解析,做事情的是 Init进程。

四、详细设计

4.1 Boot Rom

当长按电源开机的时候,引导芯片开始从固化在ROM的预设代码开始执行,然后将加载引导程序到RAM中。

4.2 BootLoader

BootLoader又称为引导程序,它在运行操作系统之前运行的一段程序,是运行的第一个程序。主要的功能有检查RAM、初始化一些硬件外设等功能,它最终的目的是启动操作系统。

4.3 Kernel

Kernel初始化可以分成三部分:zImage解压缩、kernel的汇编启动阶段、Kernel的C启动阶段

4.3.1 zImage解压缩阶段

  1. 内核加载到ram(内存)后,内核映像并不能直接运行,它是一个压缩的zImage文件。
  2. zImage映像中的并非一切被压缩,映像中包含未被压缩部分,这部分中包含解压缩程序,解压缩程序会解压缩映像中被压缩的部分。
  3. zImage使用gzip压缩的,它不仅仅是一个压缩文件,而且在这个文件的开头部分内嵌有gzip解压缩代码。
  4. 当zImage被调用时它从arch/arm/boot/compressed/head.S的start汇编例程开始执行,并最终调用arch/arm/boot/compressed/misc.c中的decompress_kernel()解压缩内核。

4.3.2 kernel的汇编启动阶段

idle进程的启动是用汇编语言编写(感兴趣可以去研究下),对应的启动如下:

@bsp\kernel\kernel5.4\arch\arm\kernel\head-common.S
ldmia	r4, {r0, r1, r2, r3}
str	r9, [r0]			@ Save processor ID
str	r7, [r1]			@ Save machine type
str	r8, [r2]			@ Save atags pointer
cmp	r3, #0
strne	r10, [r3]			@ Save control register values
mov	lr, #0
b	start_kernel //启动start_kernel函数

其中,语句b start_kernel,b 是跳转的意思,即跳转到start_kernel函数,对应的实现在bsp/kernel/kernel5.4/init/main.c,至此idle进程被启动。

4.3.3 Kernel的C启动阶段

start_kernel()函数是内核初始化C语言部分的主体。这个函数完成系统底层基本机制,包括处理器、存储管理系统、进程管理系统、中断机制、定时机制等的初始化工作。

@bsp\kernel\kernel5.4\init\main.c
asmlinkage __visible void __init start_kernel(void)
{
    char *command_line;
    char *after_dashes;

    set_task_stack_end_magic(&init_task);
    smp_setup_processor_id();//打印了驱动加载的第一行log
    ... //初始化一系列系统底层机制
    pr_notice("%s", linux_banner);//打印内核版本信息
    ...
    pr_notice("Kernel command line: %s\n", saved_command_line);//打印从uboot传递过来的command_line字符串
    ...
    /* Do the rest non-__init'ed, we're now alive */
    arch_call_rest_init();//创建init进程、kthread进程、idle进程
    prevent_tail_call_optimization();
}

4.3.3.1 kernel启动log

(1)kernel内核启动阶段,smp_setup_processor_id()函数会打印的第一条log(文中log可以在
[目录5.1开机log]
下载查看),如下:

[    0.000000] c0 Booting Linux on physical CPU 0x0

(2)接着会打印内核的一些信息,版本,作者,编译器版本,日期等,如下:

[    0.000000]c0  Linux version 5.4.147+ (lzq@cz-PowerEdge-R730) (Android (7284624, based on r416183b) clang version 12.0.5 (https://android.googlesource.com/toolchain/llvm-project c935d99d7cf2016289302412d708641d52d2f7ee), LLD 12.0.5 (/buildbot/src/android/llvm-toolchain/out/llvm-project/lld c935d99d7cf2016289302412d708641d52d2f7ee)) #5 SMP PREEMPT Thu Sep 28 15:17:40 CST 2023

(3)打印出从uboot传递过来的command_line字符串,在setup_arch函数中获得的(proc/cmdline)

[    0.000000]c0  Kernel command line: earlycon=sprd_serial_ex,0x508d0000,115200n8 console=ttySE0,921600n8 loglevel=7 init=/init root=/dev/ram0 rw vmalloc=360M printk.devkmsg=on androidboot.boot_devices=soc/soc:ap-ahb/20600000.sdio initcall_debug=1 swiotlb=1  androidboot.selinux=permissive androidboot.hardware=sl8541e_1h10_32b androidboot.dtbo_idx=0 lcd_id=ID40396 lcd_name=lcd_st7123_truly_mipi_hd lcd_base=9e000000 lcd_size=1440x720 logo_bpix=24 androidboot.ddrsize=1024M androidboot.ddrsize.range=[1024,2048)  sysdump_magic=80001000 sysdump_re_flag=1  androidboot.wdten=0  androidboot.dswdten=disable modem=shutdown ltemode=lcsfb rfboard.id=-1 rfhw.id=0 crystal=2 32k.less=1 androidboot.pmic.chipid=2721 modemboot.method=emmcboot cpcmdline=end  androidboot.verifiedbootstate=orange androidboot.flash.locked=0  androidboot.serialno=LE210210001326000028 buildvariant=userdebug androidboot.vbmeta.device=PARTUUID=1.0 androidboot.vbmeta.avb_version=1.1 androidboot.vbmeta.device_state=unlocked androidboot.

4.3.3.2 init进程&kthreadd进程

创建了Linux系统中两个重要的进程init和kthreadd,并且将当前进程设置为idle进程:

@bsp\kernel\kernel5.4\init\main.c
void __init __weak arch_call_rest_init(void)
{
    rest_init();
}

noinline void __ref rest_init(void)
{
    ...
    pid = kernel_thread(kernel_init, NULL, CLONE_FS);//创建init进程
    ...
    pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES);//创建kthreadd进程
    ...
    /* Call into cpu_idle with preempt disabled */
    cpu_startup_entry(CPUHP_ONLINE);//设置当前进程为idle进程
}

Linux下有三个特殊的进程,idle(swapper)进程(PID = 0),init进程(PID = 1)和看threadd(PID = 2):

(1)idle(swapper)进程由系统自动创建,运行在内核态。idle进程其pid=0,其前身是系统创建的第一个进程,也是唯一一个没有通过fork或者kernel_thread产生的进程。完成加载系统后,演变为进程调度、交换,常常被称为交换进程。
(2)init进程由idle通过kernel_thread创建,在内核空间完成初始化后,加载init进程,并最终转变为用户空间的init进程,是系统中所有其他用户进程的祖先进程。
(3)kthreadd进程是idle通过kernel_thread创建,并始终运行在内核空间 ,负责所有内核线程的调度和管理。

4.3.3.3 idle进程启动

这个函数是Linux内核为非引导CPU初始化和进入空闲循环的入口函数,负责在系统没有任务需要执行时,让CPU进入空闲状态。

@bsp\kernel\kernel5.4\kernel\sched\idle.c
void cpu_startup_entry(enum cpuhp_state state)
{
    arch_cpu_idle_prepare();
    cpuhp_online_idle(state);
    while (1)
        do_idle();
}

4.4 Init进程

由上一节可知,Init进程由0号idle进程启动,kernel_init()为其入口函数,该函数主要通过三种方式启动init程序。

static int __ref kernel_init(void *unused)
{
    ...
    if (ramdisk_execute_command) {
        ret = run_init_process(ramdisk_execute_command);//Step 1. 根据ramdisk_execute_command的值来启动init程序
        if (!ret)
            return 0;
        pr_err("Failed to execute %s (error %d)\n",
                ramdisk_execute_command, ret);
    }

    if (execute_command) {
        ret = run_init_process(execute_command);//Step 2. 根据execute_command的值来启动init程序
        if (!ret)
            return 0;
        panic("Requested init %s failed (error %d).",
                execute_command, ret);
    }
    if (!try_to_run_init_process("/sbin/init") ||
        !try_to_run_init_process("/etc/init") ||
        !try_to_run_init_process("/bin/init") ||
        !try_to_run_init_process("/bin/sh")) //Step 3.根据系统默认位置来启动init程序
        return 0;

    panic("No working init found.  Try passing init= option to kernel. "
            "See Linux Documentation/admin-guide/init.rst for guidance.");//Step 4.异常重启
}

(1)ramdisk_execute_command是一个全局的char指针变量,值为“/init”,也就是根目录下的init程序。该值通过uboot传递过来,具体可参考上一节的command_line。(如未配置该值,即会将该值默认设置为"/init",该项目是从该处启动init进程)
(2)execute_command也是一个全局的char指针变量,值通过uboot传递;
(3)在前面两种情况都不满足的情况下,从系统默认位置加载init程序;
(4)如上三种情况都不执行的话,进入panic函数,则设备会异常重启;
(5)可以通过如下log确认系统启动的是哪个init程序,该log由run_init_process函数打印

[    1.000614]c1  Run /init as init process

4.4.1 init程序编译

我们知道init程序是引用的根目录下的init,即/init,但是其对应的软链接指向:system/bin/init,相关信息如下:

编译makefile文件如下:

@system\core\init\Android.bp
phony {
    name: "init",
    required: [
        "init_second_stage",
    ],
}

cc_binary {
    name: "init_second_stage",
    recovery_available: true,
    stem: "init",//最终生成的二进制文件名
    defaults: ["init_defaults"],
    static_libs: ["libinit"],
    ...
    srcs: ["main.cpp"],
    ...
}

4.4.2 init程序入口函数

init程序的入口函数,根据不同的入参,响应init不同阶段、处理不同业务逻辑。

@system\core\init\main.cpp
int main(int argc, char** argv) {
    ...
    if (!strcmp(basename(argv[0]), "ueventd")) {
        return ueventd_main(argc, argv);//初始化uevent事件
    }

    if (argc > 1) {
        ...
        if (!strcmp(argv[1], "selinux_setup")) {
            android::mboot::mdb("SELinux Setup ...");
            return SetupSelinux(argv);//selinux权限
        }

        if (!strcmp(argv[1], "second_stage")) {
            return SecondStageMain(argc, argv);//第二阶段
        }
    }

    return FirstStageMain(argc, argv);//第一阶段
}

由于在kernel阶段启动init程序时,未配置参数,故首先会引用FirstStageMain函数。另外,根据log可以看到,其引用顺序如下:

 [    1.004958]c1  init: init first stage started!
 [    2.547025]c0  init: Opening SELinux policy
 [    3.226672]c0  init: init second stage started!
 [    3.715657]c1  ueventd: ueventd started!
  1. FirstStageMain:
    主要创建和挂载基本的文件系统,挂载特定分区,启用log等;
  2. SetupSelinux:
    挂载并启用selinux权限系统;
  3. SecondStageMain:
    主要解析ini.rc文件,创建zygote孵化器,fork 进程等;

4.4.3 FirstStageMain

第一阶段,该阶段所挂载的文件系统都属于ramdisk,运行在虚拟内存上。

@system\core\init\first_stage_mount.cpp
int FirstStageMain(int argc, char** argv) {
    ...
    //Step 1. 创建&挂载最基本的文件系统
    CHECKCALL(mount("tmpfs", "/dev", "tmpfs", MS_NOSUID, "mode=0755"));//将/dev设置为tmpfs并挂载,设置0755权限,tmpfs是在内存上建立的文件系统(Filesystem)
    CHECKCALL(mkdir("/dev/pts", 0755));
    CHECKCALL(mkdir("/dev/socket", 0755));
    CHECKCALL(mkdir("/dev/dm-user", 0755));
    ...
    //Step 2. 初始化log系统并打印
    SetStdioToDevNull(argv);
    InitKernelLogging(argv);
    ...
    LOG(INFO) << "init first stage started!";
    ...
    //Step 3. 加载内核驱动模块
    if (!LoadKernelModules(IsRecoveryMode() && !ForceNormalBoot(cmdline, bootconfig), want_console,
                           module_count)) {
        ...
    }
    ...
    //Step 4.挂载分区(system、vendor、product等分区)
    if (!DoFirstStageMount(!created_devices)) {
        LOG(FATAL) << "Failed to mount required partitions early ...";
    }
    ...
    //Step 5.初始化Android的安全框架Android Verified Boot
    SetInitAvbVersionInRecovery();
    ...
    //Step 6.执行下一个阶段
    const char* path = "/system/bin/init";
    const char* args[] = {path, "selinux_setup", nullptr};
    ...
    execv(path, const_cast<char**>(args));//exec系列函数可以把当前进程替换为一个新进程,且新进程与原进程有相同的PID,即重新回到main.cpp
    ...
}

Step 1.
创建&挂载最基本的文件系统,创建了如下五类文件系统:

文件系统 挂载路径 描述
tmpfs /dev 一种虚拟内存文件系统,它会将所有的文件存储在虚拟内存中,如果你将tmpfs文件系统卸载后,那么其下的所有的内容将不复存在。tmpfs既可以使用RAM,也可以使用交换分区,会根据你的实际需要而改变大小。
devpts /dev/pts 为伪终端提供了一个标准接口,它的标准挂接点是/dev/pts。只要pty的主复合设备/dev/ptmx被打开,就会在/dev/pts下动态的创建一个新的pty设备文件。
proc /proc 一个虚拟文件系统,它可以看作是内核内部数据结构的接口,通过它我们可以获得系统的信息,同时也能够在运行时修改特定的内核参数。
sysfs /sys 与proc文件系统类似,也是一个不占有任何磁盘空间的虚拟文件系统。它通常被挂接在/sys目录下。sysfs文件系统是Linux2.6内核引入的,它把连接在系统上的设备和总线组织成为一个分级的文件,使得它们可以在用户空间存取。
selinuxfs /sys/fs/selinux 用于支持SELinux的文件系统,SELinux提供了一套规则来编写安全策略文件,这套规则被称之为 SELinux Policy 语言。

相关挂载情况如下:

Step 2.
结合dev/kmsg节点,初始化log系统,并在第一阶段开始时,打印第一条log

 [    1.004958]c1  init: init first stage started!

Step 3.
加载内核驱动模块,第一阶段加载的内核模块如下:

[    1.012653]c1  init: Loaded kernel module /lib/modules/sprd_wdt.ko
[    1.020197]c0  init: Loaded kernel module /lib/modules/sc2721-regulator.ko
[    1.022427]c0  init: Loaded kernel module /lib/modules/nvmem-sc27xx-efuse.ko
[    1.026126]c0  init: Loaded kernel module /lib/modules/spool.ko
[    1.033668]c0  init: Loaded kernel module /lib/modules/sipx.ko
[    1.047553]c0  init: Loaded kernel module /lib/modules/seth.ko
[    1.048526]c0  init: Loaded kernel module /lib/modules/usb_f_vser.ko
...

Step 4.
挂载分区。创建/first_stage_ramdisk并挂载,然后将根目录切换到/first_stage_ramdisk,并挂载system、vendor 、product等系统分区,挂载信息如上。
Step 5.
初始化Android的安全框架Android Verified Boot,用于防止系统文件本身被篡改、防止系统回滚,以免回滚系统利用以前的漏洞。
Step 6.
启动下一个阶段SetupSelinux。

4.4.4 SetupSelinux

该阶段主要是初始化Selinux权限相关业务,同时在业务流程最后一步时,重新执行system/bin/init程序,再次启动下一个阶段SecondStageMain。

@system\core\init\selinux.cpp
int SetupSelinux(char** argv) {
    ...
    LOG(INFO) << "Opening SELinux policy";
    ...
    const char* path = "/system/bin/init";
    const char* args[] = {path, "second_stage", nullptr};
    execv(path, const_cast<char**>(args));
    ...
    return 1;
}

该阶段log打印如下:

 [    2.547025]c0  init: Opening SELinux policy

4.4.5 SecondStageMain

第二阶段,涉及到文件系统挂载、属性服务等系统相关业务,其中最主要的一点去解析rc文件(创建目录,修改权限,挂载分区,启动服务进程等),以期让开机流程进入下一阶段。

@system\core\init\init.cpp
int SecondStageMain(int argc, char** argv) {
    //Step 1.初始化log
    SetStdioToDevNull(argv);
    InitKernelLogging(argv);
    LOG(INFO) << "init second stage started!";
    ...
    //Step 2.属性服务初始化,读取默认属性配置
    PropertyInit();
    ...
    //Step 3.挂载其他文件系统,如/apex
    MountExtraFilesystems();
    ...
    //Step 4.启动属性服务
    StartPropertyService(&property_fd);
    ...
    //Step 5.加载开机rc文件
    ActionManager& am = ActionManager::GetInstance();
    ServiceList& sm = ServiceList::GetInstance();
    LoadBootScripts(am, sm);
    ...
    //Step 6.设置进程优先级,主进程不能退出
    setpriority(PRIO_PROCESS, 0, 0);
    while (true) {
        ...
    }
    return 0;
}

Step 1.
初始化log,该阶段log打印如下:

 [    3.226672]c0  init: init second stage started!

Step 2.
属性服务初始化,读取默认属性配置。获取system/build.prop、vendor/build.prop、/odm/build.prop、/product/build.prop等其他build.prop属性,并加载到properties map结构中,然后通过MMAP映射到全局内存中,供所有进程调用;
Step 3.
挂载其他文件系统,如/apex。挂载第二阶段相关的文件系统;
Step 4.
启动属性服务。创建socket,处理客户端发来的请求,决定是更新属性值还是新增属性值;
Step 5.
加载开机rc文件,rc文件的加载顺序如下;

[    3.481952]c0  init: Parsing file /system/etc/init/hw/init.rc...
[    3.489693]c0  init: Parsing file /init.environ.rc...
[    3.490110]c0  init: Parsing file /system/etc/init/hw/init.usb.rc...
[    3.491867]c0  init: Parsing file /init.sl8541e_1h10_32b.rc...
[    3.493283]c0  init: Parsing file /vendor/etc/init/hw/init.sl8541e_1h10_32b.rc...
[    3.494372]c0  init: Parsing file /vendor/etc/init/hw/init.sl8541e_1h10_32b.usb.rc...
[    3.509664]c0  init: Parsing file /vendor/etc/init/hw/init.ram.rc...
...

解析init.rc会把一条条命令映射到内存中,然后依次启动,启动顺序如下:

on on early-init:在初始化早期阶段触发
on init:在初始化阶段触发
on late-init:在初始化晚期阶段触发
on boot/charger:当系统启动/充电时触发
on property:当属性值满足条件时触发

Step 6.
设置进程优先级,主进程不能销毁和退出。

4.5 Zygote进程

4.5.1 Zygote进程启动脚本

(1)rc文件编译
init.rc文件是一个配置文件,最终会被打包到该目录:/system/etc/init/hw/

@system\core\rootdir\Android.bp
prebuilt_etc {
    name: "init.rc",
    src: "init.rc",
    sub_dir: "init/hw",
    required: [
        "fsverity_init",
        "platform-bootclasspath",
    ],
}

(2)init.rc
由上一节我们知道,当init程序启动到第二阶段时候,会去加载rc文件,且最开始加载的rc文件是:system/etc/init/hw/init.rc

@system\core\rootdir\init.rc
...
import /system/etc/init/hw/init.${ro.zygote}.rc//导入zygote的rc文件,ro.zygote属性可根据系统读取
...
# Mount filesystems and start core system services.
on late-init
    ...
    # Now we can start zygote for devices with file based encryption
    trigger zygote-start //在开机初始化晚期阶段,触发zygote启动
    ...

# It is recommended to put unnecessary data/ initialization from post-fs-data
# to start-zygote in device's init.rc to unblock zygote start.
on zygote-start && property:ro.crypto.state=unencrypted
    wait_for_prop odsign.verification.done 1
    # A/B update verifier that marks a successful boot.
    exec_start update_verifier_nonencrypted
    start statsd
    start netd
    start zygote
    start zygote_secondary

(3)init.zygote32.rc
启动 app_process,并改名为zygote,使用:-Xzygote、/system/bin、–zygote和–start-system-server等参数,同时重启了audioserver、cameraserver、media等服务。

@system\core\rootdir\init.zygote32.rc
service zygote /system/bin/app_process -Xzygote /system/bin --zygote --start-system-server
    class main
    priority -20
    user root
    group root readproc reserved_disk
    socket zygote stream 660 root system
    socket usap_pool_primary stream 660 root system
    onrestart exec_background - system system -- /system/bin/vdc volume abort_fuse
    onrestart write /sys/power/state on
    onrestart restart audioserver
    onrestart restart cameraserver
    onrestart restart media
    onrestart restart netd
    onrestart restart wificond
    writepid /dev/cpuset/foreground/tasks
    critical window=${zygote.critical_window.minute:-off} target=zygote-fatal

(4)zygote启动log
在解析完rc文件后,会启动对应的服务,zygote启动log如下:

[    7.916749]c3  init: starting service 'zygote'...

4.5.2 Zygote进程

(1)zygote编译文件

@frameworks\base\cmds\app_process\Android.bp
cc_binary {
    name: "app_process",
    srcs: ["app_main.cpp"], 
    ...
}

(2)app_process启动
zygote是一个名为zygote的app_process进程,解析rc文件参数,启动ZygoteInit。

int main(int argc, char* const argv[])
{
    ...
    while (i < argc) {//读取rc文件传进来的参数
        const char* arg = argv[i++];
        if (strcmp(arg, "--zygote") == 0) {
            zygote = true;
            niceName = ZYGOTE_NICE_NAME;
        } else if (strcmp(arg, "--start-system-server") == 0) {
            startSystemServer = true;
        } 
        ...
    }
    ...
    if (zygote) {
        runtime.start("com.android.internal.os.ZygoteInit", args, zygote);//启动ZygoteInit
    } 
    ...
}

(3)AndroidRuntime
通过反射去启动ZygoteInit的main函数,相关引用如下:

void AndroidRuntime::start(const char* className, const Vector<String8>& options, bool zygote)
{
    ALOGD(">>>>>> START %s uid %d <<<<<<\n",
            className != NULL ? className : "(unknown)", getuid());
    ...
    //通过反射启动函数
    char* slashClassName = toSlashClassName(className != NULL ? className : "");
    jclass startClass = env->FindClass(slashClassName);
    if (startClass == NULL) {
        ALOGE("JavaVM unable to locate class '%s'\n", slashClassName);
        /* keep going */
    } else {
        jmethodID startMeth = env->GetStaticMethodID(startClass, "main",
            "([Ljava/lang/String;)V");
        if (startMeth == NULL) {
            ALOGE("JavaVM unable to find main() in '%s'\n", className);
            /* keep going */
        } else {
            env->CallStaticVoidMethod(startClass, startMeth, strArray);

#if 0
            if (env->ExceptionCheck())
                threadExitUncaughtException(env);
#endif
        }
    }
    ...
}

当前流程会打印如下log:

01-01 08:00:08.538   387   387 D AndroidRuntime: >>>>>> START com.android.internal.os.ZygoteInit uid 0 <<<<<<

4.5.3 Zygote进程(java)

(1)ZygoteInit

@frameworks\base\core\java\com\android\internal\os\ZygoteInit.java
public static void main(String[] argv) {
    ...
    try {
        //Step 1. 预加载资源文件
        if (!enableLazyPreload) {
            bootTimingsTraceLog.traceBegin("ZygotePreload");
            EventLog.writeEvent(LOG_BOOT_PROGRESS_PRELOAD_START,
                    SystemClock.uptimeMillis());
            preload(bootTimingsTraceLog);
            EventLog.writeEvent(LOG_BOOT_PROGRESS_PRELOAD_END,
                    SystemClock.uptimeMillis());
            bootTimingsTraceLog.traceEnd(); // ZygotePreload
        }
        ...
        //Step 2.注册Zygote的socket监听接口
        zygoteServer = new ZygoteServer(isPrimaryZygote);
        //Step 3.创建system_server进程
        if (startSystemServer) {
            Runnable r = forkSystemServer(abiList, zygoteSocketName, zygoteServer);
            if (r != null) {
                r.run();
                return;
            }
        }
        //Step 4.主线程loop消息循环
        caller = zygoteServer.runSelectLoop(abiList);
    } 
    ...
}

Step 1.
提前加载类,加载系统资源(如一些公共的库、SDK等),这样当程序被fork处理后,应用的进程内已经包含了这些系统资源,大大节省了应用的启动时间。
Step 2.
注册Zygote的socket监听接口,用来接收启动应用程序的消息;
Step 3.
frok出SystemServer进程;
Step 4.
主线程loop消息循环

(2)forkSystemServer
fork出SystemServer进程,并且去调用SystemServer进程的main函数

@frameworks\base\core\java\com\android\internal\os\ZygoteInit.java
private static Runnable forkSystemServer(String abiList, String socketName,
        ZygoteServer zygoteServer) {
    ...
    /* Hardcoded command line to start the system server */
    String[] args = {
            "--setuid=1000",
            "--setgid=1000",
            "--setgroups=1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1018,1021,1023,"
                    + "1024,1032,1065,3001,3002,3003,3006,3007,3009,3010,3011",
            "--capabilities=" + capabilities + "," + capabilities,
            "--nice-name=system_server",
            "--runtime-args",
            "--target-sdk-version=" + VMRuntime.SDK_VERSION_CUR_DEVELOPMENT,
            "com.android.server.SystemServer",
    };//启动SystemServer相关参数
    ...
    try {
        ...
        /* Request to fork the system server process */
        pid = Zygote.forkSystemServer(
                parsedArgs.mUid, parsedArgs.mGid,
                parsedArgs.mGids,
                parsedArgs.mRuntimeFlags,
                null,
                parsedArgs.mPermittedCapabilities,
                parsedArgs.mEffectiveCapabilities);//fork出SystemServer进程
    } catch (IllegalArgumentException ex) {
        throw new RuntimeException(ex);
    }

    /* For child process */
    if (pid == 0) {
        ...
        return handleSystemServerProcess(parsedArgs);//通过反射调用SystemServer进程的main函数
    }
    return null;
}

(3)handleSystemServerProcess
此函数为调用SystemServer进程,最终会通过反射调用SystemServer进程的main函数,其方法调用栈如下:
handleSystemServerProcess()->ZygoteInit.zygoteInit()->RuntimeInit.applicationInit()->RuntimeInit.findStaticMain()

@frameworks\base\core\java\com\android\internal\os\RuntimeInit.java
protected static Runnable findStaticMain(String className, String[] argv,
        ClassLoader classLoader) {
    Class<?> cl;

    try {
        cl = Class.forName(className, true, classLoader);//反射调用类:com.android.server.SystemServer
    }
    ...
    Method m;
    try {
        m = cl.getMethod("main", new Class[] { String[].class });//反射调用方法:main
    } 
    ...
    return new MethodAndArgsCaller(m, argv);
}

4.6 SystemServer进程

Android系统在启动的时候,在启动两个重要的进程,一个是Zygote进程,另一个是由zygote进程fork出来的system_server进程。SystemSever负责启动系统的各项服务,Android系统中Java世界的核心Service都在这里启动。

4.6.1 main函数

由上一章节我们知道,SystemServer的入口函数是main方法,如下:

@frameworks\base\services\java\com\android\server\SystemServer.java
public static void main(String[] args) {
    new SystemServer().run();
}

private void run() {
    ...    
    Slog.i(TAG, "Entered the Android system server!");
    ...
    // Start services.
    try {
        t.traceBegin("StartServices");
        startBootstrapServices(t);//引导服务
        startCoreServices(t);//核心服务
        startOtherServices(t);//其他服务
    } 
    ...
    // Loop forever.
    Looper.loop();//主线程循环队列
    throw new RuntimeException("Main thread loop unexpectedly exited");
}

SystemServer进程成功启动,会打印如下log:

01-01 08:00:21.430  1001  1001 I SystemServer: Entered the Android system server!

4.6.2 SystemServer中启动服务

在一系列的java服务中,可以分为三类:系统boot级别服务、核心服务、其他服务,其对应如下:

类型 服务 备注
startBootstrapServices ActivityManagerService 简称AMS,管理Android四大组件的生命周期
PowerManagerService 电源管理服务
PackageManagerService 简称PMS,用于APK的安装、卸载、权限验证等
UserManagerService 用户创建、删除、查询等
... ...
startCoreServices BatteryService 对设备电池状态进行监控
UsageStatsService 收集App的使用频率等信息
... ...
startOtherServices CameraService 管理设备相机功能
BluetoothService 管理蓝牙服务
WindowManagerService 简称WMS,管理窗口服务
FingerPrintService 管理指纹服务
... ...

4.7 Home进程

一般情况下,Android原生的软体会包含两个home进程,一个是Settings进程的Fallbackhome,一个是Launcher进程。

4.7.1 Home进程的编译

(1)FallbackHome编译
Fallbackhome位于Settings进程内,随系统启动时被拉起。

@packages\apps\Settings\Android.bp
android_app {
    name: "Settings",
    defaults: ["platform_app_defaults"],
    platform_apis: true,
    certificate: "platform",
    system_ext_specific: true,
    privileged: true,
    ...
}

(2)Launcher进程编译
Launcher进程主要用于显示App的界面信息。随着Android版本的迭代、以及层出不穷的产品,目前Android的Launcher版本较多,如Home、Launcher2、Launcher3、Launcher3QuickStep,要根据自身项目的配置,找到对应的Launcher应用。

@packages\apps\Launcher3\Android.mk
include $(CLEAR_VARS)
...
LOCAL_PACKAGE_NAME := Launcher3QuickStepGo
LOCAL_PRIVILEGED_MODULE := true
LOCAL_SYSTEM_EXT_MODULE := true
LOCAL_OVERRIDES_PACKAGES := Home Launcher2 Launcher3 Launcher3QuickStep
...

4.7.2 Launcher进程启动

FallbackHome是系统由未解密到解密过程的一个过度界面,只要用户把系统解锁过一次后,FallbackHome收到解锁广播就会退出,而WMS检测到当前Acitivity栈是空的,进而启动真正的Launcher。由于FallbackHome没有界面,所以可能会出现一个问题,home进程切换时会出现空白界面,接下来才是显示Launcher的一个图标界面。

@packages\apps\Settings\src\com\android\settings\FallbackHome.java
protected void onCreate(Bundle savedInstanceState) {
    ...
    registerReceiver(mReceiver, new IntentFilter(Intent.ACTION_USER_UNLOCKED));//注册ACTION_USER_UNLOCKED广播
    maybeFinish();
}

private BroadcastReceiver mReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
        maybeFinish();//接收ACTION_USER_UNLOCKED广播
    }
};

private void maybeFinish() {
    if (getSystemService(UserManager.class).isUserUnlocked()) {
        final Intent homeIntent = new Intent(Intent.ACTION_MAIN)
                .addCategory(Intent.CATEGORY_HOME);
        final ResolveInfo homeInfo = getPackageManager().resolveActivity(homeIntent, 0);//查询home包名信息,此处一般是返回Launcher的信息
        if (Objects.equals(getPackageName(), homeInfo.activityInfo.packageName)) {
            Log.d(TAG, "User unlocked but no home; let's hope someone enables one soon?");
            mHandler.sendEmptyMessageDelayed(0, 500);//间隔500ms轮询
        } else {
            Log.d(TAG, "User unlocked and real home found; let's go!");
            getSystemService(PowerManager.class).userActivity(
                    SystemClock.uptimeMillis(), false);
            finish();//结束当前Activity,启动Launcher应用
        }
    }
}

4.7.3 FallbackHome进程启动

(1)
启动home进程
刚开机时,SystemSever进程会启动WMS服务,如果WMS未检测到Activity栈有任务时,会启动一个默认的home进程,此进程即FallbackHome。

@frameworks\base\services\core\java\com\android\server\wm\RootWindowContainer.java
void startHomeOnEmptyDisplays(String reason) {
    forAllTaskDisplayAreas(taskDisplayArea -> {
        if (taskDisplayArea.topRunningActivity() == null) {
            startHomeOnTaskDisplayArea(mCurrentUser, reason, taskDisplayArea,
                    false /* allowInstrumenting */, false /* fromHomeKey */);
        }
    });
}

(2)
ACTION_USER_UNLOCKED广播发送
系统解锁时会发送该广播,相关代码如下:

@frameworks\base\services\core\java\com\android\server\am\UserController.java
void finishUserUnlocked(final UserState uss) {
    ...
    if (!mInjector.getUserManager().isPreCreated(userId)) {
        // Dispatch unlocked to external apps
        final Intent unlockedIntent = new Intent(Intent.ACTION_USER_UNLOCKED);
        unlockedIntent.putExtra(Intent.EXTRA_USER_HANDLE, userId);
        unlockedIntent.addFlags(
                Intent.FLAG_RECEIVER_REGISTERED_ONLY | Intent.FLAG_RECEIVER_FOREGROUND);
        mInjector.broadcastIntent(unlockedIntent, null, null, 0, null,
                null, null, AppOpsManager.OP_NONE, null, false, false, MY_PID, SYSTEM_UID,
                Binder.getCallingUid(), Binder.getCallingPid(), userId);
    }
    ...
}

五、相关资源

5.1 开机log

串口+logcat:
https://download.csdn.net/download/u013320490/88800008

六、小结

本文章涉猎Android多个层级,旨在梳理整体流程,对Android设备的启动有一个感性的认识,能够达到一定逻辑自洽。
Android的每个模块、每个进程、每行代码都有其深度,没有细细揣测与推敲,是有点"亵渎"了,他日必对其中感兴趣模块加以研究,respect!

七、参考资料

  1. Android系统架构图
    https://developer.android.google.cn/guide/platform?hl=zh-cn
  2. Android Hal层的由来
    https://blog.csdn.net/Xiaoma_Pedro/article/details/130253665
  3. 开机流程
    https://www.ancii.com/acglj4qvw/
    https://gityuan.com/android/#二android架构
  4. Kernel内核
    https://blog.51cto.com/u_16213627/8681479
    https://blog.csdn.net/CAUC_learner/article/details/120435753
    https://blog.csdn.net/daringtodoit/article/details/24675867
    https://blog.csdn.net/marshal_zsx/article/details/80225854
    https://blog.51cto.com/u_11947739/6360569
    https://blog.csdn.net/qq_38499859/article/details/88187602
  5. init进程分析
    https://www.yii666.com/blog/693472.html
    https://blog.csdn.net/hai_qing_xu_kong/article/details/85707697
    https://www.yii666.com/blog/423299.html
  6. RC文件
    https://blog.csdn.net/zxc024000/article/details/666666473100

1、准备材料

正点原子stm32f407探索者开发板V2.4

STM32CubeMX软件(
Version 6.10.0

keil µVision5 IDE(
MDK-Arm

ST-LINK/V2驱动

野火DAP仿真器

XCOM V2.6串口助手

2、实验目标

使用STM32CubeMX软件配置STM32F407开发板
USB_OTG_FS为工作在Human Interface Device Class (HID)(人机接口设备类)模式下的USB_DEVICE(USB从机),利用上下左右四个用户按键模拟在Windwos上的鼠标或键盘操作

3、模拟鼠标实验流程

3.0、前提知识

关于USB的相关知识请读者阅读
STM32CubeMX教程29 USB_HOST - 使用FatFs文件系统读写U盘
实验“3、USB概述”小节内容,USB_SALVE从机接口硬件原理图请读者阅读其“4.0、前提知识”小节内容

关于USB从机参数配置中Device Descriptor 选项卡下的参数配置请阅读
STM32CubeMX教程30 USB_DEVICE - MSC外设_读卡器
实验”3.0、前提知识“小节

将USB设备接口配置工作在Human Interface Device Class (HID)模式下,然后通过USB线连接到Windows电脑上就可以作为一个人体学输入设备出现在PC的设备管理器中,在此模式下可以将USB设备模拟为鼠标、键盘等其他的外设,
默认情况下CubeMX生成的HID外设为鼠标

鼠标设备和计算机通过USB通信采用HID的鼠标协议,该协议由四个字节组成,用于向计算机报告当前鼠标的状态,四个字节代表的含义如下图所示

第一个字节共8位数据用于表示鼠标上的按键状态,每个位代表一个按钮,1表示按下,0表示未按下,最左边的Button位于字节的低位,通常下最低位表述鼠标左键,第一位表示鼠标右键,第二位表示鼠标中键,比如设置该字节数据为0x01,则表示鼠标左键被按下

第二个字节表示鼠标在水平(X)方向上的相对移动,比如设置该字节数据为10,则表示X正方向移动10刻度;第三个字节表示鼠标在竖直(Y)方向上的相对移动,比如设置该字节数据为-10,则表示Y负方向移动10刻度;第四个字节表示滚轮的状态,比如设置该字节数据为10表示向上滚动10刻度

3.1、CubeMX相关配置

3.1.0、工程基本配置

打开STM32CubeMX软件,单击ACCESS TO MCU SELECTOR选择开发板MCU(选择你使用开发板的主控MCU型号),选中MCU型号后单击页面右上角Start Project开始工程,具体如下图所示

开始工程之后在配置主页面System Core/RCC中配置HSE/LSE晶振,在System Core/SYS中配置Debug模式,具体如下图所示

详细工程建立内容读者可以阅读“
STM32CubeMX教程1 工程建立

3.1.1、时钟树配置

将时钟树中48MHz时钟配置为48MHz,也即将Main PLL(主锁相环)的Q参数调节为7,其他HCLK、PCLK1和PCLK2时钟仍然设置为STM32F407能达到的最高时钟频率,具体如下图所示

3.1.2、外设参数配置

本实验需要初始化开发板上WK_UP、KEY2、KEY1和KEY0用户按键,具体配置步骤请阅读“
STM32CubeMX教程3 GPIO输入 - 按键响应

本实验需要初始化TIM6外设实现1ms定时,具体配置步骤请阅读“
STM32CubeMX教程5 TIM 定时器概述及基本定时器

本实验需要初始化USART1作为输出信息渠道,具体配置步骤请阅读“
STM32CubeMX教程9 USART/UART 异步通信

单击Pinout & Configuration页面左边功能分类栏目中
Connectivity/USB_OTG_FS,将其模式配置为仅从机(Device_Only),其他所有参数保持默认即可
,具体配置如下图所示

单击Pinout & Configuration页面左边功能分类栏目中
Middleware and Software Packs/USB DEVICE,将其模式配置为Human Interface Device Class (HID)(人机接口设备类),其他所有参数保持默认即可
,具体配置如下图所示

HID_FS_BINTERVAL
(指定中断传输的轮询间隔):可选0x01 ~ 0xFF,以毫秒为单位,此处设置为0XA表示USB主机每10ms轮询一次USB设备获取新的信息

Parameter Settings和Device Descriptor选项卡下其余参数请阅读
STM32CubeMX教程30 USB_DEVICE - MSC外设_读卡器
实验”3.0、前提知识“和”3.1.2、外设参数配置“两个小节

3.1.3、外设中断配置

当在Middleware and SoftwarePacks中配置了USB_DEVICE的模式不为Disable时,便会自动开启USB_OTG的全局中断,且不可关闭,用户配置合适的中断优先级即可

注意本实验需要开启基本定时器TIM6的全局中断
,勾选NVIC下的全局中断,具体配置如下图所示

3.2、生成代码

3.2.0、配置Project Manager页面

单击进入Project Manager页面,在左边Project分栏中修改工程名称、工程目录和工具链,然后在Code Generator中勾选“Gnerate peripheral initialization as a pair of 'c/h' files per peripheral”,最后单击页面右上角GENERATE CODE生成工程,具体如下图所示

详细Project Manager配置内容读者可以阅读“
STM32CubeMX教程1 工程建立
”实验3.4.3小节

3.2.1、设初始化调用流程

暂无

3.2.2、外设中断调用流程

暂无

3.2.3、添加其他必要代码

在main.c文件最下方添加通过按键设置鼠标指针坐标值的函数 和 TIM6定时器1ms回调函数,具体源代码如下所示

/*设置鼠标指针坐标值*/
static void GetPointerData(uint8_t *pbuf)
{
  int8_t  x = 0, y = 0, button = 0, Wheel=0;
	
	/*按键WK_UP被按下*/
	if(HAL_GPIO_ReadPin(WK_UP_GPIO_Port,WK_UP_Pin) == GPIO_PIN_SET)
	{
		if(HAL_GPIO_ReadPin(WK_UP_GPIO_Port,WK_UP_Pin) == GPIO_PIN_SET)
		{
			printf("Scroll the wheel up\r\n");
			//y -= CURSOR_STEP;
			Wheel = 10;
		}
	}
	/*按键KEY2被按下*/
	if(HAL_GPIO_ReadPin(KEY2_GPIO_Port,KEY2_Pin) == GPIO_PIN_RESET)
	{
		if(HAL_GPIO_ReadPin(KEY2_GPIO_Port,KEY2_Pin) == GPIO_PIN_RESET)
		{
			printf("←←←\r\n");
			x -= CURSOR_STEP;
		}
	}
	/*按键KEY1被按下*/
	if(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin) == GPIO_PIN_RESET)
	{
		if(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin) == GPIO_PIN_RESET)
		{
			printf("Left_Button_Pressed\r\n");
			//y += CURSOR_STEP;
			button = 0x01;
		}
	}
	/*按键KEY0被按下*/
	if(HAL_GPIO_ReadPin(KEY0_GPIO_Port,KEY0_Pin) == GPIO_PIN_RESET)
	{
		if(HAL_GPIO_ReadPin(KEY0_GPIO_Port,KEY0_Pin) == GPIO_PIN_RESET)
		{
			printf("→→→\r\n");
			x += CURSOR_STEP;
		}
	}
  pbuf[0] = button;
  pbuf[1] = x;
  pbuf[2] = y;
  pbuf[3] = Wheel;
}

/*TIM6定时器1ms回调函数*/
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
	static __IO uint32_t counter = 0;
	
	/* check Joystick state every polling interval (10ms) */
	if(counter++ == USBD_HID_GetPollingInterval(&hUsbDeviceFS))
	{
		GetPointerData(HID_Buffer);
		 
		/* send data though IN endpoint*/
		if((HID_Buffer[0] != 0) || (HID_Buffer[1] != 0) || (HID_Buffer[2] != 0) || (HID_Buffer[3] != 0))
		{
			USBD_HID_SendReport(&hUsbDeviceFS, HID_Buffer, sizeof(HID_Buffer));
		}
		counter = 0;
	}
}

在main.c文件中包含使用到的头文件,以及定义/声明使用到的一些变量,最后在主函数main()初始化外设完毕后以中断方式打开TIM6定时器即可,具体源代码如下所示

/*main.c文件中*/
/*包含头文件*/
#include "usbd_hid.h"

/*定义/声明变量*/
extern USBD_HandleTypeDef hUsbDeviceFS;
#define CURSOR_STEP 7
uint8_t HID_Buffer[4];

/*主函数进入主循环前启动TIM6定时器*/
HAL_TIM_Base_Start_IT(&htim6);

4、烧录验证

烧录程序,使用USB连接线将开发板上USB_SALVE接口与Windows电脑的USB接口连接,连接成功后可以通过串口助手监视系统的运行

首先按下开发板上的KEY2和KEY0左右两个用户按键,可以发现电脑上的鼠标光标会随着按键的按下向左或者向右移动,然后按下WK_UP上方用户按键可以发现串口助手显示的内容被拉到最上方,也即实现了滚轮向上滚动,然后将鼠标光标移动到串口助手的打开/关闭串口按钮上,按下KEY1按键之后发现可以控制串口的打开/关闭,具体现象如下图所示

5、模拟键盘实验流程简述

5.0、前提知识

键盘设备和计算机通过USB通信采用HID的键盘协议,该协议由八个字节组成,用于向计算机报告当前键盘的状态,八个字节代表的含义如下图所示
(注释1)

5.1、CubeMX相关配置

无需做任何修改,直接使用模拟鼠标时生成的工程代码

5.2、生成代码

打开生成的工程代码,由于CubeMX默认将设备描述为了鼠标设备,可以在usbd_hid.c文件中找到一个名为HID_MOUSE_ReportDesc的数组,该数组正式鼠标报告设备描述符,因此需要将该设备描述符修改为键盘的设备描述符,同时也应该修改该报告设备描述符数组的大小HID_MOUSE_REPORT_DESC_SIZE ,具体修改内容如下所示
(注释2)

/*修改usbd_hid.c中的报告设备描述符*/
__ALIGN_BEGIN static uint8_t HID_MOUSE_ReportDesc[HID_MOUSE_REPORT_DESC_SIZE] __ALIGN_END =
{
	0x05, 0x01, // USAGE_PAGE (Generic Desktop) //63
	0x09, 0x06, // USAGE (Keyboard)
	0xa1, 0x01, // COLLECTION (Application)
	0x05, 0x07, // USAGE_PAGE (Keyboard)
	0x19, 0xe0, // USAGE_MINIMUM (Keyboard LeftControl)
	0x29, 0xe7, // USAGE_MAXIMUM (Keyboard Right GUI)
	0x15, 0x00, // LOGICAL_MINIMUM (0)
	0x25, 0x01, // LOGICAL_MAXIMUM (1)
	0x75, 0x01, // REPORT_SIZE (1)
	0x95, 0x08, // REPORT_COUNT (8)
	0x81, 0x02, // INPUT (Data,Var,Abs)
	0x95, 0x01, // REPORT_COUNT (1)
	0x75, 0x08, // REPORT_SIZE (8)
	0x81, 0x03, // INPUT (Cnst,Var,Abs)
	0x95, 0x05, // REPORT_COUNT (5)
	0x75, 0x01, // REPORT_SIZE (1)
	0x05, 0x08, // USAGE_PAGE (LEDs)
	0x19, 0x01, // USAGE_MINIMUM (Num Lock)
	0x29, 0x05, // USAGE_MAXIMUM (Kana)
	0x91, 0x02, // OUTPUT (Data,Var,Abs)
	0x95, 0x01, // REPORT_COUNT (1)
	0x75, 0x03, // REPORT_SIZE (3)
	0x91, 0x03, // OUTPUT (Cnst,Var,Abs)
	0x95, 0x06, // REPORT_COUNT (6)
	0x75, 0x08, // REPORT_SIZE (8)
	0x15, 0x00, // LOGICAL_MINIMUM (0)
	0x25, 0x65, // LOGICAL_MAXIMUM (101)
	0x05, 0x07, // USAGE_PAGE (Keyboard)
	0x19, 0x00, // USAGE_MINIMUM (Reserved (no event indicated))
	0x29, 0x65, // USAGE_MAXIMUM (Keyboard Application)
	0x81, 0x00, // INPUT (Data,Ary,Abs)
	0xc0,       // END_COLLECTION
};

/*修改usbd_hid.h中的报告设备描述符大小*/
#define HID_MOUSE_REPORT_DESC_SIZE 63U

修改报告设备描述符连接计算机之后,计算机就应该将其识别为一个键盘设备,计算机和该USB设备通信时就应该按照键盘设备的HID协议数据包进行数据解析,我们通过开发板上的四个按键来模拟键盘上的a/x/y/z四个按键,将程序直接实现在main.c文件中,具体源代码如下所示

/*设置鼠标指针坐标值*/
static void GetPointerData(uint8_t *pbuf)
{
  int8_t keyboard = 0;
	
	/*按键WK_UP被按下*/
	if(HAL_GPIO_ReadPin(WK_UP_GPIO_Port,WK_UP_Pin) == GPIO_PIN_SET)
	{
		if(HAL_GPIO_ReadPin(WK_UP_GPIO_Port,WK_UP_Pin) == GPIO_PIN_SET)
		{
			printf("WK_UP Pressed : a/A\r\n");
			keyboard = 0x04;
			while(HAL_GPIO_ReadPin(WK_UP_GPIO_Port,WK_UP_Pin));
		}
	}
	/*按键KEY2被按下*/
	if(HAL_GPIO_ReadPin(KEY2_GPIO_Port,KEY2_Pin) == GPIO_PIN_RESET)
	{
		if(HAL_GPIO_ReadPin(KEY2_GPIO_Port,KEY2_Pin) == GPIO_PIN_RESET)
		{
			printf("KEY2 Pressed : x/X\r\n");
			keyboard = 0x1B;
			while(!HAL_GPIO_ReadPin(KEY2_GPIO_Port,KEY2_Pin));
		}
	}
	/*按键KEY1被按下*/
	if(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin) == GPIO_PIN_RESET)
	{
		if(HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin) == GPIO_PIN_RESET)
		{
			printf("KEY1 Pressed : y/Y\r\n");
			keyboard = 0x1C;
			while(!HAL_GPIO_ReadPin(KEY1_GPIO_Port,KEY1_Pin));
		}
	}
	/*按键KEY0被按下*/
	if(HAL_GPIO_ReadPin(KEY0_GPIO_Port,KEY0_Pin) == GPIO_PIN_RESET)
	{
		if(HAL_GPIO_ReadPin(KEY0_GPIO_Port,KEY0_Pin) == GPIO_PIN_RESET)
		{
			printf("KEY0 Pressed : z/Z\r\n");
			keyboard = 0x1D;
			while(!HAL_GPIO_ReadPin(KEY0_GPIO_Port,KEY0_Pin));
		}
	}
	//合成键盘数据包
	for(uint8_t i=0;i<8;i++)
	{
		if(i == 2) pbuf[i] = keyboard;
		else pbuf[i] = 0;
	}
}

/*TIM6定时器1ms回调函数*/
void HAL_TIM_PeriodElapsedCallback(TIM_HandleTypeDef *htim)
{
	static __IO uint32_t counter = 0;
	
	/* check Joystick state every polling interval (10ms) */
	if(counter++ == USBD_HID_GetPollingInterval(&hUsbDeviceFS))
	{
		GetPointerData(HID_Buffer);
		 
		/* send data though IN endpoint*/
		USBD_HID_SendReport(&hUsbDeviceFS, HID_Buffer, sizeof(HID_Buffer));
		
		/* 重置counter */
		counter = 0;
	}
}

5.3、烧录验证

烧录程序,使用USB连接线将开发板上USB_SALVE接口与Windows电脑的USB接口连接,连接成功后可以通过串口助手监视系统的运行

首先我们可以通过设备管理器查找一下该设备,看看Windwos将其识别为了什么设备,打开设备管理器,在键盘中找到最后一个,右键查看其属性,在详细信息页面属性中找到父系,在下方可以查看到该设备的VID和PID,可以发现和我们配置的HID设备描述中的ID一致,具体如下图所示

然后打开串口助手,将鼠标光标点击串口助手的发送数据区域,然后随机按下开发板上的四个用户按键,可以在串口助手发送数据区域发现每按下一个按键都会对应输出a、x、y、z四个字符,并且同时串口会输出哪个按键被按下的提示,具体现象如下图所示

6、常用函数

/*return polling interval from endpoint descriptor*/
uint32_t USBD_HID_GetPollingInterval(USBD_HandleTypeDef *pdev)
/*Send HID Report*/
uint8_t USBD_HID_SendReport(USBD_HandleTypeDef *pdev, uint8_t *report, uint16_t len)

7、注释详解

注释1
:图片来源
3、USB接口的键盘描述符范例

注释2
:键盘的报告设备描述符来源
STM32CubeMX学习笔记(44)——USB接口使用(HID按键)

参考资料

微雪课堂:
STM32CubeMX系列教程25:USB Device


引言


大家好,我是你们的老伙计秀才!今天带来的是[深入浅出Java多线程]系列的第五篇内容:线程间通信。大家觉得有用请点赞,喜欢请关注!秀才在此谢过大家了!!!

在现代编程实践中,多线程技术是提高程序并发性能、优化系统资源利用率的关键手段。Java作为主流的多线程支持语言,不仅提供了丰富的API来创建和管理线程,更重要的是它内置了强大的线程间通信机制,使得多个线程能够有效地协作并同步执行任务,从而确保数据的一致性和系统的稳定性。

在实际开发中,尤其是服务器端应用中,多线程并行处理可以极大地提升服务响应速度和吞吐量。然而,多线程环境中的共享资源访问往往会带来复杂性,比如竞争条件、死锁等问题。为了解决这些问题,我们必须熟练掌握Java中用于控制线程同步与通信的各种方法和技术。

引言部分首先引入一个生活化比喻:想象一下,多线程就像是许多工人在同一工作台上协同作业,为了保证工作的有序进行和资源的安全使用,我们需要一种类似于“信号灯”或“调度员”的机制来协调这些工人之间的交互。在Java中,这种协调机制就体现在对象锁(即互斥锁)上,就如同只有一个工具箱可供同时操作一样,一个对象锁同一时间只能被一个线程持有。通过
synchronized
关键字对代码块或方法进行标注,我们能够确保在任意时刻只有一个线程访问特定的临界区资源。

例如,考虑两个学生线程A和B在抄写同一份暑假作业答案的情景。为了防止他们因老师中途修改答案而造成两人作业内容不一致的问题,我们可以通过给整个抄写过程加上对象锁,确保先让老师完成修改再让学生们开始抄写,或者学生们抄完后再由老师去修改,这就体现了线程间的同步执行。

public class ObjectLock {
    private static final Object lock = new Object();

    static class StudentThread implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                // 这里模拟抄写作业的过程
                for (int i = 0; i < 100; i++) {
                    System.out.println("Student is copying answer " + i);
                }
            }
        }
    }

    public static void main(String[] args) {
        Thread studentA = new Thread(new StudentThread());
        Thread teacher = new Thread(() -> {
            // 模拟老师修改答案前后的等待和通知逻辑
            synchronized (lock) {
                // 修改答案...
                lock.notifyAll();  // 告诉所有等待的学生现在可以继续抄写了
            }
        });

        studentA.start();
        // 假设老师需要修改答案
        try { Thread.sleep(1000); } catch (InterruptedException e) {}
        teacher.start();
    }
}

上述示例展示了如何利用对象锁实现简单的线程同步,确保了学生线程在老师修改答案后才开始抄写。当然,更复杂的场景下,线程间通信还包括诸如等待/通知机制、管道流、join方法以及ThreadLocal等多样化的技术手段。这些方法各有特色且应用场景各异,深入理解它们的工作原理并灵活运用,将有助于开发者构建高效、安全的多线程应用程序。后续章节我们将逐一探讨这些机制,并通过实例代码揭示其内在逻辑和应用场景。



锁与同步


在Java多线程编程中,锁和同步机制是确保多个线程正确访问共享资源、避免并发问题的核心手段。首先,我们来深入理解这两个概念。



概念解释


锁(Locking)

是基于对象的,每个Java对象都可以关联一个内在的锁,也被称为“对象锁”。当一个线程试图访问某个需要同步的代码块时,它必须先获取到相关的对象锁。如果该锁已经被其他线程持有,那么当前线程就必须等待,直到锁被释放。这种一对一的关系就如同婚姻中的排他性:一次只能有一个线程“结婚”(即持有锁),而其他想要进入这段关系的线程则必须等到“离婚”(即释放锁)才能获得机会。


同步(Synchronization)

则是为了保证线程间的执行顺序和数据一致性。它通过
synchronized
关键字实现,使得同一时间只有一个线程可以执行特定的代码块或方法。同步确保了在临界区内的操作不会被多个线程同时执行,从而有效防止了数据竞争和不一致的情况发生。比如,两个学生线程A和B在抄写同一份暑假作业答案时,同步机制会确保老师修改完答案后,所有学生都看到的是最新版本的答案,而不是旧版。



代码示例

下面是一个使用对象锁进行线程同步的简单示例。在这个例子中,我们希望线程A完成其任务后再启动线程B,以确保它们按序执行。

public class ObjectLockExample {
    private static final Object lock = new Object();

    static class ThreadA implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                for (int i = 0; i < 100; i++) {
                    System.out.println("Thread A is working on task " + i);
                }
                // 线程A完成工作后,唤醒可能在等待的线程B
                lock.notify();
            }
        }
    }

    static class ThreadB implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                try {
                    // 线程B先等待线程A完成工作
                    lock.wait();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                for (int i = 0; i < 100; i++) {
                    System.out.println("Thread B is now working on task " + i);
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(new ThreadA());
        threadA.start();

        // 主线程等待片刻确保线程A已经获得锁并开始执行
        Thread.sleep(10);

        Thread threadB = new Thread(new ThreadB());
        threadB.start();
    }
}



锁机制解析

在上述代码中,
synchronized
关键字修饰的代码块表示对对象锁的加锁和解锁过程。线程A首先获得锁并执行循环打印,执行完成后调用
notify()
通知等待的线程。线程B在运行时,同样尝试获取相同的锁,但由于线程A尚未释放,因此线程B将被阻塞在
synchronized
代码块外,直至线程A调用
notify()
并退出同步块,释放锁。此时,线程B得以获取锁,并从等待状态转为就绪状态继续执行。

总结起来,锁与同步机制在Java多线程环境中起到至关重要的作用,它们约束了不同线程对共享资源的访问秩序,确保了线程间的数据一致性以及程序的正确性。通过对锁的合理运用,开发者可以有效地避免竞态条件和死锁等并发问题的发生。



等待/通知机制




基本原理

在Java多线程编程中,基于对象的等待/通知机制是一种高级同步手段,它允许一个或多个线程在特定条件满足前进入等待状态,而在其他线程完成某个操作后通过发送通知唤醒这些等待中的线程。这一机制主要依赖于
java.lang.Object
类提供的
wait()

notify()

notifyAll()
方法实现。

  • wait() : 当前线程调用该方法时,会释放当前持有的对象锁,并进入无限期等待状态,直到被其他线程调用同一个对象的 notify() notifyAll() 方法唤醒。
  • notify() : 随机唤醒一个正在等待该对象监视器(即锁)的线程。
  • notifyAll() : 唤醒所有正在等待该对象监视器的线程。

使用等待/通知机制时,必须确保在
synchronized
修饰的方法或代码块内调用这些方法,因为只有持有对象锁的线程才能执行它们。此外,调用
wait()
方法后,线程需要重新获得锁才能继续执行。



实例演示

以下是一个使用等待/通知机制控制线程交替打印数字的例子:

public class WaitAndNotifyExample {
    private static final Object lock = new Object();

    static class ThreadA implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                for (int i = 0; i < 5; i++) {
                    System.out.println("ThreadA: " + i);
                    lock.notify(); // 唤醒可能等待的线程B
                    try {
                        if (i != 4) { // 不是最后一个数则进入等待
                            lock.wait(); // 线程A等待被唤醒
                        }
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                }
                lock.notify(); // 最后一次通知,以防万一还有等待的线程
            }
        }
    }

    static class ThreadB implements Runnable {
        @Override
        public void run() {
            synchronized (lock) {
                for (int i = 0; i < 5; i++) {
                    try {
                        lock.wait(); // 线程B先等待,让线程A开始
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println("ThreadB: " + i);
                    lock.notify(); // 唤醒线程A进行下一轮输出
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(new ThreadA());
        threadA.start();
        Thread.sleep(100); // 给线程A一些时间初始化
        Thread threadB = new Thread(new ThreadB());
        threadB.start();
    }
}

运行上述代码,将会看到线程A和线程B交替打印从0到4的整数序列。在这个例子中,线程A首先获取锁并打印第一个数字,然后调用
notify()
唤醒线程B;线程B在获得锁后立即调用
wait()
让自己进入等待状态,此时线程A再次获取锁并打印下一个数字,循环此过程直至完成五次打印。整个过程中,两个线程通过共享的对象锁与等待/通知机制实现了精确的协作和通信。



管道通信




定义与应用

在Java多线程编程中,管道(Pipes)是一种特殊的通信机制,它允许线程之间通过内存流进行数据传输。JDK提供的
java.io.PipedWriter

java.io.PipedReader
用于字符流之间的通信,而
java.io.PipedOutputStream

java.io.PipedInputStream
则是基于字节流的通信工具。管道通信模型类似于现实生活中的水管,一个线程作为生产者将信息写入管道的一端,另一个线程作为消费者从管道的另一端读取这些信息。

管道通信特别适用于需要在线程间高效传递数据的场景,例如,一个线程负责生成数据并将其发送到另一个线程进一步处理或展示。这种机制尤其适用于避免使用共享变量带来的同步问题,以及简化线程间的协调工作。



代码实践

以下是一个利用Java管道进行线程间通信的实例代码:

public class PipeExample {
    static class ReaderThread implements Runnable {
        private PipedReader reader;

        public ReaderThread(PipedReader reader) {
            this.reader = reader;
        }

        @Override
        public void run() {
            System.out.println("Reader thread is ready to read");
            try {
                int receive;
                while ((receive = reader.read()) != -1) {
                    System.out.print((char) receive);
                }
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    static class WriterThread implements Runnable {
        private PipedWriter writer;

        public WriterThread(PipedWriter writer) {
            this.writer = writer;
        }

        @Override
        public void run() {
            System.out.println("Writer thread is ready to write");
            try {
                writer.write("Hello, World from the pipe!");
                writer.flush(); // 确保数据被完全写入管道
            } catch (IOException e) {
                e.printStackTrace();
            } finally {
                try {
                    writer.close();
                } catch (IOException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    public static void main(String[] args) throws IOException {
        PipedWriter writer = new PipedWriter();
        PipedReader reader = new PipedReader();

        // 注意:必须先连接管道两端,否则会抛出异常
        reader.connect(writer);

        Thread readerThread = new Thread(new ReaderThread(reader));
        Thread writerThread = new Thread(new WriterThread(writer));

        readerThread.start();
        writerThread.start();

        // 等待两个线程执行完毕
        readerThread.join();
        writerThread.join();
    }
}

运行上述代码,输出结果将是“Hello, World from the pipe!”。在这个示例中,我们创建了一个字符管道,并启动了两个线程,一个负责向管道中写入字符串,另一个则负责从管道中读取并打印出来。由于管道通信是单向的,因此确保了数据只能按照指定方向流动,从而实现线程间的有序通信。



其他通信方式




join方法

join()
方法是Java中
Thread
类的一个关键实例方法,用于同步线程执行。当一个线程调用另一个线程的
join()
方法时,当前线程将进入等待状态,直到被调用
join()
的线程完成其任务并结束。这在需要确保主线程等待子线程执行完毕后再继续执行的情况下尤为有用。

例如,假设主线程创建了一个耗时计算的任务交给子线程执行,并且主线程希望在子线程完成计算后获取结果:

public class JoinExample {
    static class LongRunningTask implements Runnable {
        @Override
        public void run() {
            try {
                System.out.println("我是子线程,开始执行耗时计算...");
                Thread.sleep(2000); // 模拟耗时操作
                int result = performComputation(); // 执行计算
                System.out.println("我是子线程,计算完成,结果为: " + result);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }

        private int performComputation() {
            return 42// 示例计算结果
        }
    }

    public static void main(String[] args) throws InterruptedException {
        Thread longRunning = new Thread(new LongRunningTask());
        longRunning.start();

        // 主线程等待子线程完成
        longRunning.join();

        // 子线程结束后,主线程可以安全地访问子线程的结果(此处假设已通过共享变量或其他机制传递)
        System.out.println("主线程:子线程已完成,我可以继续执行后续操作了");
    }
}



sleep方法

sleep()

Thread
类提供的一个静态方法,它使当前线程暂停指定的时间量。与
wait()
方法不同的是,
sleep()
不会释放任何锁资源,即线程在睡眠期间依然持有其已经获得的锁。此外,
sleep()
方法不会抛出
InterruptedException
异常,除非在调用
sleep()
的过程中,该线程被中断。

示例代码:

public class SleepExample {
    public static void main(String[] args) throws InterruptedException {
        Thread threadA = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                System.out.println("Thread A is running: " + i);
                try {
                    Thread.sleep(1000); // 线程A每运行一次循环就休眠1秒
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        threadA.start();
    }
}



ThreadLocal类

ThreadLocal
类提供了一种特殊的线程绑定存储机制,每个线程都有自己的独立副本。这意味着,即使多个线程同时引用同一个
ThreadLocal
实例,它们各自存取和修改的值也互不影响。

以下是一个使用
ThreadLocal
的简单示例,展示如何在一个多线程环境下为每个线程维护独立的上下文信息:

public class ThreadLocalDemo {
    public static class WorkerThread extends Thread {
        private final ThreadLocal<String> context;

        public WorkerThread(ThreadLocal<String> context, String name) {
            this.context = context;
            setName(name);
        }

        @Override
        public void run() {
            context.set(Thread.currentThread().getName() + ": Initial Value");

            // 假设进行了一些处理
            String newValue = "Processed by " + getName();
            context.set(newValue);

            System.out.println(getName() + " has its own value: " + context.get());
        }
    }

    public static void main(String[] args) {
        ThreadLocal<String> context = new ThreadLocal<>();
        WorkerThread worker1 = new WorkerThread(context, "Thread-1");
        WorkerThread worker2 = new WorkerThread(context, "Thread-2");

        worker1.start();
        worker2.start();
    }
}

在这个例子中,
WorkerThread
继承自
Thread
并使用
ThreadLocal
来保存线程特定的上下文信息。即使两个线程都使用了相同的
ThreadLocal
实例,它们各自的
context
变量仍然保持隔离,每个线程都可以安全地读取和更新自己的私有数据。



信号量机制




volatile关键字

在Java多线程编程中,volatile关键字用于确保变量的可见性和有序性。声明为volatile的变量会保证当一个线程修改了该变量值时,其他所有线程都能立即看到这个修改的结果。例如,在下面的示例中,我们用volatile关键字实现了一个简单的“信号量”模型来控制线程A和线程B交替打印数字:

public class SignalExample {
    private static volatile int signal = 0;

    static class ThreadA implements Runnable {
        @Override
        public void run() {
            while (signal < 5) {
                if (signal % 2 == 0) {
                    System.out.println("Thread A: " + signal);
                    synchronized (SignalExample.class{
                        signal++;
                    }
                }
            }
        }
    }

    static class ThreadB implements Runnable {
        @Override
        public void run() {
            while (signal < 5) {
                if (signal % 2 == 1) {
                    System.out.println("Thread B: " + signal);
                    synchronized (SignalExample.class{
                        signal = signal + 1;
                    }
                }
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new Thread(new ThreadA()).start();
        Thread.sleep(100); // 确保线程A有机会先执行
        new Thread(new ThreadB()).start();
    }
}

尽管此处volatile关键字确保了对
signal
变量修改的可见性,但由于
signal++
不是原子操作,因此仍需要使用
synchronized
同步块以确保更新操作的原子性。



Semaphore类

JDK提供的Semaphore类是一个更完整的信号量实现,它可以用来控制同时访问特定资源的线程数量,从而有效解决线程间的并发控制问题。以下是一个使用Semaphore模拟停车场车位管理的例子:

import java.util.concurrent.Semaphore;

public class SemaphoreDemo {
    private final Semaphore parkingSpaces = new Semaphore(3); // 假设有3个停车位

    static class Car implements Runnable {
        private final Semaphore semaphore;

        public Car(Semaphore semaphore) {
            this.semaphore = semaphore;
        }

        @Override
        public void run() {
            try {
                semaphore.acquire(); // 请求获取一个车位
                System.out.println(Thread.currentThread().getName() + "已停车");
                Thread.sleep(1000); // 模拟停车时间
            } catch (InterruptedException e) {
                e.printStackTrace();
            } finally {
                semaphore.release(); // 释放车位
                System.out.println(Thread.currentThread().getName() + "已离开");
            }
        }
    }

    public static void main(String[] args) {
        SemaphoreDemo demo = new SemaphoreDemo();

        for (int i = 0; i < 6; i++) { // 创建6辆车
            Thread car = new Thread(new Car(demo.parkingSpaces), "Car-" + (i + 1));
            car.start();
        }
    }
}

在这个例子中,Semaphore对象
parkingSpaces
初始化为3,表示有3个可用停车位。每辆汽车(线程)尝试获取一个车位前都要调用
acquire()
方法,成功获取后才能进行停车动作;完成停车后通过调用
release()
方法释放车位,以便其他车辆可以继续停车。这样便实现了基于信号量的多线程资源调度与同步。



总结


在Java多线程编程中,线程间的通信是实现协同工作和同步操作的关键环节。本文通过一系列实例和详细说明,探讨了多种有效的线程间通信方式。


  1. 锁与同步
    锁机制是Java中最基础的同步手段,利用 synchronized 关键字或显式Lock类确保同一时间只有一个线程访问共享资源。代码示例展示了如何使用对象锁来确保线程A执行完毕后线程B再开始执行,从而达到线程间的有序执行。

  2. 等待/通知机制
    wait() notify() 方法提供了一种灵活的线程通信方式,允许线程在满足特定条件时进入等待状态,并在条件改变时被其他线程唤醒。通过实例演示了线程A和线程B如何交替打印数字,展示了等待/通知机制在线程协作中的应用。

  3. 管道通信
    Java提供的PipedInputStream和PipedOutputStream(或PipedReader和PipedWriter)实现了线程之间的数据流传递,特别适用于简单的信息交换场景。案例中两个线程通过管道完成字符流的读写操作,展现了管道通信的直观效果。

  4. join方法
    Thread.join() 方法允许一个线程等待另一个线程终止后再继续执行,保证了线程间的执行顺序。示例代码展示了主线程等待子线程计算完成后才继续执行的操作。

  5. sleep方法
    Thread.sleep() 使当前线程暂停指定的时间,但它并不释放锁,主要用于简单地延时线程执行。虽然在本讨论中未给出具体示例,但其作用在于控制线程执行节奏。

  6. ThreadLocal类
    ThreadLocal提供了线程局部变量功能,每个线程拥有独立的副本,解决了线程间共享变量的隔离问题。实例表明即使多个线程引用同一个ThreadLocal实例,它们各自存储和获取的数据互不影响。

  7. 信号量机制
    volatile关键字确保了变量在不同线程间的可见性,而Semaphore类则是一个更高级的信号量工具,用于管理并发访问资源的线程数量。示例代码模拟了一个停车场车位管理场景,通过Semaphore实现对有限资源的访问控制。

未来随着Java技术的发展和多核CPU普及,多线程通信的重要性日益凸显。了解并掌握以上介绍的各种线程间通信方法,有助于开发者设计出更为高效、稳定且易于维护的并发程序。同时,后续章节将继续深入讲解volatile关键字的内存语义、信号量Semaphore在复杂场景下的运用以及更多基于JDK的线程通信工具类如CountDownLatch、CyclicBarrier等,进一步丰富和完善多线程编程的知识体系。

本文使用
markdown.com.cn
排版

项目代码以上传至码云,项目地址:
https://gitee.com/breezefaith/vue-ts-scaffold

前言

近日心血来潮想做一个开源项目,目标是做一款可以适配多端、功能完备的模板工程,包含后台管理系统和前台系统,开发者基于此项目进行裁剪和扩展来完成自己的功能开发。但前台系统花样繁多,可能是PC端浏览器,可能是微信网页,微信小程序,安卓,鸿蒙……因此笔者只能从个人的需求出发,搭建了原生vue3版、原生微信小程序版、原生React版和uniapp版的前台系统模板。

本文内容主要就是来介绍从零搭建Typescript版Vue3工程的完整过程。

脚手架技术栈简介

vue3

Vue 3 是一个流行的开源JavaScript框架,用于构建用户界面和单页面应用。Vue 3与Vue 2的主要区别包括:

  • 构建:Vue 3使用monorepo架构,更容易管理和维护。
  • API:Vue 3引入了新的组合式API,它提供了更灵活的代码组织方式。
  • 性能:Vue 3提供了更好的性能,包括更快的渲染速度和更小的打包大小。
  • TypeScript:Vue 3提供了更好的TypeScript支持。

Vue3官方文档:
https://cn.vuejs.org/

TypeScript

TypeScript是由 Microsoft 开发并维护的开源编程语言,它是在 JavaScript的基础上增加了静态类型、类和接口等特性,从而将其提升为一个功能更为丰富的开发平台。TypeScript 通过编译器将 TypeScript 代码转换为 JavaScript 代码,确保这些代码能够在支持 JavaScript 的各种设备和平台上运行。这种设计允许开发者利用 TypeScript 的静态类型检查以及类、接口和其他 OOP 功能来构建大型、复杂的 Web 应用,同时享受 JavaScript 本身的高灵活性和易于使用的特点。

Typescript官网文档:
https://www.typescriptlang.org/

Pinia

Pinia 是 Vue 的存储库,它允许您跨组件/页面共享状态。 如果您熟悉 Composition API,您可能会认为您已经可以通过一个简单的 export const state = reactive({}). 这对于单页应用程序来说是正确的,但如果它是服务器端呈现的,会使您的应用程序暴露于安全漏洞。 但即使在小型单页应用程序中,您也可以从使用 Pinia 中获得很多好处:

  • dev-tools 支持
    • 跟踪动作、突变的时间线
    • Store 出现在使用它们的组件中
    • time travel 和 更容易的调试
  • 热模块更换
    • 在不重新加载页面的情况下修改您的 Store
    • 在开发时保持任何现有状态
  • 插件:使用插件扩展 Pinia 功能
  • 为 JS 用户提供适当的 TypeScript 支持或 autocompletion
  • 服务器端渲染支持

Pinia 目前也已经是 vue 官方正式的状态库。适用于 vue2 和 vue3。可以简单的理解成 Pinia 就是 Vuex5。也就是说, Vue3 项目,建议使用Pinia。

Pinia官方文档:
https://pinia.web3doc.top/

Tailwind CSS

Tailwind是一个原子类方式命名的CSS工具集。Tailwind CSS 是一个功能类优先的 CSS 框架,它集成了诸如 flex, pt-4, text-center 和 rotate-90 这样的的类,它们能直接在脚本标记语言中组合起来,构建出任何设计。它将所有的css属性全部封装成语义化的类,比如你想要一个float:left,它已经帮你封装好了,你直接使用一个float-left就可以。需要一个宽度为12像素,只需要写w-3就可以。

使用tailwindcss极大的减少代码量,能轻松完成响应式设计,不需要再写单独的css,也不需要再为class取个什么名字而苦恼。

tailwindcss官方文档:
https://tailwindcss.com/

Element Plus

Element Plus是一个基于Vue.js 3.0的UI库,是Element UI的升级版。它提供了一套漂亮、易于使用和自定义的组件,如按钮、输入框、表格、弹窗、日期选择器等。Element Plus的设计理念注重用户体验和响应式设计,所有组件都可以自适应不同屏幕大小以提供良好的用户体验。

Element Plus具有更好的性能和更好的API设计,遵循更好的Reactivity和Function API,并且使用了更符合Vue.js用户习惯的Composition API。相对于Element UI,Element Plus使用Vue.js 3.0,废弃了依赖和拦截器,优化了性能和API设计,更新了主题和组件样式,并且去掉了一些过时的组件,增加了一些更实用和流行的组件(如Slider),支持多语言和自定义主题。

Element Plus是一个功能强大、易于使用和定制的Vue.js UI库,提供了许多实用的组件和功能,并且可以按照自己的需求进行配置和扩展。它适用于各种类型的Web应用程序、移动应用程序和桌面应用程序。

Element Plus官方文档:
https://element-plus.gitee.io/zh-CN/

vite

Vite是Vue的作者尤雨溪开发的Web开发构建工具,它是一个基于浏览器原生ES模块导入的开发服务器,在开发环境下,利用浏览器去解析import,在服务器端按需编译返回,完全跳过了打包这个概念,服务器随启随用。同时不仅对Vue文件提供了支持,还支持热更新,而且热更新的速度不会随着模块增多而变慢。Vue3默认使用Vite作为项目脚手架工具。

Vite官方文档:
https://vitejs.cn/

详细步骤

Node.js安装

Vite需要 Node.js 版本 14.18+,16或更高版本。

Tailwind CSS 需要 Node.js 12.13.0 或更高版本。

可使用
node -v
命令查看当前node版本,如果不符合要求请先升级Nodejs。

创建以 typescript 开发的vue3工程

npm create vue@latest
或
yarn create vue@latest
或
pnpm create vue@latest

创建过程中需要选择项目要支持的特性,笔者选择使用TypeScript、启用JSX、引入Vue-Router和Pinia。

image

项目创建成功后执行以下命令安装npm依赖。

npm install --registry=https://registry.npmmirror.com 
或
yarn install
或
pnpm install

依赖安装完成后,执行以下命令可运行代码。

npm run dev
或
yarn dev
或
pnpm run dev

集成Pinia

安装pinia

如果项目创建过程中已选择了
pinia
特性则可跳过该步骤,如果没有,则需要手动安装
pinia
并创建自定义Store。

npm install --registry=https://registry.npmmirror.com pinia@2.0.33
或
yarn add pinia@2.0.33
或
pnpm install pinia@2.0.33

修改main.ts

将src/main.ts修改为以下内容:

import './assets/main.css'

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')

创建一个store

// stores/counter.ts
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'

export const useCounterStore = defineStore('counter', () => {
  const count = ref(0)
  const doubleCount = computed(() => count.value * 2)
  function increment() {
    count.value++
  }

  return { count, doubleCount, increment }
})

在组件中使用store

<script setup lang="ts">
import TheWelcome from '../components/TheWelcome.vue'
import { useCounterStore } from '@/stores/counter'

const counter = useCounterStore();
counter.count++
// 自动补全! 
counter.$patch({ count: counter.count + 1 })
// 或使用 action 代替
counter.increment()
</script>

<template>
  <main>
    <!-- 直接从 store 中访问 state -->
    <div>Current Count: {{ counter.count }}</div>
    <TheWelcome />
  </main>
</template>

集成Tailwind CSS

Tailwind CSS 需要 Node.js 12.13.0 或更高版本。对于大多数实际项目,建议将 Tailwind 作为 PostCSS 插件安装,本文使用的也是该方式。

安装postcss、sass、autoprefixer和tailwindcss以及相关依赖

  • Sass 是一款强化 CSS 的辅助工具,它在 CSS 语法的基础上增加了变量 (variables)、嵌套 (nested rules)、混合 (mixins)、导入 (inline imports) 等高级功能,这些拓展令 CSS 更加强大与优雅。使用 Sass 以及 Sass 的样式库(如 Compass)有助于更好地组织管理样式文件,以及更高效地开发项目。
  • autoprefixer是一款自动管理浏览器前缀的插件,它可以解析CSS文件并且添加浏览器前缀到CSS内容里,使用Can I Use(caniuse网站)的数据来决定哪些前缀是需要的。把autoprefixe添加到资源构建工具(例如Grunt)后,可以完全忘记有关CSS前缀的东西,只需按照最新的W3C规范来正常书写CSS即可。如果项目需要支持旧版浏览器,可修改browsers参数设置 。

执行以下命令安装依赖:

npm install --registry=https://registry.npmmirror.com --save-dev autoprefixer postcss postcss-comment postcss-html postcss-import postcss-scss sass sass-loader tailwindcss 
或
yarn add --save-dev autoprefixer postcss postcss-comment postcss-html postcss-import postcss-scss sass sass-loader tailwindcss 
或
pnpm install --save-dev autoprefixer postcss postcss-comment postcss-html postcss-import postcss-scss sass sass-loader tailwindcss 

创建配置文件postcss.config.js和tailwind.config.js

创建配置文件

使用命令行可以自动创建postcss.config.js和tailwind.config.js配置文件,也可以手动创建。

npx tailwindcss init -p
tailwind.config.js
/** @type {import('tailwindcss').Config} */
module.exports = {
  darkMode: "class",
  corePlugins: {
    preflight: false
  },
  content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"],
  theme: {
    extend: {
      colors: {
      }
    }
  }
};

postcss.config.js

export default {
  plugins: {
    tailwindcss: {},
    autoprefixer: {},
  },
}

创建并引入tailwind.css

创建tailwind.css

在src目录下创建styles目录,在styles目录下创建tailwind.css。

image

tailwind.css文件内容如下:

@tailwind base;
@tailwind components;
@tailwind utilities;
main.ts中引入tailwind.css

配置完成后需要引入tailwindcss,修改src/main.ts内容如下:

import '@/styles/tailwindcss.css';
import './assets/main.css'

import { createApp } from 'vue'
import { createPinia } from 'pinia'

import App from './App.vue'
import router from './router'

const app = createApp(App)

app.use(createPinia())
app.use(router)

app.mount('#app')

在组件中使用tailwindcss

<template>
  <main>
    <!-- 直接从 store 中访问 state -->
    <div class="w-full h-[100px] bg-[red] flex justify-center items-center">
      Hello Tailwind CSS
    </div>
    <TheWelcome />
  </main>
</template>

以上代码定义了一个宽度100%,高度100px,背景是红色,使用flex布局,垂直方向和水平方向内容都居中的区域,区域中有一个文本元素,显示Hello Tailwind CSS。

Element Plus

本文使用的是Element Plus按需自动引入的方式,此方式可以使编译产物体积更小,运行速度更快。如果需要实现完整导入,请参阅Element Plus官方文档。

安装Element Plus

npm install --registry=https://registry.npmmirror.com element-plus --save
或
yarn add element-plus --save
或
pnpm install element-plus --save

修改tsconfig.json

在 tsconfig.json 中通过 compilerOptions.type 指定全局组件类型,这样可以配合Volar插件实现代码提示功能。

{
  "files": [],
  "references": [
    {
      "path": "./tsconfig.node.json"
    },
    {
      "path": "./tsconfig.app.json"
    }
  ],
  "compilerOptions": {
    "types": [
      "element-plus/global"
    ]
  }
}

安装Element Plus自动导入工具

npm install --registry=https://registry.npmmirror.com -D unplugin-vue-components unplugin-auto-import
或
yarn add -D unplugin-vue-components unplugin-auto-import
或
pnpm install -D unplugin-vue-components unplugin-auto-import

修改vite.config.js

import { fileURLToPath, URL } from 'node:url'

import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [
    vue(),
    vueJsx(),
    AutoImport({
      resolvers: [ElementPlusResolver()],
    }),
    Components({
      resolvers: [ElementPlusResolver()],
    }),
  ],
  resolve: {
    alias: {
      '@': fileURLToPath(new URL('./src', import.meta.url))
    }
  }
})

使用Element Plus组件

<template>
  <main>
    <!-- 直接从 store 中访问 state -->
    <div class="w-full h-[100px] bg-[red] flex justify-center items-center">
      Hello Tailwind CSS
    </div>
    <div>
      <el-button type="primary">Element Plus按钮</el-button>
    </div>
    <div>Current Count: {{ counter.count }}</div>
    <TheWelcome />
  </main>
</template>

以上代码就是添加了一个Element Plus组件库的按钮。

至此,环境搭建完成,运行效果如下:

image

四、总结

在本文中介绍了从零搭建Typescript版Vue3工程的完整过程,如有错误,还望批评指正。

在后续实践中我也是及时更新自己的学习心得和经验总结,希望与诸位看官一起进步。

项目代码以上传至码云,项目地址:
https://gitee.com/breezefaith/vue-ts-scaffold