ลิงก์ยังไม่เคยมา แต่ PLC บอกว่าลิงก์ปกติ 3 วินาทีแรกหลังเปิดเครื่อง
ตัวจับเวลาที่ยังนับไม่ครบ ไม่ได้แปลว่าลิงก์ดี มันแปลแค่ว่ายังไม่มีข้อมูลพอจะบอกว่าลิงก์เสีย และ fail-safe ที่แยกสองอย่างนี้ไม่ออกจะเปิดช่องให้เอาต์พุตขยับได้ทุกครั้งที่เปิดเครื่อง
สรุปใน 1 นาที
- อาการ/ปัญหา
- 3 วินาทีแรกหลังเปิดเครื่อง watchdog บอกว่าลิงก์ปกติ ทั้งที่ยังไม่เคยเห็น heartbeat
- ใครควรอ่าน
- คนเขียนลิงก์ระหว่าง PLC ที่ใช้ watchdog แบบ NOT timeout
- ทางแก้แบบสั้น
- เพิ่มบิต HbRcvd ให้ลิงก์ปกติได้ก็ต่อเมื่อเคยเห็น heartbeat แล้ว และเพิ่มข้อทดสอบ T0 ที่ตรวจตั้งแต่ scan แรก
พอดีผมทำงานย้าย I/O ข้าม PLC ให้โรงงานแห่งหนึ่ง ส่งโปรแกรมรุ่นแรกไปแล้ว 12 ไฟล์ ผ่านชุดทดสอบบน simulator ครบทุกตัว วันรุ่งขึ้นผมนั่งทบทวนโค้ดของตัวเองอีกรอบ แล้วเจอว่า watchdog ของลิงก์ที่เขียนไว้ บอกว่าลิงก์ปกติตลอด 3 วินาทีแรกหลังเปิดเครื่อง ทั้งที่ยังไม่เคยได้ heartbeat จากอีกฝั่งเลยสักครั้ง ถ้าช่วงนั้นมีบิตคำสั่งค้างอยู่ในฝั่งรับ เอาต์พุตของวาล์วขยับได้ ชุดทดสอบของผมไม่เคยจับเรื่องนี้ได้ เพราะมันเริ่มมองตอนวินาทีที่ 5 บทความนี้ผมเล่าว่าช่องโหว่อยู่ตรงไหน ทำไมการทดสอบถึงมองไม่เห็น แก้อย่างไร และต้องทำอะไรกับของที่ส่งไปแล้ว
ค่าเวลา 3 วินาที ท่าปลอดภัยเมื่อลิงก์หาย และชื่อตัวแปรทั้งหมดผูกกับเครื่องจักรชุดเดียว และถูกเปลี่ยนเป็นชื่อกลางแล้ว logic ที่สั่งเอาต์พุตของเครื่องจักรจริงต้องให้วิศวกรระบบควบคุมที่รับผิดชอบเครื่องนั้นประเมินความเสี่ยง ทดสอบ และลงนามรับรอง ฟังก์ชันที่เกี่ยวกับความปลอดภัยของคนต้องเป็นวงจร hardwired หรือ safety PLC ไม่ใช่ logic ปกติแบบในบทความนี้
ระบบที่ทำ PLC สองตัวแลกบิตกันผ่านลิงก์ TCP เดิม
เครื่องชุดนี้มี PLC สองตัวที่เกี่ยวข้อง ผมขอเรียกว่า Main PLC กับ Station PLC ทั้งคู่เป็น S7-1500 เขียนบน TIA Portal V15.1 และคุยกันอยู่แล้วด้วย TSEND_C กับ TRCV_C บนลิงก์ TCP ที่มีมาแต่เดิม งานของผมคือเพิ่มบิตลงไปในลิงก์นั้น ฝั่งกลางเป็นคนคิดคำสั่ง Up กับ Down ของกระบอกลม แล้วส่งข้ามไป ฝั่งสถานีเป็นคนถือเอาต์พุตของวาล์วจริง และส่งสถานะเซนเซอร์ตรวจชิ้นงานกลับมา
พอคำสั่งของเอาต์พุตต้องเดินทางผ่านเครือข่าย คำถามแรกที่ต้องตอบคือถ้าลิงก์หายจะเกิดอะไรขึ้น ผมเลยให้ทั้งสองฝั่งส่ง heartbeat 1 Hz ให้กัน คือบิตที่สลับค่าทุกครึ่งวินาที แล้วตั้งกติกา fail-safe ไว้สองข้อ ข้อแรก ถ้าฝั่งสถานีไม่เห็น heartbeat จากฝั่งกลาง ให้บังคับกระบอกลมไปท่า UP และห้าม DOWN ข้อสอง ถ้าฝั่งกลางไม่เห็น heartbeat จากฝั่งสถานี ให้ถือว่าตำแหน่งนั้นมีชิ้นงานอยู่ ฝั่งกลางจะได้ไม่ส่งชิ้นถัดไปเข้ามาชน
กติกาสองข้อนี้ถูกต้องตามที่ตกลงกับเจ้าของงาน ที่ผิดคือวิธีที่ผมตัดสินว่า "ลิงก์ยังอยู่"
โค้ดรุ่นแรก กับหน้าต่าง 3 วินาทีที่ไม่มีใครเฝ้า
รุ่นแรกผมเขียน watchdog แบบที่เห็นกันทั่วไป เอาตัวจับเวลา TON มาจับว่า heartbeat ไม่เปลี่ยนค่านานเกิน 3 วินาทีหรือยัง ถ้ายังไม่เกินก็ถือว่าลิงก์ดี
// รุ่นแรก (ผิด) - ฝั่ง Station PLC
D.HbTimer.TON(IN := (Rx.Hb = D.HbPrev), PT := T#3S);
D.HbPrev := Rx.Hb;
D.PeerOK := NOT D.HbTimer.Q;
Valve_Down := Rx.DownCmd AND D.PeerOK;
Valve_Up := Rx.UpCmd OR NOT D.PeerOK;
อ่านผ่าน ๆ ดูถูกต้อง heartbeat นิ่งครบ 3 วินาทีเมื่อไร Q ขึ้น PeerOK ตก วาล์วถูกบังคับ UP แต่ลองไล่ตั้งแต่ scan แรกหลัง power-up ตัวจับเวลาเพิ่งเริ่มนับ Q ยังเป็น FALSE แปลว่า PeerOK เป็น TRUE ทันที ทั้งที่ PLC ตัวนี้ยังไม่เคยได้รับ heartbeat แม้แต่ครั้งเดียว อีกฝั่งอาจจะยังไม่เปิดเครื่อง สายอาจจะยังไม่ได้เสียบ โค้ดบรรทัดนี้ก็ยังตอบว่าลิงก์ปกติอยู่ 3 วินาทีเต็ม
ลำพัง PeerOK เป็น TRUE ผิดยังไม่ทำให้วาล์วขยับ ต้องมีบิตคำสั่งเป็น TRUE อยู่ในฝั่งรับด้วย ซึ่งเกิดได้จริงสองทาง ทางแรกคือ receive DB เก็บค่าแบบ retain บิต Down ที่ค้างมาจากก่อนดับไฟก็ยังอยู่ ทางที่สองคือค่าที่ partner ยังไม่ทันส่งมาเขียนทับ ถ้าบิต Down ในฝั่งรับค้างเป็น TRUE อยู่ บรรทัด Valve_Down := Rx.DownCmd AND D.PeerOK จะสั่งวาล์วลงได้ในช่วง 3 วินาทีนั้น โดยที่ไม่มีใครสั่งจริง
ทำไมชุดทดสอบ T1-T6 ถึงไม่เคยเห็น
โปรแกรมรุ่นแรกไม่ได้ส่งไปแบบไม่ทดสอบ ผมเขียน self-test เป็น OB แยกไว้ในสำเนาทดสอบ มีข้อ T1 ถึง T6 รันบน PLCSIM ครบทุก PLC และผ่านทั้งหมดก่อนส่งงาน ปัญหาอยู่ที่ step แรกของ self-test เอง
// step แรกของ self-test รุ่นเดิม
CASE T.Step OF
0: T.Wait.TON(IN := TRUE, PT := T#5S); // รอให้ระบบ "นิ่ง" ก่อน
IF T.Wait.Q THEN
T.T1 := NOT D.PeerOK; // ตรวจ PeerOK ครั้งแรกตรงนี้
T.Step := 10;
END_IF;
END_CASE;
ผมตั้งใจรอ 5 วินาทีเพื่อให้ตัวจับเวลา 3 วินาทีครบแน่ ๆ ก่อนจะตรวจว่า PeerOK ตกจริงเมื่อไม่มี heartbeat ผลคือข้อทดสอบผ่าน แต่ไม่มีข้อไหนมองช่วง 0 ถึง 3 วินาทีเลย หน้าต่างที่มีปัญหาอยู่ในช่วงที่ชุดทดสอบจงใจหลับตา
การเดาที่ผิดของผมคือคิดว่าทดสอบ steady state ครบแล้วก็พอ คือลิงก์หายแล้ว PeerOK ตก ลิงก์กลับมาแล้ว PeerOK ขึ้น สองกรณีนี้ผ่านหมด ผมเลยสรุปเองว่า fail-safe ใช้ได้ ทั้งที่จังหวะที่ fail-safe สำคัญที่สุดคือตอนเปิดเครื่อง ซึ่งเป็นจังหวะที่ทุกอย่างยังไม่มีข้อมูล
ผมเขียนตัวจำลองสั้น ๆ ไว้เทียบสองแบบบนโต๊ะ ไม่ต้องเปิด simulator ของ PLC สมมติ scan ละ 10 ms และ heartbeat ไม่เคยมาเลย 5 วินาที
SCAN_MS, PT_MS = 10, 3000
def run(use_seen, hb_series):
et, hb_last, seen, ok_scans = 0, False, False, 0
for hb in hb_series:
if hb != hb_last:
seen = True # เคยเห็น heartbeat เปลี่ยนค่าแล้ว
et = min(et + SCAN_MS, PT_MS) if hb == hb_last else 0
q = et >= PT_MS # TON.Q
hb_last = hb
link_ok = (seen and not q) if use_seen else (not q)
ok_scans += link_ok
return ok_scans
never = [False] * 500 # 5 วินาที ไม่มี heartbeat
print('รุ่นแรก PeerOK เป็น TRUE', run(False, never), 'scan')
print('รุ่นแก้ PeerOK เป็น TRUE', run(True, never), 'scan')
รุ่นแรกได้ 299 scan คือเกือบ 3 วินาทีเต็มที่ PeerOK เป็น TRUE โดยไม่มีหลักฐานอะไรรองรับ รุ่นแก้ได้ 0 ตัวจำลองนี้เป็นแค่แบบคิดบนโต๊ะ ไม่ใช่ผลวัดจากเครื่อง แต่มันทำให้เห็นว่าบั๊กแบบนี้หาได้ด้วยการไล่ scan แรก ไม่ต้องรอให้เครื่องจริงบอก
แก้ด้วย HbRcvd ต้องเคยเห็นก่อนถึงจะเชื่อ
ทางแก้คือแยกสองสถานะที่รุ่นแรกเหมารวมกันออกจากกัน "ยังไม่หมดเวลา" กับ "เคยเห็น heartbeat แล้วและยังไม่หมดเวลา" ผมเพิ่มบิต HbRcvd ที่ latch เป็น TRUE เมื่อเห็น heartbeat เปลี่ยนค่าครั้งแรก แล้วเอาไป AND กับเงื่อนไขเดิม
// รุ่นแก้ - ฝั่ง Station PLC
IF Rx.Hb <> D.HbPrev THEN
D.HbRcvd := TRUE;
END_IF;
D.HbTimer.TON(IN := (Rx.Hb = D.HbPrev), PT := T#3S);
D.HbPrev := Rx.Hb;
D.PeerOK := D.HbRcvd AND NOT D.HbTimer.Q;
Valve_Down := Rx.DownCmd AND D.PeerOK;
Valve_Up := Rx.UpCmd OR NOT D.PeerOK;
// ฝั่ง Main PLC ใช้ PeerOK แบบเดียวกัน
Prox := Rx.Prox OR NOT D.PeerOK; // ลิงก์หาย = ถือว่ามีชิ้นงาน
PeerOK := NOT HbTimer.Q
ค่าเริ่มต้นหลังเปิดเครื่องคือ TRUE ต้องรอให้ตัวจับเวลานับครบ 3 วินาทีก่อนถึงจะกลายเป็น FALSE ระหว่างนั้นคำสั่งที่ค้างอยู่ในฝั่งรับผ่านออกไปถึงเอาต์พุตได้ พูดอีกแบบคือระบบเชื่อว่าลิงก์ดีจนกว่าจะพิสูจน์ได้ว่าเสีย
PeerOK := HbRcvd AND NOT HbTimer.Q
ค่าเริ่มต้นหลังเปิดเครื่องคือ FALSE วาล์วอยู่ท่า UP และห้าม DOWN ไปจนกว่าจะเห็น heartbeat เปลี่ยนค่าจริงหนึ่งครั้ง ระบบเชื่อว่าลิงก์เสียจนกว่าจะพิสูจน์ได้ว่าดี ซึ่งเป็นทิศที่ fail-safe ควรจะเป็น
มีจุดที่ต้องดูเพิ่มเมื่อเอาแนวนี้ไปใช้ คือบิต HbRcvd ต้องกลับเป็น FALSE ทุกครั้งที่ CPU เริ่มทำงานใหม่ ถ้า DB ที่เก็บมันถูกตั้งเป็น retain บิตนี้จะค้างเป็น TRUE ข้ามการดับไฟ แล้วช่องโหว่เดิมก็กลับมาในรูปใหม่ ต้องเปิดดูการตั้งค่า retain ของ DB ตัวนั้นด้วยตาให้แน่ใจ
ข้อทดสอบ T0 ตรวจตั้งแต่ scan แรก
แก้โค้ดแล้วยังไม่จบ เพราะชุดทดสอบเดิมก็ยังหลับตาช่วงเดิมอยู่ ถ้ารันชุดเดิมกับโค้ดรุ่นแรกหรือรุ่นแก้ ผลจะออกมาผ่านเหมือนกัน ชุดทดสอบที่แยกของผิดกับของถูกไม่ออกถือว่ายังไม่มีข้อทดสอบเรื่องนี้ ผมเลยเพิ่มข้อ T0 ที่ทำงานกลับด้านกับข้ออื่น คือเริ่มที่ผ่าน แล้วตกทันทีที่เห็นสิ่งที่ไม่ควรเกิด
// T0: ระหว่างที่ยังไม่มี heartbeat PeerOK ต้องไม่เคยเป็น TRUE แม้แต่ scan เดียว
IF NOT T.Init THEN
T.T0 := TRUE;
T.Step := 0;
T.Init := TRUE;
END_IF;
IF T.Step = 0 AND D.PeerOK THEN
T.T0 := FALSE; // latch ตก แล้วไม่กลับขึ้นอีก
END_IF;
// ... ข้ออื่นทำงานตาม step เดิม ...
T.AllPass := T.Done AND T.T0 AND T.T1 AND T.T2 AND T.T3
AND T.T4 AND T.T5;
ข้อนี้ทำงานทุก scan ตลอดช่วงที่ Step ยังเป็น 0 ซึ่งคือช่วงที่ self-test ยังไม่ได้ป้อน heartbeat จำลองเข้ามา ถ้า PeerOK โผล่เป็น TRUE แม้ scan เดียว T0 จะตกและค้างอยู่อย่างนั้นจนจบการทดสอบ มันเลยจับได้ตั้งแต่ scan ที่ 1 ไม่ต้องพึ่งจังหวะที่คนทดสอบบังเอิญมองทัน
ผลหลังแก้คือ T0 ถึง T5 ผ่านครบบน simulator ทั้ง 4 PLC ที่เกี่ยวข้อง ส่วนโค้ดรุ่นแรกนั้น ไล่ตามตรรกะของข้อ T0 แล้วจะตกตั้งแต่ scan ที่ 1 เพราะ PeerOK เป็น TRUE ทันที ข้อทดสอบที่ใช้ได้ต้องทำให้ของผิดตก ไม่ใช่แค่ทำให้ของถูกผ่าน
ของที่ส่งไปแล้ว ต้องย้อนกลับไปแก้ทั้งชุด
ส่วนที่ไม่สนุกที่สุดคือรุ่นแรกส่งไปแล้ว ผมเลือกทางที่ตรงที่สุด คือ build รุ่นสองใหม่ทั้ง 12 ไฟล์ ย้ายรุ่นแรกเข้าโฟลเดอร์ _old แล้วบอกผู้รับตรง ๆ ว่ารุ่นแรกผิดตรงไหน ผิดเพราะอะไร และรุ่นที่ถูกคือชุดไหน ไม่เขียนเลี่ยงว่าเป็นการ "ปรับปรุง"
- หาว่าสำนวนที่ผิดอยู่ที่ไหนบ้าง ไม่ใช่แก้เฉพาะไฟล์ที่เปิดเจอ ค้นทุก source ที่สร้างออกไป
- แก้ที่ต้นทางตัวเดียว คือตัวสร้างโค้ด แล้วสร้างใหม่ทั้งชุด ไม่แก้มือรายไฟล์
- เพิ่มข้อทดสอบที่ทำให้ของรุ่นเก่าตก ก่อนจะเชื่อว่าของรุ่นใหม่ผ่าน
- รันชุดทดสอบใหม่บนทุก PLC ไม่ใช่เฉพาะตัวที่คิดว่าเกี่ยว
- ย้ายรุ่นเก่าออกจากโฟลเดอร์ส่งมอบทันที ไม่ปล่อยให้สองรุ่นอยู่ปนกัน
- แจ้งผู้รับเป็นลายลักษณ์อักษร ระบุว่าไฟล์ชุดไหนห้ามใช้
ขั้นแรกทำได้ด้วยการค้นข้อความใน source ที่สร้างไว้ หาทุกบรรทัดที่กำหนดค่า PeerOK แต่ไม่มี HbRcvd อยู่ในบรรทัดเดียวกัน
Get-ChildItem .\changesets -Recurse -Filter *.scl |
Select-String -Pattern 'PeerOK\s*:=' |
Where-Object { $_.Line -notmatch 'HbRcvd' } |
ForEach-Object { '{0}:{1} {2}' -f $_.Filename, $_.LineNumber, $_.Line.Trim() }
ผลที่ต้องการหลังแก้คือไม่มีบรรทัดไหนถูกพิมพ์ออกมา ส่วนการแยกรุ่นเก่าออก ผมทำก่อนวางของใหม่ลงไป จะได้ไม่มีจังหวะที่โฟลเดอร์ส่งมอบมีสองรุ่นอยู่พร้อมกัน
$old = '.\deliver\_old'
New-Item -ItemType Directory -Force -Path $old | Out-Null
Get-ChildItem .\deliver -File | Move-Item -Destination $old
# จากนั้นค่อยคัดลอกรุ่นสองที่ build ใหม่เข้ามา แล้วนับจำนวนไฟล์ให้ครบ 12
(Get-ChildItem .\deliver -File).Count
เมื่อพบว่าชุดทดสอบมองไม่เห็นบางช่วง สิ่งที่ผ่านชุดทดสอบนั้นมาก่อนหน้าทั้งหมดต้องถือว่ายังไม่ได้ทดสอบในช่วงนั้น ไม่ใช่เฉพาะไฟล์ที่กำลังเปิดอยู่ งานนี้โชคดีที่ของทั้งหมดสร้างจากตัวสร้างโค้ดตัวเดียว แก้ที่เดียวแล้วสร้างใหม่ได้ครบ ถ้าเป็นงานที่แก้มือรายไฟล์ ผมคงต้องไล่เปิดทีละตัว
ของแถม heartbeat ที่ไม่แกว่งบน S7-1500
ในงานเดียวกันยังมีอีกเรื่องที่เจอระหว่างทดสอบ คือตัวสร้าง heartbeat เอง รุ่นแรกผมเขียนด้วยสำนวนที่ใช้กันมาตั้งแต่ยุค S7-300 เอา Q ของตัวจับเวลาย้อนกลับไปป้อน IN ของตัวเอง ให้มัน reset ตัวเองทุกรอบ
// สำนวนเดิม ไม่แกว่งบน S7-1500
T(IN := NOT T.Q, PT := T#500MS);
IF T.Q THEN
HbTx := NOT HbTx;
END_IF;
บน PLCSIM ของ S7-1500 heartbeat ที่ได้จากโค้ดชุดนี้นิ่งตลอด ไม่สลับค่าเลย เหตุผลคือ IEC timer บน S7-1500 อัปเดตค่า Q ตอนที่เรียกหรืออ่าน instance จังหวะที่ Q จะเป็น TRUE ให้บรรทัด IF เห็นเลยไม่เกิดขึ้นตามที่สำนวนเดิมคาดไว้ แบบที่ใช้ได้คือให้ตัวจับเวลาวิ่งด้วย IN ที่เป็น TRUE ตรง ๆ แล้วสั่ง reset เองอย่างชัดเจนในจังหวะที่ Q ขึ้น
// แบบที่ใช้ได้
HbOsc.TON(IN := TRUE, PT := T#500MS);
IF HbOsc.Q THEN
HbTx := NOT HbTx;
HbOsc.TON(IN := FALSE, PT := T#500MS); // reset ตัวจับเวลา
END_IF;
Tx.Hb := HbTx;
เรื่องนี้เกี่ยวกับเรื่องแรกตรงที่ ถ้า heartbeat ฝั่งส่งไม่แกว่ง ฝั่งรับที่ใช้ PeerOK รุ่นแก้จะไม่มีวันเห็นลิงก์ดีเลย ซึ่งเป็นพฤติกรรมที่ถูก ส่วนฝั่งรับที่ใช้รุ่นแรกจะเห็นลิงก์ดีอยู่ 3 วินาทีแล้วตก อาการของบั๊กตัวหนึ่งเลยถูกอีกตัวหนึ่งบังไว้บางส่วน ผมเขียนเรื่องการเฝ้าของที่ค้างแต่ process ยังอยู่ไว้อีกมุมหนึ่งใน บทความเรื่อง restart แล้วไม่หายเพราะ daemon ค้าง แนวคิดเดียวกันคือตัวเฝ้าต้องดูหลักฐานว่างานเดินจริง ไม่ใช่ดูว่ายังไม่มีสัญญาณเสีย
กฎที่ได้ และสิ่งที่ยังไม่ได้ทดสอบ
สรุปคือ fail-safe ทุกตัวที่ผมเขียนต่อจากนี้ต้องมีข้อทดสอบอย่างน้อยสามจังหวะ ไม่ใช่สอง
| จังหวะ | สิ่งที่ต้องเป็นจริง | รุ่นแรกมีข้อทดสอบไหม |
|---|---|---|
| scan แรกหลัง power-up ยังไม่มี heartbeat | PeerOK เป็น FALSE ตั้งแต่ scan ที่ 1 | ไม่มี |
| ลิงก์หายหลังจากเคยดี | PeerOK ตกภายในเวลาที่กำหนด | มี |
| ลิงก์กลับมา | PeerOK ขึ้นเมื่อ heartbeat เปลี่ยนค่าอีกครั้ง | มี |
ข้อที่สองของกฎคือห้ามใส่ช่วงรอไว้หน้าข้อทดสอบแรกโดยไม่มีอะไรเฝ้าช่วงรอนั้น ถ้าจำเป็นต้องรอ ต้องมีข้อทดสอบแบบ latch ตกอย่าง T0 ครอบช่วงนั้นไว้เสมอ
ข้อแรก ลิงก์ TCP จริงระหว่าง PLC สองตัวยังไม่ได้ทดสอบ การทดสอบทั้งหมดเป็นการป้อน heartbeat จำลองทีละ PLC บน simulator เหตุผลอยู่ใน บทความเรื่อง PLCSIM กับ PROFINET interface ตัวที่สอง ข้อสอง ท่า UP เมื่อลิงก์หายปลอดภัยทุกจุดของเครื่องหรือไม่ เป็นเรื่องที่ไฟล์โปรแกรมบอกไม่ได้ ต้องให้คนหน้างานที่รู้จักเครื่องยืนยันก่อนใช้งานจริง
เรื่องที่ผมได้จากงานนี้มากกว่าตัวโค้ด คือการทดสอบที่ผ่านบอกได้แค่ว่าช่วงที่มองไม่มีปัญหา ไม่ได้บอกอะไรเกี่ยวกับช่วงที่ไม่ได้มอง และช่วงที่ไม่ได้มองมักเป็นช่วงที่ผมจงใจข้ามเองเพื่อให้การทดสอบเขียนง่าย ใครมี watchdog หน้าตาแบบรุ่นแรกอยู่ในเครื่อง ลองไล่ scan แรกดูสักรอบ ใช้เวลาไม่กี่นาที ตรงไหนผมอธิบายพฤติกรรมของตัวจับเวลาคลาดเคลื่อน เดี๋ยวกลับมาแก้เพิ่มให้ภายหลังครับ
เคยเจองานแบบนี้ไหม? ติดตรงไหน?
มาแลกวิธีกันต่อในบอร์ด LinuxThai



ความคิดเห็น 0
ยังไม่มีความคิดเห็น — เป็นคนแรกเลย