学习用:不借助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); }
代码优化建议
严格遵循BMP格式规范
- 不要直接从
headerSize后读取图像数据,应使用header.offset作为图像数据的起始偏移:fileStream.Seek(header.offset, SeekOrigin.Begin); - 增加BMP合法性校验:检查
header.type是否为0x4D42(即"B""M"的ASCII码),确保是标准BMP文件。
- 不要直接从
避免非法内存操作
- 永远不要修改
BitmapData.Scan0,所有数据传递必须通过内存复制完成。 - 确保unsafe代码中的指针操作都在
fixed块内执行,防止GC移动托管数组导致指针失效。
- 永远不要修改
内存与性能优化
- 使用
Span<T>替代部分unsafe代码,提升安全性:读取行数据时可直接用fileStream.ReadExactly(imageData.AsSpan(i * rowSize, rowSize)),无需fixed指针。 - 图像处理操作(如灰度转换)直接在Span上进行,减少多维数组的索引开销。
- 使用
错误处理增强
- 校验BMP关键字段:比如
header.bpp必须为24、header.compression必须为0(无压缩),避免处理不支持的BMP格式。 - 捕获并处理文件读取中的IO异常,避免程序意外退出。
- 校验BMP关键字段:比如
内容的提问来源于stack exchange,提问作者SelfishCrawler
相关产品推荐
相关产品推荐

