Testing Microcontroller Firmware with Python

Last Year

Acceptance Tests
System Tests
Integration Tests
Unit Tests

This Year

Acceptance Tests
System Tests
Integration Tests
Unit Tests

Motivation

  • 500k compiled code
  • 1000s of test cases
  • several hours to run on real device
  • need fast feedback

Concept

Application
HAL
Application
Python

Why Python?

  • Easy to write
  • Easy to use
  • Very powerful
cat application/*.c
CFFI gcc python
cat hal/*.h

Example: MicroPython

MicroPython Structure

In [3]:
%ls micropython/
ACKNOWLEDGEMENTS    docs/      lib/        pic16bit/  teensy/   zephyr/
bare-arm/           drivers/   LICENSE     py/        tests/
cc3200/             esp8266/   logo/       qemu-arm/  tools/
CODECONVENTIONS.md  examples/  minimal/    README.md  unix/
CONTRIBUTING.md     extmod/    mpy-cross/  stmhal/    windows/
In [4]:
%ls micropython/minimal/
frozentest.mpy  main.c    mpconfigport.h  qstrdefsport.h  stm32f405.ld
frozentest.py   Makefile  mphalport.h     README.md       uart_core.c
In [5]:
!tail -n29 micropython/minimal/uart_core.c
// Receive single character
int mp_hal_stdin_rx_chr(void) {
    unsigned char c = 0;
#if MICROPY_MIN_USE_STDOUT
    int r = read(0, &c, 1);
    (void)r;
#elif MICROPY_MIN_USE_STM32_MCU
    // wait for RXNE
    while ((USART1->SR & (1 << 5)) == 0) {
    }
    c = USART1->DR;
#endif
    return c;
}

// Send string of given length
void mp_hal_stdout_tx_strn(const char *str, mp_uint_t len) {
#if MICROPY_MIN_USE_STDOUT
    int r = write(1, str, len);
    (void)r;
#elif MICROPY_MIN_USE_STM32_MCU
    while (len--) {
        // wait for TXE
        while ((USART1->SR & (1 << 7)) == 0) {
        }
        USART1->DR = *str++;
    }
#endif
}

pymake

In [6]:
!grep 'INC ' micropython/minimal/Makefile
INC += -I.
INC += -I..
INC += -I../stmhal
INC += -I$(BUILD)
In [7]:
makefile = pymake.data.Makefile('micropython/minimal')
makefile.include('Makefile')
Use make V=1 or set BUILD_VERBOSE in your environment to increase build verbosity.
In [8]:
makefile.variables.get('INC')[2]
Out[8]:
<Expansion with elements: ['-I. -I.. -I../stmhal -I', VariableRef<Expansion of variables 'INC':1:0:1:23>(Exp<None>('BUILD'))]>
In [9]:
_.resolvestr(makefile, makefile.variables)
Out[9]:
'-I. -I.. -I../stmhal -Ibuild'
In [10]:
def resolve_variable(makefile, name):
    return makefile.variables.get(name)[2].resolvestr(makefile, makefile.variables).split()
In [11]:
!grep 'INC ' micropython/minimal/Makefile
INC += -I.
INC += -I..
INC += -I../stmhal
INC += -I$(BUILD)
In [12]:
resolve_variable(makefile, 'INC')
Out[12]:
['-I.', '-I..', '-I../stmhal', '-Ibuild']
In [13]:
include_options = resolve_variable(makefile, 'INC') + ['-I../extmod']
cat application/*.c
CFFI gcc python
cat hal/*.h

Collecting Source Code

In [14]:
%cd -q micropython/minimal
In [15]:
!grep -A7 'SRC_C ' Makefile
SRC_C = \
	main.c \
	uart_core.c \
	lib/utils/stdout_helpers.c \
	lib/utils/pyexec.c \
	lib/libc/string0.c \
	lib/mp-readline/readline.c \
	$(BUILD)/_frozen_mpy.c \
In [16]:
resolve_variable(makefile, 'SRC_C')
Out[16]:
['main.c',
 'uart_core.c',
 'lib/utils/stdout_helpers.c',
 'lib/utils/pyexec.c',
 'lib/libc/string0.c',
 'lib/mp-readline/readline.c',
 'build/_frozen_mpy.c']
In [17]:
source_files = set(x.replace('lib/', '../lib/') for x in resolve_variable(makefile, 'SRC_C'))
object_files = resolve_variable(makefile, 'PY_O_BASENAME')
source_files |= {
    os.path.join('..', 'py', x.replace('.o', '.c')) for x in object_files
}
source_files.remove('uart_core.c')
source_files.remove('../lib/libc/string0.c')
In [18]:
!grep -m2 -B1 USART1- uart_core.c
    // wait for RXNE
    while ((USART1->SR & (1 << 5)) == 0) {
    }
    c = USART1->DR;
In [19]:
resolve_variable(makefile, 'SRC_C')
Out[19]:
['main.c',
 'uart_core.c',
 'lib/utils/stdout_helpers.c',
 'lib/utils/pyexec.c',
 'lib/libc/string0.c',
 'lib/mp-readline/readline.c',
 'build/_frozen_mpy.c']
In [20]:
!LANG=C ls build/_frozen_mpy.c
ls: cannot access 'build/_frozen_mpy.c': No such file or directory
In [21]:
subprocess.run(['make', 'build/_frozen_mpy.c'], check=True);
In [22]:
source_content = ''.join(
    open(f).read() for f in sorted(source_files) if os.path.exists(f)
)
source_content = source_content.replace('int main(', 'int mpmain(')
cat application/*.c
CFFI gcc python
cat hal/*.h

Collecting Header Files

In [23]:
header_files = {os.path.join('..', 'py', 'mphal.h')}
In [25]:
header_content = '#define __attribute__(x)\n#define mp_hal_pin_obj_t void*\n'
header_content += ''.join(open(f).read() for f in header_files)
header_content = preprocess(header_content)
In [26]:
def preprocess(source):
    return subprocess.check_output(['gcc'] + include_options + ['-E', '-P', '-'], input=source, universal_newlines=True)
In [27]:
sorted(header_content.split('\n'))[-3:]
Out[27]:
['void mp_hal_stdout_tx_str(const char *str);',
 'void mp_hal_stdout_tx_strn(const char *str, size_t len);',
 'void mp_hal_stdout_tx_strn_cooked(const char *str, size_t len);']
extern "Python+C" void mp_hal_stdout_tx_str(const char *str);
In [28]:
class HeaderGenerator(pycparser.c_generator.CGenerator):
    functions = set()

    def visit_Decl(self, n, *args, **kwargs):
        result = super().visit_Decl(n, *args, **kwargs)
        if isinstance(n.type, pycparser.c_ast.FuncDecl):
            if n.name in self.functions or result in source_content:
                return ''
            self.functions.add(n.name)
            return 'extern "Python+C" ' + result
        return result

    def visit_FuncDef(self, n, *args, **kwargs):
        self.functions.add(n.decl.name)
        return ''
In [29]:
ast = pycparser.CParser().parse(header_content)
header_content = HeaderGenerator().visit(ast)
In [30]:
sorted(header_content.split('\n'))[8:11]
Out[30]:
['extern "Python+C" mp_uint_t mp_hal_ticks_us(void);',
 'extern "Python+C" void mp_hal_delay_ms(mp_uint_t ms);',
 'extern "Python+C" void mp_hal_delay_us(mp_uint_t us);']
In [31]:
header_content += 'int mpmain(int argc, char **argv);'
cat application/*.c
CFFI gcc python
cat hal/*.h

Calling CFFI

In [32]:
ffibuilder = cffi.FFI()
ffibuilder.cdef(header_content)
ffibuilder.set_source('mpsim', source_content, include_dirs=[x.replace('-I', '') for x in include_options])
ffibuilder.compile();
cat application/*.c
CFFI gcc python
cat hal/*.h

Running the Code

In [33]:
import mpsim
In [34]:
@mpsim.ffi.def_extern()
def mp_hal_stdin_rx_chr():
    return ord(sys.stdin.read(1))
In [35]:
@mpsim.ffi.def_extern()
def mp_hal_stdout_tx_strn(data, length):
    print(bytes(mpsim.ffi.buffer(data, length)).decode(), end='', flush=True)
cat application/*.c
CFFI gcc python
cat hal/*.h

Challenges

Code Structure

$ ls -l bad/
-rw-r--r-- 1 user user 397789  1. Jan  1970  main.c
$ ls -l good/
drwxr-xr-x 2 user user 4096  1. Jan  1970  application
drwxr-xr-x 2 user user 4096  1. Jan  1970  hal

Namespaces

$ cat application/file1.c
static void do_something(void) {
    ...
}
$ cat application/file2.c
static void do_something(void) {
    ...
}
$ cat application/file1.c
static void file1_do_something(void) {
    ...
}
$ cat application/file2.c
static void file2_do_something(void) {
    ...
}

Platform-dependent Code

struct {
    unsigned short major_version;
    unsigned int minor_version;
} data;

version.major = 1234;
version.minor = 567890;

checksum = sha256(&data, sizeof(data));
struct {
    uint16_t major_version;
    uint32_t minor_version;
} __attribute__((packed)) data;

version.major = htons(1234);
version.minor = htonl(567890);

checksum = sha256(&data, sizeof(data));

Interrupts

  • Not supported :(

External Interface

Application Application
HAL Python
Hardware Code
Network

Benefits

Fast Execution

Dynamic Program Analysis

AdressSanitizer (ASan)

In [39]:
ffibuilder.set_source('mpsim', source_content, extra_compile_args=['-fsanitize-address'], libraries=['asan'])

American Fuzzy Lop (afl)

In [40]:
os.environ['CC'] = 'afl-gcc'
In [ ]:
import afl
stdin = sys.stdin.detach()
while afl.loop(10000):
    application.lib.run(stdin.read())

Hardware Independence

Thank You!

Questions?