开发者社区> morixinguan> 正文
阿里云
为了无法计算的价值
打开APP
阿里云APP内打开

recovery的一些开发点滴

简介: Android的recovery是我在公司做的最多的,应该也是我在Android中了解的较为深入的一个部分。recovery这部分其实Android本身都已经提供了很完善的一套机制,但是因为公司是做机顶盒的,所以在因为平台订制的关系,recovery这部分还是做了很多修改的。
+关注继续查看

Android的recovery是我在公司做的最多的,应该也是我在Android中了解的较为深入的一个部分。recovery这部分其实Android本身都已经提供了很完善的一套机制,但是因为公司是做机顶盒的,所以在因为平台订制的关系,recovery这部分还是做了很多修改的。 
首先,修改的比较少的是OTT这种盒子,本次也主要讲这种,其实这种和手机区别不大。而类似将DVB 中的loader和Android的recovery整合到一起这种,确实比较不一样。例如在我们公司,整个升级的签名校验订制以及升级所用到的update.zip包中的烧录进程就都是自己一套的。 
但是万变不离其中,其实了解了就发现其实也都是那样。

所以简单讲下recovery的相关知识:

如何进入recovery

标准Android的recovery进入方式一般是这几种 
”在设置中点击恢复出厂设置“ 
”开机按组合件进入recovery“,例如 home + power(这种是手机的) 
”系统检测到固件更新,下载后要求你重启,这时重启会先进入recovery“ 
其实这几种在实现上是差不多: 
1、首先,我们要明白,在Android中,其实是由两个系统存在的,recovery,其实就是一个小系统,专门用来刷机的。 
2、具体是要进入哪个系统,这个是又fastboot来决定的(因为你基本可以认为他就是开机的第一个程序),他决定进那个就哪个。当然,这个也是由我们来告诉fastboot的。 
3、怎么告诉呢?有两种方式。 
第一种,就是按组合键,(就相当于,我们告诉fastboot,开机检测到有人按了这个组合件就是进recovery,别进安卓。这个当然就可以定制了,例如在机顶盒,我们通常是改成按遥控器,如”连续按上中下键“)。 
第二种,就是Android告诉fastboot下次启动进recovery,然后Android自己再重启。这个就关系到了另一个分区,叫MISC(你基本可以认为他就是存储recovery命令的)。因为fastboot启动会去读这个MISC分区中的内容,来决定自己进哪个系统。系统固件升级和恢复出厂都属于这种,就是recovery命令有点不一样。

recovery流程

首先要知道一点,recovery系统是一个类似Linux的变种,使用了Android init的那一套,但是不会进去虚拟机。所以init.rc和property那些Android的东西对于recovery还是一样的。

流程如下: 
1、一般来说,在init.reovery.rc中,就可以看到启动了/sbin/recovery,这就启动了recovery来作为一个service 
2、recovery进程简介:

这里写图片描述 
1. load_volume_table();这个函数从”/etc/recovery.fstab”读取分区信息 
2. get_arg():主要就是获取recovery的命令、参数等等,这样recovery进程才知道自己要做什么,升级包在哪,这些都以一定的结构体保存在MISC分区中,我们成为bootloader_message,也就是下面会说的BCB。

①get_bootloader_message():主要工作是根据分区的文件格式类型(mtd或emmc)从MISC分区中读取BCB数据块到一个临时的变量中。(get和set bootloader_message: 
1、/从”/misc”读取分区设置
2、/mtd类型只读取或者修改MISC_COMMAND_PAGE一页
3、/emmc类型直接对一个device进行读写操作/)

②然后开始判断Recovery服务是否有带命令行的参数(/sbin/recovery,根据现有的逻辑是没有的),若没有就从BCB中读取 recovery域。如果读取失败则从/cache/recovery/command中读取然后写入BCB临时变量。这样这个BCB的临时变量中的recovery域就被更新了。在将这个BCB的临时变量写回真实的BCB之前,又更新的这个BCB临时变量的command域为“boot-recovery”。这样做的目的是如果在升级失败(比如升级还未结束就断电了)时,系统在重启之后还会进入Recovery模式,直到升级完成。

③在这个BCB临时变量的各个域都更新完成后使用set_bootloader_message()写回到真正的BCB块中。这个过程可以用一个简单的图来概括,这样更清晰: 
这里写图片描述 
(get_arg()这个函数中,主要是获取参数,重写recovery命令到BCB。但是,有时从command_file,有时从BCB读取。 
看如何从上层进入recovery,从上层重启进入recovery的话,会将recovery命令写入到BCB,将升级包目录写进command_file。 
也就是说,command_file是不会有recovery标识的。)

// --> write the arguments we have back into the bootloader control block
// always boot into recovery after this (until finish_recovery() is called)
 strlcpy(boot.command, "boot-recovery", sizeof(boot.command));
strlcpy(boot.recovery, "recovery\n", sizeof(boot.recovery));
  • 1
  • 2
  • 3
  • 4
  • 1
  • 2
  • 3
  • 4

(所以说,从BCB读出,在写回,主要就是修改这两句话。这样子,就能保证进入升级。要注意的是,进入升级模式,是在fastboot的过程选择的,而这里是为了保证升级过程中若中断了,下次还是进recovery。 
第二种是如果BCB读取失败 还可以从command file中去读取。)

接下来就是判断从上面流程获取的recovery命令及参数了 
3. if(update_package):判断update_package是否有值,若有就表示需要升级更新包,此时就会调用 install_package()。在这一步中将要完成安装实际的升级包。这是最为复杂,也是升级update.zip包最为核心的部分。(这种就是所谓的固件升级) 
4. if(wipe_data/wipe_cache):这一步判断实际是两步,在源码中是先判断是否擦除data分区(用户数据部分)的,然后再判断是否擦除cache分区。值得注意的是在擦除data分区的时候必须连带擦除cache分区。在只擦除cache分区的情形下可以不擦除data分区。(这种就所谓的恢复出厂设置)

  1. finish_recovery():这是Recovery关闭并进入Main System的必经之路。其大体流程如下: 
    这里写图片描述 
    ① 将intent(字符串)的内容作为参数传进finish_recovery中。如果有intent需要告知Main System,则将其写入/cache/recovery/intent中。这个intent的作用尚不知有何用。 
    ② 将内存文件系统中的Recovery服务的日志(/tmp/recovery.log)拷贝到cache(/cache/recovery/log)分区中,以便告知重启后的Main System发生过什么。 
    ③ 擦除MISC分区中的BCB数据块的内容,以便系统重启后不在进入Recovery模式而是进入更新后的主系统。 
    ④ 删除/cache/recovery/command文件。这一步也是很重要的,因为重启后Bootloader会自动检索这个文件,如果未删除的话又会进入Recovery模式。原理在上面已经讲的很清楚了。

install_package()

上面已经说过,这个基本是整个recovery最复杂的也是最核心的部分,就是他完成刷机(固件升级)。详细说下: 
这里写图片描述

①ensure_path_mount():先判断所传的update.zip包路径所在的分区是否已经挂载。如果没有则先挂载。 
②load_keys():加载公钥源文件,路径位于/res/keys。(下面讲) 
③verify_file():对升级包update.zip包进行签名验证。(下面讲) 
④mzOpenZipArchive():打开升级包,并将相关的信息拷贝到一个临时的ZipArchinve变量中。这一步并未对我们的update.zip包解压。 
⑤try_update_binary():在这个函数中才是对我们的update.zip升级的地方。这个函数一开始先根据我们上一步获得的zip包信息,以及升级包的绝对路径将 update_binary文件拷贝到内存文件系统的/tmp/update_binary中。以便后面使用。 
⑥pipe():创建管道,用于下面的子进程和父进程之间的通信。父进子出。 
⑦fork():创建子进程。其中的子进程主要负责执行binary(execv(binary,args),即执行我们的安装命令脚本),父进程负责接受子进程发送的命令去更新ui显示(显示当前的进度)。子父进程间通信依靠管道。 
⑧其中,在创建子进程后,父进程有两个作用。 
一是通过管道接受子进程发送的命令来更新UI显示。 
二是等待子进程退出并返回INSTALL SUCCESS。 
其中子进程在解析执行安装脚本execv(binary,args)的作用就是去执行binary程序,这个程序的实质就是去解析update.zip包中的 updater-script脚本中的命令并执行。由此,Recovery服务就进入了实际安装update.zip包的过程。

实际上,上面已经说完了主要流程,其实也比较简单,所以接下来做一点细节的补充:

细节补充

Install_package()中load_keys和verify_file 
/返回key和key的个数,key的位置在 “/res/keys”
1.RSAPublicKey* loadedKeys = load_keys(PUBLIC_KEYS_FILE, &numKeys); 
key的结构如下: 
*{key->len,key->n0inv,{key->n[i]},{key->rr[i]}} 
*或者v2 {key->len,key->n0inv,{key->n[i]},{key->rr[i]}} 
*example”{64,0xc926ad21,{1795090719,…,-695002876},{-857949815,…,1175080310}}” 
“v2 {64,0xc926ad21,{1795090719,…,-695002876},{-857949815,…,1175080310}}” 
Key的版本不同的话,幂分别是3和65537

/*对zip包数据进行校验。对zip包的签名部分进行摘要计算(sha),再利用key对摘要 
2.err = verify_file(path, loadedKeys, numKeys);

Zip包结构 
1:主要数据,已经经过签名 
2:end-of-central-directory 包括comment_size + EOCD_HEADER_SIZE 
其中(eocd[0] = 0x50 eocd[1] = 0x4b eocd[2] = 0x05 eocd[3] = 0x06)(用于指纹校验) 
RSA块:经秘钥加密,可用于签名校验。 
3:footer:(2-byte signature start) ffff (2-byte comment size) 
其中comment_size = footer[4] + (footer[5] << 8); 
eocd_size = comment_size + EOCD_HEADER_SIZE; 
signature_start = footer[0] + (footer[1] << 8);signature_start - FOOTER_SIZE这个大小用来存放RSA block.(这算一步小校验) 
signed_len:except for the comment length field (2 bytes) and the comment data.

利用上面几个固定的字节对应的固定的值,可以进行指纹校验,这是第一步和第二步的校验 
第三步校验504b 0506 若出现在正确的位置后面的话,则”EOCD marker occurs after start of EOCD 
第四步校验就是对/zip包的前部分,SHA_update(&ctx, buffer, size);摘要计算
过程SHA_init(&ctx); 
SHA_update(&ctx, buffer, size);//一次会处理4096字节 
const uint8_t* sha1 = SHA_final(&ctx); 
得到摘要结果 
/利用公钥 对摘要进行校验,上一步得到的
RSA_verify()

Zip包的后面是RSA区和6个字节的脚信息,RSA区是明文用私钥加密后的数据,机顶盒中会有一个公钥。先对前面的升级数据进行SHA1,然后用公钥对RSA区数据进行解密。 
解密后的数据的前半部进行 pkcs1.5 padding bytes.校验。 
解密后的数据的后半部和SHA1后的数据进行比较,完成校验。(20个字节)

updater-script脚本部分函数说明 
升级脚本文件updater-script的内容可根据自己需要进行修改。对脚本中的部分函数进行简要说明:

z ui_print(char *str) 
功能:打印信息。 
参数:str指针指向要打印的信息地址。

z show_progress (char *sec,char *total) 
功能:显示进度条。 
参数: 
− sec:多少秒更新一次进度条,一般为1。 
− total:升级所耗时间(根据升级包大小来确定)。

z format(char fs_type, char *partition_type,char *location,char fs_size, char *mount_point) 
功能:格式化分区。 
参数: 
− fs_type:文件系统类型(“ubifs”or“ext4”,“raw”)。 
¾ NAND Flash器件:裸分区:raw;文件系统分区:ubifs。 
¾ eMMC器件:裸分区:不支持;文件系统分区:ext4。 
− partition_type:器件类型(“MTD”or“EMMC”)。 
− location:分区名或者分区对应的设备节点。 
¾ NAND Flash器件,分区名:system。 
¾ eMMC器件分区,对应的设备节点: /dev/block/platform/hi_mci.1/byname/syste。 
− fs_size:0表示擦除整个分区。 
− mount_point:分区挂载点。

z package_extract_file(char *package_path, char *destination_path) 
功能:从zip包中提取单个文件。 
参数: 
− package_path:解压的文件。 
− destination_path:解压到的目标路径。

z write_raw_image (char *file, char *partition) 
功能:将单个文件写入分区。 
参数: 
− file:欲写入的文件。 
− partition:欲写入的分区。

z mount(char *fs_type, char *partition_type, char *location, char *mount_point) 
功能:挂载特定分区到某目录下 
参数: 
− fs_type:文件系统类型(“ubifs”or “ext4”)。 
− partition_type:器件类型(“MTD”or “EMMC”)。 
− location:分区名字或者分区对应的设备节点。 
¾ NAND Flash器件,分区名:system 
¾ eMMC器件分区,对应的设备节点: 
/dev/block/platform/hi_mci.1/by-name/system 
− mount_point:挂载点。

z unmount(char *mount_point) 
功能:卸载分区。 
参数: 
− mount_point:分区挂载点。

z package_extract_dir(char *package_path, char *destination_path) 
功能:直接提取一文件夹并直接解压到相应目录。 
参数: 
− package_path:zip压缩包里面要提取的文件夹名。 
− destination_path:解压到的目录。

z symlink(char *name, char *argv[]) 
功能:将argv* 指向的内容全部链接到name文件。 
参数: 
− name:想要链接到的文件名。 
− argv:想要链接的文件。

z set_perm_recursive (int uid, int gid, int dir_mode, int file_mode,char *path) 
功能:修改目录权限及目录内文件的权限。 
参数: 
− uid:用户id。 
− gid:组 id。 
− dir_mode:目录权限。 
− file_mode:目录内文件权限。 
− path:目录路径。

z partchange(char *partition_type, char *new_partition) 
功能:依据传入的分区信息,在内核中建立新的分区 
参数: 
− partition_type:器件类型(“MTD”or “EMMC”) 
− new_partiton:分区信息 
− EMMC器件:关键字是:dev/block/mmcblk0 
− MTD器件:关键字是:hinand 
partchange函数不支持spi器件,支持Nand Flash,eMMC器件

z setmisc(char *partition_type, char *location) 
功能:写misc标记位 
− partition_type:器件类型(“MTD”or“EMMC”) 
− location:分区名字或者分区对应的设备节点。 
¾ NAND Flash器件,分区名:misc 
¾ eMMC器件分区,对应的设备节点: 
/dev/block/platform/hi_mci.1/by-name/misc

增量升级及升级包的制作

很多时候,我们要升级的固件和上一个版本差的只是一两个APK或者是多了一些库文件,这个时候,如果我们再升级这个system分区,即升级整个system.img就做了很多务必要的工作,而且耗费的流量太大。 
从上面的升级脚本看到,其实完全是可以将某个文件\目录按照指定的属性添加到指定的目录下的,同时也可以删除掉某个指定的文件\目录。 
这个就是增量升级。 
本来,在Android源码中 
./build/tools/releasetools/ota_from_target_files -n -i <旧包> <新包> <差分包名> 是可以制作OTA增量升级包的,但是一般,不会这么干,因为这种做法太蠢了。 
那怎么做呢,从上面的一大堆话中,其实可以知道升级就是按照按照升级脚本来的。 
所以,升级包(rom包)制作方式: 
1、改一个自己需要的升级脚本,可以试增量升级,也可以是整个镜像升级。(当然脚本还是放在哪个目录下,然后update-binary也得支持这些脚本命令才行) 
2、然后把要的东西(APK,库,镜像)和升级脚本打包成一个update.zip,在用源码中的key给这个升级包进行签名,然后就做成一个可以用的升级包了。(当然了,手机刷机常用的rom包,其实也是一样的,不过这个时候就是升级整个system.img,或者根据需要再升级某些指定的分区。) 
怎么签名: 
Java -jar out/host/linux-x86/framework/signapk.jar -w build/target/product/security/testkey.x509.pem build/target/product/security/testkey.pk8 ~/export/update_signed.zip ~/export/updatesigned.zip


之前讲过Android中recovery的基本知识。 
在工作中,需要做的经常是对标准recovery做一些定制化,所以这篇文章,记录下这段时间的一些心得:

1、增量升级:

在源码根目录下自行make otapackage 会生成升级包,两次编译后包用下面工具: 
./build/tools/releasetools/ota_from_target_files -n -i <旧包> <新包> <差分包名> ,可以制作增量升级包。这里必须用中间生成的包才行。 
改进方法: 
前面说的可以自己写一个简单的Linux脚本,把修改后的升级脚本和文件进行打包签名,这样可以比源码中直接make otapackage效率要高一些,同时也更灵活。 
有时你这个版本只是多了一两个APK,就可以在脚本用mount挂在system分区->package_extract_file直接将APK解压到制定的目录。

2、添加一些脚本命令:

例如,现在这个版本是想要减掉上一个版本的一个system/app/下的一个APK,如果我们可以自己给脚本解析器增加一个delete的命令。 
例如,增加设置命令的接口来给fastboot发送命令,让recovery系统去告诉fastboot去完成一些只能在fastboot中完成的工作。遇到两次需要这样做:(1)当时,有一个工作是要求在recovery中增加重新划分分区的功能,因为这个工作只能在fastboot中完成,所以我就是这样做的。 
(2)还有一次是恢复出厂设置后,有些fastboot中的env需要重新设置,才能算是真正的恢复出厂,就也是让recovery去告诉fastboot重新设置下env。

3、修改升级时的画面:

这个基本每次都要做的,如果只是使用安卓原本的recovery来做的话,就只要去源码下bootable/recovery/res/images把图片换换,然后修改下位置和一些简单的细节就可以了。 
不过了解下它的实现也好:

Recovery UI 在recovery源代码recovery.cpp中main有
    Device* device = make_device();       
    ui = device->GetUI();
    gCurrentUI = ui;

    ui->Init();
    ui->SetLocale(locale);
    ui->SetBackground(RecoveryUI::NONE);
if(show_text) ui->ShowText(true);
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9

(1)首先新建了一个Device类的对象, Device类封装了一些操作,包括UI的操作 
(2)调用Device类的GetUI()返回一个DefaultUI对象,recovery中涉及到三个UI类,三个类之间为继承关系,分别为DefaultUI、 ScreenRecoveryUI、RecoveryUI 
(3)调用DefaultUI类的Init(), DefaultUI类没有Init()方法,因此将调用它的父类ScreenRecoveryUI的Init() 
(4)同理,调用ScreenRecoveryUI类的SetLocale()来标识几个比较特别的区域 
(5)同理,调用ScreenRecoveryUI类的SetBackground()设置初始状态的背景图 
(6)显示recovery的主界面,即一个选择菜单 
graphics.c给出一些接口,这些接口会调用Pixelflinger的源代码给出的接口,以下是部分接口。 
Pixelflinger库来进行渲染。 附上minui部分接口的说明,供参考

int gr_init(void);            /* 初始化图形显示,主要是打开设备、分配内存、初始化一些参数 */ 
void gr_exit(void);           /* 注销图形显示,关闭设备并释放内存 */   
int gr_fb_width(void);        /* 获取屏幕的宽度 */ 
int gr_fb_height(void);       /* 获取屏幕的高度 */ 
gr_pixel *gr_fb_data(void);   /* 获取显示数据缓存的地址 */ 
void gr_flip(void);           /* 刷新显示内容 */ 
void gr_fb_blank(bool blank); /* 清屏 */ 
void gr_color(unsignedcharr, unsignedcharg, unsignedcharb, unsignedchara); /* 设置颜色 */ 
void gr_fill(intx,inty,intw,inth); /* 填充矩形区域,参数分别代表起始坐标、矩形区域大小 */ 
int gr_text(intx,inty,constchar*s); /* 显示字符串 */ 
int gr_measure(constchar*s);            /* 获取字符串在默认字库中占用的像素长度 */ 
void gr_font_size(int*x,int*y);        /* 获取当前字库一个字符所占的长宽 */  
void gr_blit(gr_surface source,intsx,intsy,intw,inth,intdx,intdy); /* 填充由source指定的图片 */ 
unsigned int gr_get_width(gr_surface surface);  /* 获取图片宽度 */ 
Unsigned int gr_get_height(gr_surface surface); /* 获取图片高度 */ 
/* 根据图片创建显示资源数据,name为图片在mk文件指定的相对路径 */ 
int res_create_surface(constchar* name, gr_surface* pSurface); 
void res_free_surface(gr_surface surface);      /* 释放资源数据 */ 
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18

screen_ui.cpp给出了设置的流程,跟踪这个代码可以知道显示的方法,要注意的是,显示文字的界面必须在showtext为TRUE的时候才会显示,所以用此方法来实现进程界面和选择界面的变换。

4、字体修改

字体比较麻烦吧,我记得我修改字体的时候,觉得很麻烦,不清楚有没有比较好的方法。 
我的方法:先在graphics.c文件中修改字体头文件。然后: 
(1)在recovery/miniui中有制作头文件的源码mkfont.c,在/recovery/font中有字体图片,需用gimp工具得到mkfont.c编译所要的结构体。注意gimp输出.c文件时,全部选项都不要选。 
(2)制作的字体文件.h存在的不足是底色和字体色的问题。修改mkfont.c文件让其相反输出即可。 
(3)在graphics.c文件中的static void gr_init_font(void)//字体函数是对字体的初始化,在这里是判断字体头文件中的字体,根据阈值0x80来选择透明度,源码默认是255,所以无论怎么调色,最后都是黑色 
(4)对颜色的修改就要先修改第3点所述部分,再在int gr_text(int x, int y, const char *s)增加想要的字体颜色即可 ,如:gr_color(255,255,255,252);

5、语言

有时,机器给不同国家,recovery也就要求显示不同语言,那就用这个函数: 
SetLocale, 该函数根据locale判断所用的字体是否属于阿拉伯语系,阿拉伯语的书写习惯是从右到左,如果是阿拉伯语系的话,就设置一个标志,后面根据这个标志决定从右到左显示文字或进度条。 SetLocale的参数locale赋值逻辑是这样的,先从command文件中读取, command文件中设置locale的命令如”–locale=zh_CN“,如果没有传入locale,初始化过程中会尝试从/cache/recovery/last_locale中读取locale, 如果该文件也没有,则locale不会被赋值,就默认用English. 
这个其实也是在setting中设置的,会设置为env保持在fastboot中。(我用的方案是这样,不清楚是不是安卓原本的)

6、recovery备份分区

7、增加备份与备份恢复

8、恢复最初升级包的两种方案

9、硬件信息与软件信息

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

相关文章
react项目实战学习笔记-学习5-知识点回顾5
react项目实战学习笔记-学习5-知识点回顾5
3 0
react项目实战学习笔记-学习3-知识点回顾3
react项目实战学习笔记-学习3-知识点回顾3
5 0
react-router-dom6学习7-页面懒加载优化
react-router-dom6学习7-页面懒加载优化
2 0
机器学习神器Scikit-Learn入门教程
Scikit-learn是一个非常知名的Python机器学习库,它广泛地用于统计分析和机器学习建模等数据科学领域。
39 0
打造类手机刷机的win10 recovery镜像
本文关键字:打造小于4G的win10精简镜像,打造类手机刷机的win10 recovery镜像,打造统一bootloader分区 as pc recovery,非romos
1248 0
React学习笔记
在JSX中使用样式和真实的样式也很类似,通过style属性来定义,但和真实DOM不同的是,属性值不能是字符串而必须为对 象。要明确记住,{}里面是JS代码,这里传进去的是标准的JS对象。
1144 0
Redis源码学习——BIO
## Redis源码学习之BIO BIO顾名思义,background IO,是redis中运行的后台IO。 网上千篇一律的说法是redis是单线程单进程。 实际上redis运行过程中并不是严格单进程单线程应用。 Redis中的多进程: 在写入备份(RDB,AOF)的时候,会fork出子进程进行备份文件的写入。 Redis中的多线程: 1. AOF的
6151 0
Recommender System
Data Sciece from Scratch 之 Recommender System 有一段时间没有看这本书了,今天正好空闲时间将在线视频关于推荐系统学习一下,正好也将这本书关于推荐系统做一个笔记,希望对大家有用。
962 0
+关注
morixinguan
ITGEGE在线教育嵌入式开发讲师。 CSDN博客专家、CSDN-Linux特邀编辑、CSDN博乐、CSDN学院讲师,目前从事嵌入式开发领域,从事与单片机,Linux,android相关的产品开发。
499
文章
1
问答
文章排行榜
最热
最新
相关电子书
更多
低代码开发师(初级)实战教程
立即下载
阿里巴巴DevOps 最佳实践手册
立即下载
冬季实战营第三期:MySQL数据库进阶实战
立即下载