Compare commits
539 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 87fa0276ad | |||
| 740bcbcc37 | |||
| 5538af829b | |||
| 1f94015434 | |||
| 3a2f9b21fb | |||
| 2b848c4a0b | |||
| ad7338365c | |||
| c1beec50e7 | |||
| dfd1528409 | |||
| f8c9296392 | |||
| c9c9dc8b0d | |||
| fe4f0db01c | |||
| f10bb8df65 | |||
| 66725c0fa3 | |||
| 0d469a835a | |||
| b5671e8c02 | |||
| 1b5e5d424c | |||
| f4ed8a4db2 | |||
| 9608b7a8ea | |||
| fc0d3e4b5c | |||
| b0a6394aa1 | |||
| 8a81fa9e41 | |||
| fff961d803 | |||
| 2b5eb1c0db | |||
| b45f50569b | |||
| 1078b554f5 | |||
| be16669564 | |||
| bbfb47ebd0 | |||
| aaf75f7992 | |||
| 493fa6ba0e | |||
| b8e1de5f2e | |||
| 364b163663 | |||
| 046097918d | |||
| d6253aa974 | |||
| 3e01e9d98e | |||
| ee002d7ead | |||
| 7efa58d9a1 | |||
| 9a71265f41 | |||
| 0b1e10f254 | |||
| 770b76d4e4 | |||
| 6fab68fa63 | |||
| e63abb919a | |||
| 32f7e2e5d9 | |||
| 48326c90ed | |||
| 4000bb73cf | |||
| 72b4b0943c | |||
| f81bb38b64 | |||
| c373e2e97d | |||
| 51ce59006b | |||
| 4ceb5b41e6 | |||
| 0d62a4a777 | |||
| e18e52ef7e | |||
| 48becbfa48 | |||
| f48208f1c5 | |||
| 002e66d928 | |||
| 045ca7f688 | |||
| 1e44be8128 | |||
| 2525c2d948 | |||
| b79ae0abfc | |||
| 1b5b2f6b7e | |||
| e4d70f9fcb | |||
| 8d8ecd9c56 | |||
| 3cce00a7cb | |||
| aed842e1f2 | |||
| 00fe66a029 | |||
| 4a4ad6bc5f | |||
| dc964090ea | |||
| 6ffbb7d5b5 | |||
| 4e4d597ed6 | |||
| 314ebd2cfe | |||
| aa2b15d306 | |||
| f8e62a9ba2 | |||
| 2be49dc9dd | |||
| a157b443cb | |||
| 0cabed0b5c | |||
| 7708f12c10 | |||
| 36f6948544 | |||
| 1221676272 | |||
| 44596f1487 | |||
| 899e4a6bdf | |||
| add61ca2af | |||
| 8744c833e5 | |||
| 2b478588a7 | |||
| 8f5ac7f5d0 | |||
| 7feceb803d | |||
| f754d59218 | |||
| fea4cc7502 | |||
| 613fc25651 | |||
| 2533a72a0a | |||
| 4d08c3e706 | |||
| b4588108ed | |||
| 77f031a8fd | |||
| cb2fb4f095 | |||
| e02f46ed6a | |||
| f405c8994f | |||
| 0b84c43d2e | |||
| ecf9d3ad3d | |||
| b49e226815 | |||
| 98d46e626d | |||
| 058e7c061c | |||
| 9c6d150261 | |||
| 35d6897fdc | |||
| 20617a33f7 | |||
| 88601422c3 | |||
| dab0d8ba17 | |||
| 1cf9f6f021 | |||
| f8d795c387 | |||
| 594d2ee57d | |||
| d56c6ce862 | |||
| 4f96c716ed | |||
| 51d89cf9d3 | |||
| e5b4182a9f | |||
| 7c8669e52a | |||
| 5c64cf0859 | |||
| 9fe8f8545d | |||
| 0566405198 | |||
| 557f1d042a | |||
| c3b217d581 | |||
| fa371bcf11 | |||
| 6e436117dd | |||
| 15bcfc7c92 | |||
| b367d16f3c | |||
| d64bbdc57f | |||
| 2c69a6ca07 | |||
| 16048140fd | |||
| b25d70e962 | |||
| 71b6a5fa14 | |||
| 6531c4a4bc | |||
| efda416f42 | |||
| b26f01f5ec | |||
| 8a0cb464c0 | |||
| 7dbc8f8676 | |||
| f5161e1567 | |||
| 27a700b0d8 | |||
| 50c75eb516 | |||
| 9191c1d966 | |||
| 0c8fb5e1b6 | |||
| c51ac2e8ef | |||
| 126c4bf57f | |||
| e71d623e60 | |||
| 929cacbd72 | |||
| c377fc8c15 | |||
| de512a0db8 | |||
| cdf88a7121 | |||
| c66b5ad40d | |||
| f08b12a46c | |||
| c63bd029a9 | |||
| 325abe4337 | |||
| 31c7129562 | |||
| f251162593 | |||
| d0e73d6553 | |||
| b46e44d92c | |||
| 4d68a614b7 | |||
| 93e057abf2 | |||
| 4da31c93c3 | |||
| fb359af5f6 | |||
| beeb8059c1 | |||
| 1c39785b72 | |||
| 49cddac0dd | |||
| 8cab390aa7 | |||
| 1576d7b709 | |||
| db5109e41d | |||
| 21c68521ff | |||
| 5f69f38dfd | |||
| 41670a1cc6 | |||
| 295eb9911f | |||
| e9cae5fdcd | |||
| 261bd9fd31 | |||
| 16be01915d | |||
| 94cce8b615 | |||
| 511fd30cc6 | |||
| 0e4cc03c2d | |||
| 8f8d97b3d8 | |||
| da36f4ba56 | |||
| 6572591181 | |||
| 83011abb16 | |||
| 04997e48aa | |||
| 842d711ed6 | |||
| b3336ab0c3 | |||
| 6b2c6ced1b | |||
| 078a27a096 | |||
| c0fcf4c4f7 | |||
| 4ba02212eb | |||
| dc214e1426 | |||
| a75bc03ab5 | |||
| 599f05be22 | |||
| b9f5df5d28 | |||
| 0f0158508a | |||
| 7e15f49ff3 | |||
| ed997a76a2 | |||
| 593c5d0c84 | |||
| 89b6132c1f | |||
| 967d29e633 | |||
| 1668842d61 | |||
| a5832788e9 | |||
| 2f70230646 | |||
| bf2a5c21b0 | |||
| 6daa01aea4 | |||
| 11d600572b | |||
| cbb4fdb893 | |||
| e94ce92c9f | |||
| 155bd5e1e8 | |||
| 02c42dea94 | |||
| f50e74c63b | |||
| eb2c454aed | |||
| fec0052865 | |||
| 11b4b2af70 | |||
| ec77c899e5 | |||
| ead8cc3a50 | |||
| 463d7be135 | |||
| 15111eaec3 | |||
| 4974321f2d | |||
| f10869f048 | |||
| 5e0c702369 | |||
| aa9f4cbfe4 | |||
| a583c36a88 | |||
| 97049a4bf0 | |||
| 94b0d79026 | |||
| 8f7c141a0b | |||
| 87615b2e99 | |||
| 6b72b7c64f | |||
| 03721aafc4 | |||
| 859be6975c | |||
| 1166ab1c2e | |||
| e6d3e5a1be | |||
| 105422cfef | |||
| 69d70af4cf | |||
| 00f9958cbb | |||
| 2b4c0e97c2 | |||
| e50d2b5cf0 | |||
| c92d503851 | |||
| dc9ae01f50 | |||
| 9163a92d3b | |||
| 343305b433 | |||
| 103e26e111 | |||
| 403e2e558d | |||
| 4cf2df4770 | |||
| b5390bb430 | |||
| 2fd8a86490 | |||
| 6fb9cd3a93 | |||
| 10ad8aa9b8 | |||
| a64db43fe4 | |||
| a275539c17 | |||
| 2e608118ce | |||
| 13001f2ae4 | |||
| 728d60675f | |||
| 0ae9c10f24 | |||
| a10d24c09c | |||
| 4c4c1d2168 | |||
| c0d7bb9c8e | |||
| f5d524fdde | |||
| 251268992e | |||
| c457da0210 | |||
| 0054ae629a | |||
| 4c74cfa825 | |||
| b7c0889cc7 | |||
| 26a97e99f9 | |||
| c121eb9773 | |||
| 13f784430d | |||
| 95beaab313 | |||
| 8bac9869c7 | |||
| d7762b339d | |||
| 8ae6a6e715 | |||
| 31ca4a1faa | |||
| 475885d621 | |||
| d286b69a2d | |||
| f1d0b5c2d9 | |||
| b1494eb10c | |||
| 48a8ef0564 | |||
| 1d39571114 | |||
| 9a231831bc | |||
| 563737f39c | |||
| c90380bf9c | |||
| b24b2b5f31 | |||
| ac9ca33f0f | |||
| 41c277a8ae | |||
| b1280e3cd1 | |||
| 496b4dc5e2 | |||
| 8ff2743374 | |||
| b623a406ad | |||
| 52e28885cb | |||
| 45086cf5f9 | |||
| 1d4b6a17ce | |||
| e7136cbea3 | |||
| 0f8b88337e | |||
| 7d9db33a25 | |||
| 77779d49d6 | |||
| 0f7b5a87fb | |||
| 7635480e06 | |||
| 50e5877882 | |||
| 68cbf8ea75 | |||
| 17ece864d1 | |||
| ac51ff2154 | |||
| a92baa269e | |||
| 26a236a597 | |||
| e2d3f5114e | |||
| 737bffc9b5 | |||
| 4c05b58f3a | |||
| ceb753bd44 | |||
| 1a624b1fae | |||
| 9d90e00d52 | |||
| f6e9106f0d | |||
| a21f3d391f | |||
| 621421f3ca | |||
| e771a8ea21 | |||
| 3e03375c88 | |||
| 741564fa28 | |||
| c23b21eed8 | |||
| 6a904c1bee | |||
| 66f862e770 | |||
| 2d16fdddc0 | |||
| 4777ed2373 | |||
| 138aebc6d6 | |||
| 2808ea9fb8 | |||
| 7099c56c34 | |||
| 498cc60b9d | |||
| dea75914f5 | |||
| fa80b17d5f | |||
| a8de47ca3f | |||
| 3dc7f4a088 | |||
| 345e12ebd7 | |||
| 4332142680 | |||
| 9bd7ff96d3 | |||
| 45393be3d5 | |||
| 8ee3be4c97 | |||
| 67c469fa40 | |||
| a6d6588257 | |||
| 4380098cc1 | |||
| 30ebbe0c26 | |||
| 4cba2eae02 | |||
| 3965eb3f28 | |||
| 42b47aa5ac | |||
| 7bbcc9cf6f | |||
| 8c72698666 | |||
| 0cbae7dab2 | |||
| 8c47005fe9 | |||
| acc547efaf | |||
| cc3595a1c4 | |||
| efc3ed1c61 | |||
| 6f18f8d811 | |||
| c89c8effd4 | |||
| adeef7d21b | |||
| 969fc5c132 | |||
| 9c2653c6ef | |||
| 62987b4596 | |||
| a3c6e89378 | |||
| b746a1ff00 | |||
| 48bc50e794 | |||
| 98664a11ed | |||
| 201d713226 | |||
| 8cbc7bc6b6 | |||
| 042e85820c | |||
| 6f99dea3a9 | |||
| 484d068250 | |||
| 13ef0715bb | |||
| db9f256fd0 | |||
| bd7f15fc3e | |||
| 73f9e10492 | |||
| 8c28f3bfdd | |||
| 54dd019950 | |||
| d07a9a4d67 | |||
| a28123115d | |||
| 5f1057ac74 | |||
| 383b4500d0 | |||
| a9ba8ec745 | |||
| 59da40bb4a | |||
| 891ca81995 | |||
| ffd462ef3c | |||
| c4edf352fe | |||
| adabb6af12 | |||
| 16917bf165 | |||
| 317e04219d | |||
| d89c07c40c | |||
| c1b425dc53 | |||
| f711130b4c | |||
| a5f7385cad | |||
| d39e5c9ab5 | |||
| 3528644e9d | |||
| 7c4628b043 | |||
| e1967cf2ea | |||
| ba9450c50b | |||
| e25e51c378 | |||
| d9ab9552c3 | |||
| b802651a56 | |||
| a3f0c60aef | |||
| 2e0f66a07e | |||
| 65c5e7d310 | |||
| 231dc81239 | |||
| 411594bf67 | |||
| 658fbe7a72 | |||
| 56893709fe | |||
| 15aa08552c | |||
| 1e6996ecd0 | |||
| 7048e9cb9a | |||
| 92b8bf9bcf | |||
| ef688485ad | |||
| ea7d0170e2 | |||
| 42886581ef | |||
| 9c2e241978 | |||
| 4a09d4c63f | |||
| 924ac3d428 | |||
| 5a806d8a26 | |||
| 794906cdc8 | |||
| 4ac869058d | |||
| c4ca292102 | |||
| e3276368b2 | |||
| 2d7c4fd40b | |||
| 744a1cb086 | |||
| 2c3939494d | |||
| ec2dbc9e01 | |||
| c8de948089 | |||
| 957ec64645 | |||
| a0c261b5ee | |||
| 8aaf9bbb0e | |||
| 59e6d77224 | |||
| b4441f0e1e | |||
| 0a3b932395 | |||
| fa4cde36f6 | |||
| e307213745 | |||
| 5224f21622 | |||
| 658bc40ccc | |||
| 6bdf063f50 | |||
| b1291f1308 | |||
| e70b861215 | |||
| 6505cef9df | |||
| 2bfa09ed15 | |||
| 49a257b34c | |||
| c0ccd777ae | |||
| 70dd6f8192 | |||
| 1e4f74ba14 | |||
| b145e74ded | |||
| b22779d244 | |||
| 6822726b2c | |||
| 871ace422b | |||
| dc44c1e54e | |||
| 3488617bc9 | |||
| 2575177e81 | |||
| d9b887c0af | |||
| cc2966319a | |||
| 78248a1cdf | |||
| 8d3349c587 | |||
| 23daa3d9ad | |||
| 8b0ea17b71 | |||
| 4c21cff753 | |||
| af5bad5609 | |||
| 15d01d4dc1 | |||
| e7f28502bc | |||
| 6ede1443e9 | |||
| 7e77af08a0 | |||
| 36a3c3449c | |||
| 67fbdd9897 | |||
| 538c44c1cc | |||
| 504db26698 | |||
| f4b9bd57d5 | |||
| d4867ddbe2 | |||
| af411866a8 | |||
| 125140a3c6 | |||
| 11c82c0d99 | |||
| ee25b10053 | |||
| a38f862fb9 | |||
| ebe2807ffd | |||
| 0a66e56770 | |||
| 790e153cd6 | |||
| 9f826dbb4d | |||
| 60a650c175 | |||
| 513becd61d | |||
| a26a9d6f52 | |||
| 3c69411409 | |||
| 4d18e3b60d | |||
| 514a514601 | |||
| a9fd5572a3 | |||
| fca414102e | |||
| 43fbfa140b | |||
| 52976bdec3 | |||
| 51604f7717 | |||
| 5a9988b557 | |||
| 64d8b052b1 | |||
| 0f0d002afc | |||
| 858434492c | |||
| d74cd030ea | |||
| 4b9a4f4e24 | |||
| bda7ab2b50 | |||
| 301c3e5183 | |||
| efeaccbb18 | |||
| fafe13f7ea | |||
| 64abefaa00 | |||
| 44dec5c49b | |||
| dafc18f5ed | |||
| 9d06798f2d | |||
| 118ea4f005 | |||
| db16694a87 | |||
| 720f841494 | |||
| 8739faa7fe | |||
| ff9fcbd43c | |||
| 172b3f4a20 | |||
| 0cece5a305 | |||
| a7f526569a | |||
| 506eccc68f | |||
| 803f7717ed | |||
| 617c84f570 | |||
| 1e568b8549 | |||
| d770e36c7c | |||
| cea2b51efb | |||
| 4f8e298394 | |||
| 2a6080827d | |||
| dd51221ebc | |||
| 63cf73b4d0 | |||
| 0edbfaf08b | |||
| e95d2be30b | |||
| f849c79b7a | |||
| df13dec479 | |||
| a12367981d | |||
| 1a35b3684c | |||
| 491dd031f1 | |||
| 51472199b4 | |||
| 925058a5ae | |||
| 74ba7ea61b | |||
| b445cbb464 | |||
| c7459b393f | |||
| 123d80afc7 | |||
| f51c0833ae | |||
| 6394853fef | |||
| 83a70721bd | |||
| f7f17931c9 | |||
| 33405a57b5 | |||
| d346e4ee84 | |||
| 8f902e09fd | |||
| 13a4a40a14 | |||
| d96113bcc4 | |||
| eac36526e7 | |||
| bfbe24e423 | |||
| b3ef7289a0 | |||
| d5bdc6eb54 | |||
| b8c6a3dab0 | |||
| 4c0a0af9f8 | |||
| ae17d36f5a | |||
| b8d87c62d7 | |||
| 9ac9879527 | |||
| a0b9d27821 |
@@ -1,20 +1,21 @@
|
|||||||
{
|
{
|
||||||
"snapshot": {
|
"snapshot": {
|
||||||
"iwdg-defer-start": "ca839ffc1cda439f",
|
"iwdg-defer-start": "ca839ffc1cda439f",
|
||||||
"hardfault-handler": "f3afab8d2be18794",
|
"hardfault-handler": "a7f538eccdf1d88d",
|
||||||
"fault-handlers-3": "f3afab8d2be18794",
|
"fault-handlers-3": "a7f538eccdf1d88d",
|
||||||
"fault-handler-enable": "be87fcd3a7926138",
|
"fault-handler-enable": "be87fcd3a7926138",
|
||||||
"hardfault-report-impl": "f3afab8d2be18794",
|
"hardfault-report-impl": "a7f538eccdf1d88d",
|
||||||
"error-handler-degrade": "d02a92afda945c4b",
|
"error-handler-degrade": "d02a92afda945c4b",
|
||||||
"stack-overflow-check": "d4735e3a265e16ee",
|
"stack-overflow-check": "d4735e3a265e16ee",
|
||||||
"malloc-failed-hook": "6b86b273ff34fce1",
|
"malloc-failed-hook": "6b86b273ff34fce1",
|
||||||
"assert-not-deadloop": "d4a00718ab2bd071",
|
"assert-not-deadloop": "d4a00718ab2bd071",
|
||||||
"assert-impl": "5d862ceb21c77a86",
|
"assert-impl": "0ccf10a7fb115aa8",
|
||||||
"stack-overflow-hook-impl": "a006edbd30d2c4d7",
|
"stack-overflow-hook-impl": "03a383dda578d12a",
|
||||||
"malloc-failed-hook-impl": "fcd7250e5cd6403b",
|
"malloc-failed-hook-impl": "fcd7250e5cd6403b",
|
||||||
"stack-overflow-hook-helper": "cacd9fd028be9b5b",
|
"stack-overflow-hook-helper": "cacd9fd028be9b5b",
|
||||||
"watchdog-module": "663f05caf22cf7d0",
|
"watchdog-module": "5fd7efd90a78fe5a",
|
||||||
"reset-cause-latch": "52fc71acbb0f1aed",
|
"net-msgq-adopt": "a0f913e3f34c8e9e",
|
||||||
|
"reset-cause-latch": "983927d587cb9f48",
|
||||||
"iwdg-hal-driver": "4b902875a84554e9",
|
"iwdg-hal-driver": "4b902875a84554e9",
|
||||||
"hal-iwdg-enabled": "1a8ed6cff34f7ba6",
|
"hal-iwdg-enabled": "1a8ed6cff34f7ba6",
|
||||||
"ioc-keepusercode": "b5bea41b6c623f7c",
|
"ioc-keepusercode": "b5bea41b6c623f7c",
|
||||||
@@ -22,11 +23,11 @@
|
|||||||
"ioc-task-stacks": "58ee94dd2a093177",
|
"ioc-task-stacks": "58ee94dd2a093177",
|
||||||
"adc1-hal-enabled": "1a8ed6cff34f7ba6",
|
"adc1-hal-enabled": "1a8ed6cff34f7ba6",
|
||||||
"adc1-calibration-call": "8d5f4b40fb218913",
|
"adc1-calibration-call": "8d5f4b40fb218913",
|
||||||
"adc1-calibration-impl": "d33bc4323e243248",
|
"adc1-calibration-impl": "25723dd0449ac701",
|
||||||
"adc-tick-tim3-branch": "39c3548c37f6c4ae",
|
"adc-tick-tim3-branch": "17d43a565f50c6f2",
|
||||||
"ioc-adc-trig-t3trgo": "a9939de260ee46f8",
|
"ioc-adc-trig-t3trgo": "a9939de260ee46f8",
|
||||||
"ioc-adc-dma-continuous": "189126670d85d80a",
|
"ioc-adc-dma-continuous": "189126670d85d80a",
|
||||||
"adc1-dma-link": "397fce92dca3b728",
|
"adc1-dma-link": "8a57c70611db1a02",
|
||||||
"adc1-dma-irq-priority": "beff08e91333229b"
|
"adc1-dma-irq-priority": "beff08e91333229b"
|
||||||
},
|
},
|
||||||
"_note": "CubeMX 存活审计基线;改动用 --write 重建"
|
"_note": "CubeMX 存活审计基线;改动用 --write 重建"
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ whenToUse: 要跑板级测试套件(CH395F/STORAGE/GD5F/RTC)回归、要开/
|
|||||||
实测 GD5F 整轮破坏性跑一次即多记一个 NAND 坏块(容量 95248→95200),
|
实测 GD5F 整轮破坏性跑一次即多记一个 NAND 坏块(容量 95248→95200),
|
||||||
计数持久化在 dhara journal checkpoint 头里,普通格式化恢复不回来。
|
计数持久化在 dhara journal checkpoint 头里,普通格式化恢复不回来。
|
||||||
- 提交门禁:pre-commit 钩子 `test-suite-switch` 默认拦截测试构建
|
- 提交门禁:pre-commit 钩子 `test-suite-switch` 默认拦截测试构建
|
||||||
(确需提交用 `ALLOW_TEST_BUILD=1` 显式放行)——**跑完务必改回全注释并 `@build`**。
|
(确需提交用 `ALLOW_TEST_BUILD=1` 显式放行)——**跑完务必改回全注释并 `/keil-build`**。
|
||||||
|
|
||||||
## 一键回归(推荐路径,勿手工循环)
|
## 一键回归(推荐路径,勿手工循环)
|
||||||
|
|
||||||
@@ -28,15 +28,15 @@ whenToUse: 要跑板级测试套件(CH395F/STORAGE/GD5F/RTC)回归、要开/
|
|||||||
- 脚本 try/finally **无条件还原 `test_config.h`**(含 Ctrl-C 路径)——这是它存在的核心
|
- 脚本 try/finally **无条件还原 `test_config.h`**(含 Ctrl-C 路径)——这是它存在的核心
|
||||||
理由:跑完忘还原 = 测试固件进库。`--keep-config` 仅调试用。
|
理由:跑完忘还原 = 测试固件进库。`--keep-config` 仅调试用。
|
||||||
- 退出码 0=全 PASS / 1=有 FAIL / 2=环境错误;4 套件真机一轮数分钟起,**仅回归窗口用,绝不挂 pre-commit**。
|
- 退出码 0=全 PASS / 1=有 FAIL / 2=环境错误;4 套件真机一轮数分钟起,**仅回归窗口用,绝不挂 pre-commit**。
|
||||||
- 手工循环时的顺序(每轮):改宏(只开一个)→ `@build` 0错0警 → 烧录 →
|
- 手工循环时的顺序(每轮):改宏(只开一个)→ `/keil-build` 0错0警 → 烧录 →
|
||||||
`serial-assistant` 采集(open→clear→烧录→wait-idle)→ 判读套件汇总行。
|
`serial-assistant` 采集(open→clear→烧录→wait-idle)→ 判读套件汇总行。
|
||||||
|
|
||||||
## 跑后检查清单(必做)
|
## 跑后检查清单(必做)
|
||||||
|
|
||||||
1. `test_config.h` 恢复全注释(用 run_all_suites.py 则已自动);
|
1. `test_config.h` 恢复全注释(用 run_all_suites.py 则已自动);
|
||||||
2. `@build` 确认产品构建 0 错 0 警;
|
2. `/keil-build` 确认产品构建 0 错 0 警;
|
||||||
3. 若开过破坏性开关:核对开机日志容量数字(健康满值 96064 pages / 187 MB),
|
3. 若开过破坏性开关:核对开机日志容量数字(健康满值 96064 pages / 187 MB),
|
||||||
掉了 → 按 `docs/测试说明/存储维护与容量恢复.md` 走恢复动作;
|
掉了 → 按 `docs/测试说明/专项验证/存储维护与容量恢复.md` 走恢复动作;
|
||||||
4. 提交前 `test-suite-switch` 门禁会再兜一道。
|
4. 提交前 `test-suite-switch` 门禁会再兜一道。
|
||||||
|
|
||||||
## 三个维护动作别混用(存储类)
|
## 三个维护动作别混用(存储类)
|
||||||
@@ -47,8 +47,8 @@ whenToUse: 要跑板级测试套件(CH395F/STORAGE/GD5F/RTC)回归、要开/
|
|||||||
| B 容量恢复 | A + `STORAGE_RECOVERY_FULL_WIPE`(擦净 2044 块 + 断电重启) | 卷全失,约 4s 擦除 |
|
| B 容量恢复 | A + `STORAGE_RECOVERY_FULL_WIPE`(擦净 2044 块 + 断电重启) | 卷全失,约 4s 擦除 |
|
||||||
| C 日常回归 | 只开 `TEST_SUITE_*`,不开破坏性 | 无(只读模式,横幅 `mode: READ-ONLY`) |
|
| C 日常回归 | 只开 `TEST_SUITE_*`,不开破坏性 | 无(只读模式,横幅 `mode: READ-ONLY`) |
|
||||||
|
|
||||||
原理与完整步骤见 `docs/测试说明/存储维护与容量恢复.md`;各套件用例清单/判读见
|
原理与完整步骤见 `docs/测试说明/专项验证/存储维护与容量恢复.md`;各套件用例清单/判读见
|
||||||
`docs/测试说明/` 对应 `*_Test_Guide.md`;流程规范 `docs/测试说明/测试开发标准流程.md` §3。
|
`docs/测试说明/` 对应 `*_Test_Guide.md`;流程规范 `docs/测试说明/流程与规范/测试开发标准流程.md` §3。
|
||||||
|
|
||||||
## 其它项目怎么套
|
## 其它项目怎么套
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: board-ui-nav
|
||||||
|
description: 板端 UI 交互的唯一正确入口:先问固件"我在哪"(STATUS 身份查询),再决定按键;到页用闭环 --to 或 bench 直开 --goto。附 #69 实证的四类走错页根因与恢复配方。
|
||||||
|
whenToUse: 需要上真机/台架进某页面看图(如故障录波、回放、定值)、在板端做 UI 取证、或从错误页面/待机态/未知屏恢复时。**写任何注键/取帧代码之前先读本技能**。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 板端 UI 导航(唯一入口 = scripts/board_ui_nav.py)
|
||||||
|
|
||||||
|
一句话纪律:**一切"进页面看图"都走 `scripts/board_ui_nav.py`,禁止另写一次性注键脚本。**
|
||||||
|
2026-11-02 #38 调试里"总是进错误页面浪费大量时间"的直接原因,就是临时手搓
|
||||||
|
`board_walk*.py` 盲按 `d*9`——而闭环导航器 2026-10-25 起就已存在(10-25 盲走事故
|
||||||
|
9 个目标只有 3 个落对,`docs/UI设计/UI_自动化测试流程.md` §7.1)。
|
||||||
|
|
||||||
|
## 为什么不能自己搓(#69 复盘四类根因,全部有实证)
|
||||||
|
|
||||||
|
1. **协议旧版零身份反馈**:KEY 的 ACK 是空帧,"发了 5 个键"≠"到了第 5 层"。(已修:
|
||||||
|
7003 新增 STATUS 命令,固件直读 `s_depth/s_path/s_sel` + 当前内容页 id,见下)
|
||||||
|
2. **N−1 屏坑**:KEY ACK 与 LCDMEM 推流共用同一条 TCP,`sleep(0.30)` 就抓帧拿到的是
|
||||||
|
上一屏(规则①:排空到帧流静默 ≥900 ms;现在 `key()` 代码已真正执行排空+双帧比对)。
|
||||||
|
3. **物理陷阱**:待机态按 `e` 是**重开最近页**不是进菜单;级联列光标记忆上次行不归零;
|
||||||
|
根列最后两行的子列弹框被 160×160 屏裁掉(selftest/runlog 因此"读不到选带")。
|
||||||
|
4. **认屏误判**:菜单子列标签与页标题同字(记录查询子列里的 事故记录/操作记录…),
|
||||||
|
像素分类器会把级联菜单判成 PAGE——**有 STATUS 时以 STATUS 为准**,trace 留痕不一致。
|
||||||
|
|
||||||
|
## 命令(python 一律 .venv;真机动作必须已持 bench 锁并加 --i-hold-bench-lock)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 我在哪?(固件身份 + 像素双口径,不一致会留痕打印)
|
||||||
|
.venv\Scripts\python.exe scripts\board_ui_nav.py --where --i-hold-bench-lock
|
||||||
|
|
||||||
|
# 到页:闭环步行(像素态设备/老固件也能用;每键重认屏,绝不猜步数)
|
||||||
|
.venv\Scripts\python.exe scripts\board_ui_nav.py --board --to wave_play --grab C:\temp\wx --i-hold-bench-lock
|
||||||
|
|
||||||
|
# 到页:bench 固件直开页(一次往返直达,不注键;见下"直开页的边界")
|
||||||
|
.venv\Scripts\python.exe scripts\board_ui_nav.py --goto wave_play --grab C:\temp\wx --i-hold-bench-lock
|
||||||
|
|
||||||
|
# 提交门禁快检(无台架、无模拟器产物也要绿)
|
||||||
|
.venv\Scripts\python.exe scripts\board_ui_nav.py --selftest-quick
|
||||||
|
```
|
||||||
|
|
||||||
|
`--list` 列出全部合法页 id 与菜单路径;`--trace 路径` 落步迹 jsonl(排错先看它)。
|
||||||
|
退出码:0 成功 / 1 分类或导航失败 / 2 环境错误 / 3 拒动(未持锁确认位等)。
|
||||||
|
|
||||||
|
## 身份回读与直开页(#69 新增,两端共享 ui_net_sink.h 契约)
|
||||||
|
|
||||||
|
- **STATUS(命令 4)**:应答 `[ver][kind][depth][n][id_len][path][sel][page id]`;
|
||||||
|
kind = MENU/PAGE/STANDBY(待机优先判)。老固件不应答 → 主机打印一次后自动退回纯像素。
|
||||||
|
生产固件也支持 STATUS(只读、零画面副作用)。
|
||||||
|
- **GOTO(命令 5)**:**编译期双闸** `DTU_BENCH`(临时 bench 固件)或模拟器宏
|
||||||
|
`UI_REMO_ALLOW_GOTO`;生产固件恒拒 `status=4`。拒绝码:1 无此页 id / 2 执行类页 /
|
||||||
|
3 开页失败 / 5 待机态(先按键唤醒再来)。直开按查看权限 `UI_PERM_VIEW` 进入,
|
||||||
|
**不弹密码**——所以它只用于调试取证,不能当作"过密码"的旁路写路径。
|
||||||
|
- 执行类页面 `clear`/`reset`(第二次 ENTER 即真执行)主机与固件**双侧**默认拒绝,
|
||||||
|
`--allow-exec-pages` 才放行,放行 = 接受后果。
|
||||||
|
|
||||||
|
## 走错页 / 未知屏恢复配方(按序,不跳步)
|
||||||
|
|
||||||
|
1. `--where` 先看 STATUS:`PAGE` → 按 `s` 关页(导航器 `--to` 自动做);
|
||||||
|
`STANDBY` → 按 `e` 唤醒(注意规则②:home 上 `e` 可能重开最近页);
|
||||||
|
`MENU depth>0` → 按 `s` 或 `l` 都收一级(台架复核 2026-11-02:菜单态 `s` 与 `l` 同走
|
||||||
|
`menu_back`,父列选中留在原地;旧说法"`s` 不收列"系把待机态 `s` 无效误读成菜单行为)。
|
||||||
|
2. STATUS 无应答(老固件)才吃退避键序(默认 `esl`,`--backoff-keys sl` 可去 ENTER);
|
||||||
|
ENTER 上限 1 次是硬闸(BACKOFF_MAX_E),报停机就 dump 帧人眼看,不许连按。
|
||||||
|
3. 反复落错页且 trace 里出现"像素与 STATUS 不一致" → 分类器或固件有账要记,
|
||||||
|
把留痕帧 `--dump-fail` 存下交 Lead,**不要**改判据迁就现状。
|
||||||
|
|
||||||
|
## 边界
|
||||||
|
|
||||||
|
- 本脚本不申请锁、不碰串口与烧录;锁的取还见 task-worktree 技能
|
||||||
|
(`lock acquire bench --task <ID>`)。
|
||||||
|
- 台架证据只进 `ci-artifacts/`(`--walk` 默认 `ci-artifacts/uinav/walk/`,与 BOARD_TRUTH
|
||||||
|
读取目录已并一),不进工作树。
|
||||||
|
- UI 源码改动的前后回归是 ui-verify 技能(5 门禁),本技能只管"人到板上去看"。
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
---
|
---
|
||||||
name: cubemx-regen
|
name: cubemx-regen
|
||||||
description: CubeMX 重生成存活工作流(L1 写进 .ioc → L2 USER CODE 区/宏改名 → L3 审计兜底):重生成前后各跑什么、哪些定制会被静默覆盖、审计脚本 check_cubemx_survival.py 的 --check/--write 语义。STM32CubeMX 项目通用方法论 + 本仓库清单。
|
description: CubeMX 重生成存活工作流(写进 .ioc → 搬进 USER CODE 区/宏改名 → 审计兜底三层):重生成前后各跑什么、哪些定制会被静默覆盖、审计脚本 check_cubemx_survival.py 的 --check/--write 语义。STM32CubeMX 项目通用方法论 + 本仓库清单。
|
||||||
whenToUse: 要用 CubeMX 重新生成工程(改 .ioc / 外设配置)、改动 Src/Inc 生成区附近代码、或排查"重生成后定制丢失/行为变了但编译不报错"时。
|
whenToUse: 要用 CubeMX 重新生成工程(改 .ioc / 外设配置)、改动 Src/Inc 生成区附近代码、或排查"重生成后定制丢失/行为变了但编译不报错"时。
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -12,12 +12,12 @@ whenToUse: 要用 CubeMX 重新生成工程(改 .ioc / 外设配置)、改
|
|||||||
|
|
||||||
## 三层优先级(防丢失的唯一正确顺序)
|
## 三层优先级(防丢失的唯一正确顺序)
|
||||||
|
|
||||||
1. **L1 写进 `.ioc`**(权限最高):凡 CubeMX 界面能表达的配置(任务栈/优先级/队列/
|
1. **写进 `.ioc`**(第一层,权限最高):凡 CubeMX 界面能表达的配置(任务栈/优先级/队列/
|
||||||
事件组/堆大小/IWDG 参数/FreeRTOS 钩子开关)必须在 GUI 里设——重生成只会照写。
|
事件组/堆大小/IWDG 参数/FreeRTOS 钩子开关)必须在 GUI 里设——重生成只会照写。
|
||||||
⚠ **别凭印象断言"界面里没有该项"**,先翻标签页或查 `db/mcu/IP/*_Modes.xml`
|
⚠ **别凭印象断言"界面里没有该项"**,先翻标签页或查 `db/mcu/IP/*_Modes.xml`
|
||||||
(实测 `configCHECK_FOR_STACK_OVERFLOW=2` 在 FreeRTOS → Config parameters →
|
(实测 `configCHECK_FOR_STACK_OVERFLOW=2` 在 FreeRTOS → Config parameters →
|
||||||
Hook function related definitions 里,自造宏区被抹两次后才找到)。
|
Hook function related definitions 里,自造宏区被抹两次后才找到)。
|
||||||
2. **L2 搬进 `USER CODE` 区**:只信 CubeMX **当前模板真实生成**的区名(优先
|
2. **搬进 `USER CODE` 区**(第二层):只信 CubeMX **当前模板真实生成**的区名(优先
|
||||||
`USER CODE BEGIN 0` 这类从未变过的标准位)。**不要依赖"自造标记区"**——
|
`USER CODE BEGIN 0` 这类从未变过的标准位)。**不要依赖"自造标记区"**——
|
||||||
模板换代时自造区连同内容一起消失(实测中断函数模板换代的教训)。
|
模板换代时自造区连同内容一起消失(实测中断函数模板换代的教训)。
|
||||||
生成区内要**去掉一句调用** → 宏改名:`USER CODE BEGIN 0` 里放空实现 +
|
生成区内要**去掉一句调用** → 宏改名:`USER CODE BEGIN 0` 里放空实现 +
|
||||||
@@ -26,12 +26,12 @@ whenToUse: 要用 CubeMX 重新生成工程(改 .ioc / 外设配置)、改
|
|||||||
要**替换生成实体定义**(如 4 个 Fault Handler 本体)→ 我们的定义放
|
要**替换生成实体定义**(如 4 个 Fault Handler 本体)→ 我们的定义放
|
||||||
`USER CODE BEGIN 0`,**必须写在 `#define X X_Gen` 之前**(写在之后会被自己的宏改名),
|
`USER CODE BEGIN 0`,**必须写在 `#define X X_Gen` 之前**(写在之后会被自己的宏改名),
|
||||||
生成区恢复原样,链接器自动移除未引用的 `*_Gen`。
|
生成区恢复原样,链接器自动移除未引用的 `*_Gen`。
|
||||||
3. **L3 审计兜底**:清单 + 哈希基线漂移检测(见下)。
|
3. **审计兜底**(第三层):清单 + 哈希基线漂移检测(见下)。
|
||||||
|
|
||||||
## 重生成前后固定动作
|
## 重生成前后固定动作
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# 重生成前:把能在 GUI 表达的都写进 .ioc(L1 检查)
|
# 重生成前:把能在 GUI 表达的都写进 .ioc(第一层检查)
|
||||||
# 重生成后【立刻】:
|
# 重生成后【立刻】:
|
||||||
& '.venv\Scripts\python.exe' scripts/check_cubemx_survival.py # 人读报告
|
& '.venv\Scripts\python.exe' scripts/check_cubemx_survival.py # 人读报告
|
||||||
& '.venv\Scripts\python.exe' scripts/check_cubemx_survival.py --check # 门禁模式(pre-commit 同款)
|
& '.venv\Scripts\python.exe' scripts/check_cubemx_survival.py --check # 门禁模式(pre-commit 同款)
|
||||||
@@ -41,21 +41,21 @@ whenToUse: 要用 CubeMX 重新生成工程(改 .ioc / 外设配置)、改
|
|||||||
|
|
||||||
- 退出码 0=通过 / 1=有 FAIL 项;已挂 pre-commit `cubemx-survival` 钩子
|
- 退出码 0=通过 / 1=有 FAIL 项;已挂 pre-commit `cubemx-survival` 钩子
|
||||||
(改 `.ioc`/`Src/`/`Inc/`/`.uvprojx` 触发)。
|
(改 `.ioc`/`Src/`/`Inc/`/`.uvprojx` 触发)。
|
||||||
- **L3 清单已清空 = 重生成应当零 diff**;仍有 diff 就是被覆盖了,直接跑脚本定位。
|
- **审计清单已清空 = 重生成应当零 diff**;仍有 diff 就是被覆盖了,直接跑脚本定位。
|
||||||
- `uvprojx` 不参与漂移检测(每次构建被 UV4 改写),只保留"源文件仍挂载"断言。
|
- `uvprojx` 不参与漂移检测(每次构建被 UV4 改写),只保留"源文件仍挂载"断言。
|
||||||
|
|
||||||
## 实测陷阱清单
|
## 实测陷阱清单
|
||||||
|
|
||||||
- CubeMX 对 System Exception(HardFault/MemManage/BusFault/UsageFault)**只生成优先级、
|
- CubeMX 对 System Exception(HardFault/MemManage/BusFault/UsageFault)**只生成优先级、
|
||||||
从不生成使能代码**;`.ioc` 里 `NVIC.MemoryManagement_IRQn=true` ≠ 已使能(只是向量可见)。
|
从不生成使能代码**;`.ioc` 里 `NVIC.MemoryManagement_IRQn=true` ≠ 已使能(只是向量可见)。
|
||||||
`SHCSR` 使能位只能手工写(L2)+ 审计项兜底。
|
`SHCSR` 使能位只能手工写(第二层)+ 审计项兜底。
|
||||||
- 启用 FreeRTOS 后模板会生成 `USER CODE BEGIN 4 / 5` 区(默认空桩)——**钩子实现必须
|
- 启用 FreeRTOS 后模板会生成 `USER CODE BEGIN 4 / 5` 区(默认空桩)——**钩子实现必须
|
||||||
整体写进这两个区**,且 helper 函数放更早的区(同名两处定义 = 编译错)。
|
整体写进这两个区**,且 helper 函数放更早的区(同名两处定义 = 编译错)。
|
||||||
- 生成文件行尾/末尾空行按工具自身风格,**勿用改写型钩子去修剪**(已豁免,勿"顺手格式化")。
|
- 生成文件行尾/末尾空行按工具自身风格,**勿用改写型钩子去修剪**(已豁免,勿"顺手格式化")。
|
||||||
- 重生成后除审计脚本外,照常走 `@build`(0 错 0 警)+ 必要时 `@test` 闭环。
|
- 重生成后除审计脚本外,照常走 `/keil-build`(0 错 0 警)+ 必要时 `/serial-boot-test` 闭环。
|
||||||
|
|
||||||
## 其它 CubeMX 项目怎么套
|
## 其它 CubeMX 项目怎么套
|
||||||
|
|
||||||
L1/L2/L3 方法论与"宏改名两招"完全通用;本仓库的清单/基线在
|
这三层方法论与"宏改名两招"完全通用;本仓库的清单/基线在
|
||||||
`scripts/check_cubemx_survival.py`(21 项 + `.cubemx_survival_baseline.json`),
|
`scripts/check_cubemx_survival.py`(21 项 + `.cubemx_survival_baseline.json`),
|
||||||
新项目照它建自己的清单。方法论详见 `docs/子系统设计/看门狗设计说明.md` §10.1/§10.2。
|
新项目照它建自己的清单。方法论详见 `docs/子系统设计/看门狗设计说明.md` §10.1/§10.2。
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
---
|
---
|
||||||
name: draw-diagram
|
name: draw-diagram
|
||||||
description: docs/ 文档技术配图绘制流程(精确布局图用 SVG 生成器 / 拓扑图用 Mermaid):挑脚本 → 改顶部数据 → 重跑生成 → 目检 → md 相对路径引用 → 脚本+产物一并提交,产物统一放 docs/images/。
|
description: docs/ 文档技术配图绘制流程(精确布局图用 tools/arch_diagram/ 的纯标准库 SVG 生成器 / 拓扑图用 Mermaid):挑脚本或照抄骨架新建生成器 → 改顶部数据 → 跑生成并把 check_fit/OVERFLOW 溢出预检清零 → Windows 无渲染器时用 headless Chrome/Edge 截图 + read_image 回读目检 → 回改生成器重跑 → 过 generated-fresh 逐字节幂等 → md 相对路径引用 → 脚本+产物一并提交,产物统一放 docs/images/。
|
||||||
whenToUse: 需要新增/修改 docs/ 文档的技术配图(协议帧、分层架构、依赖/就绪屏障、流程图、时序、状态机),或用户要求"画个图 / 生成图 / 更新架构图 / 帧格式图"时。
|
whenToUse: 需要新增/修改 docs/ 文档的技术配图(协议帧、分层架构、依赖/就绪屏障、台阶/泳道对照、刻度尺、流程图、时序、状态机),从零新写一个 gen_*_svg.py 生成器,在 Windows 上想"目检"一张 SVG,或用户要求"画个图 / 生成图 / 更新架构图 / 帧格式图"时。
|
||||||
---
|
---
|
||||||
|
|
||||||
# 文档配图绘制(SVG / Mermaid)
|
# 文档配图绘制(SVG / Mermaid)
|
||||||
|
|
||||||
`docs/` 文档技术配图的统一流程;完整选型依据 / 约定 / 检查清单见 `docs/架构设计/图形绘制流程.md`。**产物统一放 `docs/images/`**(专门配图目录,`.svg` 入库;PNG 预览属本地派生物、不入库)。
|
`docs/` 文档技术配图的统一流程;完整选型依据 / 约定 / 检查清单见 `docs/架构设计/图形绘制流程.md`。**产物统一放 `docs/images/`**(专门配图目录,`.svg` 入库;PNG 预览属本地派生物、不入库,`.gitignore` 已含 `docs/images/*.png` 与 `ci-artifacts/`)。
|
||||||
|
|
||||||
|
> 本窗实测(2026-10-29,新建 2 张图):**"浏览器打开 SVG 目检"这一步在 Windows 上不可直接执行**——没有现成 SVG 栅格化工具,只能走 headless Chrome/Edge 截图 + 图片回读;且 `generated-fresh` 是**逐字节**门禁,生成器自己的控制台打印也会成为失败源(见 §五)。
|
||||||
|
|
||||||
## 工具选型
|
## 工具选型
|
||||||
|
|
||||||
@@ -15,15 +17,15 @@ whenToUse: 需要新增/修改 docs/ 文档的技术配图(协议帧、分层
|
|||||||
| 协议帧 / 字节布局(字段宽 ∝ 字节数、偏移标尺) | SVG:`tools/arch_diagram/gen_frame_svg.py` |
|
| 协议帧 / 字节布局(字段宽 ∝ 字节数、偏移标尺) | SVG:`tools/arch_diagram/gen_frame_svg.py` |
|
||||||
| 分层架构 / 模块依赖 | SVG:`tools/arch_diagram/gen_arch_svg.py` |
|
| 分层架构 / 模块依赖 | SVG:`tools/arch_diagram/gen_arch_svg.py` |
|
||||||
| 就绪屏障 / 生产者-消费者 | SVG:`tools/arch_diagram/gen_barrier_svg.py` |
|
| 就绪屏障 / 生产者-消费者 | SVG:`tools/arch_diagram/gen_barrier_svg.py` |
|
||||||
|
| 台阶 / 泳道对照 / 刻度尺等"高度差、并列对照"类 | SVG:**新写一个** `tools/arch_diagram/gen_<主题>_svg.py`(照抄最近生成器的骨架,见 §二) |
|
||||||
| 其他精确布局图 | **复制**最接近的生成器改顶部数据(`build()` 与数据解耦,通用) |
|
| 其他精确布局图 | **复制**最接近的生成器改顶部数据(`build()` 与数据解耦,通用) |
|
||||||
| 流程 / 时序 / 状态机 / ER(只表达拓扑关系) | Markdown 内嵌 ` ```mermaid ` 代码块(`flowchart` / `sequenceDiagram` / `stateDiagram-v2` / `erDiagram`),无需生成图片 |
|
| 流程 / 时序 / 状态机 / ER(只表达拓扑关系) | Markdown 内嵌 ```mermaid 代码块(`flowchart` / `sequenceDiagram` / `stateDiagram-v2` / `erDiagram`),无需生成图片 |
|
||||||
|
|
||||||
> Mermaid 盒子按文本自适应宽、自动加有向边,**只适合"关系"类图**;凡需精确像素 / 比例的布局图一律走 SVG 生成器。
|
> Mermaid 盒子按文本自适应宽、自动加有向边,**只适合"关系"类图**;凡需精确像素 / 比例 / 高度差的布局图一律走 SVG 生成器(实测教训:五级"台阶"用 Mermaid 会被压成同一高度,"还差一级"就看不出来了)。
|
||||||
|
|
||||||
## SVG 生成流程(仓库根目录执行)
|
## 一、改既有图(最短路线)
|
||||||
|
|
||||||
1. **挑 / 复制脚本**:新类型图复制最接近的生成器,只改顶部数据区。
|
1. 改**顶部数据 / 常量区**(字段、节点、标题、配色),不改 `build()` 结构。配色沿用既有调色板(主色 / 底色):
|
||||||
2. **改顶部数据**:字段 / 节点 / 标题 / 配色。沿用既有调色板(主色 / 底色):
|
|
||||||
|
|
||||||
| 语义 | 主色 | 底色 |
|
| 语义 | 主色 | 底色 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -35,38 +37,175 @@ whenToUse: 需要新增/修改 docs/ 文档的技术配图(协议帧、分层
|
|||||||
| 灰 | `#475569` | `#f1f5f9` |
|
| 灰 | `#475569` | `#f1f5f9` |
|
||||||
| 玫红 | `#e11d48` | `#fff1f2` |
|
| 玫红 | `#e11d48` | `#fff1f2` |
|
||||||
|
|
||||||
3. **重跑**(脚本内置 `ElementTree` 自检,XML 不合法直接抛错):
|
2. 重跑(仓库根;脚本内置 `ET.fromstring` 自检,XML 不合法直接抛错):
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
& '.venv\Scripts\python.exe' tools/arch_diagram/gen_xxx.py
|
& '.venv\Scripts\python.exe' tools/arch_diagram/gen_xxx.py
|
||||||
# 成功输出:OK <字节> bytes, canvas <W>x<H> -> <路径>
|
# 期望:OK <字节> bytes, canvas <W>x<H> -> <路径>,且没有 OVERFLOW WARN / overflow warnings 行
|
||||||
```
|
```
|
||||||
|
|
||||||
4. **目检**:浏览器打开 `docs/images/*.svg`——文字不溢出盒外、框 / 线 / 标签对齐、配色符合调色板。
|
3. 目检(§三)→ 引用(§四)→ 幂等与门禁(§五)→ 提交(§六)。
|
||||||
5. **md 引用**(相对路径):`docs/` 根的 md 写 ``;`docs/UI设计/` 等子目录 md 写 ``。
|
|
||||||
6. **提交**:生成器脚本(源)+ 生成的 `.svg`(产物)**一并**提交。
|
|
||||||
|
|
||||||
## SVG 约定(手写 / 新增生成器必须遵循)
|
## 二、从零新写生成器(先抄骨架,别裸写)
|
||||||
|
|
||||||
- **纯标准库**拼 SVG(只用 `html` / `os`(+ `math`)),不引第三方库(SVG 由浏览器用系统字体渲染)
|
1. **先读同目录 1~2 个最近的脚本**(如 `tools/arch_diagram/gen_adc_frontend_svg.py`、`gen_adc_code_scale_svg.py`),抄它们的三条约定:
|
||||||
- 所有文本经 `html.escape(s, quote=True)`,防止破坏 XML
|
- **文件头 docstring** 写清四件事:产物路径 + 这张图回答什么问题 + **「为什么不用 Mermaid」** + 用法("仓库根目录,任意 python3")与"改内容 vs 调坐标"的边界;
|
||||||
- 字体栈:`Microsoft YaHei, Segoe UI, PingFang SC, sans-serif`
|
- **顶部集中常量块**:画布与几何(`W` / `H` / `PAD` / `RIGHT`)→ `FONT` → 各级字号 → 调色板 → 文案(`TITLE` / `SUB*` / 逐条目 / `FOOTNOTE`);坐标一律由常量推导,别在 `build()` 里塞魔法数;
|
||||||
- 文本宽估算(自适应盒宽):CJK / 全角 / 圈码 / 箭头 ≈ `1.0em`,其余 ≈ `0.58em`(**宁大勿小**防溢出)
|
- **纯标准库**:`import html / os / sys / xml.etree.ElementTree as ET`,helpers 只有 `esc` / `text_w` / `txt` / `line` / `rect` / `wrap` / `check_fit` / `emit` / `build`。
|
||||||
- 落盘:`open(OUT, "w", encoding="utf-8", newline="\n")`(UTF-8 无 BOM + LF)
|
2. **照抄预检三件套**(`gen_adc_frontend_svg.py` 里还没有;从 `tools/arch_diagram/gen_data_trust_ladder_svg.py` 抄):`OVERFLOW = []` + `check_fit(s, fs, avail, where)` + `wrap(s, fs, avail, max_lines, where)`,标题 / 副标题 / 盒内标题走 `check_fit`,盒内正文与脚注走 `wrap`(`wrap` 按估宽折行、超行数用 `…` 收尾并记一条 OVERFLOW)。`build()` 末尾:`emit("OK ...")` 之后 `if OVERFLOW: emit("!! %d overflow warnings" % len(OVERFLOW))`。估宽口径不变:CJK / 全角 / 圈码 / 箭头 ≈ `1.0em`,其余 ≈ `0.58em`(宁大勿小)。
|
||||||
- 写盘后 `xml.etree.ElementTree.fromstring(svg)` 自检
|
3. **照抄 `emit()`**(幂等门禁的地基,见 §五):
|
||||||
- 产物放 `docs/images/`,命名 `<主题>.svg`(可与同名 `.md` 对应)
|
|
||||||
|
|
||||||
## SVG → PNG(可选,本地派生物不入库)
|
```python
|
||||||
|
def emit(s):
|
||||||
|
"""控制台打印按目标编码替换,绝不因 GBK 终端遇上 ²/− 之类字符而崩
|
||||||
|
(generated-fresh 钩子会把本脚本拷进临时目录重跑,崩了就是一片假红)。"""
|
||||||
|
enc = getattr(sys.stdout, "encoding", None) or "ascii"
|
||||||
|
sys.stdout.write(s.encode(enc, "replace").decode(enc, "replace") + "\n")
|
||||||
|
```
|
||||||
|
|
||||||
- 最简单:浏览器打开 `.svg` → 另存为图片
|
4. 路径锚在脚本自身位置,别用 cwd(临时目录重跑才不会漂):
|
||||||
- 脚本化:`& '.venv\Scripts\python.exe' -m pip install cairosvg` 后
|
|
||||||
`& '.venv\Scripts\python.exe' -c "import cairosvg; cairosvg.svg2png(url='docs/images/xxx.svg', write_to='x.png', scale=2)"`
|
|
||||||
- `.gitignore` 已含 `docs/images/*.png`
|
|
||||||
|
|
||||||
## 提交前检查清单
|
```python
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
OUT = os.path.join(ROOT, "docs", "images", "<主题>.svg")
|
||||||
|
```
|
||||||
|
|
||||||
- [ ] 生成器输出 `OK … bytes`(无异常 = XML 合法)
|
5. 跑 `& '.venv\Scripts\python.exe' tools/arch_diagram/gen_<主题>_svg.py`,**把 OVERFLOW 告警打到 0 再进目检**——预检能在渲染前就报出 `foot: 974 > 968` 这类溢出(本窗实测两张图首跑各报一条脚注超宽),比靠眼睛反复量便宜一个数量级。
|
||||||
- [ ] 浏览器目检:文字不溢出、框 / 线 / 标签对齐、配色符合调色板
|
6. 新脚本落库时**同批**把行加进 `tools/arch_diagram/README.md` 的「**产物清单**」——那张表按图逐行,列 =
|
||||||
- [ ] 引用它的 `.md` 里图片**相对路径**正确(能显示)
|
图 / 生成器 / 这张图答什么 / 顶部数据区 / 挂在(`文档:行号`)。**全仓只此一张逐脚本清单**:
|
||||||
- [ ] 脚本 + 产物 `.svg` **一并**暂存
|
`docs/架构设计/图形绘制流程.md` 曾另立一张三列表,结果两张各漏各的(2026-10-30 实测 README 漏 5 个、
|
||||||
- [ ] `pre-commit run --files <脚本> <svg> <md>` 全过(无乱码 / 行尾空白 / 编码 OK)
|
那张漏 9 个),现已换成指针。所以新图只加一行,别在第二篇文档再起一张表。
|
||||||
|
|
||||||
|
## 三b、大图(几十框几十边)加一道碰撞审计,别只靠眼睛
|
||||||
|
|
||||||
|
边数一多,像素回读就查不全(本窗实测:42 框 37 边的端到端链路图,缩略 PNG 看着干净,
|
||||||
|
审计器一眼报出 23 条:边标签压节点、走线穿框、标签出画布)。做法是**解析自己生成的 SVG**
|
||||||
|
而不是解析渲染结果——`ET.parse` 产物,按 `font-size`/`fill` 分诊文字,估宽口径直接复用生成器的
|
||||||
|
`text_w()`(两边同一把尺子才不会假绿),然后判三件事:
|
||||||
|
|
||||||
|
1. **边标签的「文字盒」vs 节点框**(不是白片盒):交集 > 6 px² 就算问题——节点在边之后画,
|
||||||
|
压上去的**文字**会被节点白底吃掉半个字(本窗症状:「串行执行」渲染成「行执行」)。
|
||||||
|
白片本身压到框边角是**无害**的(白片先画、节点后画,只是盖住一点白底,不改字)。
|
||||||
|
除「有没有压上」,还要打印**文字与最近框的最小水平余量**并按 < 3px 报问题:
|
||||||
|
本窗「TRGO 触发」余量 0.7px、「帧 B 下发」1.5px,都没压上却随时会被真实字宽吃笔画,
|
||||||
|
缩短标签后升到 3.5px 以上才算安全。
|
||||||
|
2. **走线段 vs 节点框**(内缩 2px 再判,排除贴边端点):线穿框是硬伤,改边的出入框方位或独占通道。
|
||||||
|
3. **标签互压 / 出画布**:白片两两求交;`box[3] > H-2` 查脚注贴底。
|
||||||
|
4. **零余量复扫(生成器侧,别只跑 SVG 侧那一遍)**:SVG 反推不出哪两个框是本边端点,
|
||||||
|
走线只能内缩着查 → 1~3px 的切边照不到。用生成器自己的 `Box` / `route()` / `label_at()`
|
||||||
|
真值再扫一遍:框不膨胀不内缩、只跳本边两端。跑 SVG 侧与真值侧两遍都归零才算收工。
|
||||||
|
|
||||||
|
脚本骨架与判据见 `reference/svg-collision-audit.py`(可直接拷进 `ci-artifacts/` 当一次性工具用,
|
||||||
|
**别放 `tools/arch_diagram/`**——那里只放生成器,非 `gen_*.py` 的脚本混进去会让人以为在册)。
|
||||||
|
|
||||||
|
配套的两条几何纪律(都是本窗踩出来的):
|
||||||
|
|
||||||
|
- **同轴直连不要插退化折点**:同行两框若按「同轴 → 加一段 gx 走廊」的通用规则生成,会产出
|
||||||
|
`[(195,176),(219,176),(219,176),(243,176)]` 这种点集——线还是直的,但**标签落点算成第一半的中点**,
|
||||||
|
整格偏移半个通道 → 压框。正解:出框轴与入框轴同轴且已对齐时直接返回两点线。
|
||||||
|
- **标签落点用弧长中点,不用「最长段中点」**:多段折线里最长段可能有多条(对称 Z 字),
|
||||||
|
取第一条会把标签推到拐角上;沿总弧长取 50% 再定位,配合 `seg`/`t` 旋钮微调。
|
||||||
|
|
||||||
|
**先验审计器本身,再信它的「0 条」**:本窗把 `hi > y0` 复制粘贴成 `hi > x0`,零余量那一遍
|
||||||
|
凭空报出 3 条「穿框」,差点为此重排三条边的走线。判据脚本的假阳性比漏报更贵——它会让你
|
||||||
|
去改一张本来没问题的图。规矩:写完后挑一条**已知干净**的边(例如两框正对的直连边)单独跑一遍,
|
||||||
|
确认它不报,再跑全量。
|
||||||
|
|
||||||
|
## 三、目检(Windows:本机没有任何现成 SVG 渲染器)
|
||||||
|
|
||||||
|
**先探测,别照抄 cairosvg 命令**:本仓 `.venv` 无 `cairosvg`(`ModuleNotFoundError: No module named 'cairosvg'`),`rsvg-convert` 不存在,只有 Edge / Chrome:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# ops_bash(git-bash)里探测;哪个存在用哪个
|
||||||
|
ls -1 "/c/Program Files/Google/Chrome/Application/chrome.exe" \
|
||||||
|
"/c/Program Files (x86)/Microsoft/Edge/Application/msedge.exe" 2>/dev/null
|
||||||
|
command -v rsvg-convert inkscape magick convert 2>/dev/null || echo "no CLI rasterizer"
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Test-Path "C:\Program Files\Google\Chrome\Application\chrome.exe"
|
||||||
|
Test-Path "C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe"
|
||||||
|
```
|
||||||
|
|
||||||
|
截图(**`--screenshot=` 必须给绝对 Windows 路径 + 绝对 `file:///` 输入**,并且**固定用
|
||||||
|
`--headless=new`**:本窗用旧 `--headless` 同一套命令时成时不成——一次写出了 168 KB,
|
||||||
|
下一次静默不写文件;也别把 stderr 丢进 `/dev/null`,成功标志就是它自己打印的那行字数):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ci-artifacts # Chrome 没有建目录的参数,先自己建
|
||||||
|
OUT="D:\\Code\\DTU Code\\<工作树名>\\ci-artifacts\\<主题>.png" # 绝对路径、反斜杠
|
||||||
|
"/c/Program Files/Google/Chrome/Application/chrome.exe" \
|
||||||
|
--headless=new --disable-gpu --hide-scrollbars --force-device-scale-factor=1 \
|
||||||
|
--window-size=1060,712 \
|
||||||
|
--screenshot="$OUT" \
|
||||||
|
"file:///D:/Code/DTU Code/<工作树名>/docs/images/<主题>.svg" 2>&1 | tail -2
|
||||||
|
```
|
||||||
|
|
||||||
|
成功标志是 Chrome 自己打印的 `<N> bytes written to file D:\...`。**回读 PNG 才算目检完成**:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const r = await tools.read_image({ file_path: "D:\\Code\\DTU Code\\<工作树名>\\ci-artifacts\\<主题>.png" });
|
||||||
|
console.log(r.image.width + "x" + r.image.height, "bytes=" + r.image.bytes); // 应等于 --window-size / 画布
|
||||||
|
```
|
||||||
|
|
||||||
|
看什么:文字压框 / 溢出盒外、标签互撞(本窗实测两轮才干净:台阶图首轮"文字压到盒下方 + 标签互撞",第二、三轮才修完)、`wrap` 收尾的省略号 `…`(有 = 文案超长)、虚线与盒边线重叠、配色是否还在调色板里。发现问题**回改生成器常量并重跑预检**,然后**重新截图**(PNG 是派生物,不会自己更新),`--window-size` 跟着 `canvas WxH` 改。
|
||||||
|
|
||||||
|
## 四、md 引用(相对路径)
|
||||||
|
|
||||||
|
`docs/` 根的 md:``;`docs/任务规划/`、`docs/UI设计/` 等子目录 md:``(本窗实例:`docs/任务规划/数据可信.md` 写 ``)。alt 文本写成"这张图的结论是什么",别只重复文件名。
|
||||||
|
|
||||||
|
## 五、generated-fresh:逐字节幂等门禁(新图长期不假红的前提)
|
||||||
|
|
||||||
|
`generated-fresh` 把 `tools/arch_diagram/gen_*.py`(`docs-svg` 项:`per_script: true`、`pattern: gen_*.py`、`mode: exact`)**逐个拷进系统临时目录重跑**,与工作区产物**逐字节**比对:内容不同 = `DIFF`(生成物比生成器旧)、临时目录多出一个工作区没有的文件 = `NEW`、`rc≠0` = 「生成器报错」。由此推出四条硬约束:
|
||||||
|
|
||||||
|
1. **生成器只能写那一个 `.svg`**:绝不在脚本里顺手导出 PNG 预览(预览写 `ci-artifacts/`,不进 `docs/images/`)。
|
||||||
|
2. **stdout 不许崩**:GBK 终端遇上 `²` / `−` / CJK 路径会 `UnicodeEncodeError` → 钩子 `rc≠0`,一片假红。所以打印一律走 `emit()`,脚本里不留裸 `print()`(本窗实测:两份新生成器只改了一份,另一份照旧崩)。
|
||||||
|
3. **产物内容零不确定**:SVG 里不写时间戳 / 绝对路径 / 主机名;浮点坐标一律 `%g` / `%.1f` 定形;字典与 `os.listdir` 结果参与布局时先 `sorted()`。
|
||||||
|
4. **`gen_*.py` 命名即自动在册**(pattern 就是 `gen_*.py`)——故意不叫这个名字等于躲开门禁,门禁最大盲区正是"未登记的生成器",别利用它。
|
||||||
|
|
||||||
|
自查(仓库根):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/Scripts/python.exe tools/arch_diagram/gen_<主题>_svg.py
|
||||||
|
sha1sum docs/images/<主题>.svg
|
||||||
|
.venv/Scripts/python.exe tools/arch_diagram/gen_<主题>_svg.py # 重跑后 sha1 必须不变
|
||||||
|
.venv/Scripts/python.exe scripts/check_generated_fresh.py # 期望 rc=0,无 DIFF/NEW,[BASELINE] 不增
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& '.venv\Scripts\python.exe' scripts/check_generated_fresh.py # 单独跑(不 stash 别人在飞的改动)
|
||||||
|
& '.venv\Scripts\python.exe' scripts/check_generated_fresh.py --list # 只看在册声明
|
||||||
|
& '.venv\Scripts\python.exe' -m pre_commit run generated-fresh --files tools/arch_diagram/gen_<主题>_svg.py docs/images/<主题>.svg
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ **别跑不带 `--files` 也不带 `--all-files` 的裸 `pre_commit run`**(会 stash 掉别人在飞的改动);判读口径 → `docs/门禁说明/generated-fresh.md` §4。
|
||||||
|
|
||||||
|
## 六、提交
|
||||||
|
|
||||||
|
生成器脚本(源)+ `docs/images/<主题>.svg`(产物)+ 引用它的 `.md` + `tools/arch_diagram/README.md`「产物清单」那一行**同一笔**提交;PNG 预览不入库。
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& '.venv\Scripts\python.exe' -m pre_commit run --files <脚本> <svg> <md> <README.md>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 陷阱(本窗实测)
|
||||||
|
|
||||||
|
- **想装渲染器再转 PNG**:`cairosvg` 不在 `.venv`(`ModuleNotFoundError`)、`requirements.txt` 里也没有,`rsvg-convert` / `inkscape` / `magick` 这台机器上都不存在 → 用 §三 的 headless Chrome/Edge(权威命令在 `docs/架构设计/图形绘制流程.md` §四,2026-10-30 已同步成这套,别再往回写)。**没真渲染出 PNG 的情况下不要声称"已目检"。**
|
||||||
|
- **`--screenshot=ci-artifacts/ladder.png`(相对路径)** → `EXIT 2` + `Failed to write file ci-artifacts/ladder.png: 系统找不到指定的路径。 (0x3)`,PNG 压根没落盘;换绝对 Windows 路径后写出 95549 bytes。这条报错**不区分**"相对路径"与"目录不存在"两种成因(实测 `mkdir -p ci-artifacts` 已先跑过、仍报同一句),排查时两条都排。
|
||||||
|
- **只做预检 + 缩略目检就收工(大图)**:`check_fit` 归零、PNG 看着干净,仍可能有二十来条标签压框——节点白底后画会吃掉压上去的标签;大图必须走 §三b 的解析式审计。
|
||||||
|
- **只做预检就收工**:`check_fit` / `OVERFLOW` 归零也不代表干净——本窗实测"门标签压第 5 级盒、蓝底标签挤、多余虚线"三类碰撞只有 PNG 回读看得见;反过来只目检不预检,脚注溢出就得靠眼睛反复量。**两者都要 0**。
|
||||||
|
- **手改 SVG 成品**:等于制造「不是我改的」diff,且 `generated-fresh` 立刻 DIFF;也**不要退化成 ASCII 框图**(AGENTS.md 明令勿手绘)。一切修改都落在生成器常量上再重跑。
|
||||||
|
- **批量改生成器时 `old_string` 不匹配**:一批 `edit` 会在中途抛错,但**前面的编辑已经落盘** → 半成品生成器(本窗实测:`emit` helper 缺失却仍打印 OK,几轮之后才查出)。正解:改前先 `read` 出精确原文,逐条改,末尾统一复验(`grep -n "def emit"`、`grep -c print`、再重跑看 rc 与 OK 行)。
|
||||||
|
- **PNG 尺寸 ≠ 画布**:`--window-size` 忘了跟着 `W`/`H` 改 → 截出来带大片空白或裁掉底部脚注;回读 `r.image.width/height` 与 `canvas WxH` 对一下再判定。
|
||||||
|
- **`docs/images/` 下手写 `.svg` 或放 PNG 预览**:前者没有生成器(NEW / 躲过门禁),后者污染"临时目录多了文件"判定。
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
- [ ] 生成器输出 `OK … bytes, canvas WxH`,**无** `OVERFLOW WARN` / `!! N overflow warnings`;`grep -n "def emit"` 命中、`grep -c print` = 0。
|
||||||
|
- [ ] 绝对路径 PNG 存在、尺寸 = 画布,`read_image` 回读确认:无文字压框 / 溢出、无标签互撞、无 `…` 截断收尾、配色在调色板内。
|
||||||
|
- [ ] 连跑两次产物 sha1 不变;`scripts/check_generated_fresh.py` rc=0(无 DIFF / NEW,`[BASELINE]` 未增加)。
|
||||||
|
- [ ] 大图(≥20 边):跑一遍 §三b 的碰撞审计,报数归零(标签压框 / 走线穿框 / 标签出画布)。
|
||||||
|
- [ ] 引用它的 `.md` 相对路径正确(根目录 `images/`,子目录 `../images/`);`tools/arch_diagram/README.md`「产物清单」已加行,「挂在」列的 `文档:行号` 用 `grep -rn "<图名>.svg" docs/ --include=*.md` 现扫核对(**别抄旧行号**,行号一漂整列就废)。
|
||||||
|
- [ ] 脚本 + 产物 `.svg` + `.md` + README 行一并暂存;`pre-commit run --files …` 全过(无乱码 / 行尾空白 / 编码 OK)。
|
||||||
|
|
||||||
|
> **与规范文档的分工(2026-10-30 已对齐)**:`docs/架构设计/图形绘制流程.md` 是规范归属(AGENTS.md:同一事实只在一处详述),本 skill 是执行侧。该文档 §3.1 第 4 步与 §四 原先写的「浏览器打开 SVG / 给 `.venv` 装 cairosvg 转 PNG」在本机走不通,已同步成 headless Edge/Chrome 那套(命令只在 §四 详述,本 skill §三 留判据指针);**改渲染命令时两处一起改**——做图判据以本 skill 为准,规范条文以该文档为归属。
|
||||||
|
|||||||
@@ -0,0 +1,154 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""一次性碰撞审计(拷进 `ci-artifacts/` 用,**不要放进 `tools/arch_diagram/`**):
|
||||||
|
解析自己生成的 SVG,报「边标签压节点 / 走线穿框 / 标签互压 / 文字出画布 / 节点文字出框 /
|
||||||
|
标签余量过窄」,并打印最紧的三条「标签文字 ↔ 最近框」水平余量。
|
||||||
|
|
||||||
|
为什么需要它:`check_fit` 预检只量**框内**文字;几十框几十边的大图,
|
||||||
|
标签压框与走线穿框在缩略 PNG 里根本看不全(实测 42 框 37 边报出 23 条),
|
||||||
|
而像素回读又慢又主观。解析产物是确定性的。
|
||||||
|
|
||||||
|
依赖:待审图对应的生成器模块必须能被 import(脚本靠它复用同一把估宽尺子 `text_w()`
|
||||||
|
与几何常量 `CW` / `CH` / `EDGE_FONT` / `ECOL`,两边同尺子才不会假绿)。
|
||||||
|
生成器侧要求:边标签白片用 `fill-opacity="0.94"`、节点框用 `fill="#ffffff"` 且
|
||||||
|
`width==CW`、`height==CH`(本脚本靠这两条分诊);文字用 `<text font-size fill>`。
|
||||||
|
|
||||||
|
**这一遍照不到的东西**(别把它当唯一判据):
|
||||||
|
- 走线检查必须把框**内缩**几像素,否则合法的「线接在框边上」会全报成穿框——从 SVG
|
||||||
|
反推不出哪两个框是本边的端点。代价:1~3px 的切边也照不到。要零余量就在**生成器侧**
|
||||||
|
再用 `Box` / `route()` / `label_at()` 的真值扫一遍(线段 vs 不膨胀的框,只跳本边两端)。
|
||||||
|
- 白片比通道宽一点、被节点白底盖住边角:无害(白片先画、节点后画),不用改;
|
||||||
|
要盯的是**文字**的余量,上面那三条数就是。
|
||||||
|
- 自写的判据脚本先拿一条**已知干净**的边验它不报,再信它的「0 条」:实测一条
|
||||||
|
`hi > x0` 写成 `hi > y0` 的复制粘贴错,凭空报出 3 条「穿框」,差点为此重排走线。
|
||||||
|
|
||||||
|
用法(仓库根):
|
||||||
|
python ci-artifacts/label_audit.py <生成器模块名> [svg 路径]
|
||||||
|
# 例:python ci-artifacts/label_audit.py gen_arch_endtoend_svg
|
||||||
|
# python ci-artifacts/label_audit.py gen_wave_record_flow_svg docs/images/故障录波数据流.svg
|
||||||
|
退出码:0 = 无问题,1 = 有问题(打印明细)。
|
||||||
|
别的生成器若没有同名常量(多数用 `CHIP_FONT` / 自己的线色变量),改脚本顶部 `EDGE_COLORS` 与 `edge_labels` 那两处筛选即可——估宽必须继续用该生成器自己的 `text_w()`,别换一把尺子。
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import xml.etree.ElementTree as ET
|
||||||
|
|
||||||
|
sys.path.insert(0, "tools/arch_diagram")
|
||||||
|
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
|
||||||
|
|
||||||
|
mod_name = sys.argv[1]
|
||||||
|
G = __import__(mod_name)
|
||||||
|
svg_path = sys.argv[2] if len(sys.argv) > 2 else G.OUT
|
||||||
|
|
||||||
|
MASK_OP = "0.94" # 边标签白片的 fill-opacity(生成器侧约定)
|
||||||
|
EDGE_COLORS = set(G.ECOL.values()) # 边标签颜色(生成器侧的线色表)
|
||||||
|
|
||||||
|
def box_of_text(el):
|
||||||
|
"""<text> → (x0, y0, x1, y1, 文本, 字号, 颜色)。baseline y 之上约 0.85em、之下 0.22em。"""
|
||||||
|
x, y = float(el.get("x")), float(el.get("y"))
|
||||||
|
fs = float(el.get("font-size"))
|
||||||
|
s = "".join(el.itertext())
|
||||||
|
w = G.text_w(s, fs)
|
||||||
|
a = el.get("text-anchor", "start")
|
||||||
|
x0 = x if a == "start" else (x - w / 2 if a == "middle" else x - w)
|
||||||
|
return (x0, y - fs * 0.85, x0 + w, y + fs * 0.22, s, fs, el.get("fill"))
|
||||||
|
|
||||||
|
def area(a, b):
|
||||||
|
ix = min(a[2], b[2]) - max(a[0], b[0])
|
||||||
|
iy = min(a[3], b[3]) - max(a[1], b[1])
|
||||||
|
return ix * iy if (ix > 0.5 and iy > 0.5) else 0.0
|
||||||
|
|
||||||
|
def segs_of(d):
|
||||||
|
n = [float(v) for v in d.replace("M", " ").replace("L", " ").replace(",", " ").split()]
|
||||||
|
pts = list(zip(n[0::2], n[1::2]))
|
||||||
|
return list(zip(pts, pts[1:]))
|
||||||
|
|
||||||
|
def seg_hits_rect(sg, r, m=2.0):
|
||||||
|
"""轴对齐线段 vs 矩形(内缩 m 排除贴边的合法端点)。斜段一律算可疑。"""
|
||||||
|
(x0, y0), (x1, y1) = sg
|
||||||
|
rx0, ry0, rx1, ry1 = r[0] + m, r[1] + m, r[2] - m, r[3] - m
|
||||||
|
if abs(y0 - y1) < 0.5:
|
||||||
|
return ry0 < y0 < ry1 and min(x0, x1) < rx1 - m and max(x0, x1) > rx0 + m
|
||||||
|
if abs(x0 - x1) < 0.5:
|
||||||
|
return rx0 < x0 < rx1 and min(y0, y1) < ry1 - m and max(y0, y1) > ry0 + m
|
||||||
|
return True
|
||||||
|
|
||||||
|
root = ET.parse(svg_path).getroot()
|
||||||
|
W, H = float(root.get("width")), float(root.get("height"))
|
||||||
|
texts, nodes, masks, paths = [], [], [], []
|
||||||
|
for el in root.iter():
|
||||||
|
tag = el.tag.split("}")[-1] # 去命名空间,否则一个都匹配不上
|
||||||
|
if tag == "text":
|
||||||
|
texts.append(box_of_text(el))
|
||||||
|
elif tag == "rect":
|
||||||
|
w, h = float(el.get("width")), float(el.get("height"))
|
||||||
|
x, y = float(el.get("x")), float(el.get("y"))
|
||||||
|
if el.get("fill") == "#ffffff" and abs(w - G.CW) < 0.6 and abs(h - G.CH) < 0.6:
|
||||||
|
nodes.append((x, y, x + w, y + h, "node"))
|
||||||
|
elif el.get("fill") == "#ffffff" and el.get("fill-opacity") == MASK_OP:
|
||||||
|
masks.append((x, y, x + w, y + h, "mask"))
|
||||||
|
elif tag == "path" and el.get("fill") == "none":
|
||||||
|
paths.append(segs_of(el.get("d")))
|
||||||
|
|
||||||
|
edge_labels = [t for t in texts if t[6] in EDGE_COLORS and t[5] == G.EDGE_FONT]
|
||||||
|
bad = []
|
||||||
|
for lb in edge_labels:
|
||||||
|
b = lb[:4]
|
||||||
|
for n in nodes:
|
||||||
|
a = area(b, n)
|
||||||
|
if a > 6: # 压到框边 6 px² 以上就会被节点吃掉半个字
|
||||||
|
bad.append("标签压节点 %-12s 压 node@(%.0f,%.0f) %.0f px²" % (lb[4][:12], n[0], n[1], a))
|
||||||
|
for ob in edge_labels:
|
||||||
|
if ob is not lb and area(b, ob[:4]) > 6:
|
||||||
|
bad.append("标签互压 %s / %s" % (lb[4][:10], ob[4][:10]))
|
||||||
|
if b[0] < 2 or b[2] > W - 2 or b[3] > H - 2 or b[1] < 2:
|
||||||
|
bad.append("标签出画布 %s box=(%.0f,%.0f,%.0f,%.0f) 画布 %.0fx%.0f"
|
||||||
|
% (lb[4][:12], b[0], b[1], b[2], b[3], W, H))
|
||||||
|
for segs in paths:
|
||||||
|
for sg in segs:
|
||||||
|
for n in nodes:
|
||||||
|
if seg_hits_rect(sg, n):
|
||||||
|
bad.append("走线穿框 (%d,%d)-(%d,%d) 压 node@(%.0f,%.0f)"
|
||||||
|
% (sg[0][0], sg[0][1], sg[1][0], sg[1][1], n[0], n[1]))
|
||||||
|
break
|
||||||
|
# 「节点文字出框」只能按几何判:文字中心落在某个节点框里,却没被该框完整包住。
|
||||||
|
# 别拿 font-size 当筛选——本仓惯例里副题字号 = 边标签字号 = 脚注字号(9.5),按字号筛会全灭。
|
||||||
|
for t in texts:
|
||||||
|
cx, cy = (t[0] + t[2]) / 2, (t[1] + t[3]) / 2
|
||||||
|
host = [n for n in nodes if n[0] <= cx <= n[2] and n[1] <= cy <= n[3]]
|
||||||
|
for n in host:
|
||||||
|
if not (t[0] >= n[0] - 1 and t[2] <= n[2] + 1 and t[1] >= n[1] - 1 and t[3] <= n[3] + 1):
|
||||||
|
bad.append("节点文字出框 %r 超 node@(%.0f,%.0f)" % (t[4][:16], n[0], n[1]))
|
||||||
|
break
|
||||||
|
|
||||||
|
# 余量比「有没有压上」更早暴露问题:本仓约定边标签整段落在框间通道里,
|
||||||
|
# 只要文字离最近框 < 3px,字宽估算稍偏(本脚本与生成器共用同一把尺子,
|
||||||
|
# 但真实字体度量和它有出入)就会被后画的节点吃掉笔画。
|
||||||
|
MIN_CLEAR = 3.0
|
||||||
|
clears = []
|
||||||
|
for lb in edge_labels:
|
||||||
|
b = lb[:4]
|
||||||
|
worst, wid = None, None
|
||||||
|
for n in nodes:
|
||||||
|
if min(b[3], n[3]) - max(b[1], n[1]) <= 0: # 竖直方向不相干
|
||||||
|
continue
|
||||||
|
c = max(n[0] - b[2], b[0] - n[2]) # 正=间距,负=压进去多深
|
||||||
|
if worst is None or c < worst:
|
||||||
|
worst, wid = c, (n[0], n[1])
|
||||||
|
if worst is not None:
|
||||||
|
clears.append((worst, lb[4][:12], wid))
|
||||||
|
if worst < MIN_CLEAR:
|
||||||
|
bad.append("标签余量过窄 %r 距 node@%s 仅 %.1fpx(< %.1f)"
|
||||||
|
% (lb[4][:12], wid, worst, MIN_CLEAR))
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
for b in bad:
|
||||||
|
if b not in seen:
|
||||||
|
seen.append(b)
|
||||||
|
print("%s:边标签 %d / 节点 %d / 文字 %d / 路径 %d"
|
||||||
|
% (svg_path, len(edge_labels), len(nodes), len(texts), len(paths)))
|
||||||
|
for c in sorted(clears)[:3]:
|
||||||
|
print(" 最紧余量 %.1fpx 标签 %r 邻 node@%s" % (c[0], c[1], c[2]))
|
||||||
|
for b in seen:
|
||||||
|
print(" ! " + b)
|
||||||
|
print("合计问题 %d 条" % len(seen))
|
||||||
|
sys.exit(1 if seen else 0)
|
||||||
@@ -14,6 +14,10 @@ whenToUse: UI 代码新增中文文案、用户报告屏幕缺字/空白块、
|
|||||||
# 1. 代码落库(新界面字符串已写入 ui_menu.c / ui_content_page.c 等 UI 源码)
|
# 1. 代码落库(新界面字符串已写入 ui_menu.c / ui_content_page.c 等 UI 源码)
|
||||||
# 2. 提取字符集(纯标准库,任意 python3 均可)
|
# 2. 提取字符集(纯标准库,任意 python3 均可)
|
||||||
python tools/font_gen/extract_cn.py # 扫描 UI 源码字符串 → 仓库根 ui_cn_chars.txt
|
python tools/font_gen/extract_cn.py # 扫描 UI 源码字符串 → 仓库根 ui_cn_chars.txt
|
||||||
|
# ⚠ extract 只认"当前源码字面量",会把清单里 KEEP_EXTRA 那批**刻意保留字**(历史落盘
|
||||||
|
# 记录用字,逐条理由见 check_font_charset.py 的 KEEP_EXTRA 字典——不在本文列举,列举必
|
||||||
|
# 然过期)从 txt 洗掉:跑 make_font 前必须把它们并回 ui_cn_chars.txt,否则 font-charset
|
||||||
|
# 门禁 A 段 FAIL 并逐字打印保留理由;事故路径实名登记见 docs/门禁说明/font-charset.md §6.1
|
||||||
# 3. 生成字体(需 fontTools,一律用 .venv;宋体 .ttc 的 face0 抽取与 ▶ 箭头补回均自动完成,幂等)
|
# 3. 生成字体(需 fontTools,一律用 .venv;宋体 .ttc 的 face0 抽取与 ▶ 箭头补回均自动完成,幂等)
|
||||||
.venv\Scripts\python.exe tools/font_gen/make_font.py
|
.venv\Scripts\python.exe tools/font_gen/make_font.py
|
||||||
# 变体:--bpp 2(宋体细笔画发虚时)/ --font C:/path/to/font.ttf(换字体)
|
# 变体:--bpp 2(宋体细笔画发虚时)/ --font C:/path/to/font.ttf(换字体)
|
||||||
@@ -30,4 +34,5 @@ python tools/font_gen/extract_cn.py # 扫描 UI 源码字符串 →
|
|||||||
- `requirements.txt` 必须**纯 ASCII**(Windows 下 pip 按 GBK 解码,中文注释 → UnicodeDecodeError)
|
- `requirements.txt` 必须**纯 ASCII**(Windows 下 pip 按 GBK 解码,中文注释 → UnicodeDecodeError)
|
||||||
- 生僻字(宋体可能无字形)→ **写码前先手动验证字形**,避免"代码写完才发现是空白"
|
- 生僻字(宋体可能无字形)→ **写码前先手动验证字形**,避免"代码写完才发现是空白"
|
||||||
- 生成成功后跑 `ui-verify` skill 全回归;黄金参考因字形变化而 FAIL 属预期 → `check_content_pages.py --update` 刷新
|
- 生成成功后跑 `ui-verify` skill 全回归;黄金参考因字形变化而 FAIL 属预期 → `check_content_pages.py --update` 刷新
|
||||||
- 字数变化时同步更新文档中的字符集计数(当前 97 汉字 + ASCII 0x20-0x7E,共 193 字形):`UI_中文字体生成与配置.md`、`UI_界面制作流程.md` C3、`UI_设计架构.md`、`simulator/README.md` 已知坑 #4
|
- 字数变化时同步更新文档中的字符集计数(当前 **224** 汉字/符号 + ASCII 0x20-0x7E,共 **319** 字形;2026-10-29 实测:`ui_cn_chars.txt` 唯一码点 224、`ui_font_cn_12.c` 的 `.bitmap_index` 320 条含 notdef → 319 字形。历史上 119/214 → 179/274 → 185/280 → 218/313 均为当时快照):`UI_中文字体生成与配置.md`、`UI_界面制作流程.md` C3、`UI_设计架构.md`、`simulator/README.md` 已知坑 #4
|
||||||
|
- **新增字低于既有最小码位**(如 Ⅰ U+2160)会下探稀疏 cmap 起点并吞掉单码位补回条目(实测 ▶ 箭头全变方框而旧版 coverage 全绿)——`make_font.py`(2026-10-25 起)按 LVGL 首命中语义插入/守卫/校验,扩字后模拟器像素回归**必跑**
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
---
|
||||||
|
name: id-family-semantic-migration
|
||||||
|
description: 编号族整族清退/停发 playbook:把一个编号族(字母+数字如保护功能号 L1-x~L5-x、网络条目号 F-*、登记簿旧号 N-x、台架阶段裸形 T1~T3、用例号 TC-*,或中文前缀「卡」+ 数字这类门禁语料里**没有机器识别面**的族)在全仓代码注释、测试、活文档与门禁配置里改成语义短语(含销项删行与逐号换算表入档),或把「不清退」裁定写成硬口径入档。触发语:清退某族编号、编号改成语义名或语义短语、还有多少号可清、残留多少、批次二/批次三/批次五、「卡 0」「卡 0b」「卡 1~12」、销项删行并去除相关编号、id-check 报悬空引用或不可判定超限、不可判定引用绝对数涨点、「这一族不做了」。
|
||||||
|
whenToUse: 用于**整族**处理:先判这一族有没有机器识别面 → 清点存量 → 逐族分类 → 分批派单清退 → 收口 → 回写 docs/编号规范.md 对应节。单条待办行销项并写 void 别名用 ledger-row-retire-void-id;某个事实数(页数、叶数、钩子数)变了要同步全仓用 stale-doc-count-sweep;单个文件改名或挪目录用 scripts/rename_ref.py;跑 HEAD 版对照组用 task-worktree 的净室 worktree。这四类不由本技能覆盖,但「涨点归因」一节会用到净室 worktree。
|
||||||
|
version: 1.1.0
|
||||||
|
---
|
||||||
|
|
||||||
|
# 编号族清退成语义标识
|
||||||
|
|
||||||
|
**2026-10-29 用户裁定:全族一律停发**(含 `docs/编号规范.md` §4.1 那五个存量族)——本 playbook 的「停发」分支默认已成立,
|
||||||
|
现在唯一还需要决策的是**要不要清退存量**(全仓改语义 + 销项删行 + 逐号换算表入档)。
|
||||||
|
|
||||||
|
**动手前先判这一族有没有「机器识别面」**——它决定计数口径的来源与落地去处,两条路的做法**正好相反**:
|
||||||
|
|
||||||
|
| 族形态 | 计数口径来源 | 改名手段 | `scripts/archive/id_gate/id_aliases.json` |
|
||||||
|
|---|---|---|---|
|
||||||
|
| 字母+数字,且在 `scripts/archive/id_gate/check_ids.py` 语料内(`id_registry.json` 有族、族 pattern 能匹配它) | 门禁自身计数(`ck.refs` / `ck.stats` / 报告行) | `scripts/rename_ids.py`(映射表 + 显式 scope + 默认干跑) | **要登记**:死号 `void`、转短语 `retired-to-phrase` |
|
||||||
|
| 中文前缀(「卡」+ 数字)、纯散文里的号、圈号 ①②③ | **自建带标签正则扫描器**(门禁看不见它,2026-10-28 退役前的 id-check 全库报告与本族无关;两道门禁现都已退役(2026-10-28 / 2026-10-29),要普查形态就手工跑归档脚本 scripts/archive/id_gate/check_ids_new.py --range) | 逐处替换 + 断言命中数(`rename_ids.py` 靠族 pattern 驱动,这里用不上) | **刻意不登记**:登记了机器也看不见,只多一张没人核的表;停发只靠规范 + 评审 |
|
||||||
|
|
||||||
|
怎么判(三条,任一为否即无识别面):① `grep -nE '"name"|"pattern"' scripts/archive/id_gate/id_registry.json`,没有任何族 pattern 能匹配该形态;② `./.venv/Scripts/python.exe scripts/archive/id_gate/check_ids.py --top 0` 的任何校验里找不到它;③ registry 的 `blind_spots` 已明写不判(圈号与正常列举无法机械区分、裸形同字母多义只在属主登记处判)。实测:「卡」+ 数字**没有族**(中文形态只有 `malformed` 里 `陷阱 ?[0-9]` 那条禁令,管的是陷阱编号不是卡号),该族真实站点 288 处 / 56 文件,而门禁对它贡献 0。
|
||||||
|
|
||||||
|
判错的代价是双向的:拿门禁计数当中文字面量族的口径 → 得出「0 处待改」的**假归零**;给无识别面的族登记别名 → 制造「有机器兜底」的错觉,真正生效的两条(§4.1.1 停发行、§9.3 换算表)反而没人写。
|
||||||
|
|
||||||
|
其余铁律照旧:**不要凭 grep 估数、不要凭印象决定改哪几处、不要把「不做」只划掉一行。**
|
||||||
|
|
||||||
|
## 口径:三套计数都要挑明,先问再比
|
||||||
|
|
||||||
|
| 口径 | 怎么数 | 用途 |
|
||||||
|
|---|---|---|
|
||||||
|
| 门禁 refs(去重) | `ck.refs`:字面量 → 文件集合,**只记属主登记处文档之外**的引用 | 门禁趋势、批次验收线 |
|
||||||
|
| 逐行站点 | 按族正则重扫 `ck.iter_files()` 语料,**含属主文档的定义行** | **排工作量、拆单** |
|
||||||
|
| 自建扫描器站点 | 无识别面族:带标签正则全仓 walk(排除 `.git`) | 该族唯一口径,**不能拿 id-check 输出代替** |
|
||||||
|
|
||||||
|
实测例:保护功能号一族「字面量 28 / 站点 184 / 文件 58」(`ck.refs`)与队友报的「219 站点 / 38 文件」并存且都对。队友报任何合并数(指纹、站点数)之前**先问口径**,不要拿自己的数去否他的数。**每个数字标明是本回合实测、动手前基线还是沿用昨日值**;**形似噪声号另立分母**(中文族一节第 4 步),绝不与待清族合并成一个总数。
|
||||||
|
|
||||||
|
## Steps(字母+数字族:门禁看得见的那一路)
|
||||||
|
|
||||||
|
1. 确认范围与定调
|
||||||
|
2. 按族实测三口径(字面量 / 站点 / 文件),不用 grep 估数
|
||||||
|
3. 逐族先分类再动手
|
||||||
|
4. 映射表由属主文档生成
|
||||||
|
5. 按写域拆道并行派单
|
||||||
|
6. 门禁配置注释也是本族写入面
|
||||||
|
7. 每处替换断言命中数
|
||||||
|
8. 同步更新门禁侧配置
|
||||||
|
9. 生成器文本改动必须同笔重跑产物
|
||||||
|
10. 核队友交付
|
||||||
|
11. 分笔提交走真实 pre-commit 钩子
|
||||||
|
12. 收口只认门禁自身计数
|
||||||
|
13. 回写 docs/编号规范.md
|
||||||
|
14. 裁定「不做」也要写成可执行硬口径入档
|
||||||
|
> 本节全文(逐字搬移、未改写,含命令与实测数字)→ [steps-alpha-numeric-family.md](reference/steps-alpha-numeric-family.md)。动手前先读那篇。
|
||||||
|
|
||||||
|
## Steps(中文前缀族:连归档扫描工具都咬不到的那一路)
|
||||||
|
|
||||||
|
1. 先确认连归档扫描工具也咬不到(两道编号门禁 2026-10-28 / 29 已退役,"咬不咬"从此只是工具层面的事),并把这句话写进交付
|
||||||
|
2. 销项删行前先核证据属主
|
||||||
|
3. 自建带标签正则扫描脚本
|
||||||
|
4. 该族与形似噪声号分开计数,各自给分母
|
||||||
|
5. 派单前写一份 spec 文件
|
||||||
|
6. 逐处替换断言命中数
|
||||||
|
7. 落地位置写死(这一族只有这几条兜)
|
||||||
|
8. 替换后三道二次清洗
|
||||||
|
9. 收尾复跑 + 清中间产物
|
||||||
|
> 本节全文(逐字搬移、未改写,含命令与实测数字)→ [steps-chinese-prefix-family.md](reference/steps-chinese-prefix-family.md)。动手前先读那篇。
|
||||||
|
|
||||||
|
## 不可判定引用绝对数涨点的净室归因
|
||||||
|
|
||||||
|
「不可判定引用绝对数」比今日低点涨了 N 处时:
|
||||||
|
|
||||||
|
1. **禁止**用「对提交新增行 grep 字母+数字形态」归因:`git diff -U0` 的加行里绝大多数文本是从旧行搬来的**存量上下文**(注释整段重写、表格加一列、Markdown 重新折行),形态匹配数出来的是「哪段文字最近被搬动」,不是「哪里涨了 N 处」——用它归因必然把老引用算成增量。`diff -U0` 只在**已定位到文件之后**用来绑笔次与行号。
|
||||||
|
2. **正解 A(首选,同一计数器相减)**:在**仓外 detached worktree 跑 HEAD 版**(`task-worktree` skill 的净室测量仪;不 stash、不碰在飞工作区),两边都用同一个 `check_ids.py` 计数器导出 `(rel, literal)` 多重集(`ck.findings` 或 `err_groups(ck)`),做**逐文件集合差**,落差的 N 处直接指向具体文件与字面量。
|
||||||
|
3. **正解 B(逐文件注入)**:一次只把一个文件换成在飞版跑计数器——涨点在哪个文件上,就只在注入它时出现;文件多时按 `git diff --name-only` 折半。
|
||||||
|
4. **当轮跑不完就如实说**:本轮未跑净室归因时,只报「现值需要重新实测」并说明**需要跑净室归因**,**不得**给出候选归因对象,**不得**把上一轮的现值或余量当本回合实测值报出(每个数标明来源与时刻)。
|
||||||
|
5. 与其它交付口径冲突的差值**登记为待查项**(两套计数可能都对),不当场判谁对谁错。
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
- 拿门禁计数当中文字面量族的口径
|
||||||
|
- 给无机器识别面的族登记 id_aliases.json
|
||||||
|
- 拿 grep 计数跟人吵口径
|
||||||
|
- heredoc 里嵌 Python 正则 / 中文
|
||||||
|
- 扫描没排除 .git
|
||||||
|
- 凭记忆敲 old 串
|
||||||
|
- 替换后不做同句重复检测
|
||||||
|
- 换算表里复述字面量
|
||||||
|
- | head 之后读 $?
|
||||||
|
- 占位符泄漏
|
||||||
|
- 删行前不核证据属主、不查当初为什么保留
|
||||||
|
- 涨点归因拿 diff 新增行 grep 形态
|
||||||
|
- 全局查找替换
|
||||||
|
- 未分类就动手
|
||||||
|
- 按简称匹配文件清单
|
||||||
|
- 复现不了队友的合并指纹就怀疑丢文件
|
||||||
|
- 替换 token 含
|
||||||
|
- 只改生成器文案不重跑产物
|
||||||
|
- grep 'A\|B' 空返回当文件干净
|
||||||
|
- 一次 run_code 串 5 × wait_agent(118000) ≈ 590s 整段 abort、进度全丢
|
||||||
|
- 游标整型比较静默不生效
|
||||||
|
- git add 目录路径带进别道文件
|
||||||
|
- amend 被 gitlint B1 拦
|
||||||
|
- 改「只动注释」要拿机器证据
|
||||||
|
- 冻结历史与原始证据不改
|
||||||
|
- *.uvprojx 抖动不入库
|
||||||
|
> 本节全文(逐字搬移、未改写,含命令与实测数字)→ [pitfalls.md](reference/pitfalls.md)。动手前先读那篇。
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. `scripts/archive/id_gate/check_ids.py --strict` 与 plain **均 rc=0**(`[OK] 校验 1~6 无错误`),悬空引用为 0。
|
||||||
|
2. 编号字面量与**不可判定引用绝对数**只降不升、不超 `undecidable_total_ceiling`;可判定比例不低于 `id_registry.json` 基线(86.0%)。
|
||||||
|
3. 本族剩余站点复扫归零:有识别面族从 `ck.refs` 按族正则过滤字面量逐条打印;除冻结历史/原始证据行外无残留。
|
||||||
|
4. `pre-commit run generated-fresh --all-files` PASS + 目检 svg diff 只有文案与框宽自适应(只在动过生成器时适用);`check_doc_links.py --all` 通过。
|
||||||
|
5. `/keil-build`:`BUILD=0`、0 错 0 警、`Program Size` 与改前同数(零行为改动证据,**本回合真跑**);相关主机组(如 `net_socket 457/457`)全绿。
|
||||||
|
6. 真实 pre-commit 钩子全绿(如 `passed=33 failed=0`);提交笔内不含他道文件。
|
||||||
|
7. 规范与台账已回写并复跑核数:自己先前写进规范的清点数字要么按最终实测改对,要么标明为动手前基线;「等你裁定」残留 = 0;「不做」裁定已在 §4.1.1 + §9.1 + 批次结论段落三处落地(原「变更记录」那一处已随 2026-10-28 清流水退场)。
|
||||||
|
8. **无识别面族**:`git grep -nE '卡[ ]*[0-9]' -- .` 无输出(裸跑读 rc);自建扫描器的站点/文件数与提交正文一致;交付里明写「本族不在 check_ids 语料内、门禁判不到」与「刻意不进 `id_aliases.json`」的理由;§4.1.1 停发行 + §9.3 换算表 + 批次结论段落三处齐全,换算表行数 = 该族字面量数(实测 14),且写明旧号永久作废、不得重新取走。
|
||||||
|
9. **二次清洗三道各有实测数**:同句重复短语 0 行、各空格形态先计数后归一(复扫 0)、固定正则残留 0;被代码头注释引用的设计约束章节仍在(`git diff` 里没被动过)。
|
||||||
|
10. **叙述同步**:`AGENTS.md` / `docs/README.md` / `docs/任务规划/README.md` / `docs/任务规划/登记簿.md` 里旧口径(「三项」「卡 N」类)实测归零;`ci-artifacts/` 里本次 spec 与中间产物已删。
|
||||||
|
11. 门禁复跑清单齐:`check_ids.py` plain + `--strict`、`check_doc_links.py --all`、`bom-guard`、`no-mojibake`、`git diff --check`、`/keil-build` 或受影响单测——**每个数字标明是本回合实测**。
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Pitfalls(自 SKILL.md 外置)
|
||||||
|
|
||||||
|
> 本文由 id-family-semantic-migration 的 SKILL.md「Pitfalls」一节逐字外置而来(渐进展开:主文件只留索引与指针)。步骤号沿用原文,未重编。
|
||||||
|
|
||||||
|
- **拿门禁计数当中文字面量族的口径** → 得出「0 处待改」的假归零(实测该族真实站点 288 处 / 56 文件);先跑自建扫描器报「站点 / 文件」,并在交付里写明该族不在语料内、门禁判不到。
|
||||||
|
- **给无机器识别面的族登记 `id_aliases.json`** → 中文前缀不进语料,登记了机器也看不见,只制造「有门禁兜底」的错觉;执行面是 §4.1.1 停发行 + §9.3 换算表 + 评审。反过来,有识别面的族不登记别名 → 门禁把旧号引用判成悬空。
|
||||||
|
- **拿 grep 计数跟人吵口径**:先拿实测三口径,再谈拆单;两套计数不一致不是有人算错(见上表)。**形似噪声号并进总数** → 把 132 处里程碑噪声算进 420 处待改,动手就改坏 `docs/芯片手册/` 原文。
|
||||||
|
- **heredoc 里嵌 Python 正则 / 中文** → `invalid escape sequence` 与语法错;脚本落文件再跑。中文正则用 **raw 串 + `\uXXXX`**:非 raw 串的 `"\b"` 会被解成退格符,正则**静默失配不报错**。
|
||||||
|
- **扫描没排除 `.git`** → `.git/COMMIT_MSG_*.txt` 的 GBK 乱码历史被当残留,越清越多;**靠控制台看中文** → 一律写 UTF-8 报告文件再解析,判乱码统计 `EF BF BD` 字节数。
|
||||||
|
- **凭记忆敲 `old` 串** → 中文引号与 ASCII 双引号形态不同,命中 0;回读原文取真身,断言前先看 `git status` 自证文件未变。
|
||||||
|
- **替换后不做同句重复检测** → 留下「运行参数段(「数据可信」运行参数段交付)」这类句子(实测 12 行);**同一短语两种空格形态不归一** → 全仓口径漂移(归一前先 `git grep -c -F` 数各形态)。
|
||||||
|
- **换算表里复述字面量** → 讲规则的文档变成自己的引用站点(规范本身在门禁语料里)。
|
||||||
|
- **`| head` 之后读 `$?`** → 永远是 0,把「还有残留」当「已归零」。
|
||||||
|
- **占位符泄漏**:为躲转义把反引号写成 `«` / `»` 再全文替换,落盘后**必须核占位符残留 = 0**(实测 22 行泄漏到本节之外,含不是自己写的行)。
|
||||||
|
- **删行前不核证据属主、不查当初为什么保留** → 要么删掉唯一记录,要么把已撤销的「✅ 保留行」例外当常态。
|
||||||
|
- **涨点归因拿 diff 新增行 grep 形态** → 把存量上下文当增量;**把上一轮现值当本回合实测**、未跑净室归因就报具体归因对象 = 猜测。
|
||||||
|
- **全局查找替换**:跨族撞号(`S3` 三义)。必走 `rename_ids.py` 映射表 + 显式 scope + 干跑;无识别面族则逐处手写 + 断言命中数。
|
||||||
|
- **未分类就动手**:把变压器位号、Mermaid 节点名、数学记号、gitlint 规则名当编号改坏硬件资料;位号目录进 `excluded_paths`,记号进噪声表。
|
||||||
|
- **按简称匹配文件清单** → 误报 8 个文件丢失;比对前展开真实路径。
|
||||||
|
- **复现不了队友的合并指纹就怀疑丢文件**:先问口径(自定义顺序不可复现),改落自有逐文件 16 位基线并把跨道文件单列。
|
||||||
|
- **替换 token 含 `**` 加粗标记** → 命中数断言 0;取反引号最小形态。
|
||||||
|
- **只改生成器文案不重跑产物** → `generated-fresh` 必红(实证 `0f7b5a87` 同类);「反正就是注释,不影响图」是错的,该文本进产物。
|
||||||
|
- **`grep 'A\|B'` 空返回当文件干净**:交替竖线在多层 bash 引号里被吃掉,是探针坏不是文件干净 → 反证为空的搜索必须换 `grep -F` 复测。
|
||||||
|
- **一次 run_code 串 `5 × wait_agent(118000)` ≈ 590s 整段 abort、进度全丢**;且一次串多条长中文 `send_message` 也撑爆 120s(发出 1 条后 abort,剩余未发)。轮询上限 4×118s,长消息拆开发。
|
||||||
|
- **游标整型比较静默不生效**(字符串形态);抬完用 `--strict` 与 plain 双验。
|
||||||
|
- **`git add` 目录路径带进别道文件**、`git add -A` 卷进在飞改动;撤销自己的改动只按文件点名 `git restore scripts/archive/id_gate/id_registry.json`,不碰同树其它道文件。
|
||||||
|
- **amend 被 gitlint B1 拦**:正文行 >80,别把 ASCII 双引号写成正文首字符。
|
||||||
|
- **改「只动注释」要拿机器证据**:`/keil-build` 0 错 0 警且 `Program Size` 与改前**逐字节同数**(本回合真跑,引用历史提交正文自报的数字不算证据),别口头承诺。
|
||||||
|
- **冻结历史与原始证据不改**:boot 串口日志等采集现场是冻结证据,改了就是篡改;历史「修改记录」行按裁定保持改名后形态(号已全仓退役,留一个查无此号的引用比留改过名的条目更坏)。
|
||||||
|
- **`*.uvprojx` 抖动不入库**;`ci-artifacts/` 里的 spec 与扫描中间产物收尾删掉,别当交付物留下。
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# Steps(字母+数字族:门禁看得见的那一路)(自 SKILL.md 外置)
|
||||||
|
|
||||||
|
> 本文由 id-family-semantic-migration 的 SKILL.md「Steps(字母+数字族:门禁看得见的那一路)」一节逐字外置而来(渐进展开:主文件只留索引与指针)。步骤号沿用原文,未重编。
|
||||||
|
|
||||||
|
1. **确认范围与定调**:先按开头的表判识别面(无识别面 → 走下一节);再读 `docs/编号规范.md` §4.1(族总表,穷举)、§4.1.1(已停发栏),存量与批次见 `scripts/archive/id_gate/编号治理历史.md` §9,明确本族属主登记处(`owner_docs_glob`);旧判据「有脚本按字面量匹配它」自 2026-10-29 起作废——全族一律停发。清退 = 全仓改语义;停发 = 在规范表里写死不再新增引用(`id_registry.json` 的游标冻结只是裁定留痕,2026-10-29 起无机器读者)。
|
||||||
|
2. **按族实测三口径(字面量 / 站点 / 文件),不用 grep 估数**。`Checker.refs` 是 **dict(字面量 → 文件集合)不是方法**,`ck.refs(fam)` 这种写法会 `TypeError`;站点数门禁不直接给,用族正则重扫它自己的语料:
|
||||||
|
```python
|
||||||
|
import sys; sys.path.insert(0, "scripts")
|
||||||
|
import check_ids as ci
|
||||||
|
reg = ci.load_json(ci.REGISTRY_FILE, "r"); al = ci.load_json(ci.ALIASES_FILE, "a")
|
||||||
|
ck = ci.Checker(reg, al); ck.run() # run() 填充 refs / stats / findings
|
||||||
|
for name in ("N", "S-LDG", "TC", "FEAT"): # 族名以 id_registry.json 的 "name" 为准
|
||||||
|
rx = next(f for f in ck.families if f["name"] == name)["_re"]
|
||||||
|
lits = {l for l in ck.refs if rx.fullmatch(l)} # 字面量(属主文档之外)
|
||||||
|
files = {r for l in lits for r in ck.refs[l]} # 文件
|
||||||
|
sites = sum(len(rx.findall(line))
|
||||||
|
for rel in ck.iter_files() # 与门禁同一语料
|
||||||
|
for line in ck.read(rel).splitlines()) # 含属主文档定义行
|
||||||
|
print(name, "literals", len(lits), "sites", sites, "files", len(files))
|
||||||
|
```
|
||||||
|
全库扫一遍不便宜(数十秒量级):可丢后台 job,不要和别的长活串在同一个 120s 预算里。把活族清单与每族数字记下来作为**动手前基线**(拆批次与派单依据)。新测试套件发号导致 `id-check` 校验 3 报悬空引用时,**先按号前缀聚合**定位是哪个套件/哪个族(`check_ids.py 2>&1 | grep -a '悬空引用' | ...` 前缀计数),别逐条读。
|
||||||
|
3. **逐族先分类再动手**——不是每个命中的裸形都是编号,四类分治(实测 T1/T2/T3 的 41 处里只有部分是编号):
|
||||||
|
- 真编号 → 语义短语 / 功能名;
|
||||||
|
- 硬件位号(变压器 T1~T3 等)→ 把目录整目录登记进 `check_ids` 的 `excluded_paths`(如 `excluded_paths += tools/netlist_semantics/`),**不逐处改位号名**;
|
||||||
|
- Mermaid 流程图节点名 → 改语义名但**节点数与连线关系不变**,图结构不动;
|
||||||
|
- 数学记号 → 进噪声表;门禁自身的规则名(gitlint `T2 Title has trailing whitespace`)→ 不改。
|
||||||
|
分类前先把剩余每处原文打出来看(`grep -F` 逐文件),否则会把硬件资料改坏。
|
||||||
|
4. **映射表由属主文档生成**:从属主文档取「编号 → 功能名/语义短语」全表(实测 42 个定义 / 28 个在用),改名走 `scripts/rename_ids.py`(映射表驱动 + **显式 scope** + **默认干跑** + 自动往 `scripts/archive/id_gate/id_aliases.json` 写 scoped 旧号→新号条目,状态 `renamed` / `retired-to-phrase` / `void`)。**绝不全局查找替换**:同一裸形字面量可能同时属多个族(实测 `S3` 三义),全局替换等于造新撞号。
|
||||||
|
5. **按写域拆道并行派单**(代码与测试半 / 文档半 / 某功能面各一条道),同笔用 `send_message` 下发**统一改写口径**(措辞、是否动字符串字面量、生成器归属、冻结历史行怎么处理),避免各道自定措辞;口径长就先落成 spec 文件(中文族一节第 5 步的做法同样适用)。看板任务只认 `team_task_update` 的 `task_id` / `expected_revision` / `action=reassign` / `owner` 四个字段。
|
||||||
|
6. **门禁配置注释也是本族写入面**:`.pre-commit-config.yaml` 的注释里会挂着功能号(实测 `7h) ADC 采样有效性校验(语义取数与有效性层)`、`7i) 保护定值模型(P1 / 动作留痕层载体)`、`规划 §3.3 保护判据层`)。配置属 Lead 写域,Lead 自己改(注释不占钩子行为、不影响 51 条钩子口径),但**必须计入批次范围并在提交正文写清是 Lead 改的 config**。
|
||||||
|
7. **每处替换断言命中数**,不做整文件正则批量改写(批量改写会静默漏改):
|
||||||
|
```python
|
||||||
|
def sub(path, pairs):
|
||||||
|
p = pathlib.Path(path); t = p.read_text(encoding='utf-8')
|
||||||
|
for old, new, want in pairs:
|
||||||
|
got = t.count(old); assert got == want, (path, old, got, want)
|
||||||
|
t = t.replace(old, new)
|
||||||
|
p.write_text(t, encoding='utf-8')
|
||||||
|
```
|
||||||
|
**token 取最小形态**:文档里被反引号/加粗包住的号,替换单位取反引号形(```T4```)而**不含 `**` 标记**(第 225 行是 `**裸形 `T4`**`,按「裸形 `T4`」替就命中 0)。讲编号的文档改成**形态描述**(如「字母 T 紧跟数字 4」)能把不可判定绝对数还回余量:实测 4 处 → 982 降到 978,且 lesson 不丢。
|
||||||
|
8. **同步更新归档扫描器的配置**:`check_ids` 的 `excluded_paths` 与噪声表按第 3 步的分类结果改,使剩余命中不再是「假编号」;新测试套件发号的悬空引用要先定**定义位**:
|
||||||
|
- 定义位**只能落一处**——注册表族(`scripts/archive/id_gate/id_registry.json`)或该套件说明文档,**两处并存比没有定义位更坏**;文档真写出来了(含判据与上板清单)就以文档为准、**撤族**;
|
||||||
|
- 要建族就照现有同类族克隆(`pattern` / `digit_width` / `owner_docs_glob` / `scope` / `cursor`),别自造字段;
|
||||||
|
- **抬游标前先确认游标是字符串形态**(`'cursor': 'TC-UIDATA-026'`)——整型比较会静默不生效(实测踩坑),用字符串解析或正则取序号再比。
|
||||||
|
9. **生成器文本改动必须同笔重跑产物**:`tools/arch_diagram/gen_*.py` 里的 docstring/文案会进产物,`generated-fresh` 把生成器拷到临时目录重跑逐字节比对,**只改源不改产物必红**。这类站点整包转给生成器属主,「谁改谁跑生成器」,脚本 + 全部 svg 产物同一笔:
|
||||||
|
```bash
|
||||||
|
./.venv/Scripts/python.exe tools/arch_diagram/gen_net_instability_svg.py 2>&1 | tail -3
|
||||||
|
pre-commit run generated-fresh --all-files # 单钩子复跑必须带 --all-files 或 --files
|
||||||
|
```
|
||||||
|
⚠ **不带这两个参数的裸 `pre-commit run` 会进 staged_files_only 上下文并 stash 别道在飞的改动**(`docs/门禁说明/generated-fresh.md` §2);`pre-commit` 不在 PATH 时改用 `& '.venv\Scripts\python.exe' -m pre_commit run generated-fresh --all-files`。PASS 后再目检 diff:只应文字变长、框宽自适应(526→551、右缘未越界、画布不变)。不把该站点从批次里悄悄丢掉:登记为「待未完成半批」,收口时核对。
|
||||||
|
10. **核队友交付**(按写域并行后必做):
|
||||||
|
- 文件清单**先展开真实路径再比**:按简称匹配会误报丢失(作者列 `phasor.py`/`adc_health.py` 等 8 个,实为 `test/host/<模块>/run_unit_test.py`);用 `git -c core.quotepath=false diff --name-only` 取全路径做集合差。
|
||||||
|
- 合并指纹对不上先问口径:作者按**自定义顺序**算的数 Lead 侧复现不了(`d0a289b7` vs 报的 `a45e7e2b`)。改建 **Lead 自有逐文件 16 位基线**存 `ci-artifacts/lead_lane_b_files.json`,并把**跨道文件单列**(实测 `App/algo/README.md`)——autostash 后按逐文件比对,不假称对上别人的合并数。
|
||||||
|
11. **分笔提交走真实 pre-commit 钩子**:`git add` 用显式清单。坑:`git add -- App/algo` 把文档道的 `App/algo/README.md`(26 处)带上车,正文按 48 只文件写就不准了 → 该笔未 push 时 `--amend` 挪到文档笔并补一句归属说明;`git add -A` 会把别道在飞文件卷进来(覆盖率钩子在他编译不过的半成品上报红)→ 撤出他人域后重跑。提交正文每行 ≤80(gitlint B1),别把 ASCII 双引号写成正文首字符。
|
||||||
|
12. **收口只认门禁自身计数**(**仅限语料内的族**;无识别面族见下一节第 8~9 步),并核**只减不增**:
|
||||||
|
```bash
|
||||||
|
./.venv/Scripts/python.exe scripts/archive/id_gate/check_ids.py 2>&1 | grep -aE '扫描|可判定 |不可判定引用' | head -4
|
||||||
|
./.venv/Scripts/python.exe scripts/archive/id_gate/check_ids.py --strict 2>&1 | tail -1 # [OK] 校验 1~6 无错误
|
||||||
|
```
|
||||||
|
报告「字面量 8787→8299→7828」「不可判定绝对数 1161→984→982→978(上限 1166)」「可判定比例 88.14%(基线 86.0%,容差 0.1pp)」。**绝对数与比例两根线都要报**:把可判定引用改成散文会稀释比值,所以另有不可判定绝对数上限。涨点归因见「净室归因」一节。
|
||||||
|
13. **回写 `docs/编号规范.md`**:§4.1 族表(停发/待清栏)、§4.1.1 已停发栏;存量表与批次结果表在 `scripts/archive/id_gate/编号治理历史.md` §9(只减不增、清点数字按最终实测同步);表格若保留动手前的清点数,**明确标注「不是当前存量,是动手前基线」**,否则就是拿旧数当现状。**用户裁定原文与理由直接写进对应节**(§4.1.1 停发行 + 该族的批次结论段落)——2026-10-28 起不再往变更记录写流水(`docs/文档规范.md` §5.1)。
|
||||||
|
14. **裁定「不做」也要写成可执行硬口径入档**(不是只划掉一行——「不做」最容易被读成「那还可以继续发号」):
|
||||||
|
- §4.1.1「已停发」栏新增该族一行:**游标冻结、现存号只减不增、新用例改用语义化测试函数名 + 文档表格行名**,并写清与 §4.1 存量族栏的关系(那五族 2026-10-29 起也一律停发);
|
||||||
|
- 批次段落写明裁定原文与理由(归档 `scripts/archive/id_gate/编号治理历史.md` §9.1——§9 一族的事实源已在归档篇,规范正文只留口径)(该族是唯一真被脚本按字面量匹配的族:C 断言标签 + 测试函数名 + 文档用例表三处同一号),并给出**将来真要清退的前置条件**:追溯器(`check_test_traceability.py`)先同时认新旧两种形态、并行核对通过,**不许先改文档再改代码**;
|
||||||
|
- 全文核「等你裁定 / 需你裁定」悬空表述残留数归 0;
|
||||||
|
- 无机器识别面的族落地面不同(`docs/编号规范.md` §4.1.1 一行 + 归档 `编号治理历史.md` §9.3 换算表 + 叙述同步三处),见下一节第 7 步。
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
# Steps(中文前缀族:连归档扫描工具都咬不到的那一路)(自 SKILL.md 外置)
|
||||||
|
|
||||||
|
> 本文由 id-family-semantic-migration 的 SKILL.md「Steps(中文前缀族:门禁咬不到的那一路)」一节逐字外置而来(渐进展开:主文件只留索引与指针)。步骤号沿用原文,未重编。
|
||||||
|
|
||||||
|
1. **先确认连归档扫描工具也咬不到,并把这句话写进交付**(两道编号门禁已退役,机器面整体消失):`scripts/archive/id_gate/check_ids.py` 的族 pattern 是拉丁形态、中文前缀不进语料 → **id-check 的 refs / 可判定比例 / 悬空引用与本族无关**;报告开头写「本族不在 check_ids 语料内,口径 = 自建扫描器」。
|
||||||
|
2. **销项删行前先核证据属主**(这是删行的唯一许可证):
|
||||||
|
- 先取历史口径:`git log --oneline -20 -- <文档>`、`git show <rev>:<文档> | sed -n '/^## 5/,/^## 6/p'` 找回被删的卡表与当初「完成即销项删行 / 标 ✅ 保留行」的理由;
|
||||||
|
- 核对判据与实测记录**已完整沉淀进属主章节**(实测:「已知电压注入验证」六项判据全 PASS、斜率 6544.2 码/V、偏差 −0.14 % 全在 `docs/硬件平台/ADC通道映射说明.md` §8.1,执行侧在 `docs/测试说明/专项验证/ADC前端注入验证流程.md`);
|
||||||
|
- **删的是任务规划侧的行与结论块,不是证据本体**;被代码头注释指称的设计约束章节(§2~§4 那类)**原样保留**,只删已完成记录;
|
||||||
|
- 该行若当初是被「代码头注释按号引用」当例外保住的(实测 `App/task/adc_task.c` 的 v3.3 修订行按卡号当口径出处),必须**先把引用侧改成语义短语**,例外才成立撤销,并在规范里写明撤销理由(上一笔「完成即保留行」的例外就是这么随引用侧改动被撤掉的);
|
||||||
|
- 删完逐行通读被改小节,报**行数变化**,不留孤立残句(实测 J7 dropped line 234 len=639、J13 闭环结论块 6 行删除并注明证据属主)。
|
||||||
|
3. **自建带标签正则扫描脚本**——四条硬规矩,全是实测返工换来的:
|
||||||
|
- 脚本**落文件**(`ci-artifacts/scan_ka_refs.py`)再跑,**不用 bash heredoc**(heredoc 里的 `\s` 被多层引号咬坏:`SyntaxWarning: invalid escape sequence` + 语法错);
|
||||||
|
- 正则**一律 raw 字符串**、汉字写 `\uXXXX`:`KA = r"\u5361"`。非 raw 串里 `"\b"` 会先被 Python 解成退格符(0x08)→ 正则**静默失配且不报错**;`"\s"` 只是警告还能跑,`"\b"` 是真错;
|
||||||
|
- **排除 `.git`**(连同 `.venv`、`ci-artifacts`、`__pycache__`、`node_modules`)——实测扫进 `.git/COMMIT_MSG_*.txt`,一堆 GBK 乱码历史被当成残留,越清越多;
|
||||||
|
- 报告**写 UTF-8 文件**(`ci-artifacts/ka_refs.txt`)再本地解析,别靠控制台输出(Windows 控制台按 GBK 渲染中文 = 乱码);判乱码统计 `EF BF BD` 字节数,别信终端渲染。
|
||||||
|
|
||||||
|
```python
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""扫描中文前缀任务卡号与同期形似噪声号;报告写 UTF-8 文件。"""
|
||||||
|
import io, os, re
|
||||||
|
KA = r"\u5361" # 卡
|
||||||
|
PATS = [("CARD_ANY", re.compile(KA + r"\s*[0-9]")),
|
||||||
|
("KA0B", re.compile(KA + r"\s*0b")),
|
||||||
|
("KA12", re.compile(KA + r"\s*12")),
|
||||||
|
("M4", re.compile(r"\bM4\b")),
|
||||||
|
("M5", re.compile(r"\bM5\b")),
|
||||||
|
("M6", re.compile(r"\bM6\b"))]
|
||||||
|
SKIP = {".git", ".venv", "ci-artifacts", "__pycache__", "node_modules"}
|
||||||
|
out = io.open("ci-artifacts/ka_refs.txt", "w", encoding="utf-8")
|
||||||
|
for root, dirs, names in os.walk("."):
|
||||||
|
dirs[:] = [d for d in dirs if d not in SKIP]
|
||||||
|
for n in names:
|
||||||
|
p = os.path.join(root, n)
|
||||||
|
try:
|
||||||
|
txt = io.open(p, encoding="utf-8", errors="ignore").read()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
for i, line in enumerate(txt.splitlines(), 1):
|
||||||
|
for tag, rx in PATS:
|
||||||
|
if rx.search(line):
|
||||||
|
out.write("===== %s\n" % p) # 同一行可命中多标签 → 按标签分别计数
|
||||||
|
out.write("%d [%s] %s\n" % (i, tag, line.strip()[:200]))
|
||||||
|
out.close()
|
||||||
|
```
|
||||||
|
末尾打 `TOTAL_SITES / FILES / SCANNED / 各标签计数`;**带标签**才拆得开噪声(同一行可同时命中 `CARD_ANY` 与 `KA12`)。
|
||||||
|
4. **该族与形似噪声号分开计数,各自给分母**:一遍扫描出 `TOTAL_SITES 420 FILES 85 SCANNED 1025`,必须再拆成**卡族 288 处 / 56 文件** 与 **里程碑噪声 132 处 / 44 文件**(`M4/M5/M6` 大片落在 `docs/芯片手册/` 参考手册与料号上下文里)。两套数字各自给分母——**「420 处待改」是错的**:把 132 处噪声算进清退范围就会去改芯片手册原文。噪声侧一律保持原样,是否清退等用户单独裁定,**不要顺手做**。
|
||||||
|
5. **派单前写一份 spec 文件**(`ci-artifacts/card_retire_spec.md`),作为各道唯一口径来源,含五块:① 背景与**用户裁定原文**;② 逐号映射表(旧号形态 → 语义短语 → 属主出处);③ 措辞规则(补短语时括号怎么降级、动不动字符串字面量、生成器归属、悬空引用怎么判);④ 硬约束(BOM 原样补回、行尾由 `.gitattributes` 写死、冻结历史行与原始证据不回改、代码头注释引用的设计约束章节保留、带 BOM 文件名单);⑤ 自检与交付格式(逐文件命中数、删了哪几行、残留数、门禁复跑结果)。`send_message` 只发路径 + 关键裁定,长中文正文不要整段塞进消息(一次串多条长消息会撑爆 120s,发出 1 条后 abort,剩余未发)。
|
||||||
|
6. **逐处替换断言命中数**(同第 7 步的 `rep(tag, old, new, want)`),且 `old` 串**从原文取、不凭记忆敲**:本仓中文引号与 ASCII 双引号混用(实测某文档里的「中文引号」其实是 ASCII `"`),凭印象写 `old` 必 `got=0`;断言抛错后先 `git status --porcelain=v1 -- <文件>` 自证文件未被别道改动,再回读该行真身重跑。删行类改动单独记 tag 并打印删了几行、行号与原行长度。多道并行时同一文件常被别道改过:`edit` 报 `file changed since it was read` → 重新 `read` 再改;报 `ReplaceFileW EIO (Win32 1175)` → 先自证文件字节完好(BOM / CRLF / `EF BF BD` 三项)再重试同一笔,不要顺手改内容。
|
||||||
|
7. **落地位置写死(这一族只有这几条兜)**:
|
||||||
|
- `docs/编号规范.md` **§4.1.1「已停发的族」表加一行**,三列齐全,第三列直写「⚠ **这一族从来没有机器面**:中文前缀不进任何编号语料(族 pattern 全是拉丁形态),而两道编号门禁已于 2026-10-28 / 29 退役 → 只有本表 + 归档换算表 + 评审三条兜;`id_aliases.json` 刻意不登记(没有机器读者,登记等于多一张没人核的表)」;
|
||||||
|
- `scripts/archive/id_gate/编号治理历史.md` **§9.3 逐号换算表**(旧号形态 → 语义短语 → 现行口径出处;本族实测 14 行 = 0、0b、1~12)+ 同篇 §9 批次段落(裁定原文与逐号结论都落在归档正文),**写明旧号永久作废、不得重新取走**;
|
||||||
|
- 表内**把形态写成「卡」+ 数字而不复述字面量**:规范本身是门禁语料,复述字面量会把「讲规则的文档」变成自己的引用站点;
|
||||||
|
- **同步叙述**:`AGENTS.md`「文档与图形」的销项条目、`docs/README.md`(例:`数据可信.md = 三项阻塞任务` → 两项)、`docs/任务规划/README.md`、`docs/任务规划/登记簿.md`——「剩余三项」这类旧口径实测归零;
|
||||||
|
- **与有识别面族的判别写进同一张表**:能被脚本按字面量匹配 → 走 `rename_ids.py` + 别名登记;匹配不到(中文前缀、纯散文号、圈号)→ 走规范换算表,**不登记别名**;
|
||||||
|
- 要机械化怎么办:得先给「中文前缀 + 数字」设计候选形态,属追溯机制改造,按 §7.3 单独开卡,**别夹在清退批里顺手做**。
|
||||||
|
8. **替换后三道二次清洗**(缺一必留疤,全部实测过):
|
||||||
|
```bash
|
||||||
|
git grep -c -F '端子 / 子板变比确认' -- . ; git grep -c -F '端子/子板变比确认' -- . # 先数各形态再归一
|
||||||
|
git grep -nE '卡[ ]*[0-9]' -- . ; echo "rc=$?" # 裸跑读 rc;1 = 归零
|
||||||
|
```
|
||||||
|
- **同句重复短语**:编号位置补上的语义短语与原句已有短语撞车 → 扫「同一行里同一短语出现 ≥2 次」(实测 12 行,如 `.pre-commit-config.yaml:401`「运行参数段(「数据可信」运行参数段交付)」、`App/algo/README.md:110`「遥测快照发布(L2 发布侧,「数据可信」遥测快照发布)」)→ 改法是**把括号里的引用降级**成不含该短语的写法(「道D 交付项」「波次」「该点 DoD 原句」),逐处断言命中 = 1,别删信息;
|
||||||
|
- **空格与引号形态归一**:同一语义短语可能有 `端子 / 子板变比确认` 与 `端子/子板变比确认` 两种形态 → **先 `git grep -c -F` 分别数**(实测带空格 8 处),再整文件归一,归一后复扫到 0(残留常藏在表格单元格里,逐行打印定位);引号形态同理;
|
||||||
|
- **固定正则复扫**:`卡[ ]*[0-9]` 全仓无输出。⚠ 判 rc 时**不要把命令接在 `| head` 之后再读 `$?`**(那拿到的是 `head` 的 0,会把「还有残留」读成「已归零」)。
|
||||||
|
9. **收尾复跑 + 清中间产物**:`check_ids.py`(plain 与 `--strict`)、`check_doc_links.py --all`、bom 与乱码两道(`pre-commit run bom-guard --files <改过的文件>`、`pre-commit run no-mojibake --files <改过的文件>`)、`git diff --check`;**纯注释/纯文档改动不算免编译**——仍跑一次 `/keil-build` 取「0 错误 0 警告」且 `Program Size` 与改前同数(**本回合真跑**,引用历史提交正文自报的四个数不算),受影响的主机组单测照跑。最后**删掉 spec 与全部扫描中间产物**,别往 `ci-artifacts/` 再堆 log/txt。
|
||||||
+115
-25
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: keil-build
|
name: keil-build
|
||||||
description: Keil MDK-ARM 命令行编译闭环(自带独立脚本 keil_build.py,纯标准库,拷到任何 Keil 项目即用):UV4 -b 构建 → "0 错误 0 警告"判定 → build_log 定位报错。封装入口约定、退出码判读、UV4 改写工程文件的入库陷阱。
|
description: "Keil MDK-ARM 命令行编译闭环(自带独立脚本 keil_build.py,纯标准库,拷到任何 Keil 项目即用),覆盖 UV4 -b 构建、「0 错误 0 警告」判定、build_log 定位报错、PowerShell 与 bash 两种调用形态及各自的 exit 2 归属、退出码与 build 锁持有者名判读(含插件树的 pw: 前缀)、exit 2 时 UV4.exe 的候选路径探测与单次调用 env 注入闭环、UV4 改写工程文件的入库陷阱、构建副产物「%SystemDrive% 字面量垃圾目录」的取证、清理与入库排除。"
|
||||||
whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0 警告、或解读 UV4 构建日志时。
|
whenToUse: "需要编译 Keil MDK 工程、跑 /keil-build 验证代码改动(只改 .c 注释也算改动)是否「0 错误 0 警告」、/keil-build 报 BUILD_ENV_ERROR 与 UV4.exe not found(退出码 2;DSH 会话看不见用户级 DTU_KEIL_UV4)、解读 UV4 构建日志、退出码 2/4 或 build 锁持有者名(含 pw: 前缀),在 bash / ops_bash 里执行 PowerShell 形态的编译命令行报 syntax error near unexpected token,或构建后 git status 冒出 %SystemDrive% 目录时。"
|
||||||
---
|
---
|
||||||
|
|
||||||
# keil-build — Keil 命令行编译(独立可运行 skill)
|
# keil-build — Keil 命令行编译(独立可运行 skill)
|
||||||
@@ -10,21 +10,82 @@ whenToUse: 需要编译 Keil MDK 工程、验证代码改动是否 0 错误 0
|
|||||||
(纯标准库,任意 python3;拷整个 skill 目录到别的 Keil 项目即可用,不依赖仓库脚本)。
|
(纯标准库,任意 python3;拷整个 skill 目录到别的 Keil 项目即可用,不依赖仓库脚本)。
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# cwd = 项目根目录
|
# cwd = 项目根目录;PowerShell 形态(AGENTS「构建与验证」的编译入口)
|
||||||
& $PY ".dsh\skills\keil-build\scripts\keil_build.py"
|
& $PY ".dsh\skills\keil-build\scripts\keil_build.py"
|
||||||
# 覆盖:--project X.uvprojx --uv4 UV4.exe --target 名字 --log 路径 --timeout 600
|
# 覆盖:--project X.uvprojx --uv4 UV4.exe --target 名字 --log 路径 --timeout 600
|
||||||
```
|
```
|
||||||
|
|
||||||
解析顺序(命令行 > 项目档案 `.serialtest/config.json` > 环境 > 探测):
|
⚠ **改本文件 frontmatter 要么整值加双引号,要么一个 ASCII 冒号都别写**(2026-10-30 这份就因此整份加载失败)。
|
||||||
工程 `uvprojx`(档案缺省时自动搜 cwd 下唯一 `*.uvprojx`);UV4 `uv4` >
|
DSH 用 `yaml` 包的 `parse()` 读 frontmatter(`@deepseek-ai/dsh-skill-filesystem` 的 `parseFrontmatter()`),
|
||||||
`$DSH_KEIL_UV4` > `$DTU_KEIL_UV4` > PATH(**显式设了但路径无效 = 立即报错,不静默回退**)。
|
plain scalar 里出现 **ASCII「冒号 + 空格」**(例如把报错原文 `BUILD_ENV_ERROR: UV4.exe not found` 或
|
||||||
退出码:`0`=PASS(0错0警) / `1`=构建失败(打印报错行)/ **`2`=环境错误**(UV4 找不到或
|
`pw: 前缀` 直接写进 description)就报 `Nested mappings are not allowed in compact mappings at line 2,
|
||||||
路径无效、工程找不到、档案解析失败)/ `3`=用法错误 / `4`=build 锁被占用(并发 `-b` 不可行)。
|
column 14` → **技能整份不加载**(`skill()` 读不到、也不进技能目录)。**`js-yaml` 能吃下去** ——
|
||||||
|
拿它自检会假绿,必须用 DSH 自带的那个解析器验。另 — 值首字符不能是 `%`(directive indicator)或 `&`
|
||||||
|
(anchor),双引号里的 `%SystemDrive%` 之类不受影响。落盘后**让 DSH 真加载一次**再收口。
|
||||||
|
|
||||||
> 2026-10-12 修正:旧实现把环境错误写成 `raise SystemExit("...")` → 实际退出码是 **1**,
|
## 调用形态:先认执行器是哪个 shell(2026-10-26 实测)
|
||||||
> 而用法错误由 argparse 抛 `SystemExit(2)` → 恰好占了"环境错误"的位置。故 `AGENTS.md` /
|
|
||||||
> `new-machine-setup` 里写的"报 exit 2 = 环境问题"当时**不成立**。现按文档口径改正:
|
- 同一条编译命令,PowerShell 与 bash 是两种写法:bash 侧要去掉前导 & 并把路径改正斜杠(照抄 AGENTS 的 PowerShell 形态进 ops_bash 会语法错秒退 exit 2)
|
||||||
> 环境错误 → 2(`BuildEnvError`),用法错误 → 3(覆盖 argparse 的 `error()`)。
|
- ⚠ 那个 exit 2 不是脚本的环境错误 2:stderr 含 syntax error near unexpected token = 写法问题,换 bash 形态重跑,别去查 DTU_KEIL_UV4、别当环境错误排障
|
||||||
|
- 后台跑(run_in_background: true)要自己收码,且别把脚本接进管道:实测 $? 取到的是 tail 的码(恒 0);要看报错行就单独 grep MDK-ARM/build_log.txt
|
||||||
|
- 解析顺序:命令行 > 项目档案 .serialtest/config.json > 环境 > 探测;UV4 显式设了但路径无效 = 立即报错,不静默回退
|
||||||
|
- 退出码 0=PASS(0错0警) / 1=构建失败 / 2=环境错误 / 3=用法 / 4=build 锁被占用;2026-10-12 修正过 2 与 3 的归属,该篇末尾记了前后口径
|
||||||
|
- **bash / `ops_bash` 侧「构建到底过没过」的一次自查**(2026-10-26 实测口径):先 `cd` 到仓库根(或该 worktree 根),跑 `.venv/Scripts/python.exe .dsh/skills/keil-build/scripts/keil_build.py; echo BUILD_RC=$?` —— 正斜杠、**无前导 `&`**,退出码归 python、不归 bash;再单独读日志计数 `grep -a -E "Error\(s\)|error:|Warning\(s\)" MDK-ARM/build_log.txt`。报结论要三样齐 —— **`BUILD_RC=0` + `UV4_RC: 0` + `BUILD_OK: 0 errors, 0 warnings`**(脚本自己判「无 `error:` 行 且 尾行含 `0 Error(s), 0 Warning(s)`」);bash 吐的 `syntax error near unexpected token` **既不是构建通过也不是构建失败**,那是写法问题,别拿它当构建结论报。
|
||||||
|
> 本节全文(逐字搬移、未改写,含命令与实测数字)→ [executor-shell-forms.md](reference/executor-shell-forms.md)。动手前先读那篇。
|
||||||
|
|
||||||
|
## exit 2 实战闭环:`UV4.exe not found` 的探测 → 注入 → 复核(2026-10-30 实测)
|
||||||
|
|
||||||
|
**先定性,别动源码**:脚本报
|
||||||
|
`BUILD_ENV_ERROR: UV4.exe not found (--uv4 / config 'uv4' / $DSH_KEIL_UV4 / $DTU_KEIL_UV4 / PATH)`
|
||||||
|
且**退出码 2** = **环境错,不是构建失败**——本次 **UV4 根本没启动、`MDK-ARM/build_log.txt` 也没被改写**,
|
||||||
|
日志里看到的是上一次的。此时去改 `.c`/`.h` 或怀疑代码,就是把环境问题当编译问题排。
|
||||||
|
(会话为什么看不见:DSH 会话的 shell 由常驻服务派生,**服务启动之后**设的用户级变量不会自动进来;
|
||||||
|
`run_code` 起的 Node 进程 `process.env` 干脆是空的——AGENTS.md「会话不继承用户级变量」同记此坑。
|
||||||
|
所以**变量"已设但本会话看不见"是常态**,不是用户没设。)
|
||||||
|
|
||||||
|
1. **探测候选安装路径**(逐个 `fs.existsSync` / `test -f`,命中即取绝对路径)——
|
||||||
|
`C:/Keil_v5/UV4/UV4.exe`、`C:/Keil/UV4/UV4.exe`、`D:/Keil_v5/UV4/UV4.exe`、`D:/Keil/UV4/UV4.exe`、
|
||||||
|
`C:/Program Files/Keil_v5/UV4/UV4.exe`、`D:/Program Files/Keil_v5/UV4/UV4.exe`
|
||||||
|
(2026-10-30 实测只命中 `C:/Keil_v5/UV4/UV4.exe`,其余五个都不存在)。
|
||||||
|
```bash
|
||||||
|
for p in 'C:/Keil_v5/UV4/UV4.exe' 'C:/Keil/UV4/UV4.exe' 'D:/Keil_v5/UV4/UV4.exe' \
|
||||||
|
'D:/Keil/UV4/UV4.exe' 'C:/Program Files/Keil_v5/UV4/UV4.exe' 'D:/Program Files/Keil_v5/UV4/UV4.exe'; do
|
||||||
|
[ -f "$p" ] && echo "HIT $p"; done
|
||||||
|
```
|
||||||
|
2. **读用户级来源确认"值本来就对"**(同一轮里顺手做掉,别只靠猜路径):
|
||||||
|
bash / Node 侧 `reg query HKCU\Environment`(整表打出来再找 `DTU_KEIL_UV4` / `DSH_KEIL_UV4`),
|
||||||
|
PowerShell 侧 `[Environment]::GetEnvironmentVariable('DTU_KEIL_UV4','User')`。
|
||||||
|
有值 → 直接用它;没值/值指向旧目录 → 用第 1 步的命中路径。
|
||||||
|
⚠ **不替用户改全局**:不要 `setx`、不要 `SetEnvironmentVariable(...,'User')`——那是用户自己在真终端里
|
||||||
|
做的事(`docs/环境与部署/本地工具路径环境变量方案.md`);本闭环只解决"这一次构建怎么跑起来",
|
||||||
|
报告里也必须这么写。
|
||||||
|
3. **只给这一次调用注入**再重跑(两种等价形态,二选一):
|
||||||
|
```js
|
||||||
|
// run_code:options.env 是**整体替换**,所以要连 SystemRoot / Path / USERPROFILE 一起给
|
||||||
|
const { execSync } = await import('node:child_process');
|
||||||
|
execSync('".venv/Scripts/python.exe" .dsh/skills/keil-build/scripts/keil_build.py', {
|
||||||
|
cwd: MAIN, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024,
|
||||||
|
env: { SystemRoot: 'C:\\Windows', Path: 'C:\\Windows;C:\\Windows\\System32',
|
||||||
|
USERPROFILE: 'C:\\Users\\<你>', DTU_KEIL_UV4: 'C:\\Keil_v5\\UV4\\UV4.exe' } });
|
||||||
|
```
|
||||||
|
- bash / `ops_bash` 侧的等价写法 = 显式参数 `--uv4 'C:\Keil_v5\UV4\UV4.exe'`(值含空格要整体加引号)。
|
||||||
|
- `run_code` 的 `timeoutMs` 给足:注入后这次构建实测 **10 s** 跑完,但首次冷构建更长(我给的 500000)。
|
||||||
|
- **别顺手加 `--no-lock`**:注入重跑时锁链照旧(`LOCK: 已获取 build 锁(task=build-cli)` → `已释放锁`)。
|
||||||
|
4. **复核口径**(三样齐了才算真跑过,缺一样就是没构建):脚本回显
|
||||||
|
`UV4: <绝对路径> (<来源>)`(来源随注入方式变:`$DTU_KEIL_UV4` 或 `--uv4`)→
|
||||||
|
`UV4_RC: 0` → `BUILD_OK: 0 errors, 0 warnings`,且**外层退出码 0**(自己包 execSync 就显式把码打出来,
|
||||||
|
别接管道,见「调用形态」)。报告里写清"本次注入 + 来源",**不得声称已永久设置系统/用户环境变量**。
|
||||||
|
|
||||||
|
- ⚠ 注入的若是**注册表里那个陈旧值**,会撞同族的另一种 2:
|
||||||
|
`BUILD_ENV_ERROR: $DTU_KEIL_UV4 is set but not a file: … (fix the value; no silent fallback)`
|
||||||
|
—— 值在但指向旧盘符/旧目录名(本仓踩过 `D:\Code\DTU 程序\…` 搬迁)。脚本**刻意不静默回退**,
|
||||||
|
回到第 1 步用探测命中的路径覆盖它,别去猜目录、更别改脚本让它"猜 C 盘"(设计上明确禁止从旧路径猜)。
|
||||||
|
- ⚠ 本闭环只回答"UV4 在哪儿",**不回答"调用写法对不对"**:从 bash 侧照抄 PowerShell 的 `&` 也是 exit 2,
|
||||||
|
但 stderr 是 `syntax error near unexpected token`(见「调用形态」)。**先看 stderr 再决定排障方向**,
|
||||||
|
两种 2 的处置完全不同。
|
||||||
|
- **改 `.c` 注释(哪怕只改一行中文注释)也必须跑完这道构建验证**:2026-10-30 的实测就是
|
||||||
|
「改注释 → `/keil-build` → 吃 rc=2 → 探测+注入 → 10 s 通过」。注释类坑(BOM 被剥 → `#870-D`、
|
||||||
|
注释里写 `**` 路径 → 9-D)只在**重编译那个 TU 时**才暴露,"以前 0 警告"不代表安全。
|
||||||
|
|
||||||
## build 锁:脚本自持自释(2026-10-12)
|
## build 锁:脚本自持自释(2026-10-12)
|
||||||
|
|
||||||
@@ -35,7 +96,7 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被
|
|||||||
|
|
||||||
| 开关 | 作用 |
|
| 开关 | 作用 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `--lock-task <ID>` | 持有者名。默认 `$DTU_TASK_ID` → 从 cwd 的 `_wt/<ID>-<slug>/` 反推 `<ID>` → `build-cli` |
|
| `--lock-task <ID>` | 持有者名。默认 `$DTU_TASK_ID` → 从 cwd 反推:`_wt/<ID>-<slug>/` → `<ID>`(`@task` 那批);`.dsh-worktrees/worktree/<name>/` → `pw:<name>`(DSH worktree **插件**建的树,`plugin_tree_owner()`)→ 都没有才落 `build-cli` |
|
||||||
| `--lock-wait <N>` | 锁被占时等待 N 秒(默认 0 = 立即失败,退出码 4) |
|
| `--lock-wait <N>` | 锁被占时等待 N 秒(默认 0 = 立即失败,退出码 4) |
|
||||||
| `--no-lock` | 完全不加锁(危险:并发 `-b` 互相干扰) |
|
| `--no-lock` | 完全不加锁(危险:并发 `-b` 互相干扰) |
|
||||||
| `DTU_LOCK_HELD=build` | 环境变量:**调用方已持锁**时设它,本脚本跳过自持自释(`task-worktree done` 的门禁即如此用) |
|
| `DTU_LOCK_HELD=build` | 环境变量:**调用方已持锁**时设它,本脚本跳过自持自释(`task-worktree done` 的门禁即如此用) |
|
||||||
@@ -46,39 +107,68 @@ Agent 手工 `lock acquire build` 后忘了 `release`,锁要挂到 TTL 才被
|
|||||||
- 锁工具 = 兄弟 skill `task-worktree/scripts/task_worktree.py`;**找不到则降级为不加锁
|
- 锁工具 = 兄弟 skill `task-worktree/scripts/task_worktree.py`;**找不到则降级为不加锁
|
||||||
并告警**(保持本 skill 可独立搬运)。
|
并告警**(保持本 skill 可独立搬运)。
|
||||||
- ⚠ 从 `_wt/<ID>-<slug>/` 反推持有者名,意味着"手工 `lock acquire build --task <ID>`
|
- ⚠ 从 `_wt/<ID>-<slug>/` 反推持有者名,意味着"手工 `lock acquire build --task <ID>`
|
||||||
后再跑 `@build`"仍**幂等**(同名即视为自己持有),不会自我死锁。
|
后再跑 `/keil-build`"仍**幂等**(同名即视为自己持有),不会自我死锁。
|
||||||
|
- **退出码 4 的持有者名带 `pw:`(如 `pw:refactor-logging`)= 插件在 `.dsh-worktrees/worktree/<name>/` 里跑的构建占着 UV4,不是某个 `@task` 任务 ID**:
|
||||||
|
插件树**没有台账**,别拿它去 `lock release <ID>`(释放不掉别人的锁)、更别去杀 UV4 进程。正解 =
|
||||||
|
等那次构建自己 `finally` 释放(自持自释;崩溃则按自报 TTL 兜底),或本次加 `--lock-wait <N>` 排队;
|
||||||
|
想知道是哪棵树的谁:`"$PY" "$TW" plugin list`(缺联接再 `plugin adopt <name>`)→ 细节归
|
||||||
|
`task-worktree` skill F 节(插件桥)。
|
||||||
- **只 build 锁自动;`bench` 锁仍须 Agent 手工持**(它是**会话作用域**,跨
|
- **只 build 锁自动;`bench` 锁仍须 Agent 手工持**(它是**会话作用域**,跨
|
||||||
烧录→`@test`→探针多条命令,逐命令自动释放会让别的任务插进来扰动设备)。
|
烧录→`/serial-boot-test`→探针多条命令,逐命令自动释放会让别的任务插进来扰动设备)。
|
||||||
|
|
||||||
|
## 构建副产物:`%SystemDrive%` 字面量垃圾目录(2026-10-26 两次复发 → 2026-10-28 定性并加守卫)
|
||||||
|
|
||||||
|
- 症状:/keil-build(或 check_font_charset.py 这类 fontTools 工具)跑完,仓库根冒出未跟踪的 %SystemDrive%/ProgramData/…/Caches/(几个 .db,约 1 MB)= 本机缓存被按字面量原地复刻
|
||||||
|
- 根因(2026-10-28 实测):run_code 的 Node 进程 process.env 是空的 → known-folder 可展开串展不开变成相对路径 → 按当前目录建在仓库根;同族还会冒出 %USERPROFILE%/AppData/Local/Temp
|
||||||
|
- 清理有安全阀:⚠ 绝不用 cmd / PowerShell 直接删(rd /s /q %SystemDrive% 会被展开成 C:);只用 bash 引号形态,先 find '%SystemDrive%' -type l 预检再 rm -rf --。它是工具副产物,直接删、别 stash、更别提交
|
||||||
|
- 两层防线均已落地可核验:.gitignore:167 挡入库 + 脚本 harden_windows_env() 回填环境变量、check_percent_literal_junk() 只告警不自动删;守卫是兜底不是许可,/keil-build 仍要走 ops_bash / PowerShell
|
||||||
|
- **取证在前,处置在后**(2026-10-28 实测,别一上来 `rm -rf`):`ls -laR -- "%SystemDrive%"` + `du -sh "%SystemDrive%"` 枚举内容与体积,再 `find "%SystemDrive%" -printf "%T@ %p\n" | sort -rn | head -3` 看**最新 mtime** —— 最近几分钟还在被重建 = 环境仍残缺(脚本会打 `WARN: ENV_SPARSE:`),先断源头(换 `ops_bash`/PowerShell 跑,或按「UV4.exe not found」一节把 `env` 传全)再删,否则删完立刻长回来;顺带确认仓库里只有这一个该名字的东西(`find . -maxdepth 1 -name "*SystemDrive*"`)
|
||||||
|
- **要留证据就搬走、别就地删** —— ⚠ **shell 的 `mv` 搬不动它**:`mv "%SystemDrive%/..."` 报 `cannot stat`(含 `%` 的目录名被当变量展开 / 吃掉,路径根本对不上)。改用 Python 遍历搬到**仓库外**的临时目录 —— `os.walk` + `shutil.move`(或 `glob.glob("%SystemDrive%/*")` 逐个 `shutil.move` 到 `os.environ["TEMP"]` 下);确认是纯工具副产物(几个 `.db` 缓存)才 `rm -rf --`
|
||||||
|
- **入库排除要落成条目并核验**:补 `.gitignore` 前先 `grep -n "SystemDrive" .gitignore` 定位精确原文行再 Edit(凭记忆写 `old_string` 必不匹配,2026-10-28 踩过)。本仓条目 = `.gitignore:167` 的 `%SystemDrive%/`,上面那段注释就是复发证据(新增条目也照此**写明第几次复发、谁造的**)。收尾核验 —— `git status --porcelain` 里该目录零命中(且别的未跟踪产物一个没少)、`git check-ignore -v` 落在那一行、`git add -A --dry-run` 零命中;**绝不提交**(把本机缓存塞进历史)
|
||||||
|
> 本节全文(逐字搬移、未改写,含命令与实测数字)→ [systemdrive-junk-dir.md](reference/systemdrive-junk-dir.md)。动手前先读那篇。
|
||||||
|
|
||||||
## 本仓库
|
## 本仓库
|
||||||
|
|
||||||
**`@build` = 本 skill**(2026-10-10 起为仓库文档口径):在仓库根目录跑
|
**本 skill 的调用名 = `/keil-build`**(**技能调用名不是 shell 命令**——原样敲进终端是 command not found / syntax error,可执行形态永远是下面这条命令行):在仓库根目录跑
|
||||||
`& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py`。
|
`& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py`——
|
||||||
判定口径(与原 `MDK-ARM/build.bat` 同款;该文件已于 2026-10-10 删除,本 skill 即 `@build`):
|
**bash / `ops_bash` 下要写成 `'.venv/Scripts/python.exe' .dsh/skills/keil-build/scripts/keil_build.py`**
|
||||||
|
(原因见「调用形态」)。
|
||||||
|
判定口径(与原 `MDK-ARM/build.bat` 同款;该文件已于 2026-10-10 删除,本 skill 即编译入口):
|
||||||
先删旧日志 → `-b -j0 -o` → 无 `error:` 且含 `0 Error(s), 0 Warning(s)`。
|
先删旧日志 → `-b -j0 -o` → 无 `error:` 且含 `0 Error(s), 0 Warning(s)`。
|
||||||
本 skill 另有超时保护、UV4 多级解析链(含 PATH 兜底)、失败只打报错行。
|
本 skill 另有超时保护、UV4 多级解析链(含 PATH 兜底)、失败只打报错行。
|
||||||
环境变量 `DTU_KEIL_UV4`(用户级,指向 UV4.exe)。
|
环境变量 `DTU_KEIL_UV4`(用户级,指向 UV4.exe)——注意**它设了不等于本会话能看见**,仍报退出码 2 时走「exit 2 实战闭环」那一节,别先去找用户。
|
||||||
|
|
||||||
## 铁律与陷阱(实测教训)
|
## 铁律与陷阱(实测教训)
|
||||||
|
|
||||||
- **任何代码改动后必须先编译验证(0 错误 0 警告)再谈烧录/提交**——编译不过就进
|
- **任何代码改动后必须先编译验证(0 错误 0 警告)再谈烧录/提交**——编译不过就进
|
||||||
硬件闭环只会把编译错误伪装成运行问题。
|
硬件闭环只会把编译错误伪装成运行问题。**只改 `.c` 里一行中文注释也算改动**(2026-10-30 实测:
|
||||||
- **警告也算失败**:判 PASS 只认退出码 0,别只看"有没有 error:"。
|
改注释 → `/keil-build` → 环境错 → 探测注入 → 10 s 通过):BOM/嵌套注释这类坑只在重编译那个 TU 时才暴露。
|
||||||
|
- **警告也算失败**:判 PASS 只认退出码 0,别只看"有没有 error:"(取退出码的管道陷阱见「调用形态」)。
|
||||||
- **`MDK-ARM/*.uvprojx` 每次构建后被 UV4 自动改写,改动一律不入库**:UV4 会把工程
|
- **`MDK-ARM/*.uvprojx` 每次构建后被 UV4 自动改写,改动一律不入库**:UV4 会把工程
|
||||||
XML 归一化成本机风格(`<Tag />` → `<Tag></Tag>`、XML 声明加 `standalone="no"`)并把
|
XML 归一化成本机风格(`<Tag />` → `<Tag></Tag>`、XML 声明加 `standalone="no"`)并把
|
||||||
`PackID` 降到**本机装的 DFP 版本**(属降级——提交后会把别人的 Pack 版本一起改掉)。
|
`PackID` 降到**本机装的 DFP 版本**(属降级——提交后会把别人的 Pack 版本一起改掉)。
|
||||||
判断:`git diff` 里只见 XML 格式/`PackID` 差异而**无源文件挂载增删** →
|
判断:`git diff` 里只见 XML 格式/`PackID` 差异而**无源文件挂载增删** →
|
||||||
`git checkout -- <uvprojx>` 丢弃;只有真的增删了源文件(grep 文件名计数变化)才提交。
|
`git checkout -- <uvprojx>` 丢弃;只有真的增删了源文件(grep 文件名计数变化)才提交。
|
||||||
同目录的 `*.uvoptx` / `*.uvguix.*` 是 IDE 个人设置,已在 `.gitignore` 里,不会露出来。
|
同目录的 `*.uvoptx` / `*.uvguix.*` 是 IDE 个人设置,已在 `.gitignore` 里,不会露出来。
|
||||||
|
- **构建后仓库根冒出 `%SystemDrive%/`** = UV4 / 字体工具在残缺环境(缺 `SystemDrive`、`ProgramData`)
|
||||||
|
里按**未展开的 known-folder 字面量**建出来的**相对路径**目录,不是任何人在飞的改动:
|
||||||
|
`rm -rf -- '%SystemDrive%'`(bash 引号形态)删掉即可,**严禁 `rd /s /q %SystemDrive%`——cmd 会把它
|
||||||
|
展开成 `C:`**;`.gitignore` 已兜住 + 脚本已加 `ENV_SPARSE`/`JUNK_DIR` 守卫 → 见「构建副产物」一节。
|
||||||
- 报错定位读日志别只看终端回显:本仓库 = `MDK-ARM\build_log.txt`。
|
- 报错定位读日志别只看终端回显:本仓库 = `MDK-ARM\build_log.txt`。
|
||||||
- **exit 2 先分清"真没设"还是"会话没继承"**(2026-10-10 实测):agent 的 shell 由
|
- **exit 2 先分三种再动手**(2026-10-10 / 2026-10-26 / 2026-10-30 实测):① **bash 语法错**——stderr 有
|
||||||
常驻服务派生,服务启动**之后**设置的用户级环境变量不会自动进来。查
|
`syntax error near unexpected token`,是把 PowerShell 形态丢进了 `ops_bash`,换写法即可;
|
||||||
`[Environment]::GetEnvironmentVariable('DTU_KEIL_UV4','User')`,有值就
|
② **真没设** `DTU_KEIL_UV4`;③ **已设但本会话看不见**(agent 的 shell 由常驻服务派生,服务启动**之后**
|
||||||
`$env:DTU_KEIL_UV4 = <该值>` 注入本会话再跑,别急着让用户去设。
|
设的用户级变量不会进来;`run_code` 的 `process.env` 直接是空的)。
|
||||||
|
②③ 一律走 **「exit 2 实战闭环」** 那一节:探测候选路径 + 读 `HKCU\Environment`,
|
||||||
|
再**只往那一次调用**的 env / `--uv4` 里注入命中路径重跑——别急着让用户去设,也别替他改全局。
|
||||||
- 编码类报错(`#870-D: invalid multibyte character sequence` 等)先怀疑源文件
|
- 编码类报错(`#870-D: invalid multibyte character sequence` 等)先怀疑源文件
|
||||||
BOM/编码被改写,而不是代码本身(见仓库 AGENTS.md 编码铁律)。
|
BOM/编码被改写,而不是代码本身(见仓库 AGENTS.md 编码铁律)。
|
||||||
|
|
||||||
## 与其它 skill 的衔接
|
## 与其它 skill 的衔接
|
||||||
|
|
||||||
|
- **build 锁的对面是谁**(`BUILD_LOCK_BUSY`、退出码 4、`pw:<name>` 插件树、`@task plugin list` / `plugin adopt`、`_wt/` 台账与合并回收)归 `task-worktree` skill(F 节 = DSH worktree 插件桥);换机/新克隆时 `DTU_*` 工具路径变量的设立与「会话不继承」坑 = `new-machine-setup` skill。
|
||||||
- 烧录/回读 = `stlink-flash` skill;串口闭环 = `serial-boot-test` skill
|
- 烧录/回读 = `stlink-flash` skill;串口闭环 = `serial-boot-test` skill
|
||||||
(其档案 `flash_cmd`/`readback_cmd` 即 stlink-flash 的入口命令)。
|
(其档案 `flash_cmd`/`readback_cmd` 即 stlink-flash 的入口命令)。
|
||||||
|
- 收尾固定链(2026-10-26 实证):`/keil-build` → `scripts/ram_baseline.py --update` →
|
||||||
|
体积门禁盖戳 → 内存数字回填与 `--scan` → 复跑 `ci_local` 静态段,
|
||||||
|
直到钩子全绿且 0 错误 0 警告(RAM/栈口径见 `mem-budget` skill)。
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 调用形态:先认执行器是哪个 shell(2026-10-26 实测)(自 SKILL.md 外置)
|
||||||
|
|
||||||
|
> 本文由 keil-build 的 SKILL.md「调用形态:先认执行器是哪个 shell(2026-10-26 实测)」一节逐字外置而来(渐进展开:主文件只留索引与指针)。步骤号沿用原文,未重编。
|
||||||
|
|
||||||
|
同一条编译命令,**PowerShell 与 bash 是两种写法**;`ops_bash` / `run_code` 起的是
|
||||||
|
`/usr/bin/bash -c`,**不认 PowerShell 形态**,照抄仓库 `AGENTS.md` 会直接语法错退出。
|
||||||
|
|
||||||
|
| 执行器 | 正确写法(cwd = 仓库根) |
|
||||||
|
|---|---|
|
||||||
|
| PowerShell | `& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py` |
|
||||||
|
| bash(`ops_bash`) | `'.venv/Scripts/python.exe' .dsh/skills/keil-build/scripts/keil_build.py` |
|
||||||
|
|
||||||
|
- bash 侧**去掉前导 `&`**(bash 里 `&` 是"丢后台",不是调用操作符)、**路径改正斜杠**
|
||||||
|
(`\Scripts\python.exe` 的反斜杠在 bash 是转义,路径根本解析不出来)。
|
||||||
|
- 实测(step 41→42):把 PowerShell 形态丢进 `ops_bash` 后台任务 → 任务**秒退**、
|
||||||
|
`exit 2`,stderr 只有这一行:
|
||||||
|
|
||||||
|
```text
|
||||||
|
/usr/bin/bash: -c: line 1: syntax error near unexpected token `&'
|
||||||
|
```
|
||||||
|
|
||||||
|
- ⚠ **这个 2 不是脚本的环境错误 2**:脚本自己报环境错误会打出 `BuildEnvError` /
|
||||||
|
中文诊断(UV4 找不到、工程找不到、档案解析失败),而语法错**连解释器都没启动**,
|
||||||
|
`MDK-ARM/build_log.txt` 不会被改写。判据:stderr 含 `syntax error near unexpected token`
|
||||||
|
= 写法问题 → 换 bash 形态重跑,**别去查 `DTU_KEIL_UV4`、别当环境错误排障**。
|
||||||
|
- 后台跑(`run_in_background: true`)时要自己收码,且**别把脚本接进管道**:
|
||||||
|
`'.venv/Scripts/python.exe' .dsh/skills/keil-build/scripts/keil_build.py; rc=$?; echo RC=$rc`。
|
||||||
|
实测 `脚本 | tail -6; echo RC=$?` 里的 `$?` 是 `tail` 的退出码(恒 0),
|
||||||
|
拿它当 PASS/FAIL 会永远"绿";`set -o pipefail` 能透出非零码,但尾巴上挂的 `grep`
|
||||||
|
无匹配时同样报 1,也不可信。要看报错行就单独 grep 日志:
|
||||||
|
`grep -a -E 'Error|error:|Warning' MDK-ARM/build_log.txt`。
|
||||||
|
|
||||||
|
解析顺序(命令行 > 项目档案 `.serialtest/config.json` > 环境 > 探测):
|
||||||
|
工程 `uvprojx`(档案缺省时自动搜 cwd 下唯一 `*.uvprojx`);UV4 `uv4` >
|
||||||
|
`$DSH_KEIL_UV4` > `$DTU_KEIL_UV4` > PATH(**显式设了但路径无效 = 立即报错,不静默回退**)。
|
||||||
|
退出码:`0`=PASS(0错0警) / `1`=构建失败(打印报错行)/ **`2`=环境错误**(UV4 找不到或
|
||||||
|
路径无效、工程找不到、档案解析失败)/ `3`=用法错误 / `4`=build 锁被占用(并发 `-b` 不可行)。
|
||||||
|
**收到 2 先确认它是脚本打的还是 shell 语法错打的**(见上节),再走"环境错误"排障。
|
||||||
|
|
||||||
|
> 2026-10-12 修正:旧实现把环境错误写成 `raise SystemExit("...")` → 实际退出码是 **1**,
|
||||||
|
> 而用法错误由 argparse 抛 `SystemExit(2)` → 恰好占了"环境错误"的位置。故 `AGENTS.md` /
|
||||||
|
> `new-machine-setup` 里写的"报 exit 2 = 环境问题"当时**不成立**。现按文档口径改正:
|
||||||
|
> 环境错误 → 2(`BuildEnvError`),用法错误 → 3(覆盖 argparse 的 `error()`)。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
# 构建副产物:`%SystemDrive%` 字面量垃圾目录(2026-10-26 两次复发 → 2026-10-28 定性并加守卫)(自 SKILL.md 外置)
|
||||||
|
|
||||||
|
> 本文由 keil-build 的 SKILL.md「构建副产物:`%SystemDrive%` 字面量垃圾目录(2026-10-26 两次复发 → 2026-10-28 定性并加守卫)」一节逐字外置而来(渐进展开:主文件只留索引与指针)。步骤号沿用原文,未重编。
|
||||||
|
|
||||||
|
**症状**:`/keil-build`(或 `scripts/check_font_charset.py` 这类 fontTools 字体工具)跑完,仓库根
|
||||||
|
冒出未跟踪目录 `%SystemDrive%/ProgramData/Microsoft/Windows/Caches/`,里面是几个 `.db`
|
||||||
|
(`cversions.2.db` + `{6AF0698E-…}` 图标缓存 + `{DDF571F2-…}` 字体缓存,合计约 1 MB)——
|
||||||
|
就是本机 `C:\ProgramData\Microsoft\Windows\Caches` 那份缓存被**按字面量原地复刻**了一份。
|
||||||
|
|
||||||
|
**根因(2026-10-28 实测)**:Windows 的 known-folder 路径在注册表里是**可展开串**;展开不了,
|
||||||
|
它就变成一个**相对路径**,于是按「当前目录」(= 仓库根)建出来。三条现场证据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
reg.exe query HKCU\Environment # 本机 TMP/TEMP = REG_EXPAND_SZ %USERPROFILE%\AppData\Local\Temp
|
||||||
|
env | wc -l # ops_bash 里 = 74(完整环境)
|
||||||
|
node -e "console.log(Object.keys(process.env).length)" # run_code 里 = 0(空环境!)
|
||||||
|
```
|
||||||
|
|
||||||
|
`run_code` 的 Node 进程 `process.env` 是**空的**,从它里面 `spawn` 出去的 UV4 / 字体脚本就拿不到
|
||||||
|
`SystemDrive`/`ProgramData`/`ALLUSERSPROFILE`;实测 `os.path.expandvars("%SystemDrive%\ProgramData")`
|
||||||
|
原样返回、且 `os.path.isabs()` = **False** → 目录就长在工作区里(同族还会冒出
|
||||||
|
`%USERPROFILE%/AppData/Local/Temp`)。**正解不是「让用户去设环境变量」**,是别在残缺环境里跑构建(见第 2 条)。
|
||||||
|
|
||||||
|
**清理(有安全阀)**:
|
||||||
|
|
||||||
|
- ⚠ **绝不用 cmd / PowerShell 直接删**:`rd /s /q %SystemDrive%` 会被 cmd **展开成 `C:`**——
|
||||||
|
等于对整块系统盘下令递归强删且不带确认。
|
||||||
|
- ✅ 只用 bash 引号形态(`%` 在 bash 里没有特殊含义):先 `find '%SystemDrive%' -type l` 确认不是
|
||||||
|
reparse point / 符号链接,再 `rm -rf -- '%SystemDrive%'`;或 Node
|
||||||
|
`fs.rmSync("%SystemDrive%", { recursive: true, force: true })`。
|
||||||
|
- 它是**工具副产物**,不是谁在飞的改动——直接删,别 stash、更别提交(提交它 = 把本机缓存塞进历史)。
|
||||||
|
|
||||||
|
**让它不再污染工作区(两层,均已落地、可核验)**:
|
||||||
|
|
||||||
|
1. **进不了版本库**:`.gitignore:167` 的 `%SystemDrive%/`(2026-10-26 入库,注释里就写着复发证据)。
|
||||||
|
核验三步(2026-10-28 复跑全绿):造一份同名目录 + `.db` → `git status --porcelain` 零命中 →
|
||||||
|
`git check-ignore -v <路径>` 落在 `.gitignore:167:%SystemDrive%/` → `git add -A --dry-run` 零暂存。
|
||||||
|
2. **根本别再长出来**:本 skill 脚本 2026-10-28 起在拉起 UV4 前跑 `harden_windows_env()`——回填
|
||||||
|
`SystemDrive`/`SystemRoot`/`windir`/`ComSpec`/`ProgramData`/`ALLUSERSPROFILE`/`ProgramFiles` 与
|
||||||
|
`USERPROFILE`/`APPDATA`/`LOCALAPPDATA`/`TMP`/`TEMP`,就地展开环境里未展开的 `%VAR%` 字面量,
|
||||||
|
并把 `TMP`/`TEMP` 指到真实目录;命中即打 `WARN: ENV_SPARSE:`。构建后再跑
|
||||||
|
`check_percent_literal_junk()`,看到 `%SystemDrive%`/`%USERPROFILE%` 一类字面量目录就打
|
||||||
|
`WARN: JUNK_DIR:`(**只告警不自动删**——删目录不该由构建脚本代劳)。守卫是兜底不是许可:
|
||||||
|
**`/keil-build` 仍要走 `ops_bash` / PowerShell(继承完整环境)**,非在 Node 里 spawn 就把 `env` 传全。
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
(环境变量**显式设了但路径无效 = 立即报错,不静默回退**)
|
(环境变量**显式设了但路径无效 = 立即报错,不静默回退**)
|
||||||
日志 --log > 档案 "build_log" > <工程目录>\\build_log.txt
|
日志 --log > 档案 "build_log" > <工程目录>\\build_log.txt
|
||||||
|
|
||||||
判定口径(实测教训沉淀;原仓库 MDK-ARM/build.bat 已删除,本脚本即 @build):
|
判定口径(实测教训沉淀;原仓库 MDK-ARM/build.bat 已删除,本脚本即仓库编译入口):
|
||||||
1. 先删旧日志——UV4 -o 只在真跑了构建才写日志,残留旧日志会被当成本次结果
|
1. 先删旧日志——UV4 -o 只在真跑了构建才写日志,残留旧日志会被当成本次结果
|
||||||
2. UV4 单实例:IDE 开着时 -b 可能被转发给 IDE(退出码丢失)→ 只 WARN 不杀进程
|
2. UV4 单实例:IDE 开着时 -b 可能被转发给 IDE(退出码丢失)→ 只 WARN 不杀进程
|
||||||
3. PASS = 日志存在 + 无 "error:"(大小写不敏感)+ 含 "0 Error(s), 0 Warning(s)"
|
3. PASS = 日志存在 + 无 "error:"(大小写不敏感)+ 含 "0 Error(s), 0 Warning(s)"
|
||||||
@@ -64,6 +64,117 @@ class BuildEnvError(Exception):
|
|||||||
"""环境/前置错误(UV4 或工程找不到、档案解析失败)→ 退出码 2。"""
|
"""环境/前置错误(UV4 或工程找不到、档案解析失败)→ 退出码 2。"""
|
||||||
|
|
||||||
|
|
||||||
|
def _expand_deep(v):
|
||||||
|
"""展开未展开的 REG_EXPAND_SZ 字面量(百分号形态的变量引用),最多四轮,防自指死循环。"""
|
||||||
|
for _ in range(4):
|
||||||
|
nv = os.path.expandvars(v)
|
||||||
|
if nv == v:
|
||||||
|
break
|
||||||
|
v = nv
|
||||||
|
return v
|
||||||
|
|
||||||
|
|
||||||
|
def harden_windows_env():
|
||||||
|
"""拉起 UV4 前补齐/展开子进程环境——堵住仓库根的「%SystemDrive%/」字面量垃圾目录。
|
||||||
|
|
||||||
|
Windows 的 known-folder 路径在注册表里是**可展开串**:ProgramData 的真实取值来自
|
||||||
|
「%SystemDrive%/ProgramData/…」,本机 HKCU Environment 里的 TMP/TEMP 实测就是未展开的
|
||||||
|
「%USERPROFILE%/AppData/Local/Temp」。父进程环境残缺时(典型:Agent 在 Node
|
||||||
|
child_process / run_code 里 spawn,子进程继承到一个**空环境**,SystemDrive、ProgramData、
|
||||||
|
USERPROFILE 一个都没有)这些串展不开、留在原地就成了**相对路径**,于是 UV4 / fontTools 类
|
||||||
|
字体工具按它在**当前目录**(= 仓库根)建出
|
||||||
|
「%SystemDrive%/ProgramData/Microsoft/Windows/Caches/*.db」(图标+字体缓存库,约 1 MB),
|
||||||
|
污染 git status(2026-10-26 同一会话两次复发;仓库 .gitignore 已入库兜住它,但正解是
|
||||||
|
**根本别让它长出来**)。故此处回填 + 展开,并打可定位的 WARN:ENV_SPARSE。
|
||||||
|
"""
|
||||||
|
if os.name != "nt":
|
||||||
|
return
|
||||||
|
filled, expanded = [], []
|
||||||
|
|
||||||
|
for key, val in list(os.environ.items()):
|
||||||
|
if isinstance(val, str) and "%" in val:
|
||||||
|
new = _expand_deep(val)
|
||||||
|
if new != val and "%" not in new:
|
||||||
|
os.environ[key] = new
|
||||||
|
expanded.append(key)
|
||||||
|
|
||||||
|
root = os.environ.get("SystemRoot") or os.environ.get("windir") or ""
|
||||||
|
root = _expand_deep(root)
|
||||||
|
if "%" in root or (root and not os.path.isdir(root)):
|
||||||
|
root = os.path.join(os.path.abspath(os.sep), "Windows") # 兜底:当前盘根下的 Windows 目录
|
||||||
|
drive = os.path.splitdrive(root)[0] or os.path.abspath(os.sep)[:2]
|
||||||
|
drive = drive.rstrip(os.sep) or "C:"
|
||||||
|
sysroot = drive + os.sep
|
||||||
|
user = _expand_deep(os.environ.get("USERPROFILE") or "")
|
||||||
|
if "%" in user:
|
||||||
|
user = ""
|
||||||
|
defaults = {
|
||||||
|
"SystemDrive": drive,
|
||||||
|
"SystemRoot": root,
|
||||||
|
"windir": root,
|
||||||
|
"ComSpec": os.path.join(root, "System32", "cmd.exe"),
|
||||||
|
"ProgramData": os.path.join(sysroot, "ProgramData"),
|
||||||
|
"ALLUSERSPROFILE": os.path.join(sysroot, "ProgramData"),
|
||||||
|
"ProgramFiles": os.path.join(sysroot, "Program Files"),
|
||||||
|
}
|
||||||
|
if not user:
|
||||||
|
guess = _expand_deep(os.path.expanduser("~"))
|
||||||
|
if not guess.startswith("~") and "%" not in guess:
|
||||||
|
user = guess
|
||||||
|
if user:
|
||||||
|
defaults["USERPROFILE"] = user
|
||||||
|
defaults["APPDATA"] = os.path.join(user, "AppData", "Roaming")
|
||||||
|
defaults["LOCALAPPDATA"] = os.path.join(user, "AppData", "Local")
|
||||||
|
defaults["TMP"] = os.path.join(user, "AppData", "Local", "Temp")
|
||||||
|
defaults["TEMP"] = defaults["TMP"]
|
||||||
|
for key, val in defaults.items():
|
||||||
|
if not os.environ.get(key):
|
||||||
|
os.environ[key] = val
|
||||||
|
filled.append(key)
|
||||||
|
|
||||||
|
# TMP/TEMP 必须落在真实目录:留未展开值 = 工具按字面量在当前目录建「%USERPROFILE%/…」垃圾树
|
||||||
|
td = os.environ.get("TEMP") or os.environ.get("TMP") or ""
|
||||||
|
if "%" in td or (td and not os.path.isdir(td)):
|
||||||
|
cand = []
|
||||||
|
lad = os.environ.get("LOCALAPPDATA") or ""
|
||||||
|
if lad and "%" not in lad:
|
||||||
|
cand.append(os.path.join(lad, "Temp"))
|
||||||
|
cand.append(os.path.join(sysroot, "Temp"))
|
||||||
|
picked = next((c for c in cand if os.path.isdir(c)), None)
|
||||||
|
if not picked:
|
||||||
|
for c in cand:
|
||||||
|
try:
|
||||||
|
os.makedirs(c, exist_ok=True)
|
||||||
|
picked = c
|
||||||
|
break
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if picked:
|
||||||
|
os.environ["TEMP"] = picked
|
||||||
|
os.environ["TMP"] = picked
|
||||||
|
filled.append("TEMP/TMP=" + picked)
|
||||||
|
|
||||||
|
if filled or expanded:
|
||||||
|
print("WARN: ENV_SPARSE: 子进程环境残缺/含未展开字面量 —— 已回填[{}] 已展开[{}]".format(
|
||||||
|
", ".join(sorted(filled)) or "-", ", ".join(sorted(expanded)) or "-"))
|
||||||
|
print(" 正解:用继承**完整环境**的 shell 跑编译(ops_bash / PowerShell),"
|
||||||
|
"别从 Node spawn(空 process.env)直接起 UV4 或字体门禁脚本")
|
||||||
|
|
||||||
|
|
||||||
|
def check_percent_literal_junk():
|
||||||
|
"""构建后自检:环境残缺时 Windows 会在**当前目录**按字面量建 %SystemDrive%/ 之类的垃圾树。
|
||||||
|
|
||||||
|
只告警、不自动删——删除口径见本 skill「构建副产物」一节:必须用 bash 引号形态,
|
||||||
|
绝不能用 cmd(cmd 会把 %SystemDrive% 展开成 C:,等于对整块系统盘下 rd /s /q)。
|
||||||
|
"""
|
||||||
|
for name in ("%SystemDrive%", "%USERPROFILE%", "%LOCALAPPDATA%", "%APPDATA%",
|
||||||
|
"%ProgramData%", "%TEMP%", "%TMP%"):
|
||||||
|
if os.path.isdir(name):
|
||||||
|
print("WARN: JUNK_DIR: 工作区出现字面量目录 {}(未展开的 known-folder 路径;"
|
||||||
|
".gitignore 已兜住,不得入库)".format(name))
|
||||||
|
print(" 清理(只用 bash 引号形态,禁用 cmd 以免展开成 C:): rm -rf -- '{}'".format(name))
|
||||||
|
|
||||||
|
|
||||||
class _ArgumentParser(argparse.ArgumentParser):
|
class _ArgumentParser(argparse.ArgumentParser):
|
||||||
"""用法错误 → 退出码 3。
|
"""用法错误 → 退出码 3。
|
||||||
|
|
||||||
@@ -162,8 +273,25 @@ def find_lock_tool():
|
|||||||
return cand if cand.is_file() else None
|
return cand if cand.is_file() else None
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_tree_owner(cwd: Path):
|
||||||
|
"""DSH worktree 插件的树(仓库内 .dsh-worktrees/worktree/<name>)→ 持有者名 pw:<name>。
|
||||||
|
|
||||||
|
2026-10-29 与 task-worktree skill 的 `plugin` 子命令对齐:插件建的树不在 _wt/ 约定里,
|
||||||
|
以前在这里跑构建,持有者名一律落成 "build-cli"——`lock status` 看不出是谁在占 UV4,
|
||||||
|
多个插件树 / 主树并发时就只剩猜。名字里的多段斜杠压平成 -,与
|
||||||
|
task_worktree.py 的 plugin_lock_owner() 完全一致(同一个可 grep 的标识)。
|
||||||
|
"""
|
||||||
|
parts = cwd.parts
|
||||||
|
if ".dsh-worktrees" not in parts:
|
||||||
|
return None
|
||||||
|
tail = parts[parts.index(".dsh-worktrees") + 1:]
|
||||||
|
if tail and tail[0] == "worktree":
|
||||||
|
tail = tail[1:]
|
||||||
|
return "pw:" + "-".join(tail) if tail else "pw:?"
|
||||||
|
|
||||||
|
|
||||||
def default_lock_task():
|
def default_lock_task():
|
||||||
"""持有者名:$DTU_TASK_ID > cwd 反推 `_wt/<ID>-<slug>/` 的 `<ID>` > "build-cli"。"""
|
"""持有者名:$DTU_TASK_ID > cwd 反推 `_wt/<ID>-<slug>/` 的 `<ID>` > 插件树 `pw:<name>` > "build-cli"。"""
|
||||||
v = os.environ.get("DTU_TASK_ID")
|
v = os.environ.get("DTU_TASK_ID")
|
||||||
if v:
|
if v:
|
||||||
return v
|
return v
|
||||||
@@ -174,7 +302,7 @@ def default_lock_task():
|
|||||||
for p in (cwd, *cwd.parents):
|
for p in (cwd, *cwd.parents):
|
||||||
if p.parent.name == "_wt":
|
if p.parent.name == "_wt":
|
||||||
return p.name.split("-", 1)[0] or p.name
|
return p.name.split("-", 1)[0] or p.name
|
||||||
return "build-cli"
|
return plugin_tree_owner(cwd) or "build-cli"
|
||||||
|
|
||||||
|
|
||||||
def build_lock_ttl_hours(timeout_s):
|
def build_lock_ttl_hours(timeout_s):
|
||||||
@@ -242,6 +370,9 @@ def run_build(args):
|
|||||||
print("PROJ: {} target={}".format(proj, target or "(default)"))
|
print("PROJ: {} target={}".format(proj, target or "(default)"))
|
||||||
print("LOG: {}".format(log))
|
print("LOG: {}".format(log))
|
||||||
|
|
||||||
|
# 环境残缺守卫:先补齐/展开,否则 UV4 会在仓库根按字面量建 %SystemDrive%/ 垃圾树
|
||||||
|
harden_windows_env()
|
||||||
|
|
||||||
# UV4 单实例陷阱:IDE 开着时 -b 可能被转发(退出码丢失、GUI 弹出)——只 WARN
|
# UV4 单实例陷阱:IDE 开着时 -b 可能被转发(退出码丢失、GUI 弹出)——只 WARN
|
||||||
try:
|
try:
|
||||||
tl = subprocess.run(["tasklist", "/fi", "imagename eq UV4.exe", "/nh"],
|
tl = subprocess.run(["tasklist", "/fi", "imagename eq UV4.exe", "/nh"],
|
||||||
@@ -268,6 +399,7 @@ def run_build(args):
|
|||||||
print("BUILD_FAIL: UV4 timed out after {}s (hung or forwarded to IDE?)".format(args.timeout))
|
print("BUILD_FAIL: UV4 timed out after {}s (hung or forwarded to IDE?)".format(args.timeout))
|
||||||
return 1
|
return 1
|
||||||
print("UV4_RC: {}".format(rc))
|
print("UV4_RC: {}".format(rc))
|
||||||
|
check_percent_literal_junk()
|
||||||
|
|
||||||
if not log.is_file():
|
if not log.is_file():
|
||||||
print("BUILD_FAIL: no build log produced - UV4 did not run or hung")
|
print("BUILD_FAIL: no build log produced - UV4 did not run or hung")
|
||||||
|
|||||||
@@ -26,19 +26,21 @@ PC 上编译运行与 MCU **同一份** UI 源码(CMake + MSVC + SDL2,x64,
|
|||||||
| `lvgl_sim.exe --screenshot shot.bin` | 离屏渲染首帧 → `simulator/i1_to_png.py` 转 PNG 目检 |
|
| `lvgl_sim.exe --screenshot shot.bin` | 离屏渲染首帧 → `simulator/i1_to_png.py` 转 PNG 目检 |
|
||||||
| `lvgl_sim.exe --keys "ud" shot.bin` | 注入键序后截图:`u`=↑ `d`=↓ `l`=← `r`=→ `e`=Enter `s`=Esc(`main.c` 实锤) |
|
| `lvgl_sim.exe --keys "ud" shot.bin` | 注入键序后截图:`u`=↑ `d`=↓ `l`=← `r`=→ `e`=Enter `s`=Esc(`main.c` 实锤) |
|
||||||
| `lvgl_sim.exe --standby shot.bin` | 待机页截图 |
|
| `lvgl_sim.exe --standby shot.bin` | 待机页截图 |
|
||||||
| `lvgl_sim.exe --cfgtest` | 配置引擎闭环:加载→轮询→外部改文件重载→外科式保存回验(在当前 CWD 产生 `sim_config.ini`) |
|
| `lvgl_sim.exe --cfgtest` | 配置引擎闭环:加载→轮询→外部改文件重载→外科式保存回验。临时用当前 CWD 的 `sim_config.ini`,**跑完无论成败一律删除**(旧行为连 PASS 路径也会留一份 4000 B 伪造档 → 之后每次运行都被加载 → 引擎按设计拒写超长文档 → 网口设置页保存行变 `ERR`,把像素回归带偏) |
|
||||||
| `lvgl_sim.exe --logtest` | syslog 后端闭环(定长记录/时间戳/轮转) |
|
| `lvgl_sim.exe --logtest` | syslog 后端闭环(定长记录/时间戳/轮转) |
|
||||||
|
|
||||||
|
⚠ **模拟器按当前工作目录读写 `sim_config.ini`**:目录里残留一份配置就会改变页面内容。`simulator/tests/` 两套回归因此一律在**空临时目录**里跑(`cwd=tmp`);手工跑 `--keys`/`--screenshot` 做对比时,也要先确认当前目录没有 `sim_config.ini`,否则"复现不了别人的 FAIL"。
|
||||||
|
|
||||||
## UI 改动回归 → 用 `ui-verify` skill(勿在此零散手跑)
|
## UI 改动回归 → 用 `ui-verify` skill(勿在此零散手跑)
|
||||||
|
|
||||||
5 步门禁 = 构建 → `check_menu_rows.py`(菜单 8 状态像素 + 池基线)→ `check_content_pages.py`(文本断言 + 11 页黄金参考 + 往返池)→ `@build` → `coding-standard`。
|
5 步门禁 = 构建 → `check_menu_rows.py`(菜单 8 状态像素 + 池基线)→ `check_content_pages.py`(文本断言 + 66 张黄金参考 + 往返池)→ `/keil-build` → `coding-standard`。
|
||||||
RemoDispBus 推流回归单独跑:`.venv\Scripts\python.exe simulator/tests/check_remo_sink.py`。
|
RemoDispBus 推流回归单独跑:`.venv\Scripts\python.exe simulator/tests/check_remo_sink.py`。
|
||||||
黄金参考 `tests/ref/` 是**本地生成物不入库**:换机后先 `check_content_pages.py --update` 生成,再 `regen_ref_png.py` 出目检 PNG。
|
黄金参考 `tests/ref/*.bin` **随仓库入库**(`.gitignore` 在全仓 *.bin 兜底之后又显式反选,2026-10-22 起;缺基准 = 新克隆跑 `ui-golden` 必 FAIL):不入库的只是派生目检图 `ref/*.png`,换机后先 `check_content_pages.py --update` 生成,再 `regen_ref_png.py` 出目检 PNG。
|
||||||
|
|
||||||
## 两条铁律
|
## 两条铁律
|
||||||
|
|
||||||
- **改 `simulator/lv_conf.h` 触发 `lv-conf-sync` 门禁**:它与 MCU 端 `Lib/lv_conf.h` 必须保持同一特性子集(按**生效值**比对,未定义取 `lv_conf_internal.h` 兜底);白名单与四类判定 → `docs/代码规范/pre-commit使用说明.md` §2.15 / §5.5
|
- **改 `simulator/lv_conf.h` 触发 `lv-conf-sync` 门禁**:它与 MCU 端 `Lib/lv_conf.h` 必须保持同一特性子集(按**生效值**比对,未定义取 `lv_conf_internal.h` 兜底);四类判定与端专属白名单 → `docs/门禁说明/lv-conf-sync.md`;顶层 exclude 的教训 → `docs/代码规范/pre-commit使用说明.md` §5.5
|
||||||
- **模拟器代码与固件同标准受检**(`cppcheck` / `coding-standard` / 乱码 / 改写型钩子 / `doc-links` 全覆盖 `simulator/`);**改动触及 `sim/` 任何后端,必须同步在 `docs/测试说明/模拟器差距清单.md` 登记一行**——模拟器跑过 ≠ 真机跑过
|
- **模拟器代码与固件同标准受检**(`cppcheck` / `coding-standard` / 乱码 / 改写型钩子 / `doc-links` 全覆盖 `simulator/`);**改动触及 `sim/` 任何后端,必须同步在 `docs/测试说明/专项验证/模拟器差距清单.md` 登记一行**——模拟器跑过 ≠ 真机跑过
|
||||||
|
|
||||||
## 构建/运行报错速查(详解 `simulator/README.md`「四、已知坑(构建/运行)」)
|
## 构建/运行报错速查(详解 `simulator/README.md`「四、已知坑(构建/运行)」)
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
name: mem-budget
|
name: mem-budget
|
||||||
description: 嵌入式 RAM/栈预算核算工作流:Keil .map/.htm 静态报告(ram_report/stack_report)+ 串口运行时水位叠加 + DBG 日志成本模型(每条约 90B Flash/216B 栈)。"改任务/缓冲/栈之前必须核算"的固定动作与判读口径。
|
description: 嵌入式 RAM/栈预算核算工作流:Keil .map/.htm 静态报告(ram_report/stack_report)+ 串口运行时水位叠加 + 权威构建后的台账回填(ram_baseline.py --update 只往 ram_baseline.tsv 追加趋势行、--write 才同步 RAM预算台账 受管区、check_mem_numbers.py --write 同步文档快照区)+ DBG 日志成本模型(每条约 90B Flash/216B 栈)+ 跨树(worktree)测量的身份口径——脚本按 __file__ 定位产物、什么才算一次新测量、多条链同涨同一数值的共享帧/幻影环归因法。"改任务/缓冲/栈之前必须核算"、"这个数属于哪棵树、哪次构建"与"内存/Flash 数字钩子红了、提交前台账怎么回填"的固定动作和判读口径。
|
||||||
whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报 L6406E 或 RAM 溢出、串口 [STACK] 水位余量低于底线、或需要回答"这个改动花多少 RAM/Flash"时。
|
whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报 L6406E 或 RAM 溢出、串口 [STACK] 水位余量低于底线、要回答"这个改动花多少 RAM/Flash";以及要复算或比对另一个 worktree 的栈数字、独立验证某个栈回归该记在哪一笔提交上、判断多次启动的栈峰值抖动算不算劣化时;还有 /keil-build 之后要把内存数字回填进台账/文档、纠结该跑 ram_baseline.py --write 还是 --update、提交时 mem-budget / mem-numbers 两条钩子报"与 .map 不一致"时。
|
||||||
---
|
---
|
||||||
|
|
||||||
# mem-budget — RAM/栈预算核算
|
# mem-budget — RAM/栈预算核算
|
||||||
@@ -9,26 +9,120 @@ whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报
|
|||||||
## 固定动作(改前、改后各跑一次)
|
## 固定动作(改前、改后各跑一次)
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# cwd = 项目根;先 @build 保证 .htm/.map 是最新的
|
# cwd = 项目根;先 /keil-build 保证 .htm/.map 是最新的(产物按脚本自身位置定位,见下节)
|
||||||
& '.venv\Scripts\python.exe' ".dsh\skills\mem-budget\scripts\mem_check.py"
|
& '.venv\Scripts\python.exe' ".dsh\skills\mem-budget\scripts\mem_check.py"
|
||||||
# 等价于分别跑:
|
# 等价于分别跑:
|
||||||
# scripts/ram_report.py (.map → RAM/Flash 分区与按文件占用表)
|
# scripts/ram_report.py (.map → RAM/Flash 分区与按文件占用表)
|
||||||
# scripts/stack_report.py (.htm 静态调用图 → 每任务最坏链 vs 配置栈)
|
# scripts/stack_report.py (.htm 静态调用图 → 每任务最坏链 vs 配置栈)
|
||||||
|
# 报告落盘留档(改前/改后各一份,供逐帧差分):
|
||||||
|
& '.venv\Scripts\python.exe' scripts/stack_report.py -o ci-artifacts/sr_before.md
|
||||||
# 叠加运行时水位(有真机日志时):
|
# 叠加运行时水位(有真机日志时):
|
||||||
& '.venv\Scripts\python.exe' scripts/stack_report.py -s .dsh\skills\serial-assistant\scripts\state\serial.log
|
& '.venv\Scripts\python.exe' scripts/stack_report.py -s .dsh\skills\serial-assistant\scripts\state\serial.log
|
||||||
```
|
```
|
||||||
|
|
||||||
退出码:0=全部通过 / 1=有任务不达标 / 2=解析错误(.htm/.map 缺失先重跑构建)。
|
退出码:0=全部通过 / 1=有任务不达标 / 2=解析错误(.htm/.map 缺失先重跑构建)。
|
||||||
|
别整篇读输出,抓关键行:`grep -aE 'iec104|margin|FAIL|ISR|Rule' ci-artifacts/sr_before.md`。
|
||||||
|
|
||||||
|
**「改前 / 改后」必须是两次不同的 Linker run**(两个不同的产物身份):同一份 .htm/.map 上跑两遍
|
||||||
|
只是把同一个数字复述两遍,不构成"改后已复验"。
|
||||||
|
|
||||||
|
## 测量身份口径(先钉树与时刻,再看数字)
|
||||||
|
|
||||||
|
1. 脚本按 __file__ 定位产物,不按 cwd —— 跨树必须换脚本(在主树跑脚本量任务树 = 拿旧 .map 当真值)
|
||||||
|
2. 重跑报告脚本不算新测量(复述 ≠ 复验):只有新的 Linker run 才算一次新测量
|
||||||
|
3. 钉身份的取数法:.htm/.map 按约定不入库 → 把时刻写进正文/台账;记录最小格式 = tree/commit + Linker run 时刻 + 产物来源(本次构建 / 盘上既有)
|
||||||
|
> 本节全文(逐字搬移、未改写,含命令与实测数字)→ [measurement-identity.md](reference/measurement-identity.md)。动手前先读那篇。
|
||||||
|
|
||||||
|
## 静态最坏链回归的归因(同涨同一数值 = 共享帧,不是调用方)
|
||||||
|
|
||||||
|
- 多条任务链分毫不差同涨/同降同一个数值 ⇒ 加深在这些任务共用的被调帧 / 共享尾段上,不在任何一个调用方(别先猜「新增了大局部缓冲」,实测猜错过一次)
|
||||||
|
- 逐棵树逐帧比 frame size、不只比总数:stack_report.py 的 own-frame 表锁链尾公共段;grep -ac 'In Cycle' 在 .htm 里找环(实测 17 处)—— 链接器幻影环能在无人改栈配置时让全仓链齐涨
|
||||||
|
- Maximum Stack Usage 里的 + Unknown(Cycles, Untraceable Function Pointers) 说明静态数是估计值,别当精确值上报;不采信口头结论,用目标树的脚本 + .venv 解释器自己复算
|
||||||
|
- 处置后的下一步:抬栈(Src/freertos.c 的 USER CODE 区,属 Ask-first)或收紧共享缓冲 / 断环;改前改后各跑一次 stack_report.py + ram_report.py,且「改后」那次必须落在一次新的 Linker run 上
|
||||||
|
> 本节全文(逐字搬移、未改写,含命令与实测数字)→ [shared-frame-attribution.md](reference/shared-frame-attribution.md)。动手前先读那篇。
|
||||||
|
|
||||||
## 判读口径
|
## 判读口径
|
||||||
|
|
||||||
- **静态**:`stack_report.py` 的 Max Depth(最坏调用链累计)vs `Src/freertos.c` 配置栈;
|
- **静态**:`stack_report.py` 的 Max Depth(最坏调用链累计)vs `Src/freertos.c` 配置栈;
|
||||||
默认要求余量 ≥30%(`--min-free-pct`),ISR 另留 `--isr-margin`(默认 1024B)。
|
默认要求余量 ≥30%(`--min-free-pct`),ISR 另留 `--isr-margin`(默认 1024B)。
|
||||||
|
判定式 `margin = cfg - max(static_max_depth, runtime_peak)` 须 ≥ `max(isr_margin, 30% of cfg)`。
|
||||||
- **运行时**:开机日志 `[STACK] <task> peak_used=…/… free_min=…` 与静态值对照——
|
- **运行时**:开机日志 `[STACK] <task> peak_used=…/… free_min=…` 与静态值对照——
|
||||||
静态是上界、运行时是下界证据,**两者都要看**;余量 <1KB 底线要上报。
|
**静态最坏链是上界(硬线)、运行时峰值是下界证据(趋势)**,两者都要看;余量 <1KB 底线要上报。
|
||||||
- **RAM 总量**:`ram_report.py` 对 Grand Totals 与 `docs/内存管理/` 预算表核对;
|
- **RAM 总量**:`ram_report.py` 对 Grand Totals 与 `docs/内存管理/` 预算表核对;
|
||||||
链接错 `L6406E` = 已经溢出,用按文件表找大头。
|
链接错 `L6406E` = 已经溢出,用按文件表找大头。
|
||||||
- 任务表一致性由 pre-commit `task-table` 钩子保证(改栈后 `gen_task_table.py` 同步 AGENTS.md)。
|
- 任务表一致性由 pre-commit `task-table` 钩子保证;`mem-budget` 钩子拦 RW_IRAM1/RW_CCM >92%,
|
||||||
|
**并核台账受管区是否与 .map 同源**;`mem-numbers` 钩子核文档里的内存数字——
|
||||||
|
数字与树/时刻脱钩、或**构建后只跑了 `--update` 没回填受管区**时,响的就是这两条(回填见下一节)。
|
||||||
|
|
||||||
|
## 权威构建后的台账回填(三个旗标,别混)
|
||||||
|
|
||||||
|
`.map` 是内存/Flash 数字的**唯一事实源**;台账与文档里的数字由脚本从它生成——**不手抄、不手改**
|
||||||
|
(`docs/内存管理/RAM预算台账.md` 首段就写着"数字不手抄")。三份落点 = 三个旗标,一条命令只干其中一件:
|
||||||
|
`ram_baseline.py` 的 `--gate/--write/--update/--trend` 是 `add_mutually_exclusive_group()`
|
||||||
|
(`scripts/ram_baseline.py:415-419`),想干两件就得调两次。
|
||||||
|
|
||||||
|
| 落点 | 旗标 | 实际改了什么 |
|
||||||
|
|---|---|---|
|
||||||
|
| `scripts/ram_baseline.tsv` 趋势基线(顺带刷 `scripts/size_attestation.json` 同码凭证) | `ram_baseline.py --update` | **只**追加/覆盖当日那一行趋势(Code/RO/RW/ZI + 三区水位 + Top-10 RW+ZI)。**不碰台账正文** |
|
||||||
|
| `docs/内存管理/RAM预算台账.md` 的 `<!-- MEM_BUDGET:BEGIN/END -->` 受管区 | `ram_baseline.py --write` | 从 .map 重渲染区域水位 + 子系统配额对照(打印「已同步台账受管区。」或「台账受管区已是最新。」) |
|
||||||
|
| `docs/内存管理/RAM使用分析与优化方案.md` 的 `<!-- MEM_SNAPSHOT:BEGIN/END -->` 快照区 | `check_mem_numbers.py --write` | 从**同一份** .map 重渲染内存/Flash 快照表(打印「已更新内存快照:…」) |
|
||||||
|
|
||||||
|
**AGENTS.md 与台账 §4 那句「每次 `/keil-build` 后跑 `scripts/ram_baseline.py --update`」喂的是趋势行,不等于回填受管区。**
|
||||||
|
实测代价:照那句跑完 `--update` 就提交 → `mem-budget`、`mem-numbers` 两条钩子当场红。
|
||||||
|
`mem-budget` 的 entry 是 `python scripts/ram_baseline.py --gate`(`.pre-commit-config.yaml:571`)——它在 92% 阈值之外
|
||||||
|
还核**台账受管区与 .map 的一致性**,漂移即 `[FAIL] 台账受管区与 .map 不一致(区域水位或子系统配额表漂移)`
|
||||||
|
并打印 `修复:python scripts/ram_baseline.py --write`(`scripts/ram_baseline.py:221-224`);
|
||||||
|
`mem-numbers` 的 entry 是 `python scripts/check_mem_numbers.py --scan`(同文件 :193),不一致时打印
|
||||||
|
`修复:运行 python scripts/check_mem_numbers.py --write`(`scripts/check_mem_numbers.py:416`)。
|
||||||
|
改用两个 `--write` 后重提,同一笔提交触发的 26 条钩子全 Passed。
|
||||||
|
|
||||||
|
### 提交前固定顺序(构建 → 回填 → 真钩子提交)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# cwd = 项目根,一律 .venv 解释器
|
||||||
|
# 0) 权威构建先行(0 错 0 警):回填读的是**盘上那份 .map**,构建没落定就 --write = 把旧数字写进台账,钩子照样红
|
||||||
|
& '.venv\Scripts\python.exe' .dsh\skills\keil-build\scripts\keil_build.py
|
||||||
|
|
||||||
|
# 1) 趋势行(纪律项,不是回填):覆盖 tsv 当日行 + 顺带刷体积同码凭证
|
||||||
|
& '.venv\Scripts\python.exe' scripts/ram_baseline.py --update
|
||||||
|
|
||||||
|
# 2) 回填台账受管区(mem-budget 钩子核的就是这一段)
|
||||||
|
& '.venv\Scripts\python.exe' scripts/ram_baseline.py --write
|
||||||
|
|
||||||
|
# 3) 回填文档快照区(mem-numbers 钩子核的就是这一张表)
|
||||||
|
& '.venv\Scripts\python.exe' scripts/check_mem_numbers.py --write
|
||||||
|
|
||||||
|
# 4) 逐字自跑提交时会跑的这两条 entry(比事后翻 pre-commit 输出快)
|
||||||
|
& '.venv\Scripts\python.exe' scripts/ram_baseline.py --gate # 0 = 阈值 + 受管区都过
|
||||||
|
& '.venv\Scripts\python.exe' scripts/check_mem_numbers.py --scan # 0 = 快照区与 .map 一致
|
||||||
|
|
||||||
|
# 5) 把刚落盘的落点一并 add,然后**真钩子**提交(绝不 --no-verify、绝不手改数字)
|
||||||
|
git add docs/内存管理/RAM预算台账.md docs/内存管理/RAM使用分析与优化方案.md scripts/ram_baseline.tsv scripts/size_attestation.json
|
||||||
|
```
|
||||||
|
|
||||||
|
退出码:`ram_baseline.py` 0=通过或跳过 / **1=阈值超限或受管区漂移** / 2=.map 解析失败;
|
||||||
|
`check_mem_numbers.py` 0=一致 / 1=不一致、缺受管锚点或找不到事实源。
|
||||||
|
|
||||||
|
### 回填链上的坑(实测)
|
||||||
|
|
||||||
|
- --write 说「已是最新」而钩子还红
|
||||||
|
- tsv 落后只 WARN、不拦
|
||||||
|
- --gate 无 .map → SKIP,而脚本自己写明「SKIP 不等于 PASS」
|
||||||
|
- --map <path> 只换读、不换写
|
||||||
|
- --scan 的散句告警 --write 不替你改
|
||||||
|
- --update 的盖戳排在索引后面
|
||||||
|
- 改写型钩子当场改文件是流程不是故障
|
||||||
|
- mem-budget 触发面很宽
|
||||||
|
> 本节全文(逐字搬移、未改写,含命令与实测数字)→ [backfill-pitfalls.md](reference/backfill-pitfalls.md)。动手前先读那篇。
|
||||||
|
|
||||||
|
## 运行时峰值抖动与"算不算劣化"
|
||||||
|
|
||||||
|
- 同一份镜像连续启动 5 次,`uiTask` 峰值实测在 4116~4188 之间摆 **72 B** ⇒ 跨启动本身就有几十 B 抖动,
|
||||||
|
"相对上一轮 +100 B"**不可归因**于被测改动。判据写成"栈水位不劣于上一轮"必然假红。
|
||||||
|
- 可执行修法(二选一,别都留着):
|
||||||
|
1. 同一份像**≥3 次启动取最大值**互比,并加容差(**±128 B 量级**);
|
||||||
|
2. 硬线改用 `stack_report.py` 的**静态最坏链**,运行时峰值降级为趋势参考。
|
||||||
|
- 判据一改要同步**所有落点**:判据正文、跑判据的脚本、记录判据的文档三处一起改,只改一处等于各说各话。
|
||||||
|
|
||||||
## 成本模型(实测,估算用)
|
## 成本模型(实测,估算用)
|
||||||
|
|
||||||
@@ -42,8 +136,21 @@ whenToUse: 新增任务/加大缓冲/改栈/接入日志之前之后、链接报
|
|||||||
- **自研代码禁 C 库堆**(MicroLIB 堆仅 512B):只用 `pvPortMalloc/vPortFree`(FreeRTOS 堆),
|
- **自研代码禁 C 库堆**(MicroLIB 堆仅 512B):只用 `pvPortMalloc/vPortFree`(FreeRTOS 堆),
|
||||||
模拟器 CRT 堆照不出 MCU 堆分配失败。
|
模拟器 CRT 堆照不出 MCU 堆分配失败。
|
||||||
|
|
||||||
|
## 验证(这轮测量算数了吗)
|
||||||
|
|
||||||
|
1. 报告头 `- Source:` 的相对路径落在**我要量的那棵树**下(不是裸文件名,更不是别的树);
|
||||||
|
2. `- Linker run:`(或 .htm 的 `Last Updated:` / mtime)与本轮那次构建对得上,且**不同于**上一轮那次的时刻
|
||||||
|
——相同就是复述,回去先做一次构建再比;
|
||||||
|
3. 树身份已按 `tree/commit + 时刻 + 产物来源` 写进提交正文/台账,而不是只留在终端输出里;
|
||||||
|
4. 结论里互比的数字必须来自**同一棵树同一次链接**;跨树比较要各自给出树与时刻;
|
||||||
|
5. 涉及劣化判定时注明依据的是静态最坏链(硬线)还是运行时峰值(趋势),并已计入跨启动抖动。
|
||||||
|
6. 要入库的数字:本轮已按「`/keil-build` → `ram_baseline.py --update` → `ram_baseline.py --write` →
|
||||||
|
`check_mem_numbers.py --write`」回填,并逐字自跑 `ram_baseline.py --gate` 与 `check_mem_numbers.py --scan` 各返 0;
|
||||||
|
台账 / 文档 / tsv 的改动与代码同一笔入库,**未手改数字、未用 `--no-verify`**。
|
||||||
|
|
||||||
## 其它项目怎么套
|
## 其它项目怎么套
|
||||||
|
|
||||||
模式通用:链接器 map + 调用图 htm 是**免硬件**的栈可观测源;固定"改前改后各跑一次
|
模式通用:链接器 map + 调用图 htm 是**免硬件**的栈可观测源;固定"改前改后各跑一次
|
||||||
报告 + 运行时水位叠加 + 余量阈值"三件套。本仓库两个报告脚本按 Keil 输出格式解析,
|
报告 + 运行时水位叠加 + 余量阈值"三件套,**外加一条身份口径——脚本按自身位置定位产物,
|
||||||
换工具链(GCC newlib + `-fstack-usage`)需替换解析层,工作流不变。
|
跨树比的是"两棵树各自的脚本 + 各自的链接时刻",重跑同一份产物永远只是复述**。
|
||||||
|
本仓库两个报告脚本按 Keil 输出格式解析,换工具链(GCC newlib + `-fstack-usage`)需替换解析层,工作流不变。
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
# 回填链上的坑(实测)(自 SKILL.md 外置)
|
||||||
|
|
||||||
|
> 本文由 mem-budget 的 SKILL.md「回填链上的坑(实测)」一节逐字外置而来(渐进展开:主文件只留索引与指针)。步骤号沿用原文,未重编。
|
||||||
|
|
||||||
|
- **`--write` 说「已是最新」而钩子还红** ≠ 钩子坏了,是**落点找错了**:tsv / 台账受管区 / 文档快照区
|
||||||
|
三处各归一个旗标。按上表对号,别把同一个旗标重跑三遍;手改台账数字更不行——下次 `--write` 直接覆盖你,
|
||||||
|
钩子仍按 .map 判漂移。
|
||||||
|
- **tsv 落后只 WARN、不拦**:`--gate` 对「tsv 末行落后于当前 .map」只打印 `[WARN] …请跑 --update`
|
||||||
|
(`ram_baseline.py:232`),**拦下来的是受管区漂移**。所以"钩子绿"绝不等于"趋势行也补了",反之亦然,各查各的。
|
||||||
|
- **`--gate` 无 .map → SKIP,而脚本自己写明「SKIP 不等于 PASS」**(`ram_baseline.py:177`):
|
||||||
|
新开的 task-worktree 从没在这棵树上构建过时必然走到这里;同一状态下 `--write` 直接
|
||||||
|
`[FAIL] 找不到 .map 事实源,先 /keil-build`(`ram_baseline.py:250`)——不是代码缺陷,是这棵树还没测过体积。
|
||||||
|
无需 Keil 的强制兜底是 `scripts/check_size_gate.py --local`(= `scripts/ci_local.py` 的体积门禁步)与 CI 的 `--ci`。
|
||||||
|
- **`--map <path>` 只换读、不换写**:`LEDGER`(`ram_baseline.py:45`)与 `TARGETS`(`check_mem_numbers.py:53-54`)
|
||||||
|
都由 `REPO_ROOT = Path(__file__).resolve().parent.parent` 算出。拿主仓脚本配 `--map <另一棵树>/…map` 去 `--write`,
|
||||||
|
等于用别的树的数字**改写主仓的台账**——比"半棵树混合数字"更糟,因为它落盘了。回填永远用本树自己的脚本。
|
||||||
|
- **`--scan` 的散句告警 `--write` 不替你改**:正文里手抄的旧值("现 RW_IRAM1 62.6%" 这类散句)只告警,
|
||||||
|
脚本明确写着「`--write` 不会自动改写正文」(`check_mem_numbers.py:418`)→ 人工按受管区改那一行
|
||||||
|
(Edit 精确替换;含中文的文档绝不整文件重写)。
|
||||||
|
- **`--update` 的盖戳排在索引后面**:它顺带调 `check_size_gate.cmd_stamp()`(`ram_baseline.py:345`),
|
||||||
|
而凭证指纹按 **git 索引内容**生成,有未 staged 的脏改动时**拒盖**,且这里只 `[WARN]`、不改返回值
|
||||||
|
→ 现象是 tsv 多了一行而凭证仍是旧的,要等 `ci_local` 的体积门禁步 / CI 体积门禁那一侧才报出来。
|
||||||
|
要么先 `git add` 再 `--update`,要么补跑 `python scripts/check_size_gate.py --stamp`。
|
||||||
|
- **改写型钩子当场改文件是流程不是故障**:`trailing-whitespace` / `end-of-file-fixer` 报
|
||||||
|
`files were modified by this hook`(新建的 .c 最常触发)时,先读 `ci-artifacts/cui.log` 的真实报错确认是哪几条红,
|
||||||
|
`git add` 被改写的文件后重提。两条改写红 + 两条内存红混在同一次里时,别顺手把内存那条也当"钩子误报"绕过去。
|
||||||
|
- **`mem-budget` 触发面很宽**:`files:` 覆盖 `App/` `Drivers/BSP/` `Src/` `Inc/` `Lib/lv_conf.h`
|
||||||
|
`test/board/` `scripts/ram_baseline.` `docs/内存管理/` `.pre-commit-config.yaml`,且 pre-commit 4.x 已无
|
||||||
|
`always: true` —— 纯 UI / 纯文档提交照样被它拦:盘上 .map 与台账不同源就红,跟这笔提交碰没碰内存文件无关。
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
# 测量身份口径(先钉树与时刻,再看数字)(自 SKILL.md 外置)
|
||||||
|
|
||||||
|
> 本文由 mem-budget 的 SKILL.md「测量身份口径(先钉树与时刻,再看数字)」一节逐字外置而来(渐进展开:主文件只留索引与指针)。步骤号沿用原文,未重编。
|
||||||
|
|
||||||
|
### 1. 脚本按 `__file__` 定位产物,不按 cwd —— 跨树必须换脚本
|
||||||
|
|
||||||
|
```python
|
||||||
|
# scripts/stack_report.py:36-38
|
||||||
|
REPO_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
HTM_DEFAULT = REPO_ROOT / "MDK-ARM" / "STM32F407-Demo" / "STM32F407-Demo.htm"
|
||||||
|
FREERTOS_C = REPO_ROOT / "Src" / "freertos.c" # 配置栈来源,无命令行开关
|
||||||
|
# scripts/ram_report.py:16-18 同样用 dirname(dirname(abspath(__file__))) 定位 .map
|
||||||
|
```
|
||||||
|
|
||||||
|
⇒ `cd 进别的 worktree 再跑主仓的脚本` 量的仍是**主仓**那份 .htm/.map,数字不可比。
|
||||||
|
实测代价:这样一次"独立复算"量到主仓的 1,544 B / margin 1,016 B FAIL,据此写下的 +80 B 归因
|
||||||
|
被队友的逐树逐帧差分表推翻——目标 worktree 的真数是 1,464 B / margin 1,096 B / PASS。
|
||||||
|
|
||||||
|
正确做法:**量哪棵树就跑那棵树自己的脚本**(该树必须已在这棵树上构建过、有自己的 .htm/.map;
|
||||||
|
开树与合并的生命周期见 `task-worktree` skill):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
'.venv/Scripts/python.exe' "<目标树>/scripts/stack_report.py"
|
||||||
|
'.venv/Scripts/python.exe' "<目标树>/scripts/ram_report.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ 只给主仓脚本传 `-i <另一棵树的 .htm>`(或位置参数传另一棵树的 .map)**不够**:
|
||||||
|
`stack_report.py` 的配置栈仍从**脚本所在那棵树**的 `Src/freertos.c` 读(`FREERTOS_C` 写死自 `__file__`,
|
||||||
|
没有开关),两树栈配置一旦不同就是"半棵树的混合数字"。要整支脚本换树跑。
|
||||||
|
|
||||||
|
自检(每次复算都做):报告头两行 `- Source: ...` 与 `- Linker run: ...` 就是你实际量的树和时刻。
|
||||||
|
`Source` 若只打印一个裸文件名(不带 `MDK-ARM/...` 相对路径),说明 `-i` 指到了脚本 `REPO_ROOT` 之外的产物。
|
||||||
|
|
||||||
|
### 2. 重跑报告脚本**不算新测量**(复述 ≠ 复验)
|
||||||
|
|
||||||
|
`stack_report.py` / `ram_report.py` 只是解析盘上那一份 .htm/.map;产物没变,再跑一次数字必然不变。
|
||||||
|
那只能叫"复述",**不能写成"我复验过"**。要拿到新数字必须先有一次**新的 Linker run**(增量构建也算),
|
||||||
|
并把新时刻与产物来源一并写进记录。
|
||||||
|
|
||||||
|
⚠ `ram_report.py` 正文里的 `> Generated: …` 是**脚本运行时刻**,重跑就变——它不是链接时刻,
|
||||||
|
不能拿来当"这是新测量"的凭证。
|
||||||
|
|
||||||
|
### 3. 钉身份的取数法(.htm/.map 按约定不入库 → 把时刻写进正文/台账)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -aE 'Last Updated|Maximum Stack Usage' MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm | head -2
|
||||||
|
# → "ARM Linker, 5060960: Last Updated: Mon Oct 26 22:52:12 2026"
|
||||||
|
```
|
||||||
|
|
||||||
|
- .htm 里唯一的链接时刻锚点是 **`Last Updated:`**;`Linker run` 只是 `stack_report.py` 自己输出里的标签,
|
||||||
|
`Date of Link`、`Image component` **不在 .htm 里**(实测按它们 grep 全空,白跑一轮)。
|
||||||
|
- 取不到该字段时退回产物 mtime;同一笔链接里 .htm 与 .map 的 mtime 只差 ~0.1 s,可据此确认两份产物同源。
|
||||||
|
再配 `git log -1 --format=%H` 与该树 `git status` 的未跟踪文件数。
|
||||||
|
- 记录最小格式:**`tree/commit + Linker run 时刻 + 产物来源(本次构建 / 盘上既有)`**。
|
||||||
|
本仓 .htm/.map 不入库,不钉时刻就等于把数字和树脱钩,下一个读数字的人无从判断它描述哪棵树。
|
||||||
|
- 归属还能机械证明时顺手做掉:`git log --name-only <那笔>..HEAD -- "*.c" "*.h"` 逐行筛(排除 `.uvprojx`)。
|
||||||
|
若那笔之后没有任何提交再碰固件源文件,产物描述的就是**在库那棵树**,不是提交前快照。
|
||||||
|
- 同族坑:`git show :path` / `git show HEAD:path` 读的是索引/HEAD,**不是工作区**。
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 静态最坏链回归的归因(同涨同一数值 = 共享帧,不是调用方)(自 SKILL.md 外置)
|
||||||
|
|
||||||
|
> 本文由 mem-budget 的 SKILL.md「静态最坏链回归的归因(同涨同一数值 = 共享帧,不是调用方)」一节逐字外置而来(渐进展开:主文件只留索引与指针)。步骤号沿用原文,未重编。
|
||||||
|
|
||||||
|
- **多条任务链分毫不差同涨/同降同一个数值**(实测 default/net/iec104 三条 +80 B;断环后九条链各 −80 B)
|
||||||
|
⇒ 加深在这些任务**共用的被调帧 / 共享尾段**上,不在任何一个调用方。别逐个调用方翻,
|
||||||
|
也**别先猜"新增了大局部缓冲"**——实测猜错过一次(`con_ring_try` 只有 24 B 帧、没有数组)。
|
||||||
|
- 逐棵树逐帧比 frame size,不要只比总数:
|
||||||
|
- `stack_report.py` 输出的「Frame-size ranking (own frame)」表 + 每任务最坏链文本,锁定**链尾公共段**
|
||||||
|
(实测落在 `dbg_console_write_line → con_append → dbg_console_drain`,据此把账记到上一笔提交上);
|
||||||
|
- 直接在 .htm 里找环:`grep -ac 'In Cycle' MDK-ARM/STM32F407-Demo/STM32F407-Demo.htm`(实测 17 处),
|
||||||
|
形态为 `Max Depth = 280 + In Cycle` 与 `Call Chain = Error_Handler => dbg_console_write_line (Cycle)`。
|
||||||
|
`+ In Cycle` 表示 armlink 把**环上所有帧**计入含该环的每一条任务链——**链接器幻影环**能在无人改栈配置时让全仓链齐涨。
|
||||||
|
- 实测样本(无人改栈配置、全仓链却齐涨的那种):taskEXIT_CRITICAL ⇒ vPortExitCritical ⇒ configASSERT ⇒
|
||||||
|
vApplicationAssertFailed ⇒ dbg_console_write_raw ⇒ 回到 console_log 内部 ⇒ 再进临界区;
|
||||||
|
它使 `vApplicationAssertFailed` 的 Max Depth 由 ~40 B 变 128 B + In Cycle,差 88 B ≈ 三条链同涨 80 B 的来源。
|
||||||
|
断环方向:内核临界区改 PRIMASK 保存/恢复(保存量放局部变量)→ 零调用帧、不成环;
|
||||||
|
断环的**图证**(该帧从「136 B + In Cycle」变成「8 B、不再 In Cycle」)比数字更有说服力。
|
||||||
|
- `Maximum Stack Usage = N bytes + Unknown(Cycles, Untraceable Function Pointers)` 里的 `+ Unknown(...)`
|
||||||
|
说明静态图对环与函数指针不可完全解析,静态数是估计值,别当精确值上报。
|
||||||
|
- 不采信任何口头结论:用**目标树的脚本 + `.venv` 解释器**自己复算;复算同样先按上节钉身份。
|
||||||
|
- 处置后的下一步:抬栈(`Src/freertos.c` 的 `USER CODE` 区,属 Ask-first 动作)或收紧共享缓冲 / 断环,
|
||||||
|
**改前改后各一次** `stack_report.py` + `ram_report.py`,且"改后"那次必须落在一次新的 Linker run 上;
|
||||||
|
抬栈后跑 `gen_task_table.py` 同步任务表。
|
||||||
@@ -36,5 +36,5 @@ cmake --build simulator/build --config Debug # 0 警告
|
|||||||
- **禁止**把源文件复制进 `simulator/`——两端共用 `Drivers/BSP/` 原件(UI_界面制作流程.md C7)
|
- **禁止**把源文件复制进 `simulator/`——两端共用 `Drivers/BSP/` 原件(UI_界面制作流程.md C7)
|
||||||
- 只注册 `.c`(含 `.s`);`.h` 靠 include 路径找到,不加工程
|
- 只注册 `.c`(含 `.s`);`.h` 靠 include 路径找到,不加工程
|
||||||
- 漏注册的典型症状:一端编译失败(undefined reference)或一端行为悄悄停留在旧逻辑
|
- 漏注册的典型症状:一端编译失败(undefined reference)或一端行为悄悄停留在旧逻辑
|
||||||
- 注册完成后跑回归:UI 文件 → `ui-verify` skill;其他模块 → `@test`(`.dsh/skills/serial-boot-test/scripts/run_boot_test.py`)
|
- 注册完成后跑回归:UI 文件 → `ui-verify` skill;其他模块 → `/serial-boot-test`(`.dsh/skills/serial-boot-test/scripts/run_boot_test.py`)
|
||||||
- 改过 `CMakeLists.txt` 后需重新 `cmake -B build` 配置
|
- 改过 `CMakeLists.txt` 后需重新 `cmake -B build` 配置
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ python -m venv ".dsh\skills\serial-assistant\venv"
|
|||||||
& $PY $TOOL log --tail 40 # 读日志;--grep P [-i] [--regex](先过滤后截尾)
|
& $PY $TOOL log --tail 40 # 读日志;--grep P [-i] [--regex](先过滤后截尾)
|
||||||
& $PY $TOOL clear # 清空日志(代际 +1,会话边界)
|
& $PY $TOOL clear # 清空日志(代际 +1,会话边界)
|
||||||
& $PY $TOOL wait-idle # 等 RX 静默:--settle-ms 8000 --max-ms 30000 --first-timeout-ms 12000
|
& $PY $TOOL wait-idle # 等 RX 静默:--settle-ms 8000 --max-ms 30000 --first-timeout-ms 12000
|
||||||
& $PY $TOOL status # 全状态 JSON(daemon 版本/端口/计数/rx_age_ms/log.path)
|
& $PY $TOOL status # 全状态 JSON(daemon 版本/端口 desired+ready/计数/rx_age_ms/log.path/auto_reopen)
|
||||||
& $PY $TOOL start|stop|restart # daemon 生命周期
|
& $PY $TOOL start|stop|restart # daemon 生命周期
|
||||||
& $PY $TOOL selftest # 无头自检(行拆分/解码/截头/wait 判据,无需硬件)
|
& $PY $TOOL selftest # 无头自检(行拆分/解码/截头/wait 判据,无需硬件)
|
||||||
```
|
```
|
||||||
@@ -52,10 +52,17 @@ python -m venv ".dsh\skills\serial-assistant\venv"
|
|||||||
|
|
||||||
- **一台机只有一个 daemon**(HTTP 口 `SO_EXCLUSIVEADDRUSE` 独占绑定)。多个项目的
|
- **一台机只有一个 daemon**(HTTP 口 `SO_EXCLUSIVEADDRUSE` 独占绑定)。多个项目的
|
||||||
serial_tool 副本协议兼容、可互连:`status` 的 `daemon.version` 辨归属
|
serial_tool 副本协议兼容、可互连:`status` 的 `daemon.version` 辨归属
|
||||||
(本 skill 版 = `1.1.0-skill`),`log.path` 辨日志落点。
|
(本 skill 版 = `1.2.0-skill`),`log.path` 辨日志落点。
|
||||||
- **`open` 报"拒绝访问/端口被占用"**:先 `status` 查是不是残留旧 daemon 占着口 →
|
- **`open` 报"拒绝访问/端口被占用"**:先 `status` 查是不是残留旧 daemon 占着口 →
|
||||||
`stop`(或 `taskkill /F /PID`)→ 重新 `open`。
|
`stop`(或 `taskkill /F /PID`)→ 重新 `open`。
|
||||||
- **`wait-idle` 判据**:`no_data` = 本次等待期间零字节;首字节到达前不启动静默计时;
|
- **`wait-idle` 判据**:`no_data` = 本次等待期间零字节且**口开着**;首字节到达前不启动静默计时;
|
||||||
返回的 `rx_bytes` 是本次增量。`settle-ms` 必须大于固件的自然静默间隙(常见 3~4s)。
|
返回的 `rx_bytes` 是本次增量。`settle-ms` 必须大于固件的自然静默间隙(常见 3~4s)。
|
||||||
|
**v1.2:口未开 → 秒回 `ok:false`(exit 1,带 auto-reopen 现场),不再计满返回 `no_data`**。
|
||||||
|
- **自动重开(v1.2,issue #75)**:daemon 崩溃/被杀/读错丢口 → 新实例按 `meta.json` 现场**自动重开**
|
||||||
|
(串口线程内执行;退避 1.5s 倍增封顶 30s;`status` 的 `auto_reopen={enabled,pending,attempts,
|
||||||
|
last_error,next_retry_in_ms}` 可观测,`port.desired`=意图、`ready`=实际)。显式 `close`/`stop`
|
||||||
|
=清意图(重启后不再自动开);`restart` 免操心(stop 前抓现场、就绪后自动开回,结果在返回 JSON
|
||||||
|
的 `reopen` 键)。serial.log 缺口边界有 `[SYS] … GAP-START …` / `GAP-END …` 行:**窗口内字节不可
|
||||||
|
找回**;`GAP-START` 无 `GAP-END` = 采集器未恢复(查 `auto_reopen`/daemon.log),别当成板子静默。
|
||||||
- **`open` 不带 `--dtr/--rts` = 保持当前线路状态**;显式给才翻转。
|
- **`open` 不带 `--dtr/--rts` = 保持当前线路状态**;显式给才翻转。
|
||||||
- 日志 20MB 上限自动按行边界截头保尾 10MB;无换行残行 64KB 封顶。
|
- 日志 20MB 上限自动按行边界截头保尾 10MB;无换行残行 64KB 封顶。
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
"""serial_tool — Python 串口助手(常驻 daemon + 薄 CLI + 日志文件即接口)。
|
"""serial_tool — Python 串口助手(常驻 daemon + 薄 CLI + 日志文件即接口)。
|
||||||
|
|
||||||
设计文档:docs/测试说明/串口助手设计说明.md
|
设计文档:docs/测试说明/测试工具/串口助手设计说明.md
|
||||||
|
|
||||||
架构:
|
架构:
|
||||||
CLI(短命进程,硬超时) --HTTP JSON--> daemon(detached 单实例,127.0.0.1)
|
CLI(短命进程,硬超时) --HTTP JSON--> daemon(detached 单实例,127.0.0.1)
|
||||||
@@ -19,10 +19,15 @@
|
|||||||
status 全状态(单条 JSON)
|
status 全状态(单条 JSON)
|
||||||
log --tail N --grep P 读日志(先过滤后截尾;-i 忽略大小写 / --regex 正则)
|
log --tail N --grep P 读日志(先过滤后截尾;-i 忽略大小写 / --regex 正则)
|
||||||
clear 清空日志(原子截断 + 代际递增)
|
clear 清空日志(原子截断 + 代际递增)
|
||||||
wait-idle 等 RX 静默(@test 用;--settle-ms / --max-ms / --first-timeout-ms)
|
wait-idle 等 RX 静默(/serial-boot-test 用;--settle-ms / --max-ms / --first-timeout-ms)
|
||||||
start / stop / restart daemon 生命周期(其余命令会自动拉起 daemon)
|
start / stop / restart daemon 生命周期(其余命令会自动拉起 daemon)
|
||||||
selftest 无头自检(行拆分/解码回退/截头算法,无需硬件)
|
selftest 无头自检(行拆分/解码回退/截头算法,无需硬件)
|
||||||
|
|
||||||
|
daemon 自动重开(v1.2,issue #75):崩溃/机器重启后自动拉起的新 daemon 读旧 meta
|
||||||
|
现场自动重开口;读写出错按退避自动重连(1.5s 起步、倍增、封顶 30s);serial.log
|
||||||
|
落 GAP-START / GAP-END 缺口标记;显式 close/stop 清除该意图(不再重开)。
|
||||||
|
wait-idle 在口未开且重开未成时快速失败(ok:false),不再伪装成 no_data。
|
||||||
|
|
||||||
退出码:0=成功 1=领域错误 2=daemon 不可达/超时 3=用法错误
|
退出码:0=成功 1=领域错误 2=daemon 不可达/超时 3=用法错误
|
||||||
"""
|
"""
|
||||||
import argparse
|
import argparse
|
||||||
@@ -46,7 +51,7 @@ from pathlib import Path
|
|||||||
import serial
|
import serial
|
||||||
import serial.tools.list_ports
|
import serial.tools.list_ports
|
||||||
|
|
||||||
VERSION = "1.1.0-skill"
|
VERSION = "1.2.0-skill"
|
||||||
|
|
||||||
SCRIPT = Path(__file__).resolve()
|
SCRIPT = Path(__file__).resolve()
|
||||||
TOOL_DIR = SCRIPT.parent
|
TOOL_DIR = SCRIPT.parent
|
||||||
@@ -81,7 +86,9 @@ CAP_BYTES = 20 * 1024 * 1024 # 日志体积上限
|
|||||||
KEEP_BYTES = 10 * 1024 * 1024 # 超限后保留的尾部
|
KEEP_BYTES = 10 * 1024 * 1024 # 超限后保留的尾部
|
||||||
PARTIAL_FLUSH_S = 0.2 # 残行冲刷窗口(秒)
|
PARTIAL_FLUSH_S = 0.2 # 残行冲刷窗口(秒)
|
||||||
PARTIAL_MAX_BYTES = 64 * 1024 # 无换行残行的内存封顶(超限即当一行落地,见 LineSplitter.feed)
|
PARTIAL_MAX_BYTES = 64 * 1024 # 无换行残行的内存封顶(超限即当一行落地,见 LineSplitter.feed)
|
||||||
REOPEN_SETTLE_S = 1.5 # 重开端口退避(CH340 释放后立即重开的 EOF 陷阱)
|
REOPEN_SETTLE_S = 1.5 # 重开端口退避(CH340 释放后立即重开的 EOF 陷阱);R2 自动重连的起步间隔
|
||||||
|
REOPEN_BACKOFF_MAX_S = 30.0 # R2 自动重开退避封顶(1.5s 起步倍增:1.5/3/6/12/24/30…)
|
||||||
|
GAP_NOTE_INTERVAL_S = 60.0 # R3 退避期 serial.log 的 GAP-RETRY 节奏:首次必记 + 之后 ≤1 条/分
|
||||||
DEFAULT_TIMEOUT_S = 5.0 # CLI→daemon 硬超时(秒;wait-idle 未显式给时按 --max-ms 推导)
|
DEFAULT_TIMEOUT_S = 5.0 # CLI→daemon 硬超时(秒;wait-idle 未显式给时按 --max-ms 推导)
|
||||||
DAEMON_LOG_MAX = 1024 * 1024 # daemon.log 超此体积 → 拉起新实例前只保尾部
|
DAEMON_LOG_MAX = 1024 * 1024 # daemon.log 超此体积 → 拉起新实例前只保尾部
|
||||||
DAEMON_LOG_KEEP = 256 * 1024
|
DAEMON_LOG_KEEP = 256 * 1024
|
||||||
@@ -134,6 +141,93 @@ def port_hint(configured, names):
|
|||||||
return cands[0] if len(cands) == 1 else None
|
return cands[0] if len(cands) == 1 else None
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------- 自动重开判定 / 退避 / 缺口标记行(R1–R3,纯函数,selftest 直测)
|
||||||
|
|
||||||
|
def plan_startup_reopen(meta, default_baud=115200):
|
||||||
|
"""R1 启动自动重开的意图判定:旧 meta.port 现场非空 = 上一任「开着口死的」。
|
||||||
|
显式 `close`/`stop` 已把该字段清成 null → None(不重开);崩溃、被 taskkill、
|
||||||
|
机器重启等现场仍在 → 返回按 meta 的重开参数。"""
|
||||||
|
if not isinstance(meta, dict):
|
||||||
|
return None
|
||||||
|
p = meta.get("port")
|
||||||
|
if not isinstance(p, dict) or not p.get("name"):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
baud = int(p.get("baud") or default_baud)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
baud = default_baud
|
||||||
|
return {"port": str(p["name"]), "baud": baud,
|
||||||
|
"dtr": bool(p.get("dtr")), "rts": bool(p.get("rts"))}
|
||||||
|
|
||||||
|
|
||||||
|
def restart_reopen_plan(st):
|
||||||
|
"""R1 restart 专用:POST /stop **之前**从 /status 抓开口现场(stop 自身语义不变、
|
||||||
|
照清意图;CLI 在新 daemon 就绪后按这份现场显式 /open 回来)。旧 daemon 没口 /
|
||||||
|
已 close → None;重开缺口期(desired 未 ready)name 仍在 → 同样恢复。"""
|
||||||
|
if not isinstance(st, dict):
|
||||||
|
return None
|
||||||
|
p = st.get("port") or {}
|
||||||
|
if not p.get("name"):
|
||||||
|
return None
|
||||||
|
out = {"port": p["name"]}
|
||||||
|
try:
|
||||||
|
if p.get("baud"):
|
||||||
|
out["baud"] = int(p["baud"])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
pass
|
||||||
|
for k in ("dtr", "rts"):
|
||||||
|
if p.get(k) is not None:
|
||||||
|
out[k] = bool(p[k])
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def next_backoff(cur):
|
||||||
|
"""R2 自动重开退避序列:REOPEN_SETTLE_S 起步、倍增、REOPEN_BACKOFF_MAX_S 封顶。"""
|
||||||
|
return min(cur * 2.0, REOPEN_BACKOFF_MAX_S)
|
||||||
|
|
||||||
|
|
||||||
|
def sys_daemon_started(version, pid, prev_pid, pending):
|
||||||
|
"""R3① daemon 启动边界行:新 pid、上一任 pid、自动重开结论(pending 现场 / none)。"""
|
||||||
|
mode = ("pending %s@%s" % (pending["port"], pending["baud"]) if pending else "none")
|
||||||
|
return "daemon started v%s pid=%s prev_pid=%s auto-reopen %s" % (
|
||||||
|
version, pid, prev_pid if prev_pid is not None else "none", mode)
|
||||||
|
|
||||||
|
|
||||||
|
def sys_gap_start_lost(msg):
|
||||||
|
"""R3② 缺口起(运行中丢口):保留 serial error 原文,再落缺口声明。"""
|
||||||
|
return ("serial error: %s (GAP-START: port released; bytes in this window are "
|
||||||
|
"unrecoverable; auto-reopen retrying, backoff %.1fs doubling to %.0fs cap)"
|
||||||
|
% (msg, REOPEN_SETTLE_S, REOPEN_BACKOFF_MAX_S))
|
||||||
|
|
||||||
|
|
||||||
|
def sys_gap_start_recovery(prev_pid, port_name):
|
||||||
|
"""R3②' 缺口起(崩溃/重启恢复):真实缺口起点在「上一任死掉」与此刻之间、不可
|
||||||
|
确知 → 行内声明窗口是下限,别把恢复耗时当成全部丢失时长。"""
|
||||||
|
return ("GAP-START after daemon restart: previous daemon (pid=%s) died holding %s; "
|
||||||
|
"gap began no later than now (exact start unknown); bytes in this window "
|
||||||
|
"are unrecoverable; auto-reopen pending"
|
||||||
|
% (prev_pid if prev_pid is not None else "none", port_name))
|
||||||
|
|
||||||
|
|
||||||
|
def sys_gap_end(name, baud, gen, gap_s, attempts):
|
||||||
|
"""R3③ 缺口止(重开成功):含窗口秒数、尝试次数、gen 与不可找回声明。"""
|
||||||
|
return ("GAP-END reopened %s@%s (gen=%s) after %.1fs, %d attempt(s); bytes lost "
|
||||||
|
"inside the gap window are unrecoverable" % (name, baud, gen, gap_s, attempts))
|
||||||
|
|
||||||
|
|
||||||
|
def sys_gap_retry(attempt, err, next_s):
|
||||||
|
"""R3 退避期节奏行:首次必记、之后每分钟 ≤1 条;逐次失败原文只进 daemon.log。"""
|
||||||
|
return ("GAP-RETRY attempt %d failed: %s (next retry in %.1fs; while the gap is "
|
||||||
|
"open serial.log notes at most 1/min, full details in daemon.log)"
|
||||||
|
% (attempt, err, next_s))
|
||||||
|
|
||||||
|
|
||||||
|
def dlog(msg):
|
||||||
|
"""daemon 自身取证(自动重开逐次尝试原文等)→ stderr = state/daemon.log,
|
||||||
|
不刷 serial.log(R3 降噪口径)。"""
|
||||||
|
print("daemon %s: %s" % (VERSION, msg), file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
|
||||||
def load_meta():
|
def load_meta():
|
||||||
try:
|
try:
|
||||||
with open(META_FILE, "r", encoding="utf-8") as f:
|
with open(META_FILE, "r", encoding="utf-8") as f:
|
||||||
@@ -277,6 +371,17 @@ class Daemon:
|
|||||||
self.rts = False
|
self.rts = False
|
||||||
self.port_since = None
|
self.port_since = None
|
||||||
self.ser = None
|
self.ser = None
|
||||||
|
# ---- 自动重开/重连簿记(R1/R2/R4,issue #75)----
|
||||||
|
self.desired = False # 意图:该不该开着口(open 成功=置;显式 close/stop=清)
|
||||||
|
self._reopen_params = None # 待重开现场 {"port","baud","dtr","rts"}
|
||||||
|
self._reopen_backoff = REOPEN_SETTLE_S
|
||||||
|
self._reopen_next_at = 0.0 # 下次自动重开尝试时刻(0 = 未排期)
|
||||||
|
self._reopen_attempts = 0 # 本缺口期尝试次数(成功即清零)
|
||||||
|
self._reopen_last_error = None
|
||||||
|
self._prev_pid = None # 上一任 daemon pid(启动时读旧 meta 记入)
|
||||||
|
self._gap_start_ts = None # GAP-START 时刻(GAP-END 窗口秒数按它计)
|
||||||
|
self._gap_attempts = 0
|
||||||
|
self._last_gap_note_ts = 0.0 # GAP-RETRY 上一条 serial.log 时刻
|
||||||
self.last_released = 0.0
|
self.last_released = 0.0
|
||||||
self.last_data_at = 0.0
|
self.last_data_at = 0.0
|
||||||
self.born = time.time()
|
self.born = time.time()
|
||||||
@@ -306,13 +411,27 @@ class Daemon:
|
|||||||
def sys_line(self, text):
|
def sys_line(self, text):
|
||||||
return line_at(time.time(), "SYS", text)
|
return line_at(time.time(), "SYS", text)
|
||||||
|
|
||||||
|
def _open_identity(self):
|
||||||
|
"""当前「应然开口现场」:实开的口 > 待重开参数 > None。meta 与 status 都由它
|
||||||
|
表达意图——重开缺口期现场必须留在 meta.port 里,否则下一次崩溃/重启后无人
|
||||||
|
知道「这台机器本来该开着 COMx」。"""
|
||||||
|
if self.port_name:
|
||||||
|
return {"name": self.port_name, "baud": self.port_baud,
|
||||||
|
"dtr": self.dtr, "rts": self.rts}
|
||||||
|
p = self._reopen_params
|
||||||
|
if self.desired and p and p.get("port"):
|
||||||
|
return {"name": p["port"], "baud": p.get("baud"),
|
||||||
|
"dtr": bool(p.get("dtr")), "rts": bool(p.get("rts"))}
|
||||||
|
return None
|
||||||
|
|
||||||
def _save_meta(self):
|
def _save_meta(self):
|
||||||
|
ident = self._open_identity()
|
||||||
save_meta({
|
save_meta({
|
||||||
"pid": os.getpid(),
|
"pid": os.getpid(),
|
||||||
"version": VERSION,
|
"version": VERSION,
|
||||||
"gen": self.gen,
|
"gen": self.gen,
|
||||||
"port": ({"name": self.port_name, "baud": self.port_baud,
|
"port": ({"name": ident["name"], "baud": ident["baud"],
|
||||||
"dtr": self.dtr, "rts": self.rts} if self.port_name else None),
|
"dtr": ident["dtr"], "rts": ident["rts"]} if ident else None),
|
||||||
"since": self.port_since,
|
"since": self.port_since,
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -378,6 +497,10 @@ class Daemon:
|
|||||||
if time.time() - self.last_data_at > PARTIAL_FLUSH_S:
|
if time.time() - self.last_data_at > PARTIAL_FLUSH_S:
|
||||||
for ts, raw in splitter.flush_partial():
|
for ts, raw in splitter.flush_partial():
|
||||||
self._rx_line(ts, raw)
|
self._rx_line(ts, raw)
|
||||||
|
elif self.desired and time.time() >= self._reopen_next_at:
|
||||||
|
# R1/R2:「该开着口」却没开着(启动恢复 / 丢口重连)→ 在本线程按退避
|
||||||
|
# 自动重开(串口对象唯一持有者不变量不破;旧实现此处纯空转永不重连)
|
||||||
|
self._auto_reopen_attempt()
|
||||||
try:
|
try:
|
||||||
cmd = self.cmd_q.get_nowait()
|
cmd = self.cmd_q.get_nowait()
|
||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
@@ -398,6 +521,7 @@ class Daemon:
|
|||||||
self.emit(line_at(ts, "RX", text))
|
self.emit(line_at(ts, "RX", text))
|
||||||
|
|
||||||
def _port_error(self, msg):
|
def _port_error(self, msg):
|
||||||
|
gap = False
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.ser is not None:
|
if self.ser is not None:
|
||||||
try:
|
try:
|
||||||
@@ -409,7 +533,32 @@ class Daemon:
|
|||||||
self.error = msg
|
self.error = msg
|
||||||
self.errors += 1
|
self.errors += 1
|
||||||
self.last_released = time.time()
|
self.last_released = time.time()
|
||||||
self.emit(self.sys_line("serial error: %s (port released, reopen with 'open')" % msg))
|
if self.desired and self.port_name:
|
||||||
|
# R2:丢口不再躺平等手工 'open'——快照现场,串口线程按退避自动重开
|
||||||
|
# (USB 转串口掉线/重枚举是台架常态,旧实现 CH340 几秒后回来也不重连)
|
||||||
|
self._reopen_params = {"port": self.port_name, "baud": self.port_baud,
|
||||||
|
"dtr": self.dtr, "rts": self.rts}
|
||||||
|
self._reopen_backoff = REOPEN_SETTLE_S
|
||||||
|
self._reopen_next_at = self.last_released + REOPEN_SETTLE_S
|
||||||
|
self._reopen_attempts = 0
|
||||||
|
self._reopen_last_error = None
|
||||||
|
self._gap_start_ts = self.last_released
|
||||||
|
self._gap_attempts = 0
|
||||||
|
self._last_gap_note_ts = 0.0
|
||||||
|
gap = True
|
||||||
|
self.emit(self.sys_line(sys_gap_start_lost(msg) if gap
|
||||||
|
else "serial error: %s (port released)" % msg))
|
||||||
|
|
||||||
|
def _reset_reopen_state_locked(self):
|
||||||
|
"""重开簿记清零(意图 desired 本身由调用方负责置/清)。"""
|
||||||
|
self._reopen_params = None
|
||||||
|
self._reopen_backoff = REOPEN_SETTLE_S
|
||||||
|
self._reopen_next_at = 0.0
|
||||||
|
self._reopen_attempts = 0
|
||||||
|
self._reopen_last_error = None
|
||||||
|
self._gap_start_ts = None
|
||||||
|
self._gap_attempts = 0
|
||||||
|
self._last_gap_note_ts = 0.0
|
||||||
|
|
||||||
def _close_port_locked(self):
|
def _close_port_locked(self):
|
||||||
if self.ser is not None:
|
if self.ser is not None:
|
||||||
@@ -424,6 +573,8 @@ class Daemon:
|
|||||||
self.state = "stopped"
|
self.state = "stopped"
|
||||||
self.error = None
|
self.error = None
|
||||||
self.last_released = time.time()
|
self.last_released = time.time()
|
||||||
|
self.desired = False # 显式 close/stop = 清除重开意图(R1:此后不再自动重开)
|
||||||
|
self._reset_reopen_state_locked()
|
||||||
self.splitter_gen += 1
|
self.splitter_gen += 1
|
||||||
|
|
||||||
# -- 命令执行(均在串口线程) --
|
# -- 命令执行(均在串口线程) --
|
||||||
@@ -493,33 +644,15 @@ class Daemon:
|
|||||||
time.sleep(wait)
|
time.sleep(wait)
|
||||||
break
|
break
|
||||||
|
|
||||||
try:
|
ser, err = self._open_port(name, baud, dtr, rts)
|
||||||
# 先构造后 open:dtr/rts 状态写入 DCB 初始值(避免开端口瞬间的复位脉冲)
|
if ser is None:
|
||||||
ser = serial.Serial()
|
|
||||||
ser.port = name
|
|
||||||
ser.baudrate = baud
|
|
||||||
ser.timeout = 0
|
|
||||||
ser.dtr = dtr
|
|
||||||
ser.rts = rts
|
|
||||||
ser.open()
|
|
||||||
except serial.SerialException as e:
|
|
||||||
msg = str(e)
|
|
||||||
names = sorted(self._list_port_names())
|
|
||||||
if name not in names:
|
|
||||||
# Windows 对不存在的 COM 口常报 Access Denied(而非 FileNotFound),
|
|
||||||
# 故以端口枚举结果为准,避免把"不存在"误报成"被占用"
|
|
||||||
err = "port not found: %s (available: %s)" % (name, ", ".join(names) or "(none)")
|
|
||||||
hint = port_hint(name, names)
|
|
||||||
if hint:
|
|
||||||
err += " [hint: only candidate is %s -> set portName in %s]" % (hint, CONFIG_FILE)
|
|
||||||
elif "Access is denied" in msg or "busy" in msg.lower():
|
|
||||||
err = "port busy: %s (held by another process)" % name
|
|
||||||
else:
|
|
||||||
err = "port open failed: %s (%s)" % (name, msg)
|
|
||||||
cmd.finish({"ok": False, "error": err})
|
cmd.finish({"ok": False, "error": err})
|
||||||
return
|
return
|
||||||
|
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
gap = self._gap_start_ts is not None
|
||||||
|
gap_s = time.time() - self._gap_start_ts if gap else 0.0
|
||||||
|
gap_att = self._gap_attempts
|
||||||
self.ser = ser
|
self.ser = ser
|
||||||
self.port_name = name
|
self.port_name = name
|
||||||
self.port_baud = baud
|
self.port_baud = baud
|
||||||
@@ -530,20 +663,35 @@ class Daemon:
|
|||||||
self.error = None
|
self.error = None
|
||||||
self.last_data_at = time.time()
|
self.last_data_at = time.time()
|
||||||
self.splitter_gen += 1 # 新会话:丢掉上一段会话遗留的残行
|
self.splitter_gen += 1 # 新会话:丢掉上一段会话遗留的残行
|
||||||
|
# R1:显式 open 成功 = 写入重开意图(此后再崩溃,新 daemon 认这份现场)
|
||||||
|
self.desired = True
|
||||||
|
self._reset_reopen_state_locked()
|
||||||
|
self._reopen_params = {"port": name, "baud": baud, "dtr": dtr, "rts": rts}
|
||||||
self.emit(self.sys_line("port opened %s %d (gen=%d)" % (name, baud, self.gen)))
|
self.emit(self.sys_line("port opened %s %d (gen=%d)" % (name, baud, self.gen)))
|
||||||
|
if gap:
|
||||||
|
# 手工 open 抢在退避重试前把口拉回来了 → 缺口窗口同样要收口(R3③)
|
||||||
|
self.emit(self.sys_line(sys_gap_end(name, baud, self.gen, gap_s, gap_att)))
|
||||||
self._save_meta()
|
self._save_meta()
|
||||||
cmd.finish({"ok": True, "port": name, "baud": baud, "dtr": dtr, "rts": rts,
|
cmd.finish({"ok": True, "port": name, "baud": baud, "dtr": dtr, "rts": rts,
|
||||||
"gen": self.gen, "since": self.port_since})
|
"gen": self.gen, "since": self.port_since})
|
||||||
|
|
||||||
def _cmd_close(self, cmd):
|
def _cmd_close(self, cmd):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
if self.ser is None:
|
had_intent = self.desired
|
||||||
cmd.finish({"ok": True, "note": "port not open"})
|
was_open = self.ser is not None
|
||||||
return
|
if was_open:
|
||||||
self._close_port_locked()
|
self._close_port_locked()
|
||||||
self.emit(self.sys_line("port closed"))
|
else:
|
||||||
|
# 「重开缺口中已释放」或「从未开过」:显式 close 同样清除重开意图
|
||||||
|
# (否则缺口期 close 后一旦崩溃/机器重启,新 daemon 还会自作主张重开)
|
||||||
|
self.desired = False
|
||||||
|
self._reset_reopen_state_locked()
|
||||||
|
if was_open:
|
||||||
|
self.emit(self.sys_line("port closed"))
|
||||||
|
elif had_intent:
|
||||||
|
self.emit(self.sys_line("port not open; auto-reopen intent cleared"))
|
||||||
self._save_meta()
|
self._save_meta()
|
||||||
cmd.finish({"ok": True})
|
cmd.finish({"ok": True} if was_open else {"ok": True, "note": "port not open"})
|
||||||
|
|
||||||
def _cmd_send(self, cmd):
|
def _cmd_send(self, cmd):
|
||||||
p = cmd.params
|
p = cmd.params
|
||||||
@@ -560,7 +708,13 @@ class Daemon:
|
|||||||
with self.lock:
|
with self.lock:
|
||||||
ser = self.ser
|
ser = self.ser
|
||||||
if ser is None:
|
if ser is None:
|
||||||
cmd.finish({"ok": False, "error": "port not open (run 'open' first)"})
|
if self.desired:
|
||||||
|
ar = self.auto_reopen_status()
|
||||||
|
cmd.finish({"ok": False,
|
||||||
|
"error": "port not open; auto-reopen pending (attempts=%s last_error=%s)"
|
||||||
|
% (ar["attempts"], ar["last_error"])})
|
||||||
|
else:
|
||||||
|
cmd.finish({"ok": False, "error": "port not open (run 'open' first)"})
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
ser.write(data)
|
ser.write(data)
|
||||||
@@ -581,18 +735,124 @@ class Daemon:
|
|||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
# -- 自动重开/重连(R1/R2;一律在串口线程执行,主/HTTP 线程不碰 Serial 对象) --
|
||||||
|
|
||||||
|
def _open_port(self, name, baud, dtr, rts):
|
||||||
|
"""开串口原语(仅串口线程调用)→ (ser|None, err|None)。
|
||||||
|
先构造后 open:dtr/rts 状态写入 DCB 初始值(避免开端口瞬间的复位脉冲)。"""
|
||||||
|
try:
|
||||||
|
ser = serial.Serial()
|
||||||
|
ser.port = name
|
||||||
|
ser.baudrate = baud
|
||||||
|
ser.timeout = 0
|
||||||
|
ser.dtr = dtr
|
||||||
|
ser.rts = rts
|
||||||
|
ser.open()
|
||||||
|
return ser, None
|
||||||
|
except serial.SerialException as e:
|
||||||
|
return None, self._classify_open_error(name, str(e))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _classify_open_error(name, msg):
|
||||||
|
"""pyserial 异常原文 → 可操作错误文本(不存在 → 被占 → 其它),手工 open 与自动重开共用。"""
|
||||||
|
names = sorted(Daemon._list_port_names())
|
||||||
|
if name not in names:
|
||||||
|
# Windows 对不存在的 COM 口常报 Access Denied(而非 FileNotFound),
|
||||||
|
# 故以端口枚举结果为准,避免把"不存在"误报成"被占用"
|
||||||
|
err = "port not found: %s (available: %s)" % (name, ", ".join(names) or "(none)")
|
||||||
|
hint = port_hint(name, names)
|
||||||
|
if hint:
|
||||||
|
err += " [hint: only candidate is %s -> set portName in %s]" % (hint, CONFIG_FILE)
|
||||||
|
elif "Access is denied" in msg or "busy" in msg.lower():
|
||||||
|
err = "port busy: %s (held by another process)" % name
|
||||||
|
else:
|
||||||
|
err = "port open failed: %s (%s)" % (name, msg)
|
||||||
|
return err
|
||||||
|
|
||||||
|
def _auto_reopen_attempt(self):
|
||||||
|
"""自动重开退避循环的一次尝试(串口线程):成功 = 写现场 + GAP-END 收口;
|
||||||
|
失败 = 倍增退避(R2)。逐次失败原文进 daemon.log;serial.log 只按
|
||||||
|
「首次 + 之后每分钟 ≤1 条」记 GAP-RETRY(R3 口径)。"""
|
||||||
|
if self.ser is not None:
|
||||||
|
return
|
||||||
|
p = self._reopen_params or {}
|
||||||
|
name = p.get("port")
|
||||||
|
if not name:
|
||||||
|
self.desired = False
|
||||||
|
self._reopen_next_at = 0.0
|
||||||
|
return
|
||||||
|
baud = int(p.get("baud") or self.cfg.get("baudRate") or 115200)
|
||||||
|
ser, err = self._open_port(name, baud, bool(p.get("dtr")), bool(p.get("rts")))
|
||||||
|
now = time.time()
|
||||||
|
if ser is not None:
|
||||||
|
with self.lock:
|
||||||
|
gap = self._gap_start_ts is not None
|
||||||
|
gap_s = now - self._gap_start_ts if gap else 0.0
|
||||||
|
gap_att = max(self._gap_attempts, self._reopen_attempts, 1)
|
||||||
|
self.ser = ser
|
||||||
|
self.port_name = name
|
||||||
|
self.port_baud = baud
|
||||||
|
self.dtr = bool(p.get("dtr"))
|
||||||
|
self.rts = bool(p.get("rts"))
|
||||||
|
self.port_since = now_str()
|
||||||
|
self.state = "active"
|
||||||
|
self.error = None
|
||||||
|
self.last_data_at = now
|
||||||
|
self.splitter_gen += 1 # 新会话:残行归零
|
||||||
|
self._reset_reopen_state_locked()
|
||||||
|
self._reopen_params = {"port": name, "baud": baud,
|
||||||
|
"dtr": self.dtr, "rts": self.rts}
|
||||||
|
self.emit(self.sys_line(sys_gap_end(name, baud, self.gen, gap_s, gap_att)))
|
||||||
|
self._save_meta()
|
||||||
|
dlog("auto-reopen OK %s@%d: %d attempt(s), window %.1fs"
|
||||||
|
% (name, baud, gap_att, gap_s))
|
||||||
|
return
|
||||||
|
with self.lock:
|
||||||
|
self._reopen_attempts += 1
|
||||||
|
self._gap_attempts += 1
|
||||||
|
self._reopen_last_error = err
|
||||||
|
self._reopen_backoff = next_backoff(self._reopen_backoff)
|
||||||
|
self._reopen_next_at = now + self._reopen_backoff
|
||||||
|
att = self._reopen_attempts
|
||||||
|
note_due = att == 1 or (now - self._last_gap_note_ts) >= GAP_NOTE_INTERVAL_S
|
||||||
|
if note_due:
|
||||||
|
self._last_gap_note_ts = now
|
||||||
|
next_s = self._reopen_backoff
|
||||||
|
dlog("auto-reopen attempt %d failed (next in %.1fs): %s" % (att, next_s, err))
|
||||||
|
if note_due:
|
||||||
|
self.emit(self.sys_line(sys_gap_retry(att, err, next_s)))
|
||||||
|
|
||||||
# -- 查询 --
|
# -- 查询 --
|
||||||
|
|
||||||
|
def auto_reopen_status(self):
|
||||||
|
"""R4:自动重开/重连的对外现场(status() 持锁调用,本函数自身不加锁)。"""
|
||||||
|
ready = self.ser is not None and self.state != "error"
|
||||||
|
pending = bool(self.desired) and not ready
|
||||||
|
nxt = None
|
||||||
|
if pending and self._reopen_next_at > 0:
|
||||||
|
nxt = int(max(0.0, (self._reopen_next_at - time.time()) * 1000))
|
||||||
|
return {"enabled": bool(self.desired), "pending": pending,
|
||||||
|
"attempts": int(self._reopen_attempts),
|
||||||
|
"last_error": self._reopen_last_error,
|
||||||
|
"next_retry_in_ms": nxt}
|
||||||
|
|
||||||
def status(self):
|
def status(self):
|
||||||
with self.lock:
|
with self.lock:
|
||||||
|
ident = self._open_identity()
|
||||||
port = {"ready": self.ser is not None and self.state != "error",
|
port = {"ready": self.ser is not None and self.state != "error",
|
||||||
"name": self.port_name, "baud": self.port_baud,
|
# R4:desired = 意图(该不该开着),ready = 实际;缺口期 desired=true / ready=false
|
||||||
"dtr": self.dtr, "rts": self.rts, "since": self.port_since}
|
"desired": self.desired,
|
||||||
|
"name": ident["name"] if ident else None,
|
||||||
|
"baud": ident["baud"] if ident else None,
|
||||||
|
"dtr": ident["dtr"] if ident else self.dtr,
|
||||||
|
"rts": ident["rts"] if ident else self.rts,
|
||||||
|
"since": self.port_since}
|
||||||
counters = {"rx_bytes": self.rx_bytes, "tx_bytes": self.tx_bytes,
|
counters = {"rx_bytes": self.rx_bytes, "tx_bytes": self.tx_bytes,
|
||||||
"rx_lines": self.rx_lines, "tx_lines": self.tx_lines,
|
"rx_lines": self.rx_lines, "tx_lines": self.tx_lines,
|
||||||
"errors": self.errors}
|
"errors": self.errors}
|
||||||
state, err = self.state, self.error
|
state, err = self.state, self.error
|
||||||
gen = self.gen
|
gen = self.gen
|
||||||
|
ar = self.auto_reopen_status()
|
||||||
last = None
|
last = None
|
||||||
if self.last_rx_at:
|
if self.last_rx_at:
|
||||||
last = {"ts": datetime.fromtimestamp(self.last_rx_ts).astimezone()
|
last = {"ts": datetime.fromtimestamp(self.last_rx_ts).astimezone()
|
||||||
@@ -604,7 +864,7 @@ class Daemon:
|
|||||||
"daemon": {"pid": os.getpid(), "version": VERSION,
|
"daemon": {"pid": os.getpid(), "version": VERSION,
|
||||||
"uptime_s": int(time.time() - self.born),
|
"uptime_s": int(time.time() - self.born),
|
||||||
"exclusive_bind": getattr(self.server, "exclusive_bound", False)},
|
"exclusive_bind": getattr(self.server, "exclusive_bound", False)},
|
||||||
"port": port, "counters": counters, "last_rx": last,
|
"port": port, "auto_reopen": ar, "counters": counters, "last_rx": last,
|
||||||
# rx_age_ms:距最后一个 RX 字节的毫秒数(null = 这个 daemon 还没收过
|
# rx_age_ms:距最后一个 RX 字节的毫秒数(null = 这个 daemon 还没收过
|
||||||
# 任何字节)。判"板子是不是根本没在说话"不必再自己减时间戳(2026-10-10)
|
# 任何字节)。判"板子是不是根本没在说话"不必再自己减时间戳(2026-10-10)
|
||||||
"rx_age_ms": last["age_ms"] if last else None,
|
"rx_age_ms": last["age_ms"] if last else None,
|
||||||
@@ -615,16 +875,31 @@ class Daemon:
|
|||||||
def wait_idle(self, settle_s, max_s, first_timeout_s=None):
|
def wait_idle(self, settle_s, max_s, first_timeout_s=None):
|
||||||
"""等 RX 静默,返回 reason ∈ {idle, no_data, timeout}。
|
"""等 RX 静默,返回 reason ∈ {idle, no_data, timeout}。
|
||||||
|
|
||||||
2026-10-09 修三处(旧实现会让 @test 在"板子全程没输出"时误判收尾):
|
2026-10-09 修三处(旧实现会让 /serial-boot-test 在"板子全程没输出"时误判收尾):
|
||||||
1. no_data 判据:旧实现比的是"进入函数时 daemon 累计 rx_bytes 是否为 0"。
|
1. no_data 判据:旧实现比的是"进入函数时 daemon 累计 rx_bytes 是否为 0"。
|
||||||
daemon 常驻(本机实测 rx_bytes=1.2MB)→ 永远 >0 → 板子一个字节都不发
|
daemon 常驻(本机实测 rx_bytes=1.2MB)→ 永远 >0 → 板子一个字节都不发
|
||||||
也返回 idle:true,@test 的 no_data 拦截形同虚设。现按**本次增量**判。
|
也返回 idle:true,/serial-boot-test 的 no_data 拦截形同虚设。现按**本次增量**判。
|
||||||
2. 首字节门:旧实现从进入即开始计静默时间,板子第一行比 settle_ms 来得
|
2. 首字节门:旧实现从进入即开始计静默时间,板子第一行比 settle_ms 来得
|
||||||
晚就提前收尾("开机日志缺尾巴"的另一半成因)。现首字节到达前不启动
|
晚就提前收尾("开机日志缺尾巴"的另一半成因)。现首字节到达前不启动
|
||||||
settle 计时,改由 first_timeout_s 兜底(默认 = max_s)。
|
settle 计时,改由 first_timeout_s 兜底(默认 = max_s)。
|
||||||
3. 返回的 rx_bytes 改为**本次等待期间的增量**(旧实现回的是 daemon 累计
|
3. 返回的 rx_bytes 改为**本次等待期间的增量**(旧实现回的是 daemon 累计
|
||||||
值,调用方打出来的 WAIT: rx_bytes=1246761 毫无意义)。
|
值,调用方打出来的 WAIT: rx_bytes=1246761 毫无意义)。
|
||||||
|
v1.2(issue #75 根因 4):口未开且自动重开未成时**快速失败** ok:false(error
|
||||||
|
带 auto-reopen 现场),不再计满返回 no_data——no_data 从此只表示「口开着且
|
||||||
|
本次零字节」。
|
||||||
"""
|
"""
|
||||||
|
# R4 无口门:先判「有没有口可采」,再谈静默判据
|
||||||
|
if not (self.ser is not None and self.state != "error"):
|
||||||
|
pending = bool(getattr(self, "desired", False))
|
||||||
|
now = time.time()
|
||||||
|
na = getattr(self, "_reopen_next_at", 0.0)
|
||||||
|
nxt = int((na - now) * 1000) if (pending and na > now) else None
|
||||||
|
return {"ok": False,
|
||||||
|
"error": "port not open; auto-reopen %s (attempts=%s last_error=%s "
|
||||||
|
"next_retry_in_ms=%s) -- no_data requires an open port"
|
||||||
|
% ("pending" if pending else "disabled",
|
||||||
|
getattr(self, "_reopen_attempts", 0),
|
||||||
|
getattr(self, "_reopen_last_error", None), nxt)}
|
||||||
t0 = time.time()
|
t0 = time.time()
|
||||||
initial = self.rx_bytes
|
initial = self.rx_bytes
|
||||||
last = initial
|
last = initial
|
||||||
@@ -654,7 +929,25 @@ class Daemon:
|
|||||||
|
|
||||||
def run(self):
|
def run(self):
|
||||||
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
STATE_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
# R1(issue #75 根因 1):先读旧 meta **再**覆写。旧实现 run() 第一件事就是
|
||||||
|
# _save_meta(),把上一任「开着口死的」现场覆成 port:null——重启瞬间抹掉现场,
|
||||||
|
# 且启动流程无任何重开动作。
|
||||||
|
prev = load_meta()
|
||||||
|
self._prev_pid = prev.get("pid")
|
||||||
|
params = plan_startup_reopen(prev, self.cfg.get("baudRate") or 115200)
|
||||||
|
if params:
|
||||||
|
self.desired = True
|
||||||
|
self._reopen_params = params
|
||||||
|
self._reopen_backoff = REOPEN_SETTLE_S
|
||||||
|
# 开口动作落在串口线程(唯一持有者不变量):born+1.5s 后首次尝试,
|
||||||
|
# 沿用 CH340「释放后立即重开」的 REOPEN_SETTLE_S 退避口径
|
||||||
|
self._reopen_next_at = self.born + REOPEN_SETTLE_S
|
||||||
|
self._gap_start_ts = time.time() # 窗口下限 = 此刻;真实起点见 GAP-START 文字
|
||||||
self._save_meta()
|
self._save_meta()
|
||||||
|
self.emit(self.sys_line(sys_daemon_started(VERSION, os.getpid(),
|
||||||
|
self._prev_pid, params)))
|
||||||
|
if params:
|
||||||
|
self.emit(self.sys_line(sys_gap_start_recovery(self._prev_pid, params["port"])))
|
||||||
threading.Thread(target=self.writer_loop, name="writer", daemon=True).start()
|
threading.Thread(target=self.writer_loop, name="writer", daemon=True).start()
|
||||||
threading.Thread(target=self.serial_loop, name="serial", daemon=True).start()
|
threading.Thread(target=self.serial_loop, name="serial", daemon=True).start()
|
||||||
port = self.cfg["httpPort"]
|
port = self.cfg["httpPort"]
|
||||||
@@ -1059,7 +1352,15 @@ def cmd_start(args, force=False):
|
|||||||
st = http_request("GET", "/status", timeout=1)
|
st = http_request("GET", "/status", timeout=1)
|
||||||
return {"ok": True, "already_running": True,
|
return {"ok": True, "already_running": True,
|
||||||
"pid": st.get("daemon", {}).get("pid")}
|
"pid": st.get("daemon", {}).get("pid")}
|
||||||
|
reopen_after = None
|
||||||
if alive:
|
if alive:
|
||||||
|
# R1(restart 特判,issue #75):stop 语义不变——关端口 + 清重开意图;但
|
||||||
|
# 「重启」不该把采集现场弄丢 → stop **之前**先从 /status 抓开口现场,
|
||||||
|
# 新 daemon 就绪后再显式 /open 回来(命令仍走串口线程执行)。
|
||||||
|
try:
|
||||||
|
reopen_after = restart_reopen_plan(http_request("GET", "/status", timeout=2))
|
||||||
|
except (urllib.error.URLError, OSError, ValueError):
|
||||||
|
pass
|
||||||
# restart:先优雅停(关端口),再强杀,确认真正退出
|
# restart:先优雅停(关端口),再强杀,确认真正退出
|
||||||
try:
|
try:
|
||||||
http_request("POST", "/stop", timeout=5)
|
http_request("POST", "/stop", timeout=5)
|
||||||
@@ -1086,10 +1387,19 @@ def cmd_start(args, force=False):
|
|||||||
raise ToolError("daemon start timeout (no response within %.0fs)" % args.timeout, 2)
|
raise ToolError("daemon start timeout (no response within %.0fs)" % args.timeout, 2)
|
||||||
try:
|
try:
|
||||||
st = http_request("GET", "/status", timeout=1)
|
st = http_request("GET", "/status", timeout=1)
|
||||||
return {"ok": True, "already_running": False,
|
result = {"ok": True, "already_running": False,
|
||||||
"pid": st.get("daemon", {}).get("pid")}
|
"pid": st.get("daemon", {}).get("pid")}
|
||||||
except (urllib.error.URLError, OSError):
|
except (urllib.error.URLError, OSError):
|
||||||
return {"ok": True, "started": True}
|
result = {"ok": True, "started": True}
|
||||||
|
if reopen_after:
|
||||||
|
# 把 restart 前记住的开口现场开回来。失败不把 restart 翻成 ok:false
|
||||||
|
# (旧进程确实被干净换掉了),现场进 result["reopen"] 由调用方判读。
|
||||||
|
try:
|
||||||
|
ro = http_request("POST", "/open", reopen_after, timeout=10)
|
||||||
|
except (urllib.error.URLError, OSError) as e:
|
||||||
|
ro = {"ok": False, "error": str(e)}
|
||||||
|
result["reopen"] = {"requested": reopen_after, "result": ro}
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def cmd_stop(args):
|
def cmd_stop(args):
|
||||||
@@ -1123,26 +1433,26 @@ def run_selftest():
|
|||||||
def check(name, cond, detail=""):
|
def check(name, cond, detail=""):
|
||||||
results.append((name, bool(cond), detail))
|
results.append((name, bool(cond), detail))
|
||||||
|
|
||||||
# T1 行拆分:CRLF / 多行 / 残行冲刷
|
# 行拆分:CRLF / 多行 / 残行冲刷
|
||||||
sp = LineSplitter()
|
sp = LineSplitter()
|
||||||
got = [l for _, l in sp.feed(b"abc\r\n", 1000.0)]
|
got = [l for _, l in sp.feed(b"abc\r\n", 1000.0)]
|
||||||
check("T1a CRLF 行", got == [b"abc"], repr(got))
|
check("行拆分:CRLF 行", got == [b"abc"], repr(got))
|
||||||
got = [l for _, l in sp.feed(b"de\nf\n", 1001.0)]
|
got = [l for _, l in sp.feed(b"de\nf\n", 1001.0)]
|
||||||
check("T1b 多行", got == [b"de", b"f"], repr(got))
|
check("行拆分:多行", got == [b"de", b"f"], repr(got))
|
||||||
check("T1c 残行不入文件", sp.feed(b"ghi", 1002.0) == [] and sp.buf == b"ghi")
|
check("行拆分:残行不入文件", sp.feed(b"ghi", 1002.0) == [] and sp.buf == b"ghi")
|
||||||
got = [l for _, l in sp.flush_partial()]
|
got = [l for _, l in sp.flush_partial()]
|
||||||
check("T1d 残行冲刷", got == [b"ghi"], repr(got))
|
check("行拆分:残行冲刷", got == [b"ghi"], repr(got))
|
||||||
got = sp.feed(b"\n\n", 1003.0)
|
got = sp.feed(b"\n\n", 1003.0)
|
||||||
check("T1e 空行忽略", all(not raw for _, raw in got) and sp.buf == b"", repr(got))
|
check("行拆分:空行忽略", all(not raw for _, raw in got) and sp.buf == b"", repr(got))
|
||||||
|
|
||||||
# T2 解码回退链
|
# 解码回退链
|
||||||
check("T2a UTF-8", decode_line("中文ok".encode("utf-8")) == "中文ok")
|
check("T2a UTF-8", decode_line("中文ok".encode("utf-8")) == "中文ok")
|
||||||
check("T2b GBK", decode_line("中文".encode("gbk")) == "中文")
|
check("T2b GBK", decode_line("中文".encode("gbk")) == "中文")
|
||||||
check("T2c HEX 兜底", decode_line(b"\xff\xfe\x01") == "HEX:fffe01")
|
check("T2c HEX 兜底", decode_line(b"\xff\xfe\x01") == "HEX:fffe01")
|
||||||
|
|
||||||
# T3 时间戳格式
|
# 时间戳格式
|
||||||
s = line_at(time.time(), "RX", "x")
|
s = line_at(time.time(), "RX", "x")
|
||||||
check("T3 行格式", s.startswith("[RX] ") and "T" in s.split(" ")[1], s)
|
check("时间戳行格式", s.startswith("[RX] ") and "T" in s.split(" ")[1], s)
|
||||||
|
|
||||||
# T4 截头算法(保留尾部 + 截后继续追加)
|
# T4 截头算法(保留尾部 + 截后继续追加)
|
||||||
pat = b"0123456789abcdef"
|
pat = b"0123456789abcdef"
|
||||||
@@ -1171,6 +1481,13 @@ def run_selftest():
|
|||||||
class _Cnt:
|
class _Cnt:
|
||||||
def __init__(self, n):
|
def __init__(self, n):
|
||||||
self.rx_bytes = n
|
self.rx_bytes = n
|
||||||
|
# v1.2 起 wait-idle 先过「无口门」(R4):假对象要伪装成「口正常开着」
|
||||||
|
self.ser = object()
|
||||||
|
self.state = "active"
|
||||||
|
self.desired = True
|
||||||
|
self._reopen_attempts = 0
|
||||||
|
self._reopen_last_error = None
|
||||||
|
self._reopen_next_at = 0.0
|
||||||
|
|
||||||
r = Daemon.wait_idle(_Cnt(500000), 0.2, 0.6, 0.3) # 常驻 daemon:累计值非 0
|
r = Daemon.wait_idle(_Cnt(500000), 0.2, 0.6, 0.3) # 常驻 daemon:累计值非 0
|
||||||
check("T5a 零字节判 no_data", r["reason"] == "no_data" and r["rx_bytes"] == 0
|
check("T5a 零字节判 no_data", r["reason"] == "no_data" and r["rx_bytes"] == 0
|
||||||
@@ -1298,12 +1615,147 @@ def run_selftest():
|
|||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# T10 端口建议:换 USB 口后 COM 号变了,唯一候选直接给(多候选不猜)
|
# 端口建议:换 USB 口后 COM 号变了,唯一候选直接给(多候选不猜)
|
||||||
check("T10 port_hint 唯一候选才给建议",
|
check("端口建议:唯一候选才给建议",
|
||||||
port_hint("COM7", ["COM6"]) == "COM6"
|
port_hint("COM7", ["COM6"]) == "COM6"
|
||||||
and port_hint("COM7", ["COM6", "COM9"]) is None
|
and port_hint("COM7", ["COM6", "COM9"]) is None
|
||||||
and port_hint("COM6", ["COM6"]) is None)
|
and port_hint("COM6", ["COM6"]) is None)
|
||||||
|
|
||||||
|
# T10 R1 启动重开意图判定:崩溃现场→重开;显式 close/stop、首启→不重开
|
||||||
|
m10 = {"pid": 11, "port": {"name": "COM3", "baud": 921600, "dtr": True, "rts": False},
|
||||||
|
"since": "2026-11-04T00:00:00.000+08:00"}
|
||||||
|
check("T10a 崩溃现场(meta.port 非空)→ 重开参数",
|
||||||
|
plan_startup_reopen(m10, 115200)
|
||||||
|
== {"port": "COM3", "baud": 921600, "dtr": True, "rts": False},
|
||||||
|
repr(plan_startup_reopen(m10, 115200)))
|
||||||
|
check("T10b 显式 close/stop 后(port:null)不重开",
|
||||||
|
plan_startup_reopen({"port": None}, 115200) is None)
|
||||||
|
check("T10c 首次启动(无 meta)不重开",
|
||||||
|
plan_startup_reopen({}, 115200) is None and plan_startup_reopen(None, 115200) is None)
|
||||||
|
check("T10d baud 缺失/坏值 → 按配置回退",
|
||||||
|
plan_startup_reopen({"port": {"name": "COM4"}}, 460800)
|
||||||
|
== {"port": "COM4", "baud": 460800, "dtr": False, "rts": False}
|
||||||
|
and plan_startup_reopen({"port": {"name": "COM4", "baud": "x"}}, 460800)["baud"] == 460800)
|
||||||
|
check("T10e port 记录残缺(无 name / 非 dict)不重开",
|
||||||
|
plan_startup_reopen({"port": {"baud": 1}}, 115200) is None
|
||||||
|
and plan_startup_reopen({"port": "COM3"}, 115200) is None)
|
||||||
|
|
||||||
|
# T11 R2 退避计划:1.5s 起步、倍增、封顶 30s
|
||||||
|
seq11 = []
|
||||||
|
b11 = REOPEN_SETTLE_S
|
||||||
|
for _ in range(7):
|
||||||
|
seq11.append(b11)
|
||||||
|
b11 = next_backoff(b11)
|
||||||
|
check("T11a 退避倍增封顶(1.5→3→6→12→24→30→30)",
|
||||||
|
seq11 == [1.5, 3.0, 6.0, 12.0, 24.0, 30.0, 30.0], repr(seq11))
|
||||||
|
check("T11b 封顶边界不越界",
|
||||||
|
next_backoff(24.0) == 30.0 and next_backoff(16.0) == 30.0 and next_backoff(30.0) == 30.0,
|
||||||
|
"16→%s" % next_backoff(16.0))
|
||||||
|
|
||||||
|
# T12 R3 缺口标记行格式(口径详述 = 设计说明 §6「自动重开与日志缺口标记」)
|
||||||
|
t12a = sys_daemon_started("1.2.0-skill", 100, 99, {"port": "COM3", "baud": 115200})
|
||||||
|
check("T12a 启动边界行:新旧 pid + 自动重开中",
|
||||||
|
"pid=100" in t12a and "prev_pid=99" in t12a and "auto-reopen pending COM3@115200" in t12a,
|
||||||
|
t12a)
|
||||||
|
t12b = sys_daemon_started("v", 1, None, None)
|
||||||
|
check("T12b 边界行:无需重开 + prev_pid 未知",
|
||||||
|
"prev_pid=none" in t12b and "auto-reopen none" in t12b, t12b)
|
||||||
|
t12c = sys_gap_start_lost("read: boom")
|
||||||
|
check("T12c 缺口起(丢口):错误原文 + GAP-START + 不可找回",
|
||||||
|
t12c.startswith("serial error: read: boom") and "GAP-START" in t12c
|
||||||
|
and "unrecoverable" in t12c, t12c)
|
||||||
|
t12d = sys_gap_start_recovery(99, "COM3")
|
||||||
|
check("T12d 缺口起(崩溃恢复):prev pid + 窗口下限声明",
|
||||||
|
"GAP-START" in t12d and "pid=99" in t12d and "COM3" in t12d
|
||||||
|
and "exact start unknown" in t12d and "unrecoverable" in t12d, t12d)
|
||||||
|
t12e = sys_gap_end("COM3", 115200, 7, 12.34, 5)
|
||||||
|
check("T12e 缺口止:窗口秒数 + 尝试次数 + gen + 不可找回",
|
||||||
|
"GAP-END" in t12e and "COM3@115200" in t12e and "gen=7" in t12e
|
||||||
|
and "12.3s" in t12e and "5 attempt(s)" in t12e and "unrecoverable" in t12e, t12e)
|
||||||
|
t12f = sys_gap_retry(2, "port busy: COM3 (held by another process)", 3.0)
|
||||||
|
check("T12f GAP-RETRY:尝试数 + 下次间隔 + 1/min 口径 + daemon.log 指针",
|
||||||
|
"attempt 2" in t12f and "3.0s" in t12f and "1/min" in t12f and "daemon.log" in t12f,
|
||||||
|
t12f)
|
||||||
|
t12g = line_at(1000.0, "SYS", t12e)
|
||||||
|
check("T12g 缺口行仍走 [SYS] 前缀(行格式不变量)",
|
||||||
|
t12g.startswith("[SYS] ") and "GAP-END" in t12g, t12g[:48])
|
||||||
|
|
||||||
|
# T13 R4 wait-idle 无口门:快速失败,不再计满返回 no_data
|
||||||
|
class _NoPort:
|
||||||
|
ser = None
|
||||||
|
state = "stopped"
|
||||||
|
|
||||||
|
t13_0 = time.time()
|
||||||
|
r13 = Daemon.wait_idle(_NoPort(), 0.2, 1.0, 0.3)
|
||||||
|
fast13 = (time.time() - t13_0) < 0.2
|
||||||
|
check("T13a 无口快速失败(ok:false、非 no_data、秒回)",
|
||||||
|
r13["ok"] is False and r13.get("reason") is None and fast13
|
||||||
|
and "port not open" in r13["error"] and "auto-reopen disabled" in r13["error"],
|
||||||
|
repr(r13))
|
||||||
|
|
||||||
|
class _Pending(_NoPort):
|
||||||
|
desired = True
|
||||||
|
_reopen_attempts = 3
|
||||||
|
_reopen_last_error = "port busy: COM3 (held by another process)"
|
||||||
|
_reopen_next_at = time.time() + 2.0
|
||||||
|
|
||||||
|
r13b = Daemon.wait_idle(_Pending(), 0.2, 1.0, 0.3)
|
||||||
|
check("T13b 失败文本带 auto-reopen 现场",
|
||||||
|
"pending" in r13b["error"] and "attempts=3" in r13b["error"]
|
||||||
|
and "port busy" in r13b["error"] and "next_retry_in_ms=" in r13b["error"],
|
||||||
|
repr(r13b))
|
||||||
|
|
||||||
|
# T14 R4 status.auto_reopen 现场(正常在开 / 缺口进行 / 显式关闭后)
|
||||||
|
class _AROk:
|
||||||
|
ser = object()
|
||||||
|
state = "active"
|
||||||
|
desired = True
|
||||||
|
_reopen_attempts = 0
|
||||||
|
_reopen_last_error = None
|
||||||
|
_reopen_next_at = 0.0
|
||||||
|
|
||||||
|
s14a = Daemon.auto_reopen_status(_AROk())
|
||||||
|
check("T14a 正常在开:enabled true / pending false",
|
||||||
|
s14a["enabled"] and not s14a["pending"] and s14a["attempts"] == 0
|
||||||
|
and s14a["next_retry_in_ms"] is None, repr(s14a))
|
||||||
|
|
||||||
|
class _ARGap(_AROk):
|
||||||
|
ser = None
|
||||||
|
state = "error"
|
||||||
|
_reopen_attempts = 2
|
||||||
|
_reopen_last_error = "port not found: COM3 (available: (none))"
|
||||||
|
_reopen_next_at = time.time() + 1.5
|
||||||
|
|
||||||
|
s14b = Daemon.auto_reopen_status(_ARGap())
|
||||||
|
check("T14b 缺口中:pending + 倒计时 + last_error",
|
||||||
|
s14b["enabled"] and s14b["pending"] and s14b["attempts"] == 2
|
||||||
|
and 0 < s14b["next_retry_in_ms"] <= 1600 and "port not found" in s14b["last_error"],
|
||||||
|
repr(s14b))
|
||||||
|
|
||||||
|
class _AROff:
|
||||||
|
ser = None
|
||||||
|
state = "stopped"
|
||||||
|
desired = False
|
||||||
|
_reopen_attempts = 0
|
||||||
|
_reopen_last_error = None
|
||||||
|
_reopen_next_at = 0.0
|
||||||
|
|
||||||
|
s14c = Daemon.auto_reopen_status(_AROff())
|
||||||
|
check("T14c 显式 close 后:enabled false(台架场景 2 断言点)",
|
||||||
|
s14c["enabled"] is False and s14c["pending"] is False, repr(s14c))
|
||||||
|
|
||||||
|
# T15 R1 restart 现场捕获(cmd_start force 路径的纯判定部分)
|
||||||
|
check("T15a restart 捕获:开着的口 → 记全现场",
|
||||||
|
restart_reopen_plan({"port": {"name": "COM3", "baud": 115200,
|
||||||
|
"dtr": False, "rts": True}})
|
||||||
|
== {"port": "COM3", "baud": 115200, "dtr": False, "rts": True})
|
||||||
|
check("T15b restart 捕获:重开缺口期(name 在/baud 缺)→ 仍恢复",
|
||||||
|
restart_reopen_plan({"port": {"name": "COM3", "baud": None, "desired": True}})
|
||||||
|
== {"port": "COM3"})
|
||||||
|
check("T15c restart 捕获:已 close / 旧 daemon 不可达 → 不重开",
|
||||||
|
restart_reopen_plan({"port": {"name": None}}) is None
|
||||||
|
and restart_reopen_plan({}) is None and restart_reopen_plan(None) is None)
|
||||||
|
|
||||||
width = 46
|
width = 46
|
||||||
all_ok = True
|
all_ok = True
|
||||||
for name, ok, detail in results:
|
for name, ok, detail in results:
|
||||||
@@ -1353,7 +1805,7 @@ def build_parser():
|
|||||||
|
|
||||||
sub.add_parser("clear", parents=[common], help="清空日志(代际 +1)")
|
sub.add_parser("clear", parents=[common], help="清空日志(代际 +1)")
|
||||||
|
|
||||||
p = sub.add_parser("wait-idle", parents=[common], help="等 RX 静默(@test 用)")
|
p = sub.add_parser("wait-idle", parents=[common], help="等 RX 静默(/serial-boot-test 用)")
|
||||||
p.add_argument("--settle-ms", type=int, default=8000,
|
p.add_argument("--settle-ms", type=int, default=8000,
|
||||||
help="静默判据毫秒(默认 8000:boot 有 3~4s 自然静默间隙)")
|
help="静默判据毫秒(默认 8000:boot 有 3~4s 自然静默间隙)")
|
||||||
p.add_argument("--max-ms", type=int, default=30000, help="最长等待毫秒(默认 30000)")
|
p.add_argument("--max-ms", type=int, default=30000, help="最长等待毫秒(默认 30000)")
|
||||||
|
|||||||
@@ -14,7 +14,11 @@ whenToUse: 需要向目标板烧录或复位后,用串口日志自动判定运
|
|||||||
| 脚本 | 作用 |
|
| 脚本 | 作用 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` | 一键闭环:open+clear → 烧录 → wait-idle → 提取 boot log → 烧录回读校验 → 标记校验。串口助手按 `$DSH_SERIAL_TOOL` > 同目录 > `../serial-assistant/scripts/serial_tool.py` 解析 |
|
| `.dsh/skills/serial-boot-test/scripts/run_boot_test.py` | 一键闭环:open+clear → 烧录 → wait-idle → 提取 boot log → 烧录回读校验 → 标记校验。串口助手按 `$DSH_SERIAL_TOOL` > 同目录 > `../serial-assistant/scripts/serial_tool.py` 解析 |
|
||||||
| `.dsh/skills/serial-boot-test/scripts/verify_log.py` | 对任意日志文件做标记校验(Required/Forbidden/锚点计数/顺序/耗时),可单独用 |
|
| `.dsh/skills/serial-boot-test/scripts/verify_log.py` | 对任意日志文件做标记校验(Required/Forbidden/锚点计数/顺序/耗时),可单独用。`--exact` 做**整行 `^…$` 锚定**(子串口径照不出"行还在但行首被吃掉/行内被插字节"那一类损坏)、`--pair` 做跨复位配对、`--allow-residual/--head-char` 服务崩溃注入腿、`--selftest` 跑内置判别用例(**证明这套判据不是空跑**)。四个行级开关(`--exact` / `--pair` / `--allow-residual` / `--head-char`)同时接进了 `run_boot_test.py`,/serial-boot-test 闭环直接可用 |
|
||||||
|
|
||||||
|
> **`/serial-boot-test` 是 DSH 会话的技能调用名、不是 shell 命令**(同 `/keil-build`,见 AGENTS「构建与验证」总口径):可执行形态 = 上表 `run_boot_test.py` 命令行(仓库根跑);项目配置与判据见本仓 `docs/测试说明/流程与规范/自动化测试流程.md`。
|
||||||
|
|
||||||
|
> **判据取向(重要,会复发)**:`--exact` 默认**只锚文本、不钉长度**——字节长度属于某一份构建,钉死它会在无辜改动上假红(`@@LEN=<n>` 是显式逃生口,不是默认)。子串 `--required` 不能用于"可能丢行首/被插字节"的行。来龙去脉与固定动作见本仓 `docs/测试说明/专项验证/启动日志原子性复验流程.md`。
|
||||||
|
|
||||||
交互式串口操作(open/send/log/wait-idle/status 等)不属于本 skill——
|
交互式串口操作(open/send/log/wait-idle/status 等)不属于本 skill——
|
||||||
**用 `serial-assistant` skill**。
|
**用 `serial-assistant` skill**。
|
||||||
@@ -47,7 +51,9 @@ whenToUse: 需要向目标板烧录或复位后,用串口日志自动判定运
|
|||||||
| `done_marker` + `max_boot_ms` | 开机耗时口径与上限 | 空/0 = 只报告不判 |
|
| `done_marker` + `max_boot_ms` | 开机耗时口径与上限 | 空/0 = 只报告不判 |
|
||||||
| `settle_ms` / `max_wait_ms` / `first_wait_ms` | RX 静默判据 / 总超时 / 首字节超时 | 8000 / 30000 / 12000 |
|
| `settle_ms` / `max_wait_ms` / `first_wait_ms` | RX 静默判据 / 总超时 / 首字节超时 | 8000 / 30000 / 12000 |
|
||||||
| `require_order` / `expect_reset_cause` / `require_suite_summary` | 顺序校验 / 复位原因白名单 / 套件汇总行 | 空 = 关闭 |
|
| `require_order` / `expect_reset_cause` / `require_suite_summary` | 顺序校验 / 复位原因白名单 / 套件汇总行 | 空 = 关闭 |
|
||||||
| `require_selftest_pass` + `device_ip` | 设备侧自检台账联合判定(FTP 抓取) | 关闭 |
|
| `selftest_scope` | 自检台账判定口径:`this-run`(烧录前取基线,只判新增)/ `all`(累计,任一记录即 FAIL) | `this-run` |
|
||||||
|
| `ftp_user` / `ftp_pass` | 抓 `/log/selftest.log` 用的 FTP 账号(设备无匿名通道) | `admin` / `admin` |
|
||||||
|
| `require_selftest_pass` + `device_ip` | 设备侧自检台账联合判定(FTP 抓取;**默认只判本次启动新增**,抓取失败/无数据源一律 FAIL) | 关闭 |
|
||||||
|
|
||||||
**先跑项目的构建**(本仓库 = `keil-build` skill,其它 Keil 项目照该 skill 套;
|
**先跑项目的构建**(本仓库 = `keil-build` skill,其它 Keil 项目照该 skill 套;
|
||||||
非 Keil 项目用项目自己的构建入口),确认 0 错误 0 警告后再进闭环。
|
非 Keil 项目用项目自己的构建入口),确认 0 错误 0 警告后再进闭环。
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
|
|
||||||
"require_selftest_pass": false,
|
"require_selftest_pass": false,
|
||||||
"device_ip": "",
|
"device_ip": "",
|
||||||
|
"selftest_scope": "this-run",
|
||||||
|
"ftp_user": "admin",
|
||||||
|
"ftp_pass": "admin",
|
||||||
|
|
||||||
"httpPort": 18765
|
"httpPort": 18765
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,9 +19,12 @@
|
|||||||
5. verify_log.py 标记校验(含启动锚点计数 config "boot_marker" + --max-boot-count;
|
5. verify_log.py 标记校验(含启动锚点计数 config "boot_marker" + --max-boot-count;
|
||||||
--expect-reset-cause 原因白名单;--require-order 标记顺序;
|
--expect-reset-cause 原因白名单;--require-order 标记顺序;
|
||||||
--done-marker + --max-boot-ms 开机耗时)
|
--done-marker + --max-boot-ms 开机耗时)
|
||||||
6. (仅当 --require-selftest-pass)自检记录联合判定(S7):
|
6. (仅当 --require-selftest-pass)自检台账联合判定:默认**只判本次启动新增**
|
||||||
--selftest-log 提供本地副本则直接用它,否则从设备 FTP 抓取 /log/selftest.log
|
——烧录前先经 FTP 取 /log/selftest.log 基线,启动后再取一次,只把"基线之后新增"的
|
||||||
(需 config "device_ip");判定:文件缺失 / 0 字节 = 无故障 → PASS
|
记录交给判定(该文件是 512 条环形累计历史,"非空即 FAIL"判一次启动永远不可能过)。
|
||||||
|
设备侧无此文件 = 台账干净 = PASS;**抓取失败 / 没有数据源 / 基线被环覆盖 → FAIL**,
|
||||||
|
不再"视为无记录"放过。累计口径用 --selftest-scope all;--selftest-log 走本地副本
|
||||||
|
(无基线可比,自动降级为 all)。FTP 凭据 --ftp-user/--ftp-pass(默认出厂 admin)。
|
||||||
|
|
||||||
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
|
采集在烧录**之前**武装(open + clear 先于烧录),daemon 常驻全量日志兜底,
|
||||||
无采集窗口/首字节等待仪式。
|
无采集窗口/首字节等待仪式。
|
||||||
@@ -46,7 +49,7 @@
|
|||||||
[--flash-cmd "<烧录命令>"] [--flash-timeout 180]
|
[--flash-cmd "<烧录命令>"] [--flash-timeout 180]
|
||||||
[--required "m1;m2"] [--forbidden "FAIL;ERROR;HardFault"]
|
[--required "m1;m2"] [--forbidden "FAIL;ERROR;HardFault"]
|
||||||
[--require-suite-summary "Storage Tests"]
|
[--require-suite-summary "Storage Tests"]
|
||||||
[--require-selftest-pass] [--selftest-log <本地副本>]
|
[--require-selftest-pass [--selftest-scope this-run|all]] [--selftest-log <副本>]
|
||||||
[--device-ip 192.168.1.100] [--out-dir <目录>] [--show-tail-lines 15]
|
[--device-ip 192.168.1.100] [--out-dir <目录>] [--show-tail-lines 15]
|
||||||
[--fw-hex <构建产物 hex>] [--no-fw-check]
|
[--fw-hex <构建产物 hex>] [--no-fw-check]
|
||||||
[--no-verify-flash] [--readback-timeout 120]
|
[--no-verify-flash] [--readback-timeout 120]
|
||||||
@@ -58,7 +61,7 @@
|
|||||||
|
|
||||||
退出码(与旧版一致):
|
退出码(与旧版一致):
|
||||||
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
|
0 = PASS(采集成功 + Required 全中 + Forbidden 零命中)
|
||||||
1 = FAIL(标记缺失/命中)
|
1 = FAIL(标记缺失/命中;或要求自检台账判定却判不了——无数据源/抓取失败/无法证明无新增)
|
||||||
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
|
2 = 采集或烧录错误(含烧录回读不一致 = 芯片内容与刚编的镜像不同,本次运行作废)
|
||||||
3 = 用法/预检错误
|
3 = 用法/预检错误
|
||||||
"""
|
"""
|
||||||
@@ -137,8 +140,8 @@ def _split_cmd(cmd_str):
|
|||||||
直接交给 subprocess、**不经 `cmd /c`**:CreateProcess 能直接跑 .bat,且 Python
|
直接交给 subprocess、**不经 `cmd /c`**:CreateProcess 能直接跑 .bat,且 Python
|
||||||
会给含空格的路径正确加引号。旧写法 `['cmd','/c', 串]` 在本仓库(目录名
|
会给含空格的路径正确加引号。旧写法 `['cmd','/c', 串]` 在本仓库(目录名
|
||||||
`DTU Code` 含空格)会被 cmd 的 /c 引号规则玩坏——实测报
|
`DTU Code` 含空格)会被 cmd 的 /c 引号规则玩坏——实测报
|
||||||
`'D:\Code\DTU' is not recognized`,回读校验被假判成"芯片内容不一致"。
|
「'D:\Code\DTU' is not recognized」,回读校验被假判成"芯片内容不一致"。
|
||||||
路径本身含空格时把该段用引号包住(`"D:\some dir\flash_tool.bat" flash`)。
|
路径本身含空格时把该段用引号包住(「"D:\some dir\flash_tool.bat" flash」)。
|
||||||
posix=False 是必须的:posix=True 会吃掉 Windows 路径里的反斜杠。
|
posix=False 是必须的:posix=True 会吃掉 Windows 路径里的反斜杠。
|
||||||
"""
|
"""
|
||||||
return [p.strip('"') for p in shlex.split(cmd_str, posix=False)]
|
return [p.strip('"') for p in shlex.split(cmd_str, posix=False)]
|
||||||
@@ -223,7 +226,7 @@ def _ftp_quit(ftp):
|
|||||||
def _prune_out_dir(out_dir, keep):
|
def _prune_out_dir(out_dir, keep):
|
||||||
"""只保留最近 N 份产物(`boot_log_*.txt` / `selftest_log_*.log`)。
|
"""只保留最近 N 份产物(`boot_log_*.txt` / `selftest_log_*.log`)。
|
||||||
|
|
||||||
这些文件按 `%Y%m%d_%H%M%S` 命名、每次 @test 各留一份,旧实现从不清理——
|
这些文件按 `%Y%m%d_%H%M%S` 命名、每次 /serial-boot-test 各留一份,旧实现从不清理——
|
||||||
本机实测 out 目录已堆到 31 份 boot_log + 7 份 selftest_log(2026-10-10)。
|
本机实测 out 目录已堆到 31 份 boot_log + 7 份 selftest_log(2026-10-10)。
|
||||||
文件名含时间戳 → 按名字排序即按时间排序。
|
文件名含时间戳 → 按名字排序即按时间排序。
|
||||||
"""
|
"""
|
||||||
@@ -240,42 +243,86 @@ def _prune_out_dir(out_dir, keep):
|
|||||||
return removed
|
return removed
|
||||||
|
|
||||||
|
|
||||||
def _fetch_selftest_log(device_ip, out_dir, stamp):
|
def _selftest_records(path):
|
||||||
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 匿名,根目录 = 卷根)。
|
"""把 selftest.log 的本地副本切成 64 B 定长记录(尾部残块不算记录)。
|
||||||
|
|
||||||
成功返回本地路径字符串;失败(网络不可达 / FTP 错误 / 设备侧文件缺失)返回 ""
|
返回 [] = 台账为空(干净设备);返回 None = **未知**(文件不可读等)。
|
||||||
(按 S7 语义视为无故障记录;此处打 WARN,调用方不判整体 FAIL)。
|
两者绝不能混——把"看不见"当成"没有"正是假绿的成因。
|
||||||
|
"""
|
||||||
|
if not path:
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
raw = Path(path).read_bytes()
|
||||||
|
except OSError as exc:
|
||||||
|
print("RUN_TEST_WARN: 自检台账本地副本不可读: {}".format(exc))
|
||||||
|
return None
|
||||||
|
n = len(raw) - (len(raw) % 64)
|
||||||
|
return [raw[i:i + 64] for i in range(0, n, 64)]
|
||||||
|
|
||||||
|
|
||||||
|
def _selftest_new_records(baseline, after):
|
||||||
|
"""基线对比 → 本次运行新增的记录,返回 (新增列表, 能否证明)。
|
||||||
|
|
||||||
|
· baseline is None → 基线未知 → 无法证明
|
||||||
|
· baseline == [] → 设备原本无台账 → after 全部算本次新增
|
||||||
|
· 基线最后一条在 after 里找得到 → 它之后的就是新增
|
||||||
|
· 找不到 → 512 条环已覆盖基线(更新量 ≥ 环容)→ 无法证明;
|
||||||
|
宁可 FAIL,也不拿"看不到"当"没有"
|
||||||
|
"""
|
||||||
|
if baseline is None or after is None:
|
||||||
|
return [], False
|
||||||
|
if not baseline:
|
||||||
|
return list(after), True
|
||||||
|
last = baseline[-1]
|
||||||
|
for idx in range(len(after) - 1, -1, -1):
|
||||||
|
if after[idx] == last:
|
||||||
|
return list(after[idx + 1:]), True
|
||||||
|
return [], False
|
||||||
|
|
||||||
|
def _fetch_selftest_log(device_ip, out_dir, stamp, ftp_user="admin", ftp_pass="admin"):
|
||||||
|
"""从设备 FTP 抓取 /log/selftest.log(lftpd :21 需鉴权,根目录 = 卷根)。
|
||||||
|
|
||||||
|
返回 (本地路径, 状态),状态 ∈ {"ok", "absent", "error"}:
|
||||||
|
ok = 抓到文件(路径有效)
|
||||||
|
absent = 设备侧确实没有这个文件 = 台账干净(正常路径,不是故障)
|
||||||
|
error = 连不上 / 登录被拒 / FTP 故障 → 结果**未知**,调用方必须判 FAIL
|
||||||
|
|
||||||
|
⚠ 2026-10-22 关键更正:旧实现把 absent 与 error 都返回 "",调用方一律"视为无
|
||||||
|
记录 → PASS"。于是抓取失败会被读成"设备很干净"——这就是那条假绿。三种结果
|
||||||
|
必须分开。
|
||||||
|
|
||||||
2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp /
|
2026-10-02 台架修复:原实现只 `except OSError`,而 ftplib 的 error_temp /
|
||||||
error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上
|
error_perm 继承自 Exception 而非 OSError —— 台账"仅故障才写",干净设备上
|
||||||
/log/selftest.log 根本不存在,RETR 返回 450 直接抛出未捕获异常,令整个
|
/log/selftest.log 根本不存在,RETR 返回 450 直接抛出未捕获异常,令整个
|
||||||
`@test --require-selftest-pass` 在最后一步崩掉(而它本该是 PASS 路径)。
|
`run_boot_test.py --require-selftest-pass` 在最后一步崩掉(而它本该是 PASS 路径)。
|
||||||
现把"设备侧无此文件"单列为正常路径(INFO),其余 FTP 错误保留 WARN。
|
现把"设备侧无此文件"单列为正常路径(INFO),其余 FTP 错误保留 WARN。
|
||||||
"""
|
"""
|
||||||
local = Path(out_dir) / "selftest_log_{}.log".format(stamp)
|
local = Path(out_dir) / "selftest_log_{}.log".format(stamp)
|
||||||
ftp = ftplib.FTP()
|
ftp = ftplib.FTP()
|
||||||
try:
|
try:
|
||||||
ftp.connect(device_ip, 21, timeout=10)
|
ftp.connect(device_ip, 21, timeout=10)
|
||||||
ftp.login() # 匿名(lftpd 接受 USER anonymous / 空 PASS)
|
# lftpd 按 /config.ini 的 [ftp] 校验账号(user / pwd_hash),出厂 admin/admin;
|
||||||
|
# 设备没有匿名通道——匿名登录一律 530,所以这里必须带凭据。
|
||||||
|
ftp.login(ftp_user, ftp_pass)
|
||||||
except (OSError, ftplib.Error) as e:
|
except (OSError, ftplib.Error) as e:
|
||||||
print("RUN_TEST_WARN: selftest fetch failed: {} "
|
print("SELFTEST_FETCH: {} -> ERROR ({}) 连不上/登录被拒/FTP 故障 → 状态未知"
|
||||||
"(按 S7 语义视为无记录;设备网络/FTP 需另行排查)".format(e))
|
.format(device_ip, e))
|
||||||
return ""
|
return "", "error"
|
||||||
|
|
||||||
buf = bytearray()
|
buf = bytearray()
|
||||||
try:
|
try:
|
||||||
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
|
ftp.retrbinary("RETR /log/selftest.log", buf.extend)
|
||||||
except ftplib.all_errors as e:
|
except ftplib.all_errors as e:
|
||||||
# 450/550 = 设备侧无此文件:干净台账,S7 判 PASS(正常路径,非故障)
|
# 450/550 = 设备侧无此文件:干净台账 → 判定 PASS(正常路径,非故障)
|
||||||
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 按 S7 视为无故障记录"
|
print("SELFTEST_FETCH: {} -> (ledger absent: {}) 台账干净"
|
||||||
.format(device_ip, str(e).strip()))
|
.format(device_ip, str(e).strip()))
|
||||||
_ftp_quit(ftp)
|
_ftp_quit(ftp)
|
||||||
return ""
|
return "", "absent"
|
||||||
|
|
||||||
_ftp_quit(ftp)
|
_ftp_quit(ftp)
|
||||||
local.write_bytes(bytes(buf))
|
local.write_bytes(bytes(buf))
|
||||||
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
|
print("SELFTEST_FETCH: {} -> {} ({} bytes)".format(device_ip, local, len(buf)))
|
||||||
return str(local)
|
return str(local), "ok"
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
@@ -308,14 +355,23 @@ def main(argv=None):
|
|||||||
help="forbidden patterns, ';' separated (config 'forbidden')")
|
help="forbidden patterns, ';' separated (config 'forbidden')")
|
||||||
ap.add_argument("--require-suite-summary", default=None,
|
ap.add_argument("--require-suite-summary", default=None,
|
||||||
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
|
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
|
||||||
"with N>0, ';' separated (T-2). Empty = off")
|
"with N>0, ';' separated. Empty = off")
|
||||||
ap.add_argument("--require-selftest-pass", action="store_true", default=None,
|
ap.add_argument("--require-selftest-pass", action="store_true", default=None,
|
||||||
help="jointly judge /log/selftest.log (S7): missing/empty = PASS, "
|
help="jointly judge /log/selftest.log: missing/empty = PASS, "
|
||||||
"any 64B record = FAIL")
|
"any 64B record = FAIL")
|
||||||
ap.add_argument("--selftest-log", default=None,
|
ap.add_argument("--selftest-log", default=None,
|
||||||
help="local copy of /log/selftest.log (skips device-side fetch when given)")
|
help="local copy of /log/selftest.log (skips device-side fetch when given)")
|
||||||
|
ap.add_argument("--selftest-scope", choices=("this-run", "all"), default=None,
|
||||||
|
help="this-run (default): only records added by THIS run count as "
|
||||||
|
"faults -- needs a pre-flash baseline over FTP, so device_ip is "
|
||||||
|
"required. all: legacy cumulative rule (any record = FAIL); use it "
|
||||||
|
"for audits or a first baseline. Unproveable cases FAIL, never PASS.")
|
||||||
ap.add_argument("--device-ip", default=None,
|
ap.add_argument("--device-ip", default=None,
|
||||||
help="device IP for fetching /log/selftest.log via FTP (anonymous)")
|
help="device IP for fetching /log/selftest.log via FTP")
|
||||||
|
ap.add_argument("--ftp-user", default=None,
|
||||||
|
help="FTP user for the selftest fetch (config 'ftp_user'; default admin)")
|
||||||
|
ap.add_argument("--ftp-pass", default=None,
|
||||||
|
help="FTP password for the selftest fetch (config 'ftp_pass'; default admin)")
|
||||||
ap.add_argument("--out-dir", default=None)
|
ap.add_argument("--out-dir", default=None)
|
||||||
ap.add_argument("--show-tail-lines", type=int, default=None)
|
ap.add_argument("--show-tail-lines", type=int, default=None)
|
||||||
ap.add_argument("--fw-hex", default=None,
|
ap.add_argument("--fw-hex", default=None,
|
||||||
@@ -336,6 +392,23 @@ def main(argv=None):
|
|||||||
ap.add_argument("--require-order", default=None,
|
ap.add_argument("--require-order", default=None,
|
||||||
help="'>'-separated markers that must appear in that line order "
|
help="'>'-separated markers that must appear in that line order "
|
||||||
"(catches out-of-order boot steps); empty = off")
|
"(catches out-of-order boot steps); empty = off")
|
||||||
|
ap.add_argument("--exact", action="append", default=[],
|
||||||
|
help="repeatable regex that must match a WHOLE firmware line "
|
||||||
|
"(forwarded to verify_log.py --exact). Use it instead of --required "
|
||||||
|
"for any marker whose line could lose its head or gain spliced "
|
||||||
|
"bytes -- substring matching cannot see that class of damage. "
|
||||||
|
"Suffix '@@LEN=<n>' pins byte length: opt-in only, lengths belong "
|
||||||
|
"to one image")
|
||||||
|
ap.add_argument("--pair", action="append", nargs=2, metavar=("FIRST", "THEN"), default=[],
|
||||||
|
help="repeatable cross-reset pairing (exactly one FIRST, then exactly "
|
||||||
|
"one THEN after it), e.g. --pair MEMFAULT 'reset cause:'")
|
||||||
|
ap.add_argument("--allow-residual", type=int, default=0,
|
||||||
|
help="accept up to N stray bytes before the line head on a --exact hit "
|
||||||
|
"(crash-dump legs: 1 leftover from a reset mid-byte is known; "
|
||||||
|
"recorded, not a failure)")
|
||||||
|
ap.add_argument("--head-char", default="",
|
||||||
|
help="every --exact hit must start with this char after the optional "
|
||||||
|
"residual prefix (crash-dump legs: '[')")
|
||||||
ap.add_argument("--done-marker", default=None,
|
ap.add_argument("--done-marker", default=None,
|
||||||
help="marker whose line marks boot completion (for --max-boot-ms); "
|
help="marker whose line marks boot completion (for --max-boot-ms); "
|
||||||
"empty = don't measure")
|
"empty = don't measure")
|
||||||
@@ -384,10 +457,17 @@ def main(argv=None):
|
|||||||
args.forbidden = str(_pick(args.forbidden, "forbidden", default=FORBIDDEN_DEFAULT))
|
args.forbidden = str(_pick(args.forbidden, "forbidden", default=FORBIDDEN_DEFAULT))
|
||||||
args.require_suite_summary = str(_pick(args.require_suite_summary,
|
args.require_suite_summary = str(_pick(args.require_suite_summary,
|
||||||
"require_suite_summary", default="") or "")
|
"require_suite_summary", default="") or "")
|
||||||
|
args.selftest_scope = str(_pick(args.selftest_scope, "selftest_scope",
|
||||||
|
default="this-run") or "this-run")
|
||||||
|
if args.selftest_scope not in ("this-run", "all"):
|
||||||
|
print("RUN_TEST_USAGE_ERROR: selftest_scope must be this-run or all")
|
||||||
|
return 3
|
||||||
args.require_selftest_pass = bool(_pick(args.require_selftest_pass,
|
args.require_selftest_pass = bool(_pick(args.require_selftest_pass,
|
||||||
"require_selftest_pass", default=False))
|
"require_selftest_pass", default=False))
|
||||||
args.selftest_log = str(_pick(args.selftest_log, "selftest_log", default="") or "")
|
args.selftest_log = str(_pick(args.selftest_log, "selftest_log", default="") or "")
|
||||||
args.device_ip = str(_pick(args.device_ip, "device_ip", default="") or "")
|
args.device_ip = str(_pick(args.device_ip, "device_ip", default="") or "")
|
||||||
|
args.ftp_user = str(_pick(args.ftp_user, "ftp_user", default="admin") or "admin")
|
||||||
|
args.ftp_pass = str(_pick(args.ftp_pass, "ftp_pass", default="admin") or "admin")
|
||||||
args.out_dir = str(_pick(args.out_dir, "out_dir",
|
args.out_dir = str(_pick(args.out_dir, "out_dir",
|
||||||
default=str(Path(tempfile.gettempdir()) / "serial-boot-test")))
|
default=str(Path(tempfile.gettempdir()) / "serial-boot-test")))
|
||||||
args.show_tail_lines = int(_pick(args.show_tail_lines, "show_tail_lines", default=15))
|
args.show_tail_lines = int(_pick(args.show_tail_lines, "show_tail_lines", default=15))
|
||||||
@@ -446,6 +526,34 @@ def main(argv=None):
|
|||||||
print("Port: {} @ {} settle={}ms max={}ms".format(
|
print("Port: {} @ {} settle={}ms max={}ms".format(
|
||||||
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
|
port, args.baud_rate, args.settle_ms, args.max_wait_ms))
|
||||||
|
|
||||||
|
# 0.6 自检台账基线(只看本次新增):**必须排在串口采集武装之前**。
|
||||||
|
# /log/selftest.log 是 512 条环形累计历史,用"文件非空即 FAIL"去判一次启动,在任何
|
||||||
|
# 做过故障注入或长跑的设备上永远不可能 PASS(本仓台架实测:抓到的 29 条记录时间戳
|
||||||
|
# 全是 2026-10-10 ~ 10-14 留下的,与本次启动无关)。这里记下基线的最后一条记录,
|
||||||
|
# 启动后只判"它之后新增了几条";抓不到基线就判不了本次 → 退出码 2,不降级成"视为
|
||||||
|
# 无记录"。
|
||||||
|
# ⚠ 为什么排在 open/clear 之前(2026-10-22 实测踩到):抓基线是一次真实 FTP 会话,
|
||||||
|
# 设备会把 `USER/PASS/RETR` 打到串口上,客户端退出时还会多打一行 `[ERR] [FTP] error
|
||||||
|
# reading next command`——放在采集武装之后,这些行就落进本次启动日志:既让 Forbidden
|
||||||
|
# `[ERR]` 命中(本次判 FAIL),又把开机耗时从 ~0.6 s 拉到 ~8 s(假指标)。
|
||||||
|
st_baseline = None
|
||||||
|
if args.require_selftest_pass and args.selftest_scope == "this-run":
|
||||||
|
if args.selftest_log:
|
||||||
|
print("SELFTEST_BASELINE: 用的是本地副本,无基线可比 → 本次按累计口径判定")
|
||||||
|
args.selftest_scope = "all"
|
||||||
|
elif not args.device_ip:
|
||||||
|
print("SELFTEST_BASELINE: 没有 device_ip 就取不到基线 → 本次按累计口径判定")
|
||||||
|
args.selftest_scope = "all"
|
||||||
|
else:
|
||||||
|
base_local, base_status = _fetch_selftest_log(
|
||||||
|
args.device_ip, out_dir, stamp + "_base", args.ftp_user, args.ftp_pass)
|
||||||
|
if base_status == "error":
|
||||||
|
print("RUN_TEST_ERROR: 自检台账基线抓取失败 → 无法证明本次无新增故障"
|
||||||
|
"(不把抓取失败当成设备干净)")
|
||||||
|
return 2
|
||||||
|
st_baseline = _selftest_records(base_local)
|
||||||
|
print("SELFTEST_BASELINE: 既有记录 {} 条(status={})"
|
||||||
|
.format(len(st_baseline or []), base_status))
|
||||||
# 1. open(幂等;未运行则自动拉起常驻 daemon)
|
# 1. open(幂等;未运行则自动拉起常驻 daemon)
|
||||||
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
|
data, out = tool("open", "--port", port, "--baud", str(args.baud_rate), "--timeout", "15")
|
||||||
if data is None:
|
if data is None:
|
||||||
@@ -590,13 +698,50 @@ def main(argv=None):
|
|||||||
print("RUN_TEST_ERROR: flash readback mismatch (chip content != built image)")
|
print("RUN_TEST_ERROR: flash readback mismatch (chip content != built image)")
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
# 5.5 自检记录抓取(S7):启用且未提供本地副本时才走 FTP(需 config "device_ip")
|
# 5.5 自检记录判定:默认只判「本次启动新增」,累计口径要显式指定。
|
||||||
|
# 三种抓取结果必须分开对待(旧实现把后两种都当"无记录 → PASS",就是假绿):
|
||||||
|
# absent(设备侧无此文件) → 台账干净 → 无新增 → PASS
|
||||||
|
# error(连不上/登录被拒/故障)→ 未知 → FAIL(要求判定却没数据,不能当通过)
|
||||||
|
# 没有数据源(无 device_ip 又无 --selftest-log)→ FAIL(同上)
|
||||||
selftest_local = args.selftest_log
|
selftest_local = args.selftest_log
|
||||||
if args.require_selftest_pass and not selftest_local and not args.device_ip:
|
selftest_forced_fail = False
|
||||||
print("RUN_TEST_WARN: require_selftest_pass but no device_ip/selftest_log "
|
if args.require_selftest_pass:
|
||||||
"-> treated as no record (S7 PASS path)")
|
if selftest_local:
|
||||||
if args.require_selftest_pass and not selftest_local and args.device_ip:
|
pass # 本地副本:按它自己的口径判(基线已在 2.5 降级)
|
||||||
selftest_local = _fetch_selftest_log(args.device_ip, out_dir, stamp)
|
elif not args.device_ip:
|
||||||
|
print("RUN_TEST_FAIL: 要求自检台账判定,但既没有 device_ip 也没有 --selftest-log"
|
||||||
|
" → 无数据源,判 FAIL(旧行为是 WARN 后当无记录放过)")
|
||||||
|
selftest_forced_fail = True
|
||||||
|
else:
|
||||||
|
after_local, after_status = _fetch_selftest_log(
|
||||||
|
args.device_ip, out_dir, stamp, args.ftp_user, args.ftp_pass)
|
||||||
|
if after_status == "error":
|
||||||
|
print("RUN_TEST_FAIL: 自检台账抓取失败(设备不可达 / FTP 故障)→ 判 FAIL"
|
||||||
|
"(不把抓取失败当成设备干净)")
|
||||||
|
selftest_forced_fail = True
|
||||||
|
elif after_status == "absent":
|
||||||
|
selftest_local = None # 空台账 = 确无故障
|
||||||
|
elif args.selftest_scope == "this-run":
|
||||||
|
new_recs, provable = _selftest_new_records(
|
||||||
|
st_baseline, _selftest_records(after_local))
|
||||||
|
if not provable:
|
||||||
|
print("SELFTEST_JUDGE: FAIL —— 无法证明本次无新增"
|
||||||
|
"(基线最后一条已被 512 条环覆盖,或基线/台账不可读)")
|
||||||
|
selftest_forced_fail = True
|
||||||
|
elif not new_recs:
|
||||||
|
print("SELFTEST_JUDGE: 本次启动无新增记录(基线 {} 条 → 台账不变)"
|
||||||
|
.format(len(st_baseline or [])))
|
||||||
|
selftest_local = None
|
||||||
|
else:
|
||||||
|
# 文件名必须落在 selftest_log_*.log 里,才会被 --keep-out 的保留规则收走
|
||||||
|
delta = out_dir / "selftest_log_{}_new.log".format(stamp)
|
||||||
|
delta.write_bytes(b"".join(new_recs))
|
||||||
|
print("SELFTEST_JUDGE: 本次启动新增 {} 条 → 只把新增部分交给判定"
|
||||||
|
.format(len(new_recs)))
|
||||||
|
selftest_local = str(delta)
|
||||||
|
else:
|
||||||
|
selftest_local = after_local # --selftest-scope all:累计口径
|
||||||
|
|
||||||
|
|
||||||
# 6. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
|
# 6. 标记校验(含固件身份戳、启动锚点计数、可选的自检记录联合判定)
|
||||||
# 启动锚点(config "boot_marker",如本仓库的 'reset cause:')未配置 =
|
# 启动锚点(config "boot_marker",如本仓库的 'reset cause:')未配置 =
|
||||||
@@ -615,7 +760,14 @@ def main(argv=None):
|
|||||||
expect_reset_cause=args.expect_reset_cause,
|
expect_reset_cause=args.expect_reset_cause,
|
||||||
require_order=args.require_order,
|
require_order=args.require_order,
|
||||||
done_marker=args.done_marker,
|
done_marker=args.done_marker,
|
||||||
max_boot_ms=args.max_boot_ms)
|
max_boot_ms=args.max_boot_ms,
|
||||||
|
exact=args.exact, pairs=args.pair,
|
||||||
|
allow_residual=args.allow_residual, head_char=args.head_char)
|
||||||
|
if selftest_forced_fail and rc == 0:
|
||||||
|
# 标记校验全过,但自检台账判定根本没能执行(无数据源 / 抓取失败 / 无法证明无新增)
|
||||||
|
# —— 这类"判不了"必须以 FAIL 收口,否则就又回到假绿。
|
||||||
|
print("RUN_TEST_VERDICT: FAIL —— 自检台账判定未能执行(见上方 SELFTEST 行)")
|
||||||
|
rc = 1
|
||||||
print("RUN_TEST_RC={}".format(rc))
|
print("RUN_TEST_RC={}".format(rc))
|
||||||
return rc
|
return rc
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""verify_log.py - 对抓取的 MCU 调试日志做标记校验(纯标准库,2026-09-20 替代 verify_log.ps1)。
|
"""verify_log.py - 对抓取的 MCU 调试日志做标记校验(纯标准库,2026-09-20 替代 verify_log.ps1)。
|
||||||
|
|
||||||
用于 @test 闭环:烧录后从串口助手日志提取的开机日志交给本脚本 grep 标记,
|
用于 /serial-boot-test 闭环:烧录后从串口助手日志提取的开机日志交给本脚本 grep 标记,
|
||||||
输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。
|
输出结构化判定(格式与旧 PowerShell 版逐字一致,下游文档/习惯不受影响)。
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
@@ -16,10 +16,15 @@
|
|||||||
[--require-order 'App init OK>Network Init>net init: OK']
|
[--require-order 'App init OK>Network Init>net init: OK']
|
||||||
[--done-marker 'net init: OK'] [--max-boot-ms 8000] [--json]
|
[--done-marker 'net init: OK'] [--max-boot-ms 8000] [--json]
|
||||||
|
|
||||||
退出码(与旧版一致):
|
退出码(2026-10-27 task-32 起把 2 讲清楚;/serial-boot-test 那层的码不变):
|
||||||
0 = PASS(所有 Required 标记找到,Forbidden 零命中,汇总行要求满足)
|
0 = PASS(所有 Required 标记找到,Forbidden 零命中,汇总行要求满足)
|
||||||
1 = FAIL(缺 Required / 命中 Forbidden / 汇总行要求不满足)
|
1 = FAIL(**设备侧真有坏消息**:缺 Required / 命中 Forbidden / 汇总行不满足 / 顺序或耗时超限)
|
||||||
2 = ERROR(日志文件不存在/不可读)
|
2 = **测量没成立**(日志不存在或不可读;或开了 --max-boot-count 却零启动锚点 = 采集器没开、
|
||||||
|
日志被清、或采集没从复位开始)——这**不是**一次设备故障,按码聚合的脚本必须把它和 1
|
||||||
|
分开算,否则"采集断了"会被统计成"又崩了一次"。verdict 字段给 MEASUREMENT_BROKEN,
|
||||||
|
JSON 里 measurement.* 带三计数明细(payload_lines / boot_anchor_count / exact_hits /
|
||||||
|
loose_neighbours)。注意优先级:**零锚点但同时有 Forbidden 命中或自检故障记录时仍判 1**,
|
||||||
|
测量问题最多在旁边加一行 WARN——绝不能把真崩溃降级成"没数据"。
|
||||||
|
|
||||||
默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app/app_main.c + App/task/net_task.c),
|
默认 Required 标记对应 STM32F4-Base 启动序列(见 App/app/app_main.c + App/task/net_task.c),
|
||||||
按测试用例覆盖,例如:
|
按测试用例覆盖,例如:
|
||||||
@@ -37,15 +42,19 @@
|
|||||||
- 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为)
|
- 读取编码:先按 UTF-8,解码失败回退系统 locale 编码(旧版 Get-Content 行为)
|
||||||
- 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页
|
- 控制台输出重配置为 UTF-8(errors=replace):任何 Windows 控制台代码页
|
||||||
都只影响终端显示,不会导致本脚本崩溃
|
都只影响终端显示,不会导致本脚本崩溃
|
||||||
- 汇总行(2026-10-01 加,T-2):纯 Required 子串匹配有个盲区——套件启动横幅
|
- 汇总行(2026-10-01 加):纯 Required 子串匹配有个盲区——套件启动横幅
|
||||||
命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途
|
命中即判 PASS,**套件是否跑到汇总行、断言数是否为 0 完全不可见**(套件中途
|
||||||
挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary`
|
挂死/复位、或 Phase 宏全关时会被误判为通过)。`--require-suite-summary`
|
||||||
要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
|
要求日志中出现 `=== <名称>: N/M PASSED (failed=0) ===` 且 **N > 0**。
|
||||||
- 自检记录联合判定(S7,2026-10 实机窗口):`--require-selftest-pass` 对 /log/selftest.log
|
- 自检记录联合判定(2026-10 实机窗口;判据 2026-10-22 改为"只看本次新增"):
|
||||||
的本地副本(定长 64B 记录,格式见 App/log/sys_log.h)判定。自检仅故障写盘(v1 不记
|
`--require-selftest-pass` 对 /log/selftest.log 的本地副本(定长 64B 记录,格式见
|
||||||
通过项):**文件缺失 / 0 字节 = 无故障 → PASS**;存在任何一条记录 = 有故障 → FAIL
|
App/log/sys_log.h)判定。自检仅故障写盘(v1 不记通过项):**文件缺失 / 0 字节 = 无故障
|
||||||
(退出码 1),并逐条打印时间戳 + 描述。抓取失败(设备不可达 / FTP 错误等)由调用方
|
→ PASS**;存在记录 = FAIL(退出码 1),逐条打印时间戳 + 描述。
|
||||||
(run_boot_test.py)先打 WARN,本地文件按缺失处理 → 按上述规则不判整体 FAIL。
|
⚠ 该文件是 512 条环形**累计历史**,所以本函数收到的"有记录"必须由调用方先做差集:
|
||||||
|
run_boot_test.py 在烧录前取基线、启动后只把"基线之后新增"的记录写成临时副本交给本
|
||||||
|
函数(`--selftest-scope this-run`,默认)。**抓取失败/无数据源/无法证明无新增一律由
|
||||||
|
调用方判 FAIL**——旧实现把这些都当"无记录 → PASS",是假绿(详见
|
||||||
|
docs/测试说明/流程与规范/自动化测试流程.md 的自检结果判定节)。
|
||||||
- 启动锚点计数(2026-10-10):`--max-boot-count N`(run_boot_test.py 默认传 1)统计
|
- 启动锚点计数(2026-10-10):`--max-boot-count N`(run_boot_test.py 默认传 1)统计
|
||||||
`reset cause:` 锚点出现次数——**0 次** = 采集没从复位开始(标记全中也可能只是上一轮
|
`reset cause:` 锚点出现次数——**0 次** = 采集没从复位开始(标记全中也可能只是上一轮
|
||||||
的残留日志);**>N 次** = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
|
的残留日志);**>N 次** = 板子在采集窗口内自己复位过(IWDG 咬狗 / Fault 后
|
||||||
@@ -110,6 +119,106 @@ def _read_lines(path):
|
|||||||
return raw.decode("utf-8", errors="replace").splitlines()
|
return raw.decode("utf-8", errors="replace").splitlines()
|
||||||
|
|
||||||
|
|
||||||
|
# --- whole-line exact matching (2026-10-26, task-36; absorbed from bench-runner scratch) ---
|
||||||
|
# Why this exists (measured, not theoretical): -Required is a SUBSTRING check, and the
|
||||||
|
# console-interleave defect kept the substring while destroying the line, e.g. the
|
||||||
|
# headless victim line "26-10-26 08:37:07][NAND_FTL] Initializing NAND FTL..." still
|
||||||
|
# contains the marker "Initializing NAND FTL" -> the old check called that boot a PASS.
|
||||||
|
# Any marker whose line can lose its head is therefore NOT verifiable by substring alone.
|
||||||
|
# --exact anchors the WHOLE payload (^ ... $) so one spliced byte breaks the match, and it
|
||||||
|
# also reports lines containing the pattern's longest literal run that do NOT match fully:
|
||||||
|
# that is the splicing signature and it must never stay silent. Length pinning is OPT-IN
|
||||||
|
# ("@@LEN=<n>"): byte lengths belong to one image, pinning them by default gives a false
|
||||||
|
# red on innocent commits (measured uiTask stack swing 4116..4188 on ONE image).
|
||||||
|
EXACT_LEN_SEP = "@@LEN="
|
||||||
|
_DAEMON_RX = re.compile(r"^\[RX\] \S+ (.*)$")
|
||||||
|
_RUNBT_RX = re.compile(r"^\[\+\d+ms\] (.*)$")
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_exact(spec):
|
||||||
|
"""'REGEX@@LEN=42' -> (regex, 42); no suffix -> (regex, None)."""
|
||||||
|
if EXACT_LEN_SEP in spec:
|
||||||
|
pat, _, ln = spec.rpartition(EXACT_LEN_SEP)
|
||||||
|
return pat, int(ln.strip())
|
||||||
|
return spec, None
|
||||||
|
|
||||||
|
|
||||||
|
def _payload_of(line):
|
||||||
|
"""Drop the collector prefix so ^...$ applies to the firmware line itself.
|
||||||
|
|
||||||
|
Handles serial-assistant daemon lines ('[RX] <iso> ...'), run_boot_test boot logs
|
||||||
|
('[+Nms] ...') and bare payload lines. Archived evidence files may carry a leading
|
||||||
|
'#' provenance header; those are dropped unless include_comments is set (otherwise a
|
||||||
|
header that merely MENTIONS [ERR] would trip Forbidden / pollute exact counts).
|
||||||
|
"""
|
||||||
|
for rx in (_DAEMON_RX, _RUNBT_RX):
|
||||||
|
m = rx.match(line)
|
||||||
|
if m:
|
||||||
|
return m.group(1)
|
||||||
|
return line
|
||||||
|
|
||||||
|
|
||||||
|
def _longest_literal(pattern):
|
||||||
|
"""Longest run of non-metacharacters in a regex -> a greppable neighbour probe."""
|
||||||
|
parts = re.split(r"\\.|\\\[|\]|\(|\)|\{|\}|\||\*|\+|\?|\^|\$|\.|\[:", pattern)
|
||||||
|
parts = [x for x in parts if x and not x.startswith("\\")]
|
||||||
|
return max(parts, key=len) if parts else ""
|
||||||
|
|
||||||
|
|
||||||
|
def exact_check(payloads, spec, allow_residual=0, head_char=""):
|
||||||
|
"""(ok, detail, neighbours, residuals) for one --exact spec. See block note above."""
|
||||||
|
pat, want_len = _parse_exact(spec)
|
||||||
|
rx = re.compile("^" + pat + "$")
|
||||||
|
hits, residuals, consumed = [], [], set()
|
||||||
|
for idx, pl in enumerate(payloads):
|
||||||
|
if rx.match(pl):
|
||||||
|
hits.append(pl)
|
||||||
|
consumed.add(idx)
|
||||||
|
continue
|
||||||
|
if allow_residual > 0:
|
||||||
|
for k in range(1, allow_residual + 1):
|
||||||
|
if len(pl) > k and pl[k] == "[" and rx.match(pl[k:]):
|
||||||
|
hits.append(pl[k:])
|
||||||
|
residuals.append((k, pl[:k]))
|
||||||
|
consumed.add(idx)
|
||||||
|
break
|
||||||
|
key = _longest_literal(pat)
|
||||||
|
# a line counted (directly or after residual realignment) is never also a neighbour
|
||||||
|
neigh = [pl for i2, pl in enumerate(payloads) if key and key in pl and i2 not in consumed]
|
||||||
|
detail = "hits={} lens={} loose_neighbours={}".format(
|
||||||
|
len(hits), [len(h) for h in hits], len(neigh))
|
||||||
|
if residuals:
|
||||||
|
detail += " residual_head_bytes={}".format([(k, repr(v)) for k, v in residuals])
|
||||||
|
ok = len(hits) >= 1 and not neigh
|
||||||
|
if want_len is not None:
|
||||||
|
ok = ok and all(len(h) == want_len for h in hits)
|
||||||
|
detail += " want_len={}".format(want_len)
|
||||||
|
if head_char:
|
||||||
|
bad = [h for h in hits if not h.startswith(head_char)]
|
||||||
|
ok = ok and not bad
|
||||||
|
detail += " head_char={!r} bad={}".format(head_char, len(bad))
|
||||||
|
return ok, detail, neigh[:3], residuals[:3]
|
||||||
|
|
||||||
|
|
||||||
|
def pair_check(payloads, first_pat, then_pat):
|
||||||
|
"""Exactly one FIRST line with exactly one THEN line after it (cross-reset pairing).
|
||||||
|
|
||||||
|
Crash-dump legs need this: the fault line must pair with the NEXT boot's reset cause,
|
||||||
|
and a second following cause would mean an unlogged or repeated reset.
|
||||||
|
"""
|
||||||
|
frx, trx = re.compile(first_pat), re.compile(then_pat)
|
||||||
|
fi = [i for i, pl in enumerate(payloads) if frx.search(pl)]
|
||||||
|
ti = [i for i, pl in enumerate(payloads) if trx.search(pl)]
|
||||||
|
if len(fi) != 1:
|
||||||
|
return False, "first matches={} (want exactly 1), then matches={}".format(len(fi), len(ti))
|
||||||
|
after = [i for i in ti if i > fi[0]]
|
||||||
|
if len(after) != 1:
|
||||||
|
return False, "first at L{} but following THEN count={} at {} (want exactly 1)".format(
|
||||||
|
fi[0] + 1, len(after), [x + 1 for x in after])
|
||||||
|
return True, "first L{} -> only following THEN at L{} (gap {} line(s))".format(
|
||||||
|
fi[0] + 1, after[0] + 1, after[0] - fi[0])
|
||||||
|
|
||||||
|
|
||||||
def _reset_cause_of(line, marker):
|
def _reset_cause_of(line, marker):
|
||||||
"""从 `reset cause: software (NVIC_SystemReset) (CSR=0x14000000)` 取原因文本。"""
|
"""从 `reset cause: software (NVIC_SystemReset) (CSR=0x14000000)` 取原因文本。"""
|
||||||
i = line.find(marker)
|
i = line.find(marker)
|
||||||
@@ -120,12 +229,38 @@ def _reset_cause_of(line, marker):
|
|||||||
return tail[:j] if j > 0 else tail
|
return tail[:j] if j > 0 else tail
|
||||||
|
|
||||||
|
|
||||||
|
# --- measurement-vs-device classification (task-32; bench-runner night rule) ---
|
||||||
|
# Three counters decide which kind of "nothing found" we are looking at, and they must not
|
||||||
|
# be conflated: an all-zero read is a BROKEN MEASUREMENT, not a healthy device.
|
||||||
|
# hits=0, loose_neighbours=0, boot anchors=0 -> measurement broke (wrong file, closed
|
||||||
|
# serial port, dead collector). Nothing was observed -> no verdict is meaningful.
|
||||||
|
# hits=0, loose_neighbours>=1 -> the line exists but is damaged (head
|
||||||
|
# eaten / bytes spliced in) -> that is a real device-side defect, --exact's purpose.
|
||||||
|
# hits=0, loose_neighbours=0, anchors>=1 -> the boot DID happen; the marker is
|
||||||
|
# genuinely absent -> allowed to read as a functional miss.
|
||||||
|
# Reported as its own exit code (2, the existing "measurement/environment did not happen"
|
||||||
|
# family) so aggregation by exit code never counts a dead collector as a device failure.
|
||||||
|
def classify_measurement(n_lines, boot_count, boot_check_on, exact_checked, hits, neighbours):
|
||||||
|
"""(broken, reason). Only judges when a boot anchor check was actually requested."""
|
||||||
|
if not boot_check_on:
|
||||||
|
return False, "anchor check off (max_boot_count=0) -> cannot classify"
|
||||||
|
if n_lines == 0:
|
||||||
|
return True, "zero payload lines (collector closed / log truncated / wrong file)"
|
||||||
|
if boot_count == 0:
|
||||||
|
return True, ("log readable (%d line(s)) but ZERO boot anchor -> capture did not "
|
||||||
|
"start at a reset" % n_lines)
|
||||||
|
if exact_checked and hits == 0 and neighbours == 0:
|
||||||
|
return False, "anchors present: exact markers genuinely absent (functional miss, not measurement)"
|
||||||
|
return False, "measurement ok (hits=%d neighbours=%d anchors=%d)" % (hits, neighbours, boot_count)
|
||||||
|
|
||||||
|
|
||||||
def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
||||||
show_tail=40, out=print, suite_summary="",
|
show_tail=40, out=print, suite_summary="",
|
||||||
selftest_pass=False, selftest_log="",
|
selftest_pass=False, selftest_log="",
|
||||||
boot_marker=BOOT_MARKER_DEFAULT, max_boot_count=0,
|
boot_marker=BOOT_MARKER_DEFAULT, max_boot_count=0,
|
||||||
expect_reset_cause="", require_order="", done_marker="",
|
expect_reset_cause="", require_order="", done_marker="",
|
||||||
max_boot_ms=0, as_json=False):
|
max_boot_ms=0, as_json=False, exact=None, pairs=None,
|
||||||
|
allow_residual=0, head_char="", include_comments=False):
|
||||||
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。
|
"""标记校验核心(可被 run_boot_test.py 复用)。返回退出码 0/1/2。
|
||||||
|
|
||||||
suite_summary: ';' 分隔的套件名列表。非空时,要求日志中每个套件都出现
|
suite_summary: ';' 分隔的套件名列表。非空时,要求日志中每个套件都出现
|
||||||
@@ -147,7 +282,8 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
|||||||
"""
|
"""
|
||||||
res = {"log_path": str(log_path), "lines": 0, "bytes": 0, "required_missing": [],
|
res = {"log_path": str(log_path), "lines": 0, "bytes": 0, "required_missing": [],
|
||||||
"forbidden_hits": [], "boot": None, "suites_bad": [], "selftest": None,
|
"forbidden_hits": [], "boot": None, "suites_bad": [], "selftest": None,
|
||||||
"order_bad": [], "timing": None, "tail": [], "verdict": None, "exit": None}
|
"order_bad": [], "timing": None, "tail": [], "verdict": None, "exit": None,
|
||||||
|
"exact_bad": [], "exact_ok": [], "pair_bad": [], "pair_ok": []}
|
||||||
emit = out
|
emit = out
|
||||||
|
|
||||||
def finish(code):
|
def finish(code):
|
||||||
@@ -169,6 +305,10 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
|||||||
except OSError:
|
except OSError:
|
||||||
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
|
out("VERIFY_ERROR: cannot read log file: {}".format(log_path))
|
||||||
return finish(2)
|
return finish(2)
|
||||||
|
if not include_comments:
|
||||||
|
# archived evidence files carry an ASCII provenance header; a header that merely
|
||||||
|
# MENTIONS [ERR] must not trip Forbidden (tool-level false-red guard, task-36)
|
||||||
|
lines = [l for l in lines if not l.lstrip().startswith("#")]
|
||||||
|
|
||||||
full = "\n".join(lines)
|
full = "\n".join(lines)
|
||||||
req_list = _split_list(required)
|
req_list = _split_list(required)
|
||||||
@@ -209,6 +349,37 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
|||||||
else:
|
else:
|
||||||
out("[OK] forbidden pattern absent: {}".format(f))
|
out("[OK] forbidden pattern absent: {}".format(f))
|
||||||
|
|
||||||
|
# ---- whole-line exact checks + cross-reset pairing (task-36) ----
|
||||||
|
payloads = [_payload_of(l) for l in lines]
|
||||||
|
res["payload_lines"] = len(payloads)
|
||||||
|
exact_bad = []
|
||||||
|
for spec in (exact or []):
|
||||||
|
pat, _want = _parse_exact(spec)
|
||||||
|
ok_x, detail, neigh, residuals = exact_check(payloads, spec, allow_residual, head_char)
|
||||||
|
if ok_x:
|
||||||
|
out("[OK] exact line: {} {}".format(pat[:64], detail))
|
||||||
|
res["exact_ok"].append({"pattern": pat, "detail": detail})
|
||||||
|
else:
|
||||||
|
out("[FAIL] exact line: {} {}".format(pat[:64], detail))
|
||||||
|
for nb in neigh:
|
||||||
|
out(" inexact neighbour: {}".format(nb.strip()[:170]))
|
||||||
|
exact_bad.append(pat)
|
||||||
|
res["exact_bad"].append({"pattern": pat, "detail": detail,
|
||||||
|
"neighbours": [n.strip()[:170] for n in neigh]})
|
||||||
|
for k, v in residuals:
|
||||||
|
out("[NOTE] {} residual byte(s) before the line head, realigned and NOT a "
|
||||||
|
"failure: {}".format(k, repr(v)))
|
||||||
|
pair_bad = []
|
||||||
|
for f_pat, t_pat in (pairs or []):
|
||||||
|
ok_p, detail = pair_check(payloads, f_pat, t_pat)
|
||||||
|
if ok_p:
|
||||||
|
out("[OK] pair: {} -> {}".format(f_pat[:44], t_pat[:44]))
|
||||||
|
res["pair_ok"].append({"first": f_pat, "then": t_pat, "detail": detail})
|
||||||
|
else:
|
||||||
|
out("[FAIL] pair: {} -> {} {}".format(f_pat[:44], t_pat[:44], detail))
|
||||||
|
pair_bad.append({"first": f_pat, "then": t_pat, "detail": detail})
|
||||||
|
res["pair_bad"].append(detail)
|
||||||
|
|
||||||
# 启动锚点计数(2026-10-10):抓"跑完但中途复位过"和"采集没从复位开始"
|
# 启动锚点计数(2026-10-10):抓"跑完但中途复位过"和"采集没从复位开始"
|
||||||
boot_issue = False
|
boot_issue = False
|
||||||
if max_boot_count > 0:
|
if max_boot_count > 0:
|
||||||
@@ -307,7 +478,7 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
|||||||
"(run_boot_test.py adds it; --raw-log turns it off)")
|
"(run_boot_test.py adds it; --raw-log turns it off)")
|
||||||
res["timing"] = timing
|
res["timing"] = timing
|
||||||
|
|
||||||
# 套件汇总行解析(T-2):抓"没跑到汇总行 / 断言数为 0"的盲区
|
# 套件汇总行解析:抓"没跑到汇总行 / 断言数为 0"的盲区
|
||||||
bad_summary = []
|
bad_summary = []
|
||||||
if summary_list:
|
if summary_list:
|
||||||
parsed = {}
|
parsed = {}
|
||||||
@@ -378,15 +549,123 @@ def verify(log_path, required=REQUIRED_DEFAULT, forbidden=FORBIDDEN_DEFAULT,
|
|||||||
for ln in lines[-show_tail:]:
|
for ln in lines[-show_tail:]:
|
||||||
out(ln)
|
out(ln)
|
||||||
|
|
||||||
|
# ---- measurement vs device (task-32) ----
|
||||||
|
n_exact_checked = len(exact or [])
|
||||||
|
n_exact_hits = sum(1 for e in (res.get("exact_ok") or []) if "hits=0" not in e["detail"])
|
||||||
|
n_neighbours = 0
|
||||||
|
for e in (res.get("exact_ok") or []) + (res.get("exact_bad") or []):
|
||||||
|
m = re.search(r"loose_neighbours=(\d+)", e.get("detail", ""))
|
||||||
|
if m:
|
||||||
|
n_neighbours += int(m.group(1))
|
||||||
|
b_on = max_boot_count > 0
|
||||||
|
b_cnt = (res.get("boot") or {}).get("count", 0)
|
||||||
|
broken, m_reason = classify_measurement(len(payloads), b_cnt, b_on,
|
||||||
|
n_exact_checked > 0, n_exact_hits, n_neighbours)
|
||||||
|
res["measurement"] = {"broken": broken, "reason": m_reason, "payload_lines": len(payloads),
|
||||||
|
"boot_anchor_count": b_cnt, "anchor_check_on": b_on,
|
||||||
|
"exact_checked": n_exact_checked, "exact_hits": n_exact_hits,
|
||||||
|
"loose_neighbours": n_neighbours}
|
||||||
|
# A broken capture must never mask real device-side bad news: if the log carries
|
||||||
|
# Forbidden hits or selftest fault records, that evidence stands and we report FAIL(1)
|
||||||
|
# with the measurement problem noted on top of it (worst outcome = a real crash quietly
|
||||||
|
# re-labelled as "no data").
|
||||||
|
if broken and (hits or selftest_fail):
|
||||||
|
out("")
|
||||||
|
out("[WARN] capture also looks incomplete: " + m_reason)
|
||||||
|
out(" but device-side bad news is present -> verdict stays FAIL, not MEASUREMENT-BROKEN")
|
||||||
|
broken = False
|
||||||
|
if broken:
|
||||||
|
out("")
|
||||||
|
out("[MEASUREMENT-BROKEN] " + m_reason)
|
||||||
|
out(" no verdict is meaningful here -- fix the measurement first (open the port,")
|
||||||
|
out(" assert the collector is alive, capture from a reset). Exit 2, NOT 1:")
|
||||||
|
out(" a dead collector must not be counted as one more device failure.")
|
||||||
|
return finish(2)
|
||||||
|
|
||||||
out("")
|
out("")
|
||||||
if (missing or hits or bad_summary or selftest_fail or boot_issue
|
if (missing or hits or bad_summary or selftest_fail or boot_issue
|
||||||
or order_issue or timing_issue):
|
or order_issue or timing_issue or exact_bad or pair_bad):
|
||||||
out("VERDICT: FAIL")
|
out("VERDICT: FAIL")
|
||||||
return finish(1)
|
return finish(1)
|
||||||
out("VERDICT: PASS")
|
out("VERDICT: PASS")
|
||||||
return finish(0)
|
return finish(0)
|
||||||
|
|
||||||
|
|
||||||
|
def selftest(out=print):
|
||||||
|
"""Prove the exact/pair machinery actually discriminates (exit 0 = all behaviours ok).
|
||||||
|
|
||||||
|
Written because a PASS that cannot fail is worthless: case 2/3 are the real captured
|
||||||
|
shapes of the 2026-10-26 console-interleave defect, and case 9 shows that the legacy
|
||||||
|
substring check calls the SAME defective line a PASS -- that is the blind spot --exact
|
||||||
|
exists to close.
|
||||||
|
"""
|
||||||
|
T = "[2026-10-26 08:00:00]"
|
||||||
|
prot_re = (r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]\[PROT\] uncalibrated -> protection "
|
||||||
|
r"blocked \(UA/UN/IA VALID not all set\)")
|
||||||
|
nand_re = r"\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\]\[NAND_FTL\] Initializing NAND FTL\.\.\."
|
||||||
|
prot_ok = T + "[PROT] uncalibrated -> protection blocked (UA/UN/IA VALID not all set)"
|
||||||
|
prot_spliced = T + "[PROT] uncalibrated -> protection [blocked (UA/UN/IA VALI2D not all s0et)"
|
||||||
|
nand_ok = T + "[NAND_FTL] Initializing NAND FTL..."
|
||||||
|
nand_headless = "26-10-26 08:37:07][NAND_FTL] Initializing NAND FTL..."
|
||||||
|
mem_re = r"\[ERR\] MEMFAULT PC=\S+ LR=\S+ CFSR=\S+ HFSR=\S+"
|
||||||
|
mem_ok = "[ERR] MEMFAULT PC=0x08004a12 LR=0x08004a3f CFSR=0x00008200 HFSR=0x40000000"
|
||||||
|
rc_ok = T + "[APP] reset cause: software (NVIC_SystemReset) (CSR=0x14000000)"
|
||||||
|
cases = []
|
||||||
|
|
||||||
|
def chk(name, got, want):
|
||||||
|
cases.append((name, got == want, "got={!r} want={!r}".format(got, want)))
|
||||||
|
|
||||||
|
chk("clean exact", exact_check([prot_ok], prot_re)[0], True)
|
||||||
|
ok_s, _d_s, neigh_s, _r_s = exact_check([prot_spliced], prot_re)
|
||||||
|
chk("spliced line rejected", ok_s, False)
|
||||||
|
chk("spliced line fails even with broken probe key", len(neigh_s), 0)
|
||||||
|
ok_n, _d_n, neigh_n, _r_n = exact_check([nand_headless], nand_re)
|
||||||
|
chk("headless line rejected", ok_n, False)
|
||||||
|
chk("headless line reported as neighbour", len(neigh_n) >= 1, True)
|
||||||
|
chk("residual 1 rejected when not allowed", exact_check(["#" + nand_ok], nand_re)[0], False)
|
||||||
|
ok_r, det_r, _n_r, res_r = exact_check(["#" + nand_ok], nand_re, allow_residual=1)
|
||||||
|
chk("residual 1 accepted when allowed", ok_r and len(res_r) == 1, True)
|
||||||
|
chk("residual 2 still rejected", exact_check(["##" + nand_ok], nand_re, allow_residual=1)[0], False)
|
||||||
|
chk("head_char enforced", exact_check([nand_ok], nand_re, 0, "[")[0], True)
|
||||||
|
chk("head_char catches wrong head", exact_check([nand_headless], r"26-10-26.*NAND_FTL.*", 0, "[")[0], False)
|
||||||
|
chk("len pin ok", exact_check([nand_ok], nand_re + EXACT_LEN_SEP + str(len(nand_ok)))[0], True)
|
||||||
|
chk("len pin mismatch fails", exact_check([nand_ok], nand_re + EXACT_LEN_SEP + "9")[0], False)
|
||||||
|
chk("pair good", pair_check([mem_ok, rc_ok], "MEMFAULT", "reset cause:")[0], True)
|
||||||
|
chk("pair two following causes fails",
|
||||||
|
pair_check([mem_ok, rc_ok, rc_ok], "MEMFAULT", "reset cause:")[0], False)
|
||||||
|
chk("pair wrong order fails",
|
||||||
|
pair_check([rc_ok, mem_ok], "MEMFAULT", "reset cause:")[0], False)
|
||||||
|
# blind spot: legacy substring would PASS on this defective line
|
||||||
|
full = "\n".join([prot_ok, nand_headless])
|
||||||
|
legacy_present = "Initializing NAND FTL" in full # what --required sees
|
||||||
|
exact_ok_here = exact_check([prot_ok, nand_headless], nand_re)[0]
|
||||||
|
chk("blind spot proven: legacy substring PASSES where --exact FAILS",
|
||||||
|
legacy_present and not exact_ok_here, True)
|
||||||
|
|
||||||
|
# measurement-vs-device classification (task-32): the three-counter rule IS this function,
|
||||||
|
# so it is pinned here -- an all-zero read must not be reportable as a healthy device.
|
||||||
|
chk("measure: good capture -> not broken", classify_measurement(137, 1, True, True, 1, 0)[0], False)
|
||||||
|
chk("measure: zero payload lines -> broken", classify_measurement(0, 0, True, True, 0, 0)[0], True)
|
||||||
|
chk("measure: readable but no boot anchor -> broken",
|
||||||
|
classify_measurement(1, 0, True, True, 0, 0)[0], True)
|
||||||
|
chk("measure: anchor check off -> abstain (non-boot logs stay usable)",
|
||||||
|
classify_measurement(0, 0, False, True, 0, 0)[0], False)
|
||||||
|
chk("measure: damaged lines are device-side, not 'no data'",
|
||||||
|
classify_measurement(120, 1, True, True, 0, 3)[0], False)
|
||||||
|
chk("measure: anchors present + no neighbour = functional miss",
|
||||||
|
classify_measurement(120, 1, True, True, 0, 0)[0], False)
|
||||||
|
|
||||||
|
bad = 0
|
||||||
|
for name, ok, detail in cases:
|
||||||
|
out("{} {}".format("[OK] " if ok else "[FAIL]", name))
|
||||||
|
if not ok:
|
||||||
|
out(" {}".format(detail))
|
||||||
|
bad += 1
|
||||||
|
out("SELFTEST: {} case(s), {} failure(s) -> {}".format(
|
||||||
|
len(cases), bad, "PASS" if bad == 0 else "FAIL"))
|
||||||
|
return 0 if bad == 0 else 1
|
||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
# 控制台输出固定 UTF-8:Windows 代码页(GBK/UTF-8 终端)均不会让本脚本崩溃
|
# 控制台输出固定 UTF-8:Windows 代码页(GBK/UTF-8 终端)均不会让本脚本崩溃
|
||||||
for stream in (sys.stdout, sys.stderr):
|
for stream in (sys.stdout, sys.stderr):
|
||||||
@@ -403,7 +682,7 @@ def main(argv=None):
|
|||||||
help="forbidden patterns, ';' separated (case-insensitive)")
|
help="forbidden patterns, ';' separated (case-insensitive)")
|
||||||
ap.add_argument("--require-suite-summary", default="",
|
ap.add_argument("--require-suite-summary", default="",
|
||||||
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
|
help="suite names that MUST appear as '=== <name>: N/M PASSED (failed=0) ===' "
|
||||||
"with N>0, ';' separated (T-2). Empty = off")
|
"with N>0, ';' separated. Empty = off")
|
||||||
ap.add_argument("--show-tail-lines", type=int, default=40,
|
ap.add_argument("--show-tail-lines", type=int, default=40,
|
||||||
help="show last N log lines (0 = off)")
|
help="show last N log lines (0 = off)")
|
||||||
ap.add_argument("--require-selftest-pass", action="store_true",
|
ap.add_argument("--require-selftest-pass", action="store_true",
|
||||||
@@ -427,10 +706,36 @@ def main(argv=None):
|
|||||||
ap.add_argument("--max-boot-ms", type=int, default=0,
|
ap.add_argument("--max-boot-ms", type=int, default=0,
|
||||||
help="fail if time from first line to --done-marker exceeds N ms "
|
help="fail if time from first line to --done-marker exceeds N ms "
|
||||||
"(needs '[+Nms]' line prefix; 0 = off)")
|
"(needs '[+Nms]' line prefix; 0 = off)")
|
||||||
|
ap.add_argument("--exact", action="append", default=[],
|
||||||
|
help="repeatable regex that must match a WHOLE firmware line "
|
||||||
|
"(collector prefix stripped, anchored ^...$); a spliced byte or a "
|
||||||
|
"missing line head fails. Optional suffix '@@LEN=<n>' pins the byte "
|
||||||
|
"length -- opt-in only, lengths belong to one image. Use --exact "
|
||||||
|
"instead of --required for any marker whose line can lose its head.")
|
||||||
|
ap.add_argument("--allow-residual", type=int, default=0,
|
||||||
|
help="accept up to N stray bytes before the line head on an --exact hit "
|
||||||
|
"(crash-dump legs: 1 shift-register leftover is known; recorded, "
|
||||||
|
"not a failure). 0 = strict (default)")
|
||||||
|
ap.add_argument("--head-char", default="",
|
||||||
|
help="every --exact hit must start with this char right after the "
|
||||||
|
"optional residual prefix (crash-dump legs: '[')")
|
||||||
|
ap.add_argument("--pair", action="append", nargs=2, metavar=("FIRST", "THEN"), default=[],
|
||||||
|
help="repeatable pair: exactly one FIRST line, and exactly one THEN line "
|
||||||
|
"after it (cross-reset pairing, e.g. 'MEMFAULT' 'reset cause:')")
|
||||||
|
ap.add_argument("--include-comments", action="store_true",
|
||||||
|
help="do NOT skip '#' lines (archived evidence files carry an ASCII "
|
||||||
|
"provenance header; skipping it is the default so a header that "
|
||||||
|
"merely mentions [ERR] cannot trip --forbidden)")
|
||||||
|
ap.add_argument("--selftest", action="store_true",
|
||||||
|
help="run the built-in discrimination cases and exit (0 = all behave "
|
||||||
|
"as specified); needs no log file and no hardware")
|
||||||
ap.add_argument("--json", action="store_true",
|
ap.add_argument("--json", action="store_true",
|
||||||
help="emit one JSON object instead of the text verdict (exit code unchanged)")
|
help="emit one JSON object instead of the text verdict (exit code unchanged)")
|
||||||
args = ap.parse_args(argv)
|
args = ap.parse_args(argv)
|
||||||
|
|
||||||
|
if args.selftest:
|
||||||
|
return selftest()
|
||||||
|
|
||||||
return verify(args.log_path, args.required, args.forbidden,
|
return verify(args.log_path, args.required, args.forbidden,
|
||||||
args.show_tail_lines, suite_summary=args.require_suite_summary,
|
args.show_tail_lines, suite_summary=args.require_suite_summary,
|
||||||
selftest_pass=args.require_selftest_pass,
|
selftest_pass=args.require_selftest_pass,
|
||||||
@@ -441,7 +746,10 @@ def main(argv=None):
|
|||||||
require_order=args.require_order,
|
require_order=args.require_order,
|
||||||
done_marker=args.done_marker,
|
done_marker=args.done_marker,
|
||||||
max_boot_ms=args.max_boot_ms,
|
max_boot_ms=args.max_boot_ms,
|
||||||
as_json=args.json)
|
as_json=args.json,
|
||||||
|
exact=args.exact, pairs=args.pair,
|
||||||
|
allow_residual=args.allow_residual, head_char=args.head_char,
|
||||||
|
include_comments=args.include_comments)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
---
|
||||||
|
name: stale-doc-count-sweep
|
||||||
|
description: 全仓陈旧计数同步 playbook——某个事实数(黄金参考页数/张数、菜单树叶数与已绑叶数、字库字数与字形数、提交钩子数等)变了之后:每个数先剔伪命中再双源互证定真值 → 全形态全命中面扫描(含 .pre-commit 钩子显示名与 .dsh/skills 正文)→ 逐条分类(当前态改 / 历史行冻结(只有保留变更记录的 5 篇真台账与 scripts/archive/ 归档) / 同数不同义不动 / 带日期戳的测量只加「当时」/ 数字对但口径描述错另起一笔)→ 每处先读现文再字面替换并断言命中数 → 全文通读核对各小节 → 提交后再按全部旧形态复扫归零 → 跑门禁分笔提交。
|
||||||
|
whenToUse: 某个计数/数字变了要跨 md/yaml/py/txt 同步、用户说「同步一下 / 全仓改一遍 / 这个页数(张数、叶数、字形数、钩子数)不对」、怀疑某个数是伪命中或口径错了(含 notdef 吗、含 ASCII 吗、是张数还是页数)、发现文档数字与实测不符、或同一份文档两节数字/顺序对不上要先核口径时。
|
||||||
|
---
|
||||||
|
|
||||||
|
# 陈旧计数全仓同步
|
||||||
|
|
||||||
|
适用:**一个事实数变了**(黄金参考 27→40→47→53→60→66、已绑叶数 22→28→30、主机单测套数 28→33→41→44、无专属钩子套数 18→19→22、字库 185/280→218/313→224/319、菜单节点行数 42→43、钩子 51→52→51→52…),仓库里多处引用它的当前态表述要跟着变,而历史记录、同数异义、带日期戳的测量**不能**跟着变。
|
||||||
|
|
||||||
|
本仓已跑过四轮,照序列复跑、别临场重新发明分类:`b24b2b5f`(27→40,13 文件)、`31ca4a1f`(40→47,11 文件)、UI 补齐批(`94b0d790` 落地 → `cbb4fdb8` 产品需求同步 → `11d60057` 架构总览补漏 → `6daa01ae` 字体**口径**更正)、钩子一增一减(`1668842d`,52→51)、**第五轮 2026-10-29 录波落地**(套数 41→44、无专属钩子 19→22、黄金 60→66、菜单 42→43 节点 / 33→34 叶、已绑 28→30、字库 218/313→224/319;改动面含 `.pre-commit-config.yaml` 显示名与 `docs/门禁说明/README.md` 成对、三个 skill 正文)。
|
||||||
|
|
||||||
|
## 各计数的权威口径速查(本仓实测值,动手前先对表)
|
||||||
|
|
||||||
|
| 计数 | 真值来源(**至少两处互证**) | 已知伪命中 / 换算 |
|
||||||
|
|---|---|---|
|
||||||
|
| 内容页黄金参考**张数** | `simulator/tests/ref/` 下 `shot_*.bin` 实数(2026-10-29 实测 **66**)= `simulator/tests/check_content_pages.py` 的 `PAGES` 条目数(实测 **66**,其中 2 个 `_p2` 分页 → 页面 64) | 分页页要**单独再拍一张**(id 加 `_p2`,如 `runreport_p2`)→ **张数 = 页面个数 + 分页多出的张数**;两个数分开报,不许互相冒充(53 张时代的一个 12 行分页页会让 55 页变 56 张这类差 1) |
|
||||||
|
| 已绑内容页**叶数** | `Drivers/BSP/UI/ui_page_registry.h` 的 `#define UI_PAGE_MAP_COUNT 30u`(由 `tools/menu_gen/gen_menu.py` 生成;2026-10-29 录波道绑两叶后 28→30) | `grep -c '#page=' ui_menu_tree.txt` 得 **31**:第 5 行是菜单文件**头部注释里的示例行** → 剔掉注释/模板行后的 30 才是真值 |
|
||||||
|
| 提交**钩子数** | YAML 解析后的条目数(实测 **51**):自查命令见 `docs/门禁说明/README.md` §2 首条;与 AGENTS.md「共 N 个钩子」、该目录 §2 标题**三数同源** | `grep -c` 配置里的声明行 = 52(多算一条**被注释掉的** clang-format 候选);在配置**注释里复述那个 grep 字面量**会让文本计数再虚高 1 |
|
||||||
|
| 字库**唯一字数** | `ui_cn_chars.txt` 唯一码点数(2026-10-29 实测 **224**,其中 ASCII 区 **0** 个) | `make_font.py` 的 `-r 0x20-0x7E` 是 ASCII 子集参数,**不算进字数** |
|
||||||
|
| **字形数** | `Drivers/BSP/UI/ui_font_cn_12.c` 里 `.bitmap_index` 出现次数 **320 条含 notdef → 319 字形**;换算式 = 224 汉字/符号 + ASCII 95 = 319 | 直接报 314 就是把 notdef 当字形;文档必须写出「不含 ASCII」「含 notdef 共 314 条」这类口径句 |
|
||||||
|
| 变更记录**保留篇数** | 变体标题感知的普查(`grep -rn "^## " docs scripts` 再看哪些标题是变更记录 / 修订记录);当前态清单以 `docs/文档规范.md` §5.1 那行为准 | 只 grep 裸 `^## 变更记录` 会**漏算带编号与中文序号的标题**(实测 5 篇真台账里 2 篇是 `## 6.` / `## 九、`,另有 `## 修订记录` 变体)——2026-10-29 就差点把 5 改成 3 |
|
||||||
|
| 菜单**节点数 / 叶子数** | `ui_menu_tree.txt` 头部规则:缩进即层级(每级 2 空格)、`#` 开头为注释、空行忽略;叶子 = **不存在更深缩进后继行**的行 | 43 是节点行数、34 才是叶子(2026-10-29 现值;42 / 33 是此前时点);`未绑 = 待做 + 不做` 要与分类表逐项闭合 |
|
||||||
|
|
||||||
|
## Steps
|
||||||
|
|
||||||
|
1. 实测真值:先剔伪命中,再双源互证
|
||||||
|
2. 全形态 + 全命中面扫描
|
||||||
|
3. 逐条分类,五类各走各的路
|
||||||
|
4. 每处先读现文,再逐条字面替换并断言命中数
|
||||||
|
5. 一笔只装一类事
|
||||||
|
6. 被改文件全文通读,核对其余小节
|
||||||
|
7. 提交后再复扫一遍
|
||||||
|
8. 跑门禁、分笔提交
|
||||||
|
> 本节全文(逐字搬移、未改写,含命令与实测数字)→ [steps.md](reference/steps.md)。动手前先读那篇。
|
||||||
|
|
||||||
|
## Pitfalls
|
||||||
|
|
||||||
|
- 把伪命中计进真值
|
||||||
|
- 字形数没剔 notdef
|
||||||
|
- 数字对但口径描述错
|
||||||
|
- 权威脚本乱码就臆读或退回抄文档
|
||||||
|
- 钩子数用 grep 声明行
|
||||||
|
- 张数与页数互相冒充
|
||||||
|
- 只扫 docs/
|
||||||
|
- 提交完就收工
|
||||||
|
- 去已删掉变更记录的文档里「补一行流水」
|
||||||
|
- 历史行被当陈旧值改掉
|
||||||
|
- 照抄用户给的数
|
||||||
|
- 把节点行当叶子数
|
||||||
|
- 整文件正则批处理
|
||||||
|
- 反引号截断
|
||||||
|
- 只改记得的那节
|
||||||
|
- 误报跟着改
|
||||||
|
- 带日期戳的测量臆改新数
|
||||||
|
- 用截断的 grep 输出判定
|
||||||
|
- 新写文本撞编号门禁
|
||||||
|
> 本节全文(逐字搬移、未改写,含命令与实测数字)→ [pitfalls.md](reference/pitfalls.md)。动手前先读那篇。
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
提交前逐条打勾;回报时**逐处列文件 + 命中数断言**,不要笼统说「已全部更新」。
|
||||||
|
|
||||||
|
- [ ] 每个数都写了**两个独立来源**且数值一致(`ref/shot_*.bin` 实数 = `PAGES` 条目数;`UI_PAGE_MAP_COUNT` = 剔伪命中后的 `#page=` 计数),并在结论里贴出该数字与伪命中取舍理由
|
||||||
|
- [ ] 字形数说明**是否剔除 notdef**并给出条目数→字形数的换算(条目数 − 1 = 字形数,2026-10-29 现值 320 − 1 = 319);字数说明**是否含 ASCII**,且该口径句与实测一致
|
||||||
|
- [ ] 分类表逐条列出:改了哪些当前态、哪些历史行保留、哪些误报不动(附理由)、哪些只加「当时」、哪些属**口径错**并已另起一笔
|
||||||
|
- [ ] 替换脚本打印了每处 `old -> new xN` 且每处 `assert` 通过(无半落地;报错时已 `git diff --stat` 确认零写入)
|
||||||
|
- [ ] 命中面覆盖 `.pre-commit-config.yaml` 钩子 `name:`、AGENTS.md、`docs/**`、`scripts/**`、`.dsh/skills/*/SKILL.md`,逐条在改动清单里出现
|
||||||
|
- [ ] 被改文件已全文通读,同文档各小节与配套 README/清单的顺序、计数、完成状态一致;口径闭合(如 `未绑 = 待做 + 不做`)
|
||||||
|
- [ ] 变更记录:**保留的 5 篇真台账**(清单 → `docs/文档规范.md` §5.1)历史行原文未动;其余文档本轮**零新增流水行**(当前态改完即止);钩子一增一减这类过程细节写进提交正文,不写进文档
|
||||||
|
- [ ] **提交后**又按全部旧形态复扫一次,当前态残留 = **0**,每条剩余命中都能归类(历史行 / 误报 / 已加「当时」);发现的漏网当前态已单独补一笔
|
||||||
|
- [ ] `check_doc_links.py --all --sections-strict` 打印「文档路径检查通过(N 个文档)」;`ci_local.py` / `pre-commit` 全绿(RC=0、`failed=0`);BOM 与原文件一致、无 `EF BF BD`
|
||||||
|
- [ ] 分笔提交完成,回报 commit 号 + 每笔改了哪些文件 + 没动的那几类及理由
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Pitfalls(自 SKILL.md 外置)
|
||||||
|
|
||||||
|
> 本文由 stale-doc-count-sweep 的 SKILL.md「Pitfalls」一节逐字外置而来(渐进展开:主文件只留索引与指针)。步骤号沿用原文,未重编。
|
||||||
|
|
||||||
|
- **把伪命中计进真值**:`grep '#page='` 的首条是菜单文件头部注释里的示例行,29 里含它;真值是注册表 `UI_PAGE_MAP_COUNT` 的 28。任何「数出现次数」式计数,先打印全部命中逐条看,剔注释行/模板行/示例行。
|
||||||
|
- **字形数没剔 notdef**:`.bitmap_index` 314 条里第 1 条是 notdef,字形数是 **313**;照抄 314 就把占位符当成了字形。
|
||||||
|
- **数字对但口径描述错**:218 这个数两处都对,错在「含 ASCII/符号」这句口径(实测 218 个码点全为非 ASCII)。它不进任何数字 diff,只能逐句读口径句;且必须**另起一笔**,混进数字同步那笔会让 review 者以为只是顺手改措辞。
|
||||||
|
- **权威脚本乱码就臆读或退回抄文档**:Windows 代码页下 `font-charset` 的整行输出退化成 U+FFFD 替换字符时,改数源文件符号出现次数,别猜、别抄旧文档。
|
||||||
|
- **钩子数用 `grep` 声明行**:多算一条注释掉的候选钩子(51 变 52);反过来在配置注释里复述那个 grep 字面量会让文本计数再虚高 1。口径只认 YAML 解析后的条目数,且要与 AGENTS.md、`docs/门禁说明/README.md` §2 标题、`ci_local.py` 实跑 passed+failed+skipped 三数同源。
|
||||||
|
- **张数与页数互相冒充**:一个 12 行分页页第 2 页要单独拍一张,于是「+2 页」可能等于「+3 张」。两个数分别报、分别换算,别互相顶替。
|
||||||
|
- **数篇数只 grep 裸标题**:`^## 变更记录` 漏掉 `## 6. 变更记录`、`## 九、变更记录`、`## 修订记录` 三种写法 → 篇数直接少数一半,拿这个错数去"同步"全仓就是把对的改成错的(2026-10-29 实测:5 篇真台账只数出 3 篇,改到第 10 处才发现)。普查一律 `grep -rn "^## " docs scripts` 再看标题,或直接读 `docs/文档规范.md` §5.1 那行的例外清单。
|
||||||
|
- **只扫 docs/**:漏 `.pre-commit-config.yaml` 钩子 `name:` 显示名里的页数、AGENTS.md 钩子清单、`.dsh/skills/*/SKILL.md` 自带的计数与脚本路径(skill 正文也是命中面:font-regen 的 179/274 本窗最后才改)。
|
||||||
|
- **提交完就收工**:第 7 步的提交后复扫才是把漏网当前态(架构总览 §3)揪出来的那一步;漏了它,仓库里就留下一行「当前态写旧数」直到别人再扫。
|
||||||
|
- **去已删掉变更记录的文档里「补一行流水」**:2026-10-29 起全仓只剩 **5 篇**真台账保留变更记录表(清单 → `docs/文档规范.md` §5.1),外加 `scripts/archive/` 归档;其余文档**没有这一节**。计数同步**只改当前态**、过程交给 `git log`,别再到别的文档里找或加变更记录行。真要往那 5 篇的台账里加行,历史坑照旧:它们的表按日期**升序** → 新行**追加为末行**,插表顶会把首行「初版」挤成第二行。
|
||||||
|
- **历史行被当陈旧值改掉**:「47 → 53 页」是当天事实,改了就是伪造历史;正确动作是**只改当前态那几处**、历史行一个字不动(保留变更记录的 5 篇真台账要写台账也只追加、不回改)。**历史行被写坏**时按无反引号兜底写法整行重写,别用同样写法再改一遍。
|
||||||
|
- **照抄用户给的数**:用户说「从 40 扩到 47」也要自己数 `ref/*.bin`;数出来不一定是 47。
|
||||||
|
- **把节点行当叶子数**:42 是 `ui_menu_tree.txt` 的节点行数、33 才是叶子;不读文件头规则就数错(实测把错数写进两份文档,下一轮才自查出来)。
|
||||||
|
- **整文件正则批处理**:第一次尝试的正则没编译过、整批零落地。改法 = 逐条字面替换 + 每处 `assert count == expect`。
|
||||||
|
- **反引号截断**:脚本里含 `ui-golden`、`#page=` 这类反引号时,经 TS 模板字符串/内嵌 heredoc 传递会被截断,表现为「整批零落地」或「历史行写坏」。落成脚本文件再执行,或用 `chr(96)` / `String.fromCharCode(96)` 拼。
|
||||||
|
- **只改记得的那节**:上一笔只改 §2 与开篇、漏改 §1,用户当场问出「§1 说 16 叶、§2 说剩 3 项,自相矛盾」。改完必须全文通读同一文档所有小节。
|
||||||
|
- **误报跟着改**:「< 40KB」是绘制缓冲红线,与页数无关。
|
||||||
|
- **带日期戳的测量臆改新数**:「2026-10-26 实跑 …覆盖 40 页」改成 47 就是把历史测量冒充现值;只加「当时」。
|
||||||
|
- **用截断的 grep 输出判定**:`cut -c` 截掉的后半句常含日期戳/口径,据此分类必错。
|
||||||
|
- **新写文本别再造编号**:两道编号门禁都已退役(全库八项校验 2026-10-28、只查新增行 2026-10-29,脚本与白名单在 `scripts/archive/id_gate/`),**撞号不再有机器报警**,只剩规范与评审——所以这批文本的形态自查要自己做(`docs/编号规范.md` §1 三句;要机器帮忙就手工跑 `scripts/archive/id_gate/check_ids_new.py --worktree`)。
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
# Steps(自 SKILL.md 外置)
|
||||||
|
|
||||||
|
> 本文由 stale-doc-count-sweep 的 SKILL.md「Steps」一节逐字外置而来(渐进展开:主文件只留索引与指针)。步骤号沿用原文,未重编。
|
||||||
|
|
||||||
|
1. **实测真值:先剔伪命中,再双源互证**(不抄用户口述、不抄另一处文档)。逐数走上面那张表;两个来源对不上时**先找分歧根因**(把全部命中逐条打印出来看),不要取第一个、不要取平均,并在结论里写明分歧与取舍(实测:菜单 `#page=` 计数 29 对 注册表 28,根因是头部注释示例行)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd "<repo>"
|
||||||
|
ls simulator/tests/ref/shot_*.bin | wc -l # 张数(判据 = 入库实数)
|
||||||
|
awk '/^PAGES = \[/,/^\]/' simulator/tests/check_content_pages.py | grep -cE '^ *\("' # 页数(第二个来源,两者必须一致)
|
||||||
|
grep -n '#page=' ui_menu_tree.txt | head -3 # 先看首条是不是注释示例行,再定叶数
|
||||||
|
grep -rn 'UI_PAGE_MAP_COUNT' Drivers/BSP/UI/ui_page_registry.h # 已绑叶数(生成物)
|
||||||
|
grep -c '\.bitmap_index' Drivers/BSP/UI/ui_font_cn_12.c # 314 = 含 notdef;字形 = 该数 - 1
|
||||||
|
& '.venv\Scripts\python.exe' -c "import yaml;d=yaml.safe_load(open('.pre-commit-config.yaml',encoding='utf-8'));print(sum(len(r['hooks']) for r in d['repos']))" # 钩子数口径
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ **权威脚本输出乱码时**(`check_font_charset.py` 在 GBK 代码页下整行打印成 U+FFFD 替换字符串 + `: PASS`,数字全读不出来),**改为直接数源文件里符号出现次数**(`.bitmap_index` / `.range_start` / 码点唯一化)——不许臆读乱码,也不许因此退回去抄文档里的旧数。
|
||||||
|
|
||||||
|
2. **全形态 + 全命中面扫描**。一个数一次列出它全部历史形态,别只扫当前旧值:本轮 = `122/217`、`179/274`、`185/280`、`47 → 53`、`53 → 60`、`PAGES = 53`、`UI_PAGE_MAP_COUNT = 22`、`已绑 22 叶`、`未绑 11`、`内容页 53 页`。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn --include=*.md --include=*.py --include=*.yaml --include=*.txt --include=*.c --include=*.h \
|
||||||
|
-E '122 唯一字|217 字形|179 汉字|274 字形|185 唯一字|280 字形|47 → 53|53 → 60|内容页 53|PAGES = 53|UI_PAGE_MAP_COUNT = 22|已绑 22|未绑 11' . \
|
||||||
|
| grep -v -E '^\./(ci-artifacts|\.mnemon|\.dsh/runs)/'
|
||||||
|
```
|
||||||
|
|
||||||
|
**命中面比「文档」宽**,至少包含:`docs/**`、`AGENTS.md`、`.pre-commit-config.yaml`(**钩子 `name:` 显示名内嵌页数**:`name: UI 像素回归(菜单 8 状态 + 内容页 60 页 黄金参考)` 就是命中点)、`scripts/*.py`、`simulator/**/README.md`、以及 **`.dsh/skills/*/SKILL.md`**(本窗 `font-regen` 正文自带 179/274 计数,直到 step 150 才发现;编号门禁退役那轮 `id-family-semantic-migration`、`task-worktree` 两个 skill 正文与 `scripts/README.md` 同样是引用面)。
|
||||||
|
|
||||||
|
先列出 `文件:行:全文`,**判定前必须看整行**——`cut -c1-120` 会截掉后半句(那里常写着日期戳或口径)。
|
||||||
|
|
||||||
|
3. **逐条分类,五类各走各的路**(本 playbook 的核心,别用一条规则套全部命中):
|
||||||
|
|
||||||
|
| 类别 | 判定 | 动作 |
|
||||||
|
|---|---|---|
|
||||||
|
| 当前态陈述 | 描述「现在是多少」 | 改成实测真值 |
|
||||||
|
| 变更记录历史行 | 形如「47 → 53 页」的当日事实 | **原样冻结不回改**——但这条**只适用于保留变更记录的 5 篇真台账**(台账 / 被别处当唯一证据出处的文档,清单 → `docs/文档规范.md` §5.1)与 `scripts/archive/` 归档;其余文档没有变更记录节,**不再追加任何流水行**,数字同步只改**当前态** |
|
||||||
|
| 同数不同义 | `最坏路径 < 40KB` 这类内存/绘制缓冲红线 | **不动**,并在结论里写明「为什么不动」 |
|
||||||
|
| 带日期戳的测量 | 「2026-10-26 实跑 …覆盖当时 40 页中的」 | **不臆改新数**,只补「当时」两字锁口径;新数留待下次实跑回填 |
|
||||||
|
| 数字对但**口径描述错** | 数没错、句子把口径讲错了(实测:写「218 字(含 ASCII/符号,共 313 字形)」,而 `ui_cn_chars.txt` 的 218 个码点**全为非 ASCII**) | **另起一笔只改口径的提交**(`6daa01ae`),不与数字同步混笔;这类命中在任何数字 diff 里都看不见,只能把口径句逐句读一遍 |
|
||||||
|
|
||||||
|
拿不准就回到第 2 步把整行打印出来再判;判错历史行 = 把当天事实改成假账。
|
||||||
|
|
||||||
|
4. **每处先读现文,再逐条字面替换并断言命中数**(弃用整文件正则批处理)。本窗两度返工都是没先读现文:step 45 断言 `HIT 0` 抛错(目标串与现文不一致),step 44 是脚本语法错。脚本写成临时文件再跑(内嵌 heredoc/模板字符串里的反引号会截断整脚本),按**字节**替换以保 BOM 与行尾不动:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pathlib, sys
|
||||||
|
sys.stdout.reconfigure(encoding='utf-8', errors='replace')
|
||||||
|
JOBS = [ # (文件, old, new, 期望命中数)
|
||||||
|
('AGENTS.md', '内容页 53 页黄金参考', '内容页 60 页黄金参考', 1),
|
||||||
|
('.pre-commit-config.yaml', '内容页 53 页 黄金参考', '内容页 60 页 黄金参考', 1),
|
||||||
|
('docs/架构设计/项目架构总览.md', '122 字/217 字形', '218 字/313 字形', 1),
|
||||||
|
('docs/UI设计/UI_自动化测试流程.md', '文本断言 **310 条**、覆盖 40 页中的', '文本断言 **310 条**、覆盖当时 40 页中的', 1),
|
||||||
|
]
|
||||||
|
for path, old, new, expect in JOBS:
|
||||||
|
p = pathlib.Path(path); raw = p.read_bytes()
|
||||||
|
o, n = old.encode('utf-8'), new.encode('utf-8')
|
||||||
|
got = raw.count(o)
|
||||||
|
assert got == expect, f'{path}: 命中 {got},期望 {expect}(未写入)'
|
||||||
|
assert o != n, f'{path}: old 与 new 相同'
|
||||||
|
p.write_bytes(raw.replace(o, n))
|
||||||
|
print(f'OK {path}: {old} -> {new} x{got}')
|
||||||
|
```
|
||||||
|
|
||||||
|
每行打印 `old -> new xN`,收口时这串输出就是「改了哪几处」的账。脚本报错/没编译过,**先 `git diff --stat` 确认零写入**再换写法(不要在半落地状态上叠改)。
|
||||||
|
|
||||||
|
- **反引号**:TS 侧先用可见占位符(如 `~TB~`)写文本,落盘前再还原成真反引号 —— `const BT = String.fromCharCode(96); const q = s => s.split(TOKEN).join(BT)`(本窗实测:直接把带反引号的串塞进模板字符串/heredoc,整批零落地或把变更记录行写坏)。用 `tools.edit` 批量改时同样先 `await tools.read` 现文再传 `old_string`;批量里若某条 `old_string` 与 `new_string` 相同会报 `must differ` —— **只有该条作废、其余已落盘**,别整批重跑。
|
||||||
|
- **历史行被写坏**(反引号及紧随其后的字符经工具传递被吞 → 变更记录行只剩半行、尾部出现 `??` 这类残迹):立刻按**无反引号兜底写法整行重写**——裸词 `accident / oper / alarm / start / yk`、页面绑定写成不带反引号的 `#page=`——而不是拿同样带反引号的串再改一遍(实测连返工三次:step 81→82→83→85)。
|
||||||
|
|
||||||
|
5. **一笔只装一类事**。数字同步一笔(`cbb4fdb8`:11 处 + 变更记录一行);口径更正另起一笔(`6daa01ae`);提交后复扫逮到的漏网当前态再补独立一笔(`11d60057`)。钩子一增一减写成一个当日行的两个阶段(51 → 52 加 `id-check-new`、随即 52 → 51 摘 `id-check`),别写两行互相打脸。提交说明如实写(「先认一笔自己的错:上一笔的未绑 21 叶是探针错」),别把口径修正包装成「补充说明」。
|
||||||
|
|
||||||
|
6. **被改文件全文通读,核对其余小节**——不是只看 diff。同一文档内互相引用的小节必须一致(本窗产品需求:§2.4 立项未排、§5.14 FR-SOE-3、§8.2 绑定现状、§8.3 已绑分页表、§9.1 差距表、§9.2 顺序表六处同时引用这四个数;`docs/任务规划/README.md` §1 当前执行表 vs §2 全项目顺序表)。若发现上一笔自己写进文档的数**口径就错了**,明确认错并改正,同时把**可复核的重算方法**写进文档(「按缩进判叶子:有更深缩进后继者即非叶子」),防止再错。
|
||||||
|
|
||||||
|
7. **提交后再复扫一遍**(收尾硬步骤,不许省,也不许只在提交前扫)。按第 2 步的**全部**旧形态整仓再扫一次,逐条分类为 当前态 / 历史行 / 同数不同义:本窗 `cbb4fdb8` 提交后复扫得 14 条命中,12 条以上都在变更记录历史行(`47 → 53`、`53 → 60`、`122/217` 冻结),**唯一残留的当前态命中**是 `docs/架构设计/项目架构总览.md` §3「界面层」那行 → 单独补一笔 `11d60057`。说不出归属的就是漏改。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
grep -rn -E '22 叶|未绑 11|11 叶|53 页|122 唯一字|217 字形|185 唯一字|280 字形|内容页 53|PAGES = 53|UI_PAGE_MAP_COUNT = 22' \
|
||||||
|
. | grep -v -E '^\./(ci-artifacts|\.mnemon|\.dsh/runs)/'
|
||||||
|
```
|
||||||
|
|
||||||
|
8. **跑门禁、分笔提交**(`git commit` 属 Ask first,须用户明确指示后再做):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
& '.venv\Scripts\python.exe' scripts/check_doc_links.py --all --sections-strict # 期望「文档路径检查通过(N 个文档)」
|
||||||
|
& '\.venv\Scripts\python.exe' scripts/archive/id_gate/check_ids_new.py --range A..B # 编号形态普查(两道门禁 2026-10-28 / 2026-10-29 都已退役,不再是提交钩子)
|
||||||
|
& '.venv\Scripts\python.exe' scripts/ci_local.py # 或 pre-commit run --files <被改文件>
|
||||||
|
```
|
||||||
|
|
||||||
|
PowerShell 形态把 `./.venv/Scripts/python.exe` 换成 `& '.venv\Scripts\python.exe'`。提交信息一律 `git commit -F <UTF-8 无 BOM 文件>`,别用 `-m`(中文长标题易超 100 字符、终端代码页会转码);标题 ≤100、**正文每行 ≤80**(实测把正文压到 66/78 字符才过 gitlint)。
|
||||||
@@ -47,7 +47,7 @@ whenToUse: 需要向 STM32 目标烧录 .hex/.bin、只复位重启目标、校
|
|||||||
被拒,本 skill 会打印 `RESET_NOTE` 并**自动降级**为这条系统复位(仍按降级结果给 rc)。
|
被拒,本 skill 会打印 `RESET_NOTE` 并**自动降级**为这条系统复位(仍按降级结果给 rc)。
|
||||||
- 探针在位先 `list`;`Programming Complete` 但行为像旧固件 → 先怀疑缺 `-Rst -Run`,
|
- 探针在位先 `list`;`Programming Complete` 但行为像旧固件 → 先怀疑缺 `-Rst -Run`,
|
||||||
再怀疑 hex 没重编(对比 hex 时间戳)。
|
再怀疑 hex 没重编(对比 hex 时间戳)。
|
||||||
- 完整复位语义/故障排查(本仓库):`docs/测试说明/ST-LINK_CLI_复位使用说明.md`。
|
- 完整复位语义/故障排查(本仓库):`docs/测试说明/测试工具/ST-LINK_CLI_复位使用说明.md`。
|
||||||
|
|
||||||
## 与其它 skill 的衔接
|
## 与其它 skill 的衔接
|
||||||
|
|
||||||
|
|||||||
@@ -1,123 +1,173 @@
|
|||||||
---
|
---
|
||||||
name: task-worktree
|
name: task-worktree
|
||||||
description: 多任务并行开发的空间管理(git worktree 生命周期):new 开一棵工作树 + 任务分支、目录联接共享 .venv 与模拟器 SDK、build/bench 两类独占锁、done 预演合并回主干并审计并集残留。规格见 docs/环境与部署/并行任务工作树方案.md。
|
description: >-
|
||||||
whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;要为新任务开一个隔离空间;要把任务合并回主干;或遇到两个任务抢 COM6 / UV4、"改了文档被覆盖"、并集合并残留时。
|
并行开发的空间管理(git worktree 生命周期;`@task` 只是文档别名不是可执行命令,DSH 会话显式调用 `/task-worktree`):开任务树与分支、联接共享 `.venv` 与模拟器 SDK、build/bench 独占锁、并发上限与腾位(A)、放不掉的锁(B)、`done` 是合并点不是查看器与补绿链(C)、主树被别的会话弄脏时的手工合并(C2)、门禁指标异常的净室归因(D)、回收非登记树与悬挂台账(E)、与 DSH worktree 插件共存及联接分级(F)、树内跑门禁与消息禁词(G)、树内自查与合并后补绿命令化(`precheck` / `postmerge`)。规格 = docs/环境与部署/并行任务工作树方案.md;深规程全文在 reference/。
|
||||||
|
whenToUse: >-
|
||||||
|
要为新任务开隔离空间、同时推进多个任务、把任务合回主干或回收工作树;或撞到 `new` 报「已有 8 个活跃任务(上限 8)」、`lock release` 报「任务 X 不存在(缺台账)」、`done` 报「门禁未全绿」或「主工作树不干净」而那份脏改动是别的会话/插件留的、两个任务抢 COM6 或 UV4、并集合并残留;或在任务树里跑门禁写输出报 ENOENT、`--all` 的问题全在 `simulator/SDL2` 联接下;或门禁指标异常增长查不出哪个文件造成;或要回收 `list` 台账看不到的树、清 branch/path 都已不存在的悬挂台账;或要确认插件树里 `.venv` 与 `simulator/SDL2` 哪个必须补;或要改并行上限这个数字并同步全仓口径。
|
||||||
---
|
---
|
||||||
|
# task-worktree — 多任务并行开发空间(`@task` 仅为文档别名;DSH 显式调用 `/task-worktree`;可执行形态见「固定命令」)
|
||||||
|
|
||||||
# task-worktree — 多任务并行开发空间(短命令 `@task`)
|
- **执行体** = `.dsh/skills/task-worktree/scripts/task_worktree.py`(纯标准库;拷整个 skill 目录即可换项目用)。
|
||||||
|
- **深规程全文在 reference/ 下:做 C2 / D / E / F / G 任何一条之前先把对应那篇读完**——本文只留判得出来「要不要读」的判据。
|
||||||
|
- 为什么这么拆:本文件受 dsh-mnemon 写侧上限 **65536 字节**(`skill-size` 钩子 49152 告警;实测 72,601 字节的技能照常可读可提交,只是自进化每次「生成草案」被拒、永久卡在「生成中」)→ **新增深规程进 reference/,本文只加判据**。瘦身前 frontmatter 原文 → [frontmatter-history.md](reference/frontmatter-history.md)。
|
||||||
|
|
||||||
**执行体 = 本 skill 自带脚本** `.dsh/skills/task-worktree/scripts/task_worktree.py`
|
## 症状 → 去哪一节
|
||||||
(纯标准库,任意 python3;拷整个 skill 目录到别的项目即可用)。
|
|
||||||
|
|
||||||
```powershell
|
| 现场 / 目标 | 去 |
|
||||||
# cwd = 仓库根(或任意子目录)
|
|---|---|
|
||||||
& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" <子命令> [参数]
|
| 开新树 / 看全景 / 追平主干 / 放弃任务 | 固定命令、典型流程 |
|
||||||
```
|
| `new` 报「已有 8 个活跃任务(上限 8)」 | **A** |
|
||||||
|
| `lock release` 报「任务 X 不存在(缺台账)」,锁照旧挂着 | **B** |
|
||||||
退出码:`0`=成功 / `1`=操作失败 / `2`=前置或环境错误 / `3`=用法。
|
| `done` 做了什么、红了怎么补绿、能不能反悔、怎么算「真完成」 | **C** |
|
||||||
|
| `done` 报「主工作树不干净」,而那份脏是别的会话/插件留的 | **C2** → [merge-dirty-main-tree.md](reference/merge-dirty-main-tree.md) |
|
||||||
|
| 门禁指标异常增长要逐文件归因(主树全程只读) | **D** → [clean-room-measure.md](reference/clean-room-measure.md) |
|
||||||
|
| `list` 台账里没有的树要回收 / detached / 有在飞内容 / 悬挂台账 | **E** → [reclaim-unregistered.md](reference/reclaim-unregistered.md) |
|
||||||
|
| 插件树缺联接、锁归属 `pw:<name>`、「有插件为何还自持生命周期」 | **F** → [dsh-plugin-bridge.md](reference/dsh-plugin-bridge.md) |
|
||||||
|
| 树内写门禁输出 ENOENT、`--all` 假红、写提交/合并信息、字节体检 | **G** → [gates-in-tree.md](reference/gates-in-tree.md) |
|
||||||
|
| 收工前树内自查、或合并后体积凭证/台账红了想一条命令补绿 | `precheck` / `postmerge`(固定命令表 + C 节第 5~6 步) |
|
||||||
|
| 退出码读到 0 其实是 2、`tail` 之后误判「有冲突」 | 读输出 |
|
||||||
|
| 谁不许跑 `done`/`abort`、清场前必做什么、任务期哪些文件不许改 | 铁律 1–9 |
|
||||||
|
| 收工前最后一遍自查(各节该留什么证据)/全部实测坑 | [verification.md](reference/verification.md) / [pitfalls.md](reference/pitfalls.md) |
|
||||||
|
|
||||||
## 固定命令
|
## 固定命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PY=".venv/Scripts/python.exe"; TW=".dsh/skills/task-worktree/scripts/task_worktree.py"
|
||||||
|
"$PY" "$TW" <子命令> [参数] # PowerShell:& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" <子命令>(cwd = 仓库根或任意子目录)
|
||||||
|
```
|
||||||
|
|
||||||
| 命令 | 作用 |
|
| 命令 | 作用 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `new <ID> <slug>` | 建分支 `task/<ID>-<slug>` + 工作树 + 共享联接 + 台账;打印新空间路径 |
|
| `new <ID> <slug>` | 建分支 `task/<ID>-<slug>` + 工作树 + 共享联接 + 台账,打印新空间路径 |
|
||||||
| `list` | 所有任务一览:分支 / 落后领先 / 脏文件数 / 台架状态 + 两个锁的归属 |
|
| `list` | 分支 / 落后领先 / 脏文件数 / 台架状态 + 两把锁归属。**只遍历台账** → 非登记树看不见(E 节) |
|
||||||
| `status <ID>` | 单任务详情(未提交清单、相对基线漂移、台架记录、已建联接) |
|
| `status <ID>` | 未提交清单、相对基线漂移、台架记录、已建联接 |
|
||||||
| `sync <ID>` | 在任务工作树里 `rebase` 追平主干,把冲突控制在任务分支上 |
|
| `sync <ID>` | 在任务树里 `rebase` 追平主干,把冲突留在任务分支上 |
|
||||||
| `lock acquire\|release\|status <build\|bench>` | 独占资源锁 |
|
| `lock acquire|release|status <build\|bench>` | 独占资源锁(→ 铁律 3、B 节) |
|
||||||
|
| `plugin list|adopt|unadopt [插件树名]` | 插件桥:看清单 / 补共享联接 / 摘联接(**不删树**)→ F 节 |
|
||||||
| `done <ID>` | 预演 → 合入主干 → 并集残留审计 → 重生成产物 → 门禁 → 清场 |
|
| `done <ID>` | 预演 → 合入主干 → 并集残留审计 → 重生成产物 → 门禁 → 清场 |
|
||||||
| `abort <ID>` | 放弃任务(工作树 + 分支一起删,**永久丢弃**) |
|
| `abort <ID>` | 工作树 + 分支一起删,**永久丢弃** |
|
||||||
|
| `precheck` | 树内自查(**只读**):BOM 漂移 / CR / U+FFFD + docs 逐档门禁 + `--msgfile --ban` 消息检查;顺手补 `ci-artifacts/`(G 节机械项) |
|
||||||
|
| `postmerge` | 主树合并后补绿:体积凭证**自动诊断**(新鲜跳过;红→先短链 `--update`,只读复检真漂了才升全链)+ `check_doc_links --all` + 列待提交文件,**不代提交** |
|
||||||
|
|
||||||
`new` 常用开关:`--base <分支>`(默认 `v2-on-ui`)· `--needs-bench`(无台架 PASS 则 `done` 拒合)·
|
- **退出码** `0` 成功 / `1` 操作失败 / `2` 前置或环境错误 / `3` 用法——**只有不接管道才读得到**(→ 读输出)。
|
||||||
`--lean`(跳过 Git LFS smudge)· `--no-bootstrap`(不建联接)。
|
- `new`:`--base`(默认 `v2-on-ui`)· `--needs-bench`(无台架 PASS 则 `done` 拒合)· `--lean`(跳 LFS smudge,只省磁盘不省时间)· `--no-bootstrap`(不建联接)· `--force`(**只跳过并发上限检查**,不解决谁让位 → A)
|
||||||
|
- `done`:`--dry-run` · `--skip-build` · `--skip-gates` · `--force`。⚠ `--dry-run` **只保护预演段、不保护清场段**(实测:对已合入的分支它照样摘联接、删分支、清台账;且它的前置也要抢 `build` 锁)→ 用完复验 `git worktree list`,细节 → 陷阱清单。
|
||||||
`done` 常用开关:`--dry-run`(只预演,零改动)· `--skip-build` · `--skip-gates` · `--force`。
|
- `lock`:`--task`(acquire 必填)· `--wait <秒>` · `--ttl <小时>`(默认 `build`=1h / `bench`=6h,超期视为陈旧、下次 acquire 自动接管)· `--note` · `--bench-pass` · `--force`。⚠ `--note` 只在 **acquire** 写进 payload,**release 时被忽略** → 验收证据写主干提交说明或文档。
|
||||||
|
- 环境变量:`DTU_WT_ROOT`(默认仓库同级 `_wt/`)· `DTU_TASK_BASE`(默认基线)· `DTU_INTEGRATION_WORKTREE`(集成点,用于隔离演练)。
|
||||||
**环境变量**(高级):`DTU_WT_ROOT`(任务容器位置,默认仓库同级 `_wt/`)·
|
|
||||||
`DTU_TASK_BASE`(默认基线分支,默认 `v2-on-ui`)·
|
|
||||||
`DTU_INTEGRATION_WORKTREE`(集成点,默认主工作树——用于隔离演练/测试)。
|
|
||||||
|
|
||||||
## 典型流程
|
## 典型流程
|
||||||
|
|
||||||
```powershell
|
```bash
|
||||||
& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" new N21 dhara-isfree --needs-bench
|
"$PY" "$TW" new N21 dhara-isfree --needs-bench # 把新会话工作目录切到打印出来的 _wt/N21-dhara-isfree/
|
||||||
# → 把新会话的工作目录切到打印出来的 _wt/N21-dhara-isfree/,在那里干活
|
"$PY" "$TW" lock acquire bench --task N21 # 烧录 / /serial-boot-test / 板级套件
|
||||||
|
"$PY" "$TW" lock release bench --task N21 --bench-pass
|
||||||
& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" lock acquire bench --task N21
|
"$PY" "$TW" done N21 # 回主工作树(铁律 1);先 --dry-run 读全量
|
||||||
# 烧录 / @test / 板级套件……
|
|
||||||
& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" lock release bench --task N21 --bench-pass
|
|
||||||
|
|
||||||
# 回到主工作树执行回收
|
|
||||||
& $PY ".dsh\skills\task-worktree\scripts\task_worktree.py" done N21
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 读输出:管道与截断(两起误判的共同根因)
|
||||||
|
|
||||||
|
1. **`| tail -N` 之后的退出码是 `tail` 的,恒 0** → 脚本的 1/2 全被吞(实测三条「rc=0」真码都是 2)。判据先看文本(`[ok]` / `!!` / `前置/环境错误:` / `失败:`),要真码就**不接管道**或取 `${PIPESTATUS[0]}`。
|
||||||
|
2. **`done` 的输出别 `tail -2`**:管道下 python stdout 块缓冲、git 子进程 stderr 不缓冲,两股流错位 → 只剩尾部那句「--dry-run:到此为止…」加带 `conflict` 字样的 stderr 片段,据此误判「有冲突」。dry-run 全文 5~8 行,**读全量**。
|
||||||
|
|
||||||
|
## 分步操作(节号 A–G/C2、各节步骤号与铁律编号被 reference/ 与 docs 引用,**不许重排**)
|
||||||
|
|
||||||
|
### A. `new` 撞并发上限 → 腾位
|
||||||
|
|
||||||
|
1. 认原文 `前置/环境错误:已有 8 个活跃任务(上限 8);先 done/abort,或加 --force`(exit 2)。「活跃」= `_wt/.state/<ID>.json` 在且台账 `path` 目录还在 → **手工删目录不占名额、但留悬挂台账**(E 第 6 步)。
|
||||||
|
2. `$TW list` 定谁让位:**已完成的先 `done`**(合并回收一步不省)。
|
||||||
|
3. 无人认领、零提交、未 claim 的空闲树 → `abort <ID>`;有未提交或有提交时脚本会拒,确认可弃才 `--force`。
|
||||||
|
4. 真要第 9 棵才 `--force`:并行度上去,**冲突面与台架排队一起上去**(每棵磁盘代价见铁律 6)。
|
||||||
|
5. **别用手工 `git worktree remove` 给登记任务腾位**:台账不删(同 ID 报「任务已存在」)、又绕过「先摘联接」(铁律 5),有让 git 递归进联接删掉主树 `.venv` 的风险。要回收的是**非登记树**(`list` 里没有它)→ **E 节**。
|
||||||
|
6. 复验:`list` 条目数 == `ls _wt` 目录数,再 `new`。
|
||||||
|
|
||||||
|
### B. 非登记持有者的锁放不掉
|
||||||
|
|
||||||
|
症状:`lock release bench --task acceptwin --bench-pass`(带 `--force` 同)报 `前置/环境错误:任务 acceptwin 不存在(缺台账 _wt/.state/acceptwin.json)`,**锁照旧挂着**。根因:`release` 带 `--bench-pass` 就先 `load_state(task)`,而 `acquire` 侧不查台账 → 「释放」被「记账」挡死;锁没放,`done` 的 `release_stale_locks()` 对未到 TTL 的活锁判失败 → **一个没登记的名字挡住所有任务收口**。
|
||||||
|
|
||||||
|
**2026-10-29 已修**:无台账降级为 `[warn] …跳过 PASS 记录,仅释放锁`,**不再需要手写占位台账**。
|
||||||
|
1. `$TW lock status` 认现场(持有者 / pid / 已持有 / TTL)——别对同一句报错反复重试。
|
||||||
|
2. `$TW lock release <build|bench> --task <NAME> [--bench-pass]` 直接放;PASS 记不上会 warn,锁照放。
|
||||||
|
3. **PASS 后面还要用**(`done` 只认台账 `bench_pass`)→ 从一开始就用已 `new` 的 ID 占锁,或把结论写进主干提交/文档。插件树 `pw:<name>` 天然无台账 → F 节。
|
||||||
|
4. 最后手段:锁 = `_wt/.state/locks/<name>.lock` 的单行 JSON(`name/task/pid/host/epoch/at/note/ttl`),`release` 就是 unlink 它——手工删 = 不记 PASS 的强制释放,前面各步都不可用才做。**早期为绕坑手写的占位台账现在有害**(既占上限名额又被 `list` 当任务)→ E 第 6 步备份后删。
|
||||||
|
|
||||||
|
### C. `done <ID>`:它是合并点,不是查看器
|
||||||
|
|
||||||
|
1. **合并前确认属主提交完**:`$TW status <ID>` 看未提交与领先数,跨会话再问属主一句。实测教训:属主还剩「2/2」没提交就 `done` → 只含 1/2 的分支进了主干,只能另开一棵树补后半(自己的时序错,不是分支问题)。
|
||||||
|
2. 先 `--dry-run` 且**读全量**。判据两行:`[ok] 前置检查通过(领先 <base> 共 N 个提交)` + `[ok] 无冲突,预演结果树 <sha>`;**N 必须等于属主应交的提交数**,对不上回第 1 步。
|
||||||
|
3. 正式 `$TW done <ID>`——**必须在主工作树**(铁律 1)。
|
||||||
|
4. 见 `!! 门禁未全绿:工作树与分支**保留**,未清场` 时,紧跟的 `主干已含合并提交 <sha>` 说明**合并已落主干**;先定位哪条钩子红(`[SUMMARY] static: pre-commit hooks -- passed=N failed=M`),最常态是 `.map` 滞后 → 第 5 步。
|
||||||
|
- ✅ 走提示 a):主工作树修正后追加提交。
|
||||||
|
- ❌ **绝不用 b) 的 `reset --hard <sha>`**——那是丢弃已入库的合并提交,且 `reset --hard` 是项目级 Never(连未提交工作一起丢)。提示 b 是列出的可能性,不是建议。
|
||||||
|
5. 补绿固定链(顺序即因果,全在主工作树,逐条不接管道看退出码):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$PY" scripts/check_mem_numbers.py --write # 台账受管区 ↔ 新 .map 对齐
|
||||||
|
"$PY" scripts/ram_baseline.py --write # RAM 快照 / 92% 预算台账
|
||||||
|
"$PY" scripts/ram_baseline.py --update # 趋势基线追加一行
|
||||||
|
"$PY" scripts/check_test_coverage.py --update-baseline # 覆盖率基线(新单测 / 新套件时必刷)
|
||||||
|
"$PY" scripts/check_doc_links.py --all # 文档链接与章节引用
|
||||||
|
git add <上面刷出来的基线/快照文件> # 显式列文件,别 -A 把别人在飞的改动一起提交
|
||||||
|
git commit -F <UTF-8 无 BOM 的正文文件> # 提交属 Ask first(AGENTS.md 边界)
|
||||||
|
```
|
||||||
|
|
||||||
|
为什么必然要补 → 陷阱「`done` 的门禁跑在 `/keil-build` 之前」。合并解过冲突时,**合并提交期 pre-commit 只查冲突文件**(→ G 节第 3 条)→ 落主干后必须另跑全量。
|
||||||
|
**注释/文档-only 但碰了 .c/.h 的短链**:先比 `Program Size` 与合并前是否逐项相同——相同就只 `ram_baseline.py --update` 重写体积凭证(`scripts/size_attestation.json` 跟着补提交笔走),**别 `--write` 动受管区**(等于给在飞分支造冲突面);数字变了才走上面全链。**已命令化**:`"$PY" "$TW" postmerge`——新鲜直接跳过;红则先短链,再用只读 `check_mem_numbers` 复检裁定是否升全链;末尾跑 `--all` 并列出待提交文件(不代提交)。上面的手工全链仍是权威兜底。
|
||||||
|
6. **重跑 `done` 前主树必须干净**,否则 `失败:主工作树不干净(…)——合并会把未提交改动卷进来` → 第 5 步产物先提交。分支已并时重跑打印 `[i] task/<ID>-<slug> 已合入 <base>,跳过合并,直接清场`——**该路径在门禁之前就返回,不会替你复绿**:上一次门禁没跑到的尾巴(主树 `ci_local.py` + `check_doc_links.py --all`;体积/文档那半可 `postmerge` 一把跑)必须自己补。主树脏且那份脏不是你的 → **C2**。
|
||||||
|
7. 清场后再敲一次 `done <ID>` 得 `前置/环境错误:任务 <ID> 不存在(缺台账 …json)`(exit 2)——**不是失败**:清场最后一步 unlink 了台账。**判完成以 `git log` 为准**。
|
||||||
|
|
||||||
|
### C2. `done` 被「主工作树不干净」挡住,而那份脏是**别的并发会话**留的
|
||||||
|
|
||||||
|
- **不许**对它做 stash / checkout / reset / 提交 / 删除(项目级 Never,你不知它写没写完)。`done` 要求主树干净 → 合并与回收一起被挡,**那是保护不是工具坏了**。
|
||||||
|
- 正解六步,编号与 [reference/merge-dirty-main-tree.md](reference/merge-dirty-main-tree.md) 第 1~6 步一一对应(每步的确切命令与判据在那篇):**取证**(mtime + diff 定性有无交集,顺带 `worktree list` 看有无别人中途新建的树)→ **预演** `git merge-tree --write-tree <base> <task>`(打出结果树 sha 且 RC=0)→ **主树手工合并** `git merge --no-ff --no-edit -F ci-artifacts/merge-msg-<slug>.txt <task>` → **对账**(合并后 blob id 与 stat 同合并前存值逐项一致)→ **备份台账** `cp <ID>.json <ID>.json.state-bak` 后让 `done` 自己摘联接拆树 → **复验** `worktree list` + `lock status`。
|
||||||
|
- **blob id 变了 = stash pop 半冲突**:立刻停止收工,把两份 blob id 与 diff 交给该文件属主,绝不 `checkout` 抹平。
|
||||||
|
- **报告要点名**:绕开 `done` 的理由 + 补回它的证据(预演 sha 与 RC、blob 对账、主树全库 `check_doc_links.py --all`、两把锁空闲)+ 那份外部脏文件与中途冒出来的第二棵树(谁的、你没动它的证据)。合并信息也过 G 的禁词自检;「这份脏通常是谁」见陷阱清单。
|
||||||
|
|
||||||
|
### D. 门禁指标异常增长 → 净室逐文件归因(主树全程只读)
|
||||||
|
|
||||||
|
三条判据:仓外 detached 树当净室、**一次只注入一份在飞文件**、与 HEAD 基线逐文件差分;主树前后 `git status --porcelain` 逐行一致才算「全程只读」。全文 → [reference/clean-room-measure.md](reference/clean-room-measure.md)。
|
||||||
|
|
||||||
|
### E. 回收**非登记**工作树(`list` 为空 / detached / 树里有在飞或未跟踪内容)
|
||||||
|
|
||||||
|
三条不可跳过:① 逐文件内容**三桶归类**(与主线工作区/HEAD 比对;「很久没动」不是判据)② 动手前跑**只读 reparse 预扫**,拿到 `REPARSE: none` 才许 `git worktree remove`(铁律 5)③ 唯一未合并残留先备份再 `--force`,拆完复验 + 清悬挂台账。**绝不跑 `done`/`abort`**(铁律 9)。全文(第 1~6 步 + 两份探针脚本)→ [reference/reclaim-unregistered.md](reference/reclaim-unregistered.md)。
|
||||||
|
|
||||||
|
### F. 与 DSH worktree 插件(`/worktree`、`worktree_create`)共存
|
||||||
|
|
||||||
|
| 联接 | 级别 | 依据 | 缺了会怎样 | 逃生门 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `simulator/SDL2` | **硬需求** | `simulator/CMakeLists.txt:12` 的 `set(SDL2_DIR …)` **非 `CACHE`**,include / `lib/x64` / `SDL2.dll` 全从它派生 | `build_sim.bat` 当场失败 | **无** |
|
||||||
|
| `.venv` | 软需求 | `scripts/ci_local.py` 有 `DTU_PRECOMMIT_PYTHON` + 五级解释器候选 | **静默**落机器级解释器(缺 fontTools → `generated-fresh` 假红) | 环境变量 |
|
||||||
|
|
||||||
|
- `plugin adopt` 两条都补只是因为便宜,不代表两条都硬。插件树占锁记 `pw:<name>`、**回收仍归 /worktree**(本 skill 不删插件树,插件也不删 `_wt/`)。「有插件为何还自持生命周期」=先读插件包(`npm root -g`)再 grep 三个概念名报命中数,插件侧零命中就是理由——取证姿势与答案边界 → [reference/dsh-plugin-bridge.md](reference/dsh-plugin-bridge.md) F-2。
|
||||||
|
|
||||||
|
### G. 任务树内跑门禁:落点、`--all` 假红、消息禁词
|
||||||
|
|
||||||
|
- **新树没有 `ci-artifacts/`**(被 ignore → 不检出):重定向/写文件直接 `ENOENT` 且**整段程序零执行** → 落盘前先单独一条 `mkdir -p ci-artifacts`;树内输出一律树内相对路径(`cd` 进树后 `../_wt/x.txt` 落到 `_wt/_wt/`)。一次性产物**不进仓库根**。
|
||||||
|
- **文档门禁树内按单文件跑;全库 `--all` 留到合并后在主树跑**——树内 `simulator/SDL2` 是同一份仓库的联接,`--all` 把它一起扫成假红(实测 RC=1 而问题全在联接下、要改的文档一条没有)。非要树内跑:① 主树同命令拿基线 ② 过滤联接下的问题行 ③ 剩下的逐行比。**只做一步就是误判。**
|
||||||
|
- **合并提交期 pre-commit 只查冲突文件**(日志首行 `[INFO] Checking merge-conflict files only.`,其余全 `(no files to check) Skipped`)→ 合并落主干后**必须另跑全量** `scripts/ci_local.py`。
|
||||||
|
- **跨层改动拆不成「按层分笔」**:钩子看到的世界 = HEAD + 本笔 staged(未暂存被 stash;未跟踪文件不被 stash 但会拖红同一棵树上别人的提交)→ 登记文件 / 生成物 / 互锁引用的文档分到他笔,本笔必被整仓门禁拦(`doc-links`/`registration-check`/`generated-fresh`/`mem-numbers`)。判据:**一笔提交必须自洽**。
|
||||||
|
- **跑门禁前字节体检**:`lines / bytes / BOM / CR / U+FFFD / 三连续空行 / 行尾空格 / 末行单个 LF` + Markdown 表格每行列数一致性(单元格内竖线写成反斜杠竖线,否则被当分隔符断列)。坏行**未必是本次改动**,先定归属。`doc-links`/`no-mojibake` 只覆盖编码那半。机械项已命令化:`"$TW" precheck`(BOM 漂移 / CR / U+FFFD + docs 逐档门禁,只读);列数一致性与归属判定仍人做。
|
||||||
|
- **提交/合并信息一律 `-F` 读文件**(UTF-8 无 BOM、标题 ≤100、正文每行 ≤80,放 `ci-artifacts/`);清退类任务加**禁词自检**:`git log -1 --pretty=%B | grep -c '<关键词>'` 必须是 **0**。未推送前发现消息写错 → `--amend -F` 就地修,别追加「更正」提交(等于把禁词写回历史)。BOM/CRLF/行长/禁词一次查:`"$TW" precheck --msgfile <文件> --ban <词1> --ban <词2>…`。
|
||||||
|
- 全文(第 1~7 条,含 stash 机制与四道整仓门禁现场)→ [reference/gates-in-tree.md](reference/gates-in-tree.md)。
|
||||||
|
|
||||||
## 铁律
|
## 铁律
|
||||||
|
|
||||||
1. **`done` 只能在主工作树执行**——主干分支检出在主工作树,git 不允许同一分支在两处检出。
|
1. **`done` 只能在主工作树执行**——主干分支检出在主树,git 不允许同一分支两处检出。
|
||||||
2. **`done` 不 push**。它结束于本地合入 + 门禁全绿;推送需用户明确许可(项目既有纪律)。
|
2. **`done` 不 push**:它结束于本地合入 + 门禁全绿;推送需用户明确许可。
|
||||||
3. **`build` 与 `bench` 都必须串行**:UV4 不能两个实例同时 `-b`;串口 COM6、ST-LINK、
|
3. **`build` 与 `bench` 都必须串行**(UV4 不能两个实例同时 `-b`;COM6、ST-LINK、设备 IP `192.168.1.100` 各一份)。`build` 锁由 **/keil-build 自持自释**(`keil_build.py`)→ 调用方**不要**手工 acquire/release;`bench` 锁**必须手工** acquire → 烧录 / `/serial-boot-test` → release `--bench-pass`。非登记持有者怎么放 → B 节;插件树 → F 节。
|
||||||
设备 IP `192.168.1.100` 各只有一份。**构建与烧录都不可并发。**
|
4. **单写者文档**:`docs/任务规划/`(整个文件夹)、`AGENTS.md` 任务表块、`MDK-ARM/*.uvprojx`、`.pre-commit-config.yaml`、内存快照 —— 任务期**不在任务树里改**,留到 `done` 统一处理;用户显式豁免时(在任务树写、由 done 合回)要在交付报告里点名这条例外。
|
||||||
- `build` 锁由 **`@build` 自持自释**,调用方无需手工 acquire/release;
|
5. **清场必须先摘联接**:脚本先 `os.rmdir` 掉 `.venv` / `simulator/SDL2` 联接再 `git worktree remove`。手工拆树前必跑 **E 第 3 步**预扫拿到 `REPARSE: none`——否则 git 可能递归进联接把主树 `.venv` 一起删掉。
|
||||||
- `bench` 锁**必须手工**:`lock acquire bench --task <ID>` → 烧录/`@test`/探针 →
|
6. **活跃任务硬上限 8 = 脚本常量 `MAX_CONCURRENT`(`task_worktree.py:30`)——唯一事实源**:报错文案与 `new` 的插件树提示都插值它,AGENTS.md / 方案文档 / frontmatter / A 节的数字全是引用同步。改这个数字 → 全形态扫描走 `stale-doc-count-sweep` skill + 隔离小仓验证上限真生效;每棵磁盘代价按实测口径重述、别照抄旧数(细节 → 验证清单末条)。满了 exit 2 → A;悬挂台账 → E 第 6 步。
|
||||||
`lock release bench --task <ID> --bench-pass`。
|
7. **工作树放仓库同级 `_wt/`,绝不在仓库内部**(否则污染 `git status`、`--all-files` 与全库搜索)。唯一例外 = 插件写死的仓内 `.dsh-worktrees/`(改不了,已用预置 .gitignore + 三个全库扫描排除兜住 → F 节)。
|
||||||
4. **单写者文档**:`docs/任务规划/`(整个文件夹)、`AGENTS.md` 的任务表块、`MDK-ARM/*.uvprojx`、
|
8. **`done` 是合并点**:属主没提交完就别跑;合并提交进了主干就只用「主树补提交」收尾,**不用 `reset --hard`** 反悔 → C 节。
|
||||||
`.pre-commit-config.yaml`、内存快照 —— 任务期**不在任务工作树里改**,留到 `done` 统一处理。
|
9. **非登记 / detached 的树绝不跑 `done` / `abort`**:两者都以台账为前置(无台账 exit 2),带残留台账时 `abort --force` 会绕过内容保全把别人在飞的改动连树一起删 → E 节。
|
||||||
5. **清场必须先摘联接**:脚本会先 `os.rmdir` 掉 `.venv` / `simulator/SDL2` 联接再
|
|
||||||
`git worktree remove`。**手工清场时千万别直接 `git worktree remove`**——git 可能递归进联接,
|
|
||||||
把主工作树的 `.venv` 一起删掉。
|
|
||||||
6. **并发上限 3 棵工作树**(每棵约 83 MB 检出)。
|
|
||||||
7. **工作树放仓库同级 `_wt/`,绝不在仓库内部**——否则污染 `git status`、`pre-commit --all-files` 与全库搜索。
|
|
||||||
|
|
||||||
## 陷阱(实测)
|
## 陷阱与验证(收工前各扫一遍)
|
||||||
|
|
||||||
- **联接不是符号链接**:本仓库 `core.symlinks=false`,git 里的 symlink 会被检出成
|
- [reference/pitfalls.md](reference/pitfalls.md):全部实测坑逐条(含 `--dry-run` 不保护清场段、门禁跑在 `/keil-build` 之前、未跟踪文件在 `git log` 与合并里都不留痕、并集合并残留、新树从未构建 → 体积类门禁必然红、注释-only 也刷红体积凭证、「已合入」快路径在门禁前返回不替你复绿、`--force` 只覆盖四类检查、联接不是 symlink(`core.symlinks=false`)、锁不绑进程存活、文件工具先读后改按绝对路径记账、中文门禁回显乱码但 rc 可信)。收工前扫一眼。
|
||||||
「内容是目标路径的文本文件」。共享 `.venv` 只能用 Windows **目录联接**(`mklink /J`,
|
- [reference/verification.md](reference/verification.md):每节要留的证据(预演 sha + `MT_RC=0`、blob id 对账、两把锁空闲、禁词计数 0、`REPARSE: none`、改上限数字后的全形态扫描)。
|
||||||
不需要管理员权限),且联接必须建在版本控制之外。
|
|
||||||
实测(2026-10-12):用 ctypes 直调 `DeviceIoControl(FSCTL_SET_REPARSE_POINT)`
|
|
||||||
会被系统拒绝(Win32 4392),`mklink /J` 正常——所以脚本走后者,参数以列表交给
|
|
||||||
subprocess,含空格的路径(本项目 `DTU Code`)由 subprocess 自动加引号。
|
|
||||||
- **联接建失败不中止 `new`**:只告警并打印手工补建命令,避免留下半成品任务空间;
|
|
||||||
但该任务里跑不了 `.venv\Scripts\python.exe`,`@build` / `ci_local.py` 会找不到解释器。
|
|
||||||
- **`done` 前主工作树必须干净**:否则未提交改动会被卷进合并。
|
|
||||||
- **⚠ `done` 的门禁跑在 `@build` 之前 → 依赖 `.map` 的钩子会误报**(2026-10-12 实测):
|
|
||||||
`done` 的顺序是「合并 → 重生成 → **门禁(ci_local)** → `@build`」,而
|
|
||||||
`mem-budget`(N-27)/ `mem-numbers` 是拿**文档快照 vs `.map`** 比对 —— 门禁跑时
|
|
||||||
`.map` 还是**主工作树合并前**的旧产物,与刚合并进来的新台账**必然不一致** →
|
|
||||||
误报 `[FAIL] 台账受管区与 .map 不一致`。`@build` 跑完后 `.map` 才是新的。
|
|
||||||
**处置**:门禁失败且只差这两项时,按提示在主工作树跑
|
|
||||||
`python scripts/ram_baseline.py --write` + `python scripts/check_mem_numbers.py --write`
|
|
||||||
后追加提交,再重跑 `done`(它会检测到「已合入」直接清场)。
|
|
||||||
- **⚠ 增量构建可能不重链 → `.map` 数字瞬态不一致**(2026-10-12 实测):
|
|
||||||
`@build` 若判定无需重链,`.map` 保持旧值,此时 `--write` 生成的快照是**瞬态值**
|
|
||||||
(实测同一份源码两次读到 223,560 与 223,724,差 164 B)。**以主工作树(集成点)
|
|
||||||
的 `.map` 为准**;改完 RAM 相关代码后确认重链再刷新快照。
|
|
||||||
- **合并冲突常落在「生成物」上**:`docs/内存管理/RAM使用分析与优化方案.md`(mem-numbers
|
|
||||||
快照)在双方都改过时必冲突,且**两边都不能直接选**(各自基于不同基线)——
|
|
||||||
正确做法是解冲突后用**合并后的实际构建**重新生成,不要挑一侧。
|
|
||||||
- **并集合并残留**:双方都改过的文件(尤其 `.gitignore`)会被 git 自动并成**并集**,
|
|
||||||
可能留下指向已删目录的死规则或重复条目。脚本会自动报「双方都改过」的文件清单 +
|
|
||||||
连续重复行;**权威判据是 base / 两个 tip 三方对比**(`git show <ref>:<file>` 逐项 `grep -F`),
|
|
||||||
别用 `tail -N` 目测。
|
|
||||||
- **`.workbuddy-ai/` 刻意不共享**:记忆不随任务分叉,也不给联接递归删除的机会。
|
|
||||||
任务工作树自己的记忆由 `done` / `abort` 在清场前归并回主工作树。
|
|
||||||
- **`--lean` 只省磁盘不省时间**:实测全量 smudge 7.25 s / 83 MB,跳过 smudge 7.05 s / 45 MB,
|
|
||||||
两者工作区都干净。默认走全量(参考图可用),磁盘紧张时才用 `--lean`。
|
|
||||||
- **锁不绑定进程存活**:`lock acquire` 本身是短命进程,而真正用资源的烧录/构建在别的进程里跑。
|
|
||||||
所以判「陈旧」只看**时效**(该锁的 TTL)加显式 `--force`;pid 仅作排障记录。
|
|
||||||
同任务重复 acquire 是幂等的(不会把自己锁死)。
|
|
||||||
- **TTL 按锁分设**(2026-10-12):`build` = **1h**、`bench` = **6h**(`LOCK_TTL_HOURS`)。
|
|
||||||
口径是「兜底窗口应与操作真实时长同量级」——编译是秒~分钟级,6h 的兜底太钝;
|
|
||||||
bench 是会话级(flash→`@test`→探针),才配得上小时级。持有者还可用
|
|
||||||
`lock acquire ... --ttl <小时>` **自报**更精确的窗口(`keil_build.py` 即按其 `--timeout` 自报)。
|
|
||||||
- **陈旧锁自动接管**:超过该锁 TTL 未释放即视为陈旧,下次 `acquire` 会接管并提示;
|
|
||||||
未到 TTL 的活锁需 `--force`。`done` 的收口前置也会**自动释放陈旧锁**(不再硬失败)。
|
|
||||||
- **`build` 锁不必手工持**(2026-10-12 起):`@build`(`keil_build.py`)**自持自释**——
|
|
||||||
进入 acquire、`finally` 无条件 release,所以"Agent 忘了 release、锁一直挂到 TTL"这条在构建路径上已消除。
|
|
||||||
`done` 的门禁自己持锁并给子进程设 `DTU_LOCK_HELD=build` 告知跳过(新旧脚本都兼容)。
|
|
||||||
**`bench` 锁仍须 Agent 手工持**(会话作用域:跨烧录→`@test`→探针,见 `keil-build` SKILL.md)。
|
|
||||||
|
|
||||||
## 与其它 skill 的衔接
|
## 与其它 skill 的衔接
|
||||||
|
|
||||||
- 构建 = `keil-build` skill(`@build`);烧录/回读 = `stlink-flash` skill;串口闭环 = `serial-boot-test` skill
|
- 构建 = `keil-build`· 烧录/回读 = `stlink-flash` · 串口闭环 = `serial-boot-test`(真实 hex 路径以 `.serialtest/config.json` 的 hex 键为准——实测 `MDK-ARM/Objects/STM32F407-Demo.hex` 不存在,拷错 = 烧旧镜像)
|
||||||
- 新 `.c` 文件 = `new-source-file` skill(`uvprojx` 与 `CMakeLists.txt` 双端注册)
|
- 新 `.c` 双端注册 = `new-source-file` · RAM/.map 台账 = `mem-budget` · UI 回归 = `ui-verify` · 计数同步 = `stale-doc-count-sweep` · 卡死的技能台账行 = `mnemon-skill-ledger-stuck-repair`
|
||||||
- 完整机制与目录分配 → `docs/环境与部署/并行任务工作树方案.md`
|
- DSH worktree 插件(`/worktree` / `worktree_create`,仓内 `.dsh-worktrees/`)与本 skill 的分工、桥接命令、锁归属 → F 节 · 完整机制与目录分配 → `docs/环境与部署/并行任务工作树方案.md`
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
### D. 门禁指标异常增长 → 逐文件归因(把净室工作树当**测量仪**,主树全程只读)
|
||||||
|
|
||||||
|
**场景**(2026-10-27 实测):`id-check` 突然 FAIL「不可判定 1,178 > 上限 1,166」——比上一轮量到的 1,162 又涨了 **+16**,
|
||||||
|
而主工作树里同时压着 4 条在飞任务道的未提交改动。**不许 stash / checkout / reset**(那是搬动甚至丢弃别人未提交的工作,
|
||||||
|
项目级 Never;`git status` 一乱,整条道的现场就没了),**不许改门禁脚本本身**(它就是那把尺子,改了数字就不可信),
|
||||||
|
**也别在脏主树里跑门禁**(那测的是 4 条道 + 未跟踪文件的混合体,永远分不出谁)。正解:**在仓库之外另起一棵
|
||||||
|
detached HEAD 的干净工作树当净室,一次只往里注入一份在飞文件**,与基线差分即得每个文件的独立贡献。两步:
|
||||||
|
**净室建树 → 单文件注入测量**。
|
||||||
|
|
||||||
|
1. **建净室 + 先落基线数**(路径必须**在仓库目录之外**;铁律 7 对临时树同样成立——放仓内就污染 `git status` /
|
||||||
|
`pre-commit --all-files` / 全库搜索):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MAIN="D:/Code/DTU Code/STM32F4-Base" # 主工作树绝对路径(含空格 → 变量整体加引号)
|
||||||
|
WT="$MAIN/../_wt/und-attr" # 仓外同级 _wt/ 下;一次测量固定用同一个名字
|
||||||
|
rm -rf "$WT" 2>/dev/null
|
||||||
|
git -C "$MAIN" worktree add --detach "$WT" HEAD >/dev/null 2>&1 || { echo WT_FAIL; exit 9; }
|
||||||
|
git -C "$WT" status --porcelain # 应为空
|
||||||
|
"$PY" "$WT/scripts/archive/id_gate/check_ids.py" 2>&1 | tail -25 # ← 口径:id-check 钩子已于 2026-10-28 退役(脚本移入 scripts/archive/id_gate/),按该脚本自身默认参数跑;基线与注入用**同一条命令**
|
||||||
|
```
|
||||||
|
|
||||||
|
- `--detach`(**不要 `-b`**):净室没有分支、不写 `_wt/.state/<ID>.json` 台账 → **不占 `MAX_CONCURRENT = 8`
|
||||||
|
名额、不出现在 `$TW list`**,也没人会被误指去对它跑 `done`(建与拆都不走 `$TW new` / `$TW done`)。
|
||||||
|
- 能这么量的前提:该门禁**只读仓库内文本**。依赖本树构建产物的门禁(`mem-numbers` / `mem-budget` 要 `.map`)
|
||||||
|
在净室里必然报「找不到 .map 事实源」(见陷阱「新建工作树从未构建过」)——**那类指标不能这么归因**。
|
||||||
|
- `git status --porcelain` 非空 = 有别人的未跟踪文件被带进净室 → 先 `git clean -xdn` **干扫**(别去 `n`)
|
||||||
|
看清会删什么,只挪走多出来的那些再测;语料被污染出来的数字与 CI 不可比。
|
||||||
|
2. **一次只注入一份在飞文件 → 跑同一个扫描器 → 与基线差分**(得到的是每个文件的**独立**贡献,不是合计):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for f in "App/log/sys_log_mcu.c" "test/host/sys_log_mcu/sys_log_mcu_test.c" "docs/子系统设计/运行与自检记录设计说明.md"; do
|
||||||
|
mkdir -p "$WT/$(dirname "$f")"
|
||||||
|
cp "$MAIN/$f" "$WT/$f" # 拷进净室;主树那份只读、一字节不动
|
||||||
|
echo "== $f =="; "$PY" "$WT/scripts/archive/id_gate/check_ids.py" 2>&1 | tail -25 # ← 与第 1 步同一条命令
|
||||||
|
git -C "$WT" checkout -- "$f" # 只还原净室里自己刚拷进来的那份
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
实测:`run_par.c` / `board_ui_nav.py` / `check_nested_comment.py` / keil `SKILL.md` **各 +0**,
|
||||||
|
而 log 道那三个文件 **+6 / +8 / +2**,合计**正好 +16**。**Σ(逐文件增量) ≠ 观察到的涨幅 → 还有没被注入的
|
||||||
|
在飞文件,或存在文件间交互项**,不许就此交卷。
|
||||||
|
3. **定性增量是哪一类字面量**(只有「谁」没有「为什么」= 给不出可执行的改法):同一计数器在 **HEAD 版**与
|
||||||
|
**在飞版**各数一遍,**差值才是新增行带来的**(存量的不算这条道的账);再对新增行直接 grep。本次 16 处全是
|
||||||
|
**裸形 「字母 T 累进数字 4」**(`T` 在 `scripts/archive/id_gate/id_registry.json` 的 `bare_check_letters` 里,规范形态是连字符形),
|
||||||
|
全部落进 `skip_bare_unmanaged` —— 到这一步结论才配发给属主。
|
||||||
|
4. **闭合 + 复验主树一个字节没被污染**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C "$MAIN" worktree remove --force "$WT" # 净室里只有自己的拷贝、没有原件,--force 安全
|
||||||
|
git -C "$MAIN" worktree list # 不该再有 und-attr
|
||||||
|
git -C "$MAIN" status --porcelain | sort # 与测量前**逐行一致**(测量前先存一份基线清单)
|
||||||
|
```
|
||||||
|
|
||||||
|
⚠ **上面第一行的前提是有条件的**:树里混进了别人的在飞 / 未跟踪内容时「只有自己的拷贝」就不成立了,
|
||||||
|
`--force` 直接拆等于替别人把唯一版本删掉 → 先走 **E 节**(全景 → 逐文件分桶 → 联接预扫 → 唯一残留先备份),
|
||||||
|
再回来执行本步;复验按 **E 第 5 步**做(拆前/拆后主树 `status --porcelain -uall` 行数必须相同)。
|
||||||
|
**同一棵净室树的第二种用法**:按指定 commit **只读**构建出像 / 烧录验收——不搬动任何在飞改动,也不为台架
|
||||||
|
提前抢发一笔提交;构建与烧录都在这棵树里做(不在主树),仍受 `bench` / `build` 锁串行(铁律 3)。
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
### F. 与 DSH worktree 插件(`/worktree`、模型工具 `worktree_create`)共存
|
||||||
|
|
||||||
|
本仓现在有**两套**隔离空间。别混用,也别互相回收:
|
||||||
|
|
||||||
|
| | 本 skill(文档别名 `@task`,DSH 显式调用 `/task-worktree`) | DSH worktree 插件(`dsh-task-worktree`) |
|
||||||
|
|---|---|---|
|
||||||
|
| 树在哪 | 仓库**外** `../_wt/<ID>-<slug>/` | 仓库**内** `.dsh-worktrees/worktree/<name>/` |
|
||||||
|
| 谁建 | `new <ID> <slug>`(人来敲命令) | 会话选「Worktree模式」/ 模型调 `worktree_create`(零命令) |
|
||||||
|
| 台账 | `_wt/.state/<ID>.json`(本 skill) | `.dsh-worktrees/manifest.json`(插件私有格式,别去改) |
|
||||||
|
| 分支名 | `task/<ID>-<slug>` | `worktree/<name>` |
|
||||||
|
| 共享联接 | `new` 自动建 | **建树时没有** → 要 `plugin adopt` 补 |
|
||||||
|
| 回收 | `done`(预演 / 合并 / 门禁 / 清场)或 `abort` | 用户敲 `/worktree finish\|bring-back\|remove\|prune`(模型碰不到收尾命令) |
|
||||||
|
|
||||||
|
**为什么允许它把树建在仓内**(铁律 7 的唯一例外):路径写死在插件里(`WORKTREE_NAMESPACE = worktree`
|
||||||
|
+ `<repo>/.dsh-worktrees/`),而暴露给模型的工具只有 `worktree_create` / `worktree_list` /
|
||||||
|
`worktree_status` 三个,配置面改不动。兜法三件(均已落地):
|
||||||
|
|
||||||
|
1. `.gitignore` **预置** `.dsh-worktrees/` —— 插件首次 `create` 会往 `.gitignore` 追加一模一样的字面量,
|
||||||
|
`ensureIgnored()` 见到同名规则即跳过;不预置就等于让插件在会话中途改这份受控文件(diff 里冒出
|
||||||
|
一行"不是我加的")。
|
||||||
|
2. 全库扫描的排除面登记了它:`scripts/check_doc_links.py` 的 `SKIP_PARTS`、
|
||||||
|
`tools/refgraph/gen_refgraph.py` 的 `EXCLUDE_DIRNAMES`、`scripts/check_layer_deps.py` 的 `os.walk` 兜底。
|
||||||
|
一份仓库副本被全库扫描吞进去 = 文档链接假红 + 配图生成物逐字节漂移砸在某位同事头上。
|
||||||
|
3. 联接与锁归属由本节命令接上(下面三步)。
|
||||||
|
|
||||||
|
**这棵树是不是插件的**:`git worktree list` 里路径含 `.dsh-worktrees/` 就是。`$TW list` 现在会在台账段
|
||||||
|
下面另起一段列出它们,联接列**点名缺哪一样**(`已联` / `缺 .venv` / `缺 SDL2` / `齐(实体SDL2)`)。**插件树不占
|
||||||
|
`MAX_CONCURRENT = 8` 名额**——回收不归本 skill,
|
||||||
|
硬计入只会让别人那棵关不掉的树把 `new` 挡死(`new` 会打印一行「另有 N 个插件工作树不计入上限」提醒并发代价)。
|
||||||
|
|
||||||
|
三步接上(`<name>` 可带 `worktree/` 前缀,也可多段如 `refactor/logging`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
"$PY" "$TW" plugin list # 一览:分支 / 脏 / 缺哪个联接(缺项另起一行说后果)
|
||||||
|
"$PY" "$TW" plugin adopt N21-dhara-isfree # 只补缺失的 .venv 与 simulator/SDL2 联接
|
||||||
|
"$PY" "$TW" plugin unadopt N21-dhara-isfree # 只摘联接(os.rmdir),绝不动目标内容与树里文件
|
||||||
|
```
|
||||||
|
|
||||||
|
- **两条联接坏了的样子完全不同**:缺 `.venv` → `ci_local.py` 的四级解释器回退**静默**落到机器级
|
||||||
|
python(无 fontTools = `generated-fresh` 假红的老根因,AGENTS.md 记着);缺 `simulator/SDL2` →
|
||||||
|
`build_sim.bat` 当场报错(`SDL2_DIR` 是裸 `set`,没有 env 逃生口)。所以列表只说「已联」不够——
|
||||||
|
它按缺的那一样分别给后果,`adopt` 没补齐也只报「还不能算就绪」,不许说「现在可以跑」。
|
||||||
|
- **adopt 只碰联接**:已联的跳过;`simulator/SDL2` 若已是实体目录只告警不动;`.dsh-worktrees/` 之外的路径
|
||||||
|
一律 exit 2(实测 `plugin adopt ".."` 被拒)——这道闸防的是把主树或 `_wt` 目录当插件树遮蔽掉真实内容。
|
||||||
|
- **锁归属现在认得出来**:`/keil-build` 在插件树里自持自释时,持有者名从 cwd 反推成 `pw:<name>`(多段名压平成
|
||||||
|
`-`),与 `plugin adopt` 打印的那个名字逐字相同(`keil_build.py: plugin_tree_owner()` ↔
|
||||||
|
`task_worktree.py: plugin_lock_owner()`)→ `lock status` 不再一律落成 `build-cli`。
|
||||||
|
- 上台架仍须手工占锁、用同一个名:`lock acquire bench --task pw:<name> --note <说明>` →
|
||||||
|
`lock release bench --task pw:<name> --bench-pass`。插件树没有台账 → 按 B 节只 warn、锁照常放。
|
||||||
|
- **命名建议**:插件 `<name>` 沿用本 skill 的 `<ID>-<slug>` 形态(如 `N21-dhara-isfree`),两套视图同名好对账;
|
||||||
|
但分支前缀必然不同(`worktree/` vs `task/`),别把两者说成同一个分支。
|
||||||
|
- **收尾不在这**:`done` / `abort` 拿插件树名下手只会 exit 2(这是保护,别绕过去手工 `git worktree remove`;
|
||||||
|
要拆先 `plugin unadopt` 摘联接,再由**用户** `/worktree remove`)。`/worktree bring-back` 要求主工作树干净,
|
||||||
|
本仓常有在飞改动 → 提交或 stash 由用户定。反过来插件也不会碰 `_wt/`(路径根不同,谁也删不掉谁)。
|
||||||
|
|
||||||
|
### F-1. 两条共享联接**不是同级需求**(按仓库实配定级;别答成「两个都必须」)
|
||||||
|
|
||||||
|
- **`simulator/SDL2` = 硬需求**:`simulator/CMakeLists.txt:12` 是
|
||||||
|
`set(SDL2_DIR "\${CMAKE_CURRENT_SOURCE_DIR}/SDL2/SDL2-2.32.10")`——普通 `set()`、**非 `CACHE`**,所以既没有环境变量入口、
|
||||||
|
命令行 `-DSDL2_DIR=` 也会被这个普通变量盖掉;`SDL2_INCLUDE_DIR`(:34)、`SDL2_INCLUDE_PARENT`(:70)、`SDL2_LIB_X64`(:71)、
|
||||||
|
链接目录(:180)、`SDL2.dll` 拷贝(:207)**全部从它派生**。缺它 `build_sim.bat` 当场配不出来(脚本里的 `SDL2_DIR` 同样是
|
||||||
|
裸 `set`,也没有 env 逃生口)。
|
||||||
|
- **`.venv` = 软需求**(有显式逃生门,但绕开有代价):`scripts/ci_local.py` 的解释器解析顺序(:108-:138)——
|
||||||
|
① `DTU_PRECOMMIT_PYTHON`(显式配置,指错**立即报错**、不静默降级)→ ② `sys.executable` → ③ 仓库 `.venv` →
|
||||||
|
④ `shutil.which("python")` → ⑤ `_global_python_candidates()` 的常见机器级位置。树内没有 `.venv` 时会**静默**落到机器级
|
||||||
|
解释器 → 无 fontTools → `generated-fresh` 假红(AGENTS.md 记着的老根因);可以用 `DTU_PRECOMMIT_PYTHON` 顶掉,
|
||||||
|
但要每台机器自己配——这就是它「软」的意思:有路可走,走得不爽。
|
||||||
|
|
||||||
|
结论口径:**要补就两条都补(`plugin adopt` 一次干完,成本近乎零),但「都必须」是错的**——SDL2 没有任何替代路径,
|
||||||
|
`.venv` 有 `DTU_PRECOMMIT_PYTHON` 这条门。现役命令形式仍是 `plugin list` / `plugin adopt <name>`;**不改代码、
|
||||||
|
不改 `CMakeLists.txt`**(真要给 SDL2 加 CACHE 逃生门属于改模拟器构建口径,另案 → `lvgl-simulator` skill)。
|
||||||
|
|
||||||
|
### F-2. 「DSH 都有 worktree 插件了,为什么还自持 Python 生命周期」——取证姿势与答案边界
|
||||||
|
|
||||||
|
这题不许凭印象答,口径 = **实读插件源码**:在 DSH 安装目录定位插件包(`npm root -g` → `<global>/node_modules/@deepseek-ai/dsh/`,
|
||||||
|
或读 profile 的 `dsh.profile.bundles` 找包名),**记下版本号与文件路径**,再对三个概念名逐个 grep 并**报出命中数**:
|
||||||
|
|
||||||
|
1. 独占锁 / 资源串行(`lock`、`acquire`、`release`、TTL)——插件侧**零命中**:它的模型是「一棵树一个会话」,没有跨会话排队概念。
|
||||||
|
2. 共享目录联接(`.venv`、`simulator/SDL2`、`mklink /J`、reparse point)——零命中:插件建树就是纯 `git worktree add`,
|
||||||
|
所以插件树天生缺这两条联接(定级见 F-1)。
|
||||||
|
3. 联接状态显示(三态:已联 / 缺 .venv / 缺 SDL2 / 齐(实体SDL2))——零命中。
|
||||||
|
|
||||||
|
**重叠面要说清**:两侧底层是**同一条** `git worktree add`,本 skill 没有重造「建树」;重叠只在**开**与**收**两个动作,
|
||||||
|
而插件的收(`/worktree finish|bring-back|remove|prune`)是**人类命令**、模型碰不到,也没有「合并前预演 → 并集残留审计 →
|
||||||
|
补基线 → 门禁 → 先摘联接」这条编排。本仓为什么必须自持:硬件各只有一份(一块 ST-LINK、一套 Keil 授权、一个 COM6、
|
||||||
|
一个 `192.168.1.100`)→ 要 `build` / `bench` 锁串行;SDL2 是模拟器硬依赖且无逃生门 → 要共享联接;台架 PASS 要留给
|
||||||
|
`done` 记账 → 要台账。**给不出版本号与 grep 命中数,就不要下结论。**
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# 原 frontmatter(2026-10-29 因超 65536 字节写侧上限而瘦身,原文逐字留档)
|
||||||
|
|
||||||
|
本技能正文受 dsh-mnemon 插件写侧上限 65536 字节约束(只在写侧校验,加载侧不查),超限时自进化每次「生成草案」都会被拒、行永久卡在「生成中」。以下是瘦身前的 description / whenToUse 原文,触发词语义更全,写路由时可参考;如需恢复请连正文一起改回并先确认总体积 < 65536。
|
||||||
|
|
||||||
|
---
|
||||||
|
name: task-worktree
|
||||||
|
description: 多任务并行开发的空间管理(git worktree 生命周期):new 开一棵工作树 + 任务分支、目录联接共享 .venv 与模拟器 SDK、活跃任务硬上限 8 与腾位、build/bench 两类独占锁(含非登记任务占锁后「缺台账」释放不掉的正解)、done 预演合并回主干、**主工作树被别的并发会话弄脏时的手工合并路径(取证 → `merge-tree` 预演 → `merge --no-ff -F` → 外部改动 blob id 前后对账 → C2 节)**、门禁未全绿的补基线收尾、并集合并残留审计、**任务树内门禁与审计输出的落点(`<tree>/ci-artifacts/` 先 `mkdir -p`;文档链接按单文件在树内跑、全库 `--all` 基线合并后在主树跑;`simulator/SDL2` 联接假红 → G 节)**、**非登记工作树(detached HEAD / 无台账 / 树内有在飞或未跟踪内容)的安全回收**(逐文件内容分桶 + 联接/reparse 预扫 + 唯一残留先备份再 --force + 悬挂台账清理复验),以及**把仓外 detached worktree 当净室测量仪**——门禁指标异常增长时一次只注入一份在飞文件、与基线差分做逐文件归因;**以及与 DSH worktree 插件(`/worktree`、模型工具 `worktree_create`,树在仓内 .dsh-worktrees/)的桥接**——`plugin adopt` 补共享联接、锁归属 `pw:<name>`、回收仍归 /worktree。规格见 docs/环境与部署/并行任务工作树方案.md。
|
||||||
|
whenToUse: 要同时推进多个任务、担心同分支同文档互相冲突;要为新任务开一个隔离空间;要把任务合并回主干;或遇到 `new` 报「已有 8 个活跃任务(上限 8)」、`lock release` 报「任务 X 不存在(缺台账)」、`done` 报「门禁未全绿:工作树与分支保留,未清场」、或 `done` 报「主工作树不干净」而那份未提交改动是**别的会话/插件**留下的(不能提交也不能丢弃 → C2 节手工合并)、两个任务抢 COM6 / UV4、"改了文档被覆盖"、并集合并残留时;或**要在任务树里跑文档/审计门禁**——`--all` 报一堆问题却全在 `simulator/SDL2` 联接下、或往 `ci-artifacts/` 写输出报 ENOENT / No such file or directory(→ G 节);或**门禁指标异常增长(如 id-check 的不可判定数从 1,162 涨到 1,178、+16)却查不出是哪个文件 / 哪条任务道造成的**、主工作树压着多条在飞改动而不许 stash / 提交 / 改门禁脚本、要按 HEAD 基线只读出像验收时;或**要回收 `@task list` 里根本看不到的工作树**(`git worktree list` 有它、台账没有 / detached HEAD / 树里疑似留着别的会话的在飞或未跟踪内容)、或**要清台账里 branch / path 都已不存在的悬挂条目**(`@task list` 输出发怪、`new` 报「任务已存在」或白占 8 个名额之一);或**这棵隔离空间是 DSH worktree 插件开的**(会话选了「Worktree模式」/ `/worktree create`,树在仓库内 `.dsh-worktrees/`)——进去发现 `.venv` 不在、`@build` 与门禁全部起跑失败,或想看清谁在占 UV4 / 台架 → F 节。
|
||||||
|
---
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
### G. 任务树内跑门禁与审计:输出落点(ci-artifacts)与 SDL2 联接假红
|
||||||
|
|
||||||
|
三条硬规矩,全是 2026-10-29 实测付过失败成本的(两起 ENOENT + 一次假红误判):
|
||||||
|
|
||||||
|
1. **门禁/审计输出只落 `<tree>/ci-artifacts/`,而新树里它不存在 → 落盘前先 `mkdir -p`**。`ci-artifacts/` 被
|
||||||
|
`.gitignore` 忽略(不污染脏行数,E 第 4 步的备份同理),但也因此**不会被检出**——`$TW new` 出来的树里没有这个
|
||||||
|
目录。直接 `fs.writeFileSync("<tree>/ci-artifacts/head-md.md", …)` 当场 `ENOENT: no such file or directory`,
|
||||||
|
**整段程序零执行**(实测:那一轮的门禁体检与快照读取全没跑成,白重跑一次)。用 Node 写之前先
|
||||||
|
`fs.mkdirSync(dir, { recursive: true })`;用 bash 重定向就先单独敲一条 `mkdir -p ci-artifacts` 再落盘
|
||||||
|
——重定向在命令执行**之前**解析,目录没建出来时是同一条命令直接失败,后面串着的 `grep` 跟着一起报
|
||||||
|
`No such file or directory`,看着像「门禁没跑出结果」,其实是写错了地方。
|
||||||
|
2. **`cd` 进任务树之后,`../_wt/x.txt` 不是你以为的地方**:`cd` 之后 `..` = `_wt`,`../_wt/x.txt` 落到
|
||||||
|
`_wt/_wt/x.txt`(实测就是这么报的)。**树内命令的输出一律用树内相对路径**(`ci-artifacts/xxx.txt`);
|
||||||
|
要绝对路径就写完整绝对路径,跨树取文件用 `$MAIN/…`、`$WTROOT/…` 这种变量打头的全路径,
|
||||||
|
不靠 `cd` 之后的相对层级往上跳。
|
||||||
|
3. **任务树内文档门禁按单文件跑;全库 `--all` 基线留到合并后在主树跑**。任务树的 `simulator/SDL2` 是指向
|
||||||
|
主树同一份的**目录联接**(`new` 的第 2/3 步建的),`--all` 会把这份「仓库里的第二份仓库副本」一起扫:
|
||||||
|
实测任务树里 `check_doc_links.py --all` 打 `检查 222 个文档,35 处问题`、RC=1,而 **35 处全落在
|
||||||
|
`simulator/SDL2` 联接下**、要改的文档一条都没有 → 判红纯属联接带来的假红。单文件检查不受影响
|
||||||
|
(扫描根就是那一个文件,联接目录不在面内)。主树本身没有 SDL2 时不产生这类假红(联接如实镜像主树,
|
||||||
|
F 节那棵树连 SDL2 都没得联),但**别据此认为 `--all` 在树内可信**。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
F="docs/<子目录>/<本次改动的文件>.md" # 本次改动的那一个文件
|
||||||
|
"$PY" scripts/check_doc_links.py "$F" > ci-artifacts/dl-<slug>.txt 2>&1; echo RC=$?
|
||||||
|
cat ci-artifacts/dl-<slug>.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
逐文件跑的实测读数:需求文档 161 处章节引用 / 98% 机器可判定 / rc=0;方案文档 16 处 / 88% / rc=0。
|
||||||
|
确实要在树内跑 `--all` 时(例如怀疑主干基线本身),判红顺序**固定三步**,缺一步都不许下结论:
|
||||||
|
① 在**主树**跑同一条命令拿基线;② 过滤掉 `simulator/SDL2` 联接下的问题行;③ 剩下的与主树基线逐行比。
|
||||||
|
只跑 `--all` 见 RC=1 就回「文档门禁挂了」= 把基线噪声算成本任务的账;只跑「过滤后为空」就回「全绿」
|
||||||
|
= 会漏掉真红。全库 `--all` 的正解位置是**合并后的主工作树**:实测合入后在主树跑 `check_doc_links.py --all`
|
||||||
|
= 2,597 处章节引用 / 194 个文档 / rc=0 —— 这才证明「就地删叙述」没打断任何引用。
|
||||||
|
另:**不因 `--all` 的既有假红跳过对本次改动文件的单文件复检**(假红是基线的事,你的文件仍要逐个过)。
|
||||||
|
|
||||||
|
4. **文档改动在树内提交前的固定体检**(2026-10-29 实测):`lines / bytes / BOM / CR / U+FFFD / 三连续空行 /
|
||||||
|
行尾空格 / 末行单个 LF` + 一个自写的 **Markdown 表格每行列数一致性**检查(数格子前先把转义管道 `\|` 还原成占位符,
|
||||||
|
否则会把 `\|` 当成列边界数错)。为什么要自己数:`doc-links` 只管链接与章节、`no-mojibake` 只管编码面,
|
||||||
|
**表格断列与删段留下的三连续空行没有任何钩子会报**。
|
||||||
|
实测读数:需求文档 `lines=2665 bytes=196137 BOM=false CR=0 U+FFFD=0 tripleBlank=0 trailingWS=0 endLF=true`,
|
||||||
|
表格体检另报两处坏表格(`L988-992 counts=5/7`、`L2472-2486 counts=2/3`)——**与本次编辑无关的历史行**。
|
||||||
|
所以体检要**区分「本次改动 bad=0」与「整份文件 bad=0」**:先定位归属再决定动不动手,顺手改别人的区域等于把
|
||||||
|
没打算提交的内容卷进这笔改动(报告里写「本次改动引入 0 处」,并点名那两处是存量)。
|
||||||
|
|
||||||
|
5. **提交 / 合并信息一律 `-F` 读文件;清退类任务还要做禁词自检**(2026-10-29 两遍实测):消息文件写在
|
||||||
|
`ci-artifacts/`(被忽略 → 不污染脏行数),UTF-8 无 BOM、CR=0、标题 ≤100 字符、正文每行 ≤80(实测标题 31 / 32 字符、
|
||||||
|
正文最长行 50 / 55)——`-m` 会把中文按终端代码页转码搞坏,长中文标题还容易撞 gitlint。硬自检:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git log -1 --pretty=%B | grep -c '485' # 被清退主题的关键词:必须是 0
|
||||||
|
git log -1 --pretty=%B | grep -ci '\bDE\b' # 同主题的次要关键词:同样 0
|
||||||
|
```
|
||||||
|
|
||||||
|
关键词不许出现在**标题、正文、合并信息**任何一处(实测标题改写成「删除与本方案无关的其它串口通道对照内容」这种
|
||||||
|
语义短语替掉了它)——否则「别让我看到 X」这条要求在 `git log` 里当场破产。合并信息是另一份文件:
|
||||||
|
`ci-artifacts/merge-msg-<slug>.txt` 落盘后**同样 grep 一遍**再 `git merge --no-ff --no-edit -F`(C2 第 3 步)。
|
||||||
|
自审发现消息里的行数 / 统计算错而**尚未推送** → `git commit --amend -F <改好的文件>` 就地修(实测就这么修掉一次
|
||||||
|
off-by-one);**别追加一笔「更正」提交**——那等于把禁词和被更正的错数一起写回历史。
|
||||||
|
6. **合并提交期 pre-commit 只查冲突文件(2026-10-29 实测)**:合并那一次提交的钩子日志首行是 `[INFO] Checking merge-conflict files only.`,其余钩子全打 `(no files to check) Skipped` —— 于是「合并提交通过了 30 项钩子」这句话**只覆盖冲突文件**,别拿它当全量绿。正解:合并落主干后立刻在主工作树跑全量 `scripts/ci_local.py`(静态 + 提交信息 + 主机侧单测 + manual 段),实测全量这一步才会抓到「表格断列」「章节引用悬空」这类跨文件问题。合并解冲突时若上游把 Markdown 表格重排过,注意**单元格里的竖线必须写成反斜杠竖线**(先例 `docs/子系统设计/运行与自检记录设计说明.md` 的 `FA_WRITE\|FA_SEEKEND`),否则 `FA_WRITE|FA_OPEN_APPEND` 会被当列分隔符打断,而 `doc-links` 不查表格列数——要自己数(第 4 条)。
|
||||||
|
7. **跨层改动拆不成「按层分笔」提交(2026-10-29 实测,四笔改一笔)**:钩子看到的世界 = HEAD + 本笔 staged——pre-commit 会 stash 已跟踪文件的未暂存改动,而**未跟踪文件不在 `git diff-index` 里、不会被藏**(`git add -N` 也救不了,pre-commit 会把它撤下索引、文件仍在盘上)。后果:把双端登记(`MDK-ARM/*.uvprojx` / `simulator/CMakeLists.txt`)、生成器产物、互锁引用的文档分到别的笔里,本笔必被 `doc-links`(引用了还没入册的小节)、`registration-check`(源文件登记在另一笔)、`generated-fresh`(`#page=` 找不到页面描述符、字库清单与源不一致)、`mem-numbers`(受管区还是旧 .map 的数)四道**整仓**门禁一起拦。判据:**一笔提交必须自洽**——互锁的改动(代码 + 登记 + 生成物 + 文档)走同一笔;机制与三条处置结论(半成品要么先落地要么先挪出工作树)→ `docs/代码规范/pre-commit使用说明.md` §2「stash 覆盖面」。
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
### C2. `done` 被「主工作树不干净」挡住,而那份脏改动是**别的并发会话**留下的
|
||||||
|
|
||||||
|
**场景**(2026-10-29 同一套流程在同一天跑了两遍,两遍都撞上它):任务树里的文档改完、准备合回 `v2-on-ui`,
|
||||||
|
主工作树却压着一个自己从没碰过的未提交改动(两次都是 `M .dsh/skills/serial-boot-test/scripts/run_boot_test.py`,
|
||||||
|
另一个并发会话/插件留下的)。它既不能提交(不是我的改动,也不知道写没写完),也不能
|
||||||
|
stash / checkout / reset / 删除(项目级 Never:丢弃别人的工作)。`$TW done <ID>` 的前置正好卡在这——
|
||||||
|
它要求主工作树干净,否则未提交改动会被卷进合并(见 C 第 6 步),于是**合并与回收一起被挡**。
|
||||||
|
|
||||||
|
**正解 = 取证 → `merge-tree` 预演 → 主树手工 `--no-ff` 合并 → 自证外部文件没被动过 → 照常收工**,六步:
|
||||||
|
|
||||||
|
1. **先取证再动手**(别一看见脏就下「谁弄坏的」结论)。记下它的 mtime 与 diff 内容,判断与本合并有无交集,
|
||||||
|
并把**合并前**的 blob id 与 `diff --stat` 存进 `ci-artifacts/`(第 4 步拿它对账):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MAIN="D:/Code/DTU Code/STM32F4-Base"
|
||||||
|
EXT=".dsh/skills/serial-boot-test/scripts/run_boot_test.py" # 外部脏文件的仓库相对路径
|
||||||
|
mkdir -p ci-artifacts
|
||||||
|
git -C "$MAIN" status --porcelain # 全量脏清单(本会话只认自己那几条)
|
||||||
|
stat -c "%y %s %n" "$MAIN/$EXT" # mtime + 字节数:定性是谁在什么时候改的
|
||||||
|
git -C "$MAIN" diff -- "$EXT" | cat # 内容性质;与本次合并有无同文件同区域
|
||||||
|
git -C "$MAIN" hash-object "$MAIN/$EXT" | tee ci-artifacts/ext-blob-before.txt # ← 存下这个 blob id
|
||||||
|
git -C "$MAIN" diff --stat -- "$EXT" | tee ci-artifacts/ext-stat-before.txt # ← 以及这份统计(几加几减)
|
||||||
|
```
|
||||||
|
|
||||||
|
实测:mtime 落在另一会话的活跃时段内,diff 是「docstring 引号改写」2+/2−,与要合的文档毫无交集 →
|
||||||
|
定性为**与本合并无交集、不丢弃**。顺手 `git -C "$MAIN" worktree list` 看一眼有没有**别人中途新建的任务树**
|
||||||
|
(两次里都撞见第二棵树 `data-trust-restructure`,正是这份外部改动的来路)——这是并发会话的硬证据。
|
||||||
|
2. **`merge-tree` 预演:先证明能干净合并,再碰主干**。`$TW done --dry-run` 在这条路上同样被前置挡住,
|
||||||
|
预演只能自己敲(git 2.38+ 的新语法,打印的是**结果树 sha**):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C "$MAIN" merge-tree --write-tree v2-on-ui task/<ID>-<slug>; echo MT_RC=$?
|
||||||
|
```
|
||||||
|
|
||||||
|
判据 = **打印出一个 tree sha 且 RC=0**(实测打出 `4220906b…` + `MT_RC=0` 才敢往下走)。⚠ 本机 git 太旧没有
|
||||||
|
这个子命令时退回 `$TW done <ID> --dry-run`——它的前置也是「主树干净」,可用环境变量 `DTU_INTEGRATION_WORKTREE`
|
||||||
|
把集成点指到一棵干净的临时树去演练(见本文开头的环境变量);**别为了跑 dry-run 去动主树那份脏改动**。
|
||||||
|
预演报冲突 → 回到冲突本身处理,不许直接 `--no-ff` 硬合。
|
||||||
|
3. **主工作树里手工 `--no-ff` 合并**(合并信息走 `-F` 文件,放在被忽略的 `ci-artifacts/`;编码口径同 C 第 5 步:
|
||||||
|
UTF-8 无 BOM、正文每行 ≤80 字符;标题格式 `merge: task/<ID>-<slug> 合入 <base>`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C "$MAIN" merge --no-ff --no-edit -F ci-artifacts/merge-msg-<slug>.txt task/<ID>-<slug>
|
||||||
|
```
|
||||||
|
|
||||||
|
pre-commit 会**自己**把那份未提交改动 stash 起来、跑完钩子再放回(输出里能看到 `[INFO] Stashing unstaged files to …`)
|
||||||
|
——**这条不是让你去手工 stash**,正好相反:钩子的 stash 往返让它自己走完,你只需在第 4 步验证它把东西原样还回来了。
|
||||||
|
**为什么可以绕开 `done`**(报告里要写清这句):`done` 的前置要求主工作树干净,而那份外部改动既不能提交也不能
|
||||||
|
丢弃,所以合并只剩「手工预演 + 手工合并」一条合法路径;被跳过的门禁与审计由第 2 步(预演 sha + RC=0)、
|
||||||
|
第 4 步(blob id 对账)、G 节(合并后在主树跑全库基线)、第 6 步(`lock status`)逐项补回。
|
||||||
|
**这不是绕过保护**,是把保护要求的证据逐条自己交出来。
|
||||||
|
4. **自证那份外部改动原样还在**(本节唯一的硬证据,两次实测都靠它结案):合并后**再跑一次**同一对命令,
|
||||||
|
与第 1 步存的两个值**逐项相同**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C "$MAIN" hash-object "$MAIN/$EXT" | tee ci-artifacts/ext-blob-after.txt
|
||||||
|
git -C "$MAIN" diff --stat -- "$EXT" | tee ci-artifacts/ext-stat-after.txt
|
||||||
|
diff ci-artifacts/ext-blob-before.txt ci-artifacts/ext-blob-after.txt # 空输出才算过
|
||||||
|
git -C "$MAIN" status --porcelain # 该文件仍是 M:没被顺手提交,也没被清成干净
|
||||||
|
```
|
||||||
|
|
||||||
|
实测:合并前后 blob id 同为 `369380ca8eb024b593d872455b6a020797abcd8d`、`diff --stat` 同为 2+/2−
|
||||||
|
→ 证明 pre-commit 的 stash 往返没动别人的工作;合进主干的那份文档另按 G 节复核(零残留 + 全库基线)。
|
||||||
|
blob id 变了 = stash pop 出了半冲突(现场会留下 stash 与冲突标记)→ **立刻停止收工**,把两份 blob id 与两份
|
||||||
|
diff 原样贴进报告交给该文件属主;**绝不用 `git checkout -- $EXT` 抹平**(那是销毁别人的工作,项目级 Never)。
|
||||||
|
5. **收工仍走技能自身的代码路径,别自己手写 rm**:先把台账**备份成 `*.state-bak`**(后缀与 E 第 6 步统一),
|
||||||
|
再让脚本自己摘联接:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp "$WTROOT/.state/<ID>.json" "$MAIN/ci-artifacts/<ID>.json.state-bak" # 先备份再动
|
||||||
|
"$PY" "$TW" done <ID> # 打「已合入 <base>,跳过合并,直接清场」→ 先摘 .venv 与 simulator/SDL2 联接 → 删工作树 → 删分支 → unlink 台账
|
||||||
|
```
|
||||||
|
|
||||||
|
实测这条路径能走完(日志:`state backup -> ci-artifacts\screenlink-doc-history.json.state-bak` +
|
||||||
|
`[ok] 已摘除联接 .venv` + `[ok] 已摘除联接 simulator/SDL2`)。**若它仍拿「主工作树不干净」挡在合并前**(没走到
|
||||||
|
清场分支),改走 **E 节**的手工回收路径:台账仍按本步先 `cp` 成 `.state-bak`;第 1~2 步的前置换成
|
||||||
|
「`status --porcelain -uall` 为空 + 唯一提交为空 + `merge-base --is-ancestor` 判 MERGED」三条(本次实测三条齐),
|
||||||
|
第 3 步的联接预扫 + `rmdir` 摘联接**一步不能省**;再 `git worktree remove`(三条前置齐了就不必 `--force`)、
|
||||||
|
删分支、`git worktree prune`。清完再敲一次 `done` 会得到「任务 <ID> 不存在(缺台账 …json)」(exit 2)——
|
||||||
|
**这不是失败**(台账已被 unlink;判完成以 `git log` 为准,见 C 第 7 步)。
|
||||||
|
6. **复验收工**:`git -C "$MAIN" worktree list` 只剩主树 + **别人的树**(那棵不是你的,保留并在报告里点名,
|
||||||
|
别顺手回收 → E 节);`"$PY" "$TW" lock status` 打 `build 空闲(TTL 1.0h)` + `bench 空闲(TTL 6.0h)` 才收工。
|
||||||
|
|
||||||
|
**并发会话证据必须写进报告**:主树里那份外部脏文件、中途冒出来的第二棵任务树,都要点名——是谁的、
|
||||||
|
你怎么定性的、对这次合并的影响(本例:与本合并无交集 / 不丢弃 / 不进本次提交),以及你**没动它**的证据
|
||||||
|
(blob id 前后一致)。「不能丢弃别人的工作」类问题九成是并发会话造成的,不写清就等于没取证。
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
## 陷阱(实测)
|
||||||
|
|
||||||
|
- **`--force` 不是万能钥匙**:它只覆盖四类检查——并发上限、持有者与 `--task` 不符、陈旧锁接管、
|
||||||
|
清场时强制移除工作树。**台账缺失这类前置/环境错误(exit 2)跟它无关**(实测 `lock release
|
||||||
|
--force --bench-pass` 报的是同一句话,锁照旧没放)。同理 `abort --force` 也只覆盖「树里有东西」这一条拒绝,
|
||||||
|
**不会**替你做 E 节的内容保全——它只会删得更快。
|
||||||
|
- **未跟踪文件在 `git log`、提交门禁与 `done` 的合并里都不留痕**(2026-10-29 回收现场):钩子按 HEAD tree 的
|
||||||
|
diff-index 取文件,未跟踪文件从没进过任何钩子的视野;`git log` 也照不出「唯一未合并」——它压根没被提交过。
|
||||||
|
所以「这棵树没有遗留改动」这种结论**只能**由 E 第 2 步的逐文件内容比对给出,`log --oneline -1` + 脏计数都不够。
|
||||||
|
- **`git status --porcelain` 不带 `-uall` 会折叠未跟踪目录**(E 第 1 步):「全景」少一项、分桶少一个文件,
|
||||||
|
最后拆掉的是别人唯一的草稿。回收路径上**一律 `-uall`**。
|
||||||
|
- **`$TW list` 空 ≠ 没有工作树**:`list` 只遍历台账(A 第 1 步),用户说的「那 3 棵 done 掉」很可能在 `$TW list` 里
|
||||||
|
一个都不存在(实测就是)→ 别照 `$TW list` 的空结果回「没有任务」,先 `git worktree list` 拿全集再逐棵定性(E 节)。
|
||||||
|
2026-10-29 起 `list` 还会另起一段列出插件树(`.dsh-worktrees/`,F 节)——**"看见"不等于"归 `done` 管"**。
|
||||||
|
- **「这棵树很久没动」不是回收判据**:实测 `bench-*` 两棵既没台账也没脏文件、mtime 很旧,但真正决定去留的是
|
||||||
|
`git merge-base --is-ancestor <c> HEAD` = MERGED(提交已进主线)+ 未跟踪层为空;而另一棵 mtime 更新的树反而
|
||||||
|
全是已在主线的副本。**判据是内容与祖先关系,不是时间戳。**
|
||||||
|
- **「我记得没建过联接」不是安全判据**:判据只有扫出来的 `REPARSE: none`(E 第 3 步实测 `bench-*` 各挂一个 `.venv`
|
||||||
|
联接,正是「不是自己建的」那类)。
|
||||||
|
- **联接不是符号链接**:本仓库 `core.symlinks=false`,git 里的 symlink 会被检出成
|
||||||
|
「内容是目标路径的文本文件」。共享 `.venv` 只能用 Windows **目录联接**(`mklink /J`,
|
||||||
|
不需要管理员权限),且联接必须建在版本控制之外。
|
||||||
|
实测(2026-10-12):用 ctypes 直调 `DeviceIoControl(FSCTL_SET_REPARSE_POINT)`
|
||||||
|
会被系统拒绝(Win32 4392),`mklink /J` 正常——所以脚本走后者,参数以列表交给
|
||||||
|
subprocess,含空格的路径(本项目 `DTU Code`)由 subprocess 自动加引号。
|
||||||
|
但**读属性**用 ctypes 没问题:`GetFileAttributesW` + `FILE_ATTRIBUTE_REPARSE_POINT` 就是 E 第 3 步的预扫手段。
|
||||||
|
- **联接建失败不中止 `new`**:只告警并打印手工补建命令,避免留下半成品任务空间;
|
||||||
|
但该任务里跑不了 `.venv\Scripts\python.exe`,`/keil-build` / `ci_local.py` 会找不到解释器。
|
||||||
|
- **`done` 前主工作树必须干净**:否则未提交改动会被卷进合并(补完基线再重跑 `done` 时最容易忘,见 C 第 6 步)。
|
||||||
|
- **⚠ 任务树里 `check_doc_links.py --all` 的红多半是 `simulator/SDL2` 目录联接的假红**:全库扫描会吞下那份
|
||||||
|
「仓库里的第二份仓库副本」(实测任务树 222 个文档 / 35 处问题全在联接下、RC=1,而主树同命令基线全绿)。
|
||||||
|
判红必须先过滤 `simulator/SDL2` 下的问题行、再与主树**同一条命令**的基线比对;正解是树内按单文件跑、
|
||||||
|
`--all` 基线合并后在主树跑 → **G 节**。
|
||||||
|
- **新建的任务树里没有 `ci-artifacts/`**(被 `.gitignore` 忽略 → 不被检出):`> ci-artifacts/x.txt` 与
|
||||||
|
`fs.writeFileSync("<tree>/ci-artifacts/x", …)` 都会 `ENOENT`;`cd` 进树后再用 `../_wt/x.txt` 落到 `_wt/_wt/` →
|
||||||
|
`No such file or directory`(两起实测,第二次直接废掉一整轮体检)。落盘前先 `mkdir -p ci-artifacts`,
|
||||||
|
树内输出一律用树内相对路径 → **G 节**。
|
||||||
|
- **`done` 报「主工作树不干净」而那份脏改动不是你的**(别的并发会话 / 插件留下的,实测两次都是
|
||||||
|
`.dsh/skills/serial-boot-test/scripts/run_boot_test.py`):**不许用 stash / 提交 / 删除去清场**(项目级 Never,
|
||||||
|
你也无从判断它写没写完)→ 走 **C2**:取证(mtime + diff + `hash-object` 存进 ci-artifacts)→ `merge-tree` 预演
|
||||||
|
→ 主树 `merge --no-ff -F` → 合并前后 blob id 与 `diff --stat` 对账。**「它看着像废弃的」不是丢弃判据**。
|
||||||
|
- **⚠ `done` 的门禁跑在 `/keil-build` 之前 → 依赖 `.map` 的钩子会误报**(2026-10-12 实测):
|
||||||
|
`done` 的顺序是「合并 → 重生成 → **门禁(ci_local)** → `/keil-build`」,而
|
||||||
|
`mem-budget`(登记簿条目「缩堆迁 CCM 后半(暂缓)」)/ `mem-numbers` 是拿**文档快照 vs `.map`** 比对 —— 门禁跑时
|
||||||
|
`.map` 还是**主工作树合并前**的旧产物,与刚合并进来的新台账**必然不一致** →
|
||||||
|
误报 `[FAIL] 台账受管区与 .map 不一致`。`/keil-build` 跑完后 `.map` 才是新的。
|
||||||
|
**处置(2026-10-25 补全)**:按 C 节第 5 步的完整链补——`check_mem_numbers.py --write` →
|
||||||
|
`ram_baseline.py --write` → `ram_baseline.py --update` → `check_test_coverage.py --update-baseline` →
|
||||||
|
`check_doc_links.py --all` → 提交 → 重跑 `done`(它会检测到「已合入」直接清场)。
|
||||||
|
此时主干已含合并提交,**绝不能用提示里的 `reset --hard` 回退**。
|
||||||
|
- **`done` 的门禁 = `ci_local.py` = `pre-commit run --all-files`(50 条钩子全跑)**(2026-10-26):红了先读
|
||||||
|
`[SUMMARY] static: pre-commit hooks -- passed=N failed=M skipped=K`,**按钩子 id 定根因**,别一律当「`.map` 滞后」
|
||||||
|
去跑补基线链。典型例外:`generated-fresh`(生成器幂等性,触发面 `tools/**`、`docs/images/*.svg`、UI 生成文件)——
|
||||||
|
合进来的分支改了生成器却没重跑产物时,补基线链救不了,处置是在**主工作树重跑那个生成器**、脚本与产物同一笔提交,
|
||||||
|
再重跑 `done`。另注意 `pre-commit run --all-files` 只跑 pre-commit stage,个别只在 commit-msg / 手工阶段跑的守卫不在此列
|
||||||
|
(口径见 `scripts/ci_local.py` 头部说明)。
|
||||||
|
- **⚠ 新建工作树从未构建过 → 体积类门禁必然红**(2026-10-25 实测):`.map` 是每棵树各自的
|
||||||
|
构建产物、不入版本库,所以 `new` 出来的空间里 `mem-numbers` / `mem-budget` / `ci_local.py`
|
||||||
|
的体积门禁步都拿不到事实源,报 `[FAIL] 找不到 .map 事实源`(脚本提示已写明原因,不是代码缺陷)。
|
||||||
|
同理 `done` 的重生成步骤会 `[skip] 未找到 .map,跳过内存快照同步(需先 /keil-build)`。
|
||||||
|
**处置**:新空间第一次提交前先 `/keil-build` 一次(`build` 锁自动排队,与别的树串行)。与上一条
|
||||||
|
的区别要分清:上一条是「有 `.map` 但是旧的」,本条是「根本没有 `.map`」,处置动作不同。
|
||||||
|
- **⚠ 只改注释里的文档路径,也会把「体积同码凭证」刷红**(2026-10-30 实测,`done testdoc`):
|
||||||
|
`scripts/check_size_gate.py` 的判据是**索引 blob 指纹**(实测 1466 个文件),**不是体积数字**——
|
||||||
|
那笔全库路径同步顺带改了 20 个 `.c/.h` 的注释,`Program Size` 逐项没变
|
||||||
|
(Code=246160 / RO-data=29816 / RW-data=2036 / ZI-data=149700),凭证照旧红:
|
||||||
|
`本次代码(指纹 172c6c78b8fb…,1466 文件)与体积测量(指纹 1ed4267c77f6…,2026-10-30T01:15:01)不同码`。
|
||||||
|
**处置比上一条那条补绿链短**:合并后 `done` 自己那次 `/keil-build` 已 0 错 0 警,此时只需
|
||||||
|
`ram_baseline.py --update` 重写凭证(改的是 `scripts/size_attestation.json`;同日同数字时
|
||||||
|
`ram_baseline.tsv` 连趋势行都不会新增),**别顺手 `--write` 去动内存受管快照**——数字没变就改受管区,
|
||||||
|
等于给别的在飞分支造冲突面(台账归属口径见 `mem-budget` skill)。
|
||||||
|
**判据顺序**:先比 `Program Size` 与合并前是否逐项相同 → 相同就只补凭证;不同才走 C 节第 5 步全链。
|
||||||
|
连带的第二个坑:`done` 对「分支已合入」的路径**在门禁之前就 return**(`cmd_done` 里
|
||||||
|
`already and ahead == 0` 那条分支直接清场),所以补完凭证重跑 `done` **不会替你复绿**——
|
||||||
|
落主干后的全量证据得自己跑:`scripts/ci_local.py`(实测 47 runner / 3896 断言全绿)
|
||||||
|
+ 主树 `check_doc_links.py --all`(实测 215 个文档 / 2993 处章节引用 rc=0)。
|
||||||
|
- **⚠ 增量构建可能不重链 → `.map` 数字瞬态不一致**(2026-10-12 实测):
|
||||||
|
`/keil-build` 若判定无需重链,`.map` 保持旧值,此时 `--write` 生成的快照是**瞬态值**
|
||||||
|
(实测同一份源码两次读到 223,560 与 223,724,差 164 B)。**以主工作树(集成点)
|
||||||
|
的 `.map` 为准**;改完 RAM 相关代码后确认重链再刷新快照。
|
||||||
|
- **`ram_baseline.py` 的 help 串里有未转义的 `%`**(2026-10-25 实测:某些直调姿势会渲染 argparse help 并崩)——
|
||||||
|
查用法读脚本头部 docstring,别靠 `--help`;修脚本本身属 `mem-budget` skill 的范围。
|
||||||
|
- **合并冲突常落在「生成物」上**:`docs/内存管理/RAM使用分析与优化方案.md`(mem-numbers
|
||||||
|
快照)在双方都改过时必冲突,且**两边都不能直接选**(各自基于不同基线)——
|
||||||
|
正确做法是解冲突后用**合并后的实际构建**重新生成,不要挑一侧。
|
||||||
|
- **并集合并残留**:双方都改过的文件(尤其 `.gitignore`)会被 git 自动并成**并集**,
|
||||||
|
可能留下指向已删目录的死规则或重复条目。脚本会自动报「双方都改过」的文件清单 +
|
||||||
|
连续重复行;**权威判据是 base / 两个 tip 三方对比**(`git show <ref>:<file>` 逐项 `grep -F`),
|
||||||
|
别用 `tail -N` 目测。
|
||||||
|
- **⚠ 别用 heredoc 打补丁改门禁脚本造探针**(2026-10-27 实测三连败,白烧一整轮):heredoc 里的 python
|
||||||
|
把门禁脚本改坏——`patched 3` 之后 `PROBE_RC=1`、`SyntaxError: unterminated string literal (detected at
|
||||||
|
line 882)`,而它**正是被测对象**,改完的数不可信;另一处 `replace('\', '/')`(想把 Windows 反斜杠换成正斜杠)里的反斜杠被多层引号吞掉
|
||||||
|
→ `SyntaxError (detected at line 16)`。**正解**:探针落成**独立 `.py` 文件**(写文件的工具,UTF-8 无 BOM)
|
||||||
|
再 `"$PY" probe.py`;非用 heredoc 不可就用单引号定界 `<<'PY'`(防变量展开),且**路径一律 `as_posix()`、
|
||||||
|
一个反斜杠都不写**——反斜杠字面量要穿「JS 模板串 → bash → shell 引号 → python」四层,必被吞。
|
||||||
|
E 节那两段扫描脚本同理:**落成 `ci-artifacts/*.py` 再跑**(该目录已被忽略,不会污染第 5 步复验用的脏行数)。
|
||||||
|
- **取门禁数字:import 门禁模块,别猜输出、别打补丁**(2026-10-27;grep 源码 / 手抄正则 / 解析 stdout 三种姿势
|
||||||
|
都拿不到可复核的数,换成这句立刻拿到):先读门禁脚本的 `main()`(直读末尾那几十行)看它怎么装配检查器,
|
||||||
|
把**那几行照抄**进探针——`sys.path.insert(0, 'scripts')` → `import check_ids as ci` →
|
||||||
|
`ci.load_json(ci.REGISTRY_FILE, 'reg')` + `ci.load_json(ci.ALIASES_FILE, 'alias')` →
|
||||||
|
`ck = ci.Checker(reg, al, verbose=False)` → `ck.run()` → `print(ck.stats)`。别猜键名:先
|
||||||
|
`print(sorted(ck.stats))` 列出全量计数项,再按精确键取数(顺带 `git diff` 确认门禁脚本自己**没被改过**)。
|
||||||
|
- **别把「不可判定」当成一个数**(2026-10-27):`id-check` 有两种口径——检查器自己算的**硬不可判定**实测只有
|
||||||
|
**2** 处,而门禁红线(`scripts/archive/id_gate/id_registry.json` 的 `undecidable_total_ceiling`)量的是**派生值**
|
||||||
|
`refs_total - decidable`(实测 8,744 - 7,582 = **1,162**,拆开 = `skip_bare_unmanaged` 834 +
|
||||||
|
`noise_allowed` 326 + 硬不可判定 2,**三数精确闭合**)。所以**带属主登记处的族(TC-XXX 这类)加多少引用都算
|
||||||
|
「可判定」、一点不吃这条余量**,真正吃余量的是**裸形字母+数字**字面量;反过来「把可判定引用改写成散文」才是
|
||||||
|
**稀释比值**的动作,那才是绝对数红线要防的。口径混用会当场给整条道下错指令(本次就下错过、量完立刻撤回)。
|
||||||
|
- **队友的自查结论要复核,小心它测的是错变量**(2026-10-27):log 道做过受控实验、结论是「我们没推高这个数」——
|
||||||
|
他们改的是 TC 号、盯的是 `skip_bare_unmanaged`,那个组合当然不动;而 +16 走的是另一条路(新增裸形 「字母 T 累进数字 4」)。
|
||||||
|
定责用 D 节:逐文件注入 + HEAD/在飞两版同一计数器相减 + 对新增行 grep;闭合后 +16 = 16 处裸形 「字母 T 累进数字 4」,
|
||||||
|
**推翻**了自查结论。口头自查与 `--note` 都不是证据,**可复核的差分数字才是**。
|
||||||
|
- **`.workbuddy-ai/` 刻意不共享**:记忆不随任务分叉,也不给联接递归删除的机会。
|
||||||
|
任务工作树自己的记忆由 `done` / `abort` 在清场前归并回主工作树。⚠ **手工回收(E 节)没有这一步归并**:
|
||||||
|
拆之前顺手 `ls <tree>/.workbuddy-ai/` 看一眼,有内容就一并备份进 `ci-artifacts/wt-leftover-backup/`。
|
||||||
|
- **`--lean` 只省磁盘不省时间**:实测全量 smudge 7.25 s / 83 MB,跳过 smudge 7.05 s / 45 MB,
|
||||||
|
两者工作区都干净。默认走全量(参考图可用),磁盘紧张时才用 `--lean`。
|
||||||
|
- **锁不绑定进程存活**:`lock acquire` 本身是短命进程,而真正用资源的烧录/构建在别的进程里跑。
|
||||||
|
所以判「陈旧」只看**时效**(该锁的 TTL)加显式 `--force`;pid 仅作排障记录。
|
||||||
|
同任务重复 acquire 是幂等的(不会把自己锁死)。
|
||||||
|
- **TTL 按锁分设**(2026-10-12):`build` = **1h**、`bench` = **6h**(`LOCK_TTL_HOURS`)。
|
||||||
|
口径是「兜底窗口应与操作真实时长同量级」——编译是秒~分钟级,6h 的兜底太钝;
|
||||||
|
bench 是会话级(flash→`/serial-boot-test`→探针),才配得上小时级。持有者还可用
|
||||||
|
`lock acquire ... --ttl <小时>` **自报**更精确的窗口(`keil_build.py` 即按其 `--timeout` 自报)。
|
||||||
|
- **陈旧锁自动接管**:超过该锁 TTL 未释放即视为陈旧,下次 `acquire` 会接管并提示;
|
||||||
|
未到 TTL 的活锁需 `--force`。`done` 的收口前置也会**自动释放陈旧锁**(不再硬失败),但
|
||||||
|
**未到 TTL 的活锁会把 `done` 挡在前置检查**(B 节第 8 步)。
|
||||||
|
- **`build` 锁不必手工持**(2026-10-12 起):`/keil-build`(`keil_build.py`)**自持自释**——
|
||||||
|
进入 acquire、`finally` 无条件 release,所以"Agent 忘了 release、锁一直挂到 TTL"这条在构建路径上已消除。
|
||||||
|
`done` 的门禁自己持锁并给子进程设 `DTU_LOCK_HELD=build` 告知跳过(新旧脚本都兼容)。
|
||||||
|
**`bench` 锁仍须 Agent 手工持**(会话作用域:跨烧录→`/serial-boot-test`→探针,见 `keil-build` SKILL.md)。
|
||||||
|
- **⚠ `done --dry-run` 只保护预演段、不保护清场段**(2026-10-29 实测):`done <ID> --dry-run --skip-build --skip-gates` 在「分支已合入主干」这条路上**照样执行清场**——摘 `.venv` / `simulator/SDL2` 联接、删分支、清台账,然后打「完成(分支此前已合入)」。想真·零改动就只跑不带 `--skip-gates` 的 `--dry-run` 并停在预演输出;用完一律 `git worktree list` 复验(判完成以 `git log` 为准,不是以有没有再敲一次 `done` 为准)。另:`--dry-run` 的前置检查同样要抢 `build` 锁——被别的任务占着时它会直接报「独占资源 build 仍被 X 占用,先 release」,此时加 `--skip-build` 或先 release,**别为了预演去抢别人的锁**。
|
||||||
|
- **⚠ 文件工具「先读后改」按解析后的绝对路径记账,切树后要重新读**(2026-10-30 实测,屏板链路任务树):开新树后先用**相对路径**读过一批文件(读历史记到当时解析到的主树文件上),再进任务树直接 `edit` → 6 条并行编辑全被 `file has not been read — read the file, then retry` 拒。判据:**树内改动一律用树内绝对路径,先 read 再改**——读历史不跨路径复用,工具不做「同名相对路径」宽容。
|
||||||
|
- **⚠ 中文门禁输出回显乱码 ≠ 路径坏**(2026-10-30 实测):对含中文文件名的文档跑 `check_doc_links.py`,终端回显整段乱码,但 **rc 与判定计数全部正确**。只认 `[SUMMARY]` 行与退出码,别误诊成「文档丢失」;脚本化批量调用走 python subprocess + 显式 `encoding`,别经命令行中转中文名。
|
||||||
@@ -0,0 +1,203 @@
|
|||||||
|
### E. 回收**非登记**工作树(`$TW list` 为空 / detached HEAD / 树里有在飞或未跟踪内容)
|
||||||
|
|
||||||
|
**这类树的判定特征**(命中任意一条就适用本节):`git worktree list` 里有它、但 `_wt/.state/<ID>.json` 台账不存在;
|
||||||
|
HEAD 是 detached(`git rev-parse --abbrev-ref HEAD` 打 `HEAD`);或树里的改动不属于本会话(别的会话留下的未提交 /
|
||||||
|
未跟踪文件)。典型来源:D 节的 `--detach` 净室、`new` 之后被手工删目录只剩台账的残骸、别人手工 `git worktree add` 建的分析树
|
||||||
|
(2026-10-28 实测一次回收 3 棵:`verify-task16` / `verify-batch` / `und-attr`,各 89 MB,全部无台账 + detached)。
|
||||||
|
|
||||||
|
**为什么 `done` / `abort` 在这里整体不适用**(铁律 9):两条子命令的前置都是 `load_state()`,无台账 →
|
||||||
|
`前置/环境错误:任务 <ID> 不存在(缺台账 …json)`(exit 2)当场停住、什么也不会发生;而**假如有**一份残留台账,
|
||||||
|
`abort --force` 会拿台账的 `path` 直接 `git worktree remove` —— **绕过下面第 1~4 步的全部内容保全**,把别的会话的唯一版本一次删光;
|
||||||
|
`done` 则会去找那条(多半已不存在的)`task/` 分支做合并。另:本路径没有「合并」这一步,所以 `done` 的并集合并残留审计
|
||||||
|
在这里不发生,也没有三方对比可依据 —— **唯一性只由第 2 步的内容分桶给出**。回收只能手工走本节第 1~5 步 + 第 6 步清台账。
|
||||||
|
|
||||||
|
1. **建全景(全程只读)**:先看 `git worktree list` 的全集,再**逐棵**记三件事(脏行数含未跟踪 / 树顶提交 / 有无分支)
|
||||||
|
加一条台账判定,同时存下主树的拆前基线清单:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MAIN="D:/Code/DTU Code/STM32F4-Base"; WTROOT="$MAIN/../_wt"
|
||||||
|
git -c core.quotePath=false worktree list # 全集:登记树 + 非登记树都在这份里
|
||||||
|
MAIN_SHA=$(git -C "$MAIN" rev-parse --short HEAD) # 下面所有比对都以这一版为主线尺子
|
||||||
|
mkdir -p ci-artifacts
|
||||||
|
git -C "$MAIN" status --porcelain -uall > ci-artifacts/wt-main-before.txt # 拆前主树基线(第 5 步逐行比)
|
||||||
|
wc -l < ci-artifacts/wt-main-before.txt # 记下行数(实测 0)
|
||||||
|
for t in "$WTROOT"/*/; do
|
||||||
|
echo "--- $t"
|
||||||
|
git -c core.quotePath=false -C "$t" status --porcelain -uall | wc -l # 脏 + 未跟踪行数
|
||||||
|
git -c core.quotePath=false -C "$t" log --oneline -1 # 树顶提交
|
||||||
|
git -C "$t" rev-parse --abbrev-ref HEAD # 打 HEAD = detached,压根没分支可 done
|
||||||
|
ls "$WTROOT/.state/$(basename "$t").json" 2>/dev/null || echo "NO-LEDGER" # 台账位置随 DTU_WT_ROOT
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`-uall` 不是可选**:默认 `status --porcelain` 会把未跟踪目录折叠成一行 `dir/`,`-uall` 才展开到文件级;少了它
|
||||||
|
第 2 步的分桶就漏项(实测 `und-attr` 唯一那份就是未跟踪 `?? scripts/check_nested_comment.py`)。
|
||||||
|
- 路径含空格(本项目 `DTU Code`)→ 变量整体加引号;`core.quotePath=false` 让中文路径原样输出,便于逐行核对。
|
||||||
|
- 判定「是不是登记树」看两样:**台账在不在 + HEAD 是否 detached**。两者都指「非登记」→ 从这里开始**禁止**
|
||||||
|
`done` / `abort`(见本节开头),继续第 2 步。
|
||||||
|
2. **逐文件与主线 HEAD 比内容并分三桶**:`IN-MAINLINE`(内容等价,拆 = 零损失)/ `UNIQUE-UNMERGED`(主线没有这份
|
||||||
|
内容,拆 = 丢)/ `OLDER-DRAFT`(同路径主线已有更新版本)。落成独立只读探针跑(别用 heredoc 现写现改,
|
||||||
|
见陷阱「别用 heredoc 打补丁造探针」):
|
||||||
|
|
||||||
|
```python
|
||||||
|
# ci-artifacts/wt_bucket_scan.py — 只读:逐文件与主线工作区/HEAD 比对,三桶归类
|
||||||
|
import os, subprocess, tempfile
|
||||||
|
MAIN = r"D:\Code\DTU Code\STM32F4-Base"
|
||||||
|
WTROOT = os.path.join(os.path.dirname(MAIN), "_wt")
|
||||||
|
TREES = ["verify-task16", "verify-batch", "und-attr"] # ← 第 1 步全景里挑出的非登记树
|
||||||
|
SKIP_TOP = {".venv", "MDK-ARM", ".git", "ci-artifacts"} # 联接 / 构建产物 / 忽略区,按项目调
|
||||||
|
def git(*a):
|
||||||
|
return subprocess.run(("git", "-c", "core.quotePath=false", "-C", MAIN) + a,
|
||||||
|
capture_output=True, text=True, errors="replace")
|
||||||
|
def bucket(rel, tree):
|
||||||
|
p = os.path.join(tree, rel)
|
||||||
|
if not os.path.exists(p):
|
||||||
|
return "OTHER", "工作树里已不存在(多为已 staged 删除)"
|
||||||
|
tf = tempfile.NamedTemporaryFile(delete=False)
|
||||||
|
tf.write(open(p, "rb").read()); tf.close()
|
||||||
|
# 1) 与主线工作区(= 当前 HEAD 的检出内容)逐字节比
|
||||||
|
if git("diff", "--no-index", "--quiet", tf.name, rel).returncode == 0:
|
||||||
|
os.unlink(tf.name); return "IN-MAINLINE", "与主线 HEAD 逐字节相同"
|
||||||
|
# 2) 与主线历史各版比:内容等价于某个旧 commit → 只是主线又往前走了
|
||||||
|
hist = [x.strip() for x in git("log", "--format=%H", "HEAD", "--", rel).stdout.splitlines()]
|
||||||
|
for sha in hist[1:]:
|
||||||
|
old = git("show", f"{sha}:{rel}")
|
||||||
|
if old.returncode == 0 and old.stdout == open(p, encoding="utf-8", errors="replace").read():
|
||||||
|
os.unlink(tf.name); return "IN-MAINLINE", f"内容等价于主线历史版 {sha[:8]}"
|
||||||
|
# 3) 主线从没这条路径 → 唯一;否则同路径主线有更新版 → 更旧草稿(再用 blob diff 定性方向)
|
||||||
|
os.unlink(tf.name)
|
||||||
|
if not hist:
|
||||||
|
return "UNIQUE-UNMERGED", "主线 HEAD 与历史都没有此路径"
|
||||||
|
return "OLDER-DRAFT", "同路径主线有更新版本(用 diff --stat <HEAD> -- <f> 定性)"
|
||||||
|
for name in TREES:
|
||||||
|
tree = os.path.join(WTROOT, name)
|
||||||
|
lines = subprocess.run(("git", "-c", "core.quotePath=false", "-C", tree,
|
||||||
|
"status", "--porcelain", "-uall"),
|
||||||
|
capture_output=True, text=True, errors="replace").stdout.splitlines()
|
||||||
|
tally = {}
|
||||||
|
for line in lines:
|
||||||
|
rel = line[3:].split(" -> ")[-1].strip('"') # 兼容 ?? / M / A / R 的箭头形态
|
||||||
|
if rel.split("/")[0] in SKIP_TOP:
|
||||||
|
continue
|
||||||
|
b, why = bucket(rel, tree)
|
||||||
|
tally.setdefault(b, []).append((rel, why))
|
||||||
|
g = lambda k: len(tally.get(k, []))
|
||||||
|
print(f"== {name}: IN-MAINLINE={g('IN-MAINLINE')} UNIQUE-UNMERGED={g('UNIQUE-UNMERGED')} "
|
||||||
|
f"OLDER-DRAFT={g('OLDER-DRAFT')} OTHER={g('OTHER')}")
|
||||||
|
for b in ("UNIQUE-UNMERGED", "OLDER-DRAFT", "OTHER"):
|
||||||
|
for rel, why in tally.get(b, []):
|
||||||
|
print(f" {b}: {rel} # {why}")
|
||||||
|
```
|
||||||
|
|
||||||
|
报告里必须**逐桶给计数 + 判定证据**(实测:`verify-task16` IN-MAINLINE=6 / UNIQUE=0;`verify-batch` IN-MAINLINE=35 /
|
||||||
|
UNIQUE=1;`und-attr` IN-MAINLINE=0 / UNIQUE=1)。两个非空桶再各钉一条命令把证据坐实:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 「唯一未合并」是真更新还是更旧草稿?blob 级 diff + 逐行看方向
|
||||||
|
git -c core.quotePath=false -C "$MAIN" diff --stat "$MAIN_SHA" -- simulator/tests/README.md
|
||||||
|
diff <(git -C "$MAIN" show "$MAIN_SHA":simulator/tests/README.md) "$WTROOT/verify-batch/simulator/tests/README.md"
|
||||||
|
# 「树顶提交要不要保」只看一件事:是否已并入 HEAD。别拿「目录很久没动」当判据
|
||||||
|
for c in 77779d49 1d4b6a17; do
|
||||||
|
git -C "$MAIN" merge-base --is-ancestor "$c" HEAD && echo "$c MERGED" || echo "$c NOT-merged"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
实测:那份 `simulator/tests/README.md` 树内写「27 页 × 3200 B」、主线 HEAD 已是「60 张 × 3200 B」→ **更旧草稿**(被取代的
|
||||||
|
早先版本),不是未合并成果;`bench-21-77779d49` / `bench-23-1d4b6a17` 两棵 `merge-base --is-ancestor` 判 **MERGED** 且脏数 = 0
|
||||||
|
→ **保留不动**(不属本节,等属主自己收,报告里点名说清为什么留)。
|
||||||
|
**回收判据不是「看着旧」**:唯一内容如果没被提交过,`git log` 里根本看不见它;反过来「提交已 MERGED」也不等于可以拆——
|
||||||
|
还得看未跟踪那一层。`UNIQUE-UNMERGED` 非空又确实要留 → 它进第 4 步的备份,**不许靠 `--force` 顺手带走**。
|
||||||
|
3. **拆前扫联接 / reparse point(物理安全性 = 铁律 5 的可执行判据)**。存成 `ci-artifacts/wt_junction_scan.py` 再跑:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# 只读预扫:一个路径是不是 reparse point(Windows 目录联接在这里现形);发现即停止深入,绝不 walk 进联接
|
||||||
|
import os, ctypes
|
||||||
|
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
|
||||||
|
GA = ctypes.windll.kernel32.GetFileAttributesW
|
||||||
|
def is_reparse(p):
|
||||||
|
a = GA(p)
|
||||||
|
return a != -1 and bool(a & FILE_ATTRIBUTE_REPARSE_POINT)
|
||||||
|
for name in ["verify-task16", "verify-batch", "und-attr", "bench-21-77779d49", "bench-23-1d4b6a17"]:
|
||||||
|
top = os.path.join(r"D:\Code\DTU Code\_wt", name)
|
||||||
|
hits, entries = [], 0
|
||||||
|
for root, ds, fs in os.walk(top):
|
||||||
|
bad = [d for d in ds if is_reparse(os.path.join(root, d))]
|
||||||
|
if bad:
|
||||||
|
hits += [os.path.join(root, d) + "/" for d in bad]
|
||||||
|
ds[:] = [] # 有联接就不再深入:防转进主树 .venv 把别人整棵树数一遍
|
||||||
|
hits += [os.path.join(root, f) for f in fs if is_reparse(os.path.join(root, f))]
|
||||||
|
entries += len(ds) + len(fs)
|
||||||
|
print(f"== {name} scanned entries={entries}")
|
||||||
|
print(" REPARSE: none → 可直接 git worktree remove --force"
|
||||||
|
if not hits else " REPARSE:\n " + "
|
||||||
|
".join(hits))
|
||||||
|
```
|
||||||
|
|
||||||
|
实测输出 `== verify-task16 ... REPARSE: none`(每棵的体积 / 条目数 + 零联接就是证据),而 `bench-*` 两棵各挂一个 `.venv`
|
||||||
|
联接 → 判 **KEEP**(本节不拆它们;将来真要拆,先 `rmdir` 摘掉联接再 remove)。
|
||||||
|
**判据是「扫出来一个都没有」,不是「我记得没建过联接」**:`new --no-bootstrap` 的树、别的手工 `mklink /J` 的树都可能带着指向主树的联接。
|
||||||
|
摘联接用 `rmdir`(只删联接本身;**不是** `rd /s`,更不是 `rm -rf`——那会顺着联接删目标)。
|
||||||
|
非 Windows 环境改用 `os.path.islink` / `find <tree> -type l`(本项目是 Windows + Keil 工具链,按上面这条走)。
|
||||||
|
**到这里才算「拆前唯一性自检 + 物理安全预扫」齐**:`--force` 的安全前提 = 第 2 步 `UNIQUE-UNMERGED` 为 0
|
||||||
|
(或剩下的已全部进第 4 步备份)**且**这里 `REPARSE: none`。
|
||||||
|
4. **唯一内容先备份,再 `--force` 拆**。`UNIQUE-UNMERGED` 与需要留档的 `OLDER-DRAFT` 一律先备份进主树
|
||||||
|
`ci-artifacts/wt-leftover-backup/`(`ci-artifacts/` 已被 `.gitignore` 忽略 → **备份不会改变主树脏行数**;仓库根不放一次性产物),
|
||||||
|
**文件名 + 字节数必须打进输出**当证据:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
BK="$MAIN/ci-artifacts/wt-leftover-backup"; mkdir -p "$BK"
|
||||||
|
# 命名约定:<树名>__<原路径里的 / 换成 _>,反向可定位;台账备份统一后缀 .state-bak
|
||||||
|
cp "$WTROOT/verify-batch/simulator/tests/README.md" "$BK/verify-batch__simulator_tests_README.md"
|
||||||
|
cp "$WTROOT/und-attr/scripts/check_nested_comment.py" "$BK/und-attr__scripts_check_nested_comment.py"
|
||||||
|
ls -l "$BK" # 实测:verify-batch__simulator_tests_README.md 1769 bytes / und-attr__scripts_check_nested_comment.py 14827 bytes
|
||||||
|
for t in verify-task16 verify-batch und-attr; do
|
||||||
|
git -C "$MAIN" worktree remove --force "$WTROOT/$t"; echo "$t remove_rc=$?" # 不接管道才读得到码(见「读输出」)
|
||||||
|
done
|
||||||
|
git -C "$MAIN" worktree prune # 顺带清 .git/worktrees 里的残籍
|
||||||
|
```
|
||||||
|
|
||||||
|
自检:**备份条目数 = 第 2 步里判「要留档」的条目数**,且每个都带字节数。这一步就是本节相对 D 第 4 步的增量——
|
||||||
|
D 的前提是「净室里只有自己的拷贝」,本节的树里混着别人在飞的东西,`--force` 是一次不可逆删除,**先备份才谈得上安全**。
|
||||||
|
5. **复验:主树一个字节没动 + 树确实没了**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C "$MAIN" status --porcelain -uall > ci-artifacts/wt-main-after.txt
|
||||||
|
wc -l < ci-artifacts/wt-main-before.txt; wc -l < ci-artifacts/wt-main-after.txt # 两个数必须相同(实测 0 → 0)
|
||||||
|
diff ci-artifacts/wt-main-before.txt ci-artifacts/wt-main-after.txt # 空输出 = 逐行一致
|
||||||
|
git -c core.quotePath=false -C "$MAIN" worktree list # 已拆路径逐条消失;主树与判「保留」的树原样在列
|
||||||
|
```
|
||||||
|
|
||||||
|
行数不同 = 备份或摘除碰到了主线(多半是备份写进了未被忽略的目录,或漏摘联接)→ 立刻定位,别报「回收完成」。
|
||||||
|
第 2 步判「保留不动」的树还在 `worktree list` 里是**预期结果**,在报告里点名解释为什么留(附 `MERGED` 判据)。
|
||||||
|
6. **顺手清悬挂台账**(branch / path 都已不存在的 `_wt/.state/<ID>.json`;它会让 `$TW list` 打出一行怪条目、并让下次
|
||||||
|
`new` 同 ID 报「任务 <ID> 已存在」)。先给证据、后动手:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
for j in "$WTROOT"/.state/*.json; do # 台账位置随 DTU_WT_ROOT
|
||||||
|
id=$(basename "$j" .json)
|
||||||
|
br=$("$PY" -c "import json,sys;print(json.load(open(sys.argv[1],encoding='utf-8')).get('branch',''))" "$j")
|
||||||
|
pp=$("$PY" -c "import json,sys;print(json.load(open(sys.argv[1],encoding='utf-8')).get('path',''))" "$j")
|
||||||
|
git -C "$MAIN" rev-parse --verify "$br" >/dev/null 2>&1 && b=branch-OK || b=branch-MISSING
|
||||||
|
[ -d "$pp" ] && p=path-OK || p=path-MISSING
|
||||||
|
echo "$id $b $p <- $j"
|
||||||
|
done
|
||||||
|
```
|
||||||
|
|
||||||
|
实测这条判据打出的正是 `v5tool2 branch-MISSING path-MISSING`:`git rev-parse task/v5tool2-v11` 报
|
||||||
|
`fatal: ambiguous argument 'task/v5tool2-v11': unknown revision ...`,台账 `path` 指的目录已无,`git worktree list` 里也没有这棵树。
|
||||||
|
**三条齐(rev-parse fatal + path 目录不存在 + worktree list 无此树)才叫悬挂**;缺任何一条都回到第 1~2 步查是不是真有东西要保。
|
||||||
|
然后**备份 → 删 → 复跑两条 list 复验**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp "$WTROOT/.state/v5tool2.json" "$BK/v5tool2.json.state-bak" && rm "$WTROOT/.state/v5tool2.json"
|
||||||
|
"$PY" "$TW" list # 该条目消失(全空时打「(没有任务空间。用 new <ID> <slug> 建一个)」),echo RC=$? 应为 0
|
||||||
|
"$PY" "$TW" lock status # build 空闲(TTL 1.0h)/ bench 空闲(TTL 6.0h)—— 证明清台账没牵连两把锁
|
||||||
|
ls "$WTROOT"/.state/locks/ 2>/dev/null # 若该 ID 名下还有 .lock,先按 B 节释放,再删台账
|
||||||
|
```
|
||||||
|
|
||||||
|
- **必须 `cp` 先备份再 `rm`**:台账里存着 base / junctions / needs_bench / bench_pass,是真出纠纷时唯一的现场记录。
|
||||||
|
- 悬挂条目按定义 `path` 已不存在 → 通常**不占** `MAX_CONCURRENT = 8` 名额(A 第 1 步的「活跃」判据要求 path 目录还在),
|
||||||
|
但它把 `$TW list` 搅成怪输出、并永久挡住同 ID 复用;**path 还在的占位/残留台账才占名额**(早期为绕 B 节那个坑手写的占位台账就是这种)。两种都要清。
|
||||||
|
- **仍不对这个 ID 跑 `done` / `abort`**:清场会拿台账里的死 `path` / 死 `branch` 去做 `git worktree remove` 和删分支
|
||||||
|
(这正是 A 第 5 步说的「手工删目录留下的尾巴」,正解就是本步)。历史遗留的占位台账同理——活着时也别
|
||||||
|
`done` / `abort`,用完直接删文件(来历与现状见 B 末条)。
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
## 验证
|
||||||
|
|
||||||
|
- `$TW list`:台账段条目数 = `ls _wt` 的目录数且 ≤ 8(`MAX_CONCURRENT`);刚回收的任务与历史占位条目都不再出现
|
||||||
|
(插件树另起一段,不参与这条计数)。
|
||||||
|
- `$TW lock status`:`build 空闲(TTL 1.0h)` + `bench 空闲(TTL 6.0h)`——没有本会话留下的持有者。
|
||||||
|
- `git log --oneline -3`:能看到 `merge: task/<ID>-<slug> 合入 <base>` 与其后的补基线提交;
|
||||||
|
`git status --porcelain` 为空(补链改动已提交,才谈得上重跑 `done`)。
|
||||||
|
- `ls _wt/.state/`:该 ID 的台账已无(清场最后一步 unlink);重复 `done` 报「任务不存在」不必再追。
|
||||||
|
- **E 节回收**:`git worktree list` 只剩主树 + 判「保留不动」的树(已拆路径逐条消失);**拆前/拆后主树
|
||||||
|
`git status --porcelain -uall` 行数相同且 `diff` 为空**(实测 0 → 0);`ci-artifacts/wt-leftover-backup/` 里每个留档件都打印过
|
||||||
|
文件名与字节数、条目数 = 分桶判「要留档」的条目数;每棵被拆的树都留有 `REPARSE: none` 预扫记录;全景输出里
|
||||||
|
`UNIQUE-UNMERGED` 归零(或全部已备份);保留树在报告里点名给出判据(`merge-base --is-ancestor` = MERGED)。
|
||||||
|
- **E 第 6 步清悬挂台账**:该 json 已从 `_wt/.state/` 消失(`.state-bak` 在备份目录里)、`$TW list` 不再打出它、
|
||||||
|
`$TW lock status` 两把锁仍空闲;全程未对该 ID 跑 `done` / `abort`。
|
||||||
|
- **F 节桥接**:`plugin list` 的「联接」列按实际三态点名(`已联` / `缺 .venv` / `缺 SDL2` /
|
||||||
|
`齐(实体SDL2)`——旧实现只取样 `.venv`,半联树会说谎);**adopt/unadopt 前后主树 `.venv` 与树内文件都
|
||||||
|
还在**;主线缺 SDL2 时 `adopt` 打 `[skip]` + 「还不能算就绪」而不打「现在可以跑」;`plugin adopt ".."`
|
||||||
|
必须 exit 2;打印的 `pw:<name>` 与 `keil_build.py` 从 cwd 反推的名字逐字相同;`done <插件树名>` 只 exit 2。
|
||||||
|
- **C2 主树被别的会话弄脏时的手工合并**:合并前打印过 `git merge-tree --write-tree` 的结果树 sha 且 `MT_RC=0`;
|
||||||
|
合并用 `git merge --no-ff --no-edit -F ci-artifacts/merge-msg-<slug>.txt`(UTF-8 无 BOM)并写明绕开 `done` 的理由;
|
||||||
|
外部文件合并前后的 `git hash-object` **逐字符相同**、`git diff --stat` 同一组数字、`status` 里仍是 `M`
|
||||||
|
(没被提交也没被清掉;实测同为 `369380ca…`、2+/2−);台账先 `cp` 成 `<ID>.json.state-bak` 再由 `$TW done`
|
||||||
|
(或 E 节手工路径)摘联接拆树;收工前 `git worktree list` 只剩主树 + 别人的在飞树(点名保留)、
|
||||||
|
`$TW lock status` 两把锁均空闲(`build 空闲(TTL 1.0h)` / `bench 空闲(TTL 6.0h)`)。
|
||||||
|
- **G 节树内门禁**:`<tree>/ci-artifacts/` 先 `mkdir -p` 再写、事后 `ls` 得到真实文件(`cat` 读得到内容);
|
||||||
|
树内 `check_doc_links.py` 按**单文件**跑并打印「N 处 / 机器可判定百分比 / rc」(实测 161 处/98%/rc=0、16 处/88%/rc=0);
|
||||||
|
全库 `--all` 只在合并后的**主树**跑(实测 2,597 处 / 194 个文档 / rc=0);若树内跑过 `--all` 且判红,报告里
|
||||||
|
必须给得出「过滤 `simulator/SDL2` 后与主树同命令基线对比」这一步。
|
||||||
|
- D 节归因:测量前后 `git -C "$MAIN" status --porcelain` **逐行一致**(主树全程只读);
|
||||||
|
`git worktree list` 里净室路径已消失;逐文件增量之和 **等于**观察到的涨幅(实测 +6+8+2 = +16),且每处增量
|
||||||
|
都指到具体字面量类别(裸形 「字母 T 累进数字 4」)与属主;取数走 `import check_ids` 读 `ck.stats`,且门禁脚本自身 `git diff` 为空。
|
||||||
|
- 补绿链的每一条都自己 `echo RC=$?` 看到 0,最后以重跑 `done` 打出的**清场日志**(`已摘除联接` /
|
||||||
|
`已删除分支` / `完成(分支此前已合入)`)为成功判据,而不是 `tail` 出来的最后一行。
|
||||||
|
- **树内提交 / 合并信息(G 节)**:`git log -1 --pretty=%B | grep -c '<被清退主题关键词>'` 打出 **0**(标题 + 正文 +
|
||||||
|
合并信息三处都查过);消息文件在 `ci-artifacts/`、UTF-8 无 BOM、CR=0、标题 ≤100、正文最长行 ≤80(实测 31/50、32/55);
|
||||||
|
自审发现消息统计算错是在**未推送**状态下 `--amend -F` 修掉的,历史里没有「更正」提交。
|
||||||
|
- **文档改动体检(G 节)**:提交前打印过 `lines/bytes/BOM/CR/U+FFFD/三连续空行/行尾空格/末行 LF` 与表格列数一致性,
|
||||||
|
并区分「本次改动引入 = 0」与「文件里既有的坏行」(实测两处历史坏表格与本次无关,只在报告里点名)。
|
||||||
|
- **F 节分级与自持理由**:判 `simulator/SDL2` 为硬需求要给出 `simulator/CMakeLists.txt:12`(非 CACHE)+ 派生路径行号
|
||||||
|
(:34/:70/:71/:180/:207);判 `.venv` 为软需求要给出 `DTU_PRECOMMIT_PYTHON` 与 `ci_local.py` 五级解释器候选顺序;
|
||||||
|
答「为什么不用插件」时报出插件包版本、文件与三个概念的 grep 命中数,并说明重叠只在 `git worktree add` 的开/收两个动作。
|
||||||
|
- **改并行上限数字**:唯一事实源 = `task_worktree.py` 的 `MAX_CONCURRENT`(报错文案与 `new` 提示都 `.format()` 插值它,
|
||||||
|
实测 `:764-766` / `:768-771`),其余全是引用同步;隔离小仓验证到「8 棵成功、第 9 棵不带 `--force` 被拒」,
|
||||||
|
且没在真仓 `../_wt` 及其 `.state/` 留下验证树。
|
||||||
@@ -27,12 +27,12 @@ for _stream in (sys.stdout, sys.stderr):
|
|||||||
EXIT_OK, EXIT_FAIL, EXIT_ENV, EXIT_USAGE = 0, 1, 2, 3
|
EXIT_OK, EXIT_FAIL, EXIT_ENV, EXIT_USAGE = 0, 1, 2, 3
|
||||||
|
|
||||||
BRANCH_PREFIX = "task/"
|
BRANCH_PREFIX = "task/"
|
||||||
MAX_CONCURRENT = 3
|
MAX_CONCURRENT = 8
|
||||||
LOCK_NAMES = ("build", "bench")
|
LOCK_NAMES = ("build", "bench")
|
||||||
# 按锁分设陈旧窗口(小时)——兜底窗口应与「操作真实时长」同量级:
|
# 按锁分设陈旧窗口(小时)——兜底窗口应与「操作真实时长」同量级:
|
||||||
# build:编译是秒~分钟级(keil_build.py 自持自释,持有窗口 ≈ UV4 运行时长,
|
# build:编译是秒~分钟级(keil_build.py 自持自释,持有窗口 ≈ UV4 运行时长,
|
||||||
# 上限 = --timeout,默认 600s);崩溃泄漏时应在数十分钟内即可接管 → 1h。
|
# 上限 = --timeout,默认 600s);崩溃泄漏时应在数十分钟内即可接管 → 1h。
|
||||||
# bench:会话级(flash → @test → 探针,天然几十分钟~数小时)→ 保留 6h。
|
# bench:会话级(flash → /serial-boot-test → 探针,天然几十分钟~数小时)→ 保留 6h。
|
||||||
# 另:锁 payload 里持有者可用 `ttl` 字段自报窗口(见 lock_acquire(ttl=...)),
|
# 另:锁 payload 里持有者可用 `ttl` 字段自报窗口(见 lock_acquire(ttl=...)),
|
||||||
# 优先于这里的默认值——这样 build 锁的窗口能自动跟随其 --timeout。
|
# 优先于这里的默认值——这样 build 锁的窗口能自动跟随其 --timeout。
|
||||||
LOCK_TTL_HOURS = {"build": 1.0, "bench": 6.0}
|
LOCK_TTL_HOURS = {"build": 1.0, "bench": 6.0}
|
||||||
@@ -82,6 +82,15 @@ BASE_BRANCH = os.environ.get("DTU_TASK_BASE", "v2-on-ui")
|
|||||||
STATE_DIR = WT_ROOT / ".state"
|
STATE_DIR = WT_ROOT / ".state"
|
||||||
LOCKS_DIR = STATE_DIR / "locks"
|
LOCKS_DIR = STATE_DIR / "locks"
|
||||||
|
|
||||||
|
# DSH 的 worktree 插件(包名 dsh-task-worktree;模型工具 worktree_create、人类命令
|
||||||
|
# /worktree)把任务树建在**仓库内**这个命名空间下,与本 skill 的仓外 _wt/ 刻意分开。
|
||||||
|
# .gitignore 里预置同名规则,正是为了让插件的 ensureIgnored() 见到就跳过、
|
||||||
|
# 不在会话中途往这份受控文件里追加一行。
|
||||||
|
PLUGIN_ROOT = REPO_ROOT / ".dsh-worktrees"
|
||||||
|
PLUGIN_NAMESPACE = "worktree" # 插件侧 WORKTREE_NAMESPACE:name 即分支尾段
|
||||||
|
PLUGIN_SUBDIR = PLUGIN_ROOT / PLUGIN_NAMESPACE
|
||||||
|
PLUGIN_LOCK_PREFIX = "pw" # 插件树在锁 payload 里的持有者前缀
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- 基础工具
|
# --------------------------------------------------------------------- 基础工具
|
||||||
|
|
||||||
@@ -390,33 +399,232 @@ def bootstrap(wt: Path):
|
|||||||
return made
|
return made
|
||||||
|
|
||||||
|
|
||||||
|
# --------------------------------------------------------------------- DSH 插件工作树
|
||||||
|
#
|
||||||
|
# 插件建的树(.dsh-worktrees/worktree/<name>)缺三样东西,下面的命令逐条补。
|
||||||
|
# 回收**不在这里**:插件树由「/worktree remove|prune」收,done/abort 也不该拿去处理它。
|
||||||
|
# 1. 共享联接——没有 .venv 与 simulator 的 SDL2 SDK,插件树里「.venv\Scripts\python.exe」
|
||||||
|
# 直接找不到解释器:/keil-build、ci_local.py、所有 scripts/check_*.py 全部起跑失败;
|
||||||
|
# 2. 可见性——插件树不在台账里,「list」看不见它,8 个活跃名额也只数登记任务;
|
||||||
|
# 3. 独占资源归属——build 锁由 keil_build.py 自持自释(按 cwd 反推持有者名,
|
||||||
|
# 插件树里算成 pw:<name>),bench 锁仍要人占,占锁名得和「lock status」对得上。
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_lock_owner(name: str) -> str:
|
||||||
|
"""插件树在锁 payload 里的持有者名:pw:<name>(多段名压平成 -)。
|
||||||
|
|
||||||
|
必须与 keil_build.py 的 default_lock_task() 对同一种树算出的名字一致——这样构建
|
||||||
|
自持自释留下的归属,和手工「lock acquire bench --task pw:x」才是同一个可 grep 的标识。
|
||||||
|
"""
|
||||||
|
# 与 plugin_dir_for 同样容忍「worktree/」前缀,否则同一棵树会出现两个持有者名
|
||||||
|
# (pw:refactor-logging vs pw:worktree-refactor-logging),lock status 就散了。
|
||||||
|
segs = [s for s in name.replace(os.sep, "/").strip("/").split("/") if s]
|
||||||
|
if segs and segs[0] == PLUGIN_NAMESPACE:
|
||||||
|
segs = segs[1:]
|
||||||
|
return "{}:{}".format(PLUGIN_LOCK_PREFIX, "-".join(segs) or "?")
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_dir_for(name: str) -> Path:
|
||||||
|
"""插件 worktree 名 → 树目录;**只允许落在 .dsh-worktrees/worktree/ 里面**。
|
||||||
|
|
||||||
|
adopt/unadopt 会在解析出的目录上建/摘目录联接;不加这道闸,等于允许把主工作树或
|
||||||
|
任意 _wt 目录当插件树动手(联接会遮蔽真实内容,事后很难看出来)。
|
||||||
|
"""
|
||||||
|
raw = (name or "").strip().replace(os.sep, "/").strip("/")
|
||||||
|
if not raw:
|
||||||
|
raise EnvError("缺插件 worktree 名(先跑「plugin list」看现有树)")
|
||||||
|
if raw == PLUGIN_NAMESPACE or raw.startswith(PLUGIN_NAMESPACE + "/"):
|
||||||
|
raw = raw[len(PLUGIN_NAMESPACE):].strip("/")
|
||||||
|
p = (PLUGIN_SUBDIR / raw).resolve()
|
||||||
|
base = PLUGIN_SUBDIR.resolve()
|
||||||
|
if p == base or base not in p.parents:
|
||||||
|
raise EnvError("拒绝 {}:只接受 {} 里面的树".format(p, PLUGIN_SUBDIR))
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def junction_states(wt: Path):
|
||||||
|
"""每个共享联接的三态:link(目录联接)/ real(实体内容)/ absent(真缺)。
|
||||||
|
|
||||||
|
这是「联没联上」的**唯一**判定处——列表显示与 adopt 收尾都读它。早先列表只取样
|
||||||
|
JUNCTION_SPECS 的第一项,于是「有 .venv、缺 simulator/SDL2」的半联树被报成已联,
|
||||||
|
而 adopt 可以只成功一条(主线缺 SDL2 走 skip、联接失败走 warn)→ 说谎的正是这一列。
|
||||||
|
"""
|
||||||
|
st = {}
|
||||||
|
for rel, _desc in JUNCTION_SPECS:
|
||||||
|
link = wt / rel
|
||||||
|
if is_reparse(link):
|
||||||
|
st[rel] = "link"
|
||||||
|
elif os.path.lexists(str(link)):
|
||||||
|
st[rel] = "real"
|
||||||
|
else:
|
||||||
|
st[rel] = "absent"
|
||||||
|
return st
|
||||||
|
|
||||||
|
|
||||||
|
def junction_label(st) -> str:
|
||||||
|
"""把三态压成一列短标签:缺什么都点名,实体内容也算「齐」但不算共享。"""
|
||||||
|
tag = {rel: rel.rsplit("/", 1)[-1] for rel in st}
|
||||||
|
absent = [tag[r] for r, s in st.items() if s == "absent"]
|
||||||
|
real = [tag[r] for r, s in st.items() if s == "real"]
|
||||||
|
if absent:
|
||||||
|
return "缺 " + "+".join(absent)
|
||||||
|
return "齐(实体" + "+".join(real) + ")" if real else "已联"
|
||||||
|
|
||||||
|
|
||||||
|
def plugin_trees():
|
||||||
|
""".dsh-worktrees/worktree/ 下的工作树(事实源 = git worktree list,不读插件 manifest)。
|
||||||
|
|
||||||
|
manifest.json 是插件私有格式(MANIFEST_VERSION 1,字段随版本变),而 git 的注册表
|
||||||
|
同时覆盖「插件正常建的树」和「manifest 丢了 / 树被手工删」的残留——桥接命令要看的
|
||||||
|
是真实存在的东西。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
base = PLUGIN_SUBDIR.resolve()
|
||||||
|
except OSError:
|
||||||
|
return []
|
||||||
|
trees = []
|
||||||
|
for ent in worktree_entries():
|
||||||
|
p = ent["path"]
|
||||||
|
try:
|
||||||
|
rp = p.resolve()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
if rp == base or base not in rp.parents:
|
||||||
|
continue
|
||||||
|
dirty = (len(git_out("status", "--porcelain", cwd=p, check=False).splitlines())
|
||||||
|
if p.is_dir() else -1)
|
||||||
|
trees.append({
|
||||||
|
"name": rp.relative_to(base).as_posix(),
|
||||||
|
"path": str(p),
|
||||||
|
"branch": ent.get("branch") or "(detached)",
|
||||||
|
"dirty": dirty,
|
||||||
|
"jn": junction_states(p),
|
||||||
|
})
|
||||||
|
return trees
|
||||||
|
|
||||||
|
|
||||||
|
def print_plugin_trees() -> int:
|
||||||
|
"""打印插件树一览(无树则静默),返回数量——「list」与「new」共用同一事实。"""
|
||||||
|
trees = plugin_trees()
|
||||||
|
if not trees:
|
||||||
|
return 0
|
||||||
|
print()
|
||||||
|
print("DSH 插件工作树({} 下;**不在本 skill 台账** → 不占 {} 个名额、done/abort "
|
||||||
|
"管不到,回收走 /worktree remove|prune):".format(PLUGIN_SUBDIR, MAX_CONCURRENT))
|
||||||
|
print("{:<26} {:<34} {:>4} {:>16}".format("名称", "分支", "脏", "联接"))
|
||||||
|
for t in trees:
|
||||||
|
print("{:<26} {:<34} {:>4} {:>16}".format(
|
||||||
|
t["name"], t["branch"], "-" if t["dirty"] < 0 else str(t["dirty"]),
|
||||||
|
junction_label(t["jn"])))
|
||||||
|
def miss(rel):
|
||||||
|
return any(t["jn"].get(rel) == "absent" for t in trees)
|
||||||
|
if miss(".venv"):
|
||||||
|
print(" 缺 .venv:进去跑 /keil-build / ci_local.py 会**静默**落到机器级解释器(无 "
|
||||||
|
"fontTools,generated-fresh 假红的老根因)")
|
||||||
|
if miss("simulator/SDL2"):
|
||||||
|
print(" 缺 simulator/SDL2:模拟器 build_sim.bat 直接报错——CMakeLists.txt 的 "
|
||||||
|
"SDL2_DIR 是裸 set,没有环境变量逃生口")
|
||||||
|
if miss(".venv") or miss("simulator/SDL2"):
|
||||||
|
print(" 补法:「plugin adopt <name>」——只建缺失的联接,不碰树里任何文件")
|
||||||
|
return len(trees)
|
||||||
|
|
||||||
|
|
||||||
|
def adopt_junctions(wt: Path):
|
||||||
|
"""只补**缺失**的共享联接(已联的跳过、实体目录绝不碰),返回 (已有, 本次新建)。"""
|
||||||
|
already, made = [], []
|
||||||
|
st = junction_states(wt)
|
||||||
|
for rel, desc in JUNCTION_SPECS:
|
||||||
|
link = wt / rel
|
||||||
|
if st[rel] == "link":
|
||||||
|
already.append(rel)
|
||||||
|
continue
|
||||||
|
if st[rel] == "real":
|
||||||
|
print("[warn] {} 已是实体目录/文件(不是联接)——不动它".format(rel))
|
||||||
|
already.append(rel)
|
||||||
|
continue
|
||||||
|
target = REPO_ROOT / rel
|
||||||
|
if not target.exists():
|
||||||
|
print("[skip] {}({})主线里不存在,跳过".format(rel, desc))
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
make_junction(link, target)
|
||||||
|
except (Fail, EnvError) as err:
|
||||||
|
print("[WARN] {} 联接失败:{}".format(rel, err))
|
||||||
|
continue
|
||||||
|
made.append(rel)
|
||||||
|
print("[ok] 联接 {} -> {}".format(rel, target))
|
||||||
|
return already, made
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_plugin(a) -> int:
|
||||||
|
if a.action == "list":
|
||||||
|
if print_plugin_trees() == 0:
|
||||||
|
print("({} 下没有插件工作树)".format(PLUGIN_SUBDIR))
|
||||||
|
print(" 建法:新会话输入框选「Worktree模式」/ 让模型调 worktree_create / "
|
||||||
|
"/worktree create <name>")
|
||||||
|
print(" 建完回来「plugin adopt <name>」补共享联接,才能在里面跑 /keil-build 与门禁。")
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
wt = plugin_dir_for(a.name)
|
||||||
|
if not wt.is_dir():
|
||||||
|
raise EnvError("目录不存在:{}(先用插件建树:/worktree create 或 worktree_create)".format(wt))
|
||||||
|
|
||||||
|
if a.action == "adopt":
|
||||||
|
if git("rev-parse", "--git-dir", cwd=wt, check=False).returncode != 0:
|
||||||
|
raise EnvError("{} 不是 git 工作树(别拿普通目录当插件树)".format(wt))
|
||||||
|
print("为插件树补共享联接:{}".format(wt))
|
||||||
|
already, made = adopt_junctions(wt)
|
||||||
|
owner = plugin_lock_owner(a.name)
|
||||||
|
# 复算一次:adopt 里 skip/warn/失败都不算成功,「已有 N + 新建 M」两两相加可能对不上
|
||||||
|
# 总数——就绪判定只能按**现在的实际状态**说,否则又是把没联上的说成联上了
|
||||||
|
still = [r for r, s in junction_states(wt).items() if s == "absent"]
|
||||||
|
print()
|
||||||
|
print("[ok] 联接:已有 {} 个、本次新建 {} 个".format(len(already), len(made)))
|
||||||
|
if still:
|
||||||
|
print("[warn] 仍缺 {}:主线里该路径不存在或联接失败——先补主线再复跑 adopt"
|
||||||
|
"(这棵树还不能算就绪)".format("、".join(still)))
|
||||||
|
else:
|
||||||
|
print("该目录现在可以跑 /keil-build / ci_local.py / /serial-boot-test")
|
||||||
|
print("build 锁由 keil_build.py 自持自释,持有者名就是 {}".format(owner))
|
||||||
|
print("上台架前:$TW lock acquire bench --task {} --note <说明>".format(owner))
|
||||||
|
print("下台架后:$TW lock release bench --task {}".format(owner))
|
||||||
|
print("回收仍归插件:/worktree remove {}(本 skill 不删插件树)".format(a.name))
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
removed = [rel for rel, _spec in JUNCTION_SPECS if remove_junction(wt / rel)]
|
||||||
|
print("[ok] 摘掉联接:{}(只摘联接本身,绝不动目标内容与树里文件)".format(
|
||||||
|
"、".join(removed) if removed else "无(该树没有联接着)"))
|
||||||
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
# --------------------------------------------------------------------- 子进程脚本
|
# --------------------------------------------------------------------- 子进程脚本
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def regen(cwd: Path) -> None:
|
def regen(cwd: Path) -> None:
|
||||||
run_script("scripts/gen_task_table.py", ["--write"], cwd, "重生成 FreeRTOS 任务表")
|
run_script("scripts/gen_task_table.py", ["--write"], cwd, "重生成 FreeRTOS 任务表")
|
||||||
if list((Path(cwd) / "MDK-ARM").glob("**/*.map")):
|
if list((Path(cwd) / "MDK-ARM").glob("**/*.map")):
|
||||||
run_script("scripts/check_mem_numbers.py", ["--write"], cwd, "同步内存/Flash 快照")
|
run_script("scripts/check_mem_numbers.py", ["--write"], cwd, "同步内存/Flash 快照")
|
||||||
else:
|
else:
|
||||||
print("[skip] 未找到 .map,跳过内存快照同步(需先 @build)")
|
print("[skip] 未找到 .map,跳过内存快照同步(需先 /keil-build)")
|
||||||
|
|
||||||
|
|
||||||
def run_gates(cwd: Path, skip_build: bool) -> bool:
|
def run_gates(cwd: Path, skip_build: bool) -> bool:
|
||||||
ok = run_script("scripts/ci_local.py", [], cwd, "L0 + L0.5 本地等价 CI")
|
ok = run_script("scripts/ci_local.py", [], cwd, "静态门禁 + 主机侧纯逻辑单测 本地等价 CI")
|
||||||
if skip_build:
|
if skip_build:
|
||||||
print("[skip] 按 --skip-build 跳过 @build")
|
print("[skip] 按 --skip-build 跳过 /keil-build")
|
||||||
return ok
|
return ok
|
||||||
got, info = lock_acquire("build", "__done__", wait=900)
|
got, info = lock_acquire("build", "__done__", wait=900)
|
||||||
if not got:
|
if not got:
|
||||||
print("!! build 锁被 {} 占用(UV4 不能并发构建),跳过 @build".format(info.get("task")))
|
print("!! build 锁被 {} 占用(UV4 不能并发构建),跳过 /keil-build".format(info.get("task")))
|
||||||
return False
|
return False
|
||||||
# 告知 @build「调用方已持锁」:新版 keil_build.py 自持自释(见其 docstring),
|
# 告知 /keil-build「调用方已持锁」:新版 keil_build.py 自持自释(见其 docstring),
|
||||||
# 读到本变量即跳过,避免自我死锁;**旧版根本不看此变量、也不会自锁**,
|
# 读到本变量即跳过,避免自我死锁;**旧版根本不看此变量、也不会自锁**,
|
||||||
# 故此处对「工作树里还是旧脚本」的在途任务同样安全(向后兼容)。
|
# 故此处对「工作树里还是旧脚本」的在途任务同样安全(向后兼容)。
|
||||||
os.environ["DTU_LOCK_HELD"] = "build"
|
os.environ["DTU_LOCK_HELD"] = "build"
|
||||||
try:
|
try:
|
||||||
if not run_script(".dsh/skills/keil-build/scripts/keil_build.py", [], cwd,
|
if not run_script(".dsh/skills/keil-build/scripts/keil_build.py", [], cwd,
|
||||||
"@build(0 错 0 警)"):
|
"/keil-build(0 错 0 警)"):
|
||||||
ok = False
|
ok = False
|
||||||
finally:
|
finally:
|
||||||
os.environ.pop("DTU_LOCK_HELD", None)
|
os.environ.pop("DTU_LOCK_HELD", None)
|
||||||
@@ -557,6 +765,10 @@ def cmd_new(a) -> int:
|
|||||||
raise EnvError("已有 {} 个活跃任务(上限 {});先 done/abort,或加 --force".format(
|
raise EnvError("已有 {} 个活跃任务(上限 {});先 done/abort,或加 --force".format(
|
||||||
len(active), MAX_CONCURRENT))
|
len(active), MAX_CONCURRENT))
|
||||||
|
|
||||||
|
n_plugin = len(plugin_trees())
|
||||||
|
if n_plugin:
|
||||||
|
print("[i] 另有 {} 个 DSH 插件工作树(.dsh-worktrees/,不计入上限 {});"
|
||||||
|
"磁盘与 UV4 / 台架仍是共享资源——「plugin list」看清单".format(n_plugin, MAX_CONCURRENT))
|
||||||
WT_ROOT.mkdir(parents=True, exist_ok=True)
|
WT_ROOT.mkdir(parents=True, exist_ok=True)
|
||||||
print("[1/3] 建工作树(分支 {},基线 {}{})…".format(
|
print("[1/3] 建工作树(分支 {},基线 {}{})…".format(
|
||||||
branch, a.base, ",跳过 LFS smudge" if a.lean else ""))
|
branch, a.base, ",跳过 LFS smudge" if a.lean else ""))
|
||||||
@@ -577,7 +789,7 @@ def cmd_new(a) -> int:
|
|||||||
print()
|
print()
|
||||||
print("下一步:把新会话的工作目录切到")
|
print("下一步:把新会话的工作目录切到")
|
||||||
print(" " + str(wt))
|
print(" " + str(wt))
|
||||||
print("在该目录里干活、跑 @build 与 ci_local.py(并行安全);")
|
print("在该目录里干活、跑 /keil-build 与 ci_local.py(并行安全);")
|
||||||
print("要动台架先 `lock acquire bench --task {}`,完事 `lock release bench --task {} --bench-pass`。".format(
|
print("要动台架先 `lock acquire bench --task {}`,完事 `lock release bench --task {} --bench-pass`。".format(
|
||||||
a.task_id, a.task_id))
|
a.task_id, a.task_id))
|
||||||
print("回收用 `done {}`(**必须在主工作树执行**)。".format(a.task_id))
|
print("回收用 `done {}`(**必须在主工作树执行**)。".format(a.task_id))
|
||||||
@@ -588,6 +800,7 @@ def cmd_list(a) -> int:
|
|||||||
states = all_states()
|
states = all_states()
|
||||||
if not states:
|
if not states:
|
||||||
print("(没有任务空间。用 `new <ID> <slug>` 建一个)")
|
print("(没有任务空间。用 `new <ID> <slug>` 建一个)")
|
||||||
|
print_plugin_trees()
|
||||||
return EXIT_OK
|
return EXIT_OK
|
||||||
print("{:<12} {:<32} {:>5} {:>5} {:>4} {}".format(
|
print("{:<12} {:<32} {:>5} {:>5} {:>4} {}".format(
|
||||||
"任务", "分支", "落后", "领先", "脏", "台架"))
|
"任务", "分支", "落后", "领先", "脏", "台架"))
|
||||||
@@ -612,6 +825,7 @@ def cmd_list(a) -> int:
|
|||||||
else:
|
else:
|
||||||
print("锁 {:<6} {} 持有(已 {:.1f} 小时,于 {})".format(
|
print("锁 {:<6} {} 持有(已 {:.1f} 小时,于 {})".format(
|
||||||
n, info.get("task"), lock_age_hours(info), info.get("at")))
|
n, info.get("task"), lock_age_hours(info), info.get("at")))
|
||||||
|
print_plugin_trees()
|
||||||
return EXIT_OK
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
@@ -692,10 +906,19 @@ def cmd_lock(a) -> int:
|
|||||||
if a.bench_pass:
|
if a.bench_pass:
|
||||||
if not a.task:
|
if not a.task:
|
||||||
raise EnvError("--bench-pass 需要 --task <ID>")
|
raise EnvError("--bench-pass 需要 --task <ID>")
|
||||||
st = load_state(a.task)
|
# 非登记持有者(lead 的验收窗、插件树 pw:*)没有台账。以前这里先 load_state
|
||||||
st["bench_pass"] = True
|
# 就抛「任务 X 不存在(缺台账)」,**根本走不到释放**:锁还挂着,而 done 的前置
|
||||||
save_state(st)
|
# 会因活锁判失败——一个名字没登记,就挡住所有任务的收口。现在降级为显式告警 +
|
||||||
print("[ok] 已为任务 {} 记录台架 PASS".format(a.task))
|
# 照常释放:PASS 没进台账是事实,但不该拿「锁放不掉」当代价。
|
||||||
|
if state_file(a.task).is_file():
|
||||||
|
st = load_state(a.task)
|
||||||
|
st["bench_pass"] = True
|
||||||
|
save_state(st)
|
||||||
|
print("[ok] 已为任务 {} 记录台架 PASS".format(a.task))
|
||||||
|
else:
|
||||||
|
print("[warn] 任务 {} 无台账({})→ **跳过 PASS 记录,仅释放锁**;要把 PASS 留在"
|
||||||
|
"台账里就得用已「new」出来的任务 ID 占锁,或把验收结论写进主干提交说明/文档"
|
||||||
|
.format(a.task, state_file(a.task)))
|
||||||
ok, msg = lock_release(a.name, a.task, force=a.force)
|
ok, msg = lock_release(a.name, a.task, force=a.force)
|
||||||
print(msg)
|
print(msg)
|
||||||
return EXIT_OK if ok else EXIT_FAIL
|
return EXIT_OK if ok else EXIT_FAIL
|
||||||
@@ -747,13 +970,15 @@ def cmd_done(a) -> int:
|
|||||||
raise Fail("任务分支相对 {} 没有新提交,无需合并".format(base))
|
raise Fail("任务分支相对 {} 没有新提交,无需合并".format(base))
|
||||||
if already and ahead == 0:
|
if already and ahead == 0:
|
||||||
print("[i] {} 已合入 {},跳过合并,直接清场".format(branch, base))
|
print("[i] {} 已合入 {},跳过合并,直接清场".format(branch, base))
|
||||||
|
print("[i] 该路径在门禁之前就返回——不替你复绿;上一次门禁没跑到的尾巴,"
|
||||||
|
"清场后去主树跑 `postmerge`(或手工 ci_local + check_doc_links --all)")
|
||||||
print("=== 清场 ===")
|
print("=== 清场 ===")
|
||||||
teardown_worktree(st, force=a.force, base=base)
|
teardown_worktree(st, force=a.force, base=base)
|
||||||
print("\n完成(分支此前已合入)。")
|
print("\n完成(分支此前已合入)。")
|
||||||
return EXIT_OK
|
return EXIT_OK
|
||||||
|
|
||||||
if st.get("needs_bench") and not st.get("bench_pass"):
|
if st.get("needs_bench") and not st.get("bench_pass"):
|
||||||
raise Fail("任务标记 --needs-bench 但无台架 PASS 记录;跑完 @test 后执行 "
|
raise Fail("任务标记 --needs-bench 但无台架 PASS 记录;跑完 /serial-boot-test 后执行 "
|
||||||
"`lock release bench --task {} --bench-pass`".format(st["id"]))
|
"`lock release bench --task {} --bench-pass`".format(st["id"]))
|
||||||
print("[ok] 前置检查通过(领先 {} 共 {} 个提交)".format(base, ahead))
|
print("[ok] 前置检查通过(领先 {} 共 {} 个提交)".format(base, ahead))
|
||||||
|
|
||||||
@@ -797,6 +1022,7 @@ def cmd_done(a) -> int:
|
|||||||
print("!! 门禁未全绿:工作树与分支**保留**,未清场。")
|
print("!! 门禁未全绿:工作树与分支**保留**,未清场。")
|
||||||
print(" 主干已含合并提交 {}".format(base_after[:12]))
|
print(" 主干已含合并提交 {}".format(base_after[:12]))
|
||||||
print(" 可选:a) 在主工作树修正后追加提交(推荐)")
|
print(" 可选:a) 在主工作树修正后追加提交(推荐)")
|
||||||
|
print(" ——体积凭证/台账红就用 `postmerge` 自动诊断回填,别手抄补链")
|
||||||
print(" b) 回退本次合并:git -C \"{}\" reset --hard {}".format(main, base_before[:12]))
|
print(" b) 回退本次合并:git -C \"{}\" reset --hard {}".format(main, base_before[:12]))
|
||||||
return EXIT_FAIL
|
return EXIT_FAIL
|
||||||
|
|
||||||
@@ -808,6 +1034,130 @@ def cmd_done(a) -> int:
|
|||||||
return EXIT_OK
|
return EXIT_OK
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------- 树内预检 / 合并后补绿(机械化)
|
||||||
|
|
||||||
|
|
||||||
|
HYGIENE_BINARY = (".png", ".jpg", ".gif", ".ico", ".ttf", ".bin", ".hex", ".elf",
|
||||||
|
".axf", ".o", ".crf", ".lst", ".zip", ".gz", ".pdf", ".cur")
|
||||||
|
CR_EXEMPT = (".bat", ".cmd") # .gitattributes 强制 CRLF
|
||||||
|
|
||||||
|
|
||||||
|
def _git_bytes(*args, cwd=None):
|
||||||
|
r = subprocess.run(["git"] + [str(x) for x in args], cwd=str(cwd or REPO_ROOT),
|
||||||
|
capture_output=True, stdin=subprocess.DEVNULL)
|
||||||
|
return r.stdout if r.returncode == 0 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _changed_paths(cwd: Path):
|
||||||
|
q = ("-c", "core.quotePath=false") # 中文文件名不许被八进制转义出去
|
||||||
|
tracked = git(*q, "diff", "--name-only", "HEAD", cwd=cwd, check=False).stdout
|
||||||
|
others = git(*q, "ls-files", "--others", "--exclude-standard", cwd=cwd, check=False).stdout
|
||||||
|
return ([p.strip() for p in tracked.splitlines() if p.strip()],
|
||||||
|
[p.strip() for p in others.splitlines() if p.strip()])
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_precheck(a) -> int:
|
||||||
|
"""树内自查(只读):字节体检 + docs 逐档门禁 + 可选提交信息检查。绝不代改。"""
|
||||||
|
cwd = Path.cwd()
|
||||||
|
(cwd / "ci-artifacts").mkdir(exist_ok=True) # 新树没有 ci-artifacts(G 节:ENOENT 坑)
|
||||||
|
tracked, untracked = _changed_paths(cwd)
|
||||||
|
print("[i] {}:改动 {} 个(tracked {} + untracked {})".format(
|
||||||
|
cwd, len(tracked) + len(untracked), len(tracked), len(untracked)))
|
||||||
|
bom3, cr, fffd = bytes.fromhex("efbbbf"), b"\r", bytes.fromhex("efbfbd")
|
||||||
|
problems, docs = 0, []
|
||||||
|
for p in tracked + untracked:
|
||||||
|
f = cwd / p
|
||||||
|
if not f.is_file() or p.lower().endswith(HYGIENE_BINARY):
|
||||||
|
continue
|
||||||
|
b = f.read_bytes()
|
||||||
|
bad = []
|
||||||
|
if cr in b and not p.lower().endswith(CR_EXEMPT):
|
||||||
|
bad.append("CR")
|
||||||
|
n = b.count(fffd)
|
||||||
|
if n:
|
||||||
|
bad.append("U+FFFD x{}".format(n))
|
||||||
|
if p in tracked:
|
||||||
|
hb = _git_bytes("show", "HEAD:" + p, cwd=cwd)
|
||||||
|
if hb is not None and hb.startswith(bom3) and not b.startswith(bom3):
|
||||||
|
bad.append("BOM 漂移(HEAD 有 BOM、工作区没了——bom-guard 会拦)")
|
||||||
|
if p.startswith("docs/") and p.endswith(".md"):
|
||||||
|
docs.append(p)
|
||||||
|
if bad:
|
||||||
|
problems += 1
|
||||||
|
print("!! {}:{}".format(p, " / ".join(bad)))
|
||||||
|
for d in docs:
|
||||||
|
if not run_script("scripts/check_doc_links.py", [d], cwd, "文档门禁 " + d, tail=6):
|
||||||
|
problems += 1
|
||||||
|
if a.msgfile:
|
||||||
|
m = Path(a.msgfile)
|
||||||
|
if not m.is_file():
|
||||||
|
raise EnvError("消息文件不存在:{}".format(m))
|
||||||
|
mb = m.read_bytes()
|
||||||
|
if mb.startswith(bom3):
|
||||||
|
problems += 1
|
||||||
|
print("!! 消息带 BOM(commit -F 要 UTF-8 无 BOM)")
|
||||||
|
if cr in mb:
|
||||||
|
problems += 1
|
||||||
|
print("!! 消息含 CRLF")
|
||||||
|
ml = mb.decode("utf-8", "replace").splitlines()
|
||||||
|
for i, ln in enumerate(ml):
|
||||||
|
lim = 100 if i == 0 else 80
|
||||||
|
if len(ln) > lim:
|
||||||
|
problems += 1
|
||||||
|
print("!! 消息第 {} 行长 {} > {}".format(i + 1, len(ln), lim))
|
||||||
|
break
|
||||||
|
for w in a.ban:
|
||||||
|
c = mb.decode("utf-8", "replace").lower().count(w.lower())
|
||||||
|
print("[i] 禁词 {!r}:{} 次".format(w, c))
|
||||||
|
if c:
|
||||||
|
problems += 1
|
||||||
|
print("完成:问题 {} 个".format(problems))
|
||||||
|
return EXIT_OK if problems == 0 else EXIT_FAIL
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_postmerge(a) -> int:
|
||||||
|
"""主树合并后补绿:体积凭证自动诊断(短链/全链)+ 文档全量。列文件,不提交。
|
||||||
|
|
||||||
|
裁定外包给现有门禁:凭证红 → 先短链(--update 重写)→ 用只读的
|
||||||
|
check_mem_numbers 复检裁定数字是否真变——红了才升级全链(--write 族)。
|
||||||
|
"""
|
||||||
|
main = main_worktree()
|
||||||
|
if Path.cwd().resolve() != main.resolve():
|
||||||
|
raise EnvError("postmerge 必须在主工作树跑:{}".format(main))
|
||||||
|
if not list((main / "MDK-ARM").glob("**/*.map")):
|
||||||
|
raise EnvError("主树没有 .map——先 /keil-build 0/0 再回来(C.5 的前提)")
|
||||||
|
gate = "scripts/check_size_gate.py"
|
||||||
|
if not a.force and run_script(gate, ["--local"], main, "体积凭证检查(同码)", tail=8):
|
||||||
|
print("[ok] 凭证同码新鲜,无需补链")
|
||||||
|
else:
|
||||||
|
run_script("scripts/ram_baseline.py", ["--update"], main, "短链:重写体积凭证", tail=6)
|
||||||
|
mem_ok = (a.skip_mem or run_script("scripts/check_mem_numbers.py", [], main,
|
||||||
|
"受管区一致性复检(只读)", tail=8))
|
||||||
|
if not mem_ok:
|
||||||
|
print("[i] 快照与 .map 真漂了 → 升级全链(数字变了才动受管区)")
|
||||||
|
run_script("scripts/check_mem_numbers.py", ["--write"], main, "同步内存快照")
|
||||||
|
run_script("scripts/ram_baseline.py", ["--write"], main, "同步 RAM 预算台账")
|
||||||
|
if a.coverage:
|
||||||
|
run_script("scripts/check_test_coverage.py", ["--update-baseline"], main,
|
||||||
|
"覆盖率基线刷新", tail=6)
|
||||||
|
if not run_script(gate, ["--local"], main, "凭证复检(期望绿)", tail=8):
|
||||||
|
print("!! 凭证复检仍红——贴输出查 check_size_gate 原因,别硬提交")
|
||||||
|
return EXIT_FAIL
|
||||||
|
ok = True
|
||||||
|
if not a.skip_docs:
|
||||||
|
ok = run_script("scripts/check_doc_links.py", ["--all"], main, "文档全库检查", tail=8)
|
||||||
|
dirty = git("-c", "core.quotePath=false", "diff", "--name-only", "HEAD",
|
||||||
|
cwd=main, check=False).stdout.splitlines()
|
||||||
|
dirty = [x.strip() for x in dirty if x.strip()]
|
||||||
|
if dirty:
|
||||||
|
print()
|
||||||
|
print("[i] 回填产物(提交属 Ask first,本命令不代提交):")
|
||||||
|
for p in dirty:
|
||||||
|
print(" - " + p)
|
||||||
|
print(" 建议:git add <上面列的文件> && git commit -F <正文文件>")
|
||||||
|
return EXIT_OK if ok else EXIT_FAIL
|
||||||
|
|
||||||
|
|
||||||
def cmd_abort(a) -> int:
|
def cmd_abort(a) -> int:
|
||||||
st = load_state(a.task_id)
|
st = load_state(a.task_id)
|
||||||
wt = Path(st["path"])
|
wt = Path(st["path"])
|
||||||
@@ -874,11 +1224,17 @@ def build_parser():
|
|||||||
help="release bench 时顺带为该任务记录台架 PASS")
|
help="release bench 时顺带为该任务记录台架 PASS")
|
||||||
p.set_defaults(func=cmd_lock)
|
p.set_defaults(func=cmd_lock)
|
||||||
|
|
||||||
|
p = sub.add_parser("plugin", help="DSH worktree 插件的树(.dsh-worktrees/):list / adopt / unadopt")
|
||||||
|
p.add_argument("action", choices=["list", "adopt", "unadopt"])
|
||||||
|
p.add_argument("name", nargs="?", default="",
|
||||||
|
help="插件 worktree 名(可带 worktree/ 前缀或多段斜杠)")
|
||||||
|
p.set_defaults(func=cmd_plugin)
|
||||||
|
|
||||||
p = sub.add_parser("done", help="回收:预演 → 合入主干 → 审计 → 重生成 → 门禁 → 清场")
|
p = sub.add_parser("done", help="回收:预演 → 合入主干 → 审计 → 重生成 → 门禁 → 清场")
|
||||||
p.add_argument("task_id")
|
p.add_argument("task_id")
|
||||||
p.add_argument("--base", default=None)
|
p.add_argument("--base", default=None)
|
||||||
p.add_argument("--dry-run", action="store_true", help="只预演,不做任何改动")
|
p.add_argument("--dry-run", action="store_true", help="只预演,不做任何改动")
|
||||||
p.add_argument("--skip-build", action="store_true", help="跳过 @build")
|
p.add_argument("--skip-build", action="store_true", help="跳过 /keil-build")
|
||||||
p.add_argument("--skip-gates", action="store_true", help="跳过全部门禁")
|
p.add_argument("--skip-gates", action="store_true", help="跳过全部门禁")
|
||||||
p.add_argument("--force", action="store_true", help="清场时强制移除工作树")
|
p.add_argument("--force", action="store_true", help="清场时强制移除工作树")
|
||||||
p.set_defaults(func=cmd_done)
|
p.set_defaults(func=cmd_done)
|
||||||
@@ -887,6 +1243,18 @@ def build_parser():
|
|||||||
p.add_argument("task_id")
|
p.add_argument("task_id")
|
||||||
p.add_argument("--force", action="store_true")
|
p.add_argument("--force", action="store_true")
|
||||||
p.set_defaults(func=cmd_abort)
|
p.set_defaults(func=cmd_abort)
|
||||||
|
|
||||||
|
p = sub.add_parser("precheck", help="树内自查(只读):字节体检 + docs 逐档门禁 + 可选消息检查")
|
||||||
|
p.add_argument("--msgfile", default=None, help="附带检查提交信息文件(BOM/CRLF/行长/禁词)")
|
||||||
|
p.add_argument("--ban", action="append", default=[], help="禁词,可重复")
|
||||||
|
p.set_defaults(func=cmd_precheck)
|
||||||
|
|
||||||
|
p = sub.add_parser("postmerge", help="主树合并后补绿:体积凭证自动诊断(短/全链)+ 文档全量;不代提交")
|
||||||
|
p.add_argument("--force", action="store_true", help="凭证新鲜也强制走一遍 --update")
|
||||||
|
p.add_argument("--coverage", action="store_true", help="附带覆盖率基线刷新")
|
||||||
|
p.add_argument("--skip-docs", action="store_true", help="跳过 check_doc_links --all")
|
||||||
|
p.add_argument("--skip-mem", action="store_true", help="只挪凭证,跳过受管区复检/全链")
|
||||||
|
p.set_defaults(func=cmd_postmerge)
|
||||||
return ap
|
return ap
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
---
|
||||||
|
name: template-skill
|
||||||
|
description: Replace with description of the skill and when Claude should use it.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Insert instructions below
|
||||||
@@ -31,21 +31,24 @@ cmake --build simulator/build --config Debug # 0) 构建模拟器(须 0 警
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 内容页 FAIL,且**有意**改了该页视觉 | 预期变化 | 先肉眼确认新效果正确(窗口模式或 `i1_to_png.py` 出图对照 mockup),再 `.venv\Scripts\python.exe simulator/tests/check_content_pages.py --update` 刷参考,`git diff` 检查 ref/ 变化后随代码提交 |
|
| 内容页 FAIL,且**有意**改了该页视觉 | 预期变化 | 先肉眼确认新效果正确(窗口模式或 `i1_to_png.py` 出图对照 mockup),再 `.venv\Scripts\python.exe simulator/tests/check_content_pages.py --update` 刷参考,`git diff` 检查 ref/ 变化后随代码提交 |
|
||||||
| 内容页 FAIL,但**未**有意改视觉 | bug(缺字/几何漂移/样式回归) | 修代码;**禁止**刷参考掩盖 |
|
| 内容页 FAIL,但**未**有意改视觉 | bug(缺字/几何漂移/样式回归) | 修代码;**禁止**刷参考掩盖 |
|
||||||
| 池水位门禁失败但像素一致 | 结构/样式变更(常量样式转换、增删框体等) | 确认新值合理(残留分解见 `菜单页面设计.md` §9.5)→ 更新脚本常量 + 在流程文档变更记录登记 |
|
| 池水位门禁失败但像素一致 | 结构/样式变更(常量样式转换、增删框体等) | 确认新值合理(残留分解见 `菜单页面设计.md` §9.5)→ 更新脚本常量 + 把新值与它的实测来源写进 `docs/UI设计/UI_自动化测试流程.md` 正文常量表(2026-10-28 起不再记流水,见 `docs/文档规范.md` §5.1) |
|
||||||
| 菜单回归失败 | 结构断言(白带占比/边框/往返一致) | 按脚本打印的未过断言清单逐项修代码,无参考文件可刷 |
|
| 菜单回归失败 | 结构断言(白带占比/边框/往返一致) | 按脚本打印的未过断言清单逐项修代码,无参考文件可刷 |
|
||||||
|
|
||||||
## 池水位门禁常量(在脚本头部)
|
## 池水位门禁常量(在脚本头部)
|
||||||
|
|
||||||
| 常量 | 脚本 | 当前值 | 含义 |
|
| 常量 | 脚本 | 当前值 | 含义 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| `MENU_POOL_BASELINE` | check_menu_rows.py | 4784 B | 菜单根框建完的 LVGL 池 used(x64 模拟器) |
|
| `MENU_POOL_BASELINE` | check_menu_rows.py | 5368 B | 菜单根框建完的 LVGL 池 used(x64 模拟器;2026-10-11 随 LVGL 控件裁剪由 4784 上移) |
|
||||||
| `POOL_AFTER_ROUNDTRIP` | check_content_pages.py | 4864 B | 开页-关页往返后 = 基线 + 80 B 一次性残留 |
|
| `POOL_AFTER_ROUNDTRIP` | check_content_pages.py | 5448 B | 开页-关页往返后 = 基线 + 80 B 一次性残留 |
|
||||||
|
|
||||||
|
⚠ 两个常量量的是池的 **`used`**,与 `LV_MEM_SIZE` 总大小**无关** → 改池大小(如 2026-10-15 缩池 40→16 KB)**不该动这两个数**;动了就是页面结构变化或泄漏。
|
||||||
|
|
||||||
**禁止为过门禁盲目改常量**——无法解释的漂移 = 内存回归 bug。
|
**禁止为过门禁盲目改常量**——无法解释的漂移 = 内存回归 bug。
|
||||||
|
|
||||||
## 关联注意
|
## 关联注意
|
||||||
|
|
||||||
- `.venv` 缺失/换机:`& <本机任一 python ≥3.10> -m venv .venv; & '.venv\Scripts\python.exe' -m pip install -r requirements.txt`(`requirements.txt` 已含 `pre-commit`,重建后 L0 门禁直接可跑)
|
- `.venv` 缺失/换机:`& <本机任一 python ≥3.10> -m venv .venv; & '.venv\Scripts\python.exe' -m pip install -r requirements.txt`(`requirements.txt` 已含 `pre-commit`,重建后静态门禁直接可跑)
|
||||||
- 新增 UI `.c` 文件先走 `new-source-file` skill 双端注册,否则一端静默缺文件
|
- 新增 UI `.c` 文件先走 `new-source-file` skill 双端注册,否则一端静默缺文件
|
||||||
|
- 改 `ui_menu_tree.txt` 或界面里的中文字符串 → **同一笔**重跑生成器(`tools/menu_gen/gen_menu.py`;字体走 **font-regen** 四步),否则提交门禁 `generated-fresh` 直接拦:它把生成器拷进系统临时目录重跑、与工作区逐字节比对(实证 `0f7b5a87` 改了模板没重跑)→ `docs/门禁说明/generated-fresh.md`
|
||||||
- 字体重生成后全页大面积像素差异属预期:确认字形正确后 `--update` 刷参考
|
- 字体重生成后全页大面积像素差异属预期:确认字形正确后 `--update` 刷参考
|
||||||
- 涉及新界面/大改时本 skill 是 P3 门禁,完整管线(P0~P5)见 `docs/UI设计/UI_界面制作流程.md`
|
- 涉及新界面/大改时本 skill 是 P3 门禁,完整管线(P0~P5)见 `docs/UI设计/UI_界面制作流程.md`
|
||||||
|
|||||||
+188
-62
@@ -1,25 +1,36 @@
|
|||||||
# Gitea Actions CI —— STM32F4-Base 持续集成
|
# Gitea Actions CI —— STM32F4-Base 持续集成
|
||||||
#
|
#
|
||||||
# 覆盖范围(**只做不依赖硬件的层**,见 docs/测试说明/测试开发标准流程.md §1/§9):
|
# 覆盖范围(**只做不依赖硬件的层**,见 docs/测试说明/流程与规范/测试开发标准流程.md §1/§9):
|
||||||
# L0 静态门禁 = pre-commit 全部钩子(等价本地 `pre-commit run --all-files`)
|
# 静态门禁 static = pre-commit 全部钩子(等价本地 `pre-commit run --all-files`)
|
||||||
# L0.5 主机侧纯逻辑单测 = test/host/**(MSVC 离线编译,零硬件)
|
# + 手动阶段三根(覆盖率总闸 / 全量 cppcheck / test-cppcheck,`--hook-stage manual`;
|
||||||
|
# 它们在 .pre-commit-config.yaml 里带 stages: [manual],提交期不跑,由本 job 强制)
|
||||||
|
# 主机侧纯逻辑单测 host-unit = test/host/**(MSVC 离线编译,零硬件)
|
||||||
#
|
#
|
||||||
# ⛔ L1 板端套件(test/board/,需真机 + ST-LINK)/ L2 @test 闭环 / L3 人工目检
|
# ⛔ 板端测试套件(test/board/,需真机 + ST-LINK)/ 主机侧回归(/serial-boot-test 闭环)/ 人工目检
|
||||||
# **不上 CI** —— 需要开发板与烧录器,只能在本地跑。
|
# **不上 CI** —— 需要开发板与烧录器,只能在本地跑。
|
||||||
#
|
#
|
||||||
# 明确不做的两件事(都是刻意为之):
|
# 明确不做的两件事(都是刻意为之):
|
||||||
# 1. `@build`(Keil UV4)—— 需本机 Keil 授权,runner 上不具备;且 L0 的 cppcheck +
|
# 1. `/keil-build`(Keil UV4)—— 需本机 Keil 授权,runner 上不具备;且静态门禁的 cppcheck +
|
||||||
# 代码规范已覆盖"编译前的静态问题",真编译仍是开发者的本地红线(AGENTS.md)。
|
# 代码规范已覆盖"编译前的静态问题",真编译仍是开发者的本地红线(AGENTS.md)。
|
||||||
# 2. `mem-numbers` 钩子 —— 它的事实源是 `MDK-ARM/**/*.map`(Keil 构建产物、已 gitignore),
|
# 2. `mem-numbers` / `mem-budget` 两根钩子 —— 事实源都是 `MDK-ARM/**/*.map`(Keil
|
||||||
# 无 `@build` 即无 `.map`。故本 workflow 在**检测不到 .map 时自动跳过该钩子**
|
# 构建产物、已 gitignore),无 `/keil-build` 即无 `.map`。故本 workflow 在**检测不到
|
||||||
# (见下方 "决定跳过哪些钩子" 步骤),有 .map 的 runner(如自建带 Keil 的机器)则照常跑。
|
# .map 时自动跳过它们**(见下方 "决定跳过哪些钩子" 步骤),有 .map 的 runner 照常跑。
|
||||||
#
|
#
|
||||||
# 🔴 门禁盲区声明(S14,2026-10-02):跳过 `mem-numbers` 意味着 **CI 不守 Flash/RAM 体积门禁**。
|
# 🔴 体积门禁:盲区已关闭为**强制路径**(2026-10-25 起;原先只做到"声明")
|
||||||
# 体积变更(新增任务/缓冲/代码)不会在 CI 被拦下;必须靠**本地 `@build` 后**
|
# 跳过钩子只解释了"runner 没有 Keil",没解决**"没跑 = 通过"**:钩子在无 .map 时
|
||||||
# `python scripts/check_mem_numbers.py --scan` 兜底。故该跳过以 `::warning::` 明示
|
# 自适应 SKIP 并返回 0,于是 CI 全绿仍可能被读成"体积也守住了"。现由静态门禁作业**最后
|
||||||
# (不是弱化的 `::notice::`),并写入 job step-summary 的醒目行。
|
# 一步** `python scripts/check_size_gate.py --ci` 在**不需要 Keil** 的前提下补两条判据:
|
||||||
|
# · 同码性:`scripts/size_attestation.json` 的源码指纹必须等于本次检出树的指纹,
|
||||||
|
# 而该凭证只能由"新鲜 .map + 当时未超线"的真实链接测量写出(见 --stamp 三道前置)
|
||||||
|
# ——等价于"这份代码至少被 Keil 链接并核算过一次";
|
||||||
|
# · 红线:该次测量记录的受管区占比 ≤92%,与 `mem-budget` 同一常数同一口径。
|
||||||
|
# 强制落点 = **主干 push**(唯一能保证"这就是要发出去的那份"的位置);其余事件降级为
|
||||||
|
# `::warning::`——任务分支上的代码尚未定型,拦红只制造噪音,还会诱发多条道同时
|
||||||
|
# 抢写 scripts/ 台账。仍守不住的(**口径正文唯一**在 scripts/check_size_gate.py 的
|
||||||
|
# 模块 docstring,此处不重复):绝对数字由本地真实构建产生(runner 无 Keil 许可证)、
|
||||||
|
# 编译器选项(uvprojx,UV4 每次改写且不入库)不在指纹范围内。
|
||||||
#
|
#
|
||||||
# 产物存档(S3,2026-10-02):两个 job 把关键输出 `Tee-Object` 到 `ci-artifacts/*.log`,
|
# 产物存档与结果可见性(2026-10-02 起):两个 job 把关键输出 `Tee-Object` 到 `ci-artifacts/*.log`,
|
||||||
# 并写 $GITHUB_STEP_SUMMARY(Gitea UI 直接可见)+ `::notice::` 汇总行。
|
# 并写 $GITHUB_STEP_SUMMARY(Gitea UI 直接可见)+ `::notice::` 汇总行。
|
||||||
# **刻意不用 `actions/upload-artifact`**——内网可能拉不到外层 action(见 docs/CI/远程CI完整指南.md §5.3);
|
# **刻意不用 `actions/upload-artifact`**——内网可能拉不到外层 action(见 docs/CI/远程CI完整指南.md §5.3);
|
||||||
# 零依赖方案下日志留在 runner 工作区,UI 通过 step-summary 即可排障。
|
# 零依赖方案下日志留在 runner 工作区,UI 通过 step-summary 即可排障。
|
||||||
@@ -50,7 +61,7 @@
|
|||||||
# · 仓库 `.venv` 已 gitignore,runner 的全新工作区里**根本不存在**。
|
# · 仓库 `.venv` 已 gitignore,runner 的全新工作区里**根本不存在**。
|
||||||
# 唯一可用的机器级解释器(`C:\ProgramData\anaconda3\python.exe`)不再被探测,而文档
|
# 唯一可用的机器级解释器(`C:\ProgramData\anaconda3\python.exe`)不再被探测,而文档
|
||||||
# 要求的机器级 `DTU_PRECOMMIT_PYTHON` 从未设置(Machine 作用域一个 DTU_* 都没有)
|
# 要求的机器级 `DTU_PRECOMMIT_PYTHON` 从未设置(Machine 作用域一个 DTU_* 都没有)
|
||||||
# → run 14/15 的 L0 **全部死在"环境自检",一个钩子都没跑**。cppcheck 同理:
|
# → run 14/15 的静态门禁 **全部死在"环境自检",一个钩子都没跑**。cppcheck 同理:
|
||||||
# 旧写法"不在 PATH 就把 C:\Program Files\Cppcheck 加进 PATH"的兜底也被删了。
|
# 旧写法"不在 PATH 就把 C:\Program Files\Cppcheck 加进 PATH"的兜底也被删了。
|
||||||
# **故探测逻辑搬进 `scripts/ci_find_python.ps1`**(两个 job 共用、可离线跑、失败时
|
# **故探测逻辑搬进 `scripts/ci_find_python.ps1`**(两个 job 共用、可离线跑、失败时
|
||||||
# 打印全部候选),并恢复常见机器级安装位置兜底;`DTU_*` 仍是最高优先级、
|
# 打印全部候选),并恢复常见机器级安装位置兜底;`DTU_*` 仍是最高优先级、
|
||||||
@@ -77,7 +88,7 @@
|
|||||||
# - 该 Gitea 实例已启用 Actions:`[actions] ENABLED = true`(site admin 配置)
|
# - 该 Gitea 实例已启用 Actions:`[actions] ENABLED = true`(site admin 配置)
|
||||||
# - 已注册一个 **Windows** runner(labels 含 `windows`),且装有:
|
# - 已注册一个 **Windows** runner(labels 含 `windows`),且装有:
|
||||||
# Python 3.x、cppcheck、pre-commit、MSVC(VS2019/2022 的 C++ 工具链 + Windows SDK)
|
# Python 3.x、cppcheck、pre-commit、MSVC(VS2019/2022 的 C++ 工具链 + Windows SDK)
|
||||||
# - 缺 MSVC 时 L0.5 步骤会**降级为提示并跳过**(不硬失败),但会打印醒目 WARNING
|
# - 缺 MSVC 时主机侧单测步骤会**降级为提示并跳过**(不硬失败),但会打印醒目 WARNING
|
||||||
#
|
#
|
||||||
# 🔧 runner 机器级环境变量(可选项覆盖;runner 以 SYSTEM 服务运行,用户级变量不可见):
|
# 🔧 runner 机器级环境变量(可选项覆盖;runner 以 SYSTEM 服务运行,用户级变量不可见):
|
||||||
# DTU_PRECOMMIT_PYTHON / DTU_CPPCHECK / DTU_MSVC_CL —— 未设置时按 PATH / 标准 VS 布局自动发现。
|
# DTU_PRECOMMIT_PYTHON / DTU_CPPCHECK / DTU_MSVC_CL —— 未设置时按 PATH / 标准 VS 布局自动发现。
|
||||||
@@ -110,10 +121,10 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# L0:静态门禁(pre-commit 钩子全集)
|
# 静态门禁(pre-commit 钩子全集)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
l0-gates:
|
static-gates:
|
||||||
name: L0 静态门禁 (pre-commit)
|
name: 静态门禁 (pre-commit)
|
||||||
runs-on: windows
|
runs-on: windows
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -132,7 +143,7 @@ jobs:
|
|||||||
# * both jobs share ONE implementation,
|
# * both jobs share ONE implementation,
|
||||||
# * it can be run (and its failure list inspected) offline,
|
# * it can be run (and its failure list inspected) offline,
|
||||||
# * the well-known machine-wide install locations stay probed.
|
# * the well-known machine-wide install locations stay probed.
|
||||||
# The previous inline 2-candidate probe is what killed every L0 run
|
# The previous inline 2-candidate probe is what killed every static-gate run
|
||||||
# since 33a4aa4 -- see the file header.
|
# since 33a4aa4 -- see the file header.
|
||||||
#
|
#
|
||||||
# The script prints diagnostics with Write-Host and emits ONLY the
|
# The script prints diagnostics with Write-Host and emits ONLY the
|
||||||
@@ -154,7 +165,7 @@ jobs:
|
|||||||
# The directory fallback is NOT cosmetic: the runner service runs as
|
# The directory fallback is NOT cosmetic: the runner service runs as
|
||||||
# SYSTEM, whose PATH has no Cppcheck entry (a USER-scope PATH entry is
|
# SYSTEM, whose PATH has no Cppcheck entry (a USER-scope PATH entry is
|
||||||
# invisible to it), and no machine-level DTU_CPPCHECK is set on a
|
# invisible to it), and no machine-level DTU_CPPCHECK is set on a
|
||||||
# stock runner. Dropping this fallback in 33a4aa4 is what made L0 die
|
# stock runner. Dropping this fallback in 33a4aa4 is what made the static-gate job die
|
||||||
# on cppcheck as well, right after the Python probe had been fixed.
|
# on cppcheck as well, right after the Python probe had been fixed.
|
||||||
$found = $null
|
$found = $null
|
||||||
$envCpp = $env:DTU_CPPCHECK
|
$envCpp = $env:DTU_CPPCHECK
|
||||||
@@ -235,32 +246,36 @@ jobs:
|
|||||||
id: skip
|
id: skip
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
# mem-numbers fact source is the Keil build artifact .map (gitignored).
|
# mem-numbers / mem-budget fact source is the Keil build artifact .map
|
||||||
# No .map => this hook cannot run => skip (not "pass", but "not applicable").
|
# (gitignored). No .map => those hooks cannot run => skip them EXPLICITLY.
|
||||||
#
|
#
|
||||||
# S14 (2026-10-02): make this GAP loud. mem-numbers is the ONLY gate that
|
# 2026-10-25: two honesty fixes here.
|
||||||
# checks docs/memory numbers against the real .map (Flash/RAM size). When
|
# 1) mem-budget used NOT to be listed, yet it self-skips inside the script
|
||||||
# it is skipped, CI effectively does NOT guard the size budget -- a silent
|
# when .map is missing (exit 0) -- so on a Keil-less runner it reported
|
||||||
# ::notice:: was too easy to miss. So:
|
# "Passed" while checking nothing. A gate that fakes green is worse than
|
||||||
# - emit ::warning:: (not ::notice::) so it shows in the run summary
|
# a gate that is visibly skipped: skip it here so the report says Skipped.
|
||||||
# - write a machine-readable marker file for the summary step
|
# 2) "skipped" alone is still the blind spot, so the compensation is a
|
||||||
# - append a bold line to the job step-summary
|
# real step, not only a warning: see the "size gate" step at the end of
|
||||||
|
# this job (scripts/check_size_gate.py --ci, no Keil needed: attestation
|
||||||
|
# parity + 92% re-check on the recorded numbers). Boundary and rationale
|
||||||
|
# live in that script docstring (single source of truth).
|
||||||
$map = Get-ChildItem -Path 'MDK-ARM' -Recurse -Filter '*.map' -ErrorAction SilentlyContinue
|
$map = Get-ChildItem -Path 'MDK-ARM' -Recurse -Filter '*.map' -ErrorAction SilentlyContinue
|
||||||
if ($map) {
|
if ($map) {
|
||||||
Write-Host "found .map: $($map[0].FullName) -- mem-numbers runs normally"
|
Write-Host "found .map: $($map[0].FullName) -- size hooks run normally"
|
||||||
"value=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
"value=" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||||||
"sizegate=on" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
"sizegate=on" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||||||
"> Volume gate (mem-numbers): RAN (.map present)" |
|
"> Volume gates (mem-numbers, mem-budget): RAN (.map present)" |
|
||||||
Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
|
Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
|
||||||
} else {
|
} else {
|
||||||
Write-Host "::warning::no .map found (no Keil build) -- SKIPPING mem-numbers, so the Flash/RAM size gate does NOT run in CI. Size changes are NOT caught here; verify locally with 'python scripts/check_mem_numbers.py --scan' after a Keil build."
|
Write-Host "::warning::no .map found (no Keil build) -- SKIPPING mem-numbers and mem-budget, so absolute Flash/RAM numbers are NOT recomputed here."
|
||||||
"value=mem-numbers" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
Write-Host "::warning::that gap is no longer silent: the final step of this job runs scripts/check_size_gate.py --ci (forced path: attestation parity + 92% budget re-check). Locally: scripts/ci_local.py size step."
|
||||||
|
"value=mem-numbers,mem-budget" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||||||
"sizegate=off" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
"sizegate=off" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||||||
"> :warning: Volume gate (mem-numbers): **NOT RUN** -- no `.map` (no Keil build)." |
|
"> :warning: Volume gates (mem-numbers, mem-budget): **NOT RUN** -- no `.map` (no Keil build); compensated by the size-gate step below." |
|
||||||
Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
|
Out-File -FilePath $env:GITHUB_STEP_SUMMARY -Append -Encoding utf8
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: 运行 L0 门禁(--all-files)
|
- name: 运行静态门禁(--all-files)
|
||||||
shell: powershell
|
shell: powershell
|
||||||
env:
|
env:
|
||||||
SKIP: ${{ steps.skip.outputs.value }}
|
SKIP: ${{ steps.skip.outputs.value }}
|
||||||
@@ -274,7 +289,7 @@ jobs:
|
|||||||
# would ABORT the pipeline. Relax the preference around the pipeline and
|
# would ABORT the pipeline. Relax the preference around the pipeline and
|
||||||
# restore it afterwards (same pattern as the env-check step above).
|
# restore it afterwards (same pattern as the env-check step above).
|
||||||
New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null
|
New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null
|
||||||
$log = 'ci-artifacts\l0-pre-commit.log'
|
$log = 'ci-artifacts\static-pre-commit.log'
|
||||||
if ($env:SKIP) { Write-Host "SKIP=$env:SKIP" }
|
if ($env:SKIP) { Write-Host "SKIP=$env:SKIP" }
|
||||||
$saved = $ErrorActionPreference
|
$saved = $ErrorActionPreference
|
||||||
$ErrorActionPreference = 'Continue'
|
$ErrorActionPreference = 'Continue'
|
||||||
@@ -288,37 +303,148 @@ jobs:
|
|||||||
$passed = @($lines | Select-String -Pattern 'Passed$').Count
|
$passed = @($lines | Select-String -Pattern 'Passed$').Count
|
||||||
$failed = @($lines | Select-String -Pattern 'Failed$').Count
|
$failed = @($lines | Select-String -Pattern 'Failed$').Count
|
||||||
$skipd = @($lines | Select-String -Pattern 'Skipped$').Count
|
$skipd = @($lines | Select-String -Pattern 'Skipped$').Count
|
||||||
$summary = "L0: pre-commit hooks -- passed=$passed failed=$failed skipped=$skipd (log: $log)"
|
$summary = "static: pre-commit hooks -- passed=$passed failed=$failed skipped=$skipd (log: $log)"
|
||||||
Write-Host "::notice::$summary"
|
Write-Host "::notice::$summary"
|
||||||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### L0 static gates" -Encoding utf8
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### 静态门禁 (static gates)" -Encoding utf8
|
||||||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8
|
||||||
|
|
||||||
if ($rc -ne 0) {
|
if ($rc -ne 0) {
|
||||||
Write-Host "::error::L0 static gates failed (see output above and $log)"
|
Write-Host "::error::static gates failed (see output above and $log)"
|
||||||
exit $rc
|
exit $rc
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: 归档 L0 日志(供下载/排障)
|
# ------------------------------------------------------------
|
||||||
|
# 手动阶段门禁(2026-10-28 用户裁定):主机单测覆盖率总闸 + 全量 cppcheck。
|
||||||
|
# 这三根钩子在 .pre-commit-config.yaml 里带 `stages: [manual]`,**不在 git commit 时跑**
|
||||||
|
# (实测合计 ~165 s = 一次带代码提交耗时的三分之二);提交期只跑快的那些,全量扫描由本 job 强制。
|
||||||
|
# 逐根调用而不是一条命令:`pre_commit run <a> <b>` 不支持多 id(实测 argparse 报
|
||||||
|
# "unrecognized arguments")。
|
||||||
|
- name: 手动阶段门禁(覆盖率总闸 + 全量 cppcheck)
|
||||||
if: always()
|
if: always()
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
# S3: zero-dependency artifact archival. We do NOT use
|
# Pure ASCII only (see file header). Same $ErrorActionPreference dance as the
|
||||||
|
# static-gate step above: the runner injects 'Stop', and '2>&1 |' turns a
|
||||||
|
# native command's stderr into an ErrorRecord that would abort the pipeline.
|
||||||
|
New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null
|
||||||
|
$log = 'ci-artifacts/manual-stage.log'
|
||||||
|
if (Test-Path $log) { Remove-Item $log -Force }
|
||||||
|
$rc = 0
|
||||||
|
$hooks = @('cppcheck', 'test-cppcheck', 'test-coverage')
|
||||||
|
foreach ($h in $hooks) {
|
||||||
|
Write-Host "== manual hook: $h =="
|
||||||
|
$saved = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
& $env:PYTHON -s -m pre_commit run --all-files --hook-stage manual $h 2>&1 |
|
||||||
|
Tee-Object -FilePath $log -Append
|
||||||
|
$one = $LASTEXITCODE
|
||||||
|
$ErrorActionPreference = $saved
|
||||||
|
Write-Host "manual hook $h rc=$one"
|
||||||
|
if ($one -ne 0) { $rc = $one }
|
||||||
|
}
|
||||||
|
$lines = Get-Content $log -ErrorAction SilentlyContinue
|
||||||
|
$passed = @($lines | Select-String -Pattern 'Passed$').Count
|
||||||
|
$failed = @($lines | Select-String -Pattern 'Failed$').Count
|
||||||
|
$summary = "manual-stage: coverage + cppcheck -- passed=$passed failed=$failed (log: $log)"
|
||||||
|
Write-Host "::notice::$summary"
|
||||||
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### manual stage gates (coverage + cppcheck)" -Encoding utf8
|
||||||
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $summary -Encoding utf8
|
||||||
|
if ($rc -ne 0) {
|
||||||
|
Write-Host "::error::manual-stage gates failed (see output above and $log)"
|
||||||
|
exit $rc
|
||||||
|
}
|
||||||
|
|
||||||
|
- name: 归档静态门禁日志(供下载/排障)
|
||||||
|
if: always()
|
||||||
|
shell: powershell
|
||||||
|
run: |
|
||||||
|
# Result visibility: zero-dependency artifact archival. We do NOT use
|
||||||
# actions/upload-artifact (inner networks may not fetch outer actions);
|
# actions/upload-artifact (inner networks may not fetch outer actions);
|
||||||
# instead we keep the log on the runner AND surface its path + tail in
|
# instead we keep the log on the runner AND surface its path + tail in
|
||||||
# the step summary, so the UI is self-sufficient for triage.
|
# the step summary, so the UI is self-sufficient for triage.
|
||||||
if (Test-Path 'ci-artifacts\l0-pre-commit.log') {
|
if (Test-Path 'ci-artifacts\static-pre-commit.log') {
|
||||||
$size = (Get-Item 'ci-artifacts\l0-pre-commit.log').Length
|
$size = (Get-Item 'ci-artifacts\static-pre-commit.log').Length
|
||||||
Write-Host "archived: ci-artifacts/l0-pre-commit.log ($size bytes)"
|
Write-Host "archived: ci-artifacts/static-pre-commit.log ($size bytes)"
|
||||||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "" -Encoding utf8
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "" -Encoding utf8
|
||||||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "Archived log: ``ci-artifacts/l0-pre-commit.log`` ($size bytes on runner)" -Encoding utf8
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "Archived log: ``ci-artifacts/static-pre-commit.log`` ($size bytes on runner)" -Encoding utf8
|
||||||
|
}
|
||||||
|
if (Test-Path 'ci-artifacts\manual-stage.log') {
|
||||||
|
$msize = (Get-Item 'ci-artifacts\manual-stage.log').Length
|
||||||
|
Write-Host "archived: ci-artifacts/manual-stage.log ($msize bytes)"
|
||||||
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "Archived log: ``ci-artifacts/manual-stage.log`` ($msize bytes on runner)" -Encoding utf8
|
||||||
|
}
|
||||||
|
Write-Host "::notice::static-gate artifacts kept on runner at: ci-artifacts\ (static-pre-commit.log, manual-stage.log)"
|
||||||
|
|
||||||
|
# ------------------------------------------------------------
|
||||||
|
# 强制体积门禁(2026-10-25):不需要 Keil,也不需要 .map
|
||||||
|
# 放在静态门禁作业**最后**且 if: always()——静态门禁失败时这条结论同样要看得到,
|
||||||
|
# 而它自己失败会让整个作业变红(这正是"强制"的含义)。
|
||||||
|
- name: 体积门禁(强制路径:同码凭证 + 92% 红线,无需 Keil)
|
||||||
|
if: always()
|
||||||
|
shell: powershell
|
||||||
|
env:
|
||||||
|
GATE_REF: ${{ github.ref }}
|
||||||
|
GATE_EVENT: ${{ github.event_name }}
|
||||||
|
run: |
|
||||||
|
# Pure ASCII only (see file header).
|
||||||
|
#
|
||||||
|
# WHY THIS STEP EXISTS: mem-numbers / mem-budget read the Keil .map and
|
||||||
|
# self-skip (exit 0) without it, so "not measured" used to look like
|
||||||
|
# "passed" -- that is exactly the blind spot ("CI all green does not
|
||||||
|
# mean the size budget held"). Merely declaring it did not close it.
|
||||||
|
#
|
||||||
|
# This step enforces a check that needs NO Keil and NO .map:
|
||||||
|
# * parity -- scripts/size_attestation.json (a committed attestation
|
||||||
|
# that --stamp refuses to write unless the .map is present, newer
|
||||||
|
# than every source file in scope, and inside budget) must
|
||||||
|
# fingerprint-match THIS checked-out tree => this exact code was
|
||||||
|
# linked for real at least once;
|
||||||
|
# * budget -- the region percentages that measurement recorded must
|
||||||
|
# stay within the same 92% line mem-budget uses.
|
||||||
|
#
|
||||||
|
# Enforcement point = trunk push, the only ref guaranteed to be the code
|
||||||
|
# we ship. Task branches / WIP pull requests run --warn-only instead: the
|
||||||
|
# code there is still moving, and blocking it would push every lane to
|
||||||
|
# edit the same scripts/ ledger file at the same time.
|
||||||
|
New-Item -ItemType Directory -Force -Path 'ci-artifacts' | Out-Null
|
||||||
|
$log = 'ci-artifacts\size-gate.log'
|
||||||
|
if (-not $env:PYTHON) {
|
||||||
|
# The environment probe step already failed in that case, so the job is
|
||||||
|
# red anyway; do not mask that with a second, unrelated error.
|
||||||
|
Write-Host "::warning::PYTHON unresolved (env step failed) -- size gate cannot run"
|
||||||
|
exit 0
|
||||||
|
}
|
||||||
|
$trunks = @('refs/heads/v2-on-ui', 'refs/heads/master', 'refs/heads/main')
|
||||||
|
$enforce = ($env:GATE_EVENT -eq 'push') -and ($trunks -contains $env:GATE_REF)
|
||||||
|
if ($enforce) {
|
||||||
|
$mode = @()
|
||||||
|
$label = 'ENFORCING (trunk push)'
|
||||||
|
} else {
|
||||||
|
$mode = @('--warn-only')
|
||||||
|
$label = 'advisory (non-trunk event)'
|
||||||
|
}
|
||||||
|
$saved = $ErrorActionPreference
|
||||||
|
$ErrorActionPreference = 'Continue'
|
||||||
|
& $env:PYTHON -s scripts/check_size_gate.py --ci @mode 2>&1 |
|
||||||
|
Tee-Object -FilePath $log
|
||||||
|
$rc = $LASTEXITCODE
|
||||||
|
$ErrorActionPreference = $saved
|
||||||
|
|
||||||
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "" -Encoding utf8
|
||||||
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### Volume gate (forced path)" -Encoding utf8
|
||||||
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "size gate: rc=$rc mode=$label (attestation parity + 92% re-check; no Keil)" -Encoding utf8
|
||||||
|
Write-Host "::notice::size gate: rc=$rc mode=$label (log: $log)"
|
||||||
|
if ($rc -ne 0) {
|
||||||
|
Write-Host "::error::volume gate FAILED: this tree has no matching real Keil measurement, or the measured regions exceed the 92% budget."
|
||||||
|
Write-Host "::error::fix: run the Keil build locally (0 errors 0 warnings), then 'python scripts/ram_baseline.py --update', and commit scripts/size_attestation.json (scripts/ram_baseline.tsv is refreshed by the same command). Rationale/boundary: scripts/check_size_gate.py docstring."
|
||||||
|
exit $rc
|
||||||
}
|
}
|
||||||
Write-Host "::notice::L0 artifacts kept on runner at: ci-artifacts\l0-pre-commit.log"
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# L0.5:主机侧纯逻辑单测(零硬件;缺 MSVC 则跳过)
|
# 主机侧纯逻辑单测(零硬件;缺 MSVC 则跳过)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
l05-host-unit-tests:
|
host-unit-tests:
|
||||||
name: L0.5 主机侧单测 (MSVC)
|
name: 主机侧单测 (MSVC)
|
||||||
runs-on: windows
|
runs-on: windows
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
@@ -376,7 +502,7 @@ jobs:
|
|||||||
if ($found) {
|
if ($found) {
|
||||||
"value=yes" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
"value=yes" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||||||
} else {
|
} else {
|
||||||
Write-Host "::warning::runner has no MSVC (VS C++ toolchain) -- skipping L0.5. Install VS BuildTools and re-run."
|
Write-Host "::warning::runner has no MSVC (VS C++ toolchain) -- skipping host-side unit tests. Install VS BuildTools and re-run."
|
||||||
"value=no" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
"value=no" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,9 +511,9 @@ jobs:
|
|||||||
if: steps.msvc.outputs.value == 'yes'
|
if: steps.msvc.outputs.value == 'yes'
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
# Resolve a working Python with the SAME shared probe as L0 (no
|
# Resolve a working Python with the SAME shared probe as the static-gate job (no
|
||||||
# -NeedPreCommit: these harnesses are stdlib-only). The old inline
|
# -NeedPreCommit: these harnesses are stdlib-only). The old inline
|
||||||
# probe here had the same 2-candidate blind spot as L0 -- it only
|
# probe here had the same 2-candidate blind spot as the static-gate job -- it only
|
||||||
# survived by luck, because the stdlib tests also run on the msys64
|
# survived by luck, because the stdlib tests also run on the msys64
|
||||||
# python that happens to be on SYSTEM's PATH.
|
# python that happens to be on SYSTEM's PATH.
|
||||||
$py = & './scripts/ci_find_python.ps1' -WriteEnv
|
$py = & './scripts/ci_find_python.ps1' -WriteEnv
|
||||||
@@ -413,12 +539,12 @@ jobs:
|
|||||||
$sumPass = 0
|
$sumPass = 0
|
||||||
$sumTotal = 0
|
$sumTotal = 0
|
||||||
# Relax preference around each `2>&1 | Tee-Object` pipeline (native stderr
|
# Relax preference around each `2>&1 | Tee-Object` pipeline (native stderr
|
||||||
# becomes ErrorRecord -> 'Stop' would abort). See L0 step for details.
|
# becomes ErrorRecord -> 'Stop' would abort). See the static-gate steps for details.
|
||||||
$savedPref = $ErrorActionPreference
|
$savedPref = $ErrorActionPreference
|
||||||
foreach ($r in $runners) {
|
foreach ($r in $runners) {
|
||||||
$runnerCount++
|
$runnerCount++
|
||||||
$name = $r.Directory.Name
|
$name = $r.Directory.Name
|
||||||
$log = "ci-artifacts\l05-$name.log"
|
$log = "ci-artifacts\host-unit-$name.log"
|
||||||
Write-Host "================================"
|
Write-Host "================================"
|
||||||
Write-Host "run: $($r.FullName)"
|
Write-Host "run: $($r.FullName)"
|
||||||
Write-Host "================================"
|
Write-Host "================================"
|
||||||
@@ -444,19 +570,19 @@ jobs:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
$agg = "L0.5: $runnerCount runner(s), $sumPass/$sumTotal assertions passed, fail=$fail"
|
$agg = "host-unit: $runnerCount runner(s), $sumPass/$sumTotal assertions passed, fail=$fail"
|
||||||
Write-Host "::notice::$agg"
|
Write-Host "::notice::$agg"
|
||||||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### L0.5 host-side unit tests" -Encoding utf8
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### 主机侧纯逻辑单测 (host-unit)" -Encoding utf8
|
||||||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $agg -Encoding utf8
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value $agg -Encoding utf8
|
||||||
"count=$runnerCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
"count=$runnerCount" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||||||
"agg=$agg" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
"agg=$agg" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding ascii
|
||||||
exit $fail
|
exit $fail
|
||||||
|
|
||||||
- name: 归档 L0.5 日志(供下载/排障)
|
- name: 归档主机侧单测日志(供下载/排障)
|
||||||
if: always()
|
if: always()
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
# S3: same zero-dependency archival as L0.
|
# Result visibility: same zero-dependency archival as the static-gate job.
|
||||||
if (Test-Path 'ci-artifacts') {
|
if (Test-Path 'ci-artifacts') {
|
||||||
$logs = Get-ChildItem -Path 'ci-artifacts' -Filter '*.log' -ErrorAction SilentlyContinue
|
$logs = Get-ChildItem -Path 'ci-artifacts' -Filter '*.log' -ErrorAction SilentlyContinue
|
||||||
foreach ($f in $logs) {
|
foreach ($f in $logs) {
|
||||||
@@ -468,15 +594,15 @@ jobs:
|
|||||||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "Archived logs on runner: $names" -Encoding utf8
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "Archived logs on runner: $names" -Encoding utf8
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Write-Host "no ci-artifacts directory (L0.5 skipped or failed early)"
|
Write-Host "no ci-artifacts directory (host-side unit tests skipped or failed early)"
|
||||||
}
|
}
|
||||||
|
|
||||||
- name: L0.5 跳过说明
|
- name: 主机侧单测跳过说明
|
||||||
if: steps.msvc.outputs.value == 'no'
|
if: steps.msvc.outputs.value == 'no'
|
||||||
shell: powershell
|
shell: powershell
|
||||||
run: |
|
run: |
|
||||||
Write-Host "L0.5 skipped: runner has no MSVC."
|
Write-Host "host-side unit tests skipped: runner has no MSVC."
|
||||||
Write-Host "Local equivalent: & '.venv\Scripts\python.exe' scripts/ci_local.py"
|
Write-Host "Local equivalent: & '.venv\Scripts\python.exe' scripts/ci_local.py"
|
||||||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### L0.5 host-side unit tests" -Encoding utf8
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value "### 主机侧纯逻辑单测 (host-unit)" -Encoding utf8
|
||||||
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ":warning: **NOT RUN** -- runner has no MSVC (VS C++ toolchain)." -Encoding utf8
|
Add-Content -Path $env:GITHUB_STEP_SUMMARY -Value ":warning: **NOT RUN** -- runner has no MSVC (VS C++ toolchain)." -Encoding utf8
|
||||||
exit 0
|
exit 0
|
||||||
|
|||||||
+42
-3
@@ -120,9 +120,14 @@ simulator/SDL2/
|
|||||||
*.exe
|
*.exe
|
||||||
*.pdb
|
*.pdb
|
||||||
*.ilk
|
*.ilk
|
||||||
# 内容页黄金参考(ref/*.bin)与目检 PNG 均为本地生成物,不入库:
|
# 内容页像素黄金参考 ref/*.bin **已入库**(2026-10-22):整目录不入库时,任何新克隆 /
|
||||||
# 首次/换机后先跑 check_content_pages.py --update 生成 ref/*.bin,再跑 regen_ref_png.py 出 PNG
|
# 新工作树跑 ui-golden 都必然 FAIL(缺基准),这条像素回归就只在老机器上真实存在。
|
||||||
simulator/tests/ref/
|
# 交叉验证:两份各自独立构建的 lvgl_sim(不同构建目录、相隔 11 天的代码)对同一批 ref
|
||||||
|
# 逐字节一致 → 渲染可复现,基线可入库。派生的目检 PNG 仍是本地产物,继续忽略:
|
||||||
|
simulator/tests/ref/*.png
|
||||||
|
# ⚠ 上面第 115 行的全仓 `*.bin` 兜底(挡模拟器的 sim_netcfg.bin 之类运行产物)会连入库的
|
||||||
|
# 黄金参考一起挡掉 → 必须显式反选;反选只有排在 `*.bin` **之后**才生效(.gitignore 后者覆盖前者)
|
||||||
|
!simulator/tests/ref/*.bin
|
||||||
# 架构图 PNG 预览(源为 docs/images/*.svg,PNG 本地渲染生成,不入库)
|
# 架构图 PNG 预览(源为 docs/images/*.svg,PNG 本地渲染生成,不入库)
|
||||||
docs/images/*.png
|
docs/images/*.png
|
||||||
*.pyc
|
*.pyc
|
||||||
@@ -133,6 +138,19 @@ docs/images/*.png
|
|||||||
# Serial assistant runtime state (daemon log/meta, local runtime artifacts)
|
# Serial assistant runtime state (daemon log/meta, local runtime artifacts)
|
||||||
.dsh/skills/serial-assistant/scripts/state/
|
.dsh/skills/serial-assistant/scripts/state/
|
||||||
|
|
||||||
|
# agent 会话草稿(2026-10-14 定):提交信息草稿 / 门禁输出 / 台架日志 / 一次性改名
|
||||||
|
# 脚本 / 故障注入 patch —— 一律不入库,**而且不落仓库根**(写进 ci-artifacts/ 或系统临时
|
||||||
|
# 目录:2026-10-28 清理仓库根时那里堆着四个一次性计数改名脚本 + 一份提交信息草稿,全是
|
||||||
|
# 未跟踪的 ?? —— 一次 git add -A 就进历史;仓库根只留工程配置与生成器输入两份 .txt)。
|
||||||
|
# 判据三条,命中任一就不值得留:
|
||||||
|
# ① 能重跑的(门禁输出、日志);② 内容已在 git 历史里的(已合并实现的过程 diff);
|
||||||
|
# ③ 离开已丢弃的任务分支就跑不通的(依赖 NET_FAULT_INJECT 的注入产物)——
|
||||||
|
# 这种提交进去是"看着有证据、实际不可复现"的假证据,**方法应沉淀成文档文字**
|
||||||
|
# (见 docs/网络/网络自愈架构设计.md §5.2 E18/E19)。
|
||||||
|
# ⚠ .dsh/skills/ 是**入库的项目知识**(skills = 子域方法论),必须放行。
|
||||||
|
.dsh/*
|
||||||
|
!.dsh/skills/
|
||||||
|
|
||||||
# 记忆工具(Mnemon)本地状态:项目文档索引 documents/ + 运行时记忆
|
# 记忆工具(Mnemon)本地状态:项目文档索引 documents/ + 运行时记忆
|
||||||
# runtime/MEMORY.md、USER.md、memories.json。**只留本机、不进版本库**
|
# runtime/MEMORY.md、USER.md、memories.json。**只留本机、不进版本库**
|
||||||
# (2026-10-11 定):记忆是按机器/按人累积的私有状态,入库会让每次记忆
|
# (2026-10-11 定):记忆是按机器/按人累积的私有状态,入库会让每次记忆
|
||||||
@@ -141,4 +159,25 @@ docs/images/*.png
|
|||||||
|
|
||||||
# CI 运行时产物(.gitea/workflows/ci.yml 的 S3 步骤在 runner 工作区落盘的日志;
|
# CI 运行时产物(.gitea/workflows/ci.yml 的 S3 步骤在 runner 工作区落盘的日志;
|
||||||
# 零依赖存档方案——落在工作区目录而非上传 artifact,故必须忽略以免误提交)
|
# 零依赖存档方案——落在工作区目录而非上传 artifact,故必须忽略以免误提交)
|
||||||
|
# 子进程环境残缺时 Windows 会按字面量建目录(本会话实测两次复发:UV4 / 字体
|
||||||
|
# 工具在缺 SystemDrive、ProgramData 的环境里于仓库根创建
|
||||||
|
# %SystemDrive%/ProgramData/Microsoft/Windows/Caches/*.db,约 1 MB 字体缓存库)。
|
||||||
|
# 与"一次性中间产物不进 .gitignore"那条教训不同:这是任何一条道随时可能再生的环境
|
||||||
|
# 副产物,不兜住就会被某次 git add -A 带进历史。正解仍是继承完整环境跑这类工具。
|
||||||
|
%SystemDrive%/
|
||||||
ci-artifacts/
|
ci-artifacts/
|
||||||
|
|
||||||
|
# 提交信息草稿的兜底反选(上一条草稿块的「不入库」靠人自觉把文件写进 ci-artifacts/,
|
||||||
|
# 但 `git commit -F` 那份文件随手落在仓库根就会以 ?? 出现,被某次 add -A 带走):
|
||||||
|
# 正文已经进了 commit message,落盘副本零信息量 → 根目录锚定挡掉这个命名。
|
||||||
|
/commitmsg*.txt
|
||||||
|
|
||||||
|
# DSH worktree 插件(dsh-task-worktree)把任务树建在**仓库内**这个命名空间下,与
|
||||||
|
# @task skill 的仓外 _wt/ 两套并存。这一行必须**先由我们自己写好**:插件首次 create
|
||||||
|
# 会往 .gitignore 追加分毫不相同的字面量(ensureIgnored 见同名规则即跳过),但那等于
|
||||||
|
# 在会话中途改这份受控文件——门禁 diff 里冒出一行"不是我加的"最坑人。
|
||||||
|
# 该目录同时登记进了 check_doc_links / gen_refgraph / check_layer_deps 的排除面
|
||||||
|
# (整份仓库副本不能进全库扫描);细节 → .dsh/skills/task-worktree/SKILL.md
|
||||||
|
.dsh-worktrees/
|
||||||
|
|
||||||
|
.obsidian/
|
||||||
|
|||||||
+442
-38
@@ -1,5 +1,8 @@
|
|||||||
# pre-commit 自动化代码审查配置
|
# pre-commit 自动化代码审查配置
|
||||||
# 详见 docs/代码规范/pre-commit使用说明.md
|
# 通用机制(阶段 / stash 陷阱 / 触发面 / 增量拦截三段式)→ docs/代码规范/pre-commit使用说明.md §2
|
||||||
|
# 每个门禁一篇详解(含限制与盲区)→ docs/门禁说明/README.md
|
||||||
|
# 加/删一条钩子:同一笔提交里改 AGENTS.md 的「共 N 个钩子」与本文件注释;口径 = YAML 解析后的 hook 条目数,
|
||||||
|
# 自查命令与「声明 / 文档 / 实跑」三数同源核对 → docs/门禁说明/README.md §2(直接数文本行会把下面注释掉的候选条目算进去)
|
||||||
default_install_hook_types: [pre-commit, commit-msg]
|
default_install_hook_types: [pre-commit, commit-msg]
|
||||||
|
|
||||||
# 顶层 exclude 只挡**第三方与生成物**。
|
# 顶层 exclude 只挡**第三方与生成物**。
|
||||||
@@ -8,8 +11,15 @@ default_install_hook_types: [pre-commit, commit-msg]
|
|||||||
# tests/*.py、两份配置与 README)。整目录豁免 = 自研代码落在所有钩子之外(连
|
# tests/*.py、两份配置与 README)。整目录豁免 = 自研代码落在所有钩子之外(连
|
||||||
# lv-conf-sync 都拦不到);实测把 simulator/ 纳入 cppcheck 后,立刻揪出
|
# lv-conf-sync 都拦不到);实测把 simulator/ 纳入 cppcheck 后,立刻揪出
|
||||||
# sim_syslog.c 一处真实的空指针解引用。真正的生成物/第三方(`simulator/build/`、
|
# sim_syslog.c 一处真实的空指针解引用。真正的生成物/第三方(`simulator/build/`、
|
||||||
# `simulator/SDL2/`、`simulator/tests/ref/`、`simulator/shot*.{bin,png}`、`__pycache__/`)
|
# `simulator/SDL2/`、`simulator/shot*.{bin,png}`、`__pycache__/`)**已在 .gitignore 里**,
|
||||||
# **已在 .gitignore 里**,永远不会进 git 索引 → 无需在此重复豁免,故直接删掉该条。
|
# 不会进 git 索引 → 无需在此重复豁免,故直接删掉该条。
|
||||||
|
# ⚠ 例外(2026-10-22):`simulator/tests/ref/*.bin`(ui-golden 的像素黄金参考)已从
|
||||||
|
# 「整目录 gitignore」改为**入库**——基线不入库时任何新克隆 / 新工作树都必然报
|
||||||
|
# 「缺黄金参考」,这条门禁就只在有老副本的机器上真实存在。入库前提是实测可复现:
|
||||||
|
# 两份各自独立构建的 lvgl_sim(不同构建目录、相隔 11 天的代码)对同一批 11 个 .bin
|
||||||
|
# SHA-256 全部一致。改写型钩子(trailing-whitespace / end-of-file-fixer)声明
|
||||||
|
# `types: [text]`,位图含 NUL → 被判二进制自动 Skipped(实测「索引 blob == 磁盘 ==
|
||||||
|
# 参照」三方一致),无需为它另加 exclude。
|
||||||
exclude: |
|
exclude: |
|
||||||
(?x)^(
|
(?x)^(
|
||||||
\.opencode/node_modules/|
|
\.opencode/node_modules/|
|
||||||
@@ -66,6 +76,11 @@ repos:
|
|||||||
hooks:
|
hooks:
|
||||||
- id: cppcheck
|
- id: cppcheck
|
||||||
name: cppcheck
|
name: cppcheck
|
||||||
|
# ⚠ stages: [manual](2026-10-28 用户裁定):全库扫描实测 ~20 s,不压在提交期;
|
||||||
|
# 由**远端 CI** 强制(.gitea/workflows/ci.yml 的「手动阶段门禁(覆盖率总闸 + 全量 cppcheck)」步),
|
||||||
|
# 本地的等价入口 = scripts/ci_local.py(manual 段)或
|
||||||
|
# python -m pre_commit run --all-files --hook-stage manual cppcheck
|
||||||
|
stages: [manual]
|
||||||
entry: cppcheck
|
entry: cppcheck
|
||||||
# 注意:YAML 流序列中逗号是元素分隔符,含逗号/空格的参数必须加引号
|
# 注意:YAML 流序列中逗号是元素分隔符,含逗号/空格的参数必须加引号
|
||||||
args: ['--enable=warning,performance,portability,style', '--error-exitcode=1',
|
args: ['--enable=warning,performance,portability,style', '--error-exitcode=1',
|
||||||
@@ -105,6 +120,9 @@ repos:
|
|||||||
# 依据:docs/审查记录/测试与架构改进建议.md §3.4;实机测试实施指南.md §8.1
|
# 依据:docs/审查记录/测试与架构改进建议.md §3.4;实机测试实施指南.md §8.1
|
||||||
- id: test-cppcheck
|
- id: test-cppcheck
|
||||||
name: cppcheck (test/, warning only)
|
name: cppcheck (test/, warning only)
|
||||||
|
# ⚠ stages: [manual](2026-10-28,同 cppcheck):实测 ~38 s;由远端 CI 与
|
||||||
|
# scripts/ci_local.py 的 manual 段跑,提交期不跑。
|
||||||
|
stages: [manual]
|
||||||
entry: cppcheck
|
entry: cppcheck
|
||||||
args: ['--enable=warning', '--error-exitcode=1',
|
args: ['--enable=warning', '--error-exitcode=1',
|
||||||
'--inline-suppr', '--std=c99', '--quiet',
|
'--inline-suppr', '--std=c99', '--quiet',
|
||||||
@@ -152,11 +170,11 @@ repos:
|
|||||||
Inc/
|
Inc/
|
||||||
)
|
)
|
||||||
|
|
||||||
# 5) 文档一致性门禁(D-7)
|
# 5) 文档一致性门禁
|
||||||
# 存在性:md 互链 / 反引号路径是否指向真实文件(防迁移/删除后残留死链)
|
# 存在性:md 互链 / 反引号路径是否指向真实文件(防迁移/删除后残留死链)
|
||||||
# 章节:`§N.M` 必须能在目标文档的编号标题里找到(--sections-strict:自引用失效也拦)
|
# 章节:`§N.M` 必须能在目标文档的编号标题里找到(--sections-strict:自引用失效也拦)
|
||||||
- id: doc-links
|
- id: doc-links
|
||||||
name: 文档链接存在性 (D-3/D-7)
|
name: 文档链接存在性
|
||||||
entry: python scripts/check_doc_links.py --sections-strict
|
entry: python scripts/check_doc_links.py --sections-strict
|
||||||
language: system
|
language: system
|
||||||
files: \.md$
|
files: \.md$
|
||||||
@@ -169,23 +187,46 @@ repos:
|
|||||||
MDK-ARM/RTE/|
|
MDK-ARM/RTE/|
|
||||||
docs/芯片手册/
|
docs/芯片手册/
|
||||||
)
|
)
|
||||||
# 数值:README/AGENTS 任务表须与 Src/freertos.c 一致(D-2/D-7)
|
# mermaid 图:围栏内确定性语法预检(第 52 条,2026-10-30 加,口径 51 → 52,提交期实跑 48 → 49)。
|
||||||
|
# 为什么要有:屏板链路设计说明.md §1 的 flowchart 里 `-.|"文字"|`(`-.` 直接跟管道,不是任何合法
|
||||||
|
# 箭头 token)让 mermaid 渲染 `Syntax error in text`——该图 2026-10-29 落库、期间 doc-links 等
|
||||||
|
# 全绿:文档门禁查链接与章节,**从不查围栏内图表语法**,这是它们的共同盲区。
|
||||||
|
# v1 只查「错了必渲染失败」三条(不重造解析器):R1 缺/错图类型头;R2 `-.` 后直接跟 `|`
|
||||||
|
# (带文字点线合法形态只有 `-.->|t|` 与 `-. 文字 .->`,selftest 证明两种合法形态不误伤);
|
||||||
|
# R3 flowchart 的 subgraph 与独占行 `end` 计数配对、sequence 的块起点与 `end` 计数配对。
|
||||||
|
# 存量基线:接线当日 --all 实测 29 个自有文件 60 块围栏、0 命中 ⇒ **空基线零语料**(还清类
|
||||||
|
# 先例:id-check-new);docs/芯片手册/ 是导入转录、围栏不受控,与 doc-links 同排除面不进语料。
|
||||||
|
# 自证:--selftest 五条(正例含三种合法点线 / 本坑形态必响 / 缺 end 必响 / 假头必响且
|
||||||
|
# sequence alt-else-end 不误伤 / 基线吞得下且清空后同命中算新发)。
|
||||||
|
- id: mermaid-syntax
|
||||||
|
name: "mermaid 围栏语法预检(图类型头 / 非法点线管道 / 块配对)"
|
||||||
|
entry: python scripts/check_mermaid.py
|
||||||
|
language: system
|
||||||
|
files: \.md$
|
||||||
|
exclude: |
|
||||||
|
(?x)^(
|
||||||
|
\.opencode/node_modules/|
|
||||||
|
Lib/lvgl/|
|
||||||
|
MDK-ARM/RTE/|
|
||||||
|
docs/芯片手册/
|
||||||
|
)
|
||||||
|
# 数值:README/AGENTS 任务表须与 Src/freertos.c 一致
|
||||||
- id: task-table
|
- id: task-table
|
||||||
name: FreeRTOS 任务表一致性 (D-2/D-7)
|
name: FreeRTOS 任务表一致性
|
||||||
entry: python scripts/gen_task_table.py --check
|
entry: python scripts/gen_task_table.py --check
|
||||||
language: system
|
language: system
|
||||||
files: ^(README\.md|AGENTS\.md|Src/freertos\.c)$
|
files: ^(README\.md|AGENTS\.md|Src/freertos\.c)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
# 体积:内存/Flash 数字须与最新 .map 一致(D-7 第三档)
|
# 体积:内存/Flash 数字须与最新 .map 一致(体积档)
|
||||||
# 只在内存文档或代码变动时触发;.map 未生成时脚本自身给出明确提示
|
# 只在内存文档或代码变动时触发;.map 未生成时脚本自身给出明确提示
|
||||||
- id: mem-numbers
|
- id: mem-numbers
|
||||||
name: 内存/Flash 数字一致性 (D-7 体积档)
|
name: 内存/Flash 数字一致性(体积档)
|
||||||
entry: python scripts/check_mem_numbers.py --scan
|
entry: python scripts/check_mem_numbers.py --scan
|
||||||
language: system
|
language: system
|
||||||
files: ^(docs/内存管理/.*\.md|scripts/check_mem_numbers\.py|App/.*\.c|App/.*\.h|Src/.*\.c|Inc/.*\.h)$
|
files: ^(docs/内存管理/.*\.md|scripts/check_mem_numbers\.py|App/.*\.c|App/.*\.h|Src/.*\.c|Inc/.*\.h)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
# 5d) 两份 lv_conf.h 特性子集一致性(2026-10-11 加,随 F-1/F-2/F-3 落地)
|
# 5d) 两份 lv_conf.h 特性子集一致性(2026-10-11 加)
|
||||||
# 背景:`Lib/lv_conf.h`(MCU)与 `simulator/lv_conf.h`(PC)是两份独立文件,
|
# 背景:`Lib/lv_conf.h`(MCU)与 `simulator/lv_conf.h`(PC)是两份独立文件,
|
||||||
# 此前长期漂移——模拟器早已 I1-only,MCU 端却让 11 个 `LV_DRAW_SW_SUPPORT_*`、
|
# 此前长期漂移——模拟器早已 I1-only,MCU 端却让 11 个 `LV_DRAW_SW_SUPPORT_*`、
|
||||||
# 5 个未用控件类、`LV_USE_THEME_SIMPLE`、`LV_USE_FLEX/GRID` 靠 lv_conf_internal
|
# 5 个未用控件类、`LV_USE_THEME_SIMPLE`、`LV_USE_FLEX/GRID` 靠 lv_conf_internal
|
||||||
@@ -223,7 +264,7 @@ repos:
|
|||||||
# 累计 / 100ms 节流 / tick 回绕)是纯逻辑,板端套件无法穷举边界;本钩子
|
# 累计 / 100ms 节流 / tick 回绕)是纯逻辑,板端套件无法穷举边界;本钩子
|
||||||
# 用 MSVC 离线编译真实源文件(#include 纳入)并用可观测桩驱动,秒级、零
|
# 用 MSVC 离线编译真实源文件(#include 纳入)并用可观测桩驱动,秒级、零
|
||||||
# 硬件、不占 MCU RAM。仅在被测文件或其平台层 / 测试变动时触发。
|
# 硬件、不占 MCU RAM。仅在被测文件或其平台层 / 测试变动时触发。
|
||||||
# 详见 docs/测试说明/看门狗单元测试说明.md
|
# 详见 docs/测试说明/单元测试/看门狗单元测试说明.md
|
||||||
- id: watchdog-unit-test
|
- id: watchdog-unit-test
|
||||||
name: 看门狗判定状态机单测(主机侧)
|
name: 看门狗判定状态机单测(主机侧)
|
||||||
entry: python test/host/watchdog/run_unit_test.py
|
entry: python test/host/watchdog/run_unit_test.py
|
||||||
@@ -261,7 +302,7 @@ repos:
|
|||||||
files: ^(App/cfg/cfg_file\.(c|h)|App/cfg/cfg_file_store\.h|App/log/sys_log\.h|test/host/cfg_file/.*|test/host/common/unit_test_harness\.py)$
|
files: ^(App/cfg/cfg_file\.(c|h)|App/cfg/cfg_file_store\.h|App/log/sys_log\.h|test/host/cfg_file/.*|test/host/common/unit_test_harness\.py)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
# 7d) 相量层(L2)主机侧单元测试(2026-10-10 加)
|
# 7d) 相量层(全周傅氏相量)主机侧单元测试(2026-10-10 加)
|
||||||
# 背景:App/algo/ 为**纯算法**层(无寄存器、无 RTOS、无外设依赖),是本项目
|
# 背景:App/algo/ 为**纯算法**层(无寄存器、无 RTOS、无外设依赖),是本项目
|
||||||
# 首个可完全离线验证的算法层。板端**无信号源**,无法注入已知幅值/相位/频率的
|
# 首个可完全离线验证的算法层。板端**无信号源**,无法注入已知幅值/相位/频率的
|
||||||
# 标准波形,故"读数 = 设定值"这一核心判据在板上不可验;本钩子用 MSVC 离线编译
|
# 标准波形,故"读数 = 设定值"这一核心判据在板上不可验;本钩子用 MSVC 离线编译
|
||||||
@@ -275,7 +316,7 @@ repos:
|
|||||||
files: ^(App/algo/(phasor|sample_ring)\.(c|h)|test/host/phasor/.*|test/host/common/unit_test_harness\.py)$
|
files: ^(App/algo/(phasor|sample_ring)\.(c|h)|test/host/phasor/.*|test/host/common/unit_test_harness\.py)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
# 7e) ADC 输入层(L1)主机侧单元测试(2026-10-10 加)
|
# 7e) ADC 输入层(语义取数与有效性)主机侧单元测试(2026-10-10 加)
|
||||||
# 背景:adc_input.c 的核心是"通道号 <-> Rank 顺序 <-> 语义"三者映射的正确性;
|
# 背景:adc_input.c 的核心是"通道号 <-> Rank 顺序 <-> 语义"三者映射的正确性;
|
||||||
# 板端无信号源,无法注入已知电压验证"读到的是哪只脚"(映射说明 §8)。本钩子用
|
# 板端无信号源,无法注入已知电压验证"读到的是哪只脚"(映射说明 §8)。本钩子用
|
||||||
# MSVC 离线编译真实源文件(#include 纳入)+ HAL/TPAFE 可注入桩:给每个 Rank
|
# MSVC 离线编译真实源文件(#include 纳入)+ HAL/TPAFE 可注入桩:给每个 Rank
|
||||||
@@ -304,22 +345,37 @@ repos:
|
|||||||
files: ^(App/fs/vol_lock\.(c|h)|test/host/vol_lock/.*|test/host/common/unit_test_harness\.py)$
|
files: ^(App/fs/vol_lock\.(c|h)|test/host/vol_lock/.*|test/host/common/unit_test_harness\.py)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
# 7g) 时间同步(time_sync)主机侧单元测试(2026-10-10 加)
|
# 7f2) 卷空闲同步(写回缓存空闲 flush)主机侧单元测试(2026-10-25 加,道F 批 2)
|
||||||
# 背景:time_sync.c 是 TCP 时间同步协议状态机,也是 N-17 号看门狗风险的**全部
|
# 背景:该模块是写回缓存掉电窗口的兜底网——掉电与"脏页驻留"本身台架
|
||||||
# 逻辑贡献者**——"listen 重建 -> 800ms~1s 心跳间隔"由本模块的分片延时/退避/
|
# 不可按需构造;卷忙跳过 / ioctl 失败边沿 latch / 32 位 tick 回绕 /
|
||||||
# 结算等待组成,板端只能看到"是否复位"这一结果,看不到各段延时是否如设计。本钩子
|
# 失败路径成对放锁(不得空放)全是纯状态逻辑。本钩子用 MSVC 离线编译
|
||||||
# 用 MSVC 离线编译真实源文件(#include 纳入)+ 可脚本化的网络/事件桩(含 longjmp
|
# 真实源文件(#include 纳入;桩:可控时钟 + 事件位图 + 卷忙注入 +
|
||||||
# 跳出 for(;;) 任务循环),把分片喂狗节奏、监听退避、报文重组(半包/粘连/EOF/真错/
|
# 可注入 disk_ioctl),把四级门与 latch 分支一次钉死。
|
||||||
# 预算耗尽/tick 回绕)、重听门控逐条钉死。
|
# files 含被测源与其头、就绪位属主 sys_events 的消费面 default_task.c、
|
||||||
# files 含被测源与其头、被测的真实看门狗头,以及 harness 本身。
|
# 以及 harness 本身。
|
||||||
|
- id: vol-idle-sync-unit-test
|
||||||
|
name: 卷空闲同步单测(主机侧)
|
||||||
|
entry: python test/host/vol_idle_sync/run_unit_test.py
|
||||||
|
language: system
|
||||||
|
files: ^(App/fs/vol_idle_sync\.(c|h)|App/task/default_task\.c|test/host/vol_idle_sync/.*|test/host/common/unit_test_harness\.py)$
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
# 7g) 时间同步(time_sync)主机侧单元测试(2026-10-10 加,2026-11-04 随 :8888 退役收缩)
|
||||||
|
# 背景::8888 退役后 time_sync.c 只剩 timeSyncTask 载体编排——就绪屏障 + 200ms 校时节拍
|
||||||
|
# + SNTP 客户端调用。探针窗口心跳缺口那项看门狗风险的逻辑贡献者(分片延时节奏、屏障
|
||||||
|
# 等待态喂心跳)仍在本模块,板端只能看到"是否复位"这一结果,看不到各段延时是否如设计;
|
||||||
|
# 本钩子用 MSVC 离线编译真实源文件(#include 纳入)+ 可脚本化的事件/代次/SNTP 桩
|
||||||
|
# (含 longjmp 跳出 for(;;) 任务循环),把分片喂狗节奏、屏障分片、代次下传逐条钉死。
|
||||||
|
# 历史(监听退避、TIME 报文重组、30s 预算、tick 回绕用例)随 :8888 退役删除,见 git 历史。
|
||||||
|
# files 含被测源与其头、节拍契约头 time_sntp.h、被测的真实看门狗头,以及 harness 本身。
|
||||||
- id: time-sync-unit-test
|
- id: time-sync-unit-test
|
||||||
name: 时间同步单测(主机侧)
|
name: 时间同步单测(主机侧)
|
||||||
entry: python test/host/time_sync/run_unit_test.py
|
entry: python test/host/time_sync/run_unit_test.py
|
||||||
language: system
|
language: system
|
||||||
files: ^(App/sync/time_sync\.(c|h)|App/util/task_watchdog\.h|test/host/time_sync/.*|test/host/common/unit_test_harness\.py)$
|
files: ^(App/sync/time_sync\.(c|h)|App/sync/time_sntp\.h|App/util/task_watchdog\.h|test/host/time_sync/.*|test/host/common/unit_test_harness\.py)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
# 7h) ADC 采样有效性校验(L1-6)主机侧单元测试(2026-10-11 加)
|
# 7h) ADC 采样有效性校验主机侧单元测试(2026-10-11 加)
|
||||||
# 背景:adc_health.c 是"静默/丢帧/序号倒退/错误计数增量"的判定层——这些
|
# 背景:adc_health.c 是"静默/丢帧/序号倒退/错误计数增量"的判定层——这些
|
||||||
# 故障在健康台架上**不可复现**(复现 = 拔芯片/改驱动),板端闭环只能证明
|
# 故障在健康台架上**不可复现**(复现 = 拔芯片/改驱动),板端闭环只能证明
|
||||||
# "健康时不误报",证不了"故障时必报";100ms 门限、tick 回绕、事件边沿
|
# "健康时不误报",证不了"故障时必报";100ms 门限、tick 回绕、事件边沿
|
||||||
@@ -333,7 +389,58 @@ repos:
|
|||||||
files: ^(App/algo/adc_health\.(c|h)|App/task/adc_task\.c|test/host/adc_health/.*|test/host/common/unit_test_harness\.py)$
|
files: ^(App/algo/adc_health\.(c|h)|App/task/adc_task\.c|test/host/adc_health/.*|test/host/common/unit_test_harness\.py)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
# 7i) 保护定值模型(P1 / L5-1 载体)主机侧单元测试(2026-10-11 加)
|
# 7h2) 通道标定层(标定折算,「数据可信」标定折算与落盘整定)主机侧单元测试(2026-10-25 加,道A)
|
||||||
|
# 背景:标定折算是"码值→工程量"的唯一插入点,Q16 系数/零点两段落盘 +
|
||||||
|
# 合法带哨兵回退 + 两点标定公式——板端无标准源、坏文件注入路径在真机
|
||||||
|
# 上难以复现,只有离线端能逐系数钉死。钩子用 MSVC 编译真实源文件
|
||||||
|
# (#include 纳入;桩:dbg_log / cfg_file_register / adc_input_desc 强符号)。
|
||||||
|
# files 含被测源与头、系数口径联动的 adc_health/cfg_file/adc_task 消费方,
|
||||||
|
# 以及 harness 本身。
|
||||||
|
- id: adc-cal-unit-test
|
||||||
|
name: 通道标定层单测(主机侧)
|
||||||
|
entry: python test/host/adc_cal/run_unit_test.py
|
||||||
|
language: system
|
||||||
|
files: ^(App/algo/adc_cal\.(c|h)|App/algo/adc_health\.(c|h)|App/cfg/cfg_file\.(c|h)|App/task/adc_task\.c|test/host/adc_cal/.*|test/host/common/unit_test_harness\.py)$
|
||||||
|
pass_filenames: false
|
||||||
|
# 7h3) 量纲回归(「数据可信」"不该动作时不动作"用例,道A)主机侧单元测试(2026-10-25 加)
|
||||||
|
# 背景:台架曾长期缺"不该动作时不动作"的判据,码值/工程量错配曾以
|
||||||
|
# 恒假动作的形态混过验收。本钩子用真实链(adc_cal→ring→phasor→
|
||||||
|
# prot_elm)+ 显式换算的合成波把七类元件的量纲钉死:折算点被旁路
|
||||||
|
# 或量纲再错位,断言立刻翻转。files 覆盖链上全部生产源与两处编排。
|
||||||
|
- id: prot-dim-unit-test
|
||||||
|
name: 量纲回归单测(主机侧)
|
||||||
|
entry: python test/host/prot_dim/run_unit_test.py
|
||||||
|
language: system
|
||||||
|
files: ^(App/algo/(adc_cal|prot_elm|prot_set|phasor|sample_ring|freq)\.(c|h)|App/task/(adc_task|protect_task)\.c|test/host/prot_dim/.*|test/host/common/unit_test_harness\.py)$
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
# 7h4) 遥测快照层(全周傅氏相量发布侧,「数据可信」遥测快照发布,道D)主机侧单元测试(2026-10-25 加)
|
||||||
|
# 背景:遥测快照发布项的 DoD 一半是并发与结构类判据——双区切换无撕裂(撕裂窗
|
||||||
|
# 200 ms 量级,台架人工比对观测不到)、seq 只增、§4.5 布局与字段偏移
|
||||||
|
# (104 与 UI 直接按整体拷贝消费,破布局 = 破协议)、CLIPPED 品质汇总
|
||||||
|
# (削顶位图要等两点标定才真实出现)。主机侧以 #include 原样纳入
|
||||||
|
# meas/phasor/sample_ring 生产源,合成 9 通道帧序列一次性钉死;
|
||||||
|
# 读数与注入源比对类判据仍归台架(实测采样类)。files 覆盖被测源、
|
||||||
|
# 同链核(phasor/sample_ring)、编排方 adc_task 与 harness 本身。
|
||||||
|
- id: meas-unit-test
|
||||||
|
name: 遥测快照层单测(主机侧)
|
||||||
|
entry: python test/host/meas/run_unit_test.py
|
||||||
|
language: system
|
||||||
|
files: ^(App/algo/meas\.(c|h)|App/algo/(phasor|sample_ring)\.(c|h)|App/task/adc_task\.c|test/host/meas/.*|test/host/common/unit_test_harness\.py)$
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
# 7h5) 运行参数段(「数据可信」道D 交付项)主机侧单元测试(2026-10-25 加)
|
||||||
|
# 死区把守突发上报的量、104 整定段是现场唯一远程写入口:注册契约、
|
||||||
|
# 分档量程守卫、两段式、系数直通的合法带与生效值回显必须离线钉死。
|
||||||
|
- id: run-par-unit-test
|
||||||
|
name: 运行参数段单测(主机侧)
|
||||||
|
entry: python test/host/run_par/run_unit_test.py
|
||||||
|
language: system
|
||||||
|
files: ^(App/algo/run_par\.(c|h)|App/algo/adc_cal\.(c|h)|App/cfg/cfg_file(_store)?\.(c|h)|test/host/run_par/.*|test/host/common/unit_test_harness\.py)$
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
|
||||||
|
# 7i) 保护定值模型(定值校验和载体)主机侧单元测试(2026-10-11 加)
|
||||||
# 定值是保护动作的"输入宪法":范围校验、整表装载 all-or-nothing、
|
# 定值是保护动作的"输入宪法":范围校验、整表装载 all-or-nothing、
|
||||||
# 校验和封存-自检-闭锁(参考 tagPRS dwTagSum/dwTagSumN 模式)必须在
|
# 校验和封存-自检-闭锁(参考 tagPRS dwTagSum/dwTagSumN 模式)必须在
|
||||||
# 任何元件接线之前钉死——板端无法穷举越界定值注入,主机侧毫秒级全跑。
|
# 任何元件接线之前钉死——板端无法穷举越界定值注入,主机侧毫秒级全跑。
|
||||||
@@ -344,7 +451,7 @@ repos:
|
|||||||
files: ^(App/algo/prot_set\.(c|h)|test/host/prot_set/.*|test/host/common/unit_test_harness\.py)$
|
files: ^(App/algo/prot_set\.(c|h)|test/host/prot_set/.*|test/host/common/unit_test_harness\.py)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
# 7j) 保护元件框架(L3 P1 子集)主机侧单元测试(2026-10-11 加)
|
# 7j) 保护元件框架(保护判据 P1 子集)主机侧单元测试(2026-10-11 加)
|
||||||
# 判据核(滞回/定时限/欠量方向/闭锁联动/时基回绕)是纯逻辑,
|
# 判据核(滞回/定时限/欠量方向/闭锁联动/时基回绕)是纯逻辑,
|
||||||
# 板端复现需要标准源 + 故障注入,主机侧用合成测量序列即可穷举
|
# 板端复现需要标准源 + 故障注入,主机侧用合成测量序列即可穷举
|
||||||
# 1.2×/0.9× 验收口径(规划 §8)。
|
# 1.2×/0.9× 验收口径(规划 §8)。
|
||||||
@@ -355,10 +462,10 @@ repos:
|
|||||||
files: ^(App/algo/prot_elm\.(c|h)|App/algo/prot_set\.(c|h)|App/algo/phasor\.(c|h)|App/algo/freq\.(c|h)|test/host/prot_elm/.*|test/host/common/unit_test_harness\.py)$
|
files: ^(App/algo/prot_elm\.(c|h)|App/algo/prot_set\.(c|h)|App/algo/phasor\.(c|h)|App/algo/freq\.(c|h)|test/host/prot_elm/.*|test/host/common/unit_test_harness\.py)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
# 7j2) 频率测量层(L2.5,B8)主机侧单元测试(2026-10-12 加)
|
# 7j2) 频率测量层(测频与遥测快照,B8)主机侧单元测试(2026-10-12 加)
|
||||||
# 自适应窗测频(K_eff 对齐负序泄漏纹波周期)的 J1~J6 判据
|
# 自适应窗测频(K_eff 对齐负序泄漏纹波周期)的 J1~J6 判据
|
||||||
# (扫频/噪声/收敛/阶跃/浮空 valid=0/钳制恢复)是纯合成信号
|
# (扫频/噪声/收敛/阶跃/浮空 valid=0/钳制恢复)是纯合成信号
|
||||||
# 可穷举的精度契约;板端无标准源照不出来(规划 §3.3 L3-5)。
|
# 可穷举的精度契约;板端无标准源照不出来(规划 §3.3 过频 / 低频)。
|
||||||
- id: freq-unit-test
|
- id: freq-unit-test
|
||||||
name: 频率测量单测(主机侧)
|
name: 频率测量单测(主机侧)
|
||||||
entry: python test/host/freq/run_unit_test.py
|
entry: python test/host/freq/run_unit_test.py
|
||||||
@@ -366,7 +473,7 @@ repos:
|
|||||||
files: ^(App/algo/freq\.(c|h)|App/algo/phasor\.(c|h)|App/algo/sample_ring\.(c|h)|test/host/freq/.*|test/host/common/unit_test_harness\.py)$
|
files: ^(App/algo/freq\.(c|h)|App/algo/phasor\.(c|h)|App/algo/sample_ring\.(c|h)|test/host/freq/.*|test/host/common/unit_test_harness\.py)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
# 7k) SHA-256 主机侧单元测试(2026-10-11 加,N-19 FTP 鉴权)
|
# 7k) SHA-256 主机侧单元测试(2026-10-11 加,FTP 鉴权)
|
||||||
# sha256 是 FTP pwd_hash 校验的地基——哈希算错是"正确口令被拒 /
|
# sha256 是 FTP pwd_hash 校验的地基——哈希算错是"正确口令被拒 /
|
||||||
# 错误口令放行"的静默语义错误,板端只见 530/230 看不出根因;
|
# 错误口令放行"的静默语义错误,板端只见 530/230 看不出根因;
|
||||||
# FIPS 180-4 标准向量(空串/abc/56B 补位跨界/1MB)+ hex_eq 非法输入
|
# FIPS 180-4 标准向量(空串/abc/56B 补位跨界/1MB)+ hex_eq 非法输入
|
||||||
@@ -378,7 +485,7 @@ repos:
|
|||||||
files: ^(App/util/sha256\.(c|h)|test/host/sha256/.*|test/host/common/unit_test_harness\.py)$
|
files: ^(App/util/sha256\.(c|h)|test/host/sha256/.*|test/host/common/unit_test_harness\.py)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
# 7l) FTP 鉴权状态机主机侧单元测试(2026-10-11 加,N-19)
|
# 7l) FTP 鉴权状态机主机侧单元测试(2026-10-11 加,FTP 鉴权)
|
||||||
# 锁定 60s/解锁/成功清零/tick 32 位回绕是"防口令爆破"契约核心,
|
# 锁定 60s/解锁/成功清零/tick 32 位回绕是"防口令爆破"契约核心,
|
||||||
# 板端每验一条要真等 60s、回绕要跑满 49.7 天;lftpd_auth 纯函数
|
# 板端每验一条要真等 60s、回绕要跑满 49.7 天;lftpd_auth 纯函数
|
||||||
# 设计(tick 参数注入)就是为了让这些分支主机侧毫秒级穷举。
|
# 设计(tick 参数注入)就是为了让这些分支主机侧毫秒级穷举。
|
||||||
@@ -391,22 +498,23 @@ repos:
|
|||||||
|
|
||||||
# 7m) IEC 60870-5-104 被控站协议栈主机侧单元测试(2026-10-12 加,B4)
|
# 7m) IEC 60870-5-104 被控站协议栈主机侧单元测试(2026-10-12 加,B4)
|
||||||
# APCI 重组/STARTDT/总召分片/COV/k-w-t1~t3/遥控定值两段式/C_CS/错误
|
# APCI 重组/STARTDT/总召分片/COV/k-w-t1~t3/遥控定值两段式/C_CS/错误
|
||||||
# 应答——link 层 tick 注入 + pts 数据源 vtable 抽象就是为 L0.5 全离线
|
# 应答——link 层 tick 注入 + pts 数据源 vtable 抽象就是为主机侧纯逻辑单测全离线
|
||||||
# 穷举设计(A-4 约束 1);板端复现这些分支要真主站+真等待。
|
# 穷举设计(分层约束);板端复现这些分支要真主站+真等待。
|
||||||
# 触发面含数据源依赖(prot_set/prot_elm/phasor/sample_ring)——测试
|
# 触发面含数据源依赖(prot_set/prot_elm/phasor/sample_ring)——测试
|
||||||
# 直接 #include 这些生产源,签名变了要立刻在这里暴露。
|
# 直接 #include 这些生产源,签名变了要立刻在这里暴露。
|
||||||
- id: iec104-unit-test
|
- id: iec104-unit-test
|
||||||
name: IEC104 协议栈单测(主机侧)
|
name: IEC104 协议栈单测(主机侧)
|
||||||
entry: python test/host/iec104/run_unit_test.py
|
entry: python test/host/iec104/run_unit_test.py
|
||||||
language: system
|
language: system
|
||||||
files: ^(App/protocol/iec104/iec104_(asdu|pts|link)\.(c|h)|App/algo/(prot_set|prot_elm|phasor|sample_ring|adc_input|freq)\.(c|h)|test/host/iec104/.*|test/host/common/unit_test_harness\.py)$
|
# 运行参数段落地起本 TU 链入真实 cfg_file/adc_cal/run_par(板端真源链),触发面同步扩
|
||||||
|
files: ^(App/protocol/iec104/iec104_(asdu|pts|link)\.(c|h)|App/algo/(prot_set|prot_elm|phasor|sample_ring|adc_input|freq|meas|adc_cal|run_par)\.(c|h)|App/cfg/cfg_file(_store)?\.(c|h)|test/host/iec104/.*|test/host/common/unit_test_harness\.py)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
# 7m2) SOE 动作记录编解码主机侧单元测试(2026-10-12 加,B9)
|
# 7m2) SOE 动作记录编解码主机侧单元测试(2026-10-12 加,B9)
|
||||||
# 64 B 定长记录(类型/seq/CP56Time2a/epoch/校验和)是磁盘上的
|
# 64 B 定长记录(类型/seq/CP56Time2a/epoch/校验和)是磁盘上的
|
||||||
# 字节级契约——编解码错了要 FTP 下载+解码才看得见;纯 codec
|
# 字节级契约——编解码错了要 FTP 下载+解码才看得见;纯 codec
|
||||||
# 零平台依赖(soe_store 胶水 SIM_EXEMPT),主机侧穷举往返/
|
# 零平台依赖(soe_store 胶水 SIM_EXEMPT),主机侧穷举往返/
|
||||||
# 截断/坏校验/IV 位/seq 连续性(规划 §3.4 L4)。
|
# 截断/坏校验/IV 位/seq 连续性(规划 §3.4 出口与记录)。
|
||||||
- id: soe-unit-test
|
- id: soe-unit-test
|
||||||
name: SOE 动作记录编解码单测(主机侧)
|
name: SOE 动作记录编解码单测(主机侧)
|
||||||
entry: python test/host/soe/run_unit_test.py
|
entry: python test/host/soe/run_unit_test.py
|
||||||
@@ -414,13 +522,49 @@ repos:
|
|||||||
files: ^(App/log/soe_rec\.(c|h)|App/log/soe_store\.(c|h)|test/host/soe/.*|test/host/common/unit_test_harness\.py)$
|
files: ^(App/log/soe_rec\.(c|h)|App/log/soe_store\.(c|h)|test/host/soe/.*|test/host/common/unit_test_harness\.py)$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
|
# 7n) dhara NAND 移植层(nand_ftl)主机侧单元测试(2026-10-15 加)
|
||||||
|
# 背景:移植层六个 HAL 入口是**纯地址算术**,此前对越界页/块号(含
|
||||||
|
# DHARA_PAGE_NONE 哨兵)**不做任何上界校验**——加偏移送进 SPI 命令后被地址位
|
||||||
|
# 截断成某个真实页 → 静默读写/搬走无辜数据;而 is_free 旧实现只看页首 64 B,
|
||||||
|
# 会把"页首 0xFF、后段有数据"的已编程页误判空闲 → dhara 往已编程页再编程
|
||||||
|
# = 数据丢失。两类缺陷真机**极难复现**(要恰好撞上特定页形态 / 被污染的 meta),
|
||||||
|
# 板端只能表现为"某次复位后丢数据",无从定位。
|
||||||
|
# 本钩子用 MSVC 离线编译真实 nand_ftl.c(#include 纳入)+ **RAM 假芯片**
|
||||||
|
# (32 块 x 64 页 x 2048 B):把"双编程"做成可数事件、记录全部
|
||||||
|
# 访问次数,于是"越界不产生任何芯片访问""越界标坏块次数为 0"可机械断言;
|
||||||
|
# 并内联一份"只看页首 64 B"的旧算法做对照,证明这组断言真能咬人。
|
||||||
|
# 触发面含 dhara 本体(测试一并 #include map.c/journal.c 跑真链路场景)。
|
||||||
|
- id: nand-port-unit-test
|
||||||
|
name: dhara NAND 移植层单测(主机侧)
|
||||||
|
entry: python test/host/nand_port/run_unit_test.py
|
||||||
|
language: system
|
||||||
|
files: ^(Drivers/BSP/GD5F2GQ5UE/nand_ftl\.(c|h)|Drivers/BSP/GD5F2GQ5UE/gd5f2gq5ue\.h|Lib/dhara/(map|journal|nand|error|bytes)\.(c|h)|test/host/nand_port/.*|test/host/common/unit_test_harness\.py)$
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
# 7z) UI 数据快照层(道E task-14)主机侧单元测试(2026-10-25 加)
|
||||||
|
# 背景:ui_data 是"页面看到的整个世界",但 ui-golden 只覆盖已接线的页面——
|
||||||
|
# 遥信 27 点、定值 40 项、遥测未接线的通道错了不会让任何参考图变红。本钩子
|
||||||
|
# 用 MSVC 离线编译真实 ui_data.c 与 ui_w_soe.c(#include 纳入,零桩),把两件事
|
||||||
|
# 钉死:① 快照存储语义(首帧前读 -1 / 序号由本层盖章 / 三通道互不串台 /
|
||||||
|
# NULL 防御);② 跨层不透明下标与显示名映射的**现场一致性**——测试单元在同一
|
||||||
|
# TU 里同时 include BSP 侧与 App/algo、App/log 真头,逐项比对 adc_semantic_t /
|
||||||
|
# prot_set_id_t / soe_type_t / prot_elm_id_t(解耦换来的"魔法下标"风险,口径
|
||||||
|
# docs/UI设计/UI_数据供给设计说明.md §9.4;SOE 类型含未标定闭锁=7/复归=8)。
|
||||||
|
# files 覆盖被测两件 + 编号真源四头 + 页面消费面(ui_content_page/ui_menu*)+ harness。
|
||||||
|
- id: ui-data-unit-test
|
||||||
|
name: UI 数据快照层单测(主机侧)
|
||||||
|
entry: python test/host/ui_data/run_unit_test.py
|
||||||
|
language: system
|
||||||
|
files: ^(Drivers/BSP/UI/(ui_data|ui_w_soe|ui_w_records|ui_content_page|ui_menu)\.(c|h)|App/app/ui_data_feed\.(c|h)|App/algo/(adc_input|prot_set|prot_elm)\.(c|h)|App/log/soe_rec\.(c|h)|test/host/ui_data/.*|test/host/common/unit_test_harness\.py)$
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
# 8) 测试套件开关门禁(2026-10-01 加)
|
# 8) 测试套件开关门禁(2026-10-01 加)
|
||||||
# 背景:test_config.h 的 TEST_SUITE_* 决定固件里编入哪个测试套件,
|
# 背景:test_config.h 的 TEST_SUITE_* 决定固件里编入哪个测试套件,
|
||||||
# 全部注释 = 产品构建。但此前**无任何门禁检查**——实测已提交的 HEAD 里
|
# 全部注释 = 产品构建。但此前**无任何门禁检查**——实测已提交的 HEAD 里
|
||||||
# TEST_SUITE_CH395F 就是开启状态(.map 含 34 处 test 代码),即仓库里
|
# TEST_SUITE_CH395F 就是开启状态(.map 含 34 处 test 代码),即仓库里
|
||||||
# 躺着的是测试固件。本钩子抓这个静默漏网:默认要求产品构建,跑套件回归
|
# 躺着的是测试固件。本钩子抓这个静默漏网:默认要求产品构建,跑套件回归
|
||||||
# 确需提交测试构建时用 `--allow-test-build` / ALLOW_TEST_BUILD=1 显式放行。
|
# 确需提交测试构建时用 `--allow-test-build` / ALLOW_TEST_BUILD=1 显式放行。
|
||||||
# 详见 docs/任务规划/登记簿.md T-1
|
# 详见 docs/任务规划/登记簿.md
|
||||||
- id: test-suite-switch
|
- id: test-suite-switch
|
||||||
name: 测试套件开关=产品构建
|
name: 测试套件开关=产品构建
|
||||||
entry: python scripts/check_test_suite.py
|
entry: python scripts/check_test_suite.py
|
||||||
@@ -428,7 +572,7 @@ repos:
|
|||||||
files: ^test/board/test_config\.h$
|
files: ^test/board/test_config\.h$
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
# 9) 双端注册反向校验(2026-10-12 加,登记簿 N-23 / 审查报告 N-06 残余)
|
# 9) 双端注册反向校验(2026-10-12 加,登记簿条目「rs485_task 白名单缺口」)
|
||||||
# 背景:新 .c 必须手动注册两处(`MDK-ARM/*.uvprojx` + `simulator/CMakeLists.txt`)
|
# 背景:新 .c 必须手动注册两处(`MDK-ARM/*.uvprojx` + `simulator/CMakeLists.txt`)
|
||||||
# 才会被编译;历史上 `App/task/rs485_task.c`、`Drivers/BSP/NET/lftpd/lftpd_log.c`
|
# 才会被编译;历史上 `App/task/rs485_task.c`、`Drivers/BSP/NET/lftpd/lftpd_log.c`
|
||||||
# **两处都没注册** = 写了却从不参与任何一端编译的**死代码**,误导读者以为功能已生效。
|
# **两处都没注册** = 写了却从不参与任何一端编译的**死代码**,误导读者以为功能已生效。
|
||||||
@@ -445,17 +589,277 @@ repos:
|
|||||||
language: system
|
language: system
|
||||||
files: (\.c$|\.uvprojx$|CMakeLists\.txt$|^scripts/check_registration\.py$)
|
files: (\.c$|\.uvprojx$|CMakeLists\.txt$|^scripts/check_registration\.py$)
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
# 10) RAM 预算 92% 阈值门禁(2026-10-12 加,登记簿 N-27 前半 / 审查报告 §5.5 三件套)
|
# 10) RAM 预算 92% 阈值门禁(2026-10-12 加,登记簿条目「缩堆迁 CCM 后半(暂缓)」的台账/门禁那半 / 审查报告 §5.5 三件套)
|
||||||
# 背景:报告 §5.5 第 1 条要求"超阈值即失败 + Top-10 附表"——此前 mem-numbers 只防
|
# 背景:报告 §5.5 第 1 条要求"超阈值即失败 + Top-10 附表"——此前 mem-numbers 只防
|
||||||
# "文档数字漂移",**没有任何机械检查拦"占用超阈值"**;台账三件套(ram_baseline.py +
|
# "文档数字漂移",**没有任何机械检查拦"占用超阈值"**;台账三件套(ram_baseline.py +
|
||||||
# ram_baseline.tsv + docs/内存管理/RAM预算台账.md)落地后,本钩子把 92% 红线机械化。
|
# ram_baseline.tsv + docs/内存管理/RAM预算台账.md)落地后,本钩子把 92% 红线机械化。
|
||||||
# 逻辑:RW_IRAM1/RW_CCM 任一 >92% → FAIL + Top-10 RW+ZI 对象表;台账受管区漂移 → FAIL
|
# 逻辑:RW_IRAM1/RW_CCM 任一 >92% → FAIL + Top-10 RW+ZI 对象表;台账受管区漂移 → FAIL
|
||||||
# (提示 --write);无 .map → 自适应跳过(同 mem-numbers 在 CI 的行为,盲区登记 A-5 残余)。
|
# (提示 --write);无 .map → 自适应跳过(同 mem-numbers 在 CI 的行为,盲区已登记)。
|
||||||
# ⚠ pre-commit 4.x 无 `always: true` → files 覆盖所有能改变 RAM 的触发面:
|
# ⚠ pre-commit 4.x 无 `always: true` → files 覆盖所有能改变 RAM 的触发面:
|
||||||
# 全部自研代码目录 + lv_conf.h + 测试板配置 + 脚本/tsv/台账自身 + 本配置文件。
|
# 全部自研代码目录 + lv_conf.h + 测试板配置 + 脚本/tsv/台账自身 + 本配置文件。
|
||||||
- id: mem-budget
|
- id: mem-budget
|
||||||
name: RAM 预算 92% 阈值门禁 (N-27)
|
name: RAM 预算 92% 阈值门禁
|
||||||
entry: python scripts/ram_baseline.py --gate
|
entry: python scripts/ram_baseline.py --gate
|
||||||
language: system
|
language: system
|
||||||
files: ^(App/|Drivers/BSP/|Src/|Inc/|Lib/lv_conf\.h|test/board/|scripts/ram_baseline\.|docs/内存管理/|\.pre-commit-config\.yaml$)
|
files: ^(App/|Drivers/BSP/|Src/|Inc/|Lib/lv_conf\.h|test/board/|scripts/ram_baseline\.|docs/内存管理/|\.pre-commit-config\.yaml$)
|
||||||
pass_filenames: false
|
pass_filenames: false
|
||||||
|
|
||||||
|
# 11) UI 字符集差集检查(2026-10-15 加,报告 §6.5 的 UI 字符集与像素回归门禁残余 / §7 交付物清单"字符集差集检查"行)
|
||||||
|
# 背景:`ui_font_cn_12.c` 是**子集**字体,只含 `ui_cn_chars.txt` 列出的码位。
|
||||||
|
# 生成链 UI 源码 --extract_cn--> ui_cn_chars.txt --make_font--> 字体 .c,其中
|
||||||
|
# verify_coverage 只在**生成期**校验"txt ⊆ cmap";改完 UI 字符串忘了重跑
|
||||||
|
# font-regen 就提交 → 缺字上机渲染为空白(白框),无日志无报错只能肉眼截图发现
|
||||||
|
# (font-regen skill 的"缺字渲染为空白"约定,此前纯靠自律)。本钩子把它机械化:
|
||||||
|
# A. 源码在用 vs txt 缺 → FAIL(白框方向);多 → WARN(只费 Flash,--strict 升级)
|
||||||
|
# B. txt vs 字体 cmap 缺 → FAIL(lv_font_conv 对缺字形/低码位**静默丢字**)
|
||||||
|
# A 过 + B 过 ⟹ 字体 ⊇ 源码每个在用字。提取与 cmap 解析**复用** tools/font_gen
|
||||||
|
# 两个生成器的同一套规则(importlib 直载 extract_cn.SCAN/collect + make_font.
|
||||||
|
# verify_coverage),杜绝第二套解析规则漂移(§5.5 同类教训)。
|
||||||
|
# ⚠ pre-commit 4.x 已移除 `always: true` → files 必须覆盖全部触发面:
|
||||||
|
# UI 源码(含字体 .c 自身)+ ui_task.{c,h} + 字符集 txt + 两个生成器 + 本脚本。
|
||||||
|
- id: font-charset
|
||||||
|
name: UI 字符集差集检查(防缺字白框)
|
||||||
|
entry: python scripts/check_font_charset.py
|
||||||
|
language: system
|
||||||
|
files: ^(Drivers/BSP/UI/|App/task/ui_task\.(c|h)$|ui_cn_chars\.txt$|tools/font_gen/|scripts/check_font_charset\.py$)
|
||||||
|
pass_filenames: false
|
||||||
|
# 12a) BOM 漂移门禁(2026-10-25 道E task-20)
|
||||||
|
# 背景:AGENTS 铁律第 2 条——编辑工具会**静默剥掉**文件原有 BOM,ARMCC 于是按 CP936
|
||||||
|
# 解码带中文的源文件,报 #870-D / #8 / #274 一类**假语法错**(报错位置与内容都不指向
|
||||||
|
# 真因),而 MSVC(模拟器侧)与既有钩子全照不出来。2026-10-25 一天内道E 踩 4 次、
|
||||||
|
# 道B 批 1 踩 1 次,此前全靠人工逐文件比 HEAD 兜底——人工审计只防得住记得做的人。
|
||||||
|
# 判定:HEAD 有 EF BB BF 而工作区没有 → FAIL。两条刻意不报(新增文件无 BOM、HEAD 无
|
||||||
|
# BOM 而工作区加了 BOM)与一条刻意不做(不自动补:整文件重写正是本仓更大的坑)都
|
||||||
|
# 写在脚本头部。files 与 AGENTS 名单、脚本内 BOM_LIST **三处互指**,脚本每次跑核一次,
|
||||||
|
# 单独改任何一处都会在别人的提交里被拦下——名单散在三处而无人同步等于没有名单。
|
||||||
|
- id: bom-guard
|
||||||
|
name: BOM 漂移检查(带 BOM 文件被剥掉即拦)
|
||||||
|
entry: python scripts/check_bom_drift.py
|
||||||
|
language: system
|
||||||
|
files: ^(Drivers/BSP/UI/|App/task/ui_task\.c$|App/cfg/cfg_file\.c$|Lib/lv_conf\.h$)
|
||||||
|
|
||||||
|
# 12b) 定值查看行表一致性(2026-10-25 道E task-20 追加批;脚本随 task-15 批 3 落)
|
||||||
|
# 核 Drivers/BSP/UI/ui_w_setview.c 的四张视图表与 App/algo/prot_set 的枚举序、
|
||||||
|
# g_prot_set_meta 上下限是否仍是同一份口径:id 恰好覆盖 0..39 各一次、数值行
|
||||||
|
# min/max 等于 meta 换算、kind 与 meta.is_ctl 一致、单位纯 ASCII、小数位查表与行表
|
||||||
|
# 一致。为什么必须机械化:BSP 侧看不到 App 头,行表里的 id 是裸数字 + 行尾注名,
|
||||||
|
# 这条对齐在编译期**物理上无法自证**;而页面上只有当前可见的 8 行会被像素断言看到,
|
||||||
|
# 错位要翻到第 2、3 页才可能被人发现(发现了也已被基线钉死)。
|
||||||
|
# ⚠ 跨分支先后:脚本文件随 task-15 分支合入主干;在此之前 files 永不命中 → 钩子自然
|
||||||
|
# skip(skip 不是失败),合并后自动生效,不需要任何人再动这条配置。
|
||||||
|
- id: setview-check
|
||||||
|
name: 定值查看行表与 prot_set 枚举/上下限一致性
|
||||||
|
entry: python tools/setview_check/check_setview_limits.py
|
||||||
|
language: system
|
||||||
|
files: ^(tools/setview_check/|Drivers/BSP/UI/ui_w_setview\.(c|h)$)
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
# 12) UI 像素回归门禁(2026-10-20 加,W6 的 UI 字符集与像素回归门禁收尾第三件 / 报告 §6.5 同项、§6.1 golden 行)
|
||||||
|
# 背景:golden 两脚本(check_menu_rows.py / check_content_pages.py)早已存在且一直在
|
||||||
|
# 人工跑,但**从未进自动门禁**——"有基线"不等于"有门禁"(报告 §6.1 原文即自认此缺口)。
|
||||||
|
# 形态选型依据实测:改一个 UI 源文件后 build_sim 增量重编 + 两条 golden 合计 **2~4 秒**,
|
||||||
|
# 成本可接受 → 走自动门禁(若实测要几分钟,则应改为文档化人工门禁,一个逼人
|
||||||
|
# --no-verify 的门禁等于没有门禁)。
|
||||||
|
# 有牙实测:给 ui_page_dc.c 的 .top_y 注入 +3 位移 → build_sim 重编 →
|
||||||
|
# golden FAIL 且点名 `[dc] FAIL 与参考页不一致(1366 px 差异)`、退出码 1;还原后 diff 干净。
|
||||||
|
# ⚠ 两条 golden 按"整棵菜单树 + 全量参考图"工作、不接受文件参数,故包装器有意忽略
|
||||||
|
# argv 并 pass_filenames: false(pre-commit 传的变更名对它无意义)。
|
||||||
|
# ⚠ 参考图 simulator/tests/ref/*.bin 是判据本身,改动它等于改判据——它已入库,
|
||||||
|
# 评审时看 `git diff -- simulator/tests/ref` 必须逐页过目并在提交正文说明;
|
||||||
|
# 任何时候都不得用 --update 刷基线来"让钩子变绿"。
|
||||||
|
# ⚠ pre-commit 4.x 无 `always: true` → files 必须覆盖所有能改变像素的触发面。
|
||||||
|
# ⚠ 已知瞬时现象:首次实测时在"连续注入/还原的紧邻重建"之间偶发失败一次,
|
||||||
|
# 疑似 MSBuild 节点争用;此后 3 次干净树连跑均绿。判据:单次失败先复跑一次再看,
|
||||||
|
# 连续两次失败才当真(不许直接 --no-verify 绕过)。
|
||||||
|
- id: ui-golden
|
||||||
|
name: UI 像素回归(菜单 8 状态 + 内容页 74 张 黄金参考)
|
||||||
|
entry: python scripts/check_ui_golden.py
|
||||||
|
language: system
|
||||||
|
files: ^(Drivers/BSP/UI/|App/task/ui_task\.(c|h)$|simulator/(tests|sim|src)/|simulator/main\.c$|simulator/CMakeLists\.txt$|simulator/lv_conf\.h$|scripts/check_ui_golden\.py$|\.pre-commit-config\.yaml$)
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
# 12a) 板端 UI 导航器认屏自检(2026-10-25 道E task-25)
|
||||||
|
# 背景:大窗实测——靠预置键序盲走到深页时,9 帧里只有 3 帧真是它声称的态;
|
||||||
|
# KEY 的 ACK 是空应答、不携带状态,所以**画面是唯一事实源**。判据脚本
|
||||||
|
# scripts/board_ui_nav.py 据此改成闭环:每按一键重新认屏再决定下一个键。
|
||||||
|
# 本钩子跑它的**快检档** --selftest-quick:只核"认屏认得对"(接线当时 18 张、现 74 张黄金参考 +
|
||||||
|
# 真机留档帧,目录不在版本控制里、缺就整段跳过),秒级、无构建产物依赖。
|
||||||
|
# ⚠ 不挂完整 --selftest:第三段闭环需要 lvgl_sim.exe(构建产物,新工作树里没有)
|
||||||
|
# —— 正是同一天 mem-numbers 缺 .map 那课的形状;脚本对缺 exe 报 FAIL 并给出
|
||||||
|
# 可执行提示,不"跳过当通过"。合入前 / 上板前手工跑一次完整档:
|
||||||
|
# python scripts/board_ui_nav.py --selftest(约 30 s,含全绑定页闭环)。
|
||||||
|
# ⚠ 触发面刻意只两块(脚本自身、黄金参考帧):字体或菜单树变动**不**触发它,
|
||||||
|
# 那两类的守卫分别是 font-charset / ui-golden / registration-check 与 id 侧;
|
||||||
|
# 把每个 UI 提交都拉去跑导航器,只会让人习惯等一个与本改动无关的钩子。
|
||||||
|
- id: uinav-selftest
|
||||||
|
name: 板端 UI 导航器认屏自检(快检:黄金参考帧分类)
|
||||||
|
entry: python scripts/board_ui_nav.py --selftest-quick
|
||||||
|
language: system
|
||||||
|
files: ^(scripts/board_ui_nav\.py$|simulator/tests/ref/.*\.bin$)
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
# 13) 编号门禁的历史:2026-10-20 起曾有一版「全库语料审计」(规范 §8 的 8 项校验 +
|
||||||
|
# 99 族 registry + 别名表)。它 2026-10-28 退役——维护成本高过收益(近 80 笔提交
|
||||||
|
# 61 笔被编号体系牵连),脚本与语料整体移入 `scripts/archive/id_gate/`
|
||||||
|
# (保留可复现性,**不再进提交门禁**)。退役路线与判据替换见规范 §8.5。
|
||||||
|
|
||||||
|
# 14) 编号门禁(2026-10-28 上线只查新增行那一道;2026-10-29 用户裁定**整体退役**,钩子已摘)
|
||||||
|
# 退役后编号形态没有机器面:判据回到 docs/编号规范.md §1 三句自问 + §4.1 族总表 + 评审。
|
||||||
|
# 脚本移入 scripts/archive/id_gate/check_ids_new.py(手工可跑,--self-test 保留),留档说明
|
||||||
|
# → docs/门禁说明/id-check-new.md;手工普查与恢复机器面 → docs/编号规范.md §8.5。
|
||||||
|
# ⚠ 计数口径:本条摘除使 YAML 解析条目 52 → **51**,须与 AGENTS.md 的「共 N 个钩子」、
|
||||||
|
# docs/门禁说明/README.md §2 标题三数同源同批改(自查命令见该条;别在注释里写数钩子的字面量)。
|
||||||
|
|
||||||
|
# 14) 分层方向门禁(2026-10-22 加,结构债的"防扩大"那一半)
|
||||||
|
# 背景:AGENTS.md 与 项目架构总览.md 早就写着"六层单向依赖",但**从来没有机械检查**
|
||||||
|
# ——Drivers/BSP/ 里可以随手 include "cfg_file.h" 而无人报警。后果可测量:2026-09 审查
|
||||||
|
# 报告记"NETCFG 一处",2026-10-22 实测已长到 **8 个文件 9 处**。边界的静默腐蚀只能
|
||||||
|
# 靠门禁止住,所以本钩子做**增量拦截**(与 id-check 同构):现状 6 条钉成 BASELINE
|
||||||
|
# (只减不增,没有 --write 往里加),新出现的 (BSP 文件, App 头) 组合直接 FAIL;
|
||||||
|
# 3 条横切基础设施头走 ALLOW 永久豁免(日志后端 / 时间源 / 纯算法工具——它们是
|
||||||
|
# 任何层都要用的叶子能力,不是上层服务)。修完存量后跑 --prune 会点名让你收紧基线,
|
||||||
|
# 于是"还欠几条"变成可见进度,而不是文档里的一句口号。
|
||||||
|
# 注:本钩子只拦"静态 include 方向";**运行时的属主纪律本来就没错**(UI 只投递
|
||||||
|
# net_cfg_save_request(),写盘在 defaultTask),别把它当缺陷读。
|
||||||
|
# 扫描范围恒为全库(要跨文件建 App 头索引),故 pass_filenames: false。
|
||||||
|
- id: layer-deps
|
||||||
|
name: 分层方向(BSP 不得反向 include App)
|
||||||
|
entry: python scripts/check_layer_deps.py
|
||||||
|
language: system
|
||||||
|
files: ^Drivers/BSP/|^scripts/check_layer_deps\.py$|^\.pre-commit-config\.yaml$
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
# 15) BSP 错误码与超时命名约定(机械检查那一半)。
|
||||||
|
# 四项检查:公开 API 不得用 HAL_StatusTypeDef/bool 当错误通道(谓词 is_/has_/can_/valid
|
||||||
|
# 不在此列);*_TIMEOUT 宏不得取负值(名字叫 TIMEOUT 其实是返回码);非 void 的公开 API
|
||||||
|
# 必须有「返回值:」注释;**返回错误码的函数不得裸丢弃返回值**(接住或显式 (void)——实测
|
||||||
|
# ARMCC v5 与 cppcheck 都不报这类,只有这条会响)。存量钉 BASELINE、只减不增(2026-10-23
|
||||||
|
# 首清点 19 条 → 已还 18;新增一项又暴露 37 条 → **现 38 条**:37 丢弃 + 1 个 bool 待改签名),
|
||||||
|
# 修完跑 --prune 点名收紧基线。约定正文 = Drivers/BSP/bsp_err.h,规则说明 =
|
||||||
|
# scripts/check_bsp_err.py 文件头,逐项详解(含限制与盲区)= docs/门禁说明/bsp-err.md。
|
||||||
|
# 恒扫全库(要按 git 清单建公开头索引),故 pass_filenames: false。
|
||||||
|
- id: bsp-err
|
||||||
|
name: BSP 错误码与超时命名约定
|
||||||
|
entry: python scripts/check_bsp_err.py
|
||||||
|
language: system
|
||||||
|
files: ^Drivers/BSP/|^scripts/check_bsp_err\.py$|^\.pre-commit-config\.yaml$
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
# 8) 主机单测覆盖率总闸(2026-10-24 接线,用户裁定选 FAIL 语义)
|
||||||
|
# 为什么现在才挂:接线前本文件只有 16 个 *-unit-test 钩子,而仓库已有 28 套主机单测——
|
||||||
|
# P1 新增的 12 套不在任何提交钩子里。原先"再加一份全量插桩等于同一件事做两遍"
|
||||||
|
# 这条理由到现在已经**反了**(现状是有一遍没做),所以必须挂;缺口此前如实登记在
|
||||||
|
# docs/测试说明/单元测试/主机单测覆盖率口径.md §8。
|
||||||
|
# 为什么挂这一根而不是补 12 个 per-module 钩子:本钩子当时跑**全部 28 套**(现值 47 套,台账见
|
||||||
|
# docs/门禁说明/host-unit-tests.md §6 第 4 条),并且守 §5 的
|
||||||
|
# 回退台账(某套被删、或某文件的已覆盖行变少 → [REGRESS] 直接红)。这是 per-module
|
||||||
|
# 钩子做不到的——删掉一套测试,它那根钩子只会因为"没有文件要检查"而静默跳过。
|
||||||
|
# ⚠ FAIL 语义(用户裁定 2026-10-24):找不到 mscoverage 时脚本退出码 2 → 本钩子**失败**,
|
||||||
|
# 不做 skip。理由:skip 等于造一根会静默失效的门禁(与 pre-commit 4.x 移除 always: true
|
||||||
|
# 是同一类坑);宁可在没装组件的机器上被挡一次、看清 [ENV] 那行提示,也不要一根假绿的闸。
|
||||||
|
# 本机与 CI runner 同一台;实测两份 VS 安装都自带该 exe(口径 §7 第 8 条已按实测更正)。
|
||||||
|
# 口径 / 分母 / 基线 / 盲区详解都在 docs/测试说明/单元测试/主机单测覆盖率口径.md,本处只留接线事实。
|
||||||
|
# 触发面**含门禁自身**(.pre-commit-config.yaml):不带自身就等于谁改坏这根闸的 files:
|
||||||
|
# 模式,它就再也不跑且无人察觉(同 lv-conf-sync / bsp-err 的既有做法)。
|
||||||
|
# ⚠ stages: [manual](2026-10-28 用户裁定):本钩子跑光全部 47 套单测(41 套时实测 ~105 s,套件数
|
||||||
|
# 涨了耗时就涨,47 套这轮未重测耗时;
|
||||||
|
# 占一次带代码提交耗时的三分之二)→ 不压在提交期,由**远端 CI** 强制
|
||||||
|
# (.gitea/workflows/ci.yml 的「手动阶段门禁(覆盖率总闸 + 全量 cppcheck)」步),本地等价入口 =
|
||||||
|
# scripts/ci_local.py(manual 段);手工复跑:
|
||||||
|
# python -m pre_commit run --all-files --hook-stage manual test-coverage
|
||||||
|
# 另:此前它没写 stages: → 默认属于所有阶段,同一次提交输出里出现两次(commit-msg
|
||||||
|
# 阶段那遍几乎必然 Skipped,只看结尾会把实跑读成"没跑")。固定 manual 阶段后不再有这一坑。
|
||||||
|
- id: test-coverage
|
||||||
|
name: 主机单测覆盖率(增量拦截:已覆盖行只增不减)
|
||||||
|
stages: [manual]
|
||||||
|
entry: python scripts/check_test_coverage.py
|
||||||
|
language: system
|
||||||
|
files: ^(App/|Drivers/BSP/|test/host/|scripts/check_test_coverage\.py|scripts/test_coverage_baseline\.json|\.pre-commit-config\.yaml$)
|
||||||
|
pass_filenames: false
|
||||||
|
|
||||||
|
# 16) 生成器幂等性(2026-10-26 加,task-18;判据正文 docs/文档规范.md §4.4,索引行见 docs/门禁说明/README.md)
|
||||||
|
# 背景(实证,不是预防性):0f7b5a87 改了 tools/menu_gen/gen_menu.py 的**模板文字**却没重跑
|
||||||
|
# 生成物 → HEAD 里 Drivers/BSP/UI/ui_menu_tab.h 与模板不一致,后来是别人重跑生成器才被顺带
|
||||||
|
# 纠正(属主确认以生成器输出为准)。接线前实测又扫出 **5 张 docs/images/*.svg** 与各自生成器
|
||||||
|
# 不一致(文档挪目录后 rename_ref.py 只改了 tools/ 里的引用路径、产物没跟着重跑)——生成物
|
||||||
|
# 比生成器旧是**系统性**缺口。隐蔽之处:改生成器的人看不到任何 diff(脚本改动编译、单测全过),
|
||||||
|
# 下一个**碰巧**重跑的人却看到一段「不是我改的」改动 → 要么连带提交、要么 revert 掉真修复。
|
||||||
|
# 判定:把声明的输入与生成器脚本一起拷进**系统临时目录**重跑(三个生成器都按 __file__ 的三级
|
||||||
|
# 父目录定位仓库根 → 只会写 temp),再逐字节比对 temp 与工作区:DIFF / NEW / 生成器报错 = FAIL。
|
||||||
|
# **仓库一个字节都不写**,因此不需要还原、也不会 stash 别人在飞的改动(多人同树可安全跑)。
|
||||||
|
# 覆盖:gen_menu(含它回写的 uvprojx + CMakeLists 两份注册表)、中文字库链(extract_cn →
|
||||||
|
# ui_cn_chars.txt、make_font → ui_font_cn_12.c)、tools/arch_diagram 全部配图生成器。
|
||||||
|
# 一处刻意不按裸字节比对:ui_cn_chars.txt 按 font-regen 约定 = 源码在用 ∪ KEEP_EXTRA,改判
|
||||||
|
# 「txt 恰等于 重跑 ∪ KEEP_EXTRA」(两向都不许差;清单 importlib 直载 scripts/check_font_charset.py,
|
||||||
|
# 不留第二份清单)。存量基线 = 接线当天已漂的那 5 张 SVG,钉进脚本内 BASELINE 走 WARN 不拦提交
|
||||||
|
# (只减不增,还清即删、默认运行会点名);业务生成物(菜单表 / 页面注册表 / 字库 / 双端注册表)
|
||||||
|
# **零豁免面**,漂了就是 FAIL。
|
||||||
|
# ⚠ entry 是裸 python,而 pre-commit 在提交环境里可能把它解到**机器级**解释器:2026-10-26 真实提
|
||||||
|
# 交实测解到 C:/Python314(无 fontTools)→ 字体链报 No module named fontTools = 假红。所以脚本
|
||||||
|
# 自己按「显式 DTU_GEN_PYTHON > 当前解释器 > 仓库 .venv > PATH」解析并**逐个探针校验**(要真能
|
||||||
|
# import fontTools 才算数),整轮统一用同一个解释器;挑不出来打 [ENV]+候选表+修法、退出码 2 不
|
||||||
|
# SKIP。不把 entry 写死成 .venv 路径:那是 ci_local 2026-10-11「候选收窄 → runner 上静态门禁全灭」
|
||||||
|
# 的同一条坑(详解 docs/门禁说明/generated-fresh.md §4)。
|
||||||
|
# ⚠ 字体链外部工具(node + 全局 lv_font_conv + 系统 simsun.ttc / seguisym.ttf)缺失时该项 SKIP 并
|
||||||
|
# 打印 [SKIP],--strict-env 才升 FAIL——与 test-coverage「缺 mscoverage 必 FAIL」刻意不同:
|
||||||
|
# 缺工具的机器上没有可执行的修法,SKIP 至少把「这台没验过」留在输出里(盲区同登记在索引行)。
|
||||||
|
# ⚠ pre-commit 4.x 已移除 always: true → files 必须覆盖全部触发面:任何生成器(tools/)、
|
||||||
|
# 生成器的输入(Drivers/BSP/UI 源码、App/task/ui_task、ui_menu_tree.txt、ui_cn_chars.txt)、
|
||||||
|
# 生成物(docs/images/*.svg + 两份注册表)、脚本自身与本配置文件(同 registration-check 口径)。
|
||||||
|
- id: generated-fresh
|
||||||
|
name: 生成器幂等性(重跑到临时目录 → 与工作区逐字节一致)
|
||||||
|
entry: python scripts/check_generated_fresh.py
|
||||||
|
language: system
|
||||||
|
files: ^(tools/.+|Drivers/BSP/UI/.+|docs/images/.*\.svg|App/task/ui_task\.(c|h)|ui_menu_tree\.txt|ui_cn_chars\.txt|MDK-ARM/STM32F407-Demo\.uvprojx|simulator/CMakeLists\.txt|scripts/check_generated_fresh\.py|scripts/check_font_charset\.py|\.pre-commit-config\.yaml)$
|
||||||
|
pass_filenames: false
|
||||||
|
# ── 块注释嵌套(第 51 条,2026-10-27 加,task-29)─────────────────────────────
|
||||||
|
# 查什么:C/C++ 块注释体内再出现 /* —— ARMCC 诊断 9-D「nested comment is not allowed」。
|
||||||
|
# 本仓的诱因是「在注释里写作用域 / 通配路径」这种常态写法:Drivers/BSP/UI/** 里那个
|
||||||
|
# /* 就是第二次注释的起点(C 的块注释不嵌套)。
|
||||||
|
# 为什么要闸:它**只在重编译那个 TU 时才暴露**,历史「0 警告」不代表安全;而且按包含者
|
||||||
|
# 数量刷屏——一份被广泛 include 的头里一处嵌套 = 每个包含者一行警告(实测一例扩散到 16
|
||||||
|
# 条,把整批真警告挤到看不见)。有了这根,提交时就报,不用等 /keil-build。
|
||||||
|
# 接线当日实证(冻结引用,别去翻构建日志——它每次 /keil-build 就被覆盖、也不入库):当日 02:22
|
||||||
|
# 那次构建日志报过 ..\App\algo\run_par.c(267): warning: #9-D: nested comment is not
|
||||||
|
# allowed ——那是别人**未提交**的在飞状态。原文抄在 docs/门禁说明/nested-comment.md §2.1;
|
||||||
|
# 已提交历史回放 281 份旧字节 0 命中 ⇒ 这类问题一直靠人工在编译后清,从没被机器拦过。
|
||||||
|
# 存量基线(只减不增,除这 2 处外**零豁免**):全仓 --all 实扫 403 个 C/H,新发 0,存量
|
||||||
|
# 2 处 = test/host/common/ch395f_fake.h:7 与 fatfs_fake.h:9(注释里写 test/host/**,属
|
||||||
|
# test/ 写域,等那侧改成语义短语;还清后删 BASELINE 行,--prune 打台账)。
|
||||||
|
# 自证:--selftest 四项(正例照得出 / 字符串与行注释不误报 / 基线吞得下 / 只减不增有牙),
|
||||||
|
# 另有 10 项形态电池(含「注释里写 UI/*」「块注释内行首再开 /*」等真形态)。
|
||||||
|
# ⚠ 计数口径:本条使 YAML 解析条目 50 → **51**;配置里那条被注释掉的 clang-format 候选仍
|
||||||
|
# 在,所以**按文本 grep 声明行会得 52**(多算那条候选)——那不是口径。本行刻意不复现那个字面量,
|
||||||
|
# 否则注释自己也会被 grep 数进去(2026-10-27 实测踩过:写过一次,计数立刻虚高 1)。
|
||||||
|
# 口径与自查命令见 docs/门禁说明/README.md §2 首条。
|
||||||
|
- id: nested-comment
|
||||||
|
name: "块注释体内不得再出现 /*(ARMCC 9-D 嵌套注释)"
|
||||||
|
entry: python scripts/check_nested_comment.py
|
||||||
|
language: system
|
||||||
|
types: [c]
|
||||||
|
|
||||||
|
# ─── SKILL.md 体积与 reference/ 指针门禁(第 52 条,2026-10-29 加)─────────────────────
|
||||||
|
# 为什么要有:dsh-mnemon 插件对技能正文有 65536 字节的**写侧**硬上限(validateSkillContent),
|
||||||
|
# 但**加载侧不查**——超限的 SKILL.md 照常可读、照常进技能目录,只有一件事会坏:自进化每次
|
||||||
|
# 「生成草案」都在落盘那一步被拒,那一行永久卡在「生成中」、界面只剩转圈、连「忽略」都不出现
|
||||||
|
# (Draft/Dismiss 只在 pending 渲染)。实测 2026-10-29 一天内两个技能各卡一次(第二个 72,601
|
||||||
|
# 字节),每次白烧一个 30 分钟的草案 Agent。**在加这根之前,仓库里没有任何一处会告诉你某个
|
||||||
|
# 技能已经废了**——文件能读、能改、能提交,全绿。
|
||||||
|
# 判据:> 65536 FAIL(与插件同数、同口径:utf-8 字节数不是字符数);>= 49152(75%)WARN 不拦
|
||||||
|
# (越线通常只隔一次草案采纳:实测 49821 → 62806);正文里 (reference/xxx.md) 指向的文件不在
|
||||||
|
# → FAIL;带 UTF-8 BOM → FAIL(插件按 utf-8 解析,BOM 会让 frontmatter 读歪)。
|
||||||
|
# 全树扫不只看变更文件:钩子拿到 filenames 只当触发用,判定始终扫整个 .dsh/skills/——因为改
|
||||||
|
# reference/*.md 时 SKILL.md 没动,但它可能正是被那次外置撑过线/掏空的那一份。
|
||||||
|
# 为什么 entry 敢用裸 python:AGENTS.md「钩子 entry 的裸 python 不等于 .venv」那条教训针对的是
|
||||||
|
# 第三方依赖(fontTools);本脚本纯标准库,任意 python3 均可(实测 .venv 3.12 与 PATH 上 3.14
|
||||||
|
# 都跑通),所以不需要脚本内再自解析解释器。
|
||||||
|
# ⚠ 盲区:只管仓库内 .dsh/skills/;用户级 ~/.dsh/skills/ 不在仓库里,本钩子照不到(手工
|
||||||
|
# `node "$HOME/.dsh/scripts/mnemon-skill-ledger-doctor.mjs" --root .` 或本脚本 --root 都只报,
|
||||||
|
# 要拦得另配 shell 钩子)。只认 markdown 链接形态的指针,纯文本里写的路径不算。
|
||||||
|
# 自证:--selftest 五形态(干净 0 / 75% 线 0 带 WARN / 超上限 1 / 悬空指针+BOM 1 / 根不存在 2)。
|
||||||
|
# 详解与限制 → docs/门禁说明/skill-size.md。
|
||||||
|
- id: skill-size
|
||||||
|
name: "SKILL.md 不超插件写侧上限 65536 且 reference/ 指针不悬空"
|
||||||
|
entry: python scripts/check_skill_size.py
|
||||||
|
language: system
|
||||||
|
files: ^\.dsh/skills/
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"portName": "COM6",
|
"portName": "COM3",
|
||||||
"baudRate": 115200,
|
"baudRate": 115200,
|
||||||
|
|
||||||
"flash_cmd": ".venv\\Scripts\\python.exe .dsh\\skills\\stlink-flash\\scripts\\stlink_flash.py flash",
|
"flash_cmd": ".venv\\Scripts\\python.exe .dsh\\skills\\stlink-flash\\scripts\\stlink_flash.py flash",
|
||||||
|
|||||||
+2
-2
@@ -13,10 +13,10 @@ FreeRTOS/CMSIS-RTOS API**(见 `AGENTS.md`「平台约束」)。
|
|||||||
|
|
||||||
| 目录 | 内容 | README |
|
| 目录 | 内容 | README |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `app/` | **应用入口与全局**:`app_main.c/h`(初始化入口)、`app_cfg.h`(编译开关)、`sys_events.h`(就绪事件) | [`app/README.md`](app/README.md) |
|
| `app/` | **应用入口与全局**:`app_main.c/h`(初始化入口)、`app_cfg.h`(裁剪决策占位说明,当前无宏)、`sys_events.h`(就绪事件) | [`app/README.md`](app/README.md) |
|
||||||
| `cfg/` | **配置引擎**:`/config.ini` 解析 + 原子持久化(旧二进制档已废弃,2026-10-10) | [`cfg/README.md`](cfg/README.md) |
|
| `cfg/` | **配置引擎**:`/config.ini` 解析 + 原子持久化(旧二进制档已废弃,2026-10-10) | [`cfg/README.md`](cfg/README.md) |
|
||||||
| `sync/` | **时间同步任务**:`time_sync.c/h` | [`sync/README.md`](sync/README.md) |
|
| `sync/` | **时间同步任务**:`time_sync.c/h` | [`sync/README.md`](sync/README.md) |
|
||||||
| `algo/` | **采集与算法层(L1/L2)**:`adc_input.c/h`(语义通道映射 + STM32 ADC1 扫描) | [`algo/README.md`](algo/README.md) |
|
| `algo/` | **采集与算法层**:语义取数 + 有效性判定 + 采样环/相量/测频 + 定值模型 + 保护元件框架 | [`algo/README.md`](algo/README.md)(索引);设计 → `docs/算法/采集与算法层设计说明.md` |
|
||||||
| `log/` | **日志子系统**:syslog(NAND)+ 控制台单写者日志 | [`log/README.md`](log/README.md) |
|
| `log/` | **日志子系统**:syslog(NAND)+ 控制台单写者日志 | [`log/README.md`](log/README.md) |
|
||||||
| `task/` | **FreeRTOS 任务实现**(6 个任务) | [`task/README.md`](task/README.md) |
|
| `task/` | **FreeRTOS 任务实现**(6 个任务) | [`task/README.md`](task/README.md) |
|
||||||
| `util/` | **通用工具**:CRC / 环形缓冲 / 墙钟时间 | [`util/README.md`](util/README.md) |
|
| `util/` | **通用工具**:CRC / 环形缓冲 / 墙钟时间 | [`util/README.md`](util/README.md) |
|
||||||
|
|||||||
+240
-335
@@ -1,360 +1,265 @@
|
|||||||
# App/algo/ — 采集与算法层(L1 / L2)
|
# App/algo/ — 采集与算法层(语义取数与有效性 / 全周傅氏相量 / 保护判据)
|
||||||
|
|
||||||
> **自研代码**,属 `docs/算法/继电保护功能复现规划.md` §2.1 分层中的 **L1(采集与预处理)**
|
> 本目录的**索引**:文件清单、依赖、逐文件的"改代码必须知道"、构建与测试入口、新增模块动作。
|
||||||
> 与 **L2(相量计算)**。原则:**算法与 I/O、与调度分离**——本层不建任务、不做保护判据、
|
> **详细设计——机制、契约、判据、为什么这样设计 → `docs/算法/采集与算法层设计说明.md`**(本文不复制其内容)。
|
||||||
> 不碰寄存器(唯一例外见 §3.1),只做"数"(原始码值 → 语义通道)与"相量"
|
> 分层归属与功能项状态 → `docs/算法/继电保护功能复现规划.md` §2.1;
|
||||||
> (采样序列 → 复相量),因而可被主机侧单元测试(L0.5)离线全覆盖(落实登记簿 **A-4**)。
|
> 通道映射唯一权威 → `docs/硬件平台/ADC通道映射说明.md`;采样节拍 → `docs/子系统设计/ADC采样节拍设计说明.md`。
|
||||||
>
|
|
||||||
> 任务侧(`App/task/adc_task.c`)只做**消费与编排**;任何新算法一律落在本目录,勿塞进 `App/task/`。
|
|
||||||
|
|
||||||
## 1. 文件清单(一眼版)
|
## 1. 文件清单(一眼版)
|
||||||
|
|
||||||
| 文件 | 层 | 作用(一句话) | 主要对外入口 | 覆盖规划项 |
|
| 文件 | 层 | 作用(一句话) | 主要对外入口 | 覆盖规划项 |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| `adc_input.h` / `adc_input.c` | L1 | **统一语义取数层**:把两片转换器的原始码值按语义(`UA…IN`/温度)暴露,并消费 STM32 ADC1 的 DMA 乒乓双缓冲 | `adc_input_init()` `adc_input_poll()` `adc_input_raw()` | L1-2 / L1-3 / L1-4 |
|
| `adc_input.h` / `.c` | 语义取数与有效性 | 统一语义取数:两片转换器的码值按语义(`UA…IN` / 温度)暴露,并消费 STM32 ADC1 的 DMA 乒乓缓冲 | `adc_input_init()` `adc_input_poll()` `adc_input_raw()` | TPAFE 快照消费 / STM32 ADC1 启动与扫描 / 通道语义映射 |
|
||||||
| `adc_health.h` / `adc_health.c` | L1 | **采样有效性判定层**:吃"计数快照"(帧序号/错误计数),产出静默/丢帧/单调性/错误增量故障位——判定与告警编排分离 | `adc_health_init()` `adc_health_check()` `adc_health_flags()` | L1-6 |
|
| `adc_health.h` / `.c` | 语义取数与有效性 | 采样有效性判定:吃计数快照,产出静默 / 丢帧 / 单调性 / 错误增量故障位(判定与告警编排分离);「采样通道自检」残余的码值贴轨观察器同层 | `adc_health_check()` `adc_health_flags()` `adc_health_rail_watch()` | 「采样有效性校验」/「采样通道自检」(残余) |
|
||||||
| `sample_ring.h` / `sample_ring.c` | L2 输入 | **采样环缓冲**:按语义通道保存最近 2 个周波(40 点)采样值,供相量层"以当前时刻为终止"取窗 | `sample_ring_push_frame()` `sample_ring_window()` | L2 输入(为 L2-6 滑动窗供数) |
|
| `adc_cal.h` / `.c` | 标定折算 | 通道标定折算(「数据可信」标定折算与落盘整定):工程量 =(code+b)×k,Q16 系数/零点两段落盘 + 三态 flags + 合法带哨兵 + 两点公式 | `adc_cal_apply()` `adc_cal_flags()` `adc_cal_set()` | 「通道标定(一次系数 + 零漂)」(M4 半边) |
|
||||||
| `phasor.h` / `phasor.c` | L2 | **全周傅氏相量计算**:一周波 20 点单频 DFT → 基波/谐波相量(有效值口径),双区原子切换供无锁读 | `phasor_run()` `phasor_get()` `phasor_calc()` | L2-1 / L2-2 / L2-3 / L2-6 / L2-7 |
|
| `meas.h` / `.c` | 全周傅氏相量发布 | 遥测快照(「数据可信」遥测快照发布,M5 地基):200 ms 基波有效值 + 品质快照,双区无锁读,全仓唯一发布者 = adcTask | `meas_tick()` `meas_get()` `meas_seq()` | M5(104 改读快照与频率点绑定、UI 是读者) |
|
||||||
| `prot_set.h` / `prot_set.c` | L3 输入 | **保护定值模型**:Q10 定点 RAM 表(通讯地址对照参考 `g_tCommSetParaTab`)+ 范围校验 + 校验和封存自检闭锁 | `prot_set_init()` `prot_set_load_raw()` `prot_set_get_f()` `prot_set_selfcheck()` | §5 定值模型 / L5-1 |
|
| `sample_ring.h` / `.c` | 全周傅氏相量输入 | 按语义通道存最近 2 周波(40 点),供相量层"以当前时刻为终止"取窗 | `sample_ring_push_frame()` `sample_ring_window()` | 全周傅氏相量输入(为「每 1 ms 滑动窗刷新」供数) |
|
||||||
| `prot_elm.h` / `prot_elm.c` | L3 | **保护元件框架**:通用"越限 + 返回系数滞回 + 定时限"核 + 数据驱动实例表(过流三段 / 过负荷 / 过压 / 欠压 / 零序过压 / **过频 / 低频**) | `prot_elm_init()` `prot_elm_eval()` `prot_elm_run()` `prot_elm_active_mask()` | L3-1 / L3-3 / L3-4 / L3-5 / L3-6(软件侧) |
|
| `phasor.h` / `.c` | 全周傅氏相量 | 全周傅氏:一周波 20 点 DFT → 基波 / 谐波相量(有效值口径),双区原子切换供无锁读 | `phasor_run()` `phasor_get()` `phasor_calc()` | 全周傅氏系数表 / 基波相量实例 / 相量有效值输出 / 每 1 ms 滑动窗刷新 / 双区指针原子交换 |
|
||||||
| `freq.h` / `freq.c` | L2 派生量 | **频率测量**:相邻两拍基波相量共轭乘积取角(每拍相位增量)+ 自适应窗长滑动平均 + 假值钳制 + 幅值门(UA→IA 源回退) | `freq_init()` `freq_run()` `freq_get()` `freq_valid()` | L3-5 测量侧(`docs/算法/继电保护功能复现规划.md` §4.2 测频范围) |
|
| `freq.h` / `.c` | 全周傅氏相量的派生量 | 频率测量:相邻两拍基波相量共轭乘积取角 + 自适应窗滑动平均 + 假值钳制 + 幅值门(UA→IA 源回退) | `freq_run()` `freq_get()` `freq_valid()` | 「过频 / 低频」测量侧(测频范围 → `docs/算法/继电保护功能复现规划.md` §4.2) |
|
||||||
|
| `prot_set.h` / `.c` | 保护判据输入 | 保护定值模型:Q10 定点 RAM 表 + 范围校验 + 校验和封存 / 自检 / 闭锁 + 持久化 blob 纯逻辑 | `prot_set_load_raw()` `prot_set_get_f()` `prot_set_selfcheck()` | 「定值校验和」载体 |
|
||||||
|
| `prot_elm.h` / `.c` | 保护判据 | 保护元件框架:通用"越限 + 返回系数滞回 + 定时限"核 + 数据驱动实例表(过流三段 / 过负荷 / 过压 / 欠压 / 零序过压 / 过频 / 低频) | `prot_elm_run()` `prot_elm_eval()` `prot_elm_active_mask()` | 「三段式相间过流」/「过负荷」/「过压 / 欠压」/「过频 / 低频」/「零序过压」(软件侧) |
|
||||||
|
| `wave_ring.h` / `.c` | 故障录波数据面 | 录波环与冻结状态机:int16 × 320 点 × 8 路按通道分列,触发即冻结写指针、后触发计满 160 点封段、落盘回执才解冻、冷却期只加溢出计数 | `wave_ring_push()` `wave_ring_tick()` `wave_ring_request()` `wave_ring_sealed()` `wave_ring_read_ch()` | 「故障录波」数据面(环 + 触发冻结语义) |
|
||||||
|
| `wave_store.h` / `.c` | 故障录波段格式 | 段文件字节层的编解码与分次搬运游标:段头 + 通道表 + 两处 CRC-16/MODBUS + 段名轮转与反解 + 整段自检;不碰文件系统 | `wave_store_crc()` `wave_store_head_build()` `wave_store_head_parse()` `wave_store_seg_check()` `wave_store_pack_next()` `wave_store_seg_path()` | 「故障录波」段格式(布局 + 自检) |
|
||||||
|
|
||||||
依赖关系(自下而上,无环):`adc_input.h`(纯枚举 + 声明)← `sample_ring` ← `phasor` ← `freq`;
|
依赖(自下而上、无环):`adc_input.h`(纯枚举 + 声明)← `adc_cal`(另依赖 `cfg_file.h` 接口,
|
||||||
`prot_set` **独立**(仅 `stdint.h`);`prot_elm` ← `prot_set` + `phasor` + `freq`。
|
纯头文件级)← `sample_ring` ← `phasor` ← `freq`;`meas` ← `sample_ring` + `phasor`
|
||||||
`sample_ring.c` / `phasor.c` / `adc_health.c` / `prot_set.c` / `prot_elm.c` / `freq.c` **零平台依赖**
|
(仅 `phasor_calc` 纯核;频率/品质由调用方注入,**不链** `freq`/`adc_cal`);
|
||||||
(只用 `math.h` / `stddef.h` / `stdint.h` 与本目录头);
|
`prot_set` **独立**;`prot_elm` ← `prot_set` + `phasor` + `freq`。
|
||||||
`adc_input.c` 另依赖 `adc.h`(`hadc1`)与 `tpafe5160.h`(快照),主机侧以桩头替身。
|
`wave_store` **独立**(只用 `stdint.h` 与 `stddef.h`,编码与解码共用同一个字段校验函数);`wave_ring` ← `wave_store`(只取段头类型与返回码;**不链** `adc_cal`——封存瞬间的系数由
|
||||||
|
adcTask 现取现填后交棒,本层不复制第二个事实源)。两份录波文件同样零平台依赖、不碰文件系统。
|
||||||
|
`adc_health` 只吃调用方喂进来的计数 / 码值,不反向依赖 语义取数与有效性实现(「采样通道自检」贴轨门限由调用方
|
||||||
|
从 `adc_cal_rail_ths()` 取,本层仍零算法依赖)。
|
||||||
|
|
||||||
## 2. 数据流(一拍 = TIM3 1 kHz 节拍)
|
**除 `adc_input.c` 外全部零平台依赖**(只用 `stdint.h` / `math.h` / `stddef.h` 与本目录头)
|
||||||
|
→ 可被主机单测 `#include` 原样纳入、免桩跑。`adc_input.c` 另依赖 `adc.h`(`hadc1`)与
|
||||||
|
`tpafe5160.h`(快照),主机侧以桩头替身。
|
||||||
|
|
||||||
```mermaid
|
## 2. 一拍的数据流(一眼版)
|
||||||
flowchart TD
|
|
||||||
TIM3["TIM3 更新事件 1 kHz(PSC 83 / ARR 999)<br/>ISR: tpafe5160_start_conv_irq() + adc_tick_irq_notify()<br/>同一事件经 TRGO 触发 ADC1"] --> TPAFE["TPAFE5160 转换<br/>BUSY EXTI 收帧"]
|
|
||||||
TIM3 --> ADC["STM32 ADC1 一轮 9 通道(≈67 us)<br/>DMA2_Stream4 循环模式自动搬运"]
|
|
||||||
ADC --> PP["乒乓双缓冲 s_stm32_dma[2×9]<br/>半传输 / 传输完成回调各推进 seq"]
|
|
||||||
TIM3 -. "ADC_WAKE_FLAG<br/>osThreadFlagsWait" .-> TASK
|
|
||||||
TPAFE --> POLL
|
|
||||||
PP --> POLL
|
|
||||||
TASK["adcTask(事件驱动唤醒,非定时轮询)<br/>App/task/adc_task.c"] --> POLL["adc_input_poll()<br/>TPAFE 快照 + 消费刚完成的那半区(零忙等)"]
|
|
||||||
POLL --> RAW["adc_input_raw(ch) × 9<br/>按语义取原始码值"]
|
|
||||||
RAW --> RING["sample_ring_push_frame()<br/>整帧入环(40 点窗)"]
|
|
||||||
RING --> RUN["phasor_run()<br/>逐实例 20 点重算 + 双区切换"]
|
|
||||||
RUN --> FREQ["freq_run()<br/>共轭乘积相位增量 + 自适应窗均值(B8)"]
|
|
||||||
RUN --> GET["phasor_get(idx)"]
|
|
||||||
FREQ --> FGET["freq_get() / freq_valid()"]
|
|
||||||
GET --> CONS["消费者:adcTask 日志(现状)<br/>UI / 自检 / L3 保护任务(待接)"]
|
|
||||||
FGET --> PCONS["消费者:prot_elm_run() OF/UF 判据(B8)<br/>adcTask 心跳 freq 行"]
|
|
||||||
```
|
|
||||||
|
|
||||||
初始化落点:`adc_input_init()` 与 `phasor_init()`、`freq_init()` 由 `App/app/app_main.c` 的
|
`adc_input_poll()` → `adc_input_raw()` ×9 → `adc_cal_apply()` ×9(标定折算折算,现口径 =
|
||||||
`app_main_init()` 在**内核启动前**调用(各 `*_init()` 均无 RTOS 依赖、幂等);
|
ADC 引脚电压 V)+ `adc_health_rail_watch()` ×9(「采样通道自检」,码值域)→ `sample_ring_push_frame()`
|
||||||
`phasor_init()` 内部会连带 `sample_ring_init()`,等价于"刚上电"。
|
→ `phasor_run()` → `freq_run()` → 消费者(`adcTask` 心跳与 语义取数与有效性健康判定;`protectTask` 每 20 ms 取相量 / 频率跑
|
||||||
|
`prot_elm_run()`;**每 200 ms** `meas_tick()` 发布遥测快照供 iec104Task / uiTask 读,即「数据可信」遥测快照发布)。节拍 = TIM3 1 kHz 更新事件,两片转换器同源触发。
|
||||||
|
完整数据流图与初始化落点 → `docs/算法/采集与算法层设计说明.md` §2。
|
||||||
|
|
||||||
## 3. 逐文件说明
|
录波支路(与上面这条相量主线**并行**、同拍只多走两步,互不阻塞):`adc_input_raw()` 取 8 路 →
|
||||||
|
`wave_ring_push()` → `wave_ring_tick()`(触发即冻结写指针,后触发计满 160 点封段)→ defaultTask
|
||||||
|
只见 `wave_ring_sealed()` 与已封存区读口 → 游标分次搬运落盘 → `wave_ring_write_done()` 才解冻。
|
||||||
|
→ docs/子系统设计/故障录波设计说明.md §4.2 与 §5.1。
|
||||||
|
|
||||||
### 3.1 `adc_input.c` / `adc_input.h` — L1 ADC 输入层
|
## 3. 逐文件要点(只留"改代码时必须知道"的;机制与理由见设计文档)
|
||||||
|
|
||||||
**解决的问题**:V0.1 上 8 路电气量被两片转换器分片(硬件缺陷 **N-18**),且网名 `AINn`
|
> **改本目录前先记五条红线**:① 取数只经 `adc_input_raw()`,不写死下标;② **禁止跨转换器**做
|
||||||
≠ 板级 `ADCn` ≠ 转换器通道号(13/17 路置换)。若消费侧直接索引驱动缓冲,改版即全线错位。
|
> 相位 / 功率 / 矢量运算(双转换器无共同采样触发);③ 帧值已是 标定折算工程量,但 **未标定(`adc_cal_flags()` 无
|
||||||
本层把"哪一路"收进一张表,上层**按语义取数、不写死下标**。
|
> VALID)时单位只是 ADC 引脚电压**,不是端子二次值——下游消费精度判据见「数据可信」§4.2 三态
|
||||||
|
> 表;④ 工程量 → 帧的唯一折算点是 `adc_cal_apply()`,禁止旁路再乘系数;⑤ 本层不建任务、
|
||||||
|
> 不等事件、不写日志(`adc_cal` 的 `[ERR]` 哨兵留痕是唯一例外,且每通道每开机一次),
|
||||||
|
> `*_init()` 才可在内核前调。
|
||||||
|
> 全文 → `docs/算法/采集与算法层设计说明.md` §10。
|
||||||
|
|
||||||
| API | 干什么 | 注意 |
|
### 3.1 `adc_input.c` / `adc_input.h` — 语义取数与有效性语义取数
|
||||||
|
- 语义映射表 `k_adc_map[]` 的**内容**唯一权威是 `docs/硬件平台/ADC通道映射说明.md`;V0.2 改版只改表内容,API 不动。
|
||||||
|
- `adc_input_poll()` **仅任务上下文**(TPAFE 快照是临界区);STM32 侧固定滞后 1 拍。
|
||||||
|
- 四类 STM32 计数(`adc1_seq` / `adc1_gap` / `adc1_err` / `adc1_hw`)成因不同,**勿混读**。
|
||||||
|
- 本目录**唯一**允许出现 HAL 调用的文件。→ `docs/算法/采集与算法层设计说明.md` §3。
|
||||||
|
|
||||||
|
### 3.2 `sample_ring.c` / `.h` — 全周傅氏相量输入采样环
|
||||||
|
- 整帧写入后才推进写指针 → 取到的窗永远落在整帧边界。
|
||||||
|
- 本拍取数失败 → **整拍跳过入环**(`adc_task.c` 的 `skip`),**绝不补 0 点**。
|
||||||
|
- 不做跨通道运算、不做丢帧判定(只出 `frames()` 计数)。→ `docs/算法/采集与算法层设计说明.md` §5。
|
||||||
|
|
||||||
|
### 3.3 `phasor.c` / `.h` — 全周傅氏相量计算
|
||||||
|
- 系数表预乘 √2/N → `fAmSQ` **直接是有效值**(口径 `docs/算法/傅氏算法详解.md` §2.3)。
|
||||||
|
- 实例表 `k_inst[]` 数据驱动:**新增实例只在表尾加一行**。
|
||||||
|
- 任一实例不足一周波 → **本拍整体不产出**(不切使用区);每拍 20 点**全部重算**(不做增量递推)。
|
||||||
|
- → `docs/算法/采集与算法层设计说明.md` §6。
|
||||||
|
|
||||||
|
### 3.4 `adc_health.c` / `.h` — 语义取数与有效性判定
|
||||||
|
- 三类位**严格分开**:电平(可自愈)/ 锁存(必留痕)/ 边沿事件(仅当拍);**静默 ≠ 丢帧**。
|
||||||
|
- **gap 不告警**(消费者超拍是已知现象);告警编排在 `adc_task.c`,不在本层。
|
||||||
|
- 首拍 `check()` 只建基线。→ `docs/算法/采集与算法层设计说明.md` §4。
|
||||||
|
|
||||||
|
### 3.5 `prot_set.c` / `.h` — 保护定值模型(「定值校验和」载体)
|
||||||
|
- 装载 **all-or-nothing**:任一项越界整表不装载,越界项由 `prot_set_bad_mask()` 指认。
|
||||||
|
- 批量 `prot_set_set()` 后**必须** `prot_set_seal()`;单项原子写用 `prot_set_set_sealed()`。
|
||||||
|
- 定值项 / 实例**只许尾部追加**(枚举序 = 数组序 = 校验和覆盖序)。
|
||||||
|
- blob **换代(返回 2)≠ 损坏(返回负)**:前者打 INFO、不记自检。→ `docs/算法/采集与算法层设计说明.md` §8、§12.3。
|
||||||
|
|
||||||
|
### 3.6 `prot_elm.c` / `.h` — 保护判据元件框架
|
||||||
|
- 数据缺失两种处理**刻意不同**:相量类任一未就绪 → 整拍跳过;频率类无有效读数 → 按方向填"必不启动"值,**不整拍跳过**。
|
||||||
|
- `prot_set_locked()` 或投退 = 0 → 元件整体复位不判据;跨上下文复归走 `prot_elm_request_reset()`。
|
||||||
|
- 零序过流(IN 走 STM32)**故意不在实例表**;OF / UF 无独立 104 遥信点,只经 YX15 总聚合可见。
|
||||||
|
- `prot_elm_init()` 须在 `phasor_init()` 之后。→ `docs/算法/采集与算法层设计说明.md` §9。
|
||||||
|
|
||||||
|
### 3.7 `freq.c` / `.h` — 频率测量(「过频 / 低频」的测量输入)
|
||||||
|
- `freq_run()` **仅 adcTask 上下文**,须在 `phasor_run()` 之后同拍调;保护口径 =
|
||||||
|
`freq_get() == 0 && freq_valid() == 1`。
|
||||||
|
- 精度靠**自适应窗长**(窗长 = 泄漏纹波周期的整数倍);定长平均压不到 0.02 Hz,
|
||||||
|
换窗长 = 换精度,二者不是独立旋钮。
|
||||||
|
- 源切换即废相位基准重建(UA / IA 无共同相位基准,即双转换器无共同采样触发)。
|
||||||
|
- 判据 **J1~J6** 与实测口径 → `docs/算法/采集与算法层设计说明.md` §7.2(与 `test/host/freq/freq_test.c` 文件头同源)。
|
||||||
|
|
||||||
|
### 3.8 `adc_cal.c` / `.h` — 标定折算通道标定折算(「数据可信」标定折算与落盘整定)
|
||||||
|
- 折算模型:工程量 = (code + b) × k;**k 落盘用 int32 Q16(µV/码 × 65536)**、b 用
|
||||||
|
int32 码值(负零点靠 `CFG_VAL_INT`)——单位与定点格式的选型依据见
|
||||||
|
`docs/任务规划/数据可信.md` §4.1,勿改回 V/码 直存(Q16 只剩 20 个刻度)。
|
||||||
|
- **ROM 默认 = 量程口径冻结定死的引脚侧 LSB**(TPAFE 152.59 / STM32 805.66 µV/码,
|
||||||
|
`docs/硬件平台/ADC通道映射说明.md` §7.1);表下标 = 语义枚举(UA0…IN7),
|
||||||
|
V0.2 改版与本目录 `adc_input` 映射表同步改。
|
||||||
|
- 三态可用性 `adc_cal_flags()`:VALID(已按变比标定,二次值成立——端子变比确认与两点标定前恒 0)
|
||||||
|
/ CLIPPED(运行期按来源派生,V0.2 后自动清零)。**消费者判「能不能信」只许查它**,
|
||||||
|
`adc_cal_apply()` 不返回可用性(「数据可信」§8 风险表)。
|
||||||
|
- 合法带哨兵:k ∉ ROM×[0.5,2.0] 或 |b| > 8192 → 折算回退 ROM + 每通道每开机一条
|
||||||
|
`[ERR]` + VALID 被压制(**即便文件位图说已标定**);这层兜底同时挡坏文件与坏标定。
|
||||||
|
- `adc_cal_set()` / `adc_cal_two_point()`:整定与两点公式的算术核已齐备(单测还原合成
|
||||||
|
k/b);**台架两点标定待实测**,本层不含注入。
|
||||||
|
- cfg 两段 `[adc_cal]` / `[adc_cal_ofs]` **不挂回调**(RAM 副本即生效值,引擎 load/poll
|
||||||
|
原地改写,逐点现读);注册在 `app_main_init()` 内核前完成,必须先于 `cfg_file_load`。
|
||||||
|
|
||||||
|
### 3.9 `meas.c` / `.h` — 遥测快照发布(全周傅氏相量发布侧,「数据可信」波次)
|
||||||
|
- **唯一发布者 = adcTask**:`phasor_run()`/`freq_run()` 同拍、200 ms **tick 经过**分频
|
||||||
|
调 `meas_tick()`(非拍计数——idle/skip 拍拉长名义节拍)。docs/任务规划/数据可信.md §8
|
||||||
|
风险表"快照双写"行 = 评审判据:全仓 `meas_tick` 调用点数须为 1;iec104Task /
|
||||||
|
uiTask 只读。
|
||||||
|
- 值口径(第二轮「遥测快照发布」的 A-lite 裁决):**基波有效值瞬时透传**,与 phasor 使用区
|
||||||
|
**同拍同核逐位相等**(单测钉死);**不做** 4 点平均 / 谐波方和根——按
|
||||||
|
docs/任务规划/数据可信.md §4.5"上送节拍非平均窗,再平均引入滞后";谐波 k-bin 轨
|
||||||
|
(docs/算法/继电保护功能复现规划.md 「遥测谐波轨(可选)」)扩展只许结构尾部追加,
|
||||||
|
docs/任务规划/数据可信.md §4.5 字段序 = 协议(布局由单测按偏移钉死,72 B × 双区)。
|
||||||
|
- 并发 = phasor 双区 + 单字节索引翻转(「双区指针原子交换」同型,无锁);seq 只增供消费者判换帧。
|
||||||
|
- 品质注入:削顶位图唯一源 = `adc_cal_flags()`(口径 docs/任务规划/数据可信.md §4.2
|
||||||
|
三态表;由 adc_task 编排注入,本层不链 `adc_cal`);TEMP = 最新一拍原始值透传 +
|
||||||
|
`NT`(「数据可信」决策 5);`MEAS_QDS_*` 低字节与 104 QDS 线上字节同值
|
||||||
|
(本层**不** include 协议层头——分层方向)。
|
||||||
|
- 104 死区迁移 / 频率点绑定 = 104 改读快照那一波(道D 后续分支);读数与注入源比对 = **待台架实测**。
|
||||||
|
|
||||||
|
### 3.10 `wave_ring.c` / `wave_ring.h` — 录波环与冻结状态机(「故障录波」数据面)
|
||||||
|
- **形态三条钉死**(动它之前读 docs/子系统设计/故障录波设计说明.md §6.2 与 §7):只存 `int16`
|
||||||
|
原始码值(不做折算、不许 `float`)、**按通道分列**(不按帧交错——回放与搬运都是整列取)、
|
||||||
|
`ch_count` 固定 8(TEMP 本波不录:未折算的非线性量进录波只多占字节不增信息)。环本体 5120 B,
|
||||||
|
本层全部可写静态 5298 B,落 SRAM1(scatter 的 `RW_IRAM1` 收 `.ANY (+RW +ZI)`,代码里**没有**
|
||||||
|
任何 section 属性;CCM 只放 FreeRTOS 堆,那 5 KB 既塞不进也不该塞)。
|
||||||
|
- **冻结 ≠ 停采集**:触发冻住的是覆盖窗口(写指针停走),丢的只是录波样本,`drop_cnt` 的单位是
|
||||||
|
「点」(一拍 8 路 = 8 点);`sample_ring` / 相量 / 保护在同拍照常跑,本层不碰它们。
|
||||||
|
- **点序**:触发那一拍的样本落在段内点 159(预触发窗最后一点),点 160 起是触发后第一拍,两半
|
||||||
|
各 160 点、不重叠;回放页横轴与 PC 脚本都按这条对齐(设计说明未钉死 ±1,本层按不重叠实现)。
|
||||||
|
- **封存握手(顺序不许换,单测逐拍钉死)**:`wave_ring_tick()` 计满那一拍返回
|
||||||
|
`WAVE_RING_EVT_SEALED` → adcTask 才去取标定事实并 `wave_ring_latch_meta()` → `wave_ring_sealed()`
|
||||||
|
要求「状态 SEALED **且**已 latch」才返 1(两个标志并成一个,落盘道不可能拿半份快照落盘)→
|
||||||
|
defaultTask 只读锁副本 → `wave_ring_write_done(0 / 非 0)` 是唯一解冻入口:成功才推进轮转号与段
|
||||||
|
计数,失败只计 err 且**保持同号**(下一段重写同一个文件,盘上不留半截)。
|
||||||
|
- **两条不许把环焊死的兜底**:SEALED 滞留超过 `WAVE_SEAL_TIMEOUT_MS`(2000 拍,待台架核)自弃本段
|
||||||
|
并计 err;冷却窗 `WAVE_RING_COOL_MS`(500 拍,待台架核)**只在回执之后**计时——「落完解冻」用的是
|
||||||
|
状态不是时间,回执前任何新触发只加 `ovf_cnt` 不重录。
|
||||||
|
- **上下文归属直接长在函数命名上**(跨任务安全不靠约定):`wave_ring_init` / `push` / `tick` /
|
||||||
|
`latch_meta` 只能在 adcTask(⚠ 严禁挂到 TIM3 回调:那是 ISR 且比调度器早约 100 ms,本层没有
|
||||||
|
消费者就绪门);`wave_ring_write_done` 只能在 defaultTask;`request` / `state` / `sealed` /
|
||||||
|
`sealed_meta` / `read_ch` / `read_cb` / `get_stat` / `col` 任意上下文可调。**本层不提供任何读活
|
||||||
|
缓冲的口子**(docs/子系统设计/故障录波设计说明.md §5.2 第一条陷阱:`sample_ring_window()` 无临界区,本波不复用)。
|
||||||
|
- `seg_idx` 只是本次上电的本地计数;盘上的下一个写号归落盘道自持(开机扫目录用
|
||||||
|
`wave_store_idx_from_name()` 重建、写成功后 `wave_store_next_idx()` 推进)。
|
||||||
|
|
||||||
|
### 3.11 `wave_store.c` / `wave_store.h` — 段文件字节层(「故障录波」段格式)
|
||||||
|
- **只做字节**:文件落点、路径拼法、标定系数、数据本体全部由调用方注入(meta 由 adcTask 填、
|
||||||
|
数据经 `wave_reader_fn` 回调取、路径由落盘道拼)→ 两份文件零平台依赖,主机单测免桩 `#include`
|
||||||
|
原样纳入;`wave_store_meta_init()` 只填格式侧静态事实(并把通道表 `sem` 钉成下标、按归属填
|
||||||
|
`conv` / `bits`),`avail` / `k_q16` / `b_codes` 一律留 0 等封存瞬间现取现填——不复制第二个事实源。
|
||||||
|
- **尺寸等式**(单测钉死,落盘道拿它比对写回字节数):整段 = 32 + 12×通道数 + 2×点数×通道数 + 2;
|
||||||
|
当前口径(8 路 320 点)= 头 128 + 数据 5120 + 尾 CRC 2 = **5250 字节**(这条等式是给落盘道比对写回
|
||||||
|
字节数用的):FatFs 该移植在簇分配失败时会返回 FR_OK + 短写且无错误码,判成功必须校验字节数
|
||||||
|
(故障录波设计说明「四条已知陷阱」第二条)。
|
||||||
|
- **CRC-16/MODBUS 自带无表实现**(反射多项式 0xA001、初值 0xFFFF、异或输出 0,自检锚点字符串
|
||||||
|
"123456789" = 0x4B37)。设计说明 §6.2 原写「与屏板链路同一张表」经全仓实测**不成立**(这张表不
|
||||||
|
存在:crc.c 与 crc.h 已于 2026-10-02 删除,屏板侧 `remo_crc()` 是单字节异或)——文档等文档道回改,
|
||||||
|
代码不擅自改口径,也不再新造第三张表。
|
||||||
|
- **编解码同一把尺**:`wave_store_head_build()` 与 `wave_store_head_parse()` 走同一个字段校验函数,
|
||||||
|
判据与 PC 侧读段脚本的拒绝清单逐条对齐(零长度、每周波点数不足 2、每通道点数不够一周波、
|
||||||
|
预触发点数超段长、通道表项语义号不等于下标、版本号与魔数第三字节不一致)——防的就是「板端
|
||||||
|
写得出、PC 读不了」。反过来**不设**任何品质侧不变式:`avail` 同时带有效与削顶是合法事实
|
||||||
|
(lead 裁定 2026-10-29),编码器如实透传、解码器不许据此判废。
|
||||||
|
- **游标分次搬运**(256 B 缓冲够用,整段绝不驻留 RAM;数据 CRC 走增量中间值):
|
||||||
|
`wave_store_pack_next()` 头那一步要求缓冲容量不小于整头 128,否则返 `WAVE_ST_E_PARAM` 且**不推进
|
||||||
|
游标**;没有数据源却进数据区 → `WAVE_ST_E_STATE` 且**不产空字节**(产空字节等于写出「长度对、
|
||||||
|
内容全 0」的段 = docs/子系统设计/故障录波设计说明.md §8.1 明令禁止的伪造证据)。
|
||||||
|
- 段名严格 8.3(该移植 `FF_USE_LFN=0`):`wave_store_seg_path()` 当前布局恒返回 16 个字符(不含结束
|
||||||
|
符),**调用方缓冲至少 17 字节**(`WAVE_ST_PATH_MAX`,原写 16 会让结束符写到缓冲外一字节);
|
||||||
|
100 个号轮转,`wave_store_next_idx()` 回绕,`wave_store_idx_from_name()` 只认 basename 上的段名。
|
||||||
|
- `wave_store_head_build()` / `wave_ring_read_ch()` 这类函数**成功时返回实际字节数 / 点数**(不是
|
||||||
|
`WAVE_ST_OK`),负值才是错误码——写断言前先读头文件的「返 回 值」行(本层单测两次踩在这上面)。
|
||||||
|
|
||||||
|
## 4. 构建与测试
|
||||||
|
|
||||||
|
### 4.1 挂载与模拟器边界
|
||||||
|
- 十一个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`;`../App/algo` 已在 `<IncludePath>` 内。
|
||||||
|
- 模拟器侧分两类(Lead 裁定 2026-10-29):`wave_ring.c` 与 `wave_store.c` **真实参与**
|
||||||
|
`simulator/CMakeLists.txt`——回放页要在模拟器里出真波形,两份又零平台依赖,与「双端共享核心、不造
|
||||||
|
第二真相」同一条口径,故**不进** `SIM_EXEMPT`;其余九个采集链文件(UI 模拟器不跑采集链路)仍登记在
|
||||||
|
`scripts/check_registration.py` 的 `SIM_EXEMPT`(附理由),`registration-check` 钩子反向校验(登记簿条目「rs485_task 白名单缺口」)。
|
||||||
|
|
||||||
|
### 4.2 主机侧纯逻辑单测——生产源文件以 `#include` 原样纳入
|
||||||
|
|
||||||
|
| 模块 | 命令 | pre-commit 钩子 |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `adc_input_init()` | 清缓冲与统计 + **映射表自检**(每个 STM32 语义通道必须落在扫描 Rank 序列内)+ `HAL_ADC_Start_DMA()` 挂载乒乓缓冲(**只挂一次**) | 内核启动前调用;`-1` = 映射指向了从不扫描的通道(会恒读陈旧值);`-2` = DMA 挂载失败 |
|
| 相量 / 采样环 | `& '.venv\Scripts\python.exe' test/host/phasor/run_unit_test.py` | `phasor-unit-test` |
|
||||||
| `adc_input_poll()` | 采一拍:`tpafe5160_snapshot()` 原子快照(8 通道)+ **消费刚完成的那半区**(`stm32_take()`,按 `seq` 奇偶选半区,零忙等) | **仅任务上下文**(快照走临界区);只搬码值,不做标定/运算;STM32 侧固定滞后 1 拍 |
|
| ADC 输入层 | `& '.venv\Scripts\python.exe' test/host/adc_input/run_unit_test.py` | `adc-input-unit-test` |
|
||||||
| `adc_input_raw(ch, &out)` | 按语义读原始码值(TPAFE 16 位补码 / STM32 0..4095) | 消费侧取数**唯一入口**;须先成功 `poll()`;`-2` = 该来源尚无有效数据 |
|
| 有效性校验 | `& '.venv\Scripts\python.exe' test/host/adc_health/run_unit_test.py` | `adc-health-unit-test` |
|
||||||
| `adc_input_desc(ch)` | 语义 → 物理通道描述(来源 + 下标/通道号) | 供自检注入、UI 通道序;返回静态地址,勿改 |
|
| 定值模型 | `& '.venv\Scripts\python.exe' test/host/prot_set/run_unit_test.py` | `prot-set-unit-test` |
|
||||||
| `adc_input_stm32_rc()` | STM32 侧最近一拍状态码(0 OK / -2 本拍无新帧) | 心跳日志 `adc1_rc` |
|
| 元件框架 | `& '.venv\Scripts\python.exe' test/host/prot_elm/run_unit_test.py` | `prot-elm-unit-test` |
|
||||||
| `adc_input_stm32_seq()` / `_errs()` / `_gaps()` / `_hw_errs()` | DMA 整帧完成计数 / 停摆(连续 ≥2 拍无新帧)/ 丢帧(seq 一次前进 >1)/ HAL 错误回调 | 心跳日志 `adc1_seq` / `adc1_err` / `adc1_gap` / `adc1_hw`——四类成因不同,**勿混读** |
|
| 频率测量 | `& '.venv\Scripts\python.exe' test/host/freq/run_unit_test.py` | `freq-unit-test` |
|
||||||
|
| 通道标定(含真实 cfg_file 引擎集成) | `& '.venv\Scripts\python.exe' test/host/adc_cal/run_unit_test.py` | `adc-cal-unit-test` |
|
||||||
|
| 遥测快照(布局钉死/双区无撕裂/品质汇总) | `& '.venv\Scripts\python.exe' test/host/meas/run_unit_test.py` | `meas-unit-test` |
|
||||||
|
| 录波环(几何与点序 / 冻结与丢样 / 封存握手 / 读窗夹取 / 自弃与冷却) | `& '.venv\Scripts\python.exe' test/host/wave_ring/run_unit_test.py` | 无专属钩子:靠 `test-coverage` 总闸真跑 |
|
||||||
|
| 录波段格式(尺寸等式 / CRC 锚点 / 编解码往返与拒绝清单 / 搬运游标 / 段名轮转) | `& '.venv\Scripts\python.exe' test/host/wave_store/run_unit_test.py` | 同上 |
|
||||||
|
| 录波触发与全链路(三条来路 / 同拍多请求 / 已封存区不变 / 字节逐列对环) | `& '.venv\Scripts\python.exe' test/host/wave_trig/run_unit_test.py` | 同上 |
|
||||||
|
|
||||||
**要点**
|
改本目录任一 `.c/.h`,提交时对应钩子自动跑。**用例清单与判据数字的属主**是各
|
||||||
|
`test/host/<模块>/` 与 `docs/测试说明/` 下的单测说明,本文不复制(三处抄数字必然打架)。
|
||||||
|
|
||||||
- 语义映射表 `k_adc_map[]` 的**唯一权威口径**是 `docs/硬件平台/ADC通道映射说明.md`:
|
录波三套**刻意不新增专属钩子**(Lead 裁定 2026-10-27:钩子口径冻结,新增套数由 `test-coverage`
|
||||||
`UA/UN/IA` → TPAFE `buf[6]/[5]/[2]`;`UB/UC/IB/IC/IN/TEMP` → ADC1 `CH14/3/6/1/2/5`。
|
总闸全量真跑并守基线回退,失败照样拦,只是归因落在总闸输出上)。两套录波单测之间刻意留了
|
||||||
**V0.2 改版只改这张表的内容**,本层 API 与上层语义不动。
|
一份**分工**:环那套管语义、段格式那套管字节、`wave_trig` 管「接起来之后的语义」(含把封存区
|
||||||
- STM32 侧是**顺序扫描**(Rank1..8 = CH0..CH7、Rank9 = CH14,与 `MX_ADC1_Init()` 严格一致),
|
逐列读出来与打包游标产出的字节逐字节对齐)。两套合成段落在临时目录并把绝对路径打进日志(日志里
|
||||||
9 个通道**不是同一时刻**(一轮 ≈67 µs)。v2.0 起这一轮由 **TIM3 TRGO 硬件触发 + DMA 自动搬运**,
|
同时标明是哪一份形态),要跟 PC 侧读段脚本互验时把当次产物拷进 `ci-artifacts/wave_interop/` 再报
|
||||||
消费侧**零忙等**(旧实现逐通道轮询、单通道超时 2 ms,已于 2026-10-10 移除)。
|
路径——**别拿旧副本当本次产物**(踩过:拷过一次之后没刷,别人读到的是上一轮的字节)。
|
||||||
代价:STM32 侧数据**固定滞后 1 拍(1 ms)**——与 TPAFE 侧本来就没有同刻性(N-18),无新增误差。
|
|
||||||
- 本文件是本层**唯一**允许出现 HAL 调用的地方(它必须驱动 ADC1);新增模块若不需要外设,
|
|
||||||
请保持 `sample_ring` / `phasor` 那种零依赖形态,才能免桩单测。
|
|
||||||
|
|
||||||
### 3.2 `sample_ring.c` / `sample_ring.h` — L2 输入采样环
|
### 4.3 板端
|
||||||
|
`/keil-build` 0 错误 0 警告 → `/serial-boot-test` 看 `[ADC_TASK]` 首拍 / 心跳(`phasor(...)`、`freq(...)`、
|
||||||
|
`cal: ua_mv=… vl=0x00 cl=0xE6 rail=…`、`ring` / `skip` / `adc1_*` / `tgap`);
|
||||||
|
健康判据 = **零 `[ERR]`**(健康告警与标定哨兵全以 `[ERR]` 开头,误报会被闭环直接抓住;
|
||||||
|
`vl` 在未标定默认构建必须为 0x00,未标定即闭锁落地后 `cal:` 行就是台架期望的直接证据)。
|
||||||
|
→ `docs/算法/采集与算法层设计说明.md` §12。
|
||||||
|
|
||||||
**解决的问题**:相量计算要"以当前时刻为终止的一周波",而 L1 每次只给最新一帧。
|
## 5. 新增一个算法模块的固定动作
|
||||||
本层按**语义通道**存最近 `SAMPLE_RING_CYCLES = 2` 个周波(`SAMPLE_RING_DEPTH = 40` 点),
|
|
||||||
与参考系统 `CN_BUFSAM_CYCLE = 2` 一致,并为后续加长数据窗(1.5~2 周波抗衰减直流)预留空间。
|
|
||||||
|
|
||||||
| API | 干什么 | 返回 |
|
|
||||||
|---|---|---|
|
|
||||||
| `sample_ring_init()` | 清全部通道历史与计数(幂等,内核启动前可调) | — |
|
|
||||||
| `sample_ring_push_frame(values, n)` | 整帧压入(长度须 `= ADC_SEM_COUNT`) | `0` / `-1` 参数错 |
|
|
||||||
| `sample_ring_window(ch, n, out)` | 取该语义通道最近 `n` 点,**按时间序旧 → 新**(`out[n-1]` 最新) | `0` / `-1` 参数错 / `-2` 点数不足 |
|
|
||||||
| `sample_ring_filled()` | 已累积点数(**饱和**于 40)——判"够不够一周波" | `0..40` |
|
|
||||||
| `sample_ring_frames()` | 累计入环帧数(**不饱和**,1 kHz 下约 49.7 天回绕) | 帧数 |
|
|
||||||
|
|
||||||
**要点**
|
|
||||||
|
|
||||||
- **整帧写入后统一推进写指针** → 单帧内不可能"半新半旧",消费侧取到的窗永远落在整帧边界。
|
|
||||||
- 本层**只做按时序存/取,不做任何跨通道运算**:同刻性由上游 L1 负责,本层既不保证也不假设。
|
|
||||||
- `frames()` 与 L1 的 `tpafe5160_frame_seq()` 对照即可判丢帧(L1-6 的判据之一);
|
|
||||||
本层只提供计数,**判定不在这里**。
|
|
||||||
- 上层若某通道本拍取数失败,应**整拍跳过入环**(现 `adc_task` 的 `s_frames_skip` 即此),
|
|
||||||
切勿补 0 点——0 会污染相量窗。
|
|
||||||
|
|
||||||
### 3.3 `phasor.c` / `phasor.h` — L2 相量计算
|
|
||||||
|
|
||||||
**算法**:全周傅氏(单频 DFT),`N = PHASOR_N = 20`(1 kHz @ 50 Hz)。系数表
|
|
||||||
`k_fft_sin/k_fft_cos` **离线生成**并**预乘 √2/N**(口径 `docs/算法/傅氏算法详解.md` §2.3),
|
|
||||||
故 `fAmSQ = √(fRe²+fIm²)` **直接就是有效值**,`fAm = √2·fAmSQ` 为峰值。运行期不调 `sinf/cosf`。
|
|
||||||
|
|
||||||
| API | 干什么 | 注意 |
|
|
||||||
|---|---|---|
|
|
||||||
| `phasor_init()` | 双区清零 + 使用区/就绪复位 + `sample_ring_init()` | 幂等;内核启动前调用 |
|
|
||||||
| `phasor_inst_count()` / `phasor_inst(idx)` | 遍历实例表(`{name, sem, harm}`) | 返回静态地址,勿改;越界 `NULL` |
|
|
||||||
| `phasor_run()` | 逐实例取 20 点窗 → 重算 → 写计算区 → **一次性切换使用区** | 仅任务上下文;须先 `push_frame()` |
|
|
||||||
| `phasor_get(idx, &out)` | 读**使用区**结果 | `0` / `-1` 参数错 / `-2` 未攒满一周波 |
|
|
||||||
| `phasor_calc(x, n, harm, &out)` | 对给定窗口算 `harm` 次相量 | **纯函数**,可脱离采样环独立单测 |
|
|
||||||
|
|
||||||
**要点**
|
|
||||||
|
|
||||||
- **实例表数据驱动**(`k_inst[]`,现 5 行):`C01-IA-F1`(过流/过负荷)、`C02-UA-F1`(过压/欠压/频率)、
|
|
||||||
`C03-UN-F1`(零序过压)、`C04-IA-F2`(二次谐波制动)、`C05-IA-F5`(五次谐波选线)。
|
|
||||||
**新增实例只在表尾加一行**,遍历/日志/单测自动适配。
|
|
||||||
- **双区(计算区 / 使用区)**:结果先写非使用区,全部实例写完后单字节 `volatile` store 切换
|
|
||||||
`s_use_zone`(Cortex-M 上原子)→ 消费侧无锁、无撕裂。今日唯一消费者是 adcTask 自身,
|
|
||||||
该机制为后续独立 L3 保护任务预留。
|
|
||||||
- **任一实例数据不足一周波 → 本拍整体不产出**(不切换使用区),避免"部分新部分旧"的混合结果。
|
|
||||||
- 每拍 20 点**全部重算**(暴力法,无累积误差;迭代加减首尾项会累积)。相量刷新周期 = 1 ms。
|
|
||||||
- 相位(L2-4)**未常驻**结构体,需要时自行 `atan2f(fIm, fRe)`。
|
|
||||||
|
|
||||||
### 3.4 `adc_health.c` / `adc_health.h` — L1-6 采样有效性判定
|
|
||||||
|
|
||||||
**解决的问题**:驱动侧只有"观测计数"(`tpafe5160_frame_seq/_frstdata_err`、
|
|
||||||
`adc_input_stm32_seq/_errs/_gaps/_hw_errs`),**没有判定**——芯片静默、消费者超拍丢帧、
|
|
||||||
计数器被改写这些故障此前只能靠人读心跳日志发现。本层把判定收进纯逻辑,
|
|
||||||
告警编排留在消费者(`adc_task.c` 的 `adc_health_report()`)→ 判定可主机免桩单测。
|
|
||||||
|
|
||||||
| API | 干什么 | 注意 |
|
|
||||||
|---|---|---|
|
|
||||||
| `adc_health_init()` | 清全部状态(幂等) | 首拍 `check()` 只建基线、不判定 |
|
|
||||||
| `adc_health_check(&in, &out)` | 吃计数快照做一次判定(每拍调,常数次整数运算) | 差值法 `(int32_t)(now-prev)`,tick/计数回绕安全 |
|
|
||||||
| `adc_health_flags()` | 电平故障位:`TPAFE_STALL` / `STM32_STALL`(≥100 ms 无前进,可自愈)/ `SEQ_BACKWARD`(锁存) | 为 L5-4 闭锁预留 |
|
|
||||||
| `adc_health_sticky()` | 锁存位(含 `FRSTDATA` / `STM32_ERR` / `HW_ERR` 增量痕迹) | 自愈后仍留痕 |
|
|
||||||
| `adc_health_tpafe_gaps()` | TPAFE 累计丢帧点数(序号前进 >1 的超出部分) | 与 STM32 侧 `adc_input_stm32_gaps()` 对称 |
|
|
||||||
|
|
||||||
**要点**
|
|
||||||
|
|
||||||
- **三类语义严格分开**:电平位(stall,可自愈)/ 锁存位(出现过必留痕)/ 边沿事件位
|
|
||||||
(仅当拍有效)。`ADC_HE_FRSTDATA/STM32_ERR/HW_ERR` 与同名 `ADC_HF_*` **编号对齐**,
|
|
||||||
消费者直接用事件掩码对"已留痕位图"做每类一次 `SYSLOG_SELFTEST` 的编排。
|
|
||||||
- **gap 不告警**:消费者超拍(心跳日志 ~2 ms)是已知现象,`adc1_gap`/`tgap` 每 10 s 心跳
|
|
||||||
可见;若按事件告警会把真故障淹掉(台架实测开机 `adc1_gap≈24`、稳态每 10 s +2)。
|
|
||||||
- **静默 ≠ 丢帧**:d=0 才是静默候选;d>1 只说明消费者没跟上,生产者还活着——两者
|
|
||||||
处置完全不同,判据不许混。
|
|
||||||
- 告警策略(在 `adc_task.c`,不在本层):严重事件/电平上升沿 `DBG_ERROR` +
|
|
||||||
`SYSLOG_SELFTEST`(每类每次开机一次,防刷屏写满 32KB selftest.log);下降沿 `DBG_INFO`。
|
|
||||||
|
|
||||||
### 3.5 `prot_set.c` / `prot_set.h` — 保护定值模型(P1 / L5-1 载体)
|
|
||||||
|
|
||||||
**解决的问题**:保护判据的"输入宪法"。参考系统把定值放 EEPROM + `tagPRS` 常量表
|
|
||||||
校验和自检;本层做 **RAM 模型 + 持久化纯逻辑层**(blob 序列化/解析;文件胶水在
|
|
||||||
`App/cfg/prot_set_store.c`,B7),把编号 / 通讯地址 / 范围 / 默认值 / 校验和闭环钉死。
|
|
||||||
|
|
||||||
| API | 干什么 | 注意 |
|
|
||||||
|---|---|---|
|
|
||||||
| `prot_set_init()` | 装默认值 + 封存校验和(幂等) | 内核启动前可调 |
|
|
||||||
| `prot_set_load_raw(vals)` | 整表装载(B7 由 `prot_set_store_parse` 内部走同一路径),逐项范围校验 | **all-or-nothing**:任一项越界整表不装载,`prot_set_bad_mask()` 指认越界项;影子区 + 单字交换,读侧无中间态 |
|
|
||||||
| `prot_set_set(id, q10)` | 单项写入(范围校验,批处理路径) | **不自动重封存**——批量改完必须 `prot_set_seal()`,否则 selfcheck 判"非法改写";板端单项写一律用下行 |
|
|
||||||
| `prot_set_set_sealed(id, q10)` | **原子写+封存(B7)**:影子区改好重封后单字交换 | selfcheck 永不见中间态(收口 B5 竞态窗口);置脏标志 |
|
|
||||||
| `prot_set_dirty_peek/take/mark()` | 脏标志三件套(B7 落盘握手) | peek=iec104Task 通知;take=属主落盘前消费;mark=写失败回置重试 |
|
|
||||||
| `prot_set_store_serialize/parse(buf,len)` | 持久化 blob(`PSET`+版本+值区+sum32 正/反码对) | parse **整表原子**:magic/版本/长度/校验对/范围任一失败 = 一字不动 |
|
|
||||||
| `prot_set_get_f()` / `get_ctl()` | 工程量浮点 / 投退读取 | 单字读原子 |
|
|
||||||
| `prot_set_selfcheck()` | 重算 sum32 与封存正/反码比对,失配 → 闭锁 | L5-1;seqlock 式复核:提交撞见 = 本轮跳过不误锁;`prot_set_seal()` 是解锁的唯一合法路径 |
|
|
||||||
|
|
||||||
**要点**
|
|
||||||
|
|
||||||
- **Q10 定点**(×1024)与参考 `2^10` 折算口径一致;元数据表(地址/范围/默认)是
|
|
||||||
ROM 常量,校验和只覆盖 RAM 值区。
|
|
||||||
- **通讯地址逐项对照参考** `Comm/Pub/Const_CommSetPara.c` `g_tCommSetParaTab`
|
|
||||||
(基址 0x8820);单测 TC-PS-006 保证地址不重复、`min ≤ def ≤ max`、投退项 0..1。
|
|
||||||
- ⚠ **默认值/范围为拟定值**(参考 EEPROM 出厂表不在本仓库),待对照修订——
|
|
||||||
见规划 §5.4 落实进度注。
|
|
||||||
- **B8 扩 8 项(32→40)**:`PS_OF_EN/OF_HZ/OF_DLY/UF_EN/UF_HZ/UF_DLY/RET_OF/RET_UF`
|
|
||||||
**尾追加**(编号不回收,枚举序 = 数组序 = 校验和覆盖序)。OF/UF 的逐项通讯地址
|
|
||||||
不在本仓库(参考保护定值段 0x8820~0x8882 仅示"含低压/低频"类目)→ 全部按
|
|
||||||
**本地项 comm_addr=0** 登记(104 C_SE 不可寻址),待对照参考表补。
|
|
||||||
频率返回系数**独立**(`RET_OF=0.99 / RET_UF=1.01`,返回门槛 = 定值×系数)——
|
|
||||||
频率滞回带与电气量 0.95/1.05 不同量级,勿共用。
|
|
||||||
- **B8 换代语义**:项数变 → blob 144→176 B、`PROT_SET_BLOB_VER` 1→2。B7 旧档
|
|
||||||
(ver=1/32 项)在新固件 parse 即被**版本/长度闸**拒绝 → 出厂默认;
|
|
||||||
`prot_set_store_load()` 返回码分流:**2 = 换代**(`prot_set_store.c` 打 INFO
|
|
||||||
"prot set blob version change, defaults in use",**不是** CORRUPT、不记自检),
|
|
||||||
−1 = 真损坏(defaultTask 打 ERROR + 自检留痕)。升级路径不得报成损坏。
|
|
||||||
- **bad_mask 随项数扩为 `uint64_t`**(bit i = 第 i 项越界;消费方仅主机单测,
|
|
||||||
板端闭锁走 `prot_set_locked()`)。
|
|
||||||
|
|
||||||
### 3.6 `prot_elm.c` / `prot_elm.h` — 保护元件框架(L3,P1 子集)
|
|
||||||
|
|
||||||
**解决的问题**:参考系统 89 个 `PRS_*.c` 各自实现"滞回 + 定时限"——本框架把这段
|
|
||||||
公共核收敛为**一个通用核 + 数据驱动实例表**(同 `phasor` 的 `k_inst` 模式),
|
|
||||||
新增元件 = 表加一行 + 定值加几项。
|
|
||||||
|
|
||||||
| API | 干什么 | 注意 |
|
|
||||||
|---|---|---|
|
|
||||||
| `prot_elm_init()` | 复位状态 + 解析相量实例索引(按名前缀 `C01/C02/C03`) | 须在 `phasor_init()` 之后调;`src=FREQ` 行无相量绑定(`pidx=0xFF`,run 不查它) |
|
|
||||||
| `prot_elm_eval(now_ms, meas)` | **纯判据**:定值 RAM + 测量数组 → 全元件状态机 | 主机单测入口;NULL 测量按 0 |
|
|
||||||
| `prot_elm_run(now_ms)` | 适配层:按实例表 `src` 组测量 → eval(`src=0` 取 `phasor_get()` rms,`src=1` 取 `freq_get()`) | 相量类任一未就绪 → **整拍跳过**(不引入陈旧判据);频率类无有效读数 → **按方向填"必不启动"值**(过量填 0 / 欠量填 `1e30` → 滞回比较恒假 → 元件复位),**不整拍跳过**——频率源缺失不得饿死电流/电压类元件 |
|
|
||||||
| `prot_elm_state()` / `prot_elm_active_mask()` | 状态快照 / 动作位图 | 供 L4 出口与心跳日志 |
|
|
||||||
|
|
||||||
**要点**
|
|
||||||
|
|
||||||
- **滞回**:已启动用返回门槛(`定值 × 返回系数`),未启动用动作门槛——
|
|
||||||
过量/欠量方向由实例表 `over` 位统一处理(`UV`/`UF` 为欠量实例)。
|
|
||||||
- **定时限**:`(uint32_t)(int32_t)(now - t0) >= dly` 差值法,tick 回绕安全;
|
|
||||||
`dly=0` → 启动同拍即动作。返回**即时**(参考带 10 ms 返回延时,P4 接出口硬件时再补)。
|
|
||||||
- **闭锁联动**:`prot_set_locked()`(L5-1)或投退=0 → 该元件状态整体复位、不判据。
|
|
||||||
- **V0.1 实例**(N-18):`OC1/OC2/OC3/OL`(IA)、`OV/UV`(UA)、`3U0`(UN 外部通道)、
|
|
||||||
`OF/UF`(freq 层测频,B8)。零序过流(IN 走 STM32)**故意不在表中**。
|
|
||||||
- **OF/UF 出口可见性(B8)**:两元件**未单独映射 104 遥信点**(送检点表 = B10 口径),
|
|
||||||
其动作目前**只经 YX15 保护总聚合可见**(`iec104_pts` 的 fault-total 掩码含 OC/3U0,
|
|
||||||
OF/UF 动作会置总位但无独立分点)——README 与规划 §3.3 L3-5 行必须同此口径,勿写"独立遥信"。
|
|
||||||
- **板端接线已落地(B5,2026-10-12)**:`App/task/protect_task.c`(protectTask,
|
|
||||||
20 ms 节拍)消费 `prot_elm_run()` + L5-4 采样故障闭锁 + L5-1 校验和巡检;
|
|
||||||
跨上下文复归走 `prot_elm_request_reset()`(104 YK11 落请求、eval 头部消费)。
|
|
||||||
判据结果经 104 遥信上送(`iec104_pts` YX15~23/31)。→ `docs/算法/继电保护功能复现规划.md` §7
|
|
||||||
⚠ 实例数 7→9(B8):protectTask 启动锚点 `elm=` 与 `prot_elm_active_mask()` 位宽随之变,
|
|
||||||
`.serialtest` 锚点表未含 `elm=` 数字(已核),无需改配置。
|
|
||||||
|
|
||||||
### 3.7 `freq.c` / `freq.h` — 频率测量层(L2 派生量,L3-5 测量输入,B8)
|
|
||||||
|
|
||||||
**解决的问题**:过频/低频(L3-5)需要系统频率读数,而 L2 只出相量。本层把
|
|
||||||
"相邻两拍基波相量的相位差"变成"频率",并解决**非整周波窗长的 DFT 泄漏纹波**
|
|
||||||
这一决定精度的关键问题。
|
|
||||||
|
|
||||||
| API | 干什么 | 注意 |
|
|
||||||
|---|---|---|
|
|
||||||
| `freq_init()` | 清增量环/计数/读数/相位基准/有效标志(幂等) | 内核启动前可调,须在 `phasor_init()` 之后无要求 |
|
|
||||||
| `freq_run()` | 每拍步进:选源(UA→IA 过幅值门)→ 共轭乘积 `P=Z(m)·conj(Z(m-1))` 取角 → 假值钳制 → 自适应窗均值 | **仅 adcTask 上下文**,须在 `phasor_run()` 之后同拍调 |
|
|
||||||
| `freq_get(out)` | 读数(Hz) | 0 有读数(可能是保持值)/ −1 参数错 / −2 从未启动收敛(写 0) |
|
|
||||||
| `freq_valid()` | "本拍新鲜"标志 | 1 = 幅值门 OK 且已收敛;保护消费口径 = `get()==0 && valid()==1` |
|
|
||||||
| `freq_last()` | 最近有效读数 | 从未收敛返回 0.0 |
|
|
||||||
|
|
||||||
**算法要点(精度是实测驱动定稿的,两轮弯路记录在 freq.c 文件头)**
|
|
||||||
|
|
||||||
- **相位微分免解缠绕**:`arg(Z_now·conj(Z_prev))` 天然落在 (−π, π],纯音每拍
|
|
||||||
角**增大** 2πf/Fs(本层相量约定 φ=atan2(fIm,fRe)=窗首初相,窗每拍前滑一点);
|
|
||||||
45~55 Hz 段 |Δφ| ≈ 0.28~0.35 rad,距 ±π 裕度巨大。
|
|
||||||
- **自适应窗长**(核心):20 点矩形窗在非整周波时混入**负序泄漏项**
|
|
||||||
(45/55 Hz 处 |S₂/S₁| ≈ 3~5%),相量端轨迹成椭圆 → 逐拍相位差带周期
|
|
||||||
δ(φ) 纹波,周期 = fs/(2f) ≈ 9.1~11.1 拍。定长窗平均不整除纹波周期 →
|
|
||||||
扫频最坏误差 **0.317 Hz**(40 拍算术平均)/ **0.197 Hz**(60 拍复数和 Kay,
|
|
||||||
交叉项和角不随 K² 衰减,勿再尝试)。改 `K_eff = round(8·Fs/(2·f̂))`
|
|
||||||
(45~55 Hz 段 73~89 拍)使窗长恰为纹波周期整数倍 → 纹波相消,最坏 **0.0101 Hz**。
|
|
||||||
- **假值钳制带 35~65 Hz**(宽于系统合理带 40.5~60.5 = 参考 `CN_HERTZ_SOFTFR_MIN/MAX`,
|
|
||||||
规划 §10.1):单拍相位差本身带 ±5 Hz 纹波,钳制带贴边会**非对称丢弃纹波半周**
|
|
||||||
→ 均值偏置(实测 45 Hz 点 +0.16 Hz,已修)。35~65 仍拦得住窗滑 2 拍的 ≈2f
|
|
||||||
(90~110 Hz)与相位跳变假值;系统合理带由 OF/UF 定值范围在消费侧把关。
|
|
||||||
- **幅值门** `FREQ_AMP_MIN=50.0`(原始码值口径,**拟定待 L1-5 标定复核**):
|
|
||||||
低于门 = 无有效波形(浮空/PT 断线 rms≈0,相位无意义)→ 停更新、`valid` 清零、
|
|
||||||
读数保持 → 台架浮空不发散。信号消失过渡段(≤20 ms 残窗)读数漂移有界 ≤0.3 Hz。
|
|
||||||
- **源优先级 UA(C02)→IA(C01)**,按名前缀解析、每拍动态回退(台架只注电流亦可测频);
|
|
||||||
源切换即废相位基准重建(UA/IA 无共同相位基准,N-18)。
|
|
||||||
|
|
||||||
**精度判据(J1~J6,与 `test/host/freq/freq_test.c` 文件头同口径,数字为实测)**
|
|
||||||
|
|
||||||
| 判据 | 条件 | 门槛 | 实测最坏 |
|
|
||||||
|---|---|---|---|
|
|
||||||
| J1 扫频 | 纯音 45.00~55.00 Hz,0.5 Hz 步进(21 点) | **≤ 0.02 Hz** | **0.0101 Hz** @55.0 Hz |
|
|
||||||
| J2 噪声 | 幅值 1000 码、噪声 rms = 1% 幅值 | **≤ 0.10 Hz** | **0.0160 Hz** |
|
|
||||||
| J3 启动收敛 | 首个有效相量 → 首个有效读数 | **≤ 40 ms** | 20 ms(@39 ms 拍,相量 @19 ms) |
|
|
||||||
| J4 频率阶跃 | 50→51 Hz 进入 ±0.05 Hz 并保持 | **≤ 120 ms** | 98 ms(稳态 err 0.0053 Hz) |
|
|
||||||
| J5 零幅/浮空 | rms < 幅值门 | valid=0、不发散、保持漂移 ≤0.3 Hz | 漂移 0.13 Hz,无 NaN |
|
|
||||||
| J6 假值钳制 | 幅值+频率双跳变拍 | 60 ms 内回 ±0.05 Hz | 0.0000 Hz |
|
|
||||||
|
|
||||||
附加鲁棒性(同套件实测):直流偏置 8000 码 → err 0.0018 Hz;3/5 次谐波 20%/10%
|
|
||||||
→ err 0.0065 Hz;UA 无幅值落 IA → err 0.0020 Hz。
|
|
||||||
|
|
||||||
**J4 = 120 ms 是"精度优先于响应"的显式取舍(Lead 批准 2026-10-12)**:自适应窗
|
|
||||||
(M=8 ≈ 4 个泄漏纹波周期 ≈ 4 个信号周波)换来 0.317→0.0101 Hz,代价是频率骤变后
|
|
||||||
的窗冲刷(实测 98 ms)。OF/UF 定时限默认 **1000 ms** → 测频响应占保护总动作时间
|
|
||||||
**<10%**;暂态假值另有 J6(跳变后 ≤60 ms 回 ±0.05 Hz)兜底。故 120 ms 是工程整定,
|
|
||||||
不是判据失守——若将来要压响应,须同时重估精度(缩短窗 = 精度回退到 0.2~0.3 Hz 档)。
|
|
||||||
|
|
||||||
**板端可见性(DoD)**:adcTask 心跳/首拍日志追加一行
|
|
||||||
`freq(hb): f=5000 valid=1 (x0.01)`(MicroLIB 无 %f → ×0.01 定点,分辨率 0.01 Hz)。
|
|
||||||
正常带波形应 `valid=1` 且 `f≈5000`;浮空应 `valid=0`、`f` 为保持值。
|
|
||||||
|
|
||||||
## 4. 关键约定
|
|
||||||
|
|
||||||
- **语义映射唯一权威口径**:`docs/硬件平台/ADC通道映射说明.md`。取数一律经 `adc_input_raw()`,
|
|
||||||
禁止在消费侧直接索引驱动缓冲或写死下标。
|
|
||||||
- **禁止跨转换器运算**(V0.1 硬件缺陷 N-18):`UA/UN/IA` 在 TPAFE、`UB/UC/IB/IC/IN` 在 STM32 ADC1,
|
|
||||||
两者无共同采样触发 → 无同一时刻性、相位不确定。故本层**不做**跨转换器的相位 / 功率 /
|
|
||||||
功率因数 / 负序零序类矢量运算(V0.2 把 8 路电气量全接 TPAFE 后方可解禁)。
|
|
||||||
- **标定(L1-5)尚未实现**(缺标准源):现以**原始码值**充当工程量(系数 = 1),
|
|
||||||
因此相量读数只证明**链路通**,**不可作幅值精度判据**。
|
|
||||||
- **本层不建任务、不等事件、不写日志之外的副作用**;日志由调用侧(adcTask)打。
|
|
||||||
- 内核启动前只允许调 `*_init()`;`adc_input_poll()` / `phasor_run()` 仅任务上下文。
|
|
||||||
|
|
||||||
## 5. 静态足迹(BSS,供栈/RAM 预算参考)
|
|
||||||
|
|
||||||
| 位置 | 量 |
|
|
||||||
|---|---|
|
|
||||||
| `sample_ring.c` `s_ring[9][40]` float | **1440 B** |
|
|
||||||
| `phasor.c` `s_zone[2][5]` `phasor_t` | 160 B |
|
|
||||||
| `adc_input.c` `s_stm32_raw[16]` + `s_tpafe_raw[8]` | 48 B |
|
|
||||||
| `adc_health.c` 内部状态(9 × uint32 + uint8) | ≈ 40 B |
|
|
||||||
| `prot_set.c` `s_val[2][40]` + 封存/位图(64 位)/闭锁 | 336 B(B8:31→40 项) |
|
|
||||||
| `prot_elm.c` `s_rt[9]`(16 B/实例) | 144 B(B8:7→9 实例) |
|
|
||||||
| `freq.c` 相位增量环 `fq_d[96]` + 占位标志 + 状态 | **≈ 520 B**(B8 新增) |
|
|
||||||
| 运行期栈:`phasor_run()` 的 `win[20]` | 80 B/帧 |
|
|
||||||
| 运行期栈:`freq_run()` 局部 | ≈ 30 B/帧(无大局部数组) |
|
|
||||||
|
|
||||||
改容量(`SAMPLE_RING_CYCLES` / 实例数 / `ADC_SEM_COUNT` / `FREQ_AVG_TAPS`)前后各跑一次
|
|
||||||
`& '.venv\Scripts\python.exe' scripts/stack_report.py`(口径见 `mem-budget` skill)。
|
|
||||||
**B8 实测(adcTask 静态最坏链)**:见 READY 消息与 `docs/内存管理/` 台账回填。
|
|
||||||
|
|
||||||
## 6. 构建与测试
|
|
||||||
|
|
||||||
- **挂载**:八个 `.c` 已登记在 `MDK-ARM/STM32F407-Demo.uvprojx`,`../App/algo` 已在 `<IncludePath>` 内。
|
|
||||||
本层**不进** `simulator/CMakeLists.txt`(UI 模拟器不跑采集链路)——纯算法且模拟器无消费路径的
|
|
||||||
新文件(`prot_set.c` / `freq.c`)改在 `scripts/check_registration.py` 的 `SIM_EXEMPT` 登记豁免
|
|
||||||
(`registration-check` 钩子反向校验,N-23)。
|
|
||||||
- **主机侧单测(L0.5)**——生产源文件以 `#include` 原样纳入,无需改产品代码:
|
|
||||||
|
|
||||||
| 套件 | 命令 | pre-commit 钩子 |
|
|
||||||
|---|---|---|
|
|
||||||
| 相量层(57 用例:系数表逐项复核 / 幅值·相位 / 谐波抑制与提取 / 频偏 / 采样环 / 双区交换) | `& '.venv\Scripts\python.exe' test/host/phasor/run_unit_test.py` | `phasor-unit-test` |
|
|
||||||
| ADC 输入层(逐 Rank 注入互异码值钉死映射 + HAL 启动失败/超时分支) | `& '.venv\Scripts\python.exe' test/host/adc_input/run_unit_test.py` | `adc-input-unit-test` |
|
|
||||||
| 有效性校验(31 用例:100 ms 静默门限两侧 / 丢帧边沿 / 倒退锁存 / 错误增量 / tick 回绕 / NULL 守卫 / init 幂等) | `& '.venv\Scripts\python.exe' test/host/adc_health/run_unit_test.py` | `adc-health-unit-test` |
|
|
||||||
| 定值模型(90 用例:范围校验 / all-or-nothing 装载 / 封存-自检-闭锁 / Q10 往返 / 元数据表自检 / B7 blob 往返与损坏拒绝 / **B8 OF-UF 行 + 64 位 bad_mask + 换代拒绝**) | `& '.venv\Scripts\python.exe' test/host/prot_set/run_unit_test.py` | `prot-set-unit-test` |
|
|
||||||
| 元件框架(47 用例:滞回与定时限 / 欠量方向 / 投退 / 闭锁联动 / 时基回绕 / 位图聚合 / **B8 OF-UF 滞回与方向隔离 + run() 测频接线**) | `& '.venv\Scripts\python.exe' test/host/prot_elm/run_unit_test.py` | `prot-elm-unit-test` |
|
|
||||||
| **频率测量(46 用例:J1 扫频 45~55 Hz / J2 噪声 / J3 启动收敛 / J4 阶跃 / J5 浮空不发散 / J6 假值钳制 / 直流·谐波抑制 / UA→IA 回退 / 守卫与幂等)** | `& '.venv\Scripts\python.exe' test/host/freq/run_unit_test.py` | `freq-unit-test`(**待登记**,合并时由 Lead 加钩子) |
|
|
||||||
|
|
||||||
改本目录任一 `.c/.h`,提交时对应钩子自动跑;详见 `docs/测试说明/相量层单元测试说明.md`。
|
|
||||||
- **板端**:`@build` 0 错误 0 警告 → `@test` 看 `[ADC_TASK]` 首拍/心跳日志(`phasor(...)` 行、
|
|
||||||
`freq(...)` 行、`ring` / `skip` 计数、`adc1_rc` / `adc1_err`、`tgap`);健康判据 = 零 `[ERR]`
|
|
||||||
(`adc_health_report` 的告警全部以 `[ERR]` 开头 → 误报会被闭环直接抓住)。
|
|
||||||
- **定值档升级 vs 损坏(B8,台架判读)**:`prot_set_store_load()` **rc=2 = 换代**
|
|
||||||
(旧固件/旧项数的档被版本闸拒绝)→ 只打 INFO `prot set blob version change, defaults in use`,
|
|
||||||
**不是** `[ERR] ... CORRUPT`、不记自检;**rc<0 = 真损坏** → `[ERR] prot set blob CORRUPT` + 自检留痕。
|
|
||||||
升级固件后首启看到"version change"一行属**预期**,勿当故障排查。
|
|
||||||
|
|
||||||
## 7. 新增一个算法模块的固定动作
|
|
||||||
|
|
||||||
1. 在本目录建 `xxx.c/h`,文件头写模块注释块(规范 V1.0),**尽量零平台依赖**以便免桩单测。
|
1. 在本目录建 `xxx.c/h`,文件头写模块注释块(规范 V1.0),**尽量零平台依赖**以便免桩单测。
|
||||||
2. 用 `new-source-file` skill 注册到 `MDK-ARM/STM32F407-Demo.uvprojx`(目录已在 IncludePath,
|
需要 `NULL` 的 `.c` 自己 `#include` 一个 `stddef.h`:ARMCC 的 `stdint.h` 不提供 `NULL`,而主机侧
|
||||||
若新建子目录则必须补 IncludePath)。模拟器侧二选一:有消费路径 → 加
|
MSVC / gcc 会传递性把它拉进来,**主机单测照不出这个分叉**(2026-10-29 台基线构建实测报 22 条
|
||||||
`simulator/CMakeLists.txt`;**纯算法无消费路径 → 加 `scripts/check_registration.py`
|
identifier "NULL" 未定义,根因即此;与 `sample_ring.c` / `freq.c` 同口径,别塞进公共头)。
|
||||||
的 `SIM_EXEMPT`**(照 `prot_set.c` / `freq.c` 先例写理由),否则 `registration-check` 钩子拦。
|
2. 用 `new-source-file` skill 注册到 `MDK-ARM/STM32F407-Demo.uvprojx`(新建子目录还要补 `<IncludePath>`)。
|
||||||
3. 加 `test/host/<模块>/` 主机单测 + `.pre-commit-config.yaml` 钩子(照 `phasor-unit-test` 抄)。
|
模拟器侧二选一:有消费路径 → 加 `simulator/CMakeLists.txt`;纯算法无消费路径 → 加
|
||||||
4. 消费者写在 `App/task/`,本层只出"数";跨转换器运算的禁令见 §4。
|
`scripts/check_registration.py` 的 `SIM_EXEMPT`(照本目录既有七条写理由),否则 `registration-check` 拦。
|
||||||
|
3. 加 `test/host/<模块>/` 主机单测(照 `test/host/meas` 或 `test/host/soe_store` 抄目录形态与
|
||||||
|
`run_unit_test.py`)。**要不要补 `.pre-commit-config.yaml` 专属钩子按 Lead 裁定 2026-10-27:默认不补**,
|
||||||
|
由 `test-coverage` 总闸全量真跑;确有秒级归因需求再单独申请。用例标题写「它在验证什么」这类语义
|
||||||
|
短语,**不要发字母紧跟数字的新编号**(`docs/编号规范.md` §4.1「已停发」栏:停发不等于可以新发)。
|
||||||
|
4. 消费者写在 `App/task/`,本层只出"数";跨转换器禁令见 §3 开头四条红线。
|
||||||
|
5. 在本 README §1 清单加一行,并在 `docs/算法/采集与算法层设计说明.md` 里写清该模块的契约与理由。
|
||||||
|
6. **单测产出的互验字节(要被别人的脚本读的那些):跑测之后立刻刷新副本并记下 sha256**。三套录波单测把合成段落
|
||||||
|
在临时目录、并把**绝对路径 + 是哪一份形态**打进日志,所以「跑完顺手拷一次、以后再没刷」必然留下旧副本——
|
||||||
|
本仓这类坑不止一次(旧副本被当本次证据引用过:一次是别人拿上一轮的字节下了「字节级已闭合」的结论)。
|
||||||
|
判据:引用方只认**当次运行日志里那条路径 + 当次 sha256**;产物有第二种形态(例如本波真值形态与「已标定后
|
||||||
|
仍削顶」的构造形态)就在文件名或日志里把形态写明,别让读者靠猜;拷进 `ci-artifacts/wave_interop/` 之类的
|
||||||
|
共享目录时,同一笔操作里就把新 sha 一起报给对岸,别隔一轮。
|
||||||
|
|
||||||
## 8. 相关文档
|
## 6. 相关文档
|
||||||
|
|
||||||
|
- **本层设计与判据**:`docs/算法/采集与算法层设计说明.md`
|
||||||
- 通道映射权威口径:`docs/硬件平台/ADC通道映射说明.md`
|
- 通道映射权威口径:`docs/硬件平台/ADC通道映射说明.md`
|
||||||
- 分层与路线图(L1/L2 清单与状态):`docs/算法/继电保护功能复现规划.md`
|
- 分层与功能项清单:`docs/算法/继电保护功能复现规划.md`
|
||||||
- 傅氏算法与有效值口径:`docs/算法/傅氏算法详解.md`
|
- 傅氏算法与有效值口径:`docs/算法/傅氏算法详解.md`
|
||||||
- 采样节拍(TIM3 1 kHz + TRGO 触发 ADC1/DMA):`docs/子系统设计/ADC采样节拍设计说明.md`
|
- 采样节拍(TIM3 1 kHz + TRGO 触发 ADC1 / DMA):`docs/子系统设计/ADC采样节拍设计说明.md`
|
||||||
- 相量层单测细则:`docs/测试说明/相量层单元测试说明.md`
|
- **故障录波**(数据面 / 段格式 / 现场回放契约):docs/子系统设计/故障录波设计说明.md §6.2 是段文件
|
||||||
|
字节布局的属主,同一篇的 docs/子系统设计/故障录波设计说明.md §6.3 是字段口径裁定——两节合起来是板端
|
||||||
|
编码器与 PC 读段脚本两份独立实现的共同依据;板级判据与执行顺序 → docs/子系统设计/故障录波设计说明.md §10
|
||||||
|
- 相量层 / ADC 输入层单测细则:`docs/测试说明/单元测试/相量层单元测试说明.md`、`docs/测试说明/单元测试/ADC输入层单元测试说明.md`
|
||||||
|
- RAM 与栈台账:`docs/内存管理/RAM使用分析与优化方案.md`
|
||||||
|
|||||||
@@ -0,0 +1,487 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:通道标定与量纲折算(标定折算)——实现
|
||||||
|
* 模块功能:见 adc_cal.h。折算模型(工程量 = (code + b) × k × 1e-6,k 为 Q16
|
||||||
|
* µV/码)与两段 cfg 布局([adc_cal]:k_* + valid 位图;[adc_cal_ofs]:
|
||||||
|
* b_*)照 docs/任务规划/数据可信.md §4.1 / §4.4 落地;系数口径
|
||||||
|
* (ROM 默认 = 量程口径冻结定死的引脚侧 LSB)见 docs/硬件平台/ADC通道映射说明.md
|
||||||
|
* §7.1。
|
||||||
|
* 并发契约:cfg RAM 副本属主 = 配置引擎(defaultTask 的 load/poll 原地
|
||||||
|
* 改写),本层逐点折算现读——int32 对齐字在 Cortex-M4 上读写原子,
|
||||||
|
* poll 重载「复位默认 + 逐键解析」存在 µs 级窗口,撞上则该拍该通道
|
||||||
|
* 为默认值/半新值(相量 20 点窗摊薄,且触发面 = 用户改文件后 4 s
|
||||||
|
* 沉降一次性事件)——「数据可信」§4.4 明载此设计(不挂回调)。
|
||||||
|
* 合法带哨兵(防损坏文件/错误标定把假电压送进下游):k ∉ ROM×[0.5,2.0]
|
||||||
|
* 或 |b| > ADC_CAL_B_MAX → 该通道折算回退 ROM + b=0,flags 不报
|
||||||
|
* VALID,每通道每次开机首见沿打一条 [ERR](adc_cal_init 重置留痕)。
|
||||||
|
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
|
||||||
|
* 作者:DTU 项目组
|
||||||
|
* 创建日期:2026-10-25(道A 标定折算与落盘整定)
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-25 DTU 项目组 创建:Q16 系数/零点两段落盘 + 折算 + 三态 flags
|
||||||
|
* v1.1 2026-10-25 DTU 项目组 运行参数段(道D):新增 adc_cal_b_codes() 生效零点
|
||||||
|
* 读取口;set() 授权写者扩至 iec104Task 直通路径
|
||||||
|
* (run_par 0x8040~/0x8048~,契约同 §4.4)
|
||||||
|
* + 合法带哨兵回退 + 两点标定公式(台架注入待跑)
|
||||||
|
* v1.2 2026-10-26 DTU 项目组 道F task-16:新增 adc_cal_prot_blocked()——未标定
|
||||||
|
* 闭锁(UA/UN/IA 三路 VALID 全齐才放行)收成本层单点,
|
||||||
|
* DTU_BENCH 旁路同时收成全仓唯一一处;此前该判据在
|
||||||
|
* protect_task.c 与 ui_data_feed.c 各写一份,两处一旦
|
||||||
|
* 漂移,屏上「未标定」与判据实际停走就不是同一件事
|
||||||
|
* v1.3 2026-10-29 DTU 项目组 故障录波 task-2:新增 adc_cal_k_q16() int32 原值直读口
|
||||||
|
* (段头 k 字段 LSB 精确,float 反推在 5.28e7 掉尾数,
|
||||||
|
* 口径见 docs/子系统设计/故障录波设计说明.md §6.3)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 头文件包含区 */
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "adc_cal.h"
|
||||||
|
|
||||||
|
#include "cfg_file.h"
|
||||||
|
|
||||||
|
#define DBG_TAG "[CAL]"
|
||||||
|
#include "dbg_log.h"
|
||||||
|
|
||||||
|
/* 常量定义区 */
|
||||||
|
|
||||||
|
/* 段名(≤ CFG_FILE_MAX_NAME_LEN)与键名(k_ua…b_in,均 4/5 字符)
|
||||||
|
* ——「数据可信」§4.4 布局;键的书写顺序按该表,存储下标按语义枚举 */
|
||||||
|
#define ADC_CAL_SEC_K "adc_cal"
|
||||||
|
#define ADC_CAL_SEC_B "adc_cal_ofs"
|
||||||
|
|
||||||
|
/* Q16 → V 的合并比例因子:1e-6 / 65536(float 常量,精确到 1 ulp 以内即可:
|
||||||
|
* 系数本身 Q16 舍入误差 < 0.001 %,见「数据可信」§4.1) */
|
||||||
|
#define ADC_CAL_K_SCALE (1.52587890625e-11f)
|
||||||
|
|
||||||
|
/* 静态变量定义区 */
|
||||||
|
|
||||||
|
/* [adc_cal] 段 RAM 副本:k 按语义下标 0..7,valid 位图 bit=语义下标。
|
||||||
|
* data_size 取 offsetof(valid)+1 = 33(尾随对齐填充不属于引擎视图,
|
||||||
|
* 引擎「复位默认」按 data_size memcpy,必须覆盖 valid,见单测 TC-ADCCAL-001) */
|
||||||
|
typedef struct {
|
||||||
|
int32_t k[8];
|
||||||
|
uint8_t valid;
|
||||||
|
} adc_cal_kcfg_t;
|
||||||
|
|
||||||
|
/* [adc_cal_ofs] 段 RAM 副本:b 按语义下标 0..7(int32 有符号,有符号零点类型
|
||||||
|
* CFG_VAL_INT 是负零点偏移能干净落盘的前提) */
|
||||||
|
typedef struct {
|
||||||
|
int32_t b[8];
|
||||||
|
} adc_cal_bcfg_t;
|
||||||
|
|
||||||
|
static adc_cal_kcfg_t s_kcfg; /* 生效系数副本(引擎原地更新) */
|
||||||
|
static adc_cal_bcfg_t s_bcfg; /* 生效零点副本 */
|
||||||
|
|
||||||
|
/* 工厂默认(ROM 理论系数,量程口径冻结定死,「数据可信」§4.4):**下标 = 语义枚举**
|
||||||
|
* (UA0 / UB1 / UC2 / UN3 / IA4 / IB5 / IC6 / IN7)——UA/UN/IA 走 TPAFE
|
||||||
|
* (16 位 ±5 V 档),UB/UC/IB/IC/IN 走 STM32 ADC1;逐路对过
|
||||||
|
* docs/硬件平台/ADC通道映射说明.md §3(IN=ADC11/STM32,勿再按分组顺序误写)。
|
||||||
|
* ROM 表按**当前 V0.1 来源排布**显式书写(不查 desc 运行期派生),因为它是
|
||||||
|
* 量程口径冻结定稿的存档事实;V0.2 改版(消除双转换器无共同采样触发)时本表与 adc_input 映射表同步改,
|
||||||
|
* 上层语义与 API 不动。CLIPPED 位则**运行期按来源派生**(见 adc_cal_flags) */
|
||||||
|
static const adc_cal_kcfg_t s_kcfg_rom = {
|
||||||
|
/* idx0 UA / idx1 UB / idx2 UC / idx3 UN */
|
||||||
|
{ ADC_CAL_K_Q16_TPAFE, ADC_CAL_K_Q16_STM32, ADC_CAL_K_Q16_STM32,
|
||||||
|
ADC_CAL_K_Q16_TPAFE,
|
||||||
|
/* idx4 IA / idx5 IB / idx6 IC / idx7 IN */
|
||||||
|
ADC_CAL_K_Q16_TPAFE, ADC_CAL_K_Q16_STM32, ADC_CAL_K_Q16_STM32,
|
||||||
|
ADC_CAL_K_Q16_STM32 },
|
||||||
|
0u /* 出厂未标定:VALID 全 0 */
|
||||||
|
};
|
||||||
|
static const adc_cal_bcfg_t s_bcfg_rom = { { 0L, 0L, 0L, 0L, 0L, 0L, 0L, 0L } };
|
||||||
|
|
||||||
|
/* 合法带哨兵留痕位(bit=语义下标;每通道每次开机只打一条 [ERR],
|
||||||
|
* adc_cal_init 清零 = 新一轮观测窗) */
|
||||||
|
static uint8_t s_err_latched;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 观测计数(三态语义唯一落点在 adc_cal.h,这里只写"怎么自增"):与上面那个留痕位
|
||||||
|
* 不同,这三个数一个 [ERR] 都不打、也不参与任何判定,因此 **adc_cal_init() 刻意不清**
|
||||||
|
* ——清了水位与计数就失配,会凭空造出一段"假未落盘"差额。
|
||||||
|
*/
|
||||||
|
static volatile uint32_t s_st_set_ok; /* 成功改写 RAM 副本的次数 */
|
||||||
|
static volatile uint32_t s_st_set_reject; /* 被拒次数(非法通道 / 越出合法带) */
|
||||||
|
static volatile uint32_t s_st_persist_water; /* 落盘水位:盖写,绝不自增 */
|
||||||
|
|
||||||
|
/* 键表:offset 用 offsetof 取真值(k[0]@0 / k[1]@4 … valid@32) */
|
||||||
|
static const cfg_key_def_t s_k_keys_tbl[9] = {
|
||||||
|
{ "k_ua", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[0]), 4u },
|
||||||
|
{ "k_un", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[3]), 4u },
|
||||||
|
{ "k_ia", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[4]), 4u },
|
||||||
|
{ "k_ub", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[1]), 4u },
|
||||||
|
{ "k_uc", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[2]), 4u },
|
||||||
|
{ "k_ib", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[5]), 4u },
|
||||||
|
{ "k_ic", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[6]), 4u },
|
||||||
|
{ "k_in", CFG_VAL_INT, offsetof(adc_cal_kcfg_t, k[7]), 4u },
|
||||||
|
{ "valid", CFG_VAL_UINT, offsetof(adc_cal_kcfg_t, valid), 1u },
|
||||||
|
};
|
||||||
|
static const cfg_key_def_t s_b_keys_tbl[8] = {
|
||||||
|
{ "b_ua", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[0]), 4u },
|
||||||
|
{ "b_un", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[3]), 4u },
|
||||||
|
{ "b_ia", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[4]), 4u },
|
||||||
|
{ "b_ub", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[1]), 4u },
|
||||||
|
{ "b_uc", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[2]), 4u },
|
||||||
|
{ "b_ib", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[5]), 4u },
|
||||||
|
{ "b_ic", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[6]), 4u },
|
||||||
|
{ "b_in", CFG_VAL_INT, offsetof(adc_cal_bcfg_t, b[7]), 4u },
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 函数定义区 */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:判定语义通道是否 STM32 源(经 adc_input 权威映射表,不另立第二份表)
|
||||||
|
* 入口参数:sem - 语义通道 unsigned
|
||||||
|
* 返 回 值:1 - STM32;0 - TPAFE(含 desc 取不到时的保守分支) int
|
||||||
|
* 限定条件:无
|
||||||
|
*/
|
||||||
|
static int s_rom_k_stm32(unsigned sem) {
|
||||||
|
const adc_channel_desc_t *d = adc_input_desc((adc_semantic_t)sem);
|
||||||
|
|
||||||
|
return ((d != NULL) && (d->source == ADC_SRC_STM32)) ? 1 : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:取语义通道的 ROM 默认系数(合法带/回退参照)
|
||||||
|
* 入口参数:sem - 语义下标(0..7) unsigned
|
||||||
|
* 返 回 值:Q16 系数 int32_t
|
||||||
|
* 限定条件:sem < 8(调用方已先行拦截 TEMP/越界)
|
||||||
|
* 函数说明:直接读出厂表本体——保证「哨兵合法带基准」与「引擎工厂默认」
|
||||||
|
* 永远同一份数(两者若靠重复表达式各算各的,改一处漏一处就是暗雷)
|
||||||
|
*/
|
||||||
|
static int32_t s_rom_k(unsigned sem) {
|
||||||
|
return s_kcfg_rom.k[sem];
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:k/b 合法带检查(ROM×[0.5,2.0] / |b| ≤ ADC_CAL_B_MAX)
|
||||||
|
* 入口参数:sem - 语义下标(0..7) unsigned
|
||||||
|
* k - 待检系数 Q16 int32_t
|
||||||
|
* b - 待检零点 码值 int32_t
|
||||||
|
* 返 回 值:1 - 合法;0 - 越带 int
|
||||||
|
* 限定条件:sem < 8
|
||||||
|
*/
|
||||||
|
static int s_kband_ok(unsigned sem, int32_t k, int32_t b) {
|
||||||
|
const int32_t rom = s_rom_k(sem);
|
||||||
|
|
||||||
|
if (k < (rom >> 1) || k > (rom << 1)) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (b > ADC_CAL_B_MAX || b < -ADC_CAL_B_MAX) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:越带留痕(每通道每次开机首见沿一条 [ERR],init 重置)
|
||||||
|
* 入口参数:sem - 语义下标 0..7;k/b - 现场值
|
||||||
|
* 返 回 值:无
|
||||||
|
* 限定条件:仅 adc_cal_apply/set 内部
|
||||||
|
*/
|
||||||
|
static void s_note_bad(unsigned sem, int32_t k, int32_t b) {
|
||||||
|
const uint8_t bit = (uint8_t)(1u << sem);
|
||||||
|
|
||||||
|
if ((s_err_latched & bit) == 0u) {
|
||||||
|
s_err_latched |= bit;
|
||||||
|
DBG_ERROR("ch=%u k_q16=%ld b=%ld out of ROM band [0.5x,2x]/|b|<=%ld -> ROM fallback",
|
||||||
|
(unsigned)sem, (long)k, (long)b, (long)ADC_CAL_B_MAX);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读通道「当前生效且合法」的 k/b(越带就地回退 ROM)
|
||||||
|
* 入口参数:sem - 语义下标 0..7;p_k/p_b - 输出
|
||||||
|
* 返 回 值:1 - 用了合法值;0 - 已回退 ROM
|
||||||
|
* 限定条件:sem < 8
|
||||||
|
*/
|
||||||
|
static int s_eff_kb(unsigned sem, int32_t *p_k, int32_t *p_b) {
|
||||||
|
const int32_t k = s_kcfg.k[sem];
|
||||||
|
const int32_t b = s_bcfg.b[sem];
|
||||||
|
|
||||||
|
if (s_kband_ok(sem, k, b) != 0) {
|
||||||
|
*p_k = k;
|
||||||
|
*p_b = b;
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
s_note_bad(sem, k, b);
|
||||||
|
*p_k = s_rom_k(sem);
|
||||||
|
*p_b = 0L;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:标定层初始化——RAM 副本置 ROM 默认 + 注册两段进 cfg 引擎(幂等)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:无
|
||||||
|
* 限定条件:内核启动前调用(app_main_init);注册必须先于 cfg_file_load
|
||||||
|
* 函数说明:完整契约见 adc_cal_init() 同名声明(adc_cal.h);注册失败(段名重复/
|
||||||
|
* 段数超限)打 [ERR] 后继续——ROM 默认系数照常生效,仅文件整定失效
|
||||||
|
*/
|
||||||
|
void adc_cal_init(void) {
|
||||||
|
cfg_section_reg_t reg;
|
||||||
|
|
||||||
|
(void)memcpy(&s_kcfg, &s_kcfg_rom, sizeof(s_kcfg));
|
||||||
|
(void)memcpy(&s_bcfg, &s_bcfg_rom, sizeof(s_bcfg));
|
||||||
|
s_err_latched = 0u;
|
||||||
|
|
||||||
|
(void)memset(®, 0, sizeof(reg));
|
||||||
|
reg.name = ADC_CAL_SEC_K;
|
||||||
|
reg.p_data = &s_kcfg;
|
||||||
|
reg.p_defaults = &s_kcfg_rom;
|
||||||
|
reg.data_size = (offsetof(adc_cal_kcfg_t, valid) + 1u); /* 33:覆盖到 valid */
|
||||||
|
reg.p_keys = s_k_keys_tbl;
|
||||||
|
reg.key_count = 9u;
|
||||||
|
reg.p_cb = NULL; /* 不挂回调:RAM 副本即生效值(「数据可信」§4.4) */
|
||||||
|
if (cfg_file_register(®) != 0) {
|
||||||
|
DBG_ERROR("register '%s' FAIL (dup/full?) - calibration file values inactive",
|
||||||
|
ADC_CAL_SEC_K);
|
||||||
|
}
|
||||||
|
|
||||||
|
(void)memset(®, 0, sizeof(reg));
|
||||||
|
reg.name = ADC_CAL_SEC_B;
|
||||||
|
reg.p_data = &s_bcfg;
|
||||||
|
reg.p_defaults = &s_bcfg_rom;
|
||||||
|
reg.data_size = sizeof(s_bcfg); /* 32 */
|
||||||
|
reg.p_keys = s_b_keys_tbl;
|
||||||
|
reg.key_count = 8u;
|
||||||
|
reg.p_cb = NULL;
|
||||||
|
if (cfg_file_register(®) != 0) {
|
||||||
|
DBG_ERROR("register '%s' FAIL (dup/full?) - zero-offset file values inactive",
|
||||||
|
ADC_CAL_SEC_B);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:码值 → 工程量折算(「数据可信」§4.1 模型)
|
||||||
|
* 入口参数:sem - 语义通道;code - 原始码值 adc_semantic_t / int32_t
|
||||||
|
* 返 回 值:工程量浮点(未标定 = ADC 引脚电压 V;TEMP/越界 = 原样透传码值) float
|
||||||
|
* 限定条件:无(任意上下文,纯读)
|
||||||
|
* 函数说明:见 adc_cal.h 同名声明;k/b 经 s_eff_kb 合法带哨兵(越带回退 ROM)
|
||||||
|
*/
|
||||||
|
float adc_cal_apply(adc_semantic_t sem, int32_t code) {
|
||||||
|
int32_t k;
|
||||||
|
int32_t b;
|
||||||
|
|
||||||
|
if ((unsigned)sem >= (unsigned)ADC_SEM_TEMP) {
|
||||||
|
return (float)code; /* TEMP/越界:不纳入线性标定(决策 5),原样透传 */
|
||||||
|
}
|
||||||
|
(void)s_eff_kb((unsigned)sem, &k, &b);
|
||||||
|
return ((float)(code + b)) * ((float)k) * ADC_CAL_K_SCALE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读通道可用性标志(消费者判「能不能信」的唯一入口)
|
||||||
|
* 入口参数:sem - 语义通道 adc_semantic_t
|
||||||
|
* 返 回 值:ADC_CAL_F_VALID / ADC_CAL_F_CLIPPED 组合;TEMP/越界恒 0 uint32_t
|
||||||
|
* 限定条件:无
|
||||||
|
* 函数说明:VALID 需文件位图与 k 合法带**同时**成立(哨兵压制说谎位图,见文件头)
|
||||||
|
*/
|
||||||
|
uint32_t adc_cal_flags(adc_semantic_t sem) {
|
||||||
|
uint32_t f = 0u;
|
||||||
|
|
||||||
|
if ((unsigned)sem >= (unsigned)ADC_SEM_TEMP) {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
/* VALID = 文件位图置位 且 k 合法(哨兵不落共享状态,随重载自愈) */
|
||||||
|
if (((s_kcfg.valid & (uint8_t)(1u << (unsigned)sem)) != 0u) &&
|
||||||
|
(s_kband_ok((unsigned)sem, s_kcfg.k[sem], s_bcfg.b[sem]) != 0)) {
|
||||||
|
f |= ADC_CAL_F_VALID;
|
||||||
|
}
|
||||||
|
/* CLIPPED 由**来源**派生(R5:STM32 侧单极性无偏置 → 负半周削顶)——
|
||||||
|
* V0.2 改版(8 路全接 TPAFE,消除双转换器无共同采样触发)后本位自动恒 0,无需改码,
|
||||||
|
* 对应「数据可信」§10「页面与 104 绑定零改动」的要求 */
|
||||||
|
if (s_rom_k_stm32((unsigned)sem) != 0) {
|
||||||
|
f |= ADC_CAL_F_CLIPPED;
|
||||||
|
}
|
||||||
|
return f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:未标定闭锁判定(判据所需的 UA/UN/IA 三路是否齐)
|
||||||
|
* 返 回 值:1 - 闭锁;0 - 放行 uint8_t(契约、三路集合的理由、判据停走的四路
|
||||||
|
* 来源、两个同族谓词的边界都写在 adc_cal.h,本处不重述——重述就是第二真源)
|
||||||
|
* 函数说明:**现查 flags(),本函数不留任何状态**。刻意不复用保护任务里那份
|
||||||
|
* 「上拍闭锁态」——那是给边沿日志用的,开机首拍前恒 0;拿它当判据等于在
|
||||||
|
* 「还没判过」的时候谎称「已标定」,而这正是本谓词最贵的失效(屏上
|
||||||
|
* 「未标定」徽标一消失,现场读到的就是「改了定值就会动作」)。
|
||||||
|
* DTU_BENCH 旁路留在这里、且全仓只此一处;用 #ifndef 把产品默认路径留在
|
||||||
|
* 主分支——#ifdef 双分支会让 cppcheck 对未定义宏取真分支、把静态分析引到
|
||||||
|
* 旁路上报 knownConditionTrueFalse 假信号(实测 2026-10-25,这条形态说明原先
|
||||||
|
* 写在 protect_task.c 的实现处,随判据一起搬到这里)
|
||||||
|
*/
|
||||||
|
uint8_t adc_cal_prot_blocked(void) {
|
||||||
|
#ifndef DTU_BENCH
|
||||||
|
if (((adc_cal_flags(ADC_SEM_UA) & ADC_CAL_F_VALID) == 0u) ||
|
||||||
|
((adc_cal_flags(ADC_SEM_UN) & ADC_CAL_F_VALID) == 0u) ||
|
||||||
|
((adc_cal_flags(ADC_SEM_IA) & ADC_CAL_F_VALID) == 0u)) {
|
||||||
|
return 1u;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读通道当前生效系数(µV/码,回退后口径)
|
||||||
|
* 入口参数:sem - 语义通道 adc_semantic_t
|
||||||
|
* 返 回 值:k[µV/码];TEMP/越界返 0 float
|
||||||
|
* 限定条件:无
|
||||||
|
* 函数说明:Q16 现值 ÷ 65536 浮点还原;勿据其反推二次值(可用性看 flags)
|
||||||
|
*/
|
||||||
|
float adc_cal_k_uv(adc_semantic_t sem) {
|
||||||
|
int32_t k;
|
||||||
|
int32_t b;
|
||||||
|
|
||||||
|
if ((unsigned)sem >= (unsigned)ADC_SEM_TEMP) {
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
(void)s_eff_kb((unsigned)sem, &k, &b);
|
||||||
|
return ((float)k) / 65536.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读通道当前生效系数的 int32 Q16 原值(adc_cal_k_uv 孪生,见 .h 声明)
|
||||||
|
* 入口参数:sem - 语义通道 adc_semantic_t
|
||||||
|
* 返 回 值:k[Q16];TEMP/越界返 0 int32_t
|
||||||
|
* 限定条件:无(任意上下文,纯读)
|
||||||
|
* 函数说明:与 adc_cal_k_uv() 共用 s_eff_kb 生效视图(越带回退 ROM 口径一致),
|
||||||
|
* 差别只在不做浮点还原——录波段头按 LSB 精确记账必须走本口(§6.3)
|
||||||
|
*/
|
||||||
|
int32_t adc_cal_k_q16(adc_semantic_t sem) {
|
||||||
|
int32_t k;
|
||||||
|
int32_t b;
|
||||||
|
|
||||||
|
if ((unsigned)sem >= (unsigned)ADC_SEM_TEMP) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
(void)s_eff_kb((unsigned)sem, &k, &b);
|
||||||
|
return k;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读通道当前生效零点偏移(码值,浮点)——adc_cal_k_uv 孪生入口
|
||||||
|
* 入口参数:sem - 语义通道 adc_semantic_t
|
||||||
|
* 返 回 值:b[码值](越带时 s_eff_kb 已回 0);TEMP/越界返 0 float
|
||||||
|
* 限定条件:无(任意上下文,纯读)
|
||||||
|
* 函数说明:运行参数段的 104 b 覆盖回显 + k 单侧改写 RMW 读侧(App/algo/run_par.c)
|
||||||
|
*/
|
||||||
|
float adc_cal_b_codes(adc_semantic_t sem) {
|
||||||
|
int32_t k;
|
||||||
|
int32_t b;
|
||||||
|
|
||||||
|
if ((unsigned)sem >= (unsigned)ADC_SEM_TEMP) {
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
(void)s_eff_kb((unsigned)sem, &k, &b);
|
||||||
|
return ((float)b);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:整定入口——写通道系数与零点,并置位 VALID(「数据可信」标定落盘与整定入口)
|
||||||
|
* 入口参数:sem - 语义通道;k_q16 - 系数(µV/码×65536);b_codes - 零点(码值)
|
||||||
|
* 返 回 值:0 - 成功;-1 - 参数非法/越出合法带(RAM 不动,[ERR] 留痕) int
|
||||||
|
* 限定条件:整定路径上下文(defaultTask 整定/标定链;iec104Task 经 run_par
|
||||||
|
* 直通为运行参数段授权写者,单字原子契约同 §4.4);落盘由属主经 cfg_file_save
|
||||||
|
* 函数说明:见 adc_cal.h 同名声明
|
||||||
|
*/
|
||||||
|
int adc_cal_set(adc_semantic_t sem, int32_t k_q16, int32_t b_codes) {
|
||||||
|
if ((unsigned)sem >= (unsigned)ADC_SEM_TEMP) {
|
||||||
|
s_st_set_reject++; /* 观测:连通道号都非法,RAM 未动 */
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (s_kband_ok((unsigned)sem, k_q16, b_codes) == 0) {
|
||||||
|
DBG_ERROR("set ch=%u rejected: k_q16=%ld b=%ld out of ROM band [0.5x,2x]/|b|<=%ld",
|
||||||
|
(unsigned)sem, (long)k_q16, (long)b_codes, (long)ADC_CAL_B_MAX);
|
||||||
|
s_st_set_reject++; /* 观测:越出合法带,RAM 未动([ERR] 由上一行打) */
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
s_kcfg.k[sem] = k_q16;
|
||||||
|
s_bcfg.b[sem] = b_codes;
|
||||||
|
s_kcfg.valid |= (uint8_t)(1u << (unsigned)sem);
|
||||||
|
s_st_set_ok++; /* 观测:RAM 副本确实被改写了(与落盘无关) */
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读标定层观测计数(不取锁、不取时刻、任意上下文可读)
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:无;adc_cal_init() 之前可读(全 0 = 本层还没被改写过)
|
||||||
|
* 函数说明:见 adc_cal.h 同名声明(三态语义与那张成对读表都在那里,本处只取数)
|
||||||
|
*/
|
||||||
|
void adc_cal_get_stats(adc_cal_stats_t *p_out) {
|
||||||
|
if (p_out == NULL) {
|
||||||
|
return; /* 观测件不得反过来制造故障 */
|
||||||
|
}
|
||||||
|
p_out->set_ok = s_st_set_ok;
|
||||||
|
p_out->set_reject = s_st_set_reject;
|
||||||
|
p_out->persisted_set_ok = s_st_persist_water;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:落盘成功回执——把水位盖写为当前的 set_ok(不是自增)
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:仅"整档保存已成功"那一条路径(当前唯一调用点 run_par_store_save())
|
||||||
|
* 函数说明:盖写而非自增的理由与回归用例见 adc_cal.h 同名声明。水位直接取 set_ok 的
|
||||||
|
* 当前值,因此它**在结构上不可能越过** set_ok——差额是 uint32 相减,一旦越过
|
||||||
|
* 就绕回成一个巨大的假未落盘数,这里不留那种可能
|
||||||
|
*/
|
||||||
|
void adc_cal_note_persisted(void) {
|
||||||
|
s_st_persist_water = s_st_set_ok; /* 水位只会追平,不会越过 */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:两点标定公式(标定项的算术核,纯函数无状态)
|
||||||
|
* 入口参数:c_lo/c_hi、v_lo_uv/v_hi_uv、p_k_q16/p_b 见 adc_cal.h 同名声明
|
||||||
|
* 返 回 值:0 成功 / -1 参数或斜率非法 / -2 零点越 ±B_MAX int
|
||||||
|
* 限定条件:无(int64 中间量,主机可测;台架注入待跑)
|
||||||
|
*/
|
||||||
|
int adc_cal_two_point(int32_t c_lo, int32_t c_hi, int32_t v_lo_uv, int32_t v_hi_uv,
|
||||||
|
int32_t *p_k_q16, int32_t *p_b) {
|
||||||
|
long long dc;
|
||||||
|
long long dv;
|
||||||
|
long long kq;
|
||||||
|
long long bb;
|
||||||
|
|
||||||
|
if ((p_k_q16 == NULL) || (p_b == NULL)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
dc = (long long)c_hi - (long long)c_lo;
|
||||||
|
dv = (long long)v_hi_uv - (long long)v_lo_uv;
|
||||||
|
if ((dc <= 0LL) || (dv <= 0LL)) {
|
||||||
|
return -1; /* 斜率必须为正:码与电压同向,反向 = 映射/接线错 */
|
||||||
|
}
|
||||||
|
kq = ((dv << 16) + (dc >> 1)) / dc; /* (v_hi−v_lo)×65536/dc,四舍五入(dc>0) */
|
||||||
|
if ((kq <= 0LL) || (kq > 100000000LL)) {
|
||||||
|
return -1; /* 绝对合理域上界 1526 µV/code:越界 = 单位/量级错 */
|
||||||
|
}
|
||||||
|
bb = (((long long)v_lo_uv) << 16) / kq - (long long)c_lo;
|
||||||
|
if ((bb > (long long)ADC_CAL_B_MAX) || (bb < -(long long)ADC_CAL_B_MAX)) {
|
||||||
|
return -2; /* 零点异常 → 先查前端,别用系数凑(§8 风险表) */
|
||||||
|
}
|
||||||
|
*p_k_q16 = (int32_t)kq;
|
||||||
|
*p_b = (int32_t)bb;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:通道码值域贴轨带门限(采样通道自检 判定的门限来源,按 FS 百分比)
|
||||||
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
|
* 入口参数:sem - 语义通道;p_lo_th/p_hi_th - 输出下/上轨带界 int32_t *
|
||||||
|
* 返 回 值:0 - 已给出门限;-1 - TEMP/越界/参数空(不做贴轨监视) int
|
||||||
|
* 限定条件:无
|
||||||
|
* 函数说明:状态机在 adc_health_rail_watch();本函数只供门限,无状态
|
||||||
|
*/
|
||||||
|
int adc_cal_rail_ths(adc_semantic_t sem, int32_t *p_lo_th, int32_t *p_hi_th) {
|
||||||
|
if ((p_lo_th == NULL) || (p_hi_th == NULL) ||
|
||||||
|
((unsigned)sem >= (unsigned)ADC_SEM_TEMP)) {
|
||||||
|
return -1; /* TEMP(热敏非线性,不判「轨」)与越界不做监视 */
|
||||||
|
}
|
||||||
|
if (s_rom_k_stm32((unsigned)sem) != 0) {
|
||||||
|
*p_hi_th = (int32_t)((4095L * 98L) / 100L); /* 4013 = 98 % FS */
|
||||||
|
*p_lo_th = (int32_t)((4095L * 2L) / 100L); /* 81 = 触底带(削顶签名) */
|
||||||
|
} else {
|
||||||
|
*p_hi_th = 32112L; /* +98 % 半量程 */
|
||||||
|
*p_lo_th = -32112L; /* -98 % 半量程 */
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,276 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:通道标定与量纲折算(标定折算,「数据可信」标定折算与落盘整定)
|
||||||
|
* 模块功能:把 adc_input 的原始码值折算成工程量——模型与系数口径的唯一落点:
|
||||||
|
* 工程量 = (code + b) × k_f,k_f = k[µV/码] × 1e-6
|
||||||
|
* k 以 int32 **Q16**(µV/码 × 65536)落盘、b 以 int32 码值落盘,
|
||||||
|
* RAM 副本注册进 cfg_file 引擎([adc_cal] / [adc_cal_ofs] 两段,
|
||||||
|
* 布局见 docs/任务规划/数据可信.md §4.4);**不挂回调**——RAM 副本
|
||||||
|
* 即生效值,逐点折算现取现用(1 kHz × 9 通道 = 9k 次/秒浮点乘,可忽略)。
|
||||||
|
* 三态可用性:ADC_CAL_F_VALID(已按变比标定 → 工程量为二次值)与
|
||||||
|
* ADC_CAL_F_CLIPPED(V0.1 硬件削顶)由 adc_cal_flags() 单点查询,
|
||||||
|
* 104 / UI / 保护闭锁(未标定即闭锁)共用(docs/任务规划/数据可信.md §4.2)。
|
||||||
|
* **未标定闭锁的判定本身也归本层**(`adc_cal_prot_blocked()`):它讲的是
|
||||||
|
* 「这几路标定齐了没」,属标定层事实,不是任务编排逻辑——保护任务与 UI
|
||||||
|
* 组装器都只是它的消费者(道F task-16 把此前的两份实现收成一份)。
|
||||||
|
* ⚠ 输出单位口径 = **ADC 引脚电压 V**(eng=0):ROM 默认系数按量程口径冻结
|
||||||
|
* 定死的引脚侧 LSB(TPAFE 152.59 µV/码、STM32 805.66 µV/码,见
|
||||||
|
* docs/硬件平台/ADC通道映射说明.md §7.1);端子/子板变比确认与两点
|
||||||
|
* 标定完成前不得把输出当二次值消费。TEMP 不参与线性标定
|
||||||
|
* (决策 5),adc_cal_apply() 原样透传码值。
|
||||||
|
* 本层**纯算法 + 纯数据**:不碰寄存器、不等事件;adc_cal_init() 内核前
|
||||||
|
* 可调(注册进引擎必须先于 cfg_file_load,FTP 鉴权确立的契约);apply/flags
|
||||||
|
* 任意上下文可读(cfg RAM 副本 4 字节对齐字读写在 Cortex-M4 上原子,
|
||||||
|
* 与 defaultTask 的 load/poll 复位存在 µs 级混窗——设计接受,见 .c 头)。
|
||||||
|
* 适用平台:STM32F407ZGTx(Cortex-M4F);主机侧纯逻辑单测 test/host/adc_cal/
|
||||||
|
* 作者:DTU 项目组
|
||||||
|
* 创建日期:2026-10-25(道A 标定折算与落盘整定)
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-25 DTU 项目组 创建:Q16 系数/零点 + 折算/flags/集
|
||||||
|
* v1.1 2026-10-25 DTU 项目组 运行参数段:+adc_cal_b_codes()(run_par 回显/RMW 用)
|
||||||
|
* v1.2 2026-10-26 DTU 项目组 道F task-16:+adc_cal_prot_blocked()——「UA/UN/IA
|
||||||
|
* 三路 VALID 是否齐」这份判据停走条件从
|
||||||
|
* protect_task.c 与 ui_data_feed.c 的两份重复实现收成本层
|
||||||
|
* 单点(DTU_BENCH 旁路同时收成一处)
|
||||||
|
* v1.3 2026-10-29 DTU 项目组 故障录波 task-2:新增 adc_cal_k_q16()——k 的 int32
|
||||||
|
* Q16 原值直读口(adc_cal_k_uv 的孪生)。录波段头 k 字段
|
||||||
|
* 是 LSB 精确记账,float 在 5.28e7 码字上掉尾数,
|
||||||
|
* 禁止经浮点反推(docs/子系统设计/故障录波设计说明.md §6.3)
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef ADC_CAL_H
|
||||||
|
#define ADC_CAL_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "adc_input.h" /* adc_semantic_t(语义通道编号 = 系数表下标口径) */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* 通道可用性标志(adc_cal_flags() 返回值位) */
|
||||||
|
#define ADC_CAL_F_VALID (0x01u) /* k 已按变比标定 → 工程量为二次值(两点标定后置位) */
|
||||||
|
#define ADC_CAL_F_CLIPPED (0x02u) /* 硬件削顶(V0.1 STM32 侧单极性无偏置)→ 交流量不可信 */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ROM 默认系数(int32 Q16 = µV/码 × 65536,量程口径冻结定死的引脚侧口径):
|
||||||
|
* TPAFE ±5 V 档:10 V 跨度 / 65536 码 = 152.587890625 µV/码 → ×65536 = 10,000,000(整)
|
||||||
|
* STM32 0~3.3 V:3.3 V / 4096 码 = 805.6640625 µV/码 → ×65536 = 52,800,000(整)
|
||||||
|
* 两个默认值在 Q16 下**无舍入**(系数定点舍入项从误差预算里抹掉,
|
||||||
|
* 选型依据 → docs/任务规划/数据可信.md §4.1)
|
||||||
|
*/
|
||||||
|
#define ADC_CAL_K_Q16_TPAFE (10000000L)
|
||||||
|
#define ADC_CAL_K_Q16_STM32 (52800000L)
|
||||||
|
|
||||||
|
/* 系数合法带(「数据可信」§8 风险表):k 必须落在板级 ROM 系数的 ×[0.5, 2.0]
|
||||||
|
* 区间内;带宽用 ROM 值移位实现(>>1 / <<1),不设独立宏防两处真相 */
|
||||||
|
|
||||||
|
/* 零点偏移幅值上限(码值):超此即判文件损坏/异常标定,回退 ROM 并留痕 */
|
||||||
|
#define ADC_CAL_B_MAX (8192L)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:标定层初始化——RAM 副本置 ROM 默认 + 注册两段进 cfg 引擎(幂等)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:无
|
||||||
|
* 限定条件:内核启动前调用(app_main_init);注册必须先于 cfg_file_load
|
||||||
|
* (「所有受管段先注册、后 load」契约,见 Drivers/BSP/NETCFG/net_cfg.c 的
|
||||||
|
* net_cfg_load 注释)
|
||||||
|
* 函数说明:段属主为 defaultTask(配置引擎),本模块只提供 RAM 副本与默认值;
|
||||||
|
* 文件值由引擎 load/poll 原地写入 RAM 副本,本层逐点折算现读
|
||||||
|
*/
|
||||||
|
void adc_cal_init(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:码值 → 工程量折算(「数据可信」§4.1 模型)
|
||||||
|
* 入口参数:sem - 语义通道(TEMP 透传不折算) adc_semantic_t
|
||||||
|
* code - 原始码值(TPAFE 16 位补码 / STM32 0..4095) int32_t
|
||||||
|
* 返 回 值:工程量浮点(未标定时单位 = ADC 引脚电压 V;VALID 后置位 = 二次值) float
|
||||||
|
* 限定条件:无(任意上下文,纯读)
|
||||||
|
* 函数说明:eng = (code + b) × k[µV/码] × 1e-6,由 Q16 现值派生,无缓存副本
|
||||||
|
* (避免无回调下的陈旧系数);k/b 越合法带 → 该通道回退 ROM 默认 +
|
||||||
|
* 首见沿 [ERR] 留痕(adc_cal_init 重置留痕位),flags 同时不报 VALID
|
||||||
|
*/
|
||||||
|
float adc_cal_apply(adc_semantic_t sem, int32_t code);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读通道可用性标志(消费者判「能不能信」的唯一入口)
|
||||||
|
* 入口参数:sem - 语义通道 adc_semantic_t
|
||||||
|
* 返 回 值:ADC_CAL_F_VALID / ADC_CAL_F_CLIPPED 组合;TEMP 恒 0;sem 越界恒 0 uint32_t
|
||||||
|
* 限定条件:无
|
||||||
|
* 函数说明:VALID = 文件位图置位 **且** 该通道 k 在合法带内(哨兵不落状态,
|
||||||
|
* 天然跟随 load/poll 重载);CLIPPED = V0.1 静态表(UB/UC/IB/IC/IN)
|
||||||
|
*/
|
||||||
|
uint32_t adc_cal_flags(adc_semantic_t sem);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:**未标定闭锁判定**——保护判据所需的三路标定是否齐(「数据可信」未标定即闭锁
|
||||||
|
* / §4.3;道F task-16 把它从两处重复实现收敛成这一份真源)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:1 - 闭锁(UA/UN/IA 中至少一路 `ADC_CAL_F_VALID` 未置位,判据链应停走);
|
||||||
|
* 0 - 三路全 VALID(标定侧允许判据跑) uint8_t
|
||||||
|
* 限定条件:任意上下文(纯读 `adc_cal_flags()`,与它同一条并发契约:int32 对齐
|
||||||
|
* 字读写原子、cfg poll 重载 µs 级混窗设计接受);**现查,本层不留任何状态**
|
||||||
|
* 函数说明:① **为什么恰好这三路**:V0.1 把 8 路电气量分给两片转换器(AGENTS
|
||||||
|
* 「双转换器无共同采样触发」),UA/UN/IA = TPAFE 侧这三路是**唯一有效**
|
||||||
|
* 的测量通道;UB/UC/IB/IC/IN 即使将来标定也另受 `CLIPPED` 品质约束
|
||||||
|
* (M5 快照层处理),不掺进本闭锁源。
|
||||||
|
* ② ⚠ **不得当「保护在跑」的总括用**:判据停走共四路来源,本函数只覆盖
|
||||||
|
* 第二路——(a) 自检失败闭锁 采样故障闭锁(`adc_health_flags()`);(b) 本函数
|
||||||
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
|
* (未标定);(c) 定值校验和失配闭锁(`prot_set_locked()`);(d) 全局软压板
|
||||||
|
* 退出(`PS_GLOBAL_EN` = 0)。要判「这拍到底评估了没有」必须四路一起看
|
||||||
|
* (编排顺序见 `App/task/protect_task.c`)。
|
||||||
|
* ③ ⚠ **两个同族谓词与它不是一件事,不要顺手合并**(三种口径各有用途):
|
||||||
|
* · 电气 **8** 路全 VALID 才升 eng=二次值(量纲标签,判据在
|
||||||
|
* `App/app/ui_data_feed.c` 的 `feed_eng()`)——并进来会让部分标定的整帧
|
||||||
|
* 被误标二次值;反过来把本三路口径当 8 路用,V0.1 下 UB/UC/IB/IC/IN
|
||||||
|
* 永不置位 → 徽标与停走**永不解除**;
|
||||||
|
* · 逐通道打印 VALID/CLIPPED 位图是诊断输出(`App/task/adc_task.c`),
|
||||||
|
* 不是判定。
|
||||||
|
* ④ 台架调试旁路 = 编译期 `-DDTU_BENCH`(「数据可信」§4.3 决策 1 的例外通道,
|
||||||
|
* 仅本地台架自行定义;本仓任何工程档都不定义 = 产品恒强制)。旁路**只在
|
||||||
|
* 本函数实现内部这一处**,调用方不再各自写 `#ifndef`:否则有人只给单个
|
||||||
|
* TU 加宏,屏上「未标定」徽标与判据停走当场分叉,且 0 错误 0 警告。
|
||||||
|
*/
|
||||||
|
uint8_t adc_cal_prot_blocked(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读通道当前生效系数(µV/码,浮点)
|
||||||
|
* 入口参数:sem - 语义通道 adc_semantic_t
|
||||||
|
* 返 回 值:k[µV/码](回退后口径);TEMP / 越界返 0 float
|
||||||
|
* 限定条件:无
|
||||||
|
* 函数说明:采样通道自检 门限换算、日志与整定界面回显用;勿据其反推二次值(看 flags)
|
||||||
|
*/
|
||||||
|
float adc_cal_k_uv(adc_semantic_t sem);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读通道当前生效零点偏移(码值,浮点)——adc_cal_k_uv 的孪生入口
|
||||||
|
* 入口参数:sem - 语义通道 adc_semantic_t
|
||||||
|
* 返 回 值:b[码值](越带时为 0,与折算同视图);TEMP/越界返 0 float
|
||||||
|
* 限定条件:无
|
||||||
|
* 函数说明:104 运行参数段(「数据可信」这一项)b 覆盖回显与 k 单侧改写的 RMW
|
||||||
|
* 读侧用(App/algo/run_par.c);勿据其反推二次值(看 flags)。
|
||||||
|
*/
|
||||||
|
float adc_cal_b_codes(adc_semantic_t sem);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读通道当前生效系数的 **int32 Q16 原值**(µV/码 × 65536)——
|
||||||
|
* adc_cal_k_uv() 的孪生入口,给「必须 LSB 精确」的消费者用
|
||||||
|
* 入口参数:sem - 语义通道 adc_semantic_t
|
||||||
|
* 返 回 值:k[Q16 原值](与折算同一生效视图:越带回退 ROM、b 不参与);TEMP/越界返 0 int32_t
|
||||||
|
* 限定条件:无(任意上下文,纯读)
|
||||||
|
* 函数说明:故障录波段头的 k 字段是 int32 LSB 精确记账(docs/子系统设计/故障录波设计说明.md
|
||||||
|
* §6.3 裁定)——float 还原后在 5.28e7 码字上掉尾数,**禁止**拿
|
||||||
|
* adc_cal_k_uv() 反乘 65536 回算落盘,故开本原值口。
|
||||||
|
*/
|
||||||
|
int32_t adc_cal_k_q16(adc_semantic_t sem);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:整定入口——写通道系数与零点,并置位 VALID(「数据可信」标定落盘与整定入口)
|
||||||
|
* 入口参数:sem - 语义通道(电气 8 路) adc_semantic_t
|
||||||
|
* k_q16 - 系数,µV/码 × 65536 内 int32_t
|
||||||
|
* b_codes - 零点偏移,码值 内 int32_t
|
||||||
|
* 返 回 值:0 - 成功;-1 - 参数非法/越出合法带(RAM 不动,[ERR] 留痕) int
|
||||||
|
* 限定条件:**整定路径上下文**(defaultTask 标定流程/整定链;运行参数段落地起 iec104Task
|
||||||
|
* 经 App/algo/run_par.c 的 0x8040~/0x8048~ 直通写亦为授权写者——写的仍是
|
||||||
|
* 引擎对齐 int32 单字,µs 级混窗契约同「数据可信」§4.4)。勿在 adcTask 调用
|
||||||
|
* 函数说明:**只写 RAM 副本,不置脏、不落盘、不返回"稍后会存"的承诺**——落盘由属主经
|
||||||
|
* cfg_file_save 完成,而"要不要落盘"这件事只有一个真源:run_par 的脏标志。
|
||||||
|
* 因此授权写者是 run_par 的语义 setter(写值 + 置脏成对发生,lead 裁②);
|
||||||
|
* 绕过它直调本函数 = 值只活在 RAM 里,重启即丢且**没有错误返回**,
|
||||||
|
* 该形态现可被本层观测计数抓到(差分口径见下方 adc_cal_stats_t 那张表)
|
||||||
|
*/
|
||||||
|
int adc_cal_set(adc_semantic_t sem, int32_t k_q16, int32_t b_codes);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:两点标定公式(「数据可信」标定项的算术核,纯函数无状态)
|
||||||
|
* 入口参数:c_lo/c_hi - 两点码值(c_hi > c_lo) int32_t
|
||||||
|
* v_lo_uv/v_hi_uv - 两点实测电压(µV,v_hi > v_lo,端子/引脚域均可) int32_t
|
||||||
|
* p_k_q16 / p_b - 输出:系数(Q16)与零点偏移(码值) int32_t *
|
||||||
|
* 返 回 值:0 - 成功;-1 - 参数/斜率非法;-2 - 零点越 ±ADC_CAL_B_MAX(先查前端,
|
||||||
|
* 不得用系数凑错斜率,「数据可信」§8 风险表) int
|
||||||
|
* 限定条件:无(纯整数运算,主机侧可直接单测)
|
||||||
|
* 函数说明:k = (v_hi−v_lo)×65536/(c_hi−c_lo)(四舍五入),b = v_lo×65536/k − c_lo;
|
||||||
|
* 实测注入未做(台架待跑),本函数即「公式齐备」的交付物
|
||||||
|
*/
|
||||||
|
int adc_cal_two_point(int32_t c_lo, int32_t c_hi, int32_t v_lo_uv, int32_t v_hi_uv,
|
||||||
|
int32_t *p_k_q16, int32_t *p_b);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:通道码值域「贴轨带」门限(采样通道自检 越界检出的门限来源,按 FS 百分比)
|
||||||
|
* 入口参数:sem - 语义通道 adc_semantic_t
|
||||||
|
* p_lo_th - 下轨带上界(code ≤ 此值判触底) int32_t *
|
||||||
|
* p_hi_th - 上轨带下界(code ≥ 此值判触顶) int32_t *
|
||||||
|
* 返 回 值:0 - 已给出门限;-1 - TEMP/越界(不做贴轨监视) int
|
||||||
|
* 限定条件:无
|
||||||
|
* 函数说明:TPAFE 双极性 ±98% FS(±32112);STM32 上轨 98% FS(4013)、
|
||||||
|
* 下轨 2% FS(81);判定状态机在 adc_health_rail_watch()(采样通道自检)
|
||||||
|
*/
|
||||||
|
int adc_cal_rail_ths(adc_semantic_t sem, int32_t *p_lo_th, int32_t *p_hi_th);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 观测计数:把「改了没落盘」从意图式陈述变成可诊断事实(道G task-43 第②项;
|
||||||
|
* 先例 App/fs/vol_lock.h 的同族设计——只计数、不取锁、不在调用点打日志、不打 [ERR])
|
||||||
|
*
|
||||||
|
* 三态语义(**定义只在本头文件这一处**,消费者只描述取数与断言,不重定义):
|
||||||
|
* set_ok RAM 副本被本层 API 成功改写的累计次数
|
||||||
|
* set_reject 被本层拒掉的整定请求累计次数(参数非法 / 越出合法带)
|
||||||
|
* persisted_set_ok **水位**而不是计数:最近一次随整档保存落盘成功时的
|
||||||
|
* set_ok 快照(由 adc_cal_note_persisted() 盖写,永不越过 set_ok)
|
||||||
|
*
|
||||||
|
* 未落盘差额 = set_ok − persisted_set_ok,即"自上次成功落盘之后被改写的次数"。
|
||||||
|
* 单看差额不够定性,必须与 run_par_dirty_peek() 成对读(两列一起才只有一种解释):
|
||||||
|
*
|
||||||
|
* 差额 run_par_dirty_peek() 说的是什么事
|
||||||
|
* ------ ---------------------- ----------------------------------------------
|
||||||
|
* 0 0 盘与 RAM 齐(或压根没改过)——健康
|
||||||
|
* >0 1 改了、还没到落盘时机——正常在途
|
||||||
|
* >0 0 **改了且永远不会落盘**:写者绕过 run_par 的语义
|
||||||
|
* setter 直调 adc_cal_set()(运行参数段起的事)
|
||||||
|
*
|
||||||
|
* 消费者告诫(与 vol_lock_get_stats() 同一套,别重犯):
|
||||||
|
* ① 本组接口不取锁、不取时刻,任意上下文可读;三个数各自是 uint32 单字读写,
|
||||||
|
* Cortex-M4 上原子,但三个数之间**没有共同时刻**——别把它们拼成"一个瞬间的快照"
|
||||||
|
* 下结论,定性只按上面那张两列表成对读。
|
||||||
|
* ② 需要"被观测到的时刻"请在消费者侧自盖并冠 obs_ 前缀命名(先例:喂狗点单写者 latch),
|
||||||
|
* 本层不存时刻——存了就得连带管溢出与单位。
|
||||||
|
* ③ 这些计数**不参与任何判定**:能不能信只看 adc_cal_flags() /
|
||||||
|
* adc_cal_prot_blocked(),要不要落盘只看 run_par 的脏标志。别拿差额去改控制流,
|
||||||
|
* 那等于把观测升级成第二真源(标定写入口已由 run_par 语义 setter 收口,不再加口)。
|
||||||
|
* ④ 写清盲区:本层刻意**不挂** cfg_file 回调,因此"盘 → RAM 的覆盖"(cfg_file_load
|
||||||
|
* 与外部变更重载)不产生任何计数;未落盘的改值被一次重载无声吞掉,正是这三个
|
||||||
|
* 数照不到的那一条。要照它得先给本层挂回调 = 行为改动,另行立项处置。
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
uint32_t set_ok; /* 成功改写 RAM 副本次数(见上方唯一落点) */
|
||||||
|
uint32_t set_reject; /* 被拒次数(见上方唯一落点) */
|
||||||
|
uint32_t persisted_set_ok; /* 落盘水位,不是计数(见上方唯一落点) */
|
||||||
|
} adc_cal_stats_t;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读标定层观测计数(只读,不改任何状态)
|
||||||
|
* 入口参数:p_out - 输出:三个观测计数 adc_cal_stats_t *
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:任意上下文可读(含 ISR 与内核启动前);adc_cal_init() 之前可读,得全 0
|
||||||
|
* 函数说明:p_out 为 NULL 时静默返回——观测件不得反过来制造故障
|
||||||
|
*/
|
||||||
|
void adc_cal_get_stats(adc_cal_stats_t *p_out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:落盘成功回执——把水位 persisted_set_ok 盖写为当前的 set_ok(**不是自增**)
|
||||||
|
* 入口参数:无 void
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:只允许配置引擎属主侧"整档保存已成功"那一条路径调用;当前唯一调用点 =
|
||||||
|
* App/algo/run_par.c 的 run_par_store_save() 中 cfg_file_save() 返 0 之后
|
||||||
|
* 函数说明:为什么盖写不自增——一次 save 覆盖整档,可能对应多次整定改写(逐通道
|
||||||
|
* 标定 3 路 = 3 次 adc_cal_set + 1 次 save);自增会留下 2 次"假未落盘"。
|
||||||
|
* 水位语义让差额只反映"上次落盘之后的改写",回归锁见
|
||||||
|
* test/host/adc_cal/adc_cal_test.c 的 TC-ADCCAL-021
|
||||||
|
*/
|
||||||
|
void adc_cal_note_persisted(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* ADC_CAL_H */
|
||||||
+132
-3
@@ -1,16 +1,26 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:ADC 采样有效性校验(L1 采集与预处理,规划项 L1-6)
|
* 模块名称:ADC 采样有效性校验(语义取数与预处理)
|
||||||
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
* 模块功能:见 adc_health.h 文件头。本文件为**纯逻辑实现**:输入计数快照、
|
* 模块功能:见 adc_health.h 文件头。本文件为**纯逻辑实现**:输入计数快照、
|
||||||
* 输出故障位/事件位,零平台依赖(仅 stdint.h)→ 主机侧免桩单测。
|
* 输出故障位/事件位,零平台依赖(仅 stdint.h/string.h 标准 C)→
|
||||||
|
* 主机侧免桩单测。
|
||||||
* 适用平台:STM32F407ZGTx + TPAFE5160
|
* 适用平台:STM32F407ZGTx + TPAFE5160
|
||||||
* 作者:王建锋
|
* 作者:王建锋
|
||||||
* 创建日期:2026-10-11
|
* 创建日期:2026-10-11
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-10-11 王建锋 创建:L1-6 采样有效性校验
|
* v1.0 2026-10-11 王建锋 创建:采样有效性校验
|
||||||
|
* v1.1 2026-10-21 王建锋 新增 adc_health_age_stale():无模块状态的帧年龄
|
||||||
|
* 纯判据(参照系由调用方自存),供 protectTask 的
|
||||||
|
* 兜底新鲜度门使用,见 adc_health.h 同名声明
|
||||||
|
* v1.2 2026-10-25 DTU 项目组 新增 采样通道自检 残余:码值越界(贴轨)观察器
|
||||||
|
* (rail_watch/rail_sticky/rail_init),判据与释放
|
||||||
|
* 沿理由见 adc_health.h 采样通道自检 段注释
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "adc_health.h"
|
#include "adc_health.h"
|
||||||
|
|
||||||
|
#include <string.h> /* memset(采样通道自检 贴轨观察器数组复位) */
|
||||||
|
|
||||||
/* ===================== 内部状态 ===================== */
|
/* ===================== 内部状态 ===================== */
|
||||||
|
|
||||||
/* 基线是否已建立:首次 check() 只记基线、不判定(开机首拍无参照系) */
|
/* 基线是否已建立:首次 check() 只记基线、不判定(开机首拍无参照系) */
|
||||||
@@ -33,6 +43,17 @@ static uint32_t s_flags; /* 电平位(stall 可自愈;backward 锁存在
|
|||||||
static uint32_t s_sticky; /* 锁存位(含电平位历史并集) */
|
static uint32_t s_sticky; /* 锁存位(含电平位历史并集) */
|
||||||
static uint32_t s_tpafe_gaps;
|
static uint32_t s_tpafe_gaps;
|
||||||
|
|
||||||
|
/* 采样通道自检 贴轨观察器(每通道独立小状态机,单写者 = adcTask) */
|
||||||
|
typedef struct {
|
||||||
|
uint16_t run_hi; /* 连续贴上轨计数(饱和 0xFFFF) */
|
||||||
|
uint16_t run_lo; /* 连续贴下轨计数 */
|
||||||
|
uint8_t seen_mid; /* 见过中带样本(释放沿)——锁存的必要前提 */
|
||||||
|
uint8_t latched; /* 已报过边沿(不重复报) */
|
||||||
|
} adc_rail_t;
|
||||||
|
|
||||||
|
static adc_rail_t s_rail[ADC_RAIL_CH_NUM];
|
||||||
|
static uint32_t s_rail_sticky;
|
||||||
|
|
||||||
/* ===================== 对外接口 ===================== */
|
/* ===================== 对外接口 ===================== */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -43,6 +64,7 @@ static uint32_t s_tpafe_gaps;
|
|||||||
* 函数说明:清全部内部状态;判定从下一次 check() 的首拍基线重新开始。
|
* 函数说明:清全部内部状态;判定从下一次 check() 的首拍基线重新开始。
|
||||||
*/
|
*/
|
||||||
void adc_health_init(void) {
|
void adc_health_init(void) {
|
||||||
|
adc_health_rail_init();
|
||||||
s_primed = 0u;
|
s_primed = 0u;
|
||||||
s_tpafe_seq = 0u;
|
s_tpafe_seq = 0u;
|
||||||
s_tpafe_frst = 0u;
|
s_tpafe_frst = 0u;
|
||||||
@@ -166,6 +188,43 @@ void adc_health_check(const adc_health_in_t *in, adc_health_status_t *out) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:帧年龄判定(无模块状态,保护侧兜底门专用)
|
||||||
|
* 入口参数:见 adc_health.h 同名声明
|
||||||
|
* 返 回 值:1 - 已陈旧;0 - 新鲜或仍在建立基线
|
||||||
|
* 限定条件:任意任务上下文;不读写本模块任何 static(与 check() 的关键区别)
|
||||||
|
* 函数说明:三步——建基线 → 有前进即刷新参照系 → 原地不动才量"距最后一次前进"。
|
||||||
|
* 倒退也按"有变化"处理(本函数不判单调性,那归 ADC_HF_SEQ_BACKWARD)。
|
||||||
|
*/
|
||||||
|
uint8_t adc_health_age_stale(adc_health_age_t *a_p_age, uint32_t a_cur_seq,
|
||||||
|
uint32_t a_now_ms, uint32_t a_age_ms) {
|
||||||
|
if (a_p_age == 0) {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 1) 首拍建基线:此刻序号往往已非 0(TIM3 比调度器早约 100 ms 就在生产帧),
|
||||||
|
* 没有"上一拍"就没有参照系——开机窗口内绝不允许把保护锁掉 */
|
||||||
|
if (a_p_age->primed == 0u) {
|
||||||
|
a_p_age->primed = 1u;
|
||||||
|
a_p_age->seq = a_cur_seq;
|
||||||
|
a_p_age->adv_ms = a_now_ms;
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 2) 有任何前进(含跨多帧、含跨回绕、含倒退)即算新鲜,并刷新参照系 */
|
||||||
|
if (a_cur_seq != a_p_age->seq) {
|
||||||
|
a_p_age->seq = a_cur_seq;
|
||||||
|
a_p_age->adv_ms = a_now_ms;
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 3) 序号原地不动:只看"距最后一次前进多久",含等号(与 stall 判据同边界) */
|
||||||
|
if ((uint32_t)(int32_t)(a_now_ms - a_p_age->adv_ms) >= a_age_ms) {
|
||||||
|
return 1u;
|
||||||
|
}
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:读当前电平故障位
|
* 函数功能:读当前电平故障位
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
@@ -198,3 +257,73 @@ uint32_t adc_health_sticky(void) {
|
|||||||
uint32_t adc_health_tpafe_gaps(void) {
|
uint32_t adc_health_tpafe_gaps(void) {
|
||||||
return s_tpafe_gaps;
|
return s_tpafe_gaps;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===================== 采样通道自检 贴轨观察器实现 ===================== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:贴轨观察器复位(幂等;adc_health_init() 已内含)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:无
|
||||||
|
* 限定条件:与 check() 同为单写者任务上下文
|
||||||
|
*/
|
||||||
|
void adc_health_rail_init(void) {
|
||||||
|
(void)memset(s_rail, 0, sizeof(s_rail));
|
||||||
|
s_rail_sticky = 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:单通道单拍贴轨观察(判据与释放沿前提见 adc_health.h 同名声明)
|
||||||
|
* 入口参数:ch / code / lo_th / hi_th - 见 adc_health.h
|
||||||
|
* 返 回 值:1 - 本拍新锁存(边沿);0 - 无新事件(含 ch 越界忽略) uint32_t
|
||||||
|
* 限定条件:每拍每通道至多一次;纯算术、零平台依赖
|
||||||
|
*/
|
||||||
|
uint32_t adc_health_rail_watch(uint8_t ch, int32_t code, int32_t lo_th, int32_t hi_th) {
|
||||||
|
adc_rail_t *p;
|
||||||
|
|
||||||
|
if (ch >= (uint8_t)ADC_RAIL_CH_NUM) {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
p = &s_rail[ch];
|
||||||
|
|
||||||
|
if (code >= hi_th) { /* 贴上轨带:清对侧计数 */
|
||||||
|
p->run_lo = 0u;
|
||||||
|
if (p->run_hi < 0xFFFFu) {
|
||||||
|
p->run_hi++;
|
||||||
|
}
|
||||||
|
if ((p->run_hi >= (uint16_t)ADC_RAIL_N_RUN) && (p->seen_mid != 0u)
|
||||||
|
&& (p->latched == 0u)) {
|
||||||
|
p->latched = 1u;
|
||||||
|
s_rail_sticky |= (1uL << ch);
|
||||||
|
return 1u;
|
||||||
|
}
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
if (code <= lo_th) { /* 贴下轨带(对称) */
|
||||||
|
p->run_hi = 0u;
|
||||||
|
if (p->run_lo < 0xFFFFu) {
|
||||||
|
p->run_lo++;
|
||||||
|
}
|
||||||
|
if ((p->run_lo >= (uint16_t)ADC_RAIL_N_RUN) && (p->seen_mid != 0u)
|
||||||
|
&& (p->latched == 0u)) {
|
||||||
|
p->latched = 1u;
|
||||||
|
s_rail_sticky |= (1uL << ch);
|
||||||
|
return 1u;
|
||||||
|
}
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
/* 中带:记释放沿(锁存的必要前提),并清两侧连续计数 */
|
||||||
|
p->seen_mid = 1u;
|
||||||
|
p->run_hi = 0u;
|
||||||
|
p->run_lo = 0u;
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读贴轨锁存位图
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:bit ch = 曾检出贴轨(低 9 位有效) uint32_t
|
||||||
|
* 限定条件:无
|
||||||
|
*/
|
||||||
|
uint32_t adc_health_rail_sticky(void) {
|
||||||
|
return s_rail_sticky;
|
||||||
|
}
|
||||||
|
|||||||
+96
-5
@@ -1,5 +1,6 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:ADC 采样有效性校验(L1 采集与预处理,规划项 L1-6)
|
* 模块名称:ADC 采样有效性校验(语义取数与预处理)
|
||||||
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
* 模块功能:对两片转换器的"节拍是否还在喂数据"做**判定层**(观测计数由
|
* 模块功能:对两片转换器的"节拍是否还在喂数据"做**判定层**(观测计数由
|
||||||
* `adc_input` / `tpafe5160` 驱动提供,本模块只做判定与告警位):
|
* `adc_input` / `tpafe5160` 驱动提供,本模块只做判定与告警位):
|
||||||
* ① 静默(stall):帧序号连续 >= ADC_HEALTH_STALL_MS 无前进;
|
* ① 静默(stall):帧序号连续 >= ADC_HEALTH_STALL_MS 无前进;
|
||||||
@@ -8,12 +9,25 @@
|
|||||||
* ④ 错误计数增量:FRSTDATA 跳变 / HAL ADC·DMA 错误 / STM32 停摆计数。
|
* ④ 错误计数增量:FRSTDATA 跳变 / HAL ADC·DMA 错误 / STM32 停摆计数。
|
||||||
* ⚠ 本层**纯逻辑**:不碰寄存器、不取数、不打日志——输入是调用方
|
* ⚠ 本层**纯逻辑**:不碰寄存器、不取数、不打日志——输入是调用方
|
||||||
* 采集的计数快照,输出是故障位;日志与自检上报由消费者
|
* 采集的计数快照,输出是故障位;日志与自检上报由消费者
|
||||||
* (`App/task/adc_task.c`)按位执行 → 主机侧可单测(L0.5,A-4)。
|
* (`App/task/adc_task.c`)按位执行 → 主机侧可纯逻辑单测。
|
||||||
* 适用平台:STM32F407ZGTx + TPAFE5160
|
* 适用平台:STM32F407ZGTx + TPAFE5160
|
||||||
* 作者:王建锋
|
* 作者:王建锋
|
||||||
* 创建日期:2026-10-11
|
* 创建日期:2026-10-11
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-10-11 王建锋 创建:L1-6 采样有效性校验(静默/丢帧/单调性/错误增量)
|
* v1.0 2026-10-11 王建锋 创建:采样有效性校验(静默/丢帧/单调性/错误增量)
|
||||||
|
* v1.1 2026-10-21 王建锋 新增 adc_health_age_stale()(无模块状态的帧年龄
|
||||||
|
* 纯判据,参照系由调用方自存)与 ADC_HF_STALE 位:
|
||||||
|
* 判定层的 stall/backward 位只在 adcTask 每拍跑
|
||||||
|
* check() 时才可能置起,节拍源停摆会把本任务卡在
|
||||||
|
* osThreadFlagsWait 上 → 那些位永远是 0,而相量冻在
|
||||||
|
* 最后一帧仍被 protectTask 用于判据。该纯判据让保护侧
|
||||||
|
* 能用**自己的时基 + 中断侧计数器**独立判"数据还新不新" * v1.2 2026-10-25 DTU 项目组 新增 采样通道自检 残余「码值越界(贴轨)检出」:
|
||||||
|
* adc_health_rail_watch()/rail_sticky()/rail_init()
|
||||||
|
* ——连续 N 拍贴满量程带且该通道见过释放沿才锁存
|
||||||
|
* (空载恒 0 / 开机恒 0 是合法工况,不误报);
|
||||||
|
* 判据动因 docs/问题记录/采集与数据处理短板评测.md
|
||||||
|
* §6.2,门限来源 adc_cal_rail_ths()(按 FS 百分比)
|
||||||
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#ifndef ADC_HEALTH_H
|
#ifndef ADC_HEALTH_H
|
||||||
@@ -31,7 +45,7 @@ extern "C" {
|
|||||||
#define ADC_HEALTH_STALL_MS 100U
|
#define ADC_HEALTH_STALL_MS 100U
|
||||||
|
|
||||||
/* 电平故障位(adc_health_flags(),当前活跃;stall 可自愈清除,backward 锁存)
|
/* 电平故障位(adc_health_flags(),当前活跃;stall 可自愈清除,backward 锁存)
|
||||||
* ——供后续 L5-4「自检失败闭锁」直接消费 */
|
* ——供后续「自检失败闭锁」直接消费 */
|
||||||
#define ADC_HF_TPAFE_STALL (1uL << 0) /* TPAFE 帧序号静默 */
|
#define ADC_HF_TPAFE_STALL (1uL << 0) /* TPAFE 帧序号静默 */
|
||||||
#define ADC_HF_STM32_STALL (1uL << 1) /* STM32 DMA 完成计数静默 */
|
#define ADC_HF_STM32_STALL (1uL << 1) /* STM32 DMA 完成计数静默 */
|
||||||
#define ADC_HF_SEQ_BACKWARD (1uL << 2) /* 帧序号倒退(锁存,不自愈) */
|
#define ADC_HF_SEQ_BACKWARD (1uL << 2) /* 帧序号倒退(锁存,不自愈) */
|
||||||
@@ -51,6 +65,13 @@ extern "C" {
|
|||||||
#define ADC_HE_FRSTDATA (1uL << 3) /* 本次 FRSTDATA 计数有增量 */
|
#define ADC_HE_FRSTDATA (1uL << 3) /* 本次 FRSTDATA 计数有增量 */
|
||||||
#define ADC_HE_HW_ERR (1uL << 5) /* 本次 HAL 错误计数有增量 */
|
#define ADC_HE_HW_ERR (1uL << 5) /* 本次 HAL 错误计数有增量 */
|
||||||
|
|
||||||
|
/* 兜底新鲜度位(bit8,与判定层 bit0..bit7 位段错开)
|
||||||
|
* ⚠ 本位**不由本模块置位**:由 protectTask 调 adc_health_age_stale() 独立观测后
|
||||||
|
* OR 进闭锁位图。因此它不进 sticky、不进 adc_task 编排层的 logged 位图、
|
||||||
|
* 也不由本模块打日志——留痕走 protect 侧既有的闭锁边沿日志与 SOE。
|
||||||
|
* 位段纪律见 docs/算法/采样有效性校验设计说明.md §14 */
|
||||||
|
#define ADC_HF_STALE (1uL << 8) /* 帧年龄超限(电平,序号恢复前进即清) */
|
||||||
|
|
||||||
/* 计数快照(由调用方采集,本模块不触碰任何驱动/外设) */
|
/* 计数快照(由调用方采集,本模块不触碰任何驱动/外设) */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
uint32_t tpafe_seq; /* tpafe5160_frame_seq() */
|
uint32_t tpafe_seq; /* tpafe5160_frame_seq() */
|
||||||
@@ -71,6 +92,15 @@ typedef struct {
|
|||||||
uint32_t tpafe_stall_ms; /* TPAFE 当前静默时长(0=正常) */
|
uint32_t tpafe_stall_ms; /* TPAFE 当前静默时长(0=正常) */
|
||||||
} adc_health_status_t;
|
} adc_health_status_t;
|
||||||
|
|
||||||
|
/* 帧年龄参照系(adc_health_age_stale() 用)。状态**归调用方所有**,所以多个观察者
|
||||||
|
* 各判各的、互不污染——这是它与本模块其余接口的关键区别(check() 用的是模块级
|
||||||
|
* 单写者状态,只能有一个每拍调用者) */
|
||||||
|
typedef struct {
|
||||||
|
uint32_t seq; /* 上次看到的序号 */
|
||||||
|
uint32_t adv_ms; /* 最后一次看到序号前进的时刻(与调用方时基同源) */
|
||||||
|
uint8_t primed; /* 0 = 尚未建立基线(首拍只记录、不判定) */
|
||||||
|
} adc_health_age_t;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:采样有效性校验初始化(幂等,内核启动前可调)
|
* 函数功能:采样有效性校验初始化(幂等,内核启动前可调)
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
@@ -91,12 +121,29 @@ void adc_health_init(void);
|
|||||||
*/
|
*/
|
||||||
void adc_health_check(const adc_health_in_t *in, adc_health_status_t *out);
|
void adc_health_check(const adc_health_in_t *in, adc_health_status_t *out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:帧年龄判定(无模块状态,供与消费者存活解耦的兜底门使用)
|
||||||
|
* 入口参数:a_p_age - 调用方自存的参照系
|
||||||
|
* a_cur_seq - 本次读到的序号(须是**中断侧**递增的计数器,如
|
||||||
|
* tpafe5160_frame_seq() / adc_input_stm32_seq())
|
||||||
|
* a_now_ms - 本次时基(与 a_p_age->adv_ms 取同一个任务的 tick)
|
||||||
|
* a_age_ms - 判老门限(ms)
|
||||||
|
* 返 回 值:1 - 已陈旧(调用方应据此闭锁);0 - 新鲜,或仍在建立基线
|
||||||
|
* 限定条件:任意任务上下文;零内核依赖、零共享状态 → 主机侧免桩可直接单测
|
||||||
|
* 函数说明:差值口径与 adc_health_check() 一致((int32_t) 差值,计数与 tick
|
||||||
|
* 回绕均安全)。首次调用只建基线并返回 0;序号有任何前进即返回 0
|
||||||
|
* (一次前进多帧属消费者超拍,不是故障);倒退不在此判老,单调性
|
||||||
|
* 破坏归 ADC_HF_SEQ_BACKWARD 管。参数错返回 0——宁可不锁,不可误锁。
|
||||||
|
*/
|
||||||
|
uint8_t adc_health_age_stale(adc_health_age_t *a_p_age, uint32_t a_cur_seq,
|
||||||
|
uint32_t a_now_ms, uint32_t a_age_ms);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:读当前电平故障位
|
* 函数功能:读当前电平故障位
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返 回 值:ADC_HF_TPAFE_STALL / ADC_HF_STM32_STALL / ADC_HF_SEQ_BACKWARD 组合
|
* 返 回 值:ADC_HF_TPAFE_STALL / ADC_HF_STM32_STALL / ADC_HF_SEQ_BACKWARD 组合
|
||||||
* 限定条件:无
|
* 限定条件:无
|
||||||
* 函数说明:供 L5-4 闭锁判定与 UI;与 check() 出参 flags 同源。
|
* 函数说明:供自检失败闭锁判定与 UI;与 check() 出参 flags 同源。
|
||||||
*/
|
*/
|
||||||
uint32_t adc_health_flags(void);
|
uint32_t adc_health_flags(void);
|
||||||
|
|
||||||
@@ -119,6 +166,50 @@ uint32_t adc_health_sticky(void);
|
|||||||
*/
|
*/
|
||||||
uint32_t adc_health_tpafe_gaps(void);
|
uint32_t adc_health_tpafe_gaps(void);
|
||||||
|
|
||||||
|
/* ===================== 采样通道自检 残余:码值越界(贴轨)检出,2026-10-25 ===================== */
|
||||||
|
|
||||||
|
/* 贴轨锁存的连续拍数:20 拍 = 1 kHz 节拍下一个 50 Hz 周波。削顶/触轨波形的
|
||||||
|
* 贴带段持续 ≥ 半周波;噪声毛刺不可能连续 20 拍钉在同侧轨带内(短板评测 §6.2
|
||||||
|
* 判据①「连续 N 点贴满量程」的定标)。 */
|
||||||
|
#define ADC_RAIL_N_RUN 20u
|
||||||
|
|
||||||
|
/* 贴轨监视的通道数上限(9 = 8 电气量 + 温度;温度是否监视由调用方经
|
||||||
|
* adc_cal_rail_ths() 返回 -1 决定,本层不内嵌语义知识) */
|
||||||
|
#define ADC_RAIL_CH_NUM 9u
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:贴轨观察器复位(adc_health_init() 已内含,单独暴露供编排层复用)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:无
|
||||||
|
* 限定条件:与 check() 同——仅任务上下文(单写者)
|
||||||
|
*/
|
||||||
|
void adc_health_rail_init(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:单通道单拍贴轨观察(采样通道自检 码值越界检出,纯算术)
|
||||||
|
* 入口参数:ch - 通道号(0..ADC_RAIL_CH_NUM-1,越界忽略) uint8_t
|
||||||
|
* code - 本拍原始码值(**码值域**,与标定无关) int32_t
|
||||||
|
* lo_th - 下轨带上界(code ≤ 此值算贴底) int32_t
|
||||||
|
* hi_th - 上轨带下界(code ≥ 此值算贴顶) int32_t
|
||||||
|
* 返 回 值:1 - 本拍**新锁存**该通道(边沿,供编排层落日志);0 - 无新事件 uint32_t
|
||||||
|
* 限定条件:每拍每通道至多一次(与采样帧同节奏,单写者)
|
||||||
|
* 函数说明:锁存条件 = 连续 ADC_RAIL_N_RUN 拍贴**同一侧**轨带,且该通道此前
|
||||||
|
* 出现过中带样本(「释放沿」)。释放沿要求的理由:空载信号恒 0、
|
||||||
|
* 开机未采到帧恒 0 都是**合法工况**(短板评测 §6.2:空载与零值都是
|
||||||
|
* 合法工况)——只有「动过又钉死在轨上」才是削顶/越界签名。锁存后
|
||||||
|
* 不再重复报边沿,直到 rail_init/health_init 重置。
|
||||||
|
*/
|
||||||
|
uint32_t adc_health_rail_watch(uint8_t ch, int32_t code, int32_t lo_th, int32_t hi_th);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读贴轨锁存位图
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:bit ch = 该通道曾检出贴轨(1 kHz × 9 路 → 低 9 位有效) uint32_t
|
||||||
|
* 限定条件:无
|
||||||
|
* 函数说明:供心跳日志 / 后续品质位映射(遥测快照发布与 104 消费的 qds 消费者)只读。
|
||||||
|
*/
|
||||||
|
uint32_t adc_health_rail_sticky(void);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
+32
-20
@@ -1,17 +1,22 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:ADC 输入层(L1 采集与预处理)
|
* 模块名称:ADC 输入层(语义取数与预处理)
|
||||||
* 模块功能:语义通道映射(L1-4)+ STM32 ADC1 采样消费(L1-3,TIM3 TRGO 触发 +
|
* 模块功能:语义通道映射(通道语义映射)+ STM32 ADC1 采样消费(STM32 ADC1 启动与扫描,TIM3 TRGO 触发 +
|
||||||
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
* DMA 双缓冲)+ TPAFE 快照消费。
|
* DMA 双缓冲)+ TPAFE 快照消费。
|
||||||
* 消费侧经 adc_input_raw() 按语义取数,不写死下标。
|
* 消费侧经 adc_input_raw() 按语义取数,不写死下标。
|
||||||
* 适用平台:STM32F407ZGTx + TPAFE5160
|
* 适用平台:STM32F407ZGTx + TPAFE5160
|
||||||
* 作者:王建锋
|
* 作者:王建锋
|
||||||
* 创建日期:2026-10-10
|
* 创建日期:2026-10-10
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-10-10 王建锋 创建:L1-3(STM32 ADC1 扫描)+ L1-4(语义映射)
|
* v1.0 2026-10-10 王建锋 创建:STM32 ADC1 启动与扫描(STM32 ADC1 扫描)+ 通道语义映射(语义映射)
|
||||||
* v2.0 2026-10-10 王建锋 STM32 侧由"软件触发 + 逐通道轮询"改为
|
* v2.0 2026-10-10 王建锋 STM32 侧由"软件触发 + 逐通道轮询"改为
|
||||||
* "TIM3 TRGO 硬件触发 + DMA 双缓冲消费":本层不再触发
|
* "TIM3 TRGO 硬件触发 + DMA 双缓冲消费":本层不再触发
|
||||||
* 也不等待转换(消除最坏 2~3 ms 忙等);新增三个 HAL
|
* 也不等待转换(消除最坏 2~3 ms 忙等);新增三个 HAL
|
||||||
* 回调(ISR 内只计数)与 seq / hw_errs 观测量
|
* 回调(ISR 内只计数)与 seq / hw_errs 观测量
|
||||||
|
* v2.1 2026-10-21 王建锋 丢帧计数口径由「次数」改为「帧点数」(累加每次前进
|
||||||
|
* 的超出部分),与判定层 TPAFE 侧
|
||||||
|
* adc_health_tpafe_gaps() 同单位。⚠ 换版前固件里心跳
|
||||||
|
* 字段 adc1_gap 是次数口径,不可跨版本直接对比
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "adc_input.h"
|
#include "adc_input.h"
|
||||||
@@ -33,11 +38,11 @@
|
|||||||
/* 连续"无新帧"达到该拍数才计一次停摆故障(单拍是相位伪影,见 stm32_take 注释) */
|
/* 连续"无新帧"达到该拍数才计一次停摆故障(单拍是相位伪影,见 stm32_take 注释) */
|
||||||
#define ADC_INPUT_STALL_RUN_MAX 2U
|
#define ADC_INPUT_STALL_RUN_MAX 2U
|
||||||
|
|
||||||
/* ===== 语义映射表(L1-4)=====
|
/* ===== 语义映射表(通道语义映射)=====
|
||||||
* 权威口径:docs/硬件平台/ADC通道映射说明.md(2026-10-09 硬件确认的语义分配)。
|
* 权威口径:docs/硬件平台/ADC通道映射说明.md(2026-10-09 硬件确认的语义分配)。
|
||||||
* index 含义:TPAFE 源 = 驱动 buf 下标 0..7;STM32 源 = ADC1 通道号 0..15。
|
* index 含义:TPAFE 源 = 驱动 buf 下标 0..7;STM32 源 = ADC1 通道号 0..15。
|
||||||
* ⚠ 消费侧一律经本表按语义取数,**不得写死下标**(V0.1 有 13/17 路被置换,
|
* ⚠ 消费侧一律经本表按语义取数,**不得写死下标**(V0.1 有 13/17 路被置换,
|
||||||
* 网名编号与通道号不等价);V0.2 改版(N-18)只改本表内容。 */
|
* 网名编号与通道号不等价);V0.2 改版(消除双转换器无共同采样触发)只改本表内容。 */
|
||||||
static const adc_channel_desc_t k_adc_map[ADC_SEM_COUNT] = {
|
static const adc_channel_desc_t k_adc_map[ADC_SEM_COUNT] = {
|
||||||
[ADC_SEM_UA] = { ADC_SRC_TPAFE, 6U }, /* TPAFE buf[6](ch7) */
|
[ADC_SEM_UA] = { ADC_SRC_TPAFE, 6U }, /* TPAFE buf[6](ch7) */
|
||||||
[ADC_SEM_UB] = { ADC_SRC_STM32, (uint8_t)ADC_CHANNEL_14 }, /* PC4 */
|
[ADC_SEM_UB] = { ADC_SRC_STM32, (uint8_t)ADC_CHANNEL_14 }, /* PC4 */
|
||||||
@@ -69,7 +74,7 @@ static uint16_t s_stm32_dma[ADC_INPUT_STM32_DMA_LEN];
|
|||||||
static volatile uint32_t s_dma_seq; /* DMA 半帧/整帧完成次数(ISR 递增,只增不清) */
|
static volatile uint32_t s_dma_seq; /* DMA 半帧/整帧完成次数(ISR 递增,只增不清) */
|
||||||
static uint32_t s_dma_seq_seen; /* 消费侧上次看到的完成计数 */
|
static uint32_t s_dma_seq_seen; /* 消费侧上次看到的完成计数 */
|
||||||
static volatile uint32_t s_stm32_hw_errs; /* HAL ADC/DMA 错误回调累计(ISR 写) */
|
static volatile uint32_t s_stm32_hw_errs; /* HAL ADC/DMA 错误回调累计(ISR 写) */
|
||||||
static uint32_t s_stm32_gaps; /* 丢帧:seq 一次前进 >1(消费者超拍漏读)*/
|
static uint32_t s_stm32_gaps; /* 丢帧点数:Σ(前进量-1),与 tgap 同单位 */
|
||||||
static uint8_t s_stall_run; /* 连续"无新帧"拍数(相位伪影只会是 1 拍)*/
|
static uint8_t s_stall_run; /* 连续"无新帧"拍数(相位伪影只会是 1 拍)*/
|
||||||
/* TPAFE5160 最新一帧原始码值(16 位二进制补码),按驱动 buf 下标索引 */
|
/* TPAFE5160 最新一帧原始码值(16 位二进制补码),按驱动 buf 下标索引 */
|
||||||
static int16_t s_tpafe_raw[ADC_INPUT_TPAFE_CH_NUM];
|
static int16_t s_tpafe_raw[ADC_INPUT_TPAFE_CH_NUM];
|
||||||
@@ -79,21 +84,22 @@ static int s_stm32_rc; /* STM32 最近一次扫描状态码 */
|
|||||||
static uint32_t s_stm32_errs; /* STM32 扫描累计失败次数 */
|
static uint32_t s_stm32_errs; /* STM32 扫描累计失败次数 */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:消费一帧 STM32 ADC1 采样(L1-3,v2.0 DMA 双缓冲版)
|
* 函数功能:消费一帧 STM32 ADC1 采样(STM32 ADC1 启动与扫描,DMA 双缓冲)
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返 回 值:0 - 取到新一帧;-2 - 本拍无新帧(TIM3 未启动 / ADC 停摆 / DMA 出错)
|
* 返 回 值:0 - 取到新一帧;-2 - 本拍无新帧(TIM3 未启动 / ADC 停摆 / DMA 出错)
|
||||||
* 限定条件:仅任务上下文调用;adc_input_init() 已挂好 DMA
|
* 限定条件:仅任务上下文调用;adc_input_init() 已挂好 DMA
|
||||||
* 函数说明:本函数**既不触发转换也不等待转换**——转换由 TIM3 TRGO 硬件触发、由 DMA
|
* 函数说明:本函数**既不触发转换也不等待转换**——转换由 TIM3 TRGO 硬件触发、由 DMA
|
||||||
* 自动搬运。每拍只做"看完成计数是否前进 + 拷 9 个半字",故**零忙等**:
|
* 自动搬运。每拍只做"看完成计数是否前进 + 拷 9 个半字",故**零忙等**:
|
||||||
* 原轮询版常态 adcp=122 µs、超时拍最坏 2~3 ms,现在两档都降到 ~1 µs 量级。
|
* 单拍只剩"读一个计数 + 拷 9 个半字"→ µs 量级。
|
||||||
* 读的是"上一拍刚完成的那一半"乒乓缓冲(DMA 此刻正在写另一半)→ 无撕裂。
|
* 读的是"上一拍刚完成的那一半"乒乓缓冲(DMA 此刻正在写另一半)→ 无撕裂。
|
||||||
* ⚠ 计数未前进 = 本拍 STM32 侧**无有效数据**,必须清 s_stm32_valid,绝不
|
* ⚠ 计数未前进 = 本拍 STM32 侧**无有效数据**,必须清 s_stm32_valid,绝不
|
||||||
* 沿用上一拍旧值(同 2026-10-10 修的陈旧帧缺陷口径)→ adc_input_raw()
|
* 沿用上一拍旧值(半新半旧的混合帧会污染相量窗)→ adc_input_raw()
|
||||||
* 返 -2 → adc_task 走既有"整帧跳过"路径;下一拍自动恢复。
|
* 返 -2 → adc_task 走既有"整帧跳过"路径;下一拍自动恢复。
|
||||||
*/
|
*/
|
||||||
static int stm32_take(void) {
|
static int stm32_take(void) {
|
||||||
uint32_t seq = s_dma_seq; /* 单次 32 位读:与 ISR 自增不产生撕裂 */
|
uint32_t seq = s_dma_seq; /* 单次 32 位读:与 ISR 自增不产生撕裂 */
|
||||||
const uint16_t *src;
|
const uint16_t *src;
|
||||||
|
uint32_t d;
|
||||||
uint8_t half;
|
uint8_t half;
|
||||||
uint8_t i;
|
uint8_t i;
|
||||||
|
|
||||||
@@ -105,7 +111,7 @@ static int stm32_take(void) {
|
|||||||
* 看到"计数没前进"(那个完成已被上一拍消费掉)。这种伪影**只会连续 1 拍**——
|
* 看到"计数没前进"(那个完成已被上一拍消费掉)。这种伪影**只会连续 1 拍**——
|
||||||
* DMA 若真停,第 2 拍仍不会前进。故只有连续 >= ADC_INPUT_STALL_RUN_MAX 拍
|
* DMA 若真停,第 2 拍仍不会前进。故只有连续 >= ADC_INPUT_STALL_RUN_MAX 拍
|
||||||
* 无新帧才计入故障计数(实测开机日志突发期约 21~26 次伪影、稳态每 10 s 心跳 1 次,
|
* 无新帧才计入故障计数(实测开机日志突发期约 21~26 次伪影、稳态每 10 s 心跳 1 次,
|
||||||
* 全部为单拍;见 docs/子系统设计/ADC采样节拍设计说明.md §10.5) */
|
* 全部为单拍;见 docs/子系统设计/ADC采样节拍设计说明.md §8) */
|
||||||
if (s_stall_run < 255u) {
|
if (s_stall_run < 255u) {
|
||||||
s_stall_run++;
|
s_stall_run++;
|
||||||
}
|
}
|
||||||
@@ -115,11 +121,14 @@ static int stm32_take(void) {
|
|||||||
return ADC_INPUT_RC_STALL;
|
return ADC_INPUT_RC_STALL;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ((uint32_t)(seq - s_dma_seq_seen) > 1u) {
|
d = seq - s_dma_seq_seen;
|
||||||
|
if (d > 1u) {
|
||||||
/* 计数一次前进 >1 = 中间有帧没被读走(乒乓缓冲已被覆盖)→ **真丢帧**,
|
/* 计数一次前进 >1 = 中间有帧没被读走(乒乓缓冲已被覆盖)→ **真丢帧**,
|
||||||
* 与"无新帧"分开计数:前者是消费者超拍(本任务单拍耗时 > 1 ms),
|
* 与"无新帧"分开计数:前者是消费者超拍(本任务单拍耗时 > 1 ms),
|
||||||
* 后者多半是相位伪影 */
|
* 后者多半是相位伪影。按**丢掉的帧数**累计(d-1),与判定层 TPAFE 侧的
|
||||||
s_stm32_gaps++;
|
* adc_health_tpafe_gaps() 同单位——否则心跳里 adc1_gap 与 tgap 一个是次
|
||||||
|
* 一个是点,跨字段对比会得出错误结论 */
|
||||||
|
s_stm32_gaps += (d - 1u);
|
||||||
}
|
}
|
||||||
s_stall_run = 0u;
|
s_stall_run = 0u;
|
||||||
s_dma_seq_seen = seq;
|
s_dma_seq_seen = seq;
|
||||||
@@ -145,8 +154,9 @@ static int stm32_take(void) {
|
|||||||
* 函数说明:逐条校验映射表与扫描配置是否一致——① TPAFE 源下标须落在
|
* 函数说明:逐条校验映射表与扫描配置是否一致——① TPAFE 源下标须落在
|
||||||
* buf[0..7];② STM32 源的通道号必须出现在 k_stm32_rank_ch[] 中,
|
* buf[0..7];② STM32 源的通道号必须出现在 k_stm32_rank_ch[] 中,
|
||||||
* 否则该通道永远不会被扫描(读到的恒为陈旧值)。这是"通道宏写错 →
|
* 否则该通道永远不会被扫描(读到的恒为陈旧值)。这是"通道宏写错 →
|
||||||
* 读到另一只脚/读不到"这一 §5 警告的代码级兜底(无信号源时无法做
|
* 读到另一只脚/读不到"这一警告的代码级兜底(警告口径与逐通道验证方法:
|
||||||
* 引脚级注入验证,见 docs/硬件平台/ADC通道映射说明.md §8)。
|
* docs/硬件平台/ADC通道映射说明.md §5、§8。台架注入验证只在台架做一次,
|
||||||
|
* 本自检每次开机都跑,两者不互相替代)。
|
||||||
*/
|
*/
|
||||||
static int map_check(void) {
|
static int map_check(void) {
|
||||||
uint8_t s;
|
uint8_t s;
|
||||||
@@ -232,7 +242,7 @@ int adc_input_init(void) {
|
|||||||
* STM32 侧无新帧不影响 TPAFE 快照与返回值语义(其状态另由
|
* STM32 侧无新帧不影响 TPAFE 快照与返回值语义(其状态另由
|
||||||
* adc_input_stm32_rc() / adc_input_stm32_seq() 暴露)。
|
* adc_input_stm32_rc() / adc_input_stm32_seq() 暴露)。
|
||||||
* ⚠ STM32 侧数据比 TPAFE 侧**滞后 1 拍**(乒乓双缓冲的固有延迟,
|
* ⚠ STM32 侧数据比 TPAFE 侧**滞后 1 拍**(乒乓双缓冲的固有延迟,
|
||||||
* 9 路一致);按 N-18,V0.1 本就禁止跨转换器做矢量运算。
|
* 9 路一致);因双转换器无共同采样触发,V0.1 本就禁止跨转换器做矢量运算。
|
||||||
*/
|
*/
|
||||||
int adc_input_poll(void) {
|
int adc_input_poll(void) {
|
||||||
int r = tpafe5160_snapshot(s_tpafe_raw, (uint8_t)ADC_INPUT_TPAFE_CH_NUM);
|
int r = tpafe5160_snapshot(s_tpafe_raw, (uint8_t)ADC_INPUT_TPAFE_CH_NUM);
|
||||||
@@ -310,12 +320,14 @@ uint32_t adc_input_stm32_errs(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:读取 STM32 ADC1 的丢帧次数
|
* 函数功能:读取 STM32 ADC1 累计丢帧点数
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返 回 值:DMA 完成计数一次前进 >1 的次数(中间那帧已被乒乓缓冲覆盖)
|
* 返 回 值:Σ(完成计数每次前进的超出部分),即被乒乓缓冲覆盖掉、没被消费的帧数
|
||||||
* 限定条件:无
|
* 限定条件:无
|
||||||
* 函数说明:这是"消费者超拍"的直接证据(本任务单拍耗时 > 1 ms,如突发日志突发期);
|
* 函数说明:这是"消费者超拍"的直接证据(本任务单拍耗时 > 1 ms,如心跳日志突发期);
|
||||||
* 与 adc_input_stm32_errs()(节拍源停摆)是两类不同的故障。
|
* 与 adc_input_stm32_errs()(节拍源停摆)是两类不同的故障,**勿混读**。
|
||||||
|
* 单位与判定层 TPAFE 侧 adc_health_tpafe_gaps() 一致(都是帧点数),心跳里
|
||||||
|
* 可直接对比;注意 2026-10-21 之前固件该字段是"次数"口径。
|
||||||
*/
|
*/
|
||||||
uint32_t adc_input_stm32_gaps(void) {
|
uint32_t adc_input_stm32_gaps(void) {
|
||||||
return s_stm32_gaps;
|
return s_stm32_gaps;
|
||||||
|
|||||||
+18
-12
@@ -1,11 +1,12 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:ADC 输入层(L1 采集与预处理)
|
* 模块名称:ADC 输入层(语义取数与预处理)
|
||||||
* 模块功能:把两片转换器的原始码值按**语义**(UA/UB/UC/UN/IA/IB/IC/IN/温度)统一
|
* 模块功能:把两片转换器的原始码值按**语义**(UA/UB/UC/UN/IA/IB/IC/IN/温度)统一
|
||||||
* 暴露,使上层(UI / 自检 / 保护算法)**按语义取数,不写死下标**:
|
* 暴露,使上层(UI / 自检 / 保护算法)**按语义取数,不写死下标**:
|
||||||
* ① 语义通道映射表(L1-4)——权威口径 docs/硬件平台/ADC通道映射说明.md;
|
* ① 语义通道映射表(通道语义映射)——权威口径 docs/硬件平台/ADC通道映射说明.md;
|
||||||
* ② STM32 ADC1 9 通道采样(L1-3)——由 **TIM3 TRGO 硬件触发 + DMA 双缓冲
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
* 搬运**(v2.0),本层只消费、不触发、不等待。
|
* ② STM32 ADC1 9 通道采样(STM32 ADC1 启动与扫描)——由 **TIM3 TRGO 硬件触发 + DMA 双缓冲
|
||||||
* V0.1 上 8 路电气量分属两片转换器(硬件缺陷 N-18):UA/UN/IA 走
|
* 搬运**,本层只消费、不触发、不等待。
|
||||||
|
* V0.1 上 8 路电气量分属两片转换器(硬件缺陷:双转换器无共同采样触发):UA/UN/IA 走
|
||||||
* TPAFE5160(16 位 / ±5 V 双极性),UB/UC/IB/IC/IN 走 STM32 ADC1
|
* TPAFE5160(16 位 / ±5 V 双极性),UB/UC/IB/IC/IN 走 STM32 ADC1
|
||||||
* (12 位 / 0~3.3 V 单极性)。V0.2 改版(8 路电气量全部接 TPAFE)
|
* (12 位 / 0~3.3 V 单极性)。V0.2 改版(8 路电气量全部接 TPAFE)
|
||||||
* 只改映射表内容,本层 API 与上层语义不动。
|
* 只改映射表内容,本层 API 与上层语义不动。
|
||||||
@@ -13,11 +14,14 @@
|
|||||||
* 作者:王建锋
|
* 作者:王建锋
|
||||||
* 创建日期:2026-10-10
|
* 创建日期:2026-10-10
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-10-10 王建锋 创建:L1-3(STM32 ADC1 扫描)+ L1-4(语义映射)
|
* v1.0 2026-10-10 王建锋 创建:STM32 ADC1 启动与扫描(STM32 ADC1 扫描)+ 通道语义映射(语义映射)
|
||||||
* v2.0 2026-10-10 王建锋 STM32 侧改 TIM3 TRGO 硬件触发 + DMA 双缓冲消费:
|
* v2.0 2026-10-10 王建锋 STM32 侧改 TIM3 TRGO 硬件触发 + DMA 双缓冲消费:
|
||||||
* 消除逐通道轮询的 2 ms 忙等;节拍源 TIM6 -> TIM3
|
* 消除逐通道轮询的 2 ms 忙等;节拍源 TIM6 -> TIM3
|
||||||
* (F4 上 ADC1 只认 TIM2/TIM3/TIM8 的 TRGO,TIM6 TRGO
|
* (换源判据:F4 上 ADC1 收不到 TIM6/TIM7 的 TRGO,见
|
||||||
* 仅接 DAC);新增 adc_input_stm32_seq()/_hw_errs()
|
* docs/子系统设计/ADC采样节拍设计说明.md §3.2);新增
|
||||||
|
* adc_input_stm32_seq() / _hw_errs()
|
||||||
|
* v2.1 2026-10-21 王建锋 adc_input_stm32_gaps() 单位由「次数」改为「帧点数」,
|
||||||
|
* 与判定层 adc_health_tpafe_gaps() 同单位
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#ifndef ADC_INPUT_H
|
#ifndef ADC_INPUT_H
|
||||||
@@ -44,7 +48,7 @@ typedef enum {
|
|||||||
ADC_SEM_COUNT /* 语义通道数(哨兵) */
|
ADC_SEM_COUNT /* 语义通道数(哨兵) */
|
||||||
} adc_semantic_t;
|
} adc_semantic_t;
|
||||||
|
|
||||||
/* 数据来源:V0.1 的 8 路电气量分属两片转换器(N-18) */
|
/* 数据来源:V0.1 的 8 路电气量分属两片转换器(双转换器无共同采样触发) */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
ADC_SRC_TPAFE = 0, /* TPAFE5160:16 位、±5 V 双极性、8 通道同步 */
|
ADC_SRC_TPAFE = 0, /* TPAFE5160:16 位、±5 V 双极性、8 通道同步 */
|
||||||
ADC_SRC_STM32 /* STM32 ADC1:12 位、0~3.3 V 单极性、9 通道顺序扫描 */
|
ADC_SRC_STM32 /* STM32 ADC1:12 位、0~3.3 V 单极性、9 通道顺序扫描 */
|
||||||
@@ -61,7 +65,7 @@ typedef struct {
|
|||||||
/* STM32 ADC1 顺序扫描通道数(Rank 1..9) */
|
/* STM32 ADC1 顺序扫描通道数(Rank 1..9) */
|
||||||
#define ADC_INPUT_STM32_CH_NUM 9U
|
#define ADC_INPUT_STM32_CH_NUM 9U
|
||||||
|
|
||||||
/* STM32 ADC1 最近一次取帧状态码(v2.0:DMA 消费,已无"轮询超时"这一档) */
|
/* STM32 ADC1 最近一次取帧状态码(DMA 消费,已无"轮询超时"这一档) */
|
||||||
#define ADC_INPUT_RC_OK (0) /* 取到新一帧 */
|
#define ADC_INPUT_RC_OK (0) /* 取到新一帧 */
|
||||||
#define ADC_INPUT_RC_START (-1) /* HAL_ADC_Start_DMA 挂载失败 */
|
#define ADC_INPUT_RC_START (-1) /* HAL_ADC_Start_DMA 挂载失败 */
|
||||||
#define ADC_INPUT_RC_STALL (-2) /* 本拍无新帧(TIM3 未启动 / ADC 停摆 / DMA 出错) */
|
#define ADC_INPUT_RC_STALL (-2) /* 本拍无新帧(TIM3 未启动 / ADC 停摆 / DMA 出错) */
|
||||||
@@ -129,12 +133,14 @@ int adc_input_stm32_rc(void);
|
|||||||
uint32_t adc_input_stm32_errs(void);
|
uint32_t adc_input_stm32_errs(void);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:读取 STM32 ADC1 的丢帧次数
|
* 函数功能:读取 STM32 ADC1 累计丢帧点数
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返 回 值:DMA 完成计数一次前进 >1 的次数(中间那帧已被覆盖,未及消费)
|
* 返 回 值:Σ(完成计数每次前进的超出部分),即没被消费就被覆盖掉的帧数
|
||||||
* 限定条件:无
|
* 限定条件:无
|
||||||
* 函数说明:与 errs 分开——gap = 消费者超拍(单拍耗时 > 1 ms),
|
* 函数说明:与 errs 分开——gap = 消费者超拍(单拍耗时 > 1 ms),
|
||||||
* err = 节拍源/ DMA 停摆。两类故障处置不同,混在一起就查不了。
|
* err = 节拍源/ DMA 停摆。两类故障处置不同,混在一起就查不了。
|
||||||
|
* 单位与判定层 adc_health_tpafe_gaps() 一致(帧点数),心跳里可直接对比;
|
||||||
|
* 2026-10-21 之前的固件该字段是"次数"口径。
|
||||||
*/
|
*/
|
||||||
uint32_t adc_input_stm32_gaps(void);
|
uint32_t adc_input_stm32_gaps(void);
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:频率测量层(L2 派生量)——实现
|
* 模块名称:频率测量层(全周傅氏相量的派生量)——实现
|
||||||
* 模块功能:见 freq.h。相邻两拍基波相量共轭乘积取角(= 每拍相位增量,免
|
* 模块功能:见 freq.h。相邻两拍基波相量共轭乘积取角(= 每拍相位增量,免
|
||||||
* 显式解缠绕)+ **自适应窗长滑动平均** + 瞬时值钳制 + 幅值门。
|
* 显式解缠绕)+ **自适应窗长滑动平均** + 瞬时值钳制 + 幅值门。
|
||||||
* 输入 = phasor 使用区基波相量(UA 优先,UA 无幅值用 IA)。
|
* 输入 = phasor 使用区基波相量(UA 优先,UA 无幅值用 IA)。
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
* ≈4 个信号周波),使窗长恰为纹波周期的整数倍 → δ(m+K)≈δ(m),残差进入
|
* ≈4 个信号周波),使窗长恰为纹波周期的整数倍 → δ(m+K)≈δ(m),残差进入
|
||||||
* 0.01 Hz 量级。f_hat 用上一拍读数(收敛后误差 <0.1 Hz → K_eff 偏差 <1 拍,
|
* 0.01 Hz 量级。f_hat 用上一拍读数(收敛后误差 <0.1 Hz → K_eff 偏差 <1 拍,
|
||||||
* 自洽)。
|
* 自洽)。
|
||||||
* **精度优先于响应的显式取舍(Lead 批准 2026-10-12)**:4 周波窗换来
|
* **精度优先于响应的显式取舍**:4 周波窗换来
|
||||||
* 0.317→0.0101 Hz,代价是频率骤变后的窗冲刷(J4 阶跃整定 ≤120 ms,实测
|
* 0.317→0.0101 Hz,代价是频率骤变后的窗冲刷(J4 阶跃整定 ≤120 ms,实测
|
||||||
* 98 ms)。OF/UF 定时限默认 1000 ms → 测频响应占保护总动作时间 <10%;
|
* 98 ms)。OF/UF 定时限默认 1000 ms → 测频响应占保护总动作时间 <10%;
|
||||||
* 暂态假值另有 J6(跳变后 ≤60 ms 回 ±0.05 Hz)兜底。故 J4 的 120 ms 是
|
* 暂态假值另有 J6(跳变后 ≤60 ms 回 ±0.05 Hz)兜底。故 J4 的 120 ms 是
|
||||||
|
|||||||
+15
-8
@@ -1,15 +1,16 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:频率测量层(L2 派生量,L3-5 过频/低频的测量输入)
|
* 模块名称:频率测量层(全周傅氏相量的派生量,过频 / 低频的测量输入)
|
||||||
* 模块功能:由 L2 相量使用区的基波相量导出系统频率——相邻两拍基波相量取
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
|
* 模块功能:由 全周傅氏相量使用区的基波相量导出系统频率——相邻两拍基波相量取
|
||||||
* **共轭乘积** P = Z(m)·conj(Z(m-1))(其角 = 每拍相位增量,纯音 =
|
* **共轭乘积** P = Z(m)·conj(Z(m-1))(其角 = 每拍相位增量,纯音 =
|
||||||
* +2πf/Fs,免显式解缠绕),再对**自适应窗长**的相位增量做滑动平均:
|
* +2πf/Fs,免显式解缠绕),再对**自适应窗长**的相位增量做滑动平均:
|
||||||
* K_eff = round(8·Fs/(2·f_hat)) ≈ 泄漏纹波周期 fs/(2f) 的整数倍
|
* K_eff = round(8·Fs/(2·f_hat)) ≈ 泄漏纹波周期 fs/(2f) 的整数倍
|
||||||
* -> 非整周波 DFT 负序泄漏造成的 ~10 ms 周期相位纹波相消
|
* -> 非整周波 DFT 负序泄漏造成的 ~10 ms 周期相位纹波相消
|
||||||
* (定长窗平均实测扫频误差 0.2~0.32 Hz,自适应窗进入 0.01 Hz 量级,
|
* (定长窗平均实测扫频误差 0.2~0.32 Hz,自适应窗进入 0.01 Hz 量级,
|
||||||
* 推导与实测见 freq.c 文件头与 test/host/freq 输出)。
|
* 推导与实测见 freq.c 文件头与 test/host/freq 输出)。
|
||||||
* 覆盖规划 §3.3 L3-5 的测量侧与 §4.2 测频范围(45~55 Hz 建议段)。
|
* 覆盖规划 §3.3 过频 / 低频 的测量侧与 §4.2 测频范围(45~55 Hz 建议段)。
|
||||||
*
|
*
|
||||||
* 原理与判据(详见 App/algo/README.md §3.7 与 test/host/freq/):
|
* 原理与判据(详见 docs/算法/采集与算法层设计说明.md §7 与 test/host/freq/):
|
||||||
* - 采样率 Fs = 1000 Hz(TIM3 节拍,与 phasor 同源);45~55 Hz 段每拍
|
* - 采样率 Fs = 1000 Hz(TIM3 节拍,与 phasor 同源);45~55 Hz 段每拍
|
||||||
* 相位增量 ≈ 0.28~0.35 rad,距 ±π 缠绕边界裕度巨大,无歧义;
|
* 相位增量 ≈ 0.28~0.35 rad,距 ±π 缠绕边界裕度巨大,无歧义;
|
||||||
* - 瞬时值钳制:单拍相位差折算越出 35~65 Hz(**假值带**,宽于系统合理带
|
* - 瞬时值钳制:单拍相位差折算越出 35~65 Hz(**假值带**,宽于系统合理带
|
||||||
@@ -21,7 +22,7 @@
|
|||||||
* - 信号源优先级:UA 基波 → IA 基波(UA 无幅值时用 IA,台架只注电流亦可测频)。
|
* - 信号源优先级:UA 基波 → IA 基波(UA 无幅值时用 IA,台架只注电流亦可测频)。
|
||||||
*
|
*
|
||||||
* ⚠ 本层**纯算法**:不碰寄存器、不碰 RTOS、不自己取数(输入 = phasor 使用区)
|
* ⚠ 本层**纯算法**:不碰寄存器、不碰 RTOS、不自己取数(输入 = phasor 使用区)
|
||||||
* → 主机侧可免桩单测(L0.5,落实登记簿 A-4)。
|
* → 主机侧可免桩纯逻辑单测。
|
||||||
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
|
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
|
||||||
* 作者:DTU 项目组
|
* 作者:DTU 项目组
|
||||||
* 创建日期:2026-10-12(B8)
|
* 创建日期:2026-10-12(B8)
|
||||||
@@ -49,9 +50,15 @@
|
|||||||
#define FREQ_INST_MIN_HZ 35.0f
|
#define FREQ_INST_MIN_HZ 35.0f
|
||||||
#define FREQ_INST_MAX_HZ 65.0f
|
#define FREQ_INST_MAX_HZ 65.0f
|
||||||
|
|
||||||
/* 幅值门限(有效值,**原始码值口径**,L1-5 标定未实现):低于该值判
|
/* 幅值门限(有效值,**二次值 V 口径**,「数据可信」下游常数按工程量重定的结果,依据其 §2.3 表第 1 行):
|
||||||
* "无有效波形"。⚠ 拟定值(远小于正常波形千级码值、远大于浮空噪声),
|
* 低于该值判"无有效波形"。取值 50 V = 0.5×Un(相电压标称 100 V,与
|
||||||
* 标定落地后须按工程量口径复核(同 prot_set 默认值"拟定"口径)。 */
|
* App/algo/prot_set.c 头部量纲基准同源):正常带载 UA 在 0.9~1.1×Un,
|
||||||
|
* 50 V 之下只可能是断保险/PT 空载/浮空噪声(浮空实测 ~1.7 V 引脚域、
|
||||||
|
* 二次域更小),不会吃掉正常纹波(纹波谷值远大于 0.5×Un)。
|
||||||
|
* ⚠ 过渡期行为(eng=0,两点标定完成前):帧值还是引脚电压(≤5 V < 50 V),
|
||||||
|
* 本门**恒失效** → freq_valid()=0 → OF/UF 按"必不启动"填充复位——与未标定即闭锁
|
||||||
|
* 同向(判据链本就停走),不构成额外风险;标定后自动恢复本意。
|
||||||
|
* 数值沿用原 50.0f 属巧合(原为"远小于千级码值"的拟定值),不是漏改。 */
|
||||||
#define FREQ_AMP_MIN 50.0f
|
#define FREQ_AMP_MIN 50.0f
|
||||||
|
|
||||||
/* 相位增量滑窗环容量(拍):96 拍 = 96 ms 上限。实际平均窗长**自适应**
|
/* 相位增量滑窗环容量(拍):96 拍 = 96 ms 上限。实际平均窗长**自适应**
|
||||||
|
|||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:遥测快照发布层(全周傅氏相量派生·发布侧,「数据可信」§4.5)
|
||||||
|
* 模块功能:实现见 meas.h——200 ms 基波有效值 + 品质快照,双区无锁读。
|
||||||
|
* 本文件纯算法:输入 = sample_ring 取窗 + 调用方注入(频率/削顶位图),
|
||||||
|
* 无 RTOS / 无寄存器 / 无日志(「数据可信」层内禁令:本目录不写日志)。
|
||||||
|
* 双区不变量(docs/任务规划/数据可信.md §4.5,双区指针原子交换 同型,先例 = App/algo/
|
||||||
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
|
* phasor.c 的双区注释):写侧只碰**非使用区**,全部字段填完后一次
|
||||||
|
* volatile 单字节 store 翻转使用区;读者先取索引再整体拷贝 → 拷贝期
|
||||||
|
* 间写者不可能改读者所在区(两帧间隔 200 ms 远大于 72 B 拷贝)。
|
||||||
|
* 单核顺序执行,无需内存屏障;volatile 阻编译器把字段写入挪到翻转后。
|
||||||
|
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
|
||||||
|
* 作者:DTU 项目组
|
||||||
|
* 创建日期:2026-10-25(道D 遥测快照发布)
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-25 DTU 项目组 创建:200 ms 遥测快照 + 双区无锁读
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "meas.h"
|
||||||
|
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
#include "phasor.h" /* phasor_calc():基波 DFT 纯函数核(与使用区同核) */
|
||||||
|
#include "sample_ring.h" /* sample_ring_window():以本拍为终止的一周波窗 */
|
||||||
|
|
||||||
|
/* ===== 双区(计算区 / 使用区),口径同 App/algo/phasor.c 的 双区指针原子交换===== */
|
||||||
|
static meas_snap_t s_ms_snap[2];
|
||||||
|
static volatile uint8_t s_ms_use;
|
||||||
|
static uint8_t s_ms_ready; /* 使用区是否已产出过有效结果 */
|
||||||
|
static uint32_t s_ms_seq; /* 只增帧号(写者单写者,读者原子单字读) */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:清零一份快照(含 rsv 与全部数组元素)
|
||||||
|
* 入口参数:p - 目标快照
|
||||||
|
* 返 回 值:无
|
||||||
|
* 限定条件:p 非空
|
||||||
|
* 函数说明:静态辅助;逐字段清零(不依赖 memset,行为与本目录 phasor_zero 同型)。
|
||||||
|
*/
|
||||||
|
static void meas_zero(meas_snap_t *p) {
|
||||||
|
uint8_t i;
|
||||||
|
|
||||||
|
p->seq = 0u;
|
||||||
|
p->tick_ms = 0u;
|
||||||
|
p->valid = 0u;
|
||||||
|
p->rsv[0] = 0u;
|
||||||
|
p->rsv[1] = 0u;
|
||||||
|
p->rsv[2] = 0u;
|
||||||
|
p->freq_valid = 0u;
|
||||||
|
p->freq_hz = 0.0f;
|
||||||
|
for (i = 0u; i < (uint8_t)ADC_SEM_COUNT; i++) {
|
||||||
|
p->yc[i] = 0.0f;
|
||||||
|
p->qds[i] = 0u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:遥测快照层初始化
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:0 - 成功
|
||||||
|
* 限定条件:内核启动前或任务上下文皆可;幂等,可重复调用
|
||||||
|
* 函数说明:清双区 + 索引/就绪/序号复位。init 后到首次发布前,meas_get() 恒
|
||||||
|
* 返 -2(out 清零)、meas_seq() 恒返 0。
|
||||||
|
*/
|
||||||
|
int meas_init(void) {
|
||||||
|
meas_zero(&s_ms_snap[0]);
|
||||||
|
meas_zero(&s_ms_snap[1]);
|
||||||
|
s_ms_use = 0u;
|
||||||
|
s_ms_ready = 0u;
|
||||||
|
s_ms_seq = 0u;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:一拍遥测发布(被调即发布,节拍把关在调用方)
|
||||||
|
* 入口参数:tick_ms - 本拍时基(毫秒) uint32_t
|
||||||
|
* freq_valid_in - 频率读数新鲜度(freq_valid() 值) uint8_t
|
||||||
|
* freq_hz_in - 频率读数(freq_last() 值) float
|
||||||
|
* clip_mask - 削顶通道位图(bit s = 语义通道 s,来源唯一 =
|
||||||
|
* adc_cal_flags(),编排方注入) uint32_t
|
||||||
|
* 返 回 值:无
|
||||||
|
* 限定条件:仅 adcTask 上下文、全仓唯一调用点(meas.h 发布纪律);须在
|
||||||
|
* phasor_run() 之后调用(本拍窗含最新点,与使用区同窗同核)
|
||||||
|
* 函数说明:电气 8 路(语义序中 TEMP 之前的全部通道,映射表属主 =
|
||||||
|
* docs/硬件平台/ADC通道映射说明.md)逐路一周波基波 DFT;任一路取窗
|
||||||
|
* 不足(开机未满一周波)→ 该路 IV|NT + 整帧 valid=0,但照常发布
|
||||||
|
* (品质节拍性刷新,见 meas_tick 头注)。TEMP 只取最新一点透传 +
|
||||||
|
* NT(「数据可信」决策 5)。品质值不因 IV 清零——"值在场但不可信"
|
||||||
|
* 与"值缺失"分开,消费者按 qds 裁决。
|
||||||
|
*/
|
||||||
|
void meas_tick(uint32_t tick_ms, uint8_t freq_valid_in, float freq_hz_in,
|
||||||
|
uint32_t clip_mask) {
|
||||||
|
meas_snap_t *z = &s_ms_snap[1u - s_ms_use]; /* 计算区 = 非使用区 */
|
||||||
|
float win[PHASOR_N];
|
||||||
|
phasor_t p;
|
||||||
|
uint8_t all_ok = 1u;
|
||||||
|
uint8_t s;
|
||||||
|
|
||||||
|
meas_zero(z);
|
||||||
|
z->seq = s_ms_seq + 1u;
|
||||||
|
z->tick_ms = tick_ms;
|
||||||
|
|
||||||
|
for (s = 0u; s < (uint8_t)ADC_SEM_TEMP; s++) {
|
||||||
|
if (sample_ring_window((adc_semantic_t)s, (uint8_t)PHASOR_N, win) == 0) {
|
||||||
|
phasor_calc(win, (uint8_t)PHASOR_N, 1u, &p);
|
||||||
|
z->yc[s] = p.fAmSQ; /* 有效值口径(系数已折算,同 phasor 层) */
|
||||||
|
if ((clip_mask & (1uL << s)) != 0u) {
|
||||||
|
z->qds[s] |= (uint16_t)MEAS_QDS_CLIPPED;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
/* 本路窗不足:值缺位(yc 保持 0)+ IV|NT;整帧判不可信 */
|
||||||
|
z->qds[s] = (uint16_t)(MEAS_QDS_IV | MEAS_QDS_NT);
|
||||||
|
all_ok = 0u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* TEMP:非交流量语义(「数据可信」决策 5),最新一点原始帧值透传 + NT;
|
||||||
|
* clip_mask 位 8 不参与(adc_cal_flags(TEMP) 恒 0,无削顶语义) */
|
||||||
|
{
|
||||||
|
float tv = 0.0f;
|
||||||
|
|
||||||
|
if (sample_ring_window(ADC_SEM_TEMP, 1u, &tv) == 0) {
|
||||||
|
z->yc[ADC_SEM_TEMP] = tv;
|
||||||
|
z->qds[ADC_SEM_TEMP] = (uint16_t)MEAS_QDS_NT;
|
||||||
|
} else {
|
||||||
|
z->qds[ADC_SEM_TEMP] = (uint16_t)(MEAS_QDS_IV | MEAS_QDS_NT);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
z->valid = all_ok;
|
||||||
|
z->freq_valid = (freq_valid_in != 0u) ? 1u : 0u;
|
||||||
|
z->freq_hz = freq_hz_in;
|
||||||
|
|
||||||
|
/* 全部字段就绪后才翻索引 + 置就绪(顺序即协议,勿交换) */
|
||||||
|
s_ms_seq = z->seq;
|
||||||
|
s_ms_use = (uint8_t)(1u - s_ms_use);
|
||||||
|
s_ms_ready = 1u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读当前使用区快照(无锁整体拷贝)
|
||||||
|
* 入口参数:out - 输出快照;失败路径同样清零填充
|
||||||
|
* 返 回 值:0 - 成功;-1 - 参数错;-2 - 从未发布过 int
|
||||||
|
* 限定条件:任意上下文;无(读 volatile 索引 + 普通拷贝)
|
||||||
|
* 函数说明:先取索引再索引拷贝,避免拷贝中途索引被翻转导致"半旧半新"。
|
||||||
|
* 返回 0 时帧内部自洽(同一发布拍的 seq/tick_ms/yc/qds 成套)。
|
||||||
|
*/
|
||||||
|
int meas_get(meas_snap_t *out) {
|
||||||
|
uint8_t z;
|
||||||
|
|
||||||
|
if (out == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (s_ms_ready == 0u) {
|
||||||
|
meas_zero(out);
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
z = s_ms_use;
|
||||||
|
*out = s_ms_snap[z];
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读已发布帧数(只增)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:seq,0 = 从未发布(单字读,原子) uint32_t
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
* 函数说明:轻量"是否换帧"探针,正式取数以 meas_get() 为准。
|
||||||
|
*/
|
||||||
|
uint32_t meas_seq(void) {
|
||||||
|
return s_ms_seq;
|
||||||
|
}
|
||||||
+137
@@ -0,0 +1,137 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:遥测快照发布层(全周傅氏相量派生·发布侧,「数据可信」§4.5)
|
||||||
|
* 模块功能:每 **200 ms** 从 全周傅氏相量层取一次基波有效值 + 品质,产出一份全仓唯一的
|
||||||
|
* 遥测快照(meas_snap_t,布局权威 = docs/任务规划/数据可信.md §4.5),
|
||||||
|
* 让 104 与 UI **共用同一份上游**——"什么时候算新值、品质怎么判"只此
|
||||||
|
* 一处决定(M5 遥测链的地基;104 侧消费见「数据可信」104 改读快照,UI 侧 ui_data_feed 属
|
||||||
|
* 「UI 扩展」波次,两者都只是本快照的读者)。
|
||||||
|
* 值口径(批 2「遥测快照发布」的 A-lite 裁决,2026-10-25):**基波有效值瞬时透传**
|
||||||
|
* (同拍 phasor 使用区口径,系数已在 标定折算折算进帧值——现单位 = ADC
|
||||||
|
* 引脚电压 V,二次值语义等 adc_cal_flags() 出 VALID,见
|
||||||
|
* docs/算法/采集与算法层设计说明.md §10 红线③);**不做** 4 点平均 /
|
||||||
|
* 谐波方和根——docs/任务规划/数据可信.md §4.5 明示"200 ms 是上送节拍
|
||||||
|
* 不是平均窗,再平均引入滞后";谐波 k-bin 轨(docs/算法/继电保护功能
|
||||||
|
* 复现规划.md 遥测谐波轨(可选))将来扩展时只许在本结构**尾部追加**或留在发布侧
|
||||||
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
|
* 内部状态,既有字段序不得变更。
|
||||||
|
* TEMP 通道(「数据可信」决策 5:不参与线性标定):yc = 最新一拍原始
|
||||||
|
* 帧值透传,qds 恒置 NT——值在场但非交流量语义,UI 按「数据可信」§4.2
|
||||||
|
* 三态表显示占位符,104 无对应点。
|
||||||
|
* 并发(docs/任务规划/数据可信.md §4.5 原文口径):沿用 phasor 的
|
||||||
|
* **双区 + 单字节活跃索引翻转**(双区指针原子交换 同型,不是新机制)——写侧填满
|
||||||
|
* 计算区后一次 store 翻转,读者先取索引再整体拷贝,全程无锁;
|
||||||
|
* seq 只增,消费者据此判"是否换帧"。
|
||||||
|
* 本层**纯算法**:不碰寄存器、不碰 RTOS、不自己取数(窗自 sample_ring、
|
||||||
|
* 频率/品质由调用方注入)→ 主机侧免桩纯逻辑单测 test/host/meas/。
|
||||||
|
* 发布纪律:唯一发布者 = adcTask(在 phasor_run()/freq_run() 之后 200 ms 分频
|
||||||
|
* 调 meas_tick())。评审判据 = 全仓 meas_tick 调用点数 == 1(「数据可信」
|
||||||
|
* §8 风险表"快照双写"行);其余任务/模块**只读**。
|
||||||
|
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
|
||||||
|
* 作者:DTU 项目组
|
||||||
|
* 创建日期:2026-10-25(道D 遥测快照发布)
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-25 DTU 项目组 创建:200 ms 遥测快照 + 双区无锁读
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef MEAS_H
|
||||||
|
#define MEAS_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "adc_input.h" /* adc_semantic_t / ADC_SEM_COUNT:yc/qds 下标 = 语义通道 */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* 上送节拍(ms):adc_task 按本宏对 1 kHz 拍做**经过时间**分频(非拍计数——
|
||||||
|
* idle/skip 拍会把拍计数口径拉得比名义值长),落点见 App/task/adc_task.c */
|
||||||
|
#define MEAS_PERIOD_MS 200u
|
||||||
|
|
||||||
|
/* 每点品质位(uint16)。**低字节与线上 104 QDS 字节同值**(OV/NT/IV,数值
|
||||||
|
* 口径同 App/protocol/iec104/iec104_asdu.h 的 IEC104_QDS_*)——但本层**不得**
|
||||||
|
* include 协议层头(分层方向:算法层在协议层之下,反向依赖禁止),故在此自
|
||||||
|
* 定义同值常量;104 侧消费(104 改读快照)直接截低字节上送,一致性由本目录单测与
|
||||||
|
* test/host/iec104/ 既有断言双向钉住。高字节为 meas 域扩展位(线上不直接
|
||||||
|
* 出现,由消费者映射): */
|
||||||
|
#define MEAS_QDS_OV (0x0001u) /* 溢出(留位,本项不产出——判据待运行参数化) */
|
||||||
|
#define MEAS_QDS_NT (0x0020u) /* 非当前值:未就绪 / 语义未处理(TEMP) */
|
||||||
|
#define MEAS_QDS_IV (0x0080u) /* 无效:该路取窗失败,无有效读数 */
|
||||||
|
#define MEAS_QDS_CLIPPED (0x0100u) /* 硬件削顶(adc_cal_flags 位图直通,
|
||||||
|
* 「数据可信」§4.2 三态表;AC 量不可信,
|
||||||
|
* 消费者应映射为品质无效,如 104 改读快照侧的 IV) */
|
||||||
|
|
||||||
|
/* 遥测快照(docs/任务规划/数据可信.md §4.5 权威布局;实测 72 B,2×72 ≈ 144 B
|
||||||
|
* 双区 = 本项 DoD 预算口径,由 test/host/meas/ 的布局钉死用例锁字段偏移——
|
||||||
|
* 改字段 = 破协议,须同步 104 改读快照 / UI) */
|
||||||
|
typedef struct {
|
||||||
|
uint32_t seq; /* 只增;消费者据此判断是否换帧 */
|
||||||
|
uint32_t tick_ms; /* 发布拍时基(osKernelGetTickCount 原值) */
|
||||||
|
uint8_t valid; /* 整帧有效:本拍 8 电气路取窗全部成功 */
|
||||||
|
uint8_t rsv[3]; /* 对齐填充,恒 0 */
|
||||||
|
float yc[ADC_SEM_COUNT]; /* 工程量(引脚电压 V 域;二次值等 VALID 标定) */
|
||||||
|
uint16_t qds[ADC_SEM_COUNT]; /* 每点品质 MEAS_QDS_*(值本身不因品质清零) */
|
||||||
|
uint8_t freq_valid; /* 频率读数新鲜度(freq_valid() 注入) */
|
||||||
|
float freq_hz; /* 频率读数(freq_last() 注入,未收敛为 0) */
|
||||||
|
} meas_snap_t;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:遥测快照层初始化(清双区 / 索引 / 序号 / 就绪标志)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:0 - 成功 int
|
||||||
|
* 限定条件:内核启动前或任务上下文皆可(纯 .bss 状态,无 RTOS 依赖);
|
||||||
|
* 本仓落点 = adc_task_init()(每开机一次,幂等可重复调)
|
||||||
|
* 函数说明:init 后 meas_get() 返 -2、meas_seq() 返 0,直到首次 meas_tick()
|
||||||
|
* 发布——消费者据 -2 走"未就绪"分支,不读半初始化数据。
|
||||||
|
*/
|
||||||
|
int meas_init(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:一拍遥测发布(200 ms 分频由调用方把关,本函数被调即发布一帧)
|
||||||
|
* 入口参数:tick_ms - 本拍时基(毫秒) uint32_t
|
||||||
|
* freq_valid_in - 频率读数新鲜度(freq_valid() 值) uint8_t
|
||||||
|
* freq_hz_in - 频率读数(freq_last() 值;从未收敛为 0.0) float
|
||||||
|
* clip_mask - 削顶通道位图(bit s = 语义通道 s;来源唯一 =
|
||||||
|
* adc_cal_flags() 的 ADC_CAL_F_CLIPPED,编排方注入,
|
||||||
|
* 本层不链 adc_cal——可用性属主在折算侧) uint32_t
|
||||||
|
* 返 回 值:无
|
||||||
|
* 限定条件:**仅 adcTask 上下文、全仓唯一调用点**(发布纪律见文件头);
|
||||||
|
* 须在 sample_ring_push_frame()/phasor_run() 之后调用,本拍窗才含最新点
|
||||||
|
* 函数说明:8 电气路逐路取"以本拍为终止的一周波"做基波 DFT(复用 phasor_calc
|
||||||
|
* 纯函数核,与 phasor 使用区同窗同核 → 同拍同值,见单测钉死),
|
||||||
|
* 任一路窗不足 → 该路 yc=0 + qds=IV|NT、整帧 valid=0(不产出半新半旧
|
||||||
|
* 混合帧;与 phasor_run 的差异:phasor 宁可不切区,meas **照常发布**
|
||||||
|
* 让品质位可见——消费者需要节拍性的品质刷新,seq 亦随之只增)。
|
||||||
|
* TEMP = 最新一点透传 + NT(见文件头)。末尾单字节翻转使用区,
|
||||||
|
* 读者无锁。
|
||||||
|
*/
|
||||||
|
void meas_tick(uint32_t tick_ms, uint8_t freq_valid_in, float freq_hz_in,
|
||||||
|
uint32_t clip_mask);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读当前使用区快照(无锁整体拷贝)
|
||||||
|
* 入口参数:out - 输出快照;失败(含 -2 未就绪)时**清零填充**,调用方可安全读
|
||||||
|
* 返 回 值:0 - 成功;-1 - 参数错;-2 - 从未发布过(out 已清零,seq=0) int
|
||||||
|
* 限定条件:任意上下文(任务级读者:iec104Task / uiTask / protectTask)
|
||||||
|
* 函数说明:先取一次使用区索引再索引拷贝——拷贝期间写者最多改"**非使用区**",
|
||||||
|
* 本帧无撕裂(双区不变量,同 phasor_get 口径;写者两帧间隔 200 ms
|
||||||
|
* 远大于 72 B 拷贝耗时,读者被压超 200 ms 的竞态窗口按 §4.5 设计接受)。
|
||||||
|
* 判"新不新"用 seq:与上次不同则换帧。
|
||||||
|
*/
|
||||||
|
int meas_get(meas_snap_t *out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读已发布帧数(只增)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:seq(0 = 从未发布;单字 32 位读,Cortex-M 原子) uint32_t
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
* 函数说明:轻量观测入口(104 周期上送可先比 seq 省 72 B 拷贝);
|
||||||
|
* 与 meas_get 之间无原子性配对要求(读者以 get 返回的 seq 为准)。
|
||||||
|
*/
|
||||||
|
uint32_t meas_seq(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* MEAS_H */
|
||||||
+18
-18
@@ -1,12 +1,12 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:相量计算层(L2 相量计算)
|
* 模块名称:相量计算层(全周傅氏相量计算)
|
||||||
* 模块功能:全周傅氏算法(单频 DFT)——系数表预乘 √2/N,fAmSQ 直接即有效值。
|
* 模块功能:全周傅氏算法(单频 DFT)——系数表预乘 √2/N,fAmSQ 直接即有效值。
|
||||||
* 覆盖 L2-1 系数表 / L2-2 实例 / L2-3 有效值 / L2-6 滑动窗 / L2-7 双区交换。
|
* 覆盖 全周傅氏系数表 / 基波相量实例 / 相量有效值输出 / 每 1 ms 滑动窗刷新 / 双区指针原子交换。
|
||||||
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
|
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
|
||||||
* 作者:王建锋
|
* 作者:王建锋
|
||||||
* 创建日期:2026-10-10
|
* 创建日期:2026-10-10
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-10-10 王建锋 创建:L2-1/2/3/6/7
|
* v1.0 2026-10-10 王建锋 创建:全周傅氏系数表 等
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "phasor.h"
|
#include "phasor.h"
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
#include "sample_ring.h"
|
#include "sample_ring.h"
|
||||||
|
|
||||||
/* ===== L2-1 全周傅氏系数表 =====
|
/* ===== 全周傅氏系数表 =====
|
||||||
* g_fFFTSin20[k] = sin(2πk/N)·(√2/N)
|
* g_fFFTSin20[k] = sin(2πk/N)·(√2/N)
|
||||||
* g_fFFTCos20[k] = cos(2πk/N)·(√2/N) N = PHASOR_N = 20
|
* g_fFFTCos20[k] = cos(2πk/N)·(√2/N) N = PHASOR_N = 20
|
||||||
* 权威口径:docs/算法/傅氏算法详解.md §2.3("把 √2 藏进系数表")。
|
* 权威口径:docs/算法/傅氏算法详解.md §2.3("把 √2 藏进系数表")。
|
||||||
@@ -41,29 +41,29 @@ static const float k_fft_cos[PHASOR_N] = {
|
|||||||
/* √2:幅值 = √2 × 有效值(与系数表的 √2 折算互逆) */
|
/* √2:幅值 = √2 × 有效值(与系数表的 √2 折算互逆) */
|
||||||
#define PHASOR_SQRT2 1.4142135623730951f
|
#define PHASOR_SQRT2 1.4142135623730951f
|
||||||
|
|
||||||
/* ===== L2-2 相量实例表 =====
|
/* ===== 基波相量实例表 =====
|
||||||
* 参考项目为 **4 个实例**(C01 基波 I/U、C02 零序基波、C03 二次谐波、C04 五次谐波,
|
* 参考项目为 **4 个实例**(C01 基波 I/U、C02 零序基波、C03 二次谐波、C04 五次谐波,
|
||||||
* `APP/CLS100_Dsp/InstCalCell.c`)。本项目按 V0.1 **有效通道**(`UA`/`UN`/`IA`,
|
* `APP/CLS100_Dsp/InstCalCell.c`)。本项目按 V0.1 **有效通道**(`UA`/`UN`/`IA`,
|
||||||
* 见规划 §1.6:`UB/UC/IB/IC/IN` 无中点偏置 → 削顶不可用)展开——C01 的"I/U"
|
* 见 docs/算法/继电保护功能复现规划.md §1.6:`UB/UC/IB/IC/IN` 无中点偏置 → 削顶不可用)展开——C01 的"I/U"
|
||||||
* 拆为两行(IA 电流 / UA 电压),并补出 C04 的五次谐波轨(L1-9)。
|
* 拆为两行(IA 电流 / UA 电压),并补出 C04 的五次谐波轨(二次谐波/五次谐波提取入口)。
|
||||||
* 新增实例只需在表尾加一行,其余代码自动适配。 */
|
* 新增实例只需在表尾加一行,其余代码自动适配。 */
|
||||||
static const phasor_inst_t k_inst[] = {
|
static const phasor_inst_t k_inst[] = {
|
||||||
{ "C01-IA-F1", ADC_SEM_IA, 1u }, /* A 相电流基波(L3-1 过流 / L3-3 过负荷) */
|
{ "C01-IA-F1", ADC_SEM_IA, 1u }, /* A 相电流基波(三段式相间过流 / 过负荷) */
|
||||||
{ "C02-UA-F1", ADC_SEM_UA, 1u }, /* A 相电压基波(L3-4 过压/欠压、L3-5 频率) */
|
{ "C02-UA-F1", ADC_SEM_UA, 1u }, /* A 相电压基波(过压 / 欠压、过频 / 低频) */
|
||||||
{ "C03-UN-F1", ADC_SEM_UN, 1u }, /* 零序电压基波(L3-6 零序过压) */
|
{ "C03-UN-F1", ADC_SEM_UN, 1u }, /* 零序电压基波(零序过压) */
|
||||||
{ "C04-IA-F2", ADC_SEM_IA, 2u }, /* 二次谐波(L3-7 涌流制动) */
|
{ "C04-IA-F2", ADC_SEM_IA, 2u }, /* 二次谐波(涌流制动) */
|
||||||
{ "C05-IA-F5", ADC_SEM_IA, 5u }, /* 五次谐波(L1-9 选线) */
|
{ "C05-IA-F5", ADC_SEM_IA, 5u }, /* 五次谐波(二次谐波/五次谐波提取入口(选线用)) */
|
||||||
};
|
};
|
||||||
|
|
||||||
#define PHASOR_INST_N (sizeof(k_inst) / sizeof(k_inst[0]))
|
#define PHASOR_INST_N (sizeof(k_inst) / sizeof(k_inst[0]))
|
||||||
|
|
||||||
/* ===== L2-7 双区(计算区 / 使用区)=====
|
/* ===== 双区指针原子交换(计算区 / 使用区)=====
|
||||||
* 本拍结果先写入"计算区",全部实例写完后**一次性**切换 s_use_zone
|
* 本拍结果先写入"计算区",全部实例写完后**一次性**切换 s_use_zone
|
||||||
* (单字节 store,Cortex-M 上原子),消费侧读"使用区" → 无锁、无撕裂。
|
* (单字节 store,Cortex-M 上原子),消费侧读"使用区" → 无锁、无撕裂。
|
||||||
* s_use_zone 声明 volatile:阻止编译器把结果写入挪到切换之后
|
* s_use_zone 声明 volatile:阻止编译器把结果写入挪到切换之后
|
||||||
* (本平台单核顺序执行,无需额外内存屏障)。
|
* (本平台单核顺序执行,无需额外内存屏障)。
|
||||||
* 今日唯一消费者是 adcTask 自身(同拍读写、实际无竞争),本机制为后续
|
* 写者 = adcTask(同拍);读者跨两个上下文:adcTask 心跳与 protectTask 判据
|
||||||
* 独立保护任务(L3)预留。 */
|
* (prot_elm_run → phasor_get)→ 无锁读正是为后者服务,勿改成加锁或去掉双区。 */
|
||||||
static phasor_t s_zone[2][PHASOR_INST_N];
|
static phasor_t s_zone[2][PHASOR_INST_N];
|
||||||
static volatile uint8_t s_use_zone;
|
static volatile uint8_t s_use_zone;
|
||||||
static uint8_t s_ready; /* 使用区是否已产出过有效结果 */
|
static uint8_t s_ready; /* 使用区是否已产出过有效结果 */
|
||||||
@@ -83,7 +83,7 @@ static void phasor_zero(phasor_t *p) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:对 n 点窗口算 harm 次相量(L2-1 系数表 + L2-3 有效值)
|
* 函数功能:对 n 点窗口算 harm 次相量(全周傅氏系数表 + 相量有效值输出)
|
||||||
* 入口参数:x - 采样窗(按时间序,旧 → 新)
|
* 入口参数:x - 采样窗(按时间序,旧 → 新)
|
||||||
* n - 窗长(必须等于 PHASOR_N)
|
* n - 窗长(必须等于 PHASOR_N)
|
||||||
* harm - 谐波次数(>=1)
|
* harm - 谐波次数(>=1)
|
||||||
@@ -134,7 +134,7 @@ int phasor_init(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:取相量实例数(L2-2)
|
* 函数功能:取相量实例数
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返 回 值:实例个数
|
* 返 回 值:实例个数
|
||||||
* 限定条件:无
|
* 限定条件:无
|
||||||
@@ -159,7 +159,7 @@ const phasor_inst_t *phasor_inst(uint8_t idx) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:重算全部实例 + 双区原子切换(L2-6 / L2-7)
|
* 函数功能:重算全部实例 + 双区原子切换(每 1 ms 滑动窗刷新 / 双区指针原子交换)
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返 回 值:无
|
* 返 回 值:无
|
||||||
* 限定条件:仅任务上下文调用;须先 sample_ring_push_frame() 压入本拍数据
|
* 限定条件:仅任务上下文调用;须先 sample_ring_push_frame() 压入本拍数据
|
||||||
|
|||||||
+11
-10
@@ -1,18 +1,19 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:相量计算层(L2 相量计算)
|
* 模块名称:相量计算层(全周傅氏相量计算)
|
||||||
* 模块功能:全周傅氏(全周波傅里叶)算法——对一周波 20 点采样做单频 DFT,
|
* 模块功能:全周傅氏(全周波傅里叶)算法——对一周波 20 点采样做单频 DFT,
|
||||||
* 产出基波/谐波相量。系数表**预乘 √2/N**(口径见
|
* 产出基波/谐波相量。系数表**预乘 √2/N**(口径见
|
||||||
* `docs/算法/傅氏算法详解.md` §2.3),故 `fAmSQ = √(fRe²+fIm²)`
|
* `docs/算法/傅氏算法详解.md` §2.3),故 `fAmSQ = √(fRe²+fIm²)`
|
||||||
* **直接就是有效值**,无需再除 √2。
|
* **直接就是有效值**,无需再除 √2。
|
||||||
* 覆盖规划 §3.2:L2-1 系数表 / L2-2 实例 / L2-3 有效值输出 /
|
* 覆盖规划 §3.2:全周傅氏系数表 / 基波相量实例 / 相量有效值输出 /
|
||||||
* L2-6 每 1 ms 滑动窗 / L2-7 双区指针原子交换。
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
|
* 每 1 ms 滑动窗刷新 / 双区指针原子交换。
|
||||||
* ⚠ 本层**纯算法**:不碰寄存器、不碰任务调度、不自己取数(输入由
|
* ⚠ 本层**纯算法**:不碰寄存器、不碰任务调度、不自己取数(输入由
|
||||||
* `sample_ring` 提供)→ 主机侧可单测(L0.5,落实登记簿 A-4)。
|
* `sample_ring` 提供)→ 主机侧可纯逻辑单测。
|
||||||
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
|
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
|
||||||
* 作者:王建锋
|
* 作者:王建锋
|
||||||
* 创建日期:2026-10-10
|
* 创建日期:2026-10-10
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-10-10 王建锋 创建:L2-1/2/3/6/7(基波 + 谐波相量)
|
* v1.0 2026-10-10 王建锋 创建:全周傅氏系数表 等(基波 + 谐波相量)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#ifndef PHASOR_H
|
#ifndef PHASOR_H
|
||||||
@@ -35,7 +36,7 @@ extern "C" {
|
|||||||
* fIm - 虚部 = b1/√2(有效值相量虚部)
|
* fIm - 虚部 = b1/√2(有效值相量虚部)
|
||||||
* fAm - 幅值 = √2·√(fRe²+fIm²)(正弦峰值 A1)
|
* fAm - 幅值 = √2·√(fRe²+fIm²)(正弦峰值 A1)
|
||||||
* fAmSQ - 有效值 = √(fRe²+fIm²)(系数已折算 √2/N,故直接即有效值)
|
* fAmSQ - 有效值 = √(fRe²+fIm²)(系数已折算 √2/N,故直接即有效值)
|
||||||
* 相位(L2-4)按需由 `atan2(fIm, fRe)` 导出,不常驻本结构。 */
|
* 相位输出按需由 `atan2(fIm, fRe)` 导出,不常驻本结构。 */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
float fRe;
|
float fRe;
|
||||||
float fIm;
|
float fIm;
|
||||||
@@ -43,7 +44,7 @@ typedef struct {
|
|||||||
float fAmSQ;
|
float fAmSQ;
|
||||||
} phasor_t;
|
} phasor_t;
|
||||||
|
|
||||||
/* 相量实例(L2-2):**数据驱动**——新增实例只需在实例表尾加一行 */
|
/* 基波相量实例:**数据驱动**——新增实例只需在实例表尾加一行 */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
const char *name; /* 实例名(日志 / UI 用) */
|
const char *name; /* 实例名(日志 / UI 用) */
|
||||||
adc_semantic_t sem; /* 语义通道 */
|
adc_semantic_t sem; /* 语义通道 */
|
||||||
@@ -60,7 +61,7 @@ typedef struct {
|
|||||||
int phasor_init(void);
|
int phasor_init(void);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:取相量实例数(L2-2)
|
* 函数功能:取相量实例数
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返 回 值:实例个数
|
* 返 回 值:实例个数
|
||||||
* 限定条件:无
|
* 限定条件:无
|
||||||
@@ -78,7 +79,7 @@ uint8_t phasor_inst_count(void);
|
|||||||
const phasor_inst_t *phasor_inst(uint8_t idx);
|
const phasor_inst_t *phasor_inst(uint8_t idx);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:重算全部实例并从计算区原子切换到使用区(L2-6 滑动窗 + L2-7 双区交换)
|
* 函数功能:重算全部实例并从计算区原子切换到使用区(每 1 ms 滑动窗刷新 + 双区指针原子交换)
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返 回 值:无
|
* 返 回 值:无
|
||||||
* 限定条件:仅任务上下文调用;须先经 `sample_ring_push_frame()` 压入本拍数据
|
* 限定条件:仅任务上下文调用;须先经 `sample_ring_push_frame()` 压入本拍数据
|
||||||
@@ -99,7 +100,7 @@ void phasor_run(void);
|
|||||||
int phasor_get(uint8_t idx, phasor_t *out);
|
int phasor_get(uint8_t idx, phasor_t *out);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:对 n 点窗口算 harm 次相量(L2-1 系数表 + L2-3 有效值)
|
* 函数功能:对 n 点窗口算 harm 次相量(全周傅氏系数表 + 相量有效值输出)
|
||||||
* 入口参数:x - 采样窗(按时间序,旧 → 新;长度须 = PHASOR_N)
|
* 入口参数:x - 采样窗(按时间序,旧 → 新;长度须 = PHASOR_N)
|
||||||
* n - 窗长(必须等于 PHASOR_N)
|
* n - 窗长(必须等于 PHASOR_N)
|
||||||
* harm - 谐波次数(>=1)
|
* harm - 谐波次数(>=1)
|
||||||
|
|||||||
+75
-15
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:保护元件框架(L3,P1 子集)——实现
|
* 模块名称:保护元件框架(保护判据,P1 子集)——实现
|
||||||
* 模块功能:见 prot_elm.h。判据核 prot_elm_eval() 为纯逻辑(prot_set RAM +
|
* 模块功能:见 prot_elm.h。判据核 prot_elm_eval() 为纯逻辑(prot_set RAM +
|
||||||
* 测量数组 → 状态机),prot_elm_run() 是 phasor→测量的薄适配层。
|
* 测量数组 → 状态机),prot_elm_run() 是 phasor→测量的薄适配层。
|
||||||
*
|
*
|
||||||
@@ -18,12 +18,20 @@ typedef struct {
|
|||||||
uint8_t over; /* 1 = 过量(越上限动作),0 = 欠量(越下限动作) */
|
uint8_t over; /* 1 = 过量(越上限动作),0 = 欠量(越下限动作) */
|
||||||
uint8_t src; /* 测量源:0 = phasor 基波有效值,1 = freq 测频 */
|
uint8_t src; /* 测量源:0 = phasor 基波有效值,1 = freq 测频 */
|
||||||
prot_set_id_t en_id; /* 投退定值 */
|
prot_set_id_t en_id; /* 投退定值 */
|
||||||
|
uint8_t trip_ctl; /* 跳闸投退定值项(PS_*_TRIP);PROT_ELM_NO_TRIP =
|
||||||
|
* 该元件没有这一项(告警类)→ 动作只出告警记录。
|
||||||
|
* 用 uint8_t 而不是 prot_set_id_t 就是因为要能表达
|
||||||
|
* "无此项"这个第 41 种取值(PS_ID_COUNT 是数组长度,
|
||||||
|
* 拿去喂 prot_set_get_ctl() 会越界读) */
|
||||||
prot_set_id_t set_id; /* 动作定值 */
|
prot_set_id_t set_id; /* 动作定值 */
|
||||||
prot_set_id_t dly_id; /* 延时定值(ms) */
|
prot_set_id_t dly_id; /* 延时定值(ms) */
|
||||||
prot_set_id_t ret_id; /* 返回系数定值(RET_OVER / RET_UNDER) */
|
prot_set_id_t ret_id; /* 返回系数定值(RET_OVER / RET_UNDER) */
|
||||||
const char *pprefix; /* 相量实例名前缀("C01"…);src=1 时为 0(不用) */
|
const char *pprefix; /* 相量实例名前缀("C01"…);src=1 时为 0(不用) */
|
||||||
} pe_cfg_t;
|
} pe_cfg_t;
|
||||||
|
|
||||||
|
/* k_pe[].trip_ctl 的"无此定值项"取值(见上方字段注释) */
|
||||||
|
#define PROT_ELM_NO_TRIP 0xFFu
|
||||||
|
|
||||||
/* 测量源枚举(pe_cfg_t.src) */
|
/* 测量源枚举(pe_cfg_t.src) */
|
||||||
#define PE_SRC_PHASOR 0u
|
#define PE_SRC_PHASOR 0u
|
||||||
#define PE_SRC_FREQ 1u
|
#define PE_SRC_FREQ 1u
|
||||||
@@ -31,18 +39,24 @@ typedef struct {
|
|||||||
/* freq 无有效读数时给欠量元件的"必不启动"填充值(远大于任何欠量定值上限) */
|
/* freq 无有效读数时给欠量元件的"必不启动"填充值(远大于任何欠量定值上限) */
|
||||||
#define PE_MEAS_BIG 1e30f
|
#define PE_MEAS_BIG 1e30f
|
||||||
|
|
||||||
static const pe_cfg_t k_pe[PE_COUNT] = {
|
/* 列序 = pe_cfg_t 字段序(name, over, src, en_id, trip_ctl, set_id, dly_id, ret_id,
|
||||||
{ "OC1-IA", 1u, PE_SRC_PHASOR, PS_OC1_EN, PS_OC1_CUR, PS_OC1_DLY, PS_RET_OVER, "C01" },
|
* pprefix)。⚠ trip_ctl 紧跟 en_id 插入,新增元件必须照这一列的口径填:有跳闸投退
|
||||||
{ "OC2-IA", 1u, PE_SRC_PHASOR, PS_OC2_EN, PS_OC2_CUR, PS_OC2_DLY, PS_RET_OVER, "C01" },
|
* 定值项的填 PS_*_TRIP,没有的填 PROT_ELM_NO_TRIP(= 该元件动作只出告警记录)。*/
|
||||||
{ "OC3-IA", 1u, PE_SRC_PHASOR, PS_OC3_EN, PS_OC3_CUR, PS_OC3_DLY, PS_RET_OVER, "C01" },
|
static const pe_cfg_t k_pe[PE_COUNT] = { /* ← 表头注释见上,行内列序与之一致 */
|
||||||
{ "OL-IA", 1u, PE_SRC_PHASOR, PS_OL_EN, PS_OL_CUR, PS_OL_DLY, PS_RET_OVER, "C01" },
|
{ "OC1-IA", 1u, PE_SRC_PHASOR, PS_OC1_EN, PS_OC1_TRIP, PS_OC1_CUR, PS_OC1_DLY, PS_RET_OVER, "C01" },
|
||||||
{ "OV-UA", 1u, PE_SRC_PHASOR, PS_OV_EN, PS_OV_VOL, PS_OV_DLY, PS_RET_OVER, "C02" },
|
{ "OC2-IA", 1u, PE_SRC_PHASOR, PS_OC2_EN, PS_OC2_TRIP, PS_OC2_CUR, PS_OC2_DLY, PS_RET_OVER, "C01" },
|
||||||
{ "UV-UA", 0u, PE_SRC_PHASOR, PS_UV_EN, PS_UV_VOL, PS_UV_DLY, PS_RET_UNDER, "C02" },
|
{ "OC3-IA", 1u, PE_SRC_PHASOR, PS_OC3_EN, PS_OC3_TRIP, PS_OC3_CUR, PS_OC3_DLY, PS_RET_OVER, "C01" },
|
||||||
{ "3U0-UN", 1u, PE_SRC_PHASOR, PS_3U0_EN, PS_3U0_VOL, PS_3U0_DLY, PS_RET_OVER, "C03" },
|
{ "OL-IA", 1u, PE_SRC_PHASOR, PS_OL_EN, PS_OL_TRIP, PS_OL_CUR, PS_OL_DLY, PS_RET_OVER, "C01" },
|
||||||
/* 过频/低频(L3-5,B8):测量量 = freq 层测得频率(Hz),返回系数独立
|
/* ↓ 三项 + 下面两项频率元件:参考系统的告警段(0x8027~0x802A)本就没有「跳闸
|
||||||
|
投退」这一档,g_prot_set_meta 也无对应项 → PROT_ELM_NO_TRIP,动作一律归告警。
|
||||||
|
这不是兜底猜测,是照实反映("哪些元件算跳闸"的真源始终是这张表 + 定值表)。*/
|
||||||
|
{ "OV-UA", 1u, PE_SRC_PHASOR, PS_OV_EN, PROT_ELM_NO_TRIP, PS_OV_VOL, PS_OV_DLY, PS_RET_OVER, "C02" },
|
||||||
|
{ "UV-UA", 0u, PE_SRC_PHASOR, PS_UV_EN, PROT_ELM_NO_TRIP, PS_UV_VOL, PS_UV_DLY, PS_RET_UNDER, "C02" },
|
||||||
|
{ "3U0-UN", 1u, PE_SRC_PHASOR, PS_3U0_EN, PROT_ELM_NO_TRIP, PS_3U0_VOL, PS_3U0_DLY, PS_RET_OVER, "C03" },
|
||||||
|
/* 过频/低频(过频 / 低频,B8):测量量 = freq 层测得频率(Hz),返回系数独立
|
||||||
* (RET_OF/RET_UF——频率滞回带与电气量不同量级,勿共用 0.95/1.05) */
|
* (RET_OF/RET_UF——频率滞回带与电气量不同量级,勿共用 0.95/1.05) */
|
||||||
{ "OF-F", 1u, PE_SRC_FREQ, PS_OF_EN, PS_OF_HZ, PS_OF_DLY, PS_RET_OF, 0 },
|
{ "OF-F", 1u, PE_SRC_FREQ, PS_OF_EN, PROT_ELM_NO_TRIP, PS_OF_HZ, PS_OF_DLY, PS_RET_OF, 0 },
|
||||||
{ "UF-F", 0u, PE_SRC_FREQ, PS_UF_EN, PS_UF_HZ, PS_UF_DLY, PS_RET_UF, 0 },
|
{ "UF-F", 0u, PE_SRC_FREQ, PS_UF_EN, PROT_ELM_NO_TRIP, PS_UF_HZ, PS_UF_DLY, PS_RET_UF, 0 },
|
||||||
};
|
};
|
||||||
|
|
||||||
/* 运行时状态(picked/asserted 的对外快照 + 延时起点 + 相量实例索引缓存) */
|
/* 运行时状态(picked/asserted 的对外快照 + 延时起点 + 相量实例索引缓存) */
|
||||||
@@ -122,7 +136,8 @@ void prot_elm_request_reset(void) {
|
|||||||
* 入口参数:now_ms - 单调时基(tick ms,差值法回绕安全)
|
* 入口参数:now_ms - 单调时基(tick ms,差值法回绕安全)
|
||||||
* meas - PE_COUNT 个测量值(工程量 A/V);NULL 视为全 0
|
* meas - PE_COUNT 个测量值(工程量 A/V);NULL 视为全 0
|
||||||
* 返 回 值:无
|
* 返 回 值:无
|
||||||
* 函数说明:投退=0 或定值闭锁(L5-1)→ 状态整体复位、不判据;
|
* 函数说明:投退=0 或定值闭锁(定值校验和)→ 状态整体复位、不判据;
|
||||||
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
* 滞回 = 已启动用返回门槛(定值×系数)、未启动用动作门槛;
|
* 滞回 = 已启动用返回门槛(定值×系数)、未启动用动作门槛;
|
||||||
* 延时 dly=0 → 启动同拍即动作;返回即时(P4 接出口时再补返回延时)。
|
* 延时 dly=0 → 启动同拍即动作;返回即时(P4 接出口时再补返回延时)。
|
||||||
*/
|
*/
|
||||||
@@ -189,7 +204,8 @@ void prot_elm_eval(uint32_t now_ms, const float meas[PE_COUNT]) {
|
|||||||
* 函数功能:适配层——按实例表 src 组装测量数组后调判据核
|
* 函数功能:适配层——按实例表 src 组装测量数组后调判据核
|
||||||
* 入口参数:now_ms - 单调时基(tick ms)
|
* 入口参数:now_ms - 单调时基(tick ms)
|
||||||
* 返 回 值:无
|
* 返 回 值:无
|
||||||
* 限定条件:仅保护消费者任务上下文;工程量↔码值换算 = 1(L1-5 标定未实现)
|
* 限定条件:仅保护消费者任务上下文;测量已是 标定折算工程量(未标定域由
|
||||||
|
* protectTask 未标定即闭锁挡在门外,本函数不感知标定态)
|
||||||
* 函数说明:src=PHASOR 行任一相量未就绪(未满一周波)→ **整拍跳过**,
|
* 函数说明:src=PHASOR 行任一相量未就绪(未满一周波)→ **整拍跳过**,
|
||||||
* 不引入陈旧/半新测量(B5 口径)。src=FREQ 行:freq 有有效读数
|
* 不引入陈旧/半新测量(B5 口径)。src=FREQ 行:freq 有有效读数
|
||||||
* (started 且幅值门 OK)→ 填测得频率;否则按方向填"必不启动"值
|
* (started 且幅值门 OK)→ 填测得频率;否则按方向填"必不启动"值
|
||||||
@@ -244,7 +260,7 @@ void prot_elm_state(prot_elm_id_t id, prot_elm_state_t *out) {
|
|||||||
/*
|
/*
|
||||||
* 函数功能:聚合动作位图
|
* 函数功能:聚合动作位图
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返 回 值:bit i = 元件 i asserted(供 L4 出口 / 心跳日志)
|
* 返 回 值:bit i = 元件 i asserted(供 出口与记录 / 心跳日志)
|
||||||
*/
|
*/
|
||||||
uint32_t prot_elm_active_mask(void) {
|
uint32_t prot_elm_active_mask(void) {
|
||||||
uint32_t mask = 0u;
|
uint32_t mask = 0u;
|
||||||
@@ -258,6 +274,50 @@ uint32_t prot_elm_active_mask(void) {
|
|||||||
return mask;
|
return mask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:聚合启动位图
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:bit i = 元件 i picked(越限电平,早于/等于 asserted)
|
||||||
|
* 限定条件:任意上下文(只读快照,无锁;与 prot_elm_active_mask 同口径)
|
||||||
|
* 函数说明:调用方是 protectTask 的「保护启动」记录边沿检测(task-10)。本函数
|
||||||
|
* **只聚合电平**,不造边沿——边沿属观测者状态(s_prev_pick 在那一侧),
|
||||||
|
* 判据核里再存一份"上一拍"会让 eval 不再幂等。
|
||||||
|
*/
|
||||||
|
uint32_t prot_elm_pick_mask(void) {
|
||||||
|
uint32_t mask = 0u;
|
||||||
|
uint32_t i;
|
||||||
|
|
||||||
|
for (i = 0u; i < (uint32_t)PE_COUNT; i++) {
|
||||||
|
if (s_rt[i].picked != 0u) {
|
||||||
|
mask |= (1uL << i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:判定该元件动作上升沿应归哪一类记录(见 prot_elm.h 同号声明)
|
||||||
|
* 入口参数:id - 元件编号 prot_elm_id_t
|
||||||
|
* 返 回 值:PROT_REC_TRIP / PROT_REC_ALARM / PROT_REC_NONE(id 越界) prot_rec_kind_t
|
||||||
|
* 限定条件:任意上下文;只读,不改任何状态
|
||||||
|
* 函数说明:判据唯一来源 = k_pe[id].trip_ctl 指向的那枚 PS_*_TRIP 投退定值。
|
||||||
|
* trip_ctl == PROT_ELM_NO_TRIP 时**先返回告警**,绝不把 0xFF 喂给
|
||||||
|
* prot_set_get_ctl()(那是数组越界读,会读到定值区外的 RAM——
|
||||||
|
* 与"未标定拿 ADC 引脚电压当二次值"同类的静默错数据形状)。
|
||||||
|
*/
|
||||||
|
prot_rec_kind_t prot_elm_record_kind(prot_elm_id_t id) {
|
||||||
|
uint8_t trip_ctl;
|
||||||
|
|
||||||
|
if (id >= PE_COUNT) {
|
||||||
|
return PROT_REC_NONE;
|
||||||
|
}
|
||||||
|
trip_ctl = k_pe[(uint32_t)id].trip_ctl;
|
||||||
|
if (trip_ctl == (uint8_t)PROT_ELM_NO_TRIP) {
|
||||||
|
return PROT_REC_ALARM;
|
||||||
|
}
|
||||||
|
return (prot_set_get_ctl((prot_set_id_t)trip_ctl) != 0u) ? PROT_REC_TRIP : PROT_REC_ALARM;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:取实例名(日志用)
|
* 函数功能:取实例名(日志用)
|
||||||
* 入口参数:id - 元件编号
|
* 入口参数:id - 元件编号
|
||||||
|
|||||||
+54
-13
@@ -1,15 +1,16 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:保护元件框架(L3,P1 子集)
|
* 模块名称:保护元件框架(保护判据,P1 子集)
|
||||||
* 模块功能:通用"越限 + 返回系数滞回 + 定时限"元件核 + 实例表(数据驱动,
|
* 模块功能:通用"越限 + 返回系数滞回 + 定时限"元件核 + 实例表(数据驱动,
|
||||||
* 同 phasor 实例表模式)。判据为**纯逻辑**(吃测量值 + 定值),
|
* 同 phasor 实例表模式)。判据为**纯逻辑**(吃测量值 + 定值),
|
||||||
* 主机侧可离线单测;出口/SOE(L4)与任务编排为后续。
|
* 主机侧可离线单测;出口/SOE(出口与记录)与任务编排为后续。
|
||||||
*
|
*
|
||||||
* V0.1 有效实例(N-18:仅 TPAFE 三路):
|
* V0.1 有效实例(双转换器无共同采样触发 → 仅 TPAFE 三路):
|
||||||
* OC1/OC2/OC3 三段式相间过流(A 相 IA,L3-1)
|
* OC1/OC2/OC3 三段式相间过流(A 相 IA)
|
||||||
* OL 过负荷(A 相 IA,L3-3)
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
* OV/UV 过压/欠压(UA,L3-4)
|
* OL 过负荷(A 相 IA)
|
||||||
* 3U0 零序过压(UN 外部零序电压通道,L3-6)
|
* OV/UV 过压/欠压(UA,过压 / 欠压)
|
||||||
* OF/UF 过频/低频(测量量 = freq 层测得频率,L3-5,B8 落地)
|
* 3U0 零序过压(UN 外部零序电压通道)
|
||||||
|
* OF/UF 过频/低频(测量量 = freq 层测得频率,过频 / 低频,B8 落地)
|
||||||
*
|
*
|
||||||
* 测量数组口径:相量类实例 = 基波有效值(A/V);OF/UF = 测得频率(Hz)。
|
* 测量数组口径:相量类实例 = 基波有效值(A/V);OF/UF = 测得频率(Hz)。
|
||||||
* run() 按实例表 src 字段取数(0=phasor rms,1=freq)。freq 无有效读数
|
* run() 按实例表 src 字段取数(0=phasor rms,1=freq)。freq 无有效读数
|
||||||
@@ -22,7 +23,10 @@
|
|||||||
* - 返回**即时**(参考 GRly_TimeRelay 带 10 ms 返回延时——V0.1 无出口硬件,
|
* - 返回**即时**(参考 GRly_TimeRelay 带 10 ms 返回延时——V0.1 无出口硬件,
|
||||||
* 抖动只影响日志,P4 接出口时再补返回延时);
|
* 抖动只影响日志,P4 接出口时再补返回延时);
|
||||||
* - 复压/方向/无压无流闭锁(dwSetFyCtrl 类)随 P2/P3 元件扩展;
|
* - 复压/方向/无压无流闭锁(dwSetFyCtrl 类)随 P2/P3 元件扩展;
|
||||||
* - 工程量↔码值换算系数暂为 1(L1-5 标定未实现,同 phasor 层口径)。
|
* - 测量输入 = adc_task 经 adc_cal_apply() 折算后的工程量(标定折算,标定折算已落地):
|
||||||
|
* 未标定(VALID=0)时单位只是 ADC 引脚电压——该域下判据必然错配,由
|
||||||
|
* protectTask 的「数据可信」未标定即闭锁停走整条判据链,元件核本身不掺和
|
||||||
|
* 标定语义(保持纯逻辑);标定后单位 = 二次值,定值口径才名副其实。
|
||||||
*
|
*
|
||||||
* 线程约定:init 内核启动前或保护消费者任务上下文;eval/run 仅保护消费者任务
|
* 线程约定:init 内核启动前或保护消费者任务上下文;eval/run 仅保护消费者任务
|
||||||
* 上下文(B5 起 = protectTask);request_reset 任意上下文(跨上下文复归请求,
|
* 上下文(B5 起 = protectTask);request_reset 任意上下文(跨上下文复归请求,
|
||||||
@@ -45,8 +49,8 @@ typedef enum {
|
|||||||
PE_OV, /* 过压(UA) */
|
PE_OV, /* 过压(UA) */
|
||||||
PE_UV, /* 欠压(UA,欠量方向) */
|
PE_UV, /* 欠压(UA,欠量方向) */
|
||||||
PE_3U0, /* 零序过压(UN) */
|
PE_3U0, /* 零序过压(UN) */
|
||||||
PE_OF, /* 过频(测量量 = freq 层测得频率,B8,L3-5) */
|
PE_OF, /* 过频(测量量 = freq 层测得频率,B8,过频 / 低频) */
|
||||||
PE_UF, /* 低频(测量量 = freq 层测得频率,欠量方向,B8,L3-5) */
|
PE_UF, /* 低频(测量量 = freq 层测得频率,欠量方向,B8,过频 / 低频) */
|
||||||
PE_COUNT
|
PE_COUNT
|
||||||
} prot_elm_id_t;
|
} prot_elm_id_t;
|
||||||
|
|
||||||
@@ -78,7 +82,7 @@ void prot_elm_request_reset(void);
|
|||||||
* meas - PE_COUNT 个测量值(工程量:A / V),NULL 视为全 0
|
* meas - PE_COUNT 个测量值(工程量:A / V),NULL 视为全 0
|
||||||
* 返 回 值:无
|
* 返 回 值:无
|
||||||
* 限定条件:仅保护消费者任务上下文;调用方保证定值已装载
|
* 限定条件:仅保护消费者任务上下文;调用方保证定值已装载
|
||||||
* 函数说明:越限方向由实例表决定;投退=0 或定值闭锁(L5-1)→ 状态复位、
|
* 函数说明:越限方向由实例表决定;投退=0 或定值闭锁(定值校验和)→ 状态复位、
|
||||||
* 不判据。延时判定用 (uint32_t)(int32_t)(now - t0) >= dly,回绕安全。
|
* 不判据。延时判定用 (uint32_t)(int32_t)(now - t0) >= dly,回绕安全。
|
||||||
*/
|
*/
|
||||||
void prot_elm_eval(uint32_t now_ms, const float meas[PE_COUNT]);
|
void prot_elm_eval(uint32_t now_ms, const float meas[PE_COUNT]);
|
||||||
@@ -94,9 +98,46 @@ void prot_elm_run(uint32_t now_ms);
|
|||||||
/* 状态快照拷贝(NULL 安全;越界全零) */
|
/* 状态快照拷贝(NULL 安全;越界全零) */
|
||||||
void prot_elm_state(prot_elm_id_t id, prot_elm_state_t *out);
|
void prot_elm_state(prot_elm_id_t id, prot_elm_state_t *out);
|
||||||
|
|
||||||
/* 动作位图(bit i = 元件 i asserted),供 L4 出口 / 心跳日志 */
|
/* 动作位图(bit i = 元件 i asserted),供 出口与记录 / 心跳日志 */
|
||||||
uint32_t prot_elm_active_mask(void);
|
uint32_t prot_elm_active_mask(void);
|
||||||
|
|
||||||
|
/* 启动位图(bit i = 元件 i picked),供「保护启动」记录边沿(task-10,2026-10-26):
|
||||||
|
* picked 是越限滞回后的电平,早于(或同拍于)asserted——只看 active_mask 会让
|
||||||
|
* "启动了但没到点"这一整段在记录里彻底消失,而它正是"保护启动"页要看的东西。 */
|
||||||
|
uint32_t prot_elm_pick_mask(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 元件动作边沿的记录归属(task-10「五类记录」)。
|
||||||
|
* ⚠ 本枚举刻意**不**用 syslog_id_t:那会把 App/log 的头拽进 algo 层,而
|
||||||
|
* App/algo/prot_elm.c 同时被 test/host/prot_dim 编进测试单元(那个运行器的
|
||||||
|
* include 路径里没有 App/log,且不在本道写域)→ 一改就在别人的单测上爆错。
|
||||||
|
* "元件 → 哪一类"是判据域事实(属主在本文件),"哪一类 → 哪个日志文件"是
|
||||||
|
* 记录域映射(属主在 App/task/protect_task.c 的一行三元),两边各自单源。
|
||||||
|
*/
|
||||||
|
typedef enum {
|
||||||
|
PROT_REC_TRIP = 0u, /* 跳闸型动作 → 事故记录 */
|
||||||
|
PROT_REC_ALARM, /* 告警型动作 → 保护告警记录 */
|
||||||
|
PROT_REC_NONE /* id 越界 → 调用方一条都不许落 */
|
||||||
|
} prot_rec_kind_t;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:判定"该元件若发生动作上升沿,应归哪一类记录"
|
||||||
|
* 入口参数:id - 元件编号 prot_elm_id_t
|
||||||
|
* 返回值:PROT_REC_TRIP / PROT_REC_ALARM / PROT_REC_NONE(越界) prot_rec_kind_t
|
||||||
|
* 限定条件:任意上下文(只读 k_pe[] + prot_set RAM,不落任何状态)
|
||||||
|
* 函数说明:判据**唯一来源** = 该元件的跳闸投退定值项 g_prot_set_meta 里的
|
||||||
|
* PS_*_TRIP(其枚举注释即"0 告警 / 1 跳闸")。元件→投退项的对应关系
|
||||||
|
* 放在实例表 k_pe[] 的 trip_ctl 列(与 en_id/set_id/dly_id 同一张表,
|
||||||
|
* 新增元件只加一行);**本函数里没有第二份"哪些元件算跳闸"的名单**,
|
||||||
|
* 所以它不会与 g_prot_set_meta / k_pe[] / 整定视图门禁互相打架。
|
||||||
|
* 过压/欠压/零序过压/过频/低频五项本就没有 TRIP 投退项(告警类)→
|
||||||
|
* trip_ctl = PROT_ELM_NO_TRIP → 恒 PROT_REC_ALARM,这是照实反映,
|
||||||
|
* 不是兜底猜测。
|
||||||
|
* 定值巡检闭锁(prot_set_locked)不影响本判定:闭锁态下元件会被复位、
|
||||||
|
* 根本走不到动作上升沿,调用方无需在此重复判闭锁。
|
||||||
|
*/
|
||||||
|
prot_rec_kind_t prot_elm_record_kind(prot_elm_id_t id);
|
||||||
|
|
||||||
/* 实例名(ROM 常量,日志用) */
|
/* 实例名(ROM 常量,日志用) */
|
||||||
const char *prot_elm_name(prot_elm_id_t id);
|
const char *prot_elm_name(prot_elm_id_t id);
|
||||||
|
|
||||||
|
|||||||
+13
-4
@@ -1,10 +1,19 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:保护定值模型(P1,L5-1 载体)——实现
|
* 模块名称:保护定值模型(P1,定值校验和 载体)——实现
|
||||||
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
* 模块功能:见 prot_set.h。纯逻辑,零平台依赖(仅 stdint.h + 自身头)。
|
* 模块功能:见 prot_set.h。纯逻辑,零平台依赖(仅 stdint.h + 自身头)。
|
||||||
*
|
*
|
||||||
* ⚠ 默认值/范围为拟定值(参考 EEPROM 出厂默认表不在本仓库,规划 §10.3 待补);
|
* ⚠ 默认值/范围为拟定值(参考 EEPROM 出厂默认表不在本仓库,规划 §10.3 待补);
|
||||||
* 通讯地址逐项对照参考 Comm/Pub/Const_CommSetPara.c g_tCommSetParaTab。
|
* 通讯地址逐项对照参考 Comm/Pub/Const_CommSetPara.c g_tCommSetParaTab。
|
||||||
*
|
*
|
||||||
|
* 量纲口径(「数据可信」下游常数按工程量重定一项复核,2026-10-25):A/V 项 = **二次值**工程量,
|
||||||
|
* 标称基准 100 V / 1 A(UA/UB/UC 相电压、3U0、OV/UV 同域——OV 120 = 1.2×Un、
|
||||||
|
* UV 20 = 0.2×Un、3U0 15 = 0.15×Un;OC1/2/3 = 10/5/2 ×Ir、OL = 1.0×Ir,
|
||||||
|
* 三段递升与过负荷低于速断均自洽)。ADC 帧仅在 adc_cal VALID 置位后才处
|
||||||
|
* 于此域(未标定 = 引脚电压域,由未标定即闭锁停走判据,见 protect_task.c);
|
||||||
|
* min/max 范围覆盖 0~4×标称,无需随这一项改动数值——换算依据与三处下游
|
||||||
|
* 常数同源,见 docs/任务规划/数据可信.md §2.3。
|
||||||
|
*
|
||||||
* 创建日期:2026-10-11
|
* 创建日期:2026-10-11
|
||||||
*/
|
*/
|
||||||
#include "prot_set.h"
|
#include "prot_set.h"
|
||||||
@@ -47,7 +56,7 @@ const prot_set_meta_t g_prot_set_meta[PS_ID_COUNT] = {
|
|||||||
{ 0x8825, 0u, PROT_SET_Q10(100.0), PROT_SET_Q10(1.0), PROT_SET_Q10(10000.0) },
|
{ 0x8825, 0u, PROT_SET_Q10(100.0), PROT_SET_Q10(1.0), PROT_SET_Q10(10000.0) },
|
||||||
{ 0x8826, 0u, PROT_SET_Q10(5.0), PROT_SET_Q10(1.0), PROT_SET_Q10(10.0) },
|
{ 0x8826, 0u, PROT_SET_Q10(5.0), PROT_SET_Q10(1.0), PROT_SET_Q10(10.0) },
|
||||||
{ 0x0000, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* 全局软压板投退(YK12,B7) */
|
{ 0x0000, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* 全局软压板投退(YK12,B7) */
|
||||||
/* --- 过频 / 低频(L3-5,B8):地址全 0 = 本地项(参考逐项地址不在
|
/* --- 过频 / 低频(B8):地址全 0 = 本地项(参考逐项地址不在
|
||||||
* 本仓库,规划 §5.1 仅示"8820~8882 含低压/低频"类目);
|
* 本仓库,规划 §5.1 仅示"8820~8882 含低压/低频"类目);
|
||||||
* 默认值/范围**拟定**(DTU 馈线典型量级),待对照参考出厂表修订 --- */
|
* 默认值/范围**拟定**(DTU 馈线典型量级),待对照参考出厂表修订 --- */
|
||||||
{ 0x0000, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* OF 投退(本地) */
|
{ 0x0000, 1u, PROT_SET_Q10(1), 0, PROT_SET_Q10(1) }, /* OF 投退(本地) */
|
||||||
@@ -67,7 +76,7 @@ static volatile uint8_t s_act; /* 活跃区下标(0/1);交换 =
|
|||||||
static uint32_t s_sum; /* 封存的正码 */
|
static uint32_t s_sum; /* 封存的正码 */
|
||||||
static uint32_t s_sumn; /* 封存的反码 */
|
static uint32_t s_sumn; /* 封存的反码 */
|
||||||
static uint64_t s_bad_mask; /* load_raw/parse 越界位图(B8:40 项 → 64 位) */
|
static uint64_t s_bad_mask; /* load_raw/parse 越界位图(B8:40 项 → 64 位) */
|
||||||
static uint8_t s_locked; /* 定值非法 → 闭锁(L5-1) */
|
static uint8_t s_locked; /* 定值非法 → 闭锁(定值校验和) */
|
||||||
static uint8_t s_dirty; /* 未落盘定值改动(B7 持久化脏标志) */
|
static uint8_t s_dirty; /* 未落盘定值改动(B7 持久化脏标志) */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -285,7 +294,7 @@ void prot_set_seal(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:定值自检(L5-1)——重算 sum32 并与封存值比对
|
* 函数功能:定值自检(定值校验和)——重算 sum32 并与封存值比对
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返 回 值:0 通过 / -1 失配(已置闭锁)
|
* 返 回 值:0 通过 / -1 失配(已置闭锁)
|
||||||
* 函数说明:失配 → s_locked 置位并保持,直到下一次 seal;
|
* 函数说明:失配 → s_locked 置位并保持,直到下一次 seal;
|
||||||
|
|||||||
+15
-11
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:保护定值模型(P1,L5-1 载体)
|
* 模块名称:保护定值模型(P1,定值校验和 载体)
|
||||||
* 模块功能:保护定值的 RAM 模型——编号 / 通讯地址 / 范围 / 默认值 / 校验和自检,
|
* 模块功能:保护定值的 RAM 模型——编号 / 通讯地址 / 范围 / 默认值 / 校验和自检,
|
||||||
* 以及 B7 定值持久化的**纯逻辑层**(blob 序列化/解析 + 原子装载)。
|
* 以及 B7 定值持久化的**纯逻辑层**(blob 序列化/解析 + 原子装载)。
|
||||||
* 文件读写胶水在 App/cfg/prot_set_store.c(MCU 端)。
|
* 文件读写胶水在 App/cfg/prot_set_store.c(MCU 端)。
|
||||||
@@ -8,7 +8,7 @@
|
|||||||
* - 定点存储:Q10(×1024),与参考系统定点系数 2^10 一致(§5.3);
|
* - 定点存储:Q10(×1024),与参考系统定点系数 2^10 一致(§5.3);
|
||||||
* - 通讯地址:与参考 g_tCommSetParaTab(Comm/Pub/Const_CommSetPara.c)逐项对齐,
|
* - 通讯地址:与参考 g_tCommSetParaTab(Comm/Pub/Const_CommSetPara.c)逐项对齐,
|
||||||
* 基址 0x8820(=34848);告警类沿用运行参数段 0x8027/0x8029;
|
* 基址 0x8820(=34848);告警类沿用运行参数段 0x8027/0x8029;
|
||||||
* - 校验和(L5-1):sum32 正码 + 反码成对封存(参考 tagPRS 的
|
* - 校验和(定值校验和):sum32 正码 + 反码成对封存(参考 tagPRS 的
|
||||||
* dwTagSum/dwTagSumN 模式),selfcheck 失配 → 置闭锁;
|
* dwTagSum/dwTagSumN 模式),selfcheck 失配 → 置闭锁;
|
||||||
* - ⚠ 默认值/范围为**拟定值**:参考 EEPROM 出厂默认表未在本仓库,
|
* - ⚠ 默认值/范围为**拟定值**:参考 EEPROM 出厂默认表未在本仓库,
|
||||||
* 待对照后逐项修订(规划 §10.3 待补项);范围先按 DTU 馈线典型量级。
|
* 待对照后逐项修订(规划 §10.3 待补项);范围先按 DTU 馈线典型量级。
|
||||||
@@ -26,13 +26,17 @@
|
|||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
/* Q10 定点:工程量 × 1024(与参考 2^10 口径一致) */
|
/* Q10 定点:工程量 × 1024(与参考 2^10 口径一致)。
|
||||||
#define PROT_SET_Q10(v) ((int32_t)(((double)(v)) * 1024.0))
|
* 必须写成单精度:×/÷1024 只是 2^10 指数平移,float 与 double 的结果逐位相同
|
||||||
#define PROT_SET_FROM_Q10(v) ((float)((double)(v) / 1024.0))
|
* (宏里 42 个字面量 + int32 全域往返均已穷举核对),而写成 double 会把
|
||||||
|
* MicroLIB 的软浮点拉进镜像——F407 只有单精度 FPU,本工程的保护判据每
|
||||||
|
* 20 ms 节拍每元件要读三次定值(见 prot_elm_run),软件双精度纯属白烧。 */
|
||||||
|
#define PROT_SET_Q10(v) ((int32_t)((float)(v) * 1024.0f))
|
||||||
|
#define PROT_SET_FROM_Q10(v) ((float)(v) / 1024.0f)
|
||||||
|
|
||||||
/* 定值项编号(枚举序 = RAM 数组序 = 校验和覆盖序,只增不删不改序) */
|
/* 定值项编号(枚举序 = RAM 数组序 = 校验和覆盖序,只增不删不改序) */
|
||||||
typedef enum {
|
typedef enum {
|
||||||
/* --- 三段式相间过流(A 相,L3-1):投退/跳闸投退/电流/延时 --- */
|
/* --- 三段式相间过流(A 相):投退/跳闸投退/电流/延时 --- */
|
||||||
PS_OC1_EN = 0, /* 0x8856 Ⅰ段投退 */
|
PS_OC1_EN = 0, /* 0x8856 Ⅰ段投退 */
|
||||||
PS_OC1_TRIP, /* 0x8857 Ⅰ段跳闸投退(0 告警 / 1 跳闸) */
|
PS_OC1_TRIP, /* 0x8857 Ⅰ段跳闸投退(0 告警 / 1 跳闸) */
|
||||||
PS_OC1_CUR, /* 0x8858 Ⅰ段电流定值(A,二次值) */
|
PS_OC1_CUR, /* 0x8858 Ⅰ段电流定值(A,二次值) */
|
||||||
@@ -45,19 +49,19 @@ typedef enum {
|
|||||||
PS_OC3_TRIP, /* 0x885F */
|
PS_OC3_TRIP, /* 0x885F */
|
||||||
PS_OC3_CUR, /* 0x8860 */
|
PS_OC3_CUR, /* 0x8860 */
|
||||||
PS_OC3_DLY, /* 0x8861 */
|
PS_OC3_DLY, /* 0x8861 */
|
||||||
/* --- 过负荷(A 相,L3-3) --- */
|
/* --- 过负荷(A 相) --- */
|
||||||
PS_OL_EN, /* 0x8871 */
|
PS_OL_EN, /* 0x8871 */
|
||||||
PS_OL_TRIP, /* 0x8872 */
|
PS_OL_TRIP, /* 0x8872 */
|
||||||
PS_OL_CUR, /* 0x8873 */
|
PS_OL_CUR, /* 0x8873 */
|
||||||
PS_OL_DLY, /* 0x8874 */
|
PS_OL_DLY, /* 0x8874 */
|
||||||
/* --- 过压 / 欠压(UA,L3-4;沿用参考告警段 0x8029/0x8027) --- */
|
/* --- 过压 / 欠压(UA;沿用参考告警段 0x8029/0x8027) --- */
|
||||||
PS_OV_EN, /* 本地投退(参考告警类无独立投退,V0.1 补) */
|
PS_OV_EN, /* 本地投退(参考告警类无独立投退,V0.1 补) */
|
||||||
PS_OV_VOL, /* 0x8029 过电压门限(V,二次值) */
|
PS_OV_VOL, /* 0x8029 过电压门限(V,二次值) */
|
||||||
PS_OV_DLY, /* 0x802A 过电压延时(ms) */
|
PS_OV_DLY, /* 0x802A 过电压延时(ms) */
|
||||||
PS_UV_EN, /* 本地投退 */
|
PS_UV_EN, /* 本地投退 */
|
||||||
PS_UV_VOL, /* 0x8027 低电压门限(V,二次值) */
|
PS_UV_VOL, /* 0x8027 低电压门限(V,二次值) */
|
||||||
PS_UV_DLY, /* 0x8028 低电压延时(ms) */
|
PS_UV_DLY, /* 0x8028 低电压延时(ms) */
|
||||||
/* --- 零序过压(UN,L3-6) --- */
|
/* --- 零序过压(UN) --- */
|
||||||
PS_3U0_EN, /* 0x888E */
|
PS_3U0_EN, /* 0x888E */
|
||||||
PS_3U0_VOL, /* 0x888F 3U0 定值(V,二次值) */
|
PS_3U0_VOL, /* 0x888F 3U0 定值(V,二次值) */
|
||||||
PS_3U0_DLY, /* 0x8890 延时(ms) */
|
PS_3U0_DLY, /* 0x8890 延时(ms) */
|
||||||
@@ -70,7 +74,7 @@ typedef enum {
|
|||||||
PS_CT_SEC, /* 0x8826 CT 二次额定(A) */
|
PS_CT_SEC, /* 0x8826 CT 二次额定(A) */
|
||||||
/* --- 全局软压板(B7):104 YK12 两段式投退;0 = 保护退出服务 --- */
|
/* --- 全局软压板(B7):104 YK12 两段式投退;0 = 保护退出服务 --- */
|
||||||
PS_GLOBAL_EN, /* 本地项(无通讯地址,经 YK12 写、C_RD 不可读) */
|
PS_GLOBAL_EN, /* 本地项(无通讯地址,经 YK12 写、C_RD 不可读) */
|
||||||
/* --- 过频 / 低频(L3-5,B8):投退/动作频率/延时 + 各自返回系数 ---
|
/* --- 过频 / 低频(B8):投退/动作频率/延时 + 各自返回系数 ---
|
||||||
* ⚠ 通讯地址:参考 g_tCommSetParaTab 的保护定值段(0x8820~0x8882)
|
* ⚠ 通讯地址:参考 g_tCommSetParaTab 的保护定值段(0x8820~0x8882)
|
||||||
* 含"低压/低频"类目(规划 §5.1),但 OF/UF 的**逐项地址不在本仓库**
|
* 含"低压/低频"类目(规划 §5.1),但 OF/UF 的**逐项地址不在本仓库**
|
||||||
* (参考 Comm 源文件未随仓库提供)→ 按"找不到对应即本地项"规则全部
|
* (参考 Comm 源文件未随仓库提供)→ 按"找不到对应即本地项"规则全部
|
||||||
@@ -126,7 +130,7 @@ int32_t prot_set_get(prot_set_id_t id); /* Q10 原值 */
|
|||||||
float prot_set_get_f(prot_set_id_t id); /* 工程量浮点 */
|
float prot_set_get_f(prot_set_id_t id); /* 工程量浮点 */
|
||||||
uint8_t prot_set_get_ctl(prot_set_id_t id); /* 投退项:!=0 视为投入 */
|
uint8_t prot_set_get_ctl(prot_set_id_t id); /* 投退项:!=0 视为投入 */
|
||||||
|
|
||||||
/* 校验和(L5-1):sum32 覆盖 RAM 值区;seal 封存正/反码对 */
|
/* 校验和(定值校验和):sum32 覆盖 RAM 值区;seal 封存正/反码对 */
|
||||||
uint32_t prot_set_checksum(void);
|
uint32_t prot_set_checksum(void);
|
||||||
void prot_set_seal(void);
|
void prot_set_seal(void);
|
||||||
/* 自检:重算并与封存值比对,失配 → 置闭锁并返回 -1(已闭锁也返回 -1) */
|
/* 自检:重算并与封存值比对,失配 → 置闭锁并返回 -1(已闭锁也返回 -1) */
|
||||||
|
|||||||
@@ -0,0 +1,372 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:运行参数段(全周傅氏相量/保护判据整定层,「数据可信」这一项)——实现
|
||||||
|
* 模块功能:见 run_par.h。工厂默认 = 「数据可信」下游常数按工程量重定后的额定 0.5 % 档(电压 500
|
||||||
|
* mV / 电流 5 mA / 频率 500 mHz,毫单位 int32 定点落盘);[run_par]
|
||||||
|
* 段 9 键(db_ua…db_freq,均 ≤15 字符)≤ CFG_FILE_MAX_KEYS 不拆段
|
||||||
|
* (通道系数覆盖**不落本段**——直通 adc_cal 的 [adc_cal]/[adc_cal_ofs],
|
||||||
|
* 系数唯一真源在那两段,lead 裁②a)。
|
||||||
|
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
|
||||||
|
* 作者:DTU 项目组
|
||||||
|
* 创建日期:2026-10-25(道D 运行参数段)
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-25 DTU 项目组 创建:[run_par] 段 + 104 映射 + 直通 adc_cal
|
||||||
|
* + B7 式脏标志落盘请求
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 头文件包含区 */
|
||||||
|
#include <math.h>
|
||||||
|
#include <stddef.h>
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "run_par.h"
|
||||||
|
|
||||||
|
#include "adc_cal.h"
|
||||||
|
#include "cfg_file.h"
|
||||||
|
|
||||||
|
#define DBG_TAG "[PAR]"
|
||||||
|
#include "dbg_log.h"
|
||||||
|
|
||||||
|
/* 常量定义区 */
|
||||||
|
|
||||||
|
/* cfg 段名(≤ CFG_FILE_MAX_NAME_LEN);键名 6~7 字符小写 */
|
||||||
|
#define RUN_PAR_SEC "run_par"
|
||||||
|
|
||||||
|
/* 104 运行参数地址(复现规划 §5.1 类内落实;权威逐项表不在仓,送检点表到位
|
||||||
|
* 后以送检为准替换本常量段——lead 裁①。0x8027~0x802A = prot_set 告警段,永不让位) */
|
||||||
|
#define RUN_PAR_IOA_DB0 0x8030u /* 9 点:0x8030~0x8038 */
|
||||||
|
#define RUN_PAR_IOA_K0 0x8040u /* k 覆盖 8 路:0x8040~0x8047 */
|
||||||
|
#define RUN_PAR_IOA_B0 0x8048u /* b 覆盖 8 路:0x8048~0x804F */
|
||||||
|
|
||||||
|
/* 死区合法域上限(毫单位):电压 50 V / 电流 1 A / 频率 5 Hz——守卫挡误写,
|
||||||
|
* 不限工况;下限统一 0(负死区 = 配置错误) */
|
||||||
|
#define RUN_PAR_DB_MAX_U 50000L
|
||||||
|
#define RUN_PAR_DB_MAX_I 1000L
|
||||||
|
#define RUN_PAR_DB_MAX_F 5000L
|
||||||
|
|
||||||
|
/* 静态变量定义区 */
|
||||||
|
|
||||||
|
/* [run_par] 段 RAM 副本(引擎原地更新;属主契约与 µs 级混窗见「数据可信」§4.4,
|
||||||
|
* 同 adc_cal——本层不挂回调,副本即生效值,消费侧实时读) */
|
||||||
|
typedef struct {
|
||||||
|
int32_t db[RUN_PAR_DB_N];
|
||||||
|
} run_par_db_t;
|
||||||
|
|
||||||
|
static const run_par_db_t s_db_rom = {
|
||||||
|
{ 500L, 500L, 500L, 500L, /* UA/UB/UC/UN:0.5 V(工程量重定后的额定档) */
|
||||||
|
5L, 5L, 5L, 5L, /* IA/IB/IC/IN:0.005 A */
|
||||||
|
500L } /* 频率:0.5 Hz(绝对档,非额定百分比) */
|
||||||
|
};
|
||||||
|
static run_par_db_t s_db;
|
||||||
|
|
||||||
|
/* 键表 offset 与 run_par_db_t 字段一一对应(sizeof(int32_t) 步进) */
|
||||||
|
static const cfg_key_def_t s_db_keys[] = {
|
||||||
|
{ "db_ua", CFG_VAL_INT, 0u, 4u },
|
||||||
|
{ "db_ub", CFG_VAL_INT, 4u, 4u },
|
||||||
|
{ "db_uc", CFG_VAL_INT, 8u, 4u },
|
||||||
|
{ "db_un", CFG_VAL_INT, 12u, 4u },
|
||||||
|
{ "db_ia", CFG_VAL_INT, 16u, 4u },
|
||||||
|
{ "db_ib", CFG_VAL_INT, 20u, 4u },
|
||||||
|
{ "db_ic", CFG_VAL_INT, 24u, 4u },
|
||||||
|
{ "db_in", CFG_VAL_INT, 28u, 4u },
|
||||||
|
{ "db_freq", CFG_VAL_INT, 32u, 4u }
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 落盘脏标志:104 写成功置位,run_par_store_save 成功清零(B7 prot_set 同款,
|
||||||
|
* 只置不清 = 写盘失败天然重试);单字节,跨任务 peek 无害 */
|
||||||
|
static uint8_t s_dirty;
|
||||||
|
|
||||||
|
/* 内部函数实现区 */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:毫单位定点 → 工程量浮点(**本模块唯一换算点**)
|
||||||
|
* 入口参数:milli - 毫单位 内 int32_t
|
||||||
|
* 返 回 值:工程量 内 float
|
||||||
|
* 限定条件:仅本文件内使用(消费侧一律走 run_par_deadband()/run_par_db_range(),
|
||||||
|
* 不许各自再写一次 /1000——那才是"第二真源"最常见的样子)
|
||||||
|
*/
|
||||||
|
static float s_milli_to_eng(int32_t milli) {
|
||||||
|
return (float)milli / 1000.0f;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:死区点量程上限(毫单位)
|
||||||
|
* 入口参数:idx - 0..8 uint8_t
|
||||||
|
* 返 回 值:上限;idx 越界返 0 内 int32_t
|
||||||
|
*/
|
||||||
|
static int32_t s_db_cap_milli(uint8_t idx) {
|
||||||
|
if (idx < 4u) {
|
||||||
|
return RUN_PAR_DB_MAX_U;
|
||||||
|
}
|
||||||
|
if (idx < RUN_PAR_DB_N - 1u) {
|
||||||
|
return RUN_PAR_DB_MAX_I;
|
||||||
|
}
|
||||||
|
if (idx == (uint8_t)(RUN_PAR_DB_N - 1u)) {
|
||||||
|
return RUN_PAR_DB_MAX_F;
|
||||||
|
}
|
||||||
|
return 0L;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:工程量浮点 → 毫单位定点(只舍不入的截断由调用侧范围守卫兜底)
|
||||||
|
* 入口参数:v - 工程量(须已判有限) float
|
||||||
|
* 返 回 值:四舍五入最近毫单位 内 int32_t
|
||||||
|
*/
|
||||||
|
static int32_t s_milli_of(float v) {
|
||||||
|
return (int32_t)lrintf(v * 1000.0f);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:死区值校验(不提交),set/104 选择段共用同一守卫 = 两路语义同源
|
||||||
|
* 入口参数:idx - 0..8 uint8_t;db - 工程量 float
|
||||||
|
* 返 回 值:合法毫单位 ≥0;-1 非法(越界/NaN/负/越程) 内 int32_t
|
||||||
|
*/
|
||||||
|
static int32_t s_db_check(uint8_t idx, float db) {
|
||||||
|
int32_t milli;
|
||||||
|
|
||||||
|
if ((idx >= RUN_PAR_DB_N) || !isfinite(db) || !(db >= 0.0f)) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
milli = s_milli_of(db);
|
||||||
|
if ((milli < 0L) || ((long long)milli > (long long)s_db_cap_milli(idx))) {
|
||||||
|
return -1L;
|
||||||
|
}
|
||||||
|
return milli;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 公共函数实现区 */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:初始化——副本置工厂默认 + 注册 [run_par] 段(幂等,内核前)
|
||||||
|
* 返 回 值:无
|
||||||
|
*/
|
||||||
|
void run_par_init(void) {
|
||||||
|
cfg_section_reg_t reg;
|
||||||
|
|
||||||
|
(void)memcpy(&s_db, &s_db_rom, sizeof(s_db));
|
||||||
|
s_dirty = 0u;
|
||||||
|
|
||||||
|
(void)memset(®, 0, sizeof(reg));
|
||||||
|
reg.name = RUN_PAR_SEC;
|
||||||
|
reg.p_data = &s_db;
|
||||||
|
reg.p_defaults = &s_db_rom;
|
||||||
|
reg.data_size = sizeof(s_db);
|
||||||
|
reg.p_keys = s_db_keys;
|
||||||
|
reg.key_count = (uint8_t)(sizeof(s_db_keys) / sizeof(s_db_keys[0]));
|
||||||
|
reg.p_cb = NULL; /* 不挂回调:RAM 副本即生效值(同 adc_cal) */
|
||||||
|
if (cfg_file_register(®) != 0) {
|
||||||
|
DBG_ERROR("register %s FAIL (dup/full?) - file values inactive",
|
||||||
|
RUN_PAR_SEC);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读第 idx 路死区(工程量)
|
||||||
|
* 返 回 值:V / A / Hz;越界返 0.0(消费侧按"无死区"保守处理) float
|
||||||
|
*/
|
||||||
|
float run_par_deadband(uint8_t idx) {
|
||||||
|
if (idx >= RUN_PAR_DB_N) {
|
||||||
|
return 0.0f;
|
||||||
|
}
|
||||||
|
return s_milli_to_eng(s_db.db[idx]); /* 毫→工程:唯一换算点 = s_milli_to_eng */
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:取第 idx 路死区的合法域(工程量)——**合法域的唯一出口**
|
||||||
|
* 入口参数:idx - 0..8 uint8_t;lo/hi - 输出下限/上限(工程量 V/A/Hz) float *
|
||||||
|
* 返回值:0 - 已写出;-1 - idx 越界或指针为空(调用方不得回退成自造默认域) int
|
||||||
|
* 限定条件:任意上下文(纯常量读,无锁、无 I/O)
|
||||||
|
* 函数说明:为什么公开这个口:UI 整定页要给数字编辑器设 min/max,而这些上限的
|
||||||
|
* 真源是本模块的 RUN_PAR_DB_MAX_*(电压 50V / 电流 1A / 频率 5Hz,
|
||||||
|
* "额定 × 二档裕量")。页面里抄一份就是第二真源——判据「不新增第二份
|
||||||
|
* 真源」最典型的复发形态是"顺手把常量写进界面代码"。因此本函数的
|
||||||
|
* 消费者是装配层 App/app/ui_data_feed.c 的注入表实现,不是页面本身。
|
||||||
|
* ⚠ 给的是**粗守域**(挡误写),不是工况域;真提交仍由 s_db_check
|
||||||
|
* 同源守卫裁(104 选择段与 UI 编辑器共用同一批常量,不会漂移)。
|
||||||
|
*/
|
||||||
|
int run_par_db_range(uint8_t idx, float *lo, float *hi) {
|
||||||
|
if ((idx >= RUN_PAR_DB_N) || (lo == NULL) || (hi == NULL)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
*lo = 0.0f;
|
||||||
|
*hi = s_milli_to_eng(s_db_cap_milli(idx));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:写第 idx 路死区(守卫通过才提交;成功置脏)
|
||||||
|
* 返 回 值:0 成功;-1 非法(副本不动) int
|
||||||
|
*/
|
||||||
|
int run_par_deadband_set(uint8_t idx, float db) {
|
||||||
|
int32_t milli = s_db_check(idx, db);
|
||||||
|
|
||||||
|
if (milli < 0L) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
s_db.db[idx] = milli;
|
||||||
|
s_dirty = 1u;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:104 运行参数地址读(命中集精确匹配,见 run_par.h 地址表)
|
||||||
|
* 返 回 值:0 命中;-1 非本段(调用方落 prot_set) int
|
||||||
|
*/
|
||||||
|
int run_par_104_read(uint32_t addr, float *out) {
|
||||||
|
if (out == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if ((addr >= RUN_PAR_IOA_DB0) && (addr < RUN_PAR_IOA_DB0 + RUN_PAR_DB_N)) {
|
||||||
|
*out = run_par_deadband((uint8_t)(addr - RUN_PAR_IOA_DB0));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ((addr >= RUN_PAR_IOA_K0) && (addr < RUN_PAR_IOA_K0 + RUN_PAR_CH_N)) {
|
||||||
|
*out = adc_cal_k_uv((adc_semantic_t)(addr - RUN_PAR_IOA_K0));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ((addr >= RUN_PAR_IOA_B0) && (addr < RUN_PAR_IOA_B0 + RUN_PAR_CH_N)) {
|
||||||
|
*out = adc_cal_b_codes((adc_semantic_t)(addr - RUN_PAR_IOA_B0));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:写某路系数 k(µV/码)**并在同一函数内置脏**——整定的语义写入口
|
||||||
|
* 入口参数:sem - 电气语义通道 adc_semantic_t;k_uv - 系数(µV/码,>0) float
|
||||||
|
* 返回值:0 - RAM 副本已写 + 已置脏;-1 - 粗守或执行段拒(副本未动、不置脏) int
|
||||||
|
* 限定条件:整定路径上下文(iec104Task 的 104 执行段;UI 经 ui_data_yt_ops_t 由
|
||||||
|
* 装配层 App/app/ui_data_feed.c 转进来)。勿在 adcTask 调用
|
||||||
|
* 函数说明:**为什么"写值 + 置脏"必须长在一个函数里**(lead 裁②,2026-10-27):
|
||||||
|
* 置脏是持久化承诺,写值是 RAM 事实,两者拆开就会出现"屏上已改、盘上
|
||||||
|
* 永不落、且没有任何错误返回"这种无人报错的状态——本仓今天已实测到它的
|
||||||
|
* 既有形态(绕过本模块直调 adc_cal_set() 的写者不置脏,值永远不落盘,
|
||||||
|
* 另立 task-43 处置)。所以这里不提供单独的 mark_dirty 公开口:它一旦
|
||||||
|
* 存在就一定有人只调它(同"零消费者的状态查询口"那条教训)。
|
||||||
|
* 零点 b 走 RMW 取**当前生效值**(经合法带哨兵,天然满足再写校验)。
|
||||||
|
* 粗守(0 < k ≤ RUN_PAR_K_MAX_UV)只为挡误写,真带在 adc_cal 执行段裁:
|
||||||
|
* 两层各说各的,页面上"合法域内仍被拒(ERR)"就是执行段拒的,不伪装。
|
||||||
|
*/
|
||||||
|
int run_par_cal_k_set(adc_semantic_t sem, float k_uv) {
|
||||||
|
int32_t k_q16;
|
||||||
|
int32_t b_codes;
|
||||||
|
|
||||||
|
/* 通道号不在这里判:adc_cal_set 自带合法性与合法带裁决(越界一律 -1),
|
||||||
|
* 本层再判一次就是第二真源。粗守只挡误写:0 < k ≤ RUN_PAR_K_MAX_UV */
|
||||||
|
if (!isfinite(k_uv) || !(k_uv > 0.0f) || (k_uv > RUN_PAR_K_MAX_UV)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
k_q16 = (int32_t)lrintf(k_uv * 65536.0f);
|
||||||
|
b_codes = (int32_t)lrintf(adc_cal_b_codes(sem));
|
||||||
|
if (adc_cal_set(sem, k_q16, b_codes) != 0) {
|
||||||
|
return -1; /* 执行段拒:副本未动,脏位也不置 = 不承诺任何持久化 */
|
||||||
|
}
|
||||||
|
s_dirty = 1u;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:写某路零点偏移 b(码值)并置脏(与 k 同款语义,粗守 ±1e6 码)
|
||||||
|
* 入口参数:sem - 电气语义通道 adc_semantic_t;b_codes - 零点(码值) int32_t
|
||||||
|
* 返回值:0 - 已写并置脏;-1 - 粗守或执行段拒(副本未动、不置脏) int
|
||||||
|
* 限定条件:同 run_par_cal_k_set()
|
||||||
|
* 函数说明:k 侧 RMW 用当前生效值;±8192 真带由 adc_cal 执行段裁(粗守目的是
|
||||||
|
* 挡误写不是限工况)。本函数的消费者 = 104 的 0x8048~ 分支(改造后),
|
||||||
|
* 不是"为将来预留"的口
|
||||||
|
*/
|
||||||
|
int run_par_cal_b_set(adc_semantic_t sem, int32_t b_codes) {
|
||||||
|
int32_t k_q16;
|
||||||
|
|
||||||
|
if (((float)b_codes < -1.0e6f) || ((float)b_codes > 1.0e6f)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
k_q16 = (int32_t)lrintf(adc_cal_k_uv(sem) * 65536.0f);
|
||||||
|
if (adc_cal_set(sem, k_q16, b_codes) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
s_dirty = 1u;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:104 运行参数地址写(两段式;k/b 经本模块语义 setter——**run_par 不存
|
||||||
|
* 系数**,系数唯一真源 = [adc_cal]/[adc_cal_ofs],lead 裁②a)
|
||||||
|
* 返 回 值:0 接受 / -1 拒绝 / -2 非本段(调用方落 prot_set) int
|
||||||
|
* 函数说明:选择段 = 校验不提交(死区走与 set 同源守卫;k/b 的 adc_cal 合法带
|
||||||
|
* 由执行段裁决——IEC104 允许执行段 PN,两段结果可不一致,注释在此
|
||||||
|
* 明说防"选择过了怎么还 PN"的现场困惑)。k/b 两支自 2026-10-27 起一律
|
||||||
|
* 走 run_par_cal_k_set() / run_par_cal_b_set():写值与置脏在同一个函数
|
||||||
|
* 里发生,本函数不再自己碰 s_dirty(lead 裁②:消除两份置脏逻辑)。
|
||||||
|
*/
|
||||||
|
int run_par_104_write(uint32_t addr, float v, uint8_t select) {
|
||||||
|
uint8_t slot; /* 每个命中分支先赋值再使用(无未init读路径) */
|
||||||
|
|
||||||
|
if ((addr >= RUN_PAR_IOA_DB0) && (addr < RUN_PAR_IOA_DB0 + RUN_PAR_DB_N)) {
|
||||||
|
int32_t milli;
|
||||||
|
|
||||||
|
slot = (uint8_t)(addr - RUN_PAR_IOA_DB0);
|
||||||
|
milli = s_db_check(slot, v);
|
||||||
|
if (milli < 0L) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (select == 0u) {
|
||||||
|
s_db.db[slot] = milli;
|
||||||
|
s_dirty = 1u;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if ((addr >= RUN_PAR_IOA_K0) && (addr < RUN_PAR_IOA_K0 + RUN_PAR_CH_N)) {
|
||||||
|
slot = (uint8_t)(addr - RUN_PAR_IOA_K0);
|
||||||
|
if (!isfinite(v) || !(v > 0.0f) || (v > 1.0e9f)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (select != 0u) {
|
||||||
|
return 0; /* 粗守过了 ≠ 执行必过:合法带裁决在执行段(见函数头) */
|
||||||
|
}
|
||||||
|
/* 写值 + 置脏收进语义 setter(粗守 0<k≤上限 也在里面,与本支
|
||||||
|
* 原先那条逐字同值守卫一致,行为不变)*/
|
||||||
|
return run_par_cal_k_set((adc_semantic_t)slot, v);
|
||||||
|
}
|
||||||
|
if ((addr >= RUN_PAR_IOA_B0) && (addr < RUN_PAR_IOA_B0 + RUN_PAR_CH_N)) {
|
||||||
|
slot = (uint8_t)(addr - RUN_PAR_IOA_B0);
|
||||||
|
if (!isfinite(v)) {
|
||||||
|
return -1; /* NaN/Inf 不得进 lrintf(该转换是 UB) */
|
||||||
|
}
|
||||||
|
if (select != 0u) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
/* 同款语义 setter:粗守 ±1e6 码在 setter 内,±8192 真带由 adc_cal 执行段裁 */
|
||||||
|
return run_par_cal_b_set((adc_semantic_t)slot, (int32_t)lrintf(v));
|
||||||
|
}
|
||||||
|
return -2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:落盘脏探查(非消费)
|
||||||
|
* 返 回 值:1 有未落盘整定;0 干净 uint8_t
|
||||||
|
*/
|
||||||
|
uint8_t run_par_dirty_peek(void) {
|
||||||
|
return s_dirty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:执行落盘(整档外科保存成功后清脏;失败脏保持 = 下轮重试)
|
||||||
|
* 返 回 值:0 成功/无脏;-1 写盘失败 int
|
||||||
|
* 限定条件:defaultTask(配置引擎属主)上下文,SYS_EVT_RUN_PAR_SAVE 驱动
|
||||||
|
* 函数说明:一次 save 同时覆盖 [run_par] 9 键与刚被 adc_cal_set 改写的
|
||||||
|
* [adc_cal]/[adc_cal_ofs] 副本(lead 裁②:一条事件位一次 save)。
|
||||||
|
* 成功路径上给标定层盖一次落盘水位(adc_cal_note_persisted())——它是
|
||||||
|
* **观测回执**,不参与本函数任何判定;失败路径不盖,于是"save 一直失败"
|
||||||
|
* 在标定层侧表现为差额长期不归零(道G task-43 第②项)。
|
||||||
|
*/
|
||||||
|
int run_par_store_save(void) {
|
||||||
|
if (s_dirty == 0u) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (cfg_file_save() != 0) {
|
||||||
|
DBG_ERROR("cfg save FAIL - keep dirty, retry next round");
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
s_dirty = 0u;
|
||||||
|
adc_cal_note_persisted(); /* 观测回执:水位追平,差额归零(不是判定输入) */
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:运行参数段(全周傅氏相量/保护判据整定层,「数据可信」这一项)
|
||||||
|
* 模块功能:遥测死区(9 点)与通道标定系数覆盖(k/b 各 8 路)的**单一真源**:
|
||||||
|
* - 死区 = 本模块 int32 毫单位 RAM 副本(注册进 cfg 引擎 [run_par] 段,
|
||||||
|
* 工厂默认 = 「数据可信」下游常数按工程量重定后的额定 0.5 % 档),104 消费侧
|
||||||
|
* (App/protocol/iec104/iec104_pts.c)每拍实时读取——无投递、无缓存
|
||||||
|
* 副本(104 改读快照曾在 pts 侧留运行时副本 s_yc_db,运行参数段落地后收编于此,
|
||||||
|
* 双源必打架);
|
||||||
|
* - 系数覆盖 = **直通 adc_cal_set()**([adc_cal]/[adc_cal_ofs] 是系数
|
||||||
|
* 唯一真源,标定落盘与整定入口已定;本模块只加 104 地址映射 + 落盘请求,不另存
|
||||||
|
* 一份系数——同 D1 裁"两处判断必打架"逻辑)。
|
||||||
|
* 104 地址映射(docs/算法/继电保护功能复现规划.md §5.1 运行参数类
|
||||||
|
* 0x8020H~0x8058H;逐项表原文"待补"(参考项目 CommPara_ConfigTable
|
||||||
|
* 不在仓),本表即运行参数段的落实,送检点表若另给权威地址只改常量段:
|
||||||
|
* 0x8030~0x8038 = 死区 UA/UB/UC/UN/IA/IB/IC/IN/FREQ(工程量 V/A/Hz);
|
||||||
|
* 0x8040~0x8047 = k 覆盖(µV/码);0x8048~0x804F = b 覆盖(码值,可负);
|
||||||
|
* 其余留白(未启用类别),**0x8027~0x802A 属 prot_set 告警段**,本表
|
||||||
|
* 永不让位(两表按精确地址分流,见 iec104_pts.c set 路由)。
|
||||||
|
* 持久化(B7 prot_set 先例同款):任一写成功 → 脏标志只置不清;
|
||||||
|
* iec104Task 主循环 peek → 置 SYS_EVT_RUN_PAR_SAVE → defaultTask
|
||||||
|
* (配置引擎属主)run_par_store_save() 调 cfg_file_save() 整档外科
|
||||||
|
* 保存(含 [run_par] 与刚被 adc_cal_set 改写的 [adc_cal]/[adc_cal_ofs]
|
||||||
|
* 副本)→ 成功清脏;失败留脏 = 下轮天然重试。本模块自身零文件 IO
|
||||||
|
* (属主纪律)。
|
||||||
|
* ⚠ 三份职责别混(道G task-43 第③项,此前只有意图式陈述):
|
||||||
|
* - **谁写 RAM**:系数/零点的唯一真源是 adc_cal 的引擎副本,而本模块那两支
|
||||||
|
* 语义 setter(run_par_cal_k_set / run_par_cal_b_set,task-7 落地)是**唯一
|
||||||
|
* "写值 + 置脏"成对发生**的入口;任何消费者绕过它们直调 adc_cal_set(),
|
||||||
|
* 值只进 RAM、脏标志不动 ⇒ 屏上"当前生效"是新的、盘上仍是旧的、重启即丢,
|
||||||
|
* 且该路径**没有错误返回**。这条形态现在可观测:adc_cal 侧
|
||||||
|
* set_ok 与落盘水位的差额 > 0,而 run_par_dirty_peek() 恒为 0。
|
||||||
|
* - **谁落盘 / 什么时机**:只有 defaultTask 的 run_par_store_save()(由
|
||||||
|
* iec104Task peek 后置的 SYS_EVT_RUN_PAR_SAVE 驱动);本模块零文件 IO。
|
||||||
|
* - **cfg_file_poll() 不是保存路径**:它的方向是**盘 → RAM 的重载**(外部
|
||||||
|
* 改档 → 沉降去抖 → 重载),拿它"兜住"未落盘的改值会把 FTP 侧的旧档倒灌
|
||||||
|
* 回 RAM,比不兜更坏。设计依据见 docs/算法/采集与算法层设计说明.md §10.1。
|
||||||
|
* 并发契约:引擎副本 int32 对齐字读写在 Cortex-M4 上原子;poll 重载 µs 级窗口
|
||||||
|
* 为「数据可信」§4.4 明载设计(adc_cal 同款)。写路径 = 104 执行段
|
||||||
|
* (iec104Task)与引擎读路径互不加锁,勿改成"读侧缓存"(缓存 = 新真源)。
|
||||||
|
* 纯算法:只用 stdint/stddef/math + cfg_file.h/adc_cal.h 头(注册/直通均为
|
||||||
|
* 接口级)→ 主机侧可单测(test/host/run_par,链真 cfg 引擎 + 真 adc_cal)。
|
||||||
|
* 适用平台:STM32F407ZGTx(Cortex-M4F,硬件 FPU)
|
||||||
|
* 作者:DTU 项目组
|
||||||
|
* 创建日期:2026-10-25(道D 运行参数段)
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-25 DTU 项目组 创建:[run_par] 段 + 104 地址映射 + 直通
|
||||||
|
* adc_cal + B7 式脏标志落盘请求
|
||||||
|
* v1.1 2026-10-27 DTU 项目组 道F task-7(遥测整定两页)配套:新增语义
|
||||||
|
* setter run_par_cal_k_set / run_par_cal_b_set
|
||||||
|
* (写值 + 置脏成对;104 的 k/b 两支同批改调它,
|
||||||
|
* 消除两份置脏逻辑)+ 合法域唯一出口
|
||||||
|
* run_par_db_range() + 系数粗守上限
|
||||||
|
* RUN_PAR_K_MAX_UV;毫→工程换算收进模块内单点
|
||||||
|
* s_milli_to_eng()(顺带把 task-43 第①项所需的
|
||||||
|
* "写值必置脏"这一半满足掉)
|
||||||
|
* v1.2 2026-10-27 DTU 项目组 道G task-43 第②③项:run_par_store_save() 成功路径
|
||||||
|
* 给标定层盖一次落盘水位(纯观测回执,不参与判定);
|
||||||
|
* 本头把"谁写 RAM / 谁落盘 / 什么时机 / poll 不是保存
|
||||||
|
* 路径"从意图式陈述改成逐条契约
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef RUN_PAR_H
|
||||||
|
#define RUN_PAR_H
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#include "adc_cal.h" /* adc_semantic_t:公开语义 setter 的形参类型(同层 algo→algo,方向不逆)*/
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* 死区/覆盖点数(死区 = YC 表行数:电气 8 + 频率;覆盖 = 语义电气 8 路) */
|
||||||
|
#define RUN_PAR_DB_N 9u
|
||||||
|
#define RUN_PAR_CH_N 8u
|
||||||
|
|
||||||
|
/* 系数 k 的**粗守**上限(µV/码):只为挡误写(如把 mV 当 µV 填),不是工况域;
|
||||||
|
* 真合法带在 adc_cal 执行段裁(µV/码 ×[0.5,2.0]),两层各说各的、互不掩盖。
|
||||||
|
* 公开它是因为 UI 整定页要给编辑器设上限——上限在这里,就不许页面再抄一份。*/
|
||||||
|
#define RUN_PAR_K_MAX_UV 1.0e9f
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:运行参数初始化——副本置工厂默认 + 注册 [run_par] 段进 cfg 引擎
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:无
|
||||||
|
* 限定条件:**内核启动前**调用(app_main_init,adc_cal_init 之后);注册必须先
|
||||||
|
* 于 defaultTask 的 cfg_file_load(FTP 鉴权确立的契约,同 adc_cal 先例);幂等
|
||||||
|
* 函数说明:注册失败(段名重复/段数超限)打 [ERR] 后继续——工厂默认照常生效,
|
||||||
|
* 仅文件整定失效(adc_cal_init 同款降级)。
|
||||||
|
*/
|
||||||
|
void run_par_init(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读第 idx 路遥测死区(工程量)
|
||||||
|
* 入口参数:idx - 0..7 = 语义通道序(UA..IN),8 = 频率 uint8_t
|
||||||
|
* 返 回 值:死区 V / A / Hz;idx 越界返 0.0(调用方按"无死区"保守处理) float
|
||||||
|
* 限定条件:任意上下文(int32 单字读原子;104 COV 每拍调用)
|
||||||
|
* 函数说明:毫单位 → 浮点的换算在本模块内也只有一处(run_par.c 的
|
||||||
|
* s_milli_to_eng(),run_par_deadband 与 run_par_db_range 共用);
|
||||||
|
* 消费侧一律取本模块给的工程量,不要再各自除 1000。
|
||||||
|
*/
|
||||||
|
float run_par_deadband(uint8_t idx);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:写第 idx 路死区(工程量,含范围守卫 + 脏置位)
|
||||||
|
* 入口参数:idx - 同上 uint8_t;db - 死区工程量(V/A/Hz,≥0) float
|
||||||
|
* 返 回 值:0 成功;-1 越界/越程/NaN(副本不动) int
|
||||||
|
* 限定条件:写者两门——**iec104Task**(104 C_SE 执行段)与 **uiTask**(遥测死区页
|
||||||
|
* 经 ui_data 通道五注入转达,道F task-7 起;ui_data.h 通道五表注:合法域
|
||||||
|
* 与"写值+置脏"必须原子发生在执行侧)。两门都只写 RAM 副本 + 置脏,
|
||||||
|
* 落盘统一收敛在 defaultTask(勿在 adcTask 调用;此句为 2026-10-27 措辞
|
||||||
|
* 收口——原写"UI 若整定需另行走属主路径"是 task-2 期遗留,task-7 已接线)
|
||||||
|
* 函数说明:合法域按点型分档——电压 0~50 V、电流 0~1 A、频率 0~5 Hz(上限 =
|
||||||
|
* 额定 × 二档裕量,守卫目的是挡误写不是限工况;见 .c 常量段)。
|
||||||
|
* 成功即置脏(落盘请求,见文件头持久化契约)。
|
||||||
|
*/
|
||||||
|
int run_par_deadband_set(uint8_t idx, float db);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:104 运行参数地址读(C_RD 路由,先于 prot_set 扫描)
|
||||||
|
* 入口参数:addr - 通讯地址(0x8020~0x8058 段内) uint32_t;out - 工程量浮点
|
||||||
|
* (死区 V/A/Hz;k 覆盖 µV/码;b 覆盖 码值) float *
|
||||||
|
* 返 回 值:0 命中并写出;-1 地址不归本段(调用方继续走 prot_set) int
|
||||||
|
* 限定条件:iec104Task 上下文
|
||||||
|
* 函数说明:命中集 = 0x8030~0x8038 / 0x8040~0x8047 / 0x8048~0x804F 精确匹配;
|
||||||
|
* k/b 读回 = adc_cal **当前生效值**(与折算同视图:文件坏值被合法带
|
||||||
|
* 哨兵回退 ROM 时,回显的就是 ROM——主站看到的即下游实际吃到的)。
|
||||||
|
*/
|
||||||
|
int run_par_104_read(uint32_t addr, float *out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:104 运行参数地址写(C_SE 两段式路由,先于 prot_set 扫描)
|
||||||
|
* 入口参数:addr - 通讯地址 uint32_t;v - 工程量浮点 float;
|
||||||
|
* select - 1 = 选择段(只校验不落副本)/ 0 = 执行段 uint8_t
|
||||||
|
* 返 回 值:0 接受;-1 拒绝(越程/NaN/合法带外/非本段可写地址命中守卫);
|
||||||
|
* -2 地址不归本段(调用方继续走 prot_set) int
|
||||||
|
* 限定条件:iec104Task 上下文;执行段成功即置脏
|
||||||
|
* 函数说明:k/b 覆盖经 adc_cal_set() 合法带哨兵(µV/码 ×[0.5,2.0]、|b|≤8192,
|
||||||
|
* 越带 -1 → 104 侧 PN 否定确认,adc_cal 不留痕由**其自身**越带 [ERR]
|
||||||
|
* 机制负责——本模块直通不重复留痕);两段式与 prot_set 定值写同构。
|
||||||
|
*/
|
||||||
|
int run_par_104_write(uint32_t addr, float v, uint8_t select);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:写某路系数 k(µV/码)**并在同一函数内置脏**——整定的语义写入口
|
||||||
|
* 入口参数:sem - 电气语义通道(adc_semantic_t 枚举序) adc_semantic_t;
|
||||||
|
* k_uv - 系数(µV/码,须 >0 且 <= RUN_PAR_K_MAX_UV) float
|
||||||
|
* 返回值:0 - RAM 副本已写且已置脏;-1 - 粗守或执行段拒(副本未动、不置脏) int
|
||||||
|
* 限定条件:整定路径上下文(iec104Task 的 C_SE 执行段;UI 侧由装配层
|
||||||
|
* App/app/ui_data_feed.c 经 ui_data_yt_ops_t 转进来)。勿在 adcTask 调用
|
||||||
|
* 函数说明:**写值与置脏成对,不许拆开**(lead 裁②):拆开就等于"屏上已改、
|
||||||
|
* 盘上永不落、且没有任何错误返回"。因此本模块**不提供**公开的
|
||||||
|
* mark_dirty——单独调它等于空承诺;零点 b 由本函数 RMW 取当前生效值。
|
||||||
|
*/
|
||||||
|
int run_par_cal_k_set(adc_semantic_t sem, float k_uv);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:写某路零点偏移 b(码值)并置脏(与 k 同款语义,粗守 ±1e6 码)
|
||||||
|
* 入口参数:sem - 电气语义通道 adc_semantic_t;b_codes - 零点(码值) int32_t
|
||||||
|
* 返回值:0 - 已写并置脏;-1 - 粗守或执行段拒(副本未动、不置脏) int
|
||||||
|
* 限定条件:同 run_par_cal_k_set()
|
||||||
|
* 函数说明:k 侧 RMW 取当前生效值;±8192 真带由 adc_cal 执行段裁。消费者 =
|
||||||
|
* 104 的 0x8048~ 分支(改造后走这里),不是为将来预留的口
|
||||||
|
*/
|
||||||
|
int run_par_cal_b_set(adc_semantic_t sem, int32_t b_codes);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:取第 idx 路死区的合法域(工程量)——**合法域的唯一出口**
|
||||||
|
* 入口参数:idx - 0..8 uint8_t;lo/hi - 输出下限/上限(V/A/Hz) float *
|
||||||
|
* 返回值:0 - 已写出;-1 - idx 越界或指针空(调用方不得回退成自造默认域) int
|
||||||
|
* 限定条件:任意上下文(纯常量读)
|
||||||
|
* 函数说明:存在的唯一理由:UI 整定页要给编辑器设 min/max,而这些上限的真源是
|
||||||
|
* 本模块的 RUN_PAR_DB_MAX_*;页面抄一份就是第二真源。给的是**粗守域**,
|
||||||
|
* 真提交仍由与 104 选择段同源的守卫裁,两条路不会漂移到两套数字。
|
||||||
|
*/
|
||||||
|
int run_par_db_range(uint8_t idx, float *lo, float *hi);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:落盘脏标志探查(非消费)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:1 = 有已整定未落盘的改动;0 = 干净 uint8_t
|
||||||
|
* 限定条件:任意上下文(单字节);iec104_serv 主循环 peek 后投递事件(B7 同款)
|
||||||
|
*/
|
||||||
|
uint8_t run_par_dirty_peek(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:执行落盘请求(cfg_file_save 整档外科保存 + 成功清脏)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:0 成功(或无脏可存);-1 写盘失败(脏保持,下轮重试) int
|
||||||
|
* 限定条件:**defaultTask(配置引擎属主)上下文**,由 SYS_EVT_RUN_PAR_SAVE 驱动
|
||||||
|
* 函数说明:save 走引擎唯一写闸(超长拒写/原子改名);本函数不清
|
||||||
|
* 引擎以外模块的脏语义([adc_cal] 副本值已由写路径就位)。
|
||||||
|
*/
|
||||||
|
int run_par_store_save(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* RUN_PAR_H */
|
||||||
+24
-10
@@ -1,11 +1,11 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:采样环缓冲(L2 相量计算的输入)
|
* 模块名称:采样环缓冲(全周傅氏相量计算的输入)
|
||||||
* 模块功能:按语义通道保存最近 2 个周波(40 点)采样值,供 L2 相量计算按窗取数。
|
* 模块功能:按语义通道保存最近 2 个周波(40 点)采样值,供 全周傅氏相量计算按窗取数。
|
||||||
* 适用平台:STM32F407ZGTx(纯算法,无寄存器/无 RTOS 依赖)
|
* 适用平台:STM32F407ZGTx(纯算法,无寄存器/无 RTOS 依赖)
|
||||||
* 作者:王建锋
|
* 作者:王建锋
|
||||||
* 创建日期:2026-10-10
|
* 创建日期:2026-10-10
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-10-10 王建锋 创建:L2 输入环缓冲(20 点/周波 × 2 周波)
|
* v1.0 2026-10-10 王建锋 创建:全周傅氏相量输入环缓冲(20 点/周波 × 2 周波)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "sample_ring.h"
|
#include "sample_ring.h"
|
||||||
@@ -48,7 +48,7 @@ void sample_ring_init(void) {
|
|||||||
* 入口参数:values - 按语义通道顺序排列的一帧采样值(长度须 = ADC_SEM_COUNT)
|
* 入口参数:values - 按语义通道顺序排列的一帧采样值(长度须 = ADC_SEM_COUNT)
|
||||||
* n - 实际长度(必须等于 ADC_SEM_COUNT)
|
* n - 实际长度(必须等于 ADC_SEM_COUNT)
|
||||||
* 返 回 值:0 - 成功;-1 - 参数错
|
* 返 回 值:0 - 成功;-1 - 参数错
|
||||||
* 限定条件:同一帧内各通道须同刻(同族量同刻性由上游 L1 负责)
|
* 限定条件:同一帧内各通道须同刻(同族量同刻性由上游 语义取数与有效性负责)
|
||||||
* 函数说明:整帧写入完毕后**统一**推进写指针——单帧内不存在"半新半旧",
|
* 函数说明:整帧写入完毕后**统一**推进写指针——单帧内不存在"半新半旧",
|
||||||
* 消费侧取到的窗永远落在整帧边界上(无帧内撕裂)。
|
* 消费侧取到的窗永远落在整帧边界上(无帧内撕裂)。
|
||||||
*/
|
*/
|
||||||
@@ -73,28 +73,41 @@ int sample_ring_push_frame(const float *values, uint8_t n) {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:取指定语义通道最近 n 点的窗(按时间序,旧 → 新)
|
* 函数功能:取指定语义通道最近 n 点的窗(按时间序,旧 → 新)
|
||||||
* 入口参数:ch - 语义通道
|
* 入口参数:ch - 语义通道(0 .. ADC_SEM_COUNT-1)
|
||||||
* n - 取点数(1..SAMPLE_RING_DEPTH)
|
* n - 取点数(1 .. SAMPLE_RING_DEPTH)
|
||||||
* out - 输出缓冲(长度须 >= n)
|
* out - 输出缓冲(调用方保证长度 >= n,本层不校验容量)
|
||||||
* 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n
|
* 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n
|
||||||
* 限定条件:无
|
* 限定条件:仅 adcTask 上下文(写者与读者同任务串行,故不加锁、不用 volatile);
|
||||||
* 函数说明:out[n-1] 为最新一点。最老点下标 = head - n(模 DEPTH)。
|
* 须在同拍 sample_ring_push_frame() 之后调用。
|
||||||
|
* 函数说明:以最新一点为终点、向前回溯 n 点;out[0] 最老、out[n-1] 最新,
|
||||||
|
* 与 phasor_run() 的求和方向一致。
|
||||||
|
* 为什么需要这一层、环内布局与窗首下标的推导 →
|
||||||
|
* docs/算法/采集与算法层设计说明.md §5(本文件只留落地要点)。
|
||||||
*/
|
*/
|
||||||
int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out) {
|
int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out) {
|
||||||
uint16_t start;
|
uint16_t start;
|
||||||
uint16_t i;
|
uint16_t i;
|
||||||
|
|
||||||
|
/* 通道号先转 unsigned 再比:枚举底层可为有符号,负值会骗过 ch >= COUNT 的判据。 */
|
||||||
if ((out == NULL) || ((unsigned int)ch >= (unsigned int)ADC_SEM_COUNT)) {
|
if ((out == NULL) || ((unsigned int)ch >= (unsigned int)ADC_SEM_COUNT)) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
/* n = 0 是空窗;n > DEPTH 一个环装不下连续 n 点(回绕会把同一点重复读出)。 */
|
||||||
if ((n == 0u) || (n > SAMPLE_RING_DEPTH)) {
|
if ((n == 0u) || (n > SAMPLE_RING_DEPTH)) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
/* -2 = 窗还没攒够,调用方跳过本拍即可(首周波启动期必然出现),不是故障。 */
|
||||||
if (s_filled < (uint16_t)n) {
|
if (s_filled < (uint16_t)n) {
|
||||||
return -2;
|
return -2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 窗首 = s_head - n(s_head 是"下一待写"位置,故窗尾 = s_head - 1)。
|
||||||
|
* 必须先加 DEPTH 再减:直接减在 s_head < n 时下溢或得负数,而
|
||||||
|
* (x mod 65536) mod 40 不等于 x mod 40、C99 的负数取模也不是同余解。
|
||||||
|
* 加 DEPTH 不改同余类,并把中间值锁在 [0, 2·DEPTH - 2]。详见设计说明 §5.3。 */
|
||||||
start = (uint16_t)((s_head + SAMPLE_RING_DEPTH - (uint16_t)n) % SAMPLE_RING_DEPTH);
|
start = (uint16_t)((s_head + SAMPLE_RING_DEPTH - (uint16_t)n) % SAMPLE_RING_DEPTH);
|
||||||
|
|
||||||
|
/* 逐点取模拷贝:窗可能跨环尾,环在内存里首尾不相连,不能用一次 memcpy。 */
|
||||||
for (i = 0u; i < (uint16_t)n; i++) {
|
for (i = 0u; i < (uint16_t)n; i++) {
|
||||||
out[i] = s_ring[ch][(uint16_t)((start + i) % SAMPLE_RING_DEPTH)];
|
out[i] = s_ring[ch][(uint16_t)((start + i) % SAMPLE_RING_DEPTH)];
|
||||||
}
|
}
|
||||||
@@ -117,7 +130,8 @@ uint16_t sample_ring_filled(void) {
|
|||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返 回 值:累计入环帧数
|
* 返 回 值:累计入环帧数
|
||||||
* 限定条件:无
|
* 限定条件:无
|
||||||
* 函数说明:供心跳日志 / 丢帧观测(L1-6 判据之一)。
|
* 函数说明:供心跳日志 / 丢帧观测(采样有效性校验 判据之一)。
|
||||||
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
*/
|
*/
|
||||||
uint32_t sample_ring_frames(void) {
|
uint32_t sample_ring_frames(void) {
|
||||||
return s_frames;
|
return s_frames;
|
||||||
|
|||||||
+12
-8
@@ -1,16 +1,16 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:采样环缓冲(L2 相量计算的输入)
|
* 模块名称:采样环缓冲(全周傅氏相量计算的输入)
|
||||||
* 模块功能:按**语义通道**保存最近若干周波的采样值,供 L2 相量计算按窗取数。
|
* 模块功能:按**语义通道**保存最近若干周波的采样值,供 全周傅氏相量计算按窗取数。
|
||||||
* V0.1 上 8 路电气量分属两片转换器(硬件缺陷 N-18),本层只做"按时序
|
* V0.1 上 8 路电气量分属两片转换器(双转换器无共同采样触发),本层只做"按时序
|
||||||
* 存/取",**不做任何跨通道运算**——同刻性由上游(L1)保证,本层不假设。
|
* 存/取",**不做任何跨通道运算**——同刻性由上游(语义取数与有效性)保证,本层不假设。
|
||||||
* 容量取 **2 个周波(40 点)**,与参考系统 `CN_BUFSAM_CYCLE = 2`
|
* 容量取 **2 个周波(40 点)**,与参考系统 `CN_BUFSAM_CYCLE = 2`
|
||||||
* (规划 §10.1)一致,为后续加长数据窗(1.5~2 周波,抗衰减直流,
|
* (规划 §10.1)一致,为后续加长数据窗(1.5~2 周波,抗衰减直流,
|
||||||
* 见 `傅氏算法详解.md` §9 Q4)预留空间。
|
* 见 `傅氏算法详解.md` §9 Q4)预留空间。
|
||||||
* 适用平台:STM32F407ZGTx(**纯算法**:不碰寄存器、不碰 RTOS → 可主机侧单测 L0.5)
|
* 适用平台:STM32F407ZGTx(**纯算法**:不碰寄存器、不碰 RTOS → 可主机侧纯逻辑单测)
|
||||||
* 作者:王建锋
|
* 作者:王建锋
|
||||||
* 创建日期:2026-10-10
|
* 创建日期:2026-10-10
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-10-10 王建锋 创建:L2 输入环缓冲(20 点/周波 × 2 周波)
|
* v1.0 2026-10-10 王建锋 创建:全周傅氏相量输入环缓冲(20 点/周波 × 2 周波)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#ifndef SAMPLE_RING_H
|
#ifndef SAMPLE_RING_H
|
||||||
@@ -45,7 +45,7 @@ void sample_ring_init(void);
|
|||||||
* 入口参数:values - 按语义通道顺序排列的一帧采样值(浮点工程量,长度须 = ADC_SEM_COUNT)
|
* 入口参数:values - 按语义通道顺序排列的一帧采样值(浮点工程量,长度须 = ADC_SEM_COUNT)
|
||||||
* n - 实际长度(必须等于 ADC_SEM_COUNT,否则拒收)
|
* n - 实际长度(必须等于 ADC_SEM_COUNT,否则拒收)
|
||||||
* 返 回 值:0 - 成功;-1 - 参数错(空指针或长度不符)
|
* 返 回 值:0 - 成功;-1 - 参数错(空指针或长度不符)
|
||||||
* 限定条件:调用方保证同一帧内各通道**同刻**(同族量同刻性由上游 L1 负责)
|
* 限定条件:调用方保证同一帧内各通道**同刻**(同族量同刻性由上游 语义取数与有效性负责)
|
||||||
* 函数说明:整帧一次性写入,写完统一推进写指针——单帧内不可能出现"半新半旧",
|
* 函数说明:整帧一次性写入,写完统一推进写指针——单帧内不可能出现"半新半旧",
|
||||||
* 即天然无帧内撕裂。消费侧读到的窗永远是**整帧**边界。
|
* 即天然无帧内撕裂。消费侧读到的窗永远是**整帧**边界。
|
||||||
*/
|
*/
|
||||||
@@ -59,6 +59,9 @@ int sample_ring_push_frame(const float *values, uint8_t n);
|
|||||||
* 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n
|
* 返 回 值:0 - 成功;-1 - 参数错;-2 - 已累积点数不足 n
|
||||||
* 限定条件:无
|
* 限定条件:无
|
||||||
* 函数说明:out[n-1] 为最新一点。相量层以本函数取"以当前时刻为终止的一周波"。
|
* 函数说明:out[n-1] 为最新一点。相量层以本函数取"以当前时刻为终止的一周波"。
|
||||||
|
* ⚠ 仅 adcTask 上下文调用(写者与读者同任务串行,本层不加锁);
|
||||||
|
* 为什么需要这一层、环内布局与窗首下标的推导 →
|
||||||
|
* `docs/算法/采集与算法层设计说明.md` §5。
|
||||||
*/
|
*/
|
||||||
int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out);
|
int sample_ring_window(adc_semantic_t ch, uint8_t n, float *out);
|
||||||
|
|
||||||
@@ -76,7 +79,8 @@ uint16_t sample_ring_filled(void);
|
|||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返 回 值:累计入环帧数(32 位,1 kHz 下约 49.7 天回绕)
|
* 返 回 值:累计入环帧数(32 位,1 kHz 下约 49.7 天回绕)
|
||||||
* 限定条件:无
|
* 限定条件:无
|
||||||
* 函数说明:与 L1 的 `tpafe5160_frame_seq()` 对照可判丢帧(L1-6 采样有效性校验
|
* 函数说明:与 语义取数与有效性的 `tpafe5160_frame_seq()` 对照可判丢帧(采样有效性校验
|
||||||
|
*(保护功能清单见 docs/算法/继电保护功能复现规划.md)
|
||||||
* 的判据之一,本层只提供计数,不做判定)。
|
* 的判据之一,本层只提供计数,不做判定)。
|
||||||
*/
|
*/
|
||||||
uint32_t sample_ring_frames(void);
|
uint32_t sample_ring_frames(void);
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:屏板状态灯位映射(业务状态 → LED 位掩码)
|
||||||
|
* 模块功能:sb_led_map_eval()——状态快照进、32 位 LED 掩码出。位定义与理由全在
|
||||||
|
* sb_led_map.h(映射表唯一出处),本文件只做折算。
|
||||||
|
* 适用平台:平台无关(纯标准 C)
|
||||||
|
* 作者:王建锋
|
||||||
|
* 创建日期:2026-10-29
|
||||||
|
* 修改记录:
|
||||||
|
* 2026-10-29 王建锋 创建初始版本(批 3 的 3-1)
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include <stddef.h> /* NULL:纯逻辑不经 main.h,NULL 要自己带来 */
|
||||||
|
|
||||||
|
#include "sb_led_map.h"
|
||||||
|
|
||||||
|
/* NULL 输入归一用的全零快照(static const:不占 RAM,语义就是"什么都没发生") */
|
||||||
|
static const sb_led_in_t s_in_zero = { 0UL, 0UL, 0UL, 0U, { 0U, 0U, 0U } };
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把状态快照折算成屏板 LED 32 位掩码(契约见 sb_led_map.h)
|
||||||
|
* 入口参数:p_in - 状态快照,允许 NULL(= 全零输入,只跑心跳)
|
||||||
|
* now_ms - 毫秒时基(只用于运行灯相位)
|
||||||
|
* 返回值:32 位 LED 掩码(bit i = 灯 ID i;本函数不触碰 bit0 与 ID 6~19)
|
||||||
|
* 限定条件:任意上下文,纯函数
|
||||||
|
* 函数说明:心跳相位取 (now_ms / SB_LED_BLINK_HALF_MS) 的奇偶——用**除法取奇偶**
|
||||||
|
* 而不是"与上次比差值再翻转",因为后者要把相位存成静态量,纯函数就没了
|
||||||
|
* (单测要能不开机就验完整个闪烁序列)。回绕只截短一个半周期,无害。
|
||||||
|
*/
|
||||||
|
uint32_t sb_led_map_eval(const sb_led_in_t *p_in, uint32_t now_ms)
|
||||||
|
{
|
||||||
|
const sb_led_in_t *q = (p_in != NULL) ? p_in : &s_in_zero;
|
||||||
|
uint32_t mask = 0UL;
|
||||||
|
|
||||||
|
/* ID 1 运行心跳:偶数个 500 ms 段 = 亮 */
|
||||||
|
if (((now_ms / SB_LED_BLINK_HALF_MS) & 1UL) == 0UL)
|
||||||
|
{
|
||||||
|
mask |= SB_LED_BIT_RUN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (q->prot_active != 0UL)
|
||||||
|
{
|
||||||
|
mask |= SB_LED_BIT_ALARM;
|
||||||
|
}
|
||||||
|
if (q->iec104_run != 0U)
|
||||||
|
{
|
||||||
|
mask |= SB_LED_BIT_COMM;
|
||||||
|
}
|
||||||
|
if (q->adc_flags != 0UL)
|
||||||
|
{
|
||||||
|
mask |= SB_LED_BIT_ADC_FAULT;
|
||||||
|
}
|
||||||
|
if (q->prot_pick != 0UL)
|
||||||
|
{
|
||||||
|
mask |= SB_LED_BIT_PICKUP;
|
||||||
|
}
|
||||||
|
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:屏板状态灯位映射(业务状态 → LED 位掩码)
|
||||||
|
* 模块功能:把装置的业务状态(保护动作 / 保护启动 / 采样故障 / 104 主站在线 /
|
||||||
|
* 运行心跳)折算成屏板帧 B `info[0..3]` 的 32 位 LED 掩码。
|
||||||
|
* ⚠ 本层**纯逻辑**:不碰 HAL、不读全局、不打日志——输入是调用方
|
||||||
|
* (uiTask)采好的状态快照,输出是一个 uint32_t → 主机侧可纯逻辑单测
|
||||||
|
* (test/host/sb_led_map/)。与 Drivers/BSP/ScreenLink/sb_proto 同一分层理由。
|
||||||
|
* 适用平台:平台无关(纯标准 C)
|
||||||
|
* 作者:王建锋
|
||||||
|
* 创建日期:2026-10-29
|
||||||
|
* 修改记录:
|
||||||
|
* 2026-10-29 王建锋 创建初始版本(映射表口径见 docs/子系统设计/屏板链路设计说明.md §6)
|
||||||
|
*
|
||||||
|
* 这张表为什么必须"自定":屏板源码(DTU-LCD LED_Printf)只规定了"位 → 灯号"的
|
||||||
|
* 物理口径(bit1~bit19 → ID 1~19,ID 0 电源灯硬件常亮、不看位,bit20~31 未使用),
|
||||||
|
* **"哪路状态占哪一位"在参考主机源码里不存在**(参考的 LCD_LED_Update 实现在
|
||||||
|
* 未开源的屏板 BSP 库里)。出处 docs/产品需求/产品需求设计文档.md §5.16.3 ①。
|
||||||
|
*/
|
||||||
|
#ifndef SB_LED_MAP_H
|
||||||
|
#define SB_LED_MAP_H
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* 一、位定义(屏板物理口径:bit i = 灯 ID i;bit0 被屏板忽略,别指望用它)
|
||||||
|
* ==========================================================================*/
|
||||||
|
|
||||||
|
/* ID 1 = 运行灯:装置活着的心跳,500 ms 半周期闪烁(与主板 LED1~3 心跳节奏同源,
|
||||||
|
* docs/产品需求/产品需求设计文档.md §5.8)。它**不取决于任何输入状态**——只要这段
|
||||||
|
* 代码还在被调度,灯就在闪;灯不闪 = uiTask 停了,这正是操作员要看的信号。 */
|
||||||
|
#define SB_LED_BIT_RUN (1UL << 1)
|
||||||
|
|
||||||
|
/* ID 2 = 告警灯:任一保护元件处于**动作**态(prot_elm_active_mask != 0)。
|
||||||
|
* ⚠ 产品构建处于「未标定闭锁」(App/task/protect_task.c 的 VALID 门禁),
|
||||||
|
* 标定前恒灭——与屏上"保护动作 N"同一已知口径,不是本灯的缺陷。 */
|
||||||
|
#define SB_LED_BIT_ALARM (1UL << 2)
|
||||||
|
|
||||||
|
/* ID 3 = 通信灯:IEC 60870-5-104 链路处于 RUN(STARTDT 已确认 = 主站真的在线在
|
||||||
|
* 轮询)。选 RUN 而不选"TCP 已建连":连上不发 STARTDT 的扫描器不该点亮它。 */
|
||||||
|
#define SB_LED_BIT_COMM (1UL << 3)
|
||||||
|
|
||||||
|
/* ID 4 = 采样故障灯:ADC 采样有效性**电平**故障位非零(adc_health_flags,
|
||||||
|
* 静默/倒退等,当前活跃、可自愈熄灭)。选电平不选 sticky:这盏灯要能熄灭,
|
||||||
|
* "曾经故障过"的账在自检记录里,不该用一盏长明灯喊给操作员。 */
|
||||||
|
#define SB_LED_BIT_ADC_FAULT (1UL << 4)
|
||||||
|
|
||||||
|
/* ID 5 = 保护启动灯:任一保护元件处于**启动**(picked)态(prot_elm_pick_mask)。
|
||||||
|
* picked 早于 asserted,"越限启动了但没到动作点"这段过程只在这盏灯上可见
|
||||||
|
* (与 104「保护启动」记录同一动机,App/algo/prot_elm.h 的 pick_mask 注释)。
|
||||||
|
* 同样受未标定闭锁口径约束。 */
|
||||||
|
#define SB_LED_BIT_PICKUP (1UL << 5)
|
||||||
|
|
||||||
|
/* 已赋义位的并集(判据用:掩码里不得出现本表之外的置位)。
|
||||||
|
* ID 6~19 保留未用,恒 0;将来扩展从 ID 6 顺序取,跳号留白不回收语义。 */
|
||||||
|
#define SB_LED_BITS_DEFINED (SB_LED_BIT_RUN | SB_LED_BIT_ALARM | SB_LED_BIT_COMM | \
|
||||||
|
SB_LED_BIT_ADC_FAULT | SB_LED_BIT_PICKUP)
|
||||||
|
|
||||||
|
/* 运行灯闪烁半周期(ms):0 ~ SB_LED_BLINK_HALF_MS-1 亮,下一个 500 ms 灭,交替。
|
||||||
|
* 取 500 是为与主板 LED 心跳(defaultTask 500 ms 翻转)同拍,两套灯闪烁同相。 */
|
||||||
|
#define SB_LED_BLINK_HALF_MS (500U)
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* 二、状态输入快照(调用方每拍现采,本模块不缓存、不加锁)
|
||||||
|
* ==========================================================================*/
|
||||||
|
|
||||||
|
typedef struct
|
||||||
|
{
|
||||||
|
uint32_t prot_active; /* prot_elm_active_mask():bit i = 元件 i 动作 */
|
||||||
|
uint32_t prot_pick; /* prot_elm_pick_mask():bit i = 元件 i 启动 */
|
||||||
|
uint32_t adc_flags; /* adc_health_flags():ADC_HF_* 电平故障位 */
|
||||||
|
uint8_t iec104_run; /* 1 = IEC 104 链路 RUN(iec104_serv_link_run()) */
|
||||||
|
uint8_t pad[3]; /* 显式补齐:结构体大小固定 16 B,主机/目标端一致 */
|
||||||
|
} sb_led_in_t;
|
||||||
|
|
||||||
|
/* ============================================================================
|
||||||
|
* 三、求值入口
|
||||||
|
* ==========================================================================*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把状态快照折算成屏板 LED 32 位掩码(纯函数,映射表唯一出处见本头 §一)
|
||||||
|
* 入口参数:p_in - 状态快照;**允许 NULL**:等价于全零输入(只跑运行灯心跳——
|
||||||
|
* 心跳证明的是"这段代码活着",与别的状态无关,不该被空输入连坐)
|
||||||
|
* now_ms - 当前毫秒时基(运行灯相位唯一依赖;osKernelGetTickCount 同域)
|
||||||
|
* 返回值:32 位 LED 掩码(bit i = 灯 ID i;bit0 与 ID 6~19 恒 0,直接进帧 B info[0..3])
|
||||||
|
* 限定条件:任意上下文(纯计算:无分支副作用、不读写任何静态量、时基回绕安全——
|
||||||
|
* 回绕瞬间至多把一个半周期截短或延长 1 拍,500 ms 的心跳灯看不出来)
|
||||||
|
* 函数说明:闪烁相位 = (now_ms / 500) 的奇偶,偶 = 亮。上电 first 500 ms 为亮,
|
||||||
|
* "刚上电灯就在闪"是最直观的自检信号。各状态位相互独立,任一非零即置位,
|
||||||
|
* 无优先级、无互斥(告警与通信可以同时亮,那是两件同时在真发生的事)。
|
||||||
|
*/
|
||||||
|
uint32_t sb_led_map_eval(const sb_led_in_t *p_in, uint32_t now_ms);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* SB_LED_MAP_H */
|
||||||
@@ -0,0 +1,535 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:录波环(故障录波 · 数据面纯逻辑层:滚动环 + 触发冻结状态机 + 已封存区读口)
|
||||||
|
* 模块功能:见 App/algo/wave_ring.h 的文件头注释块——所有权矩阵、封存握手、跨任务安全、
|
||||||
|
* 形态钉死与预算全部写在那里,本文件不重抄一遍,只在实现处补「为什么这么写」。
|
||||||
|
* 本文件是纯逻辑:只 include App/algo/wave_ring.h(它带 wave_store.h),
|
||||||
|
* 不 include HAL / CMSIS / RTOS 头、不调 FatFs、不建任何 RTOS 对象、不打日志。
|
||||||
|
* 临界区也不需要:写者只有 adcTask 一个,读者只碰 SEALED 期间冻结的已封存区,
|
||||||
|
* §5.2 第一条陷阱(无临界区的活缓冲窗口)在本层根本不成立,因为没有那种入口。
|
||||||
|
* 适用平台:STM32F407ZGTx(Cortex-M4,小端);主机侧单测 test/host/wave_ring/
|
||||||
|
* 与 test/host/wave_trig/
|
||||||
|
* 作者:DTU 项目组
|
||||||
|
* 创建日期:2026-10-29(故障录波 task-1 · 数据面纯逻辑层)
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-29 DTU 项目组 创建
|
||||||
|
* v1.1 2026-10-29 DTU 项目组 触发挂号改单字节载荷编码(lead 评审:三段写在
|
||||||
|
* adcTask 抢占下会把段头的触发源 / 元件号录成上一段的旧值,溯源字段作废)
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "wave_ring.h"
|
||||||
|
|
||||||
|
/* ARMCC 的 stdint.h 不提供 NULL,而主机侧 gcc / MSVC 会传递性把它拉进来 ——
|
||||||
|
* 这个差别主机单测照不出来(2026-10-29 台基线实跑 /keil-build 才暴露:本层
|
||||||
|
* wave_ring.c 报 22 条 identifier "NULL" 未定义)。与 App/algo/sample_ring.c、App/algo/freq.c
|
||||||
|
* 同一口径:谁用 NULL 谁自己 include stddef.h,不往公共头里塞(少波及别人的 TU)。 */
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
/* 触发挂号的载荷编码(v1.1):挂号量必须**一次单字节存写**才原子,所以把载荷本身编进
|
||||||
|
* 那一个字节 —— 0 = 无请求,非 0 = (触发源 << 位移) | 元件号。触发源取值 1 到 3 占高 3 位、
|
||||||
|
* 元件号取 prot_elm 的 picked 位号(uint32 位图 → 0 到 31)占低 5 位,合起来 8 位够用。
|
||||||
|
* 本层双端编译(板端 ARMCC 与模拟器),不能引 FreeRTOS / CMSIS 的临界区内在函数,
|
||||||
|
* 所以「把三个字节压成一个字节」是唯一既原子又不依赖平台的写法。 */
|
||||||
|
#define WAVE_RG_REQ_SHIFT (5u) /* 载荷里触发源的低位置 */
|
||||||
|
#define WAVE_RG_REQ_ELM_MSK (0x1Fu) /* 载荷里元件号的位掩码(低 5 位) */
|
||||||
|
#define WAVE_RG_REQ_ELM_MAX (32u) /* 元件号合法上界(不含),picked 位图只有 32 位 */
|
||||||
|
|
||||||
|
/* ===== 一、静态对象(全部文件作用域 static,禁堆分配;落位与字节数见头注第七节) ===== */
|
||||||
|
|
||||||
|
/* 数据区:按通道分列,8 列 × 320 点 × 2 字节 = 5120 字节(§7 预算的大头)。
|
||||||
|
* 不许改成 float、不许按帧交错(§12:形态错一次 RAM 翻倍 + 跨步取数)。
|
||||||
|
* 不放在 CCM:DMA 看不见它,且 .sct 里 RW_CCM 只收 heap_4.o,普通 .bss 天然落 SRAM1 */
|
||||||
|
static int16_t s_ring[WAVE_RING_CH][WAVE_RING_PTS];
|
||||||
|
|
||||||
|
/* 已封存段的段头:latch 之前只有环侧字段有效,latch 之后才是完整段头。
|
||||||
|
* 这是 defaultTask 与 uiTask 能看到的唯一「段头视图」——内部指针一律不外露 */
|
||||||
|
static wave_seg_meta_t s_sealed;
|
||||||
|
|
||||||
|
/* 观测计数(§9 周期行的 seg= / ovf= / drop= / err= 全从这里取,落盘道不数第二份账) */
|
||||||
|
static uint32_t s_seg_total; /* 成功 ACK 的段数 */
|
||||||
|
static uint32_t s_ovf_cnt; /* 被忽略的触发数 */
|
||||||
|
static uint32_t s_drop_cnt; /* SEALED 窗口丢弃的点数(单位 = 点,一次丢一拍 = 加 8) */
|
||||||
|
static uint32_t s_err_cnt; /* 落盘失败 + 滞留超时自弃 */
|
||||||
|
static uint32_t s_last_t0_ms; /* 最近一段段首毫秒 */
|
||||||
|
static uint32_t s_last_seq; /* 最近一段段首帧序号 */
|
||||||
|
|
||||||
|
/* 状态机与索引 */
|
||||||
|
static uint16_t s_head; /* 下一个写入点(滚动位置) */
|
||||||
|
static uint16_t s_base; /* 已封存 / 正在录的段首在环内的下标(段内点 0) */
|
||||||
|
static uint16_t s_post_left; /* 后触发还差多少点封段 */
|
||||||
|
static uint16_t s_cool_left; /* 冷却剩余拍数 */
|
||||||
|
static uint16_t s_seal_hold; /* SEALED 已滞留拍数 */
|
||||||
|
static uint8_t s_state; /* wave_ring_state_t */
|
||||||
|
/* 触发请求挂号:0 = 无请求;非 0 = (触发源 << WAVE_RG_REQ_SHIFT) | 元件号。
|
||||||
|
* 刻意只用这一个字节承载「是谁要录波」:旧实现分三个字节写(先写挂号位、后写载荷),
|
||||||
|
* adcTask 抢在两条指令之间就会把上一段的旧触发源 / 旧元件号抄进本段 —— 而这两个字段是
|
||||||
|
* 这段记录唯一的溯源信息(现场靠它与 /log/start.log 对表),错一次事后无从发现。
|
||||||
|
* 单字节存写在 Cortex-M4 上原子,写序不再有意义,所以不需要任何顺序假设或屏障。 */
|
||||||
|
static uint8_t s_req;
|
||||||
|
static uint8_t s_trig_src; /* 本段实际用的触发源(钉基址时从请求抄过来) */
|
||||||
|
static uint8_t s_trig_elm; /* 本段实际用的元件序号 */
|
||||||
|
static uint8_t s_meta_ready; /* 1 = 段头已 latch */
|
||||||
|
static uint8_t s_seg_idx; /* 本次上电的轮转下标(跨上电接号由落盘道自持,见头注) */
|
||||||
|
|
||||||
|
/* 语义号 → 列号(本波恒等,但把这条恒等式收在一处:二期带 TEMP 时只改这张表,
|
||||||
|
* 写死下标的调用点会静默错位,走本函数的不会) */
|
||||||
|
static const int8_t s_col_of_sem[(uint8_t)WAVE_ST_CH_MAX] = {
|
||||||
|
0, 1, 2, 3, 4, 5, 6, 7, -1
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ===== 二、内部小工具 ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:环内下标 +n 后取模
|
||||||
|
* 入口参数:a_idx 当前下标;a_step 步长(小于环长)
|
||||||
|
* 返 回 值:取模后的下标 uint16_t
|
||||||
|
* 限定条件:仅本文件;a_idx 与 a_step 均在环长量级,无溢出风险
|
||||||
|
* 函数说明:环长 320 是 2 的幂吗?不是(320 = 64×5),所以老老实实取模,
|
||||||
|
* 不用位与——写错一次就是「看起来能跑、偶尔串段」的那种 bug。
|
||||||
|
*/
|
||||||
|
static uint16_t wave_rg_wrap(uint16_t a_idx, uint16_t a_step) {
|
||||||
|
uint16_t v = (uint16_t)(a_idx + a_step);
|
||||||
|
|
||||||
|
if (v >= (uint16_t)WAVE_RING_PTS) {
|
||||||
|
v = (uint16_t)(v - (uint16_t)WAVE_RING_PTS);
|
||||||
|
}
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把已封存段的「环侧字段」初始化(封存瞬间调,标定字段一律清零待 latch)
|
||||||
|
* 入口参数:a_now_ms 段首毫秒;a_frame_seq 段首帧序号
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:仅本文件;只在钉基址那一拍调一次
|
||||||
|
* 函数说明:先把 ch[] 全部清成「sem = 下标、其余 0」,是为了让**没有 latch** 的段
|
||||||
|
* 不可能带着上一段的系数被写进文件:sealed() 在未 latch 时本就是 0,
|
||||||
|
* 但清零让「万一有人绕过握手直接读 s_sealed」也读不出旧真值。
|
||||||
|
*/
|
||||||
|
static void wave_rg_seal_reset(uint32_t a_now_ms, uint32_t a_frame_seq) {
|
||||||
|
uint8_t i;
|
||||||
|
|
||||||
|
s_sealed.fs_hz = (uint16_t)WAVE_ST_FS_DEF;
|
||||||
|
s_sealed.pts_per_cycle = (uint8_t)WAVE_ST_PPC_DEF;
|
||||||
|
s_sealed.ch_count = (uint8_t)WAVE_RING_CH;
|
||||||
|
s_sealed.pts_per_ch = (uint16_t)WAVE_RING_PTS;
|
||||||
|
s_sealed.pre_pts = (uint16_t)WAVE_RING_PRE;
|
||||||
|
s_sealed.trig_src = s_trig_src;
|
||||||
|
s_sealed.trig_elm = s_trig_elm;
|
||||||
|
s_sealed.t0_ms = a_now_ms;
|
||||||
|
s_sealed.wall = 0u;
|
||||||
|
s_sealed.eng = (uint8_t)WAVE_ENG_PIN;
|
||||||
|
s_sealed.flags_seq = 0u;
|
||||||
|
s_sealed.clip_mask = 0u;
|
||||||
|
s_sealed.frame_seq = a_frame_seq;
|
||||||
|
for (i = 0u; i < (uint8_t)WAVE_ST_CH_MAX; i++) {
|
||||||
|
s_sealed.ch[i].sem = i;
|
||||||
|
s_sealed.ch[i].conv = 0u;
|
||||||
|
s_sealed.ch[i].bits = 0u;
|
||||||
|
s_sealed.ch[i].avail = 0u;
|
||||||
|
s_sealed.ch[i].k_q16 = 0;
|
||||||
|
s_sealed.ch[i].b_codes = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 三、初始化与压环 ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:复位环与全部状态
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:只能在 adcTask 调,且在开始压环之前(见头文件同函数注释)
|
||||||
|
* 函数说明:数据区显式清零而不是依赖 .bss 清零:本函数也可能在运行中被重新调
|
||||||
|
* (显式重配 / 单测复用),留旧数据就等于留下「上一段的尾巴混进下一段」的口子。
|
||||||
|
*/
|
||||||
|
void wave_ring_init(void) {
|
||||||
|
uint8_t c;
|
||||||
|
uint16_t i;
|
||||||
|
|
||||||
|
for (c = 0u; c < (uint8_t)WAVE_RING_CH; c++) {
|
||||||
|
for (i = 0u; i < (uint16_t)WAVE_RING_PTS; i++) {
|
||||||
|
s_ring[c][i] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s_head = 0u;
|
||||||
|
s_base = 0u;
|
||||||
|
s_post_left = 0u;
|
||||||
|
s_cool_left = 0u;
|
||||||
|
s_seal_hold = 0u;
|
||||||
|
s_state = (uint8_t)WAVE_RING_ST_IDLE;
|
||||||
|
s_req = 0u;
|
||||||
|
s_trig_src = (uint8_t)WAVE_TRIG_NONE;
|
||||||
|
s_trig_elm = 0u;
|
||||||
|
s_meta_ready = 0u;
|
||||||
|
s_seg_idx = 0u;
|
||||||
|
s_seg_total = 0u;
|
||||||
|
s_ovf_cnt = 0u;
|
||||||
|
s_drop_cnt = 0u;
|
||||||
|
s_err_cnt = 0u;
|
||||||
|
s_last_t0_ms = 0u;
|
||||||
|
s_last_seq = 0u;
|
||||||
|
wave_rg_seal_reset(0u, 0u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:压入一拍的 8 路原始码值
|
||||||
|
* 入口参数:p_codes 语义序数组(前 8 项);a_n 必须等于 WAVE_RING_CH
|
||||||
|
* 返 回 值:WAVE_ST_OK 已写入;WAVE_ST_DONE 本拍被丢弃(SEALED 窗口);WAVE_ST_E_PARAM 参数错 int32_t
|
||||||
|
* 限定条件:只能在 adcTask 调,每拍一次
|
||||||
|
* 函数说明:写的是「列优先」的 8 个分散地址(每列同一行)——这就是 §6.2 分列形态的代价,
|
||||||
|
* 换来的是落盘与回放都能按列连续搬,不用跨步长取数。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_push(const int16_t *p_codes, uint8_t a_n) {
|
||||||
|
uint8_t c;
|
||||||
|
|
||||||
|
if ((p_codes == NULL) || (a_n != (uint8_t)WAVE_RING_CH)) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
if (s_state == (uint8_t)WAVE_RING_ST_SEALED) {
|
||||||
|
/* 冻结窗口丢样本(§4.2):计数单位是点,一次丢一整拍 = WAVE_RING_CH 点 */
|
||||||
|
s_drop_cnt += (uint32_t)a_n;
|
||||||
|
return WAVE_ST_DONE;
|
||||||
|
}
|
||||||
|
for (c = 0u; c < (uint8_t)WAVE_RING_CH; c++) {
|
||||||
|
s_ring[c][s_head] = p_codes[c];
|
||||||
|
}
|
||||||
|
s_head = wave_rg_wrap(s_head, 1u);
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 四、触发、推进、交棒、ACK ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:投递一次录波请求
|
||||||
|
* 入口参数:a_src 触发源(1 手动 / 2 保护 / 3 bench);a_elm 元件序号(0 到 31)
|
||||||
|
* 返 回 值:WAVE_ST_OK 受理;WAVE_ST_DONE 被忽略(ovf 已加一);WAVE_ST_E_PARAM 触发源
|
||||||
|
* 非法或元件号越界 int32_t
|
||||||
|
* 限定条件:任意上下文(**只写一个字节**,理由见 s_req 声明处)
|
||||||
|
* 函数说明:这里判「非 IDLE 就拒」而不是「非 IDLE 就排队」——本波不做多段排队(已定裁定),
|
||||||
|
* 而且排队会把「最近一次故障」降级成「上上次故障」。
|
||||||
|
* s_req 已挂号时也拒:同一拍内多个元件同时 picked 只该录一段。
|
||||||
|
* 两条入参校验排在状态判断之前:非法调用是调用方的错,不该记进 ovf 账 ——
|
||||||
|
* ovf 的语义是「真来了第二次触发」。
|
||||||
|
* 元件号上界写死在这里:今天没有调用方会越界(picked 是 uint32 位图),这条是给
|
||||||
|
* 二期加元件的人留的牙 —— 载荷只有 5 位,越界必须当场拒,不许静默截断。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_request(uint8_t a_src, uint8_t a_elm) {
|
||||||
|
if ((a_src < (uint8_t)WAVE_TRIG_MANUAL) || (a_src > (uint8_t)WAVE_TRIG_BENCH)) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
if (a_elm >= (uint8_t)WAVE_RG_REQ_ELM_MAX) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
if ((s_state != (uint8_t)WAVE_RING_ST_IDLE) || (s_req != 0u)) {
|
||||||
|
s_ovf_cnt++;
|
||||||
|
return WAVE_ST_DONE;
|
||||||
|
}
|
||||||
|
/* 一次存写:触发源与元件号同字节落地。别的上下文要么看到「没请求」、要么看到
|
||||||
|
* 「完整的这次请求」,不存在读到半份载荷的中间态 */
|
||||||
|
s_req = (uint8_t)(((uint32_t)a_src << (uint32_t)WAVE_RG_REQ_SHIFT) | (uint32_t)a_elm);
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:推进一拍状态机
|
||||||
|
* 入口参数:a_now_ms 当前相对上电毫秒;a_frame_seq 当前帧序号
|
||||||
|
* 返 回 值:wave_ring_evt_t(本拍刚封段返回 WAVE_RING_EVT_SEALED) int32_t
|
||||||
|
* 限定条件:只能在 adcTask 调,每拍在 wave_ring_push 之后一次
|
||||||
|
* 函数说明:五个分支各管一件事,互不重叠:
|
||||||
|
* IDLE —— 消费挂号、钉段首基址、转 FREEZE
|
||||||
|
* FREEZE —— 后触发计点,计满转 SEALED
|
||||||
|
* SEALED —— 只计滞留,超时自弃(绝不让环永久焊死)
|
||||||
|
* COOL —— 递减冷却,到 0 回 IDLE
|
||||||
|
* 段首基址 = 触发那一拍写入点的「往前 160 点」,于是段内点 0 是最老的预触发点、
|
||||||
|
* 点 160 正好是触发后第一点;后触发的 160 点落在环的另一半(本来就即将被覆盖的
|
||||||
|
* 最老那 160 槽),所以「冻结」在物理上是**停止滚动覆盖**,不是停止写入。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_tick(uint32_t a_now_ms, uint32_t a_frame_seq) {
|
||||||
|
int32_t evt = (int32_t)WAVE_RING_EVT_NONE;
|
||||||
|
|
||||||
|
switch (s_state) {
|
||||||
|
case (uint8_t)WAVE_RING_ST_IDLE:
|
||||||
|
s_cool_left = 0u;
|
||||||
|
s_seal_hold = 0u;
|
||||||
|
{
|
||||||
|
/* 只读一次快照再解码:分两次读就会把两次请求的字段拼成一段的身份 */
|
||||||
|
uint8_t req = s_req;
|
||||||
|
|
||||||
|
if (req != 0u) {
|
||||||
|
/* 钉基址:本拍的样本已写进 (head-1),段首 = head - 预触发点数 */
|
||||||
|
s_trig_src = (uint8_t)((uint32_t)req >> (uint32_t)WAVE_RG_REQ_SHIFT);
|
||||||
|
s_trig_elm = (uint8_t)((uint32_t)req & (uint32_t)WAVE_RG_REQ_ELM_MSK);
|
||||||
|
s_req = 0u;
|
||||||
|
s_base = wave_rg_wrap(s_head, (uint16_t)((uint32_t)WAVE_RING_PTS - (uint32_t)WAVE_RING_PRE));
|
||||||
|
s_post_left = (uint16_t)WAVE_RING_POST;
|
||||||
|
s_meta_ready = 0u;
|
||||||
|
s_last_t0_ms = a_now_ms;
|
||||||
|
s_last_seq = a_frame_seq;
|
||||||
|
wave_rg_seal_reset(a_now_ms, a_frame_seq);
|
||||||
|
s_state = (uint8_t)WAVE_RING_ST_FREEZE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case (uint8_t)WAVE_RING_ST_FREEZE:
|
||||||
|
if (s_post_left > 0u) {
|
||||||
|
s_post_left--;
|
||||||
|
}
|
||||||
|
if (s_post_left == 0u) {
|
||||||
|
/* 封段:此刻起 s_head 正好回到 s_base,再写就覆盖段内点 0 —— 所以 push 开始丢 */
|
||||||
|
s_state = (uint8_t)WAVE_RING_ST_SEALED;
|
||||||
|
s_seal_hold = 0u;
|
||||||
|
evt = (int32_t)WAVE_RING_EVT_SEALED;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case (uint8_t)WAVE_RING_ST_SEALED:
|
||||||
|
/* 兜底:defaultTask 挂住 / 卷没挂载 / 落盘道被更高优先级的事挡住,都不能让环
|
||||||
|
* 永久冻结——下一次真故障无波可录是比丢一段更贵的代价(lead 裁定) */
|
||||||
|
s_seal_hold++;
|
||||||
|
if (s_seal_hold >= (uint16_t)WAVE_SEAL_TIMEOUT_MS) {
|
||||||
|
s_err_cnt++;
|
||||||
|
s_meta_ready = 0u;
|
||||||
|
s_seal_hold = 0u;
|
||||||
|
s_cool_left = (uint16_t)WAVE_RING_COOL_MS;
|
||||||
|
s_state = (uint8_t)WAVE_RING_ST_COOL;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
case (uint8_t)WAVE_RING_ST_COOL:
|
||||||
|
/* 冷却只在 ACK(或自弃)之后计时:它是二次抑制窗,不是冻结窗 */
|
||||||
|
if (s_cool_left > 0u) {
|
||||||
|
s_cool_left--;
|
||||||
|
}
|
||||||
|
if (s_cool_left == 0u) {
|
||||||
|
s_state = (uint8_t)WAVE_RING_ST_IDLE;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
/* 状态字节被写坏(跑飞 / 越界写)时回到 IDLE 而不是卡死:录波宁可多录也不能停 */
|
||||||
|
s_state = (uint8_t)WAVE_RING_ST_IDLE;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return evt;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:封存交棒——把封存瞬间事实锁进段头
|
||||||
|
* 入口参数:p_cal 封存瞬间事实(不可为 NULL)
|
||||||
|
* 返 回 值:WAVE_ST_OK 已锁存;WAVE_ST_E_STATE 不在待交棒态;WAVE_ST_E_PARAM 参数错 int32_t
|
||||||
|
* 限定条件:只能在 adcTask 调,只在 EVT_SEALED 那一拍调一次
|
||||||
|
* 函数说明:只 copy 封存瞬间的事实,不动环侧字段(点数 / 采样率 / 预触发这些
|
||||||
|
* 在封段那刻已按 §6.2 定死)。重复调用直接拒,不覆盖已有锁存:
|
||||||
|
* 半路换系数会让落盘道写进去的头与它已经在搬的数据不同源。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_latch_meta(const wave_seal_cal_t *p_cal) {
|
||||||
|
uint8_t i;
|
||||||
|
|
||||||
|
if (p_cal == NULL) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
if (p_cal->eng > (uint8_t)WAVE_ENG_PRI) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
if ((s_state != (uint8_t)WAVE_RING_ST_SEALED) || (s_meta_ready != 0u)) {
|
||||||
|
return WAVE_ST_E_STATE;
|
||||||
|
}
|
||||||
|
s_sealed.eng = p_cal->eng;
|
||||||
|
s_sealed.flags_seq = p_cal->flags_seq;
|
||||||
|
s_sealed.clip_mask = p_cal->clip_mask;
|
||||||
|
s_sealed.wall = p_cal->wall;
|
||||||
|
for (i = 0u; i < s_sealed.ch_count; i++) {
|
||||||
|
s_sealed.ch[i].conv = p_cal->ch[i].conv;
|
||||||
|
s_sealed.ch[i].bits = p_cal->ch[i].bits;
|
||||||
|
s_sealed.ch[i].avail = p_cal->ch[i].avail;
|
||||||
|
s_sealed.ch[i].k_q16 = p_cal->ch[i].k_q16;
|
||||||
|
s_sealed.ch[i].b_codes = p_cal->ch[i].b_codes;
|
||||||
|
}
|
||||||
|
s_meta_ready = 1u;
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:落盘 ACK
|
||||||
|
* 入口参数:a_result 0 = 成功;非 0 = 失败(短写 / 打开失败 / CRC 自弃)
|
||||||
|
* 返 回 值:WAVE_ST_OK 已解冻并起冷却;WAVE_ST_E_STATE 当前不在 SEALED int32_t
|
||||||
|
* 限定条件:只能由 defaultTask 调,一段一次
|
||||||
|
* 函数说明:无论成败都解冻 + 起冷却:错误只计数不冻结(环冻着的代价是下一次故障无波可录)。
|
||||||
|
* 成功才推进轮转号与段计数(失败段留同号,下次重写覆盖,不留半截文件)。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_write_done(int32_t a_result) {
|
||||||
|
if (s_state != (uint8_t)WAVE_RING_ST_SEALED) {
|
||||||
|
return WAVE_ST_E_STATE;
|
||||||
|
}
|
||||||
|
if (s_meta_ready == 0u) {
|
||||||
|
/* 没 latch 就没有「段」可 ACK:这时候段头从未就绪,落盘道根本拿不到
|
||||||
|
* wave_ring_sealed_meta(),绝不可能真写出文件。若在这里放过 ACK,
|
||||||
|
* 就会白推进轮转号(号 +1 而盘上没那个文件 = 凭空吃掉一个号)并把
|
||||||
|
* seg_total 报成一个没发生过的数(串口周期行的 seg=NN 从此对不上账)。
|
||||||
|
* 拒绝而不是记账,把「没交棒就 ACK」变成调用方看得见的错误。
|
||||||
|
* 本段的出口只有一个:滞留到 WAVE_SEAL_TIMEOUT_MS 自弃(那一路照实计 err)。 */
|
||||||
|
return WAVE_ST_E_STATE;
|
||||||
|
}
|
||||||
|
if (a_result == 0) {
|
||||||
|
s_seg_idx = wave_store_next_idx(s_seg_idx);
|
||||||
|
s_seg_total++;
|
||||||
|
} else {
|
||||||
|
s_err_cnt++;
|
||||||
|
}
|
||||||
|
s_meta_ready = 0u;
|
||||||
|
s_seal_hold = 0u;
|
||||||
|
s_cool_left = (uint16_t)WAVE_RING_COOL_MS;
|
||||||
|
s_state = (uint8_t)WAVE_RING_ST_COOL;
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 五、状态查询与已封存区读口 ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:查当前状态
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:wave_ring_state_t 取值 uint8_t
|
||||||
|
* 限定条件:任意上下文(只读单字节)
|
||||||
|
*/
|
||||||
|
uint8_t wave_ring_state(void) {
|
||||||
|
return s_state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:查「有已封存段可读」——跨任务唯一握手量
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:1 = SEALED 且段头已 latch;0 = 不可用 uint8_t
|
||||||
|
* 限定条件:任意上下文(只读单字节)
|
||||||
|
*/
|
||||||
|
uint8_t wave_ring_sealed(void) {
|
||||||
|
if ((s_state == (uint8_t)WAVE_RING_ST_SEALED) && (s_meta_ready != 0u)) {
|
||||||
|
return 1u;
|
||||||
|
}
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:取已封存段的段头快照
|
||||||
|
* 入口参数:p_out 输出(整份拷出)
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针;WAVE_ST_E_STATE 无就绪段 int32_t
|
||||||
|
* 限定条件:任意上下文(defaultTask / uiTask 只读)
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_sealed_meta(wave_seg_meta_t *p_out) {
|
||||||
|
uint16_t i;
|
||||||
|
const uint8_t *p_src;
|
||||||
|
uint8_t *p_dst;
|
||||||
|
|
||||||
|
if (p_out == NULL) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
if (wave_ring_sealed() == 0u) {
|
||||||
|
return WAVE_ST_E_STATE;
|
||||||
|
}
|
||||||
|
/* 逐字节拷出而不是 *p_out = s_sealed:结构体赋值在 ARMCC 上会调 __aeabi_memcpy,
|
||||||
|
* 这里没必要为一个赋值多拉一层依赖,量也就 136 字节 */
|
||||||
|
p_src = (const uint8_t *)&s_sealed;
|
||||||
|
p_dst = (uint8_t *)p_out;
|
||||||
|
for (i = 0u; i < (uint16_t)sizeof(wave_seg_meta_t); i++) {
|
||||||
|
p_dst[i] = p_src[i];
|
||||||
|
}
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:按通道读已封存区的连续点窗
|
||||||
|
* 入口参数:a_ch 列号;a_from 段内起点;a_cnt 期望点数(超出段尾自动夹);p_dst 输出
|
||||||
|
* 返 回 值:实际点数;WAVE_ST_E_PARAM / WAVE_ST_E_RANGE / WAVE_ST_E_STATE int32_t
|
||||||
|
* 限定条件:任意上下文,只读已封存区(本层没有任何读活缓冲的入口)
|
||||||
|
* 函数说明:按 s_base 取模展开,读者看到的是 320 点的线性数组。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_read_ch(uint8_t a_ch, uint16_t a_from, uint16_t a_cnt, int16_t *p_dst) {
|
||||||
|
uint16_t left;
|
||||||
|
uint16_t n;
|
||||||
|
uint16_t idx;
|
||||||
|
uint16_t i;
|
||||||
|
|
||||||
|
if ((p_dst == NULL) || (a_cnt == 0u)) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
if ((a_ch >= (uint8_t)WAVE_RING_CH) || (a_from >= (uint16_t)WAVE_RING_PTS)) {
|
||||||
|
return WAVE_ST_E_RANGE;
|
||||||
|
}
|
||||||
|
if (wave_ring_sealed() == 0u) {
|
||||||
|
return WAVE_ST_E_STATE;
|
||||||
|
}
|
||||||
|
left = (uint16_t)((uint16_t)WAVE_RING_PTS - a_from);
|
||||||
|
n = (a_cnt < left) ? a_cnt : left;
|
||||||
|
idx = wave_rg_wrap(s_base, a_from);
|
||||||
|
for (i = 0u; i < n; i++) {
|
||||||
|
p_dst[i] = s_ring[a_ch][idx];
|
||||||
|
idx = wave_rg_wrap(idx, 1u);
|
||||||
|
}
|
||||||
|
return (int32_t)n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读口的 wave_reader_fn 形态适配(落盘道直接把它交给打包游标)
|
||||||
|
* 入口参数:p_user 未用;其余同 wave_ring_read_ch(点数与起点是 uint32 形态)
|
||||||
|
* 返 回 值:实际点数;负 = WAVE_ST_E_* int32_t
|
||||||
|
* 限定条件:任意上下文,只读已封存区
|
||||||
|
* 函数说明:32 位入参先夹到 16 位形态再转调,越界一律报错不夹成 0 ——
|
||||||
|
* 夹成 0 会让落盘道以为「这一列读完了」而写出一个短段。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_read_cb(void *p_user, uint8_t a_ch, uint32_t a_from,
|
||||||
|
uint32_t a_cnt, int16_t *p_dst) {
|
||||||
|
(void)p_user;
|
||||||
|
|
||||||
|
if (a_cnt > (uint32_t)WAVE_RING_PTS) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
if (a_from > (uint32_t)WAVE_RING_PTS) {
|
||||||
|
return WAVE_ST_E_RANGE;
|
||||||
|
}
|
||||||
|
return wave_ring_read_ch(a_ch, (uint16_t)a_from, (uint16_t)a_cnt, p_dst);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:取观测计数快照
|
||||||
|
* 入口参数:p_out 输出(不可为 NULL)
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针 int32_t
|
||||||
|
* 限定条件:任意上下文(只读)
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_get_stat(wave_ring_stat_t *p_out) {
|
||||||
|
if (p_out == NULL) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
p_out->state = s_state;
|
||||||
|
p_out->trig_src = s_trig_src;
|
||||||
|
p_out->trig_elm = s_trig_elm;
|
||||||
|
p_out->meta_ready = s_meta_ready;
|
||||||
|
p_out->seg_idx = s_seg_idx;
|
||||||
|
/* 对外字段是 8 位:本波后触发最多 160 点,不会溢出;真溢出说明环长被改过,
|
||||||
|
* 饱和在 255 让它在日志里显眼,而不是绕回 0 假装快封段了 */
|
||||||
|
p_out->post_left = (s_post_left > 255u) ? 255u : (uint8_t)s_post_left;
|
||||||
|
p_out->rsv1 = 0u;
|
||||||
|
p_out->rsv2 = 0u;
|
||||||
|
p_out->cool_left = s_cool_left;
|
||||||
|
p_out->seal_hold = s_seal_hold;
|
||||||
|
p_out->seg_total = s_seg_total;
|
||||||
|
p_out->ovf_cnt = s_ovf_cnt;
|
||||||
|
p_out->drop_cnt = s_drop_cnt;
|
||||||
|
p_out->err_cnt = s_err_cnt;
|
||||||
|
p_out->last_t0_ms = s_last_t0_ms;
|
||||||
|
p_out->last_seq = s_last_seq;
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:语义通道号 → 本环列号
|
||||||
|
* 入口参数:a_sem 语义号
|
||||||
|
* 返 回 值:列号;WAVE_ST_E_RANGE = 该语义不录 int32_t
|
||||||
|
* 限定条件:任意上下文(纯计算)
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_col(uint8_t a_sem) {
|
||||||
|
if (a_sem >= (uint8_t)WAVE_ST_CH_MAX) {
|
||||||
|
return WAVE_ST_E_RANGE;
|
||||||
|
}
|
||||||
|
if (s_col_of_sem[a_sem] < 0) {
|
||||||
|
return WAVE_ST_E_RANGE;
|
||||||
|
}
|
||||||
|
return (int32_t)s_col_of_sem[a_sem];
|
||||||
|
}
|
||||||
@@ -0,0 +1,406 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:录波环形缓冲层(故障录波 · 8 路 × 320 点原始码值环 + 触发冻结状态机)
|
||||||
|
* 模块功能:按拍把 8 路电气量的原始码值压进环形缓冲,在「预触发 + 后触发」窗口计满后
|
||||||
|
* 把这一段冻结成只读的已封存区,等落盘道取走并明确 ACK 之后才解冻。口径属主 =
|
||||||
|
* docs/子系统设计/故障录波设计说明.md §4.2(段长与冻结语义)、§5.1(任务所有权)、
|
||||||
|
* §5.2(三条陷阱)、§6.2 与 §6.3(段格式;本层的已封存区就是数据区的字节形态)、
|
||||||
|
* §7(预算与落位)、§12(风险表)。本文件是它的机器可执行形态;冲突以设计文档
|
||||||
|
* 为准并回报 lead,不在代码里单方面「修正」。
|
||||||
|
* 纯逻辑层纪律:不 include HAL / CMSIS / RTOS 头、不建任务、不打日志、不碰文件系统,
|
||||||
|
* 也不链 App/algo/adc_cal —— 折算档位、有效性位图、削顶位图、epoch 秒与每路
|
||||||
|
* k_q16 / b_codes 由 adcTask 在「刚封存那一拍」作为参数交棒(见 wave_ring_latch_meta
|
||||||
|
* 与下面的「封存握手顺序」),本层只负责把它们随段头一起锁存。因此本层可被
|
||||||
|
* 主机侧单测 test/host/wave_ring/ 与 test/host/wave_trig/ 原样纳入断言、零桩。
|
||||||
|
* 与 sample_ring 的关系(为什么另起一个环):App/algo/sample_ring.c 是相量与保护的
|
||||||
|
* 上游、float 形态、深度 256,且它的 sample_ring_window() 没有临界区
|
||||||
|
* (§5.2 第一条陷阱)。录波要的是 int16 原始码值、320 点、跨任务只读,复用那个环
|
||||||
|
* 要么改它的形态与深度(动相量链),要么在跨任务读上继续裸奔 —— 两条都比另起
|
||||||
|
* 一个环贵,故本波解耦(§2.1 第 3 条结构约束)。
|
||||||
|
*
|
||||||
|
* 形态钉死(§7 预算 + §12 风险表,动这里之前先读):
|
||||||
|
* - 元素类型 int16,不许 float:float 让环从 5120 字节变 10240 字节,直接顶穿 §7 的
|
||||||
|
* 约 5.9 KB 静态增量目标;
|
||||||
|
* - 按通道分列(一路连续 320 点),不按帧交错:回放页只画一路、PC 重算全周傅氏也逐路
|
||||||
|
* 独立,交错布局两种用法都要跨步长取数(§6.2 末段「为什么按通道分列」);
|
||||||
|
* - 通道数固定 8,TEMP 不录:温度是片内 ADC 的 12 位单极性原始值、非线性且未参与线性
|
||||||
|
* 标定(docs/任务规划/数据可信.md §7 已定案),进录波只多占 640 字节,并让回放页多一
|
||||||
|
* 格会被误读成电气量的通道;带 TEMP 属二期,且必须连 flags_seq 与 clip_mask 一起升
|
||||||
|
* 版本(§6.2 那两行的括注);
|
||||||
|
* - 落位 SRAM1:本层全部是普通文件级静态,链接脚本 MDK-ARM/STM32F407-Demo/
|
||||||
|
* STM32F407-Demo.sct 把 RW_IRAM1 的 .ANY (+RW +ZI) 收在 0x20000000,CCM 只收
|
||||||
|
* heap_4.o 与 .noinit → 不加任何段属性就落在 SRAM1。CCM 只剩 7904 字节没有余量,
|
||||||
|
* 而本环不是 DMA 可见对象,也没有非进 CCM 不可的理由。
|
||||||
|
*
|
||||||
|
* 封存握手顺序(唯一合法次序,写死在这里,接线道与后续改动都必须照此):
|
||||||
|
* 1) adcTask 每拍 wave_ring_push() 然后 wave_ring_tick();
|
||||||
|
* 2) tick 计满后触发点返回 WAVE_RING_EVT_SEALED —— 此刻状态进 WAVE_RING_ST_SEALED 但
|
||||||
|
* **尚未就绪**,wave_ring_sealed() 仍返 0;
|
||||||
|
* 3) adcTask 在**同一拍**现取封存瞬间事实(adc_cal_k_q16 / adc_cal_flags / sys_clock /
|
||||||
|
* 削顶位图)并调 wave_ring_latch_meta() 一次 —— 只在第 2 步那一拍取一次,
|
||||||
|
* 既不每拍读 adc_cal(1 kHz 下 8 路白读),也不低频刷新(低频刷新等于快照可能
|
||||||
|
* 来自标定之前的旧值,正是 §12「标定落地后老文件被解释错」的入口);
|
||||||
|
* 4) 此后 wave_ring_sealed() 才返 1,defaultTask 才准开文件落盘;meta 未就绪时
|
||||||
|
* defaultTask 必须把本段当「不可用」处理并计 err,**不许拿半份快照落盘**;
|
||||||
|
* 5) defaultTask 搬完(成功或失败都算搬完)调 wave_ring_write_done(result) 明确 ACK;
|
||||||
|
* ACK 之前冻结一直有效、新触发一律只加 ovf;ACK 之后才起冷却抑制窗。
|
||||||
|
* 为什么必须用状态而不是用时间来挡(lead 裁定 2026-10-29):若解冻靠计时,会出现
|
||||||
|
* 「冷却走完、adcTask 重新推进写指针,而 defaultTask 还在读那份已封存区」→
|
||||||
|
* 半新半旧的段落进文件且没有任何错误码 —— §5.2 第一条陷阱的翻版。
|
||||||
|
* 兜底:WAVE_RING_ST_SEALED 滞留超过 WAVE_SEAL_TIMEOUT_MS(写者没来取:卷未挂载、
|
||||||
|
* 落盘链卡死都算)→ 自弃本段、err 计数加一、走冷却解冻。**绝不许让录波把环永久焊死**,
|
||||||
|
* 那会让下一次真故障无波可录。
|
||||||
|
*
|
||||||
|
* 跨任务安全(§5.1 与 §5.2 第一条;本层的唯一并发契约):
|
||||||
|
* 写者只有 adcTask,读者是 defaultTask 与 uiTask,因此只暴露「已封存」标志与已封存区
|
||||||
|
* 读口,不给任何跨任务读活缓冲的路径:
|
||||||
|
* 只能在 adcTask 调:wave_ring_init、wave_ring_push、wave_ring_tick、
|
||||||
|
* wave_ring_latch_meta;
|
||||||
|
* 只能由文件系统唯一写者 defaultTask 调:wave_ring_write_done;
|
||||||
|
* 任意上下文可调:wave_ring_request(只投一个字节,protectTask / uiTask /
|
||||||
|
* bench 都走它)、wave_ring_state、wave_ring_sealed、
|
||||||
|
* wave_ring_sealed_meta、wave_ring_read_ch、wave_ring_read_cb、
|
||||||
|
* wave_ring_get_stat、wave_ring_col。
|
||||||
|
* 为什么这样就够、不需要临界区:单字节状态量与请求量在 Cortex-M4 上单字读写天然原子;
|
||||||
|
* sealed 为 1 期间已封存区对 adcTask 是只读不写(写指针被冻在窗口之外),读者看到的数据
|
||||||
|
* 不会在脚下变;请求走「单字投递 + 下一拍由 adcTask 自己消费」,与 App/algo/prot_elm.c
|
||||||
|
* 的 prot_elm_request_reset() 同一模式。
|
||||||
|
* 「单字」是硬要求不是措辞(lead 评审 2026-10-29):投递方是 protectTask(Normal)与
|
||||||
|
* uiTask(BelowNormal),消费者是 adcTask(AboveNormal,会**抢占**它们)。旧实现先写挂号位
|
||||||
|
* 再写触发源与元件号(三个字节、两条指令的窗口),adcTask 抢在中间就会把上一段的旧触发源
|
||||||
|
* 与旧元件号抄进本段 —— 波形没错,错的恰好是这段记录唯一的溯源字段(现场靠它与
|
||||||
|
* /log/start.log 对表),一次故障只录一段、错过事后无从发现。现在载荷本身编进那一个字节
|
||||||
|
* (0 = 无请求,非 0 = 触发源占高位 | 元件号占低 5 位),一次存写要么看不见、要么看见完整
|
||||||
|
* 一份,不需要顺序假设,也不需要任何平台相关的屏障或临界区(本层双端编译,禁止引
|
||||||
|
* FreeRTOS / CMSIS 内在函数)。
|
||||||
|
*
|
||||||
|
* 观测计数的已知容差(**不修、只入档**,lead 裁定 2026-10-29):ovf 与 err 两个计数是跨上下文
|
||||||
|
* 的读-改-自增(ovf 由任意上下文的拒绝路径加、err 由 adcTask 的超时自弃与 defaultTask 的落盘
|
||||||
|
* 失败分别加),本层不设临界区,所以在并发拒绝 / 并发失败时理论上可能少计一次。定性成
|
||||||
|
* **观测计数的已知容差**:它们只用于趋势与判读,不作为任何判据的唯一依据 —— 要坐实「冷却期
|
||||||
|
* 内确实来了第二次触发」,同时看 armed 位与 /log/start.log 的时刻,别只盯 ovf 数字。
|
||||||
|
*
|
||||||
|
* 触发与冻结语义(§4.2,严格按此实现,别「顺手优化」):
|
||||||
|
* IDLE →(触发)FREEZE:段首基址当拍钉死(预触发区从此不再被覆盖,后触发区继续写入),
|
||||||
|
* 后触发计满 160 点 → SEALED:整段只读,新样本一律丢弃并计 drop,等 meta latch 与落盘
|
||||||
|
* ACK → (ACK)COOL:恢复正常滚动写入,但本窗口内新触发只加 ovf 不重录 → IDLE。
|
||||||
|
* 冻结期间丢的是录波样本:sample_ring、相量、保护照常跑,本层不碰它们、也无权影响它们。
|
||||||
|
*
|
||||||
|
* 适用平台:STM32F407ZGTx(Cortex-M4,小端);主机侧单测 test/host/wave_ring/、
|
||||||
|
* test/host/wave_trig/
|
||||||
|
* 作者:DTU 项目组
|
||||||
|
* 创建日期:2026-10-29(故障录波 task-1 · 数据面纯逻辑层)
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-29 DTU 项目组 创建:8 路 × 320 点 int16 分列环、请求/冻结/封段/ACK/
|
||||||
|
* 冷却状态机(含 SEALED 滞留超时自弃)、段头锁存与
|
||||||
|
* 已封存区按通道读口
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef WAVE_RING_H
|
||||||
|
#define WAVE_RING_H
|
||||||
|
|
||||||
|
/* 本头对外只暴露 stdint 的类型;文件里出现的 NULL 全在注释内(「不可为 NULL」这类
|
||||||
|
* 约束说明),所以这里刻意不 include stddef.h —— 需要 NULL 的是包含本头的 .c,
|
||||||
|
* 它们自己 include(台基线实测:ARMCC 的 stdint.h 不带 NULL)。 */
|
||||||
|
#include <stdint.h>
|
||||||
|
#include "wave_store.h" /* 同层配对头:wave_seg_meta_t / wave_ch_desc_t / wave_reader_fn /
|
||||||
|
WAVE_ST_* 返回码(本层复用同一套 int 返回码通道,不另发明) */
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ===== 一、环几何(数值属主 = docs/子系统设计/故障录波设计说明.md §4.2 与 §7) ===== */
|
||||||
|
|
||||||
|
/* 每通道点数 = 预触发 8 周波 + 后触发 8 周波 = 160 + 160 = 320 点(1 kHz 下 320 ms) */
|
||||||
|
#define WAVE_RING_PTS (320u)
|
||||||
|
/* 预触发点数:触发点之前保留 8 周波;回放画触发光标的位点就是这个数(也是段内触发点下标) */
|
||||||
|
#define WAVE_RING_PRE (160u)
|
||||||
|
/* 后触发点数:触发点之后采满 8 周波才封段 */
|
||||||
|
#define WAVE_RING_POST (160u)
|
||||||
|
/* 通道数 = 8(TEMP 不录,理由见本文件头注);必须与 WAVE_ST_CH_NUM 相等,单测里钉住 */
|
||||||
|
#define WAVE_RING_CH (8u)
|
||||||
|
/* 冷却抑制窗:ACK 之后这么多拍内新触发只加 ovf 不重录(§4.2「只记第一次」)。
|
||||||
|
* 500 拍 = 500 ms @1 kHz。注意:「覆盖一轮落盘窗口」这件事由 ACK 保证,本数值只是
|
||||||
|
* ACK 之后的二次抑制(防同一次故障的抖动重复触发),待台架核。 */
|
||||||
|
#define WAVE_RING_COOL_MS (500u)
|
||||||
|
/* 已封存滞留上限:SEALED 状态超过这么多拍仍没人来取(卷未挂载、落盘链卡死都算)
|
||||||
|
* → 自弃本段 + err 计数加一 + 走冷却解冻。2000 拍 = 2 s @1 kHz,待台架核。
|
||||||
|
* 判据是 tick 的推进次数(也就是 adcTask 的存活),不是墙上时间:adcTask 自己挂了
|
||||||
|
* 就轮到看门狗处理,本层不越权。 */
|
||||||
|
#define WAVE_SEAL_TIMEOUT_MS (2000u)
|
||||||
|
|
||||||
|
/* 环数据区字节数:int16 × 320 × 8 = 5120(§7 预算里最大的一项;动它等于动预算) */
|
||||||
|
#define WAVE_RING_BYTES (WAVE_RING_PTS * WAVE_RING_CH * (uint32_t)sizeof(int16_t))
|
||||||
|
|
||||||
|
/* ===== 二、类型 ===== */
|
||||||
|
|
||||||
|
/* wave_ring_tick 的事件:返回值语义是「本拍发生了什么」,不是错误 */
|
||||||
|
typedef enum {
|
||||||
|
WAVE_RING_EVT_NONE = 0, /* 正常推进,无事件 */
|
||||||
|
WAVE_RING_EVT_SEALED = 1 /* 本拍刚计满后触发:**adcTask 必须在本拍调
|
||||||
|
* wave_ring_latch_meta() 交棒封存瞬间事实**;
|
||||||
|
* 不调则本段永不就绪,最终由 WAVE_SEAL_TIMEOUT_MS
|
||||||
|
* 自弃(err 加一),可从串口周期行看到 */
|
||||||
|
} wave_ring_evt_t;
|
||||||
|
|
||||||
|
/* 环状态。迁移只发生在 wave_ring_tick 与 wave_ring_write_done 里(单写者) */
|
||||||
|
typedef enum {
|
||||||
|
WAVE_RING_ST_IDLE = 0, /* 滚动:新样本覆盖最旧点,接受触发 */
|
||||||
|
WAVE_RING_ST_FREEZE = 1, /* 已触发:段首基址已钉死(预触发区不再被覆盖),
|
||||||
|
后触发区继续写入,直到计满 WAVE_RING_POST 点 */
|
||||||
|
WAVE_RING_ST_SEALED = 2, /* 已封存:整段只读,新样本一律丢弃并计 drop;
|
||||||
|
等 meta latch、等 defaultTask 落盘并 ACK */
|
||||||
|
WAVE_RING_ST_COOL = 3 /* 冷却:已 ACK 解冻、恢复正常滚动,但新触发只加 ovf */
|
||||||
|
} wave_ring_state_t;
|
||||||
|
|
||||||
|
/* 封存瞬间必须由 adcTask 交棒的事实(本层不链 App/algo/adc_cal,全靠这份入参)。
|
||||||
|
* 段长 / 采样率 / 每周波点数 / 预触发点数 / 触发源与序号 / t0_ms / frame_seq 由本层自己填
|
||||||
|
* (环就是这些数的物理实现,让调用方填只会多一个「忘了填」的失败面)。 */
|
||||||
|
typedef struct {
|
||||||
|
uint8_t eng; /* 折算档位(wave_eng_t:0 引脚域 / 1 二次值 / 2 一次值) */
|
||||||
|
uint8_t flags_seq; /* bit s = 语义通道 s 的 ADC_CAL_F_VALID(§6.3) */
|
||||||
|
uint16_t clip_mask; /* bit s = 语义通道 s 的 ADC_CAL_F_CLIPPED(V0.1 恒 0xE6) */
|
||||||
|
uint32_t wall; /* 对时后的 epoch 秒(未对时 0;未对时就写 0,不许拿上电毫秒冒充) */
|
||||||
|
wave_ch_desc_t ch[WAVE_ST_CH_MAX]; /* 通道表前 8 项的 avail / k_q16 / b_codes:
|
||||||
|
必须取 adc_cal_k_q16() 的 int32 原值,
|
||||||
|
不许经 adc_cal_k_uv() 的 float 回算(§6.3:
|
||||||
|
float 在 5.28e7 量级掉尾数);
|
||||||
|
conv 与 bits 也在这里给(本层无从知道两片
|
||||||
|
转换器的归属);sem 由本层按「表内语义号 == 下标」
|
||||||
|
自己钉,传进来的 sem 被忽略 */
|
||||||
|
} wave_seal_cal_t;
|
||||||
|
|
||||||
|
/* 观测快照(给 §9 那条周期行 wave: 与主机单测;字段序 ≈ 日志序,别随手换) */
|
||||||
|
typedef struct {
|
||||||
|
uint8_t state; /* wave_ring_state_t */
|
||||||
|
uint8_t trig_src; /* 当前/最近一段的触发源(wave_trig_src_t;未触发为 0) */
|
||||||
|
uint8_t trig_elm; /* 触发元件序号(prot_elm 实例下标;手动与 bench 为 0) */
|
||||||
|
uint8_t meta_ready; /* 1 = 段头已 latch 可落盘;SEALED 期间为 0 就是「本段不可用」 */
|
||||||
|
uint8_t seg_idx; /* 下一个要写的段号 0..99(轮转号由本层持有,defaultTask 照它取名) */
|
||||||
|
uint8_t post_left; /* 后触发还差多少点封段(FREEZE 期间有意义;>255 时饱和在 255) */
|
||||||
|
uint8_t rsv1; /* 对齐补齐 */
|
||||||
|
uint8_t rsv2; /* 对齐补齐 */
|
||||||
|
uint16_t cool_left; /* 冷却剩余拍数 */
|
||||||
|
uint16_t seal_hold; /* 本段已在 SEALED 滞留多少拍(与 WAVE_SEAL_TIMEOUT_MS 对照) */
|
||||||
|
uint32_t seg_total; /* 累计成功 ACK 的段数(给日志 seg=NN 与对账) */
|
||||||
|
uint32_t ovf_cnt; /* 被丢弃的触发数:ACK 前重复触发 + 冷却期内触发(§9 ovf=) */
|
||||||
|
uint32_t drop_cnt; /* 冻结窗口丢弃的录波点数,单位 = 点不是拍(§9 drop=) */
|
||||||
|
uint32_t err_cnt; /* 落盘失败(write_done 非 0,含短写与 CRC 自弃)
|
||||||
|
+ SEALED 滞留超时自弃 的次数(§9 err=) */
|
||||||
|
uint32_t last_t0_ms; /* 最近一段段首相对上电毫秒 */
|
||||||
|
uint32_t last_seq; /* 最近一段段首帧序号(与串口周期行的 frames 对账) */
|
||||||
|
} wave_ring_stat_t;
|
||||||
|
|
||||||
|
/* ===== 三、生命周期(写者侧) ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:复位环与全部状态(清 5120 字节数据区 + 索引 + 状态机 + 观测计数 + 轮转号)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:**只能在 adcTask 调**,且只能在开始压环之前调(启动期或显式重配)。
|
||||||
|
* 内核启动前调用也安全:本函数不调任何 RTOS API、不建对象,纯数组清零
|
||||||
|
* (平台约束「osKernelStart 之前禁止创建类 API」对本层不适用)
|
||||||
|
* 函数说明:观测计数(seg_total / ovf_cnt / drop_cnt / err_cnt)与轮转号一并归零:
|
||||||
|
* 重启录波就是重新起账,不留旧账,免得串口周期行的累计数与段文件对不上。
|
||||||
|
* 轮转号归零意味着**本层的 seg_idx 只是本次上电的本地计数**(供日志与首轮参考),
|
||||||
|
* 不是盘上真值:跨上电的权威下标必须由落盘道自持——启动扫目录用
|
||||||
|
* wave_store_idx_from_name 重建、每次写成功后用 wave_store_next_idx 推进。
|
||||||
|
* 若照本层归零后的 0 直接取名,重启后第一笔覆盖的就是上次上电的**最新**段,
|
||||||
|
* 与轮转「新证据优先」的初衷正相反(§6.2 轮转语义)。
|
||||||
|
*/
|
||||||
|
void wave_ring_init(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:压入一拍的 8 路原始码值(数据面的唯一写入路径)
|
||||||
|
* 入口参数:p_codes 8 路码值数组,顺序 = adc_semantic_t 的前 8 项
|
||||||
|
* (UA UB UC UN IA IB IC IN,即语义号升序,与段文件通道表列序
|
||||||
|
* 同一口径,§6.3)
|
||||||
|
* a_n 数组长度,必须等于 WAVE_RING_CH
|
||||||
|
* 返 回 值:WAVE_ST_OK = 已写入;WAVE_ST_DONE = 本拍样本被丢弃(SEALED 窗口,drop 已加一);
|
||||||
|
* WAVE_ST_E_PARAM = 空指针或长度不对 int32_t
|
||||||
|
* 限定条件:**只能在 adcTask 调**,每拍一次(1 kHz);不得从其它任务或 ISR 调。
|
||||||
|
* 尤其禁止图省事挂在 Src/freertos.c 的 TIM3 回调里(adc_tick_irq_notify()
|
||||||
|
* 旁边):那是 ISR 上下文,且 ISR 比调度器早约 100 ms 启动
|
||||||
|
* (docs/子系统设计/看门狗设计说明.md §10.1),本层没有「消费者就绪」门,
|
||||||
|
* 挂上去就是拿 ISR 改状态机。
|
||||||
|
* 函数说明:IDLE、FREEZE、COOL 三态都写入;SEALED 一律丢弃并计数 —— 这就是 §4.2
|
||||||
|
* 「冻结期间丢的是录波样本」,不是丢采样链路:sample_ring、相量、保护照常被
|
||||||
|
* adcTask 喂,本层无权也不该影响它们。
|
||||||
|
* 码值是转换器原始输出:TPAFE 为 16 位补码双极性、片内 ADC 为 0..4095 原样
|
||||||
|
* 正数,**不做中点偏置、不做折算**(§6.3「样本编码」行:加偏置等于伪造双极性)。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_push(const int16_t *p_codes, uint8_t a_n);
|
||||||
|
|
||||||
|
/* ===== 四、触发请求、每拍推进、封存交棒、落盘 ACK ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:投递一次录波请求(单字投递,不碰环、不碰索引)
|
||||||
|
* 入口参数:a_src 触发源(wave_trig_src_t 的 1 / 2 / 3;0 与其它值按参数错拒绝)
|
||||||
|
* a_elm 触发元件序号(prot_elm 的 picked 位号,**必须小于 32**;
|
||||||
|
* 手动与 bench 定时传 0。越界当场拒,不许静默截断 —— 载荷只有 5 位)
|
||||||
|
* 返 回 值:WAVE_ST_OK = 已受理(下一拍由 adcTask 自己钉段首基址);
|
||||||
|
* WAVE_ST_DONE = 被忽略(处于 FREEZE 或 SEALED 或 COOL,或请求还没被消费;
|
||||||
|
* ovf 计数已加一,§9 的 ovf=);
|
||||||
|
* WAVE_ST_E_PARAM = 触发源非法或元件号越界(参数错**不记 ovf 账**) int32_t
|
||||||
|
* 限定条件:**任意上下文可调**(protectTask 的 picked 上升沿、uiTask 的手动确认、
|
||||||
|
* bench 定周期、adcTask 自己),它只写**一个字节**(载荷编码见文件头
|
||||||
|
* 「跨任务安全」段),与 App/algo/prot_elm.c 的 prot_elm_request_reset()
|
||||||
|
* 同一模式(§5.1)
|
||||||
|
* 函数说明:本函数不产生「立刻冻结」的效果 —— 段首基址由 adcTask 在下一拍
|
||||||
|
* wave_ring_tick 里钉,避免跨上下文改环索引。最坏延迟一拍 = 1 ms,
|
||||||
|
* 相对 160 点的预触发窗可以忽略。
|
||||||
|
* **ACK 前一律只加 ovf**(lead 裁定):SEALED 期间再来触发不重录、不排队,
|
||||||
|
* 本波不做多段排队。
|
||||||
|
* **返回 WAVE_ST_E_PARAM 的两条路径不触碰任何计数**(ovf / err / drop 一个都不加,
|
||||||
|
* 也不留挂号):那是调用方传错参数,与「第二次真触发」是两回事。台架不得拿 ovf
|
||||||
|
* 当「谁误用了接口」的证据——误用只在返回值里体现(lead 裁定 2026-10-29)。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_request(uint8_t a_src, uint8_t a_elm);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:推进一拍状态机(消费请求钉基址、计后触发点数、到点进 SEALED、
|
||||||
|
* 冷却递减、SEALED 滞留超时自弃)
|
||||||
|
* 入口参数:a_now_ms 当前相对上电毫秒(填段头 t0_ms;用 adcTask 已有的时基即可)
|
||||||
|
* a_frame_seq 当前帧序号(填段头 frame_seq,与串口周期行的 frames 对账)
|
||||||
|
* 返 回 值:wave_ring_evt_t(WAVE_RING_EVT_NONE / WAVE_RING_EVT_SEALED) int32_t
|
||||||
|
* 限定条件:**只能在 adcTask 调**,且每拍在 wave_ring_push 之后调一次
|
||||||
|
* (先写样本再算点数,后触发才正好 160 个点)
|
||||||
|
* 函数说明:FREEZE 期间写入行为与滚动无异,只是段首基址已钉死、预触发区不再被覆盖
|
||||||
|
* —— 这就是 §4.2「触发即冻结写指针」的准确含义:冻住的是覆盖窗口,
|
||||||
|
* 不是停止采集。计满 WAVE_RING_POST 点的本拍返回 WAVE_RING_EVT_SEALED。
|
||||||
|
* SEALED 期间本函数只做「滞留计时 + 超时自弃」,不动数据、不动基址。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_tick(uint32_t a_now_ms, uint32_t a_frame_seq);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:封存交棒 —— 把「环已知字段 + adcTask 现取的封存瞬间事实」拼成完整段头并锁存
|
||||||
|
* 入口参数:p_cal 封存瞬间事实(折算档位 / 有效性位图 / 削顶位图 / epoch 秒 / 通道表的
|
||||||
|
* conv、bits、avail、k_q16、b_codes;不可为 NULL)
|
||||||
|
* 返 回 值:WAVE_ST_OK = 已锁存,wave_ring_sealed() 从此为 1;
|
||||||
|
* WAVE_ST_E_STATE = 不在「刚封段待交棒」态(含已 latch 过第二次、非 SEALED);
|
||||||
|
* WAVE_ST_E_PARAM = 空指针或折算档位非法 int32_t
|
||||||
|
* 限定条件:**只能在 adcTask 调**,且只在 wave_ring_tick 返回 WAVE_RING_EVT_SEALED 的
|
||||||
|
* 那一拍调一次(重复调返回 WAVE_ST_E_STATE 且不覆盖已有锁存值 —— 半路覆盖
|
||||||
|
* 会让落盘道读到的头与数据不同源)
|
||||||
|
* 函数说明:为什么系数要调用方传、而不让本层自己读 adc_cal —— 本层要能被主机单测零桩
|
||||||
|
* 纳入;更根本的是 §12 的要求:标定一旦落地,老文件必须还能按**当时**的系数
|
||||||
|
* 解释,所以 eng / k / b / clip 属于段头、不属于当前固件状态。
|
||||||
|
* 取值只能在第 3 步那一拍做一次(不每拍读、不低频刷),理由见本文件头注。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_latch_meta(const wave_seal_cal_t *p_cal);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:落盘 ACK —— defaultTask 明确告知本段已搬完(成功或失败),据此解冻并起冷却
|
||||||
|
* 入口参数:a_result 0 = 落盘成功(打开、写入字节数相符、目录可用);
|
||||||
|
* 非 0 = 失败(含 f_write 短写、打开/建目录失败、CRC 自弃)——
|
||||||
|
* 失败时段已丢,但环必须解冻,错误只计数不冻结
|
||||||
|
* 返 回 值:WAVE_ST_OK = 已 ACK 并转入冷却;WAVE_ST_E_STATE = 当前不在 SEALED
|
||||||
|
* (重复 ACK 或没有段可 ACK —— 不静默改状态,防落盘道把上一段的 ACK 打到新段),
|
||||||
|
* 或段头还没 latch(第 3 步没做):这时候落盘道拿不到 sealed_meta,根本没可能
|
||||||
|
* 写出文件,放过 ACK 等于白吃一个轮转号并把 seg=NN 报成没发生过的数,
|
||||||
|
* 故一律拒绝。这种段唯一的出口是滞留到 WAVE_SEAL_TIMEOUT_MS 自弃并计 err int32_t
|
||||||
|
* 限定条件:**只能由文件系统唯一写者 defaultTask 调**,一段一次,调之前先看
|
||||||
|
* wave_ring_sealed()(它就是「SEALED 且已 latch」的合并判据)
|
||||||
|
* 函数说明:失败不推进轮转号(下一段仍用同号重写,同号文件按 FA_CREATE_ALWAYS 截断,
|
||||||
|
* 不留半截文件骗过 PC 脚本);成功才推进 seg_idx 与 seg_total。
|
||||||
|
* 本函数不删文件、不碰 FatFs:那些都在落盘道(§5.1 所有权)。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_write_done(int32_t a_result);
|
||||||
|
|
||||||
|
/* ===== 五、状态查询与已封存区读口(跨任务安全的唯一入口) ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:查当前状态
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:wave_ring_state_t 的取值 uint8_t
|
||||||
|
* 限定条件:任意上下文(只读单字节)
|
||||||
|
*/
|
||||||
|
uint8_t wave_ring_state(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:查「有已封存段可读」标志 —— 跨任务的唯一握手量(§5.2 第一条陷阱)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:1 = 状态为 SEALED **且段头已 latch**,此刻起 wave_ring_sealed_meta 与
|
||||||
|
* wave_ring_read_ch 可用;0 = 不可用 uint8_t
|
||||||
|
* 限定条件:任意上下文(只读单字节)
|
||||||
|
* 函数说明:为什么把「已 latch」并进来而不是单开一个查询:省得 defaultTask 自己
|
||||||
|
* 拼两个标志、拼错就是拿半份快照落盘(第 4 步明写的禁忌)。
|
||||||
|
* 为 1 期间已封存区对 adcTask 只读不写,读者可以慢慢分次取、不需要临界区;
|
||||||
|
* 为 0 时所有读口一律返 WAVE_ST_E_STATE 而**不是返回旧数据** ——
|
||||||
|
* 把上一段的字节当这一段,等于伪造证据。
|
||||||
|
*/
|
||||||
|
uint8_t wave_ring_sealed(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:取已封存段的段头快照(落盘道编头、回放页画标题都用它)
|
||||||
|
* 入口参数:p_out 输出(整份拷出,约 136 字节;不可为 NULL)
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针;WAVE_ST_E_STATE 无就绪段 int32_t
|
||||||
|
* 限定条件:任意上下文(defaultTask / uiTask 只读)。整份拷贝、不暴露内部指针,
|
||||||
|
* 免得调用方攥着指针跨到下一段去看
|
||||||
|
* 函数说明:这里的 fs_hz / pts_per_cycle / pre_pts 是**数据**,回放页与 PC 脚本必须
|
||||||
|
* 从这里读,不许当编译期常量(§2.1 第 1 条)。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_sealed_meta(wave_seg_meta_t *p_out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:按通道读已封存区的连续点窗(回放页画一路、落盘道搬一路都走它)
|
||||||
|
* 入口参数:a_ch 列号 = 通道表下标 = 语义号(0 到 WAVE_RING_CH-1)
|
||||||
|
* a_from 段内起始点下标(0 = 段首 = 最早的一点。点序口径:触发那一拍的
|
||||||
|
* 样本落在点 WAVE_RING_PRE-1(预触发窗的最后一点),段内点
|
||||||
|
* WAVE_RING_PRE 起是触发之后的第一拍——预触发 8 周波与后触发
|
||||||
|
* 8 周波各 160 点,故障时刻正好是两半的分界(§4.2)
|
||||||
|
* a_cnt 点数(1 起;超出段尾时函数内部夹到剩余点数,不报错)
|
||||||
|
* p_dst 输出 int16 数组(不可为 NULL;画窗 40 点或搬运 128 点按调用方给的容量)
|
||||||
|
* 返 回 值:不小于 0 = 实际读出的点数(到段尾会少于 a_cnt);
|
||||||
|
* WAVE_ST_E_PARAM 空指针或 a_cnt 为 0;WAVE_ST_E_RANGE 列号或起始点越界;
|
||||||
|
* WAVE_ST_E_STATE 无就绪段 int32_t
|
||||||
|
* 限定条件:任意上下文,但**只读已封存区**:本层不提供任何读活缓冲的接口,
|
||||||
|
* IDLE / FREEZE / COOL 下一律 WAVE_ST_E_STATE(§5.2 第一条:不给跨任务读活环的路径)
|
||||||
|
* 函数说明:内部按段首基址取模展开,对读者来说已封存区就是一段 320 点的线性数组。
|
||||||
|
* 回放页画 2 周波 = 40 点(§8.1)一次调用即可;字节形态 = 段文件数据区的
|
||||||
|
* 字节形态(int16 小端),所以落盘道能直接把它交给 wave_store 的打包游标。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_read_ch(uint8_t a_ch, uint16_t a_from, uint16_t a_cnt, int16_t *p_dst);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:同上,形态适配 App/algo/wave_store.h 的 wave_reader_fn(落盘道直接传它)
|
||||||
|
* 入口参数:p_user 上下文(本层是单例,忽略,传 NULL 即可)
|
||||||
|
* a_ch 列号
|
||||||
|
* a_from 段内起始点下标
|
||||||
|
* a_cnt 点数
|
||||||
|
* p_dst 输出 int16 数组
|
||||||
|
* 返 回 值:实际读出的点数(不小于 0);负 = WAVE_ST_E_*(同上三条) int32_t
|
||||||
|
* 限定条件:任意上下文;只读已封存区(同上)
|
||||||
|
* 函数说明:存在的唯一理由是让 wave_store_pack_start 的 p_reader 直接指过来,
|
||||||
|
* 省掉接线索里一个只为转发的包装函数。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_read_cb(void *p_user, uint8_t a_ch, uint32_t a_from,
|
||||||
|
uint32_t a_cnt, int16_t *p_dst);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:取观测计数快照(给 §9 的周期行与主机单测)
|
||||||
|
* 入口参数:p_out 输出(不可为 NULL)
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针 int32_t
|
||||||
|
* 限定条件:任意上下文(只读;一次拷约 44 字节,个别字段可能与 adcTask 的推进错开一拍,
|
||||||
|
* 这是观测量的固有精度,不构成判据错误)
|
||||||
|
* 函数说明:err_cnt 的两条来源都在本层收口(write_done 非 0、SEALED 滞留超时自弃),
|
||||||
|
* 所以 §9 日志行的 err= 直接取它即可 —— 落盘道不需要自己数第二份账。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_get_stat(wave_ring_stat_t *p_out);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把「语义通道号」换算成本环的列号(列序 = 语义号升序,§6.3)
|
||||||
|
* 入口参数:a_sem 语义号(adc_semantic_t 值)
|
||||||
|
* 返 回 值:0 到 WAVE_RING_CH-1 = 列号;WAVE_ST_E_RANGE = 该语义不录(TEMP 与越界值) int32_t
|
||||||
|
* 限定条件:任意上下文(纯计算)
|
||||||
|
* 函数说明:本波只录前 8 个语义通道,所以「列号 == 语义号」这条恒等式成立;
|
||||||
|
* 但调用方必须走这个函数而不是自己写死下标 —— 二期带 TEMP 时列号与语义号
|
||||||
|
* 就会分叉,写死下标的地方会静默错位。
|
||||||
|
*/
|
||||||
|
int32_t wave_ring_col(uint8_t a_sem);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* WAVE_RING_H */
|
||||||
@@ -0,0 +1,981 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:录波段格式层实现(故障录波 · 段文件字节布局编解码 + 打包游标 + 段名轮转)
|
||||||
|
* 模块功能:见 App/algo/wave_store.h 的文件头注释块——口径属主、职责边界、跨任务安全、
|
||||||
|
* 二期不返工的约束全部写在那里,本文件不重复一遍。
|
||||||
|
* 本文件是纯逻辑:只 include App/algo/wave_store.h 与 C 标准库的 stdint/stddef,
|
||||||
|
* 不 include HAL / CMSIS / RTOS 头、不调 FatFs、不碰文件系统、不打日志。
|
||||||
|
* 适用平台:STM32F407ZGTx(Cortex-M4,小端);主机侧单测 test/host/wave_store/
|
||||||
|
* 作者:DTU 项目组
|
||||||
|
* 创建日期:2026-10-29(故障录波 task-1 · 数据面纯逻辑层)
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-29 DTU 项目组 创建
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "wave_store.h"
|
||||||
|
|
||||||
|
/* ARMCC 的 stdint.h 不提供 NULL,而主机侧 gcc / MSVC 会传递性把它拉进来 ——
|
||||||
|
* 这个差别主机单测照不出来(2026-10-29 台基线实跑 /keil-build 才暴露:本层
|
||||||
|
* wave_store.c 报 22 条 identifier "NULL" 未定义)。与 App/algo/sample_ring.c、App/algo/freq.c
|
||||||
|
* 同一口径:谁用 NULL 谁自己 include stddef.h,不往公共头里塞(少波及别人的 TU)。 */
|
||||||
|
#include <stddef.h>
|
||||||
|
|
||||||
|
/* 打包游标一次最多从数据源取多少个点(128 字节栈上临时区,落盘道在 defaultTask 上跑,
|
||||||
|
* 不在 1 kHz 采集链上;取批是为了把「每点一次回调」压成「每 64 点一次回调」) */
|
||||||
|
#define WAVE_ST_BATCH (64u)
|
||||||
|
/* 数据区点下标非法时的内部哨兵(对外一律走 wave_store_data_off 的 0xFFFFFFFFu) */
|
||||||
|
#define WAVE_ST_BAD_OFF (0xFFFFFFFFu)
|
||||||
|
|
||||||
|
/* ===== 内部:小端读写(显式移位拼装,不依赖宿主字节序,板端与 PC 侧同一判据) ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:写 16 位小端
|
||||||
|
* 入口参数:p 目标位置(至少 2 字节);v 值
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:调用方保证 p 可写 2 字节
|
||||||
|
*/
|
||||||
|
static void wave_st_put16(uint8_t *p, uint16_t v) {
|
||||||
|
p[0] = (uint8_t)(v & 0xFFu);
|
||||||
|
p[1] = (uint8_t)((v >> 8) & 0xFFu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:写 32 位小端
|
||||||
|
* 入口参数:p 目标位置(至少 4 字节);v 值
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:调用方保证 p 可写 4 字节
|
||||||
|
*/
|
||||||
|
static void wave_st_put32(uint8_t *p, uint32_t v) {
|
||||||
|
p[0] = (uint8_t)(v & 0xFFu);
|
||||||
|
p[1] = (uint8_t)((v >> 8) & 0xFFu);
|
||||||
|
p[2] = (uint8_t)((v >> 16) & 0xFFu);
|
||||||
|
p[3] = (uint8_t)((v >> 24) & 0xFFu);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读 16 位小端
|
||||||
|
* 入口参数:p 源位置(至少 2 字节)
|
||||||
|
* 返 回 值:值 uint16_t
|
||||||
|
* 限定条件:调用方保证 p 可读 2 字节
|
||||||
|
*/
|
||||||
|
static uint16_t wave_st_get16(const uint8_t *p) {
|
||||||
|
return (uint16_t)((uint16_t)p[0] | ((uint16_t)p[1] << 8));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读 32 位小端
|
||||||
|
* 入口参数:p 源位置(至少 4 字节)
|
||||||
|
* 返 回 值:值 uint32_t
|
||||||
|
* 限定条件:调用方保证 p 可读 4 字节
|
||||||
|
*/
|
||||||
|
static uint32_t wave_st_get32(const uint8_t *p) {
|
||||||
|
return ((uint32_t)p[0]) | (((uint32_t)p[1]) << 8) |
|
||||||
|
(((uint32_t)p[2]) << 16) | (((uint32_t)p[3]) << 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把 int16 写成小端两字节
|
||||||
|
* 入口参数:p 目标位置;v 码值
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:调用方保证 p 可写 2 字节
|
||||||
|
* 函数说明:先转 uint16 再移位——负数右移是实现定义行为,转成无符号后行为确定。
|
||||||
|
*/
|
||||||
|
static void wave_st_put_code(uint8_t *p, int16_t v) {
|
||||||
|
wave_st_put16(p, (uint16_t)v);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 内部:段头字段一致性自检(编码前与解码后各跑一次,同一把尺子) ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:校验段头元数据的**几何与表序**是否自相矛盾
|
||||||
|
* 入口参数:p_meta 段头元数据(非空)
|
||||||
|
* 返 回 值:WAVE_ST_OK = 合法;WAVE_ST_E_SIZE 长度类不成立(零通道 / 零点数 / 预触发超段长);
|
||||||
|
* WAVE_ST_E_FORMAT 档位 / 触发源 / 表序非法 int
|
||||||
|
* 限定条件:p_meta 由调用方保证非空
|
||||||
|
* 函数说明:为什么编码侧也要跑:写一个「看起来像样的坏段文件」比拒写糟得多——落盘道会
|
||||||
|
* 把 CRC 与长度都算自洽的垃圾写进 NAND,等到回放页或 PC 脚本才发现,那时
|
||||||
|
* 现场已经没了(§8.1「画成一条直线等于伪造证据」是同一条忌讳的另一半)。
|
||||||
|
*/
|
||||||
|
static int wave_st_meta_check(const wave_seg_meta_t *p_meta) {
|
||||||
|
uint8_t i;
|
||||||
|
|
||||||
|
/* 零通道 / 零点数:这一段连长度都不成立(§6.2 末行「长度为 0 判废」) */
|
||||||
|
if ((p_meta->ch_count == 0u) || (p_meta->ch_count > (uint8_t)WAVE_ST_CH_MAX)) {
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
if (p_meta->pts_per_ch == 0u) {
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
/* 预触发点数不能大于一段的点数;后触发 = pts_per_ch - pre_pts,允许为 0(手动触发
|
||||||
|
* 撞上落盘窗口等极端情形),但不允许负数——那是把段长算错 */
|
||||||
|
if (p_meta->pre_pts > p_meta->pts_per_ch) {
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
/* 采样率与每周波点数是数据,但为 0 的数据没法解释(回放与 PC 都要拿它做除数) */
|
||||||
|
if ((p_meta->fs_hz == 0u) || (p_meta->pts_per_cycle == 0u)) {
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
/* 与 PC 侧 reader 的拒绝清单对齐(谁都不能产出一段「自己写得出、对面读不了」的文件):
|
||||||
|
* 每周波不足 2 点连一个周期都描不出来;段长不足一个整周期则基波算法无从取窗。 */
|
||||||
|
if (p_meta->pts_per_cycle < 2u) {
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
if (p_meta->pts_per_ch < (uint32_t)p_meta->pts_per_cycle) {
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
if (p_meta->eng > (uint8_t)WAVE_ENG_PRI) {
|
||||||
|
return WAVE_ST_E_FORMAT;
|
||||||
|
}
|
||||||
|
if (p_meta->trig_src > (uint8_t)WAVE_TRIG_BENCH) {
|
||||||
|
return WAVE_ST_E_FORMAT;
|
||||||
|
}
|
||||||
|
/* 表序:第 i 项的语义号必须等于下标(§6.3「通道表序与列序」行)——
|
||||||
|
* 数据区第 i 列归第 i 项解释,错一位就是拿 IB 的波形配 IC 的系数 */
|
||||||
|
for (i = 0u; i < p_meta->ch_count; i++) {
|
||||||
|
if (p_meta->ch[i].sem != i) {
|
||||||
|
return WAVE_ST_E_FORMAT;
|
||||||
|
}
|
||||||
|
if (p_meta->ch[i].conv > (uint8_t)1u) {
|
||||||
|
return WAVE_ST_E_FORMAT;
|
||||||
|
}
|
||||||
|
/* 位宽留个合理域:本波只有 16(TPAFE)与 12(片内 ADC),但格式层不写死这两个值,
|
||||||
|
* 只挡明显不成立的(0 位、超过 32 位)——二期换转换器不必改这里 */
|
||||||
|
if ((p_meta->ch[i].bits == 0u) || (p_meta->ch[i].bits > 32u)) {
|
||||||
|
return WAVE_ST_E_FORMAT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== CRC-16/MODBUS ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:CRC-16/MODBUS 增量计算
|
||||||
|
* 入口参数:a_crc 上一次的中间值(首次传 WAVE_ST_CRC_INIT)
|
||||||
|
* p_data 数据(NULL 视为空操作)
|
||||||
|
* a_len 字节数
|
||||||
|
* 返 回 值:新的中间值 uint16_t
|
||||||
|
* 限定条件:任意上下文;纯计算
|
||||||
|
* 函数说明:反射形态 = 右移、异或 0xA001、初值 0xFFFF、异或输出 0;
|
||||||
|
* 写进文件时低字节在前(§6.3)。无表实现的理由见 wave_store.h 头注。
|
||||||
|
*/
|
||||||
|
uint16_t wave_store_crc_update(uint16_t a_crc, const uint8_t *p_data, uint32_t a_len) {
|
||||||
|
uint16_t crc = a_crc;
|
||||||
|
uint32_t i;
|
||||||
|
|
||||||
|
if (p_data == NULL) {
|
||||||
|
return crc;
|
||||||
|
}
|
||||||
|
for (i = 0u; i < a_len; i++) {
|
||||||
|
uint8_t b = p_data[i]; /* 身兼两职:本路字节 + 下面内层 8 次的计数器 */
|
||||||
|
|
||||||
|
crc = (uint16_t)(crc ^ (uint16_t)b);
|
||||||
|
for (b = 0u; b < 8u; b++) {
|
||||||
|
if ((crc & 0x0001u) != 0u) {
|
||||||
|
crc = (uint16_t)((crc >> 1) ^ WAVE_ST_CRC_POLY);
|
||||||
|
} else {
|
||||||
|
crc = (uint16_t)(crc >> 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/* 异或输出恒 0:写成宏引用,换异或输出时只改一处 */
|
||||||
|
return (uint16_t)(crc ^ WAVE_ST_CRC_XOROUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:CRC-16/MODBUS 一次算完
|
||||||
|
* 入口参数:p_data 数据;a_len 字节数
|
||||||
|
* 返 回 值:CRC;空输入返回初值定义 uint16_t
|
||||||
|
* 限定条件:任意上下文。零长度的判废由调用方做(见 wave_store.h 同函数注释)
|
||||||
|
*/
|
||||||
|
uint16_t wave_store_crc(const uint8_t *p_data, uint32_t a_len) {
|
||||||
|
return wave_store_crc_update(WAVE_ST_CRC_INIT, p_data, a_len);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:CRC 实现自检(对规范锚点,不是自算自验)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:WAVE_ST_OK 相符;WAVE_ST_E_FORMAT 不符 int
|
||||||
|
* 限定条件:任意上下文;开机或首次落盘前一次,不在每拍路径上
|
||||||
|
* 函数说明:锚点串「123456789」的 CRC-16/MODBUS = 0x4B37(§6.3)。字面量必须纯 ASCII,
|
||||||
|
* 所以这里用显式字节数组而不是把中文塞进字符串。
|
||||||
|
*/
|
||||||
|
int wave_store_crc_selftest(void) {
|
||||||
|
static const uint8_t s_anchor[9] = {
|
||||||
|
(uint8_t)0x31u, (uint8_t)0x32u, (uint8_t)0x33u,
|
||||||
|
(uint8_t)0x34u, (uint8_t)0x35u, (uint8_t)0x36u,
|
||||||
|
(uint8_t)0x37u, (uint8_t)0x38u, (uint8_t)0x39u
|
||||||
|
};
|
||||||
|
uint16_t got;
|
||||||
|
|
||||||
|
got = wave_store_crc(s_anchor, (uint32_t)sizeof(s_anchor));
|
||||||
|
if (got != WAVE_ST_CRC_CHECK) {
|
||||||
|
return WAVE_ST_E_FORMAT;
|
||||||
|
}
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 段头编解码 ===== */
|
||||||
|
|
||||||
|
/* 语义通道的转换器归属与位宽(0 = TPAFE5160,1 = 片内 ADC1;口径属主是
|
||||||
|
* docs/硬件平台/ADC通道映射说明.md,本表是其抄本,改硬件接线时两处一起改)。
|
||||||
|
* 第 9 项 TEMP 本波不录入段文件(ch_count 恒 8),仍按实际归属填,
|
||||||
|
* 免得将来放开 TEMP 时这里是个 0。 */
|
||||||
|
static const uint8_t s_sem_conv[(uint8_t)WAVE_ST_CH_MAX] = {
|
||||||
|
(uint8_t)0u, (uint8_t)1u, (uint8_t)1u,
|
||||||
|
(uint8_t)0u, (uint8_t)0u, (uint8_t)1u, (uint8_t)1u, (uint8_t)1u, (uint8_t)1u
|
||||||
|
};
|
||||||
|
static const uint8_t s_sem_bits[(uint8_t)WAVE_ST_CH_MAX] = {
|
||||||
|
16u, 12u, 12u,
|
||||||
|
16u, 16u, 12u, 12u, 12u, 12u
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把段头元数据填成本波默认值(只填格式侧静态事实)
|
||||||
|
* 入口参数:p_meta 输出
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针 int
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
* 函数说明:填三样格式侧的静态事实:语义号 = 下标(§6.3 表序口径,也是 PC 侧判据)、
|
||||||
|
* conv / bits 按两片转换器的归属(下面那张表的口径属主是
|
||||||
|
* docs/硬件平台/ADC通道映射说明.md 的抄本,本层刻意不带 adc_input.h,
|
||||||
|
* 这样格式层在主机侧不需要任何假件就能编译;单测里 0xE6 的算术交叉验证
|
||||||
|
* 会同时钉住这套顺序,抄错就会红)。
|
||||||
|
* 不填 avail / k_q16 / b_codes:那些是封存瞬间的事实,必须由调用方现取现填
|
||||||
|
* (adc_cal_k_q16 / adc_cal_flags / sys_clock),本层不链它们;
|
||||||
|
* 调用方忘填 k_q16 与 avail 不会让编码失败(0 是合法整数),
|
||||||
|
* 但会让 PC 侧把波形解释成一条 0 线——所以单测里有一条专门检查
|
||||||
|
* 「填了系数的往返」与「没填系数时段的形状仍然正确、只是幅值归零」。
|
||||||
|
*/
|
||||||
|
int wave_store_meta_init(wave_seg_meta_t *p_meta) {
|
||||||
|
uint8_t i;
|
||||||
|
|
||||||
|
if (p_meta == NULL) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
p_meta->fs_hz = (uint16_t)WAVE_ST_FS_DEF;
|
||||||
|
p_meta->pts_per_cycle = (uint8_t)WAVE_ST_PPC_DEF;
|
||||||
|
p_meta->ch_count = (uint8_t)WAVE_ST_CH_NUM;
|
||||||
|
p_meta->pts_per_ch = (uint16_t)WAVE_ST_PTS_DEF;
|
||||||
|
p_meta->pre_pts = (uint16_t)WAVE_ST_PRE_DEF;
|
||||||
|
p_meta->trig_src = (uint8_t)WAVE_TRIG_NONE;
|
||||||
|
p_meta->trig_elm = 0u;
|
||||||
|
p_meta->t0_ms = 0u;
|
||||||
|
p_meta->wall = 0u;
|
||||||
|
p_meta->eng = (uint8_t)WAVE_ENG_PIN;
|
||||||
|
p_meta->flags_seq = 0u;
|
||||||
|
p_meta->clip_mask = 0u;
|
||||||
|
p_meta->frame_seq = 0u;
|
||||||
|
for (i = 0u; i < (uint8_t)WAVE_ST_CH_MAX; i++) {
|
||||||
|
p_meta->ch[i].sem = i;
|
||||||
|
p_meta->ch[i].conv = s_sem_conv[i];
|
||||||
|
p_meta->ch[i].bits = s_sem_bits[i];
|
||||||
|
p_meta->ch[i].avail = 0u;
|
||||||
|
p_meta->ch[i].k_q16 = 0;
|
||||||
|
p_meta->ch[i].b_codes = 0;
|
||||||
|
}
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:内部——把固定头 + 通道表写进缓冲(不含头 CRC)
|
||||||
|
* 入口参数:p_meta 段头;p_out 缓冲(容量须 >= 头 CRC 覆盖区)
|
||||||
|
* 返 回 值:写入字节数(= 头 CRC 覆盖区长度) uint32_t
|
||||||
|
* 限定条件:p_meta 已过 wave_st_meta_check、p_out 容量已核过
|
||||||
|
*/
|
||||||
|
static uint32_t wave_st_head_raw(const wave_seg_meta_t *p_meta, uint8_t *p_out) {
|
||||||
|
uint8_t i;
|
||||||
|
const uint32_t tbl_n = (uint32_t)p_meta->ch_count * WAVE_ST_CH_BYTES;
|
||||||
|
|
||||||
|
p_out[WAVE_ST_OFF_MAGIC] = (uint8_t)WAVE_ST_MAGIC0;
|
||||||
|
p_out[WAVE_ST_OFF_MAGIC + 1u] = (uint8_t)WAVE_ST_MAGIC1;
|
||||||
|
/* 魔数第三字节 = 版本号:与 ver 字段同源同值(§6.2 第 0 行 + §6.3 判废口径) */
|
||||||
|
p_out[WAVE_ST_OFF_MAGIC + 2u] = (uint8_t)WAVE_ST_VER;
|
||||||
|
p_out[WAVE_ST_OFF_VER] = (uint8_t)WAVE_ST_VER;
|
||||||
|
wave_st_put16(&p_out[WAVE_ST_OFF_FS_HZ], p_meta->fs_hz);
|
||||||
|
p_out[WAVE_ST_OFF_PTS_CYC] = p_meta->pts_per_cycle;
|
||||||
|
p_out[WAVE_ST_OFF_CH_CNT] = p_meta->ch_count;
|
||||||
|
wave_st_put16(&p_out[WAVE_ST_OFF_PTS_CH], p_meta->pts_per_ch);
|
||||||
|
wave_st_put16(&p_out[WAVE_ST_OFF_PRE_PTS], p_meta->pre_pts);
|
||||||
|
p_out[WAVE_ST_OFF_TRIG_SRC] = p_meta->trig_src;
|
||||||
|
p_out[WAVE_ST_OFF_TRIG_ELM] = p_meta->trig_elm;
|
||||||
|
wave_st_put32(&p_out[WAVE_ST_OFF_T0_MS], p_meta->t0_ms);
|
||||||
|
wave_st_put32(&p_out[WAVE_ST_OFF_WALL], p_meta->wall);
|
||||||
|
p_out[WAVE_ST_OFF_ENG] = p_meta->eng;
|
||||||
|
p_out[WAVE_ST_OFF_FLAGS] = p_meta->flags_seq;
|
||||||
|
wave_st_put16(&p_out[WAVE_ST_OFF_CLIP], p_meta->clip_mask);
|
||||||
|
wave_st_put32(&p_out[WAVE_ST_OFF_SEQ], p_meta->frame_seq);
|
||||||
|
|
||||||
|
for (i = 0u; i < p_meta->ch_count; i++) {
|
||||||
|
uint32_t pos = (uint32_t)WAVE_ST_OFF_TBL + ((uint32_t)i * WAVE_ST_CH_BYTES);
|
||||||
|
|
||||||
|
p_out[pos + WAVE_ST_CH_OFF_SEM] = p_meta->ch[i].sem;
|
||||||
|
p_out[pos + WAVE_ST_CH_OFF_CONV] = p_meta->ch[i].conv;
|
||||||
|
p_out[pos + WAVE_ST_CH_OFF_BITS] = p_meta->ch[i].bits;
|
||||||
|
p_out[pos + WAVE_ST_CH_OFF_AVAIL] = p_meta->ch[i].avail;
|
||||||
|
/* int32 系数按小端原样落 4 字节:右移到 24 位前把符号扩展掉,
|
||||||
|
* 保证板端 ARMCC 与主机 CRT 写出同一串字节 */
|
||||||
|
wave_st_put32(&p_out[pos + WAVE_ST_CH_OFF_KQ16], (uint32_t)p_meta->ch[i].k_q16);
|
||||||
|
wave_st_put32(&p_out[pos + WAVE_ST_CH_OFF_BCODE], (uint32_t)p_meta->ch[i].b_codes);
|
||||||
|
}
|
||||||
|
return (uint32_t)WAVE_ST_OFF_TBL + tbl_n;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:编码段头(固定头 + 通道表 + 头 CRC)
|
||||||
|
* 入口参数:p_meta 段头元数据;p_out 输出缓冲;a_cap 容量
|
||||||
|
* 返 回 值:>= 0 写入字节数;负 = WAVE_ST_E_* int
|
||||||
|
* 限定条件:任意上下文;无静态缓冲、可重入
|
||||||
|
* 函数说明:头 CRC 覆盖 [0, 30+12×ch_count)、自身紧随其后、低字节在前(§6.3)。
|
||||||
|
*/
|
||||||
|
int wave_store_head_build(const wave_seg_meta_t *p_meta, uint8_t *p_out, uint32_t a_cap) {
|
||||||
|
uint32_t crc_pos;
|
||||||
|
uint16_t crc;
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if ((p_meta == NULL) || (p_out == NULL)) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
rc = wave_st_meta_check(p_meta);
|
||||||
|
if (rc != WAVE_ST_OK) {
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
crc_pos = WAVE_ST_HEAD_CRC_POS(p_meta->ch_count);
|
||||||
|
if (a_cap < (crc_pos + 2u)) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
(void)wave_st_head_raw(p_meta, p_out);
|
||||||
|
crc = wave_store_crc(p_out, crc_pos);
|
||||||
|
/* 低字节在前(§6.3 首行):put16 本来就是小端,这里不额外翻转 */
|
||||||
|
wave_st_put16(&p_out[crc_pos], crc);
|
||||||
|
return (int)(crc_pos + 2u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:解码 + 全套判废(魔数 / 版本 / 缓冲长度 / 几何 / 表序 / 头 CRC)
|
||||||
|
* 入口参数:p_buf 头字节流;a_len 可读字节数;p_meta 输出
|
||||||
|
* 返 回 值:WAVE_ST_OK 或 WAVE_ST_E_*(见 wave_store.h) int
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
* 函数说明:顺序刻意是「先验魔数与版本,再算整头长度,再验 CRC,最后验语义」——
|
||||||
|
* 前两步决定我们**有没有资格**按这份布局去数偏移;反过来先按 ch_count 算长度,
|
||||||
|
* 一个被写坏的 ch_count 就能让我们读到缓冲外面去。
|
||||||
|
*/
|
||||||
|
int wave_store_head_parse(const uint8_t *p_buf, uint32_t a_len, wave_seg_meta_t *p_meta) {
|
||||||
|
uint8_t ch_count;
|
||||||
|
uint32_t crc_pos;
|
||||||
|
uint32_t head_sz;
|
||||||
|
uint16_t want;
|
||||||
|
uint16_t got;
|
||||||
|
int rc;
|
||||||
|
wave_seg_meta_t tmp;
|
||||||
|
|
||||||
|
if ((p_buf == NULL) || (p_meta == NULL)) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
/* 固定头是 30 + 1 字节通道数才能算出整头长度,至少得读到偏移 7 */
|
||||||
|
if (a_len < ((uint32_t)WAVE_ST_OFF_CH_CNT + 1u)) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
if ((p_buf[WAVE_ST_OFF_MAGIC] != (uint8_t)WAVE_ST_MAGIC0) ||
|
||||||
|
(p_buf[WAVE_ST_OFF_MAGIC + 1u] != (uint8_t)WAVE_ST_MAGIC1)) {
|
||||||
|
return WAVE_ST_E_FORMAT;
|
||||||
|
}
|
||||||
|
/* ver 必须等于魔数第三字节、且等于本层认识的版本(§6.2 末行) */
|
||||||
|
if ((p_buf[WAVE_ST_OFF_VER] != p_buf[WAVE_ST_OFF_MAGIC + 2u]) ||
|
||||||
|
(p_buf[WAVE_ST_OFF_VER] != (uint8_t)WAVE_ST_VER)) {
|
||||||
|
return WAVE_ST_E_FORMAT;
|
||||||
|
}
|
||||||
|
ch_count = p_buf[WAVE_ST_OFF_CH_CNT];
|
||||||
|
head_sz = wave_store_head_bytes(ch_count);
|
||||||
|
if (head_sz == 0u) {
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
if (a_len < head_sz) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
crc_pos = WAVE_ST_HEAD_CRC_POS(ch_count);
|
||||||
|
want = wave_st_get16(&p_buf[crc_pos]);
|
||||||
|
got = wave_store_crc(p_buf, crc_pos);
|
||||||
|
if (want != got) {
|
||||||
|
return WAVE_ST_E_CRC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 头 CRC 过了才开始解释字段:下面填的是一份可交回调用方的完整元数据 */
|
||||||
|
tmp.fs_hz = wave_st_get16(&p_buf[WAVE_ST_OFF_FS_HZ]);
|
||||||
|
tmp.pts_per_cycle = p_buf[WAVE_ST_OFF_PTS_CYC];
|
||||||
|
tmp.ch_count = ch_count;
|
||||||
|
tmp.pts_per_ch = wave_st_get16(&p_buf[WAVE_ST_OFF_PTS_CH]);
|
||||||
|
tmp.pre_pts = wave_st_get16(&p_buf[WAVE_ST_OFF_PRE_PTS]);
|
||||||
|
tmp.trig_src = p_buf[WAVE_ST_OFF_TRIG_SRC];
|
||||||
|
tmp.trig_elm = p_buf[WAVE_ST_OFF_TRIG_ELM];
|
||||||
|
tmp.t0_ms = wave_st_get32(&p_buf[WAVE_ST_OFF_T0_MS]);
|
||||||
|
tmp.wall = wave_st_get32(&p_buf[WAVE_ST_OFF_WALL]);
|
||||||
|
tmp.eng = p_buf[WAVE_ST_OFF_ENG];
|
||||||
|
tmp.flags_seq = p_buf[WAVE_ST_OFF_FLAGS];
|
||||||
|
tmp.clip_mask = wave_st_get16(&p_buf[WAVE_ST_OFF_CLIP]);
|
||||||
|
tmp.frame_seq = wave_st_get32(&p_buf[WAVE_ST_OFF_SEQ]);
|
||||||
|
for (ch_count = 0u; ch_count < (uint8_t)WAVE_ST_CH_MAX; ch_count++) {
|
||||||
|
tmp.ch[ch_count].sem = 0u;
|
||||||
|
tmp.ch[ch_count].conv = 0u;
|
||||||
|
tmp.ch[ch_count].bits = 0u;
|
||||||
|
tmp.ch[ch_count].avail = 0u;
|
||||||
|
tmp.ch[ch_count].k_q16 = 0;
|
||||||
|
tmp.ch[ch_count].b_codes = 0;
|
||||||
|
}
|
||||||
|
for (ch_count = 0u; ch_count < p_buf[WAVE_ST_OFF_CH_CNT]; ch_count++) {
|
||||||
|
uint32_t pos = (uint32_t)WAVE_ST_OFF_TBL + ((uint32_t)ch_count * WAVE_ST_CH_BYTES);
|
||||||
|
tmp.ch[ch_count].sem = p_buf[pos + WAVE_ST_CH_OFF_SEM];
|
||||||
|
tmp.ch[ch_count].conv = p_buf[pos + WAVE_ST_CH_OFF_CONV];
|
||||||
|
tmp.ch[ch_count].bits = p_buf[pos + WAVE_ST_CH_OFF_BITS];
|
||||||
|
tmp.ch[ch_count].avail = p_buf[pos + WAVE_ST_CH_OFF_AVAIL];
|
||||||
|
tmp.ch[ch_count].k_q16 = (int32_t)wave_st_get32(&p_buf[pos + WAVE_ST_CH_OFF_KQ16]);
|
||||||
|
tmp.ch[ch_count].b_codes = (int32_t)wave_st_get32(&p_buf[pos + WAVE_ST_CH_OFF_BCODE]);
|
||||||
|
}
|
||||||
|
rc = wave_st_meta_check(&tmp);
|
||||||
|
if (rc != WAVE_ST_OK) {
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
*p_meta = tmp;
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 长度 / 偏移换算 ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:整头字节数
|
||||||
|
* 入口参数:a_ch_count 通道数
|
||||||
|
* 返 回 值:字节数;通道数非法返回 0 uint32_t
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
*/
|
||||||
|
uint32_t wave_store_head_bytes(uint8_t a_ch_count) {
|
||||||
|
if ((a_ch_count == 0u) || (a_ch_count > (uint8_t)WAVE_ST_CH_MAX)) {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
return WAVE_ST_HEAD_BYTES(a_ch_count);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:按头部字段算整段应有字节数
|
||||||
|
* 入口参数:p_meta 段头
|
||||||
|
* 返 回 值:期望字节数;字段非法返回 0 uint32_t
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
* 函数说明:等式 = 32 + 12×ch_count + pts_per_ch×ch_count×2 + 2(§6.3;
|
||||||
|
* 8 路 320 点 = 5250)。落盘道拿它比对 f_write 写回字节数与 f_size。
|
||||||
|
*/
|
||||||
|
uint32_t wave_store_seg_size(const wave_seg_meta_t *p_meta) {
|
||||||
|
if (p_meta == NULL) {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
if ((p_meta->ch_count == 0u) || (p_meta->ch_count > (uint8_t)WAVE_ST_CH_MAX)) {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
if (p_meta->pts_per_ch == 0u) {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
return WAVE_ST_SEG_TOTAL(p_meta->ch_count, p_meta->pts_per_ch);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:数据区某点的文件绝对偏移
|
||||||
|
* 入口参数:a_ch_count 通道数;a_pts_ch 每通道点数;a_ch 列号;a_pt 段内点下标
|
||||||
|
* 返 回 值:偏移;非法返回 0xFFFFFFFFu 哨兵 uint32_t
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
* 函数说明:按通道分列 → 偏移 = 整头长 + (列 × 点数 + 点下标) × 2。单通道整列是连续的,
|
||||||
|
* 这正是 §6.2「只读那一路」能一次连续读的原因。
|
||||||
|
*/
|
||||||
|
uint32_t wave_store_data_off(uint8_t a_ch_count, uint16_t a_pts_ch,
|
||||||
|
uint8_t a_ch, uint32_t a_pt) {
|
||||||
|
uint32_t head_sz = wave_store_head_bytes(a_ch_count);
|
||||||
|
|
||||||
|
if ((head_sz == 0u) || (a_pts_ch == 0u) || (a_ch >= a_ch_count)) {
|
||||||
|
return WAVE_ST_BAD_OFF;
|
||||||
|
}
|
||||||
|
if (a_pt >= (uint32_t)a_pts_ch) {
|
||||||
|
return WAVE_ST_BAD_OFF;
|
||||||
|
}
|
||||||
|
return head_sz + (((uint32_t)a_ch * (uint32_t)a_pts_ch) + a_pt) * 2u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:从某点起当前列还能连续读多少字节
|
||||||
|
* 入口参数:a_pts_ch 每通道点数;a_pt 起始点下标;a_want 想读点数
|
||||||
|
* 返 回 值:可连续读字节数(参数非法返回 0) uint32_t
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
*/
|
||||||
|
uint32_t wave_store_data_bytes_avail(uint16_t a_pts_ch, uint32_t a_pt, uint32_t a_want) {
|
||||||
|
uint32_t left;
|
||||||
|
uint32_t n;
|
||||||
|
|
||||||
|
if ((a_pts_ch == 0u) || (a_want == 0u)) {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
if (a_pt >= (uint32_t)a_pts_ch) {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
left = (uint32_t)a_pts_ch - a_pt;
|
||||||
|
n = (a_want < left) ? a_want : left;
|
||||||
|
return n * 2u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:小端字节 → int16 点序列
|
||||||
|
* 入口参数:p_raw 原始字节;a_len 字节数;p_out 输出;a_max_pts 容量;p_pts 实际点数(可空)
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针 / 奇数字节 / 容量不足 int
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
* 函数说明:奇数字节按参数错处理而不是「丢掉尾巴」——半截样本被静默吞掉,
|
||||||
|
* 回放页画出来的波形会比真的少最后一个点,而这种误差正好最难查。
|
||||||
|
*/
|
||||||
|
int wave_store_le_decode(const uint8_t *p_raw, uint32_t a_len,
|
||||||
|
int16_t *p_out, uint32_t a_max_pts, uint32_t *p_pts) {
|
||||||
|
uint32_t n;
|
||||||
|
uint32_t i;
|
||||||
|
|
||||||
|
if ((p_raw == NULL) || (p_out == NULL)) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
if (((a_len & 0x00000001u) != 0u) || (a_len > (a_max_pts * 2u))) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
n = a_len / 2u;
|
||||||
|
for (i = 0u; i < n; i++) {
|
||||||
|
/* 先攒成 uint16 再转 int16:补码回绕在 C 里是实现定义但两大编译器一致,
|
||||||
|
* 而这里需要的正是「按补码位型解释」——TPAFE 的 16 位补码就是这么存的 */
|
||||||
|
p_out[i] = (int16_t)wave_st_get16(&p_raw[i * 2u]);
|
||||||
|
}
|
||||||
|
if (p_pts != NULL) {
|
||||||
|
*p_pts = n;
|
||||||
|
}
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:码值 → 定点毫单位(mV)
|
||||||
|
* 入口参数:p_desc 通道表项(必须来自段文件头);a_code 码值;p_milli 输出 mV
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针 int
|
||||||
|
* 限定条件:任意上下文(只在回放页与单测用,不在 1 kHz 采集链上;用 int64 中间量)
|
||||||
|
* 函数说明:模型 eng = (code + b) × k × 1e-6(§6.3,与 adc_cal_apply() 同式),
|
||||||
|
* k = k_q16 / 65536 µV/码 → mV = (code + b) × k_q16 / 65536000,四舍五入。
|
||||||
|
* 中间量最大约 (32767 + 8192) × 5.28e7 ≈ 2.2e12,int64 宽裕。
|
||||||
|
*/
|
||||||
|
int wave_store_code_to_milli(const wave_ch_desc_t *p_desc, int16_t a_code,
|
||||||
|
int32_t *p_milli) {
|
||||||
|
int64_t num;
|
||||||
|
int64_t den = 65536000;
|
||||||
|
int64_t v;
|
||||||
|
|
||||||
|
if ((p_desc == NULL) || (p_milli == NULL)) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
num = ((int64_t)a_code + (int64_t)p_desc->b_codes) * (int64_t)p_desc->k_q16;
|
||||||
|
if (num >= 0) {
|
||||||
|
v = (num + (den / 2)) / den;
|
||||||
|
} else {
|
||||||
|
v = -(((-num) + (den / 2)) / den);
|
||||||
|
}
|
||||||
|
*p_milli = (int32_t)v;
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 整段校验 ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:整段镜像自检(头 + 数据区 + 数据 CRC)
|
||||||
|
* 入口参数:p_img 镜像;a_len 长度;p_meta 输出段头(可空)
|
||||||
|
* 返 回 值:WAVE_ST_OK 或 WAVE_ST_E_*(见 wave_store.h) int
|
||||||
|
* 限定条件:任意上下文;整段驻留时用(本波 5250 字节)
|
||||||
|
* 函数说明:三条判废一条都不能省:长度 0 或不等、头 CRC、数据 CRC。
|
||||||
|
* 期望总长用**头里的字段**算,不用编译期常量——文件是自己说它多长,
|
||||||
|
* 与镜像实际长度不一致就是坏段(§6.2 末行)。
|
||||||
|
*/
|
||||||
|
int wave_store_seg_check(const uint8_t *p_img, uint32_t a_len, wave_seg_meta_t *p_meta) {
|
||||||
|
wave_seg_meta_t meta;
|
||||||
|
uint32_t want_total;
|
||||||
|
uint32_t data_off;
|
||||||
|
uint32_t data_len;
|
||||||
|
uint16_t want;
|
||||||
|
uint16_t got;
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if (p_img == NULL) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
if (a_len == 0u) {
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
rc = wave_store_head_parse(p_img, a_len, &meta);
|
||||||
|
if (rc != WAVE_ST_OK) {
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
want_total = wave_store_seg_size(&meta);
|
||||||
|
if (want_total == 0u) {
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
if (a_len != want_total) {
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
data_off = wave_store_head_bytes(meta.ch_count); /* 数据区起点 = 整头字节数 */
|
||||||
|
data_len = a_len - 2u - data_off;
|
||||||
|
if (data_len == 0u) {
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
want = wave_st_get16(&p_img[a_len - 2u]);
|
||||||
|
got = wave_store_crc(&p_img[data_off], data_len);
|
||||||
|
if (want != got) {
|
||||||
|
return WAVE_ST_E_CRC;
|
||||||
|
}
|
||||||
|
if (p_meta != NULL) {
|
||||||
|
*p_meta = meta;
|
||||||
|
}
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 打包游标 ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:初始化打包游标
|
||||||
|
* 入口参数:p_ctx 游标;p_meta 段头;p_reader 数据源(可空 = 只产头);p_user 上下文
|
||||||
|
* 返 回 值:WAVE_ST_OK 或 WAVE_ST_E_* int
|
||||||
|
* 限定条件:任意上下文(纯计算);一段一个游标
|
||||||
|
* 函数说明:段头整份拷进游标——搬运窗口几十毫秒,期间调用方完全可以去改它自己那份
|
||||||
|
* (比如 adcTask 又跑了几拍),本段的字节不能受影响。
|
||||||
|
*/
|
||||||
|
int wave_store_pack_start(wave_pack_ctx_t *p_ctx, const wave_seg_meta_t *p_meta,
|
||||||
|
wave_reader_fn p_reader, void *p_user) {
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if ((p_ctx == NULL) || (p_meta == NULL)) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
rc = wave_st_meta_check(p_meta);
|
||||||
|
if (rc != WAVE_ST_OK) {
|
||||||
|
p_ctx->phase = (uint8_t)WAVE_PACK_IDLE;
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
p_ctx->total = wave_store_seg_size(p_meta);
|
||||||
|
if (p_ctx->total == 0u) {
|
||||||
|
p_ctx->phase = (uint8_t)WAVE_PACK_IDLE;
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
p_ctx->phase = (uint8_t)WAVE_PACK_HEAD;
|
||||||
|
p_ctx->ch = 0u;
|
||||||
|
p_ctx->head_done = 0u;
|
||||||
|
p_ctx->ch_done = 0u;
|
||||||
|
p_ctx->tail_done = 0u;
|
||||||
|
p_ctx->rsv = 0u;
|
||||||
|
p_ctx->crc_data = WAVE_ST_CRC_INIT;
|
||||||
|
p_ctx->sent = 0u;
|
||||||
|
p_ctx->p_reader = p_reader;
|
||||||
|
p_ctx->p_user = p_user;
|
||||||
|
p_ctx->meta = *p_meta;
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:取整段字节流的下一片
|
||||||
|
* 入口参数:p_ctx 游标;p_out 缓冲;a_cap 容量;p_len 实际产出字节数
|
||||||
|
* 返 回 值:WAVE_ST_OK 还有后续;WAVE_ST_DONE 整段产完;负 = WAVE_ST_E_* int
|
||||||
|
* 限定条件:任意上下文,同一游标不得并发使用
|
||||||
|
* 函数说明:三段推进——头(一次给完,要求容量 >= 整头)、数据区(按列连续、整样本切,
|
||||||
|
* 绝不切半个 int16,也不跨列,便于回调按列读)、尾 CRC 两字节。
|
||||||
|
* 数据 CRC 边产边算(增量中间值),所以全程不需要整段驻留 RAM。
|
||||||
|
*/
|
||||||
|
int wave_store_pack_next(wave_pack_ctx_t *p_ctx, uint8_t *p_out,
|
||||||
|
uint32_t a_cap, uint32_t *p_len) {
|
||||||
|
uint32_t n = 0u;
|
||||||
|
|
||||||
|
if ((p_ctx == NULL) || (p_out == NULL) || (p_len == NULL)) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
*p_len = 0u;
|
||||||
|
if ((p_ctx->phase == (uint8_t)WAVE_PACK_IDLE) || (a_cap == 0u)) {
|
||||||
|
return WAVE_ST_E_STATE;
|
||||||
|
}
|
||||||
|
if (p_ctx->phase == (uint8_t)WAVE_PACK_DONE) {
|
||||||
|
return WAVE_ST_DONE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 头:整头一次产完(接线索按 §7 给 256 字节缓冲,足够 128 字节整头) --- */
|
||||||
|
if (p_ctx->phase == (uint8_t)WAVE_PACK_HEAD) {
|
||||||
|
uint32_t head_sz;
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
head_sz = wave_store_head_bytes(p_ctx->meta.ch_count);
|
||||||
|
if (head_sz == 0u) {
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
if (a_cap < head_sz) {
|
||||||
|
/* 不拆着头给:半截头意味着游标要存 128 字节中间态,纯浪费 */
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
rc = wave_store_head_build(&p_ctx->meta, p_out, a_cap);
|
||||||
|
if (rc < 0) {
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
n = (uint32_t)rc;
|
||||||
|
p_ctx->head_done = (uint16_t)n;
|
||||||
|
p_ctx->sent += n;
|
||||||
|
*p_len = n;
|
||||||
|
p_ctx->phase = (uint8_t)WAVE_PACK_DATA;
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 数据区:按列推进,一次最多一批 --- */
|
||||||
|
if (p_ctx->phase == (uint8_t)WAVE_PACK_DATA) {
|
||||||
|
int16_t batch[WAVE_ST_BATCH];
|
||||||
|
|
||||||
|
if (p_ctx->p_reader == NULL) {
|
||||||
|
/* 没有数据源却进了数据区:宁可报错也不产空字节。
|
||||||
|
* 产空字节会写出「长度对、内容是 0」的段文件,回放页会画出一条直线——
|
||||||
|
* 那正是 §8.1 明令禁止的伪造证据形态。 */
|
||||||
|
return WAVE_ST_E_STATE;
|
||||||
|
}
|
||||||
|
while ((n < a_cap) && (p_ctx->ch < p_ctx->meta.ch_count)) {
|
||||||
|
uint32_t room_pts = (a_cap - n) / 2u;
|
||||||
|
uint32_t col_left = (uint32_t)p_ctx->meta.pts_per_ch - (uint32_t)p_ctx->ch_done;
|
||||||
|
uint32_t take;
|
||||||
|
int32_t got;
|
||||||
|
uint32_t i;
|
||||||
|
|
||||||
|
if (col_left == 0u) {
|
||||||
|
p_ctx->ch++;
|
||||||
|
p_ctx->ch_done = 0u;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (room_pts == 0u) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
take = (room_pts < col_left) ? room_pts : col_left;
|
||||||
|
if (take > (uint32_t)WAVE_ST_BATCH) {
|
||||||
|
take = (uint32_t)WAVE_ST_BATCH;
|
||||||
|
}
|
||||||
|
got = p_ctx->p_reader(p_ctx->p_user, p_ctx->ch, (uint32_t)p_ctx->ch_done,
|
||||||
|
take, batch);
|
||||||
|
if (got < 0) {
|
||||||
|
return (int)got;
|
||||||
|
}
|
||||||
|
if ((uint32_t)got != take) {
|
||||||
|
/* 数据源给不满就是数据源的问题(越界 / 未封存),不拿半批凑长度 */
|
||||||
|
return WAVE_ST_E_STATE;
|
||||||
|
}
|
||||||
|
uint32_t base_n = n;
|
||||||
|
for (i = 0u; i < take; i++) {
|
||||||
|
wave_st_put_code(&p_out[n], batch[i]);
|
||||||
|
n += 2u;
|
||||||
|
}
|
||||||
|
p_ctx->crc_data = wave_store_crc_update(p_ctx->crc_data, &p_out[base_n], take * 2u);
|
||||||
|
p_ctx->ch_done = (uint16_t)((uint32_t)p_ctx->ch_done + take);
|
||||||
|
}
|
||||||
|
if (p_ctx->ch >= p_ctx->meta.ch_count) {
|
||||||
|
/* 最后一列的最后一点产出后,「列走完了」要到下一次进循环才发现,
|
||||||
|
* 所以这一拍可能一个字节都没产。这时候不许当参数错报出去:整段就差
|
||||||
|
* 最后两片 CRC,报 E_PARAM 会让落盘道把一次真故障判成落盘失败(白丢)。
|
||||||
|
* 让它落到下面的尾段分支,在同一次调用里把尾产完。 */
|
||||||
|
p_ctx->phase = (uint8_t)WAVE_PACK_TAIL;
|
||||||
|
}
|
||||||
|
p_ctx->sent += n;
|
||||||
|
*p_len = n;
|
||||||
|
if (n > 0u) {
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
if (p_ctx->phase != (uint8_t)WAVE_PACK_TAIL) {
|
||||||
|
return WAVE_ST_E_PARAM; /* 容量连一个整样本都装不下 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- 尾:数据区 CRC,低字节在前 --- */
|
||||||
|
if (p_ctx->phase == (uint8_t)WAVE_PACK_TAIL) {
|
||||||
|
if (p_ctx->tail_done == 0u) {
|
||||||
|
p_out[n] = (uint8_t)(p_ctx->crc_data & 0xFFu);
|
||||||
|
n++;
|
||||||
|
p_ctx->tail_done = 1u;
|
||||||
|
}
|
||||||
|
if ((p_ctx->tail_done == 1u) && (n < a_cap)) {
|
||||||
|
p_out[n] = (uint8_t)((p_ctx->crc_data >> 8) & 0xFFu);
|
||||||
|
n++;
|
||||||
|
p_ctx->tail_done = 2u;
|
||||||
|
p_ctx->phase = (uint8_t)WAVE_PACK_DONE;
|
||||||
|
}
|
||||||
|
p_ctx->sent += n;
|
||||||
|
*p_len = n;
|
||||||
|
return (p_ctx->phase == (uint8_t)WAVE_PACK_DONE) ? WAVE_ST_DONE : WAVE_ST_OK;
|
||||||
|
}
|
||||||
|
return WAVE_ST_E_STATE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:游标已产出字节数
|
||||||
|
* 入口参数:p_ctx 游标
|
||||||
|
* 返 回 值:字节数;空指针返回 0 uint32_t
|
||||||
|
* 限定条件:任意上下文(只读)
|
||||||
|
*/
|
||||||
|
uint32_t wave_store_pack_offset(const wave_pack_ctx_t *p_ctx) {
|
||||||
|
return (p_ctx == NULL) ? 0u : p_ctx->sent;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:游标对应整段的期望总长
|
||||||
|
* 入口参数:p_ctx 游标
|
||||||
|
* 返 回 值:字节数;空指针返回 0 uint32_t
|
||||||
|
* 限定条件:任意上下文(只读)
|
||||||
|
*/
|
||||||
|
uint32_t wave_store_pack_total(const wave_pack_ctx_t *p_ctx) {
|
||||||
|
return (p_ctx == NULL) ? 0u : p_ctx->total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== 段文件名轮转(只算名字,不碰文件系统) ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:内部——把两位十进制写进缓冲
|
||||||
|
* 入口参数:p 目标(至少 2 字节);a_idx 0..99
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:a_idx 已由调用方限制在 0..99
|
||||||
|
*/
|
||||||
|
static void wave_st_put_idx2(char *p, uint8_t a_idx) {
|
||||||
|
p[0] = (char)('0' + (a_idx / 10u));
|
||||||
|
p[1] = (char)('0' + (a_idx % 10u));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:按段号生成段文件路径
|
||||||
|
* 入口参数:p_out 缓冲;a_cap 容量;a_idx 段号 0..99
|
||||||
|
* 返 回 值:>= 0 写入字符数(不含结束符);WAVE_ST_E_PARAM / WAVE_ST_E_RANGE int
|
||||||
|
* 限定条件:任意上下文;纯字符拼装(不用格式化函数),板端与 PC 侧逐字节一致
|
||||||
|
* 函数说明:形态 = 目录 / 前缀 + 两位十进制 + 扩展名,严格 8.3(§5.2 第三条)。
|
||||||
|
*/
|
||||||
|
int wave_store_seg_path(char *p_out, uint32_t a_cap, uint8_t a_idx) {
|
||||||
|
static const char s_dir[] = WAVE_ST_DIR;
|
||||||
|
static const char s_pfx[] = WAVE_ST_PFX;
|
||||||
|
static const char s_ext[] = WAVE_ST_EXT;
|
||||||
|
uint32_t pos = 0u;
|
||||||
|
uint32_t i;
|
||||||
|
char buf[WAVE_ST_PATH_MAX];
|
||||||
|
|
||||||
|
if (p_out == NULL) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
if (a_idx >= (uint8_t)WAVE_ST_SEG_MAX) {
|
||||||
|
return WAVE_ST_E_RANGE;
|
||||||
|
}
|
||||||
|
for (i = 0u; i < ((uint32_t)sizeof(s_dir) - 1u); i++) {
|
||||||
|
buf[pos++] = s_dir[i];
|
||||||
|
}
|
||||||
|
buf[pos++] = '/';
|
||||||
|
for (i = 0u; i < ((uint32_t)sizeof(s_pfx) - 1u); i++) {
|
||||||
|
buf[pos++] = s_pfx[i];
|
||||||
|
}
|
||||||
|
wave_st_put_idx2(&buf[pos], a_idx);
|
||||||
|
pos += 2u;
|
||||||
|
for (i = 0u; i < ((uint32_t)sizeof(s_ext) - 1u); i++) {
|
||||||
|
buf[pos++] = s_ext[i];
|
||||||
|
}
|
||||||
|
if (pos >= (uint32_t)sizeof(buf)) {
|
||||||
|
/* 宏与拼装长度漂移时的兜底:宁可给不出名字,也绝不把结束符写到别人缓冲外 */
|
||||||
|
return WAVE_ST_E_SIZE;
|
||||||
|
}
|
||||||
|
buf[pos] = '\0';
|
||||||
|
/* 容量必须容得下结束符:写成 a_cap < pos 会放过「刚好差一个字节」的调用方,
|
||||||
|
* 于是把 p_out[pos] 写到别人缓冲外面(越界写在板子上是静默的) */
|
||||||
|
if (a_cap <= pos) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
for (i = 0u; i <= pos; i++) {
|
||||||
|
p_out[i] = buf[i];
|
||||||
|
}
|
||||||
|
return (int)pos;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:下一个轮转段号
|
||||||
|
* 入口参数:a_idx 当前段号(越界按 0 处理)
|
||||||
|
* 返 回 值:下一个段号 uint8_t
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
*/
|
||||||
|
uint8_t wave_store_next_idx(uint8_t a_idx) {
|
||||||
|
if (a_idx >= (uint8_t)WAVE_ST_SEG_MAX) {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
if (a_idx >= ((uint8_t)WAVE_ST_SEG_MAX - 1u)) {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
return (uint8_t)(a_idx + 1u);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:内部——大小写无关地比对一个字符
|
||||||
|
* 入口参数:a 待测字符(视作 ASCII);b 期望字符(取自段名宏)
|
||||||
|
* 返 回 值:1 相同;0 不同 uint8_t
|
||||||
|
* 限定条件:两侧都是 ASCII;非字母字符原样比
|
||||||
|
* 函数说明:**两侧都**归一化成小写再比。只归一化一侧是个真踩过一次的坑——
|
||||||
|
* 段名宏 WAVE_ST_PFX / WAVE_ST_EXT 是大写形态(FatFs 8.3 短名就是大写),
|
||||||
|
* 只把待测串转小写等于「小写 vs 大写」永不相等,反解会拒绝掉全部合法段名
|
||||||
|
* (单测 用例:首轮 100% 拒绝就是这个)。盘上大小写与宏写法
|
||||||
|
* 谁都不该去猜,故两侧各归一一次。
|
||||||
|
*/
|
||||||
|
static uint8_t wave_st_ci_eq(char a, char b) {
|
||||||
|
char x = a;
|
||||||
|
char y = b;
|
||||||
|
|
||||||
|
if ((x >= 'A') && (x <= 'Z')) {
|
||||||
|
x = (char)(x + ('a' - 'A'));
|
||||||
|
}
|
||||||
|
if ((y >= 'A') && (y <= 'Z')) {
|
||||||
|
y = (char)(y + ('a' - 'A'));
|
||||||
|
}
|
||||||
|
return (x == y) ? 1u : 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:从文件名或路径反解段号
|
||||||
|
* 入口参数:p_path 路径或纯文件名;p_idx 输出
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针;WAVE_ST_E_NAME 不是本层命名形态 int
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
* 函数说明:只认 basename 上的「wave + 两位数字 + .bin」(大小写都收)。
|
||||||
|
* 不匹配就返回错误,**绝不**把陌生名字当录波段——扫目录重建段账时误认一个
|
||||||
|
* 别的文件,等于把别人的文件当波形解释(名字/参数类错误码严禁升级成卷操作,
|
||||||
|
* 存储架构说明 §10.1 同一条教训)。
|
||||||
|
*/
|
||||||
|
int wave_store_idx_from_name(const char *p_path, uint8_t *p_idx) {
|
||||||
|
static const char s_pfx[] = WAVE_ST_PFX;
|
||||||
|
static const char s_ext[] = WAVE_ST_EXT;
|
||||||
|
const char *base = p_path;
|
||||||
|
const char *q = p_path;
|
||||||
|
uint32_t i;
|
||||||
|
uint32_t len = 0u;
|
||||||
|
uint8_t hi;
|
||||||
|
uint8_t lo;
|
||||||
|
|
||||||
|
if ((p_path == NULL) || (p_idx == NULL)) {
|
||||||
|
return WAVE_ST_E_PARAM;
|
||||||
|
}
|
||||||
|
/* 找 basename:支持反斜杠形态(PC 侧脚本拼出来的路径可能被原样传进来) */
|
||||||
|
while (*q != '\0') {
|
||||||
|
if ((*q == '/') || (*q == '\\')) {
|
||||||
|
base = q + 1;
|
||||||
|
}
|
||||||
|
q++;
|
||||||
|
}
|
||||||
|
q = base;
|
||||||
|
while ((*q != '\0') && (len < (uint32_t)WAVE_ST_PATH_MAX)) {
|
||||||
|
q++;
|
||||||
|
len++;
|
||||||
|
}
|
||||||
|
if ((*q != '\0') || (len != ((uint32_t)sizeof(s_pfx) - 1u) + 2u +
|
||||||
|
((uint32_t)sizeof(s_ext) - 1u))) {
|
||||||
|
return WAVE_ST_E_NAME;
|
||||||
|
}
|
||||||
|
for (i = 0u; i < ((uint32_t)sizeof(s_pfx) - 1u); i++) {
|
||||||
|
if (wave_st_ci_eq(base[i], s_pfx[i]) == 0u) {
|
||||||
|
return WAVE_ST_E_NAME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hi = (uint8_t)base[i];
|
||||||
|
lo = (uint8_t)base[i + 1u];
|
||||||
|
if ((hi < '0') || (hi > '9') || (lo < '0') || (lo > '9')) {
|
||||||
|
return WAVE_ST_E_NAME;
|
||||||
|
}
|
||||||
|
for (i = (i + 2u); i < len; i++) {
|
||||||
|
if (wave_st_ci_eq(base[i], s_ext[i - ((uint32_t)sizeof(s_pfx) - 1u) - 2u]) == 0u) {
|
||||||
|
return WAVE_ST_E_NAME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*p_idx = (uint8_t)(((hi - '0') * 10u) + (lo - '0'));
|
||||||
|
return WAVE_ST_OK;
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:录波段格式层(故障录波 · 段文件字节布局编解码 + 打包游标 + 段名轮转)
|
||||||
|
* 模块功能:把「一段录波」在内存字节缓冲与结构化元数据之间逐字节翻译,并给落盘道
|
||||||
|
* 提供分次搬运的打包游标。口径唯一属主 = docs/子系统设计/故障录波设计说明.md §6.2
|
||||||
|
* (字节布局)与 §6.3(九问裁定:板端编码器与 PC 解码器 test/host/wave_read.py
|
||||||
|
* 两份独立实现的共同依据)。本文件是它的机器可执行形态,不在此重新定义任何口径;
|
||||||
|
* 两处冲突以设计文档为准并回报 lead,不在代码里单方面「修正」。
|
||||||
|
* 纯逻辑层纪律:不 include HAL / CMSIS / RTOS 头、不调 FatFs、不碰文件系统、
|
||||||
|
* 不建任务、不打日志(文件 I/O 与任务钩子全在接线层 App/task/ 里做),
|
||||||
|
* 因此可被主机侧单测 test/host/wave_store/ 原样纳入做逐字节断言。
|
||||||
|
* 职责边界(为什么这样切):
|
||||||
|
* 1) 固定头 + 通道表编解码、头 CRC 与数据区 CRC 自检 —— 格式知识只此一处;
|
||||||
|
* 2) 打包游标(分次搬运):文件系统唯一写者只要「每次给我不超过 256 字节的下一片」,
|
||||||
|
* 整段 5250 字节不驻留 RAM(设计说明 §7:搬运缓冲 256 B × 2,不做整段驻留);
|
||||||
|
* 3) 段文件名轮转与反解:只算名字,不打开文件(FatFs FF_USE_LFN=0 只能严格
|
||||||
|
* 8.3 短名,见 docs/架构设计/STM32F4-Base存储架构说明.md §10.1);
|
||||||
|
* 4) 数据区偏移换算 + 小端解码:回放页只画一路(设计说明 §8.1),按通道分列
|
||||||
|
* 让「只读那一路」成为一次连续读。
|
||||||
|
* 不链 App/algo/adc_cal(零依赖 = 主机免桩可测):通道表里的 k_q16 / b_codes /
|
||||||
|
* 可用性由调用方在封存瞬间取 int32 原值填进 meta 后交给本层编码。系数必须走
|
||||||
|
* adc_cal_k_q16()(接线道补的 int32 直读入口),不得经 adc_cal_k_uv() 的 float
|
||||||
|
* 通道回算 —— float 在 5.28e7 量级掉尾数(设计说明 §6.3「k / b 宽度与角色」行)。
|
||||||
|
* CRC 一张表的原始说法不成立,lead 已在 §6.3 记录实测依据:全仓无可复用的
|
||||||
|
* CRC-16 表(App/util/README.md 记 crc.c 与 crc.h 已于 2026-10-02 删除;屏板链路
|
||||||
|
* Drivers/BSP/UI/ui_net_sink.c 的 remo_crc() 是单字节异或、不是 CRC-16)。
|
||||||
|
* 故本层自带无表位运算 CRC-16/MODBUS,并用规范锚点自校验。
|
||||||
|
* 适用平台:STM32F407ZGTx(Cortex-M4,小端);主机侧单测 test/host/wave_store/
|
||||||
|
* 作者:DTU 项目组
|
||||||
|
* 创建日期:2026-10-29(故障录波 task-1 · 数据面纯逻辑层)
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-29 DTU 项目组 创建:段头/通道表编解码、CRC-16/MODBUS 无表实现、
|
||||||
|
* 打包游标、段名轮转与反解、数据区偏移换算与小端解码
|
||||||
|
*
|
||||||
|
* 二期不返工的结构约束(设计说明 §2.1,改本层前先读):
|
||||||
|
* 1) 采样率与每周波点数是写进文件头的数据,不是编译期假设 —— 回放端与 PC 脚本一律
|
||||||
|
* 从头里读,本层算长度也照 meta 里的值算,不在代码里写死 1000 / 20;
|
||||||
|
* 2) 版本号并进魔数第三字节,换格式即变;本层解到不认识的版本一律判废;
|
||||||
|
* 3) 「带 TEMP 就要连 flags_seq 与 clip_mask 一起升版本」同源(§6.2 那两行的括注),
|
||||||
|
* 所以本层的通道数上限留到 9,但本波实际只用 8。
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef WAVE_STORE_H
|
||||||
|
#define WAVE_STORE_H
|
||||||
|
|
||||||
|
/* 同 App/algo/wave_ring.h:本头的 NULL 只出现在注释里(字段与入参的约束说明),
|
||||||
|
* 代码只用 stdint 的类型,故不 include stddef.h;用 NULL 的 .c 自己带。 */
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/* ===== 一、段文件布局常量(逐字段照 docs/子系统设计/故障录波设计说明.md §6.2 与 §6.3) ===== */
|
||||||
|
|
||||||
|
/* 魔数前两字节固定 'W' 与 'R',第三字节 = 版本号(设计说明 §6.2 偏移 0 行) */
|
||||||
|
#define WAVE_ST_MAGIC0 (0x57u) /* 'W' */
|
||||||
|
#define WAVE_ST_MAGIC1 (0x52u) /* 'R' */
|
||||||
|
/* 版本:本波恒 1。ver 字段必须同时等于魔数第三字节与本宏,否则整段判废(§6.2 末行) */
|
||||||
|
#define WAVE_ST_VER (1u)
|
||||||
|
|
||||||
|
/* 固定头字段偏移(字节,小端) */
|
||||||
|
#define WAVE_ST_OFF_MAGIC (0u) /* 3 字节:'W' 'R' ver */
|
||||||
|
#define WAVE_ST_OFF_VER (3u) /* 1 字节 */
|
||||||
|
#define WAVE_ST_OFF_FS_HZ (4u) /* 2 字节 本波 1000 */
|
||||||
|
#define WAVE_ST_OFF_PTS_CYC (6u) /* 1 字节 本波 20 */
|
||||||
|
#define WAVE_ST_OFF_CH_CNT (7u) /* 1 字节 本波 8 */
|
||||||
|
#define WAVE_ST_OFF_PTS_CH (8u) /* 2 字节 本波 320 */
|
||||||
|
#define WAVE_ST_OFF_PRE_PTS (10u) /* 2 字节 本波 160 */
|
||||||
|
#define WAVE_ST_OFF_TRIG_SRC (12u) /* 1 字节 */
|
||||||
|
#define WAVE_ST_OFF_TRIG_ELM (13u) /* 1 字节 */
|
||||||
|
#define WAVE_ST_OFF_T0_MS (14u) /* 4 字节 u32 */
|
||||||
|
#define WAVE_ST_OFF_WALL (18u) /* 4 字节 u32 */
|
||||||
|
#define WAVE_ST_OFF_ENG (22u) /* 1 字节 */
|
||||||
|
#define WAVE_ST_OFF_FLAGS (23u) /* 1 字节 */
|
||||||
|
#define WAVE_ST_OFF_CLIP (24u) /* 2 字节 */
|
||||||
|
#define WAVE_ST_OFF_SEQ (26u) /* 4 字节 u32 */
|
||||||
|
#define WAVE_ST_OFF_TBL (30u) /* 通道表起点 */
|
||||||
|
|
||||||
|
/* 通道表一路 12 字节 = 语义号 1 + 来源转换器 1 + 位宽 1 + 可用性 1 + k_q16 4 + b_codes 4
|
||||||
|
* 设计说明 §6.3「LSB 从哪来」:不设独立 LSB 字段,k_q16 就是 LSB(单位 µV/码 × 65536) */
|
||||||
|
#define WAVE_ST_CH_OFF_SEM (0u) /* 1 字节 */
|
||||||
|
#define WAVE_ST_CH_OFF_CONV (1u) /* 1 字节 */
|
||||||
|
#define WAVE_ST_CH_OFF_BITS (2u) /* 1 字节 */
|
||||||
|
#define WAVE_ST_CH_OFF_AVAIL (3u) /* 1 字节 = adc_cal_flags(sem) 低 8 位原值,不自造位义 */
|
||||||
|
#define WAVE_ST_CH_OFF_KQ16 (4u) /* 4 字节 int32 */
|
||||||
|
#define WAVE_ST_CH_OFF_BCODE (8u) /* 4 字节 int32 */
|
||||||
|
#define WAVE_ST_CH_BYTES (12u)
|
||||||
|
|
||||||
|
/* 本波通道数固定 8(TEMP 不录:理由见 §6.3 末段与 docs/任务规划/数据可信.md §7) */
|
||||||
|
#define WAVE_ST_CH_NUM (8u)
|
||||||
|
/* 通道数上限:给二期「带 TEMP 且同时升版本」留位;真正的口径是 meta 里的 ch_count 字段 */
|
||||||
|
#define WAVE_ST_CH_MAX (9u)
|
||||||
|
/* 每通道点数默认(= 预触发 + 后触发 = 160 + 160;长度是文件里的数据,宏只作默认值) */
|
||||||
|
#define WAVE_ST_PTS_DEF (320u)
|
||||||
|
#define WAVE_ST_PRE_DEF (160u)
|
||||||
|
#define WAVE_ST_FS_DEF (1000u)
|
||||||
|
#define WAVE_ST_PPC_DEF (20u)
|
||||||
|
|
||||||
|
/* 尺寸一律由字段算出,不写死字面量(改通道数/点数时自动跟着走):
|
||||||
|
* 头 CRC 覆盖区 = 30 + 12 × ch_count;头 CRC 自身紧随其后 2 字节;数据区自 32 + 12 × ch 起
|
||||||
|
* ch_count = 8 时:覆盖区 126、整头 128、数据区 5120、整段 5250(§6.3「CRC 覆盖范围」行) */
|
||||||
|
#define WAVE_ST_HEAD_CRC_POS(ch) ((uint32_t)(WAVE_ST_OFF_TBL) + \
|
||||||
|
((uint32_t)(ch) * WAVE_ST_CH_BYTES))
|
||||||
|
#define WAVE_ST_HEAD_BYTES(ch) ((uint32_t)(WAVE_ST_HEAD_CRC_POS(ch)) + 2u)
|
||||||
|
#define WAVE_ST_DATA_BYTES(ch, pts) ((uint32_t)(pts) * (uint32_t)(ch) * 2u)
|
||||||
|
#define WAVE_ST_SEG_TOTAL(ch, pts) (WAVE_ST_HEAD_BYTES(ch) + \
|
||||||
|
WAVE_ST_DATA_BYTES(ch, pts) + 2u)
|
||||||
|
/* 本波实际值(只给注释与单测引用,代码里一律用上面的参数化形态) */
|
||||||
|
#define WAVE_ST_HEAD_SZ_CUR (128u)
|
||||||
|
#define WAVE_ST_SEG_SZ_CUR (5250u)
|
||||||
|
|
||||||
|
/* 段目录与轮转(设计说明 §5.2 第三条:只能严格 8.3 短名;目录与 /log 平级、FTP 根下可见)
|
||||||
|
* 名字形态 = 目录 + 前缀 + 两位十进制 + 扩展名,号 0..99 共 100 个轮转覆盖 */
|
||||||
|
#define WAVE_ST_DIR "/wave"
|
||||||
|
#define WAVE_ST_PFX "WAVE"
|
||||||
|
#define WAVE_ST_EXT ".BIN"
|
||||||
|
#define WAVE_ST_SEG_MAX (100u)
|
||||||
|
#define WAVE_ST_PATH_MAX (17u) /* /wave/WAVE00.BIN = 16 字符 + 结束符 = 17 字节 */
|
||||||
|
|
||||||
|
/* CRC-16/MODBUS:多项式 0x8005 反射形态 = 0xA001、初值 0xFFFF、异或输出 0、低字节在前 */
|
||||||
|
#define WAVE_ST_CRC_POLY (0xA001u)
|
||||||
|
#define WAVE_ST_CRC_INIT (0xFFFFu)
|
||||||
|
#define WAVE_ST_CRC_XOROUT (0x0000u)
|
||||||
|
/* 自检锚点:串 123456789 的 CRC(§6.3 首行钉的常量) */
|
||||||
|
#define WAVE_ST_CRC_CHECK (0x4B37u)
|
||||||
|
|
||||||
|
/* ===== 二、返回码(int 通道;调用方必须接住或显式 (void),不得裸丢) ===== */
|
||||||
|
|
||||||
|
#define WAVE_ST_OK (0) /* 成功 */
|
||||||
|
#define WAVE_ST_DONE (1) /* 打包游标:整段已产完(不是错误) */
|
||||||
|
#define WAVE_ST_E_PARAM (-1) /* 空指针 / 容量不足 / 字段非法 */
|
||||||
|
#define WAVE_ST_E_CRC (-2) /* CRC 不符(头或数据区):整段判废 */
|
||||||
|
#define WAVE_ST_E_FORMAT (-3) /* 魔数不符 / 版本不认识 / 表内语义号不等于下标 */
|
||||||
|
#define WAVE_ST_E_SIZE (-4) /* 长度为 0 或长度与头部字段矛盾:整段判废 */
|
||||||
|
#define WAVE_ST_E_RANGE (-5) /* 列号 / 点下标 / 段号越界 */
|
||||||
|
#define WAVE_ST_E_STATE (-6) /* 状态错:未封存就读、未 start 就步进、缺数据源 */
|
||||||
|
#define WAVE_ST_E_NAME (-7) /* 文件名不是本层命名形态(扫目录重建段账时用) */
|
||||||
|
|
||||||
|
/* ===== 三、类型 ===== */
|
||||||
|
|
||||||
|
/* 触发源(§6.2 偏移 12 的取值;0 表示「无触发」,只出现在段外的状态查询里) */
|
||||||
|
typedef enum {
|
||||||
|
WAVE_TRIG_NONE = 0, /* 无 */
|
||||||
|
WAVE_TRIG_MANUAL = 1, /* 手动(UI「手动录波」确认,task-2 接线) */
|
||||||
|
WAVE_TRIG_PROTECT = 2, /* 保护启动(App/algo/prot_elm.c 的 picked 上升沿) */
|
||||||
|
WAVE_TRIG_BENCH = 3 /* bench 定周期自抓(只在 DTU_BENCH 构建里有这条来路) */
|
||||||
|
} wave_trig_src_t;
|
||||||
|
|
||||||
|
/* 折算档位(§6.2 偏移 22;单位一律以头里的 eng 为准,PC 不许按当前固件口径猜) */
|
||||||
|
typedef enum {
|
||||||
|
WAVE_ENG_PIN = 0, /* 引脚域(未标定;V0.1 产品构建停在这档) */
|
||||||
|
WAVE_ENG_SEC = 1, /* 二次值 */
|
||||||
|
WAVE_ENG_PRI = 2 /* 一次值 */
|
||||||
|
} wave_eng_t;
|
||||||
|
|
||||||
|
/* 通道表一路的内存形态(字段序 = §6.2 通道表字节序) */
|
||||||
|
typedef struct {
|
||||||
|
uint8_t sem; /* 语义号(adc_semantic_t 值;必须等于表内下标,§6.3) */
|
||||||
|
uint8_t conv; /* 来源转换器(adc_source_t 值:0 = TPAFE,1 = STM32) */
|
||||||
|
uint8_t bits; /* 转换器输出位宽(TPAFE 16 / STM32 12) */
|
||||||
|
uint8_t avail; /* 可用性 = adc_cal_flags(sem) 低 8 位原值(不自造位义) */
|
||||||
|
int32_t k_q16; /* 封存瞬间生效系数:µV/码 × 65536,就是 LSB */
|
||||||
|
int32_t b_codes; /* 封存瞬间生效零点偏移:码值 */
|
||||||
|
} wave_ch_desc_t;
|
||||||
|
|
||||||
|
/* 段头元数据 = 固定头 + 通道表的内存形态。
|
||||||
|
* 这是「待编码的段头」,不是「已写盘的证据」:打包游标只读它、不改它。 */
|
||||||
|
typedef struct {
|
||||||
|
uint16_t fs_hz; /* 采样率 Hz(从文件读出来的数据,不是编译期常量) */
|
||||||
|
uint8_t pts_per_cycle; /* 每周波点数 */
|
||||||
|
uint8_t ch_count; /* 通道数(本波 8) */
|
||||||
|
uint16_t pts_per_ch; /* 每通道点数(本波 320) */
|
||||||
|
uint16_t pre_pts; /* 预触发点数(本波 160;回放画触发光标用) */
|
||||||
|
uint8_t trig_src; /* 触发源(wave_trig_src_t) */
|
||||||
|
uint8_t trig_elm; /* 触发元件序号(prot_elm 实例下标;手动与定时为 0) */
|
||||||
|
uint32_t t0_ms; /* 段首相对上电毫秒 */
|
||||||
|
uint32_t wall; /* 对时后的 epoch 秒(未对时 0) */
|
||||||
|
uint8_t eng; /* 折算档位(wave_eng_t) */
|
||||||
|
uint8_t flags_seq; /* bit s = 语义通道 s 的 ADC_CAL_F_VALID(§6.3) */
|
||||||
|
uint16_t clip_mask; /* bit s = 语义通道 s 的 ADC_CAL_F_CLIPPED(V0.1 恒 0xE6) */
|
||||||
|
uint32_t frame_seq; /* 段首帧序号(与串口周期行的 frames 对账) */
|
||||||
|
wave_ch_desc_t ch[WAVE_ST_CH_MAX]; /* 通道表(前 ch_count 项有效) */
|
||||||
|
} wave_seg_meta_t;
|
||||||
|
|
||||||
|
/* 打包游标阶段(对外只用于「已搬完」判定与观测日志;推进全在 wave_store_pack_next 内部) */
|
||||||
|
typedef enum {
|
||||||
|
WAVE_PACK_IDLE = 0, /* 未初始化 */
|
||||||
|
WAVE_PACK_HEAD = 1, /* 正在产头(固定头 + 通道表 + 头 CRC) */
|
||||||
|
WAVE_PACK_DATA = 2, /* 正在产数据区(按通道分列,一列一列产) */
|
||||||
|
WAVE_PACK_TAIL = 3, /* 只剩数据区 CRC 两字节 */
|
||||||
|
WAVE_PACK_DONE = 4 /* 整段已产完 */
|
||||||
|
} wave_pack_phase_t;
|
||||||
|
|
||||||
|
/* 数据源回调:把「第 a_ch 列、段内第 a_from 点起的 a_cnt 个点」取进 p_dst。
|
||||||
|
* 返 回 值:实际拷贝的点数(等于 a_cnt 表示全给到);负 = 错误码 int32_t
|
||||||
|
* 只允许读已封存区:本层不提供、也不允许任何跨任务读活缓冲的路径
|
||||||
|
* (设计说明 §5.2 第一条陷阱:App/algo/sample_ring.c 的 sample_ring_window() 无临界区,
|
||||||
|
* 本波不复用它,跨任务只传「已封存」标志)。
|
||||||
|
* 生产实现 = wave_ring_read_cb()(签名一致,可直接传给 wave_store_pack_start)。 */
|
||||||
|
typedef int32_t (*wave_reader_fn)(void *p_user, uint8_t a_ch, uint32_t a_from,
|
||||||
|
uint32_t a_cnt, int16_t *p_dst);
|
||||||
|
|
||||||
|
/* 打包游标:段文件字节流的「拉模式」状态机。
|
||||||
|
* 为什么拉模式 + 小缓冲:defaultTask 是文件系统唯一写者,落盘窗口几十毫秒量级,
|
||||||
|
* 一次心跳最多一段;整段驻留 RAM 既超预算也没必要(设计说明 §7)。
|
||||||
|
* 游标不校验写回字节数 —— 那是调用方对着 f_write 做的事(§5.2 第二条陷阱:本移植
|
||||||
|
* f_write 簇分配失败会返 FR_OK + 短写、没有错误码,必须靠比对写回字节数判失败)。 */
|
||||||
|
typedef struct {
|
||||||
|
uint8_t phase; /* wave_pack_phase_t */
|
||||||
|
uint8_t ch; /* 数据区当前列 */
|
||||||
|
uint16_t head_done; /* 头区已产出字节数(含头 CRC 两字节) */
|
||||||
|
uint16_t ch_done; /* 当前列已产出点数 */
|
||||||
|
uint8_t tail_done; /* 尾部数据 CRC 已产出字节数(0..2) */
|
||||||
|
uint8_t rsv; /* 补齐用,保持结构体偶数字节 */
|
||||||
|
uint16_t crc_data; /* 数据区运行 CRC(增量中间值,避免整段驻留) */
|
||||||
|
uint32_t total; /* 整段期望字节数 */
|
||||||
|
uint32_t sent; /* 已产出字节数(= 下一片的文件偏移) */
|
||||||
|
wave_seg_meta_t meta; /* 段头快照(start 时拷入;搬运期间调用方改自己那份不影响) */
|
||||||
|
wave_reader_fn p_reader; /* 数据源;NULL = 只产头 */
|
||||||
|
void *p_user; /* 数据源上下文 */
|
||||||
|
} wave_pack_ctx_t;
|
||||||
|
|
||||||
|
/* ===== 四、CRC-16/MODBUS(无表位运算) ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:CRC-16/MODBUS 增量计算(输入输出均低位在前)
|
||||||
|
* 入口参数:a_crc 上一次的中间值(首次传 WAVE_ST_CRC_INIT)
|
||||||
|
* p_data 数据指针(NULL 且 a_len = 0 视为空操作,原样返回 a_crc)
|
||||||
|
* a_len 字节数
|
||||||
|
* 返 回 值:新的中间值(写文件时低字节在前,见 §6.3 首行) uint16_t
|
||||||
|
* 限定条件:任意上下文(含 adcTask 之外的 defaultTask / uiTask);纯计算、无静态状态、
|
||||||
|
* 不碰外设、不取临界区
|
||||||
|
* 函数说明:为什么自带实现 —— 全仓没有可复用的 CRC-16 表(依据见本文件头注)。
|
||||||
|
* 无表 = 零新增表 RAM 与表 Flash;数据区 5120 字节每字节 8 次移位,在 168 MHz
|
||||||
|
* 上量级是几十微秒,且只在 defaultTask 的落盘链上出现,不在 1 kHz 采集链上。
|
||||||
|
*/
|
||||||
|
uint16_t wave_store_crc_update(uint16_t a_crc, const uint8_t *p_data, uint32_t a_len);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:CRC-16/MODBUS 一次算完(从初值起步)
|
||||||
|
* 入口参数:p_data 数据指针
|
||||||
|
* a_len 字节数
|
||||||
|
* 返 回 值:CRC uint16_t
|
||||||
|
* 限定条件:任意上下文。p_data 为 NULL 或 a_len = 0 时返回 WAVE_ST_CRC_INIT —— 这是
|
||||||
|
* 空输入的定义值,不是「算好的段 CRC」:零长度一律判废,调用方必须先判长度
|
||||||
|
* (设计说明 §6.2 末行:长度为 0 → 整段作废)
|
||||||
|
*/
|
||||||
|
uint16_t wave_store_crc(const uint8_t *p_data, uint32_t a_len);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:CRC 自检 —— 用规范锚点验算实现是否仍是 CRC-16/MODBUS
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:WAVE_ST_OK 相符;WAVE_ST_E_FORMAT 不符(实现或编译配置被改坏) int
|
||||||
|
* 限定条件:任意上下文;开机或首次落盘前调一次即可,不在每拍路径上
|
||||||
|
* 函数说明:锚点 = 串 123456789 的 CRC = 0x4B37(§6.3)。这条是机器可执行的口径锚,
|
||||||
|
* 防的是「自算自验永远相符」:只有对已知常数才咬得住(换机、换编译器、
|
||||||
|
* 反射方向写反都在这里暴露)。落盘前失败就自弃本段并计入 err(§9 观测行)。
|
||||||
|
*/
|
||||||
|
int wave_store_crc_selftest(void);
|
||||||
|
|
||||||
|
/* ===== 五、段头编解码 ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把段头元数据填成本波默认值(只填格式侧的静态事实)
|
||||||
|
* 入口参数:p_meta 输出(调用方持有的结构;本层不返回静态地址)
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针 int
|
||||||
|
* 限定条件:任意上下文;不碰文件系统
|
||||||
|
* 函数说明:填 fs_hz = 1000、pts_per_cycle = 20、ch_count = 8、pts_per_ch = 320、
|
||||||
|
* pre_pts = 160,以及通道表的 sem = 下标 + conv / bits 按两片转换器
|
||||||
|
* (UA / UN / IA 走 TPAFE 16 位;UB / UC / IB / IC / IN 走片内 ADC 12 位 ——
|
||||||
|
* 通道归属口径属主是 docs/硬件平台/ADC通道映射说明.md,本函数只是它的抄本,
|
||||||
|
* 单测里对 0xE6 的算术交叉验证会同时钉住这套顺序)。
|
||||||
|
* 不填 avail / k_q16 / b_codes / trig_* / t0_ms / wall / eng / flags_seq /
|
||||||
|
* clip_mask / frame_seq:那些是封存瞬间的事实,必须由调用方现取现填
|
||||||
|
* (adc_cal_k_q16 / adc_cal_flags / sys_clock / wave_ring),本层不链它们。
|
||||||
|
*/
|
||||||
|
int wave_store_meta_init(wave_seg_meta_t *p_meta);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把段头元数据编码成段文件头字节流(固定头 + 通道表 + 头 CRC,全小端)
|
||||||
|
* 入口参数:p_meta 段头元数据(只读)
|
||||||
|
* p_out 输出缓冲
|
||||||
|
* a_cap 缓冲容量(须不小于按 ch_count 算出的整头字节数)
|
||||||
|
* 返 回 值:不小于 0 = 实际写入字节数;负 = WAVE_ST_E_* int
|
||||||
|
* 限定条件:任意上下文;无静态缓冲、可重入;不碰文件系统
|
||||||
|
* 函数说明:编码前先自检字段一致性 —— ch_count 为 0 或越界、pts_per_ch = 0、
|
||||||
|
* pre_pts 大于 pts_per_ch、表内语义号不等于下标(§6.3「通道表序与列序」那条
|
||||||
|
* PC 侧判据在编码侧同样成立)一律拒绝:不把坏头写成看起来像样的文件。
|
||||||
|
*/
|
||||||
|
int wave_store_head_build(const wave_seg_meta_t *p_meta, uint8_t *p_out, uint32_t a_cap);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把段文件头字节流解回元数据,并做全套判废检查(魔数 / 版本 / 长度 / 表序 / 头 CRC)
|
||||||
|
* 入口参数:p_buf 头字节流
|
||||||
|
* a_len 可读字节数
|
||||||
|
* p_meta 输出元数据(判废时内容不保证有效,调用方不得消费)
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针或缓冲不足;WAVE_ST_E_FORMAT 魔数 / 版本 /
|
||||||
|
* 表序不符;WAVE_ST_E_SIZE 零长度或长度与字段矛盾;WAVE_ST_E_CRC 头 CRC 不符 int
|
||||||
|
* 限定条件:任意上下文(uiTask 与 defaultTask 都可调)。UI 与 PC 侧必须用同一套判废语义:
|
||||||
|
* 解不过就只能显示「该段不可读」,绝不允许退化画一条直线
|
||||||
|
* (设计说明 §8.1 空态行:画直线等于伪造证据)
|
||||||
|
* 函数说明:a_len 不足「按头里 ch_count 推出来的整头长度」时按缓冲不足返回,不猜没读到
|
||||||
|
* 的字节;ver 不等于魔数第三字节、或不等于 WAVE_ST_VER 一律判废。
|
||||||
|
*/
|
||||||
|
int wave_store_head_parse(const uint8_t *p_buf, uint32_t a_len, wave_seg_meta_t *p_meta);
|
||||||
|
|
||||||
|
/* ===== 六、整段校验与长度 / 偏移换算(读侧) ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:整段镜像自检 —— 头 + 数据区 + 数据 CRC 三道全过才算「这一段可读」
|
||||||
|
* 入口参数:p_img 整段文件镜像(调用方已读进内存)
|
||||||
|
* a_len 镜像长度
|
||||||
|
* p_meta 输出段头(可为 NULL)
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针;WAVE_ST_E_SIZE 长度为 0 或与期望总长不符;
|
||||||
|
* WAVE_ST_E_CRC 任一 CRC 不符;WAVE_ST_E_FORMAT 头本身判废 int
|
||||||
|
* 限定条件:任意上下文;只在整段驻留(本波 5250 字节)时用。回放页不要走这条 ——
|
||||||
|
* 用 wave_store_head_parse + wave_store_data_off + wave_store_crc_update 分段查,
|
||||||
|
* 才符合 §7 的 256 字节读窗预算
|
||||||
|
* 函数说明:这是「段文件 CRC 不符时脚本必须拒绝而不是画出东西」那条判据在板侧的同一份
|
||||||
|
* 实现,也是本层与 PC 解码器逐字节同解的对照物(两套主机单测互验)。
|
||||||
|
*/
|
||||||
|
int wave_store_seg_check(const uint8_t *p_img, uint32_t a_len, wave_seg_meta_t *p_meta);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:按头部字段算整段文件应有的字节数(写侧记账与读侧长度判废共用)
|
||||||
|
* 入口参数:p_meta 段头元数据
|
||||||
|
* 返 回 值:期望字节数(8 路 320 点 = 5250);字段非法(含零长度)返回 0 uint32_t
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
* 函数说明:返回 0 就是「这一段连长度都不成立」,据此判废。不要拿 0 去开缓冲,
|
||||||
|
* 也不要拿 0 去比对 f_write 的写回字节数(那会把失败读成成功)。
|
||||||
|
*/
|
||||||
|
uint32_t wave_store_seg_size(const wave_seg_meta_t *p_meta);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:整头字节数(固定头 + 通道表 + 头 CRC 两字节)
|
||||||
|
* 入口参数:a_ch_count 通道数
|
||||||
|
* 返 回 值:字节数;通道数为 0 或越界返回 0 uint32_t
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
*/
|
||||||
|
uint32_t wave_store_head_bytes(uint8_t a_ch_count);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:数据区某一点在段文件里的绝对偏移(按通道分列 → 单通道是一整段连续区)
|
||||||
|
* 入口参数:a_ch_count 通道数
|
||||||
|
* a_pts_ch 每通道点数
|
||||||
|
* a_ch 列号(= 通道表下标 = 语义号,0 到 a_ch_count-1)
|
||||||
|
* a_pt 段内点下标(0 到 a_pts_ch-1)
|
||||||
|
* 返 回 值:文件偏移;参数非法返回 0xFFFFFFFFu 哨兵(这个值不是任何合法偏移) uint32_t
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
* 函数说明:回放页据此定位「只读那一路」,配合 wave_store_data_bytes_avail 一次连续读;
|
||||||
|
* PC 侧同一算法,两份实现互验。
|
||||||
|
*/
|
||||||
|
uint32_t wave_store_data_off(uint8_t a_ch_count, uint16_t a_pts_ch,
|
||||||
|
uint8_t a_ch, uint32_t a_pt);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:从某点起当前列还能连续读多少字节(不跨列、不越段尾)
|
||||||
|
* 入口参数:a_pts_ch 每通道点数
|
||||||
|
* a_pt 起始点下标
|
||||||
|
* a_want 想读的点数
|
||||||
|
* 返 回 值:可连续读字节数 = 2 × 实际可给点数;参数非法返回 0 uint32_t
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
*/
|
||||||
|
uint32_t wave_store_data_bytes_avail(uint16_t a_pts_ch, uint32_t a_pt, uint32_t a_want);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把段文件读出的小端字节解码成 int16 点序列(按时间序,旧到新)
|
||||||
|
* 入口参数:p_raw 原始字节(来自文件,2 × 点数)
|
||||||
|
* a_len 字节数(须为偶数,且不大于 a_max_pts × 2)
|
||||||
|
* p_out 输出 int16 数组
|
||||||
|
* a_max_pts 输出容量(点数)
|
||||||
|
* p_pts 输出实际点数(可为 NULL)
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针 / 奇数字节 / 容量不足 int
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
* 函数说明:显式按小端逐字节拼装,不依赖宿主字节序,所以板端与 PC 侧同一判据。
|
||||||
|
* 片内 ADC 那几路的值本就是 0..4095 的原样正数,不做中点偏置
|
||||||
|
* (§6.3「样本编码」行:加偏置等于伪造双极性)。
|
||||||
|
*/
|
||||||
|
int wave_store_le_decode(const uint8_t *p_raw, uint32_t a_len,
|
||||||
|
int16_t *p_out, uint32_t a_max_pts, uint32_t *p_pts);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把一路的 int16 码值折成定点毫单位(回放页峰值标注用;纯整数,不用 float)
|
||||||
|
* 入口参数:p_desc 该路通道表项(k_q16 / b_codes 来自段文件头,不来自当前固件)
|
||||||
|
* a_code 原始码值
|
||||||
|
* p_milli 输出四舍五入后的毫单位(k_q16 是 µV/码 → 输出单位 mV)(不可为 NULL)
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针 int
|
||||||
|
* 限定条件:任意上下文(只在回放页与单测里用,不在 1 kHz 采集链上;内部用 int64 中间量,
|
||||||
|
* 落在这条路径上时可接受)
|
||||||
|
* 函数说明:算式 eng = (code + b) × k × 1e-6,与 App/algo/adc_cal.c 的 adc_cal_apply()
|
||||||
|
* 同一模型(§6.3)。输出单位是 mV;单位含义由段头 eng 字段决定(eng = 0 是
|
||||||
|
* 引脚域,别读成二次值)。必须用段文件里的 k / b,不许拿当前固件的系数解释
|
||||||
|
* 老文件(§12 风险表「标定落地后老文件被解释错」)。
|
||||||
|
*/
|
||||||
|
int wave_store_code_to_milli(const wave_ch_desc_t *p_desc, int16_t a_code,
|
||||||
|
int32_t *p_milli);
|
||||||
|
|
||||||
|
/* ===== 七、打包游标(分次搬运,给文件系统唯一写者) ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:初始化打包游标(段头拷进游标,随后逐次拉整段字节流)
|
||||||
|
* 入口参数:p_ctx 游标(调用方持有,栈上或静态都行;本层不记它的地址)
|
||||||
|
* p_meta 段头元数据(内部拷贝;搬运期间调用方改自己那份不影响本段)
|
||||||
|
* p_reader 数据源回调;NULL = 只产头(回放页只校验头 / 主机单测只测头)
|
||||||
|
* p_user 数据源上下文(生产传 NULL,wave_ring 是单例不需要)
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针;WAVE_ST_E_FORMAT 段头字段非法;
|
||||||
|
* WAVE_ST_E_SIZE 算不出合法长度 int
|
||||||
|
* 限定条件:任意上下文(纯计算,无临界区);一段一个游标,搬完再 start 下一段
|
||||||
|
* 函数说明:p_reader 为 NULL 而 ch_count 大于 0 时,游标仍能产头,但进数据区那一步
|
||||||
|
* 返回 WAVE_ST_E_STATE —— 不静默跳过数据区,否则调用方会写出一个
|
||||||
|
* 「长度看着对、内容是空的」段文件(那正是 §8.1 要防的伪造证据形态)。
|
||||||
|
*/
|
||||||
|
int wave_store_pack_start(wave_pack_ctx_t *p_ctx, const wave_seg_meta_t *p_meta,
|
||||||
|
wave_reader_fn p_reader, void *p_user);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:取整段字节流的下一片(分次搬运的唯一推进口)
|
||||||
|
* 入口参数:p_ctx 已 start 的游标
|
||||||
|
* p_out 输出缓冲
|
||||||
|
* a_cap 本片容量(建议 256 字节,见 §7;数据区不切半点,奇数容量会少产 1 字节)
|
||||||
|
* p_len 输出本片实际字节数(不可为 NULL)
|
||||||
|
* 返 回 值:WAVE_ST_OK 产出本片且还有后续;WAVE_ST_DONE 整段已产完(*p_len 可为 0);
|
||||||
|
* 负 = WAVE_ST_E_*(未 start / 容量为 0 / 数据源回调返错 / 游标状态错) int
|
||||||
|
* 限定条件:任意上下文,但同一游标不得并发使用(文件系统唯一写者单线程推进)
|
||||||
|
* 函数说明:头区字节流与 wave_store_head_build 完全一致(内部同一份写字段函数);
|
||||||
|
* **头那一步要求 a_cap 不小于整头字节数**(本波 8 路 = 128),否则返回
|
||||||
|
* WAVE_ST_E_PARAM 且不推进游标——不拆着头给,是为了让游标不必存 128 字节
|
||||||
|
* 中间态;§7 的 256 字节搬运缓冲天然满足。
|
||||||
|
* 数据区 CRC 走增量中间值,整段最后两片是数据 CRC 的低字节、高字节。
|
||||||
|
* 调用方每写完一片都必须比对 f_write 写回字节数,短写按失败处理(§5.2)。
|
||||||
|
*/
|
||||||
|
int wave_store_pack_next(wave_pack_ctx_t *p_ctx, uint8_t *p_out,
|
||||||
|
uint32_t a_cap, uint32_t *p_len);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读游标已产出字节数(= 当前文件偏移;供记账、观测与「写没写全」判定)
|
||||||
|
* 入口参数:p_ctx 游标
|
||||||
|
* 返 回 值:已产出字节数;p_ctx 为 NULL 返回 0 uint32_t
|
||||||
|
* 限定条件:任意上下文(只读)
|
||||||
|
*/
|
||||||
|
uint32_t wave_store_pack_offset(const wave_pack_ctx_t *p_ctx);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读游标对应整段文件的期望总长(写完与 f_size / 累计写回字节数比对)
|
||||||
|
* 入口参数:p_ctx 游标
|
||||||
|
* 返 回 值:期望总字节数;p_ctx 为 NULL 返回 0 uint32_t
|
||||||
|
* 限定条件:任意上下文(只读)
|
||||||
|
*/
|
||||||
|
uint32_t wave_store_pack_total(const wave_pack_ctx_t *p_ctx);
|
||||||
|
|
||||||
|
/* ===== 八、段文件名轮转(只算名字,不碰文件系统) ===== */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:按段号生成段文件路径(严格 8.3 短名:目录 + 前缀 + 两位十进制 + 扩展名)
|
||||||
|
* 入口参数:p_out 输出缓冲(容量须不小于 WAVE_ST_PATH_MAX)
|
||||||
|
* a_cap 容量
|
||||||
|
* a_idx 段号 0 到 WAVE_ST_SEG_MAX-1
|
||||||
|
* 返 回 值:不小于 0 = 写入的字符数(不含结束符;当前布局恒为 16,故缓冲至少要 17 字节);
|
||||||
|
* WAVE_ST_E_PARAM 空指针或容量不足;
|
||||||
|
* WAVE_ST_E_RANGE 段号越界 int
|
||||||
|
* 限定条件:任意上下文;纯字符拼装(不用 sprintf,见 CODE-RULE-06 系),逐字节可预期
|
||||||
|
* 函数说明:号 0..99 轮转覆盖,共 100 个名字,写满回绕盖最老的(§5.2 第三条)。
|
||||||
|
* 本函数不检查文件是否存在、不建目录:那些都是接线道的职责。
|
||||||
|
*/
|
||||||
|
int wave_store_seg_path(char *p_out, uint32_t a_cap, uint8_t a_idx);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:下一个轮转段号(到 99 后回 0)
|
||||||
|
* 入口参数:a_idx 当前段号(越界输入按 0 处理,不猜)
|
||||||
|
* 返 回 值:下一个段号 uint8_t
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
*/
|
||||||
|
uint8_t wave_store_next_idx(uint8_t a_idx);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:从文件名或路径反解段号(掉电后靠扫目录重建段账用)
|
||||||
|
* 入口参数:p_path 路径或纯文件名(只看最后一段,大小写都收)
|
||||||
|
* p_idx 输出段号(不可为 NULL)
|
||||||
|
* 返 回 值:WAVE_ST_OK;WAVE_ST_E_PARAM 空指针;WAVE_ST_E_NAME 不是本层命名形态 int
|
||||||
|
* 限定条件:任意上下文
|
||||||
|
* 函数说明:只认「前缀 + 两位数字 + 扩展名」,其余(目录名、长名、以点开头)一律判不匹配
|
||||||
|
* —— 扫目录重建账时不许把陌生名字当录波段;名字/参数类错误码严禁升级成卷操作
|
||||||
|
* (docs/架构设计/STM32F4-Base存储架构说明.md §10.1 的同一条教训)。
|
||||||
|
*/
|
||||||
|
int wave_store_idx_from_name(const char *p_path, uint8_t *p_idx);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* WAVE_STORE_H */
|
||||||
+1
-1
@@ -7,7 +7,7 @@
|
|||||||
| 文件 | 作用 |
|
| 文件 | 作用 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `app_main.c/h` | **应用初始化入口 `app_main_init`**:时间子系统优先 → RS485 → NAND → ADC → 诊断(在 `osKernelStart` 之前执行) |
|
| `app_main.c/h` | **应用初始化入口 `app_main_init`**:时间子系统优先 → RS485 → NAND → ADC → 诊断(在 `osKernelStart` 之前执行) |
|
||||||
| `app_cfg.h` | **功能模块编译开关**(裁剪测试套件/模块用) |
|
| `app_cfg.h` | **功能裁剪的决策占位说明**——2026-10-22 起**不含任何宏**(原 3 个 `APP_*_ENABLE` 零消费点,已删;接回方法见该文件修改记录 v1.2) |
|
||||||
| `sys_events.h` | **系统就绪事件定义**(CMSIS-RTOS v2 Event Flags:`SYS_EVT_NET_READY` / `SYS_EVT_STORAGE_READY` / `SYS_EVT_FTP_READY` / `SYS_EVT_TIMESYNC_READY` 及命令位) |
|
| `sys_events.h` | **系统就绪事件定义**(CMSIS-RTOS v2 Event Flags:`SYS_EVT_NET_READY` / `SYS_EVT_STORAGE_READY` / `SYS_EVT_FTP_READY` / `SYS_EVT_TIMESYNC_READY` 及命令位) |
|
||||||
|
|
||||||
## 初始化顺序(`app_main_init`)
|
## 初始化顺序(`app_main_init`)
|
||||||
|
|||||||
+12
-33
@@ -1,47 +1,26 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:Application Configuration
|
* 模块名称:Application Configuration
|
||||||
* 模块功能:应用层全局配置宏(**预留占位,当前均未接线**)
|
* 模块功能:功能裁剪开关的决策占位说明(**本头文件当前不提供任何宏,也不被任何 .c 包含**)
|
||||||
* 适用平台:STM32F407ZGTx
|
* 适用平台:STM32F407ZGTx
|
||||||
* 作者:王建锋
|
* 作者:王建锋
|
||||||
* 创建日期:2026-07-19
|
* 创建日期:2026-07-19
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-07-19 王建锋 创建初始版本
|
* v1.0 2026-07-19 王建锋 创建初始版本
|
||||||
* v1.1 2026-10-04 王建锋 P2-12:下列宏此前被表述为"功能模块使能开关",但全库
|
* v1.1 2026-10-04 王建锋 如实标注三个 APP_*_ENABLE 为"未接线占位"(全库零消费点)
|
||||||
* 零代码引用(grep 仅本文件有定义)——"开关名存实亡"会
|
* v1.2 2026-10-22 王建锋 收口(登记簿 C5):删除 4 个零消费点的宏
|
||||||
* 误导评审者以为模块可裁剪。现如实标注为**预留占位**:
|
* (APP_NET_ENABLE / APP_ADC_ENABLE / APP_RS485_ENABLE /
|
||||||
* 当前**不门控任何代码**,各模块一律编译进产品。是否/
|
* APP_SYS_TIME_SRC)。留"看起来能裁剪、其实不生效"的宏比不留更糟:
|
||||||
* 如何接线取决于产品定义(docs/任务规划/README.md §2 阶段 B0),
|
* 评审者会误信模块可裁剪,而关掉它们不会产生任何编译期效果。
|
||||||
* 确认后再在真实初始化点(如 app_main.c 的 rs485_init /
|
* 需要真实门控时(产品定义 B0 落定后),在真实初始化点接入并**在本
|
||||||
* tpafe5160_init)加门控;在此之前**不要**把它们当开关用。
|
* 文件登记**,同时接回 App/app/app_main.c 的 include;在此之前
|
||||||
|
* 本模块一律编译进产品(各外设的常驻事实见
|
||||||
|
* docs/环境与部署/项目部署环境要求.md §3.2)。
|
||||||
|
* 注意:本文件不是 App/app/README.md 里说的"编译开关",那行已同步更正。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#ifndef APP_CFG_H
|
#ifndef APP_CFG_H
|
||||||
#define APP_CFG_H
|
#define APP_CFG_H
|
||||||
|
|
||||||
#ifdef __cplusplus
|
/* 故意不提供任何宏:功能裁剪当前不存在。理由与接回方法见上方修改记录 v1.2。 */
|
||||||
extern "C" {
|
|
||||||
#endif
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 系统时钟源选择(**预留占位,未接线**)
|
|
||||||
* 0 - DWT CYCCNT(当前实现:console_log.c 直接使用 DWT,未读本宏)
|
|
||||||
* 1 - TIM2(未实现)
|
|
||||||
* 说明:本宏当前无任何消费点;实际的"与时间源无关硬上界"由 console_log.c 内联
|
|
||||||
* 使能 DWT 实现,不经过本宏。
|
|
||||||
*/
|
|
||||||
#define APP_SYS_TIME_SRC 0
|
|
||||||
|
|
||||||
/*
|
|
||||||
* 功能模块"预留开关"(**当前均未接线,不门控任何代码**)
|
|
||||||
* 三个宏一律保持定义(= 模块编译进产品);它们**不产生任何编译期效果**,
|
|
||||||
* 仅作为产品定义(B0)落定后接入真实门控的占位锚点。
|
|
||||||
*/
|
|
||||||
#define APP_NET_ENABLE
|
|
||||||
#define APP_ADC_ENABLE
|
|
||||||
#define APP_RS485_ENABLE
|
|
||||||
|
|
||||||
#ifdef __cplusplus
|
|
||||||
}
|
|
||||||
#endif
|
|
||||||
|
|
||||||
#endif /* APP_CFG_H */
|
#endif /* APP_CFG_H */
|
||||||
|
|||||||
+112
-42
@@ -12,24 +12,29 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
#include "app_main.h"
|
#include "app_main.h"
|
||||||
#include "app_cfg.h"
|
|
||||||
|
|
||||||
#include "main.h" /* ST_DIR4_GPIO_Port / ST_DIR4_Pin 等 CubeMX 宏 */
|
#include "main.h" /* ST_DIR4_GPIO_Port / ST_DIR4_Pin 等 CubeMX 宏 */
|
||||||
#include "stm32f4xx_it.h" /* fault_handler_enable() */
|
#include "stm32f4xx_it.h" /* fault_handler_enable() */
|
||||||
#include "usart.h" /* huart5 */
|
#include "usart.h" /* huart5 */
|
||||||
#include "tim.h" /* htim3:1 kHz 采样节拍(TIM3 更新中断触发 CONVST + TRGO 触发 ADC1) */
|
#include "tim.h" /* htim3:1 kHz 采样节拍(TIM3 更新中断触发 CONVST + TRGO 触发 ADC1) */
|
||||||
|
#include "adc.h" /* adc1_disable():挂 DMA 前把 ADC 关掉,让 Start_DMA 自己使能 */
|
||||||
#include "rs485.h"
|
#include "rs485.h"
|
||||||
|
#include "sb_link.h" /* 屏板链路传输层:sb_link_init()(USART1 收中断,批 1 的 1-3) */
|
||||||
#include "sd2506.h"
|
#include "sd2506.h"
|
||||||
|
#include "boot_stats.h" /* 开机账本:上电次数(RTC 用户 SRAM)+ 本次复位原因快照(道G task-12) */
|
||||||
#include "sys_clock.h"
|
#include "sys_clock.h"
|
||||||
#include "gd5f2gq5ue.h"
|
#include "gd5f2gq5ue.h"
|
||||||
#include "tpafe5160.h"
|
#include "tpafe5160.h"
|
||||||
#include "adc_input.h" /* L1 采集层:adc_input_init()(语义映射 + STM32 ADC1 扫描) */
|
#include "adc_input.h" /* 语义取数与有效性层:adc_input_init()(语义映射 + STM32 ADC1 扫描) */
|
||||||
#include "phasor.h" /* L2 相量层:phasor_init()(系数表 + 实例 + 双区交换) */
|
#include "adc_cal.h" /* 标定折算层:adc_cal_init()(ROM 系数 + cfg 两段注册,道A 标定折算与落盘整定) */
|
||||||
#include "freq.h" /* L2 派生量:freq_init()(相位解缠绕测频,B8) */
|
#include "run_par.h" /* 运行参数段:run_par_init()([run_par] 注册 + 104 地址映射真源,道D 这一项) */
|
||||||
#include "prot_set.h" /* L3 定值模型:prot_set_init()(B5,内核前封存) */
|
#include "iec104_comm.h" /* [comm] 段:iec104_comm_init()(ca/端口注册,道G task-8) */
|
||||||
#include "prot_elm.h" /* L3 元件框架:prot_elm_init()(B5,解析相量索引) */
|
#include "phasor.h" /* 全周傅氏相量层:phasor_init()(系数表 + 实例 + 双区交换) */
|
||||||
|
#include "freq.h" /* 全周傅氏相量的派生量:freq_init()(相位解缠绕测频,B8) */
|
||||||
|
#include "prot_set.h" /* 保护判据定值模型:prot_set_init()(B5,内核前封存) */
|
||||||
|
#include "prot_elm.h" /* 保护判据元件框架:prot_elm_init()(B5,解析相量索引) */
|
||||||
#include "sys_log.h"
|
#include "sys_log.h"
|
||||||
#include "task_watchdog.h" /* task_wd_seg_report():round 8 复位不丢"进段打点"上报 */
|
#include "task_watchdog.h" /* task_wd_seg_report():第 8 轮复位不丢"进段打点"上报 */
|
||||||
#include "test_config.h" /* 套件开关:身份戳的构建变体串(FW_BUILD_VARIANT)依赖本头,
|
#include "test_config.h" /* 套件开关:身份戳的构建变体串(FW_BUILD_VARIANT)依赖本头,
|
||||||
* 改 TEST_SUITE_* / ENABLE_DESTRUCTIVE_TESTS 必重编本 TU →
|
* 改 TEST_SUITE_* / ENABLE_DESTRUCTIVE_TESTS 必重编本 TU →
|
||||||
* __DATE__/__TIME__ 随之刷新,见 app_main_init() 的戳注释 */
|
* __DATE__/__TIME__ 随之刷新,见 app_main_init() 的戳注释 */
|
||||||
@@ -74,23 +79,11 @@ volatile uint32_t g_rcc_csr_latched;
|
|||||||
* 兜底,两者同时置位时报告看门狗更有价值)。纯 ASCII,防编码坑。
|
* 兜底,两者同时置位时报告看门狗更有价值)。纯 ASCII,防编码坑。
|
||||||
*/
|
*/
|
||||||
static void reset_cause_str(uint32_t csr, char *buf, unsigned int len) {
|
static void reset_cause_str(uint32_t csr, char *buf, unsigned int len) {
|
||||||
const char *cause;
|
/* 优先级链**不在这里再写一遍**:改由 boot_stats 出一份(IWDG > WWDG > 软件 >
|
||||||
|
* POR > PIN > BOR)。原先这条 if-else 链与页面上要显示的编码各写一份,两份早晚
|
||||||
if ((csr & RCC_CSR_IWDGRSTF) != 0u) {
|
* 会漂——漂了的症状是串口说 "IWDG"、屏幕写 "software",两条却都"看着有据"。
|
||||||
cause = "IWDG (independent watchdog)";
|
* 顺序本身的理由见本函数上方原注释与 App/util/boot_stats.h 的函数头。 */
|
||||||
} else if ((csr & RCC_CSR_WWDGRSTF) != 0u) {
|
const char *cause = boot_stats_cause_str(boot_stats_cause_from_csr(csr));
|
||||||
cause = "WWDG (window watchdog)";
|
|
||||||
} else if ((csr & RCC_CSR_SFTRSTF) != 0u) {
|
|
||||||
cause = "software (NVIC_SystemReset)";
|
|
||||||
} else if ((csr & RCC_CSR_PORRSTF) != 0u) {
|
|
||||||
cause = "power-on (POR)";
|
|
||||||
} else if ((csr & RCC_CSR_PINRSTF) != 0u) {
|
|
||||||
cause = "external pin (NRST)";
|
|
||||||
} else if ((csr & RCC_CSR_BORRSTF) != 0u) {
|
|
||||||
cause = "brown-out (BOR)";
|
|
||||||
} else {
|
|
||||||
cause = "unknown";
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 简单有界拼接(不引 snprintf,避免依赖与栈开销) */
|
/* 简单有界拼接(不引 snprintf,避免依赖与栈开销) */
|
||||||
if (buf == NULL || len == 0u) {
|
if (buf == NULL || len == 0u) {
|
||||||
@@ -116,7 +109,7 @@ static void reset_cause_str(uint32_t csr, char *buf, unsigned int len) {
|
|||||||
* 1) 时间子系统(SD2506 RTC → 墙钟基准)
|
* 1) 时间子系统(SD2506 RTC → 墙钟基准)
|
||||||
* 2) RS-485
|
* 2) RS-485
|
||||||
* 3) GD5F NAND
|
* 3) GD5F NAND
|
||||||
* 4) TPAFE5160 ADC → ADC 输入层(L1,语义映射 + STM32 ADC1)
|
* 4) TPAFE5160 ADC → ADC 输入层(语义取数与有效性,语义映射 + STM32 ADC1)
|
||||||
* 5) SD2506 诊断信息
|
* 5) SD2506 诊断信息
|
||||||
* 6) 启动 TIM3 1 kHz 采样节拍(ISR 内脉冲 CONVST;同一更新事件经 TRGO 硬件触发 ADC1 + DMA)
|
* 6) 启动 TIM3 1 kHz 采样节拍(ISR 内脉冲 CONVST;同一更新事件经 TRGO 硬件触发 ADC1 + DMA)
|
||||||
*/
|
*/
|
||||||
@@ -130,14 +123,14 @@ int app_main_init(void) {
|
|||||||
|
|
||||||
/* 日志引擎初始化(创建待发队列;须先于任何 syslog_post) */
|
/* 日志引擎初始化(创建待发队列;须先于任何 syslog_post) */
|
||||||
(void)syslog_mcu_init();
|
(void)syslog_mcu_init();
|
||||||
/* 启动锚点(2026-10-02 S10):本函数在内核启动前执行、不创建任何 RTOS 对象,
|
/* 启动锚点(2026-10-02 加):本函数在内核启动前执行、不创建任何 RTOS 对象,
|
||||||
* 恒成功且无副作用;本行是"启动进度轨迹"的起点——之后的每一步都有结果日志,
|
* 恒成功且无副作用;本行是"启动进度轨迹"的起点——之后的每一步都有结果日志,
|
||||||
* 故某次启动卡死时,最后一行日志即可指向卡住的步骤。 */
|
* 故某次启动卡死时,最后一行日志即可指向卡住的步骤。 */
|
||||||
DBG_INFO("boot: syslog init OK");
|
DBG_INFO("boot: syslog init OK");
|
||||||
|
|
||||||
/* 固件身份戳(2026-10-10):__DATE__/__TIME__ 由编译器展开,与前面的字面量做
|
/* 固件身份戳(2026-10-10):__DATE__/__TIME__ 由编译器展开,与前面的字面量做
|
||||||
* C 字符串拼接 → .rodata 里是一段连续 ASCII,可直接在 .hex 里原样搜到。
|
* C 字符串拼接 → .rodata 里是一段连续 ASCII,可直接在 .hex 里原样搜到。
|
||||||
* 用途:@test 把"芯片里正在跑的镜像"和"刚编出来的镜像"对上号。此前开机标记
|
* 用途:/serial-boot-test 把"芯片里正在跑的镜像"和"刚编出来的镜像"对上号。此前开机标记
|
||||||
* 全是固定串(App init OK / Network Init / ...),旧镜像也照样全中,于是
|
* 全是固定串(App init OK / Network Init / ...),旧镜像也照样全中,于是
|
||||||
* "反复重下载、芯片一直是旧镜像"这类事故在自动闭环里完全不可见
|
* "反复重下载、芯片一直是旧镜像"这类事故在自动闭环里完全不可见
|
||||||
* (AGENTS.md 烧录铁律记的正是它)。代价:1 条 DBG ≈ 90 B Flash + 30 B 常量。
|
* (AGENTS.md 烧录铁律记的正是它)。代价:1 条 DBG ≈ 90 B Flash + 30 B 常量。
|
||||||
@@ -165,10 +158,10 @@ int app_main_init(void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 0b) round 8 取证:上报上一轮 defaultTask 主循环的**最后进段**。
|
/* 0b) 第 8 轮取证:上报上一轮 defaultTask 主循环的**最后进段**。
|
||||||
* 轨迹槽在 .noinit(scatter UNINIT_CCM),IWDG/引脚复位不清 SRAM →
|
* 轨迹槽在 .noinit(scatter UNINIT_CCM),IWDG/引脚复位不清 SRAM →
|
||||||
* 误复位后开机第一时间可见 `PREVRUN seg=N`,直接指向主循环阻塞点
|
* 误复位后开机第一时间可见 `PREVRUN seg=N`,直接指向主循环阻塞点
|
||||||
* (round 7 观察"判定分支未执行"经 round 8b 更正:真因是 IWDG 超时被静默
|
* (第 7 轮观察"判定分支未执行"经第 8b 轮更正:真因是 IWDG 超时被静默
|
||||||
* 配成复位默认值,见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)。
|
* 配成复位默认值,见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)。
|
||||||
* 裸 UART 直发,且一次复位只报一次(内部清 magic)。 */
|
* 裸 UART 直发,且一次复位只报一次(内部清 magic)。 */
|
||||||
task_wd_seg_report();
|
task_wd_seg_report();
|
||||||
@@ -177,29 +170,58 @@ int app_main_init(void) {
|
|||||||
* 1) 时间子系统初始化(尽早完成,后续所有日志带时间戳)
|
* 1) 时间子系统初始化(尽早完成,后续所有日志带时间戳)
|
||||||
* sd2506_init() 从 RTC 读取初始时间
|
* sd2506_init() 从 RTC 读取初始时间
|
||||||
* sys_clock_init() 将 RTC 时间转为 Unix epoch,建立墙钟基准
|
* sys_clock_init() 将 RTC 时间转为 Unix epoch,建立墙钟基准
|
||||||
* S10(2026-10-02):sd2506_init 原先所有失败路径静默返回,是启动段
|
* 启动段可观测性(2026-10-02 加):sd2506_init 原先所有失败路径静默返回,是启动段
|
||||||
* 唯一无日志的初始化;现内部已逐路径打 DBG_ERROR,此处再补一行
|
* 唯一无日志的初始化;现内部已逐路径打 DBG_ERROR,此处再补一行
|
||||||
* 带错误码的结果回执,使 I2C 不通时能看到"在哪一步、什么错"。
|
* 带错误码的结果回执,使 I2C 不通时能看到"在哪一步、什么错"。
|
||||||
*/
|
*/
|
||||||
{
|
{
|
||||||
int rtc_ret = sd2506_init();
|
bsp_err_t rtc_ret = sd2506_init();
|
||||||
DBG_INFO("SD2506 init: %s (ret=%d)",
|
DBG_INFO("SD2506 init: %s (ret=%d)",
|
||||||
(rtc_ret == SD2506_OK) ? "OK" : "FAILED", rtc_ret);
|
(rtc_ret == BSP_OK) ? "OK" : "FAILED", rtc_ret);
|
||||||
|
/* 1b) 开机账本(道G task-12):紧接 sd2506_init 之后,是**唯一**的调用点。
|
||||||
|
* 为什么放这儿而不是 defaultTask 的存储 bring-up:
|
||||||
|
* ① 这一步只做 1 次 I2C 读 + 至多 1 次 I2C 写,落在内核启动前——
|
||||||
|
* 单线程、非 ISR、调度器还没跑(本函数不调任何创建类内核 API,
|
||||||
|
* AGENTS「内核前禁调创建类 API」那条与此无关);
|
||||||
|
* ② 保持介质是 RTC 用户 SRAM,不是文件系统:不必等 STORAGE_READY,
|
||||||
|
* 也就不必在喂狗点上碰卷锁(这条链上根本没有卷操作可言);
|
||||||
|
* ③ sd2506_init 失败时这里自然走"读不到"那一支:状态标成不可信、
|
||||||
|
* 且不回写,不会把可能还完好的记录覆盖掉。
|
||||||
|
* 失败一律不打日志(可观测性事件不进 [ERR]),状态留在 boot_stats_get()。 */
|
||||||
|
boot_stats_init();
|
||||||
}
|
}
|
||||||
if (sys_clock_init() != 0) {
|
if (sys_clock_init() != 0) {
|
||||||
syslog_post(SYSLOG_SELFTEST, "clock chip fault");
|
syslog_post(SYSLOG_SELFTEST, "clock chip fault");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 2) RS-485 初始化(UART4 + PD0 方向控制),初始化即自动开启接收 */
|
/* 2) RS-485 初始化(UART4 + 方向脚 = ST_DIR4 宏:V0.1 板实测 PA15,V0.0 曾是 PD0,
|
||||||
|
* 轮换记录 = docs/硬件平台/V0.1_网表详细分析报告.md 的 V0.0→V0.1 引脚迁移表),
|
||||||
|
* 初始化即自动开启接收 */
|
||||||
rs485_init(&g_rs485, &huart4, ST_DIR4_GPIO_Port, ST_DIR4_Pin);
|
rs485_init(&g_rs485, &huart4, ST_DIR4_GPIO_Port, ST_DIR4_Pin);
|
||||||
DBG_INFO("RS485 init: OK (UART4, DE=PA15)");
|
DBG_INFO("RS485 init: OK (UART4, DE=PA15)");
|
||||||
|
|
||||||
|
/* 2b) 屏板链路初始化(USART1,700 kbaud 8N1,IDLE 中断接收)。
|
||||||
|
* 放在 NAND 全片扫描之前:这一步只是挂接收(µs 级),挂上之后屏板的周期召唤
|
||||||
|
* 就已经能被收下来并计数,台架不必等启动跑完才能看到链路状态。
|
||||||
|
* 波特率不在本调用里设——在 Src/usart.c 的 USER CODE 区(CubeMX 重生成只保护那里),
|
||||||
|
* 本调用只做一次交叉核对,对不上会报 BSP_ERR_STATE 并打 ERR 行。 */
|
||||||
|
{
|
||||||
|
bsp_err_t sb_ret = sb_link_init(&huart1);
|
||||||
|
/* BRR 直接读出来打:1-2 的判据是"实测位宽误差 < 1 %",而 BRR 是位宽的唯一决定量——
|
||||||
|
* 84 MHz / (16 x 700 kHz) = 7.5 → 期望 0x0078(整数 7、小数 0.5x16 = 8),误差 0 %。
|
||||||
|
* 有这一行就不必为了核波特率去架示波器:BRR 对不上就说明 USER CODE 覆盖没生效。 */
|
||||||
|
DBG_INFO("ScreenLink init: %s (ret=%d, USART1 %lu baud, BRR=0x%04lX)",
|
||||||
|
(sb_ret == BSP_OK) ? "OK" : "FAILED",
|
||||||
|
sb_ret, (unsigned long)SB_LINK_BAUD,
|
||||||
|
(unsigned long)huart1.Instance->BRR);
|
||||||
|
}
|
||||||
|
|
||||||
/* 3) GD5F 初始化测试(BBT 全片扫描,启动段最耗时的一步) */
|
/* 3) GD5F 初始化测试(BBT 全片扫描,启动段最耗时的一步) */
|
||||||
int ret = gd5f2gq5ue_init();
|
int ret = gd5f2gq5ue_init();
|
||||||
DBG_INFO("NAND init: %s",
|
DBG_INFO("NAND init: %s",
|
||||||
(ret == GD5F_OK) ? "OK" :
|
(ret == GD5F_OK) ? "OK" :
|
||||||
(ret == GD5F_ERROR) ? "ERROR" :
|
(ret == GD5F_ERROR) ? "ERROR" :
|
||||||
(ret == GD5F_BUSY_TIMEOUT) ? "BUSY TIMEOUT" :
|
(ret == GD5F_WAIT_EXPIRED) ? "BUSY TIMEOUT" :
|
||||||
(ret == GD5F_ECC_ERROR) ? "ECC ERROR" :
|
(ret == GD5F_ECC_ERROR) ? "ECC ERROR" :
|
||||||
(ret == GD5F_PROGRAM_FAIL) ? "PROGRAM FAIL" :
|
(ret == GD5F_PROGRAM_FAIL) ? "PROGRAM FAIL" :
|
||||||
(ret == GD5F_ERASE_FAIL) ? "ERASE FAIL" :
|
(ret == GD5F_ERASE_FAIL) ? "ERASE FAIL" :
|
||||||
@@ -209,14 +231,34 @@ int app_main_init(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 4) TPAFE5160 ADC 测试 */
|
/* 4) TPAFE5160 ADC 测试 */
|
||||||
int tp_ret = tpafe5160_init();
|
bsp_err_t tp_ret = tpafe5160_init();
|
||||||
DBG_INFO("ADC init: %s",
|
DBG_INFO("ADC init: %s",
|
||||||
(tp_ret == TPAFE5160_OK) ? "OK" : "TIMEOUT");
|
(tp_ret == BSP_OK) ? "OK" :
|
||||||
if (tp_ret != TPAFE5160_OK) {
|
(tp_ret == BSP_ERR_TIMEOUT) ? "TIMEOUT" :
|
||||||
|
(tp_ret == BSP_ERR_NO_DEVICE) ? "NO CHIP" : "FAIL");
|
||||||
|
if (tp_ret != BSP_OK) {
|
||||||
syslog_post(SYSLOG_SELFTEST, "ADC fault");
|
syslog_post(SYSLOG_SELFTEST, "ADC fault");
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 4b) ADC 输入层(L1)初始化:清原始码值缓冲与统计 + **语义映射表自洽性自检**
|
/* 4b-pre) 挂 DMA 之前**先把 ADC 关掉**,让使能动作由 HAL_ADC_Start_DMA 内部完成
|
||||||
|
* (它自己 __HAL_ADC_ENABLE + 等 Tstab ~20 µs,然后紧接着开 CR2.DMA 并启流)。
|
||||||
|
*
|
||||||
|
* 为什么必须这样做:MX_ADC1_Init() 在 main() 里就调用了,而挂 DMA 在这里,
|
||||||
|
* 中间隔着 GD5F 全片 BBT 扫描(数秒)。台架实测(同一次开机内用无 DMA 单通道
|
||||||
|
* 探针取真值对照,9/9 完美置换):**ADC 使能后闲置数秒才挂 DMA,DMA 乒乓缓冲
|
||||||
|
* 会整体 +2 格旋转** —— 槽 i 装的是 rank ((i+1) mod 9)+1,9 路通道按 rank 序
|
||||||
|
* 整体错开 2 位。表象是"接地 AIN2、UB 却读到别的通道的值",极易被误判成
|
||||||
|
* 映射表写错或夹子位置不对。
|
||||||
|
* 把使能与挂 DMA 压到同一时刻后旋转消失(台架 4/4 次,同开机对照差值 ≤4 码)。
|
||||||
|
* 证据与复现方法见 docs/子系统设计/ADC采样节拍设计说明.md §4.5。
|
||||||
|
*
|
||||||
|
* ⚠ 顺序不可调换:本调用必须严格早于下面的 adc_input_init()。
|
||||||
|
* 若 ADC 在本步之后仍处于使能态,旋转会重新出现。 */
|
||||||
|
if (adc1_disable() != 0) {
|
||||||
|
DBG_ERROR("ADC1 disable before DMA arm: TIMEOUT");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 4b) ADC 输入层(语义取数与有效性)初始化:清原始码值缓冲与统计 + **语义映射表自洽性自检**
|
||||||
* (映射指向了不会被扫描的通道 → 返回 -1,避免"读到恒为陈旧值"的静默错)
|
* (映射指向了不会被扫描的通道 → 返回 -1,避免"读到恒为陈旧值"的静默错)
|
||||||
* + 挂载一次 STM32 ADC1 的 DMA(失败返回 -2)。
|
* + 挂载一次 STM32 ADC1 的 DMA(失败返回 -2)。
|
||||||
* ADC1 外设已由 MX_ADC1_Init()(main.c)配好 9 通道 / 144 cycles /
|
* ADC1 外设已由 MX_ADC1_Init()(main.c)配好 9 通道 / 144 cycles /
|
||||||
@@ -236,19 +278,47 @@ int app_main_init(void) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 4c) 相量层(L2)初始化:清双区结果与有效标志 + 复位采样环缓冲。
|
/* 4c) 相量层(全周傅氏相量)初始化:清双区结果与有效标志 + 复位采样环缓冲。
|
||||||
* 纯算法层(无外设 / 无 RTOS 依赖,见 docs/算法/继电保护功能复现规划.md
|
* 纯算法层(无外设 / 无 RTOS 依赖,见 docs/算法/继电保护功能复现规划.md
|
||||||
* §3.2),须在启动采样节拍(步骤 6)之前。幂等、当前无失败路径
|
* §3.2),须在启动采样节拍(步骤 6)之前。幂等、当前无失败路径
|
||||||
* (返回值保留供后续自检扩展)。 */
|
* (返回值保留供后续自检扩展)。 */
|
||||||
(void)phasor_init();
|
(void)phasor_init();
|
||||||
DBG_INFO("phasor layer init: OK (inst=%u)", (unsigned)phasor_inst_count());
|
DBG_INFO("phasor layer init: OK (inst=%u)", (unsigned)phasor_inst_count());
|
||||||
|
|
||||||
/* 4c-2) 测频层(L2 派生量,B8)初始化:清平均窗/相位基准/有效标志。
|
/* 4c-2) 测频层(全周傅氏相量的派生量,B8)初始化:清平均窗/相位基准/有效标志。
|
||||||
* 纯算法(输入 = phasor 使用区),消费在 adcTask(freq_run 每拍)
|
* 纯算法(输入 = phasor 使用区),消费在 adcTask(freq_run 每拍)
|
||||||
* 与 protectTask(OF/UF 判据经 prot_elm_run 取数)。 */
|
* 与 protectTask(OF/UF 判据经 prot_elm_run 取数)。 */
|
||||||
(void)freq_init();
|
(void)freq_init();
|
||||||
DBG_INFO("freq layer init: OK");
|
DBG_INFO("freq layer init: OK");
|
||||||
|
|
||||||
|
/* 4c-3) 通道标定层(标定折算,「数据可信」标定折算与落盘整定)初始化:RAM 系数副本置
|
||||||
|
* ROM 默认(量程口径冻结定死的引脚侧 LSB,docs/硬件平台/ADC通道映射说明.md §7.1)
|
||||||
|
* + 向配置引擎注册 [adc_cal] / [adc_cal_ofs] 两段。注册必须先于
|
||||||
|
* defaultTask 的 cfg_file_load(「先注册后 load」契约)→ 只能落内核前;
|
||||||
|
* 折算消费在 adcTask 每拍(adc_cal_apply),标定文件值随 load/poll 原地
|
||||||
|
* 生效,无回调。 */
|
||||||
|
adc_cal_init();
|
||||||
|
DBG_INFO("adc cal init: OK (k_q16 tpafe=%ld stm32=%ld)",
|
||||||
|
(long)ADC_CAL_K_Q16_TPAFE, (long)ADC_CAL_K_Q16_STM32);
|
||||||
|
|
||||||
|
/* 4c2) 运行参数段初始化(「数据可信」这一项):死区 9 点工厂默认 + 注册
|
||||||
|
* [run_par] 段。同 adc_cal 的「先注册后 load」契约 → 必须内核前;
|
||||||
|
* 104 消费(COV 实时读 / 0x8020 段读写映射)在 iec104Task 启动后进行。 */
|
||||||
|
run_par_init();
|
||||||
|
DBG_INFO("run par init: OK (db9 k/b via adc_cal, evt=1<<8)");
|
||||||
|
|
||||||
|
/* 4c3) 通讯参数段初始化(道G task-8,「UI 补齐」第 5 项非 UI 前置):ca/端口
|
||||||
|
* 工厂默认 + 注册 [comm] 段。与 adc_cal / run_par 同「先注册后 load」契约
|
||||||
|
* → 必须内核前:defaultTask 在 storage_bring_up 里才做 net_cfg_load →
|
||||||
|
* cfg_file_load(引擎唯一读点),本注册必须排在它前面。生效语义 =
|
||||||
|
* 改后重启(iec104Task 启动读 getter,不挂回调,见 iec104_comm.h)。 */
|
||||||
|
if (iec104_comm_init() != 0) {
|
||||||
|
DBG_ERROR("comm init FAIL (dup/full?) - file values inactive");
|
||||||
|
} else {
|
||||||
|
DBG_INFO("comm init: OK (ca=%u port=%u)",
|
||||||
|
(unsigned)iec104_comm_ca(), (unsigned)iec104_comm_port());
|
||||||
|
}
|
||||||
|
|
||||||
/* 4d) 保护定值 + 元件层初始化(B5):出厂默认定值封存校验和 + 元件状态
|
/* 4d) 保护定值 + 元件层初始化(B5):出厂默认定值封存校验和 + 元件状态
|
||||||
* 复位并解析相量索引(须在 phasor_init 之后)。内核前完成 → iec104
|
* 复位并解析相量索引(须在 phasor_init 之后)。内核前完成 → iec104
|
||||||
* 定值读/protectTask 首拍前定值必已装载。判据消费在 protectTask。 */
|
* 定值读/protectTask 首拍前定值必已装载。判据消费在 protectTask。 */
|
||||||
@@ -289,8 +359,8 @@ int app_main_init(void) {
|
|||||||
/* 6) 启动 1 kHz 采样节拍:TIM3 更新事件 → ① ISR 内脉冲 CONVST(Src/main.c)
|
/* 6) 启动 1 kHz 采样节拍:TIM3 更新事件 → ① ISR 内脉冲 CONVST(Src/main.c)
|
||||||
* ② 同一事件经 TRGO 硬件触发 STM32 ADC1 一轮 9 通道转换(DMA 自动搬运)。
|
* ② 同一事件经 TRGO 硬件触发 STM32 ADC1 一轮 9 通道转换(DMA 自动搬运)。
|
||||||
* ⚠ 必须在 tpafe5160_init() 成功之后启动,否则 ISR 会在驱动首帧自检
|
* ⚠ 必须在 tpafe5160_init() 成功之后启动,否则 ISR 会在驱动首帧自检
|
||||||
* (E-44 有效性探针)期间乱发 CONVST。消费端 adcTask 退化为纯消费者。
|
* (有效性探针)期间乱发 CONVST。消费端 adcTask 退化为纯消费者。
|
||||||
* ⚠ 节拍源 v2.0 由 TIM6 改为 **TIM3**:F4 上 ADC1 的外部触发只认
|
* ⚠ 节拍源必须能触发 ADC1,故用 **TIM3**:F4 上 ADC1 的外部触发只认
|
||||||
* TIM1/TIM2/TIM3/TIM4/TIM5/TIM8 的 CC 或 TRGO,**TIM6/TIM7 的 TRGO 只接
|
* TIM1/TIM2/TIM3/TIM4/TIM5/TIM8 的 CC 或 TRGO,**TIM6/TIM7 的 TRGO 只接
|
||||||
* DAC**(查 CubeMX DB 与本仓库 stm32f4xx_hal_adc.h 的 EXTSEL 表证实)
|
* DAC**(查 CubeMX DB 与本仓库 stm32f4xx_hal_adc.h 的 EXTSEL 表证实)
|
||||||
* → 要让两片转换器由同一个硬件事件驱动,节拍源必须换成 TIM3。 */
|
* → 要让两片转换器由同一个硬件事件驱动,节拍源必须换成 TIM3。 */
|
||||||
|
|||||||
@@ -17,6 +17,14 @@ extern "C" {
|
|||||||
|
|
||||||
#include <stdint.h>
|
#include <stdint.h>
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 复位原因锁存影子(定义于 app_main.c)。Src/main.c 在 HAL_Init() 之后、串口/日志
|
||||||
|
* 尚未就绪时读 RCC->CSR 并清标志,这里提供引用契约——原先写成 main.c 函数内的局部
|
||||||
|
* extern(跨文件裸 extern,改名/删除都不会有提示)。读→清两步的顺序约束见 main.c
|
||||||
|
* 该段注释,不可调换。
|
||||||
|
*/
|
||||||
|
extern volatile uint32_t g_rcc_csr_latched;
|
||||||
|
|
||||||
int app_main_init(void);
|
int app_main_init(void);
|
||||||
void app_main_run(void);
|
void app_main_run(void);
|
||||||
|
|
||||||
|
|||||||
@@ -22,11 +22,12 @@ extern "C" {
|
|||||||
* 由 MX_FREERTOS_Init 在 freertos.c 的 generated 区域自动声明并 osEventFlagsNew 创建) */
|
* 由 MX_FREERTOS_Init 在 freertos.c 的 generated 区域自动声明并 osEventFlagsNew 创建) */
|
||||||
extern osEventFlagsId_t sys_evtHandle;
|
extern osEventFlagsId_t sys_evtHandle;
|
||||||
|
|
||||||
/* 各子系统"就绪"事件位(32 位上限内的 bit 0~30) */
|
/* 各子系统"就绪"事件位(32 位上限内的 bit 0~30)。**bit0 / bit1 在用,bit2 / bit3 是预留位**
|
||||||
|
* ——逐位的生产者与消费者对照表 = docs/架构设计/项目启动顺序管理.md §3.2 */
|
||||||
#define SYS_EVT_NET_READY (1u << 0) /* netTask:net_init + PHY 稳定完成 */
|
#define SYS_EVT_NET_READY (1u << 0) /* netTask:net_init + PHY 稳定完成 */
|
||||||
#define SYS_EVT_STORAGE_READY (1u << 1) /* defaultTask:FTL/FatFS 挂载完成 */
|
#define SYS_EVT_STORAGE_READY (1u << 1) /* defaultTask:FTL/FatFS 挂载完成 */
|
||||||
#define SYS_EVT_FTP_READY (1u << 2) /* ftpTask:lftpd_start 完成 */
|
#define SYS_EVT_FTP_READY (1u << 2) /* ftpTask:lftpd_start 完成后置位;暂无消费者(预留) */
|
||||||
#define SYS_EVT_TIMESYNC_READY (1u << 3) /* timeSyncTask:首次时间同步完成 */
|
#define SYS_EVT_TIMESYNC_READY (1u << 3) /* SNTP 首个校验通过的同步完成(App/sync/time_sntp.c 置位;就绪位,osFlagsNoClear 消费) */
|
||||||
#define SYS_EVT_NET_RECONFIG (1u << 4) /* UI:请求立即应用新网口配置(netTask 消费后重建网络) */
|
#define SYS_EVT_NET_RECONFIG (1u << 4) /* UI:请求立即应用新网口配置(netTask 消费后重建网络) */
|
||||||
#define SYS_EVT_CFG_SAVE (1u << 5) /* UI:网口配置保存请求(defaultTask = 配置引擎属主,消费并写盘) */
|
#define SYS_EVT_CFG_SAVE (1u << 5) /* UI:网口配置保存请求(defaultTask = 配置引擎属主,消费并写盘) */
|
||||||
#define SYS_EVT_CONSOLE_TX (1u << 6) /* 控制台单写者:生产者入环形缓冲后置位,defaultTask 消费并发送
|
#define SYS_EVT_CONSOLE_TX (1u << 6) /* 控制台单写者:生产者入环形缓冲后置位,defaultTask 消费并发送
|
||||||
@@ -34,6 +35,10 @@ extern osEventFlagsId_t sys_evtHandle;
|
|||||||
#define SYS_EVT_PROT_SET_SAVE (1u << 7) /* 保护定值落盘请求(B7):iec104Task 检测到定值脏置位,
|
#define SYS_EVT_PROT_SET_SAVE (1u << 7) /* 保护定值落盘请求(B7):iec104Task 检测到定值脏置位,
|
||||||
* defaultTask(配置引擎属主)消费并写 /prot_set.bin
|
* defaultTask(配置引擎属主)消费并写 /prot_set.bin
|
||||||
* (消费型命令位;写盘失败脏标志保留 → 下轮再通知重试) */
|
* (消费型命令位;写盘失败脏标志保留 → 下轮再通知重试) */
|
||||||
|
#define SYS_EVT_RUN_PAR_SAVE (1u << 8) /* 运行参数段落盘请求(「数据可信」这一项):iec104Task
|
||||||
|
* 检测到 run_par 脏(死区 9 键 / 系数覆盖联动 [adc_cal])
|
||||||
|
* 置位,defaultTask 消费并 cfg_file_save 整档外科保存
|
||||||
|
* (消费型命令位;失败脏保留 → 下轮重试,B7 同款) */
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,857 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:UI 数据组装器(ui_data_feed)
|
||||||
|
* 模块功能:App → UI 数据通道的写侧——按节拍从采集/标定/相量/测频/保护/协议/墙钟
|
||||||
|
* 各模块取数,组装成 docs/UI设计/UI_数据供给设计说明.md §4 契约的三帧
|
||||||
|
* (实时量 rt / 参数 par / 装置信息 info),经 Drivers/BSP/UI/ui_data.h
|
||||||
|
* 的发布 API 推给 UI 层;本模块是快照的**唯一填充者**,不含任何布局、
|
||||||
|
* 格式化与 LVGL 调用(显示规格全在页面行表,同文档 §8)
|
||||||
|
* 写方向(道F task-1):同一条 tick 顺带把页面投递的定值整定请求落定——
|
||||||
|
* ui_data_param_drain() 每轮取一条,执行动作经本文件的 s_param_ops
|
||||||
|
* (locked = prot_set_locked / check = g_prot_set_meta 上下限 /
|
||||||
|
* apply = prot_set_set_sealed)注入给 BSP;值域与落盘事实都留在 App
|
||||||
|
* 侧,BSP 只有队列与状态机(契约 §6.1"UI 不能直写"的落地形态)
|
||||||
|
* 量纲诚实性:eng 字段按**真值**填——docs/任务规划/数据可信.md §2.6 把承诺边界定在
|
||||||
|
* "引脚电压",故未做两点标定前 eng = UI_DATA_ENG_PIN(值 = ADC 引脚电压 V,
|
||||||
|
* 不是端子二次值);电气 8 路 adc_cal_flags() 的 VALID 全置位才升
|
||||||
|
* UI_DATA_ENG_SEC。TEMP 按决策不参与线性标定 → 恒带 UI_DATA_Q_RAW,
|
||||||
|
* 页面据此不挂单位(三态口径同文档 §4.2)
|
||||||
|
* 适用平台:STM32F407ZGTx(PC 模拟器不链接本文件,见 ui_data_feed.h 端接法)
|
||||||
|
* 作者:王建锋
|
||||||
|
* 创建日期:2026-10-25
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-25 王建锋 创建(道E task-14)
|
||||||
|
* v1.1 2026-10-26 DTU 项目组 道F task-1:① tick 前置 ui_data_param_drain()
|
||||||
|
* (定值写路径出队 → 闭锁/越界预检 → set_sealed,
|
||||||
|
* 见 s_param_ops);② 参数帧尾填 cal_block(未标定
|
||||||
|
* 闭锁徽标,三路口径同 protect_task.c,不用 eng)
|
||||||
|
* v1.3 2026-10-27 DTU 项目组 道F task-7:遥测整定注入表(ui_data_yt_ops_t)
|
||||||
|
* v1.4 2026-10-27 DTU 项目组 道G task-9:通讯参数注入表(ui_data_comm_ops_t,
|
||||||
|
* 通讯设置页);port_104 快照从 IEC104_SERV_PORT 宏
|
||||||
|
* 切 iec104_comm_port() 真源(宏同笔删除,交接口径见
|
||||||
|
* IEC104上行设计说明.md §8)
|
||||||
|
* 装配层实现 + 首轮懒绑(时序与通道四绑定等价,
|
||||||
|
* 见 feed_yt_bind_once 函数头);行数等值由编译期
|
||||||
|
* 守卫钉,量纲分档与合法域分档同源同序
|
||||||
|
* v1.2 2026-10-26 DTU 项目组 道F task-16:删除本地镜像 feed_cal_block(),
|
||||||
|
* 两个调用点(填帧 / 门控比较)改调
|
||||||
|
* adc_cal_prot_blocked()——同一份三路判定此前在
|
||||||
|
* 本文件与 protect_task.c 各写一遍,且各自带一份
|
||||||
|
* #ifndef DTU_BENCH(只给单个 TU 加宏就能让屏与判据
|
||||||
|
* 分叉);真源现落在 App/algo/adc_cal.c,旁路全仓一处。
|
||||||
|
* 行为不变(判定式逐字同构、调用时机不变)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 头文件包含区 */
|
||||||
|
#include <string.h>
|
||||||
|
|
||||||
|
#include "cmsis_os.h" /* osKernelGetTickCount:与 adc_task 同一 ms 时基 */
|
||||||
|
#include "boot_stats.h" /* 运行报告:上电次数 + 本次复位原因快照(道G task-12 数据面) */
|
||||||
|
#include "iec104_serv.h" /* 运行报告:四个 104 只读观测计数(本次开机以来,跨重连不清零) */
|
||||||
|
|
||||||
|
#include "ui_data_feed.h"
|
||||||
|
|
||||||
|
/* 日志锚点:本文件只在"点表点数与快照点数不等"这一条静默漂移上打一条一次性日志
|
||||||
|
* (每条约 90 B Flash,见 AGENTS「日志不是免费的」——常规发布路径刻意一条都不加)。
|
||||||
|
* ⚠ DBG_TAG 必须在 include 之前定义:dbg_log.h 的 DBG_ERROR 是宏,漏 include 时
|
||||||
|
* ARMCC 只把 DBG_ERROR 当隐式声明函数放过编译期,错误落在链接期(L6218E
|
||||||
|
* Undefined symbol),比漏写头文件本身更难归因 */
|
||||||
|
#define DBG_TAG "[UIDATA]"
|
||||||
|
#include "dbg_log.h"
|
||||||
|
|
||||||
|
/* 下层(Drivers/BSP):快照存侧 + 两个端口/用户名常量来源(App → BSP 是合法方向) */
|
||||||
|
#include "ui_data.h"
|
||||||
|
#include "ui_net_sink.h" /* UI_NET_SINK_PORT:远程显示(RemoDispBus)监听端口 */
|
||||||
|
#include "ftp_cfg.h" /* FTP_CFG_USER_MAX / ftp_cfg_get() */
|
||||||
|
|
||||||
|
/* 同层(App)数据源 */
|
||||||
|
#include "adc_input.h" /* ADC_SEM_COUNT / adc_input_raw(TEMP 码值) */
|
||||||
|
#include "adc_cal.h" /* adc_cal_flags:VALID/CLIPPED 三态可用性;adc_cal_prot_blocked:未标定闭锁单点;adc_cal_k_uv:系数当前生效值(与 104 回显同视图) */
|
||||||
|
#include "run_par.h" /* 道F task-7 遥测整定注入表:死区/合法域/系数 setter/脏位(真源全在本模块与 adc_cal,feed 只转达) */
|
||||||
|
#include "phasor.h" /* 基波/谐波有效值(fAmSQ) */
|
||||||
|
#include "freq.h" /* 测频读数与新鲜度 */
|
||||||
|
#include "prot_elm.h" /* 元件动作态(遥信位图来源) */
|
||||||
|
#include "prot_set.h" /* 40 项定值 Q10 + 校验和 + 闭锁 + 越界位图 */
|
||||||
|
#include "sys_clock.h" /* 墙钟字符串 */
|
||||||
|
#include "iec104_comm.h" /* 道G task-9:[comm] 真源 getter/setter/脏位;快照端口切 getter,IEC104_SERV_PORT 字面宏同笔删除 */
|
||||||
|
#include "iec104_pts.h" /* task-15 批4:遥信点数与单/双点性质(点表事实的唯一来源) */
|
||||||
|
|
||||||
|
/* 私有宏定义区 */
|
||||||
|
|
||||||
|
/* 实时帧与信息帧发布节拍(ms);参数帧只在定值校验和变化时发布
|
||||||
|
* (节拍依据 docs/UI设计/UI_数据供给设计说明.md §5.2) */
|
||||||
|
#define FEED_RT_PERIOD_MS 200u
|
||||||
|
|
||||||
|
/* 遥信位号:**与 App/protocol/iec104/iec104_pts.c 板端源的位图同源**(该文件把
|
||||||
|
* 位号写死在函数体内、未导出,UI 侧不得 include 协议头,故在此镜像一份)。
|
||||||
|
* 变更纪律:104 点表位号改动时必须同步这里 + 页面名表 + 主机一致性测试
|
||||||
|
* (test/host/ui_data 的位图断言)。V0.1 有硬件来源的只有以下 6 位,
|
||||||
|
* 其余 27 位恒无效(开入未实现);过压/欠压/过频/低频四个元件在
|
||||||
|
* V0.1 点表里没有对应遥信点,故本函数不产出。 */
|
||||||
|
#define FEED_YX_BIT_FAULT_ALL 14u /* YX15 故障总(任一电压/电流段或零序动作) */
|
||||||
|
#define FEED_YX_BIT_OC1 15u /* YX16 过流 I 段 */
|
||||||
|
#define FEED_YX_BIT_OC2 16u /* YX17 过流 II 段 */
|
||||||
|
#define FEED_YX_BIT_OC3 17u /* YX18 过流 III 段 */
|
||||||
|
#define FEED_YX_BIT_OL 22u /* YX23 过载(过负荷) */
|
||||||
|
#define FEED_YX_BIT_LOCK 30u /* YX31 保护闭锁(定值校验和自检失败) */
|
||||||
|
/* YX1~4(bit0..3)是开关位置类双点点,V0.1 无开入硬件 → 恒 0 且不在有效位图内,
|
||||||
|
* 因此本文件不为它们定义常量(定义了也不参与任何判定 = 看着像接了线其实没有) */
|
||||||
|
/* 有效位图:YX15~18/23/31(与 104 点表 YX_VALID_MASK 同集合) */
|
||||||
|
#define FEED_YX_VALID_MASK ((((uint64_t)1u << FEED_YX_BIT_FAULT_ALL) | \
|
||||||
|
((uint64_t)1u << FEED_YX_BIT_OC1) | \
|
||||||
|
((uint64_t)1u << FEED_YX_BIT_OC2) | \
|
||||||
|
((uint64_t)1u << FEED_YX_BIT_OC3) | \
|
||||||
|
((uint64_t)1u << FEED_YX_BIT_OL) | \
|
||||||
|
((uint64_t)1u << FEED_YX_BIT_LOCK)))
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 装置身份(版本类):仓库内**没有**产品级版本登记表,值沿用页面既有显示值
|
||||||
|
* (docs/UI设计/内容页架构.md 的装置信息行表现值),有正式口径后只改本段一处。
|
||||||
|
* ⚠ 软件校验不抄参考项目遗留的 "4454":构建期没有 CRC 生成步骤,如实显示 "--"
|
||||||
|
* (处置口径 docs/UI设计/页面扩充详细方案.md §3.7 第 5 行)
|
||||||
|
*/
|
||||||
|
#define FEED_MODEL "F30"
|
||||||
|
#define FEED_SW_VER "SV0.010"
|
||||||
|
#define FEED_HW_VER "HW0.010"
|
||||||
|
#define FEED_SW_SUM "--"
|
||||||
|
|
||||||
|
/* 编译期一致性守卫(ARMCC C99 无 _Static_assert,用项目惯用的数组宽度法,
|
||||||
|
* 先例 App/cfg/cfg_file.c 与 Drivers/BSP/UI/ui_menu_tab.h):
|
||||||
|
* 快照下标空间与各 App 枚举一旦漂移,编译即失败,不留到运行期
|
||||||
|
* (契约 docs/UI设计/UI_数据供给设计说明.md §9.4 第 1/2 条) */
|
||||||
|
typedef char feed_chk_yc_n_t[(UI_DATA_YC_N == (uint32_t)ADC_SEM_COUNT) ? 1 : -1];
|
||||||
|
typedef char feed_chk_set_n_t[(UI_DATA_SET_N == (uint32_t)PS_ID_COUNT) ? 1 : -1];
|
||||||
|
/* 写路径注入表的 id 形参是 uint8_t(BSP 侧不透明编号宽度,位图只有 64 位):
|
||||||
|
* 定值项数一旦破 64/255,request 的 mask 与 check/apply 的编号都会静默截断,
|
||||||
|
* 故在写侧也钉一道(BSP 侧同一条守卫在 Drivers/BSP/UI/ui_data.c) */
|
||||||
|
typedef char feed_chk_set_u8_t[((uint32_t)PS_ID_COUNT <= 64u) ? 1 : -1];
|
||||||
|
typedef char feed_chk_fuser_t[(UI_DATA_FUSER_LEN >= FTP_CFG_USER_MAX) ? 1 : -1];
|
||||||
|
|
||||||
|
/* 静态变量定义区 */
|
||||||
|
|
||||||
|
/* 组装缓冲:三帧串行填充,共用一块存储,省两份 RAM。
|
||||||
|
* 实际最大者是**实时帧**(208 B > par 192 B > info),故给 par 尾追加字段
|
||||||
|
* 只要不超过 rt 就一字节 RAM 都不多花(这条由主机单测 TC-UIDATA-021g/h 钉住,
|
||||||
|
* 尺寸一变这里就是唯一会撒谎的地方) */
|
||||||
|
static union {
|
||||||
|
ui_data_rt_t rt;
|
||||||
|
ui_data_par_t par;
|
||||||
|
ui_data_info_t info;
|
||||||
|
} s_scr;
|
||||||
|
|
||||||
|
/* 语义通道 → 基波相量实例下标缓存(-1 = 该通道无基波实例 → 该路发 IV);
|
||||||
|
* 首帧惰性建表(相量实例表是 ROM 表,运行期不变,建一次即可) */
|
||||||
|
static int8_t s_yc_phasor[ADC_SEM_COUNT];
|
||||||
|
static uint8_t s_map_done;
|
||||||
|
|
||||||
|
/* 发布节拍与参数变化检测状态
|
||||||
|
* par 帧有**三个各自独立**的变化源,任一变了就要重发一帧(详见 tick 里的门控注释):
|
||||||
|
* s_par_sum 定值改动(校验和)
|
||||||
|
* s_par_locked 定值闭锁(定值校验和自检失配,校验和不动)
|
||||||
|
* s_par_cal_block 未标定闭锁(两点标定完成,校验和也不动)
|
||||||
|
* 后两个各占一字节——省下来就是"屏上状态与真值脱拍"这笔债 */
|
||||||
|
static uint32_t s_last_rt_ms;
|
||||||
|
static uint32_t s_par_sum;
|
||||||
|
static uint8_t s_par_sum_valid;
|
||||||
|
static uint8_t s_par_locked;
|
||||||
|
static uint8_t s_par_cal_block;
|
||||||
|
static uint8_t s_first_done;
|
||||||
|
|
||||||
|
/* 函数定义区 */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:定长缓冲内拷贝字符串(跨层快照只允许定长数组,禁指针)
|
||||||
|
* 入口参数:p_dst - 目标缓冲(结构体内数组首地址) char *
|
||||||
|
* dst_len - 目标容量(含 NUL) size_t
|
||||||
|
* a_src - 源串(Flash 字面量或上层缓冲) const char *
|
||||||
|
* 返回值:无
|
||||||
|
* 限定条件:三者非 NULL 且 dst_len > 0;截断时保证 NUL 结尾
|
||||||
|
* 函数说明:不用 strcpy/sprintf(门禁 CODE-RULE-06 禁用不安全函数),
|
||||||
|
* 超长**静默截断**——快照字段本就是定长显示串,截断只会少显示尾部,
|
||||||
|
* 不会越界;本函数每 200 ms 调用个位数次,不为此加日志
|
||||||
|
*/
|
||||||
|
static void feed_copy_str(char *p_dst, size_t dst_len, const char *a_src) {
|
||||||
|
size_t n;
|
||||||
|
|
||||||
|
if ((p_dst == NULL) || (a_src == NULL) || (dst_len == 0u)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
n = strlen(a_src);
|
||||||
|
if (n > (dst_len - 1u)) {
|
||||||
|
n = dst_len - 1u;
|
||||||
|
}
|
||||||
|
(void)memcpy(p_dst, a_src, n);
|
||||||
|
p_dst[n] = '\0';
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:建立"语义通道 → 基波相量实例"映射(首帧一次)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返回值:无
|
||||||
|
* 限定条件:uiTask 上下文,ui_data_feed_tick 首帧前调用
|
||||||
|
* 函数说明:只认 harm == 1 的实例;一个语义通道有多个基波实例时取**首个**
|
||||||
|
* (现状 k_inst[] 每通道最多一个基波,取首个即唯一解)。
|
||||||
|
* UB/UC/IB/IC/IN 无实例(V0.1 硬件削顶:双转换器无共同采样触发)→ 留 -1,该路发 IV
|
||||||
|
*/
|
||||||
|
static void feed_map_phasor(void) {
|
||||||
|
uint8_t n;
|
||||||
|
uint8_t i;
|
||||||
|
|
||||||
|
for (i = 0u; i < (uint8_t)ADC_SEM_COUNT; i++) {
|
||||||
|
s_yc_phasor[i] = -1;
|
||||||
|
}
|
||||||
|
n = phasor_inst_count();
|
||||||
|
for (i = 0u; i < n; i++) {
|
||||||
|
const phasor_inst_t *p = phasor_inst(i);
|
||||||
|
|
||||||
|
if ((p == NULL) || (p->harm != 1u) || ((uint8_t)p->sem >= (uint8_t)ADC_SEM_COUNT)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (s_yc_phasor[(uint8_t)p->sem] < 0) {
|
||||||
|
s_yc_phasor[(uint8_t)p->sem] = (int8_t)i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s_map_done = 1u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:算整帧量纲口径 eng
|
||||||
|
* 入口参数:无
|
||||||
|
* 返回值:UI_DATA_ENG_PIN 或 UI_DATA_ENG_SEC uint8_t
|
||||||
|
* 限定条件:无(纯读)
|
||||||
|
* 函数说明:**全或无**——电气 8 路(TEMP 除外,它不参与线性标定)的
|
||||||
|
* ADC_CAL_F_VALID 全置位才升二次值。取全或无是因为整帧共用一个 eng:
|
||||||
|
* 只要还有一路是引脚电压,页面挂"二次值"就会把那一路的读数误标
|
||||||
|
* (宁可整体显示"未标定",也不给部分真值贴真值标签)
|
||||||
|
*/
|
||||||
|
static uint8_t feed_eng(void) {
|
||||||
|
uint8_t sem;
|
||||||
|
|
||||||
|
for (sem = 0u; sem < (uint8_t)ADC_SEM_TEMP; sem++) {
|
||||||
|
if ((adc_cal_flags((adc_semantic_t)sem) & ADC_CAL_F_VALID) == 0u) {
|
||||||
|
return UI_DATA_ENG_PIN;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return UI_DATA_ENG_SEC;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:组装遥信双点位图(bit i = 第 i 点是双点型 M_DP)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返回值:位图 uint64_t
|
||||||
|
* 限定条件:uiTask 上下文;点数上限 UI_DATA_YX_N(<64,故位图放得下)
|
||||||
|
* 函数说明:逐点问点表 `iec104_pts_yx_is_dp()`,**不在此复制"前 4 点是双点"这个事实**
|
||||||
|
* ——那句话写在这里就是第二真相,点表一改就悄悄错(下发位图的理由见
|
||||||
|
* ui_data.h 的 yx_dp 字段注释)
|
||||||
|
*/
|
||||||
|
static uint64_t feed_yx_dp(void) {
|
||||||
|
uint64_t m = 0u;
|
||||||
|
uint8_t i;
|
||||||
|
|
||||||
|
for (i = 0u; (i < (uint8_t)UI_DATA_YX_N) && (i < 64u); i++) {
|
||||||
|
if (iec104_pts_yx_is_dp(i) != 0u) {
|
||||||
|
m |= ((uint64_t)1u << i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:组装遥信位图(bit i = 第 i 点"合/动作")
|
||||||
|
* 入口参数:无
|
||||||
|
* 返回值:位图 uint64_t
|
||||||
|
* 限定条件:uiTask 上下文(读 protectTask 写的元件状态快照,读侧拷贝语义,
|
||||||
|
* 与 App/protocol/iec104/iec104_pts.c 板端源同形)
|
||||||
|
* 函数说明:位号镜像本文件 FEED_YX_BIT_* 一组宏的注释(含同步纪律);
|
||||||
|
* 双点遥信 YX1~4 无硬件 → 恒 0 且不在有效位图内
|
||||||
|
*/
|
||||||
|
static uint64_t feed_yx_bits(void) {
|
||||||
|
prot_elm_state_t st;
|
||||||
|
uint64_t bits = 0u;
|
||||||
|
uint8_t fault_all;
|
||||||
|
|
||||||
|
fault_all = 0u;
|
||||||
|
prot_elm_state(PE_OC1, &st);
|
||||||
|
if (st.asserted != 0u) {
|
||||||
|
bits |= ((uint64_t)1u << FEED_YX_BIT_OC1);
|
||||||
|
fault_all = 1u;
|
||||||
|
}
|
||||||
|
prot_elm_state(PE_OC2, &st);
|
||||||
|
if (st.asserted != 0u) {
|
||||||
|
bits |= ((uint64_t)1u << FEED_YX_BIT_OC2);
|
||||||
|
fault_all = 1u;
|
||||||
|
}
|
||||||
|
prot_elm_state(PE_OC3, &st);
|
||||||
|
if (st.asserted != 0u) {
|
||||||
|
bits |= ((uint64_t)1u << FEED_YX_BIT_OC3);
|
||||||
|
fault_all = 1u;
|
||||||
|
}
|
||||||
|
prot_elm_state(PE_3U0, &st);
|
||||||
|
if (st.asserted != 0u) {
|
||||||
|
fault_all = 1u;
|
||||||
|
}
|
||||||
|
if (fault_all != 0u) {
|
||||||
|
bits |= ((uint64_t)1u << FEED_YX_BIT_FAULT_ALL);
|
||||||
|
}
|
||||||
|
prot_elm_state(PE_OL, &st);
|
||||||
|
if (st.asserted != 0u) {
|
||||||
|
bits |= ((uint64_t)1u << FEED_YX_BIT_OL);
|
||||||
|
}
|
||||||
|
if (prot_set_locked() != 0u) {
|
||||||
|
bits |= ((uint64_t)1u << FEED_YX_BIT_LOCK);
|
||||||
|
}
|
||||||
|
return bits;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:组装实时量快照(遥测 9 路 + 相量实例槽 + 遥信 + 频率 + 墙钟)
|
||||||
|
* 入口参数:p_snap - 目标帧(调用方已清零) ui_data_rt_t *
|
||||||
|
* tick_ms - 发布时刻(tick 域) uint32_t
|
||||||
|
* 返回值:无
|
||||||
|
* 限定条件:uiTask 上下文;映射表已建(feed_map_phasor)
|
||||||
|
* 函数说明:取数口径与 104 上送同源不同路——本函数读**相量使用区**(1 ms 刷新、
|
||||||
|
* 双区原子交换),不做死区与平均;遥测链快照(docs/任务规划/数据可信.md
|
||||||
|
* §4.5 的 meas 快照)落地后,本函数改成读该快照即可,页面零改动
|
||||||
|
* (这正是本层存在的意义:换数据源只改这里)
|
||||||
|
*/
|
||||||
|
static void feed_fill_rt(ui_data_rt_t *p_snap, uint32_t tick_ms) {
|
||||||
|
uint32_t now_ep;
|
||||||
|
uint8_t sem;
|
||||||
|
char ts[UI_DATA_CLOCK_LEN];
|
||||||
|
|
||||||
|
p_snap->tick_ms = tick_ms;
|
||||||
|
p_snap->valid = 1u;
|
||||||
|
p_snap->eng = feed_eng();
|
||||||
|
|
||||||
|
/* 遥测:语义通道序的基波有效值(采样环已按 adc_cal 折算 → 当前为引脚电压域) */
|
||||||
|
for (sem = 0u; sem < (uint8_t)ADC_SEM_COUNT; sem++) {
|
||||||
|
int8_t pi = s_yc_phasor[sem];
|
||||||
|
|
||||||
|
p_snap->yc[sem].val = 0.0f;
|
||||||
|
p_snap->yc[sem].qds = 0u;
|
||||||
|
|
||||||
|
if (sem == (uint8_t)ADC_SEM_TEMP) {
|
||||||
|
/* 温度:不参与线性标定,透传转换器码值并打 RAW 位(页面不得挂单位) */
|
||||||
|
int32_t code = 0;
|
||||||
|
|
||||||
|
p_snap->yc[sem].qds = UI_DATA_Q_RAW;
|
||||||
|
if (adc_input_raw((adc_semantic_t)sem, &code) == 0) {
|
||||||
|
p_snap->yc[sem].val = (float)code;
|
||||||
|
} else {
|
||||||
|
p_snap->yc[sem].qds |= UI_DATA_Q_IV;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (pi < 0) {
|
||||||
|
/* 无基波实例 = V0.1 该通道无可用采样源(削顶) → 无效 */
|
||||||
|
p_snap->yc[sem].qds = UI_DATA_Q_IV;
|
||||||
|
} else {
|
||||||
|
phasor_t ph;
|
||||||
|
int rc = phasor_get((uint8_t)pi, &ph);
|
||||||
|
|
||||||
|
if (rc == 0) {
|
||||||
|
p_snap->yc[sem].val = ph.fAmSQ; /* 有效值口径(系数已折 sqrt2/N) */
|
||||||
|
} else if (rc == -2) {
|
||||||
|
p_snap->yc[sem].qds = UI_DATA_Q_NT; /* 未攒满一周波 */
|
||||||
|
} else {
|
||||||
|
p_snap->yc[sem].qds = UI_DATA_Q_IV;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ((adc_cal_flags((adc_semantic_t)sem) & ADC_CAL_F_CLIPPED) != 0u) {
|
||||||
|
p_snap->yc[sem].qds |= UI_DATA_Q_OV; /* 硬件削顶:交流量不可信 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 相量实例槽:含谐波(Ia 二次/五次),交流量页的 Ia2/Ia5 行走这条带 */
|
||||||
|
{
|
||||||
|
uint8_t n = phasor_inst_count();
|
||||||
|
|
||||||
|
if (n > UI_DATA_PH_MAX) {
|
||||||
|
n = UI_DATA_PH_MAX; /* 超出槽上限的实例本帧不发布(现 5 实例,余 3 槽) */
|
||||||
|
}
|
||||||
|
p_snap->ph_n = n;
|
||||||
|
for (sem = 0u; sem < UI_DATA_PH_MAX; sem++) {
|
||||||
|
p_snap->ph[sem].val = 0.0f;
|
||||||
|
p_snap->ph[sem].qds = UI_DATA_Q_IV;
|
||||||
|
}
|
||||||
|
for (sem = 0u; sem < n; sem++) {
|
||||||
|
phasor_t ph;
|
||||||
|
|
||||||
|
if (phasor_get(sem, &ph) == 0) {
|
||||||
|
p_snap->ph[sem].val = ph.fAmSQ;
|
||||||
|
p_snap->ph[sem].qds = 0u;
|
||||||
|
} else {
|
||||||
|
p_snap->ph[sem].qds = UI_DATA_Q_NT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p_snap->yx_bits = feed_yx_bits();
|
||||||
|
p_snap->yx_valid = FEED_YX_VALID_MASK;
|
||||||
|
p_snap->yx_dp = feed_yx_dp();
|
||||||
|
/* 点数漂移检查(只在真漂走时打一条日志):点表 YX_N 与本层 UI_DATA_YX_N 一旦不等,
|
||||||
|
* 页面按 33 行画、协议按另一个数上送,两边都"看起来正常"——这是静默错,值得花一条
|
||||||
|
* DBG(约 90 B Flash)换一次现场可见的自证;只打一次不刷屏(同丢弃计数口径) */
|
||||||
|
{
|
||||||
|
static uint8_t s_yx_n_bad;
|
||||||
|
uint8_t n = iec104_pts_yx_count();
|
||||||
|
|
||||||
|
if ((n != (uint8_t)UI_DATA_YX_N) && (s_yx_n_bad == 0u)) {
|
||||||
|
s_yx_n_bad = 1u;
|
||||||
|
DBG_ERROR("yx point count drift: pts=%u ui_name_table=%u",
|
||||||
|
(unsigned)n, (unsigned)UI_DATA_YX_N);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 测频:读数新鲜度单独走 freq_valid()(幅值门 + 收敛),两者任一不成立
|
||||||
|
* 页面即显示 "--"——eng=0 期间幅值门恒失效,读数本就不可消费,如实反映 */
|
||||||
|
{
|
||||||
|
float hz = 0.0f;
|
||||||
|
|
||||||
|
p_snap->freq_valid = 0u;
|
||||||
|
p_snap->freq_hz = 0.0f;
|
||||||
|
if (freq_get(&hz) == 0) {
|
||||||
|
p_snap->freq_hz = hz;
|
||||||
|
if (freq_valid() != 0u) {
|
||||||
|
p_snap->freq_valid = 1u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 墙钟:UINT32_MAX 是 sys_clock_get() 的失败哨兵 → clock_ok=0、串清空 */
|
||||||
|
p_snap->clock_ok = 0u;
|
||||||
|
p_snap->clock[0] = '\0';
|
||||||
|
now_ep = sys_clock_get();
|
||||||
|
if ((now_ep != UINT32_MAX) && (sys_clock_get_str(ts, sizeof(ts)) != NULL)) {
|
||||||
|
feed_copy_str(p_snap->clock, sizeof(p_snap->clock), ts);
|
||||||
|
p_snap->clock_ok = 1u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── 写路径(契约 §6.2):把 prot_set 的三个动作包成注入给 BSP 的函数表 ────── */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:写路径判定"定值闭锁中吗"(BSP 经 ui_data_param_ops_t 来问)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返 回 值:1 - 闭锁(定值校验和自检失败态,禁一切定值写入);0 - 可写 uint8_t
|
||||||
|
* 限定条件:uiTask 上下文(只读 prot_set 状态位)
|
||||||
|
* 函数说明:这里的"闭锁"是**定值闭锁**(= par.locked = YX31 遥信),**不是**未标定
|
||||||
|
* 闭锁(= par.cal_block):后者仍可改定值、只是判据停走,两者在屏上不得
|
||||||
|
* 互认(三词三源口径 docs/任务规划/数据可信.md §4.3 + 落地简报 §7-6)
|
||||||
|
*/
|
||||||
|
static uint8_t feed_param_locked(void) {
|
||||||
|
return prot_set_locked();
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:写路径整批预检——单项是否落在 meta 上下限内
|
||||||
|
* 入口参数:a_id - 定值项编号(位图位序;uint8_t 是 BSP 侧不透明编号的宽度)
|
||||||
|
* a_q10 - 待写 Q10 定点值
|
||||||
|
* 返 回 值:0 - 合法;-1 - 编号越界或值越界 int
|
||||||
|
* 限定条件:只读 g_prot_set_meta(ROM 表),任意上下文安全
|
||||||
|
* 函数说明:上下限**只此一处取**——BSP 行表(Drivers/BSP/UI/ui_w_setview.c)是它的
|
||||||
|
* 换算镜像,由 setview-check 钩子核一致;本函数再抄一份数字就是第二真相
|
||||||
|
* ("查看合法、整定越界"两套边界就是这么来的)
|
||||||
|
*/
|
||||||
|
static int feed_param_check(uint8_t a_id, int32_t a_q10) {
|
||||||
|
if (a_id >= (uint8_t)PS_ID_COUNT) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if ((a_q10 < g_prot_set_meta[a_id].min) || (a_q10 > g_prot_set_meta[a_id].max)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:写路径落值——单项**原子**写入 + 重封校验和 + 置脏
|
||||||
|
* 入口参数:a_id - 定值项编号;a_q10 - Q10 定点值
|
||||||
|
* 返 回 值:0 - 已写入;-1 - 编号越界或 prot_set 拒写(原值不动) int
|
||||||
|
* 限定条件:uiTask 上下文;调用前 BSP 已整批预检通过(两条件与本函数一致)
|
||||||
|
* 函数说明:用 prot_set_set_sealed()(影子拷贝→改→重封→单字交换,**每项自带置脏**,
|
||||||
|
* 落盘由 defaultTask 50 ms 圈扫脏写 /prot_set.bin,本模块不碰 FatFs)。
|
||||||
|
* ⚠ 不得改成 prot_set_set() + 批末 prot_set_seal():两条路径之间有
|
||||||
|
* "已改未封存"窗口,prot_set.c 明令不得交错,撞上就被定值校验和自检判成
|
||||||
|
* "定值被非法改写"→ 整台保护闭锁
|
||||||
|
*/
|
||||||
|
static int feed_param_apply(uint8_t a_id, int32_t a_q10) {
|
||||||
|
if (a_id >= (uint8_t)PS_ID_COUNT) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return prot_set_set_sealed((prot_set_id_t)a_id, a_q10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 注入给 ui_data_param_drain() 的执行侧实现(BSP 不保存副本,每次调用现传) */
|
||||||
|
static const ui_data_param_ops_t s_param_ops = {
|
||||||
|
feed_param_locked,
|
||||||
|
feed_param_check,
|
||||||
|
feed_param_apply
|
||||||
|
};
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ======================= 道F task-7:遥测整定注入表(ui_data_yt_ops_t)装配
|
||||||
|
*
|
||||||
|
* 两页唯一的执行侧转达处。四条装配纪律:
|
||||||
|
* ① 数值与合法域**都**现给执行侧(run_par_deadband / run_par_db_range /
|
||||||
|
* adc_cal_k_uv / RUN_PAR_K_MAX_UV)——本文件不出现任何范围数字字面量;
|
||||||
|
* 系数真带(µV/码执行段裁)没有公开访问器,也**不在此补一份**(task-43 收口),
|
||||||
|
* 真带拒的值经 set 原样返 -1,页面反显 + 保存行 ERR,两层守卫互不掩盖。
|
||||||
|
* ② 写走语义 setter(写值与置脏同一函数内发生,run_par_cal_k_set 文件头契约);
|
||||||
|
* 本层不存在"只置脏不写值"或"只写值不置脏"的组合,UI 永远够不到裸脏位。
|
||||||
|
* ③ 跨任务写口径:真机默认档 = iec104Task 的 C_SE;本文件的调用在 uiTask,
|
||||||
|
* 这**正是** Lead 裁定(task-7 派发②)批准的"装配层转达"整定路径——与 104
|
||||||
|
* 走同一批 setter、同一 int32 单字原子契约,不新开工;run_par.h 里死区旧
|
||||||
|
* 接口"iec104Task 上下文"的限定条件语写于该裁定之前,语义已被裁定覆盖
|
||||||
|
* (系数两支的限定条件已明写装配层入口,措辞同步归算法层下一笔)。
|
||||||
|
* ④ 落盘不靠本表:写成功置脏 → defaultTask 每轮无条件 run_par_store_save()
|
||||||
|
* (查实:唯一 cfg_file_save 调用者,首行"无脏立即返回";104 的 peek→事件位
|
||||||
|
* 只是加速器不是必经链)——所以"OK = 盘上真落了"在真机与模拟器(pump 清脏)
|
||||||
|
* 两端口径一致,黄金参考拍的 WAIT/OK 不是表演。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 跨层行数等值守卫:BSP 侧契约值 ↔ 执行侧真值。本文件是全仓第一处两份头
|
||||||
|
* 同时可见的编译单元——BSP 物理上无法自证(看不到 App 头),守卫落这里 */
|
||||||
|
typedef char feed_yt_db_n_check_t[(UI_DATA_YT_DB_N == (uint8_t)RUN_PAR_DB_N) ? 1 : -1];
|
||||||
|
typedef char feed_yt_ch_n_check_t[(UI_DATA_YT_CH_N == (uint8_t)RUN_PAR_CH_N) ? 1 : -1];
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:死区点数(执行侧真值直通)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返回值:RUN_PAR_DB_N uint8_t
|
||||||
|
* 限定条件:uiTask(注入表回调)
|
||||||
|
* 函数说明:行数从这一头出、BSP 契约常量在文件头编译期守卫对表——两边不等
|
||||||
|
* 编不过,等值不靠注释维持
|
||||||
|
*/
|
||||||
|
static uint8_t feed_yt_db_n(void) {
|
||||||
|
return (uint8_t)RUN_PAR_DB_N;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读第 idx 行死区现状(值 + 粗守域现给;量纲按点型分档)
|
||||||
|
* 入口参数:idx - 0..8(0..7 电气语义通道序,8 频率) uint8_t
|
||||||
|
* a_out - 行现状 ui_data_yt_row_t *
|
||||||
|
* 返回值:0 - 已写出;-1 - 越界 / 指针空(run_par_db_range 自判,本层不重复判) int
|
||||||
|
* 函数说明:unit 的分档(前 4 电压、中 4 电流、末 1 频率)**必须**与
|
||||||
|
* run_par_db_range 的档位分档同序——两处真源都没导出量纲,唯一防线是
|
||||||
|
* run_par.h 地址映射表(0x8030~0x8038 = UA..IN/FREQ)与本表同序这条
|
||||||
|
* 写死的对表纪律 + 黄金像素(档位漂了屏上会呈现"0~50"配 "A" 的肉眼矛盾);
|
||||||
|
* decimals 取 3 = 死区在引擎里的存储分辨率(毫单位 int32,换算单点
|
||||||
|
* s_milli_to_eng 在 run_par.c,本层不重除)
|
||||||
|
*/
|
||||||
|
static int feed_yt_db_row(uint8_t a_idx, ui_data_yt_row_t *a_out) {
|
||||||
|
float lo;
|
||||||
|
float hi;
|
||||||
|
|
||||||
|
if (run_par_db_range(a_idx, &lo, &hi) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
a_out->val = run_par_deadband(a_idx);
|
||||||
|
a_out->lo = lo;
|
||||||
|
a_out->hi = hi;
|
||||||
|
a_out->decimals = 3u;
|
||||||
|
a_out->unit = (a_idx < 4u) ? UI_DATA_YT_UNIT_V
|
||||||
|
: ((a_idx < 8u) ? UI_DATA_YT_UNIT_A : UI_DATA_YT_UNIT_HZ);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 函数功能:写第 idx 行死区(直通语义 setter;返回值原样上抛,页面据此记账) int */
|
||||||
|
static int feed_yt_db_set(uint8_t a_idx, float a_eng) {
|
||||||
|
return run_par_deadband_set(a_idx, a_eng);
|
||||||
|
}
|
||||||
|
|
||||||
|
static uint8_t feed_yt_cal_n(void) {
|
||||||
|
return (uint8_t)RUN_PAR_CH_N;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读第 idx 路系数现状(k = adc_cal **当前生效值**,µV/码)
|
||||||
|
* 返回值:0 - 已写出;-1 - idx 越界或指针空 int
|
||||||
|
* 函数说明:显示值与 104 C_RD 回显同视图(run_par_104_read 用的就是 adc_cal_k_uv:
|
||||||
|
* 文件坏值被真带回退 ROM 时,屏与主站看到同一份 ROM——"页面值按比例
|
||||||
|
* 变化"判据因此量的就是执行段真正吃的数);
|
||||||
|
* lo/hi 只有粗守一层(0..RUN_PAR_K_MAX_UV,上限来自 run_par 唯一出口),
|
||||||
|
* 真带在执行段裁——两层守卫的注释纪律见 ui_w_ytview 头注;
|
||||||
|
* decimals 取 0 是**显示域容量约束**不是精度偏好:粗守上限 1e9 在
|
||||||
|
* ui_w_setlist 的 int32 显示域里只够零位小数(带 2 位则上限装不下),
|
||||||
|
* 屏上输入按整 µV 量化、写口仍收 float;要小数位得收紧粗守或改组件
|
||||||
|
* 值通道,都不在本批,也不许在此抄一份更小的假上限
|
||||||
|
*/
|
||||||
|
static int feed_yt_cal_row(uint8_t a_idx, ui_data_yt_row_t *a_out) {
|
||||||
|
if ((a_out == NULL) || (a_idx >= (uint8_t)RUN_PAR_CH_N)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
a_out->val = adc_cal_k_uv((adc_semantic_t)a_idx);
|
||||||
|
a_out->lo = 0.0f; /* 下限 0 由写口拒 k<=0 兜住,这里只给输入框边界 */
|
||||||
|
a_out->hi = RUN_PAR_K_MAX_UV;
|
||||||
|
a_out->decimals = 0u;
|
||||||
|
a_out->unit = UI_DATA_YT_UNIT_UV;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 函数功能:写第 idx 路系数 k(越界防御后再进语义 setter——setter 形参是枚举,
|
||||||
|
* 编译器帮不上越界忙;返回码直通) int */
|
||||||
|
static int feed_yt_cal_k_set(uint8_t a_idx, float a_uv) {
|
||||||
|
if (a_idx >= (uint8_t)RUN_PAR_CH_N) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return run_par_cal_k_set((adc_semantic_t)a_idx, a_uv);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 函数功能:未标定闭锁徽标源(task-16 单点访问器,与判据停走同一个判定) uint8_t */
|
||||||
|
static uint8_t feed_yt_cal_blocked(void) {
|
||||||
|
return (adc_cal_prot_blocked() != 0) ? 1u : 0u;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 函数功能:整档落盘脏位(保存行三态判据;与 104 留痕同一个 peek,不另发明) uint8_t */
|
||||||
|
static uint8_t feed_yt_pending(void) {
|
||||||
|
return run_par_dirty_peek();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 遥测整定注入表(八件齐全;ui_data_yt_bind 拒残表,缺件就是装配错) */
|
||||||
|
static const ui_data_yt_ops_t s_yt_ops = {
|
||||||
|
feed_yt_db_n,
|
||||||
|
feed_yt_db_row,
|
||||||
|
feed_yt_db_set,
|
||||||
|
feed_yt_cal_n,
|
||||||
|
feed_yt_cal_row,
|
||||||
|
feed_yt_cal_k_set,
|
||||||
|
feed_yt_cal_blocked,
|
||||||
|
feed_yt_pending
|
||||||
|
};
|
||||||
|
|
||||||
|
/* ======================= 道G task-9:通讯参数注入表(ui_data_comm_ops_t)===
|
||||||
|
*
|
||||||
|
* 页面形状(两行的名/序)归 ui_page_comm.c;本层只转达值与合法带——
|
||||||
|
* lo/hi 现取 iec104_comm.h 带宏(唯一出口),值现取 getter(带内自愈),
|
||||||
|
* 装配层**不写第二份数值**(通道五同一条纪律)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读第 idx 行通讯参数现状(值 + 合法带)
|
||||||
|
* 入口参数:idx - UI_DATA_COMM_ROW_CA/_PORT uint8_t
|
||||||
|
* a_out - 行现状 ui_data_comm_row_t *
|
||||||
|
* 返回值:0 - 已整份写出;-1 - idx 越界(不猜、不顶替) int
|
||||||
|
* 限定条件:uiTask 页面绘制链(经 ui_data 通道六转发)
|
||||||
|
*/
|
||||||
|
static int feed_comm_row(uint8_t a_idx, ui_data_comm_row_t *a_out) {
|
||||||
|
if (a_idx == UI_DATA_COMM_ROW_CA) {
|
||||||
|
a_out->val = iec104_comm_ca();
|
||||||
|
a_out->lo = IEC104_COMM_CA_MIN;
|
||||||
|
a_out->hi = IEC104_COMM_CA_MAX;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (a_idx == UI_DATA_COMM_ROW_PORT) {
|
||||||
|
a_out->val = iec104_comm_port();
|
||||||
|
a_out->lo = IEC104_COMM_PORT_MIN;
|
||||||
|
a_out->hi = IEC104_COMM_PORT_MAX;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:写第 idx 行通讯参数(守卫在 setter:带外拒、副本不动、不置脏)
|
||||||
|
* 返回值:0 - 已写并置脏;-1 - idx 越界或带外拒 int
|
||||||
|
* 限定条件:uiTask 按键链(编辑提交)
|
||||||
|
* 函数说明:a_val > 65535 不在此先裁——截断成 u16 会把 65536 洗成 0 再被
|
||||||
|
* setter 合法拒掉,等于替屏幕编了个假值;越界一律原样送 setter 拒
|
||||||
|
*/
|
||||||
|
static int feed_comm_set(uint8_t a_idx, uint32_t a_val) {
|
||||||
|
if (a_val > 65535uL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (a_idx == UI_DATA_COMM_ROW_CA) {
|
||||||
|
return iec104_comm_ca_set((uint16_t)a_val);
|
||||||
|
}
|
||||||
|
if (a_idx == UI_DATA_COMM_ROW_PORT) {
|
||||||
|
return iec104_comm_port_set((uint16_t)a_val);
|
||||||
|
}
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 函数功能:[comm] 段落盘脏位(与页面 WAIT/OK 同一枚 peek,不另发明) uint8_t */
|
||||||
|
static uint8_t feed_comm_pending(void) {
|
||||||
|
return iec104_comm_dirty_peek();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 通讯参数注入表(三件齐全;ui_data_comm_bind 拒残表) */
|
||||||
|
static const ui_data_comm_ops_t s_comm_ops = {
|
||||||
|
feed_comm_row,
|
||||||
|
feed_comm_set,
|
||||||
|
feed_comm_pending
|
||||||
|
};
|
||||||
|
|
||||||
|
/* 首轮懒绑一次:ui_data_feed_tick() 的首个调用点在 App/task/ui_task.c 里先于
|
||||||
|
* ui_menu_init()(首帧发布那次),与通道四 ui_data_maint_bind 的绑定时序等价;
|
||||||
|
* 不在 ui_task.c 直接绑是为了本批写域收在派发单列出的文件里。残表是真故障,
|
||||||
|
* 必须能在启动日志里看见,不能静默(通道四同口径) */
|
||||||
|
static uint8_t s_yt_bound;
|
||||||
|
static uint8_t s_comm_bound; /* 道G task-9 通讯参数表懒绑门(同款先置位防刷日志) */
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:组装参数快照(40 项定值 Q10 + 校验和 + 封存态 + 越界位图 + 未标定闭锁)
|
||||||
|
* 入口参数:p_snap - 目标帧(调用方已清零) ui_data_par_t *
|
||||||
|
* sum - 本次发布的 prot_set_checksum() 值 uint32_t
|
||||||
|
* 返回值:无
|
||||||
|
* 限定条件:uiTask 上下文(读侧;写者 = iec104Task 的 set_sealed 与装载路径,
|
||||||
|
* 读到的永远是"值区与封存一致"的完整快照)
|
||||||
|
* 函数说明:只搬 Q10 原值不换算小数位——换算属表现层
|
||||||
|
* (docs/UI设计/UI_数据供给设计说明.md §8)
|
||||||
|
*/
|
||||||
|
static void feed_fill_par(ui_data_par_t *p_snap, uint32_t sum) {
|
||||||
|
uint8_t i;
|
||||||
|
|
||||||
|
p_snap->checksum = sum;
|
||||||
|
p_snap->valid = 1u;
|
||||||
|
p_snap->locked = prot_set_locked();
|
||||||
|
p_snap->bad_mask = prot_set_bad_mask();
|
||||||
|
/* 越界位图非 0 = 上一次整表装载/提交有越界项(prot_set_bad_mask 的既有口径),
|
||||||
|
* 页面据此反显;为 0 时无意义位图不参与显示 */
|
||||||
|
p_snap->bad_valid = (p_snap->bad_mask != 0u) ? 1u : 0u;
|
||||||
|
/* v2 尾追加:未标定闭锁徽标(与判据停走**同一个判定**:App/algo/adc_cal.c 的
|
||||||
|
* adc_cal_prot_blocked(),道F task-16 起本文件不再自带一份三路判定——两份实现
|
||||||
|
* 可以各自漂移,屏上"未标定"就会与判据实际停不走同一件事)。
|
||||||
|
* ⚠ 不要改回用 rt.eng 反推:eng 要求电气 **8** 路全 VALID,V0.1 双转换器下
|
||||||
|
* UB/UC/IB/IC/IN 永不置位 → eng 恒为引脚档,照它画徽标就永不解锁;
|
||||||
|
* 两个谓词各有用途,边界写在 adc_cal.h 的函数头第③条。
|
||||||
|
* ⚠ 与上面的 locked 是两件事——locked 禁编辑,cal_block 只说明"改了也不会动作" */
|
||||||
|
p_snap->cal_block = adc_cal_prot_blocked();
|
||||||
|
for (i = 0u; i < (uint8_t)PS_ID_COUNT; i++) {
|
||||||
|
p_snap->q10[i] = prot_set_get((prot_set_id_t)i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:组装装置信息快照(身份 + 运行时长 + 通讯参数)
|
||||||
|
* 入口参数:p_snap - 目标帧(调用方已清零) ui_data_info_t *
|
||||||
|
* tick_ms - 当前 tick(换算 uptime_s 用) uint32_t
|
||||||
|
* 返回值:无
|
||||||
|
* 限定条件:uiTask 上下文
|
||||||
|
* 函数说明:页面不得自己 include 协议/配置头取这些值,一律经本帧下发
|
||||||
|
* (docs/UI设计/页面扩充详细方案.md §3.5"值必须在 App 侧取");
|
||||||
|
* 公共地址 CA 不在此下发——固件里 CA 是逐帧从 ASDU 头解析的,
|
||||||
|
* 没有集中常量可取,待 104 侧给出查询点后按扩展规则追加字段
|
||||||
|
*/
|
||||||
|
static void feed_fill_info(ui_data_info_t *p_snap, uint32_t tick_ms) {
|
||||||
|
const ftp_cfg_t *p_ftp = ftp_cfg_get();
|
||||||
|
boot_stats_t boot;
|
||||||
|
iec104_serv_stats_t st104;
|
||||||
|
|
||||||
|
p_snap->valid = 1u;
|
||||||
|
p_snap->uptime_s = tick_ms / 1000u;
|
||||||
|
p_snap->port_104 = iec104_comm_port(); /* 真源 = [comm] RAM 副本(getter 带内恒成立,u16 读原子) */
|
||||||
|
p_snap->port_disp = (uint16_t)UI_NET_SINK_PORT;
|
||||||
|
feed_copy_str(p_snap->model, sizeof(p_snap->model), FEED_MODEL);
|
||||||
|
feed_copy_str(p_snap->sw_ver, sizeof(p_snap->sw_ver), FEED_SW_VER);
|
||||||
|
feed_copy_str(p_snap->hw_ver, sizeof(p_snap->hw_ver), FEED_HW_VER);
|
||||||
|
feed_copy_str(p_snap->build_date, sizeof(p_snap->build_date), __DATE__);
|
||||||
|
feed_copy_str(p_snap->sw_sum, sizeof(p_snap->sw_sum), FEED_SW_SUM);
|
||||||
|
if (p_ftp != NULL) {
|
||||||
|
feed_copy_str(p_snap->ftp_user, sizeof(p_snap->ftp_user), p_ftp->user);
|
||||||
|
} else {
|
||||||
|
p_snap->ftp_user[0] = '\0';
|
||||||
|
}
|
||||||
|
/* 运行报告四数(2026-10-28 UI 补齐):纯搬运,页面不得自己去 include 这两个头。
|
||||||
|
* uptime_s 仍由本函数用 tick 差算——boot_stats 不提供时长,别在那里再算一份。 */
|
||||||
|
boot_stats_get(&boot);
|
||||||
|
iec104_serv_get_stats(&st104);
|
||||||
|
p_snap->boot_count = boot.boot_count;
|
||||||
|
p_snap->boot_seq_state = boot.seq_state;
|
||||||
|
p_snap->boot_write_failed = boot.write_failed;
|
||||||
|
p_snap->rst_cause = boot.rst_cause;
|
||||||
|
p_snap->boot_ready = boot.ready;
|
||||||
|
p_snap->iec104_conn_up = st104.conn_up;
|
||||||
|
p_snap->iec104_apdu_tx = st104.apdu_tx;
|
||||||
|
p_snap->iec104_tx_fail = st104.tx_fail;
|
||||||
|
p_snap->iec104_rx_bytes = st104.rx_bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:装配节拍入口——先落定一条定值整定请求(写方向),再发布实时帧与
|
||||||
|
* 信息帧(满 200 ms 一次)、参数帧(仅校验和变化时);首轮立即发布
|
||||||
|
* (免启动初期出现一闪的 "--")
|
||||||
|
* 入口参数:无
|
||||||
|
* 返回值:无
|
||||||
|
* 限定条件:uiTask 上下文,主循环每轮一次(UI 循环周期 10 ms)
|
||||||
|
* 函数说明:每轮未到期时代价 = 一次 osKernelGetTickCount + 两次减法比较 +
|
||||||
|
* 一次空队列判断(drain 无请求时只是一次计数比较);
|
||||||
|
* 差值法吸收 tick 回绕(同 App/algo/adc_health.h 的 now_ms 口径)。
|
||||||
|
* 写路径失败态(ERR_RANGE/ERR_LOCKED/ERR_BUSY)走屏上通道,不打日志
|
||||||
|
* (每条 DBG 约 90 B Flash + 216 B 栈,AGENTS「日志不是免费的」)
|
||||||
|
*/
|
||||||
|
void ui_data_feed_tick(void) {
|
||||||
|
uint32_t now;
|
||||||
|
uint32_t sum;
|
||||||
|
uint8_t due_rt;
|
||||||
|
|
||||||
|
if (s_yt_bound == 0u) {
|
||||||
|
s_yt_bound = 1u; /* 先置位再绑:失败也不每轮重试刷日志(90 B/条) */
|
||||||
|
if (ui_data_yt_bind(&s_yt_ops) != 0) {
|
||||||
|
DBG_ERROR("UI yt bind: FAIL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (s_comm_bound == 0u) {
|
||||||
|
s_comm_bound = 1u; /* 道G task-9:同款一轮一绑;两表时序等价(先于 ui_menu_init) */
|
||||||
|
if (ui_data_comm_bind(&s_comm_ops) != 0) {
|
||||||
|
DBG_ERROR("UI comm bind: FAIL");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
now = osKernelGetTickCount();
|
||||||
|
|
||||||
|
if (s_map_done == 0u) {
|
||||||
|
feed_map_phasor();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 写路径:先落定整定请求,**再**取校验和——本轮刚写进 prot_set 的定值就进
|
||||||
|
* 本轮"校验和变化才发"那道门,页面保存行的 OK 与新值同一帧回来,不必多等
|
||||||
|
* 一轮(10 ms)。顺序反了不出错,只是慢一轮。
|
||||||
|
* 每轮最多处理一条(ui_data_param_drain 的限定条件):整定是人工低频事件,
|
||||||
|
* 一轮一条已经把最坏耗时钉住,不必排空整个队列
|
||||||
|
* ⚠ 返回值必须显式丢弃:本函数不消费"处理了几条",但 bsp-err 的
|
||||||
|
* consume-result 只认接住或 (void) 两种写法(实测编译器与 cppcheck 都不报) */
|
||||||
|
(void)ui_data_param_drain(&s_param_ops);
|
||||||
|
|
||||||
|
sum = prot_set_checksum();
|
||||||
|
|
||||||
|
due_rt = 0u;
|
||||||
|
if ((s_first_done == 0u) || ((uint32_t)(now - s_last_rt_ms) >= FEED_RT_PERIOD_MS)) {
|
||||||
|
due_rt = 1u;
|
||||||
|
s_first_done = 1u;
|
||||||
|
s_last_rt_ms = now;
|
||||||
|
}
|
||||||
|
if (due_rt != 0u) {
|
||||||
|
(void)memset(&s_scr, 0, sizeof(s_scr));
|
||||||
|
feed_fill_rt(&s_scr.rt, now);
|
||||||
|
(void)ui_data_publish_rt(&s_scr.rt);
|
||||||
|
|
||||||
|
(void)memset(&s_scr, 0, sizeof(s_scr));
|
||||||
|
feed_fill_info(&s_scr.info, now);
|
||||||
|
(void)ui_data_publish_info(&s_scr.info);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 参数帧门控:定值变化是低频事件,不随 200 ms 白拷一帧(契约 §5.2)。
|
||||||
|
* 但"校验和没变"不等于"页面该看的没变"——本帧里还有两个与校验和无关的事实:
|
||||||
|
* · locked(定值闭锁):自检失配时置位,校验和一字不变,
|
||||||
|
* 而整定页保存行要显示 LOCK 并禁编辑;
|
||||||
|
* · cal_block(未标定闭锁):两点标定写完 [adc_cal] 的 valid 就翻转,
|
||||||
|
* 校验和同样不动,而标题上的"未标定"徽标要当场消失。
|
||||||
|
* 只盯校验和,这两件事都到不了屏上(真机现象:标定做完了屏上徽标永远挂着,
|
||||||
|
* 直到某次定值改动才被顺带刷掉——那是把两件不相干的事当同步点)。
|
||||||
|
* 代价:每轮多两次字节比较(无变化时零拷贝),换来屏上状态与真值同拍 */
|
||||||
|
{
|
||||||
|
uint8_t locked_now = prot_set_locked();
|
||||||
|
uint8_t cal_now = adc_cal_prot_blocked(); /* 标定层单点,与判据同源 */
|
||||||
|
|
||||||
|
if ((s_par_sum_valid == 0u) || (sum != s_par_sum)
|
||||||
|
|| (locked_now != s_par_locked) || (cal_now != s_par_cal_block)) {
|
||||||
|
(void)memset(&s_scr, 0, sizeof(s_scr));
|
||||||
|
feed_fill_par(&s_scr.par, sum);
|
||||||
|
(void)ui_data_publish_par(&s_scr.par);
|
||||||
|
s_par_sum = sum;
|
||||||
|
s_par_locked = locked_now;
|
||||||
|
s_par_cal_block = cal_now;
|
||||||
|
s_par_sum_valid = 1u;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:UI 数据组装器(ui_data_feed)
|
||||||
|
* 模块功能:App → UI 数据通道的"写侧"——按节拍从采集/算法/保护/协议/时钟各模块
|
||||||
|
* 取数,组装成 docs/UI设计/UI_数据供给设计说明.md §4 契约的三帧
|
||||||
|
* (实时量 rt / 参数 par / 装置信息 info),经 Drivers/BSP/UI/ui_data.h
|
||||||
|
* 的发布 API 推给 UI 层;本模块是快照的**唯一填充者**
|
||||||
|
* 适用范围:App/app(装配层:可同时 include 两侧头,方向仍是上层依赖下层,
|
||||||
|
* scripts/check_layer_deps.py 只拦"Drivers/BSP 目录反向 include App 目录")
|
||||||
|
* 线程约定:uiTask 上下文,主循环每轮调 ui_data_feed_tick() 一次;与页面读写同
|
||||||
|
* 上下文串行 → 无锁(docs/UI设计/UI_数据供给设计说明.md §5.1)。
|
||||||
|
* ⚠ 不得在 adcTask/protectTask/netTask 里调用本模块
|
||||||
|
* 端接法:MCU = App/task/ui_task.c 主循环;PC 模拟器**不链接本文件**(模拟器不跑
|
||||||
|
* 固件装配链,快照由 simulator/main.c 直接发布固定帧保证像素回归可复现,
|
||||||
|
* 见 docs/UI设计/UI_数据供给设计说明.md §9.2;模拟器豁免登记在
|
||||||
|
* scripts/check_registration.py 的 SIM_EXEMPT)
|
||||||
|
* 作者:王建锋
|
||||||
|
* 创建日期:2026-10-25
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-25 王建锋 创建(道E task-14)
|
||||||
|
* v1.1 2026-10-26 DTU 项目组 道F task-1:tick 兼任定值写路径的落定节拍
|
||||||
|
* (契约 §6.2;实现见 .c 的 s_param_ops)
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef UI_DATA_FEED_H
|
||||||
|
#define UI_DATA_FEED_H
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:装配节拍入口——先落定一条定值整定请求(写方向),再发布实时帧与
|
||||||
|
* 信息帧(满 200 ms 一次)、参数帧(仅校验和变化时);首轮立即发布
|
||||||
|
* (免启动后出现一闪的 "--")
|
||||||
|
* 入口参数:无
|
||||||
|
* 返回值:无
|
||||||
|
* 限定条件:uiTask 上下文,主循环每轮一次(UI 循环周期 10 ms);页面"保存"按键
|
||||||
|
* 与本页节拍同上下文,故写路径的投递/落定之间不需要任何锁
|
||||||
|
* (Drivers/BSP/UI/ui_data.h 写路径节)
|
||||||
|
* 函数说明:本函数每轮代价 = 一次 tick 差值比较 + 一次校验和比较 + 一次
|
||||||
|
* ui_data_param_drain()(队列空时只是一次计数判断);真正的取数与组装
|
||||||
|
* 只在到期时发生(docs/UI设计/UI_数据供给设计说明.md §5.2)。
|
||||||
|
* ⚠ drain 排在取校验和**之前**:本轮写入的定值要在本轮就被"校验和变化
|
||||||
|
* 才发"那道门看见,页面才能在同一帧里同时显示 OK 与新值
|
||||||
|
* 内部零阻塞、零堆分配、零日志(日志每条约 90 B Flash + 216 B 栈,
|
||||||
|
* 200 ms 周期打不起);整定失败态走屏上通道,不打日志
|
||||||
|
*/
|
||||||
|
void ui_data_feed_tick(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* UI_DATA_FEED_H */
|
||||||
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
| `cfg_file.c/h` | **配置引擎核心**:解析 / 查询 / 保存 / 2s 周期 `cfg_file_poll` |
|
| `cfg_file.c/h` | **配置引擎核心**:解析 / 查询 / 保存 / 2s 周期 `cfg_file_poll` |
|
||||||
| `cfg_file_store.h` | **存储后端抽象接口**(MCU=FreeRTOS 堆 / 模拟器=宿主堆,共享核心零分配符号) |
|
| `cfg_file_store.h` | **存储后端抽象接口**(MCU=FreeRTOS 堆 / 模拟器=宿主堆,共享核心零分配符号) |
|
||||||
| `cfg_file_store_mcu.c` | **MCU 端后端实现**:`/config.ini`(临时名由目标路径同目录推导 + `f_rename` 原子写) |
|
| `cfg_file_store_mcu.c` | **MCU 端后端实现**:`/config.ini`(临时名由目标路径同目录推导 + `f_rename` 原子写) |
|
||||||
| `prot_set_store.c/h` | **保护定值持久化胶水(B7)**:`/prot_set.bin` blob 读写(blob 格式/校验/原子装载在 `App/algo/prot_set.c` 纯逻辑层);装载 = storage bring-up(屏障前),落盘 = defaultTask 消费 `SYS_EVT_PROT_SET_SAVE` |
|
| `prot_set_store.c/h` | **保护定值持久化胶水**:`/prot_set.bin` blob 读写(blob 格式/校验/原子装载在 `App/algo/prot_set.c` 纯逻辑层);装载 = storage bring-up(屏障前),落盘 = defaultTask 消费 `SYS_EVT_PROT_SET_SAVE` |
|
||||||
|
|
||||||
## 设计要点
|
## 设计要点
|
||||||
|
|
||||||
|
|||||||
+106
-20
@@ -8,20 +8,28 @@
|
|||||||
* 创建日期:2026-09-14
|
* 创建日期:2026-09-14
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-09-14 王建锋 创建初始版本(P3:段键表引擎 + net_cfg 集成)
|
* v1.0 2026-09-14 王建锋 创建初始版本(P3:段键表引擎 + net_cfg 集成)
|
||||||
* v1.1 2026-09-18 王建锋 P0-1:临时缓冲改经 cfg_store_alloc(MCU = FreeRTOS 堆),
|
* v1.1 2026-09-18 王建锋 临时缓冲改经 cfg_store_alloc(MCU = FreeRTOS 堆),
|
||||||
* 移除对 C 库 malloc 的全部依赖(MicroLIB C 堆仅 512B,
|
* 移除对 C 库 malloc 的全部依赖(MicroLIB C 堆仅 512B,
|
||||||
* 原 4 处 KB 级 malloc 真机必然失败);增编译期堆峰值预算
|
* 原 4 处 KB 级 malloc 真机必然失败);增编译期堆峰值预算
|
||||||
* 校验 + 失败上报自检记录
|
* 校验 + 失败上报自检记录
|
||||||
* v1.2 2026-09-21 王建锋 E-01(错误码传播审计 P0):超长文件守卫根治——poll 判定
|
* v1.2 2026-09-21 王建锋 超长文件守卫根治——poll 判定
|
||||||
* 前置到 cfg_store_stat 真实 st.size(旧检查位于截断后的
|
* 前置到 cfg_store_stat 真实 st.size(旧检查位于截断后的
|
||||||
* len 上恒假=死码,超长文件被截断当有效解析 → 界外静默
|
* len 上恒假=死码,超长文件被截断当有效解析 → 界外静默
|
||||||
* 回工厂默认 → 基线粘住 → 用户配置销毁链);save 输出上限
|
* 回工厂默认 → 基线粘住 → 用户配置销毁链);save 输出上限
|
||||||
* 由 CFG_OUT_MAX 收紧至 CFG_FILE_MAX_LEN;s_write_doc 写盘
|
* 由 CFG_OUT_MAX 收紧至 CFG_FILE_MAX_LEN;s_write_doc 写盘
|
||||||
* 唯一闸口拒写超长文档;load/poll 超长处置补自检留痕
|
* 唯一闸口拒写超长文档;load/poll 超长处置补自检留痕
|
||||||
* v1.3 2026-10-04 王建锋 P2-1:受管键行超 CFG_LINE_TEST_MAX 时拒绝保存——
|
* v1.3 2026-10-04 王建锋 受管键行超 CFG_LINE_TEST_MAX 时拒绝保存——
|
||||||
* 原实现 Pass1 静默跳过超长行,Pass2 遂在段头补写新 KV 且
|
* 原实现 Pass1 静默跳过超长行,Pass2 遂在段头补写新 KV 且
|
||||||
* 原文超长行原样保留 → 同一键出现两行(旧值残留、静默截断)
|
* 原文超长行原样保留 → 同一键出现两行(旧值残留、静默截断)
|
||||||
* 函数说明:引擎状态非重入——调用方须收敛同一任务(defaultTask:poll/save,见 P1-3;
|
* v1.4 2026-10-15 王建锋 生成文档边界余量收口——
|
||||||
|
* s_gen_document 两处 `off += snprintf(...)` 改检查式(截断即
|
||||||
|
* 返回 -1,不再跳过缓冲末尾);注册拒超长键名(≤ 15 字符),
|
||||||
|
* 使每行 112 字节余量成为可编译期验证的前提
|
||||||
|
* v1.5 2026-10-25 DTU 项目组 「数据可信」有符号零点类型:新增值类型 CFG_VAL_INT(有符号十进制,
|
||||||
|
* len 1/2/4 小端补码)——s_decode_int() 用 strtol + ERANGE +
|
||||||
|
* 宽度上下界校验,s_encode() 带负号输出;负零点偏移从此可
|
||||||
|
* 干净落盘(动因见 docs/任务规划/数据可信.md §4.4 与 §7 决策 3)
|
||||||
|
* 函数说明:引擎状态非重入——调用方须收敛同一任务(defaultTask:poll/save;
|
||||||
* 启动期 load 先于任务竞争);FS 卷并发由 FatFS REENTRANT 卷锁保证
|
* 启动期 load 先于任务竞争);FS 卷并发由 FatFS REENTRANT 卷锁保证
|
||||||
* (见《配置文件设计说明》D15 A+ 方案)
|
* (见《配置文件设计说明》D15 A+ 方案)
|
||||||
*/
|
*/
|
||||||
@@ -29,12 +37,14 @@
|
|||||||
/* 头文件包含区 */
|
/* 头文件包含区 */
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
|
||||||
#include <stdlib.h> /* strtoul(键值解码);堆分配一律走 cfg_store_alloc,禁 malloc */
|
#include <stdlib.h> /* strtoul / strtol(键值解码);堆分配一律走 cfg_store_alloc,禁 malloc */
|
||||||
|
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
|
|
||||||
#include <ctype.h>
|
#include <ctype.h>
|
||||||
|
|
||||||
|
#include <errno.h> /* ERANGE:strtol 饱和判别(INT 键解码需要,「数据可信」有符号零点类型) */
|
||||||
|
|
||||||
#include "cfg_file.h"
|
#include "cfg_file.h"
|
||||||
|
|
||||||
#include "cfg_file_store.h"
|
#include "cfg_file_store.h"
|
||||||
@@ -63,7 +73,7 @@ static const char s_doc_header[] =
|
|||||||
#define CFG_OUT_MAX 5120u /* 保存输出缓冲上限(经 cfg_store_alloc 取,见下方堆峰值预算) */
|
#define CFG_OUT_MAX 5120u /* 保存输出缓冲上限(经 cfg_store_alloc 取,见下方堆峰值预算) */
|
||||||
#define CFG_LINE_TEST_MAX 127u /* 受管键行匹配的行长上限(超长必非受管键行) */
|
#define CFG_LINE_TEST_MAX 127u /* 受管键行匹配的行长上限(超长必非受管键行) */
|
||||||
#define CFG_SNAP_MAX 64u /* 变更检测段快照缓冲(挂回调段 data_size 须 ≤ 此值) */
|
#define CFG_SNAP_MAX 64u /* 变更检测段快照缓冲(挂回调段 data_size 须 ≤ 此值) */
|
||||||
#define CFG_NAME_MAX 16u /* 段名缓冲(15 字符 + NUL) */
|
#define CFG_NAME_MAX (CFG_FILE_MAX_NAME_LEN + 1u) /* 段名/键名缓冲(15 字符 + NUL) */
|
||||||
#define CFG_HEAP_BUDGET 10240u /* 暂态堆峰值预算:读缓冲(1B NUL) + 输出缓冲之和上限 */
|
#define CFG_HEAP_BUDGET 10240u /* 暂态堆峰值预算:读缓冲(1B NUL) + 输出缓冲之和上限 */
|
||||||
|
|
||||||
/* 编译期钉死暂态堆峰值:放大 CFG_FILE_MAX_LEN / CFG_OUT_MAX 会先在此编译失败,强制
|
/* 编译期钉死暂态堆峰值:放大 CFG_FILE_MAX_LEN / CFG_OUT_MAX 会先在此编译失败,强制
|
||||||
@@ -259,6 +269,28 @@ static int s_encode(const cfg_key_def_t *p_key, const uint8_t *p_src, char *p_ds
|
|||||||
n = snprintf(p_dst, cap, "%lu", v);
|
n = snprintf(p_dst, cap, "%lu", v);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case CFG_VAL_INT: {
|
||||||
|
long v = 0L;
|
||||||
|
unsigned long raw = 0uL;
|
||||||
|
size_t i;
|
||||||
|
|
||||||
|
if (p_key->len != 1u && p_key->len != 2u && p_key->len != 4u) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
for (i = 0u; i < p_key->len; i++) {
|
||||||
|
raw |= ((unsigned long)p_src[i]) << (8u * i);
|
||||||
|
}
|
||||||
|
/* 小端补码还原为有符号值(两端 long = 32 位:ARMCC 32 位 ARM / MSVC LLP64) */
|
||||||
|
if (p_key->len == 1u) {
|
||||||
|
v = (long)(int8_t)(raw & 0xFFuL);
|
||||||
|
} else if (p_key->len == 2u) {
|
||||||
|
v = (long)(int16_t)(raw & 0xFFFFuL);
|
||||||
|
} else {
|
||||||
|
v = (long)(int32_t)raw;
|
||||||
|
}
|
||||||
|
n = snprintf(p_dst, cap, "%ld", v);
|
||||||
|
break;
|
||||||
|
}
|
||||||
case CFG_VAL_IP4:
|
case CFG_VAL_IP4:
|
||||||
n = snprintf(p_dst, cap, "%u.%u.%u.%u",
|
n = snprintf(p_dst, cap, "%u.%u.%u.%u",
|
||||||
(unsigned)p_src[0], (unsigned)p_src[1],
|
(unsigned)p_src[0], (unsigned)p_src[1],
|
||||||
@@ -272,7 +304,7 @@ static int s_encode(const cfg_key_def_t *p_key, const uint8_t *p_src, char *p_ds
|
|||||||
case CFG_VAL_STR: {
|
case CFG_VAL_STR: {
|
||||||
size_t slen;
|
size_t slen;
|
||||||
|
|
||||||
/* P3-1:len==0 时 `p_key->len - 1u` 回绕为 SIZE_MAX → memcpy 巨长越界。
|
/* len==0 时 `p_key->len - 1u` 回绕为 SIZE_MAX → memcpy 巨长越界。
|
||||||
* STR 字段至少 1 字节(含结尾 NUL),len==0 非法,直接拒绝 */
|
* STR 字段至少 1 字节(含结尾 NUL),len==0 非法,直接拒绝 */
|
||||||
if (p_key->len == 0u) {
|
if (p_key->len == 0u) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -325,6 +357,44 @@ static int s_decode_uint(uint8_t *p_dst, size_t len, const char *p_val) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:解析有符号十进制值(小端补码写入,「数据可信」有符号零点类型)
|
||||||
|
* 入口参数:p_dst - 目标字段 uint8_t *
|
||||||
|
* len - 字段字节数(1/2/4) size_t
|
||||||
|
* p_val - 值文本(可带 +/- 号) const char *
|
||||||
|
* 返回值:0 - 成功;-1 - 非法(空/非数字/带后缀/超本宽度范围/strtol 饱和) int
|
||||||
|
* 限定条件:无
|
||||||
|
* 函数说明:两端 long = 32 位,strtol 对超范围文本饱和到 LONG_MAX/MIN 并置
|
||||||
|
* ERANGE(如 "2147483648" 饱和成 INT32_MAX 本身,光比 v>max_v 抓不住),
|
||||||
|
* 故 ERANGE 与宽度上下界两道都要查;1/2 字节键按 int8/int16 范围收窄
|
||||||
|
*/
|
||||||
|
static int s_decode_int(uint8_t *p_dst, size_t len, const char *p_val) {
|
||||||
|
const long min_v = (len == 1u) ? -128L : ((len == 2u) ? -32768L : (-2147483647L - 1L));
|
||||||
|
const long max_v = (len == 1u) ? 127L : ((len == 2u) ? 32767L : 2147483647L);
|
||||||
|
char *p_end = NULL;
|
||||||
|
long v;
|
||||||
|
size_t i;
|
||||||
|
|
||||||
|
if (len != 1u && len != 2u && len != 4u) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (p_val[0] == '\0') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
errno = 0;
|
||||||
|
v = strtol(p_val, &p_end, 10);
|
||||||
|
if (p_end == p_val || *p_end != '\0' || errno == ERANGE) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (v < min_v || v > max_v) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
for (i = 0u; i < len; i++) {
|
||||||
|
p_dst[i] = (uint8_t)(((unsigned long)v) >> (8u * i));
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:解析点分 IPv4("a.b.c.d",每段 0~255)
|
* 函数功能:解析点分 IPv4("a.b.c.d",每段 0~255)
|
||||||
* 入口参数:p_dst - 4 字节目标 uint8_t *
|
* 入口参数:p_dst - 4 字节目标 uint8_t *
|
||||||
@@ -436,6 +506,8 @@ static int s_decode(const cfg_key_def_t *p_key, uint8_t *p_dst, const char *p_va
|
|||||||
switch (p_key->type) {
|
switch (p_key->type) {
|
||||||
case CFG_VAL_UINT:
|
case CFG_VAL_UINT:
|
||||||
return s_decode_uint(p_dst, p_key->len, p_val);
|
return s_decode_uint(p_dst, p_key->len, p_val);
|
||||||
|
case CFG_VAL_INT:
|
||||||
|
return s_decode_int(p_dst, p_key->len, p_val);
|
||||||
case CFG_VAL_IP4:
|
case CFG_VAL_IP4:
|
||||||
return s_decode_ip4(p_dst, p_val);
|
return s_decode_ip4(p_dst, p_val);
|
||||||
case CFG_VAL_MAC:
|
case CFG_VAL_MAC:
|
||||||
@@ -457,6 +529,7 @@ static int s_decode(const cfg_key_def_t *p_key, uint8_t *p_dst, const char *p_va
|
|||||||
*/
|
*/
|
||||||
static int s_gen_document(char *p_buf, size_t cap, size_t *p_len) {
|
static int s_gen_document(char *p_buf, size_t cap, size_t *p_len) {
|
||||||
size_t off;
|
size_t off;
|
||||||
|
int n;
|
||||||
int i;
|
int i;
|
||||||
int k;
|
int k;
|
||||||
size_t hlen;
|
size_t hlen;
|
||||||
@@ -480,7 +553,11 @@ static int s_gen_document(char *p_buf, size_t cap, size_t *p_len) {
|
|||||||
if (cap < off + 112u) {
|
if (cap < off + 112u) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
off += (size_t)snprintf(p_buf + off, cap - off, "\r\n[%s]\r\n", p_sec->name);
|
n = snprintf(p_buf + off, cap - off, "\r\n[%s]\r\n", p_sec->name);
|
||||||
|
if (n < 0 || (size_t)n >= cap - off) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
off += (size_t)n;
|
||||||
|
|
||||||
for (k = 0; k < (int)p_sec->key_count; k++) {
|
for (k = 0; k < (int)p_sec->key_count; k++) {
|
||||||
const cfg_key_def_t *p_key = &p_sec->p_keys[k];
|
const cfg_key_def_t *p_key = &p_sec->p_keys[k];
|
||||||
@@ -492,7 +569,11 @@ static int s_gen_document(char *p_buf, size_t cap, size_t *p_len) {
|
|||||||
if (cap < off + 112u) {
|
if (cap < off + 112u) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
off += (size_t)snprintf(p_buf + off, cap - off, "%s=%s\r\n", p_key->name, v);
|
n = snprintf(p_buf + off, cap - off, "%s=%s\r\n", p_key->name, v);
|
||||||
|
if (n < 0 || (size_t)n >= cap - off) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
off += (size_t)n;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
*p_len = off;
|
*p_len = off;
|
||||||
@@ -538,7 +619,7 @@ static int s_load_file(uint8_t **p_pbuf, size_t *p_len) {
|
|||||||
* len - 数据长度 size_t
|
* len - 数据长度 size_t
|
||||||
* 返回值:0 - 成功;-1 - 写失败或超长拒写 int
|
* 返回值:0 - 成功;-1 - 写失败或超长拒写 int
|
||||||
* 限定条件:p_buf 非空;len ≤ CFG_FILE_MAX_LEN
|
* 限定条件:p_buf 非空;len ≤ CFG_FILE_MAX_LEN
|
||||||
* 函数说明:E-01 纵深防御——超长文档一旦落盘,下次开机 load 守卫即判损坏、
|
* 函数说明:纵深防御——超长文档一旦落盘,下次开机 load 守卫即判损坏、
|
||||||
* 整份用户配置连同注释/未知段销毁,故在唯一出口硬性拦截,使
|
* 整份用户配置连同注释/未知段销毁,故在唯一出口硬性拦截,使
|
||||||
* "引擎永不产出超长 config.ini"成为结构性质(调用者新增路径免疫)
|
* "引擎永不产出超长 config.ini"成为结构性质(调用者新增路径免疫)
|
||||||
*/
|
*/
|
||||||
@@ -747,7 +828,7 @@ static int s_surgical(char *p_in, size_t in_len, char *p_out, size_t out_cap, si
|
|||||||
char v[80];
|
char v[80];
|
||||||
/* s_encode 成功返回文本长度(≥1),失败返回 -1:
|
/* s_encode 成功返回文本长度(≥1),失败返回 -1:
|
||||||
* 故判成功必须 `< 0` 取反,不能写 `== 0`(恒假,
|
* 故判成功必须 `< 0` 取反,不能写 `== 0`(恒假,
|
||||||
* 使补写路径沦为死码,见 2026-10-02 L0.5 修复) */
|
* 使补写路径沦为死码,见 2026-10-02 主机侧纯逻辑单测修复) */
|
||||||
if (s_encode(p_key, (const uint8_t *)s_sections[idx].p_data + p_key->offset,
|
if (s_encode(p_key, (const uint8_t *)s_sections[idx].p_data + p_key->offset,
|
||||||
v, sizeof(v)) >= 0) {
|
v, sizeof(v)) >= 0) {
|
||||||
if (s_emit_kv(p_out, out_cap, &off, p_key->name, v) != 0) {
|
if (s_emit_kv(p_out, out_cap, &off, p_key->name, v) != 0) {
|
||||||
@@ -842,7 +923,7 @@ static int s_surgical(char *p_in, size_t in_len, char *p_out, size_t out_cap, si
|
|||||||
* i_sec - 段索引 int
|
* i_sec - 段索引 int
|
||||||
* 返回值:1 - 是受管键;0 - 不是(无 '=' 或键名不匹配) int
|
* 返回值:1 - 是受管键;0 - 不是(无 '=' 或键名不匹配) int
|
||||||
* 限定条件:仅比较 '=' 前的键名(大小写无关、剥尾部空白),不解析值
|
* 限定条件:仅比较 '=' 前的键名(大小写无关、剥尾部空白),不解析值
|
||||||
* 函数说明:P2-1 用于"超长行是否落在受管键上"的判定(值可能很长,无法拷入定长缓冲);
|
* 函数说明:用于"超长行是否落在受管键上"的判定(值可能很长,无法拷入定长缓冲);
|
||||||
* 非修改式,故可安全用于仍供 Pass2 原样保留的行
|
* 非修改式,故可安全用于仍供 Pass2 原样保留的行
|
||||||
*/
|
*/
|
||||||
static int s_line_key_managed(const char *p_line, int i_sec) {
|
static int s_line_key_managed(const char *p_line, int i_sec) {
|
||||||
@@ -888,9 +969,9 @@ static int s_line_key_managed(const char *p_line, int i_sec) {
|
|||||||
/*
|
/*
|
||||||
* 函数功能:保存 Pass 1——定位段头行号与各受管键行号
|
* 函数功能:保存 Pass 1——定位段头行号与各受管键行号
|
||||||
* 入口参数:p_in - 原文本(可修改) char *
|
* 入口参数:p_in - 原文本(可修改) char *
|
||||||
* 返回值:0 - 成功;-1 - 受管键行超 CFG_LINE_TEST_MAX(拒绝保存,见 P2-1) int
|
* 返回值:0 - 成功;-1 - 受管键行超 CFG_LINE_TEST_MAX(拒绝保存) int
|
||||||
* 限定条件:行末被 NUL 化且不复原——调用方须传一次性副本
|
* 限定条件:行末被 NUL 化且不复原——调用方须传一次性副本
|
||||||
* 函数说明:P2-1(2026-10-04):受管键行超过 CFG_LINE_TEST_MAX 时**不能**静默跳过
|
* 函数说明:受管键行超过 CFG_LINE_TEST_MAX 时**不能**静默跳过
|
||||||
* ——Pass2 会因该行未登记而在段头补写新 KV,同时原文超长行原样保留,导致
|
* ——Pass2 会因该行未登记而在段头补写新 KV,同时原文超长行原样保留,导致
|
||||||
* 同一键出现两行(旧值残留 + 新值,解析取谁取决于行序)。故此处显式拒绝:
|
* 同一键出现两行(旧值残留 + 新值,解析取谁取决于行序)。故此处显式拒绝:
|
||||||
* 返回 -1,调用方中止保存、原文件不动、自检留痕。非受管键的超长行照旧跳过
|
* 返回 -1,调用方中止保存、原文件不动、自检留痕。非受管键的超长行照旧跳过
|
||||||
@@ -952,7 +1033,7 @@ static int s_surgical_pass1(char *p_in) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
else if (i_cur >= 0) {
|
else if (i_cur >= 0) {
|
||||||
/* 超长行(> CFG_LINE_TEST_MAX):若其键名为本段受管键 → 拒绝保存(P2-1);
|
/* 超长行(> CFG_LINE_TEST_MAX):若其键名为本段受管键 → 拒绝保存;
|
||||||
* 否则按未知内容放过(Pass2 原样保留) */
|
* 否则按未知内容放过(Pass2 原样保留) */
|
||||||
if (s_line_key_managed(p_line, i_cur) != 0) {
|
if (s_line_key_managed(p_line, i_cur) != 0) {
|
||||||
return -1;
|
return -1;
|
||||||
@@ -1004,13 +1085,18 @@ int cfg_file_register(const cfg_section_reg_t *p_reg) {
|
|||||||
for (k = 0; k < (int)p_reg->key_count; k++) {
|
for (k = 0; k < (int)p_reg->key_count; k++) {
|
||||||
const cfg_key_def_t *p_key = &p_reg->p_keys[k];
|
const cfg_key_def_t *p_key = &p_reg->p_keys[k];
|
||||||
|
|
||||||
if (p_key->name == NULL || p_key->len == 0u) {
|
if (p_key->name == NULL || p_key->len == 0u ||
|
||||||
|
strlen(p_key->name) >= CFG_NAME_MAX) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
if (p_key->type == CFG_VAL_UINT &&
|
if (p_key->type == CFG_VAL_UINT &&
|
||||||
(p_key->len != 1u && p_key->len != 2u && p_key->len != 4u)) {
|
(p_key->len != 1u && p_key->len != 2u && p_key->len != 4u)) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
if (p_key->type == CFG_VAL_INT &&
|
||||||
|
(p_key->len != 1u && p_key->len != 2u && p_key->len != 4u)) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
if (p_key->type == CFG_VAL_IP4 && p_key->len != 4u) {
|
if (p_key->type == CFG_VAL_IP4 && p_key->len != 4u) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@@ -1079,7 +1165,7 @@ int cfg_file_load(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (st.size > CFG_FILE_MAX_LEN) {
|
if (st.size > CFG_FILE_MAX_LEN) {
|
||||||
/* 文件超长 = 损坏:留工厂默认 + 重建模板(E-01:补自检留痕——销毁
|
/* 文件超长 = 损坏:留工厂默认 + 重建模板(补自检留痕——销毁
|
||||||
* 用户文件属重大事件,仅串口 DBG_ERROR 在现场无调试器时不可追溯) */
|
* 用户文件属重大事件,仅串口 DBG_ERROR 在现场无调试器时不可追溯) */
|
||||||
if (s_write_template() != 0) {
|
if (s_write_template() != 0) {
|
||||||
DBG_ERROR("load: corrupt file, template write FAIL");
|
DBG_ERROR("load: corrupt file, template write FAIL");
|
||||||
@@ -1168,7 +1254,7 @@ int cfg_file_save(void) {
|
|||||||
(void)memcpy(p_in1, p_in, in_len + 1u);
|
(void)memcpy(p_in1, p_in, in_len + 1u);
|
||||||
|
|
||||||
if (s_surgical_pass1((char *)p_in1) != 0) {
|
if (s_surgical_pass1((char *)p_in1) != 0) {
|
||||||
/* P2-1:受管键行超 CFG_LINE_TEST_MAX → 中止保存(原文件不动)。此类超长行
|
/* 受管键行超 CFG_LINE_TEST_MAX → 中止保存(原文件不动)。此类超长行
|
||||||
* 只可能来自手工编辑(代码生成的受管 KV 行恒 <127B);若强行外科保存会
|
* 只可能来自手工编辑(代码生成的受管 KV 行恒 <127B);若强行外科保存会
|
||||||
* 产生重复键行,故宁可拒存并留痕,也不静默写坏。 */
|
* 产生重复键行,故宁可拒存并留痕,也不静默写坏。 */
|
||||||
cfg_store_free(p_in1);
|
cfg_store_free(p_in1);
|
||||||
@@ -1190,7 +1276,7 @@ int cfg_file_save(void) {
|
|||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* E-01:输出上限由 CFG_OUT_MAX(5120) 收紧至 CFG_FILE_MAX_LEN(4096)——
|
/* 输出上限由 CFG_OUT_MAX(5120) 收紧至 CFG_FILE_MAX_LEN(4096)——
|
||||||
* 旧上限允许把 >4096B 文档合法写盘(= 下一颗雷:下次开机 load 守卫
|
* 旧上限允许把 >4096B 文档合法写盘(= 下一颗雷:下次开机 load 守卫
|
||||||
* 判损坏、整份用户文件连同注释/未知段一起销毁)。超限走下方既有
|
* 判损坏、整份用户文件连同注释/未知段一起销毁)。超限走下方既有
|
||||||
* 溢出中止路径:原文件不动 + 自检上报,RAM 副本保留(可修复后重试) */
|
* 溢出中止路径:原文件不动 + 自检上报,RAM 副本保留(可修复后重试) */
|
||||||
@@ -1256,7 +1342,7 @@ void cfg_file_poll(void) {
|
|||||||
{
|
{
|
||||||
int i;
|
int i;
|
||||||
|
|
||||||
/* E-01(2026-09-21 审计 P0):超长守卫必须基于 cfg_store_stat 的真实
|
/* 2026-09-21 审计 P0:超长守卫必须基于 cfg_store_stat 的真实
|
||||||
* st.size 且先于 s_load_file——s_load_file 读长钉死 CFG_FILE_MAX_LEN
|
* st.size 且先于 s_load_file——s_load_file 读长钉死 CFG_FILE_MAX_LEN
|
||||||
* (nread ≤ 4096 恒成立),旧检查写在读后的 len 上恒假(死码):
|
* (nread ≤ 4096 恒成立),旧检查写在读后的 len 上恒假(死码):
|
||||||
* 超长文件被截断当有效解析 → 界外段/键静默回工厂默认 → 基线粘住 →
|
* 超长文件被截断当有效解析 → 界外段/键静默回工厂默认 → 基线粘住 →
|
||||||
|
|||||||
+21
-9
@@ -8,11 +8,16 @@
|
|||||||
* 创建日期:2026-09-14
|
* 创建日期:2026-09-14
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-09-14 王建锋 创建初始版本(P3:段键表引擎 + net_cfg 集成)
|
* v1.0 2026-09-14 王建锋 创建初始版本(P3:段键表引擎 + net_cfg 集成)
|
||||||
* v1.1 2026-09-15 王建锋 属主任务由 netTask 改为 defaultTask(配置引擎单一属主,P1-3)
|
* v1.1 2026-09-15 王建锋 属主任务由 netTask 改为 defaultTask(配置引擎单一属主)
|
||||||
* v1.2 2026-09-21 王建锋 E-01(错误码传播审计 P0):超长守卫契约同步——poll 沉降
|
* v1.2 2026-09-21 王建锋 超长守卫契约同步——poll 沉降
|
||||||
* 后先按真实 st.size 判超长(旧实现查截断后读长恒假)、
|
* 后先按真实 st.size 判超长(旧实现查截断后读长恒假)、
|
||||||
* save 输出上限收紧至 CFG_FILE_MAX_LEN、写盘唯一闸口拒写
|
* save 输出上限收紧至 CFG_FILE_MAX_LEN、写盘唯一闸口拒写
|
||||||
* 超长;超长处置补自检留痕
|
* 超长;超长处置补自检留痕
|
||||||
|
* v1.3 2026-10-15 王建锋 新增公开常量
|
||||||
|
* CFG_FILE_MAX_NAME_LEN(段名/键名上限 15 字符)
|
||||||
|
* v1.4 2026-10-25 DTU 项目组 「数据可信」有符号零点类型:新增 CFG_VAL_INT 有符号十进制值类型
|
||||||
|
* (len = 1/2/4,小端补码)——标定零点偏移等负值需有符号
|
||||||
|
* 落盘,动因与取舍见 docs/任务规划/数据可信.md §4.4
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#ifndef CFG_FILE_H
|
#ifndef CFG_FILE_H
|
||||||
@@ -27,12 +32,16 @@ extern "C" {
|
|||||||
#include <stddef.h>
|
#include <stddef.h>
|
||||||
|
|
||||||
/* 配置文件长度上限(字节);超过 = 文件损坏,按出厂模板重建
|
/* 配置文件长度上限(字节);超过 = 文件损坏,按出厂模板重建
|
||||||
* (load 与 poll 同判,E-01:poll 在读取前基于真实 st.size 判定) */
|
* (load 与 poll 同判:poll 在读取前基于真实 st.size 判定) */
|
||||||
#define CFG_FILE_MAX_LEN 4096u
|
#define CFG_FILE_MAX_LEN 4096u
|
||||||
|
|
||||||
/* 引擎可注册段数上限 */
|
/* 引擎可注册段数上限 */
|
||||||
#define CFG_FILE_MAX_SECTIONS 8u
|
#define CFG_FILE_MAX_SECTIONS 8u
|
||||||
|
|
||||||
|
/* 段名 / 键名最大字符数(不含结尾 NUL);超长的注册描述符被 cfg_file_register 拒绝。
|
||||||
|
* 该上限是生成文档每行余量(112 字节)可编译期验证的前提,勿放宽 */
|
||||||
|
#define CFG_FILE_MAX_NAME_LEN 15u
|
||||||
|
|
||||||
/* 单段键数上限 */
|
/* 单段键数上限 */
|
||||||
#define CFG_FILE_MAX_KEYS 16u
|
#define CFG_FILE_MAX_KEYS 16u
|
||||||
|
|
||||||
@@ -45,17 +54,20 @@ extern "C" {
|
|||||||
* IP4 :点分 IPv4 "a.b.c.d";字段 uint8_t[4]
|
* IP4 :点分 IPv4 "a.b.c.d";字段 uint8_t[4]
|
||||||
* MAC :十六进制 MAC "xx:xx:xx:xx:xx:xx"(分隔符兼容 - 与 .);字段 uint8_t[6]
|
* MAC :十六进制 MAC "xx:xx:xx:xx:xx:xx"(分隔符兼容 - 与 .);字段 uint8_t[6]
|
||||||
* STR :ASCII 字符串;字段 char[len](len = 缓冲长度,自动补 NUL)
|
* STR :ASCII 字符串;字段 char[len](len = 缓冲长度,自动补 NUL)
|
||||||
|
* INT :有符号十进制(可带 + / - 号);len = 目标字段字节数(1/2/4,小端补码,
|
||||||
|
* 范围按宽度收窄:int8/int16/int32;负值合法,如标定零点偏移)
|
||||||
*/
|
*/
|
||||||
typedef enum {
|
typedef enum {
|
||||||
CFG_VAL_UINT = 0,
|
CFG_VAL_UINT = 0,
|
||||||
CFG_VAL_IP4,
|
CFG_VAL_IP4,
|
||||||
CFG_VAL_MAC,
|
CFG_VAL_MAC,
|
||||||
CFG_VAL_STR
|
CFG_VAL_STR,
|
||||||
|
CFG_VAL_INT
|
||||||
} cfg_val_type_t;
|
} cfg_val_type_t;
|
||||||
|
|
||||||
/* 键定义(offset/len 均相对段 RAM 副本起始) */
|
/* 键定义(offset/len 均相对段 RAM 副本起始) */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
const char *name; /* 键名(小写) */
|
const char *name; /* 键名(小写,≤ CFG_FILE_MAX_NAME_LEN 字符) */
|
||||||
cfg_val_type_t type; /* 值类型 */
|
cfg_val_type_t type; /* 值类型 */
|
||||||
size_t offset; /* 字段偏移 */
|
size_t offset; /* 字段偏移 */
|
||||||
size_t len; /* 字段长度 */
|
size_t len; /* 字段长度 */
|
||||||
@@ -63,7 +75,7 @@ typedef struct {
|
|||||||
|
|
||||||
/* 段注册描述符 */
|
/* 段注册描述符 */
|
||||||
typedef struct {
|
typedef struct {
|
||||||
const char *name; /* 段名(≤ 15 字符) */
|
const char *name; /* 段名(≤ CFG_FILE_MAX_NAME_LEN 字符) */
|
||||||
void *p_data; /* 段 RAM 副本(模块自有静态区,引擎原地更新) */
|
void *p_data; /* 段 RAM 副本(模块自有静态区,引擎原地更新) */
|
||||||
const void *p_defaults; /* 工厂默认值(与 p_data 同布局;load 先复位再解析) */
|
const void *p_defaults; /* 工厂默认值(与 p_data 同布局;load 先复位再解析) */
|
||||||
size_t data_size; /* RAM 副本字节数 */
|
size_t data_size; /* RAM 副本字节数 */
|
||||||
@@ -91,7 +103,7 @@ int cfg_file_register(const cfg_section_reg_t *p_reg);
|
|||||||
* 2. 文件缺失 → 按当前 RAM 副本生成出厂模板写盘(首启建文件)
|
* 2. 文件缺失 → 按当前 RAM 副本生成出厂模板写盘(首启建文件)
|
||||||
* 3. 文件存在 → 解析:有效键写入 RAM 副本;无效键留工厂默认并留痕
|
* 3. 文件存在 → 解析:有效键写入 RAM 副本;无效键留工厂默认并留痕
|
||||||
* 4. 文件超长(> CFG_FILE_MAX_LEN)= 损坏 → 工厂默认 + 重建模板
|
* 4. 文件超长(> CFG_FILE_MAX_LEN)= 损坏 → 工厂默认 + 重建模板
|
||||||
* + 自检留痕(poll 检出运行期超长文件执行同一契约,E-01)
|
* + 自检留痕(poll 检出运行期超长文件执行同一契约)
|
||||||
*/
|
*/
|
||||||
int cfg_file_load(void);
|
int cfg_file_load(void);
|
||||||
|
|
||||||
@@ -104,7 +116,7 @@ int cfg_file_load(void);
|
|||||||
* 值原位改写,缺失段/键补写;文件缺失/损坏 → 按 RAM 副本重建
|
* 值原位改写,缺失段/键补写;文件缺失/损坏 → 按 RAM 副本重建
|
||||||
* 完整文档(含文件头注释);写盘 = 临时文件 + 原子改名;输出超
|
* 完整文档(含文件头注释);写盘 = 临时文件 + 原子改名;输出超
|
||||||
* CFG_FILE_MAX_LEN → 拒绝保存返回 -1(原文件不动 + 自检上报,
|
* CFG_FILE_MAX_LEN → 拒绝保存返回 -1(原文件不动 + 自检上报,
|
||||||
* 引擎写盘闸口同拒,E-01)
|
* 引擎写盘闸口同拒)
|
||||||
*/
|
*/
|
||||||
int cfg_file_save(void);
|
int cfg_file_save(void);
|
||||||
|
|
||||||
@@ -117,7 +129,7 @@ int cfg_file_save(void);
|
|||||||
* 函数说明:(size, mtime 变更令牌) 相对基线变化且连续 CFG_FILE_SETTLE_CNT
|
* 函数说明:(size, mtime 变更令牌) 相对基线变化且连续 CFG_FILE_SETTLE_CNT
|
||||||
* 次轮询稳定 → 先查 st.size 超 CFG_FILE_MAX_LEN = 损坏 → 工厂默认
|
* 次轮询稳定 → 先查 st.size 超 CFG_FILE_MAX_LEN = 损坏 → 工厂默认
|
||||||
* + 重建模板 + 自检留痕(不触发段回调:保留在线修复通道,重启
|
* + 重建模板 + 自检留痕(不触发段回调:保留在线修复通道,重启
|
||||||
* 收敛,E-01);否则重载全部段(各段复位工厂默认后解析);挂回调
|
* 收敛);否则重载全部段(各段复位工厂默认后解析);挂回调
|
||||||
* 段有差异 → 调用其回调(如触发网络重建);重载后刷新基线
|
* 段有差异 → 调用其回调(如触发网络重建);重载后刷新基线
|
||||||
* (回调触发的写盘不引发二次重载)
|
* (回调触发的写盘不引发二次重载)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -9,16 +9,20 @@
|
|||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-09-14 王建锋 创建初始版本
|
* v1.0 2026-09-14 王建锋 创建初始版本
|
||||||
* v1.1 2026-09-18 王建锋 增 cfg_store_alloc / cfg_store_free / cfg_store_heap_free:
|
* v1.1 2026-09-18 王建锋 增 cfg_store_alloc / cfg_store_free / cfg_store_heap_free:
|
||||||
* 共享核心临时缓冲改走 FreeRTOS 堆(P0-1,MicroLIB C 堆仅 512B)
|
* 共享核心临时缓冲改走 FreeRTOS 堆(MicroLIB C 堆仅 512B)
|
||||||
* v1.2 2026-09-22 王建锋 原子写修复:rename 前先 unlink 已存在目标——本 FatFs
|
* v1.2 2026-09-22 王建锋 原子写修复:rename 前先 unlink 已存在目标——本 FatFs
|
||||||
* 移植 f_rename 不覆盖既有目标(FR_EXIST),旧实现一切
|
* 移植 f_rename 不覆盖既有目标(FR_EXIST),旧实现一切
|
||||||
* "目标存在的覆盖写"必然失败(E-01 台架验证暴露)
|
* "目标存在的覆盖写"必然失败(台架验证暴露)
|
||||||
* v1.3 2026-10-02 王建锋 四个后端函数纳入卷锁 try 语义(vol_lock_wait(0)):
|
* v1.3 2026-10-02 王建锋 四个后端函数纳入卷锁 try 语义(vol_lock_wait(0)):
|
||||||
* 与 ftpTask 的 lftpd 文件操作互斥(并发 FTL 写 →
|
* 与 ftpTask 的 lftpd 文件操作互斥(并发 FTL 写 →
|
||||||
* dhara journal 竞争实证);busy 返回 -1 由调用方
|
* dhara journal 竞争实证);busy 返回 -1 由调用方
|
||||||
* 按既有失败语义处理(poll 保留 RAM/保存待重试),
|
* 按既有失败语义处理(poll 保留 RAM/保存待重试),
|
||||||
* 本任务 = 看门狗喂狗点,绝不阻塞等锁
|
* 本任务 = 看门狗喂狗点,绝不阻塞等锁
|
||||||
* v1.4 2026-10-05 王建锋 原子写临时文件由 p_path 推导(P3-2):与目标同目录的
|
* v1.5 2026-10-24 王建锋 仅注释更正:cfg_store_read 的"限定条件"引用了
|
||||||
|
* FRESULT 里不存在的 FR_NO_SYSTEM,改成实际会返回的
|
||||||
|
* FR_NOT_READY / FR_NO_FILESYSTEM 并写明"非 FR_OK 一律
|
||||||
|
* 转 -1,不要枚举错误码集合"(无行为改动)
|
||||||
|
* v1.4 2026-10-05 王建锋 原子写临时文件由 p_path 推导:与目标同目录的
|
||||||
* 固定 8.3 名 config.TMP,不再硬编码卷根;目标恒为
|
* 固定 8.3 名 config.TMP,不再硬编码卷根;目标恒为
|
||||||
* "/config.ini" 时推导结果与旧值逐字节一致
|
* "/config.ini" 时推导结果与旧值逐字节一致
|
||||||
*/
|
*/
|
||||||
@@ -47,7 +51,15 @@ static const char s_path_tmp[] = "/config.TMP";
|
|||||||
* cap - 缓冲容量 size_t
|
* cap - 缓冲容量 size_t
|
||||||
* p_nread - 实际读字节数输出(不可为 NULL) size_t *
|
* p_nread - 实际读字节数输出(不可为 NULL) size_t *
|
||||||
* 返回值:0 - 成功;-1 - 文件缺失/读失败 int
|
* 返回值:0 - 成功;-1 - 文件缺失/读失败 int
|
||||||
* 限定条件:卷已挂载(未挂载时 f_open 返回 FR_NO_SYSTEM,转 -1)
|
* 限定条件:卷已挂载。未挂载时本函数一律转 -1,但**别照着不存在的错误码去写
|
||||||
|
* 判据**:Lib/FatFs/ff.h 的 FRESULT 共 20 个码(FR_OK..FR_INVALID_PARAMETER),
|
||||||
|
* 没有 FR_NO_SYSTEM(旧注释此处笔误,2026-10-24 更正)。本移植里卷未挂载
|
||||||
|
* 实际返回 FR_NOT_READY(3)(物理驱动器不可用)或 FF_VOLUMES 内无已挂载卷时
|
||||||
|
* 由 mount_volume 直接给错;卷上无有效 FAT 则返回 FR_NO_FILESYSTEM(13)。
|
||||||
|
* 本函数(以及 write_atomic / stat / remove)对**任何**非 FR_OK 一律转 -1,
|
||||||
|
* 所以这些码的差异不影响对外语义——也正因如此,谁都不该去枚举错误码集合,
|
||||||
|
* 除了自愈触发集那种"必须区分能不能靠删文件修好"的场合(见 sys_log_mcu.c
|
||||||
|
* 的 s_is_selfheal_code)。
|
||||||
* 函数说明:FIL 为**函数级 static**而非局部——本 FatFs 移植 FF_FS_TINY=0,
|
* 函数说明:FIL 为**函数级 static**而非局部——本 FatFs 移植 FF_FS_TINY=0,
|
||||||
* `FIL` 内含 `buf[FF_MAX_SS]`=512 B(Lib/FatFs/ff.h),一个局部对象就吃掉
|
* `FIL` 内含 `buf[FF_MAX_SS]`=512 B(Lib/FatFs/ff.h),一个局部对象就吃掉
|
||||||
* 528 B 栈,是配置/syslog 调用链栈深的主要来源(2026-10-10 栈预算整改)。
|
* 528 B 栈,是配置/syslog 调用链栈深的主要来源(2026-10-10 栈预算整改)。
|
||||||
@@ -86,7 +98,7 @@ int cfg_store_read(const char *p_path, uint8_t *p_buf, size_t cap, size_t *p_nre
|
|||||||
#define CFG_STORE_TMP_PATH_MAX 24u
|
#define CFG_STORE_TMP_PATH_MAX 24u
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:由目标路径推导同目录的临时文件路径(P3-2)
|
* 函数功能:由目标路径推导同目录的临时文件路径
|
||||||
* 入口参数:p_path - 目标文件路径 const char *
|
* 入口参数:p_path - 目标文件路径 const char *
|
||||||
* p_out - 输出缓冲 char *
|
* p_out - 输出缓冲 char *
|
||||||
* cap - 缓冲容量 size_t
|
* cap - 缓冲容量 size_t
|
||||||
@@ -132,7 +144,7 @@ int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len)
|
|||||||
if (p_buf == NULL) {
|
if (p_buf == NULL) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
/* P3-2:临时文件由 p_path 推导(同目录 + 固定 8.3 名),不再硬编码卷根 */
|
/* 临时文件由 p_path 推导(同目录 + 固定 8.3 名),不再硬编码卷根 */
|
||||||
if (s_build_tmp_path(p_path, tmp_path, sizeof(tmp_path)) != 0) {
|
if (s_build_tmp_path(p_path, tmp_path, sizeof(tmp_path)) != 0) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
@@ -153,7 +165,7 @@ int cfg_store_write_atomic(const char *p_path, const uint8_t *p_buf, size_t len)
|
|||||||
}
|
}
|
||||||
/* 本 FatFs 移植的 f_rename 对已存在目标返回 FR_EXIST、不覆盖(ff.c
|
/* 本 FatFs 移植的 f_rename 对已存在目标返回 FR_EXIST、不覆盖(ff.c
|
||||||
* "new name already in use" 检查,上游同语义)——"目标存在的覆盖写"必须先
|
* "new name already in use" 检查,上游同语义)——"目标存在的覆盖写"必须先
|
||||||
* unlink 目标再 rename。2026-09-22 台架实证(E-01 验证暴露):缺本步时
|
* unlink 目标再 rename。2026-09-22 台架实证:缺本步时
|
||||||
* 一切非首启写(UI 保存/模板重建)静默失败,模拟器后端(POSIX rename
|
* 一切非首启写(UI 保存/模板重建)静默失败,模拟器后端(POSIX rename
|
||||||
* 覆盖语义)照不出。unlink→rename 非原子窗口掉电 = 配置文件暂失,
|
* 覆盖语义)照不出。unlink→rename 非原子窗口掉电 = 配置文件暂失,
|
||||||
* 开机 load 按"首启"重建工厂模板——与函数失败语义一致,丢失边界 =
|
* 开机 load 按"首启"重建工厂模板——与函数失败语义一致,丢失边界 =
|
||||||
@@ -250,7 +262,7 @@ const char *cfg_store_tmp_path(void) {
|
|||||||
* 返回值:缓冲指针(内容未清零);失败返回 NULL void *
|
* 返回值:缓冲指针(内容未清零);失败返回 NULL void *
|
||||||
* 限定条件:任务上下文调用(heap_4 内部临界区自护);释放必须用 cfg_store_free
|
* 限定条件:任务上下文调用(heap_4 内部临界区自护);释放必须用 cfg_store_free
|
||||||
* 函数说明:MCU 端 MicroLIB C 堆仅 512B(startup Heap_Size=0x200),共享核心的 KB 级
|
* 函数说明:MCU 端 MicroLIB C 堆仅 512B(startup Heap_Size=0x200),共享核心的 KB 级
|
||||||
* 缓冲若走 C 库 malloc 必然返回 NULL,故统一由本接口取 FreeRTOS 堆(P0-1)
|
* 缓冲若走 C 库 malloc 必然返回 NULL,故统一由本接口取 FreeRTOS 堆
|
||||||
*/
|
*/
|
||||||
void *cfg_store_alloc(size_t size) {
|
void *cfg_store_alloc(size_t size) {
|
||||||
return pvPortMalloc(size);
|
return pvPortMalloc(size);
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ static uint32_t s_ld32(const uint8_t *p) {
|
|||||||
* -1 损坏被拒
|
* -1 损坏被拒
|
||||||
* 函数说明:读失败与"文件不存在"同归 1——启动期卷锁无竞争(本任务独占
|
* 函数说明:读失败与"文件不存在"同归 1——启动期卷锁无竞争(本任务独占
|
||||||
* bring-up),try-lock 失败即真 I/O 故障,此时保持出厂默认并让
|
* bring-up),try-lock 失败即真 I/O 故障,此时保持出厂默认并让
|
||||||
* 调用方按"无档"处理是安全侧(定值仍封存自洽,L5-1 不误锁)。
|
* 调用方按"无档"处理是安全侧(定值仍封存自洽,定值校验和不误锁)。
|
||||||
* **换代与损坏分开**(B8):magic 完好但 ver/cnt 与当前固件不符 =
|
* **换代与损坏分开**(B8):magic 完好但 ver/cnt 与当前固件不符 =
|
||||||
* 定值表升级(如 B8 32→40 项),是**预期升级路径**不是损坏 →
|
* 定值表升级(如 B8 32→40 项),是**预期升级路径**不是损坏 →
|
||||||
* 归 2 并打 INFO("version change"措辞),调用方不得按 CORRUPT
|
* 归 2 并打 INFO("version change"措辞),调用方不得按 CORRUPT
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:Volume Idle Sync(写回缓存空闲 flush)— MCU 实现
|
||||||
|
* 模块功能:就绪门控 + 周期节流 + 卷锁 try + disk_ioctl(CTRL_SYNC) 的兜底
|
||||||
|
* 状态机(设计/使用约定/边界见 vol_idle_sync.h)
|
||||||
|
* 适用平台:STM32F407ZGTx + FreeRTOS
|
||||||
|
* 作者:DTU 项目组
|
||||||
|
* 创建日期:2026-10-25
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-25 DTU 项目组 创建(道F 批 2:写回缓存空闲 flush 收口)
|
||||||
|
*/
|
||||||
|
|
||||||
|
/* 头文件包含区 */
|
||||||
|
#include "vol_idle_sync.h"
|
||||||
|
|
||||||
|
#include "test_config.h"
|
||||||
|
|
||||||
|
#if !defined(TEST_SUITE_STORAGE)
|
||||||
|
|
||||||
|
#include "cmsis_os.h"
|
||||||
|
#include "FreeRTOS.h"
|
||||||
|
#include "task.h"
|
||||||
|
#include "ff.h" /* FatFs 基础类型(diskio.h 依赖 BYTE/LBA_t 等,须先纳) */
|
||||||
|
#include "diskio.h" /* disk_ioctl / CTRL_SYNC / DRESULT */
|
||||||
|
#include "vol_lock.h"
|
||||||
|
#include "sys_events.h"
|
||||||
|
/* 本模块日志仅一条失败留痕(≤60 字符),默认行缓冲即可,不调 DBG_LINE_BUF_MAX */
|
||||||
|
#include "dbg_log.h"
|
||||||
|
|
||||||
|
/* Private defines -----------------------------------------------------------*/
|
||||||
|
/* 空闲同步检查周期(= 漏网脏页的最大掉电驻留窗,另加一个主循环拍 ≈50ms)。
|
||||||
|
* 取值依据:clean 态为"脏标志 + journal"双短路(零 NAND 触点),高频不伤介质
|
||||||
|
* 只是白跑;脏态最多一次页编程 + journal 收账,与 f_close 同型,但落在卷
|
||||||
|
* 已空闲的窗口、远离活跃写会话。5 s 与主循环其余慢观测(栈/堆水位 2 s)同
|
||||||
|
* 量级而更稀疏——本模块是兜底网,不是持久性主路径(主路径 = close 内同步)。
|
||||||
|
* 锁忙(卷不空闲)同样按本周期重试:不做欠账补偿,节奏恒定好推理。 */
|
||||||
|
#define VOL_IDLE_SYNC_PERIOD_MS 5000u
|
||||||
|
|
||||||
|
/* 静态变量定义区 */
|
||||||
|
/* 状态量放文件级 static(非函数内):主机侧白盒单测(test/host/vol_idle_sync/)
|
||||||
|
* 以 #include 纳入本文件后需要复位它们;命名带 vol 前缀避免与测试桩撞名 */
|
||||||
|
static uint32_t s_vol_sync_last_ms; /* 上次尝试时刻(tick ms,uint32 回绕) */
|
||||||
|
static uint8_t s_vol_sync_armed; /* 0 = 从未尝试(首拍直接放行) */
|
||||||
|
static uint8_t s_vol_sync_fail_latch; /* 失败留痕边沿 latch:一个 episode 一条 */
|
||||||
|
|
||||||
|
/* 函数定义区 */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:存储空闲周期同步轮询(就绪门控 → 节流 → try 卷锁 → CTRL_SYNC)
|
||||||
|
* 入口参数:无 void
|
||||||
|
* 返回值:无 void
|
||||||
|
* 限定条件:仅 defaultTask 主循环调用;内部绝不阻塞(try 锁 + 常数级耗时,
|
||||||
|
* 最坏成本 = 一次 f_close 的同步段)
|
||||||
|
* 函数说明:四级门,逐级变贵:
|
||||||
|
* ① 就绪门:sys_evtHandle 未建或 STORAGE_READY 未置 → 静默
|
||||||
|
* (挂载前 disk_ioctl 只会 RES_NOTRDY,且 bring-up 失败重试期
|
||||||
|
* 卷状态未定,一律不碰);
|
||||||
|
* ② 节流门:距上次尝试不足 VOL_IDLE_SYNC_PERIOD_MS → 返回。
|
||||||
|
* 无符号差值判据((uint32)(now - last) < 周期)天然跨 32 位回绕
|
||||||
|
* 正确,与 stack_probe_poll 同一 idiom;勿"优化"成大小比较;
|
||||||
|
* ③ 空闲门:vol_lock_wait(0) try——锁被持有 = 有文件操作序列在做
|
||||||
|
* (syslog 落盘 / 配置保存 / lftpd open...close),本轮跳过,
|
||||||
|
* 绝不改变"文件持有期内不同步"的既有语义;
|
||||||
|
* ④ 动作:disk_ioctl(0, CTRL_SYNC)——FatFs 官方幂等落盘原语
|
||||||
|
* (f_close 尾部同一条路径):FTL 脏页刷入 + dhara_map_sync。
|
||||||
|
* clean 时两级短路立即返回,零 NAND 操作。
|
||||||
|
* 失败留痕:RES_ERROR 只按"边沿"打一条 DBG_ERROR(latch),持续失败
|
||||||
|
* 不刷屏;下一次成功后 latch 复位,新 episode 再报。底层失败形态
|
||||||
|
* (Cache flush FAIL / Map sync failed)已由 nand_ftl 就地打点,
|
||||||
|
* 本条只补"空闲路径也救不回"的上下文,不重复堆日志锚点。
|
||||||
|
*/
|
||||||
|
void vol_idle_sync_poll(void) {
|
||||||
|
const uint32_t now = (uint32_t)xTaskGetTickCount();
|
||||||
|
DRESULT dr;
|
||||||
|
|
||||||
|
/* ① 就绪门 */
|
||||||
|
if (sys_evtHandle == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((osEventFlagsGet(sys_evtHandle) & SYS_EVT_STORAGE_READY) == 0u) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ② 节流门(回绕安全判据,见函数说明②) */
|
||||||
|
if ((s_vol_sync_armed != 0u)
|
||||||
|
&& ((uint32_t)(now - s_vol_sync_last_ms)
|
||||||
|
< (uint32_t)pdMS_TO_TICKS(VOL_IDLE_SYNC_PERIOD_MS))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
s_vol_sync_last_ms = now;
|
||||||
|
s_vol_sync_armed = 1u;
|
||||||
|
|
||||||
|
/* ③ 空闲门:try 锁,拿不到 = 卷不空闲,本轮跳过(下周期再试) */
|
||||||
|
if (vol_lock_wait(0u) == 0u) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ④ 幂等落盘:脏页刷入 + journal 收账;clean 时零 NAND 触点 */
|
||||||
|
dr = disk_ioctl(0, CTRL_SYNC, NULL);
|
||||||
|
vol_lock_release(); /* 成败都放锁:同步失败不产生锁泄漏 */
|
||||||
|
|
||||||
|
if (dr != RES_OK) {
|
||||||
|
if (s_vol_sync_fail_latch == 0u) {
|
||||||
|
s_vol_sync_fail_latch = 1u;
|
||||||
|
DBG_ERROR("[FS] idle sync FAIL (dr=%d)", (int)dr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s_vol_sync_fail_latch = 0u; /* 成功:重新武装,下个 episode 可再报 */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#else /* TEST_SUITE_STORAGE */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:STORAGE 套件构建下的空实现(编译期裁剪落点)
|
||||||
|
* 入口参数:无 void
|
||||||
|
* 返回值:无 void
|
||||||
|
* 限定条件:仅当 test_config.h 选中 TEST_SUITE_STORAGE 时编入本分支
|
||||||
|
* 函数说明:该构建里挂载/盘写由 testTask 的 storage_test_run 独占(测试含
|
||||||
|
* nand_ftl_deinit 掉电模拟),后台同步与它并发会互相踩坏 FTL/FatFs
|
||||||
|
* 状态——必须彻底静默,而非仅靠运行时门控。
|
||||||
|
*/
|
||||||
|
void vol_idle_sync_poll(void) {
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif /* TEST_SUITE_STORAGE */
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/*
|
||||||
|
* 模块名称:Volume Idle Sync(写回缓存空闲 flush)
|
||||||
|
* 模块功能:随 defaultTask 主循环周期轮询,在存储**空闲**时对 NAND FatFs 卷
|
||||||
|
* 补发一次 CTRL_SYNC(FatFs 幂等落盘语义 = FTL 单页写回缓存脏页刷入
|
||||||
|
* + dhara journal 待办收账,见 docs/架构设计/STM32F4-Base存储架构说明.md
|
||||||
|
* §5.5),作为写回缓存持久性的兜底网:
|
||||||
|
* - 正常写事务语义不变:f_close 内部 sync_fs 已同步,本模块零干预;
|
||||||
|
* - 价值在漏网兜底:任何"缓存留脏、之后再无调用触发 sync"的状态
|
||||||
|
* (异常路径中途放锁等)其掉电驻留窗从"无限期,直到下一笔写"
|
||||||
|
* 收敛为"不超过本模块周期";
|
||||||
|
* - 卷不空闲(卷锁被持有:syslog 落盘 / 配置保存 / lftpd
|
||||||
|
* open...close 序列进行中)本轮跳过——不改变文件持有期(如 FTP
|
||||||
|
* STOR 进行中)的既有语义。
|
||||||
|
* clean 态零 NAND 触点(脏标志与 journal 双短路);脏态成本与一次
|
||||||
|
* f_close 同型。周期 5 s(.c 内 VOL_IDLE_SYNC_PERIOD_MS)。
|
||||||
|
* ⚠ 边界(勿当成掉电保护):本板无掉电检测硬件,"掉电前强制 flush"
|
||||||
|
* 不可行;"单次写事务进行中掉电"的丢失边界也不因本模块改变。
|
||||||
|
* 适用平台:STM32F407ZGTx + FreeRTOS(CMSIS-RTOS V2);仅 MCU 端,
|
||||||
|
* 主机侧纯逻辑单测 test/host/vol_idle_sync/
|
||||||
|
* 作者:DTU 项目组
|
||||||
|
* 创建日期:2026-10-25(道F 批 2:写回缓存空闲 flush 收口)
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-25 DTU 项目组 创建:STORAGE_READY 门控 + 周期节流 +
|
||||||
|
* 卷锁 try + disk_ioctl(CTRL_SYNC) + 失败边沿 latch
|
||||||
|
*/
|
||||||
|
|
||||||
|
#ifndef VOL_IDLE_SYNC_H
|
||||||
|
#define VOL_IDLE_SYNC_H
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
extern "C" {
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:存储空闲周期同步的轮询入口(内部自带就绪门控 / 节流 / 非阻塞
|
||||||
|
* try 锁 / clean 短路,调用点每轮无条件调用即可)
|
||||||
|
* 入口参数:无 void
|
||||||
|
* 返回值:无 void
|
||||||
|
* 限定条件:仅 defaultTask 主循环调用(存储属主任务;本函数内部绝不阻塞——
|
||||||
|
* defaultTask 是看门狗喂狗点,见 vol_lock.h 使用约定);
|
||||||
|
* TEST_SUITE_STORAGE 构建下为空实现(卷由 testTask 独占,后台同步
|
||||||
|
* 必须彻底静默)
|
||||||
|
*/
|
||||||
|
void vol_idle_sync_poll(void);
|
||||||
|
|
||||||
|
#ifdef __cplusplus
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#endif /* VOL_IDLE_SYNC_H */
|
||||||
+62
-2
@@ -6,6 +6,14 @@
|
|||||||
* 创建日期:2026-10-02
|
* 创建日期:2026-10-02
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-10-02 王建锋 创建
|
* v1.0 2026-10-02 王建锋 创建
|
||||||
|
* v1.1 2026-10-26 DTU 项目组 道G task-17:① 新增 4 个观测计数(持锁 / try 超时 /
|
||||||
|
* 分片退让 / 零互斥 四态,
|
||||||
|
* 共 16 B 静态)与 vol_lock_get_stats() 只读口 —— 不取锁、
|
||||||
|
* 零外部调用、零局部变量,13 处调用点一字未动;
|
||||||
|
* ② 修 :38「返回值」注释与代码相反的错(fail-open 原被
|
||||||
|
* 归给 0,实际返 1);③ 刻意不记时刻:时刻要读 tick,
|
||||||
|
* 等于给 defaultTask 取锁链添帧,改由消费方观测并自盖
|
||||||
|
* (命名 obs_first_ms/obs_last_ms,见 vol_lock.h 告诫 ②)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* 头文件包含区 */
|
/* 头文件包含区 */
|
||||||
@@ -16,6 +24,22 @@
|
|||||||
/* 静态变量定义区 */
|
/* 静态变量定义区 */
|
||||||
static osMutexId_t s_lock; /* 卷串行化互斥锁(vol_lock_init 创建) */
|
static osMutexId_t s_lock; /* 卷串行化互斥锁(vol_lock_init 创建) */
|
||||||
|
|
||||||
|
/* 观测计数(道G task-17「卷锁 5 态可观测性」)—— 本模块唯一的新增状态,共 16 B。
|
||||||
|
* 为什么计数必须在锁层、而且只有计数没有别的:
|
||||||
|
* ① 13 处调用点各自 return、历史上零痕;在每个调用点各写一份 = 13 份真源。
|
||||||
|
* ② 不能在这里打日志或投记录:唯一能落盘的通道(/log/wdev.log、run.log)自己也要
|
||||||
|
* 取这把卷锁,而卷锁**非递归** —— 被卷忙挡住时既写不出东西,还会自取死锁。
|
||||||
|
* ③ 也不读 tick 记时刻:那会给 defaultTask 的取锁链添一个 callee 与几字节帧,而
|
||||||
|
* 这条链的栈余量正是要省着花的;"什么时候开始忙"交给消费方观测(口径见 vol_lock.h
|
||||||
|
* 的告诫 ②:观测时刻不等于发生时刻,别写成首次发生)。
|
||||||
|
* 并发:多个任务可同时自增同一个计数,竞态下少计不会多计(判据是"是否 > 0 / 是否还
|
||||||
|
* 在增长",不是精确值),所以不为此上临界区 —— 上临界区反倒把观测做成了故障本身。
|
||||||
|
* volatile:跨任务读写且无锁保护,禁止把读侧重复读优化掉。 */
|
||||||
|
static volatile uint32_t s_st_acquired; /* 持锁态:真取到互斥(四态定义见 vol_lock.h) */
|
||||||
|
static volatile uint32_t s_st_try_timeout; /* try 超时态:timeout=0 没取到,卷被别人占住 */
|
||||||
|
static volatile uint32_t s_st_blk_timeout; /* 分片退让态:分片等待超时,ftpTask 正常退让 */
|
||||||
|
static volatile uint32_t s_st_failopen; /* 零互斥态:锁不存在却返 1,谎报成功那一态 */
|
||||||
|
|
||||||
/* 函数定义区 */
|
/* 函数定义区 */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -35,18 +59,35 @@ int vol_lock_init(void) {
|
|||||||
/*
|
/*
|
||||||
* 函数功能:获取卷锁(首次调用自动 vol_lock_init)
|
* 函数功能:获取卷锁(首次调用自动 vol_lock_init)
|
||||||
* 入口参数:timeout_ms - 等待上限(毫秒);0 = try 语义(不阻塞) uint32_t
|
* 入口参数:timeout_ms - 等待上限(毫秒);0 = try 语义(不阻塞) uint32_t
|
||||||
* 返回值:1 - 已持锁;0 - 超时/未取得,或锁创建失败(fail-open) int
|
* 返回值:1 - 已取得互斥,**或锁不存在的 fail-open(返 1 但此刻并未串行化)**;
|
||||||
|
* 0 - 未取得(timeout=0 的 try 失败,或 timeout>0 的分片等待超时) int
|
||||||
* 限定条件:返回 0 时调用方**不得** vol_lock_release;fail-open 是为保证
|
* 限定条件:返回 0 时调用方**不得** vol_lock_release;fail-open 是为保证
|
||||||
* 看门狗喂狗点(defaultTask)绝不能因等锁而阻塞
|
* 看门狗喂狗点(defaultTask)绝不能因等锁而阻塞
|
||||||
|
* 函数说明:⚠ 原 v1.0 这行「返回值」把 fail-open 归给 0,与本函数 :46 的代码和
|
||||||
|
* TC-VOLLOCK-008 都相反(道G task-17 修正)。"返 1"有两种成因,调用方
|
||||||
|
* 无需各自分辨(它们只关心能不能干活),但整机的判据必须看
|
||||||
|
* vol_lock_get_stats().failopen —— 累计 > 0 = 本 boot 至少发生过一次
|
||||||
|
* "无互斥读写卷"。本函数只自增计数:不打日志、不投记录、不读 tick、
|
||||||
|
* 不新增局部变量(理由见上面计数块)。
|
||||||
*/
|
*/
|
||||||
int vol_lock_wait(uint32_t timeout_ms) {
|
int vol_lock_wait(uint32_t timeout_ms) {
|
||||||
if (s_lock == NULL) {
|
if (s_lock == NULL) {
|
||||||
(void)vol_lock_init();
|
(void)vol_lock_init();
|
||||||
if (s_lock == NULL) {
|
if (s_lock == NULL) {
|
||||||
|
s_st_failopen++; /* 零互斥态:返 1 但零互斥 —— 谎报成功那一态 */
|
||||||
return 1; /* 锁创建失败:fail-open(不阻塞,保持启动段可用) */
|
return 1; /* 锁创建失败:fail-open(不阻塞,保持启动段可用) */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (osMutexWait(s_lock, timeout_ms) == osOK) ? 1 : 0;
|
if (osMutexWait(s_lock, timeout_ms) == osOK) {
|
||||||
|
s_st_acquired++; /* 持锁态:真持锁 */
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (timeout_ms == 0u) {
|
||||||
|
s_st_try_timeout++; /* try 超时态:压根没等(喂狗点/绘制路径只有这一态会发生) */
|
||||||
|
} else {
|
||||||
|
s_st_blk_timeout++; /* 分片退让态:等了一会儿没等到,与 try 超时态语义不同 */
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -60,3 +101,22 @@ void vol_lock_release(void) {
|
|||||||
(void)osMutexRelease(s_lock);
|
(void)osMutexRelease(s_lock);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读卷锁观测计数(契约与消费方告诫见 vol_lock.h 的函数头)
|
||||||
|
* 入口参数:p_out - 输出缓冲;NULL 时无害返回 vol_lock_stats_t *
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:不取卷锁、不关中断、不阻塞;**零外部调用、零局部变量**,因此不改变
|
||||||
|
* 任何调用方的栈最坏链(这条是本卡选型的结论,不要"顺手优化"掉)
|
||||||
|
* 函数说明:逐字段拷而非整体赋值 —— 每个字段都是某个真实值,不会读到半更新的字;
|
||||||
|
* 四元组之间不保证同一瞬间(诊断够用,理由见 .h 的告诫 ①)
|
||||||
|
*/
|
||||||
|
void vol_lock_get_stats(vol_lock_stats_t *p_out) {
|
||||||
|
if (p_out == NULL) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
p_out->acquired = s_st_acquired;
|
||||||
|
p_out->try_timeout = s_st_try_timeout;
|
||||||
|
p_out->blk_timeout = s_st_blk_timeout;
|
||||||
|
p_out->failopen = s_st_failopen;
|
||||||
|
}
|
||||||
|
|||||||
+63
-1
@@ -13,6 +13,13 @@
|
|||||||
* 长耗时恢复旋转饥饿 defaultTask 主循环(喂狗点)
|
* 长耗时恢复旋转饥饿 defaultTask 主循环(喂狗点)
|
||||||
* → IWDG 误复位;单写者卷属主假设在多任务 FatFs
|
* → IWDG 误复位;单写者卷属主假设在多任务 FatFs
|
||||||
* 调用下不成立,须显式串行化)
|
* 调用下不成立,须显式串行化)
|
||||||
|
* v1.1 2026-10-26 DTU 项目组 道G task-17:① 修契约——vol_lock.c 原 :38 的
|
||||||
|
* 「返回值」把 fail-open 归给 0,而代码(:46)与
|
||||||
|
* TC-VOLLOCK-008 都是返 1;现在 .h/.c 两处都按代码写实,
|
||||||
|
* 并显式警告"返 1 也可能是零互斥";② 新增 5 态计数与
|
||||||
|
* vol_lock_get_stats() 只读口(不取锁、任意上下文可调);
|
||||||
|
* ③ 使用约定补「可观测性」段(为何计数在锁层、为何不得
|
||||||
|
* 在失败分支里取锁打日志)
|
||||||
*
|
*
|
||||||
* 使用约定(铁律):
|
* 使用约定(铁律):
|
||||||
* - 锁跨越"一次完整文件操作序列"(open...close / opendir...closedir /
|
* - 锁跨越"一次完整文件操作序列"(open...close / opendir...closedir /
|
||||||
@@ -23,6 +30,12 @@
|
|||||||
* - ftpTask 调用方用 100ms 分片阻塞等待 + 每片心跳(lftpd_task_wd_beat),
|
* - ftpTask 调用方用 100ms 分片阻塞等待 + 每片心跳(lftpd_task_wd_beat),
|
||||||
* 防长等待导致 FTP 心跳位缺席误复位;
|
* 防长等待导致 FTP 心跳位缺席误复位;
|
||||||
* - 锁在内核启动后由 defaultTask 存储 bring-up 创建(vol_lock_init 幂等)。
|
* - 锁在内核启动后由 defaultTask 存储 bring-up 创建(vol_lock_init 幂等)。
|
||||||
|
*
|
||||||
|
* 可观测性(道G task-17):锁层自带 5 态计数 + `vol_lock_get_stats()` 只读口。
|
||||||
|
* 为什么计数必须在**锁层**:唯一能把事写下来的通道(`/log/wdev.log` 等)自己也要
|
||||||
|
* 取这把锁,而卷锁**非递归**——被卷忙挡住时既写不出日志,也不许在失败分支里再取
|
||||||
|
* 同一把锁。所以锁层只自增计数;一次性留痕交给 defaultTask(syslog 唯一写者)按边沿
|
||||||
|
* 补投自检记录。调用方 13 处 try 的分支与返回值一字不动(不动分支 = 不动行为)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#ifndef VOL_LOCK_H
|
#ifndef VOL_LOCK_H
|
||||||
@@ -45,11 +58,60 @@ int vol_lock_init(void);
|
|||||||
/*
|
/*
|
||||||
* 函数功能:获取卷锁
|
* 函数功能:获取卷锁
|
||||||
* 入口参数:timeout_ms - 等待超时(0 = 非阻塞 try) uint32_t
|
* 入口参数:timeout_ms - 等待超时(0 = 非阻塞 try) uint32_t
|
||||||
* 返回值:1 - 已获取;0 - 超时未获取(卷被占用) int
|
* 返回值:1 - 已取得互斥,**或锁不存在的 fail-open(此时并未串行化)**;
|
||||||
|
* 0 - 未取得(try 超时,或分片等待超时) int
|
||||||
* 限定条件:见文件头使用约定(defaultTask 必须 timeout_ms=0)
|
* 限定条件:见文件头使用约定(defaultTask 必须 timeout_ms=0)
|
||||||
|
* 函数说明:⚠ 道G task-17 修掉的契约缺口——本函数只有 0/1 两值,而"返 1"有两种
|
||||||
|
* 成因:真取到锁,与 `osMutexNew` 失败时的 fail-open(返 1 但对卷的访问
|
||||||
|
* **零互斥**)。要求 13 处调用方各自区分是不现实的(它们只关心"能不能干活"),
|
||||||
|
* 判据请改用 `vol_lock_get_stats()` 的 `failopen` 计数:累计 > 0 =
|
||||||
|
* 本 boot 内至少发生过一次"无互斥读写卷"。
|
||||||
|
* 本函数内只**自增计数**:不打日志、不投记录、不阻塞(理由见 .c 计数块)。
|
||||||
*/
|
*/
|
||||||
int vol_lock_wait(uint32_t timeout_ms);
|
int vol_lock_wait(uint32_t timeout_ms);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 卷锁观测计数(道G task-17「卷锁 5 态可观测性」)。四个字段就是四态,语义**不得**合并:
|
||||||
|
* `acquired` 持锁态 = 真取到互斥的次数
|
||||||
|
* `try_timeout` try 超时态 = timeout=0 的 try 失败次数(卷被别的任务占住;
|
||||||
|
* defaultTask/uiTask 的喂狗与绘制路径只有这一态会发生)
|
||||||
|
* `blk_timeout` 分片退让态 = timeout>0 的分片等待超时次数(ftpTask 100 ms 分片正常
|
||||||
|
* 退让;与 try 超时态语义不同:那是"等了一会儿没等到",
|
||||||
|
* 这是"压根没等")
|
||||||
|
* `failopen` 零互斥态 = 锁不存在却返 1 的次数(**谎报成功**那一态,见 wait 函数头)
|
||||||
|
* 「未调用」这一态不占计数:三个计数全 0 就说明这条路径没跑过——区分"没跑过"与
|
||||||
|
* "跑了但一直忙"正是本卡的目的(此前两者在日志上完全一样)。
|
||||||
|
* ⚠ 本层**刻意不记时刻**:记时刻就得读 tick,等于给 defaultTask 的取锁链添一个
|
||||||
|
* callee 与几字节帧,而这条链的余量我们整天都在省着花(诊断不值那几个字节)。
|
||||||
|
* 代价是"什么时候开始忙"只能由消费方观测,见下面 get_stats 的第② 条告诫。
|
||||||
|
*/
|
||||||
|
typedef struct {
|
||||||
|
uint32_t acquired;
|
||||||
|
uint32_t try_timeout;
|
||||||
|
uint32_t blk_timeout;
|
||||||
|
uint32_t failopen;
|
||||||
|
} vol_lock_stats_t;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读卷锁观测计数(纯拷字段)
|
||||||
|
* 入口参数:p_out - 输出缓冲;NULL 时无害返回 vol_lock_stats_t *
|
||||||
|
* 返 回 值:无 void
|
||||||
|
* 限定条件:任意上下文可调——**不取卷锁**、不关中断、不阻塞(卷被长期占住时正是最
|
||||||
|
* 需要读这几位的时刻,观测口再抢锁就把观测做成故障的一部分)
|
||||||
|
* 函数说明:① 四个字段各是 32 位对齐量,Cortex-M4 上单字读写原子,但**不保证是同一
|
||||||
|
* 瞬间的四元组快照**(诊断够用)。计数本身跨任务读-改-写,竞态下可能
|
||||||
|
* **少计不会多计** → 判据用「是否 > 0 / 与上一轮比是否增长」,别拿精确值当账。
|
||||||
|
* ② ⚠ **给消费方的告诫**(消费方 = 看门狗/自检记录道,接口按此写就不会错):
|
||||||
|
* 本层不产出时刻。消费方若自行盖时间戳,那是「**被观测到的时刻**」,不是
|
||||||
|
* 「**发生的时刻**」——真实发生可以早到一整个轮询周期之前。命名请用
|
||||||
|
* `obs_first_ms` / `obs_last_ms`(观测时刻)这类,**别用** `first_ms`/`last_ms`,
|
||||||
|
* 文案里也别写「首次/末次发生」(那就是新的谎报)。
|
||||||
|
* 一次性留痕自己做边沿:照 `sys_log.h` 的 `syslog_post_dropped()` 先例(唯一
|
||||||
|
* 写者按差值补发,一个 episode 一条),锁层自身绝不产日志、不投记录。
|
||||||
|
* ③ 本函数零外部调用、零局部变量(除出参),因此不改变任何调用方的栈最坏链。
|
||||||
|
*/
|
||||||
|
void vol_lock_get_stats(vol_lock_stats_t *p_out);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:释放卷锁(获取方任务释放)
|
* 函数功能:释放卷锁(获取方任务释放)
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
|
|||||||
+65
-4
@@ -7,11 +7,11 @@
|
|||||||
|
|
||||||
| 文件 | 作用 |
|
| 文件 | 作用 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `sys_log.h` | **日志接口(平台无关)**:定长 64B 记录写入 `/log/run.log`·`selftest.log` |
|
| `sys_log.h` | **日志接口(平台无关)**:定长 64B 记录,七类各一个 `/log` 文件(run / selftest / accid / alarm / start / oper / yk)|
|
||||||
| `sys_log_mcu.c` | `sys_log` 的 MCU 端后端实现(NAND + FatFs) |
|
| `sys_log_mcu.c` | `sys_log` 的 MCU 端后端实现(NAND + FatFs) |
|
||||||
| `console_log.c/h` | **控制台单写者日志**:多任务日志经队列汇入唯一写者,避免串口输出交错 |
|
| `console_log.c/h` | **控制台单写者日志**:多任务日志经队列汇入唯一写者,避免串口输出交错 |
|
||||||
| `soe_rec.c/h` | **SOE 记录纯编解码**(B9):64B 定长记录(CP56Time2a 时标 + 类型 + 元件号 + 单调序号 + 值 + sum8 撕裂校验),零 I/O 零 OS 依赖,主机单测覆盖 |
|
| `soe_rec.c/h` | **SOE 记录纯编解码**:64B 定长记录(CP56Time2a 时标 + 类型 + 元件号 + 单调序号 + 值 + sum8 撕裂校验),零 I/O 零 OS 依赖,主机单测覆盖 |
|
||||||
| `soe_store.c/h` | **SOE 落盘胶水**(B9):`/log/soe.log`,protectTask 投递(零文件系统)→ defaultTask 排空落盘 |
|
| `soe_store.c/h` | **SOE 落盘胶水**:`/log/soe.log`,protectTask 投递(零文件系统)→ defaultTask 排空落盘 |
|
||||||
|
|
||||||
## 关键机制
|
## 关键机制
|
||||||
|
|
||||||
@@ -23,13 +23,74 @@
|
|||||||
内核启动后由 `syslog_drain` 惰性创建真队列并排空
|
内核启动后由 `syslog_drain` 惰性创建真队列并排空
|
||||||
- ⚠️ 写盘必须校验 `f_write` 返回的**字节数**(该 FatFs 移植在簇分配失败时返回 `FR_OK` + 短写,
|
- ⚠️ 写盘必须校验 `f_write` 返回的**字节数**(该 FatFs 移植在簇分配失败时返回 `FR_OK` + 短写,
|
||||||
不报错误码);追加写必须 `FA_WRITE|FA_OPEN_APPEND`(`FA_OPEN_APPEND` 单独用**不含写位**)
|
不报错误码);追加写必须 `FA_WRITE|FA_OPEN_APPEND`(`FA_OPEN_APPEND` 单独用**不含写位**)
|
||||||
|
- **清除记录 = 请求 / 落地分离**:非写者上下文(uiTask 等)只能 `syslog_request_clear(id)`
|
||||||
|
置一个标志(零盘触点、零卷锁),真正的 `f_unlink` 在 **defaultTask** 的 `syslog_drain()`
|
||||||
|
**尾部**执行——顺序也是判据的一部分:排在条目排空之后,否则本轮刚排空的旧记录会被
|
||||||
|
写回去、`syslog_count()` 不归零。结果读 `syslog_clear_state(id)`
|
||||||
|
(`NONE/OK/WAIT/FAIL`),事实复核读 `syslog_count()`。
|
||||||
|
落地函数 `syslog_clear_apply(id)` 是 MCU 后端专属(模拟器不实现 → 共享代码误调在
|
||||||
|
链接期就报错):卷锁一律 try,卷占用返 -1 且**不动原文件**;幂等只认 `FR_NO_FILE`,
|
||||||
|
`FR_NO_PATH` 如实返 -1 且**不做任何卷级动作**(不补目录、不重格式化)
|
||||||
|
- 删除的作用域 = 上表七个记录文件(`syslog_request_clear(id)` 逐 id 独立请求);取证文件
|
||||||
|
`wdev.log` 与 `soe.log` 各有各的属主与通道,不在本 API 作用域,想清它们要另开 API。
|
||||||
|
清除成功会往**操作记录**里落一条 `clear <文件>`——但清 `oper.log` 自己时**刻意不投**,
|
||||||
|
否则"我清除了记录"这条会把刚删空的文件又写成 1 条,直接砸掉「删除后 `syslog_count()`
|
||||||
|
归零」这条判据(`TC-SYSLOG-133b` 钉住)
|
||||||
|
- ⚠ 宿主后端(`simulator/sim/sim_syslog.c`)与 MCU 端**判据相同、时序不同**:模拟器单线程,
|
||||||
|
七类对应工作目录下七份宿主文件(`sim_accid.log` 等),`syslog_post_quiet()` 在本端
|
||||||
|
等价于 `syslog_post()`(宿主进程没有那条栈硬线,也没有队列与丢弃);
|
||||||
|
`syslog_request_clear()` 调用内就删完了,因此 `WAIT` 态在模拟器上永不出现
|
||||||
|
(同 `syslog_post` 在两端"有队列 / 直写无队列"的差异)
|
||||||
|
|
||||||
|
### 五类记录与「记录类型 = 日志 ID」(道F task-10,2026-10-26;task-11 记录 5 页按此做,2026-10-28 五页已落地)
|
||||||
|
|
||||||
|
**决策一:记录类型就是 `syslog_id_t` 本身,一类记录 = `/log` 下一个严格 8.3 短名的文件。**
|
||||||
|
64B 定长记录体里**没有类型字段位**:`[0..18]` 时标 / `[19]` 空格 / `[20..29]` epoch /
|
||||||
|
`[30]` 空格 / `[31..58]` 描述 / 尾部补零,这个布局被 UI 记录页与主机解码工具按**固定偏移**
|
||||||
|
读了了很久(run.log / selftest.log 是既有文件,既有读者)。塞类型字节要么挪走 epoch
|
||||||
|
(上电/重启分类就断),要么挪走描述(屏上少字),且两端读者同批改——所以
|
||||||
|
"这条记录是什么类型"由**它在哪个文件里**回答。`TC-SYSLOG-136` 把这条钉成机器断言:
|
||||||
|
同一条内容在七类之间逐字节相同,记录体不编码类型。
|
||||||
|
|
||||||
|
**决策二:不动 `soe_type_t`(二进制 SOE 通道保持现有 8 型)。** 加 SOE 类型必须同步 UI 侧
|
||||||
|
`ui_w_soe.c` 的类型名表(那是另一位队友的写域),而 SOE 页与这五页本就是两套读者
|
||||||
|
(二进制解码器 vs 文本列表)。**别把新类型顺手加进 SOE**:那会同时造出
|
||||||
|
"同一个事件两个真源"。
|
||||||
|
|
||||||
|
| 记录 | 日志 ID / 文件 | 容量上限 | 写入点 | 触发判据 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| 事故 | `SYSLOG_ACCIDENT` / `accid.log` | 512 | `App/task/protect_task.c:188` | `asserted` 上升沿**且**该元件跳闸投退项 = 1 |
|
||||||
|
| 保护告警 | `SYSLOG_ALARM` / `alarm.log` | 1024 | 同文件 `191` | `asserted` 上升沿**且**跳闸投退项 = 0 或该元件无此项 |
|
||||||
|
| 保护启动 | `SYSLOG_START` / `start.log` | 1024 | 同文件 `183` | `picked` 上升沿(越限电平,早于/等于动作) |
|
||||||
|
| 操作 | `SYSLOG_OPER` / `oper.log` | 512 | `App/protocol/iec104/iec104_pts.c:582/610`、`App/task/default_task.c:266`、`App/log/sys_log_mcu.c:682`(宿主 `sim_syslog.c:245`) | 强制复归受理 / 软压板投退落地 / 定值**真落盘成功** / 清除记录成功 |
|
||||||
|
| 遥控 | `SYSLOG_YK` / `yk.log` | 256 | `App/protocol/iec104/iec104_pts.c:581/584/590/594/604/606/617` | 104 双点命令的选择段、执行段、非法 DPC、未知 IOA 各一条 |
|
||||||
|
|
||||||
|
- **"哪些元件算跳闸"只有一个真源**:定值表 `g_prot_set_meta` 里的 `PS_*_TRIP`(枚举注释
|
||||||
|
即"0 告警 / 1 跳闸")+ 实例表 `k_pe[]` 的 `trip_ctl` 列。分类入口
|
||||||
|
`prot_elm_record_kind()`(`App/algo/prot_elm.c`)里**没有第二份元件名单**,
|
||||||
|
没有该投退项的五项(OV/UV/3U0/OF/UF)填 `PROT_ELM_NO_TRIP=0xFF` → 恒告警;
|
||||||
|
`TC-PROTELEM-015` 逐元钉住"逐项独立 / 改定值即改类别 / 无此项者不受别人定值影响"。
|
||||||
|
- **描述只用 ROM 常量或 `prot_elm_name()` 直传**:所有写入点零 `snprintf`、零拼接。
|
||||||
|
最长一条 20 字节 < `SYSLOG_DESC_MAX`(28);`TC-IEC104-030` 逐条量长度(超 28 会被
|
||||||
|
投递侧静默截断,屏上少几个字,没人会去数)。
|
||||||
|
- ⚠️ **两个投递口,按调用者的栈预算选**:`syslog_post()` 的两个"满态丢弃留痕"分支
|
||||||
|
内联着日志行缓冲(每处约 216 B 栈),因此 `syslog_post_quiet()` 给静态最坏链吃紧的
|
||||||
|
任务用(**protectTask、iec104Task 必须用它**),丢弃只累加 `syslog_post_dropped()`、
|
||||||
|
由唯一写者 defaultTask 在 `syslog_drain()` 里按差值补一条留痕——同 `soe_store_post`
|
||||||
|
当年那条 `872 → 1192 B FAIL` 的实证纪律。收/丢判据两个口完全一致。
|
||||||
|
- **读侧契约(给 task-11;2026-10-28 五页已按此接上)**:`syslog_count(id)` + `syslog_get_record(id, idx, ts, ts_len,
|
||||||
|
desc, desc_len)` 与运行/自检记录**同一枚 reader**,"最近 20 条"= 从 `count-20` 起读;
|
||||||
|
`count()==0` 就是空态,页面显示"无记录"。**不要**在共享代码里调 MCU 专属的
|
||||||
|
`syslog_clear_apply()`(模拟器不实现 → 链接期报错是故意的);清除一律走
|
||||||
|
`syslog_request_clear()` + `syslog_clear_state()`,并把 `WAIT` 当正常态看(模拟器端
|
||||||
|
永不出现 `WAIT`,见下条)。
|
||||||
|
|
||||||
### console_log(控制台单写者)
|
### console_log(控制台单写者)
|
||||||
|
|
||||||
- 多任务的生产者 → **单写者消费者**,消除任务间日志行交错
|
- 多任务的生产者 → **单写者消费者**,消除任务间日志行交错
|
||||||
- 设计详见 `docs/子系统设计/控制台单写者日志设计说明.md`
|
- 设计详见 `docs/子系统设计/控制台单写者日志设计说明.md`
|
||||||
|
|
||||||
### soe(SOE 事件顺序记录,B9)
|
### soe(SOE 事件顺序记录,SOE 记录)
|
||||||
|
|
||||||
- **记录 = 64 B 定长二进制**(`soe_rec.h` 布局),落 `/log/soe.log`;
|
- **记录 = 64 B 定长二进制**(`soe_rec.h` 布局),落 `/log/soe.log`;
|
||||||
与 run.log 分文件——run.log 是文本记录、有 UI/主机读取方,二进制 SOE
|
与 run.log 分文件——run.log 是文本记录、有 UI/主机读取方,二进制 SOE
|
||||||
|
|||||||
+407
-68
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:Console Single-Writer Logging — 控制台单写者日志(USART1)实现
|
* 模块名称:Console Single-Writer Logging — 控制台单写者日志(RS232#1 / UART5)实现
|
||||||
* 模块功能:控制台单写者日志的环形缓冲 + 唯一写者发送实现(API 见 console_log.h);
|
* 模块功能:控制台单写者日志的环形缓冲 + 唯一写者发送实现(API 见 console_log.h);
|
||||||
* 兼载 MicroLIB printf 底层 fputc 重定向
|
* 兼载 MicroLIB printf 底层 fputc 重定向
|
||||||
* 适用平台:STM32F407ZGTx(仅 MCU 端)
|
* 适用平台:STM32F407ZGTx(仅 MCU 端)
|
||||||
@@ -8,17 +8,17 @@
|
|||||||
* 修改记录:
|
* 修改记录:
|
||||||
* 2026-09-24 王建锋 创建(自 Src/usart.c USER CODE 区迁出,实现逻辑不变,
|
* 2026-09-24 王建锋 创建(自 Src/usart.c USER CODE 区迁出,实现逻辑不变,
|
||||||
* 仅变更文件位置:HAL 库生成文件不得包含应用代码)
|
* 仅变更文件位置:HAL 库生成文件不得包含应用代码)
|
||||||
* 2026-10-02 王建锋 僵尸通道喂狗节奏保护(台架 gen-288 实证):round-3 的
|
* 2026-10-02 王建锋 僵尸通道喂狗节奏保护(台架日志代次 288 实证):第 3 轮的
|
||||||
* "单次 drain 预算 250ms" 只限单次调用——ORE 锁存在外设软复位后
|
* "单次 drain 预算 250ms" 只限单次调用——ORE 锁存在外设软复位后
|
||||||
* 毫秒级重锁(PA10 悬空 + RX 使能),背靠背 drain 每轮再耗 100ms
|
* 毫秒级重锁(PA10 悬空 + RX 使能),背靠背 drain 每轮再耗 100ms
|
||||||
* 块超时 → 主循环周期拉到 450~500ms → 100ms 节流的喂狗抽样间隙
|
* 块超时 → 主循环周期拉到 450~500ms → 100ms 节流的喂狗抽样间隙
|
||||||
* 超过 IWDG 最坏容限 1.7s → 停喂 → 误复位(且心跳位全员健康、
|
* 超过 IWDG 最坏容限 1.7s → 停喂 → 误复位(且心跳位全员健康、
|
||||||
* 无 stall 日志,纯节奏性饥饿)。修复:发送故障后立即结束本轮
|
* 无 stall 日志,纯节奏性饥饿)。修复:发送故障后立即结束本轮
|
||||||
* drain 并进入 300ms 发送冷却(冷却期内 drain 直接让位主循环),
|
* drain 并进入 300ms 发送冷却(冷却期内 drain 直接让位主循环),
|
||||||
* 保证喂狗节奏 ≤ ~300ms。根治(USART1 TX-only 灭 ORE 机制)
|
* 保证喂狗节奏 ≤ ~300ms。根治(把 USART1 改成只发不收)已于 2026-10-26
|
||||||
* 另行推进(.ioc 结构性修改 + CubeMX 重生成,见 docs/任务规划/登记簿.md S-3)。
|
* 由用户裁定不做,本缓解即长期方案;机制详述 docs/子系统设计/控制台单写者日志设计说明.md §12。
|
||||||
* 2026-10-02 王建锋 round 8b 加固:发送超时判据改用 DWT CYCCNT(时间源无关)。
|
* 2026-10-02 王建锋 第 8b 轮加固:发送超时判据改用 DWT CYCCNT(时间源无关)。
|
||||||
* 台架 round 8a 取证(见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)定位到:defaultTask
|
* 台架第 8a 轮取证(见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录)定位到:defaultTask
|
||||||
* 冻结在 console 突发行内、无 STALL、复位为 IWDGRSTF。唯一自洽机理 =
|
* 冻结在 console 突发行内、无 STALL、复位为 IWDGRSTF。唯一自洽机理 =
|
||||||
* HAL_UART_Transmit 的超时判据 `HAL_GetTick() - Tickstart` 依赖
|
* HAL_UART_Transmit 的超时判据 `HAL_GetTick() - Tickstart` 依赖
|
||||||
* TIM7 的 uwTick —— TIM7 一旦停摆,超时永不成立 → 写者死等 TXE →
|
* TIM7 的 uwTick —— TIM7 一旦停摆,超时永不成立 → 写者死等 TXE →
|
||||||
@@ -31,6 +31,44 @@
|
|||||||
* adcTask(B1)等周期任务此时落日志会与 defaultTask 的存储初始化
|
* adcTask(B1)等周期任务此时落日志会与 defaultTask 的存储初始化
|
||||||
* 日志字节级交错(台架实测 [ADC_TASK] 首帧行与 [NAND_FTL] 行交错)。
|
* 日志字节级交错(台架实测 [ADC_TASK] 首帧行与 [NAND_FTL] 行交错)。
|
||||||
* 提供就绪查询供周期任务门控自身日志,不改直发路径本身。
|
* 提供就绪查询供周期任务门控自身日志,不改直发路径本身。
|
||||||
|
* 2026-10-26 王建锋 **O1 第二个实例(台架实证)修复:裸发路径加「行级单一所有者」**。
|
||||||
|
* 台架同镜像两次启动(fw 戳 Oct 26 2026 16:27:01,排除构建差异):16:28 那次
|
||||||
|
* protectTask 的未标定 INFO 完好,16:37 那次同一行变成
|
||||||
|
* "protection [blocked (UA/UN/IA VALI2D not all s0et)" —— 多出的 3 个字节是
|
||||||
|
* '[' '2' '0',而 DBG 行前缀恰是 '[' + sys_clock_get_str() 的 "2026-…",
|
||||||
|
* **即另一条行的行首三个字符**;串口 framing 错只会改字节、不会让行变长,
|
||||||
|
* 故只可能是另一个写者在同一行内插入。机理 = 写者注册前(内核启动 ->
|
||||||
|
* default_task.c 的 dbg_console_writer_attach,中间隔着整段 storage_bring_up)
|
||||||
|
* 所有任务的整行都走 dbg_console_write_raw 逐字节轮询 USART1->DR,**零互斥**
|
||||||
|
* (docs/子系统设计/控制台单写者日志设计说明.md 在册 O1;20261012_B6 那次
|
||||||
|
* "st[art:" 同一形态)。修法 = con_tx_try_claim()/con_tx_release() 一对
|
||||||
|
* **非阻塞**所有权(只在几条指令的临界区里做 test-and-set,绝不把 7.8 ms
|
||||||
|
* 的发送包进临界区、绝不关中断、绝不等待);抢不到所有权的那条行**不丢**,
|
||||||
|
* 落进既有环形缓冲延后发(竞争失败不该变成丢行,更不该变成 [ERR] 假红——
|
||||||
|
* 那个窗口每 3 次启动命中 2 次),只有环也放不下才计入既有 dropped 账
|
||||||
|
* (真溢出仍然看得见)。顺带堵掉第二处洞:**attach 恰好撞上别人已发出的半行**
|
||||||
|
* ——dbg_console_drain 现在也必须先拿到所有权才开轮,拿不到就让位
|
||||||
|
* (tail 不推进,环里一条不少,下轮 ≤50 ms 后重发,这条路径永不丢)。
|
||||||
|
* 另导出 dbg_console_tx_begin()/tx_end() 供"一条行分多段裸发"的调用方收口
|
||||||
|
* (Src/freertos.c 故障钩子是下一张卡),本模块不改任何调用方文件。
|
||||||
|
* 2026-10-24 王建锋 主机单测(test/host/console_log)照出的四条一并修复:
|
||||||
|
* 冷却改判独立标志 s_con_cooling(旧写法拿 tick 值当"是否在冷却"哨兵,
|
||||||
|
* 故障恰好落在 HAL_GetTick()==0 那一拍时冷却等于没记);
|
||||||
|
* con_append 的"复核空位 + 整行拷贝 + 推进 head"收进同一临界区(旧写法
|
||||||
|
* 判据在区外,多生产者可在环将满时同时过判据 -> head 越过容量把未发出
|
||||||
|
* 的旧行静默覆盖,既不报错也不计丢弃);
|
||||||
|
* drain 失败分诊:Instance==NULL(未初始化)不再走外设软复位自愈(那会
|
||||||
|
* 在 HAL 未就绪时重配 RCC/GPIO),改记 dropped;僵尸通道仍自愈;
|
||||||
|
* 三本账分开(docs §13-O2):新增 dbg_console_tx_timeout(),dropped 只记
|
||||||
|
* 真丢的,tx_fault 仍只记软复位自愈次数;[ERR] dropped 行改为"发成功才
|
||||||
|
* 销账",通道不可用期间不再把丢弃证据一起冲掉。
|
||||||
|
* 2026-10-28 王建锋 **根因归因更正(纯注释,无行为变更)**:原注释把 ORE 锁存
|
||||||
|
* 写成"PA10 悬空 / 未接线",用户确认 PA10 **一直是接线的**,该归因不成立。
|
||||||
|
* 真正的充分条件是"**RX 使能了却从不读 DR**"——线上任意一个字节或一个
|
||||||
|
* 噪声沿使 RXNE 置位后无人读,下一个事件即锁存 ORE(ORE 只能靠"先读 SR
|
||||||
|
* 再读 DR"清除 ⇒ 永不自愈);引脚悬空只影响发生率,不是必要条件。
|
||||||
|
* 缓解措施与结论不变,仅改注释;机制详见
|
||||||
|
* docs/子系统设计/控制台单写者日志设计说明.md §12。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#include "console_log.h"
|
#include "console_log.h"
|
||||||
@@ -55,22 +93,37 @@
|
|||||||
* ① 行级原子:整行在一次临界区内拷入环形缓冲,写者按块一次 HAL_UART_Transmit
|
* ① 行级原子:整行在一次临界区内拷入环形缓冲,写者按块一次 HAL_UART_Transmit
|
||||||
* 发出 → 行与行不可能再交错(UART 只有这一个调用点);
|
* 发出 → 行与行不可能再交错(UART 只有这一个调用点);
|
||||||
* ② 指针可控:进 UART 的缓冲恒为本文件的环形缓冲,调用方脏指针再也到不了 UART;
|
* ② 指针可控:进 UART 的缓冲恒为本文件的环形缓冲,调用方脏指针再也到不了 UART;
|
||||||
* ③ 写者未注册(内核启动早期/裸机)或中断上下文:回退直接阻塞发送(无并发或必须裸发)。
|
* ③ 写者未注册(内核启动早期/裸机)或中断上下文:回退直接发送,但**整行独占**
|
||||||
|
* 由 con_tx_try_claim() 保证(道G task-23:未注册期原来就是"多个任务都直发、
|
||||||
|
* 零互斥",O1 两个实例都出在这里);抢不到独占的那条行不丢,落环形缓冲延后发,
|
||||||
|
* 与当场直发逐字节相同。ISR / 调度器未启动两种上下文放行(不许等)。
|
||||||
* 约束:不新增任务、不动态分配;环形缓冲为 BSS(SRAM1,非 CCM),无 DMA 可达性冲突。
|
* 约束:不新增任务、不动态分配;环形缓冲为 BSS(SRAM1,非 CCM),无 DMA 可达性冲突。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/* ===== 控制台端口(单点收口)=====
|
||||||
|
* 换口只改这一段:句柄 / IRQn / 初始化函数。时钟宏随句柄走——UART5 在 APB1、
|
||||||
|
* USART1 在 APB2,别照抄;串口模式与波特率在 Src/usart.c 的 MX_*_Init 里,本模块不管。
|
||||||
|
* 控制台在 RS232#1/UART5(USART1 专用给屏板链路——700 kbaud 只有它 MCU 引脚直出)。
|
||||||
|
* 端口排布、台架端子与判据见 docs/子系统设计/屏板链路设计说明.md 的 §2 与 §3.2。 */
|
||||||
|
#define CON_UART huart5
|
||||||
|
#define CON_UART_IRQN UART5_IRQn
|
||||||
|
#define CON_UART_CLK_DISABLE() __HAL_RCC_UART5_CLK_DISABLE()
|
||||||
|
#define CON_UART_CLK_ENABLE() __HAL_RCC_UART5_CLK_ENABLE()
|
||||||
|
#define CON_UART_INIT() MX_UART5_Init()
|
||||||
|
|
||||||
#define DBG_CON_RING_SIZE 4096U /* 环形缓冲字节数(必须为 2 的幂) */
|
#define DBG_CON_RING_SIZE 4096U /* 环形缓冲字节数(必须为 2 的幂) */
|
||||||
#define DBG_CON_CHUNK_MAX 256U /* 写者单次 HAL_UART_Transmit 字节上限 */
|
#define DBG_CON_CHUNK_MAX 256U /* 写者单次 HAL_UART_Transmit 字节上限 */
|
||||||
#define DBG_CON_WAIT_MS 200U /* 缓冲满时生产者最长等待(ms),仍满则丢弃并计数 */
|
#define DBG_CON_WAIT_MS 200U /* 缓冲满时生产者最长等待(ms),仍满则丢弃并计数 */
|
||||||
#define DBG_CON_TX_TIMEOUT_MS 100U /* 单次发送超时:256B 块 @115200 标称 22ms;僵尸通道限时 + 自愈 */
|
#define DBG_CON_TX_TIMEOUT_MS 100U /* 单次发送超时:256B 块 @115200 标称 22ms;僵尸通道限时 + 自愈 */
|
||||||
#define DBG_CON_DRAIN_BUDGET_MS 250U /* 单次 drain 连续发送上限:让位喂狗轮询(缺失容忍 800ms) */
|
#define DBG_CON_DRAIN_BUDGET_MS 250U /* 单次 drain 连续发送上限:让位喂狗轮询(缺失容忍 800ms) */
|
||||||
#define DBG_CON_FAULT_COOL_MS 300U /* 故障后发送冷却:ORE 锁存毫秒级重锁,背靠连发无益
|
#define DBG_CON_FAULT_COOL_MS 300U /* 故障后发送冷却:ORE 锁存毫秒级重锁,背靠连发无益
|
||||||
且拖长主循环周期 → 喂狗节奏饥饿(gen-288 实证);
|
且拖长主循环周期 → 喂狗节奏饥饿(台架日志代次 288 实证);
|
||||||
冷却期内 drain 只让位、不发送,下轮重试 */
|
冷却期内 drain 只让位、不发送,下轮重试 */
|
||||||
|
|
||||||
/* ==== round 8b:与时间源无关的硬上界(DWT CYCCNT)====
|
/* ==== 第 8b 轮:与时间源无关的硬上界(DWT CYCCNT)====
|
||||||
* HAL_UART_Transmit 的超时判据是 `HAL_GetTick() - Tickstart > Timeout`,而 HAL 时基
|
* HAL_UART_Transmit 的超时判据是 `HAL_GetTick() - Tickstart > Timeout`,而 HAL 时基
|
||||||
* = TIM7 的 uwTick。TIM7 停摆 → 超时永不成立 → 写者死等 TXE → 唯一喂狗者停摆 →
|
* = TIM7 的 uwTick。TIM7 停摆 → 超时永不成立 → 写者死等 TXE → 唯一喂狗者停摆 →
|
||||||
* IWDG 误复位(round 8a 台架现场)。改用 DWT CYCCNT(内核硬件周期计数,不依赖任何
|
* IWDG 误复位(第 8a 轮台架现场)。改用 DWT CYCCNT(内核硬件周期计数,不依赖任何
|
||||||
* 中断/uwTick)做判据;DWT 使能失败时退回 HAL_UART_Transmit(旧行为,功能不退化)。 */
|
* 中断/uwTick)做判据;DWT 使能失败时退回 HAL_UART_Transmit(旧行为,功能不退化)。 */
|
||||||
#define CON_TX_TIMEOUT_CYC (SystemCoreClock / 10U) /* 发送硬上界 = 100ms 对应周期数 */
|
#define CON_TX_TIMEOUT_CYC (SystemCoreClock / 10U) /* 发送硬上界 = 100ms 对应周期数 */
|
||||||
/*
|
/*
|
||||||
@@ -89,20 +142,74 @@
|
|||||||
static uint8_t s_con_ring[DBG_CON_RING_SIZE];
|
static uint8_t s_con_ring[DBG_CON_RING_SIZE];
|
||||||
static volatile uint16_t s_con_head; /* 生产者写入位置 */
|
static volatile uint16_t s_con_head; /* 生产者写入位置 */
|
||||||
static volatile uint16_t s_con_tail; /* 写者读取位置 */
|
static volatile uint16_t s_con_tail; /* 写者读取位置 */
|
||||||
static volatile uint32_t s_con_drop; /* 因缓冲满丢弃的行数 */
|
static volatile uint32_t s_con_drop; /* 真没落地的丢弃数(一次一行/一块) */
|
||||||
static volatile uint32_t s_con_tx_fault; /* TX 失败(僵尸通道)次数,均经外设软复位自愈 */
|
static volatile uint32_t s_con_tx_fault; /* TX 失败(僵尸通道)次数,均经外设软复位自愈 */
|
||||||
static volatile uint8_t s_con_tx_fault_log; /* TX 故障留痕待上报(由写者任务在 drain 之外补记 run.log) */
|
static volatile uint8_t s_con_tx_fault_log; /* TX 故障留痕待上报(由写者任务在 drain 之外补记 run.log) */
|
||||||
static volatile uint32_t s_con_cool_until; /* 故障冷却截止 tick(0 = 无冷却) */
|
static volatile uint32_t s_con_cool_until; /* 故障冷却截止 tick(只存时刻,不再兼作哨兵) */
|
||||||
|
static volatile uint8_t s_con_cooling; /* 1 = 正在故障冷却(独立标志:tick 恰好等于 0 的那一拍也必须算在冷却) */
|
||||||
|
static volatile uint32_t s_con_tx_timeout; /* 发送块超时次数(写者排空所见,与丢弃账分开) */
|
||||||
static volatile uint8_t s_con_draining; /* 1 = 处于 drain 中(重入保护) */
|
static volatile uint8_t s_con_draining; /* 1 = 处于 drain 中(重入保护) */
|
||||||
static volatile uint8_t s_con_writer_up; /* 1 = 写者已注册(此后走环形缓冲) */
|
static volatile uint8_t s_con_writer_up; /* 1 = 写者已注册(此后走环形缓冲) */
|
||||||
static volatile uint8_t s_con_dwt_up; /* 1 = DWT CYCCNT 已使能(硬上界基准可用) */
|
static volatile uint8_t s_con_dwt_up; /* 1 = DWT CYCCNT 已使能(硬上界基准可用) */
|
||||||
static TaskHandle_t s_con_writer_task; /* 写者任务句柄(自等保护用) */
|
static TaskHandle_t s_con_writer_task; /* 写者任务句柄(自等保护用) */
|
||||||
|
/* 行级单一所有者(道G task-23,O1 第二实例):控制台串口数据寄存器同一时刻只允许
|
||||||
|
* 一个上下文在逐字节轮询写入。NULL = 空闲。
|
||||||
|
* ⚠ 这不是"锁",是**所有权凭证**:拿不到就当场让路,绝不排队等待(见
|
||||||
|
* con_tx_try_claim 函数头:裸发一行 @115200 约 7.8 ms,把它包进临界区等于给
|
||||||
|
* 启动窗口塞进 7.8 ms 的中断延迟与最坏喂狗抖动,比交错本身更糟)。 */
|
||||||
|
/*
|
||||||
|
* 本模块"只有几条指令"的关中断窗口(道G task-26 收口时定,2026-10-26)
|
||||||
|
* ---------------------------------------------------------------------
|
||||||
|
* 刻意**不用** taskENTER_CRITICAL()/taskEXIT_CRITICAL(),改用 CMSIS 的 PRIMASK
|
||||||
|
* 保存 + 恢复。理由是一条只在**静态调用图**上看得见、真机上却很贵的坑:
|
||||||
|
* taskEXIT_CRITICAL() => vPortExitCritical() => configASSERT()
|
||||||
|
* => vApplicationAssertFailed() (本仓断言钩子自 2026-09-29 起就调
|
||||||
|
* dbg_console_write_raw 上报 file:line)
|
||||||
|
* => 又回到本模块 => 又进临界区
|
||||||
|
* armlink 因此在本模块周围长出三条互递归环(con_tx_try_claim => vPortExitCritical、
|
||||||
|
* dbg_console_write_raw => con_tx_release、con_ring_try => vPortExitCritical),并把
|
||||||
|
* 这坨算进**每一条含临界区的任务链**:实测 vApplicationAssertFailed 的 Max Depth 从
|
||||||
|
* 约 40 B 变 128 B + In Cycle,defaultTask 3,040->3,120、netTask 1,920->2,000、
|
||||||
|
* iec104Task 1,464->1,544 —— 三条同涨 80 B 就是"加深在共享帧上"的铁证,iec104 的
|
||||||
|
* margin 因此掉到 1,016 < 1,024 ISR 底线。
|
||||||
|
* 换成 PRIMASK 之后:
|
||||||
|
* ① 三个原语都是 core_cmFunc.h 里的静态内联 —— 零调用帧、零断言,那三条环不成立;
|
||||||
|
* ② **屏蔽面变宽是有意的**:本 port(RVDS/ARM_CM4F)的临界区走 BASEPRI,只挡
|
||||||
|
* 优先级数值 >= configMAX_SYSCALL_INTERRUPT_PRIORITY 的中断,PRIMASK 连更高
|
||||||
|
* 优先级的也一起挡(NMI 与 HardFault 除外)。这是**语义变强**,不是变弱;窗口
|
||||||
|
* 只有几条指令(一次 test-and-set / 一次清属主 / 一次整行入环拷贝,与换之前
|
||||||
|
* 同宽度同时长),代价是几微秒级的额外屏蔽,读代码的人必须知道这一点。
|
||||||
|
* ③ 不依赖调度器与 uxCriticalNesting,未注册期与中断里同样可用。
|
||||||
|
* ⚠ **为什么必须把 PRIMASK 存进局部变量、退出时原样写回,而不是直接 __enable_irq()**:
|
||||||
|
* 本模块的窗口可能嵌套在调用者已有的临界区里(断言/故障钩子、写者 drain 轮内的
|
||||||
|
* 留痕行都是)。无条件开中断 = **替别人把任务级临界区提前打开**,那是比交错严重
|
||||||
|
* 一个量级的竞态;保存/恢复则天然支持嵌套。保存量放局部而不是共享静态量,是因为
|
||||||
|
* 窗口可能被更高优先级上下文再次进入,共享量会被它改写、退出时就恢复错了。
|
||||||
|
* ⚠ 只允许包住"几条指令"的量级,**绝不允许**包住串口发送本身:一行 90 B @115200
|
||||||
|
* 约 7.8 ms,把它关进中断等于给启动窗口塞 7.8 ms 中断延迟与最坏喂狗抖动
|
||||||
|
* (红线与机器判据见 con_tx_bounded 函数头与主机单测 TC-CONLOG-015b 与 TC-CONLOG-177)。
|
||||||
|
*/
|
||||||
|
#define CON_CRIT_ENTER(pm_var) do { (pm_var) = __get_PRIMASK(); __disable_irq(); } while (0)
|
||||||
|
#define CON_CRIT_EXIT(pm_var) do { __set_PRIMASK(pm_var); } while (0)
|
||||||
|
|
||||||
|
static volatile TaskHandle_t s_con_tx_owner;
|
||||||
|
static volatile uint8_t s_con_tx_depth; /* 同一任务重入深度(drain 轮内的裸发留痕行) */
|
||||||
|
|
||||||
|
/* con_tx_try_claim 的三种结果(区分开是必要的:**放行**的那些上下文没有持有凭证,
|
||||||
|
* 绝不能在释放时把别人的所有权清掉) */
|
||||||
|
#define CON_TX_NO 0u /* 别人正持有整行 -> 调用方让路(不阻塞) */
|
||||||
|
#define CON_TX_BYPASS 1u /* 放行:本上下文不许等也无需凭证(ISR / 调度器未启动) */
|
||||||
|
#define CON_TX_HELD 2u /* 已持有:调用方发完必须 con_tx_release */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:计算环形缓冲已用字节数(head-tail 差值回绕取模,无锁)
|
* 函数功能:计算环形缓冲已用字节数(head-tail 差值回绕取模,无锁)
|
||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返回值:已用字节数 uint16_t
|
* 返回值:已用字节数 uint16_t
|
||||||
*/
|
*/
|
||||||
|
static uint8_t con_ring_try(const char *p_buf, uint16_t len);
|
||||||
|
/* 定义在下方 con_append 旁;裸发让路路径要用,故前置声明
|
||||||
|
* (ARMCC 对隐式声明报警告,本仓红线是 0 警告) */
|
||||||
|
|
||||||
static uint16_t con_used(void) {
|
static uint16_t con_used(void) {
|
||||||
return (uint16_t)((uint16_t)(s_con_head - s_con_tail) & (DBG_CON_RING_SIZE - 1U));
|
return (uint16_t)((uint16_t)(s_con_head - s_con_tail) & (DBG_CON_RING_SIZE - 1U));
|
||||||
}
|
}
|
||||||
@@ -133,7 +240,7 @@ static void con_dwt_ensure(void) {
|
|||||||
* 入口参数:u - USART 寄存器基址(调用方已判非 NULL) USART_TypeDef *
|
* 入口参数:u - USART 寄存器基址(调用方已判非 NULL) USART_TypeDef *
|
||||||
* p_buf - 待发数据;len - 字节数
|
* p_buf - 待发数据;len - 字节数
|
||||||
* 返回值:HAL_OK - 已发出;HAL_TIMEOUT - 计数上界内未完成(本块放弃)
|
* 返回值:HAL_OK - 已发出;HAL_TIMEOUT - 计数上界内未完成(本块放弃)
|
||||||
* 限定条件:USART1 已初始化
|
* 限定条件:控制台串口已初始化
|
||||||
* 函数说明:刻意不用 HAL_UART_Transmit——理由见 CON_TX_GUARD_MAX 处注释
|
* 函数说明:刻意不用 HAL_UART_Transmit——理由见 CON_TX_GUARD_MAX 处注释
|
||||||
* (tick 依赖不可信 + 其调用链污染所有任务的最坏栈链)
|
* (tick 依赖不可信 + 其调用链污染所有任务的最坏栈链)
|
||||||
*/
|
*/
|
||||||
@@ -163,12 +270,12 @@ static HAL_StatusTypeDef con_tx_counted(USART_TypeDef *u, const uint8_t *p_buf,
|
|||||||
* 函数功能:有界裸发送(不依赖 uwTick / HAL 超时)
|
* 函数功能:有界裸发送(不依赖 uwTick / HAL 超时)
|
||||||
* 入口参数:p_buf - 待发数据;len - 字节数(调用方已限 ≤ DBG_CON_CHUNK_MAX)
|
* 入口参数:p_buf - 待发数据;len - 字节数(调用方已限 ≤ DBG_CON_CHUNK_MAX)
|
||||||
* 返回值:HAL_OK - 已全部写入发送寄存器;HAL_TIMEOUT - 有界上界内未完成
|
* 返回值:HAL_OK - 已全部写入发送寄存器;HAL_TIMEOUT - 有界上界内未完成
|
||||||
* 限定条件:USART1 已初始化
|
* 限定条件:控制台串口已初始化
|
||||||
* 函数说明:寄存器级轮询 TXE/TC,超时判据用 DWT CYCCNT(CON_TX_TIMEOUT_CYC);
|
* 函数说明:寄存器级轮询 TXE/TC,超时判据用 DWT CYCCNT(CON_TX_TIMEOUT_CYC);
|
||||||
* DWT 未使能时走 con_tx_counted(指令计数上界,2026-10-10 取代
|
* DWT 未使能时走 con_tx_counted(指令计数上界,2026-10-10 取代
|
||||||
* HAL_UART_Transmit 回退)。本函数只做 TX,
|
* HAL_UART_Transmit 回退)。本函数只做 TX,
|
||||||
* 不触碰 RX/错误标志——僵尸通道自愈由调用方按返回值处理。
|
* 不触碰 RX/错误标志——僵尸通道自愈由调用方按返回值处理。
|
||||||
* ⚠ USART1 未初始化(huart1.Instance == NULL,如 HAL_Init 早期或
|
* ⚠ 控制台串口未初始化(CON_UART.Instance == NULL,如 HAL_Init 早期或
|
||||||
* 断言/故障上报路径)时立即返回 HAL_ERROR:否则会解引用 NULL 去读写
|
* 断言/故障上报路径)时立即返回 HAL_ERROR:否则会解引用 NULL 去读写
|
||||||
* 地址 0(flash 别名区)→ 在故障处理里再触发故障(2026-10-02 台架实测)。
|
* 地址 0(flash 别名区)→ 在故障处理里再触发故障(2026-10-02 台架实测)。
|
||||||
*/
|
*/
|
||||||
@@ -178,45 +285,181 @@ static HAL_StatusTypeDef con_tx_bounded(const uint8_t *p_buf, uint16_t len) {
|
|||||||
uint16_t i;
|
uint16_t i;
|
||||||
|
|
||||||
con_dwt_ensure();
|
con_dwt_ensure();
|
||||||
if (huart1.Instance == NULL) {
|
if (CON_UART.Instance == NULL) {
|
||||||
return HAL_ERROR; /* USART1 未初始化:无可用通道,安全让位 */
|
return HAL_ERROR; /* 控制台串口未初始化:无可用通道,安全让位(不是超时,不记超时账) */
|
||||||
}
|
}
|
||||||
if (s_con_dwt_up == 0U) {
|
if (s_con_dwt_up == 0U) {
|
||||||
return con_tx_counted(huart1.Instance, p_buf, len);
|
return con_tx_counted(CON_UART.Instance, p_buf, len);
|
||||||
}
|
}
|
||||||
|
|
||||||
u = huart1.Instance;
|
u = CON_UART.Instance;
|
||||||
t0 = DWT->CYCCNT;
|
t0 = DWT->CYCCNT;
|
||||||
for (i = 0U; i < len; i++) {
|
for (i = 0U; i < len; i++) {
|
||||||
while ((u->SR & USART_SR_TXE) == 0U) {
|
while ((u->SR & USART_SR_TXE) == 0U) {
|
||||||
if ((uint32_t)(DWT->CYCCNT - t0) >= CON_TX_TIMEOUT_CYC) {
|
if ((uint32_t)(DWT->CYCCNT - t0) >= CON_TX_TIMEOUT_CYC) {
|
||||||
return HAL_TIMEOUT; /* 有界:本块放弃(调用方自愈) */
|
return HAL_TIMEOUT; /* 有界:本块放弃(记账由调用方按返回值做) */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
u->DR = (uint16_t)p_buf[i];
|
u->DR = (uint16_t)p_buf[i];
|
||||||
}
|
}
|
||||||
while ((u->SR & USART_SR_TC) == 0U) {
|
while ((u->SR & USART_SR_TC) == 0U) {
|
||||||
if ((uint32_t)(DWT->CYCCNT - t0) >= CON_TX_TIMEOUT_CYC) {
|
if ((uint32_t)(DWT->CYCCNT - t0) >= CON_TX_TIMEOUT_CYC) {
|
||||||
return HAL_TIMEOUT;
|
return HAL_TIMEOUT; /* TC 超时:字节已交给发送器,调用方分账 */
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return HAL_OK;
|
return HAL_OK;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:控制台裸发送(异常/早期路径专用:有界、单次、不加锁)
|
* 函数功能:尝试取得"控制台串口整行发送所有权"(**非阻塞**,道G task-23)
|
||||||
|
* 返回值:CON_TX_HELD 已持有(发完必须 con_tx_release)/ CON_TX_BYPASS 放行且不记账
|
||||||
|
* / CON_TX_NO 别人正持有整行,调用方须让路
|
||||||
|
* 限定条件:任意上下文(内部只有一次几条指令的临界区,绝不睡眠、绝不长期关中断)
|
||||||
|
* 函数说明:三条规则各有硬理由,改动前请连读 docs/子系统设计/控制台单写者日志设计说明.md
|
||||||
|
* 的「行级单一所有者」一节:
|
||||||
|
* ① IPSR != 0(异常/中断,含四个 Fault 的崩溃上报)-> **放行**:这些上下文不许阻塞,
|
||||||
|
* 且一发生就要复位,日志是唯一遗言,绝不允许在这里"让路"掉。已知残余(登记在同一
|
||||||
|
* 篇文档):ISR 可以打断某任务已发出半行的裸发,两侧仍可能交错——但 Fault 行以
|
||||||
|
* "[ERR]" 起头且整段在同一次 con_tx_bounded 内,标记校验按字节 grep 仍能命中,
|
||||||
|
* 不构成漏检;把钩子改成"一条行一次发完"是另一张卡的活(API 已备:tx_begin/tx_end)。
|
||||||
|
* ② 调度器未启动(xTaskGetCurrentTaskHandle() == NULL,osKernelStart 之前)-> **放行**:
|
||||||
|
* 此刻全系统只有一个执行流,本就没有第二个写者;早期启动日志一条都不该丢。
|
||||||
|
* ③ 已调度且空闲 -> 临界区里"看 + 认领"(几条指令),拿到即整行独占;已被持有 ->
|
||||||
|
* 立刻返回 CON_TX_NO,**不排队**:裸发一行 @115200 约 7.8 ms,等锁等于把自己的
|
||||||
|
* 日志节拍压到别人的串口耗时上,adcTask(AboveNormal,1 kHz 消费)可能被压住
|
||||||
|
* 数十拍,比交错更糟。
|
||||||
|
*/
|
||||||
|
static uint8_t con_tx_try_claim(void) {
|
||||||
|
TaskHandle_t me;
|
||||||
|
uint32_t pm;
|
||||||
|
uint8_t got = 0U;
|
||||||
|
|
||||||
|
if (__get_IPSR() != 0U) {
|
||||||
|
return (uint8_t)CON_TX_BYPASS;
|
||||||
|
}
|
||||||
|
me = xTaskGetCurrentTaskHandle();
|
||||||
|
if (me == NULL) {
|
||||||
|
return (uint8_t)CON_TX_BYPASS;
|
||||||
|
}
|
||||||
|
if (s_con_tx_owner == me) {
|
||||||
|
if (s_con_tx_depth < 255U) {
|
||||||
|
s_con_tx_depth++;
|
||||||
|
}
|
||||||
|
return (uint8_t)CON_TX_HELD;
|
||||||
|
}
|
||||||
|
CON_CRIT_ENTER(pm); /* 窗口选型与嵌套语义理由见 CON_CRIT_ENTER 定义处 */
|
||||||
|
if (s_con_tx_owner == NULL) {
|
||||||
|
s_con_tx_owner = me;
|
||||||
|
s_con_tx_depth = 1U;
|
||||||
|
got = 1U;
|
||||||
|
}
|
||||||
|
CON_CRIT_EXIT(pm);
|
||||||
|
|
||||||
|
return (got != 0U) ? (uint8_t)CON_TX_HELD : (uint8_t)CON_TX_NO;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:归还整行发送所有权(与 con_tx_try_claim 成对)
|
||||||
|
* 入口参数:held - con_tx_try_claim 的返回值(只有 CON_TX_HELD 才真正归还)
|
||||||
|
* 返回值:无
|
||||||
|
* 限定条件:与取锁同上下文;CON_TX_BYPASS / CON_TX_NO 一律空操作——放行的那些上下文
|
||||||
|
* 从未持有凭证,误清会把别人正持有的整行放开,等于把这次修复改没
|
||||||
|
* 函数说明:同任务嵌套按深度递减(写者一轮 drain 内还会再裸发 [WARN]/[ERR] 留痕行,
|
||||||
|
* 那是嵌套持有者,最外层释放时才清 owner)
|
||||||
|
*/
|
||||||
|
static void con_tx_release(uint8_t held) {
|
||||||
|
TaskHandle_t me;
|
||||||
|
uint32_t pm;
|
||||||
|
|
||||||
|
if (held != (uint8_t)CON_TX_HELD) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
me = xTaskGetCurrentTaskHandle();
|
||||||
|
if (s_con_tx_owner != me) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (s_con_tx_depth > 1U) {
|
||||||
|
s_con_tx_depth--;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CON_CRIT_ENTER(pm);
|
||||||
|
if (s_con_tx_owner == me) {
|
||||||
|
s_con_tx_owner = NULL;
|
||||||
|
s_con_tx_depth = 0U;
|
||||||
|
}
|
||||||
|
CON_CRIT_EXIT(pm);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把"一条行分几段裸发"的调用方收成一个整行独占者(道G task-23)
|
||||||
|
* 返回值:1 - 已独占(此后本任务每次 dbg_console_write_raw 都直接发,段间无人插入)
|
||||||
|
* 0 - 别人正持有;调用方**照发即可**(本函数不改变任何调用方的既有语义,
|
||||||
|
* 绝不阻塞、绝不吞字,只是段间仍可能被插——调用方自己权衡)
|
||||||
|
* 限定条件:任务上下文(ISR 内调用会返回 1 但不记账,见 con_tx_try_claim ①)
|
||||||
|
* 函数说明:给多段裸发的诊断/崩溃钩子用(Src/freertos.c 的钩子把一条行拆成
|
||||||
|
* head/name/":"/" (resetting)" 等多次直发,段与段之间就是插字节的位置)。
|
||||||
|
* 配对使用:tx_begin() -> 若干次 write_raw -> tx_end()。嵌套安全(按深度计)。
|
||||||
|
*/
|
||||||
|
uint8_t dbg_console_tx_begin(void) {
|
||||||
|
uint8_t held = con_tx_try_claim();
|
||||||
|
|
||||||
|
return (held == (uint8_t)CON_TX_NO) ? 0U : 1U;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:结束一次整行独占(与 dbg_console_tx_begin 成对)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返回值:无
|
||||||
|
* 限定条件:只在 dbg_console_tx_begin() 之后调用;未持有时为空操作(幂等)
|
||||||
|
*/
|
||||||
|
void dbg_console_tx_end(void) {
|
||||||
|
con_tx_release((uint8_t)CON_TX_HELD);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:控制台裸发送(异常/早期路径专用:有界、单次;**整行独占发送器**)
|
||||||
* 入口参数:buf - 文本缓冲;len - 字节数(超上限截断)
|
* 入口参数:buf - 文本缓冲;len - 字节数(超上限截断)
|
||||||
* 限定条件:串口已初始化;仅用于写者未注册、中断/异常上下文、崩溃上报
|
* 返回值:无
|
||||||
|
* 限定条件:串口已初始化;写者未注册、中断/异常上下文、崩溃上报、诊断转储
|
||||||
* 函数说明:走 con_tx_bounded(DWT 硬上界),tick 停摆时仍能在有界时间内返回。
|
* 函数说明:走 con_tx_bounded(DWT 硬上界),tick 停摆时仍能在有界时间内返回。
|
||||||
|
* 道G task-23 起本函数是裸发的**唯一漏斗**(所有裸发调用点都经过它,所以调用方
|
||||||
|
* 文件一个都不用改):
|
||||||
|
* · 拿到所有权 -> 整行一次发完,中途不会有别人的字节插进来;
|
||||||
|
* · 拿不到 -> **本行不丢**,改投既有环形缓冲延后发。竞争失败不该变成丢行,更不该
|
||||||
|
* 变成 [ERR]:台架那个未注册窗口每 3 次启动命中 2 次,按"丢弃 + [ERR]"处理只会
|
||||||
|
* 把静默缺陷换成常红的门禁([ERR] 是 /serial-boot-test 的 Forbidden 标记)。
|
||||||
|
* 入环的是**同一批字节原文**,本模块与 drain 都不补前缀、不重打时间戳(DBG_* 的
|
||||||
|
* 前缀在调用点就已 snprintf 进调用方缓冲)-> 延后发出去的行与它当场直发时
|
||||||
|
* 逐字节相同(判据⑦),取证时不会出现"证据说谎";
|
||||||
|
* · 环也放不下 -> 这才计入既有 s_con_drop(真溢出,仍然可见,不静默);
|
||||||
|
* · ISR / 调度器未启动 -> 放行直发(con_tx_try_claim ①②)。
|
||||||
*/
|
*/
|
||||||
void dbg_console_write_raw(const char *buf, unsigned len) {
|
void dbg_console_write_raw(const char *buf, unsigned len) {
|
||||||
|
uint8_t held;
|
||||||
|
|
||||||
if ((buf == NULL) || (len == 0U)) {
|
if ((buf == NULL) || (len == 0U)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (len > DBG_CON_CHUNK_MAX) {
|
if (len > DBG_CON_CHUNK_MAX) {
|
||||||
len = DBG_CON_CHUNK_MAX;
|
len = DBG_CON_CHUNK_MAX;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
held = con_tx_try_claim();
|
||||||
|
if (held == (uint8_t)CON_TX_NO) {
|
||||||
|
if (con_ring_try(buf, (uint16_t)len) != 0U) {
|
||||||
|
/* 延后发:写者空出来就排出去(未注册时也一样——注册后第一轮 drain 就发)。
|
||||||
|
* 唤醒事件只在事件组已建时发(内核前 sys_evtHandle 还是 NULL) */
|
||||||
|
if (sys_evtHandle != NULL) {
|
||||||
|
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_CONSOLE_TX);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
s_con_drop++; /* 让了路又放不下环 = 真丢,进既有那本账 */
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
(void)con_tx_bounded((const uint8_t *)buf, (uint16_t)len);
|
(void)con_tx_bounded((const uint8_t *)buf, (uint16_t)len);
|
||||||
|
con_tx_release(held);
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -242,20 +485,24 @@ uint8_t dbg_console_writer_ready(void) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:USART1 僵尸通道自愈(清错误锁存 + 整外设重初始化)
|
* 函数功能:控制台串口僵尸通道自愈(清错误锁存 + 整外设重初始化)
|
||||||
* 函数说明:RX 悬空噪声(PA10 未接线)锁存的 ORE/NE/FE/PE 使 HAL 轮询 TX 恒快速失败
|
* 函数说明:**RX 使能了却从不读 DR** → 线上任意一个字节或一个噪声沿使 RXNE 置位后无人读,
|
||||||
* (控制台静默、系统不复位);发送器卡死状态则使 TXE 等待耗满超时(冻结唯一
|
* 下一个事件即锁存 ORE(常伴 NE/FE/PE);ORE 只能靠"先读 SR 再读 DR"清除 ⇒ 永不自愈。
|
||||||
* 写者 → 看门狗误复位,2026-10-02 台架实证)。外设软复位清锁存与卡死状态后
|
* 锁存后 HAL 轮询 TX 恒快速失败(控制台静默、系统不复位);发送器卡死状态则使 TXE
|
||||||
* 重新初始化,故障时间被单块超时限定
|
* 等待耗满超时(冻结唯一写者 → 看门狗误复位,2026-10-02 台架实证)。外设软复位清锁存与卡死状态后
|
||||||
|
* 重新初始化,故障时间被单块超时限定。
|
||||||
|
* ⚠ 2026-10-28 更正:原注释把根因写成"PA10 未接线 / 悬空",用户确认 PA10 一直是接线的;
|
||||||
|
* 充分条件是"RX 使能却不消费 DR",与引脚是否悬空无关
|
||||||
|
* (详见 docs/子系统设计/控制台单写者日志设计说明.md §12)。
|
||||||
*/
|
*/
|
||||||
static void con_uart_reinit(void) {
|
static void con_uart_reinit(void) {
|
||||||
__NVIC_ClearPendingIRQ(USART1_IRQn);
|
__NVIC_ClearPendingIRQ(CON_UART_IRQN);
|
||||||
__HAL_RCC_USART1_CLK_DISABLE();
|
CON_UART_CLK_DISABLE();
|
||||||
__HAL_RCC_USART1_CLK_ENABLE();
|
CON_UART_CLK_ENABLE();
|
||||||
huart1.gState = HAL_UART_STATE_RESET;
|
CON_UART.gState = HAL_UART_STATE_RESET;
|
||||||
huart1.RxState = HAL_UART_STATE_RESET;
|
CON_UART.RxState = HAL_UART_STATE_RESET;
|
||||||
huart1.ErrorCode = HAL_UART_ERROR_NONE;
|
CON_UART.ErrorCode = HAL_UART_ERROR_NONE;
|
||||||
(void)MX_USART1_UART_Init();
|
(void)CON_UART_INIT();
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -268,6 +515,7 @@ static void con_uart_reinit(void) {
|
|||||||
void dbg_console_drain(void) {
|
void dbg_console_drain(void) {
|
||||||
uint16_t used;
|
uint16_t used;
|
||||||
uint32_t t0;
|
uint32_t t0;
|
||||||
|
uint8_t held;
|
||||||
|
|
||||||
if (s_con_writer_up == 0U) {
|
if (s_con_writer_up == 0U) {
|
||||||
return;
|
return;
|
||||||
@@ -276,18 +524,32 @@ void dbg_console_drain(void) {
|
|||||||
return; /* 重入保护:写者自记录不触发嵌套 drain */
|
return; /* 重入保护:写者自记录不触发嵌套 drain */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 整行所有权(道G task-23):本函数按块直写 CON_UART->DR,若此刻别的应用上下文
|
||||||
|
* 还在未注册期那条裸发里发到自己一半(attach 就发生在 defaultTask 里,与别的
|
||||||
|
* 任务裸发**可以**前后脚撞上),两边就会逐字节交错——那正是本卡要修的形态。
|
||||||
|
* 拿不到就**本轮让位**:tail 不推进 => 环里一条不少,主循环 ≤50 ms 后再试,
|
||||||
|
* 所以这条路径**永不丢弃**,也就不该在这里加任何计数(加了就是把让位谎报成丢行)。 */
|
||||||
|
held = con_tx_try_claim();
|
||||||
|
if (held == (uint8_t)CON_TX_NO) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
s_con_draining = 1U;
|
s_con_draining = 1U;
|
||||||
t0 = (uint32_t)HAL_GetTick();
|
t0 = (uint32_t)HAL_GetTick();
|
||||||
|
|
||||||
/* 故障冷却:僵尸通道(ORE 重锁)秒内不自愈,冷却期内不发送——
|
/* 故障冷却:僵尸通道(ORE 重锁)秒内不自愈,冷却期内不发送——
|
||||||
* 本轮 drain 直接让位主循环,保住喂狗节奏(gen-288 停喂误复位的
|
* 本轮 drain 直接让位主循环,保住喂狗节奏(日志代次 288 停喂误复位的
|
||||||
* 直接诱因 = 背靠连发的块超时链)。无符号差值跨 49.7 天回绕安全 */
|
* 直接诱因 = 背靠连发的块超时链)。是否在冷却看 s_con_cooling 独立标志,
|
||||||
if (s_con_cool_until != 0U) {
|
* **不再拿 tick 值当哨兵**(2026-10-24 实测:故障恰好落在 tick==0 那一拍时,旧写法
|
||||||
|
* 记出的 cool_until==0 等于没记冷却,下一轮立刻再耗一次 100ms 块超时)。
|
||||||
|
* 无符号差值跨 49.7 天回绕安全 */
|
||||||
|
if (s_con_cooling != 0U) {
|
||||||
if ((uint32_t)(t0 - s_con_cool_until) < DBG_CON_FAULT_COOL_MS) {
|
if ((uint32_t)(t0 - s_con_cool_until) < DBG_CON_FAULT_COOL_MS) {
|
||||||
s_con_draining = 0U;
|
s_con_draining = 0U;
|
||||||
|
con_tx_release(held); /* 让位也要归还凭证,否则别人永远进不来 */
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
s_con_cool_until = 0U;
|
s_con_cooling = 0U;
|
||||||
}
|
}
|
||||||
|
|
||||||
while ((used = con_used()) != 0U) {
|
while ((used = con_used()) != 0U) {
|
||||||
@@ -309,12 +571,28 @@ void dbg_console_drain(void) {
|
|||||||
taskEXIT_CRITICAL();
|
taskEXIT_CRITICAL();
|
||||||
|
|
||||||
if (st != HAL_OK) {
|
if (st != HAL_OK) {
|
||||||
|
if (st == HAL_ERROR) {
|
||||||
|
/* 2026-10-24 修复:控制台串口 **未初始化**(CON_UART.Instance == NULL,
|
||||||
|
* 只可能出现在 HAL_Init 早期或断言/故障上报路径)与"僵尸通道"是两回事:
|
||||||
|
* 这里**绝不做外设重初始化**——那会在 HAL 尚未就绪时跑 MspInit 重配
|
||||||
|
* RCC/GPIO;也不计 tx_fault(那本账的语义是"软复位自愈次数")。
|
||||||
|
* 本块字节确实一个都没上线 -> 计入 dropped(只记真丢的),
|
||||||
|
* 末尾的 [ERR] dropped 行负责留痕;本轮到此为止(失败即止)。 */
|
||||||
|
s_con_drop++;
|
||||||
|
break;
|
||||||
|
}
|
||||||
/* 僵尸通道:软复位自愈 + 进入发送冷却(ORE 毫秒级重锁,
|
/* 僵尸通道:软复位自愈 + 进入发送冷却(ORE 毫秒级重锁,
|
||||||
* 继续背靠连发只会把主循环耗在块超时链上);本块丢弃
|
* 继续背靠连发只会把主循环耗在块超时链上);本块丢弃
|
||||||
* (行已出环,不可重放) */
|
* (行已出环,不可重放),本块在超时账与丢弃账上各记一笔 */
|
||||||
|
s_con_tx_timeout++; /* 新增账:块超时次数(TXE 与 TC 两条硬上界
|
||||||
|
* 都算,口径 = 写者排空时观察到的块超时) */
|
||||||
con_uart_reinit();
|
con_uart_reinit();
|
||||||
s_con_tx_fault++;
|
s_con_tx_fault++;
|
||||||
|
s_con_drop++; /* 确认不了的块算真丢(一次丢弃事件),
|
||||||
|
* 旧写法只涨 tx_fault -> 账上看不出日志在丢,
|
||||||
|
* docs §13-O2 记的正是这条 */
|
||||||
s_con_cool_until = (uint32_t)HAL_GetTick();
|
s_con_cool_until = (uint32_t)HAL_GetTick();
|
||||||
|
s_con_cooling = 1U;
|
||||||
if (s_con_tx_fault == 1U) {
|
if (s_con_tx_fault == 1U) {
|
||||||
static const char note[] = "\r\n[WARN] CONSOLE tx fault: UART reinit (self-healed)\r\n";
|
static const char note[] = "\r\n[WARN] CONSOLE tx fault: UART reinit (self-healed)\r\n";
|
||||||
|
|
||||||
@@ -338,31 +616,96 @@ void dbg_console_drain(void) {
|
|||||||
|
|
||||||
s_con_draining = 0U;
|
s_con_draining = 0U;
|
||||||
|
|
||||||
/* 丢弃可观测性:缓冲满丢行必须显式可见(否则会掩盖开机自检/测试标记的丢失) */
|
/* 丢弃可观测性:缓冲满丢行必须显式可见(否则会掩盖开机自检/测试标记的丢失)。
|
||||||
|
* 账上有两种成因,都算"真没落地":① 环满等不到空位(con_append);② 抢不到整行
|
||||||
|
* 所有权**且**环也放不下(dbg_console_write_raw,道G task-23)。正常让路不入这本账,
|
||||||
|
* 所以 [ERR] 行至今仍是"控制台在丢东西"的唯一信号,没被稀释。 */
|
||||||
if (s_con_drop != 0U) {
|
if (s_con_drop != 0U) {
|
||||||
char note[64];
|
char note[64];
|
||||||
uint32_t d = s_con_drop;
|
uint32_t d = s_con_drop;
|
||||||
int n;
|
int n;
|
||||||
|
|
||||||
s_con_drop = 0U;
|
|
||||||
n = snprintf(note, sizeof(note), "\r\n[ERR] CONSOLE dropped %lu lines\r\n",
|
n = snprintf(note, sizeof(note), "\r\n[ERR] CONSOLE dropped %lu lines\r\n",
|
||||||
(unsigned long)d);
|
(unsigned long)d);
|
||||||
if (n > 0) {
|
if (n > 0) {
|
||||||
if (n > (int)sizeof(note) - 1) {
|
if (n > (int)sizeof(note) - 1) {
|
||||||
n = (int)sizeof(note) - 1;
|
n = (int)sizeof(note) - 1;
|
||||||
}
|
}
|
||||||
dbg_console_write_raw(note, (unsigned)n);
|
/* 只有**真发出去了才销账**(2026-10-24 一并修正):旧写法先把计数清零
|
||||||
|
* 再裸发,通道不可用/僵尸时那一发也失败 -> 丢弃证据被一起冲掉,[ERR] 行
|
||||||
|
* 永远出不来("永不静默"变成"永不显示")。留到下一轮或串口恢复后补发。 */
|
||||||
|
/* 所有权仍在手里(本凭证覆盖整轮,含这条留痕行) */
|
||||||
|
if (con_tx_bounded((const uint8_t *)note, (uint16_t)n) == HAL_OK) {
|
||||||
|
/* 按"本次报出去的这条"扣账而不是清零:期间生产者新丢的行留下轮报。
|
||||||
|
* 递减只发生在这一处(单写者 + s_con_draining 重入保护),生产者只增,
|
||||||
|
* 故 s_con_drop >= d 恒成立,无需再写下溢分支(写了也永远测不到)。 */
|
||||||
|
s_con_drop -= d;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
con_tx_release(held);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:尝试把一整行原文放进环形缓冲(**只试一次,绝不等待**)
|
||||||
|
* 入口参数:p_buf - 整行文本;len - 字节数(调用方已钳位)
|
||||||
|
* 返回值:1 - 已入环(head 已推进);0 - 空位不够,什么都没写
|
||||||
|
* 限定条件:任务上下文(内部是一次几条指令至多拷满 192B 的临界区,不睡眠)
|
||||||
|
* 函数说明:两个调用方共用同一份判据,避免长出"两套入环语义":
|
||||||
|
* ① con_append 的常规路径(外面套有界等待,等写者腾位);
|
||||||
|
* ② dbg_console_write_raw 抢不到整行所有权时的让路路径(**不套等待**:延后发一条行
|
||||||
|
* 可以,把调用者压住 200ms 就是另一场事故)。
|
||||||
|
* 复核空位 + 整行拷贝 + 推进 head **必须在同一个临界区内**(2026-10-24 修复):旧写法
|
||||||
|
* 先出区判 free_space >= len 再进区写,两个生产者可在环 >95% 满时同时过判据,head
|
||||||
|
* 合计越过容量 -> con_used() 回绕变小 -> 后写的行覆盖尚未发出的旧行,且既不报错也不
|
||||||
|
* 计丢弃(静默损坏)。等待(vTaskDelay/DWT 判据)刻意留在区外。
|
||||||
|
* ⚠ 入环的是调用方给的**字节原文**:本函数与 drain 都不补前缀、不重打时间戳
|
||||||
|
* (DBG_* 的前缀在调用点就已 snprintf 进调用方缓冲)——延后发与当场直发逐字节
|
||||||
|
* 相同,这是 task-23 判据⑦的结构保证。
|
||||||
|
*/
|
||||||
|
static uint8_t con_ring_try(const char *p_buf, uint16_t len)
|
||||||
|
{
|
||||||
|
uint32_t pm;
|
||||||
|
uint8_t fitted = 0U;
|
||||||
|
|
||||||
|
/* 窗口用 PRIMASK 而不是 taskENTER_CRITICAL():后者会经 vPortExitCritical 里的
|
||||||
|
* configASSERT 接回断言钩子,再回到本模块,静态调用图上长出一条环,armlink 把它
|
||||||
|
* 整条记进**每一条含临界区的任务链**(实测三条链同涨 80 B)。理由全文见
|
||||||
|
* CON_CRIT_ENTER 定义处。屏蔽时长与换之前一致(就是这一次整行拷贝)。 */
|
||||||
|
CON_CRIT_ENTER(pm);
|
||||||
|
if ((uint16_t)((DBG_CON_RING_SIZE - 1U) - con_used()) >= len) {
|
||||||
|
uint16_t head = s_con_head;
|
||||||
|
uint16_t first = (uint16_t)(DBG_CON_RING_SIZE - head);
|
||||||
|
uint16_t i;
|
||||||
|
|
||||||
|
if (first > len) {
|
||||||
|
first = len;
|
||||||
|
}
|
||||||
|
for (i = 0U; i < first; i++) {
|
||||||
|
s_con_ring[head + i] = (uint8_t)p_buf[i];
|
||||||
|
}
|
||||||
|
for (i = first; i < len; i++) {
|
||||||
|
s_con_ring[(uint16_t)((uint16_t)(head + i) & (DBG_CON_RING_SIZE - 1U))] =
|
||||||
|
(uint8_t)p_buf[i];
|
||||||
|
}
|
||||||
|
s_con_head = (uint16_t)((uint16_t)(head + len) & (DBG_CON_RING_SIZE - 1U));
|
||||||
|
fitted = 1U;
|
||||||
|
}
|
||||||
|
CON_CRIT_EXIT(pm);
|
||||||
|
|
||||||
|
return fitted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:把一段文本放入控制台(写者已注册)/直接发出(未注册或中断上下文)
|
* 函数功能:把一段文本放入控制台(写者已注册)/直接发出(未注册或中断上下文)
|
||||||
* 返回值:0 - 已入缓冲或已直发;-1 - 缓冲满丢弃(计入 dbg_console_dropped)
|
* 返回值:0 - 已入缓冲或已直发;-1 - 缓冲满丢弃(计入 dbg_console_dropped)
|
||||||
* 函数说明:生产者不持锁等待过久:最多 DBG_CON_WAIT_MS,仍满则丢弃该行;
|
* 函数说明:生产者不持锁等待过久:最多 DBG_CON_WAIT_MS,仍满则丢弃该行;
|
||||||
|
* "复核空位 -> 整行拷贝 -> 推进 head"是一个原子的临界区动作;
|
||||||
|
* 环满等待(vTaskDelay / DWT 判据)一律在临界区之外,区内绝不睡眠。
|
||||||
* 写者自身记录日志时先排空缓冲,避免自等。
|
* 写者自身记录日志时先排空缓冲,避免自等。
|
||||||
* 等待上界优先用 DWT 真实时间(CON_APPEND_WAIT_CYC,时间源无关)——
|
* 等待上界优先用 DWT 真实时间(CON_APPEND_WAIT_CYC,时间源无关)——
|
||||||
* tick 停摆时 vTaskDelay 本身也会停,故不能用它作唯一判据(round 8b)。
|
* tick 停摆时 vTaskDelay 本身也会停,故不能用它作唯一判据(第 8b 轮加固)。
|
||||||
*/
|
*/
|
||||||
static int con_append(const char *buf, uint16_t len) {
|
static int con_append(const char *buf, uint16_t len) {
|
||||||
uint32_t waited = 0U;
|
uint32_t waited = 0U;
|
||||||
@@ -392,10 +735,9 @@ static int con_append(const char *buf, uint16_t len) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (;;) {
|
for (;;) {
|
||||||
uint16_t used = con_used();
|
/* 判据与拷贝都在 con_ring_try 的那一个临界区里完成(函数头有为什么);
|
||||||
uint16_t free_space = (uint16_t)((DBG_CON_RING_SIZE - 1U) - used);
|
* 本函数只负责"等多久"——等待一律在区外,区内绝不睡眠 */
|
||||||
|
if (con_ring_try(buf, len) != 0U) {
|
||||||
if (free_space >= len) {
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (have_dwt != 0U) {
|
if (have_dwt != 0U) {
|
||||||
@@ -411,26 +753,6 @@ static int con_append(const char *buf, uint16_t len) {
|
|||||||
waited++;
|
waited++;
|
||||||
}
|
}
|
||||||
|
|
||||||
taskENTER_CRITICAL();
|
|
||||||
{
|
|
||||||
uint16_t head = s_con_head;
|
|
||||||
uint16_t first = (uint16_t)(DBG_CON_RING_SIZE - head);
|
|
||||||
uint16_t i;
|
|
||||||
|
|
||||||
if (first > len) {
|
|
||||||
first = len;
|
|
||||||
}
|
|
||||||
for (i = 0U; i < first; i++) {
|
|
||||||
s_con_ring[head + i] = (uint8_t)buf[i];
|
|
||||||
}
|
|
||||||
for (i = first; i < len; i++) {
|
|
||||||
s_con_ring[(uint16_t)((uint16_t)(head + i) & (DBG_CON_RING_SIZE - 1U))] =
|
|
||||||
(uint8_t)buf[i];
|
|
||||||
}
|
|
||||||
s_con_head = (uint16_t)((uint16_t)(head + len) & (DBG_CON_RING_SIZE - 1U));
|
|
||||||
}
|
|
||||||
taskEXIT_CRITICAL();
|
|
||||||
|
|
||||||
if (sys_evtHandle != NULL) {
|
if (sys_evtHandle != NULL) {
|
||||||
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_CONSOLE_TX); /* 唤醒写者 */
|
(void)osEventFlagsSet(sys_evtHandle, SYS_EVT_CONSOLE_TX); /* 唤醒写者 */
|
||||||
}
|
}
|
||||||
@@ -472,6 +794,23 @@ uint32_t dbg_console_tx_fault(void) {
|
|||||||
return s_con_tx_fault;
|
return s_con_tx_fault;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:已发生的发送块超时次数(诊断用)
|
||||||
|
* 返回值:块超时累计次数 uint32_t
|
||||||
|
* 限定条件:无(任意上下文可读,含 ISR)
|
||||||
|
* 函数说明:三本账分开(docs/子系统设计/控制台单写者日志设计说明.md §13-O2)——
|
||||||
|
* dbg_console_dropped() = **真没落地**的丢弃数(缓冲满丢的一行,或一个
|
||||||
|
* 没能确认送达而出环的块;[ERR] 行报的就是它);
|
||||||
|
* dbg_console_tx_fault() = 外设软复位**自愈次数**(语义不变);
|
||||||
|
* dbg_console_tx_timeout() = 本账:写者排空时观察到的**块超时**次数
|
||||||
|
* (TXE 与 TC 两条硬上界都算)。
|
||||||
|
* 裸发路径(内核前 / ISR / 两条告警行自身)的超时不记本账——那些上下文里
|
||||||
|
* 没有"块"的概念,也只可能发生在启动极早期,混进来只会让运维读数失去意义。
|
||||||
|
* UART 物理失效但字节已发出的情形只涨本账,不污染另两本。 */
|
||||||
|
uint32_t dbg_console_tx_timeout(void) {
|
||||||
|
return s_con_tx_timeout;
|
||||||
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:取走"控制台 TX 故障待上报"标志(读后清)
|
* 函数功能:取走"控制台 TX 故障待上报"标志(读后清)
|
||||||
* 返回值:1 - 需要补记一条 run.log 证据,0 - 无
|
* 返回值:1 - 需要补记一条 run.log 证据,0 - 无
|
||||||
|
|||||||
+22
-4
@@ -1,5 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* 模块名称:Console Single-Writer Logging — 控制台单写者日志(USART1 调试串口)
|
* 模块名称:Console Single-Writer Logging — 控制台单写者日志(RS232#1 / UART5)
|
||||||
* 模块功能:调试控制台单写者环形缓冲的公共 API:所有任务/上下文(含 printf)只把
|
* 模块功能:调试控制台单写者环形缓冲的公共 API:所有任务/上下文(含 printf)只把
|
||||||
* 已格式化整行文本入环形缓冲,写者任务(defaultTask)为唯一
|
* 已格式化整行文本入环形缓冲,写者任务(defaultTask)为唯一
|
||||||
* HAL_UART_Transmit 发送点(行级原子 + 指针可控)
|
* HAL_UART_Transmit 发送点(行级原子 + 指针可控)
|
||||||
@@ -15,6 +15,9 @@
|
|||||||
* 修改记录:
|
* 修改记录:
|
||||||
* 2026-09-24 王建锋 创建(自 Src/usart.c USER CODE 区迁出:
|
* 2026-09-24 王建锋 创建(自 Src/usart.c USER CODE 区迁出:
|
||||||
* HAL 库生成文件不得包含应用代码)
|
* HAL 库生成文件不得包含应用代码)
|
||||||
|
* 2026-10-26 王建锋 道G task-23:裸发路径加「行级单一所有者」(O1 第二实例),
|
||||||
|
* 新增 dbg_console_tx_begin()/dbg_console_tx_end() 供"一条行分多段裸发"
|
||||||
|
* 的调用方收口;dbg_console_write_raw() 的"不加锁"三个字从接口上取消
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#ifndef CONSOLE_LOG_H
|
#ifndef CONSOLE_LOG_H
|
||||||
@@ -32,15 +35,27 @@ extern "C" {
|
|||||||
* dbg_console_write_line - 生产者入口:调用方已格式化好的整行文本一次入环形缓冲
|
* dbg_console_write_line - 生产者入口:调用方已格式化好的整行文本一次入环形缓冲
|
||||||
* (刻意**不用**自研可变参数函数——规范 S12 7.6 禁止;格式化在
|
* (刻意**不用**自研可变参数函数——规范 S12 7.6 禁止;格式化在
|
||||||
* DBG_PRINT 宏内用库 snprintf 完成)
|
* DBG_PRINT 宏内用库 snprintf 完成)
|
||||||
* dbg_console_write_raw - 异常/早期路径:有界单次直发(不加锁)
|
* dbg_console_write_raw - 异常/早期路径:有界单次直发;**整行独占发送器**
|
||||||
|
* (道G task-23:拿不到独占就把整行原文转入环形缓冲延后发,
|
||||||
|
* 与当场直发逐字节相同——不补前缀、不重打时间戳;
|
||||||
|
* 连环都放不下才计入 dbg_console_dropped())
|
||||||
* dbg_console_writer_attach- 写者任务(defaultTask)注册自己为唯一发送者
|
* dbg_console_writer_attach- 写者任务(defaultTask)注册自己为唯一发送者
|
||||||
* dbg_console_writer_ready - 写者是否已注册(1=已注册)。供**周期性任务**在写者就绪前
|
* dbg_console_writer_ready - 写者是否已注册(1=已注册)。供**周期性任务**在写者就绪前
|
||||||
* 抑制日志用:写者未注册时 con_append 走无锁直发路径
|
* 抑制日志用:写者未注册时 con_append 走无锁直发路径
|
||||||
* (仅假设 defaultTask 在日志),其余任务此时落日志会与
|
* (仅假设 defaultTask 在日志),其余任务此时落日志会与
|
||||||
* defaultTask 的存储初始化日志字节级交错(2026-10-10 台架实测)
|
* defaultTask 的存储初始化日志字节级交错(2026-10-10 台架实测)
|
||||||
* dbg_console_drain - 写者任务排空缓冲并发送(单次有界;TX 失败自动软复位自愈)
|
* dbg_console_drain - 写者任务排空缓冲并发送(单次有界;TX 失败自动软复位自愈)
|
||||||
* dbg_console_dropped - 因缓冲满丢弃的行数(诊断)
|
* dbg_console_dropped - 真没落地的丢弃数:① 缓冲满丢的一行;② 一个没能确认
|
||||||
* dbg_console_tx_fault - 因僵尸通道软复位自愈的次数(诊断)
|
* 送达而出环的块;③ 抢不到整行所有权**且**环也放不下的裸发
|
||||||
|
* (道G task-23)。诊断;[ERR] CONSOLE dropped 行报的就是它。
|
||||||
|
* ⚠ 抢不到所有权但成功让路入环的那条行**不入本账**(它没丢,
|
||||||
|
* 只是晚一点发)——这本账必须保持"红得有意义",因为
|
||||||
|
* [ERR] 是 /serial-boot-test 的 Forbidden 标记)
|
||||||
|
* dbg_console_tx_fault - 因僵尸通道软复位自愈的次数(诊断,语义不变)
|
||||||
|
* dbg_console_tx_timeout - 写者排空时观察到的块超时次数(TXE / TC 两条硬上界都算)。
|
||||||
|
* 三本账分开:看日志有没有在丢 -> dropped;看外设自愈了几次
|
||||||
|
* -> tx_fault;看 UART 物理失效(含"字节已发出但 TC 不置位")
|
||||||
|
* -> tx_timeout。别把三本账混成一个数(docs §13-O2 )
|
||||||
* dbg_console_tx_fault_log_take
|
* dbg_console_tx_fault_log_take
|
||||||
* - 取走"TX 故障待上报"标志(读后清)。**必须**由写者任务在
|
* - 取走"TX 故障待上报"标志(读后清)。**必须**由写者任务在
|
||||||
* dbg_console_drain() 之外调用后自行 syslog_post——本模块
|
* dbg_console_drain() 之外调用后自行 syslog_post——本模块
|
||||||
@@ -50,11 +65,14 @@ extern "C" {
|
|||||||
#define DBG_CON_LINE_MAX 192U /* 单行文本上限(含结尾 '\0',超出截断) */
|
#define DBG_CON_LINE_MAX 192U /* 单行文本上限(含结尾 '\0',超出截断) */
|
||||||
int dbg_console_write_line(const char *line, unsigned len);
|
int dbg_console_write_line(const char *line, unsigned len);
|
||||||
void dbg_console_write_raw(const char *buf, unsigned len);
|
void dbg_console_write_raw(const char *buf, unsigned len);
|
||||||
|
uint8_t dbg_console_tx_begin(void); /* 1=已独占一条行;0=别人持有(调用方照发即可) */
|
||||||
|
void dbg_console_tx_end(void); /* 与 tx_begin 成对;未持有时空操作(幂等) */
|
||||||
void dbg_console_writer_attach(void);
|
void dbg_console_writer_attach(void);
|
||||||
uint8_t dbg_console_writer_ready(void);
|
uint8_t dbg_console_writer_ready(void);
|
||||||
void dbg_console_drain(void);
|
void dbg_console_drain(void);
|
||||||
uint32_t dbg_console_dropped(void);
|
uint32_t dbg_console_dropped(void);
|
||||||
uint32_t dbg_console_tx_fault(void);
|
uint32_t dbg_console_tx_fault(void);
|
||||||
|
uint32_t dbg_console_tx_timeout(void);
|
||||||
uint8_t dbg_console_tx_fault_log_take(void);
|
uint8_t dbg_console_tx_fault_log_take(void);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
+9
-3
@@ -2,9 +2,9 @@
|
|||||||
* 模块名称:SOE Record — 事件顺序记录编解码(纯逻辑,双端可测)
|
* 模块名称:SOE Record — 事件顺序记录编解码(纯逻辑,双端可测)
|
||||||
* 模块功能:定长 64B SOE(Sequence-of-Events)记录的构建与解析:
|
* 模块功能:定长 64B SOE(Sequence-of-Events)记录的构建与解析:
|
||||||
* CP56Time2a 时标 + 记录类型 + 元件号 + 单调序号 + 值 + 校验和。
|
* CP56Time2a 时标 + 记录类型 + 元件号 + 单调序号 + 值 + 校验和。
|
||||||
* **零 I/O、零 OS 依赖**(仅 stdint/string)→ 主机侧 L0.5 离线单测
|
* **零 I/O、零 OS 依赖**(仅 stdint/string)→ 主机侧纯逻辑离线单测
|
||||||
* (落实登记簿 A-4 约束 1"协议/记录与硬件 I/O 分离")。
|
* (分层约束:协议/记录与硬件 I/O 分离)。
|
||||||
* 记录口径(docs/算法/继电保护功能复现规划.md §3.4 L4-4/L4-5,B9):
|
* 记录口径(docs/算法/继电保护功能复现规划.md §3.4「SOE 记录」「动作记录」,B9):
|
||||||
* 落盘文件 = /log/soe.log(8.3 合规;FF_USE_LFN=0 约束),定长 64B 一条
|
* 落盘文件 = /log/soe.log(8.3 合规;FF_USE_LFN=0 约束),定长 64B 一条
|
||||||
* (照 run.log 先例:count = filesize/64 O(1)、容量满删除重建)。
|
* (照 run.log 先例:count = filesize/64 O(1)、容量满删除重建)。
|
||||||
* 独立于 run.log 的理由:run.log 是文本记录(时间戳+epoch+UTF-8 描述,
|
* 独立于 run.log 的理由:run.log 是文本记录(时间戳+epoch+UTF-8 描述,
|
||||||
@@ -61,6 +61,8 @@ extern "C" {
|
|||||||
* 4 软压板投入 value = 1
|
* 4 软压板投入 value = 1
|
||||||
* 5 采样闭锁 value = adc_health 闭锁位图(触发时的 hf & mask)
|
* 5 采样闭锁 value = adc_health 闭锁位图(触发时的 hf & mask)
|
||||||
* 6 采样恢复 value = 0
|
* 6 采样恢复 value = 0
|
||||||
|
* 7 标定闭锁 value = UA/UN/IA VALID 未齐的通道位图(未标定即闭锁,2026-10-25)
|
||||||
|
* 8 标定复归 value = 0
|
||||||
*/
|
*/
|
||||||
typedef enum {
|
typedef enum {
|
||||||
SOE_T_ELM_ASSERT = 1u,
|
SOE_T_ELM_ASSERT = 1u,
|
||||||
@@ -69,6 +71,10 @@ typedef enum {
|
|||||||
SOE_T_PLATE_IN,
|
SOE_T_PLATE_IN,
|
||||||
SOE_T_SAMPLE_BLOCK,
|
SOE_T_SAMPLE_BLOCK,
|
||||||
SOE_T_SAMPLE_RESUME,
|
SOE_T_SAMPLE_RESUME,
|
||||||
|
/* 未标定闭锁(「数据可信」未标定即闭锁一项,2026-10-25 道A 追加):UA/UN/IA 的 VALID
|
||||||
|
* 未全置位 → 判据停走;与采样闭锁分型,边沿各留一条记录 */
|
||||||
|
SOE_T_CAL_BLOCK,
|
||||||
|
SOE_T_CAL_RESUME,
|
||||||
SOE_T_COUNT /* 哨兵值,非有效类型 */
|
SOE_T_COUNT /* 哨兵值,非有效类型 */
|
||||||
} soe_type_t;
|
} soe_type_t;
|
||||||
|
|
||||||
|
|||||||
+183
-5
@@ -3,6 +3,16 @@
|
|||||||
* 模块功能:实现 soe_store.h——protectTask 投递(取时标 + soe_rec_build +
|
* 模块功能:实现 soe_store.h——protectTask 投递(取时标 + soe_rec_build +
|
||||||
* SPSC 待发环 / 消息队列,零 FS);defaultTask(syslog_drain 尾部)
|
* SPSC 待发环 / 消息队列,零 FS);defaultTask(syslog_drain 尾部)
|
||||||
* 排空落 /log/soe.log(64B 定长,容量满删除重建)。
|
* 排空落 /log/soe.log(64B 定长,容量满删除重建)。
|
||||||
|
* 修改记录:
|
||||||
|
* v1.0 2026-10-12 王建锋 创建初始版本(B9,设计见 docs/算法/继电保护功能复现规划.md §3.4)
|
||||||
|
* v1.1 2026-10-24 王建锋 仅注释/文档更正(D 项,无行为改动):s_soe_append
|
||||||
|
* 函数头原写"打开/写入结构性错误 → unlink 重试一次
|
||||||
|
* (同 run.log)",与实现不符且方向错误——实现里
|
||||||
|
* f_open 失败直接返回 -1、不 unlink,这是**有意**的:
|
||||||
|
* SOE 是事件序列,打开失败就删文件等于销毁已发生的
|
||||||
|
* 事件。改为如实描述"仅写失败才重试一次",并补上
|
||||||
|
* 与 sys_log_mcu.c s_is_selfheal_code 同源的逐码表
|
||||||
|
* (名字/参数类错误码两侧都绝不 unlink)
|
||||||
* 创建日期:2026-10-12(B9)
|
* 创建日期:2026-10-12(B9)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -38,6 +48,16 @@ static osMessageQueueId_t s_queue; /* 运行期队列(drain 首次调
|
|||||||
static const osMessageQueueAttr_t s_queue_attr = { .name = "soe" };
|
static const osMessageQueueAttr_t s_queue_attr = { .name = "soe" };
|
||||||
|
|
||||||
static volatile uint32_t s_seq; /* 单调序号(RAM 生命周期内;仅复位重置) */
|
static volatile uint32_t s_seq; /* 单调序号(RAM 生命周期内;仅复位重置) */
|
||||||
|
|
||||||
|
/* 只读遍历(UI 记录页)用的读盘窗口,与 App/log/sys_log_mcu.c 读侧同构:
|
||||||
|
* 定长记录按 8 条一窗整块取回(8 × 64 B = 512 B),命中窗口零 I/O;
|
||||||
|
* 代数由写者在卷锁区间内自增,读者也在卷锁区间内采样代数 → 窗口不会跨写失效 */
|
||||||
|
#define SOE_RD_WINDOW 8u
|
||||||
|
static uint8_t s_rd_win[SOE_RD_WINDOW][SOE_REC_SIZE];
|
||||||
|
static uint32_t s_rd_win_base;
|
||||||
|
static uint32_t s_rd_win_cnt;
|
||||||
|
static volatile uint32_t s_rd_gen; /* 写者落盘/重建次数(对齐 32 位读写在 ARM 上原子) */
|
||||||
|
static uint32_t s_rd_win_gen;
|
||||||
static volatile uint32_t s_drop_cnt; /* 丢弃计数(队列满/环满) */
|
static volatile uint32_t s_drop_cnt; /* 丢弃计数(队列满/环满) */
|
||||||
static uint32_t s_drop_logged; /* 丢弃留痕水位(drain 侧差值上报) */
|
static uint32_t s_drop_logged; /* 丢弃留痕水位(drain 侧差值上报) */
|
||||||
static uint8_t s_fault_reported; /* 写故障留痕去重(成功写复位) */
|
static uint8_t s_fault_reported; /* 写故障留痕去重(成功写复位) */
|
||||||
@@ -88,6 +108,30 @@ void soe_store_post(uint8_t type, uint8_t elm_id, uint32_t value) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* ── 自愈触发集逐码依据(与 sys_log_mcu.c 的同名资料块同源;2026-10-24 逐条核对
|
||||||
|
* Lib/FatFs/ff.c)─────────────────────────────────────────────────────────
|
||||||
|
* 进触发集(run.log 侧允许 unlink 重建一次):介质/结构类 FR_DISK_ERR(1) /
|
||||||
|
* FR_INT_ERR(2) / FR_NO_FILESYSTEM(13) / FR_DENIED(7)。本模块**只在写失败路径**
|
||||||
|
* 上碰自己的文件——打开失败连这些码也不 unlink。
|
||||||
|
* 不算自愈:FR_NO_FILE(4)——带 FA_OPEN_APPEND 打开时文件会被直接建出来;返回本
|
||||||
|
* 码的唯一现实场景是同名对象是目录(ff.c:3854),unlink 修不好它。
|
||||||
|
* 两侧都绝不 unlink:名字/参数类 FR_INVALID_NAME(6) / FR_NO_PATH(5) /
|
||||||
|
* FR_INVALID_OBJECT(9),以及 FR_NOT_READY(3) / FR_NOT_ENABLED(12) /
|
||||||
|
* FR_TIMEOUT(15)——同名同因,删了也修不好,反而每轮丢一份历史(§10.1 第 10 条
|
||||||
|
* 严禁的正是这种归集)。
|
||||||
|
* 写失败路径不接这套判定:f_write 的返回码里不存在名字/参数类(能走到那里说明
|
||||||
|
* 已经打开成功),而簇分配失败是 FR_OK + 短写、根本没有错误码可判,只能无条件
|
||||||
|
* 重试一次。
|
||||||
|
* 为什么 SOE 比 run.log 保守(两侧资料块各写一份、互指,改动必须同步):run.log
|
||||||
|
* 是滚动运行日志,坏了重建损失有限;SOE 是**事件序列**,文件里是一条条已经发生
|
||||||
|
* 过的保护事件,"打开失败就删整个历史文件"等于**销毁事件证据**——SOE 的存在就
|
||||||
|
* 是为了留这些事件,删了比写不进去更糟。正确出路是等 syslog_mcu_mounted() 每 2s
|
||||||
|
* 重试补目录 / 等卷恢复,条目留在待发环与队列里,不丢。
|
||||||
|
* (run.log 侧的同一张表 + 同一理由见 App/log/sys_log_mcu.c 的同名资料块,测试
|
||||||
|
* 侧两侧对齐由 TC-SYSLOG-101/102 与 TC-SOESTORE-022/022b 分别钉住。)
|
||||||
|
*/
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:追加一条 64B SOE 记录到 /log/soe.log
|
* 函数功能:追加一条 64B SOE 记录到 /log/soe.log
|
||||||
* 入口参数:rec - 完整记录(SOE_REC_SIZE 字节) const uint8_t *
|
* 入口参数:rec - 完整记录(SOE_REC_SIZE 字节) const uint8_t *
|
||||||
@@ -98,11 +142,20 @@ void soe_store_post(uint8_t type, uint8_t elm_id, uint32_t value) {
|
|||||||
* ② FA_WRITE|FA_OPEN_APPEND(本移植 OPEN_APPEND 不含 WRITE,
|
* ② FA_WRITE|FA_OPEN_APPEND(本移植 OPEN_APPEND 不含 WRITE,
|
||||||
* 缺写位 f_write 恒 FR_DENIED,2026-09-20 实证);
|
* 缺写位 f_write 恒 FR_DENIED,2026-09-20 实证);
|
||||||
* ③ 判失败必须校验写回字节数(FR_OK+短写 = 失败,簇分配无错误码);
|
* ③ 判失败必须校验写回字节数(FR_OK+短写 = 失败,簇分配无错误码);
|
||||||
* ④ 容量满 unlink 重建;打开/写入结构性错误 → unlink 重试一次
|
* ④ 容量满 unlink 重建;**仅写入失败(含 FR_OK+短写)才 unlink 重试
|
||||||
* (L1 文件级自愈,同 run.log)。
|
* 一次**——f_open 失败一律直接返回 -1、不 unlink。
|
||||||
|
* (2026-10-24 定案:本条旧注释写的是"打开/写入结构性错误都 unlink
|
||||||
|
* 重试一次(同 run.log)",与实际实现不符,也与本模块的语义相反:
|
||||||
|
* run.log 是**滚动运行日志**,坏了重建损失有限;SOE 是**事件序列**,
|
||||||
|
* 文件里是已经发生的事件,打开失败就把整个历史文件删掉等于销毁
|
||||||
|
* 证据,方向是错的。正确出路是让 syslog_mcu_mounted() 每 2s 重试
|
||||||
|
* 补目录/等卷恢复,条目留在待发环与队列里,不丢。)
|
||||||
|
* ⑤ 自愈触发集与 sys_log_mcu.c 的 s_is_selfheal_code() 同源,但本模块取
|
||||||
|
* 更保守的子集:打开失败**任何错误码都不 unlink**,只有写失败才重试
|
||||||
|
* 一次。逐码依据与两侧差异见上方同名资料块。
|
||||||
* ⚠ 本模块**没有**任何"卷损坏"归类/重格式化路径:unlink 失败、
|
* ⚠ 本模块**没有**任何"卷损坏"归类/重格式化路径:unlink 失败、
|
||||||
* 名字/参数类错误码一律只留痕(存储架构 §10.1 教训),条目丢弃
|
* 名字/参数类错误码一律只留痕(存储架构 §10.1 教训),条目丢弃
|
||||||
* 计数,绝不升级成卷操作。
|
* 计数,绝不升级成卷操作(全模块无 f_mount/f_mkfs/f_opendir)。
|
||||||
*/
|
*/
|
||||||
static int s_soe_append(const uint8_t *rec) {
|
static int s_soe_append(const uint8_t *rec) {
|
||||||
static FIL f; /* static:FF_FS_TINY=0 → FIL=528B 栈;
|
static FIL f; /* static:FF_FS_TINY=0 → FIL=528B 栈;
|
||||||
@@ -121,6 +174,9 @@ static int s_soe_append(const uint8_t *rec) {
|
|||||||
? ((uint32_t)finfo.fsize / SOE_REC_SIZE) : 0u;
|
? ((uint32_t)finfo.fsize / SOE_REC_SIZE) : 0u;
|
||||||
if (cnt >= SOE_STORE_MAX) {
|
if (cnt >= SOE_STORE_MAX) {
|
||||||
(void)f_unlink(SOE_PATH);
|
(void)f_unlink(SOE_PATH);
|
||||||
|
/* 代数自增(读者窗口失效):**unlink 当场就加**,不等写成功——文件已没了,
|
||||||
|
* 旧窗口里那几条记录在任何索引上都不再存在,晚一步都会有读者按陈旧窗口读数 */
|
||||||
|
s_rd_gen++;
|
||||||
DBG_INFO("cap full, recreate " SOE_PATH);
|
DBG_INFO("cap full, recreate " SOE_PATH);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,8 +185,9 @@ static int s_soe_append(const uint8_t *rec) {
|
|||||||
fr = f_write(&f, rec, SOE_REC_SIZE, &bw);
|
fr = f_write(&f, rec, SOE_REC_SIZE, &bw);
|
||||||
(void)f_close(&f);
|
(void)f_close(&f);
|
||||||
if ((fr != FR_OK) || (bw != SOE_REC_SIZE)) {
|
if ((fr != FR_OK) || (bw != SOE_REC_SIZE)) {
|
||||||
/* L1 文件级自愈:陈旧/损坏条目 → 删除重建、重试一次(同 run.log;
|
/* 文件级自愈:**只有写失败**才删除重建、重试一次(打开失败走下面
|
||||||
* unlink 返回值刻意不检查——失败也只是重试失败 → 留痕,不升级) */
|
* 的 else,不删——见函数头 ④)。unlink 返回值刻意不检查:失败也只是
|
||||||
|
* 重试失败 → 留痕计数,绝不升级成卷操作。 */
|
||||||
(void)f_unlink(SOE_PATH);
|
(void)f_unlink(SOE_PATH);
|
||||||
bw = 0u;
|
bw = 0u;
|
||||||
fr = f_open(&f, SOE_PATH, FA_WRITE | FA_OPEN_APPEND);
|
fr = f_open(&f, SOE_PATH, FA_WRITE | FA_OPEN_APPEND);
|
||||||
@@ -143,10 +200,13 @@ static int s_soe_append(const uint8_t *rec) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
/* 打开失败:只留痕、**不 unlink**(SOE 是事件序列,删文件 = 销毁历史;
|
||||||
|
* 名字/参数类错误码 unlink 也修不好,见函数头的逐码表) */
|
||||||
rc = -1;
|
rc = -1;
|
||||||
}
|
}
|
||||||
if (rc == 0) {
|
if (rc == 0) {
|
||||||
s_fault_reported = 0u;
|
s_fault_reported = 0u;
|
||||||
|
s_rd_gen++; /* 落盘成功 → 读者窗口作废(在卷锁区间内自增,与读者同锁同调) */
|
||||||
} else if (s_fault_reported == 0u) {
|
} else if (s_fault_reported == 0u) {
|
||||||
s_fault_reported = 1u;
|
s_fault_reported = 1u;
|
||||||
DBG_ERROR("write " SOE_PATH " fail (%d)", (int)fr);
|
DBG_ERROR("write " SOE_PATH " fail (%d)", (int)fr);
|
||||||
@@ -208,3 +268,121 @@ void soe_store_drain(void) {
|
|||||||
uint32_t soe_store_dropped(void) {
|
uint32_t soe_store_dropped(void) {
|
||||||
return s_drop_cnt;
|
return s_drop_cnt;
|
||||||
}
|
}
|
||||||
|
/* ========================= 只读遍历(UI 记录页) ============================ */
|
||||||
|
/*
|
||||||
|
* 函数功能:已落盘的 SOE 记录条数(= filesize / SOE_REC_SIZE,O(1))
|
||||||
|
* 入口参数:无
|
||||||
|
* 返回值:条数;文件缺失、未挂载或 f_stat 失败返回 0 uint32_t
|
||||||
|
* 限定条件:任意任务上下文可调(uiTask 的 SOE 记录页用);只做 f_stat,不取卷锁
|
||||||
|
* ——与 App/log/sys_log_mcu.c 的 syslog_count() 同一口径,不另起第二套
|
||||||
|
* 函数说明:条数按**整条 64 B 槽**相除,写者留下的半条尾槽天然不计入,这是
|
||||||
|
* "读者看不到半成品记录"的第一道保障(另两道见 soe_store_read 头注)
|
||||||
|
*/
|
||||||
|
uint32_t soe_store_count(void) {
|
||||||
|
FILINFO finfo;
|
||||||
|
|
||||||
|
if (f_stat(SOE_PATH, &finfo) != FR_OK) {
|
||||||
|
return 0u;
|
||||||
|
}
|
||||||
|
return (uint32_t)finfo.fsize / SOE_REC_SIZE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:把包含 idx 的那一窗(最多 8 条)整块读进 RAM 并取出目标记录
|
||||||
|
* 入口参数:idx - 记录序号 uint32_t(调用方已保证 < count)
|
||||||
|
* p_rec - 目标记录输出(SOE_REC_SIZE 字节) uint8_t *
|
||||||
|
* 返回值:0 - 成功;-1 - 卷忙 / 越界 / 打开定位读失败 / 短读 int
|
||||||
|
* 限定条件:内部函数;卷锁 try 语义(0 超时),拿不到锁立即返回不阻塞
|
||||||
|
* 函数说明:定长记录一次取 8 条(8 × 64 B = 512 B)而不是逐条 open/read:一页 8 行
|
||||||
|
* = 4 条记录,逐条读等于同一扇区 open 四次。记录页只在开页与翻页时重算,命中
|
||||||
|
* 窗口即零 I/O——整套"窗口 + 代数 + try 锁"结论与 App/log/sys_log_mcu.c 读侧
|
||||||
|
* 同源,刻意不另起第二套读盘路径(两套读盘规则必然漂移,是本仓反复在防的)
|
||||||
|
*/
|
||||||
|
static int s_rd_fill(uint32_t idx, uint8_t *p_rec) {
|
||||||
|
static FIL f; /* static:FF_FS_TINY=0 → FIL=528 B 栈;
|
||||||
|
* open~close 全程在卷锁区间内,跨任务由卷锁串行化 */
|
||||||
|
FRESULT fr;
|
||||||
|
UINT br = 0u;
|
||||||
|
uint32_t total;
|
||||||
|
uint32_t base;
|
||||||
|
uint32_t want;
|
||||||
|
|
||||||
|
if (vol_lock_wait(0u) == 0u) {
|
||||||
|
return -1; /* 卷忙不是故障:uiTask 下一轮重绘自然重试 */
|
||||||
|
}
|
||||||
|
/* 等锁期间写者可能已推进(甚至已 unlink 重建)→ 锁内重新取条数再校验边界 */
|
||||||
|
total = soe_store_count();
|
||||||
|
if ((total == 0u) || (idx >= total)) {
|
||||||
|
vol_lock_release();
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
base = (idx / SOE_RD_WINDOW) * SOE_RD_WINDOW;
|
||||||
|
want = total - base;
|
||||||
|
if (want > SOE_RD_WINDOW) {
|
||||||
|
want = SOE_RD_WINDOW;
|
||||||
|
}
|
||||||
|
fr = f_open(&f, SOE_PATH, FA_READ);
|
||||||
|
if (fr == FR_OK) {
|
||||||
|
fr = f_lseek(&f, (FSIZE_t)base * SOE_REC_SIZE);
|
||||||
|
if (fr == FR_OK) {
|
||||||
|
fr = f_read(&f, s_rd_win, want * SOE_REC_SIZE, &br);
|
||||||
|
}
|
||||||
|
(void)f_close(&f);
|
||||||
|
}
|
||||||
|
if ((fr != FR_OK) || (br != (UINT)(want * SOE_REC_SIZE))) {
|
||||||
|
s_rd_win_cnt = 0u; /* 作废窗口:坏窗口留着会让后续行继续读到垃圾 */
|
||||||
|
s_rd_win_gen = 0u;
|
||||||
|
vol_lock_release();
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
s_rd_win_base = base;
|
||||||
|
s_rd_win_cnt = want;
|
||||||
|
/* 代数在**卷锁区间内**采样:写者也只在持锁时自增,二者同侧 → 窗口与代数一致 */
|
||||||
|
s_rd_win_gen = s_rd_gen;
|
||||||
|
vol_lock_release();
|
||||||
|
|
||||||
|
(void)memcpy(p_rec, (const uint8_t *)s_rd_win + ((size_t)(idx - base) * SOE_REC_SIZE),
|
||||||
|
SOE_REC_SIZE);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读取第 idx 条 SOE 记录(解析后的结构体;idx 0 = 最早一条)
|
||||||
|
* 入口参数:idx - 记录序号 uint32_t
|
||||||
|
* p_out - 输出(不可为 NULL) soe_rec_t *
|
||||||
|
* 返回值:0 - 成功;-1 - p_out 为空 / 越界 / 卷忙 / I/O 失败 / 记录校验不过 int
|
||||||
|
* 限定条件:uiTask 上下文(记录页逐行取数);**不阻塞**——卷忙立即 -1,页面这行
|
||||||
|
* 退化显示"读失败",下一轮重绘再试;SOE 页坏不得拖住主界面节拍
|
||||||
|
* 函数说明:读者不得看到半成品记录,三道保障缺一不可:
|
||||||
|
* ① count 只数整条 64 B 槽(半条尾槽不计入);
|
||||||
|
* ② 窗口代数:写者每次成功落盘/重建在卷锁区间内自增 s_rd_gen,读者
|
||||||
|
* 比对填充时记下的代数,不匹配即重填——对齐 32 位读写在 ARM 上原子,
|
||||||
|
* 跨任务无需临界区,最坏多读一轮(同 syslog 读侧结论);
|
||||||
|
* ③ 单条硬门槛:soe_rec_parse() 校版本 + 校验和,撕裂/半写一律 -3 拒收。
|
||||||
|
* count 与 read 之间新落一条的竞态**容忍**为"本次遍历少看到一条"
|
||||||
|
* (200 ms 重绘自然追上),绝不容忍读到半条——所以边界一律以锁内
|
||||||
|
* 重取到的 total 为准,不用调用方传进来的旧 total。
|
||||||
|
* soe_rec_parse 的 -1/-2/-3 在本 API 折成一个 -1:调用方(UI)对三者
|
||||||
|
* 的处理完全相同(这行显示读不到),把内部码外溢只会诱导页面写死分支
|
||||||
|
*/
|
||||||
|
int soe_store_read(uint32_t idx, soe_rec_t *p_out) {
|
||||||
|
uint8_t rec[SOE_REC_SIZE];
|
||||||
|
|
||||||
|
if (p_out == NULL) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
/* 快路径:同代数且落在窗口内 → 零 I/O(代数比较用无锁读,最坏多刷一轮) */
|
||||||
|
if ((s_rd_win_cnt != 0u) && (s_rd_win_gen == s_rd_gen) &&
|
||||||
|
(idx >= s_rd_win_base) && (idx < s_rd_win_base + s_rd_win_cnt)) {
|
||||||
|
(void)memcpy(rec, (const uint8_t *)s_rd_win
|
||||||
|
+ ((size_t)(idx - s_rd_win_base) * SOE_REC_SIZE), SOE_REC_SIZE);
|
||||||
|
} else if (s_rd_fill(idx, rec) != 0) {
|
||||||
|
return -1;
|
||||||
|
} else {
|
||||||
|
/* 窗口未命中的分支已在 s_rd_fill 里完成取数与校验,这里不重复读盘 */
|
||||||
|
}
|
||||||
|
if (soe_rec_parse(rec, p_out) != 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,43 @@ void soe_store_drain(void);
|
|||||||
*/
|
*/
|
||||||
uint32_t soe_store_dropped(void);
|
uint32_t soe_store_dropped(void);
|
||||||
|
|
||||||
|
/* ---------------- 只读遍历(UI 的 SOE 记录页用;写侧零影响) ----------------
|
||||||
|
* 读者不得看到半成品记录,三道保障缺一不可(实现见 soe_store.c 同号函数):
|
||||||
|
* 1) count 只数**整条 64 B 槽**(filesize / SOE_REC_SIZE 整除),写者留下的
|
||||||
|
* 半条尾槽天然不计入;
|
||||||
|
* 2) 窗口代数:写者每次成功落盘 / 容量重建,在**卷锁区间内**自增内部代数,
|
||||||
|
* 读者比对填窗时记下的代数,不符即重填。对齐的 32 位读写在 ARM 上原子,
|
||||||
|
* 跨任务不需要临界区;最坏多读一轮,不会读出错数据;
|
||||||
|
* 3) 单条硬门槛:soe_rec_parse() 校版本 + 校验和,撕裂/半写一律拒收(-3)。
|
||||||
|
* 容忍的竞态只有一个:count 与 read 之间新落一条 → 本次遍历少看到一条,下一轮
|
||||||
|
* 重绘(uiTask 200 ms 节拍)自然追上。**不容忍**的是读到半条,所以边界一律以
|
||||||
|
* 卷锁内重新取到的条数为准,不用调用方传进来的旧值。
|
||||||
|
* 与 App/log/sys_log_mcu.c 的 syslog_count/syslog_get_record 同构(同一套窗口 +
|
||||||
|
* 代数 + try 锁纪律),刻意不另起第二套读盘路径。
|
||||||
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:已落盘的 SOE 记录条数(= filesize / SOE_REC_SIZE,O(1))
|
||||||
|
* 入口参数:无
|
||||||
|
* 返回值:条数;文件缺失、未挂载或 f_stat 失败返回 0 uint32_t
|
||||||
|
* 限定条件:任意任务上下文可调(uiTask 记录页用);只 f_stat,不取卷锁
|
||||||
|
* 函数说明:见上方"只读遍历"总说明第 1 道保障
|
||||||
|
*/
|
||||||
|
uint32_t soe_store_count(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:读取第 idx 条 SOE 记录(解析后的结构体,idx 0 = 最早一条)
|
||||||
|
* 入口参数:idx - 记录序号 uint32_t
|
||||||
|
* p_out - 输出(不可为 NULL) soe_rec_t *
|
||||||
|
* 返回值:0 - 成功;-1 - p_out 为空 / 越界 / 卷忙 / I/O 失败 / 版本或校验和不过 int
|
||||||
|
* 限定条件:uiTask 等读上下文;**不阻塞**——卷被占用立即返回 -1(调用方这行退化
|
||||||
|
* 显示"读失败",下轮重绘再试),排障页不得拖住主界面节拍
|
||||||
|
* 函数说明:soe_rec_parse 的 -1/-2/-3 在此折成一个 -1:调用方对三者的处置完全相同
|
||||||
|
* (这行读不到),把内部码外溢只会诱导页面为区分不出来的差异写分支。
|
||||||
|
* 时间字段取记录内的 CP56(事件自己的时标),不用"现在的时间"换算
|
||||||
|
*/
|
||||||
|
int soe_store_read(uint32_t idx, soe_rec_t *p_out);
|
||||||
|
|
||||||
#ifdef __cplusplus
|
#ifdef __cplusplus
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
|
|||||||
+177
-7
@@ -7,6 +7,20 @@
|
|||||||
* 创建日期:2026-09-14
|
* 创建日期:2026-09-14
|
||||||
* 修改记录:
|
* 修改记录:
|
||||||
* v1.0 2026-09-14 王建锋 创建初始版本(设计见 docs/子系统设计/运行与自检记录设计说明.md §4)
|
* v1.0 2026-09-14 王建锋 创建初始版本(设计见 docs/子系统设计/运行与自检记录设计说明.md §4)
|
||||||
|
* v1.2 2026-10-26 王建锋 新增「记录删除」API(UI 装置维护「清除记录」的前置,
|
||||||
|
* 道F task-4;判据 docs/任务规划/UI补齐.md §2 第 3 行
|
||||||
|
* 「不允许只做一个没有实际效果的按钮」):
|
||||||
|
* 共享层 syslog_request_clear() / syslog_clear_state()
|
||||||
|
* (双端都实现,UI 侧只准用这两个)+ MCU 后端专属
|
||||||
|
* syslog_clear_apply()(落地半边,仅 defaultTask)。
|
||||||
|
* 请求与落地分离的理由与执行点写在下面声明处
|
||||||
|
* v1.1 2026-10-24 王建锋 仅契约注释更正,与 App/log/sys_log_mcu.c 的 C/F 两项
|
||||||
|
* 整改同步(无签名、无行为改动):
|
||||||
|
* syslog_get_record 写死容量要求(ts_len > SYSLOG_TS_LEN、
|
||||||
|
* desc_len > SYSLOG_DESC_MAX)并明确"容量不足一律 -1、
|
||||||
|
* 不截断、不写调用方缓冲"——旧注释像是"给少了会被截断",
|
||||||
|
* 实际是整条读不到;syslog_post 写明必须假设丢弃存在、
|
||||||
|
* 且丢弃留痕已按通道节流(节流后无丢弃计数)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
#ifndef SYS_LOG_H
|
#ifndef SYS_LOG_H
|
||||||
@@ -21,10 +35,29 @@ extern "C" {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* 日志 ID:决定落盘文件与容量上限
|
* 日志 ID:决定落盘文件与容量上限
|
||||||
|
*
|
||||||
|
* ⚠ **记录类型 = 日志 ID 本身**(一类记录 = /log 下一个严格 8.3 短名的文件),
|
||||||
|
* 记录体内**没有**类型字段位(Lead 裁定 2026-10-26,task-10):64B 定长格式被
|
||||||
|
* UI 记录页(文本读者)与主机解码工具同时按固定偏移读,塞类型字节等于当场砸掉
|
||||||
|
* 这两批既有读者;而运行/自检记录已经在这份格式里跑了很久,不能为五类新记录
|
||||||
|
* 改版本位。于是"这条记录是什么类型"由它在哪个文件里回答。
|
||||||
|
* 同理二进制 SOE 通道的 soe_type_t 本轮**不动**:改它必须同步 UI 侧的类型名表
|
||||||
|
* (类型名表在 UI 侧 <Drivers/BSP/UI/ui_w_soe.c>,不在本道写域),而且 SOE
|
||||||
|
* 页与这五页是两套读者。
|
||||||
|
* 口径与五个写入点已登记进 App/log/README.md(task-11「记录 5 页」按此做——五个页面已于
|
||||||
|
* 2026-10-28 落地:ui_page_accident / oper / alarm / start / yk,各页只读一个文件)。
|
||||||
*/
|
*/
|
||||||
typedef enum {
|
typedef enum {
|
||||||
SYSLOG_RUN = 0u, /* 运行记录 /log/run.log:上电/重启事件 */
|
SYSLOG_RUN = 0u, /* 运行记录 /log/run.log:上电/重启事件 */
|
||||||
SYSLOG_SELFTEST, /* 自检记录 /log/selftest.log:开机自检故障(仅故障才写) */
|
SYSLOG_SELFTEST, /* 自检记录 /log/selftest.log:开机自检故障(仅故障才写) */
|
||||||
|
/* ↓ 五类记录(docs/UI设计/页面扩充详细方案.md §3.6 的五行,task-10 落写入点)
|
||||||
|
* ⚠ 只准**尾部追加**:id 值即枚举序 = 两端 s_paths[]/s_caps[] 的下标,
|
||||||
|
* 中间插值会让老文件被新 id 读成别的类型(静默错类别,比崩更难查)。 */
|
||||||
|
SYSLOG_ACCIDENT, /* 事故记录 /log/accid.log:跳闸投退=1 的元件动作 */
|
||||||
|
SYSLOG_ALARM, /* 保护告警 /log/alarm.log:跳闸投退=0 或该项不存在的元件动作 */
|
||||||
|
SYSLOG_START, /* 保护启动 /log/start.log:元件 picked 上升沿 */
|
||||||
|
SYSLOG_OPER, /* 操作记录 /log/oper.log:强制复归 / 清除记录 / 对时 / 定值保存 */
|
||||||
|
SYSLOG_YK, /* 遥控记录 /log/yk.log:104 遥控的选择 / 执行 / 拒绝 */
|
||||||
SYSLOG_COUNT /* 哨兵值,非有效 ID */
|
SYSLOG_COUNT /* 哨兵值,非有效 ID */
|
||||||
} syslog_id_t;
|
} syslog_id_t;
|
||||||
|
|
||||||
@@ -46,9 +79,21 @@ typedef enum {
|
|||||||
|
|
||||||
/*
|
/*
|
||||||
* 容量上限(条数;满时删除重建,设计文档 §4.3)
|
* 容量上限(条数;满时删除重建,设计文档 §4.3)
|
||||||
|
*
|
||||||
|
* ⚠ **上限是逻辑上限,不是预分配**:文件按追加写自然增长,一条不写就不占簇;
|
||||||
|
* 到顶才 unlink 重建(照 run.log 先例)。七类全满的名义峰值 =
|
||||||
|
* (1024+512+512+1024+1024+512+256) × 64B = **304 KB**,NAND 2 GB、且这些
|
||||||
|
* 记录只在事件边沿才写(判据动作/遥控/人工操作),不构成容量或磨损瓶颈。
|
||||||
|
* 告警/启动给到 1024 是因为一次系统故障会连片触发多个元件,别让最想了解
|
||||||
|
* 故障过程的两类记录最先被截掉。
|
||||||
*/
|
*/
|
||||||
#define SYSLOG_RUN_MAX 1024u /* /log/run.log:64KB */
|
#define SYSLOG_RUN_MAX 1024u /* /log/run.log:64KB */
|
||||||
#define SYSLOG_SELFTEST_MAX 512u /* /log/selftest.log:32KB */
|
#define SYSLOG_SELFTEST_MAX 512u /* /log/selftest.log:32KB */
|
||||||
|
#define SYSLOG_ACCIDENT_MAX 512u /* /log/accid.log:32KB(跳闸稀有,换长历史) */
|
||||||
|
#define SYSLOG_ALARM_MAX 1024u /* /log/alarm.log:64KB */
|
||||||
|
#define SYSLOG_START_MAX 1024u /* /log/start.log:64KB */
|
||||||
|
#define SYSLOG_OPER_MAX 512u /* /log/oper.log:32KB */
|
||||||
|
#define SYSLOG_YK_MAX 256u /* /log/yk.log:16KB(低频,与事故同稀有) */
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 上电/重启分类窗口(秒):run.log 末条 epoch 距 now < 该值 → "reboot",否则 "power on"
|
* 上电/重启分类窗口(秒):run.log 末条 epoch 距 now < 该值 → "reboot",否则 "power on"
|
||||||
@@ -58,12 +103,52 @@ typedef enum {
|
|||||||
/*
|
/*
|
||||||
* 函数功能:投递一条日志(任意上下文;非阻塞,队列满则丢弃)
|
* 函数功能:投递一条日志(任意上下文;非阻塞,队列满则丢弃)
|
||||||
* 入口参数:id - 日志 ID syslog_id_t
|
* 入口参数:id - 日志 ID syslog_id_t
|
||||||
* desc - 描述串(UTF-8,≤SYSLOG_DESC_MAX 字节;超长截断) const char *
|
* desc - 描述串(UTF-8;超过 SYSLOG_DESC_MAX 的部分丢弃,落盘只留前 28 字节)
|
||||||
|
* 本参数**投递时即拷贝进待发条目**:调用方缓冲区可立即复用,
|
||||||
|
* 不要求生命周期覆盖到落盘(2026-10-24 台架实证后由「只存指针」
|
||||||
|
* 改为条目内嵌定长缓冲,理由见 sys_log_mcu.c 的 syslog_entry_t 注释)
|
||||||
|
* 指针本身不可为 NULL const char *
|
||||||
* 返回值:无
|
* 返回值:无
|
||||||
* 限定条件:MCU 端 FreeRTOS 启动前调用走静态待发数组(由 defaultTask 挂载后落盘)
|
* 限定条件:MCU 端 FreeRTOS 启动前调用走静态待发数组(由 defaultTask 挂载后落盘)
|
||||||
|
* 函数说明:调用方**必须假设投递可能被丢弃**——MCU 后端(sys_log_mcu.c)队列 8 槽、
|
||||||
|
* 启动前待发数组 8 槽,卷被 ftpTask 长期占用时 defaultTask 会把整批回投
|
||||||
|
* 队列(队列因此可长时间处于满态),此时本函数直接丢弃该条,并只在
|
||||||
|
* "转入故障"的那一次留痕(丢弃留痕按正常→故障节流,F 项整改 2026-10-24;
|
||||||
|
* 节流后**丢弃总数没有计数**,要计量得另加计数器)。必须留住的证据走
|
||||||
|
* sys_log_evidence(),不要靠 syslog_post 重试。
|
||||||
|
* 模拟器后端(simulator/sim/sim_syslog.c)是单线程直写宿主文件、**没有
|
||||||
|
* 队列与丢弃**,因此上面这段满态语义只约束 MCU 端。
|
||||||
*/
|
*/
|
||||||
void syslog_post(syslog_id_t id, const char *desc);
|
void syslog_post(syslog_id_t id, const char *desc);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:投递一条日志——**调用链上不带日志行缓冲**的那一个版本
|
||||||
|
* 入口参数:id - 日志 ID syslog_id_t
|
||||||
|
* desc - 描述串(NUL 结尾;超过 SYSLOG_DESC_MAX 截断),同 syslog_post
|
||||||
|
* 返回值:无
|
||||||
|
* 限定条件:任意上下文;MCU 端不碰 FatFs、不碰卷锁、不阻塞
|
||||||
|
* 函数说明:为什么要有第二个投递口(不是洁癖):每个 DBG_* 调用点在调用者栈上
|
||||||
|
* 内联约 216 B 行缓冲,而 syslog_post 的两个"满态丢弃留痕"分支就躺在
|
||||||
|
* 它自己的帧里——于是"调一次 post"在静态最坏链上等于"带上这两处缓冲"。
|
||||||
|
* protectTask 的静态最坏链是 B9 写进 DoD 的硬线(872 B / 57%,当年在
|
||||||
|
* post 里加一条 DBG_* 实测垫到 1192 B = stack_report FAIL),iec104Task
|
||||||
|
* 同样是深链任务,所以这五类记录的写入点一律走本函数。
|
||||||
|
* 代价是丢弃时不当场留痕:只累加 syslog_post_dropped(),由唯一写者
|
||||||
|
* defaultTask 在 syslog_drain() 里按差值补一条(照 soe_store 的先例)。
|
||||||
|
* 与 syslog_post 的差别**只有留痕路径**,收/丢的判据逐条相同。
|
||||||
|
* 模拟器后端没有队列也没有栈预算问题,本函数等价于 syslog_post。
|
||||||
|
*/
|
||||||
|
void syslog_post_quiet(syslog_id_t id, const char *desc);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:取 syslog_post_quiet() 通道的累计丢弃条数(可观测性)
|
||||||
|
* 入口参数:无
|
||||||
|
* 返回值:MCU 端 = 队列满 / 启动前数组满累计次数;模拟器端恒 0(直写不丢) uint32_t
|
||||||
|
* 函数说明:与 soe_store_dropped() 同款定位。留痕由 drain 按差值上报,本函数是
|
||||||
|
* 给单测与台架读数用的,不参与任何判定路径。
|
||||||
|
*/
|
||||||
|
uint32_t syslog_post_dropped(void);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:获取记录条数(= filesize / SYSLOG_REC_SIZE,O(1))
|
* 函数功能:获取记录条数(= filesize / SYSLOG_REC_SIZE,O(1))
|
||||||
* 入口参数:id - 日志 ID syslog_id_t
|
* 入口参数:id - 日志 ID syslog_id_t
|
||||||
@@ -75,11 +160,19 @@ uint32_t syslog_count(syslog_id_t id);
|
|||||||
* 函数功能:读取第 idx 条记录(时间戳 + 描述,均 NUL 结尾)
|
* 函数功能:读取第 idx 条记录(时间戳 + 描述,均 NUL 结尾)
|
||||||
* 入口参数:id - 日志 ID syslog_id_t
|
* 入口参数:id - 日志 ID syslog_id_t
|
||||||
* idx - 记录序号(0 起) uint32_t
|
* idx - 记录序号(0 起) uint32_t
|
||||||
* p_ts - 时间戳输出缓冲(≥SYSLOG_TS_LEN+1 字节;不可为 NULL) char *
|
* p_ts - 时间戳输出缓冲(不可为 NULL) char *
|
||||||
* ts_len- p_ts 容量 size_t
|
* ts_len- p_ts 容量,必须 > SYSLOG_TS_LEN(即 ≥ SYSLOG_TS_LEN+1 = 20 字节) size_t
|
||||||
* p_desc- 描述输出缓冲(≥SYSLOG_DESC_MAX+1 字节;不可为 NULL) char *
|
* p_desc- 描述输出缓冲(不可为 NULL) char *
|
||||||
* desc_len - p_desc 容量 size_t
|
* desc_len - p_desc 容量,必须 > SYSLOG_DESC_MAX(即 ≥ 29 字节) size_t
|
||||||
* 返回值:0 - 成功;-1 - 越界/读失败 int
|
* 返回值:0 - 成功;-1 - 越界 / 读失败 / **缓冲容量不足** int
|
||||||
|
* 限定条件:容量不足一律返回 -1,**不做静默截断、也不写调用方一个字节**
|
||||||
|
* (C 项整改 2026-10-24;旧注释只写"≥…+1 字节"像是"给少了会被截断",
|
||||||
|
* 照它给 28 字节缓冲的结果是整条读不到而不是读到 27 字符)。
|
||||||
|
* 描述字段在写入侧已截到 SYSLOG_DESC_MAX,所以按上面的最小容量给缓冲
|
||||||
|
* 就一定装得下,读侧不需要第二道截断。
|
||||||
|
* MCU 后端与模拟器后端(sim_syslog.c)在这条守卫上取值相同,行为一致;
|
||||||
|
* 模拟器侧那份同样不可达的截断支还留在文件里(未列入本轮改动范围)。
|
||||||
|
* 调用方:uiTask 的日志页(行缓冲 32B)与 SYSLOG 取证打印。
|
||||||
*/
|
*/
|
||||||
int syslog_get_record(syslog_id_t id, uint32_t idx, char *p_ts, size_t ts_len, char *p_desc, size_t desc_len);
|
int syslog_get_record(syslog_id_t id, uint32_t idx, char *p_ts, size_t ts_len, char *p_desc, size_t desc_len);
|
||||||
|
|
||||||
@@ -91,6 +184,58 @@ int syslog_get_record(syslog_id_t id, uint32_t idx, char *p_ts, size_t ts_len, c
|
|||||||
*/
|
*/
|
||||||
int syslog_last_epoch(syslog_id_t id, uint32_t *p_epoch);
|
int syslog_last_epoch(syslog_id_t id, uint32_t *p_epoch);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 记录删除(「清除记录」):请求 → 落地分离
|
||||||
|
*
|
||||||
|
* 为什么必须分两步:defaultTask 是本模块**唯一写者**(存储架构说明 §10.1 第 7 条),
|
||||||
|
* uiTask 直接 f_unlink 会与写者的追加写/「容量满 unlink 重建」交错,而且删除要走
|
||||||
|
* 卷锁 + FAT 回写——uiTask 在 IWDG required_mask 内、每 100 ms 喂一次心跳,
|
||||||
|
* 给它一条最坏数百 ms 的存储路径就是拿误复位当代价。故 UI 侧只登记请求,
|
||||||
|
* 真正的删除在 defaultTask 的 syslog_drain() 尾部执行
|
||||||
|
* (范式同 App/algo/prot_elm.c 的 prot_elm_request_reset():单字节标志 + 消费者落地)。
|
||||||
|
*
|
||||||
|
* 状态码(syslog_clear_state 返回值;写成宏而不是枚举,与本头既有 SYSLOG_* 常量同族)
|
||||||
|
*/
|
||||||
|
#define SYSLOG_CLEAR_NONE 0 /* 从未请求过清除(上电初值) */
|
||||||
|
#define SYSLOG_CLEAR_OK 1 /* 已清空:文件不在盘上,同拍 syslog_count() 必为 0 */
|
||||||
|
#define SYSLOG_CLEAR_WAIT (-1) /* 请求排队中:等 defaultTask 下一轮 drain(含卷占用顺延、卷未就绪挂起) */
|
||||||
|
#define SYSLOG_CLEAR_FAIL (-2) /* 最近一次落地失败:原文件未被删除,可再发一次请求 */
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:登记「清除某日志全部记录」的请求(任意上下文;不碰存储、不阻塞)
|
||||||
|
* 入口参数:id - 日志 ID syslog_id_t
|
||||||
|
* 返回值:0 - 请求已登记(同一 id 重复登记合并为一次,幂等)
|
||||||
|
* -1 - id 越界(请求未登记,状态一个字节都没动) int
|
||||||
|
* 限定条件:非唯一写者的上下文(uiTask / netTask / iec104Task)**只能**用本函数发起
|
||||||
|
* 删除,不得直调 MCU 后端的 syslog_clear_apply()
|
||||||
|
* 函数说明:本函数**不等于"已清除"**,只代表"已受理"——MCU 端的删除动作发生在
|
||||||
|
* defaultTask 的下一次 syslog_drain()(50 ms 节拍),结果读
|
||||||
|
* syslog_clear_state(),事实复核读 syslog_count()。
|
||||||
|
* ⚠ 两端时序有别(同 syslog_post 的队列/直写差异,那边写在 post 的头注释里):
|
||||||
|
* MCU 端返回后状态通常是 SYSLOG_CLEAR_WAIT;模拟器端单线程、本调用内
|
||||||
|
* 同步落地,返回后立即是 OK/FAIL,**永远不会读到 WAIT**。UI 侧要按
|
||||||
|
* "WAIT 也正常"写判断,别把 WAIT 当失败。
|
||||||
|
* 刻意**不**在删除成功后补一条 "records cleared" 留痕:判据要求
|
||||||
|
* syslog_count() 归零,写了立刻变 1;要留痕归操作记录/SOE
|
||||||
|
* (docs/任务规划/UI补齐.md 第 6 项的写入方),不是本 API 的职责。
|
||||||
|
*/
|
||||||
|
int syslog_request_clear(syslog_id_t id);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:查「清除记录」的状态(给 UI 出真实反馈,不是"按了就算成功")
|
||||||
|
* 入口参数:id - 日志 ID syslog_id_t
|
||||||
|
* 返回值:SYSLOG_CLEAR_NONE(0) / SYSLOG_CLEAR_OK(1) / SYSLOG_CLEAR_WAIT(-1) /
|
||||||
|
* SYSLOG_CLEAR_FAIL(-2);id 越界按 NONE 返回(等价"从没请求过") int
|
||||||
|
* 限定条件:任意上下文(MCU 端只读两个 volatile 单字节,Cortex-M 上原子,无锁)
|
||||||
|
* 函数说明:排队位优先于结果字节——否则连点两次会先把上一次的结果当成这次的,
|
||||||
|
* 还在排队的请求被显示成"已清空"。
|
||||||
|
* 无锁交接的最坏竞态只有两种,都不产生错误数据:① 写者已销账、尚未写
|
||||||
|
* 结果的那几个指令周期里读 → 读到上一次的结果(同向,多为 OK);
|
||||||
|
* ② 上一次失败被新请求清零 → 那次失败不再显示(用户已在重新发起,
|
||||||
|
* 语义正确)。为它上临界区要引 FreeRTOS 头 + 宿主桩,不值。
|
||||||
|
*/
|
||||||
|
int syslog_clear_state(syslog_id_t id);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* MCU 后端专属 API(sys_log_mcu.c;模拟器端无对应实现,勿在共享代码调用)
|
* MCU 后端专属 API(sys_log_mcu.c;模拟器端无对应实现,勿在共享代码调用)
|
||||||
*/
|
*/
|
||||||
@@ -116,16 +261,41 @@ void syslog_mcu_mounted(void);
|
|||||||
* 入口参数:无
|
* 入口参数:无
|
||||||
* 返回值:无
|
* 返回值:无
|
||||||
* 限定条件:仅 defaultTask(唯一写者)调用;卷未就绪时直接返回(条目滞留待重试)
|
* 限定条件:仅 defaultTask(唯一写者)调用;卷未就绪时直接返回(条目滞留待重试)
|
||||||
|
* 本函数尾部同时消费「清除记录」请求(syslog_request_clear 的落地半边,
|
||||||
|
* 排在条目排空之后——删除必须是本轮对这两个文件的最后一次盘上动作,
|
||||||
|
* 先删后写会把刚排空的旧记录又落回去,"清除"就变成没效果的按钮)
|
||||||
*/
|
*/
|
||||||
void syslog_drain(void);
|
void syslog_drain(void);
|
||||||
|
|
||||||
|
/*
|
||||||
|
* 函数功能:立即删除某日志的全部记录(「清除记录」的落地半边,MCU 后端专属)
|
||||||
|
* 入口参数:id - 日志 ID syslog_id_t
|
||||||
|
* 返回值:0 - 记录已清空(含"文件本就不存在"的幂等成功,见下)
|
||||||
|
* -1 - 未清空:卷被占用或删除失败,**原文件一个字节都没少** int
|
||||||
|
* 限定条件:仅 defaultTask(syslog 唯一写者)上下文,且调用处**不得持卷锁**
|
||||||
|
* (卷锁非递归锁);卷锁一律 try(vol_lock_wait(0),本任务 = 看门狗
|
||||||
|
* 喂狗点,阻塞等锁 = 停喂 = IWDG 误复位)
|
||||||
|
* 函数说明:其它上下文请调 syslog_request_clear()(本头文件上方);本函数在
|
||||||
|
* MCU 后端之外**没有任何实现**,共享代码误调会在链接期报错,
|
||||||
|
* 这比运行时断言硬。产品路径上唯一的调用者是 syslog_drain 尾部的
|
||||||
|
* 请求消费循环,直接调用只用于板级套件与主机单测。
|
||||||
|
* 幂等口径:只有 FR_NO_FILE 算"已经没有什么可删了"→ 返 0;
|
||||||
|
* FR_NO_PATH(/log 目录不在)**不算**幂等——记录确实读不到,但成因是
|
||||||
|
* 目录没了而不是"被清空",补目录归 syslog_mcu_mounted() 每 2s 的重试,
|
||||||
|
* 这里如实返 -1,不把卷异常洗成"清除成功"。
|
||||||
|
* 名字/参数类错误码(FR_INVALID_NAME 等)一律只返 -1 不做任何补救动作:
|
||||||
|
* 本函数压根没有卷级动作(无 f_mkdir / 无 f_mkfs / 不重试 unlink),
|
||||||
|
* 这正是存储架构说明 §10.1 第 10 条在删除路径上的落地形态。
|
||||||
|
*/
|
||||||
|
int syslog_clear_apply(syslog_id_t id);
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* 函数功能:把一条取证行追加到 /log/wdev.log(掉电不丢;变长行,不走 64B 定长格式)
|
* 函数功能:把一条取证行追加到 /log/wdev.log(掉电不丢;变长行,不走 64B 定长格式)
|
||||||
* 入口参数:p_desc - 取证描述行(调用方已格式化;不可为 NULL) const char *
|
* 入口参数:p_desc - 取证描述行(调用方已格式化;不可为 NULL) const char *
|
||||||
* 返回值:0 - 成功;-1 - 卷占用或写失败(证据可能回滚,**不阻断判定**) int
|
* 返回值:0 - 成功;-1 - 卷占用或写失败(证据可能回滚,**不阻断判定**) int
|
||||||
* 限定条件:poller(defaultTask)上下文调用;卷锁一律 try(绝不阻塞,
|
* 限定条件:poller(defaultTask)上下文调用;卷锁一律 try(绝不阻塞,
|
||||||
* 本上下文 = 看门狗喂狗点)
|
* 本上下文 = 看门狗喂狗点)
|
||||||
* 函数说明:round 7 取证通道(设计见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录:
|
* 函数说明:第 7 轮取证通道(设计见 docs/子系统设计/看门狗设计说明.md 2026-10-02 变更记录:
|
||||||
* 误复位后 RAM 态随复位消失、stall 行经僵尸串口丢失,故证据必须落盘)。
|
* 误复位后 RAM 态随复位消失、stall 行经僵尸串口丢失,故证据必须落盘)。
|
||||||
* 内部在释放卷锁后立刻追加一条 s_append(SYSLOG_RUN, ...) 写另一个
|
* 内部在释放卷锁后立刻追加一条 s_append(SYSLOG_RUN, ...) 写另一个
|
||||||
* 文件——FTL 单页缓存语义为"写到不同 LPN 才把前一脏页刷进 NAND",
|
* 文件——FTL 单页缓存语义为"写到不同 LPN 才把前一脏页刷进 NAND",
|
||||||
|
|||||||
+907
-112
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user