Try   HackMD

嵌入式工程師的 0x10 個 C 語言問題

原文:A ‘C’ Test: The 0x10 Best Questions for Would-be Embedded Programmers
紀錄閱讀這篇原文時做的筆記~

Preprocessor

1. 使用 #define 宣告一個常數,這個常數代表一年有多少秒(不用考慮閏年)

#define SECONDS_PER_YEAR (60UL * 60UL * 24UL * 365UL)

要注意的地方:

  1. #define 的基本語法,例如:
    • 不需要以分號 ; 做結尾
    • 使用 ( ) 包起來,以確保運算順序
  2. Macro 的命名:
    • 良好的 macro 命名習慣是全部都大寫字母,且用 _ 分隔不同單字
  3. 使用 macro 的話,pre-processor 會在編譯前將 macro 的內容做展開,所以我們不必自己事先計算一年有多少秒
  4. 以上計算的結果在 16-bit 的機器會造成 overflow,因此才用 L,告訴編譯器要將這個數字視為 Long
    • 16-bit unsigned integer 範圍:0 ~ 65,535
      一年有 31,536,000 秒
  5. 更細心一點的話,標示為 unsigned long UL 以避免 signed 和 unsigned 的陷阱

第五點原文:

As a bonus, if you modified the expression with a UL (indicating unsigned long), then you are off to a great start because you are showing that you are mindful of the perils of signed and unsigned types

我想作者的意思應該是說:
若一個 expression 內,同時有 signed 跟 unsigend,那 signed 其實會被 implicitly 解讀成 unsigned

image

image

以上擷取自 CMU 15-213 「Bits, Bytes, and Integers」課程投影片

若一個 expression 同時有 signed 和 unsigned 的數字,則 signed 數字會被解讀成 unsigned(bit pattern 不會變,只是被解讀成 unsigned)。

例如以下程式碼:

#include <stdio.h>

int my_arr[] = { 21, 22, 23, 24, 25, 26 };

#define MY_ARRAY_SIZE (sizeof(my_arr)/sizeof(my_arr[0]))

int f(void) {
    int d = -1;
    int x = 0;

    if (d <= MY_ARRAY_SIZE) {
        x = my_arr[d + 2];
    }

    return x;
}

int main(int argc, char *argv[]) {

    int x = f();
    printf("%d\n", x);    // 得到 0

    return 0;
}

為什麼 return 的 x 是 0?因為問題出在 if 的判斷句: d <= MY_ARRAY_SIZE

  • (sizeof(my_arr)/sizeof(my_arr[0])) 得到的是無號的整數 size_t
  • 但是 d 是有號數!
  • 如果有號數和無號數在同一個 expression 內做比較,那有號數會被 implicitly 轉換為無號整數,然後進行比較。
  • 因此 -1 若被看成無號數,那會是一個很大的整數,所以 d 就不可能小於等於 MY_ARRAY_SIZE,所以 x 就會一直是 0。

補充:使用 macro 時為什麼要多善用 ( )

考慮以下例子:

#include <stdio.h>
#define SQUARE(X) X * X

int main(int argc, char *argv[]) {

    int x = 10;
    printf("%d\n", SQUARE(x + 1));
    // 以上寫法在 macro 展開後會變成 x + 1 * x + 1
    // 結果就變成 2x + 1,而不是原本想要的 (x + 1) * (x + 1)
    
    int result = 100/SQUARE(5);
    // 預期應該要得到 4
    // 但 macro 展開後會變成 100/5*5
    // 所以實際得到的是 100
    
    return 0;
}

所以在使用 macro 時,良好習慣就是用 ( ) 包起來,以確保運算順序是正確的

補充:32-bit 和 64-bit 電腦 Data Type Size

image

以上擷取自 CMU 15-213 「Bits, Bytes, and Integers」課程投影片

2. 寫一個標準的 MIN macro,也就是給兩個參數,return 比較小的那個

#define MIN(A, B) ((A) <= (B) ? (A) : (B))

要注意的地方:

  1. 瞭解如何使用 #define 來定義 macro。Macro 可產生 inline code,而在嵌入式系統通常會滿常用到 inline code,因為可達到好的效能。
  2. 瞭解使用 ternary conditional operator 的好處。Ternary conditional operator 可以讓編譯器產生比 if-then-else 更優化的程式碼。由於在嵌入式系統,「效能」是很重要的議題,因此懂得使用 ternary conditional operator 是很重要的。
  3. 瞭解括號在使用 macro 的重要性。
  4. 從這題可以討論到一個使用 macro 要很小心的點,像是以下的程式碼會發生什麼事?
least = MIN(*p++, b);

以上寫法,在經由 pre-processor 展開後會變成以下:

least = ((*p++) <= (b) ? (*p++) : (b));

接下來,先來看 *p++ 是在做什麼事~再看這樣的 macro 有可能會造成怎樣的問題。

首先,依據 C Operator Precedence 可以看到,postfix increment 的優先權是高於 * dereference operator:

image

所以 *p++ 其實是 *(p++),實際執行的順序是:

  1. 先將 p 的值(也就是某個記憶體位址)取出來,然後做 dereference 以取得儲存在該記憶體位址的 data
  2. 然後 p 再遞增,指向下一個 data 所在的位址(也就是 p++,會讓 p 增加一個 sizeof(<data_type>) 單位)

而這項的 MIN macro 的寫法會造成的問題是,假如 *p 所取得的 data 的值是 小於等於 b,那 *p++ 在 macro 展開後 會重複出現兩次,導致 p++ 被執行兩次

  1. 第一次是在 (*p++) <= (b)
  2. 第二次則是在 (*p++) : (b) 執行

所以最終 p 指向的記憶體位址會是:原本的記憶體位址 + 兩個 data type 的 size。

如以下例子:

#include <stdio.h>

#define MIN(A, B) ((A) <= (B) ? (A) : (B))

int main() {

    int nums[] = {100, 200, 300, 400};
    int n = 1000;
    int *p = &nums[0];
    printf("%p\n", p);        // 0x16d0ef5e0
    printf("%p\n", p + 1);    // 0x16d0ef5e4
    printf("%p\n", p + 2);    // 0x16d0ef5e8
    int least = MIN(*p++, n);

    printf("nums[0]: %d, n: %d, least: %d\n", nums[0], n, least);
    // nums[0]: 100, n: 1000, least: 200
    
    printf("%d\n", *p);       // 300
    printf("%p\n", p);        // 0x16d0ef5e8

    return 0;
}

可以看到 p 最後指向的記憶體位址是 0x16d0ef5e8,也就是 0x16d0ef5e0 加上兩個 sizeof(int)

補充:使用 typeof Operator

若要解決前述傳入 ++xx++ 而造成被執行兩次的問題,可以使用 typeof Operator

在 C23 標準(ISO/IEC 9899:2024 (en) — N3220 working draft),typeof 成為了 C 語言的一個 operator(在 C23 之前,typeof 是 GNU GCC 的 Extension,但 C23 就成為 C 語言的標準了~)

typeof 更詳細的內容可以看 C23 標準 6.7.3.6 Typeof specifiers 此章節。

/* macro.c */
#include <stdio.h>

#define MIN(a,b)             \
   ({ typeof(a) _a = (a);    \
       typeof(b) _b = (b);   \
     _a <= _b ? _a : _b; })

int main() {

    int nums[] = {100, 200, 300, 400};
    int n = 1000;

    int *p = &nums[0];
    printf("%p\n", p);            // 0x16f76f5e0
    printf("%p\n", p + 1);        // 0x16f76f5e4
    printf("%p\n", p + 2);        // 0x16f76f5e8
    int least = MIN(*p++, n);

    printf("nums[0]: %d, n: %d, least: %d\n", nums[0], n, least);
    // nums[0]: 100, n: 1000, least: 100

    printf("%d\n", *p);           // 200
    printf("%p\n", p);            // 0x16f76f5e4

    return 0;
}

編譯並執行:

$ gcc macro.c -std=c23 -o macro && ./macro

PS. 若要使用 C23 標準,可以將 GNU GCC 更新到 gcc-14,並使用 -std=c23 這個 option 就能使用 C23 標準囉
若 GNU GCC 是版本是 gcc-13,那就使用 -std=c2x 這個 option。

從以上結果就可以看到,*p++ 其實就只有被執行一次~

補充:從組合語言來看 Ternary Conditional Operator

這篇原文作者有提到 ternary conditional operator 可以產生比 if-else 還好的程式碼,所以就觀察看看會產生怎樣的組合語言~

有以下兩個 .c 檔:

/* if-else.c */
int main() {
    int n1 = 100;
    int n2 = 200;
    int n3 = 0;

    if (n1 > n2) {
        n3 = n1;
    }
    else {
        n3 = n2;
    }

    return 0;
}
/* ternary.c */
int main() {
    int n1 = 100;
    int n2 = 200;
    int n3 = 0;

    n3 = (n1 > n2) ? n1 : n2;

    return 0;
}

接下來,我在我的 ARM64 Ubuntu 24.04 虛擬機安裝 x86-64 的 cross-compiler:

$ sudo apt install gcc-x86-64-linux-gnu

用以下兩個指令產生 x86-64 的 intel 語法的 NASM 組合語言程式碼(因為我只有學過 NASM 所以才轉成 NASM 格式的 x86-64 組語 XD),且沒開編譯器最佳化(-O0):

$ x86_64-linux-gnu-gcc -S -masm=intel -O0 -no-pie ternary.c -o ternary_nasm_asm
$ x86_64-linux-gnu-gcc -S -masm=intel -O0 -no-pie if-else.c -o if-else_nasm_asm

以下是 ternary_nasm_asmmain

main: .LFB0: .cfi_startproc endbr64 push rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 mov rbp, rsp .cfi_def_cfa_register 6 mov DWORD PTR -12[rbp], 100 ; n1 mov DWORD PTR -8[rbp], 200 ; n2 mov DWORD PTR -4[rbp], 0 ; n3 mov edx, DWORD PTR -8[rbp] ; move n2 到 edx register mov eax, DWORD PTR -12[rbp] ; move n1 到 eax register cmp edx, eax ; 做 n2 - n1,並將結果設置到 EFLAGS register 的 status flags cmovge eax, edx ; 依據前面 cmp 指令設置的 status flgs 做判斷 ; 若條件成立( SF == OF ),就將 edx (n2) 的值 mov 到 eax mov DWORD PTR -4[rbp], eax ; 將 eax 的值放到 n3 mov eax, 0 ; 清空 eax register pop rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0:

以下是 if-else_nasm_asmmain

main: .LFB0: .cfi_startproc endbr64 push rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 mov rbp, rsp .cfi_def_cfa_register 6 mov DWORD PTR -12[rbp], 100 ; n1 mov DWORD PTR -8[rbp], 200 ; n2 mov DWORD PTR -4[rbp], 0 ; n3 mov eax, DWORD PTR -12[rbp] ; 將 n1 的值 move 到 eax register cmp eax, DWORD PTR -8[rbp] ; 做 n1 - n2,並將結果設置到 EFLAGS register 的 status flags jle .L2 ; 若 n1 <= n2,則跳到L2 mov eax, DWORD PTR -12[rbp] ; 接下來這裡是 n1 > n2 的 case ; 將 n1 的值 move 到 eax register mov DWORD PTR -4[rbp], eax ; 將 n1 的值 move 給 n3 jmp .L3 .L2: mov eax, DWORD PTR -8[rbp] ; 將 n2 的值 move 到 eax mov DWORD PTR -4[rbp], eax ; 將 n2 的值給 n3 .L3: mov eax, 0 pop rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0:

可以看到最主要的差異在於,這個例子的 ternary condition operator,使用了 x86-64 的 cmovge(conditional move if greater or equal)指令,這種 condition move(CMOVcc)指令是 branchless 的。它會依據前面 cmp 指令的結果決定要不要將 edx move 到 eax。這種做法就不用跳來跳去,比較有效率。

if-else 的方式則會產生 conditional jump 的組合語言。如果預測錯誤,那 CPU pipeline 就要 flush 然後重抓指令,這樣會比較沒效率。而 conditional move 的指令就能避免這種事情發生,所以會更有效率。

這篇有對 conditional jump 和 conditional move 的一些說明
https://stackoverflow.com/questions/26154488/difference-between-conditional-instructions-cmov-and-jump-instructions


接下來來實際觀察看看執行時間的差異~

以下兩個程式碼,為了要看出比較顯著的差異,改成讓 ternary conditional operator 和 if-else 都各執行 100000000 次。
此外,每一個 iteration 都變更 n1n2 的值,增加 misprediction 的機率。

/* ternary_time.c */
#include <stdio.h>
#include <time.h>

int main() {
    int n1 = 100;
    int n2 = 200;
    long long n3 = 0;

    clock_t start = clock();

    for (int i = 0; i < 100000000; i++) {
        n3 += (n1 > n2) ? n1 : n2;
        n2 = n1;
        n1 = i % 300;
    }

    clock_t end = clock();

    printf("n3: %lld\n", n3);
    printf("time: %f seconds\n", (double)(end - start) / CLOCKS_PER_SEC);

    return 0;
}
/* if-else_time.c */
#include <stdio.h>
#include <time.h>

int main() {
    int n1 = 100;
    int n2 = 200;
    long long n3 = 0;

    clock_t start = clock();

    for (int i = 0; i < 100000000; i++) {
        if (n1 > n2) {
            n3 += n1;
        }
        else {
            n3 += n2;
        }

        n2 = n1;
        n1 = i % 300;
    }

    clock_t end = clock();
    
    printf("n3: %lld\n", n3);
    printf("time: %f seconds\n", (double)(end - start) / CLOCKS_PER_SEC);

    return 0;
}

用以下指令編譯:

$ gcc -O0 ternary_time.c -o ternary_time
$ gcc -O0 if-else_time.c -o if-else_time

結果如下:

image

可以看到執行時間差了一倍多,有顯著的差異存在~

3. 使用 #error 這個 Pre-processor 指令的目的為何?

原文所提供的參考資料:In Praise of the #error Directive
Diagnostic directives - C
Diagnostic directives - C++

在 C/C++,#error 會終止編譯過程,並產生指定的錯誤訊息。使用此指令可以讓 programmer 在編譯早期就發現某些錯誤條件,讓 programmer 能即時發現和處理問題。

#error 指令的語法如下:

#error "error_message"

其中,error_message 是一個字串,表示要顯示的錯誤訊息。

ex.

/* error.c */
#include <stdio.h>

#define MY_MACRO (1)

#ifndef MY_MACRO
#error "MY_MACRO is not defined at the first check point"
#endif

#undef MY_MACRO

#ifndef MY_MACRO
#error "MY_MACRO is not defined at the second check point"
#endif

int main() {

    printf("MY_MACRO: %d\n", MY_MACRO);

    return 0;
}

image

Infinite Loops

4. 嵌入式系統時常會用到 Infinite Loops,在 C 語言我們可以怎麼做出 Infinite Loops 呢?

有許多方法可以寫出無限迴圈,原文作者比較喜歡的是以下這個寫法:

while (1)
{
    // do something
}

另一個寫法如下:

for (;;)
{
    // do something
}

第三個寫法如下:

Loop:

// do something

goto Loop;

Data Declarations

5. 用變數 a 寫出以下 definitions

  1. An integer
  2. A pointer to an integer
  3. A pointer to a pointer to an integer
  4. An array of ten integers
  5. An array of ten pointers to integers
  6. A pointer to an array of ten integers
  7. A pointer to a function that takes an integer as an argument and returns an integer
  8. An array of ten pointers to functions that take an integer argument and return an integer

解答:

// 1. An integer
int a;

// 2. A pointer to an integer
int *a;

// 3. A pointer to a pointer to an integer
int **a;

// 4. An array of ten integers
int a[10];

// 5. An array of ten pointers to integers
int *a[10];

// 6. A pointer to an array of ten integers
int (*a)[10];

// 7. A pointer to a function that takes an integer as an argument and returns an integer
int (*a)(int);

// 8. An array of ten pointers to functions that take an integer argument and return an integer
int (*a[10])(int);

補充:CMU 15-213 課程提到如何解析 C 語言 Declaration 的技巧

這個解析的技巧就是:

  1. 先找到變數名稱
  2. 再來看變數名稱左右有沒有 operator,若兩側都有 operator 就看哪個優先權比較高,就優先用那個 operator 去解析
    • 在變數宣告時,我們會遇到的 operator 基本上只有 *( )[ ] 這三種 operator
      image
    • 這三個 operator 優先權最高的是 ( ),再來是 [ ],最後是 *
    • ( ):代表是 function
      • PS. 不要跟用來保護運算順序用的括號搞錯喔~這裡的 ( ) 指的是 function 傳入參數的 ( )
      • 像是若看到 (),就代表沒有指定這個 function 的參數
      • 若看到 (int, float) 則代表這個 function 的第一個參數是 integer,第二個參數是 float
    • [ ]:代表是 array
    • *:代表是 pointer
  3. 解析完最內層後,再往外一層解析,順序也是先看左右兩側有沒有 operator,然後看哪個 operator 優先權最高就先用該 operator 做解析
  4. 後續依此類推

知道這個技巧後~再複雜再變態的 declaration 都有辦法解析了 >///< 開心 🥳
真的大推 CMU 15-213 這門課!!!


int *p;

001


int *p[13];

002


int *(p[13]);

003


int **p;

004


int (*p)[13];

005


int *f();

006


int (*f)();

007


void (*f[10])(void *);

008


int (*(*f())[13])();

009


int (*(*x[3])())[5];

010


int (*(*f(int, float))(float))[3];

011

補充:關於 f()f(void) 在 C 和 C++ 的差別

這是 cppreference 關於 C++ Function declaration 的頁面

cpp
可以看到在 C++,f()f(void) 都是指 empty parameter list,就是指這個 function 沒有參數。

但在 C 語言

C
這兩種 function 的宣告方是是不一樣的意思,f(void) 代表這個 function 沒有參數,而 f() 則代表這個 function 的參數未定。

因為我是先學 C++,所以我的認知一直是 C++ 的版本,直到最近才知道原來 f(void)f() 在 C 語言是不等價的 😱🤯😵‍💫😵

!!!更新!!!

Picture1

在 C23 有新的規範了。依據 C23 標準 § 6.7.7.4 (13),f() 這樣的宣告就相當於 f(void) 這樣的宣告,兩者的效力都是一樣的,都是指這個 function 是沒有參數的。

Static

關於 C/C++ static 的用法,我覺得這篇講的超清楚的:https://shengyu7697.github.io/cpp-static/

6. static 這個 keyword 的作用是什麼?

static 在 C 語言的三個主要用途:

  1. 用在 local variable:該 local variable 的生命週期(lifetime)會變的跟整個程式一樣,也就是該變數的值會在 function 多次呼叫間保留下來。
  2. 用在 global variable:將該 global variable 的作用範圍(scope)限制在該 .c 檔,只有該 .c 檔可以存取到該 global variable,其他 .c 檔無法存取到該 global variable
  3. 用在 function:將該 function 的作用範圍限制在該 .c 檔,只有該 .c 檔可以存取到該 function,其他 .c 檔無法存取到該 function

所以 static 主要是可以延長 local variable 的生命週期,或是將 function/global variable 的作用範圍限制在該 .c 檔。

補充:C 語言的 Translation Unit、Scope、Static Storage Duration、Internal Linkage

Translation Unit

image
依據 C 語言標準 § 5.1.1.1 (1),一個 translation unit 是指一個 .c 檔(source file)加上經由其 #include 所包含的所有檔案,且是經過 preprocessor 處理展開後的整體內容。


Scope 作用域

image
image

依據 C 語言標準 § 6.2.1 (4):

  • File Scope
    • 被宣告在 block 或參數列表之外的 identifier(identifier 就是指變數名稱或 function 名稱),他的 scope 就是 file scope。
    • 像是 global variable,以及被定義在 block 外部的 function(例如,一般的 function definition)。
    • 具有 file scope 的 identifier,在自從他被宣告之後,在整個 translation unit 裡面都可以被看到和使用。
      我覺得原文用 terminaties at the end of the translation unit,聽起來好像會有一種像是到某個時間點結束的概念,這樣好像會有點容易跟生命週期(lifetime)的概念搞混 >_<,但這裡指的是作用域喔!
      • Scope 作用域:編譯期的概念,編譯器用來判斷在哪裡可以用這個 identifier
      • Lifetime 生命週期:執行期間的概念,也就是在程式執行時,這個 identifier 什麼時候會存在
  • Block Scope
    • 如果一個 identifier 是在一個 block 內被宣告(像是 if block 或 while block),或是在 function definition 的參數列表中被宣告,那這樣的 identifier 就具有 block scope。
    • 具有 block scope 的 identifier,在自從他被宣告後,在他所在的 block 內都可以看到和使用它。
  • Function Prototype Scope
    • 被宣告在 function prototype 的參數列表內的 identifier。

The static Storage-Class Specifiers

依據 cppreference

The static specifier specifies both static storage duration (unless combined with _Thread_local)(since C11) and internal linkage (unless used at block scope).
It can be used with functions at file scope and with variables at both file and block scope, but NOT in function parameter lists.

使用 static 宣告的話,會被設定成 static storage durationinternal linkage

  • 但若還有合併 _Thread_local 關鍵字,那就會是 thread storage duration,而不是 static storage duration
  • 若是 block scope,那就不會有 linkeage,因為只會有 static storage duration

static 可以用在:

  • File scope 的 function
  • File scope 跟 block scope 的變數

那什麼是 static storage durationinternal linkage


Static Storage Duration

每個物件都會有所謂的 storage duration 的特性,會用來決定物件的生命週期(lifetime)。C 語言有四種 storage duration,分別是 automaticstaticthread、以及 allocated

依據 cppreference

Static storage duration. The storage duration is the entire execution of the program, and the value stored in the object is initialized only once, prior to main function. All objects declared static and all objects with either internal or external linkage that aren't declared _Thread_local (until C23) thread_local (since C23)(since C11) have this storage duration.

  • 擁有 static storage duration 的物件,其生命週期就是程式執行的整個期間。
  • 這類物件的值只會被初始化一次,且是在 main function 被執行前就會被初始化。
  • 怎樣的物件會有 static storage duration
    • static 關鍵字宣告的物件
    • Internal linkage 的物件
    • External linkage 且沒有被宣告成 _Thread_localthread_local 的物件

Internal Linkage

Linkage 是指一個 identifier(變數或 function)能否被其他 scope 存取到的能力。C 語言有三種 linkage,分別是 no linkageinternal linkage、以及 external linkage

依據 cppreference

internal linkage. The variable or function can be referred to from all scopes in the current translation unit.
All file scope variables which are declared static or constexpr(since C23) have this linkage, and all file scope functions declared static
(static function declarations are only allowed at file scope).

  • 具有 internal linkage 的變數或 function,只能被他所在的 translation unit 的所有 scope 存取。
  • 那怎樣的變數和 function 會是 internal linkage?
    • 所有被宣告為 staticconstexpr 的 file scope 變數
    • 所有被宣告為 static 的 file scope functions
      • PS. static function 的宣告只允許發生在 file scope

Static Storage Duration 物件的初始化

image
image

依據 C 語言標準 § 6.7.11 (11),若 static storage duration 物件沒有被初始化,那他會被初始化成其 data type 的 0。

補充:static 變數會被放在哪

image

以上擷取自 CMU 15-213「Linking」課程的投影片

一個 Process 的 memory layout 包含 stack、heap、data section(data segment)、text section(text segment)。

其中 data section 又分成 .data section 和 .bss section。

  • .data section 放的是有初始值的 global variable 和 static variable
  • .bss section 放的則是無初始值的 global variable 和 static variable,這些 variable 會被初始化成其 data type 的 0

假設有以下程式碼:

/* static.c */
#include <stdio.h>

int global_var_initialized = 100;
int global_var_uninitialized;

static int static_global_var_initialized = 200;
static int static_global_var_uninitialized;

int main() {
    int local_var_initialized = 300;
    int local_var_uninitialized;

    static int static_local_var_initialized = 400;
    static int static_local_var_uninitialized;

    printf("global_var_uninitialized value:        %d\n", global_var_uninitialized);
    printf("static_global_var_uninitialized value: %d\n", static_global_var_uninitialized);
    printf("local_var_uninitialized value:         %d\n", local_var_uninitialized);
    printf("static_local_var_uninitialized value:  %d\n", static_local_var_uninitialized);

    printf("address of global_var_initialized:          %p\n", &global_var_initialized);
    printf("address of global_var_uninitialized:        %p\n", &global_var_uninitialized);
    printf("address of static_global_var_initialized:   %p\n", &static_global_var_initialized);
    printf("address of static_global_var_uninitialized: %p\n", &static_global_var_uninitialized);
    printf("address of local_var_initialized:           %p\n", &local_var_initialized);
    printf("address of local_var_uninitialized:         %p\n", &local_var_uninitialized);
    printf("address of static_local_var_initialized:    %p\n", &static_local_var_initialized);
    printf("address of static_local_var_uninitialized:  %p\n", &static_local_var_uninitialized);

    return 0;
}

用以下指令編譯:

$ gcc -no-pie static.c -o static

執行的結果:

image

  • 可以看到,沒有初始值的 global variable 和 static variable(無論是 global 或 local)都會被初始化成 0

記憶體位址重新整理後如下:

記憶體位址 變數名稱
0xffffdebe7b04 local_var_uninitialized
0xffffdebe7b00 local_var_initialized
0x420050 static_local_var_uninitialized
0x42004c static_global_var_uninitialized
0x420048 global_var_uninitialized
0x420040 static_local_var_initialized
0x42003c static_global_var_initialized
0x420038 global_var_initialized
  • 可以看到未初始化的 global 和 static variable 都被放在一起
  • 有初始值的 global 和 static variable 也都被放在一起

image

  • 使用 objdump 去查看可執行檔的 symbol table,可看到有初始值的 global 和 static 變數都被放在 .data section
  • 無初始值的 global 和 static 變數則都被放在 .bss section

Const

7. const 這個 keyword 的作用是什麼?

const 修飾的變數,在初始化後就不能再修改他的值。編譯器負責在編譯時期檢查 const 變數是否有被修改。

以下變數的宣告,分別代表什麼意思:

const int a; int const a; const int *a; int * const a; int const * a const;

前兩個都是 constant integer。

在這裡先提一下第五個 int const * a const;,第五個是原文裡面出現的,作者想表達的是 constant pointer to constant,但是 const 在 C 語言不能寫在變數名稱後面,這樣在編譯時期會出現 error 喔,如以下截圖:

upload_fded058984a52cca91b3c7741ff6a072

const 跟 pointer 名稱,到底該怎麼放?

補充:const pointer 的語法

跟據 cppreference 的說明:

upload_c3191704810ef92ca87a6d3975388e32

宣告 pointer 的語法是:

*   attr-spec-seq(optional)   qualifiers(optional)   declarator
  • qualifiers 就是像 constvolatile 這類的修飾詞
  • declarator 則是我要宣告的 pointer 的名稱
  • 出現在 * 和 pointer 名稱之間的 qualifiers,是用來修飾該 pointer
  • 所以 * const <ptr_name> 就代表這是一個 constant pointer,也就是這個 pointer 只能指向固定的對象,不能讓他指向其他人
    • 宣告這個 pointer 的時候就要初始化他,因為後續就不能更改

所以用來修飾 pointer 的 qualifiers 是出現在 * 和 pointer 名稱之間,且 qualifiers 不應該出現在 pointer 名稱的右側。

接下來~搭配前面在 Data declaration 提到的 CMU 15-213 課程提到的方法~


const int *a;

pic01

  • Pointer to Constant
  • Pointer 所指向的 data 是常數不可以修改這個 data 的值
  • 但我們可以修改 pointer 要指向哪裡
int n1 = 100;
int n2 = 200;
const int *ptr = &n1;

*ptr = 999;    // ERROR
ptr = &n2;     // OK

int * const a;

pic02

  • Constant Pointer
  • 我們可以修改 pointer 所指向的 data
  • 但我們不可以修改 pointer 所指向的對象,所以在宣告 pointer 的時候,就要初始化說這個 pointer 要指向誰,因為後續就不能再作更改了
int n1 = 100;
int n2 = 200;
int *const ptr = &n1;

*ptr = 999;    // OK
ptr = &n2;     // ERROR

const int * const a;

pic03

  • Constant Pointer to Const
  • Pointer 所指向的 data 是常數不可以修改這個 data 的值
  • 我們不可以修改 pointer 所指向的對象,所以在宣告 pointer 的時候,就要初始化說這個 pointer 要指向誰,因為後續就不能再作更改了
int n1 = 100;
int n2 = 200;
const int *const ptr = &n1;

*ptr = 999;    // ERROR
ptr = &n2;     // ERROR

int * const * pcp = &cp;

pic04


int const * const * const a;

pic05


int (*const *f)(void);

pic06

補充:const 的一些資訊

S1PDC29Rke
依據 C 語言標準 § 6.7.4.1 (7),如果嘗試用用一個 non-const lvalue 物件去修改一個 const 物件,這會是 undefined behavior。


B1cTA3q0yx
依據 C 語言標準 § 6.7.4.1,compiler 可以選擇把 const(且非 volatile)的變數放進 read-only 的記憶體區域(像 .rodata)。所以並不是 const 物件都一定會被放到 read only 記憶體區域。
此外,如果程式裡完全沒用到該變數的位址(&obj),那 compiler 甚至有可能不幫你分配記憶體空間(直接在指令裡用常數就好)。


https://en.cppreference.com/w/c/language/const

HJcpvAoRye
如以下例子:

#include <stdio.h>

int main() {
    const int n = 100;
    printf("n: %d\n", n);

    int *ptr = &n;
    *ptr = 999;
    printf("n: %d\n", n);

    return 0;
}

用一個 pointer 去指向一個 const integer,然後再用這個 pointer 去修改它的值,這是 undefined behavior。雖然這段程式碼用 GNU GCC 可以編譯,但是會有 warning,且由於這是 undefined behavior,可能用不同編譯器的結果會不一樣。

image

Volatile

8. volatile 這個 keword 的作用是什麼?舉三個不同的例子說明

簡單來講~volatile 最主要的效果就是禁止編譯器對變數的存取做最佳化的動作,所以每次去存取 volatile 變數時都一定會去 memory 裡面存取該變數。

假設某個變數在程式碼裡面都沒被修改過,也沒有被宣告成 volatile,那編譯器可能就會假設該變數的值都不會被更改,那編譯器在做最佳化的時候可能就會直接省略對該變數的存取,甚至完全移除對該變數的讀取動作。
(這是合理但錯誤的假設,因為如果變數會被其他外部因素更改,像是被 ISR 或其他執行緒修改,那這樣就會有問題)

或是說假如我剛剛已經存取過某個變數的值了,這個變數的值被放在 register,那我接下來要用他的話,編譯器就假設這個變數在 memory 裡面的值都是不會被修改,所以就沒必要再跑去 memory 裡面抓他的值,直接用放在 register 裡面的值就好了。

那怎樣的變數會需要被宣告成 volatile 哩?基本上,只要是「可能在程式碼看不到的情況下會被修改」的變數(像是會被 ISR 或其他執行緒修改),就應該宣告成 volatile,這樣才能避免編譯器做出錯誤的最佳化。

需要設定成 volatile 的情境:

  1. 周邊設備的硬體 register(像是 status register)
  1. 在 ISR 內被使用的非區域變數(原文是用 non-stack variable,就是指不是放在 stack 的變數)
  2. 被多個執行緒共享的變數

原文作者還問了以下幾個更深入的問題:

  1. 一個參數可以同時是 constvolatile 嗎?請解釋你的答案
  2. Pointer 可以是 volatile 嗎?請解釋你的答案
  3. 以下 function 有什麼錯誤?
    ​​​​int square(volatile int *ptr)
    ​​​​{
    ​​​​    return *ptr * *ptr;
    ​​​​}
    

答案如下:

  1. 可以。像是 read only status register,由於他會不預期地被更改,所以他是 volatile;且由於程式不應該試圖去修改它,所以他是 const
  2. 可以,雖然不常見。其中一個例子就是當一個 ISR 修改了一個指向 buffer 的 pointer
  3. 這個問題很缺德(wicked)!這個 function 是要 return 被 *ptr 指向的值的平方,但由於 *ptrvolatile,所以編譯器會產生類似以下的程式碼:
int square(volatile int *ptr)
{
    int a,b;
    a = *ptr;
    b = *ptr;
    return a * b;
}

由於 *ptr 的值有可能會不預期地被改變,因此 ab 的值有可能會是不一樣的,也因此有可能會 return 不是平方的數值。正確的寫法應該如下:

int square(volatile int *ptr)
{
    int a;
    a = *ptr;
    return a * a;
}

補充:從組合語言來觀察 volatile 的效果

/* volatile.c */
int global_var = 0;
volatile int global_volatile_var = 0;

int main() {
    int a = global_var;
    int b = global_volatile_var;
    return 0;
}
  • 以上程式碼,若有開編譯器最佳化的話,由於 ab 這兩個變數後續都不會被操作,編譯器可能會假設這兩個變數都不會被修改和存取,所以若有開編譯器最佳化的話,就沒必要去存取那兩個變數的值然後 assign 給 a/b;但由於 global_volatile_var 有被設定為 volatile,所以編譯器不會對這個變數做任何假設、也就不會做任何優化的動作,所以還是會去記憶體存取這個變數的值
  • 所以若有開編譯器最佳化,預期並不會去記憶體存取 global_var,僅會去記憶體存取 global_volatile_var

接下來,我在我的 ARM64 Ubuntu 24.04 虛擬機安裝 x86-64 的 cross-compiler:

$ sudo apt install gcc-x86-64-linux-gnu

用以下兩個指令產生 intel 語法的 NASM 組合語言程式碼,且分別是將編譯器最佳化開到最大(-O3)和沒開編譯器最佳化(-O0):

$ x86_64-linux-gnu-gcc -S -masm=intel -O0 -no-pie volatile.c -o volatile_intel_O0
$ x86_64-linux-gnu-gcc -S -masm=intel -O3 -no-pie volatile.c -o volatile_intel_O3

以下是沒開編譯器最佳化的 main 的內容:

main: .LFB0: .cfi_startproc endbr64 push rbp .cfi_def_cfa_offset 16 .cfi_offset 6, -16 mov rbp, rsp .cfi_def_cfa_register 6 mov eax, DWORD PTR global_var[rip] mov DWORD PTR -8[rbp], eax mov eax, DWORD PTR global_volatile_var[rip] mov DWORD PTR -4[rbp], eax mov eax, 0 pop rbp .cfi_def_cfa 7, 8 ret .cfi_endproc .LFE0:
  • 在 line 10 和 line 12 可以看到,都有確實去記憶體存取 global_varglobal_volatile_var 的值

以下是有開編譯器最佳化的 main 的內容:

main: .LFB0: .cfi_startproc endbr64 mov eax, DWORD PTR global_volatile_var[rip] xor eax, eax ret .cfi_endproc .LFE0:
  • 可以看到 main 裡面只有去記憶體存取 global_volatile_var 的值(line 5)

Bit Manipulation

9. 嵌入式系統時常會需要操作 register 或變數的 bits

假設有個 integer 變數 a,請寫程式碼:

  1. a 的 bit 3 設為 1
  2. a 的 bit 3 設為 0

解法就是使用 #define 和 bit masks:

#define BIT3 (0x1 << 3)
static int a;

void set_bit3(void) {
    a |= BIT3;
}

void clear_bit3(void) {
    a &= ~BIT3;
}

補充:其他 bit manipulation 考題

Toggle a Bit

Toogle 第 k 個 bit,也就是若第 k 個 bit 是 1,那就把它變成 0;若是 0,則把它變成 1。

int toggleBit(int n, int k) {
    return (n ^ (1 << k));
}

void toggle_bit3() {
    a ^= BIT3;
}

Find a Bit

找出第 k 個 bit 是 0 或 1

int findBit(int n, int k) {
    return ((n >> k) & 0x1);
}

Modify a Bit

將第 k 個 bit 設定為 b

  1. 先將 1 左移 k 位,取得 mask
mask = (1 << k)
  1. 利用 mask 將第 k 個 bit 設定為 0
n = (n & ~mask)
  1. 最後將 b 左移 k 位,然後和前述得到的 n| operation
return ((n & ~mask) | (b << k));

完整程式碼:

int modifyBit(int n, int k, int b) {
    int mask = (1 << k);
    return ((n & ~mask) | (b << k);
}

原文作者也有提到有的人可能會用 bit-field 來達到這樣的效果,但 bit-field 會是 NON-PORTABLE不同編譯器的實作會不一樣喔!所以不要用這種做法去做 bit manipulation

image
依據 C 語言標準 § 6.7.3.2 的內容,bit-field 是 high-order to low-order,或是 low-order to high-order,這是編譯器廠商決定的。

例如以下程式碼,我想用 bit-field 的方式將 num 的第三個 bit 設為 1:

/* test.c */
#include <stdio.h>

typedef struct {
    unsigned int bit_0 : 1;
    unsigned int bit_1 : 1;
    unsigned int bit_2 : 1;
    unsigned int bit_3 : 1;
    unsigned int bit_others : 28;
} MyBitField;

int main() {

    int num = 0x0;
    MyBitField *ptr = (MyBitField *) &num;
    ptr->bit_3 = 1;
    printf("num: %b\n", num);

    return 0;
}

我在 ARM64 的 Ubuntu 24.04 虛擬機用 GNU GCC 編譯:

$ gcc test.c -std=c2x -o test_arm64

接下來,測試用不同的編譯器是否會有差異,這裡用 PowerPC 編譯器來測試。首先要在 Ubuntu 安裝 PowerPC cross-compiler 和模擬器:

$ sudo apt -y install gcc-powerpc-linux-gnu qemu-user

用 PowerPC cross-compiler 編譯:

$ powerpc-linux-gnu-gcc -static test.c -o test_ppc

執行結果:

image

  • 可以看到 test_ppc 是一個 32-bit 的 PowerPC 架構 ELF 可執行檔,且是靜態連結的版本。而 MSB 則是指 most significant byte first,也就是 Big Endian
  • test_arm64 則是一個 64-bit 的 ARM 架構可執行檔,且是 Little Endian
  • 執行這兩個可執行檔,可看到結果是完全不一樣的!這樣的寫法就是 non-portable~

PS. %b 是 C23 新加入的 conversion specifier,在使用 GNU GCC 編譯時記得要設定 -std=c2x(for gcc-13)或 -std=c23(for gcc-14)

image

Accessing Fixed Memory Locations

10. 嵌入式系統時常會需要存取特定記憶體位址。請在記憶體位址 0x67a9 存入一個 integer 0xaa55

有許多寫法可達到這效果,原文作者比較想看到的是類似以下的寫法:

int *ptr;
ptr = (int *) 0x67a9;    // 將 0x67a9 這個數字轉型成 pointer to integer
                         // 並 assign 給 ptr
                         // 這樣 ptr 就指向記憶體位址 0x67a9
*ptr = 0xaa55;

另一種寫法如下:

*(int * const)(0x67a9) = 0xaa55;

原文作者建議在面試時寫第一個寫法比較好~

Interrupts

11. Interrupts 在嵌入式系統扮演著重要的角色。因此,許多編譯器廠商會提供 extension 來 support interrupts

一般而言,編譯器廠商提供的 interrupt extension 的 keyword 最常見的會是 __interrupt

以下程式碼使用 __interrupt 定義了一個 interrupt service routine(ISR),對以下程式碼有何看法呢?

__interrupt double compute_area(double radius) {
    double area = PI * radius * radius;
    printf(“nArea = %f”, area);
    return area;
}

以上程式碼有許多錯誤

  1. ISR 不應該 return value
  2. 不應該傳參數進去 ISR
  3. 許多處理器/編譯器在處理浮點數運算不一定是 re-entrant。有些情況,需要 stack 額外的 registers,有的情況僅僅是不能在 ISR 內做浮點數運算。此外,ISR 本來就應該是短,不會花很多時間
  4. 與第三點類似,printf() 通常會有 reentrancy 和 效能的問題。

補充:其他不該用在 ISR 的東西

Mutex 跟 Semaphore 也不建議用在 ISR 裡面,因為 ISR 要設計成執行時間很短、很快就結束。而 Mutex 跟 Semaphore 是 Blocking 機制,如果已經被鎖住,然後 ISR 又嘗試去鎖,那就會 block 了!

不過有些 RTOS 會提供可以在 ISR 裡面使用的版本,像是 FreeRTOS 就有 xSemaphoreGiveFromISR 這個專門用在 ISR 的版本,這是在 ISR 裡面「釋放」semaphore 的操作。
而在 ISR 裡面釋放 semaphore 大多都是為了要把另一個 task 叫醒,讓他去處理跟 interrupt 有關、但可能較複雜、會需要花比較多時間的事情,這種用法叫做 deferred interrupt handling

補充:Interrupt Extension

原文用的 __interrupt,我只有查到好像是 TMS320C6000 Optimizing Compiler 這個編譯器有提供這個 extension。他們的 user manual 也有提到:

You can only use the __interrupt keyword with a function that is defined to return void and that has NO parameters. The body of the interrupt function can have local variables and is free to use the stack or global variables. For example:

__interrupt void int_handler() { unsigned int flags; ... }

而 GNU GCC 用的是 __attribute__ ((interrupt ("IRQ"))),使用方法是:

void f () __attribute__ ((interrupt ("IRQ")));

https://gcc.gnu.org/onlinedocs/gcc/ARM-Function-Attributes.html

補充:Thread Safety & Re-Entrancy

image

image

以上截自 CMU 15-213「Synchronization: Advanced」課程投影片

Code Examples

12. 以下程式碼的 output 為何?以及為什麼?

void foo(void)
{
    unsigned int a = 6;
    int b = -20;
    (a+b > 6) ? puts("> 6") : puts("<= 6");
}

以上程式碼,a 是無號整數,b 是有號整數。

如前面所述,若一個 expression 同時有有號數和無號數,那有號數會被轉成無號數,所以 a+bb 會是一個非常非常大的數,所以相加後一定是大於 6,所以會印出 > 6

原文作者說這在嵌入式系統非常重要,並建議在嵌入式系統應多使用 unsigned data types(原文作者推薦的文章:Efficient C Code for Eight-Bit MCUS)。

13. 對以下的 code 有何看法?

unsigned int zero = 0;
unsigned int compzero = 0xFFFF; /* 1's complement of zero */

以上程式碼的 compzero 是想要取得 0 的 1 補數,但 0xFFFF 這寫法僅有在 int 是 16 bits 的系統才會得到正確的結果。

但現在大部分的 CPU,int 都是 32 bits,那以上寫法得到的 0 的 1 補數就會是錯誤的喔!

正確的寫法應該如下,這樣才能確保程式碼的可移植性:

unsigned int compzero = ~0;

原文作者說~好的嵌入式工程師是有需要對底層硬體有深入的瞭解和敏感度的!

在 64 bits 的電腦執行以下程式碼,可看到 ~zero 才能得到正確的結果:

#include <stdio.h>

int main() {
    
    unsigned int zero = 0;
    unsigned int compzero1 = 0xFFFF;
    printf("compzero1: %x\n", compzero1);    // compzero1: ffff
    printf("compzero1: %b\n", compzero1);    // compzero1: 1111111111111111

    unsigned int compzero2 = ~zero;
    printf("compzero2: %x\n", compzero2);    // compzero2: ffffffff
    printf("compzero2: %b\n", compzero2);    // compzero2: 11111111111111111111111111111111

    return 0;
}

Dynamic Memory Allocation

14. 雖然較少見,但有時在嵌入式系統還是會需要從 Heap 做 dynamic allocation 來配置記憶體空間。那在嵌入式系統做 dynamic memory allocation 會有什麼 issue 需要注意?

原文作者期望聽到的回答包括像是:

  • Memory fragmentation
    • 當 heap 經過多次的 allocation 和 free,有可能會造成嚴重的 fragmentation 問題,也就是 free memory block 加總起來的空間是夠用的,但我們找不到連續的 free memory block 來 allocate
  • Garbage collection 的問題
    • 由於 C 語言並沒有 garbage collection 的機制,所以釋放記憶體資源這件事是由 programmer 負責~
  • Variable execution time
    • malloc 的執行時間不是固定的,會根據 heap 的狀況而有差異。若 heap fragmentation 的狀況很嚴重,可能會需要找很久才能找到需要的 free memory block
    • 在 real-time system,我們就會希望執行的時間是 deterministic ㄛ!

以下程式碼的 output 為何?以及為什麼?

char *ptr;
if ((ptr = (char *)malloc(0)) == NULL) {
    puts(“Got a null pointer”);
}
else {
    puts(“Got a valid pointer”);
}

image
依據 C 語言的標準,若做了 malloc(0) 這件事,那就是依據各家編譯器廠商自己實作而定,有可能會有兩種狀況:

  1. 回傳 NULL
  2. 或者是他的行為就像是分配了一個 size 非 0 的記憶體,但我們不該用這個 pointer 去存取物件

而 GNU GCC 的實作是會回傳一個 non-NULL pointer。

In the GNU C Library, a successful malloc(0) returns a non-null pointer to a newly allocated size-zero block
https://www.gnu.org/software/libc/manual/html_node/Malloc-Examples.html

補充:有些嵌入式系統不會使用 std C lib 的 mallocfree,因為

  • 很多小型的嵌入式系統沒有提供 std C library
  • Standard C lib 實作的 mallocfree 會佔用比較大的空間,在一些記憶體空間有限的嵌入式裝置並不適用
  • 通常這些並不是 thread safe 的 dynamic allocation 機制
  • 如前所述,執行的時間較不固定,每次呼叫這些 API 所需的時間可能差異頗大。但是在 real-time system 執行時間是 deterministic 是很重要的事
  • 通常嵌入式系統的記憶體空間較有限,而 standard C library 的 dynamic allocation 可能會造成比較嚴重的 fragmentation

補充:FreeRTOS 的 Dynamic Allocation 機制

https://www.freertos.org/Documentation/02-Kernel/02-Kernel-features/09-Memory-management/01-Memory-management

FreeRTOS 提供五種 dynamic allocation 的機制,programmer 可以依照專案的需求選擇合適的方式。

  1. heap_1
    • 最簡單的實作,不支援 free 的操作,所以一旦分配了就不能釋放
    • 保證執行的時間都是 deterministic
    • heap_1 現在已經比較少用了,大多是用在記憶體配置這件事都在初始化階段就完成、不需要做釋放記憶體這個操作的系統
  2. heap_2
    • 可以 allocate 也支援 free 的操作
    • 相鄰的 free memory block 並不會被合併,因此有可能會造成 fragmentation
    • 用 best fit 去分配記憶體區塊。但因為 best fit,就必須要走完整個維護 free memory block 的 link list,才能找到 best fit 的 free memory block,所以執行時間是 non-deterministic
  3. heap_3
    • 是 standard C library 的 mallocfree 的 wrapper,但是在呼叫 mallocfree 之前,會先暫停 scheduler,確保是 thread safe
    • 但仍有 standard C library 的幾個問題:
      • 執行時間是 non-deterministic
      • 佔的空間可能很大
  4. heap_4
    • heap_2 很像,但是會將相鄰的 free memory block 做合併,以降低 fragmentation 的程度
    • 使用 first fit 來分配記憶體區塊
    • 由於使用 first fit,所以仍是 non-deterministic,但比 standard C library 的 malloc 還有效率
  5. heap_5
    • 實作細節跟 heap_4 一樣,用 first fit,且會將相鄰的 free memory block 做合併
    • 但差別在於,可以將多個 physical 記憶體設定成 logically 看起來像是一塊大記憶體

Heap 1 示意圖

freertos_heap_1

Heap 2 示意圖

freertos_heap_2_1
Heap 2 是用 link list 去維護 free memory block,每個 free memory block 的起始位址都會放入 BlockLink_t 這個 struct。該 struct 的pxNextFreeBlock 是一個 pointer,會指向下一個 free memory block 的起始位址;然後 xBlockSize 是紀錄這個 free memory block 的大小。

xStart 是整個 link list 的開頭,xEnd 則是結尾,由這兩個串起維護 free memory block 的 link list,且這個 link list 會依照 free memory block 的 size 由小排到大。

freertos_heap_2_2
這是 heap 剛初始化完的樣子,現在整個 heap 都是 free 的,是一個大的 free memory block,所以在他的起始位址會被放入一個 BlockLink_t

然後 xStartpxNextFreeBlock 就會指向這個大的 free memory block 的起始位址;然後這個大的 free memory block 的 pxNectFreeBlock 就會指向 xEnd

freertos_heap_2_3
這是分配了第一個 task 的狀況,第一個 task 用掉了圖中藍色那部分的 heap。剩下的 heap 還是一整塊 free 的 memory block,所以在剩下的 free memory block 的起始位址也會放入一個 BlockLink_t,然後 xStartpxNextFreeBlock 改成指向這個 free memory block 的起始位址,而這個 free memory block 的 pxNextFreeBlock 則指向 xEnd

橘色的部分則是一開始的 free memory block 放 BlockLink_t 的地方。

freertos_heap_2_4
接下來又分配了第二個 task。

freertos_heap_2_5
最後,假如釋放了那兩個 task 所佔用的記憶體區塊,由於 heap 2 沒有將相鄰 free memory block 合併的機制,所以會有一塊一塊的 free memory block,這樣就會有 fragmentation 的問題!

然後由於這個 free memory block list 會依據 free memory block 的大小由小排到大,所以可看到 xStartpxNextFreeBlock 會先指向最小的那塊,而最小的那塊的 pxNextFreeBlock 則會指向第二小的那塊,第二小的那塊的 pxNextFreeBlock 則指向剩下最大的那塊,最大的那塊的 pxNextFreeBlock 就指向 xEnd

Typedef

15. 在 C 語言,typedef 是用來為既有的 data type 建立別名。我們也可以用 #define 來達到類似的效果,譬如以下程式碼:

#define dPS struct s *
typedef struct s * tPS;

以上兩種方式所宣告的 dPStPS,都是 pointer,且都是指向 struct s 的 pointer,那哪一種方式比較好勒?以及為什麼哩?

考慮以下狀況:

dPS p1, p2;
tPS p3, p4;

使用 #define 的作法,在經由 preprocessor 展開後會變成:

struct s * p1, p2;

這樣的話,只有 p1 是 pointer,而 p2 則是一個 struct s

所以使用 typedef :+1: 會是比較好的做法,才能避免這種狀況發生。

Obfuscated Syntax

16. 以下寫法在 C 語言是合法的嗎?若是合法,那這段程式碼會做什麼事?

int a = 5, b = 7, c;
c = a+++b;

這是合法的寫法,但關鍵在於編譯器該如何解析 a+++b。該解析成 a++ + b?或 a + ++b 勒?

編譯器在 Lexical analysis 階段,會依據 Maximum munch rule 來解析程式碼,也就是當編譯器遇到一串 characters 時,他會盡可能「吃掉」最多的 characters 來組成一個合法的 token。

所謂的「吃掉」就是指,盡量把相鄰的 characters 當成一個完整的語法單位,像是將 ++ 視為一個 token,而不是視為 ++ 兩個 tokens。

所以當編譯器看到 a+++b 時,會先盡量吃掉最多的 characters 來組成一個合法的 token。所以一開始先吃掉 a++,而這是合法的,剩下的 +b 也是合法的 token,那編譯器就會解析成 (a++) + b

c = (a++) + b 實際做的事情就是:

  1. 先將 a 的值取出來,然後跟 b 相加,並 assign 給 c,所以 c 是 12
  2. 之後再做 a++,所以 a 的值變成 6

所以各變數最後的 value 會是:

  • a:6
  • b:7
  • c:12