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 จริง ๆ มันเป้นเรื่องพื้นฐานมาก ๆ ที่จำเป็นต้องรู้เลยแหละ