Loop แท้ไม่มีอยู่จริง มีแต่ความจริงซึ่งคนโง่ยอมรับไม่ได้
อะ อะจ๊ะเอ๋ตัวเอง ท่านผู้เจริญซึ่งมากไปด้วยปัญญา เดี๋ยวลงไปคุยกันข้างล่างนะครับ อ๊ะฮ่า ดูไปคิดไป วันนี้คือวันที่ 25 ตุลาคม 2567 เมื่อหลายเดือนก่อน เราไปเล่าเรื่องกันขำ ๆ ว่า ๆ จริง ๆ แล้วพวก Loop ที่เราใช้เขียนโปรแกรมกันอยู่ มันไม่มีอยู่จริง สิ่งที่เราใช้งานกันมันพยายาม Abstract บางอย่างออกไป วันนี้เราจะมาถอดการทำงานของ Loop จริง ๆ กันว่า มันทำงานอย่างไรกันแน่ ผ่านภาษา Assembly
ปล. สำหรับใครที่อ่าน Assembly ไม่ออก ไม่ต้องกังวลเด้อ จะพยายามอธิบายละเอียดมากขึ้นให้ พวก Document ของคำสั่งต่าง ๆ ที่ใช้ เราแปะลิงค์ไว้ให้หมดแล้วเด้อ
ปล2. เรา Compile Target เป็น ARM64 สำหรับ Apple Silicon ดังนั้น หากใครเอาไป Compile Target เป็น x86_64 หรือ AMD64 คำสั่งที่ได้มันอาจจะแตกต่างกัน แต่หลักการทำงานจะคล้ายกันเด้อ
ปล3. Document ของ ARM64 สามารถเข้าถึงได้จากเว็บนี้
Compile C Source Code to Assembly
#include <stdio.h>
int main () {
int a = 0;
for (int i=0;i<10;i++)
a += i;
printf("%d", a);
return 0;
}
เพื่อจำลองให้เห็นภาพ เราจะใช้ Source Code ง่าย ๆ ในภาษา C นั่นคือให้มันหาผลลัพธ์ของ 1+2+...+9 ไปเรื่อย ๆ และสุดท้ายบวกเสร็จ ค่อยให้มันเอาผลลัพธ์แสดงออกทางหน้าจอ
gcc main.c -S -o main
จากนั้น เราจะทำการ Compile มันให้เป็น Assembly กันโดยใช้ C Compiler อย่าง gcc แต่เราเลือกใส่ Flag -S เพื่อบอกให้มันแปลงเป็นรูปแบบของ Assembly Language นั่นเอง
.section __TEXT,__text,regular,pure_instructions
.build_version macos, 15, 0 sdk_version 15, 0
.globl _main ; -- Begin function main
.p2align 2
_main: ; @main
.cfi_startproc
; %bb.0:
sub sp, sp, #48
stp x29, x30, [sp, #32] ; 16-byte Folded Spill
add x29, sp, #32
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
stur wzr, [x29, #-4]
stur wzr, [x29, #-8]
stur wzr, [x29, #-12]
b LBB0_1
LBB0_1: ; =>This Inner Loop Header: Depth=1
ldur w8, [x29, #-12]
subs w8, w8, #10
cset w8, ge
tbnz w8, #0, LBB0_4
b LBB0_2
LBB0_2: ; in Loop: Header=BB0_1 Depth=1
ldur w9, [x29, #-12]
ldur w8, [x29, #-8]
add w8, w8, w9
stur w8, [x29, #-8]
b LBB0_3
LBB0_3: ; in Loop: Header=BB0_1 Depth=1
ldur w8, [x29, #-12]
add w8, w8, #1
stur w8, [x29, #-12]
b LBB0_1
LBB0_4:
ldur w9, [x29, #-8]
; implicit-def: $x8
mov x8, x9
mov x9, sp
str x8, [x9]
adrp x0, l_.str@PAGE
add x0, x0, l_.str@PAGEOFF
bl _printf
mov w0, #0 ; =0x0
ldp x29, x30, [sp, #32] ; 16-byte Folded Reload
add sp, sp, #48
ret
.cfi_endproc
; -- End function
.section __TEXT,__cstring,cstring_literals
l_.str: ; @.str
.asciz "%d"
.subsections_via_symbols
และนี่คือสิ่งที่เราจะได้ออกมาจากการ Compile แต่ต้องบอกก่อนนะว่า ผลลัพธ์ที่ออกมาอาจจะแตกต่างกันได้ เนื่องจาก มันต้อง Compile ตาม Target System ที่เรากำหนด เช่นในตัวอย่างนี้ เรา Compile โดยมี Target เป็น ARM64 ดังนั้น ในวันนี้คำสั่งที่เราจะยกขึ้นมาเล่าทั้งหมดอ้างอิงจาก Document ของ ARM เป็นหลักเด้อ ส่วนถ้าใครใช้ x86 ก็ลองไปหาอ่าน Document ของ CPU ตัวเองได้เด้อว่า เขาใช้อะไร แต่ตัวหลักการทั้งหมดจะเหมือนกันอยู่แล้วไม่ต้องเป็นห่วง
Basic Structure
_main:
.cfi_startproc
LBB0_1:
LBB0_2:
LBB0_3:
LBB0_4:
.cfi_endproc
l_.str:
.asciz "%d"
.subsections_via_symbols
ถ้าเราลองแยกส่วน Source Code ออกมา เราจะพบว่าส่วนที่เป็นโปรแกรม จะมีทั้งหมด 5 ส่วนด้วยกัน เดาไม่ยาก โปรแกรมจะเริ่มต้นจาก _main: ก่อน โดยเราจะเห็นว่า บรรทัดแรกของมันเรียกคำสั่งสำหรับการเริ่มโปรแกรมจากนั้น มันจะทำงานภายใน Main ไปเรื่อย ๆ พูดง่าย ๆ ก็คือ มันอยู่ใน Main Function ที่เราเขียนไปนั่นเอง
ถัดลงไปคือพวก LBB0_1 ไปเรื่อย ๆ จนถึง LBB0_4 จริง ๆ มันไม่ได้เป็นชื่อเฉพาะอะไร มันเป็นแค่ชื่อ Section ที่ gcc เป็นคน Generate ขึ้นมา โดย gcc มันจะแยกส่วนของ Source Code ออกมาเป็น Scope ต่าง ๆ ที่เดี๋ยวเราจะเข้าไปดูให้ลึกขึ้นอีกที แต่การทำงานของมันก็คือ เริ่มจาก Main แล้วกระโดดไปตาม LBB หรือส่วนอื่น ๆ ของ โปรแกรมไปเรื่อย ๆ จนไปจบตรงที่เขียนว่า .cfi_endproc นั่นเอง
ส่วนสุดท้ายที่เรายังไม่ได้เล่าคือ l_.str อันนี้ก็เป็นอีก Section ที่ gcc Generate ออกมาให้เช่นกัน แต่เราเดาได้ไม่ยากเลยว่า มันเป็นส่วนสำหรับการเก็บข้อมูล String ยิ่งเราดูที่คำสั่งภายใน Block ของมัน เราจะเห็นว่า มันคือสิ่งที่เราเขียนอยู่ใน printf นั่นเอง
How Loop Actually Works?
_main:
.cfi_startproc
b LBB0_1
LBB0_1:
tbnz w8, #0, LBB0_4
b LBB0_2
LBB0_2:
b LBB0_3
LBB0_3:
b LBB0_1
LBB0_4:
.cfi_endproc
l_.str:
.asciz "%d"
.subsections_via_symbols
ในการกระโดดข้ามไปยัง Block ต่าง ๆ ใน Assembly เราจะใช้คำสั่งที่ช่ือว่า Branch ใน ARM64 เราจะใช้คำสั่งว่า b แล้วตามด้วย Branch ที่เราต้องการจะกระโดดไป เช่น b LBB0_1 หมายความว่า ให้กระโดดไปที่ LBB0_1
และอีกส่วนที่เกี่ยวกับการทำ Branching เช่นกันคือ TBNZ หรือเต็ม ๆ แปลว่า Test bit and Branch if Nonzero หรือก็คือ ให้เช็คว่าถ้ามันไม่ได้เป็น 0 ให้กระโดด เช่น TBNZ ที่อยู่ใน LBB0_1 มันแปลว่า ถ้าค่าที่อยู่ใน w8 มันไม่เท่ากับ 0
ดังนั้น ในการทำงาน ถ้าเราลองอ่าน เราจะเห็นได้ว่า มันมี 2 เส้นเริ่มจาก main > LBB0_1 > LBB0_4 และเส้นยาวเป็น main > LBB0_1 > LBB0_2 > LBB0_3 > LBB0_1 เดี๋ยวเราค่อย ๆ ลองมาดูทีละ Branch กันว่า มันเกิดอะไรขึ้นบ้างแบบคร่าว ๆ
_main: ; @main
.cfi_startproc
; %bb.0:
sub sp, sp, #48
stp x29, x30, [sp, #32] ; 16-byte Folded Spill
add x29, sp, #32
.cfi_def_cfa w29, 16
.cfi_offset w30, -8
.cfi_offset w29, -16
stur wzr, [x29, #-4]
stur wzr, [x29, #-8]
stur wzr, [x29, #-12]
b LBB0_1
อย่างแรกใน Main เราจะเห็นว่ามันทำอะไรไม่รู้อยู่เต็มไปหมด จริง ๆ มันเทียบเท่ากับช่วงก่อนเราจะเริ่ม Loop ตรงที่เรามีการประกาศตัวแปร a ขึ้นมาให้เท่ากับ 0 และบรรทัดสุดท้าย มันโยน Branch ไปที่ LBB0_1
LBB0_1: ; =>This Inner Loop Header: Depth=1
ldur w8, [x29, #-12]
subs w8, w8, #10
cset w8, ge
tbnz w8, #0, LBB0_4
b LBB0_2
นี่ละถึงส่วนสำคัญแล้ว จะเห็นว่า gcc มันเขียน Comment บอกไว้ด้วยว่ามันคือ Loop Header ซึ่งเราจะเห็นว่า มันเป็นจุดตัดของ Branch ละ เพราะมันมีคำสั่ง Branching 2 ตัวด้วยกัน ถ้ามันเทียบ w8 แล้วเป็น 0 มันจะโดดไป LBB0_4 แต่ถ้าไม่ มันจะลงไปใช้คำสั่ง Branch เฉย ๆ ไปที่ LBB0_2 แทน ซึ่งก่อนหน้าที่มันจะเริ่มเทียบ w8 มันมีการคำนวณบางอย่าง เริ่มจาก LDUR แปลว่า Load Register Unscale เดาว่า น่าจะเป็นการโหลดค่า i ขึ้นมา จากนั้น มันไป subs ที่หมายถึง Substract เอาค่าจาก w8 ไปลบกับ 10 แล้วเก็บลงไปใน w8
ถัดไป CSET มันคือ CINC ตัวเดียวกัน ซึ่งมันก็คือ Conditional Increment หมายความว่า ถ้ามันเข้าเงื่อนไขเทียบกับ 0 มันจะบวก 1 ให้เราทันที ในกรณีนี้เขาใส่เงื่อนไขเป็น ge ก็คือ มากกว่าหรือเท่ากับ นั่นหมายความว่า ถ้าค่าใน w8 มันมากกว่าหรือเท่ากับ 0 มันจะบวก 1 ให้ w8 ไป พูดง่าย ๆ Logic ตรงนี้มันเป็นส่วนหัว Loop ที่เราค่อย ๆ บวกค่า i ขึ้นไปเรื่อย ๆ แต่วิธีการทำงานมันกลับด้านกันเป็นการนับถอยหลังแทน เพราะถ้าเราลองดูในบรรทัดถัดลงมาที่ TBNZ เราจะเห็นว่า ถ้ามันเป็น 0 ก็คือนับจบแล้ว มันก็จะเด้งไปที่ LBB0_4 กลับกัน ถ้าไม่ ก็จะไปที่คำสั่งต่อไป นั่นคือ การกระโดดไป LBB0_2
LBB0_2: ; in Loop: Header=BB0_1 Depth=1
ldur w9, [x29, #-12]
ldur w8, [x29, #-8]
add w8, w8, w9
stur w8, [x29, #-8]
b LBB0_3
ที่ LBB0_2 มี Comment ให้เราเสร็จว่าส่วนนี้คืออยู่ภายใน Loop เริ่มจากการโหลดค่าลงมาใส่ w8 และ w9 จากนั้นมันจะเอา w8 บวกกับ w9 ซึ่งก็คือ การเอา a + i ที่อยู่ใน Code ของเรา บวกเสร็จ มันก็ต้องเก็บค่าลงไป โดยใช้ STUR หรือ Store Register (Unscaled) จากนั้นค่อย Branch ไปที่ LBB0_3
LBB0_3: ; in Loop: Header=BB0_1 Depth=1
ldur w8, [x29, #-12]
add w8, w8, #1
stur w8, [x29, #-12]
b LBB0_1
มาใน LBB0_3 ส่วนนี้มันจะเขียนว่าเป็น Header ลองมาดูว่ามันทำอะไร อย่างแรก มันโหลด w8 หรือก็คือ i ของเราขึ้นมา จากนั้น มันบวก 1 เข้าไป แล้วเก็บ แล้วค่อย Branch กลับไปที่ LBB0_1 ใหม่เป็นอันจบ Flow ใหญ่
LBB0_4:
ldur w9, [x29, #-8]
; implicit-def: $x8
mov x8, x9
mov x9, sp
str x8, [x9]
adrp x0, l_.str@PAGE
add x0, x0, l_.str@PAGEOFF
bl _printf
mov w0, #0 ; =0x0
ldp x29, x30, [sp, #32] ; 16-byte Folded Reload
add sp, sp, #48
ret
.cfi_endproc
เมื่อมันผ่านเงื่อนไขบน LBB0_1 ให้กระโดดมาที่ LBB0_4 ได้ มันก็คือส่วนที่หลุดออกจาก Loop มาได้ เราจะเห็นว่า มันจะเริ่มโหลดค่าใส่ w9 นั่นคือ a ของเรานั่นเอง หลังจากนั้นมันจะดูยุบยับไปหมด แต่หลัก ๆ มันคือการย้ายค่าไปมาเพื่อแทนค่า String ที่เราจะเอาไปเรียก printf โดยเราจะเห็นว่า มันมีคำสั่ง Branching แล้วเขียนว่า printf นี่แหละมันคือวิธีการเรียก Function ของ Assembly ในที่นี้ BL ไม่ใช่ Boy Love แต่เป็น Branch Link หรือ Branch ออกไปที่ Segment ที่อยู่นอกไฟล์นี้นั่นเอง ซึ่งมันเรียกไปที่ Branch ที่ชื่อว่า _printf เดาไม่ยาก มันคือ Segment ที่ประกอบไปด้วยคำสั่งสำหรับการเอาข้อความออกมาทางหน้าจอ และเกือบสุดท้ายเมื่อทำงานเสร็จ มันจะวิ่งไปที่ RET หรือ Return from subroutine หรือก็คือการส่วนที่เราบอกว่า return 0; นั่นเอง และสุดท้ายเจอ endproc ก็คือการจบโปรแกรมนั่นเอง
ดังนั้นขั้นตอนของ For Loop ในกรณีนี้คือ มันจะเริ่มจากการของพื้นที่บน Memory เพื่อแปะค่าเริ่มต้นของตัวแปร i เข้าไป จากนั้น มันจะทำสิ่งที่อยู่ภายใน Loop เมื่อจบมันจะเด้งไปที่การบวกค่า i จากนั้นก็ย้อนกลับขึ้นไปหัว Loop ใหม่ แล้วเช็คเงื่อนไข ถ้าผ่านก็วิ่งต่อ ไม่ผ่านก็เด้งออกจาก Loop นั่นเอง
สรุป
และนี่แหละก็คือหลักการทำงานของ Loop ที่เราใช้งานกันจริง ๆ จากคำถามที่ว่า ทำไมพี่รู้ว่า Loop มันทำงานแบบนี้ ก็เพราะการ Compile ออกมาเป็น Assembly แล้วอ่านกับไล่ดูอย่างที่ทำให้ดูเลย แนะนำว่า ลองเปลี่ยนเป็น While Loop ดู แล้วลองค่อย ๆ ไล่ดูว่า มันมีความเหมือนหรือแตกต่างกันอย่างไรได้ และอีกประเด็นที่เราสามารถเห็นได้จากบทความนี้คือ จริง ๆ แล้ว Loop เป็น Idea ที่ใหม่เข้ามาทีหลัง มัน Abstract การทำงานหลาย ๆ อย่างเข้าไปเป็นคำสั่ง 1 ตัวที่เราใช้งานกัน ด้วยบทความนี้ น่าจะพอทำให้เราเข้าใจการทำงานของ Loop ที่เราใช้งานกันทุกวันได้ว่า จริง ๆ แล้ว มันทำงานอย่างไรกันแน่
เอาจริงปะ ถ้าเราเขียนโปรแกรมแบบทั่ว ๆ ไป High-Level ไม่จำเป็นต้องรู้ก็ได้นะ แต่ถ้าใครสนใจพวก Low-Level Programming จริง ๆ มันเป้นเรื่องพื้นฐานมาก ๆ ที่จำเป็นต้องรู้เลยแหละ