การพัฒนาเฟิร์มแวร์สำหรับไมโครคอนโทรลเลอร์

จาก Theory Wiki
ไปยังการนำทาง ไปยังการค้นหา
วิกินี้เป็นส่วนหนึ่งของรายวิชา 01204223

วิกินี้อธิบายถึงขั้นตอนและตัวอย่างการพัฒนาเฟิร์มแวร์ลงบนบอร์ดไมโครคอนโทรลเลอร์ที่เราได้ประกอบขึ้นมา โดยเนื้อหาครอบคลุมเฉพาะสภาพแวดล้อมการพัฒนาโปรแกรมบนลินุกซ์และ Mac OS X เท่านั้น

ติดตั้งซอฟท์แวร์ที่เกี่ยวข้อง

สำหรับระบบปฏิบัติการ Linux (Debian/Ubuntu/Mint)

  • ครอสคอมไพเลอร์ (cross compiler) สำหรับไมโครคอนโทรลเลอร์ตระกูล AVR รวมถึงไลบรารีที่เกี่ยวข้อง
sudo apt-get install gcc-avr avr-libc
  • AVR toolchain
sudo apt-get install binutils-avr
  • AVRDUDE (AVR Downloader/UploaDEr) ใช้สำหรับโหลดรหัสภาษาเครื่องลงบนหน่วยความจำแฟลชของไมโครคอนโทรลเลอร์ผ่านพอร์ต USB
sudo apt-get install avrdude

สำหรับระบบปฏิบัติการ Mac OS X

  • ดาวน์โหลดและติดตั้งชุดโปรแกรม CrossPack-AVR เวอร์ชันล่าสุดจาก Objective Development

การพัฒนาเฟิร์มแวร์ด้วยภาษาแอสเซมบลี้

ภาษาแอสเซมบลี้ (assembly language) จัดเป็นภาษาระดับต่ำที่ใช้การแทนรหัสภาษาเครื่อง (machine code) ด้วยสัญลักษณ์ ดังนั้นคำสั่งในภาษาแอสเซมบลี้หนึ่งคำสั่งจึงเทียบเท่ากับคำสั่งในภาษาเครื่องหนึ่งคำสั่งเช่นกัน แม้ภาษาแอสเซมบลี้จะมีความยุ่งยากมากกว่าภาษาชั้นสูงเช่นภาษาซี การเขียนโปรแกรมในภาษาแอสเซมบลี้ก็ยังเป็นที่นิยมในหลาย ๆ สถานการณ์เนื่องจากผู้พัฒนาโปรแกรมสามารถทราบถึงการทำงานของหน่วยประมวลผลได้ในรายละเอียดทุกคำสั่ง โดยเฉพาะอย่างยิ่งในงานที่ต้องมีการกำหนดเวลาในการประมวลผลที่แม่นยำ

ตัวอย่างโปรแกรมภาษาแอสเซมบลี้

ใช้โปรแกรมเท็กซ์เอดิเตอร์สร้างโปรแกรมภาษาแอสเซมบลี้ขึ้นมาหนึ่งโปรแกรมตามตัวอย่างด้านล่าง จากนั้นบันทึกโปรแกรมลงในไฟล์ชื่อ first.S (สังเกตว่าใช้นามสกุล .S ตัวใหญ่)

.global main
main:
    ldi r16,0b00000111  ; ทำให้ PC2,PC1,PC0 เป็นเอาท์พุท
    out 0x07,r16
    ldi r16,0b00000100  ; ทำให้ LED สีเขียวติด
    out 0x08,r16
loop:
    rjmp loop           ; วนซ้ำอยู่ที่เดิมเพื่อไม่ให้โปรแกรมจบการทำงาน

แต่ละบรรทัดมีความหมายดังนี้

  • .global main เป็นการประกาศให้ป้ายระบุตำแหน่ง main เป็นที่รู้จักของไลบรารีภายนอก
  • main: ระบุว่า ณ ตำแหน่งนี้สามารถถูกอ้างถึงได้โดยใช้ชื่อ main
  • ldi r16,0b00000111 โหลดค่า 00000111 ฐานสอง (ซึ่งเท่ากับ 7) ลงไปในรีจีสเตอร์ r16
  • out 0x07,r16 นำค่าใน r16 ไปใส่ไว้ใน I/O รีจีสเตอร์หมายเลข 7 ซึ่งหมายถึงรีจีสเตอร์ DDRC ดังนั้นจึงมีผลทำให้ขา PC0 ถึง PC2 ทำหน้าที่เป็นเอาท์พุท ส่วนขา PC3 ถึง PC7 ทำหน้าที่เป็นอินพุท
  • ldi r16,0b00000100 โหลดค่า 00000100 ฐานสอง (ซึ่งเท่ากับ 4) ลงไปในรีจีสเตอร์ r16
  • out 0x08,r16 นำค่าใน r16 ไปใส่ไว้ใน I/O รีจีสเตอร์หมายเลข 8 ซึ่งหมายถึงรีจีสเตอร์ PORTC จึงมีผลทำให้ขา PC2 มีค่าลอจิกเป็น 1 จึงทำให้ LED สีเขียวติดสว่าง
  • loop: ระบุว่า ณ ตำแหน่งนี้สามารถถูกอ้างถึงได้โดยใช้ชื่อ loop
  • rjmp loop กระโดดกลับไปทำงานที่ตำแหน่งหน่วยความจำที่ระบุด้วยป้าย loop ซึ่งก็คือตำแหน่งเดิม

การแอสเซมเบิลเพื่อสร้างรหัสภาษาเครื่อง

โปรแกรมที่เขียนด้วยภาษาแอสเซมบลี้ต้องผ่านกระบวนการแปลภาษาเพื่อให้อยู่ในรูปรหัสภาษาเครื่องโดยอาศัยโปรแกรมแปลภาษาที่เรียกว่าแอสเซมเบลอร์ (assembler) ในที่นี้เราจะใช้โปรแกรม avr-gcc ซึ่งตัวมันเองจะไปเรียกใช้โปรแกรม avr-as เพื่อแปลโปรแกรมภาษาแอสเซมบลี้เป็นรหัสภาษาเครื่องสำหรับสถาปัตยกรรม AVR อีกทีหนึ่ง

avr-gcc -mmcu=atmega168 -o first.elf first.S

อ็อพชันต่าง ๆ ที่ระบุในคำสั่งข้างต้นมีหน้าที่ดังนี้

  • -mmcu=atmega168 เป็นตัวบอกคอมไพเลอร์ว่าไมโครคอนโทรลเลอร์ที่ใช้เป็นเบอร์ ATMega168
  • -o first.elf ระบุว่าให้เอาท์พุทถูกเก็บลงในไฟล์ first.elf หากไม่ระบุโปรแกรมจะสร้างไฟล์ชื่อ a.out แทน

ผลลัพธ์ที่ได้จะอยู่ในรูปของไฟล์ฟอร์แมต ELF (Excutable and Linkable Format) ซึ่งประกอบไปด้วยเฮดเดอร์และข้อมูลเสริมอื่น ๆ อีกมากมาย อย่างไรก็ตามเราต้องการเพียงแค่ส่วนที่เป็นรหัสภาษาเครื่องของโปรแกรม ซึ่งสกัดออกมาได้โดยใช้คำสั่ง avr-objcopy ดังนี้

avr-objcopy -j .text -j .data -O ihex first.elf first.hex

อ็อพชันต่าง ๆ ที่ระบุในคำสั่งข้างต้นมีหน้าที่ดังนี้

  • -j .text -j .data สกัดข้อมูลจากเซคชัน .text และ .data ซึ่งเป็นเซคชันที่เก็บโค้ดโปรแกรมและข้อมูลเริ่มต้นในไฟล์ ELF
  • -O ihex ส่งเอาท์พุทในรูปแบบ Intel HEX

ผลลัพธ์ที่ได้จะถูกเก็บไว้ในไฟล์ first.hex ซึ่งเป็นไฟล์ ASCII โดยภายในไฟล์จะมีข้อมูลคล้ายคลึงกับที่แสดงในตัวอย่าง

$ cat first.hex
:100000000C9434000C943E000C943E000C943E0082
:100010000C943E000C943E000C943E000C943E0068
:100020000C943E000C943E000C943E000C943E0058
:100030000C943E000C943E000C943E000C943E0048
:100040000C943E000C943E000C943E000C943E0038
:100050000C943E000C943E000C943E000C943E0028
:100060000C943E000C943E0011241FBECFEFD4E050
:10007000DEBFCDBF0E9440000C9445000C940000F0
:0E00800007E007B904E008B9FFCFF894FFCFFE
:00000001FF

สังเกตว่าไฟล์รหัสภาษาเครื่องดูจะยาวกว่าต้นฉบับโปรแกรมภาษาแอสเซมบลี้มาก ทดลองแปลงโปรแกรมภาษาเครื่องกลับมาเป็นภาษาแอสเซมบลี้โดยใช้คำสั่ง avr-objdump ดังนี้

$ avr-objdump -d first.elf

กระบวนการข้างต้นเรียกว่าเป็นการทำดิสแอสเซมเบิล (disassemble) ซึ่งอ็อปชัน -d ย่อมาจาก disassemble บนหน้าจอจะแสดงผลลัพธ์ดังนี้

first.elf:     file format elf32-avr


Disassembly of section .text:

00000000 <__vectors>:
   0:	0c 94 34 00 	jmp	0x68	; 0x68 <__ctors_end>
   4:	0c 94 3e 00 	jmp	0x7c	; 0x7c <__bad_interrupt>
   8:	0c 94 3e 00 	jmp	0x7c	; 0x7c <__bad_interrupt>
   :
   : ละไว้
   :
  64:	0c 94 3e 00 	jmp	0x7c	; 0x7c <__bad_interrupt>

00000068 <__ctors_end>:
  68:	11 24       	eor	r1, r1
  6a:	1f be       	out	0x3f, r1	; 63
  6c:	cf ef       	ldi	r28, 0xFF	; 255
  6e:	d4 e0       	ldi	r29, 0x04	; 4
  70:	de bf       	out	0x3e, r29	; 62
  72:	cd bf       	out	0x3d, r28	; 61
  74:	0e 94 40 00 	call	0x80	; 0x80 <main>
  78:	0c 94 45 00 	jmp	0x8a	; 0x8a <_exit>

0000007c <__bad_interrupt>:
  7c:	0c 94 00 00 	jmp	0	; 0x0 <__vectors>

00000080 <main>:
  80:	07 e0       	ldi	r16, 0x07	; 7
  82:	07 b9       	out	0x07, r16	; 7
  84:	04 e0       	ldi	r16, 0x04	; 4
  86:	08 b9       	out	0x08, r16	; 8

00000088 <loop>:
  88:	ff cf       	rjmp	.-2      	; 0x88 <loop>

0000008a <_exit>:
  8a:	f8 94       	cli

0000008c <__stop_program>:
  8c:	ff cf       	rjmp	.-2      	; 0x8c <__stop_program>

ตัวเลขด้านซ้ายมือสุดในแต่ละบรรทัดที่มีคำสั่งภาษาแอสเซมบลี้อยู่ (เช่น 7c:) แสดงตำแหน่งที่อยู่ (address) ของแฟลชโปรแกรมในรูปฐานสิบหก ตัวเลขชุดถัดมาคือรหัสคำสั่งภาษาเครื่อง และส่วนที่เหลือเป็นคำสั่งภาษาแอสเซมบลี้ที่สอดคล้องกัน ดังนั้นบรรทัดที่ระบุว่า

  80:	07 e0       	ldi	r16, 0x07	; 7

จึงมีความหมายว่าให้บรรจุรหัสคำสั่งภาษาเครื่อง 07 และ e0 (สองไบต์) ลงไปในชิปที่ตำแหน่งหน่วยความจำ 80 และ 81 (ฐานสิบหก)

แต่สังเกตว่าคำสั่งภาษาแอสเซมบลี้ที่เราเขียนไว้ในต้นฉบับปรากฏอยู่ที่ตำแหน่งหน่วยความจำที่ 80 ถึง 89 เท่านั้น แต่รหัสภาษาเครื่องที่ดิสแอมเซมเบิลออกมานั้นกลับประกอบไปด้วยอะไรอีกหลายอย่างที่เราไม่ได้เขียน ทั้งนี้เนื่องจากในสถาปัตยกรรม AVR จำเป็นต้องมีการตั้งค่าเริ่มต้นให้กับชิปหลายอย่างก่อนที่จะเริ่มทำงานได้อย่างถูกต้อง ดังนั้น avr-gcc จึงปะส่วนการตั้งค่าเริ่มต้นนี้ให้เราในส่วนหัวของโปรแกรม นอกจากนั้นยังมีการปะโค้ดให้วนซ้ำไว้ในส่วนท้ายโปรแกรมเพื้อป้องกันกรณีที่เราเผลอปล่อยโปรแกรมให้จบการทำงานออกมา

การโหลดโปรแกรมลงบนหน่วยความจำแฟลชของไมโครคอนโทรลเลอร์

โดยทั่วไปการนำโปรแกรมลงสู่แฟลชของไมโครคอนโทรลเลอร์นั้นมักอาศัยเครื่องโปรแกรมชิป (chip programmer) อย่างไรก็ตามชิป ATmega168 ที่แจกไปให้นั้นได้ถูกป้อนโปรแกรมพิเศษที่เรียกว่าบูทโหลดเดอร์ (boot loader) เอาไว้เพื่อจำลองบอร์ดไมโครคอนโทรลเลอร์เป็นอุปกรณ์โปรแกรมชิปชนิด USBasp ซึ่งจะรอรับรหัสภาษาเครื่องที่ส่งมาทางพอร์ท USB ของเครื่องคอมพิวเตอร์ และเขียนข้อมูลเหล่านั้นลงสู่หน่วยความจำแฟลช

บูทโหลดเดอร์ถูกติดตั้งไว้ในตำแหน่งแฟลชที่เป็นบูทเซคเตอร์ของชิป (เริ่มต้นที่แอดเดรส 0x3800 ของหน่วยความจำแฟลช) ซึ่งเป็นจุดแรกที่ไมโครคอนโทรลเลอร์เริ่มต้นทำงาน กระบวนการทำงานของบูทโหลดเดอร์ที่เตรียมไว้ให้เป็นดังรูปด้านล่าง

Flow.png

จะเห็นว่าเงื่อนไขของการที่จะให้บูทโหลดเดอร์เข้าสู่โหมด USB เพื่อรอรับข้อมูลนั้นคือไมโครคอนโทรลเลอร์ต้องถูกรีเซ็ตด้วยปุ่มรีเซ็ต และขา Bootloader (PD7) ต้องถูกเชื่อมลงกราวนด์ ซึ่งทำได้โดยการเสียบจั๊มเปอร์เพื่อชอร์ตวงจรในตำแหน่งที่ระบุว่า Boot-loader บนบอร์ด จุดสังเกตที่แสดงให้เห็นว่าไมโครคอนโทรลเลอร์กำลังรอรับข้อมูลจากพอร์ท USB คือ LED สีเขียวบนบอร์ดจะกระพริบสั้น ๆ และถี่ ๆ

ในระหว่างที่ไมโครคอนโทรลเลอร์จำลองตัวเองเป็นอุปกรณ์ USB หากเรียกคำสั่ง lsusb บนลินุกซ์ บนเครื่องคอมพิวเตอร์จะต้องปรากฏรายการอุปกรณ์ที่มี VID:PID เป็น 16c0:05dc ดังตัวอย่าง

$ lsusb
Bus 004 Device 001: ID 0000:0000  
Bus 003 Device 007: ID 16c0:05dc VOTI shared ID for use with libusb <-- ต้องปรากฏบรรทัดนี้ (หมายเลข Bus และ Device อาจแตกต่างออกไป)
Bus 003 Device 001: ID 0000:0000  
Bus 002 Device 001: ID 0000:0000  
Bus 001 Device 001: ID 0000:0000

สำหรับเครื่องที่ใช้ Mac OS X ให้ใช้คำสั่ง system_profiler ควบคู่กับ grep เพื่อหาอุปกรณ์ที่ชื่อ USBasp ดังแสดง

$ system_profiler SPUSBDataType | grep -A 10 USBasp
       USBasp:

          Product ID: 0x05dc
          Vendor ID: 0x16c0
          Version:  1.02
          Speed: Up to 1.5 Mb/sec
          Manufacturer: www.fischl.de
          Location ID: 0xfd130000
          Current Available (mA): 500
          Current Required (mA): Unknown (Device has not been configured)

อันแสดงว่าไมโครคอนโทรลเลอร์อยู่ในสภาพพร้อมที่จะรับโปรแกรมแล้ว

การส่งโปรแกรมไปยังไมโครคอนโทรลเลอร์ผ่านพอร์ท USB นั้นให้ใช้คำสั่ง avrdude ดังแสดง

avrdude -p atmega168 -c usbasp -U flash:w:first.hex

อ็อพชันต่าง ๆ ที่ใช้ในคำสั่งข้างต้นมีหน้าที่ดังนี้

  • -p atmega168 ระบุว่าไมโครคอนโทรลเลอร์ปลายทางคือเบอร์ ATmega168
  • -c usbasp ระบุว่าเครื่องโปรแกรมชิปที่ใช้คือชนิด USBAsp
  • -U flash:w:first.hex ระบุว่าให้ดำเนินการเขียนข้อมูลลงสู่หน่วยความจำแฟลชของไมโครคอนโทรลเลอร์ โดยนำเข้าข้อมูลจากไฟล์ first.hex

หมายเหตุ: หากพบปัญหาเกี่ยวกับสิทธิการเข้าถึงอุปกรณ์ USB ให้ดำเนินตามขั้นตอนที่อธิบายไว้ในเอกสาร การแก้ไขสิทธิการเข้าถึงพอร์ท USB ของบอร์ด MCU

การพัฒนาเฟิร์มแวร์ด้วยภาษาซี

ภาษาซีจัดเป็นภาษาระดับสูง (high-level programming language) ที่ถือว่ามีการทำงานใกล้เคียงกับภาษาเครื่องมากโดยที่ผู้พัฒนาโปรแกรมไม่จำเป็นต้องเขียนคำสั่งให้ละเอียดยิบเหมือนกับภาษาแอสเซมบลี้ จึงเป็นภาษาที่ได้รับความนิยมมากในงานที่ต้องเขียนโปรแกรมใกล้ชิดกับฮาร์ดแวร์ดังเช่นงานด้านระบบฝังตัว (embedded system)

Short, sweet, to the point, FREE-exactly as informtaoin should be!

การคอมไพล์โปรแกรมให้เป็นภาษาเครื่อง

กระบวนการแปลโปรแกรมภาษาชั้นสูงให้เป็นภาษาเครื่องนั้นเรียกว่าเป็นการคอมไพล์ (compile) ซึ่งอาศัยโปรแกรมที่เรียกว่าคอมไพเลอร์ (compiler) เป็นตัวดำเนินการ เราอาศัยโปรแกรม avr-gcc เป็นตัวคอมไพเลอร์โดยเรียกใช้งานดังนี้ (สังเกตว่ามีการใช้อ็อปชัน -O เพิ่มเติมขึ้นมา)

avr-gcc -mmcu=atmega168 -O -o first.elf first.c

อ็อพชันต่าง ๆ ที่ระบุในคำสั่งข้างต้นมีหน้าที่ดังนี้

  • -mmcu=atmega168 เป็นตัวบอกคอมไพเลอร์ว่าไมโครคอนโทรลเลอร์ที่ใช้เป็นเบอร์ ATMega168
  • -O ระบุว่าให้คอมไพเลอร์ทำ code optimization ซึ่งจำเป็นต้องใช้เพื่อให้ฟังก์ชัน _delay_ms() ทำงานได้ถูกต้อง
  • -o first.elf ระบุว่าให้เอาท์พุทถูกเก็บลงในไฟล์ first.elf หากไม่ระบุโปรแกรมจะสร้างไฟล์ชื่อ a.out แทน

หมายเหตุ: avr-gcc ทำหน้าที่เป็นได้ทั้งแอสเซมเบลอร์และคอมไพเลอร์ ขึ้นอยู่กับนามสกุลของไฟล์อินพุทว่าเป็น .S หรือ .c

การสร้างกระบวนการอัตโนมัติด้วย Makefile

จากที่ผ่านมาจะเห็นว่าการพัฒนาเฟิร์มแวร์สำหรับไมโครคอนโทรลเลอร์นั้นประกอบด้วยการแก้ไขโปรแกรมด้วยเท็กซ์เอดิเตอร์ และเซฟลงในไฟล์ .c จากนั้นจึงดำเนินตามขั้นตอนดังนี้

  1. ครอสคอมไพล์โปรแกรมด้วยคำสั่ง avr-gcc
  2. สกัดรหัสภาษาเครื่องจากไฟล์ ELF ด้วยคำสั่ง avr-objcopy
  3. ส่งรหัสภาษาเครื่องไปยังไมโครคอนโทรลเลอร์ด้วยคำสั่ง avrdude

ในแต่ละขั้นตอนนั้นมีการเรียกคำสั่งที่ค่อนข้างยาว เราจึงควรสร้าง Makefile ขึ้นมาเพื่อให้คำสั่งเหล่านี้ถูกเรียกใช้งานโดยอัตโนมัติ

all: first.hex

flash: first.hex
    avrdude -p atmega168 -c usbasp -U flash:w:first.hex

first.hex: first.elf
    avr-objcopy -j .text -j .data -O ihex first.elf first.hex

first.elf: first.c
    avr-gcc -mmcu=atmega168 -O -o first.elf first.c

(ระวังว่าบรรทัดที่เยื้องเข้าไปนั้นต้องเป็นอักขระแท็บ ไม่ใช่ช่องว่าง)

สมมติว่าภายในไดเรคตอรีที่เรียกใช้คำสั่ง make มีเพียงไฟล์ first.c และ Makefile การเรียกคำสั่ง

make

จะถือเป็นการสร้างเป้าหมายที่ระบุไว้ตัวแรกสุด ในที่นี้คือ all ซึ่งจะมีผลให้มีการดำเนินการดังนี้

  • เป้าหมาย all ระบุไว้ว่าให้สร้างเป้าหมาย first.hex ขึ้นมา
  • make หาไฟล์ first.hex ไม่พบ แต่ทราบว่าสามารถสร้างขึ้นจาก first.elf
  • make หาไฟล์ first.elf ไม่พบ แต่ทราบว่าสามารถสร้างขึ้นจาก first.c
  • make พบไฟล์ first.c ในไดเรคตอรี จึงเรียกคำสั่ง avr-gcc เพื่อสร้างไฟล์ first.elf
  • เมื่อได้ first.elf มาแล้วจึงเรียก avr-objcopy เพื่อสร้างไฟล์ first.hex เป็นอันเสร็จกระบวนการ

หากต้องการให้มีการเขียนแฟลชไมโครคอนโทรลเลอร์ ให้เรียกคำสั่ง make โดยระบุเป้าหมาย flash ดังนี้

make flash

ซึ่งจะมีการดำเนินการสร้างเป้าหมาย first.hex โดยอัตโนมัติหากหาไฟล์นี้ไม่พบหรือไฟล์ที่มีอยู่นั้นเก่ากว่าไฟล์ first.c จากนั้นจึงตามด้วยการเรียกใช้คำสั่ง avrdude เพื่อแฟลชเฟิร์มแวร์ใหม่ให้กับไมโครคอนโทรลเลอร์

หากต้องการนำ Makefile ไปปรับใช้ในโครงงานอื่นได้ง่าย เราสามารถปรับ Makefile ให้ยืดยุ่นขึ้นโดยระบุขั้นตอนด้วยรูปแบบ (pattern) ดังตัวอย่าง

TARGET=first.hex
MCU=atmega168
F_CPU=16000000L
CFLAGS=-DF_CPU=$(F_CPU) -mmcu=$(MCU) -O

all: $(TARGET)

flash: $(TARGET)
    avrdude -p atmega168 -c usbasp -U flash:w:$(TARGET)

%.hex: %.elf
    avr-objcopy -j .text -j .data -O ihex $< $@

%.elf: %.c
    avr-gcc $(CFLAGS) -o $@ $?

%.o: %.c
    avr-gcc -c $(CFLAGS) $@ $<

การนำ Makefile นี้ไปใช้กับโครงงานอื่นทำได้โดยการเปลี่ยนบรรทัดแรกจาก first.hex เป็ืนชื่อไฟล์สำหรับโครงงานนั้น ๆ เท่านั้น สังเกตว่าเรายังได้ระบุค่าของ F_CPU ไว้ระหว่างการคอมไพล์ ดังนั้นบรรทัด

#define F_CPU 16000000L

ในไฟล์ .c จึงไม่จำเป็นอีกต่อไป แต่เพื่อความแน่ใจว่าค่าของ F_CPU จะต้องถูกนิยามไว้ที่ใดที่หนึ่ง และต้องไม่ถูกนิยามซ้ำ เราควรใส่เงื่อนไขการนิยามไว้ตอนต้นของโปรแกรมดังนี้

#ifndef F_CPU
#define F_CPU 16000000L
#endif

บทความที่เกี่ยวข้อง

ข้อมูลเพิ่มเติม