取得指向類別成員函式的指位器
Tags: esp32, arduino, cpp
Published: 2024-02-23 01:20:32

在開發 ESP32 的應用程式時, 有時候會需要取得指向函式的指位器, 像是要設定中斷的話, 就必須提供一個函式作為中斷觸發時的處理常式, 如果採用一般的函式, 只要直接寫函式名稱就可以了, 例如[這個程式](https://wokwi.com/projects/390440188226367489)：

```cpp
int count = 0;

void ARDUINO_ISR_ATTR isr(void) {
  count++;
}


void setup() {
  // put your setup code here, to run once:
  Serial.begin(115200);
  pinMode(26, INPUT_PULLUP);
  attachInterrupt(26, isr, RISING);
}

void loop() {
  // put your main code here, to run repeatedly:
  delay(200); // this speeds up the simulation
  Serial.println(count);
}
```

但如果你想要使用類別中的成員函式當成中斷處理常式, 直接這樣寫會出錯：

```cpp
class Button {
  public:
    int count = 0;

    void ARDUINO_ISR_ATTR isr(void) {
      count++;
    }

};

Button b;

void setup() {
  // put your setup code here, to run once:
  Serial.begin(115200);
  pinMode(26, INPUT_PULLUP);
  attachInterrupt(26, Button::isr, RISING);
}

void loop() {
  // put your main code here, to run repeatedly:
  delay(200); // this speeds up the simulation
  Serial.println(b.count);
}
```

它會說你用錯非靜態的類別成員函式：

```
sketch.ino: In function 'void setup()':
sketch.ino:17:31: error: invalid use of non-static member function 'void Button::isr()'
   attachInterrupt(26, Button::isr, RISING);
                               ^~~
```

如果改成透過實例, 也一樣會出錯：

```cpp
  attachInterrupt(26, b.isr, RISING);
```

如果改成這樣想要取得成員函式的位址：

```cpp
  attachInterrupt(26, &b.isr, RISING);
```

它會告訴你不允許取得實例的成員函式位址, 也沒辦法幫你把成員函式轉換成一般函式：

```
sketch.ino: In function 'void setup()':
sketch.ino:17:26: error: ISO C++ forbids taking the address of a bound member function to form a pointer to member function.  Say '&Button::isr' [-fpermissive]
   attachInterrupt(26, &b.isr, RISING);
                          ^~~
sketch.ino:17:37: error: cannot convert 'void (Button::*)()' to 'void (*)()'
   attachInterrupt(26, &b.isr, RISING);
                                     ^
```

實際上成員函式就跟一般函式是一樣的, 只不過叫用成員函式的時候, C++ 編譯器會幫你把綁定的實例傳入成為 this 指向的物件。如果你寫過 Python, 對這一點就會很有感。

為了解決這個問題, C++ 提供有 [`std::bind`](https://en.cppreference.com/w/cpp/utility/functional/bind) 函式, 只要傳入類別中成員函式的位址, 以及要綁定在哪一個物件上叫用它, 它就會幫你建立一個 [`std::function`](https://en.cppreference.com/w/cpp/utility/functional/function) 類別的物件, 並且紀錄成員函式的位址與物件, 當你叫用這個 `std:function` 類別實例時, 它會幫你轉去叫用成員函式, 並且把記錄的物件傳入。

ESP32 就搭配這個機制, 另外在 `FunctionalInterrupt.h` 檔中提供了相對應版本的 `attachInterrupt` 函式, 剛剛的程式就可以改寫[如下的版本](https://wokwi.com/projects/390439532306368513)：

```cpp
#include<FunctionalInterrupt.h>

class Button {
  public:
    int count = 0;

    void ARDUINO_ISR_ATTR isr(void) {
      count++;
    }

};

Button b;

void setup() {
  // put your setup code here, to run once:
  Serial.begin(115200);
  pinMode(26, INPUT_PULLUP);
  attachInterrupt(26, std::bind(&Button::isr, &b), RISING);
}

void loop() {
  // put your main code here, to run repeatedly:
  delay(200); // this speeds up the simulation
  Serial.println(b.count);
}
```

要注意：

1. 一定要引入 `FunctionalInterrupt.h`, 否則會引用到原始版本的 [`attachInterrupt`](https://docs.espressif.com/projects/arduino-esp32/en/latest/api/gpio.html?highlight=attachinterrupt#attachinterrupt) 函式, 這個版本只接受 [`voidFuncPtr`](https://github.com/espressif/arduino-esp32/blob/113de1fa02b3665a9755e09fb189304f0eb3ac7b/cores/esp32/esp32-hal-gpio.c#L82) 型別的函式, 它實際上是 `void (*voidFuncPtr)(void);`, 不接受 `std::function` 類別實例, [`FunctionalInterrupt.h` 內的版本](https://github.com/espressif/arduino-esp32/blob/e18d8b95051db660e00c7bb1b6df9f3146dbef51/cores/esp32/FunctionalInterrupt.h#L20) 才能接受 `std::function` 類別實例。

2. 叫用 `bind` 時記得傳入物件的位址, 否則會變成複製物件, 如果在中斷處理函式中會修改物件的成員變數, 就會變成修改到副本, 而不是你傳入的那個物件。