WinCE NAND flash - FAL

WinCE NAND flash - FAL

From ESSLabWiki

1. Introduction

Flash與一般常見的Disk不同,其特性是無法重複對同一塊記憶體位置去做Write的動作,必須要Erase那塊記憶體位置才可以做Write的動作。因此一般的File System,如FAT16、FAT32、NFTS…,無法直接在Flash Memory上使用;若是想要沿用這些File System,則必須透過一層Translation Layer來將Logical Block Address對應到實體的Flash Memory的位置,並透過一些機制能讓系統能把Flash Memory當作一般的硬碟一樣處理,在Win CE的平台上,我們稱這層為FTL(Flash Translation Layer)。

FTL最原始的應用,為使用於NOR Flash,然而目前的市場價格,大容量的NOR Flash的成本,遠高於同樣容量的NAND flash ,因此有NFTL(NAND Flash Translation Layer)的產生,NFTL主要想法與FTL相似,主要差別在於是用在NAND Flash上。

clip_image001

Figure 1. Architecture

本文件將介紹WinCE6.0在與Flash相關的Data structure、RAM上的Data structure,以及Win CE如何在Flash作Garbage Collection、Wear-Leveling,最後說明如何在Win CE的基本系統上,註冊一個Flash Device。

2. System Architecture

下圖為Windows CE 平台中,Flash Memory驅動的系統架構

clip_image002

Figure 2. Flash Architecture

先對本篇的重點:FAL及FMD做基本定義的說明如下:

FMD(FLASH Media Driver)可針對特定廠商的Flash作Driven、Read、Write、Erase等動作,實際去對Flash做讀寫的操作。

FAL(FLASH Abstraction Layer):File System對Flash讀寫,必須透過此層操作。而此層則再根據FMD所提供的Interface再對Flash做讀寫。

參考Figure 1再來看此圖的行為;File System為了對Flash操作,必須透過FAL對FMD(Driver)做操作。因此面對各家Flash來說,只需修改FMD即可,而不用再行修改 FAL的Interface即可對Flash做Read、Write、Erase等基本的操作。因此可得到一個結論,若是有一個產品裡的Flash換成了另一廠商的,因Flash對FAL的interface是一致的,那麼只要抽換FMD則其它的程式都不用再修改即可使用。

那麼FMD對FAL所提供的interface及其作用後面會再詳細說明,此處便不再贅述。

FAL存在兩個Table(DLUT、Secondary Table)以提供Translation的動作,其中也存在兩條List一個存Free Sector,一個存Dirty Block來分別提供Free Sector以及可幫助Garbage Collection的作用,詳細的流程及使用後續會再行說明。

3. Terminology

以下說明一些在本篇會出現的基本名詞及其定義

在Flash中可能會有多個Region,在實體上會如下圖:

clip_image003

Figure 3. Region at Flash

而Region中會存有多個Block,其示意圖如下:

clip_image004

Figure 4. Block at Region

而Block中也有多個Sector,其示意圖如下:

clip_image005

Figure 5. Sector at Block

但這裡要注意的是,Win CE是採用Sector Mapping的方式,因此取Block動作時,必須依所給的Sector Address算出它是屬於那一個Block。

這裡再說明它在各Term的情況:

對Region而言,它只紀錄了它有多少個Block,且一個Block有多少個Sector。

對Block而言,它的起始位置就是第一個Sector(以Figure 5來看,就是Sector 1)。但是這裡要說明一下Block裡面是如何去算它第一個Sector的位置。假設一個Block中有10個Sector,那麼下一個Block的第一個Sector就是Sector 11。

對File System而言,所看到的Sector Address都是Logical Address;當File System想取一個在Flash上的資料,必須要給FAL那個資料的Logical Address,而FAL要去取所需的資料,需根據Logical Address解析是位於Mapping Table的位置,再依據Table中的位置取出Physical Address,再交由FMD去取出在Flash上的Data;FAL再將取得的Data交給送出Request的System。這中間更詳細的細節,後面章節會再解釋。

4. Address Translation

一個Logical Address要如何快速找到Physical Address,靠的就是Mapping Table。有了這個就可以很方便且快速的找到所需的Address(Sector)。本段要介紹如何Create Mapping Table以及如何Mapping。

4.1 In-RAM Data Structures

PUCHAR m_pDynamicLUT[MASTER_TABLE_SIZE]; DWORD m_cbPhysicalAddr;

BOOL m_bIsNumSectorsPerSecTableLog2;

DWORD m_dwNumSectorsPerSecTable;

DWORD m_dwSecondaryTableSize;

DWORD m_dwStartLogSector;

DWORD m_dwNumLogSectors;

DWORD m_dwStartPhysSector;

DWORD m_dwNumPhysSectors;

這裡主要的動作是在處理及維護 DLUT Table和Secondary Table;其用途是為了快速的從Logical Address來找到Physical Address;是做Translation一個重要的Table。

m_pDynamicLUT[MASTER_TABLE_SIZE]:

這裡MASTER_TABLE_SIZE是256。因此可知一開始便給定Table的大小。這個Variable是Dynamic Look-Up Table (DLUT),也就是Mapping Table。

m_bIsNumSectorsPerSecTableLog2:

判斷Sector的個數在Secondary Table中是否是power of 2,會影響計算Table Size以及Secondary ID。若它是2的倍數,可直接Shift取值,節省做除法取值的時間

m_dwNumSectorsPerSecTable:

在Secondary table中可紀錄多少個Sector

m_dwSecondaryTableSize:

Secondary Table的Size。根據有多少個Logical Sector及Physical Address的長度來決定Size的大小

m_dwStartLogSector:

這個Logical Block的起始Sector的位置

m_dwNumLogSectors:

Logical Sector的個數

m_dwStartPhysSector:

這個Physical Block的起始Sector的位置

m_dwNumPhysSectors:

Physical Block的Sector的個數

4.2 On-Flash Data Structures

typedef struct _FlashInfoEx

{

DWORD cbSize;

FLASH_TYPE flashType;

DWORD dwNumBlocks;

DWORD dwDataBytesPerSector;

DWORD dwNumRegions;

FlashRegion region[1];

}FlashInfoEx, *PFlashInfoEx;

typedef struct _FlashInfo

{

FLASH_TYPE flashType;

DWORD dwNumBlocks;

DWORD dwBytesPerBlock;

WORD wSectorsPerBlock;

WORD wDataBytesPerSector;

}FlashInfo, *PFlashInfo;

flashType:在init之後這裡要取得Flash Type看是NOR亦或NAND。

dwNumBlocks:看這Flash中有多少個Block

dwBytesPerBlock:一個Block有多少Bytes

wSectorsPerBlock:一個Block有多少個Sector

wDataBytesPerSector:一個Sector有多少個Bytes

這裡是取得Flash的基本Info,之後再去算這個Flash內有多少Block。而FlashInfoEx與FlashInfo的差別是Region的部分,由於看的NAND flash的部分只用一個Region,所以,下面都以一個Region做介紹。

Block有以下幾種Status, 根據不同的status來決定Sector會被放在那一個List裡。

BLOCK_STATUS_UNKNOWN:其它無法判斷的情況

BLOCK_STATUS_BAD:這個Block是Bad Block。

BLOCK_STATUS_READONLY:這個Block是Read Only。

BLOCK_STATUS_RESERVED:這個Block被Reserved。

另外,在取Block Status時,FAL是用Block ID跟FMD取Block Status,而FMD是在得到這個Block ID之後,去算出此Block在Flash中的第一個Sector是第幾個Sector,再去取出它的Status給FAL。

以下圖為例,若是一個Block有10個Sector,那麼Block 2的第一個Sector就是Sector 11。因此,FMD便是取Sector 11的Status交還給FAL。

clip_image006

Figure 6. Get Block Status

4.3 How to Create Mapping Table

這裡介紹在Win CE所使用的Mapping Table。它是去Scan Flash上Info,先取出Region、Flash Type。一個Region內有多個Block,每一個Region都有各自一個獨立的Table ,Region裡有多個Block。

Region的data Structure如下

typedef struct _FlashRegion

{

REGION_TYPE regionType;

DWORD dwStartPhysBlock;

DWORD dwNumPhysBlocks;

DWORD dwNumLogicalBlocks;

DWORD dwSectorsPerBlock;

DWORD dwBytesPerBlock;

DWORD dwCompactBlocks;

}FlashRegion, *PFlashRegion;

dwStartPhysBlock:在這個Region中Physical Block的index值,這裡的初始值是0。也就是說若是這個Flash中有兩個Region,而每個Region中有10個Block,則第一個 Region的Start Physical Block為0,第二個Region的Start Physical Block為10。

dwNumPhysBlocks:有多少個Block在這個Region裡

dwSectorsPerBlock:Block中有多少個Sector

dwCompactBlocks:在Compactor裡定義其值為2,之後在Compaction會看到

這裡是做Sector Mapping。因此Table是依一個個Sector去建成的。先取Block,再取Block中的所有Sector的Info(這裡取出的只有 Spare area的資料);依據這個Sector status再決定它是放入一般的List (在這裡亦有分Free, Read only) 或是Dirty list。其它有info的sector則是去做Mapping Table的動作。其中要注意的是,由於Write是以Sector為單位在寫,所以存Free List是以Sector為單位在存,而Erase是以Block為單位,所以Dirty List是以Block為單位在存。

建Table的psuedo-code如下,

for (each Block)

{

for (each Sector)

{

Read Sector Info (spare area)

if (Sector is free)

{

add into m_list(Free)

}

if (Sector is dirty)

{

add into dirty_list

}

if (Sector is mapped)

{

if (Block is Read_Only && Sector is first sector on Block)

{

add into m_list(Read_only);

for (each Sector)

mapping the Table : L2P

break;

}

mapping the Table : L2P

}

} //for (each Sector)

}// for (each Block)

接下來說明Mapping Table的流程:

DLUT(Dynamic Look Up Table)指的是Master Table,有256個entry,一個entry對映一個Secondary Table。沒有用到的Sector(Free Sector)不會在Table中去建立,採取On Demand的方式。

一個有資料的Sector會存有之前在系統中所代表其位置的Sector Logical Address,若是要找Secondary Tables並未建立,系統才會去建其Table,並根據Logical Address算出它在Secondary Table中的位置,再將其Physical Address存入至Table中。

clip_image007

Figure 7. DLUT and Secondary Table

下圖是詳細的建立Table的過程:

1. 根據給的Logical Sector先去算出Secondary Table ID(此圖假設算出來的ID = 1),再查詢是否已有Secondary Table,若該table不存在,系統會在此時Allocate一個新的Secondary Table。

2. 就先前讀出的Sector’s Spare Area的資料來建Secondary Table。再來便是計算在Secondary Table中的位置

3. 取出後再把Physical Sector Address存到這個位置中:

clip_image008

Figure 8. Create Logical to Physical Sector Address

4.4 Read

它做的流程可參考Figure 4來解釋,。根據Sector’s Logical Address算出在DLUT是第幾個entry,再依據Entry就可以取得相對應的Secondary Table,再利用Logical Address做計算取出在這個Secondary Table中的位置,之後便可取得Physical Address。而FMD就可根據這個Address來取出位於Flash的資料。

4.5 Write

在Write之前必須在Free list中取一個可用的Sector,若是Sector不足,便會啟動Compaction機制,以取到足夠的Sector,以便完成 Write。

在Write之前先將Sector Info Mark成處理中,再去寫Sector info;寫完info確定該Sector可以寫入,再將Sector Info 標示為處理完,與Data一起寫入。最後更新Mapping Table以及Logical Address對映的Physical Address。

而之前舊的資料,將其update Sector info設定為Dirty;此外會算出此Sector位於哪一個Block,並將該Block Dirty的Sector個數加1。這邊要請讀者注意的是,它是SLC的動作,它可以允許一次這個動作,而MLC是不允許此值直接變更寫回。

5. Garbage Collection (Compaction)

Garbage Collection,在WinCE中的命名是Compaction。簡單的說,其目的是回收被無效資料所佔據的空間,且一次可回收愈多愈好。

5.1 What’s Garbage Collection?

由於Flash無法在更新資料時寫回相同的位置(除非先將該位置Erase),所以採Outplace的方式寫回資料。Outplace是將更新的資料寫入在不同的頁面中,來避免每次更新資料就必須進行抹除的動作的Overhead。

那麼原來不能直接寫回而變成無用舊資料的Sector,便需要有policy去將它釋放成為可用的Sector,這個policy便是Garbage Collection

5.2 Garbage Collection Policy

在這個FAL中會存在兩條List,分別去紀錄Free Sector及Dirty Block。這兩條List所紀錄的大小不一樣是由於寫是Sector為單位去寫,而Erase是以Block為單位在做。

上面所提到的直接選Dirty Block,便是直接去取Dirty List裡Dirty Sector最多的一個Block來當做Victim Block。而Random選Block的方式,是取第一個Physical Block做Base,用1~32做Random去除Physical Block的數量取餘數;Base加上餘數之後的值為Block ID,這個Block即為Victim Block。

Compaction的時機是當Free Sector不足時做,且會做到這個Compaction完成才離開,這裡稱它是Synchronous Compaction。例如:一個Block有20個Sector,那麼Free Sector最少要有40個才夠。另一個情況是,當你要寫的Data的Sector個數若是比目前Free Sector個數多時,它也會強制執行Compaction直到有足夠的Free Sector,得以完成Write Data的任務才會結束。

另一個時機是當Flash目前的Dirty Sector數目Free Sector多(但此情況還是Free Sector的個數比限制的下限個數多),但是它的Priority低,是Background Compaction這裡稱它是Asynchronous Compaction,不會強制必須要Compaction結束才會離開。

6. Wear-Leveling

6.1 What is Wear-Leveling?

因為Flash的每個Block有Erase次數的限制,當某個Block Erase次數過多,可能會造成該Block存取速度變慢,嚴重時甚至會造成該Block毀損。因此為了避免同一區塊過度存取而造成毀損,且能夠平均每個 Block Erase的的次數,此機制稱為Wear-Leveling。

6.2 How WL is done in FAL

這裡Critical是指當Free Sector不足時的情況,所以會直接取Dirty List裡的Block去做Compact。其它情況下就使用Random的方式,下面就直接介紹這兩種policy。

1. Critical = True

從Dirty List裡取一個Dirty Sector最多的Block來Earse

Bool value 不更改。

2. Critical = False

是用Random的方式,用Static Bool value做交替的條件,Bool value的初始值為False。

若第一次是Random方式選(Bool Value = True),且更改Bool value

則第二次從Dirty List裡選(Bool Value = False),亦更改Bool value

此外,若是Random選出來的Block是Free或是Invalid就從Dirty List裡選。

Random使用範例如下:

Sequence(S)

1

2

3

4

5

Execute

Dirtiest

Random

Dirtiest

Random

Dirtiest

Free Block

N

N

N

Y

N

Re-Execute

Dirtiest

S 1:第一次是Bool value = False,所以從Dirty List裡取,且更改Bool value為True

S 2:第二次是Bool value = True,所以取Random,且更改Bool value為False

S 3:第三次是Bool value = False,從Dirty List裡取,且更改Bool value為True

S 4:第四次是Bool value = True,所以取Random,且更改Bool value為False。但取出的Block為Free Block所以改取Dirty List。

S 5:第五次是Bool value = False,從Dirty List裡取,且更改Bool value為True

綜合使用範例如下:

C = Critical, D = Dirty Block, R = Random Block,

B = bRandom-表示T下一次要選Random或是從Dirty List裡取Victim

(請看Critical = False的範例及說明)

若C的順序如下:

TFFTFFF B = F (init)

則選的Block的流程如下:

Critical

Choose Block

B (Change)

C = T

D

B = F(no change)

C = F

D

B = T

C = F

R→若選出Block is Free

則再從D選

B = F

C = T

D

B = F (no change)

C = F

D

B = T

C = F

R

B = F

C = F

D

B = T

7. FMD/FAL interface

7.1 FMD給FAL的函式介面

typedef struct _FMDInterface

{

DWORD cbSize;

PFN_INIT pInit;

PFN_DEINIT pDeInit;

PFN_GETINFO pGetInfo;

PFN_GETBLOCKSTATUS pGetBlockStatus;

PFN_SETBLOCKSTATUS pSetBlockStatus;

PFN_READSECTOR pReadSector;

PFN_WRITESECTOR pWriteSector;

PFN_ERASEBLOCK pEraseBlock;

PFN_POWERUP pPowerUp;

PFN_POWERDOWN pPowerDown;

PFN_GETPHYSSECTORADDR pGetPhysSectorAddr;

PFN_OEMIOCONTROL pOEMIoControl;

} FMDInterface, *PFMDInterface;

以上FMD Function其本來是以FMD_做Prefix,以pGetBlockStatus為例,其在FMD.cpp的naming是 FMD_GetBlockStauts;但為了某些使用技巧,因此將其包裝成Structure使其得以用Function Point的方式使用,以下說Function的用途:

pInit:

當Flash Device要initial時對Device做初始化的動作。找到其支援的chip加以進行初始化,以及返回一個FMD handle。

pDeInit:

取得FMD handle以釋放一些用到的資源,關掉chip controller。

pGetInfo:

該函數用於取得Flash的資訊。其中pFlashInfo是一個包含Flash資訊的結構。

pFlashInfo->flashType:Flash的類型。

pFlashInfo->wDataBytesPerSector:一個Sector多少個Bytes。

pFlashInfo->dwNumBlocks:Flash中總共有多少個Block。

pFlashInfo->wSectorsPerBlock:每個Block中包含多少個Sector。

pFlashInfo->dwBytesPerBlock:每個Block中包含多少個Bytes。

pGetBlockStatus:

為取得某一個block的狀態。參數為Block Address。由於Flash中可能有Bad Block,所以首先會檢查目前是否為Bad Block,是取得第一個Sector的Status即可知道。如果發現該塊是Bad Block,應該返回BLOCK_STATUS_BAD。如果不是Bad Block,需要讀取這個塊的起始Sector的Sector Info。如果讀該Sector Info出錯,應該返回BLOCK_STATUS_UNKNOWN。

pSetBlockStatus:

設定某個block的狀態,第一個參數是Block位址,第二個是要設定的狀態。在這個函數中,首先檢查dwStatus是不是 BLOCK_STATUS_BAD,如果是就對作Bad Block標記,然後返回FALSE。如果不是,就將dwStatus寫到該block的第一個Sector的info中。

pReadSector:

用於讀Flash上的一個Sector。其中傳入的參數值代表如下:

startSectorAddr:

Sector的起始位址,就是從哪個Sector開始。

pSectorBuff:

讀出的每一個Sector的資料都存放在這個buffer中。

pSectorInfoBuff:

一般每個Sector的資訊會被保存在Flash的資料中。從Flash的資料將該Sector的相關資訊讀出來,存放在這個buffer中。這些資料也就是Spare area。

dwNumSectors:

讀取多少個Sector。

pWriteSector:

意義與上面相同,此處是對Write。

pEraseBlock:

為Erase block,參數為第幾個block。

pPowerUp:

恢復Flash設備電源

pPowerDown:

關閉Flash設備電源

pGetPhysSectorAddr:

取得在Flash上它physical Address。

pOEMIoControl:

就像很多的IOControl函數一樣,根據不同的case,實現相應的功能。針對NAND Flash來說,這裡面不一定都需要implement。事實上,如果什麼都沒有implement,也不影響使用。

7.2 FAL給File System的函式介面

DSK_Init:

這裡會先去initial FMD,以便取得Flash基本資料。之後再去initial FAL,以取得對Flash操作的interface,還有建立Translation Table。

DSK_Deinit:Free FAL object再Free FMD

DSK_Open:這裡並未implement只有debug message。

DSK_Close:這裡並未implement只有debug message,return TRUE。

DSK_Read:Not Used。

DSK_Write:Not Used。

DSK_Seek:Not support。

DSK_PowerDown:直接Call FMD的PowerDown function。

DSK_PowerUp:直接Call FMD的PowerUp function。

DSK_IOControl:

對Flash的操作皆是透過這支Function。先根據進來的Control Code再去做相應的檢查;例如對Flash取得其Information(GetInfo),要確認傳進來的Buffer以及Size是否正確,無誤之後再去做相應的動作。

因此上述的Open、Close、Read、Write皆是由此Function做處理。

8. 與系統註冊一個Device

8.1 FAL、FMD

在 WinCE 裡的Flash driven實作是由FAL及FMD所組成(Figure 1),FAL以Library的型式,它對外的interface其Prefix為DSK_開頭(7.2章有介紹),而FMD是Dynamic Link的型式,其Prefix則是FMD_開頭(7.1章有介紹)。要在WinCE上需要MSflash.lib來趨動,這會需要有fal.lib及 fmd.dll,才可以在WinCE裡使用這個Flash。FAL也要有一個FMD才可以在WinCE上Run一個Device,否則在WinCE而言,也只是提供了一個Interface而沒有可使用的Device。

FAL是由下列File組成的(在此僅列.Cpp)

1. Compactor – 提供Compactor function

2. Fal – Build Mapping Table以及對FMD Layer下Read、Write、Compactor、Format的動作,實際對Flash做Read、Write、Erase還是FMD Layer(架構請見Figure 1)

3. falmain – 提供給File System的Interface

4. log2physmap – Mapping Table之Update及取得Physical Sector Address。

5. sectormgr – 對Sector之管理,諸如對Free-List及Dirty-List之處理(管理),以及提供對Sector操作的Function

以下做的動作是將系統中FAL(Library)從系統中抽出來,再加上修改過的Flash FMD(Driver),將其在Simulator上得以被註冊顯示在WinCE的環境裡。

8.2如何與系統註冊一個Storage

Step 1.

在建好一個OSDesign時,請先Build整個環境(約二十至四十分鐘不等),若是先做下面的動作再一起Build,會出現Error。

Step 2.

加入Project在your clone emulator (這裡是Clone自Device Emulator : ARMV4I。Clone name : ARVV4IBase)

下的SRC->Drivers (path : C:/WINCE600/PLATFORM/ARMV4IBase/SRC/DRIVERS)

以下是顯示在Visual Studio的情況。

clip_image009

這裡要改下列的數個File

Dirs:C:/WINCE600/PLATFORM/your clone emulator/SRC/DRIVES裡,加入新加的Project name(Driver name)。

Example:(# -- 注解)

# @CESYSGEN ENDIF CE_MODULES_SHOWFAL

FALSHOW /

FMDSHOW /

Platfrom.bib:其目的是要定義那些是要包在OS的Image內,因此要加入新的FMD。

Example:

IF BSP_FMDSHOW1

FMDSHOW1.dll $(_FLATRELEASEDIR)/FMDSHOW1.dll NK SHK

ENDIF

Platfrom.reg:定義冷開機時系統啟始的Registry Key及相關數值,值得一提的是,Storage的IClass是可以重複的。

Example:

[HKEY_LOCAL_MACHINE/Drivers/BuiltIn/FMDSHOW1]

"Prefix"="DSK"

"Dll"="FMDSHOW1.dll"

"IClass"=multi_sz:"{A4E7EDDA-E575-4252-9D6B-4195D48BB865}"

之後若是修改Platfrom.reg裡的值只需要Build->Advanced BuildCommands->Build Current BSP and Subprojects即可

Source:是存要Link的library或是會使用到的library,這個是每個Project都會有的。

Catalog Item:如此建的FMD才可以在Catalog Items View下的Device Drivers裡看到。

Step 3. 以下的選項記得要選起來

clip_image010

Step 4. 上面的動作都做了之後,選取你新建的FMD再整個Project Build過。再來,便是Attach Device之後就可以看到Win CE emulator的畫面,點取紅色方框的My Device

clip_image011

Step 5.點取紅色方框的Control Panel

clip_image012

Step6.點取紅色方框的Storage Manger

clip_image013

Step 7. 以下便是新加的FMD。

按下Partitions旁的New Button

clip_image014

會出現下列視窗,取一個名字按右上方的OK

clip_image015

會回到上一個畫面,選Properties

clip_image016

Step 8. 先選Dismount。

clip_image017

其它的Button會Enable

clip_image018

選Format Button

Step 9. Choose Start Button

clip_image019

之後會跳一個訊問Message Box,選確定便會開始Format。完成後按下OK

clip_image020

Step 10. 回到Partition Properties的畫面,選Mount Button,按下右上方的OK

clip_image021

回到Storage Properties畫面後,按下右上方的OK

clip_image022

Step 11. 回到My Device畫面就可以看到產生了一個新的Storage。

clip_image023

以上便是去Create一個新的Flash Device的簡單流程。

上面这篇分析很清晰,不过比较零散,大致的类的练习随意画了个图:

clip_image024

一个FAL实例针对一个Region区域,有些概念需要清楚,Wince的Sector是底层返回的页的大小,不要混淆。

针对大的Nand Flash FAL+FMD绝对不是好的实现方式,上电建立映射表的过程太慢,如果将每个页信息放入一个集中的地方,又难免导致该处使用过度。

使用MDD+PDD可能会更好,还没实验,之后做对比。

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值