SPL ARMv7源代码分析
了解u-boot spl代码流程.1.u-boot spl 来源 现在很多处理器都内置一个BOOT ROM,执行部分初始化,并可从各种外设和存储器中加载程序并执行,BOOT ROM中固化的程序被称为一级程序加载器,被它加载的程序就称为二级程序加载器(secondary program loader,即SPL)。其实u-boot本身就可以作为二级程序加载器,但不幸的是一般BOOT ROM之...
- 熟悉SPL.
1.SPL 起源
现在很多处理器都内置一个BOOT ROM,执行部分初始化,可以从各种外设和存储器中加载程序并执行,BOOT ROM中固化的程序被称为一级程序加载器,被它加载的程序就称为二级程序加载器(secondary program loader,即SPL)。其实u-boot本身就可以作为二级程序加载器,但不幸的是一般BOOT ROM之后,主存储器都是没有初始化的,例如BeagleBoneBlack只有109K的内置RAM可用,这就限制了程序的大小,全功能的u-boot不能运行在这么小的RAM上运行。于是精简的u-boot,u-boot-spl就此问世。
SPL是uboot第一阶段执行的代码. 主要负责搬移uboot第二阶段的代码到内存中运行. SPL是由固化在芯片内部的ROM引导的. 我们知道很多芯片厂商固化的ROM支持从nandflash, SDCARD等外部介质启动. 所谓启动, 就是从这些外部介质中搬移一段固定大小(4K/8K/16K等)的代码到内部RAM中运行. 这里搬移的就是SPL. 在最新版本的uboot中, 可以看到SPL也支持nandflash, SDCARD等多种启动方式. 当SPL本身被搬移到内部RAM中运行时, 它会从nandflash, SDCARD等外部介质中搬移uboot第二阶段的代码到外部内存中.
2.SPL Framework( doc/README.SPL)
To unify all existing implementations for a secondary program loader (SPL) and to allow simply adding of new implementations this generic SPL framework has been created. With this framework almost all source files for a board can be reused. No code duplication or symlinking is necessary anymore.
The object files for SPL are built separately and placed in the “spl” directory.The final binaries which are generated are u-boot-spl, u-boot-spl.bin and u-boot-spl.map.
A config option named CONFIG_SPL_BUILD is enabled by Kconfig for SPL. Source files can therefore be compiled for SPL with different settings.
To support generic U-Boot libraries and drivers in the SPL binary one can optionally define CONFIG_SPL_XXX_SUPPORT.
2.1.开启 SPL 功能
The building of SPL images can be enabled by CONFIG_SPL option in Kconfig.
CONFIG_SPL = y
2.2.SPL 任务
CPU初始刚上电的状态。需要小心的设置好很多状态,包括cpu状态、中断状态、MMU状态等等。 在armv7架构的SPL,主要负责的事情有:
- 关闭中断fiq、irq,设置SVC32 mode
- 禁用MMU、TLB
- 芯片级、板级的一些初始化操作
- IO初始化
- 时钟
- 内存
- 选项,串口初始化
- 选项,nand flash初始化
- 其他额外的操作
- 加载u-boot,跳转到u-boot
3.SPL 代码分析
3.1.SPL 入口函数
Because SPL images normally have a different text base, one has to be configured by defining CONFIG_SPL_TEXT_BASE. The linker script has to be defined with CONFIG_SPL_LDSCRIPT.
SPL的入口是由链接脚本决定的,uboot armv7链接脚本默认目录为arch/arm/cpu/u-boot-spl.lds。用户可以在配置文件中通过CONFIG_SYS_LDSCRIPT来指定自己的链接脚本。详见uboot编译流程。
include/configs/am335x_evm.h:
#define CONFIG_SYS_LDSCRIPT "board/ti/am335x/u-boot.lds"
入口地址也是由链接器决定的,在配置文件中由CONFIG_SPL_TEXT_BASE指定,在编译时指定为-Ttext [TEXT_BASE]。 在u-boot-spl.lds中ENTRY(_start),规定了代码的入口函数是_start。
arch/arm/cpu/u-boot-spl.lds:
OUTPUT_FORMAT("elf32-littlearm", "elf32-littlearm", "elf32-littlearm")
OUTPUT_ARCH(arm)
ENTRY(_start)
SECTIONS
{
. = 0x00000000;
. = ALIGN(4);
.text :
{
__image_copy_start = .;
*(.vectors)
CPUDIR/start.o (.text*)
*(.text*)
*(.glue*)
}
. = ALIGN(4);
.rodata : { *(SORT_BY_ALIGNMENT(SORT_BY_NAME(.rodata*))) }
. = ALIGN(4);
.data : {
*(.data*)
}
. = ALIGN(4);
.u_boot_list : {
KEEP(*(SORT(.u_boot_list*)));
}
. = ALIGN(4);
.binman_sym_table : {
__binman_sym_start = .;
KEEP(*(SORT(.binman_sym*)));
__binman_sym_end = .;
}
. = ALIGN(4);
__image_copy_end = .;
.rel.dyn : {
__rel_dyn_start = .;
*(.rel*)
__rel_dyn_end = .;
}
.end :
{
*(.__end)
}
_image_binary_end = .;
.bss __rel_dyn_start (OVERLAY) : {
__bss_start = .;
*(.bss*)
. = ALIGN(4);
__bss_end = .;
}
__bss_size = __bss_end - __bss_start;
.dynsym _image_binary_end : { *(.dynsym) }
.dynbss : { *(.dynbss) }
.dynstr : { *(.dynstr*) }
.dynamic : { *(.dynamic*) }
.hash : { *(.hash*) }
.plt : { *(.plt*) }
.interp : { *(.interp*) }
.gnu : { *(.gnu*) }
.ARM.exidx : { *(.ARM.exidx*) }
}
#if defined(IMAGE_MAX_SIZE)
ASSERT(__image_copy_end - __image_copy_start <= (IMAGE_MAX_SIZE), \
"SPL image too big");
#endif
#if defined(CONFIG_SPL_BSS_MAX_SIZE)
ASSERT(__bss_end - __bss_start <= (CONFIG_SPL_BSS_MAX_SIZE), \
"SPL image BSS too big");
#endif
#if defined(CONFIG_SPL_MAX_FOOTPRINT)
ASSERT(__bss_end - _start <= (CONFIG_SPL_MAX_FOOTPRINT), \
"SPL image plus BSS too big");
#endif
__start汇编实现在arch/arm/lib/vectors.S,.global声明_start为全局符号,_start就会被链接器链接到,也就是链接脚本中的入口地址了。
.globl _start
_start:
...
b reset
...
reset汇编代码实现arch/arm/cpu/armv7/start.S,主要工作:
- disable FIQ and IRQ
- set the cpu to SVC32 mode
- 初始化cp15协处理器,其主要目的就是关闭其MMU和TLB(bl cpu_init_cp15)
- 平台级和板级的初始化 (bl cpu_init_crit)
代码如下:
save_boot_params_ret:
...
/*
* disable interrupts (FIQ and IRQ), also set the cpu to SVC32 mode,
* except if in HYP mode already
*/
mrs r0, cpsr
and r1, r0, #0x1f @ mask mode bits
teq r1, #0x1a @ test for HYP mode
bicne r0, r0, #0x1f @ clear all mode bits
orrne r0, r0, #0x13 @ set SVC mode
orr r0, r0, #0xc0 @ disable FIQ and IRQ
msr cpsr,r0
/*
* Setup vector:
* (OMAP4 spl TEXT_BASE is not 32 byte aligned.
* Continue to use ROM code vector only in OMAP4 spl)
*/
#if !(defined(CONFIG_OMAP44XX) && defined(CONFIG_SPL_BUILD))
/****************************************************************
设置异常向量的基地址,正常异常模式下异常向量的基地址为0x00000000,高异常模式下
异常向量的基地址为0xffff0000,这里V=0设置成正常异常模式
*****************************************************************/
/* Set V=0 in CP15 SCTRL register - for VBAR to point to vector */
mrc p15, 0, r0, c1, c0, 0 @ Read CP15 SCTRL Register
bic r0, #CR_V @ V = 0
mcr p15, 0, r0, c1, c0, 0 @ Write CP15 SCTRL Register
/*********************************************
重新设置异常向量的基地址,只有上面V=0的情况下,这里才能去重新
配置异常向量表的基地址!
**********************************************/
/* Set vector address in CP15 VBAR register */
ldr r0, =_start
mcr p15, 0, r0, c12, c0, 0 @Set VBAR
#endif
/* the mask ROM code should have PLL and others stable */
#ifndef CONFIG_SKIP_LOWLEVEL_INIT
bl cpu_init_cp15 //初始化协处理器
bl cpu_init_crit //初始化内存和锁相环
#endif
bl _main //调用c代码
为什么要设置SVC模式?
ARM7体系的cpu有七种工作模式:用户模式(USR),系统模式(SYS),管理模式(SVC),快中断模式(FIQ),中断模式(IRQ),中止模式(ABT),未定义模式(UND) 。除了用户模式之外的其他6种处理器模式称为特权模式。 特权模式下,程序可以访问所有的系统资源(除了特定模式下的影子寄存器),也可以任意地进行处理器模式的切换。
特权模式中,除系统模式外,其他5种模式又称为异常模式。
- 用户模式下访问的资源受限,故不能使用用户模式
- 系统模式的优先级低于异常模式,故不使用系统模式
- 快中断模式、中断模式、中止模式、未定义模式用于特殊场景下由CPU自动切入,故不使用, 所以需要使用SVC模式。
如何设置SVC模式?
cpsr为当前状态寄存器,它的格式如下所示:
cpsr是32位寄存器:
- 寄存器[31:28]位为状态标志位
- [27:8]位保留未被使用
- [7:0]位为控制位
-bit 7/bit 6 :设置是否使能中断请求和快速中断请求 - bit 5:设置CPU操作状态,当设置为1处理器执行在Thumb状态,为0时执行在ARM状态
- bit[0-4] :决定CPU的工作模式
- [7:0]位为控制位
模式位具体说明如下图所示:
为什么要关闭所有中断?
在启动过程中,中断环境并没有完全准备好,也就是中断向量表和中断处理函数并没有完成设置,一旦有中断产生,可能会导致预想不到的问题,或者是程序跑飞。因此,在准备好中断环境之前,需要关闭所有中断。
Setup vector
第一步:设置cp15 SCTLR Register bit<13>为0,即设置异常向量的基地址为0x00000000。
/* Set V=0 in CP15 SCTRL register - for VBAR to point to vector */
mrc p15, 0, r0, c1, c0, 0 @ Read CP15 SCTRL Register
bic r0, #CR_V @ V = 0
mcr p15, 0, r0, c1, c0, 0 @ Write CP15 SCTRL Register
其中: #define CR_V (1 << 13) /* Vectors relocated to 0xffff0000 */
CP15对应指令如下:
SCTLR寄存器如下所示:
第二步: 通过VBAR重新指定异常向量表地址为任意位置(注意:只有当SCTLR Register bit<13>为0 时满足)。
/* Set vector address in CP15 VBAR register */
ldr r0, =_start
mcr p15, 0, r0, c12, c0, 0 @Set VBAR
bl cpu_init_cp15:详细见此
ENTRY(cpu_init_cp15)
/*
* Invalidate L1 I/D
*/
mov r0, #0 @ set up for MCR
mcr p15, 0, r0, c8, c7, 0 @ invalidate TLBs
mcr p15, 0, r0, c7, c5, 0 @ invalidate icache
mcr p15, 0, r0, c7, c5, 6 @ invalidate BP array
mcr p15, 0, r0, c7, c10, 4 @ DSB
mcr p15, 0, r0, c7, c5, 4 @ ISB
/*
* disable MMU stuff and caches
*/
mrc p15, 0, r0, c1, c0, 0
bic r0, r0, #0x00002000 @ clear bits 13 (--V-)
bic r0, r0, #0x00000007 @ clear bits 2:0 (-CAM)
orr r0, r0, #0x00000002 @ set bit 1 (--A-) Align
orr r0, r0, #0x00000800 @ set bit 11 (Z---) BTB
#ifdef CONFIG_SYS_ICACHE_OFF
bic r0, r0, #0x00001000 @ clear bit 12 (I) I-cache
#else
orr r0, r0, #0x00001000 @ set bit 12 (I) I-cache
#endif
mcr p15, 0, r0, c1, c0, 0
/* Early stack for ERRATA that needs into call C code */
#if defined(CONFIG_SPL_BUILD) && defined(CONFIG_SPL_STACK)
ldr r0, =(CONFIG_SPL_STACK)
#else
ldr r0, =(CONFIG_SYS_INIT_SP_ADDR)
#endif
框图流程如下:
问题
1.为什么要关闭MMU?
MMU是用来把虚拟地址转化为物理地址的,现在是要设置控制寄存器,而控制寄存器地址就是物理地址,所以需要关闭MMU。
2.为什么要关闭cache?
cache和MMU是通过CP15进行管理的,刚上电的时候,CPU还不能管理它们,所以上电的时候MMU必须关闭,指令cache可关闭,可不关闭,但数据cache一定要关闭,否则可能导致刚开始的代码里面,去取数据的时候,从cache里面取,而这时候RAM中数据还没有cache过来,导致数据预取异常。
3.栈顶的设置
配置 SPL 的栈顶地址,以rk3399为例:
include/configs/rk3399_common.h:
19 #define CONFIG_SYS_INIT_SP_ADDR 0x00300000
22 #if defined(CONFIG_SPL_BUILD) && defined(CONFIG_TPL_BOOTROM_SUPPORT)
23 #define CONFIG_SPL_STACK 0x00400000
24 #define CONFIG_SPL_MAX_SIZE 0x100000
bl cpu_init_crit:
/*************************************************************************
*
* CPU_init_critical registers
*
* setup important registers
* setup memory timing
*
*************************************************************************/
ENTRY(cpu_init_crit)
/*
* Jump to board specific initialization...
* The Mask ROM will have already initialized
* basic memory. Go here to bump up clock rate and handle
* wake up conditions.
*/
b lowlevel_init @ go setup pll,mux,memory
ENDPROC(cpu_init_crit)
lowlevel_init一般是由板级代码自己实现的。但是对于某些平台来说,也可以使用通用的lowlevel_init,其定义在arch/arm/cpu/lowlevel_init.S中。在移植过程中,需要在板级目录下(board/xxx/xxx)创建lowlevel_init.S 实现lowlevel_init。
在lowlevel_init中,要实现如下:
- 检查一些复位状态
- 关闭看门狗
- 系统时钟初始化
- GIC 初始化
- SDRAM初始化
- 串口初始化
- Nand flash初始化
bl _main
建立c运行环境,并且调用board_init_f 进行先前的板级初始化动作,由于board_init_f是以C语言的方式实现,所以需要先构造C语言环境。
arch/arm/lib/crt0.S:
ENTRY(_main)
...
#因为要进入C 环境,设置堆栈
ldr sp, =(CONFIG_SPL_STACK)
#堆栈是8字节对齐,2^7bit=2^3byte=8byte
bic sp, sp, #7 /* 8-byte alignment for ABI compliance */
mov r0, sp
//调用common/board_f.c的board_init_f()函数,参数为0。
mov r0, #0
bl board_init_f
arch/arm/lib/spl.c:
/*
* In the context of SPL, board_init_f() prepares the hardware for execution
* from system RAM (DRAM, DDR...). As system RAM may not be available yet,
* board_init_f() must use the current GD to store any data which must be
* passed on to later stages. These data include the relocation destination,
* the future stack, and the future GD location. BSS is cleared after this
* function (and therefore must be accessible).
*
* We provide this version by default but mark it as __weak to allow for
* platforms to do this in their own way if needed. Please see the top
* level U-Boot README "Board Initialization Flow" section for info on what
* to put in this function.
*/
void __weak board_init_f(ulong dummy)
{
}
首先了解一下背景:
9 U-Boot traditionally had a board.c file for each architecture. This introduced
10 quite a lot of duplication, with each architecture tending to do
11 initialisation slightly differently. To address this, a new 'generic board
12 init' feature was introduced in March 2013 (further motivation is
13 provided in the cover letter below).
14
15 All boards and architectures have moved to this as of mid 2016.
16
17
18 What has changed?
19 -----------------
20
21 The main change is that the arch/<arch>/lib/board.c file is removed in
22 favour of common/board_f.c (for pre-relocation init) and common/board_r.c
23 (for post-relocation init).
24
25 Related to this, the global_data and bd_t structures now have a core set of
26 fields which are common to all architectures. Architecture-specific fields
27 have been moved to separate structures.
跳转到 bl board_init_f,该函数是一个weak函数,以rk3399为例,重定义该函数,主要实现功能(rk3399-board-spl.c):
- 初始化uart;
- 初始化uboot DM,解析device tree;
- 安全timer的初始化;
- 初始化pinctrl;
- 初始化ddr.
u-boot链接脚本arch/arm/cpu/u-boot.lds,发现执行汇编入口文件也是start.S,所以u-boot也会执行board_init_f函数,不过不是spl执行的board_init_f,具体如下所示:
38 . = 0x00000000;
39
40 . = ALIGN(4);
41 .text :
42 {
43 *(.__image_copy_start)
44 *(.vectors)
45 CPUDIR/start.o (.text*)
46 }
u-boot将需要在board_init_f中初始化的内容,抽象为一系列API。这些API由u-boot声明,由平台的开发者根据实际情况实现。该函数在common/board_f.c文件中定义。对global data进行简单的初始化之后,调用位于init_sequence_f数组中的各种初始化API,进行各式各样的初始化动作。这些API有些需要板级厂商进行实现。以下是对init_sequence_f数组中相关的API进行说明。
837 static const init_fnc_t init_sequence_f[] = {
838 setup_mon_len,
839 #ifdef CONFIG_OF_CONTROL
840 fdtdec_setup,
841 #endif
842 #ifdef CONFIG_TRACE
843 trace_early_init,
844 #endif
845 initf_malloc,
846 log_init,
847 initf_bootstage, /* uses its own timer, so does not need DM */
848 #ifdef CONFIG_BLOBLIST
849 bloblist_init,
850 ...
1.调用setup_mon_len()设置gd->mon_len的值,这个值表示u-boot代码大小.
2.调用fdtdec_setup()设置gd->fdt_blob的值.
如果打开了CONFIG_OF_CONTROL,即使用dts,则调用fdtdec_setup,设置gd->fdt_blob指针(即device tree所在的存储位置)的值。对ARM平台来说,u-boot的Makefile会通过连接脚本,将dtb文件打包到u-boot image的__dtb_dt_begin位置处,因此不需要特别关心.
gd->fdt_blob = (ulong *)&_end;,因此通过u-boot.map文件查找到_end的地址为0x0000000080060570 _end = .,在u-boot的命令行模式读取该段内存数据md 80060570
在fdtdec_prepare_fdt()函数中,会通过gd->fdt_blob指向区域的值来判断是否是device tree.
3.调用initf_malloc()设置gd->malloc_limit分配空间限制为CONFIG_SYS_MALLOC_F_LEN.
4.调用initf_dm()进行u-boot的driver model的初始化,解析fdt的设备并注册与之匹配的驱动.
5.调用env_init()设置gd->env_addr环境变量的地址。env_init在common/env_mmc.c中定义(文件名不一定)。里面用到了个全局数组default_environment[],该数组在include/env_default.h中定义,数组中定义好多环境变量相关的,我们可以通过在u-boot终端敲pirntenv命令打印环境变量。环境变量的值可以在编译u-boot之后查看u-boot.cfg中找到。
6.调用init_baud_rate()设置gd->baudrate波特率,也就是从环境变量中获取baudrate的值。
gd->baudrate = getenv_ulong("baudrate", 10, CONFIG_BAUDRATE);
获取当前使用串口波特率,可以有两个途径(优先级从高到低),从baudrate中获取;从CONFIG_BAUDRATE配置项获取。
7.调用serial_init()和console_init_f()初始化串口相关的设备和驱动。初始化硬件串口,由原厂实现,最终在drivers/serial/文件中实现。
8.调用display_text_info()打印u-boot代码段的起始和结束地址,以及BSS段的起始和结束地址。
9.调用print_cpuinfo()打印CPU的相关信息。
10.调用show_board_info()打印板级的相关信息,在common/board_info.c文件中定义,主要去获取dts中model节点的信息.
11.调用dram_init()初始化系统的DDR,dram_init应该由平台相关的代码实现。如果DDR已经初始化过了,则不需要重新初始化,只需要把DDR信息(DDR大小和初始地址)保存在global data中即可。在我们这里,将DDR的大小信息保存在gd->ram_size中。
DDR初始化之后,u-boot relocate,首先讲讲为什么需要relocate?
在以前的板子上,u-boot有可能是运行在NOR FLASH或ROM上,空间很小,执行慢,而且不支持写操作,DDR SDRAM初始化完毕之后,需要将其relocate到DDR SDRAM去运行,空间大,执行的速度也比较快,支持写操作;考虑到后续的kernel是在DDR SDRAM的低端位置解压缩并执行的,为了避免麻烦,u-boot将使用DRAM的顶端地址,即gd->ram_top所代表的位置。
最后调用board_init_r函数( common/board_r.c ):
和board_init_f相似,也有一个初始化序列init_sequence_r,该序列中包含如下的初始化函数:
-
initr_trace,初始化并使能u-boot的tracing system,涉及的配置项有CONFIG_TRACE。
-
initr_reloc,设置relocation完成的标志。
-
initr_caches,使能dcache、icache等,涉及的配置项有CONFIG_ARM。
-
initr_malloc,malloc有关的初始化。
-
initr_dm,relocate之后,重新初始化DM,涉及的配置项有CONFIG_DM。
-
board_init,具体的板级初始化,需要由board代码根据需要实现,涉及的配置项有CONFIG_ARM。
-
set_cpu_clk_info,Initialize clock framework,涉及的配置项有CONFIG_CLOCKS。
-
initr_serial,重新初始化串口。
-
initr_announce,宣布已经在RAM中执行,会打印relocate后的地址。
-
board_early_init_r,由板级代码实现,涉及的配置项有CONFIG_BOARD_EARLY_INIT_R。
-
arch_early_init_r,由arch代码实现,涉及的配置项有CONFIG_ARCH_EARLY_INIT_R。
-
power_init_board,板级的power init代码,由板级代码实现,例如hold住power。
-
initr_flash、initr_nand、initr_onenand、initr_mmc、initr_dataflash,各种flash设备的初始化。
-
initr_env,环境变量有关的初始化。
-
initr_secondary_cpu,初始化其它的CPU core。
-
stdio_add_devices,各种输入输出设备的初始化,如LCD driver等。
-
interrupt_init,中断有关的初始化。
-
initr_enable_interrupts,使能系统的中断,涉及的配置项有CONFIG_ARM(ARM平台u-boot实在开中断的情况下运行的)。
-
initr_status_led,状态指示LED的初始化,涉及的配置项有CONFIG_STATUS_LED、STATUS_LED_BOOT。
-
initr_ethaddr,Ethernet的初始化,涉及的配置项有CONFIG_CMD_NET。
-
board_late_init,由板级代码实现,涉及的配置项有CONFIG_BOARD_LATE_INIT。
-
run_main_loop/main_loop,执行到main_loop,开始命令行操作。
4.关于DT解析分析:
重点分析dm_init_and_scan:
- dm_init()
○ Creates an empty list of devices and uclasses
○ Binds and probes a root device - dm_scan_platdata()
○ Scans available platform data looking for devices to be created
○ Platform data may only be used when memory constraints prohibit device tree - dm_scan_fdt()
○ Scan device tree and bind drivers to nodes to create devices
该函数会被调用三次:
1.spl
- spl_common_init->dm_init_and_scan(common/spl/spl.c)
2.uboot (在relocate之前的initf_dm和之后的initr_dm都调用)
- initf_dm->dm_init_and_scan(common/board_f.c)
- initr_dm->dm_init_and_scan(common/board_r.c)
static int initf_dm(void)
{
#if defined(CONFIG_DM) && defined(CONFIG_SYS_MALLOC_F_LEN)
int ret;
ret = dm_init_and_scan(true); // 调用dm_init_and_scan对DM进行初始化和设备的解析
if (ret)
return ret;
#endif
return 0;
}
#ifdef CONFIG_DM
static int initr_dm(void)
{
int ret;
/* Save the pre-reloc driver model and start a new one */
gd->dm_root_f = gd->dm_root; // 存储relocate之前的根设备
gd->dm_root = NULL;
ret = dm_init_and_scan(false); // 调用dm_init_and_scan对DM进行初始化和设备的解析
if (ret)
return ret;
return 0;
}
#endif
“u-boot,dm-pre-reloc”属性,当设置该属性时,则表示这个设备在relocate之前就需要使用。 当dm_init_and_scan的参数为true时,只会对带有“u-boot,dm-pre-reloc”属性的节点进行解析。而当参数为false的时候,则会对所有节点都进行解析。
refer to
http://wowothink.com/146db8db/
开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!
更多推荐
所有评论(0)