用 Python 打造一個圖形化局域網掃描器:實戰網絡設備發現工具

在日常的網絡管理、安全測試或家庭網絡排查中,我們常常需要快速瞭解當前局域網中有哪些設備在線。雖然命令行工具(如 nmaparp-scan)功能強大,但對於非技術人員來説門檻較高。本文將帶你從零開始,使用 Python + Tkinter + 多線程 + 系統命令調用 構建一個圖形化局域網掃描器,具備 IP 掃描、主機名解析、MAC 地址獲取和響應時間顯示等核心功能。


一、項目目標與功能預覽

我們的局域網掃描器將實現以下功能:

  • ✅ 圖形用户界面(GUI),操作直觀;
  • ✅ 支持自定義 IP 範圍輸入(如 192.168.1.1-254);
  • ✅ 併發 Ping 掃描,快速檢測在線設備;
  • ✅ 自動解析主機名(Hostname);
  • ✅ 通過 ARP 表獲取 MAC 地址;
  • ✅ 顯示 Ping 響應時間(ms);
  • ✅ 實時進度條與狀態提示;
  • ✅ 支持隨時停止掃描;
  • ✅ 跨平台兼容(Windows / Linux / macOS)。

最終效果如下圖所示:

頂部輸入框可設置掃描範圍,點擊“開始掃描”後,下方表格實時列出在線設備的 IP、主機名、MAC 和延遲,底部狀態欄顯示進度信息。

image.png


二、技術選型與核心模塊

1. GUI 框架:tkinter

  • Python 內置,無需額外安裝;
  • 提供 ttk 主題控件,界面更現代;
  • 支持 Treeview 表格、Progressbar 進度條、ScrolledText 等組件。

2. 網絡探測:subprocess + 系統 ping 命令

  • 利用操作系統原生命令進行 ICMP 探測;
  • 通過正則表達式解析響應時間;
  • 兼容 Windows (ping -n) 與 Unix-like (ping -c) 參數差異。

3. 主機信息獲取:

  • 主機名socket.gethostbyaddr(ip)
  • MAC 地址:先 ping 觸發 ARP 緩存,再調用 arp -aarp -n 解析。

4. 併發控制:threading

  • 為每個 IP 啓動獨立線程,提升掃描速度;
  • 限制最大併發數(如 50 線程),避免系統資源耗盡;
  • 使用 daemon=True 確保主線程退出時子線程自動終止。

三、代碼結構詳解

3.1 主類 NetworkScanner

class NetworkScanner:
    def __init__(self, root):
        self.root = root
        self.root.title("局域網掃描器")
        self.root.geometry("800x600")
        self.create_widgets()

初始化主窗口並創建 UI 組件。


3.2 創建圖形界面 create_widgets()

  • 輸入區域:IP 範圍輸入框 + “開始/停止”按鈕;
  • 進度條ttk.Progressbar 顯示掃描進度;
  • 結果表格ttk.Treeview 展示四列數據(IP、主機名、MAC、延遲);
  • 狀態欄:底部 Label 實時反饋操作狀態;
  • 線程控制標誌self.scanning = False 用於優雅停止。

💡 技巧:使用 gridpack 佈局管理器組合,實現靈活排版。


3.3 核心掃描邏輯

(1)Ping 探測 ping(ip)
def ping(self, ip):
    # 根據平台選擇 ping 命令
    if Windows: ping -n 1 -w 500 ip
    else:       ping -c 1 -W 0.5 ip
    # 正則提取響應時間
    time_match = re.search(r"時間[=<](\d+)ms|time[=<](\d+\.?\d*)\s*ms", output)
  • 超時設為 500ms,避免卡頓;
  • 成功條件:輸出中包含 TTL=(Windows)或 ttl=(Linux/macOS)。
(2)獲取主機名 get_hostname(ip)
try:
    return socket.gethostbyaddr(ip)[0]
except:
    return "未知"
  • 反向 DNS 查詢,失敗則返回“未知”。
(3)獲取 MAC 地址 get_mac_address(ip)
# 先 ping 一次,確保 ARP 表有記錄
subprocess.call(["ping", "-c", "1", ip], ...)
# 執行 arp -a (Win) 或 arp -n (Unix)
output = subprocess.check_output(["arp", ...])
# 正則匹配 MAC:([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})

⚠️ 注意:此方法依賴本地 ARP 緩存,若目標未通信過可能無法獲取。


3.4 多線程掃描控制

啓動掃描 start_scan()
  • 解析 IP 範圍(支持 192.168.1.1-254192.168.1);
  • 清空舊結果,禁用按鈕,啓動後台線程。
掃描主循環 scan_range(base_ip, start, end)
for i in range(start, end+1):
    ip = f"{base_ip}.{i}"
    thread = threading.Thread(target=self.scan_ip, args=(ip, self.update_tree))
    thread.start()
    threads.append(thread)
    
    # 控制併發數 ≤ 50
    if len(threads) >= 50:
        for t in threads: t.join()
        threads = []
  • 每次批量啓動最多 50 個線程,避免資源爆炸;
  • 掃描完成後恢復 UI 狀態。
安全停止 stop_scan()
self.scanning = False  # 設置標誌位
# 後台線程檢查該標誌後自動退出
  • 無需強制 kill 線程,實現優雅終止。

四、跨平台兼容性處理

功能 Windows Linux / macOS
Ping 命令 ping -n 1 -w 500 ping -c 1 -W 0.5
ARP 查詢 arp -a ip arp -n ip
TTL 關鍵字 "TTL=" "ttl="

通過 platform.system().lower() 動態判斷系統類型,確保命令正確執行。


五、運行效果與優化建議

示例輸入:

192.168.1.1-254

輸出結果(表格):

IP地址 主機名 MAC地址 響應時間(ms)
192.168.1.1 router.home aa:bb:cc:dd:ee:ff 2
192.168.1.105 DESKTOP-ABC 11:22:33:44:55:66 8

優化方向:

  1. 增加端口掃描:結合 socket.connect_ex() 檢測開放端口;
  2. 廠商識別:根據 MAC 前綴查詢設備廠商(需 OUI 數據庫);
  3. 導出結果:支持 CSV/Excel 導出;
  4. 圖標美化:為不同設備類型(手機、PC、IoT)添加圖標;
  5. 性能提升:改用 asyncio + aioping 實現異步 Ping(更高效)。

六、總結

本文通過一個完整的 局域網掃描器項目,展示瞭如何結合 Python 的多種能力:

  • GUI 開發(Tkinter)
  • 系統交互(subprocess)
  • 網絡編程(socket)
  • 多線程併發
  • 正則解析
  • 跨平台適配

該項目不僅實用,更是學習 Python 綜合應用的絕佳案例。你可以將其作為網絡工具箱的一部分,或在此基礎上擴展更高級的功能(如漏洞掃描、設備畫像等)。

🔧 源碼已完整提供,複製即可運行!
📌 注意:部分功能(如 MAC 獲取)在虛擬機或受限網絡中可能受限,建議在真實局域網環境測試。


附:運行要求

  • Python 3.6+
  • 無第三方依賴(僅標準庫)
  • 完整代碼

import tkinter as tk
from tkinter import ttk, scrolledtext
import socket
import threading
import platform
import subprocess
import re
from datetime import datetime

class NetworkScanner:
    def __init__(self, root):
        self.root = root
        self.root.title("局域網掃描器")
        self.root.geometry("800x600")

        # 創建界面元素
        self.create_widgets()

    def create_widgets(self):
        # 頂部框架 - 輸入區域
        top_frame = ttk.Frame(self.root, padding="10")
        top_frame.pack(fill=tk.X)

        # IP範圍輸入
        ttk.Label(top_frame, text="IP範圍:").grid(row=0, column=0, padx=5, pady=5)
        self.ip_entry = ttk.Entry(top_frame, width=20)
        self.ip_entry.grid(row=0, column=1, padx=5, pady=5)
        self.ip_entry.insert(0, "192.168.1.1-254")

        # 掃描按鈕
        self.scan_button = ttk.Button(top_frame, text="開始掃描", command=self.start_scan)
        self.scan_button.grid(row=0, column=2, padx=5, pady=5)

        # 停止按鈕
        self.stop_button = ttk.Button(top_frame, text="停止掃描", command=self.stop_scan, state=tk.DISABLED)
        self.stop_button.grid(row=0, column=3, padx=5, pady=5)

        # 進度條
        self.progress = ttk.Progressbar(self.root, orient="horizontal", mode="determinate")
        self.progress.pack(fill=tk.X, padx=10, pady=5)

        # 結果顯示區域
        result_frame = ttk.Frame(self.root)
        result_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5)

        # 創建Treeview顯示結果
        columns = ("IP地址", "主機名", "MAC地址", "響應時間(ms)")
        self.tree = ttk.Treeview(result_frame, columns=columns, show="headings")

        # 設置列標題
        for col in columns:
            self.tree.heading(col, text=col)
            self.tree.column(col, width=150, anchor=tk.CENTER)

        # 添加滾動條
        scrollbar = ttk.Scrollbar(result_frame, orient=tk.VERTICAL, command=self.tree.yview)
        self.tree.configure(yscroll=scrollbar.set)

        self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
        scrollbar.pack(side=tk.RIGHT, fill=tk.Y)

        # 底部狀態欄
        self.status_var = tk.StringVar()
        self.status_var.set("就緒")
        status_bar = ttk.Label(self.root, textvariable=self.status_var, relief=tk.SUNKEN)
        status_bar.pack(side=tk.BOTTOM, fill=tk.X)

        # 掃描標誌
        self.scanning = False

    def get_local_ip(self):
        """獲取本機IP地址"""
        try:
            s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
            s.connect(("8.8.8.8", 80))
            ip = s.getsockname()[0]
            s.close()
            return ip
        except:
            return "127.0.0.1"

    def ping(self, ip):
        """Ping指定IP,檢查是否在線"""
        try:
            # 根據操作系統選擇ping命令
            if platform.system().lower() == "windows":
                output = subprocess.check_output(["ping", "-n", "1", "-w", "500", ip], 
                                               stderr=subprocess.STDOUT, 
                                               universal_newlines=True)
            else:
                output = subprocess.check_output(["ping", "-c", "1", "-W", "0.5", ip], 
                                               stderr=subprocess.STDOUT, 
                                               universal_newlines=True)

            # 檢查ping結果
            if "TTL=" in output or "ttl=" in output:
                # 提取響應時間
                time_match = re.search(r"時間[=<](\d+)ms|time[=<](\d+\.?\d*)\s*ms", output)
                if time_match:
                    time_ms = time_match.group(1) if time_match.group(1) else time_match.group(2)
                else:
                    time_ms = "N/A"
                return True, time_ms
            return False, "N/A"
        except:
            return False, "N/A"

    def get_hostname(self, ip):
        """獲取IP對應的主機名"""
        try:
            hostname = socket.gethostbyaddr(ip)[0]
            return hostname
        except:
            return "未知"

    def get_mac_address(self, ip):
        """獲取IP對應的MAC地址"""
        try:
            # 根據操作系統選擇命令
            if platform.system().lower() == "windows":
                # 先ping一下確保ARP表中有該IP
                subprocess.call(["ping", "-n", "1", ip], 
                               stdout=subprocess.DEVNULL, 
                               stderr=subprocess.DEVNULL)
                # 查詢ARP表
                output = subprocess.check_output(["arp", "-a", ip], 
                                              stderr=subprocess.STDOUT, 
                                              universal_newlines=True)
                # 提取MAC地址
                mac_match = re.search(r"([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})", output)
                if mac_match:
                    return mac_match.group(0)
            else:
                # Linux/Mac系統
                subprocess.call(["ping", "-c", "1", ip], 
                               stdout=subprocess.DEVNULL, 
                               stderr=subprocess.DEVNULL)
                output = subprocess.check_output(["arp", "-n", ip], 
                                              stderr=subprocess.STDOUT, 
                                              universal_newlines=True)
                mac_match = re.search(r"([0-9a-fA-F]{2}[:-]){5}([0-9a-fA-F]{2})", output)
                if mac_match:
                    return mac_match.group(0)
            return "未知"
        except:
            return "未知"

    def scan_ip(self, ip, update_callback):
        """掃描單個IP"""
        if not self.scanning:
            return

        is_online, response_time = self.ping(ip)
        if is_online:
            hostname = self.get_hostname(ip)
            mac_address = self.get_mac_address(ip)
            update_callback(ip, hostname, mac_address, response_time)

    def update_tree(self, ip, hostname, mac_address, response_time):
        """更新Treeview"""
        self.tree.insert("", tk.END, values=(ip, hostname, mac_address, response_time))
        self.root.update_idletasks()

    def start_scan(self):
        """開始掃描"""
        # 清空結果
        for item in self.tree.get_children():
            self.tree.delete(item)

        # 解析IP範圍
        ip_range = self.ip_entry.get().strip()
        if not ip_range:
            self.status_var.set("請輸入IP範圍")
            return

        try:
            if "-" in ip_range:
                base_ip, range_part = ip_range.split("-")
                last_octet_base = ".".join(base_ip.split(".")[:-1])
                start = int(base_ip.split(".")[-1])
                end = int(range_part)
            else:
                # 如果只輸入了網絡段,掃描1-254
                last_octet_base = ip_range
                start = 1
                end = 254
        except:
            self.status_var.set("IP範圍格式錯誤,應為如 192.168.1.1-254")
            return

        # 更新UI狀態
        self.scanning = True
        self.scan_button.config(state=tk.DISABLED)
        self.stop_button.config(state=tk.NORMAL)
        self.progress.config(maximum=end-start+1)
        self.progress.config(value=0)

        # 啓動掃描線程
        self.scan_thread = threading.Thread(
            target=self.scan_range,
            args=(last_octet_base, start, end)
        )
        self.scan_thread.daemon = True
        self.scan_thread.start()

        # 更新狀態欄
        self.status_var.set(f"正在掃描 {last_octet_base}.{start} 到 {last_octet_base}.{end}...")

    def scan_range(self, base_ip, start, end):
        """掃描IP範圍"""
        threads = []
        count = 0

        for i in range(start, end+1):
            if not self.scanning:
                break

            ip = f"{base_ip}.{i}"
            # 創建並啓動線程
            thread = threading.Thread(
                target=self.scan_ip,
                args=(ip, self.update_tree)
            )
            thread.daemon = True
            thread.start()
            threads.append(thread)

            # 限制併發線程數
            if len(threads) >= 50:
                for t in threads:
                    t.join()
                threads = []

            # 更新進度條
            count += 1
            self.progress.config(value=count)

        # 等待剩餘線程完成
        for t in threads:
            t.join()

        # 更新UI狀態
        self.scanning = False
        self.scan_button.config(state=tk.NORMAL)
        self.stop_button.config(state=tk.DISABLED)
        self.status_var.set(f"掃描完成,發現 {len(self.tree.get_children())} 個在線設備")

    def stop_scan(self):
        """停止掃描"""
        self.scanning = False
        self.status_var.set("正在停止掃描...")
        self.scan_button.config(state=tk.NORMAL)
        self.stop_button.config(state=tk.DISABLED)

if __name__ == "__main__":
    root = tk.Tk()
    app = NetworkScanner(root)
    root.mainloop()