多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

矩阵转置offset计算错误:嵌入式越界bug的完整复盘

矩阵转置offset计算错误:嵌入式越界bug的完整复盘 1. 项目背景与报错场景1.1 一个奇怪的崩溃日志前阵子调一个GD32F407的数据采集项目两路ADC采样每轮采128个点我要把它们排成一个16行8列的矩阵再转置成8行16列一边刷LCD一边按行串口上传。某天下午同事截了个日志过来第一行是illegal input, offset 1, char 我第一反应是项目里谁调了解析库给了解析器一段不规范的文本。可采集程序里并没有多少文本解析的地方。后来才明白这个报错来自一个轻量级的配置解析组件它在解析一块临时缓冲区时发现在字符偏移为1的位置出现了一个。是左尖括号按协议不该出现在那个位置。但问题是缓冲区的内容是谁写进去的没有人主动往解析缓冲区里写啊。逐层查下去最终定位到matrix_tranpose这个函数。函数名是我写的少了个s——不是transpose而是tranpose。转置时偏移量算错写越界把紧挨着目标缓冲区的另一块解析缓冲区前几个字节改成了乱码乱码里恰好有0x3C也就是字符解析器一上来就撞到非法输入于是抛出了这个措辞标准的报错。可以说解析器非常无辜真正的元凶是“transpose offset计算”里的一个下标的单位写错了。1.2 插曲tranpose这个拼写错误这个拼写偏差带来的影响超出预期。排查时我下意识在工程里搜“transpose”结果相关的调用一个都搜不到因为函数名和变量名全是tranpose。后来同事提醒我才意识到自己把单词拼错了。搜“tranpose”才找到代码。之后跟人沟通每说一次“transpose”都要纠正一遍浪费了不少时间。这算是整个复盘里最啼笑皆非的插曲但也让我养成了给核心函数起名时逐字母检查的习惯。回到问题本身这个bug发生的条件其实非常基础一个二维矩阵在线性内存中的偏移计算。可越是基础的东西写岔的时候越隐蔽。花了一下午才定位原因之一就是我先被报错字符串带偏了没有立刻检查数据生成链路。这篇复盘会把推导、定位、工具和常见的坑完整理一遍尤其适合跟我一样在做嵌入式图像、信号处理或者需要预处理的读者。即便你不用GD32用的不是C语言offset计算的逻辑也完全相通。2. transpose offset计算的原理与公式推导2.1 行主序存储下的一维偏移公式二维数组在内存里并不是真正的“二维”它只是一段连续的线性地址。所谓某个元素的offset指的是“从数组首地址开始向后数第几个元素”。大多数C/C编译器采用行主序存储A[N][M]在内存中是先放第0行整行再放第1行整行以此类推。所以元素A[i][j]的源偏移是src_off i * M j这里M是每一行的元素个数。为什么不是i * N j因为你的行数是N列数是M每行有M个元素。想走到第i行必须先跨过i整行每行宽M个元素跨过之后到了第i行的起点再往右走j步就到A[i][j]了。这个“每行宽”我习惯叫stride也就是步长不只是数学意义上的列数还可能包含内存对齐产生的填充。用生活化类比来说电影院每排固定10个座位。你要找第3排第5座先走过2排每排10个座就是20步再往右走4步从0计数总共24步。这里“10”就是步长。矩阵转置里最容易错的地方恰恰是这个“每排多少个座位”在转置前后会变。源矩阵每排M个目标矩阵每排N个计算目标偏移时必须乘N而不是M。2.2 转置后目标偏移的公式与实例矩阵转置的定义A[i][j]放到B[j][i]。如果A是N行M列那么B是M行N列。目标矩阵B中元素在第j行第i列B每行有N个元素所以目标偏移是dst_off j * N i很多人在写代码时看到源偏移是i * M j就顺手把目标偏移写成j * M i。这就是bug的根源。M是原矩阵的列数N是原矩阵的行数转置后目标矩阵的“每行宽”是N不是M。也就是说目标偏移里乘的应该是原矩阵的行数而不是原矩阵的列数。不理解的话用一个2行3列的最小例子手推一遍效果非常直观。设源矩阵A1 2 3 4 5 6转置后目标矩阵B是3行2列1 4 2 5 3 6把几个关键元素的线性偏移都列出来源元素源偏移i*3j目标位置j,i目标偏移j*2iA[0][0]10B[0][0]0A[0][1]21B[1][0]2A[0][2]32B[2][0]4A[1][0]43B[0][1]1A[1][1]54B[1][1]3A[1][2]65B[2][1]5可以看到源偏移序列是0、1、2、3、4、5目标偏移序列是0、2、4、1、3、5。目标偏移不是简单重排而是因为B每行只有2个元素A[1][0]在源里排第3个到目标矩阵里却变成了第1个。行列互换步长变了整个顺序都变了。不把这一步在纸上推一遍代码里非常容易错。2.3 代码实现示例先把索引写在纸上是真不亏最直接的C语言实现如下void transpose_int(int *dst, const int *src, int rows, int cols) { for (int i 0; i rows; i) { for (int j 0; j cols; j) { int src_off i * cols j; int dst_off j * rows i; dst[dst_off] src[src_off]; } } }这里rows是原矩阵行数cols是原矩阵列数dst的大小是rows * cols个int和src元素总数相同因为转置不改变元素总个数。不同之处只是元素排列顺序变了。需要注意如果src和dst是同一块内存这个循环不能直接用——但那是原地转置的问题。方阵的原地转置比较简单只交换上三角或下三角for (int i 0; i rows; i) for (int j i 1; j cols; j) swap(m[i * cols j], m[j * cols i]);非方阵原地转置则复杂得多涉及置换环很容易绕晕。所以我建议除非明确要做低内存优化否则一律开一块目标缓冲区用上面的双循环搬移把源和目的分开能让offset计算一目了然。还有一个脱不开嘴的细节是stride。处理图像时每一行不一定正好是width个像素因为内存对齐可能在一行末尾留出padding。这时源偏移就不再是i * width j而是i * src_stride j目标偏移要写成j * dst_stride i。src_stride和dst_stride分别是源和目标每行的实际元素个数。很多成熟的图像库比如OpenCV的矩阵转置实现都会在内部处理这种步长问题。如果你写的是固定算法可以暂时忽略一旦移植到图像上就一定要把stride纳入offset计算。3. 排障实录从报错信息到根因定位3.1 报错来自哪里解析器还是算法先回到那条illegal input, offset 1, char 。它的格式很像常见配置文件解析器的输出告诉你“在偏移X处遇到字符Y但这个位置不允许出现Y”。它本身是一个非常准确的解析器异常。但解析器只是受害者。它拿到了被污染的数据然后如实报告。排查的关键一步是把数据源找出来。我当时把出错前的一帧数据用十六进制dump出来和正常帧对比发现前面十几个字节里多了几个不属于采样结果的字节其中一个就是0x3C。顺着写入路径往前推发现这些多余字节是从转置函数的目标缓冲区“溢出”出来的。转置函数在计算目标偏移时用了错误的步长导致写越界把后面相邻缓冲区的头部污染了。而那块相邻缓冲区刚好是给解析器用的。这给我们的教训是报错信息永远只是症状。看到“illegal input”不要立刻修改解析器逻辑应该先回答一个问题——“这段输入是谁拼出来的”。在这个项目里输入不是外部文件而是内部函数往缓冲区写的数据。一旦意识到这一点排查方向就从“解析函数有没有bug”转向“数据生成函数有没有越界”速度就快多了。3.2 逐层定位最小样本与断言检查定位过程其实很朴素。我没有直接在16x8的大矩阵里调试而是用固定数组构造了一个最小复现样本一个2行3列的矩阵const int src_test[6] {1, 2, 3, 4, 5, 6}; int dst_test[6] {0}; transpose_int(dst_test, src_test, 2, 3);然后在函数内部临时添加打印输出每个循环步的i、j、src_off、dst_off。打印结果和手工推演的表格一比很快发现当i1, j2时也就是源元素A[1][2]正确目标偏移应该是2*215但打印出来却是7因为代码里错误地写成了dst_off j * cols i也就是最终算成了2*317。7已经超过了目标缓冲区最大下标5于是越界写入了后面的解析缓冲区。加入断言能提前抓住这个错误assert(dst_off 0 dst_off rows * cols);这句话的意思是目标偏移必须在目标缓冲区范围内。如果越界程序会立即崩溃而不是继续运行并把问题掩盖到后续的解析器报错。在嵌入式Release版本中assert可能被禁用所以更稳妥的做法是定义自己的CHECK宏越界时打印file:line并触发hardfault。这样排查时间可以从小时级压缩到分钟级。3.3 同源联想GD32的向量表偏移也是offset问题这个项目里还有另一处和offset有关的地方GD32的APP程序从Bootloader跳转出来运行在Flash的偏移地址需要把CPU中断向量表指针搬到新的基地址。GD32F4的写法比较直接在APP初始化代码里设置SCB-VTOR APP_BASE_ADDRESS;同时链接脚本里也要把VECTOR_TABLE_OFFSET改成相应的偏移量。如果只配了寄存器链接脚本没有配套或者两者不一致中断向量表读到的地址就可能是错的。表现出来就是程序看起来在跑但某个中断一进来就飞到完全无关的函数里外设表现异常屏幕上输出乱码。这个现象和转置越界的相似性很强都是“应该在某个位置读数据结果因为偏移基准或步长算错读到了错误的位置”。转置偏移算错导致缓冲区互相污染向量表偏移配错导致中断向量指错。两者抽象一下都是“offset计算的一致性”问题。所以我后来把这两类问题都用同一套排查思路处理先明确基准地址再明确步长最后检查边界。GD32的VTOR设置虽然和矩阵转置表面上八竿子打不着但底层逻辑是共通的共同点都是“你告诉机器从哪开始、每走一步跨多大、最多能走到哪”。4. offset计算的易错点与通用排查清单4.1 五个容易翻车的细节行列数传反。函数参数写的是(rows, cols)调用时传成(cols, rows)。方阵测试完全看不出来因为行数和列数相等换成3x2矩阵就崩。解决办法是测试用例里必须包含非方阵。目标缓冲区大小分配错误。有人分配dst时会习惯性写成rows * rows或者cols * cols这在方阵下没问题但非方阵时会越界。转置不改变元素总数目标缓冲区大小永远是rows * cols个元素。忽略stride。图像一行可能有padding计算偏移必须用“每一行实际占用的元素个数”而不是逻辑上的列数。如果图像宽度受内存对齐限制一行末尾有多余字节而代码里还用i * width j就会出现每行末尾错位。把转置和旋转混为一谈。转置是A[i][j]到B[j][i]旋转90度是A[i][j]到B[j][N-1-i]公式明显不同。看错了公式offset算出来的结果完全不对而且表现相似很难第一时间发现。原地转置时把所有元素都交换了。方阵原地转置只需要交换非对角线两侧的元素非方阵原地转置则涉及置换环直接暴力交换会重复搬移。建议新手一律用辅助数组避免引入玄学bug。下面这张表可以贴在代码旁边当速查坑点典型症状检查建议行列数传反目标偏移越界或错位用非方阵测试用例目标缓冲分配错误溢写到相邻内存检查分配大小rows*cols忽略stride每行内部错位使用实际行步长混淆旋转输出方向不对逐元素对比公式原地转置错误交换数据成对重复用辅助数组调试4.2 排查工具与手段排查offset问题我一般按“打印、断言、黄金数据”三步走。第一步在疑似函数里临时打印每个循环变量和偏移值尤其打印第一次越界的位置。第二步给所有内存读写加边界断言把越界从“数据被污染但程序继续跑”变成“当场崩溃”。第三步构造一个黄金测试数据例如用1到6的数组做2行3列转置预期结果是1,4,2,5,3,6如果结果不对直接比对函数公式不用去猜别的环节。在嵌入式平台上不方便printf的话可以用调试器watch一个全局数组来记录偏移值或者把关键信息通过串口打印到上位机。还有一招把dst缓冲区放在两个已知pattern的填充区之间比如在两边填入0xAA和0x55越界写入会改变这些pattern这样通过看pattern是否被破坏就能快速知道有没有越界。这招在裸机调试里很好用。另外记住别先怀疑编译器。编译器再激进也不太可能把i * M j优化成错误的东西。绝大多数offset问题是源代码里M、N、rows、cols写错了。先把公式对照纸面抄一遍再谈优化。4.3 延伸思考SQL Server分页里的OFFSET和TOP“sqlserver offset 后再 top 20 查到的是什么”这个热搜词很有意思因为它把offset从内存索引拉到了数据库分页。SQL Server的标准分页写法是SELECT * FROM logs ORDER BY log_id OFFSET 20 ROWS FETCH NEXT 20 ROWS ONLY;语义很清楚按log_id排序后跳过前20行取接下来的20行也就是第21到第40条记录。这里的OFFSET就是“跳过多少行”FETCH NEXT就是“取多少行”和矩阵转置里的偏移本质一样都是基于一个确定的顺序按某个步长跳过一段。但有人会写出SELECT TOP 20 ... OFFSET 20 ROWS这样的混合语句问到底查到的是哪20条。这里容易踩坑在于TOP和OFFSET同时出现时执行逻辑并不符合直觉不同版本、不同优化器对这两个子句的处理顺序可能不同结果可能让人意外。稳妥起见要用分页就应该用OFFSET ... FETCH NEXT不要尝试用TOP来模拟分页。如果没有ORDER BYOFFSET就失去了意义因为结果集顺序不确定你根本不知道“跳过20行”跳的是哪些行。这也是offset计算的通用原则偏移必须依赖一个稳定的布局或顺序否则就不可预期。在矩阵里是行主序和列数在数据库里是ORDER BY子句在GD32里是向量表基地址和链接脚本偏移。确定好这个前提offset计算才不会翻车。5. 踩坑后的一点实操心得5.1 三条教训这次复盘的收获不只在算法本身更在调试心态。第一函数名拼错的代价很大一个tranpose让搜索、沟通、定位全线变慢写代码时多花十秒钟检查拼写能省下后面十分钟甚至一小时。第二看到报错字符串时第一反应不该是去搜它而是去问“这段数据是怎么生成的”。illegal input掩盖了真正的转置越界一旦数据流映入眼帘根因藏不住。第三基础公式再简单也值得手推。用2x3矩阵在纸上算一遍偏移比在16x8的大矩阵里反复打印要高效太多。5.2 一个小技巧把矩阵转置的黄金数据做成硬编码常量最后分享一个实实在在的小技巧。我在测试代码里放了一段黄金数据每次改动转置相关逻辑都会跑一遍const int src_golden[] {1, 2, 3, 4, 5, 6}; // 2行3列 const int dst_expected[] {1, 4, 2, 5, 3, 6}; // 3行2列 int dst_golden[6]; transpose_int(dst_golden, src_golden, 2, 3); for (int k 0; k 6; k) { if (dst_golden[k] ! dst_expected[k]) { // 报告错误 } }这段代码不用测试框架几行就能写完在桌面端调试和嵌入式板子上都能跑。它不仅能在下次改代码时第一时间暴露回归还能用来验证你的索引公式在非方阵下是否正确。经历过这次illegal input, offset 1, char 的迷惑事件之后我现在写任何涉及offset的代码都会先写这么一段黄金测试再开始写业务逻辑。这个方法很笨但确实能让你少走很多弯路。
返回列表