Lab5 bootloader

教程目的:

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 [email protected]
//
// 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

備註:

此爲浙江大學計算機學院嵌入式課程實驗報告。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章