You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

学习用:不借助Bitmap类读取BMP的LockBits问题(C# unsafe代码)

问题描述

我开发了一个用于学习的Windows Forms项目,手动读取24bit RGB BMP文件,多数功能正常,但窗体绘制位图的方法存在两个核心问题:

  • 原代码中注释的bits.Scan0 = (nint)ptr仅在行无填充时生效(如1920x1080桌面截图),打开行大小无法被4整除的文件时,图像变灰且像素偏移。
  • 将存储图像数据的3D数组改为2D数组后,行填充导致的显示异常解决,但小尺寸位图(约150x150及以下)在执行bits.UnlockBits后程序静默退出,无异常抛出,大尺寸图像无此问题。

补充测试情况

  • 用Paint创建的27x27 24bppRGB BMP文件会导致程序崩溃;
  • 将读取的图像数据写入新文件后,可在Windows查看器中正常打开。

初始代码

public sealed unsafe partial class Form1 : Form
{
    private byte[,,] imageData;
    private BMPHeader header;

    private readonly FieldInfo[] headerFields;
    private readonly int headerSize;

    public Form1()
    {
        InitializeComponent();
        panel.Paint += Panel_Paint;
        headerSize = sizeof(BMPHeader);
        headerFields = typeof(BMPHeader).GetFields();
    }

    private void openFileButton_Click(object sender, EventArgs e)
    {
        using var openFileDialog = new OpenFileDialog()
        {
            InitialDirectory = Environment.CurrentDirectory,
        };
        var dialogResult = openFileDialog.ShowDialog();
        if (dialogResult is DialogResult.OK)
        {
            ParseFile(openFileDialog.FileName);
        }
    }

    private void ParseFile(string filename)
    {
        using var fileStream = new FileStream(filename, FileMode.Open);
        if (fileStream.Length <= headerSize)
        {
            MessageBox.Show("Incorrect file format");
            return;
        }

        Span<byte> headerBuffer = stackalloc byte[headerSize];
        fileStream.ReadExactly(headerBuffer);

        fixed (byte* ptr = headerBuffer)
        {
            header = *(BMPHeader*)ptr;
        }

        foreach (var field in headerFields)
        {
            fileInfo.AppendText($"{field.Name}: {field.GetValue(header)}{Environment.NewLine}");
        }

        var pixelSize = header.bpp / 8;
        var rowSize = (int)Math.Ceiling((double)header.bpp * header.width / 32) * 4;
        var dataSizeCalculated = rowSize * header.height;
        var dataSizeFromHeader = header.image_size;
        var dataSizeStreamInfo = fileStream.Length - headerSize;

        if (dataSizeCalculated != dataSizeFromHeader || dataSizeFromHeader != dataSizeStreamInfo)
        {
            MessageBox.Show("Incorrect file format");
            fileInfo.Clear();
            return;
        }


        imageData = new byte[header.height, header.width, pixelSize];
        fixed (byte* ptr = imageData)
        {
            for (int i = header.height - 1; i >= 0; i--)
            {
                var rowPtr = new Span<byte>(ptr + (i * header.width * pixelSize), rowSize);
                fileStream.ReadExactly(rowPtr);
            }
        }
        panel.Width = header.width;
        panel.Height = header.height;
        panel.Invalidate();
    }

    private void transformGrayButton_Click(object sender, EventArgs e)
    {
        Parallel.For(0, header.height, (i) =>
        {
            for (int j = 0; j < header.width; j++)
            {
                var grayPixel = 0.299 * imageData[i, j, 0] + 0.587 * imageData[i, j, 1] + 0.114 * imageData[i, j, 2];
                for (int c = 0; c < 3; c++)
                    imageData[i, j, c] = (byte)grayPixel;
            }
        });
        panel.Invalidate();
    }

    private void createBorderButton_Click(object sender, EventArgs e)
    {
        const int BorderSize = 15;
        if (header.height < BorderSize * 2 || header.width < BorderSize * 2)
            return;

        var rnd = Random.Shared;
        
        var color = Color.FromArgb(rnd.Next(0, 255), rnd.Next(0, 255), rnd.Next(0, 255));

        for (int i = 0; i < BorderSize; i++)
        {
            for (int j = 0; j < header.width; j++)
            {
                SetPixel(i, j, color);
                SetPixel(header.height - i - 1, j, color);
            }

            for (int j = BorderSize; j < header.height - BorderSize; j++)
            {
                SetPixel(j, i, color);
                SetPixel(j, header.width - i - 1, color);
            }
        }
        panel.Invalidate();
    }

    private void SetPixel(int x, int y, Color color)
    {
        imageData[x, y, 0] = color.B;
        imageData[x, y, 1] = color.G;
        imageData[x, y, 2] = color.R;
    }

    private void Panel_Paint(object sender, PaintEventArgs e)
    {
        if (imageData is null)
            return;

        using var bmp = new Bitmap(header.width, header.height);
        var bits = bmp.LockBits(new Rectangle(0, 0, header.width, header.height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb);

        fixed (byte* ptr = imageData)
        {
            //bits.Scan0 = (nint)ptr; // works only when rows have no padding
            for (int i = 0; i < header.height; i++)
            {
                byte* destRow = (byte*)bits.Scan0 + (i * bits.Stride);
                byte* sourceRow = ptr + (i * header.width * 3);
                Buffer.MemoryCopy(sourceRow, destRow, header.width * 3, header.width * 3);
            }
        }
        bmp.UnlockBits(bits);
        e.Graphics.DrawImage(bmp, 0, 0);
    }
}

BMPHeader结构体

[StructLayout(LayoutKind.Sequential, Pack = 1)]
struct BMPHeader
{
    public ushort type;
    public uint size;
    public ushort reserved1;
    public ushort reserved2;
    public uint offset;
    public uint dib_size;
    public int width;
    public int height;
    public ushort planes;
    public ushort bpp;
    public uint compression;
    public uint image_size;
    public uint x_ppm;
    public uint y_ppm;
    public uint num_colors;
    public uint important_colors;
};

写入文件测试代码

var writeFs = new FileStream("testWrite.bmp", FileMode.Create, FileAccess.ReadWrite);
writeFs.Write(headerBuffer);
fixed (byte* ptr = imageData)
{
    for (int i = header.height - 1; i >= 0; i--)
    {
        var rowPtr = new Span<byte>(ptr + (i * header.width * pixelSize), rowSize);
        writeFs.Write(rowPtr);
    }
}
writeFs.Close();

更新后的代码

public sealed unsafe partial class Form1 : Form
{
    private byte[,] imageData;
    private BMPHeader header;

    private readonly FieldInfo[] headerFields;
    private readonly int headerSize;

    public Form1()
    {
        InitializeComponent();
        panel.Paint += Panel_Paint;
        headerSize = sizeof(BMPHeader);
        headerFields = typeof(BMPHeader).GetFields();
    }

    private void openFileButton_Click(object sender, EventArgs e)
    {
        using var openFileDialog = new OpenFileDialog()
        {
            InitialDirectory = Environment.CurrentDirectory,
        };
        var dialogResult = openFileDialog.ShowDialog();
        if (dialogResult is DialogResult.OK)
        {
            ParseFile(openFileDialog.FileName);
        }
    }

    private void ParseFile(string filename)
    {
        using var fileStream = new FileStream(filename, FileMode.Open);
        if (fileStream.Length <= headerSize)
        {
            return;
        }

        Span<byte> headerBuffer = stackalloc byte[headerSize];
        fileStream.ReadExactly(headerBuffer);

        fixed (byte* ptr = headerBuffer)
        {
            header = *(BMPHeader*)ptr;
        }

        foreach (var field in headerFields)
        {
            fileInfo.AppendText($"{field.Name}: {field.GetValue(header)}{Environment.NewLine}");
        }

        var pixelSize = header.bpp / 8;
        var rowSize = (int)Math.Ceiling((double)header.bpp * header.width / 32) * 4;
        var dataSizeCalculated = rowSize * header.height;
        var dataSizeFromHeader = header.image_size;
        var dataSizeStreamInfo = fileStream.Length - headerSize;

        if (dataSizeCalculated != dataSizeFromHeader || dataSizeFromHeader != dataSizeStreamInfo)
        {
            fileInfo.Clear();
            return;
        }


        imageData = new byte[header.height, rowSize]; //array is now 2D
        fixed (byte* ptr = imageData)
        {
            for (int i = header.height - 1; i >= 0; i--)
            {
                var rowPtr = new Span<byte>(ptr + i * rowSize, rowSize); //row pointer is based on rowSize now
                fileStream.ReadExactly(rowPtr);
            }
        }

        panel.Width = header.width;
        panel.Height = header.height;
        panel.Invalidate();
    }

    private void transformGrayButton_Click(object sender, EventArgs e)
    {
        Parallel.For(0, header.height, (i) =>
        {
            for (int j = 0; j < header.width; j++)
            {
                var grayPixel = 0.299 * imageData[i, j * 3] + 0.587 * imageData[i, j * 3 + 1] + 0.114 * imageData[i, j * 3 + 2]; //fixed here
                for (int c = 0; c < 3; c++)
                    imageData[i, j * 3 + c] = (byte)grayPixel;
            }
        });
        panel.Invalidate();
    }

    private void createBorderButton_Click(object sender, EventArgs e)
    {
        const int BorderSize = 15;
        if (header.height < BorderSize * 2 || header.width < BorderSize * 2)
            return;

        var rnd = Random.Shared;
        
        var color = Color.FromArgb(rnd.Next(0, 255), rnd.Next(0, 255), rnd.Next(0, 255));

        for (int i = 0; i < BorderSize; i++)
        {
            for (int j = 0; j < header.width; j++)
            {
                SetPixel(i, j, color);
                SetPixel(header.height - i - 1, j, color);
            }

            for (int j = BorderSize; j < header.height - BorderSize; j++)
            {
                SetPixel(j, i, color);
                SetPixel(j, header.width - i - 1, color);
            }
        }
        panel.Invalidate();
    }

    private void SetPixel(int x, int y, Color color)
    {
        imageData[x, y * 3] = color.B; //fixed here
        imageData[x, y * 3 + 1] = color.G;
        imageData[x, y * 3 + 2] = color.R;
    }

    private void Panel_Paint(object sender, PaintEventArgs e)
    {
        if (imageData is null)
            return;

        using var bmp = new Bitmap(header.width, header.height);
        var bits = bmp.LockBits(new Rectangle(0, 0, header.width, header.height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb);

        fixed (byte* ptr = imageData)
        {
            try
            {
                bits.Scan0 = (nint)ptr;
                bmp.UnlockBits(bits);
                e.Graphics.DrawImage(bmp, 0, 0);
            }
            catch (Exception ex)
            {
                // doesn't fall here
                MessageBox.Show(ex.Message);
            }
        }
    }
}

问题分析与解决

问题1:直接赋值Scan0导致的行填充异常

BMP格式要求每行像素数据必须是4字节对齐的,行大小(rowSize)会向上取整到4的倍数。初始代码的3D数组byte[,,] imageData仅存储了header.width * 3字节的有效像素,没有包含填充字节;但Bitmap.LockBits返回的BitmapData.Stride是带填充的行大小。

当直接把bits.Scan0设置为3D数组的指针时,GDI+会按照自己的Stride读取内存,数组中缺失的填充字节会导致下一行数据偏移,最终出现像素错位、颜色变灰的问题。只有当header.width*3刚好是4的倍数时,行大小与Stride一致,才能正常显示。

问题2:修改Scan0导致的程序崩溃

修改BitmapData.Scan0是完全非法的操作:

  • Scan0是GDI+为Bitmap内部分配的非托管内存指针,其生命周期由GDI+管理。
  • 强行替换为托管数组的指针后,调用UnlockBits时GDI+会尝试释放不属于它的内存,触发内存访问违规,这类非托管异常无法被.NET运行时捕获,导致程序静默退出。
  • 小尺寸数组的内存布局更靠近GC管理的敏感区域,更容易触发崩溃;大尺寸数组只是暂时未触发,但本质仍是非法操作。

正确的绘制方式

无论使用3D还是2D数组,都必须通过合法内存复制将数据写入Bitmap的内存区域,不能修改Scan0。以下是适配2D数组的正确绘制逻辑:

private void Panel_Paint(object sender, PaintEventArgs e)
{
    if (imageData is null)
        return;

    using var bmp = new Bitmap(header.width, header.height);
    var bits = bmp.LockBits(new Rectangle(0, 0, header.width, header.height), ImageLockMode.ReadWrite, PixelFormat.Format24bppRgb);
    var rowSize = (int)Math.Ceiling((double)header.bpp * header.width / 32) * 4;

    fixed (byte* ptr = imageData)
    {
        for (int i = 0; i < header.height; i++)
        {
            byte* destRow = (byte*)bits.Scan0 + (i * bits.Stride);
            byte* sourceRow = ptr + (i * rowSize);
            // 复制整行(包含填充字节)
            Buffer.MemoryCopy(sourceRow, destRow, bits.Stride, rowSize);
        }
    }
    bmp.UnlockBits(bits);
    e.Graphics.DrawImage(bmp, 0, 0);
}

代码优化建议

  1. 严格遵循BMP格式规范

    • 不要直接从headerSize后读取图像数据,应使用header.offset作为图像数据的起始偏移:
      fileStream.Seek(header.offset, SeekOrigin.Begin);
      
    • 增加BMP合法性校验:检查header.type是否为0x4D42(即"B""M"的ASCII码),确保是标准BMP文件。
  2. 避免非法内存操作

    • 永远不要修改BitmapData.Scan0,所有数据传递必须通过内存复制完成。
    • 确保unsafe代码中的指针操作都在fixed块内执行,防止GC移动托管数组导致指针失效。
  3. 内存与性能优化

    • 使用Span<T>替代部分unsafe代码,提升安全性:读取行数据时可直接用fileStream.ReadExactly(imageData.AsSpan(i * rowSize, rowSize)),无需fixed指针。
    • 图像处理操作(如灰度转换)直接在Span上进行,减少多维数组的索引开销。
  4. 错误处理增强

    • 校验BMP关键字段:比如header.bpp必须为24、header.compression必须为0(无压缩),避免处理不支持的BMP格式。
    • 捕获并处理文件读取中的IO异常,避免程序意外退出。

内容的提问来源于stack exchange,提问作者SelfishCrawler

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.10 15:23:40