1. ESP32 Arduino开发环境中的WiFi连接原理与工程实践

在嵌入式物联网系统中,WiFi连接不是简单的“填个SSID和密码就完事”的黑盒操作。它是一套涉及硬件射频前端、MAC层协议栈、TCP/IP网络栈、应用层状态机的完整软件体系。ESP32芯片内部集成的WiFi基带处理器(Wi-Fi MAC)和TCP/IP协议栈(LwIP)共同构成了这一能力的基础。Arduino Core for ESP32作为上层封装,将这些复杂性抽象为一组可预测、可调试的C++ API。本文不讲“超简单”,而是深入拆解 WiFi.begin() 背后的真实执行路径、常见失效点及可落地的诊断方法——因为所有“简单”都建立在对底层机制的清晰认知之上。

1.1 WiFi协议栈架构与ESP32角色定位

ESP32并非仅靠MCU裸跑WiFi协议。其内部采用双CPU架构:一个为应用处理器(APP CPU),另一个为协议栈处理器(PRO CPU)。WiFi协议栈(包括802.11 MAC、WPA/WPA2安全握手、DHCP客户端、DNS解析器、LwIP TCP/IP栈)默认运行在PRO CPU上,由ESP-IDF底层驱动管理。Arduino Core for ESP32通过 esp_wifi_ 系列API与该协议栈通信,而 WiFi.h 头文件提供的 WiFi.begin() 等接口,本质是调用 esp_wifi_connect() 并启动后台事件循环监听连接状态变更。

这意味着: 连接过程完全异步 WiFi.begin() 函数调用后立即返回,并不阻塞等待连接完成;实际连接、认证、IP获取等动作均在PRO CPU后台线程中执行,最终通过事件通知(如 SYSTEM_EVENT_STA_CONNECTED )触发用户回调或状态更新。若忽略这一异步特性,直接在 WiFi.begin() 后立刻调用 WiFi.localIP() ,极大概率得到 0.0.0.0 ——因为DHCP尚未完成地址分配。

1.2 标准连接流程的工程分解

一个健壮的WiFi连接流程必须包含四个明确阶段:初始化、配置、连接触发、状态轮询与错误处理。字幕中演示的代码虽短,但隐含了关键步骤。我们将其还原为符合生产环境要求的结构化实现:

#include <WiFi.h>

// 1. 全局配置:SSID与密码应避免硬编码,此处仅为演示
const char* ssid = "ICODNG";      // 注意:大小写敏感,空格不可见
const char* password = "your_password_here";

void setup() {
  Serial.begin(115200);
  delay(1000); // 确保串口稳定

  // 2. WiFi模块初始化:这是必要前置步骤
  // 此调用会初始化WiFi驱动、注册事件处理回调、启动PRO CPU上的协议栈
  WiFi.mode(WIFI_STA); // 强制设置为Station模式(非AP或AP+STA)

  // 3. 启动连接:传入SSID和密码
  // 此调用仅向协议栈发送连接请求,不等待结果
  WiFi.begin(ssid, password);

  // 4. 连接状态轮询:必须主动检查,不能假设立即成功
  Serial.println("Connecting to WiFi...");
  int connectionAttempt = 0;
  const int maxAttempts = 30; // 最大等待30秒(按每秒1次轮询)

  while (WiFi.status() != WL_CONNECTED && connectionAttempt < maxAttempts) {
    delay(1000);
    Serial.print(".");
    connectionAttempt++;
  }

  // 5. 连接结果判定与处理
  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("\n✅ WiFi connected successfully!");
    Serial.print("IP address: ");
    Serial.println(WiFi.localIP()); // 此时localIP()才返回有效地址
    Serial.print("Subnet mask: ");
    Serial.println(WiFi.subnetMask());
    Serial.print("Gateway IP: ");
    Serial.println(WiFi.gatewayIP());
  } else {
    Serial.println("\n❌ WiFi connection failed.");
    Serial.print("Error code: ");
    Serial.println(WiFi.status()); // 输出具体错误码,如WL_NO_SSID_AVAIL, WL_CONNECT_FAILED等
  }
}

void loop() {
  // 主循环中可进行网络应用,如HTTP请求、MQTT发布等
  // 注意:此处不应再调用WiFi.begin(),除非需要重连
}

这段代码揭示了字幕中未明说但至关重要的工程细节:

  • WiFi.mode(WIFI_STA) 显式声明设备工作模式。ESP32默认可能处于 WIFI_OFF 或混合模式,不显式设置可能导致连接失败或行为异常。
  • WiFi.status() 返回值是枚举类型 wl_status_t ,其定义位于 WiFiType.h 中,包含十余种状态码。 WL_CONNECTED 仅表示已通过802.11认证并获得IP,不保证网络可达; WL_NO_SSID_AVAIL 表示目标AP未被扫描到; WL_CONNECT_FAILED 通常指向密码错误或认证失败; WL_DISCONNECTED 则可能是信号弱、AP拒绝接入或DHCP超时。
  • 轮询机制引入超时保护。无限 while(1) 循环在嵌入式系统中是危险设计,可能导致看门狗复位或无法响应其他任务。30秒超时是经验值,兼顾大多数家庭路由器DHCP响应时间与用户等待容忍度。

1.3 字幕中“密码写错”问题的深度溯源

字幕作者最终发现SSID拼写错误( ICODNG 误写为其他字符串),这暴露了WiFi调试中最常见的陷阱: 肉眼不可见的字符差异 。SSID看似简单,实则存在多种隐蔽错误源:

错误类型 示例 诊断方法
大小写混淆 ICODNG vs icodng WiFi SSID严格区分大小写,需逐字符比对
不可见字符 ICODNG 末尾含空格或全角空格 使用十六进制编辑器查看字符串原始字节,或在串口打印 strlen(ssid) 验证长度
特殊字符转义 SSID含 @ # $ 等符号 Arduino IDE默认使用UTF-8编码,若SSID含非ASCII字符(如中文),需确保源文件保存为UTF-8无BOM格式,且路由器AP设置支持相应字符集
隐藏字符粘贴 从网页复制SSID带HTML实体(如 &nbsp; 在纯文本编辑器(如Notepad++)中粘贴后启用“显示所有字符”功能

更深层的问题在于: ESP32的WiFi扫描与连接是两个独立过程 WiFi.begin() 内部会先触发一次被动扫描(Passive Scan),尝试在已知信道上捕获目标AP的Beacon帧。若扫描阶段未发现该SSID,则直接返回 WL_NO_SSID_AVAIL 。因此,当连接失败时,首要动作不是反复重试 begin() ,而是验证AP是否可被发现。

1.4 基于扫描的故障诊断工具链

字幕中提到的“WiFi.scanNetworks()”是解决此类问题的黄金工具。它强制执行一次主动扫描(Active Scan),遍历所有信道并收集周围AP信息。以下是一个增强版诊断程序,可嵌入任何项目中:

#include <WiFi.h>

void scanAndDiagnose() {
  Serial.println("\n🔍 Starting WiFi network scan...");

  // 1. 初始化扫描:参数为true表示同步扫描(阻塞直到完成),false为异步
  int numNetworks = WiFi.scanNetworks(true);

  if (numNetworks == 0) {
    Serial.println("❌ No networks found. Check antenna, power, and environment.");
    return;
  }

  Serial.printf("✅ Found %d networks:\n", numNetworks);
  Serial.println("--------------------------------------------------");

  // 2. 遍历扫描结果,高亮显示目标SSID
  bool targetFound = false;
  for (int i = 0; i < numNetworks; i++) {
    String ssid = WiFi.SSID(i);
    int rssi = WiFi.RSSI(i);
    String encryption = getEncryptionType(WiFi.encryptionType(i));

    // 高亮匹配目标SSID(大小写敏感)
    if (ssid == String("ICODNG")) {
      Serial.printf("🎯 [%d] %s | RSSI: %d dBm | Enc: %s\n", 
                    i + 1, ssid.c_str(), rssi, encryption.c_str());
      targetFound = true;
    } else {
      Serial.printf("   [%d] %s | RSSI: %d dBm | Enc: %s\n", 
                    i + 1, ssid.c_str(), rssi, encryption.c_str());
    }
  }
  Serial.println("--------------------------------------------------");

  if (!targetFound) {
    Serial.println("⚠️  Target SSID 'ICODNG' NOT FOUND in scan results.");
    Serial.println("   Possible causes:");
    Serial.println("   - AP is hidden (not broadcasting SSID)");
    Serial.println("   - Device is out of range (check RSSI of nearby networks)");
    Serial.println("   - AP is on 5GHz band (ESP32-WROOM-32 only supports 2.4GHz)");
    Serial.println("   - Channel conflict or interference");
  } else {
    Serial.println("💡 Target SSID confirmed visible. Proceed with connection.");
  }
}

String getEncryptionType(uint8_t encType) {
  switch (encType) {
    case WIFI_AUTH_OPEN: return "OPEN";
    case WIFI_AUTH_WEP: return "WEP";
    case WIFI_AUTH_WPA_PSK: return "WPA/PSK";
    case WIFI_AUTH_WPA2_PSK: return "WPA2/PSK";
    case WIFI_AUTH_WPA_WPA2_PSK: return "WPA/WPA2";
    case WIFI_AUTH_WPA2_ENTERPRISE: return "WPA2-ENT";
    default: return "UNKNOWN";
  }
}

void setup() {
  Serial.begin(115200);
  delay(1000);

  WiFi.mode(WIFI_STA);
  WiFi.disconnect(); // 确保干净状态

  scanAndDiagnose(); // 执行扫描诊断

  // 此处可继续执行连接逻辑...
}

void loop() {}

此程序的价值在于将模糊的“连不上”转化为可量化的数据:
- 若扫描结果为空,问题必然在物理层(天线接触不良、供电不足、模块损坏);
- 若扫描到大量网络但缺失目标SSID,说明AP未广播(Hidden SSID)或不在2.4GHz频段;
- 若目标SSID存在但RSSI极低(<-85dBm),则需调整设备位置或检查AP发射功率;
- 加密类型显示为 OPEN 而预期为 WPA2/PSK ,提示密码无需输入,但 WiFi.begin() 仍需传入空字符串。

1.5 连接稳定性强化:从“能连”到“可靠连”

工业级应用要求WiFi连接具备自恢复能力。字幕中演示的单次连接逻辑在路由器重启、信号临时中断等场景下会永久失联。一个健壮的方案需引入状态监控与自动重连机制:

#include <WiFi.h>

const char* ssid = "ICODNG";
const char* password = "your_password";
unsigned long lastConnectionAttempt = 0;
const unsigned long reconnectInterval = 30000; // 30秒重连间隔

void handleWiFiConnection() {
  wl_status_t status = WiFi.status();

  // 1. 已连接且IP有效:维持连接
  if (status == WL_CONNECTED && WiFi.localIP()[0] != 0) {
    return;
  }

  // 2. 未连接或连接丢失:触发重连
  unsigned long now = millis();
  if (now - lastConnectionAttempt > reconnectInterval) {
    lastConnectionAttempt = now;

    Serial.println("🔄 Attempting WiFi reconnection...");

    // 清理旧状态
    WiFi.disconnect();
    delay(100);

    // 重新初始化并连接
    WiFi.mode(WIFI_STA);
    WiFi.begin(ssid, password);

    // 启动短时轮询(最多10秒),避免长阻塞
    int pollCount = 0;
    while (WiFi.status() != WL_CONNECTED && pollCount < 10) {
      delay(1000);
      pollCount++;
    }

    if (WiFi.status() == WL_CONNECTED) {
      Serial.printf("✅ Reconnected. IP: %s\n", WiFi.localIP().toString().c_str());
    } else {
      Serial.printf("❌ Reconnect failed. Status: %d\n", WiFi.status());
    }
  }
}

void setup() {
  Serial.begin(115200);
  delay(1000);
  WiFi.mode(WIFI_STA);
}

void loop() {
  handleWiFiConnection(); // 每次loop中检查连接状态
  delay(2000); // 主循环节奏控制
}

该设计遵循嵌入式实时系统原则:
- 非阻塞 :重连尝试限制在10秒内,避免主循环长时间挂起;
- 退避策略 :30秒重连间隔防止高频重试耗尽资源;
- 状态隔离 WiFi.disconnect() 确保每次连接都是干净的起点,避免残留状态干扰;
- IP有效性校验 WiFi.localIP()[0] != 0 比单纯检查 WL_CONNECTED 更可靠,因某些固件版本在DHCP失败时仍返回该状态。

2. Arduino IDE环境搭建的底层依赖与常见陷阱

字幕标题强调“超简单”,但环境搭建的“简单”表象下,是ESP32 Arduino Core对庞大工具链的精密封装。理解其组成,是解决编译失败、上传异常、串口乱码等基础问题的前提。

2.1 工具链核心组件解析

Arduino IDE for ESP32并非独立软件,而是基于标准Arduino框架的扩展包,其正常运行依赖四个关键层级:

层级 组件 作用 常见问题
硬件抽象层(HAL) esp32-hal-* 系列库 直接操作ESP32寄存器,提供GPIO、UART、WiFi等外设驱动 版本不匹配导致 WiFi.h 无法识别或功能异常
Arduino Core arduino-esp32 实现 setup() / loop() 框架、 Serial 类、 WiFi 类等标准API 未正确安装或路径错误,IDE报 WiFi.h: No such file
交叉编译工具链 xtensa-esp32-elf-gcc 将C++代码编译为ESP32可执行的二进制文件 编译报错 command not found ,通常因权限或路径未加入系统变量
烧录工具(esptool.py) Python脚本 通过串口将固件写入ESP32 Flash 上传失败报 A fatal error occurred: Failed to connect to ESP32 ,多因USB转串口芯片驱动未安装

当字幕中提及“编译过程比较慢”,其根本原因是:Arduino Core for ESP32的编译并非简单链接,而是执行完整的ESP-IDF构建流程——包括生成分区表(partition table)、合并bootloader、app固件、phy_init_data等多段二进制,并进行签名与加密(若启用)。一次完整编译常涉及数百个源文件,故耗时显著长于普通Arduino AVR项目。

2.2 Windows平台下的典型安装故障与修复

Windows用户在安装ESP32支持包时,最常遭遇三类问题,其根源均与系统权限和路径有关:

问题一:Arduino IDE无法识别端口
- 现象 :设备管理器显示 CP210x USB to UART Bridge Controller ,但Arduino IDE端口列表为空。
- 根因 :CP210x驱动安装后,系统未赋予当前用户对COM端口的访问权限。
- 修复 :以管理员身份运行命令提示符,执行:
cmd net localgroup "Users" /add "COMxxxx"
其中 COMxxxx 为设备管理器中显示的具体端口号(如 COM3 )。或更简单:在设备管理器中右键该端口→属性→端口设置→高级→勾选“使用特定的COM端口号”,手动指定一个低位端口(如COM3)。

问题二:上传时提示“Failed to connect to ESP32”
- 现象 :点击上传按钮后,IDE卡在 Connecting... ,数秒后报错。
- 根因 :ESP32未进入下载模式(Download Mode),或USB转串口芯片与ESP32的GPIO0/EN引脚电平不匹配。
- 修复
1. 手动强制下载模式 :按住ESP32开发板上的 BOOT 按钮不放,再按一下 EN (或 RESET )按钮,松开 EN 后继续按住 BOOT 约1秒,最后松开 BOOT 。此时板载LED通常会闪烁,表明进入下载模式。
2. 检查DTR/RTS自动复位电路 :部分廉价开发板的CH340G芯片不支持DTR/RTS自动控制。可在Arduino IDE→文件→首选项中,勾选“显示详细输出”,观察上传日志末尾是否有 esptool.py --chip esp32 --port COM3 --baud 921600 ... 。若无,说明IDE未调用esptool,需检查板子型号选择是否正确(如选成 ESP32 Dev Module 而非 DOIT ESP32 DEVKIT V1 )。

问题三:串口监视器显示乱码
- 现象 Serial.println("Hello") 输出为 等不可读字符。
- 根因 :串口监视器波特率与 Serial.begin() 参数不一致,或USB转串口芯片存在兼容性问题。
- 修复
1. 确认波特率匹配 :代码中 Serial.begin(115200) ,则串口监视器右下角必须选择 115200 baud 。切勿依赖“Auto”选项。
2. 更换USB线缆 :劣质USB线仅传输电力,不支持数据通信。更换为带屏蔽层的原装线。
3. 禁用流控 :在串口监视器设置中,将“行结束”改为 None ,避免发送额外的 \r\n 干扰。

2.3 Linux/macOS平台的静默权限陷阱

Linux/macOS用户常忽略一个关键点:USB设备节点默认属于 root dialout 组,普通用户无权访问。若未正确配置,上传或串口通信会静默失败。

Linux解决方案

# 查看当前用户所属组
groups

# 若无dialout组,添加用户到dialout组
sudo usermod -a -G dialout $USER

# 重启用户会话(或重启系统)使组变更生效
# 验证:插拔USB设备后,ls -l /dev/ttyUSB* 应显示 dialout 组

macOS解决方案

# macOS Catalina及以后版本,需在“系统偏好设置→安全性与隐私→隐私→完全磁盘访问”中
# 手动添加Arduino IDE.app和Terminal.app
# 同时,在“辅助功能”中也需添加

3. WiFi连接的进阶工程考量:超越基础API

当项目从“点亮LED”迈向“工业数据采集”,基础的 WiFi.begin() 便显露出局限。开发者必须直面信号质量、安全策略、资源竞争等现实约束。

3.1 信号强度(RSSI)的工程化利用

RSSI(Received Signal Strength Indicator)不仅是调试工具,更是系统决策依据。ESP32可通过 WiFi.RSSI() 获取当前连接AP的信号强度,单位为dBm。其数值范围约为-100(极弱)至-20(极强),工程实践中可建立如下分级策略:

RSSI范围 (dBm) 信号质量 推荐动作
≥ -50 极佳 全速运行,启用高带宽应用(如视频流)
-50 ~ -70 良好 正常运行,启用标准应用(如传感器上报)
-70 ~ -85 一般 降低上报频率,启用数据压缩,关闭非关键服务
< -85 极差 进入低功耗待机,停止所有网络活动,仅周期性扫描重连

示例代码实现动态上报策略:

#include <WiFi.h>
#include <HTTPClient.h>

const int MIN_RSSI = -80; // 触发降频的阈值
int currentRssi = 0;

void adjustReportingRate() {
  currentRssi = WiFi.RSSI();

  if (currentRssi >= -50) {
    // 每5秒上报一次
    reportingInterval = 5000;
  } else if (currentRssi >= -70) {
    // 每10秒上报一次
    reportingInterval = 10000;
  } else if (currentRssi >= -80) {
    // 每30秒上报一次
    reportingInterval = 30000;
  } else {
    // 信号极差,暂停上报
    reportingInterval = 0;
    Serial.println("📶 Signal too weak. Pausing data upload.");
  }
}

void uploadSensorData() {
  if (reportingInterval == 0 || WiFi.status() != WL_CONNECTED) return;

  static unsigned long lastUpload = 0;
  if (millis() - lastUpload < reportingInterval) return;

  lastUpload = millis();
  HTTPClient http;
  http.begin("http://your-server.com/api/data");
  http.addHeader("Content-Type", "application/json");

  String payload = "{\"temp\":" + String(readTemperature()) + "}";
  int httpResponseCode = http.POST(payload);

  if (httpResponseCode > 0) {
    Serial.printf("✅ Upload OK. Code: %d\n", httpResponseCode);
  } else {
    Serial.printf("❌ Upload failed. Code: %d\n", httpResponseCode);
  }
  http.end();
}

3.2 安全连接:WPA Enterprise与证书校验

家庭WiFi多用WPA2-PSK,但企业环境普遍采用WPA2-Enterprise(802.1X),需EAP-TLS认证。Arduino Core for ESP32支持此模式,但需手动加载证书:

#include <WiFi.h>
#include <WiFiClientSecure.h>

// 1. 定义服务器证书(PEM格式,需转换为C字符串)
const char* rootCACertificate = R"EOF(
-----BEGIN CERTIFICATE-----
MIIDxTCCAq2gAwIBAgIQAqxcJmoLQJuPC38PULfU3DANBgkqhkiG9w0BAQUFADBs
...
-----END CERTIFICATE-----
)EOF";

void connectToEnterpriseWiFi() {
  WiFi.mode(WIFI_STA);

  // 2. 设置Enterprise参数
  WiFi.setCertAuthMode(WIFI_CERT_AUTH_MODE_TLS); // 启用TLS认证
  WiFi.setCACert(rootCACertificate); // 加载CA证书

  // 3. 提供用户名和密码(EAP-PEAP/MSCHAPv2)
  WiFi.begin("EnterpriseSSID", "username", "password");

  // 后续连接逻辑同前...
}

关键注意 :证书必须为PEM格式,且需使用 R"EOF(...)" 原始字符串字面量,避免反斜杠转义错误。证书体积较大,会占用Flash空间,需在 platformio.ini 中增加 board_build.flash_mode = dio 以优化。

3.3 多任务环境下的WiFi资源竞争

在FreeRTOS环境下(ESP32 Arduino默认启用), WiFi 类的API并非完全线程安全。例如, WiFi.scanNetworks() WiFi.begin() 若在不同任务中并发调用,可能导致协议栈内部状态冲突。官方文档明确建议: 所有WiFi API应在同一个任务(通常是 loop() 所在的任务)中调用

若必须跨任务操作,应使用互斥锁(Mutex)保护:

#include <WiFi.h>
#include <freertos/FreeRTOS.h>
#include <freertos/semphr.h>

SemaphoreHandle_t wifiMutex;

void initWiFiMutex() {
  wifiMutex = xSemaphoreCreateMutex();
  if (wifiMutex == NULL) {
    Serial.println("❌ Failed to create WiFi mutex");
  }
}

void safeWiFiConnect(const char* ssid, const char* pass) {
  if (xSemaphoreTake(wifiMutex, portMAX_DELAY) == pdTRUE) {
    WiFi.begin(ssid, pass);
    xSemaphoreGive(wifiMutex);
  }
}

4. 实战经验:我在真实项目中踩过的坑

理论终需实践检验。以下是我过去三年在多个ESP32 WiFi项目中总结的血泪教训,它们无法从文档中直接获得,却能帮你节省数周调试时间。

坑一:DHCP租期耗尽后的IP冲突
某环境监测设备部署后稳定运行两周,随后突然失联。抓包发现设备仍在发送ARP请求,但无响应。排查发现:路由器DHCP租期设为24小时,而ESP32的LwIP DHCP客户端在租期过半时会尝试续租,但若此时网络短暂中断,续租失败,租期到期后设备未释放旧IP,导致新获取的IP与局域网内其他设备冲突。
解法 :在 loop() 中定期检查 WiFi.localIP() 是否变化,若检测到IP变更,主动调用 WiFi.disconnect() WiFi.reconnect() ,强制刷新网络栈。

坑二:WiFi事件队列溢出
在高速传感器采集中,我启用了 WiFi.onEvent() 监听 SYSTEM_EVENT_STA_DISCONNECTED 事件。当网络频繁抖动时,事件队列(默认10个槽位)迅速填满,新事件被丢弃,导致设备无法感知断连。
解法 :增大事件队列深度。在 platformio.ini 中添加:

build_flags = 
  -DCONFIG_ESP_EVENT_POST_FROM_ISR=1
  -DCONFIG_ESP_EVENT_QUEUE_SIZE=32

坑三:蓝牙与WiFi共存干扰
ESP32同时开启WiFi和BLE时,2.4GHz频段资源争抢严重,表现为WiFi吞吐量暴跌50%以上。官方推荐的共存方案( esp_coex_preference_set() )在Arduino Core中未暴露。
解法 :禁用BLE或WiFi之一,或改用ESP32-S2/S3等单射频芯片。若必须共存,将WiFi信道固定为1、6、11(避开BLE跳频中心),并通过 esp_wifi_set_channel(6, WIFI_SECOND_CHAN_NONE) 强制设置。

坑四:低功耗模式下的WiFi唤醒失效
为延长电池寿命,我让设备在 deep sleep 后唤醒并连接WiFi。但首次唤醒总失败,后续才正常。
根因 deep sleep 会切断WiFi PHY供电,唤醒后PHY需重新初始化,但Arduino Core的 WiFi.begin() 未内置此逻辑。
解法 :唤醒后,先调用 esp_wifi_init(&cfg) (需包含 esp_wifi.h ),再 WiFi.begin() 。或更简单:在 setup() 中调用 WiFi.mode(WIFI_OFF) ,唤醒后 WiFi.mode(WIFI_STA) 即可触发完整初始化。

这些经验没有银弹,唯有在真实噪声环境中反复锤炼,才能将“超简单”的承诺,转化为可交付、可维护、可信赖的嵌入式系统。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐