Lab5 bootloader

这篇教程详细介绍了如何修改David Welch的RPi bootloader,实现串口命令交互,包括加载程序、执行、内存读写及验证功能。教程涵盖了树莓派启动原理、内存结构、裸机代码概念,以及通讯协议的扩展。通过python脚本与板卡串口交互,完成各种命令操作。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

教程目的:

David Welch的GitHub的 bootloader05给出了一个非常简单的RPi bootloader,他的代码链接在内存的0x00020000位置,一直在监听串口是 否有XMODEM协议的文件下载,如果有就开始接收数据,并复制到0x00008000位置,传输完成后跳转到 0x00008000去执行。
TA写了一个Python脚本,按照下面的命令调用脚本可以下载并执行用户程序

python xmodem-loader.py -p com3 -baud 115200 output.bin

你的任务是修改bootloader和python脚本实现如下功能:

调用命令 python xmodem-loader.py -p com3 -baud 115200 启动脚本并且与板卡建立串口连接,之后可以发送下面的命令。
load *.bin 下载程序*.bin
go 执行已下载的程序
peek addr 以一个字为单位读取内存中addr位置的数据(addr是4字节对齐,十六进行的形式,长度为8,例如 0x00008000),并以十六进制的形式输出
poke addr data 以一个字为单位修改内存中addr位置的数据为data(addr是4字节对齐,十六进行的形式,长 度为8, data也是十六进行的形式,长度为8)
verify *.bin 验证已下载的程序和*.bin是否完全相同。

教程器材及软件:

  1. 树莓派的板子。
  2. SD卡(已经有镜像刷入)。
  3. 电源线及USB充电器。
  4. putty和psftp。
  5. 有DHCP的网线。
  6. 串口转USB线。

原理:

此处有必要先来讲讲原理性问题。因为,下面主要就是代码工作,但是十分繁琐,花了我差不多6个小时。

树莓派的启动:

下面是来自David Welch的github中的关于树莓派启动的介绍。

From what we know so far there is a gpu on chip which:
  1. boots off of an on chip rom of some sort
  2. reads the sd card and looks for additional gpu specific boot filesbootcode.bin and start.elf in the root dir of the first partition(fat32 formatted, loader.bin no longer used/required)
  3. in the same dir it looks for config.txt which you can do things likechange the arm speed from the default 700MHz, change the address whereto load kernel.img, and many others
  4. it reads kernel.img the arm boot binary file and copies it to memory
  5. releases reset on the arm such that it runs from the address where the kernel.img data was written
所以,此处的bootloader,其实就是kernel.img。之前,那是linux内核的位置。我们所要做的就是将kernel.img重命名一下。然后,放入我们自己写的bootloader,别忘了将名字改成kernel.img。这样,arm在启动后,就会将这个文件加载到0x8000的地方,然后跳到那里去执行。

树莓派的内存结构图:

我们可以在David Welch的代码中看到很多的通过写入到某一个内存地址,从而达到控制外设的目的。其中他主要用到的是timer和串口的IO操作,不过这个可以不用仔细去关心它到底怎么做的。

裸机代码:

绝大多数人应该都是没有写过裸机代码的。所谓裸机代码,指的是这个时候没有操作系统,你需要直接与硬件打交道。bootloader就算是一种裸机代码。这个时候需要注意一些事情。

  1. 因为是裸机代码,所以print系列和scanf系列的肯定没有了,当然,你可以自己实现。另外,基本上c语言函数库是不能调用了。但是,strlen等不需要操作系统支持的函数库可能是可以用的,只要它里面没有用到其他奇怪的函数(有时候,代码里会内嵌堆栈检查的代码,所以它可能会需要这样的函数。)。但是,我没试过,不知道这里可不可以。
  2. bootloader这个时候还没有开启mmu,所以内存地址是随便读。但是,我没试过读不存在的地址会发生什么事,异常了?
  3. 硬件驱动程序需要自己写,David Welch的代码中就实现了串口和timer的功能,这个东西只要有datasheet和大把看datasheet的时间,那么也是不难完成的。
  4. kernel.img里面到底是什么样的结构。依照我过去的x86的经验,它里面是前面部分是纯代码,后面部分会跟着纯数据。此处纯的意思是相对PE(windows下的exe文件格式)和ELF(linux下的obj文件和可执行文件的格式)文件格式来说的,这两种文件格式都是需要代码来解析它,找出它的入口地址,并且它的代码和数据都是存放在相应的数据段和代码段里面的。而kernel.img里面是从第一个字节开始就是代码,它是vector.s的_start函数的第一条指令。其中,makefile中的objcopy和objdump就是干将bootloader05.elf文件中抠出代码和数据。
  5. 调试困难,因为,你没有什么可以单步调试的工具。print大法几乎就是唯一的办法。

bootloader05的代码树:

.
├── Makefile  //makefile
├── README
├── blinker.bin  //这个待会需要。你通过xmodem协议,将这个文件传输到树莓派上。执行之后,效果是板子上的ACT灯会一闪一闪。
├── blinker.c
├── blinker.elf
├── blinker.hex
├── blinker.list
├── blinker.o
├── bootloader05.c//这个是kernel.img的核心文件,也是完成任务主要修改的文件。
├── bootloader05.elf
├── bootloader05.hex
├── bootloader05.list //kernel.img的反汇编文件。
├── bootloader05.o
├── kernel.img //这个需要拷贝到树莓派的boot目录,替换原来linux内核的kernel.img。(这个boot目录是树莓派中的linux,将SD卡中的FAT32分区,挂载到/boot/的结果。你将SD卡插到电脑中后,这个所谓的boot目录,就是Fat32分区,你会在那里看到一个kernel.img)
├── loader //这个是ld的链接控制文件(好像不是这个名字,类似的吧),使得kernel.img的代码起始地址为0x8000.
├── memmap //这个是ld的链接控制文件,使得blinker.bin的代码起始地址为0x8000.
├── periph.c//这个是串口控制和timer控制的一些代码。
├── periph.o
├── start.o
├── start.s //这个是blinker.bin用的入口代码和其他arm汇编代码。
├── vectors.o
├── vectors.s//这个是kernel.img用的入口代码和其他arm汇编代码。

串口读写:

fm提供的是在linux下的python代码,但是,我不想切换到ubuntu下面,也就用windows干了。读写串口对于windows来说,也是很简单的一件事。这个文档对此有详细的描述,我就不多说什么了。

通讯协议:

我就是定义了一个很简单的通讯协议。原来的通讯协议只能传输文件。我只是加了一个命令类型的字段,使得可以执行其他命令,比如GO,Verify之类的。此处需要注意在树莓派上的接受端,它如何解析。在这个地方,我低估了,这个问题的复杂程度,就直接在他原来的代码上修改了,结果,导致逻辑上太复杂了,不断的出bug。一个建议的解析方法是使用编译原理中讲到的实现DFA方法中的switch嵌套switch的方法。因为,这个东西本质上就是一个状态机嘛。

忠告:

  1. 将整个代码运行的流程,先想明白了再做。可以先将David Welch的代码弄上去试试,再添加自己的代码。这个过程估计不是一次将kernel.img拷贝到树莓派上就能完成的。我是有10+的过程。
  2. 最好进行一个代码和协议的设计。我用了2个小时看David Welch的代码和理清其中逻辑关系,3小时写代码,3小时调试程序(bug不断啊!!!)。结果也只是,勉强能够做出实验的效果。但是,不管从代码结构还是屏幕上的输出来说都是非常混乱的。主要因为,我基本没想过代码怎么写,基本是边写边想。简单的代码是可以这样的,但是,显然我低估了它的复杂度。这样干的另外一个理由是,这就是一个一次性的代码,交完作业就完事了,也不想把它写得很健壮。
  3. 多用print大法将中间结果输出来,否则怎么死的都不知道。最好不要期望,这事是一遍就能过的。准备好几个小时的时间来折腾吧!
  4. 将协议在本地进行测试之后,再和树莓派之间进行测试。

教程步骤:

实在是写不出步骤了,因为太多了。原理就是上面这样。

结果:

最开始的输出:

load命令:

verify命令:

peek addr:

poke address value:

GO命令:

我原来以为它会返回到kernel.img的,最后发现它的效果是ACT LED一闪一闪的效果。

代码:

代码我是主要部分的都贴出来了,但是,写得非常的乱(这几乎可以算是我写的最差的代码,主要指的是DFA部分。),也没太多的参考价值。如果,你想在我这份代码上改出你自己的代码,那么请做好,3h+的调试时间。

//-------------------------------------------------------------------------
//-------------------------------------------------------------------------

// The raspberry pi firmware at the time this was written defaults
// loading at address 0x8000.  Although this bootloader could easily
// load at 0x0000, it loads at 0x8000 so that the same binaries built
// for the SD card work with this bootloader.  Change the ARMBASE
// below to use a different location.

#define ARMBASE 0x8000

extern void PUT32 ( unsigned int, unsigned int );
extern void PUT16 ( unsigned int, unsigned int );
extern void PUT8 ( unsigned int, unsigned int );
extern unsigned int GET8 ( unsigned int );
extern unsigned int GET32 ( unsigned int );
extern unsigned int GETPC ( void );
extern void BRANCHTO ( unsigned int );
extern void dummy ( unsigned int );

extern void uart_init ( void );
extern unsigned int uart_lcr ( void );
extern void uart_flush ( void );
extern void uart_send ( unsigned int );
extern unsigned int uart_recv ( void );
extern void hexstring ( unsigned int );
void SendString(char* str);
extern void hexstrings ( unsigned int );
extern void timer_init ( void );
extern unsigned int timer_tick ( void );

extern void timer_init ( void );
extern unsigned int timer_tick ( void );

#include "xmodem/xmodem.h"
//------------------------------------------------------------------------
unsigned char xstring[256];
//------------------------------------------------------------------------
int notmain ( void )
{
    unsigned int ra;
    //unsigned int rb;
    unsigned int rx;
    unsigned int addr;
    unsigned int block;
    unsigned int state;

    unsigned int crc;

    uart_init();
	uart_flush();
	SendString("Hello World!\n");
    hexstring(0x12345678);
    hexstring(GETPC());
    hexstring(ARMBASE);
	uart_flush();
    timer_init();

//SOH 0x01
//ACK 0x06
//NAK 0x15
//EOT 0x04

//block numbers start with 1

//134 byte packet
//starts with SOH
//Command Type

	//block number byte
	//255-block number
	//128 bytes of data
	//checksum byte (whole packet)

//a single EOT instead of SOH when done, send an ACK on it too

    block=1;
    addr=ARMBASE;
    state=0;
    crc=0;
	int isSame=1;
	int isEnd=0;
    rx=timer_tick();
    while(1)
    {

        ra=timer_tick();
        if((ra-rx)>=4000000)
        {
            uart_send(0x15);
            rx+=4000000;
        }
        if((uart_lcr()&0x01)==0) continue;
        xstring[state]=uart_recv();
        rx=timer_tick();
        if(isEnd)
        {	
			if(xstring[state]==0x04 && state==0)
			{
				if(xstring[1]==CT_LOAD)
				{
					//uart_send(0x06);
					for(ra=0;ra<30;ra++) hexstring(ra);
					hexstring(0x11111111);
					hexstring(0x22222222);
					hexstring(0x33333333);
					SendString("Load Successfully!\n");
					uart_flush();
					//BRANCHTO(ARMBASE);
				}
				else if(xstring[1]==CT_VERIFY)
				{
					if(isSame)
					{
						SendString("Verify same!\n");
					}
					else
					{
						SendString("Verify not same!\n");
					}
					uart_flush();
				}
				isSame = 1;
				isEnd = 0;
				block=1;
				addr=ARMBASE;
				state=0;
				crc=0;
				continue;

			}
            if(xstring[state]==0x04&&state!=0&&(xstring[1]!=CT_LOAD && 
				xstring[1]!=CT_VERIFY))
            {
				if(xstring[1]==CT_GO)
				{
					SendString("Before GO!\n");
					uart_flush();
					BRANCHTO(ARMBASE);
					SendString("After GO!\n");
					uart_flush();
				}
				else if(xstring[1]==CT_PEEK)
				{
					unsigned int address = 0;
					address+=xstring[2];
					address+=xstring[3]<<8;
					address+=xstring[4]<<16;
					address+=xstring[5]<<24;

					hexstrings(GET32(address));
					uart_flush();
				}
				else if(xstring[1]==CT_POKE)
				{
					unsigned int address = 0;
					address+=xstring[2];
					address+=xstring[3]<<8;
					address+=xstring[4]<<16;
					address+=xstring[5]<<24;
					unsigned int value = 0;
					value+=xstring[6];
					value+=xstring[7]<<8;
					value+=xstring[8]<<16;
					value+=xstring[9]<<24;
					PUT32(address,value);
				}
				else
				{
					SendString("Unknow Command!\n");
					uart_flush();
				}

				isSame = 1;
				isEnd = 0;
				block=1;
				addr=ARMBASE;
				state=0;
				crc=0;
				continue;

               // break;
            }
        }
        switch(state)
        {
            case 0:
            {
                if(xstring[state]==0x01)
                {
                   // crc=xstring[state];
                    state++;
                }
                else
                {
                    state=0;
                    uart_send(0x15);
					SendString("Start Miss Match!\n");
                }
                break;
            }
			case 1:
			{
				if(xstring[1]==CT_GO)
				{
					state++;
					isEnd = 1;
				}
				else
				{
					state++;
				}
				break;
			}
            case 2:
            {
                if(xstring[state]==block)
                {
                   // crc+=xstring[state];
                    state++;
                }
				else
				{
					state++;
				}
               // else
                //{
               //     state=0;
                //    uart_send(0x15);
               // }
                break;
            }
            case 3:
            {
                if(xstring[state]==(0xFF-xstring[state-1]))
                {
                   // crc+=xstring[state];
				   crc=0;
                    state++;
                }
               // else
               // {
                //    uart_send(0x15);
                 //   state=0;
              //  }
			  else
			  {
			  crc=0;
			  	state++;
				}
                break;
            }
			case 5:
			{
				if(xstring[1]==CT_PEEK)
				{
					state++;
					isEnd = 1;
				}
				else
				{
                	crc+=xstring[state];
	                state++;
				}

				break;
				
			}
			case 9:
			{
				if(xstring[1]==CT_POKE)
				{
					state++;
					isEnd = 1;
				}
				else
				{
                	crc+=xstring[state];
	                state++;
				}

				break;
				
			}
            case 132:
            {
                crc&=0xFF;
                if(xstring[state]==crc)
                {
                    for(ra=0;ra<128;ra++)
                    {
						if(xstring[1]==CT_LOAD)
						{
                        	PUT8(addr++,xstring[ra+4]);
						}
						else
						{
							if((GET8(addr++)&0xff)!=xstring[ra+4])
							{
								isSame=0;
							}
						}
                    }
					SendString("OneTurn!\n");
                    uart_send(0x06);
                    block=(block+1)&0xFF;
					isEnd=1;
				    state=0;
                }
                else
                {
					SendString("CRC Failed!\n");
                    uart_send(0x15);
					state=0;
                }
                break;
            }
            default:
            {
                crc+=xstring[state];
                state++;
                break;
            }
        }
    }
    return(0);
}
//-------------------------------------------------------------------------
//-------------------------------------------------------------------------


//-------------------------------------------------------------------------
//
// Copyright (c) 2012 David Welch dwelch@dwelch.com
//
// Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
//
//-------------------------------------------------------------------------

#include<Windows.h>
#include<stdio.h>
#include<string.h>
#include<iostream>
#include<fstream>
#include<stdlib.h>
#include "xmodem.h"

using namespace std;

void Help()
{
	cout<<"xmodem:"<<endl;
	cout<<"command format:"<<endl;
	cout<<"\txmodem -p com_port_name -baud speed"<<endl;
	cout<<"\txmodem -h this info"<<endl;
}
void ReadAll(HANDLE hCom)
{
	::Sleep(500);
	int ret;
	char str[101];
	DWORD len=100;
	while(len==100)
	{
		ret = ::ReadFile(hCom,str,sizeof(str)-1,&len,NULL);
		if(!ret)
		{
			cout<<"Error:Can't ReadFile"<<endl;
		}
		cout<<"Read Length:"<<len<<endl;
		str[len]='\0';
		cout<<str;
	}

}
void SendCom(HANDLE hCom,unsigned char* str,int len)
{
	int ret;
	DWORD lenReturn=0;
	COMSTAT ComStat;
	DWORD dwErrorFlags;
	::ClearCommError(hCom,&dwErrorFlags,&ComStat);
	ret = ::WriteFile(hCom,str,len,&lenReturn,NULL);
	if(!ret)
	{
		cout<<"Error:Can't WriteFile"<<endl;
	}
	cout<<"Send Length:"<<len<<endl;
}
void SendFile(HANDLE hCom,char* fileName,int commandType)
{
	ifstream fin(fileName,ios::binary);
	if(!fin.good())
	{
		cout<<"Can't open file "<<fileName<<endl;
		exit(-1);
	}
	unsigned char command[134];
	::memset(command,0,sizeof(command));

	int count = 1;
	while(!fin.eof())
	{
		command[0]=SOH;
		command[1]=commandType;
		command[2]=count;
		command[3]=255-count;
		fin.read((char*)command+4,128);
		unsigned int checkSum=0;
		for(int i=4; i<4+128; i++)
		{
			checkSum+=command[i];
		}
		command[132]=(unsigned char)(checkSum&0xff);
	//	command[133]=EOT;
		
		SendCom(hCom,command,133);
		count++;
	}
	command[0]=EOT;
	SendCom(hCom,command,1);
}
int main(int argc,char** argv)
{
	//Handle command line parameter.
	const char* comPort = "COM3";
	int baud = 115200;
	for(int i=1; i<argc; i+=2)
	{
		if(::strcmp(argv[i],"-p")==0)
		{
			if(i+1<argc)
			{
				comPort = argv[i+1];
			}
			else
			{
				cout<<"Error:You forget the parameter of -p"<<endl;
				return -1;
			}
		}
		else if(::strcmp(argv[i],"-baud")==0)
		{
			if(i+1<argc)
			{
				baud = atoi(argv[i+1]);
				if(baud<0)
				{
					cout<<"Error:There is something wrong with baud&"<<argv[i+1]<<endl;
					return -1;
				}
			}
			else
			{
				cout<<"Error:You forget the parameter of -p"<<endl;
				return -1;
			}
		}
		else if(::strcmp(argv[i],"-h")==0)
		{
			Help();
			return 0;
		}
		else
		{
			cout<<"Error:Can't recognize "<<argv[i]<<endl;
		}
	}
	//------------------------Set Com-----------------------------------
	HANDLE hCom;
	hCom = ::CreateFile(comPort,GENERIC_READ|GENERIC_WRITE,
			0,NULL,OPEN_EXISTING,0,NULL);
	if(hCom==(HANDLE)-1)
	{
		cout<<"Error:Can't open "<<comPort<<endl;
		return -1;
	}
	cout<<"CreateFile Success!"<<endl;
	DCB dcb;
	BOOL ret = ::GetCommState(hCom,&dcb);
	if(!ret)
	{
		cout<<"Error:Can't GetCommState"<<endl;
		return -1;
	}
	dcb.BaudRate=baud;
	//dcb.fParity = NOPARITY;
	dcb.ByteSize = 8;
	dcb.Parity = NOPARITY;
	dcb.StopBits = ONESTOPBIT;
	ret = ::SetCommState(hCom,&dcb);
	if(!ret)
	{
		cout<<"Error:Can't SetCommState"<<endl;
		return -1;
	}
	ret == ::SetupComm(hCom,1024,1024);
	if(!ret)
	{
		cout<<"Error:Can't SetupComm"<<endl;
		return -1;
	}
	cout<<"SetupComm Success!"<<endl;
	//--------------------Read&Write Com--------------------------------
	ret = ::PurgeComm(hCom,PURGE_TXCLEAR|PURGE_RXCLEAR);
	if(!ret)
	{
		cout<<"Error:Can't PurgeComm"<<endl;
		return -1;
	}
	cout<<"PurgeComm Success!"<<endl;
	while(1)
	{
		//ReadAll(hCom);
		char str[101];
		DWORD len=0;

		::gets(str);
		//cin>>str;
		//int ll=strlen(str);
		//str[ll]='\n';
		//str[ll+1]='\0';
		char bin[50];
		unsigned int address;
		unsigned int value;
		if(::sscanf(str,"load %s",bin)==1)
		{
			SendFile(hCom,bin,CT_LOAD);	
		}
		else if(::strcmp(str,"go")==0)
		{
			unsigned char command[10];
			command[0]=SOH;
			command[1]=CT_GO;
			command[2]=EOT;
			DWORD lenReturn;
			BOOL ret = ::WriteFile(hCom,command,3,&lenReturn,NULL);
			if(!ret)
			{
				cout<<"Error:Can't WriteFile"<<endl;
			}
			cout<<"Send Length:"<<3<<endl;
				
		}
		else if(::sscanf(str,"peek %x",&address)==1)
		{
			unsigned char command[10];
			command[0]=SOH;
			command[1]=CT_PEEK;
			*((unsigned int*)&command[2])=address;
			command[6]=EOT;
			DWORD lenReturn;
			BOOL ret = ::WriteFile(hCom,command,7,&lenReturn,NULL);
			if(!ret)
			{
				cout<<"Error:Can't WriteFile"<<endl;
			}
			cout<<"Send Length:"<<7<<endl;

		}
		else if(::sscanf(str,"poke %x %x",&address,&value)==2)
		{
			unsigned char command[11];
			command[0]=SOH;
			command[1]=CT_POKE;
			*((unsigned int*)&command[2])=address;
			*((unsigned int*)&command[6])=value;
			command[10]=EOT;
			DWORD lenReturn;
			BOOL ret = ::WriteFile(hCom,command,11,&lenReturn,NULL);
			if(!ret)
			{
				cout<<"Error:Can't WriteFile"<<endl;
			}
			cout<<"Send Length:"<<11<<endl;
		}
		else if(::sscanf(str,"verify %s",bin)==1)
		{
			SendFile(hCom,bin,CT_VERIFY);	
		}
		else if(::strcmp(str,"nop")==0)
		{

		}
		else if(::strcmp(str,"exit")==0)
		{
			break;
		}
		else
		{
			cout<<"Error:Unknow Command:"<<str<<endl;
		}

		ReadAll(hCom);

	}

	//--------------------Close Com-------------------------------------
	ret = ::CloseHandle(hCom);
	if(!ret)
	{
		cout<<"Error:Can't CloseHandle"<<endl;
		return -1;
	}
	return 0;
}




后记:

这次实验过程做的十分有趣,最开始我认为这个实验大概需要十来个小时才能完成。当我看完了要求和github上的代码的时候,我认为这个2-3小时就能完成。结果代码就写了2-3小时,最后悲剧的调试到了晚上1点钟,才算完事。真是欲速则不达,本来以为是一次性代码,草草了事就行,结果是草草的代码,到处bug。改掉这个bug,又改出了新的bug。

在技术上,如果想要做的更好些,应该采用异步的串口读写和异步的标准输入和输出,否则就会出现串口读的不干净,不及时的问题。

参考:

https://github.com/dwelch67/raspberrypi

http://wenku.baidu.com/view/9a31bb0103d8ce2f006623be.html

关于树莓派板子的详细硬件datasheet:http://raspberrypi.wikispaces.com/Hardware

SOH,ACK,NAK等,它的定义源自于ascii,之前都不知道原来ascii表中还蕴含了这样的控制字。http://baike.baidu.com/view/15482.htm

备注:

此为浙江大学计算机学院嵌入式课程实验报告。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值