Because you have an ELF executable, this probably precludes "funny" architectures (e.g. Intel 8051, PIC, etc.) that might have segmented or non-linear, non-contiguous address spaces.
So, you [probably] can use the method you've described with main to get the actual address. You just need to convert to/from either char * or uintptr_t types so you are using byte offsets/differences.
But, you can also create a unified table of pointers to the various functions using by creating descriptor structs that are placed in a special linker section of your choosing using (e.g.) __attribute__((section("mysection"))
Here is some code that shows what I mean:
#include <stdio.h>
typedef struct {
    int (*test_func)(void *);           // pointer to test function
    const char *test_name;              // name of the test
    int test_retval;                    // test return value
    // more data ...
    int test_xtra;
} testctl_t;
// define a struct instance for a given test
#define ATTACH_TEST(_func) \
    testctl_t _func##_ctl __attribute__((section("testctl"))) = { \
        .test_func = _func, \
        .test_name = #_func \
    }
// advance to next struct (must be 16 byte aligned)
#define TESTNEXT(_test) \
    (testctl_t *) (((char *) _test) + asiz)
int
test_abc(void *t)
{
    printf("test_abc: hello\n");
    return 1;
}
ATTACH_TEST(test_abc);
int
test_def(void *t)
{
    printf("test_def: hello\n");
    return 2;
}
ATTACH_TEST(test_def);
int
main(void)
{
    // these are special symbols defined by the linker for our special linker
    // section that denote the start/end of the section (similar to
    // _etext/_edata)
    extern testctl_t __start_testctl;
    extern testctl_t __stop_testctl;
    size_t rsiz = sizeof(testctl_t);
    size_t asiz;
    testctl_t *test;
    // align the size to a 16 byte boundary
    asiz = rsiz;
    asiz += 15;
    asiz /= 16;
    asiz *= 16;
    // show the struct sizes
    printf("main: sizeof(testctl_t)=%zx/%zx\n",rsiz,asiz);
    // section start and stop symbol addresses
    printf("main: start=%p stop=%p\n",&__start_testctl,&__stop_testctl);
    // cross check of expected pointer values
    printf("main: test_abc=%p test_abc_ctl=%p\n",test_abc,&test_abc_ctl);
    printf("main: test_def=%p test_def_ctl=%p\n",test_def,&test_def_ctl);
    for (test = &__start_testctl;  test < &__stop_testctl;
        test = TESTNEXT(test)) {
        printf("\n");
        // show the address of our test descriptor struct and the pointer to
        // the function
        printf("main: test=%p test_func=%p\n",test,test->test_func);
        printf("main: calling %s ...\n",test->test_name);
        test->test_retval = test->test_func(test);
        printf("main: return is %d\n",test->test_retval);
    }
    return 0;
}
Here is the program output:
main: sizeof(testctl_t)=18/20
main: start=0x404040 stop=0x404078
main: test_abc=0x401146 test_abc_ctl=0x404040
main: test_def=0x401163 test_def_ctl=0x404060
main: test=0x404040 test_func=0x401146
main: calling test_abc ...
test_abc: hello
main: return is 1
main: test=0x404060 test_func=0x401163
main: calling test_def ...
test_def: hello
main: return is 2