Tutorial

Loop แท้ไม่มีอยู่จริง มีแต่ความจริงซึ่งคนโง่ยอมรับไม่ได้

By Arnon Puitrakul - 25 ตุลาคม 2024

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

Read Next...

จัดการเรื่องแต่ละมื้อ แต่ละเดย์ด้วย Obsidian

จัดการเรื่องแต่ละมื้อ แต่ละเดย์ด้วย Obsidian

Obsidian เป็นโปรแกรมสำหรับการจด Note ที่เรียกว่า สารพัดประโยชน์มาก ๆ เราสามารถเอามาทำอะไรได้เยอะมาก ๆ หนึ่งในสิ่งที่เราเอามาทำคือ นำมาใช้เป็นระบบสำหรับการจัดการ Todo List ในแต่ละวันของเรา ทำอะไรบ้าง วันนี้เราจะมาเล่าให้อ่านกันว่า เราจัดการะบบอย่างไร...

Loop แท้ไม่มีอยู่จริง มีแต่ความจริงซึ่งคนโง่ยอมรับไม่ได้

Loop แท้ไม่มีอยู่จริง มีแต่ความจริงซึ่งคนโง่ยอมรับไม่ได้

อะ อะจ๊ะเอ๋ตัวเอง เป็นยังไงบ้างละ เมื่อหลายเดือนก่อน เราไปเล่าเรื่องกันขำ ๆ ว่า ๆ จริง ๆ แล้วพวก Loop ที่เราใช้เขียนโปรแกรมกันอยู่ มันไม่มีอยู่จริง สิ่งที่เราใช้งานกันมันพยายาม Abstract บางอย่างออกไป วันนี้เราจะมาถอดการทำงานของ Loop จริง ๆ กันว่า มันทำงานอย่างไรกันแน่ ผ่านภาษา Assembly...

Monitor การทำงาน MySQL ด้วย Prometheus และ Grafana

Monitor การทำงาน MySQL ด้วย Prometheus และ Grafana

นอกจากการทำให้ Application รันได้แล้ว อีกเรื่องที่สำคัญไม่แพ้กันคือการวางระบบ Monitoring ที่ดี วันนี้เราจะมาแนะนำวิธีการ Monitor การทำงานของ MySQL ผ่านการสร้าง Dashboard บน Grafana กัน...

เสริมความ"แข็งแกร่ง" ให้ SSH ด้วย fail2ban

เสริมความ"แข็งแกร่ง" ให้ SSH ด้วย fail2ban

จากตอนที่แล้ว เราเล่าในเรื่องของการ Harden Security ของ SSH Service ของเราด้วยการปรับการตั้งค่าบางอย่างเพื่อลด Attack Surface ที่อาจจะเกิดขึ้นได้ หากใครยังไม่ได้อ่านก็ย้อนกลับไปอ่านกันก่อนเด้อ วันนี้เรามาเล่าวิธีการที่มัน Advance มากขึ้น อย่างการใช้ fail2ban...