Follow and pinBaijun Technology
Don’t miss any valuable content
Author: Zhan Rongkai
Original text:
https://www.ibm.com/developerworks/cn/linux/l-btloader/index.html
1. Introduction
Running a GNU/Linux system on dedicated embedded boards has become increasingly popular. An embedded Linux system can typically be divided into four layers from a software perspective:
-
Boot loader. This includes boot code (optional) embedded in firmware and the Boot Loader itself.
-
Linux kernel. A customized kernel specific to the embedded board and the kernel’s boot parameters.
-
File system. This includes the root file system and file systems built on Flash memory devices. Typically, a ram disk is used as the root filesystem.
-
User applications. Applications specific to the user. Sometimes, an embedded graphical user interface may also be included between the user applications and the kernel layer. Common embedded GUIs include MicroWindows and MiniGUI.
The boot loader is the first piece of software code that runs after the system is powered on. Recall that in the architecture of a PC, the boot loader consists of the BIOS (essentially a firmware program) and the OS Boot Loader located in the hard disk MBR (such as LILO and GRUB).
After the BIOS completes hardware detection and resource allocation, it reads the Boot Loader from the hard disk MBR into the system’s RAM and then hands over control to the OS Boot Loader. The main task of the Boot Loader is to read the kernel image from the hard disk into RAM and then jump to the kernel’s entry point to run, thus starting the operating system.
In embedded systems, there is typically no firmware program like the BIOS (note that some embedded CPUs may have a small built-in boot program), so the entire system loading and startup task is completed entirely by the Boot Loader. For example, in an embedded system based on the ARM7TDMI core, the system usually begins execution at address 0x00000000 when powered on or reset, where the Boot Loader program is typically located.
This article will discuss the Boot Loader of embedded systems from four aspects: the concept of Boot Loader, the main tasks of the Boot Loader, the framework structure of the Boot Loader, and the installation of the Boot Loader.
2. Concept of Boot Loader
In simple terms, the Boot Loader is a small program that runs before the operating system kernel runs. Through this small program, we can initialize hardware devices, establish a memory mapping, thereby bringing the system’s software and hardware environment to an appropriate state, to prepare the correct environment for the final invocation of the operating system kernel.
Typically, the Boot Loader is heavily dependent on hardware implementation, especially in the embedded world. Therefore, establishing a universal Boot Loader in the embedded world is nearly impossible. Nevertheless, we can still summarize some general concepts about the Boot Loader to guide user-specific Boot Loader design and implementation.
-
Supported CPUs and Embedded Boards by the Boot Loader Each different CPU architecture has a different Boot Loader. Some Boot Loaders support multiple CPU architectures; for example, U-Boot supports both ARM and MIPS architectures.
In addition to being dependent on the CPU architecture, the Boot Loader also relies on the specific configuration of the embedded board-level devices. This means that for two different embedded boards, even if they are built on the same CPU, to make the Boot Loader program running on one board also run on another board, it usually requires modifications to the Boot Loader’s source code.
-
Installation Medium of the Boot Loader After powering on or resetting, all CPUs typically fetch instructions from an address pre-arranged by the CPU manufacturer. For example, CPUs based on the ARM7TDMI core usually fetch their first instruction from address 0x00000000 upon reset.
Embedded systems built on CPUs typically have some type of solid-state storage device (such as ROM, EEPROM, or FLASH) mapped to this pre-arranged address. Therefore, after powering on, the CPU will first execute the Boot Loader program.
Below is a typical memory allocation structure diagram of a solid-state storage device that contains a Boot Loader, kernel boot parameters, kernel image, and root filesystem image.
Figure 1 Typical memory allocation structure of solid-state storage device
3. Devices or Mechanisms Used to Control the Boot Loader
The host and target machine generally establish a connection via a serial port, and the Boot Loader software usually performs I/O through the serial port during execution, such as outputting print information to the serial port, reading user control characters from the serial port, etc.
4. Is the Boot Loader Startup Process Single Stage or Multi-Stage?
Typically, multi-stage Boot Loaders can provide more complex functions and better portability. Most Boot Loaders that boot from solid-state storage devices are two-stage startup processes, meaning the startup process can be divided into stage 1 and stage 2. The specific tasks completed in stage 1 and stage 2 will be discussed below.
5. Operation Modes of the Boot Loader
Most Boot Loaders include two different operation modes: “Boot loading” mode and “Downloading” mode. This distinction is only meaningful for developers. However, from the end user’s perspective, the role of the Boot Loader is to load the operating system, and there is no distinction between so-called boot loading mode and downloading work mode.
Boot loading mode: This mode is also known as “Autonomous” mode. In this mode, the Boot Loader loads the operating system from a solid-state storage device on the target machine into RAM to run, and the entire process does not involve user intervention.
This mode is the normal operating mode of the Boot Loader; therefore, when embedded products are released, the Boot Loader must clearly operate in this mode.
Downloading mode: In this mode, the Boot Loader on the target machine downloads files from the host via communication means such as serial connection or network connection, such as downloading kernel images and root filesystem images. Files downloaded from the host are typically first stored in the target machine’s RAM by the Boot Loader and then written to the target machine’s FLASH solid-state storage device.
This mode of the Boot Loader is typically used when initially installing the kernel and root filesystem; additionally, subsequent system updates will also use this work mode of the Boot Loader. Boot Loaders operating in this mode usually provide a simple command-line interface to their end users.
Powerful Boot Loaders like Blob or U-Boot often support both work modes and allow users to switch between them. For example, Blob starts in normal boot loading mode but will delay for 10 seconds, waiting for the end user to press any key to switch Blob to downloading mode. If no user key is pressed within 10 seconds, Blob continues to boot the Linux kernel.
6. Communication Devices and Protocols Used for File Transfer Between Boot Loader and Host
The most common scenario is that the Boot Loader on the target machine transfers files via a serial port to the host, and the transmission protocol is usually one of the xmodem/ymodem/zmodem protocols. However, since the speed of serial transmission is limited, it is a better choice to download files via an Ethernet connection using the TFTP protocol.
Additionally, when discussing this topic, the software used on the host side must also be considered. For example, when downloading files via an Ethernet connection and TFTP protocol, the host must have software to provide TFTP services.
After discussing the above concepts of the Boot Loader, let’s specifically look at what tasks the Boot Loader should accomplish.
3. Main Tasks and Typical Structural Framework of the Boot Loader
Before continuing the discussion in this section, let’s make an assumption that the kernel image and root filesystem image have been loaded into RAM to run. The reason for this assumption is that, in embedded systems, kernel images and root filesystem images can also run directly in solid-state storage devices like ROM or Flash. However, this approach undoubtedly sacrifices running speed.
From the operating system’s perspective, the Boot Loader’s ultimate goal is to correctly invoke the kernel for execution.
Additionally, since the implementation of the Boot Loader relies on the CPU architecture, most Boot Loaders are divided into stage 1 and stage 2. Code that depends on the CPU architecture, such as device initialization code, is usually placed in stage 1 and is typically implemented in assembly language to achieve a compact form.
Stage 2 is usually implemented in C language, allowing for more complex functions and better code readability and portability.
The Boot Loader’s stage 1 typically includes the following steps (in execution order):
-
Hardware device initialization.
-
Preparing RAM space for loading stage 2 of the Boot Loader.
-
Copying stage 2 of the Boot Loader to RAM space.
-
Setting up the stack.
-
Jumping to the C entry point of stage 2.
The Boot Loader’s stage 2 typically includes the following steps (in execution order):
-
Initializing the hardware devices to be used in this stage.
-
Detecting the system memory mapping.
-
Reading the kernel image and root filesystem image from flash into RAM.
-
Setting boot parameters for the kernel.
-
Invoking the kernel.
3.1 Stage 1 of the Boot Loader
3.1.1 Basic Hardware Initialization
This is the operation that the Boot Loader executes first, aimed at preparing a basic hardware environment for the execution of stage 2 and the subsequent kernel execution. It typically includes the following steps (in execution order):
1. Mask all interrupts. Providing service for interrupts is usually the responsibility of OS device drivers; therefore, there is no need to respond to any interrupts during the entire execution process of the Boot Loader. Interrupt masking can be accomplished by writing to the CPU’s interrupt mask register or status register (such as ARM’s CPSR register).
2. Set the CPU speed and clock frequency.
3. RAM initialization. This includes correctly setting the functional registers of the system’s memory controller and various memory library control registers.
4. Initialize LED. Typically, drive the LED via GPIO to indicate whether the system status is OK or Error. If there is no LED on the board, this can also be accomplished by initializing the UART to print the Boot Loader’s logo character information to the serial port.
5. Disable the CPU’s internal instruction/data cache.
3.1.2 Preparing RAM Space for Loading Stage 2
To achieve faster execution speed, stage 2 is typically loaded into RAM space for execution, so a usable RAM space range must be prepared for loading stage 2 of the Boot Loader.
Since stage 2 is usually C language executable code, when considering space size, in addition to the size of the stage 2 executable image, stack space must also be taken into account. Additionally, the space size is best a multiple of the memory page size (typically 4KB).
Generally, 1M of RAM space is sufficient. The specific address range can be arranged arbitrarily; for example, Blob arranges its stage 2 executable image to execute in a 1M space starting from the system RAM starting address 0xc0200000. However, arranging stage 2 at the topmost 1MB of the entire RAM space (i.e., (RamEnd-1MB) – RamEnd) is a recommended method.
For convenience in later discussions, let’s denote the size of the allocated RAM space as: stage2_size (bytes), and denote the starting and ending addresses as: stage2_start and stage2_end (both addresses are aligned to a 4-byte boundary). Therefore:
stage2_end = stage2_start + stage2_size
Additionally, it must be ensured that the allocated address range is indeed readable and writable RAM space; thus, the allocated address range must be tested. A specific testing method can be similar to that of Blob, which tests each memory page starting two words for readability and writability. For convenience in later discussions, we denote this detection algorithm as: test_mempage, with the specific steps as follows:
1. First, save the content of the first two words of the memory page.
2. Write arbitrary numbers into these two words. For example, write 0x55 into the first word, and 0xaa into the second word.
3. Then, immediately read back the content of these two words. Obviously, the contents we read should be 0x55 and 0xaa. If not, it indicates that this memory page does not occupy a valid RAM space.
4. Then, write arbitrary numbers into these two words again. For example, write 0xaa into the first word, and 0x55 into the second word.
5. Then, immediately read back the content of these two words. Obviously, the contents we read should be 0xaa and 0x55. If not, it indicates that this memory page does not occupy a valid RAM space.
6. Restore the original content of these two words. The test is complete.
To obtain a clean RAM space range, we can also zero out the allocated RAM space range.
3.1.3 Copying Stage 2 to RAM
When copying, two points must be determined: (1) the starting and ending addresses where the stage 2 executable image is stored in solid-state storage devices; (2) the starting address of the RAM space.
3.1.4 Setting the Stack Pointer sp
Setting the stack pointer is to prepare for the execution of C language code. Typically, we can set the value of sp to (stage2_end-4), that is, at the top of the 1MB RAM space allocated in section 3.1.2 (the stack grows downwards).
Additionally, before setting the stack pointer sp, we can also turn off the LED to indicate to the user that we are ready to jump to stage 2.
After the above execution steps, the system’s physical memory layout should look like the following diagram:
3.1.5 Jumping to the C Entry Point of Stage 2
Once everything is ready, we can jump to the Boot Loader’s stage 2 for execution. For example, in ARM systems, this can be achieved by modifying the PC register to the appropriate address.
Figure 2 System memory layout when stage 2 executable image has just been copied to RAM space
3.2 Stage 2 of the Boot Loader
As mentioned earlier, the code of stage 2 is usually implemented in C language to facilitate more complex functions and achieve better code readability and portability. However, unlike ordinary C language applications, we cannot use any support functions from the glibc library when compiling and linking programs like the Boot Loader.
The reason is obvious. This brings us to a question: where do we jump into the main() function? Directly using the starting address of the main() function as the entry point for the entire stage 2 executable image might be the most straightforward idea.
However, this approach has two drawbacks:
1) Unable to pass function parameters through the main() function;
2) Unable to handle the return situation of the main() function.
A more clever method is to utilize the concept of trampoline. That is, write a small trampoline program in assembly language and use this trampoline program as the execution entry point of the stage 2 executable image. Then we can use CPU jump instructions in the trampoline assembly program to jump into the main() function for execution; when the main() function returns, the CPU execution path clearly returns to our trampoline program.
In short, the idea of this method is to use this trampoline program as an external wrapper for the main() function.
Below is a simple example of a trampoline program (from blob):
.text
.globl _trampoline
_trampoline:
bl main
/* if main ever returns we just call it again */
b _trampoline
As can be seen, when the main() function returns, we use a jump instruction to re-execute the trampoline program—of course, this also re-executes the main() function, which is the meaning of the term trampoline.
3.2.1 Initializing the Hardware Devices to be Used in This Stage
This typically includes:
(1) Initializing at least one serial port to communicate with the end user for I/O output information;
(2) Initializing timers, etc.
Before initializing these devices, we can also turn the LED back on to indicate that we have entered the main() function execution.
After device initialization is complete, some print information can be output, such as program name string, version number, etc.
3.2.2 Detecting the System Memory Mapping
The so-called memory mapping refers to which address ranges in the entire 4GB physical address space are allocated to address the system’s RAM units. For example, in the SA-1100 CPU, the address space starting from 0xC0000000 is used as the system’s RAM address space, while in the Samsung S3C44B0X CPU, the address space from 0x0c000000 to 0x10000000 is used as the system’s RAM address space.
Although CPUs usually reserve a large enough address space for system RAM, when building specific embedded systems, it may not implement all the RAM address space reserved by the CPU.
This means that specific embedded systems often only map part of the total RAM address space reserved by the CPU to RAM units, leaving the remaining part of the reserved RAM address space unused.
Due to this fact, the stage 2 of the Boot Loader must detect the entire system’s memory mapping situation before it can do anything (such as reading the kernel image stored in flash into RAM), meaning it must know which of the total RAM address space reserved by the CPU is actually mapped to RAM address units and which is in an “unused” state.
(1) Description of Memory Mapping
A continuous address range in the RAM address space can be described using the following data structure:
typedef struct memory_area_struct {
u32 start; /* the base address of the memory region */
u32 size; /* the byte number of the memory region */
int used;
} memory_area_t;
This continuous address range in the RAM address space can be in one of two states:
(1) used=1, indicating that this continuous address range has been implemented, meaning it is actually mapped to RAM units.
(2) used=0, indicating that this continuous address range has not been implemented by the system and is in an unused state.
Based on the above memory_area_t data structure, the entire RAM address space reserved by the CPU can be represented by an array of type memory_area_t, as follows:
memory_area_t memory_map[NUM_MEM_AREAS] = {
[0 ... (NUM_MEM_AREAS - 1)] = {
.start = 0,
.size = 0,
.used = 0
},
};
(2) Memory Mapping Detection
Below is a simple and effective algorithm that can be used to detect the entire RAM address space memory mapping situation:
/* Array initialization */
for(i = 0; i < NUM_MEM_AREAS; i++)
memory_map[i].used = 0;
/* first write a 0 to all memory locations */
for(addr = MEM_START; addr < MEM_END; addr += PAGE_SIZE)
* (u32 *)addr = 0;
for(i = 0, addr = MEM_START; addr < MEM_END; addr += PAGE_SIZE) {
/*
* Detect whether the address space starting from base address MEM_START+i*PAGE_SIZE, with size PAGE_SIZE,
* is a valid RAM address space.
*/
Call the algorithm test_mempage() in section 3.1.2;
if ( current memory page isnot a valid ram page) {
/* no RAM here */
if(memory_map[i].used )
i++;
continue;
}
/*
* The current page has already been a valid address range mapped to RAM,
* but we also need to check whether the current page is just an alias of some address page in the 4GB address space?
*/
if(* (u32 *)addr != 0) { /* alias? */
/* This memory page is an alias of some address page in the 4GB address space */
if ( memory_map[i].used )
i++;
continue;
}
/*
* The current page has already been a valid address range mapped to RAM,
* and it is not an alias of some address page in the 4GB address space.
*/
if (memory_map[i].used == 0) {
memory_map[i].start = addr;
memory_map[i].size = PAGE_SIZE;
memory_map[i].used = 1;
} else {
memory_map[i].size += PAGE_SIZE;
}
} /* end of for (…) */
After using the above algorithm to detect the system’s memory mapping situation, the Boot Loader can also print the detailed information about the memory mapping to the serial port.
3.2.3 Loading Kernel Image and Root Filesystem Image
(1) Planning Memory Occupation Layout
This includes two aspects:
(1) The memory range occupied by the kernel image;
(2) The memory range occupied by the root filesystem image.
When planning memory occupation layout, the base address and image size are the two main considerations.
For the kernel image, it is generally copied to a memory range starting from (MEM_START+0x8000) with a size of about 1MB (embedded Linux kernels generally do not exceed 1MB).
Why should we leave this 32KB memory space from MEM_START to MEM_START+0x8000? This is because the Linux kernel needs to place some global data structures in this memory, such as boot parameters and kernel page tables.
For the root filesystem image, it is generally copied to the place starting from MEM_START+0x00100000. If using Ramdisk as the root filesystem image, its decompressed size is usually 1MB.
(2) Copying from Flash
Since embedded CPUs like ARM typically address Flash and other solid-state storage devices in a unified memory address space, reading data from Flash is not different from reading data from RAM units. A simple loop can complete the work of copying images from Flash devices:
while(count) {
*dest++ = *src++; /* they are all aligned with word boundary */
count -= 4; /* byte number */
};
3.2.4 Setting Kernel Boot Parameters
It can be said that after copying the kernel image and root filesystem image into RAM space, the Linux kernel can be prepared for startup. However, before invoking the kernel, one preparatory step should be taken: setting the boot parameters for the Linux kernel.
Linux kernels after 2.4.x expect boot parameters to be passed in the form of a tagged list (tagged list). The boot parameters tagged list starts with the ATAG_CORE tag and ends with the ATAG_NONE tag. Each tag consists of a tag_header structure identifying the parameter being passed and the subsequent parameter value data structure.
The data structures tag and tag_header are defined in the include/asm/setup.h header file of the Linux kernel source code:
/* The list ends with an ATAG_NONE node. */
#define ATAG_NONE 0x00000000
struct tag_header {
u32 size; /* Note that size is in units of words */
u32 tag;
};
……
struct tag {
struct tag_header hdr;
union {
struct tag_core core;
struct tag_mem32 mem;
struct tag_videotext videotext;
struct tag_ramdisk ramdisk;
struct tag_initrd initrd;
struct tag_serialnr serialnr;
struct tag_revision revision;
struct tag_videolfb videolfb;
struct tag_cmdline cmdline;
/*
* Acorn specific
*/
struct tag_acorn acorn;
/*
* DC21285 specific
*/
struct tag_memclk memclk;
} u;
};
In embedded Linux systems, common boot parameters that usually need to be set by the Boot Loader include: ATAG_CORE, ATAG_MEM, ATAG_CMDLINE, ATAG_RAMDISK, ATAG_INITRD, etc.
For example, the code to set ATAG_CORE is as follows:
params = (struct tag *)BOOT_PARAMS;
params->hdr.tag = ATAG_CORE;
params->hdr.size = tag_size(tag_core);
params->u.core.flags = 0;
params->u.core.pagesize = 0;
params->u.core.rootdev = 0;
params = tag_next(params);
Here, BOOT_PARAMS indicates the starting base address of the kernel boot parameters in memory, and the pointer params is a pointer of type struct tag. The macro tag_next() calculates the starting address of the next tag adjacent to the current tag, taking the pointer pointing to the current tag as its parameter. Note that the device ID of the kernel’s root filesystem is set here.
Below is an example code for setting the memory mapping situation:
for(i = 0; i < NUM_MEM_AREAS; i++) {
if(memory_map[i].used) {
params->hdr.tag = ATAG_MEM;
params->hdr.size = tag_size(tag_mem32);
params->u.mem.start = memory_map[i].start;
params->u.mem.size = memory_map[i].size;
params = tag_next(params);
}
}
As can be seen, each valid memory segment in the memory_map[] array corresponds to an ATAG_MEM parameter tag.
The Linux kernel can receive information in the form of command line parameters during startup, utilizing this capability we can provide hardware parameter information that the kernel cannot detect itself or override (override) the information detected by the kernel.
For example, we can use a command line parameter string “console=ttyS0,115200n8” to notify the kernel to use ttyS0 as the console, with settings of “115200bps, no parity, 8 data bits”. Below is a snippet of code for setting the kernel command line parameter string:
char *p;
/* eat leading white space */
for(p = commandline; *p == ' '; p++)
;
/* skip non-existent command lines so the kernel will still
* use its default command line.
*/
if(*p == '\0')
return;
params->hdr.tag = ATAG_CMDLINE;
params->hdr.size = (sizeof(struct tag_header) + strlen(p) + 1 + 4) >>> 2;
strcpy(params->u.cmdline.cmdline, p);
params = tag_next(params);
Note that in the above code, when setting the size of the tag_header, it must include the string’s terminator ‘\0’, and it should also round the byte number up to 4 bytes, because the size member in the tag_header structure is measured in words.
Below is an example code for setting ATAG_INITRD, which tells the kernel where to find the initrd image (compressed format) in RAM and its size:
params->hdr.tag = ATAG_INITRD2;
params->hdr.size = tag_size(tag_initrd);
params->u.initrd.start = RAMDISK_RAM_BASE;
params->u.initrd.size = INITRD_LEN;
params = tag_next(params);
Below is an example code for setting ATAG_RAMDISK, which tells the kernel how large the decompressed Ramdisk is (in KB):
params->hdr.tag = ATAG_RAMDISK;
params->hdr.size = tag_size(tag_ramdisk);
params->u.ramdisk.start = 0;
params->u.ramdisk.size = RAMDISK_SIZE; /* Note that the unit is KB */
params->u.ramdisk.flags = 1; /* automatically load ramdisk */
params = tag_next(params);
Finally, set the ATAG_NONE tag to end the entire boot parameter list:
static void setup_end_tag(void)
{
params->hdr.tag = ATAG_NONE;
params->hdr.size = 0;
}
3.2.5 Invoking the Kernel
The Boot Loader invokes the Linux kernel by directly jumping to the first instruction of the kernel, that is, directly jumping to the address MEM_START+0x8000. When jumping, the following conditions must be met:
1. Setting of CPU registers: R0=0;
R1=Machine Type ID; for Machine Type Number, refer to linux/arch/arm/tools/mach-types.
R2=The starting base address of the boot parameter tagged list in RAM;
2. CPU Mode: Interrupts (IRQs and FIQs) must be disabled; CPU must be in SVC mode;
3. Cache and MMU settings: MMU must be turned off; Instruction cache can be on or off; data cache must be off;
If using C language, invoking the kernel can look like the following example code:
void (*theKernel)(int zero, int arch, u32 params_addr) =
(void (*)(int, int, u32))KERNEL_RAM_BASE;
……
theKernel(0, ARCH_NUMBER, (u32) kernel_params_start);
Note that the theKernel() function call should never return. If this call returns, it indicates an error.
4. About Serial Terminal
In the design and implementation of the boot loader program, nothing is more exciting than correctly receiving print information from the serial terminal. Additionally, printing information to the serial terminal is also a very important and effective debugging method. However, we often encounter issues where the serial terminal displays garbled characters or does not display anything at all.
There are mainly two reasons for this issue:
(1) Incorrect initialization settings of the serial port by the boot loader.
(2) Incorrect settings of the serial port by the terminal emulation program running on the host side, including settings for baud rate, parity, data bits, and stop bits.
Additionally, sometimes we may encounter this issue: during the operation of the boot loader, we can correctly output information to the serial terminal, but after the boot loader starts the kernel, we cannot see the kernel’s startup output information. The reasons for this issue can be considered from the following aspects:
(1) First, please confirm that your kernel has been configured to support the serial terminal during compilation and that the correct serial port driver has been configured.
(2) The initialization settings of the serial port by your boot loader may be inconsistent with the initialization settings of the serial port by the kernel. Additionally, for CPUs like s3c44b0x, the setting of the CPU clock frequency may also affect the serial port. Therefore, if the boot loader and kernel have inconsistent settings for their CPU clock frequencies, it may also prevent the serial terminal from displaying information correctly.
(3) Finally, also confirm that the kernel base address used by the boot loader must be consistent with the running base address used by the kernel image during compilation; especially for uClinux. Assuming your kernel image was compiled with a base address of 0xc0008000, but your boot loader loads it to 0xc0010000, then the kernel image cannot execute correctly.
5. Conclusion
The design and implementation of the Boot Loader is a very complex process. If you cannot receive that exciting “uncompressing linux……………… done, booting the kernel……” kernel startup information from the serial port, I doubt anyone can say, “Hey, my boot loader has successfully turned on!”.
-End-
Previous article: The Role of Common Macros in the Linux Kernel
By the way, Wei Dongshan’s device tree video course is on hot sale, and students have evaluated it as the most refined device tree special tutorial, with 4 free sections available. If you’re interested, click:The entire device tree video recording is completed, with a total of 6 lessons and 29 sections, adding quantity without increasing price. Now it’s still 69 yuan, and it may increase in price later. It doesn’t participate in the Double Eleven price war. Learn early and benefit early.
You can also save the image below and open the mobile Taobao to enter:
How to get the selected valuable articles from this public account?
Just reply “Directory” in the dialog box of this public account, and I will send it to you.
Join the community:
The official WeChat group of Wei Dongshan is open for students to communicate. Add the administrator’s WeChat (13266630429, verify: join group) to join. Limited spots are available on a first-come, first-served basis.