經過上一篇摧殘之後,剩下應該就還好了~~!!
終於來到第20天,鐵人賽開始準備最後的倒數!
今天主要是剖析一下RISC-V架構中如何將新增Watchpoint和Breakpoint,並把它們移除!!
讓我們開始吧!
在「Day 13: 了解Trigger Module的神秘面紗(下)~~!」中其實已經有稍微說明到如何去評估(偵測)、初始化RISC-V Debug System中的Trigger Module,這邊會再稍微深入剖析!
參考以下內容(src/target/riscv/riscv.c):
int riscv_enumerate_triggers(struct target *target)
{
RISCV_INFO(r);
for (int hartid = 0; hartid < riscv_count_harts(target); ++hartid) {
if (!riscv_hart_enabled(target, hartid))
continue;
riscv_reg_t tselect = riscv_get_register_on_hart(target, hartid,
GDB_REGNO_TSELECT);
for (unsigned t = 0; t < RISCV_MAX_TRIGGERS; ++t) {
r->trigger_count[hartid] = t;
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TSELECT, t);
uint64_t tselect_rb = riscv_get_register_on_hart(target, hartid,
GDB_REGNO_TSELECT);
/* Mask off the top bit, which is used as tdrmode in old
* implementations. */
tselect_rb &= ~(1ULL << (riscv_xlen(target)-1));
if (tselect_rb != t)
break;
uint64_t tdata1 = riscv_get_register_on_hart(target, hartid,
GDB_REGNO_TDATA1);
int type = get_field(tdata1, MCONTROL_TYPE(riscv_xlen(target)));
switch (type) {
case 1:
/* On these older cores we don't support software using
* triggers. */
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TDATA1, 0);
break;
case 2:
if (tdata1 & MCONTROL_DMODE(riscv_xlen(target)))
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TDATA1, 0);
break;
}
}
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TSELECT, tselect);
LOG_INFO("[%d] Found %d triggers", hartid, r->trigger_count[hartid]);
}
return ERROR_OK;
}
主要分成以下幾個步驟:
首先是Step 1. 對$tselect寫入目前選擇Trigger的編號:
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TSELECT, t);
riscv_set_register_on_hart()
這個函式已經在「Day 18: 深入淺出 RISC-V 源碼剖析 (3) - Register Access」中的"3.1 Write Registers by Abstract Command"中特別解釋過了!
根據目前的設計:
#define RISCV_MAX_TRIGGERS 32
for (unsigned t = 0; t < RISCV_MAX_TRIGGERS; ++t) {
}
單一個Hart所支援的Trigger Module數量上限是"32"
再來是Step 2. 重新讀回$tselect,確認該Trigger是否存在,
然後很重要的一個點就是要把$tselect讀回來看看,看看跟寫入是否一致:
uint64_t tselect_rb = riscv_get_register_on_hart(target, hartid,
GDB_REGNO_TSELECT);
....
if (tselect_rb != t)
break;
發現不一致的話,就表示這個點開始的Trigger皆不存在,因為Trigger的編號必須保證"連續",因此一旦發現不同的話,就表示可以去評估下一個Hart!
如果通過檢測,表示這個Trigger存在,接下來就是Step 3. 讀取$tdata1,確認Trigger支援的功能,去評估這個Trigger:
uint64_t tdata1 = riscv_get_register_on_hart(target, hartid,
GDB_REGNO_TDATA1);
int type = get_field(tdata1, MCONTROL_TYPE(riscv_xlen(target)));
依照目前架構的設計type主要有三種:
我們主要是需要type = 2的那些Trigger,方便我們當作Hardware的Breakpoint和Watchpoint使用!
在type = 2中,這邊多加了一個判斷:
if (tdata1 & MCONTROL_DMODE(riscv_xlen(target)))
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TDATA1, 0);
break;
主要是評估該Trigger的dmode是否有被拉起來,讓這個Trigger僅限於Debug Mode下才能使用,如果發現dmode被設定成1,則將整個$tdata1寫0,作清空!
根據整體架構的設計,在Machine Mode中也可以使用該Trigger!
最後重複以上流程,直到所有Hart中的所有Trigger都被檢驗過!
這是Debugger最重要的的功能之一,主要是負責讓程式運行至指定的位置後停下來,
等待進一步的處理!
基本上Breakpoint分稱兩種:
一樣先從上層的進入點開始剖析!
參考以下內容(src/target/riscv/riscv.c):
int riscv_add_breakpoint(struct target *target, struct breakpoint *breakpoint)
{
if (breakpoint->type == BKPT_SOFT) {
///譯註: 新增Software Breakpoint
if (target_read_memory(target, breakpoint->address, breakpoint->length, 1,
breakpoint->orig_instr) != ERROR_OK) {
LOG_ERROR("Failed to read original instruction at 0x%" TARGET_PRIxADDR,
breakpoint->address);
return ERROR_FAIL;
}
int retval;
if (breakpoint->length == 4)
retval = target_write_u32(target, breakpoint->address, ebreak());
else
retval = target_write_u16(target, breakpoint->address, ebreak_c());
if (retval != ERROR_OK) {
LOG_ERROR("Failed to write %d-byte breakpoint instruction at 0x%"
TARGET_PRIxADDR, breakpoint->length, breakpoint->address);
return ERROR_FAIL;
}
} else if (breakpoint->type == BKPT_HARD) {
///譯註: 新增Hardware Breakpoint
struct trigger trigger;
trigger_from_breakpoint(&trigger, breakpoint);
int result = add_trigger(target, &trigger);
if (result != ERROR_OK)
return result;
} else {
LOG_INFO("OpenOCD only supports hardware and software breakpoints.");
return ERROR_TARGET_RESOURCE_NOT_AVAILABLE;
}
breakpoint->set = true;
return ERROR_OK;
}
可以看到OpenOCD在負責處理GDB重送過來的要求後,會把幾本的資料放入struct breakpoint中,基本定義如下:
參考以下內容(src/target/breakpoint.h):
struct breakpoint {
target_addr_t address;
uint32_t asid;
int length;
enum breakpoint_type type;
int set;
uint8_t *orig_instr;
struct breakpoint *next;
uint32_t unique_id;
int linked_BRP;
};
裡面幾本上包含以下資訊:
剩下資料會在後面章節中談到!
在這個進入點最重要的是,就是區分Software breakpoint和Hardware breakpoint,
然後分別做出對應的處理!
Software breakpoint最基本的觀念就是在該行原先的指令中,用ebreak指令來取代
好了講完了~~!
讓我們剖析一下這部分要如何處理,簡單的流程如下:
很簡單,然後看實作的部分:
參考以下內容(src/target/riscv/riscv.c):
if (target_read_memory(target, breakpoint->address, breakpoint->length, 1,
breakpoint->orig_instr) != ERROR_OK) {
LOG_ERROR("Failed to read original instruction at 0x%" TARGET_PRIxADDR,
breakpoint->address);
return ERROR_FAIL;
}
int retval;
if (breakpoint->length == 4)
retval = target_write_u32(target, breakpoint->address, ebreak());
else
retval = target_write_u16(target, breakpoint->address, ebreak_c());
if (retval != ERROR_OK) {
LOG_ERROR("Failed to write %d-byte breakpoint instruction at 0x%"
TARGET_PRIxADDR, breakpoint->length, breakpoint->address);
return ERROR_FAIL;
}
首先是Step 1. 讀取原先位置的指令:
target_read_memory(target, breakpoint->address, breakpoint->length, 1,
breakpoint->orig_instr)
在這邊用用到breakpoint資料結構中的address和length,並用我們在上篇「Day 19: 深入淺出 RISC-V 源碼剖析 (4) - Memory Access」所提到的Memory Access的方式,將原先指令從記憶體中讀出!
並將其放入breakpoint資料結構中的"orig_instr"中,方便我們日後再移除這個Software breakpoint的時候,可以把原先的指令還原回去!
再來是Step 2. 寫入對應的ebreak:
int retval;
if (breakpoint->length == 4)
retval = target_write_u32(target, breakpoint->address, ebreak());
else
retval = target_write_u16(target, breakpoint->address, ebreak_c());
if (retval != ERROR_OK) {
LOG_ERROR("Failed to write %d-byte breakpoint instruction at 0x%"
TARGET_PRIxADDR, breakpoint->length, breakpoint->address);
return ERROR_FAIL;
}
這部分就是要依照原先指令的長度,加入對應的ebreak指令:
並將其覆蓋回原先指令的位置!
在進入點中,參考以下內容(src/target/riscv/riscv.c):
struct trigger trigger;
trigger_from_breakpoint(&trigger, breakpoint);
int result = add_trigger(target, &trigger);
if (result != ERROR_OK)
return result;
就是很簡單的呼叫trigger_from_breakpoint()
和add_trigger()
來處理!
而這個處理的過程在「Day 13: 了解Trigger Module的神秘面紗(下)~~!」中也有稍微剖析過!
首先是trigger_from_breakpoint(),主要負責準備好Trigger新增時所需要的資料,
參考以下內容(src/target/riscv/riscv.c):
static void trigger_from_breakpoint(struct trigger *trigger,
const struct breakpoint *breakpoint)
{
trigger->address = breakpoint->address;
trigger->length = breakpoint->length;
trigger->mask = ~0LL;
trigger->read = false;
trigger->write = false;
trigger->execute = true; ///譯註: 這行很重要,用來標示這是個Breakpoint
/* unique_id is unique across both breakpoints and watchpoints. */
trigger->unique_id = breakpoint->unique_id;
}
先把Trigger需要的資料準備好,分成以下幾種:
由於Breakpoint是在指令"執行"的時候停下來,因此需要設定Trigger的以下三個欄位:
最後,為了方便處理Trigger對應Breakpoint的關係,這邊也在Trigger中記錄breakpoint的unique_id:
/* unique_id is unique across both breakpoints and watchpoints. */
trigger->unique_id = breakpoint->unique_id;
準備好資料後,最後是呼叫add_trigger(),將這個Trigger加入,
參考以下內容(src/target/riscv/riscv.c):
static int add_trigger(struct target *target, struct trigger *trigger)
{
RISCV_INFO(r);
/* In RTOS mode, we need to set the same trigger in the same slot on every
* hart, to keep up the illusion that each hart is a thread running on the
* same core. */
/* Otherwise, we just set the trigger on the one hart this target deals
* with. */
riscv_reg_t tselect[RISCV_MAX_HARTS];
int first_hart = -1;
for (int hartid = 0; hartid < riscv_count_harts(target); ++hartid) {
if (!riscv_hart_enabled(target, hartid))
continue;
if (first_hart < 0)
first_hart = hartid;
tselect[hartid] = riscv_get_register_on_hart(target, hartid,
GDB_REGNO_TSELECT);
}
assert(first_hart >= 0);
unsigned int i;
for (i = 0; i < r->trigger_count[first_hart]; i++) {
if (r->trigger_unique_id[i] != -1)
continue;
riscv_set_register_on_hart(target, first_hart, GDB_REGNO_TSELECT, i);
uint64_t tdata1 = riscv_get_register_on_hart(target, first_hart, GDB_REGNO_TDATA1);
int type = get_field(tdata1, MCONTROL_TYPE(riscv_xlen(target)));
int result = ERROR_OK;
for (int hartid = first_hart; hartid < riscv_count_harts(target); ++hartid) {
if (!riscv_hart_enabled(target, hartid))
continue;
if (hartid > first_hart)
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TSELECT, i);
switch (type) {
case 1:
result = maybe_add_trigger_t1(target, hartid, trigger, tdata1);
break;
case 2:
result = maybe_add_trigger_t2(target, hartid, trigger, tdata1);
break;
default:
LOG_DEBUG("trigger %d has unknown type %d", i, type);
continue;
}
if (result != ERROR_OK)
continue;
}
if (result != ERROR_OK)
continue;
LOG_DEBUG("Using trigger %d (type %d) for bp %d", i, type,
trigger->unique_id);
r->trigger_unique_id[i] = trigger->unique_id;
break;
}
for (int hartid = first_hart; hartid < riscv_count_harts(target); ++hartid) {
if (!riscv_hart_enabled(target, hartid))
continue;
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TSELECT,
tselect[hartid]);
}
if (i >= r->trigger_count[first_hart]) {
LOG_ERROR("Couldn't find an available hardware trigger.");
return ERROR_TARGET_RESOURCE_NOT_AVAILABLE;
}
return ERROR_OK;
}
中間落落長的判斷,不過因為只有Trigger type = 2的時候才能夠當作Breakpoint用,
所以這邊我們只關心核心的地方:
result = maybe_add_trigger_t2(target, hartid, trigger, tdata1);
來剖析maybe_add_trigger_t2()的實作部分,
參考以下內容(src/target/riscv/riscv.c):
static int maybe_add_trigger_t2(struct target *target, unsigned hartid,
struct trigger *trigger, uint64_t tdata1)
{
RISCV_INFO(r);
/* tselect is already set */
if (tdata1 & (MCONTROL_EXECUTE | MCONTROL_STORE | MCONTROL_LOAD)) {
/* Trigger is already in use, presumably by user code. */
return ERROR_TARGET_RESOURCE_NOT_AVAILABLE;
}
/* address/data match trigger */
tdata1 |= MCONTROL_DMODE(riscv_xlen(target));
tdata1 = set_field(tdata1, MCONTROL_ACTION,
MCONTROL_ACTION_DEBUG_MODE);
tdata1 = set_field(tdata1, MCONTROL_MATCH, MCONTROL_MATCH_EQUAL);
tdata1 |= MCONTROL_M;
if (r->misa & (1 << ('H' - 'A')))
tdata1 |= MCONTROL_H;
if (r->misa & (1 << ('S' - 'A')))
tdata1 |= MCONTROL_S;
if (r->misa & (1 << ('U' - 'A')))
tdata1 |= MCONTROL_U;
if (trigger->execute)
tdata1 |= MCONTROL_EXECUTE;
if (trigger->read)
tdata1 |= MCONTROL_LOAD;
if (trigger->write)
tdata1 |= MCONTROL_STORE;
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TDATA1, tdata1);
uint64_t tdata1_rb = riscv_get_register_on_hart(target, hartid, GDB_REGNO_TDATA1);
LOG_DEBUG("tdata1=0x%" PRIx64, tdata1_rb);
if (tdata1 != tdata1_rb) {
LOG_DEBUG("Trigger doesn't support what we need; After writing 0x%"
PRIx64 " to tdata1 it contains 0x%" PRIx64,
tdata1, tdata1_rb);
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TDATA1, 0);
return ERROR_TARGET_RESOURCE_NOT_AVAILABLE;
}
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TDATA2, trigger->address);
return ERROR_OK;
}
一樣,我們來一一剖析實作,
首先是一個簡單的判斷:
/* tselect is already set */
if (tdata1 & (MCONTROL_EXECUTE | MCONTROL_STORE | MCONTROL_LOAD)) {
/* Trigger is already in use, presumably by user code. */
return ERROR_TARGET_RESOURCE_NOT_AVAILABLE;
}
這邊主要是判斷,這個Trigger有沒有被使用過,
最基本的就是判斷$tdata1中的execute、store、load有沒有被使用過!?
再來是做些基本的設定:
///譯註: 將dmode設定成1,作保護
tdata1 |= MCONTROL_DMODE(riscv_xlen(target));
///譯註: 設定action = 1
tdata1 = set_field(tdata1, MCONTROL_ACTION,
MCONTROL_ACTION_DEBUG_MODE);
///譯註: 設定match = 0
tdata1 = set_field(tdata1, MCONTROL_MATCH, MCONTROL_MATCH_EQUAL);
///譯註: 設定m、h、s、u
tdata1 |= MCONTROL_M;
if (r->misa & (1 << ('H' - 'A')))
tdata1 |= MCONTROL_H;
if (r->misa & (1 << ('S' - 'A')))
tdata1 |= MCONTROL_S;
if (r->misa & (1 << ('U' - 'A')))
tdata1 |= MCONTROL_U;
///譯註: 設定execute、read、write
if (trigger->execute)
tdata1 |= MCONTROL_EXECUTE;
if (trigger->read)
tdata1 |= MCONTROL_LOAD;
if (trigger->write)
tdata1 |= MCONTROL_STORE;
一開始先將dmode設定成1,防止除了Debugger以外的人,修改到這個Trigger;
接著是設定action為1,讓踩到Trigger的時候,Hart直接停下來並進入Debug Mode中;
然後設定match為0,表示address必須完全相符才行;
除了M(Matchine) Mode一定會有之外,依照硬體ISA支援的部分,將對應的H(hypervisor) Mode、S(supervisor) Mode、U(user) Mode中,讓Trigger在這些Mode下都能夠作用;
最後是將execute、load、store依照所需,設定對應的部分!
Watchpoint也是這是Debugger最重要的的功能之一,主要是負責當程式讀/寫指定的位置時候後,能夠停下來,等待進一步的處理!
不像Breakpoint一樣分成兩種,Watchpoint一定要硬體支援才行!
先來看看進入點的部分,參考以下內容(src/target/riscv/riscv.c):
int riscv_add_watchpoint(struct target *target, struct watchpoint *watchpoint)
{
struct trigger trigger;
trigger_from_watchpoint(&trigger, watchpoint);
int result = add_trigger(target, &trigger);
if (result != ERROR_OK)
return result;
watchpoint->set = true;
return ERROR_OK;
}
可以看到OpenOCD在負責處理GDB重送過來的要求後,會把幾本的資料放入struct watchpoint中,基本定義如下!
參考以下內容(src/target/watchpoint.h):
struct watchpoint {
target_addr_t address;
uint32_t length;
uint32_t mask;
uint32_t value;
enum watchpoint_rw rw;
int set;
struct watchpoint *next;
int unique_id;
};
裡面幾本上包含以下資訊:
再來就是很簡單的呼叫trigger_from_watchpoint()
和add_trigger()
來處理!
而這個處理的過程在「Day 13: 了解Trigger Module的神秘面紗(下)~~!」中也有稍微剖析過!
首先是trigger_from_watchpoint()的部分,
參考以下內容(src/target/watchpoint.h):
static void trigger_from_watchpoint(struct trigger *trigger,
const struct watchpoint *watchpoint)
{
trigger->address = watchpoint->address;
trigger->length = watchpoint->length;
trigger->mask = watchpoint->mask;
trigger->value = watchpoint->value;
trigger->read = (watchpoint->rw == WPT_READ || watchpoint->rw == WPT_ACCESS); ///譯註: 這行很重要,用來標示這是個Read Watchpoint或是Access Watchpoint
trigger->write = (watchpoint->rw == WPT_WRITE || watchpoint->rw == WPT_ACCESS); ///譯註: 這行很重要,用來標示這是個Write Watchpoint或是Access Watchpoint
trigger->execute = false;
/* unique_id is unique across both breakpoints and watchpoints. */
trigger->unique_id = watchpoint->unique_id;
}
類似trigger_from_breakpoint(),將對應的資料設定好!
主要需要注意的地方只有read、write、execute設定上有差異外,基本流程都一樣!
最後是呼叫add_trigger()
來處理!
這部分已經在上面的"# 1.4 Add Trigger"中提到,就不需要再重複說明!
最後是移除Trigger的部分!
由於這部分比較簡單些,將Breakpoint和Watchpoint在這邊統一剖析!
一樣先來剖一下上層的進入點,參考以下內容(src/target/riscv/riscv.c):
int riscv_remove_breakpoint(struct target *target,
struct breakpoint *breakpoint)
{
if (breakpoint->type == BKPT_SOFT) {
if (target_write_memory(target, breakpoint->address, breakpoint->length, 1,
breakpoint->orig_instr) != ERROR_OK) {
LOG_ERROR("Failed to restore instruction for %d-byte breakpoint at "
"0x%" TARGET_PRIxADDR, breakpoint->length, breakpoint->address);
return ERROR_FAIL;
}
} else if (breakpoint->type == BKPT_HARD) {
struct trigger trigger;
trigger_from_breakpoint(&trigger, breakpoint);
int result = remove_trigger(target, &trigger);
if (result != ERROR_OK)
return result;
} else {
LOG_INFO("OpenOCD only supports hardware and software breakpoints.");
return ERROR_TARGET_RESOURCE_NOT_AVAILABLE;
}
breakpoint->set = false;
return ERROR_OK;
}
上面章節有提到過Breakpoint分成兩種:
當然這邊處理的流程也稍微不同!
首先是Software Breakpoint的部分:
if (target_write_memory(target, breakpoint->address, breakpoint->length, 1,
breakpoint->orig_instr) != ERROR_OK) {
LOG_ERROR("Failed to restore instruction for %d-byte breakpoint at "
"0x%" TARGET_PRIxADDR, breakpoint->length, breakpoint->address);
return ERROR_FAIL;
}
幾本上就是把原先breakpoint中紀錄的orig_instr給寫回去原本的位置!
搞定收工!
再來是Hardware Breakpoint的部分:
struct trigger trigger;
trigger_from_breakpoint(&trigger, breakpoint);
int result = remove_trigger(target, &trigger);
if (result != ERROR_OK)
return result;
不得不說我覺得這個實作有點多餘XD
因為他先呼叫了trigger_from_breakpoint(),將breakpoint轉成trigger的資料,
比如以下的實作部分,參考以下內容(src/target/riscv/riscv.c):
static void trigger_from_breakpoint(struct trigger *trigger,
const struct breakpoint *breakpoint)
{
trigger->address = breakpoint->address;
trigger->length = breakpoint->length;
trigger->mask = ~0LL;
trigger->read = false;
trigger->write = false;
trigger->execute = true;
/* unique_id is unique across both breakpoints and watchpoints. */
trigger->unique_id = breakpoint->unique_id;
}
不過基本上會用到的資料也只有那個unique_id而已....
最後,呼叫remove_trigger()來移除這個Trigger!
基本實作如下,參考以下內容(src/target/riscv/riscv.c):
static int remove_trigger(struct target *target, struct trigger *trigger)
{
RISCV_INFO(r);
int first_hart = -1;
for (int hartid = 0; hartid < riscv_count_harts(target); ++hartid) {
if (!riscv_hart_enabled(target, hartid))
continue;
if (first_hart < 0) {
first_hart = hartid;
break;
}
}
assert(first_hart >= 0);
unsigned int i;
///譯註: 這裡最重要,要找到這個Breakpoint對應的Trigger編號!
for (i = 0; i < r->trigger_count[first_hart]; i++) {
if (r->trigger_unique_id[i] == trigger->unique_id)
break;
}
if (i >= r->trigger_count[first_hart]) {
LOG_ERROR("Couldn't find the hardware resources used by hardware "
"trigger.");
return ERROR_FAIL;
}
LOG_DEBUG("Stop using resource %d for bp %d", i, trigger->unique_id);
for (int hartid = first_hart; hartid < riscv_count_harts(target); ++hartid) {
if (!riscv_hart_enabled(target, hartid))
continue;
riscv_reg_t tselect = riscv_get_register_on_hart(target, hartid, GDB_REGNO_TSELECT);
///譯註: 清除Trigger
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TSELECT, i);
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TDATA1, 0);
riscv_set_register_on_hart(target, hartid, GDB_REGNO_TSELECT, tselect);
}
r->trigger_unique_id[i] = -1;
return ERROR_OK;
}
基本流程就是先用unique_id找到對應的Trigger點,然後就....
直接清成"0"就行了XD!
最後是移除Watchpoint的進入點,參考以下內容(src/target/riscv/riscv.c):
int riscv_remove_watchpoint(struct target *target,
struct watchpoint *watchpoint)
{
struct trigger trigger;
trigger_from_watchpoint(&trigger, watchpoint);
int result = remove_trigger(target, &trigger);
if (result != ERROR_OK)
return result;
watchpoint->set = false;
return ERROR_OK;
}
一樣先呼叫trigger_from_watchpoint()將Watchpoint轉成Trigger的資料結構,
然後..... 就呼叫remove_trigger()來移除!
收工!
本篇詳細的說明Software breakpoint、Hardware breakpoint和Watchpoint的部分,
當然,對比原本的Spec中,Trigger還包含多種match、length、mask等應用,
比方說希望能夠在某個變數大於0x10000時候才停下來,這邊的實作"完全做不到"XD!
等日後有更進一步的實作,應該會再另闢新文說明!如果有時間的話
沒意外的話,明天應該就會是這個系列的最終章!
再來就要換全新的主題啦!!!