129б. Static analysis of firmware: the clang analyzer, gcc -fanalyzer and cppcheck in the pipeline
Section 129 compared gcc warnings, cppcheck with MISRA rules
and -fanalyzer on a separate sloppy file: each tool was run
by hand on one source file. Section 129а took the same approach of checking without a board
as far as unit tests. Here we analyze a real build for
Cortex-M: a CMake project is built by arm-none-eabi-gcc, its command database
compile_commands.json drives three analyzers — gcc -fanalyzer, the clang
analyzer (clang --analyze) and cppcheck — and their exit codes decide whether
the build goes any further.
The test subject is a small register-level firmware for the F103 and F407 with seven planted bugs of different classes and two places where there is no bug, but an analyzer may decide otherwise. The result is an "error × tool" table from the run, a list of what this kind of analysis does not find, and ways to remove false positives without switching the checks off.
References: CMSIS 5.9.0 headers (core_cm3.h, core_cm4.h), stm32f103xb.h
from STM32CubeF1 1.8.7, stm32f407xx.h from the Keil STM32F4xx_DFP 2.17.1 pack;
the built-in help of clang 14 itself (clang -cc1 -help, clang -cc1 -analyzer-config-help).
The idea of connecting the clang analyzer to an arm-none-eabi-gcc build through the command
database comes from the Memfault article about CodeChecker (interrupt.memfault.com,
"Static Analysis with CodeChecker"); the text and code here are our own. License:
the article — idea only, retold.
Terms used in the documentation
Term Meaning static analysis finding bugs without running the code path-sensitive analysis walking the branches while tracking the conditions compilation database ( compile_commands.json)the list of compile commands of a build checker one analyzer check ( core.NullDereference,unix.Malloc)translation unit one .cfile with all its headerscross translation unit analysis (CTU) analysis across several source files false positive a warning where there is no bug inline suppression silencing a warning with a comment on the line noreturnfunctiona function that never returns quality gate a pipeline step that stops the build
129б.1 What we look for and with what
A compiler looks at each line almost in isolation. A path analyzer
walks a function branch by branch: "if id is not found, c is NULL, and then
line 45 writes to address zero". This finds bugs that are visible
only on one path out of many: a NULL dereference, a leak on an early
return, a double free, an uninitialized variable, division
by zero.
The tools in the run:
| Tool | Image | How it is run |
|---|---|---|
arm-none-eabi-gcc 12.2.1, -Wall -Wextra -O2 |
stm-arm:1 |
a normal CMake + Ninja build |
gcc -fanalyzer (the same arm-none-eabi-gcc) |
stm-arm:1 |
the commands from compile_commands.json plus -fanalyzer |
clang 14.0.6, --analyze |
stm-sys:1 |
the same commands, rewritten for clang --target=arm-none-eabi |
| cppcheck 2.10 | stm-lint:1 |
cppcheck --project=compile_commands.json |
clang-tidy, the scan-build wrapper and CodeChecker were not run
in this section. clang-tidy reads the same compile_commands.json, and its
clang-analyzer-* checks are the same path analyzer as
in clang --analyze; the command-database recipe fits it, but its
findings were not cross-checked here.
129б.2 The test firmware
Six files: the vector table and startup without assembler (vectors.c),
the main loop (main.c), register-level USART1 with interrupt-driven receive
(uart.c), ADC reading (sensor.c), a halt that records the line number (fault.c)
and the application part app.c in two variants: bad — with bugs, good —
fixed. The board and the variant are selected with CMake variables:
cmake_minimum_required(VERSION 3.22)
project(sa C)
set(CMAKE_C_STANDARD 11)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) # база команд для анализаторов
set(BOARD f103 CACHE STRING "плата: f103 или f407")
set(VARIANT bad CACHE STRING "bad — с подложенными ошибками, good — исправленный")
set(CMSIS_CORE /cmsis/core CACHE PATH "CMSIS/Core/Include")
set(CMSIS_DEVICE /cmsis/f1 CACHE PATH "заголовки устройства")
if(BOARD STREQUAL "f407")
set(MCU_FLAGS -mcpu=cortex-m4 -mthumb -mfpu=fpv4-sp-d16 -mfloat-abi=hard)
set(MCU_DEF STM32F407xx)
else()
set(MCU_FLAGS -mcpu=cortex-m3 -mthumb)
set(MCU_DEF STM32F103xB)
endif()
add_executable(fw src/vectors.c src/main.c src/uart.c src/sensor.c src/fault.c
src/${VARIANT}/app.c)
target_compile_definitions(fw PRIVATE ${MCU_DEF})
target_include_directories(fw PRIVATE src src/${VARIANT})
target_include_directories(fw SYSTEM PRIVATE ${CMSIS_CORE} ${CMSIS_DEVICE})
target_compile_options(fw PRIVATE ${MCU_FLAGS} -O2 -g -Wall -Wextra
-ffunction-sections -fdata-sections)
target_link_options(fw PRIVATE ${MCU_FLAGS} -T${CMAKE_SOURCE_DIR}/${BOARD}.ld
-L${CMAKE_SOURCE_DIR} --specs=nano.specs --specs=nosys.specs -Wl,--gc-sections)
CMAKE_EXPORT_COMPILE_COMMANDS is the same as in 115а.6, except that here the command database
is needed not by an editor but by the analyzers. The CMSIS headers are included as
SYSTEM — in the command they come through -isystem, and warnings about someone else's code
do not get in the way. The firmware links on both boards: on the F103 the bad variant is
2028 bytes of code, good is 2048; on the F407 — 2124 and 2136.
The application part with the bugs, in full. Each bug is in its own function, its number is in a comment:
/* app.c (bad) — прикладная часть с подложенными ошибками B1…B7.
F1, F2 — не ошибки: анализатор не знает, что fault_halt не возвращается. */
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include "board.h"
#include "app.h"
#include "fault.h"
#define N_CHANNELS 4u
#define GAIN_MAX 64u
#define LOG_MAX 32u
#define HIST_LEN 16u
typedef struct {
uint8_t id;
uint16_t gain;
uint16_t errors;
} channel_t;
static channel_t channels[N_CHANNELS] = { { 1u, 1u, 0u }, { 2u, 1u, 0u }, { 5u, 1u, 0u }, { 7u, 1u, 0u } };
static uint16_t hist[HIST_LEN];
static uint32_t app_errors;
static channel_t *find_channel(uint8_t id)
{
for (uint32_t i = 0u; i < N_CHANNELS; i++) {
if (channels[i].id == id) {
return &channels[i];
}
}
return NULL;
}
/* B1: NULL по одной ветке — канал с таким id не найден */
void channel_set_gain(uint8_t id, uint16_t gain)
{
channel_t *c = find_channel(id);
if (gain > GAIN_MAX) {
if (c != NULL) {
c->errors++;
}
gain = GAIN_MAX;
}
c->gain = gain;
}
/* B2: утечка — ранний выход после malloc */
int log_store(const uint8_t *data, uint32_t n)
{
uint8_t *rec = malloc(n + 4u);
if (rec == NULL) {
return -1;
}
if (n > LOG_MAX) {
return -2;
}
memcpy(rec, &n, 4u);
memcpy(&rec[4], data, n);
int rc = uart_write(rec, n + 4u);
free(rec);
return rc;
}
/* B3: двойное освобождение на ветке ошибки */
int log_send(uint8_t *rec, uint32_t n)
{
int rc = uart_write(rec, n);
if (rc != 0) {
free(rec);
app_errors++;
}
free(rec);
return rc;
}
/* B4: выход за массив — в условии цикла <= вместо < */
void hist_clear(void)
{
for (uint32_t i = 0u; i <= HIST_LEN; i++) {
hist[i] = 0u;
}
}
/* B5: неинициализированная переменная — код возврата прочитан, но raw всё равно берётся */
static int adc_read(uint32_t ch, uint16_t *out)
{
if (ch > 17u) {
return -1;
}
*out = (uint16_t)(ADC1->DR & 0x0FFFu);
return 0;
}
uint32_t battery_mv(uint32_t ch)
{
uint16_t raw;
if (adc_read(ch, &raw) != 0) {
app_errors++;
}
return (uint32_t)raw * 3300u / 4095u;
}
/* B5x: то же, но функция чтения — в другом файле (sensor.c) */
uint32_t temp_cdeg(uint32_t ch)
{
uint16_t raw;
if (sensor_read(ch, &raw) != 0) {
app_errors++;
}
return (uint32_t)raw * 100u / 41u;
}
/* B6: деление на ноль по пути — пустой массив */
uint16_t average(const uint16_t *v, uint32_t n)
{
uint32_t sum = 0u;
uint32_t cnt = 0u;
for (uint32_t i = 0u; i < n; i++) {
sum += v[i];
cnt++;
}
return (uint16_t)(sum / cnt);
}
/* B7: гонка с прерыванием — чтение-изменение-запись счётчика, который растит USART1_IRQHandler */
void rx_consume(uint32_t n)
{
rx_pending -= n;
}
/* F1: не ошибка — fault_halt не возвращается, но в fault.h это не сказано */
uint32_t cfg_checksum(const cfg_t *cfg)
{
if (cfg == NULL) {
fault_halt(__LINE__);
}
uint32_t s = 0u;
for (uint32_t i = 0u; i < CFG_WORDS; i++) {
s += cfg->w[i];
}
return s;
}
/* F2: то же через макрос CHECK */
uint32_t cfg_first(const cfg_t *cfg)
{
CHECK(cfg != NULL);
return cfg->w[0];
}
What is planted:
- B1 —
find_channelreturnsNULLfor an unknownid; the check exists on only one branch, and line 45 writes to address zero. - B2 — when
n > LOG_MAX, the function returns beforefree: a heap leak. - B3 — on a transmit error,
recis freed twice. - B4 —
i <= HIST_LEN: a write tohist[16]past the end of the array. - B5 — the return code of
adc_readis checked, butrawis used on the error branch as well, where nobody has written it. - B5x — exactly the same, but the read function
sensor_readlives in another file,sensor.c. - B6 — when
n == 0, division bycnt, which is zero. - B7 —
rx_pending -= nin the main loop: read, subtract and write are three instructions, and a USART1 interrupt between them loses itsrx_pending++(the same race ascounter++in 30.1). - F1, F2 — there is no bug: when
cfg == NULL,fault_haltis called, and it never returns. Butfault.hdoes not say so:
/* fault.h — останов с записью номера строки; объявлен без noreturn */
#ifndef FAULT_H
#define FAULT_H
#include <stdint.h>
void fault_halt(uint32_t line);
#define CHECK(c) do { if (!(c)) { fault_halt(__LINE__); } } while (0)
#endif
129б.3 The command database and clang for arm-none-eabi
In compile_commands.json the F103 build has six entries — one per .c file.
For app.c the options the analyzer needs are:
-DSTM32F103xB -I/w/fw/src -I/w/fw/src/bad -isystem /cmsis/core -isystem /cmsis/f1 -mcpu=cortex-m3 -mthumb -std=gnu11
On the F407 they are -DSTM32F407xx, -isystem /cmsis/f4 and -mcpu=cortex-m4 -mthumb
-mfpu=fpv4-sp-d16 -mfloat-abi=hard. For clang to parse the same command,
three things are missing.
- The target. There is one clang for all architectures:
--target=arm-none-eabi, and it takes the core and FPU from the same-mcpu,-mfpu,-mfloat-abi. - The newlib headers. arm-none-eabi-gcc supplies
stdlib.handstring.hon its own; they are not in the command. - A clean command. gcc options the analysis does not need (
-O2,-ffunction-sections,-o) are dropped: what remains is macros, paths, the standard, the core and the ABI.
arm-none-eabi-gcc -xc -E -v - shows where newlib is:
/usr/lib/gcc/arm-none-eabi/12.2.1/include
/usr/lib/gcc/arm-none-eabi/12.2.1/include-fixed
/usr/lib/gcc/arm-none-eabi/12.2.1/../../../arm-none-eabi/include
The first two directories are gcc's own headers (stdint.h, stddef.h):
clang has its own versions of those. The third one is newlib. A copy of it with the link resolved
(cp -rL) becomes <sysroot>/include, and --sysroot tells clang
where to look.
All of this is done by an 87-line script; it is also the gate: exit code 1 if there are more findings than the threshold.
#!/usr/bin/env python3
"""analyze.py — прогон анализатора по compile_commands.json сборки arm-none-eabi-gcc.
analyze.py clang <каталог сборки> --sysroot <newlib> [--enable проверка] [--loop K] [--config ключ=значение]
analyze.py gcc <каталог сборки>
общие ключи: --root <исходники> (пути в отчёте — от него), --max N (порог)
Печатает замечания строками «FIND файл:строка проверка | текст», в конце — «TOTAL n».
Код возврата: 0, если замечаний не больше N (по умолчанию 0), иначе 1; 2 — файл не разобран."""
import argparse, json, os, re, shlex, subprocess, sys
# ключи, которые нужны анализатору: макросы, пути, стандарт, ядро и ABI
KEEP = re.compile(r"^(-D|-U|-I|-isystem|-std=|-mcpu=|-mthumb$|-mfloat-abi=|-mfpu=)")
WITH_ARG = {"-D", "-U", "-I", "-isystem", "-include"}
DIAG = re.compile(r"^(?P<file>[^:\s]+):(?P<line>\d+):\d+: warning: (?P<msg>.*?) \[(?P<chk>[^\]]+)\]$")
def to_clang(argv, src, a):
"""Команда gcc → команда clang --analyze: оставить нужные ключи, добавить цель и newlib."""
out, i = [], 1
while i < len(argv):
if argv[i] in WITH_ARG:
out += argv[i:i + 2]
i += 2
continue
if KEEP.match(argv[i]):
out.append(argv[i])
i += 1
extra = []
if a.enable:
extra += ["-Xanalyzer", "-analyzer-checker=" + a.enable]
if a.loop: # сколько раз анализатор проходит цикл
extra += ["-Xclang", "-analyzer-max-loop", "-Xclang", str(a.loop)]
if a.config:
extra += ["-Xclang", "-analyzer-config", "-Xclang", a.config]
return (["clang", "--target=arm-none-eabi", "--sysroot=" + a.sysroot, "--analyze",
"-Xanalyzer", "-analyzer-output=text",
"-Xclang", "-analyzer-config", "-Xclang", "display-checker-name=true"] + extra + out + [src])
def to_gcc_analyzer(argv, src):
"""Та же команда gcc с -fanalyzer; объектный файл не нужен — анализ идёт при компиляции."""
out, i = [argv[0]], 1
while i < len(argv):
if argv[i] == "-o":
i += 2
continue
if argv[i] != src:
out.append(argv[i])
i += 1
return out + ["-fanalyzer", "-o", "/dev/null", src]
def main():
p = argparse.ArgumentParser()
p.add_argument("tool", choices=["clang", "gcc"])
p.add_argument("build")
p.add_argument("--sysroot", default="")
p.add_argument("--enable", default="")
p.add_argument("--loop", type=int, default=0)
p.add_argument("--config", default="")
p.add_argument("--root", default=os.getcwd())
p.add_argument("--max", type=int, default=0)
a = p.parse_args()
db = json.load(open(os.path.join(a.build, "compile_commands.json")))
found = set()
for e in db:
argv = e["arguments"] if "arguments" in e else shlex.split(e["command"])
cmd = to_clang(argv, e["file"], a) if a.tool == "clang" else to_gcc_analyzer(argv, e["file"])
r = subprocess.run(cmd, cwd=e["directory"], capture_output=True, text=True)
if r.returncode != 0 and "error:" in r.stderr:
print("ERROR", e["file"], r.stderr.strip().splitlines()[0])
return 2
for ln in r.stderr.splitlines():
m = DIAG.match(ln)
if m and (a.tool == "clang" or m["chk"].startswith("-Wanalyzer")):
where = os.path.relpath(m["file"], a.root)
found.add("FIND %s:%s %s | %s" % (where, m["line"], m["chk"], m["msg"]))
for f in sorted(found):
print(f)
print("TOTAL %d (порог %d)" % (len(found), a.max))
return 0 if len(found) <= a.max else 1
if __name__ == "__main__":
sys.exit(main())
The gcc mode is simpler: the command is the same, -fanalyzer is added to it, and the object
file goes to /dev/null. The paths in the command database are absolute, so all three
containers see the project and the build at the same paths /w/fw and /w/out.
129б.4 The run: who found what
F103 build, bad variant. Each cell shows the check that fired
and the line it pointed to; "—" means the tool stayed silent.
| Bug | gcc -Wall -Wextra |
gcc -fanalyzer |
clang --analyze |
cppcheck |
|---|---|---|---|---|
| B1 NULL on one branch | — | null-dereference, 45 |
core.NullDereference, 45 |
nullPointerRedundantCheck, 45 |
| B2 leak | — | malloc-leak, 55 |
unix.Malloc, 56 |
memleak, 56 |
B3 double free |
— | double-free, 73 |
unix.Malloc, 73 |
doubleFree, 73 |
| B4 out of bounds | -Waggressive-loop-optimizations, 81 |
— | — | arrayIndexOutOfBounds, 81 |
| B5 uninitialized | -Wmaybe-uninitialized, 101 |
use-of-uninitialized-value, 101 |
core.UndefinedBinaryOperatorResult, 101 |
— |
| B5x the same, other file | — | — | — | — |
| B6 division by zero | — | — | core.DivideZero, 123 |
— |
| B7 race with an interrupt | — | — | — | — |
| F1 (not a bug) | — | — | false: core.NullDereference, 140 |
false: nullPointerRedundantCheck, 140 |
| F2 (not a bug) | — | — | — | false: nullPointerRedundantCheck, 149 |
| other | — | — | — | false ×2: comparePointers, vectors.c 21 and 25 |
Score over the eight real bugs: -Wall -Wextra — 2, -fanalyzer — 4,
clang — 5, cppcheck — 4. Together — 6 of 8: nobody found B5x and B7. False positives:
gcc — 0, clang — 1, cppcheck — 4. The F407 build gave the same table
line for line: the core and the FPU do not affect the findings.
What this shows:
- The tools complement each other, as in 129.5. Only clang found
the division by zero; only cppcheck and gcc found the out-of-bounds write, and gcc did so
not with a bounds warning but as a side effect: at
-O2the optimizer noticed that the 17th loop iteration is undefined behavior. - cppcheck did not see the uninitialized
raw(B5): the path goes through a call toadc_read, which writes nothing through the pointer on its error branch. - Nobody found B5x. Each tool parses one
.cfile at a time: aboutsensor_readfromsensor.cit knows only the declaration. A run of-fanalyzer -fltoover the whole program in a single link found the same 4 bugs and missed B5x too. clang's cross-module mode (CTU) was not run here. - No tool will find B7. The analyzer does not know that
USART1_IRQHandlercan interruptrx_consumebetween the read and the write: to it these are two independent functions. Races with interrupts are left to coding rules (section 30) and review.
129б.5 Why the analyzer stayed silent
A path analyzer has a budget, and the defaults change the result.
A loop is walked only a few times. clang has a bounds check
alpha.security.ArrayBoundV2 (alpha means experimental, off by
default). When enabled, it did not find B4: the analyzer never reaches the 17th iteration of the loop
in hist_clear. It did produce 2 false positives
in Reset_Handler instead — on copying .data and zeroing .bss, where the bounds
are set by the linker script. With the -analyzer-max-loop 17 option B4 is found —
but B1 disappears.
Why B1 disappeared. Spending longer on loops, clang was able to walk
all of find_channel and saw that the NULL came as the return value of a called function.
By default it hides such paths: the suppress-null-return-paths setting
(default true) is, according to the clang help, a heuristic against reports where the path
goes through functions that are "more careful than the caller". With
suppress-null-return-paths=false B1 came back: 6 findings instead of 5.
Conclusion: raising the budget for the sake of one bug means risking others;
a second tool is more reliable.
A check inside a macro. F1 and F2 are the same code: a NULL check,
a call to fault_halt and an access to cfg. In F1 the check is written as an if, and clang
reported a false positive. In F2 the same check is inside the CHECK macro —
and clang stayed silent. cppcheck flagged both places. So clang's silence on
assert-like macros is no proof that noreturn is not needed.
129б.6 False positives and the gate in the pipeline
The best suppression is telling the truth in the code. In the fixed variant
fault.h differs by one line:
/* fault.h — останов с записью номера строки; анализатор знает, что возврата нет */
#ifndef FAULT_H
#define FAULT_H
#include <stdint.h>
__attribute__((noreturn)) void fault_halt(uint32_t line);
#define CHECK(c) do { if (!(c)) { fault_halt(__LINE__); } } while (0)
#endif
With __attribute__((noreturn)) both clang and cppcheck understand that there is no path after
fault_halt: F1 and F2 disappeared for both, while the code of cfg_checksum
and cfg_first did not change by a single character.
The fixed functions:
void channel_set_gain(uint8_t id, uint16_t gain)
{
channel_t *c = find_channel(id);
if (c == NULL) {
app_errors++;
return;
}
if (gain > GAIN_MAX) {
c->errors++;
gain = GAIN_MAX;
}
c->gain = gain;
}
int log_store(const uint8_t *data, uint32_t n)
{
if (n > LOG_MAX) {
return -2;
}
uint8_t *rec = malloc(n + 4u);
if (rec == NULL) {
return -1;
}
memcpy(rec, &n, 4u);
memcpy(&rec[4], data, n);
int rc = uart_write(rec, n + 4u);
free(rec);
return rc;
}
uint16_t average(const uint16_t *v, uint32_t n)
{
if ((v == NULL) || (n == 0u)) {
return 0u;
}
uint32_t sum = 0u;
for (uint32_t i = 0u; i < n; i++) {
sum += v[i];
}
return (uint16_t)(sum / n);
}
void rx_consume(uint32_t n)
{
uint32_t primask = __get_PRIMASK();
__disable_irq();
rx_pending -= n;
__set_PRIMASK(primask);
}
The last one is the fix for B7, which the analysis did not ask for: interrupts are disabled
for the duration of the read-modify-write, and then the previous PRIMASK is restored,
rather than unconditionally enabling them.
When the code is honest but the warning remains. cppcheck believes that &_sdata
and &_edata point to different objects and must not be compared. For the C
language that is true, but the bounds are set by the linker script. Such a place
is suppressed with a comment on the line — naming the check, not everything:
void Reset_Handler(void)
{
/* _sdata.._edata и _sbss.._ebss — границы одной области из скрипта компоновщика */
uint32_t *src = &_sidata;
// cppcheck-suppress comparePointers
for (uint32_t *dst = &_sdata; dst < &_edata; dst++) {
*dst = *src++;
}
// cppcheck-suppress comparePointers
for (uint32_t *dst = &_sbss; dst < &_ebss; dst++) {
*dst = 0u;
}
(void)main();
Default_Handler();
}
The comment only works with the --inline-suppr option: without it cppcheck
reports these 2 warnings on the good variant, with it — 0. On the bad variant
6 remain with --inline-suppr.
The gate. Each pipeline job runs in its own image, and its exit code stops the build:
# Этап анализа для .gitlab-ci.yml (раздел 110). Все задания видят проект по одному пути —
# $CI_PROJECT_DIR, поэтому абсолютные пути из compile_commands.json годятся во всех образах.
stages: [build, analyze]
variables:
BOARD: f103
VARIANT: good
B: $CI_PROJECT_DIR/build/$BOARD
build:
stage: build
image: stm-arm:1
script:
- cmake -S fw -B $B -G Ninja -DCMAKE_TOOLCHAIN_FILE=$CI_PROJECT_DIR/fw/arm-gcc.cmake -DBOARD=$BOARD -DVARIANT=$VARIANT
-DCMSIS_CORE=$CI_PROJECT_DIR/cmsis/core -DCMSIS_DEVICE=$CI_PROJECT_DIR/cmsis/device
- cmake --build $B
- mkdir -p $B/sysroot && cp -rL /usr/lib/arm-none-eabi/include $B/sysroot/
artifacts:
paths: [build/]
gcc-analyzer:
stage: analyze
image: stm-arm:1
needs: [build]
script:
- python3 tools/analyze.py gcc $B --root fw --max 0
clang-analyzer:
stage: analyze
image: stm-sys:1
needs: [build]
script:
- python3 tools/analyze.py clang $B --sysroot $B/sysroot --root fw --max 0
cppcheck:
stage: analyze
image: stm-lint:1
needs: [build]
script:
- cppcheck --project=$B/compile_commands.json --enable=warning,style,performance,portability
--platform=unix32 --inline-suppr --error-exitcode=1 --quiet
The jobs were run one after another the way a runner would run them: each in its own
image, the project mounted at /builds/sa, the build/ directory shared.
| Job | bad |
good |
|---|---|---|
build |
exit 0, 2 findings | exit 0, 0 findings |
gcc-analyzer |
exit 1, 4 findings | exit 0, 0 findings |
clang-analyzer |
exit 1, 6 findings | exit 0, 0 findings |
cppcheck |
exit 1, 6 findings | exit 0, 0 findings |
On bad all three analyzers return exit code 1, on good — 0; the build
with two -Wall warnings passes — -Werror (129.4) is not
enabled here. The --max threshold is for legacy code: the findings that already exist
are accepted as a baseline, and new ones are not let through. On bad with --max 7
clang reports its 6 findings and exit code 0. The threshold is only ever lowered.
129б.7 Pitfalls
clang: 'stdlib.h' file not found. The command from the database is gcc's, and
gcc supplies the newlib headers on its own. You need --sysroot with the
include directory of arm-none-eabi-gcc. Copy it with cp -rL: in Debian this
directory is a symbolic link, and a plain cp -r copies the link itself,
which leads nowhere in another container.
The analyzer cannot find the files from the command database. The paths in
compile_commands.json are absolute. If the build runs in one container and the analysis
in another, mount the project and the build directory at the same paths. In GitLab
all jobs see the project in $CI_PROJECT_DIR.
CMake was unable to find a build program corresponding to "Ninja".
This is what a relative toolchain path looks like: -DCMAKE_TOOLCHAIN_FILE=fw/arm-gcc.cmake
is resolved by CMake from the build directory, the file is not found, no compiler is set —
and the message talks about Ninja. Make the path absolute, through $CI_PROJECT_DIR.
gcc -fanalyzer with -fsyntax-only is silent. The analysis runs together
with compilation; without it there are no findings. Compile to /dev/null.
You enabled an experimental check and got noise.
alpha.security.ArrayBoundV2 gave 2 false positives on this firmware
and did not find B4. Experimental checks are for one-off runs,
not for the gate.
You raised the budget and lost a finding. -analyzer-max-loop 17 found B4
and hid B1 (129б.5). Pin the analyzer settings in the script, otherwise
runs cannot be compared.
The analysis is clean, so there are no races. No: none of the tools sees B7. An empty report means "we did not find what we know how to look for".
129б.8 Summary
compile_commands.jsonfrom the CMake build — 6 commands for the firmware; they drivegcc -fanalyzer,clang --analyzewith--target=arm-none-eabiand--sysrootpointing to newlib, andcppcheck --project.- Of the eight planted bugs:
-Wall -Wextra— 2,-fanalyzer— 4, clang — 5, cppcheck — 4; together — 6 of 8. Only clang found the division by zero, only cppcheck and gcc found the out-of-bounds write. - Nobody found: a bug through a function from another file (B5x, needs cross-module analysis) and a race with an interrupt (B7).
- False positives: clang — 1, cppcheck — 4, gcc — 0.
noreturnonfault_haltremoved F1 and F2 for both; the comparison of addresses from the linker script is handled by acppcheck-suppresscomment and--inline-suppr. - Analyzer settings change the answer:
-analyzer-max-loop 17found B4, but hid B1. - The gate: on
badall three analyzers return exit code 1, ongood— 0; the--maxthreshold is for legacy code, and it is only ever lowered.
Full sources
main.c — the main loop: everything that calls the application functions.
/* main.c — главный цикл: всё, что вызывает прикладные функции */
#include <stdlib.h>
#include <string.h>
#include "board.h"
#include "app.h"
static const cfg_t cfg = { { 1u, 2u, 3u, 4u, 5u, 6u, 7u, 8u } };
int main(void)
{
static const uint8_t hello[] = "sa129b\r\n";
uint16_t samples[4] = { 100u, 200u, 300u, 400u };
uart_init(115200u);
hist_clear();
channel_set_gain(5u, 80u);
(void)log_store(hello, sizeof hello - 1u);
uint8_t *rec = malloc(8u);
if (rec != NULL) {
memcpy(rec, hello, 8u);
(void)log_send(rec, 8u);
}
for (;;) {
uint32_t s = battery_mv(3u) + temp_cdeg(16u) + average(samples, 4u) + cfg_checksum(&cfg) + cfg_first(&cfg);
(void)uart_write((const uint8_t *)&s, sizeof s);
if (rx_pending > 0u) {
rx_consume(rx_pending);
}
}
}
vectors.c — the vector table and startup without assembler.
/* vectors.c — таблица векторов и старт без ассемблера */
#include <stdint.h>
#include "board.h"
extern uint32_t _estack, _sidata, _sdata, _edata, _sbss, _ebss;
int main(void);
void Reset_Handler(void);
void USART1_IRQHandler(void);
static void Default_Handler(void)
{
for (;;) {
}
}
void Reset_Handler(void)
{
/* _sdata.._edata и _sbss.._ebss — границы одной области из скрипта компоновщика */
uint32_t *src = &_sidata;
// cppcheck-suppress comparePointers
for (uint32_t *dst = &_sdata; dst < &_edata; dst++) {
*dst = *src++;
}
// cppcheck-suppress comparePointers
for (uint32_t *dst = &_sbss; dst < &_ebss; dst++) {
*dst = 0u;
}
(void)main();
Default_Handler();
}
__attribute__((section(".isr_vector"), used))
static void (*const vectors[16 + USART1_IRQn + 1])(void) = {
[0] = (void (*)(void))&_estack,
[1] = Reset_Handler,
[2 ... 15] = Default_Handler,
[16 ... 16 + USART1_IRQn - 1] = Default_Handler,
[16 + USART1_IRQn] = USART1_IRQHandler,
};
uart.c — register-level USART1: polled transmit, interrupt-driven receive.
/* uart.c — USART1 на регистрах: передача ожиданием, приём по прерыванию */
#include <stddef.h>
#include "board.h"
#include "app.h"
#define RX_LEN 64u
volatile uint32_t rx_pending;
static volatile uint8_t rx_buf[RX_LEN];
static volatile uint32_t rx_head;
void uart_init(uint32_t baud)
{
#if defined(STM32F407xx)
RCC->AHB1ENR |= RCC_AHB1ENR_GPIOAEN;
RCC->APB2ENR |= RCC_APB2ENR_USART1EN;
GPIOA->MODER = (GPIOA->MODER & ~(GPIO_MODER_MODER9 | GPIO_MODER_MODER10))
| GPIO_MODER_MODER9_1 | GPIO_MODER_MODER10_1; /* PA9, PA10 — AF */
GPIOA->AFR[1] = (GPIOA->AFR[1] & ~0x0FF0u) | 0x0770u; /* AF7 = USART1 */
#else
RCC->APB2ENR |= RCC_APB2ENR_IOPAEN | RCC_APB2ENR_USART1EN;
GPIOA->CRH = (GPIOA->CRH & ~0x0FF0u) | 0x04B0u; /* PA9 AF push-pull, PA10 вход */
#endif
USART1->BRR = (PCLK2_HZ + baud / 2u) / baud;
USART1->CR1 = USART_CR1_UE | USART_CR1_TE | USART_CR1_RE | USART_CR1_RXNEIE;
NVIC_EnableIRQ(USART1_IRQn);
}
int uart_write(const uint8_t *p, uint32_t n)
{
if (p == NULL) {
return -1;
}
for (uint32_t i = 0u; i < n; i++) {
while ((USART1->SR & USART_SR_TXE) == 0u) {
}
USART1->DR = p[i];
}
return 0;
}
void USART1_IRQHandler(void)
{
if ((USART1->SR & USART_SR_RXNE) != 0u) {
rx_buf[rx_head] = (uint8_t)USART1->DR;
rx_head = (rx_head + 1u) % RX_LEN;
rx_pending++;
}
}
sensor.c — reading an ADC channel.
/* sensor.c — чтение канала АЦП; на неверном канале *out не трогает */
#include "board.h"
#include "app.h"
int sensor_read(uint32_t ch, uint16_t *out)
{
if (ch > 17u) {
return -1;
}
*out = (uint16_t)(ADC1->DR & 0x0FFFu);
return 0;
}
fault.c — record the line and wait for a reset by the watchdog.
/* fault.c — запомнить строку и ждать сброса сторожевым таймером */
#include "board.h"
#include "fault.h"
volatile uint32_t fault_line;
void fault_halt(uint32_t line)
{
fault_line = line;
__disable_irq();
for (;;) {
}
}
app.c — the fixed variant (the good directory): the same application functions without bugs B1…B7.
/* app.c (good) — те же функции, ошибки B1…B7 исправлены */
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include "board.h"
#include "app.h"
#include "fault.h"
#define N_CHANNELS 4u
#define GAIN_MAX 64u
#define LOG_MAX 32u
#define HIST_LEN 16u
typedef struct {
uint8_t id;
uint16_t gain;
uint16_t errors;
} channel_t;
static channel_t channels[N_CHANNELS] = { { 1u, 1u, 0u }, { 2u, 1u, 0u }, { 5u, 1u, 0u }, { 7u, 1u, 0u } };
static uint16_t hist[HIST_LEN];
static uint32_t app_errors;
static channel_t *find_channel(uint8_t id)
{
for (uint32_t i = 0u; i < N_CHANNELS; i++) {
if (channels[i].id == id) {
return &channels[i];
}
}
return NULL;
}
void channel_set_gain(uint8_t id, uint16_t gain)
{
channel_t *c = find_channel(id);
if (c == NULL) {
app_errors++;
return;
}
if (gain > GAIN_MAX) {
c->errors++;
gain = GAIN_MAX;
}
c->gain = gain;
}
int log_store(const uint8_t *data, uint32_t n)
{
if (n > LOG_MAX) {
return -2;
}
uint8_t *rec = malloc(n + 4u);
if (rec == NULL) {
return -1;
}
memcpy(rec, &n, 4u);
memcpy(&rec[4], data, n);
int rc = uart_write(rec, n + 4u);
free(rec);
return rc;
}
int log_send(uint8_t *rec, uint32_t n)
{
int rc = uart_write(rec, n);
if (rc != 0) {
app_errors++;
}
free(rec);
return rc;
}
void hist_clear(void)
{
for (uint32_t i = 0u; i < HIST_LEN; i++) {
hist[i] = 0u;
}
}
static int adc_read(uint32_t ch, uint16_t *out)
{
if (ch > 17u) {
return -1;
}
*out = (uint16_t)(ADC1->DR & 0x0FFFu);
return 0;
}
uint32_t battery_mv(uint32_t ch)
{
uint16_t raw;
if (adc_read(ch, &raw) != 0) {
app_errors++;
return 0u;
}
return (uint32_t)raw * 3300u / 4095u;
}
uint32_t temp_cdeg(uint32_t ch)
{
uint16_t raw = 0u;
if (sensor_read(ch, &raw) != 0) {
app_errors++;
return 0u;
}
return (uint32_t)raw * 100u / 41u;
}
uint16_t average(const uint16_t *v, uint32_t n)
{
if ((v == NULL) || (n == 0u)) {
return 0u;
}
uint32_t sum = 0u;
for (uint32_t i = 0u; i < n; i++) {
sum += v[i];
}
return (uint16_t)(sum / n);
}
void rx_consume(uint32_t n)
{
uint32_t primask = __get_PRIMASK();
__disable_irq();
rx_pending -= n;
__set_PRIMASK(primask);
}
uint32_t cfg_checksum(const cfg_t *cfg)
{
if (cfg == NULL) {
fault_halt(__LINE__);
}
uint32_t s = 0u;
for (uint32_t i = 0u; i < CFG_WORDS; i++) {
s += cfg->w[i];
}
return s;
}
uint32_t cfg_first(const cfg_t *cfg)
{
CHECK(cfg != NULL);
return cfg->w[0];
}
Next: section 130 "A board for a microcontroller: principles".