ESP32 SPIFFS 開啟不存在檔案的神奇功能
Tags: arduino, esp32, spiffs, posix
Published: 2022-02-18 16:56:07

如果你有使用 ESP32 的 SPIFFS 檔案系統, 你可能會遇到這樣的靈異現象, **即使檔案不存在, 也能以 "r" 讀取模式開啟**。我們先以底下的程式檢視本文示範用的 ESP32 開發板目前 SPIFFS 檔案系統的內容：

```cpp
#include<SPIFFS.h>

void setup() {
  if (!SPIFFS.begin(true)) {
    Serial.println("mount error.");
    while (1) {} //blocking
  }

  File root = SPIFFS.open("/");

  Serial.begin(115200);
  Serial.println("==================");
  if (root.isDirectory()) {
    File file = root.openNextFile();
    while (file) {
      if (file.isDirectory()) {
        Serial.print("[");
        Serial.print(String(file.path()).substring(1));
        Serial.println("]");
      }
      else {
        Serial.println(String(file.path()).substring(1));
      }
      file = root.openNextFile();
    }
  }
  Serial.println("==================");
}
```

執行後的結果如下：

```
==================
test_dir/KKK.txt
TTT.txt
==================
```

你可以看到目前檔案系統內只有兩個檔案, 接著, 我們以底下的程式嘗試透過預設的 "r" 讀取模式開啟一個不存在的檔案 test.txt, 並藉由檢查[傳回的 FILE 物件是否為 false](https://github.com/espressif/arduino-esp32/blob/f2a20e8a38a3a0e4b5fd43d31519d7984b868678/libraries/FS/src/vfs_api.cpp#L336) 來確認是否無法開啟檔案：

```cpp
#include <SPIFFS.h>

void setup() {
  Serial.begin(115200);
  Serial.println("============================");
  if (!SPIFFS.begin(true)) {
    Serial.println("mount error.");
    while (1) {} //blocking
  }

  File file = SPIFFS.open("/test.txt");
  if (!file) {
    Serial.println("error opening file.");
    while (1) {} //blocking
  }
  Serial.println("file opened.");
  Serial.println("============================");
}

void loop() {
}
```

執行結果如下：

```
============================
file opened.
============================
```

你會發現即使 test.txt 根本不存在, `!file` 也不會成立, 也就是你永遠可以開啟一個不存在的檔案。

如果你查看 SPIFFS 的範例, 像是 [SPIFFS_Test](https://github.com/espressif/arduino-esp32/blob/f2a20e8a38a3a0e4b5fd43d31519d7984b868678/libraries/SPIFFS/examples/SPIFFS_Test/SPIFFS_Test.ino#L44), 會看到它檢查檔案是否成功開啟除了檢查傳回的 `File` 物件外, 還會確認開啟的並不是目錄：

```cpp
    File file = fs.open(path);
    if(!file || file.isDirectory()){
        Serial.println("- failed to open file for reading");
        return;
    }
```

我們就比照辦理, 看看是否可以判斷不存在的檔案：

```cpp
#include <SPIFFS.h>

void setup() {
  Serial.begin(115200);
  Serial.println("============================");
  if (!SPIFFS.begin(true)) {
    Serial.println("mount error.");
    while (1) {} //blocking
  }

  File file = SPIFFS.open("/test.txt");
  if (!file || file.isDirectory()) {
    Serial.println("error opening file.");
    while (1) {} //blocking
  }
  Serial.println("file opened.");
  Serial.println("============================");
}

void loop() {
}
```

執行結果如下：

```
============================
error opening file.
```

這次正確判斷無法開啟檔案了, 可見若是開啟一個不存在的檔案, 它會當成目錄開啟。

## 探究 SPIFFS 檔案系統

如果看 [SPIFFS 的原始碼](https://github.com/espressif/arduino-esp32/blob/f2a20e8a38a3a0e4b5fd43d31519d7984b868678/libraries/FS/src/vfs_api.cpp#L19), 會看到 `SPIFFS.open()` 在大多數錯誤情況下會使用 `log_e` 巨集噴出 `error` 等級的錯誤訊息, 並傳回一個空的內容的 `File` 物件, 雖然我們的測試程式在編譯時都將『**工具/Core Debug Level**』設為 **Verbose**, 但是在執行過程中, 並沒有看到任何的錯誤訊息, 而且 `!file` 也不是 `true`, 所以可以排除這些錯誤的可能性, 只剩下底下這段可以不出錯而返回：

```cpp
...
    struct stat st;
    //file found
    if(!stat(temp, &st)) {
        free(temp);
        if (S_ISREG(st.st_mode) || S_ISDIR(st.st_mode)) {
            return std::make_shared<VFSFileImpl>(this, fpath, mode);
        }
        log_e("%s has wrong mode 0x%08X", fpath, st.st_mode);
        return FileImplPtr();
    }

    //try to open this as directory (might be mount point)
    DIR * d = opendir(temp);
    if(d) {
        closedir(d);
        free(temp);
        return std::make_shared<VFSFileImpl>(this, fpath, mode);
    }
...

```

第一段是使用 [`stat()`](https://man7.org/linux/man-pages/man2/lstat.2.html) 系統函式檢查是不是存在指定的路徑, 並在該路徑是一般檔案或是目錄時傳回內含該路徑相關資訊的物件。第二段則是利用 [`opendir()`](https://man7.org/linux/man-pages/man3/opendir.3.html) 系統函式看看是否可以將指定路徑以目錄的方式開啟, 若成功一樣傳回內含指定路徑相關資訊的物件。既然如此, 我們就來確認到底是哪一種狀況？

首先叫用 `stat()`, 我們以底下的測試程式檢查不存在的檔案以及存在的檔案的傳回結果, [`stat()` 的傳回值](https://man7.org/linux/man-pages/man2/lstat.2.html#RETURN_VALUE)為 0 表示成功, 也就是指定的檔案存在：

```cpp
#include <SPIFFS.h>
#include <sys/unistd.h>
#include <sys/stat.h>
#include <dirent.h>

void setup() {
  Serial.begin(115200);
  Serial.println("============================");
  if (!SPIFFS.begin(true)) {
    Serial.println("mount error.");
    while (1) {} //blocking
  }

  struct stat st;
  if(!stat("/spiffs/test.txt", &st)) {
    Serial.println("test.txt existed.");
  }

  if(!stat("/spiffs/TTT.txt", &st)) {
    Serial.println("TTT.txt existed.");
  }

  Serial.println("============================");
}

void loop() {
}
```

要注意的是叫用系統函式時, 路徑要加上檔案系統的**掛載點 (mount point)**, SPIFFS 的掛載點在 "/SPIFFS"。實際執行結果如下：

```
============================
TTT.txt existed.
============================
```

你可以看到只有真的存在的 TTT.txt 檔才能通過 `stat()` 的檢查, 表示 `SPIFFS.open()` 並不是因為 `stat()` 而成功開啟。那剩下來的可能性就是 `opendir()` 系統函式了, 我們一樣寫個程式來檢驗：

```cpp
#include <SPIFFS.h>
#include <dirent.h>

void setup() {
  Serial.begin(115200);
  Serial.println("============================");
  if (!SPIFFS.begin(true)) {
    Serial.println("mount error.");
    while (1) {} //blocking
  }

  DIR *d;
  d = opendir("/spiffs/test.txt");
  if(d) {
    Serial.println("test.txt existed.");
  }
  d = opendir("/spiffs/test_dir");
  if(d) {
    Serial.println("test_dir existed.");
  }
  Serial.println("============================");
}

void loop() {
}
```

`opendir()` 在[成功時會傳回有效的指位器, 否則會傳回 `NULL`](https://man7.org/linux/man-pages/man3/opendir.3.html#RETURN_VALUE), 底下是執行結果： 

```
============================
test.txt existed.
test_dir existed.
============================
```

你可以看到不論指定的路徑到底是什麼？`opendir()` 都成功, 因此我們對一個不存在的檔案叫用 `SPIFFS.open()` 就會成功, 但會被當成目錄。

解謎完成後, 剩下的問題就是, 為什麼？

## SPIFFS 檔案系統沒有目錄

之所以是這樣, 最主要的原因就是 **SPIFFS 檔案系統裡面根本就不存在目錄結構**, 因此本文一開始的程式列出的 SPIFFS 檔案系統內容：

```
==================
test_dir/KKK.txt
TTT.txt
==================
```

看起來好像是有一個 test_dir 目錄, 其中有個 KKK.txt 檔案, 實際上並不是這樣, 而是有一個名稱為 "/test_dir/KKK.txt" 的檔案, 為了讓你有目錄結構的假象, 當你叫用 [`File.openNextFile()`](https://github.com/espressif/arduino-esp32/blob/f2a20e8a38a3a0e4b5fd43d31519d7984b868678/libraries/FS/src/vfs_api.cpp#L434) 時, 就會去找尋以該 `File` 物件的路徑 + '/' 開頭的檔案, 因此 "/test_dir/KKK.txt" 被當成是一個檔案, 而不會找到 "test_dir" 目錄。

由於 `SPIFFS.open()` 也可以用來開啟目錄, 因此當指定的路徑經由 `stat()` 系統函式檢查並不存在時, 會由 `open()` 系統函式嘗試以目錄形式開啟, 這時理論上要去檢查是否有以指定的路徑加上 '/' 開頭的檔案, 才能將之視為目錄, 但是對於 SPIFFS 檔案系統來說, **最糟的狀況等於要檢查所有檔案的名稱**才能確認, 為了不影響效能, `open()` 系統函式不會進行上述檢查工作, 直接假設指定的路徑是目錄, 而這就是我們遇到的問題。

其實網路上已經有人碰過[相同的問題](https://github.com/espressif/arduino-esp32/issues/681#issuecomment-333067621), 並且有善心人士指出問題所在, 本文僅是在前人的基礎上嘗試說明得更清楚而已。