01076051Lecture 04

Embedded C

บทที่ 4: การเขียนโปรแกรมภาษา C สำหรับไมโครคอนโทรลเลอร์

ภาษา C เป็นภาษาหลักของงาน embedded เพราะอยู่ใกล้ฮาร์ดแวร์พอที่จะคุม register ได้ แต่ยังอ่านง่ายกว่า assembly. บทนี้ปูโครงสร้างโปรแกรม C สำหรับ MCU, การเลือก data type จาก stdint.h ให้ตรงขนาด, operator และ bit masking ที่ใช้คุมบิตของ GPIO, control structures และ function (pass by value vs reference) — ทั้งหมดคือเครื่องมือที่ใช้จริงใน Lab01/Lab02.

ผลการเรียนรู้ (Learning Outcomes)

  • อธิบายโครงสร้างโปรแกรม C สำหรับ MCU และการวางตัวแปร global/local ในหน่วยความจำ
  • เลือก data type (stdint.h) ให้เหมาะกับจำนวนบิตและ signed/unsigned
  • เข้าถึง register/port ผ่าน header และ pointer (GPIO_TypeDef)
  • ใช้ operator เลขคณิต/ตรรกะ/bitwise/shift และ bit masking เพื่อ set/clear/toggle/test บิต
  • ใช้ control structures (if/else-if/switch, while/do-while/for) อย่างถูกต้อง
  • เขียนและเรียก function ทั้งแบบ pass by value และ pass by reference

§4.1 โครงสร้างโปรแกรม C สำหรับ MCU

โปรแกรม C สำหรับ MCU มีโครงสร้างเป็นลำดับดังนี้. เริ่มด้วยการ include header ของชิป (เช่น stm32f767xx.h) ที่นิยามชื่อและที่อยู่ของ register ให้. จากนั้นประกาศตัวแปร global, นิยาม function ต่าง ๆ และมี main() ที่ปิดท้ายด้วย infinite loop while(1) (หรือ for(;;)) เพื่อให้โปรแกรมทำงานซ้ำตลอดเวลา.

ตัวแปร global เป็นแบบ static คืออยู่ใน RAM ตำแหน่งคงที่ ทุกฟังก์ชันเข้าถึงได้. ส่วนตัวแปร local เป็นแบบ automatic คือถูกจองบน stack หรือใน register ตอนเข้าฟังก์ชัน และถูกคืนเมื่อออกจากฟังก์ชัน.

ภาพรวม memory map ของ MCU: register ของ Cortex (NVIC/SysTick) อยู่ช่วง address สูงที่ 0xE000_0000, peripheral register อยู่ที่ 0x4000_0000, RAM เก็บตัวแปรและ stack, Flash เก็บ code และค่าคงที่ ส่วน reset vector และ interrupt vector อยู่ใน word แรก ๆ ของ Flash.

โครงสร้างพื้นฐานของโปรแกรม C สำหรับ MCUc
#include "stm32f767xx.h"   // ชื่อ/ที่อยู่ register ของชิป

int count, bob;            // global (static) → วางใน RAM ตำแหน่งคงที่

int function1(char x) {    // ฟังก์ชัน: รับ param, คืนค่า int
    int i, j;              // local (automatic) → stack หรือ registers
    return i;
}

void main(void) {
    unsigned char sw1; int k;   // local
    // init ตัวแปร / พอร์ต / อุปกรณ์
    while (1) {                  // infinite loop (for(;;) ก็ได้)
        // งานที่ทำซ้ำตลอด
    }
}
VIZ-18Memory Map — 8 บล็อก × 512 MB
Block 2
0x4000_0000

Peripherals: APB1/APB2/AHB1/AHB2 (GPIO อยู่ที่นี่)

Little-endian: word 0x11223344
44+033+122+211+3
byte +0 = LSB (0x44)

address space 4 GB แบ่งเป็น 8 บล็อก บล็อกละ 512 MB. คลิกบล็อกเพื่อดู base address และหน้าที่. byte เรียงแบบ little-endian (byte ต่ำสุดใน word คือ LSB).

§4.2 Data Types (stdint.h)

การเลือก data type ต้องให้ตรงกับลักษณะข้อมูลสองอย่าง. อย่างแรกคือจำนวนบิต ซึ่งกำหนดช่วงค่าที่เก็บได้. อย่างที่สองคือ signed หรือ unsigned ซึ่งกำหนด operator ที่ compiler เลือกใช้และความหมายของบิตบนสุด. ข้อมูลที่ไม่ใช่เลขมีเครื่องหมาย เช่นค่า register หรือ flag ควรประกาศเป็น unsigned.

stdint.h มีชนิดข้อมูลที่ระบุขนาดชัดเจน เช่น uint8_t, int16_t, uint32_t ซึ่งช่วยตัดความกำกวมเรื่องจำนวนบิตและ signed/unsigned ที่อาจต่างกันไปตาม compiler หรือแพลตฟอร์ม.

ตัวอย่างการใช้งาน: uint16_t n = GPIOA->IDR; สำหรับอ่าน 16 บิตจาก IDR, uint32_t a = ADC; สำหรับรับค่าจาก ADC, uint8_t cnt; สำหรับตัวนับลูป. ข้อสังเกตเรื่อง floating point: Cortex-M3 ไม่รองรับการคำนวณ float ด้วยฮาร์ดแวร์ ต้องพึ่งไลบรารีซึ่งช้า แต่บอร์ด M7 ของเรามี FPU จึงคำนวณ float ด้วยฮาร์ดแวร์ได้โดยตรง.

ประกาศบิตช่วงค่า
char / unsigned char / uint8_t80 .. 255
signed char / int8_t8-128 .. +127
short / signed short / int16_t16-32768 .. +32767
unsigned short / uint16_t160 .. 65535
int / signed int / int32_t32-2147483648 .. +2147483647
unsigned int / uint32_t320 .. 4294967295
Data types ตาม stdint.h และช่วงค่า
VIZ-19Data Type Range Explorer (stdint.h)
จำนวนบิต
16 bit
signed?
unsigned
ช่วงค่า
0 .. 65535
บิต
bbbbbbbbbbbbbbbb

ถ้าบวกเกิน 65535 จะ wrap กลับเป็น 0 (modulo 2^16); compiler ใช้ unsigned compare

เลือก type เพื่อดูจำนวนบิต, ช่วงค่า และพฤติกรรม overflow. จำนวนบิตกำหนดช่วง; signed/unsigned กำหนด operator และการ compare ที่ compiler ใช้.

§4.3 Constants และ Variables

การเขียน constant มีรูปแบบตามฐานเลข. ฐานสิบเป็น default, ฐานสิบหก (hex) ขึ้นต้นด้วย 0x, ฐานแปด (octal) ขึ้นต้นด้วย 0 — ระวังว่าเลขฐานสิบห้ามใส่ 0 นำหน้าเพราะจะกลายเป็น octal. ส่วน char เขียนใน single quote เช่น 'a' (ASCII 0x61) และ string คือ array ของ char ที่จบด้วย null '\0'.

Variable คือตำแหน่งเก็บข้อมูลที่ addressable ประกาศด้วย size, type และชื่อ แล้วถูกจองไว้ใน register, RAM หรือ ROM (สำหรับค่าคงที่) โดยเป็น automatic หรือ static ก็ได้. Array เช่น int n[5]; จะวางต่อเนื่องในหน่วยความจำ (n, n+4, n+8, ...) โดย index เริ่มที่ 0 ถึง N-1 (ตัวแรก index 0 เสมอ).

Static variable เก็บค่าถาวรตลอดโปรแกรมใน RAM ที่ไม่ถูกจองใหม่. ถ้าประกาศนอกฟังก์ชันจะเป็น global scope. ถ้าประกาศในฟังก์ชันด้วย keyword static จะเป็น local scope แต่ค่ายังคงอยู่ข้ามการเรียกฟังก์ชัน ต่างจาก automatic ที่ถูก init ใหม่ทุกครั้งที่เข้าฟังก์ชัน.

§4.4 Operators — เลขคณิต / ตรรกะ / bitwise / shift

operator เลขคณิตได้แก่ + - * / % โดย * / % มีลำดับความสำคัญสูงกว่า + - เช่น j*k+m/n หมายถึง (j*k)+(m/n). operator bitwise ทำงานทีละบิตครบทั้ง n บิต ได้แก่ & (AND), | (OR), ^ (XOR), ~ (complement) ซึ่งต่างจาก Boolean operator && (AND) และ || (OR) ที่ให้ผลจริง/เท็จของทั้งค่า.

Bit masking คือหัวใจของการคุม GPIO: A & 0xFE ใช้เคลียร์บิต 0, A | 0x01 ใช้เซ็ตบิต 0, A ^ 0x01 ใช้สลับบิต 0, และ A & 0x01 ใช้ทดสอบบิต 0. ส่วน shift: x << y เลื่อนซ้ายเติม 0 เท่ากับคูณ 2^y และ x >> y เลื่อนขวาเท่ากับหาร 2^y ซึ่งเร็วกว่าการคูณ/หารตรง ๆ และใช้แพ็ก BCD ได้.

ข้อผิดพลาดยอดฮิตคือสับสนระหว่าง = (assignment) กับ == (equality). if (m = n) หมายถึง assign ค่า n ให้ m แล้วทดสอบว่าค่า ≠ 0 ซึ่งมักไม่ใช่สิ่งที่ตั้งใจ. แนวทาง MISRA C เป็นชุดกฎการเขียน C ให้ปลอดภัยสำหรับงานยานยนต์/embedded ที่ช่วยป้องกันบั๊กประเภทนี้.

Bit masking กับ register ของ GPIOc
GPIOA->ODR |= 0x01;          // เซ็ตบิต 0 (ขา PA0 = 1)
GPIOA->ODR &= ~0x01;         // เคลียร์บิต 0 (ขา PA0 = 0)
GPIOA->ODR ^= 0x01;          // สลับบิต 0 (toggle)

if ((GPIOA->IDR & 0x80) != 0) {   // ทดสอบบิต 7
    // PA7 เป็น 1
}

uint16_t sw = GPIOB->IDR & 0x10;  // อ่าน PB4: ได้ 0 หรือ 0x10
VIZ-20Bit-Mask Playground
A (คลิก)
0xA5
mask (คลิก)
0x0F
ผลลัพธ์: A & 0x0F = 0x05
result
00000101
0x05

คลิกบิตเพื่อแก้ไบต์ A และ mask แล้วเลือก operator. & ใช้ทดสอบ/เคลียร์บิต, | เซ็ตบิต, ^ สลับบิต, ~ กลับทุกบิต — คือ bit masking ที่ใช้คุม ODR/IDR ของ GPIO.

VIZ-21Shift Visualizer (<< , >>)
ทิศทาง
จำนวน n
x = 6
00000110
result = 12
00001100

x << 1 = x × 2^1 = x × 2
6 << 1 = 12

เลื่อนบิตซ้าย/ขวา n ตำแหน่ง (เติม 0). เลื่อนซ้ายเท่ากับคูณ 2^n, เลื่อนขวาเท่ากับหาร 2^n — เร็วกว่าการคูณ/หารตรง ๆ และใช้แพ็ก BCD ได้.

§4.5 Control Structures

การควบคุมลำดับการทำงานมีสองกลุ่ม. กลุ่มการตัดสินใจ: if, if-else, else-if หลายชั้น และ switch-case (ทดสอบตัวแปรกับค่าคงที่หลายค่า). กลุ่มการวนซ้ำ: while (ทดสอบเงื่อนไขก่อนวน), do-while (ทำก่อนแล้วค่อยทดสอบ จึงรันอย่างน้อย 1 รอบ), for (init; cond; update) ซึ่งเป็นรูปย่อของ while. ทุกลูปต้องมีสิ่งที่ทำให้เงื่อนไขเป็นเท็จในที่สุด ไม่งั้นจะวนไม่จบ.

ตัวอย่าง polling ขา: while ((GPIOA->IDR & 0x01)==0) {} คือรอจนกว่า PA0 จะเป็น 1 แล้วจึงไปอ่าน GPIOB ต่อ. การใช้ nested for (ลูปซ้อน) นิยมทำ time delay แบบง่าย ส่วน while(1) ใน main เป็น infinite loop ที่ตั้งใจให้ไม่จบ.

polling ปุ่มด้วย while แล้วทำงานตามc
// รอจน PA0 = 1 (ปุ่มถูกกด)
while ((GPIOA->IDR & 0x01) == 0) {
    // busy wait
}
uint16_t data = GPIOB->IDR;   // อ่าน PB เมื่อ PA0 = 1

// switch เลือกงานตามค่าปุ่ม
switch (data & 0x03) {
    case 0x01: GPIOB->BSRR = 0x0001; break;  // set PB0
    case 0x02: GPIOB->BSRR = 0x0002; break;  // set PB1
    default:   break;
}

§4.6 Functions

เราแบ่งโปรแกรมใหญ่ออกเป็นงานย่อยด้วย function เพื่อจัดการความซับซ้อน, นำกลับมา reuse และใช้งาน library ได้. ตัวอย่างการนิยาม เช่น int math_func(int k, int n){ int j; j=n+k-5; return j; } และถ้าไม่มีค่าคืนให้ใช้ชนิด void.

การส่ง argument ทำได้ 2 แบบ. pass by value คือส่งสำเนาค่าเข้าไป ฟังก์ชันจึงแก้ค่าตัวแปรต้นทางไม่ได้. pass by reference คือส่ง address ด้วยเครื่องหมาย & แล้วรับด้วย pointer *y ทำให้ฟังก์ชันอ่านและแก้ตัวแปรต้นทางได้. ทั้งค่าและ address ถูกส่งผ่าน stack. ปิดท้ายสไลด์พูดถึง Finite State Machine (แบบ Mealy หรือ Moore) เป็น teaser ของการออกแบบ logic เชิงสถานะ.

pass by value vs pass by referencec
// pass by value: แก้ค่า x ภายในไม่กระทบตัวจริง
int add5(int x) { return x + 5; }

// pass by reference: รับ address แก้ตัวแปรต้นทางได้
void square(int x, int *y) { *y = x * x; }

void demo(void) {
    int n = 4, k = 0;
    square(n, &k);   // ส่ง address ของ k → k = 16
}

§4.7 GPIO_TypeDef struct (offset) — โยง register กับ C

ชื่อ GPIOB ในภาษา C คือ pointer ที่ชี้ไปยัง struct GPIO_TypeDef ซึ่งถูกวางทับ register block จริงของพอร์ต. ลำดับของ field ใน struct ถูกจัดให้ตรงกับ offset ของ register พอดี เช่น MODER อยู่ที่ offset 0x00, OTYPER ที่ 0x04 ไปจนถึง ODR ที่ 0x14 และ BSRR ที่ 0x18.

เพราะ field เรียงตรงกับ offset ของ register การเขียน GPIOB->ODR จึงเข้าถึง address = base + 0x14 ได้โดยตรง ไม่ต้องคำนวณ address เอง.

GPIO_TypeDef — struct ที่แมปตรงกับ registerc
typedef struct {
    volatile uint32_t MODER;    // 0x00
    volatile uint32_t OTYPER;   // 0x04
    volatile uint32_t OSPEEDR;  // 0x08
    volatile uint32_t PUPDR;    // 0x0C
    volatile uint32_t IDR;      // 0x10  (input)
    volatile uint32_t ODR;      // 0x14  (output)
    volatile uint32_t BSRR;     // 0x18  (atomic set/reset)
    volatile uint32_t LCKR;     // 0x1C
    volatile uint32_t AFR[2];   // 0x20-0x24
} GPIO_TypeDef;

// GPIOB_BASE = AHB1PERIPH_BASE + 0x0400
// GPIOB->ODR เข้าถึง address = GPIOB_BASE + 0x14
VIZ-22GPIO_TypeDef — Field → Address
พอร์ตbase = 0x4002_0400
GPIOB->ODR

output data

base = 0x4002_0400
offset = 0x14
address = 0x4002_0414

struct GPIO_TypeDef วางทับ register block จริง. เลือกพอร์ตและ field แล้วดู address = base + offset. เช่น GPIOB->ODR = GPIOB_BASE + 0x14.

แบบฝึกหัด

12 ข้อ — แต่ละข้อมีชนิดและระดับความยากกำกับ กดปุ่ม "ดูเฉลย" เพื่อดูคำตอบและคำอธิบาย

  1. 1.ปรนัยง่าย

    ตัวแปร global ในโปรแกรม MCU ถูกวางไว้ที่ใด

  2. 2.ปรนัยง่าย

    ชนิด uint8_t มีช่วงค่าเท่าใด

  3. 3.ปรนัยกลาง

    operation ใดใช้เคลียร์บิต 0 ของ A โดยไม่กระทบบิตอื่น

  4. 4.ปรนัยกลาง

    A ^ 0x01 ทำอะไรกับบิต 0

  5. 5.ถูก/ผิดง่าย

    x << 3 ให้ผลเท่ากับ x คูณ 2^3 = 8

  6. 6.ถูก/ผิดกลาง

    การส่ง argument แบบ pass by value ทำให้ฟังก์ชันแก้ค่าตัวแปรต้นทางได้

  7. 7.เติมคำกลาง

    เซ็ตบิต 0 ของ ODR ใช้ GPIOA->ODR ____ 0x01; ส่วนการเคลียร์ใช้ GPIOA->ODR ____ ~0x01;

  8. 8.เติมคำง่าย

    การทดสอบความเท่ากันใช้ operator ____ ส่วนการ assign ใช้ operator ____

  9. 9.ปรนัยกลาง

    GPIOB->ODR เข้าถึง address ใด เมื่อ ODR มี offset = 0x14

  10. 10.ปรนัยกลาง

    ลูปใดทำงานอย่างน้อย 1 รอบเสมอ (ทำ body ก่อนทดสอบเงื่อนไข)

  11. 11.วิเคราะห์ยาก

    if (m = n) ต่างจาก if (m == n) อย่างไร และทำไมจึงเป็นบั๊ก

  12. 12.วิเคราะห์กลาง

    อ่านปุ่ม PB4 ด้วย sw = GPIOB->IDR & 0x10; ได้ค่าอะไรบ้าง และควรใช้ data type ใด