深度解析

ICO 格式解码:文件结构、头部字节与存续原因

koboshiCo-founder
·10 分钟阅读
ICO 格式解码:文件结构、头部字节与存续原因
概述

ICO 是一个容器,而非图像编解码器。它诞生于 1985 年的 Windows 1.0,可以在单个文件中存储同一图标的多种分辨率,供操作系统在运行时按需选取。以下是完整的技术拆解——从 6 字节文件头到 SVG 网站图标为何至今未能将其淘汰。

用十六进制编辑器打开一个 ICO 文件。前六个字节:

00 00 01 00 03 00

这就是整个文件头。00 00 是 Reserved 字段——永远为零。01 00 是 Type 字段——1 表示这是一个图标文件(光标文件用 2)。03 00 是 Count 字段——小端序表示 3,意味着这个 ICO 内包含三幅独立图像。六个字节,描述了一个容纳三份独立图像文件的容器。文件的其余部分是关于这些图像的元数据,以及图像数据本身。

ICO 既不是压缩算法,也不是色彩空间,而是一个容器——它是当今每一台 Windows 电脑和每一个主流浏览器上仍在日常使用的最小、最原始的容器格式。

ICO 的起源

微软在 1985 年的 Windows 1.0 中引入了 ICO。当时的 PC 生态根本没有标准化图像文件的概念。没有 JPEG,没有 PNG,没有 GIF。位图数据就是原始像素数组,每个程序各自管理存储。微软需要一种为程序、文件夹和系统 UI 元素分发图标的方式。需求很简单:

  • 一个图标一个文件,无论操作系统需要多少种尺寸
  • 运行时快速查找——操作系统不应为了获知图像尺寸而解码整张图
  • 在仅有 256 KB RAM 的系统上保持极小的内存占用

解决方案是目录结构。文件以头部开头,说明内部有多少幅图像。随后是一组条目数组,每个条目描述一幅图像的宽度、高度、位深度以及在文件中的偏移量。操作系统读取目录,找到匹配当前显示上下文的条目,跳转到对应偏移,直接渲染位图。

这一设计比 ZIP 早两年,比 GIF 早两年,比 JPEG 早七年。ICO 是一种微型文件系统,远在文件系统本身变得复杂之前就已出现。

ICO 存在的根本原因

图标从来不是单张图像,而是一套不同尺寸的图像组合。Windows 在文件列表中将同一图标渲染为 16 x 16,在桌面渲染为 32 x 32,在资源管理器详细信息窗格渲染为 48 x 48,在"超大图标"视图中渲染为 256 x 256。200% 缩放的高 DPI 显示器需要从 64 x 64 源渲染出 32 x 32 图标。操作系统必须根据上下文在瞬间做出判断,选出最合适的尺寸。

如果图标以独立 PNG 文件存储,操作系统需要打开多个文件、分别解码、缓存结果。而使用 ICO,操作系统打开一个文件,读取一个 16 字节的目录条目,直接跳转到位图数据。目录让格式具备自描述能力。元数据无需解码器。

网站图标(favicon)的情况也一样。当浏览器请求 /favicon.ico 时,它获得的是一个包含所有可能尺寸的文件——16 x 16 用于标签页,32 x 32 用于书签栏,180 x 180 用于 iOS 主屏幕快捷方式。浏览器无需解析图像头部,就能选出正确条目。

文件头结构

ICO 文件头分为两部分:ICONDIRICONDIRENTRY 数组。

ICONDIR(6 字节)

Bytes 0-1: Reserved     (must be 0)
Bytes 2-3: Type         (1 = icon, 2 = cursor)
Bytes 4-5: Count        (number of images, little-endian uint16)

每个 ICO 文件都以 00 00 01 00 开头。接下来两个字节告诉你后面有多少幅图像。

ICONDIRENTRY(每个 16 字节)

每幅图像对应一个条目:

Bytes 0:    Width         (in pixels; 0 means 256)
Bytes 1:    Height        (in pixels; 0 means 256)
Bytes 2:    ColorCount    (0 if more than 256 colors)
Bytes 3:    Reserved      (always 0)
Bytes 4-5:  Planes        (1 for icons)
Bytes 6-7:  BitCount      (bits per pixel: 1, 4, 8, 24, or 32)
Bytes 8-11: BytesInRes    (size of image data in bytes, uint32)
Bytes 12-15: ImageOffset  (byte offset to image data from file start, uint32)

Width 和 Height 字段各占一个字节。显式可存储的最大值是 255。当图像为 256 x 256 像素时,字段存储 0x00,解码器将其解释为 256。这一怪异设计自 1985 年起就是规范的一部分。

以下是一个包含三幅图像的 ICO 目录示例——16 x 16 BMP、32 x 32 BMP 和 256 x 256 PNG:

Offset 0x00:  00 00 01 00 03 00                    -- ICONDIR: 3 images
Offset 0x06:  10 10 00 00 01 00 18 00 2C 01 00 00 16 00 00 00  -- 16x16, 24bpp, BMP, 300 bytes at 0x16
Offset 0x16:  20 20 00 00 01 00 18 00 A8 02 00 00 42 01 00 00  -- 32x32, 24bpp, BMP, 680 bytes at 0x142
Offset 0x26:  00 00 00 00 01 00 20 00 B4 1C 00 00 EA 03 00 00  -- 256x256, 32bpp, PNG, 7348 bytes at 0x3EA

操作系统读取目录,找到满足需求的条目,然后跳转到 ImageOffset。不需要解析,不需要猜测,直接进行内存访问。

为什么 PNG、WebP 和 JPEG 从未取代 ICO

按任何现代指标衡量,PNG 都比 ICO 内部的位图编码更优秀。PNG 压缩率更高,支持真正的 Alpha 透明,工具链成熟。WebP 体积更小。JPEG 适合照片。然而 ICO 依然存在。三个原因:

1. 单文件多分辨率。 PNG 存储单张图像。ICO 可存储任意数量。网站当然可以提供一个 PNG 压缩包,但没有浏览器知道如何从中为 favicon 挑选正确尺寸。ICO 的目录结构用六个字节解决了这个问题。

2. 系统 API 绑定。 Windows 的 LoadIconExtractIconSHGetFileInfo 都期望 ICO 数据。Win32 API 没有对应 PNG 图标容器的等价物。改变这一点将破坏自 1985 年以来编译的每一个 Windows 应用程序,而微软从不会在向后兼容上让步。

3. Favicon 标准。 HTML 的 <link rel="icon"> 标签接受 PNG、SVG 和 ICO,但浏览器隐式默认请求 /favicon.ico 的做法早于这些选项。自 Internet Explorer 5(1999 年)以来,每个浏览器都会默认请求 /favicon.ico。未显式声明 favicon 链接的网站仍需要在该路径放置 ICO,否则浏览器会收到 404。

PNG 没有取代 ICO,因为 ICO 从来就不是在图像质量层面竞争。它竞争的是容器语义,而且没有其他格式提供同样的"文件中的目录"模型,外加三十年的操作系统支持。

容器内部是什么

ICO 文件中的每个条目指向一幅独立图像。图像数据可以是以下两种格式之一:

BMP 编码(遗留方案)

在 Windows Vista 之前,所有 ICO 图像都是未压缩或 RLE 压缩的 BMP 位图。存储的数据是 DIB(设备无关位图)——即没有 BITMAPFILEHEADER 的 BMP。它直接从 BITMAPINFOHEADER 开始:

Bytes 0-3:   Header size      (40 for BITMAPINFOHEADER)
Bytes 4-7:   Width            (uint32)
Bytes 8-11:  Height           (uint32, doubled for XOR + AND masks)
Bytes 12-13: Planes           (1)
Bytes 14-15: BitCount         (1, 4, 8, 24, or 32)
Bytes 16-19: Compression      (0 for uncompressed, 1 or 2 for RLE)
Bytes 20-23: Image size       (0 if uncompressed)
Bytes 24-27: X pixels per meter
Bytes 28-31: Y pixels per meter
Bytes 32-35: Colors used
Bytes 36-39: Important colors

DIB 头中的 Height 字段是实际图标高度的两倍。一个 32 x 32 的图标在 Height 字段中存储 64。上半部分是 XOR 掩码(实际彩色图像),下半部分是 AND 掩码(1 位透明位图)。对于带 Alpha 通道的 32 位图标,AND 掩码通常被忽略。

对于 24 位及更低、不含 Alpha 的 BMP 编码图标,AND 掩码是唯一的透明机制。AND 掩码中的 1 表示透明,0 表示不透明。这就是 Windows 在 32 位色彩成为标准之前实现透明的方式。

PNG 编码(Windows Vista 起)

从 Windows Vista 开始,ICO 文件可以存储 PNG 编码的图像。ICONDIRENTRY 中指定偏移量处的图像数据是一个完整的 PNG 文件——包含自身的 89 50 4E 47 签名和完整的 PNG 块结构。

这是 256 x 256 图标应该使用的格式。一个 256 x 256 的 32 位 BMP 未压缩时约 262 KB。同一张图作为 PNG 通常只有 20-60 KB。现代图标工具对 256 x 256 生成 PNG 编码条目,对小尺寸生成 BMP 编码条目,在兼容性和文件大小之间取得最佳平衡。

编码对比

编码引入时间压缩方式Alpha 通道支持适用尺寸
BMPWindows 1.0 (1985)无或 RLE1 位 AND 掩码16 x 16 至 48 x 48
PNGWindows Vista (2006)DEFLATE8 位 Alpha64 x 64 至 256 x 256

检测与解析 ICO 文件

不要相信 .ico 扩展名。读取前六个字节并解析目录。

TypeScript

interface IcoEntry {
  width: number
  height: number
  bitCount: number
  bytesInRes: number
  imageOffset: number
}

interface IcoInfo {
  valid: boolean
  type: "icon" | "cursor" | "unknown"
  count: number
  entries: IcoEntry[]
}

async function inspectIco(file: File): Promise<IcoInfo> {
  const buffer = await file.slice(0, 1024).arrayBuffer()
  const bytes = new Uint8Array(buffer)

  if (bytes.length < 6)
    return { valid: false, type: "unknown", count: 0, entries: [] }

  const reserved = bytes[0] | (bytes[1] << 8)
  const type = bytes[2] | (bytes[3] << 8)
  const count = bytes[4] | (bytes[5] << 8)

  if (reserved !== 0 || (type !== 1 && type !== 2)) {
    return { valid: false, type: "unknown", count: 0, entries: [] }
  }

  const entries: IcoEntry[] = []
  const dirSize = 6 + count * 16
  if (bytes.length < dirSize) {
    return { valid: false, type: "unknown", count: 0, entries: [] }
  }

  for (let i = 0; i < count; i++) {
    const off = 6 + i * 16
    entries.push({
      width: bytes[off] === 0 ? 256 : bytes[off],
      height: bytes[off + 1] === 0 ? 256 : bytes[off + 1],
      bitCount: bytes[off + 6] | (bytes[off + 7] << 8),
      bytesInRes:
        bytes[off + 8] |
        (bytes[off + 9] << 8) |
        (bytes[off + 10] << 16) |
        (bytes[off + 11] << 24),
      imageOffset:
        bytes[off + 12] |
        (bytes[off + 13] << 8) |
        (bytes[off + 14] << 16) |
        (bytes[off + 15] << 24),
    })
  }

  return {
    valid: true,
    type: type === 1 ? "icon" : "cursor",
    count,
    entries,
  }
}

Python

import struct
from typing import TypedDict

class IcoEntry(TypedDict):
    width: int
    height: int
    bit_count: int
    bytes_in_res: int
    image_offset: int

class IcoInfo(TypedDict):
    valid: bool
    type: str
    count: int
    entries: list[IcoEntry]

def inspect_ico(path: str) -> IcoInfo:
    with open(path, "rb") as f:
        header = f.read(1024)

    if len(header) < 6:
        return {"valid": False, "type": "unknown", "count": 0, "entries": []}

    reserved, type_, count = struct.unpack("<HHH", header[:6])
    if reserved != 0 or type_ not in (1, 2):
        return {"valid": False, "type": "unknown", "count": 0, "entries": []}

    dir_size = 6 + count * 16
    if len(header) < dir_size:
        return {"valid": False, "type": "unknown", "count": 0, "entries": []}

    entries: list[IcoEntry] = []
    for i in range(count):
        off = 6 + i * 16
        w, h, colors, _, planes, bit_count = struct.unpack("<BBBBHH", header[off:off+8])
        bytes_in_res, image_offset = struct.unpack("<II", header[off+8:off+16])
        entries.append({
            "width": 256 if w == 0 else w,
            "height": 256 if h == 0 else h,
            "bit_count": bit_count,
            "bytes_in_res": bytes_in_res,
            "image_offset": image_offset,
        })

    return {
        "valid": True,
        "type": "icon" if type_ == 1 else "cursor",
        "count": count,
        "entries": entries,
    }

Go

package main

import (
	"encoding/binary"
	"fmt"
	"os"
)

type IcoEntry struct {
	Width        int
	Height       int
	BitCount     int
	BytesInRes   uint32
	ImageOffset  uint32
}

type IcoInfo struct {
	Valid   bool
	Type    string
	Count   int
	Entries []IcoEntry
}

func inspectIco(path string) (IcoInfo, error) {
	f, err := os.Open(path)
	if err != nil {
		return IcoInfo{}, err
	}
	defer f.Close()

	buf := make([]byte, 1024)
	n, _ := f.Read(buf)
	buf = buf[:n]

	if n < 6 {
		return IcoInfo{Valid: false, Type: "unknown"}, nil
	}

	reserved := binary.LittleEndian.Uint16(buf[0:2])
	typ := binary.LittleEndian.Uint16(buf[2:4])
	count := binary.LittleEndian.Uint16(buf[4:6])

	if reserved != 0 || (typ != 1 && typ != 2) {
		return IcoInfo{Valid: false, Type: "unknown"}, nil
	}

	dirSize := 6 + int(count)*16
	if n < dirSize {
		return IcoInfo{Valid: false, Type: "unknown"}, nil
	}

	entries := make([]IcoEntry, 0, count)
	for i := 0; i < int(count); i++ {
		off := 6 + i*16
		w := buf[off]
		h := buf[off+1]
		bitCount := binary.LittleEndian.Uint16(buf[off+6 : off+8])
		bytesInRes := binary.LittleEndian.Uint32(buf[off+8 : off+12])
		imageOffset := binary.LittleEndian.Uint32(buf[off+12 : off+16])

		if w == 0 { w = 256 }
		if h == 0 { h = 256 }

		entries = append(entries, IcoEntry{
			Width:       int(w),
			Height:      int(h),
			BitCount:    int(bitCount),
			BytesInRes:  bytesInRes,
			ImageOffset: imageOffset,
		})
	}

	typStr := "icon"
	if typ == 2 {
		typStr = "cursor"
	}
	return IcoInfo{Valid: true, Type: typStr, Count: int(count), Entries: entries}, nil
}

PHP

function inspectIco(string $path): array {
    $header = file_get_contents($path, false, null, 0, 1024);
    if (strlen($header) < 6) {
        return ["valid" => false, "type" => "unknown", "count" => 0, "entries" => []];
    }

    $reserved = unpack("v", substr($header, 0, 2))[1];
    $type = unpack("v", substr($header, 2, 2))[1];
    $count = unpack("v", substr($header, 4, 2))[1];

    if ($reserved !== 0 || ($type !== 1 && $type !== 2)) {
        return ["valid" => false, "type" => "unknown", "count" => 0, "entries" => []];
    }

    $dirSize = 6 + $count * 16;
    if (strlen($header) < $dirSize) {
        return ["valid" => false, "type" => "unknown", "count" => 0, "entries" => []];
    }

    $entries = [];
    for ($i = 0; $i < $count; $i++) {
        $off = 6 + $i * 16;
        $w = ord($header[$off]);
        $h = ord($header[$off + 1]);
        $bitCount = unpack("v", substr($header, $off + 6, 2))[1];
        $bytesInRes = unpack("V", substr($header, $off + 8, 4))[1];
        $imageOffset = unpack("V", substr($header, $off + 12, 4))[1];

        $entries[] = [
            "width" => $w === 0 ? 256 : $w,
            "height" => $h === 0 ? 256 : $h,
            "bit_count" => $bitCount,
            "bytes_in_res" => $bytesInRes,
            "image_offset" => $imageOffset,
        ];
    }

    return [
        "valid" => true,
        "type" => $type === 1 ? "icon" : "cursor",
        "count" => $count,
        "entries" => $entries,
    ];
}

ImageMagick CLI

magick identify -verbose favicon.ico | grep -E "(Print size|Resolution|Colorspace|Type)"

列出所有内部图像:

magick identify favicon.ico

提取特定尺寸:

magick favicon.ico[2] extracted-256x256.png

或者简单地:

file favicon.ico
# favicon.ico: MS Windows icon resource - 5 icons, 16x16, 32 bits/pixel, 32x32, 32 bits/pixel

最佳实践与使用场景

ICO 不是通用图像格式。它是针对特定上下文的部署产物。在需要它的场景中使用,其余情况一律用 PNG。

Favicons

对于现代网站,同时提供两种格式:

<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="icon" type="image/x-icon" href="/favicon.ico" />

浏览器会选取它们支持的第一种格式。支持 SVG 的浏览器获得矢量图标。旧版浏览器和书签工具回退到 ICO。ICO 文件应包含:

尺寸编码方式用途
16 x 16BMP浏览器标签页、旧版 IE
32 x 32BMP任务栏、书签栏
48 x 48BMPWindows 快捷方式
180 x 180PNGiOS 主屏幕图标
256 x 256PNGWindows 资源管理器超大图标视图

包含这五个条目的 favicon ICO 通常在 30-50 KB。如果不包含 256 x 256 的 PNG,可降至 10 KB 以下。

Windows 应用程序图标

Windows 应用程序的可执行文件会嵌入 ICO 资源。操作系统在运行时从嵌入目录中加载对应尺寸。对于面向 Windows 10 和 11 的桌面应用,应包含:

  • 16 x 16、24 x 24、32 x 32、48 x 48(BMP,用于旧版和标准 DPI)
  • 64 x 64、128 x 128、256 x 256(PNG,用于高 DPI 显示器)

Windows 会在缺少精确尺寸时自动缩小,但缩放的视觉效果比直接渲染原生小尺寸更差。你省略的每一个尺寸都在牺牲视觉质量。

何时不使用 ICO

  • 网页内容图像:使用 WebP、JPEG 或 PNG。ICO 在压缩上没有优势,对内联图像也没有浏览器原生渲染收益。
  • 照片:ICO 并非为高色深连续色调图像设计。文件体积会爆炸。
  • 跨平台资源:macOS 使用 .icns,而非 ICO。Linux 使用 PNG 或 SVG。ICO 是 Windows 原生格式,只是恰好能在 Web 上工作。

ICO 的未来

ICO 的寿命会比大多数唱衰它的预言更长。原因与其起源相同:向后兼容。

SVG favicon 在技术层面更优越。它们可以缩放到任意分辨率,压缩率优于任何栅格格式,还支持动画和交互。Chrome、Firefox 和 Safari 都支持 SVG favicon。然而 ICO 依然存在,因为数百万台设备、企业系统和旧版浏览器仍在默认请求 /favicon.ico。没有 ICO 文件的网站会在服务器日志中产生 404,并在旧软件中显示破损图标。

Windows 11 仍将 ICO 用于应用程序图标、文件夹图标和系统 UI。Win32 API 仍期望 ICO 数据。微软对这一格式没有任何替换兴趣——没有业务理由去破坏三十年的应用程序兼容性。

真正发生变化的,是 ICO 文件的生成方式。现代工具链——IconKitchen、Figma 插件、在线生成器——会自动输出包含 PNG 编码高分辨率条目的 ICO 文件。容器本身没变,变的是里面的载荷。

ICO 谈不上让人喜欢,但它无处不在、不会破坏任何东西、支持成本几乎为零。这正是它到 2035 年仍将存在的原因。

如果你需要从现有图像创建 ICO 文件,JPG 转 ICO 可以直接在浏览器中将 JPG、PNG 和 WebP 源文件转换为多分辨率 ICO——无需上传,无需服务器处理。它会生成标准尺寸矩阵,并自动为 256 x 256 选择 PNG 编码、为小尺寸选择 BMP 编码,让你在兼容性和文件大小之间获得最佳平衡。

更多推荐阅读